saumwacht-godot/scripts/luneraria/view/lune_unit_view.gd
Martin Stickelbroeck 1b540f2a55 G3 (Zwischenstand): neues Ingame-HUD; Projekt wechselt nach Unity
Nicht abgenommen: Nach dem ersten Spieltest wurde entschieden, Champions of
Luneraria nach Unity 6 zu portieren. Dieser Stand ist die Referenz für das
HUD im Unity-Projekt (../luneraria-unity).

- HUD neu aus Komponenten mit eigenem Theme, Porträts aus den echten Modellen
  (einmal gerendert, zwischengespeichert), Lane-Übersicht mit deterministischer
  Bedrohung aus öffentlichem Zustand, Bossgrotte, nächste Welle, Ereignisse,
  Minimap mit zusammengefassten Armeen, Held/Items/Talente, Einheitendetails,
  Angriffschampions, Bereit-Sperre, UI-Größe, Team-Formen statt nur Farben
- Privatsphäre über LuneHudModel: fremdes Gold und fremde Entwürfe erscheinen nie
- Keine Regeländerung: Regeln 91/91, M2 73/73, Netz 43/43; verify_g3 715/715;
  verify_m1 38/38 und verify_m2 14/14 mit angepassten UI-Treibern
- Spieltest bis Welle 8 über die echte Oberfläche, Befunde in G3-HUD.md
- HELDEN-KONZEPT.md: vom Projektinhaber vorgegebene Helden (nicht umgesetzt)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:59:55 +02:00

234 lines
9.5 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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")
const Materials = preload("res://scripts/luneraria/view/lune_materials.gd")
## G2: every boss unit (grotto and deployed) shows the Schlundkrone; its veins and crystals
## take the colour of the team it fights for.
const BOSS_MODEL := "schlundkrone"
var unit: LuneUnit
## Ground the figure stands on (lane decks, terrain, grotto terrace); flat without it.
var terrain: LuneTerrain
## Optional world height (adds plaza and dais steps); preferred over the bare terrain.
var ground: Callable
var model: Node3D
var skeletal: bool = false
var _fill: MeshInstance3D
var _bars: Node3D
var _ring: MeshInstance3D
## G3: wide second ring in the owner's shape under a deployed boss (ownership marker).
var _owner_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
## Boss look (G2): dressed LN_* surfaces, vein energy that follows sleep, fight and death.
var _boss_surfaces: Array[GeometryInstance3D] = []
var _tag: Label3D
var _boss_energy: float = 1.0
var _sent_energy: float = -1.0
var _sent_flash: float = -1.0
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", ""))
if unit.kind == "boss" and AssetLibrary.has_model(BOSS_MODEL):
id = BOSS_MODEL
model = AssetLibrary.instantiate_fighter(id)
if model == null:
model = EnemyVisualsFallback.make(TEAM_COLORS[unit.team])
add_child(model)
if id == BOSS_MODEL:
_boss_surfaces = Materials.dress(model)
for surface: GeometryInstance3D in _boss_surfaces:
surface.set_instance_shader_parameter("tint", Materials.team_color(unit.team, "crystal"))
surface.set_instance_shader_parameter("seed", 1.7 + unit.team)
_boss_energy = .45 if unit.dormant else 1.0
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
# G3: team shape as well as colour – round ring for Blau, diamond for Rot, hexagon for the
# neutral grotto boss (the HUD and minimap use the same shapes).
torus.rings = [20, 4, 6][clampi(unit.team, 0, 2)]
torus.ring_segments = 4
_ring.mesh = torus
if unit.team == 1:
_ring.rotation.y = PI * .25
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)
if unit.kind == "boss" and unit.team <= 1:
_owner_ring = MeshInstance3D.new()
var outer := TorusMesh.new()
outer.inner_radius = 1.0 * scale_factor
outer.outer_radius = 1.12 * scale_factor
outer.rings = 20 if unit.team == 0 else 4
outer.ring_segments = 4
_owner_ring.mesh = outer
_owner_ring.material_override = _flat(TEAM_COLORS[unit.team], 3.0)
_owner_ring.scale = Vector3(1, .2, 1)
_owner_ring.position.y = .07
_owner_ring.rotation.y = PI * .25 if unit.team == 1 else 0.0
_owner_ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(_owner_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
# Fixed screen size under the perspective game camera (about 18 px at 1080p).
tag.pixel_size = .00034
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))]
elif unit.kind == "boss" and unit.team <= 1:
# G3: a deployed boss says whom it fights for; its crystals alone are close to Blau's armies.
title = "%s · für %s" % [title.to_upper(), "Blau" if unit.team == 0 else "Rot"]
tag.text = title
add_child(tag)
_tag = tag
_last_attack = unit.attack_serial
_last_ability = unit.ability_serial
position = Vector3(unit.position.x, _ground(unit.position), 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, _ground(at), at.y)
rotation.y = lerp_angle(rotation.y, unit.facing, minf(1.0, delta * 12.0))
var moved: float = Vector2(position.x - _last_visual.x, position.z - _last_visual.z).length()
_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)
if not _boss_surfaces.is_empty():
_sync_boss(delta)
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
if _owner_ring != null:
_owner_ring.visible = unit.alive
if _tag != null:
# Fallen skeletal figures stay in their death pose, but without their name.
_tag.visible = unit.alive
# Rigid figures have no death clip; skeletal ones stay down in their death pose.
if not skeletal:
visible = unit.alive
## Veins sleep while the grotto boss is dormant, burn while it fights, flash on hits and go
## out over its death clip.
func _sync_boss(delta: float) -> void:
var goal: float = 0.0 if not unit.alive else (.45 if unit.dormant else 1.0)
_boss_energy = move_toward(_boss_energy, goal, delta * (.6 if not unit.alive else 1.5))
var flash: float = clampf(unit.hit_flash * 2.2, 0.0, .45) if unit.alive else 0.0
if absf(_boss_energy - _sent_energy) > .01:
_sent_energy = _boss_energy
for surface: GeometryInstance3D in _boss_surfaces:
surface.set_instance_shader_parameter("energy", _boss_energy)
if absf(flash - _sent_flash) > .02:
_sent_flash = flash
for surface: GeometryInstance3D in _boss_surfaces:
surface.set_instance_shader_parameter("damage", flash)
## Where tower bolts and effects hit this figure (upper body, not the feet).
func aim_point() -> Vector3:
return global_position + Vector3(0, _height * .55, 0)
## Relative ground footprint for lock-on markers (bigger figures get bigger rings).
func footprint() -> float:
return clampf(float(unit.def.get("view_scale", 1.0)) if unit != null else 1.0, .8, 2.2)
func _ground(at: Vector2) -> float:
if ground.is_valid():
return float(ground.call(at)) + .03
return terrain.walk_height(at) + .03 if terrain != null else 0.0
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