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>
158 lines
6 KiB
GDScript
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
|