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:
@@ -586,6 +586,61 @@ pub struct GameData {
|
||||
#[derive(Event, Default)]
|
||||
pub struct RequestGameData;
|
||||
|
||||
/// One assemblable ship in the Ships browser: an XBG7 part family reconstructed
|
||||
/// into a whole model, with any linked [`sylpheed_formats::game_data::Vessel`]
|
||||
/// stats.
|
||||
#[derive(Clone)]
|
||||
pub struct ShipRow {
|
||||
/// Resource-family id, e.g. `e106`.
|
||||
pub id: String,
|
||||
/// `ADAN` / `TCAF` / `Neutral` / `Other`.
|
||||
pub faction: String,
|
||||
/// Display name (from the linked Vessel, else the id).
|
||||
pub name: String,
|
||||
/// The stage container to render from, e.g. `hidden/resource3d/Stage_S02.xpr`.
|
||||
pub stage_file: String,
|
||||
/// Stage labels the ship appears in (`S02`, `S04`).
|
||||
pub stages: Vec<String>,
|
||||
/// Base part resource names to draw together.
|
||||
pub parts: Vec<String>,
|
||||
/// True when the id matched a `Vessel` recipe (a real capital ship).
|
||||
pub has_vessel: bool,
|
||||
pub hp: Option<f32>,
|
||||
pub size: Option<(f32, f32, f32)>,
|
||||
pub turrets: Option<i64>,
|
||||
pub bridges: Option<i64>,
|
||||
pub shield_gens: Option<i64>,
|
||||
}
|
||||
|
||||
/// The Ships browser state — capital ships assembled from XBG7 part families.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct ShipBrowser {
|
||||
pub open: bool,
|
||||
pub loading: bool,
|
||||
pub loaded: bool,
|
||||
pub filter: String,
|
||||
/// Faction filter: "" = all, else "ADAN"/"TCAF"/"Neutral".
|
||||
pub faction: String,
|
||||
pub rows: Vec<ShipRow>,
|
||||
/// Family id of the ship currently rendered (for row highlight).
|
||||
pub selected: Option<String>,
|
||||
}
|
||||
|
||||
/// Ask the loader to scan the stage containers and build the ship catalog.
|
||||
#[derive(Event, Default)]
|
||||
pub struct RequestShipCatalog;
|
||||
|
||||
/// Ask the loader to assemble + render one ship's part family.
|
||||
#[derive(Event)]
|
||||
pub struct RequestShipRender {
|
||||
/// Stage container path to read the parts from.
|
||||
pub file: String,
|
||||
/// Base part resource names to decode + assemble.
|
||||
pub parts: Vec<String>,
|
||||
/// Display label for the model info line.
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// The currently-open IPFB data pack, shown as a master-detail browser.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct PakView {
|
||||
@@ -676,6 +731,8 @@ enum IsoLoaderMsg {
|
||||
VoiceLibraryLoaded {
|
||||
clips: Vec<sylpheed_formats::slb::VoiceClip>,
|
||||
},
|
||||
/// The assembled-ship catalog for the Ships browser.
|
||||
ShipCatalogLoaded(Vec<ShipRow>),
|
||||
/// The decoded game-data tables for the browser.
|
||||
GameDataLoaded(Box<GameSnapshot>),
|
||||
/// User dismissed the file dialog — not an error.
|
||||
@@ -896,11 +953,14 @@ impl Plugin for IsoLoaderPlugin {
|
||||
.init_resource::<AudioPreview>()
|
||||
.init_resource::<VoiceLibrary>()
|
||||
.init_resource::<GameData>()
|
||||
.init_resource::<ShipBrowser>()
|
||||
.add_event::<RequestSubtitles>()
|
||||
.add_event::<RequestVoice>()
|
||||
.add_event::<RequestAudio>()
|
||||
.add_event::<RequestVoiceLibrary>()
|
||||
.add_event::<RequestGameData>();
|
||||
.add_event::<RequestGameData>()
|
||||
.add_event::<RequestShipCatalog>()
|
||||
.add_event::<RequestShipRender>();
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -930,6 +990,8 @@ impl Plugin for IsoLoaderPlugin {
|
||||
advance_audio_playback,
|
||||
handle_voice_library_request,
|
||||
handle_game_data_request,
|
||||
handle_ship_catalog_request,
|
||||
handle_ship_render_request,
|
||||
)
|
||||
.chain()
|
||||
.in_set(IsoLoaderSystemSet),
|
||||
@@ -1770,6 +1832,7 @@ fn poll_loader_channel(
|
||||
mut audio_preview: ResMut<AudioPreview>,
|
||||
mut voice_lib: ResMut<VoiceLibrary>,
|
||||
mut game_data: ResMut<GameData>,
|
||||
mut ships: ResMut<ShipBrowser>,
|
||||
) {
|
||||
let receiver = channels.receiver.lock().unwrap();
|
||||
loop {
|
||||
@@ -1894,6 +1957,11 @@ fn poll_loader_channel(
|
||||
game_data.loaded = true;
|
||||
}
|
||||
}
|
||||
Ok(IsoLoaderMsg::ShipCatalogLoaded(rows)) => {
|
||||
ships.loading = false;
|
||||
ships.loaded = !rows.is_empty();
|
||||
ships.rows = rows;
|
||||
}
|
||||
Ok(IsoLoaderMsg::Cancelled) => {
|
||||
iso_state.loading = false;
|
||||
browser.loading = false;
|
||||
@@ -2321,6 +2389,25 @@ fn pack_metallic_roughness(spc: Option<&Image>, gls: Option<&Image>) -> Option<I
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) -> PreparedXpr {
|
||||
prepare_models_impl(bytes, models, false)
|
||||
}
|
||||
|
||||
/// Assemble several XBG7 resources into ONE whole model in a shared coordinate
|
||||
/// frame — the capital-ship case, where each part (`e106_bdy_01`, `e106_brg_01`,
|
||||
/// …) already carries its ship-local position. Unlike the stage path, the parts
|
||||
/// are NOT laid out in a grid and are recentred by a single shared bbox so their
|
||||
/// relative placement is preserved. See [`sylpheed_formats::ship`].
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn prepare_ship(bytes: &[u8], parts: &[sylpheed_formats::mesh::Xbg7Model]) -> PreparedXpr {
|
||||
prepare_models_impl(bytes, parts, true)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn prepare_models_impl(
|
||||
bytes: &[u8],
|
||||
models: &[sylpheed_formats::mesh::Xbg7Model],
|
||||
assemble: bool,
|
||||
) -> PreparedXpr {
|
||||
use bevy::render::mesh::{Indices, PrimitiveTopology};
|
||||
use sylpheed_formats::texture::X360Texture;
|
||||
|
||||
@@ -2328,7 +2415,9 @@ fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) ->
|
||||
const GAP: f32 = 4.0;
|
||||
const MAX_EXTENT: f32 = 20_000.0; // drop skybox planes / stray-vertex anchors
|
||||
let pitch = CELL + GAP;
|
||||
let is_stage = models.len() > 1;
|
||||
// Grid layout only for a true multi-model stage browse — an assembled ship
|
||||
// keeps its parts in one shared space.
|
||||
let is_stage = models.len() > 1 && !assemble;
|
||||
|
||||
// Keep models with real, bounded geometry.
|
||||
let drawn: Vec<&sylpheed_formats::mesh::Xbg7Model> = models
|
||||
@@ -2425,6 +2514,25 @@ fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) ->
|
||||
let mut total_v = 0usize;
|
||||
let mut total_t = 0usize;
|
||||
|
||||
// Assembled ship: one shared recentre across ALL parts so each part keeps its
|
||||
// ship-local offset (bridge fore, engines aft). Computed from the raw parts —
|
||||
// ship parts carry no node transform, so raw == placed.
|
||||
let shared_center = if assemble {
|
||||
let mut lo = Vec3::splat(f32::MAX);
|
||||
let mut hi = Vec3::splat(f32::MIN);
|
||||
for m in &drawn {
|
||||
for sub in &m.meshes {
|
||||
for p in &sub.positions {
|
||||
lo = lo.min(Vec3::from_array(*p));
|
||||
hi = hi.max(Vec3::from_array(*p));
|
||||
}
|
||||
}
|
||||
}
|
||||
(lo.x <= hi.x).then(|| (lo + hi) * 0.5)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
for (i, model) in drawn.iter().enumerate() {
|
||||
// Per-model bbox → recentre + (stage) uniform scale to the grid cell.
|
||||
let mut lo = Vec3::splat(f32::MAX);
|
||||
@@ -2438,7 +2546,9 @@ fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) ->
|
||||
if lo.x > hi.x {
|
||||
continue;
|
||||
}
|
||||
let center = (lo + hi) * 0.5;
|
||||
// An assembled ship uses the shared centre (preserve relative placement);
|
||||
// otherwise recentre each model on its own bbox.
|
||||
let center = shared_center.unwrap_or((lo + hi) * 0.5);
|
||||
let (scale, cell) = if is_stage {
|
||||
let extent = (hi - lo).max_element().max(1e-3);
|
||||
let col = i % cols;
|
||||
@@ -2601,7 +2711,17 @@ fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) ->
|
||||
} else {
|
||||
CELL
|
||||
};
|
||||
let (name, info) = if is_stage {
|
||||
let (name, info) = if assemble {
|
||||
(
|
||||
"ship".to_string(),
|
||||
format!(
|
||||
"Assembled ship · {} parts · {} verts · {} tris",
|
||||
drawn.len(),
|
||||
total_v,
|
||||
total_t
|
||||
),
|
||||
)
|
||||
} else if is_stage {
|
||||
(
|
||||
"stage".to_string(),
|
||||
format!(
|
||||
@@ -3666,6 +3786,196 @@ fn build_game_snapshot(source: &SourceKind) -> Option<GameSnapshot> {
|
||||
Some(GameSnapshot { weapons, craft, vessels, characters, missions, arsenal, flights })
|
||||
}
|
||||
|
||||
/// Handles a [`RequestShipCatalog`]: scans the stage containers and reconstructs
|
||||
/// the assemblable ship catalog off-thread.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn handle_ship_catalog_request(
|
||||
mut events: EventReader<RequestShipCatalog>,
|
||||
iso_state: Res<IsoState>,
|
||||
channels: Res<IsoChannels>,
|
||||
mut ships: ResMut<ShipBrowser>,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
if ships.loaded || ships.loading {
|
||||
return; // already built / building
|
||||
}
|
||||
ships.loading = true;
|
||||
let source = iso_state.source_kind.clone();
|
||||
let sender = channels.sender.clone();
|
||||
std::thread::spawn(move || {
|
||||
let rows = build_ship_catalog(&source);
|
||||
let _ = sender.send(IsoLoaderMsg::ShipCatalogLoaded(rows));
|
||||
});
|
||||
}
|
||||
|
||||
/// Scan every `Stage_SNN.xpr` container, group XBG7 resources into whole ships,
|
||||
/// and join each to its [`sylpheed_formats::game_data::Vessel`] stats.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn build_ship_catalog(source: &SourceKind) -> Vec<ShipRow> {
|
||||
use sylpheed_formats::game_data::{self as gd, Vessel};
|
||||
use sylpheed_formats::ship::ships_in_container;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
// Vessel stats keyed by family id (`rou_e105` → `e105`).
|
||||
let vessels: Vec<Vessel> = read_pak_archive_blocking(source, "dat/GP_MAIN_GAME_E.pak")
|
||||
.map(|p| gd::load_vessels(&p))
|
||||
.unwrap_or_default();
|
||||
let vmap: BTreeMap<String, &Vessel> = vessels
|
||||
.iter()
|
||||
.filter_map(|v| {
|
||||
let id = v.model.as_deref()?.trim_start_matches("rou_").to_string();
|
||||
Some((id, v))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// A capital-ship-ish display name from the Vessel id (`UN_e105_ADAN_Cruiser`
|
||||
// → `Cruiser`), else the family id.
|
||||
let pretty = |id: &str, v: Option<&&Vessel>| -> String {
|
||||
if let Some(v) = v {
|
||||
if let Some(vid) = &v.id {
|
||||
// UN_<id>_<FACTION>_<Class…>
|
||||
if let Some(rest) = vid.strip_prefix("UN_") {
|
||||
let parts: Vec<&str> = rest.splitn(3, '_').collect();
|
||||
if parts.len() == 3 {
|
||||
return parts[2].replace('_', " ");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
id.to_string()
|
||||
};
|
||||
|
||||
let mut agg: BTreeMap<String, ShipRow> = BTreeMap::new();
|
||||
for n in 1..=30u32 {
|
||||
let file = format!("hidden/resource3d/Stage_S{n:02}.xpr");
|
||||
let Ok(bytes) = read_source_file(source, &file) else {
|
||||
continue;
|
||||
};
|
||||
let label = format!("S{n:02}");
|
||||
for sm in ships_in_container(&bytes) {
|
||||
// Skip single-piece props / debris — not "whole ships".
|
||||
if sm.parts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let v = vmap.get(&sm.id);
|
||||
let entry = agg.entry(sm.id.clone()).or_insert_with(|| ShipRow {
|
||||
id: sm.id.clone(),
|
||||
faction: sm.faction.label().to_string(),
|
||||
name: pretty(&sm.id, v),
|
||||
stage_file: file.clone(),
|
||||
stages: Vec::new(),
|
||||
parts: sm.parts.clone(),
|
||||
has_vessel: v.is_some(),
|
||||
hp: v.and_then(|v| v.hp),
|
||||
size: v.and_then(|v| Some((v.size_x?, v.size_y?, v.size_z?))),
|
||||
turrets: v.and_then(|v| v.turret_count),
|
||||
bridges: v.and_then(|v| v.bridge_count),
|
||||
shield_gens: v.and_then(|v| v.shield_generator_count),
|
||||
});
|
||||
if !entry.stages.contains(&label) {
|
||||
entry.stages.push(label.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut rows: Vec<ShipRow> = agg.into_values().collect();
|
||||
// Real capital ships (Vessel-matched) first, then by faction, then id.
|
||||
rows.sort_by(|a, b| {
|
||||
b.has_vessel
|
||||
.cmp(&a.has_vessel)
|
||||
.then(a.faction.cmp(&b.faction))
|
||||
.then(a.id.cmp(&b.id))
|
||||
});
|
||||
rows
|
||||
}
|
||||
|
||||
/// Handles a [`RequestShipRender`]: assembles the ship's part family into one
|
||||
/// world-space model off-thread and posts it through the shared XPR render path.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn handle_ship_render_request(
|
||||
mut events: EventReader<RequestShipRender>,
|
||||
iso_state: Res<IsoState>,
|
||||
channels: Res<IsoChannels>,
|
||||
mut xpr_load: ResMut<XprLoadState>,
|
||||
mut browser: ResMut<FileBrowserState>,
|
||||
) {
|
||||
let Some(req) = events.read().last() else {
|
||||
return;
|
||||
};
|
||||
let (file, parts, label) = (req.file.clone(), req.parts.clone(), req.label.clone());
|
||||
|
||||
// Supersede any in-flight XPR/stage decode, exactly like a file selection.
|
||||
xpr_load.generation = xpr_load.generation.wrapping_add(1);
|
||||
if let Some(prev) = xpr_load.cancel.take() {
|
||||
prev.store(true, Ordering::Relaxed);
|
||||
}
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
xpr_load.cancel = Some(cancel.clone());
|
||||
let generation = xpr_load.generation;
|
||||
browser.loading = true;
|
||||
|
||||
let source = iso_state.source_kind.clone();
|
||||
let sender = channels.sender.clone();
|
||||
std::thread::spawn(move || {
|
||||
let prepared = build_ship_model(&source, &file, &parts, &label, &cancel);
|
||||
let _ = sender.send(IsoLoaderMsg::XprPrepared {
|
||||
generation,
|
||||
prepared: Box::new(prepared),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Read a stage container, decode only the ship's parts, and assemble them into a
|
||||
/// single world-space model (reusing the XPR model prepare path).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn build_ship_model(
|
||||
source: &SourceKind,
|
||||
file: &str,
|
||||
parts: &[String],
|
||||
label: &str,
|
||||
cancel: &Arc<AtomicBool>,
|
||||
) -> PreparedXpr {
|
||||
use sylpheed_formats::mesh::Xbg7Model;
|
||||
let bytes = match read_source_file(source, file) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return PreparedXpr::Info(format!("Could not read {file}: {e}")),
|
||||
};
|
||||
let wanted: std::collections::HashSet<String> = parts.iter().cloned().collect();
|
||||
let should_cancel = || cancel.load(Ordering::Relaxed);
|
||||
let models = Xbg7Model::models_named(&bytes, &wanted, &should_cancel);
|
||||
if should_cancel() {
|
||||
return PreparedXpr::Cancelled;
|
||||
}
|
||||
if models.is_empty() {
|
||||
return PreparedXpr::Info(format!("No geometry decoded for {label}."));
|
||||
}
|
||||
// Assemble, then relabel with the ship's display name.
|
||||
match prepare_ship(&bytes, &models) {
|
||||
PreparedXpr::Model {
|
||||
meshes,
|
||||
materials,
|
||||
focus,
|
||||
radius,
|
||||
min_radius,
|
||||
max_radius,
|
||||
info,
|
||||
..
|
||||
} => PreparedXpr::Model {
|
||||
meshes,
|
||||
materials,
|
||||
focus,
|
||||
radius,
|
||||
min_radius,
|
||||
max_radius,
|
||||
name: label.to_string(),
|
||||
info,
|
||||
},
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a [`RequestVoiceLibrary`]: reads `tables.pak`'s `sounds.tbl` and
|
||||
/// enumerates the voice clips off-thread.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -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