maintain the combat timers the way the client does
audit findings #4/#7/#8. resolve_attacks only ever wrote hit_timer, and with the wrong model; field_52 (load), field_60 (dash), field_64 (special index) were left at 0 forever. all four are hashed by LogicCombatComponent::encode, so the checksum broke the instant anything could shoot. now, mirroring LogicCombatComponent::update and updateHitTimer: - field_52 and field_60 decrement by 50 every tick, unconditionally, for every combat component - the two lines at the top of update. - hit_timer is seeded with LoadTime the first time, advances by the 50ms step, and a shot lands on each HitSpeed boundary the accumulator crosses (field18/HitSpeed rising past its previous quotient), instead of "fire at load_time+hit_speed then reset". on a shot field_52 is set back to LoadTime and field_64 cycles through SpecialAttackInterval. still not bit-exact for combat: the state field (2 while attacking), field_68 recovery, buff-scaled hit speed, and projectiles-as-objects are their own ports. this closes the "timers never move" break; damage lands under the new model (harness).
This commit is contained in:
parent
a9a2e048ee
commit
2933d2ff7e
2 changed files with 117 additions and 14 deletions
|
|
@ -413,3 +413,68 @@ fn a_deployed_troop_counts_down_and_holds_still_until_ready() {
|
|||
.position();
|
||||
assert_ne!(moved, spawn_pos, "a deployed troop moves");
|
||||
}
|
||||
#[test]
|
||||
fn a_troop_in_range_damages_the_tower_with_the_client_hit_model() {
|
||||
let root = assets();
|
||||
if let Ok(t) = LogicDataTables::load_from_dir(&root) {
|
||||
LogicDataTables::install(Arc::new(t));
|
||||
}
|
||||
let builder = BattleBuilder::new(&root);
|
||||
let mut mode = builder
|
||||
.build(
|
||||
LogicDataRef::by_name(table::LOCATIONS, "PvP_goblin"),
|
||||
LogicDataRef::None,
|
||||
LogicDataRef::by_name(table::ARENAS, "Arena_T"),
|
||||
vec![avatar(5), avatar(0)],
|
||||
[None, None],
|
||||
1,
|
||||
)
|
||||
.expect("battle");
|
||||
let (tower_id, tower_pos, start_hp) = mode
|
||||
.battle
|
||||
.objects
|
||||
.objects
|
||||
.iter()
|
||||
.find_map(|e| match &e.body {
|
||||
logic::battle::LogicObjectBody::Character(_) if e.owner_index() == 1 => {
|
||||
let hp = e
|
||||
.components
|
||||
.get(2)
|
||||
.and_then(|c| c.as_ref())
|
||||
.and_then(|c| match c {
|
||||
logic::battle::LogicComponent::Hitpoint(h) => Some(h.hitpoints),
|
||||
_ => None,
|
||||
})?;
|
||||
Some((e.global_id, e.position(), hp))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("an enemy princess tower");
|
||||
let entries = builder.summon(
|
||||
&LogicDataRef::spell("Knight"),
|
||||
LogicVector2::new(tower_pos.0, tower_pos.1 - 800),
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
);
|
||||
for entry in entries {
|
||||
mode.battle.objects.push(entry);
|
||||
}
|
||||
for tick in 1..200 {
|
||||
mode.battle.tick(tick);
|
||||
}
|
||||
let hp = mode
|
||||
.battle
|
||||
.objects
|
||||
.objects
|
||||
.iter()
|
||||
.find(|e| e.global_id == tower_id)
|
||||
.and_then(|e| e.components.get(2))
|
||||
.and_then(|c| c.as_ref())
|
||||
.and_then(|c| match c {
|
||||
logic::battle::LogicComponent::Hitpoint(h) => Some(h.hitpoints),
|
||||
_ => None,
|
||||
})
|
||||
.expect("tower still present");
|
||||
assert!(hp < start_hp, "the knight's attacks should reduce tower hp ({hp} !< {start_hp})");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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_DEPLOY: i32 = 5;
|
||||
pub const MANA_ACCUMULATOR_STEP: i32 = 5000;
|
||||
|
|
@ -570,30 +571,67 @@ impl LogicBattle {
|
|||
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
|
||||
|| self.objects.objects[index].is_deploying()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let alive = self.objects.objects[index].is_alive();
|
||||
let deploying = self.objects.objects[index].is_deploying();
|
||||
let special_interval = self.objects.objects[index]
|
||||
.data
|
||||
.data()
|
||||
.map(|row| row.int(CHARACTER_SPECIAL_ATTACK_INTERVAL_COLUMN))
|
||||
.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 {
|
||||
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) {
|
||||
let reach = 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));
|
||||
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;
|
||||
}
|
||||
}
|
||||
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
|
||||
};
|
||||
}
|
||||
hits.push((target, damage));
|
||||
}
|
||||
}
|
||||
for (target, amount) in hits {
|
||||
|
|
|
|||
Loading…
Reference in a new issue