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:
Loïc Wisniewski 2026-08-19 11:16:38 +02:00
commit f5a16a7b95
35 changed files with 1783 additions and 152 deletions

View file

@ -702,8 +702,11 @@ func _show_building_info(type: String) -> void:
elif can_build and definition.unique and GameState.has_building_of_type(type):
lines.append("WARNING: only one may exist - this destroys your current one.")
if can_build and not GameState.can_afford(cost):
lines.append("Not enough rocks - needs %d, you have %d." % [cost, GameState.rocks])
# The same pooled test the build itself uses. Asking the bank here while
# charging bank-plus-hold below would hide the build button for something
# the player can in fact afford.
if can_build and not GameState.can_afford_construction(cost):
lines.append("Not enough rocks - needs %d, you have %d." % [cost, GameState.construction_ore()])
can_build = false
info_title.text = definition.display_name
@ -750,17 +753,17 @@ func _confirm_build() -> void:
# glyph/HUD rendering the moment it produced anything.
build_status.text = "%s has nothing to extract" % planet.planet_name
return
if not GameState.spend(definition.cost):
build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.rocks]
if not GameState.spend_construction(definition.cost):
build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.construction_ore()]
return
GameState.add_extractor(planet.planet_name, planet.position, planet.resource_type, planet.planet_id)
build_menu.visible = false
return
if definition.unique and GameState.has_building_of_type(type):
if not GameState.can_afford(definition.cost):
if not GameState.can_afford_construction(definition.cost):
build_status.text = "Not enough rocks (need %d, have %d)" % [
definition.cost, GameState.rocks
definition.cost, GameState.construction_ore()
]
return
# Only now is the old one destroyed - never before the new one is
@ -772,8 +775,8 @@ func _confirm_build() -> void:
func _try_place(type: String, cost: int) -> void:
if not GameState.spend(cost):
build_status.text = "Not enough rocks (need %d, have %d)" % [cost, GameState.rocks]
if not GameState.spend_construction(cost):
build_status.text = "Not enough rocks (need %d, have %d)" % [cost, GameState.construction_ore()]
return
building_field.place(type, fleet.get_centroid())
build_menu.visible = false

View file

@ -1816,3 +1816,39 @@ func spend(cost: int) -> bool:
return false
bank_inventory.withdraw(RESOURCE_ORE, cost)
return true
# --- construction: the bank AND what the ships are carrying -----------------
#
# A build is put up by the fleet, on the spot, so it can use ore the fleet is
# holding as well as ore already banked. That is not a convenience: a fresh
# game starts with an empty bank, and the ONLY way to bank ore is to deliver
# it to a mothership. Charging construction to the bank alone therefore made
# the first mothership cost ore that could not exist yet - the fleet could
# mine all it liked and the game was unwinnable.
#
# Research still spends from the bank alone (can_afford/spend above). That
# stays deliberate: hauling ore back to a base to fund science is the loop,
# and unlike construction it was never deadlocked, because by the time there
# is anything to research there is a mothership to bank into.
func construction_ore() -> int:
return bank_inventory.amount(RESOURCE_ORE) + fleet_inventory.amount(RESOURCE_ORE)
func can_afford_construction(cost: int) -> bool:
return construction_ore() >= cost
func spend_construction(cost: int) -> bool:
# Bank first, hold second. The hold is also the fleet's mining capacity,
# so leaving it as full as possible means the next trip out can still
# carry something back.
if not can_afford_construction(cost):
return false
var from_bank: int = bank_inventory.withdraw(RESOURCE_ORE, cost)
var remaining: int = cost - from_bank
if remaining > 0:
fleet_inventory.withdraw(RESOURCE_ORE, remaining)
return true

View file

@ -60,6 +60,8 @@ const PALETTE_STEP: float = 92.0
@onready var load_panel: Control = $UI/LoadPanel
@onready var load_list: ItemList = $UI/LoadPanel/List
@onready var load_close_button: IconButton = $UI/LoadPanel/CloseButton
@onready var load_title_label: Label = $UI/LoadPanel/TitleLabel
@onready var delete_map_button: IconButton = $UI/LoadPanel/DeleteMapButton
var current_setup: Resource
var selected_palette: String = "capture_point"
@ -71,6 +73,11 @@ var _profile_cursor: int = 0
# kept only as the id list _load_list_setup_ids indexes into when the
# panel's own selection fires.
var _load_list_setup_ids: PackedStringArray = PackedStringArray()
# While armed, tapping a row in the load panel DELETES that setup instead of
# loading it. One button and one meaning per tap, rather than a second list
# or a per-row delete affordance that would be easy to hit by accident on a
# phone - and the panel says plainly which mode it is in.
var _delete_map_armed: bool = false
var _palette_buttons: Array = []
var _origin_marker: NeonPiece
@ -95,7 +102,8 @@ func _ready() -> void:
load_button.pressed.connect(_on_load_pressed)
delete_button.pressed.connect(_on_delete_pressed)
name_input.text_changed.connect(_on_name_changed)
load_close_button.pressed.connect(func(): load_panel.visible = false)
load_close_button.pressed.connect(_on_load_close_pressed)
delete_map_button.pressed.connect(_on_delete_map_pressed)
load_list.item_selected.connect(_on_load_list_item_selected)
_origin_marker = NeonPiece.create(item_host, Vector2.ZERO, ORIGIN_COLOR)
@ -164,6 +172,33 @@ func save_setup() -> Dictionary:
return {"ok": true, "message": "Saved '%s'." % current_setup.setup_id}
func delete_setup(setup_id: String) -> Dictionary:
# Deliberately mirrors save_setup(): same editor-only guard, same
# {ok, message} shape, and the same rescan afterwards so the in-memory
# registry stops offering a file that is no longer there.
if setup_id.strip_edges() == "":
return {"ok": false, "message": "No setup to delete."}
if not OS.has_feature("editor"):
return {"ok": false, "message": "Setups can only be deleted when running from the project."}
var path: String = "res://PlanetSetups/%s.tres" % setup_id
if not ResourceLoader.exists(path):
return {"ok": false, "message": "No setup file for '%s'." % setup_id}
var err: int = DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
if err != OK:
return {"ok": false, "message": "Delete failed (error %d)." % err}
# Godot writes a .uid sidecar next to a saved resource; leaving it behind
# makes the next import complain about a uid with no file.
var uid_path: String = ProjectSettings.globalize_path(path + ".uid")
if FileAccess.file_exists(uid_path):
DirAccess.remove_absolute(uid_path)
PlanetSetups.rescan()
# The deleted setup may be the one on screen. Leaving it loaded would let
# the next save silently recreate the file the player just removed.
if current_setup != null and String(current_setup.setup_id) == setup_id:
new_setup("untitled", "Untitled", _profile_ids[_profile_cursor])
return {"ok": true, "message": "Deleted '%s'." % setup_id}
# --- model: capture points ---------------------------------------------
func add_capture_point(at: Vector2) -> int:
@ -224,11 +259,13 @@ func remove_enemy(index: int) -> void:
func _refresh_background() -> void:
if current_setup == null or background == null:
return
# The exact call PlanetSurfaceController makes when landing, on the exact
# same instanced scene. Setting the colours here by hand is what let the
# editor and the game drift: this side never swapped in the profile's own
# SHADER, so a gas giant authored here was drawn as recoloured craters
# while the game drew cloud bands. See the header of SurfaceBackground.gd.
var profile: Resource = PlanetSurfaceProfiles.get_profile(current_setup.surface_profile_id)
var material: ShaderMaterial = background.material
material.set_shader_parameter("base_color", profile.base_color)
material.set_shader_parameter("crater_color", profile.crater_color)
material.set_shader_parameter("highlight_color", profile.highlight_color)
background.apply_profile(profile)
if profile_value_label != null:
profile_value_label.text = current_setup.surface_profile_id.to_upper()
_refresh_reward_label()
@ -351,22 +388,58 @@ func _on_save_pressed() -> void:
func _on_load_pressed() -> void:
if not _refresh_load_list():
return
_delete_map_armed = false
_refresh_load_panel_mode()
load_panel.visible = true
func _refresh_load_list() -> bool:
var setups: Array = PlanetSetups.all()
if setups.is_empty():
_set_status("No saved setups yet.")
return
load_panel.visible = false
return false
load_list.clear()
_load_list_setup_ids = PackedStringArray()
for setup in setups:
load_list.add_item("%s (%s)" % [setup.display_name, setup.surface_profile_id])
_load_list_setup_ids.append(setup.setup_id)
load_panel.visible = true
return true
func _refresh_load_panel_mode() -> void:
if load_title_label != null:
load_title_label.text = "Tap a setup to DELETE" if _delete_map_armed else "Load a setup"
func _on_delete_map_pressed() -> void:
_delete_map_armed = not _delete_map_armed
_refresh_load_panel_mode()
func _on_load_close_pressed() -> void:
load_panel.visible = false
_delete_map_armed = false
_refresh_load_panel_mode()
func _on_load_list_item_selected(index: int) -> void:
if index < 0 or index >= _load_list_setup_ids.size():
return
load_setup(_load_list_setup_ids[index])
var setup_id: String = _load_list_setup_ids[index]
if _delete_map_armed:
var outcome: Dictionary = delete_setup(setup_id)
_set_status(outcome.message)
# Disarmed either way: a delete that failed should not leave the next
# tap primed to try again on a different setup.
_delete_map_armed = false
if not _refresh_load_list():
return
_refresh_load_panel_mode()
return
load_setup(setup_id)
load_panel.visible = false

View file

@ -1,34 +1,13 @@
[gd_scene load_steps=5 format=3]
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://PlanetSetupEditor.gd" id="1"]
[ext_resource type="Script" path="res://LunarBackground.gd" id="2"]
[ext_resource type="Shader" path="res://lunar_surface.gdshader" id="3"]
[ext_resource type="PackedScene" path="res://SurfaceBackground.tscn" id="3"]
[ext_resource type="Script" path="res://IconButton.gd" id="4"]
[sub_resource type="ShaderMaterial" id="1"]
shader = ExtResource("3")
shader_parameter/camera_zoom = 1.0
shader_parameter/viewport_size = Vector2(720, 1280)
shader_parameter/cell_size = 220.0
shader_parameter/base_color = Color(0.75, 0.75, 0.76, 1)
shader_parameter/crater_color = Color(0.44, 0.44, 0.45, 1)
shader_parameter/highlight_color = Color(0.89, 0.89, 0.9, 1)
[node name="PlanetSetupEditor" type="Node2D"]
script = ExtResource("1")
[node name="BackgroundLayer" type="CanvasLayer" parent="."]
layer = -10
[node name="Background" type="ColorRect" parent="BackgroundLayer"]
material = SubResource("1")
mouse_filter = 2
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("2")
[node name="BackgroundLayer" parent="." instance=ExtResource("3")]
[node name="Camera2D" type="Camera2D" parent="."]
position = Vector2(0, 0)
@ -233,8 +212,28 @@ text = "Load a setup"
offset_left = 24
offset_top = 110
offset_right = 696
offset_bottom = 1180
offset_bottom = 1080
theme_override_font_sizes/font_size = 34
theme_override_constants/v_separation = 30
[node name="DeleteMapButton" type="Control" parent="UI/LoadPanel"]
offset_left = 24
offset_top = 1096
offset_right = 96
offset_bottom = 1168
script = ExtResource("4")
icon = 28
glow_color = Color(0.9, 0.35, 0.35, 1)
[node name="DeleteMapCaption" type="Label" parent="UI/LoadPanel"]
mouse_filter = 2
offset_left = 112
offset_top = 1108
offset_right = 690
offset_bottom = 1160
theme_override_font_sizes/font_size = 24
vertical_alignment = 1
text = "Delete a setup"
[node name="CloseButton" type="Control" parent="UI/LoadPanel"]
anchor_left = 1.0

View file

@ -23,10 +23,27 @@ func apply_setup(new_setup: Resource) -> void:
GameState.planet_definition_id, int(setup.research_point_reward))
for i in range(setup.capture_points.size()):
_add_capture_point(i, setup.capture_points[i])
if _garrison_is_defeated():
return
for spawn in setup.enemy_spawns:
_add_enemy(spawn)
# A conquered planet stays conquered. Its garrison was destroyed to take it,
# so re-landing must not quietly restore it - the capture points already come
# back marked captured, and enemies standing on a planet the player owns read
# as the conquest having been undone.
#
# Guarded on there being capture points to take, not on the conquered flag
# alone: register_capture_points() treats a setup with none as vacuously
# conquered (see PlanetSurfaceController's note on unregistered planets), and
# a setup that authored enemies but no capture points is a garrison nobody
# can ever have beaten. Suppressing those would delete them on first sight.
func _garrison_is_defeated() -> bool:
return not setup.capture_points.is_empty() \
and GameState.is_planet_conquered(GameState.planet_id)
func _add_capture_point(index: int, at: Vector2) -> void:
var point: Node = CAPTURE_POINT_SCENE.instantiate()
point.point_index = index

View file

@ -16,6 +16,8 @@ const PLANET_SETUP_SCRIPT: Script = preload("res://PlanetSetup.gd")
static var _all: Array = []
static var _scanned: bool = false
# Which setup each planet drew, once it has drawn one. See setup_for_planet.
static var _assigned: Dictionary = {}
static func _ensure_scanned() -> void:
@ -42,6 +44,17 @@ static func rescan() -> void:
_all = []
_scanned = false
_ensure_scanned()
# Assignments are PRUNED, not cleared. Clearing them would undo the whole
# point of remembering: saving one map in the editor would silently re-deal
# every planet the player had already visited. What has to go is only the
# entries pointing at a setup that no longer exists - a deleted map must
# not stay pinned to a planet.
var live: Dictionary = {}
for setup in _all:
live[setup] = true
for key in _assigned.keys():
if not live.has(_assigned[key]):
_assigned.erase(key)
static func all() -> Array:
@ -54,16 +67,41 @@ static func setups_for_profile(profile_id: String) -> Array:
for setup in all():
if setup.surface_profile_id == profile_id:
result.append(setup)
# Sorted by id, because the order they arrive in is whatever order the
# filesystem listed them in - which is not something to seed a choice
# with. The pick below indexes into this array, so an unstable order
# means the same planet can land on a different map from one run to the
# next, and the "this is MY planet's layout" illusion goes with it.
result.sort_custom(func(a, b): return String(a.setup_id) < String(b.setup_id))
return result
static func setup_for_planet(planet_id: String, profile_id: String) -> Resource:
# Same pattern as WorldGen.planet_data_at_chunk: seeded purely from the
# planet's own stable id, so the same planet always resolves to the
# same setup from anywhere, with nothing stored.
# Every planet gets a map of its own type, drawn at random, and keeps it.
#
# The draw is seeded purely from the planet's own stable id - same pattern
# as WorldGen.planet_data_at_chunk - so leaving a planet and coming back
# lands on the same layout without anything having to be saved.
#
# The result is then remembered anyway, because the seed alone is not
# quite enough: the pool it indexes into changes the moment the map editor
# saves a new setup, which would silently re-deal every planet in the
# middle of a session. Once a planet has been visited its map is pinned
# for as long as the process lives.
#
# Keyed on the profile too, not the planet alone: the memo must never be
# able to hand back a gas map for a rocky query just because that planet
# id was seen first.
var key: String = "%s|%s" % [planet_id, profile_id]
var remembered: Resource = _assigned.get(key)
if remembered != null:
return remembered
var candidates: Array = setups_for_profile(profile_id)
if candidates.is_empty():
return null
var rng: RandomNumberGenerator = RandomNumberGenerator.new()
rng.seed = hash(planet_id)
return candidates[rng.randi() % candidates.size()]
var chosen: Resource = candidates[rng.randi() % candidates.size()]
_assigned[key] = chosen
return chosen

89
PlanetSetups/map1.tres Normal file
View file

@ -0,0 +1,89 @@
[gd_resource type="Resource" script_class="PlanetSetup" format=3]
[ext_resource type="Script" path="res://PlanetSetupSpawn.gd" id="1_krxpw"]
[ext_resource type="Script" path="res://PlanetSetup.gd" id="2_nljfb"]
[sub_resource type="Resource" id="Resource_atujs"]
script = ExtResource("1_krxpw")
position = Vector2(-2005.3026, -137.49512)
[sub_resource type="Resource" id="Resource_seaud"]
script = ExtResource("1_krxpw")
position = Vector2(-1749.1129, -147.55289)
[sub_resource type="Resource" id="Resource_e5tts"]
script = ExtResource("1_krxpw")
position = Vector2(-1489.0216, 26.782429)
[sub_resource type="Resource" id="Resource_y4e3o"]
script = ExtResource("1_krxpw")
position = Vector2(-1408.3934, 240.23131)
[sub_resource type="Resource" id="Resource_fve7e"]
script = ExtResource("1_krxpw")
position = Vector2(-1576.1522, 546.43555)
[sub_resource type="Resource" id="Resource_dunmq"]
script = ExtResource("1_krxpw")
position = Vector2(-2046.9172, 481.61862)
[sub_resource type="Resource" id="Resource_hgibr"]
script = ExtResource("1_krxpw")
position = Vector2(-563.0971, -1320.9633)
[sub_resource type="Resource" id="Resource_xl5ap"]
script = ExtResource("1_krxpw")
position = Vector2(-399.2397, -1011.40643)
[sub_resource type="Resource" id="Resource_f2300"]
script = ExtResource("1_krxpw")
position = Vector2(-110.53853, -848.2465)
[sub_resource type="Resource" id="Resource_r0ph7"]
script = ExtResource("1_krxpw")
position = Vector2(265.29315, -930.944)
[sub_resource type="Resource" id="Resource_8kb31"]
script = ExtResource("1_krxpw")
position = Vector2(637.2235, -1261.734)
[sub_resource type="Resource" id="Resource_pf7co"]
script = ExtResource("1_krxpw")
position = Vector2(1845.347, -205.66466)
[sub_resource type="Resource" id="Resource_sviw7"]
script = ExtResource("1_krxpw")
position = Vector2(1338.1693, -72.67802)
[sub_resource type="Resource" id="Resource_elsje"]
script = ExtResource("1_krxpw")
position = Vector2(1270.5457, 227.93843)
[sub_resource type="Resource" id="Resource_5gck6"]
script = ExtResource("1_krxpw")
position = Vector2(1386.2861, 623.54553)
[sub_resource type="Resource" id="Resource_pkihc"]
script = ExtResource("1_krxpw")
position = Vector2(1665.8842, 706.24286)
[sub_resource type="Resource" id="Resource_th38b"]
script = ExtResource("1_krxpw")
position = Vector2(2074.2273, 560.9635)
[sub_resource type="Resource" id="Resource_bhel2"]
script = ExtResource("1_krxpw")
enemy_id = "sniper"
position = Vector2(-323.81326, 553.14087)
[sub_resource type="Resource" id="Resource_bupqc"]
script = ExtResource("1_krxpw")
enemy_id = "sniper"
position = Vector2(396.6392, 734.1813)
[resource]
script = ExtResource("2_nljfb")
setup_id = "map1"
display_name = "map1"
capture_points = PackedVector2Array(-1825.8397, 147.47598, -29.910276, -1294.1425, 1706.1982, 184.35464)
enemy_spawns = Array[Resource]([SubResource("Resource_atujs"), SubResource("Resource_seaud"), SubResource("Resource_e5tts"), SubResource("Resource_y4e3o"), SubResource("Resource_fve7e"), SubResource("Resource_dunmq"), SubResource("Resource_hgibr"), SubResource("Resource_xl5ap"), SubResource("Resource_f2300"), SubResource("Resource_r0ph7"), SubResource("Resource_8kb31"), SubResource("Resource_pf7co"), SubResource("Resource_sviw7"), SubResource("Resource_elsje"), SubResource("Resource_5gck6"), SubResource("Resource_pkihc"), SubResource("Resource_th38b"), SubResource("Resource_bhel2"), SubResource("Resource_bupqc")])

View file

@ -0,0 +1,56 @@
[gd_resource type="Resource" script_class="PlanetSetup" format=3]
[ext_resource type="Script" path="res://PlanetSetupSpawn.gd" id="1_1umkq"]
[ext_resource type="Script" path="res://PlanetSetup.gd" id="2_3lt83"]
[sub_resource type="Resource" id="Resource_4sgq7"]
script = ExtResource("1_1umkq")
enemy_id = "turret_spawner"
position = Vector2(-620, -180)
[sub_resource type="Resource" id="Resource_ppux6"]
script = ExtResource("1_1umkq")
enemy_id = "turret_spawner"
position = Vector2(640, -160)
[sub_resource type="Resource" id="Resource_tij1g"]
script = ExtResource("1_1umkq")
enemy_id = "artillery"
position = Vector2(0, -720)
[sub_resource type="Resource" id="Resource_cvqsq"]
script = ExtResource("1_1umkq")
enemy_id = "sniper"
position = Vector2(-380, 420)
[sub_resource type="Resource" id="Resource_ec1ix"]
script = ExtResource("1_1umkq")
enemy_id = "sniper"
position = Vector2(400, 440)
[sub_resource type="Resource" id="Resource_nx4ij"]
script = ExtResource("1_1umkq")
position = Vector2(120, 60)
[sub_resource type="Resource" id="Resource_sepu5"]
script = ExtResource("1_1umkq")
enemy_id = "enemy_mothership"
position = Vector2(731.2211, 301.56653)
[sub_resource type="Resource" id="Resource_luar8"]
script = ExtResource("1_1umkq")
enemy_id = "sniper"
position = Vector2(-593.29315, 150.69946)
[sub_resource type="Resource" id="Resource_ykd6p"]
script = ExtResource("1_1umkq")
enemy_id = "sniper"
position = Vector2(469.82953, -304.24854)
[resource]
script = ExtResource("2_3lt83")
setup_id = "research_lab_1"
display_name = "Research Lab Garrison"
surface_profile_id = "research_lab"
capture_points = PackedVector2Array(-450, -60, 460, -40, 0, 480)
enemy_spawns = Array[Resource]([SubResource("Resource_4sgq7"), SubResource("Resource_ppux6"), SubResource("Resource_tij1g"), SubResource("Resource_cvqsq"), SubResource("Resource_ec1ix"), SubResource("Resource_nx4ij"), SubResource("Resource_sepu5"), SubResource("Resource_luar8"), SubResource("Resource_ykd6p")])

View file

@ -1,8 +1,7 @@
[gd_scene load_steps=16 format=3]
[gd_scene load_steps=14 format=3]
[ext_resource type="Script" path="res://GameController.gd" id="1"]
[ext_resource type="Script" path="res://LunarBackground.gd" id="2"]
[ext_resource type="Shader" path="res://lunar_tiled.gdshader" id="3"]
[ext_resource type="PackedScene" path="res://SurfaceBackground.tscn" id="3"]
[ext_resource type="Script" path="res://Fleet.gd" id="4"]
[ext_resource type="Script" path="res://PlanetSurfaceController.gd" id="5"]
[ext_resource type="Script" path="res://IconButton.gd" id="6"]
@ -13,30 +12,10 @@
[ext_resource type="Script" path="res://SpeedFader.gd" id="17"]
[ext_resource type="Script" path="res://CaptureLabel.gd" id="15"]
[sub_resource type="ShaderMaterial" id="1"]
shader = ExtResource("3")
shader_parameter/camera_cell = Vector2(0, 0)
shader_parameter/camera_frac = Vector2(0, 0)
shader_parameter/camera_zoom = 1.0
shader_parameter/viewport_size = Vector2(720, 1280)
shader_parameter/cell_size = 220.0
shader_parameter/tile_cells = 32.0
[node name="PlanetSurface" type="Node2D"]
script = ExtResource("1")
[node name="BackgroundLayer" type="CanvasLayer" parent="."]
layer = -10
[node name="LunarBackground" type="ColorRect" parent="BackgroundLayer"]
material = SubResource("1")
mouse_filter = 2
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("2")
[node name="BackgroundLayer" parent="." instance=ExtResource("3")]
[node name="Fleet" type="Node2D" parent="."]
script = ExtResource("4")

View file

@ -18,7 +18,7 @@ const SETUP_HOST_SCENE: PackedScene = preload("res://PlanetSetupHost.tscn")
@onready var planet_name_label: Label = get_node("../UI/PlanetNameLabel")
@onready var exit_button: IconButton = get_node("../UI/ExitButton")
@onready var fade_rect: ColorRect = get_node("../FadeOverlay/FadeRect")
@onready var background: ColorRect = get_node("../BackgroundLayer/LunarBackground")
@onready var background: ColorRect = get_node("../BackgroundLayer/Background")
@onready var feature_host: Node2D = get_node("../SurfaceFeatures")
var transitioning: bool = false
@ -40,12 +40,11 @@ func _ready() -> void:
func _compose_surface(profile: Resource) -> void:
var atmosphere: ShaderMaterial = background.material
if profile.background_shader != null:
atmosphere.shader = profile.background_shader
atmosphere.set_shader_parameter("base_color", profile.base_color)
atmosphere.set_shader_parameter("crater_color", profile.crater_color)
atmosphere.set_shader_parameter("highlight_color", profile.highlight_color)
# Shared with PlanetSetupEditor, deliberately: the backdrop is one
# instanced scene configured through one method, so what an author sees
# while placing markers is what a player sees after landing. See the
# header of SurfaceBackground.gd.
background.apply_profile(profile)
for caption_path in ["../UI/BankCaption", "../UI/FleetCaption", "../UI/CaptureLabel"]:
var caption: Label = get_node(caption_path)
caption.add_theme_color_override("font_color", profile.caption_color)

View file

@ -6,7 +6,10 @@ extends Resource
@export var profile_id: String = "rocky"
@export var features_scene: PackedScene
# null means "keep the scene's existing background shader" (lunar craters).
# Which shader draws this surface's backdrop. Every profile names one - the
# old "null means keep whatever the scene already had" convention is what let
# the game and the setup editor drift apart, since each scene "already had" a
# different shader. SurfaceBackground.apply_profile() is the only consumer.
@export var background_shader: Shader = null
@export var base_color: Color = Color(0.75, 0.75, 0.76, 1.0)
@export var crater_color: Color = Color(0.44, 0.44, 0.45, 1.0)

View file

@ -1,12 +1,14 @@
[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=3 format=3]
[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=4 format=3]
[ext_resource type="Script" path="res://PlanetSurfaceProfile.gd" id="1"]
[ext_resource type="PackedScene" path="res://RockySurfaceFeatures.tscn" id="2"]
[ext_resource type="Shader" path="res://lunar_tiled.gdshader" id="3"]
[resource]
script = ExtResource("1")
profile_id = "rocky"
features_scene = ExtResource("2")
background_shader = ExtResource("3")
base_color = Color(0.75, 0.75, 0.76, 1)
crater_color = Color(0.44, 0.44, 0.45, 1)
highlight_color = Color(0.89, 0.89, 0.9, 1)

View file

@ -1,8 +1,19 @@
extends ColorRect
# Feeds the lunar shader the camera's position split into a CELL INDEX plus
# a fraction within that cell, rather than a raw world position. See the
# long note in Starfield.gd for why - identical reasoning, identical split.
# THE surface backdrop. Not "the one PlanetSurface.tscn happens to use" -
# both the landed surface and the setup editor instance the SAME
# SurfaceBackground.tscn, and both configure it through the SAME entry point
# (apply_profile below). That is deliberate: what the editor shows is only
# useful if it is what the player will actually see, and the two drifted
# apart the moment they were two hand-written ShaderMaterials in two scene
# files - the editor kept drawing recoloured craters for a gas giant,
# because swapping in the profile's own shader had only ever been written on
# the game's side. There is now one place to change, so there is nothing
# left to keep in sync by hand.
#
# Feeds the shader the camera's position split into a CELL INDEX plus a
# fraction within that cell, rather than a raw world position. See the long
# note in Starfield.gd for why - identical reasoning, identical split.
#
# Forgejo #68: also bakes the crater field once, at load, instead of letting
# it be re-derived per pixel per frame. The field is a fixed function of
@ -24,19 +35,42 @@ const TILE_CELLS: float = 32.0
# than as steps.
const BAKE_PIXELS_PER_CELL: int = 64
# The palette, sampled from a NASA Artemis lunar photograph. These live on
# the node rather than as parameters of the drawing material because the
# shader that actually consumes them is the BAKE shader, which the scene
# never references directly - leaving them as orphan parameters on the tiled
# material would make them look live when they are only read once at load.
# The palette the scene starts with, before any profile is applied. Every
# host calls apply_profile() and overwrites these; they exist so the node
# still draws something sane on its own (and so the editor's own first frame
# is not undefined).
@export var base_color: Color = Color(0.75, 0.75, 0.76)
@export var crater_color: Color = Color(0.44, 0.44, 0.45)
@export var highlight_color: Color = Color(0.89, 0.89, 0.90)
@onready var camera: Camera2D = get_node("../../Camera2D")
var camera: Camera2D = null
# What the current baked texture was baked FROM. A bake is only worth
# redoing when one of these changed - the editor re-applies a profile on
# every cycle of the profile button, and rocky and void share a shader while
# differing only in colour, so keying on the shader alone would show void's
# palette with rocky's crater texture.
var _baked_key: Array = []
func _ready() -> void:
# Applies a surface profile to this backdrop: which shader draws it, and the
# three colours that shader shades with. The single place either host is
# allowed to configure the background from.
#
# The profile's shader is authoritative and always applied. There used to be
# a "null means keep whatever the scene already had" convention here, which
# is precisely how the two scenes came apart: "whatever the scene already
# had" was a different shader in each of them.
func apply_profile(profile: Resource) -> void:
if profile == null or material == null:
return
base_color = profile.base_color
crater_color = profile.crater_color
highlight_color = profile.highlight_color
if profile.background_shader != null:
material.shader = profile.background_shader
material.set_shader_parameter("base_color", base_color)
material.set_shader_parameter("crater_color", crater_color)
material.set_shader_parameter("highlight_color", highlight_color)
_bake_field()
@ -54,6 +88,15 @@ func _material_wants_baked_field() -> bool:
func _process(_delta: float) -> void:
# Resolved lazily from the viewport rather than by a hardcoded sibling
# path: this scene is instanced under two different hosts, and a path
# that happens to be right in both is a coincidence waiting to be broken
# by the next host.
if not is_instance_valid(camera):
camera = get_viewport().get_camera_2d()
if camera == null:
return
var viewport_size: Vector2 = get_viewport_rect().size
var cell_size: float = material.get_shader_parameter("cell_size")
var p: Vector2 = camera.global_position / cell_size
@ -65,6 +108,12 @@ func _process(_delta: float) -> void:
# while - out of a mobile float entirely: GDScript does this in double
# precision. fposmod, not fmod, so flying into negative coordinates
# wraps to the far side of the tile instead of mirroring.
#
# The procedural shaders (gas, research lab) get the same wrapped index.
# They hash it rather than sampling a texture with it, so the wrap makes
# their fields periodic too - which is invisible at this period, and is
# what keeps every value they hash small enough to stay exact in a
# mobile float.
material.set_shader_parameter("camera_cell", Vector2(
fposmod(cell.x, TILE_CELLS), fposmod(cell.y, TILE_CELLS)))
material.set_shader_parameter("camera_frac", p - cell)
@ -74,8 +123,9 @@ func _process(_delta: float) -> void:
# Renders exactly one period of the procedural field into a texture and hands
# it to the tiled shader. Runs once, during the planet-arrival fade, where a
# single expensive frame is invisible.
# it to the tiled shader. Runs when a profile that samples a baked field is
# applied - during the planet-arrival fade, where a single expensive frame is
# invisible.
func _bake_field() -> void:
# Headless runs - every scene-based test - rasterize nothing, so a bake
# would allocate a render target, await a draw that never produces
@ -84,28 +134,25 @@ func _bake_field() -> void:
# for the background to appear on.
if DisplayServer.get_name() == "headless":
return
# One frame of grace before committing to the bake. A surface profile can
# replace this material's shader outright - a gas giant brings its own
# background (#51) - and PlanetSurfaceController does that in _ready(),
# alongside this one, with no ordering guarantee between them. Rendering
# a 2048-square lunar tile for a shader that will never sample it would
# waste exactly the frame the player is already waiting on to land.
await get_tree().process_frame
if not _material_wants_baked_field():
return
var key: Array = [material.shader, base_color, crater_color, highlight_color]
if key == _baked_key:
return
_baked_key = key
var procedural: Shader = load("res://lunar_surface.gdshader")
if procedural == null:
push_warning("lunar background: could not load the field shader to bake")
push_warning("surface background: could not load the field shader to bake")
return
var side: int = int(TILE_CELLS) * BAKE_PIXELS_PER_CELL
var bake_material := ShaderMaterial.new()
bake_material.shader = procedural
# Carry the look across from whatever the scene configured, so the tile
# is baked with the colours actually set on this node rather than the
# shader's own defaults.
# Carry the look across from whatever the profile configured, so the tile
# is baked with the colours actually in use rather than the shader's own
# defaults.
bake_material.set_shader_parameter("base_color", base_color)
bake_material.set_shader_parameter("crater_color", crater_color)
bake_material.set_shader_parameter("highlight_color", highlight_color)
@ -131,10 +178,11 @@ func _bake_field() -> void:
var image: Image = sub_viewport.get_texture().get_image()
sub_viewport.queue_free()
# Headless runs (every scene-based test) render nothing at all, so there
# is no image to read back. That is not a failure: the background simply
# has nothing to show, and the rest of the scene is unaffected.
if image == null or image.is_empty():
# Nothing rasterized, so nothing was learned - drop the key so a
# later apply of the same profile tries again rather than assuming
# a texture is already in place.
_baked_key = []
return
# Mipmaps are what keep a crater field from shimmering when the camera

27
SurfaceBackground.tscn Normal file
View file

@ -0,0 +1,27 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://SurfaceBackground.gd" id="1"]
[ext_resource type="Shader" path="res://lunar_tiled.gdshader" id="2"]
[sub_resource type="ShaderMaterial" id="1"]
resource_local_to_scene = true
shader = ExtResource("2")
shader_parameter/camera_cell = Vector2(0, 0)
shader_parameter/camera_frac = Vector2(0, 0)
shader_parameter/camera_zoom = 1.0
shader_parameter/viewport_size = Vector2(720, 1280)
shader_parameter/cell_size = 220.0
shader_parameter/tile_cells = 32.0
[node name="SurfaceBackground" type="CanvasLayer"]
layer = -10
[node name="Background" type="ColorRect" parent="."]
material = SubResource("1")
mouse_filter = 2
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")

View file

@ -1,10 +1,12 @@
[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=2 format=3]
[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=3 format=3]
[ext_resource type="Script" path="res://PlanetSurfaceProfile.gd" id="1"]
[ext_resource type="Shader" path="res://lunar_tiled.gdshader" id="2"]
[resource]
script = ExtResource("1")
profile_id = "void"
background_shader = ExtResource("2")
base_color = Color(0.04, 0.02, 0.07, 1)
crater_color = Color(0.01, 0.005, 0.02, 1)
highlight_color = Color(0.45, 0.15, 0.65, 1)

View file

@ -40,14 +40,38 @@ func recenter_view() -> void:
content.position = viewport_center - fleet_local * content.scale.x
# The map takes over touch input while it is open, so a drag pans the map
# instead of ordering the fleet somewhere. What it must NOT take over is a
# touch aimed at its own buttons.
#
# _input() runs BEFORE the GUI system, so set_input_as_handled() here stopped
# the touch ever reaching CloseButton._gui_input: the map could be opened and
# then not closed. On Android and desktop that stayed hidden, because Godot
# also emits an emulated MOUSE event for each touch and this only ever
# swallowed touch events - the button still got the mouse one, a second
# chance a phone browser does not give. So it surfaced on the web build,
# while being a real routing bug on every platform.
#
# Letting those events through is safe: IconButton consumes them with
# accept_event(), which marks them handled before GameController's
# _unhandled_input runs - so tapping Close still cannot also move the fleet.
func _input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventScreenTouch:
# A press over one of the map's own buttons is never adopted, and
# because it is never adopted, neither is anything else that touch
# goes on to do - the index simply never enters `touches`.
if event.pressed and _over_own_button(event.position):
return
if not event.pressed and not touches.has(event.index):
return
_handle_touch(event)
get_viewport().set_input_as_handled()
elif event is InputEventScreenDrag:
if not touches.has(event.index):
return
_handle_drag(event)
get_viewport().set_input_as_handled()
elif event is InputEventMagnifyGesture:
@ -55,6 +79,18 @@ func _input(event: InputEvent) -> void:
get_viewport().set_input_as_handled()
# Asked of the children rather than listed by name, so a button added to the
# map panel later is covered without anyone having to remember this exists.
# Keyed on having a `pressed` signal: the backdrop is a full-screen ColorRect
# and would otherwise match every point on the display.
func _over_own_button(at: Vector2) -> bool:
for child in get_children():
if child is Control and child.visible and child.has_signal("pressed") \
and (child as Control).get_global_rect().has_point(at):
return true
return false
func _handle_touch(event: InputEventScreenTouch) -> void:
if event.pressed:
touches[event.index] = event.position

View file

@ -3,18 +3,18 @@ shader_type canvas_item;
// Gas giant cloud-band backdrop for a landed gas surface. Same uniform
// interface as lunar_surface.gdshader (camera_cell/camera_frac/camera_zoom/
// viewport_size/cell_size/base_color/crater_color/highlight_color), so
// LunarBackground.gd feeds this shader with no changes at all - only
// PlanetSurfaceController swaps which shader the material uses.
// SurfaceBackground.gd feeds this shader with no changes at all - only the
// surface profile decides which shader the material uses.
//
// Renders horizontal cloud bands instead of craters: each grid row (a
// horizontal strip one cell tall) hashes its own band centre and thickness,
// nearest-row-wins picks which band a pixel belongs to, and the resulting
// distance field drives colour exactly the way the crater distance field
// does in lunar_surface.gdshader - base_color is the deep atmosphere,
// horizontal strip one cell tall) hashes its own band centre, thickness and
// drift speed, nearest-row-wins picks which band a pixel belongs to, and the
// resulting distance field drives colour exactly the way the crater distance
// field does in lunar_surface.gdshader - base_color is the deep atmosphere,
// crater_color the shadowed band body, highlight_color the lit band crest.
// A low-frequency turbulence value perturbs each row's edge so the bands
// ripple instead of reading as ruled lines, and slides sideways over time
// for the lateral drift.
// Turbulence perturbs each row's edge so the bands ripple instead of reading
// as ruled lines, and each row slides sideways at its OWN rate, which is what
// gives a gas giant its sheared, differentially-rotating look.
//
// PRECISION: never reconstruct an absolute world position. Every value fed
// to a hash is either a small camera-relative fraction (local, in [0,1]) or
@ -24,12 +24,31 @@ shader_type canvas_item;
// the fractional precision this shader depends on and no `highp` fixes it.
// The one exception is TIME, which the drift is built from directly per the
// ticket - see combine_lattice() for why that's still safe to mix in.
//
// CONTINUITY is the other invariant, and it is the one that broke. Every
// lattice coordinate handed to value_noise() is an (integer, fraction) pair,
// and the noise is only continuous if crossing a cell boundary - fraction
// 1 -> 0 while the integer steps by one - lands on the same value from both
// sides. That holds for the pair as it is built, and for halve_lattice()
// below, which is written to preserve it. It did NOT hold for the old
// second octave, which doubled the integer part while
// leaving the fraction alone: at every cell boundary the noise jumped from
// hash(2i+1) to hash(2i+2) with nothing in between, so the band edges broke
// into visibly disjoint segments once per cell across the whole screen. If a
// new octave is added, rescale the coordinate with halve_lattice() (or an
// equivalent that folds the carry) - anything that touches the integer and
// the fraction independently puts the seams straight back.
uniform vec2 camera_cell = vec2(0.0, 0.0);
uniform vec2 camera_frac = vec2(0.0, 0.0);
uniform float camera_zoom = 1.0;
uniform vec2 viewport_size = vec2(720.0, 1280.0);
uniform float cell_size = 220.0;
// The period the camera cell arrives wrapped to - see SurfaceBackground.gd,
// which wraps it in double precision before it ever reaches a shader. Must
// be a multiple of 4, so halving it twice for the coarse octaves below stays
// an exact integer count of cells.
uniform float tile_cells = 32.0;
uniform vec4 base_color : source_color = vec4(0.13, 0.025, 0.22, 1.0);
uniform vec4 crater_color : source_color = vec4(0.055, 0.008, 0.11, 1.0);
uniform vec4 highlight_color : source_color = vec4(0.52, 0.17, 0.7, 1.0);
@ -40,8 +59,22 @@ uniform vec4 highlight_color : source_color = vec4(0.52, 0.17, 0.7, 1.0);
const int ROW_SCAN = 2; // rows -1..+2
const float MIN_THICKNESS = 0.25;
const float MAX_THICKNESS = 0.55;
const float TURBULENCE_STRENGTH = 0.22;
// How far a band edge may wander, in cells. The scan above has to reach
// every row that could still change a pixel, or a band gets shaded by a row
// the loop never visited - a hard horizontal seam. The furthest normalized
// distance anything below reacts to is MAX_SIGNIFICANT_DISTANCE (the
// crest's outer edge), so a row matters out to that times MAX_THICKNESS,
// plus TURBULENCE_STRENGTH * 0.5 =
// 0.74 cells; with band centres anywhere in [0,1) that is only ever dy in
// -1..+1, inside the loop's -1..+2. Raising either constant eats that
// headroom - tests/test_gas_bands.gd fails if it runs out.
const float TURBULENCE_STRENGTH = 0.32;
const float DRIFT_SPEED = 0.045; // cells per second, lateral
// The largest normalized distance any shading term below reacts to: past
// this the crest has faded out and the pixel is plain atmosphere. Named
// because the row-scan headroom argument above is stated in terms of it,
// and tests/test_gas_bands.gd reads both out of this file to check it.
const float MAX_SIGNIFICANT_DISTANCE = 1.05;
// Same hash21 formulation as lunar_surface.gdshader (Dave Hoskins, all
// multipliers < 1 so it stays accurate at any cell-index magnitude).
@ -51,6 +84,15 @@ float hash21(vec2 p) {
return fract((p3.x + p3.y) * p3.z);
}
// Three independent values from one hash, so a row's centre, thickness and
// drift rate cost roughly what one of them used to. Dave Hoskins' hash23,
// same family and the same all-multipliers-below-one property.
vec3 hash23(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
p3 += dot(p3, p3.yxz + 33.33);
return fract((p3.xxy + p3.yzz) * p3.zyx);
}
// Splits a float into an exact integer part and a [0,1) remainder - the
// same floor/subtract trick `rel` -> `cell_rel` + `local` already uses
// below, generalized so it can also fold TIME-based drift into a lattice
@ -71,16 +113,66 @@ vec2 combine_lattice(vec2 base, vec2 add) {
return vec2(base.x + add.x + carry, f - carry);
}
// Halves a lattice coordinate: the same point, expressed on a lattice of
// twice the spacing, which is how a lower-frequency octave is taken without
// ever forming the underlying continuous coordinate.
//
// Continuous by construction. `rem` is which half of the coarse cell the
// fine index sits in (0 or 1), so the fraction is (rem + f) * 0.5, sweeping
// 0 -> 1 across the two fine cells that make up one coarse cell. Crossing
// out of the first (f: 1 -> 0, i -> i+1, rem: 0 -> 1) gives 0.5 from both
// sides; crossing out of the second gives 1.0 then 0.0 with the coarse
// index stepping, which is the same lattice point.
vec2 halve_lattice(vec2 coord) {
float half_i = floor(coord.x * 0.5);
float rem = coord.x - half_i * 2.0;
return vec2(half_i, (rem + coord.y) * 0.5);
}
// 1D value noise over an integer lattice, given as a pre-split (i, f) pair:
// hashes the two neighboring integers and blends by the fraction. `axis`
// varies the hash sequence so different callers (different rows, or the
// turbulence octaves) don't sample the same noise.
float value_noise(vec2 coord, float axis) {
float a = hash21(vec2(coord.x, axis));
float b = hash21(vec2(coord.x + 1.0, axis));
//
// Every index is taken modulo `period` first, which is what makes the field
// exactly periodic - the same trick lunar_surface.gdshader uses. It is not
// decoration: the camera cell index arrives ALREADY WRAPPED into
// [0, tile_cells), so a pixel's cell index shifts by exactly tile_cells the
// moment the camera crosses a wrap boundary. Hash on the raw index and the
// entire sky rearranges itself at that instant. Hash on the wrapped one and
// the shift lands on the same value, so the boundary cannot be seen.
//
// The +1 neighbour is wrapped too, or the seam would simply move to the
// last cell of each period instead of disappearing.
float value_noise(vec2 coord, float period, float axis) {
float a = hash21(vec2(mod(coord.x, period), axis));
float b = hash21(vec2(mod(coord.x + 1.0, period), axis));
return mix(a, b, smoothstep(0.0, 1.0, coord.y));
}
// Fractal turbulence for one row: three octaves, each an exact rescaling of
// the same lattice coordinate. The weights are deliberately bottom-heavy -
// the quarter-frequency octave (one undulation per four cells, ~880 world
// units) carries most of the amplitude, so bands sweep in long slow curves
// and the finer octaves only roughen the edge. The old version was a single
// cell-frequency wobble, which read as buzz on the band edge rather than as
// weather.
//
// Each octave carries its OWN period: halving the lattice halves how many
// cells one period spans, so the coarse octaves wrap at tile_cells/2 and
// tile_cells/4. Crossing a camera wrap shifts the fine index by exactly
// tile_cells, hence the halved indices by exactly tile_cells/2 and /4, and
// the parity halve_lattice keys its fraction off is preserved because
// tile_cells divides by 4 - so all three octaves land on the same values
// they had a moment before.
float row_turbulence(vec2 lattice, float period, float axis) {
vec2 half_freq = halve_lattice(lattice);
vec2 quarter = 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;
}
void fragment() {
vec2 screen_px = UV * viewport_size;
vec2 centered = screen_px - viewport_size * 0.5;
@ -93,13 +185,8 @@ void fragment() {
vec2 local = rel - cell_rel;
vec2 cell = camera_cell + cell_rel;
// Lateral drift, folded into the X lattice coordinate the safe way:
// TIME is unbounded but session-scale (the ticket calls for using it
// directly, no extra uniform), so it gets its own int/frac split and is
// combined with the cell's via combine_lattice() rather than added to
// cell.x as one continuous float.
vec2 drift = int_frac(TIME * DRIFT_SPEED);
vec2 turb_x = combine_lattice(vec2(cell.x, local.x), drift);
vec2 base_lattice = vec2(cell.x, local.x);
float time_units = TIME * DRIFT_SPEED;
// Nearest-row-wins band field, the same single-scalar discipline as
// lunar_surface.gdshader's nearest-crater distance: every visual below
@ -108,13 +195,25 @@ void fragment() {
float nearest = 1000.0;
for (int dy = -1; dy <= ROW_SCAN; dy++) {
float row = cell.y + float(dy);
float band_centre = hash21(vec2(row, 5.0));
float thickness = mix(MIN_THICKNESS, MAX_THICKNESS, hash21(vec2(row, 8.0)));
// centre, thickness and drift rate, from one hash - on the WRAPPED
// row index, so a band keeps its identity across a camera wrap.
vec3 row_hash = hash23(vec2(mod(row, tile_cells), 5.0));
float band_centre = row_hash.x;
float thickness = mix(MIN_THICKNESS, MAX_THICKNESS, row_hash.y);
// Differential rotation: every row slides at its own rate, and some
// slide backwards. Neighbouring bands therefore shear past each
// other instead of the whole sky sliding as one sheet.
float drift_rate = mix(-1.0, 1.0, row_hash.z);
// Two octaves of drifting turbulence, phased per-row so neighboring
// bands don't ripple in lockstep.
float turb = value_noise(turb_x, row * 3.7 + 11.0) * 0.65
+ value_noise(vec2(turb_x.x * 2.0, turb_x.y), row * 3.7 + 47.0) * 0.35;
// TIME is unbounded but session-scale (the ticket calls for using it
// directly, no extra uniform), so it gets its own int/frac split and
// is combined with the cell's via combine_lattice() rather than
// added to cell.x as one continuous float.
vec2 drift = int_frac(time_units * drift_rate);
vec2 row_lattice = combine_lattice(base_lattice, drift);
// Phased per-row so neighbouring bands don't ripple in lockstep.
float turb = row_turbulence(row_lattice, tile_cells, mod(row, tile_cells) * 3.7);
// Safe: local.y is bounded [0,1), dy and row are small integers, so
// this stays a small camera-relative value with no huge magnitude.
@ -126,7 +225,8 @@ void fragment() {
}
float core = 1.0 - smoothstep(0.0, 0.6, nearest);
float crest = smoothstep(0.5, 0.75, nearest) * (1.0 - smoothstep(0.75, 1.0, nearest));
float crest = smoothstep(0.5, 0.78, nearest)
* (1.0 - smoothstep(0.78, MAX_SIGNIFICANT_DISTANCE, nearest));
vec3 color = base_color.rgb;
color = mix(color, crater_color.rgb, core * 0.85);

View file

@ -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:

View file

@ -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[*]}
"

View 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

View file

@ -0,0 +1 @@
uid://bldykfmviv5lp

View 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

View file

@ -0,0 +1 @@
uid://dmwnshp5r6gch

307
tests/test_gas_bands.gd Normal file
View 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

View file

@ -0,0 +1 @@
uid://b30n8oom8283w

View file

@ -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:

View 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)

View file

@ -0,0 +1 @@
uid://by7lf8250s5m

View 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

View file

@ -0,0 +1 @@
uid://boirnc74ieft4

View 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)

View file

@ -0,0 +1 @@
uid://dynonerc7v0fp

View file

@ -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:

View file

@ -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