- commit
- 3560b68744224d8155bc134c2bfa7586982a4bf3
- parent
- 7f8c43e3f83b01fcf52363801273a832c4c8360e
- author
- Dami <dami.etoile@gmail.com>
| M | progress.md | 52 | ++++++++++++++++++++- |
| A | tools/ | 38 | +++++++++++++++ |
| A | tools/ | 75 | ++++++++++++++++++++++++++++++ |
| A | tools/ | 37 | +++++++++++++++ |
| A | tools/ | 71 | ++++++++++++++++++++++++++++ |
@@ -721,7 +721,57 @@ milestones, photo mode, per-biome fog/lighting tints, destructible decor revival
721721 (unblocks at M10 — physics gives decor something to collide with; user's722722 pop-and-fling answer from the design round still stands).723723 724-## HANDOFF — next agent starts at M6 (see ROADMAP above)724+## HANDOFF — next agent starts MID-M11 (jump set-piece next)725+726+STATE (2026-08-08 end): M1–M10 done. M11 mid-flight, all green (65 tests):727+smooth ribbon collision is the default channel law; Marble Blast feel live728+(gravity −20, authority accel, curvature braking, restitution 0.35); per-tile729+modifier channel live (pieceFamilies + planner zone mods → rail stations →730+assist law); FIRST set-piece live: boost-chute (descents ≥1.0 get a boost zone).731+Physics gates: tests/physics_test.ts (7 seeds, survivability + trajectory732+determinism). Rebuild wasm after any shim.c change: ./tools/build_physics.sh.733+Client: deno task dev, /?endless=1 (?phys=0 = kinematic fallback). Read734+docs/physics.md (research + all collision lessons) before touching proxies.735+736+NEXT WORK, IN ORDER (detailed plans in the ROADMAP section + docs/physics.md):737+738+1. JUMP set-piece: planner opens a real 1–2 track-cell channel gap on long739+ straight runs (new clause: gap after a boost zone; claim the flight band;740+ ribbon strips already break naturally — absence of stations IS the gap); size741+ the catch with trajectory math at plan time (launch v from the boost target ≈742+ 6·speedScale, flight t = gap·2/vh, drop = ½·20·t²; catch = widened profile743+ rings or a cup-style pan + funnel walls at the landing); add a named744+ survivability scenario at min/max entry speeds. SPECTACLE: pad placements at745+ the lip so it reads.746+2. Propeller + shock zone kinds: PlanMod.kind union grows; propeller =747+ gravityScale ~0.35 during flight (needs a per-zone gravity override in the748+ client eachStep — same pattern as boost's extra pull); shock = marble749+ restitution override near hard landings (needs a shim setter750+ ph_marble_restitution(float)).751+3. Bump lips: re-admit family "bump" in endless WFC ONLY inside boost zones (wfc752+ exclude callback already exists in world.ts) — at boost speeds the bump crest753+ is a launch, caught like the jump.754+4. Branch respawns: enable diamond reservations in endless (planFeatures755+ currently skipped — run it on island runs' straight windows), alt centerline756+ → second rail per branch, respawn picks the branch not yet ridden. Loop mode757+ is the visual testbed for branch correctness.758+5. Pad visuals for all zones (placements at zone cells so set-pieces read on759+ screen), then M12 (see ROADMAP: island verticality + gravity760+ rotation/bouncers/icy over the modifier channel), then M13 freeze (endless761+ stream goldens: pin segment hashes for fixed seeds).762+763+DEBUG LOOP (vendored in tools/physics-debug/, run with deno --allow-read; paths764+inside are absolute — fix to relative when first used):765+766+- phys-deep.ts <seed>: drives the real wasm marble over 8 segments with the767+ SHARED assist law; prints FELL/WEDGED with rail context. Keep its assist loop768+ in sync with tests/physics_test.ts (one law, one truth).769+- phys-contact.ts <seed> x y z: which slab/box proxies touch a stuck marble770+ (meshes skipped — extend if needed).771+- ribbon-repro.ts / ribbon-bisect.ts: isolated mesh-collision repros (synthetic772+ straight/arc; square-vs-ribbon winding bisect). Method: never guess — repro in773+ isolation, instrument, fix the law, rerun gates. Every real bug becomes a774+ named scenario in tests/scenario_test.ts.725775 726776 Before anything: `deno task test` (57 green), `genVersion()` (ga265f1373eed),727777 read "M5 systems" + the ROADMAP. Work the milestones IN ORDER; do not start a@@ -0,0 +1,38 @@
1+import { EndlessWorld } from "/Users/dami/Developer/marbles/src/gen/endless/world.ts";2+import { channelProxies, islandProxies } from "/Users/dami/Developer/marbles/src/gen/endless/proxies.ts";3+const catalog = { packs: [{ slug: "nature-kit", ids: JSON.parse(Deno.readTextFileSync("/Users/dami/Developer/marbles/assets/nature-kit.manifest.json")).pieces.map((p: { id: string }) => p.id) }] };4+const seed = Deno.args[0];5+const M = Deno.args.slice(1, 4).map(Number);6+const R = 0.21;7+const w = new EndlessWorld(seed, catalog);8+for (let i = 0; i < 8; i++) {9+ const seg = w.get(i);10+ const all = [...channelProxies(seg.path), ...seg.islands.flatMap((x) => islandProxies(x.placements))];11+ for (const p of all) {12+ if (p.kind === "mesh") continue;13+ // Transform marble into slab local frame (yaw then pitch inverse).14+ const dx = M[0] - p.center[0], dy = M[1] - p.center[1], dz = M[2] - p.center[2];15+ const yaw = p.kind === "slab" ? p.yaw : 0;16+ const pitch = p.kind === "slab" ? p.pitch : 0;17+ // inverse yaw18+ let lx = Math.cos(-yaw) * dx + Math.sin(-yaw) * dz * -1;19+ let lz = -Math.sin(yaw) * dx * -1 + Math.cos(yaw) * dz;20+ // careful: rotation about y by yaw maps local→world: wx = cos? use standard: world = Ry(yaw)·local where Ry: x' = x cos + z sin, z' = -x sin + z cos... our convention: local z → (sin yaw, cos yaw). So world = [x cos yaw + z sin yaw, y, -x sin yaw + z cos yaw]. Inverse: local = [wx cos yaw - wz sin yaw, y, wx sin yaw + wz cos yaw].21+ lx = dx * Math.cos(yaw) - dz * Math.sin(yaw);22+ lz = dx * Math.sin(yaw) + dz * Math.cos(yaw);23+ let ly = dy;24+ // inverse pitch about x: local = [x, y cos p - z ... ] Rx(pitch): y' = y cos - z sin, z' = y sin + z cos. Inverse: y = ly cos + lz2 sin...25+ const cy2 = ly * Math.cos(pitch) + lz * Math.sin(pitch) * -1 * -1;26+ const cz2 = -ly * Math.sin(pitch) * -1 * -1 + lz * Math.cos(pitch);27+ ly = ly * Math.cos(pitch) + lz * Math.sin(pitch);28+ lz = cz2 === lz ? lz : (-(M[1]-p.center[1]) * Math.sin(pitch) + lz * Math.cos(pitch));29+ void cy2;30+ const ex = Math.max(Math.abs(lx) - p.half[0], 0);31+ const ey = Math.max(Math.abs(ly) - p.half[1], 0);32+ const ez = Math.max(Math.abs(lz) - p.half[2], 0);33+ const d = Math.hypot(ex, ey, ez);34+ if (d < R) {35+ console.log(`${p.kind} CONTACT d=${d.toFixed(3)} c=(${p.center.map((v)=>v.toFixed(2))}) h=(${p.half.map((v)=>v.toFixed(2))}) yaw=${(p.kind==="slab"?p.yaw:0).toFixed(2)} pitch=${(p.kind==="slab"?p.pitch:0).toFixed(2)}`);36+ }37+ }38+}@@ -0,0 +1,75 @@
1+// Headless deep-drive: find where cua-seed-1 wedges at segment 4+.2+import { EndlessWorld } from "/Users/dami/Developer/marbles/src/gen/endless/world.ts";3+import { channelProxies, islandProxies } from "/Users/dami/Developer/marbles/src/gen/endless/proxies.ts";4+import { assistForce, PhysicsEngine, type PhysicsModule } from "/Users/dami/Developer/marbles/src/client/physics/engine.ts";5+import { segLength, segPoint } from "/Users/dami/Developer/marbles/src/gen/geom.ts";6+const catalog = { packs: [{ slug: "nature-kit", ids: JSON.parse(Deno.readTextFileSync("/Users/dami/Developer/marbles/assets/nature-kit.manifest.json")).pieces.map((p: { id: string }) => p.id) }] };7+const m = await ((await import("/Users/dami/Developer/marbles/static/dist/box3d.mjs")).default as () => Promise<PhysicsModule>)();8+const w = new EndlessWorld(Deno.args[0] ?? "cua-seed-1", catalog);9+const engine = new PhysicsEngine(m);10+const rail: { p: number[]; t: number[]; s: number }[] = [];11+let acc = 0;12+for (let i = 0; i < 8; i++) {13+ const seg = w.get(i);14+ engine.addSegment(i, [...channelProxies(seg.path), ...seg.islands.flatMap((x) => islandProxies(x.placements))]);15+ for (const part of seg.path.segments.flatMap((q) => q.curve)) {16+ const len = segLength(part);17+ const steps = Math.max(1, Math.ceil(len / 0.5));18+ for (let j = 0; j < steps; j++) {19+ const a = segPoint(part, j / steps);20+ const b = segPoint(part, Math.min(1, (j + 1) / steps));21+ const d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];22+ const l = Math.hypot(...d) || 1;23+ rail.push({ p: [...a], t: d.map((v) => v / l), s: acc });24+ acc += l;25+ }26+ }27+}28+engine.spawnMarble([rail[0].p[0], rail[0].p[1] + 0.4, rail[0].p[2]], [rail[0].t[0] * 2.2, 0, rail[0].t[2] * 2.2]);29+let nearest = 0;30+let lastS = 0, stall = 0;31+for (let step = 0; step < 240 * 60; step++) {32+ engine.step(1 / 60, () => {33+ const p = engine.state().pos;34+ let bd = Infinity;35+ for (let i = Math.max(0, nearest - 25); i <= Math.min(rail.length - 1, nearest + 50); i++) {36+ const r = rail[i].p;37+ const d = (r[0] - p[0]) ** 2 + (r[1] - p[1]) ** 2 + (r[2] - p[2]) ** 2;38+ if (d < bd) { bd = d; nearest = i; }39+ }40+ const st = engine.state();41+ const t = rail[nearest].t as [number, number, number];42+ let climbTy = t[1];43+ let curveAhead = 0;44+ for (let k2 = 1; k2 <= 8; k2++) {45+ const look = rail[nearest + k2]?.t;46+ const prev = rail[nearest + k2 - 1]?.t;47+ if (!look || !prev) break;48+ climbTy = Math.max(climbTy, look[1]);49+ const dot = Math.min(1, look[0]*prev[0] + look[1]*prev[1] + look[2]*prev[2]);50+ curveAhead += Math.acos(Math.max(-1, dot));51+ }52+ const f = assistForce(st.vel, t, engine.mass, climbTy, curveAhead);53+ if (f) engine.force(f[0], f[1], f[2]);54+ });55+ {56+ const stq = engine.state();57+ const g = globalThis as { fell?: boolean };58+ if (!g.fell && stq.pos[1] < rail[nearest].p[1] - 6) {59+ g.fell = true;60+ console.log(61+ `FELL at s=${rail[nearest].s.toFixed(1)} pos=(${stq.pos.map((v) => v.toFixed(2))}) ` +62+ `rail=(${rail[nearest].p.map((v) => v.toFixed(2))}) tan=(${rail[nearest].t.map((v) => v.toFixed(2))}) ` +63+ `vel=(${stq.vel.map((v) => v.toFixed(2))})`,64+ );65+ }66+ }67+ const s = rail[nearest].s;68+ if (s > lastS + 0.4) { lastS = s; stall = 0; } else stall++;69+ if (stall > 15 * 60) {70+ const st = engine.state();71+ console.log(`WEDGED s=${s.toFixed(1)} pos=(${st.pos.map((v)=>v.toFixed(4))}) rail=(${rail[nearest].p.map((v)=>v.toFixed(2))}) tan=(${rail[nearest].t.map((v)=>v.toFixed(2))})`);72+ break;73+ }74+}75+console.log("final s:", rail[nearest].s.toFixed(1), "/", acc.toFixed(1));@@ -0,0 +1,37 @@
1+import { PhysicsEngine, type PhysicsModule } from "/Users/dami/Developer/marbles/src/client/physics/engine.ts";2+const factory = (await import("/Users/dami/Developer/marbles/static/dist/box3d.mjs")).default as () => Promise<PhysicsModule>;3+4+async function drop(name: string, vertices: number[], indices: number[]) {5+ const m = await factory();6+ const engine = new PhysicsEngine(m);7+ engine.addSegment(0, [{ kind: "mesh", vertices, indices }]);8+ engine.spawnMarble([0.5, 1, 0.2], [0, 0, 0]);9+ for (let i = 0; i < 120; i++) engine.step(1 / 60);10+ const st = engine.state();11+ console.log(`${name}: y=${st.pos[1].toFixed(3)} ${st.pos[1] > 0 ? "OK" : "FELL"}`);12+}13+14+// 1. The known-good square, via the engine path (winding [0,2,1]).15+await drop("square-engine", [-2, 0, -2, 2, 0, -2, 2, 0, 2, -2, 0, 2], [0, 2, 1, 0, 3, 2]);16+// 2. Two-ring flat strip, 3 profile points each, ribbon-style indices.17+// Rings along +x (travel), profile along +z. ribbonOf winding:18+// (a, b+1, b), (a, a+1, b+1) per quad.19+{20+ const v: number[] = [];21+ for (const x of [0, 2]) for (const z of [-1, 0, 1]) v.push(x, 0, z);22+ const N = 3;23+ const idx: number[] = [];24+ const a = 0, b = N;25+ for (let j = 0; j < N - 1; j++) {26+ idx.push(a + j, b + j + 1, b + j);27+ idx.push(a + j, a + j + 1, b + j + 1);28+ }29+ await drop("mini-ribbon", v, idx);30+ // 3. Same but opposite winding.31+ const idx2: number[] = [];32+ for (let j = 0; j < N - 1; j++) {33+ idx2.push(a + j, b + j, b + j + 1);34+ idx2.push(a + j, b + j + 1, a + j + 1);35+ }36+ await drop("mini-ribbon-flip", v, idx2);37+}@@ -0,0 +1,71 @@
1+// Isolated ribbon repro: synthetic straight + arc paths, marble dropped on2+// the ribbon ONLY (no boxes, no cups). Where does collision break?3+import { channelProxies } from "/Users/dami/Developer/marbles/src/gen/endless/proxies.ts";4+import { PhysicsEngine, type PhysicsModule } from "/Users/dami/Developer/marbles/src/client/physics/engine.ts";5+import type { LoopPath } from "/Users/dami/Developer/marbles/src/shared/types.ts";6+7+const factory = (await import("/Users/dami/Developer/marbles/static/dist/box3d.mjs")).default as () => Promise<PhysicsModule>;8+9+const straight: LoopPath = {10+ segments: [{11+ placementIndex: -1,12+ curve: [{ kind: "line", a: [0, 0.5, 0], b: [12, 0.5, 0] }],13+ length: 12,14+ }],15+ totalLength: 12,16+};17+const arc: LoopPath = {18+ segments: [{19+ placementIndex: -1,20+ curve: [{21+ kind: "arc",22+ center: [0, 0.5, 3],23+ radius: 3,24+ a0: -Math.PI / 2,25+ a1: 0,26+ y0: 0.5,27+ y1: 0.5,28+ }],29+ length: 4.7,30+ }],31+ totalLength: 4.7,32+};33+34+for (const [name, path, spawn, vel] of [35+ ["straight", straight, [1, 1.2, 0], [2, 0, 0]],36+ ["arc", arc, [0.3, 1.2, 0.1], [0, 0, 2]],37+] as const) {38+ const m = await factory();39+ const engine = new PhysicsEngine(m);40+ const proxies = channelProxies(path, true);41+ const mesh = proxies.find((p) => p.kind === "mesh");42+ console.log(`${name}: proxies=${proxies.length} meshVerts=${mesh && mesh.kind === "mesh" ? mesh.vertices.length / 3 : 0} tris=${mesh && mesh.kind === "mesh" ? mesh.indices.length / 3 : 0}`);43+ engine.addSegment(0, proxies);44+ engine.spawnMarble(spawn as [number, number, number], vel as [number, number, number]);45+ for (let i = 0; i < 300; i++) engine.step(1 / 60);46+ const st = engine.state();47+ console.log(` after 5s: pos=(${st.pos.map((v) => v.toFixed(2))}) — ${st.pos[1] > 0 ? "RESTING/ROLLING ✓" : "FELL THROUGH ✗"}`);48+}49+50+// Diagnose the arc: first triangle normal + ring layout.51+{52+ const proxies = channelProxies(arc, true);53+ const mesh = proxies.find((p) => p.kind === "mesh");54+ if (mesh && mesh.kind === "mesh") {55+ const v = mesh.vertices;56+ const t = mesh.indices;57+ const p = (i: number) => [v[i * 3], v[i * 3 + 1], v[i * 3 + 2]];58+ console.log("ring0 first3:", p(0), p(1), p(2));59+ console.log("ring1 first3:", p(10), p(11), p(12));60+ const [i0, i1, i2] = [t[0], t[1], t[2]];61+ const A = p(i0), B = p(i1), C = p(i2);62+ const e1 = [B[0] - A[0], B[1] - A[1], B[2] - A[2]];63+ const e2 = [C[0] - A[0], C[1] - A[1], C[2] - A[2]];64+ const n = [65+ e1[1] * e2[2] - e1[2] * e2[1],66+ e1[2] * e2[0] - e1[0] * e2[2],67+ e1[0] * e2[1] - e1[1] * e2[0],68+ ];69+ console.log(`tri0 idx=(${i0},${i1},${i2}) normalY=${n[1].toFixed(4)}`);70+ }71+}