guts/web/js/world/dev.html
jing 689c478a7f [lane A] Round 1: the canal v0 — spline, streaming tube, scanner wall, arenas
Implements THE WORLD CONTRACT (TECH.md) in web/js/world/; drop-in for the stub.
boot.js now runs this on C's L2_esophagus (3600u, hash cefc4f83), zero console errors.

- spline.js: centreline + arclength LUTs + parallel-transport frames + radius law +
  peristalsis phase + project() + hash(). No three.js import, so qa can run its
  14-assertion selfcheck headless.
- curviness is solved from a curvature budget, not authored as an amplitude: the pinch
  guard caught the tube folding through itself on a wide+curvy join (turn radius 9.9 vs
  tube radius 17.2). C can no longer author a pinch.
- peristalsis: global OMEGA, k(s) = OMEGA/flow(s), phase K(s) = integral k ds. A crest
  therefore travels at exactly the local flow speed, so "surf the crest" == "ride the
  current". Esophagus wave amp 0.9 -> 1.4 (measured): moves wallRho for Lane B.
- tube.js: chunked streaming, seams aligned to biome joins, arenas own their s-span.
- arena.js: displaced icosphere shells v0 (three's polyhedron detail is 20*(detail+1)^2,
  not 20*4^detail — 720 tris was far too coarse); fog sized to the room, since biome fog
  tuned for 100u corridors renders C's 360u acid sea as a black rectangle.
- index.js: prefers assets.texture() over get() (get returns raw JSON a shader can't eat),
  plus an explicit slug map — D's wall_smallint_a vs biome id small_intestine would miss
  silently forever under the assets-optional law.

Measured (C's real L2 + D's real pack, 1920x1080, renderer.info): 8 draws worst frame
(budget 150), 74k tris (500k), no geometry leak across a full sweep, <120ms load,
pinch ratio 3.32. fps not claimed — the screenshot pane runs tabs hidden, pausing rAF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:59:19 +10:00

227 lines
9.8 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>GUTS — Lane A world harness</title>
<!--
Lane A's own harness. NOT the game: js/boot.js is the game, F owns it.
This exists because boot.js can't reach js/world/index.js yet — pickWorld() imports Lane C's
js/levels/index.js in the same try block, and that module doesn't exist, so every non-stub
boot falls back to the stub regardless of my work landing. Patch offered in LANE_A_NOTES.md
§-> Lane F. Until it's applied, this is how the canal gets eyeballed. Delete it once boot can
show the real world.
http://localhost:8140/js/world/dev.html?dbg=1 rail cam down the fixture canal
...?dbg=1&fly=1 noclip (WASD/QE, drag to look)
...?dbg=1&s=430 park the rail cam at s=430
...?dbg=1&fakeassets=1 exercise the Lane D texture path
...?lvl=L2_esophagus C's level, when it lands
...?shots=1 hide the overlay
-->
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #02100d; }
canvas { display: block; }
#dbg { position: fixed; left: 10px; bottom: 10px; color: #39e6ff;
font: 12px/1.5 ui-monospace, monospace; text-shadow: 0 0 4px #000;
pointer-events: none; white-space: pre; }
</style>
<script type="importmap">
{ "imports": { "three": "../../vendor/three.module.js",
"three/addons/": "../../vendor/addons/" } }
</script>
</head>
<body>
<div id="dbg"></div>
<script type="module">
import * as THREE from 'three';
import { createRng } from '../core/rng.js';
import { createWorld } from './index.js';
import { createFlyCam } from './flycam.js';
import { FIXTURE } from './_fixture.js';
const p = new URLSearchParams(location.search);
const on = (k) => p.has(k) && p.get(k) !== '0';
const num = (k, d) => (p.has(k) ? parseFloat(p.get(k)) : d);
// --- level: C's if it's landed, fixture otherwise ---------------------------------------
let level = FIXTURE;
if (p.has('lvl')) {
try {
const mod = await import('../levels/index.js');
level = await mod.getLevel(p.get('lvl'));
} catch (e) {
console.warn('[devharness] no Lane C registry yet, using fixture —', e.message);
}
}
const seed = p.has('seed') ? (parseInt(p.get('seed'), 10) >>> 0) : level.seed;
// --- fake assets (?fakeassets=1) ---------------------------------------------------------
// Stands in for Lane D so the USE_DETAIL branch of the wall shader is exercised before their
// pack exists: a seamless grayscale fold pattern, same duck-type index.js expects from
// assets.get('textures', 'wall_<biome>_a'). Proves the integration, not the art.
function fakeAssets() {
const S = 256, cv = document.createElement('canvas');
cv.width = cv.height = S;
const ctx = cv.getContext('2d'), img = ctx.createImageData(S, S), r = createRng(seed)('assets.fake');
for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) {
const u = x / S, v = y / S; // periodic in both axes => tiles cleanly
let g = 0.5 + 0.22 * Math.sin(Math.PI * 2 * 3 * u + 2 * Math.sin(Math.PI * 2 * 2 * v))
+ 0.16 * Math.sin(Math.PI * 2 * 5 * v)
+ 0.10 * Math.sin(Math.PI * 2 * 11 * u + 1.7);
g = Math.max(0, Math.min(1, g + (r() - 0.5) * 0.10));
const i = (y * S + x) * 4, b = (g * 255) | 0;
img.data[i] = img.data[i + 1] = img.data[i + 2] = b; img.data[i + 3] = 255;
}
ctx.putImageData(img, 0, 0);
const tex = new THREE.CanvasTexture(cv);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
return { get: (kind, id) => (kind === 'textures' && /^wall_/.test(id) ? { isTexture: false, texture: tex, tile: [3, 14] } : null) };
}
// --- shell -------------------------------------------------------------------------------
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 600);
const t0 = performance.now();
// ?assets=1 uses Lane D's real loader + pack; ?fakeassets=1 uses the synthetic stand-in above.
let assets = null;
if (on('fakeassets')) assets = fakeAssets();
else if (on('assets')) {
try {
const m = await import('../core/assets.js');
assets = await m.createAssets({ flags: { localassets: p.get('localassets') !== '0' } });
} catch (e) {
console.warn('[devharness] Lane D assets.js unavailable, procedural walls —', e.message);
}
}
const world = await createWorld(level, { rng: createRng(seed), quality: p.get('quality') || 'high', assets });
const loadMs = performance.now() - t0;
scene.add(world.group);
scene.background = new THREE.Color(world.biomeAt(0).palette.void);
const fly = on('fly') ? createFlyCam({ camera, dom: renderer.domElement, world }) : null;
let sCam = num('s', 5);
const railSpeed = num('speed', 1) * (p.has('s') ? 0 : 1); // ?s= parks the cam for clean shots
function rail(dt) {
sCam = (sCam + world.biomeAt(sCam).flow * 0.6 * railSpeed * dt) % world.length;
const f = world.sample(sCam), ahead = world.sample(Math.min(sCam + 12, world.length));
camera.position.copy(f.pos).addScaledVector(f.nor, -f.radius * 0.25);
camera.up.copy(f.nor);
camera.lookAt(ahead.pos);
}
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
const dbgEl = document.getElementById('dbg');
if (!on('dbg') || on('shots')) dbgEl.style.display = 'none';
renderer.info.autoReset = false;
let frames = 0, fpsT = 0, fps = 0, draws = 0, tris = 0;
window.DBG = {
world, scene, camera, renderer,
get draws() { return draws; }, get tris() { return tris; }, get fps() { return fps; },
shot(name = 'shot') {
const a = document.createElement('a');
a.download = `${name}.png`;
a.href = renderer.domElement.toDataURL('image/png');
a.click();
},
/**
* Fix the drawing buffer to an explicit size for evidence frames. Two traps, both learned
* the hard way, both of which will bite anyone shooting boot.js (see LANE_A_NOTES §-> Lane F):
* 1. Screenshot tooling drives the tab HIDDEN. A hidden tab gets no requestAnimationFrame,
* so the render loop simply is not running when you shoot.
* 2. A hidden tab also reports innerWidth/innerHeight 0 — so the canvas is 0x0 and
* toDataURL() returns the literal string "data:,". The pane screenshot still looks fine
* because grabbing it fronts the tab; JS-side capture does not.
* Sizing explicitly also makes every committed shot the same resolution regardless of window.
*/
size(w = 1920, h = 1080) {
renderer.setPixelRatio(1);
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
return [renderer.domElement.width, renderer.domElement.height];
},
/** Deterministic evidence frame: park the rail cam at s, set the wave to exactly t, render. */
pose(s = 5, t = 0, w = 1920, h = 1080) {
this.size(w, h);
world.update(t - world.time, s); // land the wave phase exactly on t
for (let i = 0; i < 12; i++) world.update(0, s); // let the 2-chunks/update budget fill
sCam = s;
rail(0);
renderer.render(scene, camera);
const r = { s, t, draws: renderer.info.render.calls, tris: renderer.info.render.triangles,
pulse: world.flowPulse(s, t), ...world.stats() };
renderer.info.reset();
return r;
},
/** Park the noclip cam explicitly (arena interiors, wall close-ups), then render. */
poseAt(pos, lookAt, t = 0, w = 1920, h = 1080) {
this.size(w, h);
const p = new THREE.Vector3(...pos), l = new THREE.Vector3(...lookAt);
const s = world.project(p).s;
world.update(t - world.time, s);
for (let i = 0; i < 12; i++) world.update(0, s);
camera.position.copy(p);
camera.up.set(0, 1, 0);
camera.lookAt(l);
renderer.render(scene, camera);
const r = { s, draws: renderer.info.render.calls, tris: renderer.info.render.triangles };
renderer.info.reset();
return r;
},
/** Streaming leak probe: fly the whole canal, report renderer.info deltas. */
sweep(step = 20) {
const g0 = renderer.info.memory.geometries;
for (let s = 0; s <= world.length; s += step) world.update(0.016, s);
world.update(0.016, 0);
const g1 = renderer.info.memory.geometries;
return { geometriesBefore: g0, geometriesAfter: g1, delta: g1 - g0, ...world.stats() };
},
};
let last = performance.now();
function frame(now) {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
if (fly) fly.update(dt); else rail(dt);
const camS = fly ? world.project(camera.position).s : sCam;
world.update(dt, camS);
renderer.render(scene, camera);
draws = renderer.info.render.calls;
tris = renderer.info.render.triangles;
renderer.info.reset();
frames++; fpsT += dt;
if (fpsT >= 0.5) {
fps = Math.round(frames / fpsT); frames = 0; fpsT = 0;
if (on('dbg') && !on('shots')) {
const st = world.stats();
dbgEl.textContent =
`${fps} fps · ${draws} draws · ${(tris / 1000) | 0}k tris · load ${loadMs.toFixed(0)}ms\n` +
`${level.id} seed=${seed} hash=${world.hash()} L=${world.length}\n` +
`s=${camS.toFixed(0)} ${world.biomeAt(camS).id} ${world.modeAt(camS)} · flow ${world.biomeAt(camS).flow.toFixed(1)} · pulse ${world.flowPulse(camS).toFixed(2)}\n` +
`chunks ${st.chunks} live / ${st.built} built / ${st.disposed} disposed · turnR ${st.minTurnRadius.toFixed(0)} vs R ${st.maxRadius.toFixed(1)}`;
}
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>