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

305 lines
13 KiB
GDScript

extends Node3D
## The boss grotto "Mondschlund" (G2): the Blender sanctuary on the G1 terrace, its five
## states and the resting boss. It reads only public match state (round, boss claim and lock
## round, the grotto boss of the running combat) and never writes into the simulation.
## AVAILABLE the boss rests on its dais, runes, source and cave light are awake
## FIGHT expeditions are inside: stronger light, faster pulse; the boss is a real unit
## DEPLOYED ("nicht verfügbar") the boss fights on a lane for the claiming team: dimmed,
## the runes keep a faint glow in that team's colour
## COOLDOWN exhausted energy after the boss fell: dark, cracked source, no boss
## SOON the last locked round: first energy returns, runes flicker awake
## The cooldown rules themselves stay in LuneMatch; this only mirrors them.
const Materials = preload("res://scripts/luneraria/view/lune_materials.gd")
const Fx = preload("res://scripts/luneraria/view/lune_fx.gd")
enum State { AVAILABLE, FIGHT, DEPLOYED, COOLDOWN, SOON }
const STATE_NAMES: Array[String] = ["verfuegbar", "kampf", "nicht_verfuegbar", "cooldown", "bald_verfuegbar"]
const SCENE_PATH := "res://assets/luneraria/g2/bossgrotte.glb"
const BOSS_MODEL := "schlundkrone"
const FALLBACK_BOSS := "e10"
## Local boss point and the source crystal cluster on the altar (see build_bossgrotte.py).
const BOSS_LOCAL := Vector3(-1.5, 0, -1.5)
const SOURCE_LOCAL := Vector3(-3.13, 1.5, -3.13)
const CAVE_LOCAL := Vector3(-7.46, .5, -7.46)
## Floor and dais heights above the terrace (figures stand on them).
const FLOOR_LIFT: float = .088
const DAIS: Array = [[1.64, .124], [2.24, .065]]
## Visual targets per state: runes, crystals, moon water, cave light, source light, motes.
const LOOK: Dictionary = {
State.AVAILABLE: {"runes": 1.0, "rune_flicker": 0.0, "crystal": 1.0, "crystal_flicker": 0.0, "fracture": 0.0, "water": 1.0,
"cave": 2.0, "source": 1.2, "motes": .8},
State.FIGHT: {"runes": 1.45, "rune_flicker": .12, "crystal": 1.45, "crystal_flicker": .2, "fracture": 0.0, "water": 1.3,
"cave": 2.4, "source": 1.6, "motes": 1.0},
State.DEPLOYED: {"runes": .28, "rune_flicker": 0.0, "crystal": .22, "crystal_flicker": .25, "fracture": .5, "water": .22,
"cave": .55, "source": .35, "motes": 0.0},
State.COOLDOWN: {"runes": .05, "rune_flicker": .5, "crystal": .07, "crystal_flicker": .7, "fracture": .9, "water": .05,
"cave": .22, "source": 0.0, "motes": 0.0},
State.SOON: {"runes": .5, "rune_flicker": .45, "crystal": .45, "crystal_flicker": .35, "fracture": .3, "water": .4,
"cave": 1.2, "source": .8, "motes": .3},
}
var state: int = State.AVAILABLE
## Team whose colour the runes carry while the boss is deployed (-1 = neutral).
var claim_team: int = -1
var fade_target: float = 0.0
var _fade: float = 0.0
var boss_figure: Node3D
var boss_visible: bool = false
var cave_light: OmniLight3D
var source_light: OmniLight3D
var _parts: Dictionary = {}
var _instances: Array = []
var _runes: Array = []
var _crystals: Array = []
var _water: Array = []
var _stone: Array = []
var _look: Dictionary = {}
## The back of the sanctuary (gate, cave, rocks) is what can hide Top-lane figures; only it fades.
var _back: Array = []
var _sent: Dictionary = {}
var _motes: GPUParticles3D
var _sparks: GPUParticles3D
var _burst: GPUParticles3D
var _dust: GPUParticles3D
var _flash: MeshInstance3D
var _flash_time: float = -1.0
var _time: float = 0.0
var _pulse: float = 0.0
var _boss_awake: bool = false
var _ready_once: bool = false
var _victory_played: bool = false
func setup() -> void:
name = "BossGrotto"
var model: Node3D = (load(SCENE_PATH) as PackedScene).instantiate()
model.name = "Model"
add_child(model)
for child: Node in model.get_children():
_parts[str(child.name)] = child
_instances = Materials.dress(model)
Materials.apply_team(_instances, 2, 4.2)
for key: String in _parts:
var list: Array = _collect(_parts[key])
if key in ["Gate", "Rocks", "Cave", "CaveGlow"]:
_back.append_array(list)
match key:
"Runes":
_runes.append_array(list)
"Source", "PillarCrystals", "CaveGlow":
_crystals.append_array(list)
"Channel":
_water.append_array(list)
"Cave":
pass
_:
_stone.append_array(list)
Materials.apply_rune_tint(_runes, 2)
# The terrace sits 1.7 m up; the dither fade starts above head height of figures on it.
Materials.set_param(_instances, "fade_floor", position.y + 2.2)
for group: Array in [_stone, _crystals, _water]:
for instance: GeometryInstance3D in group:
instance.set_instance_shader_parameter("tint", Materials.team_color(2, "crystal"))
cave_light = OmniLight3D.new()
cave_light.name = "CaveLight"
cave_light.position = CAVE_LOCAL
cave_light.light_color = Materials.team_color(2, "light")
cave_light.omni_range = 7.5
cave_light.omni_attenuation = 1.6
add_child(cave_light)
source_light = OmniLight3D.new()
source_light.name = "SourceLight"
source_light.position = SOURCE_LOCAL + Vector3(0, .6, 0)
source_light.light_color = Materials.team_color(2, "light").lightened(.4)
source_light.omni_range = 6.5
source_light.omni_attenuation = 1.5
add_child(source_light)
var violet: Color = Materials.team_color(2, "crystal")
_motes = Fx.motes(violet, 26, 2.2, 3.2)
_motes.position = BOSS_LOCAL + Vector3(0, .4, 0)
add_child(_motes)
_sparks = Fx.sparks(Materials.team_color(2, "light"), 18, 2.6, 1.4)
_sparks.position = BOSS_LOCAL + Vector3(0, .5, 0)
add_child(_sparks)
_burst = Fx.shard_burst(violet, 60, 6.0)
add_child(_burst)
_dust = Fx.dust_burst(40, 1.6, 1.8, Color(.5, .47, .52))
add_child(_dust)
_flash = Fx.flash_mesh(Color(violet.r * 2.0, violet.g * 2.0, violet.b * 2.0, 1.0), 6.0)
_flash.position = SOURCE_LOCAL
_flash.visible = false
add_child(_flash)
_make_boss()
_look = (LOOK[State.AVAILABLE] as Dictionary).duplicate()
_apply_look(0.0, true)
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
## The boss resting on its dais while the grotto is open: the same figure the fight uses.
func _make_boss() -> void:
var id: String = BOSS_MODEL if AssetLibrary.has_model(BOSS_MODEL) else FALLBACK_BOSS
boss_figure = AssetLibrary.instantiate_fighter(id)
if boss_figure == null:
return
boss_figure.name = "RestingBoss"
boss_figure.scale = Vector3.ONE * 1.7
boss_figure.position = BOSS_LOCAL + Vector3(0, DAIS[0][1], 0)
# It faces the arena and the two stairs (towards Mid).
boss_figure.rotation.y = atan2(LuneMap.AXIS.x, LuneMap.AXIS.y)
add_child(boss_figure)
if AssetLibrary.has_model(BOSS_MODEL):
_tint_boss(boss_figure, 2)
## Crystal glow of the boss figure follows the team it fights for (neutral violet in the grotto).
static func _tint_boss(figure: Node3D, team: int) -> void:
for instance: GeometryInstance3D in Materials.dress(figure):
instance.set_instance_shader_parameter("tint", Materials.team_color(team, "crystal"))
instance.set_instance_shader_parameter("seed", 1.7)
# Resting: the veins glow at half strength until someone enters the grotto.
instance.set_instance_shader_parameter("energy", .55)
## Height of the grotto floor or boss dais above the terrace at a local ground distance.
static func surface_lift(local: Vector2) -> float:
var d: float = local.distance_to(Vector2(BOSS_LOCAL.x, BOSS_LOCAL.z))
for step: Array in DAIS:
if d < float(step[0]):
return float(step[1])
return FLOOR_LIFT if local.length() < LuneTerrain.ARENA_RADIUS - .2 else 0.0
## The state from the public match state (static so tests can ask without a scene).
static func state_for(match_state: LuneMatch) -> int:
var combat: LuneCombat = match_state.combat if match_state.phase == LuneMatch.Phase.COMBAT else null
if combat != null and combat.grotto_boss != null:
if combat.grotto_boss.alive:
return State.FIGHT
return State.COOLDOWN
var claim: Dictionary = match_state.boss.claim
if not claim.is_empty():
return State.DEPLOYED
var next: int = int(match_state.boss.next_available_round)
if match_state.round_number >= next:
return State.AVAILABLE
if match_state.round_number == next - 1:
return State.SOON
return State.COOLDOWN
func state_name() -> String:
return STATE_NAMES[state]
## World box of the gate and its rocks (the part that can hide Top-lane figures behind it).
func occluder_box() -> AABB:
var box := AABB()
var first: bool = true
for local: Vector3 in [Vector3(-9.6, 2.2, -2.2), Vector3(-2.2, 2.2, -9.6), Vector3(-5.1, 7.4, -9.8), Vector3(-9.8, 7.4, -5.1),
Vector3(-4.2, 2.2, -4.2), Vector3(-8.6, 7.4, -8.6)]:
var point: Vector3 = to_global(local)
box = AABB(point, Vector3.ZERO) if first else box.expand(point)
first = false
return box
func current_fade() -> float:
return _fade
## Called every frame with the match and the unit views (to find the fighting boss).
func sync(match_state: LuneMatch, views: Dictionary, delta: float) -> void:
_time += delta
var next: int = state_for(match_state)
var claim: Dictionary = match_state.boss.claim
var team: int = int(claim.team) if not claim.is_empty() else -1
if next != state or team != claim_team:
_enter(next, team)
var combat: LuneCombat = match_state.combat if match_state.phase == LuneMatch.Phase.COMBAT else null
var fighter: LuneUnit = combat.grotto_boss if combat != null else null
# The resting figure stands in whenever no boss unit is on the dais.
boss_visible = state == State.AVAILABLE and fighter == null
if boss_figure != null:
boss_figure.visible = boss_visible
if boss_visible:
_animate_resting(delta)
if fighter != null and fighter.alive and not fighter.dormant and not _boss_awake:
_boss_awake = true
_wake(views.get(fighter.id))
elif fighter == null or fighter.dormant:
_boss_awake = false
# The expeditions failed and the boss still stands: it claims its grotto (victory clip).
if fighter != null and fighter.alive and combat.grotto_resolved and combat.grotto_outcome == "failed" and not _victory_played:
_victory_played = true
var view: Node3D = views.get(fighter.id)
if view != null and view.model != null and view.model.has_method("play_event"):
view.model.play_event("victory")
elif fighter == null:
_victory_played = false
_pulse = maxf(0.0, _pulse - delta * 1.6)
_apply_look(delta)
_ready_once = true
func _enter(next: int, team: int) -> void:
var previous: int = state
state = next
claim_team = team
var tint: Color = Materials.team_color(team, "rune") if next == State.DEPLOYED and team >= 0 else Materials.team_color(2, "rune")
Materials.set_param(_runes, "tint", tint)
_motes.emitting = float(LOOK[next].motes) > 0.0
_motes.amount_ratio = maxf(.05, float(LOOK[next].motes))
_sparks.emitting = next == State.FIGHT
if _ready_once and previous == State.FIGHT and next == State.COOLDOWN:
# The boss has fallen: the source overloads once and the energy collapses.
Fx.fire(_burst, to_global(SOURCE_LOCAL))
Fx.fire(_dust, to_global(BOSS_LOCAL))
_flash.visible = true
_flash_time = 0.0
## The boss wakes when one expedition remains: roar clip on its figure and a pulse of light.
func _wake(view: Node3D) -> void:
_pulse = 1.0
if view != null and view.model != null and view.model.has_method("play_event"):
view.model.play_event("roar")
func _animate_resting(_delta: float) -> void:
if boss_figure.has_method("preview"):
var length: float = boss_figure.clip_length("idle") if boss_figure.has_method("clip_length") else 3.0
boss_figure.preview("idle", fmod(_time, maxf(.1, length)))
elif boss_figure.has_method("pose"):
boss_figure.pose(_time, false, 0.0, 0.0, 0.0)
func _param(group: Array, key: String, parameter: String, value: float, epsilon: float = .01) -> void:
var id: String = key + ":" + parameter
if _sent.has(id) and absf(float(_sent[id]) - value) < epsilon:
return
_sent[id] = value
Materials.set_param(group, parameter, value)
## Eases every visual channel towards the state's targets (1.2 s), plus the fight pulse.
func _apply_look(delta: float, instant: bool = false) -> void:
var target: Dictionary = LOOK[state]
for key: String in target:
_look[key] = float(target[key]) if instant else move_toward(float(_look[key]), float(target[key]), delta * maxf(.8, absf(float(target[key]) - float(_look[key])) / 1.2))
var beat: float = 0.0
if state == State.FIGHT:
beat = .25 * (.5 + .5 * sin(_time * 5.5))
var boost: float = 1.0 + _pulse * 1.3 + beat
_param(_runes, "runes", "energy", float(_look.runes) * boost, .02)
_param(_runes, "runes", "flicker", float(_look.rune_flicker))
_param(_crystals, "crystals", "energy", float(_look.crystal) * boost, .02)
_param(_crystals, "crystals", "flicker", float(_look.crystal_flicker))
_param(_crystals, "crystals", "fracture", float(_look.fracture))
_param(_water, "water", "energy", float(_look.water) * (1.0 + _pulse * .6), .02)
cave_light.light_energy = float(_look.cave) * (1.0 + _pulse * .8 + beat * .6)
source_light.light_energy = float(_look.source) * boost
cave_light.visible = cave_light.light_energy > .02
source_light.visible = source_light.light_energy > .02
if _flash_time >= 0.0:
_flash_time += delta
var k: float = clampf(_flash_time / .6, 0.0, 1.0)
_flash.scale = Vector3.ONE * lerpf(.3, 1.5, k)
_flash.transparency = k
if k >= 1.0:
_flash.visible = false
_flash_time = -1.0
_fade = move_toward(_fade, fade_target, delta * 3.0)
_param(_back, "back", "fade", _fade, .02)