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>
209 lines
8.3 KiB
GDScript
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
|