diff --git a/src/course/seesaw-lab.ts b/src/course/seesaw-lab.ts new file mode 100644 index 0000000..1ee7eae --- /dev/null +++ b/src/course/seesaw-lab.ts @@ -0,0 +1,33 @@ +/** + * SEE-SAW LAB — a throwaway test course (?course=seesaw) for tuning the see-saw + * obstacle in isolation, away from the noise of a real level. + * + * Design that dodges the wedge problem: you roll along a RAISED DECK straight + * onto the plank's near end (both at y1.8 — a flush seam, no ramp to misalign), + * teeter across, and roll off the FAR end down onto the floor (a drop is never a + * wedge). A fan gives the momentum to carry over the pivot. + * + * Knobs to tune for feel (John's call): the see-saw's `maxTilt` (how much it + * rocks) and `restTilt` (start it level or pre-tipped), and the fan `force`. + */ +import type { CourseSpec } from './spec' + +export const SEESAW_LAB: CourseSpec = { + name: 'See-saw Lab', + spawn: [0, 2.3, 18], + finish: { xMin: -6, xMax: 6, zMin: -32, zMax: -20, yMax: 4 }, + boxes: [ + // base floor — catches the drop off the plank and carries the finish + { pos: [0, -0.5, -10], half: [10, 0.5, 32], color: '#efe3c6', roughness: 1 }, + // raised deck you roll along into the plank's near end (top y1.8) + { pos: [0, 0.9, 8], half: [4, 0.9, 12], color: '#f7edd2', cast: true }, + // finish pad on the floor, past the drop + { pos: [0, 0.4, -26], half: [6, 0.4, 4], color: '#FF6EB4', cast: true }, + ], + machines: [ + // fan on the deck — momentum to carry over the see-saw's pivot + { kind: 'fan', cfg: { id: 'sl-fan', position: [0, 2.2, 10], direction: [0, 0, -1], force: 30, range: 16, spread: 4 } }, + // the see-saw: deck flush into the +Z end (z-4), drops off the -Z end (z-12) to the floor + { kind: 'seesaw', cfg: { id: 'sl-seesaw', position: [0, 1.6, -8], plankSize: [8, 0.4, 3.5], tipAxis: 'z', maxTilt: 0.35 } }, + ], +} diff --git a/src/course/spec.ts b/src/course/spec.ts index de3d421..0dc3243 100644 --- a/src/course/spec.ts +++ b/src/course/spec.ts @@ -24,6 +24,7 @@ import type { SurfaceName, } from '../machine/index' import { TOASTER_ALLEY } from './toaster-alley' +import { SEESAW_LAB } from './seesaw-lab' export interface FinishBox { xMin: number; xMax: number; zMin: number; zMax: number; yMax: number } @@ -168,4 +169,5 @@ export function buildCourseFromSpec( * game.ts and is the default). Load with `?course=`. */ export const COURSES: Record = { toaster: TOASTER_ALLEY, + seesaw: SEESAW_LAB, } diff --git a/src/machine/parts.ts b/src/machine/parts.ts index e429d7f..5735c4b 100644 --- a/src/machine/parts.ts +++ b/src/machine/parts.ts @@ -262,19 +262,30 @@ export function createSpringBoot(world: World, cfg: SpringBootConfig): MachinePa export interface SeeSawConfig { id: string position: Vec3 - /** Plank [length(x), thickness(y), width(z)]. Default [8, 0.4, 2.4]. */ + /** Plank [length, thickness, width]. Length runs along the crossing axis + * (see tipAxis); width is the other horizontal. Default [8, 0.4, 2.4]. */ plankSize?: Vec3 + /** Which horizontal axis you CROSS along: 'x' (default, tips left↔right) or + * 'z' (tips fore↔aft — for a course that runs along Z). */ + tipAxis?: 'x' | 'z' /** Max tilt each way, radians. Default 0.5 (~29°). */ maxTilt?: number - /** Initial tilt (rad, about Z). Negative = +X end starts low. Default 0 (level). */ + /** Initial tilt (rad). Sign follows the tip axis; 0 = level. Default 0. */ restTilt?: number } export function createSeeSaw(world: World, cfg: SeeSawConfig): MachinePart { const { physics, rapier, scene } = world const pos = asVec3(cfg.position) - const [px, py, pz] = cfg.plankSize ?? [8, 0.4, 2.4] + const [len, py, wid] = cfg.plankSize ?? [8, 0.4, 2.4] const maxTilt = cfg.maxTilt ?? 0.5 + // Crossing axis → plank footprint + the axis it rocks about. 'z' runs the long + // side along Z and tips about X, so you roll across it in a Z-flowing course. + const tipZ = cfg.tipAxis === 'z' + const hx = (tipZ ? wid : len) * 0.5 + const hz = (tipZ ? len : wid) * 0.5 + const tiltAxis = tipZ ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 0, 1) + const jointAxis = tipZ ? { x: 1, y: 0, z: 0 } : { x: 0, y: 0, z: 1 } const group = new THREE.Group() scene.add(group) @@ -295,7 +306,7 @@ export function createSeeSaw(world: World, cfg: SeeSawConfig): MachinePart { // dynamic plank (optionally pre-tilted so delivery direction is deterministic) const restTilt = cfg.restTilt ?? 0 - const q0 = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), restTilt) + const q0 = new THREE.Quaternion().setFromAxisAngle(tiltAxis, restTilt) const plankBody = physics.createRigidBody( rapier.RigidBodyDesc.dynamic() .setTranslation(pos.x, pos.y, pos.z) @@ -303,22 +314,22 @@ export function createSeeSaw(world: World, cfg: SeeSawConfig): MachinePart { .setAngularDamping(0.6), ) physics.createCollider( - rapier.ColliderDesc.cuboid(px * 0.5, py * 0.5, pz * 0.5) + rapier.ColliderDesc.cuboid(hx, py * 0.5, hz) .setDensity(0.4) .setFriction(1.0), plankBody, ) - // revolute joint about Z → the plank tips in the X/Y plane (blob rolls along X) + // revolute joint about the tip axis → the plank rocks so you roll across it const jd = rapier.JointData.revolute( - { x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 1 }, + { x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 0 }, jointAxis, ) jd.limitsEnabled = true jd.limits = [-maxTilt, maxTilt] physics.createImpulseJoint(jd, anchor, plankBody, true) const plank = new THREE.Mesh( - new THREE.BoxGeometry(px, py, pz), + new THREE.BoxGeometry(hx * 2, py, hz * 2), new THREE.MeshStandardMaterial({ color: '#a1887f', roughness: 0.7 }), ) plank.castShadow = true