spaceship/shaders/research_lab_surface.gdshader
Loïc Wisniewski 2f161621c3 #84: move 215 root files into a folder hierarchy
Everything lived in one flat directory, so a diff gave no clue what area it
touched and finding the file you wanted meant knowing its exact name. Twelve
folders now, grouped by what a thing IS rather than what type it is - a
vessel's definition, its component scenes and its upgrade resources sit
together, because that is the set you read when you change one of them.

  buildings  core  controllers  enemies  logistics  planets
  resources  shaders  surface  ui  vessels  vfx  world

332 files moved and every res:// reference rewritten - 306 files touched,
including the autoload and main-scene paths in project.godot. Nothing renamed:
this is purely where files sit, so the diff is a pile of pure moves and no
behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at
the root, where Godot and the export preset expect them.

Verified three ways, because a suite alone would not have caught all of it:
the full 117 pass, the web export builds clean, and the game renders.

The export is why that matters. The FIRST export after the move failed with
"Attempt to open script 'res://GameState.gd'" - the old path - while
project.godot on disk plainly said res://core/GameState.gd. That is this
project's documented Android FUSE mtime trap: Godot served a cached copy of a
file whose content had changed. A `touch` fixed it. Nothing in the test suite
would have found that, because the suite runs from a copy on a native
filesystem - the one place the bug cannot happen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:44:14 +02:00

117 lines
5.3 KiB
Text

shader_type canvas_item;
// Backdrop for a landed enemy research lab.
//
// The other surfaces are NATURAL: lunar_surface.gdshader erodes craters,
// gas_surface.gdshader bands clouds. A research lab is the one place in the
// game that is BUILT, and it was previously borrowing the crater field with
// a dark red palette - which read as "rocky planet, tinted", not as walking
// onto someone's facility. So this draws a plated floor instead: panel
// seams, service conduits running through them, and lit junctions where two
// conduits cross.
//
// Deliberately loop-free. lunar_surface.gdshader needs a neighbour scan
// because a crater overlaps its cell's borders; nothing here does, so every
// feature is a function of this pixel's own cell and the shader stays a
// handful of hashes per pixel (#68's lesson, applied up front rather than
// after the fact).
//
// The conduits still have to run CONTINUOUSLY across cell borders, which is
// what a neighbour scan would normally buy. The trick is to make a
// horizontal run's existence and height depend only on cell.y, and a
// vertical run's on cell.x: every cell in a row then independently agrees on
// the same answer, so the line joins up without any cell ever looking at
// its neighbours.
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;
// Shared with the lunar background so the same camera feed drives both, and
// so the field repeats on the same period. See LunarBackground.gd.
uniform float tile_cells = 32.0;
uniform vec4 base_color : source_color = vec4(0.16, 0.12, 0.12, 1.0);
uniform vec4 crater_color : source_color = vec4(0.05, 0.03, 0.03, 1.0);
uniform vec4 highlight_color : source_color = vec4(0.75, 0.20, 0.15, 1.0);
// Plates per cell, across and down.
const float SUBPANELS = 3.0;
const float SEAM_WIDTH = 0.05;
const float CONDUIT_WIDTH = 0.055;
// How many rows/columns carry a conduit. Low enough that the floor stays
// mostly plating, high enough that a screen usually shows a junction.
const float CONDUIT_CHANCE = 0.38;
float hash11(float p) {
vec3 p3 = fract(vec3(p) * vec3(0.1031, 0.1030, 0.0973));
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
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 every other background here: an
// absolute world position reaches the tens of thousands, where a mobile
// float has already lost the fractional bits this needs. camera_cell
// arrives pre-wrapped from GDScript's double precision.
vec2 rel = camera_frac + centered * (camera_zoom / cell_size);
vec2 cell_rel = floor(rel);
vec2 local = rel - cell_rel;
vec2 cell = mod(camera_cell + cell_rel, tile_cells);
// --- plating ---------------------------------------------------------
vec2 sub = local * SUBPANELS;
vec2 sub_cell = floor(sub);
vec2 sub_local = sub - sub_cell;
vec2 edge = min(sub_local, 1.0 - sub_local);
float seam = 1.0 - smoothstep(0.0, SEAM_WIDTH, min(edge.x, edge.y));
// A little per-plate variation, so a large floor does not read as one
// flat fill: plates are stamped, not poured.
float plate = hash21(cell * SUBPANELS + sub_cell);
vec3 color = mix(base_color.rgb, crater_color.rgb, plate * 0.40);
color = mix(color, crater_color.rgb, seam * 0.85);
// --- conduits --------------------------------------------------------
// Existence and offset keyed to a SINGLE axis of the cell index, which
// is what makes a run continuous across every cell it passes through.
float has_h = step(1.0 - CONDUIT_CHANCE, hash11(cell.y + 0.5));
float has_v = step(1.0 - CONDUIT_CHANCE, hash11(cell.x + 13.7));
float run_y = 0.25 + 0.5 * hash11(cell.y + 7.3);
float run_x = 0.25 + 0.5 * hash11(cell.x + 3.1);
float h_band = has_h * (1.0 - smoothstep(0.0, CONDUIT_WIDTH, abs(local.y - run_y)));
float v_band = has_v * (1.0 - smoothstep(0.0, CONDUIT_WIDTH, abs(local.x - run_x)));
// Charge travelling along the run. Phase is built from cell + local, a
// continuous coordinate, so the pulse crosses cell borders unbroken
// rather than restarting inside every cell.
float pulse_h = 0.55 + 0.45 * sin((cell.x + local.x) * 2.4 - TIME * 1.6);
float pulse_v = 0.55 + 0.45 * sin((cell.y + local.y) * 2.4 - TIME * 1.6);
float conduit = max(h_band * pulse_h, v_band * pulse_v);
// A dark channel under the glow, so a conduit reads as recessed into the
// floor rather than painted on top of it.
color = mix(color, crater_color.rgb, max(h_band, v_band) * 0.55);
color = mix(color, highlight_color.rgb, conduit * 0.75);
// Junction box wherever a horizontal and a vertical run cross.
float junction = has_h * has_v * (1.0 - smoothstep(0.0, CONDUIT_WIDTH * 2.4,
length(vec2(local.x - run_x, local.y - run_y))));
color = mix(color, highlight_color.rgb, junction * 0.9);
// Same dither as the other backgrounds: the gradients here are shallow
// and dark, which is exactly where 8-bit quantization bands visibly.
float dither = (hash21(screen_px + vec2(7.0, 13.0)) - 0.5) / 128.0;
color += vec3(dither);
COLOR = vec4(color, 1.0);
}