class_name CombatUnit extends Node3D ## A disposable battle instance. Ownership remains in EconomyManager's roster. const MovementComponent = preload("res://scripts/combat/movement_component.gd") const TargetingComponent = preload("res://scripts/combat/targeting_component.gd") const AttackComponent = preload("res://scripts/combat/attack_component.gd") const AbilityComponent = preload("res://scripts/combat/ability_component.gd") const EnemyVisuals = preload("res://scripts/presentation/enemy_visuals.gd") const DAMAGE_MATRIX = [[1.30, 0.75, 1.00], [1.00, 1.30, 0.75], [0.75, 1.00, 1.30]] var uid: int = 0 var team_id: int = 0 var is_attacker: bool = false var alive: bool = true var hp: float = 1.0 var max_hp: float = 1.0 var data: Dictionary = {} var home_cell: Vector2i = Vector2i(-1, -1) var target_policy: int = 0 var target = null var shield: float = 0.0 var shield_remaining: float = INF var effects: Dictionary = {} var dots: Dictionary = {} var combat_time: float = 0.0 var wave_number: int = 1 var resolved: bool = false var leaked: bool = false var facing: float = PI var attack_flash: float = 0.0 var ability_flash: float = 0.0 var ability_pose_flash: float = 0.0 var movement_component = MovementComponent.new() var targeting_component = TargetingComponent.new() var attack_component = AttackComponent.new() var ability_component = AbilityComponent.new() var _body: Node3D var _hp_bar: MeshInstance3D var _shield_bar: MeshInstance3D var _selection: MeshInstance3D var _body_material: StandardMaterial3D var _base_color: Color var _shot: MeshInstance3D var _pulse: MeshInstance3D var _burn: MeshInstance3D var _shot_target: Vector3 var _previous_position: Vector3 var model_loaded: bool = false var visual_height: float = 1.6 var _model: Node3D var _bars: Node3D var hit_flash: float = 0.0 var _pose_attack: float = 0.0 func setup(definition: Dictionary, stable_id: int, lane: int, attacker: bool, cell: Vector2i = Vector2i(-1, -1), policy: int = 0, make_visuals: bool = true) -> void: data = definition.duplicate(true) uid = stable_id team_id = lane is_attacker = attacker home_cell = cell target_policy = policy max_hp = float(data.get("max_hp", 1.0)) hp = max_hp alive = true target = null shield = 0.0 shield_remaining = INF effects.clear() dots.clear() combat_time = 0.0 resolved = false leaked = false attack_component = AttackComponent.new() ability_component = AbilityComponent.new() for child in get_children(): child.free() _body = null _hp_bar = null _shield_bar = null _selection = null _body_material = null model_loaded = false _model = null _bars = null hit_flash = 0.0 _pose_attack = 0.0 attack_flash = 0.0 ability_flash = 0.0 ability_pose_flash = 0.0 if make_visuals: _make_visuals() ability_component.start(self) func distance_to_unit(other) -> float: return Vector2(position.x, position.z).distance_to(Vector2(other.position.x, other.position.z)) func receive_damage(raw: float, attack_type: int) -> void: if not alive: return var armor = clampi(int(data.get("armor_type", 0)), 0, 2) var value = maxf(0.0, raw) * float(DAMAGE_MATRIX[clampi(attack_type, 0, 2)][armor]) value *= 1.0 - damage_reduction() var absorbed = minf(shield, value) shield -= absorbed hp = maxf(0.0, hp - (value - absorbed)) if value > 0.0: hit_flash = .18 if hp <= 0.0: alive = false target = null func heal(amount: float) -> void: if alive: hp = minf(max_hp, hp + maxf(0.0, amount) * (1.0 - strongest_effect("healing_reduction"))) func grant_shield(amount: float, duration: float = INF) -> void: if alive and amount > shield: shield = amount shield_remaining = duration func apply_effect(family: String, kind: String, amount: float, duration: float) -> void: if not alive: return if effects.has(family): effects[family].amount = maxf(float(effects[family].amount), amount) effects[family].remaining = maxf(float(effects[family].remaining), duration) else: effects[family] = {"kind": kind, "amount": amount, "remaining": duration} func apply_dot(family: String, damage: float, duration: float, attack_type: int) -> void: if not alive: return if dots.has(family): var effect: Dictionary = dots[family] if damage > float(effect.damage): effect.damage = damage effect.attack_type = attack_type effect.remaining = maxf(float(effect.remaining), duration) else: dots[family] = {"damage": damage, "remaining": duration, "next_tick": 1.0, "attack_type": attack_type} func advance_status(delta: float) -> void: combat_time += delta attack_flash = maxf(0.0, attack_flash - delta) _pose_attack = maxf(0.0, _pose_attack - delta) hit_flash = maxf(0.0, hit_flash - delta) ability_flash = maxf(0.0, ability_flash - delta) ability_pose_flash = maxf(0.0, ability_pose_flash - delta) for family in effects.keys(): effects[family].remaining -= delta if float(effects[family].remaining) <= 0.000001: effects.erase(family) if shield_remaining != INF: shield_remaining -= delta if shield_remaining <= 0.000001: shield = 0.0 for family in dots.keys(): var effect: Dictionary = dots[family] effect.next_tick -= minf(delta, maxf(0.0, float(effect.remaining))) while float(effect.next_tick) <= 0.000001 and alive: receive_damage(float(effect.damage), int(effect.attack_type)) effect.next_tick += 1.0 effect.remaining -= delta if float(effect.remaining) <= 0.000001: dots.erase(family) func strongest_effect(kind: String) -> float: var amount = 0.0 for effect in effects.values(): if str(effect.kind) == kind: amount = maxf(amount, float(effect.amount)) return amount func damage_bonus() -> float: var result = 0.0 for effect in effects.values(): if str(effect.kind) == "damage": result += float(effect.amount) return minf(0.30, result) func damage_reduction() -> float: var result = strongest_effect("reduction") match str(data.get("ability", "")): "g03": if combat_time < 8.0 - 0.000001: result = maxf(result, 0.15) "g04": result = maxf(result, 0.25 if combat_time < 10.0 - 0.000001 else 0.10) return minf(0.35, result) func attack_speed_factor() -> float: var slowing = strongest_effect("attack_slow") * (0.5 if _is_boss() else 1.0) return 1.0 + clampf(strongest_effect("attack_speed") - slowing, -0.25, 0.25) func movement_factor() -> float: return 1.0 - minf(0.35, strongest_effect("movement_slow") * (0.5 if _is_boss() else 1.0)) func _is_boss() -> bool: return str(data.get("id", "")) in ["e07", "e08", "e09", "e10"] func set_selected(selected: bool) -> void: if is_instance_valid(_selection): _selection.visible = selected func play_ability() -> void: # A received heal shares the effect pulse, but never casts this unit's ability. ability_flash = 0.5 ability_pose_flash = 0.5 if model_loaded: Soundscape.play("ability", team_id, true) func play_attack(at: Vector3) -> void: attack_flash = 0.18 _pose_attack = .28 if model_loaded: var sound: String = "stone" if str(data.id) in ["g07", "g08"]: sound = "mortar" elif float(data.get("attack_range", 1.0)) > 1.5: sound = "bolt" if int(data.get("attack_type", 0)) == 1 else "ember" Soundscape.play(sound, team_id, true, .94 + (uid % 5) * .03) _shot_target = at + Vector3.UP * .75 var heading: Vector3 = at - position facing = atan2(heading.x, heading.z) func refresh_visuals(delta: float = 0.0) -> void: var skeletal: bool = is_instance_valid(_model) and _model.has_method("update_combat") visible = alive or (skeletal and not leaked) if not is_instance_valid(_body): return if skeletal: _body.rotation.y = facing var distance: float = position.distance_to(_previous_position) if delta > 0 else 0.0 var in_range: bool = alive and is_instance_valid(target) and distance_to_unit(target) <= float(data.attack_range)+.035 _model.update_combat(delta,distance,in_range,attack_component.progress,attack_component.hit_count,ability_pose_flash,hit_flash,alive) _previous_position = position _bars.visible = alive if not alive: _selection.hide() _shot.hide() _pulse.hide() _burn.hide() return if not alive: return _body.rotation.y = facing var moving: bool = position.distance_squared_to(_previous_position) > 0.00001 _body.position.y = sin(_pose_attack / .28 * PI) * .025 _body.rotation.x = -sin(_pose_attack / .28 * PI) * .05 if not skeletal and is_instance_valid(_model) and _model.has_method("pose"): _model.pose(combat_time + (uid % 17) * .23, moving, _pose_attack, ability_pose_flash, hit_flash) if moving and not skeletal: _body.position.y += absf(sin(combat_time * 10.0 + uid)) * .035 if skeletal: _body.position.y = 0 _body.rotation.x = 0 _previous_position = position _hp_bar.scale.x = maxf(0.001, hp / max_hp) _hp_bar.position.x = -0.44 * (1.0 - hp / max_hp) _shield_bar.visible = shield > 0.0 _shield_bar.scale.x = clampf(shield / max_hp, 0.001, 1.0) if _body_material != null: _body_material.emission_enabled = ability_flash > 0.0 _body_material.emission = _base_color * 0.5 _shot.visible = attack_flash > 0.0 and float(data.get("attack_range", 1.0)) > 1.5 if _shot.visible: var progress: float = 1.0 - attack_flash / .18 var offset: Vector3 = _shot_target - position _shot.position = Vector3(0, visual_height * .6, 0).lerp(offset, progress) if str(data.id) in ["g07", "g08"]: _shot.position.y += sin(progress * PI) * 1.1 _shot.scale = Vector3.ONE * (1.3 if str(data.id) == "g08" else 1.0) _pulse.visible = ability_flash > 0.0 if _pulse.visible: var radius: float = 1.0 + (1.0 - ability_flash / .5) * 3.0 _pulse.scale = Vector3(radius, 1.0, radius) (_pulse.material_override as StandardMaterial3D).albedo_color.a = ability_flash * 1.5 _burn.visible = dots.has("burn") or dots.has("seed_burn") if _burn.visible: (_burn.material_override as StandardMaterial3D).albedo_color = Color("a7d277") if dots.has("seed_burn") and not dots.has("burn") else Color("ffad50") _burn.position.y = visual_height * .65 + sin(combat_time * 16.0) * .12 func _make_visuals() -> void: _body = Node3D.new() _body.name = "VisualBody" add_child(_body) var id: String = str(data.get("id", "")) var model: Node3D = AssetLibrary.instantiate_fighter(id) if model == null and is_attacker: model = EnemyVisuals.create(data) if model != null: _body.add_child(model) model_loaded = true _model = model visual_height = model.get_aabb().end.y else: _make_fallback_body() visual_height = 2.3 if _is_boss() else 1.6 var ring := TorusMesh.new() ring.inner_radius = .48 ring.outer_radius = .58 ring.rings = 16 if team_id == 0 else 3 ring.ring_segments = 4 var team_color := Color("57d4c6") if team_id == 0 else Color("f18c84") if is_attacker: team_color = Color("d96d8a") _add_mesh(self, ring, _glow_material(team_color), Vector3(0, .055, 0)) var selection_mesh := TorusMesh.new() selection_mesh.inner_radius = .70 selection_mesh.outer_radius = .80 selection_mesh.rings = 32 selection_mesh.ring_segments = 4 _selection = _add_mesh(self, selection_mesh, _glow_material(Color("ffe6a0"), 2.4), Vector3(0,.075,0)) _selection.visible = false _bars = Node3D.new() _bars.name = "HealthBillboard" _bars.position.y = visual_height + .22 add_child(_bars) var bar_background := BoxMesh.new() bar_background.size = Vector3(.98,.10,.012) _add_mesh(_bars, bar_background, _bar_material(Color("0c141c")), Vector3.ZERO) var health_mesh := BoxMesh.new() health_mesh.size = Vector3(.92,.055,.015) _hp_bar = _add_mesh(_bars,health_mesh,_bar_material(Color("f07878") if is_attacker else Color("6ee0a8")),Vector3(0,0,.012)) _shield_bar = _add_mesh(_bars,health_mesh,_bar_material(Color("7ec8ff")),Vector3(0,.11,.012)) _shield_bar.visible = false var spark_mesh := SphereMesh.new() spark_mesh.radius = .10 spark_mesh.height = .20 spark_mesh.radial_segments = 6 spark_mesh.rings = 2 var nature: bool = id.begins_with("w") var ember := _material((Color("d99db8") if id in ["w07","w08"] else Color("a7d277")) if nature else (Color("ffad50") if not is_attacker else Color("eb8299"))) ember.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED _shot = _add_mesh(self,spark_mesh,ember,Vector3.ZERO) _shot.visible = false _burn = _add_mesh(self,spark_mesh,ember.duplicate(),Vector3.ZERO) _burn.scale = Vector3(1.5,3,1.5) _burn.visible = false var aura := TorusMesh.new() aura.inner_radius = .90 aura.outer_radius = 1.0 aura.rings = 32 aura.ring_segments = 4 var aura_material := _material(Color("78dcad") if nature else Color("ffcf7c")) aura_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED aura_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA _pulse = _add_mesh(self,aura,aura_material,Vector3(0,.08,0)) _pulse.visible = false if _is_boss(): var nameplate := Label3D.new() nameplate.text = str(data.display_name).to_upper() nameplate.font_size = 32 nameplate.pixel_size = .008 nameplate.position.y = visual_height + .60 nameplate.billboard = BaseMaterial3D.BILLBOARD_ENABLED nameplate.modulate = Color("ffb9a4") add_child(nameplate) refresh_visuals() func _make_fallback_body() -> void: var armor = int(data.get("armor_type", 0)) var upgraded = not is_attacker and str(data.get("id", "")).right(1) in ["2", "4", "6", "8", "0"] _base_color = Color("e6663d") if not is_attacker else Color("d7d8e0") if armor == 1: _base_color = Color("676273") if not is_attacker else Color("98a3b7") elif armor == 2: _base_color = Color("efbb68") if not is_attacker else Color("b49dde") _body_material = _material(_base_color) var body_mesh: Mesh if armor == 1: var box = BoxMesh.new() box.size = Vector3(0.65, 0.9, 0.58) body_mesh = box else: var cylinder = CylinderMesh.new() cylinder.top_radius = 0.22 if armor == 2 else 0.32 cylinder.bottom_radius = 0.40 cylinder.height = 0.90 cylinder.radial_segments = 5 if armor == 2 else 6 body_mesh = cylinder _add_mesh(_body, body_mesh, _body_material, Vector3(0, 0.70, 0)) var head = SphereMesh.new() head.radius = 0.23 head.height = 0.46 head.radial_segments = 8 head.rings = 4 _add_mesh(_body, head, _material(Color("ffe0a1") if not is_attacker else Color("535575")), Vector3(0, 1.31, 0)) var weapon = BoxMesh.new() weapon.size = Vector3(0.12, 0.14, 0.9) if float(data.get("attack_range", 1.0)) > 1.5 else Vector3(0.13, 0.95, 0.13) _add_mesh(_body, weapon, _material(Color("e6be77")), Vector3(0.43, 0.90, 0.12)) if upgraded or _is_boss(): var crest = CylinderMesh.new() crest.top_radius = 0.0 crest.bottom_radius = 0.29 crest.height = 0.42 crest.radial_segments = 4 _add_mesh(_body, crest, _material(Color("ffc861")), Vector3(0, 1.65, 0)) if _is_boss(): _body.scale = Vector3.ONE * 1.35 func _material(color: Color) -> StandardMaterial3D: var material = StandardMaterial3D.new() material.albedo_color = color material.roughness = 0.95 return material func _add_mesh(parent: Node3D, mesh: Mesh, material: StandardMaterial3D, at: Vector3) -> MeshInstance3D: var instance = MeshInstance3D.new() instance.mesh = mesh instance.material_override = material instance.position = at parent.add_child(instance) return instance func face_health_bars(camera_basis: Basis) -> void: if is_instance_valid(_bars): _bars.global_basis = camera_basis func _bar_material(color: Color) -> StandardMaterial3D: var mat: StandardMaterial3D = _material(color) mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED return mat func _glow_material(color: Color, energy: float = 1.6) -> StandardMaterial3D: var mat: StandardMaterial3D = _material(color) mat.emission_enabled = true mat.emission = color mat.emission_energy_multiplier = energy mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED return mat