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();
|
.position();
|
||||||
assert_ne!(moved, spawn_pos, "a deployed troop moves");
|
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;
|
use crate::data::LogicDataRef;
|
||||||
pub const TICK_MILLISECONDS: i32 = 50;
|
pub const TICK_MILLISECONDS: i32 = 50;
|
||||||
pub const CHARACTER_DEPLOY_TIME_COLUMN: &str = "DeployTime";
|
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_MOVING: i32 = 1;
|
||||||
pub const CHARACTER_STATE_DEPLOY: i32 = 5;
|
pub const CHARACTER_STATE_DEPLOY: i32 = 5;
|
||||||
pub const MANA_ACCUMULATOR_STEP: i32 = 5000;
|
pub const MANA_ACCUMULATOR_STEP: i32 = 5000;
|
||||||
|
|
@ -570,30 +571,67 @@ impl LogicBattle {
|
||||||
let mut hits: Vec<(usize, i32)> = Vec::new();
|
let mut hits: Vec<(usize, i32)> = Vec::new();
|
||||||
for index in 0..self.objects.objects.len() {
|
for index in 0..self.objects.objects.len() {
|
||||||
let stats = self.objects.objects[index].stats();
|
let stats = self.objects.objects[index].stats();
|
||||||
if !self.objects.objects[index].is_alive()
|
let alive = self.objects.objects[index].is_alive();
|
||||||
|| stats.hit_speed < 1
|
let deploying = self.objects.objects[index].is_deploying();
|
||||||
|| self.objects.objects[index].is_deploying()
|
let special_interval = self.objects.objects[index]
|
||||||
{
|
.data
|
||||||
continue;
|
.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 {
|
let Some(component) = self.objects.objects[index].combat_mut() else {
|
||||||
continue;
|
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 {
|
let Some(target) = component.target_index else {
|
||||||
component.hit_timer = 0;
|
component.hit_timer = 0;
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let reach = stats.range + radii.get(target).copied().unwrap_or(0);
|
let reach = range + radii.get(target).copied().unwrap_or(0);
|
||||||
if distance_squared(positions[index], positions[target]) > reach.saturating_mul(reach) {
|
if distance_squared(positions[index], positions[target])
|
||||||
|
> reach.saturating_mul(reach)
|
||||||
|
{
|
||||||
component.hit_timer = 0;
|
component.hit_timer = 0;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
component.hit_timer += TICK_MILLISECONDS;
|
let before = component.hit_timer;
|
||||||
let period = stats.hit_speed.max(TICK_MILLISECONDS);
|
let mut seeded = component.hit_timer;
|
||||||
let wind_up = stats.load_time.max(0);
|
if seeded == 0 && !component.flag_73 {
|
||||||
if component.hit_timer >= wind_up + period {
|
if load_time <= hit_speed {
|
||||||
component.hit_timer = wind_up;
|
seeded = load_time - component.field_52;
|
||||||
hits.push((target, stats.damage));
|
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 {
|
for (target, amount) in hits {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue