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

377 lines
16 KiB
GDScript

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)