diff --git a/apps/game/src/gameplay/character_creation/mod.rs b/apps/game/src/gameplay/character_creation/mod.rs index 42c8996..2b2f519 100644 --- a/apps/game/src/gameplay/character_creation/mod.rs +++ b/apps/game/src/gameplay/character_creation/mod.rs @@ -5,8 +5,8 @@ //! spawn / despawn lifecycle, and handles the Escape → Galaxy shortcut. //! //! 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. +//! and the app transitions to [`AppState::StartingBaseSelection`]. The TODO is +//! to persist it to SpacetimeDB once the Rust SDK is integrated. mod params; mod ui; @@ -39,6 +39,7 @@ impl Plugin for CharacterCreationPlugin { Update, ( escape_to_galaxy, + ui::scroll_character_creation_form, ui::picker_button_handler, ui::refresh_picker_values, ui::action_button_handler, @@ -78,10 +79,7 @@ fn despawn_character_creation( // ── Input ─────────────────────────────────────────────────────────────────── -fn escape_to_galaxy( - keys: Res>, - mut next_state: ResMut>, -) { +fn escape_to_galaxy(keys: Res>, mut next_state: ResMut>) { if keys.just_pressed(KeyCode::Escape) { 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 index 334c27e..1334060 100644 --- a/apps/game/src/gameplay/character_creation/params.rs +++ b/apps/game/src/gameplay/character_creation/params.rs @@ -117,6 +117,217 @@ pub const STARTING_SHIPS: &[StartingShip] = &[ }, ]; +// ── Bannerlord-style backstory ───────────────────────────────────────────── + +pub const BACKSTORY_QUESTION_COUNT: usize = 5; + +#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)] +pub struct CharacterStats { + pub command: i32, + pub piloting: i32, + pub gunnery: i32, + pub engineering: i32, + pub trade: i32, + pub scouting: i32, + pub diplomacy: i32, + pub grit: i32, +} + +impl CharacterStats { + pub const fn new( + command: i32, + piloting: i32, + gunnery: i32, + engineering: i32, + trade: i32, + scouting: i32, + diplomacy: i32, + grit: i32, + ) -> Self { + Self { + command, + piloting, + gunnery, + engineering, + trade, + scouting, + diplomacy, + grit, + } + } + + pub fn add(self, other: Self) -> Self { + Self { + command: self.command + other.command, + piloting: self.piloting + other.piloting, + gunnery: self.gunnery + other.gunnery, + engineering: self.engineering + other.engineering, + trade: self.trade + other.trade, + scouting: self.scouting + other.scouting, + diplomacy: self.diplomacy + other.diplomacy, + grit: self.grit + other.grit, + } + } + + pub fn summary(self) -> String { + format!( + "Command {} Piloting {} Gunnery {} Engineering {} Trade {} Scouting {} Diplomacy {} Grit {}", + self.command, + self.piloting, + self.gunnery, + self.engineering, + self.trade, + self.scouting, + self.diplomacy, + self.grit, + ) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct BackstoryChoice { + pub id: &'static str, + pub name: &'static str, + pub blurb: &'static str, + pub stats: CharacterStats, +} + +#[derive(Debug, Clone, Copy)] +pub struct BackstoryQuestion { + pub prompt: &'static str, + pub choices: &'static [BackstoryChoice], +} + +pub const CHILDHOOD_CHOICES: &[BackstoryChoice] = &[ + BackstoryChoice { + id: "dockborn", + name: "Raised on station docks", + blurb: + "You grew up under gantries, learning ships by sound before you ever touched a helm.", + stats: CharacterStats::new(0, 1, 0, 2, 1, 0, 0, 0), + }, + BackstoryChoice { + id: "merchant_house", + name: "Merchant house ward", + blurb: "Contract law, polite threats, and price sheets were dinner-table conversation.", + stats: CharacterStats::new(0, 0, 0, 0, 3, 0, 1, 0), + }, + BackstoryChoice { + id: "frontier_clan", + name: "Frontier clan child", + blurb: + "Your first lessons were vacuum discipline, ration math, and how to spot trouble early.", + stats: CharacterStats::new(0, 1, 0, 0, 0, 2, 0, 1), + }, +]; + +pub const APPRENTICESHIP_CHOICES: &[BackstoryChoice] = &[ + BackstoryChoice { + id: "engine_rat", + name: "Engine-room apprentice", + blurb: "You patched coolant lines with your sleeves rolled up and alarms howling.", + stats: CharacterStats::new(0, 0, 0, 3, 0, 0, 0, 1), + }, + BackstoryChoice { + id: "deck_cadet", + name: "Deck cadet", + blurb: "You drilled formation flying, boarding defense, and the art of sounding calm.", + stats: CharacterStats::new(2, 1, 1, 0, 0, 0, 0, 0), + }, + BackstoryChoice { + id: "route_scout", + name: "Route scout", + blurb: "You ran courier paths ahead of the main convoy with only sensors for company.", + stats: CharacterStats::new(0, 2, 0, 0, 0, 2, 0, 0), + }, +]; + +pub const FIRST_COMMAND_CHOICES: &[BackstoryChoice] = &[ + BackstoryChoice { + id: "convoy_guard", + name: "Convoy guard", + blurb: "Your first command kept traders alive through pirate weather and bad intel.", + stats: CharacterStats::new(1, 0, 2, 0, 1, 0, 0, 0), + }, + BackstoryChoice { + id: "salvage_skipper", + name: "Salvage skipper", + blurb: "You made a living from broken hulls, cold wrecks, and systems nobody trusted.", + stats: CharacterStats::new(0, 1, 0, 2, 0, 1, 0, 0), + }, + BackstoryChoice { + id: "free_trader", + name: "Free trader", + blurb: "You learned that a clean manifest is useful, and a flexible one is profitable.", + stats: CharacterStats::new(0, 0, 0, 0, 3, 0, 1, 0), + }, +]; + +pub const CRISIS_CHOICES: &[BackstoryChoice] = &[ + BackstoryChoice { + id: "mutiny_mediator", + name: "Settled a mutiny", + blurb: "You talked an armed crew down before anyone vented the wrong compartment.", + stats: CharacterStats::new(1, 0, 0, 0, 0, 0, 3, 0), + }, + BackstoryChoice { + id: "ambush_survivor", + name: "Survived an ambush", + blurb: "You limped home on backup thrusters and spite, with half your armor gone.", + stats: CharacterStats::new(0, 1, 2, 0, 0, 0, 0, 1), + }, + BackstoryChoice { + id: "blackout_fix", + name: "Restored a dark station", + blurb: "When the lights died, you brought power back one breaker at a time.", + stats: CharacterStats::new(0, 0, 0, 3, 0, 0, 0, 1), + }, +]; + +pub const REASON_CHOICES: &[BackstoryChoice] = &[ + BackstoryChoice { + id: "debt", + name: "A debt to settle", + blurb: "Someone owns a piece of your future. You intend to buy it back.", + stats: CharacterStats::new(0, 0, 0, 0, 2, 0, 0, 2), + }, + BackstoryChoice { + id: "lost_signal", + name: "A lost signal", + blurb: "A voice from the dark keeps appearing in old survey bands. You need answers.", + stats: CharacterStats::new(0, 1, 0, 0, 0, 3, 0, 0), + }, + BackstoryChoice { + id: "new_banner", + name: "A banner of your own", + blurb: "You are done serving other captains. The next legend wears your colors.", + stats: CharacterStats::new(3, 0, 1, 0, 0, 0, 0, 0), + }, +]; + +pub const BACKSTORY_QUESTIONS: &[BackstoryQuestion; BACKSTORY_QUESTION_COUNT] = &[ + BackstoryQuestion { + prompt: "You were born...", + choices: CHILDHOOD_CHOICES, + }, + BackstoryQuestion { + prompt: "As a young adult, you trained as...", + choices: APPRENTICESHIP_CHOICES, + }, + BackstoryQuestion { + prompt: "Your first command was...", + choices: FIRST_COMMAND_CHOICES, + }, + BackstoryQuestion { + prompt: "You became known when you...", + choices: CRISIS_CHOICES, + }, + BackstoryQuestion { + prompt: "You set out because of...", + choices: REASON_CHOICES, + }, +]; + // ── Draft resource ────────────────────────────────────────────────────────── /// Editable character draft, mutated by the picker UI and read at confirm time. @@ -130,6 +341,7 @@ pub struct CharacterDraft { pub name_index: usize, pub origin_index: usize, pub ship_index: usize, + pub backstory_indices: [usize; BACKSTORY_QUESTION_COUNT], } impl CharacterDraft { @@ -145,6 +357,20 @@ impl CharacterDraft { &STARTING_SHIPS[self.ship_index % STARTING_SHIPS.len()] } + pub fn backstory_choice(&self, question_index: usize) -> &'static BackstoryChoice { + let question = &BACKSTORY_QUESTIONS[question_index % BACKSTORY_QUESTIONS.len()]; + let choice_index = self.backstory_indices[question_index] % question.choices.len(); + &question.choices[choice_index] + } + + pub fn stats(&self) -> CharacterStats { + let mut stats = CharacterStats::default(); + for question_index in 0..BACKSTORY_QUESTION_COUNT { + stats = stats.add(self.backstory_choice(question_index).stats); + } + stats + } + /// 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 { @@ -165,6 +391,15 @@ impl CharacterDraft { pub fn cycle_ship(&mut self, delta: i32) { self.ship_index = Self::cycle(self.ship_index, delta, STARTING_SHIPS.len()); } + + pub fn cycle_backstory(&mut self, question_index: usize, delta: i32) { + let question = &BACKSTORY_QUESTIONS[question_index % BACKSTORY_QUESTIONS.len()]; + self.backstory_indices[question_index] = Self::cycle( + self.backstory_indices[question_index], + delta, + question.choices.len(), + ); + } } // ── Tests ─────────────────────────────────────────────────────────────────── @@ -214,9 +449,19 @@ mod tests { name_index: 2, origin_index: 1, ship_index: 0, + backstory_indices: [0; BACKSTORY_QUESTION_COUNT], }; assert_eq!(d.name(), CHARACTER_NAMES[2]); assert_eq!(d.origin().id, ORIGINS[1].id); assert_eq!(d.ship().id, STARTING_SHIPS[0].id); } + + #[test] + fn backstory_choices_wrap_and_grant_stats() { + let mut d = CharacterDraft::default(); + d.cycle_backstory(0, -1); + assert_eq!(d.backstory_choice(0).id, "frontier_clan"); + assert_eq!(d.stats().scouting, 2); + assert_eq!(d.stats().grit, 4); + } } diff --git a/apps/game/src/gameplay/character_creation/ui.rs b/apps/game/src/gameplay/character_creation/ui.rs index 2436b62..8b4ac50 100644 --- a/apps/game/src/gameplay/character_creation/ui.rs +++ b/apps/game/src/gameplay/character_creation/ui.rs @@ -1,5 +1,5 @@ -//! Character creation UI: a centered single-column layout with three pickers -//! (name / origin / starting ship) and Confirm / Back buttons. +//! Character creation UI: a sticky header, scrollable form, and sticky +//! Confirm / Back buttons. //! //! Each picker is a row of `[<] label value [>]`. The `[<]` and `[>]` //! buttons carry a [`PickerButton`] marker identifying which field to cycle @@ -11,11 +11,12 @@ //! ([`crate::gameplay::galaxy::ui`]): dark navy panels, cyan borders, dim //! labels, bright values. -use bevy::prelude::*; +use bevy::{input::mouse::MouseWheel, prelude::*, ui::ScrollPosition, window::PrimaryWindow}; -use super::{CharacterCreationSpawned, CharacterCreationButton}; +use super::{CharacterCreationButton, CharacterCreationSpawned}; use crate::gameplay::character_creation::params::{ - CharacterDraft, ORIGINS, STARTING_SHIPS, CHARACTER_NAMES, + CharacterDraft, BACKSTORY_QUESTIONS, BACKSTORY_QUESTION_COUNT, CHARACTER_NAMES, ORIGINS, + STARTING_SHIPS, }; // ── Styling constants ─────────────────────────────────────────────────────── @@ -39,8 +40,8 @@ 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; +const CARD_WIDTH: f32 = 760.0; // ── Markers ───────────────────────────────────────────────────────────────── @@ -51,6 +52,7 @@ const ARROW_BUTTON: f32 = 36.0; pub enum PickerButton { Name(i32), Origin(i32), + Backstory(usize, i32), Ship(i32), } @@ -75,6 +77,18 @@ pub struct ShipBlurb; #[derive(Component)] pub struct OriginBonus; +#[derive(Component)] +pub struct BackstoryValue(pub usize); + +#[derive(Component)] +pub struct StatsSummary; + +#[derive(Component)] +pub struct CharacterCreationScrollViewport; + +#[derive(Component)] +pub struct CharacterCreationScrollContent; + // ── Setup ─────────────────────────────────────────────────────────────────── pub fn setup_character_creation_ui(mut commands: Commands) { @@ -85,9 +99,8 @@ pub fn setup_character_creation_ui(mut commands: Commands) { height: Val::Percent(100.0), display: Display::Flex, flex_direction: FlexDirection::Column, - justify_content: JustifyContent::Center, + justify_content: JustifyContent::FlexStart, align_items: AlignItems::Center, - row_gap: Val::Px(18.0), padding: UiRect::all(Val::Px(24.0)), ..default() }, @@ -105,121 +118,168 @@ pub fn setup_character_creation_ui(mut commands: Commands) { TextColor(TEXT_BRIGHT), )); root.spawn(( - Text::new("Pick a name, an origin, and a ship. Confirm to begin."), + Text::new( + "Pick a name, trace your backstory, choose a ship, and confirm to begin.", + ), TextFont { font_size: SUBTITLE_FONT_SIZE, ..default() }, TextColor(TEXT_DIM), Node { - margin: UiRect::bottom(Val::Px(8.0)), + margin: UiRect::bottom(Val::Px(12.0)), ..default() }, )); - // ── Main card ──────────────────────────────────────────────── + // ── Scrollable form ───────────────────────────────────────── root.spawn(( Node { - width: Val::Px(PICKER_WIDTH + 64.0), - padding: UiRect::all(Val::Px(24.0)), + width: Val::Percent(100.0), + flex_grow: 1.0, flex_direction: FlexDirection::Column, - row_gap: Val::Px(18.0), - border: UiRect::all(Val::Px(1.0)), + align_items: AlignItems::Center, + overflow: Overflow::scroll_y(), + padding: UiRect::vertical(Val::Px(4.0)), ..default() }, - BackgroundColor(PANEL_BG), - BorderColor(PANEL_BORDER), - BorderRadius::all(Val::Px(10.0)), + ScrollPosition::default(), + CharacterCreationScrollViewport, + CharacterCreationScrollContent, )) - .with_children(|card| { - spawn_section_label(card, "NAME"); - spawn_picker_row(card, "Callsign", PickerButton::Name(-1), PickerButton::Name(1)); - spawn_spacer(card); + .with_children(|scroll| { + scroll + .spawn(( + Node { + width: Val::Percent(100.0), + max_width: Val::Px(CARD_WIDTH), + padding: UiRect::all(Val::Px(20.0)), + flex_direction: FlexDirection::Column, + row_gap: Val::Px(12.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, "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, - )); + spawn_section_label(card, "BACKSTORY"); + spawn_backstory_rows(card); + 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() + }, + StatsSummary, + )); + 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, - ); - }); + root.spawn(Node { + width: Val::Percent(100.0), + max_width: Val::Px(CARD_WIDTH), + flex_direction: FlexDirection::Row, + flex_wrap: FlexWrap::Wrap, + justify_content: JustifyContent::Center, + column_gap: Val::Px(16.0), + row_gap: Val::Px(8.0), + margin: UiRect::top(Val::Px(12.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(( @@ -233,6 +293,51 @@ pub fn setup_character_creation_ui(mut commands: Commands) { }); } +// ── Scroll input ─────────────────────────────────────────────────────────── + +/// Route mouse-wheel events to the form's scroll container when the cursor is +/// over it. Bevy 0.16 does not wire `MouseWheel` to `ScrollPosition` +/// automatically, so this mirrors the galaxy panel bridge. +pub fn scroll_character_creation_form( + mut scroll_events: EventReader, + primary_window: Query<&Window, With>, + viewport_nodes: Query<(&ComputedNode, &GlobalTransform), With>, + mut scroll_content: Query<&mut ScrollPosition, With>, +) { + let Ok(window) = primary_window.single() else { + scroll_events.clear(); + return; + }; + + let Some(cursor_pos) = window.cursor_position() else { + scroll_events.clear(); + return; + }; + + let over_viewport = viewport_nodes.iter().any(|(node, transform)| { + let pos = transform.translation().truncate(); + let size = node.size(); + let min = pos - size * 0.5; + let max = pos + size * 0.5; + cursor_pos.x >= min.x + && cursor_pos.x <= max.x + && cursor_pos.y >= min.y + && cursor_pos.y <= max.y + }); + + if !over_viewport { + return; + } + + let Ok(mut scroll_pos) = scroll_content.single_mut() else { + return; + }; + + for event in scroll_events.read() { + scroll_pos.offset_y -= event.y * 20.0; + } +} + // ── Sub-spawners ──────────────────────────────────────────────────────────── fn spawn_section_label(parent: &mut ChildSpawnerCommands, label: &str) { @@ -320,6 +425,18 @@ fn spawn_picker_row_with_marker( }); } +fn spawn_backstory_rows(parent: &mut ChildSpawnerCommands) { + for (question_index, question) in BACKSTORY_QUESTIONS.iter().enumerate() { + spawn_picker_row_with_marker( + parent, + question.prompt, + PickerButton::Backstory(question_index, -1), + PickerButton::Backstory(question_index, 1), + BackstoryValue(question_index), + ); + } +} + fn spawn_arrow_button(parent: &mut ChildSpawnerCommands, glyph: &str, marker: PickerButton) { parent .spawn(( @@ -396,42 +513,16 @@ fn spawn_action_button( #[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, - ), - >, + mut text_queries: ParamSet<( + Query<&mut Text, With>, + Query<&mut Text, With>, + Query<&mut Text, With>, + Query<&mut Text, With>, + Query<(&BackstoryValue, &mut Text)>, + Query<&mut Text, With>, + Query<&mut Text, With>, + Query<&mut Text, With>, + )>, ) { // The draft only changes when a picker button is pressed; if the resource // hasn't been mutated, we can skip the whole pass. @@ -439,39 +530,77 @@ pub fn refresh_picker_values( return; } - if let Ok(mut txt) = name_q.single_mut() { - if txt.0 != draft.name() { - txt.0 = draft.name().to_string(); + { + let mut name_q = text_queries.p0(); + 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(); + { + let mut origin_q = text_queries.p1(); + 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(); + { + let mut origin_blurb_q = text_queries.p2(); + 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 mut origin_bonus_q = text_queries.p3(); + if let Ok(mut txt) = origin_bonus_q.single_mut() { + if txt.0 != origin.bonus { + txt.0 = origin.bonus.to_string(); + } + } + } + + { + let mut backstory_q = text_queries.p4(); + for (marker, mut txt) in &mut backstory_q { + let choice = draft.backstory_choice(marker.0); + if txt.0 != choice.name { + txt.0 = choice.name.to_string(); + } + } + } + + { + let mut stats_q = text_queries.p5(); + if let Ok(mut txt) = stats_q.single_mut() { + let summary = draft.stats().summary(); + if txt.0 != summary { + txt.0 = summary; + } } } 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; + { + let mut ship_q = text_queries.p6(); + 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(); + { + let mut ship_blurb_q = text_queries.p7(); + if let Ok(mut txt) = ship_blurb_q.single_mut() { + if txt.0 != ship.blurb { + txt.0 = ship.blurb.to_string(); + } } } } @@ -492,12 +621,15 @@ pub fn picker_button_handler( match *button { PickerButton::Name(delta) => draft.cycle_name(delta), PickerButton::Origin(delta) => draft.cycle_origin(delta), + PickerButton::Backstory(question_index, delta) => { + draft.cycle_backstory(question_index, delta); + } PickerButton::Ship(delta) => draft.cycle_ship(delta), } } } -/// Confirm / Back buttons. Confirm transitions to `AppState::InGame`; Back +/// Confirm / Back buttons. Confirm advances to starting base selection; 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. @@ -514,12 +646,14 @@ pub fn action_button_handler( CharacterCreationButton::Confirm => { // TODO: persist `*draft` to SpacetimeDB before entering the game. bevy::log::info!( - "Confirming character: name={}, origin={}, ship={}", + "Confirming character: name={}, origin={}, ship={}, backstory={}, stats={}", draft.name(), draft.origin().id, draft.ship().id, + format_backstory_log(&draft), + draft.stats().summary(), ); - next_state.set(crate::state::AppState::InGame); + next_state.set(crate::state::AppState::StartingBaseSelection); } CharacterCreationButton::Back => { next_state.set(crate::state::AppState::Galaxy); @@ -528,6 +662,15 @@ pub fn action_button_handler( } } +fn format_backstory_log(draft: &CharacterDraft) -> String { + let mut entries = Vec::with_capacity(BACKSTORY_QUESTION_COUNT); + for question_index in 0..BACKSTORY_QUESTION_COUNT { + let choice = draft.backstory_choice(question_index); + entries.push(format!("{}: {}", choice.id, choice.blurb)); + } + entries.join(" | ") +} + // ── Sanity check: name/origin/ship counts must agree ─────────────────────── // // These constants are here so a quick grep tells future-you where the UI's @@ -543,4 +686,5 @@ const _PRESET_NONEMPTY: () = { assert!(!CHARACTER_NAMES.is_empty()); assert!(!ORIGINS.is_empty()); assert!(!STARTING_SHIPS.is_empty()); + assert!(BACKSTORY_QUESTIONS.len() == BACKSTORY_QUESTION_COUNT); }; diff --git a/apps/game/src/gameplay/galaxy/contents.rs b/apps/game/src/gameplay/galaxy/contents.rs index 6ab96ad..c0efe5e 100644 --- a/apps/game/src/gameplay/galaxy/contents.rs +++ b/apps/game/src/gameplay/galaxy/contents.rs @@ -661,11 +661,11 @@ fn system_hash(s: &str) -> u64 { /// same trick. Asteroid rocks share a single jittered icosphere mesh. pub struct ContentAssets { // Meshes - pub planet_mesh: Handle, // unit sphere - pub station_mesh: Handle, // unit cube - pub anomaly_mesh: Handle, // low-poly sphere (angular/crystalline) - pub gas_mesh: Handle, // unit sphere (used with translucent mat) - pub stargate_mesh: Handle, // unit torus + pub planet_mesh: Handle, // unit sphere + pub station_mesh: Handle, // unit cube + pub anomaly_mesh: Handle, // low-poly sphere (angular/crystalline) + pub gas_mesh: Handle, // unit sphere (used with translucent mat) + pub stargate_mesh: Handle, // unit torus /// Jittered icosphere shared by every asteroid instance across all belts. /// Per-rock variation comes from per-instance Transform + material pick. pub asteroid_mesh: Handle, @@ -820,8 +820,7 @@ pub fn spawn_system_contents( // (mild — linear formula, not Kepler). Y-jitter from // `rock.position.y` is preserved because the orbital // system only overwrites x/z. - let rock_radius = - Vec2::new(rock.position.x, rock.position.z).length(); + let rock_radius = Vec2::new(rock.position.x, rock.position.z).length(); let rock_phase = rock.position.z.atan2(rock.position.x); belt_parent.spawn(( Orbital { diff --git a/apps/game/src/gameplay/galaxy/mod.rs b/apps/game/src/gameplay/galaxy/mod.rs index 5cb3d11..afe784e 100644 --- a/apps/game/src/gameplay/galaxy/mod.rs +++ b/apps/game/src/gameplay/galaxy/mod.rs @@ -23,9 +23,9 @@ use crate::camera::apply_orbit_reset; use crate::state::AppState; pub use contents::{SystemContents, SystemContext, SystemSummary}; -pub use params::{GalaxyParams, SelectedStar}; pub use params::{BeamParams, CoreParams, DiskParams}; -use params::{NEAREST_NEIGHBOR_CONNECTIONS, SPACING_ATTEMPTS, MIN_SYSTEM_SPACING}; +pub use params::{GalaxyParams, SelectedStar}; +use params::{MIN_SYSTEM_SPACING, NEAREST_NEIGHBOR_CONNECTIONS, SPACING_ATTEMPTS}; pub struct GalaxyPlugin; @@ -37,10 +37,7 @@ impl Plugin for GalaxyPlugin { OnEnter(AppState::Galaxy), (setup_galaxy_scene, ui::setup_galaxy_ui), ) - .add_systems( - OnExit(AppState::Galaxy), - (despawn_galaxy, reset_selection), - ) + .add_systems(OnExit(AppState::Galaxy), (despawn_galaxy, reset_selection)) .add_systems( Update, ( @@ -114,6 +111,33 @@ const FACTIONS: &[(&str, [f32; 3])] = &[ ("Caldari", [0.22, 0.74, 0.97]), // blue ]; +const OUTER_STARTING_SYSTEM_RADIUS: f32 = 0.65; + +#[derive(Debug, Clone)] +pub struct StartingBaseCandidate { + pub id: String, + pub name: String, + pub faction: &'static str, + pub security: f32, + pub distance_from_core: f32, + pub contents: SystemContents, +} + +#[derive(Debug, Clone)] +pub struct StartingBaseMapSystem { + pub id: String, + pub position: Vec3, + pub color: [f32; 3], + pub candidate_index: Option, +} + +#[derive(Debug, Clone)] +pub struct StartingBaseMap { + pub systems: Vec, + pub candidates: Vec, + pub connections: Vec<(usize, usize)>, +} + // ── Setup ─────────────────────────────────────────────────────────────────── fn setup_galaxy_scene( @@ -182,6 +206,56 @@ fn generate_galaxy( (systems, contents, connections) } +pub fn generate_starting_base_map(params: &GalaxyParams) -> StartingBaseMap { + let (systems, contents, connections) = generate_galaxy(params); + let outer_radius = params.size * OUTER_STARTING_SYSTEM_RADIUS; + let mut candidates = Vec::new(); + let mut map_systems = Vec::with_capacity(systems.len()); + + for (system, contents) in systems.into_iter().zip(contents) { + let distance_from_core = system.position.length(); + if !system.is_core && distance_from_core >= outer_radius { + candidates.push(StartingBaseCandidate { + id: system.id.clone(), + name: system.name.clone(), + faction: system.faction, + security: system.security, + distance_from_core, + contents, + }); + } + + map_systems.push(StartingBaseMapSystem { + id: system.id, + position: system.position, + color: system.color, + candidate_index: None, + }); + } + + candidates.sort_by(|a, b| { + b.distance_from_core + .partial_cmp(&a.distance_from_core) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.name.cmp(&b.name)) + }); + + for (index, candidate) in candidates.iter().enumerate() { + if let Some(system) = map_systems + .iter_mut() + .find(|system| system.id == candidate.id) + { + system.candidate_index = Some(index); + } + } + + StartingBaseMap { + systems: map_systems, + candidates, + connections, + } +} + /// Position-only galaxy generation. Composes multiple structural layers — /// a [`CoreParams`] cluster, up to [`MAX_DISKS`] independent [`DiskParams`] /// spiral disks, and two [`BeamParams`] columns along ±Y — into a single @@ -196,10 +270,7 @@ fn generate_system_positions(params: &GalaxyParams, rng: &mut StdRng) -> Vec = Vec::with_capacity(total); // Spacing scales with overall density: tighter for crowded galaxies, @@ -210,7 +281,14 @@ fn generate_system_positions(params: &GalaxyParams, rng: &mut StdRng) -> Vec::None; @@ -531,7 +608,9 @@ enum SystemOrigin { /// Which arm within the disk. arm: u32, }, - Beam { side: BeamTag }, + Beam { + side: BeamTag, + }, } #[allow(dead_code)] @@ -611,7 +690,13 @@ fn spawn_galaxy_scene( // Parent group so all galaxy contents despawn together. commands - .spawn((Transform::default(), GalaxyScene, GalaxySpawned)) + .spawn(( + Transform::default(), + Visibility::default(), + InheritedVisibility::default(), + GalaxyScene, + GalaxySpawned, + )) .with_children(|parent| { // XYZ reference axes through the origin. axes::spawn_axes(parent, meshes, materials); @@ -639,6 +724,8 @@ fn spawn_galaxy_scene( parent .spawn(( Transform::from_translation(sys.position), + Visibility::default(), + InheritedVisibility::default(), StarSystem { id: sys.id.clone(), name: sys.name.clone(), @@ -733,10 +820,7 @@ fn build_connections(systems: &[GeneratedSystem]) -> Vec<(usize, usize)> { // ── Lifecycle systems ─────────────────────────────────────────────────────── -fn despawn_galaxy( - mut commands: Commands, - query: Query>, -) { +fn despawn_galaxy(mut commands: Commands, query: Query>) { for entity in &query { // Bevy 0.16: despawn() is recursive by default. commands.entity(entity).despawn(); diff --git a/apps/game/src/gameplay/galaxy/orbits.rs b/apps/game/src/gameplay/galaxy/orbits.rs index 1573de1..a497ddf 100644 --- a/apps/game/src/gameplay/galaxy/orbits.rs +++ b/apps/game/src/gameplay/galaxy/orbits.rs @@ -41,10 +41,7 @@ use super::poi::Orbital; /// Advance every [`Orbital`] along its path. /// /// See module docs for the math and the hierarchy assumption. -pub fn advance_orbital_paths( - time: Res