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>
305 lines
13 KiB
GDScript
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)
|