diff --git a/STAGES.md b/STAGES.md index 2683d66..fe5d56d 100644 --- a/STAGES.md +++ b/STAGES.md @@ -3,10 +3,18 @@ > One page per hop. What's built, what's thin, what I'd add, ranked. > Write your direction under **DIRECTION** and I'll build to it. -Current state: 13 playable hops, ~5,400 lines, all clean. Each hop is currently -**2–4 minutes** of content. The design doc calls for **5–20**. The genres, mechanics -and set pieces are all in — the *duration* isn't. That's the gap this document is -for. +Current state: 13 playable hops, ~6,400 lines, all clean. + +**Since the first pass** (you gave me the wheel; these were my calls): the philes +are readable, the game saves, MTU costs you distance, ROOT reads your whole save and +your FidoNet message comes back on Tuesday, TIME_WAIT is a room with sixteen people +in it and one of them ages out mid-sentence, SMASHING THE STACK has ret2libc, +THE CLIMB is a graded final exam with one gate per era, THE BLUE BOX has the routing +card and the 2111 bridge, and THE COPPER lets you follow one call through seven +banks of relays to a wrong number. Items marked **DONE** below. + +Remaining gap is still duration: most hops are 3–6 minutes against a doc that calls +for 5–20. Repo: `ssh://git@100.71.119.27:222/monster/haxtory.git` · run: `python3 serve.py 8123` @@ -69,10 +77,11 @@ half-open connections with three lines each, and FIN by the door with nine. not a room. And FIN's nine lines fire on a single E-mash. **What I'd add, ranked** -1. **More people, fewer words each.** Twelve to sixteen seated, one or two lines apiece, most of them not about you. A waiting room is oppressive because of volume, not depth. -2. **Someone leaves while you're in there.** A connection ages out — quietly, mid-sentence, no ceremony. Nobody reacts. That's the level's thesis in one event. -3. **The clock should matter.** Two minutes is MSL. If the player stays a full lap, something happens. If they leave in twenty seconds, they never know it would have. -4. **FIN should refuse to talk if you rush him.** Approach and mash E and he says "you're not listening". Come back after you've talked to three others and he opens up. +1. ~~More people, fewer words each.~~ **DONE** — sixteen seated, one or two lines apiece, most of it not about you. +2. ~~Someone ages out mid-sentence.~~ **DONE** — at 38 s, quietly, and nobody in the room looks up. +3. ~~The clock should matter.~~ **DONE** — a full two-minute lap is noticed, and is a phile. +4. ~~FIN refuses if you rush him.~~ **DONE** — three real conversations before he'll tell you about the door. +5. **Still open:** somebody should arrive while you're in there, not just leave. **DIRECTION:** > _ @@ -113,9 +122,9 @@ and Aleph One chain-smoking at the start. straight line — no reason to ever go back. **What I'd add, ranked** -1. **ret2libc as a real second route.** NX turns on halfway, the sled stops working, and you have to build a bridge out of the *ends of other people's platforms* — the gadget chain as physical scaffolding. This is the best unbuilt thing in the whole game. -2. **Make the address matter.** Right now any four bytes work because they're prewritten. Let the player choose an address and have the level jump there — including to the wrong places, with consequences. -3. **A second buffer.** Off-by-one instead of a stampede: one byte over, into the low byte of the saved pointer. Much harder, much more satisfying. +1. ~~ret2libc as a real second route.~~ **DONE** — NX lands the instant you reach the shellcode; libc appears; four gadgets in order, four real decoys, and the chain lays the bridge behind you. +2. **Make the address matter.** Any four bytes work because they're prewritten. Let the player choose, including wrongly. +3. **A second buffer.** Off-by-one into the low byte of the saved pointer. Harder, better. 4. **Aleph One should say more than one line.** He's the mentor and he's got four words. **DIRECTION:** @@ -226,10 +235,10 @@ Strowger plaque. thing in the game and you walk through it in forty seconds. **What I'd add, ranked** -1. **Make the cathedral navigable.** Frames you walk *between*, not past. Get lost in it. It should be the biggest space in the game and feel like it. -2. **Follow one call.** Pick a connection at the MDF and physically follow it through the switches as it's set up, step by step, relay by relay. That's the level's reason to exist and it isn't in yet. -3. **More to listen to.** One phone call. There should be six, and one of them should be a modem, and one should be somebody who hears you. -4. **Weather.** The design doc says it's the first level with weather. There's no water in it. +1. ~~Follow one call.~~ **DONE** — start it at the MDF and follow it down the aisles through seven banks of relays. Each digit is counted out at ten pulses a second, because that is how a step-by-step switch dials. It connects. It is a wrong number. +2. **Make the cathedral navigable.** You now walk the aisles, but it should be somewhere you can get lost. +3. **More to listen to.** One phone call on the lineman's handset. There should be six, and one should be a modem, and one should be somebody who hears you. +4. **Weather.** The doc says it's the first level with weather. There's no water in it. **DIRECTION:** > _ @@ -249,10 +258,10 @@ whistling anyway. **Thin:** the routing codes are dictated. You type what you're told. **What I'd add, ranked** -1. **Let the player route.** Give them a map of area codes and let them work out the path themselves. The instrument is real; the puzzle isn't yet. -2. **The 2111 conference.** The design doc's best unbuilt scene: a loop-around with other phreaks in it, all voices, no faces, and you can just sit there. -3. **Joybubbles should teach by ear only.** He half does. Take the frequencies off the readout during his lessons entirely and make the player find 2600 by listening. -4. **Free-play mode after SS7.** You keep the box. It does nothing. Let the player still press the buttons. +1. ~~Let the player route.~~ **DONE** — the routing card is on the wall, the objective names a destination, and Joybubbles refuses to give you the digits. +2. ~~The 2111 conference.~~ **DONE** — dialable from anywhere, forever, by anyone who knows the number. Six voices, no faces, loops indefinitely, and holding 2600 drops the bridge. After SS7 there is nothing on it. +3. **Joybubbles should teach by ear only.** He half does. Take the frequencies off the readout during his lessons. +4. **Free-play mode after SS7.** You keep the box. Let the player still press the buttons. **DIRECTION:** > _ @@ -271,10 +280,10 @@ flips next door, and Log4Shell as a sign you hold up. game. The eras flash past as colour changes with no content in them. **What I'd add, ranked** -1. **One mechanic callback per era.** Fifty seconds each, each one handing you back a verb you learned there for exactly one more use. That's the whole point of the sequence and none of it is in. -2. **Everything on fire.** It's described as burning and it isn't. -3. **The Spectre section should be a real split screen.** Two viewports, two timelines. It's a scissor-test and a second camera. -4. **Log4Shell needs its beat of silence.** Right now it fires and moves on. It should stop, let the fortress walk over, read the sign, and go and fetch it while you stand there doing nothing. +1. ~~One mechanic callback per era.~~ **DONE** — seven gates: seize, wiper, 450 ms reversal, ANSI bomb, worm, netsplit, NX. Graded, not lethal (you arrive at TTL 2 by design, so nothing up here spends TTL). +2. ~~Everything on fire.~~ **DONE** — embers, thickening as you climb. +3. ~~Log4Shell's beat of silence.~~ **DONE** — the world stops, the fortress walks over and reads it. +4. **The Spectre section should be a real split screen.** Two viewports, two timelines. Scissor test and a second camera. **DIRECTION:** > _ @@ -292,9 +301,9 @@ TIME_WAIT with FIN saying *told you*, then it loops to hop 1. **Thin:** the loop is instant. And the ending doesn't know what you did. **What I'd add, ranked** -1. **The ending should read your save.** Philes found, hops skipped, whether you got the NUP, what you posted to FidoNet, how many times you expired. It has all of it and uses none of it. -2. **Your FidoNet message should come back.** You posted it at hop 35 and it takes until Tuesday. Sixty-four hops later is Tuesday. This is free and it is the best single thing left unbuilt in the game. -3. **New game plus, honestly.** The loop should keep the philes and reset everything else, and hop 1 should be very slightly different the second time. +1. ~~The ending should read your save.~~ **DONE** — philes, expiries, the baud you negotiated and lived with, the climb grade, whether somebody vouched for you, whether you hung up without saying goodbye. +2. ~~Your FidoNet message comes back.~~ **DONE** — Tuesday. Somebody replied, because somebody always did. +3. ~~The loop keeps the philes.~~ **DONE** (`flags.looped` counts rounds) — but hop 1 is not yet different the second time. 4. **CHECKSUM.** The anonymous pass-by. Needs a server. Last. **DIRECTION:** @@ -306,7 +315,7 @@ TIME_WAIT with FIN saying *told you*, then it loops to hop 1. | | | |---|---| -| **The philes** | 40 of 200 written. All in `src/philes.js`, all readable with `P`. Pure writing. | +| **The philes** | 51 of 200 written. All in `src/philes.js`, all readable with `P`. Pure writing. | | **Audio** | Every tone is real but the *music* isn't built — the design says each level's pad is generated from that era's protocol and right now they're just chords. | | **The retrain** | A mid-game renegotiation that drops your baud without warning. One system, touches every level after it. | | **Difficulty** | Nothing scales. TTL is the only pressure and it's linear. | diff --git a/src/kit.js b/src/kit.js index dfed761..1d4ddfb 100644 --- a/src/kit.js +++ b/src/kit.js @@ -23,19 +23,25 @@ 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; +// 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(g.measureText(text).width) + pad * 2; - cv.width = Math.max(2, w); cv.height = px + pad * 2; + 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 = 'center'; - g2.fillText(text, cv.width / 2, cv.height / 2); + 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; @@ -60,8 +66,8 @@ export function sprite(text, { color = '#ffffff', size = 1, opacity = 1 } = {}) } // 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 }); +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), diff --git a/src/levels/bluebox.js b/src/levels/bluebox.js index f926246..998c2e4 100644 --- a/src/levels/bluebox.js +++ b/src/levels/bluebox.js @@ -18,36 +18,71 @@ const KEYS = [ ['KP2', 'KeyJ'], ]; +// The routing card. A real phreak had one of these — on paper, in a pocket, worked +// out by hand and traded. You are not told the code. You are told where you are +// going, and the card is on the wall, and you look it up like they did. +const ROUTING = [ + ['LOS ANGELES', '213'], + ['NEW YORK', '212'], + ['LONDON', '044'], + ['CHICAGO', '312'], + ['INWARD OPR', '121'], + ['SYDNEY TANDEM', '8121'], + ['THE PAYPHONE', '5551212'], + ['— the bridge —', '2111'], +]; + const STAGES = [ - { id: 'seize', code: null, + { id: 'seize', dest: null, code: null, teach: [ ['JOYBUBBLES', 'you hear that? that flat hum. that is a trunk with nothing on it.'], ['JOYBUBBLES', 'twenty six hundred cycles says "idle". hold it and the far end lets go.'], ['JOYBUBBLES', 'hold the space bar. keep holding. do not be polite about it.'], ] }, - { id: 'route1', code: 'KP 213 ST', + { id: 'route1', dest: 'LOS ANGELES', code: 'KP213ST', teach: [ ['JOYBUBBLES', 'good. now the near end still thinks you are on a call, and the far end is gone.'], ['JOYBUBBLES', 'you are standing in a hole in the telephone network.'], - ['JOYBUBBLES', 'KP. then the digits. then ST. K, numbers, L.'], + ['JOYBUBBLES', 'KP, then the digits, then ST. K, numbers, L.'], + ['JOYBUBBLES', 'i am not going to tell you the digits. the card is on the wall. read it.'], ] }, - { id: 'route2', code: 'KP 044 ST', + { id: 'route2', dest: 'LONDON', code: 'KP044ST', teach: [ - ['JOYBUBBLES', 'again. further. that one was los angeles, this one is london.'], - ['JOYBUBBLES', 'nobody is checking. nobody built a thing that checks.'], + ['JOYBUBBLES', 'again. further.'], + ['JOYBUBBLES', 'nobody is checking. nobody ever built a thing that checks.'], ] }, - { id: 'route3', code: 'KP 8121 ST', + { id: 'route3', dest: 'SYDNEY TANDEM', code: 'KP8121ST', teach: [ ['JOYBUBBLES', 'now stack it. tandem on tandem. keep the trunk and take another one.'], ['JOYBUBBLES', 'we are going all the way round.'], ] }, - { id: 'loop', code: 'KP 5551212 ST', + { id: 'loop', dest: 'THE PAYPHONE', code: 'KP5551212ST', teach: [ ['JOYBUBBLES', 'last one. and this one comes home.'], - ['JOYBUBBLES', 'that number is the payphone six feet behind you.'], + ['JOYBUBBLES', 'oh — and there is a bridge somebody left up years ago. two one one one.'], + ['JOYBUBBLES', 'it is not going anywhere. dial it whenever you like.'], ] }, ]; +// ── the 2111 conference. +// A loop-around somebody left up. All voices, no faces, and you can just sit there. +const CONF = [ + ['—', 'is that somebody new'], + ['a flat voice', 'hello, new person'], + ['—', 'do not say your name. nobody here says their name.'], + ['a girl, 14', 'i got here from a wrong number. i have been coming back for a year.'], + ['—', 'she is the best router on this bridge and she is fourteen and she is blind.'], + ['a flat voice', 'i work for the phone company. i am not going to do anything about any of you.'], + ['—', '...'], + ['a girl, 14', 'what does it look like, where you are'], + ['—', 'do not answer that. she does not mean it the way you think.'], + ['a girl, 14', 'i do mean it that way.'], + ['—', '...'], + ['a flat voice', 'it is a hole in a switch in kansas and it has been open since before any of us.'], + ['a flat voice', 'nobody maintains it. nobody knows it is here. that is the entire reason it works.'], + ['—', 'stay as long as you want. everybody does.'], +]; + let S = {}; export default { @@ -57,7 +92,9 @@ export default { async mount(ctx) { const { scene, camera } = ctx; S = { t: 0, stage: -1, seize: null, held: 0, seized: false, buf: [], log: [], - hops: [], ringing: false, answered: false, dead: false, teaching: false }; + hops: [], ringing: false, answered: false, dead: false, teaching: false, + inConf: false, confVoices: [], confLoop: null, confSpeaker: -1 }; + window.__S = S; // debug handle scene.background = new THREE.Color(0x03060f); scene.fog = new THREE.FogExp2(0x03060f, 0.02); @@ -142,10 +179,25 @@ export default { const jh = new THREE.Mesh(new THREE.SphereGeometry(0.9, 16, 12), new THREE.MeshBasicMaterial({ color: 0xffb648, wireframe: true, transparent: true, opacity: 0.3 })); S.joy.add(jh); S.jhalo = jh; - S.joy.position.set(-4.6, -0.4, -6.0); + S.joy.position.set(-4.4, -3.6, -6.4); scene.add(S.joy); const jl = new THREE.PointLight(0xffc98a, 8, 14, 2); jl.position.copy(S.joy.position); scene.add(jl); + // the card. worked out by hand, traded, and kept in a pocket. + const cardTxt = 'ROUTING\n\n' + ROUTING.map(([n, c]) => n.padEnd(17, ' ') + c).join('\n'); + S.card = panel(cardTxt, { color: '#cfe4f6', size: 4.0, align: 'left' }); + S.card.position.set(-5.6, 0.4, -9.0); + S.card.rotation.y = 0.44; + scene.add(S.card); + const cb = new THREE.Box3().setFromObject(S.card); + const cardBack = new THREE.Mesh( + new THREE.PlaneGeometry((cb.max.x - cb.min.x) * 1.12 + 0.4, (cb.max.y - cb.min.y) * 1.08 + 0.4), + new THREE.MeshBasicMaterial({ color: 0x0a1626 })); + cardBack.position.set(-5.6, 0.4, -9.06); cardBack.rotation.y = 0.44; scene.add(cardBack); + const cardLight = new THREE.PointLight(0x9fc8ff, 9, 12, 2); + cardLight.position.set(-4.6, 1.0, -7.2); scene.add(cardLight); + + S.cardSeen = false; S.readout = sprite('', { color: '#9fd8ff', size: 0.6 }); S.readout.position.set(-3.6, 2.4, -7.5); scene.add(S.readout); @@ -172,8 +224,8 @@ export default { } ctx.hush(); if (st.code) { - S.readout.userData.setText('DIAL ' + st.code, '#ffd9a8'); - ctx.say(`enter ${st.code}`, 5000); + S.readout.userData.setText('ROUTE TO ' + st.dest, '#ffd9a8'); + ctx.say(`route to ${st.dest} — the card is on the wall`, 5200); } else { S.readout.userData.setText('HOLD 2600', '#ffd9a8'); } @@ -181,6 +233,61 @@ export default { S.teaching = false; }, + // ── the 2111 conference. Optional, permanent, and the only place in this game + // where nothing at all is being asked of you. + async conference(ctx) { + S.inConf = true; + S.teaching = true; + A.relayClick(0.3, 0.5); + S.hum.level ? S.hum.level(0.015) : null; + S.readout.userData.setText('2111 — the bridge', '#ffd9a8'); + ctx.hint('SPACEhold to drop the bridge'); + + // voices. no faces. six warm points in the dark and none of them are anybody. + S.confVoices = []; + for (let i = 0; i < 6; i++) { + const a = (i / 6) * Math.PI * 2; + const g = new THREE.Group(); + const c = new THREE.Mesh(new THREE.SphereGeometry(0.30, 14, 10), glow(0xffd9a8, 0.9)); + g.add(c); + const h = new THREE.Mesh(new THREE.SphereGeometry(0.62, 12, 9), + new THREE.MeshBasicMaterial({ color: 0xffb648, wireframe: true, transparent: true, opacity: 0.22 })); + g.add(h); + g.position.set(Math.cos(a) * 5.2, -0.4 + Math.sin(a * 2) * 0.8, -7 + Math.sin(a) * 4); + ctx.scene.add(g); + const l = new THREE.PointLight(0xffc98a, 4, 9, 2); l.position.copy(g.position); ctx.scene.add(l); + S.confVoices.push({ g, core: c, halo: h, light: l, phase: i }); + } + ctx.phile('conference'); + + // it loops. it does not end. you leave when you leave. + S.confLine = 0; + S.confLoop = setInterval(() => { + if (!S.inConf) return; + const [who, line] = CONF[S.confLine % CONF.length]; + S.confLine++; + ctx.say(line, 4200, who); + S.confSpeaker = (S.confLine * 3) % S.confVoices.length; + S.speaking = 1.4; + A.blip(300 + Math.random() * 200, 0.02, 0.02); + }, 4600); + S.teaching = false; + }, + + leaveConference(ctx) { + if (!S.inConf) return; + S.inConf = false; + clearInterval(S.confLoop); + S.confVoices.forEach(v => { ctx.scene.remove(v.g); ctx.scene.remove(v.light); }); + S.confVoices = []; + ctx.hush(); + A.relayClick(0.3, 0.8); + const st = STAGES[S.stage]; + S.readout.userData.setText(st && st.dest ? 'ROUTE TO ' + st.dest : '', '#ffd9a8'); + ctx.hint('SPACEhold — 2600 Hz
1–9 0MF digits
KKP   LST'); + ctx.say('you dropped the bridge. it is still up. it is always still up.', 4200); + }, + // ── the set piece. your call has gone round the earth and it is now ringing the // payphone standing six feet away from you. async homecoming(ctx) { @@ -266,6 +373,14 @@ export default { S.strands.forEach((l, i) => { l.material.opacity = 0.35 + Math.sin(S.t * 0.7 + i) * 0.15; }); S.tube.rotation.z += dt * 0.02; S.jhalo.rotation.y += dt * 0.5; S.jhalo.rotation.x += dt * 0.2; + S.confVoices.forEach((v, i) => { + v.halo.rotation.y += dt * 0.3; + const talking = i === S.confSpeaker && S.speaking > 0; + const k = talking ? 1 + Math.sin(S.t * 24) * 0.13 : 1 + Math.sin(S.t * 1.3 + v.phase) * 0.04; + v.core.scale.setScalar(k); + v.light.intensity = talking ? 8 : 4; + v.g.position.y += Math.sin(S.t * 0.7 + v.phase) * dt * 0.15; + }); S.speaking = Math.max(0, (S.speaking || 0) - dt); S.jcore.scale.setScalar(1 + (S.speaking > 0 ? Math.sin(S.t * 26) * 0.10 : Math.sin(S.t * 1.7) * 0.03)); S.box.rotation.y = Math.sin(S.t * 0.5) * 0.03; @@ -280,6 +395,9 @@ export default { b.mesh.position.z = 0.32 - b.lit * 0.08; }); + // looking at the card counts as reading it + if (!S.cardSeen && S.stage >= 1) { S.cardSeen = true; ctx.phile('routing'); } + if (S.teaching) return; // ── 2600 Hz. hold it. @@ -287,6 +405,7 @@ export default { if (!S.seize) { S.seize = A.seize(0.15); S.held = 0; } S.held += dt; ctx.link.glitch = Math.min(0.25, S.held * 0.08); + if (S.held > 0.9 && S.inConf) { S.seize.stop(0.05); S.seize = null; S.held = 0; ctx.link.glitch = 0; this.leaveConference(ctx); return; } if (S.held > 1.4 && !S.seized && !S.dead) { S.seized = true; S.hum.stop(0.3); @@ -323,15 +442,25 @@ export default { }, checkCode(ctx) { + const got = S.buf.join(''); + + // ── the bridge. dialable at any point, from anywhere, forever, by anyone who + // knows the number — which was exactly the problem with it. + if (got === 'KP2111ST') { + S.buf = []; + if (S.dead) { A.burst(480, 620, 0.5, 0.16); ctx.say('nothing. there is nothing on 2111 any more.', 4000); return; } + if (!S.inConf) return this.conference(ctx); + return; + } + const st = STAGES[S.stage]; if (!st || !st.code) return; const want = st.code.replace(/\s+/g, ''); - const got = S.buf.join(''); if (got !== want) { A.burst(480, 620, 0.4, 0.16); ctx.say('misroute the switch took it and did nothing with it.', 2600); S.buf = []; - S.readout.userData.setText('DIAL ' + st.code, '#ffd9a8'); + S.readout.userData.setText('ROUTE TO ' + st.dest, '#ffd9a8'); ctx.spendTTL(1, 'misroute'); return; } @@ -340,7 +469,7 @@ export default { setTimeout(() => A.relayClick(0.25, 0.9), 120); S.hops.push(st.code); this.drawHop(ctx, S.hops.length); - ctx.say(`tandem ${S.hops.length} ${st.code}`, 2600); + ctx.say(`tandem ${S.hops.length} ${st.dest} · ${st.code}`, 2600); S.buf = []; S.readout.userData.setText('', '#63ffa2'); setTimeout(() => this.advance(ctx), 2200); diff --git a/src/levels/copper.js b/src/levels/copper.js index 01b3d06..64ca3b8 100644 --- a/src/levels/copper.js +++ b/src/levels/copper.js @@ -16,6 +16,10 @@ const CALL = [ ['', 'she is right. you should put it down.'], ]; +// KLondike 5-2368. A step-by-step switch dials by *counting* — the wiper steps once +// per pulse, and you can hear the number being spelled out in relays. +const DIGITS = [5, 5, 5, 2, 3, 6, 8]; + let S = {}; export default { @@ -24,7 +28,9 @@ export default { async mount(ctx) { const { scene, camera } = ctx; - S = { t: 0, relays: [], step: 0, beige: false, listened: 0, talking: false, zone: 0 }; + S = { t: 0, relays: [], step: 0, beige: false, listened: 0, talking: false, zone: 0, + following: false, legAt: 0, stepping: false, callDone: false, waited: false }; + window.__S = S; // debug handle scene.background = new THREE.Color(0x0a0704); scene.fog = new THREE.FogExp2(0x0a0704, 0.028); @@ -150,6 +156,42 @@ export default { t.position.set(0, 13.6, z); co.add(t); } + // ── the main distribution frame, and one call about to be set up on it. + // You can follow it. Step by step. All the way through. That is the level. + S.mdf = new THREE.Group(); + const mdfBody = new THREE.Mesh(new THREE.BoxGeometry(3.0, 6.0, 1.2), + new THREE.MeshStandardMaterial({ color: 0x4a4030, roughness: 0.7, metalness: 0.5 })); + mdfBody.position.y = 3; S.mdf.add(mdfBody); + for (let i = 0; i < 40; i++) { + const j = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 2.6, 5), + new THREE.MeshStandardMaterial({ color: 0xb87333, roughness: 0.35, metalness: 0.95 })); + j.position.set(-1.2 + (i % 8) * 0.34, 1.2 + Math.floor(i / 8) * 1.1, 0.7); + j.rotation.z = 0.4 + (i % 3) * 0.2; S.mdf.add(j); + } + S.mdf.position.set(-14, 0, -146); + scene.add(S.mdf); + ctx.world.add(-14, 0, -146, 3.2, 6, 1.4); + const mdfTag = sprite('MDF', { color: '#ffb648', size: 0.5 }); + mdfTag.position.set(-14, 7, -146); scene.add(mdfTag); + + // the call itself: a small bright thing that will walk the whole hall + S.call = new THREE.Group(); + const cc = new THREE.Mesh(new THREE.SphereGeometry(0.34, 14, 10), glow(0xfff0c0, 1.6)); + S.call.add(cc); S.callCore = cc; + const cl = new THREE.PointLight(0xffd9a8, 20, 22, 2); S.call.add(cl); S.callLight = cl; + S.call.position.set(-14, 3.2, -146); + S.call.visible = false; + scene.add(S.call); + S.callTag = sprite('', { color: '#ffd9a8', size: 0.7 }); + S.callTag.position.set(0, 12.5, -190); scene.add(S.callTag); + S.callTag.visible = false; + + // seven legs, one per digit, zig-zagging across the frames of the office + S.legs = DIGITS.map((d, i) => ({ + digit: d, + to: new THREE.Vector3(-12 + (i % 5) * 6, 3.0 + (i % 3) * 1.8, -152 - i * 13), + })); + // the plaque. it is true, which is why it is on a plaque. const plaque = panel( 'ALMON B. STROWGER, UNDERTAKER, KANSAS CITY, 1891\n' + @@ -246,6 +288,29 @@ export default { ctx.say('he built the whole automatic telephone network out of spite. it worked.', 5200); } + // ── follow the call + if (!S.following && !S.callDone && Math.hypot(b.pos.x + 14, b.pos.z + 146) < 5) { + ctx.hint('Efollow the call'); + if (In.useHit()) this.followCall(ctx); + } + if (S.following) { + const leg = S.legs[S.legAt]; + if (leg) { + const d = S.call.position.distanceTo(leg.to); + const near = b.pos.distanceTo(S.call.position) < 16; + if (d > 0.6) { + // it waits for you. it is in no hurry and neither is the network. + if (near) S.call.position.lerp(leg.to, 1 - Math.exp(-1.6 * dt)); + else if (!S.waited) { S.waited = true; ctx.say('it is waiting for you.', 2600); } + if (near) S.waited = false; + } else if (!S.stepping) { + this.stepDigit(ctx, leg); + } + } + S.callCore.scale.setScalar(1 + Math.sin(S.t * 7) * 0.12); + S.callLight.intensity = 18 + Math.sin(S.t * 7) * 6; + } + if (b.pos.z < -243 && !S.done) { S.done = true; (async () => { @@ -255,6 +320,67 @@ export default { } }, + // ── one call, set up in front of you, at the speed the machinery actually did it + async followCall(ctx) { + S.following = true; + S.legAt = 0; + S.dialled = ''; + S.call.visible = true; + S.callTag.visible = true; + S.callTag.userData.setText('· · · · · · ·', '#ffd9a8'); + ctx.hint('WASDkeep up'); + ctx.say('somebody a mile away has just lifted a handset. this is their call.
you can follow it all the way through if you want to.', 6000); + ctx.phile('stepbystep'); + }, + + // a step-by-step switch dials by counting: one step of the wiper per pulse. You + // can hear the number being spelled out, digit by digit, in relays. + async stepDigit(ctx, leg) { + S.stepping = true; + for (let i = 0; i < leg.digit; i++) { + A.relayClick(0.3, 0.85 + i * 0.02); + S.callCore.material.color.setHex(i % 2 ? 0xfff0c0 : 0xffb648); + await ctx.sleep(105); // ten pulses per second. it was. + } + S.callCore.material.color.setHex(0xfff0c0); + S.dialled += leg.digit; + S.callTag.userData.setText( + S.dialled.split('').join(' ') + ' ' + '·'.repeat(DIGITS.length - S.dialled.length).split('').join(' '), + '#ffd9a8'); + await ctx.sleep(420); + S.legAt++; + S.stepping = false; + if (S.legAt >= S.legs.length) this.callConnects(ctx); + }, + + async callConnects(ctx) { + S.following = false; + S.callDone = true; + ctx.hint(''); + S.callTag.userData.setText('KL5 - 2368', '#63ffa2'); + await ctx.sleep(600); + const rb = A.ringback(); + ctx.say('seven digits. seven banks of relays. about a mile of copper.', 4600); + await ctx.sleep(4800); + rb.stop(); + A.relayClick(0.35, 0.5); + await ctx.sleep(500); + // and then, because most calls are + ctx.say('hello?', 2600, 'a man'); + await ctx.sleep(2800); + ctx.say('...no. no, there is no one here by that name.', 3600, 'a man'); + await ctx.sleep(3800); + ctx.say('i think you have the wrong number.', 3200, 'a man'); + await ctx.sleep(3400); + A.relayClick(0.3, 0.4); + ctx.say('', 0); ctx.hush(); + await ctx.sleep(1400); + ctx.say('you followed that for two minutes. it was a wrong number.
the switch did not mind. it does not know what a number is.', 6400); + ctx.phile('wrongnumber'); + S.call.visible = false; + S.callTag.visible = false; + }, + async listen(ctx) { ctx.hint(''); A.relayClick(0.3, 0.4); diff --git a/src/philes.js b/src/philes.js index fa51605..f519cdc 100644 --- a/src/philes.js +++ b/src/philes.js @@ -513,6 +513,34 @@ single thing after that had to be smuggled through a hole shaped like a person talking.`, }, + 'stepbystep': { + title: 'how a step-by-step switch dials', + from: 'Strowger, and every exchange that copied him', + body: `there is no computer anywhere in this. the dial on the telephone is a +spring-loaded wheel that breaks the circuit a fixed number of times — once +for a one, nine times for a nine — at ten pulses a second. + +at the other end a wiper steps up one contact per pulse and stops where the +pulses stop. that is the digit. that is the whole mechanism. seven digits +is seven banks of relays, each one physically climbing to the number you +dialled and then handing you to the next. + +you can hear the number. it is being counted out in front of you.`, + }, + + 'wrongnumber': { + title: 'KL5-2368', + from: 'a man who was not expecting anybody', + body: `you followed one call across a hall the size of a cathedral, through seven +banks of relays, over about a mile of copper, at ten pulses a second. + +it was a wrong number. + +the switch did not mind. the switch does not know what a number is. it +counts pulses and closes contacts and it would have done exactly the same +thing for a declaration of war.`, + }, + 'beige': { title: 'the beige box', from: 'a handset and a pair of jaws', @@ -542,6 +570,34 @@ was going to be on the line who could make those noises on purpose. that assumption held for about twenty years.`, }, + 'conference': { + title: '2111', + from: 'a bridge somebody left up', + body: `it is a loop-around in a switch in kansas that somebody wired wrong, or +wired deliberately, decades ago, and then left. + +there is no operator. there is no list of members. there is no way to be +thrown off it. you dial four digits and you are in a room with whoever +else dialled the same four digits, and nobody says their name, and some of +them are fourteen and some of them work for the phone company and it does +not come up. + +it exists because nobody is maintaining it. the moment anyone official +notices it is there, it stops being there. that was true of all of this.`, + }, + + 'routing': { + title: 'the card', + from: 'worked out by hand, and traded', + body: `nobody published these. you worked them out — dialling, listening, +writing down what answered — and then you copied them onto a card, and the +card went in your pocket, and if somebody was alright you copied it out +for them too. + +it is the only documentation the entire long distance network had that was +written by the people using it rather than the people running it.`, + }, + 'tandem-loop': { title: 'you rang the payphone next to you', from: 'the set piece',