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>
241 lines
8.8 KiB
GDScript
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
|