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 <noreply@anthropic.com>
This commit is contained in:
parent
f88be60e60
commit
49cab1f69b
@ -75,6 +75,36 @@ lanes landed. Every cross-lane glue decision is listed here.
|
|||||||
auto-start, HUD banner + tracker 5/5, AudioContext running.
|
auto-start, HUD banner + tracker 5/5, AudioContext running.
|
||||||
- The platter-well gap ring behaves as a trench (collider pushes, no clipping).
|
- 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)
|
## Not yet done (deploy phase)
|
||||||
|
|
||||||
Run `/ship-check` before exposing anything publicly; deploy per deploy-map
|
Run `/ship-check` before exposing anything publicly; deploy per deploy-map
|
||||||
|
|||||||
@ -30,22 +30,22 @@ export function createRenderer(container: HTMLElement): Renderer {
|
|||||||
container.appendChild(renderer.domElement);
|
container.appendChild(renderer.domElement);
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
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.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);
|
const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000);
|
||||||
camera.position.set(0, 4, 8);
|
camera.position.set(0, 4, 8);
|
||||||
|
|
||||||
// Warm key light (parallel; direction = position, target at origin).
|
// 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);
|
key.position.set(70, 130, 45);
|
||||||
scene.add(key);
|
scene.add(key);
|
||||||
// Cool sky / warm ground hemispheric fill.
|
// Warm-cream sky / warm ground hemispheric fill — a lamp-lit room, not a void.
|
||||||
const hemi = new THREE.HemisphereLight(0x9fb4d8, 0x4a3a28, 0.65);
|
const hemi = new THREE.HemisphereLight(0xd8c2a4, 0x4a3a28, 1.0);
|
||||||
scene.add(hemi);
|
scene.add(hemi);
|
||||||
// Small ambient lift so deep AO corners never go pure black.
|
// 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();
|
const atlas = buildAtlas();
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,19 @@ const TAU = PLATTER.spinUpSeconds * 0.43;
|
|||||||
|
|
||||||
export type Speed = 33 | 45;
|
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 {
|
export class Platter extends MachineBase {
|
||||||
readonly deck: 'A' | 'B';
|
readonly deck: 'A' | 'B';
|
||||||
private readonly cx: number;
|
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
|
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 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)
|
private readonly _vel: Vec3 = [0, 0, 0]; // velocityAt scratch (reused, no alloc)
|
||||||
|
|
||||||
constructor(deck: 'A' | 'B', spindleX: number, spindleZ: number) {
|
constructor(deck: 'A' | 'B', spindleX: number, spindleZ: number) {
|
||||||
@ -43,29 +55,31 @@ export class Platter extends MachineBase {
|
|||||||
this.cx = spindleX;
|
this.cx = spindleX;
|
||||||
this.cz = spindleZ;
|
this.cz = spindleZ;
|
||||||
|
|
||||||
// ---- Collider: one +Y cylinder spanning the platter well floor → vinyl
|
// ---- Colliders: a stack of +Y cylinders — the record is a terraced
|
||||||
// surface, so the spinning body pushes the player out of the well's gap
|
// amphitheater (groove tiers rising toward the spindle tower). Each tier
|
||||||
// ring too (not just off the top). Top face = Y_RECORD_TOP exactly.
|
// rises 1 voxel so the player can auto-step up a SPINNING staircase.
|
||||||
const wellFloor = Y_PLINTH_TOP - PLATTER.wellDepth; // 74
|
// The outer tier's top face = Y_RECORD_TOP exactly (step-on from the
|
||||||
const halfH = (Y_RECORD_TOP - wellFloor) / 2; // 3.5
|
// plinth unchanged); the base cylinder reaches the well floor so the
|
||||||
const cy = wellFloor + halfH; // 77.5
|
// spinning body still pushes players out of the well's gap ring.
|
||||||
this.collider = {
|
// Surface velocity = ω × r (rigid rotation about +Y), shared scratch —
|
||||||
id: this.id,
|
// consume before the next velocityAt call (CONTRACTS §4).
|
||||||
shape: { kind: 'cylinder', center: [this.cx, cy, this.cz], radius: PLATTER.radius, halfHeight: halfH },
|
const velocityAt = (x: number, _y: number, z: number): Vec3 => {
|
||||||
// Surface velocity = ω × r (rigid rotation about +Y). Zero when stopped.
|
const v = this._vel;
|
||||||
// Writes into a reused scratch (no per-call alloc in the ride hot path).
|
if (this.omega === 0) { v[0] = v[1] = v[2] = 0; return v; }
|
||||||
// Contract: consume the value before the next velocityAt call on THIS
|
v[0] = this.omega * (z - this.cz);
|
||||||
// collider — Lane B reads it immediately each tick (CONTRACTS §4).
|
v[1] = 0;
|
||||||
velocityAt: (x: number, _y: number, z: number): Vec3 => {
|
v[2] = -this.omega * (x - this.cx);
|
||||||
const v = this._vel;
|
return v;
|
||||||
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.buildVisuals(deck);
|
||||||
this.spin.position.set(this.cx, 0, this.cz);
|
this.spin.position.set(this.cx, 0, this.cz);
|
||||||
@ -100,29 +114,42 @@ export class Platter extends MachineBase {
|
|||||||
const slipMat = blockMaterial(7); // slipmat_felt
|
const slipMat = blockMaterial(7); // slipmat_felt
|
||||||
this.spin.add(cylinderY(rr + 1.5, 0.5, slipMat, 0, Y_RECORD_TOP - 0.75, 0));
|
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 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 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 sideMat = blockMaterial(vinylId, { opacity: deck === 'A' ? 0.55 : 1 });
|
||||||
const recordBody = cylinderY(rr, 1.4, sideMat, 0, Y_RECORD_TOP - 0.7, 0);
|
const tierBottom = Y_RECORD_TOP - 1.4; // outer tier's underside on the slipmat
|
||||||
this.spin.add(recordBody);
|
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 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 topTex = recordTopTexture(vinylDef.tint, label, 'TC-00' + (deck === 'A' ? '1' : '2') + ' · TURNCRAFT');
|
||||||
const topMat = new THREE.MeshStandardMaterial({
|
const topMat = new THREE.MeshStandardMaterial({ map: topTex, roughness: 0.5, metalness: 0.05 });
|
||||||
map: topTex, roughness: 0.5, metalness: 0.05,
|
const top = new THREE.Mesh(new THREE.CircleGeometry(labelTier.r, 48), topMat);
|
||||||
transparent: deck === 'A', opacity: deck === 'A' ? 0.94 : 1,
|
|
||||||
});
|
|
||||||
const top = new THREE.Mesh(new THREE.CircleGeometry(rr, 64), topMat);
|
|
||||||
top.rotation.x = -Math.PI / 2; // face +Y
|
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);
|
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 chrome = blockMaterial(11); // chrome
|
||||||
const pin = cylinderY(0.8, 8, chrome, 0, Y_RECORD_TOP + 3.5, 0, 16);
|
for (let i = 3; i < TIERS.length; i++) {
|
||||||
this.spin.add(pin);
|
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) ----
|
// ---- Control surface (called by buttons / faders / win wiring) ----
|
||||||
|
|||||||
@ -11,8 +11,9 @@ import type { ColliderShape, KinematicCollider, Vec3 } from '../core/types';
|
|||||||
import { MachineBase } from './machine';
|
import { MachineBase } from './machine';
|
||||||
import { blockMaterial } from './util';
|
import { blockMaterial } from './util';
|
||||||
|
|
||||||
const HEAD_Y = Y_RECORD_TOP + 1.5; // headshell hovers just above the vinyl
|
const HEAD_Y = Y_RECORD_TOP + 2.6; // hovers above the vinyl, clearing the mid terrace
|
||||||
const R_INNER = 9; // headshell radius-from-spindle at inner groove
|
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_OUTER = PLATTER.recordRadius - 3; // outer groove
|
||||||
const R_REST = PLATTER.radius + 1; // parked just off the platter rim
|
const R_REST = PLATTER.radius + 1; // parked just off the platter rim
|
||||||
const TRACK_SECONDS = 240; // ~4 min for a full inward pass
|
const TRACK_SECONDS = 240; // ~4 min for a full inward pass
|
||||||
|
|||||||
21
src/main.ts
21
src/main.ts
@ -2,7 +2,7 @@
|
|||||||
// Wires the five lanes: engine (A), player (B), worldgen (C),
|
// Wires the five lanes: engine (A), player (B), worldgen (C),
|
||||||
// machines/interaction (D), audio/fx/ui (E).
|
// 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 { FIXED_DT, MAX_SUBSTEPS } from './core/constants';
|
||||||
import { createRenderer, VoxelWorld, ChunkManager } from './engine';
|
import { createRenderer, VoxelWorld, ChunkManager } from './engine';
|
||||||
import { WORLD_X, WORLD_Y, WORLD_Z } from './core/constants';
|
import { WORLD_X, WORLD_Y, WORLD_Z } from './core/constants';
|
||||||
@ -49,6 +49,16 @@ const questPos: QuestPositions = {
|
|||||||
const machines = createMachines(world, questPos);
|
const machines = createMachines(world, questPos);
|
||||||
for (const o of machines.getObject3Ds()) scene.add(o as Object3D);
|
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,
|
// 6 (+6.5). Player, then give machines the player view (walk-push faders,
|
||||||
// walk-press buttons — Lane D handoff friction #2).
|
// walk-press buttons — Lane D handoff friction #2).
|
||||||
const player = new PlayerController(world, {
|
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
|
if (steps === MAX_SUBSTEPS) acc = 0; // don't spiral after long stalls
|
||||||
audio.updateListener(player);
|
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();
|
chunks.update();
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
@ -131,4 +148,4 @@ requestAnimationFrame(frame);
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window { TURNCRAFT: unknown }
|
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 };
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { PLAYER, GRAVITY } from '../core/constants';
|
|||||||
import { bus } from '../core/events';
|
import { bus } from '../core/events';
|
||||||
import { createInputState, InputController, type InputState } from './input';
|
import { createInputState, InputController, type InputState } from './input';
|
||||||
import {
|
import {
|
||||||
resolveAxis, moveHorizontalWithStep, HALF_W, HEIGHT,
|
resolveAxis, moveHorizontalWithStep, HALF_W, HEIGHT, STEP_HEIGHT,
|
||||||
} from './collision';
|
} from './collision';
|
||||||
|
|
||||||
// --- Local feel tuning (NOT in core PLAYER; see HANDOFF.md if you re-tune). ---
|
// --- 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);
|
const dist = Math.hypot(rx, rz);
|
||||||
|
|
||||||
// Stand on top → record as a ground candidate (snapped after the loop).
|
// 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 (
|
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
|
p[1] <= top + STAND_UP_EPS && p[1] >= top - LAND_DOWN_EPS
|
||||||
) {
|
) {
|
||||||
if (top > groundTop) { groundTop = top; groundCollider = c; }
|
if (top > groundTop) { groundTop = top; groundCollider = c; }
|
||||||
continue;
|
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) {
|
if (p[1] < top && p[1] + HEIGHT > bottom && dist < s.radius + HALF_W && dist > 1e-4) {
|
||||||
const push = s.radius + HALF_W - dist;
|
const upPen = top - p[1]; // lift feet onto the top
|
||||||
const nx = rx / dist, nz = rz / dist;
|
const radialPen = s.radius + HALF_W - dist; // push out past the wall
|
||||||
p[0] += nx * push; p[2] += nz * push;
|
|
||||||
const inward = this._vel[0] * nx + this._vel[2] * nz;
|
// Auto-step: a grounded player walking into a low lip (≤ STEP_HEIGHT,
|
||||||
if (inward < 0) { this._vel[0] -= inward * nx; this._vel[2] -= inward * nz; }
|
// 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 {
|
} else {
|
||||||
// AABB collider: push out along the minimum-penetration axis.
|
// AABB collider: push out along the minimum-penetration axis.
|
||||||
|
|||||||
@ -95,6 +95,15 @@ export function buildShell(w: IVoxelWorld): void {
|
|||||||
for (const sy of [WALL_TOP - 28, WALL_TOP - 16])
|
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);
|
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.
|
// Platter well: recessed empty pit for Lane D's rotating platter.
|
||||||
const wellR = PLATTER.radius + 2;
|
const wellR = PLATTER.radius + 2;
|
||||||
cylinderY(w, f.spindleX, f.spindleZ, Y_PLINTH_TOP - 6, PLINTH_Y1, wellR, T.air);
|
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).
|
// 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);
|
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) ---
|
// --- deck-top furniture (voxel, non-moving) ---
|
||||||
// start/stop press-plate recess (4x4x1) — Lane D button.
|
// 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);
|
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).
|
// Four channel strips: 3x3 knob-stem grid (back) + fader alley (front).
|
||||||
for (const cx of MIXER.channelX) {
|
// Knob caps get a colorful club-mixer palette (concept-art pass): reds,
|
||||||
for (let gx = -1; gx <= 1; gx++) for (let gz = -1; gz <= 1; gz++)
|
// whites, golds and a few glowing LED caps popping out of the black face.
|
||||||
// top row = silver gain trims, lower rows = black EQ knobs
|
const knobPalette = [T.knobK, T.knobK, T.cableR, T.cableW, T.gold, T.ledB, T.ledG, T.ledA];
|
||||||
setVox(w, cx + gx * 2, MIXER.faceTopY, M.maxZ - 20 + gz * 2, gz === -1 ? T.knobS : T.knobK);
|
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.
|
// 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);
|
box(w, cx - 1, MIXER.faceTopY - 5, M.minZ + 12, cx + 1, MIX_Y1, M.minZ + 25, T.air);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user