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