DISTORDER STACKING. Each space-time distorder vessel past the first raises the fleet's speed ceiling by 1.0, so a second one is worth crewing instead of being dead weight. GameState gains max_fleet_speed_multiplier, pushed by Fleet whenever composition changes - the same direction as every other fleet-derived figure, since GameState does not reach into the scene tree to count ships. The energy drain is normalised against the CURRENT ceiling, so full boost costs the same per second however wide it is: extra vessels buy more speed, not cheaper speed. Losing one re-clamps an over-ceiling boost rather than leaving the fleet travelling faster than its crew can sustain. TRANSFER MENU. Rows overlapped, and the last one ran into the hint text. row_height is divided from the available space, but each row's contents used a fixed pixel ladder - glyph at +10, buttons at +88, selector at +142 - which needs about 244px. Three resources fit; the fourth cut rows to 180 and every one spilled 64px into the next. Every offset is now a fraction of the row, so it cannot overflow. Also dropped the per-row AMOUNT caption (four copies of a word the footer hint already explains) and moved each glyph and name to the TOP of its row, where they read as its heading rather than the next row's. HANGAR. Six vessel types in one row gave each about 100px for a 155px label, so the names ran together. Now a 3x2 grid per section, sized from the space between the first heading and the status line, using the empty lower half. LAB RESEARCH REWARD. A harder enemy lab is worth more research points, and how hard a lab is depends on the layout authored for it - how many capture points, how many defenders - which the planet TYPE cannot know. PlanetSetup carries research_point_reward, set in the map editor, overriding the planet type's flat rate. 0 means "no opinion", so every map authored before this keeps its old value. RESEARCH LAB BACKGROUND. Its own shader instead of the crater field with a red tint, which read as "rocky planet, tinted" rather than someone's facility: plated floor, seams, service conduits, lit junctions. Loop-free by construction (#68's lesson applied up front) - conduits stay continuous across cells by keying a horizontal run to cell.y and a vertical to cell.x, so neighbouring cells agree without any cell scanning its neighbours. ENEMY DETAIL. Every enemy was a single flat outline, which left the artillery and the turret spawner as the same octagon at two sizes - the silhouette carried no information about what the thing did. EnemyDefinition gains detail_lines and detail_rings as DATA, so adding an enemy still means adding a definition rather than editing Enemy.gd, and the four non-default hulls are redrawn: a long-barrelled sniper, a squat mortar, a hive with launch bays, a capital ship with spine and ribs. Three things drew an enemy from its definition - the enemy, the map editor's markers, and the palette buttons - each reimplementing it from hull_points alone, so the new detail appeared in the game and nowhere else and an editor marker stopped matching what it placed. They now share EnemyDefinitions.draw_definition(). ARTILLERY. The 1.8s charge-up before the tower fired arrived long before the threat was legible and mostly read as the tower being stuck. It fires immediately now; the warning is the shell you can see in the air plus a small cross on the ground at the impact point. Deliberately not a blast-radius ring - a large translucent area was easy to read as the explosion having already happened, where the cross says "move off this spot". MAIN MENU. No Quit in any build (a web build has nothing to quit to, and Android's back gesture already does it), a plain black background, and the game is "Spaceship" - in the title and in config/name, which is what drives the browser tab for the web export. change_scene_to_file() returns an error and otherwise fails silently, leaving the menu up, which is indistinguishable from an unpressed button; it is now checked and reported on screen. Full suite green at 99/99. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
209 lines
8 KiB
GDScript
209 lines
8 KiB
GDScript
extends SceneTree
|
|
|
|
# Headless gameplay test: run with
|
|
# godot --headless -s res://tests/test_artillery_enemy.gd
|
|
# Verifies:
|
|
# 1. the tower fires IMMEDIATELY - the shell is in the air on the frame it
|
|
# is launched - and the warning is the shell's flight plus the impact
|
|
# cross drawn at its target, not a charge-up before anything happens
|
|
# 2. a ship that leaves the blast radius before impact takes no damage;
|
|
# one that stays takes the full hit
|
|
# 3. a shielded ship inside the blast survives, losing exactly one charge
|
|
# 4. the impact point is fixed at fire time - moving the fleet after the
|
|
# shot is launched does not redirect it (tested through the real
|
|
# EnemyArtilleryComponent, since that's what could tempt "aim at the
|
|
# fleet's live position" instead of its position at fire time)
|
|
|
|
# Resolved via load() inside _initialize(), NOT a top-level `const ...
|
|
# preload(...)` - a top-level preload in a script-mode ENTRY point (-s
|
|
# this_file.gd) evaluates during this file's own early compilation, which
|
|
# can happen before autoloads are registered. See test_capture_points.gd
|
|
# for the full explanation.
|
|
var ARTILLERY_SHELL_SCRIPT: Script
|
|
|
|
var game_state: Node
|
|
var main: Node
|
|
var fleet: Node
|
|
var shell: Node
|
|
var ship_inside: Node
|
|
var ship_leaves: Node
|
|
var shield_ship: Node
|
|
var far_ship: Node
|
|
var artillery: Node
|
|
var art_component: Node
|
|
var live_shell: Node
|
|
var recorded_target: Vector2 = Vector2.ZERO
|
|
var impact_point: Vector2 = Vector2.ZERO
|
|
var travel_time: float = 0.0
|
|
var origin: Vector2 = Vector2.ZERO
|
|
var stage2_wait: float = 0.0
|
|
var stage5_wait: float = 0.0
|
|
var stage: int = 0
|
|
var elapsed: float = 0.0
|
|
|
|
|
|
func _initialize() -> void:
|
|
ARTILLERY_SHELL_SCRIPT = load("res://ArtilleryShell.gd")
|
|
game_state = root.get_node("GameState")
|
|
game_state.buildings.clear()
|
|
game_state.extractors.clear()
|
|
game_state.transporters.clear()
|
|
game_state.stored_vessels.clear()
|
|
game_state.build_time_scale = 0.02
|
|
game_state.reset_progression()
|
|
game_state.rocks = 2000
|
|
main = load("res://Main.tscn").instantiate()
|
|
root.add_child(main)
|
|
fleet = main.get_node("Fleet")
|
|
|
|
|
|
func _process(delta: float) -> bool:
|
|
elapsed += delta
|
|
match stage:
|
|
0:
|
|
if elapsed < 0.1:
|
|
return false
|
|
main.get_node("EnemySpawner").set_process(false)
|
|
main.get_node("AsteroidField").set_process(false)
|
|
for a in get_nodes_in_group("asteroids"):
|
|
a.queue_free()
|
|
|
|
impact_point = Vector2(5000, 0)
|
|
fleet.spawn_ships(1, impact_point)
|
|
ship_inside = fleet.ships[0]
|
|
ship_inside.position = impact_point
|
|
|
|
fleet.add_ship(GameState.VESSEL_INTERCEPTOR)
|
|
ship_leaves = fleet.ships[fleet.ships.size() - 1]
|
|
ship_leaves.position = impact_point + Vector2(100, 0)
|
|
|
|
fleet.add_ship(GameState.VESSEL_SHIELD)
|
|
shield_ship = fleet.ships[fleet.ships.size() - 1]
|
|
shield_ship.position = impact_point + Vector2(-100, 0)
|
|
|
|
origin = impact_point + Vector2(400, 400)
|
|
travel_time = origin.distance_to(impact_point) / 300.0
|
|
shell = load("res://ArtilleryShell.tscn").instantiate()
|
|
shell.shell_speed = 300.0
|
|
shell.aoe_radius = 220.0
|
|
shell.damage = 3
|
|
main.add_child(shell)
|
|
shell.launch(origin, impact_point)
|
|
|
|
elapsed = 0.0
|
|
stage = 1
|
|
return false
|
|
1:
|
|
# The shell must already be travelling - there is no charge-up
|
|
# phase to sit in any more - and it must have moved off the
|
|
# tower under its own speed.
|
|
if shell.phase != ARTILLERY_SHELL_SCRIPT.Phase.TRAVELING:
|
|
return _fail("the shell is not in the air on the frame after firing")
|
|
if elapsed < travel_time * 0.4:
|
|
return false
|
|
if shell.shell_pos.distance_to(shell.target) >= origin.distance_to(impact_point):
|
|
return _fail("the shell has not closed any distance on its target")
|
|
# The warning the player actually gets: a cross on the ground at
|
|
# the point of impact. Drawing cannot be asserted headlessly, so
|
|
# this checks what it is drawn FROM - the aim point is fixed and
|
|
# the marker has a real size.
|
|
if not shell.target.is_equal_approx(impact_point):
|
|
return _fail("the impact marker would not be drawn at the point the shell was aimed at")
|
|
if ARTILLERY_SHELL_SCRIPT.IMPACT_CROSS_RADIUS <= 0.0:
|
|
return _fail("the impact cross has no size, so nothing marks the landing point")
|
|
if not is_instance_valid(ship_inside) or not is_instance_valid(ship_leaves) \
|
|
or not is_instance_valid(shield_ship):
|
|
return _fail("damage landed before the shell arrived")
|
|
|
|
# Ship B clears the blast radius now, well before impact.
|
|
ship_leaves.position = impact_point + Vector2(2000, 0)
|
|
# Captured once, as a plain float: the shell frees itself once
|
|
# it resolves, so reading off it from inside the wait condition
|
|
# below (evaluated every frame) would touch a freed object on
|
|
# any frame after that.
|
|
stage2_wait = travel_time + 1.5
|
|
elapsed = 0.0
|
|
stage = 2
|
|
return false
|
|
2:
|
|
# Enough time for the remaining travel and the full explosion
|
|
# visual to resolve.
|
|
if elapsed < stage2_wait:
|
|
return false
|
|
if is_instance_valid(ship_inside):
|
|
return _fail("a ship that stayed inside the blast radius survived")
|
|
if not is_instance_valid(ship_leaves):
|
|
return _fail("a ship that left the blast radius before impact was still hit")
|
|
if not is_instance_valid(shield_ship):
|
|
return _fail("a shielded ship inside the blast was destroyed instead of absorbing the hit")
|
|
if shield_ship.shield_charges != 0:
|
|
return _fail("shield charges did not drop to 0 after absorbing the artillery hit (charges=%d)" %
|
|
shield_ship.shield_charges)
|
|
print("stayed=destroyed left=survived shielded=absorbed-one-charge")
|
|
|
|
# --- Part B: the impact point does not home onto a fleet that
|
|
# moves after the shot is fired ---------------------------------
|
|
fleet.spawn_ships(1, Vector2(9000, 0))
|
|
far_ship = fleet.ships[0]
|
|
artillery = load("res://Enemy.tscn").instantiate()
|
|
artillery.enemy_id = "artillery"
|
|
artillery.position = far_ship.position + Vector2(300, 0)
|
|
main.add_child(artillery)
|
|
art_component = artillery.get_component("artillery")
|
|
art_component.cooldown_left = 0.0
|
|
art_component.fire_cooldown = 999.0 # only the one shot for this check
|
|
elapsed = 0.0
|
|
stage = 3
|
|
return false
|
|
3:
|
|
if elapsed < 0.15:
|
|
return false
|
|
# The shell is added to the tree root (a sibling of main, not
|
|
# its child - see EnemyArtilleryComponent.tick()), so it has to
|
|
# be found via its group, not main.get_children().
|
|
var shells: Array = get_nodes_in_group("artillery_shells")
|
|
if shells.is_empty():
|
|
return _fail("artillery enemy did not fire when in range and off cooldown")
|
|
live_shell = shells[0]
|
|
recorded_target = live_shell.target
|
|
|
|
far_ship.position += Vector2(4000, 0) # flee well clear of the recorded target
|
|
elapsed = 0.0
|
|
stage = 4
|
|
return false
|
|
4:
|
|
# Check the target field WHILE the shell is still alive - it
|
|
# frees itself right after exploding, so this has to happen
|
|
# before that, not after.
|
|
if elapsed < 0.3:
|
|
return false
|
|
if not is_instance_valid(live_shell):
|
|
return _fail("shell resolved implausibly fast - timing assumption broke")
|
|
if live_shell.target != recorded_target:
|
|
return _fail("shell target changed after the fleet moved - it homed instead of staying fixed")
|
|
# Captured once, as a plain float: far_ship's own weapon can
|
|
# destroy the artillery enemy (and with it art_component) well
|
|
# before this wait ends, so the condition below must not keep
|
|
# re-reading a property off a node that might already be freed.
|
|
# No charge-up to wait out any more - just flight time plus the
|
|
# explosion visual.
|
|
stage5_wait = (300.0 / 180.0) + 1.5
|
|
elapsed = 0.0
|
|
stage = 5
|
|
return false
|
|
5:
|
|
if elapsed < stage5_wait:
|
|
return false
|
|
if not is_instance_valid(far_ship):
|
|
return _fail("fleeing ship was hit by a shot aimed at its old position - homing occurred")
|
|
print("shell held its fire-time target and did not home onto the fleeing ship")
|
|
print("TEST_ARTILLERY_ENEMY: PASS")
|
|
quit(0)
|
|
return true
|
|
return false
|
|
|
|
|
|
func _fail(message: String) -> bool:
|
|
print("TEST_ARTILLERY_ENEMY: FAIL (%s)" % message)
|
|
quit(1)
|
|
return true
|