haxtory/src/kit.js
type-two 8e194d677e The bridge, the card, and one call followed all the way through
THE BLUE BOX: you are not told the digits any more. The routing card is
on the wall — worked out by hand, traded, kept in a pocket, the only
documentation the long distance network ever had that was written by the
people using it. The objective names a destination and Joybubbles
explicitly refuses to give you the code.

And 2111 is up. A loop-around somebody left in a switch in Kansas,
dialable from anywhere at any point by anyone who knows the number, which
was exactly the problem with it. Six voices, no faces, nobody says their
name, it loops indefinitely, and holding 2600 drops the bridge. After SS7
there is nothing on it.

THE COPPER: start a call at the MDF and follow it. Seven banks of relays
down the aisles, each digit counted out at ten pulses a second because
that is how a step-by-step switch dials — the wiper steps once per pulse
and stops where the pulses stop. It connects. It is a wrong number. The
switch does not mind; it does not know what a number is.

Also fixed in kit: labelTexture ignored newlines, so any multi-line panel
rendered as one enormous single line. The routing card was nine metres
wide before this.

51 philes. All 13 hops swept clean. STAGES.md updated with what landed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 16:34:54 +10:00

291 lines
12 KiB
JavaScript

// kit.js — the shared parts. A body that collides with boxes, a camera rig that can
// be first or third person, text that floats, and a seeded random so that ASLR can
// reshuffle a level the same way twice when it wants to.
import * as THREE from '../vendor/three.module.js';
import * as In from './input.js';
// ───────────────────────────────────────────────────────────────── random
export function rng(seed) {
let a = seed >>> 0;
return () => {
a += 0x6D2B79F5; let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export const lerp = (a, b, t) => a + (b - a) * t;
export const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
export const damp = (a, b, l, dt) => lerp(a, b, 1 - Math.exp(-l * dt));
// ────────────────────────────────────────────────────────────────── text
const labelCache = new Map();
// Handles newlines. Without this a multi-line string renders as one very long line
// and a panel built from it comes out about nine metres wide.
export function labelTexture(text, { fg = '#ffffff', bg = 'rgba(0,0,0,0)', px = 64, pad = 12, font = 'ui-monospace, Menlo, monospace', weight = 'bold', align = 'center' } = {}) {
const key = text + fg + bg + px + weight + align;
if (labelCache.has(key)) return labelCache.get(key);
const lines = String(text).split('\n');
const lh = Math.round(px * 1.35);
const cv = document.createElement('canvas');
const g = cv.getContext('2d');
g.font = `${weight} ${px}px ${font}`;
const w = Math.ceil(Math.max(...lines.map(l => g.measureText(l).width))) + pad * 2;
cv.width = Math.max(2, w);
cv.height = lines.length * lh + pad * 2;
const g2 = cv.getContext('2d');
g2.font = `${weight} ${px}px ${font}`;
g2.fillStyle = bg; g2.fillRect(0, 0, cv.width, cv.height);
g2.fillStyle = fg; g2.textBaseline = 'middle'; g2.textAlign = align;
const x = align === 'left' ? pad : cv.width / 2;
lines.forEach((l, i) => g2.fillText(l, x, pad + lh * (i + 0.5)));
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
tex.minFilter = THREE.LinearFilter;
labelCache.set(key, tex);
return tex;
}
export function sprite(text, { color = '#ffffff', size = 1, opacity = 1 } = {}) {
const tex = labelTexture(text, { fg: color });
const m = new THREE.SpriteMaterial({ map: tex, transparent: true, opacity, depthWrite: false });
const s = new THREE.Sprite(m);
const ar = tex.image.width / tex.image.height;
s.scale.set(size * ar, size, 1);
// the new texture is a different width, so the scale has to follow it — otherwise
// a longer string gets squeezed into the old aspect and reads as garbage
s.userData.setText = (t2, c2) => {
const nt = labelTexture(t2, { fg: c2 || color });
m.map = nt; m.needsUpdate = true;
s.scale.set(size * (nt.image.width / nt.image.height), size, 1);
};
return s;
}
// Flat text panel that faces one way. For walls, signs, ANSI on plaster.
export function panel(text, { color = '#ffffff', size = 1, bg = 'rgba(0,0,0,0)', align = 'center' } = {}) {
const tex = labelTexture(text, { fg: color, bg, align });
const ar = tex.image.width / tex.image.height;
const m = new THREE.Mesh(
new THREE.PlaneGeometry(size * ar, size),
new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false, side: THREE.DoubleSide })
);
return m;
}
// ─────────────────────────────────────────────────────────────── materials
export function glow(color, intensity = 1) {
return new THREE.MeshBasicMaterial({ color: new THREE.Color(color).multiplyScalar(intensity) });
}
export function solid(color, { rough = 0.9, metal = 0.0, emissive = 0x000000, ei = 1 } = {}) {
return new THREE.MeshStandardMaterial({ color, roughness: rough, metalness: metal, emissive, emissiveIntensity: ei });
}
export function wire(color, opacity = 0.35) {
return new THREE.MeshBasicMaterial({ color, wireframe: true, transparent: true, opacity });
}
// ──────────────────────────────────────────────────────────────── the box world
export class World {
constructor() { this.boxes = []; this.tris = []; }
// add(x,y,z, w,h,d) — centred on x,z, y is the BOTTOM. Matches how you think
// about a platform.
add(x, y, z, w, h, d, tag = null) {
const b = { min: new THREE.Vector3(x - w / 2, y, z - d / 2), max: new THREE.Vector3(x + w / 2, y + h, z + d / 2), tag };
this.boxes.push(b);
return b;
}
addMesh(mesh, tag = null) {
mesh.updateMatrixWorld(true);
const bb = new THREE.Box3().setFromObject(mesh);
const b = { min: bb.min.clone(), max: bb.max.clone(), tag, mesh };
this.boxes.push(b);
return b;
}
clear() { this.boxes.length = 0; }
}
const _tmp = new THREE.Vector3();
export class Body {
constructor(world, { r = 0.4, h = 1.7, gravity = 26, speed = 7, air = 0.45, jump = 9.2 } = {}) {
this.world = world;
this.pos = new THREE.Vector3();
this.vel = new THREE.Vector3();
this.r = r; this.h = h;
this.gravity = gravity; this.speed = speed; this.air = air; this.jumpV = jump;
this.grounded = false; this.coyote = 0; this.buffer = 0; this.ground = null;
this.noclip = false; this.floorY = 0; this.hasFloor = true;
}
get feet() { return this.pos.y; }
aabb(p = this.pos) {
return { min: _tmp.set(p.x - this.r, p.y, p.z - this.r).clone(), max: new THREE.Vector3(p.x + this.r, p.y + this.h, p.z + this.r) };
}
static overlap(a, b) {
return a.min.x < b.max.x && a.max.x > b.min.x &&
a.min.y < b.max.y && a.max.y > b.min.y &&
a.min.z < b.max.z && a.max.z > b.min.z;
}
// Axis-separated sweep. Boring, correct at the edges, does not tunnel at these speeds.
move(dt, wishX, wishZ, wantJump) {
const accel = this.grounded ? 60 : 60 * this.air;
const tgt = { x: wishX * this.speed, z: wishZ * this.speed };
this.vel.x = damp(this.vel.x, tgt.x, this.grounded ? 18 : 6, dt);
this.vel.z = damp(this.vel.z, tgt.z, this.grounded ? 18 : 6, dt);
void accel;
if (wantJump) this.buffer = 0.12;
this.buffer = Math.max(0, this.buffer - dt);
this.coyote = this.grounded ? 0.11 : Math.max(0, this.coyote - dt);
if (this.buffer > 0 && this.coyote > 0) { this.vel.y = this.jumpV; this.grounded = false; this.coyote = 0; this.buffer = 0; }
this.vel.y -= this.gravity * dt;
if (this.vel.y < -60) this.vel.y = -60;
if (this.noclip) { this.pos.addScaledVector(this.vel, dt); return; }
const step = (axis, d) => {
if (d === 0) return;
this.pos[axis] += d;
const a = this.aabb();
for (const b of this.world.boxes) {
if (b.disabled) continue;
if (!Body.overlap(a, b)) continue;
if (b.tag === 'nx' && axis === 'y' && d < 0) continue; // you may not stand there
if (b.trigger) { b.hit = true; continue; }
if (axis === 'y') {
if (d < 0) { this.pos.y = b.max.y; this.vel.y = 0; this.grounded = true; this.ground = b; }
else { this.pos.y = b.min.y - this.h; this.vel.y = 0; }
} else {
if (d > 0) this.pos[axis] = (axis === 'x' ? b.min.x : b.min.z) - this.r;
else this.pos[axis] = (axis === 'x' ? b.max.x : b.max.z) + this.r;
this.vel[axis] = 0;
}
a.min.copy(this.aabb().min); a.max.copy(this.aabb().max);
}
};
this.grounded = false; this.ground = null;
step('y', this.vel.y * dt);
step('x', this.vel.x * dt);
step('z', this.vel.z * dt);
if (this.hasFloor && this.pos.y < this.floorY) { this.pos.y = this.floorY; this.vel.y = 0; this.grounded = true; }
}
}
// ─────────────────────────────────────────────────────────── camera rigs
export class Look {
constructor({ sens = 0.0022, maxPitch = 1.45 } = {}) {
this.yaw = 0; this.pitch = 0; this.sens = sens; this.maxPitch = maxPitch;
}
update() {
this.yaw -= In.mouse.dx * this.sens;
this.pitch -= In.mouse.dy * this.sens;
this.pitch = clamp(this.pitch, -this.maxPitch, this.maxPitch);
return this;
}
// Move axis rotated into world space by yaw. The camera looks down its own -Z,
// so forward is (-sin, -cos) and right is (cos, -sin). Get this wrong and W
// walks you backwards, which is exactly what it did the first time.
wish() {
const a = In.axis();
const s = Math.sin(this.yaw), c = Math.cos(this.yaw);
return { x: a.x * c + a.y * s, z: -a.x * s + a.y * c };
}
// unit forward vector on the ground plane
forward() { return { x: -Math.sin(this.yaw), z: -Math.cos(this.yaw) }; }
applyFirstPerson(cam, body, bob = 0) {
cam.position.set(body.pos.x, body.pos.y + body.h * 0.92 + bob, body.pos.z);
cam.rotation.set(0, 0, 0, 'YXZ');
cam.rotation.order = 'YXZ';
cam.rotation.y = this.yaw; cam.rotation.x = this.pitch;
}
applyThirdPerson(cam, body, dist = 7, height = 3.2, world = null) {
const dir = new THREE.Vector3(Math.sin(this.yaw), 0, Math.cos(this.yaw));
const want = new THREE.Vector3(
body.pos.x + dir.x * dist,
body.pos.y + height - this.pitch * 4,
body.pos.z + dir.z * dist
);
cam.position.lerp(want, 0.22);
cam.lookAt(body.pos.x, body.pos.y + body.h * 0.6, body.pos.z);
void world;
}
}
// ─────────────────────────────────────────────────────────────── the player
// SYN. A small bright thing with too many layers on.
export function makeSyn() {
const g = new THREE.Group();
const core = new THREE.Mesh(new THREE.IcosahedronGeometry(0.34, 1), glow(0x9ff5ff, 1.6));
g.add(core);
const halo = new THREE.Mesh(new THREE.IcosahedronGeometry(0.5, 1),
new THREE.MeshBasicMaterial({ color: 0x2ad8ff, transparent: true, opacity: 0.22, wireframe: true }));
g.add(halo);
// the headers. seven of them, and they come off.
const HEADERS = [
{ name: 'TLS', color: 0xffffff, r: 1.30 },
{ name: 'HTTP', color: 0xd8e8ff, r: 1.14 },
{ name: 'SESSION', color: 0xa8d8ff, r: 0.99 },
{ name: 'TCP', color: 0x7ec8ff, r: 0.86 },
{ name: 'IP', color: 0x58b0ff, r: 0.74 },
{ name: 'ETHERNET', color: 0x3c90e0, r: 0.63 },
{ name: 'VOLTAGE', color: 0x2a70b0, r: 0.55 },
];
const shells = HEADERS.map((h, i) => {
const m = new THREE.Mesh(
new THREE.IcosahedronGeometry(h.r, 1),
new THREE.MeshBasicMaterial({ color: h.color, wireframe: true, transparent: true, opacity: 0.30 - i * 0.02 })
);
m.userData.header = h.name;
g.add(m);
return m;
});
g.userData = {
core, halo, shells, headers: HEADERS, worn: shells.length,
shed() {
if (this.worn <= 0) return null;
this.worn--;
const s = shells[this.worn];
s.userData.shedding = 1;
return HEADERS[this.worn].name;
},
tick(t, dt) {
halo.rotation.y += dt * 0.8; halo.rotation.x += dt * 0.31;
core.rotation.y -= dt * 1.2;
const pulse = 1 + Math.sin(t * 3.1) * 0.06;
core.scale.setScalar(pulse);
shells.forEach((s, i) => {
if (i >= this.worn) {
s.userData.shedding = (s.userData.shedding ?? 1) - dt * 0.7;
const k = Math.max(0, s.userData.shedding);
s.scale.setScalar(1 + (1 - k) * 3.5);
s.material.opacity = k * 0.3;
s.visible = k > 0.01;
} else {
s.rotation.y += dt * (0.12 + i * 0.05) * (i % 2 ? -1 : 1);
s.rotation.z += dt * 0.07;
}
});
}
};
return g;
}
// ─────────────────────────────────────────────────────────────── lighting
export function threePointLight(scene, { key = 0xffffff, fill = 0x334455, keyI = 1.5, ambI = 0.35 } = {}) {
const a = new THREE.AmbientLight(fill, ambI); scene.add(a);
const d = new THREE.DirectionalLight(key, keyI); d.position.set(4, 12, 6); scene.add(d);
const d2 = new THREE.DirectionalLight(fill, keyI * 0.4); d2.position.set(-6, 4, -8); scene.add(d2);
return { a, d, d2 };
}