You are SYN. You want ACK. Root is the only one who can give it to you.
Three load-bearing ideas, all working:
- TTL is the health bar and it only goes down. 64 hops, no healing.
- Bandwidth is resolution. One render target, one shader; at 1200 baud
and below the 3D scene renders as ASCII glyphs.
- Every header you drop is a piece of clothing. Seven wireframe shells,
one per OSI layer, shed on the way down.
Every frequency is the real frequency, synthesised, nothing sampled:
350+440 dial tone, the DTMF matrix, R1 MF pairs at real KP/digit timing,
2600 Hz, ANSam at 2100 reversing phase every 450 ms, Bell 103 FSK.
Thirteen hops, each a different genre over one renderer — FP tutorial,
OSI descent platformer, walking sim, EFnet twin-stick, 2D buffer overflow,
Morris worm RTS, LBL terminal stealth, a whole BBS with a playable LORD,
the five-boss handshake arena, the copper, a playable blue box, the climb,
and an empty room with an ACK in it.
No engine, no build step, one vendored three.js.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
285 lines
12 KiB
JavaScript
285 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();
|
|
export function labelTexture(text, { fg = '#ffffff', bg = 'rgba(0,0,0,0)', px = 64, pad = 12, font = 'ui-monospace, Menlo, monospace', weight = 'bold' } = {}) {
|
|
const key = text + fg + bg + px + weight;
|
|
if (labelCache.has(key)) return labelCache.get(key);
|
|
const cv = document.createElement('canvas');
|
|
const g = cv.getContext('2d');
|
|
g.font = `${weight} ${px}px ${font}`;
|
|
const w = Math.ceil(g.measureText(text).width) + pad * 2;
|
|
cv.width = Math.max(2, w); cv.height = px + 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 = 'center';
|
|
g2.fillText(text, cv.width / 2, cv.height / 2);
|
|
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)' } = {}) {
|
|
const tex = labelTexture(text, { fg: color, bg });
|
|
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 };
|
|
}
|