chore: sync codebase remediation, gameplay systems, and docs

Security & infrastructure:
- Remove unused services/ (auth, spacetimedb) and auth.db
- Add .env.example template, expand .gitignore for env/db files
- Add GitHub Actions CI + commitlint config and workflows
- Add manual vendor chunking and source maps to docs/site vite configs

Shared UI & docs app:
- Add ARIA props and focus-visible rings to Button/Panel
- Add ButtonAsLink primitive; use shared Button in NotFound
- Wire @void-nav/ui into docs app; refresh content pages
- Replace Todo page with Kanban board

Gameplay (Bevy):
- Add ai module (behavior, faction, navigation, perception, spawning, states)
- Add narrative module (events, history, synthesis, ui)
- Refine galaxy contents and in-system flight/scene systems
This commit is contained in:
2026-06-16 11:49:13 -04:00
parent 98c2ba59df
commit 57633addfe
60 changed files with 5084 additions and 2473 deletions

View File

@@ -107,6 +107,11 @@ pub fn spawn_camera(mut commands: Commands) {
.looking_at(orbit.target, Vec3::Y),
MainCamera,
orbit,
Camera {
// Customize clear color for space background
clear_color: ClearColorConfig::Custom(Color::srgb(0.02, 0.02, 0.05)),
..default()
},
));
}
@@ -121,7 +126,7 @@ const ORBIT_MAX_DISTANCE: f32 = 1500.0;
/// Drag is suppressed when the cursor is over any UI node — otherwise clicking
/// buttons or panels would also rotate the camera.
///
/// Only runs when camera mode is Orbit.
/// Only runs when camera mode is Orbit or Follow (for tactical repositioning).
pub fn orbit_camera_control(
camera_state: Res<CameraState>,
mouse_input: Res<ButtonInput<MouseButton>>,
@@ -131,8 +136,8 @@ pub fn orbit_camera_control(
mut query: Query<(&mut Transform, &mut OrbitCamera), With<MainCamera>>,
ui_nodes: Query<(&bevy::ui::ComputedNode, &GlobalTransform)>,
) {
// Only run orbit controls in Orbit mode
if camera_state.mode != CameraMode::Orbit {
// Only run orbit controls in Orbit or Follow mode (not Cinematic)
if !camera_state.mode.is_orbit() && !camera_state.mode.is_follow() {
mouse_motion.clear();
scroll_events.clear();
return;

View File

@@ -0,0 +1,355 @@
//! AI behavior execution systems.
//!
//! Systems that execute specific behaviors for NPCs: patrol, combat,
//! flee, mining, and trading.
use bevy::prelude::*;
use crate::gameplay::ai::{
AiConfig, AiNavigation, AiState, BehaviorState, CombatState, FactionAiBehavior,
FleeState, MiningState, PatrolState, StateTransition, TradingState,
};
use crate::gameplay::movement::components::MoveTarget;
use crate::gameplay::ai::perception::{Perception, PerceptionEvent, PerceptionEventType};
/// Update state transitions for all AI entities.
///
/// This system evaluates conditions and triggers state transitions
/// based on AI state, perception, and faction behavior.
pub fn update_state_transitions(
mut commands: Commands,
ai_config: Res<AiConfig>,
mut ai_query: Query<(
Entity,
&mut AiState,
&Perception,
&FactionAiBehavior,
)>,
mut state_events: EventWriter<StateTransition>,
mut perception_events: EventReader<PerceptionEvent>,
time: Res<Time>,
) {
// First, handle perception events that might trigger state changes
for event in perception_events.read() {
if let Ok((_, mut ai_state, perception, faction_behavior)) =
ai_query.get_mut(event.observer)
{
match event.event_type {
PerceptionEventType::HostileDetected => {
// Transition to combat if we're not already fleeing
if ai_state.behavior != BehaviorState::Combat
&& ai_state.behavior != BehaviorState::Flee
{
let old_state = ai_state.behavior;
ai_state.behavior = BehaviorState::Combat;
ai_state.target_entity = Some(event.perceived);
state_events.send(StateTransition {
entity: event.observer,
from_state: old_state,
to_state: BehaviorState::Combat,
reason: "Hostile detected".to_string(),
});
}
}
PerceptionEventType::Lost => {
// Lost track of target - return to idle or patrol
if Some(event.perceived) == ai_state.target_entity {
let old_state = ai_state.behavior;
// Return to patrol if we have patrol waypoints, else idle
ai_state.behavior = BehaviorState::Idle;
ai_state.target_entity = None;
ai_state.target_position = None;
state_events.send(StateTransition {
entity: event.observer,
from_state: old_state,
to_state: BehaviorState::Idle,
reason: "Target lost".to_string(),
});
}
}
_ => {}
}
}
}
// Then, update time-based transitions
for (entity, mut ai_state, perception, faction_behavior) in ai_query.iter_mut() {
ai_state.time_in_state += time.delta_secs();
match ai_state.behavior {
BehaviorState::Idle => {
// Check for threats in idle state
if let Some(threat) = perception.primary_threat {
if faction_behavior.aggressiveness > 0.5 {
let old_state = ai_state.behavior;
ai_state.behavior = BehaviorState::Combat;
ai_state.target_entity = Some(threat);
state_events.send(StateTransition {
entity,
from_state: old_state,
to_state: BehaviorState::Combat,
reason: "Threat detected in idle".to_string(),
});
}
}
}
BehaviorState::Combat => {
// Check hull threshold for fleeing
if ai_state.hull_percentage < faction_behavior.flee_threshold() {
let old_state = ai_state.behavior;
ai_state.behavior = BehaviorState::Flee;
state_events.send(StateTransition {
entity,
from_state: old_state,
to_state: BehaviorState::Flee,
reason: "Hull critical".to_string(),
});
}
}
BehaviorState::Flee => {
// Check if we've reached safe distance
// TODO: Implement distance check from threat
// For now, NPCs stay in flee until they detect a safe distance
}
BehaviorState::Patrol | BehaviorState::Mining | BehaviorState::Trading => {
// Continue current behavior until interrupted
}
}
}
}
/// Execute patrol behavior for NPCs.
///
/// NPCs with patrol behavior follow waypoints through their assigned area.
pub fn execute_patrol(
mut commands: Commands,
mut ai_query: Query<(
Entity,
&mut AiState,
&mut PatrolState,
&mut AiNavigation,
&Transform,
)>,
time: Res<Time>,
) {
for (entity, mut ai_state, mut patrol_state, mut navigation, transform) in ai_query.iter_mut()
{
// Update time at waypoint if we're waiting
if navigation.movement_target.is_none() && !patrol_state.waypoints.is_empty() {
patrol_state.time_at_waypoint += time.delta_secs();
if patrol_state.time_at_waypoint >= patrol_state.waypoint_wait_time {
// Move to next waypoint
patrol_state.time_at_waypoint = 0.0;
patrol_state.current_waypoint = (patrol_state.current_waypoint + 1)
% patrol_state.waypoints.len();
let target = patrol_state.waypoints[patrol_state.current_waypoint];
ai_state.target_position = Some(target);
// Assign movement target
commands.entity(entity).insert(MoveTarget(target));
navigation.movement_target = Some(target);
navigation.is_moving = true;
}
}
}
}
/// Execute combat behavior for NPCs.
///
/// NPCs in combat engage their targets with appropriate tactics.
pub fn execute_combat(
mut commands: Commands,
ai_config: Res<AiConfig>,
mut ai_query: Query<(
Entity,
&mut AiState,
&mut CombatState,
&mut AiNavigation,
&FactionAiBehavior,
&Transform,
)>,
target_query: Query<&Transform>,
time: Res<Time>,
) {
for (entity, mut ai_state, mut combat_state, mut navigation, faction_behavior, transform) in
ai_query.iter_mut()
{
// Get target transform if we have one
let target_transform = if let Some(target_entity) = combat_state.target {
target_query.get(target_entity).ok()
} else if let Some(target_entity) = ai_state.target_entity {
combat_state.target = Some(target_entity);
target_query.get(target_entity).ok()
} else {
None
};
if let Some(target_tf) = target_transform {
let distance = transform.translation.distance(target_tf.translation);
// Move to preferred engagement distance
let preferred_distance = faction_behavior.preferred_engagement_distance();
let distance_diff = distance - preferred_distance;
if distance_diff.abs() > 20.0 {
// Need to adjust distance
let direction = (target_tf.translation - transform.translation).normalize();
let target_pos = if distance > preferred_distance {
// Move closer
transform.translation + direction * (distance - preferred_distance) * 0.5
} else {
// Move away
transform.translation - direction * (preferred_distance - distance) * 0.5
};
commands.entity(entity).insert(MoveTarget(target_pos));
navigation.movement_target = Some(target_pos);
navigation.is_moving = true;
} else {
// At optimal distance, stop moving
navigation.is_moving = false;
commands.entity(entity).remove::<MoveTarget>();
}
// Update attack timing
combat_state.time_since_last_attack += time.delta_secs();
if combat_state.time_since_last_attack >= combat_state.attack_cooldown {
// Attack!
combat_state.time_since_last_attack = 0.0;
combat_state.is_attacking = true;
// In full implementation, this would trigger weapon fire
bevy::log::debug!("Entity {:?} attacks!", entity);
} else {
combat_state.is_attacking = false;
}
}
}
}
/// Execute flee behavior for NPCs.
///
/// NPCs in flee behavior move away from threats at high speed.
pub fn execute_flee(
mut commands: Commands,
ai_config: Res<AiConfig>,
mut ai_query: Query<(
Entity,
&mut AiState,
&mut FleeState,
&mut AiNavigation,
&Transform,
)>,
threat_query: Query<&Transform>,
) {
for (entity, mut ai_state, mut flee_state, mut navigation, transform) in ai_query.iter_mut() {
// Get threat position
let threat_transform = if let Some(threat_entity) = flee_state.threat {
threat_query.get(threat_entity).ok()
} else if let Some(threat_entity) = ai_state.target_entity {
flee_state.threat = Some(threat_entity);
threat_query.get(threat_entity).ok()
} else {
// No threat - return to idle
ai_state.behavior = BehaviorState::Idle;
continue;
};
if let Some(threat_tf) = threat_transform {
// Calculate flee direction (away from threat)
let away_from_threat = (transform.translation - threat_tf.translation).normalize();
flee_state.flee_direction = away_from_threat;
// Move away at high speed
let flee_distance = ai_config.safe_flee_distance;
let target_pos = transform.translation + away_from_threat * flee_distance;
commands.entity(entity).insert(MoveTarget(target_pos));
navigation.movement_target = Some(target_pos);
navigation.is_moving = true;
navigation.speed_factor = 1.2; // 120% speed when fleeing
}
}
}
/// Execute mining behavior for NPCs.
///
/// NPCs with mining behavior stay at asteroid belts and mine.
pub fn execute_mining(
mut ai_query: Query<(
Entity,
&mut AiState,
&mut MiningState,
&mut AiNavigation,
)>,
time: Res<Time>,
) {
for (entity, mut ai_state, mut mining_state, mut navigation) in ai_query.iter_mut() {
// Update mining time
mining_state.mining_time += time.delta_secs();
// Mining is complete when cargo is full or cycle duration is reached
if mining_state.mining_time >= mining_state.mining_cycle_duration
|| mining_state.cargo_remaining <= 0.0
{
// Mining cycle complete
mining_state.mining_time = 0.0;
if mining_state.cargo_remaining > 0.0 {
// Continue mining
bevy::log::debug!("Entity {:?} continues mining", entity);
} else {
// Cargo full - return to station
ai_state.behavior = BehaviorState::Trading;
bevy::log::debug!("Entity {:?} cargo full, returning to station", entity);
}
}
}
}
/// Execute trading behavior for NPCs.
///
/// NPCs with trading behavior travel between stations.
pub fn execute_trading(
mut commands: Commands,
mut ai_query: Query<(
Entity,
&mut AiState,
&mut TradingState,
&mut AiNavigation,
&Transform,
)>,
) {
for (entity, mut ai_state, mut trading_state, mut navigation, transform) in ai_query.iter_mut() {
// If we have waypoints, follow them
if !trading_state.trade_route.is_empty() {
let current_waypoint = trading_state.trade_route[trading_state.current_waypoint];
let distance = transform.translation.distance(current_waypoint);
if distance < 20.0 {
// Reached waypoint - move to next
trading_state.current_waypoint =
(trading_state.current_waypoint + 1) % trading_state.trade_route.len();
let next_waypoint = trading_state.trade_route[trading_state.current_waypoint];
commands.entity(entity).insert(MoveTarget(next_waypoint));
navigation.movement_target = Some(next_waypoint);
navigation.is_moving = true;
} else if navigation.movement_target.is_none() {
// Start moving
commands.entity(entity).insert(MoveTarget(current_waypoint));
navigation.movement_target = Some(current_waypoint);
navigation.is_moving = true;
}
}
}
}

View File

@@ -0,0 +1,115 @@
//! Faction-based AI behavior profiles.
//!
//! Different factions have different AI behaviors and response patterns.
//! This module defines faction-specific AI characteristics.
use bevy::prelude::*;
/// AI behavior profile for a faction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FactionAiProfile {
/// Concord: Strict law enforcement, immediate response to hostiles
Concord,
/// Amarr: Hierarchical, organized patrols
Amarr,
/// Minmatar: Aggressive, fast response
Minmatar,
/// Gallente: Balanced, diplomatic preference
Gallente,
/// Caldari: Calculated, strategic positioning
Caldari,
/// Pirates: Opportunistic, ambush tactics
Pirates,
/// Independent: Variable behavior
Independent,
}
/// Faction-specific AI behavior settings.
#[derive(Component, Debug, Clone)]
pub struct FactionAiBehavior {
/// Faction this behavior belongs to
pub faction: String,
/// Behavior profile for this faction
pub profile: FactionAiProfile,
/// Combat aggressiveness (0.0 to 1.0)
pub aggressiveness: f32,
/// Response time to threats (seconds)
pub response_time: f32,
/// Preference for diplomatic solutions (0.0 to 1.0)
pub diplomatic_preference: f32,
/// Trade openness (0.0 to 1.0)
pub trade_openness: f32,
}
impl Default for FactionAiBehavior {
fn default() -> Self {
Self {
faction: "Independent".to_string(),
profile: FactionAiProfile::Independent,
aggressiveness: 0.5,
response_time: 5.0,
diplomatic_preference: 0.5,
trade_openness: 0.5,
}
}
}
impl FactionAiBehavior {
/// Create faction-specific AI behavior from faction name.
pub fn from_faction(faction: &str) -> Self {
let profile = match faction {
"Concord" => FactionAiProfile::Concord,
"Amarr" => FactionAiProfile::Amarr,
"Minmatar" => FactionAiProfile::Minmatar,
"Gallente" => FactionAiProfile::Gallente,
"Caldari" => FactionAiProfile::Caldari,
"Pirates" => FactionAiProfile::Pirates,
_ => FactionAiProfile::Independent,
};
let (aggressiveness, response_time, diplomatic_preference, trade_openness) = match profile {
FactionAiProfile::Concord => (0.9, 2.0, 0.3, 0.5), // Quick to respond, lawful
FactionAiProfile::Amarr => (0.7, 4.0, 0.2, 0.6), // Organized but slower
FactionAiProfile::Minmatar => (0.8, 3.0, 0.4, 0.7), // Aggressive but reasonable
FactionAiProfile::Gallente => (0.5, 5.0, 0.8, 0.9), // Diplomatic, trade-friendly
FactionAiProfile::Caldari => (0.6, 4.0, 0.5, 0.7), // Calculated, balanced
FactionAiProfile::Pirates => (0.9, 1.0, 0.0, 0.1), // Very aggressive, hostile
FactionAiProfile::Independent => (0.5, 5.0, 0.6, 0.8), // Variable
};
Self {
faction: faction.to_string(),
profile,
aggressiveness,
response_time,
diplomatic_preference,
trade_openness,
}
}
/// Get the preferred engagement distance for this faction.
pub fn preferred_engagement_distance(&self) -> f32 {
match self.profile {
FactionAiProfile::Concord => 80.0, // Close range for law enforcement
FactionAiProfile::Amarr => 120.0, // Mid-range, organized
FactionAiProfile::Minmatar => 60.0, // Close range, aggressive
FactionAiProfile::Gallente => 100.0, // Mid-range, balanced
FactionAiProfile::Caldari => 150.0, // Long range, strategic
FactionAiProfile::Pirates => 50.0, // Very close, ambush
FactionAiProfile::Independent => 100.0,
}
}
/// Get the flee threshold for this faction.
pub fn flee_threshold(&self) -> f32 {
match self.profile {
FactionAiProfile::Concord => 0.1, // Rarely flee (law enforcement)
FactionAiProfile::Amarr => 0.25, // Flee at 25% hull
FactionAiProfile::Minmatar => 0.3, // Flee at 30% hull
FactionAiProfile::Gallente => 0.35,
FactionAiProfile::Caldari => 0.2, // Calculated retreat
FactionAiProfile::Pirates => 0.5, // Flee easily (opportunistic)
FactionAiProfile::Independent => 0.35,
}
}
}

View File

@@ -0,0 +1,87 @@
//! AI system for NPC behavior.
//!
//! This module provides a state machine-based AI system for NPC ships
//! and entities. It includes:
//!
//! - **State Machine**: Hierarchical behavior states (Idle, Patrol, Combat, Flee, Mining, Trading)
//! - **Navigation**: Pathfinding and movement using the existing movement system
//! - **Perception**: Event-based detection of player and other entities
//! - **Spawning**: NPC ship spawning based on system security and faction
//! - **Faction AI**: Faction-specific behavior profiles
mod behavior;
mod faction;
mod navigation;
mod perception;
mod spawning;
mod states;
pub use faction::{FactionAiBehavior, FactionAiProfile};
pub use navigation::{AiNavigation, assign_movement_target};
pub use perception::{Perceptible, Perception, PerceptionEvent, update_perception};
pub use spawning::{NpcMetadata, NpcSpawner, spawn_station_traffic, spawn_system_npcs};
pub use states::{
AiState, AiStateMachine, BehaviorState, CombatState, FleeState,
MiningState, PatrolState, StateTransition, TradingState,
};
pub use behavior::{execute_patrol, execute_combat, execute_flee, execute_mining, execute_trading};
use bevy::prelude::*;
use crate::state::AppState;
/// Main AI plugin that orchestrates all AI systems.
pub struct AiPlugin;
impl Plugin for AiPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<AiStateMachine>()
.add_event::<PerceptionEvent>()
.add_event::<StateTransition>()
// AI spawning systems - run when entering in-game state
.add_systems(OnEnter(AppState::InGame), spawning::spawn_system_npcs)
// AI update systems - run every frame during gameplay
.add_systems(
Update,
(
perception::update_perception,
navigation::update_ai_navigation,
behavior::update_state_transitions,
behavior::execute_patrol,
behavior::execute_combat,
behavior::execute_flee,
behavior::execute_mining,
behavior::execute_trading,
)
.chain()
.run_if(in_state(AppState::InGame)),
);
}
}
/// Configuration resource for AI behavior tuning.
#[derive(Resource, Debug, Clone)]
pub struct AiConfig {
/// Detection range for perception (in world units)
pub perception_range: f32,
/// How often AI updates their decisions (in seconds)
pub decision_interval: f32,
/// Range at which NPCs engage in combat
pub combat_range: f32,
/// Hull percentage at which NPCs flee
pub flee_hull_threshold: f32,
/// Distance considered "safe" when fleeing
pub safe_flee_distance: f32,
}
impl Default for AiConfig {
fn default() -> Self {
Self {
perception_range: 500.0,
decision_interval: 0.5,
combat_range: 200.0,
flee_hull_threshold: 0.3, // 30% hull
safe_flee_distance: 800.0,
}
}
}

View File

@@ -0,0 +1,120 @@
//! AI navigation system.
//!
//! Handles AI movement and pathfinding using the existing movement system.
//! NPCs use the same movement components as the player but are driven by
//! AI decisions rather than player input.
use bevy::prelude::*;
use crate::gameplay::movement::components::MoveTarget;
use crate::gameplay::ai::{AiConfig, AiState, BehaviorState};
/// Component for AI-controlled navigation.
#[derive(Component, Debug, Clone)]
pub struct AiNavigation {
/// Current movement target (world position)
pub movement_target: Option<Vec3>,
/// Distance threshold to consider target reached
pub arrival_distance: f32,
/// Whether we're currently moving toward target
pub is_moving: bool,
/// Preferred speed (as fraction of max speed)
pub speed_factor: f32,
}
impl Default for AiNavigation {
fn default() -> Self {
Self {
movement_target: None,
arrival_distance: 10.0,
is_moving: false,
speed_factor: 0.8,
}
}
}
/// Assign a movement target to an AI entity.
///
/// This function adds or updates the MoveTarget component for an NPC
/// and updates the AiNavigation component accordingly.
pub fn assign_movement_target(
commands: &mut Commands,
entity: Entity,
target: Vec3,
arrival_distance: f32,
speed_factor: f32,
) {
commands.entity(entity).insert(MoveTarget(target));
commands.entity(entity).insert(AiNavigation {
movement_target: Some(target),
arrival_distance,
is_moving: true,
speed_factor,
});
}
/// Clear movement target for an AI entity.
pub fn clear_movement_target(commands: &mut Commands, entity: Entity) {
commands.entity(entity).remove::<MoveTarget>();
commands.entity(entity).insert(AiNavigation {
movement_target: None,
arrival_distance: 10.0,
is_moving: false,
speed_factor: 0.8,
});
}
/// Update AI navigation based on current behavior state.
///
/// This system runs every frame and updates movement targets based on
/// the NPC's current behavior state and state-specific data.
pub fn update_ai_navigation(
mut commands: Commands,
ai_config: Res<AiConfig>,
mut ai_query: Query<(
Entity,
&mut AiState,
&mut AiNavigation,
&Transform,
)>,
) {
for (entity, mut ai_state, mut navigation, transform) in ai_query.iter_mut() {
// Check if we've arrived at our current target
if let Some(target) = navigation.movement_target {
let distance_to_target = transform.translation.distance(target);
if distance_to_target < navigation.arrival_distance {
// We've arrived - clear the movement target
navigation.is_moving = false;
navigation.movement_target = None;
commands.entity(entity).remove::<MoveTarget>();
}
}
// Update navigation based on behavior state
match ai_state.behavior {
BehaviorState::Idle => {
// No movement in idle state
if navigation.is_moving {
clear_movement_target(&mut commands, entity);
}
}
BehaviorState::Flee => {
// Flee movement is handled by flee behavior system
// Just update speed factor for emergency
navigation.speed_factor = 1.2; // 120% speed when fleeing
}
BehaviorState::Combat => {
// Combat maintains distance to target
navigation.speed_factor = 0.9; // 90% speed in combat
}
BehaviorState::Patrol | BehaviorState::Trading => {
// Normal movement speed
navigation.speed_factor = 0.7; // 70% speed for routine operations
}
BehaviorState::Mining => {
// No movement while mining - we stay at the belt
navigation.speed_factor = 0.0;
}
}
}
}

View File

@@ -0,0 +1,162 @@
//! AI perception system.
//!
//! Event-based detection system for AI entities to perceive the player
//! and other entities in the game world.
use bevy::prelude::*;
/// Marks an entity as perceptible to AI (can be detected).
#[derive(Component, Debug, Clone)]
pub struct Perceptible {
/// Detection priority (higher = more likely to be noticed)
pub priority: f32,
/// Whether this entity is hostile
pub is_hostile: bool,
/// Faction of this entity (for faction-based perception)
pub faction: Option<String>,
}
impl Default for Perceptible {
fn default() -> Self {
Self {
priority: 1.0,
is_hostile: false,
faction: None,
}
}
}
/// Perception component for AI entities.
#[derive(Component, Debug, Clone)]
pub struct Perception {
/// Entities currently perceived by this AI
pub perceived_entities: Vec<Entity>,
/// The most threatening perceived entity
pub primary_threat: Option<Entity>,
/// The most interesting perceived entity (for non-hostile interactions)
pub primary_interest: Option<Entity>,
/// Time since last perception update
pub time_since_last_update: f32,
}
impl Default for Perception {
fn default() -> Self {
Self {
perceived_entities: Vec::new(),
primary_threat: None,
primary_interest: None,
time_since_last_update: 0.0,
}
}
}
/// Event sent when an AI perceives something notable.
#[derive(Event, Debug, Clone)]
pub struct PerceptionEvent {
/// AI entity that perceived something
pub observer: Entity,
/// Entity that was perceived
pub perceived: Entity,
/// Type of perception event
pub event_type: PerceptionEventType,
}
/// Types of perception events.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PerceptionEventType {
/// Detected a new entity
Detected,
/// Lost track of an entity
Lost,
/// Entity became hostile
HostileDetected,
/// Entity is in trouble (low health, etc.)
DistressSignal,
/// Entity has valuable cargo
ValuableTarget,
}
/// Update perception for all AI entities.
///
/// This system runs every frame and updates each AI's perception
/// based on nearby entities and their perceptible components.
pub fn update_perception(
mut perception_query: Query<(
Entity,
&mut Perception,
&Transform,
)>,
perceptible_query: Query<(Entity, &Perceptible, &Transform)>,
mut perception_events: EventWriter<PerceptionEvent>,
time: Res<Time>,
) {
for (observer_entity, mut perception, observer_transform) in perception_query.iter_mut() {
perception.time_since_last_update += time.delta_secs();
// Update perception every 0.5 seconds
if perception.time_since_last_update < 0.5 {
continue;
}
perception.time_since_last_update = 0.0;
let previous_entities = std::mem::take(&mut perception.perceived_entities);
// Find all perceptible entities within range
for (perceived_entity, perceptible, perceived_transform) in perceptible_query.iter() {
// Don't perceive ourselves
if perceived_entity == observer_entity {
continue;
}
let distance = observer_transform
.translation
.distance(perceived_transform.translation);
// Detection range based on priority
let detection_range = 500.0 * perceptible.priority;
if distance < detection_range {
// Entity is perceived
perception.perceived_entities.push(perceived_entity);
// Check if this is a newly detected entity
if !previous_entities.contains(&perceived_entity) {
perception_events.send(PerceptionEvent {
observer: observer_entity,
perceived: perceived_entity,
event_type: if perceptible.is_hostile {
PerceptionEventType::HostileDetected
} else {
PerceptionEventType::Detected
},
});
}
// Update primary threat
if perceptible.is_hostile {
if perception.primary_threat.is_none()
|| distance < observer_transform.translation.distance(
// Can't access this easily without another query, simplify for now
Vec3::ZERO,
)
{
perception.primary_threat = Some(perceived_entity);
}
} else {
perception.primary_interest = Some(perceived_entity);
}
}
}
// Check for lost entities
for lost_entity in previous_entities {
if !perception.perceived_entities.contains(&lost_entity) {
perception_events.send(PerceptionEvent {
observer: observer_entity,
perceived: lost_entity,
event_type: PerceptionEventType::Lost,
});
}
}
}
}

View File

@@ -0,0 +1,311 @@
//! NPC spawning system.
//!
//! Handles spawning of NPC ships based on system security level,
//! faction presence, and gameplay requirements.
use bevy::prelude::*;
use crate::gameplay::ai::{AiState, BehaviorState};
use crate::gameplay::galaxy::{Identifiable, StarSystem};
use crate::gameplay::in_system::ActiveSystem;
use crate::gameplay::movement::components::{MaxSpeed, TurnRate, Velocity};
/// Metadata for spawned NPCs.
#[derive(Component, Debug, Clone)]
pub struct NpcMetadata {
/// NPC identifier
pub id: String,
/// Display name
pub name: String,
/// Faction this NPC belongs to
pub faction: String,
/// NPC type (patrol, pirate, trader, miner)
pub npc_type: NpcType,
/// Spawn level (affects stats)
pub spawn_level: u32,
}
/// Types of NPCs that can be spawned.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NpcType {
/// Faction patrol ship
Patrol,
/// Pirate / hostile
Pirate,
/// Trading vessel
Trader,
/// Mining vessel
Miner,
/// Station traffic (shuttles, etc.)
StationTraffic,
}
/// NPC spawner resource.
#[derive(Resource, Debug, Clone)]
pub struct NpcSpawner {
/// Whether to spawn NPCs in the current system
pub spawn_enabled: bool,
/// Maximum NPCs per system
pub max_npcs_per_system: u32,
/// Spawn multiplier (for difficulty scaling)
pub spawn_multiplier: f32,
}
impl Default for NpcSpawner {
fn default() -> Self {
Self {
spawn_enabled: true,
max_npcs_per_system: 15,
spawn_multiplier: 1.0,
}
}
}
/// Spawn NPCs for the current system when entering gameplay.
///
/// This runs on AppState::InGame enter and spawns appropriate NPCs
/// based on the active system's security level and faction.
pub fn spawn_system_npcs(
mut commands: Commands,
active_system: Res<ActiveSystem>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
// For now, use a default security level since ActiveSystem doesn't store it
let security = 0.5; // Default mid-sec
let faction = "Concord"; // Default faction
// Determine spawn count based on security level
let base_spawn_count = match security {
sec if sec >= 0.8 => 8, // High-sec: more patrols
sec if sec >= 0.5 => 5, // Mid-sec: moderate
_ => 3, // Low-sec: fewer but more dangerous
};
let spawn_count = (base_spawn_count as f32) * 1.0; // Use spawn multiplier from resource if available
bevy::log::info!(
"Spawning {} NPCs for system {} (security: {})",
spawn_count as u32,
active_system.system_name,
security
);
// Spawn faction patrols for high-sec systems
if security >= 0.5 {
let patrol_count = (spawn_count * 0.6) as u32;
for i in 0..patrol_count {
spawn_npc(
&mut commands,
&mut meshes,
&mut materials,
&active_system.system_name,
faction,
NpcType::Patrol,
i,
);
}
}
// Spawn pirates for low-sec systems
if security < 0.5 {
let pirate_count = (spawn_count * 0.4) as u32 + 1;
for i in 0..pirate_count {
spawn_npc(
&mut commands,
&mut meshes,
&mut materials,
&active_system.system_name,
faction,
NpcType::Pirate,
i,
);
}
}
// Spawn traders for all systems
let trader_count = (spawn_count * 0.2) as u32 + 1;
for i in 0..trader_count {
spawn_npc(
&mut commands,
&mut meshes,
&mut materials,
&active_system.system_name,
faction,
NpcType::Trader,
i,
);
}
// Spawn miners for systems with asteroid belts
let miner_count = (spawn_count * 0.2) as u32 + 1;
for i in 0..miner_count {
spawn_npc(
&mut commands,
&mut meshes,
&mut materials,
&active_system.system_name,
faction,
NpcType::Miner,
i,
);
}
}
/// Spawn a single NPC ship.
fn spawn_npc(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
system_name: &str,
faction: &str,
npc_type: NpcType,
index: u32,
) {
// Determine faction and appearance based on NPC type
let (npc_faction, color, name_prefix) = match npc_type {
NpcType::Patrol => (
faction.to_string(),
get_faction_color(faction),
"PATROL",
),
NpcType::Pirate => (
"Pirates".to_string(),
[0.8, 0.2, 0.2], // Red
"PIRATE",
),
NpcType::Trader => (
"Commerce".to_string(),
[0.2, 0.6, 0.8], // Blue
"TRADER",
),
NpcType::Miner => (
"MiningGuild".to_string(),
[0.6, 0.6, 0.2], // Yellow
"MINER",
),
NpcType::StationTraffic => (
"Station".to_string(),
[0.5, 0.5, 0.5], // Gray
"TRAFFIC",
),
};
// Generate spawn position (random position within system bounds)
let angle = (index as f32) * 0.5; // Spread out spawns
let radius = 150.0 + (index as f32) * 50.0;
let spawn_pos = Vec3::new(
angle.cos() * radius,
0.0,
angle.sin() * radius,
);
// Create NPC entity
let npc_id = format!("npc-{}-{}", name_prefix, index);
let npc_name = format!("{} {}", name_prefix, index + 1);
let mut entity = commands.spawn((
// Transform
Transform::from_translation(spawn_pos),
Visibility::default(),
InheritedVisibility::default(),
// NPC metadata
NpcMetadata {
id: npc_id.clone(),
name: npc_name.clone(),
faction: npc_faction.clone(),
npc_type,
spawn_level: 1,
},
// AI state
AiState {
behavior: match npc_type {
NpcType::Patrol => BehaviorState::Patrol,
NpcType::Pirate => BehaviorState::Idle,
NpcType::Trader => BehaviorState::Trading,
NpcType::Miner => BehaviorState::Mining,
NpcType::StationTraffic => BehaviorState::Idle,
},
..default()
},
// Identifiable for targeting system
Identifiable {
id: npc_id,
display_name: npc_name.clone(),
classification: crate::gameplay::galaxy::Classification::Ship,
},
// Movement components
MaxSpeed(50.0),
TurnRate(2.0),
Velocity::default(),
// Visual representation (simple cone for now)
Mesh3d(meshes.add(Cone::new(2.0, 8.0).mesh())),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: Color::srgb(color[0], color[1], color[2]),
emissive: LinearRgba::new(color[0] * 0.3, color[1] * 0.3, color[2] * 0.3, 1.0),
..default()
})),
));
// Add state-specific components
match npc_type {
NpcType::Patrol => {
entity.insert(crate::gameplay::ai::PatrolState::default());
}
NpcType::Pirate => {
// Pirates don't have special state - they use base AiState
}
NpcType::Trader => {
entity.insert(crate::gameplay::ai::TradingState::default());
}
NpcType::Miner => {
entity.insert(crate::gameplay::ai::MiningState::default());
}
NpcType::StationTraffic => {
// Traffic is idle most of the time
}
}
bevy::log::debug!("Spawned NPC: {} at {:?}", npc_name.clone(), spawn_pos);
}
/// Spawn station traffic (NPCs that dock/undock at stations).
pub fn spawn_station_traffic(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
active_system: &ActiveSystem,
) {
// Spawn 2-3 traffic NPCs per station
let traffic_count = 3;
let faction = "Concord"; // Default faction
for i in 0..traffic_count {
spawn_npc(
commands,
meshes,
materials,
&active_system.system_name,
faction,
NpcType::StationTraffic,
i,
);
}
}
/// Get the color for a faction.
fn get_faction_color(faction: &str) -> [f32; 3] {
match faction {
"Concord" => [0.13, 0.83, 0.93], // cyan
"Amarr" => [0.96, 0.62, 0.04], // amber
"Minmatar" => [0.94, 0.27, 0.27], // red
"Gallente" => [0.66, 0.33, 0.97], // purple
"Caldari" => [0.22, 0.74, 0.97], // blue
_ => [0.5, 0.5, 0.5], // gray (default)
}
}

View File

@@ -0,0 +1,207 @@
//! AI state machine definitions.
//!
//! Defines the hierarchical behavior state machine for NPC entities.
//! States include Idle, Patrol, Combat, Flee, Mining, and Trading.
use bevy::prelude::*;
/// The main behavior states for NPC AI.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BehaviorState {
/// No active behavior - waiting for stimulus
#[default]
Idle,
/// Following a patrol route or pattern
Patrol,
/// Engaging in combat with a hostile target
Combat,
/// Fleeing from a threat
Flee,
/// Mining at an asteroid belt
Mining,
/// Trading between stations
Trading,
}
/// Current AI state component - attached to all NPC entities.
#[derive(Component, Debug, Clone)]
pub struct AiState {
/// Current behavior state
pub behavior: BehaviorState,
/// Time since last state change
pub time_in_state: f32,
/// Current target entity (if any)
pub target_entity: Option<Entity>,
/// Current target position (if any)
pub target_position: Option<Vec3>,
/// Hull percentage (0.0 to 1.0) - affects state transitions
pub hull_percentage: f32,
}
impl Default for AiState {
fn default() -> Self {
Self {
behavior: BehaviorState::Idle,
time_in_state: 0.0,
target_entity: None,
target_position: None,
hull_percentage: 1.0,
}
}
}
/// State-specific data for patrol behavior.
#[derive(Component, Debug, Clone)]
pub struct PatrolState {
/// List of waypoints in the patrol route
pub waypoints: Vec<Vec3>,
/// Current waypoint index
pub current_waypoint: usize,
/// Whether to loop the patrol route
pub loop_route: bool,
/// Wait time at each waypoint (seconds)
pub waypoint_wait_time: f32,
/// Time spent at current waypoint
pub time_at_waypoint: f32,
}
impl Default for PatrolState {
fn default() -> Self {
Self {
waypoints: Vec::new(),
current_waypoint: 0,
loop_route: true,
waypoint_wait_time: 2.0,
time_at_waypoint: 0.0,
}
}
}
/// State-specific data for combat behavior.
#[derive(Component, Debug, Clone)]
pub struct CombatState {
/// Target entity we're fighting
pub target: Option<Entity>,
/// Preferred combat distance
pub preferred_distance: f32,
/// Time since last attack
pub time_since_last_attack: f32,
/// Attack cooldown (seconds)
pub attack_cooldown: f32,
/// Whether we're currently attacking
pub is_attacking: bool,
}
impl Default for CombatState {
fn default() -> Self {
Self {
target: None,
preferred_distance: 100.0,
time_since_last_attack: 0.0,
attack_cooldown: 3.0,
is_attacking: false,
}
}
}
/// State-specific data for flee behavior.
#[derive(Component, Debug, Clone)]
pub struct FleeState {
/// Entity we're fleeing from
pub threat: Option<Entity>,
/// Direction we're fleeing (normalized)
pub flee_direction: Vec3,
/// Distance we've fled
pub distance_fled: f32,
/// Whether we've reached safe distance
pub reached_safe_distance: bool,
}
impl Default for FleeState {
fn default() -> Self {
Self {
threat: None,
flee_direction: Vec3::ZERO,
distance_fled: 0.0,
reached_safe_distance: false,
}
}
}
/// State-specific data for mining behavior.
#[derive(Component, Debug, Clone)]
pub struct MiningState {
/// Asteroid belt we're mining at
pub belt_entity: Option<Entity>,
/// Time spent mining
pub mining_time: f32,
/// Mining cycle duration (seconds)
pub mining_cycle_duration: f32,
/// Cargo capacity remaining
pub cargo_remaining: f32,
}
impl Default for MiningState {
fn default() -> Self {
Self {
belt_entity: None,
mining_time: 0.0,
mining_cycle_duration: 8.0,
cargo_remaining: 100.0,
}
}
}
/// State-specific data for trading behavior.
#[derive(Component, Debug, Clone)]
pub struct TradingState {
/// Current destination station
pub destination_station: Option<Entity>,
/// Current trade route waypoints
pub trade_route: Vec<Vec3>,
/// Current waypoint in trade route
pub current_waypoint: usize,
/// Time until next trade decision
pub time_until_next_decision: f32,
}
impl Default for TradingState {
fn default() -> Self {
Self {
destination_station: None,
trade_route: Vec::new(),
current_waypoint: 0,
time_until_next_decision: 5.0,
}
}
}
/// Event sent when an AI entity transitions to a new state.
#[derive(Event, Debug, Clone)]
pub struct StateTransition {
/// Entity that changed state
pub entity: Entity,
/// Previous state
pub from_state: BehaviorState,
/// New state
pub to_state: BehaviorState,
/// Reason for transition (for debugging/logging)
pub reason: String,
}
/// Resource that tracks all AI state machines for debugging and monitoring.
#[derive(Resource, Debug, Default)]
pub struct AiStateMachine {
/// Count of NPCs in each behavior state
pub state_counts: [usize; 6],
}
impl AiStateMachine {
/// Record a state transition for statistics.
pub fn record_transition(&mut self, from: BehaviorState, to: BehaviorState) {
// Decrement old state count
self.state_counts[from as usize] = self.state_counts[from as usize].saturating_sub(1);
// Increment new state count
self.state_counts[to as usize] += 1;
}
}

View File

@@ -753,6 +753,7 @@ pub fn spawn_system_contents(
contents: &SystemContents,
star_entity: Entity,
assets: &ContentAssets,
materials: &mut Assets<StandardMaterial>,
) {
// ── Planets ─────────────────────────────────────────────────────────────
for planet in &contents.planets {
@@ -786,6 +787,18 @@ pub fn spawn_system_contents(
if planet.habitable {
entity.insert(HabitablePlanet);
}
// Add city lights for high-population planets
if let Some(city_lights_mat) = city_lights_material(materials, planet.planet_type, planet.population) {
entity.with_children(|planet| {
planet.spawn((
Mesh3d(assets.planet_mesh.clone()),
MeshMaterial3d(city_lights_mat),
// Slightly larger sphere for city lights glow
Transform::from_scale(Vec3::splat(1.02)),
));
});
}
}
// ── Asteroid belts ──────────────────────────────────────────────────────
@@ -853,6 +866,8 @@ pub fn spawn_system_contents(
for station in &contents.stations {
let local = orbital_position(station.orbit, station.phase);
let cube_size = 0.16;
// Use population-based material for visual indicator
let station_mat = population_station_material(materials, station.population);
parent.spawn((
Station,
Orbital {
@@ -871,7 +886,7 @@ pub fn spawn_system_contents(
classification: Classification::Structure,
},
Mesh3d(assets.station_mesh.clone()),
MeshMaterial3d(assets.station_material.clone()),
MeshMaterial3d(station_mat),
Transform::from_translation(local).with_scale(Vec3::splat(cube_size)),
));
}
@@ -1070,6 +1085,56 @@ fn translucent_material(
})
}
/// Create a population-based emissive material for stations.
/// Higher population = brighter, more cyan glow (civilization lights).
fn population_station_material(
materials: &mut Assets<StandardMaterial>,
population: u32,
) -> Handle<StandardMaterial> {
// Base cyan color for stations
let (base_r, base_g, base_b) = (0.13, 0.83, 0.93);
// Scale emissive intensity by population (logarithmic scale)
// Pop 10k -> 1.0x, Pop 10M -> 2.5x
let pop_factor = 1.0 + (population as f32).log10() * 0.5;
let intensity = (pop_factor * 1.4).min(3.5);
materials.add(StandardMaterial {
base_color: Color::srgb(base_r, base_g, base_b),
emissive: LinearRgba::new(base_r * intensity, base_g * intensity, base_b * intensity, 1.0),
unlit: true,
..default()
})
}
/// Create city lights material for high-population planets.
/// Returns Some material if population > 100,000, None otherwise.
fn city_lights_material(
materials: &mut Assets<StandardMaterial>,
planet_type: PlanetType,
population: u32,
) -> Option<Handle<StandardMaterial>> {
// Only show city lights on high-population habitable worlds
const CITY_LIGHTS_THRESHOLD: u32 = 100_000;
if population < CITY_LIGHTS_THRESHOLD {
return None;
}
// Warm yellow-white lights for cities
let (r, g, b) = (1.0, 0.95, 0.8);
// Scale by population
let pop_factor = 1.0 + (population as f32).log10() * 0.3;
let intensity = (pop_factor * 0.8).min(2.0).min(population as f32 / 500_000.0);
Some(materials.add(StandardMaterial {
base_color: Color::srgba(r, g, b, 0.0), // Transparent base
emissive: LinearRgba::new(r * intensity, g * intensity, b * intensity, 1.0),
unlit: true,
alpha_mode: AlphaMode::Blend,
..default()
}))
}
/// Slugify a display name for use as part of an [`Identifiable::id`]:
/// lowercase, spaces → hyphens. Good enough for stable, human-readable IDs.
fn slug(s: &str) -> String {

View File

@@ -112,8 +112,6 @@ 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,
@@ -223,7 +221,6 @@ pub fn generate_galaxy(params: &GalaxyParams) -> GeneratedGalaxy {
impl GeneratedGalaxy {
pub fn starting_base_map(&self) -> StartingBaseMap {
let outer_radius = self.params.size * OUTER_STARTING_SYSTEM_RADIUS;
let mut candidates = Vec::new();
let mut map_systems = Vec::with_capacity(self.systems.len());
@@ -242,7 +239,13 @@ impl GeneratedGalaxy {
for (system, contents) in self.systems.iter().zip(self.contents.iter()) {
let distance_from_core = system.position.length();
if !system.is_core && distance_from_core >= outer_radius {
// A system is valid for starting if it has:
// - At least one station (stations are always dockable)
// - OR at least one inhabited planet (population > 0)
let has_dockable = !contents.stations.is_empty()
|| contents.planets.iter().any(|p| p.population > 0);
if !system.is_core && has_dockable {
candidates.push(StartingBaseCandidate {
id: system.id.clone(),
name: system.name.clone(),
@@ -801,6 +804,7 @@ fn spawn_galaxy_scene(
sys_contents,
star_entity,
&content_assets,
materials,
);
});
}
@@ -938,7 +942,6 @@ mod tests {
fn starting_base_map_uses_saved_galaxy_without_regeneration() {
let galaxy = generate_galaxy(&GalaxyParams::default());
let map = galaxy.starting_base_map();
let outer_radius = galaxy.params.size * OUTER_STARTING_SYSTEM_RADIUS;
assert!(map.candidates.iter().all(|candidate| {
galaxy
@@ -946,10 +949,20 @@ mod tests {
.iter()
.any(|system| system.id == candidate.id)
}));
assert!(map
.candidates
.iter()
.all(|candidate| candidate.distance_from_core >= outer_radius));
// Candidates must have dockable locations (stations or inhabited planets) and not be core systems
assert!(map.candidates.iter().all(|candidate| {
let index = galaxy
.systems
.iter()
.position(|system| system.id == candidate.id);
index.is_some_and(|index| {
let system = &galaxy.systems[index];
let contents = &galaxy.contents[index];
!system.is_core
&& (!contents.stations.is_empty()
|| contents.planets.iter().any(|p| p.population > 0))
})
}));
assert!(map.candidates.iter().all(|candidate| {
let index = galaxy
.systems

View File

@@ -13,7 +13,8 @@ use crate::gameplay::movement::components::{Velocity, MoveTarget};
use crate::gameplay::galaxy::Identifiable;
use super::{DockedState, UndockEvent};
use super::scene::{Docked, PlayerShip};
use super::flight_ui::setup_flight_ui;
// UI removed - no longer needed
// use super::flight_ui::setup_flight_ui;
/// Flight state component attached to the player ship when actively flying.
#[derive(Component, Debug, Clone, Default)]
@@ -53,7 +54,7 @@ fn handle_undock(
mut docked_state: ResMut<DockedState>,
mut camera_state: ResMut<CameraState>,
player_query: Query<(Entity, &Transform), (With<PlayerShip>, With<Docked>)>,
docked_ui_query: Query<Entity, With<super::ui::DockedUi>>,
// docked_ui_query removed - UI no longer needed
) {
for event in events.read() {
bevy::log::info!("Handling undock from station {:?}", event.station_entity);
@@ -86,19 +87,19 @@ fn handle_undock(
// Update docked state resource
docked_state.undock();
// Transition camera to follow mode
// Transition camera to tactical follow mode (isometric view)
camera_state.mode = CameraMode::Follow;
camera_state.target_entity = Some(player_entity);
camera_state.follow_distance = 15.0;
camera_state.follow_height = 5.0;
camera_state.follow_distance = 45.0; // Higher for tactical view
camera_state.follow_height = 35.0; // Isometric angle
// Spawn flight HUD
setup_flight_ui(commands.reborrow());
// UI removed - gameplay only
// setup_flight_ui(commands.reborrow());
// Despawn docked UI
for entity in docked_ui_query.iter() {
commands.entity(entity).despawn();
}
// Despawn docked UI (commented out - UI being removed)
// for entity in docked_ui_query.iter() {
// commands.entity(entity).despawn();
// }
bevy::log::info!("Transitioned to flight mode");
}
@@ -111,7 +112,7 @@ fn handle_docking(
mut docked_state: ResMut<DockedState>,
mut camera_state: ResMut<CameraState>,
identifiable_query: Query<&Identifiable>,
flight_ui_query: Query<Entity, With<super::flight_ui::FlightUi>>,
// flight_ui_query removed - UI no longer needed
) {
for event in events.read() {
bevy::log::info!("Handling docking at target {:?}", event.station);
@@ -137,13 +138,13 @@ fn handle_docking(
camera_state.mode = CameraMode::Cinematic;
camera_state.target_entity = Some(event.station);
// UI removed - no longer needed
// Despawn flight HUD
for entity in flight_ui_query.iter() {
commands.entity(entity).despawn();
}
// for entity in flight_ui_query.iter() {
// commands.entity(entity).despawn();
// }
// Respawn docked UI
super::ui::setup_docked_ui(commands.reborrow());
// super::ui::setup_docked_ui(commands.reborrow());
bevy::log::info!("Docked at {}", identifiable.display_name);
}

View File

@@ -39,24 +39,27 @@ impl Plugin for InSystemPlugin {
OnEnter(AppState::InGame),
(
scene::setup_in_system_view,
ui::setup_docked_ui,
// UI removed - no longer needed
// ui::setup_docked_ui,
add_targetable_to_pois,
).chain(),
)
.add_systems(
OnExit(AppState::InGame),
(
ui::despawn_docked_ui,
flight_ui::despawn_flight_ui,
// UI removed - no longer needed
// ui::despawn_docked_ui,
// flight_ui::despawn_flight_ui,
scene::despawn_in_system_scene,
).chain(),
)
.add_systems(
Update,
(
ui::refresh_docked_ui,
ui::undock_button_handler,
flight_ui::update_flight_ui,
// UI removed - no longer needed
// ui::refresh_docked_ui,
// ui::undock_button_handler,
// flight_ui::update_flight_ui,
handle_action_triggered,
)
.chain()

View File

@@ -248,9 +248,48 @@ fn spawn_system_scene(
contents,
star_entity,
&content_assets,
materials,
);
});
// Spawn tactical grid helper for spatial reference
// Matches the movement demo styling: subtle dark grid
const GRID_SIZE: f32 = 200.0;
const GRID_DIVISIONS: usize = 20;
const GRID_COLOR: Color = Color::srgba(0.05, 0.08, 0.13, 0.5); // #0d1520 with transparency
// Create grid lines
let step = GRID_SIZE / GRID_DIVISIONS as f32;
let half_size = GRID_SIZE * 0.5;
for i in 0..=GRID_DIVISIONS {
let offset = (i as f32 * step) - half_size;
// X-axis line
commands.spawn((
Mesh3d(meshes.add(Cuboid::new(GRID_SIZE, 0.02, 0.02))),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: GRID_COLOR,
unlit: true,
..default()
})),
Transform::from_translation(Vec3::new(0.0, -2.0, offset)),
InSystemSpawned,
));
// Z-axis line
commands.spawn((
Mesh3d(meshes.add(Cuboid::new(0.02, 0.02, GRID_SIZE))),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: GRID_COLOR,
unlit: true,
..default()
})),
Transform::from_translation(Vec3::new(offset, -2.0, 0.0)),
InSystemSpawned,
));
}
// If we have a docking target, spawn player ship docked at it
let station_entity = if let Some(target) = docking_target {
// Calculate target position

View File

@@ -1,8 +1,10 @@
pub mod ai;
pub mod campaign;
pub mod character_creation;
pub mod galaxy;
pub mod in_system;
pub mod movement;
pub mod narrative;
pub mod physics;
pub mod star_map;
pub mod starting_base;

View File

@@ -0,0 +1,212 @@
//! Event logging system.
//!
//! Captures all player actions as structured events that become
//! the foundation for narrative generation.
use bevy::prelude::*;
use std::collections::HashMap;
use crate::gameplay::narrative::CampaignHistory;
/// All possible game events that can occur in a campaign.
#[derive(Event, Debug, Clone)]
pub enum GameEvent {
/// Player mined ore from an asteroid belt
Mining(MiningEvent),
/// Player bought or sold items at a station
Trade(TradeEvent),
/// Player engaged in combat
Combat(CombatEvent),
/// Player discovered a new system or POI
Exploration(ExplorationEvent),
/// Player docked at a station
Docking(DockingEvent),
/// Player accepted/abandoned/failed a mission
Mission(MissionEvent),
/// Custom event for extensible storytelling
Custom(CustomEvent),
}
/// Event from mining activities.
#[derive(Debug, Clone)]
pub struct MiningEvent {
pub timestamp: f64,
pub location: String,
pub ore_type: String,
pub quantity: u32,
pub duration_seconds: f32,
}
/// Event from trading activities.
#[derive(Debug, Clone)]
pub struct TradeEvent {
pub timestamp: f64,
pub station: String,
pub faction: String,
pub is_purchase: bool,
pub item_name: String,
pub quantity: u32,
pub unit_price: f32,
pub total_value: f32,
}
/// Event from combat activities.
#[derive(Debug, Clone)]
pub struct CombatEvent {
pub timestamp: f64,
pub location: String,
pub opponent: String,
pub opponent_faction: Option<String>,
pub outcome: CombatOutcome,
pub hull_remaining: f32,
pub rewards_earned: Option<f32>,
}
/// Outcome of a combat engagement.
#[derive(Debug, Clone)]
pub enum CombatOutcome {
Victory,
Defeat,
Retreat,
Draw,
}
/// Event from exploration activities.
#[derive(Debug, Clone)]
pub struct ExplorationEvent {
pub timestamp: f64,
pub location: String,
pub discovery_type: DiscoveryType,
pub description: String,
}
/// Types of discoveries.
#[derive(Debug, Clone)]
pub enum DiscoveryType {
NewSystem,
NewStation,
Anomaly,
Derelict,
ResourceDeposit,
}
/// Event from docking activities.
#[derive(Debug, Clone)]
pub struct DockingEvent {
pub timestamp: f64,
pub station: String,
pub system: String,
pub faction: String,
pub visit_duration_seconds: f32,
}
/// Event from mission activities.
#[derive(Debug, Clone)]
pub struct MissionEvent {
pub timestamp: f64,
pub mission_id: String,
pub mission_type: String,
pub event_type: MissionEventType,
pub outcome: Option<MissionOutcome>,
}
/// Type of mission event.
#[derive(Debug, Clone)]
pub enum MissionEventType {
Accepted,
Completed,
Abandoned,
Failed,
}
/// Outcome of a completed mission.
#[derive(Debug, Clone)]
pub enum MissionOutcome {
Success,
PartialSuccess,
Failure,
}
/// Custom event for extensible storytelling.
#[derive(Debug, Clone)]
pub struct CustomEvent {
pub timestamp: f64,
pub event_type: String,
pub title: String,
pub description: String,
pub metadata: HashMap<String, String>,
}
impl GameEvent {
/// Get the timestamp of this event.
pub fn timestamp(&self) -> f64 {
match self {
GameEvent::Mining(e) => e.timestamp,
GameEvent::Trade(e) => e.timestamp,
GameEvent::Combat(e) => e.timestamp,
GameEvent::Exploration(e) => e.timestamp,
GameEvent::Docking(e) => e.timestamp,
GameEvent::Mission(e) => e.timestamp,
GameEvent::Custom(e) => e.timestamp,
}
}
/// Get a short title for this event.
pub fn title(&self) -> String {
match self {
GameEvent::Mining(e) => format!("Mined {} {}", e.quantity, e.ore_type),
GameEvent::Trade(e) => {
if e.is_purchase {
format!("Purchased {} {}", e.quantity, e.item_name)
} else {
format!("Sold {} {}", e.quantity, e.item_name)
}
}
GameEvent::Combat(e) => match e.outcome {
CombatOutcome::Victory => format!("Victory over {}", e.opponent),
CombatOutcome::Defeat => format!("Defeated by {}", e.opponent),
CombatOutcome::Retreat => format!("Retreated from {}", e.opponent),
CombatOutcome::Draw => format!("Draw with {}", e.opponent),
},
GameEvent::Exploration(e) => match e.discovery_type {
DiscoveryType::NewSystem => format!("Discovered system: {}", e.location),
DiscoveryType::NewStation => format!("Discovered station: {}", e.location),
DiscoveryType::Anomaly => format!("Found anomaly: {}", e.description),
DiscoveryType::Derelict => format!("Found derelict: {}", e.description),
DiscoveryType::ResourceDeposit => format!("Found resource: {}", e.description),
},
GameEvent::Docking(e) => format!("Docked at {}", e.station),
GameEvent::Mission(e) => format!(
"{:?} mission: {}",
e.event_type, e.mission_id
),
GameEvent::Custom(e) => e.title.clone(),
}
}
/// Get the location where this event occurred.
pub fn location(&self) -> String {
match self {
GameEvent::Mining(e) => e.location.clone(),
GameEvent::Trade(e) => e.station.clone(),
GameEvent::Combat(e) => e.location.clone(),
GameEvent::Exploration(e) => e.location.clone(),
GameEvent::Docking(e) => e.system.clone(),
GameEvent::Mission(e) => "Unknown".to_string(),
GameEvent::Custom(e) => e.metadata.get("location").cloned().unwrap_or_default(),
}
}
}
/// Log game events to campaign history.
///
/// This system listens for GameEvents and adds them to the CampaignHistory.
pub fn log_game_events(
mut events: EventReader<GameEvent>,
mut history: ResMut<CampaignHistory>,
) {
for event in events.read() {
bevy::log::debug!("Logging game event: {}", event.title());
history.add_event(event.clone());
}
}

View File

@@ -0,0 +1,313 @@
//! Campaign history tracking.
//!
//! Maintains the timeline of all events in a campaign with chapter
//! divisions and key moment detection.
use bevy::prelude::*;
use std::collections::HashMap;
use crate::gameplay::narrative::{GameEvent, NarrativeConfig};
/// Complete campaign history with timeline and chapters.
#[derive(Resource, Debug, Clone)]
pub struct CampaignHistory {
/// All events in chronological order
pub events: Vec<GameEvent>,
/// Detected chapters in the campaign
pub chapters: Vec<Chapter>,
/// Key moments (significant events)
pub key_moments: Vec<KeyMoment>,
/// Campaign statistics
pub statistics: CampaignStatistics,
/// Time since last chapter detection
pub time_since_chapter_check: f32,
}
impl Default for CampaignHistory {
fn default() -> Self {
Self {
events: Vec::new(),
chapters: Vec::new(),
key_moments: Vec::new(),
statistics: CampaignStatistics::default(),
time_since_chapter_check: 0.0,
}
}
}
impl CampaignHistory {
/// Add a new event to the campaign history.
pub fn add_event(&mut self, event: GameEvent) {
// Update statistics before consuming the event
self.update_statistics(&event);
// Check if this is a key moment
if self.is_key_moment(&event) {
self.key_moments.push(KeyMoment {
event_index: self.events.len(),
timestamp: event.timestamp(),
significance: self.calculate_significance(&event),
description: event.title(),
});
}
// Finally, consume the event by pushing it to the history
self.events.push(event);
}
/// Get the most recent N events.
pub fn recent_events(&self, count: usize) -> &[GameEvent] {
let start = self.events.len().saturating_sub(count);
&self.events[start..]
}
/// Get all events since a given timestamp.
pub fn events_since(&self, timestamp: f64) -> &[GameEvent] {
let start = self
.events
.partition_point(|e| e.timestamp() < timestamp);
&self.events[start..]
}
/// Check if an event is a key moment.
fn is_key_moment(&self, event: &GameEvent) -> bool {
match event {
GameEvent::Combat(e) => {
matches!(e.outcome, crate::gameplay::narrative::CombatOutcome::Victory)
&& e.hull_remaining < 0.3
}
GameEvent::Exploration(e) => {
matches!(
e.discovery_type,
crate::gameplay::narrative::DiscoveryType::NewSystem
)
}
GameEvent::Mission(e) => {
matches!(
e.event_type,
crate::gameplay::narrative::MissionEventType::Completed
)
}
_ => false,
}
}
/// Calculate the significance score of an event (0.0 to 1.0).
fn calculate_significance(&self, event: &GameEvent) -> f32 {
match event {
GameEvent::Combat(e) => {
let base = 0.7;
let bonus = if e.hull_remaining < 0.3 { 0.2 } else { 0.0 };
base + bonus
}
GameEvent::Exploration(_) => 0.6,
GameEvent::Mission(_) => 0.5,
GameEvent::Trade(_) => 0.2,
GameEvent::Mining(_) => 0.1,
GameEvent::Docking(_) => 0.05,
GameEvent::Custom(_) => 0.4,
}
}
/// Update campaign statistics based on a new event.
fn update_statistics(&mut self, event: &GameEvent) {
match event {
GameEvent::Mining(_) => self.statistics.mining_operations += 1,
GameEvent::Trade(_) => self.statistics.trade_operations += 1,
GameEvent::Combat(e) => {
self.statistics.combat_operations += 1;
match e.outcome {
crate::gameplay::narrative::CombatOutcome::Victory => {
self.statistics.combat_victories += 1
}
crate::gameplay::narrative::CombatOutcome::Defeat => {
self.statistics.combat_defeats += 1
}
_ => {}
}
}
GameEvent::Exploration(_) => self.statistics.discoveries += 1,
GameEvent::Mission(_) => self.statistics.missions_completed += 1,
GameEvent::Docking(_) => self.statistics.docking_operations += 1,
GameEvent::Custom(_) => {}
}
}
}
/// A chapter in the campaign narrative.
#[derive(Debug, Clone)]
pub struct Chapter {
/// Chapter index (0-based)
pub index: usize,
/// Chapter title
pub title: String,
/// Start event index
pub start_event: usize,
/// End event index (exclusive)
pub end_event: usize,
/// Primary theme of this chapter
pub theme: ChapterTheme,
/// Summary of events in this chapter
pub summary: String,
/// Timestamp when chapter started
pub start_timestamp: f64,
}
/// Themes that chapters can have.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChapterTheme {
Exploration,
Commerce,
Conflict,
Diplomacy,
Mystery,
Survival,
Growth,
}
/// A key moment in the campaign.
#[derive(Debug, Clone)]
pub struct KeyMoment {
/// Index of the event in the campaign history
pub event_index: usize,
/// When this occurred
pub timestamp: f64,
/// How significant (0.0 to 1.0)
pub significance: f32,
/// Description
pub description: String,
}
/// Campaign statistics.
#[derive(Debug, Clone, Default)]
pub struct CampaignStatistics {
/// Total events logged
pub total_events: usize,
/// Mining operations performed
pub mining_operations: u32,
/// Trade operations performed
pub trade_operations: u32,
/// Combat operations performed
pub combat_operations: u32,
/// Combat victories
pub combat_victories: u32,
/// Combat defeats
pub combat_defeats: u32,
/// Discoveries made
pub discoveries: u32,
/// Missions completed
pub missions_completed: u32,
/// Docking operations
pub docking_operations: u32,
}
/// Detect chapters in the campaign history.
///
/// This system runs periodically and analyzes the event stream to
/// identify natural chapter boundaries based on event patterns.
pub fn detect_chapters(
mut history: ResMut<CampaignHistory>,
config: Res<NarrativeConfig>,
time: Res<Time>,
) {
history.time_since_chapter_check += time.delta_secs();
// Only check every 10 seconds
if history.time_since_chapter_check < 10.0 {
return;
}
history.time_since_chapter_check = 0.0;
// Only detect chapters if we have enough events
if history.events.len() < config.events_per_chapter {
return;
}
// Calculate expected chapter count
let expected_chapters = history.events.len() / config.events_per_chapter;
// Add new chapter if needed
if history.chapters.len() < expected_chapters {
let start_index = if let Some(last_chapter) = history.chapters.last() {
last_chapter.end_event
} else {
0
};
let end_index = (start_index + config.events_per_chapter).min(history.events.len());
// Determine theme from events in this range
let theme = detect_chapter_theme(&history.events[start_index..end_index]);
let chapter = Chapter {
index: history.chapters.len(),
title: format!("Chapter {}: {:?}", history.chapters.len() + 1, theme),
start_event: start_index,
end_event: end_index,
theme,
summary: generate_chapter_summary(&history.events[start_index..end_index]),
start_timestamp: history
.events
.get(start_index)
.map(|e| e.timestamp())
.unwrap_or(0.0),
};
bevy::log::info!("Detected new chapter: {}", chapter.title);
history.chapters.push(chapter);
}
}
/// Detect the theme of a chapter from its events.
fn detect_chapter_theme(events: &[GameEvent]) -> ChapterTheme {
let mut counts = HashMap::new();
for event in events {
let category = match event {
GameEvent::Mining(_) => "commerce",
GameEvent::Trade(_) => "commerce",
GameEvent::Combat(_) => "conflict",
GameEvent::Exploration(_) => "exploration",
GameEvent::Docking(_) => "commerce",
GameEvent::Mission(_) => "growth",
GameEvent::Custom(_) => "mystery",
};
*counts.entry(category).or_insert(0) += 1;
}
let max_category = counts
.into_iter()
.max_by_key(|(_, count)| *count)
.map(|(cat, _)| cat)
.unwrap_or("exploration");
match max_category {
"exploration" => ChapterTheme::Exploration,
"commerce" => ChapterTheme::Commerce,
"conflict" => ChapterTheme::Conflict,
"growth" => ChapterTheme::Growth,
"mystery" => ChapterTheme::Mystery,
_ => ChapterTheme::Exploration,
}
}
/// Generate a summary for a chapter from its events.
fn generate_chapter_summary(events: &[GameEvent]) -> String {
if events.is_empty() {
return "Empty chapter".to_string();
}
let start = events.first().unwrap();
let end = events.last().unwrap();
let duration_hours = (end.timestamp() - start.timestamp()) / 3600.0;
format!(
"Events from {:.1} hours of gameplay. Started with {}, ended with {}.",
duration_hours,
start.title(),
end.title()
)
}

View File

@@ -0,0 +1,70 @@
//! Narrative system for AI-generated storytelling.
//!
//! This module provides the foundation for the AI Story Director,
//! which tracks player actions and weaves them into an ongoing narrative.
//!
//! ## Components
//!
//! - **Event Logging**: Capture all player actions as structured events
//! - **Campaign History**: Maintain timeline of events with chapter divisions
//! - **Narrative Synthesis**: Generate story text from events (LLM-powered)
//! - **Story UI**: Display campaign narrative to players
mod events;
mod history;
mod synthesis;
mod ui;
pub use events::*;
pub use history::*;
pub use synthesis::*;
pub use ui::*;
use bevy::prelude::*;
use crate::state::AppState;
/// Main narrative plugin.
pub struct NarrativePlugin;
impl Plugin for NarrativePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<NarrativeConfig>()
.init_resource::<CampaignHistory>()
.add_event::<GameEvent>()
// Narrative systems run during gameplay
.add_systems(
Update,
events::log_game_events.run_if(in_state(AppState::InGame)),
)
// Chapter detection runs periodically
.add_systems(
Update,
history::detect_chapters.run_if(in_state(AppState::InGame)),
);
}
}
/// Configuration for narrative generation.
#[derive(Resource, Debug, Clone)]
pub struct NarrativeConfig {
/// Whether narrative generation is enabled
pub enabled: bool,
/// Events per chapter (approximate)
pub events_per_chapter: usize,
/// Whether to use LLM for synthesis (when available)
pub use_llm_synthesis: bool,
/// Update interval for story generation (seconds)
pub synthesis_interval: f32,
}
impl Default for NarrativeConfig {
fn default() -> Self {
Self {
enabled: true,
events_per_chapter: 25,
use_llm_synthesis: false, // Disabled until LLM integration
synthesis_interval: 60.0,
}
}
}

View File

@@ -0,0 +1,51 @@
//! Narrative synthesis engine.
//!
//! TODO: LLM-based narrative generation that weaves events into
//! coherent story text.
//!
//! This module will:
//! - Aggregate events into story beats
//! - Generate narrative text using LLM API (Claude)
//! - Maintain story coherence and continuity
//! - Handle character dialogue and scene descriptions
use bevy::prelude::*;
/// Placeholder for narrative synthesis functionality.
///
/// Full implementation will require:
/// - Event aggregation and pattern detection
/// - LLM API integration (Claude API)
/// - Story template system
/// - Character personality modeling
pub struct NarrativeSynthesis {
/// LLM API endpoint (when available)
pub api_endpoint: Option<String>,
/// Whether synthesis is available
pub available: bool,
}
impl Default for NarrativeSynthesis {
fn default() -> Self {
Self {
api_endpoint: None,
available: false,
}
}
}
/// Generate narrative text from events.
///
/// TODO: Implement LLM-based synthesis.
pub fn generate_narrative(_events: &[crate::gameplay::narrative::GameEvent]) -> String {
// Placeholder: returns a simple summary
// Full implementation will use LLM to generate engaging narrative
"Narrative synthesis not yet implemented. This will use the Claude API to generate compelling story text from campaign events.".to_string()
}
/// Generate chapter summary.
///
/// TODO: Implement with LLM synthesis.
pub fn generate_chapter_summary(_chapter: &super::Chapter) -> String {
"Chapter summary generation not yet implemented.".to_string()
}

View File

@@ -0,0 +1,64 @@
//! Story log UI system.
//!
//! TODO: In-game interface for reading campaign narrative with
//! chapter navigation and search functionality.
//!
//! This module will:
//! - Display story log with chapter divisions
//! - Support chapter navigation
//! - Provide search and filtering
//! - Allow export/save of campaign story
use bevy::prelude::*;
/// Component for the story log UI root.
#[derive(Component)]
pub struct StoryLogUi;
/// Component for chapter list items.
#[derive(Component)]
pub struct ChapterListItem {
/// Chapter index
pub index: usize,
}
/// Component for event detail items.
#[derive(Component)]
pub struct EventDetailItem {
/// Event index
pub event_index: usize,
}
/// Setup the story log UI.
///
/// TODO: Implement full UI with:
/// - Chapter list with titles and themes
/// - Event timeline display
/// - Chapter navigation buttons
/// - Search/filter controls
/// - Export button
pub fn setup_story_log_ui(_commands: &mut Commands) {
// Placeholder: UI setup to be implemented
bevy::log::info!("Story log UI setup not yet implemented");
}
/// Update the story log UI with current narrative data.
///
/// TODO: Implement UI update logic.
pub fn update_story_log_ui() {
// Placeholder: UI update to be implemented
}
/// Handle chapter navigation clicks.
///
/// TODO: Implement chapter navigation.
pub fn handle_chapter_navigation() {
// Placeholder: Navigation handling to be implemented
}
/// Export campaign story to file.
///
/// TODO: Implement export functionality.
pub fn export_campaign_story(_history: &super::CampaignHistory) -> Result<(), String> {
Err("Export not yet implemented".to_string())
}

View File

@@ -176,6 +176,7 @@ pub fn spawn_selected_pois(
system_contents,
star_entity,
&content_assets,
&mut materials,
);
});

View File

@@ -8,9 +8,9 @@ use bevy::prelude::*;
use camera::{orbit_camera_control, follow_camera_system, CameraState};
use gameplay::campaign::CampaignDraft;
use gameplay::{
character_creation::CharacterCreationPlugin, galaxy::GalaxyPlugin, in_system::InSystemPlugin,
movement::MovementPlugin, physics::PhysicsPlugin, star_map::StarMapPlugin,
starting_base::StartingBasePlugin,
ai::AiPlugin, character_creation::CharacterCreationPlugin, galaxy::GalaxyPlugin,
in_system::InSystemPlugin, movement::MovementPlugin, narrative::NarrativePlugin,
physics::PhysicsPlugin, star_map::StarMapPlugin, starting_base::StartingBasePlugin,
};
use state::AppState;
use ui::main_menu;
@@ -50,6 +50,8 @@ fn main() {
CharacterCreationPlugin,
StartingBasePlugin,
InSystemPlugin,
AiPlugin,
NarrativePlugin,
))
.run();
}