Share one backdrop between game and editor, and fix three input/economy bugs
The setup editor and the landed surface drew different backgrounds for the same planet. Each scene carried its own hand-written ShaderMaterial, and only the game side ever swapped in a profile's shader, so a gas giant authored in the editor showed recoloured craters while the game showed cloud bands. Both now instance one SurfaceBackground.tscn and configure it through one method, and every profile names its own shader - the old "null means keep whatever the scene already had" convention was the drift, since each scene "already had" something different. The shared material is resource_local_to_scene, or two live surfaces would repaint each other. The gas giant's bands broke into disjoint segments once per cell: the second turbulence octave doubled only the INTEGER half of a lattice coordinate, which is a different point in every cell rather than the same point at twice the frequency. Measured, the old form steps 0.153 at a cell boundary against 0.0002 now. A second discontinuity went with it - the camera cell index arrives pre-wrapped, so hashing it raw rearranged the whole sky every 32 cells; hashes are now wrapped per octave and the field is exactly periodic. Bands also gained low-frequency octaves and per-row differential drift. WorldMap swallowed every touch in _input(), which runs before the GUI system, so CloseButton never saw one: the map could be opened and not closed. Android and desktop hid it behind Godot's emulated mouse event, which this never swallowed; a phone browser gives no such second chance. Its test had been closing the map by calling the method the button was failing to reach. Construction now draws on the fleet hold as well as the bank. With an empty starting bank and ore bankable only at a mothership, charging construction to the bank alone made the first mothership cost ore that could not yet exist. Also: a conquered planet no longer re-spawns its garrison (guarded on having capture points, since a setup with none registers as vacuously conquered); planets keep the map they drew, with the candidate list sorted rather than left in filesystem order and assignments pruned rather than cleared on rescan; and the map editor lists setups at a tappable size and can delete one, as an explicit armed mode rather than a tap away from destroying work. Full suite 106/106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
137e4e528d
commit
f5a16a7b95
35 changed files with 1783 additions and 152 deletions
|
|
@ -8,6 +8,15 @@ extends SceneTree
|
|||
# this environment, softpipe's non-JIT rasterizer does not).
|
||||
#
|
||||
# Args (after `--`): <scene_res_path> <output_png_path> [wait_frames]
|
||||
# [key=value ...]
|
||||
#
|
||||
# Any trailing key=value pairs are assigned onto the GameState autoload
|
||||
# BEFORE the scene is instantiated. Several scenes read GameState in
|
||||
# _ready() to decide what they draw - PlanetSurface.tscn picks its whole
|
||||
# surface profile from planet_definition_id - so without this the tool can
|
||||
# only ever photograph the default planet:
|
||||
#
|
||||
# ... -- res://PlanetSurface.tscn out.png 90 planet_definition_id=gas_giant
|
||||
|
||||
var wait_frames: int = 60
|
||||
var frames: int = 0
|
||||
|
|
@ -27,6 +36,13 @@ func _initialize() -> void:
|
|||
output_path = args[1]
|
||||
if args.size() >= 3:
|
||||
wait_frames = int(args[2])
|
||||
for i in range(3, args.size()):
|
||||
var pair: PackedStringArray = args[i].split("=", true, 1)
|
||||
if pair.size() != 2:
|
||||
printerr("expected key=value, got: %s" % args[i])
|
||||
quit(1)
|
||||
return
|
||||
root.get_node("GameState").set(pair[0], pair[1])
|
||||
|
||||
var packed: PackedScene = load(scene_path)
|
||||
if packed == null:
|
||||
|
|
|
|||
|
|
@ -10,8 +10,12 @@
|
|||
# script). softpipe is Mesa's older non-JIT (pure C) rasterizer - slower,
|
||||
# but doesn't hit this crash. Good enough for one-shot screenshots.
|
||||
#
|
||||
# Usage: bash tests/screenshot.sh <scene_res_path> <output_png_path> [wait_frames] [WxH]
|
||||
# Usage: bash tests/screenshot.sh <scene_res_path> <output_png_path> [wait_frames] [WxH] [key=value ...]
|
||||
# Example: bash tests/screenshot.sh res://Main.tscn /tmp/out.png 60 720x1280
|
||||
# bash tests/screenshot.sh res://PlanetSurface.tscn /tmp/gas.png 90 720x1280 planet_definition_id=gas_giant
|
||||
#
|
||||
# Trailing key=value pairs are set on the GameState autoload before the
|
||||
# scene loads - see tests/_screenshot_tool.gd.
|
||||
#
|
||||
# The resolution matters for anything with UI in it: the game is authored
|
||||
# for a 720x1280 portrait viewport, so rendering at the default landscape
|
||||
|
|
@ -26,6 +30,8 @@ SCENE_PATH="${1:?usage: screenshot.sh <scene_res_path> <output_png_path> [wait_f
|
|||
OUTPUT_PATH="${2:?usage: screenshot.sh <scene_res_path> <output_png_path> [wait_frames] [WxH]}"
|
||||
WAIT_FRAMES="${3:-60}"
|
||||
RESOLUTION="${4:-1280x720}"
|
||||
shift 4 2>/dev/null || shift $#
|
||||
STATE_ARGS=("$@")
|
||||
|
||||
proot-distro login ubuntu -- bash -c "
|
||||
# Display :99 is dedicated to this helper. Clear both a live process left by
|
||||
|
|
@ -38,5 +44,5 @@ trap 'kill \$XVFB_PID >/dev/null 2>&1 || true' EXIT
|
|||
sleep 2
|
||||
mkdir -p /tmp/xdgrt && chmod 700 /tmp/xdgrt
|
||||
cd '$PROJECT_DIR'
|
||||
DISPLAY=:99 XDG_RUNTIME_DIR=/tmp/xdgrt GALLIUM_DRIVER=softpipe '$GODOT_BIN' --audio-driver Dummy --rendering-driver opengl3 --rendering-method gl_compatibility --resolution '$RESOLUTION' -s res://tests/_screenshot_tool.gd -- '$SCENE_PATH' '$OUTPUT_PATH' '$WAIT_FRAMES'
|
||||
DISPLAY=:99 XDG_RUNTIME_DIR=/tmp/xdgrt GALLIUM_DRIVER=softpipe '$GODOT_BIN' --audio-driver Dummy --rendering-driver opengl3 --rendering-method gl_compatibility --resolution '$RESOLUTION' -s res://tests/_screenshot_tool.gd -- '$SCENE_PATH' '$OUTPUT_PATH' '$WAIT_FRAMES' ${STATE_ARGS[*]}
|
||||
"
|
||||
|
|
|
|||
163
tests/test_build_uses_fleet_hold.gd
Normal file
163
tests/test_build_uses_fleet_hold.gd
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_build_uses_fleet_hold.gd
|
||||
# A new game starts with an empty bank. Ore is mined into the FLEET HOLD, and
|
||||
# the only way to move it into the bank is to deliver it to a mothership - so
|
||||
# charging construction to the bank alone made the first mothership cost ore
|
||||
# that could not exist yet. Mine forever, build nothing: the game was
|
||||
# unwinnable from its own opening position.
|
||||
#
|
||||
# Construction now draws on the bank AND the hold. Covers:
|
||||
# A. the deadlock premise still holds - a bank-only test rejects a build
|
||||
# the fleet can plainly pay for, so this is guarding something real
|
||||
# B. the pooled test accepts it, and paying takes from the bank first and
|
||||
# the hold only for the shortfall
|
||||
# C. an unaffordable build takes nothing at all (no partial charge)
|
||||
# D. end to end: a fleet holding ore, with an empty bank, actually gets a
|
||||
# mothership built through BuildController
|
||||
# E. research is deliberately NOT pooled - it still spends from the bank
|
||||
|
||||
# Deliberately more than one starting interceptor can carry (its hold is 20,
|
||||
# which is exactly MOTHERSHIP_COST). The capacity is widened where this is
|
||||
# used, so "the hold paid for it" is visible as a remainder rather than as
|
||||
# the hold happening to empty to zero.
|
||||
const HOLD_ORE: int = 40
|
||||
|
||||
var game_state: Node
|
||||
var scene: Node
|
||||
var build_controller: 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.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
|
||||
var cost: int = GameState.MOTHERSHIP_COST
|
||||
|
||||
# --- A. the deadlock, exactly as a fresh game presents it ---------------
|
||||
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0)
|
||||
game_state.fleet_inventory.total_capacity = HOLD_ORE
|
||||
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, HOLD_ORE)
|
||||
if game_state.can_afford(cost):
|
||||
_fail("the bank-only test passed with an empty bank, so it can no longer show the deadlock this fixes")
|
||||
return
|
||||
print("bank 0 + hold %d: bank-only affordability says no (the original deadlock)" % HOLD_ORE)
|
||||
|
||||
# --- B. pooled, and paid bank-first -------------------------------------
|
||||
if not game_state.can_afford_construction(cost):
|
||||
_fail("a fleet holding %d ore still could not afford a %d build" % [HOLD_ORE, cost])
|
||||
return
|
||||
if not game_state.spend_construction(cost):
|
||||
_fail("spend_construction refused a build it had just called affordable")
|
||||
return
|
||||
if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 0:
|
||||
_fail("paying from an empty bank somehow changed it")
|
||||
return
|
||||
if game_state.fleet_inventory.amount(GameState.RESOURCE_ORE) != HOLD_ORE - cost:
|
||||
_fail("the hold should have paid the whole %d, but holds %d of %d" \
|
||||
% [cost, game_state.fleet_inventory.amount(GameState.RESOURCE_ORE), HOLD_ORE])
|
||||
return
|
||||
print("paid %d entirely from the hold: hold %d -> %d" % [cost, HOLD_ORE, HOLD_ORE - cost])
|
||||
|
||||
# Bank first when there is a bank to draw on: the hold is also the fleet's
|
||||
# mining capacity, so it is spent last.
|
||||
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 8)
|
||||
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 30)
|
||||
if not game_state.spend_construction(20):
|
||||
_fail("a 20 build was refused with 8 banked and 30 held")
|
||||
return
|
||||
if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 0 \
|
||||
or game_state.fleet_inventory.amount(GameState.RESOURCE_ORE) != 18:
|
||||
_fail("a split payment should empty the bank first: bank %d, hold %d (expected 0 and 18)" \
|
||||
% [game_state.bank_inventory.amount(GameState.RESOURCE_ORE),
|
||||
game_state.fleet_inventory.amount(GameState.RESOURCE_ORE)])
|
||||
return
|
||||
print("split payment drained the bank first: bank 8 -> 0, hold 30 -> 18")
|
||||
|
||||
# --- C. no partial charge -----------------------------------------------
|
||||
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 5)
|
||||
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 5)
|
||||
if game_state.spend_construction(50):
|
||||
_fail("a build that cost more than everything owned was allowed")
|
||||
return
|
||||
if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 5 \
|
||||
or game_state.fleet_inventory.amount(GameState.RESOURCE_ORE) != 5:
|
||||
_fail("a rejected build still took ore: bank %d, hold %d (expected 5 and 5)" \
|
||||
% [game_state.bank_inventory.amount(GameState.RESOURCE_ORE),
|
||||
game_state.fleet_inventory.amount(GameState.RESOURCE_ORE)])
|
||||
return
|
||||
print("a build nobody can afford takes nothing")
|
||||
|
||||
# --- E. research stays banked -------------------------------------------
|
||||
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0)
|
||||
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 500)
|
||||
if game_state.can_afford(1):
|
||||
_fail("research affordability started counting the fleet hold - it is meant to stay banked")
|
||||
return
|
||||
print("research still spends from the bank alone")
|
||||
|
||||
# --- D. end to end, through the real build path --------------------------
|
||||
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0)
|
||||
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, HOLD_ORE)
|
||||
scene = load("res://Main.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
current_scene = scene
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
scene.get_node("AsteroidField").set_process(false)
|
||||
scene.get_node("EnemySpawner").set_process(false)
|
||||
build_controller = scene.get_node("BuildController")
|
||||
|
||||
# Whatever else the scene did while starting up, the point of the
|
||||
# test is a fleet with ore and an empty bank.
|
||||
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0)
|
||||
# Widened past what a starting interceptor can carry. Its real
|
||||
# hold is 20 and a mothership costs exactly 20, so at the stock
|
||||
# capacity a successful build empties the hold to zero - which is
|
||||
# indistinguishable from the hold having been emptied by anything
|
||||
# else. With room to spare, the remainder is the evidence.
|
||||
game_state.fleet_inventory.total_capacity = HOLD_ORE
|
||||
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, HOLD_ORE)
|
||||
var before: int = game_state.buildings.size()
|
||||
|
||||
build_controller.pending_build_type = GameState.BUILDING_MOTHERSHIP
|
||||
build_controller._confirm_build()
|
||||
|
||||
if game_state.buildings.size() != before + 1:
|
||||
return _fail("building a mothership from the fleet's own hold did not place one (%d -> %d): %s" \
|
||||
% [before, game_state.buildings.size(), build_controller.build_status.text])
|
||||
# Checked as a TOTAL, not as the hold alone: a mothership standing
|
||||
# where the fleet is starts taking delivery of the hold on the
|
||||
# very next tick, so ore legitimately moves from hold to bank
|
||||
# straight after the build. What must be true is that exactly the
|
||||
# cost left the pool - and that it left, since the bank was empty
|
||||
# and could not have paid.
|
||||
var total: int = game_state.construction_ore()
|
||||
if total != HOLD_ORE - GameState.MOTHERSHIP_COST:
|
||||
return _fail("the mothership was placed but %d ore remains, expected %d" \
|
||||
% [total, HOLD_ORE - GameState.MOTHERSHIP_COST])
|
||||
print("built a mothership with an empty bank, paid out of the fleet hold (%d ore left, bank %d + hold %d)" \
|
||||
% [total, game_state.bank_inventory.amount(GameState.RESOURCE_ORE),
|
||||
game_state.fleet_inventory.amount(GameState.RESOURCE_ORE)])
|
||||
|
||||
print("TEST_BUILD_USES_FLEET_HOLD: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _fail(message: String) -> bool:
|
||||
print("TEST_BUILD_USES_FLEET_HOLD: FAIL (%s)" % message)
|
||||
quit(1)
|
||||
return true
|
||||
1
tests/test_build_uses_fleet_hold.gd.uid
Normal file
1
tests/test_build_uses_fleet_hold.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bldykfmviv5lp
|
||||
137
tests/test_conquered_planet_clear.gd
Normal file
137
tests/test_conquered_planet_clear.gd
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_conquered_planet_clear.gd
|
||||
# A planet's garrison was destroyed to take it, so it must not be standing
|
||||
# there again the next time the player lands. Capture points already came
|
||||
# back marked captured; the enemies came back alive, which read as the
|
||||
# conquest having quietly been undone.
|
||||
#
|
||||
# Covers:
|
||||
# A. an unconquered planet lays out its authored enemies, as before
|
||||
# B. once every capture point is taken, re-landing places no enemies - and
|
||||
# still places the capture points, marked captured
|
||||
# C. a setup that authors enemies but NO capture points keeps them. Such a
|
||||
# planet counts as vacuously conquered (nothing to capture), so keying
|
||||
# only off the conquered flag would delete a garrison nobody has beaten
|
||||
|
||||
const PLANET_KEY: String = "test_conquered_planet_clear"
|
||||
const OPEN_PLANET_KEY: String = "test_conquered_planet_clear_open"
|
||||
|
||||
var game_state: Node
|
||||
var host_scene: PackedScene
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.planet_capture.clear()
|
||||
host_scene = load("res://PlanetSetupHost.tscn")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
if stage == 0:
|
||||
# One frame of grace so the autoload is fully up, same as the other
|
||||
# scene-driven tests.
|
||||
if elapsed < 0.1:
|
||||
return false
|
||||
|
||||
# --- A. unconquered: the garrison is there ---------------------------
|
||||
game_state.planet_id = PLANET_KEY
|
||||
game_state.planet_definition_id = "rocky"
|
||||
var setup: Resource = _make_setup(2, 3)
|
||||
var enemies: int = _apply_and_count_enemies(setup)
|
||||
if enemies != 3:
|
||||
return _fail("an unconquered planet placed %d of its 3 authored enemies" % enemies)
|
||||
if game_state.is_planet_conquered(PLANET_KEY):
|
||||
return _fail("a planet with 2 uncaptured points reported itself conquered")
|
||||
print("unconquered planet: 3 enemies placed")
|
||||
|
||||
# --- B. conquered: the garrison stays destroyed ----------------------
|
||||
game_state.mark_point_captured(PLANET_KEY, 0)
|
||||
game_state.mark_point_captured(PLANET_KEY, 1)
|
||||
if not game_state.is_planet_conquered(PLANET_KEY):
|
||||
return _fail("capturing every point did not conquer the planet")
|
||||
|
||||
var host: Node = host_scene.instantiate()
|
||||
root.add_child(host)
|
||||
host.apply_setup(setup)
|
||||
var remaining: int = _count_enemies(host)
|
||||
var points: int = _count_capture_points(host)
|
||||
host.free()
|
||||
if remaining != 0:
|
||||
return _fail("re-landing on a conquered planet placed %d enemies" % remaining)
|
||||
if points != 2:
|
||||
return _fail("a conquered planet placed %d of its 2 capture points - conquest should clear the garrison, not the map" % points)
|
||||
print("conquered planet: 0 enemies, both capture points still placed")
|
||||
|
||||
# --- C. a setup with nothing to capture keeps its enemies ------------
|
||||
game_state.planet_id = OPEN_PLANET_KEY
|
||||
var open_setup: Resource = _make_setup(0, 2)
|
||||
var open_enemies: int = _apply_and_count_enemies(open_setup)
|
||||
if not game_state.is_planet_conquered(OPEN_PLANET_KEY):
|
||||
return _fail("a setup with no capture points was expected to register as vacuously conquered, and did not - so this case no longer tests what it claims to")
|
||||
if open_enemies != 2:
|
||||
return _fail("a setup with no capture points lost %d of its 2 enemies to the conquest rule" \
|
||||
% (2 - open_enemies))
|
||||
print("setup with no capture points: both enemies kept, despite counting as conquered")
|
||||
|
||||
print("TEST_CONQUERED_PLANET_CLEAR: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _make_setup(capture_count: int, enemy_count: int) -> Resource:
|
||||
var setup: Resource = load("res://PlanetSetup.gd").new()
|
||||
setup.setup_id = "in_memory"
|
||||
setup.surface_profile_id = "rocky"
|
||||
var points: PackedVector2Array = PackedVector2Array()
|
||||
for i in range(capture_count):
|
||||
points.append(Vector2(200.0 * float(i + 1), 0.0))
|
||||
setup.capture_points = points
|
||||
var spawn_script: Script = load("res://PlanetSetupSpawn.gd")
|
||||
for i in range(enemy_count):
|
||||
var spawn: Resource = spawn_script.new()
|
||||
spawn.enemy_id = "chaser"
|
||||
spawn.position = Vector2(0.0, 200.0 * float(i + 1))
|
||||
setup.enemy_spawns.append(spawn)
|
||||
return setup
|
||||
|
||||
|
||||
func _apply_and_count_enemies(setup: Resource) -> int:
|
||||
var host: Node = host_scene.instantiate()
|
||||
root.add_child(host)
|
||||
host.apply_setup(setup)
|
||||
var count: int = _count_enemies(host)
|
||||
host.free()
|
||||
return count
|
||||
|
||||
|
||||
# Counted by group rather than by script or node name: that is how everything
|
||||
# else in the game finds enemies, so this cannot pass while the real hunters
|
||||
# see something different.
|
||||
func _count_enemies(host: Node) -> int:
|
||||
var count: int = 0
|
||||
for child in host.get_children():
|
||||
if child.is_in_group("enemies"):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
func _count_capture_points(host: Node) -> int:
|
||||
var count: int = 0
|
||||
for child in host.get_children():
|
||||
if child.is_in_group("enemies"):
|
||||
continue
|
||||
if "point_index" in child:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
func _fail(message: String) -> bool:
|
||||
print("TEST_CONQUERED_PLANET_CLEAR: FAIL (%s)" % message)
|
||||
quit(1)
|
||||
return true
|
||||
1
tests/test_conquered_planet_clear.gd.uid
Normal file
1
tests/test_conquered_planet_clear.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dmwnshp5r6gch
|
||||
307
tests/test_gas_bands.gd
Normal file
307
tests/test_gas_bands.gd
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless test: run with
|
||||
# godot --headless -s res://tests/test_gas_bands.gd
|
||||
# The gas giant's cloud bands were breaking into disjoint segments - the band
|
||||
# edges stepped sideways once per cell, right across the screen. The cause was
|
||||
# in gas_surface.gdshader's second turbulence octave, which took a
|
||||
# double-frequency sample by doubling only the INTEGER half of a lattice
|
||||
# coordinate and leaving the fraction alone. That is not the same point at
|
||||
# twice the frequency; it is a different point on every cell, so the noise -
|
||||
# and with it the band edge - jumped discontinuously at every cell boundary.
|
||||
#
|
||||
# A fragment shader cannot be asserted on from GDScript, so this ports the
|
||||
# band field to GDScript against the SAME hashes and the SAME constants
|
||||
# (parsed out of the shader, so the two cannot drift) and measures the thing
|
||||
# that actually went wrong: continuity.
|
||||
#
|
||||
# Covers:
|
||||
# A. the field is continuous in X across many cell boundaries - no step
|
||||
# larger than a small multiple of what a smooth field produces between
|
||||
# adjacent samples
|
||||
# B. the same in Y, where the row loop's window slides by one
|
||||
# C. the test has teeth: the OLD, broken octave is re-implemented here and
|
||||
# must FAIL the same scan. Without this, a field that had been flattened
|
||||
# to a constant would sail through A and B
|
||||
# D. the row scan still reaches every row that can change a pixel, from the
|
||||
# constants as they currently stand - the seam this prevents is a
|
||||
# horizontal one, which A and B would not catch
|
||||
# E. the field is EXACTLY periodic over the tile period, in both axes. The
|
||||
# camera cell index arrives already wrapped (SurfaceBackground.gd wraps
|
||||
# it in double precision), so a pixel's cell index shifts by exactly one
|
||||
# period whenever the camera crosses a wrap boundary. Unless the hashes
|
||||
# are wrapped to match, the whole sky rearranges itself at that instant
|
||||
|
||||
const SHADER_PATH := "res://gas_surface.gdshader"
|
||||
# Sample spacing along the scan, in cells. Small enough that a genuinely
|
||||
# smooth field moves very little between samples.
|
||||
const STEP := 0.001
|
||||
const SPAN_CELLS := 12.0
|
||||
# What counts as a break. The field's slope is bounded by its octave weights
|
||||
# (0.55/0.30/0.15 over 4/2/1 cells, smoothstep slope at most 1.5) times
|
||||
# TURBULENCE_STRENGTH, divided by the thinnest band - about 0.9 per cell, so
|
||||
# a smooth field moves ~0.0009 between samples. Twenty times that is still
|
||||
# far below the ~0.3 jumps the broken octave produced, so this neither trips
|
||||
# on noise nor lets the real defect through.
|
||||
const MAX_STEP := 0.02
|
||||
|
||||
var source: String = ""
|
||||
var min_thickness: float = 0.0
|
||||
var max_thickness: float = 0.0
|
||||
var turbulence_strength: float = 0.0
|
||||
var max_significant: float = 0.0
|
||||
var row_scan: int = 0
|
||||
var tile_cells: float = 32.0
|
||||
# Set while scanning, to run the same scan against the broken formulation.
|
||||
var use_broken_octave: bool = false
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var file: FileAccess = FileAccess.open(SHADER_PATH, FileAccess.READ)
|
||||
if file == null:
|
||||
_fail("could not read %s" % SHADER_PATH)
|
||||
return
|
||||
source = file.get_as_text()
|
||||
file.close()
|
||||
|
||||
min_thickness = _read_float_const("MIN_THICKNESS")
|
||||
max_thickness = _read_float_const("MAX_THICKNESS")
|
||||
turbulence_strength = _read_float_const("TURBULENCE_STRENGTH")
|
||||
max_significant = _read_float_const("MAX_SIGNIFICANT_DISTANCE")
|
||||
row_scan = int(_read_int_const("ROW_SCAN"))
|
||||
tile_cells = _read_uniform_default("tile_cells")
|
||||
if is_nan(tile_cells):
|
||||
_fail("the shader no longer declares a tile_cells uniform, so nothing bounds what it hashes")
|
||||
return
|
||||
if is_nan(min_thickness) or is_nan(max_thickness) or is_nan(turbulence_strength) \
|
||||
or is_nan(max_significant) or row_scan == 0:
|
||||
_fail("could not parse the shader's band constants")
|
||||
return
|
||||
|
||||
if not _check_premises():
|
||||
return
|
||||
if not _check_continuity():
|
||||
return
|
||||
if not _check_row_scan_headroom():
|
||||
return
|
||||
if not _check_periodicity():
|
||||
return
|
||||
|
||||
print("TEST_GAS_BANDS: PASS")
|
||||
quit(0)
|
||||
|
||||
|
||||
# --- A/B/C: continuity ------------------------------------------------------
|
||||
|
||||
func _check_continuity() -> bool:
|
||||
var x_break: float = _worst_step_along_x()
|
||||
if x_break > MAX_STEP:
|
||||
return _fail("the band field breaks along X: a step of %.4f between samples %0.4f cells apart" \
|
||||
% [x_break, STEP])
|
||||
var y_break: float = _worst_step_along_y()
|
||||
if y_break > MAX_STEP:
|
||||
return _fail("the band field breaks along Y: a step of %.4f between samples %0.4f cells apart" \
|
||||
% [y_break, STEP])
|
||||
print("continuous across %d cell boundaries: worst step %.5f in X, %.5f in Y (allowed %.3f)" \
|
||||
% [int(SPAN_CELLS), x_break, y_break, MAX_STEP])
|
||||
|
||||
# C. The scan has to be able to see a break, or A and B prove nothing.
|
||||
use_broken_octave = true
|
||||
var broken: float = _worst_step_along_x()
|
||||
use_broken_octave = false
|
||||
if broken <= MAX_STEP:
|
||||
return _fail("the continuity scan did not detect the ORIGINAL defect (worst step %.5f), so it cannot be trusted to detect a new one" % broken)
|
||||
print("scan verified against the original defect: the broken octave steps %.3f, %.0fx the allowance" \
|
||||
% [broken, broken / MAX_STEP])
|
||||
return true
|
||||
|
||||
|
||||
func _worst_step_along_x() -> float:
|
||||
# Deliberately started off-lattice, so the sample points straddle cell
|
||||
# boundaries rather than landing exactly on them - a break exactly at an
|
||||
# integer could otherwise be stepped over.
|
||||
var y: float = 0.317
|
||||
var worst: float = 0.0
|
||||
var x: float = -SPAN_CELLS * 0.5 + 0.0004
|
||||
var previous: float = _nearest(x, y)
|
||||
while x < SPAN_CELLS * 0.5:
|
||||
x += STEP
|
||||
var value: float = _nearest(x, y)
|
||||
worst = maxf(worst, absf(value - previous))
|
||||
previous = value
|
||||
return worst
|
||||
|
||||
|
||||
func _worst_step_along_y() -> float:
|
||||
var x: float = 0.211
|
||||
var worst: float = 0.0
|
||||
var y: float = -SPAN_CELLS * 0.5 + 0.0004
|
||||
var previous: float = _nearest(x, y)
|
||||
while y < SPAN_CELLS * 0.5:
|
||||
y += STEP
|
||||
var value: float = _nearest(x, y)
|
||||
worst = maxf(worst, absf(value - previous))
|
||||
previous = value
|
||||
return worst
|
||||
|
||||
|
||||
# --- D: the row scan still covers every row that matters --------------------
|
||||
|
||||
func _check_row_scan_headroom() -> bool:
|
||||
# A row can still change a pixel while the pixel is within
|
||||
# MAX_SIGNIFICANT_DISTANCE * thickness of its band, and the band itself
|
||||
# wanders by up to half the turbulence strength.
|
||||
var reach: float = max_significant * max_thickness + turbulence_strength * 0.5
|
||||
# to_band = (local.y - dy) - band_centre, with local.y in [0,1) and
|
||||
# band_centre in [0,1), so (local.y - band_centre) spans (-1, 1) and a
|
||||
# row is relevant for dy anywhere in (-1 - reach, 1 + reach).
|
||||
var lowest_needed: int = ceili(-1.0 - reach)
|
||||
var highest_needed: int = floori(1.0 + reach)
|
||||
if lowest_needed < -1 or highest_needed > row_scan:
|
||||
return _fail("a band reaches %.2f cells, so rows dy %d..%d can still change a pixel - but the loop only scans -1..%d" \
|
||||
% [reach, lowest_needed, highest_needed, row_scan])
|
||||
print("band reach %.2f cells needs dy within %d..%d; the loop scans -1..%d" \
|
||||
% [reach, lowest_needed, highest_needed, row_scan])
|
||||
return true
|
||||
|
||||
|
||||
# --- the field, ported ------------------------------------------------------
|
||||
|
||||
func _nearest(x: float, y: float) -> float:
|
||||
# Same split the shader does: an exact integer cell index plus a bounded
|
||||
# fraction. TIME is pinned to zero here - drift slides the field, it does
|
||||
# not change its shape, and a fixed value keeps this reproducible.
|
||||
var cell := Vector2(floor(x), floor(y))
|
||||
var local := Vector2(x - cell.x, y - cell.y)
|
||||
var base_lattice := Vector2(cell.x, local.x)
|
||||
|
||||
var nearest: float = 1000.0
|
||||
for dy in range(-1, row_scan + 1):
|
||||
var row: float = cell.y + float(dy)
|
||||
var row_hash: Vector3 = _hash23(Vector2(fposmod(row, tile_cells), 5.0))
|
||||
var band_centre: float = row_hash.x
|
||||
var thickness: float = lerpf(min_thickness, max_thickness, row_hash.y)
|
||||
var turb: float = _row_turbulence(base_lattice, tile_cells,
|
||||
fposmod(row, tile_cells) * 3.7)
|
||||
var to_band: float = (local.y - float(dy)) - band_centre
|
||||
to_band += (turb - 0.5) * turbulence_strength
|
||||
nearest = minf(nearest, absf(to_band) / thickness)
|
||||
return nearest
|
||||
|
||||
|
||||
func _row_turbulence(lattice: Vector2, period: float, axis: float) -> float:
|
||||
if use_broken_octave:
|
||||
# The original, as it stood: one cell-frequency octave plus a second
|
||||
# that doubled the integer part of the lattice coordinate and left
|
||||
# the fraction untouched.
|
||||
return _value_noise(lattice, period, axis + 11.0) * 0.65 \
|
||||
+ _value_noise(Vector2(lattice.x * 2.0, lattice.y), period, axis + 47.0) * 0.35
|
||||
var half_freq: Vector2 = _halve_lattice(lattice)
|
||||
var quarter: Vector2 = _halve_lattice(half_freq)
|
||||
return _value_noise(quarter, period * 0.25, axis + 11.0) * 0.55 \
|
||||
+ _value_noise(half_freq, period * 0.5, axis + 47.0) * 0.30 \
|
||||
+ _value_noise(lattice, period, axis + 83.0) * 0.15
|
||||
|
||||
|
||||
func _halve_lattice(coord: Vector2) -> Vector2:
|
||||
var half_i: float = floor(coord.x * 0.5)
|
||||
var rem: float = coord.x - half_i * 2.0
|
||||
return Vector2(half_i, (rem + coord.y) * 0.5)
|
||||
|
||||
|
||||
func _value_noise(coord: Vector2, period: float, axis: float) -> float:
|
||||
var a: float = _hash21(Vector2(fposmod(coord.x, period), axis))
|
||||
var b: float = _hash21(Vector2(fposmod(coord.x + 1.0, period), axis))
|
||||
return lerpf(a, b, smoothstep(0.0, 1.0, coord.y))
|
||||
|
||||
|
||||
func _fract(v: float) -> float:
|
||||
return v - floor(v)
|
||||
|
||||
|
||||
func _fract3(v: Vector3) -> Vector3:
|
||||
return Vector3(_fract(v.x), _fract(v.y), _fract(v.z))
|
||||
|
||||
|
||||
func _hash21(p: Vector2) -> float:
|
||||
var p3: Vector3 = _fract3(Vector3(p.x, p.y, p.x) * 0.1031)
|
||||
var shifted := Vector3(p3.y, p3.z, p3.x) + Vector3(33.33, 33.33, 33.33)
|
||||
p3 += Vector3.ONE * p3.dot(shifted)
|
||||
return _fract((p3.x + p3.y) * p3.z)
|
||||
|
||||
|
||||
func _hash23(p: Vector2) -> Vector3:
|
||||
var p3: Vector3 = _fract3(Vector3(p.x, p.y, p.x) * Vector3(0.1031, 0.1030, 0.0973))
|
||||
var shifted := Vector3(p3.y, p3.x, p3.z) + Vector3(33.33, 33.33, 33.33)
|
||||
p3 += Vector3.ONE * p3.dot(shifted)
|
||||
return _fract3(Vector3(
|
||||
(p3.x + p3.y) * p3.z,
|
||||
(p3.x + p3.z) * p3.y,
|
||||
(p3.y + p3.z) * p3.x))
|
||||
|
||||
|
||||
# --- premises ---------------------------------------------------------------
|
||||
|
||||
func _check_premises() -> bool:
|
||||
# The exact defect, named so it cannot quietly return.
|
||||
if "vec2(turb_x.x * 2.0" in source:
|
||||
return _fail("the shader is doubling a lattice coordinate's integer part while leaving its fraction alone - that is the band-break defect itself")
|
||||
if not ("halve_lattice" in source):
|
||||
return _fail("the shader no longer rescales its octaves through halve_lattice, so continuity is no longer guaranteed by construction")
|
||||
if not ("combine_lattice" in source):
|
||||
return _fail("the shader no longer folds drift in through combine_lattice, so TIME is being added to a cell index directly")
|
||||
return true
|
||||
|
||||
|
||||
func _read_float_const(name: String) -> float:
|
||||
var regex := RegEx.new()
|
||||
regex.compile("const\\s+float\\s+%s\\s*=\\s*(-?[0-9.]+)" % name)
|
||||
var found: RegExMatch = regex.search(source)
|
||||
if found == null:
|
||||
return NAN
|
||||
return float(found.get_string(1))
|
||||
|
||||
|
||||
func _read_int_const(name: String) -> float:
|
||||
var regex := RegEx.new()
|
||||
regex.compile("const\\s+int\\s+%s\\s*=\\s*(-?[0-9]+)" % name)
|
||||
var found: RegExMatch = regex.search(source)
|
||||
if found == null:
|
||||
return 0.0
|
||||
return float(found.get_string(1))
|
||||
|
||||
|
||||
func _read_uniform_default(name: String) -> float:
|
||||
var regex := RegEx.new()
|
||||
regex.compile("uniform\\s+float\\s+%s\\s*=\\s*(-?[0-9.]+)" % name)
|
||||
var found: RegExMatch = regex.search(source)
|
||||
if found == null:
|
||||
return NAN
|
||||
return float(found.get_string(1))
|
||||
|
||||
|
||||
# --- E: exact periodicity ---------------------------------------------------
|
||||
|
||||
func _check_periodicity() -> bool:
|
||||
if fmod(tile_cells, 4.0) != 0.0:
|
||||
return _fail("a tile period of %.0f cells is not a multiple of 4, so the quarter-frequency octave cannot wrap on a whole cell and the seam comes back" % tile_cells)
|
||||
var worst: float = 0.0
|
||||
for i in range(400):
|
||||
var x: float = -7.3 + float(i) * 0.11
|
||||
var y: float = 2.9 + float(i) * 0.07
|
||||
worst = maxf(worst, absf(_nearest(x, y) - _nearest(x + tile_cells, y)))
|
||||
worst = maxf(worst, absf(_nearest(x, y) - _nearest(x, y + tile_cells)))
|
||||
# Exact, not approximate: the same hashes are evaluated on indices that
|
||||
# differ by whole periods, so identical inputs reach them. Anything but a
|
||||
# rounding-scale difference means the wrap is visible.
|
||||
if worst > 1e-9:
|
||||
return _fail("the field is not periodic over %.0f cells (differs by %.6f), so crossing a camera wrap rearranges the sky" \
|
||||
% [tile_cells, worst])
|
||||
print("field is exactly periodic over %.0f cells in both axes (worst difference %s)" % [tile_cells, str(worst)])
|
||||
return true
|
||||
|
||||
|
||||
func _fail(message: String) -> bool:
|
||||
print("TEST_GAS_BANDS: FAIL (%s)" % message)
|
||||
quit(1)
|
||||
return false
|
||||
1
tests/test_gas_bands.gd.uid
Normal file
1
tests/test_gas_bands.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://b30n8oom8283w
|
||||
|
|
@ -29,7 +29,7 @@ extends SceneTree
|
|||
|
||||
const SHADER_PATH := "res://lunar_surface.gdshader"
|
||||
const TILED_SHADER_PATH := "res://lunar_tiled.gdshader"
|
||||
const BACKGROUND_SCRIPT_PATH := "res://LunarBackground.gd"
|
||||
const BACKGROUND_SCRIPT_PATH := "res://SurfaceBackground.gd"
|
||||
# Largest normalized distance any shading term below reacts to. Past this,
|
||||
# every smoothstep has saturated and the pixel is plain regolith.
|
||||
const MAX_SIGNIFICANT_DISTANCE := 1.15
|
||||
|
|
@ -123,7 +123,7 @@ func _check_tiling_setup() -> bool:
|
|||
regex.compile("const\\s+TILE_CELLS\\s*:\\s*float\\s*=\\s*([0-9.]+)")
|
||||
var found: RegExMatch = regex.search(script_source)
|
||||
if found == null:
|
||||
return _fail("LunarBackground.gd no longer declares TILE_CELLS, the shared tile period")
|
||||
return _fail("SurfaceBackground.gd no longer declares TILE_CELLS, the shared tile period")
|
||||
tile_cells = float(found.get_string(1))
|
||||
if tile_cells < 4.0:
|
||||
return _fail("a tile period of %.0f cells is too short to read as anything but a repeat" % tile_cells)
|
||||
|
|
@ -133,7 +133,7 @@ func _check_tiling_setup() -> bool:
|
|||
# existing cell/fraction split is that the raw index is too large for a
|
||||
# mobile float, and the tiled shader adds it straight into a UV.
|
||||
if not ("fposmod(cell.x, TILE_CELLS)" in script_source):
|
||||
return _fail("LunarBackground.gd no longer wraps the camera cell into the tile before handing it to the shader")
|
||||
return _fail("SurfaceBackground.gd no longer wraps the camera cell into the tile before handing it to the shader")
|
||||
|
||||
var tiled_file: FileAccess = FileAccess.open(TILED_SHADER_PATH, FileAccess.READ)
|
||||
if tiled_file == null:
|
||||
|
|
|
|||
142
tests/test_planet_map_assignment.gd
Normal file
142
tests/test_planet_map_assignment.gd
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless test: run with
|
||||
# godot --headless -s res://tests/test_planet_map_assignment.gd
|
||||
# Every planet should draw a map of its own surface type at random, and then
|
||||
# keep it - leaving a planet and coming back must land on the same layout, or
|
||||
# the place stops feeling like a place.
|
||||
#
|
||||
# Two things could break that, and both are silent:
|
||||
# - the candidate list is built by scanning a directory, and the order a
|
||||
# filesystem lists files in is not something to seed a choice with. The
|
||||
# pick indexes into that list, so an unstable order re-deals every planet
|
||||
# - the pool itself changes the moment the map editor saves a new setup,
|
||||
# which would re-deal planets mid-session even with a perfect seed
|
||||
#
|
||||
# Covers:
|
||||
# A. the same planet resolves to the same setup, every time
|
||||
# B. the setup it draws is always authored for that planet's own profile
|
||||
# C. different planets do not all collapse onto one setup
|
||||
# D. an assignment survives a new setup file appearing in the pool
|
||||
# E. candidates come back in a deterministic order regardless of scan order
|
||||
|
||||
const SCRATCH_ID: String = "test_assignment_scratch"
|
||||
const SCRATCH_PATH: String = "res://PlanetSetups/%s.tres" % SCRATCH_ID
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_remove_scratch()
|
||||
PlanetSetups.rescan()
|
||||
|
||||
# --- E. deterministic ordering ------------------------------------------
|
||||
var profile_id: String = "rocky"
|
||||
var ordered: Array = PlanetSetups.setups_for_profile(profile_id)
|
||||
if ordered.size() < 2:
|
||||
# Not a failure of the code under test, but this file cannot say
|
||||
# anything without a pool to draw from - say so rather than passing
|
||||
# vacuously.
|
||||
_fail("only %d '%s' setups exist, so nothing here can distinguish a stable draw from a fixed one" \
|
||||
% [ordered.size(), profile_id])
|
||||
return
|
||||
var ids: Array = []
|
||||
for setup in ordered:
|
||||
ids.append(String(setup.setup_id))
|
||||
var sorted_ids: Array = ids.duplicate()
|
||||
sorted_ids.sort()
|
||||
if ids != sorted_ids:
|
||||
_fail("setups_for_profile returned %s, which is not sorted - the draw indexes into this, so the order cannot be left to the filesystem" % str(ids))
|
||||
return
|
||||
print("'%s' pool is deterministic: %s" % [profile_id, str(ids)])
|
||||
|
||||
# --- A/B/C. stable, correctly typed, and actually varied -----------------
|
||||
var first_pass: Dictionary = {}
|
||||
var planet_ids: Array = []
|
||||
for i in range(40):
|
||||
planet_ids.append("planet_%d_%d" % [i * 7, i * 13])
|
||||
for planet_id in planet_ids:
|
||||
var setup: Resource = PlanetSetups.setup_for_planet(planet_id, profile_id)
|
||||
if setup == null:
|
||||
_fail("%s drew no setup at all" % planet_id)
|
||||
return
|
||||
if String(setup.surface_profile_id) != profile_id:
|
||||
_fail("%s is a '%s' planet but drew a '%s' map" \
|
||||
% [planet_id, profile_id, setup.surface_profile_id])
|
||||
return
|
||||
first_pass[planet_id] = setup
|
||||
|
||||
for planet_id in planet_ids:
|
||||
for repeat in range(3):
|
||||
if PlanetSetups.setup_for_planet(planet_id, profile_id) != first_pass[planet_id]:
|
||||
_fail("%s drew a different map on a later visit" % planet_id)
|
||||
return
|
||||
print("40 planets each re-drew the same map on every revisit")
|
||||
|
||||
var distinct: Dictionary = {}
|
||||
for planet_id in first_pass:
|
||||
distinct[first_pass[planet_id].setup_id] = true
|
||||
if distinct.size() < 2:
|
||||
_fail("all 40 planets drew the same map (%s), so the draw is not random at all" % str(distinct.keys()))
|
||||
return
|
||||
print("across 40 planets the draw used %d of the %d available maps" % [distinct.size(), ids.size()])
|
||||
|
||||
# --- D. a new map appearing does not re-deal the planets already visited -
|
||||
var scratch: Resource = load("res://PlanetSetup.gd").new()
|
||||
scratch.setup_id = SCRATCH_ID
|
||||
scratch.display_name = "Assignment Scratch"
|
||||
scratch.surface_profile_id = profile_id
|
||||
var err: int = ResourceSaver.save(scratch, SCRATCH_PATH)
|
||||
if err != OK:
|
||||
_fail("could not write the scratch setup (error %d)" % err)
|
||||
return
|
||||
# Exactly what the map editor does after a save.
|
||||
PlanetSetups.rescan()
|
||||
var live: Dictionary = {}
|
||||
var scratch_in_pool: bool = false
|
||||
for setup in PlanetSetups.setups_for_profile(profile_id):
|
||||
live[setup] = true
|
||||
if String(setup.setup_id) == SCRATCH_ID:
|
||||
scratch_in_pool = true
|
||||
if not scratch_in_pool:
|
||||
_remove_scratch()
|
||||
_fail("the scratch setup did not enter the pool, so this case proves nothing")
|
||||
return
|
||||
|
||||
var moved: int = 0
|
||||
var checked: int = 0
|
||||
for planet_id in planet_ids:
|
||||
# Skipped if the map this planet drew is no longer in the pool. The
|
||||
# suite runs tests in parallel against one project directory, so
|
||||
# another test's scratch setup can appear and vanish underneath this
|
||||
# one - and a planet losing an assignment because its MAP was deleted
|
||||
# is correct behaviour, not the re-deal this case is looking for.
|
||||
if not live.has(first_pass[planet_id]):
|
||||
continue
|
||||
checked += 1
|
||||
if PlanetSetups.setup_for_planet(planet_id, profile_id) != first_pass[planet_id]:
|
||||
moved += 1
|
||||
if checked == 0:
|
||||
_remove_scratch()
|
||||
_fail("every planet's map disappeared from the pool mid-test, so nothing could be checked")
|
||||
return
|
||||
_remove_scratch()
|
||||
PlanetSetups.rescan()
|
||||
if moved > 0:
|
||||
_fail("saving one new map re-dealt %d of %d already-visited planets" % [moved, checked])
|
||||
return
|
||||
print("saving a new map left all %d existing assignments alone" % checked)
|
||||
|
||||
print("TEST_PLANET_MAP_ASSIGNMENT: PASS")
|
||||
quit(0)
|
||||
|
||||
|
||||
func _remove_scratch() -> void:
|
||||
var path: String = ProjectSettings.globalize_path(SCRATCH_PATH)
|
||||
if FileAccess.file_exists(path):
|
||||
DirAccess.remove_absolute(path)
|
||||
if FileAccess.file_exists(path + ".uid"):
|
||||
DirAccess.remove_absolute(path + ".uid")
|
||||
|
||||
|
||||
func _fail(message: String) -> void:
|
||||
print("TEST_PLANET_MAP_ASSIGNMENT: FAIL (%s)" % message)
|
||||
quit(1)
|
||||
1
tests/test_planet_map_assignment.gd.uid
Normal file
1
tests/test_planet_map_assignment.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://by7lf8250s5m
|
||||
155
tests/test_setup_editor_delete.gd
Normal file
155
tests/test_setup_editor_delete.gd
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless test: run with
|
||||
# godot --headless -s res://tests/test_setup_editor_delete.gd
|
||||
# The map editor could write setups but never remove one, so a mistyped name
|
||||
# or an abandoned experiment stayed in the pool forever - and every planet of
|
||||
# that type kept drawing it. It also listed setups in a font small enough to
|
||||
# be awkward to hit on a phone.
|
||||
#
|
||||
# Deleting is destructive and the list is the only place it can be triggered
|
||||
# from, so the panel is explicitly MODAL about it: a tap loads, unless the
|
||||
# delete button has been pressed first, and the panel says which it is.
|
||||
#
|
||||
# Covers:
|
||||
# A. the load panel lists saved setups and a tap loads one (unarmed)
|
||||
# B. arming changes what a tap means, and says so in the title
|
||||
# C. an armed tap deletes the file and drops it from the registry
|
||||
# D. one tap deletes one setup - it disarms itself afterwards
|
||||
# E. deleting the setup currently being edited leaves a fresh one open,
|
||||
# rather than one whose next save would recreate the file
|
||||
# F. the list is laid out at a size worth tapping at
|
||||
|
||||
const SCRATCH_ID: String = "test_delete_scratch"
|
||||
const SCRATCH_PATH: String = "res://PlanetSetups/%s.tres" % SCRATCH_ID
|
||||
const KEEP_ID: String = "test_delete_keeper"
|
||||
const KEEP_PATH: String = "res://PlanetSetups/%s.tres" % KEEP_ID
|
||||
const MIN_LIST_FONT_SIZE: int = 30
|
||||
|
||||
var editor: Node
|
||||
var elapsed: float = 0.0
|
||||
var stage: int = 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_remove(SCRATCH_PATH)
|
||||
_remove(KEEP_PATH)
|
||||
PlanetSetups.rescan()
|
||||
editor = load("res://PlanetSetupEditor.tscn").instantiate()
|
||||
root.add_child(editor)
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
if stage != 0:
|
||||
return false
|
||||
# _ready() (palette build, initial new_setup) needs a frame to flush -
|
||||
# same hazard as tests/test_setup_editor.gd.
|
||||
if elapsed < 0.1:
|
||||
return false
|
||||
stage = 1
|
||||
|
||||
# --- F. the list is tappable --------------------------------------------
|
||||
var list: ItemList = editor.load_list
|
||||
var font_size: int = list.get_theme_font_size("font_size")
|
||||
if font_size < MIN_LIST_FONT_SIZE:
|
||||
return _fail("the setup list draws at font size %d; %d or more is what makes the rows worth tapping at" \
|
||||
% [font_size, MIN_LIST_FONT_SIZE])
|
||||
print("setup list font size %d" % font_size)
|
||||
|
||||
# Two setups, so deleting one still leaves a populated list - a panel that
|
||||
# empties would hide itself and mask a wrong-row deletion.
|
||||
editor.new_setup(KEEP_ID, "Delete Keeper", "rocky")
|
||||
editor.add_capture_point(Vector2(10, 20))
|
||||
if not editor.save_setup().ok:
|
||||
return _fail("could not save the keeper setup")
|
||||
editor.new_setup(SCRATCH_ID, "Delete Scratch", "rocky")
|
||||
editor.add_capture_point(Vector2(30, 40))
|
||||
if not editor.save_setup().ok:
|
||||
return _fail("could not save the scratch setup")
|
||||
|
||||
# --- A. the panel lists them, and an unarmed tap loads -------------------
|
||||
editor._on_load_pressed()
|
||||
if not editor.load_panel.visible:
|
||||
return _fail("the load panel did not open")
|
||||
if editor.load_title_label.text != "Load a setup":
|
||||
return _fail("the panel opened in delete mode: '%s'" % editor.load_title_label.text)
|
||||
var keep_index: int = Array(editor._load_list_setup_ids).find(KEEP_ID)
|
||||
var scratch_index: int = Array(editor._load_list_setup_ids).find(SCRATCH_ID)
|
||||
if keep_index < 0 or scratch_index < 0:
|
||||
return _fail("the panel listed %s, missing one of the two just saved" \
|
||||
% str(editor._load_list_setup_ids))
|
||||
editor._on_load_list_item_selected(keep_index)
|
||||
if String(editor.current_setup.setup_id) != KEEP_ID:
|
||||
return _fail("an unarmed tap did not load '%s' (got '%s')" % [KEEP_ID, editor.current_setup.setup_id])
|
||||
if editor.load_panel.visible:
|
||||
return _fail("loading left the panel open")
|
||||
print("unarmed tap loaded '%s' and closed the panel" % KEEP_ID)
|
||||
|
||||
# --- B. arming changes the meaning of a tap, visibly ---------------------
|
||||
editor._on_load_pressed()
|
||||
editor._on_delete_map_pressed()
|
||||
if not editor._delete_map_armed:
|
||||
return _fail("pressing the delete button did not arm delete mode")
|
||||
if editor.load_title_label.text == "Load a setup":
|
||||
return _fail("delete mode did not change the panel title, so nothing tells the player a tap now deletes")
|
||||
print("armed: panel now reads '%s'" % editor.load_title_label.text)
|
||||
|
||||
# --- C. an armed tap deletes ---------------------------------------------
|
||||
scratch_index = Array(editor._load_list_setup_ids).find(SCRATCH_ID)
|
||||
editor._on_load_list_item_selected(scratch_index)
|
||||
if FileAccess.file_exists(ProjectSettings.globalize_path(SCRATCH_PATH)):
|
||||
return _fail("the setup file is still on disk after an armed tap")
|
||||
for setup in PlanetSetups.all():
|
||||
if String(setup.setup_id) == SCRATCH_ID:
|
||||
return _fail("the registry still offers '%s' after deleting it" % SCRATCH_ID)
|
||||
if Array(editor._load_list_setup_ids).find(KEEP_ID) < 0:
|
||||
return _fail("deleting one setup also removed '%s' from the list" % KEEP_ID)
|
||||
print("armed tap deleted '%s'; '%s' untouched" % [SCRATCH_ID, KEEP_ID])
|
||||
|
||||
# --- D. one tap, one deletion -------------------------------------------
|
||||
if editor._delete_map_armed:
|
||||
return _fail("delete mode stayed armed after a deletion, so the next tap would delete too")
|
||||
if editor.load_title_label.text != "Load a setup":
|
||||
return _fail("the panel still reads '%s' after disarming" % editor.load_title_label.text)
|
||||
print("delete mode disarmed itself after one deletion")
|
||||
|
||||
# --- E. deleting what is open leaves a fresh setup ------------------------
|
||||
editor.load_setup(KEEP_ID)
|
||||
if String(editor.current_setup.setup_id) != KEEP_ID:
|
||||
return _fail("could not reopen '%s' to test deleting the open setup" % KEEP_ID)
|
||||
var outcome: Dictionary = editor.delete_setup(KEEP_ID)
|
||||
if not outcome.ok:
|
||||
return _fail("deleting the open setup failed: %s" % outcome.message)
|
||||
if String(editor.current_setup.setup_id) == KEEP_ID:
|
||||
return _fail("the editor is still holding '%s' after deleting it - the next save would put the file straight back" % KEEP_ID)
|
||||
if not editor.current_setup.capture_points.is_empty():
|
||||
return _fail("the replacement setup carried the deleted one's capture points over")
|
||||
print("deleting the open setup left a fresh '%s' being edited" % editor.current_setup.setup_id)
|
||||
|
||||
# Deleting something that is not there is refused, not silently "ok".
|
||||
if editor.delete_setup(SCRATCH_ID).ok:
|
||||
return _fail("deleting an already-deleted setup reported success")
|
||||
|
||||
_remove(SCRATCH_PATH)
|
||||
_remove(KEEP_PATH)
|
||||
PlanetSetups.rescan()
|
||||
print("TEST_SETUP_EDITOR_DELETE: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
|
||||
|
||||
func _remove(res_path: String) -> void:
|
||||
var path: String = ProjectSettings.globalize_path(res_path)
|
||||
if FileAccess.file_exists(path):
|
||||
DirAccess.remove_absolute(path)
|
||||
if FileAccess.file_exists(path + ".uid"):
|
||||
DirAccess.remove_absolute(path + ".uid")
|
||||
|
||||
|
||||
func _fail(message: String) -> bool:
|
||||
_remove(SCRATCH_PATH)
|
||||
_remove(KEEP_PATH)
|
||||
print("TEST_SETUP_EDITOR_DELETE: FAIL (%s)" % message)
|
||||
quit(1)
|
||||
return true
|
||||
1
tests/test_setup_editor_delete.gd.uid
Normal file
1
tests/test_setup_editor_delete.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://boirnc74ieft4
|
||||
134
tests/test_surface_background_shared.gd
Normal file
134
tests/test_surface_background_shared.gd
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless test: run with
|
||||
# godot --headless -s res://tests/test_surface_background_shared.gd
|
||||
# The landed surface and the setup editor drew DIFFERENT backgrounds for the
|
||||
# same planet. Each scene carried its own hand-written ShaderMaterial (the
|
||||
# game's baked-and-tiled lunar shader, the editor's per-pixel procedural one)
|
||||
# and only the game's side ever swapped in a profile's own shader - so a gas
|
||||
# giant authored in the editor showed recoloured craters while the game showed
|
||||
# cloud bands. An editor that lies about what the player will see is worse
|
||||
# than no preview.
|
||||
#
|
||||
# Both scenes now instance ONE SurfaceBackground.tscn and configure it through
|
||||
# ONE method. This test guards that, since nothing about the code stops
|
||||
# someone re-adding a local material to one scene and pulling them apart
|
||||
# again.
|
||||
#
|
||||
# Covers:
|
||||
# A. both scenes get their backdrop from the same PackedScene file
|
||||
# B. neither overrides the shared material - the two start out identical
|
||||
# C. every surface profile names its own background_shader; a null one
|
||||
# would mean "whatever this scene already had", which is the ambiguity
|
||||
# that caused the drift
|
||||
# D. applying each profile through the shared entry point produces the same
|
||||
# shader AND the same colours on both scenes, for every profile
|
||||
|
||||
const BACKGROUND_SCENE_PATH := "res://SurfaceBackground.tscn"
|
||||
const HOST_SCENES := {
|
||||
"PlanetSurface.tscn": "res://PlanetSurface.tscn",
|
||||
"PlanetSetupEditor.tscn": "res://PlanetSetupEditor.tscn",
|
||||
}
|
||||
const COLOR_PARAMETERS := ["base_color", "crater_color", "highlight_color"]
|
||||
|
||||
var backgrounds: Dictionary = {}
|
||||
var hosts: Array = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var surface_profiles: Script = load("res://PlanetSurfaceProfiles.gd")
|
||||
|
||||
# --- A/B: same source scene, same untouched material ---------------------
|
||||
for scene_name in HOST_SCENES:
|
||||
var host: Node = load(HOST_SCENES[scene_name]).instantiate()
|
||||
hosts.append(host)
|
||||
var layer: Node = host.get_node_or_null("BackgroundLayer")
|
||||
if layer == null:
|
||||
_fail("%s has no BackgroundLayer" % scene_name)
|
||||
return
|
||||
if layer.scene_file_path != BACKGROUND_SCENE_PATH:
|
||||
_fail("%s builds its own background (%s) instead of instancing %s" \
|
||||
% [scene_name, layer.scene_file_path, BACKGROUND_SCENE_PATH])
|
||||
return
|
||||
var background: Node = layer.get_node_or_null("Background")
|
||||
if background == null:
|
||||
_fail("%s's background layer has no Background node" % scene_name)
|
||||
return
|
||||
if not background.has_method("apply_profile"):
|
||||
_fail("%s's background has no apply_profile() - the shared entry point is gone" % scene_name)
|
||||
return
|
||||
backgrounds[scene_name] = background
|
||||
print("both scenes instance %s and expose apply_profile()" % BACKGROUND_SCENE_PATH)
|
||||
|
||||
var names: Array = backgrounds.keys()
|
||||
var reference: ShaderMaterial = backgrounds[names[0]].material
|
||||
for i in range(1, names.size()):
|
||||
var other: ShaderMaterial = backgrounds[names[i]].material
|
||||
# Same configuration, SEPARATE objects. Sub-resources in a PackedScene
|
||||
# are shared between instances by default, which would make the whole
|
||||
# comparison below vacuous - it would be reading one material twice -
|
||||
# and would mean two live surfaces repainting each other, baked
|
||||
# texture included. SurfaceBackground.tscn marks the material
|
||||
# resource_local_to_scene for exactly this reason.
|
||||
if other == reference:
|
||||
_fail("%s and %s share one ShaderMaterial object, so applying a profile to either repaints both" \
|
||||
% [names[0], names[i]])
|
||||
return
|
||||
if other.shader != reference.shader:
|
||||
_fail("%s and %s start on different shaders (%s vs %s), so one of them overrides the shared material" \
|
||||
% [names[0], names[i], reference.shader.resource_path, other.shader.resource_path])
|
||||
return
|
||||
print("both start on the same shader: %s" % reference.shader.resource_path)
|
||||
|
||||
# --- C: no profile leaves its shader unstated ----------------------------
|
||||
for profile_id in surface_profiles.PROFILES:
|
||||
var profile: Resource = surface_profiles.get_profile(profile_id)
|
||||
if profile.background_shader == null:
|
||||
_fail("the '%s' profile does not name a background_shader, so what it draws depends on which scene happens to be hosting it" % profile_id)
|
||||
return
|
||||
print("all %d profiles name their own background shader" % surface_profiles.PROFILES.size())
|
||||
|
||||
# --- D: applying a profile lands identically on both ---------------------
|
||||
for profile_id in surface_profiles.PROFILES:
|
||||
var profile: Resource = surface_profiles.get_profile(profile_id)
|
||||
for scene_name in backgrounds:
|
||||
backgrounds[scene_name].apply_profile(profile)
|
||||
|
||||
var first: ShaderMaterial = backgrounds[names[0]].material
|
||||
if first.shader != profile.background_shader:
|
||||
_fail("applying '%s' did not put its own shader on the background" % profile_id)
|
||||
return
|
||||
for i in range(1, names.size()):
|
||||
var other: ShaderMaterial = backgrounds[names[i]].material
|
||||
if other.shader != first.shader:
|
||||
_fail("'%s' resolved to %s in %s but %s in %s" \
|
||||
% [profile_id, first.shader.resource_path, names[0],
|
||||
other.shader.resource_path, names[i]])
|
||||
return
|
||||
for parameter in COLOR_PARAMETERS:
|
||||
var a: Color = first.get_shader_parameter(parameter)
|
||||
var b: Color = other.get_shader_parameter(parameter)
|
||||
if not a.is_equal_approx(b):
|
||||
_fail("'%s' set %s to %s in %s but %s in %s" \
|
||||
% [profile_id, parameter, a, names[0], b, names[i]])
|
||||
return
|
||||
# Identical to each other is only half of it - both have to match the
|
||||
# profile, or the shared path could be uniformly ignoring it.
|
||||
for parameter in COLOR_PARAMETERS:
|
||||
var applied: Color = first.get_shader_parameter(parameter)
|
||||
var declared: Color = profile.get(parameter)
|
||||
if not applied.is_equal_approx(declared):
|
||||
_fail("'%s' declares %s %s but the background ended up with %s" \
|
||||
% [profile_id, parameter, declared, applied])
|
||||
return
|
||||
print(" '%s' -> %s, identical on both" % [profile_id, first.shader.resource_path.get_file()])
|
||||
|
||||
for host in hosts:
|
||||
host.free()
|
||||
print("TEST_SURFACE_BACKGROUND_SHARED: PASS")
|
||||
quit(0)
|
||||
|
||||
|
||||
func _fail(message: String) -> void:
|
||||
print("TEST_SURFACE_BACKGROUND_SHARED: FAIL (%s)" % message)
|
||||
quit(1)
|
||||
1
tests/test_surface_background_shared.gd.uid
Normal file
1
tests/test_surface_background_shared.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dynonerc7v0fp
|
||||
|
|
@ -6,11 +6,16 @@ extends SceneTree
|
|||
# lunar_surface.gdshader recoloured (craters reading as regolith, not
|
||||
# atmosphere). Verifies:
|
||||
# 1. the gas profile resolves a background_shader different from rocky's
|
||||
# (rocky's stays null - "keep the scene's existing shader")
|
||||
# 2. PlanetSurfaceController._compose_surface() actually swaps the
|
||||
# background material's shader when landing on a gas giant
|
||||
# 3. landing on a rocky planet leaves the scene's own lunar shader in
|
||||
# place - a null background_shader is a no-op, not a forced reset
|
||||
# 3. landing on a rocky planet ends up on rocky's own declared shader
|
||||
#
|
||||
# Rocky's background_shader used to be left null, meaning "keep whatever the
|
||||
# hosting scene already had". That convention is gone: it resolved to a
|
||||
# different shader in the game than in the setup editor, which is exactly how
|
||||
# the two came apart (see tests/test_surface_background_shared.gd). Every
|
||||
# profile now names its shader, so this checks rocky lands on ITS OWN rather
|
||||
# than on whatever PlanetSurface.tscn shipped with.
|
||||
#
|
||||
# PlanetSurfaceController's _ready() (which calls _compose_surface()) does
|
||||
# not fire synchronously inside add_child() for a -s entry-point script -
|
||||
|
|
@ -20,6 +25,7 @@ extends SceneTree
|
|||
|
||||
var game_state: Node
|
||||
var scene_shader: Shader
|
||||
var rocky_shader: Shader
|
||||
var gas_shader: Shader
|
||||
var rocky_shader_after_compose: Shader
|
||||
var scene: Node
|
||||
|
|
@ -38,9 +44,10 @@ func _initialize() -> void:
|
|||
# has no business having an opinion about.
|
||||
var packed: PackedScene = load("res://PlanetSurface.tscn")
|
||||
var probe: Node = packed.instantiate()
|
||||
scene_shader = probe.get_node("BackgroundLayer/LunarBackground").material.shader
|
||||
scene_shader = probe.get_node("BackgroundLayer/Background").material.shader
|
||||
probe.free()
|
||||
|
||||
|
||||
# Loaded by path into a local, not referenced by its bare global
|
||||
# class_name - same load-order hazard PlanetSetups.gd and
|
||||
# PlanetSurfaceController.gd already work around: the class_name cache
|
||||
|
|
@ -48,10 +55,11 @@ func _initialize() -> void:
|
|||
var surface_profiles: Script = load("res://PlanetSurfaceProfiles.gd")
|
||||
var rocky_profile: Resource = surface_profiles.get_profile("rocky")
|
||||
var gas_profile: Resource = surface_profiles.get_profile("gas")
|
||||
if rocky_profile.background_shader != null:
|
||||
print("TEST_SURFACE_PROFILES: FAIL (rocky profile sets a background_shader - it should stay null)")
|
||||
if rocky_profile.background_shader == null:
|
||||
print("TEST_SURFACE_PROFILES: FAIL (rocky profile has no background_shader set)")
|
||||
quit(1)
|
||||
return
|
||||
rocky_shader = rocky_profile.background_shader
|
||||
if gas_profile.background_shader == null:
|
||||
print("TEST_SURFACE_PROFILES: FAIL (gas profile has no background_shader set)")
|
||||
quit(1)
|
||||
|
|
@ -73,7 +81,7 @@ func _process(delta: float) -> bool:
|
|||
0:
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
var rocky_bg: ShaderMaterial = scene.get_node("BackgroundLayer/LunarBackground").material
|
||||
var rocky_bg: ShaderMaterial = scene.get_node("BackgroundLayer/Background").material
|
||||
rocky_shader_after_compose = rocky_bg.shader
|
||||
scene.free()
|
||||
|
||||
|
|
@ -86,11 +94,11 @@ func _process(delta: float) -> bool:
|
|||
1:
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
var gas_bg: ShaderMaterial = scene.get_node("BackgroundLayer/LunarBackground").material
|
||||
var gas_bg: ShaderMaterial = scene.get_node("BackgroundLayer/Background").material
|
||||
scene.free()
|
||||
|
||||
if rocky_shader_after_compose != scene_shader:
|
||||
print("TEST_SURFACE_PROFILES: FAIL (landing on a rocky planet changed the background shader)")
|
||||
if rocky_shader_after_compose != rocky_shader:
|
||||
print("TEST_SURFACE_PROFILES: FAIL (landing on a rocky planet did not put rocky's own declared shader on the background)")
|
||||
quit(1)
|
||||
return true
|
||||
if gas_bg.shader != gas_shader:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,18 @@ extends SceneTree
|
|||
# fleet, a single-finger drag while the map is open pans the map content
|
||||
# instead of leaking through to the gameplay fleet-move/zoom controls, and
|
||||
# closing hides it again.
|
||||
#
|
||||
# Open and close are driven by TAPPING THE BUTTONS, not by calling open_map()
|
||||
# and close_map(). Calling them directly is what let this ship broken: the
|
||||
# map took over touch input while it was visible and swallowed it in _input(),
|
||||
# which runs before the GUI system - so the close button never saw the touch
|
||||
# and the map could not be closed at all on a touch-only device. Every part
|
||||
# of that was reachable from a test; none of it was reached, because the test
|
||||
# closed the map by calling the method the button was failing to reach.
|
||||
#
|
||||
# The open tap doubles as the harness check: if a synthetic tap could not be
|
||||
# delivered at all, the close tap would "fail" for a reason that has nothing
|
||||
# to do with the map.
|
||||
|
||||
const POS_TOLERANCE: float = 3.0
|
||||
|
||||
|
|
@ -14,6 +26,8 @@ var fleet: Node
|
|||
var camera: Camera2D
|
||||
var world_map: Control
|
||||
var content: Node2D
|
||||
var map_button: Control
|
||||
var close_button: Control
|
||||
var fleet_target_before: Vector2
|
||||
var camera_zoom_before: float
|
||||
var content_pos_before_drag: Vector2
|
||||
|
|
@ -29,6 +43,8 @@ func _initialize() -> void:
|
|||
camera = main_scene.get_node("Camera2D")
|
||||
world_map = main_scene.get_node("UI/WorldMap")
|
||||
content = main_scene.get_node("UI/WorldMap/MapContent")
|
||||
map_button = main_scene.get_node("UI/MapButton")
|
||||
close_button = main_scene.get_node("UI/WorldMap/CloseButton")
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
|
|
@ -41,7 +57,7 @@ func _process(delta: float) -> bool:
|
|||
print("TEST_WORLD_MAP_UI: FAIL (map visible by default)")
|
||||
quit(1)
|
||||
return true
|
||||
world_map.open_map()
|
||||
_tap(map_button)
|
||||
stage = 1
|
||||
elapsed = 0.0
|
||||
return false
|
||||
|
|
@ -55,6 +71,10 @@ func _process(delta: float) -> bool:
|
|||
print("open_visible=%s content_pos=%s expected=%s recenter_ok=%s" % [
|
||||
world_map.visible, content.position, expected_content_pos, recenter_ok
|
||||
])
|
||||
if not world_map.visible:
|
||||
print("TEST_WORLD_MAP_UI: FAIL (tapping the map button did not open the map)")
|
||||
quit(1)
|
||||
return true
|
||||
if not (world_map.visible and recenter_ok):
|
||||
print("TEST_WORLD_MAP_UI: FAIL (open/recenter)")
|
||||
quit(1)
|
||||
|
|
@ -102,7 +122,9 @@ func _process(delta: float) -> bool:
|
|||
quit(1)
|
||||
return true
|
||||
|
||||
world_map.close_map()
|
||||
# The subject: a tap on the close button, delivered exactly the
|
||||
# way the tap that opened the map was.
|
||||
_tap(close_button)
|
||||
stage = 4
|
||||
elapsed = 0.0
|
||||
return false
|
||||
|
|
@ -110,7 +132,11 @@ func _process(delta: float) -> bool:
|
|||
if elapsed < 0.1:
|
||||
return false
|
||||
var closed_ok: bool = not world_map.visible
|
||||
print("closed_ok=%s" % closed_ok)
|
||||
print("closed_by_button=%s map_button_back=%s" % [closed_ok, map_button.visible])
|
||||
if closed_ok and not map_button.visible:
|
||||
print("TEST_WORLD_MAP_UI: FAIL (map closed but its open button never came back)")
|
||||
quit(1)
|
||||
return true
|
||||
if closed_ok:
|
||||
print("TEST_WORLD_MAP_UI: PASS")
|
||||
quit(0)
|
||||
|
|
@ -121,6 +147,30 @@ func _process(delta: float) -> bool:
|
|||
return false
|
||||
|
||||
|
||||
# Taps a control at the centre of its own rect, pushed straight into the
|
||||
# viewport in local coordinates. Deliberately NOT Input.parse_input_event with
|
||||
# a hand-written position: headless rescales those through the stretch
|
||||
# transform (see the note in stage 3), which is fine for the pan check below -
|
||||
# it only cares about direction and ratio - but useless for hitting a target.
|
||||
# Its own touch index, away from the 0 the pan check below uses. push_input()
|
||||
# is delivered immediately while Input.parse_input_event() is queued for the
|
||||
# next frame, so the two can arrive out of order - and a release whose index
|
||||
# the map is still holding belongs to the map, correctly, which would make
|
||||
# this read as a broken button when nothing is broken. A finger cannot be in
|
||||
# two places at once, so distinct indices is what really happens anyway.
|
||||
const TAP_INDEX: int = 7
|
||||
|
||||
|
||||
func _tap(control: Control) -> void:
|
||||
var at: Vector2 = control.get_global_rect().get_center()
|
||||
for pressed in [true, false]:
|
||||
var ev := InputEventScreenTouch.new()
|
||||
ev.index = TAP_INDEX
|
||||
ev.pressed = pressed
|
||||
ev.position = at
|
||||
control.get_viewport().push_input(ev, true)
|
||||
|
||||
|
||||
func _touch(index: int, pressed: bool, pos: Vector2) -> void:
|
||||
var ev: InputEventScreenTouch = InputEventScreenTouch.new()
|
||||
ev.index = index
|
||||
|
|
|
|||
Loading…
Reference in a new issue