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

241 lines
8.8 KiB
GDScript

extends Node3D
## Tower attack presentation (G2): target lock marker, crystal bolt in flight and the impact.
## Bolts are requested by LuneTowerView when the rules' attack cycle is about to complete and
## are timed to land on the rules' hit; they only follow the target's view. All objects are
## pooled; nothing here can cause, cancel or duplicate damage or gold.
const Materials = preload("res://scripts/luneraria/view/lune_materials.gd")
const Fx = preload("res://scripts/luneraria/view/lune_fx.gd")
const POOL: int = 24
class Bolt:
var node: Node3D
var core: MeshInstance3D
var trail: MeshInstance3D
var from: Vector3
var target: Node3D
var last_aim: Vector3
var elapsed: float = 0.0
var flight: float = .25
var arc: float = .7
var team: int = 0
var heavy: bool = false
var active: bool = false
var camera: Camera3D
var _bolts: Array[Bolt] = []
var _impacts: Array = []
var _sparks: Array[GPUParticles3D] = []
var _next_spark: int = 0
var _markers: Dictionary = {}
var _materials: Dictionary = {}
var _ring_texture: GradientTexture2D
## Counters for tests and the benchmark (purely informative).
var launched: int = 0
var landed: int = 0
func _ready() -> void:
name = "TowerFx"
func _material(team: int, kind: String) -> StandardMaterial3D:
var key: String = "%d:%s" % [team, kind]
if _materials.has(key):
return _materials[key]
var color: Color = Materials.team_color(team, "crystal")
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.disable_receive_shadows = true
match kind:
"core":
material.albedo_color = Color(color.r * 2.6 + .4, color.g * 2.6 + .4, color.b * 2.6 + .4)
"trail":
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
material.albedo_texture = Fx.soft_texture()
material.albedo_color = Color(color.r * 1.6, color.g * 1.6, color.b * 1.6, .85)
"flash":
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
material.albedo_texture = Fx.soft_texture()
material.albedo_color = Color(color.r * 2.0, color.g * 2.0, color.b * 2.0, 1.0)
"marker":
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
material.albedo_texture = _ring()
material.albedo_color = Color(color.r * 1.4, color.g * 1.4, color.b * 1.4, .9)
material.cull_mode = BaseMaterial3D.CULL_DISABLED
_materials[key] = material
return material
func _ring() -> GradientTexture2D:
if _ring_texture == null:
var gradient := Gradient.new()
gradient.offsets = PackedFloat32Array([0.0, .62, .78, .86, .93, 1.0])
gradient.colors = PackedColorArray([Color(1, 1, 1, 0), Color(1, 1, 1, 0.05), Color(1, 1, 1, .35), Color(1, 1, 1, 1), Color(1, 1, 1, .2), Color(1, 1, 1, 0)])
_ring_texture = GradientTexture2D.new()
_ring_texture.gradient = gradient
_ring_texture.fill = GradientTexture2D.FILL_RADIAL
_ring_texture.fill_from = Vector2(.5, .5)
_ring_texture.fill_to = Vector2(1.0, .5)
_ring_texture.width = 128
_ring_texture.height = 128
return _ring_texture
func _new_bolt() -> Bolt:
var bolt := Bolt.new()
bolt.node = Node3D.new()
bolt.node.name = "Bolt"
add_child(bolt.node)
bolt.core = MeshInstance3D.new()
var prism := PrismMesh.new()
prism.size = Vector3(.16, .5, .16)
bolt.core.mesh = prism
bolt.core.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
bolt.node.add_child(bolt.core)
bolt.trail = MeshInstance3D.new()
var quad := QuadMesh.new()
quad.size = Vector2(1, 1)
quad.center_offset = Vector3(-.5, 0, 0)
bolt.trail.mesh = quad
bolt.trail.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(bolt.trail)
bolt.node.visible = false
bolt.trail.visible = false
return bolt
func _free_bolt() -> Bolt:
for bolt: Bolt in _bolts:
if not bolt.active:
return bolt
if _bolts.size() < POOL:
var bolt: Bolt = _new_bolt()
_bolts.append(bolt)
return bolt
return null
## Starts a bolt from `from` that lands on the target view after `flight` simulation seconds.
func launch(from: Vector3, target: Node3D, flight: float, team: int, heavy: bool = false) -> void:
var bolt: Bolt = _free_bolt()
if bolt == null or target == null:
return
bolt.active = true
bolt.from = from
bolt.target = target
bolt.last_aim = _aim(target)
bolt.elapsed = 0.0
bolt.flight = maxf(.05, flight)
bolt.arc = clampf(from.distance_to(bolt.last_aim) * .08, .15, .9)
bolt.team = team
bolt.heavy = heavy
bolt.core.material_override = _material(team, "core")
bolt.trail.material_override = _material(team, "trail")
bolt.core.scale = Vector3.ONE * (1.35 if heavy else 1.0)
bolt.node.visible = true
bolt.trail.visible = true
bolt.node.global_position = from
launched += 1
func _aim(target: Node3D) -> Vector3:
if target != null and is_instance_valid(target):
if target.has_method("aim_point"):
return target.aim_point()
return target.global_position + Vector3(0, 1.0, 0)
return Vector3.ZERO
func _path(bolt: Bolt, t: float) -> Vector3:
var eased: float = t * t * (3.0 - 2.0 * t) * .35 + t * .65
return bolt.from.lerp(bolt.last_aim, eased) + Vector3(0, sin(PI * t) * bolt.arc, 0)
## Advances bolts (simulation time, so pause and speed apply) and impact flashes (real time).
func process_fx(delta: float, sim_delta: float) -> void:
for bolt: Bolt in _bolts:
if not bolt.active:
continue
if bolt.target != null and is_instance_valid(bolt.target):
bolt.last_aim = _aim(bolt.target)
bolt.elapsed += sim_delta
var t: float = clampf(bolt.elapsed / bolt.flight, 0.0, 1.0)
var at: Vector3 = _path(bolt, t)
var behind: Vector3 = _path(bolt, maxf(0.0, t - .22))
var direction: Vector3 = at - behind
if direction.length() > .001:
bolt.node.global_transform = Transform3D(Basis.looking_at(direction.normalized(), Vector3.UP) * Basis(Vector3.RIGHT, -PI * .5), at)
_stretch(bolt.trail, behind, at, .32 if bolt.heavy else .24)
if t >= 1.0:
bolt.active = false
bolt.node.visible = false
bolt.trail.visible = false
_impact(bolt.last_aim, bolt.team, bolt.heavy)
landed += 1
for index: int in range(_impacts.size() - 1, -1, -1):
var entry: Array = _impacts[index]
var flash: MeshInstance3D = entry[0]
entry[1] = float(entry[1]) + delta
var k: float = float(entry[1]) / .22
if k >= 1.0:
flash.queue_free()
_impacts.remove_at(index)
continue
flash.scale = Vector3.ONE * lerpf(.35, 1.3, k) * float(entry[2])
flash.transparency = k
## Orients a quad as a ribbon from a to b that faces the camera.
func _stretch(trail: MeshInstance3D, a: Vector3, b: Vector3, width: float) -> void:
var axis: Vector3 = b - a
var length: float = axis.length()
if length < .01 or camera == null:
trail.visible = false
return
trail.visible = true
var x: Vector3 = axis / length
var to_camera: Vector3 = (camera.global_position - b).normalized()
var z: Vector3 = (to_camera - x * to_camera.dot(x))
if z.length() < .001:
z = Vector3.UP
z = z.normalized()
var y: Vector3 = z.cross(x)
trail.global_transform = Transform3D(Basis(x * length, y * width, z), b)
func _impact(at: Vector3, team: int, heavy: bool) -> void:
var flash: MeshInstance3D = Fx.flash_mesh(Color.WHITE, 1.0)
flash.material_override = _material(team, "flash")
add_child(flash)
flash.global_position = at
_impacts.append([flash, 0.0, 1.35 if heavy else 1.0])
if _sparks.size() < 8:
var sparks: GPUParticles3D = Fx.shard_burst(Materials.team_color(team, "crystal"), 10, 3.2)
sparks.lifetime = .6
add_child(sparks)
_sparks.append(sparks)
var emitter: GPUParticles3D = _sparks[_next_spark % _sparks.size()]
_next_spark += 1
Fx.fire(emitter, at)
## Lock-on ring under the current target of a tower; tightens as the shot charges.
func mark(tower_id: String, target: Node3D, charge: float, team: int) -> void:
var marker: MeshInstance3D = _markers.get(tower_id)
if target == null or not is_instance_valid(target) or charge <= .02:
if marker != null:
marker.visible = false
return
if marker == null:
marker = MeshInstance3D.new()
marker.name = "Lock_" + tower_id
var quad := QuadMesh.new()
quad.size = Vector2(2, 2)
quad.orientation = PlaneMesh.FACE_Y
marker.mesh = quad
marker.material_override = _material(team, "marker")
marker.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(marker)
_markers[tower_id] = marker
marker.visible = true
var feet: Vector3 = target.global_position + Vector3(0, .07, 0)
var size: float = lerpf(1.25, .72, clampf(charge, 0.0, 1.0)) * (target.call("footprint") if target.has_method("footprint") else 1.0)
marker.global_transform = Transform3D(Basis(Vector3.UP, Time.get_ticks_msec() * .0018) * Basis.from_scale(Vector3(size, 1, size)), feet)
marker.transparency = 1.0 - clampf(.35 + charge * .65, 0.0, 1.0)
func clear_markers() -> void:
for id: String in _markers:
(_markers[id] as MeshInstance3D).visible = false