scroll.server/crates/logic/src/battle/logic_simulation.rs
WiseDev 054321ad50 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.
2026-08-23 13:10:30 +03:00

291 lines
10 KiB
Rust

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;
}
}
}
}