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.
|
//! spawn / despawn lifecycle, and handles the Escape → Galaxy shortcut.
|
||||||
//!
|
//!
|
||||||
//! Persistence is not yet wired up — on Confirm, the current draft is logged
|
//! 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
|
//! and the app transitions to [`AppState::StartingBaseSelection`]. The TODO is
|
||||||
//! to SpacetimeDB once the Rust SDK is integrated.
|
//! to persist it to SpacetimeDB once the Rust SDK is integrated.
|
||||||
|
|
||||||
mod params;
|
mod params;
|
||||||
mod ui;
|
mod ui;
|
||||||
@@ -39,6 +39,7 @@ impl Plugin for CharacterCreationPlugin {
|
|||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
escape_to_galaxy,
|
escape_to_galaxy,
|
||||||
|
ui::scroll_character_creation_form,
|
||||||
ui::picker_button_handler,
|
ui::picker_button_handler,
|
||||||
ui::refresh_picker_values,
|
ui::refresh_picker_values,
|
||||||
ui::action_button_handler,
|
ui::action_button_handler,
|
||||||
@@ -78,10 +79,7 @@ fn despawn_character_creation(
|
|||||||
|
|
||||||
// ── Input ───────────────────────────────────────────────────────────────────
|
// ── Input ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn escape_to_galaxy(
|
fn escape_to_galaxy(keys: Res<ButtonInput<KeyCode>>, mut next_state: ResMut<NextState<AppState>>) {
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
|
||||||
mut next_state: ResMut<NextState<AppState>>,
|
|
||||||
) {
|
|
||||||
if keys.just_pressed(KeyCode::Escape) {
|
if keys.just_pressed(KeyCode::Escape) {
|
||||||
next_state.set(AppState::Galaxy);
|
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 ──────────────────────────────────────────────────────────
|
// ── Draft resource ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Editable character draft, mutated by the picker UI and read at confirm time.
|
/// 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 name_index: usize,
|
||||||
pub origin_index: usize,
|
pub origin_index: usize,
|
||||||
pub ship_index: usize,
|
pub ship_index: usize,
|
||||||
|
pub backstory_indices: [usize; BACKSTORY_QUESTION_COUNT],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CharacterDraft {
|
impl CharacterDraft {
|
||||||
@@ -145,6 +357,20 @@ impl CharacterDraft {
|
|||||||
&STARTING_SHIPS[self.ship_index % STARTING_SHIPS.len()]
|
&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`.
|
/// Advance `field` by `delta`, wrapping around the length of `options`.
|
||||||
/// Generic over the picker so the same code handles name / origin / ship.
|
/// Generic over the picker so the same code handles name / origin / ship.
|
||||||
pub fn cycle(field: usize, delta: i32, options: usize) -> usize {
|
pub fn cycle(field: usize, delta: i32, options: usize) -> usize {
|
||||||
@@ -165,6 +391,15 @@ impl CharacterDraft {
|
|||||||
pub fn cycle_ship(&mut self, delta: i32) {
|
pub fn cycle_ship(&mut self, delta: i32) {
|
||||||
self.ship_index = Self::cycle(self.ship_index, delta, STARTING_SHIPS.len());
|
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 ───────────────────────────────────────────────────────────────────
|
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||||
@@ -214,9 +449,19 @@ mod tests {
|
|||||||
name_index: 2,
|
name_index: 2,
|
||||||
origin_index: 1,
|
origin_index: 1,
|
||||||
ship_index: 0,
|
ship_index: 0,
|
||||||
|
backstory_indices: [0; BACKSTORY_QUESTION_COUNT],
|
||||||
};
|
};
|
||||||
assert_eq!(d.name(), CHARACTER_NAMES[2]);
|
assert_eq!(d.name(), CHARACTER_NAMES[2]);
|
||||||
assert_eq!(d.origin().id, ORIGINS[1].id);
|
assert_eq!(d.origin().id, ORIGINS[1].id);
|
||||||
assert_eq!(d.ship().id, STARTING_SHIPS[0].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
|
//! Character creation UI: a sticky header, scrollable form, and sticky
|
||||||
//! (name / origin / starting ship) and Confirm / Back buttons.
|
//! Confirm / Back buttons.
|
||||||
//!
|
//!
|
||||||
//! Each picker is a row of `[<] label value [>]`. The `[<]` and `[>]`
|
//! Each picker is a row of `[<] label value [>]`. The `[<]` and `[>]`
|
||||||
//! buttons carry a [`PickerButton`] marker identifying which field to cycle
|
//! buttons carry a [`PickerButton`] marker identifying which field to cycle
|
||||||
@@ -11,11 +11,12 @@
|
|||||||
//! ([`crate::gameplay::galaxy::ui`]): dark navy panels, cyan borders, dim
|
//! ([`crate::gameplay::galaxy::ui`]): dark navy panels, cyan borders, dim
|
||||||
//! labels, bright values.
|
//! 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::{
|
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 ───────────────────────────────────────────────────────
|
// ── Styling constants ───────────────────────────────────────────────────────
|
||||||
@@ -39,8 +40,8 @@ const BLURB_FONT_SIZE: f32 = 13.0;
|
|||||||
const BUTTON_FONT_SIZE: f32 = 18.0;
|
const BUTTON_FONT_SIZE: f32 = 18.0;
|
||||||
const ARROW_FONT_SIZE: f32 = 20.0;
|
const ARROW_FONT_SIZE: f32 = 20.0;
|
||||||
|
|
||||||
const PICKER_WIDTH: f32 = 520.0;
|
|
||||||
const ARROW_BUTTON: f32 = 36.0;
|
const ARROW_BUTTON: f32 = 36.0;
|
||||||
|
const CARD_WIDTH: f32 = 760.0;
|
||||||
|
|
||||||
// ── Markers ─────────────────────────────────────────────────────────────────
|
// ── Markers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ const ARROW_BUTTON: f32 = 36.0;
|
|||||||
pub enum PickerButton {
|
pub enum PickerButton {
|
||||||
Name(i32),
|
Name(i32),
|
||||||
Origin(i32),
|
Origin(i32),
|
||||||
|
Backstory(usize, i32),
|
||||||
Ship(i32),
|
Ship(i32),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +77,18 @@ pub struct ShipBlurb;
|
|||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
pub struct OriginBonus;
|
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 ───────────────────────────────────────────────────────────────────
|
// ── Setup ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn setup_character_creation_ui(mut commands: Commands) {
|
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),
|
height: Val::Percent(100.0),
|
||||||
display: Display::Flex,
|
display: Display::Flex,
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Column,
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::FlexStart,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
row_gap: Val::Px(18.0),
|
|
||||||
padding: UiRect::all(Val::Px(24.0)),
|
padding: UiRect::all(Val::Px(24.0)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
@@ -105,121 +118,168 @@ pub fn setup_character_creation_ui(mut commands: Commands) {
|
|||||||
TextColor(TEXT_BRIGHT),
|
TextColor(TEXT_BRIGHT),
|
||||||
));
|
));
|
||||||
root.spawn((
|
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 {
|
TextFont {
|
||||||
font_size: SUBTITLE_FONT_SIZE,
|
font_size: SUBTITLE_FONT_SIZE,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
TextColor(TEXT_DIM),
|
TextColor(TEXT_DIM),
|
||||||
Node {
|
Node {
|
||||||
margin: UiRect::bottom(Val::Px(8.0)),
|
margin: UiRect::bottom(Val::Px(12.0)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
// ── Main card ────────────────────────────────────────────────
|
// ── Scrollable form ─────────────────────────────────────────
|
||||||
root.spawn((
|
root.spawn((
|
||||||
Node {
|
Node {
|
||||||
width: Val::Px(PICKER_WIDTH + 64.0),
|
width: Val::Percent(100.0),
|
||||||
padding: UiRect::all(Val::Px(24.0)),
|
flex_grow: 1.0,
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Column,
|
||||||
row_gap: Val::Px(18.0),
|
align_items: AlignItems::Center,
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
overflow: Overflow::scroll_y(),
|
||||||
|
padding: UiRect::vertical(Val::Px(4.0)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(PANEL_BG),
|
ScrollPosition::default(),
|
||||||
BorderColor(PANEL_BORDER),
|
CharacterCreationScrollViewport,
|
||||||
BorderRadius::all(Val::Px(10.0)),
|
CharacterCreationScrollContent,
|
||||||
))
|
))
|
||||||
.with_children(|card| {
|
.with_children(|scroll| {
|
||||||
spawn_section_label(card, "NAME");
|
scroll
|
||||||
spawn_picker_row(card, "Callsign", PickerButton::Name(-1), PickerButton::Name(1));
|
.spawn((
|
||||||
spawn_spacer(card);
|
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_section_label(card, "ORIGIN");
|
||||||
spawn_picker_row_with_marker(
|
spawn_picker_row_with_marker(
|
||||||
card,
|
card,
|
||||||
"Background",
|
"Background",
|
||||||
PickerButton::Origin(-1),
|
PickerButton::Origin(-1),
|
||||||
PickerButton::Origin(1),
|
PickerButton::Origin(1),
|
||||||
OriginValue,
|
OriginValue,
|
||||||
);
|
);
|
||||||
card.spawn((
|
card.spawn((
|
||||||
Text::new(""),
|
Text::new(""),
|
||||||
TextFont {
|
TextFont {
|
||||||
font_size: BLURB_FONT_SIZE,
|
font_size: BLURB_FONT_SIZE,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
TextColor(TEXT_DIM),
|
TextColor(TEXT_DIM),
|
||||||
Node {
|
Node {
|
||||||
width: Val::Percent(100.0),
|
width: Val::Percent(100.0),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
OriginBlurb,
|
OriginBlurb,
|
||||||
));
|
));
|
||||||
card.spawn((
|
card.spawn((
|
||||||
Text::new(""),
|
Text::new(""),
|
||||||
TextFont {
|
TextFont {
|
||||||
font_size: BLURB_FONT_SIZE,
|
font_size: BLURB_FONT_SIZE,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
TextColor(ACCENT),
|
TextColor(ACCENT),
|
||||||
Node {
|
Node {
|
||||||
width: Val::Percent(100.0),
|
width: Val::Percent(100.0),
|
||||||
margin: UiRect::top(Val::Px(2.0)),
|
margin: UiRect::top(Val::Px(2.0)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
OriginBonus,
|
OriginBonus,
|
||||||
));
|
));
|
||||||
spawn_spacer(card);
|
spawn_spacer(card);
|
||||||
|
|
||||||
spawn_section_label(card, "STARTING SHIP");
|
spawn_section_label(card, "BACKSTORY");
|
||||||
spawn_picker_row_with_marker(
|
spawn_backstory_rows(card);
|
||||||
card,
|
card.spawn((
|
||||||
"Hull",
|
Text::new(""),
|
||||||
PickerButton::Ship(-1),
|
TextFont {
|
||||||
PickerButton::Ship(1),
|
font_size: BLURB_FONT_SIZE,
|
||||||
ShipValue,
|
..default()
|
||||||
);
|
},
|
||||||
card.spawn((
|
TextColor(ACCENT),
|
||||||
Text::new(""),
|
Node {
|
||||||
TextFont {
|
width: Val::Percent(100.0),
|
||||||
font_size: BLURB_FONT_SIZE,
|
margin: UiRect::top(Val::Px(2.0)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
TextColor(TEXT_DIM),
|
StatsSummary,
|
||||||
Node {
|
));
|
||||||
width: Val::Percent(100.0),
|
spawn_spacer(card);
|
||||||
..default()
|
|
||||||
},
|
spawn_section_label(card, "STARTING SHIP");
|
||||||
ShipBlurb,
|
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 ──────────────────────────────────────────────
|
// ── Button row ──────────────────────────────────────────────
|
||||||
root
|
root.spawn(Node {
|
||||||
.spawn(Node {
|
width: Val::Percent(100.0),
|
||||||
flex_direction: FlexDirection::Row,
|
max_width: Val::Px(CARD_WIDTH),
|
||||||
column_gap: Val::Px(16.0),
|
flex_direction: FlexDirection::Row,
|
||||||
margin: UiRect::top(Val::Px(4.0)),
|
flex_wrap: FlexWrap::Wrap,
|
||||||
..default()
|
justify_content: JustifyContent::Center,
|
||||||
})
|
column_gap: Val::Px(16.0),
|
||||||
.with_children(|row| {
|
row_gap: Val::Px(8.0),
|
||||||
spawn_action_button(
|
margin: UiRect::top(Val::Px(12.0)),
|
||||||
row,
|
..default()
|
||||||
"Confirm",
|
})
|
||||||
BUTTON_BG_CONFIRM,
|
.with_children(|row| {
|
||||||
BUTTON_BORDER_CONFIRM,
|
spawn_action_button(
|
||||||
CharacterCreationButton::Confirm,
|
row,
|
||||||
);
|
"Confirm",
|
||||||
spawn_action_button(
|
BUTTON_BG_CONFIRM,
|
||||||
row,
|
BUTTON_BORDER_CONFIRM,
|
||||||
"Back",
|
CharacterCreationButton::Confirm,
|
||||||
BUTTON_BG,
|
);
|
||||||
PANEL_BORDER,
|
spawn_action_button(
|
||||||
CharacterCreationButton::Back,
|
row,
|
||||||
);
|
"Back",
|
||||||
});
|
BUTTON_BG,
|
||||||
|
PANEL_BORDER,
|
||||||
|
CharacterCreationButton::Back,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Footer hint ─────────────────────────────────────────────
|
// ── Footer hint ─────────────────────────────────────────────
|
||||||
root.spawn((
|
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 ────────────────────────────────────────────────────────────
|
// ── Sub-spawners ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn spawn_section_label(parent: &mut ChildSpawnerCommands, label: &str) {
|
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) {
|
fn spawn_arrow_button(parent: &mut ChildSpawnerCommands, glyph: &str, marker: PickerButton) {
|
||||||
parent
|
parent
|
||||||
.spawn((
|
.spawn((
|
||||||
@@ -396,42 +513,16 @@ fn spawn_action_button(
|
|||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
pub fn refresh_picker_values(
|
pub fn refresh_picker_values(
|
||||||
draft: Res<CharacterDraft>,
|
draft: Res<CharacterDraft>,
|
||||||
mut name_q: Query<&mut Text, With<NameValue>>,
|
mut text_queries: ParamSet<(
|
||||||
mut origin_q: Query<&mut Text, (With<OriginValue>, Without<NameValue>, Without<ShipValue>)>,
|
Query<&mut Text, With<NameValue>>,
|
||||||
mut ship_q: Query<&mut Text, (With<ShipValue>, Without<NameValue>, Without<OriginValue>)>,
|
Query<&mut Text, With<OriginValue>>,
|
||||||
mut origin_blurb_q: Query<
|
Query<&mut Text, With<OriginBlurb>>,
|
||||||
&mut Text,
|
Query<&mut Text, With<OriginBonus>>,
|
||||||
(
|
Query<(&BackstoryValue, &mut Text)>,
|
||||||
With<OriginBlurb>,
|
Query<&mut Text, With<StatsSummary>>,
|
||||||
Without<NameValue>,
|
Query<&mut Text, With<ShipValue>>,
|
||||||
Without<OriginValue>,
|
Query<&mut Text, With<ShipBlurb>>,
|
||||||
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
|
// The draft only changes when a picker button is pressed; if the resource
|
||||||
// hasn't been mutated, we can skip the whole pass.
|
// hasn't been mutated, we can skip the whole pass.
|
||||||
@@ -439,39 +530,77 @@ pub fn refresh_picker_values(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(mut txt) = name_q.single_mut() {
|
{
|
||||||
if txt.0 != draft.name() {
|
let mut name_q = text_queries.p0();
|
||||||
txt.0 = draft.name().to_string();
|
if let Ok(mut txt) = name_q.single_mut() {
|
||||||
|
if txt.0 != draft.name() {
|
||||||
|
txt.0 = draft.name().to_string();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let origin = draft.origin();
|
let origin = draft.origin();
|
||||||
if let Ok(mut txt) = origin_q.single_mut() {
|
{
|
||||||
if txt.0 != origin.name {
|
let mut origin_q = text_queries.p1();
|
||||||
txt.0 = origin.name.to_string();
|
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 {
|
let mut origin_blurb_q = text_queries.p2();
|
||||||
txt.0 = origin.blurb.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 {
|
let mut origin_bonus_q = text_queries.p3();
|
||||||
txt.0 = origin.bonus.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 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();
|
let ship = draft.ship();
|
||||||
if let Ok(mut txt) = ship_q.single_mut() {
|
{
|
||||||
let label = format!("{} · {}", ship.name, ship.role);
|
let mut ship_q = text_queries.p6();
|
||||||
if txt.0 != label {
|
if let Ok(mut txt) = ship_q.single_mut() {
|
||||||
txt.0 = label;
|
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 {
|
let mut ship_blurb_q = text_queries.p7();
|
||||||
txt.0 = ship.blurb.to_string();
|
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 {
|
match *button {
|
||||||
PickerButton::Name(delta) => draft.cycle_name(delta),
|
PickerButton::Name(delta) => draft.cycle_name(delta),
|
||||||
PickerButton::Origin(delta) => draft.cycle_origin(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),
|
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
|
/// 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)
|
/// extra resources — the draft is persisted later by the (yet-to-be-written)
|
||||||
/// save pipeline.
|
/// save pipeline.
|
||||||
@@ -514,12 +646,14 @@ pub fn action_button_handler(
|
|||||||
CharacterCreationButton::Confirm => {
|
CharacterCreationButton::Confirm => {
|
||||||
// TODO: persist `*draft` to SpacetimeDB before entering the game.
|
// TODO: persist `*draft` to SpacetimeDB before entering the game.
|
||||||
bevy::log::info!(
|
bevy::log::info!(
|
||||||
"Confirming character: name={}, origin={}, ship={}",
|
"Confirming character: name={}, origin={}, ship={}, backstory={}, stats={}",
|
||||||
draft.name(),
|
draft.name(),
|
||||||
draft.origin().id,
|
draft.origin().id,
|
||||||
draft.ship().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 => {
|
CharacterCreationButton::Back => {
|
||||||
next_state.set(crate::state::AppState::Galaxy);
|
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 ───────────────────────
|
// ── Sanity check: name/origin/ship counts must agree ───────────────────────
|
||||||
//
|
//
|
||||||
// These constants are here so a quick grep tells future-you where the UI's
|
// 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!(!CHARACTER_NAMES.is_empty());
|
||||||
assert!(!ORIGINS.is_empty());
|
assert!(!ORIGINS.is_empty());
|
||||||
assert!(!STARTING_SHIPS.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.
|
/// same trick. Asteroid rocks share a single jittered icosphere mesh.
|
||||||
pub struct ContentAssets {
|
pub struct ContentAssets {
|
||||||
// Meshes
|
// Meshes
|
||||||
pub planet_mesh: Handle<Mesh>, // unit sphere
|
pub planet_mesh: Handle<Mesh>, // unit sphere
|
||||||
pub station_mesh: Handle<Mesh>, // unit cube
|
pub station_mesh: Handle<Mesh>, // unit cube
|
||||||
pub anomaly_mesh: Handle<Mesh>, // low-poly sphere (angular/crystalline)
|
pub anomaly_mesh: Handle<Mesh>, // low-poly sphere (angular/crystalline)
|
||||||
pub gas_mesh: Handle<Mesh>, // unit sphere (used with translucent mat)
|
pub gas_mesh: Handle<Mesh>, // unit sphere (used with translucent mat)
|
||||||
pub stargate_mesh: Handle<Mesh>, // unit torus
|
pub stargate_mesh: Handle<Mesh>, // unit torus
|
||||||
/// Jittered icosphere shared by every asteroid instance across all belts.
|
/// Jittered icosphere shared by every asteroid instance across all belts.
|
||||||
/// Per-rock variation comes from per-instance Transform + material pick.
|
/// Per-rock variation comes from per-instance Transform + material pick.
|
||||||
pub asteroid_mesh: Handle<Mesh>,
|
pub asteroid_mesh: Handle<Mesh>,
|
||||||
@@ -820,8 +820,7 @@ pub fn spawn_system_contents(
|
|||||||
// (mild — linear formula, not Kepler). Y-jitter from
|
// (mild — linear formula, not Kepler). Y-jitter from
|
||||||
// `rock.position.y` is preserved because the orbital
|
// `rock.position.y` is preserved because the orbital
|
||||||
// system only overwrites x/z.
|
// system only overwrites x/z.
|
||||||
let rock_radius =
|
let rock_radius = Vec2::new(rock.position.x, rock.position.z).length();
|
||||||
Vec2::new(rock.position.x, rock.position.z).length();
|
|
||||||
let rock_phase = rock.position.z.atan2(rock.position.x);
|
let rock_phase = rock.position.z.atan2(rock.position.x);
|
||||||
belt_parent.spawn((
|
belt_parent.spawn((
|
||||||
Orbital {
|
Orbital {
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ use crate::camera::apply_orbit_reset;
|
|||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub use contents::{SystemContents, SystemContext, SystemSummary};
|
pub use contents::{SystemContents, SystemContext, SystemSummary};
|
||||||
pub use params::{GalaxyParams, SelectedStar};
|
|
||||||
pub use params::{BeamParams, CoreParams, DiskParams};
|
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;
|
pub struct GalaxyPlugin;
|
||||||
|
|
||||||
@@ -37,10 +37,7 @@ impl Plugin for GalaxyPlugin {
|
|||||||
OnEnter(AppState::Galaxy),
|
OnEnter(AppState::Galaxy),
|
||||||
(setup_galaxy_scene, ui::setup_galaxy_ui),
|
(setup_galaxy_scene, ui::setup_galaxy_ui),
|
||||||
)
|
)
|
||||||
.add_systems(
|
.add_systems(OnExit(AppState::Galaxy), (despawn_galaxy, reset_selection))
|
||||||
OnExit(AppState::Galaxy),
|
|
||||||
(despawn_galaxy, reset_selection),
|
|
||||||
)
|
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -114,6 +111,33 @@ const FACTIONS: &[(&str, [f32; 3])] = &[
|
|||||||
("Caldari", [0.22, 0.74, 0.97]), // blue
|
("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 ───────────────────────────────────────────────────────────────────
|
// ── Setup ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn setup_galaxy_scene(
|
fn setup_galaxy_scene(
|
||||||
@@ -182,6 +206,56 @@ fn generate_galaxy(
|
|||||||
(systems, contents, connections)
|
(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 —
|
/// Position-only galaxy generation. Composes multiple structural layers —
|
||||||
/// a [`CoreParams`] cluster, up to [`MAX_DISKS`] independent [`DiskParams`]
|
/// a [`CoreParams`] cluster, up to [`MAX_DISKS`] independent [`DiskParams`]
|
||||||
/// spiral disks, and two [`BeamParams`] columns along ±Y — into a single
|
/// 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
|
// Total budget — pre-allocation only; each pass appends as many systems
|
||||||
// as it manages to place.
|
// as it manages to place.
|
||||||
let disk_total: usize = params.disks.iter().map(|d| d.count).sum();
|
let disk_total: usize = params.disks.iter().map(|d| d.count).sum();
|
||||||
let total = params.core.count
|
let total = params.core.count + disk_total + params.beam_top.count + params.beam_bottom.count;
|
||||||
+ disk_total
|
|
||||||
+ params.beam_top.count
|
|
||||||
+ params.beam_bottom.count;
|
|
||||||
let mut systems: Vec<GeneratedSystem> = Vec::with_capacity(total);
|
let mut systems: Vec<GeneratedSystem> = Vec::with_capacity(total);
|
||||||
|
|
||||||
// Spacing scales with overall density: tighter for crowded galaxies,
|
// 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.
|
// Counter shared across passes for `g-{n}` IDs and name suffixes.
|
||||||
let mut next_index = 0usize;
|
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() {
|
for (disk_index, disk) in params.disks.iter().enumerate() {
|
||||||
generate_disk(
|
generate_disk(
|
||||||
@@ -340,8 +418,7 @@ fn generate_disk(
|
|||||||
// Non-core disk factions cycle through Amarr/Minmatar/Gallente/Caldari.
|
// Non-core disk factions cycle through Amarr/Minmatar/Gallente/Caldari.
|
||||||
// Offset by disk_index so different disks don't all share the same
|
// Offset by disk_index so different disks don't all share the same
|
||||||
// faction assignment pattern.
|
// faction assignment pattern.
|
||||||
let faction_index =
|
let faction_index = 1 + ((arm as usize + disk_index) % (FACTIONS.len() - 1));
|
||||||
1 + ((arm as usize + disk_index) % (FACTIONS.len() - 1));
|
|
||||||
let (faction, color) = FACTIONS[faction_index];
|
let (faction, color) = FACTIONS[faction_index];
|
||||||
|
|
||||||
let mut position = Option::<Vec3>::None;
|
let mut position = Option::<Vec3>::None;
|
||||||
@@ -531,7 +608,9 @@ enum SystemOrigin {
|
|||||||
/// Which arm within the disk.
|
/// Which arm within the disk.
|
||||||
arm: u32,
|
arm: u32,
|
||||||
},
|
},
|
||||||
Beam { side: BeamTag },
|
Beam {
|
||||||
|
side: BeamTag,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -611,7 +690,13 @@ fn spawn_galaxy_scene(
|
|||||||
|
|
||||||
// Parent group so all galaxy contents despawn together.
|
// Parent group so all galaxy contents despawn together.
|
||||||
commands
|
commands
|
||||||
.spawn((Transform::default(), GalaxyScene, GalaxySpawned))
|
.spawn((
|
||||||
|
Transform::default(),
|
||||||
|
Visibility::default(),
|
||||||
|
InheritedVisibility::default(),
|
||||||
|
GalaxyScene,
|
||||||
|
GalaxySpawned,
|
||||||
|
))
|
||||||
.with_children(|parent| {
|
.with_children(|parent| {
|
||||||
// XYZ reference axes through the origin.
|
// XYZ reference axes through the origin.
|
||||||
axes::spawn_axes(parent, meshes, materials);
|
axes::spawn_axes(parent, meshes, materials);
|
||||||
@@ -639,6 +724,8 @@ fn spawn_galaxy_scene(
|
|||||||
parent
|
parent
|
||||||
.spawn((
|
.spawn((
|
||||||
Transform::from_translation(sys.position),
|
Transform::from_translation(sys.position),
|
||||||
|
Visibility::default(),
|
||||||
|
InheritedVisibility::default(),
|
||||||
StarSystem {
|
StarSystem {
|
||||||
id: sys.id.clone(),
|
id: sys.id.clone(),
|
||||||
name: sys.name.clone(),
|
name: sys.name.clone(),
|
||||||
@@ -733,10 +820,7 @@ fn build_connections(systems: &[GeneratedSystem]) -> Vec<(usize, usize)> {
|
|||||||
|
|
||||||
// ── Lifecycle systems ───────────────────────────────────────────────────────
|
// ── Lifecycle systems ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn despawn_galaxy(
|
fn despawn_galaxy(mut commands: Commands, query: Query<Entity, With<GalaxySpawned>>) {
|
||||||
mut commands: Commands,
|
|
||||||
query: Query<Entity, With<GalaxySpawned>>,
|
|
||||||
) {
|
|
||||||
for entity in &query {
|
for entity in &query {
|
||||||
// Bevy 0.16: despawn() is recursive by default.
|
// Bevy 0.16: despawn() is recursive by default.
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
|
|||||||
@@ -41,10 +41,7 @@ use super::poi::Orbital;
|
|||||||
/// Advance every [`Orbital`] along its path.
|
/// Advance every [`Orbital`] along its path.
|
||||||
///
|
///
|
||||||
/// See module docs for the math and the hierarchy assumption.
|
/// See module docs for the math and the hierarchy assumption.
|
||||||
pub fn advance_orbital_paths(
|
pub fn advance_orbital_paths(time: Res<Time>, mut orbitals: Query<(&Orbital, &mut Transform)>) {
|
||||||
time: Res<Time>,
|
|
||||||
mut orbitals: Query<(&Orbital, &mut Transform)>,
|
|
||||||
) {
|
|
||||||
let t = time.elapsed_secs();
|
let t = time.elapsed_secs();
|
||||||
for (orbital, mut transform) in &mut orbitals {
|
for (orbital, mut transform) in &mut orbitals {
|
||||||
if orbital.period <= 0.0 {
|
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"
|
"orbital_position requires period > 0; static POIs should be handled by the caller"
|
||||||
);
|
);
|
||||||
let angle = phase + (std::f32::consts::TAU / period) * t;
|
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 ──────────────────────────────────────────────────────────────────
|
// ─── Tests ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ pub const DISK_COUNT_MAX: usize = 220;
|
|||||||
pub const DISK_COUNT_STEP: usize = 4;
|
pub const DISK_COUNT_STEP: usize = 4;
|
||||||
pub const DISK_ARMS_MIN: u32 = 1;
|
pub const DISK_ARMS_MIN: u32 = 1;
|
||||||
pub const DISK_ARMS_MAX: u32 = 6;
|
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_MAX: f32 = 18.0; // ~2.9 turns — tight logarithmic spiral
|
||||||
pub const DISK_TWIST_STEP: f32 = 0.4;
|
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 super::GalaxySpawned;
|
||||||
use crate::gameplay::galaxy::params::*;
|
use crate::gameplay::galaxy::params::*;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
// ── Markers ─────────────────────────────────────────────────────────────────
|
// ── Markers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -91,6 +92,8 @@ pub enum ParamButton {
|
|||||||
Regenerate,
|
Regenerate,
|
||||||
/// Reset orbit camera to default orientation.
|
/// Reset orbit camera to default orientation.
|
||||||
CenterView,
|
CenterView,
|
||||||
|
/// Accept the current galaxy and continue to character creation.
|
||||||
|
CreateGalaxy,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resource tracking which disk layer is currently selected in the tab bar.
|
/// 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.
|
// Scrollable content area.
|
||||||
parent
|
parent
|
||||||
.spawn((
|
.spawn((
|
||||||
@@ -375,12 +405,7 @@ fn spawn_icon_button(parent: &mut ChildSpawnerCommands, label: &str, marker: Par
|
|||||||
parent_button(parent, marker, label, 26.0);
|
parent_button(parent, marker, label, 26.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parent_button(
|
fn parent_button(parent: &mut ChildSpawnerCommands, marker: ParamButton, label: &str, width: f32) {
|
||||||
parent: &mut ChildSpawnerCommands,
|
|
||||||
marker: ParamButton,
|
|
||||||
label: &str,
|
|
||||||
width: f32,
|
|
||||||
) {
|
|
||||||
parent
|
parent
|
||||||
.spawn((
|
.spawn((
|
||||||
Button,
|
Button,
|
||||||
@@ -461,6 +486,7 @@ pub fn param_button_handler(
|
|||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut params: ResMut<GalaxyParams>,
|
mut params: ResMut<GalaxyParams>,
|
||||||
mut selected_disk: ResMut<SelectedDisk>,
|
mut selected_disk: ResMut<SelectedDisk>,
|
||||||
|
mut next_state: ResMut<NextState<AppState>>,
|
||||||
query: Query<(&Interaction, &ParamButton), Changed<Interaction>>,
|
query: Query<(&Interaction, &ParamButton), Changed<Interaction>>,
|
||||||
) {
|
) {
|
||||||
for (interaction, button) in &query {
|
for (interaction, button) in &query {
|
||||||
@@ -499,13 +525,11 @@ pub fn param_button_handler(
|
|||||||
params.bump_generation();
|
params.bump_generation();
|
||||||
}
|
}
|
||||||
ParamButton::CoreRadiusDecr => {
|
ParamButton::CoreRadiusDecr => {
|
||||||
params.core.radius =
|
params.core.radius = (params.core.radius - CORE_RADIUS_STEP).max(CORE_RADIUS_MIN);
|
||||||
(params.core.radius - CORE_RADIUS_STEP).max(CORE_RADIUS_MIN);
|
|
||||||
params.bump_generation();
|
params.bump_generation();
|
||||||
}
|
}
|
||||||
ParamButton::CoreRadiusIncr => {
|
ParamButton::CoreRadiusIncr => {
|
||||||
params.core.radius =
|
params.core.radius = (params.core.radius + CORE_RADIUS_STEP).min(CORE_RADIUS_MAX);
|
||||||
(params.core.radius + CORE_RADIUS_STEP).min(CORE_RADIUS_MAX);
|
|
||||||
params.bump_generation();
|
params.bump_generation();
|
||||||
}
|
}
|
||||||
// ── Disk tab management ───────────────────────────────────────
|
// ── Disk tab management ───────────────────────────────────────
|
||||||
@@ -531,7 +555,10 @@ pub fn param_button_handler(
|
|||||||
// ── Disk params (target selected_disk) ────────────────────────
|
// ── Disk params (target selected_disk) ────────────────────────
|
||||||
ParamButton::DiskCountDecr => {
|
ParamButton::DiskCountDecr => {
|
||||||
let disk = selected_disk_mut(&mut params, selected_disk.0);
|
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();
|
params.bump_generation();
|
||||||
}
|
}
|
||||||
ParamButton::DiskCountIncr => {
|
ParamButton::DiskCountIncr => {
|
||||||
@@ -571,14 +598,14 @@ pub fn param_button_handler(
|
|||||||
}
|
}
|
||||||
ParamButton::DiskRotationDecr => {
|
ParamButton::DiskRotationDecr => {
|
||||||
let disk = selected_disk_mut(&mut params, selected_disk.0);
|
let disk = selected_disk_mut(&mut params, selected_disk.0);
|
||||||
disk.rotation_offset = (disk.rotation_offset - DISK_ROTATION_STEP)
|
disk.rotation_offset =
|
||||||
.max(DISK_ROTATION_MIN);
|
(disk.rotation_offset - DISK_ROTATION_STEP).max(DISK_ROTATION_MIN);
|
||||||
params.bump_generation();
|
params.bump_generation();
|
||||||
}
|
}
|
||||||
ParamButton::DiskRotationIncr => {
|
ParamButton::DiskRotationIncr => {
|
||||||
let disk = selected_disk_mut(&mut params, selected_disk.0);
|
let disk = selected_disk_mut(&mut params, selected_disk.0);
|
||||||
disk.rotation_offset = (disk.rotation_offset + DISK_ROTATION_STEP)
|
disk.rotation_offset =
|
||||||
.min(DISK_ROTATION_MAX);
|
(disk.rotation_offset + DISK_ROTATION_STEP).min(DISK_ROTATION_MAX);
|
||||||
params.bump_generation();
|
params.bump_generation();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,15 +673,13 @@ pub fn param_button_handler(
|
|||||||
params.bump_generation();
|
params.bump_generation();
|
||||||
}
|
}
|
||||||
ParamButton::BeamBottomThicknessDecr => {
|
ParamButton::BeamBottomThicknessDecr => {
|
||||||
params.beam_bottom.thickness = (params.beam_bottom.thickness
|
params.beam_bottom.thickness =
|
||||||
- BEAM_THICKNESS_STEP)
|
(params.beam_bottom.thickness - BEAM_THICKNESS_STEP).max(BEAM_THICKNESS_MIN);
|
||||||
.max(BEAM_THICKNESS_MIN);
|
|
||||||
params.bump_generation();
|
params.bump_generation();
|
||||||
}
|
}
|
||||||
ParamButton::BeamBottomThicknessIncr => {
|
ParamButton::BeamBottomThicknessIncr => {
|
||||||
params.beam_bottom.thickness = (params.beam_bottom.thickness
|
params.beam_bottom.thickness =
|
||||||
+ BEAM_THICKNESS_STEP)
|
(params.beam_bottom.thickness + BEAM_THICKNESS_STEP).min(BEAM_THICKNESS_MAX);
|
||||||
.min(BEAM_THICKNESS_MAX);
|
|
||||||
params.bump_generation();
|
params.bump_generation();
|
||||||
}
|
}
|
||||||
ParamButton::BeamBottomLengthDecr => {
|
ParamButton::BeamBottomLengthDecr => {
|
||||||
@@ -691,15 +716,13 @@ pub fn param_button_handler(
|
|||||||
params.bump_generation();
|
params.bump_generation();
|
||||||
}
|
}
|
||||||
ParamButton::BeamBottomGravityDecr => {
|
ParamButton::BeamBottomGravityDecr => {
|
||||||
params.beam_bottom.gravity = (params.beam_bottom.gravity
|
params.beam_bottom.gravity =
|
||||||
- BEAM_GRAVITY_STEP)
|
(params.beam_bottom.gravity - BEAM_GRAVITY_STEP).max(BEAM_GRAVITY_MIN);
|
||||||
.max(BEAM_GRAVITY_MIN);
|
|
||||||
params.bump_generation();
|
params.bump_generation();
|
||||||
}
|
}
|
||||||
ParamButton::BeamBottomGravityIncr => {
|
ParamButton::BeamBottomGravityIncr => {
|
||||||
params.beam_bottom.gravity = (params.beam_bottom.gravity
|
params.beam_bottom.gravity =
|
||||||
+ BEAM_GRAVITY_STEP)
|
(params.beam_bottom.gravity + BEAM_GRAVITY_STEP).min(BEAM_GRAVITY_MAX);
|
||||||
.min(BEAM_GRAVITY_MAX);
|
|
||||||
params.bump_generation();
|
params.bump_generation();
|
||||||
}
|
}
|
||||||
ParamButton::RandomSettings => {
|
ParamButton::RandomSettings => {
|
||||||
@@ -728,6 +751,14 @@ pub fn param_button_handler(
|
|||||||
ParamButton::Regenerate => params.reseed_and_bump(),
|
ParamButton::Regenerate => params.reseed_and_bump(),
|
||||||
// CenterView is handled by `reset_view_button_handler` (needs Commands).
|
// CenterView is handled by `reset_view_button_handler` (needs Commands).
|
||||||
ParamButton::CenterView => {}
|
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)>,
|
mut values: Query<(&ParamValue, &mut Text)>,
|
||||||
) {
|
) {
|
||||||
// Resolve the selected disk, clamped to a valid index.
|
// 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;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -791,14 +825,7 @@ pub fn refresh_control_panel_values(
|
|||||||
|
|
||||||
// Top beam
|
// Top beam
|
||||||
"beam_top_enabled" => {
|
"beam_top_enabled" => {
|
||||||
format!(
|
format!("{}", if params.beam_top.enabled { "On" } else { "Off" })
|
||||||
"{}",
|
|
||||||
if params.beam_top.enabled {
|
|
||||||
"On"
|
|
||||||
} else {
|
|
||||||
"Off"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
"beam_top_thickness" => format!("{:.0}", params.beam_top.thickness),
|
"beam_top_thickness" => format!("{:.0}", params.beam_top.thickness),
|
||||||
"beam_top_length" => format!("{:.0}", params.beam_top.length),
|
"beam_top_length" => format!("{:.0}", params.beam_top.length),
|
||||||
@@ -859,31 +886,24 @@ pub fn rebuild_scroll_content(
|
|||||||
commands.entity(scroll_entity).despawn();
|
commands.entity(scroll_entity).despawn();
|
||||||
|
|
||||||
// Re-spawn a fresh scroll content as a child of the panel.
|
// Re-spawn a fresh scroll content as a child of the panel.
|
||||||
commands
|
commands.entity(panel_entity).with_children(|parent| {
|
||||||
.entity(panel_entity)
|
parent
|
||||||
.with_children(|parent| {
|
.spawn((
|
||||||
parent
|
Node {
|
||||||
.spawn((
|
flex_grow: 1.0,
|
||||||
Node {
|
flex_direction: FlexDirection::Column,
|
||||||
flex_grow: 1.0,
|
row_gap: Val::Px(6.0),
|
||||||
flex_direction: FlexDirection::Column,
|
padding: UiRect::px(PANEL_PADDING, PANEL_PADDING, PANEL_PADDING, PANEL_PADDING),
|
||||||
row_gap: Val::Px(6.0),
|
overflow: Overflow::scroll_y(),
|
||||||
padding: UiRect::px(
|
..default()
|
||||||
PANEL_PADDING,
|
},
|
||||||
PANEL_PADDING,
|
ScrollPosition::default(),
|
||||||
PANEL_PADDING,
|
GalaxyScrollContent,
|
||||||
PANEL_PADDING,
|
))
|
||||||
),
|
.with_children(|scroll| {
|
||||||
overflow: Overflow::scroll_y(),
|
spawn_scroll_contents_with_tabs(scroll, ¶ms, selected_disk.0);
|
||||||
..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
|
/// Full scroll content builder that includes the actual tab buttons for the
|
||||||
@@ -954,11 +974,7 @@ fn spawn_scroll_contents_with_tabs(
|
|||||||
} else {
|
} else {
|
||||||
PANEL_BORDER
|
PANEL_BORDER
|
||||||
};
|
};
|
||||||
let text_color = if is_active {
|
let text_color = if is_active { TEXT_BRIGHT } else { TEXT_DIM };
|
||||||
TEXT_BRIGHT
|
|
||||||
} else {
|
|
||||||
TEXT_DIM
|
|
||||||
};
|
|
||||||
row.spawn((
|
row.spawn((
|
||||||
Button,
|
Button,
|
||||||
Node {
|
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 = rng.gen_range(DISK_TILT_MIN..=DISK_TILT_MAX);
|
||||||
disk.tilt = (disk.tilt / DISK_TILT_STEP).round() * DISK_TILT_STEP;
|
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 = rng.gen_range(DISK_ROTATION_MIN..=DISK_ROTATION_MAX);
|
||||||
disk.rotation_offset =
|
disk.rotation_offset = (disk.rotation_offset / DISK_ROTATION_STEP).round() * DISK_ROTATION_STEP;
|
||||||
(disk.rotation_offset / DISK_ROTATION_STEP).round() * DISK_ROTATION_STEP;
|
|
||||||
disk
|
disk
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ pub mod galaxy;
|
|||||||
pub mod movement;
|
pub mod movement;
|
||||||
pub mod physics;
|
pub mod physics;
|
||||||
pub mod star_map;
|
pub mod star_map;
|
||||||
|
pub mod starting_base;
|
||||||
|
|||||||
@@ -18,10 +18,18 @@ pub fn click_to_move(
|
|||||||
if !mouse_input.just_pressed(MouseButton::Left) {
|
if !mouse_input.just_pressed(MouseButton::Left) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Ok(window) = primary_window.single() else { return };
|
let Ok(window) = primary_window.single() else {
|
||||||
let Some(cursor_pos) = window.cursor_position() else { return };
|
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 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).
|
// Intersect ray with the ground plane (y = GROUND_PLANE_Y).
|
||||||
if ray.direction.y.abs() < 1e-6 {
|
if ray.direction.y.abs() < 1e-6 {
|
||||||
@@ -33,6 +41,8 @@ pub fn click_to_move(
|
|||||||
}
|
}
|
||||||
let target = ray.origin + ray.direction * t;
|
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;
|
move_target.0 = target;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,10 +33,7 @@ pub fn steer_to_target(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Apply Velocity to Transform.translation.
|
/// Apply Velocity to Transform.translation.
|
||||||
pub fn integrate_velocity(
|
pub fn integrate_velocity(mut query: Query<(&mut Transform, &Velocity)>, time: Res<Time>) {
|
||||||
mut query: Query<(&mut Transform, &Velocity)>,
|
|
||||||
time: Res<Time>,
|
|
||||||
) {
|
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
for (mut transform, velocity) in &mut query {
|
for (mut transform, velocity) in &mut query {
|
||||||
transform.translation += velocity.0 * dt;
|
transform.translation += velocity.0 * dt;
|
||||||
|
|||||||
@@ -35,10 +35,7 @@ impl Default for Orbit {
|
|||||||
|
|
||||||
/// Advance each Orbit's angle and recompute local translation.
|
/// Advance each Orbit's angle and recompute local translation.
|
||||||
/// The orbit center is the parent entity (via Bevy's parent-child hierarchy).
|
/// The orbit center is the parent entity (via Bevy's parent-child hierarchy).
|
||||||
pub fn update_orbits(
|
pub fn update_orbits(mut query: Query<(&mut Orbit, &mut Transform)>, time: Res<Time>) {
|
||||||
mut query: Query<(&mut Orbit, &mut Transform)>,
|
|
||||||
time: Res<Time>,
|
|
||||||
) {
|
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
for (mut orbit, mut transform) in &mut query {
|
for (mut orbit, mut transform) in &mut query {
|
||||||
orbit.angle += orbit.angular_velocity * dt;
|
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).
|
/// Check if two spheres overlap. Use length-squared for performance (no sqrt).
|
||||||
pub fn overlaps(
|
pub fn overlaps(a_center: Vec3, a_radius: f32, b_center: Vec3, b_radius: f32) -> bool {
|
||||||
a_center: Vec3,
|
|
||||||
a_radius: f32,
|
|
||||||
b_center: Vec3,
|
|
||||||
b_radius: f32,
|
|
||||||
) -> bool {
|
|
||||||
let combined = a_radius + b_radius;
|
let combined = a_radius + b_radius;
|
||||||
(a_center - b_center).length_squared() < combined * combined
|
(a_center - b_center).length_squared() < combined * combined
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the vector to push `a` out of `b` so they no longer overlap.
|
/// 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).
|
/// Returns `None` if they don't overlap, or if their centers coincide (ambiguous direction).
|
||||||
pub fn separate(
|
pub fn separate(a_center: Vec3, a_radius: f32, b_center: Vec3, b_radius: f32) -> Option<Vec3> {
|
||||||
a_center: Vec3,
|
|
||||||
a_radius: f32,
|
|
||||||
b_center: Vec3,
|
|
||||||
b_radius: f32,
|
|
||||||
) -> Option<Vec3> {
|
|
||||||
let delta = a_center - b_center;
|
let delta = a_center - b_center;
|
||||||
let combined = a_radius + b_radius;
|
let combined = a_radius + b_radius;
|
||||||
let dist = delta.length();
|
let dist = delta.length();
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ impl Plugin for StarMapPlugin {
|
|||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.add_systems(OnEnter(AppState::InGame), setup_star_map)
|
app.add_systems(OnEnter(AppState::InGame), setup_star_map)
|
||||||
.add_systems(OnExit(AppState::InGame), despawn_star_map);
|
.add_systems(OnExit(AppState::InGame), despawn_star_map);
|
||||||
// .add_systems(
|
// .add_systems(
|
||||||
// Update,
|
// Update,
|
||||||
// (/* update systems: pan/zoom, hover, click star systems */)
|
// (/* update systems: pan/zoom, hover, click star systems */)
|
||||||
// .run_if(in_state(AppState::InGame)),
|
// .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 camera::orbit_camera_control;
|
||||||
use gameplay::{
|
use gameplay::{
|
||||||
character_creation::CharacterCreationPlugin,
|
character_creation::CharacterCreationPlugin, galaxy::GalaxyPlugin, movement::MovementPlugin,
|
||||||
galaxy::GalaxyPlugin,
|
physics::PhysicsPlugin, star_map::StarMapPlugin, starting_base::StartingBasePlugin,
|
||||||
movement::MovementPlugin,
|
|
||||||
physics::PhysicsPlugin,
|
|
||||||
star_map::StarMapPlugin,
|
|
||||||
};
|
};
|
||||||
use state::AppState;
|
use state::AppState;
|
||||||
use ui::main_menu;
|
use ui::main_menu;
|
||||||
@@ -42,6 +39,7 @@ fn main() {
|
|||||||
GalaxyPlugin,
|
GalaxyPlugin,
|
||||||
StarMapPlugin,
|
StarMapPlugin,
|
||||||
CharacterCreationPlugin,
|
CharacterCreationPlugin,
|
||||||
|
StartingBasePlugin,
|
||||||
))
|
))
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub enum AppState {
|
|||||||
MainMenu,
|
MainMenu,
|
||||||
Galaxy,
|
Galaxy,
|
||||||
CharacterCreation,
|
CharacterCreation,
|
||||||
|
StartingBaseSelection,
|
||||||
InGame,
|
InGame,
|
||||||
Options,
|
Options,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ use bevy::ui::ComputedNode;
|
|||||||
/// Coordinates: Bevy UI layout runs in **physical** pixels, while
|
/// Coordinates: Bevy UI layout runs in **physical** pixels, while
|
||||||
/// [`Window::cursor_position`] returns **logical** pixels. We multiply by the
|
/// [`Window::cursor_position`] returns **logical** pixels. We multiply by the
|
||||||
/// window's scale factor to convert.
|
/// window's scale factor to convert.
|
||||||
pub fn cursor_over_ui(
|
pub fn cursor_over_ui(window: &Window, nodes: &Query<(&ComputedNode, &GlobalTransform)>) -> bool {
|
||||||
window: &Window,
|
|
||||||
nodes: &Query<(&ComputedNode, &GlobalTransform)>,
|
|
||||||
) -> bool {
|
|
||||||
let Some(cursor_logical) = window.cursor_position() else {
|
let Some(cursor_logical) = window.cursor_position() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@@ -32,11 +29,7 @@ pub fn cursor_over_ui(
|
|||||||
let half = node.size() * 0.5;
|
let half = node.size() * 0.5;
|
||||||
let min = center - half;
|
let min = center - half;
|
||||||
let max = center + half;
|
let max = center + half;
|
||||||
if cursor.x >= min.x
|
if cursor.x >= min.x && cursor.x <= max.x && cursor.y >= min.y && cursor.y <= max.y {
|
||||||
&& cursor.x <= max.x
|
|
||||||
&& cursor.y >= min.y
|
|
||||||
&& cursor.y <= max.y
|
|
||||||
{
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user