diff --git a/crates/game-service/src/battle_session.rs b/crates/game-service/src/battle_session.rs index 960fe1d..bf3ea0b 100644 --- a/crates/game-service/src/battle_session.rs +++ b/crates/game-service/src/battle_session.rs @@ -92,6 +92,7 @@ impl BattleSession { while self.tick < tick { self.tick += 1; self.release_queued(self.tick); + self.mode.battle.tick(); if self.tick >= self.next_bot_emote { self.next_bot_emote = Self::roll_emote_tick(&mut self.random, self.tick); if let Some(event) = self.bot_emote() { diff --git a/crates/logic/src/battle/logic_component.rs b/crates/logic/src/battle/logic_component.rs index 4fefb22..892070b 100644 --- a/crates/logic/src/battle/logic_component.rs +++ b/crates/logic/src/battle/logic_component.rs @@ -16,12 +16,14 @@ pub struct LogicCombatComponent { pub target: LogicGameObjectRef, pub attackers: Vec<(LogicGameObjectRef, i32)>, pub observers: Vec, + pub target_index: Option, + pub hit_timer: i32, } impl LogicCombatComponent { pub fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> { writer.write_boolean(self.flag_72); writer.write_boolean(self.flag_73); - writer.write_vint(self.field_48); + writer.write_vint(self.hit_timer); writer.write_vint(self.field_52); writer.write_vint(self.field_60); writer.write_vint(self.field_64); diff --git a/crates/logic/src/battle/logic_game_object.rs b/crates/logic/src/battle/logic_game_object.rs index 7fed6a3..3f9af99 100644 --- a/crates/logic/src/battle/logic_game_object.rs +++ b/crates/logic/src/battle/logic_game_object.rs @@ -37,6 +37,18 @@ pub enum LogicObjectBody { Summoner(Box), } impl LogicObjectBody { + pub fn base(&self) -> &LogicGameObject { + match self { + LogicObjectBody::Character(character) => &character.base, + LogicObjectBody::Summoner(summoner) => &summoner.character.base, + } + } + pub fn level_index(&self) -> i32 { + match self { + LogicObjectBody::Character(character) => character.level_index, + LogicObjectBody::Summoner(summoner) => summoner.character.level_index, + } + } pub fn base_mut(&mut self) -> &mut LogicGameObject { match self { LogicObjectBody::Character(character) => &mut character.base, diff --git a/crates/logic/src/battle/logic_simulation.rs b/crates/logic/src/battle/logic_simulation.rs new file mode 100644 index 0000000..e093725 --- /dev/null +++ b/crates/logic/src/battle/logic_simulation.rs @@ -0,0 +1,291 @@ +use crate::battle::logic_battle::LogicBattle; +use crate::battle::logic_component::{LogicComponent, COMPONENT_COMBAT, COMPONENT_HITPOINT}; +use crate::battle::logic_game_object::LogicGameObjectEntry; +use crate::data::LogicDataRef; +pub const TICK_MILLISECONDS: i32 = 50; +pub const COLUMN_SPEED: &str = "Speed"; +pub const COLUMN_SIGHT_RANGE: &str = "SightRange"; +pub const COLUMN_RANGE: &str = "Range"; +pub const COLUMN_COLLISION_RADIUS: &str = "CollisionRadius"; +pub const COLUMN_HIT_SPEED: &str = "HitSpeed"; +pub const COLUMN_LOAD_TIME: &str = "LoadTime"; +pub const COLUMN_DAMAGE: &str = "Damage"; +pub const COLUMN_ATTACKS_AIR: &str = "AttacksAir"; +pub const COLUMN_ATTACKS_GROUND: &str = "AttacksGround"; +pub const COLUMN_TARGET_ONLY_BUILDINGS: &str = "TargetOnlyBuildings"; +pub const COLUMN_FLYING_HEIGHT: &str = "FlyingHeight"; +pub const COLUMN_PROJECTILE: &str = "Projectile"; +pub const DISTANCE_SATURATION: i32 = 46340; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LogicCharacterStats { + pub speed: i32, + pub sight_range: i32, + pub range: i32, + pub collision_radius: i32, + pub hit_speed: i32, + pub load_time: i32, + pub damage: i32, + pub attacks_air: bool, + pub attacks_ground: bool, + pub target_only_buildings: bool, + pub flying: bool, +} +impl LogicCharacterStats { + pub fn of(data: &LogicDataRef, level_index: i32) -> Self { + let Some(row) = data.data() else { + return Self::none(); + }; + let level = level_index.max(0) as usize; + let projectile = row.string(COLUMN_PROJECTILE); + let damage = if projectile.is_empty() { + row.int_at(COLUMN_DAMAGE, level) + } else { + LogicDataRef::by_name(crate::data::table::PROJECTILES, projectile) + .data() + .map(|shot| shot.int_at(COLUMN_DAMAGE, level)) + .unwrap_or(0) + }; + Self { + speed: row.int(COLUMN_SPEED), + sight_range: row.int(COLUMN_SIGHT_RANGE), + range: row.int(COLUMN_RANGE), + collision_radius: row.int(COLUMN_COLLISION_RADIUS), + hit_speed: row.int(COLUMN_HIT_SPEED), + load_time: row.int(COLUMN_LOAD_TIME), + damage, + attacks_air: row.boolean(COLUMN_ATTACKS_AIR), + attacks_ground: row.boolean(COLUMN_ATTACKS_GROUND), + target_only_buildings: row.boolean(COLUMN_TARGET_ONLY_BUILDINGS), + flying: row.int(COLUMN_FLYING_HEIGHT) > 0, + } + } + pub fn none() -> Self { + Self { + speed: 0, + sight_range: 0, + range: 0, + collision_radius: 0, + hit_speed: 0, + load_time: 0, + damage: 0, + attacks_air: false, + attacks_ground: false, + target_only_buildings: false, + flying: false, + } + } + pub fn is_building(&self) -> bool { + self.speed == 0 + } +} +pub fn integer_sqrt(value: i64) -> i64 { + if value <= 0 { + return 0; + } + let mut root = value.min(46340); + let mut previous = 0; + while root != previous { + previous = root; + root = (root + value / root) / 2; + } + root +} +pub fn distance_squared(from: (i32, i32), to: (i32, i32)) -> i32 { + let dx = to.0 - from.0; + let dy = to.1 - from.1; + if dx.abs() > DISTANCE_SATURATION || dy.abs() > DISTANCE_SATURATION { + return i32::MAX; + } + let square_x = dx * dx; + if dy * dy >= (square_x ^ i32::MAX) { + return i32::MAX; + } + square_x + dy * dy +} +impl LogicGameObjectEntry { + pub fn position(&self) -> (i32, i32) { + let base = self.body.base(); + (base.position.x, base.position.y) + } + pub fn owner_index(&self) -> i32 { + self.body.base().owner_index + } + pub fn level_index(&self) -> i32 { + self.body.level_index() + } + pub fn stats(&self) -> LogicCharacterStats { + LogicCharacterStats::of(&self.data, self.level_index()) + } + pub fn damage(&mut self, amount: i32) { + if let Some(Some(LogicComponent::Hitpoint(component))) = + self.components.get_mut(COMPONENT_HITPOINT) + { + component.hitpoints = (component.hitpoints - amount.max(0)).max(0); + } + } + pub fn combat_mut(&mut self) -> Option<&mut crate::battle::logic_component::LogicCombatComponent> { + match self.components.get_mut(COMPONENT_COMBAT)? { + Some(LogicComponent::Combat(component)) => Some(component), + _ => None, + } + } +} +impl LogicBattle { + pub fn tick(&mut self) { + self.retarget(); + self.move_objects(); + self.resolve_attacks(); + self.remove_dead(); + } + fn retarget(&mut self) { + let snapshot: Vec<(i32, (i32, i32), LogicCharacterStats, bool)> = self + .objects + .objects + .iter() + .map(|entry| { + ( + entry.owner_index(), + entry.position(), + entry.stats(), + entry.is_alive(), + ) + }) + .collect(); + for index in 0..self.objects.objects.len() { + let (owner, position, stats, alive) = snapshot[index]; + if !alive || stats.range < 1 { + continue; + } + let mut best: Option<(i32, usize)> = None; + for (other, (other_owner, other_position, other_stats, other_alive)) in + snapshot.iter().enumerate() + { + if other == index || !other_alive || *other_owner == owner { + continue; + } + if stats.target_only_buildings && !other_stats.is_building() { + continue; + } + if other_stats.flying && !stats.attacks_air { + continue; + } + if !other_stats.flying && !stats.attacks_ground { + continue; + } + let reach = stats.sight_range + other_stats.collision_radius; + let distance = distance_squared(position, *other_position); + if distance > reach.saturating_mul(reach) { + continue; + } + if best.map(|(closest, _)| distance < closest).unwrap_or(true) { + best = Some((distance, other)); + } + } + let target = best.map(|(_, other)| other); + if let Some(component) = self.objects.objects[index].combat_mut() { + component.target_index = target; + } + } + } + fn move_objects(&mut self) { + let positions: Vec<(i32, i32)> = self + .objects + .objects + .iter() + .map(LogicGameObjectEntry::position) + .collect(); + for index in 0..self.objects.objects.len() { + let stats = self.objects.objects[index].stats(); + if stats.speed < 1 || !self.objects.objects[index].is_alive() { + continue; + } + let Some(target) = self.objects.objects[index] + .combat_mut() + .and_then(|component| component.target_index) + else { + continue; + }; + let Some(goal) = positions.get(target).copied() else { + continue; + }; + let from = positions[index]; + let stand_off = stats.range; + let dx = (goal.0 - from.0) as i64; + let dy = (goal.1 - from.1) as i64; + let distance = integer_sqrt(dx * dx + dy * dy); + if distance <= stand_off as i64 || distance == 0 { + continue; + } + let step = (stats.speed as i64).min(distance - stand_off as i64); + let base = self.objects.objects[index].body.base_mut(); + base.position.x = from.0 + (dx * step / distance) as i32; + base.position.y = from.1 + (dy * step / distance) as i32; + } + } + fn resolve_attacks(&mut self) { + let positions: Vec<(i32, i32)> = self + .objects + .objects + .iter() + .map(LogicGameObjectEntry::position) + .collect(); + let radii: Vec = self + .objects + .objects + .iter() + .map(|entry| entry.stats().collision_radius) + .collect(); + let mut hits: Vec<(usize, i32)> = Vec::new(); + for index in 0..self.objects.objects.len() { + let stats = self.objects.objects[index].stats(); + if !self.objects.objects[index].is_alive() || stats.hit_speed < 1 { + continue; + } + let Some(component) = self.objects.objects[index].combat_mut() else { + continue; + }; + let Some(target) = component.target_index else { + component.hit_timer = 0; + continue; + }; + let reach = stats.range + radii.get(target).copied().unwrap_or(0); + if distance_squared(positions[index], positions[target]) > reach.saturating_mul(reach) { + component.hit_timer = 0; + continue; + } + component.hit_timer += TICK_MILLISECONDS; + let period = stats.hit_speed.max(TICK_MILLISECONDS); + let wind_up = stats.load_time.max(0); + if component.hit_timer >= wind_up + period { + component.hit_timer = wind_up; + hits.push((target, stats.damage)); + } + } + for (target, amount) in hits { + if let Some(entry) = self.objects.objects.get_mut(target) { + entry.damage(amount); + } + } + } + fn remove_dead(&mut self) { + let dead: Vec = self + .objects + .objects + .iter() + .filter(|entry| !entry.is_alive()) + .map(|entry| entry.global_id) + .collect(); + if dead.is_empty() { + return; + } + self.objects.objects.retain(LogicGameObjectEntry::is_alive); + for towers in self.leader_towers.iter_mut() { + towers.retain(|tower| !dead.contains(tower)); + } + for index in 0..self.objects.objects.len() { + if let Some(component) = self.objects.objects[index].combat_mut() { + component.target_index = None; + } + } + } +} diff --git a/crates/logic/src/battle/mod.rs b/crates/logic/src/battle/mod.rs index 40dc47c..f1d1912 100644 --- a/crates/logic/src/battle/mod.rs +++ b/crates/logic/src/battle/mod.rs @@ -6,6 +6,7 @@ mod logic_game_mode; mod logic_game_object; mod logic_game_object_manager; mod logic_game_object_ref; +mod logic_simulation; mod logic_tilemap; mod logic_time; mod logic_tutorial_manager; @@ -29,6 +30,7 @@ pub use logic_game_object::{ }; pub use logic_game_object_manager::LogicGameObjectManager; pub use logic_game_object_ref::LogicGameObjectRef; +pub use logic_simulation::{LogicCharacterStats, TICK_MILLISECONDS}; pub use logic_tilemap::{LogicTilemap, OBJECT_KING_TOWER, OBJECT_PRINCESS_TOWER, SUBTILE_UNITS}; pub use logic_time::LogicTime; pub use logic_tutorial_manager::LogicTutorialManager;