saumwacht-godot/scripts/combat/unit.gd
Martin Stickelbroeck c2b447610c Baseline: Saumwacht 0.15.0 (Phase 20A) vor dem Umbau zu Champions of Luneraria
Unveränderter Ausgangsstand inklusive Masterprompt und Konzeptbildern
unter assets/. .godot/ und docs/screenshots/ bleiben laut .gitignore
unversioniert; letztere sind unter ../backups gesichert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:00:36 +02:00

433 lines
15 KiB
GDScript

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