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)