saumwacht-godot/scripts/luneraria/view/lune_unit_view.gd
Martin Stickelbroeck 9f62e30377 G2: Ancients, Türme, Bossgrotte und Superboss; macOS- und Windows-ARM64-Pakete
Mondherz-Ancients (Saumkern-Kristall als Herz, Sichelklingen, Ringe, Krone,
Teamwappen, Energieadern) mit den Stufen stabil/instabil/kritisch, kurzem
Zusammenbruch und bleibender Ruine; der Sieg wartet nie auf die Grafik.
Turmfamilie aus einem Blender-Generator: Grenzwacht (Tower 1), Bastion
(Tower 2), Kernwacht (Basisturm in der Formensprache des Ancients), mit
Schadensstufen, Einsturz, Ruine und sichtbarem Angriff (Zielring, Aufladen,
Bolzen, Einschlag) - der Schaden bleibt vollständig in der Simulation.
Bossgrotte Mondschlund mit Tor, Treppe in die Tiefe, Altar, Säulen, Felsen
und fünf Zuständen; der Generator hält Abstand zur Top-Lane, die Höhle
braucht eine kleine Geländesenke (nur Darstellung). Superboss Schlundkrone
mit 24 Knochen und 11 Clips (inkl. Brüllen und Sieg), Adern in Teamfarbe,
dasselbe Modell für Grotte und Lane. Gemeinsame LN-Materialfamilie (Stein,
Metall, Kristall, Rune, Stoff, Mondwasser, Kreaturenhaut), Occlusion-Fade
ohne dithernde Schatten. Keine Regeländerung.

Geprüft: verify_g2 245/245, verify_g1 443/443, Regeln 91/91 und 73/73,
Stress 17/17, Balance 40 Partien identisch mit G1, verify_m1 38/38,
verify_m2 14/14, Netz 43/43 und Loopback 12/12, Legacy 209 und 463.
21 Ansichten vorher/nachher (1920x1080), Videos Turmkampf und Bossbegegnung,
Leistung G1/G2 auf demselben Mac (+0,5 ms in vollen Kampfansichten).
Pakete 0.17.0-g2 für macOS (Universal, ad-hoc signiert), Windows x64/ARM64
und Linux; macOS nativ und unter Rosetta, beide Windows-Pakete in Windows 11
ARM (Parallels, OpenGL-Fallback) mit --lune-smoke bestanden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:12:49 +02:00

209 lines
8.3 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")
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
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
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
# 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))]
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 _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