simulate movement, targeting and damage on the server

crowns now move because the server finally hurts things. each tick it
retargets, moves, resolves attacks and buries the dead, in that order.

the numbers are the client's own. Speed is position units per tick, so a
knight at 60 covers 1.2 tiles a second; SightRange and Range are in the
same units with 1000 to a game tile, and the target's CollisionRadius is
added on the far side of both. distances compare squared with the
client's saturation rule - beyond 46340 on either axis, or on overflow,
the distance is INT_MAX rather than a wrapped negative. the attack timer
counts milliseconds fifty at a time and fires once LoadTime + HitSpeed
have passed, then rewinds to LoadTime. tower damage comes from
projectiles.csv, not from the Damage column of buildings.csv, which is
empty for them.

a princess tower leaving the board is struck from leader_towers, which is
what getStars reads, so crowns follow from the same list the client
keeps.

what is deliberately not modelled yet: pathfinding, so units walk
straight at their target instead of along the roads and over the bridges;
collision, pushback and avoidance; projectiles as travelling objects,
since the damage lands the moment the attack fires; and buffs. field_48
on the combat component is renamed hit_timer after what it holds.
This commit is contained in:
WiseDev 2026-08-23 13:10:30 +03:00
parent 2ea283cdda
commit 054321ad50
5 changed files with 309 additions and 1 deletions

View file

@ -92,6 +92,7 @@ impl BattleSession {
while self.tick < tick {
self.tick += 1;
self.release_queued(self.tick);
self.mode.battle.tick();
if self.tick >= self.next_bot_emote {
self.next_bot_emote = Self::roll_emote_tick(&mut self.random, self.tick);
if let Some(event) = self.bot_emote() {

View file

@ -16,12 +16,14 @@ pub struct LogicCombatComponent {
pub target: LogicGameObjectRef,
pub attackers: Vec<(LogicGameObjectRef, i32)>,
pub observers: Vec<LogicGameObjectRef>,
pub target_index: Option<usize>,
pub hit_timer: i32,
}
impl LogicCombatComponent {
pub fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> {
writer.write_boolean(self.flag_72);
writer.write_boolean(self.flag_73);
writer.write_vint(self.field_48);
writer.write_vint(self.hit_timer);
writer.write_vint(self.field_52);
writer.write_vint(self.field_60);
writer.write_vint(self.field_64);

View file

@ -37,6 +37,18 @@ pub enum LogicObjectBody {
Summoner(Box<crate::battle::logic_character::LogicSummoner>),
}
impl LogicObjectBody {
pub fn base(&self) -> &LogicGameObject {
match self {
LogicObjectBody::Character(character) => &character.base,
LogicObjectBody::Summoner(summoner) => &summoner.character.base,
}
}
pub fn level_index(&self) -> i32 {
match self {
LogicObjectBody::Character(character) => character.level_index,
LogicObjectBody::Summoner(summoner) => summoner.character.level_index,
}
}
pub fn base_mut(&mut self) -> &mut LogicGameObject {
match self {
LogicObjectBody::Character(character) => &mut character.base,

View file

@ -0,0 +1,291 @@
use crate::battle::logic_battle::LogicBattle;
use crate::battle::logic_component::{LogicComponent, COMPONENT_COMBAT, COMPONENT_HITPOINT};
use crate::battle::logic_game_object::LogicGameObjectEntry;
use crate::data::LogicDataRef;
pub const TICK_MILLISECONDS: i32 = 50;
pub const COLUMN_SPEED: &str = "Speed";
pub const COLUMN_SIGHT_RANGE: &str = "SightRange";
pub const COLUMN_RANGE: &str = "Range";
pub const COLUMN_COLLISION_RADIUS: &str = "CollisionRadius";
pub const COLUMN_HIT_SPEED: &str = "HitSpeed";
pub const COLUMN_LOAD_TIME: &str = "LoadTime";
pub const COLUMN_DAMAGE: &str = "Damage";
pub const COLUMN_ATTACKS_AIR: &str = "AttacksAir";
pub const COLUMN_ATTACKS_GROUND: &str = "AttacksGround";
pub const COLUMN_TARGET_ONLY_BUILDINGS: &str = "TargetOnlyBuildings";
pub const COLUMN_FLYING_HEIGHT: &str = "FlyingHeight";
pub const COLUMN_PROJECTILE: &str = "Projectile";
pub const DISTANCE_SATURATION: i32 = 46340;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LogicCharacterStats {
pub speed: i32,
pub sight_range: i32,
pub range: i32,
pub collision_radius: i32,
pub hit_speed: i32,
pub load_time: i32,
pub damage: i32,
pub attacks_air: bool,
pub attacks_ground: bool,
pub target_only_buildings: bool,
pub flying: bool,
}
impl LogicCharacterStats {
pub fn of(data: &LogicDataRef, level_index: i32) -> Self {
let Some(row) = data.data() else {
return Self::none();
};
let level = level_index.max(0) as usize;
let projectile = row.string(COLUMN_PROJECTILE);
let damage = if projectile.is_empty() {
row.int_at(COLUMN_DAMAGE, level)
} else {
LogicDataRef::by_name(crate::data::table::PROJECTILES, projectile)
.data()
.map(|shot| shot.int_at(COLUMN_DAMAGE, level))
.unwrap_or(0)
};
Self {
speed: row.int(COLUMN_SPEED),
sight_range: row.int(COLUMN_SIGHT_RANGE),
range: row.int(COLUMN_RANGE),
collision_radius: row.int(COLUMN_COLLISION_RADIUS),
hit_speed: row.int(COLUMN_HIT_SPEED),
load_time: row.int(COLUMN_LOAD_TIME),
damage,
attacks_air: row.boolean(COLUMN_ATTACKS_AIR),
attacks_ground: row.boolean(COLUMN_ATTACKS_GROUND),
target_only_buildings: row.boolean(COLUMN_TARGET_ONLY_BUILDINGS),
flying: row.int(COLUMN_FLYING_HEIGHT) > 0,
}
}
pub fn none() -> Self {
Self {
speed: 0,
sight_range: 0,
range: 0,
collision_radius: 0,
hit_speed: 0,
load_time: 0,
damage: 0,
attacks_air: false,
attacks_ground: false,
target_only_buildings: false,
flying: false,
}
}
pub fn is_building(&self) -> bool {
self.speed == 0
}
}
pub fn integer_sqrt(value: i64) -> i64 {
if value <= 0 {
return 0;
}
let mut root = value.min(46340);
let mut previous = 0;
while root != previous {
previous = root;
root = (root + value / root) / 2;
}
root
}
pub fn distance_squared(from: (i32, i32), to: (i32, i32)) -> i32 {
let dx = to.0 - from.0;
let dy = to.1 - from.1;
if dx.abs() > DISTANCE_SATURATION || dy.abs() > DISTANCE_SATURATION {
return i32::MAX;
}
let square_x = dx * dx;
if dy * dy >= (square_x ^ i32::MAX) {
return i32::MAX;
}
square_x + dy * dy
}
impl LogicGameObjectEntry {
pub fn position(&self) -> (i32, i32) {
let base = self.body.base();
(base.position.x, base.position.y)
}
pub fn owner_index(&self) -> i32 {
self.body.base().owner_index
}
pub fn level_index(&self) -> i32 {
self.body.level_index()
}
pub fn stats(&self) -> LogicCharacterStats {
LogicCharacterStats::of(&self.data, self.level_index())
}
pub fn damage(&mut self, amount: i32) {
if let Some(Some(LogicComponent::Hitpoint(component))) =
self.components.get_mut(COMPONENT_HITPOINT)
{
component.hitpoints = (component.hitpoints - amount.max(0)).max(0);
}
}
pub fn combat_mut(&mut self) -> Option<&mut crate::battle::logic_component::LogicCombatComponent> {
match self.components.get_mut(COMPONENT_COMBAT)? {
Some(LogicComponent::Combat(component)) => Some(component),
_ => None,
}
}
}
impl LogicBattle {
pub fn tick(&mut self) {
self.retarget();
self.move_objects();
self.resolve_attacks();
self.remove_dead();
}
fn retarget(&mut self) {
let snapshot: Vec<(i32, (i32, i32), LogicCharacterStats, bool)> = self
.objects
.objects
.iter()
.map(|entry| {
(
entry.owner_index(),
entry.position(),
entry.stats(),
entry.is_alive(),
)
})
.collect();
for index in 0..self.objects.objects.len() {
let (owner, position, stats, alive) = snapshot[index];
if !alive || stats.range < 1 {
continue;
}
let mut best: Option<(i32, usize)> = None;
for (other, (other_owner, other_position, other_stats, other_alive)) in
snapshot.iter().enumerate()
{
if other == index || !other_alive || *other_owner == owner {
continue;
}
if stats.target_only_buildings && !other_stats.is_building() {
continue;
}
if other_stats.flying && !stats.attacks_air {
continue;
}
if !other_stats.flying && !stats.attacks_ground {
continue;
}
let reach = stats.sight_range + other_stats.collision_radius;
let distance = distance_squared(position, *other_position);
if distance > reach.saturating_mul(reach) {
continue;
}
if best.map(|(closest, _)| distance < closest).unwrap_or(true) {
best = Some((distance, other));
}
}
let target = best.map(|(_, other)| other);
if let Some(component) = self.objects.objects[index].combat_mut() {
component.target_index = target;
}
}
}
fn move_objects(&mut self) {
let positions: Vec<(i32, i32)> = self
.objects
.objects
.iter()
.map(LogicGameObjectEntry::position)
.collect();
for index in 0..self.objects.objects.len() {
let stats = self.objects.objects[index].stats();
if stats.speed < 1 || !self.objects.objects[index].is_alive() {
continue;
}
let Some(target) = self.objects.objects[index]
.combat_mut()
.and_then(|component| component.target_index)
else {
continue;
};
let Some(goal) = positions.get(target).copied() else {
continue;
};
let from = positions[index];
let stand_off = stats.range;
let dx = (goal.0 - from.0) as i64;
let dy = (goal.1 - from.1) as i64;
let distance = integer_sqrt(dx * dx + dy * dy);
if distance <= stand_off as i64 || distance == 0 {
continue;
}
let step = (stats.speed as i64).min(distance - stand_off as i64);
let base = self.objects.objects[index].body.base_mut();
base.position.x = from.0 + (dx * step / distance) as i32;
base.position.y = from.1 + (dy * step / distance) as i32;
}
}
fn resolve_attacks(&mut self) {
let positions: Vec<(i32, i32)> = self
.objects
.objects
.iter()
.map(LogicGameObjectEntry::position)
.collect();
let radii: Vec<i32> = self
.objects
.objects
.iter()
.map(|entry| entry.stats().collision_radius)
.collect();
let mut hits: Vec<(usize, i32)> = Vec::new();
for index in 0..self.objects.objects.len() {
let stats = self.objects.objects[index].stats();
if !self.objects.objects[index].is_alive() || stats.hit_speed < 1 {
continue;
}
let Some(component) = self.objects.objects[index].combat_mut() else {
continue;
};
let Some(target) = component.target_index else {
component.hit_timer = 0;
continue;
};
let reach = stats.range + radii.get(target).copied().unwrap_or(0);
if distance_squared(positions[index], positions[target]) > reach.saturating_mul(reach) {
component.hit_timer = 0;
continue;
}
component.hit_timer += TICK_MILLISECONDS;
let period = stats.hit_speed.max(TICK_MILLISECONDS);
let wind_up = stats.load_time.max(0);
if component.hit_timer >= wind_up + period {
component.hit_timer = wind_up;
hits.push((target, stats.damage));
}
}
for (target, amount) in hits {
if let Some(entry) = self.objects.objects.get_mut(target) {
entry.damage(amount);
}
}
}
fn remove_dead(&mut self) {
let dead: Vec<crate::battle::logic_game_object_ref::LogicGameObjectRef> = self
.objects
.objects
.iter()
.filter(|entry| !entry.is_alive())
.map(|entry| entry.global_id)
.collect();
if dead.is_empty() {
return;
}
self.objects.objects.retain(LogicGameObjectEntry::is_alive);
for towers in self.leader_towers.iter_mut() {
towers.retain(|tower| !dead.contains(tower));
}
for index in 0..self.objects.objects.len() {
if let Some(component) = self.objects.objects[index].combat_mut() {
component.target_index = None;
}
}
}
}

View file

@ -6,6 +6,7 @@ mod logic_game_mode;
mod logic_game_object;
mod logic_game_object_manager;
mod logic_game_object_ref;
mod logic_simulation;
mod logic_tilemap;
mod logic_time;
mod logic_tutorial_manager;
@ -29,6 +30,7 @@ pub use logic_game_object::{
};
pub use logic_game_object_manager::LogicGameObjectManager;
pub use logic_game_object_ref::LogicGameObjectRef;
pub use logic_simulation::{LogicCharacterStats, TICK_MILLISECONDS};
pub use logic_tilemap::{LogicTilemap, OBJECT_KING_TOWER, OBJECT_PRINCESS_TOWER, SUBTILE_UNITS};
pub use logic_time::LogicTime;
pub use logic_tutorial_manager::LogicTutorialManager;