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.
|
//! Character creation scene.
|
||||||
//!
|
//!
|
||||||
//! Barebones skeleton: spawns a placeholder UI on enter, despawns on exit,
|
//! Layout / picker UI lives in [`ui`]; presets and the editable draft
|
||||||
//! and wires a `Confirm` button that transitions to [`AppState::InGame`] and
|
//! resource live in [`params`]. This module wires the Bevy plugin, owns the
|
||||||
//! a `Back` button (or Escape) that returns to [`AppState::Galaxy`].
|
//! spawn / despawn lifecycle, and handles the Escape → Galaxy shortcut.
|
||||||
//!
|
//!
|
||||||
//! Intended to grow into the real character creator — name, portrait, stats,
|
//! Persistence is not yet wired up — on Confirm, the current draft is logged
|
||||||
//! starting ship, background / origin, etc. Each major aspect should split
|
//! and the app transitions to [`AppState::InGame`]. The TODO is to persist it
|
||||||
//! into its own submodule (e.g. `ui.rs`, `params.rs`, `presets.rs`) once it
|
//! to SpacetimeDB once the Rust SDK is integrated.
|
||||||
//! outgrows this file (see the module-layout guidelines in `apps/game/AGENTS.md`).
|
|
||||||
|
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::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
@@ -17,7 +26,11 @@ pub struct CharacterCreationPlugin;
|
|||||||
|
|
||||||
impl Plugin for CharacterCreationPlugin {
|
impl Plugin for CharacterCreationPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
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(
|
.add_systems(
|
||||||
OnExit(AppState::CharacterCreation),
|
OnExit(AppState::CharacterCreation),
|
||||||
despawn_character_creation,
|
despawn_character_creation,
|
||||||
@@ -26,9 +39,11 @@ impl Plugin for CharacterCreationPlugin {
|
|||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
escape_to_galaxy,
|
escape_to_galaxy,
|
||||||
confirm_button_handler,
|
ui::picker_button_handler,
|
||||||
back_button_handler,
|
ui::refresh_picker_values,
|
||||||
|
ui::action_button_handler,
|
||||||
)
|
)
|
||||||
|
.chain()
|
||||||
.run_if(in_state(AppState::CharacterCreation)),
|
.run_if(in_state(AppState::CharacterCreation)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -49,104 +64,6 @@ pub enum CharacterCreationButton {
|
|||||||
Back,
|
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 ───────────────────────────────────────────────────────────────
|
// ── Lifecycle ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn despawn_character_creation(
|
fn despawn_character_creation(
|
||||||
@@ -169,26 +86,3 @@ fn escape_to_galaxy(
|
|||||||
next_state.set(AppState::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