feat(client): ScatterField — reusable seeded scatter for client-side paint
Extracted from the T-1194 stipple, which was the first of a family: graffiti placement, cracks in textures, drifting cloud cover — presentation decisions that must look the same when the player returns to a place, and which the simulation has no opinion about and should not be burdened with. THE LINE IT DRAWS. It answers "how is this drawn", never "what is here". A cell's biome, a settlement's position, whether a wall exists — those are world data, derived once by the server and sampled everywhere (D-255(f) mechanism B), and the player eventually stands on them; inventing those here would put the map and the ground in disagreement. Stated on the class so the next consumer does not have to re-derive it: if the answer changes what is THERE it is not a ScatterField question; if it only changes how it is DRAWN, it is. Bit-identity with the server's Rust noise is explicitly NOT a requirement (Jeroen: "a seed is a seed and the functional intended outcome is repetition here"). Nothing here is compared against a server value or round-tripped through a save, so the contract is stability across sessions, not agreement across languages — which is precisely why paint belongs on this side: it buys visual density with no cross-language determinism burden. Seeded from GameState.world_seed, so two playthroughs scatter differently and one playthrough is stable forever. API: domain() resolves a name to a salt ONCE (the first consumer runs ~700,000 times per canvas rebuild, so the hot calls take an int, never a string); value/chance/pick/jitter for discrete marks; smooth() for continuous fields like cloud cover; an optional time axis for animation. Domains keep consumers uncorrelated — without them graffiti and cracks at the same wall coordinate would mark identical spots and read as one artefact. The tests pin the CONTRACT, not the numbers — freezing outputs would make any future improvement to the mixer a breaking change for no gain. They caught a real defect immediately: (-x, -y) collided with (x, y), because negated coordinates produce negated products and the sign-bit mask folded the pair together, mirroring every mark west and south of the origin onto its north-east counterpart. Not an edge case — the descent ladder's own anchor sits at y = -5,675,959. Fixed by zigzag-encoding coordinates before mixing. 1853 client tests, 0 failed (15 new). Global capture re-verified unchanged after migrating the stipple onto the service. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,12 @@ extends RefCounted
|
||||
|
||||
const AtlasOverlayColors := preload("res://ui/implant/apps/atlas/atlas_overlay_colors.gd")
|
||||
|
||||
## Preloaded rather than referenced by its `class_name`, matching this file's
|
||||
## existing convention — a global class symbol is not guaranteed to be
|
||||
## registered when a script is opened directly (the parse sweep does exactly
|
||||
## that), whereas a preload resolves by path every time.
|
||||
const ScatterField := preload("res://scripts/scatter_field.gd")
|
||||
|
||||
const TOGGLE_TEMP: String = "gen_dw_temp"
|
||||
const TOGGLE_MOISTURE: String = "gen_dw_moisture"
|
||||
const TOGGLE_VEGETATION: String = "gen_dw_veg"
|
||||
@@ -162,14 +168,17 @@ static func _texture(planes: CellPlanes, col: int, row: int, base: Color) -> Col
|
||||
var out: Color = base
|
||||
|
||||
var rug: float = _ruggedness(planes, col, row)
|
||||
if rug > 0.0 and _dither(col, row, 0x51F) < rug:
|
||||
if rug > 0.0 and ScatterField.chance(_relief_salt, col, row, rug):
|
||||
out = out.darkened(RELIEF_STIPPLE_STRENGTH * rug)
|
||||
|
||||
# Half-frequency lattice: one mark per 2x2 cells, so the vegetation grain is
|
||||
# visibly coarser than the relief grain rather than a second speckle at the
|
||||
# same pitch.
|
||||
var veg: int = _l8_value(planes.vegetation, col, row)
|
||||
if veg >= VEGETATION_SCRUB and _dither(col >> 1, row >> 1, 0xB33F) < VEGETATION_PATCH_DENSITY:
|
||||
if (
|
||||
veg >= VEGETATION_SCRUB
|
||||
and ScatterField.chance(_veg_salt, col >> 1, row >> 1, VEGETATION_PATCH_DENSITY)
|
||||
):
|
||||
out = out.darkened(VEGETATION_PATCH_STRENGTH)
|
||||
return out
|
||||
|
||||
@@ -217,14 +226,13 @@ static func _ruggedness(planes: CellPlanes, col: int, row: int) -> float:
|
||||
return clampf(maxf(from_relief, from_elev), 0.0, 1.0)
|
||||
|
||||
|
||||
## Deterministic [0,1) dither for a cell, salted per mark type so the relief and
|
||||
## vegetation lattices never correlate (sharing a hash would stack both marks on
|
||||
## the same cells and read as one texture at double contrast).
|
||||
static func _dither(col: int, row: int, salt: int) -> float:
|
||||
var h: int = (col * 0x1F1F1F1F) ^ (row * 0x9E3779B1) ^ salt
|
||||
h = (h ^ (h >> 15)) * 0x2545F491
|
||||
h = (h ^ (h >> 13)) * 0x27220A95
|
||||
return float(absi(h) % 4096) / 4096.0
|
||||
## Domain salts for the two marks, resolved ONCE at class load rather than per
|
||||
## pixel (this file's colorize loop runs ~700,000 times per canvas rebuild at
|
||||
## 1290x540). Separate domains keep the two lattices uncorrelated — sharing one
|
||||
## would stack both marks on the same cells and read as a single texture at
|
||||
## double contrast.
|
||||
static var _relief_salt: int = ScatterField.domain(&"atlas/relief_stipple")
|
||||
static var _veg_salt: int = ScatterField.domain(&"atlas/vegetation_patch")
|
||||
|
||||
|
||||
static func _base_color(planes: CellPlanes, col: int, row: int) -> Color:
|
||||
|
||||
Reference in New Issue
Block a user