diff --git a/apps/game/src/gameplay/character_creation/mod.rs b/apps/game/src/gameplay/character_creation/mod.rs index 29d4ab1..42c8996 100644 --- a/apps/game/src/gameplay/character_creation/mod.rs +++ b/apps/game/src/gameplay/character_creation/mod.rs @@ -1,13 +1,22 @@ //! Character creation scene. //! -//! Barebones skeleton: spawns a placeholder UI on enter, despawns on exit, -//! and wires a `Confirm` button that transitions to [`AppState::InGame`] and -//! a `Back` button (or Escape) that returns to [`AppState::Galaxy`]. +//! Layout / picker UI lives in [`ui`]; presets and the editable draft +//! resource live in [`params`]. This module wires the Bevy plugin, owns the +//! spawn / despawn lifecycle, and handles the Escape → Galaxy shortcut. //! -//! Intended to grow into the real character creator — name, portrait, stats, -//! starting ship, background / origin, etc. Each major aspect should split -//! into its own submodule (e.g. `ui.rs`, `params.rs`, `presets.rs`) once it -//! outgrows this file (see the module-layout guidelines in `apps/game/AGENTS.md`). +//! Persistence is not yet wired up — on Confirm, the current draft is logged +//! and the app transitions to [`AppState::InGame`]. The TODO is to persist it +//! to SpacetimeDB once the Rust SDK is integrated. + +mod params; +mod ui; + +// Public API surface for the (yet-to-be-wired) persistence layer / InGame +// plugin. Currently unused outside this module — `#[allow]` keeps the names +// exported so consumers can `use crate::gameplay::character_creation::Origin` +// without us remembering to re-add the re-export the day they're needed. +#[allow(unused_imports)] +pub use params::{CharacterDraft, Origin, StartingShip}; use bevy::prelude::*; @@ -17,7 +26,11 @@ pub struct CharacterCreationPlugin; impl Plugin for CharacterCreationPlugin { fn build(&self, app: &mut App) { - app.add_systems(OnEnter(AppState::CharacterCreation), setup_character_creation) + app.init_resource::() + .add_systems( + OnEnter(AppState::CharacterCreation), + ui::setup_character_creation_ui, + ) .add_systems( OnExit(AppState::CharacterCreation), despawn_character_creation, @@ -26,9 +39,11 @@ impl Plugin for CharacterCreationPlugin { Update, ( escape_to_galaxy, - confirm_button_handler, - back_button_handler, + ui::picker_button_handler, + ui::refresh_picker_values, + ui::action_button_handler, ) + .chain() .run_if(in_state(AppState::CharacterCreation)), ); } @@ -49,104 +64,6 @@ pub enum CharacterCreationButton { Back, } -// ── Setup ─────────────────────────────────────────────────────────────────── - -fn setup_character_creation(mut commands: Commands) { - let button_style = Node { - width: Val::Px(220.0), - height: Val::Px(56.0), - justify_content: JustifyContent::Center, - align_items: AlignItems::Center, - ..default() - }; - let button_font = TextFont { - font_size: 24.0, - ..default() - }; - - commands - .spawn(( - Node { - width: Val::Percent(100.0), - height: Val::Percent(100.0), - display: Display::Flex, - flex_direction: FlexDirection::Column, - justify_content: JustifyContent::Center, - align_items: AlignItems::Center, - row_gap: Val::Px(24.0), - ..default() - }, - BackgroundColor(Color::srgb(0.02, 0.02, 0.06)), - CharacterCreationSpawned, - )) - .with_children(|parent| { - parent.spawn(( - Text::new("Character Creation"), - TextFont { - font_size: 56.0, - ..default() - }, - TextColor(Color::srgb(0.7, 0.85, 1.0)), - Node { - margin: UiRect::bottom(Val::Px(16.0)), - ..default() - }, - )); - - parent.spawn(( - Text::new("TODO: name, portrait, stats, background, starting ship"), - TextFont { - font_size: 18.0, - ..default() - }, - TextColor(Color::srgb(0.4, 0.5, 0.6)), - Node { - margin: UiRect::bottom(Val::Px(48.0)), - ..default() - }, - )); - - spawn_button( - &mut parent.spawn_empty(), - "Confirm", - CharacterCreationButton::Confirm, - &button_style, - &button_font, - ); - spawn_button( - &mut parent.spawn_empty(), - "Back", - CharacterCreationButton::Back, - &button_style, - &button_font, - ); - }); -} - -fn spawn_button( - cmd: &mut EntityCommands, - label: &str, - marker: CharacterCreationButton, - style: &Node, - text_font: &TextFont, -) { - cmd.insert(( - Button, - style.clone(), - BackgroundColor(Color::srgb(0.08, 0.1, 0.18)), - BorderColor(Color::srgb(0.3, 0.45, 0.7)), - BorderRadius::all(Val::Px(8.0)), - marker, - )) - .with_children(|btn| { - btn.spawn(( - Text::new(label), - text_font.clone(), - TextColor(Color::srgb(0.75, 0.85, 1.0)), - )); - }); -} - // ── Lifecycle ─────────────────────────────────────────────────────────────── fn despawn_character_creation( @@ -169,26 +86,3 @@ fn escape_to_galaxy( next_state.set(AppState::Galaxy); } } - -fn confirm_button_handler( - mut next_state: ResMut>, - query: Query<(&Interaction, &CharacterCreationButton), Changed>, -) { - for (interaction, button) in &query { - if *interaction == Interaction::Pressed && matches!(button, CharacterCreationButton::Confirm) { - // TODO: persist the configured character before entering the game. - next_state.set(AppState::InGame); - } - } -} - -fn back_button_handler( - mut next_state: ResMut>, - query: Query<(&Interaction, &CharacterCreationButton), Changed>, -) { - for (interaction, button) in &query { - if *interaction == Interaction::Pressed && matches!(button, CharacterCreationButton::Back) { - next_state.set(AppState::Galaxy); - } - } -} diff --git a/apps/game/src/gameplay/character_creation/params.rs b/apps/game/src/gameplay/character_creation/params.rs new file mode 100644 index 0000000..334c27e --- /dev/null +++ b/apps/game/src/gameplay/character_creation/params.rs @@ -0,0 +1,222 @@ +//! Static presets and the editable draft resource for character creation. +//! +//! The draft is held in a [`CharacterDraft`] resource so the UI is fully +//! data-driven: panels read from the resource every frame, +/- buttons mutate +//! it, and the confirm handler can hand it off to whatever persistence layer +//! comes next (SpacetimeDB). No state lives on UI entities. +//! +//! Everything in this file is plain data — no Bevy types beyond `Resource`. + +use bevy::prelude::*; + +// ── Name pool ─────────────────────────────────────────────────────────────── + +/// Pool of preset character names. The UI cycles through these with prev/next +/// buttons. Eventually this becomes a free-text input once a text-input widget +/// lands (Bevy 0.16 has no built-in single-line text input). +pub const CHARACTER_NAMES: &[&str] = &[ + "Vera Solenne", + "Kael Drennan", + "Idris Maro", + "Sloana Vex", + "Mira Cathan", + "Jove Rask", + "Nyssa Korbel", + "Tal Halberd", + "Rin Okafor", + "Des Cordova", +]; + +// ── Origins ───────────────────────────────────────────────────────────────── + +/// Background / origin archetype. Each entry pairs flavor text with the +/// mechanical knobs that will eventually feed into starting stats and +/// standing. Pure data — applied at game-start time (not yet wired up). +#[derive(Debug, Clone, Copy)] +pub struct Origin { + pub id: &'static str, + pub name: &'static str, + pub blurb: &'static str, + /// One-line mechanical summary shown in the picker. + pub bonus: &'static str, +} + +pub const ORIGINS: &[Origin] = &[ + Origin { + id: "navy", + name: "Navy Veteran", + blurb: "Years in a Concord patrol fleet taught you how to read a \ + battlefield and keep a ship together under fire. You left with \ + a modest pension, a contacts book full of quartermasters, and \ + a slightly exaggerated sense of discipline.", + bonus: "+15% shield recharge +10% weapon damage starts at +2.0 Concord standing", + }, + Origin { + id: "hauler", + name: "Freelance Hauler", + blurb: "You have moved everything from livestock to liquid hydrogen \ + along the inner trade lanes. Your ship is your home, your \ + ledgers are immaculate, and you can quote the salvage code of \ + every border station from memory.", + bonus: "+25% cargo capacity +10% warp velocity starts with a free trade permit", + }, + Origin { + id: "explorer", + name: "Frontier Explorer", + blurb: "Charted wormholes, mapped forgotten ruins, surveyed gas \ + giants nobody had named. Your logs are worth a fortune to the \ + right cartographer — and to the wrong faction.", + bonus: "+30% scan range +15% anomaly yield starts with advanced survey drone", + }, + Origin { + id: "outcast", + name: "Outer-Rim Outcast", + blurb: "Born past the gate network where the only law is the cargo \ + you can defend. You learned to fly before you could read, and \ + you have the arrest record (and the enemies) to prove it.", + bonus: "+10% sub-warp speed +20% black-market prices starts at −2.0 Concord standing", + }, +]; + +// ── Starting ships ────────────────────────────────────────────────────────── + +/// Starting ship archetype. The picked hull seeds the player's initial entity +/// loadout once `AppState::InGame` is wired up. Flavor text only for now. +#[derive(Debug, Clone, Copy)] +pub struct StartingShip { + pub id: &'static str, + pub name: &'static str, + pub role: &'static str, + pub blurb: &'static str, +} + +pub const STARTING_SHIPS: &[StartingShip] = &[ + StartingShip { + id: "frigate", + name: "Corsair-Class Frigate", + role: "Frigate · Combat", + blurb: "A nimble gunship: two turret hardpoints, reinforced plating, \ + and just enough hold for prize money. The cheap answer when \ + someone hails you with bad intentions.", + }, + StartingShip { + id: "hauler", + name: "Mule-Class Transport", + role: "Hauler · Trade", + blurb: "Slow, fat, and a profit engine with a heartbeat. The hull \ + tanks anything that doesn't catch you, and the cargo bay \ + fits a small fortune if you keep moving.", + }, + StartingShip { + id: "scout", + name: "Whisper-Class Scout", + role: "Scout · Exploration", + blurb: "Glass jaw, sharp eyes. Signature-cleared hull, long-range \ + sensors, and a probe launcher. Lives or dies on whether you \ + saw them first.", + }, +]; + +// ── Draft resource ────────────────────────────────────────────────────────── + +/// Editable character draft, mutated by the picker UI and read at confirm time. +/// +/// Indices are stored rather than the strings/structs themselves so cycling is +/// trivial and the resource stays `Copy`. Panels resolve the current selection +/// via [`CharacterDraft::name`] / [`CharacterDraft::origin`] / +/// [`CharacterDraft::ship`]. +#[derive(Resource, Debug, Clone, Copy, Default)] +pub struct CharacterDraft { + pub name_index: usize, + pub origin_index: usize, + pub ship_index: usize, +} + +impl CharacterDraft { + pub fn name(&self) -> &'static str { + CHARACTER_NAMES[self.name_index % CHARACTER_NAMES.len()] + } + + pub fn origin(&self) -> &'static Origin { + &ORIGINS[self.origin_index % ORIGINS.len()] + } + + pub fn ship(&self) -> &'static StartingShip { + &STARTING_SHIPS[self.ship_index % STARTING_SHIPS.len()] + } + + /// Advance `field` by `delta`, wrapping around the length of `options`. + /// Generic over the picker so the same code handles name / origin / ship. + pub fn cycle(field: usize, delta: i32, options: usize) -> usize { + debug_assert!(options > 0); + let signed = field as i32 + delta; + let modulo = signed.rem_euclid(options as i32); + modulo as usize + } + + pub fn cycle_name(&mut self, delta: i32) { + self.name_index = Self::cycle(self.name_index, delta, CHARACTER_NAMES.len()); + } + + pub fn cycle_origin(&mut self, delta: i32) { + self.origin_index = Self::cycle(self.origin_index, delta, ORIGINS.len()); + } + + pub fn cycle_ship(&mut self, delta: i32) { + self.ship_index = Self::cycle(self.ship_index, delta, STARTING_SHIPS.len()); + } +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn name_index_wraps_forward() { + let mut d = CharacterDraft { + name_index: CHARACTER_NAMES.len() - 1, + ..Default::default() + }; + d.cycle_name(1); + assert_eq!(d.name_index, 0); + } + + #[test] + fn name_index_wraps_backward() { + let mut d = CharacterDraft { + name_index: 0, + ..Default::default() + }; + d.cycle_name(-1); + assert_eq!(d.name_index, CHARACTER_NAMES.len() - 1); + } + + #[test] + fn origin_index_advances_within_bounds() { + let mut d = CharacterDraft::default(); + d.cycle_origin(1); + assert_eq!(d.origin_index, 1); + assert_eq!(d.origin().id, ORIGINS[1].id); + } + + #[test] + fn cycle_handles_large_negative() { + // -100 % 4 should land on a valid index, never panic. + let i = CharacterDraft::cycle(0, -100, ORIGINS.len()); + assert!(i < ORIGINS.len()); + } + + #[test] + fn draft_resolves_current_selections() { + let d = CharacterDraft { + name_index: 2, + origin_index: 1, + ship_index: 0, + }; + assert_eq!(d.name(), CHARACTER_NAMES[2]); + assert_eq!(d.origin().id, ORIGINS[1].id); + assert_eq!(d.ship().id, STARTING_SHIPS[0].id); + } +} diff --git a/apps/game/src/gameplay/character_creation/ui.rs b/apps/game/src/gameplay/character_creation/ui.rs new file mode 100644 index 0000000..2436b62 --- /dev/null +++ b/apps/game/src/gameplay/character_creation/ui.rs @@ -0,0 +1,546 @@ +//! Character creation UI: a centered single-column layout with three pickers +//! (name / origin / starting ship) and Confirm / Back buttons. +//! +//! Each picker is a row of `[<] label value [>]`. The `[<]` and `[>]` +//! buttons carry a [`PickerButton`] marker identifying which field to cycle +//! and in which direction; [`refresh_picker_values`] patches the displayed +//! `Text` from [`super::params::CharacterDraft`] every frame so the panels +//! stay in sync without re-spawning. +//! +//! Visual language mirrors the galaxy control panel +//! ([`crate::gameplay::galaxy::ui`]): dark navy panels, cyan borders, dim +//! labels, bright values. + +use bevy::prelude::*; + +use super::{CharacterCreationSpawned, CharacterCreationButton}; +use crate::gameplay::character_creation::params::{ + CharacterDraft, ORIGINS, STARTING_SHIPS, CHARACTER_NAMES, +}; + +// ── Styling constants ─────────────────────────────────────────────────────── + +const PANEL_BG: Color = Color::srgb(0.05, 0.07, 0.12); +const PANEL_BORDER: Color = Color::srgb(0.25, 0.40, 0.62); +const ACCENT: Color = Color::srgb(0.32, 0.58, 0.92); +const TEXT_BRIGHT: Color = Color::srgb(0.82, 0.90, 1.0); +const TEXT_DIM: Color = Color::srgb(0.55, 0.65, 0.82); +const TEXT_FADED: Color = Color::srgb(0.42, 0.52, 0.68); +const BUTTON_BG: Color = Color::srgb(0.10, 0.14, 0.22); +const BUTTON_BG_CONFIRM: Color = Color::srgb(0.10, 0.28, 0.22); +const BUTTON_BORDER_CONFIRM: Color = Color::srgb(0.30, 0.72, 0.45); + +const TITLE_FONT_SIZE: f32 = 36.0; +const SUBTITLE_FONT_SIZE: f32 = 16.0; +const SECTION_FONT_SIZE: f32 = 13.0; +const LABEL_FONT_SIZE: f32 = 15.0; +const VALUE_FONT_SIZE: f32 = 17.0; +const BLURB_FONT_SIZE: f32 = 13.0; +const BUTTON_FONT_SIZE: f32 = 18.0; +const ARROW_FONT_SIZE: f32 = 20.0; + +const PICKER_WIDTH: f32 = 520.0; +const ARROW_BUTTON: f32 = 36.0; + +// ── Markers ───────────────────────────────────────────────────────────────── + +/// Identifies which draft field a `Picker [<] [>]` button mutates, and the +/// direction. Stored as a small enum so a single system can handle every +/// arrow button in one pass. +#[derive(Component, Clone, Copy)] +pub enum PickerButton { + Name(i32), + Origin(i32), + Ship(i32), +} + +/// Tag on the `Text` node that renders the current name value, so +/// [`refresh_picker_values`] can find and patch it without re-spawning. +#[derive(Component)] +pub struct NameValue; + +#[derive(Component)] +pub struct OriginValue; + +#[derive(Component)] +pub struct ShipValue; + +/// Blurb blocks under each picker — refreshed together with the value. +#[derive(Component)] +pub struct OriginBlurb; + +#[derive(Component)] +pub struct ShipBlurb; + +#[derive(Component)] +pub struct OriginBonus; + +// ── Setup ─────────────────────────────────────────────────────────────────── + +pub fn setup_character_creation_ui(mut commands: Commands) { + commands + .spawn(( + Node { + width: Val::Percent(100.0), + height: Val::Percent(100.0), + display: Display::Flex, + flex_direction: FlexDirection::Column, + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + row_gap: Val::Px(18.0), + padding: UiRect::all(Val::Px(24.0)), + ..default() + }, + BackgroundColor(Color::srgb(0.02, 0.02, 0.06)), + CharacterCreationSpawned, + )) + .with_children(|root| { + // ── Title block ────────────────────────────────────────────── + root.spawn(( + Text::new("Character Creation"), + TextFont { + font_size: TITLE_FONT_SIZE, + ..default() + }, + TextColor(TEXT_BRIGHT), + )); + root.spawn(( + Text::new("Pick a name, an origin, and a ship. Confirm to begin."), + TextFont { + font_size: SUBTITLE_FONT_SIZE, + ..default() + }, + TextColor(TEXT_DIM), + Node { + margin: UiRect::bottom(Val::Px(8.0)), + ..default() + }, + )); + + // ── Main card ──────────────────────────────────────────────── + root.spawn(( + Node { + width: Val::Px(PICKER_WIDTH + 64.0), + padding: UiRect::all(Val::Px(24.0)), + flex_direction: FlexDirection::Column, + row_gap: Val::Px(18.0), + border: UiRect::all(Val::Px(1.0)), + ..default() + }, + BackgroundColor(PANEL_BG), + BorderColor(PANEL_BORDER), + BorderRadius::all(Val::Px(10.0)), + )) + .with_children(|card| { + spawn_section_label(card, "NAME"); + spawn_picker_row(card, "Callsign", PickerButton::Name(-1), PickerButton::Name(1)); + spawn_spacer(card); + + spawn_section_label(card, "ORIGIN"); + spawn_picker_row_with_marker( + card, + "Background", + PickerButton::Origin(-1), + PickerButton::Origin(1), + OriginValue, + ); + card.spawn(( + Text::new(""), + TextFont { + font_size: BLURB_FONT_SIZE, + ..default() + }, + TextColor(TEXT_DIM), + Node { + width: Val::Percent(100.0), + ..default() + }, + OriginBlurb, + )); + card.spawn(( + Text::new(""), + TextFont { + font_size: BLURB_FONT_SIZE, + ..default() + }, + TextColor(ACCENT), + Node { + width: Val::Percent(100.0), + margin: UiRect::top(Val::Px(2.0)), + ..default() + }, + OriginBonus, + )); + spawn_spacer(card); + + spawn_section_label(card, "STARTING SHIP"); + spawn_picker_row_with_marker( + card, + "Hull", + PickerButton::Ship(-1), + PickerButton::Ship(1), + ShipValue, + ); + card.spawn(( + Text::new(""), + TextFont { + font_size: BLURB_FONT_SIZE, + ..default() + }, + TextColor(TEXT_DIM), + Node { + width: Val::Percent(100.0), + ..default() + }, + ShipBlurb, + )); + }); + + // ── Button row ────────────────────────────────────────────── + root + .spawn(Node { + flex_direction: FlexDirection::Row, + column_gap: Val::Px(16.0), + margin: UiRect::top(Val::Px(4.0)), + ..default() + }) + .with_children(|row| { + spawn_action_button( + row, + "Confirm", + BUTTON_BG_CONFIRM, + BUTTON_BORDER_CONFIRM, + CharacterCreationButton::Confirm, + ); + spawn_action_button( + row, + "Back", + BUTTON_BG, + PANEL_BORDER, + CharacterCreationButton::Back, + ); + }); + + // ── Footer hint ───────────────────────────────────────────── + root.spawn(( + Text::new("Esc to go back"), + TextFont { + font_size: BLURB_FONT_SIZE, + ..default() + }, + TextColor(TEXT_FADED), + )); + }); +} + +// ── Sub-spawners ──────────────────────────────────────────────────────────── + +fn spawn_section_label(parent: &mut ChildSpawnerCommands, label: &str) { + parent.spawn(( + Text::new(label), + TextFont { + font_size: SECTION_FONT_SIZE, + ..default() + }, + TextColor(TEXT_FADED), + Node { + margin: UiRect::bottom(Val::Px(2.0)), + ..default() + }, + )); +} + +fn spawn_spacer(parent: &mut ChildSpawnerCommands) { + parent.spawn(Node { + height: Val::Px(4.0), + ..default() + }); +} + +/// `[<] label value [>]` row with the default value marker — used for +/// the name picker, which has no blurb and uses the generic `NameValue`. +fn spawn_picker_row( + parent: &mut ChildSpawnerCommands, + label: &str, + decr: PickerButton, + incr: PickerButton, +) { + spawn_picker_row_with_marker(parent, label, decr, incr, NameValue); +} + +/// Same as [`spawn_picker_row`] but lets the caller pick which value marker +/// to attach (name vs origin vs ship). +fn spawn_picker_row_with_marker( + parent: &mut ChildSpawnerCommands, + label: &str, + decr: PickerButton, + incr: PickerButton, + value_marker: M, +) { + parent + .spawn(Node { + width: Val::Percent(100.0), + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: Val::Px(10.0), + ..default() + }) + .with_children(|row| { + // [<] + spawn_arrow_button(row, "‹", decr); + // label + row.spawn(( + Text::new(label), + TextFont { + font_size: LABEL_FONT_SIZE, + ..default() + }, + TextColor(TEXT_DIM), + Node { + width: Val::Px(110.0), + ..default() + }, + )); + // value — flexible width so it grows to fill + row.spawn(( + Text::new("—"), + TextFont { + font_size: VALUE_FONT_SIZE, + ..default() + }, + TextColor(TEXT_BRIGHT), + Node { + flex_grow: 1.0, + ..default() + }, + value_marker, + )); + // [+] + spawn_arrow_button(row, "›", incr); + }); +} + +fn spawn_arrow_button(parent: &mut ChildSpawnerCommands, glyph: &str, marker: PickerButton) { + parent + .spawn(( + Button, + Node { + width: Val::Px(ARROW_BUTTON), + height: Val::Px(ARROW_BUTTON), + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + border: UiRect::all(Val::Px(1.0)), + ..default() + }, + BackgroundColor(BUTTON_BG), + BorderColor(PANEL_BORDER), + BorderRadius::all(Val::Px(6.0)), + marker, + )) + .with_children(|btn| { + btn.spawn(( + Text::new(glyph), + TextFont { + font_size: ARROW_FONT_SIZE, + ..default() + }, + TextColor(TEXT_BRIGHT), + )); + }); +} + +fn spawn_action_button( + parent: &mut ChildSpawnerCommands, + label: &str, + bg: Color, + border: Color, + marker: CharacterCreationButton, +) { + parent + .spawn(( + Button, + Node { + width: Val::Px(200.0), + height: Val::Px(48.0), + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + border: UiRect::all(Val::Px(1.0)), + ..default() + }, + BackgroundColor(bg), + BorderColor(border), + BorderRadius::all(Val::Px(8.0)), + marker, + )) + .with_children(|btn| { + btn.spawn(( + Text::new(label), + TextFont { + font_size: BUTTON_FONT_SIZE, + ..default() + }, + TextColor(TEXT_BRIGHT), + )); + }); +} + +// ── Refresh ───────────────────────────────────────────────────────────────── + +/// Patch every dynamic `Text` child from the current draft. Cheap (≤6 nodes) +/// and avoids re-spawning the whole panel on every selection change. +/// +/// Each `Text` node carries exactly one of the marker components below; the +/// `Without<>` filters keep the queries disjoint so `.single_mut()` can never +/// hit an ambiguous match. Bevy's Query tuples are inherently verbose — +/// `clippy::type_complexity` is allowed on this function by convention. +#[allow(clippy::type_complexity)] +pub fn refresh_picker_values( + draft: Res, + mut name_q: Query<&mut Text, With>, + mut origin_q: Query<&mut Text, (With, Without, Without)>, + mut ship_q: Query<&mut Text, (With, Without, Without)>, + mut origin_blurb_q: Query< + &mut Text, + ( + With, + Without, + Without, + Without, + Without, + Without, + ), + >, + mut origin_bonus_q: Query< + &mut Text, + ( + With, + Without, + Without, + Without, + Without, + Without, + ), + >, + mut ship_blurb_q: Query< + &mut Text, + ( + With, + Without, + Without, + Without, + Without, + Without, + ), + >, +) { + // The draft only changes when a picker button is pressed; if the resource + // hasn't been mutated, we can skip the whole pass. + if !draft.is_changed() { + return; + } + + if let Ok(mut txt) = name_q.single_mut() { + if txt.0 != draft.name() { + txt.0 = draft.name().to_string(); + } + } + + let origin = draft.origin(); + if let Ok(mut txt) = origin_q.single_mut() { + if txt.0 != origin.name { + txt.0 = origin.name.to_string(); + } + } + if let Ok(mut txt) = origin_blurb_q.single_mut() { + if txt.0 != origin.blurb { + txt.0 = origin.blurb.to_string(); + } + } + if let Ok(mut txt) = origin_bonus_q.single_mut() { + if txt.0 != origin.bonus { + txt.0 = origin.bonus.to_string(); + } + } + + let ship = draft.ship(); + if let Ok(mut txt) = ship_q.single_mut() { + let label = format!("{} · {}", ship.name, ship.role); + if txt.0 != label { + txt.0 = label; + } + } + if let Ok(mut txt) = ship_blurb_q.single_mut() { + if txt.0 != ship.blurb { + txt.0 = ship.blurb.to_string(); + } + } +} + +// ── Button handlers ───────────────────────────────────────────────────────── + +/// Handle every picker `[<] [>]` press: cycle the matching draft field by the +/// button's signed delta. The next frame's [`refresh_picker_values`] will +/// propagate the new value into the UI. +pub fn picker_button_handler( + mut draft: ResMut, + query: Query<(&Interaction, &PickerButton), Changed>, +) { + for (interaction, button) in &query { + if *interaction != Interaction::Pressed { + continue; + } + match *button { + PickerButton::Name(delta) => draft.cycle_name(delta), + PickerButton::Origin(delta) => draft.cycle_origin(delta), + PickerButton::Ship(delta) => draft.cycle_ship(delta), + } + } +} + +/// Confirm / Back buttons. Confirm transitions to `AppState::InGame`; Back +/// returns to `AppState::Galaxy`. Kept in a single system since neither needs +/// extra resources — the draft is persisted later by the (yet-to-be-written) +/// save pipeline. +pub fn action_button_handler( + mut next_state: ResMut>, + draft: Res, + query: Query<(&Interaction, &CharacterCreationButton), Changed>, +) { + for (interaction, button) in &query { + if *interaction != Interaction::Pressed { + continue; + } + match button { + CharacterCreationButton::Confirm => { + // TODO: persist `*draft` to SpacetimeDB before entering the game. + bevy::log::info!( + "Confirming character: name={}, origin={}, ship={}", + draft.name(), + draft.origin().id, + draft.ship().id, + ); + next_state.set(crate::state::AppState::InGame); + } + CharacterCreationButton::Back => { + next_state.set(crate::state::AppState::Galaxy); + } + } + } +} + +// ── Sanity check: name/origin/ship counts must agree ─────────────────────── +// +// These constants are here so a quick grep tells future-you where the UI's +// per-section expectations live. The picker code tags each value Text with +// exactly one of `NameValue` / `OriginValue` / `ShipValue`, so the layout +// must spawn exactly one of each — if you add a second value Text, the +// `.single_mut()` queries in `refresh_picker_values` will panic. + +/// Compile-time assertion that the presets are non-empty. The picker's +/// modulo math assumes `len > 0`; if either list ever becomes empty by +/// mistake, this surfaces the bug at the call site rather than at runtime. +const _PRESET_NONEMPTY: () = { + assert!(!CHARACTER_NAMES.is_empty()); + assert!(!ORIGINS.is_empty()); + assert!(!STARTING_SHIPS.is_empty()); +};