class_name LaserBeamEffect extends Node2D const COMBAT_TARGET = preload("res://core/CombatTarget.gd") # The laser shot itself: the strike and the picture of it, with nothing in it # about who fired or why. # # This used to live inside LaserController, which meant the beam was a thing # only the FLEET could do - the player's laser button, the fleet's hold, the # fleet's aim. Garrisoned vessels (#103 follow-up) need the same shot with a # different trigger and a different magazine, so the effect moved out here. # Same shape as EnemyBigLaserBeam and ArtilleryShell on the enemy side: added # to the tree root, self-animating, self-freeing, and it outlives the vessel # that fired it. # # The damage below is dealt at the INSTANT of the shot, in full, along the # whole beam. The extension in _draw is the picture of that, not a travelling # projectile - a beam that had to arrive would be a change to how the weapon # plays. signal struck(hit_count: int) const BEAM_COLOR := Color(0.3, 0.9, 1.0) const BEAM_HALF_WIDTH := 38.0 # The DRAWN beam is scaled from the hit width rather than tuned separately. # The two were independent numbers and had drifted apart: the beam struck # anything within 22 units of the line while its outer glow spanned about 11, # so it killed things it visibly missed. NeonDraw's widest layer is 18px at # scale 1, so this makes the glow span exactly what the beam hits. const BEAM_WIDTH_SCALE := BEAM_HALF_WIDTH * 2.0 / 18.0 # #90: the beam was one flat line that blinked on at full length and faded. # It now IGNITES - it runs out of the ship over the first slice of its life, # holds white-hot, and falls away. const FLASH_DURATION := 0.34 const IGNITE_FRACTION := 0.3 # The white-hot rod down the middle of the coloured glow. Drawn INSIDE the # beam's own width, never wider: the drawn beam is deliberately the same span # as the hit test, and a halo painted past that would put the "it visibly # missed" complaint back the other way round. const CORE_SCALE := 0.42 const CORE_COLOR := Color(0.85, 0.99, 1.0) const TIP_RADIUS := BEAM_HALF_WIDTH * 0.55 const MUZZLE_RADIUS := BEAM_HALF_WIDTH * 0.8 # By path, not by class name - see the note at the top of MuzzleSparkVFX. const MUZZLE_SPARK = preload("res://vfx/MuzzleSparkVFX.gd") var from: Vector2 = Vector2.ZERO var to: Vector2 = Vector2.ZERO var time: float = FLASH_DURATION # Per beam, so two lasers firing together do not flicker in lockstep. var phase: float = 0.0 var hit_count: int = 0 static func fire(world: Node, origin: Vector2, direction: Vector2, beam_range: float, damage: int) -> LaserBeamEffect: var effect := LaserBeamEffect.new() var heading: Vector2 = direction.normalized() if direction.length() > 0.001 else Vector2.UP effect.from = origin effect.to = origin + heading * beam_range effect.phase = randf() * TAU world.add_child(effect) effect._strike(heading, damage) # #90: something happens at the hull, not just out in space. The ordinary # gun already does this (#87); the laser fired in complete silence at its # own end, which is most of why a shot read as a line appearing rather # than as a vessel firing. MUZZLE_SPARK.spawn(world, origin, heading, BEAM_COLOR) return effect func _strike(direction: Vector2, damage: int) -> void: hit_count = 0 var range_along: float = from.distance_to(to) # Every attackable composition shares this contract, including opened boss # eyes. Shooting a path through an asteroid field still works unchanged. for enemy in get_tree().get_nodes_in_group(COMBAT_TARGET.GROUP): if not is_instance_valid(enemy) or not (enemy is Node2D): continue var to_enemy: Vector2 = enemy.global_position - from var along: float = to_enemy.dot(direction) if along < 0.0 or along > range_along: continue var perpendicular: float = (to_enemy - direction * along).length() if perpendicular <= BEAM_HALF_WIDTH: enemy.take_damage(damage) hit_count += 1 struck.emit(hit_count) queue_redraw() func _process(delta: float) -> void: time = maxf(time - delta, 0.0) queue_redraw() if time <= 0.0: queue_free() # How far along its length the beam is currently drawn. Cubic ease-out, so it # leaves the ship fast and settles into full reach rather than crawling. # Public because it is the whole of #90's "it comes out of the ship". func extension() -> float: var progress: float = 1.0 - time / FLASH_DURATION var out: float = clampf(progress / IGNITE_FRACTION, 0.0, 1.0) return 1.0 - pow(1.0 - out, 3.0) func tip() -> Vector2: return from.lerp(to, extension()) func _draw() -> void: var progress: float = 1.0 - time / FLASH_DURATION # Full brightness while it extends, then away. Squared so it holds its # light and drops at the end, instead of dimming from the first frame the # way a plain linear fade did. var decay: float = clampf((progress - IGNITE_FRACTION) / (1.0 - IGNITE_FRACTION), 0.0, 1.0) var alpha: float = 1.0 - decay * decay # A live blade hums. Small enough to be texture rather than a blink. var flicker: float = 1.0 + 0.09 * sin(time * 70.0 + phase) var at: Vector2 = tip() NeonDraw.draw_glowing_line(self, from, at, BEAM_COLOR, alpha, BEAM_WIDTH_SCALE * flicker * (1.0 - decay * 0.45)) # The white-hot rod, over the same span at a fraction of the width. This # is where the extra light comes from: brighter through the middle, not # broader across. NeonDraw.draw_glowing_line(self, from, at, CORE_COLOR, alpha, BEAM_WIDTH_SCALE * CORE_SCALE * flicker) # The leading cap, brightest while the blade is still running out. var cap: float = TIP_RADIUS * (0.7 + 0.3 * (1.0 - decay)) * flicker NeonDraw.draw_glowing_circle(self, at, cap, CORE_COLOR, alpha) # And the flare left at the emitter, which fades faster than the beam so # the hull does not keep glowing after the shot is over. var muzzle: float = MUZZLE_RADIUS * (1.0 - progress) if muzzle > 1.0: NeonDraw.draw_glowing_circle(self, from, muzzle, CORE_COLOR, alpha * (1.0 - progress))