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>
291 lines
12 KiB
JavaScript
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 };
|
|
}
|