Stacking distorders, menu layouts, lab rewards, enemy detail, artillery cross
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>
This commit is contained in:
parent
a3c6316676
commit
7817d1ffc6
48 changed files with 897 additions and 159 deletions
|
|
@ -1,5 +1,9 @@
|
|||
extends Node2D
|
||||
|
||||
# Preview, not a test - an artillery shell in flight with the cross marking
|
||||
# where it will land. Named "telegraph" from when the tower charged up
|
||||
# before firing; the warning is now the shell itself plus that cross.
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
GameState.planet_name = "Violet Tempest"
|
||||
|
|
@ -11,10 +15,15 @@ func _ready() -> void:
|
|||
add_child(surface)
|
||||
|
||||
var shell: Node = load("res://ArtilleryShell.tscn").instantiate()
|
||||
shell.telegraph_time = 10.0 # frozen mid-countdown for the screenshot
|
||||
shell.aoe_radius = 220.0
|
||||
surface.add_child(shell)
|
||||
# Frozen with the shell part-way to its target, which is the moment the
|
||||
# warning has to work: the shell is visibly in the air and the cross
|
||||
# marks where it will land. There is no charge-up phase to capture any
|
||||
# more - the tower fires straight away.
|
||||
shell.launch(Vector2(-600, -600), Vector2.ZERO)
|
||||
shell.telegraph_left = shell.telegraph_time * 0.45
|
||||
shell.shell_pos = Vector2(-260, -260)
|
||||
shell.position = shell.shell_pos
|
||||
shell.trail = PackedVector2Array([Vector2(-600, -600), Vector2(-430, -430), shell.shell_pos])
|
||||
shell.set_process(false)
|
||||
shell.queue_redraw()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
extends Node2D
|
||||
|
||||
# Preview, not a test - an artillery shell in flight with the cross marking
|
||||
# where it will land. Named "telegraph" from when the tower charged up
|
||||
# before firing; the warning is now the shell itself plus that cross.
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
GameState.planet_name = "Kepler's Rest"
|
||||
|
|
@ -11,10 +15,15 @@ func _ready() -> void:
|
|||
add_child(surface)
|
||||
|
||||
var shell: Node = load("res://ArtilleryShell.tscn").instantiate()
|
||||
shell.telegraph_time = 10.0 # frozen mid-countdown for the screenshot
|
||||
shell.aoe_radius = 220.0
|
||||
surface.add_child(shell)
|
||||
# Frozen with the shell part-way to its target, which is the moment the
|
||||
# warning has to work: the shell is visibly in the air and the cross
|
||||
# marks where it will land. There is no charge-up phase to capture any
|
||||
# more - the tower fires straight away.
|
||||
shell.launch(Vector2(-600, -600), Vector2.ZERO)
|
||||
shell.telegraph_left = shell.telegraph_time * 0.45
|
||||
shell.shell_pos = Vector2(-260, -260)
|
||||
shell.position = shell.shell_pos
|
||||
shell.trail = PackedVector2Array([Vector2(-600, -600), Vector2(-430, -430), shell.shell_pos])
|
||||
shell.set_process(false)
|
||||
shell.queue_redraw()
|
||||
|
|
|
|||
29
tests/_enemy_gallery_preview.gd
Normal file
29
tests/_enemy_gallery_preview.gd
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
extends Node2D
|
||||
|
||||
# Preview, not a test - every enemy hull side by side, so their silhouettes
|
||||
# can be compared at a glance. The chaser is included as the reference the
|
||||
# others are meant to be more distinctive than.
|
||||
# bash tests/screenshot.sh res://tests/_enemy_gallery_preview.tscn /tmp/enemies.png 15 720x1280
|
||||
|
||||
const ENEMY_DEFINITIONS = preload("res://EnemyDefinitions.gd")
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var enemy_scene: PackedScene = load("res://Enemy.tscn")
|
||||
var ids: Array = ["chaser", "sniper", "artillery", "turret_spawner", "enemy_mothership"]
|
||||
var y: float = 200.0
|
||||
for id in ids:
|
||||
var definition: Resource = ENEMY_DEFINITIONS.get_definition(id)
|
||||
if definition == null:
|
||||
continue
|
||||
var enemy: Node2D = enemy_scene.instantiate()
|
||||
enemy.enemy_id = id
|
||||
enemy.position = Vector2(230.0, y)
|
||||
add_child(enemy)
|
||||
var label := Label.new()
|
||||
label.text = definition.display_name
|
||||
label.position = Vector2(330.0, y - 20.0)
|
||||
label.add_theme_font_size_override("font_size", 30)
|
||||
label.add_theme_color_override("font_color", definition.glow_color)
|
||||
add_child(label)
|
||||
y += 200.0
|
||||
1
tests/_enemy_gallery_preview.gd.uid
Normal file
1
tests/_enemy_gallery_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dn5mgaf76i0m3
|
||||
6
tests/_enemy_gallery_preview.tscn
Normal file
6
tests/_enemy_gallery_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/_enemy_gallery_preview.gd" id="1"]
|
||||
|
||||
[node name="EnemyGalleryPreview" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
14
tests/_research_lab_surface_preview.gd
Normal file
14
tests/_research_lab_surface_preview.gd
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
extends Node2D
|
||||
|
||||
# Preview, not a test - the enemy research lab's own backdrop.
|
||||
# bash tests/screenshot.sh res://tests/_research_lab_surface_preview.tscn /tmp/lab.png 20 720x1280
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
GameState.planet_name = "Vault Nine"
|
||||
GameState.planet_definition_id = "research_lab"
|
||||
GameState.surviving_ship_count = 3
|
||||
GameState.rocks = 128
|
||||
var surface: Node = load("res://PlanetSurface.tscn").instantiate()
|
||||
surface.get_node("SurfaceController").fade_duration = 0.0
|
||||
add_child(surface)
|
||||
1
tests/_research_lab_surface_preview.gd.uid
Normal file
1
tests/_research_lab_surface_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://b330nrwpm5003
|
||||
6
tests/_research_lab_surface_preview.tscn
Normal file
6
tests/_research_lab_surface_preview.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/_research_lab_surface_preview.gd" id="1"]
|
||||
|
||||
[node name="ResearchLabSurfacePreview" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
29
tests/_web_play_probe.gd
Normal file
29
tests/_web_play_probe.gd
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
extends Node2D
|
||||
|
||||
# Probe, not a test - presses Play on the main menu under the REAL
|
||||
# gl_compatibility renderer, which is the same path the web export takes
|
||||
# (WebGL2 == GLES3 compatibility). --headless uses a dummy renderer and so
|
||||
# cannot surface a failure that only happens once something actually has to
|
||||
# be rendered or a shader has to compile.
|
||||
#
|
||||
# bash tests/screenshot.sh res://tests/_web_play_probe.tscn /tmp/play.png 45 720x1280
|
||||
|
||||
var menu: Node
|
||||
var frames: int = 0
|
||||
var pressed: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
menu = load("res://MainMenu.tscn").instantiate()
|
||||
add_child(menu)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
frames += 1
|
||||
if frames == 8 and not pressed:
|
||||
pressed = true
|
||||
print("PLAY_PROBE: pressing Play")
|
||||
menu._on_play_pressed()
|
||||
if frames == 30:
|
||||
var scene: Node = get_tree().current_scene
|
||||
print("PLAY_PROBE: current_scene=%s" % [scene.name if scene != null else "<null>"])
|
||||
1
tests/_web_play_probe.gd.uid
Normal file
1
tests/_web_play_probe.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dtudtqerivj84
|
||||
6
tests/_web_play_probe.tscn
Normal file
6
tests/_web_play_probe.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/_web_play_probe.gd" id="1"]
|
||||
|
||||
[node name="WebPlayProbe" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
|
|
@ -3,8 +3,9 @@ extends SceneTree
|
|||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_artillery_enemy.gd
|
||||
# Verifies:
|
||||
# 1. the telegraph is visible (phase == TELEGRAPHING) for the whole
|
||||
# window before any damage lands
|
||||
# 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
|
||||
|
|
@ -34,6 +35,7 @@ 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
|
||||
|
|
@ -79,10 +81,9 @@ func _process(delta: float) -> bool:
|
|||
shield_ship = fleet.ships[fleet.ships.size() - 1]
|
||||
shield_ship.position = impact_point + Vector2(-100, 0)
|
||||
|
||||
var origin: Vector2 = impact_point + Vector2(400, 400)
|
||||
origin = impact_point + Vector2(400, 400)
|
||||
travel_time = origin.distance_to(impact_point) / 300.0
|
||||
shell = load("res://ArtilleryShell.tscn").instantiate()
|
||||
shell.telegraph_time = 1.0
|
||||
shell.shell_speed = 300.0
|
||||
shell.aoe_radius = 220.0
|
||||
shell.damage = 3
|
||||
|
|
@ -93,28 +94,40 @@ func _process(delta: float) -> bool:
|
|||
stage = 1
|
||||
return false
|
||||
1:
|
||||
# Mid-telegraph: nobody hurt yet, still visibly telegraphing.
|
||||
if elapsed < shell.telegraph_time * 0.8:
|
||||
# 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.phase != ARTILLERY_SHELL_SCRIPT.Phase.TELEGRAPHING:
|
||||
return _fail("shell left the telegraph phase too early")
|
||||
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 telegraph window finished")
|
||||
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 re-reading shell.telegraph_time from inside
|
||||
# the wait condition below (evaluated every frame) would touch
|
||||
# a freed object on any frame after that.
|
||||
stage2_wait = shell.telegraph_time * 0.2 + travel_time + 1.5
|
||||
# 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 telegraph, travel, and the full
|
||||
# explosion visual to resolve.
|
||||
# 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):
|
||||
|
|
@ -138,7 +151,6 @@ func _process(delta: float) -> bool:
|
|||
main.add_child(artillery)
|
||||
art_component = artillery.get_component("artillery")
|
||||
art_component.cooldown_left = 0.0
|
||||
art_component.telegraph_time = 0.3
|
||||
art_component.fire_cooldown = 999.0 # only the one shot for this check
|
||||
elapsed = 0.0
|
||||
stage = 3
|
||||
|
|
@ -173,7 +185,9 @@ func _process(delta: float) -> bool:
|
|||
# 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.
|
||||
stage5_wait = art_component.telegraph_time + (300.0 / 180.0) + 1.5
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -131,8 +131,8 @@ func _process(delta: float) -> bool:
|
|||
return _fail("the dev mode button's visibility did not follow is_developer_build()")
|
||||
if menu.editor_button.visible != developing:
|
||||
return _fail("the map editor button's visibility did not follow is_developer_build()")
|
||||
if menu.play_button.visible != true or menu.quit_button.visible != (not OS.has_feature("web")):
|
||||
return _fail("gating the development entries also hid an entry a player needs")
|
||||
if not menu.play_button.visible:
|
||||
return _fail("gating the development entries also hid Play")
|
||||
# Hiding is presentation; pressing it anyway must still be refused.
|
||||
if not developing:
|
||||
menu._on_dev_mode_pressed()
|
||||
|
|
|
|||
1
tests/test_dev_mode.gd.uid
Normal file
1
tests/test_dev_mode.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://xmhkba108cv6
|
||||
135
tests/test_distorder_stacking.gd
Normal file
135
tests/test_distorder_stacking.gd
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_distorder_stacking.gd
|
||||
# Each space-time distorder vessel PAST THE FIRST raises the fleet's speed
|
||||
# ceiling by 1.0, so a second one is worth crewing rather than dead weight.
|
||||
#
|
||||
# Covers:
|
||||
# A. one vessel gives the original ceiling, so nothing changed for a fleet
|
||||
# that carries a single distorder
|
||||
# B. each additional vessel adds exactly 1.0
|
||||
# C. the fader's full travel reaches the WIDENED ceiling, not the old one
|
||||
# D. losing a vessel mid-boost pulls the fleet back down to the new
|
||||
# ceiling rather than leaving it faster than its crew can sustain
|
||||
# E. full boost still costs the same energy per second however wide the
|
||||
# ceiling is - more vessels buy more speed, not cheaper speed
|
||||
|
||||
var game_state: Node
|
||||
var main: Node
|
||||
var fleet: Node
|
||||
var fader: Node
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.reset_progression()
|
||||
game_state.vessel_levels["unlock_distorder"] = 1
|
||||
game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 500)
|
||||
main = load("res://Main.tscn").instantiate()
|
||||
root.add_child(main)
|
||||
fleet = main.get_node("Fleet")
|
||||
fader = main.get_node("UI/SpeedFader")
|
||||
|
||||
|
||||
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)
|
||||
# Room for a fleet of distorders.
|
||||
game_state.unlocked.append_array(["wing", "squadron", "armada"])
|
||||
|
||||
# --- A. one vessel is unchanged ------------------------------
|
||||
fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([
|
||||
GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_DISTORDER]))
|
||||
if not is_equal_approx(game_state.max_fleet_speed_multiplier, GameState.MAX_FLEET_SPEED_MULTIPLIER):
|
||||
return _fail("one distorder gives a ceiling of %.1f, expected the unchanged %.1f" \
|
||||
% [game_state.max_fleet_speed_multiplier, GameState.MAX_FLEET_SPEED_MULTIPLIER])
|
||||
print("1 distorder -> ceiling %.1fx" % game_state.max_fleet_speed_multiplier)
|
||||
|
||||
# --- B. each additional vessel adds 1.0 ----------------------
|
||||
for extra in range(1, 4):
|
||||
fleet.add_ship(GameState.VESSEL_DISTORDER)
|
||||
var expected: float = GameState.MAX_FLEET_SPEED_MULTIPLIER + float(extra)
|
||||
if not is_equal_approx(game_state.max_fleet_speed_multiplier, expected):
|
||||
return _fail("%d distorders give a ceiling of %.1f, expected %.1f" \
|
||||
% [extra + 1, game_state.max_fleet_speed_multiplier, expected])
|
||||
print("%d distorders -> ceiling %.1fx" % [extra + 1, game_state.max_fleet_speed_multiplier])
|
||||
|
||||
# --- C. the fader reaches the widened ceiling ----------------
|
||||
# Dragging the track to its top must mean the CURRENT ceiling, or
|
||||
# the extra vessels would be unreachable through the only control
|
||||
# that exists for them.
|
||||
fader._process(0.0)
|
||||
if not fader.visible:
|
||||
return _fail("the fader is hidden with four distorder vessels crewed")
|
||||
var press := InputEventScreenTouch.new()
|
||||
press.index = 3
|
||||
press.position = fader.global_position + Vector2(fader.size.x * 0.5, fader.size.y - 4.0)
|
||||
press.pressed = true
|
||||
main._handle_touch(press)
|
||||
var drag := InputEventScreenDrag.new()
|
||||
drag.index = 3
|
||||
drag.position = fader.global_position + Vector2(fader.size.x * 0.5, 0.0)
|
||||
main._handle_drag(drag)
|
||||
if not is_equal_approx(game_state.fleet_speed_multiplier, game_state.max_fleet_speed_multiplier):
|
||||
return _fail("dragging the fader to the top reached %.2fx, not the ceiling %.2fx" \
|
||||
% [game_state.fleet_speed_multiplier, game_state.max_fleet_speed_multiplier])
|
||||
print("fader top reaches %.1fx" % game_state.fleet_speed_multiplier)
|
||||
|
||||
# --- E. full boost costs the same per second -----------------
|
||||
# The drain is normalized against the current ceiling, so sitting
|
||||
# at full boost is the same price whatever the ceiling is.
|
||||
var wide_energy: int = _energy_spent_over(1.0)
|
||||
|
||||
# --- D. losing vessels pulls the boost back down -------------
|
||||
var boosted_before: float = game_state.fleet_speed_multiplier
|
||||
for i in range(fleet.ships.size() - 1, -1, -1):
|
||||
if is_instance_valid(fleet.ships[i]) and fleet.ships[i].vessel_type == GameState.VESSEL_DISTORDER:
|
||||
fleet.store_ship(i)
|
||||
break
|
||||
if game_state.max_fleet_speed_multiplier >= boosted_before:
|
||||
return _fail("losing a distorder left the ceiling at %.1f, no lower than the %.1f the fleet was already doing" \
|
||||
% [game_state.max_fleet_speed_multiplier, boosted_before])
|
||||
if game_state.fleet_speed_multiplier > game_state.max_fleet_speed_multiplier + 0.001:
|
||||
return _fail("the fleet is still doing %.2fx after its ceiling dropped to %.2fx" \
|
||||
% [game_state.fleet_speed_multiplier, game_state.max_fleet_speed_multiplier])
|
||||
print("losing one pulled %.1fx down to the new %.1fx ceiling" \
|
||||
% [boosted_before, game_state.fleet_speed_multiplier])
|
||||
|
||||
# Same measurement at the narrower ceiling, also at full boost.
|
||||
game_state.set_fleet_speed_multiplier(game_state.max_fleet_speed_multiplier)
|
||||
var narrow_energy: int = _energy_spent_over(1.0)
|
||||
if absi(wide_energy - narrow_energy) > 1:
|
||||
return _fail("full boost cost %d energy with a wide ceiling and %d with a narrow one - extra vessels changed the PRICE, not just the speed" \
|
||||
% [wide_energy, narrow_energy])
|
||||
print("full boost costs %d/%d energy per second at either ceiling" % [wide_energy, narrow_energy])
|
||||
|
||||
print("TEST_DISTORDER_STACKING: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
# Energy actually consumed by holding the current multiplier for `seconds`,
|
||||
# driven directly so the measurement does not depend on frame pacing.
|
||||
func _energy_spent_over(seconds: float) -> int:
|
||||
var before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY)
|
||||
var step: float = 0.05
|
||||
var t: float = 0.0
|
||||
while t < seconds:
|
||||
game_state._advance_distorder_drain(step)
|
||||
t += step
|
||||
return before - game_state.fleet_resource(GameState.RESOURCE_ENERGY)
|
||||
|
||||
|
||||
func _fail(message: String) -> bool:
|
||||
print("TEST_DISTORDER_STACKING: FAIL (%s)" % message)
|
||||
quit(1)
|
||||
return true
|
||||
1
tests/test_distorder_stacking.gd.uid
Normal file
1
tests/test_distorder_stacking.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cftfhg1hdb5c4
|
||||
1
tests/test_fleet_growth.gd.uid
Normal file
1
tests/test_fleet_growth.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://hbmlomqcx20q
|
||||
115
tests/test_lab_research_reward.gd
Normal file
115
tests/test_lab_research_reward.gd
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless test: run with
|
||||
# godot --headless -s res://tests/test_lab_research_reward.gd
|
||||
# A harder enemy research lab should be 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. So the
|
||||
# reward is a per-map value set in the map editor, falling back to the
|
||||
# planet type's flat rate when a setup does not express an opinion.
|
||||
#
|
||||
# Covers:
|
||||
# A. a setup carries its own reward and it survives a save/load round trip
|
||||
# B. a map's reward overrides the planet type's flat rate on conquest
|
||||
# C. a setup that says nothing (0) still gets the type's rate, so every
|
||||
# map authored before this existed keeps its old value
|
||||
# D. the editor's control cycles the value and reports it
|
||||
|
||||
var game_state: Node
|
||||
var editor: Node
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.reset_new_game()
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
# --- A. the field exists and round-trips ---------------------
|
||||
var setup: Resource = load("res://PlanetSetup.gd").new()
|
||||
setup.setup_id = "reward_probe"
|
||||
setup.display_name = "Reward Probe"
|
||||
setup.surface_profile_id = "rocky"
|
||||
setup.capture_points = PackedVector2Array([Vector2(10, 10), Vector2(-10, -10)])
|
||||
setup.research_point_reward = 5
|
||||
var path: String = "user://test_reward_probe.tres"
|
||||
if ResourceSaver.save(setup, path) != OK:
|
||||
return _fail("could not save a setup carrying a research reward")
|
||||
var reloaded: Resource = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
|
||||
if reloaded == null or int(reloaded.research_point_reward) != 5:
|
||||
return _fail("the reward did not survive a save/load round trip")
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
|
||||
print("a setup carries its own reward (%d) across a save/load" % reloaded.research_point_reward)
|
||||
|
||||
# --- B. the map's reward beats the planet type's rate --------
|
||||
# A research_lab planet's own definition pays a flat 1.
|
||||
var flat_rate: int = int(load("res://ResearchLabPlanetDefinition.tres").conquest_research_points)
|
||||
game_state.research_points = 0
|
||||
game_state.register_capture_points("hard_lab", 2, "research_lab", 6)
|
||||
game_state.mark_point_captured("hard_lab", 0)
|
||||
game_state.mark_point_captured("hard_lab", 1)
|
||||
if game_state.research_points != 6:
|
||||
return _fail("a hard lab authored at 6 points awarded %d" % game_state.research_points)
|
||||
if flat_rate >= 6:
|
||||
return _fail("the planet type's flat rate (%d) is not below the authored one - this test proves nothing" % flat_rate)
|
||||
print("a hard lab authored at 6 pays 6, over the planet type's flat %d" % flat_rate)
|
||||
|
||||
# --- C. silence still means the type's rate ------------------
|
||||
game_state.research_points = 0
|
||||
game_state.register_capture_points("plain_lab", 1, "research_lab", 0)
|
||||
game_state.mark_point_captured("plain_lab", 0)
|
||||
if game_state.research_points != flat_rate:
|
||||
return _fail("a setup with no authored reward paid %d, expected the type's %d" \
|
||||
% [game_state.research_points, flat_rate])
|
||||
print("a setup with no opinion still pays the planet type's %d" % flat_rate)
|
||||
|
||||
# A harder map must never be worth LESS than an easier one.
|
||||
if 6 <= flat_rate:
|
||||
return _fail("authoring a harder lab did not raise its worth")
|
||||
|
||||
editor = load("res://PlanetSetupEditor.tscn").instantiate()
|
||||
root.add_child(editor)
|
||||
current_scene = editor
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
# --- D. the editor can set it --------------------------------
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
editor.new_setup("reward_edit", "Reward Edit", "rocky")
|
||||
var seen: Array = []
|
||||
for i in range(editor.REWARD_OPTIONS.size()):
|
||||
seen.append(int(editor.current_setup.research_point_reward))
|
||||
editor._on_reward_pressed()
|
||||
# Cycling the whole list must come back round to where it began.
|
||||
if int(editor.current_setup.research_point_reward) != seen[0]:
|
||||
return _fail("cycling every reward option did not return to the start")
|
||||
if seen.size() != editor.REWARD_OPTIONS.size() or seen.max() <= 0:
|
||||
return _fail("the reward control did not offer a range of values: %s" % [seen])
|
||||
editor.current_setup.research_point_reward = 3
|
||||
editor._refresh_reward_label()
|
||||
if not "3" in editor.reward_value_label.text:
|
||||
return _fail("the editor does not report the chosen reward: '%s'" % editor.reward_value_label.text)
|
||||
editor.current_setup.research_point_reward = 0
|
||||
editor._refresh_reward_label()
|
||||
if not "DEFAULT" in editor.reward_value_label.text:
|
||||
return _fail("the editor does not distinguish 'use the planet type's rate': '%s'" \
|
||||
% editor.reward_value_label.text)
|
||||
print("the map editor cycles %s and reports the choice" % [seen])
|
||||
|
||||
print("TEST_LAB_RESEARCH_REWARD: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _fail(message: String) -> bool:
|
||||
print("TEST_LAB_RESEARCH_REWARD: FAIL (%s)" % message)
|
||||
quit(1)
|
||||
return true
|
||||
1
tests/test_lab_research_reward.gd.uid
Normal file
1
tests/test_lab_research_reward.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cka4wugwoaway
|
||||
|
|
@ -5,9 +5,8 @@ extends SceneTree
|
|||
# Verifies:
|
||||
# 1. the menu loads with Play/Map Editor/Quit; Map Editor is enabled and
|
||||
# reaches PlanetSetupEditor.tscn (#48)
|
||||
# 2. Quit's visibility follows OS.has_feature("web") (this platform is
|
||||
# not a web export, so this run exercises the "visible" branch; the
|
||||
# condition itself is what's under test, not the concrete boolean)
|
||||
# 2. there is no Quit entry in any build - a web build has nothing to quit
|
||||
# to, and Android's back gesture already does it
|
||||
# 3. Play resets GameState to a fresh game AND reaches Main.tscn
|
||||
|
||||
var menu: Node
|
||||
|
|
@ -43,8 +42,8 @@ func _process(delta: float) -> bool:
|
|||
return _fail("map editor entry visibility did not follow is_developer_build()")
|
||||
if menu.dev_button.visible != developing or menu.dev_label.visible != developing:
|
||||
return _fail("dev mode entry visibility did not follow is_developer_build()")
|
||||
if menu.quit_button.visible != (not OS.has_feature("web")):
|
||||
return _fail("quit button visibility did not follow OS.has_feature('web')")
|
||||
if menu.has_node("UI/QuitButton") or menu.has_node("UI/QuitLabel"):
|
||||
return _fail("the menu still carries a Quit entry")
|
||||
|
||||
_press(menu.editor_button)
|
||||
elapsed = 0.0
|
||||
|
|
|
|||
Loading…
Reference in a new issue