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)]
|
#[derive(Event, Default)]
|
||||||
pub struct RequestGameData;
|
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.
|
/// The currently-open IPFB data pack, shown as a master-detail browser.
|
||||||
#[derive(Resource, Default)]
|
#[derive(Resource, Default)]
|
||||||
pub struct PakView {
|
pub struct PakView {
|
||||||
@@ -676,6 +731,8 @@ enum IsoLoaderMsg {
|
|||||||
VoiceLibraryLoaded {
|
VoiceLibraryLoaded {
|
||||||
clips: Vec<sylpheed_formats::slb::VoiceClip>,
|
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.
|
/// The decoded game-data tables for the browser.
|
||||||
GameDataLoaded(Box<GameSnapshot>),
|
GameDataLoaded(Box<GameSnapshot>),
|
||||||
/// User dismissed the file dialog — not an error.
|
/// User dismissed the file dialog — not an error.
|
||||||
@@ -896,11 +953,14 @@ impl Plugin for IsoLoaderPlugin {
|
|||||||
.init_resource::<AudioPreview>()
|
.init_resource::<AudioPreview>()
|
||||||
.init_resource::<VoiceLibrary>()
|
.init_resource::<VoiceLibrary>()
|
||||||
.init_resource::<GameData>()
|
.init_resource::<GameData>()
|
||||||
|
.init_resource::<ShipBrowser>()
|
||||||
.add_event::<RequestSubtitles>()
|
.add_event::<RequestSubtitles>()
|
||||||
.add_event::<RequestVoice>()
|
.add_event::<RequestVoice>()
|
||||||
.add_event::<RequestAudio>()
|
.add_event::<RequestAudio>()
|
||||||
.add_event::<RequestVoiceLibrary>()
|
.add_event::<RequestVoiceLibrary>()
|
||||||
.add_event::<RequestGameData>();
|
.add_event::<RequestGameData>()
|
||||||
|
.add_event::<RequestShipCatalog>()
|
||||||
|
.add_event::<RequestShipRender>();
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
{
|
{
|
||||||
@@ -930,6 +990,8 @@ impl Plugin for IsoLoaderPlugin {
|
|||||||
advance_audio_playback,
|
advance_audio_playback,
|
||||||
handle_voice_library_request,
|
handle_voice_library_request,
|
||||||
handle_game_data_request,
|
handle_game_data_request,
|
||||||
|
handle_ship_catalog_request,
|
||||||
|
handle_ship_render_request,
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.in_set(IsoLoaderSystemSet),
|
.in_set(IsoLoaderSystemSet),
|
||||||
@@ -1770,6 +1832,7 @@ fn poll_loader_channel(
|
|||||||
mut audio_preview: ResMut<AudioPreview>,
|
mut audio_preview: ResMut<AudioPreview>,
|
||||||
mut voice_lib: ResMut<VoiceLibrary>,
|
mut voice_lib: ResMut<VoiceLibrary>,
|
||||||
mut game_data: ResMut<GameData>,
|
mut game_data: ResMut<GameData>,
|
||||||
|
mut ships: ResMut<ShipBrowser>,
|
||||||
) {
|
) {
|
||||||
let receiver = channels.receiver.lock().unwrap();
|
let receiver = channels.receiver.lock().unwrap();
|
||||||
loop {
|
loop {
|
||||||
@@ -1894,6 +1957,11 @@ fn poll_loader_channel(
|
|||||||
game_data.loaded = true;
|
game_data.loaded = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(IsoLoaderMsg::ShipCatalogLoaded(rows)) => {
|
||||||
|
ships.loading = false;
|
||||||
|
ships.loaded = !rows.is_empty();
|
||||||
|
ships.rows = rows;
|
||||||
|
}
|
||||||
Ok(IsoLoaderMsg::Cancelled) => {
|
Ok(IsoLoaderMsg::Cancelled) => {
|
||||||
iso_state.loading = false;
|
iso_state.loading = false;
|
||||||
browser.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"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) -> PreparedXpr {
|
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 bevy::render::mesh::{Indices, PrimitiveTopology};
|
||||||
use sylpheed_formats::texture::X360Texture;
|
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 GAP: f32 = 4.0;
|
||||||
const MAX_EXTENT: f32 = 20_000.0; // drop skybox planes / stray-vertex anchors
|
const MAX_EXTENT: f32 = 20_000.0; // drop skybox planes / stray-vertex anchors
|
||||||
let pitch = CELL + GAP;
|
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.
|
// Keep models with real, bounded geometry.
|
||||||
let drawn: Vec<&sylpheed_formats::mesh::Xbg7Model> = models
|
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_v = 0usize;
|
||||||
let mut total_t = 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() {
|
for (i, model) in drawn.iter().enumerate() {
|
||||||
// Per-model bbox → recentre + (stage) uniform scale to the grid cell.
|
// Per-model bbox → recentre + (stage) uniform scale to the grid cell.
|
||||||
let mut lo = Vec3::splat(f32::MAX);
|
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 {
|
if lo.x > hi.x {
|
||||||
continue;
|
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 (scale, cell) = if is_stage {
|
||||||
let extent = (hi - lo).max_element().max(1e-3);
|
let extent = (hi - lo).max_element().max(1e-3);
|
||||||
let col = i % cols;
|
let col = i % cols;
|
||||||
@@ -2601,7 +2711,17 @@ fn prepare_models(bytes: &[u8], models: &[sylpheed_formats::mesh::Xbg7Model]) ->
|
|||||||
} else {
|
} else {
|
||||||
CELL
|
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(),
|
"stage".to_string(),
|
||||||
format!(
|
format!(
|
||||||
@@ -3666,6 +3786,196 @@ fn build_game_snapshot(source: &SourceKind) -> Option<GameSnapshot> {
|
|||||||
Some(GameSnapshot { weapons, craft, vessels, characters, missions, arsenal, flights })
|
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
|
/// Handles a [`RequestVoiceLibrary`]: reads `tables.pak`'s `sounds.tbl` and
|
||||||
/// enumerates the voice clips off-thread.
|
/// enumerates the voice clips off-thread.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ use bevy_egui::{egui, EguiContexts};
|
|||||||
use crate::iso_loader::{
|
use crate::iso_loader::{
|
||||||
AudioPreview, FileInfo, FileSelected, GameCategory, GameData, ImageRgba, IsoState, ModelPreview,
|
AudioPreview, FileInfo, FileSelected, GameCategory, GameData, ImageRgba, IsoState, ModelPreview,
|
||||||
MovieSubtitles, MovieVoice, PakContent, PakView, RequestAudio, RequestGameData, RequestOpenDir,
|
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,
|
VideoPreview, VoiceLibrary, IsoLoaderSystemSet,
|
||||||
};
|
};
|
||||||
use crate::ViewerState;
|
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.
|
// 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_viewer_ui.after(IsoLoaderSystemSet));
|
||||||
app.add_systems(Update, draw_game_data_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>,
|
audio: EventWriter<'w, RequestAudio>,
|
||||||
voice_lib: EventWriter<'w, RequestVoiceLibrary>,
|
voice_lib: EventWriter<'w, RequestVoiceLibrary>,
|
||||||
game_data: EventWriter<'w, RequestGameData>,
|
game_data: EventWriter<'w, RequestGameData>,
|
||||||
|
ships: EventWriter<'w, RequestShipCatalog>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_viewer_ui(
|
fn draw_viewer_ui(
|
||||||
@@ -201,6 +204,12 @@ fn draw_viewer_ui(
|
|||||||
events.game_data.send_default();
|
events.game_data.send_default();
|
||||||
ui.close_menu();
|
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| {
|
ui.menu_button("Help", |ui| {
|
||||||
@@ -1521,3 +1530,155 @@ fn draw_game_data_ui(
|
|||||||
});
|
});
|
||||||
game_data.open &= open;
|
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