viewer: Ships browser (View ▸ Ships) — assemble & view whole capital ships
Adds a Ships window that scans the stage containers, reconstructs each capital ship from its XBG7 part family (via sylpheed_formats::ship), and renders the whole assembled model in the 3D view on click. - iso_loader: ShipBrowser resource + ShipRow (id, faction, name, parts, stage, and linked Vessel stats: HP/size/turrets/bridges/shield gens), RequestShipCatalog + RequestShipRender events, ShipCatalogLoaded message. build_ship_catalog scans Stage_S01..S30, groups resources into ships, joins Vessel recipes by id, and sorts capital ships (Vessel-matched) first. handle_ship_render_request decodes only the ship's parts (models_named) and assembles them off-thread, reusing the existing XprPrepared → apply_prepared_xpr render + camera-focus path. - prepare_models gains an `assemble` mode (prepare_ship): parts share ONE recentre so each keeps its ship-local offset (bridge fore, engines aft) instead of the per-model recentre / thumbnail grid used for a stage browse. - ui: draw_ships_ui (own system, keeps draw_viewer_ui under the 16-param limit) — faction filter chips, name/id search, collapsible per-ship stat cards with an "Assemble & view in 3D" button; colour-coded by faction. View ▸ Ships menu item. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,8 @@ use bevy_egui::{egui, EguiContexts};
|
||||
use crate::iso_loader::{
|
||||
AudioPreview, FileInfo, FileSelected, GameCategory, GameData, ImageRgba, IsoState, ModelPreview,
|
||||
MovieSubtitles, MovieVoice, PakContent, PakView, RequestAudio, RequestGameData, RequestOpenDir,
|
||||
RequestOpenIso, RequestSubtitles, RequestVoiceLibrary, SkyboxPreview, TextPreview, TexturePreview,
|
||||
RequestOpenIso, RequestShipCatalog, RequestShipRender, RequestSubtitles, RequestVoiceLibrary,
|
||||
ShipBrowser, SkyboxPreview, TextPreview, TexturePreview,
|
||||
VideoPreview, VoiceLibrary, IsoLoaderSystemSet,
|
||||
};
|
||||
use crate::ViewerState;
|
||||
@@ -26,6 +27,7 @@ impl Plugin for ViewerUiPlugin {
|
||||
// Run after the iso_loader chain so we see the frame's final state.
|
||||
app.add_systems(Update, draw_viewer_ui.after(IsoLoaderSystemSet));
|
||||
app.add_systems(Update, draw_game_data_ui.after(IsoLoaderSystemSet));
|
||||
app.add_systems(Update, draw_ships_ui.after(IsoLoaderSystemSet));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +140,7 @@ struct UiEvents<'w> {
|
||||
audio: EventWriter<'w, RequestAudio>,
|
||||
voice_lib: EventWriter<'w, RequestVoiceLibrary>,
|
||||
game_data: EventWriter<'w, RequestGameData>,
|
||||
ships: EventWriter<'w, RequestShipCatalog>,
|
||||
}
|
||||
|
||||
fn draw_viewer_ui(
|
||||
@@ -201,6 +204,12 @@ fn draw_viewer_ui(
|
||||
events.game_data.send_default();
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("🚀 Ships…").clicked() {
|
||||
// The Ships window (its own system) reads this event to open +
|
||||
// lazily scan the stage containers for assemblable ships.
|
||||
events.ships.send_default();
|
||||
ui.close_menu();
|
||||
}
|
||||
});
|
||||
|
||||
ui.menu_button("Help", |ui| {
|
||||
@@ -1521,3 +1530,155 @@ fn draw_game_data_ui(
|
||||
});
|
||||
game_data.open &= open;
|
||||
}
|
||||
|
||||
// ── Ships browser ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// The Ships browser — a floating window over the capital ships reconstructed
|
||||
/// from XBG7 part families. Picking a ship assembles its parts and renders the
|
||||
/// whole model in the 3D view. Its own system (keeps `draw_viewer_ui` under the
|
||||
/// parameter limit); a `RequestShipCatalog` event (View menu) opens it and lazily
|
||||
/// kicks off the stage scan, while clicking a row fires `RequestShipRender`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn draw_ships_ui(
|
||||
mut contexts: EguiContexts,
|
||||
mut ships: ResMut<ShipBrowser>,
|
||||
mut requests: EventReader<RequestShipCatalog>,
|
||||
mut render: EventWriter<RequestShipRender>,
|
||||
) {
|
||||
if requests.read().next().is_some() {
|
||||
ships.open = true;
|
||||
}
|
||||
if !ships.open {
|
||||
return;
|
||||
}
|
||||
let ctx = contexts.ctx_mut().clone();
|
||||
let mut open = true;
|
||||
egui::Window::new("🚀 Ships")
|
||||
.default_width(560.0)
|
||||
.default_height(560.0)
|
||||
.open(&mut open)
|
||||
.show(&ctx, |ui| {
|
||||
if ships.loading {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label("Scanning stage models for ships…");
|
||||
});
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
if !ships.loaded {
|
||||
ui.label("Open a game source first (File ▸ Open…).");
|
||||
return;
|
||||
}
|
||||
|
||||
// Faction filter chips.
|
||||
ui.horizontal(|ui| {
|
||||
for (label, val) in
|
||||
[("All", ""), ("ADAN", "ADAN"), ("TCAF", "TCAF"), ("Neutral", "Neutral")]
|
||||
{
|
||||
let sel = ships.faction == val;
|
||||
if ui.selectable_label(sel, label).clicked() {
|
||||
ships.faction = val.to_string();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("🔍");
|
||||
ui.text_edit_singleline(&mut ships.filter);
|
||||
if !ships.filter.is_empty() && ui.small_button("✖").clicked() {
|
||||
ships.filter.clear();
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
let ShipBrowser { rows, filter, faction, selected, .. } = &mut *ships;
|
||||
let needle = filter.to_lowercase();
|
||||
let mut to_render: Option<(String, String, Vec<String>, String)> = None;
|
||||
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
let mut last_section = String::new();
|
||||
for row in rows.iter() {
|
||||
if !faction.is_empty() && &row.faction != faction {
|
||||
continue;
|
||||
}
|
||||
if !needle.is_empty()
|
||||
&& !row.name.to_lowercase().contains(&needle)
|
||||
&& !row.id.contains(&needle)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Section header: capital ships first, then other assemblies.
|
||||
let section = if row.has_vessel { "Capital ships" } else { "Other assemblies" };
|
||||
if section != last_section {
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(section).strong().weak());
|
||||
last_section = section.to_string();
|
||||
}
|
||||
|
||||
let is_sel = selected.as_deref() == Some(row.id.as_str());
|
||||
let color = match row.faction.as_str() {
|
||||
"TCAF" => egui::Color32::from_rgb(120, 170, 235),
|
||||
"ADAN" => egui::Color32::from_rgb(235, 120, 120),
|
||||
_ => egui::Color32::from_rgb(180, 180, 180),
|
||||
};
|
||||
egui::CollapsingHeader::new(
|
||||
egui::RichText::new(format!("{} · {}", row.name, row.id)).color(color),
|
||||
)
|
||||
.id_salt(&row.id)
|
||||
.default_open(false)
|
||||
.show(ui, |ui| {
|
||||
egui::Grid::new(("shipstat", &row.id)).num_columns(2).show(ui, |ui| {
|
||||
ui.label("Faction");
|
||||
ui.strong(&row.faction);
|
||||
ui.end_row();
|
||||
if let Some(hp) = row.hp {
|
||||
ui.label("Hull HP");
|
||||
ui.strong(format!("{hp:.0}"));
|
||||
ui.end_row();
|
||||
}
|
||||
if let Some((x, y, z)) = row.size {
|
||||
ui.label("Size (m)");
|
||||
ui.strong(format!("{x:.0} × {y:.0} × {z:.0}"));
|
||||
ui.end_row();
|
||||
}
|
||||
if row.turrets.is_some() || row.shield_gens.is_some() {
|
||||
ui.label("Hardpoints");
|
||||
ui.strong(format!(
|
||||
"{} turrets · {} bridges · {} shield gens",
|
||||
fint(row.turrets),
|
||||
fint(row.bridges),
|
||||
fint(row.shield_gens),
|
||||
));
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label("Model parts");
|
||||
ui.strong(format!("{}", row.parts.len()));
|
||||
ui.end_row();
|
||||
ui.label("Appears in");
|
||||
ui.strong(row.stages.join(", "));
|
||||
ui.end_row();
|
||||
});
|
||||
let btn = egui::Button::new(if is_sel {
|
||||
"● Showing in 3D view"
|
||||
} else {
|
||||
"▶ Assemble & view in 3D"
|
||||
});
|
||||
if ui.add(btn).clicked() {
|
||||
to_render = Some((
|
||||
row.id.clone(),
|
||||
row.stage_file.clone(),
|
||||
row.parts.clone(),
|
||||
format!("{} ({})", row.name, row.id),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if let Some((id, file, parts, label)) = to_render {
|
||||
*selected = Some(id);
|
||||
render.send(RequestShipRender { file, parts, label });
|
||||
}
|
||||
});
|
||||
ships.open &= open;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user