- 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>
320 lines
13 KiB
TypeScript
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;
|
|
}
|