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:
2026-06-12 23:40:33 -04:00
parent 71c6d18817
commit d139dc08d9
20 changed files with 1789 additions and 310 deletions

View File

@@ -5,8 +5,8 @@
//! spawn / despawn lifecycle, and handles the Escape → Galaxy shortcut.
//!
//! Persistence is not yet wired up — on Confirm, the current draft is logged
//! and the app transitions to [`AppState::InGame`]. The TODO is to persist it
//! to SpacetimeDB once the Rust SDK is integrated.
//! and the app transitions to [`AppState::StartingBaseSelection`]. The TODO is
//! to persist it to SpacetimeDB once the Rust SDK is integrated.
mod params;
mod ui;
@@ -39,6 +39,7 @@ impl Plugin for CharacterCreationPlugin {
Update,
(
escape_to_galaxy,
ui::scroll_character_creation_form,
ui::picker_button_handler,
ui::refresh_picker_values,
ui::action_button_handler,
@@ -78,10 +79,7 @@ fn despawn_character_creation(
// ── Input ───────────────────────────────────────────────────────────────────
fn escape_to_galaxy(
keys: Res<ButtonInput<KeyCode>>,
mut next_state: ResMut<NextState<AppState>>,
) {
fn escape_to_galaxy(keys: Res<ButtonInput<KeyCode>>, mut next_state: ResMut<NextState<AppState>>) {
if keys.just_pressed(KeyCode::Escape) {
next_state.set(AppState::Galaxy);
}

View File

@@ -117,6 +117,217 @@ pub const STARTING_SHIPS: &[StartingShip] = &[
},
];
// ── Bannerlord-style backstory ─────────────────────────────────────────────
pub const BACKSTORY_QUESTION_COUNT: usize = 5;
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
pub struct CharacterStats {
pub command: i32,
pub piloting: i32,
pub gunnery: i32,
pub engineering: i32,
pub trade: i32,
pub scouting: i32,
pub diplomacy: i32,
pub grit: i32,
}
impl CharacterStats {
pub const fn new(
command: i32,
piloting: i32,
gunnery: i32,
engineering: i32,
trade: i32,
scouting: i32,
diplomacy: i32,
grit: i32,
) -> Self {
Self {
command,
piloting,
gunnery,
engineering,
trade,
scouting,
diplomacy,
grit,
}
}
pub fn add(self, other: Self) -> Self {
Self {
command: self.command + other.command,
piloting: self.piloting + other.piloting,
gunnery: self.gunnery + other.gunnery,
engineering: self.engineering + other.engineering,
trade: self.trade + other.trade,
scouting: self.scouting + other.scouting,
diplomacy: self.diplomacy + other.diplomacy,
grit: self.grit + other.grit,
}
}
pub fn summary(self) -> String {
format!(
"Command {} Piloting {} Gunnery {} Engineering {} Trade {} Scouting {} Diplomacy {} Grit {}",
self.command,
self.piloting,
self.gunnery,
self.engineering,
self.trade,
self.scouting,
self.diplomacy,
self.grit,
)
}
}
#[derive(Debug, Clone, Copy)]
pub struct BackstoryChoice {
pub id: &'static str,
pub name: &'static str,
pub blurb: &'static str,
pub stats: CharacterStats,
}
#[derive(Debug, Clone, Copy)]
pub struct BackstoryQuestion {
pub prompt: &'static str,
pub choices: &'static [BackstoryChoice],
}
pub const CHILDHOOD_CHOICES: &[BackstoryChoice] = &[
BackstoryChoice {
id: "dockborn",
name: "Raised on station docks",
blurb:
"You grew up under gantries, learning ships by sound before you ever touched a helm.",
stats: CharacterStats::new(0, 1, 0, 2, 1, 0, 0, 0),
},
BackstoryChoice {
id: "merchant_house",
name: "Merchant house ward",
blurb: "Contract law, polite threats, and price sheets were dinner-table conversation.",
stats: CharacterStats::new(0, 0, 0, 0, 3, 0, 1, 0),
},
BackstoryChoice {
id: "frontier_clan",
name: "Frontier clan child",
blurb:
"Your first lessons were vacuum discipline, ration math, and how to spot trouble early.",
stats: CharacterStats::new(0, 1, 0, 0, 0, 2, 0, 1),
},
];
pub const APPRENTICESHIP_CHOICES: &[BackstoryChoice] = &[
BackstoryChoice {
id: "engine_rat",
name: "Engine-room apprentice",
blurb: "You patched coolant lines with your sleeves rolled up and alarms howling.",
stats: CharacterStats::new(0, 0, 0, 3, 0, 0, 0, 1),
},
BackstoryChoice {
id: "deck_cadet",
name: "Deck cadet",
blurb: "You drilled formation flying, boarding defense, and the art of sounding calm.",
stats: CharacterStats::new(2, 1, 1, 0, 0, 0, 0, 0),
},
BackstoryChoice {
id: "route_scout",
name: "Route scout",
blurb: "You ran courier paths ahead of the main convoy with only sensors for company.",
stats: CharacterStats::new(0, 2, 0, 0, 0, 2, 0, 0),
},
];
pub const FIRST_COMMAND_CHOICES: &[BackstoryChoice] = &[
BackstoryChoice {
id: "convoy_guard",
name: "Convoy guard",
blurb: "Your first command kept traders alive through pirate weather and bad intel.",
stats: CharacterStats::new(1, 0, 2, 0, 1, 0, 0, 0),
},
BackstoryChoice {
id: "salvage_skipper",
name: "Salvage skipper",
blurb: "You made a living from broken hulls, cold wrecks, and systems nobody trusted.",
stats: CharacterStats::new(0, 1, 0, 2, 0, 1, 0, 0),
},
BackstoryChoice {
id: "free_trader",
name: "Free trader",
blurb: "You learned that a clean manifest is useful, and a flexible one is profitable.",
stats: CharacterStats::new(0, 0, 0, 0, 3, 0, 1, 0),
},
];
pub const CRISIS_CHOICES: &[BackstoryChoice] = &[
BackstoryChoice {
id: "mutiny_mediator",
name: "Settled a mutiny",
blurb: "You talked an armed crew down before anyone vented the wrong compartment.",
stats: CharacterStats::new(1, 0, 0, 0, 0, 0, 3, 0),
},
BackstoryChoice {
id: "ambush_survivor",
name: "Survived an ambush",
blurb: "You limped home on backup thrusters and spite, with half your armor gone.",
stats: CharacterStats::new(0, 1, 2, 0, 0, 0, 0, 1),
},
BackstoryChoice {
id: "blackout_fix",
name: "Restored a dark station",
blurb: "When the lights died, you brought power back one breaker at a time.",
stats: CharacterStats::new(0, 0, 0, 3, 0, 0, 0, 1),
},
];
pub const REASON_CHOICES: &[BackstoryChoice] = &[
BackstoryChoice {
id: "debt",
name: "A debt to settle",
blurb: "Someone owns a piece of your future. You intend to buy it back.",
stats: CharacterStats::new(0, 0, 0, 0, 2, 0, 0, 2),
},
BackstoryChoice {
id: "lost_signal",
name: "A lost signal",
blurb: "A voice from the dark keeps appearing in old survey bands. You need answers.",
stats: CharacterStats::new(0, 1, 0, 0, 0, 3, 0, 0),
},
BackstoryChoice {
id: "new_banner",
name: "A banner of your own",
blurb: "You are done serving other captains. The next legend wears your colors.",
stats: CharacterStats::new(3, 0, 1, 0, 0, 0, 0, 0),
},
];
pub const BACKSTORY_QUESTIONS: &[BackstoryQuestion; BACKSTORY_QUESTION_COUNT] = &[
BackstoryQuestion {
prompt: "You were born...",
choices: CHILDHOOD_CHOICES,
},
BackstoryQuestion {
prompt: "As a young adult, you trained as...",
choices: APPRENTICESHIP_CHOICES,
},
BackstoryQuestion {
prompt: "Your first command was...",
choices: FIRST_COMMAND_CHOICES,
},
BackstoryQuestion {
prompt: "You became known when you...",
choices: CRISIS_CHOICES,
},
BackstoryQuestion {
prompt: "You set out because of...",
choices: REASON_CHOICES,
},
];
// ── Draft resource ──────────────────────────────────────────────────────────
/// Editable character draft, mutated by the picker UI and read at confirm time.
@@ -130,6 +341,7 @@ pub struct CharacterDraft {
pub name_index: usize,
pub origin_index: usize,
pub ship_index: usize,
pub backstory_indices: [usize; BACKSTORY_QUESTION_COUNT],
}
impl CharacterDraft {
@@ -145,6 +357,20 @@ impl CharacterDraft {
&STARTING_SHIPS[self.ship_index % STARTING_SHIPS.len()]
}
pub fn backstory_choice(&self, question_index: usize) -> &'static BackstoryChoice {
let question = &BACKSTORY_QUESTIONS[question_index % BACKSTORY_QUESTIONS.len()];
let choice_index = self.backstory_indices[question_index] % question.choices.len();
&question.choices[choice_index]
}
pub fn stats(&self) -> CharacterStats {
let mut stats = CharacterStats::default();
for question_index in 0..BACKSTORY_QUESTION_COUNT {
stats = stats.add(self.backstory_choice(question_index).stats);
}
stats
}
/// Advance `field` by `delta`, wrapping around the length of `options`.
/// Generic over the picker so the same code handles name / origin / ship.
pub fn cycle(field: usize, delta: i32, options: usize) -> usize {
@@ -165,6 +391,15 @@ impl CharacterDraft {
pub fn cycle_ship(&mut self, delta: i32) {
self.ship_index = Self::cycle(self.ship_index, delta, STARTING_SHIPS.len());
}
pub fn cycle_backstory(&mut self, question_index: usize, delta: i32) {
let question = &BACKSTORY_QUESTIONS[question_index % BACKSTORY_QUESTIONS.len()];
self.backstory_indices[question_index] = Self::cycle(
self.backstory_indices[question_index],
delta,
question.choices.len(),
);
}
}
// ── Tests ───────────────────────────────────────────────────────────────────
@@ -214,9 +449,19 @@ mod tests {
name_index: 2,
origin_index: 1,
ship_index: 0,
backstory_indices: [0; BACKSTORY_QUESTION_COUNT],
};
assert_eq!(d.name(), CHARACTER_NAMES[2]);
assert_eq!(d.origin().id, ORIGINS[1].id);
assert_eq!(d.ship().id, STARTING_SHIPS[0].id);
}
#[test]
fn backstory_choices_wrap_and_grant_stats() {
let mut d = CharacterDraft::default();
d.cycle_backstory(0, -1);
assert_eq!(d.backstory_choice(0).id, "frontier_clan");
assert_eq!(d.stats().scouting, 2);
assert_eq!(d.stats().grit, 4);
}
}

View File

@@ -1,5 +1,5 @@
//! Character creation UI: a centered single-column layout with three pickers
//! (name / origin / starting ship) and Confirm / Back buttons.
//! Character creation UI: a sticky header, scrollable form, and sticky
//! Confirm / Back buttons.
//!
//! Each picker is a row of `[<] label value [>]`. The `[<]` and `[>]`
//! buttons carry a [`PickerButton`] marker identifying which field to cycle
@@ -11,11 +11,12 @@
//! ([`crate::gameplay::galaxy::ui`]): dark navy panels, cyan borders, dim
//! labels, bright values.
use bevy::prelude::*;
use bevy::{input::mouse::MouseWheel, prelude::*, ui::ScrollPosition, window::PrimaryWindow};
use super::{CharacterCreationSpawned, CharacterCreationButton};
use super::{CharacterCreationButton, CharacterCreationSpawned};
use crate::gameplay::character_creation::params::{
CharacterDraft, ORIGINS, STARTING_SHIPS, CHARACTER_NAMES,
CharacterDraft, BACKSTORY_QUESTIONS, BACKSTORY_QUESTION_COUNT, CHARACTER_NAMES, ORIGINS,
STARTING_SHIPS,
};
// ── Styling constants ───────────────────────────────────────────────────────
@@ -39,8 +40,8 @@ const BLURB_FONT_SIZE: f32 = 13.0;
const BUTTON_FONT_SIZE: f32 = 18.0;
const ARROW_FONT_SIZE: f32 = 20.0;
const PICKER_WIDTH: f32 = 520.0;
const ARROW_BUTTON: f32 = 36.0;
const CARD_WIDTH: f32 = 760.0;
// ── Markers ─────────────────────────────────────────────────────────────────
@@ -51,6 +52,7 @@ const ARROW_BUTTON: f32 = 36.0;
pub enum PickerButton {
Name(i32),
Origin(i32),
Backstory(usize, i32),
Ship(i32),
}
@@ -75,6 +77,18 @@ pub struct ShipBlurb;
#[derive(Component)]
pub struct OriginBonus;
#[derive(Component)]
pub struct BackstoryValue(pub usize);
#[derive(Component)]
pub struct StatsSummary;
#[derive(Component)]
pub struct CharacterCreationScrollViewport;
#[derive(Component)]
pub struct CharacterCreationScrollContent;
// ── Setup ───────────────────────────────────────────────────────────────────
pub fn setup_character_creation_ui(mut commands: Commands) {
@@ -85,9 +99,8 @@ pub fn setup_character_creation_ui(mut commands: Commands) {
height: Val::Percent(100.0),
display: Display::Flex,
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
justify_content: JustifyContent::FlexStart,
align_items: AlignItems::Center,
row_gap: Val::Px(18.0),
padding: UiRect::all(Val::Px(24.0)),
..default()
},
@@ -105,121 +118,168 @@ pub fn setup_character_creation_ui(mut commands: Commands) {
TextColor(TEXT_BRIGHT),
));
root.spawn((
Text::new("Pick a name, an origin, and a ship. Confirm to begin."),
Text::new(
"Pick a name, trace your backstory, choose a ship, and confirm to begin.",
),
TextFont {
font_size: SUBTITLE_FONT_SIZE,
..default()
},
TextColor(TEXT_DIM),
Node {
margin: UiRect::bottom(Val::Px(8.0)),
margin: UiRect::bottom(Val::Px(12.0)),
..default()
},
));
// ── Main card ────────────────────────────────────────────────
// ── Scrollable form ─────────────────────────────────────────
root.spawn((
Node {
width: Val::Px(PICKER_WIDTH + 64.0),
padding: UiRect::all(Val::Px(24.0)),
width: Val::Percent(100.0),
flex_grow: 1.0,
flex_direction: FlexDirection::Column,
row_gap: Val::Px(18.0),
border: UiRect::all(Val::Px(1.0)),
align_items: AlignItems::Center,
overflow: Overflow::scroll_y(),
padding: UiRect::vertical(Val::Px(4.0)),
..default()
},
BackgroundColor(PANEL_BG),
BorderColor(PANEL_BORDER),
BorderRadius::all(Val::Px(10.0)),
ScrollPosition::default(),
CharacterCreationScrollViewport,
CharacterCreationScrollContent,
))
.with_children(|card| {
spawn_section_label(card, "NAME");
spawn_picker_row(card, "Callsign", PickerButton::Name(-1), PickerButton::Name(1));
spawn_spacer(card);
.with_children(|scroll| {
scroll
.spawn((
Node {
width: Val::Percent(100.0),
max_width: Val::Px(CARD_WIDTH),
padding: UiRect::all(Val::Px(20.0)),
flex_direction: FlexDirection::Column,
row_gap: Val::Px(12.0),
border: UiRect::all(Val::Px(1.0)),
..default()
},
BackgroundColor(PANEL_BG),
BorderColor(PANEL_BORDER),
BorderRadius::all(Val::Px(10.0)),
))
.with_children(|card| {
spawn_section_label(card, "NAME");
spawn_picker_row(
card,
"Callsign",
PickerButton::Name(-1),
PickerButton::Name(1),
);
spawn_spacer(card);
spawn_section_label(card, "ORIGIN");
spawn_picker_row_with_marker(
card,
"Background",
PickerButton::Origin(-1),
PickerButton::Origin(1),
OriginValue,
);
card.spawn((
Text::new(""),
TextFont {
font_size: BLURB_FONT_SIZE,
..default()
},
TextColor(TEXT_DIM),
Node {
width: Val::Percent(100.0),
..default()
},
OriginBlurb,
));
card.spawn((
Text::new(""),
TextFont {
font_size: BLURB_FONT_SIZE,
..default()
},
TextColor(ACCENT),
Node {
width: Val::Percent(100.0),
margin: UiRect::top(Val::Px(2.0)),
..default()
},
OriginBonus,
));
spawn_spacer(card);
spawn_section_label(card, "ORIGIN");
spawn_picker_row_with_marker(
card,
"Background",
PickerButton::Origin(-1),
PickerButton::Origin(1),
OriginValue,
);
card.spawn((
Text::new(""),
TextFont {
font_size: BLURB_FONT_SIZE,
..default()
},
TextColor(TEXT_DIM),
Node {
width: Val::Percent(100.0),
..default()
},
OriginBlurb,
));
card.spawn((
Text::new(""),
TextFont {
font_size: BLURB_FONT_SIZE,
..default()
},
TextColor(ACCENT),
Node {
width: Val::Percent(100.0),
margin: UiRect::top(Val::Px(2.0)),
..default()
},
OriginBonus,
));
spawn_spacer(card);
spawn_section_label(card, "STARTING SHIP");
spawn_picker_row_with_marker(
card,
"Hull",
PickerButton::Ship(-1),
PickerButton::Ship(1),
ShipValue,
);
card.spawn((
Text::new(""),
TextFont {
font_size: BLURB_FONT_SIZE,
..default()
},
TextColor(TEXT_DIM),
Node {
width: Val::Percent(100.0),
..default()
},
ShipBlurb,
));
spawn_section_label(card, "BACKSTORY");
spawn_backstory_rows(card);
card.spawn((
Text::new(""),
TextFont {
font_size: BLURB_FONT_SIZE,
..default()
},
TextColor(ACCENT),
Node {
width: Val::Percent(100.0),
margin: UiRect::top(Val::Px(2.0)),
..default()
},
StatsSummary,
));
spawn_spacer(card);
spawn_section_label(card, "STARTING SHIP");
spawn_picker_row_with_marker(
card,
"Hull",
PickerButton::Ship(-1),
PickerButton::Ship(1),
ShipValue,
);
card.spawn((
Text::new(""),
TextFont {
font_size: BLURB_FONT_SIZE,
..default()
},
TextColor(TEXT_DIM),
Node {
width: Val::Percent(100.0),
..default()
},
ShipBlurb,
));
});
});
// ── Button row ──────────────────────────────────────────────
root
.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: Val::Px(16.0),
margin: UiRect::top(Val::Px(4.0)),
..default()
})
.with_children(|row| {
spawn_action_button(
row,
"Confirm",
BUTTON_BG_CONFIRM,
BUTTON_BORDER_CONFIRM,
CharacterCreationButton::Confirm,
);
spawn_action_button(
row,
"Back",
BUTTON_BG,
PANEL_BORDER,
CharacterCreationButton::Back,
);
});
root.spawn(Node {
width: Val::Percent(100.0),
max_width: Val::Px(CARD_WIDTH),
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
justify_content: JustifyContent::Center,
column_gap: Val::Px(16.0),
row_gap: Val::Px(8.0),
margin: UiRect::top(Val::Px(12.0)),
..default()
})
.with_children(|row| {
spawn_action_button(
row,
"Confirm",
BUTTON_BG_CONFIRM,
BUTTON_BORDER_CONFIRM,
CharacterCreationButton::Confirm,
);
spawn_action_button(
row,
"Back",
BUTTON_BG,
PANEL_BORDER,
CharacterCreationButton::Back,
);
});
// ── Footer hint ─────────────────────────────────────────────
root.spawn((
@@ -233,6 +293,51 @@ pub fn setup_character_creation_ui(mut commands: Commands) {
});
}
// ── Scroll input ───────────────────────────────────────────────────────────
/// Route mouse-wheel events to the form's scroll container when the cursor is
/// over it. Bevy 0.16 does not wire `MouseWheel` to `ScrollPosition`
/// automatically, so this mirrors the galaxy panel bridge.
pub fn scroll_character_creation_form(
mut scroll_events: EventReader<MouseWheel>,
primary_window: Query<&Window, With<PrimaryWindow>>,
viewport_nodes: Query<(&ComputedNode, &GlobalTransform), With<CharacterCreationScrollViewport>>,
mut scroll_content: Query<&mut ScrollPosition, With<CharacterCreationScrollContent>>,
) {
let Ok(window) = primary_window.single() else {
scroll_events.clear();
return;
};
let Some(cursor_pos) = window.cursor_position() else {
scroll_events.clear();
return;
};
let over_viewport = viewport_nodes.iter().any(|(node, transform)| {
let pos = transform.translation().truncate();
let size = node.size();
let min = pos - size * 0.5;
let max = pos + size * 0.5;
cursor_pos.x >= min.x
&& cursor_pos.x <= max.x
&& cursor_pos.y >= min.y
&& cursor_pos.y <= max.y
});
if !over_viewport {
return;
}
let Ok(mut scroll_pos) = scroll_content.single_mut() else {
return;
};
for event in scroll_events.read() {
scroll_pos.offset_y -= event.y * 20.0;
}
}
// ── Sub-spawners ────────────────────────────────────────────────────────────
fn spawn_section_label(parent: &mut ChildSpawnerCommands, label: &str) {
@@ -320,6 +425,18 @@ fn spawn_picker_row_with_marker<M: Component>(
});
}
fn spawn_backstory_rows(parent: &mut ChildSpawnerCommands) {
for (question_index, question) in BACKSTORY_QUESTIONS.iter().enumerate() {
spawn_picker_row_with_marker(
parent,
question.prompt,
PickerButton::Backstory(question_index, -1),
PickerButton::Backstory(question_index, 1),
BackstoryValue(question_index),
);
}
}
fn spawn_arrow_button(parent: &mut ChildSpawnerCommands, glyph: &str, marker: PickerButton) {
parent
.spawn((
@@ -396,42 +513,16 @@ fn spawn_action_button(
#[allow(clippy::type_complexity)]
pub fn refresh_picker_values(
draft: Res<CharacterDraft>,
mut name_q: Query<&mut Text, With<NameValue>>,
mut origin_q: Query<&mut Text, (With<OriginValue>, Without<NameValue>, Without<ShipValue>)>,
mut ship_q: Query<&mut Text, (With<ShipValue>, Without<NameValue>, Without<OriginValue>)>,
mut origin_blurb_q: Query<
&mut Text,
(
With<OriginBlurb>,
Without<NameValue>,
Without<OriginValue>,
Without<ShipValue>,
Without<OriginBonus>,
Without<ShipBlurb>,
),
>,
mut origin_bonus_q: Query<
&mut Text,
(
With<OriginBonus>,
Without<NameValue>,
Without<OriginValue>,
Without<ShipValue>,
Without<OriginBlurb>,
Without<ShipBlurb>,
),
>,
mut ship_blurb_q: Query<
&mut Text,
(
With<ShipBlurb>,
Without<NameValue>,
Without<OriginValue>,
Without<ShipValue>,
Without<OriginBlurb>,
Without<OriginBonus>,
),
>,
mut text_queries: ParamSet<(
Query<&mut Text, With<NameValue>>,
Query<&mut Text, With<OriginValue>>,
Query<&mut Text, With<OriginBlurb>>,
Query<&mut Text, With<OriginBonus>>,
Query<(&BackstoryValue, &mut Text)>,
Query<&mut Text, With<StatsSummary>>,
Query<&mut Text, With<ShipValue>>,
Query<&mut Text, With<ShipBlurb>>,
)>,
) {
// The draft only changes when a picker button is pressed; if the resource
// hasn't been mutated, we can skip the whole pass.
@@ -439,39 +530,77 @@ pub fn refresh_picker_values(
return;
}
if let Ok(mut txt) = name_q.single_mut() {
if txt.0 != draft.name() {
txt.0 = draft.name().to_string();
{
let mut name_q = text_queries.p0();
if let Ok(mut txt) = name_q.single_mut() {
if txt.0 != draft.name() {
txt.0 = draft.name().to_string();
}
}
}
let origin = draft.origin();
if let Ok(mut txt) = origin_q.single_mut() {
if txt.0 != origin.name {
txt.0 = origin.name.to_string();
{
let mut origin_q = text_queries.p1();
if let Ok(mut txt) = origin_q.single_mut() {
if txt.0 != origin.name {
txt.0 = origin.name.to_string();
}
}
}
if let Ok(mut txt) = origin_blurb_q.single_mut() {
if txt.0 != origin.blurb {
txt.0 = origin.blurb.to_string();
{
let mut origin_blurb_q = text_queries.p2();
if let Ok(mut txt) = origin_blurb_q.single_mut() {
if txt.0 != origin.blurb {
txt.0 = origin.blurb.to_string();
}
}
}
if let Ok(mut txt) = origin_bonus_q.single_mut() {
if txt.0 != origin.bonus {
txt.0 = origin.bonus.to_string();
{
let mut origin_bonus_q = text_queries.p3();
if let Ok(mut txt) = origin_bonus_q.single_mut() {
if txt.0 != origin.bonus {
txt.0 = origin.bonus.to_string();
}
}
}
{
let mut backstory_q = text_queries.p4();
for (marker, mut txt) in &mut backstory_q {
let choice = draft.backstory_choice(marker.0);
if txt.0 != choice.name {
txt.0 = choice.name.to_string();
}
}
}
{
let mut stats_q = text_queries.p5();
if let Ok(mut txt) = stats_q.single_mut() {
let summary = draft.stats().summary();
if txt.0 != summary {
txt.0 = summary;
}
}
}
let ship = draft.ship();
if let Ok(mut txt) = ship_q.single_mut() {
let label = format!("{} · {}", ship.name, ship.role);
if txt.0 != label {
txt.0 = label;
{
let mut ship_q = text_queries.p6();
if let Ok(mut txt) = ship_q.single_mut() {
let label = format!("{} · {}", ship.name, ship.role);
if txt.0 != label {
txt.0 = label;
}
}
}
if let Ok(mut txt) = ship_blurb_q.single_mut() {
if txt.0 != ship.blurb {
txt.0 = ship.blurb.to_string();
{
let mut ship_blurb_q = text_queries.p7();
if let Ok(mut txt) = ship_blurb_q.single_mut() {
if txt.0 != ship.blurb {
txt.0 = ship.blurb.to_string();
}
}
}
}
@@ -492,12 +621,15 @@ pub fn picker_button_handler(
match *button {
PickerButton::Name(delta) => draft.cycle_name(delta),
PickerButton::Origin(delta) => draft.cycle_origin(delta),
PickerButton::Backstory(question_index, delta) => {
draft.cycle_backstory(question_index, delta);
}
PickerButton::Ship(delta) => draft.cycle_ship(delta),
}
}
}
/// Confirm / Back buttons. Confirm transitions to `AppState::InGame`; Back
/// Confirm / Back buttons. Confirm advances to starting base selection; Back
/// returns to `AppState::Galaxy`. Kept in a single system since neither needs
/// extra resources — the draft is persisted later by the (yet-to-be-written)
/// save pipeline.
@@ -514,12 +646,14 @@ pub fn action_button_handler(
CharacterCreationButton::Confirm => {
// TODO: persist `*draft` to SpacetimeDB before entering the game.
bevy::log::info!(
"Confirming character: name={}, origin={}, ship={}",
"Confirming character: name={}, origin={}, ship={}, backstory={}, stats={}",
draft.name(),
draft.origin().id,
draft.ship().id,
format_backstory_log(&draft),
draft.stats().summary(),
);
next_state.set(crate::state::AppState::InGame);
next_state.set(crate::state::AppState::StartingBaseSelection);
}
CharacterCreationButton::Back => {
next_state.set(crate::state::AppState::Galaxy);
@@ -528,6 +662,15 @@ pub fn action_button_handler(
}
}
fn format_backstory_log(draft: &CharacterDraft) -> String {
let mut entries = Vec::with_capacity(BACKSTORY_QUESTION_COUNT);
for question_index in 0..BACKSTORY_QUESTION_COUNT {
let choice = draft.backstory_choice(question_index);
entries.push(format!("{}: {}", choice.id, choice.blurb));
}
entries.join(" | ")
}
// ── Sanity check: name/origin/ship counts must agree ───────────────────────
//
// These constants are here so a quick grep tells future-you where the UI's
@@ -543,4 +686,5 @@ const _PRESET_NONEMPTY: () = {
assert!(!CHARACTER_NAMES.is_empty());
assert!(!ORIGINS.is_empty());
assert!(!STARTING_SHIPS.is_empty());
assert!(BACKSTORY_QUESTIONS.len() == BACKSTORY_QUESTION_COUNT);
};

View File

@@ -661,11 +661,11 @@ fn system_hash(s: &str) -> u64 {
/// same trick. Asteroid rocks share a single jittered icosphere mesh.
pub struct ContentAssets {
// Meshes
pub planet_mesh: Handle<Mesh>, // unit sphere
pub station_mesh: Handle<Mesh>, // unit cube
pub anomaly_mesh: Handle<Mesh>, // low-poly sphere (angular/crystalline)
pub gas_mesh: Handle<Mesh>, // unit sphere (used with translucent mat)
pub stargate_mesh: Handle<Mesh>, // unit torus
pub planet_mesh: Handle<Mesh>, // unit sphere
pub station_mesh: Handle<Mesh>, // unit cube
pub anomaly_mesh: Handle<Mesh>, // low-poly sphere (angular/crystalline)
pub gas_mesh: Handle<Mesh>, // unit sphere (used with translucent mat)
pub stargate_mesh: Handle<Mesh>, // unit torus
/// Jittered icosphere shared by every asteroid instance across all belts.
/// Per-rock variation comes from per-instance Transform + material pick.
pub asteroid_mesh: Handle<Mesh>,
@@ -820,8 +820,7 @@ pub fn spawn_system_contents(
// (mild — linear formula, not Kepler). Y-jitter from
// `rock.position.y` is preserved because the orbital
// system only overwrites x/z.
let rock_radius =
Vec2::new(rock.position.x, rock.position.z).length();
let rock_radius = Vec2::new(rock.position.x, rock.position.z).length();
let rock_phase = rock.position.z.atan2(rock.position.x);
belt_parent.spawn((
Orbital {

View File

@@ -23,9 +23,9 @@ use crate::camera::apply_orbit_reset;
use crate::state::AppState;
pub use contents::{SystemContents, SystemContext, SystemSummary};
pub use params::{GalaxyParams, SelectedStar};
pub use params::{BeamParams, CoreParams, DiskParams};
use params::{NEAREST_NEIGHBOR_CONNECTIONS, SPACING_ATTEMPTS, MIN_SYSTEM_SPACING};
pub use params::{GalaxyParams, SelectedStar};
use params::{MIN_SYSTEM_SPACING, NEAREST_NEIGHBOR_CONNECTIONS, SPACING_ATTEMPTS};
pub struct GalaxyPlugin;
@@ -37,10 +37,7 @@ impl Plugin for GalaxyPlugin {
OnEnter(AppState::Galaxy),
(setup_galaxy_scene, ui::setup_galaxy_ui),
)
.add_systems(
OnExit(AppState::Galaxy),
(despawn_galaxy, reset_selection),
)
.add_systems(OnExit(AppState::Galaxy), (despawn_galaxy, reset_selection))
.add_systems(
Update,
(
@@ -114,6 +111,33 @@ const FACTIONS: &[(&str, [f32; 3])] = &[
("Caldari", [0.22, 0.74, 0.97]), // blue
];
const OUTER_STARTING_SYSTEM_RADIUS: f32 = 0.65;
#[derive(Debug, Clone)]
pub struct StartingBaseCandidate {
pub id: String,
pub name: String,
pub faction: &'static str,
pub security: f32,
pub distance_from_core: f32,
pub contents: SystemContents,
}
#[derive(Debug, Clone)]
pub struct StartingBaseMapSystem {
pub id: String,
pub position: Vec3,
pub color: [f32; 3],
pub candidate_index: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct StartingBaseMap {
pub systems: Vec<StartingBaseMapSystem>,
pub candidates: Vec<StartingBaseCandidate>,
pub connections: Vec<(usize, usize)>,
}
// ── Setup ───────────────────────────────────────────────────────────────────
fn setup_galaxy_scene(
@@ -182,6 +206,56 @@ fn generate_galaxy(
(systems, contents, connections)
}
pub fn generate_starting_base_map(params: &GalaxyParams) -> StartingBaseMap {
let (systems, contents, connections) = generate_galaxy(params);
let outer_radius = params.size * OUTER_STARTING_SYSTEM_RADIUS;
let mut candidates = Vec::new();
let mut map_systems = Vec::with_capacity(systems.len());
for (system, contents) in systems.into_iter().zip(contents) {
let distance_from_core = system.position.length();
if !system.is_core && distance_from_core >= outer_radius {
candidates.push(StartingBaseCandidate {
id: system.id.clone(),
name: system.name.clone(),
faction: system.faction,
security: system.security,
distance_from_core,
contents,
});
}
map_systems.push(StartingBaseMapSystem {
id: system.id,
position: system.position,
color: system.color,
candidate_index: None,
});
}
candidates.sort_by(|a, b| {
b.distance_from_core
.partial_cmp(&a.distance_from_core)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.name.cmp(&b.name))
});
for (index, candidate) in candidates.iter().enumerate() {
if let Some(system) = map_systems
.iter_mut()
.find(|system| system.id == candidate.id)
{
system.candidate_index = Some(index);
}
}
StartingBaseMap {
systems: map_systems,
candidates,
connections,
}
}
/// Position-only galaxy generation. Composes multiple structural layers —
/// a [`CoreParams`] cluster, up to [`MAX_DISKS`] independent [`DiskParams`]
/// spiral disks, and two [`BeamParams`] columns along ±Y — into a single
@@ -196,10 +270,7 @@ fn generate_system_positions(params: &GalaxyParams, rng: &mut StdRng) -> Vec<Gen
// Total budget — pre-allocation only; each pass appends as many systems
// as it manages to place.
let disk_total: usize = params.disks.iter().map(|d| d.count).sum();
let total = params.core.count
+ disk_total
+ params.beam_top.count
+ params.beam_bottom.count;
let total = params.core.count + disk_total + params.beam_top.count + params.beam_bottom.count;
let mut systems: Vec<GeneratedSystem> = Vec::with_capacity(total);
// Spacing scales with overall density: tighter for crowded galaxies,
@@ -210,7 +281,14 @@ fn generate_system_positions(params: &GalaxyParams, rng: &mut StdRng) -> Vec<Gen
// Counter shared across passes for `g-{n}` IDs and name suffixes.
let mut next_index = 0usize;
generate_core(&mut systems, rng, &params.core, params.size, base_spacing, &mut next_index);
generate_core(
&mut systems,
rng,
&params.core,
params.size,
base_spacing,
&mut next_index,
);
for (disk_index, disk) in params.disks.iter().enumerate() {
generate_disk(
@@ -340,8 +418,7 @@ fn generate_disk(
// Non-core disk factions cycle through Amarr/Minmatar/Gallente/Caldari.
// Offset by disk_index so different disks don't all share the same
// faction assignment pattern.
let faction_index =
1 + ((arm as usize + disk_index) % (FACTIONS.len() - 1));
let faction_index = 1 + ((arm as usize + disk_index) % (FACTIONS.len() - 1));
let (faction, color) = FACTIONS[faction_index];
let mut position = Option::<Vec3>::None;
@@ -531,7 +608,9 @@ enum SystemOrigin {
/// Which arm within the disk.
arm: u32,
},
Beam { side: BeamTag },
Beam {
side: BeamTag,
},
}
#[allow(dead_code)]
@@ -611,7 +690,13 @@ fn spawn_galaxy_scene(
// Parent group so all galaxy contents despawn together.
commands
.spawn((Transform::default(), GalaxyScene, GalaxySpawned))
.spawn((
Transform::default(),
Visibility::default(),
InheritedVisibility::default(),
GalaxyScene,
GalaxySpawned,
))
.with_children(|parent| {
// XYZ reference axes through the origin.
axes::spawn_axes(parent, meshes, materials);
@@ -639,6 +724,8 @@ fn spawn_galaxy_scene(
parent
.spawn((
Transform::from_translation(sys.position),
Visibility::default(),
InheritedVisibility::default(),
StarSystem {
id: sys.id.clone(),
name: sys.name.clone(),
@@ -733,10 +820,7 @@ fn build_connections(systems: &[GeneratedSystem]) -> Vec<(usize, usize)> {
// ── Lifecycle systems ───────────────────────────────────────────────────────
fn despawn_galaxy(
mut commands: Commands,
query: Query<Entity, With<GalaxySpawned>>,
) {
fn despawn_galaxy(mut commands: Commands, query: Query<Entity, With<GalaxySpawned>>) {
for entity in &query {
// Bevy 0.16: despawn() is recursive by default.
commands.entity(entity).despawn();

View File

@@ -41,10 +41,7 @@ use super::poi::Orbital;
/// Advance every [`Orbital`] along its path.
///
/// See module docs for the math and the hierarchy assumption.
pub fn advance_orbital_paths(
time: Res<Time>,
mut orbitals: Query<(&Orbital, &mut Transform)>,
) {
pub fn advance_orbital_paths(time: Res<Time>, mut orbitals: Query<(&Orbital, &mut Transform)>) {
let t = time.elapsed_secs();
for (orbital, mut transform) in &mut orbitals {
if orbital.period <= 0.0 {
@@ -79,7 +76,11 @@ pub fn orbital_position(semi_major_axis: f32, phase: f32, period: f32, t: f32) -
"orbital_position requires period > 0; static POIs should be handled by the caller"
);
let angle = phase + (std::f32::consts::TAU / period) * t;
Vec3::new(angle.cos() * semi_major_axis, 0.0, angle.sin() * semi_major_axis)
Vec3::new(
angle.cos() * semi_major_axis,
0.0,
angle.sin() * semi_major_axis,
)
}
// ─── Tests ──────────────────────────────────────────────────────────────────

View File

@@ -66,7 +66,7 @@ pub const DISK_COUNT_MAX: usize = 220;
pub const DISK_COUNT_STEP: usize = 4;
pub const DISK_ARMS_MIN: u32 = 1;
pub const DISK_ARMS_MAX: u32 = 6;
pub const DISK_TWIST_MIN: f32 = 4.0; // ~0.6 turns — bare minimum curvature
pub const DISK_TWIST_MIN: f32 = 4.0; // ~0.6 turns — bare minimum curvature
pub const DISK_TWIST_MAX: f32 = 18.0; // ~2.9 turns — tight logarithmic spiral
pub const DISK_TWIST_STEP: f32 = 0.4;

View File

@@ -21,6 +21,7 @@ use bevy::{input::mouse::MouseWheel, prelude::*, ui::ScrollPosition, window::Pri
use super::GalaxySpawned;
use crate::gameplay::galaxy::params::*;
use crate::state::AppState;
// ── Markers ─────────────────────────────────────────────────────────────────
@@ -91,6 +92,8 @@ pub enum ParamButton {
Regenerate,
/// Reset orbit camera to default orientation.
CenterView,
/// Accept the current galaxy and continue to character creation.
CreateGalaxy,
}
/// Resource tracking which disk layer is currently selected in the tab bar.
@@ -201,6 +204,33 @@ fn spawn_control_panel(commands: &mut Commands, params: &GalaxyParams) {
));
});
parent
.spawn((
Button,
Node {
height: Val::Px(36.0),
margin: UiRect::px(PANEL_PADDING, PANEL_PADDING, 4.0, PANEL_PADDING),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(Val::Px(1.0)),
..default()
},
BackgroundColor(Color::srgb(0.10, 0.28, 0.22)),
BorderColor(Color::srgb(0.30, 0.72, 0.45)),
BorderRadius::all(Val::Px(6.0)),
ParamButton::CreateGalaxy,
))
.with_children(|btn| {
btn.spawn((
Text::new("Create Galaxy"),
TextFont {
font_size: BUTTON_FONT_SIZE,
..default()
},
TextColor(TEXT_BRIGHT),
));
});
// Scrollable content area.
parent
.spawn((
@@ -375,12 +405,7 @@ fn spawn_icon_button(parent: &mut ChildSpawnerCommands, label: &str, marker: Par
parent_button(parent, marker, label, 26.0);
}
fn parent_button(
parent: &mut ChildSpawnerCommands,
marker: ParamButton,
label: &str,
width: f32,
) {
fn parent_button(parent: &mut ChildSpawnerCommands, marker: ParamButton, label: &str, width: f32) {
parent
.spawn((
Button,
@@ -461,6 +486,7 @@ pub fn param_button_handler(
mut commands: Commands,
mut params: ResMut<GalaxyParams>,
mut selected_disk: ResMut<SelectedDisk>,
mut next_state: ResMut<NextState<AppState>>,
query: Query<(&Interaction, &ParamButton), Changed<Interaction>>,
) {
for (interaction, button) in &query {
@@ -499,13 +525,11 @@ pub fn param_button_handler(
params.bump_generation();
}
ParamButton::CoreRadiusDecr => {
params.core.radius =
(params.core.radius - CORE_RADIUS_STEP).max(CORE_RADIUS_MIN);
params.core.radius = (params.core.radius - CORE_RADIUS_STEP).max(CORE_RADIUS_MIN);
params.bump_generation();
}
ParamButton::CoreRadiusIncr => {
params.core.radius =
(params.core.radius + CORE_RADIUS_STEP).min(CORE_RADIUS_MAX);
params.core.radius = (params.core.radius + CORE_RADIUS_STEP).min(CORE_RADIUS_MAX);
params.bump_generation();
}
// ── Disk tab management ───────────────────────────────────────
@@ -531,7 +555,10 @@ pub fn param_button_handler(
// ── Disk params (target selected_disk) ────────────────────────
ParamButton::DiskCountDecr => {
let disk = selected_disk_mut(&mut params, selected_disk.0);
disk.count = disk.count.saturating_sub(DISK_COUNT_STEP).max(DISK_COUNT_MIN);
disk.count = disk
.count
.saturating_sub(DISK_COUNT_STEP)
.max(DISK_COUNT_MIN);
params.bump_generation();
}
ParamButton::DiskCountIncr => {
@@ -571,14 +598,14 @@ pub fn param_button_handler(
}
ParamButton::DiskRotationDecr => {
let disk = selected_disk_mut(&mut params, selected_disk.0);
disk.rotation_offset = (disk.rotation_offset - DISK_ROTATION_STEP)
.max(DISK_ROTATION_MIN);
disk.rotation_offset =
(disk.rotation_offset - DISK_ROTATION_STEP).max(DISK_ROTATION_MIN);
params.bump_generation();
}
ParamButton::DiskRotationIncr => {
let disk = selected_disk_mut(&mut params, selected_disk.0);
disk.rotation_offset = (disk.rotation_offset + DISK_ROTATION_STEP)
.min(DISK_ROTATION_MAX);
disk.rotation_offset =
(disk.rotation_offset + DISK_ROTATION_STEP).min(DISK_ROTATION_MAX);
params.bump_generation();
}
@@ -646,15 +673,13 @@ pub fn param_button_handler(
params.bump_generation();
}
ParamButton::BeamBottomThicknessDecr => {
params.beam_bottom.thickness = (params.beam_bottom.thickness
- BEAM_THICKNESS_STEP)
.max(BEAM_THICKNESS_MIN);
params.beam_bottom.thickness =
(params.beam_bottom.thickness - BEAM_THICKNESS_STEP).max(BEAM_THICKNESS_MIN);
params.bump_generation();
}
ParamButton::BeamBottomThicknessIncr => {
params.beam_bottom.thickness = (params.beam_bottom.thickness
+ BEAM_THICKNESS_STEP)
.min(BEAM_THICKNESS_MAX);
params.beam_bottom.thickness =
(params.beam_bottom.thickness + BEAM_THICKNESS_STEP).min(BEAM_THICKNESS_MAX);
params.bump_generation();
}
ParamButton::BeamBottomLengthDecr => {
@@ -691,15 +716,13 @@ pub fn param_button_handler(
params.bump_generation();
}
ParamButton::BeamBottomGravityDecr => {
params.beam_bottom.gravity = (params.beam_bottom.gravity
- BEAM_GRAVITY_STEP)
.max(BEAM_GRAVITY_MIN);
params.beam_bottom.gravity =
(params.beam_bottom.gravity - BEAM_GRAVITY_STEP).max(BEAM_GRAVITY_MIN);
params.bump_generation();
}
ParamButton::BeamBottomGravityIncr => {
params.beam_bottom.gravity = (params.beam_bottom.gravity
+ BEAM_GRAVITY_STEP)
.min(BEAM_GRAVITY_MAX);
params.beam_bottom.gravity =
(params.beam_bottom.gravity + BEAM_GRAVITY_STEP).min(BEAM_GRAVITY_MAX);
params.bump_generation();
}
ParamButton::RandomSettings => {
@@ -728,6 +751,14 @@ pub fn param_button_handler(
ParamButton::Regenerate => params.reseed_and_bump(),
// CenterView is handled by `reset_view_button_handler` (needs Commands).
ParamButton::CenterView => {}
ParamButton::CreateGalaxy => {
bevy::log::info!(
"Created galaxy: seed={}, generation={}",
params.seed,
params.generation
);
next_state.set(AppState::CharacterCreation);
}
}
}
}
@@ -770,7 +801,10 @@ pub fn refresh_control_panel_values(
mut values: Query<(&ParamValue, &mut Text)>,
) {
// Resolve the selected disk, clamped to a valid index.
let Some(disk) = params.disks.get(selected_disk.0.min(params.disks.len().saturating_sub(1))) else {
let Some(disk) = params
.disks
.get(selected_disk.0.min(params.disks.len().saturating_sub(1)))
else {
return;
};
@@ -791,14 +825,7 @@ pub fn refresh_control_panel_values(
// Top beam
"beam_top_enabled" => {
format!(
"{}",
if params.beam_top.enabled {
"On"
} else {
"Off"
}
)
format!("{}", if params.beam_top.enabled { "On" } else { "Off" })
}
"beam_top_thickness" => format!("{:.0}", params.beam_top.thickness),
"beam_top_length" => format!("{:.0}", params.beam_top.length),
@@ -859,31 +886,24 @@ pub fn rebuild_scroll_content(
commands.entity(scroll_entity).despawn();
// Re-spawn a fresh scroll content as a child of the panel.
commands
.entity(panel_entity)
.with_children(|parent| {
parent
.spawn((
Node {
flex_grow: 1.0,
flex_direction: FlexDirection::Column,
row_gap: Val::Px(6.0),
padding: UiRect::px(
PANEL_PADDING,
PANEL_PADDING,
PANEL_PADDING,
PANEL_PADDING,
),
overflow: Overflow::scroll_y(),
..default()
},
ScrollPosition::default(),
GalaxyScrollContent,
))
.with_children(|scroll| {
spawn_scroll_contents_with_tabs(scroll, &params, selected_disk.0);
});
});
commands.entity(panel_entity).with_children(|parent| {
parent
.spawn((
Node {
flex_grow: 1.0,
flex_direction: FlexDirection::Column,
row_gap: Val::Px(6.0),
padding: UiRect::px(PANEL_PADDING, PANEL_PADDING, PANEL_PADDING, PANEL_PADDING),
overflow: Overflow::scroll_y(),
..default()
},
ScrollPosition::default(),
GalaxyScrollContent,
))
.with_children(|scroll| {
spawn_scroll_contents_with_tabs(scroll, &params, selected_disk.0);
});
});
}
/// Full scroll content builder that includes the actual tab buttons for the
@@ -954,11 +974,7 @@ fn spawn_scroll_contents_with_tabs(
} else {
PANEL_BORDER
};
let text_color = if is_active {
TEXT_BRIGHT
} else {
TEXT_DIM
};
let text_color = if is_active { TEXT_BRIGHT } else { TEXT_DIM };
row.spawn((
Button,
Node {
@@ -1278,8 +1294,7 @@ fn randomize_disk(rng: &mut impl rand::Rng) -> DiskParams {
disk.tilt = rng.gen_range(DISK_TILT_MIN..=DISK_TILT_MAX);
disk.tilt = (disk.tilt / DISK_TILT_STEP).round() * DISK_TILT_STEP;
disk.rotation_offset = rng.gen_range(DISK_ROTATION_MIN..=DISK_ROTATION_MAX);
disk.rotation_offset =
(disk.rotation_offset / DISK_ROTATION_STEP).round() * DISK_ROTATION_STEP;
disk.rotation_offset = (disk.rotation_offset / DISK_ROTATION_STEP).round() * DISK_ROTATION_STEP;
disk
}

View File

@@ -3,3 +3,4 @@ pub mod galaxy;
pub mod movement;
pub mod physics;
pub mod star_map;
pub mod starting_base;

View File

@@ -18,10 +18,18 @@ pub fn click_to_move(
if !mouse_input.just_pressed(MouseButton::Left) {
return;
}
let Ok(window) = primary_window.single() else { return };
let Some(cursor_pos) = window.cursor_position() else { return };
let Ok((camera, camera_gt)) = camera_query.single() else { return };
let Ok(ray) = camera.viewport_to_world(camera_gt, cursor_pos) else { return };
let Ok(window) = primary_window.single() else {
return;
};
let Some(cursor_pos) = window.cursor_position() else {
return;
};
let Ok((camera, camera_gt)) = camera_query.single() else {
return;
};
let Ok(ray) = camera.viewport_to_world(camera_gt, cursor_pos) else {
return;
};
// Intersect ray with the ground plane (y = GROUND_PLANE_Y).
if ray.direction.y.abs() < 1e-6 {
@@ -33,6 +41,8 @@ pub fn click_to_move(
}
let target = ray.origin + ray.direction * t;
let Ok(mut move_target) = player.single_mut() else { return };
let Ok(mut move_target) = player.single_mut() else {
return;
};
move_target.0 = target;
}

View File

@@ -33,10 +33,7 @@ pub fn steer_to_target(
}
/// Apply Velocity to Transform.translation.
pub fn integrate_velocity(
mut query: Query<(&mut Transform, &Velocity)>,
time: Res<Time>,
) {
pub fn integrate_velocity(mut query: Query<(&mut Transform, &Velocity)>, time: Res<Time>) {
let dt = time.delta_secs();
for (mut transform, velocity) in &mut query {
transform.translation += velocity.0 * dt;

View File

@@ -35,10 +35,7 @@ impl Default for Orbit {
/// Advance each Orbit's angle and recompute local translation.
/// The orbit center is the parent entity (via Bevy's parent-child hierarchy).
pub fn update_orbits(
mut query: Query<(&mut Orbit, &mut Transform)>,
time: Res<Time>,
) {
pub fn update_orbits(mut query: Query<(&mut Orbit, &mut Transform)>, time: Res<Time>) {
let dt = time.delta_secs();
for (mut orbit, mut transform) in &mut query {
orbit.angle += orbit.angular_velocity * dt;

View File

@@ -26,24 +26,14 @@ pub fn ray_vs_sphere(
}
/// Check if two spheres overlap. Use length-squared for performance (no sqrt).
pub fn overlaps(
a_center: Vec3,
a_radius: f32,
b_center: Vec3,
b_radius: f32,
) -> bool {
pub fn overlaps(a_center: Vec3, a_radius: f32, b_center: Vec3, b_radius: f32) -> bool {
let combined = a_radius + b_radius;
(a_center - b_center).length_squared() < combined * combined
}
/// Compute the vector to push `a` out of `b` so they no longer overlap.
/// Returns `None` if they don't overlap, or if their centers coincide (ambiguous direction).
pub fn separate(
a_center: Vec3,
a_radius: f32,
b_center: Vec3,
b_radius: f32,
) -> Option<Vec3> {
pub fn separate(a_center: Vec3, a_radius: f32, b_center: Vec3, b_radius: f32) -> Option<Vec3> {
let delta = a_center - b_center;
let combined = a_radius + b_radius;
let dist = delta.length();

View File

@@ -8,11 +8,11 @@ impl Plugin for StarMapPlugin {
fn build(&self, app: &mut App) {
app.add_systems(OnEnter(AppState::InGame), setup_star_map)
.add_systems(OnExit(AppState::InGame), despawn_star_map);
// .add_systems(
// Update,
// (/* update systems: pan/zoom, hover, click star systems */)
// .run_if(in_state(AppState::InGame)),
// );
// .add_systems(
// Update,
// (/* update systems: pan/zoom, hover, click star systems */)
// .run_if(in_state(AppState::InGame)),
// );
}
}

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

View 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(&params);
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);
}
}
}

View 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",
}
}

View File

@@ -7,11 +7,8 @@ use bevy::prelude::*;
use camera::orbit_camera_control;
use gameplay::{
character_creation::CharacterCreationPlugin,
galaxy::GalaxyPlugin,
movement::MovementPlugin,
physics::PhysicsPlugin,
star_map::StarMapPlugin,
character_creation::CharacterCreationPlugin, galaxy::GalaxyPlugin, movement::MovementPlugin,
physics::PhysicsPlugin, star_map::StarMapPlugin, starting_base::StartingBasePlugin,
};
use state::AppState;
use ui::main_menu;
@@ -42,6 +39,7 @@ fn main() {
GalaxyPlugin,
StarMapPlugin,
CharacterCreationPlugin,
StartingBasePlugin,
))
.run();
}

View File

@@ -6,6 +6,7 @@ pub enum AppState {
MainMenu,
Galaxy,
CharacterCreation,
StartingBaseSelection,
InGame,
Options,
}

View File

@@ -13,10 +13,7 @@ use bevy::ui::ComputedNode;
/// Coordinates: Bevy UI layout runs in **physical** pixels, while
/// [`Window::cursor_position`] returns **logical** pixels. We multiply by the
/// window's scale factor to convert.
pub fn cursor_over_ui(
window: &Window,
nodes: &Query<(&ComputedNode, &GlobalTransform)>,
) -> bool {
pub fn cursor_over_ui(window: &Window, nodes: &Query<(&ComputedNode, &GlobalTransform)>) -> bool {
let Some(cursor_logical) = window.cursor_position() else {
return false;
};
@@ -32,11 +29,7 @@ pub fn cursor_over_ui(
let half = node.size() * 0.5;
let min = center - half;
let max = center + half;
if cursor.x >= min.x
&& cursor.x <= max.x
&& cursor.y >= min.y
&& cursor.y <= max.y
{
if cursor.x >= min.x && cursor.x <= max.x && cursor.y >= min.y && cursor.y <= max.y {
return true;
}
}