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) # The width an unupgraded emitter cuts, and what the definition declares. # #204 made it a vessel stat, so the width in force is `half_width` below; # this is only what a beam fired without one falls back to. const BEAM_HALF_WIDTH := 38.0 # NeonDraw's widest layer is 18px at scale 1, so dividing by it makes the # glow span exactly what the beam hits. The DRAWN beam is scaled from the hit # width rather than tuned separately: the two were once independent numbers # and had drifted apart, so the beam struck anything within 22 units of the # line while its outer glow spanned about 11, killing things it visibly # missed. Kept a ratio rather than a constant now that the width can grow - # a wider beam has to LOOK wider, or the drift comes back with every level. const NEON_WIDEST_LAYER := 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) # Both ends of the beam, as fractions of its own width. const TIP_FRACTION := 0.55 const MUZZLE_FRACTION := 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 # How far either side of the line this beam strikes, and how wide it is drawn. var half_width: float = BEAM_HALF_WIDTH static func fire(world: Node, origin: Vector2, direction: Vector2, beam_range: float, damage: int, beam_half_width: float = BEAM_HALF_WIDTH) -> LaserBeamEffect: var effect := LaserBeamEffect.new() effect.half_width = maxf(beam_half_width, 1.0) 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 <= 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() var width_scale: float = half_width * 2.0 / NEON_WIDEST_LAYER NeonDraw.draw_glowing_line(self, from, at, BEAM_COLOR, alpha, 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, width_scale * CORE_SCALE * flicker) # The leading cap, brightest while the blade is still running out. var cap: float = half_width * TIP_FRACTION * (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 = half_width * MUZZLE_FRACTION * (1.0 - progress) if muzzle > 1.0: NeonDraw.draw_glowing_circle(self, from, muzzle, CORE_COLOR, alpha * (1.0 - progress))