From c8d4731232f91aa2a1b49de155a6137948eed9e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sat, 22 Aug 2026 23:29:55 +0200 Subject: [PATCH] #90: give the laser an ignition instead of a blink The shot was one flat line that appeared at full length and faded. Nothing happened at the ship, nothing moved, and a hit looked exactly like a miss. The blade now runs out of the hull over the first 30% of the flash on a cubic ease-out, holds white-hot, then falls away, with a hum in its width, a bright cap at the leading end, a flare at the emitter, and sparks off the hull that fired it. MuzzleSparkVFX takes an optional colour so the laser throws cyan where the ordinary gun (#87) keeps its gold. The extra light is a white-hot rod down the middle at 42% of the width, deliberately not a wider halo: the note at BEAM_WIDTH_SCALE records that the drawn beam spans exactly what the beam hits, and a halo past that would put the "it visibly missed" complaint back the other way round. The damage is untouched - dealt in full, along the whole beam, on the frame of the shot. A beam that had to arrive would change how the weapon plays, which the ticket did not ask for, so the test pins it down: a target at the far end of the range must be hit before the drawn blade has visibly reached it. Co-Authored-By: Claude Opus 5 --- controllers/LaserController.gd | 81 ++++++++++++++- tests/test_laser_animation.gd | 163 ++++++++++++++++++++++++++++++ tests/test_laser_animation.gd.uid | 1 + vfx/MuzzleSparkVFX.gd | 12 ++- 4 files changed, 249 insertions(+), 8 deletions(-) create mode 100644 tests/test_laser_animation.gd create mode 100644 tests/test_laser_animation.gd.uid diff --git a/controllers/LaserController.gd b/controllers/LaserController.gd index 1116d9e..799b7ce 100644 --- a/controllers/LaserController.gd +++ b/controllers/LaserController.gd @@ -10,8 +10,29 @@ const BEAM_HALF_WIDTH := 38.0 # 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 -const FLASH_DURATION := 0.22 +# #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 - which is the sabre look the report asked +# for and also the only part of the shot the eye can follow. +# +# Long enough for the extension to read as movement rather than as a +# stutter, still short enough to be gone before the 4s cooldown is up. +const FLASH_DURATION := 0.34 +# The fraction of that life spent extending. The rest holds and decays. +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 (see BEAM_WIDTH_SCALE above), 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) +# The leading cap, and the flare left at the emitter. Both are radii, both +# stay within the beam's half width so nothing suggests a wider reach. +const TIP_RADIUS := BEAM_HALF_WIDTH * 0.55 +const MUZZLE_RADIUS := BEAM_HALF_WIDTH * 0.8 const ENERGY_PER_SHOT: int = 8 +# By path, not by class name - see the note at the top of MuzzleSparkVFX. +const MUZZLE_SPARK = preload("res://vfx/MuzzleSparkVFX.gd") var beams: Array = [] var last_hit_count: int = 0 @@ -55,6 +76,11 @@ func _perform_shot(ship: Node2D, component: Node) -> void: direction = direction.normalized() if direction.length() > 0.001 else Vector2.UP var beam_to: Vector2 = ship.global_position + direction * component.beam_range _strike(ship.global_position, beam_to, direction, int(component.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(get_tree().root, ship.global_position, direction, BEAM_COLOR) func _next_ready_laser_ship() -> Node2D: @@ -70,7 +96,15 @@ func _advance_effects(delta: float) -> void: func _strike(from: Vector2, to: Vector2, direction: Vector2, damage: int) -> void: - beams.append({"from": from, "to": to, "time": FLASH_DURATION}) + # The damage below is dealt HERE, in full, along the whole beam, at the + # instant of the shot. 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, which #90 did not ask for. + beams.append({ + "from": from, "to": to, "time": FLASH_DURATION, + # Per beam, so two lasers firing together do not flicker in lockstep. + "phase": randf() * TAU, + }) last_hit_count = 0 var range_along: float = from.distance_to(to) # Asteroids share the enemies' take_damage() contract, so the beam sweeps @@ -90,12 +124,49 @@ func _strike(from: Vector2, to: Vector2, direction: Vector2, damage: int) -> voi last_hit_count += 1 +# How far along its length a 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" and is +# worth asserting on directly. +func beam_extension(beam: Dictionary) -> float: + var progress: float = 1.0 - float(beam.time) / FLASH_DURATION + var out: float = clampf(progress / IGNITE_FRACTION, 0.0, 1.0) + return 1.0 - pow(1.0 - out, 3.0) + + +func beam_tip(beam: Dictionary) -> Vector2: + return Vector2(beam.from).lerp(Vector2(beam.to), beam_extension(beam)) + + func _draw() -> void: if aiming: NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target, TARGET_COLOR, 0.7, 0.6) for beam in beams: var progress: float = 1.0 - float(beam.time) / FLASH_DURATION - var alpha: float = 1.0 - progress - NeonDraw.draw_glowing_line(self, beam.from, beam.to, - BEAM_COLOR, alpha, BEAM_WIDTH_SCALE * (1.0 - progress * 0.45)) + # 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(float(beam.time) * 70.0 + float(beam.phase)) + var tip: Vector2 = beam_tip(beam) + var from: Vector2 = beam.from + + NeonDraw.draw_glowing_line(self, from, tip, 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, tip, 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, tip, 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)) diff --git a/tests/test_laser_animation.gd b/tests/test_laser_animation.gd new file mode 100644 index 0000000..28e763b --- /dev/null +++ b/tests/test_laser_animation.gd @@ -0,0 +1,163 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_laser_animation.gd +# Forgejo #90: the laser shot was one flat line that blinked on at full +# length and faded out. Nothing happened at the ship, nothing moved, and the +# only thing separating a hit from a miss was how long the line lingered. +# +# It now ignites: the blade runs out of the hull over the first slice of the +# flash, holds white-hot, and falls away, with sparks thrown off the hull +# that fired it. +# +# An animation cannot be eyeballed from a headless test, so this asserts the +# things that MAKE it one, and - more importantly - that none of it reached +# the gameplay underneath: +# A. the beam extends: early in the flash it is drawn well short of its +# full length, and by the end of the ignition it has arrived +# B. the extension only ever moves outward, never back down the blade +# C. the flash outlives the frame it was fired on, and clears itself +# D. the DAMAGE is still instant, at full range, on the frame of the shot - +# the picture extends, the weapon does not. A target at the far end +# must be hit before the drawn beam has visibly got there +# E. the hull throws sparks, in the beam's own colour rather than the +# ordinary gun's gold + +var game_state: Node +var main: Node +var fleet: Node +var laser: Node +var far_enemy: Node +var far_health: int = 0 +var extensions: Array = [] +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.stored_vessels.clear() + game_state.reset_progression() + game_state.vessel_levels["unlock_laser"] = 1 + game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 500) + main = load("res://world/Main.tscn").instantiate() + root.add_child(main) + current_scene = main + fleet = main.get_node("Fleet") + laser = main.get_node("LaserController") + + +func _sparks() -> Array: + var found: Array = [] + for node in root.get_children(): + if node.get_script() != null \ + and String(node.get_script().resource_path).ends_with("MuzzleSparkVFX.gd"): + found.append(node) + return found + + +func _spawn_enemy(at: Vector2) -> Node: + var enemy: Node = load("res://enemies/Enemy.tscn").instantiate() + enemy.position = at + main.add_child(enemy) + # Still, so where it was hit is where it was put. + enemy.speed = 0.0 + return enemy + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.2: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"): + if is_instance_valid(hazard): + hazard.queue_free() + for spark in _sparks(): + spark.queue_free() + fleet.spawn_ships(1, Vector2.ZERO, PackedStringArray([GameState.VESSEL_LASER])) + laser._process(0.0) + stage = 1 + elapsed = 0.0 + return false + 1: + if elapsed < 0.1: + return false + var shooter: Node2D = laser._next_ready_laser_ship() + if shooter == null: + return _fail("no laser ship was ready to fire") + + # D. The target sits at the very end of the beam's reach, in the + # direction it will be fired. If the shot only damaged what the + # drawn blade had swept, this would survive the frame. + var reach: float = shooter.get_component("laser").beam_range + far_enemy = _spawn_enemy(shooter.global_position + Vector2(reach - 40.0, 0.0)) + far_health = far_enemy.hp + laser.fire_at(shooter.global_position + Vector2(reach, 0.0)) + + if laser.beams.is_empty(): + return _fail("firing produced no beam at all") + if laser.last_hit_count < 1: + return _fail("the target at the far end of the beam was not hit on the frame of the shot") + # It may have died outright, which is still a hit; if it lived it + # must have lost hit points. + if is_instance_valid(far_enemy) and far_enemy.hp >= far_health: + return _fail("the far target survived the shot untouched") + print("full-range target hit on the firing frame: %d hp -> %s" % [ + far_health, + str(far_enemy.hp) if is_instance_valid(far_enemy) else "destroyed", + ]) + + # A. and the picture has NOT got there yet on that same frame. + var first: float = laser.beam_extension(laser.beams[0]) + if first >= 0.5: + return _fail("the beam was already %.0f%% extended on the frame it was fired" \ + % [first * 100.0]) + extensions.append(first) + + # E. sparks off the hull, in the beam's colour. + var sparks: Array = _sparks() + if sparks.is_empty(): + return _fail("firing threw no sparks off the hull") + if sparks[0].color != laser.BEAM_COLOR: + return _fail("the muzzle sparks are %s, not the beam's own %s" \ + % [sparks[0].color, laser.BEAM_COLOR]) + print("hull sparks in the beam's colour: %s" % sparks[0].color) + + stage = 2 + elapsed = 0.0 + return false + 2: + # B/C. Sample the extension across the ignition. + if laser.beams.is_empty(): + if extensions.size() < 3: + return _fail("the flash was over after only %d frames - nothing to animate" \ + % extensions.size()) + if extensions[extensions.size() - 1] < 0.999: + return _fail("the beam never reached its full length (stopped at %.2f)" \ + % extensions[extensions.size() - 1]) + print("beam extended over %d frames: %.2f -> %.2f, monotonically" % [ + extensions.size(), extensions[0], extensions[extensions.size() - 1]]) + print("TEST_LASER_ANIMATION: PASS") + quit(0) + return true + var now: float = laser.beam_extension(laser.beams[0]) + if now < extensions[extensions.size() - 1] - 0.001: + return _fail("the beam retracted: %.3f after %.3f" \ + % [now, extensions[extensions.size() - 1]]) + extensions.append(now) + if elapsed > 3.0: + return _fail("the beam flash never cleared") + return false + return false + + +func _fail(message: String) -> bool: + print("TEST_LASER_ANIMATION: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_laser_animation.gd.uid b/tests/test_laser_animation.gd.uid new file mode 100644 index 0000000..39ca389 --- /dev/null +++ b/tests/test_laser_animation.gd.uid @@ -0,0 +1 @@ +uid://6v17x47nyfya diff --git a/vfx/MuzzleSparkVFX.gd b/vfx/MuzzleSparkVFX.gd index a51a0ce..6a0b3b7 100644 --- a/vfx/MuzzleSparkVFX.gd +++ b/vfx/MuzzleSparkVFX.gd @@ -23,15 +23,21 @@ const LENGTH: float = 9.0 const COLOR: Color = Color(1.0, 0.88, 0.42) var age: float = 0.0 +var color: Color = COLOR var sparks: Array = [] -static func spawn(parent: Node, world_position: Vector2, direction: Vector2) -> void: +# The colour is optional and defaults to the gun's own: #90 wanted the laser +# to do something at the hull too, and a cyan weapon throwing gold sparks +# would read as a different weapon firing from the same ship. +static func spawn(parent: Node, world_position: Vector2, direction: Vector2, + spark_color: Color = COLOR) -> void: if direction.length() < 0.001: return var vfx: Node2D = (load(SELF_PATH) as GDScript).new() vfx.position = world_position vfx.rotation = direction.angle() + vfx.color = spark_color parent.add_child(vfx) @@ -63,5 +69,5 @@ func _draw() -> void: var heading: Vector2 = Vector2.RIGHT.rotated(float(spark.angle)) var start: Vector2 = heading * float(spark.speed) * age var end: Vector2 = start + heading * float(spark.length) - NeonDraw.draw_glowing_line(self, start, end, COLOR, fade, 0.35, - COLOR.darkened(0.7)) + NeonDraw.draw_glowing_line(self, start, end, color, fade, 0.35, + color.darkened(0.7))