extends Node3D ## One lane or base tower (G2). Reads the authoritative building HP and, during combat, the ## tower's LuneUnit (target, attack progress, attack serial) and turns them into visuals: ## four damage states, a short destruction sequence into a lasting ruin, the charging crystal ## and the launch of the tower bolt (flown by LuneTowerFx). Nothing here writes back into the ## simulation; the rules have applied the damage by the time a bolt lands. const Materials = preload("res://scripts/luneraria/view/lune_materials.gd") const Fx = preload("res://scripts/luneraria/view/lune_fx.gd") enum State { INTACT, LIGHT, HEAVY, DESTROYING, RUIN } const ASSETS: Dictionary = { "outer": {"path": "res://assets/luneraria/g2/grenzwacht.glb", "top": 6.3, "crystal": 5.2, "light_range": 7.0}, "inner": {"path": "res://assets/luneraria/g2/bastion.glb", "top": 7.4, "crystal": 6.2, "light_range": 8.0}, "base": {"path": "res://assets/luneraria/g2/kernwacht.glb", "top": 8.6, "crystal": 5.25, "light_range": 9.0}, } ## Hit point fractions where the look changes (the same bands as the Ancient). const LIGHT_BELOW: float = .605 const HEAVY_BELOW: float = .305 const DESTROY_SECONDS: float = 1.7 ## Occlusion fade (G2): dithering starts this far above the foot; half width of the box that ## can hide figures behind the tower. const FADE_FLOOR: float = 2.0 const OCCLUDER_RADIUS: Dictionary = {"outer": 1.35, "inner": 1.7, "base": 1.8} ## A bolt leaves when the attack cycle has this much left, so it lands on the rules' hit. const LAUNCH_AT: float = .74 static var _scenes: Dictionary = {} var building_id: String = "" var team: int = 0 var tier: String = "outer" var state: int = State.INTACT var fraction: float = 1.0 ## Occlusion fade the world asks for (0 opaque .. 1 mostly dithered away above fade_floor). var fade_target: float = 0.0 var seed_value: float = 0.0 var crystal: Node3D var ring: Node3D var shards: Array[Node3D] = [] var light: OmniLight3D var _parts: Dictionary = {} var _instances: Array = [] var _structure: Array = [] var _crystals: Array = [] var _cloth: Array = [] var _ruin: Array = [] var _sent: Dictionary = {} var _sparks: GPUParticles3D var _smoke: GPUParticles3D var _dust: GPUParticles3D var _burst: GPUParticles3D var _bar: Node3D var _bar_fill: MeshInstance3D var _rest: Dictionary = {} var _time: float = 0.0 var _destroy_time: float = -1.0 var _dust_fired: bool = false var _ruin_smoke_left: float = 0.0 var _hit_kick: float = 0.0 var _charge: float = 0.0 var _fade: float = 0.0 var _crystal_rest := Vector3.ZERO var _last_serial: int = -1 var _launched_for: int = -1 var _upper_axis := Vector3.RIGHT var _energy_boost: float = 0.0 func setup(definition: Dictionary, facing: Vector2) -> void: building_id = str(definition.id) team = int(definition.team) tier = str(definition.tier) name = building_id seed_value = float(absi(hash(building_id)) % 997) / 97.0 var spec: Dictionary = ASSETS[tier] if not _scenes.has(tier): _scenes[tier] = load(spec.path) var model: Node3D = (_scenes[tier] as PackedScene).instantiate() model.name = "Model" add_child(model) # Local +z of the model faces the lane (base towers: away from their Ancient). 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") ring = _parts.get("Ring") for index: int in range(4): if _parts.has("Shard_%d" % index): shards.append(_parts["Shard_%d" % index]) if crystal != null: _crystal_rest = crystal.position for key: String in _parts: var list: Array = _collect(_parts[key]) if key == "Crystal" or key.begins_with("Shard"): _crystals.append_array(list) elif key.begins_with("Pennant") or key.begins_with("Banner"): _cloth.append_array(list) elif key == "Ruin": _ruin.append_array(list) else: _structure.append_array(list) Materials.apply_rune_tint(_structure + _ruin, team) # Glowing crystals, thin rings, shards and cloth cast no sun shadow (saves shadow-pass draws; # their shadows were only a few pixels from the game camera). for instance: GeometryInstance3D in _crystals + _cloth: instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF if ring != null: for instance: GeometryInstance3D in _collect(ring): instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF # The pennant belongs to the upper part, so it falls with it. if _parts.has("Upper") and _parts.has("Pennant"): var cloth: Node3D = _parts["Pennant"] var upper: Node3D = _parts["Upper"] var local: Transform3D = upper.transform.affine_inverse() * cloth.transform cloth.get_parent().remove_child(cloth) upper.add_child(cloth) cloth.transform = local # The upper part tips sideways relative to the lane. _upper_axis = Vector3(facing.x, 0, facing.y).normalized().rotated(Vector3.UP, PI * .5 + (seed_value - 5.0) * .08) _set_visible("Upper_Damaged", false) _set_visible("Debris", false) _set_visible("Ruin", false) light = OmniLight3D.new() light.name = "TowerLight" light.position = Vector3(0, float(spec.crystal), 0) light.light_color = Materials.team_color(team, "light") light.omni_range = float(spec.light_range) light.omni_attenuation = 1.4 light.light_energy = 1.3 add_child(light) _sparks = Fx.sparks(Materials.team_color(team, "light"), 16, 1.0, 1.5) _sparks.position = Vector3(0, float(spec.crystal) * .55, 0) add_child(_sparks) _smoke = Fx.smoke(8, .7, .24, 1.2) _smoke.position = Vector3(0, float(spec.crystal) * .6, 0) add_child(_smoke) _dust = Fx.dust_burst(34 if tier != "base" else 44, 1.5, 1.7) add_child(_dust) _burst = Fx.shard_burst(Materials.team_color(team, "crystal"), 26, 4.5) add_child(_burst) _build_bar(float(spec.top) + .6) # The occlusion fade only thins out what stands above head height of the figures around. 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 _set_visible(key: String, value: bool) -> void: if _parts.has(key): (_parts[key] as Node3D).visible = value ## Sets an instance parameter on a group only when it really changes (saves server calls). 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 = 2.2 if tier != "base" else 2.6 var back := MeshInstance3D.new() var quad := QuadMesh.new() quad.size = Vector2(width + .08, .2) back.mesh = quad var back_material := StandardMaterial3D.new() back_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED back_material.albedo_color = Color(.03, .03, .05, .75) 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, .13) 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(.1) 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 ## World-space point the bolt leaves from (crystal tip). func muzzle() -> Vector3: if crystal != null and crystal.is_inside_tree(): return crystal.global_position + Vector3(0, .35, 0) return global_position + Vector3(0, float(ASSETS[tier].crystal), 0) func is_standing() -> bool: return state != State.DESTROYING and state != State.RUIN ## World box of the part that can hide figures (above the fade floor); empty for ruins. func occluder_box() -> AABB: if not is_standing(): return AABB() var r: float = float(OCCLUDER_RADIUS[tier]) var top: float = float(ASSETS[tier].top) return AABB(global_position + Vector3(-r, FADE_FLOOR, -r), Vector3(r * 2.0, top - FADE_FLOOR, r * 2.0)) func current_fade() -> float: return _fade ## Authoritative HP -> visual state. `animate` is false while the view is (re)built, so a ## tower that is already down appears as a ruin without replaying its collapse. 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 - .0005 and value > 0.0: _hit_kick = 1.0 var next: int = State.INTACT if value <= 0.0: if state == State.RUIN or state == State.DESTROYING: next = state else: next = State.DESTROYING if animate else State.RUIN elif fraction < HEAVY_BELOW: next = State.HEAVY elif fraction < LIGHT_BELOW: next = State.LIGHT 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 damaged_top: bool = next == State.HEAVY or (next == State.DESTROYING and from == State.HEAVY) _set_visible("Upper", next != State.RUIN and not damaged_top) _set_visible("Upper_Damaged", next != State.RUIN and damaged_top) _set_visible("Debris", next == State.HEAVY or next == State.DESTROYING) _set_visible("Lower", next != State.RUIN) _set_visible("Ruin", next == State.RUIN) for key: String in _parts: if key == "Crystal" or key == "Ring" or key.begins_with("Shard") or key.begins_with("Banner"): (_parts[key] as Node3D).visible = next != State.RUIN _sparks.emitting = next == State.HEAVY _smoke.emitting = next == State.LIGHT or next == State.HEAVY _smoke.amount_ratio = .35 if next == State.LIGHT else 1.0 match next: State.DESTROYING: _destroy_time = 0.0 _dust_fired = false Fx.fire(_burst, muzzle()) State.RUIN: _destroy_time = -1.0 _ruin_smoke_left = 18.0 if from == State.DESTROYING else 0.0 _smoke.emitting = _ruin_smoke_left > 0.0 _smoke.amount_ratio = .6 light.visible = false _bar.visible = false Materials.set_param(_ruin, "energy", .14) Materials.set_param(_ruin, "flicker", .6) Materials.set_param(_ruin, "damage", .8) Materials.set_param(_ruin, "scorch", .7) for part: String in ["Upper", "Upper_Damaged"]: if _parts.has(part): (_parts[part] as Node3D).transform = _rest[part] if crystal != null: crystal.scale = Vector3.ONE var damage: float = {State.INTACT: 0.0, State.LIGHT: .42, State.HEAVY: .82}.get(next, .9) _param(_structure, "structure", "damage", damage) _param(_structure, "structure", "scorch", {State.INTACT: 0.0, State.LIGHT: .22}.get(next, .55)) _param(_cloth, "cloth", "tatter", {State.INTACT: 0.0, State.LIGHT: .35, State.HEAVY: .7}.get(next, .8)) ## Combat hook with the tower's own LuneUnit (null outside combat) and its target's view. ## Returns a bolt request [from, target_view, flight_seconds] when a shot leaves this frame. func update_attack(unit: LuneUnit, target_view: Node3D, interval: float) -> Array: if unit == null or not unit.alive or not is_standing(): _charge = move_toward(_charge, 0.0, .08) return [] var request: Array = [] var aiming: bool = unit.in_range and unit.target != null and unit.target.alive _charge = unit.attack_progress if aiming else move_toward(_charge, 0.0, .1) var cycle: int = unit.attack_serial if aiming and target_view != null and unit.attack_progress >= LAUNCH_AT and _launched_for != cycle: _launched_for = cycle request = [muzzle(), target_view, maxf(.06, (1.0 - unit.attack_progress) * interval)] elif cycle != _last_serial and _last_serial >= 0 and _launched_for != cycle - 1 and target_view != null: # A hit without a predicted launch (retarget, big time step): a quick bolt now. request = [muzzle(), target_view, .08] if cycle != _last_serial and _last_serial >= 0: _energy_boost = 1.0 _last_serial = cycle return request func charge() -> float: return _charge func process_view(delta: float, sim_delta: float) -> void: _time += delta var calm: float = {State.INTACT: 1.0, State.LIGHT: .85, State.HEAVY: .6}.get(state, 0.0) _energy_boost = maxf(0.0, _energy_boost - sim_delta * 4.0) if crystal != null and crystal.visible and state != State.DESTROYING: var bob: float = sin(_time * 1.3 + seed_value) * .05 * calm if state == State.HEAVY: bob += sin(_time * 17.0) * .012 crystal.position = _crystal_rest + Vector3(0, bob, 0) crystal.rotation.y += delta * (.35 + _charge * 2.6) if ring != null and ring.visible: ring.rotation.y += delta * (.5 + _charge * 1.5) * (1.0 if state != State.HEAVY else .55) ring.rotation.x = sin(_time * .7 + seed_value) * .08 + (.22 if state == State.HEAVY else 0.0) for index: int in range(shards.size()): var a: float = _time * .6 + TAU * index / shards.size() shards[index].position = _crystal_rest + Vector3(cos(a) * 1.02, .12 + sin(_time * 1.1 + index) * .08, sin(a) * 1.02) shards[index].rotation.y = a var flicker: float = {State.INTACT: 0.0, State.LIGHT: .18, State.HEAVY: .6}.get(state, .8) if state != State.DESTROYING and state != State.RUIN: _param(_crystals, "crystal", "energy", calm * (1.0 + _charge * .9 + _energy_boost * 1.6), .02) _param(_crystals, "crystal", "flicker", flicker) _param(_crystals, "crystal", "fracture", 0.0 if state == State.INTACT else (.4 if state == State.LIGHT else .9)) _param(_structure, "structure", "energy", .35 + calm * .65 + _charge * .5, .03) _param(_structure, "structure", "flicker", flicker) if light.visible and state != State.DESTROYING: var wobble: float = 1.0 if flicker > 0.0: wobble = lerpf(1.0, .45 + .55 * absf(sin(_time * 23.0 + seed_value) * sin(_time * 7.3)), flicker) light.light_energy = (.55 + 1.0 * calm) * wobble * (1.0 + _charge * .5 + _energy_boost * 1.2) # A hit on the building: a short shudder of the upper part. if _hit_kick > 0.0 and is_standing(): _hit_kick = maxf(0.0, _hit_kick - delta * 5.0) var part: String = "Upper_Damaged" if state == State.HEAVY else "Upper" if _parts.has(part): (_parts[part] as Node3D).transform = (_rest[part] as Transform3D).translated_local(Vector3(sin(_time * 60.0) * .03 * _hit_kick, 0, 0)) if state == State.DESTROYING: _destroy_step(delta) elif state == State.RUIN and _ruin_smoke_left > 0.0: _ruin_smoke_left -= delta if _ruin_smoke_left <= 0.0: _smoke.emitting = false _fade = move_toward(_fade, fade_target, delta * 3.0) _param(_instances, "all", "fade", _fade, .02) ## Collapse: the crystal overloads and fails, the upper part tips and sinks behind a dust ## burst, then the ruin replaces the tower. Short and bounded; the rules finished long ago. func _destroy_step(delta: float) -> void: _destroy_time += delta var t: float = _destroy_time var part: String = "Upper_Damaged" if _parts.has("Upper_Damaged") and (_parts["Upper_Damaged"] as Node3D).visible else "Upper" if _parts.has(part): var rest: Transform3D = _rest[part] var fall: float = smoothstep(.15, 1.05, t) var tilt: float = fall * fall * .95 var drop: float = fall * fall * 2.4 (_parts[part] as Node3D).transform = Transform3D(Basis(_upper_axis, tilt) * rest.basis, rest.origin + Vector3(0, -drop, 0) + _upper_axis.cross(Vector3.UP) * fall * .6) if crystal != null: var overload: float = clampf(1.0 - t / .35, 0.0, 1.0) Materials.set_param(_crystals, "energy", 3.0 * overload + .05) Materials.set_param(_crystals, "flicker", 1.0) crystal.scale = Vector3.ONE * maxf(.01, 1.0 + overload * .25 - smoothstep(.25, .5, t)) light.light_energy = maxf(0.0, 4.0 * (1.0 - t / .5)) if t >= .45 and not _dust_fired: _dust_fired = true Fx.fire(_dust, global_position + Vector3(0, .3, 0)) if t >= DESTROY_SECONDS * .62: _enter(State.RUIN)