// 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 }; }