Share one backdrop between game and editor, and fix three input/economy bugs
The setup editor and the landed surface drew different backgrounds for the same planet. Each scene carried its own hand-written ShaderMaterial, and only the game side ever swapped in a profile's shader, so a gas giant authored in the editor showed recoloured craters while the game showed cloud bands. Both now instance one SurfaceBackground.tscn and configure it through one method, and every profile names its own shader - the old "null means keep whatever the scene already had" convention was the drift, since each scene "already had" something different. The shared material is resource_local_to_scene, or two live surfaces would repaint each other. The gas giant's bands broke into disjoint segments once per cell: the second turbulence octave doubled only the INTEGER half of a lattice coordinate, which is a different point in every cell rather than the same point at twice the frequency. Measured, the old form steps 0.153 at a cell boundary against 0.0002 now. A second discontinuity went with it - the camera cell index arrives pre-wrapped, so hashing it raw rearranged the whole sky every 32 cells; hashes are now wrapped per octave and the field is exactly periodic. Bands also gained low-frequency octaves and per-row differential drift. WorldMap swallowed every touch in _input(), which runs before the GUI system, so CloseButton never saw one: the map could be opened and not closed. Android and desktop hid it behind Godot's emulated mouse event, which this never swallowed; a phone browser gives no such second chance. Its test had been closing the map by calling the method the button was failing to reach. Construction now draws on the fleet hold as well as the bank. With an empty starting bank and ore bankable only at a mothership, charging construction to the bank alone made the first mothership cost ore that could not yet exist. Also: a conquered planet no longer re-spawns its garrison (guarded on having capture points, since a setup with none registers as vacuously conquered); planets keep the map they drew, with the candidate list sorted rather than left in filesystem order and assignments pruned rather than cleared on rescan; and the map editor lists setups at a tappable size and can delete one, as an explicit armed mode rather than a tap away from destroying work. Full suite 106/106. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
137e4e528d
commit
f5a16a7b95
35 changed files with 1783 additions and 152 deletions
|
|
@ -702,8 +702,11 @@ func _show_building_info(type: String) -> void:
|
||||||
elif can_build and definition.unique and GameState.has_building_of_type(type):
|
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.")
|
lines.append("WARNING: only one may exist - this destroys your current one.")
|
||||||
|
|
||||||
if can_build and not GameState.can_afford(cost):
|
# The same pooled test the build itself uses. Asking the bank here while
|
||||||
lines.append("Not enough rocks - needs %d, you have %d." % [cost, GameState.rocks])
|
# 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
|
can_build = false
|
||||||
|
|
||||||
info_title.text = definition.display_name
|
info_title.text = definition.display_name
|
||||||
|
|
@ -750,17 +753,17 @@ func _confirm_build() -> void:
|
||||||
# glyph/HUD rendering the moment it produced anything.
|
# glyph/HUD rendering the moment it produced anything.
|
||||||
build_status.text = "%s has nothing to extract" % planet.planet_name
|
build_status.text = "%s has nothing to extract" % planet.planet_name
|
||||||
return
|
return
|
||||||
if not GameState.spend(definition.cost):
|
if not GameState.spend_construction(definition.cost):
|
||||||
build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.rocks]
|
build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.construction_ore()]
|
||||||
return
|
return
|
||||||
GameState.add_extractor(planet.planet_name, planet.position, planet.resource_type, planet.planet_id)
|
GameState.add_extractor(planet.planet_name, planet.position, planet.resource_type, planet.planet_id)
|
||||||
build_menu.visible = false
|
build_menu.visible = false
|
||||||
return
|
return
|
||||||
|
|
||||||
if definition.unique and GameState.has_building_of_type(type):
|
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)" % [
|
build_status.text = "Not enough rocks (need %d, have %d)" % [
|
||||||
definition.cost, GameState.rocks
|
definition.cost, GameState.construction_ore()
|
||||||
]
|
]
|
||||||
return
|
return
|
||||||
# Only now is the old one destroyed - never before the new one is
|
# 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:
|
func _try_place(type: String, cost: int) -> void:
|
||||||
if not GameState.spend(cost):
|
if not GameState.spend_construction(cost):
|
||||||
build_status.text = "Not enough rocks (need %d, have %d)" % [cost, GameState.rocks]
|
build_status.text = "Not enough rocks (need %d, have %d)" % [cost, GameState.construction_ore()]
|
||||||
return
|
return
|
||||||
building_field.place(type, fleet.get_centroid())
|
building_field.place(type, fleet.get_centroid())
|
||||||
build_menu.visible = false
|
build_menu.visible = false
|
||||||
|
|
|
||||||
36
GameState.gd
36
GameState.gd
|
|
@ -1816,3 +1816,39 @@ func spend(cost: int) -> bool:
|
||||||
return false
|
return false
|
||||||
bank_inventory.withdraw(RESOURCE_ORE, cost)
|
bank_inventory.withdraw(RESOURCE_ORE, cost)
|
||||||
return true
|
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
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,8 @@ const PALETTE_STEP: float = 92.0
|
||||||
@onready var load_panel: Control = $UI/LoadPanel
|
@onready var load_panel: Control = $UI/LoadPanel
|
||||||
@onready var load_list: ItemList = $UI/LoadPanel/List
|
@onready var load_list: ItemList = $UI/LoadPanel/List
|
||||||
@onready var load_close_button: IconButton = $UI/LoadPanel/CloseButton
|
@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 current_setup: Resource
|
||||||
var selected_palette: String = "capture_point"
|
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
|
# kept only as the id list _load_list_setup_ids indexes into when the
|
||||||
# panel's own selection fires.
|
# panel's own selection fires.
|
||||||
var _load_list_setup_ids: PackedStringArray = PackedStringArray()
|
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 _palette_buttons: Array = []
|
||||||
var _origin_marker: NeonPiece
|
var _origin_marker: NeonPiece
|
||||||
|
|
||||||
|
|
@ -95,7 +102,8 @@ func _ready() -> void:
|
||||||
load_button.pressed.connect(_on_load_pressed)
|
load_button.pressed.connect(_on_load_pressed)
|
||||||
delete_button.pressed.connect(_on_delete_pressed)
|
delete_button.pressed.connect(_on_delete_pressed)
|
||||||
name_input.text_changed.connect(_on_name_changed)
|
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)
|
load_list.item_selected.connect(_on_load_list_item_selected)
|
||||||
|
|
||||||
_origin_marker = NeonPiece.create(item_host, Vector2.ZERO, ORIGIN_COLOR)
|
_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}
|
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 ---------------------------------------------
|
# --- model: capture points ---------------------------------------------
|
||||||
|
|
||||||
func add_capture_point(at: Vector2) -> int:
|
func add_capture_point(at: Vector2) -> int:
|
||||||
|
|
@ -224,11 +259,13 @@ func remove_enemy(index: int) -> void:
|
||||||
func _refresh_background() -> void:
|
func _refresh_background() -> void:
|
||||||
if current_setup == null or background == null:
|
if current_setup == null or background == null:
|
||||||
return
|
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 profile: Resource = PlanetSurfaceProfiles.get_profile(current_setup.surface_profile_id)
|
||||||
var material: ShaderMaterial = background.material
|
background.apply_profile(profile)
|
||||||
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)
|
|
||||||
if profile_value_label != null:
|
if profile_value_label != null:
|
||||||
profile_value_label.text = current_setup.surface_profile_id.to_upper()
|
profile_value_label.text = current_setup.surface_profile_id.to_upper()
|
||||||
_refresh_reward_label()
|
_refresh_reward_label()
|
||||||
|
|
@ -351,22 +388,58 @@ func _on_save_pressed() -> void:
|
||||||
|
|
||||||
|
|
||||||
func _on_load_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()
|
var setups: Array = PlanetSetups.all()
|
||||||
if setups.is_empty():
|
if setups.is_empty():
|
||||||
_set_status("No saved setups yet.")
|
_set_status("No saved setups yet.")
|
||||||
return
|
load_panel.visible = false
|
||||||
|
return false
|
||||||
load_list.clear()
|
load_list.clear()
|
||||||
_load_list_setup_ids = PackedStringArray()
|
_load_list_setup_ids = PackedStringArray()
|
||||||
for setup in setups:
|
for setup in setups:
|
||||||
load_list.add_item("%s (%s)" % [setup.display_name, setup.surface_profile_id])
|
load_list.add_item("%s (%s)" % [setup.display_name, setup.surface_profile_id])
|
||||||
_load_list_setup_ids.append(setup.setup_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:
|
func _on_load_list_item_selected(index: int) -> void:
|
||||||
if index < 0 or index >= _load_list_setup_ids.size():
|
if index < 0 or index >= _load_list_setup_ids.size():
|
||||||
return
|
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
|
load_panel.visible = false
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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://PlanetSetupEditor.gd" id="1"]
|
||||||
[ext_resource type="Script" path="res://LunarBackground.gd" id="2"]
|
[ext_resource type="PackedScene" path="res://SurfaceBackground.tscn" id="3"]
|
||||||
[ext_resource type="Shader" path="res://lunar_surface.gdshader" id="3"]
|
|
||||||
[ext_resource type="Script" path="res://IconButton.gd" id="4"]
|
[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"]
|
[node name="PlanetSetupEditor" type="Node2D"]
|
||||||
script = ExtResource("1")
|
script = ExtResource("1")
|
||||||
|
|
||||||
[node name="BackgroundLayer" type="CanvasLayer" parent="."]
|
[node name="BackgroundLayer" parent="." instance=ExtResource("3")]
|
||||||
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="Camera2D" type="Camera2D" parent="."]
|
[node name="Camera2D" type="Camera2D" parent="."]
|
||||||
position = Vector2(0, 0)
|
position = Vector2(0, 0)
|
||||||
|
|
@ -233,8 +212,28 @@ text = "Load a setup"
|
||||||
offset_left = 24
|
offset_left = 24
|
||||||
offset_top = 110
|
offset_top = 110
|
||||||
offset_right = 696
|
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
|
theme_override_font_sizes/font_size = 24
|
||||||
|
vertical_alignment = 1
|
||||||
|
text = "Delete a setup"
|
||||||
|
|
||||||
[node name="CloseButton" type="Control" parent="UI/LoadPanel"]
|
[node name="CloseButton" type="Control" parent="UI/LoadPanel"]
|
||||||
anchor_left = 1.0
|
anchor_left = 1.0
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,27 @@ func apply_setup(new_setup: Resource) -> void:
|
||||||
GameState.planet_definition_id, int(setup.research_point_reward))
|
GameState.planet_definition_id, int(setup.research_point_reward))
|
||||||
for i in range(setup.capture_points.size()):
|
for i in range(setup.capture_points.size()):
|
||||||
_add_capture_point(i, setup.capture_points[i])
|
_add_capture_point(i, setup.capture_points[i])
|
||||||
|
if _garrison_is_defeated():
|
||||||
|
return
|
||||||
for spawn in setup.enemy_spawns:
|
for spawn in setup.enemy_spawns:
|
||||||
_add_enemy(spawn)
|
_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:
|
func _add_capture_point(index: int, at: Vector2) -> void:
|
||||||
var point: Node = CAPTURE_POINT_SCENE.instantiate()
|
var point: Node = CAPTURE_POINT_SCENE.instantiate()
|
||||||
point.point_index = index
|
point.point_index = index
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ const PLANET_SETUP_SCRIPT: Script = preload("res://PlanetSetup.gd")
|
||||||
|
|
||||||
static var _all: Array = []
|
static var _all: Array = []
|
||||||
static var _scanned: bool = false
|
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:
|
static func _ensure_scanned() -> void:
|
||||||
|
|
@ -42,6 +44,17 @@ static func rescan() -> void:
|
||||||
_all = []
|
_all = []
|
||||||
_scanned = false
|
_scanned = false
|
||||||
_ensure_scanned()
|
_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:
|
static func all() -> Array:
|
||||||
|
|
@ -54,16 +67,41 @@ static func setups_for_profile(profile_id: String) -> Array:
|
||||||
for setup in all():
|
for setup in all():
|
||||||
if setup.surface_profile_id == profile_id:
|
if setup.surface_profile_id == profile_id:
|
||||||
result.append(setup)
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
static func setup_for_planet(planet_id: String, profile_id: String) -> Resource:
|
static func setup_for_planet(planet_id: String, profile_id: String) -> Resource:
|
||||||
# Same pattern as WorldGen.planet_data_at_chunk: seeded purely from the
|
# Every planet gets a map of its own type, drawn at random, and keeps it.
|
||||||
# planet's own stable id, so the same planet always resolves to the
|
#
|
||||||
# same setup from anywhere, with nothing stored.
|
# 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)
|
var candidates: Array = setups_for_profile(profile_id)
|
||||||
if candidates.is_empty():
|
if candidates.is_empty():
|
||||||
return null
|
return null
|
||||||
var rng: RandomNumberGenerator = RandomNumberGenerator.new()
|
var rng: RandomNumberGenerator = RandomNumberGenerator.new()
|
||||||
rng.seed = hash(planet_id)
|
rng.seed = hash(planet_id)
|
||||||
return candidates[rng.randi() % candidates.size()]
|
var chosen: Resource = candidates[rng.randi() % candidates.size()]
|
||||||
|
_assigned[key] = chosen
|
||||||
|
return chosen
|
||||||
|
|
|
||||||
89
PlanetSetups/map1.tres
Normal file
89
PlanetSetups/map1.tres
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
[gd_resource type="Resource" script_class="PlanetSetup" format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://PlanetSetupSpawn.gd" id="1_krxpw"]
|
||||||
|
[ext_resource type="Script" path="res://PlanetSetup.gd" id="2_nljfb"]
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_atujs"]
|
||||||
|
script = ExtResource("1_krxpw")
|
||||||
|
position = Vector2(-2005.3026, -137.49512)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_seaud"]
|
||||||
|
script = ExtResource("1_krxpw")
|
||||||
|
position = Vector2(-1749.1129, -147.55289)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_e5tts"]
|
||||||
|
script = ExtResource("1_krxpw")
|
||||||
|
position = Vector2(-1489.0216, 26.782429)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_y4e3o"]
|
||||||
|
script = ExtResource("1_krxpw")
|
||||||
|
position = Vector2(-1408.3934, 240.23131)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_fve7e"]
|
||||||
|
script = ExtResource("1_krxpw")
|
||||||
|
position = Vector2(-1576.1522, 546.43555)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_dunmq"]
|
||||||
|
script = ExtResource("1_krxpw")
|
||||||
|
position = Vector2(-2046.9172, 481.61862)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_hgibr"]
|
||||||
|
script = ExtResource("1_krxpw")
|
||||||
|
position = Vector2(-563.0971, -1320.9633)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_xl5ap"]
|
||||||
|
script = ExtResource("1_krxpw")
|
||||||
|
position = Vector2(-399.2397, -1011.40643)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_f2300"]
|
||||||
|
script = ExtResource("1_krxpw")
|
||||||
|
position = Vector2(-110.53853, -848.2465)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_r0ph7"]
|
||||||
|
script = ExtResource("1_krxpw")
|
||||||
|
position = Vector2(265.29315, -930.944)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_8kb31"]
|
||||||
|
script = ExtResource("1_krxpw")
|
||||||
|
position = Vector2(637.2235, -1261.734)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_pf7co"]
|
||||||
|
script = ExtResource("1_krxpw")
|
||||||
|
position = Vector2(1845.347, -205.66466)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_sviw7"]
|
||||||
|
script = ExtResource("1_krxpw")
|
||||||
|
position = Vector2(1338.1693, -72.67802)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_elsje"]
|
||||||
|
script = ExtResource("1_krxpw")
|
||||||
|
position = Vector2(1270.5457, 227.93843)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_5gck6"]
|
||||||
|
script = ExtResource("1_krxpw")
|
||||||
|
position = Vector2(1386.2861, 623.54553)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_pkihc"]
|
||||||
|
script = ExtResource("1_krxpw")
|
||||||
|
position = Vector2(1665.8842, 706.24286)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_th38b"]
|
||||||
|
script = ExtResource("1_krxpw")
|
||||||
|
position = Vector2(2074.2273, 560.9635)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_bhel2"]
|
||||||
|
script = ExtResource("1_krxpw")
|
||||||
|
enemy_id = "sniper"
|
||||||
|
position = Vector2(-323.81326, 553.14087)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_bupqc"]
|
||||||
|
script = ExtResource("1_krxpw")
|
||||||
|
enemy_id = "sniper"
|
||||||
|
position = Vector2(396.6392, 734.1813)
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("2_nljfb")
|
||||||
|
setup_id = "map1"
|
||||||
|
display_name = "map1"
|
||||||
|
capture_points = PackedVector2Array(-1825.8397, 147.47598, -29.910276, -1294.1425, 1706.1982, 184.35464)
|
||||||
|
enemy_spawns = Array[Resource]([SubResource("Resource_atujs"), SubResource("Resource_seaud"), SubResource("Resource_e5tts"), SubResource("Resource_y4e3o"), SubResource("Resource_fve7e"), SubResource("Resource_dunmq"), SubResource("Resource_hgibr"), SubResource("Resource_xl5ap"), SubResource("Resource_f2300"), SubResource("Resource_r0ph7"), SubResource("Resource_8kb31"), SubResource("Resource_pf7co"), SubResource("Resource_sviw7"), SubResource("Resource_elsje"), SubResource("Resource_5gck6"), SubResource("Resource_pkihc"), SubResource("Resource_th38b"), SubResource("Resource_bhel2"), SubResource("Resource_bupqc")])
|
||||||
56
PlanetSetups/research_lab_1.tres
Normal file
56
PlanetSetups/research_lab_1.tres
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
[gd_resource type="Resource" script_class="PlanetSetup" format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://PlanetSetupSpawn.gd" id="1_1umkq"]
|
||||||
|
[ext_resource type="Script" path="res://PlanetSetup.gd" id="2_3lt83"]
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_4sgq7"]
|
||||||
|
script = ExtResource("1_1umkq")
|
||||||
|
enemy_id = "turret_spawner"
|
||||||
|
position = Vector2(-620, -180)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_ppux6"]
|
||||||
|
script = ExtResource("1_1umkq")
|
||||||
|
enemy_id = "turret_spawner"
|
||||||
|
position = Vector2(640, -160)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_tij1g"]
|
||||||
|
script = ExtResource("1_1umkq")
|
||||||
|
enemy_id = "artillery"
|
||||||
|
position = Vector2(0, -720)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_cvqsq"]
|
||||||
|
script = ExtResource("1_1umkq")
|
||||||
|
enemy_id = "sniper"
|
||||||
|
position = Vector2(-380, 420)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_ec1ix"]
|
||||||
|
script = ExtResource("1_1umkq")
|
||||||
|
enemy_id = "sniper"
|
||||||
|
position = Vector2(400, 440)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_nx4ij"]
|
||||||
|
script = ExtResource("1_1umkq")
|
||||||
|
position = Vector2(120, 60)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_sepu5"]
|
||||||
|
script = ExtResource("1_1umkq")
|
||||||
|
enemy_id = "enemy_mothership"
|
||||||
|
position = Vector2(731.2211, 301.56653)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_luar8"]
|
||||||
|
script = ExtResource("1_1umkq")
|
||||||
|
enemy_id = "sniper"
|
||||||
|
position = Vector2(-593.29315, 150.69946)
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_ykd6p"]
|
||||||
|
script = ExtResource("1_1umkq")
|
||||||
|
enemy_id = "sniper"
|
||||||
|
position = Vector2(469.82953, -304.24854)
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("2_3lt83")
|
||||||
|
setup_id = "research_lab_1"
|
||||||
|
display_name = "Research Lab Garrison"
|
||||||
|
surface_profile_id = "research_lab"
|
||||||
|
capture_points = PackedVector2Array(-450, -60, 460, -40, 0, 480)
|
||||||
|
enemy_spawns = Array[Resource]([SubResource("Resource_4sgq7"), SubResource("Resource_ppux6"), SubResource("Resource_tij1g"), SubResource("Resource_cvqsq"), SubResource("Resource_ec1ix"), SubResource("Resource_nx4ij"), SubResource("Resource_sepu5"), SubResource("Resource_luar8"), SubResource("Resource_ykd6p")])
|
||||||
|
|
@ -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://GameController.gd" id="1"]
|
||||||
[ext_resource type="Script" path="res://LunarBackground.gd" id="2"]
|
[ext_resource type="PackedScene" path="res://SurfaceBackground.tscn" id="3"]
|
||||||
[ext_resource type="Shader" path="res://lunar_tiled.gdshader" id="3"]
|
|
||||||
[ext_resource type="Script" path="res://Fleet.gd" id="4"]
|
[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://PlanetSurfaceController.gd" id="5"]
|
||||||
[ext_resource type="Script" path="res://IconButton.gd" id="6"]
|
[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://SpeedFader.gd" id="17"]
|
||||||
[ext_resource type="Script" path="res://CaptureLabel.gd" id="15"]
|
[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"]
|
[node name="PlanetSurface" type="Node2D"]
|
||||||
script = ExtResource("1")
|
script = ExtResource("1")
|
||||||
|
|
||||||
[node name="BackgroundLayer" type="CanvasLayer" parent="."]
|
[node name="BackgroundLayer" parent="." instance=ExtResource("3")]
|
||||||
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="Fleet" type="Node2D" parent="."]
|
[node name="Fleet" type="Node2D" parent="."]
|
||||||
script = ExtResource("4")
|
script = ExtResource("4")
|
||||||
|
|
|
||||||
|
|
@ -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 planet_name_label: Label = get_node("../UI/PlanetNameLabel")
|
||||||
@onready var exit_button: IconButton = get_node("../UI/ExitButton")
|
@onready var exit_button: IconButton = get_node("../UI/ExitButton")
|
||||||
@onready var fade_rect: ColorRect = get_node("../FadeOverlay/FadeRect")
|
@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")
|
@onready var feature_host: Node2D = get_node("../SurfaceFeatures")
|
||||||
|
|
||||||
var transitioning: bool = false
|
var transitioning: bool = false
|
||||||
|
|
@ -40,12 +40,11 @@ func _ready() -> void:
|
||||||
|
|
||||||
|
|
||||||
func _compose_surface(profile: Resource) -> void:
|
func _compose_surface(profile: Resource) -> void:
|
||||||
var atmosphere: ShaderMaterial = background.material
|
# Shared with PlanetSetupEditor, deliberately: the backdrop is one
|
||||||
if profile.background_shader != null:
|
# instanced scene configured through one method, so what an author sees
|
||||||
atmosphere.shader = profile.background_shader
|
# while placing markers is what a player sees after landing. See the
|
||||||
atmosphere.set_shader_parameter("base_color", profile.base_color)
|
# header of SurfaceBackground.gd.
|
||||||
atmosphere.set_shader_parameter("crater_color", profile.crater_color)
|
background.apply_profile(profile)
|
||||||
atmosphere.set_shader_parameter("highlight_color", profile.highlight_color)
|
|
||||||
for caption_path in ["../UI/BankCaption", "../UI/FleetCaption", "../UI/CaptureLabel"]:
|
for caption_path in ["../UI/BankCaption", "../UI/FleetCaption", "../UI/CaptureLabel"]:
|
||||||
var caption: Label = get_node(caption_path)
|
var caption: Label = get_node(caption_path)
|
||||||
caption.add_theme_color_override("font_color", profile.caption_color)
|
caption.add_theme_color_override("font_color", profile.caption_color)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ extends Resource
|
||||||
|
|
||||||
@export var profile_id: String = "rocky"
|
@export var profile_id: String = "rocky"
|
||||||
@export var features_scene: PackedScene
|
@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 background_shader: Shader = null
|
||||||
@export var base_color: Color = Color(0.75, 0.75, 0.76, 1.0)
|
@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)
|
@export var crater_color: Color = Color(0.44, 0.44, 0.45, 1.0)
|
||||||
|
|
|
||||||
|
|
@ -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="Script" path="res://PlanetSurfaceProfile.gd" id="1"]
|
||||||
[ext_resource type="PackedScene" path="res://RockySurfaceFeatures.tscn" id="2"]
|
[ext_resource type="PackedScene" path="res://RockySurfaceFeatures.tscn" id="2"]
|
||||||
|
[ext_resource type="Shader" path="res://lunar_tiled.gdshader" id="3"]
|
||||||
|
|
||||||
[resource]
|
[resource]
|
||||||
script = ExtResource("1")
|
script = ExtResource("1")
|
||||||
profile_id = "rocky"
|
profile_id = "rocky"
|
||||||
features_scene = ExtResource("2")
|
features_scene = ExtResource("2")
|
||||||
|
background_shader = ExtResource("3")
|
||||||
base_color = Color(0.75, 0.75, 0.76, 1)
|
base_color = Color(0.75, 0.75, 0.76, 1)
|
||||||
crater_color = Color(0.44, 0.44, 0.45, 1)
|
crater_color = Color(0.44, 0.44, 0.45, 1)
|
||||||
highlight_color = Color(0.89, 0.89, 0.9, 1)
|
highlight_color = Color(0.89, 0.89, 0.9, 1)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,19 @@
|
||||||
extends ColorRect
|
extends ColorRect
|
||||||
|
|
||||||
# Feeds the lunar shader the camera's position split into a CELL INDEX plus
|
# THE surface backdrop. Not "the one PlanetSurface.tscn happens to use" -
|
||||||
# a fraction within that cell, rather than a raw world position. See the
|
# both the landed surface and the setup editor instance the SAME
|
||||||
# long note in Starfield.gd for why - identical reasoning, identical split.
|
# 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
|
# 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
|
# 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.
|
# than as steps.
|
||||||
const BAKE_PIXELS_PER_CELL: int = 64
|
const BAKE_PIXELS_PER_CELL: int = 64
|
||||||
|
|
||||||
# The palette, sampled from a NASA Artemis lunar photograph. These live on
|
# The palette the scene starts with, before any profile is applied. Every
|
||||||
# the node rather than as parameters of the drawing material because the
|
# host calls apply_profile() and overwrites these; they exist so the node
|
||||||
# shader that actually consumes them is the BAKE shader, which the scene
|
# still draws something sane on its own (and so the editor's own first frame
|
||||||
# never references directly - leaving them as orphan parameters on the tiled
|
# is not undefined).
|
||||||
# material would make them look live when they are only read once at load.
|
|
||||||
@export var base_color: Color = Color(0.75, 0.75, 0.76)
|
@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 crater_color: Color = Color(0.44, 0.44, 0.45)
|
||||||
@export var highlight_color: Color = Color(0.89, 0.89, 0.90)
|
@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()
|
_bake_field()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -54,6 +88,15 @@ func _material_wants_baked_field() -> bool:
|
||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
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 viewport_size: Vector2 = get_viewport_rect().size
|
||||||
var cell_size: float = material.get_shader_parameter("cell_size")
|
var cell_size: float = material.get_shader_parameter("cell_size")
|
||||||
var p: Vector2 = camera.global_position / 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
|
# while - out of a mobile float entirely: GDScript does this in double
|
||||||
# precision. fposmod, not fmod, so flying into negative coordinates
|
# precision. fposmod, not fmod, so flying into negative coordinates
|
||||||
# wraps to the far side of the tile instead of mirroring.
|
# 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(
|
material.set_shader_parameter("camera_cell", Vector2(
|
||||||
fposmod(cell.x, TILE_CELLS), fposmod(cell.y, TILE_CELLS)))
|
fposmod(cell.x, TILE_CELLS), fposmod(cell.y, TILE_CELLS)))
|
||||||
material.set_shader_parameter("camera_frac", p - cell)
|
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
|
# 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
|
# it to the tiled shader. Runs when a profile that samples a baked field is
|
||||||
# single expensive frame is invisible.
|
# applied - during the planet-arrival fade, where a single expensive frame is
|
||||||
|
# invisible.
|
||||||
func _bake_field() -> void:
|
func _bake_field() -> void:
|
||||||
# Headless runs - every scene-based test - rasterize nothing, so a bake
|
# Headless runs - every scene-based test - rasterize nothing, so a bake
|
||||||
# would allocate a render target, await a draw that never produces
|
# 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.
|
# for the background to appear on.
|
||||||
if DisplayServer.get_name() == "headless":
|
if DisplayServer.get_name() == "headless":
|
||||||
return
|
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():
|
if not _material_wants_baked_field():
|
||||||
return
|
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")
|
var procedural: Shader = load("res://lunar_surface.gdshader")
|
||||||
if procedural == null:
|
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
|
return
|
||||||
|
|
||||||
var side: int = int(TILE_CELLS) * BAKE_PIXELS_PER_CELL
|
var side: int = int(TILE_CELLS) * BAKE_PIXELS_PER_CELL
|
||||||
var bake_material := ShaderMaterial.new()
|
var bake_material := ShaderMaterial.new()
|
||||||
bake_material.shader = procedural
|
bake_material.shader = procedural
|
||||||
# Carry the look across from whatever the scene configured, so the tile
|
# Carry the look across from whatever the profile configured, so the tile
|
||||||
# is baked with the colours actually set on this node rather than the
|
# is baked with the colours actually in use rather than the shader's own
|
||||||
# shader's own defaults.
|
# defaults.
|
||||||
bake_material.set_shader_parameter("base_color", base_color)
|
bake_material.set_shader_parameter("base_color", base_color)
|
||||||
bake_material.set_shader_parameter("crater_color", crater_color)
|
bake_material.set_shader_parameter("crater_color", crater_color)
|
||||||
bake_material.set_shader_parameter("highlight_color", highlight_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()
|
var image: Image = sub_viewport.get_texture().get_image()
|
||||||
sub_viewport.queue_free()
|
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():
|
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
|
return
|
||||||
|
|
||||||
# Mipmaps are what keep a crater field from shimmering when the camera
|
# Mipmaps are what keep a crater field from shimmering when the camera
|
||||||
27
SurfaceBackground.tscn
Normal file
27
SurfaceBackground.tscn
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
[gd_scene load_steps=4 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://SurfaceBackground.gd" id="1"]
|
||||||
|
[ext_resource type="Shader" path="res://lunar_tiled.gdshader" id="2"]
|
||||||
|
|
||||||
|
[sub_resource type="ShaderMaterial" id="1"]
|
||||||
|
resource_local_to_scene = true
|
||||||
|
shader = ExtResource("2")
|
||||||
|
shader_parameter/camera_cell = Vector2(0, 0)
|
||||||
|
shader_parameter/camera_frac = Vector2(0, 0)
|
||||||
|
shader_parameter/camera_zoom = 1.0
|
||||||
|
shader_parameter/viewport_size = Vector2(720, 1280)
|
||||||
|
shader_parameter/cell_size = 220.0
|
||||||
|
shader_parameter/tile_cells = 32.0
|
||||||
|
|
||||||
|
[node name="SurfaceBackground" type="CanvasLayer"]
|
||||||
|
layer = -10
|
||||||
|
|
||||||
|
[node name="Background" type="ColorRect" parent="."]
|
||||||
|
material = SubResource("1")
|
||||||
|
mouse_filter = 2
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
@ -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="Script" path="res://PlanetSurfaceProfile.gd" id="1"]
|
||||||
|
[ext_resource type="Shader" path="res://lunar_tiled.gdshader" id="2"]
|
||||||
|
|
||||||
[resource]
|
[resource]
|
||||||
script = ExtResource("1")
|
script = ExtResource("1")
|
||||||
profile_id = "void"
|
profile_id = "void"
|
||||||
|
background_shader = ExtResource("2")
|
||||||
base_color = Color(0.04, 0.02, 0.07, 1)
|
base_color = Color(0.04, 0.02, 0.07, 1)
|
||||||
crater_color = Color(0.01, 0.005, 0.02, 1)
|
crater_color = Color(0.01, 0.005, 0.02, 1)
|
||||||
highlight_color = Color(0.45, 0.15, 0.65, 1)
|
highlight_color = Color(0.45, 0.15, 0.65, 1)
|
||||||
|
|
|
||||||
36
WorldMap.gd
36
WorldMap.gd
|
|
@ -40,14 +40,38 @@ func recenter_view() -> void:
|
||||||
content.position = viewport_center - fleet_local * content.scale.x
|
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:
|
func _input(event: InputEvent) -> void:
|
||||||
if not visible:
|
if not visible:
|
||||||
return
|
return
|
||||||
|
|
||||||
if event is InputEventScreenTouch:
|
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)
|
_handle_touch(event)
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
elif event is InputEventScreenDrag:
|
elif event is InputEventScreenDrag:
|
||||||
|
if not touches.has(event.index):
|
||||||
|
return
|
||||||
_handle_drag(event)
|
_handle_drag(event)
|
||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
elif event is InputEventMagnifyGesture:
|
elif event is InputEventMagnifyGesture:
|
||||||
|
|
@ -55,6 +79,18 @@ func _input(event: InputEvent) -> void:
|
||||||
get_viewport().set_input_as_handled()
|
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:
|
func _handle_touch(event: InputEventScreenTouch) -> void:
|
||||||
if event.pressed:
|
if event.pressed:
|
||||||
touches[event.index] = event.position
|
touches[event.index] = event.position
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,18 @@ shader_type canvas_item;
|
||||||
// Gas giant cloud-band backdrop for a landed gas surface. Same uniform
|
// Gas giant cloud-band backdrop for a landed gas surface. Same uniform
|
||||||
// interface as lunar_surface.gdshader (camera_cell/camera_frac/camera_zoom/
|
// interface as lunar_surface.gdshader (camera_cell/camera_frac/camera_zoom/
|
||||||
// viewport_size/cell_size/base_color/crater_color/highlight_color), so
|
// viewport_size/cell_size/base_color/crater_color/highlight_color), so
|
||||||
// LunarBackground.gd feeds this shader with no changes at all - only
|
// SurfaceBackground.gd feeds this shader with no changes at all - only the
|
||||||
// PlanetSurfaceController swaps which shader the material uses.
|
// surface profile decides which shader the material uses.
|
||||||
//
|
//
|
||||||
// Renders horizontal cloud bands instead of craters: each grid row (a
|
// Renders horizontal cloud bands instead of craters: each grid row (a
|
||||||
// horizontal strip one cell tall) hashes its own band centre and thickness,
|
// horizontal strip one cell tall) hashes its own band centre, thickness and
|
||||||
// nearest-row-wins picks which band a pixel belongs to, and the resulting
|
// drift speed, nearest-row-wins picks which band a pixel belongs to, and the
|
||||||
// distance field drives colour exactly the way the crater distance field
|
// resulting distance field drives colour exactly the way the crater distance
|
||||||
// does in lunar_surface.gdshader - base_color is the deep atmosphere,
|
// field does in lunar_surface.gdshader - base_color is the deep atmosphere,
|
||||||
// crater_color the shadowed band body, highlight_color the lit band crest.
|
// 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
|
// Turbulence perturbs each row's edge so the bands ripple instead of reading
|
||||||
// ripple instead of reading as ruled lines, and slides sideways over time
|
// as ruled lines, and each row slides sideways at its OWN rate, which is what
|
||||||
// for the lateral drift.
|
// gives a gas giant its sheared, differentially-rotating look.
|
||||||
//
|
//
|
||||||
// PRECISION: never reconstruct an absolute world position. Every value fed
|
// 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
|
// 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 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
|
// 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.
|
// 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_cell = vec2(0.0, 0.0);
|
||||||
uniform vec2 camera_frac = vec2(0.0, 0.0);
|
uniform vec2 camera_frac = vec2(0.0, 0.0);
|
||||||
uniform float camera_zoom = 1.0;
|
uniform float camera_zoom = 1.0;
|
||||||
uniform vec2 viewport_size = vec2(720.0, 1280.0);
|
uniform vec2 viewport_size = vec2(720.0, 1280.0);
|
||||||
uniform float cell_size = 220.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 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 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);
|
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 int ROW_SCAN = 2; // rows -1..+2
|
||||||
const float MIN_THICKNESS = 0.25;
|
const float MIN_THICKNESS = 0.25;
|
||||||
const float MAX_THICKNESS = 0.55;
|
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
|
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
|
// Same hash21 formulation as lunar_surface.gdshader (Dave Hoskins, all
|
||||||
// multipliers < 1 so it stays accurate at any cell-index magnitude).
|
// 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);
|
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
|
// Splits a float into an exact integer part and a [0,1) remainder - the
|
||||||
// same floor/subtract trick `rel` -> `cell_rel` + `local` already uses
|
// same floor/subtract trick `rel` -> `cell_rel` + `local` already uses
|
||||||
// below, generalized so it can also fold TIME-based drift into a lattice
|
// 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);
|
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:
|
// 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`
|
// hashes the two neighboring integers and blends by the fraction. `axis`
|
||||||
// varies the hash sequence so different callers (different rows, or the
|
// varies the hash sequence so different callers (different rows, or the
|
||||||
// turbulence octaves) don't sample the same noise.
|
// turbulence octaves) don't sample the same noise.
|
||||||
float value_noise(vec2 coord, float axis) {
|
//
|
||||||
float a = hash21(vec2(coord.x, axis));
|
// Every index is taken modulo `period` first, which is what makes the field
|
||||||
float b = hash21(vec2(coord.x + 1.0, axis));
|
// 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));
|
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() {
|
void fragment() {
|
||||||
vec2 screen_px = UV * viewport_size;
|
vec2 screen_px = UV * viewport_size;
|
||||||
vec2 centered = screen_px - viewport_size * 0.5;
|
vec2 centered = screen_px - viewport_size * 0.5;
|
||||||
|
|
@ -93,13 +185,8 @@ void fragment() {
|
||||||
vec2 local = rel - cell_rel;
|
vec2 local = rel - cell_rel;
|
||||||
vec2 cell = camera_cell + cell_rel;
|
vec2 cell = camera_cell + cell_rel;
|
||||||
|
|
||||||
// Lateral drift, folded into the X lattice coordinate the safe way:
|
vec2 base_lattice = vec2(cell.x, local.x);
|
||||||
// TIME is unbounded but session-scale (the ticket calls for using it
|
float time_units = TIME * DRIFT_SPEED;
|
||||||
// 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);
|
|
||||||
|
|
||||||
// Nearest-row-wins band field, the same single-scalar discipline as
|
// Nearest-row-wins band field, the same single-scalar discipline as
|
||||||
// lunar_surface.gdshader's nearest-crater distance: every visual below
|
// lunar_surface.gdshader's nearest-crater distance: every visual below
|
||||||
|
|
@ -108,13 +195,25 @@ void fragment() {
|
||||||
float nearest = 1000.0;
|
float nearest = 1000.0;
|
||||||
for (int dy = -1; dy <= ROW_SCAN; dy++) {
|
for (int dy = -1; dy <= ROW_SCAN; dy++) {
|
||||||
float row = cell.y + float(dy);
|
float row = cell.y + float(dy);
|
||||||
float band_centre = hash21(vec2(row, 5.0));
|
// centre, thickness and drift rate, from one hash - on the WRAPPED
|
||||||
float thickness = mix(MIN_THICKNESS, MAX_THICKNESS, hash21(vec2(row, 8.0)));
|
// 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
|
// TIME is unbounded but session-scale (the ticket calls for using it
|
||||||
// bands don't ripple in lockstep.
|
// directly, no extra uniform), so it gets its own int/frac split and
|
||||||
float turb = value_noise(turb_x, row * 3.7 + 11.0) * 0.65
|
// is combined with the cell's via combine_lattice() rather than
|
||||||
+ value_noise(vec2(turb_x.x * 2.0, turb_x.y), row * 3.7 + 47.0) * 0.35;
|
// 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
|
// 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.
|
// 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 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;
|
vec3 color = base_color.rgb;
|
||||||
color = mix(color, crater_color.rgb, core * 0.85);
|
color = mix(color, crater_color.rgb, core * 0.85);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,15 @@ extends SceneTree
|
||||||
# this environment, softpipe's non-JIT rasterizer does not).
|
# this environment, softpipe's non-JIT rasterizer does not).
|
||||||
#
|
#
|
||||||
# Args (after `--`): <scene_res_path> <output_png_path> [wait_frames]
|
# Args (after `--`): <scene_res_path> <output_png_path> [wait_frames]
|
||||||
|
# [key=value ...]
|
||||||
|
#
|
||||||
|
# Any trailing key=value pairs are assigned onto the GameState autoload
|
||||||
|
# BEFORE the scene is instantiated. Several scenes read GameState in
|
||||||
|
# _ready() to decide what they draw - PlanetSurface.tscn picks its whole
|
||||||
|
# surface profile from planet_definition_id - so without this the tool can
|
||||||
|
# only ever photograph the default planet:
|
||||||
|
#
|
||||||
|
# ... -- res://PlanetSurface.tscn out.png 90 planet_definition_id=gas_giant
|
||||||
|
|
||||||
var wait_frames: int = 60
|
var wait_frames: int = 60
|
||||||
var frames: int = 0
|
var frames: int = 0
|
||||||
|
|
@ -27,6 +36,13 @@ func _initialize() -> void:
|
||||||
output_path = args[1]
|
output_path = args[1]
|
||||||
if args.size() >= 3:
|
if args.size() >= 3:
|
||||||
wait_frames = int(args[2])
|
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)
|
var packed: PackedScene = load(scene_path)
|
||||||
if packed == null:
|
if packed == null:
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,12 @@
|
||||||
# script). softpipe is Mesa's older non-JIT (pure C) rasterizer - slower,
|
# script). softpipe is Mesa's older non-JIT (pure C) rasterizer - slower,
|
||||||
# but doesn't hit this crash. Good enough for one-shot screenshots.
|
# but doesn't hit this crash. Good enough for one-shot screenshots.
|
||||||
#
|
#
|
||||||
# Usage: bash tests/screenshot.sh <scene_res_path> <output_png_path> [wait_frames] [WxH]
|
# Usage: bash tests/screenshot.sh <scene_res_path> <output_png_path> [wait_frames] [WxH] [key=value ...]
|
||||||
# Example: bash tests/screenshot.sh res://Main.tscn /tmp/out.png 60 720x1280
|
# 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
|
# The resolution matters for anything with UI in it: the game is authored
|
||||||
# for a 720x1280 portrait viewport, so rendering at the default landscape
|
# for a 720x1280 portrait viewport, so rendering at the default landscape
|
||||||
|
|
@ -26,6 +30,8 @@ SCENE_PATH="${1:?usage: screenshot.sh <scene_res_path> <output_png_path> [wait_f
|
||||||
OUTPUT_PATH="${2:?usage: screenshot.sh <scene_res_path> <output_png_path> [wait_frames] [WxH]}"
|
OUTPUT_PATH="${2:?usage: screenshot.sh <scene_res_path> <output_png_path> [wait_frames] [WxH]}"
|
||||||
WAIT_FRAMES="${3:-60}"
|
WAIT_FRAMES="${3:-60}"
|
||||||
RESOLUTION="${4:-1280x720}"
|
RESOLUTION="${4:-1280x720}"
|
||||||
|
shift 4 2>/dev/null || shift $#
|
||||||
|
STATE_ARGS=("$@")
|
||||||
|
|
||||||
proot-distro login ubuntu -- bash -c "
|
proot-distro login ubuntu -- bash -c "
|
||||||
# Display :99 is dedicated to this helper. Clear both a live process left by
|
# 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
|
sleep 2
|
||||||
mkdir -p /tmp/xdgrt && chmod 700 /tmp/xdgrt
|
mkdir -p /tmp/xdgrt && chmod 700 /tmp/xdgrt
|
||||||
cd '$PROJECT_DIR'
|
cd '$PROJECT_DIR'
|
||||||
DISPLAY=:99 XDG_RUNTIME_DIR=/tmp/xdgrt GALLIUM_DRIVER=softpipe '$GODOT_BIN' --audio-driver Dummy --rendering-driver opengl3 --rendering-method gl_compatibility --resolution '$RESOLUTION' -s res://tests/_screenshot_tool.gd -- '$SCENE_PATH' '$OUTPUT_PATH' '$WAIT_FRAMES'
|
DISPLAY=:99 XDG_RUNTIME_DIR=/tmp/xdgrt GALLIUM_DRIVER=softpipe '$GODOT_BIN' --audio-driver Dummy --rendering-driver opengl3 --rendering-method gl_compatibility --resolution '$RESOLUTION' -s res://tests/_screenshot_tool.gd -- '$SCENE_PATH' '$OUTPUT_PATH' '$WAIT_FRAMES' ${STATE_ARGS[*]}
|
||||||
"
|
"
|
||||||
|
|
|
||||||
163
tests/test_build_uses_fleet_hold.gd
Normal file
163
tests/test_build_uses_fleet_hold.gd
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Headless gameplay test: run with
|
||||||
|
# godot --headless -s res://tests/test_build_uses_fleet_hold.gd
|
||||||
|
# A new game starts with an empty bank. Ore is mined into the FLEET HOLD, and
|
||||||
|
# the only way to move it into the bank is to deliver it to a mothership - so
|
||||||
|
# charging construction to the bank alone made the first mothership cost ore
|
||||||
|
# that could not exist yet. Mine forever, build nothing: the game was
|
||||||
|
# unwinnable from its own opening position.
|
||||||
|
#
|
||||||
|
# Construction now draws on the bank AND the hold. Covers:
|
||||||
|
# A. the deadlock premise still holds - a bank-only test rejects a build
|
||||||
|
# the fleet can plainly pay for, so this is guarding something real
|
||||||
|
# B. the pooled test accepts it, and paying takes from the bank first and
|
||||||
|
# the hold only for the shortfall
|
||||||
|
# C. an unaffordable build takes nothing at all (no partial charge)
|
||||||
|
# D. end to end: a fleet holding ore, with an empty bank, actually gets a
|
||||||
|
# mothership built through BuildController
|
||||||
|
# E. research is deliberately NOT pooled - it still spends from the bank
|
||||||
|
|
||||||
|
# Deliberately more than one starting interceptor can carry (its hold is 20,
|
||||||
|
# which is exactly MOTHERSHIP_COST). The capacity is widened where this is
|
||||||
|
# used, so "the hold paid for it" is visible as a remainder rather than as
|
||||||
|
# the hold happening to empty to zero.
|
||||||
|
const HOLD_ORE: int = 40
|
||||||
|
|
||||||
|
var game_state: Node
|
||||||
|
var scene: Node
|
||||||
|
var build_controller: Node
|
||||||
|
var stage: int = 0
|
||||||
|
var elapsed: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
game_state = root.get_node("GameState")
|
||||||
|
game_state.reset_progression()
|
||||||
|
game_state.buildings.clear()
|
||||||
|
game_state.extractors.clear()
|
||||||
|
|
||||||
|
var cost: int = GameState.MOTHERSHIP_COST
|
||||||
|
|
||||||
|
# --- A. the deadlock, exactly as a fresh game presents it ---------------
|
||||||
|
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0)
|
||||||
|
game_state.fleet_inventory.total_capacity = HOLD_ORE
|
||||||
|
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, HOLD_ORE)
|
||||||
|
if game_state.can_afford(cost):
|
||||||
|
_fail("the bank-only test passed with an empty bank, so it can no longer show the deadlock this fixes")
|
||||||
|
return
|
||||||
|
print("bank 0 + hold %d: bank-only affordability says no (the original deadlock)" % HOLD_ORE)
|
||||||
|
|
||||||
|
# --- B. pooled, and paid bank-first -------------------------------------
|
||||||
|
if not game_state.can_afford_construction(cost):
|
||||||
|
_fail("a fleet holding %d ore still could not afford a %d build" % [HOLD_ORE, cost])
|
||||||
|
return
|
||||||
|
if not game_state.spend_construction(cost):
|
||||||
|
_fail("spend_construction refused a build it had just called affordable")
|
||||||
|
return
|
||||||
|
if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 0:
|
||||||
|
_fail("paying from an empty bank somehow changed it")
|
||||||
|
return
|
||||||
|
if game_state.fleet_inventory.amount(GameState.RESOURCE_ORE) != HOLD_ORE - cost:
|
||||||
|
_fail("the hold should have paid the whole %d, but holds %d of %d" \
|
||||||
|
% [cost, game_state.fleet_inventory.amount(GameState.RESOURCE_ORE), HOLD_ORE])
|
||||||
|
return
|
||||||
|
print("paid %d entirely from the hold: hold %d -> %d" % [cost, HOLD_ORE, HOLD_ORE - cost])
|
||||||
|
|
||||||
|
# Bank first when there is a bank to draw on: the hold is also the fleet's
|
||||||
|
# mining capacity, so it is spent last.
|
||||||
|
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 8)
|
||||||
|
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 30)
|
||||||
|
if not game_state.spend_construction(20):
|
||||||
|
_fail("a 20 build was refused with 8 banked and 30 held")
|
||||||
|
return
|
||||||
|
if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 0 \
|
||||||
|
or game_state.fleet_inventory.amount(GameState.RESOURCE_ORE) != 18:
|
||||||
|
_fail("a split payment should empty the bank first: bank %d, hold %d (expected 0 and 18)" \
|
||||||
|
% [game_state.bank_inventory.amount(GameState.RESOURCE_ORE),
|
||||||
|
game_state.fleet_inventory.amount(GameState.RESOURCE_ORE)])
|
||||||
|
return
|
||||||
|
print("split payment drained the bank first: bank 8 -> 0, hold 30 -> 18")
|
||||||
|
|
||||||
|
# --- C. no partial charge -----------------------------------------------
|
||||||
|
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 5)
|
||||||
|
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 5)
|
||||||
|
if game_state.spend_construction(50):
|
||||||
|
_fail("a build that cost more than everything owned was allowed")
|
||||||
|
return
|
||||||
|
if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 5 \
|
||||||
|
or game_state.fleet_inventory.amount(GameState.RESOURCE_ORE) != 5:
|
||||||
|
_fail("a rejected build still took ore: bank %d, hold %d (expected 5 and 5)" \
|
||||||
|
% [game_state.bank_inventory.amount(GameState.RESOURCE_ORE),
|
||||||
|
game_state.fleet_inventory.amount(GameState.RESOURCE_ORE)])
|
||||||
|
return
|
||||||
|
print("a build nobody can afford takes nothing")
|
||||||
|
|
||||||
|
# --- E. research stays banked -------------------------------------------
|
||||||
|
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0)
|
||||||
|
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 500)
|
||||||
|
if game_state.can_afford(1):
|
||||||
|
_fail("research affordability started counting the fleet hold - it is meant to stay banked")
|
||||||
|
return
|
||||||
|
print("research still spends from the bank alone")
|
||||||
|
|
||||||
|
# --- D. end to end, through the real build path --------------------------
|
||||||
|
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0)
|
||||||
|
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, HOLD_ORE)
|
||||||
|
scene = load("res://Main.tscn").instantiate()
|
||||||
|
root.add_child(scene)
|
||||||
|
current_scene = scene
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> bool:
|
||||||
|
elapsed += delta
|
||||||
|
match stage:
|
||||||
|
0:
|
||||||
|
if elapsed < 0.3:
|
||||||
|
return false
|
||||||
|
scene.get_node("AsteroidField").set_process(false)
|
||||||
|
scene.get_node("EnemySpawner").set_process(false)
|
||||||
|
build_controller = scene.get_node("BuildController")
|
||||||
|
|
||||||
|
# Whatever else the scene did while starting up, the point of the
|
||||||
|
# test is a fleet with ore and an empty bank.
|
||||||
|
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0)
|
||||||
|
# Widened past what a starting interceptor can carry. Its real
|
||||||
|
# hold is 20 and a mothership costs exactly 20, so at the stock
|
||||||
|
# capacity a successful build empties the hold to zero - which is
|
||||||
|
# indistinguishable from the hold having been emptied by anything
|
||||||
|
# else. With room to spare, the remainder is the evidence.
|
||||||
|
game_state.fleet_inventory.total_capacity = HOLD_ORE
|
||||||
|
game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, HOLD_ORE)
|
||||||
|
var before: int = game_state.buildings.size()
|
||||||
|
|
||||||
|
build_controller.pending_build_type = GameState.BUILDING_MOTHERSHIP
|
||||||
|
build_controller._confirm_build()
|
||||||
|
|
||||||
|
if game_state.buildings.size() != before + 1:
|
||||||
|
return _fail("building a mothership from the fleet's own hold did not place one (%d -> %d): %s" \
|
||||||
|
% [before, game_state.buildings.size(), build_controller.build_status.text])
|
||||||
|
# Checked as a TOTAL, not as the hold alone: a mothership standing
|
||||||
|
# where the fleet is starts taking delivery of the hold on the
|
||||||
|
# very next tick, so ore legitimately moves from hold to bank
|
||||||
|
# straight after the build. What must be true is that exactly the
|
||||||
|
# cost left the pool - and that it left, since the bank was empty
|
||||||
|
# and could not have paid.
|
||||||
|
var total: int = game_state.construction_ore()
|
||||||
|
if total != HOLD_ORE - GameState.MOTHERSHIP_COST:
|
||||||
|
return _fail("the mothership was placed but %d ore remains, expected %d" \
|
||||||
|
% [total, HOLD_ORE - GameState.MOTHERSHIP_COST])
|
||||||
|
print("built a mothership with an empty bank, paid out of the fleet hold (%d ore left, bank %d + hold %d)" \
|
||||||
|
% [total, game_state.bank_inventory.amount(GameState.RESOURCE_ORE),
|
||||||
|
game_state.fleet_inventory.amount(GameState.RESOURCE_ORE)])
|
||||||
|
|
||||||
|
print("TEST_BUILD_USES_FLEET_HOLD: PASS")
|
||||||
|
quit(0)
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(message: String) -> bool:
|
||||||
|
print("TEST_BUILD_USES_FLEET_HOLD: FAIL (%s)" % message)
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
1
tests/test_build_uses_fleet_hold.gd.uid
Normal file
1
tests/test_build_uses_fleet_hold.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bldykfmviv5lp
|
||||||
137
tests/test_conquered_planet_clear.gd
Normal file
137
tests/test_conquered_planet_clear.gd
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Headless gameplay test: run with
|
||||||
|
# godot --headless -s res://tests/test_conquered_planet_clear.gd
|
||||||
|
# A planet's garrison was destroyed to take it, so it must not be standing
|
||||||
|
# there again the next time the player lands. Capture points already came
|
||||||
|
# back marked captured; the enemies came back alive, which read as the
|
||||||
|
# conquest having quietly been undone.
|
||||||
|
#
|
||||||
|
# Covers:
|
||||||
|
# A. an unconquered planet lays out its authored enemies, as before
|
||||||
|
# B. once every capture point is taken, re-landing places no enemies - and
|
||||||
|
# still places the capture points, marked captured
|
||||||
|
# C. a setup that authors enemies but NO capture points keeps them. Such a
|
||||||
|
# planet counts as vacuously conquered (nothing to capture), so keying
|
||||||
|
# only off the conquered flag would delete a garrison nobody has beaten
|
||||||
|
|
||||||
|
const PLANET_KEY: String = "test_conquered_planet_clear"
|
||||||
|
const OPEN_PLANET_KEY: String = "test_conquered_planet_clear_open"
|
||||||
|
|
||||||
|
var game_state: Node
|
||||||
|
var host_scene: PackedScene
|
||||||
|
var stage: int = 0
|
||||||
|
var elapsed: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
game_state = root.get_node("GameState")
|
||||||
|
game_state.planet_capture.clear()
|
||||||
|
host_scene = load("res://PlanetSetupHost.tscn")
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> bool:
|
||||||
|
elapsed += delta
|
||||||
|
if stage == 0:
|
||||||
|
# One frame of grace so the autoload is fully up, same as the other
|
||||||
|
# scene-driven tests.
|
||||||
|
if elapsed < 0.1:
|
||||||
|
return false
|
||||||
|
|
||||||
|
# --- A. unconquered: the garrison is there ---------------------------
|
||||||
|
game_state.planet_id = PLANET_KEY
|
||||||
|
game_state.planet_definition_id = "rocky"
|
||||||
|
var setup: Resource = _make_setup(2, 3)
|
||||||
|
var enemies: int = _apply_and_count_enemies(setup)
|
||||||
|
if enemies != 3:
|
||||||
|
return _fail("an unconquered planet placed %d of its 3 authored enemies" % enemies)
|
||||||
|
if game_state.is_planet_conquered(PLANET_KEY):
|
||||||
|
return _fail("a planet with 2 uncaptured points reported itself conquered")
|
||||||
|
print("unconquered planet: 3 enemies placed")
|
||||||
|
|
||||||
|
# --- B. conquered: the garrison stays destroyed ----------------------
|
||||||
|
game_state.mark_point_captured(PLANET_KEY, 0)
|
||||||
|
game_state.mark_point_captured(PLANET_KEY, 1)
|
||||||
|
if not game_state.is_planet_conquered(PLANET_KEY):
|
||||||
|
return _fail("capturing every point did not conquer the planet")
|
||||||
|
|
||||||
|
var host: Node = host_scene.instantiate()
|
||||||
|
root.add_child(host)
|
||||||
|
host.apply_setup(setup)
|
||||||
|
var remaining: int = _count_enemies(host)
|
||||||
|
var points: int = _count_capture_points(host)
|
||||||
|
host.free()
|
||||||
|
if remaining != 0:
|
||||||
|
return _fail("re-landing on a conquered planet placed %d enemies" % remaining)
|
||||||
|
if points != 2:
|
||||||
|
return _fail("a conquered planet placed %d of its 2 capture points - conquest should clear the garrison, not the map" % points)
|
||||||
|
print("conquered planet: 0 enemies, both capture points still placed")
|
||||||
|
|
||||||
|
# --- C. a setup with nothing to capture keeps its enemies ------------
|
||||||
|
game_state.planet_id = OPEN_PLANET_KEY
|
||||||
|
var open_setup: Resource = _make_setup(0, 2)
|
||||||
|
var open_enemies: int = _apply_and_count_enemies(open_setup)
|
||||||
|
if not game_state.is_planet_conquered(OPEN_PLANET_KEY):
|
||||||
|
return _fail("a setup with no capture points was expected to register as vacuously conquered, and did not - so this case no longer tests what it claims to")
|
||||||
|
if open_enemies != 2:
|
||||||
|
return _fail("a setup with no capture points lost %d of its 2 enemies to the conquest rule" \
|
||||||
|
% (2 - open_enemies))
|
||||||
|
print("setup with no capture points: both enemies kept, despite counting as conquered")
|
||||||
|
|
||||||
|
print("TEST_CONQUERED_PLANET_CLEAR: PASS")
|
||||||
|
quit(0)
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _make_setup(capture_count: int, enemy_count: int) -> Resource:
|
||||||
|
var setup: Resource = load("res://PlanetSetup.gd").new()
|
||||||
|
setup.setup_id = "in_memory"
|
||||||
|
setup.surface_profile_id = "rocky"
|
||||||
|
var points: PackedVector2Array = PackedVector2Array()
|
||||||
|
for i in range(capture_count):
|
||||||
|
points.append(Vector2(200.0 * float(i + 1), 0.0))
|
||||||
|
setup.capture_points = points
|
||||||
|
var spawn_script: Script = load("res://PlanetSetupSpawn.gd")
|
||||||
|
for i in range(enemy_count):
|
||||||
|
var spawn: Resource = spawn_script.new()
|
||||||
|
spawn.enemy_id = "chaser"
|
||||||
|
spawn.position = Vector2(0.0, 200.0 * float(i + 1))
|
||||||
|
setup.enemy_spawns.append(spawn)
|
||||||
|
return setup
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_and_count_enemies(setup: Resource) -> int:
|
||||||
|
var host: Node = host_scene.instantiate()
|
||||||
|
root.add_child(host)
|
||||||
|
host.apply_setup(setup)
|
||||||
|
var count: int = _count_enemies(host)
|
||||||
|
host.free()
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
# Counted by group rather than by script or node name: that is how everything
|
||||||
|
# else in the game finds enemies, so this cannot pass while the real hunters
|
||||||
|
# see something different.
|
||||||
|
func _count_enemies(host: Node) -> int:
|
||||||
|
var count: int = 0
|
||||||
|
for child in host.get_children():
|
||||||
|
if child.is_in_group("enemies"):
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
func _count_capture_points(host: Node) -> int:
|
||||||
|
var count: int = 0
|
||||||
|
for child in host.get_children():
|
||||||
|
if child.is_in_group("enemies"):
|
||||||
|
continue
|
||||||
|
if "point_index" in child:
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(message: String) -> bool:
|
||||||
|
print("TEST_CONQUERED_PLANET_CLEAR: FAIL (%s)" % message)
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
1
tests/test_conquered_planet_clear.gd.uid
Normal file
1
tests/test_conquered_planet_clear.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dmwnshp5r6gch
|
||||||
307
tests/test_gas_bands.gd
Normal file
307
tests/test_gas_bands.gd
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Headless test: run with
|
||||||
|
# godot --headless -s res://tests/test_gas_bands.gd
|
||||||
|
# The gas giant's cloud bands were breaking into disjoint segments - the band
|
||||||
|
# edges stepped sideways once per cell, right across the screen. The cause was
|
||||||
|
# in gas_surface.gdshader's second turbulence octave, which took a
|
||||||
|
# double-frequency sample by doubling only the INTEGER half of a lattice
|
||||||
|
# coordinate and leaving the fraction alone. That is not the same point at
|
||||||
|
# twice the frequency; it is a different point on every cell, so the noise -
|
||||||
|
# and with it the band edge - jumped discontinuously at every cell boundary.
|
||||||
|
#
|
||||||
|
# A fragment shader cannot be asserted on from GDScript, so this ports the
|
||||||
|
# band field to GDScript against the SAME hashes and the SAME constants
|
||||||
|
# (parsed out of the shader, so the two cannot drift) and measures the thing
|
||||||
|
# that actually went wrong: continuity.
|
||||||
|
#
|
||||||
|
# Covers:
|
||||||
|
# A. the field is continuous in X across many cell boundaries - no step
|
||||||
|
# larger than a small multiple of what a smooth field produces between
|
||||||
|
# adjacent samples
|
||||||
|
# B. the same in Y, where the row loop's window slides by one
|
||||||
|
# C. the test has teeth: the OLD, broken octave is re-implemented here and
|
||||||
|
# must FAIL the same scan. Without this, a field that had been flattened
|
||||||
|
# to a constant would sail through A and B
|
||||||
|
# D. the row scan still reaches every row that can change a pixel, from the
|
||||||
|
# constants as they currently stand - the seam this prevents is a
|
||||||
|
# horizontal one, which A and B would not catch
|
||||||
|
# E. the field is EXACTLY periodic over the tile period, in both axes. The
|
||||||
|
# camera cell index arrives already wrapped (SurfaceBackground.gd wraps
|
||||||
|
# it in double precision), so a pixel's cell index shifts by exactly one
|
||||||
|
# period whenever the camera crosses a wrap boundary. Unless the hashes
|
||||||
|
# are wrapped to match, the whole sky rearranges itself at that instant
|
||||||
|
|
||||||
|
const SHADER_PATH := "res://gas_surface.gdshader"
|
||||||
|
# Sample spacing along the scan, in cells. Small enough that a genuinely
|
||||||
|
# smooth field moves very little between samples.
|
||||||
|
const STEP := 0.001
|
||||||
|
const SPAN_CELLS := 12.0
|
||||||
|
# What counts as a break. The field's slope is bounded by its octave weights
|
||||||
|
# (0.55/0.30/0.15 over 4/2/1 cells, smoothstep slope at most 1.5) times
|
||||||
|
# TURBULENCE_STRENGTH, divided by the thinnest band - about 0.9 per cell, so
|
||||||
|
# a smooth field moves ~0.0009 between samples. Twenty times that is still
|
||||||
|
# far below the ~0.3 jumps the broken octave produced, so this neither trips
|
||||||
|
# on noise nor lets the real defect through.
|
||||||
|
const MAX_STEP := 0.02
|
||||||
|
|
||||||
|
var source: String = ""
|
||||||
|
var min_thickness: float = 0.0
|
||||||
|
var max_thickness: float = 0.0
|
||||||
|
var turbulence_strength: float = 0.0
|
||||||
|
var max_significant: float = 0.0
|
||||||
|
var row_scan: int = 0
|
||||||
|
var tile_cells: float = 32.0
|
||||||
|
# Set while scanning, to run the same scan against the broken formulation.
|
||||||
|
var use_broken_octave: bool = false
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
var file: FileAccess = FileAccess.open(SHADER_PATH, FileAccess.READ)
|
||||||
|
if file == null:
|
||||||
|
_fail("could not read %s" % SHADER_PATH)
|
||||||
|
return
|
||||||
|
source = file.get_as_text()
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
min_thickness = _read_float_const("MIN_THICKNESS")
|
||||||
|
max_thickness = _read_float_const("MAX_THICKNESS")
|
||||||
|
turbulence_strength = _read_float_const("TURBULENCE_STRENGTH")
|
||||||
|
max_significant = _read_float_const("MAX_SIGNIFICANT_DISTANCE")
|
||||||
|
row_scan = int(_read_int_const("ROW_SCAN"))
|
||||||
|
tile_cells = _read_uniform_default("tile_cells")
|
||||||
|
if is_nan(tile_cells):
|
||||||
|
_fail("the shader no longer declares a tile_cells uniform, so nothing bounds what it hashes")
|
||||||
|
return
|
||||||
|
if is_nan(min_thickness) or is_nan(max_thickness) or is_nan(turbulence_strength) \
|
||||||
|
or is_nan(max_significant) or row_scan == 0:
|
||||||
|
_fail("could not parse the shader's band constants")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not _check_premises():
|
||||||
|
return
|
||||||
|
if not _check_continuity():
|
||||||
|
return
|
||||||
|
if not _check_row_scan_headroom():
|
||||||
|
return
|
||||||
|
if not _check_periodicity():
|
||||||
|
return
|
||||||
|
|
||||||
|
print("TEST_GAS_BANDS: PASS")
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
|
||||||
|
# --- A/B/C: continuity ------------------------------------------------------
|
||||||
|
|
||||||
|
func _check_continuity() -> bool:
|
||||||
|
var x_break: float = _worst_step_along_x()
|
||||||
|
if x_break > MAX_STEP:
|
||||||
|
return _fail("the band field breaks along X: a step of %.4f between samples %0.4f cells apart" \
|
||||||
|
% [x_break, STEP])
|
||||||
|
var y_break: float = _worst_step_along_y()
|
||||||
|
if y_break > MAX_STEP:
|
||||||
|
return _fail("the band field breaks along Y: a step of %.4f between samples %0.4f cells apart" \
|
||||||
|
% [y_break, STEP])
|
||||||
|
print("continuous across %d cell boundaries: worst step %.5f in X, %.5f in Y (allowed %.3f)" \
|
||||||
|
% [int(SPAN_CELLS), x_break, y_break, MAX_STEP])
|
||||||
|
|
||||||
|
# C. The scan has to be able to see a break, or A and B prove nothing.
|
||||||
|
use_broken_octave = true
|
||||||
|
var broken: float = _worst_step_along_x()
|
||||||
|
use_broken_octave = false
|
||||||
|
if broken <= MAX_STEP:
|
||||||
|
return _fail("the continuity scan did not detect the ORIGINAL defect (worst step %.5f), so it cannot be trusted to detect a new one" % broken)
|
||||||
|
print("scan verified against the original defect: the broken octave steps %.3f, %.0fx the allowance" \
|
||||||
|
% [broken, broken / MAX_STEP])
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _worst_step_along_x() -> float:
|
||||||
|
# Deliberately started off-lattice, so the sample points straddle cell
|
||||||
|
# boundaries rather than landing exactly on them - a break exactly at an
|
||||||
|
# integer could otherwise be stepped over.
|
||||||
|
var y: float = 0.317
|
||||||
|
var worst: float = 0.0
|
||||||
|
var x: float = -SPAN_CELLS * 0.5 + 0.0004
|
||||||
|
var previous: float = _nearest(x, y)
|
||||||
|
while x < SPAN_CELLS * 0.5:
|
||||||
|
x += STEP
|
||||||
|
var value: float = _nearest(x, y)
|
||||||
|
worst = maxf(worst, absf(value - previous))
|
||||||
|
previous = value
|
||||||
|
return worst
|
||||||
|
|
||||||
|
|
||||||
|
func _worst_step_along_y() -> float:
|
||||||
|
var x: float = 0.211
|
||||||
|
var worst: float = 0.0
|
||||||
|
var y: float = -SPAN_CELLS * 0.5 + 0.0004
|
||||||
|
var previous: float = _nearest(x, y)
|
||||||
|
while y < SPAN_CELLS * 0.5:
|
||||||
|
y += STEP
|
||||||
|
var value: float = _nearest(x, y)
|
||||||
|
worst = maxf(worst, absf(value - previous))
|
||||||
|
previous = value
|
||||||
|
return worst
|
||||||
|
|
||||||
|
|
||||||
|
# --- D: the row scan still covers every row that matters --------------------
|
||||||
|
|
||||||
|
func _check_row_scan_headroom() -> bool:
|
||||||
|
# A row can still change a pixel while the pixel is within
|
||||||
|
# MAX_SIGNIFICANT_DISTANCE * thickness of its band, and the band itself
|
||||||
|
# wanders by up to half the turbulence strength.
|
||||||
|
var reach: float = max_significant * max_thickness + turbulence_strength * 0.5
|
||||||
|
# to_band = (local.y - dy) - band_centre, with local.y in [0,1) and
|
||||||
|
# band_centre in [0,1), so (local.y - band_centre) spans (-1, 1) and a
|
||||||
|
# row is relevant for dy anywhere in (-1 - reach, 1 + reach).
|
||||||
|
var lowest_needed: int = ceili(-1.0 - reach)
|
||||||
|
var highest_needed: int = floori(1.0 + reach)
|
||||||
|
if lowest_needed < -1 or highest_needed > row_scan:
|
||||||
|
return _fail("a band reaches %.2f cells, so rows dy %d..%d can still change a pixel - but the loop only scans -1..%d" \
|
||||||
|
% [reach, lowest_needed, highest_needed, row_scan])
|
||||||
|
print("band reach %.2f cells needs dy within %d..%d; the loop scans -1..%d" \
|
||||||
|
% [reach, lowest_needed, highest_needed, row_scan])
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
# --- the field, ported ------------------------------------------------------
|
||||||
|
|
||||||
|
func _nearest(x: float, y: float) -> float:
|
||||||
|
# Same split the shader does: an exact integer cell index plus a bounded
|
||||||
|
# fraction. TIME is pinned to zero here - drift slides the field, it does
|
||||||
|
# not change its shape, and a fixed value keeps this reproducible.
|
||||||
|
var cell := Vector2(floor(x), floor(y))
|
||||||
|
var local := Vector2(x - cell.x, y - cell.y)
|
||||||
|
var base_lattice := Vector2(cell.x, local.x)
|
||||||
|
|
||||||
|
var nearest: float = 1000.0
|
||||||
|
for dy in range(-1, row_scan + 1):
|
||||||
|
var row: float = cell.y + float(dy)
|
||||||
|
var row_hash: Vector3 = _hash23(Vector2(fposmod(row, tile_cells), 5.0))
|
||||||
|
var band_centre: float = row_hash.x
|
||||||
|
var thickness: float = lerpf(min_thickness, max_thickness, row_hash.y)
|
||||||
|
var turb: float = _row_turbulence(base_lattice, tile_cells,
|
||||||
|
fposmod(row, tile_cells) * 3.7)
|
||||||
|
var to_band: float = (local.y - float(dy)) - band_centre
|
||||||
|
to_band += (turb - 0.5) * turbulence_strength
|
||||||
|
nearest = minf(nearest, absf(to_band) / thickness)
|
||||||
|
return nearest
|
||||||
|
|
||||||
|
|
||||||
|
func _row_turbulence(lattice: Vector2, period: float, axis: float) -> float:
|
||||||
|
if use_broken_octave:
|
||||||
|
# The original, as it stood: one cell-frequency octave plus a second
|
||||||
|
# that doubled the integer part of the lattice coordinate and left
|
||||||
|
# the fraction untouched.
|
||||||
|
return _value_noise(lattice, period, axis + 11.0) * 0.65 \
|
||||||
|
+ _value_noise(Vector2(lattice.x * 2.0, lattice.y), period, axis + 47.0) * 0.35
|
||||||
|
var half_freq: Vector2 = _halve_lattice(lattice)
|
||||||
|
var quarter: Vector2 = _halve_lattice(half_freq)
|
||||||
|
return _value_noise(quarter, period * 0.25, axis + 11.0) * 0.55 \
|
||||||
|
+ _value_noise(half_freq, period * 0.5, axis + 47.0) * 0.30 \
|
||||||
|
+ _value_noise(lattice, period, axis + 83.0) * 0.15
|
||||||
|
|
||||||
|
|
||||||
|
func _halve_lattice(coord: Vector2) -> Vector2:
|
||||||
|
var half_i: float = floor(coord.x * 0.5)
|
||||||
|
var rem: float = coord.x - half_i * 2.0
|
||||||
|
return Vector2(half_i, (rem + coord.y) * 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
func _value_noise(coord: Vector2, period: float, axis: float) -> float:
|
||||||
|
var a: float = _hash21(Vector2(fposmod(coord.x, period), axis))
|
||||||
|
var b: float = _hash21(Vector2(fposmod(coord.x + 1.0, period), axis))
|
||||||
|
return lerpf(a, b, smoothstep(0.0, 1.0, coord.y))
|
||||||
|
|
||||||
|
|
||||||
|
func _fract(v: float) -> float:
|
||||||
|
return v - floor(v)
|
||||||
|
|
||||||
|
|
||||||
|
func _fract3(v: Vector3) -> Vector3:
|
||||||
|
return Vector3(_fract(v.x), _fract(v.y), _fract(v.z))
|
||||||
|
|
||||||
|
|
||||||
|
func _hash21(p: Vector2) -> float:
|
||||||
|
var p3: Vector3 = _fract3(Vector3(p.x, p.y, p.x) * 0.1031)
|
||||||
|
var shifted := Vector3(p3.y, p3.z, p3.x) + Vector3(33.33, 33.33, 33.33)
|
||||||
|
p3 += Vector3.ONE * p3.dot(shifted)
|
||||||
|
return _fract((p3.x + p3.y) * p3.z)
|
||||||
|
|
||||||
|
|
||||||
|
func _hash23(p: Vector2) -> Vector3:
|
||||||
|
var p3: Vector3 = _fract3(Vector3(p.x, p.y, p.x) * Vector3(0.1031, 0.1030, 0.0973))
|
||||||
|
var shifted := Vector3(p3.y, p3.x, p3.z) + Vector3(33.33, 33.33, 33.33)
|
||||||
|
p3 += Vector3.ONE * p3.dot(shifted)
|
||||||
|
return _fract3(Vector3(
|
||||||
|
(p3.x + p3.y) * p3.z,
|
||||||
|
(p3.x + p3.z) * p3.y,
|
||||||
|
(p3.y + p3.z) * p3.x))
|
||||||
|
|
||||||
|
|
||||||
|
# --- premises ---------------------------------------------------------------
|
||||||
|
|
||||||
|
func _check_premises() -> bool:
|
||||||
|
# The exact defect, named so it cannot quietly return.
|
||||||
|
if "vec2(turb_x.x * 2.0" in source:
|
||||||
|
return _fail("the shader is doubling a lattice coordinate's integer part while leaving its fraction alone - that is the band-break defect itself")
|
||||||
|
if not ("halve_lattice" in source):
|
||||||
|
return _fail("the shader no longer rescales its octaves through halve_lattice, so continuity is no longer guaranteed by construction")
|
||||||
|
if not ("combine_lattice" in source):
|
||||||
|
return _fail("the shader no longer folds drift in through combine_lattice, so TIME is being added to a cell index directly")
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _read_float_const(name: String) -> float:
|
||||||
|
var regex := RegEx.new()
|
||||||
|
regex.compile("const\\s+float\\s+%s\\s*=\\s*(-?[0-9.]+)" % name)
|
||||||
|
var found: RegExMatch = regex.search(source)
|
||||||
|
if found == null:
|
||||||
|
return NAN
|
||||||
|
return float(found.get_string(1))
|
||||||
|
|
||||||
|
|
||||||
|
func _read_int_const(name: String) -> float:
|
||||||
|
var regex := RegEx.new()
|
||||||
|
regex.compile("const\\s+int\\s+%s\\s*=\\s*(-?[0-9]+)" % name)
|
||||||
|
var found: RegExMatch = regex.search(source)
|
||||||
|
if found == null:
|
||||||
|
return 0.0
|
||||||
|
return float(found.get_string(1))
|
||||||
|
|
||||||
|
|
||||||
|
func _read_uniform_default(name: String) -> float:
|
||||||
|
var regex := RegEx.new()
|
||||||
|
regex.compile("uniform\\s+float\\s+%s\\s*=\\s*(-?[0-9.]+)" % name)
|
||||||
|
var found: RegExMatch = regex.search(source)
|
||||||
|
if found == null:
|
||||||
|
return NAN
|
||||||
|
return float(found.get_string(1))
|
||||||
|
|
||||||
|
|
||||||
|
# --- E: exact periodicity ---------------------------------------------------
|
||||||
|
|
||||||
|
func _check_periodicity() -> bool:
|
||||||
|
if fmod(tile_cells, 4.0) != 0.0:
|
||||||
|
return _fail("a tile period of %.0f cells is not a multiple of 4, so the quarter-frequency octave cannot wrap on a whole cell and the seam comes back" % tile_cells)
|
||||||
|
var worst: float = 0.0
|
||||||
|
for i in range(400):
|
||||||
|
var x: float = -7.3 + float(i) * 0.11
|
||||||
|
var y: float = 2.9 + float(i) * 0.07
|
||||||
|
worst = maxf(worst, absf(_nearest(x, y) - _nearest(x + tile_cells, y)))
|
||||||
|
worst = maxf(worst, absf(_nearest(x, y) - _nearest(x, y + tile_cells)))
|
||||||
|
# Exact, not approximate: the same hashes are evaluated on indices that
|
||||||
|
# differ by whole periods, so identical inputs reach them. Anything but a
|
||||||
|
# rounding-scale difference means the wrap is visible.
|
||||||
|
if worst > 1e-9:
|
||||||
|
return _fail("the field is not periodic over %.0f cells (differs by %.6f), so crossing a camera wrap rearranges the sky" \
|
||||||
|
% [tile_cells, worst])
|
||||||
|
print("field is exactly periodic over %.0f cells in both axes (worst difference %s)" % [tile_cells, str(worst)])
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(message: String) -> bool:
|
||||||
|
print("TEST_GAS_BANDS: FAIL (%s)" % message)
|
||||||
|
quit(1)
|
||||||
|
return false
|
||||||
1
tests/test_gas_bands.gd.uid
Normal file
1
tests/test_gas_bands.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b30n8oom8283w
|
||||||
|
|
@ -29,7 +29,7 @@ extends SceneTree
|
||||||
|
|
||||||
const SHADER_PATH := "res://lunar_surface.gdshader"
|
const SHADER_PATH := "res://lunar_surface.gdshader"
|
||||||
const TILED_SHADER_PATH := "res://lunar_tiled.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,
|
# Largest normalized distance any shading term below reacts to. Past this,
|
||||||
# every smoothstep has saturated and the pixel is plain regolith.
|
# every smoothstep has saturated and the pixel is plain regolith.
|
||||||
const MAX_SIGNIFICANT_DISTANCE := 1.15
|
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.]+)")
|
regex.compile("const\\s+TILE_CELLS\\s*:\\s*float\\s*=\\s*([0-9.]+)")
|
||||||
var found: RegExMatch = regex.search(script_source)
|
var found: RegExMatch = regex.search(script_source)
|
||||||
if found == null:
|
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))
|
tile_cells = float(found.get_string(1))
|
||||||
if tile_cells < 4.0:
|
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)
|
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
|
# 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.
|
# mobile float, and the tiled shader adds it straight into a UV.
|
||||||
if not ("fposmod(cell.x, TILE_CELLS)" in script_source):
|
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)
|
var tiled_file: FileAccess = FileAccess.open(TILED_SHADER_PATH, FileAccess.READ)
|
||||||
if tiled_file == null:
|
if tiled_file == null:
|
||||||
|
|
|
||||||
142
tests/test_planet_map_assignment.gd
Normal file
142
tests/test_planet_map_assignment.gd
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Headless test: run with
|
||||||
|
# godot --headless -s res://tests/test_planet_map_assignment.gd
|
||||||
|
# Every planet should draw a map of its own surface type at random, and then
|
||||||
|
# keep it - leaving a planet and coming back must land on the same layout, or
|
||||||
|
# the place stops feeling like a place.
|
||||||
|
#
|
||||||
|
# Two things could break that, and both are silent:
|
||||||
|
# - the candidate list is built by scanning a directory, and the order a
|
||||||
|
# filesystem lists files in is not something to seed a choice with. The
|
||||||
|
# pick indexes into that list, so an unstable order re-deals every planet
|
||||||
|
# - the pool itself changes the moment the map editor saves a new setup,
|
||||||
|
# which would re-deal planets mid-session even with a perfect seed
|
||||||
|
#
|
||||||
|
# Covers:
|
||||||
|
# A. the same planet resolves to the same setup, every time
|
||||||
|
# B. the setup it draws is always authored for that planet's own profile
|
||||||
|
# C. different planets do not all collapse onto one setup
|
||||||
|
# D. an assignment survives a new setup file appearing in the pool
|
||||||
|
# E. candidates come back in a deterministic order regardless of scan order
|
||||||
|
|
||||||
|
const SCRATCH_ID: String = "test_assignment_scratch"
|
||||||
|
const SCRATCH_PATH: String = "res://PlanetSetups/%s.tres" % SCRATCH_ID
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_remove_scratch()
|
||||||
|
PlanetSetups.rescan()
|
||||||
|
|
||||||
|
# --- E. deterministic ordering ------------------------------------------
|
||||||
|
var profile_id: String = "rocky"
|
||||||
|
var ordered: Array = PlanetSetups.setups_for_profile(profile_id)
|
||||||
|
if ordered.size() < 2:
|
||||||
|
# Not a failure of the code under test, but this file cannot say
|
||||||
|
# anything without a pool to draw from - say so rather than passing
|
||||||
|
# vacuously.
|
||||||
|
_fail("only %d '%s' setups exist, so nothing here can distinguish a stable draw from a fixed one" \
|
||||||
|
% [ordered.size(), profile_id])
|
||||||
|
return
|
||||||
|
var ids: Array = []
|
||||||
|
for setup in ordered:
|
||||||
|
ids.append(String(setup.setup_id))
|
||||||
|
var sorted_ids: Array = ids.duplicate()
|
||||||
|
sorted_ids.sort()
|
||||||
|
if ids != sorted_ids:
|
||||||
|
_fail("setups_for_profile returned %s, which is not sorted - the draw indexes into this, so the order cannot be left to the filesystem" % str(ids))
|
||||||
|
return
|
||||||
|
print("'%s' pool is deterministic: %s" % [profile_id, str(ids)])
|
||||||
|
|
||||||
|
# --- A/B/C. stable, correctly typed, and actually varied -----------------
|
||||||
|
var first_pass: Dictionary = {}
|
||||||
|
var planet_ids: Array = []
|
||||||
|
for i in range(40):
|
||||||
|
planet_ids.append("planet_%d_%d" % [i * 7, i * 13])
|
||||||
|
for planet_id in planet_ids:
|
||||||
|
var setup: Resource = PlanetSetups.setup_for_planet(planet_id, profile_id)
|
||||||
|
if setup == null:
|
||||||
|
_fail("%s drew no setup at all" % planet_id)
|
||||||
|
return
|
||||||
|
if String(setup.surface_profile_id) != profile_id:
|
||||||
|
_fail("%s is a '%s' planet but drew a '%s' map" \
|
||||||
|
% [planet_id, profile_id, setup.surface_profile_id])
|
||||||
|
return
|
||||||
|
first_pass[planet_id] = setup
|
||||||
|
|
||||||
|
for planet_id in planet_ids:
|
||||||
|
for repeat in range(3):
|
||||||
|
if PlanetSetups.setup_for_planet(planet_id, profile_id) != first_pass[planet_id]:
|
||||||
|
_fail("%s drew a different map on a later visit" % planet_id)
|
||||||
|
return
|
||||||
|
print("40 planets each re-drew the same map on every revisit")
|
||||||
|
|
||||||
|
var distinct: Dictionary = {}
|
||||||
|
for planet_id in first_pass:
|
||||||
|
distinct[first_pass[planet_id].setup_id] = true
|
||||||
|
if distinct.size() < 2:
|
||||||
|
_fail("all 40 planets drew the same map (%s), so the draw is not random at all" % str(distinct.keys()))
|
||||||
|
return
|
||||||
|
print("across 40 planets the draw used %d of the %d available maps" % [distinct.size(), ids.size()])
|
||||||
|
|
||||||
|
# --- D. a new map appearing does not re-deal the planets already visited -
|
||||||
|
var scratch: Resource = load("res://PlanetSetup.gd").new()
|
||||||
|
scratch.setup_id = SCRATCH_ID
|
||||||
|
scratch.display_name = "Assignment Scratch"
|
||||||
|
scratch.surface_profile_id = profile_id
|
||||||
|
var err: int = ResourceSaver.save(scratch, SCRATCH_PATH)
|
||||||
|
if err != OK:
|
||||||
|
_fail("could not write the scratch setup (error %d)" % err)
|
||||||
|
return
|
||||||
|
# Exactly what the map editor does after a save.
|
||||||
|
PlanetSetups.rescan()
|
||||||
|
var live: Dictionary = {}
|
||||||
|
var scratch_in_pool: bool = false
|
||||||
|
for setup in PlanetSetups.setups_for_profile(profile_id):
|
||||||
|
live[setup] = true
|
||||||
|
if String(setup.setup_id) == SCRATCH_ID:
|
||||||
|
scratch_in_pool = true
|
||||||
|
if not scratch_in_pool:
|
||||||
|
_remove_scratch()
|
||||||
|
_fail("the scratch setup did not enter the pool, so this case proves nothing")
|
||||||
|
return
|
||||||
|
|
||||||
|
var moved: int = 0
|
||||||
|
var checked: int = 0
|
||||||
|
for planet_id in planet_ids:
|
||||||
|
# Skipped if the map this planet drew is no longer in the pool. The
|
||||||
|
# suite runs tests in parallel against one project directory, so
|
||||||
|
# another test's scratch setup can appear and vanish underneath this
|
||||||
|
# one - and a planet losing an assignment because its MAP was deleted
|
||||||
|
# is correct behaviour, not the re-deal this case is looking for.
|
||||||
|
if not live.has(first_pass[planet_id]):
|
||||||
|
continue
|
||||||
|
checked += 1
|
||||||
|
if PlanetSetups.setup_for_planet(planet_id, profile_id) != first_pass[planet_id]:
|
||||||
|
moved += 1
|
||||||
|
if checked == 0:
|
||||||
|
_remove_scratch()
|
||||||
|
_fail("every planet's map disappeared from the pool mid-test, so nothing could be checked")
|
||||||
|
return
|
||||||
|
_remove_scratch()
|
||||||
|
PlanetSetups.rescan()
|
||||||
|
if moved > 0:
|
||||||
|
_fail("saving one new map re-dealt %d of %d already-visited planets" % [moved, checked])
|
||||||
|
return
|
||||||
|
print("saving a new map left all %d existing assignments alone" % checked)
|
||||||
|
|
||||||
|
print("TEST_PLANET_MAP_ASSIGNMENT: PASS")
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
|
||||||
|
func _remove_scratch() -> void:
|
||||||
|
var path: String = ProjectSettings.globalize_path(SCRATCH_PATH)
|
||||||
|
if FileAccess.file_exists(path):
|
||||||
|
DirAccess.remove_absolute(path)
|
||||||
|
if FileAccess.file_exists(path + ".uid"):
|
||||||
|
DirAccess.remove_absolute(path + ".uid")
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(message: String) -> void:
|
||||||
|
print("TEST_PLANET_MAP_ASSIGNMENT: FAIL (%s)" % message)
|
||||||
|
quit(1)
|
||||||
1
tests/test_planet_map_assignment.gd.uid
Normal file
1
tests/test_planet_map_assignment.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://by7lf8250s5m
|
||||||
155
tests/test_setup_editor_delete.gd
Normal file
155
tests/test_setup_editor_delete.gd
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Headless test: run with
|
||||||
|
# godot --headless -s res://tests/test_setup_editor_delete.gd
|
||||||
|
# The map editor could write setups but never remove one, so a mistyped name
|
||||||
|
# or an abandoned experiment stayed in the pool forever - and every planet of
|
||||||
|
# that type kept drawing it. It also listed setups in a font small enough to
|
||||||
|
# be awkward to hit on a phone.
|
||||||
|
#
|
||||||
|
# Deleting is destructive and the list is the only place it can be triggered
|
||||||
|
# from, so the panel is explicitly MODAL about it: a tap loads, unless the
|
||||||
|
# delete button has been pressed first, and the panel says which it is.
|
||||||
|
#
|
||||||
|
# Covers:
|
||||||
|
# A. the load panel lists saved setups and a tap loads one (unarmed)
|
||||||
|
# B. arming changes what a tap means, and says so in the title
|
||||||
|
# C. an armed tap deletes the file and drops it from the registry
|
||||||
|
# D. one tap deletes one setup - it disarms itself afterwards
|
||||||
|
# E. deleting the setup currently being edited leaves a fresh one open,
|
||||||
|
# rather than one whose next save would recreate the file
|
||||||
|
# F. the list is laid out at a size worth tapping at
|
||||||
|
|
||||||
|
const SCRATCH_ID: String = "test_delete_scratch"
|
||||||
|
const SCRATCH_PATH: String = "res://PlanetSetups/%s.tres" % SCRATCH_ID
|
||||||
|
const KEEP_ID: String = "test_delete_keeper"
|
||||||
|
const KEEP_PATH: String = "res://PlanetSetups/%s.tres" % KEEP_ID
|
||||||
|
const MIN_LIST_FONT_SIZE: int = 30
|
||||||
|
|
||||||
|
var editor: Node
|
||||||
|
var elapsed: float = 0.0
|
||||||
|
var stage: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_remove(SCRATCH_PATH)
|
||||||
|
_remove(KEEP_PATH)
|
||||||
|
PlanetSetups.rescan()
|
||||||
|
editor = load("res://PlanetSetupEditor.tscn").instantiate()
|
||||||
|
root.add_child(editor)
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> bool:
|
||||||
|
elapsed += delta
|
||||||
|
if stage != 0:
|
||||||
|
return false
|
||||||
|
# _ready() (palette build, initial new_setup) needs a frame to flush -
|
||||||
|
# same hazard as tests/test_setup_editor.gd.
|
||||||
|
if elapsed < 0.1:
|
||||||
|
return false
|
||||||
|
stage = 1
|
||||||
|
|
||||||
|
# --- F. the list is tappable --------------------------------------------
|
||||||
|
var list: ItemList = editor.load_list
|
||||||
|
var font_size: int = list.get_theme_font_size("font_size")
|
||||||
|
if font_size < MIN_LIST_FONT_SIZE:
|
||||||
|
return _fail("the setup list draws at font size %d; %d or more is what makes the rows worth tapping at" \
|
||||||
|
% [font_size, MIN_LIST_FONT_SIZE])
|
||||||
|
print("setup list font size %d" % font_size)
|
||||||
|
|
||||||
|
# Two setups, so deleting one still leaves a populated list - a panel that
|
||||||
|
# empties would hide itself and mask a wrong-row deletion.
|
||||||
|
editor.new_setup(KEEP_ID, "Delete Keeper", "rocky")
|
||||||
|
editor.add_capture_point(Vector2(10, 20))
|
||||||
|
if not editor.save_setup().ok:
|
||||||
|
return _fail("could not save the keeper setup")
|
||||||
|
editor.new_setup(SCRATCH_ID, "Delete Scratch", "rocky")
|
||||||
|
editor.add_capture_point(Vector2(30, 40))
|
||||||
|
if not editor.save_setup().ok:
|
||||||
|
return _fail("could not save the scratch setup")
|
||||||
|
|
||||||
|
# --- A. the panel lists them, and an unarmed tap loads -------------------
|
||||||
|
editor._on_load_pressed()
|
||||||
|
if not editor.load_panel.visible:
|
||||||
|
return _fail("the load panel did not open")
|
||||||
|
if editor.load_title_label.text != "Load a setup":
|
||||||
|
return _fail("the panel opened in delete mode: '%s'" % editor.load_title_label.text)
|
||||||
|
var keep_index: int = Array(editor._load_list_setup_ids).find(KEEP_ID)
|
||||||
|
var scratch_index: int = Array(editor._load_list_setup_ids).find(SCRATCH_ID)
|
||||||
|
if keep_index < 0 or scratch_index < 0:
|
||||||
|
return _fail("the panel listed %s, missing one of the two just saved" \
|
||||||
|
% str(editor._load_list_setup_ids))
|
||||||
|
editor._on_load_list_item_selected(keep_index)
|
||||||
|
if String(editor.current_setup.setup_id) != KEEP_ID:
|
||||||
|
return _fail("an unarmed tap did not load '%s' (got '%s')" % [KEEP_ID, editor.current_setup.setup_id])
|
||||||
|
if editor.load_panel.visible:
|
||||||
|
return _fail("loading left the panel open")
|
||||||
|
print("unarmed tap loaded '%s' and closed the panel" % KEEP_ID)
|
||||||
|
|
||||||
|
# --- B. arming changes the meaning of a tap, visibly ---------------------
|
||||||
|
editor._on_load_pressed()
|
||||||
|
editor._on_delete_map_pressed()
|
||||||
|
if not editor._delete_map_armed:
|
||||||
|
return _fail("pressing the delete button did not arm delete mode")
|
||||||
|
if editor.load_title_label.text == "Load a setup":
|
||||||
|
return _fail("delete mode did not change the panel title, so nothing tells the player a tap now deletes")
|
||||||
|
print("armed: panel now reads '%s'" % editor.load_title_label.text)
|
||||||
|
|
||||||
|
# --- C. an armed tap deletes ---------------------------------------------
|
||||||
|
scratch_index = Array(editor._load_list_setup_ids).find(SCRATCH_ID)
|
||||||
|
editor._on_load_list_item_selected(scratch_index)
|
||||||
|
if FileAccess.file_exists(ProjectSettings.globalize_path(SCRATCH_PATH)):
|
||||||
|
return _fail("the setup file is still on disk after an armed tap")
|
||||||
|
for setup in PlanetSetups.all():
|
||||||
|
if String(setup.setup_id) == SCRATCH_ID:
|
||||||
|
return _fail("the registry still offers '%s' after deleting it" % SCRATCH_ID)
|
||||||
|
if Array(editor._load_list_setup_ids).find(KEEP_ID) < 0:
|
||||||
|
return _fail("deleting one setup also removed '%s' from the list" % KEEP_ID)
|
||||||
|
print("armed tap deleted '%s'; '%s' untouched" % [SCRATCH_ID, KEEP_ID])
|
||||||
|
|
||||||
|
# --- D. one tap, one deletion -------------------------------------------
|
||||||
|
if editor._delete_map_armed:
|
||||||
|
return _fail("delete mode stayed armed after a deletion, so the next tap would delete too")
|
||||||
|
if editor.load_title_label.text != "Load a setup":
|
||||||
|
return _fail("the panel still reads '%s' after disarming" % editor.load_title_label.text)
|
||||||
|
print("delete mode disarmed itself after one deletion")
|
||||||
|
|
||||||
|
# --- E. deleting what is open leaves a fresh setup ------------------------
|
||||||
|
editor.load_setup(KEEP_ID)
|
||||||
|
if String(editor.current_setup.setup_id) != KEEP_ID:
|
||||||
|
return _fail("could not reopen '%s' to test deleting the open setup" % KEEP_ID)
|
||||||
|
var outcome: Dictionary = editor.delete_setup(KEEP_ID)
|
||||||
|
if not outcome.ok:
|
||||||
|
return _fail("deleting the open setup failed: %s" % outcome.message)
|
||||||
|
if String(editor.current_setup.setup_id) == KEEP_ID:
|
||||||
|
return _fail("the editor is still holding '%s' after deleting it - the next save would put the file straight back" % KEEP_ID)
|
||||||
|
if not editor.current_setup.capture_points.is_empty():
|
||||||
|
return _fail("the replacement setup carried the deleted one's capture points over")
|
||||||
|
print("deleting the open setup left a fresh '%s' being edited" % editor.current_setup.setup_id)
|
||||||
|
|
||||||
|
# Deleting something that is not there is refused, not silently "ok".
|
||||||
|
if editor.delete_setup(SCRATCH_ID).ok:
|
||||||
|
return _fail("deleting an already-deleted setup reported success")
|
||||||
|
|
||||||
|
_remove(SCRATCH_PATH)
|
||||||
|
_remove(KEEP_PATH)
|
||||||
|
PlanetSetups.rescan()
|
||||||
|
print("TEST_SETUP_EDITOR_DELETE: PASS")
|
||||||
|
quit(0)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _remove(res_path: String) -> void:
|
||||||
|
var path: String = ProjectSettings.globalize_path(res_path)
|
||||||
|
if FileAccess.file_exists(path):
|
||||||
|
DirAccess.remove_absolute(path)
|
||||||
|
if FileAccess.file_exists(path + ".uid"):
|
||||||
|
DirAccess.remove_absolute(path + ".uid")
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(message: String) -> bool:
|
||||||
|
_remove(SCRATCH_PATH)
|
||||||
|
_remove(KEEP_PATH)
|
||||||
|
print("TEST_SETUP_EDITOR_DELETE: FAIL (%s)" % message)
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
1
tests/test_setup_editor_delete.gd.uid
Normal file
1
tests/test_setup_editor_delete.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://boirnc74ieft4
|
||||||
134
tests/test_surface_background_shared.gd
Normal file
134
tests/test_surface_background_shared.gd
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Headless test: run with
|
||||||
|
# godot --headless -s res://tests/test_surface_background_shared.gd
|
||||||
|
# The landed surface and the setup editor drew DIFFERENT backgrounds for the
|
||||||
|
# same planet. Each scene carried its own hand-written ShaderMaterial (the
|
||||||
|
# game's baked-and-tiled lunar shader, the editor's per-pixel procedural one)
|
||||||
|
# and only the game's side ever swapped in a profile's own shader - so a gas
|
||||||
|
# giant authored in the editor showed recoloured craters while the game showed
|
||||||
|
# cloud bands. An editor that lies about what the player will see is worse
|
||||||
|
# than no preview.
|
||||||
|
#
|
||||||
|
# Both scenes now instance ONE SurfaceBackground.tscn and configure it through
|
||||||
|
# ONE method. This test guards that, since nothing about the code stops
|
||||||
|
# someone re-adding a local material to one scene and pulling them apart
|
||||||
|
# again.
|
||||||
|
#
|
||||||
|
# Covers:
|
||||||
|
# A. both scenes get their backdrop from the same PackedScene file
|
||||||
|
# B. neither overrides the shared material - the two start out identical
|
||||||
|
# C. every surface profile names its own background_shader; a null one
|
||||||
|
# would mean "whatever this scene already had", which is the ambiguity
|
||||||
|
# that caused the drift
|
||||||
|
# D. applying each profile through the shared entry point produces the same
|
||||||
|
# shader AND the same colours on both scenes, for every profile
|
||||||
|
|
||||||
|
const BACKGROUND_SCENE_PATH := "res://SurfaceBackground.tscn"
|
||||||
|
const HOST_SCENES := {
|
||||||
|
"PlanetSurface.tscn": "res://PlanetSurface.tscn",
|
||||||
|
"PlanetSetupEditor.tscn": "res://PlanetSetupEditor.tscn",
|
||||||
|
}
|
||||||
|
const COLOR_PARAMETERS := ["base_color", "crater_color", "highlight_color"]
|
||||||
|
|
||||||
|
var backgrounds: Dictionary = {}
|
||||||
|
var hosts: Array = []
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
var surface_profiles: Script = load("res://PlanetSurfaceProfiles.gd")
|
||||||
|
|
||||||
|
# --- A/B: same source scene, same untouched material ---------------------
|
||||||
|
for scene_name in HOST_SCENES:
|
||||||
|
var host: Node = load(HOST_SCENES[scene_name]).instantiate()
|
||||||
|
hosts.append(host)
|
||||||
|
var layer: Node = host.get_node_or_null("BackgroundLayer")
|
||||||
|
if layer == null:
|
||||||
|
_fail("%s has no BackgroundLayer" % scene_name)
|
||||||
|
return
|
||||||
|
if layer.scene_file_path != BACKGROUND_SCENE_PATH:
|
||||||
|
_fail("%s builds its own background (%s) instead of instancing %s" \
|
||||||
|
% [scene_name, layer.scene_file_path, BACKGROUND_SCENE_PATH])
|
||||||
|
return
|
||||||
|
var background: Node = layer.get_node_or_null("Background")
|
||||||
|
if background == null:
|
||||||
|
_fail("%s's background layer has no Background node" % scene_name)
|
||||||
|
return
|
||||||
|
if not background.has_method("apply_profile"):
|
||||||
|
_fail("%s's background has no apply_profile() - the shared entry point is gone" % scene_name)
|
||||||
|
return
|
||||||
|
backgrounds[scene_name] = background
|
||||||
|
print("both scenes instance %s and expose apply_profile()" % BACKGROUND_SCENE_PATH)
|
||||||
|
|
||||||
|
var names: Array = backgrounds.keys()
|
||||||
|
var reference: ShaderMaterial = backgrounds[names[0]].material
|
||||||
|
for i in range(1, names.size()):
|
||||||
|
var other: ShaderMaterial = backgrounds[names[i]].material
|
||||||
|
# Same configuration, SEPARATE objects. Sub-resources in a PackedScene
|
||||||
|
# are shared between instances by default, which would make the whole
|
||||||
|
# comparison below vacuous - it would be reading one material twice -
|
||||||
|
# and would mean two live surfaces repainting each other, baked
|
||||||
|
# texture included. SurfaceBackground.tscn marks the material
|
||||||
|
# resource_local_to_scene for exactly this reason.
|
||||||
|
if other == reference:
|
||||||
|
_fail("%s and %s share one ShaderMaterial object, so applying a profile to either repaints both" \
|
||||||
|
% [names[0], names[i]])
|
||||||
|
return
|
||||||
|
if other.shader != reference.shader:
|
||||||
|
_fail("%s and %s start on different shaders (%s vs %s), so one of them overrides the shared material" \
|
||||||
|
% [names[0], names[i], reference.shader.resource_path, other.shader.resource_path])
|
||||||
|
return
|
||||||
|
print("both start on the same shader: %s" % reference.shader.resource_path)
|
||||||
|
|
||||||
|
# --- C: no profile leaves its shader unstated ----------------------------
|
||||||
|
for profile_id in surface_profiles.PROFILES:
|
||||||
|
var profile: Resource = surface_profiles.get_profile(profile_id)
|
||||||
|
if profile.background_shader == null:
|
||||||
|
_fail("the '%s' profile does not name a background_shader, so what it draws depends on which scene happens to be hosting it" % profile_id)
|
||||||
|
return
|
||||||
|
print("all %d profiles name their own background shader" % surface_profiles.PROFILES.size())
|
||||||
|
|
||||||
|
# --- D: applying a profile lands identically on both ---------------------
|
||||||
|
for profile_id in surface_profiles.PROFILES:
|
||||||
|
var profile: Resource = surface_profiles.get_profile(profile_id)
|
||||||
|
for scene_name in backgrounds:
|
||||||
|
backgrounds[scene_name].apply_profile(profile)
|
||||||
|
|
||||||
|
var first: ShaderMaterial = backgrounds[names[0]].material
|
||||||
|
if first.shader != profile.background_shader:
|
||||||
|
_fail("applying '%s' did not put its own shader on the background" % profile_id)
|
||||||
|
return
|
||||||
|
for i in range(1, names.size()):
|
||||||
|
var other: ShaderMaterial = backgrounds[names[i]].material
|
||||||
|
if other.shader != first.shader:
|
||||||
|
_fail("'%s' resolved to %s in %s but %s in %s" \
|
||||||
|
% [profile_id, first.shader.resource_path, names[0],
|
||||||
|
other.shader.resource_path, names[i]])
|
||||||
|
return
|
||||||
|
for parameter in COLOR_PARAMETERS:
|
||||||
|
var a: Color = first.get_shader_parameter(parameter)
|
||||||
|
var b: Color = other.get_shader_parameter(parameter)
|
||||||
|
if not a.is_equal_approx(b):
|
||||||
|
_fail("'%s' set %s to %s in %s but %s in %s" \
|
||||||
|
% [profile_id, parameter, a, names[0], b, names[i]])
|
||||||
|
return
|
||||||
|
# Identical to each other is only half of it - both have to match the
|
||||||
|
# profile, or the shared path could be uniformly ignoring it.
|
||||||
|
for parameter in COLOR_PARAMETERS:
|
||||||
|
var applied: Color = first.get_shader_parameter(parameter)
|
||||||
|
var declared: Color = profile.get(parameter)
|
||||||
|
if not applied.is_equal_approx(declared):
|
||||||
|
_fail("'%s' declares %s %s but the background ended up with %s" \
|
||||||
|
% [profile_id, parameter, declared, applied])
|
||||||
|
return
|
||||||
|
print(" '%s' -> %s, identical on both" % [profile_id, first.shader.resource_path.get_file()])
|
||||||
|
|
||||||
|
for host in hosts:
|
||||||
|
host.free()
|
||||||
|
print("TEST_SURFACE_BACKGROUND_SHARED: PASS")
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(message: String) -> void:
|
||||||
|
print("TEST_SURFACE_BACKGROUND_SHARED: FAIL (%s)" % message)
|
||||||
|
quit(1)
|
||||||
1
tests/test_surface_background_shared.gd.uid
Normal file
1
tests/test_surface_background_shared.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dynonerc7v0fp
|
||||||
|
|
@ -6,11 +6,16 @@ extends SceneTree
|
||||||
# lunar_surface.gdshader recoloured (craters reading as regolith, not
|
# lunar_surface.gdshader recoloured (craters reading as regolith, not
|
||||||
# atmosphere). Verifies:
|
# atmosphere). Verifies:
|
||||||
# 1. the gas profile resolves a background_shader different from rocky's
|
# 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
|
# 2. PlanetSurfaceController._compose_surface() actually swaps the
|
||||||
# background material's shader when landing on a gas giant
|
# background material's shader when landing on a gas giant
|
||||||
# 3. landing on a rocky planet leaves the scene's own lunar shader in
|
# 3. landing on a rocky planet ends up on rocky's own declared shader
|
||||||
# place - a null background_shader is a no-op, not a forced reset
|
#
|
||||||
|
# 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
|
# PlanetSurfaceController's _ready() (which calls _compose_surface()) does
|
||||||
# not fire synchronously inside add_child() for a -s entry-point script -
|
# not fire synchronously inside add_child() for a -s entry-point script -
|
||||||
|
|
@ -20,6 +25,7 @@ extends SceneTree
|
||||||
|
|
||||||
var game_state: Node
|
var game_state: Node
|
||||||
var scene_shader: Shader
|
var scene_shader: Shader
|
||||||
|
var rocky_shader: Shader
|
||||||
var gas_shader: Shader
|
var gas_shader: Shader
|
||||||
var rocky_shader_after_compose: Shader
|
var rocky_shader_after_compose: Shader
|
||||||
var scene: Node
|
var scene: Node
|
||||||
|
|
@ -38,9 +44,10 @@ func _initialize() -> void:
|
||||||
# has no business having an opinion about.
|
# has no business having an opinion about.
|
||||||
var packed: PackedScene = load("res://PlanetSurface.tscn")
|
var packed: PackedScene = load("res://PlanetSurface.tscn")
|
||||||
var probe: Node = packed.instantiate()
|
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()
|
probe.free()
|
||||||
|
|
||||||
|
|
||||||
# Loaded by path into a local, not referenced by its bare global
|
# Loaded by path into a local, not referenced by its bare global
|
||||||
# class_name - same load-order hazard PlanetSetups.gd and
|
# class_name - same load-order hazard PlanetSetups.gd and
|
||||||
# PlanetSurfaceController.gd already work around: the class_name cache
|
# 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 surface_profiles: Script = load("res://PlanetSurfaceProfiles.gd")
|
||||||
var rocky_profile: Resource = surface_profiles.get_profile("rocky")
|
var rocky_profile: Resource = surface_profiles.get_profile("rocky")
|
||||||
var gas_profile: Resource = surface_profiles.get_profile("gas")
|
var gas_profile: Resource = surface_profiles.get_profile("gas")
|
||||||
if rocky_profile.background_shader != null:
|
if rocky_profile.background_shader == null:
|
||||||
print("TEST_SURFACE_PROFILES: FAIL (rocky profile sets a background_shader - it should stay null)")
|
print("TEST_SURFACE_PROFILES: FAIL (rocky profile has no background_shader set)")
|
||||||
quit(1)
|
quit(1)
|
||||||
return
|
return
|
||||||
|
rocky_shader = rocky_profile.background_shader
|
||||||
if gas_profile.background_shader == null:
|
if gas_profile.background_shader == null:
|
||||||
print("TEST_SURFACE_PROFILES: FAIL (gas profile has no background_shader set)")
|
print("TEST_SURFACE_PROFILES: FAIL (gas profile has no background_shader set)")
|
||||||
quit(1)
|
quit(1)
|
||||||
|
|
@ -73,7 +81,7 @@ func _process(delta: float) -> bool:
|
||||||
0:
|
0:
|
||||||
if elapsed < 0.2:
|
if elapsed < 0.2:
|
||||||
return false
|
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
|
rocky_shader_after_compose = rocky_bg.shader
|
||||||
scene.free()
|
scene.free()
|
||||||
|
|
||||||
|
|
@ -86,11 +94,11 @@ func _process(delta: float) -> bool:
|
||||||
1:
|
1:
|
||||||
if elapsed < 0.2:
|
if elapsed < 0.2:
|
||||||
return false
|
return false
|
||||||
var gas_bg: ShaderMaterial = scene.get_node("BackgroundLayer/LunarBackground").material
|
var gas_bg: ShaderMaterial = scene.get_node("BackgroundLayer/Background").material
|
||||||
scene.free()
|
scene.free()
|
||||||
|
|
||||||
if rocky_shader_after_compose != scene_shader:
|
if rocky_shader_after_compose != rocky_shader:
|
||||||
print("TEST_SURFACE_PROFILES: FAIL (landing on a rocky planet changed the background shader)")
|
print("TEST_SURFACE_PROFILES: FAIL (landing on a rocky planet did not put rocky's own declared shader on the background)")
|
||||||
quit(1)
|
quit(1)
|
||||||
return true
|
return true
|
||||||
if gas_bg.shader != gas_shader:
|
if gas_bg.shader != gas_shader:
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,18 @@ extends SceneTree
|
||||||
# fleet, a single-finger drag while the map is open pans the map content
|
# 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
|
# instead of leaking through to the gameplay fleet-move/zoom controls, and
|
||||||
# closing hides it again.
|
# 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
|
const POS_TOLERANCE: float = 3.0
|
||||||
|
|
||||||
|
|
@ -14,6 +26,8 @@ var fleet: Node
|
||||||
var camera: Camera2D
|
var camera: Camera2D
|
||||||
var world_map: Control
|
var world_map: Control
|
||||||
var content: Node2D
|
var content: Node2D
|
||||||
|
var map_button: Control
|
||||||
|
var close_button: Control
|
||||||
var fleet_target_before: Vector2
|
var fleet_target_before: Vector2
|
||||||
var camera_zoom_before: float
|
var camera_zoom_before: float
|
||||||
var content_pos_before_drag: Vector2
|
var content_pos_before_drag: Vector2
|
||||||
|
|
@ -29,6 +43,8 @@ func _initialize() -> void:
|
||||||
camera = main_scene.get_node("Camera2D")
|
camera = main_scene.get_node("Camera2D")
|
||||||
world_map = main_scene.get_node("UI/WorldMap")
|
world_map = main_scene.get_node("UI/WorldMap")
|
||||||
content = main_scene.get_node("UI/WorldMap/MapContent")
|
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:
|
func _process(delta: float) -> bool:
|
||||||
|
|
@ -41,7 +57,7 @@ func _process(delta: float) -> bool:
|
||||||
print("TEST_WORLD_MAP_UI: FAIL (map visible by default)")
|
print("TEST_WORLD_MAP_UI: FAIL (map visible by default)")
|
||||||
quit(1)
|
quit(1)
|
||||||
return true
|
return true
|
||||||
world_map.open_map()
|
_tap(map_button)
|
||||||
stage = 1
|
stage = 1
|
||||||
elapsed = 0.0
|
elapsed = 0.0
|
||||||
return false
|
return false
|
||||||
|
|
@ -55,6 +71,10 @@ func _process(delta: float) -> bool:
|
||||||
print("open_visible=%s content_pos=%s expected=%s recenter_ok=%s" % [
|
print("open_visible=%s content_pos=%s expected=%s recenter_ok=%s" % [
|
||||||
world_map.visible, content.position, expected_content_pos, recenter_ok
|
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):
|
if not (world_map.visible and recenter_ok):
|
||||||
print("TEST_WORLD_MAP_UI: FAIL (open/recenter)")
|
print("TEST_WORLD_MAP_UI: FAIL (open/recenter)")
|
||||||
quit(1)
|
quit(1)
|
||||||
|
|
@ -102,7 +122,9 @@ func _process(delta: float) -> bool:
|
||||||
quit(1)
|
quit(1)
|
||||||
return true
|
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
|
stage = 4
|
||||||
elapsed = 0.0
|
elapsed = 0.0
|
||||||
return false
|
return false
|
||||||
|
|
@ -110,7 +132,11 @@ func _process(delta: float) -> bool:
|
||||||
if elapsed < 0.1:
|
if elapsed < 0.1:
|
||||||
return false
|
return false
|
||||||
var closed_ok: bool = not world_map.visible
|
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:
|
if closed_ok:
|
||||||
print("TEST_WORLD_MAP_UI: PASS")
|
print("TEST_WORLD_MAP_UI: PASS")
|
||||||
quit(0)
|
quit(0)
|
||||||
|
|
@ -121,6 +147,30 @@ func _process(delta: float) -> bool:
|
||||||
return false
|
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:
|
func _touch(index: int, pressed: bool, pos: Vector2) -> void:
|
||||||
var ev: InputEventScreenTouch = InputEventScreenTouch.new()
|
var ev: InputEventScreenTouch = InputEventScreenTouch.new()
|
||||||
ev.index = index
|
ev.index = index
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue