From 49cab1f69b0ec3e48ea65f3da74d8076cdf1a932 Mon Sep 17 00:00:00 2001 From: jing Date: Mon, 13 Jul 2026 21:48:00 +1000 Subject: [PATCH] Concept-art pass: terraced records, spindle towers, warm lit room - Records are stepped groove amphitheaters (3 vinyl tiers + 4-tier spindle tower, stacked rotating cylinder colliders) - you climb a spinning staircase. Tonearm clearances raised to match. - Player physics: cylinder contacts resolve by minimum penetration (fixes a latent radial-slingshot bug), collider lips auto-step like voxel ledges, rim-standing radius grace. - Well seam ring raised (was a 7-deep inescapable trench, now a 1-step Technics seam). - Plywood ceiling with amber lamp panels + warm point lights; brighter warm lighting rig; colorful mixer knob caps; rim-crumble debris. - window.TURNCRAFT_CINE camera override for screenshots/trailers. Verified live: rim->plateau spiral climb at 33rpm, tower climb 83->87, seam escape, quest/win chain, 121 fps. Co-Authored-By: Claude Fable 5 --- docs/INTEGRATION_NOTES.md | 30 ++++++++++ src/engine/renderer.ts | 12 ++-- src/machines/platter.ts | 101 +++++++++++++++++++++------------ src/machines/tonearm.ts | 5 +- src/main.ts | 21 ++++++- src/player/PlayerController.ts | 41 ++++++++++--- src/worldgen/buildBooth.ts | 40 +++++++++++-- 7 files changed, 191 insertions(+), 59 deletions(-) diff --git a/docs/INTEGRATION_NOTES.md b/docs/INTEGRATION_NOTES.md index 9624c0a..de2160c 100644 --- a/docs/INTEGRATION_NOTES.md +++ b/docs/INTEGRATION_NOTES.md @@ -75,6 +75,36 @@ lanes landed. Every cross-lane glue decision is listed here. auto-start, HUD banner + tracker 5/5, AudioContext running. - The platter-well gap ring behaves as a trench (collider pushes, no clipping). +## Concept-art pass (post-integration, from John's Gemini mockup) + +- **Terraced records**: the flat disc became a stepped "groove amphitheater" — + 3 vinyl tiers rising 1 voxel each to the label plateau, then a 4-tier + stepped spindle tower with an amber lamp (all in `TIERS`, `platter.ts`). + Each tier is its own stacked kinematic cylinder; you walk/spiral up a + SPINNING staircase. Tonearm raised (`HEAD_Y` +2.6) and `R_INNER` pulled to + 16 so the needle clears the terraces and never hits the tower. +- **Player physics upgrades this required** (`PlayerController.ts`): + cylinder contacts now resolve by minimum penetration (up vs radial), which + fixes a latent slingshot bug (feet dipping below a disc's top while deep + inside its footprint used to teleport the player radially to the rim); + 1-voxel collider lips auto-step like voxel ledges; cylinder tops have a + half-foot radius grace so you can stand on rims. +- **Well seam**: the platter well's outer ring was a 7-deep inescapable + trench; floor raised to 1 below the plinth top (worldgen) — now a Technics + seam you step out of. +- **Room feel**: plywood ceiling with six recessed amber lamp panels + (worldgen) + matching warm PointLights (main.ts), warmer/brighter + hemi/ambient/fog (renderer.ts). +- **Mixer color pop**: knob caps get a deterministic palette scatter (red/ + white/gold/LED) in `buildMixer`. **Rim crumble**: vinyl chips/dust/screws + scattered around the well mouths in `buildDeck`. +- Dev nicety: `window.TURNCRAFT_CINE = { pos, look }` detaches the camera + for screenshots/trailers; `window.TURNCRAFT` exposes camera/render too. +- Verified: walk-in from the rim spirals up to the label plateau at 33 rpm + (fighting the spin is intentional physics — stop the deck for an easy + climb); tower fully climbable (83→87 by walking); seam escape 1-step; + 121 fps with ceiling + lights; quest/win chain re-verified end-to-end. + ## Not yet done (deploy phase) Run `/ship-check` before exposing anything publicly; deploy per deploy-map diff --git a/src/engine/renderer.ts b/src/engine/renderer.ts index 7f6c97b..69111a2 100644 --- a/src/engine/renderer.ts +++ b/src/engine/renderer.ts @@ -30,22 +30,22 @@ export function createRenderer(container: HTMLElement): Renderer { container.appendChild(renderer.domElement); const scene = new THREE.Scene(); - const bg = new THREE.Color(0x0a0908); // near-black, warm + const bg = new THREE.Color(0x181009); // warm dusk haze (concept-art pass) scene.background = bg; - scene.fog = new THREE.Fog(0x1a1310, 90, 420); // warm plywood-brown horizon + scene.fog = new THREE.Fog(0x241a12, 80, 380); // warm plywood-brown room haze const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000); camera.position.set(0, 4, 8); // Warm key light (parallel; direction = position, target at origin). - const key = new THREE.DirectionalLight(0xfff1dd, 2.2); + const key = new THREE.DirectionalLight(0xfff1dd, 2.6); key.position.set(70, 130, 45); scene.add(key); - // Cool sky / warm ground hemispheric fill. - const hemi = new THREE.HemisphereLight(0x9fb4d8, 0x4a3a28, 0.65); + // Warm-cream sky / warm ground hemispheric fill — a lamp-lit room, not a void. + const hemi = new THREE.HemisphereLight(0xd8c2a4, 0x4a3a28, 1.0); scene.add(hemi); // Small ambient lift so deep AO corners never go pure black. - scene.add(new THREE.AmbientLight(0x20202a, 0.25)); + scene.add(new THREE.AmbientLight(0x2a241e, 0.35)); const atlas = buildAtlas(); diff --git a/src/machines/platter.ts b/src/machines/platter.ts index 1f7298d..a742bf7 100644 --- a/src/machines/platter.ts +++ b/src/machines/platter.ts @@ -21,6 +21,19 @@ const TAU = PLATTER.spinUpSeconds * 0.43; export type Speed = 33 | 45; +// The record as terraced "groove amphitheater" tiers (concept-art pass): +// each tier's TOP surface, rising 1 voxel per ring toward the center, then +// the stepped spindle tower. All 1-voxel rises = auto-steppable while spinning. +const TIERS: ReadonlyArray<{ r: number; top: number }> = [ + { r: PLATTER.recordRadius, top: Y_RECORD_TOP }, // outer vinyl, 81 + { r: 26, top: Y_RECORD_TOP + 1 }, // mid groove terrace + { r: 14, top: Y_RECORD_TOP + 2 }, // label plateau + { r: 5.5, top: Y_RECORD_TOP + 3 }, // spindle tower, stepped + { r: 4.5, top: Y_RECORD_TOP + 4 }, + { r: 3.5, top: Y_RECORD_TOP + 5 }, + { r: 2.5, top: Y_RECORD_TOP + 6 }, +]; + export class Platter extends MachineBase { readonly deck: 'A' | 'B'; private readonly cx: number; @@ -34,7 +47,6 @@ export class Platter extends MachineBase { unlocked45 = true; // 45 is available for rim-launch traversal from the start private readonly spin = new THREE.Group(); // everything that rotates about the spindle - private readonly collider: KinematicCollider; private readonly _vel: Vec3 = [0, 0, 0]; // velocityAt scratch (reused, no alloc) constructor(deck: 'A' | 'B', spindleX: number, spindleZ: number) { @@ -43,29 +55,31 @@ export class Platter extends MachineBase { this.cx = spindleX; this.cz = spindleZ; - // ---- Collider: one +Y cylinder spanning the platter well floor → vinyl - // surface, so the spinning body pushes the player out of the well's gap - // ring too (not just off the top). Top face = Y_RECORD_TOP exactly. - const wellFloor = Y_PLINTH_TOP - PLATTER.wellDepth; // 74 - const halfH = (Y_RECORD_TOP - wellFloor) / 2; // 3.5 - const cy = wellFloor + halfH; // 77.5 - this.collider = { - id: this.id, - shape: { kind: 'cylinder', center: [this.cx, cy, this.cz], radius: PLATTER.radius, halfHeight: halfH }, - // Surface velocity = ω × r (rigid rotation about +Y). Zero when stopped. - // Writes into a reused scratch (no per-call alloc in the ride hot path). - // Contract: consume the value before the next velocityAt call on THIS - // collider — Lane B reads it immediately each tick (CONTRACTS §4). - velocityAt: (x: number, _y: number, z: number): Vec3 => { - const v = this._vel; - if (this.omega === 0) { v[0] = v[1] = v[2] = 0; return v; } - v[0] = this.omega * (z - this.cz); - v[1] = 0; - v[2] = -this.omega * (x - this.cx); - return v; - }, + // ---- Colliders: a stack of +Y cylinders — the record is a terraced + // amphitheater (groove tiers rising toward the spindle tower). Each tier + // rises 1 voxel so the player can auto-step up a SPINNING staircase. + // The outer tier's top face = Y_RECORD_TOP exactly (step-on from the + // plinth unchanged); the base cylinder reaches the well floor so the + // spinning body still pushes players out of the well's gap ring. + // Surface velocity = ω × r (rigid rotation about +Y), shared scratch — + // consume before the next velocityAt call (CONTRACTS §4). + const velocityAt = (x: number, _y: number, z: number): Vec3 => { + const v = this._vel; + if (this.omega === 0) { v[0] = v[1] = v[2] = 0; return v; } + v[0] = this.omega * (z - this.cz); + v[1] = 0; + v[2] = -this.omega * (x - this.cx); + return v; }; - this.colliders = [this.collider]; + const wellFloor = Y_PLINTH_TOP - PLATTER.wellDepth; // 74 + this.colliders = TIERS.map((t, i) => { + const halfH = (t.top - wellFloor) / 2; + return { + id: `${this.id}_t${i}`, + shape: { kind: 'cylinder' as const, center: [this.cx, wellFloor + halfH, this.cz] as Vec3, radius: t.r, halfHeight: halfH }, + velocityAt, + }; + }); this.buildVisuals(deck); this.spin.position.set(this.cx, 0, this.cz); @@ -100,29 +114,42 @@ export class Platter extends MachineBase { const slipMat = blockMaterial(7); // slipmat_felt this.spin.add(cylinderY(rr + 1.5, 0.5, slipMat, 0, Y_RECORD_TOP - 0.75, 0)); - // Record body: blue translucent (deck A) / black (deck B). + // Record body: terraced groove tiers (blue translucent on A / black on B), + // matching the TIERS colliders — a stepped vinyl amphitheater. const vinylId = deck === 'A' ? 9 : 8; // vinyl_blue / vinyl_black const vinylDef = deck === 'A' ? BLOCK_BY_NAME.get('vinyl_blue')! : BLOCK_BY_NAME.get('vinyl_black')!; - const sideMat = blockMaterial(vinylId, { opacity: deck === 'A' ? 0.5 : 1 }); - const recordBody = cylinderY(rr, 1.4, sideMat, 0, Y_RECORD_TOP - 0.7, 0); - this.spin.add(recordBody); + const sideMat = blockMaterial(vinylId, { opacity: deck === 'A' ? 0.55 : 1 }); + const tierBottom = Y_RECORD_TOP - 1.4; // outer tier's underside on the slipmat + for (const t of TIERS.slice(0, 2)) { + this.spin.add(cylinderY(t.r, t.top - tierBottom, sideMat, 0, tierBottom + (t.top - tierBottom) / 2, 0)); + } - // Record top face: grooves + printed cream label (canvas texture). + // Label plateau: cream label with grooves + catalog print on top. const label = BLOCK_BY_NAME.get('label_cream')!.tint; + const labelTier = TIERS[2]; + this.spin.add(cylinderY(labelTier.r, labelTier.top - tierBottom, sideMat, 0, tierBottom + (labelTier.top - tierBottom) / 2, 0)); const topTex = recordTopTexture(vinylDef.tint, label, 'TC-00' + (deck === 'A' ? '1' : '2') + ' · TURNCRAFT'); - const topMat = new THREE.MeshStandardMaterial({ - map: topTex, roughness: 0.5, metalness: 0.05, - transparent: deck === 'A', opacity: deck === 'A' ? 0.94 : 1, - }); - const top = new THREE.Mesh(new THREE.CircleGeometry(rr, 64), topMat); + const topMat = new THREE.MeshStandardMaterial({ map: topTex, roughness: 0.5, metalness: 0.05 }); + const top = new THREE.Mesh(new THREE.CircleGeometry(labelTier.r, 48), topMat); top.rotation.x = -Math.PI / 2; // face +Y - top.position.y = Y_RECORD_TOP + 0.01; + top.position.y = labelTier.top + 0.01; this.spin.add(top); - // Chrome spindle pin (does not rotate meaningfully; ride onto it to climb). + // Stepped spindle tower (concept-art pass): chunky steel tiers you can + // hop up while the record turns, with a warm lamp block on top. + const steel = blockMaterial(4, { roughness: 0.7 }); // steel_grey const chrome = blockMaterial(11); // chrome - const pin = cylinderY(0.8, 8, chrome, 0, Y_RECORD_TOP + 3.5, 0, 16); - this.spin.add(pin); + for (let i = 3; i < TIERS.length; i++) { + const t = TIERS[i]; + const mat = i % 2 ? steel : chrome; + const box = new THREE.Mesh(new THREE.BoxGeometry(t.r * 2, 1, t.r * 2), mat); + box.position.y = t.top - 0.5; + this.spin.add(box); + } + const lampTop = TIERS[TIERS.length - 1]; + const lamp = new THREE.Mesh(new THREE.BoxGeometry(1.4, 0.6, 1.4), blockMaterial(20, { emissive: 0.9 })); // led_amber + lamp.position.y = lampTop.top + 0.3; + this.spin.add(lamp); } // ---- Control surface (called by buttons / faders / win wiring) ---- diff --git a/src/machines/tonearm.ts b/src/machines/tonearm.ts index a09c08f..3ff0aa1 100644 --- a/src/machines/tonearm.ts +++ b/src/machines/tonearm.ts @@ -11,8 +11,9 @@ import type { ColliderShape, KinematicCollider, Vec3 } from '../core/types'; import { MachineBase } from './machine'; import { blockMaterial } from './util'; -const HEAD_Y = Y_RECORD_TOP + 1.5; // headshell hovers just above the vinyl -const R_INNER = 9; // headshell radius-from-spindle at inner groove +const HEAD_Y = Y_RECORD_TOP + 2.6; // hovers above the vinyl, clearing the mid terrace +const R_INNER = 16; // inner groove — stays outside the label + // plateau (r 14, top +2) and the tower const R_OUTER = PLATTER.recordRadius - 3; // outer groove const R_REST = PLATTER.radius + 1; // parked just off the platter rim const TRACK_SECONDS = 240; // ~4 min for a full inward pass diff --git a/src/main.ts b/src/main.ts index 0a7d3a1..1ee2d27 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,7 +2,7 @@ // Wires the five lanes: engine (A), player (B), worldgen (C), // machines/interaction (D), audio/fx/ui (E). -import type { Object3D, PerspectiveCamera } from 'three'; +import { PointLight, type Object3D, type PerspectiveCamera } from 'three'; import { FIXED_DT, MAX_SUBSTEPS } from './core/constants'; import { createRenderer, VoxelWorld, ChunkManager } from './engine'; import { WORLD_X, WORLD_Y, WORLD_Z } from './core/constants'; @@ -49,6 +49,16 @@ const questPos: QuestPositions = { const machines = createMachines(world, questPos); for (const o of machines.getObject3Ds()) scene.add(o as Object3D); +// Warm light pools under the six ceiling lamp panels worldgen places +// (buildShell); positions must match its [112,224,336] × [70,160] grid. +for (const lx of [112, 224, 336]) { + for (const lz of [70, 160]) { + const lamp = new PointLight(0xffd9a6, 900, 0, 1.9); + lamp.position.set(lx, 136, lz); + scene.add(lamp); + } +} + // 6 (+6.5). Player, then give machines the player view (walk-push faders, // walk-press buttons — Lane D handoff friction #2). const player = new PlayerController(world, { @@ -122,6 +132,13 @@ function frame(now: number): void { } if (steps === MAX_SUBSTEPS) acc = 0; // don't spiral after long stalls audio.updateListener(player); + // Dev/trailer cinematic override: window.TURNCRAFT_CINE = { pos, look } + // detaches the camera from the player until set back to null. + const cine = (window as unknown as { TURNCRAFT_CINE?: { pos: number[]; look: number[] } }).TURNCRAFT_CINE; + if (cine) { + camera.position.set(cine.pos[0], cine.pos[1], cine.pos[2]); + camera.lookAt(cine.look[0], cine.look[1], cine.look[2]); + } chunks.update(); render(); } @@ -131,4 +148,4 @@ requestAnimationFrame(frame); declare global { interface Window { TURNCRAFT: unknown } } -window.TURNCRAFT = { world, player, machines, quest: machines.quest, hotbar, audio, fx, hud, chunks, interaction }; +window.TURNCRAFT = { world, player, machines, quest: machines.quest, hotbar, audio, fx, hud, chunks, interaction, camera, render }; diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index a17f392..742ea3d 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -12,7 +12,7 @@ import { PLAYER, GRAVITY } from '../core/constants'; import { bus } from '../core/events'; import { createInputState, InputController, type InputState } from './input'; import { - resolveAxis, moveHorizontalWithStep, HALF_W, HEIGHT, + resolveAxis, moveHorizontalWithStep, HALF_W, HEIGHT, STEP_HEIGHT, } from './collision'; // --- Local feel tuning (NOT in core PLAYER; see HANDOFF.md if you re-tune). --- @@ -230,21 +230,46 @@ export class PlayerController implements IPlayerView { const dist = Math.hypot(rx, rz); // Stand on top → record as a ground candidate (snapped after the loop). + // Radius grace of half the foot box: you can stand on the rim with your + // center slightly past it, matching how AABB feet rest on voxel edges. if ( - dist < s.radius && this._vel[1] <= 0.001 && + dist < s.radius + HALF_W * 0.5 && this._vel[1] <= 0.001 && p[1] <= top + STAND_UP_EPS && p[1] >= top - LAND_DOWN_EPS ) { if (top > groundTop) { groundTop = top; groundCollider = c; } continue; } - // Side push-out (radial) when the body overlaps the disc slab. + // Overlap with the disc slab: resolve by MINIMUM penetration. if (p[1] < top && p[1] + HEIGHT > bottom && dist < s.radius + HALF_W && dist > 1e-4) { - const push = s.radius + HALF_W - dist; - const nx = rx / dist, nz = rz / dist; - p[0] += nx * push; p[2] += nz * push; - const inward = this._vel[0] * nx + this._vel[2] * nz; - if (inward < 0) { this._vel[0] -= inward * nx; this._vel[2] -= inward * nz; } + const upPen = top - p[1]; // lift feet onto the top + const radialPen = s.radius + HALF_W - dist; // push out past the wall + + // Auto-step: a grounded player walking into a low lip (≤ STEP_HEIGHT, + // e.g. the record's terraced groove tiers) steps up onto it even + // though the radial push would be smaller — mirrors the voxel ledge + // auto-step. Grounding may come from voxels (this.onGround survives + // the voxel pass only on blocks) or from a collider top found + // earlier in THIS loop (this.onGround is stale-false then). + const stepUp = upPen <= STEP_HEIGHT + 0.05 && + (this.onGround || groundCollider !== null) && this._vel[1] <= 0.001; + + if (stepUp || upPen <= radialPen) { + // Pull the center just inside the lip so next tick's stand check + // holds (voxel auto-step lands you on the ledge the same way). + const inTo = s.radius - HALF_W * 0.25; + if (dist > inTo) { + const k = inTo / dist; + p[0] = cx + rx * k; p[2] = cz + rz * k; + } + if (top > groundTop) { groundTop = top; groundCollider = c; } + } else { + // Wall is the nearest surface: radial push-out. + const nx = rx / dist, nz = rz / dist; + p[0] += nx * radialPen; p[2] += nz * radialPen; + const inward = this._vel[0] * nx + this._vel[2] * nz; + if (inward < 0) { this._vel[0] -= inward * nx; this._vel[2] -= inward * nz; } + } } } else { // AABB collider: push out along the minimum-penetration axis. diff --git a/src/worldgen/buildBooth.ts b/src/worldgen/buildBooth.ts index c8d156a..dc01ada 100644 --- a/src/worldgen/buildBooth.ts +++ b/src/worldgen/buildBooth.ts @@ -95,6 +95,15 @@ export function buildShell(w: IVoxelWorld): void { for (const sy of [WALL_TOP - 28, WALL_TOP - 16]) for (const sx of [hx + 2, hx + 9]) setVox(w, sx, sy, BACK_Z0 - 1, T.screw); } + + // Ceiling with recessed warm lamps (concept-art pass): the booth is a lit + // room, not an open void. Slab sits on the wall tops; six 3x3 amber lamp + // panels inset flush with its underside (main.ts parks matching point + // lights under them). + box(w, 0, WALL_TOP + 1, 0, WORLD_X - 1, WALL_TOP + 2, BACK_Z1, T.ply); + for (const lx of [112, 224, 336]) + for (const lz of [70, 160]) + box(w, lx - 1, WALL_TOP + 1, lz - 1, lx + 1, WALL_TOP + 1, lz + 1, T.ledA); } // ============================================================================= @@ -121,9 +130,26 @@ function buildDeck(w: IVoxelWorld, f: DeckFootprint, a: DeckAnchors, rng: () => // Platter well: recessed empty pit for Lane D's rotating platter. const wellR = PLATTER.radius + 2; cylinderY(w, f.spindleX, f.spindleZ, Y_PLINTH_TOP - 6, PLINTH_Y1, wellR, T.air); + // Raise the outer seam ring (between the platter edge and the well wall) to + // one voxel below the plinth top: a fallen player just steps back out. The + // full-depth ring was a 7-deep inescapable trench (integration fix). + cylinderY(w, f.spindleX, f.spindleZ, Y_PLINTH_TOP - 6, Y_PLINTH_TOP - 2, wellR, T.steel); + cylinderY(w, f.spindleX, f.spindleZ, Y_PLINTH_TOP - 6, Y_PLINTH_TOP - 2, PLATTER.radius, T.air); // Chrome spindle stub at the well centre (1-voxel base + short post). box(w, f.spindleX, Y_PLINTH_TOP - 6, f.spindleZ, f.spindleX, Y_PLINTH_TOP - 4, f.spindleZ, T.chrome); + // Rim crumble (concept-art pass): scattered debris — vinyl chips, dust, + // a loose screw — banked around the well mouth on the plinth top. + for (let i = 0; i < 26; i++) { + const ang = rng() * Math.PI * 2; + const rad = wellR + 1 + rng() * 4; + const dx = Math.round(f.spindleX + Math.cos(ang) * rad); + const dz = Math.round(f.spindleZ + Math.sin(ang) * rad); + if (dx <= f.minX + 1 || dx >= f.maxX - 1 || dz <= f.minZ + 1 || dz >= f.maxZ - 1) continue; + const pick = rng(); + setVox(w, dx, Y_PLINTH_TOP, dz, pick < 0.5 ? T.dust : pick < 0.85 ? (isB ? T.vinylK : T.vinylB) : T.screw); + } + // --- deck-top furniture (voxel, non-moving) --- // start/stop press-plate recess (4x4x1) — Lane D button. box(w, a.startStop[0] - 2, PLINTH_Y1, a.startStop[2] - 2, a.startStop[0] + 1, PLINTH_Y1, a.startStop[2] + 1, T.air); @@ -190,10 +216,16 @@ export function buildMixer(w: IVoxelWorld, rng: () => number): void { } // Four channel strips: 3x3 knob-stem grid (back) + fader alley (front). - for (const cx of MIXER.channelX) { - for (let gx = -1; gx <= 1; gx++) for (let gz = -1; gz <= 1; gz++) - // top row = silver gain trims, lower rows = black EQ knobs - setVox(w, cx + gx * 2, MIXER.faceTopY, M.maxZ - 20 + gz * 2, gz === -1 ? T.knobS : T.knobK); + // Knob caps get a colorful club-mixer palette (concept-art pass): reds, + // whites, golds and a few glowing LED caps popping out of the black face. + const knobPalette = [T.knobK, T.knobK, T.cableR, T.cableW, T.gold, T.ledB, T.ledG, T.ledA]; + for (let ci = 0; ci < MIXER.channelX.length; ci++) { + const cx = MIXER.channelX[ci]; + for (let gx = -1; gx <= 1; gx++) for (let gz = -1; gz <= 1; gz++) { + // top row = silver gain trims, lower rows = colorful EQ knobs + const cap = gz === -1 ? T.knobS : knobPalette[(ci * 5 + (gx + 1) * 3 + (gz + 1) * 7) % knobPalette.length]; + setVox(w, cx + gx * 2, MIXER.faceTopY, M.maxZ - 20 + gz * 2, cap); + } // fader alley: 14 long (Z) x 3 wide (X) x 5 deep — Lane D adds the sled/cap. box(w, cx - 1, MIXER.faceTopY - 5, M.minZ + 12, cx + 1, MIX_Y1, M.minZ + 25, T.air); }