saumwacht-godot/scripts/luneraria/view/lune_unit_view.gd
Martin Stickelbroeck 8b17563814 G1: Drei-Lane-Layout mit Höhenfeld, Grotte, Aufmarschbereichen und MOBA-Kamera
Top windet sich über das Hochland, Mid quert die Kluft über die Mittelplattform,
Bottom führt durch das Tiefland über die Deltabrücke; Top und Bottom sind exakt
gleich lang, alles bleibt spiegelsymmetrisch. LuneTerrain liefert ein exakt
gespiegeltes Höhenfeld, auf dem Figuren stehen. Bossgrotte als Terrasse auf der
Achse zwischen Top und Mid, Aufmarschbereiche je Lane, perspektivische Kamera
mit Sprungtasten (F2–F4, B, G, H, O, R, Q/E). Bestandsassets wiederverwendet,
neue Formen als Blockout für G2/G4.

Geprüft: verify_g1 443/443, Regeln 91/91, M2 73/73, verify_m1 38/38,
verify_m2 14/14, Netz 43/43, Loopback 12/12, Balance 40 Partien (Seitentausch
20/20), Legacy 209/463. Vorher/Nachher-Aufnahmen und Messungen in G1-KARTE.md.

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

158 lines
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
## Ground the figure stands on (lane decks, terrain, grotto terrace); flat without it.
var terrain: LuneTerrain
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
# 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)
_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)
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
func _ground(at: Vector2) -> float:
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