From 07f0ee542738f2965036d6643d9f3d0bc8eccf22 Mon Sep 17 00:00:00 2001 From: WiseDev <83840010+wisedevik@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:27:07 +0300 Subject: [PATCH] converge combat timers, projectile references, pending damage, and the spawn ring - replay the shooter's combat update a second time when it launches a projectile, matching the client's own component-pass re-entry - carry the shooter's damage effect on every projectile and null a shot's target/source when either leaves the board - derive pending physical damage from the shots actually in flight instead of an incremental total, so it can never go negative - split the spawn ring's two angle registers so three- and five-unit cards land where the client puts them - attribute a combat target left at NONE to the exact site that did it, gated to report each object once - number and annotate every checksum field so a client-reported mismatch resolves straight to a name --- crates/logic/src/battle/logic_battle.rs | 97 +- crates/logic/src/battle/logic_character.rs | 2 +- crates/logic/src/battle/logic_component.rs | 88 +- crates/logic/src/battle/logic_game_mode.rs | 35 +- crates/logic/src/battle/logic_game_object.rs | 61 +- .../src/battle/logic_game_object_manager.rs | 40 +- crates/logic/src/battle/logic_pathfinder.rs | 431 ++++- crates/logic/src/battle/logic_projectile.rs | 43 + crates/logic/src/battle/logic_simulation.rs | 1698 ++++++++++++++--- crates/logic/src/battle/logic_summoner.rs | 210 ++ crates/logic/src/battle/logic_tilemap.rs | 27 +- .../src/battle/logic_tutorial_manager.rs | 2 +- crates/logic/src/battle/mod.rs | 50 +- crates/logic/src/battle/verify.rs | 50 +- crates/logic/src/data/data_ref.rs | 21 +- crates/logic/src/data/logic_data.rs | 25 + crates/logic/src/data/logic_data_table.rs | 2 +- crates/logic/src/data/logic_data_tables.rs | 6 +- crates/logic/src/data/mod.rs | 7 +- crates/logic/src/data/scaled.rs | 63 + crates/logic/src/data/typed.rs | 251 ++- crates/logic/src/lib.rs | 19 +- crates/logic/src/logic_math.rs | 150 +- crates/logic/src/messages/battle_result.rs | 23 +- crates/logic/src/messages/sector_heartbeat.rs | 54 + crates/logic/tests/client_path_replay.rs | 42 + crates/logic/tests/lane_assignment.rs | 88 + crates/titan/src/checksum.rs | 50 +- 28 files changed, 3100 insertions(+), 535 deletions(-) create mode 100644 crates/logic/src/battle/logic_projectile.rs create mode 100644 crates/logic/src/battle/logic_summoner.rs create mode 100644 crates/logic/src/data/scaled.rs create mode 100644 crates/logic/tests/client_path_replay.rs create mode 100644 crates/logic/tests/lane_assignment.rs diff --git a/crates/logic/src/battle/logic_battle.rs b/crates/logic/src/battle/logic_battle.rs index d9eabe8..3074726 100644 --- a/crates/logic/src/battle/logic_battle.rs +++ b/crates/logic/src/battle/logic_battle.rs @@ -1,9 +1,10 @@ -use titan::{ByteStreamReader, ByteStreamWriter, LogicLong, Payload, Result}; -use crate::battle::logic_game_object::LogicGameObjectEntry; +use crate::battle::logic_game_object::{LogicGameObjectEntry, LogicObjectBody, LogicVector2}; use crate::battle::logic_game_object_manager::LogicGameObjectManager; use crate::battle::logic_game_object_ref::LogicGameObjectRef; +use crate::battle::logic_summoner::{check_spell_position, find_position_for_spell, DeployBlocker}; use crate::data::LogicDataRef; use crate::model::LogicSpellDeck; +use titan::{ByteStreamReader, ByteStreamWriter, LogicLong, Payload, Result}; pub const BATTLE_TYPE_PVP: i32 = 0; pub const BATTLE_TYPE_NPC: i32 = 1; pub const BATTLE_TYPE_REPLAY: i32 = 3; @@ -11,8 +12,6 @@ pub const BATTLE_INT_ARRAY: usize = 8; pub const BATTLE_TICKS_PER_SECOND: i32 = 20; pub const LEADER_TOWER_COUNT: i32 = 2; pub const CROWNS_FOR_LEADER: i32 = 3; -pub const LOCATION_MATCH_LENGTH_COLUMN: &str = "MatchLength"; -pub const LOCATION_OVERTIME_COLUMN: &str = "OvertimeSeconds"; pub const BATTLE_TRAILING_INTS: usize = 6; #[derive(Debug, Clone, PartialEq, Eq)] pub struct LogicBattle { @@ -41,6 +40,59 @@ pub struct LogicBattle { pub trailing: [i32; BATTLE_TRAILING_INTS], pub tilemap: Option, } +impl LogicBattle { + pub fn deploy_blockers(&self, caster_is_owner_zero: bool) -> Vec { + let mut blockers = Vec::new(); + for entry in self.objects.objects.iter() { + if !entry.is_alive() || entry.is_projectile() { + continue; + } + let Some(data) = entry.data.as_character() else { + continue; + }; + let is_summoner = matches!(entry.body, LogicObjectBody::Summoner(_)); + if !data.is_building() && !is_summoner { + continue; + } + let no_deploy_w = data.no_deploy_size_w(); + let friendly = no_deploy_w < 1 || (entry.owner_index() == 0) == caster_is_owner_zero; + let (size_w, size_h) = if friendly { + let size = data.size_in_tiles(); + (size, size) + } else { + (no_deploy_w, data.no_deploy_size_h()) + }; + let position = entry.position(); + blockers.push(DeployBlocker { + x: position.0, + y: position.1, + size_w, + size_h, + }); + } + blockers + } + pub fn resolve_spell_position( + &self, + spell: &LogicDataRef, + raw: LogicVector2, + owner: i32, + ) -> Option { + let tilemap = self.tilemap.as_ref()?; + let summon_name = spell + .as_spell() + .map(|spell| spell.summon_character().to_owned()); + let summon = summon_name + .filter(|name| !name.is_empty()) + .map(|name| LogicDataRef::by_name(crate::data::table::CHARACTERS_COMBINED, &name)); + let summon_data = summon.as_ref().and_then(|data| data.as_character()); + if check_spell_position(tilemap, raw.x, raw.y, summon_data.is_some()) != 0 { + return None; + } + let blockers = self.deploy_blockers(owner == 0); + find_position_for_spell(summon_data.as_ref(), raw, tilemap, &blockers, false) + } +} impl Default for LogicBattle { fn default() -> Self { Self { @@ -77,14 +129,14 @@ impl LogicBattle { } pub fn match_length_seconds(&self) -> i32 { self.location - .data() - .map(|row| row.int(LOCATION_MATCH_LENGTH_COLUMN)) + .as_location() + .map(|location| location.match_length()) .unwrap_or(0) } pub fn overtime_length_seconds(&self) -> i32 { self.location - .data() - .map(|row| row.int(LOCATION_OVERTIME_COLUMN)) + .as_location() + .map(|location| location.overtime_seconds()) .unwrap_or(0) } pub fn leader(&self, index: usize) -> Option<&LogicGameObjectEntry> { @@ -95,7 +147,9 @@ impl LogicBattle { .find(|entry| entry.global_id == *id) } pub fn is_leader_alive(&self, index: usize) -> bool { - self.leader(index).map(|entry| entry.is_alive()).unwrap_or(false) + self.leader(index) + .map(|entry| entry.is_alive()) + .unwrap_or(false) } pub fn stars(&self, index: usize) -> i32 { let other = 1 - index.min(1); @@ -130,9 +184,30 @@ impl LogicBattle { } self.stars(0) != self.stars(1) } + pub fn resolve_winner(&mut self, tick: i32) { + if self.battle_ended_called { + return; + } + self.battle_ended_called = true; + self.end_counter = 1; + let mut end = self.match_length_seconds(); + if self.is_on_overtime { + end += self.overtime_length_seconds(); + } + self.battle_ended_with_timeout = tick / BATTLE_TICKS_PER_SECOND >= end; + let (s0, s1) = (self.stars(0), self.stars(1)); + self.winner_index = if s0 < s1 { + 1 + } else if s0 > s1 { + 0 + } else { + -1 + }; + } } impl Payload for LogicBattle { fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> { + titan::checksum::checksum_trace_note("battle.header"); self.location.encode(writer)?; self.npc.encode(writer)?; self.arena.encode(writer)?; @@ -154,6 +229,7 @@ impl Payload for LogicBattle { writer.write_boolean(self.show_start_hud); writer.write_boolean(self.is_on_overtime); self.objects.encode(writer)?; + titan::checksum::checksum_trace_note("battle.decks"); for deck in &self.decks { match deck { None => writer.write_boolean(false), @@ -163,9 +239,11 @@ impl Payload for LogicBattle { } } } + titan::checksum::checksum_trace_note("battle.leaders"); for leader in &self.leaders { leader.encode(writer)?; } + titan::checksum::checksum_trace_note("battle.leader_towers"); for towers in &self.leader_towers { writer.write_vint(towers.len() as i32); for tower in towers { @@ -176,6 +254,7 @@ impl Payload for LogicBattle { writer.write_vint(self.winner_score_change); writer.write_vint(self.loser_score_change); } + titan::checksum::checksum_trace_note("battle.trailing"); for value in self.trailing { writer.write_vint(value); } diff --git a/crates/logic/src/battle/logic_character.rs b/crates/logic/src/battle/logic_character.rs index d2a787e..e7aa504 100644 --- a/crates/logic/src/battle/logic_character.rs +++ b/crates/logic/src/battle/logic_character.rs @@ -1,5 +1,5 @@ -use titan::{ByteStreamWriter, Result}; use crate::battle::logic_game_object::{LogicGameObject, LogicVector2}; +use titan::{ByteStreamWriter, Result}; pub const DIRECTION_TOP: i32 = 256; pub const DIRECTION_BOTTOM: i32 = -256; pub const DEFAULT_SIZE: i32 = 100; diff --git a/crates/logic/src/battle/logic_component.rs b/crates/logic/src/battle/logic_component.rs index 3783ef5..87ed29a 100644 --- a/crates/logic/src/battle/logic_component.rs +++ b/crates/logic/src/battle/logic_component.rs @@ -1,19 +1,19 @@ -use titan::{ByteStreamWriter, Payload, Result}; use crate::battle::logic_game_object::LogicVector2; use crate::battle::logic_game_object_ref::LogicGameObjectRef; +use titan::{ByteStreamWriter, Payload, Result}; pub const COMPONENT_COMBAT: usize = 0; pub const COMPONENT_MOVEMENT: usize = 1; pub const COMPONENT_HITPOINT: usize = 2; pub const COMPONENT_BUFF: usize = 3; #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct LogicCombatComponent { - pub flag_72: bool, - pub flag_73: bool, + pub is_healing: bool, + pub charge_ready: bool, pub field_48: i32, - pub field_52: i32, - pub field_60: i32, - pub field_64: i32, - pub field_68: i32, + pub load_timer: i32, + pub dash_cooldown: i32, + pub special_attack_counter: i32, + pub attack_finish_timer: i32, pub target: LogicGameObjectRef, pub attackers: Vec<(LogicGameObjectRef, i32)>, pub observers: Vec, @@ -21,14 +21,33 @@ pub struct LogicCombatComponent { pub hit_timer: i32, } impl LogicCombatComponent { + pub fn note_attacker(&mut self, attacker: LogicGameObjectRef, timer: i32) { + if let Some(entry) = self + .attackers + .iter_mut() + .find(|(reference, _)| *reference == attacker) + { + entry.1 = timer; + } else { + self.attackers.push((attacker, timer)); + } + } + pub fn age_attackers(&mut self, dt: i32) { + for i in (0..self.attackers.len()).rev() { + self.attackers[i].1 -= dt; + if self.attackers[i].1 <= 0 { + self.attackers.remove(i); + } + } + } pub fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> { - writer.write_boolean(self.flag_72); - writer.write_boolean(self.flag_73); + writer.write_boolean(self.is_healing); + writer.write_boolean(self.charge_ready); writer.write_vint(self.hit_timer); - writer.write_vint(self.field_52); - writer.write_vint(self.field_60); - writer.write_vint(self.field_64); - writer.write_vint(self.field_68); + writer.write_vint(self.load_timer); + writer.write_vint(self.dash_cooldown); + writer.write_vint(self.special_attack_counter); + writer.write_vint(self.attack_finish_timer); writer.write_vint(self.attackers.len() as i32); writer.write_vint(self.observers.len() as i32); self.target.encode(writer)?; @@ -66,7 +85,6 @@ pub struct LogicMovementComponent { pub move_timer: i32, pub jump_distance: i32, pub goal: Option<(i32, i32)>, - pub route: Vec<(i32, i32)>, } impl Default for LogicMovementComponent { fn default() -> Self { @@ -92,7 +110,6 @@ impl Default for LogicMovementComponent { move_timer: 0, jump_distance: 0, goal: None, - route: Vec::new(), } } } @@ -142,6 +159,19 @@ impl LogicHitpointComponent { ..Self::default() } } + pub fn with_lifetime(hitpoints: i32, life_time: i32) -> Self { + let lifetime_damage = if life_time >= 1 { + (100_000i64 * hitpoints as i64 / life_time as i64 / 20) as i32 + } else { + 0 + }; + Self { + hitpoints, + base_hitpoints: hitpoints, + lifetime_damage, + ..Self::default() + } + } pub fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> { writer.write_vint(self.hitpoints); writer.write_vint(self.base_hitpoints); @@ -161,17 +191,19 @@ pub struct LogicCharacterBuffComponent { pub field_36: i32, pub field_40: i32, pub buff_type_count: usize, - pub field_52: i32, + pub damage_multiplier: i32, pub field_56: i32, pub field_60: i32, pub field_64: i32, - pub field_68: i32, + pub size_multiplier: i32, pub field_72: i32, } impl LogicCharacterBuffComponent { pub fn empty(buff_type_count: usize) -> Self { Self { buff_type_count, + damage_multiplier: 100, + size_multiplier: 100, ..Self::default() } } @@ -187,15 +219,25 @@ impl LogicCharacterBuffComponent { for _ in 0..self.buff_type_count { writer.write_boolean(false); } - writer.write_vint(self.field_52); + writer.write_vint(self.damage_multiplier); writer.write_vint(self.field_56); writer.write_vint(self.field_60); writer.write_vint(self.field_64); - writer.write_vint(self.field_68); + writer.write_vint(self.size_multiplier); writer.write_vint(self.field_72); Ok(()) } } +#[cfg(test)] +mod buff_component_tests { + use super::LogicCharacterBuffComponent; + #[test] + fn an_unbuffed_component_reports_full_size_and_damage() { + let component = LogicCharacterBuffComponent::empty(5); + assert_eq!(component.size_multiplier, 100); + assert_eq!(component.damage_multiplier, 100); + } +} #[derive(Debug, Clone, PartialEq, Eq)] pub enum LogicComponent { Combat(LogicCombatComponent), @@ -204,6 +246,14 @@ pub enum LogicComponent { Buff(LogicCharacterBuffComponent), } impl LogicComponent { + pub fn kind(&self) -> &'static str { + match self { + LogicComponent::Combat(_) => "combat", + LogicComponent::Movement(_) => "movement", + LogicComponent::Hitpoint(_) => "hitpoint", + LogicComponent::Buff(_) => "buff", + } + } pub fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> { match self { LogicComponent::Combat(component) => component.encode(writer), diff --git a/crates/logic/src/battle/logic_game_mode.rs b/crates/logic/src/battle/logic_game_mode.rs index 0035c1b..6043514 100644 --- a/crates/logic/src/battle/logic_game_mode.rs +++ b/crates/logic/src/battle/logic_game_mode.rs @@ -1,9 +1,9 @@ -use titan::{ByteStreamWriter, Payload, Result}; use crate::battle::logic_battle::LogicBattle; use crate::battle::logic_time::LogicTime; use crate::battle::logic_tutorial_manager::LogicTutorialManager; use crate::logic_random::LogicRandom; use crate::model::LogicClientAvatar; +use titan::{ByteStreamWriter, Payload, Result}; pub const SECTION_BATTLE: i32 = 11; pub const SECTION_TUTORIAL: i32 = 12; #[derive(Debug, Default, Clone, PartialEq, Eq)] @@ -16,40 +16,57 @@ pub struct LogicGameMode { pub tutorial_manager: LogicTutorialManager, } impl LogicGameMode { - pub fn snapshot(&self) -> Result> { + pub fn snapshot(&self, commands: &[Vec]) -> Result> { let mut writer = ByteStreamWriter::new(); - self.encode(&mut writer)?; + self.write(&mut writer, Some(commands))?; Ok(writer.into_inner()) } } impl LogicGameMode { - pub fn write(&self, writer: &mut ByteStreamWriter, with_commands: bool) -> Result { + pub fn write( + &self, + writer: &mut ByteStreamWriter, + commands: Option<&[Vec]>, + ) -> Result { + titan::checksum::checksum_trace_note("gamemode.tick, checkpoint, section"); writer.write_vint(self.time.tick); writer.write_checksum_checkpoint(); writer.write_vint(SECTION_BATTLE); + titan::checksum::checksum_trace_note("time"); self.time.encode(writer)?; + titan::checksum::checksum_trace_note("random, seed"); self.random.encode(writer)?; writer.write_vint(self.random_seed); self.battle.encode(writer)?; - for avatar in &self.avatars { + for (index, avatar) in self.avatars.iter().enumerate() { + titan::checksum::checksum_trace_note(format!("avatar[{index}]")); avatar.encode(writer)?; } + titan::checksum::checksum_trace_note("tutorial"); writer.write_vint(SECTION_TUTORIAL); self.tutorial_manager.encode(writer)?; let checksum = writer.write_checksum_checkpoint(); - if with_commands { - writer.write_vint(0); + if let Some(commands) = commands { + writer.write_vint(commands.len() as i32); + for command in commands { + writer.write_raw(command); + } } Ok(checksum) } pub fn calculate_checksum(&self) -> Result { let mut writer = ByteStreamWriter::new(); - self.write(&mut writer, false) + self.write(&mut writer, None) + } + pub fn checksum_stream(&self) -> Result<(i32, Vec)> { + let mut writer = ByteStreamWriter::new(); + let checksum = self.write(&mut writer, None)?; + Ok((checksum, writer.into_inner())) } } impl Payload for LogicGameMode { fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> { - self.write(writer, true)?; + self.write(writer, Some(&[]))?; Ok(()) } fn decode(_reader: &mut titan::ByteStreamReader<'_>) -> Result { diff --git a/crates/logic/src/battle/logic_game_object.rs b/crates/logic/src/battle/logic_game_object.rs index 3f9af99..0c8c548 100644 --- a/crates/logic/src/battle/logic_game_object.rs +++ b/crates/logic/src/battle/logic_game_object.rs @@ -1,6 +1,6 @@ -use titan::Payload; use crate::battle::logic_game_object_ref::LogicGameObjectRef; use crate::data::LogicDataRef; +use titan::Payload; pub const COMPONENT_PASSES: usize = 4; pub const OBJECT_TYPE_COUNT: usize = 6; pub const CHARACTER_OBJECT_TYPE: i32 = 5; @@ -35,30 +35,83 @@ impl LogicGameObject { pub enum LogicObjectBody { Character(Box), Summoner(Box), + Projectile(Box), } impl LogicObjectBody { pub fn base(&self) -> &LogicGameObject { match self { LogicObjectBody::Character(character) => &character.base, LogicObjectBody::Summoner(summoner) => &summoner.character.base, + LogicObjectBody::Projectile(projectile) => &projectile.base, } } pub fn level_index(&self) -> i32 { match self { LogicObjectBody::Character(character) => character.level_index, LogicObjectBody::Summoner(summoner) => summoner.character.level_index, + LogicObjectBody::Projectile(projectile) => projectile.level_index, } } pub fn base_mut(&mut self) -> &mut LogicGameObject { match self { LogicObjectBody::Character(character) => &mut character.base, LogicObjectBody::Summoner(summoner) => &mut summoner.character.base, + LogicObjectBody::Projectile(projectile) => &mut projectile.base, + } + } + pub fn lane_id(&self) -> i32 { + match self { + LogicObjectBody::Character(character) => character.lane_id, + LogicObjectBody::Summoner(summoner) => summoner.character.lane_id, + LogicObjectBody::Projectile(_) => 0, + } + } + pub fn set_lane_id(&mut self, lane: i32) { + match self { + LogicObjectBody::Character(character) => character.lane_id = lane, + LogicObjectBody::Summoner(summoner) => summoner.character.lane_id = lane, + LogicObjectBody::Projectile(_) => {} + } + } + pub fn state(&self) -> i32 { + match self { + LogicObjectBody::Character(character) => character.state, + LogicObjectBody::Summoner(summoner) => summoner.character.state, + LogicObjectBody::Projectile(_) => 0, + } + } + pub fn set_state(&mut self, state: i32) { + match self { + LogicObjectBody::Character(character) => character.state = state, + LogicObjectBody::Summoner(summoner) => summoner.character.state = state, + LogicObjectBody::Projectile(_) => {} + } + } + pub fn set_direction(&mut self, direction: LogicVector2) { + match self { + LogicObjectBody::Character(character) => character.direction = direction, + LogicObjectBody::Summoner(summoner) => summoner.character.direction = direction, + LogicObjectBody::Projectile(_) => {} + } + } + pub fn add_pending_physical_damage(&mut self, amount: i32) { + match self { + LogicObjectBody::Character(character) => { + character.pending_physical_damage = + (character.pending_physical_damage + amount).max(0) + } + LogicObjectBody::Summoner(summoner) => { + summoner.character.pending_physical_damage = + (summoner.character.pending_physical_damage + amount).max(0) + } + LogicObjectBody::Projectile(_) => {} } } pub fn encode(&self, writer: &mut titan::ByteStreamWriter) -> titan::Result<()> { match self { LogicObjectBody::Character(character) => character.encode(writer), LogicObjectBody::Summoner(summoner) => summoner.encode(writer), + LogicObjectBody::Projectile(projectile) => projectile.encode(writer), } } } @@ -83,8 +136,14 @@ impl LogicGameObjectEntry { } } pub fn is_alive(&self) -> bool { + if let LogicObjectBody::Projectile(projectile) = &self.body { + return !projectile.destroyed; + } self.hitpoints().map(|value| value > 0).unwrap_or(false) } + pub fn is_projectile(&self) -> bool { + matches!(self.body, LogicObjectBody::Projectile(_)) + } pub fn new( data: LogicDataRef, global_id: LogicGameObjectRef, diff --git a/crates/logic/src/battle/logic_game_object_manager.rs b/crates/logic/src/battle/logic_game_object_manager.rs index ab84ced..1e32f60 100644 --- a/crates/logic/src/battle/logic_game_object_manager.rs +++ b/crates/logic/src/battle/logic_game_object_manager.rs @@ -1,5 +1,5 @@ -use titan::{ByteStreamReader, ByteStreamWriter, Payload, Result}; use crate::battle::logic_game_object::{LogicGameObjectEntry, COMPONENT_PASSES, OBJECT_TYPE_COUNT}; +use titan::{ByteStreamReader, ByteStreamWriter, Payload, Result}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct LogicGameObjectManager { pub instance_counters: [i32; OBJECT_TYPE_COUNT], @@ -14,6 +14,30 @@ impl Default for LogicGameObjectManager { } } impl LogicGameObjectManager { + pub fn reserve_instance(&mut self, object_type: i32) -> i32 { + let Ok(index) = usize::try_from(object_type) else { + return 0; + }; + let Some(counter) = self.instance_counters.get_mut(index) else { + return 0; + }; + let instance = *counter; + *counter = instance + 1; + instance + } + fn note(&self, index: usize, entry: &LogicGameObjectEntry, section: &str) { + if !titan::checksum::checksum_trace_active() { + return; + } + let id = match entry.global_id.0 { + Some(id) => format!("{}:{}", id.class_id, id.instance_id), + None => "-".to_string(), + }; + titan::checksum::checksum_trace_note(format!( + "objects[{index}].{section} {id} {}", + entry.data.name() + )); + } pub fn push(&mut self, entry: LogicGameObjectEntry) { let object_type = entry.object_type(); if let Ok(index) = usize::try_from(object_type) { @@ -29,22 +53,28 @@ impl LogicGameObjectManager { } impl Payload for LogicGameObjectManager { fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> { + titan::checksum::checksum_trace_note("objects.instance_counters"); for counter in self.instance_counters { writer.write_vint(counter); } + titan::checksum::checksum_trace_note("objects.count"); writer.write_vint(self.objects.len() as i32); - for entry in &self.objects { + for (index, entry) in self.objects.iter().enumerate() { + self.note(index, entry, "data"); entry.data.encode(writer)?; } - for entry in &self.objects { + for (index, entry) in self.objects.iter().enumerate() { + self.note(index, entry, "global_id"); entry.global_id.encode(writer)?; } - for entry in &self.objects { + for (index, entry) in self.objects.iter().enumerate() { + self.note(index, entry, "body"); entry.body.encode(writer)?; } for pass in 0..COMPONENT_PASSES { - for entry in &self.objects { + for (index, entry) in self.objects.iter().enumerate() { if let Some(component) = &entry.components[pass] { + self.note(index, entry, component.kind()); component.encode(writer)?; } } diff --git a/crates/logic/src/battle/logic_pathfinder.rs b/crates/logic/src/battle/logic_pathfinder.rs index 0c4e99a..74ea098 100644 --- a/crates/logic/src/battle/logic_pathfinder.rs +++ b/crates/logic/src/battle/logic_pathfinder.rs @@ -1,119 +1,340 @@ -use std::collections::BinaryHeap; -use crate::battle::logic_tilemap::{ - LogicTilemap, GROUND_COST, SUBTILE_UNITS, WATER_COST, WATER_COST_JUMPING, -}; -pub const PATH_STEP_LIMIT: usize = 4096; -fn tile_of(units: i32) -> i32 { - units.div_euclid(SUBTILE_UNITS) +use crate::battle::logic_simulation::distance_squared; +use crate::battle::logic_tilemap::{LogicTilemap, SUBTILE_UNITS}; +pub const COST_UNREACHABLE: i32 = 0xFFF_FFFF; +pub const COST_MAX_TRAVERSABLE: i32 = 268_435_454; +#[inline] +pub fn tile_of(units: i32) -> i32 { + units / SUBTILE_UNITS } -fn centre_of(tile: i32) -> i32 { +#[inline] +pub fn centre_of(tile: i32) -> i32 { tile * SUBTILE_UNITS + SUBTILE_UNITS / 2 } -#[derive(PartialEq, Eq)] -struct Step { - estimate: i32, - cost: i32, - index: usize, -} -impl Ord for Step { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - other - .estimate - .cmp(&self.estimate) - .then_with(|| other.index.cmp(&self.index)) +#[inline] +pub fn path_finder_cost(tm: &LogicTilemap, x: i32, y: i32, lane_id: i32, jump: bool) -> i32 { + if (x | y) < 0 || tm.width() <= x || tm.height() <= y { + return COST_UNREACHABLE; + } + let v = tm.tile_at(x, y); + if (v >> 5) & 1 == 1 { + if jump { + 20 + } else { + 800 + } + } else { + let lane = v & 3; + if lane != 0 { + if lane_id == lane { + 1 + } else { + 5 + } + } else { + 20 + } } } -impl PartialOrd for Step { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) +#[inline] +pub fn is_passable_path_finder(tm: &LogicTilemap, x: i32, y: i32) -> bool { + (x | y) >= 0 && tm.width() > x && tm.height() > y +} +struct AStar<'a> { + tm: &'a LogicTilemap, + width: i32, + height: i32, + goal_x: i32, + goal_y: i32, + lane_id: i32, + jump: bool, + state: Vec, + came_from: Vec, + score: Vec, + heap: Vec, + heap_size: usize, +} +impl AStar<'_> { + #[inline] + fn heuristic(&self, x: i32, y: i32) -> i32 { + 10 * (self.goal_x - x).abs().max((self.goal_y - y).abs()) + } + fn heap_add(&mut self, node: i32) { + let pos = self.heap_size; + self.heap[pos] = node; + self.heap_size += 1; + if pos >= 1 { + let key = self.score[node as usize]; + let mut v2 = pos as i32; + let mut v4 = pos as i32 - 1; + loop { + let parent_idx = v4 >> 1; + let parent = self.heap[parent_idx as usize]; + if key >= self.score[parent as usize] { + break; + } + self.heap[v2 as usize] = parent; + v2 = parent_idx; + self.heap[parent_idx as usize] = node; + v4 = parent_idx - 1; + if parent_idx < 1 { + break; + } + } + } + } + fn remove_smallest(&mut self) -> i32 { + let v1 = self.heap_size; + if v1 == 0 { + return -1; + } + let root = self.heap[0]; + let last = self.heap[v1 - 1]; + self.heap_size = v1 - 1; + self.heap[0] = last; + let mut pos = 0i32; + loop { + let left = (2 * pos) | 1; + let right = 2 * pos + 2; + let size = self.heap_size as i32; + let mut best = pos; + if right < size { + if self.score[last as usize] <= self.score[self.heap[right as usize] as usize] { + best = pos; + } else { + best = right; + } + } + if left < size + && self.score[self.heap[best as usize] as usize] + > self.score[self.heap[left as usize] as usize] + { + best = left; + } + if best == pos { + break; + } + self.heap[pos as usize] = self.heap[best as usize]; + self.heap[best as usize] = last; + pos = best; + } + root + } + fn relax(&mut self, cur: i32, nx: i32, ny: i32, nidx: i32, weight: i32) { + if ny < 0 { + return; + } + if nx < 0 || self.height <= ny { + return; + } + if self.width <= nx { + return; + } + let cost = path_finder_cost(self.tm, nx, ny, self.lane_id, self.jump); + if cost <= COST_MAX_TRAVERSABLE && self.state[nidx as usize] != 2 { + let g = self.score[cur as usize] + cost * weight; + let f = g + self.heuristic(nx, ny); + if self.state[nidx as usize] == 0 { + self.state[nidx as usize] = 1; + self.came_from[nidx as usize] = cur; + self.score[nidx as usize] = f; + self.heap_add(nidx); + } + } + } + fn expand(&mut self, node: i32) { + let w = self.width; + let y = node / w; + let x = node % w; + self.relax(node, x, y - 1, node - w, 10); + self.relax(node, x, y + 1, node + w, 10); + self.relax(node, x - 1, y, node - 1, 10); + self.relax(node, x + 1, y, node + 1, 10); + self.relax(node, x - 1, y - 1, node - 1 - w, 14); + self.relax(node, x - 1, y + 1, node - 1 + w, 14); + self.relax(node, x + 1, y + 1, node + 1 + w, 14); + self.relax(node, x + 1, y - 1, node + 1 - w, 14); + } + fn run(&mut self, start: i32, goal: i32) -> Vec { + self.goal_x = goal % self.width; + self.goal_y = goal / self.width; + self.came_from[start as usize] = -1; + self.came_from[goal as usize] = -1; + self.expand(start); + self.state[start as usize] = 2; + if self.heap_size > 0 { + loop { + let v = self.remove_smallest(); + self.state[v as usize] = 2; + self.expand(v); + if self.state[goal as usize] == 2 || self.heap_size == 0 { + break; + } + } + } + let mut path = Vec::new(); + let mut cur = goal; + let mut i = self.came_from[goal as usize]; + while i != -1 { + path.push(cur); + cur = i; + i = self.came_from[i as usize]; + } + path } } pub fn find_path( - tilemap: &LogicTilemap, - from: (i32, i32), - to: (i32, i32), - jumps: bool, -) -> Vec<(i32, i32)> { - let start = (tile_of(from.0), tile_of(from.1)); - let goal = (tile_of(to.0), tile_of(to.1)); - if start == goal - || !tilemap.is_inside(start.0, start.1) - || !tilemap.is_inside(goal.0, goal.1) - { + tm: &LogicTilemap, + start_tile: (i32, i32), + goal_tile: (i32, i32), + lane_id: i32, + jump: bool, +) -> Vec { + let width = tm.width(); + let height = tm.height(); + if width < 1 || height < 1 { return Vec::new(); } - let width = tilemap.width().max(1) as usize; - let height = tilemap.height().max(1) as usize; - let index_of = |x: i32, y: i32| y as usize * width + x as usize; - let mut cost = vec![i32::MAX; width * height]; - let mut came_from = vec![usize::MAX; width * height]; - let mut open = BinaryHeap::new(); - let start_index = index_of(start.0, start.1); - cost[start_index] = 0; - open.push(Step { - estimate: 0, - cost: 0, - index: start_index, - }); - let goal_index = index_of(goal.0, goal.1); - let mut visited = 0; - while let Some(step) = open.pop() { - if step.index == goal_index { - break; - } - if step.cost > cost[step.index] { - continue; - } - visited += 1; - if visited > PATH_STEP_LIMIT { - break; - } - let x = (step.index % width) as i32; - let y = (step.index / width) as i32; - for (dx, dy) in [(1, 0), (-1, 0), (0, 1), (0, -1)] { - let (nx, ny) = (x + dx, y + dy); - if !tilemap.is_inside(nx, ny) { - continue; - } - let water = tilemap.is_water(nx, ny); - let step_cost = if water { - if jumps { - WATER_COST_JUMPING - } else { - WATER_COST - } - } else { - GROUND_COST - }; - let next = index_of(nx, ny); - let total = step.cost.saturating_add(step_cost); - if total >= cost[next] { - continue; - } - cost[next] = total; - came_from[next] = step.index; - let heuristic = (goal.0 - nx).abs() + (goal.1 - ny).abs(); - open.push(Step { - estimate: total.saturating_add(heuristic), - cost: total, - index: next, - }); - } - } - if came_from[goal_index] == usize::MAX { + if !is_passable_path_finder(tm, start_tile.0, start_tile.1) { return Vec::new(); } - let mut path = Vec::new(); - let mut cursor = goal_index; - while cursor != start_index { - let x = (cursor % width) as i32; - let y = (cursor / width) as i32; - path.push((centre_of(x), centre_of(y))); - cursor = came_from[cursor]; - if cursor == usize::MAX { - return Vec::new(); - } + if path_finder_cost(tm, goal_tile.0, goal_tile.1, lane_id, jump) >= COST_UNREACHABLE { + return Vec::new(); + } + let start = start_tile.0 + width * start_tile.1; + let goal = goal_tile.0 + width * goal_tile.1; + let n = (width * height) as usize; + let mut a = AStar { + tm, + width, + height, + goal_x: 0, + goal_y: 0, + lane_id, + jump, + state: vec![0u8; n], + came_from: vec![-1i32; n], + score: vec![0i32; n], + heap: vec![0i32; n], + heap_size: 0, + }; + a.run(start, goal) +} +pub fn get_lane_id(tm: &LogicTilemap, deploy_x: i32, deploy_y: i32) -> i32 { + let (mw, mh) = (tm.width(), tm.height()); + if mw < 1 { + return 0; + } + let dx = deploy_x / SUBTILE_UNITS; + let ndy = deploy_y / -SUBTILE_UNITS; + let mut best_lane = 0i32; + let mut best_dist = i32::MAX; + let mut x = 0; + while x < mw { + let mut y = 0; + while y < mh { + let lane = tm.tile_at(x, y) & 3; + let d = (x - dx) * (x - dx) + (ndy + y) * (ndy + y); + if lane >= 1 && d < best_dist { + best_dist = d; + best_lane = lane; + } + y += 1; + } + x += 1; + } + best_lane +} +pub fn spawn_lane_of(tm: &LogicTilemap, x: i32, y: i32) -> i32 { + let x = x.clamp(250, SUBTILE_UNITS * tm.width() - 250); + let y = y.clamp(250, SUBTILE_UNITS * tm.height() - 250); + get_lane_id(tm, x, y) +} +#[allow(clippy::too_many_arguments)] +pub fn closest_tile_position_to_target( + tm: &LogicTilemap, + unit_x: i32, + unit_y: i32, + target_x: i32, + target_y: i32, + target_tile_x: i32, + target_tile_y: i32, + range: i32, + healing_power: i32, + owner_top: bool, +) -> Option<(i32, i32)> { + let (width, height) = (tm.width(), tm.height()); + let (ref_x, ref_y) = if healing_power < 1 { + (unit_x, unit_y) + } else { + (target_x, if owner_top { -range } else { range } + target_y) + }; + let r = range / SUBTILE_UNITS + 1; + let x_min = (target_tile_x - r).max(0); + let x_max = (r + target_tile_x).min(width - 1); + let y_min = (target_tile_y - r).max(0); + let y_max = (r + target_tile_y).min(height - 1); + if y_min > y_max { + return None; + } + let range_sq = range.wrapping_mul(range); + let mut best = i32::MAX; + let mut best_tile: Option<(i32, i32)> = None; + let mut ty = y_min; + while ty <= y_max { + let cy = SUBTILE_UNITS * ty + SUBTILE_UNITS / 2; + let dy_sq = (cy - ref_y).wrapping_mul(cy - ref_y); + let mut tx = x_min; + while tx <= x_max { + let cx = SUBTILE_UNITS * tx + SUBTILE_UNITS / 2; + if is_passable_path_finder(tm, tx, ty) + && distance_squared((target_x, target_y), (cx, cy)) <= range_sq + { + let d = dy_sq.wrapping_add((cx - ref_x).wrapping_mul(cx - ref_x)); + if d < best { + best = d; + best_tile = Some((tx, ty)); + } + } + tx += 1; + } + ty += 1; + } + best_tile +} +#[inline] +pub fn vec_length(x: i32, y: i32) -> i32 { + crate::logic_sqrt(distance_squared((0, 0), (x, y))) +} +#[inline] +pub fn normalize_to(x: &mut i32, y: &mut i32, target_len: i32) { + let len = vec_length(*x, *y); + if len != 0 { + *x = x.wrapping_mul(target_len) / len; + *y = y.wrapping_mul(target_len) / len; + } +} +pub fn path_target_normal(path: &[i32], unit_x: i32, unit_y: i32, width: i32) -> (i32, i32) { + if path.is_empty() { + return (0, 0); + } + let node = path[path.len() - 1]; + let mut nx = centre_of(node % width) - unit_x; + let mut ny = centre_of(node / width) - unit_y; + normalize_to(&mut nx, &mut ny, 256); + (nx, ny) +} +pub fn target_position_where_going_now( + path: &[i32], + width: i32, + target_pos: Option<(i32, i32)>, + unit_tile_x: i32, + unit_tile_y: i32, +) -> (i32, i32) { + if let Some(&node) = path.last() { + (centre_of(node % width), centre_of(node / width)) + } else if let Some(p) = target_pos { + p + } else { + (centre_of(unit_tile_x), centre_of(unit_tile_y)) } - path.reverse(); - path } diff --git a/crates/logic/src/battle/logic_projectile.rs b/crates/logic/src/battle/logic_projectile.rs new file mode 100644 index 0000000..2846703 --- /dev/null +++ b/crates/logic/src/battle/logic_projectile.rs @@ -0,0 +1,43 @@ +use crate::battle::logic_game_object::{LogicGameObject, LogicVector2}; +use crate::battle::logic_game_object_ref::LogicGameObjectRef; +use crate::data::LogicDataRef; +use titan::{ByteStreamWriter, GlobalId, Payload, Result}; +pub const PROJECTILE_OBJECT_TYPE: i32 = 3; +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct LogicProjectile { + pub destroyed: bool, + pub base: LogicGameObject, + pub target_position: LogicVector2, + pub start_position: LogicVector2, + pub offset: LogicVector2, + pub target: LogicGameObjectRef, + pub source: LogicGameObjectRef, + pub aux_data: LogicDataRef, + pub effect: LogicDataRef, + pub level_index: i32, + pub target_z: i32, + pub start_z: i32, + pub hit_objects: Vec, +} +impl LogicProjectile { + pub fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> { + writer.write_boolean(self.destroyed); + self.base.encode_base(writer)?; + self.target_position.encode(writer)?; + self.start_position.encode(writer)?; + self.offset.encode(writer)?; + self.target.encode(writer)?; + self.source.encode(writer)?; + self.aux_data.encode(writer)?; + self.effect.encode(writer)?; + writer.write_vint(self.level_index); + writer.write_vint(self.target_z); + writer.write_vint(self.start_z); + writer.write_vint(self.hit_objects.len() as i32); + for id in &self.hit_objects { + writer.write_vint(id.class_id); + writer.write_vint(id.instance_id); + } + Ok(()) + } +} diff --git a/crates/logic/src/battle/logic_simulation.rs b/crates/logic/src/battle/logic_simulation.rs index ba9e9a8..547a53f 100644 --- a/crates/logic/src/battle/logic_simulation.rs +++ b/crates/logic/src/battle/logic_simulation.rs @@ -1,17 +1,23 @@ use crate::battle::logic_battle::LogicBattle; use crate::battle::logic_battle::BATTLE_TICKS_PER_SECOND; use crate::battle::logic_component::{LogicComponent, COMPONENT_COMBAT, COMPONENT_HITPOINT}; -use crate::battle::logic_game_object::{LogicGameObjectEntry, LogicObjectBody}; +use crate::battle::logic_game_object::{LogicGameObjectEntry, LogicObjectBody, LogicVector2}; +use crate::battle::logic_game_object_ref::LogicGameObjectRef; use crate::battle::logic_tilemap::SUBTILE_UNITS; use crate::data::LogicDataRef; pub const TICK_MILLISECONDS: i32 = 50; -pub const CHARACTER_DEPLOY_TIME_COLUMN: &str = "DeployTime"; -pub const CHARACTER_SPECIAL_ATTACK_INTERVAL_COLUMN: &str = "SpecialAttackInterval"; pub const CHARACTER_STATE_MOVING: i32 = 1; +pub const CHARACTER_STATE_ATTACK: i32 = 2; pub const CHARACTER_STATE_DEPLOY: i32 = 5; pub const MANA_ACCUMULATOR_STEP: i32 = 5000; pub const MANA_RATE_SCALE: i32 = 100; pub const GLOBAL_MAX_MANA: &str = "MAX_MANA"; +pub const GLOBAL_DAMAGE_NOTICE_TIME: &str = "DAMAGE_NOTICE_TIME"; +pub const GLOBAL_REDUCED_TOWER_DAMAGE: &str = "REDUCED_TOWER_DAMAGE_PERCENT"; +pub fn reduced_crown_tower_damage(damage: i32) -> i32 { + (crate::LogicGlobals::number(GLOBAL_REDUCED_TOWER_DAMAGE) * damage + 99) / 100 +} +pub const GLOBAL_ATTACK_FINISH_TIME: &str = "ATTACK_FINISH_TIME_MS"; pub const GLOBAL_MANA_REGEN: &str = "MANA_REGEN_MS"; pub const GLOBAL_MANA_REGEN_END: &str = "MANA_REGEN_MS_END"; pub const GLOBAL_MANA_REGEN_OVERTIME: &str = "MANA_REGEN_MS_OVERTIME"; @@ -24,27 +30,13 @@ pub const GLOBAL_MANA_SPEED_UP_SECONDS: &str = "MANA_SPEED_UP_WHEN_REMAINING_SEC pub const GLOBAL_KING_ACTIVATE_TIME_MS: &str = "KING_ACTIVATE_TIME_MS"; pub const KING_ACTIVATION_STEP: i32 = 50; pub const PRINCESS_TOWERS_TOTAL: i32 = 4; -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_MASS: &str = "Mass"; pub const COLLISION_RADIUS_CAP: i32 = 500; +pub const SIGHT_BUILDING_BONUS: i32 = 1000; pub const COLLISION_OVERLAP_CAP: i32 = 300; pub const COLLISION_PUSH_CAP: i32 = 300; pub const MASS_MIN: i32 = 1; -pub const MASS_MAX: i32 = 20; pub const MASS_FOR_STATIC: i32 = 20; -pub const COLUMN_PROJECTILE: &str = "Projectile"; pub const DISTANCE_SATURATION: i32 = 46340; -pub const WAYPOINT_REACHED: i32 = 250; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LogicCharacterStats { pub speed: i32, @@ -58,36 +50,33 @@ pub struct LogicCharacterStats { pub attacks_ground: bool, pub target_only_buildings: bool, pub flying: bool, + pub jump: bool, pub mass: i32, + pub stop_movement_after_ms: i32, + pub wait_ms: i32, } impl LogicCharacterStats { pub fn of(data: &LogicDataRef, level_index: i32) -> Self { - let Some(row) = data.data() else { + let Some(character) = data.as_character() 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, - mass: row.int(COLUMN_MASS).clamp(MASS_MIN, MASS_MAX), + speed: character.speed(), + sight_range: character.sight_range(), + range: character.range(), + collision_radius: character.collision_radius(), + hit_speed: character.hit_speed(), + load_time: character.load_time(), + damage: character.damage(level), + attacks_air: character.attacks_air(), + attacks_ground: character.attacks_ground(), + target_only_buildings: character.target_only_buildings(), + flying: character.is_flying(), + jump: character.jump_enabled(), + mass: character.mass(), + stop_movement_after_ms: character.stop_movement_after_ms(), + wait_ms: character.wait_ms(), } } pub fn none() -> Self { @@ -103,7 +92,10 @@ impl LogicCharacterStats { attacks_ground: false, target_only_buildings: false, flying: false, + jump: false, mass: MASS_MIN, + stop_movement_after_ms: 0, + wait_ms: 0, } } pub fn is_building(&self) -> bool { @@ -134,6 +126,7 @@ impl LogicGameObjectEntry { match &self.body { LogicObjectBody::Character(character) => character.deploy_timer > 0, LogicObjectBody::Summoner(summoner) => summoner.character.deploy_timer > 0, + LogicObjectBody::Projectile(_) => false, } } pub fn level_index(&self) -> i32 { @@ -149,6 +142,21 @@ impl LogicGameObjectEntry { component.hitpoints = (component.hitpoints - amount.max(0)).max(0); } } + pub fn damage_directional(&mut self, amount: i32, dir_x: i32, dir_y: i32) { + if let Some(Some(LogicComponent::Hitpoint(component))) = + self.components.get_mut(COMPONENT_HITPOINT) + { + if component.hitpoints >= 1 { + let after = component.hitpoints - amount.max(0); + if after <= 0 { + component.death_hit_angle = crate::logic_math::get_angle(dir_x, dir_y); + component.hitpoints = 0; + } else { + component.hitpoints = after; + } + } + } + } pub fn movement(&self) -> Option<&crate::battle::logic_component::LogicMovementComponent> { match self .components @@ -172,48 +180,57 @@ impl LogicGameObjectEntry { pub fn combat_mut( &mut self, ) -> Option<&mut crate::battle::logic_component::LogicCombatComponent> { + if self.body.base().component_mask & (1 << COMPONENT_COMBAT) == 0 { + return None; + } match self.components.get_mut(COMPONENT_COMBAT)? { Some(LogicComponent::Combat(component)) => Some(component), _ => None, } } } -struct CollisionBody { - alive: bool, - position: (i32, i32), - stats: LogicCharacterStats, - owner: i32, - moves: bool, -} impl LogicBattle { fn regenerate_mana(&mut self, tick: i32) { let max_mana = crate::LogicGlobals::number(GLOBAL_MAX_MANA).max(1); let elapsed = tick / BATTLE_TICKS_PER_SECOND; - if elapsed >= self.match_length_seconds() { + if (self.match_length_seconds() >= 1 || !self.is_npc_battle()) + && elapsed >= self.match_length_seconds() + { self.is_on_overtime = true; } let speed_up = crate::LogicGlobals::number(GLOBAL_MANA_SPEED_UP_SECONDS); - let regen = if self.is_on_overtime { - let seconds_left = - (self.match_length_seconds() + self.overtime_length_seconds() - elapsed).max(0); - let _ = seconds_left; + let overtime = self.is_on_overtime; + let seconds_left = (self.match_length_seconds() - elapsed).max(0); + let global_regen = if overtime { crate::LogicGlobals::number(GLOBAL_MANA_REGEN_OVERTIME) + } else if seconds_left <= speed_up { + crate::LogicGlobals::number(GLOBAL_MANA_REGEN_END) } else { - let seconds_left = (self.match_length_seconds() - elapsed).max(0); - if seconds_left <= speed_up { - crate::LogicGlobals::number(GLOBAL_MANA_REGEN_END) - } else { - crate::LogicGlobals::number(GLOBAL_MANA_REGEN) - } + crate::LogicGlobals::number(GLOBAL_MANA_REGEN) }; - let step = regen.saturating_mul(MANA_RATE_SCALE) / max_mana; - if step < 1 { - return; - } + let npc = self.is_npc_battle().then(|| self.npc.as_npc()).flatten(); + let account_ids = self.account_ids; for entry in self.objects.objects.iter_mut() { + let owner = entry.owner_index().clamp(0, 1) as usize; let LogicObjectBody::Summoner(summoner) = &mut entry.body else { continue; }; + let regen = match npc.as_ref() { + Some(npc) if account_ids[owner].high == -1 => { + if overtime { + npc.mana_regen_ms_overtime() + } else if seconds_left <= speed_up { + npc.mana_regen_ms_end() + } else { + npc.mana_regen_ms() + } + } + _ => global_regen, + }; + let step = regen.saturating_mul(MANA_RATE_SCALE) / max_mana; + if step < 1 { + continue; + } summoner.mana_regen_timer += MANA_ACCUMULATOR_STEP; let gained = summoner.mana_regen_timer / step; if gained < 1 { @@ -224,15 +241,63 @@ impl LogicBattle { } } pub fn tick(&mut self, tick: i32) { + crate::battle::set_attribution_tick(tick); self.activate_summoners(); - self.advance_deploy(); self.regenerate_mana(tick); self.cycle_summoner_decks(tick); self.retarget(); - self.resolve_collisions(); + let projectiles = self.resolve_attacks(); self.move_objects(); - self.resolve_attacks(); + for entry in projectiles { + self.objects.push(entry); + } + self.tick_projectiles(); + self.drain_lifetimes(); + self.advance_deploy(); + self.advance_spawns(); + self.update_combat_component_states(); self.remove_dead(); + self.reconcile_pending_physical_damage(); + } + fn reconcile_pending_physical_damage(&mut self) { + let mut owed: Vec<(LogicGameObjectRef, i32)> = Vec::new(); + for entry in &self.objects.objects { + let LogicObjectBody::Projectile(projectile) = &entry.body else { + continue; + }; + if projectile.destroyed || projectile.target.is_none() { + continue; + } + let Some(data) = entry.data.as_projectile() else { + continue; + }; + if !data.uses_pending_physical_damage() { + continue; + } + let damage = data.damage(projectile.level_index.max(0) as usize); + match owed + .iter_mut() + .find(|(target, _)| *target == projectile.target) + { + Some((_, total)) => *total += damage, + None => owed.push((projectile.target, damage)), + } + } + for entry in self.objects.objects.iter_mut() { + let want = owed + .iter() + .find(|(target, _)| *target == entry.global_id) + .map(|(_, total)| *total) + .unwrap_or(0); + let held = match &mut entry.body { + LogicObjectBody::Character(character) => &mut character.pending_physical_damage, + LogicObjectBody::Summoner(summoner) => { + &mut summoner.character.pending_physical_damage + } + LogicObjectBody::Projectile(_) => continue, + }; + *held = want; + } } fn cycle_summoner_decks(&mut self, tick: i32) { let elapsed = tick / BATTLE_TICKS_PER_SECOND; @@ -316,15 +381,24 @@ impl LogicBattle { fn advance_deploy(&mut self) { for entry in self.objects.objects.iter_mut() { let has_movement = matches!( - entry.components.get(crate::battle::logic_component::COMPONENT_MOVEMENT), + entry + .components + .get(crate::battle::logic_component::COMPONENT_MOVEMENT), Some(Some(_)) ); let LogicObjectBody::Character(character) = &mut entry.body else { continue; }; + if character.state == CHARACTER_STATE_DEPLOY && character.deploy_timer <= 0 { + character.state = if has_movement { + CHARACTER_STATE_MOVING + } else { + 0 + }; + continue; + } if character.deploy_timer > 0 { - character.deploy_timer = - (character.deploy_timer - TICK_MILLISECONDS).max(0); + character.deploy_timer = (character.deploy_timer - TICK_MILLISECONDS).max(0); if character.deploy_timer == 0 { character.state = if has_movement { CHARACTER_STATE_MOVING @@ -335,6 +409,43 @@ impl LogicBattle { } } } + fn update_combat_component_states(&mut self) { + for entry in self.objects.objects.iter_mut() { + let has_combat = matches!( + entry.components.get(COMPONENT_COMBAT), + Some(Some(LogicComponent::Combat(_))) + ); + if !has_combat { + continue; + } + let hitpoints = match entry.components.get(COMPONENT_HITPOINT) { + Some(Some(LogicComponent::Hitpoint(component))) => Some(component.hitpoints), + _ => None, + }; + let LogicObjectBody::Character(character) = &mut entry.body else { + continue; + }; + let alive = match hitpoints { + Some(hitpoints) => hitpoints > 0, + None => character.deploy_timer > 0, + }; + let reload_timer = character.reload.map(|(timer, _)| timer).unwrap_or(0); + let on = alive && character.deploy_timer <= 0 && reload_timer == 0; + if on { + character.base.component_mask |= 1 << COMPONENT_COMBAT; + } else { + character.base.component_mask &= !(1 << COMPONENT_COMBAT); + } + if !on { + if let Some(Some(LogicComponent::Combat(combat))) = + entry.components.get_mut(COMPONENT_COMBAT) + { + combat.target = crate::battle::logic_game_object_ref::LogicGameObjectRef::NONE; + combat.target_index = None; + } + } + } + } fn retarget(&mut self) { let snapshot: Vec<(i32, (i32, i32), LogicCharacterStats, bool)> = self .objects @@ -345,13 +456,29 @@ impl LogicBattle { entry.owner_index(), entry.position(), entry.stats(), - entry.is_alive(), + entry.is_alive() && !entry.is_projectile(), ) }) .collect(); + let is_summoner: Vec = self + .objects + .objects + .iter() + .map(|entry| matches!(entry.body, LogicObjectBody::Summoner(_))) + .collect(); for index in 0..self.objects.objects.len() { let (owner, position, stats, alive) = snapshot[index]; - if !alive || stats.range < 1 || self.objects.objects[index].is_deploying() { + if !alive || stats.range < 1 { + if alive { + self.attribute_none(index, "retarget: skipped, Range column below 1"); + } + continue; + } + if self.objects.objects[index] + .combat_mut() + .map(|component| component.attack_finish_timer >= 1) + .unwrap_or(false) + { continue; } let mut best: Option<(i32, usize)> = None; @@ -370,7 +497,12 @@ impl LogicBattle { if !other_stats.flying && !stats.attacks_ground { continue; } - let reach = stats.sight_range + other_stats.collision_radius; + let building_bonus = if other_stats.is_building() || is_summoner[other] { + SIGHT_BUILDING_BONUS + } else { + 0 + }; + let reach = stats.sight_range + other_stats.collision_radius + building_bonus; let distance = distance_squared(position, *other_position); if distance > reach.saturating_mul(reach) { continue; @@ -379,83 +511,249 @@ impl LogicBattle { best = Some((distance, other)); } } - let target = best.map(|(_, other)| other); + let target = best + .map(|(_, other)| other) + .or_else(|| self.default_target(index)); + let target_ref = target + .map(|other| self.objects.objects[other].global_id) + .unwrap_or(crate::battle::logic_game_object_ref::LogicGameObjectRef::NONE); + if target.is_none() { + self.attribute_none( + index, + if best.is_none() { + "retarget: nothing in sight AND default_target returned None" + } else { + "retarget: default fallback" + }, + ); + } if let Some(component) = self.objects.objects[index].combat_mut() { component.target_index = target; + component.target = target_ref; } } } + fn attribute_none(&self, index: usize, site: &'static str) { + if !crate::battle::target_attribution_enabled() { + return; + } + let entry = &self.objects.objects[index]; + let id = entry + .global_id + .0 + .map(|id| (id.class_id, id.instance_id)) + .unwrap_or((0, 0)); + if !crate::battle::attribution_first_time(id, site) { + return; + } + tracing::warn!( + tick = crate::battle::attribution_tick(), + object = ?entry.global_id.0, + data = %entry.data.name(), + state = entry.body.state(), + site, + "combat target left NONE" + ); + } + fn is_crown_tower(&self, index: usize) -> bool { + let id = self.objects.objects[index].global_id; + self.leaders.contains(&id) || self.leader_towers.iter().any(|towers| towers.contains(&id)) + } fn default_target(&self, index: usize) -> Option { let owner = self.objects.objects[index].owner_index(); let from = self.objects.objects[index].position(); - let mut building: Option<(i32, usize)> = None; - let mut anything: Option<(i32, usize)> = None; - for (other, entry) in self.objects.objects.iter().enumerate() { - if other == index || entry.owner_index() == owner || !entry.is_alive() { - continue; - } - let distance = distance_squared(from, entry.position()); - let closer = |best: &Option<(i32, usize)>| { - best.map(|(closest, _)| distance < closest).unwrap_or(true) - }; - if entry.stats().is_building() && closer(&building) { - building = Some((distance, other)); - } - if closer(&anything) { - anything = Some((distance, other)); + let enemy = if owner == 0 { 1usize } else { 0usize }; + let position_of = |target: &LogicGameObjectRef| { + self.objects + .objects + .iter() + .position(|entry| entry.global_id == *target && entry.is_alive()) + }; + let king = self.leaders.get(enemy).copied()?; + let mut best = position_of(&king)?; + let towers = self.leader_towers.get(enemy)?; + let total = self + .tilemap + .as_ref() + .map(|tilemap| tilemap.princess_towers().len() as i32) + .unwrap_or(4); + if towers.len() as i32 == total / 2 { + let mut best_distance = distance_squared(from, self.objects.objects[best].position()); + for tower in towers { + let Some(other) = position_of(tower) else { + continue; + }; + let distance = distance_squared(from, self.objects.objects[other].position()); + if distance < best_distance { + best = other; + best_distance = distance; + } } } - building.or(anything).map(|(_, other)| other) + Some(best) } - fn next_waypoint( + fn check_avoidance( &mut self, index: usize, from: (i32, i32), - goal: (i32, i32), - flying: bool, - ) -> (i32, i32) { - let stale = match self.objects.objects[index].movement() { - Some(movement) => movement.goal != Some(goal) || movement.path.is_empty(), - None => return goal, + width: i32, + snapshot: Option<&[(i32, i32)]>, + ) { + if self.objects.objects[index].body.state() == 4 { + if let Some(m) = self.objects.objects[index].movement_mut() { + m.avoidance_side_step = 0; + } + return; + } + let (is_stopped, charge, cur_side) = match self.objects.objects[index].movement() { + Some(m) => (m.is_stopped, m.charge_time, m.avoidance_side_step), + None => return, }; - if stale { - let path = self - .tilemap - .as_ref() - .map(|tilemap| crate::battle::find_path(tilemap, from, goal, flying)) - .unwrap_or_default(); - if let Some(movement) = self.objects.objects[index].movement_mut() { - movement.goal = Some(goal); - movement.route = path; + if !is_stopped { + let dir = self.body_direction(index); + let radius = self.objects.objects[index] + .stats() + .collision_radius + .min(COLLISION_RADIUS_CAP); + let look = (from.0 + dir.0, from.1 + dir.1); + let self_z = self.objects.objects[index].body.base().z; + let self_mass = self.objects.objects[index].stats().mass; + let next_node = self.objects.objects[index] + .movement() + .and_then(|m| m.path.last().copied()); + let path_len = self.objects.objects[index] + .movement() + .map(|m| m.path.len()) + .unwrap_or(0); + let mut unit_count = 0i32; + let mut building_count = 0i32; + let mut unit_side_bit = 0i32; + let mut building_side_bit = 0i32; + let mut node_covered = false; + const CELL_SHIFT: i32 = 10; + let qx0 = (look.0 - radius) >> CELL_SHIFT; + let qy0 = (look.1 - radius) >> CELL_SHIFT; + let mut candidates: Vec<(i32, i32, usize)> = Vec::new(); + for other in 0..self.objects.objects.len() { + if other == index || self.objects.objects[other].is_projectile() { + continue; + } + let opos = self.objects.objects[other].position(); + let oradius = self.objects.objects[other].stats().collision_radius; + if oradius < 1 { + continue; + } + let reach = oradius + radius; + if distance_squared(look, opos) >= reach.saturating_mul(reach) { + continue; + } + if (self_z > 0) != (self.objects.objects[other].body.base().z > 0) { + continue; + } + let snap = snapshot + .and_then(|positions| positions.get(other).copied()) + .unwrap_or(opos); + candidates.push(( + ((snap.0 - oradius) >> CELL_SHIFT).max(qx0).max(0), + ((snap.1 - oradius) >> CELL_SHIFT).max(qy0).max(0), + other, + )); + } + candidates.sort_unstable(); + for (_, _, other) in candidates { + let opos = self.objects.objects[other].position(); + let oradius = self.objects.objects[other].stats().collision_radius; + let cross = dir.1 * (opos.0 - from.0) - dir.0 * (opos.1 - from.1); + let cross_bit = if cross < 0 { 1 } else { 0 }; + if self.objects.objects[other].movement().is_some() { + let odir = self.body_direction(other); + let o_stopped = self.objects.objects[other] + .movement() + .map(|m| m.is_stopped) + .unwrap_or(false); + let v34 = if o_stopped { + 0 + } else { + odir.0 * dir.0 + odir.1 * dir.1 + }; + let head_on = if charge < 10000 { + v34 <= 0 + } else { + v34 <= 0 && self_mass <= self.objects.objects[other].stats().mass + }; + if head_on { + unit_count += 1; + let o_side = self.objects.objects[other] + .movement() + .map(|m| m.avoidance_side_step) + .unwrap_or(0); + unit_side_bit = if o_side != 0 { + (o_side > 0) as i32 + } else { + cross_bit + }; + } + } else { + if let Some(node) = next_node { + if path_len >= 2 { + let node_x = 500 * (node % width) + 250; + let node_y = 500 * (node / width) + 250; + if distance_squared(opos, (node_x, node_y)) + < oradius.saturating_mul(oradius) + { + node_covered = true; + } + } + } + building_side_bit = cross_bit; + building_count += 1; + } + } + if node_covered { + if let Some(m) = self.objects.objects[index].movement_mut() { + m.path.pop(); + } + } + let side_bit = if building_count > 0 { + building_side_bit + } else { + unit_side_bit + }; + if building_count + unit_count >= 1 { + let new_side = if cur_side != 0 { + if building_count >= 1 { + let nudge = if side_bit == 1 { 20 } else { -20 }; + (cur_side + nudge).clamp(-200, 200) + } else { + cur_side + } + } else if side_bit == 1 { + 200 + } else { + -200 + }; + if let Some(m) = self.objects.objects[index].movement_mut() { + m.avoidance_side_step = new_side; + } } } - let reached = self - .objects - .objects - .get(index) - .and_then(LogicGameObjectEntry::movement) - .and_then(|movement| movement.route.first().copied()) - .map(|node| { - let close = distance_squared(from, node) <= WAYPOINT_REACHED * WAYPOINT_REACHED; - (node, close) - }); - match reached { - Some((node, true)) => { - if let Some(movement) = self.objects.objects[index].movement_mut() { - if !movement.route.is_empty() { - movement.route.remove(0); - } - } - let _ = node; - self.objects - .objects - .get(index) - .and_then(LogicGameObjectEntry::movement) - .and_then(|movement| movement.route.first().copied()) - .unwrap_or(goal) - } - Some((node, false)) => node, - None => goal, + if let Some(m) = self.objects.objects[index].movement_mut() { + let s = m.avoidance_side_step; + m.avoidance_side_step = if s < 1 { + (s + 10).min(0) + } else { + (s - 10).max(0) + }; + } + } + fn body_direction(&self, index: usize) -> (i32, i32) { + match &self.objects.objects[index].body { + LogicObjectBody::Character(character) => (character.direction.x, character.direction.y), + LogicObjectBody::Summoner(summoner) => ( + summoner.character.direction.x, + summoner.character.direction.y, + ), + LogicObjectBody::Projectile(_) => (0, 0), } } fn move_objects(&mut self) { @@ -465,142 +763,279 @@ impl LogicBattle { .iter() .map(LogicGameObjectEntry::position) .collect(); + let width = self.tilemap.as_ref().map(|map| map.width()).unwrap_or(1); + let (limit_x, limit_y) = self + .tilemap + .as_ref() + .map(|map| { + ( + map.width() * SUBTILE_UNITS - 1, + map.height() * SUBTILE_UNITS - 1, + ) + }) + .unwrap_or((i32::MAX, i32::MAX)); 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() - || self.objects.objects[index].is_deploying() - { + if self.objects.objects[index].movement().is_none() { continue; } - let target = match self.objects.objects[index] + let stats = self.objects.objects[index].stats(); + let from = positions[index]; + let stopped = self.objects.objects[index] + .movement() + .map(|m| m.is_stopped) + .unwrap_or(false); + let live = stats.speed >= 1 + && self.objects.objects[index].is_alive() + && !self.objects.objects[index].is_deploying() + && !stopped; + if self.objects.objects[index].is_alive() && !self.objects.objects[index].is_deploying() + { + self.check_avoidance(index, from, width, Some(&positions)); + } + let target = self.objects.objects[index] .combat_mut() .and_then(|component| component.target_index) - { - Some(target) => target, - None => match self.default_target(index) { - Some(target) => target, - None => continue, - }, - }; - let Some(goal) = positions.get(target).copied() else { - continue; - }; - let from = positions[index]; - let stand_off = stats.range; - let straight = crate::logic_sqrt(distance_squared(from, goal)) as i64; - if straight <= stand_off as i64 || straight == 0 { - continue; + .or_else(|| self.default_target(index)); + let target_pos = target.and_then(|t| self.objects.objects.get(t).map(|e| e.position())); + let mut footstep_halt = false; + if live { + if let Some(movement) = self.objects.objects[index].movement_mut() { + if !movement.is_stopped { + movement.move_timer += TICK_MILLISECONDS; + let stop_after = stats.stop_movement_after_ms; + if stop_after >= 1 && movement.move_timer > stop_after { + let cycle = stats.wait_ms + stop_after; + if movement.move_timer >= cycle { + movement.move_timer -= cycle; + } else { + footstep_halt = true; + } + } + } + } } - let waypoint = self.next_waypoint(index, from, goal, stats.flying); - let dx = (waypoint.0 - from.0) as i64; - let dy = (waypoint.1 - from.1) as i64; - let leg = crate::logic_sqrt(distance_squared(from, waypoint)) as i64; - if leg == 0 { - continue; + let mut new_goal: Option<(i32, i32)> = None; + let mut rebuilt_path: Option> = None; + let mut do_step = false; + if live { + if let Some(tp) = target_pos { + let straight = crate::logic_sqrt(distance_squared(from, tp)) as i64; + if straight != 0 { + let lane_id = self.objects.objects[index].body.lane_id(); + let owner_top = self.objects.objects[index].owner_index() == 1; + let (path_empty, path0_tile) = match self.objects.objects[index].movement() + { + Some(m) => ( + m.path.is_empty(), + m.path.first().map(|&n| (n % width, n / width)), + ), + None => (true, None), + }; + if let Some(tm) = self.tilemap.as_ref() { + if let Some(goal_tile) = crate::battle::closest_tile_position_to_target( + tm, + from.0, + from.1, + tp.0, + tp.1, + crate::battle::tile_of(tp.0), + crate::battle::tile_of(tp.1), + stats.range, + 0, + owner_top, + ) { + new_goal = Some(goal_tile); + do_step = true; + if path_empty || path0_tile != Some(goal_tile) { + let start_tile = ( + crate::battle::tile_of(from.0), + crate::battle::tile_of(from.1), + ); + let mut path = if stats.flying { + vec![goal_tile.0 + width * goal_tile.1] + } else { + crate::battle::find_path( + tm, start_tile, goal_tile, lane_id, stats.jump, + ) + }; + path.dedup(); + rebuilt_path = Some(path); + } + } + } + } + } + } + if let Some(goal_tile) = new_goal { + if let Some(path) = rebuilt_path { + let normal = crate::battle::path_target_normal(&path, from.0, from.1, width); + if let Some(movement) = self.objects.objects[index].movement_mut() { + movement.goal = Some(goal_tile); + movement.path = path; + movement.path_target_normal = LogicVector2::new(normal.0, normal.1); + } + } + } + let (path, normal) = match self.objects.objects[index].movement() { + Some(m) => ( + m.path.clone(), + (m.path_target_normal.x, m.path_target_normal.y), + ), + None => (Vec::new(), (0, 0)), + }; + let start_tile = ( + crate::battle::tile_of(from.0), + crate::battle::tile_of(from.1), + ); + let goto = crate::battle::target_position_where_going_now( + &path, + width, + target_pos, + start_tile.0, + start_tile.1, + ); + let (mut mx, mut my) = (0i32, 0i32); + if do_step && !footstep_halt { + let leg = crate::logic_sqrt(distance_squared(from, goto)) as i64; + if leg != 0 { + let step = (stats.speed as i64).min(MAX_MOVE_STEP).min(leg); + let dx = (goto.0 - from.0) as i64; + let dy = (goto.1 - from.1) as i64; + mx = ((((dx << 8) / leg) * step) >> 8) as i32; + my = ((((dy << 8) / leg) * step) >> 8) as i32; + let side = self.objects.objects[index] + .movement() + .map(|m| m.avoidance_side_step) + .unwrap_or(0); + if side != 0 && (mx != 0 || my != 0) { + let s = side.clamp(-256, 256) as i64; + let (sx, sy) = (mx as i64, my as i64); + let mut rx = ((((256 - s.abs()) * sx) >> 8) + ((s * sy) >> 8)) as i32; + let mut ry = ((((256 - s.abs()) * sy) >> 8) + ((-(sx * s)) >> 8)) as i32; + crate::battle::logic_pathfinder::normalize_to( + &mut rx, + &mut ry, + step as i32, + ); + mx = rx; + my = ry; + } + } + } + let (shx, shy) = self.collision_shove(index, from); + let mut new_pos = from; + if mx != 0 || my != 0 || shx != 0 || shy != 0 { + new_pos = ( + (from.0 + mx + shx as i32).clamp(0, limit_x), + (from.1 + my + shy as i32).clamp(0, limit_y), + ); + let base = self.objects.objects[index].body.base_mut(); + base.position.x = new_pos.0; + base.position.y = new_pos.1; + } + if do_step { + let dir = crate::battle::path_target_normal(&path, from.0, from.1, width); + if dir != (0, 0) { + if let LogicObjectBody::Character(character) = + &mut self.objects.objects[index].body + { + character.direction = LogicVector2::new(dir.0, dir.1); + } + } + } + let reached = ((normal.1 * (goto.1 - new_pos.1)) / 256 + + (normal.0 * (goto.0 - new_pos.0)) / 256) + < 1001; + let mut popped = false; + if let Some(movement) = self.objects.objects[index].movement_mut() { + movement.path_node_reached = reached; + if reached && !movement.path.is_empty() { + movement.path.pop(); + popped = true; + } + } + if popped { + let np = match self.objects.objects[index].movement() { + Some(m) => m.path.clone(), + None => Vec::new(), + }; + let normal2 = crate::battle::path_target_normal(&np, new_pos.0, new_pos.1, width); + if let Some(movement) = self.objects.objects[index].movement_mut() { + movement.path_target_normal = LogicVector2::new(normal2.0, normal2.1); + } } - let step = (stats.speed as i64) - .min(MAX_MOVE_STEP) - .min(straight - stand_off as i64) - .min(leg); - let base = self.objects.objects[index].body.base_mut(); - base.position.x = from.0 + (dx * step / leg) as i32; - base.position.y = from.1 + (dy * step / leg) as i32; } } - fn resolve_collisions(&mut self) { - let bodies: Vec = self - .objects - .objects - .iter() - .map(|entry| CollisionBody { - alive: entry.is_alive(), - position: entry.position(), - stats: entry.stats(), - owner: entry.owner_index(), - moves: entry.movement().is_some(), - }) - .collect(); - for index in 0..self.objects.objects.len() { - let CollisionBody { - alive, - position: mine, - stats, - owner, - moves, - } = &bodies[index]; - if !alive || !moves || stats.collision_radius < 1 { - continue; - } - let mut push = (0i64, 0i64, 0i32); - for (other, body) in bodies.iter().enumerate() { - let (theirs, other_stats, other_moves) = (&body.position, &body.stats, body.moves); - if other == index || !body.alive || other_stats.collision_radius < 1 { - continue; - } - if stats.flying != other_stats.flying { - continue; - } - let reach = if other_moves { - stats.collision_radius - } else { - stats.collision_radius.min(COLLISION_RADIUS_CAP) - }; - let sum = reach + other_stats.collision_radius; - let (mut dx, mut dy) = (mine.0 - theirs.0, mine.1 - theirs.1); - if dx.abs() > sum || dy.abs() > sum { - continue; - } - let mut square = dx * dx + dy * dy; - if square == 0 { - dx = 0; - dy = if *owner == 0 { -1 } else { 1 }; - square = 1; - } - if square > sum.saturating_mul(sum) { - continue; - } - let distance = crate::logic_sqrt(square).max(1) as i64; - let other_mass = if other_stats.is_building() { - MASS_FOR_STATIC - } else { - other_stats.mass - }; - let overlap = (sum - distance as i32).clamp(0, COLLISION_OVERLAP_CAP); - let strength = - ((overlap * other_mass) / stats.mass.max(MASS_MIN) + 1).min(COLLISION_PUSH_CAP); - push.0 += (strength as i64 * dx as i64) / distance; - push.1 += (strength as i64 * dy as i64) / distance; - push.2 += 1; - } - if push.2 == 0 { - continue; - } - let mut shove = (push.0 / push.2 as i64, push.1 / push.2 as i64); - let allowance = COLLISION_PUSH_LIMIT; - let travelled = - crate::logic_sqrt(distance_squared((0, 0), (shove.0 as i32, shove.1 as i32))) - as i64; - if travelled > allowance { - shove.0 = shove.0 * allowance / travelled; - shove.1 = shove.1 * allowance / travelled; - } - let (limit_x, limit_y) = self - .tilemap - .as_ref() - .map(|map| { - ( - map.width() * SUBTILE_UNITS - 1, - map.height() * SUBTILE_UNITS - 1, - ) - }) - .unwrap_or((i32::MAX, i32::MAX)); - let base = self.objects.objects[index].body.base_mut(); - base.position.x = (base.position.x + shove.0 as i32).clamp(0, limit_x); - base.position.y = (base.position.y + shove.1 as i32).clamp(0, limit_y); + fn collision_shove(&self, index: usize, from: (i32, i32)) -> (i64, i64) { + let stats = self.objects.objects[index].stats(); + let owner = self.objects.objects[index].owner_index(); + if stats.collision_radius < 1 { + return (0, 0); } + let mut push = (0i64, 0i64, 0i32); + for other in 0..self.objects.objects.len() { + if other == index { + continue; + } + let entry = &self.objects.objects[other]; + if !entry.is_alive() || entry.is_projectile() { + continue; + } + let other_stats = entry.stats(); + if other_stats.collision_radius < 1 { + continue; + } + if stats.flying != other_stats.flying { + continue; + } + let other_moves = entry.movement().is_some(); + let theirs = entry.position(); + let reach = if other_moves { + stats.collision_radius + } else { + stats.collision_radius.min(COLLISION_RADIUS_CAP) + }; + let sum = reach + other_stats.collision_radius; + let (mut dx, mut dy) = (from.0 - theirs.0, from.1 - theirs.1); + if dx.abs() > sum || dy.abs() > sum { + continue; + } + let mut square = dx * dx + dy * dy; + if square == 0 { + dx = 0; + dy = if owner == 1 { -1 } else { 1 }; + square = 1; + } + if square > sum.saturating_mul(sum) { + continue; + } + let distance = crate::logic_sqrt(square).max(1) as i64; + let other_mass = if other_stats.is_building() { + MASS_FOR_STATIC + } else { + other_stats.mass + }; + let overlap = (sum - distance as i32).clamp(0, COLLISION_OVERLAP_CAP); + let strength = + ((overlap * other_mass) / stats.mass.max(MASS_MIN) + 1).min(COLLISION_PUSH_CAP); + push.0 += (strength as i64 * dx as i64) / distance; + push.1 += (strength as i64 * dy as i64) / distance; + push.2 += 1; + } + if push.2 == 0 { + return (0, 0); + } + let mut shove = (push.0 / push.2 as i64, push.1 / push.2 as i64); + let allowance = COLLISION_PUSH_LIMIT; + let travelled = + crate::logic_sqrt(distance_squared((0, 0), (shove.0 as i32, shove.1 as i32))) as i64; + if travelled > allowance { + shove.0 = shove.0 * allowance / travelled; + shove.1 = shove.1 * allowance / travelled; + } + shove } - fn resolve_attacks(&mut self) { + fn resolve_attacks(&mut self) -> Vec { let positions: Vec<(i32, i32)> = self .objects .objects @@ -613,75 +1048,625 @@ impl LogicBattle { .iter() .map(|entry| entry.stats().collision_radius) .collect(); - let mut hits: Vec<(usize, i32)> = Vec::new(); + let mut fires: Vec<(usize, usize, i32, i32, Option)> = Vec::new(); + let notice_time = crate::LogicGlobals::number(GLOBAL_DAMAGE_NOTICE_TIME); + let attack_finish_time = crate::LogicGlobals::number(GLOBAL_ATTACK_FINISH_TIME); for index in 0..self.objects.objects.len() { let stats = self.objects.objects[index].stats(); let alive = self.objects.objects[index].is_alive(); let deploying = self.objects.objects[index].is_deploying(); + if let Some(component) = self.objects.objects[index].combat_mut() { + component.age_attackers(TICK_MILLISECONDS); + } let special_interval = self.objects.objects[index] .data - .data() - .map(|row| row.int(CHARACTER_SPECIAL_ATTACK_INTERVAL_COLUMN)) + .as_character() + .map(|character| character.special_attack_interval()) .unwrap_or(0); let hit_speed = stats.hit_speed; let load_time = stats.load_time.max(0); let range = stats.range; let damage = stats.damage; - let Some(component) = self.objects.objects[index].combat_mut() else { - continue; - }; - component.field_52 = (component.field_52 - TICK_MILLISECONDS).max(0); - component.field_60 = (component.field_60 - TICK_MILLISECONDS).max(0); - if !alive || deploying || hit_speed < 1 { + let level = self.objects.objects[index].level_index(); + let projectile = self.objects.objects[index] + .data + .as_character() + .map(|character| character.projectile().to_owned()) + .filter(|name| !name.is_empty()) + .map(|name| LogicDataRef::by_name(crate::data::table::PROJECTILES, &name)) + .filter(|data| data.is_resolved()); + if self.objects.objects[index].combat_mut().is_none() { continue; } - let Some(target) = component.target_index else { - component.hit_timer = 0; - continue; - }; - let reach = range + radii.get(target).copied().unwrap_or(0); - if distance_squared(positions[index], positions[target]) - > reach.saturating_mul(reach) + let mut attacking = false; + let mut attack_target: Option = None; + let mut melee_fire: Option = None; + let mut finish_hold = false; + let force_finish = self.objects.objects[index] + .data + .as_character() + .map(|character| character.force_attack_animation_to_end()) + .unwrap_or(false); + let pushed_back = self.objects.objects[index] + .movement() + .map(|movement| movement.is_pushed_back) + .unwrap_or(false); { - component.hit_timer = 0; - continue; - } - let before = component.hit_timer; - let mut seeded = component.hit_timer; - if seeded == 0 && !component.flag_73 { - if load_time <= hit_speed { - seeded = load_time - component.field_52; - component.hit_timer = seeded; - component.field_52 = load_time; - } else if component.field_52 > hit_speed { - component.hit_timer = 0; - continue; - } else { - component.field_52 = 0; - seeded = 0; + let component = self.objects.objects[index].combat_mut().unwrap(); + component.load_timer = (component.load_timer - TICK_MILLISECONDS).max(0); + component.dash_cooldown = (component.dash_cooldown - TICK_MILLISECONDS).max(0); + if alive && !deploying && hit_speed >= 1 { + let hit_timer_now = component.hit_timer; + match component.target_index { + Some(target) + if { + let reach = range + radii.get(target).copied().unwrap_or(0); + let in_range = + distance_squared(positions[index], positions[target]) + <= reach.saturating_mul(reach); + !pushed_back && (in_range || hit_timer_now % hit_speed.max(1) >= 51) + } => + { + attacking = true; + attack_target = Some(target); + let before = component.hit_timer; + let mut seeded = component.hit_timer; + let mut fired_ok = true; + if seeded == 0 && !component.charge_ready { + if load_time <= hit_speed { + seeded = load_time - component.load_timer; + component.hit_timer = seeded; + component.load_timer = load_time; + } else if component.load_timer > hit_speed { + component.hit_timer = 0; + fired_ok = false; + } else { + component.load_timer = 0; + seeded = 0; + } + } + if fired_ok { + if component.charge_ready { + component.hit_timer = + seeded + hit_speed - seeded % hit_speed.max(1); + component.charge_ready = false; + } else { + component.hit_timer = seeded + TICK_MILLISECONDS; + } + if component.hit_timer / hit_speed.max(1) + > before / hit_speed.max(1) + { + component.load_timer = load_time; + if special_interval >= 2 { + component.special_attack_counter = if component + .special_attack_counter + == special_interval - 1 + { + 0 + } else { + component.special_attack_counter + 1 + }; + } + fires.push((index, target, damage, level, projectile.clone())); + if projectile.is_none() { + melee_fire = Some(target); + } else { + debug_assert!( + hit_speed > TICK_MILLISECONDS, + "a HitSpeed of one tick would fire twice on the replay" + ); + component.dash_cooldown = + (component.dash_cooldown - TICK_MILLISECONDS).max(0); + component.load_timer = + (component.load_timer - TICK_MILLISECONDS).max(0); + component.age_attackers(TICK_MILLISECONDS); + component.hit_timer += TICK_MILLISECONDS; + } + } + } + } + None => { + if component.attack_finish_timer >= 1 { + component.attack_finish_timer += TICK_MILLISECONDS; + let limit = if force_finish { + hit_speed + - TICK_MILLISECONDS + - component.hit_timer % hit_speed.max(1) + } else { + attack_finish_time + }; + if component.attack_finish_timer >= limit { + component.hit_timer = 0; + component.attack_finish_timer = 0; + } + finish_hold = true; + } else { + component.hit_timer = 0; + } + } + Some(_) => { + component.hit_timer = 0; + component.attack_finish_timer = 0; + } + } } } - if component.flag_73 { - component.hit_timer = seeded + hit_speed - seeded % hit_speed.max(1); - component.flag_73 = false; - } else { - component.hit_timer = seeded + TICK_MILLISECONDS; - } - if component.hit_timer / hit_speed.max(1) > before / hit_speed.max(1) { - component.field_52 = load_time; - if special_interval >= 2 { - component.field_64 = if component.field_64 == special_interval - 1 { - 0 - } else { - component.field_64 + 1 - }; + if alive && !deploying && !finish_hold { + let old_state = self.objects.objects[index].body.state(); + let has_move = self.objects.objects[index].movement().is_some(); + if attacking { + if let Some(tp) = attack_target.and_then(|t| positions.get(t).copied()) { + let from = positions[index]; + let mut dx = tp.0 - from.0; + let mut dy = tp.1 - from.1; + crate::battle::logic_pathfinder::normalize_to(&mut dx, &mut dy, 256); + self.objects.objects[index] + .body + .set_direction(LogicVector2::new(dx, dy)); + } + self.objects.objects[index] + .body + .set_state(CHARACTER_STATE_ATTACK); + if old_state != CHARACTER_STATE_ATTACK { + if let Some(movement) = self.objects.objects[index].movement_mut() { + movement.is_stopped = true; + movement.path.clear(); + } + } + } else if old_state == CHARACTER_STATE_ATTACK { + let default_state = if has_move { CHARACTER_STATE_MOVING } else { 0 }; + self.objects.objects[index].body.set_state(default_state); + if let Some(movement) = self.objects.objects[index].movement_mut() { + movement.is_stopped = false; + } + } + } + if let Some(victim) = melee_fire { + let attacker = self.objects.objects[index].global_id; + if let Some(component) = self.objects.objects[victim].combat_mut() { + component.note_attacker(attacker, notice_time); } - hits.push((target, damage)); } } - for (target, amount) in hits { - if let Some(entry) = self.objects.objects.get_mut(target) { - entry.damage(amount); + let mut projectiles: Vec = Vec::new(); + for (source, target, amount, level, projectile) in fires { + match projectile { + Some(data) => projectiles.push(self.build_projectile(source, target, level, data)), + None => { + let (sx, sy) = positions[source]; + let (tx, ty) = positions[target]; + if let Some(entry) = self.objects.objects.get_mut(target) { + entry.damage_directional(amount, tx - sx, ty - sy); + } + } + } + } + projectiles + } + fn build_projectile( + &mut self, + source: usize, + target: usize, + level: i32, + data: LogicDataRef, + ) -> LogicGameObjectEntry { + use crate::battle::logic_game_object::LogicVector2; + let src_pos = self.objects.objects[source].position(); + let src_z = self.objects.objects[source].body.base().z; + let src_owner = self.objects.objects[source].owner_index(); + let src_id = self.objects.objects[source].global_id; + let src_char = self.objects.objects[source].data.as_character(); + let start_radius = src_char + .as_ref() + .map(|c| c.projectile_start_radius()) + .unwrap_or(0); + let y_offset = src_char + .as_ref() + .map(|c| c.projectile_y_offset()) + .unwrap_or(0); + let start_z_off = src_char + .as_ref() + .map(|c| c.projectile_start_z()) + .unwrap_or(0); + let tgt_pos = self.objects.objects[target].position(); + let tgt_id = self.objects.objects[target].global_id; + let (dx, dy) = (tgt_pos.0 - src_pos.0, tgt_pos.1 - src_pos.1); + let dist = crate::logic_sqrt(distance_squared(src_pos, tgt_pos)); + let (sdx, sdy) = if dist != 0 { + (dx * start_radius / dist, dy * start_radius / dist) + } else { + (0, 0) + }; + let y_off = if src_owner != 0 { -y_offset } else { y_offset }; + let start = (src_pos.0 + sdx, src_pos.1 + sdy + y_off); + let start_z = src_z + start_z_off; + let projectile_data = data.as_projectile(); + let uses_pending = projectile_data + .as_ref() + .map(|p| p.uses_pending_physical_damage()) + .unwrap_or(false); + let dmg = projectile_data + .as_ref() + .map(|p| p.damage(level.max(0) as usize)) + .unwrap_or(0); + if uses_pending { + self.objects.objects[target] + .body + .add_pending_physical_damage(dmg); + } + let instance = self + .objects + .reserve_instance(crate::battle::logic_projectile::PROJECTILE_OBJECT_TYPE); + let global_id = crate::battle::logic_game_object_ref::LogicGameObjectRef::of( + crate::battle::logic_projectile::PROJECTILE_OBJECT_TYPE + 1, + instance, + ); + let effect = src_char + .as_ref() + .map(|character| character.damage_effect()) + .filter(|effect| !effect.is_none()) + .or_else(|| projectile_data.as_ref().map(|shot| shot.damage_effect())) + .unwrap_or_default(); + let mut projectile = crate::battle::logic_projectile::LogicProjectile { + level_index: level, + target: tgt_id, + source: src_id, + effect, + start_position: LogicVector2::new(start.0, start.1), + start_z, + target_position: LogicVector2::new(tgt_pos.0, tgt_pos.1), + ..Default::default() + }; + projectile.base.owner_index = src_owner; + projectile.base.position = LogicVector2::new(start.0, start.1); + projectile.base.z = start_z; + LogicGameObjectEntry::new( + data, + global_id, + LogicObjectBody::Projectile(Box::new(projectile)), + Default::default(), + ) + } + fn tick_projectiles(&mut self) { + for index in 0..self.objects.objects.len() { + if !matches!( + self.objects.objects[index].body, + LogicObjectBody::Projectile(_) + ) { + continue; + } + let Some(projectile_data) = self.objects.objects[index].data.as_projectile() else { + continue; + }; + let speed = projectile_data.speed().max(1); + let level = self.objects.objects[index].level_index().max(0) as usize; + let (target_ref, offset) = match &self.objects.objects[index].body { + LogicObjectBody::Projectile(projectile) => (projectile.target, projectile.offset), + _ => continue, + }; + let target_index = self + .objects + .objects + .iter() + .position(|entry| entry.global_id == target_ref && entry.is_alive()); + if let Some(ti) = target_index { + if projectile_data.is_homing() { + let tpos = self.objects.objects[ti].position(); + let tz = self.objects.objects[ti].body.base().z; + if let LogicObjectBody::Projectile(projectile) = + &mut self.objects.objects[index].body + { + projectile.target_position = + crate::battle::logic_game_object::LogicVector2::new( + tpos.0 + offset.x, + tpos.1 + offset.y, + ); + projectile.target_z = tz; + } + } + } + let from = self.objects.objects[index].position(); + let goal = match &self.objects.objects[index].body { + LogicObjectBody::Projectile(projectile) => { + (projectile.target_position.x, projectile.target_position.y) + } + _ => continue, + }; + let distance = crate::logic_sqrt(distance_squared(from, goal)) as i32; + if distance <= speed { + if let Some(ti) = target_index { + let amount = projectile_data.damage(level); + let amount = + if projectile_data.has_reduced_tower_damage() && self.is_crown_tower(ti) { + reduced_crown_tower_damage(amount) + } else { + amount + }; + let (dir_x, dir_y, source) = match &self.objects.objects[index].body { + LogicObjectBody::Projectile(projectile) => ( + projectile.target_position.x - projectile.start_position.x, + projectile.target_position.y - projectile.start_position.y, + projectile.source, + ), + _ => (0, 0, LogicGameObjectRef::NONE), + }; + if projectile_data.uses_pending_physical_damage() { + self.objects.objects[ti] + .body + .add_pending_physical_damage(-amount); + } + self.objects.objects[ti].damage_directional(amount, dir_x, dir_y); + if source != LogicGameObjectRef::NONE { + let notice = crate::LogicGlobals::number(GLOBAL_DAMAGE_NOTICE_TIME); + if let Some(component) = self.objects.objects[ti].combat_mut() { + component.note_attacker(source, notice); + } + } + } + let base = self.objects.objects[index].body.base_mut(); + base.position = crate::battle::logic_game_object::LogicVector2::new(goal.0, goal.1); + base.z = 0; + if let LogicObjectBody::Projectile(projectile) = + &mut self.objects.objects[index].body + { + projectile.destroyed = true; + } + } else { + let (from_z, target_z) = match &self.objects.objects[index].body { + LogicObjectBody::Projectile(projectile) => { + (projectile.base.z, projectile.target_z) + } + _ => continue, + }; + let step_x = (goal.0 - from.0) as i64 * speed as i64 / distance.max(1) as i64; + let step_y = (goal.1 - from.1) as i64 * speed as i64 / distance.max(1) as i64; + let step_z = (target_z - from_z) as i64 * speed as i64 / distance.max(1) as i64; + let base = self.objects.objects[index].body.base_mut(); + base.position.x = from.0 + step_x as i32; + base.position.y = from.1 + step_y as i32; + base.z = from_z + step_z as i32; + } + } + } + fn advance_spawns(&mut self) { + use crate::battle::logic_game_object::CHARACTER_OBJECT_TYPE; + let buff_type_count = crate::LogicDataTables::instance() + .table(crate::data::table::DAMAGE_TYPES) + .map(|rows| rows.count()) + .unwrap_or(0); + let mut requests: Vec<(usize, LogicDataRef, i32)> = Vec::new(); + for index in 0..self.objects.objects.len() { + let Some(cdata) = self.objects.objects[index].data.as_character() else { + continue; + }; + if cdata.spawn_character().is_empty() { + continue; + } + let spawn_name = cdata.spawn_character().to_owned(); + let spawn_number = cdata.spawn_number(); + let spawn_interval = cdata.spawn_interval(); + let spawn_pause = cdata.spawn_pause_time(); + let spawn_limit = cdata.spawn_limit(); + let spawn_level = cdata.spawn_character_level_index(); + let character = match &mut self.objects.objects[index].body { + LogicObjectBody::Character(c) => &mut **c, + LogicObjectBody::Summoner(s) => &mut s.character, + LogicObjectBody::Projectile(_) => continue, + }; + if character.deploy_timer > 0 { + continue; + } + if !(character.remaining_spawn_count > 0 || spawn_limit < 1) { + continue; + } + character.spawn_timer -= TICK_MILLISECONDS; + if character.spawn_timer > 0 { + continue; + } + let level = spawn_level + character.level_index; + character.field_120 += 1; + if spawn_limit >= 1 { + character.remaining_spawn_count -= 1; + } + let delay = if character.field_120 >= spawn_number { + character.field_120 = 0; + spawn_pause + } else { + spawn_interval + }; + character.spawn_timer = (character.spawn_timer + delay).max(1); + let data = LogicDataRef::by_name(crate::data::table::CHARACTERS_COMBINED, &spawn_name); + if !data.is_none() { + requests.push((index, data, level)); + } + } + let width = self.tilemap.as_ref().map(|map| map.width()).unwrap_or(1); + for (spawner, data, level) in requests { + let position = self.spawn_position(spawner, &data); + let owner = self.objects.objects[spawner].owner_index(); + let instance = self.objects.reserve_instance(CHARACTER_OBJECT_TYPE); + let mut entry = + build_spawned_character(data, instance, position, owner, level, buff_type_count); + if let Some(tilemap) = self.tilemap.as_ref() { + let lane = crate::battle::spawn_lane_of(tilemap, position.0, position.1); + entry.body.set_lane_id(lane); + } + let gid = entry.global_id; + self.objects.push(entry); + if let Some(idx) = self.objects.objects.iter().position(|e| e.global_id == gid) { + self.birth_move(idx, width); + } + } + } + fn birth_move(&mut self, index: usize, width: i32) { + let stats = self.objects.objects[index].stats(); + if stats.speed < 1 || self.objects.objects[index].is_deploying() { + return; + } + let from = self.objects.objects[index].position(); + self.check_avoidance(index, from, width, None); + let stopped = self.objects.objects[index] + .movement() + .map(|m| m.is_stopped) + .unwrap_or(false); + if stopped { + return; + } + if let Some(m) = self.objects.objects[index].movement_mut() { + m.move_timer += TICK_MILLISECONDS; + } + let target = self.objects.objects[index] + .combat_mut() + .and_then(|c| c.target_index); + let target = target.or_else(|| self.default_target(index)); + let Some(t) = target else { return }; + let tp = self.objects.objects[t].position(); + if crate::logic_sqrt(distance_squared(from, tp)) == 0 { + return; + } + let owner_top = self.objects.objects[index].owner_index() == 1; + let lane_id = self.objects.objects[index].body.lane_id(); + let goal_and_path = { + let Some(tm) = self.tilemap.as_ref() else { + return; + }; + let Some(goal_tile) = crate::battle::closest_tile_position_to_target( + tm, + from.0, + from.1, + tp.0, + tp.1, + crate::battle::tile_of(tp.0), + crate::battle::tile_of(tp.1), + stats.range, + 0, + owner_top, + ) else { + return; + }; + let start_tile = ( + crate::battle::tile_of(from.0), + crate::battle::tile_of(from.1), + ); + let mut path = if stats.flying { + vec![goal_tile.0 + width * goal_tile.1] + } else { + crate::battle::find_path(tm, start_tile, goal_tile, lane_id, stats.jump) + }; + path.dedup(); + (goal_tile, path) + }; + let (goal_tile, path) = goal_and_path; + let normal = crate::battle::path_target_normal(&path, from.0, from.1, width); + if let Some(m) = self.objects.objects[index].movement_mut() { + m.goal = Some(goal_tile); + m.path = path.clone(); + m.path_target_normal = LogicVector2::new(normal.0, normal.1); + } + let start_tile = ( + crate::battle::tile_of(from.0), + crate::battle::tile_of(from.1), + ); + let goto = crate::battle::target_position_where_going_now( + &path, + width, + Some(tp), + start_tile.0, + start_tile.1, + ); + let (mut mx, mut my) = (0i32, 0i32); + let leg = crate::logic_sqrt(distance_squared(from, goto)) as i64; + if leg != 0 { + let step = (stats.speed as i64).min(MAX_MOVE_STEP).min(leg); + mx = ((((goto.0 - from.0) as i64) << 8) / leg * step >> 8) as i32; + my = ((((goto.1 - from.1) as i64) << 8) / leg * step >> 8) as i32; + let side = self.objects.objects[index] + .movement() + .map(|m| m.avoidance_side_step) + .unwrap_or(0); + if side != 0 && (mx != 0 || my != 0) { + let s = side.clamp(-256, 256) as i64; + let (sx, sy) = (mx as i64, my as i64); + let mut rx = ((((256 - s.abs()) * sx) >> 8) + ((s * sy) >> 8)) as i32; + let mut ry = ((((256 - s.abs()) * sy) >> 8) + ((-(sx * s)) >> 8)) as i32; + crate::battle::logic_pathfinder::normalize_to(&mut rx, &mut ry, step as i32); + mx = rx; + my = ry; + } + } + let (shx, shy) = self.collision_shove(index, from); + let (limit_x, limit_y) = self + .tilemap + .as_ref() + .map(|m| { + ( + m.width() * SUBTILE_UNITS - 1, + m.height() * SUBTILE_UNITS - 1, + ) + }) + .unwrap_or((i32::MAX, i32::MAX)); + let new_pos = ( + (from.0 + mx + shx as i32).clamp(0, limit_x), + (from.1 + my + shy as i32).clamp(0, limit_y), + ); + { + let base = self.objects.objects[index].body.base_mut(); + base.position.x = new_pos.0; + base.position.y = new_pos.1; + } + if normal != (0, 0) { + if let LogicObjectBody::Character(c) = &mut self.objects.objects[index].body { + c.direction = LogicVector2::new(normal.0, normal.1); + } + } + let reached = ((normal.1 * (goto.1 - new_pos.1)) / 256 + + (normal.0 * (goto.0 - new_pos.0)) / 256) + < 1001; + if let Some(m) = self.objects.objects[index].movement_mut() { + m.path_node_reached = reached; + if reached && !m.path.is_empty() { + m.path.pop(); + let n = crate::battle::path_target_normal(&m.path, new_pos.0, new_pos.1, width); + m.path_target_normal = LogicVector2::new(n.0, n.1); + } + } + } + fn spawn_position(&self, spawner: usize, data: &LogicDataRef) -> (i32, i32) { + let sp = self.objects.objects[spawner].position(); + let owner = self.objects.objects[spawner].owner_index(); + let r = self.objects.objects[spawner].stats().collision_radius + + LogicCharacterStats::of(data, 0).collision_radius; + let width = self.tilemap.as_ref().map(|map| map.width()).unwrap_or(1); + let center_x = width * SUBTILE_UNITS / 2; + for (bx, by) in [(0, r), (-r, 0), (0, -r), (r, 0)] { + let ox = if sp.0 > center_x { -bx } else { bx }; + let oy = if owner == 1 { -by } else { by }; + let cand = (sp.0 + ox, sp.1 + oy); + let (tx, ty) = (cand.0 / SUBTILE_UNITS, cand.1 / SUBTILE_UNITS); + if let Some(map) = self.tilemap.as_ref() { + if map.is_inside(tx, ty) && !map.is_water(tx, ty) { + return cand; + } + } + } + (sp.0 + 1, sp.1) + } + fn drain_lifetimes(&mut self) { + for entry in self.objects.objects.iter_mut() { + if let Some(Some(LogicComponent::Hitpoint(hp))) = + entry.components.get_mut(COMPONENT_HITPOINT) + { + if hp.hitpoints >= 1 && hp.lifetime_damage != 0 { + hp.lifetime_accumulator += hp.lifetime_damage; + if hp.lifetime_accumulator >= 100 { + hp.hitpoints -= hp.lifetime_accumulator / 100; + hp.lifetime_accumulator %= 100; + if hp.hitpoints <= 0 { + hp.hitpoints = 0; + hp.lifetime_accumulator = 0; + } + } + } } } } @@ -703,10 +1688,111 @@ impl LogicBattle { for towers in self.leader_towers.iter_mut() { towers.retain(|tower| !dead.contains(tower)); } + for entry in self.objects.objects.iter_mut() { + let LogicObjectBody::Projectile(projectile) = &mut entry.body else { + continue; + }; + if dead.contains(&projectile.target) { + projectile.target = crate::battle::logic_game_object_ref::LogicGameObjectRef::NONE; + } + if dead.contains(&projectile.source) { + projectile.source = crate::battle::logic_game_object_ref::LogicGameObjectRef::NONE; + } + } + let attack_finish_time = crate::LogicGlobals::number(GLOBAL_ATTACK_FINISH_TIME); for index in 0..self.objects.objects.len() { + let hit_speed = self.objects.objects[index].stats().hit_speed; if let Some(component) = self.objects.objects[index].combat_mut() { component.target_index = None; + if dead.contains(&component.target) { + component.target = + crate::battle::logic_game_object_ref::LogicGameObjectRef::NONE; + if component.hit_timer >= 1 && hit_speed >= 2 && attack_finish_time >= 1 { + component.attack_finish_timer = 1; + } + } + if !component.attackers.is_empty() { + component + .attackers + .retain(|(reference, _)| !dead.contains(reference)); + } + } + if let LogicObjectBody::Projectile(projectile) = &mut self.objects.objects[index].body { + if dead.contains(&projectile.source) { + projectile.source = + crate::battle::logic_game_object_ref::LogicGameObjectRef::NONE; + } } } } } +fn build_spawned_character( + data: LogicDataRef, + instance: i32, + position: (i32, i32), + owner: i32, + level: i32, + buff_type_count: usize, +) -> LogicGameObjectEntry { + use crate::battle::logic_character::{LogicCharacter, DIRECTION_BOTTOM, DIRECTION_TOP}; + use crate::battle::logic_component::{ + LogicCharacterBuffComponent, LogicCombatComponent, LogicHitpointComponent, + LogicMovementComponent, + }; + use crate::battle::logic_game_object::{LogicGameObject, CHARACTER_OBJECT_TYPE}; + let cdata = data.as_character(); + let hitpoints = cdata + .as_ref() + .map(|c| c.hitpoints(level.max(0) as usize)) + .unwrap_or(0); + let moves = cdata.as_ref().map(|c| c.speed() > 0).unwrap_or(false); + let life_time = cdata.as_ref().map(|c| c.life_time()).unwrap_or(0); + let spawn_start = cdata + .as_ref() + .map(|c| c.spawn_start_time().max(0)) + .unwrap_or(0); + let spawn_limit = cdata.as_ref().map(|c| c.spawn_limit().max(0)).unwrap_or(0); + let character = LogicCharacter { + level_index: level, + base: LogicGameObject { + position: LogicVector2::new(position.0, position.1), + owner_index: owner, + ..LogicGameObject::default() + }, + direction: LogicVector2::new( + 0, + if owner == 0 { + DIRECTION_TOP + } else { + DIRECTION_BOTTOM + }, + ), + state: if moves { CHARACTER_STATE_MOVING } else { 0 }, + deploy_timer: 0, + spawn_timer: spawn_start, + remaining_spawn_count: spawn_limit, + ..LogicCharacter::default() + }; + let hitpoint = if life_time >= 1 { + LogicHitpointComponent::with_lifetime(hitpoints, life_time) + } else { + LogicHitpointComponent::healthy(hitpoints) + }; + let components = [ + Some(LogicComponent::Combat(LogicCombatComponent::default())), + moves.then(|| LogicComponent::Movement(LogicMovementComponent::default())), + Some(LogicComponent::Hitpoint(hitpoint)), + Some(LogicComponent::Buff(LogicCharacterBuffComponent::empty( + buff_type_count, + ))), + ]; + LogicGameObjectEntry::new( + data, + crate::battle::logic_game_object_ref::LogicGameObjectRef::of( + CHARACTER_OBJECT_TYPE + 1, + instance, + ), + LogicObjectBody::Character(Box::new(character)), + components, + ) +} diff --git a/crates/logic/src/battle/logic_summoner.rs b/crates/logic/src/battle/logic_summoner.rs new file mode 100644 index 0000000..0543e0e --- /dev/null +++ b/crates/logic/src/battle/logic_summoner.rs @@ -0,0 +1,210 @@ +use crate::battle::logic_game_object::LogicVector2; +use crate::battle::logic_simulation::distance_squared; +use crate::battle::logic_tilemap::{LogicTilemap, SUBTILE_UNITS}; +use crate::data::LogicCharacterData; +pub const DEPLOY_CELL_UNITS: i32 = 1000; +pub const FIND_POSITION_MAX_RING: i32 = 30; +pub const DEPLOY_BORDER_TILES: i32 = 0; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DeployBlocker { + pub x: i32, + pub y: i32, + pub size_w: i32, + pub size_h: i32, +} +pub fn snap_coordinate(character: &LogicCharacterData, value: i32) -> i32 { + let base = DEPLOY_CELL_UNITS * (value / DEPLOY_CELL_UNITS); + let centred = !character.is_building() || character.size_in_tiles() & 1 == 1; + if centred { + base + 500 + } else { + base + } +} +pub fn build_free_tile_map(blockers: &[DeployBlocker], cell_w: i32, cell_h: i32) -> Vec { + let mut free = vec![true; (cell_w.max(0) * cell_h.max(0)) as usize]; + for blocker in blockers { + if blocker.size_w == 0 { + continue; + } + let y_from = (blocker.y - 500 * blocker.size_h) / DEPLOY_CELL_UNITS; + let y_to = (blocker.y + 500 * blocker.size_h) / DEPLOY_CELL_UNITS; + let x_from = (blocker.x - 500 * blocker.size_w) / DEPLOY_CELL_UNITS; + let x_to = (blocker.x + 500 * blocker.size_w) / DEPLOY_CELL_UNITS; + let mut y = y_from; + while y < y_to { + if y >= 0 && y < cell_h { + let mut x = x_from; + while x < x_to { + if x >= 0 && x < cell_w { + free[(y * cell_w + x) as usize] = false; + } + x += 1; + } + } + y += 1; + } + } + free +} +pub fn position_free( + free: &[bool], + cell_w: i32, + cell_h: i32, + pos: (i32, i32), + size_in_tiles: i32, + tilemap: &LogicTilemap, +) -> bool { + let x0 = (pos.0 - 500 * size_in_tiles) / DEPLOY_CELL_UNITS; + let x1 = (pos.0 + 500 * size_in_tiles) / DEPLOY_CELL_UNITS; + if x0 >= x1 { + return true; + } + let y0 = (pos.1 - 500 * size_in_tiles) / DEPLOY_CELL_UNITS; + let y1 = (pos.1 + 500 * size_in_tiles) / DEPLOY_CELL_UNITS; + let mut x = x0; + while x < x1 { + let mut y = y0; + while y < y1 { + if x < 0 || y < 0 || x >= cell_w || y >= cell_h { + return false; + } + if !free[(y * cell_w + x) as usize] { + return false; + } + let mut quadrant = 0; + while quadrant < 4 { + if !tilemap.can_place_egg(2 * x + (quadrant & 1), 2 * y + (quadrant >> 1)) { + return false; + } + quadrant += 1; + } + y += 1; + } + x += 1; + } + true +} +pub fn roads_beneath(tilemap: &LogicTilemap, pos: (i32, i32), size_in_tiles: i32) -> bool { + let from_x = (pos.0 - 500 * size_in_tiles) / SUBTILE_UNITS; + let to_x = (pos.0 + 500 * size_in_tiles) / SUBTILE_UNITS; + let from_y = (pos.1 - 500 * size_in_tiles) / SUBTILE_UNITS; + let to_y = (pos.1 + 500 * size_in_tiles) / SUBTILE_UNITS; + let mut x = from_x; + while x < to_x { + let mut y = from_y; + while y < to_y { + if tilemap.lane_bits(x, y) >= 1 { + return true; + } + y += 1; + } + x += 1; + } + false +} +pub fn check_spell_position( + tilemap: &LogicTilemap, + x: i32, + y: i32, + summons_a_character: bool, +) -> i32 { + if !summons_a_character { + return 0; + } + if x < -499 { + return 1; + } + let tile_x = x / SUBTILE_UNITS; + let tile_y = y / SUBTILE_UNITS; + if tile_y < DEPLOY_BORDER_TILES { + return 2; + } + if tile_x >= tilemap.width() { + return 3; + } + if tile_y >= tilemap.height() - DEPLOY_BORDER_TILES { + return 4; + } + if !tilemap.can_place_egg(tile_x, tile_y) { + return 5; + } + 0 +} +pub fn find_position_for_spell( + summon: Option<&LogicCharacterData>, + input: LogicVector2, + tilemap: &LogicTilemap, + blockers: &[DeployBlocker], + avoid_roads: bool, +) -> Option { + let x = input.x.clamp(0, SUBTILE_UNITS * tilemap.width()); + let y = input.y.clamp(0, SUBTILE_UNITS * tilemap.height()); + let Some(summon) = summon else { + return Some(LogicVector2::new( + x + 500 - x % DEPLOY_CELL_UNITS, + y + 500 - y % DEPLOY_CELL_UNITS, + )); + }; + let cell_w = tilemap.width() >> 1; + let cell_h = tilemap.height() >> 1; + let free = build_free_tile_map(blockers, cell_w, cell_h); + let footprint = if summon.is_building() { + summon.size_in_tiles() + } else { + 1 + }; + let origin_x = snap_coordinate(summon, x); + let origin_y = snap_coordinate(summon, y); + let mut best: Option<(i32, i32)> = None; + let mut best_distance = i32::MAX; + let mut last_ring = FIND_POSITION_MAX_RING; + let mut ring = 0; + loop { + let steps = if ring <= 0 { 1 } else { 2 * ring }; + let mut step = 0; + while step < steps { + let low = -ring; + let rising = step - ring; + let falling = ring - step; + let corners = if ring <= 0 { 1 } else { 4 }; + let mut corner = 0; + while corner < corners { + let (mut dx, mut dy) = if corner & 1 == 1 { + (low, falling) + } else { + (rising, low) + }; + if corner >= 2 { + dx = -dx; + dy = -dy; + } + let candidate = ( + origin_x + DEPLOY_CELL_UNITS * dx, + origin_y + DEPLOY_CELL_UNITS * dy, + ); + if position_free(&free, cell_w, cell_h, candidate, footprint, tilemap) { + let mut distance = distance_squared(candidate, (x, y)); + if avoid_roads && roads_beneath(tilemap, candidate, summon.size_in_tiles()) { + distance = i32::MAX; + } + if distance < best_distance { + best = Some(candidate); + best_distance = distance; + last_ring = 0; + } + } + corner += 1; + } + step += 1; + } + if ring >= last_ring { + break; + } + ring += 1; + } + if best_distance == i32::MAX { + return None; + } + best.map(|(bx, by)| LogicVector2::new(bx, by)) +} diff --git a/crates/logic/src/battle/logic_tilemap.rs b/crates/logic/src/battle/logic_tilemap.rs index 96d258f..2391c76 100644 --- a/crates/logic/src/battle/logic_tilemap.rs +++ b/crates/logic/src/battle/logic_tilemap.rs @@ -2,9 +2,7 @@ use std::collections::HashMap; use std::path::Path; pub const SUBTILE_UNITS: i32 = 500; pub const WATER_BIT: i32 = 5; -pub const WATER_COST: i32 = 800; -pub const WATER_COST_JUMPING: i32 = 20; -pub const GROUND_COST: i32 = 1; +pub const NO_DEPLOY_BIT: i32 = 4; pub const SECTION_OBJECTS: &str = "Objects"; pub const SECTION_MAP: &str = "Map"; pub const OBJECT_KING_TOWER: &str = "KingTower"; @@ -66,15 +64,21 @@ impl LogicTilemap { } } SECTION_MAP => { - let values: Vec = row[1..] + let cells: Vec<&str> = row + .get(1..) + .unwrap_or_default() .iter() .map(|value| value.trim()) - .take_while(|value| !value.is_empty()) - .filter_map(number) .collect(); - if !values.is_empty() { - tilemap.tiles.push(values); + if cells.iter().all(|value| number(value).is_none()) { + continue; } + tilemap.tiles.push( + cells + .iter() + .map(|value| number(value).unwrap_or(0)) + .collect(), + ); } _ => {} } @@ -99,6 +103,13 @@ impl LogicTilemap { pub fn is_water(&self, x: i32, y: i32) -> bool { (self.tile_at(x, y) >> WATER_BIT) & 1 == 1 } + pub fn lane_bits(&self, x: i32, y: i32) -> i32 { + self.tile_at(x, y) & 3 + } + pub fn can_place_egg(&self, x: i32, y: i32) -> bool { + let tile = self.tile_at(x, y); + (tile >> WATER_BIT) & 1 == 0 && (tile >> NO_DEPLOY_BIT) & 1 == 0 + } pub fn is_inside(&self, x: i32, y: i32) -> bool { x >= 0 && y >= 0 && x < self.width() && y < self.height() } diff --git a/crates/logic/src/battle/logic_tutorial_manager.rs b/crates/logic/src/battle/logic_tutorial_manager.rs index 0019a62..87b431b 100644 --- a/crates/logic/src/battle/logic_tutorial_manager.rs +++ b/crates/logic/src/battle/logic_tutorial_manager.rs @@ -1,6 +1,6 @@ -use titan::Payload; use crate::battle::logic_game_object_ref::LogicGameObjectRef; use crate::data::LogicDataRef; +use titan::Payload; #[derive(Debug, Default, Clone, PartialEq, Eq, Payload)] pub struct LogicTutorialManager { pub tutorial: LogicDataRef, diff --git a/crates/logic/src/battle/mod.rs b/crates/logic/src/battle/mod.rs index 52fb29d..b25aa4f 100644 --- a/crates/logic/src/battle/mod.rs +++ b/crates/logic/src/battle/mod.rs @@ -7,7 +7,9 @@ mod logic_game_object; mod logic_game_object_manager; mod logic_game_object_ref; mod logic_pathfinder; +mod logic_projectile; mod logic_simulation; +mod logic_summoner; mod logic_tilemap; mod logic_time; mod logic_tutorial_manager; @@ -16,15 +18,15 @@ pub use logic_battle::{ LogicBattle, BATTLE_INT_ARRAY, BATTLE_TICKS_PER_SECOND, BATTLE_TRAILING_INTS, BATTLE_TYPE_NPC, BATTLE_TYPE_PVP, BATTLE_TYPE_REPLAY, CROWNS_FOR_LEADER, LEADER_TOWER_COUNT, }; +pub use logic_battle_event::LogicBattleEvent; pub use logic_character::{ LogicCharacter, LogicSummoner, LogicSummonerDeck, DEFAULT_SIZE, DIRECTION_BOTTOM, DIRECTION_TOP, }; pub use logic_component::{ - LogicMovementComponent, LogicCharacterBuffComponent, LogicCombatComponent, LogicComponent, LogicHitpointComponent, - COMPONENT_BUFF, COMPONENT_COMBAT, COMPONENT_HITPOINT, COMPONENT_MOVEMENT, + LogicMovementComponent, COMPONENT_BUFF, COMPONENT_COMBAT, COMPONENT_HITPOINT, + COMPONENT_MOVEMENT, }; -pub use logic_battle_event::LogicBattleEvent; pub use logic_game_mode::{LogicGameMode, SECTION_BATTLE, SECTION_TUTORIAL}; pub use logic_game_object::{ LogicGameObject, LogicGameObjectEntry, LogicObjectBody, LogicVector2, CHARACTER_OBJECT_TYPE, @@ -32,12 +34,48 @@ pub use logic_game_object::{ }; pub use logic_game_object_manager::LogicGameObjectManager; pub use logic_game_object_ref::LogicGameObjectRef; -pub use logic_pathfinder::find_path; +pub use logic_pathfinder::{ + centre_of, closest_tile_position_to_target, find_path, get_lane_id, path_target_normal, + spawn_lane_of, target_position_where_going_now, tile_of, +}; +pub use logic_projectile::{LogicProjectile, PROJECTILE_OBJECT_TYPE}; pub use logic_simulation::{ - LogicCharacterStats, CHARACTER_DEPLOY_TIME_COLUMN, CHARACTER_STATE_DEPLOY, CHARACTER_STATE_MOVING, - TICK_MILLISECONDS, + LogicCharacterStats, CHARACTER_STATE_DEPLOY, CHARACTER_STATE_MOVING, TICK_MILLISECONDS, +}; +pub use logic_summoner::{ + build_free_tile_map, check_spell_position, find_position_for_spell, position_free, + roads_beneath, snap_coordinate, DeployBlocker, DEPLOY_CELL_UNITS, }; pub use logic_tilemap::{LogicTilemap, OBJECT_KING_TOWER, OBJECT_PRINCESS_TOWER, SUBTILE_UNITS}; pub use logic_time::LogicTime; pub use logic_tutorial_manager::LogicTutorialManager; pub use verify::{verify_snapshot, SnapshotReport}; +mod target_attribution { + use std::cell::{Cell, RefCell}; + use std::collections::HashSet; + use std::sync::OnceLock; + thread_local! { + static TICK: Cell = const { Cell::new(0) }; + } + pub fn set_tick(tick: i32) { + TICK.with(|t| t.set(tick)); + } + pub fn tick() -> i32 { + TICK.with(|t| t.get()) + } + pub fn enabled() -> bool { + static ON: OnceLock = OnceLock::new(); + *ON.get_or_init(|| std::env::var("SCROLL_TRACE_TICK").is_ok()) + } + thread_local! { + static REPORTED: RefCell> = + RefCell::new(HashSet::new()); + } + pub fn first_time(object: (i32, i32), site: &'static str) -> bool { + REPORTED.with(|seen| seen.borrow_mut().insert((object.0, object.1, site))) + } +} +pub use target_attribution::{ + enabled as target_attribution_enabled, first_time as attribution_first_time, + set_tick as set_attribution_tick, tick as attribution_tick, +}; diff --git a/crates/logic/src/battle/verify.rs b/crates/logic/src/battle/verify.rs index 8f5ed48..4c71f2d 100644 --- a/crates/logic/src/battle/verify.rs +++ b/crates/logic/src/battle/verify.rs @@ -1,9 +1,8 @@ -use titan::{ByteStreamReader, Result}; use crate::battle::logic_game_mode::{SECTION_BATTLE, SECTION_TUTORIAL}; use crate::data::{table, LogicDataRef, LogicDataTables}; use crate::model::DECK_SLOT_COUNT; +use titan::{ByteStreamReader, Result}; pub const BUFF_ARRAY_TABLE: i32 = table::DAMAGE_TYPES; -pub const MOVEMENT_SPEED_COLUMN: &str = "Speed"; pub const MOVEMENT_TAIL_VINTS: usize = 17; #[derive(Debug, Clone, PartialEq, Eq)] pub struct SnapshotReport { @@ -68,12 +67,18 @@ impl<'a, 'b> Verifier<'a, 'b> { self.reader.read_vint()?; self.vints(2)?; self.vints(12)?; - let row = data.data(); - let mana_limit = row.map(|r| r.int("ManaGenerateLimit")).unwrap_or(0); + let character = data.as_character(); + let mana_limit = character + .as_ref() + .map(|character| character.mana_generate_limit()) + .unwrap_or(0); if mana_limit >= 1 { self.reader.read_vint()?; } - let reload = row.map(|r| r.int("ReloadAfterHits")).unwrap_or(0); + let reload = character + .as_ref() + .map(|character| character.reload_after_hits()) + .unwrap_or(0); if reload >= 1 { self.vints(2)?; } @@ -93,6 +98,23 @@ impl<'a, 'b> Verifier<'a, 'b> { self.vints(4)?; Ok(()) } + fn projectile(&mut self) -> Result<()> { + self.reader.read_boolean()?; + self.vints(2)?; + self.vints(2)?; + self.reader.read_vint()?; + self.vints(2)?; + self.vints(2)?; + self.vints(2)?; + self.global_id()?; + self.global_id()?; + self.data_ref()?; + self.data_ref()?; + self.vints(3)?; + let hits = self.reader.read_vint()?.max(0) as usize; + self.vints(hits * 2)?; + Ok(()) + } fn movement(&mut self) -> Result<()> { for _ in 0..4 { self.reader.read_boolean()?; @@ -219,7 +241,9 @@ impl<'a, 'b> Verifier<'a, 'b> { for entry in &data { let is_summoner = entry.global_id().is_some() && entry.global_id() == summoner; let entry = entry.clone(); - if is_summoner { + if entry.as_projectile().is_some() { + self.projectile()?; + } else if is_summoner { self.summoner(&entry)?; } else { self.character(&entry)?; @@ -232,9 +256,12 @@ impl<'a, 'b> Verifier<'a, 'b> { self.mark("components"); for pass in 0..4 { for entry in &data { + if entry.as_projectile().is_some() { + continue; + } let moves = entry - .data() - .map(|row| row.int(MOVEMENT_SPEED_COLUMN) > 0) + .as_character() + .map(|character| character.speed() > 0) .unwrap_or(false); match pass { 0 => self.combat()?, @@ -281,8 +308,11 @@ impl<'a, 'b> Verifier<'a, 'b> { self.reader.read_vint()?; self.mark("commands"); let commands = self.reader.read_vint()?; - if commands != 0 { - return Err(titan::Error::Unsupported("commands are not expected yet")); + if !(0..=32).contains(&commands) { + return Err(titan::Error::Unsupported("implausible command count")); + } + for _ in 0..commands { + crate::commands::LogicCommandManager::decode_command(self.reader)?; } self.mark("end"); Ok(()) diff --git a/crates/logic/src/data/data_ref.rs b/crates/logic/src/data/data_ref.rs index dceb9bc..0d9752a 100644 --- a/crates/logic/src/data/data_ref.rs +++ b/crates/logic/src/data/data_ref.rs @@ -1,13 +1,14 @@ -use std::fmt; -use std::sync::Arc; -use titan::{ByteStreamReader, ByteStreamWriter, GlobalId, Payload, Result}; use crate::data::logic_data::LogicData; use crate::data::logic_data_tables::LogicDataTables; use crate::data::tables::table; use crate::data::typed::{ - LogicArenaData, LogicRarityData, LogicResourceData, LogicResourcePackData, LogicSpellData, + LogicArenaData, LogicCharacterData, LogicLocationData, LogicNpcData, LogicProjectileData, + LogicRarityData, LogicResourceData, LogicResourcePackData, LogicSpellData, LogicTreasureChestData, }; +use std::fmt; +use std::sync::Arc; +use titan::{ByteStreamReader, ByteStreamWriter, GlobalId, Payload, Result}; #[derive(Clone, Default)] pub enum LogicDataRef { #[default] @@ -121,6 +122,18 @@ impl LogicDataRef { pub fn as_resource_pack(&self) -> Option { self.typed(table::RESOURCE_PACKS, LogicResourcePackData::new) } + pub fn as_character(&self) -> Option { + self.typed(table::CHARACTERS_COMBINED, LogicCharacterData::new) + } + pub fn as_location(&self) -> Option { + self.typed(table::LOCATIONS, LogicLocationData::new) + } + pub fn as_npc(&self) -> Option { + self.typed(table::NPCS, LogicNpcData::new) + } + pub fn as_projectile(&self) -> Option { + self.typed(table::PROJECTILES, LogicProjectileData::new) + } } impl PartialEq for LogicDataRef { fn eq(&self, other: &Self) -> bool { diff --git a/crates/logic/src/data/logic_data.rs b/crates/logic/src/data/logic_data.rs index 66558b9..273161c 100644 --- a/crates/logic/src/data/logic_data.rs +++ b/crates/logic/src/data/logic_data.rs @@ -1,3 +1,4 @@ +use crate::data::scaled::{level_scale, ScaleKind}; use std::sync::Arc; use titan::{CsvRow, CsvTable, GlobalId}; #[derive(Debug, Clone)] @@ -59,6 +60,30 @@ impl LogicData { _ => 0, } } + pub fn scaled_value(&self, column: &str, level: i32, kind: ScaleKind) -> i32 { + let (Some(row), Some(index)) = (self.row(), self.column(column)) else { + return 0; + }; + let count = row.value_count(index); + if count == 0 { + return 0; + } + if count == 1 { + return row + .int(index) + .wrapping_mul(level_scale(kind.percent(), level)) + / 100; + } + let index_at = if count < 8 { + level.min(count as i32 - 1) + } else { + level + }; + if index_at < 0 { + return 0; + } + row.int_at(index, index_at as usize) + } pub fn string_at(&self, column: &str, index: usize) -> &str { match (self.row(), self.column(column)) { (Some(row), Some(column)) => row.string_at(column, index), diff --git a/crates/logic/src/data/logic_data_table.rs b/crates/logic/src/data/logic_data_table.rs index da81060..4fac01b 100644 --- a/crates/logic/src/data/logic_data_table.rs +++ b/crates/logic/src/data/logic_data_table.rs @@ -1,7 +1,7 @@ +use crate::data::logic_data::LogicData; use std::collections::HashMap; use std::sync::Arc; use titan::CsvTable; -use crate::data::logic_data::LogicData; #[derive(Debug, Clone)] pub struct LogicDataTable { table_index: i32, diff --git a/crates/logic/src/data/logic_data_tables.rs b/crates/logic/src/data/logic_data_tables.rs index 5f53332..edde566 100644 --- a/crates/logic/src/data/logic_data_tables.rs +++ b/crates/logic/src/data/logic_data_tables.rs @@ -1,10 +1,10 @@ +use crate::data::logic_data::LogicData; +use crate::data::logic_data_table::LogicDataTable; +use crate::data::tables::table; use std::collections::HashMap; use std::path::Path; use std::sync::{Arc, OnceLock, PoisonError, RwLock}; use titan::{CsvError, CsvReader, GlobalId}; -use crate::data::logic_data::LogicData; -use crate::data::logic_data_table::LogicDataTable; -use crate::data::tables::table; pub const TABLE_COUNT: usize = 62; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LogicDataTableResource { diff --git a/crates/logic/src/data/mod.rs b/crates/logic/src/data/mod.rs index 564ea57..ae08916 100644 --- a/crates/logic/src/data/mod.rs +++ b/crates/logic/src/data/mod.rs @@ -3,6 +3,7 @@ mod globals; mod logic_data; mod logic_data_table; mod logic_data_tables; +mod scaled; mod tables; mod typed; pub use data_ref::LogicDataRef; @@ -12,8 +13,10 @@ pub use logic_data_table::LogicDataTable; pub use logic_data_tables::{ DataError, LogicDataTableResource, LogicDataTables, DATA_TABLE_RESOURCES, TABLE_COUNT, }; +pub use scaled::{level_scale, ScaleKind}; pub use tables::{table, DataId, RESOURCE_DIAMONDS, RESOURCE_FREE_GOLD, RESOURCE_GOLD}; pub use typed::{ - arena_by_index, LogicArenaData, LogicRarityData, LogicResourceData, LogicResourcePackData, - LogicSpellData, LogicTreasureChestData, + arena_by_index, LogicArenaData, LogicCharacterData, LogicLocationData, LogicNpcData, + LogicProjectileData, LogicRarityData, LogicResourceData, LogicResourcePackData, LogicSpellData, + LogicTreasureChestData, }; diff --git a/crates/logic/src/data/scaled.rs b/crates/logic/src/data/scaled.rs new file mode 100644 index 0000000..5165d92 --- /dev/null +++ b/crates/logic/src/data/scaled.rs @@ -0,0 +1,63 @@ +use crate::data::globals::LogicGlobals; +pub const DAMAGE_PER_SPELL_LEVEL: &str = "DAMAGE_INCREASE_PERCENT_PER_SPELL_LEVEL"; +pub const HITPOINT_PER_SPELL_LEVEL: &str = "HITPOINT_INCREASE_PERCENT_PER_SPELL_LEVEL"; +pub const DAMAGE_PER_KING_LEVEL: &str = "DAMAGE_INCREASE_PERCENT_PER_KING_LEVEL"; +pub const HITPOINT_PER_KING_LEVEL: &str = "HITPOINT_INCREASE_PERCENT_PER_KING_LEVEL"; +pub const DAMAGE_PER_TOWER_LEVEL: &str = "DAMAGE_INCREASE_PERCENT_PER_TOWER_LEVEL"; +pub const HITPOINT_PER_TOWER_LEVEL: &str = "HITPOINT_INCREASE_PERCENT_PER_TOWER_LEVEL"; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScaleKind { + None, + SpellDamage, + SpellHitpoints, + KingDamage, + KingHitpoints, + TowerDamage, + TowerHitpoints, +} +impl ScaleKind { + pub fn percent(self) -> i32 { + match self { + ScaleKind::None => 0, + ScaleKind::SpellDamage => LogicGlobals::number(DAMAGE_PER_SPELL_LEVEL), + ScaleKind::SpellHitpoints => LogicGlobals::number(HITPOINT_PER_SPELL_LEVEL), + ScaleKind::KingDamage => LogicGlobals::number(DAMAGE_PER_KING_LEVEL), + ScaleKind::KingHitpoints => LogicGlobals::number(HITPOINT_PER_KING_LEVEL), + ScaleKind::TowerDamage => LogicGlobals::number(DAMAGE_PER_TOWER_LEVEL), + ScaleKind::TowerHitpoints => LogicGlobals::number(HITPOINT_PER_TOWER_LEVEL), + } + } +} +pub fn level_scale(percent: i32, level: i32) -> i32 { + let mut scale: i32 = 100; + if level < 1 { + return scale; + } + let step = percent.wrapping_add(100); + for _ in 0..level { + scale = if scale >= 100_000 { + (scale / 100).wrapping_mul(step) + } else { + scale.wrapping_mul(step) / 100 + }; + } + scale +} +#[cfg(test)] +mod tests { + use super::level_scale; + #[test] + fn the_ten_percent_ladder_matches_the_measured_damage() { + assert_eq!(level_scale(10, 0), 100); + assert_eq!(level_scale(10, 1), 110); + assert_eq!(level_scale(10, 2), 121); + assert_eq!(level_scale(10, 6), 176); + assert_eq!(24 * level_scale(10, 6) / 100, 42); + assert_eq!(24 * level_scale(10, 2) / 100, 29); + } + #[test] + fn a_level_below_one_never_enters_the_loop() { + assert_eq!(level_scale(10, -3), 100); + assert_eq!(level_scale(9, 0), 100); + } +} diff --git a/crates/logic/src/data/typed.rs b/crates/logic/src/data/typed.rs index 3e2fe8a..7e94b65 100644 --- a/crates/logic/src/data/typed.rs +++ b/crates/logic/src/data/typed.rs @@ -1,6 +1,7 @@ -use std::sync::Arc; use crate::data::data_ref::LogicDataRef; use crate::data::logic_data::LogicData; +use crate::data::tables::table; +use std::sync::Arc; macro_rules! typed_data { ($name:ident) => { #[derive(Debug, Clone)] @@ -27,6 +28,9 @@ macro_rules! typed_data { pub fn int_at(&self, column: &str, level: usize) -> i32 { self.0.int_at(column, level) } + pub fn scaled(&self, column: &str, level: i32, kind: crate::data::ScaleKind) -> i32 { + self.0.scaled_value(column, level, kind) + } pub fn string(&self, column: &str) -> &str { self.0.string(column) } @@ -42,6 +46,242 @@ typed_data!(LogicResourceData); typed_data!(LogicTreasureChestData); typed_data!(LogicRarityData); typed_data!(LogicResourcePackData); +typed_data!(LogicCharacterData); +typed_data!(LogicLocationData); +typed_data!(LogicNpcData); +fn tower_projectile(tower: &str) -> String { + LogicDataRef::by_name(crate::data::tables::table::BUILDINGS, tower) + .as_character() + .map(|data| data.projectile().to_owned()) + .unwrap_or_default() +} +typed_data!(LogicProjectileData); +impl LogicProjectileData { + pub fn speed(&self) -> i32 { + self.int("Speed") + } + pub fn damage(&self, level: usize) -> i32 { + self.scaled("Damage", level as i32, self.damage_kind()) + } + fn damage_kind(&self) -> crate::data::ScaleKind { + let name = self.name(); + if name == tower_projectile("KingTower") { + crate::data::ScaleKind::KingDamage + } else if name == tower_projectile("PrincessTower") { + crate::data::ScaleKind::TowerDamage + } else { + crate::data::ScaleKind::SpellDamage + } + } + pub fn radius(&self) -> i32 { + self.int("Radius") + } + pub fn is_homing(&self) -> bool { + self.boolean("Homing") + } + pub fn pushback(&self) -> i32 { + self.int("PushBack") + } + pub fn damage_type(&self) -> i32 { + self.int("DamageType") + } + pub fn has_reduced_tower_damage(&self) -> bool { + self.boolean("ReducedTowerDamage") + } + pub fn uses_pending_physical_damage(&self) -> bool { + self.is_homing() && self.radius() < 1 && self.damage_type() == 0 + } + pub fn damage_effect(&self) -> LogicDataRef { + let named = self.string("DamageType"); + let mut damage_type = LogicDataRef::by_name(table::DAMAGE_TYPES, named); + if damage_type.is_none() { + damage_type = LogicDataRef::by_name(table::DAMAGE_TYPES, "Physical"); + } + let effect = damage_type + .data() + .map(|row| row.string("DamageEffect").to_string()) + .unwrap_or_default(); + LogicDataRef::by_name(table::EFFECTS, &effect) + } +} +impl LogicCharacterData { + pub fn damage_effect(&self) -> LogicDataRef { + LogicDataRef::by_name(table::EFFECTS, self.string("DamageEffect")) + } + pub fn speed(&self) -> i32 { + self.int("Speed") + } + pub fn sight_range(&self) -> i32 { + self.int("SightRange") + } + pub fn range(&self) -> i32 { + self.int("Range") + } + pub fn collision_radius(&self) -> i32 { + self.int("CollisionRadius") + } + pub fn is_building(&self) -> bool { + self.speed() == 0 + } + pub fn tile_size_override(&self) -> i32 { + self.int("TileSizeOverride") + } + pub fn size_in_tiles(&self) -> i32 { + let override_size = self.tile_size_override(); + if override_size > 0 { + return override_size; + } + (self.collision_radius() + 499) / 500 + 1 + } + pub fn no_deploy_size_w(&self) -> i32 { + self.int("NoDeploySizeW") + } + pub fn no_deploy_size_h(&self) -> i32 { + self.int("NoDeploySizeH") + } + pub fn hit_speed(&self) -> i32 { + self.int("HitSpeed") + } + pub fn load_time(&self) -> i32 { + self.int("LoadTime") + } + pub fn force_attack_animation_to_end(&self) -> bool { + self.boolean("ForceAttackAnimationToEnd") + } + pub fn mass(&self) -> i32 { + self.int("Mass") + } + pub fn stop_movement_after_ms(&self) -> i32 { + self.int("StopMovementAfterMS") + } + pub fn wait_ms(&self) -> i32 { + self.int("WaitMS") + } + pub fn flying_height(&self) -> i32 { + self.int("FlyingHeight") + } + pub fn is_flying(&self) -> bool { + self.flying_height() > 0 + } + pub fn jump_enabled(&self) -> bool { + self.boolean("JumpEnabled") + } + pub fn deploy_time(&self) -> i32 { + self.int("DeployTime") + } + pub fn special_attack_interval(&self) -> i32 { + self.int("SpecialAttackInterval") + } + pub fn mana_generate_limit(&self) -> i32 { + self.int("ManaGenerateLimit") + } + pub fn reload_after_hits(&self) -> i32 { + self.int("ReloadAfterHits") + } + pub fn attacks_air(&self) -> bool { + self.boolean("AttacksAir") + } + pub fn attacks_ground(&self) -> bool { + self.boolean("AttacksGround") + } + pub fn target_only_buildings(&self) -> bool { + self.boolean("TargetOnlyBuildings") + } + pub fn projectile(&self) -> &str { + self.string("Projectile") + } + pub fn spawn_character(&self) -> &str { + self.string("SpawnCharacter") + } + pub fn spawn_character_level_index(&self) -> i32 { + self.int("SpawnCharacterLevelIndex") + } + pub fn spawn_number(&self) -> i32 { + self.int("SpawnNumber") + } + pub fn spawn_interval(&self) -> i32 { + self.int("SpawnInterval") + } + pub fn spawn_pause_time(&self) -> i32 { + self.int("SpawnPauseTime") + } + pub fn spawn_start_time(&self) -> i32 { + self.int("SpawnStartTime") + } + pub fn spawn_limit(&self) -> i32 { + self.int("SpawnLimit") + } + pub fn life_time(&self) -> i32 { + self.int("LifeTime") + } + pub fn projectile_start_radius(&self) -> i32 { + self.int("ProjectileStartRadius") + } + pub fn projectile_y_offset(&self) -> i32 { + self.int("ProjectileYOffset") + } + pub fn projectile_start_z(&self) -> i32 { + self.int("ProjectileStartZ") + } + pub fn hitpoints(&self, level: usize) -> i32 { + self.scaled("Hitpoints", level as i32, self.scale_kind(false)) + } + fn scale_kind(&self, damage: bool) -> crate::data::ScaleKind { + use crate::data::ScaleKind; + if self.name() == "KingTower" { + if damage { + ScaleKind::KingDamage + } else { + ScaleKind::KingHitpoints + } + } else if self.boolean("IsSummonerTower") { + if damage { + ScaleKind::TowerDamage + } else { + ScaleKind::TowerHitpoints + } + } else if damage { + ScaleKind::SpellDamage + } else { + ScaleKind::SpellHitpoints + } + } + pub fn damage(&self, level: usize) -> i32 { + let projectile = self.projectile(); + if projectile.is_empty() { + return self.scaled("Damage", level as i32, self.scale_kind(true)); + } + LogicDataRef::by_name(crate::data::tables::table::PROJECTILES, projectile) + .data() + .map(|shot| shot.int_at("Damage", level)) + .unwrap_or(0) + } +} +impl LogicLocationData { + pub fn match_length(&self) -> i32 { + self.int("MatchLength") + } + pub fn overtime_seconds(&self) -> i32 { + self.int("OvertimeSeconds") + } + pub fn file_name(&self) -> &str { + self.string("FileName") + } +} +impl LogicNpcData { + pub fn location(&self) -> &str { + self.string("Location") + } + pub fn mana_regen_ms(&self) -> i32 { + self.int("ManaRegenMs") + } + pub fn mana_regen_ms_end(&self) -> i32 { + self.int("ManaRegenMsEnd") + } + pub fn mana_regen_ms_overtime(&self) -> i32 { + self.int("ManaRegenMsOvertime") + } +} impl LogicArenaData { pub fn index(&self) -> i32 { self.int("Arena") @@ -243,6 +483,12 @@ impl LogicSpellData { pub fn mana_cost(&self) -> i32 { self.int("ManaCost") } + pub fn summon_character(&self) -> &str { + self.string("SummonCharacter") + } + pub fn summon_number(&self) -> i32 { + self.int("SummonNumber") + } pub fn unlock_arena(&self) -> &str { self.string("UnlockArena") } @@ -272,6 +518,9 @@ impl LogicArenaData { pub fn request_size(&self) -> i32 { self.int("RequestSize") } + pub fn pvp_location(&self) -> &str { + self.string("PvpLocation") + } } impl LogicResourceData { pub fn cap(&self) -> i32 { diff --git a/crates/logic/src/lib.rs b/crates/logic/src/lib.rs index 254b45e..e02dc5b 100644 --- a/crates/logic/src/lib.rs +++ b/crates/logic/src/lib.rs @@ -4,30 +4,31 @@ pub mod commands; pub mod data; pub mod factory; pub mod home; +pub mod logic_math; pub mod logic_random; pub mod messages; -pub mod logic_math; pub mod model; -pub use logic_math::{logic_cos_scaled, logic_sin, logic_sin_scaled, logic_sqrt, spawn_offset, SIN_TABLE, SQRT_TABLE}; pub use commands::{ chest_source, command_type, CommandMeta, CommandOutcome, Execute, LogicBuyCardCommand, LogicBuyChestCommand, LogicBuyResourcePackCommand, LogicClaimAchievementRewardCommand, LogicClaimRewardCommand, LogicCollectFreeChestCommand, LogicCollectMultiWinChestCommand, LogicCommand, LogicCommandHeader, LogicCommandManager, LogicCompleteTutorialBattleCommand, - LogicDoSpellCommand, LogicFuseSpellsCommand, - LogicHelpOpenedCommand, LogicMoveSpellCommand, LogicPageOpenedCommand, - LogicRefreshAchievementsCommand, LogicReward, LogicShopOpenedCommand, + LogicDoSpellCommand, LogicFuseSpellsCommand, LogicHelpOpenedCommand, LogicMoveSpellCommand, + LogicPageOpenedCommand, LogicRefreshAchievementsCommand, LogicReward, LogicShopOpenedCommand, LogicShopSeedChangedCommand, LogicSortCollectionCommand, LogicStartMatchmakeCommand, LogicStartRewardClaimCommand, LogicSwapSpellsCommand, LogicUpdateLastShownLevelUpCommand, }; pub use data::{ - table, DataError, LogicArenaData, LogicData, LogicDataRef, LogicDataTable, - LogicDataTableResource, LogicDataTables, LogicGlobals, LogicRarityData, LogicResourceData, - LogicResourcePackData, LogicSpellData, LogicTreasureChestData, DATA_TABLE_RESOURCES, - TABLE_COUNT, + table, DataError, LogicArenaData, LogicCharacterData, LogicData, LogicDataRef, LogicDataTable, + LogicDataTableResource, LogicDataTables, LogicGlobals, LogicLocationData, LogicNpcData, + LogicProjectileData, LogicRarityData, LogicResourceData, LogicResourcePackData, LogicSpellData, + LogicTreasureChestData, DATA_TABLE_RESOURCES, TABLE_COUNT, }; pub use factory::{scroll_message_registry, LogicScrollMessageFactory}; pub use home::{randomize_shop_items, LogicHomeMode}; +pub use logic_math::{ + logic_cos_scaled, logic_sin, logic_sin_scaled, logic_sqrt, spawn_offset, SIN_TABLE, SQRT_TABLE, +}; pub use logic_random::LogicRandom; pub use messages::*; pub use model::*; diff --git a/crates/logic/src/logic_math.rs b/crates/logic/src/logic_math.rs index 16d5420..9a5e857 100644 --- a/crates/logic/src/logic_math.rs +++ b/crates/logic/src/logic_math.rs @@ -1,20 +1,18 @@ pub const SQRT_TABLE: [i32; 256] = [ - 0, 16, 22, 27, 32, 35, 39, 42, 45, 48, 50, 53, 55, 57, 59, 61, - 64, 65, 67, 69, 71, 73, 75, 76, 78, 80, 81, 83, 84, 86, 87, 89, - 90, 91, 93, 94, 96, 97, 98, 99, 101, 102, 103, 104, 106, 107, 108, 109, - 110, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, - 128, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, - 143, 144, 144, 145, 146, 147, 148, 149, 150, 150, 151, 152, 153, 154, 155, 155, - 156, 157, 158, 159, 160, 160, 161, 162, 163, 163, 164, 165, 166, 167, 167, 168, - 169, 170, 170, 171, 172, 173, 173, 174, 175, 176, 176, 177, 178, 178, 179, 180, - 181, 181, 182, 183, 183, 184, 185, 185, 186, 187, 187, 188, 189, 189, 190, 191, - 192, 192, 193, 193, 194, 195, 195, 196, 197, 197, 198, 199, 199, 200, 201, 201, - 202, 203, 203, 204, 204, 205, 206, 206, 207, 208, 208, 209, 209, 210, 211, 211, - 212, 212, 213, 214, 214, 215, 215, 216, 217, 217, 218, 218, 219, 219, 220, 221, - 221, 222, 222, 223, 224, 224, 225, 225, 226, 226, 227, 227, 228, 229, 229, 230, - 230, 231, 231, 232, 232, 233, 234, 234, 235, 235, 236, 236, 237, 237, 238, 238, - 239, 240, 240, 241, 241, 242, 242, 243, 243, 244, 244, 245, 245, 246, 246, 247, - 247, 248, 248, 249, 249, 250, 250, 251, 251, 252, 252, 253, 253, 254, 254, 255, + 0, 16, 22, 27, 32, 35, 39, 42, 45, 48, 50, 53, 55, 57, 59, 61, 64, 65, 67, 69, 71, 73, 75, 76, + 78, 80, 81, 83, 84, 86, 87, 89, 90, 91, 93, 94, 96, 97, 98, 99, 101, 102, 103, 104, 106, 107, + 108, 109, 110, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 128, + 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 144, 145, + 146, 147, 148, 149, 150, 150, 151, 152, 153, 154, 155, 155, 156, 157, 158, 159, 160, 160, 161, + 162, 163, 163, 164, 165, 166, 167, 167, 168, 169, 170, 170, 171, 172, 173, 173, 174, 175, 176, + 176, 177, 178, 178, 179, 180, 181, 181, 182, 183, 183, 184, 185, 185, 186, 187, 187, 188, 189, + 189, 190, 191, 192, 192, 193, 193, 194, 195, 195, 196, 197, 197, 198, 199, 199, 200, 201, 201, + 202, 203, 203, 204, 204, 205, 206, 206, 207, 208, 208, 209, 209, 210, 211, 211, 212, 212, 213, + 214, 214, 215, 215, 216, 217, 217, 218, 218, 219, 219, 220, 221, 221, 222, 222, 223, 224, 224, + 225, 225, 226, 226, 227, 227, 228, 229, 229, 230, 230, 231, 231, 232, 232, 233, 234, 234, 235, + 235, 236, 236, 237, 237, 238, 238, 239, 240, 240, 241, 241, 242, 242, 243, 243, 244, 244, 245, + 245, 246, 246, 247, 247, 248, 248, 249, 249, 250, 250, 251, 251, 252, 252, 253, 253, 254, 254, + 255, ]; pub fn logic_sqrt(value: i32) -> i32 { if value < 0x10000 { @@ -36,7 +34,11 @@ pub fn logic_sqrt(value: i32) -> i32 { SQRT_TABLE[(value >> 8) as usize] }; let next = seed + 1; - return if next.wrapping_mul(next) > value { seed } else { next }; + return if next.wrapping_mul(next) > value { + seed + } else { + next + }; } let refined = if value < 0x1000000 { let seed = if value < 0x100000 { @@ -73,16 +75,11 @@ pub fn logic_sqrt(value: i32) -> i32 { root - i32::from(root.wrapping_mul(root) > value) } pub const SIN_TABLE: [i32; 91] = [ - 0, 18, 36, 54, 71, 89, 107, 125, 143, 160, - 178, 195, 213, 230, 248, 265, 282, 299, 316, 333, - 350, 367, 384, 400, 416, 433, 449, 465, 481, 496, - 512, 527, 543, 558, 573, 587, 602, 616, 630, 644, - 658, 672, 685, 698, 711, 724, 737, 749, 761, 773, - 784, 796, 807, 818, 828, 839, 849, 859, 868, 878, - 887, 896, 904, 912, 920, 928, 935, 943, 949, 956, - 962, 968, 974, 979, 984, 989, 994, 998, 1002, 1005, - 1008, 1011, 1014, 1016, 1018, 1020, 1022, 1023, 1023, 1024, - 1024, + 0, 18, 36, 54, 71, 89, 107, 125, 143, 160, 178, 195, 213, 230, 248, 265, 282, 299, 316, 333, + 350, 367, 384, 400, 416, 433, 449, 465, 481, 496, 512, 527, 543, 558, 573, 587, 602, 616, 630, + 644, 658, 672, 685, 698, 711, 724, 737, 749, 761, 773, 784, 796, 807, 818, 828, 839, 849, 859, + 868, 878, 887, 896, 904, 912, 920, 928, 935, 943, 949, 956, 962, 968, 974, 979, 984, 989, 994, + 998, 1002, 1005, 1008, 1011, 1014, 1016, 1018, 1020, 1022, 1023, 1023, 1024, 1024, ]; pub fn logic_sin(deg: i32) -> i32 { let mut v = deg % 360; @@ -104,13 +101,64 @@ pub fn logic_sin_scaled(deg: i32, scale: i32) -> i32 { pub fn logic_cos_scaled(deg: i32, scale: i32) -> i32 { logic_sin_scaled(deg + 90, scale) } +pub const ATAN_TABLE: [i32; 129] = [ + 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 8, 9, 9, 10, 10, 11, 11, 11, 12, 12, + 13, 13, 14, 14, 14, 15, 15, 16, 16, 17, 17, 17, 18, 18, 19, 19, 19, 20, 20, 21, 21, 21, 22, 22, + 22, 23, 23, 24, 24, 24, 25, 25, 25, 26, 26, 27, 27, 27, 28, 28, 28, 29, 29, 29, 30, 30, 30, 31, + 31, 31, 32, 32, 32, 33, 33, 33, 34, 34, 34, 35, 35, 35, 35, 36, 36, 36, 37, 37, 37, 37, 38, 38, + 38, 39, 39, 39, 39, 40, 40, 40, 40, 41, 41, 41, 41, 42, 42, 42, 42, 43, 43, 43, 43, 44, 44, 44, + 44, 45, 45, 45, +]; +pub fn get_angle(x: i32, y: i32) -> i32 { + if x == 0 && y == 0 { + return 0; + } + if x >= 1 && y >= 0 { + return if y >= x { + 90 - ATAN_TABLE[((x << 7) / y) as usize] + } else { + ATAN_TABLE[((y << 7) / x) as usize] + }; + } + let v3 = x.abs(); + if x <= 0 && y >= 1 { + return if v3 >= y { + 180 - ATAN_TABLE[((y << 7) / v3) as usize] + } else { + ATAN_TABLE[((v3 << 7) / y) as usize] + 90 + }; + } + let v4 = y.abs(); + if x < 0 && y <= 0 { + if v4 < v3 { + return ATAN_TABLE[((v4 << 7) / v3) as usize] + 180; + } + if v4 != 0 { + return 270 - ATAN_TABLE[((v3 << 7) / v4) as usize]; + } + return 0; + } + if v3 < v4 { + return ATAN_TABLE[((v3 << 7) / v4) as usize] + 270; + } + if v3 == 0 { + return 0; + } + let v5 = (360 - ATAN_TABLE[((v4 << 7) / v3) as usize]) % 360; + if v5 >= 0 { + v5 + } else { + v5 + 360 + } +} pub fn spawn_offset(index: i32, count: i32, radius: i32, mirror: bool) -> (i32, i32) { if count <= 1 { return (0, 0); } let mut idx = index; let mut n = count; - let mut base_angle = 90; + let mut ring_base = 180; + let mut angle_base = 90; let mut divisor = 2; let mut r = radius; let use_ring = match count { @@ -118,12 +166,12 @@ pub fn spawn_offset(index: i32, count: i32, radius: i32, mirror: bool) -> (i32, 3 | 5 => true, 4 => { n = 4; - base_angle = 45; + ring_base = 45; true } 7 => { if index != 0 { - base_angle = 0; + ring_base = 0; idx -= 1; n = 6; true @@ -132,7 +180,7 @@ pub fn spawn_offset(index: i32, count: i32, radius: i32, mirror: bool) -> (i32, } } _ => { - base_angle = 0; + ring_base = 0; count >= 3 } }; @@ -142,10 +190,12 @@ pub fn spawn_offset(index: i32, count: i32, radius: i32, mirror: bool) -> (i32, r = (3 * idx % 7).wrapping_mul(r) / 6; } divisor = n; + angle_base = ring_base; } else if count != 2 { divisor = count; + angle_base = ring_base; } - let angle = base_angle + 360 * idx / divisor.max(1) + 90; + let angle = angle_base + 360 * idx / divisor.max(1) + 90; let dx = logic_cos_scaled(angle, r); let dy = logic_sin_scaled(angle, r); (dx, if mirror { -dy } else { dy }) @@ -165,6 +215,20 @@ mod trig_tests { assert_eq!(logic_sin_scaled(90, 1000), 1000); } #[test] + fn get_angle_matches_the_client_cardinals() { + assert_eq!(get_angle(0, 0), 0); + assert_eq!(get_angle(1, 0), 0); + assert_eq!(get_angle(0, 1), 90); + assert_eq!(get_angle(-1, 0), 180); + assert_eq!(get_angle(0, -1), 270); + assert_eq!(get_angle(1, 1), 45); + assert_eq!(get_angle(-1, 1), 135); + assert_eq!(get_angle(-1, -1), 225); + assert_eq!(get_angle(1, -1), 315); + assert_eq!(get_angle(700, 700), 45); + assert_eq!(get_angle(0, 5000), 90); + } + #[test] fn a_single_unit_has_no_offset_and_pairs_split() { assert_eq!(spawn_offset(0, 1, 300, false), (0, 0)); let a = spawn_offset(0, 2, 300, false); @@ -174,4 +238,26 @@ mod trig_tests { assert_eq!(a.0, -b.0); assert_ne!(a.0, 0); } + #[test] + fn a_three_unit_card_starts_its_ring_on_the_y_axis() { + let ring: Vec<_> = (0..3).map(|i| spawn_offset(i, 3, 500, true)).collect(); + assert_eq!( + ring[0].0, 0, + "the first of three is not on the y axis: {ring:?}" + ); + assert!( + ring[0].1 > 0, + "the first of three points the wrong way: {ring:?}" + ); + assert_eq!(ring[1].0, -ring[2].0, "the pair is not symmetric: {ring:?}"); + assert_eq!(ring[1].1, ring[2].1, "the pair is not level: {ring:?}"); + assert!(ring[1].1 < 0, "the pair is on the wrong side: {ring:?}"); + for (dx, dy) in &ring { + let r = ((dx * dx + dy * dy) as f64).sqrt(); + assert!((r - 577.0).abs() <= 1.0, "radius drifted: {ring:?}"); + } + let pair = [spawn_offset(0, 2, 500, true), spawn_offset(1, 2, 500, true)]; + assert_eq!(pair[0].1, 0); + assert_eq!(pair[0].0, -pair[1].0); + } } diff --git a/crates/logic/src/messages/battle_result.rs b/crates/logic/src/messages/battle_result.rs index c595296..f787713 100644 --- a/crates/logic/src/messages/battle_result.rs +++ b/crates/logic/src/messages/battle_result.rs @@ -1,5 +1,5 @@ -use titan::Message; use crate::battle::LogicGameObjectRef; +use titan::Message; pub const BATTLE_RESULT_WIN: i32 = 1; pub const BATTLE_RESULT_LOSE: i32 = 2; pub const BATTLE_RESULT_DRAW: i32 = 3; @@ -46,4 +46,25 @@ impl BattleResultMessage { ..Self::default() } } + #[allow(clippy::too_many_arguments)] + pub fn result( + result: i32, + own_stars: i32, + opponent_stars: i32, + score_change: i32, + gold_reward: i32, + exp_reward: i32, + full_update: Option>, + ) -> Self { + Self { + result, + score_change, + gold_reward, + exp_reward, + own_stars, + opponent_stars, + full_update, + ..Self::default() + } + } } diff --git a/crates/logic/src/messages/sector_heartbeat.rs b/crates/logic/src/messages/sector_heartbeat.rs index ee4fdb2..4fe42ea 100644 --- a/crates/logic/src/messages/sector_heartbeat.rs +++ b/crates/logic/src/messages/sector_heartbeat.rs @@ -7,11 +7,65 @@ pub struct SectorHeartbeatMessage { } impl SectorHeartbeatMessage { pub fn new(server_turn: i32, checksum: i32) -> Self { + Self::build(server_turn, checksum, &[], None) + } + pub fn with_commands(server_turn: i32, checksum: i32, commands: &[Vec]) -> Self { + Self::build(server_turn, checksum, commands, None) + } + pub fn with_tick_data( + server_turn: i32, + checksum: i32, + commands: &[Vec], + tick_data: &[u8], + ) -> Self { + Self::build(server_turn, checksum, commands, Some(tick_data)) + } + fn build( + server_turn: i32, + checksum: i32, + commands: &[Vec], + tick_data: Option<&[u8]>, + ) -> Self { let mut writer = ByteStreamWriter::new(); writer.write_vint(server_turn); writer.write_vint(checksum); + if !commands.is_empty() || tick_data.is_some() { + writer.write_vint(commands.len() as i32); + for command in commands { + writer.write_raw(command); + } + } + if let Some(tick_data) = tick_data { + writer.write_bytes(Some(tick_data)); + } Self { body: writer.into_inner(), } } } +#[cfg(test)] +mod tests { + use super::*; + use titan::ByteStreamReader; + #[test] + fn an_empty_turn_keeps_the_two_vint_form() { + let message = SectorHeartbeatMessage::new(19, -1234); + let mut reader = ByteStreamReader::new(&message.body); + assert_eq!(reader.read_vint().unwrap(), 19); + assert_eq!(reader.read_vint().unwrap(), -1234); + assert!(reader.is_at_end()); + } + #[test] + fn commands_follow_the_checksum_behind_a_count() { + let first = vec![7u8, 1, 2]; + let second = vec![9u8, 3]; + let message = + SectorHeartbeatMessage::with_commands(19, -1234, &[first.clone(), second.clone()]); + let mut reader = ByteStreamReader::new(&message.body); + assert_eq!(reader.read_vint().unwrap(), 19); + assert_eq!(reader.read_vint().unwrap(), -1234); + assert_eq!(reader.read_vint().unwrap(), 2); + let rest = &message.body[message.body.len() - first.len() - second.len()..]; + assert_eq!(rest, [first, second].concat()); + } +} diff --git a/crates/logic/tests/client_path_replay.rs b/crates/logic/tests/client_path_replay.rs new file mode 100644 index 0000000..86831aa --- /dev/null +++ b/crates/logic/tests/client_path_replay.rs @@ -0,0 +1,42 @@ +use logic::battle::{find_path, LogicTilemap}; +fn client_path() -> Vec { + let mut path = vec![1755]; + let mut node = 1720; + while node >= 1144 { + path.push(node); + node -= 36; + } + path.push(1107); + path +} +fn arena() -> LogicTilemap { + LogicTilemap::load( + std::path::Path::new("../../assets"), + "locations/goblin_arena.csv", + ) + .expect("the goblin arena tilemap") +} +#[test] +fn we_reproduce_the_clients_route_node_for_node() { + let tm = arena(); + let expected = client_path(); + assert_eq!(expected.len(), 19); + for start in [(26, 29), (27, 29)] { + assert_eq!( + find_path(&tm, start, (27, 48), 2, false), + expected, + "start {start:?} should walk the client's route" + ); + } +} +#[test] +fn the_route_bulges_because_the_lane_pinches_not_because_of_a_tie() { + let tm = arena(); + assert_eq!(tm.lane_bits(27, 31), 0); + assert_eq!(tm.lane_bits(28, 31), 2); + let path = find_path(&tm, (27, 29), (27, 48), 2, false); + assert!( + !path.contains(&(31 * 36 + 27)), + "the route must not step on the off-lane tile (27,31)" + ); +} diff --git a/crates/logic/tests/lane_assignment.rs b/crates/logic/tests/lane_assignment.rs new file mode 100644 index 0000000..de89629 --- /dev/null +++ b/crates/logic/tests/lane_assignment.rs @@ -0,0 +1,88 @@ +use logic::battle::{get_lane_id, spawn_lane_of, LogicTilemap}; +fn arena() -> LogicTilemap { + LogicTilemap::load( + std::path::Path::new("../../assets"), + "locations/goblin_arena.csv", + ) + .expect("the goblin arena tilemap") +} +#[test] +fn the_arena_is_36_by_64_subtiles() { + let tm = arena(); + assert_eq!((tm.width(), tm.height()), (36, 64)); +} +#[test] +fn the_map_rows_are_all_full_width() { + let tm = arena(); + assert_eq!(tm.tiles.len(), 64); + for (y, row) in tm.tiles.iter().enumerate() { + assert_eq!(row.len(), 36, "map row {y} is ragged"); + } +} +#[test] +fn the_arena_has_its_river_and_both_lanes() { + let tm = arena(); + let tiles = || (0..tm.height()).flat_map(|y| (0..tm.width()).map(move |x| (x, y))); + assert_eq!( + tiles().filter(|(x, y)| tm.lane_bits(*x, *y) >= 1).count(), + 692 + ); + assert_eq!(tiles().filter(|(x, y)| tm.is_water(*x, *y)).count(), 112); +} +#[test] +fn the_right_lane_pinches_to_two_columns_at_the_bridge() { + let tm = arena(); + for y in [31, 32] { + assert_eq!( + tm.lane_bits(27, y), + 0, + "({},{y}) is off-lane at the pinch", + 27 + ); + assert_eq!( + tm.lane_bits(30, y), + 0, + "({},{y}) is off-lane at the pinch", + 30 + ); + assert_eq!(tm.lane_bits(28, y), 2); + assert_eq!(tm.lane_bits(29, y), 2); + } + for y in [30, 33, 34, 40, 47] { + for x in 27..31 { + assert_eq!(tm.lane_bits(x, y), 2, "({x},{y}) is the right lane"); + } + } + for y in 30..34 { + for x in 24..27 { + assert!(tm.is_water(x, y), "({x},{y}) should be river"); + } + assert!(tm.is_water(31, y), "(31,{y}) should be river"); + } +} +#[test] +fn a_drop_by_the_right_bridge_takes_the_right_lane() { + let tm = arena(); + assert_eq!(get_lane_id(&tm, 12500, 14500), 2); + assert_eq!(get_lane_id(&tm, 10000, 14500), 2); + assert_eq!(get_lane_id(&tm, 11000, 14500), 2); +} +#[test] +fn a_drop_by_the_left_bridge_takes_the_left_lane() { + let tm = arena(); + for (x, y) in [(6923, 8500), (7788, 8999), (7788, 8001)] { + assert_eq!(get_lane_id(&tm, x, y), 1, "({x},{y}) is on the left"); + } +} +#[test] +fn the_single_spawn_path_clamps_before_it_reads_the_lane() { + let tm = arena(); + assert_eq!( + spawn_lane_of(&tm, -5000, 14500), + get_lane_id(&tm, 250, 14500) + ); + assert_eq!( + spawn_lane_of(&tm, 999_999, 14500), + get_lane_id(&tm, 36 * 500 - 250, 14500) + ); +} diff --git a/crates/titan/src/checksum.rs b/crates/titan/src/checksum.rs index 46b6287..0cbae54 100644 --- a/crates/titan/src/checksum.rs +++ b/crates/titan/src/checksum.rs @@ -1,3 +1,37 @@ +use std::cell::RefCell; +thread_local! { + static TRACE: RefCell>> = const { RefCell::new(None) }; + static SKIP_ONE: std::cell::Cell = const { std::cell::Cell::new(false) }; +} +pub fn checksum_trace_skip_one() { + SKIP_ONE.with(|s| s.set(true)); +} +pub fn checksum_trace_start() { + TRACE.with(|t| *t.borrow_mut() = Some(Vec::new())); +} +pub fn checksum_trace_take() -> Vec { + TRACE.with(|t| t.borrow_mut().take().unwrap_or_default()) +} +pub fn checksum_trace_note(note: impl std::fmt::Display) { + TRACE.with(|t| { + if let Some(list) = t.borrow_mut().as_mut() { + list.push(format!("# {note}")); + } + }); +} +pub fn checksum_trace_active() -> bool { + TRACE.with(|t| t.borrow().is_some()) +} +fn trace_field(kind: &str, value: i32) { + if SKIP_ONE.with(|s| s.replace(false)) { + return; + } + TRACE.with(|t| { + if let Some(list) = t.borrow_mut().as_mut() { + list.push(format!("{kind}:{value}")); + } + }); +} #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct ChecksumEncoder { state: u32, @@ -16,18 +50,23 @@ impl ChecksumEncoder { self.state = self.state.rotate_right(31).wrapping_add(delta as u32); } pub fn write_boolean(&mut self, value: bool) { + trace_field("b", value as i32); self.mix(if value { 13 } else { 7 }); } pub fn write_byte(&mut self, value: i8) { + trace_field("y", value as i32); self.mix(value as u8 as i32 + 11); } pub fn write_short(&mut self, value: i16) { + trace_field("s", value as i32); self.mix(value as u16 as i32 + 19); } pub fn write_int(&mut self, value: i32) { + trace_field("I", value); self.mix(value.wrapping_add(9)); } pub fn write_vint(&mut self, value: i32) { + trace_field("i", value); self.mix(value.wrapping_add(33)); } pub fn write_long(&mut self, high: i32, low: i32) { @@ -36,11 +75,18 @@ impl ChecksumEncoder { } pub fn write_string(&mut self, length: Option) { match length { - None => self.mix(27), - Some(length) => self.mix(length as i32 + 28), + None => { + trace_field("S", -1); + self.mix(27); + } + Some(length) => { + trace_field("S", length as i32); + self.mix(length as i32 + 28); + } } } pub fn write_string_reference(&mut self, length: usize) { + trace_field("R", length as i32); self.mix(length as i32 + 38); } }