converge combat timers, projectile references, pending damage, and the spawn ring

- replay the shooter's combat update a second time when it launches a
  projectile, matching the client's own component-pass re-entry
- carry the shooter's damage effect on every projectile and null a
  shot's target/source when either leaves the board
- derive pending physical damage from the shots actually in flight
  instead of an incremental total, so it can never go negative
- split the spawn ring's two angle registers so three- and five-unit
  cards land where the client puts them
- attribute a combat target left at NONE to the exact site that did it,
  gated to report each object once
- number and annotate every checksum field so a client-reported
  mismatch resolves straight to a name
This commit is contained in:
WiseDev 2026-08-28 16:27:07 +03:00
parent c18ce37a42
commit 07f0ee5427
28 changed files with 3100 additions and 535 deletions

View file

@ -1,9 +1,10 @@
use titan::{ByteStreamReader, ByteStreamWriter, LogicLong, Payload, Result};
use crate::battle::logic_game_object::LogicGameObjectEntry;
use crate::battle::logic_game_object::{LogicGameObjectEntry, LogicObjectBody, LogicVector2};
use crate::battle::logic_game_object_manager::LogicGameObjectManager;
use crate::battle::logic_game_object_ref::LogicGameObjectRef;
use crate::battle::logic_summoner::{check_spell_position, find_position_for_spell, DeployBlocker};
use crate::data::LogicDataRef;
use crate::model::LogicSpellDeck;
use titan::{ByteStreamReader, ByteStreamWriter, LogicLong, Payload, Result};
pub const BATTLE_TYPE_PVP: i32 = 0;
pub const BATTLE_TYPE_NPC: i32 = 1;
pub const BATTLE_TYPE_REPLAY: i32 = 3;
@ -11,8 +12,6 @@ pub const BATTLE_INT_ARRAY: usize = 8;
pub const BATTLE_TICKS_PER_SECOND: i32 = 20;
pub const LEADER_TOWER_COUNT: i32 = 2;
pub const CROWNS_FOR_LEADER: i32 = 3;
pub const LOCATION_MATCH_LENGTH_COLUMN: &str = "MatchLength";
pub const LOCATION_OVERTIME_COLUMN: &str = "OvertimeSeconds";
pub const BATTLE_TRAILING_INTS: usize = 6;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogicBattle {
@ -41,6 +40,59 @@ pub struct LogicBattle {
pub trailing: [i32; BATTLE_TRAILING_INTS],
pub tilemap: Option<crate::battle::LogicTilemap>,
}
impl LogicBattle {
pub fn deploy_blockers(&self, caster_is_owner_zero: bool) -> Vec<DeployBlocker> {
let mut blockers = Vec::new();
for entry in self.objects.objects.iter() {
if !entry.is_alive() || entry.is_projectile() {
continue;
}
let Some(data) = entry.data.as_character() else {
continue;
};
let is_summoner = matches!(entry.body, LogicObjectBody::Summoner(_));
if !data.is_building() && !is_summoner {
continue;
}
let no_deploy_w = data.no_deploy_size_w();
let friendly = no_deploy_w < 1 || (entry.owner_index() == 0) == caster_is_owner_zero;
let (size_w, size_h) = if friendly {
let size = data.size_in_tiles();
(size, size)
} else {
(no_deploy_w, data.no_deploy_size_h())
};
let position = entry.position();
blockers.push(DeployBlocker {
x: position.0,
y: position.1,
size_w,
size_h,
});
}
blockers
}
pub fn resolve_spell_position(
&self,
spell: &LogicDataRef,
raw: LogicVector2,
owner: i32,
) -> Option<LogicVector2> {
let tilemap = self.tilemap.as_ref()?;
let summon_name = spell
.as_spell()
.map(|spell| spell.summon_character().to_owned());
let summon = summon_name
.filter(|name| !name.is_empty())
.map(|name| LogicDataRef::by_name(crate::data::table::CHARACTERS_COMBINED, &name));
let summon_data = summon.as_ref().and_then(|data| data.as_character());
if check_spell_position(tilemap, raw.x, raw.y, summon_data.is_some()) != 0 {
return None;
}
let blockers = self.deploy_blockers(owner == 0);
find_position_for_spell(summon_data.as_ref(), raw, tilemap, &blockers, false)
}
}
impl Default for LogicBattle {
fn default() -> Self {
Self {
@ -77,14 +129,14 @@ impl LogicBattle {
}
pub fn match_length_seconds(&self) -> i32 {
self.location
.data()
.map(|row| row.int(LOCATION_MATCH_LENGTH_COLUMN))
.as_location()
.map(|location| location.match_length())
.unwrap_or(0)
}
pub fn overtime_length_seconds(&self) -> i32 {
self.location
.data()
.map(|row| row.int(LOCATION_OVERTIME_COLUMN))
.as_location()
.map(|location| location.overtime_seconds())
.unwrap_or(0)
}
pub fn leader(&self, index: usize) -> Option<&LogicGameObjectEntry> {
@ -95,7 +147,9 @@ impl LogicBattle {
.find(|entry| entry.global_id == *id)
}
pub fn is_leader_alive(&self, index: usize) -> bool {
self.leader(index).map(|entry| entry.is_alive()).unwrap_or(false)
self.leader(index)
.map(|entry| entry.is_alive())
.unwrap_or(false)
}
pub fn stars(&self, index: usize) -> i32 {
let other = 1 - index.min(1);
@ -130,9 +184,30 @@ impl LogicBattle {
}
self.stars(0) != self.stars(1)
}
pub fn resolve_winner(&mut self, tick: i32) {
if self.battle_ended_called {
return;
}
self.battle_ended_called = true;
self.end_counter = 1;
let mut end = self.match_length_seconds();
if self.is_on_overtime {
end += self.overtime_length_seconds();
}
self.battle_ended_with_timeout = tick / BATTLE_TICKS_PER_SECOND >= end;
let (s0, s1) = (self.stars(0), self.stars(1));
self.winner_index = if s0 < s1 {
1
} else if s0 > s1 {
0
} else {
-1
};
}
}
impl Payload for LogicBattle {
fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> {
titan::checksum::checksum_trace_note("battle.header");
self.location.encode(writer)?;
self.npc.encode(writer)?;
self.arena.encode(writer)?;
@ -154,6 +229,7 @@ impl Payload for LogicBattle {
writer.write_boolean(self.show_start_hud);
writer.write_boolean(self.is_on_overtime);
self.objects.encode(writer)?;
titan::checksum::checksum_trace_note("battle.decks");
for deck in &self.decks {
match deck {
None => writer.write_boolean(false),
@ -163,9 +239,11 @@ impl Payload for LogicBattle {
}
}
}
titan::checksum::checksum_trace_note("battle.leaders");
for leader in &self.leaders {
leader.encode(writer)?;
}
titan::checksum::checksum_trace_note("battle.leader_towers");
for towers in &self.leader_towers {
writer.write_vint(towers.len() as i32);
for tower in towers {
@ -176,6 +254,7 @@ impl Payload for LogicBattle {
writer.write_vint(self.winner_score_change);
writer.write_vint(self.loser_score_change);
}
titan::checksum::checksum_trace_note("battle.trailing");
for value in self.trailing {
writer.write_vint(value);
}

View file

@ -1,5 +1,5 @@
use titan::{ByteStreamWriter, Result};
use crate::battle::logic_game_object::{LogicGameObject, LogicVector2};
use titan::{ByteStreamWriter, Result};
pub const DIRECTION_TOP: i32 = 256;
pub const DIRECTION_BOTTOM: i32 = -256;
pub const DEFAULT_SIZE: i32 = 100;

View file

@ -1,19 +1,19 @@
use titan::{ByteStreamWriter, Payload, Result};
use crate::battle::logic_game_object::LogicVector2;
use crate::battle::logic_game_object_ref::LogicGameObjectRef;
use titan::{ByteStreamWriter, Payload, Result};
pub const COMPONENT_COMBAT: usize = 0;
pub const COMPONENT_MOVEMENT: usize = 1;
pub const COMPONENT_HITPOINT: usize = 2;
pub const COMPONENT_BUFF: usize = 3;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct LogicCombatComponent {
pub flag_72: bool,
pub flag_73: bool,
pub is_healing: bool,
pub charge_ready: bool,
pub field_48: i32,
pub field_52: i32,
pub field_60: i32,
pub field_64: i32,
pub field_68: i32,
pub load_timer: i32,
pub dash_cooldown: i32,
pub special_attack_counter: i32,
pub attack_finish_timer: i32,
pub target: LogicGameObjectRef,
pub attackers: Vec<(LogicGameObjectRef, i32)>,
pub observers: Vec<LogicGameObjectRef>,
@ -21,14 +21,33 @@ pub struct LogicCombatComponent {
pub hit_timer: i32,
}
impl LogicCombatComponent {
pub fn note_attacker(&mut self, attacker: LogicGameObjectRef, timer: i32) {
if let Some(entry) = self
.attackers
.iter_mut()
.find(|(reference, _)| *reference == attacker)
{
entry.1 = timer;
} else {
self.attackers.push((attacker, timer));
}
}
pub fn age_attackers(&mut self, dt: i32) {
for i in (0..self.attackers.len()).rev() {
self.attackers[i].1 -= dt;
if self.attackers[i].1 <= 0 {
self.attackers.remove(i);
}
}
}
pub fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> {
writer.write_boolean(self.flag_72);
writer.write_boolean(self.flag_73);
writer.write_boolean(self.is_healing);
writer.write_boolean(self.charge_ready);
writer.write_vint(self.hit_timer);
writer.write_vint(self.field_52);
writer.write_vint(self.field_60);
writer.write_vint(self.field_64);
writer.write_vint(self.field_68);
writer.write_vint(self.load_timer);
writer.write_vint(self.dash_cooldown);
writer.write_vint(self.special_attack_counter);
writer.write_vint(self.attack_finish_timer);
writer.write_vint(self.attackers.len() as i32);
writer.write_vint(self.observers.len() as i32);
self.target.encode(writer)?;
@ -66,7 +85,6 @@ pub struct LogicMovementComponent {
pub move_timer: i32,
pub jump_distance: i32,
pub goal: Option<(i32, i32)>,
pub route: Vec<(i32, i32)>,
}
impl Default for LogicMovementComponent {
fn default() -> Self {
@ -92,7 +110,6 @@ impl Default for LogicMovementComponent {
move_timer: 0,
jump_distance: 0,
goal: None,
route: Vec::new(),
}
}
}
@ -142,6 +159,19 @@ impl LogicHitpointComponent {
..Self::default()
}
}
pub fn with_lifetime(hitpoints: i32, life_time: i32) -> Self {
let lifetime_damage = if life_time >= 1 {
(100_000i64 * hitpoints as i64 / life_time as i64 / 20) as i32
} else {
0
};
Self {
hitpoints,
base_hitpoints: hitpoints,
lifetime_damage,
..Self::default()
}
}
pub fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> {
writer.write_vint(self.hitpoints);
writer.write_vint(self.base_hitpoints);
@ -161,17 +191,19 @@ pub struct LogicCharacterBuffComponent {
pub field_36: i32,
pub field_40: i32,
pub buff_type_count: usize,
pub field_52: i32,
pub damage_multiplier: i32,
pub field_56: i32,
pub field_60: i32,
pub field_64: i32,
pub field_68: i32,
pub size_multiplier: i32,
pub field_72: i32,
}
impl LogicCharacterBuffComponent {
pub fn empty(buff_type_count: usize) -> Self {
Self {
buff_type_count,
damage_multiplier: 100,
size_multiplier: 100,
..Self::default()
}
}
@ -187,15 +219,25 @@ impl LogicCharacterBuffComponent {
for _ in 0..self.buff_type_count {
writer.write_boolean(false);
}
writer.write_vint(self.field_52);
writer.write_vint(self.damage_multiplier);
writer.write_vint(self.field_56);
writer.write_vint(self.field_60);
writer.write_vint(self.field_64);
writer.write_vint(self.field_68);
writer.write_vint(self.size_multiplier);
writer.write_vint(self.field_72);
Ok(())
}
}
#[cfg(test)]
mod buff_component_tests {
use super::LogicCharacterBuffComponent;
#[test]
fn an_unbuffed_component_reports_full_size_and_damage() {
let component = LogicCharacterBuffComponent::empty(5);
assert_eq!(component.size_multiplier, 100);
assert_eq!(component.damage_multiplier, 100);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LogicComponent {
Combat(LogicCombatComponent),
@ -204,6 +246,14 @@ pub enum LogicComponent {
Buff(LogicCharacterBuffComponent),
}
impl LogicComponent {
pub fn kind(&self) -> &'static str {
match self {
LogicComponent::Combat(_) => "combat",
LogicComponent::Movement(_) => "movement",
LogicComponent::Hitpoint(_) => "hitpoint",
LogicComponent::Buff(_) => "buff",
}
}
pub fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> {
match self {
LogicComponent::Combat(component) => component.encode(writer),

View file

@ -1,9 +1,9 @@
use titan::{ByteStreamWriter, Payload, Result};
use crate::battle::logic_battle::LogicBattle;
use crate::battle::logic_time::LogicTime;
use crate::battle::logic_tutorial_manager::LogicTutorialManager;
use crate::logic_random::LogicRandom;
use crate::model::LogicClientAvatar;
use titan::{ByteStreamWriter, Payload, Result};
pub const SECTION_BATTLE: i32 = 11;
pub const SECTION_TUTORIAL: i32 = 12;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
@ -16,40 +16,57 @@ pub struct LogicGameMode {
pub tutorial_manager: LogicTutorialManager,
}
impl LogicGameMode {
pub fn snapshot(&self) -> Result<Vec<u8>> {
pub fn snapshot(&self, commands: &[Vec<u8>]) -> Result<Vec<u8>> {
let mut writer = ByteStreamWriter::new();
self.encode(&mut writer)?;
self.write(&mut writer, Some(commands))?;
Ok(writer.into_inner())
}
}
impl LogicGameMode {
pub fn write(&self, writer: &mut ByteStreamWriter, with_commands: bool) -> Result<i32> {
pub fn write(
&self,
writer: &mut ByteStreamWriter,
commands: Option<&[Vec<u8>]>,
) -> Result<i32> {
titan::checksum::checksum_trace_note("gamemode.tick, checkpoint, section");
writer.write_vint(self.time.tick);
writer.write_checksum_checkpoint();
writer.write_vint(SECTION_BATTLE);
titan::checksum::checksum_trace_note("time");
self.time.encode(writer)?;
titan::checksum::checksum_trace_note("random, seed");
self.random.encode(writer)?;
writer.write_vint(self.random_seed);
self.battle.encode(writer)?;
for avatar in &self.avatars {
for (index, avatar) in self.avatars.iter().enumerate() {
titan::checksum::checksum_trace_note(format!("avatar[{index}]"));
avatar.encode(writer)?;
}
titan::checksum::checksum_trace_note("tutorial");
writer.write_vint(SECTION_TUTORIAL);
self.tutorial_manager.encode(writer)?;
let checksum = writer.write_checksum_checkpoint();
if with_commands {
writer.write_vint(0);
if let Some(commands) = commands {
writer.write_vint(commands.len() as i32);
for command in commands {
writer.write_raw(command);
}
}
Ok(checksum)
}
pub fn calculate_checksum(&self) -> Result<i32> {
let mut writer = ByteStreamWriter::new();
self.write(&mut writer, false)
self.write(&mut writer, None)
}
pub fn checksum_stream(&self) -> Result<(i32, Vec<u8>)> {
let mut writer = ByteStreamWriter::new();
let checksum = self.write(&mut writer, None)?;
Ok((checksum, writer.into_inner()))
}
}
impl Payload for LogicGameMode {
fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> {
self.write(writer, true)?;
self.write(writer, Some(&[]))?;
Ok(())
}
fn decode(_reader: &mut titan::ByteStreamReader<'_>) -> Result<Self> {

View file

@ -1,6 +1,6 @@
use titan::Payload;
use crate::battle::logic_game_object_ref::LogicGameObjectRef;
use crate::data::LogicDataRef;
use titan::Payload;
pub const COMPONENT_PASSES: usize = 4;
pub const OBJECT_TYPE_COUNT: usize = 6;
pub const CHARACTER_OBJECT_TYPE: i32 = 5;
@ -35,30 +35,83 @@ impl LogicGameObject {
pub enum LogicObjectBody {
Character(Box<crate::battle::logic_character::LogicCharacter>),
Summoner(Box<crate::battle::logic_character::LogicSummoner>),
Projectile(Box<crate::battle::logic_projectile::LogicProjectile>),
}
impl LogicObjectBody {
pub fn base(&self) -> &LogicGameObject {
match self {
LogicObjectBody::Character(character) => &character.base,
LogicObjectBody::Summoner(summoner) => &summoner.character.base,
LogicObjectBody::Projectile(projectile) => &projectile.base,
}
}
pub fn level_index(&self) -> i32 {
match self {
LogicObjectBody::Character(character) => character.level_index,
LogicObjectBody::Summoner(summoner) => summoner.character.level_index,
LogicObjectBody::Projectile(projectile) => projectile.level_index,
}
}
pub fn base_mut(&mut self) -> &mut LogicGameObject {
match self {
LogicObjectBody::Character(character) => &mut character.base,
LogicObjectBody::Summoner(summoner) => &mut summoner.character.base,
LogicObjectBody::Projectile(projectile) => &mut projectile.base,
}
}
pub fn lane_id(&self) -> i32 {
match self {
LogicObjectBody::Character(character) => character.lane_id,
LogicObjectBody::Summoner(summoner) => summoner.character.lane_id,
LogicObjectBody::Projectile(_) => 0,
}
}
pub fn set_lane_id(&mut self, lane: i32) {
match self {
LogicObjectBody::Character(character) => character.lane_id = lane,
LogicObjectBody::Summoner(summoner) => summoner.character.lane_id = lane,
LogicObjectBody::Projectile(_) => {}
}
}
pub fn state(&self) -> i32 {
match self {
LogicObjectBody::Character(character) => character.state,
LogicObjectBody::Summoner(summoner) => summoner.character.state,
LogicObjectBody::Projectile(_) => 0,
}
}
pub fn set_state(&mut self, state: i32) {
match self {
LogicObjectBody::Character(character) => character.state = state,
LogicObjectBody::Summoner(summoner) => summoner.character.state = state,
LogicObjectBody::Projectile(_) => {}
}
}
pub fn set_direction(&mut self, direction: LogicVector2) {
match self {
LogicObjectBody::Character(character) => character.direction = direction,
LogicObjectBody::Summoner(summoner) => summoner.character.direction = direction,
LogicObjectBody::Projectile(_) => {}
}
}
pub fn add_pending_physical_damage(&mut self, amount: i32) {
match self {
LogicObjectBody::Character(character) => {
character.pending_physical_damage =
(character.pending_physical_damage + amount).max(0)
}
LogicObjectBody::Summoner(summoner) => {
summoner.character.pending_physical_damage =
(summoner.character.pending_physical_damage + amount).max(0)
}
LogicObjectBody::Projectile(_) => {}
}
}
pub fn encode(&self, writer: &mut titan::ByteStreamWriter) -> titan::Result<()> {
match self {
LogicObjectBody::Character(character) => character.encode(writer),
LogicObjectBody::Summoner(summoner) => summoner.encode(writer),
LogicObjectBody::Projectile(projectile) => projectile.encode(writer),
}
}
}
@ -83,8 +136,14 @@ impl LogicGameObjectEntry {
}
}
pub fn is_alive(&self) -> bool {
if let LogicObjectBody::Projectile(projectile) = &self.body {
return !projectile.destroyed;
}
self.hitpoints().map(|value| value > 0).unwrap_or(false)
}
pub fn is_projectile(&self) -> bool {
matches!(self.body, LogicObjectBody::Projectile(_))
}
pub fn new(
data: LogicDataRef,
global_id: LogicGameObjectRef,

View file

@ -1,5 +1,5 @@
use titan::{ByteStreamReader, ByteStreamWriter, Payload, Result};
use crate::battle::logic_game_object::{LogicGameObjectEntry, COMPONENT_PASSES, OBJECT_TYPE_COUNT};
use titan::{ByteStreamReader, ByteStreamWriter, Payload, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogicGameObjectManager {
pub instance_counters: [i32; OBJECT_TYPE_COUNT],
@ -14,6 +14,30 @@ impl Default for LogicGameObjectManager {
}
}
impl LogicGameObjectManager {
pub fn reserve_instance(&mut self, object_type: i32) -> i32 {
let Ok(index) = usize::try_from(object_type) else {
return 0;
};
let Some(counter) = self.instance_counters.get_mut(index) else {
return 0;
};
let instance = *counter;
*counter = instance + 1;
instance
}
fn note(&self, index: usize, entry: &LogicGameObjectEntry, section: &str) {
if !titan::checksum::checksum_trace_active() {
return;
}
let id = match entry.global_id.0 {
Some(id) => format!("{}:{}", id.class_id, id.instance_id),
None => "-".to_string(),
};
titan::checksum::checksum_trace_note(format!(
"objects[{index}].{section} {id} {}",
entry.data.name()
));
}
pub fn push(&mut self, entry: LogicGameObjectEntry) {
let object_type = entry.object_type();
if let Ok(index) = usize::try_from(object_type) {
@ -29,22 +53,28 @@ impl LogicGameObjectManager {
}
impl Payload for LogicGameObjectManager {
fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> {
titan::checksum::checksum_trace_note("objects.instance_counters");
for counter in self.instance_counters {
writer.write_vint(counter);
}
titan::checksum::checksum_trace_note("objects.count");
writer.write_vint(self.objects.len() as i32);
for entry in &self.objects {
for (index, entry) in self.objects.iter().enumerate() {
self.note(index, entry, "data");
entry.data.encode(writer)?;
}
for entry in &self.objects {
for (index, entry) in self.objects.iter().enumerate() {
self.note(index, entry, "global_id");
entry.global_id.encode(writer)?;
}
for entry in &self.objects {
for (index, entry) in self.objects.iter().enumerate() {
self.note(index, entry, "body");
entry.body.encode(writer)?;
}
for pass in 0..COMPONENT_PASSES {
for entry in &self.objects {
for (index, entry) in self.objects.iter().enumerate() {
if let Some(component) = &entry.components[pass] {
self.note(index, entry, component.kind());
component.encode(writer)?;
}
}

View file

@ -1,119 +1,340 @@
use std::collections::BinaryHeap;
use crate::battle::logic_tilemap::{
LogicTilemap, GROUND_COST, SUBTILE_UNITS, WATER_COST, WATER_COST_JUMPING,
};
pub const PATH_STEP_LIMIT: usize = 4096;
fn tile_of(units: i32) -> i32 {
units.div_euclid(SUBTILE_UNITS)
use crate::battle::logic_simulation::distance_squared;
use crate::battle::logic_tilemap::{LogicTilemap, SUBTILE_UNITS};
pub const COST_UNREACHABLE: i32 = 0xFFF_FFFF;
pub const COST_MAX_TRAVERSABLE: i32 = 268_435_454;
#[inline]
pub fn tile_of(units: i32) -> i32 {
units / SUBTILE_UNITS
}
fn centre_of(tile: i32) -> i32 {
#[inline]
pub fn centre_of(tile: i32) -> i32 {
tile * SUBTILE_UNITS + SUBTILE_UNITS / 2
}
#[derive(PartialEq, Eq)]
struct Step {
estimate: i32,
cost: i32,
index: usize,
}
impl Ord for Step {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
other
.estimate
.cmp(&self.estimate)
.then_with(|| other.index.cmp(&self.index))
#[inline]
pub fn path_finder_cost(tm: &LogicTilemap, x: i32, y: i32, lane_id: i32, jump: bool) -> i32 {
if (x | y) < 0 || tm.width() <= x || tm.height() <= y {
return COST_UNREACHABLE;
}
let v = tm.tile_at(x, y);
if (v >> 5) & 1 == 1 {
if jump {
20
} else {
800
}
} else {
let lane = v & 3;
if lane != 0 {
if lane_id == lane {
1
} else {
5
}
} else {
20
}
}
}
impl PartialOrd for Step {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
#[inline]
pub fn is_passable_path_finder(tm: &LogicTilemap, x: i32, y: i32) -> bool {
(x | y) >= 0 && tm.width() > x && tm.height() > y
}
struct AStar<'a> {
tm: &'a LogicTilemap,
width: i32,
height: i32,
goal_x: i32,
goal_y: i32,
lane_id: i32,
jump: bool,
state: Vec<u8>,
came_from: Vec<i32>,
score: Vec<i32>,
heap: Vec<i32>,
heap_size: usize,
}
impl AStar<'_> {
#[inline]
fn heuristic(&self, x: i32, y: i32) -> i32 {
10 * (self.goal_x - x).abs().max((self.goal_y - y).abs())
}
fn heap_add(&mut self, node: i32) {
let pos = self.heap_size;
self.heap[pos] = node;
self.heap_size += 1;
if pos >= 1 {
let key = self.score[node as usize];
let mut v2 = pos as i32;
let mut v4 = pos as i32 - 1;
loop {
let parent_idx = v4 >> 1;
let parent = self.heap[parent_idx as usize];
if key >= self.score[parent as usize] {
break;
}
self.heap[v2 as usize] = parent;
v2 = parent_idx;
self.heap[parent_idx as usize] = node;
v4 = parent_idx - 1;
if parent_idx < 1 {
break;
}
}
}
}
fn remove_smallest(&mut self) -> i32 {
let v1 = self.heap_size;
if v1 == 0 {
return -1;
}
let root = self.heap[0];
let last = self.heap[v1 - 1];
self.heap_size = v1 - 1;
self.heap[0] = last;
let mut pos = 0i32;
loop {
let left = (2 * pos) | 1;
let right = 2 * pos + 2;
let size = self.heap_size as i32;
let mut best = pos;
if right < size {
if self.score[last as usize] <= self.score[self.heap[right as usize] as usize] {
best = pos;
} else {
best = right;
}
}
if left < size
&& self.score[self.heap[best as usize] as usize]
> self.score[self.heap[left as usize] as usize]
{
best = left;
}
if best == pos {
break;
}
self.heap[pos as usize] = self.heap[best as usize];
self.heap[best as usize] = last;
pos = best;
}
root
}
fn relax(&mut self, cur: i32, nx: i32, ny: i32, nidx: i32, weight: i32) {
if ny < 0 {
return;
}
if nx < 0 || self.height <= ny {
return;
}
if self.width <= nx {
return;
}
let cost = path_finder_cost(self.tm, nx, ny, self.lane_id, self.jump);
if cost <= COST_MAX_TRAVERSABLE && self.state[nidx as usize] != 2 {
let g = self.score[cur as usize] + cost * weight;
let f = g + self.heuristic(nx, ny);
if self.state[nidx as usize] == 0 {
self.state[nidx as usize] = 1;
self.came_from[nidx as usize] = cur;
self.score[nidx as usize] = f;
self.heap_add(nidx);
}
}
}
fn expand(&mut self, node: i32) {
let w = self.width;
let y = node / w;
let x = node % w;
self.relax(node, x, y - 1, node - w, 10);
self.relax(node, x, y + 1, node + w, 10);
self.relax(node, x - 1, y, node - 1, 10);
self.relax(node, x + 1, y, node + 1, 10);
self.relax(node, x - 1, y - 1, node - 1 - w, 14);
self.relax(node, x - 1, y + 1, node - 1 + w, 14);
self.relax(node, x + 1, y + 1, node + 1 + w, 14);
self.relax(node, x + 1, y - 1, node + 1 - w, 14);
}
fn run(&mut self, start: i32, goal: i32) -> Vec<i32> {
self.goal_x = goal % self.width;
self.goal_y = goal / self.width;
self.came_from[start as usize] = -1;
self.came_from[goal as usize] = -1;
self.expand(start);
self.state[start as usize] = 2;
if self.heap_size > 0 {
loop {
let v = self.remove_smallest();
self.state[v as usize] = 2;
self.expand(v);
if self.state[goal as usize] == 2 || self.heap_size == 0 {
break;
}
}
}
let mut path = Vec::new();
let mut cur = goal;
let mut i = self.came_from[goal as usize];
while i != -1 {
path.push(cur);
cur = i;
i = self.came_from[i as usize];
}
path
}
}
pub fn find_path(
tilemap: &LogicTilemap,
from: (i32, i32),
to: (i32, i32),
jumps: bool,
) -> Vec<(i32, i32)> {
let start = (tile_of(from.0), tile_of(from.1));
let goal = (tile_of(to.0), tile_of(to.1));
if start == goal
|| !tilemap.is_inside(start.0, start.1)
|| !tilemap.is_inside(goal.0, goal.1)
{
tm: &LogicTilemap,
start_tile: (i32, i32),
goal_tile: (i32, i32),
lane_id: i32,
jump: bool,
) -> Vec<i32> {
let width = tm.width();
let height = tm.height();
if width < 1 || height < 1 {
return Vec::new();
}
let width = tilemap.width().max(1) as usize;
let height = tilemap.height().max(1) as usize;
let index_of = |x: i32, y: i32| y as usize * width + x as usize;
let mut cost = vec![i32::MAX; width * height];
let mut came_from = vec![usize::MAX; width * height];
let mut open = BinaryHeap::new();
let start_index = index_of(start.0, start.1);
cost[start_index] = 0;
open.push(Step {
estimate: 0,
cost: 0,
index: start_index,
});
let goal_index = index_of(goal.0, goal.1);
let mut visited = 0;
while let Some(step) = open.pop() {
if step.index == goal_index {
break;
}
if step.cost > cost[step.index] {
continue;
}
visited += 1;
if visited > PATH_STEP_LIMIT {
break;
}
let x = (step.index % width) as i32;
let y = (step.index / width) as i32;
for (dx, dy) in [(1, 0), (-1, 0), (0, 1), (0, -1)] {
let (nx, ny) = (x + dx, y + dy);
if !tilemap.is_inside(nx, ny) {
continue;
}
let water = tilemap.is_water(nx, ny);
let step_cost = if water {
if jumps {
WATER_COST_JUMPING
} else {
WATER_COST
}
} else {
GROUND_COST
};
let next = index_of(nx, ny);
let total = step.cost.saturating_add(step_cost);
if total >= cost[next] {
continue;
}
cost[next] = total;
came_from[next] = step.index;
let heuristic = (goal.0 - nx).abs() + (goal.1 - ny).abs();
open.push(Step {
estimate: total.saturating_add(heuristic),
cost: total,
index: next,
});
}
}
if came_from[goal_index] == usize::MAX {
if !is_passable_path_finder(tm, start_tile.0, start_tile.1) {
return Vec::new();
}
let mut path = Vec::new();
let mut cursor = goal_index;
while cursor != start_index {
let x = (cursor % width) as i32;
let y = (cursor / width) as i32;
path.push((centre_of(x), centre_of(y)));
cursor = came_from[cursor];
if cursor == usize::MAX {
return Vec::new();
}
if path_finder_cost(tm, goal_tile.0, goal_tile.1, lane_id, jump) >= COST_UNREACHABLE {
return Vec::new();
}
let start = start_tile.0 + width * start_tile.1;
let goal = goal_tile.0 + width * goal_tile.1;
let n = (width * height) as usize;
let mut a = AStar {
tm,
width,
height,
goal_x: 0,
goal_y: 0,
lane_id,
jump,
state: vec![0u8; n],
came_from: vec![-1i32; n],
score: vec![0i32; n],
heap: vec![0i32; n],
heap_size: 0,
};
a.run(start, goal)
}
pub fn get_lane_id(tm: &LogicTilemap, deploy_x: i32, deploy_y: i32) -> i32 {
let (mw, mh) = (tm.width(), tm.height());
if mw < 1 {
return 0;
}
let dx = deploy_x / SUBTILE_UNITS;
let ndy = deploy_y / -SUBTILE_UNITS;
let mut best_lane = 0i32;
let mut best_dist = i32::MAX;
let mut x = 0;
while x < mw {
let mut y = 0;
while y < mh {
let lane = tm.tile_at(x, y) & 3;
let d = (x - dx) * (x - dx) + (ndy + y) * (ndy + y);
if lane >= 1 && d < best_dist {
best_dist = d;
best_lane = lane;
}
y += 1;
}
x += 1;
}
best_lane
}
pub fn spawn_lane_of(tm: &LogicTilemap, x: i32, y: i32) -> i32 {
let x = x.clamp(250, SUBTILE_UNITS * tm.width() - 250);
let y = y.clamp(250, SUBTILE_UNITS * tm.height() - 250);
get_lane_id(tm, x, y)
}
#[allow(clippy::too_many_arguments)]
pub fn closest_tile_position_to_target(
tm: &LogicTilemap,
unit_x: i32,
unit_y: i32,
target_x: i32,
target_y: i32,
target_tile_x: i32,
target_tile_y: i32,
range: i32,
healing_power: i32,
owner_top: bool,
) -> Option<(i32, i32)> {
let (width, height) = (tm.width(), tm.height());
let (ref_x, ref_y) = if healing_power < 1 {
(unit_x, unit_y)
} else {
(target_x, if owner_top { -range } else { range } + target_y)
};
let r = range / SUBTILE_UNITS + 1;
let x_min = (target_tile_x - r).max(0);
let x_max = (r + target_tile_x).min(width - 1);
let y_min = (target_tile_y - r).max(0);
let y_max = (r + target_tile_y).min(height - 1);
if y_min > y_max {
return None;
}
let range_sq = range.wrapping_mul(range);
let mut best = i32::MAX;
let mut best_tile: Option<(i32, i32)> = None;
let mut ty = y_min;
while ty <= y_max {
let cy = SUBTILE_UNITS * ty + SUBTILE_UNITS / 2;
let dy_sq = (cy - ref_y).wrapping_mul(cy - ref_y);
let mut tx = x_min;
while tx <= x_max {
let cx = SUBTILE_UNITS * tx + SUBTILE_UNITS / 2;
if is_passable_path_finder(tm, tx, ty)
&& distance_squared((target_x, target_y), (cx, cy)) <= range_sq
{
let d = dy_sq.wrapping_add((cx - ref_x).wrapping_mul(cx - ref_x));
if d < best {
best = d;
best_tile = Some((tx, ty));
}
}
tx += 1;
}
ty += 1;
}
best_tile
}
#[inline]
pub fn vec_length(x: i32, y: i32) -> i32 {
crate::logic_sqrt(distance_squared((0, 0), (x, y)))
}
#[inline]
pub fn normalize_to(x: &mut i32, y: &mut i32, target_len: i32) {
let len = vec_length(*x, *y);
if len != 0 {
*x = x.wrapping_mul(target_len) / len;
*y = y.wrapping_mul(target_len) / len;
}
}
pub fn path_target_normal(path: &[i32], unit_x: i32, unit_y: i32, width: i32) -> (i32, i32) {
if path.is_empty() {
return (0, 0);
}
let node = path[path.len() - 1];
let mut nx = centre_of(node % width) - unit_x;
let mut ny = centre_of(node / width) - unit_y;
normalize_to(&mut nx, &mut ny, 256);
(nx, ny)
}
pub fn target_position_where_going_now(
path: &[i32],
width: i32,
target_pos: Option<(i32, i32)>,
unit_tile_x: i32,
unit_tile_y: i32,
) -> (i32, i32) {
if let Some(&node) = path.last() {
(centre_of(node % width), centre_of(node / width))
} else if let Some(p) = target_pos {
p
} else {
(centre_of(unit_tile_x), centre_of(unit_tile_y))
}
path.reverse();
path
}

View file

@ -0,0 +1,43 @@
use crate::battle::logic_game_object::{LogicGameObject, LogicVector2};
use crate::battle::logic_game_object_ref::LogicGameObjectRef;
use crate::data::LogicDataRef;
use titan::{ByteStreamWriter, GlobalId, Payload, Result};
pub const PROJECTILE_OBJECT_TYPE: i32 = 3;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct LogicProjectile {
pub destroyed: bool,
pub base: LogicGameObject,
pub target_position: LogicVector2,
pub start_position: LogicVector2,
pub offset: LogicVector2,
pub target: LogicGameObjectRef,
pub source: LogicGameObjectRef,
pub aux_data: LogicDataRef,
pub effect: LogicDataRef,
pub level_index: i32,
pub target_z: i32,
pub start_z: i32,
pub hit_objects: Vec<GlobalId>,
}
impl LogicProjectile {
pub fn encode(&self, writer: &mut ByteStreamWriter) -> Result<()> {
writer.write_boolean(self.destroyed);
self.base.encode_base(writer)?;
self.target_position.encode(writer)?;
self.start_position.encode(writer)?;
self.offset.encode(writer)?;
self.target.encode(writer)?;
self.source.encode(writer)?;
self.aux_data.encode(writer)?;
self.effect.encode(writer)?;
writer.write_vint(self.level_index);
writer.write_vint(self.target_z);
writer.write_vint(self.start_z);
writer.write_vint(self.hit_objects.len() as i32);
for id in &self.hit_objects {
writer.write_vint(id.class_id);
writer.write_vint(id.instance_id);
}
Ok(())
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,210 @@
use crate::battle::logic_game_object::LogicVector2;
use crate::battle::logic_simulation::distance_squared;
use crate::battle::logic_tilemap::{LogicTilemap, SUBTILE_UNITS};
use crate::data::LogicCharacterData;
pub const DEPLOY_CELL_UNITS: i32 = 1000;
pub const FIND_POSITION_MAX_RING: i32 = 30;
pub const DEPLOY_BORDER_TILES: i32 = 0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DeployBlocker {
pub x: i32,
pub y: i32,
pub size_w: i32,
pub size_h: i32,
}
pub fn snap_coordinate(character: &LogicCharacterData, value: i32) -> i32 {
let base = DEPLOY_CELL_UNITS * (value / DEPLOY_CELL_UNITS);
let centred = !character.is_building() || character.size_in_tiles() & 1 == 1;
if centred {
base + 500
} else {
base
}
}
pub fn build_free_tile_map(blockers: &[DeployBlocker], cell_w: i32, cell_h: i32) -> Vec<bool> {
let mut free = vec![true; (cell_w.max(0) * cell_h.max(0)) as usize];
for blocker in blockers {
if blocker.size_w == 0 {
continue;
}
let y_from = (blocker.y - 500 * blocker.size_h) / DEPLOY_CELL_UNITS;
let y_to = (blocker.y + 500 * blocker.size_h) / DEPLOY_CELL_UNITS;
let x_from = (blocker.x - 500 * blocker.size_w) / DEPLOY_CELL_UNITS;
let x_to = (blocker.x + 500 * blocker.size_w) / DEPLOY_CELL_UNITS;
let mut y = y_from;
while y < y_to {
if y >= 0 && y < cell_h {
let mut x = x_from;
while x < x_to {
if x >= 0 && x < cell_w {
free[(y * cell_w + x) as usize] = false;
}
x += 1;
}
}
y += 1;
}
}
free
}
pub fn position_free(
free: &[bool],
cell_w: i32,
cell_h: i32,
pos: (i32, i32),
size_in_tiles: i32,
tilemap: &LogicTilemap,
) -> bool {
let x0 = (pos.0 - 500 * size_in_tiles) / DEPLOY_CELL_UNITS;
let x1 = (pos.0 + 500 * size_in_tiles) / DEPLOY_CELL_UNITS;
if x0 >= x1 {
return true;
}
let y0 = (pos.1 - 500 * size_in_tiles) / DEPLOY_CELL_UNITS;
let y1 = (pos.1 + 500 * size_in_tiles) / DEPLOY_CELL_UNITS;
let mut x = x0;
while x < x1 {
let mut y = y0;
while y < y1 {
if x < 0 || y < 0 || x >= cell_w || y >= cell_h {
return false;
}
if !free[(y * cell_w + x) as usize] {
return false;
}
let mut quadrant = 0;
while quadrant < 4 {
if !tilemap.can_place_egg(2 * x + (quadrant & 1), 2 * y + (quadrant >> 1)) {
return false;
}
quadrant += 1;
}
y += 1;
}
x += 1;
}
true
}
pub fn roads_beneath(tilemap: &LogicTilemap, pos: (i32, i32), size_in_tiles: i32) -> bool {
let from_x = (pos.0 - 500 * size_in_tiles) / SUBTILE_UNITS;
let to_x = (pos.0 + 500 * size_in_tiles) / SUBTILE_UNITS;
let from_y = (pos.1 - 500 * size_in_tiles) / SUBTILE_UNITS;
let to_y = (pos.1 + 500 * size_in_tiles) / SUBTILE_UNITS;
let mut x = from_x;
while x < to_x {
let mut y = from_y;
while y < to_y {
if tilemap.lane_bits(x, y) >= 1 {
return true;
}
y += 1;
}
x += 1;
}
false
}
pub fn check_spell_position(
tilemap: &LogicTilemap,
x: i32,
y: i32,
summons_a_character: bool,
) -> i32 {
if !summons_a_character {
return 0;
}
if x < -499 {
return 1;
}
let tile_x = x / SUBTILE_UNITS;
let tile_y = y / SUBTILE_UNITS;
if tile_y < DEPLOY_BORDER_TILES {
return 2;
}
if tile_x >= tilemap.width() {
return 3;
}
if tile_y >= tilemap.height() - DEPLOY_BORDER_TILES {
return 4;
}
if !tilemap.can_place_egg(tile_x, tile_y) {
return 5;
}
0
}
pub fn find_position_for_spell(
summon: Option<&LogicCharacterData>,
input: LogicVector2,
tilemap: &LogicTilemap,
blockers: &[DeployBlocker],
avoid_roads: bool,
) -> Option<LogicVector2> {
let x = input.x.clamp(0, SUBTILE_UNITS * tilemap.width());
let y = input.y.clamp(0, SUBTILE_UNITS * tilemap.height());
let Some(summon) = summon else {
return Some(LogicVector2::new(
x + 500 - x % DEPLOY_CELL_UNITS,
y + 500 - y % DEPLOY_CELL_UNITS,
));
};
let cell_w = tilemap.width() >> 1;
let cell_h = tilemap.height() >> 1;
let free = build_free_tile_map(blockers, cell_w, cell_h);
let footprint = if summon.is_building() {
summon.size_in_tiles()
} else {
1
};
let origin_x = snap_coordinate(summon, x);
let origin_y = snap_coordinate(summon, y);
let mut best: Option<(i32, i32)> = None;
let mut best_distance = i32::MAX;
let mut last_ring = FIND_POSITION_MAX_RING;
let mut ring = 0;
loop {
let steps = if ring <= 0 { 1 } else { 2 * ring };
let mut step = 0;
while step < steps {
let low = -ring;
let rising = step - ring;
let falling = ring - step;
let corners = if ring <= 0 { 1 } else { 4 };
let mut corner = 0;
while corner < corners {
let (mut dx, mut dy) = if corner & 1 == 1 {
(low, falling)
} else {
(rising, low)
};
if corner >= 2 {
dx = -dx;
dy = -dy;
}
let candidate = (
origin_x + DEPLOY_CELL_UNITS * dx,
origin_y + DEPLOY_CELL_UNITS * dy,
);
if position_free(&free, cell_w, cell_h, candidate, footprint, tilemap) {
let mut distance = distance_squared(candidate, (x, y));
if avoid_roads && roads_beneath(tilemap, candidate, summon.size_in_tiles()) {
distance = i32::MAX;
}
if distance < best_distance {
best = Some(candidate);
best_distance = distance;
last_ring = 0;
}
}
corner += 1;
}
step += 1;
}
if ring >= last_ring {
break;
}
ring += 1;
}
if best_distance == i32::MAX {
return None;
}
best.map(|(bx, by)| LogicVector2::new(bx, by))
}

View file

@ -2,9 +2,7 @@ use std::collections::HashMap;
use std::path::Path;
pub const SUBTILE_UNITS: i32 = 500;
pub const WATER_BIT: i32 = 5;
pub const WATER_COST: i32 = 800;
pub const WATER_COST_JUMPING: i32 = 20;
pub const GROUND_COST: i32 = 1;
pub const NO_DEPLOY_BIT: i32 = 4;
pub const SECTION_OBJECTS: &str = "Objects";
pub const SECTION_MAP: &str = "Map";
pub const OBJECT_KING_TOWER: &str = "KingTower";
@ -66,15 +64,21 @@ impl LogicTilemap {
}
}
SECTION_MAP => {
let values: Vec<i32> = row[1..]
let cells: Vec<&str> = row
.get(1..)
.unwrap_or_default()
.iter()
.map(|value| value.trim())
.take_while(|value| !value.is_empty())
.filter_map(number)
.collect();
if !values.is_empty() {
tilemap.tiles.push(values);
if cells.iter().all(|value| number(value).is_none()) {
continue;
}
tilemap.tiles.push(
cells
.iter()
.map(|value| number(value).unwrap_or(0))
.collect(),
);
}
_ => {}
}
@ -99,6 +103,13 @@ impl LogicTilemap {
pub fn is_water(&self, x: i32, y: i32) -> bool {
(self.tile_at(x, y) >> WATER_BIT) & 1 == 1
}
pub fn lane_bits(&self, x: i32, y: i32) -> i32 {
self.tile_at(x, y) & 3
}
pub fn can_place_egg(&self, x: i32, y: i32) -> bool {
let tile = self.tile_at(x, y);
(tile >> WATER_BIT) & 1 == 0 && (tile >> NO_DEPLOY_BIT) & 1 == 0
}
pub fn is_inside(&self, x: i32, y: i32) -> bool {
x >= 0 && y >= 0 && x < self.width() && y < self.height()
}

View file

@ -1,6 +1,6 @@
use titan::Payload;
use crate::battle::logic_game_object_ref::LogicGameObjectRef;
use crate::data::LogicDataRef;
use titan::Payload;
#[derive(Debug, Default, Clone, PartialEq, Eq, Payload)]
pub struct LogicTutorialManager {
pub tutorial: LogicDataRef,

View file

@ -7,7 +7,9 @@ mod logic_game_object;
mod logic_game_object_manager;
mod logic_game_object_ref;
mod logic_pathfinder;
mod logic_projectile;
mod logic_simulation;
mod logic_summoner;
mod logic_tilemap;
mod logic_time;
mod logic_tutorial_manager;
@ -16,15 +18,15 @@ pub use logic_battle::{
LogicBattle, BATTLE_INT_ARRAY, BATTLE_TICKS_PER_SECOND, BATTLE_TRAILING_INTS, BATTLE_TYPE_NPC,
BATTLE_TYPE_PVP, BATTLE_TYPE_REPLAY, CROWNS_FOR_LEADER, LEADER_TOWER_COUNT,
};
pub use logic_battle_event::LogicBattleEvent;
pub use logic_character::{
LogicCharacter, LogicSummoner, LogicSummonerDeck, DEFAULT_SIZE, DIRECTION_BOTTOM, DIRECTION_TOP,
};
pub use logic_component::{
LogicMovementComponent,
LogicCharacterBuffComponent, LogicCombatComponent, LogicComponent, LogicHitpointComponent,
COMPONENT_BUFF, COMPONENT_COMBAT, COMPONENT_HITPOINT, COMPONENT_MOVEMENT,
LogicMovementComponent, COMPONENT_BUFF, COMPONENT_COMBAT, COMPONENT_HITPOINT,
COMPONENT_MOVEMENT,
};
pub use logic_battle_event::LogicBattleEvent;
pub use logic_game_mode::{LogicGameMode, SECTION_BATTLE, SECTION_TUTORIAL};
pub use logic_game_object::{
LogicGameObject, LogicGameObjectEntry, LogicObjectBody, LogicVector2, CHARACTER_OBJECT_TYPE,
@ -32,12 +34,48 @@ pub use logic_game_object::{
};
pub use logic_game_object_manager::LogicGameObjectManager;
pub use logic_game_object_ref::LogicGameObjectRef;
pub use logic_pathfinder::find_path;
pub use logic_pathfinder::{
centre_of, closest_tile_position_to_target, find_path, get_lane_id, path_target_normal,
spawn_lane_of, target_position_where_going_now, tile_of,
};
pub use logic_projectile::{LogicProjectile, PROJECTILE_OBJECT_TYPE};
pub use logic_simulation::{
LogicCharacterStats, CHARACTER_DEPLOY_TIME_COLUMN, CHARACTER_STATE_DEPLOY, CHARACTER_STATE_MOVING,
TICK_MILLISECONDS,
LogicCharacterStats, CHARACTER_STATE_DEPLOY, CHARACTER_STATE_MOVING, TICK_MILLISECONDS,
};
pub use logic_summoner::{
build_free_tile_map, check_spell_position, find_position_for_spell, position_free,
roads_beneath, snap_coordinate, DeployBlocker, DEPLOY_CELL_UNITS,
};
pub use logic_tilemap::{LogicTilemap, OBJECT_KING_TOWER, OBJECT_PRINCESS_TOWER, SUBTILE_UNITS};
pub use logic_time::LogicTime;
pub use logic_tutorial_manager::LogicTutorialManager;
pub use verify::{verify_snapshot, SnapshotReport};
mod target_attribution {
use std::cell::{Cell, RefCell};
use std::collections::HashSet;
use std::sync::OnceLock;
thread_local! {
static TICK: Cell<i32> = const { Cell::new(0) };
}
pub fn set_tick(tick: i32) {
TICK.with(|t| t.set(tick));
}
pub fn tick() -> i32 {
TICK.with(|t| t.get())
}
pub fn enabled() -> bool {
static ON: OnceLock<bool> = OnceLock::new();
*ON.get_or_init(|| std::env::var("SCROLL_TRACE_TICK").is_ok())
}
thread_local! {
static REPORTED: RefCell<HashSet<(i32, i32, &'static str)>> =
RefCell::new(HashSet::new());
}
pub fn first_time(object: (i32, i32), site: &'static str) -> bool {
REPORTED.with(|seen| seen.borrow_mut().insert((object.0, object.1, site)))
}
}
pub use target_attribution::{
enabled as target_attribution_enabled, first_time as attribution_first_time,
set_tick as set_attribution_tick, tick as attribution_tick,
};

View file

@ -1,9 +1,8 @@
use titan::{ByteStreamReader, Result};
use crate::battle::logic_game_mode::{SECTION_BATTLE, SECTION_TUTORIAL};
use crate::data::{table, LogicDataRef, LogicDataTables};
use crate::model::DECK_SLOT_COUNT;
use titan::{ByteStreamReader, Result};
pub const BUFF_ARRAY_TABLE: i32 = table::DAMAGE_TYPES;
pub const MOVEMENT_SPEED_COLUMN: &str = "Speed";
pub const MOVEMENT_TAIL_VINTS: usize = 17;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotReport {
@ -68,12 +67,18 @@ impl<'a, 'b> Verifier<'a, 'b> {
self.reader.read_vint()?;
self.vints(2)?;
self.vints(12)?;
let row = data.data();
let mana_limit = row.map(|r| r.int("ManaGenerateLimit")).unwrap_or(0);
let character = data.as_character();
let mana_limit = character
.as_ref()
.map(|character| character.mana_generate_limit())
.unwrap_or(0);
if mana_limit >= 1 {
self.reader.read_vint()?;
}
let reload = row.map(|r| r.int("ReloadAfterHits")).unwrap_or(0);
let reload = character
.as_ref()
.map(|character| character.reload_after_hits())
.unwrap_or(0);
if reload >= 1 {
self.vints(2)?;
}
@ -93,6 +98,23 @@ impl<'a, 'b> Verifier<'a, 'b> {
self.vints(4)?;
Ok(())
}
fn projectile(&mut self) -> Result<()> {
self.reader.read_boolean()?;
self.vints(2)?;
self.vints(2)?;
self.reader.read_vint()?;
self.vints(2)?;
self.vints(2)?;
self.vints(2)?;
self.global_id()?;
self.global_id()?;
self.data_ref()?;
self.data_ref()?;
self.vints(3)?;
let hits = self.reader.read_vint()?.max(0) as usize;
self.vints(hits * 2)?;
Ok(())
}
fn movement(&mut self) -> Result<()> {
for _ in 0..4 {
self.reader.read_boolean()?;
@ -219,7 +241,9 @@ impl<'a, 'b> Verifier<'a, 'b> {
for entry in &data {
let is_summoner = entry.global_id().is_some() && entry.global_id() == summoner;
let entry = entry.clone();
if is_summoner {
if entry.as_projectile().is_some() {
self.projectile()?;
} else if is_summoner {
self.summoner(&entry)?;
} else {
self.character(&entry)?;
@ -232,9 +256,12 @@ impl<'a, 'b> Verifier<'a, 'b> {
self.mark("components");
for pass in 0..4 {
for entry in &data {
if entry.as_projectile().is_some() {
continue;
}
let moves = entry
.data()
.map(|row| row.int(MOVEMENT_SPEED_COLUMN) > 0)
.as_character()
.map(|character| character.speed() > 0)
.unwrap_or(false);
match pass {
0 => self.combat()?,
@ -281,8 +308,11 @@ impl<'a, 'b> Verifier<'a, 'b> {
self.reader.read_vint()?;
self.mark("commands");
let commands = self.reader.read_vint()?;
if commands != 0 {
return Err(titan::Error::Unsupported("commands are not expected yet"));
if !(0..=32).contains(&commands) {
return Err(titan::Error::Unsupported("implausible command count"));
}
for _ in 0..commands {
crate::commands::LogicCommandManager::decode_command(self.reader)?;
}
self.mark("end");
Ok(())

View file

@ -1,13 +1,14 @@
use std::fmt;
use std::sync::Arc;
use titan::{ByteStreamReader, ByteStreamWriter, GlobalId, Payload, Result};
use crate::data::logic_data::LogicData;
use crate::data::logic_data_tables::LogicDataTables;
use crate::data::tables::table;
use crate::data::typed::{
LogicArenaData, LogicRarityData, LogicResourceData, LogicResourcePackData, LogicSpellData,
LogicArenaData, LogicCharacterData, LogicLocationData, LogicNpcData, LogicProjectileData,
LogicRarityData, LogicResourceData, LogicResourcePackData, LogicSpellData,
LogicTreasureChestData,
};
use std::fmt;
use std::sync::Arc;
use titan::{ByteStreamReader, ByteStreamWriter, GlobalId, Payload, Result};
#[derive(Clone, Default)]
pub enum LogicDataRef {
#[default]
@ -121,6 +122,18 @@ impl LogicDataRef {
pub fn as_resource_pack(&self) -> Option<LogicResourcePackData> {
self.typed(table::RESOURCE_PACKS, LogicResourcePackData::new)
}
pub fn as_character(&self) -> Option<LogicCharacterData> {
self.typed(table::CHARACTERS_COMBINED, LogicCharacterData::new)
}
pub fn as_location(&self) -> Option<LogicLocationData> {
self.typed(table::LOCATIONS, LogicLocationData::new)
}
pub fn as_npc(&self) -> Option<LogicNpcData> {
self.typed(table::NPCS, LogicNpcData::new)
}
pub fn as_projectile(&self) -> Option<LogicProjectileData> {
self.typed(table::PROJECTILES, LogicProjectileData::new)
}
}
impl PartialEq for LogicDataRef {
fn eq(&self, other: &Self) -> bool {

View file

@ -1,3 +1,4 @@
use crate::data::scaled::{level_scale, ScaleKind};
use std::sync::Arc;
use titan::{CsvRow, CsvTable, GlobalId};
#[derive(Debug, Clone)]
@ -59,6 +60,30 @@ impl LogicData {
_ => 0,
}
}
pub fn scaled_value(&self, column: &str, level: i32, kind: ScaleKind) -> i32 {
let (Some(row), Some(index)) = (self.row(), self.column(column)) else {
return 0;
};
let count = row.value_count(index);
if count == 0 {
return 0;
}
if count == 1 {
return row
.int(index)
.wrapping_mul(level_scale(kind.percent(), level))
/ 100;
}
let index_at = if count < 8 {
level.min(count as i32 - 1)
} else {
level
};
if index_at < 0 {
return 0;
}
row.int_at(index, index_at as usize)
}
pub fn string_at(&self, column: &str, index: usize) -> &str {
match (self.row(), self.column(column)) {
(Some(row), Some(column)) => row.string_at(column, index),

View file

@ -1,7 +1,7 @@
use crate::data::logic_data::LogicData;
use std::collections::HashMap;
use std::sync::Arc;
use titan::CsvTable;
use crate::data::logic_data::LogicData;
#[derive(Debug, Clone)]
pub struct LogicDataTable {
table_index: i32,

View file

@ -1,10 +1,10 @@
use crate::data::logic_data::LogicData;
use crate::data::logic_data_table::LogicDataTable;
use crate::data::tables::table;
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, OnceLock, PoisonError, RwLock};
use titan::{CsvError, CsvReader, GlobalId};
use crate::data::logic_data::LogicData;
use crate::data::logic_data_table::LogicDataTable;
use crate::data::tables::table;
pub const TABLE_COUNT: usize = 62;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LogicDataTableResource {

View file

@ -3,6 +3,7 @@ mod globals;
mod logic_data;
mod logic_data_table;
mod logic_data_tables;
mod scaled;
mod tables;
mod typed;
pub use data_ref::LogicDataRef;
@ -12,8 +13,10 @@ pub use logic_data_table::LogicDataTable;
pub use logic_data_tables::{
DataError, LogicDataTableResource, LogicDataTables, DATA_TABLE_RESOURCES, TABLE_COUNT,
};
pub use scaled::{level_scale, ScaleKind};
pub use tables::{table, DataId, RESOURCE_DIAMONDS, RESOURCE_FREE_GOLD, RESOURCE_GOLD};
pub use typed::{
arena_by_index, LogicArenaData, LogicRarityData, LogicResourceData, LogicResourcePackData,
LogicSpellData, LogicTreasureChestData,
arena_by_index, LogicArenaData, LogicCharacterData, LogicLocationData, LogicNpcData,
LogicProjectileData, LogicRarityData, LogicResourceData, LogicResourcePackData, LogicSpellData,
LogicTreasureChestData,
};

View file

@ -0,0 +1,63 @@
use crate::data::globals::LogicGlobals;
pub const DAMAGE_PER_SPELL_LEVEL: &str = "DAMAGE_INCREASE_PERCENT_PER_SPELL_LEVEL";
pub const HITPOINT_PER_SPELL_LEVEL: &str = "HITPOINT_INCREASE_PERCENT_PER_SPELL_LEVEL";
pub const DAMAGE_PER_KING_LEVEL: &str = "DAMAGE_INCREASE_PERCENT_PER_KING_LEVEL";
pub const HITPOINT_PER_KING_LEVEL: &str = "HITPOINT_INCREASE_PERCENT_PER_KING_LEVEL";
pub const DAMAGE_PER_TOWER_LEVEL: &str = "DAMAGE_INCREASE_PERCENT_PER_TOWER_LEVEL";
pub const HITPOINT_PER_TOWER_LEVEL: &str = "HITPOINT_INCREASE_PERCENT_PER_TOWER_LEVEL";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScaleKind {
None,
SpellDamage,
SpellHitpoints,
KingDamage,
KingHitpoints,
TowerDamage,
TowerHitpoints,
}
impl ScaleKind {
pub fn percent(self) -> i32 {
match self {
ScaleKind::None => 0,
ScaleKind::SpellDamage => LogicGlobals::number(DAMAGE_PER_SPELL_LEVEL),
ScaleKind::SpellHitpoints => LogicGlobals::number(HITPOINT_PER_SPELL_LEVEL),
ScaleKind::KingDamage => LogicGlobals::number(DAMAGE_PER_KING_LEVEL),
ScaleKind::KingHitpoints => LogicGlobals::number(HITPOINT_PER_KING_LEVEL),
ScaleKind::TowerDamage => LogicGlobals::number(DAMAGE_PER_TOWER_LEVEL),
ScaleKind::TowerHitpoints => LogicGlobals::number(HITPOINT_PER_TOWER_LEVEL),
}
}
}
pub fn level_scale(percent: i32, level: i32) -> i32 {
let mut scale: i32 = 100;
if level < 1 {
return scale;
}
let step = percent.wrapping_add(100);
for _ in 0..level {
scale = if scale >= 100_000 {
(scale / 100).wrapping_mul(step)
} else {
scale.wrapping_mul(step) / 100
};
}
scale
}
#[cfg(test)]
mod tests {
use super::level_scale;
#[test]
fn the_ten_percent_ladder_matches_the_measured_damage() {
assert_eq!(level_scale(10, 0), 100);
assert_eq!(level_scale(10, 1), 110);
assert_eq!(level_scale(10, 2), 121);
assert_eq!(level_scale(10, 6), 176);
assert_eq!(24 * level_scale(10, 6) / 100, 42);
assert_eq!(24 * level_scale(10, 2) / 100, 29);
}
#[test]
fn a_level_below_one_never_enters_the_loop() {
assert_eq!(level_scale(10, -3), 100);
assert_eq!(level_scale(9, 0), 100);
}
}

View file

@ -1,6 +1,7 @@
use std::sync::Arc;
use crate::data::data_ref::LogicDataRef;
use crate::data::logic_data::LogicData;
use crate::data::tables::table;
use std::sync::Arc;
macro_rules! typed_data {
($name:ident) => {
#[derive(Debug, Clone)]
@ -27,6 +28,9 @@ macro_rules! typed_data {
pub fn int_at(&self, column: &str, level: usize) -> i32 {
self.0.int_at(column, level)
}
pub fn scaled(&self, column: &str, level: i32, kind: crate::data::ScaleKind) -> i32 {
self.0.scaled_value(column, level, kind)
}
pub fn string(&self, column: &str) -> &str {
self.0.string(column)
}
@ -42,6 +46,242 @@ typed_data!(LogicResourceData);
typed_data!(LogicTreasureChestData);
typed_data!(LogicRarityData);
typed_data!(LogicResourcePackData);
typed_data!(LogicCharacterData);
typed_data!(LogicLocationData);
typed_data!(LogicNpcData);
fn tower_projectile(tower: &str) -> String {
LogicDataRef::by_name(crate::data::tables::table::BUILDINGS, tower)
.as_character()
.map(|data| data.projectile().to_owned())
.unwrap_or_default()
}
typed_data!(LogicProjectileData);
impl LogicProjectileData {
pub fn speed(&self) -> i32 {
self.int("Speed")
}
pub fn damage(&self, level: usize) -> i32 {
self.scaled("Damage", level as i32, self.damage_kind())
}
fn damage_kind(&self) -> crate::data::ScaleKind {
let name = self.name();
if name == tower_projectile("KingTower") {
crate::data::ScaleKind::KingDamage
} else if name == tower_projectile("PrincessTower") {
crate::data::ScaleKind::TowerDamage
} else {
crate::data::ScaleKind::SpellDamage
}
}
pub fn radius(&self) -> i32 {
self.int("Radius")
}
pub fn is_homing(&self) -> bool {
self.boolean("Homing")
}
pub fn pushback(&self) -> i32 {
self.int("PushBack")
}
pub fn damage_type(&self) -> i32 {
self.int("DamageType")
}
pub fn has_reduced_tower_damage(&self) -> bool {
self.boolean("ReducedTowerDamage")
}
pub fn uses_pending_physical_damage(&self) -> bool {
self.is_homing() && self.radius() < 1 && self.damage_type() == 0
}
pub fn damage_effect(&self) -> LogicDataRef {
let named = self.string("DamageType");
let mut damage_type = LogicDataRef::by_name(table::DAMAGE_TYPES, named);
if damage_type.is_none() {
damage_type = LogicDataRef::by_name(table::DAMAGE_TYPES, "Physical");
}
let effect = damage_type
.data()
.map(|row| row.string("DamageEffect").to_string())
.unwrap_or_default();
LogicDataRef::by_name(table::EFFECTS, &effect)
}
}
impl LogicCharacterData {
pub fn damage_effect(&self) -> LogicDataRef {
LogicDataRef::by_name(table::EFFECTS, self.string("DamageEffect"))
}
pub fn speed(&self) -> i32 {
self.int("Speed")
}
pub fn sight_range(&self) -> i32 {
self.int("SightRange")
}
pub fn range(&self) -> i32 {
self.int("Range")
}
pub fn collision_radius(&self) -> i32 {
self.int("CollisionRadius")
}
pub fn is_building(&self) -> bool {
self.speed() == 0
}
pub fn tile_size_override(&self) -> i32 {
self.int("TileSizeOverride")
}
pub fn size_in_tiles(&self) -> i32 {
let override_size = self.tile_size_override();
if override_size > 0 {
return override_size;
}
(self.collision_radius() + 499) / 500 + 1
}
pub fn no_deploy_size_w(&self) -> i32 {
self.int("NoDeploySizeW")
}
pub fn no_deploy_size_h(&self) -> i32 {
self.int("NoDeploySizeH")
}
pub fn hit_speed(&self) -> i32 {
self.int("HitSpeed")
}
pub fn load_time(&self) -> i32 {
self.int("LoadTime")
}
pub fn force_attack_animation_to_end(&self) -> bool {
self.boolean("ForceAttackAnimationToEnd")
}
pub fn mass(&self) -> i32 {
self.int("Mass")
}
pub fn stop_movement_after_ms(&self) -> i32 {
self.int("StopMovementAfterMS")
}
pub fn wait_ms(&self) -> i32 {
self.int("WaitMS")
}
pub fn flying_height(&self) -> i32 {
self.int("FlyingHeight")
}
pub fn is_flying(&self) -> bool {
self.flying_height() > 0
}
pub fn jump_enabled(&self) -> bool {
self.boolean("JumpEnabled")
}
pub fn deploy_time(&self) -> i32 {
self.int("DeployTime")
}
pub fn special_attack_interval(&self) -> i32 {
self.int("SpecialAttackInterval")
}
pub fn mana_generate_limit(&self) -> i32 {
self.int("ManaGenerateLimit")
}
pub fn reload_after_hits(&self) -> i32 {
self.int("ReloadAfterHits")
}
pub fn attacks_air(&self) -> bool {
self.boolean("AttacksAir")
}
pub fn attacks_ground(&self) -> bool {
self.boolean("AttacksGround")
}
pub fn target_only_buildings(&self) -> bool {
self.boolean("TargetOnlyBuildings")
}
pub fn projectile(&self) -> &str {
self.string("Projectile")
}
pub fn spawn_character(&self) -> &str {
self.string("SpawnCharacter")
}
pub fn spawn_character_level_index(&self) -> i32 {
self.int("SpawnCharacterLevelIndex")
}
pub fn spawn_number(&self) -> i32 {
self.int("SpawnNumber")
}
pub fn spawn_interval(&self) -> i32 {
self.int("SpawnInterval")
}
pub fn spawn_pause_time(&self) -> i32 {
self.int("SpawnPauseTime")
}
pub fn spawn_start_time(&self) -> i32 {
self.int("SpawnStartTime")
}
pub fn spawn_limit(&self) -> i32 {
self.int("SpawnLimit")
}
pub fn life_time(&self) -> i32 {
self.int("LifeTime")
}
pub fn projectile_start_radius(&self) -> i32 {
self.int("ProjectileStartRadius")
}
pub fn projectile_y_offset(&self) -> i32 {
self.int("ProjectileYOffset")
}
pub fn projectile_start_z(&self) -> i32 {
self.int("ProjectileStartZ")
}
pub fn hitpoints(&self, level: usize) -> i32 {
self.scaled("Hitpoints", level as i32, self.scale_kind(false))
}
fn scale_kind(&self, damage: bool) -> crate::data::ScaleKind {
use crate::data::ScaleKind;
if self.name() == "KingTower" {
if damage {
ScaleKind::KingDamage
} else {
ScaleKind::KingHitpoints
}
} else if self.boolean("IsSummonerTower") {
if damage {
ScaleKind::TowerDamage
} else {
ScaleKind::TowerHitpoints
}
} else if damage {
ScaleKind::SpellDamage
} else {
ScaleKind::SpellHitpoints
}
}
pub fn damage(&self, level: usize) -> i32 {
let projectile = self.projectile();
if projectile.is_empty() {
return self.scaled("Damage", level as i32, self.scale_kind(true));
}
LogicDataRef::by_name(crate::data::tables::table::PROJECTILES, projectile)
.data()
.map(|shot| shot.int_at("Damage", level))
.unwrap_or(0)
}
}
impl LogicLocationData {
pub fn match_length(&self) -> i32 {
self.int("MatchLength")
}
pub fn overtime_seconds(&self) -> i32 {
self.int("OvertimeSeconds")
}
pub fn file_name(&self) -> &str {
self.string("FileName")
}
}
impl LogicNpcData {
pub fn location(&self) -> &str {
self.string("Location")
}
pub fn mana_regen_ms(&self) -> i32 {
self.int("ManaRegenMs")
}
pub fn mana_regen_ms_end(&self) -> i32 {
self.int("ManaRegenMsEnd")
}
pub fn mana_regen_ms_overtime(&self) -> i32 {
self.int("ManaRegenMsOvertime")
}
}
impl LogicArenaData {
pub fn index(&self) -> i32 {
self.int("Arena")
@ -243,6 +483,12 @@ impl LogicSpellData {
pub fn mana_cost(&self) -> i32 {
self.int("ManaCost")
}
pub fn summon_character(&self) -> &str {
self.string("SummonCharacter")
}
pub fn summon_number(&self) -> i32 {
self.int("SummonNumber")
}
pub fn unlock_arena(&self) -> &str {
self.string("UnlockArena")
}
@ -272,6 +518,9 @@ impl LogicArenaData {
pub fn request_size(&self) -> i32 {
self.int("RequestSize")
}
pub fn pvp_location(&self) -> &str {
self.string("PvpLocation")
}
}
impl LogicResourceData {
pub fn cap(&self) -> i32 {

View file

@ -4,30 +4,31 @@ pub mod commands;
pub mod data;
pub mod factory;
pub mod home;
pub mod logic_math;
pub mod logic_random;
pub mod messages;
pub mod logic_math;
pub mod model;
pub use logic_math::{logic_cos_scaled, logic_sin, logic_sin_scaled, logic_sqrt, spawn_offset, SIN_TABLE, SQRT_TABLE};
pub use commands::{
chest_source, command_type, CommandMeta, CommandOutcome, Execute, LogicBuyCardCommand,
LogicBuyChestCommand, LogicBuyResourcePackCommand, LogicClaimAchievementRewardCommand,
LogicClaimRewardCommand, LogicCollectFreeChestCommand, LogicCollectMultiWinChestCommand,
LogicCommand, LogicCommandHeader, LogicCommandManager, LogicCompleteTutorialBattleCommand,
LogicDoSpellCommand, LogicFuseSpellsCommand,
LogicHelpOpenedCommand, LogicMoveSpellCommand, LogicPageOpenedCommand,
LogicRefreshAchievementsCommand, LogicReward, LogicShopOpenedCommand,
LogicDoSpellCommand, LogicFuseSpellsCommand, LogicHelpOpenedCommand, LogicMoveSpellCommand,
LogicPageOpenedCommand, LogicRefreshAchievementsCommand, LogicReward, LogicShopOpenedCommand,
LogicShopSeedChangedCommand, LogicSortCollectionCommand, LogicStartMatchmakeCommand,
LogicStartRewardClaimCommand, LogicSwapSpellsCommand, LogicUpdateLastShownLevelUpCommand,
};
pub use data::{
table, DataError, LogicArenaData, LogicData, LogicDataRef, LogicDataTable,
LogicDataTableResource, LogicDataTables, LogicGlobals, LogicRarityData, LogicResourceData,
LogicResourcePackData, LogicSpellData, LogicTreasureChestData, DATA_TABLE_RESOURCES,
TABLE_COUNT,
table, DataError, LogicArenaData, LogicCharacterData, LogicData, LogicDataRef, LogicDataTable,
LogicDataTableResource, LogicDataTables, LogicGlobals, LogicLocationData, LogicNpcData,
LogicProjectileData, LogicRarityData, LogicResourceData, LogicResourcePackData, LogicSpellData,
LogicTreasureChestData, DATA_TABLE_RESOURCES, TABLE_COUNT,
};
pub use factory::{scroll_message_registry, LogicScrollMessageFactory};
pub use home::{randomize_shop_items, LogicHomeMode};
pub use logic_math::{
logic_cos_scaled, logic_sin, logic_sin_scaled, logic_sqrt, spawn_offset, SIN_TABLE, SQRT_TABLE,
};
pub use logic_random::LogicRandom;
pub use messages::*;
pub use model::*;

View file

@ -1,20 +1,18 @@
pub const SQRT_TABLE: [i32; 256] = [
0, 16, 22, 27, 32, 35, 39, 42, 45, 48, 50, 53, 55, 57, 59, 61,
64, 65, 67, 69, 71, 73, 75, 76, 78, 80, 81, 83, 84, 86, 87, 89,
90, 91, 93, 94, 96, 97, 98, 99, 101, 102, 103, 104, 106, 107, 108, 109,
110, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126,
128, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142,
143, 144, 144, 145, 146, 147, 148, 149, 150, 150, 151, 152, 153, 154, 155, 155,
156, 157, 158, 159, 160, 160, 161, 162, 163, 163, 164, 165, 166, 167, 167, 168,
169, 170, 170, 171, 172, 173, 173, 174, 175, 176, 176, 177, 178, 178, 179, 180,
181, 181, 182, 183, 183, 184, 185, 185, 186, 187, 187, 188, 189, 189, 190, 191,
192, 192, 193, 193, 194, 195, 195, 196, 197, 197, 198, 199, 199, 200, 201, 201,
202, 203, 203, 204, 204, 205, 206, 206, 207, 208, 208, 209, 209, 210, 211, 211,
212, 212, 213, 214, 214, 215, 215, 216, 217, 217, 218, 218, 219, 219, 220, 221,
221, 222, 222, 223, 224, 224, 225, 225, 226, 226, 227, 227, 228, 229, 229, 230,
230, 231, 231, 232, 232, 233, 234, 234, 235, 235, 236, 236, 237, 237, 238, 238,
239, 240, 240, 241, 241, 242, 242, 243, 243, 244, 244, 245, 245, 246, 246, 247,
247, 248, 248, 249, 249, 250, 250, 251, 251, 252, 252, 253, 253, 254, 254, 255,
0, 16, 22, 27, 32, 35, 39, 42, 45, 48, 50, 53, 55, 57, 59, 61, 64, 65, 67, 69, 71, 73, 75, 76,
78, 80, 81, 83, 84, 86, 87, 89, 90, 91, 93, 94, 96, 97, 98, 99, 101, 102, 103, 104, 106, 107,
108, 109, 110, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 128,
128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 144, 145,
146, 147, 148, 149, 150, 150, 151, 152, 153, 154, 155, 155, 156, 157, 158, 159, 160, 160, 161,
162, 163, 163, 164, 165, 166, 167, 167, 168, 169, 170, 170, 171, 172, 173, 173, 174, 175, 176,
176, 177, 178, 178, 179, 180, 181, 181, 182, 183, 183, 184, 185, 185, 186, 187, 187, 188, 189,
189, 190, 191, 192, 192, 193, 193, 194, 195, 195, 196, 197, 197, 198, 199, 199, 200, 201, 201,
202, 203, 203, 204, 204, 205, 206, 206, 207, 208, 208, 209, 209, 210, 211, 211, 212, 212, 213,
214, 214, 215, 215, 216, 217, 217, 218, 218, 219, 219, 220, 221, 221, 222, 222, 223, 224, 224,
225, 225, 226, 226, 227, 227, 228, 229, 229, 230, 230, 231, 231, 232, 232, 233, 234, 234, 235,
235, 236, 236, 237, 237, 238, 238, 239, 240, 240, 241, 241, 242, 242, 243, 243, 244, 244, 245,
245, 246, 246, 247, 247, 248, 248, 249, 249, 250, 250, 251, 251, 252, 252, 253, 253, 254, 254,
255,
];
pub fn logic_sqrt(value: i32) -> i32 {
if value < 0x10000 {
@ -36,7 +34,11 @@ pub fn logic_sqrt(value: i32) -> i32 {
SQRT_TABLE[(value >> 8) as usize]
};
let next = seed + 1;
return if next.wrapping_mul(next) > value { seed } else { next };
return if next.wrapping_mul(next) > value {
seed
} else {
next
};
}
let refined = if value < 0x1000000 {
let seed = if value < 0x100000 {
@ -73,16 +75,11 @@ pub fn logic_sqrt(value: i32) -> i32 {
root - i32::from(root.wrapping_mul(root) > value)
}
pub const SIN_TABLE: [i32; 91] = [
0, 18, 36, 54, 71, 89, 107, 125, 143, 160,
178, 195, 213, 230, 248, 265, 282, 299, 316, 333,
350, 367, 384, 400, 416, 433, 449, 465, 481, 496,
512, 527, 543, 558, 573, 587, 602, 616, 630, 644,
658, 672, 685, 698, 711, 724, 737, 749, 761, 773,
784, 796, 807, 818, 828, 839, 849, 859, 868, 878,
887, 896, 904, 912, 920, 928, 935, 943, 949, 956,
962, 968, 974, 979, 984, 989, 994, 998, 1002, 1005,
1008, 1011, 1014, 1016, 1018, 1020, 1022, 1023, 1023, 1024,
1024,
0, 18, 36, 54, 71, 89, 107, 125, 143, 160, 178, 195, 213, 230, 248, 265, 282, 299, 316, 333,
350, 367, 384, 400, 416, 433, 449, 465, 481, 496, 512, 527, 543, 558, 573, 587, 602, 616, 630,
644, 658, 672, 685, 698, 711, 724, 737, 749, 761, 773, 784, 796, 807, 818, 828, 839, 849, 859,
868, 878, 887, 896, 904, 912, 920, 928, 935, 943, 949, 956, 962, 968, 974, 979, 984, 989, 994,
998, 1002, 1005, 1008, 1011, 1014, 1016, 1018, 1020, 1022, 1023, 1023, 1024, 1024,
];
pub fn logic_sin(deg: i32) -> i32 {
let mut v = deg % 360;
@ -104,13 +101,64 @@ pub fn logic_sin_scaled(deg: i32, scale: i32) -> i32 {
pub fn logic_cos_scaled(deg: i32, scale: i32) -> i32 {
logic_sin_scaled(deg + 90, scale)
}
pub const ATAN_TABLE: [i32; 129] = [
0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 8, 9, 9, 10, 10, 11, 11, 11, 12, 12,
13, 13, 14, 14, 14, 15, 15, 16, 16, 17, 17, 17, 18, 18, 19, 19, 19, 20, 20, 21, 21, 21, 22, 22,
22, 23, 23, 24, 24, 24, 25, 25, 25, 26, 26, 27, 27, 27, 28, 28, 28, 29, 29, 29, 30, 30, 30, 31,
31, 31, 32, 32, 32, 33, 33, 33, 34, 34, 34, 35, 35, 35, 35, 36, 36, 36, 37, 37, 37, 37, 38, 38,
38, 39, 39, 39, 39, 40, 40, 40, 40, 41, 41, 41, 41, 42, 42, 42, 42, 43, 43, 43, 43, 44, 44, 44,
44, 45, 45, 45,
];
pub fn get_angle(x: i32, y: i32) -> i32 {
if x == 0 && y == 0 {
return 0;
}
if x >= 1 && y >= 0 {
return if y >= x {
90 - ATAN_TABLE[((x << 7) / y) as usize]
} else {
ATAN_TABLE[((y << 7) / x) as usize]
};
}
let v3 = x.abs();
if x <= 0 && y >= 1 {
return if v3 >= y {
180 - ATAN_TABLE[((y << 7) / v3) as usize]
} else {
ATAN_TABLE[((v3 << 7) / y) as usize] + 90
};
}
let v4 = y.abs();
if x < 0 && y <= 0 {
if v4 < v3 {
return ATAN_TABLE[((v4 << 7) / v3) as usize] + 180;
}
if v4 != 0 {
return 270 - ATAN_TABLE[((v3 << 7) / v4) as usize];
}
return 0;
}
if v3 < v4 {
return ATAN_TABLE[((v3 << 7) / v4) as usize] + 270;
}
if v3 == 0 {
return 0;
}
let v5 = (360 - ATAN_TABLE[((v4 << 7) / v3) as usize]) % 360;
if v5 >= 0 {
v5
} else {
v5 + 360
}
}
pub fn spawn_offset(index: i32, count: i32, radius: i32, mirror: bool) -> (i32, i32) {
if count <= 1 {
return (0, 0);
}
let mut idx = index;
let mut n = count;
let mut base_angle = 90;
let mut ring_base = 180;
let mut angle_base = 90;
let mut divisor = 2;
let mut r = radius;
let use_ring = match count {
@ -118,12 +166,12 @@ pub fn spawn_offset(index: i32, count: i32, radius: i32, mirror: bool) -> (i32,
3 | 5 => true,
4 => {
n = 4;
base_angle = 45;
ring_base = 45;
true
}
7 => {
if index != 0 {
base_angle = 0;
ring_base = 0;
idx -= 1;
n = 6;
true
@ -132,7 +180,7 @@ pub fn spawn_offset(index: i32, count: i32, radius: i32, mirror: bool) -> (i32,
}
}
_ => {
base_angle = 0;
ring_base = 0;
count >= 3
}
};
@ -142,10 +190,12 @@ pub fn spawn_offset(index: i32, count: i32, radius: i32, mirror: bool) -> (i32,
r = (3 * idx % 7).wrapping_mul(r) / 6;
}
divisor = n;
angle_base = ring_base;
} else if count != 2 {
divisor = count;
angle_base = ring_base;
}
let angle = base_angle + 360 * idx / divisor.max(1) + 90;
let angle = angle_base + 360 * idx / divisor.max(1) + 90;
let dx = logic_cos_scaled(angle, r);
let dy = logic_sin_scaled(angle, r);
(dx, if mirror { -dy } else { dy })
@ -165,6 +215,20 @@ mod trig_tests {
assert_eq!(logic_sin_scaled(90, 1000), 1000);
}
#[test]
fn get_angle_matches_the_client_cardinals() {
assert_eq!(get_angle(0, 0), 0);
assert_eq!(get_angle(1, 0), 0);
assert_eq!(get_angle(0, 1), 90);
assert_eq!(get_angle(-1, 0), 180);
assert_eq!(get_angle(0, -1), 270);
assert_eq!(get_angle(1, 1), 45);
assert_eq!(get_angle(-1, 1), 135);
assert_eq!(get_angle(-1, -1), 225);
assert_eq!(get_angle(1, -1), 315);
assert_eq!(get_angle(700, 700), 45);
assert_eq!(get_angle(0, 5000), 90);
}
#[test]
fn a_single_unit_has_no_offset_and_pairs_split() {
assert_eq!(spawn_offset(0, 1, 300, false), (0, 0));
let a = spawn_offset(0, 2, 300, false);
@ -174,4 +238,26 @@ mod trig_tests {
assert_eq!(a.0, -b.0);
assert_ne!(a.0, 0);
}
#[test]
fn a_three_unit_card_starts_its_ring_on_the_y_axis() {
let ring: Vec<_> = (0..3).map(|i| spawn_offset(i, 3, 500, true)).collect();
assert_eq!(
ring[0].0, 0,
"the first of three is not on the y axis: {ring:?}"
);
assert!(
ring[0].1 > 0,
"the first of three points the wrong way: {ring:?}"
);
assert_eq!(ring[1].0, -ring[2].0, "the pair is not symmetric: {ring:?}");
assert_eq!(ring[1].1, ring[2].1, "the pair is not level: {ring:?}");
assert!(ring[1].1 < 0, "the pair is on the wrong side: {ring:?}");
for (dx, dy) in &ring {
let r = ((dx * dx + dy * dy) as f64).sqrt();
assert!((r - 577.0).abs() <= 1.0, "radius drifted: {ring:?}");
}
let pair = [spawn_offset(0, 2, 500, true), spawn_offset(1, 2, 500, true)];
assert_eq!(pair[0].1, 0);
assert_eq!(pair[0].0, -pair[1].0);
}
}

View file

@ -1,5 +1,5 @@
use titan::Message;
use crate::battle::LogicGameObjectRef;
use titan::Message;
pub const BATTLE_RESULT_WIN: i32 = 1;
pub const BATTLE_RESULT_LOSE: i32 = 2;
pub const BATTLE_RESULT_DRAW: i32 = 3;
@ -46,4 +46,25 @@ impl BattleResultMessage {
..Self::default()
}
}
#[allow(clippy::too_many_arguments)]
pub fn result(
result: i32,
own_stars: i32,
opponent_stars: i32,
score_change: i32,
gold_reward: i32,
exp_reward: i32,
full_update: Option<Vec<u8>>,
) -> Self {
Self {
result,
score_change,
gold_reward,
exp_reward,
own_stars,
opponent_stars,
full_update,
..Self::default()
}
}
}

View file

@ -7,11 +7,65 @@ pub struct SectorHeartbeatMessage {
}
impl SectorHeartbeatMessage {
pub fn new(server_turn: i32, checksum: i32) -> Self {
Self::build(server_turn, checksum, &[], None)
}
pub fn with_commands(server_turn: i32, checksum: i32, commands: &[Vec<u8>]) -> Self {
Self::build(server_turn, checksum, commands, None)
}
pub fn with_tick_data(
server_turn: i32,
checksum: i32,
commands: &[Vec<u8>],
tick_data: &[u8],
) -> Self {
Self::build(server_turn, checksum, commands, Some(tick_data))
}
fn build(
server_turn: i32,
checksum: i32,
commands: &[Vec<u8>],
tick_data: Option<&[u8]>,
) -> Self {
let mut writer = ByteStreamWriter::new();
writer.write_vint(server_turn);
writer.write_vint(checksum);
if !commands.is_empty() || tick_data.is_some() {
writer.write_vint(commands.len() as i32);
for command in commands {
writer.write_raw(command);
}
}
if let Some(tick_data) = tick_data {
writer.write_bytes(Some(tick_data));
}
Self {
body: writer.into_inner(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use titan::ByteStreamReader;
#[test]
fn an_empty_turn_keeps_the_two_vint_form() {
let message = SectorHeartbeatMessage::new(19, -1234);
let mut reader = ByteStreamReader::new(&message.body);
assert_eq!(reader.read_vint().unwrap(), 19);
assert_eq!(reader.read_vint().unwrap(), -1234);
assert!(reader.is_at_end());
}
#[test]
fn commands_follow_the_checksum_behind_a_count() {
let first = vec![7u8, 1, 2];
let second = vec![9u8, 3];
let message =
SectorHeartbeatMessage::with_commands(19, -1234, &[first.clone(), second.clone()]);
let mut reader = ByteStreamReader::new(&message.body);
assert_eq!(reader.read_vint().unwrap(), 19);
assert_eq!(reader.read_vint().unwrap(), -1234);
assert_eq!(reader.read_vint().unwrap(), 2);
let rest = &message.body[message.body.len() - first.len() - second.len()..];
assert_eq!(rest, [first, second].concat());
}
}

View file

@ -0,0 +1,42 @@
use logic::battle::{find_path, LogicTilemap};
fn client_path() -> Vec<i32> {
let mut path = vec![1755];
let mut node = 1720;
while node >= 1144 {
path.push(node);
node -= 36;
}
path.push(1107);
path
}
fn arena() -> LogicTilemap {
LogicTilemap::load(
std::path::Path::new("../../assets"),
"locations/goblin_arena.csv",
)
.expect("the goblin arena tilemap")
}
#[test]
fn we_reproduce_the_clients_route_node_for_node() {
let tm = arena();
let expected = client_path();
assert_eq!(expected.len(), 19);
for start in [(26, 29), (27, 29)] {
assert_eq!(
find_path(&tm, start, (27, 48), 2, false),
expected,
"start {start:?} should walk the client's route"
);
}
}
#[test]
fn the_route_bulges_because_the_lane_pinches_not_because_of_a_tie() {
let tm = arena();
assert_eq!(tm.lane_bits(27, 31), 0);
assert_eq!(tm.lane_bits(28, 31), 2);
let path = find_path(&tm, (27, 29), (27, 48), 2, false);
assert!(
!path.contains(&(31 * 36 + 27)),
"the route must not step on the off-lane tile (27,31)"
);
}

View file

@ -0,0 +1,88 @@
use logic::battle::{get_lane_id, spawn_lane_of, LogicTilemap};
fn arena() -> LogicTilemap {
LogicTilemap::load(
std::path::Path::new("../../assets"),
"locations/goblin_arena.csv",
)
.expect("the goblin arena tilemap")
}
#[test]
fn the_arena_is_36_by_64_subtiles() {
let tm = arena();
assert_eq!((tm.width(), tm.height()), (36, 64));
}
#[test]
fn the_map_rows_are_all_full_width() {
let tm = arena();
assert_eq!(tm.tiles.len(), 64);
for (y, row) in tm.tiles.iter().enumerate() {
assert_eq!(row.len(), 36, "map row {y} is ragged");
}
}
#[test]
fn the_arena_has_its_river_and_both_lanes() {
let tm = arena();
let tiles = || (0..tm.height()).flat_map(|y| (0..tm.width()).map(move |x| (x, y)));
assert_eq!(
tiles().filter(|(x, y)| tm.lane_bits(*x, *y) >= 1).count(),
692
);
assert_eq!(tiles().filter(|(x, y)| tm.is_water(*x, *y)).count(), 112);
}
#[test]
fn the_right_lane_pinches_to_two_columns_at_the_bridge() {
let tm = arena();
for y in [31, 32] {
assert_eq!(
tm.lane_bits(27, y),
0,
"({},{y}) is off-lane at the pinch",
27
);
assert_eq!(
tm.lane_bits(30, y),
0,
"({},{y}) is off-lane at the pinch",
30
);
assert_eq!(tm.lane_bits(28, y), 2);
assert_eq!(tm.lane_bits(29, y), 2);
}
for y in [30, 33, 34, 40, 47] {
for x in 27..31 {
assert_eq!(tm.lane_bits(x, y), 2, "({x},{y}) is the right lane");
}
}
for y in 30..34 {
for x in 24..27 {
assert!(tm.is_water(x, y), "({x},{y}) should be river");
}
assert!(tm.is_water(31, y), "(31,{y}) should be river");
}
}
#[test]
fn a_drop_by_the_right_bridge_takes_the_right_lane() {
let tm = arena();
assert_eq!(get_lane_id(&tm, 12500, 14500), 2);
assert_eq!(get_lane_id(&tm, 10000, 14500), 2);
assert_eq!(get_lane_id(&tm, 11000, 14500), 2);
}
#[test]
fn a_drop_by_the_left_bridge_takes_the_left_lane() {
let tm = arena();
for (x, y) in [(6923, 8500), (7788, 8999), (7788, 8001)] {
assert_eq!(get_lane_id(&tm, x, y), 1, "({x},{y}) is on the left");
}
}
#[test]
fn the_single_spawn_path_clamps_before_it_reads_the_lane() {
let tm = arena();
assert_eq!(
spawn_lane_of(&tm, -5000, 14500),
get_lane_id(&tm, 250, 14500)
);
assert_eq!(
spawn_lane_of(&tm, 999_999, 14500),
get_lane_id(&tm, 36 * 500 - 250, 14500)
);
}

View file

@ -1,3 +1,37 @@
use std::cell::RefCell;
thread_local! {
static TRACE: RefCell<Option<Vec<String>>> = const { RefCell::new(None) };
static SKIP_ONE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
pub fn checksum_trace_skip_one() {
SKIP_ONE.with(|s| s.set(true));
}
pub fn checksum_trace_start() {
TRACE.with(|t| *t.borrow_mut() = Some(Vec::new()));
}
pub fn checksum_trace_take() -> Vec<String> {
TRACE.with(|t| t.borrow_mut().take().unwrap_or_default())
}
pub fn checksum_trace_note(note: impl std::fmt::Display) {
TRACE.with(|t| {
if let Some(list) = t.borrow_mut().as_mut() {
list.push(format!("# {note}"));
}
});
}
pub fn checksum_trace_active() -> bool {
TRACE.with(|t| t.borrow().is_some())
}
fn trace_field(kind: &str, value: i32) {
if SKIP_ONE.with(|s| s.replace(false)) {
return;
}
TRACE.with(|t| {
if let Some(list) = t.borrow_mut().as_mut() {
list.push(format!("{kind}:{value}"));
}
});
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ChecksumEncoder {
state: u32,
@ -16,18 +50,23 @@ impl ChecksumEncoder {
self.state = self.state.rotate_right(31).wrapping_add(delta as u32);
}
pub fn write_boolean(&mut self, value: bool) {
trace_field("b", value as i32);
self.mix(if value { 13 } else { 7 });
}
pub fn write_byte(&mut self, value: i8) {
trace_field("y", value as i32);
self.mix(value as u8 as i32 + 11);
}
pub fn write_short(&mut self, value: i16) {
trace_field("s", value as i32);
self.mix(value as u16 as i32 + 19);
}
pub fn write_int(&mut self, value: i32) {
trace_field("I", value);
self.mix(value.wrapping_add(9));
}
pub fn write_vint(&mut self, value: i32) {
trace_field("i", value);
self.mix(value.wrapping_add(33));
}
pub fn write_long(&mut self, high: i32, low: i32) {
@ -36,11 +75,18 @@ impl ChecksumEncoder {
}
pub fn write_string(&mut self, length: Option<usize>) {
match length {
None => self.mix(27),
Some(length) => self.mix(length as i32 + 28),
None => {
trace_field("S", -1);
self.mix(27);
}
Some(length) => {
trace_field("S", length as i32);
self.mix(length as i32 + 28);
}
}
}
pub fn write_string_reference(&mut self, length: usize) {
trace_field("R", length as i32);
self.mix(length as i32 + 38);
}
}