TURNCRAFT/src/engine/atlas.ts
jing 9c668d340c Headshell Workshop + Glow-Up phase: assembly minigame, art pass, review fixes
- Workshop (WORKSHOP_CARTRIDGE): five-stage cartridge assembly at Deck A —
  seat/square, crimp four tag-wires, torque screws, ride-the-arm counterweight
  balance, needle-drop diagnostic with per-fault audio + scope. Relay-synced
  (per-field co-op merge: held screw + carried wire survive remote state).
- Glow-Up (G1-G5): 32px atlas with per-voxel variants, selective LED bloom
  (quality-gated), screen-print decal system + party flyers, mixer/PCB worldgen
  density pass, record groove-sheen side texture.
- 10 confirmed multi-agent review fixes, incl. co-op screw-stomp soft-lock,
  ride-snap collider-identity (magnet feet / eaten record-fling), workshop SFX
  exact-match map (rca_seated hijack), beam ride colliders to the head,
  double-crimp guard, WIRING INCOMPLETE diagnosis mode, skate-abort timer,
  completedState wiring, per-tick material churn, trackingHeavy platter drag.
- Crossfader playtest fix: slew-limited sled (3.4 v/s) + ribbed grip caps with
  amber index — the sled reads as a heavy handle, not a teleporting wall.
- Demo harnesses: machinesDemo hold-key wiring, playerDemo seesaw phase
  continuity, audioDemo incomplete fault button.
- Workshop sync: exact 1.0 screw endpoint gets its own emit signature.

Verified: typecheck + vite build clean, live solo quest smoke, two-client
co-op relay smoke (simultaneous torque, no rewind, exact convergence).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 21:32:07 +10:00

320 lines
13 KiB
TypeScript

// TURNCRAFT — Lane A. Procedural texture atlas + block materials. (Glow-Up v2)
//
// Atlas of 32x32 px tiles painted per block `pattern` + `tint`. Each block gets
// NVAR painted VARIANTS laid out in extra columns; the material's shader patch
// hashes each voxel's world position to pick a variant, so a repeated surface
// (PCB floor, plywood wall, brushed metal) breaks up instead of tiling like
// wallpaper. A parallel emissive atlas carries the glow (black elsewhere).
//
// Two MeshStandardMaterials (opaque + transparent) sample the atlas. The greedy
// mesher writes a per-quad tile-repeat `uv` (0..w,0..h) and an `aTile` = the
// block's BASE cell (id%8, id/8); the shader fracts the repeat, expands the cell
// column by the hashed variant, and samples map + emissiveMap. The mesher is
// unchanged — the variant system lives entirely here.
//
// Filtering: NearestFilter, no mipmaps. fract keeps each sample strictly inside
// its variant cell ([col/(COLS*NVAR), (col+1)/(COLS*NVAR))) so there is no
// bleeding and no mip-derivative seams — the chunky voxel look is preserved.
import * as THREE from 'three';
import { BLOCKS, BLOCK_BY_ID, type BlockDef } from '../core/blocks';
const TILE = 32; // was 16 — sharper patterns
const COLS = 8; // base columns (matches mesher aTile: id%8)
const ROWS = Math.ceil(BLOCKS.length / COLS); // 31 blocks -> 4 rows
const NVAR = 4; // painted variants per block
const GRID_W = COLS * NVAR; // physical atlas columns (32)
export interface Atlas {
texture: THREE.CanvasTexture;
emissiveTexture: THREE.CanvasTexture;
cols: number;
rows: number;
opaqueMaterial: THREE.Material;
transparentMaterial: THREE.Material;
/** Multiply emissive intensity (Lane E pulses this to the beat; default 1). */
setEmissiveBoost(v: number): void;
dispose(): void;
}
// --- deterministic per-tile PRNG (stable atlas across reloads) ---
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function clamp8(v: number): number { return v < 0 ? 0 : v > 255 ? 255 : v | 0; }
/**
* Paint one variant of one block's 32x32 tile onto a fresh 2D context.
* `alpha` bakes the block's translucency (opaque = 1; glass/vinyl < 1).
*/
function paintTile(ctx: CanvasRenderingContext2D, def: BlockDef, variant: number): void {
const rng = mulberry32((0x9e3779b9 ^ (def.id * 2654435761) ^ (variant * 0x85ebca6b)) >>> 0);
const [r, g, b] = def.tint;
const alpha = def.transparent ? (def.pattern === 'glass' ? 0.24 : 0.82) : 1;
// colour from tint * multiplier, at the block's alpha
const col = (m: number, a = alpha) =>
`rgba(${clamp8(r * m)},${clamp8(g * m)},${clamp8(b * m)},${a})`;
const fill = (m: number, a = alpha) => { ctx.fillStyle = col(m, a); ctx.fillRect(0, 0, TILE, TILE); };
const noise = (amp: number, step = 2) => {
for (let y = 0; y < TILE; y += step)
for (let x = 0; x < TILE; x += step) {
ctx.fillStyle = col(1 + (rng() - 0.5) * amp);
ctx.fillRect(x, y, step, step);
}
};
switch (def.pattern) {
case 'solid': {
noise(0.12);
break;
}
case 'brushed': {
// long vertical strokes: low-freq column shade + fine grain + a few scratches
for (let x = 0; x < TILE; x++) {
const m = 1 + Math.sin(x * 0.5 + variant) * 0.05 + (rng() - 0.5) * 0.05;
ctx.fillStyle = col(m);
ctx.fillRect(x, 0, 1, TILE);
}
for (let i = 0; i < 3; i++) { ctx.fillStyle = col(1.3, alpha * 0.55); ctx.fillRect((rng() * TILE) | 0, 0, 1, TILE); }
for (let i = 0; i < 2; i++) { ctx.fillStyle = col(0.7, alpha * 0.55); ctx.fillRect((rng() * TILE) | 0, 0, 1, TILE); }
break;
}
case 'plywood': {
if (def.name === 'ply_edge') {
// end grain: concentric growth rings
fill(1);
const cx = TILE * (0.3 + rng() * 0.4), cy = TILE * (0.3 + rng() * 0.4);
for (let ring = 0; ring < 18; ring++) {
ctx.strokeStyle = col(ring % 2 ? 0.9 : 0.74);
ctx.lineWidth = 1.3;
ctx.beginPath(); ctx.arc(cx, cy, ring * 2.3 + 2, 0, Math.PI * 2); ctx.stroke();
}
} else {
// long horizontal wavy grain
for (let y = 0; y < TILE; y++) {
const wave = Math.sin(y * 0.5 + variant * 1.7) * 1.6;
const m = 1 + Math.sin((y + wave) * 0.85) * 0.09 + (rng() - 0.5) * 0.04;
ctx.fillStyle = col(m); ctx.fillRect(0, y, TILE, 1);
}
for (let i = 0; i < 3; i++) { ctx.fillStyle = col(0.8, alpha * 0.6); ctx.fillRect(0, (rng() * TILE) | 0, TILE, 1); }
}
break;
}
case 'speckle': {
noise(0.08);
for (let i = 0; i < 26; i++) {
ctx.fillStyle = col(0.6, alpha);
ctx.fillRect((rng() * TILE) | 0, (rng() * TILE) | 0, 2, 2);
}
break;
}
case 'grooves': {
// concentric arcs (record grooves) from a centre far below the tile + a sheen line
fill(1);
const cx = TILE / 2 + (variant - 1.5) * 3, cy = TILE * 3.2;
for (let rad = 56; rad < 96; rad += 2) {
ctx.strokeStyle = col(0.72, alpha * 0.9);
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(cx, cy, rad + rng() * 0.4, -Math.PI, 0); ctx.stroke();
}
ctx.strokeStyle = col(1.7, alpha * 0.5);
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, 8 + variant); ctx.lineTo(TILE, 12 + variant); ctx.stroke();
break;
}
case 'pcb': {
// 4 variants that tile into routed traces: mid-edge trace stubs line up
fill(1); noise(0.09, 2);
const mid = TILE / 2;
const copper = 'rgb(190,120,64)';
ctx.strokeStyle = copper; ctx.lineWidth = 3; ctx.lineCap = 'butt';
ctx.beginPath();
if (variant === 0) { ctx.moveTo(0, mid); ctx.lineTo(TILE, mid); } // straight-through
else if (variant === 1) { ctx.moveTo(mid, 0); ctx.lineTo(mid, TILE); } // vertical
else if (variant === 2) { ctx.moveTo(0, mid); ctx.lineTo(TILE, mid); ctx.moveTo(mid, 0); ctx.lineTo(mid, TILE); } // cross
else { ctx.moveTo(0, mid); ctx.lineTo(mid, mid); ctx.lineTo(mid, TILE); } // L-bend
ctx.stroke();
const via = (x: number, y: number) => {
ctx.fillStyle = 'rgb(206,208,212)'; ctx.beginPath(); ctx.arc(x, y, 3.6, 0, 7); ctx.fill();
ctx.fillStyle = 'rgb(30,44,32)'; ctx.beginPath(); ctx.arc(x, y, 1.6, 0, 7); ctx.fill();
};
if (variant >= 2) via(mid, mid);
if (variant === 0) via(TILE * 0.28, mid);
if (variant === 1) via(mid, TILE * 0.72);
// white silkscreen flecks
ctx.fillStyle = 'rgba(220,226,214,0.7)';
for (let i = 0; i < 5; i++) ctx.fillRect((rng() * TILE) | 0, (rng() * TILE) | 0, 1 + ((rng() * 3) | 0), 1);
break;
}
case 'mesh': {
fill(1);
ctx.fillStyle = col(0.34);
for (let y = 2; y < TILE; y += 3)
for (let x = 2; x < TILE; x += 3) ctx.fillRect(x, y, 2, 2);
break;
}
case 'glass': {
fill(1); // already low alpha
ctx.strokeStyle = col(1.5, alpha * 1.4);
ctx.lineWidth = 1;
for (let i = -TILE; i < TILE; i += 9) {
ctx.beginPath(); ctx.moveTo(i + variant * 2, 0); ctx.lineTo(i + variant * 2 + TILE, TILE); ctx.stroke();
}
break;
}
case 'led': {
// bright core, soft radial falloff (smooth gradient)
const c = TILE / 2 - 0.5;
const grad = ctx.createRadialGradient(c, c, 1, c, c, TILE * 0.55);
grad.addColorStop(0, col(1.6)); grad.addColorStop(0.5, col(1.05)); grad.addColorStop(1, col(0.35));
ctx.fillStyle = grad; ctx.fillRect(0, 0, TILE, TILE);
break;
}
case 'felt': {
noise(0.2, 1); // soft high-frequency fibres
break;
}
}
}
export function buildAtlas(): Atlas {
const w = GRID_W * TILE, h = ROWS * TILE;
const baseCanvas = makeCanvas(w, h);
const emisCanvas = makeCanvas(w, h);
const baseCtx = baseCanvas.getContext('2d')!;
const emisCtx = emisCanvas.getContext('2d')!;
emisCtx.fillStyle = '#000';
emisCtx.fillRect(0, 0, w, h); // emissive defaults to black (no glow)
const tileCanvas = makeCanvas(TILE, TILE);
const tileCtx = tileCanvas.getContext('2d')!;
for (let id = 0; id < BLOCKS.length; id++) {
const def = BLOCK_BY_ID[id];
if (!def) continue;
const baseCol = id % COLS, row = (id / COLS) | 0;
for (let variant = 0; variant < NVAR; variant++) {
const ox = (baseCol * NVAR + variant) * TILE, oy = row * TILE;
tileCtx.clearRect(0, 0, TILE, TILE);
paintTile(tileCtx, def, variant);
baseCtx.drawImage(tileCanvas, ox, oy);
if (def.emissive > 0) {
// emissive tile = base rgb * emissive strength (glows in its own colour)
const img = tileCtx.getImageData(0, 0, TILE, TILE);
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
d[i] = d[i] * def.emissive; d[i + 1] = d[i + 1] * def.emissive; d[i + 2] = d[i + 2] * def.emissive;
d[i + 3] = 255;
}
emisCtx.putImageData(img, ox, oy);
}
}
}
const texture = new THREE.CanvasTexture(baseCanvas);
const emissiveTexture = new THREE.CanvasTexture(emisCanvas);
for (const t of [texture, emissiveTexture]) {
t.magFilter = THREE.NearestFilter;
t.minFilter = THREE.NearestFilter;
t.generateMipmaps = false;
t.flipY = false; // cell (col,row) is top-based; keeps atlas UV math direct
t.colorSpace = THREE.SRGBColorSpace;
t.needsUpdate = true;
}
const opaqueMaterial = makeBlockMaterial(texture, emissiveTexture, false);
const transparentMaterial = makeBlockMaterial(texture, emissiveTexture, true);
return {
texture, emissiveTexture, cols: COLS, rows: ROWS,
opaqueMaterial, transparentMaterial,
setEmissiveBoost(v: number) {
(opaqueMaterial as THREE.MeshStandardMaterial).emissiveIntensity = v;
(transparentMaterial as THREE.MeshStandardMaterial).emissiveIntensity = v;
},
dispose() {
texture.dispose(); emissiveTexture.dispose();
opaqueMaterial.dispose(); transparentMaterial.dispose();
},
};
}
function makeCanvas(w: number, h: number): HTMLCanvasElement {
const c = document.createElement('canvas');
c.width = w; c.height = h;
return c;
}
function makeBlockMaterial(
map: THREE.Texture, emissiveMap: THREE.Texture, transparent: boolean,
): THREE.MeshStandardMaterial {
const mat = new THREE.MeshStandardMaterial({
map,
emissiveMap,
emissive: 0xffffff, // glow colour comes from emissiveMap; intensity scales it
emissiveIntensity: 1.0,
vertexColors: true, // baked AO (see mesher)
roughness: 0.78, // a touch more polish than v1 (0.82)
metalness: 0.10,
transparent,
depthWrite: !transparent,
side: transparent ? THREE.DoubleSide : THREE.FrontSide,
});
// Atlas UV patch. `uv` = per-quad tile-repeat (0..w,0..h); `aTile` = the
// block's BASE cell (id%8, id/8). We hash each voxel's world position to pick
// one of NVAR variants, expand the cell column, fract the repeat, and sample
// map + emissiveMap ourselves. `position` is baked world-space (chunk meshes
// have identity transform), so flooring it -eps*normal lands in the owning
// voxel — the variant is constant across a face and changes at voxel borders.
mat.onBeforeCompile = (shader) => {
shader.uniforms.uAtlasGrid = { value: new THREE.Vector2(GRID_W, ROWS) };
shader.vertexShader = shader.vertexShader
.replace('#include <common>',
`#include <common>
attribute vec2 aTile;
varying vec2 vTileRepeat;
varying vec2 vTileCell;
varying vec3 vTcWorld;
varying vec3 vTcNormal;`)
.replace('#include <uv_vertex>',
`#include <uv_vertex>
vTileRepeat = uv;
vTileCell = aTile;
vTcWorld = position;
vTcNormal = normal;`);
shader.fragmentShader = shader.fragmentShader
.replace('#include <common>',
`#include <common>
uniform vec2 uAtlasGrid;
varying vec2 vTileRepeat;
varying vec2 vTileCell;
varying vec3 vTcWorld;
varying vec3 vTcNormal;
float tcHash(vec3 p){ p = fract(p * 0.3183099 + 0.1); p *= 17.0; return fract(p.x * p.y * p.z * (p.x + p.y + p.z)); }
vec2 turncraftAtlasUV() {
vec3 voxel = floor(vTcWorld - 0.02 * vTcNormal);
float variant = floor(tcHash(voxel) * ${NVAR}.0);
float col = vTileCell.x * ${NVAR}.0 + variant;
return (vec2(col, vTileCell.y) + fract(vTileRepeat)) / uAtlasGrid;
}`)
.replace('#include <map_fragment>',
`diffuseColor *= texture2D( map, turncraftAtlasUV() );`)
.replace('#include <emissivemap_fragment>',
`totalEmissiveRadiance *= texture2D( emissiveMap, turncraftAtlasUV() ).rgb;`);
};
mat.customProgramCacheKey = () => `turncraft-atlas-v2-${transparent ? 't' : 'o'}`;
return mat;
}