Split character creation into mod/params/ui submodules

Promote the monolithic character_creation/mod.rs into a folder per
apps/game/AGENTS.md: lays out the picker UI in ui.rs, holds presets
and the editable CharacterDraft resource in params.rs, and keeps
mod.rs as the plugin + lifecycle shell.

Re-exports CharacterDraft / Origin / StartingShip at the module root
for the (yet-to-be-wired) persistence layer.
This commit is contained in:
2026-06-07 22:19:52 -04:00
parent 8a966b9584
commit b372a75a84
3 changed files with 793 additions and 131 deletions

View File

@@ -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::<CharacterDraft>()
.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<NextState<AppState>>,
query: Query<(&Interaction, &CharacterCreationButton), Changed<Interaction>>,
) {
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<NextState<AppState>>,
query: Query<(&Interaction, &CharacterCreationButton), Changed<Interaction>>,
) {
for (interaction, button) in &query {
if *interaction == Interaction::Pressed && matches!(button, CharacterCreationButton::Back) {
next_state.set(AppState::Galaxy);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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<M: Component>(
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<CharacterDraft>,
mut name_q: Query<&mut Text, With<NameValue>>,
mut origin_q: Query<&mut Text, (With<OriginValue>, Without<NameValue>, Without<ShipValue>)>,
mut ship_q: Query<&mut Text, (With<ShipValue>, Without<NameValue>, Without<OriginValue>)>,
mut origin_blurb_q: Query<
&mut Text,
(
With<OriginBlurb>,
Without<NameValue>,
Without<OriginValue>,
Without<ShipValue>,
Without<OriginBonus>,
Without<ShipBlurb>,
),
>,
mut origin_bonus_q: Query<
&mut Text,
(
With<OriginBonus>,
Without<NameValue>,
Without<OriginValue>,
Without<ShipValue>,
Without<OriginBlurb>,
Without<ShipBlurb>,
),
>,
mut ship_blurb_q: Query<
&mut Text,
(
With<ShipBlurb>,
Without<NameValue>,
Without<OriginValue>,
Without<ShipValue>,
Without<OriginBlurb>,
Without<OriginBonus>,
),
>,
) {
// 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<CharacterDraft>,
query: Query<(&Interaction, &PickerButton), Changed<Interaction>>,
) {
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<NextState<crate::state::AppState>>,
draft: Res<CharacterDraft>,
query: Query<(&Interaction, &CharacterCreationButton), Changed<Interaction>>,
) {
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());
};