Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75b7603f04 | ||
|
|
67cb853397 | ||
|
|
657ca9d035 |
21 changed files with 1191 additions and 108 deletions
|
|
@ -195,7 +195,56 @@ var pending_time: float = 0.0
|
||||||
var context_actions_expanded: bool = false
|
var context_actions_expanded: bool = false
|
||||||
|
|
||||||
|
|
||||||
|
# Gap between the last resource row and the first row of HUD buttons.
|
||||||
|
const HUD_COLUMN_MARGIN: float = 18.0
|
||||||
|
|
||||||
|
|
||||||
|
# Seats the top-anchored HUD button columns below the resource readout instead
|
||||||
|
# of at the fixed y the scene authored.
|
||||||
|
#
|
||||||
|
# ResourceReadout builds one row per resource type AT RUNTIME, so how far down
|
||||||
|
# it reaches is not knowable when the scene is laid out - the scene's guess
|
||||||
|
# was 154, and four resources actually reach 270, straight over the build
|
||||||
|
# button sitting at a hardcoded 160. Every resource type added since made it
|
||||||
|
# worse. Deriving the column top from the readout's real bottom makes the
|
||||||
|
# overlap impossible rather than merely fixed for today's resource count.
|
||||||
|
#
|
||||||
|
# This TRANSLATES the block rather than re-flowing it, so the rows keep their
|
||||||
|
# spacing relative to one another - BuildingActionButton and HangarButton
|
||||||
|
# deliberately share a row, being mutually exclusive, and a flow layout would
|
||||||
|
# pull them apart.
|
||||||
|
func _seat_hud_columns() -> void:
|
||||||
|
var readout: ResourceReadout = get_node_or_null("../UI/ResourceReadout")
|
||||||
|
if readout == null:
|
||||||
|
return
|
||||||
|
# Both columns, not just the left one: the readout spans the full width
|
||||||
|
# (the fleet hold column sits at x 356), so the second column of buttons
|
||||||
|
# collides with it exactly as the first does.
|
||||||
|
var buttons: Array = [
|
||||||
|
build_button, transfer_button,
|
||||||
|
action_button, landing_button,
|
||||||
|
building_action_button, hangar_button,
|
||||||
|
]
|
||||||
|
var highest: float = INF
|
||||||
|
for button in buttons:
|
||||||
|
if button != null:
|
||||||
|
highest = minf(highest, button.position.y)
|
||||||
|
if is_inf(highest):
|
||||||
|
return
|
||||||
|
|
||||||
|
var shift: float = readout.content_bottom() + HUD_COLUMN_MARGIN - highest
|
||||||
|
# Only ever pushed down, never pulled up: with few enough resources the
|
||||||
|
# authored layout already clears the readout, and there is no reason to
|
||||||
|
# drag the HUD away from where the scene deliberately put it.
|
||||||
|
if shift <= 0.0:
|
||||||
|
return
|
||||||
|
for button in buttons:
|
||||||
|
if button != null:
|
||||||
|
button.position.y += shift
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
_seat_hud_columns()
|
||||||
build_button.pressed.connect(_on_build_pressed)
|
build_button.pressed.connect(_on_build_pressed)
|
||||||
build_close_button.pressed.connect(_close_build_menu)
|
build_close_button.pressed.connect(_close_build_menu)
|
||||||
_configure_build_menu_entries()
|
_configure_build_menu_entries()
|
||||||
|
|
|
||||||
44
Fleet.gd
44
Fleet.gd
|
|
@ -238,33 +238,25 @@ func stop() -> void:
|
||||||
# since that lands inside arrive_radius immediately).
|
# since that lands inside arrive_radius immediately).
|
||||||
fleet_target = get_formation_origin()
|
fleet_target = get_formation_origin()
|
||||||
for ship in ships:
|
for ship in ships:
|
||||||
# A ship's own `velocity` is NOT how fast it is really travelling
|
# A ship's `velocity` is already its true motion, including the
|
||||||
# while the fleet cruises. Ship snaps to its slot and reports zero
|
# fleet's travel, because Ship feeds the slot velocity forward into
|
||||||
# the moment it is within arrive_radius of it, even though the
|
# its own steering rather than carrying it in a separate field. So
|
||||||
# anchor is dragging that slot forward at cruise_speed underneath
|
# stopping is only a matter of telling it the slot has halted and
|
||||||
# it. Its true motion is its catch-up velocity PLUS the fleet's,
|
# aiming it at the point its own braking curve reaches.
|
||||||
# which it already carries as `fleet_velocity` (zero for a
|
|
||||||
# point-to-point move_to, where `velocity` alone is the whole
|
|
||||||
# story).
|
|
||||||
#
|
#
|
||||||
# Braking from `velocity` alone therefore stopped the fleet dead
|
# This used to do `ship.velocity += ship.fleet_velocity` here, to
|
||||||
# on any frame where the ships happened to be seated - and how
|
# compensate for `velocity` reading zero while the ship was in fact
|
||||||
# often that happens depends on the frame rate, because the anchor
|
# cruising (Ship snapped to its slot every tick and zeroed it). That
|
||||||
# advances cruise_speed * delta per frame. At ~25fps it steps far
|
# compensation was an unbounded ACCUMULATOR: every release added a
|
||||||
# enough that ships always lag and always report motion, which is
|
# whole cruise velocity, the next steer refilled fleet_velocity, and
|
||||||
# the only reason releasing the stick looked right.
|
# tapping a direction repeatedly stacked them up faster than
|
||||||
var motion: Vector2 = ship.velocity + ship.fleet_velocity
|
# `acceleration` could bleed them off - so a vessel could be flung
|
||||||
# HAND THE FLEET'S MOTION TO THE SHIP. Until now the anchor was
|
# along at many times its own top speed, and ships fell out of sync
|
||||||
# carrying it along and its own `velocity` was ~0, so aiming it at a
|
# because each bled off at a rate set by its own distance to its
|
||||||
# braking point ahead made it start from REST: the fleet stopped
|
# slot. Ship now bounds its total speed every tick as well, so
|
||||||
# dead, then accelerated into its own stopping distance and halted a
|
# neither half of that can come back.
|
||||||
# second time. That is the "pause, then it moves again" stutter.
|
var motion: Vector2 = ship.velocity
|
||||||
# Giving it the velocity it was actually travelling at makes the
|
# The fleet is not cruising any more, so the slot is standing still.
|
||||||
# stop one continuous decelerating coast.
|
|
||||||
ship.velocity = motion
|
|
||||||
# Context that is no longer true: the fleet is not cruising any
|
|
||||||
# more, so leaving it set would have Ship face a heading it is not
|
|
||||||
# travelling on.
|
|
||||||
ship.fleet_velocity = Vector2.ZERO
|
ship.fleet_velocity = Vector2.ZERO
|
||||||
var speed: float = motion.length()
|
var speed: float = motion.length()
|
||||||
if speed <= 0.001:
|
if speed <= 0.001:
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,142 @@ extends ColorRect
|
||||||
# Feeds the lunar shader the camera's position split into a CELL INDEX plus
|
# Feeds the lunar shader the camera's position split into a CELL INDEX plus
|
||||||
# a fraction within that cell, rather than a raw world position. See the
|
# a fraction within that cell, rather than a raw world position. See the
|
||||||
# long note in Starfield.gd for why - identical reasoning, identical split.
|
# long note in Starfield.gd for why - identical reasoning, identical split.
|
||||||
|
#
|
||||||
|
# Forgejo #68: also bakes the crater field once, at load, instead of letting
|
||||||
|
# it be re-derived per pixel per frame. The field is a fixed function of
|
||||||
|
# world position, so every frame was recomputing an identical answer. It is
|
||||||
|
# rendered once into a square texture covering one period of the field (the
|
||||||
|
# field is periodic - see the cell wrap in lunar_surface.gdshader) and
|
||||||
|
# lunar_tiled.gdshader then scrolls that texture with repeat wrapping, which
|
||||||
|
# keeps the canvas infinite while the per-frame cost drops to one fetch.
|
||||||
|
|
||||||
|
# Single source of truth for the tile period, shared with BOTH shaders. The
|
||||||
|
# field repeats every TILE_CELLS * cell_size world units - at 32 cells of
|
||||||
|
# 220 that is 7040 units, several screens wide at default zoom.
|
||||||
|
const TILE_CELLS: float = 32.0
|
||||||
|
# Texture resolution for the bake, in pixels per cell. One cell is cell_size
|
||||||
|
# (220) world units, so this is a ~3.4x magnification at default zoom. That
|
||||||
|
# is affordable because the field is smooth by construction: the sharpest
|
||||||
|
# feature is the rim band, roughly 0.12 cells wide, which still lands across
|
||||||
|
# ~8 texels here and so survives bilinear magnification as a gradient rather
|
||||||
|
# than as steps.
|
||||||
|
const BAKE_PIXELS_PER_CELL: int = 64
|
||||||
|
|
||||||
|
# The palette, sampled from a NASA Artemis lunar photograph. These live on
|
||||||
|
# the node rather than as parameters of the drawing material because the
|
||||||
|
# shader that actually consumes them is the BAKE shader, which the scene
|
||||||
|
# never references directly - leaving them as orphan parameters on the tiled
|
||||||
|
# material would make them look live when they are only read once at load.
|
||||||
|
@export var base_color: Color = Color(0.75, 0.75, 0.76)
|
||||||
|
@export var crater_color: Color = Color(0.44, 0.44, 0.45)
|
||||||
|
@export var highlight_color: Color = Color(0.89, 0.89, 0.90)
|
||||||
|
|
||||||
@onready var camera: Camera2D = get_node("../../Camera2D")
|
@onready var camera: Camera2D = get_node("../../Camera2D")
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_bake_field()
|
||||||
|
|
||||||
|
|
||||||
|
# Asked of the shader itself rather than by comparing against a known path,
|
||||||
|
# so this keeps working whichever shader a surface profile swaps in: the
|
||||||
|
# question is only ever "does what is drawing this background actually
|
||||||
|
# sample a baked field".
|
||||||
|
func _material_wants_baked_field() -> bool:
|
||||||
|
if material == null or material.shader == null:
|
||||||
|
return false
|
||||||
|
for uniform in material.shader.get_shader_uniform_list():
|
||||||
|
if String(uniform.get("name", "")) == "field_texture":
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(_delta: float) -> void:
|
||||||
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
|
||||||
var cell: Vector2 = Vector2(floor(p.x), floor(p.y))
|
var cell: Vector2 = Vector2(floor(p.x), floor(p.y))
|
||||||
|
|
||||||
material.set_shader_parameter("camera_cell", cell)
|
# The tiled shader samples a repeating texture, so only the camera's
|
||||||
|
# position WITHIN the tile matters. Wrapping here rather than in the
|
||||||
|
# shader keeps the raw cell index - which reaches the thousands after a
|
||||||
|
# while - out of a mobile float entirely: GDScript does this in double
|
||||||
|
# precision. fposmod, not fmod, so flying into negative coordinates
|
||||||
|
# wraps to the far side of the tile instead of mirroring.
|
||||||
|
material.set_shader_parameter("camera_cell", Vector2(
|
||||||
|
fposmod(cell.x, TILE_CELLS), fposmod(cell.y, TILE_CELLS)))
|
||||||
material.set_shader_parameter("camera_frac", p - cell)
|
material.set_shader_parameter("camera_frac", p - cell)
|
||||||
material.set_shader_parameter("camera_zoom", 1.0 / camera.zoom.x)
|
material.set_shader_parameter("camera_zoom", 1.0 / camera.zoom.x)
|
||||||
material.set_shader_parameter("viewport_size", viewport_size)
|
material.set_shader_parameter("viewport_size", viewport_size)
|
||||||
|
material.set_shader_parameter("tile_cells", TILE_CELLS)
|
||||||
|
|
||||||
|
|
||||||
|
# Renders exactly one period of the procedural field into a texture and hands
|
||||||
|
# it to the tiled shader. Runs once, during the planet-arrival fade, where a
|
||||||
|
# single expensive frame is invisible.
|
||||||
|
func _bake_field() -> void:
|
||||||
|
# Headless runs - every scene-based test - rasterize nothing, so a bake
|
||||||
|
# would allocate a render target, await a draw that never produces
|
||||||
|
# anything readable, and leave this coroutine suspended forever holding
|
||||||
|
# the SubViewport alive. Nothing is lost by skipping: there is no display
|
||||||
|
# for the background to appear on.
|
||||||
|
if DisplayServer.get_name() == "headless":
|
||||||
|
return
|
||||||
|
|
||||||
|
# One frame of grace before committing to the bake. A surface profile can
|
||||||
|
# replace this material's shader outright - a gas giant brings its own
|
||||||
|
# background (#51) - and PlanetSurfaceController does that in _ready(),
|
||||||
|
# alongside this one, with no ordering guarantee between them. Rendering
|
||||||
|
# a 2048-square lunar tile for a shader that will never sample it would
|
||||||
|
# waste exactly the frame the player is already waiting on to land.
|
||||||
|
await get_tree().process_frame
|
||||||
|
if not _material_wants_baked_field():
|
||||||
|
return
|
||||||
|
|
||||||
|
var procedural: Shader = load("res://lunar_surface.gdshader")
|
||||||
|
if procedural == null:
|
||||||
|
push_warning("lunar background: could not load the field shader to bake")
|
||||||
|
return
|
||||||
|
|
||||||
|
var side: int = int(TILE_CELLS) * BAKE_PIXELS_PER_CELL
|
||||||
|
var bake_material := ShaderMaterial.new()
|
||||||
|
bake_material.shader = procedural
|
||||||
|
# Carry the look across from whatever the scene configured, so the tile
|
||||||
|
# is baked with the colours actually set on this node rather than the
|
||||||
|
# shader's own defaults.
|
||||||
|
bake_material.set_shader_parameter("base_color", base_color)
|
||||||
|
bake_material.set_shader_parameter("crater_color", crater_color)
|
||||||
|
bake_material.set_shader_parameter("highlight_color", highlight_color)
|
||||||
|
bake_material.set_shader_parameter("cell_size", material.get_shader_parameter("cell_size"))
|
||||||
|
bake_material.set_shader_parameter("tile_cells", TILE_CELLS)
|
||||||
|
# Non-zero switches the field shader into bake mode: UV maps straight
|
||||||
|
# onto one tile and the per-screen-pixel dither is left out.
|
||||||
|
bake_material.set_shader_parameter("bake_cells", TILE_CELLS)
|
||||||
|
|
||||||
|
var sub_viewport := SubViewport.new()
|
||||||
|
sub_viewport.size = Vector2i(side, side)
|
||||||
|
sub_viewport.disable_3d = true
|
||||||
|
sub_viewport.transparent_bg = false
|
||||||
|
sub_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
|
||||||
|
var bake_rect := ColorRect.new()
|
||||||
|
bake_rect.material = bake_material
|
||||||
|
bake_rect.size = Vector2(side, side)
|
||||||
|
sub_viewport.add_child(bake_rect)
|
||||||
|
add_child(sub_viewport)
|
||||||
|
|
||||||
|
# One frame for the SubViewport to actually draw, then read it back.
|
||||||
|
await RenderingServer.frame_post_draw
|
||||||
|
var image: Image = sub_viewport.get_texture().get_image()
|
||||||
|
sub_viewport.queue_free()
|
||||||
|
|
||||||
|
# Headless runs (every scene-based test) render nothing at all, so there
|
||||||
|
# is no image to read back. That is not a failure: the background simply
|
||||||
|
# has nothing to show, and the rest of the scene is unaffected.
|
||||||
|
if image == null or image.is_empty():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Mipmaps are what keep a crater field from shimmering when the camera
|
||||||
|
# zooms out and each screen pixel starts covering many texels.
|
||||||
|
image.generate_mipmaps()
|
||||||
|
var texture := ImageTexture.create_from_image(image)
|
||||||
|
material.set_shader_parameter("field_texture", texture)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
[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="Script" path="res://LunarBackground.gd" id="2"]
|
||||||
[ext_resource type="Shader" path="res://lunar_surface.gdshader" 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"]
|
||||||
|
|
@ -15,13 +15,12 @@
|
||||||
|
|
||||||
[sub_resource type="ShaderMaterial" id="1"]
|
[sub_resource type="ShaderMaterial" id="1"]
|
||||||
shader = ExtResource("3")
|
shader = ExtResource("3")
|
||||||
shader_parameter/camera_pos = Vector2(0, 0)
|
shader_parameter/camera_cell = Vector2(0, 0)
|
||||||
|
shader_parameter/camera_frac = Vector2(0, 0)
|
||||||
shader_parameter/camera_zoom = 1.0
|
shader_parameter/camera_zoom = 1.0
|
||||||
shader_parameter/viewport_size = Vector2(720, 1280)
|
shader_parameter/viewport_size = Vector2(720, 1280)
|
||||||
shader_parameter/cell_size = 220.0
|
shader_parameter/cell_size = 220.0
|
||||||
shader_parameter/base_color = Color(0.75, 0.75, 0.76, 1)
|
shader_parameter/tile_cells = 32.0
|
||||||
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="PlanetSurface" type="Node2D"]
|
[node name="PlanetSurface" type="Node2D"]
|
||||||
script = ExtResource("1")
|
script = ExtResource("1")
|
||||||
|
|
@ -97,12 +96,16 @@ theme_override_constants/outline_size = 4
|
||||||
text = "FLEET HOLD"
|
text = "FLEET HOLD"
|
||||||
|
|
||||||
[node name="PlanetNameLabel" type="Label" parent="UI"]
|
[node name="PlanetNameLabel" type="Label" parent="UI"]
|
||||||
offset_left = 24
|
mouse_filter = 2
|
||||||
offset_top = 156
|
offset_left = 400
|
||||||
|
offset_top = 40
|
||||||
offset_right = 696
|
offset_right = 696
|
||||||
offset_bottom = 282
|
offset_bottom = 148
|
||||||
text = ""
|
text = ""
|
||||||
horizontal_alignment = 1
|
horizontal_alignment = 2
|
||||||
|
vertical_alignment = 0
|
||||||
|
autowrap_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 30
|
||||||
theme_override_colors/font_color = Color(0.08, 0.08, 0.11, 1)
|
theme_override_colors/font_color = Color(0.08, 0.08, 0.11, 1)
|
||||||
theme_override_colors/font_outline_color = Color(0.95, 0.95, 0.96, 0.85)
|
theme_override_colors/font_outline_color = Color(0.95, 0.95, 0.96, 0.85)
|
||||||
theme_override_constants/outline_size = 5
|
theme_override_constants/outline_size = 5
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,26 @@ func _ready() -> void:
|
||||||
|
|
||||||
y += ROW_HEIGHT
|
y += ROW_HEIGHT
|
||||||
|
|
||||||
|
# Stop understating our own extent. The rows are drawn by child nodes,
|
||||||
|
# which happily paint outside this Control's rect, so the scene-authored
|
||||||
|
# height stayed at its original guess no matter how many rows appeared.
|
||||||
|
size.y = rows_height()
|
||||||
|
|
||||||
|
|
||||||
|
# How tall the rows are, derived from the live resource list rather than from
|
||||||
|
# whatever _ready() happened to have built. That matters: _ready() runs in
|
||||||
|
# scene-tree order, so a sibling laying itself out relative to this readout
|
||||||
|
# (BuildController._seat_hud_columns) can legitimately ask BEFORE this node
|
||||||
|
# has built anything, and a cached height would answer zero.
|
||||||
|
func rows_height() -> float:
|
||||||
|
return float(GameState.resource_types().size()) * ROW_HEIGHT
|
||||||
|
|
||||||
|
|
||||||
|
# Bottom edge of the rows in this Control's parent space - the line anything
|
||||||
|
# stacked below the readout has to clear.
|
||||||
|
func content_bottom() -> float:
|
||||||
|
return position.y + rows_height()
|
||||||
|
|
||||||
|
|
||||||
func _add_glyph(resource_type: String, at: Vector2) -> void:
|
func _add_glyph(resource_type: String, at: Vector2) -> void:
|
||||||
var glyph: ResourceGlyph = ResourceGlyph.new()
|
var glyph: ResourceGlyph = ResourceGlyph.new()
|
||||||
|
|
|
||||||
49
Ship.gd
49
Ship.gd
|
|
@ -13,7 +13,15 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd")
|
||||||
var definition: Resource
|
var definition: Resource
|
||||||
var glow_color: Color = Color(0.25, 0.85, 1.0)
|
var glow_color: Color = Color(0.25, 0.85, 1.0)
|
||||||
var core_color: Color = Color.WHITE
|
var core_color: Color = Color.WHITE
|
||||||
|
# The ship's TRUE world velocity - what `position` is actually integrated
|
||||||
|
# from, and the single source of truth for how fast this vessel is moving.
|
||||||
|
# Nothing else may contribute motion; see the note in _physics_process.
|
||||||
var velocity: Vector2 = Vector2.ZERO
|
var velocity: Vector2 = Vector2.ZERO
|
||||||
|
# The velocity of this ship's formation SLOT (the fleet anchor's own travel).
|
||||||
|
# Fed forward into the steering below rather than added on top of it: matching
|
||||||
|
# the slot's speed is most of the job of holding formation, so supplying it
|
||||||
|
# directly leaves `velocity` free to be a small correction and lets the ship
|
||||||
|
# sit in its slot with no steady-state lag and no teleporting.
|
||||||
var fleet_velocity: Vector2 = Vector2.ZERO
|
var fleet_velocity: Vector2 = Vector2.ZERO
|
||||||
var move_target: Vector2
|
var move_target: Vector2
|
||||||
|
|
||||||
|
|
@ -109,22 +117,47 @@ func _physics_process(delta: float) -> void:
|
||||||
# "snaps onto its slot" formation tightness at any boost level that
|
# "snaps onto its slot" formation tightness at any boost level that
|
||||||
# 1.0x already has.
|
# 1.0x already has.
|
||||||
var boost: float = GameState.fleet_speed_multiplier
|
var boost: float = GameState.fleet_speed_multiplier
|
||||||
|
var effective_acceleration: float = acceleration * boost
|
||||||
var to_target: Vector2 = move_target - position
|
var to_target: Vector2 = move_target - position
|
||||||
var distance: float = to_target.length()
|
var distance: float = to_target.length()
|
||||||
if distance <= arrive_radius * boost:
|
|
||||||
|
# Snap only when the slot is STANDING STILL. A stationary target is the
|
||||||
|
# one case where converging asymptotically would leave a permanent
|
||||||
|
# sub-pixel residue, and settling exactly is what "the fleet has arrived"
|
||||||
|
# means everywhere else in the game. While the slot is moving the ship
|
||||||
|
# steers continuously instead - a snap there would be a teleport, which
|
||||||
|
# is what used to make `velocity` read zero for a ship that was in fact
|
||||||
|
# travelling at cruise speed, and that lie is what the whole
|
||||||
|
# stop()/fleet_velocity bookkeeping tangle existed to paper over.
|
||||||
|
if fleet_velocity == Vector2.ZERO and distance <= arrive_radius * boost:
|
||||||
position = move_target
|
position = move_target
|
||||||
velocity = Vector2.ZERO
|
velocity = Vector2.ZERO
|
||||||
else:
|
else:
|
||||||
var effective_acceleration: float = acceleration * boost
|
# Feed-forward plus correction. The ship aims to travel at its slot's
|
||||||
var braking_speed: float = sqrt(2.0 * effective_acceleration * distance)
|
# velocity, plus whatever extra closes the gap to the slot - so
|
||||||
var desired_speed: float = min(max_speed * boost, braking_speed)
|
# holding formation costs no error, and `velocity` stays the ship's
|
||||||
var desired_velocity: Vector2 = to_target.normalized() * desired_speed
|
# real motion rather than a residual that has to be added to
|
||||||
|
# something else to mean anything.
|
||||||
|
var correction: Vector2 = Vector2.ZERO
|
||||||
|
if distance > 0.001:
|
||||||
|
var braking_speed: float = sqrt(2.0 * effective_acceleration * distance)
|
||||||
|
correction = to_target.normalized() * min(max_speed * boost, braking_speed)
|
||||||
|
var desired_velocity: Vector2 = fleet_velocity + correction
|
||||||
|
# HARD BOUND on how fast any single vessel may travel, applied to the
|
||||||
|
# total and re-applied every tick. Feed-forward plus correction can
|
||||||
|
# ask for more than a vessel can actually fly, and without this the
|
||||||
|
# excess was free speed: a ship that had been handed a large velocity
|
||||||
|
# kept it, decaying only at `acceleration`. Clamping the REQUEST is
|
||||||
|
# not enough - the clamp has to sit on the integrated result, so no
|
||||||
|
# sequence of commands can leave a vessel travelling faster than its
|
||||||
|
# own top speed.
|
||||||
|
desired_velocity = desired_velocity.limit_length(max_speed * boost)
|
||||||
velocity = velocity.move_toward(desired_velocity, effective_acceleration * delta)
|
velocity = velocity.move_toward(desired_velocity, effective_acceleration * delta)
|
||||||
|
velocity = velocity.limit_length(max_speed * boost)
|
||||||
position += velocity * delta
|
position += velocity * delta
|
||||||
|
|
||||||
var motion: Vector2 = velocity + fleet_velocity
|
if velocity.length() > 8.0:
|
||||||
if motion.length() > 8.0:
|
var target_angle: float = velocity.angle() + PI / 2.0
|
||||||
var target_angle: float = motion.angle() + PI / 2.0
|
|
||||||
rotation = lerp_angle(rotation, target_angle, rotation_speed * delta)
|
rotation = lerp_angle(rotation, target_angle, rotation_speed * delta)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,14 @@ shader_type canvas_item;
|
||||||
// a thin bright rim wedge through the middle of the merged basin. The
|
// a thin bright rim wedge through the middle of the merged basin. The
|
||||||
// second-nearest distance is tracked purely to detect that case and fade
|
// second-nearest distance is tracked purely to detect that case and fade
|
||||||
// the rim out there - see the note further down.
|
// the rim out there - see the note further down.
|
||||||
|
//
|
||||||
|
// This runs on EVERY pixel of a fullscreen rect on a phone, so it is the
|
||||||
|
// single most expensive thing in the surface scene and the subject of
|
||||||
|
// Forgejo #68 ("the moon game is a bit slow"). The three optimizations
|
||||||
|
// that brought it down - a bounded 3x3 scan, one hash per cell instead of
|
||||||
|
// three, and one sqrt per pixel instead of one per sample - are each
|
||||||
|
// justified at the point they appear below. Time it with
|
||||||
|
// bash tests/profile_shader.sh res://lunar_surface.gdshader
|
||||||
|
|
||||||
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);
|
||||||
|
|
@ -46,33 +54,62 @@ uniform vec4 highlight_color : source_color = vec4(0.89, 0.89, 0.90, 1.0);
|
||||||
// cuts appear in this grayscale output the FIELD is at fault, and if
|
// cuts appear in this grayscale output the FIELD is at fault, and if
|
||||||
// it is smooth the field is fine and the SHADING is at fault.
|
// it is smooth the field is fine and the SHADING is at fault.
|
||||||
uniform float debug_mode = 0.0;
|
uniform float debug_mode = 0.0;
|
||||||
|
// Width of the repeating tile, in cells. The crater field is made PERIODIC
|
||||||
|
// with this period (see the wrap in the loop), which is what lets
|
||||||
|
// LunarBackground.gd bake one square of it into a texture and then scroll
|
||||||
|
// that texture forever with repeat wrapping, instead of re-deriving the
|
||||||
|
// field per pixel per frame. Set from LunarBackground.TILE_CELLS, which is
|
||||||
|
// the single source of truth shared with lunar_tiled.gdshader.
|
||||||
|
uniform float tile_cells = 32.0;
|
||||||
|
// BAKE MODE. 0 = normal camera-relative sampling for direct display.
|
||||||
|
// Above zero, UV is mapped straight onto [0, tile_cells] instead, so
|
||||||
|
// rendering this shader to a square SubViewport produces exactly one period
|
||||||
|
// of the field - the texture the runtime shader then samples.
|
||||||
|
uniform float bake_cells = 0.0;
|
||||||
|
|
||||||
const vec2 LIGHT_DIR = vec2(-0.6, -0.8);
|
const vec2 LIGHT_DIR = vec2(-0.6, -0.8);
|
||||||
const float MIN_RADIUS = 0.16;
|
const float MIN_RADIUS = 0.16;
|
||||||
|
// MUST stay below 1.0. The 3x3 scan below is only correct because a crater
|
||||||
|
// cannot reach out of its own cell's immediate neighbourhood; see the long
|
||||||
|
// note at the loop. tests/test_lunar_field.gd asserts this bound directly
|
||||||
|
// against the shader source so it cannot drift unnoticed.
|
||||||
const float MAX_RADIUS = 0.38;
|
const float MAX_RADIUS = 0.38;
|
||||||
// Sentinel for "this cell holds no crater". Must stay well inside mediump
|
// Sentinel for "this cell holds no crater", in SQUARED normalized units to
|
||||||
// range (max ~65504) so it can never become +Inf - see the note in the
|
// match the loop. Must stay well inside mediump range (max ~65504) so it
|
||||||
// loop. Only needs to exceed the largest real normalized distance (~18).
|
// can never become +Inf - see the note in the loop. Only needs to exceed
|
||||||
const float MISS_DISTANCE = 1000.0;
|
// the largest real squared normalized distance, which for a 3x3 scan is
|
||||||
|
// (2.83 cells / 0.16 min radius)^2 ~= 313.
|
||||||
|
const float MISS_DISTANCE_SQ = 10000.0;
|
||||||
|
// Loop-carried initial value. Above any real value INCLUDING one carrying
|
||||||
|
// the miss sentinel (~10313), so a pixel whose whole neighbourhood is
|
||||||
|
// empty still leaves the "winning" offset populated with something finite.
|
||||||
|
const float START_DISTANCE_SQ = 20000.0;
|
||||||
|
|
||||||
|
// One hash call yields all four values a cell needs (crater centre x/y,
|
||||||
|
// whether the cell holds a crater at all, and its radius). The previous
|
||||||
|
// version called two different hashes three times per cell with different
|
||||||
|
// constant offsets, which was three quarters of the loop body's arithmetic
|
||||||
|
// for no extra information - a single hash with four independent output
|
||||||
|
// lanes decorrelates just as well.
|
||||||
|
//
|
||||||
|
// Multipliers chosen for precision safety on mobile GPUs: they are all
|
||||||
|
// < 1, so every intermediate stays small no matter how large the cell
|
||||||
|
// index gets. An earlier version multiplied by ~123/456 first, which lands
|
||||||
|
// the intermediate in the thousands where a mobile float loses its
|
||||||
|
// fractional bits entirely. (Standard "hash without sine" formulation,
|
||||||
|
// Dave Hoskins.)
|
||||||
|
vec4 hash42(vec2 p) {
|
||||||
|
vec4 p4 = fract(vec4(p.xyxy) * vec4(0.1031, 0.1030, 0.0973, 0.1099));
|
||||||
|
p4 += dot(p4, p4.wzxy + 33.33);
|
||||||
|
return fract((p4.xxyz + p4.yzzw) * p4.zywx);
|
||||||
|
}
|
||||||
|
|
||||||
// Hash pair chosen for precision safety on mobile GPUs. The previous
|
|
||||||
// version multiplied the cell coordinate by ~123/456 before fract(), which
|
|
||||||
// lands the intermediate in the thousands where a mobile float loses its
|
|
||||||
// fractional bits entirely. These multipliers are all < 1, so every
|
|
||||||
// intermediate stays small no matter how large the cell index gets.
|
|
||||||
// (Standard "hash without sine" formulation, Dave Hoskins.)
|
|
||||||
float hash21(vec2 p) {
|
float hash21(vec2 p) {
|
||||||
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
|
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
|
||||||
p3 += dot(p3, p3.yzx + 33.33);
|
p3 += dot(p3, p3.yzx + 33.33);
|
||||||
return fract((p3.x + p3.y) * p3.z);
|
return fract((p3.x + p3.y) * p3.z);
|
||||||
}
|
}
|
||||||
|
|
||||||
vec2 hash22(vec2 p) {
|
|
||||||
vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
|
|
||||||
p3 += dot(p3, p3.yzx + 33.33);
|
|
||||||
return fract((p3.xx + p3.yz) * p3.zy);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
|
|
@ -92,11 +129,18 @@ void fragment() {
|
||||||
// calling floor(x) and fract(x) separately lets the GPU round the two
|
// calling floor(x) and fract(x) separately lets the GPU round the two
|
||||||
// evaluations differently, which right at a cell boundary makes them
|
// evaluations differently, which right at a cell boundary makes them
|
||||||
// disagree by a full cell and snaps the sampled point one cell sideways.
|
// disagree by a full cell and snaps the sampled point one cell sideways.
|
||||||
vec2 rel = camera_frac + centered * (camera_zoom / cell_size);
|
//
|
||||||
|
// In bake mode none of that applies: the target is one tile of the field
|
||||||
|
// in isolation, so UV maps directly onto [0, bake_cells] and there is no
|
||||||
|
// camera in the picture at all.
|
||||||
|
bool baking = bake_cells > 0.0;
|
||||||
|
vec2 rel = baking
|
||||||
|
? UV * bake_cells
|
||||||
|
: camera_frac + centered * (camera_zoom / cell_size);
|
||||||
vec2 cell_rel = floor(rel);
|
vec2 cell_rel = floor(rel);
|
||||||
vec2 local = rel - cell_rel;
|
vec2 local = rel - cell_rel;
|
||||||
// Absolute cell index, used only for hashing. Built from exact integers.
|
// Absolute cell index, used only for hashing. Built from exact integers.
|
||||||
vec2 cell = camera_cell + cell_rel;
|
vec2 cell = baking ? cell_rel : camera_cell + cell_rel;
|
||||||
|
|
||||||
// Single scalar field: normalized distance to the nearest crater.
|
// Single scalar field: normalized distance to the nearest crater.
|
||||||
// < 1.0 means this pixel is inside that crater; 1.0 is exactly on its
|
// < 1.0 means this pixel is inside that crater; 1.0 is exactly on its
|
||||||
|
|
@ -110,22 +154,49 @@ void fragment() {
|
||||||
// through the middle of what should read as one merged depression.
|
// through the middle of what should read as one merged depression.
|
||||||
// A pixel in that region is inside BOTH craters, i.e. second_nearest is
|
// A pixel in that region is inside BOTH craters, i.e. second_nearest is
|
||||||
// also < 1.0 - which is precisely the condition to fade the rim out.
|
// also < 1.0 - which is precisely the condition to fade the rim out.
|
||||||
float nearest = MISS_DISTANCE;
|
//
|
||||||
float second_nearest = MISS_DISTANCE;
|
// Tracked SQUARED, and square-rooted once at the end. Ordering is
|
||||||
vec2 nearest_dir = vec2(0.0, 1.0);
|
// unchanged by squaring (both sides are non-negative), so the same
|
||||||
|
// crater wins - but this turns one sqrt per sample into two per pixel,
|
||||||
|
// and sqrt is one of the more expensive things a fragment shader can
|
||||||
|
// ask for.
|
||||||
|
float nearest_sq = START_DISTANCE_SQ;
|
||||||
|
float second_nearest_sq = START_DISTANCE_SQ;
|
||||||
|
vec2 nearest_offset = vec2(0.0, 1.0);
|
||||||
|
|
||||||
// 5x5 rather than 3x3. A crater's radius is at most MAX_RADIUS cells,
|
// 3x3 is provably sufficient, and provably so only because of the two
|
||||||
// so in theory 3x3 covers every crater that could possibly reach this
|
// clamps in the loop body. A crater's centre is clamped into its own
|
||||||
// pixel - but on-device, craters were still being clipped exactly at
|
// cell and its radius is clamped to at most MAX_RADIUS (0.38) cells, so
|
||||||
// cell boundaries, which means some crater's influence was reaching
|
// the furthest a crater can reach past its own cell is 0.38 cells. This
|
||||||
// further than the scan did (e.g. a hash returning a value outside its
|
// pixel's `local` lies in [0,1], so a crater two cells out is at least
|
||||||
// nominal range on that GPU, inflating a radius). Scanning two cells
|
// 1.0 cells away - a normalized distance of at least 1/0.38 = 2.6,
|
||||||
// out costs more samples but makes the clip impossible to reach even
|
// which is past every threshold used below (the floor fades out by 0.9,
|
||||||
// if a radius comes out several times larger than intended.
|
// the rim band by 1.0, and the overlap suppression saturates at 1.15).
|
||||||
for (int dx = -2; dx <= 2; dx++) {
|
// Such a crater therefore cannot change a single pixel of the output.
|
||||||
for (int dy = -2; dy <= 2; dy++) {
|
//
|
||||||
|
// This replaces an earlier 5x5 scan. That width was not chosen from the
|
||||||
|
// geometry - 3x3 was already the geometric answer - but as insurance
|
||||||
|
// against craters that were observed clipping at cell boundaries
|
||||||
|
// on-device, which meant some crater's influence was reaching further
|
||||||
|
// than its nominal radius allowed, e.g. a hash returning a value outside
|
||||||
|
// [0,1] on that GPU and extrapolating mix() into a huge radius. Clamping
|
||||||
|
// both the radius AND the centre fixes that at the source and makes the
|
||||||
|
// bound above hold no matter what the hash returns, so paying for 25
|
||||||
|
// samples per pixel to hide a driver quirk is no longer necessary.
|
||||||
|
// tests/test_lunar_field.gd checks the two scans agree exactly.
|
||||||
|
for (int dx = -1; dx <= 1; dx++) {
|
||||||
|
for (int dy = -1; dy <= 1; dy++) {
|
||||||
vec2 offset = vec2(float(dx), float(dy));
|
vec2 offset = vec2(float(dx), float(dy));
|
||||||
highp vec2 neighbor_cell = cell + offset;
|
// Wrapping the cell index before hashing is the entire trick that
|
||||||
|
// makes the field tileable: cell N and cell N + tile_cells hash
|
||||||
|
// identically, so the pattern repeats exactly with that period.
|
||||||
|
// It works on a Worley field specifically because the scan only
|
||||||
|
// ever reads immediate neighbours - wrap those consistently and
|
||||||
|
// the seam is not merely hidden, it does not exist. GLSL mod()
|
||||||
|
// returns a non-negative result for a positive divisor, so
|
||||||
|
// negative cell indices wrap correctly too.
|
||||||
|
highp vec2 neighbor_cell = mod(cell + offset, tile_cells);
|
||||||
|
vec4 h = hash42(neighbor_cell);
|
||||||
|
|
||||||
// Cells without a crater are pushed far away rather than
|
// Cells without a crater are pushed far away rather than
|
||||||
// branched around, keeping the loop body uniform. NOTE the
|
// branched around, keeping the loop body uniform. NOTE the
|
||||||
|
|
@ -134,32 +205,45 @@ void fragment() {
|
||||||
// becomes +Inf there, and (1.0 - has_crater) * Inf evaluates to
|
// becomes +Inf there, and (1.0 - has_crater) * Inf evaluates to
|
||||||
// 0 * Inf = NaN for every cell that DOES hold a crater - which
|
// 0 * Inf = NaN for every cell that DOES hold a crater - which
|
||||||
// compares false against everything and would silently discard
|
// compares false against everything and would silently discard
|
||||||
// every crater. MISS_DISTANCE only has to beat the largest real
|
// every crater.
|
||||||
// normalized distance (~2.9 cells / 0.16 min radius ~= 18).
|
float has_crater = step(0.35, h.z);
|
||||||
float has_crater = step(0.35, hash21(neighbor_cell + vec2(41.0, 5.0)));
|
// clamp() on both of these is what bounds a crater's reach to
|
||||||
vec2 crater_center = offset + hash22(neighbor_cell);
|
// its own cell plus MAX_RADIUS, which is what lets the scan
|
||||||
|
// above be 3x3. Without them a driver returning a hash outside
|
||||||
|
// [0,1] could place a centre, or inflate a radius, far enough to
|
||||||
|
// reach past the scan - which is exactly the clipping that was
|
||||||
|
// seen on-device.
|
||||||
|
vec2 crater_center = offset + clamp(h.xy, 0.0, 1.0);
|
||||||
|
float radius = clamp(mix(MIN_RADIUS, MAX_RADIUS, h.w), MIN_RADIUS, MAX_RADIUS);
|
||||||
|
|
||||||
vec2 to_sample = local - crater_center;
|
vec2 to_sample = local - crater_center;
|
||||||
float dist = length(to_sample);
|
float normalized_sq = dot(to_sample, to_sample) / (radius * radius)
|
||||||
// clamp() guards against a hash landing outside [0,1] on some
|
+ (1.0 - has_crater) * MISS_DISTANCE_SQ;
|
||||||
// driver and extrapolating mix() into a huge radius.
|
|
||||||
float radius = clamp(mix(MIN_RADIUS, MAX_RADIUS, hash21(neighbor_cell + vec2(9.0, 2.0))), MIN_RADIUS, MAX_RADIUS);
|
|
||||||
|
|
||||||
float normalized = dist / radius + (1.0 - has_crater) * MISS_DISTANCE;
|
if (normalized_sq < nearest_sq) {
|
||||||
|
second_nearest_sq = nearest_sq;
|
||||||
if (normalized < nearest) {
|
nearest_sq = normalized_sq;
|
||||||
second_nearest = nearest;
|
// Offset from the winning crater's centre to this pixel,
|
||||||
nearest = normalized;
|
// kept so the rim's light/shadow split can be derived from
|
||||||
// Direction from the winning crater's centre to this pixel,
|
// the SAME crater that won the distance test - the shading
|
||||||
// used for the rim's light/shadow split. Taken from the SAME
|
// can then never describe one crater while the floor
|
||||||
// crater that won the distance test, so the shading can never
|
// describes another.
|
||||||
// describe one crater while the floor describes another.
|
nearest_offset = to_sample;
|
||||||
nearest_dir = dist > 0.0001 ? to_sample / dist : vec2(0.0, 1.0);
|
} else if (normalized_sq < second_nearest_sq) {
|
||||||
} else if (normalized < second_nearest) {
|
second_nearest_sq = normalized_sq;
|
||||||
second_nearest = normalized;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
float nearest = sqrt(nearest_sq);
|
||||||
|
float second_nearest = sqrt(second_nearest_sq);
|
||||||
|
// normalize() would divide by zero for a pixel sitting exactly on a
|
||||||
|
// crater centre; the guard keeps that pixel's rim term merely arbitrary
|
||||||
|
// rather than NaN. It is deep inside the floor, where the rim band is
|
||||||
|
// zero anyway.
|
||||||
|
float offset_length = length(nearest_offset);
|
||||||
|
vec2 nearest_dir = offset_length > 0.0001 ? nearest_offset / offset_length : vec2(0.0, 1.0);
|
||||||
|
|
||||||
vec2 light_dir = normalize(LIGHT_DIR);
|
vec2 light_dir = normalize(LIGHT_DIR);
|
||||||
float facing = dot(nearest_dir, light_dir);
|
float facing = dot(nearest_dir, light_dir);
|
||||||
|
|
||||||
|
|
@ -188,8 +272,14 @@ void fragment() {
|
||||||
// can visibly "step" into faint bands on real hardware. A small random
|
// can visibly "step" into faint bands on real hardware. A small random
|
||||||
// jitter well under one visible step breaks the stepping up without
|
// jitter well under one visible step breaks the stepping up without
|
||||||
// being perceptible as noise.
|
// being perceptible as noise.
|
||||||
float dither = (hash21(screen_px + vec2(7.0, 13.0)) - 0.5) / 128.0;
|
// Skipped when baking: dither is a per-SCREEN-pixel jitter, and baking it
|
||||||
color += vec3(dither);
|
// would freeze the noise into the texture, where magnification smears it
|
||||||
|
// into visible blotches and tiling repeats it. lunar_tiled.gdshader
|
||||||
|
// applies the identical term live instead, which is where it belongs.
|
||||||
|
if (!baking) {
|
||||||
|
float dither = (hash21(screen_px + vec2(7.0, 13.0)) - 0.5) / 128.0;
|
||||||
|
color += vec3(dither);
|
||||||
|
}
|
||||||
|
|
||||||
// Diagnostic checkerboard: alternate cells tinted, plus a bright line
|
// Diagnostic checkerboard: alternate cells tinted, plus a bright line
|
||||||
// right at each cell edge. Makes the grid unmistakable in a screenshot.
|
// right at each cell edge. Makes the grid unmistakable in a screenshot.
|
||||||
|
|
|
||||||
79
lunar_tiled.gdshader
Normal file
79
lunar_tiled.gdshader
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
shader_type canvas_item;
|
||||||
|
|
||||||
|
// The lunar backdrop as it is actually drawn every frame: one texture fetch
|
||||||
|
// plus a dither, and nothing else.
|
||||||
|
//
|
||||||
|
// Forgejo #68. lunar_surface.gdshader derives the crater field procedurally,
|
||||||
|
// which costs a 9-sample Worley scan on every pixel of a fullscreen rect,
|
||||||
|
// every frame, forever - and it recomputes the exact same answer each time,
|
||||||
|
// because the field is a fixed function of world position. So it is
|
||||||
|
// evaluated ONCE, into a square texture covering one period of the field
|
||||||
|
// (LunarBackground.gd does the bake at scene load), and this shader scrolls
|
||||||
|
// that texture with repeat wrapping. The canvas stays infinite; the per-
|
||||||
|
// frame cost stops scaling with how complicated the field is.
|
||||||
|
//
|
||||||
|
// Two things pay for that, both stated plainly because they are real:
|
||||||
|
//
|
||||||
|
// 1. The field REPEATS, every tile_cells * cell_size world units. It is
|
||||||
|
// seamless (lunar_surface.gdshader wraps the cell index before hashing,
|
||||||
|
// so the periodicity is exact rather than blended), but a player who
|
||||||
|
// flies far enough passes the same crater arrangement again. Lunar
|
||||||
|
// regolith is about the most forgiving possible content for that: no
|
||||||
|
// landmarks, no horizon, no orientation cues, nothing to anchor a
|
||||||
|
// memory of "I have seen this crater before" to.
|
||||||
|
// 2. The texture has a fixed resolution, so zooming in past its native
|
||||||
|
// density softens the craters rather than resolving finer ones. The
|
||||||
|
// field is smooth gradients by construction - the sharpest thing in it
|
||||||
|
// is the rim band, and that still spans several texels - so bilinear
|
||||||
|
// magnification reproduces it closely instead of showing blocks.
|
||||||
|
//
|
||||||
|
// Mipmaps handle the other direction (zooming out), which is where a
|
||||||
|
// high-frequency crater field would otherwise alias into shimmer.
|
||||||
|
|
||||||
|
uniform sampler2D field_texture : filter_linear_mipmap, repeat_enable;
|
||||||
|
uniform vec2 camera_cell = vec2(0.0, 0.0);
|
||||||
|
uniform vec2 camera_frac = vec2(0.0, 0.0);
|
||||||
|
uniform float camera_zoom = 1.0;
|
||||||
|
uniform vec2 viewport_size = vec2(720.0, 1280.0);
|
||||||
|
uniform float cell_size = 220.0;
|
||||||
|
uniform float tile_cells = 32.0;
|
||||||
|
|
||||||
|
float hash21(vec2 p) {
|
||||||
|
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
|
||||||
|
p3 += dot(p3, p3.yzx + 33.33);
|
||||||
|
return fract((p3.x + p3.y) * p3.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
void fragment() {
|
||||||
|
vec2 screen_px = UV * viewport_size;
|
||||||
|
vec2 centered = screen_px - viewport_size * 0.5;
|
||||||
|
|
||||||
|
// Same camera-relative construction as the procedural shader, and for
|
||||||
|
// the same reason: an absolute world position reaches the tens of
|
||||||
|
// thousands, where a mobile float has lost the fractional bits this
|
||||||
|
// needs. camera_cell arrives ALREADY WRAPPED into [0, tile_cells) -
|
||||||
|
// LunarBackground.gd does that wrap in GDScript's double precision, so
|
||||||
|
// the large number never enters the shader at all.
|
||||||
|
vec2 rel = camera_frac + centered * (camera_zoom / cell_size);
|
||||||
|
vec2 uv = (camera_cell + rel) / tile_cells;
|
||||||
|
|
||||||
|
// repeat_enable does the wrapping, so uv running outside [0,1] is not
|
||||||
|
// merely tolerated, it is the mechanism.
|
||||||
|
vec3 color = texture(field_texture, uv).rgb;
|
||||||
|
|
||||||
|
// Tiny per-pixel dither to mask 8-bit colour-quantization banding: the
|
||||||
|
// crater shading is a smooth gradient over a fairly large area, which
|
||||||
|
// can visibly "step" into faint bands on real hardware. A small random
|
||||||
|
// jitter well under one visible step breaks the stepping up without
|
||||||
|
// being perceptible as noise. Doubly needed here - the baked texture is
|
||||||
|
// itself 8-bit, so it quantizes the gradient a second time.
|
||||||
|
//
|
||||||
|
// Deliberately keyed to the SCREEN pixel, not the world position: it has
|
||||||
|
// to stay fixed to the display or it turns into crawling noise as the
|
||||||
|
// camera moves, and it must not be baked into the tile or it would
|
||||||
|
// repeat along with everything else.
|
||||||
|
float dither = (hash21(screen_px + vec2(7.0, 13.0)) - 0.5) / 128.0;
|
||||||
|
color += vec3(dither);
|
||||||
|
|
||||||
|
COLOR = vec4(color, 1.0);
|
||||||
|
}
|
||||||
1
lunar_tiled.gdshader.uid
Normal file
1
lunar_tiled.gdshader.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dmmdpa3pmr8g
|
||||||
106
tests/_profile_surface_shader.gd
Normal file
106
tests/_profile_surface_shader.gd
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Not a test - a stopwatch for a fullscreen background shader, in the same
|
||||||
|
# spirit as _profile_startup.gd but aimed at the FRAGMENT side instead of
|
||||||
|
# scene setup. Forgejo #68 ("the moon game is a bit slow").
|
||||||
|
#
|
||||||
|
# Renders nothing but one fullscreen ColorRect carrying the given shader, so
|
||||||
|
# the frame time is essentially pure per-pixel shader cost with no gameplay,
|
||||||
|
# UI or draw calls mixed in. Run it via tests/profile_shader.sh, which
|
||||||
|
# supplies Xvfb + Mesa softpipe exactly like screenshot.sh does.
|
||||||
|
#
|
||||||
|
# Softpipe is a pure-C rasterizer, not the phone's GPU, so the ABSOLUTE
|
||||||
|
# numbers mean nothing. What it measures well is RELATIVE per-fragment ALU
|
||||||
|
# cost between two versions of the same shader: softpipe executes the
|
||||||
|
# fragment program per pixel on the CPU, so halving the arithmetic shows up
|
||||||
|
# as roughly half the frame time.
|
||||||
|
#
|
||||||
|
# TWO THINGS WILL MISLEAD YOU IF YOU READ THE RAW NUMBER.
|
||||||
|
#
|
||||||
|
# 1. There is a FLOOR. A shader that does nothing but assign a constant
|
||||||
|
# colour still costs ~657 ms/frame at 720x1280 here, which is softpipe's
|
||||||
|
# own per-pixel overhead. Subtract it before comparing, or a shader that
|
||||||
|
# got 20x cheaper will look like it only got 2x cheaper.
|
||||||
|
#
|
||||||
|
# 2. It cannot price a TEXTURE FETCH against arithmetic. Softpipe does
|
||||||
|
# filtering and mip selection in CPU code, so a fetch measures as
|
||||||
|
# expensive here; real GPUs - mobile ones especially - run it on
|
||||||
|
# dedicated hardware where it is close to free. Trading arithmetic for a
|
||||||
|
# lookup is exactly the kind of change this harness will report backwards.
|
||||||
|
# Use it to compare arithmetic against arithmetic, and nothing else.
|
||||||
|
#
|
||||||
|
# Args (after `--`): <shader_res_path> [timed_frames] [warmup_frames]
|
||||||
|
|
||||||
|
var shader_path: String
|
||||||
|
var timed_frames: int = 30
|
||||||
|
var warmup_frames: int = 10
|
||||||
|
var frames: int = 0
|
||||||
|
var rect: ColorRect
|
||||||
|
var start_usec: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
var args: PackedStringArray = OS.get_cmdline_user_args()
|
||||||
|
if args.is_empty():
|
||||||
|
printerr("usage: -- <shader_res_path> [timed_frames] [warmup_frames]")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
shader_path = args[0]
|
||||||
|
if args.size() >= 2:
|
||||||
|
timed_frames = int(args[1])
|
||||||
|
if args.size() >= 3:
|
||||||
|
warmup_frames = int(args[2])
|
||||||
|
|
||||||
|
var shader: Shader = load(shader_path)
|
||||||
|
if shader == null:
|
||||||
|
printerr("could not load shader: %s" % shader_path)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
var material := ShaderMaterial.new()
|
||||||
|
material.shader = shader
|
||||||
|
|
||||||
|
var layer := CanvasLayer.new()
|
||||||
|
layer.layer = -10
|
||||||
|
root.add_child(layer)
|
||||||
|
|
||||||
|
rect = ColorRect.new()
|
||||||
|
rect.material = material
|
||||||
|
rect.anchor_right = 1.0
|
||||||
|
rect.anchor_bottom = 1.0
|
||||||
|
rect.size = root.get_visible_rect().size
|
||||||
|
layer.add_child(rect)
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> bool:
|
||||||
|
# Move the camera every frame. A static camera would let a driver skip
|
||||||
|
# redundant uniform uploads and would only ever exercise one crater
|
||||||
|
# layout; drifting keeps the measurement honest and representative of
|
||||||
|
# actually flying around.
|
||||||
|
var t: float = float(frames) * 0.05
|
||||||
|
var p: Vector2 = Vector2(cos(t), sin(t)) * 40.0
|
||||||
|
var cell := Vector2(floor(p.x), floor(p.y))
|
||||||
|
rect.material.set_shader_parameter("camera_cell", cell)
|
||||||
|
rect.material.set_shader_parameter("camera_frac", p - cell)
|
||||||
|
rect.material.set_shader_parameter("camera_zoom", 1.0)
|
||||||
|
rect.material.set_shader_parameter("viewport_size", root.get_visible_rect().size)
|
||||||
|
|
||||||
|
frames += 1
|
||||||
|
# Shaders compile on first use and the first frames carry that cost, so
|
||||||
|
# start the clock only once the pipeline is warm.
|
||||||
|
if frames == warmup_frames:
|
||||||
|
start_usec = Time.get_ticks_usec()
|
||||||
|
return false
|
||||||
|
if frames < warmup_frames + timed_frames:
|
||||||
|
return false
|
||||||
|
|
||||||
|
var span_usec: int = Time.get_ticks_usec() - start_usec
|
||||||
|
var per_frame_ms: float = float(span_usec) / 1000.0 / float(timed_frames)
|
||||||
|
var px: Vector2 = root.get_visible_rect().size
|
||||||
|
print("SHADER_PROFILE: %s" % shader_path)
|
||||||
|
print(" viewport : %dx%d (%d px)" % [px.x, px.y, int(px.x * px.y)])
|
||||||
|
print(" frames timed : %d (after %d warmup)" % [timed_frames, warmup_frames])
|
||||||
|
print(" per frame : %.1f ms" % per_frame_ms)
|
||||||
|
print(" per megapixel : %.1f ms" % (per_frame_ms / (px.x * px.y / 1000000.0)))
|
||||||
|
quit(0)
|
||||||
|
return true
|
||||||
1
tests/_profile_surface_shader.gd.uid
Normal file
1
tests/_profile_surface_shader.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dgg7cnpiq2y58
|
||||||
1
tests/_setup_editor_load_panel_preview.gd.uid
Normal file
1
tests/_setup_editor_load_panel_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://ckltesr471lqy
|
||||||
34
tests/profile_shader.sh
Normal file
34
tests/profile_shader.sh
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
#!/data/data/com.termux/files/usr/bin/bash
|
||||||
|
# Times a fullscreen background shader's per-pixel cost. Same Xvfb + Mesa
|
||||||
|
# softpipe rig as screenshot.sh (see the long note there about llvmpipe
|
||||||
|
# crashing with SIGILL in this environment), because a fragment shader can
|
||||||
|
# only be timed if something actually rasterizes it - --headless renders
|
||||||
|
# nothing at all.
|
||||||
|
#
|
||||||
|
# Softpipe is a CPU rasterizer, so treat the numbers as RELATIVE only:
|
||||||
|
# comparing two versions of the same shader is meaningful, comparing against
|
||||||
|
# a phone's frame budget is not.
|
||||||
|
#
|
||||||
|
# Usage: bash tests/profile_shader.sh <shader_res_path> [timed_frames] [warmup] [WxH]
|
||||||
|
# Example: bash tests/profile_shader.sh res://lunar_surface.gdshader 30 10 720x1280
|
||||||
|
|
||||||
|
set -e
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64"
|
||||||
|
|
||||||
|
SHADER_PATH="${1:?usage: profile_shader.sh <shader_res_path> [timed_frames] [warmup] [WxH]}"
|
||||||
|
TIMED_FRAMES="${2:-30}"
|
||||||
|
WARMUP_FRAMES="${3:-10}"
|
||||||
|
RESOLUTION="${4:-720x1280}"
|
||||||
|
|
||||||
|
proot-distro login ubuntu -- bash -c "
|
||||||
|
pkill -x Xvfb >/dev/null 2>&1 || true
|
||||||
|
rm -f /tmp/.X99-lock /tmp/.X11-unix/X99
|
||||||
|
Xvfb :99 -screen 0 ${RESOLUTION}x24 &
|
||||||
|
XVFB_PID=\$!
|
||||||
|
trap 'kill \$XVFB_PID >/dev/null 2>&1 || true' EXIT
|
||||||
|
sleep 2
|
||||||
|
mkdir -p /tmp/xdgrt && chmod 700 /tmp/xdgrt
|
||||||
|
cd '$PROJECT_DIR'
|
||||||
|
DISPLAY=:99 XDG_RUNTIME_DIR=/tmp/xdgrt GALLIUM_DRIVER=softpipe '$GODOT_BIN' --audio-driver Dummy --rendering-driver opengl3 --rendering-method gl_compatibility --resolution '$RESOLUTION' -s res://tests/_profile_surface_shader.gd -- '$SHADER_PATH' '$TIMED_FRAMES' '$WARMUP_FRAMES'
|
||||||
|
"
|
||||||
1
tests/test_enemy_detection_range.gd.uid
Normal file
1
tests/test_enemy_detection_range.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dekjmrvv0baqy
|
||||||
196
tests/test_fleet_speed_bounded.gd
Normal file
196
tests/test_fleet_speed_bounded.gd
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Headless gameplay test: run with
|
||||||
|
# godot --headless -s res://tests/test_fleet_speed_bounded.gd
|
||||||
|
# A reported movement bug: rotating the fleet and spamming a direction let
|
||||||
|
# vessels fall out of sync and fly at wildly high speeds - "speed of an
|
||||||
|
# individual ship is not bounded".
|
||||||
|
#
|
||||||
|
# The cause was in Fleet.stop(), which compensated for Ship.velocity reading
|
||||||
|
# zero during cruise by doing `ship.velocity += ship.fleet_velocity`. That is
|
||||||
|
# an accumulator: each release added a whole cruise velocity, the next steer
|
||||||
|
# refilled fleet_velocity, and tapping faster than `acceleration` could bleed
|
||||||
|
# it off stacked them without limit. Ships desynced because each bled off at
|
||||||
|
# a rate set by its own distance to its slot.
|
||||||
|
#
|
||||||
|
# Covers:
|
||||||
|
# A. no vessel ever exceeds its own top speed, however hard the direction
|
||||||
|
# is spammed - the property that was actually violated
|
||||||
|
# B. spamming cannot outrun an honestly-held stick: the fleet covers no
|
||||||
|
# more ground than cruising for the same time would
|
||||||
|
# C. the same holds while the formation is being pivoted, which is the
|
||||||
|
# case the report named
|
||||||
|
# D. ships stay in sync with each other rather than fanning out
|
||||||
|
|
||||||
|
var game_state: Node
|
||||||
|
var main: Node
|
||||||
|
var fleet: Node
|
||||||
|
var stage: int = 0
|
||||||
|
var elapsed: float = 0.0
|
||||||
|
var fastest_seen: float = 0.0
|
||||||
|
var worst_spread: float = 0.0
|
||||||
|
var start_centroid: Vector2 = Vector2.ZERO
|
||||||
|
var spam_toggle: bool = false
|
||||||
|
var pivot_step: int = 0
|
||||||
|
var honest_distance: float = 0.0
|
||||||
|
|
||||||
|
const SPAM_TIME: float = 3.0
|
||||||
|
# Every ship in the test fleet is an interceptor, so one limit covers them
|
||||||
|
# all. Two percent of slack absorbs the single-tick overshoot inherent in
|
||||||
|
# integrating at a finite step; the bug being caught overshot by multiples,
|
||||||
|
# not by percents.
|
||||||
|
const SPEED_TOLERANCE: float = 1.02
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
game_state = root.get_node("GameState")
|
||||||
|
game_state.buildings.clear()
|
||||||
|
game_state.extractors.clear()
|
||||||
|
game_state.transporters.clear()
|
||||||
|
game_state.stored_vessels.clear()
|
||||||
|
game_state.reset_progression()
|
||||||
|
main = load("res://Main.tscn").instantiate()
|
||||||
|
root.add_child(main)
|
||||||
|
fleet = main.get_node("Fleet")
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> bool:
|
||||||
|
elapsed += delta
|
||||||
|
match stage:
|
||||||
|
0:
|
||||||
|
if elapsed < 0.2:
|
||||||
|
return false
|
||||||
|
main.get_node("EnemySpawner").set_process(false)
|
||||||
|
main.get_node("AsteroidField").set_process(false)
|
||||||
|
fleet.spawn_ships(4, Vector2.ZERO)
|
||||||
|
|
||||||
|
# --- reference: what holding the stick honestly achieves --------
|
||||||
|
start_centroid = fleet.get_centroid()
|
||||||
|
fleet.move_in_direction(Vector2(1.0, 0.0))
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 1
|
||||||
|
return false
|
||||||
|
1:
|
||||||
|
if elapsed < SPAM_TIME:
|
||||||
|
_watch_speeds()
|
||||||
|
return false
|
||||||
|
honest_distance = fleet.get_centroid().distance_to(start_centroid)
|
||||||
|
print("held steady for %.1fs: travelled %.1f, fastest vessel %.1f" \
|
||||||
|
% [SPAM_TIME, honest_distance, fastest_seen])
|
||||||
|
if not _check_speed_bound("while simply holding a direction"):
|
||||||
|
return true
|
||||||
|
fleet.stop()
|
||||||
|
fastest_seen = 0.0
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 2
|
||||||
|
return false
|
||||||
|
2:
|
||||||
|
# Let the coast-to-rest finish so the spam leg starts from the
|
||||||
|
# same standing start the honest leg did.
|
||||||
|
if elapsed < 1.0:
|
||||||
|
return false
|
||||||
|
start_centroid = fleet.get_centroid()
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 3
|
||||||
|
return false
|
||||||
|
3:
|
||||||
|
# --- A + B. tap the same direction as fast as frames allow ------
|
||||||
|
spam_toggle = not spam_toggle
|
||||||
|
if spam_toggle:
|
||||||
|
fleet.move_in_direction(Vector2(1.0, 0.0))
|
||||||
|
else:
|
||||||
|
fleet.stop()
|
||||||
|
_watch_speeds()
|
||||||
|
if elapsed < SPAM_TIME:
|
||||||
|
return false
|
||||||
|
var spam_distance: float = fleet.get_centroid().distance_to(start_centroid)
|
||||||
|
print("spammed for %.1fs: travelled %.1f, fastest vessel %.1f" \
|
||||||
|
% [SPAM_TIME, spam_distance, fastest_seen])
|
||||||
|
if not _check_speed_bound("while spamming a direction"):
|
||||||
|
return true
|
||||||
|
# Tapping releases the stick half the time, so it should cover
|
||||||
|
# LESS ground than holding it - certainly not more.
|
||||||
|
if spam_distance > honest_distance * 1.1:
|
||||||
|
return _fail_now("spamming a direction covered %.1f against %.1f for simply holding it - tapping is faster than flying" \
|
||||||
|
% [spam_distance, honest_distance])
|
||||||
|
fleet.stop()
|
||||||
|
fastest_seen = 0.0
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 4
|
||||||
|
return false
|
||||||
|
4:
|
||||||
|
if elapsed < 1.0:
|
||||||
|
return false
|
||||||
|
start_centroid = fleet.get_centroid()
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 5
|
||||||
|
return false
|
||||||
|
5:
|
||||||
|
# --- C + D. spam the direction WHILE pivoting the formation -----
|
||||||
|
# The reported case. A pivot re-seats every slot at once, so this
|
||||||
|
# is where ships are most able to fall out of step with one
|
||||||
|
# another.
|
||||||
|
spam_toggle = not spam_toggle
|
||||||
|
if spam_toggle:
|
||||||
|
fleet.move_in_direction(Vector2(1.0, 0.0))
|
||||||
|
else:
|
||||||
|
fleet.stop()
|
||||||
|
pivot_step += 1
|
||||||
|
_pivot_formation(PI / 2.0 if pivot_step % 2 == 0 else -PI / 2.0)
|
||||||
|
_watch_speeds()
|
||||||
|
if elapsed < SPAM_TIME:
|
||||||
|
return false
|
||||||
|
var pivot_distance: float = fleet.get_centroid().distance_to(start_centroid)
|
||||||
|
print("spammed with pivots for %.1fs: travelled %.1f, fastest vessel %.1f, worst pair spread %.1f" \
|
||||||
|
% [SPAM_TIME, pivot_distance, fastest_seen, worst_spread])
|
||||||
|
if not _check_speed_bound("while spamming a direction and pivoting"):
|
||||||
|
return true
|
||||||
|
if pivot_distance > honest_distance * 1.1:
|
||||||
|
return _fail_now("spamming with pivots covered %.1f against %.1f for simply holding a direction" \
|
||||||
|
% [pivot_distance, honest_distance])
|
||||||
|
# Ships must remain a fleet. The formation is a wedge a couple of
|
||||||
|
# hundred units across; a desync flings one vessel far beyond it.
|
||||||
|
if worst_spread > 900.0:
|
||||||
|
return _fail_now("vessels fell %.1f apart - the fleet came apart rather than moving together" % worst_spread)
|
||||||
|
print("no vessel outran its own top speed, and none broke formation")
|
||||||
|
|
||||||
|
print("TEST_FLEET_SPEED_BOUNDED: PASS")
|
||||||
|
quit(0)
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _watch_speeds() -> void:
|
||||||
|
var boost: float = game_state.fleet_speed_multiplier
|
||||||
|
for ship in fleet.ships:
|
||||||
|
if not is_instance_valid(ship):
|
||||||
|
continue
|
||||||
|
fastest_seen = maxf(fastest_seen, ship.velocity.length() / (ship.max_speed * boost))
|
||||||
|
for other in fleet.ships:
|
||||||
|
if is_instance_valid(other):
|
||||||
|
worst_spread = maxf(worst_spread, ship.position.distance_to(other.position))
|
||||||
|
|
||||||
|
|
||||||
|
# fastest_seen is carried as a RATIO of each vessel's own limit, so a mixed
|
||||||
|
# fleet with different top speeds would be judged per vessel rather than
|
||||||
|
# against a single global number.
|
||||||
|
func _check_speed_bound(context: String) -> bool:
|
||||||
|
if fastest_seen > SPEED_TOLERANCE:
|
||||||
|
_fail_now("a vessel reached %.2fx its own top speed %s - individual speed is not bounded" \
|
||||||
|
% [fastest_seen, context])
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _pivot_formation(angle: float) -> void:
|
||||||
|
var rotated: Array = []
|
||||||
|
for offset in game_state.formation:
|
||||||
|
rotated.append(Vector2(offset).rotated(angle))
|
||||||
|
game_state.formation = rotated
|
||||||
|
fleet.apply_formation()
|
||||||
|
|
||||||
|
|
||||||
|
func _fail_now(message: String) -> bool:
|
||||||
|
print("TEST_FLEET_SPEED_BOUNDED: FAIL (%s)" % message)
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
1
tests/test_fleet_speed_bounded.gd.uid
Normal file
1
tests/test_fleet_speed_bounded.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bkcs1gl5h616f
|
||||||
321
tests/test_lunar_field.gd
Normal file
321
tests/test_lunar_field.gd
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Headless test: run with
|
||||||
|
# godot --headless -s res://tests/test_lunar_field.gd
|
||||||
|
# Forgejo #68: the lunar backdrop shader was the most expensive thing in the
|
||||||
|
# surface scene, running a 5x5 (25-sample) neighbour scan on every pixel of a
|
||||||
|
# fullscreen rect. It is now 3x3 (9 samples).
|
||||||
|
#
|
||||||
|
# A fragment shader cannot be asserted on directly from GDScript, so this
|
||||||
|
# test guards the REASONING that made the reduction safe. The 5x5 width was
|
||||||
|
# insurance against craters observed clipping at cell boundaries on-device -
|
||||||
|
# i.e. against a crater reaching further than its nominal radius allowed.
|
||||||
|
# The fix was to bound that reach at the source (clamp the centre into its
|
||||||
|
# own cell, clamp the radius), which makes a 3x3 scan provably sufficient.
|
||||||
|
# If someone later loosens either clamp or raises MAX_RADIUS past a cell, the
|
||||||
|
# clipping bug comes straight back - and looks like a GPU mystery rather than
|
||||||
|
# an edit. This test makes that edit fail loudly instead.
|
||||||
|
#
|
||||||
|
# Covers:
|
||||||
|
# A. the shader source still carries every premise the bound rests on
|
||||||
|
# (3x3 loop, clamped centre, clamped radius, MAX_RADIUS below a cell)
|
||||||
|
# B. worst case geometry: even a crater pinned at the far corner of a
|
||||||
|
# ring-2 cell at maximum radius cannot reach a threshold that changes a
|
||||||
|
# pixel. Independent of what the hash returns, so it holds on any driver
|
||||||
|
# C. differential check against the real hash: over thousands of sampled
|
||||||
|
# points, a 3x3 scan and a 5x5 scan produce the SAME rendered output
|
||||||
|
# D. the miss sentinel stays inside mediump range when squared, so it
|
||||||
|
# cannot become +Inf (and then NaN) on a mobile GPU
|
||||||
|
|
||||||
|
const SHADER_PATH := "res://lunar_surface.gdshader"
|
||||||
|
const TILED_SHADER_PATH := "res://lunar_tiled.gdshader"
|
||||||
|
const BACKGROUND_SCRIPT_PATH := "res://LunarBackground.gd"
|
||||||
|
# Largest normalized distance any shading term below reacts to. Past this,
|
||||||
|
# every smoothstep has saturated and the pixel is plain regolith.
|
||||||
|
const MAX_SIGNIFICANT_DISTANCE := 1.15
|
||||||
|
const MEDIUMP_MAX := 65504.0
|
||||||
|
const SAMPLE_COUNT := 4000
|
||||||
|
|
||||||
|
var source: String = ""
|
||||||
|
var min_radius: float = 0.0
|
||||||
|
var max_radius: float = 0.0
|
||||||
|
var miss_distance_sq: float = 0.0
|
||||||
|
var start_distance_sq: float = 0.0
|
||||||
|
var tile_cells: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
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_radius = _read_const("MIN_RADIUS")
|
||||||
|
max_radius = _read_const("MAX_RADIUS")
|
||||||
|
miss_distance_sq = _read_const("MISS_DISTANCE_SQ")
|
||||||
|
start_distance_sq = _read_const("START_DISTANCE_SQ")
|
||||||
|
if is_nan(min_radius) or is_nan(max_radius) or is_nan(miss_distance_sq) or is_nan(start_distance_sq):
|
||||||
|
_fail("could not parse the shader's radius/sentinel constants")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not _check_premises():
|
||||||
|
return
|
||||||
|
if not _check_tiling_setup():
|
||||||
|
return
|
||||||
|
if not _check_worst_case_geometry():
|
||||||
|
return
|
||||||
|
if not _check_sentinels():
|
||||||
|
return
|
||||||
|
if not _check_scans_agree():
|
||||||
|
return
|
||||||
|
if not _check_field_is_periodic():
|
||||||
|
return
|
||||||
|
|
||||||
|
print("TEST_LUNAR_FIELD: PASS")
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
|
||||||
|
# --- A. the premises are still in the source ---------------------------------
|
||||||
|
func _check_premises() -> bool:
|
||||||
|
# The scan itself. Both loops must be the 3x3 form; a stray -2 anywhere in
|
||||||
|
# the bounds means someone widened it back without updating this test.
|
||||||
|
if not ("for (int dx = -1; dx <= 1; dx++)" in source and "for (int dy = -1; dy <= 1; dy++)" in source):
|
||||||
|
return _fail("the neighbour scan is no longer the 3x3 loop this test reasons about")
|
||||||
|
# The centre clamp. Without it a hash outside [0,1] places a crater centre
|
||||||
|
# outside its own cell and the ring-2 bound below stops holding.
|
||||||
|
if not ("clamp(h.xy, 0.0, 1.0)" in source):
|
||||||
|
return _fail("the crater centre is no longer clamped into its own cell")
|
||||||
|
# The radius clamp. Without it mix() extrapolates into an arbitrarily
|
||||||
|
# large radius, which is the specific failure that caused the original
|
||||||
|
# on-device crater clipping.
|
||||||
|
if not ("clamp(mix(MIN_RADIUS, MAX_RADIUS," in source and "), MIN_RADIUS, MAX_RADIUS)" in source):
|
||||||
|
return _fail("the crater radius is no longer clamped to [MIN_RADIUS, MAX_RADIUS]")
|
||||||
|
# MAX_SIGNIFICANT_DISTANCE below is the furthest normalized distance any
|
||||||
|
# shading term still reacts to, and the overlap suppression is the term
|
||||||
|
# that reaches furthest. Pin it here: raising that upper bound past
|
||||||
|
# MAX_SIGNIFICANT_DISTANCE would make ring-2 craters matter again, and
|
||||||
|
# the geometry check would go on passing against a stale number.
|
||||||
|
if not ("smoothstep(0.85, %.2f, second_nearest)" % MAX_SIGNIFICANT_DISTANCE in source):
|
||||||
|
return _fail("the overlap suppression no longer saturates at %.2f, so the significant range this test assumes is stale" \
|
||||||
|
% MAX_SIGNIFICANT_DISTANCE)
|
||||||
|
print("shader still clamps centre and radius, still scans 3x3, still saturates by %.2f" % MAX_SIGNIFICANT_DISTANCE)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
# --- E. the tiling rig is wired to one shared period -------------------------
|
||||||
|
# The tile period appears in three places - the field shader that bakes it,
|
||||||
|
# the tiled shader that scrolls it, and the GDScript that sizes the bake and
|
||||||
|
# wraps the camera. If they ever disagree the seam stops being seamless, and
|
||||||
|
# it fails as a faint repeating discontinuity rather than as an error.
|
||||||
|
func _check_tiling_setup() -> bool:
|
||||||
|
if not ("mod(cell + offset, tile_cells)" in source):
|
||||||
|
return _fail("the field shader no longer wraps the cell index, so the baked tile would not be seamless")
|
||||||
|
|
||||||
|
var script_file: FileAccess = FileAccess.open(BACKGROUND_SCRIPT_PATH, FileAccess.READ)
|
||||||
|
if script_file == null:
|
||||||
|
return _fail("could not read %s" % BACKGROUND_SCRIPT_PATH)
|
||||||
|
var script_source: String = script_file.get_as_text()
|
||||||
|
script_file.close()
|
||||||
|
|
||||||
|
var regex := RegEx.new()
|
||||||
|
regex.compile("const\\s+TILE_CELLS\\s*:\\s*float\\s*=\\s*([0-9.]+)")
|
||||||
|
var found: RegExMatch = regex.search(script_source)
|
||||||
|
if found == null:
|
||||||
|
return _fail("LunarBackground.gd no longer declares TILE_CELLS, the shared tile period")
|
||||||
|
tile_cells = float(found.get_string(1))
|
||||||
|
if tile_cells < 4.0:
|
||||||
|
return _fail("a tile period of %.0f cells is too short to read as anything but a repeat" % tile_cells)
|
||||||
|
|
||||||
|
# The camera index must be wrapped on the GDScript side, in double
|
||||||
|
# precision, before it reaches the shader - the whole point of the
|
||||||
|
# existing cell/fraction split is that the raw index is too large for a
|
||||||
|
# mobile float, and the tiled shader adds it straight into a UV.
|
||||||
|
if not ("fposmod(cell.x, TILE_CELLS)" in script_source):
|
||||||
|
return _fail("LunarBackground.gd no longer wraps the camera cell into the tile before handing it to the shader")
|
||||||
|
|
||||||
|
var tiled_file: FileAccess = FileAccess.open(TILED_SHADER_PATH, FileAccess.READ)
|
||||||
|
if tiled_file == null:
|
||||||
|
return _fail("could not read %s" % TILED_SHADER_PATH)
|
||||||
|
var tiled_source: String = tiled_file.get_as_text()
|
||||||
|
tiled_file.close()
|
||||||
|
|
||||||
|
# repeat_enable is the mechanism that makes the canvas infinite;
|
||||||
|
# mipmaps are what stop the crater field shimmering when zoomed out.
|
||||||
|
if not ("repeat_enable" in tiled_source):
|
||||||
|
return _fail("the tiled shader no longer samples with repeat_enable, so the canvas stops repeating")
|
||||||
|
if not ("filter_linear_mipmap" in tiled_source):
|
||||||
|
return _fail("the tiled shader no longer uses mipmaps, so zooming out will alias the crater field")
|
||||||
|
print("tile period %.0f cells, wrapped in GDScript, sampled with repeat + mipmaps" % tile_cells)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
# --- F. the field really is seamless -----------------------------------------
|
||||||
|
# Periodicity is asserted directly rather than inferred from the wrap being
|
||||||
|
# present: the same point one whole tile away must render identically. A seam
|
||||||
|
# is exactly the case where it does not.
|
||||||
|
func _check_field_is_periodic() -> bool:
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = 6800
|
||||||
|
var shifts: Array = [
|
||||||
|
Vector2(tile_cells, 0.0),
|
||||||
|
Vector2(0.0, tile_cells),
|
||||||
|
Vector2(tile_cells, tile_cells),
|
||||||
|
Vector2(-tile_cells * 3.0, tile_cells * 2.0),
|
||||||
|
]
|
||||||
|
for i in range(SAMPLE_COUNT):
|
||||||
|
var cell := Vector2(rng.randi_range(-4000, 4000), rng.randi_range(-4000, 4000))
|
||||||
|
var local := Vector2(rng.randf(), rng.randf())
|
||||||
|
var here := _shade(cell, local, 1)
|
||||||
|
for shift in shifts:
|
||||||
|
var there := _shade(cell + shift, local, 1)
|
||||||
|
for k in range(here.size()):
|
||||||
|
if absf(here[k] - there[k]) > 0.0001:
|
||||||
|
return _fail("the field is not periodic: cell %s and cell %s render differently (term %d)" \
|
||||||
|
% [cell, cell + shift, k])
|
||||||
|
print("%d sampled points: the field repeats exactly every %.0f cells in both axes" % [SAMPLE_COUNT, tile_cells])
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
# --- B. worst case geometry, independent of the hash -------------------------
|
||||||
|
func _check_worst_case_geometry() -> bool:
|
||||||
|
if max_radius >= 1.0:
|
||||||
|
return _fail("MAX_RADIUS is %.3f - a crater at least a full cell wide can reach past a 3x3 scan" % max_radius)
|
||||||
|
if min_radius <= 0.0 or min_radius > max_radius:
|
||||||
|
return _fail("MIN_RADIUS %.3f is not a sane lower bound below MAX_RADIUS %.3f" % [min_radius, max_radius])
|
||||||
|
|
||||||
|
# Closest approach between the sampled pixel and ANY crater centre two
|
||||||
|
# cells out. The pixel's local coordinate spans [0,1]; a ring-2 cell spans
|
||||||
|
# [2,3] (or [-2,-1]), and the centre clamp keeps the centre inside it. So
|
||||||
|
# the gap is at least one whole cell however the hash lands.
|
||||||
|
var closest_ring_two_distance: float = 1.0
|
||||||
|
var closest_normalized: float = closest_ring_two_distance / max_radius
|
||||||
|
if closest_normalized <= MAX_SIGNIFICANT_DISTANCE:
|
||||||
|
return _fail("a crater two cells out reaches a normalized distance of %.2f, inside the significant range (%.2f) - a 3x3 scan would clip it" \
|
||||||
|
% [closest_normalized, MAX_SIGNIFICANT_DISTANCE])
|
||||||
|
print("worst case ring-2 crater lands at %.2f, clear of the %.2f threshold (%.1fx margin)" \
|
||||||
|
% [closest_normalized, MAX_SIGNIFICANT_DISTANCE, closest_normalized / MAX_SIGNIFICANT_DISTANCE])
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
# --- D. the sentinels cannot overflow a mobile float -------------------------
|
||||||
|
func _check_sentinels() -> bool:
|
||||||
|
# The largest value the loop can actually carry: a real squared distance
|
||||||
|
# plus the miss sentinel added on top of it.
|
||||||
|
var largest_real_sq: float = pow(sqrt(8.0) / min_radius, 2.0)
|
||||||
|
var largest_carried: float = miss_distance_sq + largest_real_sq
|
||||||
|
if largest_carried >= MEDIUMP_MAX:
|
||||||
|
return _fail("the loop can carry %.0f, past the mediump ceiling %.0f - it becomes +Inf and then NaN on a mobile GPU" \
|
||||||
|
% [largest_carried, MEDIUMP_MAX])
|
||||||
|
if miss_distance_sq <= largest_real_sq:
|
||||||
|
return _fail("MISS_DISTANCE_SQ %.0f does not beat the largest real squared distance %.0f, so an empty cell could win the scan" \
|
||||||
|
% [miss_distance_sq, largest_real_sq])
|
||||||
|
if start_distance_sq <= largest_carried:
|
||||||
|
return _fail("START_DISTANCE_SQ %.0f is not above every value the loop can produce (%.0f), so the winning offset can stay unset" \
|
||||||
|
% [start_distance_sq, largest_carried])
|
||||||
|
print("sentinels ordered correctly and %.0f stays inside the mediump ceiling %.0f" % [largest_carried, MEDIUMP_MAX])
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
# --- C. 3x3 and 5x5 render the same thing ------------------------------------
|
||||||
|
func _check_scans_agree() -> bool:
|
||||||
|
# The hash is ported from the shader below. It will not be bit-identical
|
||||||
|
# to any given GPU's evaluation, and does not need to be: what is being
|
||||||
|
# checked is that dropping the outer ring changes nothing, which follows
|
||||||
|
# from the clamped bounds rather than from the hash's exact values. The
|
||||||
|
# port's job is to catch a mistake in that reasoning on realistic inputs,
|
||||||
|
# not to emulate a driver.
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = 68
|
||||||
|
var worst_delta: float = 0.0
|
||||||
|
for i in range(SAMPLE_COUNT):
|
||||||
|
# Cell indices deliberately reach into the thousands - the same
|
||||||
|
# magnitudes the camera actually produces after flying a while.
|
||||||
|
var cell := Vector2(rng.randi_range(-4000, 4000), rng.randi_range(-4000, 4000))
|
||||||
|
var local := Vector2(rng.randf(), rng.randf())
|
||||||
|
var near := _shade(cell, local, 1)
|
||||||
|
var far := _shade(cell, local, 2)
|
||||||
|
for k in range(near.size()):
|
||||||
|
var delta: float = absf(near[k] - far[k])
|
||||||
|
worst_delta = maxf(worst_delta, delta)
|
||||||
|
if delta > 0.0001:
|
||||||
|
return _fail("3x3 and 5x5 scans disagree at cell %s local %s: term %d differs by %f" \
|
||||||
|
% [cell, local, k, delta])
|
||||||
|
print("%d sampled points: 3x3 and 5x5 scans agree exactly (worst term delta %.8f)" % [SAMPLE_COUNT, worst_delta])
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
# The three values the shader's colour is actually a function of. Comparing
|
||||||
|
# these rather than raw `nearest` is deliberate: a 3x3 scan can legitimately
|
||||||
|
# report a different nearest distance than a 5x5 one (it may find no crater
|
||||||
|
# where 5x5 found a far one), and what matters is only that the PIXEL comes
|
||||||
|
# out the same colour.
|
||||||
|
func _shade(cell: Vector2, local: Vector2, ring: int) -> Array:
|
||||||
|
var nearest_sq: float = start_distance_sq
|
||||||
|
var second_sq: float = start_distance_sq
|
||||||
|
var nearest_offset := Vector2(0.0, 1.0)
|
||||||
|
|
||||||
|
for dx in range(-ring, ring + 1):
|
||||||
|
for dy in range(-ring, ring + 1):
|
||||||
|
var offset := Vector2(dx, dy)
|
||||||
|
var neighbor: Vector2 = cell + offset
|
||||||
|
# Same wrap the shader does, and the reason the field tiles.
|
||||||
|
neighbor = Vector2(fposmod(neighbor.x, tile_cells), fposmod(neighbor.y, tile_cells))
|
||||||
|
var h: Vector4 = _hash42(neighbor)
|
||||||
|
var has_crater: float = 1.0 if h.z >= 0.35 else 0.0
|
||||||
|
var centre: Vector2 = offset + Vector2(clampf(h.x, 0.0, 1.0), clampf(h.y, 0.0, 1.0))
|
||||||
|
var radius: float = clampf(lerpf(min_radius, max_radius, h.w), min_radius, max_radius)
|
||||||
|
var to_sample: Vector2 = local - centre
|
||||||
|
var normalized_sq: float = to_sample.length_squared() / (radius * radius) \
|
||||||
|
+ (1.0 - has_crater) * miss_distance_sq
|
||||||
|
if normalized_sq < nearest_sq:
|
||||||
|
second_sq = nearest_sq
|
||||||
|
nearest_sq = normalized_sq
|
||||||
|
nearest_offset = to_sample
|
||||||
|
elif normalized_sq < second_sq:
|
||||||
|
second_sq = normalized_sq
|
||||||
|
|
||||||
|
var nearest: float = sqrt(nearest_sq)
|
||||||
|
var second: float = sqrt(second_sq)
|
||||||
|
var offset_length: float = nearest_offset.length()
|
||||||
|
var nearest_dir: Vector2 = nearest_offset / offset_length if offset_length > 0.0001 else Vector2(0.0, 1.0)
|
||||||
|
var facing: float = nearest_dir.dot(Vector2(-0.6, -0.8).normalized())
|
||||||
|
|
||||||
|
var floor_amount: float = 1.0 - smoothstep(0.45, 0.9, nearest)
|
||||||
|
var rim_band: float = smoothstep(0.55, 0.85, nearest) * (1.0 - smoothstep(0.85, 1.0, nearest))
|
||||||
|
rim_band *= smoothstep(0.85, 1.15, second)
|
||||||
|
# facing only reaches the output multiplied by rim_band, so folding it in
|
||||||
|
# here keeps an irrelevant direction difference deep inside a crater floor
|
||||||
|
# from reading as a visual difference.
|
||||||
|
return [floor_amount, rim_band * clampf(facing, 0.0, 1.0), rim_band * clampf(-facing, 0.0, 1.0)]
|
||||||
|
|
||||||
|
|
||||||
|
func _hash42(p: Vector2) -> Vector4:
|
||||||
|
var p4 := _fract4(Vector4(p.x, p.y, p.x, p.y) * Vector4(0.1031, 0.1030, 0.0973, 0.1099))
|
||||||
|
var d: float = p4.dot(Vector4(p4.w, p4.z, p4.x, p4.y) + Vector4(33.33, 33.33, 33.33, 33.33))
|
||||||
|
p4 += Vector4(d, d, d, d)
|
||||||
|
# fract((p4.xxyz + p4.yzzw) * p4.zywx)
|
||||||
|
var a := Vector4(p4.x, p4.x, p4.y, p4.z)
|
||||||
|
var b := Vector4(p4.y, p4.z, p4.z, p4.w)
|
||||||
|
var c := Vector4(p4.z, p4.y, p4.w, p4.x)
|
||||||
|
return _fract4((a + b) * c)
|
||||||
|
|
||||||
|
|
||||||
|
func _fract4(v: Vector4) -> Vector4:
|
||||||
|
return Vector4(v.x - floor(v.x), v.y - floor(v.y), v.z - floor(v.z), v.w - floor(v.w))
|
||||||
|
|
||||||
|
|
||||||
|
func _read_const(name: String) -> float:
|
||||||
|
var regex := RegEx.new()
|
||||||
|
regex.compile("const\\s+float\\s+" + name + "\\s*=\\s*([0-9.eE+-]+)")
|
||||||
|
var found: RegExMatch = regex.search(source)
|
||||||
|
if found == null:
|
||||||
|
return NAN
|
||||||
|
return float(found.get_string(1))
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(message: String) -> bool:
|
||||||
|
print("TEST_LUNAR_FIELD: FAIL (%s)" % message)
|
||||||
|
quit(1)
|
||||||
|
return false
|
||||||
1
tests/test_lunar_field.gd.uid
Normal file
1
tests/test_lunar_field.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b0bjyh6fn2v34
|
||||||
1
tests/test_research_lab_extractor_gate.gd.uid
Normal file
1
tests/test_research_lab_extractor_gate.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://1ynm1v0c1qfk
|
||||||
|
|
@ -58,6 +58,17 @@ func _process(delta: float) -> bool:
|
||||||
return false
|
return false
|
||||||
main.get_node("EnemySpawner").set_process(false)
|
main.get_node("EnemySpawner").set_process(false)
|
||||||
main.get_node("AsteroidField").set_process(false)
|
main.get_node("AsteroidField").set_process(false)
|
||||||
|
# Silencing the spawners is not enough: whatever Main.tscn already
|
||||||
|
# placed keeps moving under its own _physics_process, and this test
|
||||||
|
# flies the fleet several thousand units at up to 3x boost. Losing
|
||||||
|
# the distorder ship to a rock mid-run resets the multiplier -
|
||||||
|
# CORRECTLY, that is what _sync_composition_state is for - and the
|
||||||
|
# test then failed three stages later claiming the multiplier had
|
||||||
|
# not survived a scene swap. Same clean-slate idiom as
|
||||||
|
# test_ship_lab.gd.
|
||||||
|
for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"):
|
||||||
|
if is_instance_valid(hazard):
|
||||||
|
hazard.queue_free()
|
||||||
|
|
||||||
# --- A. absent, then appears -------------------------------------
|
# --- A. absent, then appears -------------------------------------
|
||||||
if fader.visible:
|
if fader.visible:
|
||||||
|
|
@ -192,6 +203,13 @@ func _process(delta: float) -> bool:
|
||||||
# scenario here since this fleet keeps its distorder ship the
|
# scenario here since this fleet keeps its distorder ship the
|
||||||
# whole way across the swap, via GameState.surviving_vessel_types
|
# whole way across the swap, via GameState.surviving_vessel_types
|
||||||
# exactly like every other vessel type).
|
# exactly like every other vessel type).
|
||||||
|
# Check the premise before relying on it. Everything below is about
|
||||||
|
# the multiplier surviving a scene swap, which is only meaningful
|
||||||
|
# while the fleet still crews a distorder vessel - so if the fleet
|
||||||
|
# lost one on the way here, say THAT rather than reporting a reset
|
||||||
|
# multiplier as if the swap were at fault.
|
||||||
|
if not fleet.has_capability("distorder"):
|
||||||
|
return _fail_now("the fleet lost its distorder vessel before the scene swap, so the multiplier reset is expected rather than a bug")
|
||||||
game_state.surviving_ship_count = fleet.ships.size()
|
game_state.surviving_ship_count = fleet.ships.size()
|
||||||
game_state.surviving_vessel_types = fleet.vessel_types()
|
game_state.surviving_vessel_types = fleet.vessel_types()
|
||||||
game_state.planet_id = "test_space_time_distorder"
|
game_state.planet_id = "test_space_time_distorder"
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ extends SceneTree
|
||||||
# rather than checking on the same one.
|
# rather than checking on the same one.
|
||||||
|
|
||||||
var game_state: Node
|
var game_state: Node
|
||||||
var lunar_shader: Shader
|
var scene_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
|
||||||
|
|
@ -29,7 +29,17 @@ var elapsed: float = 0.0
|
||||||
|
|
||||||
func _initialize() -> void:
|
func _initialize() -> void:
|
||||||
game_state = root.get_node("GameState")
|
game_state = root.get_node("GameState")
|
||||||
lunar_shader = load("res://lunar_surface.gdshader")
|
# Whatever PlanetSurface.tscn itself ships with, read from the scene
|
||||||
|
# rather than named by path. The property under test is "a null
|
||||||
|
# background_shader leaves the scene's own background alone" - naming a
|
||||||
|
# specific file here instead made this fail the moment the lunar
|
||||||
|
# background changed implementation (#68 swapped the per-pixel
|
||||||
|
# procedural shader for a baked, tiled one), which is a change this test
|
||||||
|
# has no business having an opinion about.
|
||||||
|
var packed: PackedScene = load("res://PlanetSurface.tscn")
|
||||||
|
var probe: Node = packed.instantiate()
|
||||||
|
scene_shader = probe.get_node("BackgroundLayer/LunarBackground").material.shader
|
||||||
|
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
|
||||||
|
|
@ -47,8 +57,8 @@ func _initialize() -> void:
|
||||||
quit(1)
|
quit(1)
|
||||||
return
|
return
|
||||||
gas_shader = gas_profile.background_shader
|
gas_shader = gas_profile.background_shader
|
||||||
if gas_shader == lunar_shader:
|
if gas_shader == scene_shader:
|
||||||
print("TEST_SURFACE_PROFILES: FAIL (gas profile points at the lunar shader, not its own)")
|
print("TEST_SURFACE_PROFILES: FAIL (gas profile points at the scene's own shader, not its own)")
|
||||||
quit(1)
|
quit(1)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -79,7 +89,7 @@ func _process(delta: float) -> bool:
|
||||||
var gas_bg: ShaderMaterial = scene.get_node("BackgroundLayer/LunarBackground").material
|
var gas_bg: ShaderMaterial = scene.get_node("BackgroundLayer/LunarBackground").material
|
||||||
scene.free()
|
scene.free()
|
||||||
|
|
||||||
if rocky_shader_after_compose != lunar_shader:
|
if rocky_shader_after_compose != scene_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 changed the background shader)")
|
||||||
quit(1)
|
quit(1)
|
||||||
return true
|
return true
|
||||||
|
|
@ -87,8 +97,8 @@ func _process(delta: float) -> bool:
|
||||||
print("TEST_SURFACE_PROFILES: FAIL (landing on a gas giant did not swap in gas_surface.gdshader)")
|
print("TEST_SURFACE_PROFILES: FAIL (landing on a gas giant did not swap in gas_surface.gdshader)")
|
||||||
quit(1)
|
quit(1)
|
||||||
return true
|
return true
|
||||||
if gas_bg.shader == lunar_shader:
|
if gas_bg.shader == scene_shader:
|
||||||
print("TEST_SURFACE_PROFILES: FAIL (gas surface still uses the lunar shader)")
|
print("TEST_SURFACE_PROFILES: FAIL (gas surface still uses the scene's own lunar background)")
|
||||||
quit(1)
|
quit(1)
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue