Drei Helden mit Nähe-/Teilnahme-XP, Talenten und drei Itemplätzen, drei Angriffschampions mit Buchung n -> n+1, Bossgrotte mit Streitfall, ruhendem Boss, idempotenter Eroberung, einmaliger Entsendung für das Siegerteam, hohem Kopfgeld und Sperre bis Sieg+4. HUD-Reiter, Porträt aus echtem Modell, Grotten-Asset, Bots für alle Systeme. Regeln M2 73/73, M1 91/91, App M2 15/15 und M1 38/38, Bossszenarien dokumentiert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
152 lines
5.6 KiB
GDScript
152 lines
5.6 KiB
GDScript
extends Node3D
|
|
## Visual twin of one LuneUnit. Reads the authoritative state and drives the existing
|
|
## figure animations from real combat events; it never writes back into the simulation.
|
|
|
|
const TEAM_COLORS: Array[Color] = [Color("5aa8ff"), Color("ff6a55"), Color("b37cff")]
|
|
const HERO_GOLD := Color("f2c96b")
|
|
|
|
var unit: LuneUnit
|
|
var model: Node3D
|
|
var skeletal: bool = false
|
|
var _fill: MeshInstance3D
|
|
var _bars: Node3D
|
|
var _ring: MeshInstance3D
|
|
var _last_attack: int = 0
|
|
var _last_ability: int = 0
|
|
var _attack_flash: float = 0.0
|
|
var _ability_flash: float = 0.0
|
|
var _time: float = 0.0
|
|
var _last_visual: Vector3
|
|
var _height: float = 1.6
|
|
|
|
static func _flat(color: Color, energy: float = 0.0) -> StandardMaterial3D:
|
|
var material := StandardMaterial3D.new()
|
|
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
material.albedo_color = color
|
|
if energy > 0.0:
|
|
material.emission_enabled = true
|
|
material.emission = color
|
|
material.emission_energy_multiplier = energy
|
|
return material
|
|
|
|
func bind(source: LuneUnit) -> void:
|
|
unit = source
|
|
name = "Unit_%d" % unit.id
|
|
var id: String = str(unit.def.get("id", ""))
|
|
model = AssetLibrary.instantiate_fighter(id)
|
|
if model == null:
|
|
model = EnemyVisualsFallback.make(TEAM_COLORS[unit.team])
|
|
add_child(model)
|
|
skeletal = model.has_method("update_combat")
|
|
if model.has_method("get_aabb"):
|
|
_height = maxf(1.0, model.get_aabb().end.y)
|
|
var scale_factor: float = float(unit.def.get("view_scale", 1.0))
|
|
model.scale = Vector3.ONE * scale_factor
|
|
_height *= scale_factor
|
|
_ring = MeshInstance3D.new()
|
|
var torus := TorusMesh.new()
|
|
torus.inner_radius = .42 * scale_factor
|
|
torus.outer_radius = .52 * scale_factor
|
|
torus.rings = 20
|
|
torus.ring_segments = 4
|
|
_ring.mesh = torus
|
|
var ring_color: Color = HERO_GOLD if unit.kind == "hero" else TEAM_COLORS[unit.team]
|
|
_ring.material_override = _flat(ring_color, 2.2 if unit.kind in ["hero", "boss", "champion"] else (1.4 if unit.kind != "monster" else .6))
|
|
_ring.scale = Vector3(1, .25, 1)
|
|
_ring.position.y = .05
|
|
_ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
|
add_child(_ring)
|
|
_bars = Node3D.new()
|
|
_bars.position.y = _height + .25
|
|
add_child(_bars)
|
|
var back := MeshInstance3D.new()
|
|
var quad := QuadMesh.new()
|
|
quad.size = Vector2(.9, .1)
|
|
back.mesh = quad
|
|
var back_material := _flat(Color(0, 0, 0, .7))
|
|
back_material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
|
|
back_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
back_material.no_depth_test = true
|
|
back.material_override = back_material
|
|
back.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
|
_bars.add_child(back)
|
|
_fill = MeshInstance3D.new()
|
|
var fill_quad := QuadMesh.new()
|
|
fill_quad.size = Vector2(.86, .07)
|
|
fill_quad.center_offset = Vector3(.43, 0, 0)
|
|
_fill.mesh = fill_quad
|
|
var fill_material := _flat(TEAM_COLORS[unit.team].lightened(.15))
|
|
fill_material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
|
|
fill_material.billboard_keep_scale = true
|
|
fill_material.no_depth_test = true
|
|
fill_material.render_priority = 1
|
|
_fill.material_override = fill_material
|
|
_fill.position = Vector3(-.43, 0, 0)
|
|
_fill.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
|
_bars.add_child(_fill)
|
|
if unit.kind in ["hero", "champion", "boss"]:
|
|
# Named units carry a readable tag: heroes with level, champions with role, the boss.
|
|
var tag := Label3D.new()
|
|
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
|
tag.no_depth_test = true
|
|
tag.fixed_size = true
|
|
tag.pixel_size = .0011
|
|
tag.font_size = 30
|
|
tag.outline_size = 10
|
|
tag.modulate = HERO_GOLD if unit.kind == "hero" else TEAM_COLORS[unit.team].lightened(.3)
|
|
tag.position.y = _height + .55
|
|
var title: String = str(unit.def.get("display_name", ""))
|
|
if unit.kind == "hero":
|
|
title = "%s · Stufe %d" % [title, int(unit.def.get("level", 1))]
|
|
tag.text = title
|
|
add_child(tag)
|
|
_last_attack = unit.attack_serial
|
|
_last_ability = unit.ability_serial
|
|
position = Vector3(unit.position.x, 0, unit.position.y)
|
|
rotation.y = unit.facing
|
|
_last_visual = position
|
|
|
|
func sync(alpha: float, delta: float) -> void:
|
|
if unit == null:
|
|
return
|
|
_time += delta
|
|
var at: Vector2 = unit.previous_position.lerp(unit.position, clampf(alpha, 0.0, 1.0))
|
|
position = Vector3(at.x, 0, at.y)
|
|
rotation.y = lerp_angle(rotation.y, unit.facing, minf(1.0, delta * 12.0))
|
|
var moved: float = position.distance_to(_last_visual)
|
|
_last_visual = position
|
|
if unit.attack_serial != _last_attack:
|
|
_last_attack = unit.attack_serial
|
|
_attack_flash = .28
|
|
if unit.ability_serial != _last_ability:
|
|
_last_ability = unit.ability_serial
|
|
_ability_flash = .5
|
|
_attack_flash = maxf(0.0, _attack_flash - delta)
|
|
_ability_flash = maxf(0.0, _ability_flash - delta)
|
|
if skeletal:
|
|
model.rotation.y = 0.0
|
|
model.update_combat(delta, moved, unit.in_range and unit.alive, unit.attack_progress, unit.hit_count, _ability_flash, unit.hit_flash, unit.alive)
|
|
elif model.has_method("pose"):
|
|
model.pose(_time + float(unit.id % 17) * .23, moved > .002, _attack_flash, _ability_flash, unit.hit_flash)
|
|
var fraction: float = clampf(unit.hp / maxf(1.0, unit.max_hp), 0.0, 1.0)
|
|
_fill.scale.x = maxf(.001, fraction)
|
|
_bars.visible = unit.alive and fraction < .999
|
|
_ring.visible = unit.alive
|
|
# Rigid figures have no death clip; skeletal ones stay down in their death pose.
|
|
if not skeletal:
|
|
visible = unit.alive
|
|
|
|
class EnemyVisualsFallback:
|
|
static func make(color: Color) -> Node3D:
|
|
var body := MeshInstance3D.new()
|
|
var capsule := CapsuleMesh.new()
|
|
capsule.radius = .35
|
|
capsule.height = 1.4
|
|
body.mesh = capsule
|
|
body.position.y = .7
|
|
var material := StandardMaterial3D.new()
|
|
material.albedo_color = color
|
|
body.material_override = material
|
|
var root := Node3D.new()
|
|
root.add_child(body)
|
|
return root
|