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