saumwacht-godot/scripts/luneraria/view/lune_ancient_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

424 lines
18 KiB
GDScript

extends Node3D
## One Ancient (G2 "Mondherz"): the monumental heart of a base. Reads the authoritative HP
## and shows four states - stable, unstable (cracks, uneasy energy), critical (heavy cracks,
## leaking energy, a leaning blade, a fallen fragment, sparks and smoke) and the collapse into
## a lasting ruin. The collapse is presentation only: the match has already been decided and
## processed when it starts, and it never delays anything.
const Materials = preload("res://scripts/luneraria/view/lune_materials.gd")
const Fx = preload("res://scripts/luneraria/view/lune_fx.gd")
enum State { STABLE, UNSTABLE, CRITICAL, COLLAPSING, FALLEN }
const SCENE_PATH := "res://assets/luneraria/g2/ancient.glb"
const UNSTABLE_BELOW: float = .605
const CRITICAL_BELOW: float = .305
const COLLAPSE_SECONDS: float = 2.8
const CRYSTAL_Y: float = 4.7
const BLADE_ANGLES: Array[float] = [0.0, PI * .5, PI, PI * 1.5]
## Dais steps (outer radius, top height above the plaza) - figures are lifted onto them.
const DAIS_STEPS: Array = [[4.0, .05], [3.4, .1], [2.8, .15]]
## Occlusion fade (G2): the blades, rings and heart above this height may thin out.
const FADE_FLOOR: float = 2.4
const OCCLUDER_RADIUS: float = 3.2
const TOP: float = 10.4
static var _scene: PackedScene
var building_id: String = ""
var team: int = 0
var state: int = State.STABLE
var fraction: float = 1.0
var fade_target: float = 0.0
var seed_value: float = 0.0
var light: OmniLight3D
var crystal: Node3D
var satellites: Array[Node3D] = []
var fragments: Array[Node3D] = []
var rings: Array[Node3D] = []
var blades: Array[Node3D] = []
var crown: Node3D
var crest: Node3D
var _parts: Dictionary = {}
var _rest: Dictionary = {}
var _instances: Array = []
var _structure: Array = []
var _crystals: Array = []
var _cloth: Array = []
var _ruin: Array = []
var _sent: Dictionary = {}
var _motes: GPUParticles3D
var _sparks: GPUParticles3D
var _smoke: GPUParticles3D
var _dust: GPUParticles3D
var _burst: GPUParticles3D
var _flash: MeshInstance3D
var _bar: Node3D
var _bar_fill: MeshInstance3D
var _time: float = 0.0
var _collapse_time: float = -1.0
var _smoke_left: float = 0.0
var _hit: float = 0.0
var _fade: float = 0.0
var _burst_fired: bool = false
var _fallen_fragment: int = 0
## Energy leaking from the cracked heart (critical state): fast, irregular sparks upwards.
var _leak: GPUParticles3D
## Critical state: the inner ring has slipped and hangs tilted; one satellite sags (unstable).
var _ring_slipped: bool = false
var _sag: float = 0.0
func setup(definition: Dictionary, facing: Vector2) -> void:
building_id = str(definition.id)
team = int(definition.team)
name = building_id
seed_value = 3.0 + team * 5.0
if _scene == null:
_scene = load(SCENE_PATH)
var model: Node3D = _scene.instantiate()
model.name = "Model"
add_child(model)
model.rotation.y = atan2(facing.x, facing.y)
for child: Node in model.get_children():
_parts[str(child.name)] = child
_rest[str(child.name)] = (child as Node3D).transform
_instances = Materials.dress(model)
Materials.apply_team(_instances, team, seed_value)
crystal = _parts.get("Crystal")
crown = _parts.get("Crown")
crest = _parts.get("Crest_A" if team == 0 else "Crest_B")
(_parts["Crest_B" if team == 0 else "Crest_A"] as Node3D).visible = false
for index: int in range(6):
if _parts.has("Satellite_%d" % index):
satellites.append(_parts["Satellite_%d" % index])
if _parts.has("Fragment_%d" % index):
fragments.append(_parts["Fragment_%d" % index])
for key: String in ["Ring_Outer", "Ring_Inner"]:
rings.append(_parts[key])
for index: int in range(4):
blades.append(_parts["Blade_%d" % index])
for key: String in _parts:
var list: Array = _collect(_parts[key])
if key == "Crystal" or key.begins_with("Satellite") or key.begins_with("Fragment"):
_crystals.append_array(list)
elif key.begins_with("Banner"):
_cloth.append_array(list)
elif key.begins_with("Ruin"):
_ruin.append_array(list)
else:
_structure.append_array(list)
Materials.apply_rune_tint(_structure, team)
# The glowing heart, satellites, fragments and the banners cast no sun shadow.
for instance: GeometryInstance3D in _crystals + _cloth:
instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
Materials.set_param(_structure, "crack_glow", 0.0)
(_parts["Ruin_Crystal"] as Node3D).visible = false
(_parts["Ruin_Rubble"] as Node3D).visible = false
light = OmniLight3D.new()
light.name = "HeartLight"
light.position = Vector3(0, CRYSTAL_Y, 0)
light.light_color = Materials.team_color(team, "light")
light.omni_range = 13.0
light.omni_attenuation = 1.2
light.light_energy = 2.2
add_child(light)
var color: Color = Materials.team_color(team, "crystal")
_motes = Fx.motes(color, 22, 2.4, 4.0)
_motes.position = Vector3(0, 2.4, 0)
_motes.emitting = true
add_child(_motes)
_sparks = Fx.sparks(Materials.team_color(team, "light"), 26, 2.1, 3.0)
_sparks.position = Vector3(0, 3.2, 0)
add_child(_sparks)
_smoke = Fx.smoke(22, 1.4, .15, 2.4, 4.5)
_smoke.position = Vector3(0, 2.2, 0)
add_child(_smoke)
_leak = Fx.leak(color, 34, .9)
_leak.position = Vector3(0, CRYSTAL_Y - .6, 0)
add_child(_leak)
_dust = Fx.dust_burst(70, 2.6, 2.6)
add_child(_dust)
_burst = Fx.shard_burst(color, 90, 8.0)
add_child(_burst)
_flash = Fx.flash_mesh(Color(color.r * 2.0, color.g * 2.0, color.b * 2.0, 1.0), 9.0)
_flash.position = Vector3(0, CRYSTAL_Y, 0)
_flash.visible = false
add_child(_flash)
_build_bar(11.2)
Materials.set_param(_instances, "fade_floor", global_position.y + FADE_FLOOR)
func _collect(node: Node) -> Array:
var result: Array = []
if node is GeometryInstance3D:
result.append(node)
for child: Node in node.get_children():
result.append_array(_collect(child))
return result
func _param(group: Array, group_key: String, parameter: String, value: float, epsilon: float = .01) -> void:
var key: String = group_key + ":" + parameter
if _sent.has(key) and absf(float(_sent[key]) - value) < epsilon:
return
_sent[key] = value
Materials.set_param(group, parameter, value)
func _build_bar(height: float) -> void:
_bar = Node3D.new()
_bar.name = "HealthBar"
_bar.position.y = height
add_child(_bar)
var width: float = 3.4
var back := MeshInstance3D.new()
var quad := QuadMesh.new()
quad.size = Vector2(width + .1, .26)
back.mesh = quad
var back_material := StandardMaterial3D.new()
back_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
back_material.albedo_color = Color(.03, .03, .05, .78)
back_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
back_material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
back_material.no_depth_test = true
back.material_override = back_material
back.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_bar.add_child(back)
_bar_fill = MeshInstance3D.new()
var fill := QuadMesh.new()
fill.size = Vector2(width, .17)
fill.center_offset = Vector3(width * .5, 0, 0)
_bar_fill.mesh = fill
var fill_material := StandardMaterial3D.new()
fill_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
fill_material.albedo_color = Materials.team_color(team, "light").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
_bar_fill.material_override = fill_material
_bar_fill.position.x = -width * .5
_bar_fill.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_bar.add_child(_bar_fill)
_bar.visible = false
## Height of the dais or plaza surface above the plateau at a local ground distance.
static func surface_lift(distance: float) -> float:
var lift: float = 0.0
for step: Array in DAIS_STEPS:
if distance < float(step[0]):
lift = float(step[1])
return lift
func is_standing() -> bool:
return state != State.COLLAPSING and state != State.FALLEN
## World box of the part that can hide figures (above the fade floor); empty once fallen.
func occluder_box() -> AABB:
if not is_standing():
return AABB()
return AABB(global_position + Vector3(-OCCLUDER_RADIUS, FADE_FLOOR, -OCCLUDER_RADIUS),
Vector3(OCCLUDER_RADIUS * 2.0, TOP - FADE_FLOOR, OCCLUDER_RADIUS * 2.0))
func current_fade() -> float:
return _fade
## 0..1 energy of the heart for the floor veins (dims with damage, gone when fallen).
func vein_energy() -> float:
return {State.STABLE: 1.0, State.UNSTABLE: .75, State.CRITICAL: .5}.get(state, 0.0) * (1.0 + _hit * .8)
func vein_flicker() -> float:
return {State.STABLE: 0.0, State.UNSTABLE: .25, State.CRITICAL: .7}.get(state, 1.0)
func set_health(value: float, max_value: float, animate: bool = true) -> void:
var previous: float = fraction
fraction = clampf(value / maxf(1.0, max_value), 0.0, 1.0)
if fraction < previous - .0003 and value > 0.0:
_hit = 1.0
var next: int = State.STABLE
if value <= 0.0:
if state == State.COLLAPSING or state == State.FALLEN:
next = state
else:
next = State.COLLAPSING if animate else State.FALLEN
elif fraction < CRITICAL_BELOW:
next = State.CRITICAL
elif fraction < UNSTABLE_BELOW:
next = State.UNSTABLE
if next != state:
_enter(next)
_bar.visible = value > 0.0 and fraction < .999
_bar_fill.scale.x = maxf(.001, fraction)
func _enter(next: int) -> void:
var from: int = state
state = next
var critical: bool = next == State.CRITICAL
_sparks.emitting = next == State.UNSTABLE or critical
_sparks.amount_ratio = .55 if next == State.UNSTABLE else 1.0
_smoke.emitting = critical
_leak.emitting = critical
_ring_slipped = critical
_sag = {State.UNSTABLE: .6, State.CRITICAL: 1.0}.get(next, 0.0)
_motes.emitting = next in [State.STABLE, State.UNSTABLE, State.CRITICAL]
_motes.amount_ratio = {State.STABLE: .6, State.UNSTABLE: .8, State.CRITICAL: 1.0}.get(next, 0.0)
var damage: float = {State.STABLE: 0.0, State.UNSTABLE: .45, State.CRITICAL: .85}.get(next, .9)
_param(_structure, "structure", "damage", damage)
_param(_structure, "structure", "crack_glow", {State.UNSTABLE: .8, State.CRITICAL: 1.5}.get(next, 0.0))
_param(_structure, "structure", "scorch", {State.STABLE: 0.0, State.UNSTABLE: .2, State.CRITICAL: .45}.get(next, .75))
_param(_cloth, "cloth", "tatter", {State.STABLE: 0.0, State.UNSTABLE: .3, State.CRITICAL: .6}.get(next, .85))
match next:
State.CRITICAL:
# Two blades have come loose (one leans far out), two fragments lie on the dais.
for spec: Array in [[0, -.3, -.1], [2, .14, -.04]]:
var blade: Node3D = blades[int(spec[0])]
var axis: Vector3 = _tangent(int(spec[0]))
var rest_blade: Transform3D = _rest[str(blade.name)]
blade.transform = Transform3D(Basis(axis, float(spec[1])) * rest_blade.basis, rest_blade.origin + Vector3(0, float(spec[2]), 0))
_fallen_fragment = 2
State.COLLAPSING:
_collapse_time = 0.0
_burst_fired = false
State.FALLEN:
_collapse_time = -1.0
_pose_fallen(1.0)
_leak.emitting = false
_smoke.emitting = from == State.COLLAPSING
_smoke.amount_ratio = 1.0
_smoke_left = 25.0 if from == State.COLLAPSING else 0.0
light.visible = false
_bar.visible = false
Materials.set_param(_ruin, "energy", .16)
Materials.set_param(_ruin, "flicker", .7)
Materials.set_param(_ruin, "fracture", .8)
Materials.set_param(_ruin, "damage", .7)
_param(_structure, "structure", "energy", .04)
_param(_structure, "structure", "flicker", 0.0)
func _tangent(index: int) -> Vector3:
var a: float = BLADE_ANGLES[index]
return Vector3(-sin(a), 0, cos(a))
func process_view(delta: float, _sim_delta: float) -> void:
_time += delta
_hit = maxf(0.0, _hit - delta * 2.5)
var calm: float = {State.STABLE: 1.0, State.UNSTABLE: .85, State.CRITICAL: .7}.get(state, 0.0)
var unrest: float = {State.STABLE: 0.0, State.UNSTABLE: .35, State.CRITICAL: 1.0}.get(state, 0.0)
if is_standing():
var jitter: float = sin(_time * 21.0) * sin(_time * 7.7) * .02 * unrest
crystal.position = (_rest["Crystal"] as Transform3D).origin + Vector3(jitter, sin(_time * 1.1) * .07 * calm, 0)
crystal.rotation.y += delta * (.18 + unrest * .25)
for index: int in range(satellites.size()):
var a: float = _time * (.42 + unrest * .2) + TAU * index / satellites.size()
var r: float = 1.25 + .1 * sin(_time * .9 + index)
var y: float = CRYSTAL_Y - 1.1 + .35 * sin(_time * .7 + index * 1.3)
if index == 0 and _sag > 0.0:
# One satellite has lost its hold: it sags and trembles.
y -= 1.1 * _sag
r += .35 * _sag + sin(_time * 13.0) * .03 * _sag
satellites[index].position = Vector3(cos(a) * r, y, sin(a) * r)
satellites[index].rotation.y = -a
for index: int in range(fragments.size()):
if index < _fallen_fragment:
fragments[index].position = _fragment_rest(index)
continue
var rest: Vector3 = (_rest[str(fragments[index].name)] as Transform3D).origin
var angle: float = atan2(rest.z, rest.x) + _time * .12
var radius: float = Vector2(rest.x, rest.z).length()
fragments[index].position = Vector3(cos(angle) * radius, rest.y + sin(_time * .8 + index) * .18, sin(angle) * radius)
fragments[index].rotation.y += delta * .5
var wobble: float = unrest * .08
rings[0].rotation = Vector3(sin(_time * .6) * wobble, _time * .32, sin(_time * .9) * wobble)
if _ring_slipped:
# The inner ring has slipped from its bearing: tilted, grinding round in jerks.
var jerk: float = floorf(_time * 1.5) * .35 + smoothstep(0.0, .25, fmod(_time * 1.5, 1.0)) * .35
rings[1].rotation = Vector3(.5 + sin(_time * 2.3) * .03, -jerk, .22)
else:
rings[1].rotation = Vector3(sin(_time * .8 + 1.0) * wobble * 1.6, -_time * .46, cos(_time * .7) * wobble * 1.6)
var flicker: float = {State.STABLE: 0.0, State.UNSTABLE: .25, State.CRITICAL: .85}.get(state, .9)
_param(_crystals, "crystal", "energy", calm * (1.0 + _hit * 1.4), .02)
_param(_crystals, "crystal", "flicker", flicker)
_param(_crystals, "crystal", "fracture", unrest * .8)
_param(_structure, "structure", "energy", .45 + calm * .55 + _hit * .5, .02)
_param(_structure, "structure", "flicker", flicker * .8)
var pulse: float = 1.0
if flicker > 0.0:
pulse = lerpf(1.0, .5 + .5 * absf(sin(_time * 17.0 + seed_value) * sin(_time * 5.3)), flicker)
light.light_energy = (1.0 + calm * 1.3) * pulse * (1.0 + _hit * .8)
elif state == State.COLLAPSING:
_collapse_step(delta)
elif state == State.FALLEN and _smoke_left > 0.0:
_smoke_left -= delta
if _smoke_left <= 0.0:
_smoke.emitting = false
_fade = move_toward(_fade, fade_target, delta * 3.0)
_param(_instances, "all", "fade", _fade, .02)
func _fragment_rest(index: int) -> Vector3:
var rest: Vector3 = (_rest[str(fragments[index].name)] as Transform3D).origin
var flat := Vector2(rest.x, rest.z).normalized() * 2.95
return Vector3(flat.x, .32, flat.y)
## Collapse (2.8 s): the heart overloads, implodes and bursts; the blades crumble into the
## dust, rings and crown fall onto the pedestal, fragments drop; the ruin remains.
func _collapse_step(delta: float) -> void:
_collapse_time += delta
var t: float = _collapse_time
var overload: float = smoothstep(0.0, .5, t) * (1.0 - smoothstep(.55, .75, t))
if crystal.visible:
crystal.scale = Vector3.ONE * maxf(.05, 1.0 + overload * .14 - smoothstep(.5, .72, t) * .95)
crystal.rotation.y += delta * (1.0 + overload * 6.0)
Materials.set_param(_crystals, "energy", 1.0 + overload * 3.5)
Materials.set_param(_crystals, "flicker", 1.0)
light.light_energy = 2.2 + overload * 7.0 if t < .8 else maxf(0.0, 9.0 * (1.0 - (t - .8) / .9))
for index: int in range(rings.size()):
rings[index].rotation.y += delta * (2.0 + overload * 8.0) * (1.0 if index == 0 else -1.0)
if t >= .7 and not _burst_fired:
_burst_fired = true
Fx.fire(_burst, crystal.global_position)
Fx.fire(_dust, global_position + Vector3(0, .4, 0))
crystal.visible = false
for satellite: Node3D in satellites:
satellite.visible = false
(_parts["Ruin_Crystal"] as Node3D).visible = true
(_parts["Ruin_Rubble"] as Node3D).visible = true
_flash.visible = true
_smoke.emitting = true
if _flash.visible:
var k: float = clampf((t - .7) / .55, 0.0, 1.0)
_flash.scale = Vector3.ONE * lerpf(.4, 1.6, k)
_flash.transparency = k
if k >= 1.0:
_flash.visible = false
if t >= .7:
_pose_fallen(smoothstep(.7, 2.2, t))
if t >= COLLAPSE_SECONDS:
_enter(State.FALLEN)
## Interpolates the falling parts from where they stand to their resting place (k = 0..1).
func _pose_fallen(k: float) -> void:
var fall: float = k * k
for index: int in range(blades.size()):
var blade: Node3D = blades[index]
var rest: Transform3D = _rest[str(blade.name)]
var axis: Vector3 = _tangent(index)
var out := Vector3(cos(BLADE_ANGLES[index]), 0, sin(BLADE_ANGLES[index]))
blade.transform = Transform3D(Basis(axis, -.45 * fall) * rest.basis, rest.origin + out * .8 * fall + Vector3(0, -2.6 * fall, 0))
blade.scale = Vector3.ONE * lerpf(1.0, .02, smoothstep(.45, .9, k))
blade.visible = k < .9
var landings: Array = [[Vector3(.25, 1.42, -.1), Vector3(.34, 0, .12)], [Vector3(-.3, 1.5, .15), Vector3(-.28, 0, -.42)]]
for index: int in range(rings.size()):
var rest: Transform3D = _rest[str(rings[index].name)]
var land: Array = landings[index]
rings[index].position = rest.origin.lerp(land[0], fall)
rings[index].rotation = (rings[index].rotation as Vector3).lerp(land[1], k)
for node: Node3D in [crown, crest]:
var rest: Transform3D = _rest[str(node.name)]
node.position = rest.origin.lerp(Vector3(.2, 1.62, .1), fall)
node.rotation = Vector3(.45, .3, .22) * k
for index: int in range(fragments.size()):
var start: Vector3 = (_rest[str(fragments[index].name)] as Transform3D).origin
fragments[index].position = start.lerp(_fragment_rest(index), fall)
fragments[index].rotation = Vector3(1.2, float(index), .4) * k
if k >= 1.0:
crystal.visible = false
for satellite: Node3D in satellites:
satellite.visible = false
(_parts["Ruin_Crystal"] as Node3D).visible = true
(_parts["Ruin_Rubble"] as Node3D).visible = true
Materials.set_param(_crystals, "energy", .12)
Materials.set_param(_crystals, "flicker", .8)