feat(gameplay): add starting base module and refine galaxy/character creation systems
Co-Authored-By: Oz <oz-agent@warp.dev>
This commit is contained in:
@@ -5,8 +5,8 @@
|
||||
//! spawn / despawn lifecycle, and handles the Escape → Galaxy shortcut.
|
||||
//!
|
||||
//! Persistence is not yet wired up — on Confirm, the current draft is logged
|
||||
//! and the app transitions to [`AppState::InGame`]. The TODO is to persist it
|
||||
//! to SpacetimeDB once the Rust SDK is integrated.
|
||||
//! and the app transitions to [`AppState::StartingBaseSelection`]. The TODO is
|
||||
//! to persist it to SpacetimeDB once the Rust SDK is integrated.
|
||||
|
||||
mod params;
|
||||
mod ui;
|
||||
@@ -39,6 +39,7 @@ impl Plugin for CharacterCreationPlugin {
|
||||
Update,
|
||||
(
|
||||
escape_to_galaxy,
|
||||
ui::scroll_character_creation_form,
|
||||
ui::picker_button_handler,
|
||||
ui::refresh_picker_values,
|
||||
ui::action_button_handler,
|
||||
@@ -78,10 +79,7 @@ fn despawn_character_creation(
|
||||
|
||||
// ── Input ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn escape_to_galaxy(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut next_state: ResMut<NextState<AppState>>,
|
||||
) {
|
||||
fn escape_to_galaxy(keys: Res<ButtonInput<KeyCode>>, mut next_state: ResMut<NextState<AppState>>) {
|
||||
if keys.just_pressed(KeyCode::Escape) {
|
||||
next_state.set(AppState::Galaxy);
|
||||
}
|
||||
|
||||
@@ -117,6 +117,217 @@ pub const STARTING_SHIPS: &[StartingShip] = &[
|
||||
},
|
||||
];
|
||||
|
||||
// ── Bannerlord-style backstory ─────────────────────────────────────────────
|
||||
|
||||
pub const BACKSTORY_QUESTION_COUNT: usize = 5;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
|
||||
pub struct CharacterStats {
|
||||
pub command: i32,
|
||||
pub piloting: i32,
|
||||
pub gunnery: i32,
|
||||
pub engineering: i32,
|
||||
pub trade: i32,
|
||||
pub scouting: i32,
|
||||
pub diplomacy: i32,
|
||||
pub grit: i32,
|
||||
}
|
||||
|
||||
impl CharacterStats {
|
||||
pub const fn new(
|
||||
command: i32,
|
||||
piloting: i32,
|
||||
gunnery: i32,
|
||||
engineering: i32,
|
||||
trade: i32,
|
||||
scouting: i32,
|
||||
diplomacy: i32,
|
||||
grit: i32,
|
||||
) -> Self {
|
||||
Self {
|
||||
command,
|
||||
piloting,
|
||||
gunnery,
|
||||
engineering,
|
||||
trade,
|
||||
scouting,
|
||||
diplomacy,
|
||||
grit,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add(self, other: Self) -> Self {
|
||||
Self {
|
||||
command: self.command + other.command,
|
||||
piloting: self.piloting + other.piloting,
|
||||
gunnery: self.gunnery + other.gunnery,
|
||||
engineering: self.engineering + other.engineering,
|
||||
trade: self.trade + other.trade,
|
||||
scouting: self.scouting + other.scouting,
|
||||
diplomacy: self.diplomacy + other.diplomacy,
|
||||
grit: self.grit + other.grit,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn summary(self) -> String {
|
||||
format!(
|
||||
"Command {} Piloting {} Gunnery {} Engineering {} Trade {} Scouting {} Diplomacy {} Grit {}",
|
||||
self.command,
|
||||
self.piloting,
|
||||
self.gunnery,
|
||||
self.engineering,
|
||||
self.trade,
|
||||
self.scouting,
|
||||
self.diplomacy,
|
||||
self.grit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct BackstoryChoice {
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
pub blurb: &'static str,
|
||||
pub stats: CharacterStats,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct BackstoryQuestion {
|
||||
pub prompt: &'static str,
|
||||
pub choices: &'static [BackstoryChoice],
|
||||
}
|
||||
|
||||
pub const CHILDHOOD_CHOICES: &[BackstoryChoice] = &[
|
||||
BackstoryChoice {
|
||||
id: "dockborn",
|
||||
name: "Raised on station docks",
|
||||
blurb:
|
||||
"You grew up under gantries, learning ships by sound before you ever touched a helm.",
|
||||
stats: CharacterStats::new(0, 1, 0, 2, 1, 0, 0, 0),
|
||||
},
|
||||
BackstoryChoice {
|
||||
id: "merchant_house",
|
||||
name: "Merchant house ward",
|
||||
blurb: "Contract law, polite threats, and price sheets were dinner-table conversation.",
|
||||
stats: CharacterStats::new(0, 0, 0, 0, 3, 0, 1, 0),
|
||||
},
|
||||
BackstoryChoice {
|
||||
id: "frontier_clan",
|
||||
name: "Frontier clan child",
|
||||
blurb:
|
||||
"Your first lessons were vacuum discipline, ration math, and how to spot trouble early.",
|
||||
stats: CharacterStats::new(0, 1, 0, 0, 0, 2, 0, 1),
|
||||
},
|
||||
];
|
||||
|
||||
pub const APPRENTICESHIP_CHOICES: &[BackstoryChoice] = &[
|
||||
BackstoryChoice {
|
||||
id: "engine_rat",
|
||||
name: "Engine-room apprentice",
|
||||
blurb: "You patched coolant lines with your sleeves rolled up and alarms howling.",
|
||||
stats: CharacterStats::new(0, 0, 0, 3, 0, 0, 0, 1),
|
||||
},
|
||||
BackstoryChoice {
|
||||
id: "deck_cadet",
|
||||
name: "Deck cadet",
|
||||
blurb: "You drilled formation flying, boarding defense, and the art of sounding calm.",
|
||||
stats: CharacterStats::new(2, 1, 1, 0, 0, 0, 0, 0),
|
||||
},
|
||||
BackstoryChoice {
|
||||
id: "route_scout",
|
||||
name: "Route scout",
|
||||
blurb: "You ran courier paths ahead of the main convoy with only sensors for company.",
|
||||
stats: CharacterStats::new(0, 2, 0, 0, 0, 2, 0, 0),
|
||||
},
|
||||
];
|
||||
|
||||
pub const FIRST_COMMAND_CHOICES: &[BackstoryChoice] = &[
|
||||
BackstoryChoice {
|
||||
id: "convoy_guard",
|
||||
name: "Convoy guard",
|
||||
blurb: "Your first command kept traders alive through pirate weather and bad intel.",
|
||||
stats: CharacterStats::new(1, 0, 2, 0, 1, 0, 0, 0),
|
||||
},
|
||||
BackstoryChoice {
|
||||
id: "salvage_skipper",
|
||||
name: "Salvage skipper",
|
||||
blurb: "You made a living from broken hulls, cold wrecks, and systems nobody trusted.",
|
||||
stats: CharacterStats::new(0, 1, 0, 2, 0, 1, 0, 0),
|
||||
},
|
||||
BackstoryChoice {
|
||||
id: "free_trader",
|
||||
name: "Free trader",
|
||||
blurb: "You learned that a clean manifest is useful, and a flexible one is profitable.",
|
||||
stats: CharacterStats::new(0, 0, 0, 0, 3, 0, 1, 0),
|
||||
},
|
||||
];
|
||||
|
||||
pub const CRISIS_CHOICES: &[BackstoryChoice] = &[
|
||||
BackstoryChoice {
|
||||
id: "mutiny_mediator",
|
||||
name: "Settled a mutiny",
|
||||
blurb: "You talked an armed crew down before anyone vented the wrong compartment.",
|
||||
stats: CharacterStats::new(1, 0, 0, 0, 0, 0, 3, 0),
|
||||
},
|
||||
BackstoryChoice {
|
||||
id: "ambush_survivor",
|
||||
name: "Survived an ambush",
|
||||
blurb: "You limped home on backup thrusters and spite, with half your armor gone.",
|
||||
stats: CharacterStats::new(0, 1, 2, 0, 0, 0, 0, 1),
|
||||
},
|
||||
BackstoryChoice {
|
||||
id: "blackout_fix",
|
||||
name: "Restored a dark station",
|
||||
blurb: "When the lights died, you brought power back one breaker at a time.",
|
||||
stats: CharacterStats::new(0, 0, 0, 3, 0, 0, 0, 1),
|
||||
},
|
||||
];
|
||||
|
||||
pub const REASON_CHOICES: &[BackstoryChoice] = &[
|
||||
BackstoryChoice {
|
||||
id: "debt",
|
||||
name: "A debt to settle",
|
||||
blurb: "Someone owns a piece of your future. You intend to buy it back.",
|
||||
stats: CharacterStats::new(0, 0, 0, 0, 2, 0, 0, 2),
|
||||
},
|
||||
BackstoryChoice {
|
||||
id: "lost_signal",
|
||||
name: "A lost signal",
|
||||
blurb: "A voice from the dark keeps appearing in old survey bands. You need answers.",
|
||||
stats: CharacterStats::new(0, 1, 0, 0, 0, 3, 0, 0),
|
||||
},
|
||||
BackstoryChoice {
|
||||
id: "new_banner",
|
||||
name: "A banner of your own",
|
||||
blurb: "You are done serving other captains. The next legend wears your colors.",
|
||||
stats: CharacterStats::new(3, 0, 1, 0, 0, 0, 0, 0),
|
||||
},
|
||||
];
|
||||
|
||||
pub const BACKSTORY_QUESTIONS: &[BackstoryQuestion; BACKSTORY_QUESTION_COUNT] = &[
|
||||
BackstoryQuestion {
|
||||
prompt: "You were born...",
|
||||
choices: CHILDHOOD_CHOICES,
|
||||
},
|
||||
BackstoryQuestion {
|
||||
prompt: "As a young adult, you trained as...",
|
||||
choices: APPRENTICESHIP_CHOICES,
|
||||
},
|
||||
BackstoryQuestion {
|
||||
prompt: "Your first command was...",
|
||||
choices: FIRST_COMMAND_CHOICES,
|
||||
},
|
||||
BackstoryQuestion {
|
||||
prompt: "You became known when you...",
|
||||
choices: CRISIS_CHOICES,
|
||||
},
|
||||
BackstoryQuestion {
|
||||
prompt: "You set out because of...",
|
||||
choices: REASON_CHOICES,
|
||||
},
|
||||
];
|
||||
|
||||
// ── Draft resource ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Editable character draft, mutated by the picker UI and read at confirm time.
|
||||
@@ -130,6 +341,7 @@ pub struct CharacterDraft {
|
||||
pub name_index: usize,
|
||||
pub origin_index: usize,
|
||||
pub ship_index: usize,
|
||||
pub backstory_indices: [usize; BACKSTORY_QUESTION_COUNT],
|
||||
}
|
||||
|
||||
impl CharacterDraft {
|
||||
@@ -145,6 +357,20 @@ impl CharacterDraft {
|
||||
&STARTING_SHIPS[self.ship_index % STARTING_SHIPS.len()]
|
||||
}
|
||||
|
||||
pub fn backstory_choice(&self, question_index: usize) -> &'static BackstoryChoice {
|
||||
let question = &BACKSTORY_QUESTIONS[question_index % BACKSTORY_QUESTIONS.len()];
|
||||
let choice_index = self.backstory_indices[question_index] % question.choices.len();
|
||||
&question.choices[choice_index]
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> CharacterStats {
|
||||
let mut stats = CharacterStats::default();
|
||||
for question_index in 0..BACKSTORY_QUESTION_COUNT {
|
||||
stats = stats.add(self.backstory_choice(question_index).stats);
|
||||
}
|
||||
stats
|
||||
}
|
||||
|
||||
/// Advance `field` by `delta`, wrapping around the length of `options`.
|
||||
/// Generic over the picker so the same code handles name / origin / ship.
|
||||
pub fn cycle(field: usize, delta: i32, options: usize) -> usize {
|
||||
@@ -165,6 +391,15 @@ impl CharacterDraft {
|
||||
pub fn cycle_ship(&mut self, delta: i32) {
|
||||
self.ship_index = Self::cycle(self.ship_index, delta, STARTING_SHIPS.len());
|
||||
}
|
||||
|
||||
pub fn cycle_backstory(&mut self, question_index: usize, delta: i32) {
|
||||
let question = &BACKSTORY_QUESTIONS[question_index % BACKSTORY_QUESTIONS.len()];
|
||||
self.backstory_indices[question_index] = Self::cycle(
|
||||
self.backstory_indices[question_index],
|
||||
delta,
|
||||
question.choices.len(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
@@ -214,9 +449,19 @@ mod tests {
|
||||
name_index: 2,
|
||||
origin_index: 1,
|
||||
ship_index: 0,
|
||||
backstory_indices: [0; BACKSTORY_QUESTION_COUNT],
|
||||
};
|
||||
assert_eq!(d.name(), CHARACTER_NAMES[2]);
|
||||
assert_eq!(d.origin().id, ORIGINS[1].id);
|
||||
assert_eq!(d.ship().id, STARTING_SHIPS[0].id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backstory_choices_wrap_and_grant_stats() {
|
||||
let mut d = CharacterDraft::default();
|
||||
d.cycle_backstory(0, -1);
|
||||
assert_eq!(d.backstory_choice(0).id, "frontier_clan");
|
||||
assert_eq!(d.stats().scouting, 2);
|
||||
assert_eq!(d.stats().grit, 4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Character creation UI: a centered single-column layout with three pickers
|
||||
//! (name / origin / starting ship) and Confirm / Back buttons.
|
||||
//! Character creation UI: a sticky header, scrollable form, and sticky
|
||||
//! Confirm / Back buttons.
|
||||
//!
|
||||
//! Each picker is a row of `[<] label value [>]`. The `[<]` and `[>]`
|
||||
//! buttons carry a [`PickerButton`] marker identifying which field to cycle
|
||||
@@ -11,11 +11,12 @@
|
||||
//! ([`crate::gameplay::galaxy::ui`]): dark navy panels, cyan borders, dim
|
||||
//! labels, bright values.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::{input::mouse::MouseWheel, prelude::*, ui::ScrollPosition, window::PrimaryWindow};
|
||||
|
||||
use super::{CharacterCreationSpawned, CharacterCreationButton};
|
||||
use super::{CharacterCreationButton, CharacterCreationSpawned};
|
||||
use crate::gameplay::character_creation::params::{
|
||||
CharacterDraft, ORIGINS, STARTING_SHIPS, CHARACTER_NAMES,
|
||||
CharacterDraft, BACKSTORY_QUESTIONS, BACKSTORY_QUESTION_COUNT, CHARACTER_NAMES, ORIGINS,
|
||||
STARTING_SHIPS,
|
||||
};
|
||||
|
||||
// ── Styling constants ───────────────────────────────────────────────────────
|
||||
@@ -39,8 +40,8 @@ const BLURB_FONT_SIZE: f32 = 13.0;
|
||||
const BUTTON_FONT_SIZE: f32 = 18.0;
|
||||
const ARROW_FONT_SIZE: f32 = 20.0;
|
||||
|
||||
const PICKER_WIDTH: f32 = 520.0;
|
||||
const ARROW_BUTTON: f32 = 36.0;
|
||||
const CARD_WIDTH: f32 = 760.0;
|
||||
|
||||
// ── Markers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -51,6 +52,7 @@ const ARROW_BUTTON: f32 = 36.0;
|
||||
pub enum PickerButton {
|
||||
Name(i32),
|
||||
Origin(i32),
|
||||
Backstory(usize, i32),
|
||||
Ship(i32),
|
||||
}
|
||||
|
||||
@@ -75,6 +77,18 @@ pub struct ShipBlurb;
|
||||
#[derive(Component)]
|
||||
pub struct OriginBonus;
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct BackstoryValue(pub usize);
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct StatsSummary;
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct CharacterCreationScrollViewport;
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct CharacterCreationScrollContent;
|
||||
|
||||
// ── Setup ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn setup_character_creation_ui(mut commands: Commands) {
|
||||
@@ -85,9 +99,8 @@ pub fn setup_character_creation_ui(mut commands: Commands) {
|
||||
height: Val::Percent(100.0),
|
||||
display: Display::Flex,
|
||||
flex_direction: FlexDirection::Column,
|
||||
justify_content: JustifyContent::Center,
|
||||
justify_content: JustifyContent::FlexStart,
|
||||
align_items: AlignItems::Center,
|
||||
row_gap: Val::Px(18.0),
|
||||
padding: UiRect::all(Val::Px(24.0)),
|
||||
..default()
|
||||
},
|
||||
@@ -105,121 +118,168 @@ pub fn setup_character_creation_ui(mut commands: Commands) {
|
||||
TextColor(TEXT_BRIGHT),
|
||||
));
|
||||
root.spawn((
|
||||
Text::new("Pick a name, an origin, and a ship. Confirm to begin."),
|
||||
Text::new(
|
||||
"Pick a name, trace your backstory, choose a ship, and confirm to begin.",
|
||||
),
|
||||
TextFont {
|
||||
font_size: SUBTITLE_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_DIM),
|
||||
Node {
|
||||
margin: UiRect::bottom(Val::Px(8.0)),
|
||||
margin: UiRect::bottom(Val::Px(12.0)),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
|
||||
// ── Main card ────────────────────────────────────────────────
|
||||
// ── Scrollable form ─────────────────────────────────────────
|
||||
root.spawn((
|
||||
Node {
|
||||
width: Val::Px(PICKER_WIDTH + 64.0),
|
||||
padding: UiRect::all(Val::Px(24.0)),
|
||||
width: Val::Percent(100.0),
|
||||
flex_grow: 1.0,
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: Val::Px(18.0),
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
align_items: AlignItems::Center,
|
||||
overflow: Overflow::scroll_y(),
|
||||
padding: UiRect::vertical(Val::Px(4.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(PANEL_BG),
|
||||
BorderColor(PANEL_BORDER),
|
||||
BorderRadius::all(Val::Px(10.0)),
|
||||
ScrollPosition::default(),
|
||||
CharacterCreationScrollViewport,
|
||||
CharacterCreationScrollContent,
|
||||
))
|
||||
.with_children(|card| {
|
||||
spawn_section_label(card, "NAME");
|
||||
spawn_picker_row(card, "Callsign", PickerButton::Name(-1), PickerButton::Name(1));
|
||||
spawn_spacer(card);
|
||||
.with_children(|scroll| {
|
||||
scroll
|
||||
.spawn((
|
||||
Node {
|
||||
width: Val::Percent(100.0),
|
||||
max_width: Val::Px(CARD_WIDTH),
|
||||
padding: UiRect::all(Val::Px(20.0)),
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: Val::Px(12.0),
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(PANEL_BG),
|
||||
BorderColor(PANEL_BORDER),
|
||||
BorderRadius::all(Val::Px(10.0)),
|
||||
))
|
||||
.with_children(|card| {
|
||||
spawn_section_label(card, "NAME");
|
||||
spawn_picker_row(
|
||||
card,
|
||||
"Callsign",
|
||||
PickerButton::Name(-1),
|
||||
PickerButton::Name(1),
|
||||
);
|
||||
spawn_spacer(card);
|
||||
|
||||
spawn_section_label(card, "ORIGIN");
|
||||
spawn_picker_row_with_marker(
|
||||
card,
|
||||
"Background",
|
||||
PickerButton::Origin(-1),
|
||||
PickerButton::Origin(1),
|
||||
OriginValue,
|
||||
);
|
||||
card.spawn((
|
||||
Text::new(""),
|
||||
TextFont {
|
||||
font_size: BLURB_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_DIM),
|
||||
Node {
|
||||
width: Val::Percent(100.0),
|
||||
..default()
|
||||
},
|
||||
OriginBlurb,
|
||||
));
|
||||
card.spawn((
|
||||
Text::new(""),
|
||||
TextFont {
|
||||
font_size: BLURB_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(ACCENT),
|
||||
Node {
|
||||
width: Val::Percent(100.0),
|
||||
margin: UiRect::top(Val::Px(2.0)),
|
||||
..default()
|
||||
},
|
||||
OriginBonus,
|
||||
));
|
||||
spawn_spacer(card);
|
||||
spawn_section_label(card, "ORIGIN");
|
||||
spawn_picker_row_with_marker(
|
||||
card,
|
||||
"Background",
|
||||
PickerButton::Origin(-1),
|
||||
PickerButton::Origin(1),
|
||||
OriginValue,
|
||||
);
|
||||
card.spawn((
|
||||
Text::new(""),
|
||||
TextFont {
|
||||
font_size: BLURB_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_DIM),
|
||||
Node {
|
||||
width: Val::Percent(100.0),
|
||||
..default()
|
||||
},
|
||||
OriginBlurb,
|
||||
));
|
||||
card.spawn((
|
||||
Text::new(""),
|
||||
TextFont {
|
||||
font_size: BLURB_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(ACCENT),
|
||||
Node {
|
||||
width: Val::Percent(100.0),
|
||||
margin: UiRect::top(Val::Px(2.0)),
|
||||
..default()
|
||||
},
|
||||
OriginBonus,
|
||||
));
|
||||
spawn_spacer(card);
|
||||
|
||||
spawn_section_label(card, "STARTING SHIP");
|
||||
spawn_picker_row_with_marker(
|
||||
card,
|
||||
"Hull",
|
||||
PickerButton::Ship(-1),
|
||||
PickerButton::Ship(1),
|
||||
ShipValue,
|
||||
);
|
||||
card.spawn((
|
||||
Text::new(""),
|
||||
TextFont {
|
||||
font_size: BLURB_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_DIM),
|
||||
Node {
|
||||
width: Val::Percent(100.0),
|
||||
..default()
|
||||
},
|
||||
ShipBlurb,
|
||||
));
|
||||
spawn_section_label(card, "BACKSTORY");
|
||||
spawn_backstory_rows(card);
|
||||
card.spawn((
|
||||
Text::new(""),
|
||||
TextFont {
|
||||
font_size: BLURB_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(ACCENT),
|
||||
Node {
|
||||
width: Val::Percent(100.0),
|
||||
margin: UiRect::top(Val::Px(2.0)),
|
||||
..default()
|
||||
},
|
||||
StatsSummary,
|
||||
));
|
||||
spawn_spacer(card);
|
||||
|
||||
spawn_section_label(card, "STARTING SHIP");
|
||||
spawn_picker_row_with_marker(
|
||||
card,
|
||||
"Hull",
|
||||
PickerButton::Ship(-1),
|
||||
PickerButton::Ship(1),
|
||||
ShipValue,
|
||||
);
|
||||
card.spawn((
|
||||
Text::new(""),
|
||||
TextFont {
|
||||
font_size: BLURB_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_DIM),
|
||||
Node {
|
||||
width: Val::Percent(100.0),
|
||||
..default()
|
||||
},
|
||||
ShipBlurb,
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
// ── Button row ──────────────────────────────────────────────
|
||||
root
|
||||
.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: Val::Px(16.0),
|
||||
margin: UiRect::top(Val::Px(4.0)),
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
spawn_action_button(
|
||||
row,
|
||||
"Confirm",
|
||||
BUTTON_BG_CONFIRM,
|
||||
BUTTON_BORDER_CONFIRM,
|
||||
CharacterCreationButton::Confirm,
|
||||
);
|
||||
spawn_action_button(
|
||||
row,
|
||||
"Back",
|
||||
BUTTON_BG,
|
||||
PANEL_BORDER,
|
||||
CharacterCreationButton::Back,
|
||||
);
|
||||
});
|
||||
root.spawn(Node {
|
||||
width: Val::Percent(100.0),
|
||||
max_width: Val::Px(CARD_WIDTH),
|
||||
flex_direction: FlexDirection::Row,
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
justify_content: JustifyContent::Center,
|
||||
column_gap: Val::Px(16.0),
|
||||
row_gap: Val::Px(8.0),
|
||||
margin: UiRect::top(Val::Px(12.0)),
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
spawn_action_button(
|
||||
row,
|
||||
"Confirm",
|
||||
BUTTON_BG_CONFIRM,
|
||||
BUTTON_BORDER_CONFIRM,
|
||||
CharacterCreationButton::Confirm,
|
||||
);
|
||||
spawn_action_button(
|
||||
row,
|
||||
"Back",
|
||||
BUTTON_BG,
|
||||
PANEL_BORDER,
|
||||
CharacterCreationButton::Back,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Footer hint ─────────────────────────────────────────────
|
||||
root.spawn((
|
||||
@@ -233,6 +293,51 @@ pub fn setup_character_creation_ui(mut commands: Commands) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Scroll input ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Route mouse-wheel events to the form's scroll container when the cursor is
|
||||
/// over it. Bevy 0.16 does not wire `MouseWheel` to `ScrollPosition`
|
||||
/// automatically, so this mirrors the galaxy panel bridge.
|
||||
pub fn scroll_character_creation_form(
|
||||
mut scroll_events: EventReader<MouseWheel>,
|
||||
primary_window: Query<&Window, With<PrimaryWindow>>,
|
||||
viewport_nodes: Query<(&ComputedNode, &GlobalTransform), With<CharacterCreationScrollViewport>>,
|
||||
mut scroll_content: Query<&mut ScrollPosition, With<CharacterCreationScrollContent>>,
|
||||
) {
|
||||
let Ok(window) = primary_window.single() else {
|
||||
scroll_events.clear();
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(cursor_pos) = window.cursor_position() else {
|
||||
scroll_events.clear();
|
||||
return;
|
||||
};
|
||||
|
||||
let over_viewport = viewport_nodes.iter().any(|(node, transform)| {
|
||||
let pos = transform.translation().truncate();
|
||||
let size = node.size();
|
||||
let min = pos - size * 0.5;
|
||||
let max = pos + size * 0.5;
|
||||
cursor_pos.x >= min.x
|
||||
&& cursor_pos.x <= max.x
|
||||
&& cursor_pos.y >= min.y
|
||||
&& cursor_pos.y <= max.y
|
||||
});
|
||||
|
||||
if !over_viewport {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(mut scroll_pos) = scroll_content.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
for event in scroll_events.read() {
|
||||
scroll_pos.offset_y -= event.y * 20.0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sub-spawners ────────────────────────────────────────────────────────────
|
||||
|
||||
fn spawn_section_label(parent: &mut ChildSpawnerCommands, label: &str) {
|
||||
@@ -320,6 +425,18 @@ fn spawn_picker_row_with_marker<M: Component>(
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_backstory_rows(parent: &mut ChildSpawnerCommands) {
|
||||
for (question_index, question) in BACKSTORY_QUESTIONS.iter().enumerate() {
|
||||
spawn_picker_row_with_marker(
|
||||
parent,
|
||||
question.prompt,
|
||||
PickerButton::Backstory(question_index, -1),
|
||||
PickerButton::Backstory(question_index, 1),
|
||||
BackstoryValue(question_index),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_arrow_button(parent: &mut ChildSpawnerCommands, glyph: &str, marker: PickerButton) {
|
||||
parent
|
||||
.spawn((
|
||||
@@ -396,42 +513,16 @@ fn spawn_action_button(
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn refresh_picker_values(
|
||||
draft: Res<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>,
|
||||
),
|
||||
>,
|
||||
mut text_queries: ParamSet<(
|
||||
Query<&mut Text, With<NameValue>>,
|
||||
Query<&mut Text, With<OriginValue>>,
|
||||
Query<&mut Text, With<OriginBlurb>>,
|
||||
Query<&mut Text, With<OriginBonus>>,
|
||||
Query<(&BackstoryValue, &mut Text)>,
|
||||
Query<&mut Text, With<StatsSummary>>,
|
||||
Query<&mut Text, With<ShipValue>>,
|
||||
Query<&mut Text, With<ShipBlurb>>,
|
||||
)>,
|
||||
) {
|
||||
// The draft only changes when a picker button is pressed; if the resource
|
||||
// hasn't been mutated, we can skip the whole pass.
|
||||
@@ -439,39 +530,77 @@ pub fn refresh_picker_values(
|
||||
return;
|
||||
}
|
||||
|
||||
if let Ok(mut txt) = name_q.single_mut() {
|
||||
if txt.0 != draft.name() {
|
||||
txt.0 = draft.name().to_string();
|
||||
{
|
||||
let mut name_q = text_queries.p0();
|
||||
if let Ok(mut txt) = name_q.single_mut() {
|
||||
if txt.0 != draft.name() {
|
||||
txt.0 = draft.name().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let origin = draft.origin();
|
||||
if let Ok(mut txt) = origin_q.single_mut() {
|
||||
if txt.0 != origin.name {
|
||||
txt.0 = origin.name.to_string();
|
||||
{
|
||||
let mut origin_q = text_queries.p1();
|
||||
if let Ok(mut txt) = origin_q.single_mut() {
|
||||
if txt.0 != origin.name {
|
||||
txt.0 = origin.name.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(mut txt) = origin_blurb_q.single_mut() {
|
||||
if txt.0 != origin.blurb {
|
||||
txt.0 = origin.blurb.to_string();
|
||||
{
|
||||
let mut origin_blurb_q = text_queries.p2();
|
||||
if let Ok(mut txt) = origin_blurb_q.single_mut() {
|
||||
if txt.0 != origin.blurb {
|
||||
txt.0 = origin.blurb.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(mut txt) = origin_bonus_q.single_mut() {
|
||||
if txt.0 != origin.bonus {
|
||||
txt.0 = origin.bonus.to_string();
|
||||
{
|
||||
let mut origin_bonus_q = text_queries.p3();
|
||||
if let Ok(mut txt) = origin_bonus_q.single_mut() {
|
||||
if txt.0 != origin.bonus {
|
||||
txt.0 = origin.bonus.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut backstory_q = text_queries.p4();
|
||||
for (marker, mut txt) in &mut backstory_q {
|
||||
let choice = draft.backstory_choice(marker.0);
|
||||
if txt.0 != choice.name {
|
||||
txt.0 = choice.name.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut stats_q = text_queries.p5();
|
||||
if let Ok(mut txt) = stats_q.single_mut() {
|
||||
let summary = draft.stats().summary();
|
||||
if txt.0 != summary {
|
||||
txt.0 = summary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ship = draft.ship();
|
||||
if let Ok(mut txt) = ship_q.single_mut() {
|
||||
let label = format!("{} · {}", ship.name, ship.role);
|
||||
if txt.0 != label {
|
||||
txt.0 = label;
|
||||
{
|
||||
let mut ship_q = text_queries.p6();
|
||||
if let Ok(mut txt) = ship_q.single_mut() {
|
||||
let label = format!("{} · {}", ship.name, ship.role);
|
||||
if txt.0 != label {
|
||||
txt.0 = label;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(mut txt) = ship_blurb_q.single_mut() {
|
||||
if txt.0 != ship.blurb {
|
||||
txt.0 = ship.blurb.to_string();
|
||||
{
|
||||
let mut ship_blurb_q = text_queries.p7();
|
||||
if let Ok(mut txt) = ship_blurb_q.single_mut() {
|
||||
if txt.0 != ship.blurb {
|
||||
txt.0 = ship.blurb.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -492,12 +621,15 @@ pub fn picker_button_handler(
|
||||
match *button {
|
||||
PickerButton::Name(delta) => draft.cycle_name(delta),
|
||||
PickerButton::Origin(delta) => draft.cycle_origin(delta),
|
||||
PickerButton::Backstory(question_index, delta) => {
|
||||
draft.cycle_backstory(question_index, delta);
|
||||
}
|
||||
PickerButton::Ship(delta) => draft.cycle_ship(delta),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirm / Back buttons. Confirm transitions to `AppState::InGame`; Back
|
||||
/// Confirm / Back buttons. Confirm advances to starting base selection; Back
|
||||
/// returns to `AppState::Galaxy`. Kept in a single system since neither needs
|
||||
/// extra resources — the draft is persisted later by the (yet-to-be-written)
|
||||
/// save pipeline.
|
||||
@@ -514,12 +646,14 @@ pub fn action_button_handler(
|
||||
CharacterCreationButton::Confirm => {
|
||||
// TODO: persist `*draft` to SpacetimeDB before entering the game.
|
||||
bevy::log::info!(
|
||||
"Confirming character: name={}, origin={}, ship={}",
|
||||
"Confirming character: name={}, origin={}, ship={}, backstory={}, stats={}",
|
||||
draft.name(),
|
||||
draft.origin().id,
|
||||
draft.ship().id,
|
||||
format_backstory_log(&draft),
|
||||
draft.stats().summary(),
|
||||
);
|
||||
next_state.set(crate::state::AppState::InGame);
|
||||
next_state.set(crate::state::AppState::StartingBaseSelection);
|
||||
}
|
||||
CharacterCreationButton::Back => {
|
||||
next_state.set(crate::state::AppState::Galaxy);
|
||||
@@ -528,6 +662,15 @@ pub fn action_button_handler(
|
||||
}
|
||||
}
|
||||
|
||||
fn format_backstory_log(draft: &CharacterDraft) -> String {
|
||||
let mut entries = Vec::with_capacity(BACKSTORY_QUESTION_COUNT);
|
||||
for question_index in 0..BACKSTORY_QUESTION_COUNT {
|
||||
let choice = draft.backstory_choice(question_index);
|
||||
entries.push(format!("{}: {}", choice.id, choice.blurb));
|
||||
}
|
||||
entries.join(" | ")
|
||||
}
|
||||
|
||||
// ── Sanity check: name/origin/ship counts must agree ───────────────────────
|
||||
//
|
||||
// These constants are here so a quick grep tells future-you where the UI's
|
||||
@@ -543,4 +686,5 @@ const _PRESET_NONEMPTY: () = {
|
||||
assert!(!CHARACTER_NAMES.is_empty());
|
||||
assert!(!ORIGINS.is_empty());
|
||||
assert!(!STARTING_SHIPS.is_empty());
|
||||
assert!(BACKSTORY_QUESTIONS.len() == BACKSTORY_QUESTION_COUNT);
|
||||
};
|
||||
|
||||
@@ -661,11 +661,11 @@ fn system_hash(s: &str) -> u64 {
|
||||
/// same trick. Asteroid rocks share a single jittered icosphere mesh.
|
||||
pub struct ContentAssets {
|
||||
// Meshes
|
||||
pub planet_mesh: Handle<Mesh>, // unit sphere
|
||||
pub station_mesh: Handle<Mesh>, // unit cube
|
||||
pub anomaly_mesh: Handle<Mesh>, // low-poly sphere (angular/crystalline)
|
||||
pub gas_mesh: Handle<Mesh>, // unit sphere (used with translucent mat)
|
||||
pub stargate_mesh: Handle<Mesh>, // unit torus
|
||||
pub planet_mesh: Handle<Mesh>, // unit sphere
|
||||
pub station_mesh: Handle<Mesh>, // unit cube
|
||||
pub anomaly_mesh: Handle<Mesh>, // low-poly sphere (angular/crystalline)
|
||||
pub gas_mesh: Handle<Mesh>, // unit sphere (used with translucent mat)
|
||||
pub stargate_mesh: Handle<Mesh>, // unit torus
|
||||
/// Jittered icosphere shared by every asteroid instance across all belts.
|
||||
/// Per-rock variation comes from per-instance Transform + material pick.
|
||||
pub asteroid_mesh: Handle<Mesh>,
|
||||
@@ -820,8 +820,7 @@ pub fn spawn_system_contents(
|
||||
// (mild — linear formula, not Kepler). Y-jitter from
|
||||
// `rock.position.y` is preserved because the orbital
|
||||
// system only overwrites x/z.
|
||||
let rock_radius =
|
||||
Vec2::new(rock.position.x, rock.position.z).length();
|
||||
let rock_radius = Vec2::new(rock.position.x, rock.position.z).length();
|
||||
let rock_phase = rock.position.z.atan2(rock.position.x);
|
||||
belt_parent.spawn((
|
||||
Orbital {
|
||||
|
||||
@@ -23,9 +23,9 @@ use crate::camera::apply_orbit_reset;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub use contents::{SystemContents, SystemContext, SystemSummary};
|
||||
pub use params::{GalaxyParams, SelectedStar};
|
||||
pub use params::{BeamParams, CoreParams, DiskParams};
|
||||
use params::{NEAREST_NEIGHBOR_CONNECTIONS, SPACING_ATTEMPTS, MIN_SYSTEM_SPACING};
|
||||
pub use params::{GalaxyParams, SelectedStar};
|
||||
use params::{MIN_SYSTEM_SPACING, NEAREST_NEIGHBOR_CONNECTIONS, SPACING_ATTEMPTS};
|
||||
|
||||
pub struct GalaxyPlugin;
|
||||
|
||||
@@ -37,10 +37,7 @@ impl Plugin for GalaxyPlugin {
|
||||
OnEnter(AppState::Galaxy),
|
||||
(setup_galaxy_scene, ui::setup_galaxy_ui),
|
||||
)
|
||||
.add_systems(
|
||||
OnExit(AppState::Galaxy),
|
||||
(despawn_galaxy, reset_selection),
|
||||
)
|
||||
.add_systems(OnExit(AppState::Galaxy), (despawn_galaxy, reset_selection))
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -114,6 +111,33 @@ const FACTIONS: &[(&str, [f32; 3])] = &[
|
||||
("Caldari", [0.22, 0.74, 0.97]), // blue
|
||||
];
|
||||
|
||||
const OUTER_STARTING_SYSTEM_RADIUS: f32 = 0.65;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StartingBaseCandidate {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub faction: &'static str,
|
||||
pub security: f32,
|
||||
pub distance_from_core: f32,
|
||||
pub contents: SystemContents,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StartingBaseMapSystem {
|
||||
pub id: String,
|
||||
pub position: Vec3,
|
||||
pub color: [f32; 3],
|
||||
pub candidate_index: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StartingBaseMap {
|
||||
pub systems: Vec<StartingBaseMapSystem>,
|
||||
pub candidates: Vec<StartingBaseCandidate>,
|
||||
pub connections: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
// ── Setup ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn setup_galaxy_scene(
|
||||
@@ -182,6 +206,56 @@ fn generate_galaxy(
|
||||
(systems, contents, connections)
|
||||
}
|
||||
|
||||
pub fn generate_starting_base_map(params: &GalaxyParams) -> StartingBaseMap {
|
||||
let (systems, contents, connections) = generate_galaxy(params);
|
||||
let outer_radius = params.size * OUTER_STARTING_SYSTEM_RADIUS;
|
||||
let mut candidates = Vec::new();
|
||||
let mut map_systems = Vec::with_capacity(systems.len());
|
||||
|
||||
for (system, contents) in systems.into_iter().zip(contents) {
|
||||
let distance_from_core = system.position.length();
|
||||
if !system.is_core && distance_from_core >= outer_radius {
|
||||
candidates.push(StartingBaseCandidate {
|
||||
id: system.id.clone(),
|
||||
name: system.name.clone(),
|
||||
faction: system.faction,
|
||||
security: system.security,
|
||||
distance_from_core,
|
||||
contents,
|
||||
});
|
||||
}
|
||||
|
||||
map_systems.push(StartingBaseMapSystem {
|
||||
id: system.id,
|
||||
position: system.position,
|
||||
color: system.color,
|
||||
candidate_index: None,
|
||||
});
|
||||
}
|
||||
|
||||
candidates.sort_by(|a, b| {
|
||||
b.distance_from_core
|
||||
.partial_cmp(&a.distance_from_core)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
|
||||
for (index, candidate) in candidates.iter().enumerate() {
|
||||
if let Some(system) = map_systems
|
||||
.iter_mut()
|
||||
.find(|system| system.id == candidate.id)
|
||||
{
|
||||
system.candidate_index = Some(index);
|
||||
}
|
||||
}
|
||||
|
||||
StartingBaseMap {
|
||||
systems: map_systems,
|
||||
candidates,
|
||||
connections,
|
||||
}
|
||||
}
|
||||
|
||||
/// Position-only galaxy generation. Composes multiple structural layers —
|
||||
/// a [`CoreParams`] cluster, up to [`MAX_DISKS`] independent [`DiskParams`]
|
||||
/// spiral disks, and two [`BeamParams`] columns along ±Y — into a single
|
||||
@@ -196,10 +270,7 @@ fn generate_system_positions(params: &GalaxyParams, rng: &mut StdRng) -> Vec<Gen
|
||||
// Total budget — pre-allocation only; each pass appends as many systems
|
||||
// as it manages to place.
|
||||
let disk_total: usize = params.disks.iter().map(|d| d.count).sum();
|
||||
let total = params.core.count
|
||||
+ disk_total
|
||||
+ params.beam_top.count
|
||||
+ params.beam_bottom.count;
|
||||
let total = params.core.count + disk_total + params.beam_top.count + params.beam_bottom.count;
|
||||
let mut systems: Vec<GeneratedSystem> = Vec::with_capacity(total);
|
||||
|
||||
// Spacing scales with overall density: tighter for crowded galaxies,
|
||||
@@ -210,7 +281,14 @@ fn generate_system_positions(params: &GalaxyParams, rng: &mut StdRng) -> Vec<Gen
|
||||
// Counter shared across passes for `g-{n}` IDs and name suffixes.
|
||||
let mut next_index = 0usize;
|
||||
|
||||
generate_core(&mut systems, rng, ¶ms.core, params.size, base_spacing, &mut next_index);
|
||||
generate_core(
|
||||
&mut systems,
|
||||
rng,
|
||||
¶ms.core,
|
||||
params.size,
|
||||
base_spacing,
|
||||
&mut next_index,
|
||||
);
|
||||
|
||||
for (disk_index, disk) in params.disks.iter().enumerate() {
|
||||
generate_disk(
|
||||
@@ -340,8 +418,7 @@ fn generate_disk(
|
||||
// Non-core disk factions cycle through Amarr/Minmatar/Gallente/Caldari.
|
||||
// Offset by disk_index so different disks don't all share the same
|
||||
// faction assignment pattern.
|
||||
let faction_index =
|
||||
1 + ((arm as usize + disk_index) % (FACTIONS.len() - 1));
|
||||
let faction_index = 1 + ((arm as usize + disk_index) % (FACTIONS.len() - 1));
|
||||
let (faction, color) = FACTIONS[faction_index];
|
||||
|
||||
let mut position = Option::<Vec3>::None;
|
||||
@@ -531,7 +608,9 @@ enum SystemOrigin {
|
||||
/// Which arm within the disk.
|
||||
arm: u32,
|
||||
},
|
||||
Beam { side: BeamTag },
|
||||
Beam {
|
||||
side: BeamTag,
|
||||
},
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -611,7 +690,13 @@ fn spawn_galaxy_scene(
|
||||
|
||||
// Parent group so all galaxy contents despawn together.
|
||||
commands
|
||||
.spawn((Transform::default(), GalaxyScene, GalaxySpawned))
|
||||
.spawn((
|
||||
Transform::default(),
|
||||
Visibility::default(),
|
||||
InheritedVisibility::default(),
|
||||
GalaxyScene,
|
||||
GalaxySpawned,
|
||||
))
|
||||
.with_children(|parent| {
|
||||
// XYZ reference axes through the origin.
|
||||
axes::spawn_axes(parent, meshes, materials);
|
||||
@@ -639,6 +724,8 @@ fn spawn_galaxy_scene(
|
||||
parent
|
||||
.spawn((
|
||||
Transform::from_translation(sys.position),
|
||||
Visibility::default(),
|
||||
InheritedVisibility::default(),
|
||||
StarSystem {
|
||||
id: sys.id.clone(),
|
||||
name: sys.name.clone(),
|
||||
@@ -733,10 +820,7 @@ fn build_connections(systems: &[GeneratedSystem]) -> Vec<(usize, usize)> {
|
||||
|
||||
// ── Lifecycle systems ───────────────────────────────────────────────────────
|
||||
|
||||
fn despawn_galaxy(
|
||||
mut commands: Commands,
|
||||
query: Query<Entity, With<GalaxySpawned>>,
|
||||
) {
|
||||
fn despawn_galaxy(mut commands: Commands, query: Query<Entity, With<GalaxySpawned>>) {
|
||||
for entity in &query {
|
||||
// Bevy 0.16: despawn() is recursive by default.
|
||||
commands.entity(entity).despawn();
|
||||
|
||||
@@ -41,10 +41,7 @@ use super::poi::Orbital;
|
||||
/// Advance every [`Orbital`] along its path.
|
||||
///
|
||||
/// See module docs for the math and the hierarchy assumption.
|
||||
pub fn advance_orbital_paths(
|
||||
time: Res<Time>,
|
||||
mut orbitals: Query<(&Orbital, &mut Transform)>,
|
||||
) {
|
||||
pub fn advance_orbital_paths(time: Res<Time>, mut orbitals: Query<(&Orbital, &mut Transform)>) {
|
||||
let t = time.elapsed_secs();
|
||||
for (orbital, mut transform) in &mut orbitals {
|
||||
if orbital.period <= 0.0 {
|
||||
@@ -79,7 +76,11 @@ pub fn orbital_position(semi_major_axis: f32, phase: f32, period: f32, t: f32) -
|
||||
"orbital_position requires period > 0; static POIs should be handled by the caller"
|
||||
);
|
||||
let angle = phase + (std::f32::consts::TAU / period) * t;
|
||||
Vec3::new(angle.cos() * semi_major_axis, 0.0, angle.sin() * semi_major_axis)
|
||||
Vec3::new(
|
||||
angle.cos() * semi_major_axis,
|
||||
0.0,
|
||||
angle.sin() * semi_major_axis,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -66,7 +66,7 @@ pub const DISK_COUNT_MAX: usize = 220;
|
||||
pub const DISK_COUNT_STEP: usize = 4;
|
||||
pub const DISK_ARMS_MIN: u32 = 1;
|
||||
pub const DISK_ARMS_MAX: u32 = 6;
|
||||
pub const DISK_TWIST_MIN: f32 = 4.0; // ~0.6 turns — bare minimum curvature
|
||||
pub const DISK_TWIST_MIN: f32 = 4.0; // ~0.6 turns — bare minimum curvature
|
||||
pub const DISK_TWIST_MAX: f32 = 18.0; // ~2.9 turns — tight logarithmic spiral
|
||||
pub const DISK_TWIST_STEP: f32 = 0.4;
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ use bevy::{input::mouse::MouseWheel, prelude::*, ui::ScrollPosition, window::Pri
|
||||
|
||||
use super::GalaxySpawned;
|
||||
use crate::gameplay::galaxy::params::*;
|
||||
use crate::state::AppState;
|
||||
|
||||
// ── Markers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -91,6 +92,8 @@ pub enum ParamButton {
|
||||
Regenerate,
|
||||
/// Reset orbit camera to default orientation.
|
||||
CenterView,
|
||||
/// Accept the current galaxy and continue to character creation.
|
||||
CreateGalaxy,
|
||||
}
|
||||
|
||||
/// Resource tracking which disk layer is currently selected in the tab bar.
|
||||
@@ -201,6 +204,33 @@ fn spawn_control_panel(commands: &mut Commands, params: &GalaxyParams) {
|
||||
));
|
||||
});
|
||||
|
||||
parent
|
||||
.spawn((
|
||||
Button,
|
||||
Node {
|
||||
height: Val::Px(36.0),
|
||||
margin: UiRect::px(PANEL_PADDING, PANEL_PADDING, 4.0, PANEL_PADDING),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgb(0.10, 0.28, 0.22)),
|
||||
BorderColor(Color::srgb(0.30, 0.72, 0.45)),
|
||||
BorderRadius::all(Val::Px(6.0)),
|
||||
ParamButton::CreateGalaxy,
|
||||
))
|
||||
.with_children(|btn| {
|
||||
btn.spawn((
|
||||
Text::new("Create Galaxy"),
|
||||
TextFont {
|
||||
font_size: BUTTON_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_BRIGHT),
|
||||
));
|
||||
});
|
||||
|
||||
// Scrollable content area.
|
||||
parent
|
||||
.spawn((
|
||||
@@ -375,12 +405,7 @@ fn spawn_icon_button(parent: &mut ChildSpawnerCommands, label: &str, marker: Par
|
||||
parent_button(parent, marker, label, 26.0);
|
||||
}
|
||||
|
||||
fn parent_button(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
marker: ParamButton,
|
||||
label: &str,
|
||||
width: f32,
|
||||
) {
|
||||
fn parent_button(parent: &mut ChildSpawnerCommands, marker: ParamButton, label: &str, width: f32) {
|
||||
parent
|
||||
.spawn((
|
||||
Button,
|
||||
@@ -461,6 +486,7 @@ pub fn param_button_handler(
|
||||
mut commands: Commands,
|
||||
mut params: ResMut<GalaxyParams>,
|
||||
mut selected_disk: ResMut<SelectedDisk>,
|
||||
mut next_state: ResMut<NextState<AppState>>,
|
||||
query: Query<(&Interaction, &ParamButton), Changed<Interaction>>,
|
||||
) {
|
||||
for (interaction, button) in &query {
|
||||
@@ -499,13 +525,11 @@ pub fn param_button_handler(
|
||||
params.bump_generation();
|
||||
}
|
||||
ParamButton::CoreRadiusDecr => {
|
||||
params.core.radius =
|
||||
(params.core.radius - CORE_RADIUS_STEP).max(CORE_RADIUS_MIN);
|
||||
params.core.radius = (params.core.radius - CORE_RADIUS_STEP).max(CORE_RADIUS_MIN);
|
||||
params.bump_generation();
|
||||
}
|
||||
ParamButton::CoreRadiusIncr => {
|
||||
params.core.radius =
|
||||
(params.core.radius + CORE_RADIUS_STEP).min(CORE_RADIUS_MAX);
|
||||
params.core.radius = (params.core.radius + CORE_RADIUS_STEP).min(CORE_RADIUS_MAX);
|
||||
params.bump_generation();
|
||||
}
|
||||
// ── Disk tab management ───────────────────────────────────────
|
||||
@@ -531,7 +555,10 @@ pub fn param_button_handler(
|
||||
// ── Disk params (target selected_disk) ────────────────────────
|
||||
ParamButton::DiskCountDecr => {
|
||||
let disk = selected_disk_mut(&mut params, selected_disk.0);
|
||||
disk.count = disk.count.saturating_sub(DISK_COUNT_STEP).max(DISK_COUNT_MIN);
|
||||
disk.count = disk
|
||||
.count
|
||||
.saturating_sub(DISK_COUNT_STEP)
|
||||
.max(DISK_COUNT_MIN);
|
||||
params.bump_generation();
|
||||
}
|
||||
ParamButton::DiskCountIncr => {
|
||||
@@ -571,14 +598,14 @@ pub fn param_button_handler(
|
||||
}
|
||||
ParamButton::DiskRotationDecr => {
|
||||
let disk = selected_disk_mut(&mut params, selected_disk.0);
|
||||
disk.rotation_offset = (disk.rotation_offset - DISK_ROTATION_STEP)
|
||||
.max(DISK_ROTATION_MIN);
|
||||
disk.rotation_offset =
|
||||
(disk.rotation_offset - DISK_ROTATION_STEP).max(DISK_ROTATION_MIN);
|
||||
params.bump_generation();
|
||||
}
|
||||
ParamButton::DiskRotationIncr => {
|
||||
let disk = selected_disk_mut(&mut params, selected_disk.0);
|
||||
disk.rotation_offset = (disk.rotation_offset + DISK_ROTATION_STEP)
|
||||
.min(DISK_ROTATION_MAX);
|
||||
disk.rotation_offset =
|
||||
(disk.rotation_offset + DISK_ROTATION_STEP).min(DISK_ROTATION_MAX);
|
||||
params.bump_generation();
|
||||
}
|
||||
|
||||
@@ -646,15 +673,13 @@ pub fn param_button_handler(
|
||||
params.bump_generation();
|
||||
}
|
||||
ParamButton::BeamBottomThicknessDecr => {
|
||||
params.beam_bottom.thickness = (params.beam_bottom.thickness
|
||||
- BEAM_THICKNESS_STEP)
|
||||
.max(BEAM_THICKNESS_MIN);
|
||||
params.beam_bottom.thickness =
|
||||
(params.beam_bottom.thickness - BEAM_THICKNESS_STEP).max(BEAM_THICKNESS_MIN);
|
||||
params.bump_generation();
|
||||
}
|
||||
ParamButton::BeamBottomThicknessIncr => {
|
||||
params.beam_bottom.thickness = (params.beam_bottom.thickness
|
||||
+ BEAM_THICKNESS_STEP)
|
||||
.min(BEAM_THICKNESS_MAX);
|
||||
params.beam_bottom.thickness =
|
||||
(params.beam_bottom.thickness + BEAM_THICKNESS_STEP).min(BEAM_THICKNESS_MAX);
|
||||
params.bump_generation();
|
||||
}
|
||||
ParamButton::BeamBottomLengthDecr => {
|
||||
@@ -691,15 +716,13 @@ pub fn param_button_handler(
|
||||
params.bump_generation();
|
||||
}
|
||||
ParamButton::BeamBottomGravityDecr => {
|
||||
params.beam_bottom.gravity = (params.beam_bottom.gravity
|
||||
- BEAM_GRAVITY_STEP)
|
||||
.max(BEAM_GRAVITY_MIN);
|
||||
params.beam_bottom.gravity =
|
||||
(params.beam_bottom.gravity - BEAM_GRAVITY_STEP).max(BEAM_GRAVITY_MIN);
|
||||
params.bump_generation();
|
||||
}
|
||||
ParamButton::BeamBottomGravityIncr => {
|
||||
params.beam_bottom.gravity = (params.beam_bottom.gravity
|
||||
+ BEAM_GRAVITY_STEP)
|
||||
.min(BEAM_GRAVITY_MAX);
|
||||
params.beam_bottom.gravity =
|
||||
(params.beam_bottom.gravity + BEAM_GRAVITY_STEP).min(BEAM_GRAVITY_MAX);
|
||||
params.bump_generation();
|
||||
}
|
||||
ParamButton::RandomSettings => {
|
||||
@@ -728,6 +751,14 @@ pub fn param_button_handler(
|
||||
ParamButton::Regenerate => params.reseed_and_bump(),
|
||||
// CenterView is handled by `reset_view_button_handler` (needs Commands).
|
||||
ParamButton::CenterView => {}
|
||||
ParamButton::CreateGalaxy => {
|
||||
bevy::log::info!(
|
||||
"Created galaxy: seed={}, generation={}",
|
||||
params.seed,
|
||||
params.generation
|
||||
);
|
||||
next_state.set(AppState::CharacterCreation);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -770,7 +801,10 @@ pub fn refresh_control_panel_values(
|
||||
mut values: Query<(&ParamValue, &mut Text)>,
|
||||
) {
|
||||
// Resolve the selected disk, clamped to a valid index.
|
||||
let Some(disk) = params.disks.get(selected_disk.0.min(params.disks.len().saturating_sub(1))) else {
|
||||
let Some(disk) = params
|
||||
.disks
|
||||
.get(selected_disk.0.min(params.disks.len().saturating_sub(1)))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -791,14 +825,7 @@ pub fn refresh_control_panel_values(
|
||||
|
||||
// Top beam
|
||||
"beam_top_enabled" => {
|
||||
format!(
|
||||
"{}",
|
||||
if params.beam_top.enabled {
|
||||
"On"
|
||||
} else {
|
||||
"Off"
|
||||
}
|
||||
)
|
||||
format!("{}", if params.beam_top.enabled { "On" } else { "Off" })
|
||||
}
|
||||
"beam_top_thickness" => format!("{:.0}", params.beam_top.thickness),
|
||||
"beam_top_length" => format!("{:.0}", params.beam_top.length),
|
||||
@@ -859,31 +886,24 @@ pub fn rebuild_scroll_content(
|
||||
commands.entity(scroll_entity).despawn();
|
||||
|
||||
// Re-spawn a fresh scroll content as a child of the panel.
|
||||
commands
|
||||
.entity(panel_entity)
|
||||
.with_children(|parent| {
|
||||
parent
|
||||
.spawn((
|
||||
Node {
|
||||
flex_grow: 1.0,
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: Val::Px(6.0),
|
||||
padding: UiRect::px(
|
||||
PANEL_PADDING,
|
||||
PANEL_PADDING,
|
||||
PANEL_PADDING,
|
||||
PANEL_PADDING,
|
||||
),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
ScrollPosition::default(),
|
||||
GalaxyScrollContent,
|
||||
))
|
||||
.with_children(|scroll| {
|
||||
spawn_scroll_contents_with_tabs(scroll, ¶ms, selected_disk.0);
|
||||
});
|
||||
});
|
||||
commands.entity(panel_entity).with_children(|parent| {
|
||||
parent
|
||||
.spawn((
|
||||
Node {
|
||||
flex_grow: 1.0,
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: Val::Px(6.0),
|
||||
padding: UiRect::px(PANEL_PADDING, PANEL_PADDING, PANEL_PADDING, PANEL_PADDING),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
ScrollPosition::default(),
|
||||
GalaxyScrollContent,
|
||||
))
|
||||
.with_children(|scroll| {
|
||||
spawn_scroll_contents_with_tabs(scroll, ¶ms, selected_disk.0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Full scroll content builder that includes the actual tab buttons for the
|
||||
@@ -954,11 +974,7 @@ fn spawn_scroll_contents_with_tabs(
|
||||
} else {
|
||||
PANEL_BORDER
|
||||
};
|
||||
let text_color = if is_active {
|
||||
TEXT_BRIGHT
|
||||
} else {
|
||||
TEXT_DIM
|
||||
};
|
||||
let text_color = if is_active { TEXT_BRIGHT } else { TEXT_DIM };
|
||||
row.spawn((
|
||||
Button,
|
||||
Node {
|
||||
@@ -1278,8 +1294,7 @@ fn randomize_disk(rng: &mut impl rand::Rng) -> DiskParams {
|
||||
disk.tilt = rng.gen_range(DISK_TILT_MIN..=DISK_TILT_MAX);
|
||||
disk.tilt = (disk.tilt / DISK_TILT_STEP).round() * DISK_TILT_STEP;
|
||||
disk.rotation_offset = rng.gen_range(DISK_ROTATION_MIN..=DISK_ROTATION_MAX);
|
||||
disk.rotation_offset =
|
||||
(disk.rotation_offset / DISK_ROTATION_STEP).round() * DISK_ROTATION_STEP;
|
||||
disk.rotation_offset = (disk.rotation_offset / DISK_ROTATION_STEP).round() * DISK_ROTATION_STEP;
|
||||
disk
|
||||
}
|
||||
|
||||
|
||||
@@ -3,3 +3,4 @@ pub mod galaxy;
|
||||
pub mod movement;
|
||||
pub mod physics;
|
||||
pub mod star_map;
|
||||
pub mod starting_base;
|
||||
|
||||
@@ -18,10 +18,18 @@ pub fn click_to_move(
|
||||
if !mouse_input.just_pressed(MouseButton::Left) {
|
||||
return;
|
||||
}
|
||||
let Ok(window) = primary_window.single() else { return };
|
||||
let Some(cursor_pos) = window.cursor_position() else { return };
|
||||
let Ok((camera, camera_gt)) = camera_query.single() else { return };
|
||||
let Ok(ray) = camera.viewport_to_world(camera_gt, cursor_pos) else { return };
|
||||
let Ok(window) = primary_window.single() else {
|
||||
return;
|
||||
};
|
||||
let Some(cursor_pos) = window.cursor_position() else {
|
||||
return;
|
||||
};
|
||||
let Ok((camera, camera_gt)) = camera_query.single() else {
|
||||
return;
|
||||
};
|
||||
let Ok(ray) = camera.viewport_to_world(camera_gt, cursor_pos) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Intersect ray with the ground plane (y = GROUND_PLANE_Y).
|
||||
if ray.direction.y.abs() < 1e-6 {
|
||||
@@ -33,6 +41,8 @@ pub fn click_to_move(
|
||||
}
|
||||
let target = ray.origin + ray.direction * t;
|
||||
|
||||
let Ok(mut move_target) = player.single_mut() else { return };
|
||||
let Ok(mut move_target) = player.single_mut() else {
|
||||
return;
|
||||
};
|
||||
move_target.0 = target;
|
||||
}
|
||||
|
||||
@@ -33,10 +33,7 @@ pub fn steer_to_target(
|
||||
}
|
||||
|
||||
/// Apply Velocity to Transform.translation.
|
||||
pub fn integrate_velocity(
|
||||
mut query: Query<(&mut Transform, &Velocity)>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
pub fn integrate_velocity(mut query: Query<(&mut Transform, &Velocity)>, time: Res<Time>) {
|
||||
let dt = time.delta_secs();
|
||||
for (mut transform, velocity) in &mut query {
|
||||
transform.translation += velocity.0 * dt;
|
||||
|
||||
@@ -35,10 +35,7 @@ impl Default for Orbit {
|
||||
|
||||
/// Advance each Orbit's angle and recompute local translation.
|
||||
/// The orbit center is the parent entity (via Bevy's parent-child hierarchy).
|
||||
pub fn update_orbits(
|
||||
mut query: Query<(&mut Orbit, &mut Transform)>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
pub fn update_orbits(mut query: Query<(&mut Orbit, &mut Transform)>, time: Res<Time>) {
|
||||
let dt = time.delta_secs();
|
||||
for (mut orbit, mut transform) in &mut query {
|
||||
orbit.angle += orbit.angular_velocity * dt;
|
||||
|
||||
@@ -26,24 +26,14 @@ pub fn ray_vs_sphere(
|
||||
}
|
||||
|
||||
/// Check if two spheres overlap. Use length-squared for performance (no sqrt).
|
||||
pub fn overlaps(
|
||||
a_center: Vec3,
|
||||
a_radius: f32,
|
||||
b_center: Vec3,
|
||||
b_radius: f32,
|
||||
) -> bool {
|
||||
pub fn overlaps(a_center: Vec3, a_radius: f32, b_center: Vec3, b_radius: f32) -> bool {
|
||||
let combined = a_radius + b_radius;
|
||||
(a_center - b_center).length_squared() < combined * combined
|
||||
}
|
||||
|
||||
/// Compute the vector to push `a` out of `b` so they no longer overlap.
|
||||
/// Returns `None` if they don't overlap, or if their centers coincide (ambiguous direction).
|
||||
pub fn separate(
|
||||
a_center: Vec3,
|
||||
a_radius: f32,
|
||||
b_center: Vec3,
|
||||
b_radius: f32,
|
||||
) -> Option<Vec3> {
|
||||
pub fn separate(a_center: Vec3, a_radius: f32, b_center: Vec3, b_radius: f32) -> Option<Vec3> {
|
||||
let delta = a_center - b_center;
|
||||
let combined = a_radius + b_radius;
|
||||
let dist = delta.length();
|
||||
|
||||
@@ -8,11 +8,11 @@ impl Plugin for StarMapPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(OnEnter(AppState::InGame), setup_star_map)
|
||||
.add_systems(OnExit(AppState::InGame), despawn_star_map);
|
||||
// .add_systems(
|
||||
// Update,
|
||||
// (/* update systems: pan/zoom, hover, click star systems */)
|
||||
// .run_if(in_state(AppState::InGame)),
|
||||
// );
|
||||
// .add_systems(
|
||||
// Update,
|
||||
// (/* update systems: pan/zoom, hover, click star systems */)
|
||||
// .run_if(in_state(AppState::InGame)),
|
||||
// );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
89
apps/game/src/gameplay/starting_base/mod.rs
Normal file
89
apps/game/src/gameplay/starting_base/mod.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
//! Starting base selection scene.
|
||||
//!
|
||||
//! After character creation, the player chooses a starting base from generated
|
||||
//! outer-galaxy systems. The choice is kept in a resource until persistence is
|
||||
//! wired into the game client.
|
||||
|
||||
mod scene;
|
||||
mod ui;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::gameplay::galaxy::StartingBaseCandidate;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub struct StartingBasePlugin;
|
||||
|
||||
impl Plugin for StartingBasePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<StartingBaseDraft>()
|
||||
.add_systems(
|
||||
OnEnter(AppState::StartingBaseSelection),
|
||||
(scene::setup_starting_base_scene, ui::setup_starting_base_ui).chain(),
|
||||
)
|
||||
.add_systems(
|
||||
OnExit(AppState::StartingBaseSelection),
|
||||
despawn_starting_base_ui,
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
escape_to_character_creation,
|
||||
scene::starting_base_orbit_camera_control,
|
||||
scene::select_starting_base_on_click,
|
||||
scene::animate_starting_base_selection,
|
||||
ui::scroll_starting_base_panels,
|
||||
ui::candidate_button_handler,
|
||||
ui::refresh_starting_base_ui,
|
||||
ui::action_button_handler,
|
||||
)
|
||||
.chain()
|
||||
.run_if(in_state(AppState::StartingBaseSelection)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub struct StartingBaseDraft {
|
||||
pub candidates: Vec<StartingBaseCandidate>,
|
||||
pub selected_index: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct StartingBaseSelection {
|
||||
pub system_id: String,
|
||||
pub system_name: String,
|
||||
pub faction: &'static str,
|
||||
pub security: f32,
|
||||
}
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct StartingBaseSpawned;
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct StartingBaseInputBlocker;
|
||||
|
||||
#[derive(Component, Clone, Copy)]
|
||||
pub enum StartingBaseButton {
|
||||
Candidate(usize),
|
||||
Confirm,
|
||||
Back,
|
||||
}
|
||||
|
||||
fn despawn_starting_base_ui(
|
||||
mut commands: Commands,
|
||||
query: Query<Entity, With<StartingBaseSpawned>>,
|
||||
) {
|
||||
for entity in &query {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_to_character_creation(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut next_state: ResMut<NextState<AppState>>,
|
||||
) {
|
||||
if keys.just_pressed(KeyCode::Escape) {
|
||||
next_state.set(AppState::CharacterCreation);
|
||||
}
|
||||
}
|
||||
286
apps/game/src/gameplay/starting_base/scene.rs
Normal file
286
apps/game/src/gameplay/starting_base/scene.rs
Normal file
@@ -0,0 +1,286 @@
|
||||
//! 3D galaxy view for starting-base selection.
|
||||
|
||||
use bevy::input::mouse::{MouseMotion, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
|
||||
use super::{StartingBaseDraft, StartingBaseInputBlocker, StartingBaseSpawned};
|
||||
use crate::camera::{MainCamera, OrbitCamera};
|
||||
use crate::gameplay::galaxy::{generate_starting_base_map, GalaxyParams, StartingBaseMapSystem};
|
||||
|
||||
const SELECTION_PIXEL_THRESHOLD: f32 = 18.0;
|
||||
const SELECTED_SCALE: f32 = 2.4;
|
||||
const CANDIDATE_SCALE: f32 = 1.25;
|
||||
const FOGGED_SCALE: f32 = 0.58;
|
||||
const SELECTION_LERP_SPEED: f32 = 10.0;
|
||||
const ORBIT_ROTATE_SENSITIVITY: f32 = 0.005;
|
||||
const ORBIT_ZOOM_SENSITIVITY: f32 = 0.1;
|
||||
const ORBIT_MIN_DISTANCE: f32 = 40.0;
|
||||
const ORBIT_MAX_DISTANCE: f32 = 1500.0;
|
||||
|
||||
#[derive(Component)]
|
||||
struct StartingBaseSceneRoot;
|
||||
|
||||
#[derive(Component)]
|
||||
pub(super) struct StartingBaseSystemVisual {
|
||||
candidate_index: Option<usize>,
|
||||
}
|
||||
|
||||
pub fn setup_starting_base_scene(
|
||||
mut commands: Commands,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||
params: Res<GalaxyParams>,
|
||||
mut draft: ResMut<StartingBaseDraft>,
|
||||
) {
|
||||
let map = generate_starting_base_map(¶ms);
|
||||
draft.candidates = map.candidates;
|
||||
draft.selected_index = None;
|
||||
|
||||
let star_mesh = meshes.add(Sphere::new(1.0).mesh().ico(3).unwrap());
|
||||
let fogged_material = materials.add(StandardMaterial {
|
||||
base_color: Color::srgb(0.10, 0.12, 0.16),
|
||||
emissive: LinearRgba::new(0.02, 0.025, 0.035, 1.0),
|
||||
unlit: true,
|
||||
..default()
|
||||
});
|
||||
let connection_material = materials.add(StandardMaterial {
|
||||
base_color: Color::srgb(0.07, 0.09, 0.13),
|
||||
emissive: LinearRgba::new(0.025, 0.035, 0.055, 1.0),
|
||||
unlit: true,
|
||||
..default()
|
||||
});
|
||||
|
||||
let candidate_materials: Vec<Handle<StandardMaterial>> = map
|
||||
.systems
|
||||
.iter()
|
||||
.map(|system| {
|
||||
materials.add(StandardMaterial {
|
||||
base_color: Color::srgb(system.color[0], system.color[1], system.color[2]),
|
||||
emissive: LinearRgba::new(
|
||||
system.color[0] * 1.6,
|
||||
system.color[1] * 1.6,
|
||||
system.color[2] * 1.6,
|
||||
1.0,
|
||||
),
|
||||
unlit: true,
|
||||
..default()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
Transform::default(),
|
||||
Visibility::default(),
|
||||
InheritedVisibility::default(),
|
||||
StartingBaseSceneRoot,
|
||||
StartingBaseSpawned,
|
||||
))
|
||||
.with_children(|parent| {
|
||||
for (index, system) in map.systems.iter().enumerate() {
|
||||
spawn_system(
|
||||
parent,
|
||||
&star_mesh,
|
||||
&candidate_materials[index],
|
||||
&fogged_material,
|
||||
system,
|
||||
);
|
||||
}
|
||||
|
||||
for (a, b) in &map.connections {
|
||||
let Some(from) = map.systems.get(*a).map(|system| system.position) else {
|
||||
continue;
|
||||
};
|
||||
let Some(to) = map.systems.get(*b).map(|system| system.position) else {
|
||||
continue;
|
||||
};
|
||||
spawn_connection(parent, &mut meshes, &connection_material, from, to);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_system(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
star_mesh: &Handle<Mesh>,
|
||||
candidate_material: &Handle<StandardMaterial>,
|
||||
fogged_material: &Handle<StandardMaterial>,
|
||||
system: &StartingBaseMapSystem,
|
||||
) {
|
||||
let scale = if system.candidate_index.is_some() {
|
||||
CANDIDATE_SCALE
|
||||
} else {
|
||||
FOGGED_SCALE
|
||||
};
|
||||
let material = if system.candidate_index.is_some() {
|
||||
candidate_material.clone()
|
||||
} else {
|
||||
fogged_material.clone()
|
||||
};
|
||||
|
||||
parent.spawn((
|
||||
Mesh3d(star_mesh.clone()),
|
||||
MeshMaterial3d(material),
|
||||
Transform::from_translation(system.position).with_scale(Vec3::splat(scale)),
|
||||
StartingBaseSystemVisual {
|
||||
candidate_index: system.candidate_index,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
fn spawn_connection(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
meshes: &mut Assets<Mesh>,
|
||||
material: &Handle<StandardMaterial>,
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
) {
|
||||
let delta = to - from;
|
||||
let length = delta.length();
|
||||
if length < 0.01 {
|
||||
return;
|
||||
}
|
||||
let midpoint = (from + to) * 0.5;
|
||||
let direction = delta / length;
|
||||
let rotation = Quat::from_rotation_arc(Vec3::Y, direction);
|
||||
parent.spawn((
|
||||
Mesh3d(meshes.add(Cylinder::new(0.08, length).mesh())),
|
||||
MeshMaterial3d(material.clone()),
|
||||
Transform::from_translation(midpoint).with_rotation(rotation),
|
||||
));
|
||||
}
|
||||
|
||||
pub fn starting_base_orbit_camera_control(
|
||||
mouse_input: Res<ButtonInput<MouseButton>>,
|
||||
primary_window: Query<&Window, With<PrimaryWindow>>,
|
||||
mut mouse_motion: EventReader<MouseMotion>,
|
||||
mut scroll_events: EventReader<MouseWheel>,
|
||||
mut camera_query: Query<(&mut Transform, &mut OrbitCamera), With<MainCamera>>,
|
||||
ui_nodes: Query<(&ComputedNode, &GlobalTransform), With<StartingBaseInputBlocker>>,
|
||||
) {
|
||||
let Ok((mut transform, mut orbit)) = camera_query.single_mut() else {
|
||||
mouse_motion.clear();
|
||||
scroll_events.clear();
|
||||
return;
|
||||
};
|
||||
|
||||
let cursor_over_ui = primary_window
|
||||
.single()
|
||||
.ok()
|
||||
.map(|window| cursor_over_starting_base_ui(window, &ui_nodes))
|
||||
.unwrap_or(false);
|
||||
|
||||
if mouse_input.pressed(MouseButton::Left) && !cursor_over_ui {
|
||||
for event in mouse_motion.read() {
|
||||
let yaw = Quat::from_rotation_y(-event.delta.x * ORBIT_ROTATE_SENSITIVITY);
|
||||
let pitch = Quat::from_rotation_x(event.delta.y * ORBIT_ROTATE_SENSITIVITY);
|
||||
orbit.rotation = (yaw * orbit.rotation * pitch).normalize();
|
||||
}
|
||||
} else {
|
||||
mouse_motion.clear();
|
||||
}
|
||||
|
||||
for event in scroll_events.read() {
|
||||
if cursor_over_ui {
|
||||
continue;
|
||||
}
|
||||
orbit.distance *= 1.0 - event.y * ORBIT_ZOOM_SENSITIVITY;
|
||||
}
|
||||
|
||||
orbit.distance = orbit.distance.clamp(ORBIT_MIN_DISTANCE, ORBIT_MAX_DISTANCE);
|
||||
let position = orbit.target + orbit.rotation * Vec3::new(0.0, 0.0, orbit.distance);
|
||||
*transform = Transform::from_translation(position).with_rotation(orbit.rotation);
|
||||
}
|
||||
|
||||
pub fn select_starting_base_on_click(
|
||||
mouse_input: Res<ButtonInput<MouseButton>>,
|
||||
primary_window: Query<&Window, With<PrimaryWindow>>,
|
||||
camera_query: Query<(&Camera, &GlobalTransform), With<MainCamera>>,
|
||||
systems: Query<(&GlobalTransform, &StartingBaseSystemVisual)>,
|
||||
ui_nodes: Query<(&ComputedNode, &GlobalTransform), With<StartingBaseInputBlocker>>,
|
||||
mut draft: ResMut<StartingBaseDraft>,
|
||||
) {
|
||||
if !mouse_input.just_pressed(MouseButton::Left) {
|
||||
return;
|
||||
}
|
||||
let Ok(window) = primary_window.single() else {
|
||||
return;
|
||||
};
|
||||
if cursor_over_starting_base_ui(window, &ui_nodes) {
|
||||
return;
|
||||
}
|
||||
let Some(cursor) = window.cursor_position() else {
|
||||
return;
|
||||
};
|
||||
let Ok((camera, camera_gt)) = camera_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut best: Option<(usize, f32)> = None;
|
||||
for (transform, visual) in &systems {
|
||||
let Some(candidate_index) = visual.candidate_index else {
|
||||
continue;
|
||||
};
|
||||
let Ok(viewport) = camera.world_to_viewport(camera_gt, transform.translation()) else {
|
||||
continue;
|
||||
};
|
||||
let distance = viewport.distance(cursor);
|
||||
if distance >= SELECTION_PIXEL_THRESHOLD {
|
||||
continue;
|
||||
}
|
||||
if best.is_none_or(|(_, current)| distance < current) {
|
||||
best = Some((candidate_index, distance));
|
||||
}
|
||||
}
|
||||
|
||||
let new_selection = best.map(|(candidate_index, _)| candidate_index);
|
||||
if draft.selected_index != new_selection {
|
||||
draft.selected_index = new_selection;
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_over_starting_base_ui(
|
||||
window: &Window,
|
||||
nodes: &Query<(&ComputedNode, &GlobalTransform), With<StartingBaseInputBlocker>>,
|
||||
) -> bool {
|
||||
let Some(cursor_logical) = window.cursor_position() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let cursor = cursor_logical * window.scale_factor();
|
||||
for (node, transform) in nodes {
|
||||
if node.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let center = transform.translation().truncate();
|
||||
let half = node.size() * 0.5;
|
||||
let min = center - half;
|
||||
let max = center + half;
|
||||
if cursor.x >= min.x && cursor.x <= max.x && cursor.y >= min.y && cursor.y <= max.y {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn animate_starting_base_selection(
|
||||
draft: Res<StartingBaseDraft>,
|
||||
time: Res<Time>,
|
||||
mut systems: Query<(&StartingBaseSystemVisual, &mut Transform)>,
|
||||
) {
|
||||
let dt = time.delta_secs().min(0.1);
|
||||
let alpha = (dt * SELECTION_LERP_SPEED).clamp(0.0, 1.0);
|
||||
|
||||
for (visual, mut transform) in &mut systems {
|
||||
let target = match visual.candidate_index {
|
||||
Some(index) if Some(index) == draft.selected_index => SELECTED_SCALE,
|
||||
Some(_) => CANDIDATE_SCALE,
|
||||
None => FOGGED_SCALE,
|
||||
};
|
||||
let current = transform.scale.x;
|
||||
let next = current + (target - current) * alpha;
|
||||
if (next - current).abs() > 1e-4 {
|
||||
transform.scale = Vec3::splat(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
631
apps/game/src/gameplay/starting_base/ui.rs
Normal file
631
apps/game/src/gameplay/starting_base/ui.rs
Normal file
@@ -0,0 +1,631 @@
|
||||
//! UI for selecting the player's starting base of operations.
|
||||
|
||||
use bevy::{input::mouse::MouseWheel, prelude::*, ui::ScrollPosition, window::PrimaryWindow};
|
||||
|
||||
use super::{
|
||||
StartingBaseButton, StartingBaseDraft, StartingBaseInputBlocker, StartingBaseSelection,
|
||||
StartingBaseSpawned,
|
||||
};
|
||||
use crate::gameplay::galaxy::{
|
||||
Difficulty, GasKind, SignalKind, StartingBaseCandidate, SystemContents,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
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 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_SELECTED_BG: Color = Color::srgb(0.16, 0.28, 0.44);
|
||||
const BUTTON_CONFIRM_BG: Color = Color::srgb(0.10, 0.28, 0.22);
|
||||
const BUTTON_DISABLED_BG: Color = Color::srgb(0.08, 0.09, 0.12);
|
||||
const BUTTON_CONFIRM_BORDER: Color = Color::srgb(0.30, 0.72, 0.45);
|
||||
|
||||
const TITLE_FONT_SIZE: f32 = 34.0;
|
||||
const SUBTITLE_FONT_SIZE: f32 = 15.0;
|
||||
const PANEL_TITLE_FONT_SIZE: f32 = 18.0;
|
||||
const BODY_FONT_SIZE: f32 = 14.0;
|
||||
const SMALL_FONT_SIZE: f32 = 12.0;
|
||||
const BUTTON_FONT_SIZE: f32 = 16.0;
|
||||
const CARD_WIDTH: f32 = 1040.0;
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct StartingBaseScrollViewport;
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct StartingBaseScrollContent;
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct CandidateButtonBg(pub usize);
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct DetailsText;
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct ConfirmButtonBg;
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct ConfirmButtonText;
|
||||
|
||||
pub fn setup_starting_base_ui(mut commands: Commands, draft: Res<StartingBaseDraft>) {
|
||||
commands
|
||||
.spawn((
|
||||
Node {
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
display: Display::Flex,
|
||||
flex_direction: FlexDirection::Column,
|
||||
align_items: AlignItems::Center,
|
||||
padding: UiRect::all(Val::Px(24.0)),
|
||||
row_gap: Val::Px(12.0),
|
||||
..default()
|
||||
},
|
||||
StartingBaseSpawned,
|
||||
))
|
||||
.with_children(|root| {
|
||||
root.spawn((
|
||||
Text::new("Choose Starting Base"),
|
||||
TextFont {
|
||||
font_size: TITLE_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_BRIGHT),
|
||||
));
|
||||
root.spawn((
|
||||
Text::new("Outer-galaxy systems only. Inspect local POIs before committing."),
|
||||
TextFont {
|
||||
font_size: SUBTITLE_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_DIM),
|
||||
));
|
||||
|
||||
root.spawn(Node {
|
||||
width: Val::Percent(100.0),
|
||||
max_width: Val::Px(CARD_WIDTH),
|
||||
flex_grow: 1.0,
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: Val::Px(16.0),
|
||||
..default()
|
||||
})
|
||||
.with_children(|columns| {
|
||||
spawn_candidate_panel(columns, &draft.candidates);
|
||||
spawn_details_panel(columns);
|
||||
});
|
||||
|
||||
root.spawn(Node {
|
||||
width: Val::Percent(100.0),
|
||||
max_width: Val::Px(CARD_WIDTH),
|
||||
flex_direction: FlexDirection::Row,
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
justify_content: JustifyContent::Center,
|
||||
column_gap: Val::Px(16.0),
|
||||
row_gap: Val::Px(8.0),
|
||||
..default()
|
||||
})
|
||||
.insert(StartingBaseInputBlocker)
|
||||
.with_children(|row| {
|
||||
spawn_action_button(
|
||||
row,
|
||||
"Confirm Base",
|
||||
BUTTON_DISABLED_BG,
|
||||
BUTTON_CONFIRM_BORDER,
|
||||
StartingBaseButton::Confirm,
|
||||
Some((ConfirmButtonBg, ConfirmButtonText)),
|
||||
);
|
||||
spawn_action_button(
|
||||
row,
|
||||
"Back",
|
||||
BUTTON_BG,
|
||||
PANEL_BORDER,
|
||||
StartingBaseButton::Back,
|
||||
None,
|
||||
);
|
||||
});
|
||||
|
||||
root.spawn((
|
||||
Text::new("Esc to return to character creation"),
|
||||
TextFont {
|
||||
font_size: SMALL_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_FADED),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_candidate_panel(parent: &mut ChildSpawnerCommands, candidates: &[StartingBaseCandidate]) {
|
||||
parent
|
||||
.spawn(panel_node(Val::Percent(38.0)))
|
||||
.with_children(|panel| {
|
||||
panel.spawn((
|
||||
Text::new(format!("Candidate Systems ({})", candidates.len())),
|
||||
TextFont {
|
||||
font_size: PANEL_TITLE_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_BRIGHT),
|
||||
));
|
||||
|
||||
panel
|
||||
.spawn((
|
||||
Node {
|
||||
flex_grow: 1.0,
|
||||
width: Val::Percent(100.0),
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: Val::Px(8.0),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
ScrollPosition::default(),
|
||||
StartingBaseScrollViewport,
|
||||
StartingBaseScrollContent,
|
||||
))
|
||||
.with_children(|scroll| {
|
||||
if candidates.is_empty() {
|
||||
scroll.spawn((
|
||||
Text::new("No outer-galaxy systems were generated for these settings."),
|
||||
TextFont {
|
||||
font_size: BODY_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_DIM),
|
||||
));
|
||||
}
|
||||
|
||||
for (index, candidate) in candidates.iter().enumerate() {
|
||||
spawn_candidate_button(scroll, index, candidate);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_details_panel(parent: &mut ChildSpawnerCommands) {
|
||||
parent
|
||||
.spawn(panel_node(Val::Percent(62.0)))
|
||||
.with_children(|panel| {
|
||||
panel.spawn((
|
||||
Text::new("System Inspection"),
|
||||
TextFont {
|
||||
font_size: PANEL_TITLE_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_BRIGHT),
|
||||
));
|
||||
panel
|
||||
.spawn((
|
||||
Node {
|
||||
flex_grow: 1.0,
|
||||
width: Val::Percent(100.0),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
ScrollPosition::default(),
|
||||
StartingBaseScrollViewport,
|
||||
StartingBaseScrollContent,
|
||||
))
|
||||
.with_children(|scroll| {
|
||||
scroll.spawn((
|
||||
Text::new("Select an outer system to inspect planets, stations, gates, resources, and anomalies."),
|
||||
TextFont {
|
||||
font_size: BODY_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_DIM),
|
||||
Node {
|
||||
width: Val::Percent(100.0),
|
||||
..default()
|
||||
},
|
||||
DetailsText,
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn panel_node(
|
||||
width: Val,
|
||||
) -> (
|
||||
Node,
|
||||
BackgroundColor,
|
||||
BorderColor,
|
||||
BorderRadius,
|
||||
StartingBaseInputBlocker,
|
||||
) {
|
||||
(
|
||||
Node {
|
||||
width,
|
||||
height: Val::Percent(100.0),
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: Val::Px(10.0),
|
||||
padding: UiRect::all(Val::Px(16.0)),
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(PANEL_BG),
|
||||
BorderColor(PANEL_BORDER),
|
||||
BorderRadius::all(Val::Px(8.0)),
|
||||
StartingBaseInputBlocker,
|
||||
)
|
||||
}
|
||||
|
||||
fn spawn_candidate_button(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
index: usize,
|
||||
candidate: &StartingBaseCandidate,
|
||||
) {
|
||||
parent
|
||||
.spawn((
|
||||
Button,
|
||||
Node {
|
||||
width: Val::Percent(100.0),
|
||||
flex_direction: FlexDirection::Column,
|
||||
align_items: AlignItems::FlexStart,
|
||||
padding: UiRect::all(Val::Px(10.0)),
|
||||
row_gap: Val::Px(3.0),
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BUTTON_BG),
|
||||
BorderColor(PANEL_BORDER),
|
||||
BorderRadius::all(Val::Px(6.0)),
|
||||
StartingBaseButton::Candidate(index),
|
||||
CandidateButtonBg(index),
|
||||
))
|
||||
.with_children(|button| {
|
||||
button.spawn((
|
||||
Text::new(candidate.name.clone()),
|
||||
TextFont {
|
||||
font_size: BUTTON_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_BRIGHT),
|
||||
));
|
||||
button.spawn((
|
||||
Text::new(format!(
|
||||
"{} | sec {:.2} | {:.0}u | {} POIs",
|
||||
candidate.faction,
|
||||
candidate.security,
|
||||
candidate.distance_from_core,
|
||||
candidate.contents.total()
|
||||
)),
|
||||
TextFont {
|
||||
font_size: SMALL_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_DIM),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_action_button(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
label: &str,
|
||||
bg: Color,
|
||||
border: Color,
|
||||
marker: StartingBaseButton,
|
||||
confirm_markers: Option<(ConfirmButtonBg, ConfirmButtonText)>,
|
||||
) {
|
||||
let has_confirm_markers = confirm_markers.is_some();
|
||||
let mut entity = parent.spawn((
|
||||
Button,
|
||||
Node {
|
||||
width: Val::Px(210.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,
|
||||
));
|
||||
|
||||
if let Some((button_bg, _)) = confirm_markers {
|
||||
entity.insert(button_bg);
|
||||
}
|
||||
|
||||
entity.with_children(|button| {
|
||||
let mut text = button.spawn((
|
||||
Text::new(label),
|
||||
TextFont {
|
||||
font_size: BUTTON_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_BRIGHT),
|
||||
));
|
||||
if has_confirm_markers {
|
||||
text.insert(ConfirmButtonText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn scroll_starting_base_panels(
|
||||
mut scroll_events: EventReader<MouseWheel>,
|
||||
primary_window: Query<&Window, With<PrimaryWindow>>,
|
||||
viewport_nodes: Query<(&ComputedNode, &GlobalTransform), With<StartingBaseScrollViewport>>,
|
||||
mut scroll_content: Query<
|
||||
(&GlobalTransform, &mut ScrollPosition),
|
||||
With<StartingBaseScrollContent>,
|
||||
>,
|
||||
) {
|
||||
let Ok(window) = primary_window.single() else {
|
||||
scroll_events.clear();
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(cursor_pos) = window.cursor_position() else {
|
||||
scroll_events.clear();
|
||||
return;
|
||||
};
|
||||
|
||||
let hovered = viewport_nodes.iter().find_map(|(node, transform)| {
|
||||
let pos = transform.translation().truncate();
|
||||
let size = node.size();
|
||||
let min = pos - size * 0.5;
|
||||
let max = pos + size * 0.5;
|
||||
if cursor_pos.x >= min.x
|
||||
&& cursor_pos.x <= max.x
|
||||
&& cursor_pos.y >= min.y
|
||||
&& cursor_pos.y <= max.y
|
||||
{
|
||||
Some(transform.translation())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let Some(hovered_position) = hovered else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (transform, mut scroll_pos) in &mut scroll_content {
|
||||
if transform.translation().distance(hovered_position) > 0.5 {
|
||||
continue;
|
||||
}
|
||||
for event in scroll_events.read() {
|
||||
scroll_pos.offset_y -= event.y * 20.0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn candidate_button_handler(
|
||||
mut draft: ResMut<StartingBaseDraft>,
|
||||
query: Query<(&Interaction, &StartingBaseButton), Changed<Interaction>>,
|
||||
) {
|
||||
for (interaction, button) in &query {
|
||||
if *interaction != Interaction::Pressed {
|
||||
continue;
|
||||
}
|
||||
if let StartingBaseButton::Candidate(index) = *button {
|
||||
if index < draft.candidates.len() {
|
||||
draft.selected_index = Some(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh_starting_base_ui(
|
||||
draft: Res<StartingBaseDraft>,
|
||||
mut texts: ParamSet<(
|
||||
Query<&mut Text, With<DetailsText>>,
|
||||
Query<&mut Text, With<ConfirmButtonText>>,
|
||||
)>,
|
||||
mut backgrounds: ParamSet<(
|
||||
Query<(&CandidateButtonBg, &mut BackgroundColor)>,
|
||||
Query<&mut BackgroundColor, With<ConfirmButtonBg>>,
|
||||
)>,
|
||||
) {
|
||||
if !draft.is_changed() {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
let mut candidate_bgs = backgrounds.p0();
|
||||
for (marker, mut bg) in &mut candidate_bgs {
|
||||
bg.0 = if Some(marker.0) == draft.selected_index {
|
||||
BUTTON_SELECTED_BG
|
||||
} else {
|
||||
BUTTON_BG
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut details_text = texts.p0();
|
||||
if let Ok(mut text) = details_text.single_mut() {
|
||||
text.0 = match selected_candidate(&draft) {
|
||||
Some(candidate) => format_candidate_details(candidate),
|
||||
None => "Select an outer system to inspect planets, stations, gates, resources, and anomalies.".to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let has_selection = draft.selected_index.is_some();
|
||||
{
|
||||
let mut confirm_bg = backgrounds.p1();
|
||||
if let Ok(mut bg) = confirm_bg.single_mut() {
|
||||
bg.0 = if has_selection {
|
||||
BUTTON_CONFIRM_BG
|
||||
} else {
|
||||
BUTTON_DISABLED_BG
|
||||
};
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut confirm_text = texts.p1();
|
||||
if let Ok(mut text) = confirm_text.single_mut() {
|
||||
text.0 = if has_selection {
|
||||
"Confirm Base".to_string()
|
||||
} else {
|
||||
"Select a Base".to_string()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn action_button_handler(
|
||||
mut commands: Commands,
|
||||
mut next_state: ResMut<NextState<AppState>>,
|
||||
draft: Res<StartingBaseDraft>,
|
||||
query: Query<(&Interaction, &StartingBaseButton), Changed<Interaction>>,
|
||||
) {
|
||||
for (interaction, button) in &query {
|
||||
if *interaction != Interaction::Pressed {
|
||||
continue;
|
||||
}
|
||||
|
||||
match button {
|
||||
StartingBaseButton::Confirm => {
|
||||
let Some(candidate) = selected_candidate(&draft) else {
|
||||
continue;
|
||||
};
|
||||
let selection = StartingBaseSelection {
|
||||
system_id: candidate.id.clone(),
|
||||
system_name: candidate.name.clone(),
|
||||
faction: candidate.faction,
|
||||
security: candidate.security,
|
||||
};
|
||||
bevy::log::info!(
|
||||
"Selected starting base: id={}, name={}, faction={}, security={:.2}, poi_count={}",
|
||||
selection.system_id,
|
||||
selection.system_name,
|
||||
selection.faction,
|
||||
selection.security,
|
||||
candidate.contents.total(),
|
||||
);
|
||||
commands.insert_resource(selection);
|
||||
next_state.set(AppState::InGame);
|
||||
}
|
||||
StartingBaseButton::Back => {
|
||||
next_state.set(AppState::CharacterCreation);
|
||||
}
|
||||
StartingBaseButton::Candidate(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_candidate(draft: &StartingBaseDraft) -> Option<&StartingBaseCandidate> {
|
||||
draft
|
||||
.selected_index
|
||||
.and_then(|index| draft.candidates.get(index))
|
||||
}
|
||||
|
||||
fn format_candidate_details(candidate: &StartingBaseCandidate) -> String {
|
||||
let mut lines = vec![
|
||||
candidate.name.clone(),
|
||||
format!(
|
||||
"System ID: {} | Faction: {} | Security: {:.2} | Distance: {:.0}u",
|
||||
candidate.id, candidate.faction, candidate.security, candidate.distance_from_core
|
||||
),
|
||||
format!("Total POIs: {}", candidate.contents.total()),
|
||||
String::new(),
|
||||
];
|
||||
|
||||
append_contents(&mut lines, &candidate.contents);
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn append_contents(lines: &mut Vec<String>, contents: &SystemContents) {
|
||||
lines.push(format!("Planets ({})", contents.planets.len()));
|
||||
for planet in &contents.planets {
|
||||
let population = if planet.population > 0 {
|
||||
format!("pop {}", planet.population)
|
||||
} else {
|
||||
"uninhabited".to_string()
|
||||
};
|
||||
lines.push(format!(
|
||||
"- {}: {}, orbit {:.1}, {}",
|
||||
planet.name,
|
||||
planet.planet_type.display_name(),
|
||||
planet.orbit,
|
||||
population
|
||||
));
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
lines.push(format!("Stations ({})", contents.stations.len()));
|
||||
for station in &contents.stations {
|
||||
lines.push(format!(
|
||||
"- {}: orbit {:.1}, pop {}",
|
||||
station.name, station.orbit, station.population
|
||||
));
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
lines.push(format!("Asteroid Belts ({})", contents.belts.len()));
|
||||
for belt in &contents.belts {
|
||||
lines.push(format!(
|
||||
"- {}: orbit {:.1}-{:.1}, yield {:.0}",
|
||||
belt.name, belt.inner_orbit, belt.outer_orbit, belt.yield_remaining
|
||||
));
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
lines.push(format!("Gas Clouds ({})", contents.gas_clouds.len()));
|
||||
for cloud in &contents.gas_clouds {
|
||||
lines.push(format!(
|
||||
"- {}: {}, flow {:.1}/s",
|
||||
cloud.name,
|
||||
gas_kind_label(cloud.gas_kind),
|
||||
cloud.flow_rate
|
||||
));
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
lines.push(format!("Anomalies ({})", contents.anomalies.len()));
|
||||
for anomaly in &contents.anomalies {
|
||||
let probe_text = if anomaly.requires_probe {
|
||||
"probe required"
|
||||
} else {
|
||||
"ship sensors"
|
||||
};
|
||||
lines.push(format!(
|
||||
"- {}: {}, {}, {}",
|
||||
anomaly.name,
|
||||
difficulty_label(anomaly.difficulty),
|
||||
signal_kind_label(anomaly.signal_kind),
|
||||
probe_text
|
||||
));
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
lines.push(format!("Stargates ({})", contents.stargates.len()));
|
||||
for gate in &contents.stargates {
|
||||
lines.push(format!(
|
||||
"- {}: destination {}",
|
||||
gate.name, gate.destination_name
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn difficulty_label(difficulty: Difficulty) -> &'static str {
|
||||
match difficulty {
|
||||
Difficulty::Trivial => "trivial",
|
||||
Difficulty::Easy => "easy",
|
||||
Difficulty::Moderate => "moderate",
|
||||
Difficulty::Hard => "hard",
|
||||
Difficulty::Expert => "expert",
|
||||
}
|
||||
}
|
||||
|
||||
fn signal_kind_label(signal_kind: SignalKind) -> &'static str {
|
||||
match signal_kind {
|
||||
SignalKind::Infrared => "infrared",
|
||||
SignalKind::Gravimetric => "gravimetric",
|
||||
SignalKind::Radar => "radar",
|
||||
SignalKind::Magnetometric => "magnetometric",
|
||||
}
|
||||
}
|
||||
|
||||
fn gas_kind_label(gas_kind: GasKind) -> &'static str {
|
||||
match gas_kind {
|
||||
GasKind::Hydrogen => "hydrogen",
|
||||
GasKind::Helium => "helium",
|
||||
GasKind::Nitrogen => "nitrogen",
|
||||
GasKind::Exotic => "exotic",
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,8 @@ use bevy::prelude::*;
|
||||
|
||||
use camera::orbit_camera_control;
|
||||
use gameplay::{
|
||||
character_creation::CharacterCreationPlugin,
|
||||
galaxy::GalaxyPlugin,
|
||||
movement::MovementPlugin,
|
||||
physics::PhysicsPlugin,
|
||||
star_map::StarMapPlugin,
|
||||
character_creation::CharacterCreationPlugin, galaxy::GalaxyPlugin, movement::MovementPlugin,
|
||||
physics::PhysicsPlugin, star_map::StarMapPlugin, starting_base::StartingBasePlugin,
|
||||
};
|
||||
use state::AppState;
|
||||
use ui::main_menu;
|
||||
@@ -42,6 +39,7 @@ fn main() {
|
||||
GalaxyPlugin,
|
||||
StarMapPlugin,
|
||||
CharacterCreationPlugin,
|
||||
StartingBasePlugin,
|
||||
))
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ pub enum AppState {
|
||||
MainMenu,
|
||||
Galaxy,
|
||||
CharacterCreation,
|
||||
StartingBaseSelection,
|
||||
InGame,
|
||||
Options,
|
||||
}
|
||||
|
||||
@@ -13,10 +13,7 @@ use bevy::ui::ComputedNode;
|
||||
/// Coordinates: Bevy UI layout runs in **physical** pixels, while
|
||||
/// [`Window::cursor_position`] returns **logical** pixels. We multiply by the
|
||||
/// window's scale factor to convert.
|
||||
pub fn cursor_over_ui(
|
||||
window: &Window,
|
||||
nodes: &Query<(&ComputedNode, &GlobalTransform)>,
|
||||
) -> bool {
|
||||
pub fn cursor_over_ui(window: &Window, nodes: &Query<(&ComputedNode, &GlobalTransform)>) -> bool {
|
||||
let Some(cursor_logical) = window.cursor_position() else {
|
||||
return false;
|
||||
};
|
||||
@@ -32,11 +29,7 @@ pub fn cursor_over_ui(
|
||||
let half = node.size() * 0.5;
|
||||
let min = center - half;
|
||||
let max = center + half;
|
||||
if cursor.x >= min.x
|
||||
&& cursor.x <= max.x
|
||||
&& cursor.y >= min.y
|
||||
&& cursor.y <= max.y
|
||||
{
|
||||
if cursor.x >= min.x && cursor.x <= max.x && cursor.y >= min.y && cursor.y <= max.y {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user