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:
@@ -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;
|
||||
|
||||
355
apps/game/src/gameplay/ai/behavior.rs
Normal file
355
apps/game/src/gameplay/ai/behavior.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
115
apps/game/src/gameplay/ai/faction.rs
Normal file
115
apps/game/src/gameplay/ai/faction.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
87
apps/game/src/gameplay/ai/mod.rs
Normal file
87
apps/game/src/gameplay/ai/mod.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
120
apps/game/src/gameplay/ai/navigation.rs
Normal file
120
apps/game/src/gameplay/ai/navigation.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
162
apps/game/src/gameplay/ai/perception.rs
Normal file
162
apps/game/src/gameplay/ai/perception.rs
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
311
apps/game/src/gameplay/ai/spawning.rs
Normal file
311
apps/game/src/gameplay/ai/spawning.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
207
apps/game/src/gameplay/ai/states.rs
Normal file
207
apps/game/src/gameplay/ai/states.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
212
apps/game/src/gameplay/narrative/events.rs
Normal file
212
apps/game/src/gameplay/narrative/events.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
313
apps/game/src/gameplay/narrative/history.rs
Normal file
313
apps/game/src/gameplay/narrative/history.rs
Normal 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()
|
||||
)
|
||||
}
|
||||
70
apps/game/src/gameplay/narrative/mod.rs
Normal file
70
apps/game/src/gameplay/narrative/mod.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
51
apps/game/src/gameplay/narrative/synthesis.rs
Normal file
51
apps/game/src/gameplay/narrative/synthesis.rs
Normal 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()
|
||||
}
|
||||
64
apps/game/src/gameplay/narrative/ui.rs
Normal file
64
apps/game/src/gameplay/narrative/ui.rs
Normal 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())
|
||||
}
|
||||
@@ -176,6 +176,7 @@ pub fn spawn_selected_pois(
|
||||
system_contents,
|
||||
star_entity,
|
||||
&content_assets,
|
||||
&mut materials,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user