mirror of
https://github.com/Xevion/smart-rgb.git
synced 2025-12-06 13:16:31 -06:00
599 lines
29 KiB
Rust
599 lines
29 KiB
Rust
//! Consolidated game plugin integrating all core systems
|
|
//!
|
|
//! This module provides the main `GamePlugin` which sets up all game logic including:
|
|
//! - Networking (local or remote)
|
|
//! - Spawn phase management
|
|
//! - Core game systems and event handling
|
|
//! - Turn execution and processing
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use bevy_ecs::prelude::*;
|
|
use tracing::{debug, info, trace};
|
|
|
|
use crate::app::{App, Last, Plugin, Update};
|
|
use crate::game::ships::{LaunchShipEvent, ShipArrivalEvent, handle_ship_arrivals_system, launch_ship_system, update_ships_system};
|
|
use crate::game::{BorderCache, NationId, TerrainData, clear_territory_changes_system};
|
|
use crate::networking::SpawnConfigEvent;
|
|
use crate::networking::server::TurnGenerator;
|
|
use crate::networking::{IntentEvent, ProcessTurnEvent};
|
|
use crate::time::{FixedTime, Time};
|
|
|
|
use crate::game::core::constants::game::TICK_INTERVAL;
|
|
use crate::game::{AttackControls, CurrentTurn, SpawnPhase, SpawnTimeout, check_local_player_outcome, handle_attack_click_system, handle_attack_ratio_keys_system, handle_center_camera_system, handle_spawn_click_system, handle_spawns_system, process_and_apply_actions_system, process_player_income_system, tick_attacks_system, turn_is_ready, update_player_borders_system};
|
|
use crate::networking::client::IntentReceiver;
|
|
use crate::networking::server::{TurnReceiver, generate_turns_system, poll_turns_system};
|
|
|
|
#[cfg(feature = "ui")]
|
|
use crate::ui::{emit_attacks_update_system, emit_leaderboard_snapshot_system, emit_nation_highlight_system, emit_ships_update_system};
|
|
|
|
// Re-export protocol types for convenience
|
|
#[cfg(feature = "ui")]
|
|
use crate::ui::protocol::{BackendMessage, SpawnCountdown};
|
|
|
|
/// Network mode configuration for the game
|
|
pub enum NetworkMode {
|
|
/// Local single-player or hotseat mode
|
|
Local,
|
|
/// Remote multiplayer mode (non-WASM only)
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
Remote { server_address: String },
|
|
}
|
|
|
|
/// Main game plugin that consolidates all core game logic
|
|
///
|
|
/// This plugin sets up:
|
|
/// - Network channels (local or remote)
|
|
/// - Spawn phase management
|
|
/// - Core game systems
|
|
/// - Turn processing
|
|
/// - Input handling
|
|
pub struct GamePlugin {
|
|
pub network_mode: NetworkMode,
|
|
}
|
|
|
|
impl GamePlugin {
|
|
pub fn new(network_mode: NetworkMode) -> Self {
|
|
Self { network_mode }
|
|
}
|
|
}
|
|
|
|
impl Plugin for GamePlugin {
|
|
fn build(&self, app: &mut App) {
|
|
let _guard = tracing::debug_span!("game_plugin_build").entered();
|
|
|
|
// Setup networking based on mode
|
|
match &self.network_mode {
|
|
NetworkMode::Local => {
|
|
let _guard = tracing::trace_span!("network_setup", mode = "local").entered();
|
|
info!("Initializing GamePlugin in Local mode");
|
|
|
|
// Local mode: use tracked intent channel for turn coordination
|
|
let (tracked_intent_tx, tracked_intent_rx) = flume::unbounded();
|
|
|
|
// Note: Turn channels (turn_tx/turn_rx) are created later in initialize_game_resources
|
|
// when StartGame is called. Connection doesn't need turn_rx yet.
|
|
|
|
// Create a placeholder receiver that will be replaced when game starts
|
|
let (_placeholder_tx, placeholder_rx) = flume::unbounded();
|
|
|
|
// Create LocalBackend for single-player mode
|
|
let backend = crate::networking::client::LocalBackend::new(
|
|
tracked_intent_tx,
|
|
placeholder_rx,
|
|
NationId::ZERO, // Local player is always NationId::ZERO
|
|
);
|
|
|
|
// Create Connection resource with LocalBackend
|
|
let connection = crate::networking::client::Connection::new_local(backend);
|
|
|
|
app.insert_resource(connection).insert_resource(IntentReceiver { rx: tracked_intent_rx }).add_systems(Update, (poll_turns_system.before(update_current_turn_system), crate::networking::client::send_intent_system)).add_systems(Last, clear_territory_changes_system);
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
NetworkMode::Remote { server_address: _ } => {
|
|
// TODO: Remote networking currently disabled due to bincode incompatibility with glam types
|
|
unimplemented!("Remote networking temporarily disabled");
|
|
/*
|
|
let _guard = tracing::trace_span!("network_setup", mode = "remote", server = %server_address).entered();
|
|
info!(
|
|
"Initializing GamePlugin in Remote mode (server: {})",
|
|
server_address
|
|
);
|
|
|
|
// Remote mode: use NetMessage protocol
|
|
let (net_intent_tx, net_intent_rx) = flume::unbounded();
|
|
let (net_message_tx, net_message_rx) = flume::unbounded();
|
|
|
|
app.insert_resource(crate::networking::client::RemoteClientConnection {
|
|
intent_tx: net_intent_tx,
|
|
net_message_rx,
|
|
player_id: None,
|
|
})
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
crate::networking::client::systems::send_net_intent_system,
|
|
crate::networking::client::systems::receive_net_message_system,
|
|
crate::networking::client::systems::handle_spawn_config_system,
|
|
),
|
|
);
|
|
|
|
// Spawn networking thread
|
|
let server_addr = server_address.clone();
|
|
let server_addr_span = server_addr.clone();
|
|
std::thread::spawn(move || {
|
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.unwrap();
|
|
|
|
runtime.block_on(
|
|
async move {
|
|
use crate::networking::protocol::NetMessage;
|
|
use tracing::error;
|
|
|
|
info!("Connecting to remote server at {}", server_addr);
|
|
|
|
// Load server certificate for validation
|
|
let cert_path = "dev-cert.pem";
|
|
let cert_data = match std::fs::read(cert_path) {
|
|
Ok(data) => data,
|
|
Err(e) => {
|
|
error!("Failed to read certificate file {}: {}", cert_path, e);
|
|
error!("Please run the `generate-dev-cert.ps1` script first.");
|
|
return;
|
|
}
|
|
};
|
|
|
|
let pem =
|
|
pem::parse(&cert_data).expect("Failed to parse PEM certificate");
|
|
let cert_hash =
|
|
ring::digest::digest(&ring::digest::SHA256, pem.contents())
|
|
.as_ref()
|
|
.to_vec();
|
|
|
|
let client = web_transport::ClientBuilder::new()
|
|
.with_server_certificate_hashes(vec![cert_hash])
|
|
.expect("Failed to create client with certificate hash");
|
|
|
|
let mut connection =
|
|
match client.connect(server_addr.parse().unwrap()).await {
|
|
Ok(conn) => {
|
|
info!("Connected to server successfully");
|
|
conn
|
|
}
|
|
Err(e) => {
|
|
error!("Failed to connect to server: {}", e);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let (mut send_stream, mut recv_stream) =
|
|
match connection.open_bi().await {
|
|
Ok(streams) => {
|
|
info!("Opened bidirectional stream");
|
|
streams
|
|
}
|
|
Err(e) => {
|
|
error!("Failed to open bidirectional stream: {}", e);
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Read initial ServerConfig
|
|
info!("Reading initial server config...");
|
|
let mut len_bytes = Vec::new();
|
|
while len_bytes.len() < 8 {
|
|
let remaining = 8 - len_bytes.len();
|
|
match recv_stream.read(remaining).await {
|
|
Ok(Some(chunk)) => {
|
|
len_bytes.extend_from_slice(&chunk);
|
|
}
|
|
Ok(None) => {
|
|
error!("Stream closed before reading server config length");
|
|
return;
|
|
}
|
|
Err(e) => {
|
|
error!("Failed to read server config length: {}", e);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
let len =
|
|
u64::from_be_bytes(len_bytes[0..8].try_into().unwrap()) as usize;
|
|
|
|
let mut message_bytes = Vec::new();
|
|
while message_bytes.len() < len {
|
|
let remaining = len - message_bytes.len();
|
|
match recv_stream.read(remaining).await {
|
|
Ok(Some(chunk)) => {
|
|
message_bytes.extend_from_slice(&chunk);
|
|
}
|
|
Ok(None) => {
|
|
error!("Stream closed before reading server config data");
|
|
return;
|
|
}
|
|
Err(e) => {
|
|
error!("Failed to read server config data: {}", e);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
match bincode::decode_from_slice(
|
|
&message_bytes,
|
|
bincode::config::standard(),
|
|
) {
|
|
Ok((net_message, _)) => {
|
|
info!("Received server config: {:?}", net_message);
|
|
match net_message {
|
|
NetMessage::ServerConfig { player_id } => {
|
|
info!("Assigned player ID: {}", player_id);
|
|
}
|
|
_ => {
|
|
error!("Expected ServerConfig, got: {:?}", net_message);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
error!("Failed to decode server config: {}", e);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Send intents to server
|
|
let send_task = async {
|
|
while let Ok(net_message) = net_intent_rx.recv_async().await {
|
|
match bincode::encode_to_vec(
|
|
net_message,
|
|
bincode::config::standard(),
|
|
) {
|
|
Ok(message_bytes) => {
|
|
let len_bytes =
|
|
(message_bytes.len() as u64).to_be_bytes();
|
|
|
|
let mut written = 0;
|
|
while written < len_bytes.len() {
|
|
match send_stream.write(&len_bytes[written..]).await
|
|
{
|
|
Ok(bytes_written) => written += bytes_written,
|
|
Err(e) => {
|
|
error!(
|
|
"Failed to send length prefix: {}",
|
|
e
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut written = 0;
|
|
while written < message_bytes.len() {
|
|
match send_stream
|
|
.write(&message_bytes[written..])
|
|
.await
|
|
{
|
|
Ok(bytes_written) => written += bytes_written,
|
|
Err(e) => {
|
|
error!("Failed to send message: {}", e);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
error!("Failed to encode message: {}", e);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Receive messages from server
|
|
let recv_task = async {
|
|
loop {
|
|
let mut len_bytes = Vec::new();
|
|
while len_bytes.len() < 8 {
|
|
let remaining = 8 - len_bytes.len();
|
|
if let Ok(maybe_chunk) = recv_stream.read(remaining).await {
|
|
if let Some(chunk) = maybe_chunk {
|
|
len_bytes.extend_from_slice(&chunk);
|
|
} else {
|
|
break;
|
|
}
|
|
} else {
|
|
error!("Stream closed before reading length prefix");
|
|
break;
|
|
}
|
|
}
|
|
let len =
|
|
u64::from_be_bytes(len_bytes[0..8].try_into().unwrap())
|
|
as usize;
|
|
|
|
let mut message_bytes = Vec::new();
|
|
while message_bytes.len() < len {
|
|
let remaining = len - message_bytes.len();
|
|
if let Ok(maybe_chunk) = recv_stream.read(remaining).await {
|
|
if let Some(chunk) = maybe_chunk {
|
|
message_bytes.extend_from_slice(&chunk);
|
|
} else {
|
|
break;
|
|
}
|
|
} else {
|
|
error!("Stream closed before reading full message");
|
|
break;
|
|
}
|
|
}
|
|
|
|
match bincode::decode_from_slice(
|
|
&message_bytes,
|
|
bincode::config::standard(),
|
|
) {
|
|
Ok((net_message, _)) => {
|
|
if net_message_tx.send_async(net_message).await.is_err()
|
|
{
|
|
error!("Failed to forward message to client");
|
|
break;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
error!("Failed to decode message: {}", e);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
futures_lite::future::zip(send_task, recv_task).await;
|
|
error!("Connection to server closed");
|
|
}
|
|
.instrument(
|
|
tracing::trace_span!("remote_connection", server = %server_addr_span),
|
|
),
|
|
);
|
|
});
|
|
*/
|
|
}
|
|
}
|
|
|
|
// Configure fixed timestep for game logic (10 TPS = 100ms)
|
|
app.insert_resource(FixedTime::from_seconds(TICK_INTERVAL as f64 / 1000.0));
|
|
|
|
// Core multiplayer events and resources
|
|
app.add_message::<IntentEvent>().add_message::<ProcessTurnEvent>().add_message::<SpawnConfigEvent>().add_message::<LaunchShipEvent>().add_message::<ShipArrivalEvent>();
|
|
|
|
// UI-related events and resources (feature-gated)
|
|
#[cfg(feature = "ui")]
|
|
{
|
|
use crate::ui::{DisplayOrderUpdateCounter, LastAttacksDigest, LastDisplayOrder, LastLeaderboardDigest, LeaderboardThrottle, NationHighlightState, ShipStateTracker};
|
|
|
|
app.init_resource::<LastLeaderboardDigest>().init_resource::<LastAttacksDigest>().init_resource::<LeaderboardThrottle>().init_resource::<DisplayOrderUpdateCounter>().init_resource::<LastDisplayOrder>().init_resource::<NationHighlightState>().init_resource::<ShipStateTracker>();
|
|
}
|
|
|
|
// Input-related resources
|
|
app.init_resource::<SpawnPhase>().init_resource::<AttackControls>().init_resource::<BorderCache>();
|
|
|
|
// Spawn phase management
|
|
app.init_resource::<SpawnPhaseInitialized>().init_resource::<PreviousSpawnState>().add_systems(Update, (emit_initial_spawn_phase_system, manage_spawn_phase_system));
|
|
|
|
// Core game logic systems (run in Update, event-driven)
|
|
app.add_systems(
|
|
Update,
|
|
(
|
|
// Step 1: Receive turn events and update CurrentTurn resource
|
|
update_current_turn_system,
|
|
// Step 2: Execute gameplay systems only when turn is ready (10 TPS)
|
|
process_player_income_system.run_if(turn_is_ready),
|
|
)
|
|
.chain(),
|
|
);
|
|
|
|
// Turn execution systems - split into two chains due to Bevy's tuple size limit
|
|
// Must run after update_current_turn_system so CurrentTurn exists for turn_is_ready check
|
|
app.add_systems(Update, (process_and_apply_actions_system, tick_attacks_system, handle_spawns_system, launch_ship_system).chain().after(update_current_turn_system).run_if(turn_is_ready));
|
|
|
|
app.add_systems(
|
|
Update,
|
|
(update_ships_system, handle_ship_arrivals_system, check_local_player_outcome, update_player_borders_system)
|
|
.chain()
|
|
// Runs after entire first chain (which includes handle_spawns_system)
|
|
.after(launch_ship_system)
|
|
.run_if(turn_is_ready),
|
|
);
|
|
|
|
// UI update systems (feature-gated)
|
|
#[cfg(feature = "ui")]
|
|
app.add_systems(Update, (emit_leaderboard_snapshot_system, emit_attacks_update_system, emit_ships_update_system, emit_nation_highlight_system));
|
|
|
|
// Command handlers
|
|
#[cfg(feature = "ui")]
|
|
app.add_systems(Update, handle_frontend_messages_system);
|
|
|
|
// Platform-agnostic input systems
|
|
app.add_systems(Update, (handle_spawn_click_system, handle_attack_click_system, handle_center_camera_system, handle_attack_ratio_keys_system));
|
|
|
|
// Input state frame update
|
|
app.add_systems(Last, clear_input_state_system);
|
|
|
|
// Turn generation system (must run before poll_turns_system)
|
|
app.add_systems(Update, generate_turns_system.before(poll_turns_system));
|
|
}
|
|
}
|
|
|
|
/// Resource to track if we've emitted the initial spawn phase event
|
|
#[derive(Resource, Default)]
|
|
struct SpawnPhaseInitialized {
|
|
emitted_initial: bool,
|
|
}
|
|
|
|
/// Resource to track previous spawn state for incremental updates
|
|
#[derive(Resource, Default)]
|
|
pub struct PreviousSpawnState {
|
|
pub spawns: Vec<crate::game::SpawnPoint>,
|
|
}
|
|
|
|
/// System to emit initial SpawnPhaseUpdate when game starts
|
|
#[cfg(feature = "ui")]
|
|
fn emit_initial_spawn_phase_system(mut initialized: If<ResMut<SpawnPhaseInitialized>>, spawn_phase: If<Res<SpawnPhase>>, territory_manager: Option<Res<crate::game::TerritoryManager>>, mut backend_messages: MessageWriter<BackendMessage>) {
|
|
if initialized.emitted_initial || !spawn_phase.active || territory_manager.is_none() {
|
|
return;
|
|
}
|
|
|
|
backend_messages.write(BackendMessage::SpawnPhaseUpdate { countdown: None });
|
|
initialized.emitted_initial = true;
|
|
debug!("Emitted initial SpawnPhaseUpdate (no countdown)");
|
|
}
|
|
|
|
#[cfg(not(feature = "ui"))]
|
|
fn emit_initial_spawn_phase_system() {}
|
|
|
|
/// System to manage spawn timeout and emit countdown updates
|
|
#[cfg(feature = "ui")]
|
|
fn manage_spawn_phase_system(mut spawn_timeout: If<ResMut<SpawnTimeout>>, spawn_phase: If<Res<SpawnPhase>>, time: Res<Time>, mut backend_messages: MessageWriter<BackendMessage>) {
|
|
if !spawn_phase.active || !spawn_timeout.active {
|
|
return;
|
|
}
|
|
|
|
spawn_timeout.update(time.delta_secs());
|
|
|
|
let started_at_ms = time.epoch_millis() - (spawn_timeout.elapsed_secs * 1000.0) as u64;
|
|
|
|
backend_messages.write(BackendMessage::SpawnPhaseUpdate { countdown: Some(SpawnCountdown { started_at_ms, duration_secs: spawn_timeout.duration_secs }) });
|
|
|
|
trace!("SpawnPhaseUpdate: remaining {:.1}s", spawn_timeout.remaining_secs);
|
|
}
|
|
|
|
#[cfg(not(feature = "ui"))]
|
|
fn manage_spawn_phase_system() {}
|
|
|
|
/// System to clear per-frame input state data
|
|
fn clear_input_state_system(input: Option<NonSend<Arc<Mutex<crate::ui::input::InputState>>>>) {
|
|
if let Some(input) = input
|
|
&& let Ok(mut state) = input.lock()
|
|
{
|
|
state.clear_frame_data();
|
|
}
|
|
}
|
|
|
|
/// System to receive turn events and update CurrentTurn resource
|
|
pub fn update_current_turn_system(mut turn_events: MessageReader<ProcessTurnEvent>, mut current_turn: Option<ResMut<CurrentTurn>>, mut commands: Commands) {
|
|
// Read all turn events (should only be one per frame at 10 TPS)
|
|
let turns: Vec<_> = turn_events.read().map(|e| e.0.clone()).collect();
|
|
|
|
if turns.is_empty() {
|
|
return;
|
|
}
|
|
|
|
// Take the latest turn (in case multiple arrived, though this shouldn't happen)
|
|
let turn = turns.into_iter().last().unwrap();
|
|
|
|
if let Some(ref mut current_turn_res) = current_turn {
|
|
// Update existing resource
|
|
current_turn_res.turn = turn;
|
|
current_turn_res.processed = false; // Mark as ready for processing
|
|
} else {
|
|
// Initialize resource on first turn
|
|
commands.insert_resource(CurrentTurn::new(turn));
|
|
}
|
|
}
|
|
|
|
/// System to handle FrontendMessage events
|
|
#[cfg(feature = "ui")]
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn handle_frontend_messages_system(mut commands: Commands, mut frontend_messages: MessageReader<crate::ui::protocol::FrontendMessage>, territory_manager: Option<Res<crate::game::TerritoryManager>>, intent_receiver: Option<Res<IntentReceiver>>, mut attack_controls: Option<ResMut<AttackControls>>, mut spawn_phase: ResMut<SpawnPhase>, mut spawn_phase_init: ResMut<SpawnPhaseInitialized>, mut previous_spawn_state: ResMut<PreviousSpawnState>) {
|
|
use crate::ui::protocol::FrontendMessage;
|
|
use tracing::{debug, error, info};
|
|
|
|
for message in frontend_messages.read() {
|
|
match message {
|
|
FrontendMessage::StartGame => {
|
|
let _guard = tracing::debug_span!("handle_start_game").entered();
|
|
info!("Processing StartGame command");
|
|
|
|
if territory_manager.is_some() {
|
|
error!("Game already running - ignoring StartGame");
|
|
continue;
|
|
}
|
|
|
|
let Some(ref intent_receiver) = intent_receiver else {
|
|
error!("IntentReceiver not available - cannot start game");
|
|
continue;
|
|
};
|
|
|
|
let terrain_data = {
|
|
let _guard = tracing::debug_span!("terrain_loading").entered();
|
|
match crate::game::TerrainData::load_world_map() {
|
|
Ok(data) => data,
|
|
Err(e) => {
|
|
error!("Failed to load World map: {}", e);
|
|
continue;
|
|
}
|
|
}
|
|
};
|
|
|
|
let terrain_arc = Arc::new(terrain_data.clone());
|
|
commands.insert_resource(terrain_data);
|
|
|
|
let size = terrain_arc.size();
|
|
let width = size.x;
|
|
let height = size.y;
|
|
let tile_count = (width as usize) * (height as usize);
|
|
let mut conquerable_tiles = Vec::with_capacity(tile_count);
|
|
|
|
{
|
|
let _guard = tracing::trace_span!("conquerable_tiles_calculation", tile_count = tile_count).entered();
|
|
|
|
for y in 0..height {
|
|
for x in 0..width {
|
|
conquerable_tiles.push(terrain_arc.is_conquerable(glam::U16Vec2::new(x, y)));
|
|
}
|
|
}
|
|
}
|
|
|
|
let params = crate::game::GameInitParamsBuilder::new(width, height, conquerable_tiles, NationId::ZERO, intent_receiver.rx.clone(), terrain_arc).build();
|
|
|
|
crate::game::initialize_game_resources(&mut commands, params);
|
|
info!("Game initialized successfully");
|
|
}
|
|
|
|
FrontendMessage::QuitGame => {
|
|
info!("Processing QuitGame command");
|
|
if territory_manager.is_some() {
|
|
// Remove all game-specific resources (refactored standalone resources)
|
|
commands.remove_resource::<crate::game::TerritoryManager>();
|
|
commands.remove_resource::<crate::game::ActiveAttacks>();
|
|
commands.remove_resource::<crate::game::DeterministicRng>();
|
|
commands.remove_resource::<crate::game::CoastalTiles>();
|
|
commands.remove_resource::<crate::game::PlayerEntityMap>();
|
|
commands.remove_resource::<crate::game::ClientPlayerId>();
|
|
commands.remove_resource::<crate::game::HumanPlayerCount>();
|
|
commands.remove_resource::<crate::game::LocalPlayerContext>();
|
|
commands.remove_resource::<TurnReceiver>();
|
|
commands.remove_resource::<crate::game::SpawnManager>();
|
|
commands.remove_resource::<crate::game::SpawnTimeout>();
|
|
commands.remove_resource::<TerrainData>();
|
|
commands.remove_resource::<TurnGenerator>();
|
|
|
|
// Reset permanent resources to default state
|
|
spawn_phase.active = false;
|
|
spawn_phase_init.emitted_initial = false;
|
|
previous_spawn_state.spawns.clear();
|
|
|
|
// Note: LocalTurnServerHandle cleanup requires World access
|
|
// It will be cleaned up automatically when the resource is dropped
|
|
|
|
info!("Game stopped and resources cleaned up");
|
|
}
|
|
}
|
|
FrontendMessage::SetAttackRatio { ratio } => {
|
|
if let Some(ref mut controls) = attack_controls {
|
|
controls.attack_ratio = ratio.clamp(0.01, 1.0);
|
|
debug!("Attack ratio set to {:.1}%", controls.attack_ratio * 100.0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|