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>
433 lines
15 KiB
GDScript
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
|