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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
222
apps/game/src/gameplay/character_creation/params.rs
Normal file
222
apps/game/src/gameplay/character_creation/params.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
546
apps/game/src/gameplay/character_creation/ui.rs
Normal file
546
apps/game/src/gameplay/character_creation/ui.rs
Normal 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());
|
||||
};
|
||||
Reference in New Issue
Block a user