class_name CombatantStateRecord extends RefCounted signal changed(state: CombatantStateRecord) const SCHEMA_VERSION := 1 const NO_NPC_ID := -1 var data: Dictionary func _init(record_data: Dictionary = {}) -> void: data = record_data.duplicate(true) static func _snap(value: float) -> float: return snappedf(value, 1.0 / 1024.0) static func create( combatant_id: StringName, kind: StringName, faction_id: StringName, display_name: String, position: Vector3, max_health: float, weapon_id: StringName, npc_id: int = NO_NPC_ID, hostile: bool = false ) -> CombatantStateRecord: var snapped_position := Vector3(_snap(position.x), _snap(position.y), _snap(position.z)) return ( CombatantStateRecord . new( { "schema_version": SCHEMA_VERSION, "combatant_id": String(combatant_id), "kind": String(kind), "faction_id": String(faction_id), "display_name": display_name, "npc_id": npc_id, "position": [snapped_position.x, snapped_position.y, snapped_position.z], "health": max_health, "max_health": max_health, "weapon_id": String(weapon_id), "alive": true, "hostile": hostile, "attack_cooldown": 0, } ) ) static func from_dictionary(record_data: Dictionary) -> CombatantStateRecord: if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: return null if not ( record_data . has_all( [ "combatant_id", "kind", "faction_id", "display_name", "npc_id", "position", "health", "max_health", "weapon_id", "alive", "hostile", "attack_cooldown", ] ) ): return null var combatant_id := String(record_data["combatant_id"]) var display_name := String(record_data["display_name"]) if combatant_id.is_empty() or display_name.is_empty(): return null if ( StringName(record_data["kind"]) not in [ SimulationIds.COMBATANT_KIND_NPC, SimulationIds.COMBATANT_KIND_RAIDER, SimulationIds.COMBATANT_KIND_WOLF, SimulationIds.COMBATANT_KIND_PLAYER, ] ): return null var saved_position = record_data["position"] if not saved_position is Array or saved_position.size() != 3: return null for component in saved_position: if not is_finite(float(component)): return null var health := float(record_data["health"]) var max_health := float(record_data["max_health"]) var npc_id := int(record_data["npc_id"]) if ( not is_finite(health) or not is_finite(max_health) or health < 0.0 or max_health <= 0.0 or health > max_health or npc_id < NO_NPC_ID or int(record_data["attack_cooldown"]) < 0 ): return null var normalized := record_data.duplicate(true) normalized["schema_version"] = SCHEMA_VERSION normalized["combatant_id"] = combatant_id normalized["kind"] = String(record_data["kind"]) normalized["faction_id"] = String(record_data["faction_id"]) normalized["display_name"] = display_name normalized["npc_id"] = npc_id normalized["position"] = [ _snap(float(saved_position[0])), _snap(float(saved_position[1])), _snap(float(saved_position[2])) ] normalized["health"] = health normalized["max_health"] = max_health normalized["weapon_id"] = String(record_data["weapon_id"]) normalized["alive"] = bool(record_data["alive"]) normalized["hostile"] = bool(record_data["hostile"]) normalized["attack_cooldown"] = int(record_data["attack_cooldown"]) return CombatantStateRecord.new(normalized) func get_combatant_id() -> StringName: return StringName(data["combatant_id"]) func get_kind() -> StringName: return StringName(data["kind"]) func get_faction_id() -> StringName: return StringName(data["faction_id"]) func get_display_name() -> String: return String(data["display_name"]) func get_npc_id() -> int: return int(data["npc_id"]) func get_position() -> Vector3: var saved_position: Array = data["position"] return Vector3(float(saved_position[0]), float(saved_position[1]), float(saved_position[2])) func get_health() -> float: return float(data["health"]) func get_max_health() -> float: return float(data["max_health"]) func get_weapon_id() -> StringName: return StringName(data["weapon_id"]) func is_alive() -> bool: return bool(data["alive"]) and get_health() > 0.0 func is_hostile() -> bool: return bool(data["hostile"]) func get_attack_cooldown() -> int: return int(data["attack_cooldown"]) func is_attack_ready() -> bool: return is_alive() and get_attack_cooldown() <= 0 func set_position(value: Vector3) -> void: if not value.is_finite() or value.is_equal_approx(get_position()): return data["position"] = [_snap(value.x), _snap(value.y), _snap(value.z)] changed.emit(self) func set_hostile(value: bool) -> void: if value == is_hostile(): return data["hostile"] = value changed.emit(self) func tick_cooldown() -> void: if get_attack_cooldown() > 0: data["attack_cooldown"] = get_attack_cooldown() - 1 func take_damage(amount: float) -> float: if not is_alive() or not is_finite(amount) or amount <= 0.0: return 0.0 var previous := get_health() data["health"] = maxf(previous - amount, 0.0) if get_health() <= 0.0: data["alive"] = false changed.emit(self) return previous - get_health() func mark_attacked() -> void: data["attack_cooldown"] = _weapon_cooldown_ticks() func _weapon_cooldown_ticks() -> int: var weapon := SimulationItems.get_weapon(get_weapon_id()) var seconds := weapon.attack_cooldown if weapon != null else 0.6 return maxi(int(ceil(seconds / 1.2)), 1) func to_dictionary() -> Dictionary: return data.duplicate(true)