viewer: Game Data browser (View ▸ Game Data)
Wire the decoded game_data + localization tables into the viewer as a browsable window. View ▸ Game Data opens a floating panel with category tabs — Weapons, Craft, Capital Ships, Characters, Missions, Arsenal, Flights — decoded off-thread from GP_MAIN_GAME_E + GP_HANGAR_ARSENAL. - Weapons/Craft/Ships: sortable stat grids (power/velocity/range; HP/cruise/accel/radar/turrets; HP/length/turrets/bridges/shield-gen), with a live name filter. - Characters: name (localized) + faction (colour-coded) + portrait count. - Missions: collapsible per stage — location, resolved objectives, fail conditions, and the enemy roster with HP. - Arsenal: the player's nose/arm weapon options in four columns. - Flights: distinct wingman line-ups. Its own egui system (keeps draw_viewer_ui under Bevy's 16-param limit); a RequestGameData event from the menu opens it and lazily decodes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -503,6 +503,89 @@ pub struct VoiceLibrary {
|
||||
#[derive(Event, Default)]
|
||||
pub struct RequestVoiceLibrary;
|
||||
|
||||
// ── Game-data browser (decoded IDXD tables) ────────────────────────────────────
|
||||
|
||||
/// Which decoded table the Game Data browser is showing.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum GameCategory {
|
||||
#[default]
|
||||
Weapons,
|
||||
Craft,
|
||||
Vessels,
|
||||
Characters,
|
||||
Missions,
|
||||
Arsenal,
|
||||
Flights,
|
||||
}
|
||||
|
||||
impl GameCategory {
|
||||
pub const ALL: [GameCategory; 7] = [
|
||||
GameCategory::Weapons,
|
||||
GameCategory::Craft,
|
||||
GameCategory::Vessels,
|
||||
GameCategory::Characters,
|
||||
GameCategory::Missions,
|
||||
GameCategory::Arsenal,
|
||||
GameCategory::Flights,
|
||||
];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
GameCategory::Weapons => "Weapons",
|
||||
GameCategory::Craft => "Craft",
|
||||
GameCategory::Vessels => "Capital Ships",
|
||||
GameCategory::Characters => "Characters",
|
||||
GameCategory::Missions => "Missions",
|
||||
GameCategory::Arsenal => "Arsenal",
|
||||
GameCategory::Flights => "Flights",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A character row, name resolved via localization.
|
||||
pub struct CharRow {
|
||||
pub name: String,
|
||||
pub faction: String,
|
||||
pub faces: usize,
|
||||
}
|
||||
|
||||
/// A mission row: stage + its resolved briefing.
|
||||
pub struct MissionRow {
|
||||
pub id: String,
|
||||
pub location: String,
|
||||
pub phases: u32,
|
||||
pub objectives: Vec<String>,
|
||||
pub lose: Vec<String>,
|
||||
pub enemies: Vec<String>,
|
||||
}
|
||||
|
||||
/// Everything the Game Data browser shows, decoded off-thread from the paks.
|
||||
#[derive(Default)]
|
||||
pub struct GameSnapshot {
|
||||
pub weapons: Vec<sylpheed_formats::game_data::Weapon>,
|
||||
pub craft: Vec<sylpheed_formats::game_data::CraftUnit>,
|
||||
pub vessels: Vec<sylpheed_formats::game_data::Vessel>,
|
||||
pub characters: Vec<CharRow>,
|
||||
pub missions: Vec<MissionRow>,
|
||||
pub arsenal: sylpheed_formats::game_data::Arsenal,
|
||||
/// Distinct flight line-ups: `(callsign, pilot)` rows.
|
||||
pub flights: Vec<Vec<(String, String)>>,
|
||||
}
|
||||
|
||||
/// The Game Data browser state (decoded combat / mission / cast tables).
|
||||
#[derive(Resource, Default)]
|
||||
pub struct GameData {
|
||||
pub open: bool,
|
||||
pub loading: bool,
|
||||
pub loaded: bool,
|
||||
pub category: GameCategory,
|
||||
pub filter: String,
|
||||
pub snapshot: GameSnapshot,
|
||||
}
|
||||
|
||||
/// Ask the loader to decode the game-data tables.
|
||||
#[derive(Event, Default)]
|
||||
pub struct RequestGameData;
|
||||
|
||||
/// The currently-open IPFB data pack, shown as a master-detail browser.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct PakView {
|
||||
@@ -593,6 +676,8 @@ enum IsoLoaderMsg {
|
||||
VoiceLibraryLoaded {
|
||||
clips: Vec<sylpheed_formats::slb::VoiceClip>,
|
||||
},
|
||||
/// The decoded game-data tables for the browser.
|
||||
GameDataLoaded(Box<GameSnapshot>),
|
||||
/// User dismissed the file dialog — not an error.
|
||||
Cancelled,
|
||||
Error(String),
|
||||
@@ -810,10 +895,12 @@ impl Plugin for IsoLoaderPlugin {
|
||||
.init_resource::<MovieVoice>()
|
||||
.init_resource::<AudioPreview>()
|
||||
.init_resource::<VoiceLibrary>()
|
||||
.init_resource::<GameData>()
|
||||
.add_event::<RequestSubtitles>()
|
||||
.add_event::<RequestVoice>()
|
||||
.add_event::<RequestAudio>()
|
||||
.add_event::<RequestVoiceLibrary>();
|
||||
.add_event::<RequestVoiceLibrary>()
|
||||
.add_event::<RequestGameData>();
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -842,6 +929,7 @@ impl Plugin for IsoLoaderPlugin {
|
||||
handle_audio_request,
|
||||
advance_audio_playback,
|
||||
handle_voice_library_request,
|
||||
handle_game_data_request,
|
||||
)
|
||||
.chain()
|
||||
.in_set(IsoLoaderSystemSet),
|
||||
@@ -1681,6 +1769,7 @@ fn poll_loader_channel(
|
||||
mut mvoice: ResMut<MovieVoice>,
|
||||
mut audio_preview: ResMut<AudioPreview>,
|
||||
mut voice_lib: ResMut<VoiceLibrary>,
|
||||
mut game_data: ResMut<GameData>,
|
||||
) {
|
||||
let receiver = channels.receiver.lock().unwrap();
|
||||
loop {
|
||||
@@ -1793,6 +1882,18 @@ fn poll_loader_channel(
|
||||
voice_lib.loaded = true;
|
||||
}
|
||||
}
|
||||
Ok(IsoLoaderMsg::GameDataLoaded(snap)) => {
|
||||
game_data.loading = false;
|
||||
// An empty snapshot means the decode failed (no game source, or a
|
||||
// bare pak) — leave `loaded=false` so re-opening retries.
|
||||
let empty = snap.weapons.is_empty() && snap.craft.is_empty() && snap.missions.is_empty();
|
||||
if empty {
|
||||
game_data.loaded = false;
|
||||
} else {
|
||||
game_data.snapshot = *snap;
|
||||
game_data.loaded = true;
|
||||
}
|
||||
}
|
||||
Ok(IsoLoaderMsg::Cancelled) => {
|
||||
iso_state.loading = false;
|
||||
browser.loading = false;
|
||||
@@ -3462,6 +3563,109 @@ fn handle_audio_request(
|
||||
});
|
||||
}
|
||||
|
||||
/// Handles a [`RequestGameData`]: decodes the combat / mission / cast tables
|
||||
/// off-thread and posts the snapshot back for the browser.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn handle_game_data_request(
|
||||
mut events: EventReader<RequestGameData>,
|
||||
iso_state: Res<IsoState>,
|
||||
channels: Res<IsoChannels>,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
let source = iso_state.source_kind.clone();
|
||||
let sender = channels.sender.clone();
|
||||
std::thread::spawn(move || {
|
||||
let snap = build_game_snapshot(&source).unwrap_or_default();
|
||||
let _ = sender.send(IsoLoaderMsg::GameDataLoaded(Box::new(snap)));
|
||||
});
|
||||
}
|
||||
|
||||
/// Decode the game-data tables into a browser snapshot (English pak + hangar pak).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn build_game_snapshot(source: &SourceKind) -> Option<GameSnapshot> {
|
||||
use sylpheed_formats::{game_data as gd, localization::TextIndex};
|
||||
let main = read_pak_archive_blocking(source, "dat/GP_MAIN_GAME_E.pak").ok()?;
|
||||
let text = TextIndex::build(&main);
|
||||
|
||||
let mut weapons = gd::load_weapons(&main);
|
||||
weapons.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
let mut craft = gd::load_units(&main);
|
||||
craft.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
let mut vessels = gd::load_vessels(&main);
|
||||
vessels.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
|
||||
let mut characters: Vec<CharRow> = gd::load_characters(&main)
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
let id = c.id?;
|
||||
let short = id.trim_start_matches("Character").to_string();
|
||||
let name = text.character_name(&short).unwrap_or(&short).to_string();
|
||||
Some(CharRow { name, faction: c.faction.unwrap_or_default(), faces: c.faces.len() })
|
||||
})
|
||||
.collect();
|
||||
characters.sort_by(|a, b| (a.faction.clone(), a.name.clone()).cmp(&(b.faction.clone(), b.name.clone())));
|
||||
|
||||
// Combat rosters, keyed by stage where the table self-identifies.
|
||||
let rosters = gd::load_unit_rosters(&main);
|
||||
let stat_hp = |id: &str| -> Option<f32> {
|
||||
craft.iter().find(|u| u.id.as_deref() == Some(id)).and_then(|u| u.hp)
|
||||
.or_else(|| vessels.iter().find(|v| v.id.as_deref() == Some(id)).and_then(|v| v.hp))
|
||||
};
|
||||
let mut missions: Vec<MissionRow> = Vec::new();
|
||||
for s in gd::load_stages(&main) {
|
||||
// main + extra story stages only (skip the Test stage)
|
||||
if s.id.len() != 3 || !s.id.starts_with('S') || s.id[1..].parse::<u32>().is_err() {
|
||||
continue;
|
||||
}
|
||||
let objectives: Vec<String> =
|
||||
(1..=s.phases).flat_map(|p| text.objectives(&s.id, p)).map(str::to_string).collect();
|
||||
let lose: Vec<String> =
|
||||
(1..=s.phases).flat_map(|p| text.lose_conditions(&s.id, p)).map(str::to_string).collect();
|
||||
let enemies: Vec<String> = rosters
|
||||
.iter()
|
||||
.find(|r| r.stage.as_deref() == Some(s.id.as_str()))
|
||||
.map(|r| {
|
||||
r.units.iter().filter(|u| u.contains("ADAN")).map(|u| {
|
||||
let name = u.trim_start_matches("UN_").splitn(3, '_').nth(2).unwrap_or(u).replace('_', " ");
|
||||
match stat_hp(u) {
|
||||
Some(h) => format!("{name} ({h:.0} HP)"),
|
||||
None => name,
|
||||
}
|
||||
}).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
missions.push(MissionRow {
|
||||
id: s.id.clone(),
|
||||
location: s.location.unwrap_or_default().replace('_', " "),
|
||||
phases: s.phases,
|
||||
objectives,
|
||||
lose,
|
||||
enemies,
|
||||
});
|
||||
}
|
||||
missions.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
|
||||
// Arsenal + flights from the hangar pak (optional).
|
||||
let (arsenal, flights) = read_pak_archive_blocking(source, "dat/GP_HANGAR_ARSENAL.pak")
|
||||
.map(|h| {
|
||||
let arsenal = gd::load_arsenal(&h);
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut flights = Vec::new();
|
||||
for r in gd::load_pilot_rosters(&h) {
|
||||
let key: String = r.pilots.iter().map(|(c, p)| format!("{c}:{p}")).collect::<Vec<_>>().join(",");
|
||||
if seen.insert(key) {
|
||||
flights.push(r.pilots);
|
||||
}
|
||||
}
|
||||
(arsenal, flights)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Some(GameSnapshot { weapons, craft, vessels, characters, missions, arsenal, flights })
|
||||
}
|
||||
|
||||
/// Handles a [`RequestVoiceLibrary`]: reads `tables.pak`'s `sounds.tbl` and
|
||||
/// enumerates the voice clips off-thread.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -10,10 +10,10 @@ use bevy::prelude::*;
|
||||
use bevy_egui::{egui, EguiContexts};
|
||||
|
||||
use crate::iso_loader::{
|
||||
AudioPreview, FileInfo, FileSelected, ImageRgba, IsoState, ModelPreview, MovieSubtitles,
|
||||
MovieVoice, PakContent, PakView, RequestAudio, RequestOpenDir, RequestOpenIso, RequestSubtitles,
|
||||
RequestVoiceLibrary, SkyboxPreview, TextPreview, TexturePreview, VideoPreview, VoiceLibrary,
|
||||
IsoLoaderSystemSet,
|
||||
AudioPreview, FileInfo, FileSelected, GameCategory, GameData, ImageRgba, IsoState, ModelPreview,
|
||||
MovieSubtitles, MovieVoice, PakContent, PakView, RequestAudio, RequestGameData, RequestOpenDir,
|
||||
RequestOpenIso, RequestSubtitles, RequestVoiceLibrary, SkyboxPreview, TextPreview, TexturePreview,
|
||||
VideoPreview, VoiceLibrary, IsoLoaderSystemSet,
|
||||
};
|
||||
use crate::ViewerState;
|
||||
use sylpheed_formats::SubLang;
|
||||
@@ -25,6 +25,7 @@ impl Plugin for ViewerUiPlugin {
|
||||
app.insert_resource(FileBrowserState::default());
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +137,7 @@ struct UiEvents<'w> {
|
||||
subtitles: EventWriter<'w, RequestSubtitles>,
|
||||
audio: EventWriter<'w, RequestAudio>,
|
||||
voice_lib: EventWriter<'w, RequestVoiceLibrary>,
|
||||
game_data: EventWriter<'w, RequestGameData>,
|
||||
}
|
||||
|
||||
fn draw_viewer_ui(
|
||||
@@ -193,6 +195,12 @@ fn draw_viewer_ui(
|
||||
}
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("🗃 Game Data…").clicked() {
|
||||
// The Game Data window (its own system) reads this event to
|
||||
// open + lazily decode the tables.
|
||||
events.game_data.send_default();
|
||||
ui.close_menu();
|
||||
}
|
||||
});
|
||||
|
||||
ui.menu_button("Help", |ui| {
|
||||
@@ -1280,3 +1288,236 @@ fn draw_skybox_grid(ui: &mut egui::Ui, skybox: &SkyboxPreview) {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Game Data browser ──────────────────────────────────────────────────────────
|
||||
|
||||
fn fnum(v: Option<f32>) -> String {
|
||||
match v {
|
||||
Some(x) if x == x.trunc() => format!("{}", x as i64),
|
||||
Some(x) => format!("{x}"),
|
||||
None => "·".into(),
|
||||
}
|
||||
}
|
||||
fn fint(v: Option<i64>) -> String {
|
||||
v.map(|x| x.to_string()).unwrap_or_else(|| "·".into())
|
||||
}
|
||||
|
||||
/// The Game Data browser — a floating window over the decoded IDXD tables
|
||||
/// (weapons, craft, ships, cast, missions, arsenal, flights). Its own system so
|
||||
/// `draw_viewer_ui` stays under Bevy's system-parameter limit; a `RequestGameData`
|
||||
/// event (from the View menu) opens it and lazily kicks off decoding.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn draw_game_data_ui(
|
||||
mut contexts: EguiContexts,
|
||||
mut game_data: ResMut<GameData>,
|
||||
mut requests: EventReader<RequestGameData>,
|
||||
) {
|
||||
if requests.read().next().is_some() {
|
||||
game_data.open = true;
|
||||
if !game_data.loaded {
|
||||
game_data.loading = true;
|
||||
}
|
||||
}
|
||||
if !game_data.open {
|
||||
return;
|
||||
}
|
||||
let ctx = contexts.ctx_mut().clone();
|
||||
let mut open = true;
|
||||
egui::Window::new("🗃 Game Data")
|
||||
.default_width(700.0)
|
||||
.default_height(560.0)
|
||||
.open(&mut open)
|
||||
.show(&ctx, |ui| {
|
||||
if game_data.loading {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label("Decoding game tables…");
|
||||
});
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
if !game_data.loaded {
|
||||
ui.label("Open a game source first (File ▸ Open…).");
|
||||
return;
|
||||
}
|
||||
let GameData { category, filter, snapshot, .. } = &mut *game_data;
|
||||
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for cat in GameCategory::ALL {
|
||||
ui.selectable_value(category, cat, cat.label());
|
||||
}
|
||||
});
|
||||
let filterable = !matches!(category, GameCategory::Arsenal | GameCategory::Flights);
|
||||
if filterable {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("🔍");
|
||||
ui.text_edit_singleline(filter);
|
||||
if !filter.is_empty() && ui.small_button("✖").clicked() {
|
||||
filter.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.separator();
|
||||
let f = filter.to_lowercase();
|
||||
let hit = |s: &str| f.is_empty() || s.to_lowercase().contains(&f);
|
||||
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match *category {
|
||||
GameCategory::Weapons => {
|
||||
egui::Grid::new("g_weap").striped(true).num_columns(6).show(ui, |ui| {
|
||||
for h in ["Weapon", "Targets", "Power", "Velocity", "Range", "Reload"] {
|
||||
ui.strong(h);
|
||||
}
|
||||
ui.end_row();
|
||||
for w in &snapshot.weapons {
|
||||
let name = w.id.as_deref().unwrap_or("?").trim_start_matches("Weapon_");
|
||||
if !hit(name) {
|
||||
continue;
|
||||
}
|
||||
ui.label(name);
|
||||
ui.label(w.target_type.as_deref().unwrap_or("·"));
|
||||
ui.label(fnum(w.power));
|
||||
ui.label(fnum(w.velocity));
|
||||
ui.label(fnum(w.max_range));
|
||||
ui.label(fint(w.loading_count));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
GameCategory::Craft => {
|
||||
egui::Grid::new("g_craft").striped(true).num_columns(6).show(ui, |ui| {
|
||||
for h in ["Craft", "HP", "Cruise", "Accel", "Radar", "Turrets"] {
|
||||
ui.strong(h);
|
||||
}
|
||||
ui.end_row();
|
||||
for u in &snapshot.craft {
|
||||
let name = u.id.as_deref().unwrap_or("?").trim_start_matches("UN_");
|
||||
if !hit(name) {
|
||||
continue;
|
||||
}
|
||||
ui.label(name);
|
||||
ui.label(fnum(u.hp));
|
||||
ui.label(fnum(u.cruising_velocity));
|
||||
ui.label(fnum(u.acceleration));
|
||||
ui.label(fnum(u.radar_range));
|
||||
ui.label(fint(u.turret_count));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
GameCategory::Vessels => {
|
||||
egui::Grid::new("g_ves").striped(true).num_columns(6).show(ui, |ui| {
|
||||
for h in ["Vessel", "HP", "Length", "Turrets", "Bridges", "Shield gen"] {
|
||||
ui.strong(h);
|
||||
}
|
||||
ui.end_row();
|
||||
for v in &snapshot.vessels {
|
||||
let name = v.id.as_deref().unwrap_or("?").trim_start_matches("UN_");
|
||||
if !hit(name) {
|
||||
continue;
|
||||
}
|
||||
ui.label(name);
|
||||
ui.label(fnum(v.hp));
|
||||
ui.label(fnum(v.size_z));
|
||||
ui.label(fint(v.turret_count));
|
||||
ui.label(fint(v.bridge_count));
|
||||
ui.label(fint(v.shield_generator_count));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
GameCategory::Characters => {
|
||||
egui::Grid::new("g_char").striped(true).num_columns(3).show(ui, |ui| {
|
||||
for h in ["Name", "Faction", "Portraits"] {
|
||||
ui.strong(h);
|
||||
}
|
||||
ui.end_row();
|
||||
for c in &snapshot.characters {
|
||||
if !hit(&c.name) && !hit(&c.faction) {
|
||||
continue;
|
||||
}
|
||||
ui.label(&c.name);
|
||||
let col = if c.faction == "TCAF" {
|
||||
egui::Color32::from_rgb(90, 160, 232)
|
||||
} else if c.faction == "ADAN" {
|
||||
egui::Color32::from_rgb(224, 86, 122)
|
||||
} else {
|
||||
egui::Color32::GRAY
|
||||
};
|
||||
ui.colored_label(col, if c.faction.is_empty() { "—" } else { &c.faction });
|
||||
ui.label(c.faces.to_string());
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
GameCategory::Missions => {
|
||||
for m in &snapshot.missions {
|
||||
if !hit(&m.id) && !hit(&m.location) && !m.objectives.iter().any(|o| hit(o)) {
|
||||
continue;
|
||||
}
|
||||
let head = format!("{} · {} · {} phases", m.id, m.location, m.phases);
|
||||
egui::CollapsingHeader::new(head).id_salt(&m.id).show(ui, |ui| {
|
||||
if !m.objectives.is_empty() {
|
||||
ui.strong("Objectives");
|
||||
for o in &m.objectives {
|
||||
ui.label(format!("▸ {o}"));
|
||||
}
|
||||
}
|
||||
if !m.lose.is_empty() {
|
||||
ui.add_space(4.0);
|
||||
ui.strong("Fail conditions");
|
||||
for l in &m.lose {
|
||||
ui.colored_label(egui::Color32::from_rgb(224, 86, 122), l);
|
||||
}
|
||||
}
|
||||
if !m.enemies.is_empty() {
|
||||
ui.add_space(4.0);
|
||||
ui.strong("Enemy roster");
|
||||
for e in &m.enemies {
|
||||
ui.label(format!("• {e}"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
GameCategory::Arsenal => {
|
||||
ui.columns(4, |cols| {
|
||||
for (i, (title, list)) in [
|
||||
("Nose", &snapshot.arsenal.nose),
|
||||
("Arm 1", &snapshot.arsenal.arm1),
|
||||
("Arm 2", &snapshot.arsenal.arm2),
|
||||
("Arm 3", &snapshot.arsenal.arm3),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
cols[i].strong(format!("{title} ({})", list.len()));
|
||||
for w in list {
|
||||
cols[i].label(w.replace('_', " "));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
GameCategory::Flights => {
|
||||
ui.label(
|
||||
egui::RichText::new("Distinct wingman line-ups (story order)").weak().small(),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
for (n, lineup) in snapshot.flights.iter().enumerate() {
|
||||
egui::CollapsingHeader::new(format!("Line-up {}", n + 1))
|
||||
.id_salt(n)
|
||||
.default_open(n == 0)
|
||||
.show(ui, |ui| {
|
||||
egui::Grid::new(("g_flight", n)).striped(true).num_columns(2).show(ui, |ui| {
|
||||
for (cs, pilot) in lineup {
|
||||
ui.label(cs);
|
||||
ui.strong(pilot);
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
game_data.open &= open;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user