feat(machine): see-saw tipAxis option + a tuning lab course

createSeeSaw gains `tipAxis: 'x'|'z'` (default 'x' = unchanged). 'z' rocks the
plank fore↔aft so you can cross it in a Z-flowing course — the missing piece
that kept the see-saw out of every course (it was X-crossing only).

Adds ?course=seesaw, a throwaway lab for tuning the see-saw in isolation:
raised deck flush into the plank's near end (no ramp to misalign), teeter
across, drop off the far end onto the floor (a drop is never a wedge), fan for
momentum. Browser-verified it completes end-to-end.

Feel is still open (John's call): at crossing speed the plank barely pitches
(reads as a stable wobble-bridge). Knobs left exposed — maxTilt/restTilt on the
part, plank density/damping in the factory, fan force in the lab — to tune
"does it feel good" together. NOT wired into any shipped course.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-19 16:00:55 +10:00
parent 67921925f5
commit 85c29111fa
3 changed files with 54 additions and 8 deletions

33
src/course/seesaw-lab.ts Normal file
View File

@ -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 } },
],
}

View File

@ -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=<name>`. */
export const COURSES: Record<string, CourseSpec> = {
toaster: TOASTER_ALLEY,
seesaw: SEESAW_LAB,
}

View File

@ -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 leftright) or
* 'z' (tips foreaft 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