at type 0 the client stopped working the elixir bar out for itself and started reading it from the summoner in our snapshot - which never moved, so the bar sat where the opening state left it and no card could be afforded. the rule from LogicSummoner::tick: an accumulator gains five thousand a tick and one mana is granted for every MANA_REGEN_MS * 100 / MAX_MANA it holds, the remainder carried rather than dropped. that works out at 2.8 seconds a mana with the shipped globals, and halves in the last sixty seconds through MANA_REGEN_MS_END, which is the speed-up the game has always had.
359 lines
13 KiB
Rust
359 lines
13 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, LogicObjectBody};
|
|
use crate::battle::logic_battle::BATTLE_TICKS_PER_SECOND;
|
|
use crate::data::LogicDataRef;
|
|
pub const TICK_MILLISECONDS: i32 = 50;
|
|
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_MANA_REGEN: &str = "MANA_REGEN_MS";
|
|
pub const GLOBAL_MANA_REGEN_END: &str = "MANA_REGEN_MS_END";
|
|
pub const GLOBAL_MANA_SPEED_UP_SECONDS: &str = "MANA_SPEED_UP_WHEN_REMAINING_SECONDS";
|
|
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 {
|
|
fn regenerate_mana(&mut self, tick: i32) {
|
|
let max_mana = crate::LogicGlobals::number(GLOBAL_MAX_MANA).max(1);
|
|
let seconds_left = (self.match_length_seconds() - tick / BATTLE_TICKS_PER_SECOND).max(0);
|
|
let speed_up = crate::LogicGlobals::number(GLOBAL_MANA_SPEED_UP_SECONDS);
|
|
let regen = if seconds_left <= speed_up {
|
|
crate::LogicGlobals::number(GLOBAL_MANA_REGEN_END)
|
|
} else {
|
|
crate::LogicGlobals::number(GLOBAL_MANA_REGEN)
|
|
};
|
|
let step = regen.saturating_mul(MANA_RATE_SCALE) / max_mana;
|
|
if step < 1 {
|
|
return;
|
|
}
|
|
for entry in self.objects.objects.iter_mut() {
|
|
let LogicObjectBody::Summoner(summoner) = &mut entry.body else {
|
|
continue;
|
|
};
|
|
summoner.mana_regen_timer += MANA_ACCUMULATOR_STEP;
|
|
let gained = summoner.mana_regen_timer / step;
|
|
if gained < 1 {
|
|
continue;
|
|
}
|
|
summoner.mana_regen_timer -= gained * step;
|
|
summoner.mana = (summoner.mana + gained).clamp(0, max_mana);
|
|
}
|
|
}
|
|
pub fn tick(&mut self, tick: i32) {
|
|
self.regenerate_mana(tick);
|
|
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 default_target(&self, index: usize) -> Option<usize> {
|
|
let owner = self.objects.objects[index].owner_index();
|
|
let from = self.objects.objects[index].position();
|
|
let mut best: Option<(i32, usize)> = None;
|
|
for (other, entry) in self.objects.objects.iter().enumerate() {
|
|
if other == index || entry.owner_index() == owner || !entry.is_alive() {
|
|
continue;
|
|
}
|
|
if !entry.stats().is_building() {
|
|
continue;
|
|
}
|
|
let distance = distance_squared(from, entry.position());
|
|
if best.map(|(closest, _)| distance < closest).unwrap_or(true) {
|
|
best = Some((distance, other));
|
|
}
|
|
}
|
|
best.map(|(_, other)| other)
|
|
}
|
|
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 target = match 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 = integer_sqrt(
|
|
((goal.0 - from.0) as i64).pow(2) + ((goal.1 - from.1) as i64).pow(2),
|
|
);
|
|
if straight <= stand_off as i64 || straight == 0 {
|
|
continue;
|
|
}
|
|
let waypoint = self
|
|
.tilemap
|
|
.as_ref()
|
|
.map(|tilemap| crate::battle::find_path(tilemap, from, goal, stats.flying))
|
|
.and_then(|path| path.first().copied())
|
|
.unwrap_or(goal);
|
|
let dx = (waypoint.0 - from.0) as i64;
|
|
let dy = (waypoint.1 - from.1) as i64;
|
|
let leg = integer_sqrt(dx * dx + dy * dy);
|
|
if leg == 0 {
|
|
continue;
|
|
}
|
|
let step = (stats.speed as i64).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_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;
|
|
}
|
|
}
|
|
}
|
|
}
|