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)