diff --git a/BuildController.gd b/BuildController.gd index 109f753..63cebf1 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -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 diff --git a/GameState.gd b/GameState.gd index 1e46592..b9df572 100644 --- a/GameState.gd +++ b/GameState.gd @@ -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 diff --git a/PlanetSetupEditor.gd b/PlanetSetupEditor.gd index 6d9b6aa..b4fc731 100644 --- a/PlanetSetupEditor.gd +++ b/PlanetSetupEditor.gd @@ -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 diff --git a/PlanetSetupEditor.tscn b/PlanetSetupEditor.tscn index e726a03..de55cd1 100644 --- a/PlanetSetupEditor.tscn +++ b/PlanetSetupEditor.tscn @@ -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 diff --git a/PlanetSetupHost.gd b/PlanetSetupHost.gd index 7624851..a1052ea 100644 --- a/PlanetSetupHost.gd +++ b/PlanetSetupHost.gd @@ -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 diff --git a/PlanetSetups.gd b/PlanetSetups.gd index 81105a4..a0bab41 100644 --- a/PlanetSetups.gd +++ b/PlanetSetups.gd @@ -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 diff --git a/PlanetSetups/map1.tres b/PlanetSetups/map1.tres new file mode 100644 index 0000000..8e12259 --- /dev/null +++ b/PlanetSetups/map1.tres @@ -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")]) diff --git a/PlanetSetups/research_lab_1.tres b/PlanetSetups/research_lab_1.tres new file mode 100644 index 0000000..83b5430 --- /dev/null +++ b/PlanetSetups/research_lab_1.tres @@ -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")]) diff --git a/PlanetSurface.tscn b/PlanetSurface.tscn index 199cdf8..e31fc4f 100644 --- a/PlanetSurface.tscn +++ b/PlanetSurface.tscn @@ -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") diff --git a/PlanetSurfaceController.gd b/PlanetSurfaceController.gd index 2777a61..f25ef45 100644 --- a/PlanetSurfaceController.gd +++ b/PlanetSurfaceController.gd @@ -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) diff --git a/PlanetSurfaceProfile.gd b/PlanetSurfaceProfile.gd index 7f132ba..d13a6f8 100644 --- a/PlanetSurfaceProfile.gd +++ b/PlanetSurfaceProfile.gd @@ -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) diff --git a/RockySurfaceProfile.tres b/RockySurfaceProfile.tres index ab220e0..fad0c2b 100644 --- a/RockySurfaceProfile.tres +++ b/RockySurfaceProfile.tres @@ -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) diff --git a/LunarBackground.gd b/SurfaceBackground.gd similarity index 57% rename from LunarBackground.gd rename to SurfaceBackground.gd index 8b85533..029c390 100644 --- a/LunarBackground.gd +++ b/SurfaceBackground.gd @@ -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 diff --git a/LunarBackground.gd.uid b/SurfaceBackground.gd.uid similarity index 100% rename from LunarBackground.gd.uid rename to SurfaceBackground.gd.uid diff --git a/SurfaceBackground.tscn b/SurfaceBackground.tscn new file mode 100644 index 0000000..f41a98d --- /dev/null +++ b/SurfaceBackground.tscn @@ -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") diff --git a/VoidSurfaceProfile.tres b/VoidSurfaceProfile.tres index da0b6f3..40cbe56 100644 --- a/VoidSurfaceProfile.tres +++ b/VoidSurfaceProfile.tres @@ -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) diff --git a/WorldMap.gd b/WorldMap.gd index a36e26c..b1f631a 100644 --- a/WorldMap.gd +++ b/WorldMap.gd @@ -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 diff --git a/gas_surface.gdshader b/gas_surface.gdshader index 4963c04..834e0aa 100644 --- a/gas_surface.gdshader +++ b/gas_surface.gdshader @@ -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); diff --git a/tests/_screenshot_tool.gd b/tests/_screenshot_tool.gd index 23ef716..dc81b21 100644 --- a/tests/_screenshot_tool.gd +++ b/tests/_screenshot_tool.gd @@ -8,6 +8,15 @@ extends SceneTree # this environment, softpipe's non-JIT rasterizer does not). # # Args (after `--`): [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: diff --git a/tests/screenshot.sh b/tests/screenshot.sh index f17bf9c..d5a715e 100644 --- a/tests/screenshot.sh +++ b/tests/screenshot.sh @@ -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 [wait_frames] [WxH] +# Usage: bash tests/screenshot.sh [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 [wait_f OUTPUT_PATH="${2:?usage: screenshot.sh [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[*]} " diff --git a/tests/test_build_uses_fleet_hold.gd b/tests/test_build_uses_fleet_hold.gd new file mode 100644 index 0000000..2a79f14 --- /dev/null +++ b/tests/test_build_uses_fleet_hold.gd @@ -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 diff --git a/tests/test_build_uses_fleet_hold.gd.uid b/tests/test_build_uses_fleet_hold.gd.uid new file mode 100644 index 0000000..e6a7af5 --- /dev/null +++ b/tests/test_build_uses_fleet_hold.gd.uid @@ -0,0 +1 @@ +uid://bldykfmviv5lp diff --git a/tests/test_conquered_planet_clear.gd b/tests/test_conquered_planet_clear.gd new file mode 100644 index 0000000..1f4bf03 --- /dev/null +++ b/tests/test_conquered_planet_clear.gd @@ -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 diff --git a/tests/test_conquered_planet_clear.gd.uid b/tests/test_conquered_planet_clear.gd.uid new file mode 100644 index 0000000..d57771c --- /dev/null +++ b/tests/test_conquered_planet_clear.gd.uid @@ -0,0 +1 @@ +uid://dmwnshp5r6gch diff --git a/tests/test_gas_bands.gd b/tests/test_gas_bands.gd new file mode 100644 index 0000000..9804173 --- /dev/null +++ b/tests/test_gas_bands.gd @@ -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 diff --git a/tests/test_gas_bands.gd.uid b/tests/test_gas_bands.gd.uid new file mode 100644 index 0000000..9d6a234 --- /dev/null +++ b/tests/test_gas_bands.gd.uid @@ -0,0 +1 @@ +uid://b30n8oom8283w diff --git a/tests/test_lunar_field.gd b/tests/test_lunar_field.gd index a6c2f03..e576b35 100644 --- a/tests/test_lunar_field.gd +++ b/tests/test_lunar_field.gd @@ -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: diff --git a/tests/test_planet_map_assignment.gd b/tests/test_planet_map_assignment.gd new file mode 100644 index 0000000..f566181 --- /dev/null +++ b/tests/test_planet_map_assignment.gd @@ -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) diff --git a/tests/test_planet_map_assignment.gd.uid b/tests/test_planet_map_assignment.gd.uid new file mode 100644 index 0000000..062d7f0 --- /dev/null +++ b/tests/test_planet_map_assignment.gd.uid @@ -0,0 +1 @@ +uid://by7lf8250s5m diff --git a/tests/test_setup_editor_delete.gd b/tests/test_setup_editor_delete.gd new file mode 100644 index 0000000..d5c0085 --- /dev/null +++ b/tests/test_setup_editor_delete.gd @@ -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 diff --git a/tests/test_setup_editor_delete.gd.uid b/tests/test_setup_editor_delete.gd.uid new file mode 100644 index 0000000..d02bc8a --- /dev/null +++ b/tests/test_setup_editor_delete.gd.uid @@ -0,0 +1 @@ +uid://boirnc74ieft4 diff --git a/tests/test_surface_background_shared.gd b/tests/test_surface_background_shared.gd new file mode 100644 index 0000000..eb901f0 --- /dev/null +++ b/tests/test_surface_background_shared.gd @@ -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) diff --git a/tests/test_surface_background_shared.gd.uid b/tests/test_surface_background_shared.gd.uid new file mode 100644 index 0000000..1505953 --- /dev/null +++ b/tests/test_surface_background_shared.gd.uid @@ -0,0 +1 @@ +uid://dynonerc7v0fp diff --git a/tests/test_surface_profiles.gd b/tests/test_surface_profiles.gd index e310f54..7002043 100644 --- a/tests/test_surface_profiles.gd +++ b/tests/test_surface_profiles.gd @@ -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: diff --git a/tests/test_world_map_ui.gd b/tests/test_world_map_ui.gd index 80d185b..8c284b9 100644 --- a/tests/test_world_map_ui.gd +++ b/tests/test_world_map_ui.gd @@ -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