diff --git a/GasSurfaceProfile.tres b/GasSurfaceProfile.tres index 0267c48..73f6bcf 100644 --- a/GasSurfaceProfile.tres +++ b/GasSurfaceProfile.tres @@ -1,14 +1,16 @@ -[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=3 format=3] +[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=4 format=3] [ext_resource type="Script" path="res://PlanetSurfaceProfile.gd" id="1"] [ext_resource type="PackedScene" path="res://GasSurfaceFeatures.tscn" id="2"] +[ext_resource type="Shader" path="res://gas_surface.gdshader" id="3"] [resource] script = ExtResource("1") profile_id = "gas" features_scene = ExtResource("2") -base_color = Color(0.13, 0.025, 0.22, 1) -crater_color = Color(0.055, 0.008, 0.11, 1) -highlight_color = Color(0.52, 0.17, 0.7, 1) +background_shader = ExtResource("3") +base_color = Color(0.1, 0.04, 0.22, 1) +crater_color = Color(0.045, 0.015, 0.13, 1) +highlight_color = Color(0.62, 0.42, 0.85, 1) caption_color = Color(0.78, 0.88, 0.94, 1) caption_outline_color = Color(0.02, 0.01, 0.05, 0.9) diff --git a/PlanetSurfaceController.gd b/PlanetSurfaceController.gd index c984c65..1a74b2c 100644 --- a/PlanetSurfaceController.gd +++ b/PlanetSurfaceController.gd @@ -41,6 +41,8 @@ func _ready() -> void: func _compose_surface(profile: Resource) -> void: var atmosphere: ShaderMaterial = background.material + if profile.background_shader != null: + atmosphere.shader = profile.background_shader atmosphere.set_shader_parameter("base_color", profile.base_color) atmosphere.set_shader_parameter("crater_color", profile.crater_color) atmosphere.set_shader_parameter("highlight_color", profile.highlight_color) diff --git a/PlanetSurfaceProfile.gd b/PlanetSurfaceProfile.gd index d2ea9be..7f132ba 100644 --- a/PlanetSurfaceProfile.gd +++ b/PlanetSurfaceProfile.gd @@ -6,6 +6,8 @@ extends Resource @export var profile_id: String = "rocky" @export var features_scene: PackedScene +# null means "keep the scene's existing background shader" (lunar craters). +@export var background_shader: Shader = null @export var base_color: Color = Color(0.75, 0.75, 0.76, 1.0) @export var crater_color: Color = Color(0.44, 0.44, 0.45, 1.0) @export var highlight_color: Color = Color(0.89, 0.89, 0.9, 1.0) diff --git a/gas_surface.gdshader b/gas_surface.gdshader new file mode 100644 index 0000000..4963c04 --- /dev/null +++ b/gas_surface.gdshader @@ -0,0 +1,141 @@ +shader_type canvas_item; + +// Gas giant cloud-band backdrop for a landed gas surface. Same uniform +// interface as lunar_surface.gdshader (camera_cell/camera_frac/camera_zoom/ +// viewport_size/cell_size/base_color/crater_color/highlight_color), so +// LunarBackground.gd feeds this shader with no changes at all - only +// PlanetSurfaceController swaps which shader the material uses. +// +// Renders horizontal cloud bands instead of craters: each grid row (a +// horizontal strip one cell tall) hashes its own band centre and thickness, +// nearest-row-wins picks which band a pixel belongs to, and the resulting +// distance field drives colour exactly the way the crater distance field +// does in lunar_surface.gdshader - base_color is the deep atmosphere, +// crater_color the shadowed band body, highlight_color the lit band crest. +// A low-frequency turbulence value perturbs each row's edge so the bands +// ripple instead of reading as ruled lines, and slides sideways over time +// for the lateral drift. +// +// 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 +// an exact integer cell/row index (cell, camera_cell + cell_rel) - the same +// split lunar_surface.gdshader and Starfield.gd use, for the same reason: +// world coordinates reach the tens of thousands, where a mobile float loses +// the fractional precision this shader depends on and no `highp` fixes it. +// The one exception is TIME, which the drift is built from directly per the +// ticket - see combine_lattice() for why that's still safe to mix in. + +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 vec4 base_color : source_color = vec4(0.13, 0.025, 0.22, 1.0); +uniform vec4 crater_color : source_color = vec4(0.055, 0.008, 0.11, 1.0); +uniform vec4 highlight_color : source_color = vec4(0.52, 0.17, 0.7, 1.0); + +// Row scan range. 4 rows x a handful of hashes each is far cheaper than +// lunar's 5x5 (25-sample) crater scan - this shader has to stay renderable +// through the softpipe software rasterizer the screenshot harness uses. +const int ROW_SCAN = 2; // rows -1..+2 +const float MIN_THICKNESS = 0.25; +const float MAX_THICKNESS = 0.55; +const float TURBULENCE_STRENGTH = 0.22; +const float DRIFT_SPEED = 0.045; // cells per second, lateral + +// Same hash21 formulation as lunar_surface.gdshader (Dave Hoskins, all +// multipliers < 1 so it stays accurate at any cell-index magnitude). +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); +} + +// Splits a float into an exact integer part and a [0,1) remainder - the +// same floor/subtract trick `rel` -> `cell_rel` + `local` already uses +// below, generalized so it can also fold TIME-based drift into a lattice +// coordinate without ever forming one large continuous float. +vec2 int_frac(float v) { + float i = floor(v); + return vec2(i, v - i); +} + +// Combines two already-safe (integer, fraction) pairs - e.g. a cell index +// plus a drift offset - by adding the integers (exact, since both are +// integers) and the fractions (both in [0,1), so the sum is in [0,2)), +// then re-normalizing any carry out of the fraction back into the integer +// part. Never adds an integer directly to a fraction. +vec2 combine_lattice(vec2 base, vec2 add) { + float f = base.y + add.y; + float carry = floor(f); + return vec2(base.x + add.x + carry, f - carry); +} + +// 1D value noise over an integer lattice, given as a pre-split (i, f) pair: +// hashes the two neighboring integers and blends by the fraction. `axis` +// varies the hash sequence so different callers (different rows, or the +// turbulence octaves) don't sample the same noise. +float value_noise(vec2 coord, float axis) { + float a = hash21(vec2(coord.x, axis)); + float b = hash21(vec2(coord.x + 1.0, axis)); + return mix(a, b, smoothstep(0.0, 1.0, coord.y)); +} + +void fragment() { + vec2 screen_px = UV * viewport_size; + vec2 centered = screen_px - viewport_size * 0.5; + + // Identical split to lunar_surface.gdshader: `rel` is always small and + // camera-relative, `cell` is an exact integer built from two integers + // (camera_cell + cell_rel), and `local` is the bounded [0,1) remainder. + vec2 rel = camera_frac + centered * (camera_zoom / cell_size); + vec2 cell_rel = floor(rel); + vec2 local = rel - cell_rel; + vec2 cell = camera_cell + cell_rel; + + // Lateral drift, folded into the X lattice coordinate the safe way: + // TIME is unbounded but session-scale (the ticket calls for using it + // directly, no extra uniform), so it gets its own int/frac split and is + // combined with the cell's via combine_lattice() rather than added to + // cell.x as one continuous float. + vec2 drift = int_frac(TIME * DRIFT_SPEED); + vec2 turb_x = combine_lattice(vec2(cell.x, local.x), drift); + + // Nearest-row-wins band field, the same single-scalar discipline as + // lunar_surface.gdshader's nearest-crater distance: every visual below + // is a pure function of `nearest`, so the band body and its crest can + // never describe two different bands. + float nearest = 1000.0; + for (int dy = -1; dy <= ROW_SCAN; dy++) { + float row = cell.y + float(dy); + float band_centre = hash21(vec2(row, 5.0)); + float thickness = mix(MIN_THICKNESS, MAX_THICKNESS, hash21(vec2(row, 8.0))); + + // Two octaves of drifting turbulence, phased per-row so neighboring + // bands don't ripple in lockstep. + float turb = value_noise(turb_x, row * 3.7 + 11.0) * 0.65 + + value_noise(vec2(turb_x.x * 2.0, turb_x.y), row * 3.7 + 47.0) * 0.35; + + // 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. + float to_band = (local.y - float(dy)) - band_centre; + to_band += (turb - 0.5) * TURBULENCE_STRENGTH; + + float normalized = abs(to_band) / thickness; + nearest = min(nearest, normalized); + } + + 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)); + + vec3 color = base_color.rgb; + color = mix(color, crater_color.rgb, core * 0.85); + color = mix(color, highlight_color.rgb, crest * 0.6); + + // Same dither as lunar_surface.gdshader, to mask 8-bit banding on the + // smooth band gradients. + float dither = (hash21(screen_px + vec2(7.0, 13.0)) - 0.5) / 128.0; + color += vec3(dither); + + COLOR = vec4(color, 1.0); +} diff --git a/tests/_rocky_surface_preview.gd b/tests/_rocky_surface_preview.gd new file mode 100644 index 0000000..5b7b20a --- /dev/null +++ b/tests/_rocky_surface_preview.gd @@ -0,0 +1,20 @@ +extends Node2D + +# Preview, not a test - #51's "the rocky surface is unchanged" acceptance +# criterion. Mirrors _gas_surface_preview.gd exactly except for the planet +# type, so the two screenshots are a fair side-by-side comparison. +# +# bash tests/screenshot.sh res://tests/_rocky_surface_preview.tscn /tmp/rocky.png 20 720x1280 + + +func _ready() -> void: + GameState.planet_name = "Kepler's Rest" + GameState.planet_definition_id = "rocky" + GameState.surviving_ship_count = 3 + GameState.rocks = 128 + GameState.gas = 46 + GameState.fleet_cargo = 34 + GameState.fleet_gas_cargo = 17 + var surface: Node = load("res://PlanetSurface.tscn").instantiate() + surface.get_node("SurfaceController").fade_duration = 0.0 + add_child(surface) diff --git a/tests/_rocky_surface_preview.tscn b/tests/_rocky_surface_preview.tscn new file mode 100644 index 0000000..aa3985a --- /dev/null +++ b/tests/_rocky_surface_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_rocky_surface_preview.gd" id="1"] + +[node name="RockySurfacePreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/test_surface_profiles.gd b/tests/test_surface_profiles.gd new file mode 100644 index 0000000..a9acc62 --- /dev/null +++ b/tests/test_surface_profiles.gd @@ -0,0 +1,98 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_surface_profiles.gd +# Forgejo #51: a landed gas giant needs its own background shader instead of +# lunar_surface.gdshader recoloured (craters reading as regolith, not +# atmosphere). Verifies: +# 1. the gas profile resolves a background_shader different from rocky's +# (rocky's stays null - "keep the scene's existing shader") +# 2. PlanetSurfaceController._compose_surface() actually swaps the +# background material's shader when landing on a gas giant +# 3. landing on a rocky planet leaves the scene's own lunar shader in +# place - a null background_shader is a no-op, not a forced reset +# +# PlanetSurfaceController's _ready() (which calls _compose_surface()) does +# not fire synchronously inside add_child() for a -s entry-point script - +# it's deferred to the next frame, same as ResourceReadout's was for #49 - +# so every read of the composed shader below waits a frame after add_child() +# rather than checking on the same one. + +var game_state: Node +var lunar_shader: Shader +var gas_shader: Shader +var rocky_shader_after_compose: Shader +var scene: Node +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + lunar_shader = load("res://lunar_surface.gdshader") + + # Loaded by path into a local, not referenced by its bare global + # class_name - same load-order hazard PlanetSetups.gd and + # PlanetSurfaceController.gd already work around: the class_name cache + # isn't guaranteed warm before an import pass has run. + var surface_profiles: Script = load("res://PlanetSurfaceProfiles.gd") + var rocky_profile: Resource = surface_profiles.get_profile("rocky") + var gas_profile: Resource = surface_profiles.get_profile("gas") + if rocky_profile.background_shader != null: + print("TEST_SURFACE_PROFILES: FAIL (rocky profile sets a background_shader - it should stay null)") + quit(1) + return + if gas_profile.background_shader == null: + print("TEST_SURFACE_PROFILES: FAIL (gas profile has no background_shader set)") + quit(1) + return + gas_shader = gas_profile.background_shader + if gas_shader == lunar_shader: + print("TEST_SURFACE_PROFILES: FAIL (gas profile points at the lunar shader, not its own)") + quit(1) + return + + game_state.planet_definition_id = "rocky" + scene = load("res://PlanetSurface.tscn").instantiate() + root.add_child(scene) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.2: + return false + var rocky_bg: ShaderMaterial = scene.get_node("BackgroundLayer/LunarBackground").material + rocky_shader_after_compose = rocky_bg.shader + scene.free() + + game_state.planet_definition_id = "gas_giant" + scene = load("res://PlanetSurface.tscn").instantiate() + root.add_child(scene) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.2: + return false + var gas_bg: ShaderMaterial = scene.get_node("BackgroundLayer/LunarBackground").material + scene.free() + + if rocky_shader_after_compose != lunar_shader: + print("TEST_SURFACE_PROFILES: FAIL (landing on a rocky planet changed the background shader)") + quit(1) + return true + if gas_bg.shader != gas_shader: + print("TEST_SURFACE_PROFILES: FAIL (landing on a gas giant did not swap in gas_surface.gdshader)") + quit(1) + return true + if gas_bg.shader == lunar_shader: + print("TEST_SURFACE_PROFILES: FAIL (gas surface still uses the lunar shader)") + quit(1) + return true + + print("TEST_SURFACE_PROFILES: PASS") + quit(0) + return true + return false