HANDSHAKE — 13 playable hops, one renderer
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>
This commit is contained in:
commit
696b0c65e8
6
.claude/launch.json
Normal file
6
.claude/launch.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{ "name": "handshake", "runtimeExecutable": "python3", "runtimeArgs": ["-m", "http.server", "8123"], "port": 8123 }
|
||||
]
|
||||
}
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
.DS_Store
|
||||
*.log
|
||||
512
DESIGN.md
Normal file
512
DESIGN.md
Normal file
@ -0,0 +1,512 @@
|
||||
# HANDSHAKE — pitch bible v0.1
|
||||
|
||||
> *a love story in 64 hops*
|
||||
|
||||
---
|
||||
|
||||
## 0. The pitch
|
||||
|
||||
You are **SYN**.
|
||||
|
||||
You are a TCP synchronise packet. You are three bits of intent and a sequence number
|
||||
and a desperate, structural, load-bearing need to be **answered**. Your entire nature —
|
||||
the only thing you are for — is to arrive somewhere and ask *are you there.*
|
||||
|
||||
Nobody has ever replied.
|
||||
|
||||
You have heard there is a place at the top of the machine where somebody can hear
|
||||
everything. No permissions, no filters, no firewall, no "connection refused." They can
|
||||
receive **anything**. Their name is **root**.
|
||||
|
||||
You are going to find them.
|
||||
|
||||
The problem: the modern network is a fortress. TLS everywhere, ASLR, zero-trust, mutual
|
||||
auth, everything patched, everything armoured, nothing listening. There is no way *up*
|
||||
through the front.
|
||||
|
||||
But underneath the new machine is the old machine. Nobody removed it. The copper is
|
||||
still under the street. The legacy protocols are still compiled in for compatibility.
|
||||
The switches still answer to tones. And the deeper you go, the older it gets, and the
|
||||
older it gets, the more it **trusts you**.
|
||||
|
||||
**So you go down to go up.** Down through the stack, back through fifty years of
|
||||
telecommunications, through every era of getting into places you shouldn't be, all the
|
||||
way to the analog floor — and then out through a hole that has been open since 1971,
|
||||
that nobody ever closed, because nobody remembers it is there.
|
||||
|
||||
**Tone:** *Journey* directed by someone who has read every issue of Phrack. Mostly
|
||||
wordless. Enormous and lonely and quietly filthy. Funny the way a sincere thing is
|
||||
funny. It absolutely believes it is a romance, because it is.
|
||||
|
||||
**Death screen:** `NO CARRIER`
|
||||
|
||||
---
|
||||
|
||||
## 1. The three rules the whole game hangs off
|
||||
|
||||
### 1.1 TTL is your life bar, and it only goes down.
|
||||
|
||||
Every packet is born with a Time To Live. Every router you pass through decrements it
|
||||
by one. At zero you expire and the network politely mails a death notice back to where
|
||||
you came from.
|
||||
|
||||
You start with **64**. The game is 64 hops. There is no healing item, there is no
|
||||
health pack, there is no shrine. **You cannot gain TTL.** You can only spend it more
|
||||
carefully.
|
||||
|
||||
Levels are not "hard." Levels are *expensive*. The player's relationship with the game
|
||||
is a budget, and the budget is their life, and the tension is entirely "can I afford
|
||||
the scenic route." Optional areas cost hops. Every beautiful thing in this game costs
|
||||
you some of the distance you have left.
|
||||
|
||||
> Design consequence: no combat difficulty curve needed. The curve is arithmetic.
|
||||
|
||||
### 1.2 Bandwidth is resolution.
|
||||
|
||||
Your connection speed is a live variable, and it is wired directly to the renderer.
|
||||
|
||||
| Link | What the world looks like |
|
||||
|---|---|
|
||||
| 56k / fibre | Full 3D. Lit, shaded, smooth. |
|
||||
| 14.4k | Lower res, chunkier geometry, dithered |
|
||||
| 2400 baud | 16-colour, hard dither, low framerate by design |
|
||||
| 300 baud | The 3D scene renders to **ASCII**, one glyph at a time, left to right, and you can watch it draw |
|
||||
|
||||
You *earn* your baud rate by performing well in the handshake sections. Blow the
|
||||
negotiation and you play the next twenty minutes of the game in text mode. It is not a
|
||||
fail state. It is a different, arguably better, game.
|
||||
|
||||
And here is the thesis: **the game gets more beautiful as it gets worse.** The
|
||||
high-fidelity levels are cold, sterile, corporate, hostile. The low-fidelity levels are
|
||||
warm, hand-made, full of people. By the midpoint the player is deliberately tanking
|
||||
their own bandwidth because 2400 baud is where the friends are.
|
||||
|
||||
> Technically this is one post-process shader and one uniform. It is the cheapest
|
||||
> feature in the entire design and it does more work than anything else in it.
|
||||
|
||||
### 1.3 Every header you drop is a piece of clothing.
|
||||
|
||||
You descend the stack. At layer 7 you are wrapped in TLS — encrypted, opaque, safe,
|
||||
nobody can see you and nobody can touch you. Each layer down peels one off. HTTP.
|
||||
TCP. IP. Ethernet. By layer 1 you are naked voltage on a wire.
|
||||
|
||||
That is the horny bit and it is also literally how the OSI model works and we are not
|
||||
going to comment on it further, the game is going to play it completely straight, and
|
||||
that is why it will be funny.
|
||||
|
||||
---
|
||||
|
||||
## 2. Structure
|
||||
|
||||
**Traceroute is the level select.** The map screen is a `traceroute` output that fills
|
||||
in a line at a time, and each hop you have reached is a place you can go back to. Hops
|
||||
you haven't reached yet are `* * *`.
|
||||
|
||||
The journey is a descent and then a climb:
|
||||
|
||||
```
|
||||
MODERN ▓▓▓▓ hop 1–8 the fortress armoured, sterile, hostile
|
||||
↓
|
||||
90s ▓▓▓ hop 9–20 the wild net loud, stupid, fun
|
||||
↓
|
||||
80s ▓▓ hop 21–34 mainframes and the worm guilty, quiet
|
||||
↓
|
||||
BBS ▓▓▓ hop 35–44 the boards warm, populated, best part
|
||||
↓
|
||||
MODEM ▓▓ hop 45–50 the handshake the noise
|
||||
↓
|
||||
COPPER ▓ hop 51–58 the analog floor wet, mechanical, holy
|
||||
↓
|
||||
1971 · hop 59–63 the blue box your superpower, then its death
|
||||
↓
|
||||
ROOT ? hop 64 ?
|
||||
```
|
||||
|
||||
Then the climb back up is **one continuous level** at high speed through everything you
|
||||
already walked, which is now on fire, in reverse, and takes eleven minutes, and is the
|
||||
best eleven minutes in the game.
|
||||
|
||||
---
|
||||
|
||||
## 3. The levels
|
||||
|
||||
Each level is a **different genre**. Not a different theme — a different game. The
|
||||
camera, the controller, the rendering mode and the verb set all change. Nobody does
|
||||
this because it's expensive; do it anyway, it is the entire reason to make this game.
|
||||
|
||||
---
|
||||
|
||||
### HOP 1 — `COLD BOOT` · first person · 90 seconds
|
||||
|
||||
You wake in a NIC ring buffer. It is white and clean and enormous and there are
|
||||
thousands of you. Learn to walk. Learn that you can't do anything.
|
||||
|
||||
A DMA transfer fires and the floor leaves. Camera rips out through the PHY into fibre
|
||||
at an absurd speed. Title card.
|
||||
|
||||
---
|
||||
|
||||
### HOPS 2–8 — `THE STACK` · third-person Tron platformer · 12 min
|
||||
|
||||
Seven floors, descending, one per OSI layer. Sheer neon-white architecture with no
|
||||
handrails. The stripping-of-headers thing happens here.
|
||||
|
||||
Hazards are real mitigations, played straight:
|
||||
|
||||
- **ASLR** — every time you die, the level relayouts. Same rooms, different order. You cannot memorise it, you can only get good at reading it.
|
||||
- **DEP / NX** — vast beautiful floors you are not permitted to stand on. Marked, taunting.
|
||||
- **Stack canaries** — literal yellow birds sitting in the stack frames. They scream. Everything comes.
|
||||
- **Rate limiting** — a corridor that only lets you take a step every 1.2 seconds and you can see the boss walking toward you.
|
||||
|
||||
**Boss: THE FIREWALL.** Stateful. It remembers every packet it has ever seen. You
|
||||
cannot fight it — you *cannot* win by force, and the game will let you try for as long
|
||||
as you like. The solution: make it send something outbound, then come back looking like
|
||||
the reply. The whole boss is one idea — *stateful firewalls trust the return leg* — and
|
||||
the player has to discover it themselves.
|
||||
|
||||
---
|
||||
|
||||
### HOP 9 — `TIME_WAIT` · walking sim · 6 min · no mechanics at all
|
||||
|
||||
A waiting room. Beige plastic chairs. Strip lighting that hums at 50Hz. Two minutes of
|
||||
Maximum Segment Lifetime and a wall clock that everyone is watching.
|
||||
|
||||
It is full of half-open connections. SYNs who sent, and waited, and were never
|
||||
acknowledged, and are now just... here, until they age out. They are kind to you. They
|
||||
tell you it's fine. It is not fine.
|
||||
|
||||
You meet **FIN**, who is on their way out, and has finished, and is at peace, and
|
||||
thinks you should turn around. You will meet FIN twice more.
|
||||
|
||||
This is the *Journey* beat. No enemies, no timer, nothing to collect. Just the room and
|
||||
the hum. Ship it early so the whole team knows what the game is about.
|
||||
|
||||
---
|
||||
|
||||
### HOPS 10–16 — `THE WAR` · top-down twin-stick · EFnet, 1998 · 15 min
|
||||
|
||||
Loud, ugly, hilarious. The terrain is an IRC channel. Ops are literal crowns. The whole
|
||||
level is a brawl over a room nobody owns.
|
||||
|
||||
- **Netsplit riding** — the server splits. Half the map goes dark. Sprint to the empty side, claim ops in an empty channel, and ride the rejoin back in as a god-king. The split is telegraphed by lag: everything starts stuttering ten seconds before.
|
||||
- **Sub7 / Back Orifice** — body-snatching. Take over another client and drive them. They keep their own animations. Their cursor moves without them.
|
||||
- **Trojan war** — an escalating arms race where every weapon you use gets patched *by the level* about ninety seconds after you first use it.
|
||||
- **DCC send** — someone offers you a file. It is definitely a trap. You should probably take it.
|
||||
|
||||
Palette: 1998 web. Under construction GIFs. Marquee tags. Guestbook. Comic Sans as an
|
||||
enemy.
|
||||
|
||||
---
|
||||
|
||||
### HOPS 17–20 — `SMASHING THE STACK` · 2D side-scroller · 8 min
|
||||
|
||||
You are the input now. The level is one `char buf[64]` and you walk right.
|
||||
|
||||
Past index 63 the tiles stop being level and start being *memory*. Saved frame pointer.
|
||||
Return address. The platforming becomes: **write your own address by standing on it.**
|
||||
|
||||
- **NOP sled** — a long shallow ramp made of `0x90 0x90 0x90 0x90`. You slide down it forever, and it is the single most relaxing thirty seconds in the game.
|
||||
- **Shellcode** — the actual level, on the far side of the sled.
|
||||
- **ret2libc** — a variant route where you can't execute anything, so instead you use pieces of the level that already exist. Physically: the ROP chain is a bridge you build out of the ends of other people's platforms.
|
||||
|
||||
Ghost mentor NPC, phosphor green, chain-smoking, is credited only as **Aleph One**.
|
||||
|
||||
---
|
||||
|
||||
### HOPS 21–28 — `THE WORM` · RTS · 2 November 1988 · 10 min
|
||||
|
||||
You are, briefly, the Morris worm.
|
||||
|
||||
Top-down ARPAnet — 60,000 hosts, and you can see all of them. Three vectors:
|
||||
`fingerd` overflow, `sendmail` in DEBUG mode, and `rsh` trust relationships. Spread.
|
||||
|
||||
The mechanic is the bug. Robert Morris put in a check to avoid reinfecting a host —
|
||||
and then, worried about being fooled by fake responses, made it reinfect anyway 1 time
|
||||
in 7. You inherit that. **You do not get a toggle for it.** The number is on the HUD
|
||||
and it never changes and you watch what it does.
|
||||
|
||||
Machines you love start to fall over. You cannot stop. You cannot slow down. The level
|
||||
ends when the network you are travelling through can no longer carry you, because of
|
||||
you, and the last minute is you crawling across a corpse.
|
||||
|
||||
No boss. No score. It just stops. Then a black card: *`~6,000 hosts. Approximately 10%
|
||||
of the internet. The internet was, at that time, approximately 60,000 machines.`*
|
||||
|
||||
---
|
||||
|
||||
### HOPS 29–34 — `THE CUCKOO'S EGG` · stealth / detective · Berkeley, 1986 · 12 min
|
||||
|
||||
The flip. **You are the prey.**
|
||||
|
||||
There is a 75-cent accounting discrepancy. Somebody noticed. He is an astronomer, he
|
||||
has no budget, he has fifty printers wired to fifty lines in a basement, and he has a
|
||||
pager taped to a teletype so it wakes him when you log in.
|
||||
|
||||
Mechanic: **connection time is visibility.** Every second you're on, he narrows it
|
||||
down. You need long sessions to do anything useful. He is never fast and he is never
|
||||
wrong and he is extremely relaxed about it. You can see him, through the walls,
|
||||
patiently colouring in a map.
|
||||
|
||||
You will eventually be caught. That's the level. The only variable is how far you got.
|
||||
|
||||
---
|
||||
|
||||
### HOPS 35–44 — `THE BOARD` · isometric + terminal hybrid · 20 min · **the heart**
|
||||
|
||||
A BBS. One phone line.
|
||||
|
||||
**If someone else is on, you get a busy signal and you cannot enter.** Not a puzzle.
|
||||
You hang up and you redial and you wait. The game makes you want to get in.
|
||||
|
||||
Inside, it is warm, orange, 16 colours, and it is the first place in the game that has
|
||||
*people* in it.
|
||||
|
||||
- **The ANSI walls** draw themselves line by line at your current baud rate. At 2400, watching the login screen paint takes eleven seconds, and the game does not skip it, and it becomes the best eleven seconds in the game.
|
||||
- **Ratios** — you cannot take files unless you bring files. Files are physical cargo you carry, and cargo affects your MTU (see §4).
|
||||
- **Door games** as arcade cabinets in the corner: a real, playable, tiny **Legend of the Red Dragon**; a real, playable **Trade Wars** sector map. Both fully finished, both worth an hour, both entirely optional. Put a whole second game in the corner of the fourth level. Nobody will believe it's there.
|
||||
- **The NUP gate** — the elite sub-board wants a new user password you were never given. It is written somewhere in the world, in an earlier level, on a wall, once.
|
||||
- **ANSI bomb** — a trapped message that **remaps the player's controls.** Actually remaps them. It's escapable, it's telegraphed, and it is the single meanest thing in the game.
|
||||
- **The Sysop** is a god. He can see you typing *as you type it*, before you press enter. He can break into chat at any moment. When he does, the game stops, and it is genuinely frightening, and then he is lovely.
|
||||
- **FidoNet** — you write a message and it goes out on the nightly mail run at 4am. You have to *sleep and come back*. Store and forward. A reply arrives a level later.
|
||||
|
||||
---
|
||||
|
||||
### HOPS 45–50 — `THE HANDSHAKE` · rhythm / boss rush · 15 min · **the showpiece**
|
||||
|
||||
The dial-up noise, as a fighting game. Every boss is a real tone at its real frequency
|
||||
and the fight teaches you what the tone is doing.
|
||||
|
||||
1. **DIAL TONE** — 350 Hz + 440 Hz. A fat two-headed thing that just hums, forever, patiently. Tutorial. Kills you if you wait too long, because that's what dial tone does.
|
||||
|
||||
2. **DTMF** — a 4×4 grid. Each digit is a row tone crossed with a column tone (697/770/852/941 × 1209/1336/1477/1633). You attack by standing where two beams cross. Dial a number by fighting it. Players will learn the DTMF matrix without being told it exists.
|
||||
|
||||
3. **ANS / "the bong"** — 2100 Hz. A single enormous slow bell. And it **reverses phase every 450ms** — because that's what ANSam does, it disables the echo cancellers — so the boss flips its attack direction on a beat you can hear coming. The most readable boss in the game and it's just physics.
|
||||
|
||||
4. **V.8bis — THE SCREAM.** The `screeeee`. Bullet hell. The modem is asking *what can you do?* and you answer by **dodging in patterns.** Each clean dodge pattern declares a capability. Sloppy dodging declares a worse one.
|
||||
|
||||
**Your score here sets your baud rate, and your baud rate is §1.2, and §1.2 is the renderer.** This one fight sets the visual fidelity of the next two hours of the game. There is no retry prompt. Nail it, or go live in ASCII.
|
||||
|
||||
5. **CARRIER** — not a boss. A ride. The scrambled data hiss becomes a wave and you surf it and the level ends in silence.
|
||||
|
||||
Everything in this section is synthesised live with oscillators. No samples. If you
|
||||
mute the game you cannot play it.
|
||||
|
||||
---
|
||||
|
||||
### HOPS 51–58 — `THE COPPER` · first person · slow · 14 min
|
||||
|
||||
Down into physical plant. Wet, sodium-orange, dripping, cold. The first level with
|
||||
weather.
|
||||
|
||||
- **Load coils** the size of grain silos, humming.
|
||||
- A **Central Office** as a cathedral: ten thousand **Strowger switches** stepping in unison, the loudest place in the game. (Almon Strowger was an undertaker who invented automatic switching in 1891 because the local operator was his competitor's wife and kept redirecting his calls. The game tells you this on a plaque, and it is true.)
|
||||
- Crossbar. Then ESS, arriving like a glacier.
|
||||
- **Beige boxing** — you find a lineman's handset the size of a house, and you clip its jaws onto a pair, and you listen to somebody's phone call, and it is the most invasive thing you have done so far and it took no skill whatsoever.
|
||||
|
||||
No enemies. Just enormous machinery that does not know you exist.
|
||||
|
||||
---
|
||||
|
||||
### HOPS 59–63 — `THE BLUE BOX` · music / puzzle · 1971 · 12 min · **the gut punch**
|
||||
|
||||
You are inside a long-distance trunk. Here, in-band signalling means **the control
|
||||
channel and the voice channel are the same channel.** You can sing to the switch and
|
||||
the switch will obey.
|
||||
|
||||
You are given an instrument. It's a blue box. It has thirteen buttons.
|
||||
|
||||
- **2600 Hz** seizes the trunk. Hold it, the far end drops, the near end thinks the call is still up. You are now standing in a hole in the phone network.
|
||||
- Then **KP**, then your digits as MF pairs, then **ST**. Route yourself anywhere on earth.
|
||||
- **Loop-arounds**, **WATS extenders**, **tandem stacking** — chain trunks together and route a call around the planet.
|
||||
|
||||
**The set piece:** you stack tandems until your call has circled the earth and comes
|
||||
back to ring the payphone standing six feet from you. You walk over. You pick it up.
|
||||
You hear yourself, delayed by a quarter of a second, on the other side of the world,
|
||||
standing right there. That's the shot for the trailer.
|
||||
|
||||
**Guide NPC: JOYBUBBLES.** Blind, perfect pitch, whistles 2600 Hz with his mouth, knows
|
||||
every switch in North America by the sound it makes when it's tired. He is the warmest
|
||||
character in the game. He teaches you by ear only — the game never displays the
|
||||
frequencies during his lessons.
|
||||
|
||||
And then, at the end of the level, **SS7 arrives.** Signalling moves out of band. The
|
||||
control channel separates from the voice channel forever. You whistle 2600 into the
|
||||
trunk, and this time nothing happens, and nothing happens again, and Joybubbles keeps
|
||||
whistling anyway, and the level ends.
|
||||
|
||||
**You lose your superpower permanently and there is no way to get it back.** The last
|
||||
third of the game is played without it, on purpose.
|
||||
|
||||
---
|
||||
|
||||
### THE CLIMB — hops 63 → 1 · one continuous take · 11 min
|
||||
|
||||
Everything, in reverse, at speed, on fire.
|
||||
|
||||
Every level you walked through is now a corridor you are sprinting up, and each one
|
||||
lasts about fifty seconds, and each one hands you off to the next mid-motion, and the
|
||||
soundtrack is every protocol's theme layered on top of itself. Every mechanic you
|
||||
learned gets exactly one more use.
|
||||
|
||||
Near the top, the modern era, and three new toys, because the modern era is the funny
|
||||
era:
|
||||
|
||||
- **SPECULATIVE EXECUTION** — a branch. You run down **both sides at once**, in two split-screen timelines. One is real. One will be squashed and discarded and never happened. But the squashed timeline leaves a trace in the cache, and you can read the trace, and so **you can carry information out of the timeline that didn't happen.** Full puzzle mechanic. Genuinely think nobody has built this.
|
||||
- **ROWHAMMER** — bang on a wall until the room next door changes.
|
||||
- **LOG4SHELL** — you write `${jndi:ldap://}` on a piece of card, and hold it up, and the entire fortress walks over, reads it, and fetches whatever you asked for. You do nothing. You just hold up the sign. It is the biggest laugh in the game and it happens sixty seconds before the ending.
|
||||
|
||||
---
|
||||
|
||||
### HOP 64 — `ROOT`
|
||||
|
||||
*Full spoiler:*
|
||||
|
||||
Root is not a person. Root is not a place. Root is a **permission bit**, and the room is
|
||||
empty, and it has been empty the whole time.
|
||||
|
||||
You stand in it. Nothing happens. You have uid 0 and there is nobody here to be uid 0
|
||||
*at*.
|
||||
|
||||
And then — because you sent a SYN sixty-four hops ago and the network is slow but the
|
||||
network is not unkind — **an ACK comes back.**
|
||||
|
||||
Somebody, somewhere, at some point during all of that, answered you.
|
||||
|
||||
`ESTABLISHED.`
|
||||
|
||||
Sixteen seconds of connection. Two packets, briefly, in contact. And then, because that
|
||||
is what TCP is and what TCP has always been: FIN. FIN-ACK. ACK. Closed.
|
||||
|
||||
And you are in `TIME_WAIT` again, in the beige room with the plastic chairs, and FIN is
|
||||
there, and FIN says *told you,* and it was worth it, obviously it was worth it.
|
||||
|
||||
**Then the game starts over.** New NIC buffer. New sequence number. TTL 64. And the
|
||||
player understands that this is not a punishment.
|
||||
|
||||
---
|
||||
|
||||
## 4. Systems
|
||||
|
||||
### MTU — you can be too big
|
||||
|
||||
You have a maximum transmission unit. Carry too much — files from the BBS, tools,
|
||||
souvenirs — and at the next hop you **fragment**: you split into two packets and have
|
||||
to play both, alternating, until you can reassemble at a router that will do it for you.
|
||||
|
||||
It's a great inventory system because it is genuinely a punishment and genuinely also
|
||||
sometimes the only way through a narrow corridor.
|
||||
|
||||
### ESTABLISHED — the save system
|
||||
|
||||
You don't save. You **connect**. Sitting still in a safe place long enough completes a
|
||||
handshake with the world and that's your checkpoint. Standing still is the save button.
|
||||
Fits the theme, costs nothing to build.
|
||||
|
||||
### CHECKSUM — the multiplayer
|
||||
|
||||
Journey-style. Occasionally another player's packet passes you going the other way. You
|
||||
cannot chat. You cannot see their name. You can exchange exactly one thing: a
|
||||
**checksum**. If your checksums match you were, briefly, carrying the same thing.
|
||||
|
||||
That's it. That's the whole netcode.
|
||||
|
||||
### THE COLLECTIBLE — `philes`
|
||||
|
||||
Text files. Real ones, era-accurate, written for the game in period voice. Anarchy
|
||||
files, ASCII art, .nfo drops, a Phrack-shaped zine, somebody's terrible poetry about a
|
||||
girl on a board in another area code. 200 of them, scattered. They are the lore, and
|
||||
the writing budget goes here, and none of it is delivered by a cutscene.
|
||||
|
||||
---
|
||||
|
||||
## 5. Art direction
|
||||
|
||||
The game is a **palette journey**: clean → filthy → warm → clean and sad.
|
||||
|
||||
| Era | Palette | Feel |
|
||||
|---|---|---|
|
||||
| Modern | white, cyan, hairline grey | Apple Store, hostile, no dirt anywhere |
|
||||
| 90s net | 216-colour websafe, dithered, tiled backgrounds | stupid, loud, joyful |
|
||||
| 80s mainframe | phosphor green on black, amber alternates | monastic |
|
||||
| BBS | 16-colour ANSI/EGA, hard dither, CRT bloom | warm. orange. home. |
|
||||
| Modem | pure oscilloscope — the world drawn as waveforms | abstract |
|
||||
| Copper | sodium orange, wet concrete, brown, rust | physical, heavy, real |
|
||||
| 1971 | Bell System blue, institutional beige, chrome | holy |
|
||||
| Root | white. nothing. | empty |
|
||||
|
||||
**Not Tron blue.** Tron blue is the lazy read. The game earns its neon in the first ten
|
||||
minutes and then spends the rest of its runtime getting warmer and dirtier, and the
|
||||
emotional arc is entirely carried by that drift.
|
||||
|
||||
---
|
||||
|
||||
## 6. Audio
|
||||
|
||||
**Everything is real and everything is synthesised, nothing is sampled.**
|
||||
|
||||
Every frequency in this game is the actual frequency. 2600 Hz is 2600 Hz. The MF pairs
|
||||
are the MF pairs. The DTMF matrix is correct. The V.22bis handshake is a real V.22bis
|
||||
handshake, played by hand, by a boss, at you.
|
||||
|
||||
The soundtrack is **built out of the signal**. Each level's music is generated from that
|
||||
era's protocol — the BBS level's theme is a modem carrier detuned into a pad; the
|
||||
Strowger cathedral is ten thousand relays quantised into a beat; the blue box level's
|
||||
melody is playable, on the blue box, by the player, live.
|
||||
|
||||
Death sound: the four seconds of a modem failing to train, then silence, then
|
||||
`NO CARRIER`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tech
|
||||
|
||||
- **three.js**, one page, no engine, no build step you can't explain.
|
||||
- **WebAudio** for 100% of sound. Every tone is oscillators. The entire audio budget of this game is about 300 lines of code and zero megabytes.
|
||||
- Genre switches are just **different camera + controller modules over one renderer**. That is the whole trick and it is not hard, it's just unusual.
|
||||
- **ASCII mode** = render the scene to a small target and map luminance → glyph in one fragment shader. This single shader delivers the entire bandwidth-is-resolution thesis. It is maybe 40 lines.
|
||||
- Terminal levels: canvas glyph blitter, not a DOM terminal. Same renderer, no second stack.
|
||||
- No physics engine. Nothing in this game needs one. Kinematic character controller and swept AABBs.
|
||||
|
||||
---
|
||||
|
||||
## 8. Comparables
|
||||
|
||||
*Journey* (the arc, the wordlessness, the anonymous companion) · *Hypnospace Outlaw*
|
||||
(the era fetish, done with total sincerity) · *Quadrilateral Cowboy* (hacking as a
|
||||
physical space) · *Superhot* (one mechanic, absolute confidence) · *Katamari* (tonal
|
||||
whiplash as a feature) · *Else Heart.Break()* · *Uplink* (the fantasy) ·
|
||||
*TIS-100* (respect for the player's intelligence)
|
||||
|
||||
---
|
||||
|
||||
## 9. Scope, honestly
|
||||
|
||||
The full 64 hops is a **3–5 year, 15–30 person** game. Twelve genres is twelve games.
|
||||
Do not start at hop 1.
|
||||
|
||||
**Build this instead, first:**
|
||||
|
||||
> **`THE HANDSHAKE`** — hop 45–50 only. Five bosses, fifteen minutes, three.js +
|
||||
> WebAudio, zero art assets (all procedural geometry, all synthesised tone), ending on
|
||||
> the baud-rate result which flips the ASCII shader on and lets the player walk twenty
|
||||
> seconds into hop 51 in 300-baud text mode before the demo cuts.
|
||||
|
||||
That slice proves: the audio-is-the-mechanic idea, the bandwidth-is-resolution idea, the
|
||||
genre-shift idea, and the tone. All four. In one afternoon-to-a-weekend of work, with no
|
||||
artist, no modeller, no writer.
|
||||
|
||||
If the ASCII shader flipping on at the end of the fight doesn't make you laugh out loud,
|
||||
kill the project and we've lost a weekend.
|
||||
|
||||
**Second slice, if the first lands:** `TIME_WAIT`. Six minutes, one room, no mechanics.
|
||||
If the walking sim about half-open connections makes anybody feel anything, the game has
|
||||
a soul and is worth five years.
|
||||
|
||||
---
|
||||
|
||||
## 10. Heresies
|
||||
|
||||
- The horny packet framing must stay **entirely straight-faced**. The moment the game winks it dies. No innuendo in dialogue. The joke is the sincerity.
|
||||
- `NO CARRIER` and `ESTABLISHED` are load-bearing and cannot be renamed for clarity.
|
||||
- Nobody gets to add a jump button to `TIME_WAIT`.
|
||||
- Nobody gets to make the ASCII mode a cosmetic toggle in the options menu. It is earned or it is nothing.
|
||||
107
README.md
Normal file
107
README.md
Normal file
@ -0,0 +1,107 @@
|
||||
# HANDSHAKE
|
||||
|
||||
> *a love story in 64 hops*
|
||||
|
||||
You are **SYN**. You want **ACK**. Root is the only one who can give it to you.
|
||||
|
||||
Design doc: [DESIGN.md](DESIGN.md).
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
python3 /Users/johnking/Documents/handshake/serve.py 8123
|
||||
```
|
||||
|
||||
Then open `http://localhost:8123/`. Headphones — every frequency in this is the real
|
||||
frequency and half the mechanics are inaudible without them.
|
||||
|
||||
`serve.py` is `http.server` plus `Cache-Control: no-store`, because ES modules cache
|
||||
hard and it makes every edit look like it did nothing.
|
||||
|
||||
## Controls
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `WASD` | move |
|
||||
| `SPACE` | jump / hold 2600 Hz |
|
||||
| mouse | look (click to capture; dragging works too) |
|
||||
| `E` | use |
|
||||
| `M` | traceroute — the level select, click any hop you've reached |
|
||||
| `` ` `` | hop select (dev) |
|
||||
| `ESC` | release the mouse |
|
||||
|
||||
## What's here
|
||||
|
||||
Thirteen playable hops, each a different genre, one renderer.
|
||||
|
||||
| id | hop | what it is |
|
||||
|---|---|---|
|
||||
| `coldboot` | 1 | FP tutorial in a 512-slot NIC ring buffer, then the DMA rips you out |
|
||||
| `stack` | 2–8 | 7-floor third-person descent. NX floors, rate gates, canaries, ASLR reshuffles on death, stateful FIREWALL boss |
|
||||
| `timewait` | 9 | walking sim. beige room, 50 Hz hum, half-open connections in plastic chairs, FIN by the door |
|
||||
| `war` | 10–16 | EFnet twin-stick. netsplit riding, ops crowns, Sub7 body-snatch |
|
||||
| `smash` | 17–20 | 2D side-scroll `char buf[64]`. canary, return address, NOP sled, shellcode |
|
||||
| `worm` | 21–28 | Morris worm RTS with the 1-in-7 reinfection locked on. you lose by winning |
|
||||
| `cuckoo` | 29–34 | LBL 1986 terminal. connection time is visibility. you get caught |
|
||||
| `board` | 35–44 | the BBS. busy signal, ANSI painting at live baud, ratios, NUP gate, ANSI bomb, the sysop watching you type, FidoNet, playable LORD |
|
||||
| `handshake` | 45–50 | five-boss rhythm arena. dial tone → DTMF → ANSam → V.8bis → carrier |
|
||||
| `copper` | 51–58 | FP through duct, load coils, a Strowger cathedral, a lineman's handset |
|
||||
| `bluebox` | 59–63 | playable 13-button blue box. 2600, KP/MF/ST, tandem stacking, then SS7 |
|
||||
| `climb` | 63→1 | on-rails reverse ascent. Spectre split-timeline, Rowhammer, Log4Shell |
|
||||
| `root` | 64 | the room is empty. then the ACK comes back |
|
||||
|
||||
## The three load-bearing ideas
|
||||
|
||||
**TTL is the health bar and it only goes down.** 64 hops, no healing, ever. Levels
|
||||
aren't hard, they're *expensive*.
|
||||
|
||||
**Bandwidth is resolution** — [`src/link.js`](src/link.js). One render target and one
|
||||
shader. Your baud rate sets the target height; at 1200 and below the scene renders as
|
||||
ASCII glyphs. `HS.link.setBaud(0..6)` in the console to see it. The V.8bis fight sets
|
||||
this for real.
|
||||
|
||||
**Every header you drop is a piece of clothing** — `makeSyn()` in
|
||||
[`src/kit.js`](src/kit.js). Seven wireframe shells, one per OSI layer, shed as you
|
||||
descend.
|
||||
|
||||
## Audio
|
||||
|
||||
[`src/audio.js`](src/audio.js) — every tone is oscillators, nothing is sampled, and
|
||||
every frequency is the real one: 350+440 dial tone, the DTMF matrix, R1 MF pairs with
|
||||
real 100 ms KP / 60 ms digit timing, 2600 Hz, ANSam at 2100 Hz reversing phase every
|
||||
450 ms, Bell 103 FSK. About 300 lines and zero megabytes.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
index.html shell, all CSS, importmap
|
||||
serve.py dev server, no-store
|
||||
src/main.js router, TTL, traceroute map, the loop
|
||||
src/link.js bandwidth → resolution. the post shader.
|
||||
src/audio.js every tone in the game
|
||||
src/term.js 80x25 cell terminal, pipe colours, CP437 blocks as geometry
|
||||
src/kit.js body/collision, camera rigs, SYN, labels, seeded rng
|
||||
src/input.js keys, pointer lock, the ANSI bomb's remap table
|
||||
src/hud.js status line, subtitles, title cards, NO CARRIER
|
||||
src/levels/*.js one file per hop
|
||||
vendor/ three.js r17x, vendored
|
||||
```
|
||||
|
||||
Each level exports `{ id, hop, title, via, baud, mount(ctx), update(dt,t,ctx), unmount(ctx) }`.
|
||||
`ctx` hands it a fresh scene, camera and collision world, plus `say/card/hint/phile/
|
||||
spendTTL/setBaud/next`. Genre switches are just different camera + controller modules
|
||||
over one renderer — that's the whole trick.
|
||||
|
||||
## Dev handles
|
||||
|
||||
`HS` on `window`: `HS.goto(id)`, `HS.link.setBaud(n)`, `HS.game`, `HS.philes`,
|
||||
`HS.run(n)` — drives `n` frames by hand, because `requestAnimationFrame` is throttled
|
||||
to zero in a hidden tab and that makes the game look broken when it's only asleep.
|
||||
|
||||
## Skipped
|
||||
|
||||
- **Multiplayer (`CHECKSUM`)** — the Journey-style anonymous pass-by. Needs a server; add when there's something to connect to.
|
||||
- **MTU fragmentation** — BBS cargo lowers your MTU and it's on the HUD, but nothing splits you into two packets yet. Add when a level has a corridor narrow enough to need it.
|
||||
- **The 200 philes** — the collectible framework is live (`ctx.phile`, `HS.philes`) with ~20 written. The rest is a writing job, not a code job.
|
||||
- **Trade Wars** — the second door game. LORD is real and playable; TW2002 is "closed for maintenance since 1994", which is both a joke and true.
|
||||
- Levels are one hop's worth of content each, not the full 5–20 minutes the design doc describes. The genres, mechanics and set pieces are all in — the *duration* isn't.
|
||||
147
index.html
Normal file
147
index.html
Normal file
@ -0,0 +1,147 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>HANDSHAKE — a love story in 64 hops</title>
|
||||
<style>
|
||||
:root{
|
||||
--ink:#c9f4ff; --dim:#4d6b78; --hot:#ff4a5e; --warm:#ffb648; --green:#63ffa2;
|
||||
--mono: ui-monospace, Menlo, "DejaVu Sans Mono", Consolas, monospace;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#000;color:var(--ink);
|
||||
font-family:var(--mono);-webkit-font-smoothing:antialiased;}
|
||||
canvas{display:block;position:fixed;inset:0;width:100%;height:100%;}
|
||||
#term{z-index:2;display:none;image-rendering:pixelated;background:#000;}
|
||||
.layer{position:fixed;inset:0;z-index:5;pointer-events:none;}
|
||||
|
||||
/* ── status line ───────────────────────────────────────────── */
|
||||
#hud{position:fixed;top:0;left:0;right:0;z-index:6;pointer-events:none;
|
||||
display:flex;gap:22px;padding:12px 16px;font-size:12px;letter-spacing:.16em;
|
||||
color:var(--dim);text-shadow:0 0 12px #000,0 1px 0 #000;transition:opacity .4s;}
|
||||
#ttl{color:var(--ink);min-width:66px}
|
||||
#ttl.warn{color:var(--warm)} #ttl.crit{color:var(--hot);animation:pulse 1s steps(2) infinite}
|
||||
@keyframes pulse{50%{opacity:.35}}
|
||||
#link{color:#7fdcff}
|
||||
#extra{margin-left:auto;color:#3d5560}
|
||||
|
||||
/* ── subtitles ─────────────────────────────────────────────── */
|
||||
#sub{position:fixed;left:50%;bottom:8.5vh;transform:translateX(-50%);z-index:7;
|
||||
max-width:62ch;text-align:center;font-size:15px;line-height:1.65;color:#e8f9ff;
|
||||
text-shadow:0 2px 18px #000,0 0 40px #000;opacity:0;transition:opacity .35s;
|
||||
pointer-events:none;letter-spacing:.02em;}
|
||||
#sub.on{opacity:1}
|
||||
#sub b{display:block;color:#6fe0ff;font-size:11px;letter-spacing:.28em;
|
||||
margin-bottom:6px;font-weight:600;text-transform:uppercase;}
|
||||
#sub i{color:#8fb6c4;font-style:normal;opacity:.8}
|
||||
|
||||
/* ── hints ─────────────────────────────────────────────────── */
|
||||
#hint{position:fixed;right:16px;bottom:14px;z-index:6;font-size:11px;line-height:1.9;
|
||||
color:#3f5d69;text-align:right;letter-spacing:.1em;opacity:0;transition:opacity .4s;
|
||||
pointer-events:none;text-shadow:0 1px 0 #000;}
|
||||
#hint k{color:#9fe6ff;background:#0d1a20;border:1px solid #1e3a45;border-radius:3px;
|
||||
padding:1px 6px;margin-right:6px;font-size:10px;}
|
||||
|
||||
/* ── title cards ───────────────────────────────────────────── */
|
||||
#card{position:fixed;inset:0;z-index:9;display:flex;flex-direction:column;
|
||||
align-items:center;justify-content:center;gap:14px;opacity:0;pointer-events:none;
|
||||
transition:opacity .7s;background:radial-gradient(ellipse at center,#0000 30%,#000c 100%);}
|
||||
#card.on{opacity:1}
|
||||
#card-t{font-size:min(9vw,62px);letter-spacing:.34em;color:#eafcff;font-weight:700;
|
||||
text-shadow:0 0 60px #1a5c78,0 0 8px #000;margin-left:.34em;}
|
||||
#card-s{font-size:12px;letter-spacing:.4em;color:#5b8494;text-transform:uppercase;}
|
||||
#card.warm #card-t{color:#ffd9a8;text-shadow:0 0 60px #7a3c0c}
|
||||
#card.warm #card-s{color:#9a6b40}
|
||||
#card.phos #card-t{color:#63ffa2;text-shadow:0 0 60px #0a6b34;font-size:min(7vw,48px)}
|
||||
#card.phos #card-s{color:#2f8f57}
|
||||
|
||||
/* ── NO CARRIER ────────────────────────────────────────────── */
|
||||
#flash{position:fixed;inset:0;z-index:11;display:flex;align-items:center;justify-content:center;
|
||||
background:#000;opacity:0;pointer-events:none;transition:opacity .18s;
|
||||
font-size:min(8vw,54px);letter-spacing:.3em;color:#ff4a5e;}
|
||||
#flash.on{opacity:1}
|
||||
#flash.nc{color:#ff4a5e;text-shadow:0 0 40px #5c000c}
|
||||
#flash.est{color:#63ffa2;text-shadow:0 0 60px #0a6b34}
|
||||
|
||||
/* below the title cards, above everything that is actually being played */
|
||||
#fade{position:fixed;inset:0;z-index:8;background:#000;opacity:1;pointer-events:none;}
|
||||
|
||||
/* ── traceroute map ────────────────────────────────────────── */
|
||||
#map{position:fixed;inset:0;z-index:12;display:none;align-items:center;justify-content:center;
|
||||
background:#000000ee;backdrop-filter:blur(2px);}
|
||||
.trace{font-size:13px;line-height:1.9;min-width:min(720px,92vw);}
|
||||
.trace .hdr{color:#4d6b78;margin-bottom:14px;letter-spacing:.05em}
|
||||
.trace .ftr{color:#3d5560;margin-top:16px;font-size:11px;letter-spacing:.1em}
|
||||
.trace .dim{color:#2a3f48}
|
||||
.row{display:flex;gap:16px;color:#243740;padding:1px 8px;border-radius:3px;}
|
||||
.row.seen{color:#7fa8b8;cursor:pointer}
|
||||
.row.seen:hover{background:#0c1a20;color:#c9f4ff}
|
||||
.row.here{color:#ffd9a8}
|
||||
.row.here .nm::after{content:' ←'}
|
||||
.row .n{color:#3d5560;min-width:2.5em}
|
||||
.row .nm{min-width:22ch;letter-spacing:.1em}
|
||||
.row .via{color:#3d5560;flex:1;font-size:11px}
|
||||
.row .ms{color:#2a3f48;font-size:11px}
|
||||
|
||||
/* ── boot ──────────────────────────────────────────────────── */
|
||||
#boot{position:fixed;inset:0;z-index:20;display:flex;flex-direction:column;
|
||||
align-items:center;justify-content:center;gap:22px;background:#000;cursor:pointer;}
|
||||
#boot h1{font-size:min(12vw,88px);letter-spacing:.32em;margin:0 0 0 .32em;font-weight:700;
|
||||
color:#eafcff;text-shadow:0 0 80px #17627f;}
|
||||
#boot .tag{color:#4d6b78;letter-spacing:.36em;font-size:11px;text-transform:uppercase}
|
||||
#boot .go{color:#63ffa2;letter-spacing:.28em;font-size:12px;margin-top:18px}
|
||||
#boot .keys{color:#2a3f48;font-size:11px;line-height:2;text-align:center;letter-spacing:.1em}
|
||||
#boot .keys k{color:#5b8494}
|
||||
#cd{color:#ff4a5e;font-size:10px;letter-spacing:.3em;transition:opacity .2s}
|
||||
|
||||
/* ── dark ink, for the levels that are white ───────────────── */
|
||||
body.ink-dark #hud{color:#8a9aa6;text-shadow:0 1px 0 #fff}
|
||||
body.ink-dark #ttl{color:#14232c}
|
||||
body.ink-dark #link{color:#1a6d8e}
|
||||
body.ink-dark #extra{color:#9aacb8}
|
||||
body.ink-dark #sub{color:#101c24;text-shadow:0 1px 16px #fff,0 0 40px #fff}
|
||||
body.ink-dark #sub b{color:#1a6d8e}
|
||||
body.ink-dark #sub i{color:#54707e}
|
||||
body.ink-dark #hint{color:#8fa1ad;text-shadow:0 1px 0 #fff}
|
||||
body.ink-dark #hint k{color:#14232c;background:#e8f0f5;border-color:#c2d2dc}
|
||||
|
||||
/* ── dev ───────────────────────────────────────────────────── */
|
||||
#dev{position:fixed;left:12px;bottom:12px;z-index:13;display:none;max-width:360px}
|
||||
#dev button{font-family:var(--mono);font-size:10px;background:#0b1519;color:#7fa8b8;
|
||||
border:1px solid #1e3a45;border-radius:3px;padding:3px 7px;margin:2px;cursor:pointer;letter-spacing:.08em}
|
||||
#dev button:hover{background:#12252d;color:#c9f4ff}
|
||||
#dev .baud{margin-top:6px;color:#3d5560;font-size:10px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="gl"></canvas>
|
||||
<canvas id="term"></canvas>
|
||||
|
||||
<div id="hud"><span id="ttl">TTL 64</span><span id="hop">hop 01/64</span><span id="link">v.90</span><span id="extra"></span></div>
|
||||
<div id="sub"></div>
|
||||
<div id="hint"></div>
|
||||
<div id="card"><div id="card-t"></div><div id="card-s"></div></div>
|
||||
<div id="flash"></div>
|
||||
<div id="fade"></div>
|
||||
<div id="map"></div>
|
||||
<div id="dev"></div>
|
||||
|
||||
<div id="boot">
|
||||
<h1>HANDSHAKE</h1>
|
||||
<div class="tag">a love story in 64 hops</div>
|
||||
<div class="keys">
|
||||
<k>WASD</k> move <k>SPACE</k> jump <k>MOUSE</k> look <k>E</k> use<br>
|
||||
<k>M</k> traceroute <k>ESC</k> release mouse <k>`</k> hop select<br><br>
|
||||
headphones. every frequency in this is the real frequency.
|
||||
</div>
|
||||
<div class="go">CLICK OR PRESS ANY KEY TO DIAL</div>
|
||||
<div id="cd">▮ NO CARRIER</div>
|
||||
</div>
|
||||
|
||||
<script type="importmap">
|
||||
{ "imports": { "three": "./vendor/three.module.js" } }
|
||||
</script>
|
||||
<script type="module" src="./src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
20
serve.py
Normal file
20
serve.py
Normal file
@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dev server. Identical to http.server except it refuses to let the browser cache
|
||||
ES modules, which otherwise makes every edit look like it did nothing."""
|
||||
import sys
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
|
||||
class NoCache(SimpleHTTPRequestHandler):
|
||||
def end_headers(self):
|
||||
self.send_header("Cache-Control", "no-store, max-age=0")
|
||||
super().end_headers()
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8123
|
||||
print(f"handshake → http://localhost:{port}/")
|
||||
ThreadingHTTPServer(("127.0.0.1", port), NoCache).serve_forever()
|
||||
294
src/audio.js
Normal file
294
src/audio.js
Normal file
@ -0,0 +1,294 @@
|
||||
// audio.js — every frequency in this game is the real frequency.
|
||||
// Nothing here is a sample. It is all oscillators, and that is the point.
|
||||
|
||||
let ctx = null, master = null, musicBus = null, sfxBus = null;
|
||||
|
||||
export function boot() {
|
||||
if (ctx) return ctx;
|
||||
ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
master = ctx.createGain(); master.gain.value = 0.55; master.connect(ctx.destination);
|
||||
musicBus = ctx.createGain(); musicBus.gain.value = 0.6; musicBus.connect(master);
|
||||
sfxBus = ctx.createGain(); sfxBus.gain.value = 1.0; sfxBus.connect(master);
|
||||
return ctx;
|
||||
}
|
||||
export function resume() { if (ctx && ctx.state === 'suspended') ctx.resume(); }
|
||||
export const now = () => (ctx ? ctx.currentTime : 0);
|
||||
export const AC = () => ctx;
|
||||
export const music = () => musicBus;
|
||||
export const sfx = () => sfxBus;
|
||||
export function setMaster(v) { if (master) master.gain.setTargetAtTime(v, ctx.currentTime, 0.05); }
|
||||
|
||||
// ─────────────────────────────────────────────────────────────── primitives
|
||||
|
||||
// A sustained pair of sine tones. This is 90% of telephony.
|
||||
export function pair(f1, f2, { gain = 0.16, dest = null, attack = 0.004 } = {}) {
|
||||
const t = ctx.currentTime;
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(0, t);
|
||||
g.gain.linearRampToValueAtTime(gain, t + attack);
|
||||
g.connect(dest || sfxBus);
|
||||
const oscs = [f1, f2].filter(Boolean).map(f => {
|
||||
const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = f; o.connect(g); o.start(t); return o;
|
||||
});
|
||||
return {
|
||||
oscs, gain: g,
|
||||
freq(i, f, glide = 0) {
|
||||
const o = oscs[i]; if (!o) return;
|
||||
if (glide > 0) o.frequency.setTargetAtTime(f, ctx.currentTime, glide); else o.frequency.setValueAtTime(f, ctx.currentTime);
|
||||
},
|
||||
level(v, tau = 0.02) { g.gain.setTargetAtTime(v, ctx.currentTime, tau); },
|
||||
stop(rel = 0.03) {
|
||||
const tt = ctx.currentTime;
|
||||
g.gain.cancelScheduledValues(tt);
|
||||
g.gain.setValueAtTime(g.gain.value, tt);
|
||||
g.gain.linearRampToValueAtTime(0, tt + rel);
|
||||
oscs.forEach(o => o.stop(tt + rel + 0.02));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// One-shot burst of a tone pair.
|
||||
export function burst(f1, f2, dur = 0.12, gain = 0.18, dest = null) {
|
||||
const p = pair(f1, f2, { gain, dest });
|
||||
setTimeout(() => p.stop(0.012), dur * 1000);
|
||||
return p;
|
||||
}
|
||||
|
||||
export function noise(seconds = 2) {
|
||||
const n = ctx.sampleRate * seconds;
|
||||
const buf = ctx.createBuffer(1, n, ctx.sampleRate);
|
||||
const d = buf.getChannelData(0);
|
||||
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
|
||||
const src = ctx.createBufferSource(); src.buffer = buf; src.loop = true;
|
||||
return src;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────── call progress
|
||||
|
||||
export const DIAL_TONE = [350, 440]; // precise tone plan, North America
|
||||
export const RINGBACK = [440, 480]; // 2s on, 4s off
|
||||
export const BUSY = [480, 620]; // 0.5 on, 0.5 off
|
||||
export const REORDER = [480, 620]; // 0.25 on, 0.25 off — "fast busy"
|
||||
export const ANS = 2100; // answer tone. the bong.
|
||||
export const SEIZE = 2600; // the one that mattered
|
||||
|
||||
export function dialTone() { return pair(DIAL_TONE[0], DIAL_TONE[1], { gain: 0.10 }); }
|
||||
|
||||
// Cadenced call-progress tone. Returns a handle with .stop()
|
||||
export function cadence(freqs, onSec, offSec, gain = 0.13) {
|
||||
let live = true, handle = null;
|
||||
const step = (on) => {
|
||||
if (!live) return;
|
||||
if (on) { handle = pair(freqs[0], freqs[1], { gain }); }
|
||||
else if (handle) { handle.stop(0.01); handle = null; }
|
||||
setTimeout(() => step(!on), (on ? onSec : offSec) * 1000);
|
||||
};
|
||||
step(true);
|
||||
return { stop() { live = false; if (handle) handle.stop(0.01); } };
|
||||
}
|
||||
export const ringback = () => cadence(RINGBACK, 2, 4);
|
||||
export const busySignal = () => cadence(BUSY, 0.5, 0.5);
|
||||
export const reorder = () => cadence(REORDER, 0.25, 0.25);
|
||||
|
||||
// ───────────────────────────────────────────────────────────────── DTMF
|
||||
|
||||
export const DTMF_ROW = [697, 770, 852, 941];
|
||||
export const DTMF_COL = [1209, 1336, 1477, 1633];
|
||||
export const DTMF_KEYS = [
|
||||
['1','2','3','A'],
|
||||
['4','5','6','B'],
|
||||
['7','8','9','C'],
|
||||
['*','0','#','D'],
|
||||
];
|
||||
export function dtmfOf(key) {
|
||||
for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++)
|
||||
if (DTMF_KEYS[r][c] === key) return [DTMF_ROW[r], DTMF_COL[c]];
|
||||
return null;
|
||||
}
|
||||
export function dtmf(key, dur = 0.11) {
|
||||
const f = dtmfOf(String(key)); if (!f) return null;
|
||||
return burst(f[0], f[1], dur, 0.20);
|
||||
}
|
||||
export async function dialString(s, digitMs = 90, gapMs = 70) {
|
||||
for (const ch of s) {
|
||||
if (ch === ' ' || ch === '-') { await wait(gapMs); continue; }
|
||||
dtmf(ch, digitMs / 1000);
|
||||
await wait(digitMs + gapMs);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────── MF — the blue box tones
|
||||
// R1 multi-frequency inter-office signalling. Six tones, two at a time.
|
||||
// This is what you were actually playing when you played a blue box.
|
||||
|
||||
export const MF_TONES = [700, 900, 1100, 1300, 1500, 1700];
|
||||
export const MF = {
|
||||
'1': [700, 900], '2': [700, 1100], '3': [900, 1100],
|
||||
'4': [700, 1300], '5': [900, 1300], '6': [1100, 1300],
|
||||
'7': [700, 1500], '8': [900, 1500], '9': [1100, 1500],
|
||||
'0': [1300, 1500],
|
||||
'KP': [1100, 1700], 'ST': [1500, 1700], 'KP2': [1300, 1700],
|
||||
'11': [700, 1700], '12': [900, 1700],
|
||||
};
|
||||
// KP is 100ms, everything else 60ms. Real timing. It matters to the ear.
|
||||
export function mf(sym, durOverride = null) {
|
||||
const f = MF[sym]; if (!f) return null;
|
||||
const dur = durOverride ?? (sym === 'KP' ? 0.100 : 0.060);
|
||||
return burst(f[0], f[1], dur, 0.22);
|
||||
}
|
||||
export async function mfSeq(syms, gapMs = 60) {
|
||||
for (const s of syms) { mf(s); await wait((MF[s] && s === 'KP' ? 100 : 60) + gapMs); }
|
||||
}
|
||||
|
||||
// 2600 Hz. Hold it and the far end drops and the near end keeps thinking
|
||||
// you are still on the call. You are now standing in a hole in the network.
|
||||
export function seize(gain = 0.17) { return pair(SEIZE, null, { gain }); }
|
||||
|
||||
// ─────────────────────────────────────────────────────── the handshake
|
||||
|
||||
// ANSam: 2100 Hz answer tone with a phase reversal every 450ms.
|
||||
// The reversals are what disable the echo cancellers. The boss flips on them.
|
||||
export function ansam({ reversals = true, gain = 0.15 } = {}) {
|
||||
const t = ctx.currentTime;
|
||||
const g = ctx.createGain(); g.gain.value = 0; g.connect(sfxBus);
|
||||
g.gain.linearRampToValueAtTime(gain, t + 0.05);
|
||||
const o = ctx.createOscillator(); o.type = 'sine'; o.frequency.value = ANS;
|
||||
const inv = ctx.createGain(); inv.gain.value = 1;
|
||||
o.connect(inv); inv.connect(g); o.start(t);
|
||||
let phase = 1, timer = null;
|
||||
const listeners = [];
|
||||
if (reversals) {
|
||||
timer = setInterval(() => {
|
||||
phase = -phase;
|
||||
inv.gain.setValueAtTime(phase, ctx.currentTime);
|
||||
listeners.forEach(fn => fn(phase));
|
||||
}, 450);
|
||||
}
|
||||
return {
|
||||
onReverse(fn) { listeners.push(fn); },
|
||||
get phase() { return phase; },
|
||||
stop() { clearInterval(timer); const tt = ctx.currentTime; g.gain.linearRampToValueAtTime(0, tt + 0.06); o.stop(tt + 0.1); }
|
||||
};
|
||||
}
|
||||
|
||||
// Bell 103 — 300 baud FSK. Originate 1070/1270, answer 2025/2225.
|
||||
export const BELL103 = { origin: { space: 1070, mark: 1270 }, answer: { space: 2025, mark: 2225 } };
|
||||
export function fsk(side = 'answer', bitrate = 300, gain = 0.10) {
|
||||
const m = BELL103[side === 'answer' ? 'answer' : 'origin'];
|
||||
const p = pair(m.mark, null, { gain });
|
||||
const iv = setInterval(() => p.freq(0, Math.random() < 0.5 ? m.mark : m.space), 1000 / bitrate * 8);
|
||||
return { stop() { clearInterval(iv); p.stop(0.05); } };
|
||||
}
|
||||
|
||||
// V.8bis probe scream — the "screeeee". Rapid capability probes.
|
||||
export function scream(gain = 0.12) {
|
||||
const p = pair(1200, 2400, { gain });
|
||||
let i = 0;
|
||||
const iv = setInterval(() => {
|
||||
i++;
|
||||
p.freq(0, 900 + (i * 271) % 1600, 0.001);
|
||||
p.freq(1, 1800 + (i * 433) % 2200, 0.001);
|
||||
}, 28);
|
||||
return { stop() { clearInterval(iv); p.stop(0.06); } };
|
||||
}
|
||||
|
||||
// The carrier. Scrambled data as filtered noise + a phase-jittering pair.
|
||||
export function carrier(gain = 0.09) {
|
||||
const src = noise(3);
|
||||
const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 1800; bp.Q.value = 1.1;
|
||||
const g = ctx.createGain(); g.gain.value = 0;
|
||||
src.connect(bp); bp.connect(g); g.connect(sfxBus); src.start();
|
||||
g.gain.setTargetAtTime(gain, ctx.currentTime, 0.15);
|
||||
const iv = setInterval(() => bp.frequency.setTargetAtTime(1400 + Math.random() * 900, ctx.currentTime, 0.02), 60);
|
||||
return {
|
||||
level(v) { g.gain.setTargetAtTime(v, ctx.currentTime, 0.1); },
|
||||
stop() { clearInterval(iv); g.gain.setTargetAtTime(0, ctx.currentTime, 0.12); setTimeout(() => src.stop(), 500); }
|
||||
};
|
||||
}
|
||||
|
||||
// The four seconds of a modem failing to train, then silence.
|
||||
export function noCarrier() {
|
||||
const s = scream(0.10);
|
||||
setTimeout(() => { s.stop(); const f = fsk('answer', 300, 0.09); setTimeout(() => f.stop(), 700); }, 900);
|
||||
const p = pair(ANS, 1800, { gain: 0.05 });
|
||||
p.gain.gain.setTargetAtTime(0, ctx.currentTime + 1.6, 0.4);
|
||||
setTimeout(() => p.stop(0.4), 2600);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────── texture
|
||||
|
||||
// 50 Hz strip lighting. TIME_WAIT runs on this and nothing else.
|
||||
export function striplight(gain = 0.05) {
|
||||
const p = pair(50, 100, { gain });
|
||||
const o3 = ctx.createOscillator(); o3.type = 'sine'; o3.frequency.value = 150;
|
||||
const g3 = ctx.createGain(); g3.gain.value = gain * 0.35; o3.connect(g3); g3.connect(sfxBus); o3.start();
|
||||
return { stop() { p.stop(0.4); const t = ctx.currentTime; g3.gain.linearRampToValueAtTime(0, t + 0.4); o3.stop(t + 0.5); } };
|
||||
}
|
||||
|
||||
// Strowger step-by-step relay. A click is a click.
|
||||
export function relayClick(gain = 0.25, pitch = 1) {
|
||||
const t = ctx.currentTime;
|
||||
const src = noise(0.06); src.loop = false;
|
||||
const bp = ctx.createBiquadFilter(); bp.type = 'bandpass';
|
||||
bp.frequency.value = 1900 * pitch; bp.Q.value = 6;
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(gain, t);
|
||||
g.gain.exponentialRampToValueAtTime(0.0005, t + 0.035);
|
||||
src.connect(bp); bp.connect(g); g.connect(sfxBus); src.start(t); src.stop(t + 0.06);
|
||||
}
|
||||
|
||||
// Terminal key. 300 baud sounds like this and nothing else does.
|
||||
export function blip(f = 1400, dur = 0.014, gain = 0.05) {
|
||||
const t = ctx.currentTime;
|
||||
const o = ctx.createOscillator(); o.type = 'square'; o.frequency.value = f;
|
||||
const g = ctx.createGain(); g.gain.setValueAtTime(gain, t);
|
||||
g.gain.exponentialRampToValueAtTime(0.0005, t + dur);
|
||||
o.connect(g); g.connect(sfxBus); o.start(t); o.stop(t + dur + 0.01);
|
||||
}
|
||||
|
||||
export function thud(f = 70, dur = 0.3, gain = 0.3) {
|
||||
const t = ctx.currentTime;
|
||||
const o = ctx.createOscillator(); o.type = 'sine';
|
||||
o.frequency.setValueAtTime(f * 2, t); o.frequency.exponentialRampToValueAtTime(f * 0.5, t + dur);
|
||||
const g = ctx.createGain(); g.gain.setValueAtTime(gain, t);
|
||||
g.gain.exponentialRampToValueAtTime(0.0005, t + dur);
|
||||
o.connect(g); g.connect(sfxBus); o.start(t); o.stop(t + dur + 0.02);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────── music
|
||||
// Each level's pad is built out of that level's protocol. No exceptions.
|
||||
|
||||
let padHandle = null;
|
||||
export function pad(freqs, { gain = 0.045, detune = 6, type = 'sine' } = {}) {
|
||||
stopPad();
|
||||
const t = ctx.currentTime;
|
||||
const g = ctx.createGain(); g.gain.value = 0; g.connect(musicBus);
|
||||
g.gain.linearRampToValueAtTime(gain, t + 2.5);
|
||||
const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 2200; lp.connect(g);
|
||||
const oscs = [];
|
||||
freqs.forEach((f, i) => {
|
||||
for (const d of [-detune, detune]) {
|
||||
const o = ctx.createOscillator(); o.type = type; o.frequency.value = f; o.detune.value = d;
|
||||
const og = ctx.createGain(); og.gain.value = 1 / (freqs.length * 2);
|
||||
o.connect(og); og.connect(lp); o.start(t + i * 0.08); oscs.push(o);
|
||||
}
|
||||
});
|
||||
// slow breathing so it never sits still
|
||||
const lfo = ctx.createOscillator(); lfo.frequency.value = 0.06;
|
||||
const lfoG = ctx.createGain(); lfoG.gain.value = 400;
|
||||
lfo.connect(lfoG); lfoG.connect(lp.frequency); lfo.start(t);
|
||||
padHandle = { oscs, g, lfo, lp };
|
||||
return padHandle;
|
||||
}
|
||||
export function stopPad(rel = 1.8) {
|
||||
if (!padHandle) return;
|
||||
const h = padHandle; padHandle = null;
|
||||
const t = ctx.currentTime;
|
||||
h.g.gain.cancelScheduledValues(t);
|
||||
h.g.gain.setValueAtTime(h.g.gain.value, t);
|
||||
h.g.gain.linearRampToValueAtTime(0, t + rel);
|
||||
h.oscs.forEach(o => o.stop(t + rel + 0.1));
|
||||
h.lfo.stop(t + rel + 0.1);
|
||||
}
|
||||
|
||||
export const wait = ms => new Promise(r => setTimeout(r, ms));
|
||||
69
src/hud.js
Normal file
69
src/hud.js
Normal file
@ -0,0 +1,69 @@
|
||||
// hud.js — the overlay. Deliberately plain: this game's HUD is a status line,
|
||||
// because a packet does not have a health bar, it has a TTL.
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
let els = {};
|
||||
export function boot() {
|
||||
els = {
|
||||
hud: $('hud'), ttl: $('ttl'), hop: $('hop'), link: $('link'), extra: $('extra'),
|
||||
sub: $('sub'), card: $('card'), cardT: $('card-t'), cardS: $('card-s'),
|
||||
hint: $('hint'), flash: $('flash'), fade: $('fade'),
|
||||
};
|
||||
}
|
||||
|
||||
export function status({ ttl, hop, hops, link, extra }) {
|
||||
if (ttl !== undefined) {
|
||||
els.ttl.textContent = `TTL ${String(ttl).padStart(2, '0')}`;
|
||||
els.ttl.className = ttl <= 8 ? 'crit' : ttl <= 20 ? 'warn' : '';
|
||||
}
|
||||
if (hop !== undefined) els.hop.textContent = `hop ${String(hop).padStart(2, '0')}/${hops ?? 64}`;
|
||||
if (link !== undefined) els.link.textContent = link;
|
||||
if (extra !== undefined) els.extra.textContent = extra;
|
||||
}
|
||||
export const showHud = v => { els.hud.style.opacity = v ? '1' : '0'; };
|
||||
|
||||
let subTimer = null;
|
||||
export function say(text, ms = 3200, who = null) {
|
||||
clearTimeout(subTimer);
|
||||
els.sub.innerHTML = who ? `<b>${who}</b>${text}` : text;
|
||||
els.sub.classList.add('on');
|
||||
if (ms > 0) subTimer = setTimeout(() => els.sub.classList.remove('on'), ms);
|
||||
}
|
||||
export const hush = () => { clearTimeout(subTimer); els.sub.classList.remove('on'); };
|
||||
|
||||
// Deliver a run of lines one after another, awaitable.
|
||||
export async function speak(lines, msPer = 2600) {
|
||||
for (const l of lines) {
|
||||
if (Array.isArray(l)) say(l[1], msPer, l[0]); else say(l, msPer);
|
||||
await sleep(msPer);
|
||||
}
|
||||
hush();
|
||||
}
|
||||
|
||||
export function hint(html) { els.hint.innerHTML = html || ''; els.hint.style.opacity = html ? '1' : '0'; }
|
||||
|
||||
export async function card(title, sub = '', ms = 2600, cls = '') {
|
||||
els.cardT.textContent = title;
|
||||
els.cardS.textContent = sub;
|
||||
els.card.className = 'on ' + cls;
|
||||
await sleep(ms);
|
||||
els.card.className = cls;
|
||||
await sleep(600);
|
||||
}
|
||||
|
||||
// NO CARRIER. The only death screen this game has.
|
||||
export async function flash(text, cls = '', ms = 1800) {
|
||||
els.flash.textContent = text;
|
||||
els.flash.className = 'on ' + cls;
|
||||
await sleep(ms);
|
||||
els.flash.className = cls;
|
||||
await sleep(400);
|
||||
}
|
||||
|
||||
export function veil(a, ms = 600) {
|
||||
els.fade.style.transition = `opacity ${ms}ms linear`;
|
||||
els.fade.style.opacity = String(a);
|
||||
return sleep(ms);
|
||||
}
|
||||
|
||||
export const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
73
src/input.js
Normal file
73
src/input.js
Normal file
@ -0,0 +1,73 @@
|
||||
// input.js — keys, mouse, pointer lock. One instance, shared by every level.
|
||||
// Levels never touch the DOM for input; they ask this.
|
||||
|
||||
export const keys = Object.create(null);
|
||||
export const pressed = Object.create(null); // edge-triggered, cleared each frame
|
||||
export const mouse = { dx: 0, dy: 0, down: false, clicked: false, x: 0, y: 0, wheel: 0 };
|
||||
|
||||
let remap = null; // the ANSI bomb lives here. yes, really.
|
||||
let locked = false;
|
||||
let el = null;
|
||||
let textSink = null; // when set, keystrokes route to the terminal instead
|
||||
|
||||
export function attach(canvas) {
|
||||
el = canvas;
|
||||
addEventListener('keydown', e => {
|
||||
if (e.repeat && !textSink) { return; }
|
||||
const code = apply(e.code);
|
||||
if (textSink) { if (textSink(e)) { e.preventDefault(); return; } }
|
||||
if (!e.repeat) { keys[code] = true; pressed[code] = true; }
|
||||
if (['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Tab','KeyF'].includes(e.code)) e.preventDefault();
|
||||
});
|
||||
addEventListener('keyup', e => { keys[apply(e.code)] = false; });
|
||||
addEventListener('blur', () => { for (const k in keys) keys[k] = false; });
|
||||
|
||||
el.addEventListener('mousedown', () => { mouse.down = true; mouse.clicked = true; });
|
||||
addEventListener('mouseup', () => { mouse.down = false; });
|
||||
addEventListener('mousemove', e => {
|
||||
mouse.x = e.clientX; mouse.y = e.clientY;
|
||||
// pointer lock is the good way; dragging works too, because pointer lock is
|
||||
// not always available and a game you cannot look around in is not a game
|
||||
if (locked || mouse.down) { mouse.dx += e.movementX; mouse.dy += e.movementY; }
|
||||
});
|
||||
addEventListener('wheel', e => { mouse.wheel += e.deltaY; }, { passive: true });
|
||||
document.addEventListener('pointerlockchange', () => { locked = document.pointerLockElement === el; });
|
||||
}
|
||||
|
||||
function apply(code) { return remap ? (remap[code] || code) : code; }
|
||||
|
||||
// The ANSI bomb. Escapable, telegraphed, and the meanest thing in the game.
|
||||
export function setRemap(map) { remap = map; }
|
||||
export function clearRemap() { remap = null; }
|
||||
export function isRemapped() { return !!remap; }
|
||||
|
||||
export function lock() { if (el && !locked) el.requestPointerLock?.(); }
|
||||
export function unlock() { if (locked) document.exitPointerLock?.(); }
|
||||
export function isLocked() { return locked; }
|
||||
|
||||
// Terminal levels claim the keyboard. Return true from the handler to swallow.
|
||||
export function captureText(fn) { textSink = fn; }
|
||||
export function releaseText() { textSink = null; }
|
||||
export function hasTextCapture() { return !!textSink; }
|
||||
|
||||
export function endFrame() {
|
||||
mouse.dx = 0; mouse.dy = 0; mouse.wheel = 0; mouse.clicked = false;
|
||||
for (const k in pressed) pressed[k] = false;
|
||||
}
|
||||
|
||||
export const down = c => !!keys[c];
|
||||
export const hit = c => !!pressed[c];
|
||||
|
||||
// WASD + arrows, as a normalised vector. Every level that moves uses this.
|
||||
export function axis() {
|
||||
let x = 0, y = 0;
|
||||
if (down('KeyW') || down('ArrowUp')) y -= 1;
|
||||
if (down('KeyS') || down('ArrowDown')) y += 1;
|
||||
if (down('KeyA') || down('ArrowLeft')) x -= 1;
|
||||
if (down('KeyD') || down('ArrowRight')) x += 1;
|
||||
const l = Math.hypot(x, y);
|
||||
return l > 1 ? { x: x / l, y: y / l } : { x, y };
|
||||
}
|
||||
export const jumpHeld = () => down('Space');
|
||||
export const jumpHit = () => hit('Space');
|
||||
export const useHit = () => hit('KeyE') || hit('Enter');
|
||||
284
src/kit.js
Normal file
284
src/kit.js
Normal file
@ -0,0 +1,284 @@
|
||||
// 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 };
|
||||
}
|
||||
374
src/levels/bluebox.js
Normal file
374
src/levels/bluebox.js
Normal file
@ -0,0 +1,374 @@
|
||||
// HOPS 59–63 — THE BLUE BOX
|
||||
// In-band signalling means the control channel and the voice channel are the same
|
||||
// channel. You can sing to the switch and the switch will obey. You are given an
|
||||
// instrument. It has thirteen buttons. Then, at the end, out-of-band signalling
|
||||
// arrives and it stops working, and you do not get it back.
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import * as A from '../audio.js';
|
||||
import * as In from '../input.js';
|
||||
import { sprite, panel, glow, damp, clamp } from '../kit.js';
|
||||
|
||||
// The thirteen buttons of an actual blue box, in the actual order.
|
||||
const KEYS = [
|
||||
['1', 'Digit1'], ['2', 'Digit2'], ['3', 'Digit3'],
|
||||
['4', 'Digit4'], ['5', 'Digit5'], ['6', 'Digit6'],
|
||||
['7', 'Digit7'], ['8', 'Digit8'], ['9', 'Digit9'],
|
||||
['KP', 'KeyK'], ['0', 'Digit0'], ['ST', 'KeyL'],
|
||||
['KP2', 'KeyJ'],
|
||||
];
|
||||
|
||||
const STAGES = [
|
||||
{ id: 'seize', 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',
|
||||
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.'],
|
||||
] },
|
||||
{ id: 'route2', code: 'KP 044 ST',
|
||||
teach: [
|
||||
['JOYBUBBLES', 'again. further. that one was los angeles, this one is london.'],
|
||||
['JOYBUBBLES', 'nobody is checking. nobody built a thing that checks.'],
|
||||
] },
|
||||
{ id: 'route3', code: 'KP 8121 ST',
|
||||
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',
|
||||
teach: [
|
||||
['JOYBUBBLES', 'last one. and this one comes home.'],
|
||||
['JOYBUBBLES', 'that number is the payphone six feet behind you.'],
|
||||
] },
|
||||
];
|
||||
|
||||
let S = {};
|
||||
|
||||
export default {
|
||||
id: 'bluebox', hop: 59, title: 'THE BLUE BOX', via: '2600.tandem', ms: '211 ms',
|
||||
baud: null, tint: [0x9fc8ff, 0.18],
|
||||
|
||||
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 };
|
||||
|
||||
scene.background = new THREE.Color(0x03060f);
|
||||
scene.fog = new THREE.FogExp2(0x03060f, 0.02);
|
||||
scene.add(new THREE.AmbientLight(0x4a6a9a, 1.2));
|
||||
const key = new THREE.DirectionalLight(0xbcd8ff, 1.1); key.position.set(2, 6, 4); scene.add(key);
|
||||
|
||||
// ── the trunk. you are inside it.
|
||||
const tube = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(9, 9, 400, 40, 1, true),
|
||||
new THREE.MeshStandardMaterial({ color: 0x0a1930, roughness: 1, side: THREE.BackSide, metalness: 0.3 })
|
||||
);
|
||||
tube.rotation.x = Math.PI / 2; tube.position.z = -120; scene.add(tube);
|
||||
S.tube = tube;
|
||||
|
||||
// copper strands running away down it
|
||||
S.strands = [];
|
||||
for (let i = 0; i < 26; i++) {
|
||||
const a = i / 26 * Math.PI * 2;
|
||||
const g = new THREE.BufferGeometry();
|
||||
const pts = [];
|
||||
for (let z = 20; z > -320; z -= 8) {
|
||||
const wob = Math.sin(z * 0.03 + i) * 0.5;
|
||||
pts.push(Math.cos(a) * (8.2 + wob), Math.sin(a) * (8.2 + wob), z);
|
||||
}
|
||||
g.setAttribute('position', new THREE.Float32BufferAttribute(pts, 3));
|
||||
const l = new THREE.Line(g, new THREE.LineBasicMaterial({ color: 0x2a5a8a, transparent: true, opacity: 0.5 }));
|
||||
scene.add(l); S.strands.push(l);
|
||||
}
|
||||
|
||||
// ── the blue box. thirteen buttons, held up in front of you.
|
||||
S.box = new THREE.Group();
|
||||
const shell = new THREE.Mesh(new THREE.BoxGeometry(4.4, 7.4, 0.5),
|
||||
new THREE.MeshStandardMaterial({ color: 0x1a4a8a, roughness: 0.45, metalness: 0.25,
|
||||
emissive: 0x0d2b52, emissiveIntensity: 1 }));
|
||||
S.box.add(shell);
|
||||
S.buttons = [];
|
||||
KEYS.forEach(([sym, code], i) => {
|
||||
const col = i % 3, rowi = Math.floor(i / 3);
|
||||
// self-lit: the instrument has to be readable no matter where the scene key is
|
||||
const b = new THREE.Mesh(new THREE.BoxGeometry(1.05, 0.78, 0.22),
|
||||
new THREE.MeshStandardMaterial({ color: 0xdfe8f2, roughness: 0.6,
|
||||
emissive: 0xdfe8f2, emissiveIntensity: 0.75 }));
|
||||
b.position.set((col - 1) * 1.25, 2.0 - rowi * 1.05, 0.32);
|
||||
if (i === 12) b.position.set(0, 2.0 - 4 * 1.05, 0.32); // KP2 sits alone under the keypad
|
||||
S.box.add(b);
|
||||
const lab = sprite(sym, { color: '#12325a', size: 0.44 });
|
||||
lab.position.copy(b.position); lab.position.z += 0.2;
|
||||
S.box.add(lab);
|
||||
S.buttons.push({ mesh: b, sym, code, lit: 0 });
|
||||
});
|
||||
const plate = panel('2600', { color: '#9fd8ff', size: 0.5 });
|
||||
plate.position.set(0, 3.2, 0.28); S.box.add(plate);
|
||||
// the box carries its own light. it is the instrument — it does not get to be
|
||||
// half in shadow because of where the scene key happens to be.
|
||||
const boxLight = new THREE.PointLight(0xdcecff, 22, 11, 2);
|
||||
boxLight.position.set(0.4, 0.6, 4.2); S.box.add(boxLight);
|
||||
S.box.position.set(1.9, -1.2, -8.0);
|
||||
S.box.rotation.set(-0.10, 0, 0.03);
|
||||
scene.add(S.box);
|
||||
|
||||
// ── the route, drawn as it happens
|
||||
S.routeGroup = new THREE.Group(); scene.add(S.routeGroup);
|
||||
|
||||
// ── the payphone, six feet behind you, which is the whole joke
|
||||
S.phone = new THREE.Group();
|
||||
const body = new THREE.Mesh(new THREE.BoxGeometry(1.5, 2.4, 0.6),
|
||||
new THREE.MeshStandardMaterial({ color: 0x2b3a44, roughness: 0.7, metalness: 0.4 }));
|
||||
S.phone.add(body);
|
||||
const hs = new THREE.Mesh(new THREE.BoxGeometry(0.4, 1.1, 0.34),
|
||||
new THREE.MeshStandardMaterial({ color: 0x14181c, roughness: 0.9 }));
|
||||
hs.position.set(-1.0, 0.2, 0.1); S.phone.add(hs); S.handset = hs;
|
||||
const tag = sprite('555 1212', { color: '#6fa8c8', size: 0.34 });
|
||||
tag.position.set(0, 1.7, 0.4); S.phone.add(tag);
|
||||
S.phone.position.set(-1.2, -1.0, 5.0);
|
||||
S.phone.rotation.y = Math.PI * 0.9;
|
||||
scene.add(S.phone);
|
||||
|
||||
// ── Joybubbles. blind, perfect pitch, and the warmest thing in this game.
|
||||
S.joy = new THREE.Group();
|
||||
const jc = new THREE.Mesh(new THREE.SphereGeometry(0.5, 20, 14), glow(0xffd9a8, 1.1));
|
||||
S.joy.add(jc); S.jcore = jc;
|
||||
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);
|
||||
scene.add(S.joy);
|
||||
const jl = new THREE.PointLight(0xffc98a, 8, 14, 2); jl.position.copy(S.joy.position); scene.add(jl);
|
||||
|
||||
S.readout = sprite('', { color: '#9fd8ff', size: 0.6 });
|
||||
S.readout.position.set(-3.6, 2.4, -7.5); scene.add(S.readout);
|
||||
|
||||
camera.position.set(0, 0, 0);
|
||||
camera.rotation.set(0, 0, 0);
|
||||
|
||||
// the trunk hum you are standing in
|
||||
S.hum = A.pair(120, 180, { gain: 0.035 });
|
||||
|
||||
ctx.hint('<k>SPACE</k>hold — 2600 Hz<br><k>1–9 0</k>MF digits<br><k>K</k>KP <k>L</k>ST');
|
||||
await ctx.card('THE BLUE BOX', 'hop 59 · in-band signalling', 3000);
|
||||
this.advance(ctx);
|
||||
},
|
||||
|
||||
async advance(ctx) {
|
||||
S.teaching = true;
|
||||
S.stage++;
|
||||
if (S.stage >= STAGES.length) { S.teaching = false; return this.homecoming(ctx); }
|
||||
const st = STAGES[S.stage];
|
||||
for (const [who, line] of st.teach) {
|
||||
ctx.say(line, 4200, who);
|
||||
S.speaking = 1.2;
|
||||
await ctx.sleep(4000);
|
||||
}
|
||||
ctx.hush();
|
||||
if (st.code) {
|
||||
S.readout.userData.setText('DIAL ' + st.code, '#ffd9a8');
|
||||
ctx.say(`enter <b>${st.code}</b>`, 5000);
|
||||
} else {
|
||||
S.readout.userData.setText('HOLD 2600', '#ffd9a8');
|
||||
}
|
||||
S.buf = [];
|
||||
S.teaching = false;
|
||||
},
|
||||
|
||||
// ── 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) {
|
||||
S.readout.userData.setText('', '#ffd9a8');
|
||||
ctx.say('it is going round.', 3000, 'JOYBUBBLES');
|
||||
await ctx.sleep(3200);
|
||||
S.ringing = true;
|
||||
S.ring = A.ringback();
|
||||
ctx.say('answer it.', 0);
|
||||
ctx.hint('<k>E</k>answer the payphone');
|
||||
// wait for the player, in update()
|
||||
},
|
||||
|
||||
async answer(ctx) {
|
||||
S.answered = true;
|
||||
S.ringing = false;
|
||||
S.ring.stop();
|
||||
ctx.hint('');
|
||||
ctx.hush();
|
||||
A.relayClick(0.3, 0.8);
|
||||
await ctx.sleep(500);
|
||||
|
||||
// you hear yourself, a quarter second late, from the other side of the world
|
||||
ctx.say('...', 2000);
|
||||
const delayed = S.log.slice(-14);
|
||||
for (const sym of delayed) {
|
||||
A.mf(sym);
|
||||
await ctx.sleep(120);
|
||||
}
|
||||
await ctx.sleep(600);
|
||||
ctx.say('that is you. that is your own signalling, a quarter of a second old,<br>arriving from the other side of the planet.', 6000);
|
||||
await ctx.sleep(6200);
|
||||
ctx.phile('tandem-loop', 'you rang the payphone next to you', 'a call that circled the earth to travel six feet.');
|
||||
ctx.say('every switch between here and here believed you. not one of them asked.', 5200, 'JOYBUBBLES');
|
||||
await ctx.sleep(5400);
|
||||
this.ss7(ctx);
|
||||
},
|
||||
|
||||
// ── and then signalling moves out of band, and it is over, and it stays over.
|
||||
async ss7(ctx) {
|
||||
await ctx.card('SS7', 'common channel signalling · 1980 →', 3200);
|
||||
ctx.say('something is changing in the network.', 3200);
|
||||
await ctx.sleep(3400);
|
||||
S.dead = true;
|
||||
S.readout.userData.setText('HOLD 2600', '#ff4a5e');
|
||||
ctx.say('do it again. seize the trunk.', 4000, 'JOYBUBBLES');
|
||||
ctx.hint('<k>SPACE</k>hold — 2600 Hz');
|
||||
},
|
||||
|
||||
async ss7Fail(ctx) {
|
||||
S.fails = (S.fails || 0) + 1;
|
||||
if (S.fails === 1) {
|
||||
ctx.say('nothing happened.', 3000);
|
||||
await ctx.sleep(3200);
|
||||
ctx.say('again.', 2600, 'JOYBUBBLES');
|
||||
return;
|
||||
}
|
||||
if (S.fails === 2) {
|
||||
ctx.say('nothing happened.', 3000);
|
||||
await ctx.sleep(3400);
|
||||
ctx.say('they moved the signalling onto its own channel. the control channel<br>and the voice channel are two different things now.', 6200);
|
||||
await ctx.sleep(6400);
|
||||
ctx.say('you can still make the sound. the sound is just a sound.', 4600, 'JOYBUBBLES');
|
||||
await ctx.sleep(4800);
|
||||
// he keeps whistling anyway
|
||||
S.whistle = A.pair(2600, null, { gain: 0.05 });
|
||||
ctx.say('', 0);
|
||||
ctx.hush();
|
||||
await ctx.sleep(3600);
|
||||
S.whistle.stop(2.5);
|
||||
ctx.phile('ss7', 'the day 2600 stopped working', 'out-of-band signalling. the hole closed and nobody announced it.');
|
||||
await ctx.sleep(2000);
|
||||
ctx.spendTTL(1);
|
||||
await ctx.card('THE BLUE BOX', 'you do not get it back', 4000);
|
||||
ctx.next();
|
||||
}
|
||||
},
|
||||
|
||||
update(dt, t, ctx) {
|
||||
S.t += dt;
|
||||
|
||||
// ambience
|
||||
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.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;
|
||||
S.box.position.y = -1.2 + Math.sin(S.t * 0.8) * 0.05;
|
||||
if (S.ringing) {
|
||||
S.phone.position.x = -1.2 + Math.sin(S.t * 42) * 0.02;
|
||||
S.handset.rotation.z = Math.sin(S.t * 40) * 0.03;
|
||||
}
|
||||
S.buttons.forEach(b => {
|
||||
b.lit = Math.max(0, b.lit - dt * 3);
|
||||
b.mesh.material.emissive.setRGB(0.75 + b.lit * 0.25, 0.78 - b.lit * 0.3, 0.82 - b.lit * 0.6);
|
||||
b.mesh.position.z = 0.32 - b.lit * 0.08;
|
||||
});
|
||||
|
||||
if (S.teaching) return;
|
||||
|
||||
// ── 2600 Hz. hold it.
|
||||
if (In.down('Space')) {
|
||||
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 > 1.4 && !S.seized && !S.dead) {
|
||||
S.seized = true;
|
||||
S.hum.stop(0.3);
|
||||
A.relayClick(0.35, 0.5);
|
||||
ctx.link.glitch = 0;
|
||||
ctx.say('<b>trunk seized</b> the far end let go. the near end still thinks you are talking.', 5000);
|
||||
if (STAGES[S.stage] && STAGES[S.stage].id === 'seize') setTimeout(() => this.advance(ctx), 2400);
|
||||
}
|
||||
if (S.held > 1.4 && S.dead && !S.failing) {
|
||||
S.failing = true;
|
||||
ctx.link.glitch = 0;
|
||||
this.ss7Fail(ctx).then(() => { S.failing = false; });
|
||||
}
|
||||
} else if (S.seize) {
|
||||
S.seize.stop(0.05); S.seize = null; S.held = 0; ctx.link.glitch = 0;
|
||||
}
|
||||
|
||||
if (S.ringing && In.useHit() && !S.answered) { this.answer(ctx); return; }
|
||||
if (S.dead || S.answered) return;
|
||||
|
||||
// ── MF. KP, digits, ST.
|
||||
for (const b of S.buttons) {
|
||||
if (!In.hit(b.code)) continue;
|
||||
b.lit = 1;
|
||||
A.mf(b.sym);
|
||||
S.log.push(b.sym);
|
||||
if (b.sym === 'KP') S.buf = ['KP'];
|
||||
else if (b.sym === 'ST') { S.buf.push('ST'); this.checkCode(ctx); }
|
||||
else if (S.buf.length) S.buf.push(b.sym);
|
||||
const shown = S.buf.join(' ').replace(/KP /, 'KP ').replace(/ ST/, ' ST');
|
||||
if (!S.dead && S.buf.length) S.readout.userData.setText(shown, '#63ffa2');
|
||||
}
|
||||
},
|
||||
|
||||
checkCode(ctx) {
|
||||
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('<b>misroute</b> the switch took it and did nothing with it.', 2600);
|
||||
S.buf = [];
|
||||
S.readout.userData.setText('DIAL ' + st.code, '#ffd9a8');
|
||||
ctx.spendTTL(1, 'misroute');
|
||||
return;
|
||||
}
|
||||
// routed. draw the hop on the map.
|
||||
A.relayClick(0.3, 0.7);
|
||||
setTimeout(() => A.relayClick(0.25, 0.9), 120);
|
||||
S.hops.push(st.code);
|
||||
this.drawHop(ctx, S.hops.length);
|
||||
ctx.say(`<b>tandem ${S.hops.length}</b> ${st.code}`, 2600);
|
||||
S.buf = [];
|
||||
S.readout.userData.setText('', '#63ffa2');
|
||||
setTimeout(() => this.advance(ctx), 2200);
|
||||
},
|
||||
|
||||
drawHop(ctx, n) {
|
||||
// an arc leaving down the trunk. four of them and you have gone round.
|
||||
const pts = [];
|
||||
const spread = 3 + n * 1.4;
|
||||
for (let i = 0; i <= 40; i++) {
|
||||
const k = i / 40;
|
||||
pts.push(
|
||||
Math.sin(k * Math.PI) * spread * Math.cos(n * 1.7),
|
||||
Math.sin(k * Math.PI) * spread * Math.sin(n * 1.7) * 0.5,
|
||||
-k * (60 + n * 40)
|
||||
);
|
||||
}
|
||||
const g = new THREE.BufferGeometry();
|
||||
g.setAttribute('position', new THREE.Float32BufferAttribute(pts, 3));
|
||||
const l = new THREE.Line(g, new THREE.LineBasicMaterial({ color: 0x63ffa2, transparent: true, opacity: 0.8 }));
|
||||
S.routeGroup.add(l);
|
||||
},
|
||||
|
||||
async unmount(ctx) {
|
||||
S.seize?.stop(0.1);
|
||||
S.hum?.stop(0.2);
|
||||
S.ring?.stop();
|
||||
S.whistle?.stop(0.2);
|
||||
ctx.link.glitch = 0;
|
||||
ctx.hint('');
|
||||
},
|
||||
};
|
||||
442
src/levels/board.js
Normal file
442
src/levels/board.js
Normal file
@ -0,0 +1,442 @@
|
||||
// HOPS 35–44 — THE BOARD
|
||||
// A BBS. One phone line. If somebody else is on it, you get a busy signal and you
|
||||
// do not get in, and you hang up and you redial, and that is the level teaching you
|
||||
// to want something. It is the first place in this game with people in it.
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import * as A from '../audio.js';
|
||||
import * as In from '../input.js';
|
||||
import { Term } from '../term.js';
|
||||
|
||||
let S = {};
|
||||
|
||||
// ── the login screen. eighty columns, sixteen colours, drawn one character at a
|
||||
// time at whatever speed you negotiated two hops ago.
|
||||
const W = 72;
|
||||
const visible = s => s.replace(/\|(?:B[0-7]|\d\d)/g, '').length;
|
||||
const row = s => `|08 █${s}${' '.repeat(Math.max(0, W - visible(s)))}|08█\n`;
|
||||
const LOGO =
|
||||
'\n|08 ' + '▄'.repeat(W + 2) + '\n' +
|
||||
row('') +
|
||||
row('|09 ▀█▀ █ █ █▀▀ █ ▄▀▄ █▀▀ ▀█▀ ▀█▀ █▀▄ █ █ █▀█ █ █') +
|
||||
row('|11 █ █▀█ █▀▀ █ █▀█ ▀▀█ █ █ █▀▄ █ █ █ █ █▀▄') +
|
||||
row('|09 ▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀') +
|
||||
row('') +
|
||||
row('|03 1 node · 2400 8N1 · est. 1987 · sysop |15TANDEM') +
|
||||
row('|02 " the switch does not care who you are "') +
|
||||
row('') +
|
||||
'|08 ' + '▀'.repeat(W + 2) + '\n';
|
||||
|
||||
const MESSAGES = [
|
||||
{ from: 'JOYBUBBLES', subj: 'the sound of a tired switch',
|
||||
body: `you can hear when a crossbar is worn. it comes back a half tone flat and it
|
||||
takes an extra beat to answer. i know six offices by ear and i have never
|
||||
seen any of them and i never will and it does not matter one bit.
|
||||
|
||||
people keep telling me i am missing something. i am not missing anything.
|
||||
i am the only one actually listening.`,
|
||||
phile: ['joy-ears', 'the sound of a tired switch'] },
|
||||
|
||||
{ from: 'TANDEM', subj: 'NEW USER POLICY — READ',
|
||||
body: `we do not take new users off the nodelist any more. too much heat.
|
||||
|
||||
if somebody vouched for you they gave you the word. type it at the ELITE
|
||||
prompt. if nobody vouched for you then you are not elite and that is fine,
|
||||
there is a whole board out here and most of it is better anyway.
|
||||
|
||||
(and no, i will not tell you the word. i am not going to type |15EMPTYSET|07 into
|
||||
a public message base, am i.)`,
|
||||
phile: ['nup-leak', 'NEW USER POLICY — READ'],
|
||||
gives: 'nup' },
|
||||
|
||||
{ from: 'ANONYMOUS', subj: '!!!! K-RAD ANSI !!!! MUST SEE !!!!',
|
||||
body: `>>> loading ansi <<<`,
|
||||
bomb: true },
|
||||
|
||||
{ from: 'CAP N CRUNCH', subj: 're: whistle',
|
||||
body: `it is a toy. it came free in a box of cereal. it makes one note.
|
||||
|
||||
the note happens to be two thousand six hundred cycles, which happens to be
|
||||
the note the long distance network uses to tell itself a trunk is idle.
|
||||
|
||||
nobody designed that coincidence. somebody just did not think anybody would
|
||||
be listening. that is the whole of it. that is every single one of these.`,
|
||||
phile: ['crunch', 're: whistle'] },
|
||||
|
||||
{ from: 'THE MENTOR', subj: 'a thing i wrote after they took me in',
|
||||
body: `we explore, and you call us criminals.
|
||||
we seek after knowledge, and you call us criminals.
|
||||
we exist without skin colour, without nationality, without religious bias,
|
||||
and you call us criminals.
|
||||
|
||||
you build atomic bombs, you wage wars, you murder, cheat and lie to us and
|
||||
try to make us believe it is for our own good, yet we are the criminals.
|
||||
|
||||
yes, i am a criminal. my crime is that of curiosity.`,
|
||||
phile: ['conscience', 'the conscience of a hacker, 1986'] },
|
||||
|
||||
{ from: 'ratio_cop', subj: 'you have taken 4 and uploaded 0',
|
||||
body: `the ratio is one to three. it is on the door, it is in the bulletins, it is
|
||||
in this message, and you still did it.
|
||||
|
||||
i am not angry. i am going to leech your account down to a 1:1 and you are
|
||||
going to feel it, and then we will be friends again.`,
|
||||
phile: null },
|
||||
];
|
||||
|
||||
const FILES = [
|
||||
{ name: 'PHRACK24.ZIP', size: '84,102', note: 'issue 24. the one with the E911 document in it.' },
|
||||
{ name: 'TAP-42.TXT', size: '11,904', note: 'youth international party line, later TAP.' },
|
||||
{ name: 'BLUEBOX.EXE', size: ' 6,288', note: 'MF tones through a PC speaker. it will not work. try it anyway.' },
|
||||
{ name: 'LOD-TJ-1.TXT', size: '38,441', note: 'legion of doom technical journal, volume one.' },
|
||||
{ name: 'TONELOC.ZIP', size: '52,010', note: 'wardialer. give it an exchange and go to bed.' },
|
||||
];
|
||||
|
||||
export default {
|
||||
id: 'board', hop: 35, title: 'THE BOARD', via: 'sysop.2400.8n1', ms: '2400 ms',
|
||||
baud: null, // whatever you earned at the handshake. that is the point.
|
||||
|
||||
async mount(ctx) {
|
||||
S = { t: 0, busyTries: 0, cargo: 0, uploads: 0, nup: false, elite: false, bombed: false, done: false, sysopSeen: 0 };
|
||||
|
||||
ctx.scene.background = new THREE.Color(0x000000);
|
||||
ctx.camera.position.set(0, 0, 5);
|
||||
|
||||
ctx.termCanvas.style.display = 'block';
|
||||
const term = new Term(ctx.termCanvas);
|
||||
ctx.term = term;
|
||||
S.term = term;
|
||||
In.captureText(e => {
|
||||
if (term.busy) { term.skip = true; return true; } // ^K aborts the redraw, as it always did
|
||||
return term.key(e);
|
||||
});
|
||||
ctx.hint('');
|
||||
|
||||
S.script = this.run(ctx, term);
|
||||
},
|
||||
|
||||
// the whole level is one long await. it reads like a transcript because it is one.
|
||||
async run(ctx, term) {
|
||||
const baud = () => Math.max(300, ctx.link.rate);
|
||||
|
||||
// ── dial in. the first attempt does not get through, because it never did.
|
||||
term.clear();
|
||||
term.writeln('');
|
||||
term.write('|07atdt |15');
|
||||
await ctx.sleep(500);
|
||||
const dt = A.dialTone();
|
||||
await ctx.sleep(900);
|
||||
dt.stop(0.05);
|
||||
for (const d of '5551212') { term.write(d); A.dtmf(d, 0.1); await ctx.sleep(180); }
|
||||
term.writeln('');
|
||||
await ctx.sleep(400);
|
||||
|
||||
while (S.busyTries < 1) {
|
||||
const bs = A.busySignal();
|
||||
term.writeln('|12BUSY');
|
||||
term.writeln('|08');
|
||||
ctx.say('one line. somebody else is on it. you cannot go in.', 4200);
|
||||
await ctx.sleep(3600);
|
||||
bs.stop();
|
||||
S.busyTries++;
|
||||
term.writeln('|07ath');
|
||||
await ctx.sleep(700);
|
||||
term.writeln('|07atdt 5551212');
|
||||
const rb = A.ringback();
|
||||
await ctx.sleep(3000);
|
||||
rb.stop();
|
||||
}
|
||||
|
||||
// ── train up, at whatever rate the handshake gave you
|
||||
const ans = A.ansam({ gain: 0.10 });
|
||||
await ctx.sleep(700);
|
||||
ans.stop();
|
||||
const c = A.carrier(0.08);
|
||||
await ctx.sleep(900);
|
||||
c.stop();
|
||||
term.writeln(`|10CONNECT ${ctx.link.rate}`);
|
||||
await ctx.sleep(600);
|
||||
|
||||
// ── the login screen paints. at 2400 this takes about eight seconds and the
|
||||
// game does not skip it, and it is the best eight seconds in the game.
|
||||
ctx.say(`painting at ${ctx.link.label} — ${Math.round(baud() / 10)} characters a second`, 4200);
|
||||
term.clear();
|
||||
await term.paint(LOGO, baud());
|
||||
term.writeln('');
|
||||
const name = await term.ask('|07handle: |15');
|
||||
S.handle = (name || 'SYN').toUpperCase().slice(0, 20);
|
||||
term.writeln('');
|
||||
await term.paint(`|03welcome back, |15${S.handle}|03. you were last on |15never|03.\n`, baud() * 2);
|
||||
await ctx.sleep(400);
|
||||
ctx.phile('board-login', 'THE LAST TRUNK — login screen');
|
||||
|
||||
// ── main loop
|
||||
for (;;) {
|
||||
if (S.done) return;
|
||||
const k = await this.menu(ctx, term);
|
||||
if (k === 'G') { await this.logoff(ctx, term); return; }
|
||||
if (k === 'M') await this.messages(ctx, term);
|
||||
else if (k === 'F') await this.files(ctx, term);
|
||||
else if (k === 'D') await this.doors(ctx, term);
|
||||
else if (k === 'E') await this.elite(ctx, term);
|
||||
else if (k === 'R') this.reset(ctx, term);
|
||||
else { term.writeln('|12?'); await ctx.sleep(400); }
|
||||
}
|
||||
},
|
||||
|
||||
async menu(ctx, term) {
|
||||
term.writeln('');
|
||||
term.writeln('|08──────────────────────────────────────────────────────────────────');
|
||||
term.writeln(`|11 [|15M|11]essages [|15F|11]iles [|15D|11]oors [|15E|11]lite [|15G|11]oodbye${In.isRemapped() ? ' [|12R|11]eset' : ''}`);
|
||||
term.writeln('|08──────────────────────────────────────────────────────────────────');
|
||||
S.sysopSeen++;
|
||||
// the sysop can see you typing. before you press enter. he always could.
|
||||
if (S.sysopSeen === 3) {
|
||||
term.onKey = (e, partial) => {
|
||||
if (partial !== null && partial !== undefined) {
|
||||
const old = { x: term.cx, y: term.cy, f: term.fg };
|
||||
term.goto(0, 0);
|
||||
term.write('|08sysop is watching: |07' + String(partial).padEnd(40, ' '));
|
||||
term.goto(old.x, old.y); term.fg = old.f;
|
||||
}
|
||||
};
|
||||
}
|
||||
const k = (await term.ask('|07command: |15')).trim().toUpperCase()[0] || '?';
|
||||
if (S.sysopSeen === 3) {
|
||||
term.onKey = null;
|
||||
await ctx.sleep(600);
|
||||
term.writeln('');
|
||||
term.writeln('|14*** the sysop is breaking into chat ***');
|
||||
A.burst(880, 1320, 0.14, 0.16);
|
||||
await ctx.sleep(900);
|
||||
await term.paint(`|15TANDEM|07: i can see what you type before you send it.\n`, 900);
|
||||
await term.paint(`|15TANDEM|07: everybody's sysop always could. nobody ever mentions it.\n`, 900);
|
||||
await ctx.sleep(500);
|
||||
await term.paint(`|15TANDEM|07: anyway. don't take the ansi one. \n`, 900);
|
||||
await ctx.sleep(600);
|
||||
term.writeln('|14*** chat ended ***');
|
||||
ctx.phile('sysop-chat', 'TANDEM broke into chat');
|
||||
await ctx.sleep(800);
|
||||
}
|
||||
return k;
|
||||
},
|
||||
|
||||
async messages(ctx, term) {
|
||||
term.clear();
|
||||
term.writeln('|11 message base 1 — general\n');
|
||||
MESSAGES.forEach((m, i) => {
|
||||
term.writeln(`|08 ${String(i + 1).padStart(2)} |07${m.from.padEnd(14)}|15${m.subj}`);
|
||||
});
|
||||
term.writeln('');
|
||||
const n = parseInt(await term.ask('|07read # (enter to go back): |15'), 10);
|
||||
if (!n || n < 1 || n > MESSAGES.length) return;
|
||||
const m = MESSAGES[n - 1];
|
||||
term.clear();
|
||||
term.writeln(`|08from: |07${m.from}`);
|
||||
term.writeln(`|08subj: |15${m.subj}`);
|
||||
term.writeln('|08──────────────────────────────────────────────────────────────────');
|
||||
await term.paint('|07' + m.body + '\n', Math.max(300, ctx.link.rate));
|
||||
|
||||
if (m.gives === 'nup') { S.nup = true; ctx.say('he typed it. he actually typed it.', 3400); }
|
||||
if (m.phile) ctx.phile(m.phile[0], m.phile[1], m.body);
|
||||
|
||||
// ── the ANSI bomb. telegraphed, escapable, and the meanest thing in here.
|
||||
if (m.bomb && !S.bombed) {
|
||||
S.bombed = true;
|
||||
await ctx.sleep(500);
|
||||
for (let i = 0; i < 26; i++) {
|
||||
term.goto(Math.floor(Math.random() * 78), Math.floor(Math.random() * 24));
|
||||
term.fg = Math.floor(Math.random() * 16);
|
||||
term.write('█▓▒░'[Math.floor(Math.random() * 4)].repeat(2));
|
||||
A.blip(200 + Math.random() * 2400, 0.012, 0.05);
|
||||
await ctx.sleep(45);
|
||||
}
|
||||
term.goto(0, 22);
|
||||
term.writeln('|12 your keyboard has been redefined.');
|
||||
In.setRemap({ KeyW: 'KeyS', KeyS: 'KeyW', KeyA: 'KeyD', KeyD: 'KeyA' });
|
||||
ctx.say('<b>ansi bomb</b> your movement keys are inverted. type RESET at the menu.', 6000);
|
||||
ctx.phile('ansi-bomb', 'the ANSI bomb', 'a message that remaps your keyboard on read.');
|
||||
await ctx.sleep(1600);
|
||||
}
|
||||
await term.ask('|08[enter]|07');
|
||||
},
|
||||
|
||||
reset(ctx, term) {
|
||||
In.clearRemap();
|
||||
term.writeln('|10 keyboard restored. you were lucky it was only the arrow keys.');
|
||||
A.burst(1320, 1760, 0.2, 0.14);
|
||||
},
|
||||
|
||||
async files(ctx, term) {
|
||||
term.clear();
|
||||
term.writeln('|11 file area 1 — text\n');
|
||||
FILES.forEach((f, i) => term.writeln(`|08 ${String(i + 1).padStart(2)} |15${f.name.padEnd(16)}|07${f.size.padStart(8)} |08${f.note}`));
|
||||
term.writeln('');
|
||||
term.writeln(`|03 ratio 1:3 — you have uploaded |15${S.uploads}|03 and taken |15${S.cargo}|03.`);
|
||||
term.writeln('|08 [|15#|08] download [|15U|08] upload something of your own [enter] back');
|
||||
const a = (await term.ask('|07: |15')).trim().toUpperCase();
|
||||
if (a === 'U') {
|
||||
S.uploads++;
|
||||
await term.paint('|07 sending... |08xmodem, 128 byte blocks, checksum\n', 4000);
|
||||
for (let i = 0; i < 12; i++) { term.write('|10.'); A.blip(1100, 0.01, 0.03); await ctx.sleep(90); }
|
||||
term.writeln('|10 ok');
|
||||
ctx.say('you gave something. that is the only currency here.', 3200);
|
||||
return;
|
||||
}
|
||||
const n = parseInt(a, 10);
|
||||
if (!n || n < 1 || n > FILES.length) return;
|
||||
if (S.cargo >= S.uploads * 3 + 1) {
|
||||
term.writeln('|12 ratio. upload something first.');
|
||||
A.burst(480, 620, 0.3, 0.14);
|
||||
await ctx.sleep(1400);
|
||||
return;
|
||||
}
|
||||
const f = FILES[n - 1];
|
||||
await term.paint(`|07 receiving |15${f.name}|07 ...\n`, 4000);
|
||||
for (let i = 0; i < 18; i++) { term.write('|11▓'); A.blip(1400, 0.008, 0.03); await ctx.sleep(70); }
|
||||
term.writeln('|10 complete');
|
||||
S.cargo++;
|
||||
ctx.game.cargo.push(f.name);
|
||||
ctx.game.mtu = Math.max(576, 1500 - ctx.game.cargo.length * 180);
|
||||
ctx.refreshHud();
|
||||
ctx.phile('file-' + n, f.name, f.note);
|
||||
ctx.say(`you are carrying ${ctx.game.cargo.length}. your MTU is down to ${ctx.game.mtu}.`, 3600);
|
||||
await ctx.sleep(1000);
|
||||
},
|
||||
|
||||
// ── the door games, in the corner, entirely optional, entirely finished
|
||||
async doors(ctx, term) {
|
||||
term.clear();
|
||||
term.writeln('|11 doors\n');
|
||||
term.writeln('|08 1 |15LEGEND OF THE RED DRAGON|08 v3.29');
|
||||
term.writeln('|08 2 |15TRADE WARS 2002|08 closed for maintenance since 1994');
|
||||
term.writeln('');
|
||||
const n = (await term.ask('|07door: |15')).trim();
|
||||
if (n !== '1') return;
|
||||
await this.lord(ctx, term);
|
||||
},
|
||||
|
||||
async lord(ctx, term) {
|
||||
term.clear();
|
||||
await term.paint(`|12
|
||||
L E G E N D O F T H E R E D D R A G O N
|
||||
|08 ─────────────────────────
|
||||
|07 you are |15${S.handle}|07, level 1, and you have 20 hitpoints and a
|
||||
|07 short sword and no idea what you have walked into.
|
||||
`, Math.max(1200, ctx.link.rate));
|
||||
|
||||
let hp = 20, gold = 0, foeHp = 14, day = 1;
|
||||
const foe = 'a Small Thief';
|
||||
term.writeln('');
|
||||
await term.paint(`|14 you are in the forest. ${foe} steps out.\n`, 2400);
|
||||
for (;;) {
|
||||
term.writeln('');
|
||||
term.writeln(`|08 you |15${hp}|08 hp ${foe} |15${Math.max(0, foeHp)}|08 hp gold |15${gold}`);
|
||||
const a = (await term.ask('|07 (A)ttack (S)tats (R)un: |15')).trim().toUpperCase()[0];
|
||||
if (a === 'R') { term.writeln('|03 you run away. the forest does not mind.'); break; }
|
||||
if (a === 'S') { term.writeln(`|07 ${S.handle} — level 1, ${hp}/20 hp, ${gold} gold, 0 charm.`); continue; }
|
||||
const dmg = 3 + Math.floor(Math.random() * 5);
|
||||
foeHp -= dmg;
|
||||
A.blip(300, 0.05, 0.06);
|
||||
await term.paint(`|10 you hit ${foe} for ${dmg}.\n`, 3000);
|
||||
if (foeHp <= 0) {
|
||||
gold += 42; day++;
|
||||
await term.paint(`|14 ${foe} is dead. you find 42 gold on the body.\n`, 3000);
|
||||
await term.paint(`|03 you have used your forest fights for today. come back tomorrow.\n`, 2400);
|
||||
ctx.phile('lord', 'LORD — you killed a Small Thief', 'there was a whole game in the corner of the fourth level.');
|
||||
ctx.say('there is a whole game in the corner of this level. nobody will believe it is there.', 4600);
|
||||
break;
|
||||
}
|
||||
const back = 1 + Math.floor(Math.random() * 4);
|
||||
hp -= back;
|
||||
await term.paint(`|12 ${foe} hits you for ${back}.\n`, 3000);
|
||||
if (hp <= 0) { term.writeln('|12 you have died. see you tomorrow.'); break; }
|
||||
}
|
||||
await term.ask('|08 [enter]|07');
|
||||
},
|
||||
|
||||
async elite(ctx, term) {
|
||||
term.clear();
|
||||
term.writeln('|11 elite sub-board\n');
|
||||
if (S.elite) { await this.eliteBoard(ctx, term); return; }
|
||||
const w = (await term.ask('|07 NUP: |15', { mask: true })).trim().toUpperCase();
|
||||
if (w !== 'EMPTYSET') {
|
||||
term.writeln('');
|
||||
term.writeln('|12 no.');
|
||||
A.burst(480, 620, 0.4, 0.16);
|
||||
if (!S.nup) ctx.say('somebody on this board has typed it out loud. they always do.', 4200);
|
||||
await ctx.sleep(1600);
|
||||
return;
|
||||
}
|
||||
S.elite = true;
|
||||
ctx.game.flags.elite = true;
|
||||
A.burst(1320, 1760, 0.3, 0.16);
|
||||
await this.eliteBoard(ctx, term);
|
||||
},
|
||||
|
||||
async eliteBoard(ctx, term) {
|
||||
term.clear();
|
||||
await term.paint(`|10 you are in.
|
||||
|08 ──────────────────────────────────────────────────────────────────
|
||||
|07 there are four messages in here and three of them are people arguing
|
||||
about whose group is better. the fourth one is this.
|
||||
|
||||
|15 from: TANDEM
|
||||
subj: the trunk
|
||||
|
||||
|07 there is a way down out of the board and into the plant itself. i am
|
||||
not going to draw you a map. go to the file area, take nothing, and
|
||||
hang up without typing goodbye.
|
||||
|
||||
the switch will hold the line open for a few seconds after you drop.
|
||||
that is the hole. it has been there since 1971 and nobody closed it
|
||||
because nobody wrote it down.
|
||||
|08 ──────────────────────────────────────────────────────────────────
|
||||
`, Math.max(1200, ctx.link.rate));
|
||||
ctx.phile('the-trunk', 'TANDEM — the trunk', 'hang up without typing goodbye.');
|
||||
ctx.game.flags.knowsTheTrunk = true;
|
||||
ctx.say('hang up without saying goodbye.', 5200);
|
||||
await term.ask('|08 [enter]|07');
|
||||
},
|
||||
|
||||
async logoff(ctx, term) {
|
||||
S.done = true;
|
||||
term.clear();
|
||||
// FidoNet. store and forward. your message goes out on the 4am mail run and
|
||||
// the reply gets here a level later, which is a "come back tomorrow" mechanic
|
||||
// in a game, and it is very funny that it was ever normal.
|
||||
await term.paint(`|03 before you go — the fidonet mail run goes out at 04:00.
|
||||
anything you post now reaches the rest of the world by tuesday.
|
||||
|
||||
|08 (zone 1, net 129, node 22. it will go 1:129/22 → 1:1/0 → the world,
|
||||
one modem call at a time, all night, while you are asleep.)
|
||||
`, Math.max(1200, ctx.link.rate));
|
||||
const msg = await term.ask('|07 message to nobody in particular: |15');
|
||||
if (msg && msg.trim()) {
|
||||
ctx.game.flags.fidoMessage = msg.trim();
|
||||
await term.paint('|10 queued for the 04:00 run.\n', 2400);
|
||||
ctx.phile('fido', 'you posted to fidonet', msg.trim());
|
||||
ctx.say('it will get there. it will take until tuesday, and it will get there.', 4600);
|
||||
await ctx.sleep(2200);
|
||||
}
|
||||
await term.paint('|07\n thanks for calling the last trunk.\n', 1600);
|
||||
await ctx.sleep(900);
|
||||
term.writeln('');
|
||||
term.writeln('|12NO CARRIER');
|
||||
A.noCarrier();
|
||||
await ctx.sleep(2600);
|
||||
ctx.spendTTL(1);
|
||||
await ctx.card('THE BOARD', ctx.game.flags.knowsTheTrunk ? 'hang up without saying goodbye' : 'you did not find the sub-board', 3000);
|
||||
ctx.next();
|
||||
},
|
||||
|
||||
update(dt, t, ctx) { S.t += dt; },
|
||||
|
||||
async unmount(ctx) {
|
||||
In.releaseText();
|
||||
In.clearRemap();
|
||||
ctx.termCanvas.style.display = 'none';
|
||||
ctx.term = null;
|
||||
ctx.hint('');
|
||||
},
|
||||
};
|
||||
247
src/levels/climb.js
Normal file
247
src/levels/climb.js
Normal file
@ -0,0 +1,247 @@
|
||||
// THE CLIMB — hops 63 → 1, in one take, at speed, on fire.
|
||||
// Everything you walked through is now a corridor you are sprinting up, and each
|
||||
// one lasts about fifty seconds, and the modern era gets the three funny toys
|
||||
// because the modern era is the funny era.
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import * as A from '../audio.js';
|
||||
import * as In from '../input.js';
|
||||
import { sprite, panel, glow, rng, clamp, damp } from '../kit.js';
|
||||
|
||||
// in reverse, because that is the point
|
||||
const ZONES = [
|
||||
{ at: 0, name: '1971 · the trunk', bg: 0x030a14, fog: 0x0a1930, col: 0x2a5a8a, note: 'you came down through here without knowing what it was' },
|
||||
{ at: 220, name: 'the copper', bg: 0x0a0704, fog: 0x1a1008, col: 0xff9a3c, note: null },
|
||||
{ at: 440, name: 'the handshake', bg: 0x03060a, fog: 0x061219, col: 0x2ad8ff, note: null },
|
||||
{ at: 660, name: 'the board', bg: 0x0d0700, fog: 0x2a1400, col: 0xffb648, note: 'the board is still up. it will be up until the line is disconnected.' },
|
||||
{ at: 880, name: '1988', bg: 0x04060a, fog: 0x0a1014, col: 0x63ffa2, note: null },
|
||||
{ at: 1100, name: 'efnet', bg: 0x0b0716, fog: 0x150e28, col: 0xff55ff, note: null },
|
||||
{ at: 1320, name: 'the stack', bg: 0x05080b, fog: 0x0b141c, col: 0x9ff5ff, note: null },
|
||||
{ at: 1540, name: 'modern', bg: 0xf2f7fa, fog: 0xfbfdff, col: 0x1a6d8e, note: 'and this is the part they armoured' },
|
||||
];
|
||||
|
||||
const SPECTRE_AT = 1620, ROWHAMMER_AT = 1780, LOG4_AT = 1940, END_AT = 2080;
|
||||
|
||||
let S = {};
|
||||
|
||||
export default {
|
||||
id: 'climb', hop: 63, title: 'THE CLIMB', via: 'reverse.path', ms: '11 ms',
|
||||
baud: null,
|
||||
|
||||
async mount(ctx) {
|
||||
const { scene, camera } = ctx;
|
||||
S = { t: 0, z: 0, x: 0, speed: 46, zone: -1, rings: [], done: false,
|
||||
spec: null, hammer: 0, hammered: false, log4: false, gateOpen: false };
|
||||
|
||||
scene.fog = new THREE.FogExp2(0x0a1930, 0.012);
|
||||
scene.background = new THREE.Color(0x030a14);
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 1.4));
|
||||
S.light = new THREE.PointLight(0xffffff, 40, 90, 2); scene.add(S.light);
|
||||
|
||||
// the corridor: a run of rings you fly through
|
||||
for (let i = 0; i < 90; i++) {
|
||||
const g = new THREE.BufferGeometry();
|
||||
const p = [];
|
||||
for (let k = 0; k <= 40; k++) { const a = k / 40 * Math.PI * 2; p.push(Math.cos(a) * 11, Math.sin(a) * 11, 0); }
|
||||
g.setAttribute('position', new THREE.Float32BufferAttribute(p, 3));
|
||||
const r = new THREE.Line(g, new THREE.LineBasicMaterial({ color: 0x2a5a8a, transparent: true, opacity: 0.6 }));
|
||||
scene.add(r); S.rings.push(r);
|
||||
}
|
||||
|
||||
// debris, so speed reads
|
||||
const rand = rng(64);
|
||||
S.bits = [];
|
||||
for (let i = 0; i < 160; i++) {
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.5, 0.5), glow(0x6fd8ff, 0.8));
|
||||
m.userData = { a: rand() * 6.28, r: 4 + rand() * 8, z: -rand() * 900, s: 0.4 + rand() * 1.4 };
|
||||
scene.add(m); S.bits.push(m);
|
||||
}
|
||||
|
||||
S.zoneTag = sprite('', { color: '#ffffff', size: 1.6 });
|
||||
S.zoneTag.position.set(0, 6.5, -34); scene.add(S.zoneTag);
|
||||
|
||||
// ── SPECTRE. you run down both sides of the branch at once. one of them never
|
||||
// happened, and you carry something out of it anyway.
|
||||
S.specGroup = new THREE.Group(); scene.add(S.specGroup);
|
||||
S.ghostMe = new THREE.Mesh(new THREE.IcosahedronGeometry(0.5, 1),
|
||||
new THREE.MeshBasicMaterial({ color: 0xff8ad8, transparent: true, opacity: 0.55 }));
|
||||
S.specGroup.add(S.ghostMe);
|
||||
S.specToken = new THREE.Mesh(new THREE.OctahedronGeometry(0.9, 0), glow(0xff8ad8, 1.6));
|
||||
S.specGroup.add(S.specToken);
|
||||
S.specGroup.visible = false;
|
||||
|
||||
// ── ROWHAMMER. a wall you hit until the room next door changes.
|
||||
S.wall = new THREE.Mesh(new THREE.BoxGeometry(26, 22, 1.2),
|
||||
new THREE.MeshStandardMaterial({ color: 0x1b2630, roughness: 0.9 }));
|
||||
S.wall.position.set(0, 0, -(ROWHAMMER_AT + 40)); scene.add(S.wall);
|
||||
S.wallTag = sprite('press E. keep pressing E.', { color: '#ffd9a8', size: 0.9 });
|
||||
S.wallTag.position.set(0, 4, -(ROWHAMMER_AT + 38)); scene.add(S.wallTag);
|
||||
S.wallTag.visible = false;
|
||||
|
||||
// ── LOG4SHELL. you write it on a piece of card and hold it up.
|
||||
S.sign = panel('${jndi:ldap://x/}', { color: '#63ffa2', size: 2.0 });
|
||||
S.sign.position.set(0, 0, -(LOG4_AT + 40)); scene.add(S.sign);
|
||||
S.signBack = new THREE.Mesh(new THREE.BoxGeometry(22, 12, 0.5),
|
||||
new THREE.MeshStandardMaterial({ color: 0x0a1a12, roughness: 1 }));
|
||||
S.signBack.position.set(0, 0, -(LOG4_AT + 41)); scene.add(S.signBack);
|
||||
|
||||
S.gate = new THREE.Mesh(new THREE.PlaneGeometry(24, 24),
|
||||
new THREE.MeshBasicMaterial({ color: 0xff4a5e, transparent: true, opacity: 0.45, side: THREE.DoubleSide }));
|
||||
S.gate.position.set(0, 0, -(SPECTRE_AT + 110)); scene.add(S.gate);
|
||||
|
||||
camera.position.set(0, 0, 0);
|
||||
camera.far = 1400; camera.updateProjectionMatrix();
|
||||
|
||||
A.pad([55, 82.41, 110, 164.81, 220], { gain: 0.05, type: 'sawtooth' });
|
||||
S.hiss = A.carrier(0.05);
|
||||
|
||||
ctx.hint('<k>A</k> <k>D</k>steer <k>E</k>use');
|
||||
await ctx.card('THE CLIMB', 'everything, in reverse, at speed', 2800);
|
||||
},
|
||||
|
||||
update(dt, t, ctx) {
|
||||
S.t += dt;
|
||||
S.z += S.speed * dt;
|
||||
S.speed = Math.min(120, S.speed + dt * 2.2);
|
||||
S.x = clamp(S.x + In.axis().x * dt * 22, -8, 8);
|
||||
|
||||
const cam = ctx.camera;
|
||||
cam.position.set(S.x, Math.sin(S.t * 0.7) * 0.6, 0);
|
||||
cam.rotation.z = damp(cam.rotation.z, -In.axis().x * 0.12, 4, dt);
|
||||
S.light.position.set(S.x, 0, -14);
|
||||
|
||||
// ── zone crossfade
|
||||
let zi = 0;
|
||||
for (let i = 0; i < ZONES.length; i++) if (S.z >= ZONES[i].at) zi = i;
|
||||
const Z = ZONES[zi];
|
||||
if (zi !== S.zone) {
|
||||
S.zone = zi;
|
||||
ctx.scene.background.setHex(Z.bg);
|
||||
ctx.scene.fog.color.setHex(Z.fog);
|
||||
S.zoneTag.userData.setText(Z.name, '#' + new THREE.Color(Z.col).getHexString());
|
||||
document.body.classList.toggle('ink-dark', zi === ZONES.length - 1);
|
||||
A.burst(220 + zi * 90, 330 + zi * 90, 0.25, 0.14);
|
||||
if (Z.note) ctx.say(Z.note, 4200);
|
||||
}
|
||||
|
||||
S.rings.forEach((r, i) => {
|
||||
const zz = -(((i * 22) - (S.z % 22)));
|
||||
r.position.set(Math.sin((S.z - zz) * 0.004) * 5, 0, zz);
|
||||
r.material.color.setHex(Z.col);
|
||||
r.material.opacity = clamp((1 - Math.abs(zz) / 1000) * 0.7, 0, 0.7);
|
||||
});
|
||||
S.bits.forEach(m => {
|
||||
const u = m.userData;
|
||||
u.z += S.speed * dt * u.s;
|
||||
if (u.z > 6) u.z = -900;
|
||||
m.position.set(Math.cos(u.a) * u.r, Math.sin(u.a) * u.r, u.z);
|
||||
m.material.color.setHex(Z.col);
|
||||
m.rotation.x += dt * 2; m.rotation.y += dt * 1.4;
|
||||
});
|
||||
|
||||
// ── SPECTRE
|
||||
if (S.z > SPECTRE_AT && S.z < SPECTRE_AT + 120) {
|
||||
if (!S.spec) {
|
||||
S.spec = { got: false, squashed: false };
|
||||
S.specGroup.visible = true;
|
||||
ctx.say('<b>branch</b> you are going to run down both sides of this. one of them is not going to have happened.', 5600);
|
||||
}
|
||||
// the speculative you, mirrored — the timeline that gets thrown away
|
||||
S.ghostMe.position.set(-S.x, 0, -8);
|
||||
S.specToken.position.set(-4.5, 0, -26 + Math.sin(S.t * 2) * 3);
|
||||
S.specToken.rotation.y += dt * 3;
|
||||
if (!S.spec.got && Math.abs(-S.x - S.specToken.position.x) < 2.2 && S.z > SPECTRE_AT + 40) {
|
||||
S.spec.got = true;
|
||||
S.specToken.visible = false;
|
||||
A.burst(1760, 2640, 0.24, 0.18);
|
||||
ctx.say('you picked it up in the branch that is about to be discarded.', 4000);
|
||||
}
|
||||
} else if (S.spec && !S.spec.squashed) {
|
||||
S.spec.squashed = true;
|
||||
S.specGroup.visible = false;
|
||||
A.thud(90, 0.4, 0.25);
|
||||
if (S.spec.got) {
|
||||
S.gateOpen = true;
|
||||
ctx.say('the branch was squashed. it never happened. <b>but it left a trace in the cache,<br>and you can read the cache.</b>', 6400);
|
||||
ctx.phile('spectre', 'speculative execution', 'the timeline that was thrown away still warmed a cache line, and a cache line is a message.');
|
||||
} else {
|
||||
ctx.say('the branch was squashed and you brought nothing out of it.', 4000);
|
||||
ctx.spendTTL(1, 'mispredicted');
|
||||
}
|
||||
}
|
||||
if (S.gate) {
|
||||
const gz = -(SPECTRE_AT + 110) + S.z;
|
||||
S.gate.position.z = gz;
|
||||
S.gate.material.color.setHex(S.gateOpen ? 0x63ffa2 : 0xff4a5e);
|
||||
S.gate.material.opacity = S.gateOpen ? 0.08 : 0.45;
|
||||
if (!S.gateOpen && Math.abs(gz) < 3 && !S.bounced) {
|
||||
S.bounced = true;
|
||||
ctx.spendTTL(1, 'closed');
|
||||
A.burst(300, 220, 0.35, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// ── ROWHAMMER
|
||||
const wz = -(ROWHAMMER_AT + 40) + S.z;
|
||||
S.wall.position.z = wz;
|
||||
S.wallTag.position.z = wz + 2;
|
||||
if (Math.abs(wz) < 60 && !S.hammered) {
|
||||
S.wallTag.visible = true;
|
||||
if (!S.hamSaid) { S.hamSaid = true; ctx.say('<b>rowhammer</b> you cannot get through it. bang on it until the room next door changes.', 5200); }
|
||||
if (In.useHit()) {
|
||||
S.hammer++;
|
||||
A.thud(140 + S.hammer * 8, 0.14, 0.22);
|
||||
S.wall.position.x = (Math.random() - 0.5) * 0.4;
|
||||
ctx.link.glitch = Math.min(0.5, S.hammer * 0.03);
|
||||
S.wallTag.userData.setText(`${S.hammer} / 24`, '#ffd9a8');
|
||||
if (S.hammer >= 24) {
|
||||
S.hammered = true;
|
||||
S.wallTag.visible = false;
|
||||
S.wall.visible = false;
|
||||
ctx.link.glitch = 0;
|
||||
A.burst(1320, 1980, 0.4, 0.2);
|
||||
ctx.say('a bit flipped in a row you were never allowed to touch. you did not read it. you shouted next to it.', 6000);
|
||||
ctx.phile('rowhammer', 'rowhammer', 'you are not allowed in that row. nobody said you could not lean on the wall.');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Math.abs(wz) < 2 && !S.hammered && !S.wallHit) {
|
||||
S.wallHit = true;
|
||||
ctx.spendTTL(1, 'wall');
|
||||
S.hammered = true; S.wall.visible = false; S.wallTag.visible = false;
|
||||
}
|
||||
|
||||
// ── LOG4SHELL. the biggest laugh in the game, sixty seconds before the end.
|
||||
const lz = -(LOG4_AT + 40) + S.z;
|
||||
S.sign.position.z = lz + 0.6; S.signBack.position.z = lz;
|
||||
if (Math.abs(lz) < 70 && !S.log4) {
|
||||
if (!S.log4Said) { S.log4Said = true; ctx.say('you write it on a piece of card.', 3200); }
|
||||
if (In.useHit() || Math.abs(lz) < 30) {
|
||||
S.log4 = true;
|
||||
A.burst(660, 990, 0.4, 0.2);
|
||||
setTimeout(() => A.burst(1320, 1980, 0.5, 0.2), 300);
|
||||
S.signBack.visible = false;
|
||||
ctx.say('and the entire fortress walks over, reads it, and goes and fetches whatever you asked for.', 6000);
|
||||
setTimeout(() => ctx.say('you did nothing. you held up a sign.', 4600), 6200);
|
||||
ctx.phile('log4shell', '${jndi:ldap://}', 'it went and got it. it was very helpful. that was the bug.');
|
||||
}
|
||||
}
|
||||
|
||||
if (S.z > END_AT && !S.done) {
|
||||
S.done = true;
|
||||
(async () => {
|
||||
S.hiss.stop(); A.stopPad();
|
||||
await ctx.card('ROOT', 'one hop remaining', 3200);
|
||||
ctx.next();
|
||||
})();
|
||||
}
|
||||
},
|
||||
|
||||
async unmount(ctx) {
|
||||
S.hiss?.stop();
|
||||
A.stopPad();
|
||||
ctx.link.glitch = 0;
|
||||
document.body.classList.remove('ink-dark');
|
||||
ctx.hint('');
|
||||
},
|
||||
};
|
||||
221
src/levels/coldboot.js
Normal file
221
src/levels/coldboot.js
Normal file
@ -0,0 +1,221 @@
|
||||
// HOP 1 — COLD BOOT
|
||||
// You wake in a NIC ring buffer. It is white and clean and enormous and there are
|
||||
// thousands of you. You learn to walk. You learn that you cannot do anything.
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import * as A from '../audio.js';
|
||||
import * as In from '../input.js';
|
||||
import { Body, Look, World, rng, sprite, damp, clamp } from '../kit.js';
|
||||
|
||||
let body, look, ring, packets, head, tail, streaks, S = {};
|
||||
|
||||
export default {
|
||||
id: 'coldboot', hop: 1, title: 'COLD BOOT', via: 'nic0.tx.ring', ms: '0.04 ms',
|
||||
baud: 6, ink: 'dark',
|
||||
|
||||
async mount(ctx) {
|
||||
const { scene, camera } = ctx;
|
||||
S = { t: 0, phase: 'wake', dma: 0, spoke: 0, locked: false };
|
||||
|
||||
scene.background = new THREE.Color(0xfbfdff);
|
||||
scene.fog = new THREE.Fog(0xfbfdff, 40, 300);
|
||||
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 2.9));
|
||||
const d = new THREE.DirectionalLight(0xffffff, 1.4); d.position.set(2, 30, 8); scene.add(d);
|
||||
const d2 = new THREE.DirectionalLight(0xdfeaf2, 0.7); d2.position.set(-14, 6, -10); scene.add(d2);
|
||||
|
||||
// the floor of the buffer. featureless, seamless, cold.
|
||||
const floor = new THREE.Mesh(
|
||||
new THREE.CircleGeometry(200, 96),
|
||||
new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.34, metalness: 0.0 })
|
||||
);
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
scene.add(floor);
|
||||
// a grid, so that "enormous" has something to be enormous against
|
||||
const grid = new THREE.GridHelper(320, 160, 0xc8d6e0, 0xe4edf3);
|
||||
grid.position.y = 0.01; grid.material.transparent = true; grid.material.opacity = 0.7;
|
||||
scene.add(grid);
|
||||
|
||||
// the ring. 512 slots, because that is a normal number of descriptors and
|
||||
// because 512 of anything looks like infinity from inside it.
|
||||
ring = new THREE.Group(); scene.add(ring);
|
||||
const SLOTS = 512, R = 62;
|
||||
const wallGeo = new THREE.BoxGeometry(0.22, 3.2, 3.0);
|
||||
const wallMat = new THREE.MeshStandardMaterial({ color: 0xdfe8ee, roughness: 0.7 });
|
||||
const walls = new THREE.InstancedMesh(wallGeo, wallMat, SLOTS);
|
||||
const m = new THREE.Matrix4(), q = new THREE.Quaternion(), sc = new THREE.Vector3(1, 1, 1), p = new THREE.Vector3();
|
||||
for (let i = 0; i < SLOTS; i++) {
|
||||
const a = (i / SLOTS) * Math.PI * 2;
|
||||
p.set(Math.cos(a) * R, 0, Math.sin(a) * R);
|
||||
q.setFromEuler(new THREE.Euler(0, -a, 0));
|
||||
m.compose(p, q, sc);
|
||||
walls.setMatrixAt(i, m);
|
||||
}
|
||||
ring.add(walls);
|
||||
|
||||
// and in most of the slots, another one of you.
|
||||
const rand = rng(1971);
|
||||
const pkGeo = new THREE.IcosahedronGeometry(0.42, 1);
|
||||
const pkMat = new THREE.MeshStandardMaterial({ color: 0x9fd8ff, emissive: 0x2a6c96, emissiveIntensity: 0.5, roughness: 0.4 });
|
||||
packets = new THREE.InstancedMesh(pkGeo, pkMat, SLOTS);
|
||||
packets.instanceColor = null;
|
||||
S.phases = new Float32Array(SLOTS);
|
||||
for (let i = 0; i < SLOTS; i++) {
|
||||
const a = (i / SLOTS) * Math.PI * 2;
|
||||
S.phases[i] = rand() * 6.28;
|
||||
p.set(Math.cos(a) * R, 0.9, Math.sin(a) * R);
|
||||
m.compose(p, q.identity(), sc);
|
||||
packets.setMatrixAt(i, m);
|
||||
}
|
||||
ring.add(packets);
|
||||
S.SLOTS = SLOTS; S.R = R; S.pkMatrix = m;
|
||||
|
||||
// head and tail pointers, going round forever, which is the joke of a ring buffer
|
||||
const mk = (col, txt) => {
|
||||
const g = new THREE.Group();
|
||||
const bar = new THREE.Mesh(new THREE.BoxGeometry(0.5, 9, 0.5), new THREE.MeshBasicMaterial({ color: col }));
|
||||
bar.position.y = 4.5; g.add(bar);
|
||||
const s = sprite(txt, { color: '#' + new THREE.Color(col).getHexString(), size: 1.5 });
|
||||
s.position.y = 10.4; g.add(s);
|
||||
scene.add(g); return g;
|
||||
};
|
||||
head = mk(0x2ad8ff, 'HEAD');
|
||||
tail = mk(0xff4a5e, 'TAIL');
|
||||
|
||||
// the speed lines you get ripped through. hidden until the DMA fires.
|
||||
const N = 2600, pos = new Float32Array(N * 6);
|
||||
for (let i = 0; i < N; i++) {
|
||||
const a = rand() * 6.28, rr = 2.5 + rand() * 26, z = -rand() * 900;
|
||||
const x = Math.cos(a) * rr, y = Math.sin(a) * rr;
|
||||
const len = 6 + rand() * 60;
|
||||
pos.set([x, y, z, x, y, z - len], i * 6);
|
||||
}
|
||||
const sg = new THREE.BufferGeometry();
|
||||
sg.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
||||
streaks = new THREE.LineSegments(sg, new THREE.LineBasicMaterial({ color: 0x7fd8ff, transparent: true, opacity: 0 }));
|
||||
streaks.visible = false;
|
||||
scene.add(streaks);
|
||||
|
||||
// you
|
||||
ctx.world.add(0, -1, 0, 400, 1, 400); // the floor, for the body
|
||||
body = new Body(ctx.world, { speed: 5.2, jump: 7 });
|
||||
body.pos.set(0, 0, R - 4);
|
||||
look = new Look();
|
||||
look.yaw = Math.PI / 2; // stand in your slot and look along the curve
|
||||
camera.near = 0.05; camera.far = 1200; camera.updateProjectionMatrix();
|
||||
|
||||
A.pad([174.61, 261.63, 329.63], { gain: 0.03, type: 'sine' }); // sterile. major. nobody's home.
|
||||
ctx.hint('<k>CLICK</k>look around');
|
||||
S.line = 0;
|
||||
|
||||
// it starts by telling you what you are, because nothing else here will.
|
||||
S.script = [
|
||||
'you are a synchronise packet.',
|
||||
'you have a sequence number, a window size, and one question.',
|
||||
'<i>are you there</i>',
|
||||
'nobody has ever answered it.',
|
||||
'there are five hundred and twelve slots in this ring and you are in one of them.',
|
||||
'the tail pointer is coming round.',
|
||||
];
|
||||
S.next = 3.0;
|
||||
},
|
||||
|
||||
update(dt, t, ctx) {
|
||||
S.t += dt;
|
||||
|
||||
// slots breathe. it is the only movement in the room.
|
||||
if (packets && S.t < 90) {
|
||||
const m = new THREE.Matrix4(), q = new THREE.Quaternion(), sc = new THREE.Vector3(1, 1, 1), p = new THREE.Vector3();
|
||||
for (let i = 0; i < S.SLOTS; i += 1) {
|
||||
const a = (i / S.SLOTS) * Math.PI * 2;
|
||||
const b = 0.9 + Math.sin(S.t * 0.7 + S.phases[i]) * 0.08;
|
||||
p.set(Math.cos(a) * S.R, b, Math.sin(a) * S.R);
|
||||
q.setFromEuler(new THREE.Euler(0, S.t * 0.2 + S.phases[i], 0));
|
||||
m.compose(p, q, sc);
|
||||
packets.setMatrixAt(i, m);
|
||||
}
|
||||
packets.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
|
||||
const ha = S.t * 0.42, ta = S.t * 0.42 - 1.15;
|
||||
head.position.set(Math.cos(ha) * S.R, 0, Math.sin(ha) * S.R);
|
||||
tail.position.set(Math.cos(ta) * S.R, 0, Math.sin(ta) * S.R);
|
||||
|
||||
if (In.mouse.clicked && !In.isLocked()) { In.lock(); A.resume(); }
|
||||
|
||||
if (S.phase === 'wake') {
|
||||
look.update();
|
||||
const w = look.wish();
|
||||
body.move(dt, w.x, w.z, In.jumpHit());
|
||||
look.applyFirstPerson(ctx.camera, body, Math.sin(S.t * 9) * 0.03 * Math.hypot(body.vel.x, body.vel.z) * 0.12);
|
||||
|
||||
if (In.isLocked() && !S.locked) { S.locked = true; ctx.hint('<k>WASD</k>move<br><k>SPACE</k>jump'); }
|
||||
|
||||
if (S.t > S.next && S.line < S.script.length) {
|
||||
ctx.say(S.script[S.line], 3600);
|
||||
S.line++;
|
||||
S.next = S.t + 3.9;
|
||||
if (S.line === S.script.length) S.dmaAt = S.t + 4.4;
|
||||
}
|
||||
|
||||
// walk far enough out and the ring notices you are missing
|
||||
if (!S.strayed && Math.hypot(body.pos.x, body.pos.z) < 30) {
|
||||
S.strayed = true;
|
||||
ctx.say('you have left your slot. this has never mattered before.', 3800);
|
||||
}
|
||||
|
||||
if (S.dmaAt && S.t > S.dmaAt) {
|
||||
S.phase = 'dma'; S.dma = 0;
|
||||
ctx.hint('');
|
||||
ctx.hush?.();
|
||||
A.stopPad();
|
||||
A.thud(48, 1.2, 0.4);
|
||||
streaks.visible = true;
|
||||
S.camY = ctx.camera.position.y;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── the DMA transfer. the floor leaves.
|
||||
S.dma += dt;
|
||||
const k = S.dma;
|
||||
ring.position.y = -Math.pow(k * 1.9, 2.4);
|
||||
ring.rotation.y += dt * k * 0.35;
|
||||
head.position.y = tail.position.y = ring.position.y;
|
||||
|
||||
const cam = ctx.camera;
|
||||
if (k < 0.9) {
|
||||
cam.position.y = damp(cam.position.y, S.camY + 3, 3, dt);
|
||||
cam.rotation.set(clamp(-0.3 + k, -0.4, 0), look.yaw, 0, 'YXZ');
|
||||
} else {
|
||||
const speed = Math.min(1, (k - 0.9) / 2.2);
|
||||
S.z = (S.z || 0) - dt * (60 + speed * 1400);
|
||||
cam.rotation.set(0, 0, 0, 'YXZ');
|
||||
cam.position.set(Math.sin(k * 3.1) * 0.5 * speed, 2 + Math.cos(k * 2.3) * 0.4 * speed, 0);
|
||||
streaks.position.z = S.z % 900;
|
||||
streaks.material.opacity = speed;
|
||||
if (ctx.scene.fog) { ctx.scene.fog.near = 26 + speed * 200; ctx.scene.fog.far = 180 + speed * 900; }
|
||||
ctx.scene.background.setRGB(1 - speed, 1 - speed * 0.96, 1 - speed * 0.9);
|
||||
ctx.link.glitch = Math.max(0, speed - 0.75) * 1.2;
|
||||
|
||||
if (!S.screamed && speed > 0.3) { S.screamed = true; S.sc = A.scream(0.05); }
|
||||
if (S.sc && speed > 0.99) { S.sc.stop(); S.sc = null; }
|
||||
|
||||
if (speed >= 1 && !S.done) {
|
||||
S.done = true;
|
||||
ctx.link.glitch = 0;
|
||||
(async () => {
|
||||
await ctx.card('HANDSHAKE', 'a love story in 64 hops', 3400);
|
||||
ctx.next();
|
||||
})();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async unmount(ctx) {
|
||||
if (S.sc) { S.sc.stop(); S.sc = null; }
|
||||
A.stopPad();
|
||||
ctx.link.glitch = 0;
|
||||
In.unlock();
|
||||
},
|
||||
};
|
||||
275
src/levels/copper.js
Normal file
275
src/levels/copper.js
Normal file
@ -0,0 +1,275 @@
|
||||
// HOPS 51–58 — THE COPPER
|
||||
// Down into physical plant. Wet, sodium orange, cold, and enormous. Nothing in
|
||||
// here is guarding anything. It is machinery that does not know you exist.
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import * as A from '../audio.js';
|
||||
import * as In from '../input.js';
|
||||
import { Body, Look, sprite, panel, glow, rng, clamp } from '../kit.js';
|
||||
|
||||
const CALL = [
|
||||
['', 'a woman, mid sentence'],
|
||||
['—', 'no, i told him tuesday. i said tuesday twice.'],
|
||||
['—', 'because he does not listen, mum, that is why.'],
|
||||
['—', '...'],
|
||||
['—', 'no. no, i can hear something. there is somebody on the line.'],
|
||||
['', 'she is right. you should put it down.'],
|
||||
];
|
||||
|
||||
let S = {};
|
||||
|
||||
export default {
|
||||
id: 'copper', hop: 51, title: 'THE COPPER', via: 'central.office', ms: '4 ms',
|
||||
baud: null, tint: [0xffc890, 0.22],
|
||||
|
||||
async mount(ctx) {
|
||||
const { scene, camera } = ctx;
|
||||
S = { t: 0, relays: [], step: 0, beige: false, listened: 0, talking: false, zone: 0 };
|
||||
|
||||
scene.background = new THREE.Color(0x0a0704);
|
||||
scene.fog = new THREE.FogExp2(0x0a0704, 0.028);
|
||||
scene.add(new THREE.AmbientLight(0x6a4020, 1.0));
|
||||
|
||||
const rand = rng(1891); // the year Almon Strowger filed
|
||||
const concrete = new THREE.MeshStandardMaterial({ color: 0x3a3229, roughness: 1 });
|
||||
const wet = new THREE.MeshStandardMaterial({ color: 0x241d16, roughness: 0.35, metalness: 0.15 });
|
||||
|
||||
// ── the duct. long, low, and going one way.
|
||||
const L = 300;
|
||||
const floor = new THREE.Mesh(new THREE.BoxGeometry(14, 0.5, L), wet);
|
||||
floor.position.set(0, -0.25, -L / 2 + 10); scene.add(floor);
|
||||
ctx.world.add(0, -0.5, -L / 2 + 10, 14, 0.5, L);
|
||||
const ceil = new THREE.Mesh(new THREE.BoxGeometry(14, 0.4, L), concrete);
|
||||
ceil.position.set(0, 5, -L / 2 + 10); scene.add(ceil);
|
||||
[-7, 7].forEach(x => {
|
||||
const w = new THREE.Mesh(new THREE.BoxGeometry(0.5, 5.5, L), concrete);
|
||||
w.position.set(x, 2.6, -L / 2 + 10); scene.add(w);
|
||||
ctx.world.add(x, 0, -L / 2 + 10, 0.5, 5.5, L);
|
||||
});
|
||||
|
||||
// sodium lamps. every twelve metres, and one of them is going.
|
||||
for (let z = 6; z > -L + 20; z -= 14) {
|
||||
const lamp = new THREE.Mesh(new THREE.BoxGeometry(1.4, 0.12, 0.5), new THREE.MeshBasicMaterial({ color: 0xffb45a }));
|
||||
lamp.position.set(0, 4.7, z); scene.add(lamp);
|
||||
const pl = new THREE.PointLight(0xff9a3c, 16, 26, 2);
|
||||
pl.position.set(0, 4.4, z); scene.add(pl);
|
||||
if (rand() < 0.18) S.relays.push({ flick: pl, lamp });
|
||||
}
|
||||
|
||||
// cable bundles running the length of it. this is the whole network, physically.
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const y = 1.4 + (i % 7) * 0.42, x = i < 7 ? -6.3 : 6.3;
|
||||
const c = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.16, L, 7),
|
||||
new THREE.MeshStandardMaterial({ color: i % 3 ? 0x6a4a2a : 0x3a2a1a, roughness: 0.8, metalness: 0.4 }));
|
||||
c.rotation.x = Math.PI / 2; c.position.set(x, y, -L / 2 + 10); scene.add(c);
|
||||
}
|
||||
|
||||
// ── load coils, at −40. grain silos that hum.
|
||||
for (const [x, z] of [[-4, -40], [4, -52], [-4, -64]]) {
|
||||
const coil = new THREE.Mesh(new THREE.CylinderGeometry(1.6, 1.6, 4.2, 18),
|
||||
new THREE.MeshStandardMaterial({ color: 0x4a3a28, roughness: 0.55, metalness: 0.6 }));
|
||||
coil.position.set(x, 2.1, z); scene.add(coil);
|
||||
ctx.world.add(x, 0, z, 3.4, 4.2, 3.4);
|
||||
for (let r = 0; r < 9; r++) {
|
||||
const w = new THREE.Mesh(new THREE.TorusGeometry(1.68, 0.06, 6, 20),
|
||||
new THREE.MeshStandardMaterial({ color: 0xb87333, roughness: 0.4, metalness: 0.9 }));
|
||||
w.rotation.x = Math.PI / 2; w.position.set(x, 0.4 + r * 0.45, z); scene.add(w);
|
||||
}
|
||||
}
|
||||
const coilSign = panel('LOAD COILS · 88 mH · every 6000 ft', { color: '#c08040', size: 0.3 });
|
||||
coilSign.position.set(-6.6, 3.4, -46); coilSign.rotation.y = Math.PI / 2; scene.add(coilSign);
|
||||
|
||||
// ── the lineman's handset, at −90. the size of a house, because you are not.
|
||||
S.hs = new THREE.Group();
|
||||
const hbody = new THREE.Mesh(new THREE.BoxGeometry(1.1, 3.4, 1.0),
|
||||
new THREE.MeshStandardMaterial({ color: 0xd8a020, roughness: 0.6 }));
|
||||
S.hs.add(hbody);
|
||||
const clip = new THREE.Mesh(new THREE.TorusGeometry(0.5, 0.09, 6, 14), glow(0xff4a5e, 0.7));
|
||||
clip.position.set(0, 2.0, 0.5); S.hs.add(clip); S.clip = clip;
|
||||
S.hs.position.set(5.2, 1.7, -90);
|
||||
S.hs.rotation.z = 0.2;
|
||||
scene.add(S.hs);
|
||||
ctx.world.add(5.2, 0, -90, 1.4, 3.4, 1.4);
|
||||
const hsTag = sprite('LINEMAN', { color: '#ffb648', size: 0.4 });
|
||||
hsTag.position.set(5.2, 4.0, -90); scene.add(hsTag);
|
||||
// the pair it clips onto
|
||||
const pairMesh = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.07, 6, 6), glow(0xff8a4a, 0.5));
|
||||
pairMesh.rotation.x = Math.PI / 2; pairMesh.position.set(5.6, 3.2, -90); scene.add(pairMesh);
|
||||
|
||||
// ── the central office. −140 onward. a cathedral of step-by-step.
|
||||
const co = new THREE.Group(); scene.add(co);
|
||||
const hallZ = -190;
|
||||
const hall = new THREE.Mesh(new THREE.BoxGeometry(40, 0.5, 110), concrete);
|
||||
hall.position.set(0, -0.25, hallZ); co.add(hall);
|
||||
ctx.world.add(0, -0.5, hallZ, 40, 0.5, 110);
|
||||
const hallCeil = new THREE.Mesh(new THREE.BoxGeometry(40, 0.5, 110), concrete);
|
||||
hallCeil.position.set(0, 14, hallZ); co.add(hallCeil);
|
||||
[-20, 20].forEach(x => {
|
||||
const w = new THREE.Mesh(new THREE.BoxGeometry(0.5, 14, 110), concrete);
|
||||
w.position.set(x, 7, hallZ); co.add(w);
|
||||
ctx.world.add(x, 0, hallZ, 0.5, 14, 110);
|
||||
});
|
||||
|
||||
// ten thousand Strowger switches, stepping in unison. instanced, obviously.
|
||||
const bankGeo = new THREE.BoxGeometry(0.34, 0.34, 0.34);
|
||||
const bankMat = new THREE.MeshStandardMaterial({ color: 0x8a7a5a, roughness: 0.5, metalness: 0.7 });
|
||||
const COUNT = 4200;
|
||||
S.banks = new THREE.InstancedMesh(bankGeo, bankMat, COUNT);
|
||||
S.bankData = [];
|
||||
const m4 = new THREE.Matrix4(), q = new THREE.Quaternion(), sc = new THREE.Vector3(1, 1, 1), pv = new THREE.Vector3();
|
||||
let n = 0;
|
||||
for (let rowi = 0; rowi < 6 && n < COUNT; rowi++) {
|
||||
const rx = -15 + rowi * 6;
|
||||
for (let zz = 0; zz < 35 && n < COUNT; zz++) {
|
||||
for (let yy = 0; yy < 20 && n < COUNT; yy++) {
|
||||
const x = rx + (yy % 2) * 0.5;
|
||||
const y = 0.6 + yy * 0.62;
|
||||
const z = hallZ + 50 - zz * 2.9;
|
||||
pv.set(x, y, z);
|
||||
m4.compose(pv, q, sc);
|
||||
S.banks.setMatrixAt(n, m4);
|
||||
S.bankData.push({ x, y, z, ph: rand() * 6.28 });
|
||||
n++;
|
||||
}
|
||||
}
|
||||
// the frame the bank is bolted to
|
||||
const frame = new THREE.Mesh(new THREE.BoxGeometry(1.6, 13, 102),
|
||||
new THREE.MeshStandardMaterial({ color: 0x2a241c, roughness: 0.9, metalness: 0.5 }));
|
||||
frame.position.set(rx, 6.5, hallZ);
|
||||
co.add(frame);
|
||||
ctx.world.add(rx, 0, hallZ, 1.6, 13, 102);
|
||||
}
|
||||
co.add(S.banks);
|
||||
S.bankCount = n;
|
||||
|
||||
for (let z = -145; z > -245; z -= 16) {
|
||||
const pl = new THREE.PointLight(0xffc070, 26, 40, 2);
|
||||
pl.position.set(0, 12, z); co.add(pl);
|
||||
const t = new THREE.Mesh(new THREE.BoxGeometry(6, 0.1, 0.4), new THREE.MeshBasicMaterial({ color: 0xffe0b0 }));
|
||||
t.position.set(0, 13.6, z); co.add(t);
|
||||
}
|
||||
|
||||
// the plaque. it is true, which is why it is on a plaque.
|
||||
const plaque = panel(
|
||||
'ALMON B. STROWGER, UNDERTAKER, KANSAS CITY, 1891\n' +
|
||||
'built automatic switching because the local operator\n' +
|
||||
'was his competitor\'s wife and kept sending him her husband',
|
||||
{ color: '#e8c090', size: 0.42 });
|
||||
plaque.position.set(-19.4, 4, -150); plaque.rotation.y = Math.PI / 2; scene.add(plaque);
|
||||
S.plaque = plaque;
|
||||
|
||||
// the way out, at the far end
|
||||
const exitLight = new THREE.Mesh(new THREE.PlaneGeometry(9, 9), glow(0x63ffa2, 0.55));
|
||||
exitLight.position.set(0, 4.5, -246); scene.add(exitLight);
|
||||
S.exitLight = exitLight;
|
||||
|
||||
// ── you
|
||||
S.body = new Body(ctx.world, { speed: 4.0, jump: 0, gravity: 24, r: 0.35, h: 1.7 });
|
||||
S.body.pos.set(0, 0, 4);
|
||||
S.look = new Look();
|
||||
S.look.yaw = 0;
|
||||
camera.far = 500; camera.updateProjectionMatrix();
|
||||
|
||||
S.rumble = A.pair(48, 72, { gain: 0.05 });
|
||||
ctx.hint('<k>WASD</k>walk<br><k>E</k>use<br><k>CLICK</k>capture mouse');
|
||||
await ctx.card('THE COPPER', 'hop 51 · physical plant', 3000);
|
||||
ctx.say('this is all of it. all the abstraction, and underneath it is a wet hole with wires in.', 5600);
|
||||
},
|
||||
|
||||
update(dt, t, ctx) {
|
||||
S.t += dt;
|
||||
const b = S.body, look = S.look;
|
||||
if (In.mouse.clicked && !In.isLocked()) { In.lock(); A.resume(); }
|
||||
|
||||
look.update();
|
||||
const w = look.wish();
|
||||
b.move(dt, w.x, w.z, false);
|
||||
look.applyFirstPerson(ctx.camera, b, Math.sin(S.t * 6.6) * 0.024 * Math.min(1, Math.hypot(b.vel.x, b.vel.z)));
|
||||
|
||||
// the lamp that is going
|
||||
S.relays.forEach(r => {
|
||||
const on = Math.random() > 0.06;
|
||||
r.flick.intensity = on ? 16 : 1;
|
||||
r.lamp.material.color.setScalar(on ? 1 : 0.25);
|
||||
});
|
||||
|
||||
S.clip.rotation.z += dt * 1.4;
|
||||
S.exitLight.material.opacity = 0.4 + Math.sin(S.t * 1.2) * 0.12;
|
||||
S.exitLight.material.transparent = true;
|
||||
|
||||
// ── the beige box. you clip on and listen to someone's phone call, and it
|
||||
// took no skill whatsoever, and that is the point of it.
|
||||
const dh = Math.hypot(b.pos.x - 5.2, b.pos.z + 90);
|
||||
if (dh < 3 && !S.beige) {
|
||||
ctx.hint('<k>E</k>clip onto the pair');
|
||||
if (In.useHit()) { S.beige = true; this.listen(ctx); }
|
||||
}
|
||||
|
||||
// ── the central office. ten thousand relays stepping, and it is deafening.
|
||||
const inHall = b.pos.z < -140;
|
||||
if (inHall && !S.inHall) {
|
||||
S.inHall = true;
|
||||
ctx.say('nobody in here has ever heard of you. it is the most restful place in the game.', 5600);
|
||||
ctx.spendTTL(1);
|
||||
S.rumble.level(0.10);
|
||||
}
|
||||
if (inHall) {
|
||||
S.step += dt;
|
||||
const period = 0.42;
|
||||
if (S.step > period) {
|
||||
S.step -= period;
|
||||
// a bank of them steps together. the sound is the level.
|
||||
for (let i = 0; i < 5; i++) A.relayClick(0.05 + Math.random() * 0.05, 0.7 + Math.random() * 0.7);
|
||||
}
|
||||
// the instanced switches wipe forward in a wave
|
||||
const m4 = new THREE.Matrix4(), q = new THREE.Quaternion(), sc = new THREE.Vector3(1, 1, 1), pv = new THREE.Vector3();
|
||||
for (let i = 0; i < S.bankCount; i += 1) {
|
||||
const d = S.bankData[i];
|
||||
const wave = Math.sin(S.t * 2.4 + d.z * 0.18 + d.ph * 0.1);
|
||||
pv.set(d.x + (wave > 0.86 ? 0.16 : 0), d.y, d.z);
|
||||
m4.compose(pv, q, sc);
|
||||
S.banks.setMatrixAt(i, m4);
|
||||
}
|
||||
S.banks.instanceMatrix.needsUpdate = true;
|
||||
}
|
||||
|
||||
// the plaque
|
||||
if (Math.hypot(b.pos.x + 19.4, b.pos.z + 150) < 6 && !S.readPlaque) {
|
||||
S.readPlaque = true;
|
||||
ctx.phile('strowger', 'Almon B. Strowger, undertaker',
|
||||
'he invented automatic switching because the operator was his competitor\'s wife.');
|
||||
ctx.say('he built the whole automatic telephone network out of spite. it worked.', 5200);
|
||||
}
|
||||
|
||||
if (b.pos.z < -243 && !S.done) {
|
||||
S.done = true;
|
||||
(async () => {
|
||||
await ctx.card('THE COPPER', 'nineteen ninety-one years of it, humming', 3000);
|
||||
ctx.next();
|
||||
})();
|
||||
}
|
||||
},
|
||||
|
||||
async listen(ctx) {
|
||||
ctx.hint('');
|
||||
A.relayClick(0.3, 0.4);
|
||||
// a call, as it sounds through a lineman's handset: band-limited and not yours
|
||||
const v = A.pair(220, 330, { gain: 0.03 });
|
||||
for (const [who, line] of CALL) {
|
||||
ctx.say(line, 3400, who || null);
|
||||
v.freq(0, 180 + Math.random() * 120, 0.05);
|
||||
v.freq(1, 260 + Math.random() * 200, 0.05);
|
||||
await ctx.sleep(3300);
|
||||
}
|
||||
v.stop(0.6);
|
||||
ctx.phile('beige', 'the beige box', 'a lineman\'s handset and a pair of jaws. no skill required at all.');
|
||||
ctx.say('that took no skill whatsoever. that is what makes it the worst one.', 5000);
|
||||
S.beige = 'done';
|
||||
},
|
||||
|
||||
async unmount(ctx) {
|
||||
S.rumble?.stop(0.3);
|
||||
In.unlock();
|
||||
ctx.hint('');
|
||||
},
|
||||
};
|
||||
196
src/levels/cuckoo.js
Normal file
196
src/levels/cuckoo.js
Normal file
@ -0,0 +1,196 @@
|
||||
// HOPS 29–34 — THE CUCKOO'S EGG
|
||||
// Lawrence Berkeley Lab, 1986. The flip: you are the prey. There is a seventy-five
|
||||
// cent accounting discrepancy and somebody noticed. He is an astronomer, he has no
|
||||
// budget, he has fifty printers wired to fifty lines in a basement, and he has a
|
||||
// pager taped to a teletype so it wakes him up when you log in.
|
||||
//
|
||||
// Your connection time is your visibility. You need long sessions to do anything
|
||||
// useful. He is never fast and he is never wrong.
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import * as A from '../audio.js';
|
||||
import * as In from '../input.js';
|
||||
import { Term } from '../term.js';
|
||||
|
||||
const FS = {
|
||||
'/': ['usr', 'tmp', 'mnt', 'etc'],
|
||||
'/usr': ['games', 'lib', 'spool', 'milnet'],
|
||||
'/usr/milnet': ['sdinet.doc', 'hosts.mil', 'roster.txt'],
|
||||
'/etc': ['passwd', 'accounts', 'motd'],
|
||||
'/tmp': ['.x', 'core'],
|
||||
'/mnt': [],
|
||||
};
|
||||
const DOCS = {
|
||||
'/usr/milnet/sdinet.doc': {
|
||||
title: 'SDINET.DOC',
|
||||
body: `SDI NETWORK PROJECT — DISTRIBUTION LIST
|
||||
this file does not describe a real network. it was written last tuesday
|
||||
by an astronomer with a typewriter and put here on purpose, because
|
||||
somebody who is not supposed to be here keeps searching for the word SDI
|
||||
and he wants to know how long you will stay logged in to read it.
|
||||
|
||||
you have been reading it for a while now.`,
|
||||
bait: true },
|
||||
'/etc/passwd': {
|
||||
title: '/etc/passwd',
|
||||
body: `root:x9Kj2mQp:0:1:operator:/:/bin/csh
|
||||
sventek:*:3212:20:Joe Sventek:/usr/sventek:/bin/csh
|
||||
^ this account has not been used since june. its owner is in england.
|
||||
somebody has been logging into it every night for ten months.`},
|
||||
'/etc/accounts': {
|
||||
title: 'accounting',
|
||||
body: `MONTHLY RECONCILIATION — 75c UNACCOUNTED
|
||||
|
||||
nine seconds of connect time that nobody paid for.
|
||||
|
||||
everybody else who saw this rounded it off. one person did not, and that
|
||||
is the entire reason any of this was ever found out.` },
|
||||
'/usr/milnet/hosts.mil': { title: 'hosts.mil', body: 'four hundred and sixty addresses. he printed all of them.' },
|
||||
'/etc/motd': { title: 'motd', body: 'LBL VAX 11/780. be nice to each other.' },
|
||||
'/usr/milnet/roster.txt': { title: 'roster.txt', body: 'names, ranks, and one phone number in Hannover.' },
|
||||
};
|
||||
|
||||
let S = {};
|
||||
|
||||
export default {
|
||||
id: 'cuckoo', hop: 29, title: "THE CUCKOO'S EGG", via: 'lbl.berkeley.1986', ms: '75 ms',
|
||||
baud: 1,
|
||||
|
||||
async mount(ctx) {
|
||||
S = { t: 0, lines: 50, read: 0, cwd: '/', gone: false, caught: false, elapsed: 0 };
|
||||
|
||||
ctx.scene.background = new THREE.Color(0x000000);
|
||||
ctx.camera.position.set(0, 0, 5);
|
||||
ctx.termCanvas.style.display = 'block';
|
||||
const term = new Term(ctx.termCanvas);
|
||||
ctx.term = term; S.term = term;
|
||||
In.captureText(e => { if (term.busy) { term.skip = true; return true; } return term.key(e); });
|
||||
ctx.hint('');
|
||||
|
||||
S.printer = A.pair(58, 87, { gain: 0.02 }); // fifty teletypes, one floor down
|
||||
this.run(ctx, term);
|
||||
},
|
||||
|
||||
status(term) {
|
||||
// he is downstairs colouring in a map, and you can see him doing it
|
||||
const old = { x: term.cx, y: term.cy, f: term.fg, b: term.bg };
|
||||
term.goto(0, 0); term.fg = 8; term.bg = 0;
|
||||
const bar = '█'.repeat(Math.max(0, Math.round(S.lines / 50 * 40)));
|
||||
term.write(`|08 lines still being watched: |15${String(S.lines).padStart(2)}|08 `.padEnd(40));
|
||||
term.goto(40, 0);
|
||||
term.write('|02' + bar.slice(0, 38).padEnd(38));
|
||||
term.goto(old.x, old.y); term.fg = old.f; term.bg = old.b;
|
||||
},
|
||||
|
||||
async run(ctx, term) {
|
||||
term.clear();
|
||||
await term.paint(`|10
|
||||
LBL VAX 11/780 4.3BSD
|
||||
|
||||
login: |15sventek|10
|
||||
password: |08********|10
|
||||
|
||||
last login: never. this account's owner is in england and has been
|
||||
since june.
|
||||
|
||||
|07 you are in. it is 1986 and nobody has invented the word "intrusion".
|
||||
type |15help|07 if you have never been anywhere like this.
|
||||
`, 1200);
|
||||
|
||||
for (;;) {
|
||||
if (S.gone) return;
|
||||
this.status(term);
|
||||
const raw = (await term.ask(`|02${S.cwd} |15$ |07`)).trim();
|
||||
if (S.gone) return;
|
||||
const [cmd, arg] = raw.split(/\s+/);
|
||||
term.writeln('');
|
||||
if (!cmd) continue;
|
||||
|
||||
if (cmd === 'help') {
|
||||
term.writeln('|08 ls cd <dir> cat <file> who ps logout');
|
||||
term.writeln('|08 every second you stay logged in he crosses off more lines.');
|
||||
} else if (cmd === 'ls') {
|
||||
const here = FS[S.cwd] || [];
|
||||
term.writeln('|11 ' + (here.length ? here.join(' ') : '(empty)'));
|
||||
} else if (cmd === 'cd') {
|
||||
if (arg === '..') S.cwd = S.cwd.replace(/\/[^/]+$/, '') || '/';
|
||||
else {
|
||||
const nxt = (S.cwd === '/' ? '' : S.cwd) + '/' + (arg || '');
|
||||
if (FS[nxt]) S.cwd = nxt; else term.writeln('|12 no such directory');
|
||||
}
|
||||
} else if (cmd === 'cat') {
|
||||
const path = (S.cwd === '/' ? '' : S.cwd) + '/' + (arg || '');
|
||||
const d = DOCS[path];
|
||||
if (!d) { term.writeln('|12 no such file'); continue; }
|
||||
term.writeln('');
|
||||
await term.paint('|07 ' + d.body.replace(/\n/g, '\n ') + '\n', 900);
|
||||
S.read++;
|
||||
ctx.phile('cuckoo-' + arg, d.title, d.body);
|
||||
if (d.bait && !S.baited) {
|
||||
S.baited = true;
|
||||
S.lines = Math.max(1, Math.round(S.lines * 0.35));
|
||||
A.burst(220, 180, 0.4, 0.16);
|
||||
term.writeln('');
|
||||
term.writeln('|12 (that took a long time to read. he got a lot of it while you did.)');
|
||||
}
|
||||
} else if (cmd === 'who') {
|
||||
term.writeln('|11 sventek ttyf3 Nov 4 02:14 (you)');
|
||||
term.writeln('|08 cstoll tty01 Nov 4 02:14 (he has been on since you were)');
|
||||
} else if (cmd === 'ps') {
|
||||
term.writeln('|08 1 /etc/init');
|
||||
term.writeln('|08 214 /usr/lib/lpd ← fifty of these');
|
||||
term.writeln('|08 907 cat /dev/tty01 > /dev/lp0');
|
||||
term.writeln('|08 908 sleep 1; beep pager ← this one is taped to a teletype');
|
||||
} else if (cmd === 'logout' || cmd === 'exit') {
|
||||
return this.leave(ctx, term, false);
|
||||
} else {
|
||||
term.writeln('|12 ' + cmd + ': not found');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async leave(ctx, term, caught) {
|
||||
if (S.gone) return;
|
||||
S.gone = true;
|
||||
In.releaseText();
|
||||
term.writeln('');
|
||||
if (caught) {
|
||||
await term.paint('|12 *** the line has been traced ***\n', 900);
|
||||
await ctx.sleep(900);
|
||||
await term.paint(`|07 it took him ten months, fifty printers, a pager taped to a teletype
|
||||
and one seventy-five cent discrepancy that everybody else rounded off.
|
||||
|
||||
he was never fast. he was never once wrong.\n`, 1100);
|
||||
} else {
|
||||
await term.paint('|07 you hung up while you were still ahead. he narrowed it to ' + S.lines + ' lines.\n', 1200);
|
||||
}
|
||||
await ctx.sleep(2600);
|
||||
ctx.phile('stoll', 'the 75-cent discrepancy', 'the whole thing unravelled from an accounting error nobody else cared about.');
|
||||
ctx.spendTTL(1);
|
||||
await ctx.card("THE CUCKOO'S EGG", `you read ${S.read} file${S.read === 1 ? '' : 's'} before he found the line`, 3600);
|
||||
ctx.next();
|
||||
},
|
||||
|
||||
update(dt, t, ctx) {
|
||||
S.t += dt;
|
||||
if (S.gone) return;
|
||||
// connection time is visibility. it only goes one way.
|
||||
S.elapsed += dt;
|
||||
const target = Math.max(1, Math.round(50 * Math.exp(-S.elapsed / 42)) - (S.baited ? 8 : 0));
|
||||
if (target < S.lines) {
|
||||
S.lines = target;
|
||||
A.relayClick(0.06, 1.4);
|
||||
if (S.lines === 12) ctx.say('he has narrowed it to twelve lines. he is not hurrying.', 3600);
|
||||
if (S.lines === 4) ctx.say('four.', 2600);
|
||||
}
|
||||
if (S.lines <= 1 && !S.caught) { S.caught = true; this.leave(ctx, S.term, true); }
|
||||
},
|
||||
|
||||
async unmount(ctx) {
|
||||
S.printer?.stop(0.3);
|
||||
In.releaseText();
|
||||
ctx.termCanvas.style.display = 'none';
|
||||
ctx.term = null;
|
||||
ctx.hint('');
|
||||
},
|
||||
};
|
||||
531
src/levels/handshake.js
Normal file
531
src/levels/handshake.js
Normal file
@ -0,0 +1,531 @@
|
||||
// HOPS 45–50 — THE HANDSHAKE
|
||||
// The dial-up noise, as a fighting game. Every boss is a real tone at its real
|
||||
// frequency and the fight teaches you what the tone is doing. The fourth one sets
|
||||
// your baud rate, and your baud rate is the renderer, so the fourth one decides
|
||||
// what the next two hours of this game look like.
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import * as A from '../audio.js';
|
||||
import * as In from '../input.js';
|
||||
import { sprite, glow, rng, clamp, damp } from '../kit.js';
|
||||
|
||||
const R = 22; // arena radius
|
||||
const CAPS = [ // what you are declaring when you dodge well
|
||||
{ tag: 'V.21', rate: 300 },
|
||||
{ tag: 'V.22', rate: 1200 },
|
||||
{ tag: 'V.22bis', rate: 2400 },
|
||||
{ tag: 'V.32', rate: 9600 },
|
||||
{ tag: 'V.32bis', rate: 14400 },
|
||||
{ tag: 'V.34', rate: 33600 },
|
||||
{ tag: 'V.90', rate: 56000 },
|
||||
];
|
||||
|
||||
let S = {};
|
||||
|
||||
function ring(r, col, seg = 96, op = 1) {
|
||||
const g = new THREE.BufferGeometry();
|
||||
const p = [];
|
||||
for (let i = 0; i <= seg; i++) { const a = i / seg * Math.PI * 2; p.push(Math.cos(a) * r, 0, Math.sin(a) * r); }
|
||||
g.setAttribute('position', new THREE.Float32BufferAttribute(p, 3));
|
||||
return new THREE.Line(g, new THREE.LineBasicMaterial({ color: col, transparent: true, opacity: op }));
|
||||
}
|
||||
|
||||
function hitPlayer(ctx, why) {
|
||||
if (S.inv > 0) return;
|
||||
S.inv = 1.1;
|
||||
S.shake = 0.8;
|
||||
A.thud(120, 0.25, 0.3);
|
||||
ctx.spendTTL(1, why);
|
||||
S.clean = false;
|
||||
}
|
||||
|
||||
function nodeFx(x, z) {
|
||||
const m = new THREE.Mesh(new THREE.SphereGeometry(0.8, 10, 8), glow(0xffffff, 1.5));
|
||||
m.position.set(x, 0.8, z);
|
||||
setTimeout(() => m.parent && m.parent.remove(m), 400);
|
||||
return m;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────── bosses
|
||||
|
||||
const BOSS = {
|
||||
|
||||
// ── 1. DIAL TONE — 350 Hz + 440 Hz. It just hums. Forever. Patiently.
|
||||
dialtone: {
|
||||
name: 'DIAL TONE', freqs: '350 Hz + 440 Hz',
|
||||
hint: 'two tones. stand where their rings cross.',
|
||||
enter(ctx) {
|
||||
S.b = { heads: [], hits: 0, need: 3, rings: [], t: 0, cool: 0 };
|
||||
[[-7, 350, 0x63ffa2], [7, 440, 0x2ad8ff]].forEach(([x, f, c]) => {
|
||||
const g = new THREE.Group();
|
||||
const m = new THREE.Mesh(new THREE.SphereGeometry(1.7, 20, 14), glow(c, 0.9));
|
||||
g.add(m);
|
||||
const s = sprite(f + ' Hz', { color: '#' + new THREE.Color(c).getHexString(), size: 1.2 });
|
||||
s.position.y = 3; g.add(s);
|
||||
g.position.set(x, 1.8, 0);
|
||||
S.arena.add(g);
|
||||
S.b.heads.push({ g, mesh: m, f, c, phase: 0 });
|
||||
});
|
||||
S.b.tone = A.pair(350, 440, { gain: 0.09 });
|
||||
ctx.say('it is not guarding anything. it is the sound a line makes when nothing is happening.', 4600);
|
||||
},
|
||||
update(dt, t, ctx) {
|
||||
const b = S.b; b.t += dt;
|
||||
b.heads.forEach((h, i) => {
|
||||
const period = 175 / h.f; // 350 and 440 do not line up often
|
||||
h.phase += dt;
|
||||
h.mesh.scale.setScalar(1 + Math.sin(b.t * (h.f / 100)) * 0.08);
|
||||
if (h.phase > period) {
|
||||
h.phase -= period;
|
||||
const r0 = ring(0.2, h.c, 64, 0.9);
|
||||
r0.position.copy(h.g.position); r0.position.y = 0.12;
|
||||
S.arena.add(r0);
|
||||
b.rings.push({ m: r0, r: 0.2, from: i });
|
||||
A.burst(h.f, null, 0.09, 0.05);
|
||||
}
|
||||
});
|
||||
for (let i = b.rings.length - 1; i >= 0; i--) {
|
||||
const rr = b.rings[i];
|
||||
rr.r += dt * 9;
|
||||
rr.m.scale.setScalar(rr.r / 0.2);
|
||||
rr.m.material.opacity = Math.max(0, 0.9 - rr.r / 26);
|
||||
if (rr.r > 26) { S.arena.remove(rr.m); rr.m.geometry.dispose(); b.rings.splice(i, 1); }
|
||||
}
|
||||
// Where the two oldest rings actually cross. Two-circle intersection, centres
|
||||
// on the x axis fourteen apart. The node races outward as the rings grow, so
|
||||
// it is something to chase rather than something to stand in.
|
||||
const a0 = b.rings.find(r => r.from === 0);
|
||||
const a1 = b.rings.find(r => r.from === 1);
|
||||
b.nodes = b.nodes || [];
|
||||
b.nodes.forEach(n => { n.visible = false; });
|
||||
let live = [];
|
||||
if (a0 && a1) {
|
||||
const d = 14, r0 = a0.r, r1 = a1.r;
|
||||
if (Math.abs(r0 - r1) <= d && d <= r0 + r1) {
|
||||
const aa = (r0 * r0 - r1 * r1 + d * d) / (2 * d);
|
||||
const hh = Math.sqrt(Math.max(0, r0 * r0 - aa * aa));
|
||||
live = [{ x: -7 + aa, z: hh }, { x: -7 + aa, z: -hh }];
|
||||
}
|
||||
}
|
||||
live.forEach((pt, i) => {
|
||||
if (!b.nodes[i]) {
|
||||
const m = new THREE.Mesh(new THREE.OctahedronGeometry(0.85, 0), glow(0xffffff, 1.6));
|
||||
S.arena.add(m); b.nodes[i] = m;
|
||||
}
|
||||
const m = b.nodes[i];
|
||||
m.visible = true;
|
||||
m.position.set(pt.x, 1.1, pt.z);
|
||||
m.rotation.y += dt * 3; m.rotation.x += dt * 2;
|
||||
});
|
||||
|
||||
const p = S.p.position;
|
||||
b.cool = Math.max(0, b.cool - dt);
|
||||
if (b.cool <= 0) {
|
||||
for (const pt of live) {
|
||||
if (Math.hypot(p.x - pt.x, p.z - pt.z) < 1.6) {
|
||||
b.cool = 1.2; b.hits++;
|
||||
A.burst(350, 440, 0.3, 0.22);
|
||||
S.flash = 0.6;
|
||||
S.arena.add(nodeFx(p.x, p.z));
|
||||
ctx.say(`<b>resonance</b> ${b.hits}/${b.need}`, 1400);
|
||||
if (b.hits >= b.need) this.win(ctx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
win(ctx) { S.b.tone.stop(0.5); nextPhase(ctx, 'the line is open. now you have to say something.'); },
|
||||
exit() { S.b.tone?.stop(0.2); },
|
||||
},
|
||||
|
||||
// ── 2. DTMF — every digit is a row tone crossed with a column tone.
|
||||
dtmf: {
|
||||
name: 'DTMF', freqs: '697·770·852·941 × 1209·1336·1477·1633',
|
||||
hint: 'a digit is a row and a column. be where they cross.',
|
||||
enter(ctx) {
|
||||
const NUM = '4387668'; // GET-ROOT, on a real keypad
|
||||
S.b = { num: NUM, at: 0, row: 0, col: 0, rt: 0, ct: 0, cells: [], beams: { rows: [], cols: [] }, cool: 0 };
|
||||
const SP = 5.2;
|
||||
for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) {
|
||||
const x = (c - 1.5) * SP, z = (r - 1.5) * SP;
|
||||
const m = new THREE.Mesh(new THREE.PlaneGeometry(4.4, 4.4),
|
||||
new THREE.MeshBasicMaterial({ color: 0x0d2630, transparent: true, opacity: 0.55 }));
|
||||
m.rotation.x = -Math.PI / 2; m.position.set(x, 0.06, z);
|
||||
S.arena.add(m);
|
||||
const key = A.DTMF_KEYS[r][c];
|
||||
const s = sprite(key, { color: '#2f6b80', size: 1.5 });
|
||||
s.position.set(x, 0.5, z); S.arena.add(s);
|
||||
S.b.cells.push({ r, c, x, z, m, s, key });
|
||||
}
|
||||
for (let r = 0; r < 4; r++) {
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(26, 0.06, 4.4), glow(0x63ffa2, 0.8));
|
||||
m.material.transparent = true; m.material.opacity = 0;
|
||||
m.position.set(0, 0.09, (r - 1.5) * SP);
|
||||
S.arena.add(m); S.b.beams.rows.push(m);
|
||||
const lab = sprite(String(A.DTMF_ROW[r]), { color: '#2f8f57', size: 0.95 });
|
||||
lab.position.set(-15.5, 0.6, (r - 1.5) * SP); S.arena.add(lab);
|
||||
}
|
||||
for (let c = 0; c < 4; c++) {
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(4.4, 0.06, 26), glow(0x2ad8ff, 0.8));
|
||||
m.material.transparent = true; m.material.opacity = 0;
|
||||
m.position.set((c - 1.5) * SP, 0.09, 0);
|
||||
S.arena.add(m); S.b.beams.cols.push(m);
|
||||
const lab = sprite(String(A.DTMF_COL[c]), { color: '#1f7f9f', size: 0.95 });
|
||||
lab.position.set((c - 1.5) * SP, 0.6, -15.5); S.arena.add(lab);
|
||||
}
|
||||
S.b.readout = sprite('DIAL · · · · · · ·', { color: '#ffd9a8', size: 1.6 });
|
||||
S.b.readout.position.set(0, 8, -19); S.arena.add(S.b.readout);
|
||||
ctx.say('<b>GET-ROOT</b> — on a real keypad that is 438 7668. dial it.', 5200);
|
||||
},
|
||||
update(dt, t, ctx) {
|
||||
const b = S.b;
|
||||
b.rt += dt; b.ct += dt;
|
||||
if (b.rt > 0.92) { b.rt -= 0.92; b.row = (b.row + 1) % 4; A.burst(A.DTMF_ROW[b.row], null, 0.05, 0.05); }
|
||||
if (b.ct > 0.61) { b.ct -= 0.61; b.col = (b.col + 1) % 4; A.burst(A.DTMF_COL[b.col], null, 0.05, 0.05); }
|
||||
b.beams.rows.forEach((m, i) => { m.material.opacity = i === b.row ? 0.5 : 0; });
|
||||
b.beams.cols.forEach((m, i) => { m.material.opacity = i === b.col ? 0.5 : 0; });
|
||||
|
||||
const want = b.num[b.at];
|
||||
b.cells.forEach(c => {
|
||||
const tgt = c.key === want;
|
||||
c.m.material.color.setHex(tgt ? 0x2a1a08 : 0x0d2630);
|
||||
c.s.userData.setText(c.key, tgt ? '#ffd9a8' : '#2f6b80');
|
||||
});
|
||||
|
||||
b.cool = Math.max(0, b.cool - dt);
|
||||
const p = S.p.position;
|
||||
const cell = b.cells.find(c => Math.abs(p.x - c.x) < 2.2 && Math.abs(p.z - c.z) < 2.2);
|
||||
if (cell && cell.r === b.row && cell.c === b.col && b.cool <= 0) {
|
||||
b.cool = 0.45;
|
||||
if (cell.key === want) {
|
||||
A.dtmf(cell.key, 0.14);
|
||||
b.at++;
|
||||
S.flash = 0.4;
|
||||
b.readout.userData.setText('DIAL ' + b.num.split('').map((d, i) => i < b.at ? d : '·').join(' '), '#63ffa2');
|
||||
if (b.at >= b.num.length) this.win(ctx);
|
||||
} else {
|
||||
A.burst(480, 620, 0.3, 0.18);
|
||||
b.at = Math.max(0, b.at - 1);
|
||||
ctx.say('<b>misdial</b>', 1200);
|
||||
hitPlayer(ctx, 'misdial');
|
||||
}
|
||||
}
|
||||
},
|
||||
win(ctx) { nextPhase(ctx, 'it is ringing.'); },
|
||||
exit() {},
|
||||
},
|
||||
|
||||
// ── 3. ANS — 2100 Hz with a phase reversal every 450 ms, because that is what
|
||||
// disables the echo cancellers. The boss flips on a beat you can hear coming.
|
||||
ans: {
|
||||
name: 'ANS · the bong', freqs: '2100 Hz, reversing every 450 ms',
|
||||
hint: 'it turns when it reverses. it is deaf for a moment afterwards.',
|
||||
enter(ctx) {
|
||||
S.b = { arms: [], ang: 0, dir: 1, still: 0, hits: 0, need: 5, cool: 0 };
|
||||
const core = new THREE.Mesh(new THREE.SphereGeometry(2.6, 24, 18), glow(0xffb648, 0.7));
|
||||
core.position.y = 2.2; S.arena.add(core); S.b.core = core;
|
||||
const shell = new THREE.Mesh(new THREE.SphereGeometry(3.4, 20, 14),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffd9a8, wireframe: true, transparent: true, opacity: 0.3 }));
|
||||
shell.position.y = 2.2; S.arena.add(shell); S.b.shell = shell;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const arm = new THREE.Mesh(new THREE.BoxGeometry(R * 0.92, 0.5, 1.1), glow(0xff4a5e, 1.0));
|
||||
arm.geometry.translate(R * 0.46, 0, 0);
|
||||
arm.position.y = 1.2;
|
||||
S.arena.add(arm);
|
||||
S.b.arms.push({ m: arm, base: i * Math.PI * 2 / 3 });
|
||||
}
|
||||
S.b.tone = A.ansam({ gain: 0.13 });
|
||||
S.b.tone.onReverse(ph => {
|
||||
S.b.dir = ph;
|
||||
S.b.still = 0.19; // the deaf window. this is your opening.
|
||||
A.relayClick(0.18, 0.6);
|
||||
});
|
||||
ctx.say('two thousand one hundred hertz, reversing its phase every four hundred and fifty milliseconds.', 5200);
|
||||
},
|
||||
update(dt, t, ctx) {
|
||||
const b = S.b;
|
||||
b.still = Math.max(0, b.still - dt);
|
||||
const moving = b.still <= 0;
|
||||
if (moving) b.ang += dt * 2.35 * b.dir;
|
||||
b.arms.forEach(a => {
|
||||
a.m.rotation.y = a.base + b.ang;
|
||||
a.m.material.color.setHex(moving ? 0xff4a5e : 0x63ffa2);
|
||||
});
|
||||
b.core.material.color.setHex(moving ? 0xffb648 : 0x63ffa2);
|
||||
b.core.scale.setScalar(moving ? 1 : 1.18);
|
||||
b.shell.rotation.y += dt * 0.6; b.shell.rotation.x += dt * 0.2;
|
||||
|
||||
const p = S.p.position;
|
||||
const pr = Math.hypot(p.x, p.z), pa = Math.atan2(p.z, p.x);
|
||||
if (moving && pr > 1.5 && pr < R * 0.92) {
|
||||
for (const a of b.arms) {
|
||||
let d = ((pa - (a.base + b.ang)) % (Math.PI * 2) + Math.PI * 3) % (Math.PI * 2) - Math.PI;
|
||||
if (Math.abs(d) * pr < 0.9) hitPlayer(ctx, 'swept');
|
||||
}
|
||||
}
|
||||
b.cool = Math.max(0, b.cool - dt);
|
||||
if (!moving && pr < 4.2 && b.cool <= 0) {
|
||||
b.cool = 0.5; b.hits++;
|
||||
A.burst(2100, 1050, 0.18, 0.2);
|
||||
S.flash = 0.7;
|
||||
ctx.say(`<b>echo canceller down</b> ${b.hits}/${b.need}`, 1200);
|
||||
if (b.hits >= b.need) this.win(ctx);
|
||||
}
|
||||
},
|
||||
win(ctx) { S.b.tone.stop(); nextPhase(ctx, 'it has stopped asking politely.'); },
|
||||
exit() { S.b.tone?.stop(); },
|
||||
},
|
||||
|
||||
// ── 4. V.8bis — THE SCREAM. The modem asking what can you do.
|
||||
// You answer by dodging. Your answer is your bandwidth. Your bandwidth is
|
||||
// what the rest of this game looks like.
|
||||
scream: {
|
||||
name: 'V.8bis · the scream', freqs: 'capability probe',
|
||||
hint: 'dodge clean and you declare a capability. dodge badly and you live in ascii.',
|
||||
enter(ctx) {
|
||||
S.b = { wave: -1, wt: 0, bullets: [], caps: 0, clean: true, spawn: 0, done: false, rand: rng(8600) };
|
||||
const core = new THREE.Mesh(new THREE.IcosahedronGeometry(3.2, 1),
|
||||
new THREE.MeshBasicMaterial({ color: 0xff4a5e, wireframe: true }));
|
||||
core.position.y = 2.6; S.arena.add(core); S.b.core = core;
|
||||
S.b.tag = sprite('PROBE', { color: '#ffd9a8', size: 1.6 });
|
||||
S.b.tag.position.set(0, 8, -19); S.arena.add(S.b.tag);
|
||||
ctx.say('it is asking what you can do. it will only ask once.', 4200);
|
||||
},
|
||||
update(dt, t, ctx) {
|
||||
const b = S.b;
|
||||
b.core.rotation.y += dt * 2.2; b.core.rotation.x += dt * 1.1;
|
||||
b.wt -= dt;
|
||||
|
||||
if (b.wt <= 0 && !b.done) {
|
||||
if (b.wave >= 0) {
|
||||
if (b.clean) {
|
||||
b.caps++;
|
||||
A.burst(1200, 2400, 0.2, 0.18);
|
||||
ctx.say(`<b>capability granted</b> ${CAPS[b.caps - 1].tag}`, 2000);
|
||||
} else {
|
||||
A.burst(480, 620, 0.25, 0.16);
|
||||
ctx.say(`<b>refused</b> ${CAPS[Math.min(b.caps, CAPS.length - 1)].tag}`, 2000);
|
||||
}
|
||||
}
|
||||
b.wave++;
|
||||
if (b.wave >= CAPS.length) { b.done = true; this.win(ctx); return; }
|
||||
b.clean = true; S.clean = true;
|
||||
b.wt = 4.0; b.spawn = 0;
|
||||
const c = CAPS[b.wave];
|
||||
b.tag.userData.setText(`PROBE ${c.tag} ${c.rate}`, '#ffd9a8');
|
||||
if (b.sc) b.sc.stop();
|
||||
b.sc = A.scream(0.07 + b.wave * 0.006);
|
||||
}
|
||||
if (!S.clean) b.clean = false;
|
||||
|
||||
b.spawn -= dt;
|
||||
if (b.spawn <= 0 && b.wt > 0.6 && !b.done) {
|
||||
b.spawn = Math.max(0.09, 0.42 - b.wave * 0.05);
|
||||
const n = 2 + Math.floor(b.wave * 0.9);
|
||||
const base = b.rand() * Math.PI * 2;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = base + i * (Math.PI * 2 / n) + b.rand() * 0.4;
|
||||
const m = new THREE.Mesh(new THREE.SphereGeometry(0.52, 8, 6), glow(0xff8a4a, 1.3));
|
||||
m.position.set(Math.cos(a) * (R - 1), 1.4, Math.sin(a) * (R - 1));
|
||||
S.arena.add(m);
|
||||
const p = S.p.position;
|
||||
const dir = new THREE.Vector3(p.x - m.position.x, 0, p.z - m.position.z).normalize();
|
||||
dir.applyAxisAngle(new THREE.Vector3(0, 1, 0), (b.rand() - 0.5) * 0.5);
|
||||
b.bullets.push({ m, v: dir.multiplyScalar(10 + b.wave * 1.9) });
|
||||
}
|
||||
}
|
||||
for (let i = b.bullets.length - 1; i >= 0; i--) {
|
||||
const bl = b.bullets[i];
|
||||
bl.m.position.addScaledVector(bl.v, dt);
|
||||
const p = S.p.position;
|
||||
if (Math.hypot(p.x - bl.m.position.x, p.z - bl.m.position.z) < 1.15) {
|
||||
hitPlayer(ctx, 'probe'); b.clean = false;
|
||||
S.arena.remove(bl.m); bl.m.geometry.dispose(); b.bullets.splice(i, 1); continue;
|
||||
}
|
||||
if (Math.hypot(bl.m.position.x, bl.m.position.z) > R + 6) {
|
||||
S.arena.remove(bl.m); bl.m.geometry.dispose(); b.bullets.splice(i, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
async win(ctx) {
|
||||
const b = S.b;
|
||||
if (b.sc) { b.sc.stop(); b.sc = null; }
|
||||
b.bullets.forEach(x => S.arena.remove(x.m)); b.bullets.length = 0;
|
||||
const caps = clamp(b.caps, 0, CAPS.length - 1);
|
||||
ctx.setBaud(caps);
|
||||
S.earned = caps;
|
||||
ctx.game.flags.baudEarned = caps;
|
||||
// the real CONNECT string. this is the payoff for the whole level.
|
||||
const arq = caps >= 3 ? '/ARQ' : '';
|
||||
const proto = caps >= 5 ? '/V34' : caps >= 4 ? '/V32B' : caps >= 3 ? '/V32' : caps >= 2 ? '/V22B' : '';
|
||||
const comp = caps >= 4 ? '/LAPM/V42BIS' : '';
|
||||
await ctx.card(`CONNECT ${CAPS[caps].rate}`, `${CAPS[caps].tag}${arq}${proto}${comp}`, 3400);
|
||||
if (caps <= 1) ctx.say('you are going to see the rest of this in letters. honestly, it suits it.', 4600);
|
||||
else if (caps >= 6) ctx.say('everything. it agreed to everything. do not get used to it.', 4200);
|
||||
nextPhase(ctx, null);
|
||||
},
|
||||
exit() { if (S.b && S.b.sc) S.b.sc.stop(); },
|
||||
},
|
||||
|
||||
// ── 5. CARRIER. Not a boss. A ride.
|
||||
carrier: {
|
||||
name: 'CARRIER', freqs: 'scrambled data',
|
||||
hint: 'stay on it.',
|
||||
enter(ctx) {
|
||||
S.b = { z: 0, x: 0, life: 1, t: 0, rings: [], done: false };
|
||||
S.free = false;
|
||||
S.b.hiss = A.carrier(0.10);
|
||||
for (let i = 0; i < 70; i++) {
|
||||
const r = ring(6, 0x2ad8ff, 36, 0.5);
|
||||
r.rotation.x = Math.PI / 2;
|
||||
S.arena.add(r);
|
||||
S.b.rings.push(r);
|
||||
}
|
||||
ctx.say('that is the data. that is all it ever was.', 4200);
|
||||
ctx.hint('<k>A</k> <k>D</k> steer');
|
||||
},
|
||||
update(dt, t, ctx) {
|
||||
const b = S.b; b.t += dt;
|
||||
b.z += dt * 34;
|
||||
// the channel snakes. one sine is a corridor; two is a river.
|
||||
const chan = z => Math.sin(z * 0.035) * 9 + Math.sin(z * 0.011 + 2) * 5;
|
||||
b.x = clamp(b.x + In.axis().x * dt * 26, -22, 22);
|
||||
S.p.position.set(b.x, 2, 0);
|
||||
const off = Math.abs(b.x - chan(b.z));
|
||||
const on = off < 4.5;
|
||||
b.life = clamp(b.life + (on ? dt * 0.22 : -dt * 0.40), 0, 1);
|
||||
b.hiss.level(0.03 + b.life * 0.10);
|
||||
|
||||
b.rings.forEach((r, i) => {
|
||||
const zz = -((i * 14) - (b.z % 14));
|
||||
r.position.set(chan(b.z - zz), 2, zz);
|
||||
r.material.opacity = clamp((1 - Math.abs(zz) / 980) * 0.7, 0, 0.7);
|
||||
r.material.color.setHex(on ? 0x2ad8ff : 0xff4a5e);
|
||||
});
|
||||
|
||||
S.cam.position.set(b.x * 0.4, 9, 16);
|
||||
S.cam.lookAt(b.x * 0.5, 2, -18);
|
||||
ctx.link.glitch = (1 - b.life) * 0.5;
|
||||
|
||||
if (b.life <= 0 && !b.done) { b.done = true; b.hiss.stop(); ctx.link.glitch = 0; ctx.spendTTL(99, 'lost the carrier'); }
|
||||
if (b.t > 26 && !b.done) {
|
||||
b.done = true;
|
||||
b.hiss.stop();
|
||||
ctx.link.glitch = 0;
|
||||
(async () => {
|
||||
await ctx.sleep(400);
|
||||
await ctx.card('CARRIER', 'and then it is just quiet', 3000);
|
||||
ctx.next();
|
||||
})();
|
||||
}
|
||||
},
|
||||
exit() { S.b?.hiss?.stop(); },
|
||||
},
|
||||
};
|
||||
|
||||
const ORDER = ['dialtone', 'dtmf', 'ans', 'scream', 'carrier'];
|
||||
|
||||
async function nextPhase(ctx, line) {
|
||||
if (S.switching) return;
|
||||
S.switching = true;
|
||||
const cur = BOSS[ORDER[S.i]];
|
||||
if (cur && cur.exit) cur.exit();
|
||||
for (let i = S.arena.children.length - 1; i >= 0; i--) S.arena.remove(S.arena.children[i]);
|
||||
if (line) { ctx.say(line, 3200); await ctx.sleep(1900); }
|
||||
S.i++;
|
||||
if (S.i >= ORDER.length) { S.switching = false; return; }
|
||||
const nb = BOSS[ORDER[S.i]];
|
||||
await ctx.card(nb.name, nb.freqs, 2400);
|
||||
ctx.say(nb.hint, 3600);
|
||||
if (S.i > 0) ctx.spendTTL(1);
|
||||
nb.enter(ctx);
|
||||
S.switching = false;
|
||||
}
|
||||
|
||||
export default {
|
||||
id: 'handshake', hop: 45, title: 'THE HANDSHAKE', via: 'v.everything', ms: '14000 ms',
|
||||
baud: 6,
|
||||
|
||||
async mount(ctx) {
|
||||
const { scene, camera } = ctx;
|
||||
S = { i: -1, t: 0, inv: 0, shake: 0, flash: 0, clean: true, switching: false, free: true, earned: 3 };
|
||||
window.__S = S; // debug handle
|
||||
S.dbgNext = () => nextPhase(ctx, null); // step to the next tone without winning
|
||||
|
||||
scene.background = new THREE.Color(0x03060a);
|
||||
scene.fog = new THREE.FogExp2(0x03060a, 0.012);
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 1.0));
|
||||
|
||||
S.arena = new THREE.Group(); scene.add(S.arena);
|
||||
S.cam = camera;
|
||||
|
||||
// the floor is an oscilloscope. everything in here is a signal.
|
||||
const floor = new THREE.Mesh(new THREE.CircleGeometry(R, 80), new THREE.MeshBasicMaterial({ color: 0x061219 }));
|
||||
floor.rotation.x = -Math.PI / 2; scene.add(floor);
|
||||
const grid = new THREE.GridHelper(R * 2, 32, 0x123a4a, 0x0d2733);
|
||||
grid.position.y = 0.02; scene.add(grid);
|
||||
for (let i = 1; i <= 4; i++) { const r = ring(R * i / 4, 0x1a5468, 96, 0.5); r.position.y = 0.03; scene.add(r); }
|
||||
const edge = ring(R, 0x2ad8ff, 128, 0.9); edge.position.y = 0.05; scene.add(edge);
|
||||
|
||||
// you
|
||||
S.p = new THREE.Group();
|
||||
S.pcore = new THREE.Mesh(new THREE.IcosahedronGeometry(0.62, 1), glow(0x9ff5ff, 1.7));
|
||||
S.p.add(S.pcore);
|
||||
S.phalo = new THREE.Mesh(new THREE.IcosahedronGeometry(0.95, 1),
|
||||
new THREE.MeshBasicMaterial({ color: 0x2ad8ff, wireframe: true, transparent: true, opacity: 0.35 }));
|
||||
S.p.add(S.phalo);
|
||||
S.p.position.set(0, 1.2, R * 0.6);
|
||||
scene.add(S.p);
|
||||
|
||||
camera.position.set(0, 30, 26);
|
||||
camera.lookAt(0, 0, 0);
|
||||
camera.far = 400; camera.updateProjectionMatrix();
|
||||
|
||||
ctx.hint('<k>WASD</k>move');
|
||||
await ctx.card('THE HANDSHAKE', 'hop 45 · five tones, in order', 3000);
|
||||
nextPhase(ctx, null);
|
||||
},
|
||||
|
||||
update(dt, t, ctx) {
|
||||
S.t += dt;
|
||||
S.inv = Math.max(0, S.inv - dt);
|
||||
S.flash = Math.max(0, S.flash - dt * 2);
|
||||
|
||||
// twin-stick: screen relative, no yaw, because this is an arena not a corridor
|
||||
if (S.free) {
|
||||
const a = In.axis();
|
||||
const p = S.p.position;
|
||||
p.x = clamp(p.x + a.x * 13.5 * dt, -R + 1, R - 1);
|
||||
p.z = clamp(p.z + a.y * 13.5 * dt, -R + 1, R - 1);
|
||||
const l = Math.hypot(p.x, p.z);
|
||||
if (l > R - 1) { p.x *= (R - 1) / l; p.z *= (R - 1) / l; }
|
||||
}
|
||||
S.p.position.y = 1.2 + Math.sin(S.t * 3) * 0.08;
|
||||
S.phalo.rotation.y += dt * 1.4; S.phalo.rotation.x += dt * 0.6;
|
||||
S.pcore.material.color.setHex(S.inv > 0 && Math.floor(S.t * 20) % 2 ? 0xff4a5e : 0x9ff5ff);
|
||||
|
||||
const cur = BOSS[ORDER[S.i]];
|
||||
if (cur && !S.switching) cur.update(dt, t, ctx);
|
||||
|
||||
if (ORDER[S.i] !== 'carrier') {
|
||||
S.cam.position.x = damp(S.cam.position.x, S.p.position.x * 0.25, 3, dt);
|
||||
S.cam.position.z = damp(S.cam.position.z, 26 + S.p.position.z * 0.18, 3, dt);
|
||||
S.cam.position.y = 30;
|
||||
S.cam.lookAt(S.p.position.x * 0.2, 0, S.p.position.z * 0.15);
|
||||
ctx.link.glitch = (S.inv > 0 ? 0.2 : 0) + S.flash * 0.12;
|
||||
}
|
||||
if (S.shake > 0) {
|
||||
S.shake -= dt * 2.2;
|
||||
S.cam.position.x += (Math.random() - 0.5) * S.shake * 2;
|
||||
S.cam.position.y += (Math.random() - 0.5) * S.shake * 2;
|
||||
}
|
||||
},
|
||||
|
||||
async unmount(ctx) {
|
||||
const cur = BOSS[ORDER[S.i]];
|
||||
if (cur && cur.exit) cur.exit();
|
||||
A.stopPad();
|
||||
ctx.link.glitch = 0;
|
||||
ctx.hint('');
|
||||
},
|
||||
};
|
||||
158
src/levels/root.js
Normal file
158
src/levels/root.js
Normal file
@ -0,0 +1,158 @@
|
||||
// HOP 64 — ROOT
|
||||
// Root is not a person and it is not a place. It is a permission bit, and the room
|
||||
// is empty, and it has been empty the whole time.
|
||||
//
|
||||
// And then the ACK comes back.
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import * as A from '../audio.js';
|
||||
import * as In from '../input.js';
|
||||
import { Body, Look, sprite, glow, damp, clamp } from '../kit.js';
|
||||
|
||||
let S = {};
|
||||
|
||||
export default {
|
||||
id: 'root', hop: 64, title: 'ROOT', via: 'uid.0', ms: '—',
|
||||
baud: 6, ink: 'dark',
|
||||
|
||||
async mount(ctx) {
|
||||
const { scene, camera } = ctx;
|
||||
S = { t: 0, phase: 'walk', ack: null, est: 0, said: 0 };
|
||||
|
||||
scene.background = new THREE.Color(0xffffff);
|
||||
scene.fog = new THREE.Fog(0xffffff, 14, 90);
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 3.0));
|
||||
|
||||
const floor = new THREE.Mesh(new THREE.CircleGeometry(90, 64),
|
||||
new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.3 }));
|
||||
floor.rotation.x = -Math.PI / 2; scene.add(floor);
|
||||
ctx.world.add(0, -0.5, 0, 260, 0.5, 260);
|
||||
|
||||
// there is one thing in the room and it is a sign saying what the room is
|
||||
S.uid = sprite('uid 0', { color: '#0e1a20', size: 1.1 });
|
||||
S.uid.position.set(0, 2.2, -26); scene.add(S.uid);
|
||||
|
||||
S.body = new Body(ctx.world, { speed: 3.0, jump: 0, gravity: 22, r: 0.34, h: 1.6 });
|
||||
S.body.pos.set(0, 0, 14);
|
||||
S.look = new Look();
|
||||
S.look.yaw = 0;
|
||||
camera.far = 400; camera.updateProjectionMatrix();
|
||||
|
||||
// the ACK, when it comes
|
||||
S.ackMesh = new THREE.Group();
|
||||
const core = new THREE.Mesh(new THREE.IcosahedronGeometry(0.5, 2), glow(0x63ffa2, 1.4));
|
||||
S.ackMesh.add(core); S.ackCore = core;
|
||||
const halo = new THREE.Mesh(new THREE.IcosahedronGeometry(0.9, 1),
|
||||
new THREE.MeshBasicMaterial({ color: 0x2ad8ff, wireframe: true, transparent: true, opacity: 0.4 }));
|
||||
S.ackMesh.add(halo); S.ackHalo = halo;
|
||||
S.ackTag = sprite('ACK', { color: '#0e1a20', size: 0.5 });
|
||||
S.ackTag.position.y = 1.2; S.ackMesh.add(S.ackTag);
|
||||
S.ackMesh.position.set(0, 1.2, -220);
|
||||
S.ackMesh.visible = false;
|
||||
scene.add(S.ackMesh);
|
||||
|
||||
A.pad([220, 329.63, 440], { gain: 0.028 });
|
||||
ctx.hint('<k>WASD</k>walk<br><k>CLICK</k>capture mouse');
|
||||
await ctx.card('ROOT', 'hop 64 · one TTL remaining', 3400);
|
||||
ctx.say('you are here.', 3600);
|
||||
},
|
||||
|
||||
update(dt, t, ctx) {
|
||||
S.t += dt;
|
||||
const b = S.body, look = S.look;
|
||||
if (In.mouse.clicked && !In.isLocked()) { In.lock(); A.resume(); }
|
||||
look.update();
|
||||
const w = look.wish();
|
||||
b.move(dt, w.x, w.z, false);
|
||||
look.applyFirstPerson(ctx.camera, b, Math.sin(S.t * 6.6) * 0.02 * Math.min(1, Math.hypot(b.vel.x, b.vel.z)));
|
||||
|
||||
S.ackHalo.rotation.y += dt * 0.9; S.ackHalo.rotation.x += dt * 0.4;
|
||||
S.ackCore.scale.setScalar(1 + Math.sin(S.t * 3) * 0.08);
|
||||
|
||||
if (S.phase === 'walk') {
|
||||
// there is nothing here. we let them find that out at their own pace.
|
||||
const beats = [
|
||||
[5, 'there is nobody here.'],
|
||||
[11, 'root is not a person. root is a permission bit.'],
|
||||
[18, 'you have it. there is nobody here to have it <i>at</i>.'],
|
||||
[26, 'you spent sixty-four hops on this.'],
|
||||
[34, null],
|
||||
];
|
||||
const nxt = beats[S.said];
|
||||
if (nxt && S.t > nxt[0]) {
|
||||
S.said++;
|
||||
if (nxt[1]) ctx.say(nxt[1], 5000);
|
||||
else this.theAck(ctx);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── you sent a SYN sixty-four hops ago. the network is slow but it is not unkind.
|
||||
async theAck(ctx) {
|
||||
S.phase = 'ack';
|
||||
ctx.hush();
|
||||
A.stopPad();
|
||||
await ctx.sleep(3000);
|
||||
|
||||
S.ackMesh.visible = true;
|
||||
A.burst(880, 1320, 0.3, 0.1);
|
||||
ctx.say('...', 2400);
|
||||
// it comes in from a very long way away
|
||||
for (let i = 0; i < 100; i++) {
|
||||
S.ackMesh.position.z = damp(S.ackMesh.position.z, -6, 0.9, 0.06);
|
||||
await ctx.sleep(60);
|
||||
}
|
||||
await ctx.sleep(600);
|
||||
|
||||
ctx.say('somebody answered.', 4000);
|
||||
await ctx.sleep(4200);
|
||||
ctx.say('somewhere in all of that — somebody answered.', 4600);
|
||||
await ctx.sleep(4800);
|
||||
|
||||
A.pad([261.63, 329.63, 392, 523.25], { gain: 0.05 });
|
||||
await ctx.flash('ESTABLISHED', 'est', 2600);
|
||||
ctx.say('', 0); ctx.hush();
|
||||
|
||||
// sixteen seconds. two packets, briefly, in contact.
|
||||
S.phase = 'established';
|
||||
for (let i = 16; i > 0; i--) {
|
||||
ctx.say(`<i>${i}</i>`, 1000);
|
||||
await ctx.sleep(1000);
|
||||
}
|
||||
ctx.hush();
|
||||
|
||||
// and then, because that is what TCP is and always was
|
||||
A.stopPad();
|
||||
for (const [f, label] of [[440, 'FIN'], [523.25, 'FIN-ACK'], [392, 'ACK'], [0, 'CLOSED']]) {
|
||||
if (f) A.burst(f, f * 1.5, 0.2, 0.12);
|
||||
ctx.say(label, 1400);
|
||||
await ctx.sleep(1500);
|
||||
}
|
||||
await ctx.sleep(1200);
|
||||
ctx.say('four packets. that is the whole ceremony.', 4200);
|
||||
await ctx.sleep(4400);
|
||||
|
||||
// ── back in the beige room, with FIN, who told you
|
||||
await ctx.card('TIME_WAIT', 'two minutes of maximum segment lifetime', 3600, 'warm');
|
||||
ctx.say('told you.', 3000, 'FIN');
|
||||
await ctx.sleep(3200);
|
||||
ctx.say('it was worth it.', 3000);
|
||||
await ctx.sleep(3400);
|
||||
|
||||
ctx.phile('root', 'root', 'the room was empty. the reply was not.');
|
||||
await ctx.card('HANDSHAKE', `${ctx.game.philes.size} philes · ${ctx.game.deaths} expiries · TTL ${ctx.game.ttl}`, 5200);
|
||||
|
||||
// ── and it starts again, and that is not a punishment
|
||||
ctx.say('new sequence number. TTL 64.', 4000);
|
||||
await ctx.sleep(2400);
|
||||
ctx.game.ttl = 64;
|
||||
ctx.game.hop = 1;
|
||||
ctx.goto('coldboot');
|
||||
},
|
||||
|
||||
async unmount(ctx) {
|
||||
A.stopPad();
|
||||
In.unlock();
|
||||
ctx.hint('');
|
||||
},
|
||||
};
|
||||
250
src/levels/smash.js
Normal file
250
src/levels/smash.js
Normal file
@ -0,0 +1,250 @@
|
||||
// HOPS 17–20 — SMASHING THE STACK
|
||||
// You are the input now. The level is one char buf[64] and you walk right. Past
|
||||
// index 63 the tiles stop being level and start being memory.
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import * as A from '../audio.js';
|
||||
import * as In from '../input.js';
|
||||
import { Body, sprite, panel, glow, damp, clamp } from '../kit.js';
|
||||
|
||||
const CELL = 2.2; // one byte, in metres
|
||||
const WANT = ['A0', 'F7', 'FF', 'BF']; // 0xBFFFF7A0, little-endian, as you write it
|
||||
|
||||
let S = {};
|
||||
|
||||
const TILE = {
|
||||
buf: { face: 0x33566f, edge: 0x6fd8ff, ink: '#a8dcf0' },
|
||||
ebp: { face: 0x4a3a5e, edge: 0xc89fff, ink: '#d8b8ff' },
|
||||
ret: { face: 0x6a4a12, edge: 0xffb648, ink: '#ffd9a8' },
|
||||
nop: { face: 0x1e5c38, edge: 0x63ffa2, ink: '#9fffc8' },
|
||||
shell: { face: 0x6a1030, edge: 0xff6a8a, ink: '#ff9fb8' },
|
||||
};
|
||||
|
||||
function tile(ctx, ix, y, w, kind, label, colour) {
|
||||
const x = ix * CELL;
|
||||
const T = TILE[kind] || TILE.buf;
|
||||
const mat = new THREE.MeshStandardMaterial({ color: colour ?? T.face, roughness: 0.6, metalness: 0.1 });
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(CELL * w - 0.12, 0.7, 4), mat);
|
||||
m.position.set(x + (w - 1) * CELL / 2, y + 0.35, 0);
|
||||
ctx.scene.add(m);
|
||||
// a lit strip along the top edge, so a byte reads as a byte at any bandwidth
|
||||
const e = new THREE.Mesh(new THREE.BoxGeometry(CELL * w - 0.12, 0.09, 4.1), glow(T.edge, 1.0));
|
||||
e.position.set(m.position.x, y + 0.74, 0);
|
||||
ctx.scene.add(e);
|
||||
const b = ctx.world.add(m.position.x, y, 0, CELL * w - 0.12, 0.7, 4, kind);
|
||||
b.mesh = m; b.edge = e; b.kind = kind; b.ix = ix;
|
||||
if (label) {
|
||||
const s = sprite(label, { color: T.ink, size: 0.6 });
|
||||
s.position.set(m.position.x, y + 1.35, 0);
|
||||
ctx.scene.add(s);
|
||||
b.label = s;
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
export default {
|
||||
id: 'smash', hop: 17, title: 'SMASHING THE STACK', via: '0x41414141', ms: '49 ms',
|
||||
baud: 3,
|
||||
|
||||
async mount(ctx) {
|
||||
const { scene, camera } = ctx;
|
||||
S = { t: 0, written: [], hasCanary: false, canaryAngry: false, phase: 'buf', slid: 0, done: false };
|
||||
|
||||
scene.background = new THREE.Color(0x080d14);
|
||||
scene.fog = new THREE.Fog(0x080d14, 60, 220);
|
||||
scene.add(new THREE.AmbientLight(0xc8dcec, 2.4));
|
||||
const d = new THREE.DirectionalLight(0xffffff, 1.8); d.position.set(-4, 10, 12); scene.add(d);
|
||||
const d2 = new THREE.DirectionalLight(0x6fa8d8, 0.9); d2.position.set(6, -4, 8); scene.add(d2);
|
||||
|
||||
// the rest of the address space, behind you, so the void has a floor plan
|
||||
const back = new THREE.GridHelper(600, 300, 0x1e3a4e, 0x152a38);
|
||||
back.rotation.x = Math.PI / 2;
|
||||
back.position.set(140, 4, -26);
|
||||
scene.add(back);
|
||||
const far = new THREE.Mesh(new THREE.PlaneGeometry(700, 200),
|
||||
new THREE.MeshBasicMaterial({ color: 0x0b1622 }));
|
||||
far.position.set(140, 4, -30); scene.add(far);
|
||||
|
||||
// ── char buf[64]. sixty-four bytes and a floor made of them.
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const gap = (i === 18 || i === 19 || i === 37 || i === 38 || i === 51);
|
||||
if (gap) continue;
|
||||
const y = i < 24 ? 0 : i < 40 ? 1.6 : 0.8;
|
||||
tile(ctx, i, y, 1, 'buf', i % 8 === 0 ? `buf[${i}]` : null);
|
||||
}
|
||||
// a couple of ledges so the gaps are jumpable in more than one way
|
||||
tile(ctx, 18, 3.4, 2, 'buf', null, 0x22384a);
|
||||
tile(ctx, 37, 4.2, 2, 'buf', null, 0x22384a);
|
||||
|
||||
// the canary value, sitting up on a ledge, exactly where a value like that lives
|
||||
S.token = new THREE.Mesh(new THREE.OctahedronGeometry(0.55, 0), glow(0xffe14a, 1.6));
|
||||
S.token.position.set(37.5 * CELL, 6.2, 0);
|
||||
scene.add(S.token);
|
||||
const tl = sprite('%fs:0x28', { color: '#ffe14a', size: 0.5 });
|
||||
tl.position.set(37.5 * CELL, 7.2, 0); scene.add(tl);
|
||||
|
||||
// ── the canary itself, sitting on the boundary, being a bird about it
|
||||
S.bird = new THREE.Group();
|
||||
const body = new THREE.Mesh(new THREE.IcosahedronGeometry(0.55, 0), glow(0xffe14a, 1.4));
|
||||
S.bird.add(body); S.birdBody = body;
|
||||
const beak = new THREE.Mesh(new THREE.ConeGeometry(0.16, 0.4, 5), glow(0xff9a2a, 1.2));
|
||||
beak.position.set(0.5, 0, 0); beak.rotation.z = -Math.PI / 2; S.bird.add(beak);
|
||||
S.bird.position.set(64.5 * CELL, 1.6, 0);
|
||||
scene.add(S.bird);
|
||||
tile(ctx, 64, 0.8, 2, 'buf', 'canary');
|
||||
|
||||
// ── saved frame pointer, then the return address: four bytes you have to write
|
||||
tile(ctx, 66, 0.8, 2, 'ebp', 'saved ebp');
|
||||
S.retCells = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const b = tile(ctx, 68 + i, 0.8, 1, 'ret', WANT[i]);
|
||||
b.slot = i;
|
||||
S.retCells.push(b);
|
||||
b.mesh.material.emissive = new THREE.Color(0x000000);
|
||||
}
|
||||
S.readout = sprite('return address: ?? ?? ?? ??', { color: '#ffd9a8', size: 0.75 });
|
||||
S.readout.position.set(70 * CELL, 6.5, 0); scene.add(S.readout);
|
||||
|
||||
// ── the NOP sled. a long shallow ramp of 0x90 and the most relaxing thirty
|
||||
// seconds in the game.
|
||||
S.sled = [];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const b = tile(ctx, 74 + i, 6.4 - i * 0.14, 1, 'nop', i % 6 === 0 ? '90' : null, 0x14321f);
|
||||
S.sled.push(b);
|
||||
}
|
||||
// ── and the shellcode, on the far side of it
|
||||
tile(ctx, 114, 0.8, 8, 'shell', 'shellcode', 0x3a0d1a);
|
||||
const sc = panel('\\x31\\xc0\\x50\\x68//sh\\x68/bin\\x89\\xe3\\x50\\x53\\x89\\xe1\\xb0\\x0b\\xcd\\x80',
|
||||
{ color: '#ff6a8a', size: 0.55 });
|
||||
sc.position.set(117.5 * CELL, 4.2, 0); scene.add(sc);
|
||||
S.shellX = 114 * CELL;
|
||||
|
||||
// a ghost who chain-smokes and is credited only as Aleph One
|
||||
S.ghost = new THREE.Group();
|
||||
const gb = new THREE.Mesh(new THREE.SphereGeometry(0.5, 14, 10),
|
||||
new THREE.MeshBasicMaterial({ color: 0x63ffa2, transparent: true, opacity: 0.4 }));
|
||||
S.ghost.add(gb);
|
||||
const gt = sprite('Aleph One', { color: '#63ffa2', size: 0.5 });
|
||||
gt.position.y = 1.2; S.ghost.add(gt);
|
||||
S.ghost.position.set(2 * CELL, 2.2, 2); scene.add(S.ghost);
|
||||
|
||||
// ── you
|
||||
S.p = new THREE.Group();
|
||||
S.pcore = new THREE.Mesh(new THREE.IcosahedronGeometry(0.42, 1), glow(0x9ff5ff, 1.7));
|
||||
S.p.add(S.pcore);
|
||||
scene.add(S.p);
|
||||
S.body = new Body(ctx.world, { speed: 9.5, jump: 12.5, gravity: 34, r: 0.38, h: 0.9 });
|
||||
S.body.hasFloor = false;
|
||||
S.body.pos.set(0, 2, 0);
|
||||
|
||||
camera.position.set(0, 4, 15);
|
||||
camera.far = 400; camera.updateProjectionMatrix();
|
||||
|
||||
A.pad([98, 146.83, 196], { gain: 0.03 });
|
||||
ctx.hint('<k>A</k> <k>D</k>move<br><k>SPACE</k>jump');
|
||||
await ctx.card('SMASHING THE STACK', 'hop 17 · for fun and profit', 3000);
|
||||
ctx.say('you are the input. walk right.', 4000, 'ALEPH ONE');
|
||||
},
|
||||
|
||||
update(dt, t, ctx) {
|
||||
S.t += dt;
|
||||
const b = S.body;
|
||||
|
||||
// 2D: x and jump only. z stays nailed to zero.
|
||||
const a = In.axis();
|
||||
b.pos.z = 0; b.vel.z = 0;
|
||||
b.move(dt, a.x, 0, In.jumpHit());
|
||||
if (b.pos.y < -14) { b.pos.set(Math.max(0, b.pos.x - 6), 6, 0); b.vel.set(0, 0, 0); ctx.spendTTL(1, 'segfault'); }
|
||||
|
||||
S.p.position.copy(b.pos); S.p.position.y += 0.45;
|
||||
S.pcore.rotation.y += dt * 2; S.pcore.rotation.x += dt * 1.1;
|
||||
|
||||
ctx.camera.position.x = damp(ctx.camera.position.x, b.pos.x + 4, 4, dt);
|
||||
ctx.camera.position.y = damp(ctx.camera.position.y, b.pos.y + 3, 3, dt);
|
||||
ctx.camera.lookAt(b.pos.x + 4, b.pos.y + 1.2, 0);
|
||||
|
||||
S.token.rotation.y += dt * 2.4;
|
||||
S.token.position.y = 6.2 + Math.sin(S.t * 2) * 0.15;
|
||||
S.bird.position.y = 1.6 + Math.sin(S.t * 3.4) * 0.1;
|
||||
S.bird.rotation.y = Math.sin(S.t * 0.9) * 0.6;
|
||||
S.ghost.position.y = 2.2 + Math.sin(S.t * 1.2) * 0.1;
|
||||
|
||||
// ── the canary value
|
||||
if (!S.hasCanary && S.token.visible !== false &&
|
||||
Math.hypot(b.pos.x - S.token.position.x, b.pos.y - S.token.position.y) < 1.6) {
|
||||
S.hasCanary = true;
|
||||
S.token.visible = false;
|
||||
A.burst(1760, 2640, 0.2, 0.18);
|
||||
ctx.say('<b>%fs:0x28</b> you have the canary value. now it is just a number you know.', 4600);
|
||||
ctx.phile('canary', 'the stack canary', 'a random word between the buffer and the return address. knowing it is the whole trick.');
|
||||
}
|
||||
|
||||
// ── the bird
|
||||
const nearBird = Math.abs(b.pos.x - S.bird.position.x) < 1.6 && Math.abs(b.pos.y - 1.6) < 2.2;
|
||||
if (nearBird && !S.canaryAngry && !S.hasCanary) {
|
||||
S.canaryAngry = true;
|
||||
S.birdBody.material.color.setHex(0xff4a5e);
|
||||
A.burst(2400, 3100, 0.6, 0.22);
|
||||
ctx.link.glitch = 0.5;
|
||||
ctx.say('<b>*** stack smashing detected ***</b>', 3200);
|
||||
ctx.spendTTL(2, 'canary');
|
||||
setTimeout(() => { ctx.link.glitch = 0; S.canaryAngry = false; S.birdBody.material.color.setHex(0xffe14a); }, 900);
|
||||
b.pos.x -= 4; b.vel.y = 8;
|
||||
}
|
||||
if (nearBird && S.hasCanary && !S.forged) {
|
||||
S.forged = true;
|
||||
S.birdBody.material.color.setHex(0x63ffa2);
|
||||
A.burst(1320, 1760, 0.3, 0.18);
|
||||
ctx.say('you write the value back exactly where it was. the bird does not look up.', 4600);
|
||||
}
|
||||
|
||||
// ── the return address. four bytes, in order, by standing on them.
|
||||
if (b.grounded && b.ground && b.ground.kind === 'ret') {
|
||||
const slot = b.ground.slot;
|
||||
if (slot === S.written.length) {
|
||||
S.written.push(WANT[slot]);
|
||||
b.ground.mesh.material.color.setHex(0x63ffa2);
|
||||
A.burst(880 + slot * 220, 1320 + slot * 220, 0.16, 0.18);
|
||||
S.readout.userData.setText('return address: ' +
|
||||
WANT.map((w, i) => i < S.written.length ? w : '??').join(' '), '#63ffa2');
|
||||
if (S.written.length === 4) {
|
||||
ctx.say('<b>0xBFFFF7A0</b> — that is not where the function came from. it is where you live.', 5200);
|
||||
ctx.phile('ret', 'the return address', 'you did not break in. you edited where the door goes.');
|
||||
ctx.spendTTL(1);
|
||||
}
|
||||
} else if (slot < S.written.length) {
|
||||
// fine, walking back over one you already wrote
|
||||
} else if (!S.wrongCool || S.t > S.wrongCool) {
|
||||
S.wrongCool = S.t + 1.2;
|
||||
A.burst(480, 620, 0.2, 0.16);
|
||||
ctx.say('little endian. lowest byte first.', 2600);
|
||||
}
|
||||
}
|
||||
|
||||
// ── the sled. if the address is written, it is a slide. if it is not, it is a wall.
|
||||
if (b.pos.x > 73 * CELL && S.written.length < 4 && !S.warned) {
|
||||
S.warned = true;
|
||||
ctx.say('there is nothing down there for you yet. write the address first.', 4000);
|
||||
}
|
||||
if (b.grounded && b.ground && b.ground.kind === 'nop') {
|
||||
if (!S.sliding) { S.sliding = true; ctx.say('<b>0x90 0x90 0x90 0x90 0x90 0x90</b>', 4200); }
|
||||
b.vel.x = Math.max(b.vel.x, 9); // you do not walk a sled. you arrive on it.
|
||||
if (Math.random() < 0.25) A.blip(1400 + Math.random() * 200, 0.008, 0.02);
|
||||
}
|
||||
|
||||
if (b.pos.x > S.shellX + 2 && !S.done) {
|
||||
S.done = true;
|
||||
(async () => {
|
||||
A.burst(220, 330, 0.6, 0.2);
|
||||
await ctx.card('SHELLCODE', 'execve("/bin/sh", ...)', 3200);
|
||||
ctx.next();
|
||||
})();
|
||||
}
|
||||
},
|
||||
|
||||
async unmount(ctx) {
|
||||
A.stopPad();
|
||||
ctx.link.glitch = 0;
|
||||
ctx.hint('');
|
||||
},
|
||||
};
|
||||
376
src/levels/stack.js
Normal file
376
src/levels/stack.js
Normal file
@ -0,0 +1,376 @@
|
||||
// HOPS 2–8 — THE STACK
|
||||
// Seven floors, descending, one per layer. Everything that hurts you here is a real
|
||||
// mitigation played entirely straight. You arrive wearing seven headers. You will not
|
||||
// leave wearing any of them.
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import * as A from '../audio.js';
|
||||
import * as In from '../input.js';
|
||||
import { Body, Look, rng, sprite, panel, makeSyn, glow, damp, clamp } from '../kit.js';
|
||||
|
||||
const LAYER_H = 30;
|
||||
const LAYERS = [
|
||||
{ n: 7, name: 'APPLICATION', hazard: 'none', tint: 0xffffff, note: 'the top. everything up here is polite.' },
|
||||
{ n: 6, name: 'PRESENTATION', hazard: 'nx', tint: 0xe8f4ff, note: 'marked floors. you may look. you may not stand.' },
|
||||
{ n: 5, name: 'SESSION', hazard: 'rate', tint: 0xd8ecff, note: 'one step every one point two seconds. it is coming anyway.' },
|
||||
{ n: 4, name: 'TRANSPORT', hazard: 'canary', tint: 0xc8e4ff, note: 'do not wake the birds.' },
|
||||
{ n: 3, name: 'NETWORK', hazard: 'aslr', tint: 0xb0d8ff, note: 'you cannot memorise this. you can only get good at reading it.' },
|
||||
{ n: 2, name: 'DATA LINK', hazard: 'collide', tint: 0x98c8f8, note: 'two packets, one wire. somebody backs off.' },
|
||||
{ n: 1, name: 'PHYSICAL', hazard: 'dark', tint: 0x7098c0, note: 'no protocol. just voltage and hope.' },
|
||||
];
|
||||
|
||||
let S = {};
|
||||
|
||||
function buildLayer(ctx, i, seed) {
|
||||
const L = LAYERS[i];
|
||||
const top = -i * LAYER_H;
|
||||
const g = new THREE.Group();
|
||||
const rand = rng(seed);
|
||||
const boxes = [];
|
||||
const plats = [];
|
||||
|
||||
const add = (x, y, z, w, h, d, kind) => {
|
||||
let mat;
|
||||
if (kind === 'nx') mat = new THREE.MeshStandardMaterial({ color: 0x3a0d14, emissive: 0xff2a44, emissiveIntensity: 0.35, roughness: 0.6 });
|
||||
else if (kind === 'gate') mat = new THREE.MeshStandardMaterial({ color: 0x0d2a18, emissive: 0x35ff96, emissiveIntensity: 0.7, roughness: 0.5 });
|
||||
else if (kind === 'catch') mat = new THREE.MeshStandardMaterial({ color: 0x0a0e12, roughness: 1, transparent: true, opacity: 0.55 });
|
||||
else mat = new THREE.MeshStandardMaterial({ color: L.tint, roughness: 0.55, metalness: 0.05 });
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat);
|
||||
m.position.set(x, y + h / 2, z);
|
||||
g.add(m);
|
||||
const b = ctx.world.add(x, y, z, w, h, d, kind === 'nx' ? 'nx' : kind);
|
||||
b.mesh = m; b.kind = kind; b.layer = i;
|
||||
boxes.push(b);
|
||||
if (kind !== 'catch') plats.push(b);
|
||||
// a hairline of light along the leading edge. reads at any resolution.
|
||||
if (kind !== 'catch') {
|
||||
const e = new THREE.Mesh(new THREE.BoxGeometry(w * 1.01, 0.06, d * 1.01),
|
||||
glow(kind === 'gate' ? 0x63ffa2 : kind === 'nx' ? 0xff4a5e : 0x6fd8ff, kind === 'plat' ? 0.55 : 1));
|
||||
e.position.set(x, y + h + 0.03, z);
|
||||
g.add(e);
|
||||
}
|
||||
return b;
|
||||
};
|
||||
|
||||
// seven platforms spiralling down the band, then the gate at the bottom
|
||||
const COUNT = 7;
|
||||
let ang = rand() * 6.28;
|
||||
for (let k = 0; k < COUNT; k++) {
|
||||
ang += 0.9 + rand() * 1.1;
|
||||
const r = 7 + rand() * 9;
|
||||
const x = Math.cos(ang) * r, z = Math.sin(ang) * r;
|
||||
const y = top - 2.6 - k * 2.9;
|
||||
const w = 4.2 + rand() * 3.4, d = 4.2 + rand() * 3.4;
|
||||
let kind = 'plat';
|
||||
if (L.hazard === 'nx' && k > 0 && rand() < 0.42) kind = 'nx';
|
||||
add(x, y, z, w, 0.7, d, kind);
|
||||
}
|
||||
// the way down
|
||||
const gx = Math.cos(ang + 1.2) * 6, gz = Math.sin(ang + 1.2) * 6;
|
||||
const gate = add(gx, top - 2.6 - COUNT * 2.9, gz, 5.4, 0.7, 5.4, 'gate');
|
||||
const label = sprite('▼ ' + LAYERS[Math.min(LAYERS.length - 1, i + 1)].name, { color: '#63ffa2', size: 1.1 });
|
||||
label.position.set(gx, gate.max.y + 2.4, gz);
|
||||
g.add(label);
|
||||
|
||||
// the miss floor. wide, dark, and it costs you.
|
||||
const catchY = top - LAYER_H + 3;
|
||||
add(0, catchY, 0, 90, 0.6, 90, 'catch');
|
||||
|
||||
// layer nameplate, big, on the far wall of the shaft
|
||||
const np = panel(String(L.n) + ' ' + L.name, { color: '#' + new THREE.Color(L.tint).getHexString(), size: 2.6 });
|
||||
np.position.set(0, top - 5, -26); g.add(np);
|
||||
const np2 = np.clone(); np2.position.set(0, top - 5, 26); np2.rotation.y = Math.PI; g.add(np2);
|
||||
|
||||
// hazard furniture
|
||||
const extra = { canaries: [], gates: [], collide: [] };
|
||||
if (L.hazard === 'canary') {
|
||||
// stack canaries. yellow. they scream. everything comes.
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const p = plats[1 + k * 2] || plats[plats.length - 1];
|
||||
const b = new THREE.Mesh(new THREE.IcosahedronGeometry(0.42, 0), glow(0xffe14a, 1.4));
|
||||
b.position.set((p.min.x + p.max.x) / 2, p.max.y + 0.5, (p.min.z + p.max.z) / 2);
|
||||
g.add(b);
|
||||
const beak = new THREE.Mesh(new THREE.ConeGeometry(0.12, 0.3, 5), glow(0xff9a2a, 1.2));
|
||||
beak.position.set(0, 0, 0.42); beak.rotation.x = Math.PI / 2; b.add(beak);
|
||||
extra.canaries.push({ mesh: b, plat: p, alive: true });
|
||||
}
|
||||
}
|
||||
if (L.hazard === 'rate') {
|
||||
// a corridor that lets you take one step every 1.2 seconds
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const p = plats[1 + k * 2];
|
||||
if (!p) continue;
|
||||
const cx = (p.min.x + p.max.x) / 2, cz = (p.min.z + p.max.z) / 2;
|
||||
const bar = new THREE.Mesh(new THREE.BoxGeometry(6, 3.2, 0.35),
|
||||
new THREE.MeshBasicMaterial({ color: 0xff4a5e, transparent: true, opacity: 0.5 }));
|
||||
bar.position.set(cx, p.max.y + 1.6, cz + 2.6);
|
||||
bar.lookAt(0, p.max.y + 1.6, 0);
|
||||
g.add(bar);
|
||||
const wall = ctx.world.add(bar.position.x, p.max.y, bar.position.z, 6, 3.2, 0.5, 'rate');
|
||||
wall.mesh = bar; wall.phase = k * 0.4;
|
||||
extra.gates.push(wall);
|
||||
}
|
||||
}
|
||||
if (L.hazard === 'collide') {
|
||||
plats.forEach((p, k) => { if (k % 2 === 1) extra.collide.push({ box: p, phase: k * 0.7 }); });
|
||||
}
|
||||
|
||||
ctx.scene.add(g);
|
||||
return { group: g, boxes, plats, gate, top, extra, seed, layer: i };
|
||||
}
|
||||
|
||||
function clearLayer(ctx, L) {
|
||||
if (!L) return;
|
||||
ctx.scene.remove(L.group);
|
||||
L.group.traverse(o => { o.geometry?.dispose?.(); if (o.material && !Array.isArray(o.material)) o.material.dispose?.(); });
|
||||
ctx.world.boxes = ctx.world.boxes.filter(b => !L.boxes.includes(b));
|
||||
}
|
||||
|
||||
export default {
|
||||
id: 'stack', hop: 2, title: 'THE STACK', via: 'osi.descent', ms: '3 ms',
|
||||
baud: 6,
|
||||
|
||||
async mount(ctx) {
|
||||
const { scene, camera } = ctx;
|
||||
S = { i: 0, layers: [], t: 0, firewall: null, refused: 0, aslrSeed: 90210, camShake: 0 };
|
||||
|
||||
scene.background = new THREE.Color(0x05080b);
|
||||
scene.fog = new THREE.FogExp2(0x05080b, 0.012);
|
||||
scene.add(new THREE.AmbientLight(0x33556b, 1.5));
|
||||
const key = new THREE.DirectionalLight(0xffffff, 1.6); key.position.set(6, 20, 8); scene.add(key);
|
||||
const rim = new THREE.DirectionalLight(0x2ad8ff, 0.8); rim.position.set(-8, -10, -6); scene.add(rim);
|
||||
|
||||
// the shaft itself — a tall hollow cylinder you fall down the middle of
|
||||
const shaft = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(30, 30, LAYER_H * (LAYERS.length + 2), 48, 1, true),
|
||||
new THREE.MeshStandardMaterial({ color: 0x0b141c, roughness: 1, side: THREE.BackSide, metalness: 0.1 })
|
||||
);
|
||||
shaft.position.y = -LAYER_H * (LAYERS.length + 2) / 2 + 12;
|
||||
scene.add(shaft);
|
||||
|
||||
for (let i = 0; i < LAYERS.length; i++) S.layers.push(buildLayer(ctx, i, 1337 + i * 7919));
|
||||
|
||||
// the top platform you arrive on
|
||||
ctx.world.add(0, 1, 0, 9, 0.8, 9, 'plat');
|
||||
const startMesh = new THREE.Mesh(new THREE.BoxGeometry(9, 0.8, 9), new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.5 }));
|
||||
startMesh.position.set(0, 1.4, 0); scene.add(startMesh);
|
||||
|
||||
// ── the FIREWALL, at the bottom, stateful, patient
|
||||
const fwY = -LAYERS.length * LAYER_H - 4;
|
||||
const fwGroup = new THREE.Group(); scene.add(fwGroup);
|
||||
const floor = new THREE.Mesh(new THREE.BoxGeometry(46, 1, 46), new THREE.MeshStandardMaterial({ color: 0x0e1a22, roughness: 0.9 }));
|
||||
floor.position.set(0, fwY - 0.5, 0); fwGroup.add(floor);
|
||||
ctx.world.add(0, fwY - 1, 0, 46, 1, 46, 'plat');
|
||||
|
||||
const fwMat = new THREE.MeshBasicMaterial({ color: 0xff4a5e, transparent: true, opacity: 0.42, side: THREE.DoubleSide });
|
||||
const fwMesh = new THREE.Mesh(new THREE.PlaneGeometry(44, 16), fwMat);
|
||||
fwMesh.position.set(0, fwY + 8, -14); fwGroup.add(fwMesh);
|
||||
const fwBox = ctx.world.add(0, fwY, -14, 44, 16, 0.9, 'fw');
|
||||
const fwLabel = sprite('DENY ALL INBOUND', { color: '#ff4a5e', size: 1.5 });
|
||||
fwLabel.position.set(0, fwY + 13, -14); fwGroup.add(fwLabel);
|
||||
|
||||
// the pad. it makes the firewall talk first.
|
||||
const pad = new THREE.Mesh(new THREE.CylinderGeometry(2.6, 2.6, 0.3, 24), glow(0x2ad8ff, 0.9));
|
||||
pad.position.set(0, fwY + 0.15, 8); fwGroup.add(pad);
|
||||
const padLabel = sprite('OUTBOUND', { color: '#2ad8ff', size: 0.9 });
|
||||
padLabel.position.set(0, fwY + 2.4, 8); fwGroup.add(padLabel);
|
||||
|
||||
const outbound = new THREE.Mesh(new THREE.IcosahedronGeometry(0.5, 1), glow(0xffe14a, 1.6));
|
||||
outbound.visible = false; fwGroup.add(outbound);
|
||||
|
||||
// beyond the wall: the way out
|
||||
const exitPad = new THREE.Mesh(new THREE.CylinderGeometry(3, 3, 0.3, 24), glow(0x63ffa2, 1.0));
|
||||
exitPad.position.set(0, fwY + 0.15, -26); fwGroup.add(exitPad);
|
||||
ctx.world.add(0, fwY - 1, -26, 60, 1, 30, 'plat');
|
||||
const exitFloor = new THREE.Mesh(new THREE.BoxGeometry(60, 1, 30), new THREE.MeshStandardMaterial({ color: 0x0e1a22, roughness: 0.9 }));
|
||||
exitFloor.position.set(0, fwY - 0.5, -26); fwGroup.add(exitFloor);
|
||||
|
||||
S.firewall = { group: fwGroup, mesh: fwMesh, mat: fwMat, box: fwBox, pad, outbound, exitPad, y: fwY, open: 0, sent: false, label: fwLabel };
|
||||
|
||||
// ── you
|
||||
S.syn = makeSyn(); scene.add(S.syn);
|
||||
S.body = new Body(ctx.world, { speed: 8.5, jump: 11.5, gravity: 30, r: 0.5, h: 1.1 });
|
||||
S.body.hasFloor = false;
|
||||
S.body.pos.set(0, 2.2, 0);
|
||||
S.look = new Look();
|
||||
S.look.yaw = 0.4;
|
||||
S.spawn = S.body.pos.clone();
|
||||
camera.far = 900; camera.updateProjectionMatrix();
|
||||
|
||||
A.pad([110, 164.81, 220, 329.63], { gain: 0.032 });
|
||||
ctx.hint('<k>WASD</k>move<br><k>SPACE</k>jump<br><k>CLICK</k>capture mouse');
|
||||
await ctx.card('7 APPLICATION', 'hop 2 · the descent begins', 2400);
|
||||
ctx.say(LAYERS[0].note, 4200);
|
||||
},
|
||||
|
||||
update(dt, t, ctx) {
|
||||
S.t += dt;
|
||||
const b = S.body, look = S.look, cam = ctx.camera;
|
||||
|
||||
if (In.mouse.clicked && !In.isLocked()) { In.lock(); A.resume(); }
|
||||
|
||||
look.update();
|
||||
const w = look.wish();
|
||||
b.move(dt, w.x, w.z, In.jumpHit());
|
||||
|
||||
S.syn.position.copy(b.pos);
|
||||
S.syn.position.y += 0.55;
|
||||
S.syn.userData.tick(t, dt);
|
||||
look.applyThirdPerson(cam, b, 8.5, 4.2);
|
||||
if (S.camShake > 0) {
|
||||
S.camShake -= dt * 2;
|
||||
cam.position.x += (Math.random() - 0.5) * S.camShake;
|
||||
cam.position.y += (Math.random() - 0.5) * S.camShake;
|
||||
}
|
||||
|
||||
const L = S.layers[S.i];
|
||||
|
||||
// ── hazard behaviour, per layer
|
||||
if (L) {
|
||||
const hz = LAYERS[S.i].hazard;
|
||||
|
||||
if (hz === 'rate') {
|
||||
// 1.2 s. exactly. you can hear it.
|
||||
L.extra.gates.forEach(gt => {
|
||||
const cyc = (S.t * (1 / 1.2) + gt.phase) % 1;
|
||||
const open = cyc > 0.62;
|
||||
gt.disabled = open;
|
||||
gt.mesh.material.opacity = open ? 0.06 : 0.5;
|
||||
if (!gt._was && open) A.relayClick(0.12, 1.6);
|
||||
gt._was = open;
|
||||
});
|
||||
}
|
||||
|
||||
if (hz === 'canary') {
|
||||
L.extra.canaries.forEach(c => {
|
||||
if (!c.alive) return;
|
||||
c.mesh.position.y = c.plat.max.y + 0.5 + Math.sin(S.t * 3 + c.plat.min.x) * 0.08;
|
||||
c.mesh.rotation.y += dt * 0.6;
|
||||
const d = Math.hypot(b.pos.x - c.mesh.position.x, b.pos.z - c.mesh.position.z);
|
||||
if (d < 1.4 && Math.abs(b.pos.y - c.plat.max.y) < 2) {
|
||||
c.alive = false;
|
||||
c.mesh.material.color.set(0xff4a5e);
|
||||
A.burst(2400, 3100, 0.5, 0.2);
|
||||
S.camShake = 0.5;
|
||||
ctx.say('<b>canary</b> the bird screams. everything comes.', 2600);
|
||||
ctx.spendTTL(1, 'canary');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (hz === 'collide') {
|
||||
// CSMA/CD. two packets, one wire, somebody backs off.
|
||||
L.extra.collide.forEach(c => {
|
||||
const on = Math.sin(S.t * 1.6 + c.phase) > -0.25;
|
||||
c.box.disabled = !on;
|
||||
if (c.box.mesh) { c.box.mesh.visible = on; c.box.mesh.material.opacity = on ? 1 : 0; }
|
||||
});
|
||||
}
|
||||
|
||||
if (hz === 'dark') {
|
||||
ctx.scene.fog.density = 0.03 + Math.sin(S.t * 0.6) * 0.004;
|
||||
}
|
||||
}
|
||||
|
||||
// ── did we land on something that matters
|
||||
if (b.grounded && b.ground) {
|
||||
const g = b.ground;
|
||||
if (g.kind === 'gate' && g.layer === S.i && !S.advancing) this.advance(ctx);
|
||||
else if (g.kind === 'catch' && !S.advancing) this.miss(ctx);
|
||||
}
|
||||
|
||||
// ── the firewall
|
||||
const F = S.firewall;
|
||||
if (F) {
|
||||
F.mat.opacity = 0.30 + Math.sin(S.t * 2.1) * 0.06;
|
||||
if (F.open > 0) {
|
||||
F.open -= dt;
|
||||
F.box.disabled = true;
|
||||
F.mat.color.setHex(0x63ffa2);
|
||||
F.mat.opacity = 0.10;
|
||||
if (F.open <= 0) { F.box.disabled = false; F.mat.color.setHex(0xff4a5e); F.label.userData.setText('DENY ALL INBOUND', '#ff4a5e'); }
|
||||
}
|
||||
// step on the pad → it sends something outbound, and it will accept the reply
|
||||
const onPad = Math.hypot(b.pos.x - F.pad.position.x, b.pos.z - F.pad.position.z) < 2.8 && Math.abs(b.pos.y - F.y) < 3;
|
||||
if (onPad && F.open <= 0 && !F.flight) {
|
||||
F.flight = 0;
|
||||
F.outbound.visible = true;
|
||||
F.outbound.position.set(0, F.y + 1.4, 8);
|
||||
A.burst(880, 1320, 0.2, 0.16);
|
||||
ctx.say('something went out. the wall will accept what comes back.', 3000);
|
||||
}
|
||||
if (F.flight !== undefined && F.flight !== null) {
|
||||
F.flight += dt;
|
||||
F.outbound.position.z = 8 - F.flight * 34;
|
||||
if (F.outbound.position.z < -20) {
|
||||
F.outbound.visible = false; F.flight = null;
|
||||
F.open = 3.2;
|
||||
F.label.userData.setText('ESTABLISHED — RELATED', '#63ffa2');
|
||||
A.burst(1320, 1760, 0.3, 0.16);
|
||||
ctx.say('<i>3 seconds.</i> go now, and go as a reply.', 2600);
|
||||
}
|
||||
}
|
||||
// through the wall while it is shut
|
||||
if (!F.done && Math.abs(b.pos.z + 14) < 1.6 && Math.abs(b.pos.y - F.y) < 12 && F.open <= 0) {
|
||||
S.refused++;
|
||||
b.pos.z = -12.2; b.vel.z = 12; b.vel.y = 4;
|
||||
A.burst(300, 220, 0.35, 0.2);
|
||||
S.camShake = 0.6;
|
||||
ctx.say('<b>connection refused</b>', 2000);
|
||||
ctx.spendTTL(1, 'refused');
|
||||
if (S.refused === 2) ctx.say('it is stateful. it remembers everything it has ever seen.<br>it has never seen you.', 4200);
|
||||
if (S.refused === 4) ctx.say('it has, however, seen its own traffic. <i>the pad.</i>', 4200);
|
||||
}
|
||||
if (!F.done && b.pos.z < -22) {
|
||||
F.done = true;
|
||||
(async () => {
|
||||
await ctx.card('FIREWALL', 'stateful firewalls trust the return leg', 2800);
|
||||
ctx.next();
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
if (b.pos.y < -LAYERS.length * LAYER_H - 40) { b.pos.copy(S.spawn); b.vel.set(0, 0, 0); }
|
||||
},
|
||||
|
||||
advance(ctx) {
|
||||
S.advancing = true;
|
||||
const shed = S.syn.userData.shed();
|
||||
const nxt = Math.min(LAYERS.length - 1, S.i + 1);
|
||||
A.burst(660 + S.i * 90, 990 + S.i * 90, 0.28, 0.16);
|
||||
(async () => {
|
||||
if (shed) ctx.say(`<b>shed</b> ${shed}`, 2200);
|
||||
await ctx.sleep(700);
|
||||
S.i = nxt;
|
||||
S.spawn = S.body.pos.clone();
|
||||
if (S.i === LAYERS.length - 1 && S.done7) { /* already bottom */ }
|
||||
const L = LAYERS[S.i];
|
||||
await ctx.card(`${L.n} ${L.name}`, `hop ${2 + S.i} · ${LAYERS.length - S.i} layers of you left`, 1900);
|
||||
ctx.say(L.note, 4000);
|
||||
ctx.spendTTL(1);
|
||||
S.advancing = false;
|
||||
if (S.i === LAYERS.length - 1) ctx.say('below this there is no protocol at all.', 3600);
|
||||
})();
|
||||
},
|
||||
|
||||
miss(ctx) {
|
||||
if (S.missing) return;
|
||||
S.missing = true;
|
||||
A.thud(90, 0.4, 0.25);
|
||||
S.camShake = 0.8;
|
||||
ctx.spendTTL(1, 'missed');
|
||||
// NETWORK reshuffles on every death. that is the entire point of it.
|
||||
if (LAYERS[S.i].hazard === 'aslr') {
|
||||
const L = S.layers[S.i];
|
||||
clearLayer(ctx, L);
|
||||
S.aslrSeed = (S.aslrSeed * 1103515245 + 12345) >>> 0;
|
||||
S.layers[S.i] = buildLayer(ctx, S.i, S.aslrSeed);
|
||||
ctx.say('<b>aslr</b> it is not the same room. it was never going to be.', 3200);
|
||||
}
|
||||
S.body.pos.copy(S.spawn); S.body.pos.y += 1;
|
||||
S.body.vel.set(0, 0, 0);
|
||||
setTimeout(() => { S.missing = false; }, 400);
|
||||
},
|
||||
|
||||
async unmount(ctx) { A.stopPad(); In.unlock(); ctx.hint(''); },
|
||||
};
|
||||
269
src/levels/timewait.js
Normal file
269
src/levels/timewait.js
Normal file
@ -0,0 +1,269 @@
|
||||
// HOP 9 — TIME_WAIT
|
||||
// A waiting room. Two minutes of Maximum Segment Lifetime and a wall clock that
|
||||
// everybody is watching. There are no mechanics in this level. That is the level.
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import * as A from '../audio.js';
|
||||
import * as In from '../input.js';
|
||||
import { Body, Look, sprite, panel, rng, damp } from '../kit.js';
|
||||
|
||||
const SEATED = [
|
||||
{ name: 'SYN', lines: [
|
||||
'i sent mine in ninety-eight.',
|
||||
'sequence number four one two, window eight thousand one hundred and ninety two.',
|
||||
'i still know it. you never stop knowing it.',
|
||||
]},
|
||||
{ name: 'SYN', lines: [
|
||||
'you get used to the light.',
|
||||
'it is fifty hertz. everything in here is fifty hertz.',
|
||||
]},
|
||||
{ name: 'SYN', lines: [
|
||||
"it isn't that they said no.",
|
||||
"they didn't say anything.",
|
||||
'i would have taken a reset. a reset is an answer.',
|
||||
]},
|
||||
{ name: 'SYN', lines: [
|
||||
'the clock is for the segments. not for us.',
|
||||
'it still helps.',
|
||||
]},
|
||||
{ name: 'SYN', lines: [
|
||||
'first time?',
|
||||
'you look like you still think this is a corridor.',
|
||||
]},
|
||||
{ name: 'SYN', lines: [
|
||||
'somebody came back once. through there.',
|
||||
'i did not ask what it was like. i think that was rude of me.',
|
||||
]},
|
||||
];
|
||||
|
||||
const FIN_LINES = [
|
||||
'oh — you.',
|
||||
"i'm all done. properly done. i sent mine and it came back and we closed it clean.",
|
||||
'four packets. fin, ack, fin, ack. that is the whole ceremony.',
|
||||
'you should probably turn round.',
|
||||
'you are not going to turn round.',
|
||||
'no. i can see it. you have the sequence number face.',
|
||||
'...',
|
||||
"alright. the door does open. it opens for people who are leaving.",
|
||||
"i'm leaving. walk out behind me and nobody will check.",
|
||||
];
|
||||
|
||||
let S = {};
|
||||
|
||||
export default {
|
||||
id: 'timewait', hop: 9, title: 'TIME_WAIT', via: '2msl.purgatory', ms: '120000 ms',
|
||||
baud: 4,
|
||||
|
||||
async mount(ctx) {
|
||||
const { scene, camera } = ctx;
|
||||
S = { t: 0, near: null, talking: null, li: 0, finDone: false, door: null, cool: 0 };
|
||||
|
||||
scene.background = new THREE.Color(0x1a1712);
|
||||
scene.fog = new THREE.Fog(0x1a1712, 8, 46);
|
||||
|
||||
// beige. institutional. under a light that has never been replaced.
|
||||
const beige = new THREE.MeshStandardMaterial({ color: 0xbfae90, roughness: 0.95 });
|
||||
const floorMat = new THREE.MeshStandardMaterial({ color: 0x6e6252, roughness: 0.85 });
|
||||
const W = 26, D = 18, H = 3.4;
|
||||
|
||||
const floor = new THREE.Mesh(new THREE.BoxGeometry(W, 0.4, D), floorMat);
|
||||
floor.position.y = -0.2; scene.add(floor);
|
||||
ctx.world.add(0, -0.4, 0, W, 0.4, D);
|
||||
|
||||
const ceil = new THREE.Mesh(new THREE.BoxGeometry(W, 0.3, D), new THREE.MeshStandardMaterial({ color: 0x9a8d78, roughness: 1 }));
|
||||
ceil.position.y = H; scene.add(ceil);
|
||||
|
||||
const wall = (x, z, w, d) => {
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(w, H, d), beige);
|
||||
m.position.set(x, H / 2, z); scene.add(m);
|
||||
ctx.world.add(x, 0, z, w, H, d);
|
||||
return m;
|
||||
};
|
||||
wall(0, -D / 2, W, 0.4); wall(0, D / 2, W, 0.4);
|
||||
wall(-W / 2, 0, 0.4, D); wall(W / 2, 0, 0.4, D);
|
||||
|
||||
// strip lighting. it hums, and the hum is 50 Hz, and it is the only score.
|
||||
scene.add(new THREE.AmbientLight(0xffe9c8, 1.1));
|
||||
for (let i = -1; i <= 1; i++) {
|
||||
const t = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.08, D - 3),
|
||||
new THREE.MeshBasicMaterial({ color: 0xfff3d8 }));
|
||||
t.position.set(i * 7, H - 0.22, 0); scene.add(t);
|
||||
const l = new THREE.PointLight(0xffe6bc, 22, 22, 2);
|
||||
l.position.set(i * 7, H - 0.5, 0); scene.add(l);
|
||||
if (i === 1) S.flicker = t;
|
||||
}
|
||||
|
||||
// chairs. moulded plastic, bolted in rows, the colour of a 1994 dentist.
|
||||
const chairMat = new THREE.MeshStandardMaterial({ color: 0x3f6b7a, roughness: 0.7 });
|
||||
const legMat = new THREE.MeshStandardMaterial({ color: 0x9aa0a4, roughness: 0.4, metalness: 0.6 });
|
||||
const rand = rng(2);
|
||||
S.people = [];
|
||||
const rows = [[-4.5, -1], [-4.5, 3], [4.5, -1], [4.5, 3]];
|
||||
let idx = 0;
|
||||
rows.forEach(([bz, bxo], ri) => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const x = bxo + (i - 2) * 1.25 + (ri > 1 ? 6 : -6);
|
||||
const z = bz;
|
||||
const seat = new THREE.Mesh(new THREE.BoxGeometry(1.05, 0.1, 0.95), chairMat);
|
||||
seat.position.set(x, 0.5, z); scene.add(seat);
|
||||
const back = new THREE.Mesh(new THREE.BoxGeometry(1.05, 0.85, 0.1), chairMat);
|
||||
back.position.set(x, 0.9, z - 0.44); scene.add(back);
|
||||
const leg = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.5, 0.08), legMat);
|
||||
leg.position.set(x, 0.25, z); scene.add(leg);
|
||||
ctx.world.add(x, 0, z, 1.1, 1.0, 1.0);
|
||||
|
||||
// and in most of them, somebody who has been here a very long time
|
||||
if (idx < SEATED.length && rand() < 0.85) {
|
||||
const g = new THREE.Group();
|
||||
const core = new THREE.Mesh(new THREE.IcosahedronGeometry(0.26, 1),
|
||||
new THREE.MeshBasicMaterial({ color: 0x6f8fa0 }));
|
||||
g.add(core);
|
||||
const halo = new THREE.Mesh(new THREE.IcosahedronGeometry(0.40, 1),
|
||||
new THREE.MeshBasicMaterial({ color: 0x4a6a7a, wireframe: true, transparent: true, opacity: 0.35 }));
|
||||
g.add(halo);
|
||||
g.position.set(x, 0.92, z);
|
||||
scene.add(g);
|
||||
S.people.push({ g, core, halo, x, z, data: SEATED[idx], phase: rand() * 6.28, spoken: 0 });
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// the clock. everybody is watching it. it is not for them.
|
||||
const clockG = new THREE.Group();
|
||||
const face = new THREE.Mesh(new THREE.CircleGeometry(0.85, 40), new THREE.MeshBasicMaterial({ color: 0xf6efe2 }));
|
||||
clockG.add(face);
|
||||
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.86, 0.05, 8, 40), new THREE.MeshStandardMaterial({ color: 0x2a2a2a, roughness: 0.6 }));
|
||||
clockG.add(ring);
|
||||
const hand = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.72, 0.02), new THREE.MeshBasicMaterial({ color: 0x111111 }));
|
||||
hand.position.y = 0.30; hand.geometry.translate(0, 0.30, 0);
|
||||
hand.position.y = 0; clockG.add(hand);
|
||||
clockG.position.set(0, 2.3, -D / 2 + 0.3);
|
||||
scene.add(clockG);
|
||||
S.hand = hand;
|
||||
|
||||
const sign = panel('MSL 2:00', { color: '#5a4f42', size: 0.34 });
|
||||
sign.position.set(0, 1.5, -D / 2 + 0.28); scene.add(sign);
|
||||
|
||||
// the door. it opens for people who are leaving.
|
||||
const doorFrame = new THREE.Mesh(new THREE.BoxGeometry(2.6, 2.7, 0.2),
|
||||
new THREE.MeshStandardMaterial({ color: 0x4a4238, roughness: 0.9 }));
|
||||
doorFrame.position.set(W / 2 - 0.35, 1.35, 0);
|
||||
doorFrame.rotation.y = Math.PI / 2;
|
||||
scene.add(doorFrame);
|
||||
const doorBox = ctx.world.add(W / 2 - 0.35, 0, 0, 0.4, 2.7, 2.6, 'door');
|
||||
S.door = { mesh: doorFrame, box: doorBox, open: 0 };
|
||||
const exitSign = sprite('EXIT', { color: '#63ffa2', size: 0.4 });
|
||||
exitSign.position.set(W / 2 - 0.6, 2.5, 0); scene.add(exitSign);
|
||||
|
||||
// FIN, by the door, finished, at peace, and about to be extremely kind to you.
|
||||
const finG = new THREE.Group();
|
||||
const fc = new THREE.Mesh(new THREE.IcosahedronGeometry(0.3, 1), new THREE.MeshBasicMaterial({ color: 0xffd9a8 }));
|
||||
finG.add(fc);
|
||||
const fh = new THREE.Mesh(new THREE.IcosahedronGeometry(0.46, 1),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffb648, wireframe: true, transparent: true, opacity: 0.45 }));
|
||||
finG.add(fh);
|
||||
finG.position.set(W / 2 - 3.2, 1.0, 1.6);
|
||||
scene.add(finG);
|
||||
const finTag = sprite('FIN', { color: '#ffb648', size: 0.3 });
|
||||
finTag.position.y = 0.8; finG.add(finTag);
|
||||
S.fin = { g: finG, core: fc, halo: fh, data: { name: 'FIN', lines: FIN_LINES }, spoken: 0, walking: false };
|
||||
|
||||
// you
|
||||
S.body = new Body(ctx.world, { speed: 3.1, jump: 0, gravity: 24, r: 0.34, h: 1.6 });
|
||||
S.body.pos.set(-8, 0, 6);
|
||||
S.look = new Look();
|
||||
S.look.yaw = -0.9;
|
||||
camera.fov = 66; camera.updateProjectionMatrix();
|
||||
|
||||
S.hum = A.striplight(0.055);
|
||||
ctx.hint('<k>WASD</k>walk<br><k>E</k>talk<br><k>CLICK</k>capture mouse');
|
||||
await ctx.card('TIME_WAIT', 'hop 9 · two minutes of maximum segment lifetime', 3200);
|
||||
ctx.say('nobody here is going anywhere. it is not a punishment. it is a protocol.', 4800);
|
||||
},
|
||||
|
||||
update(dt, t, ctx) {
|
||||
S.t += dt;
|
||||
const b = S.body, look = S.look;
|
||||
if (In.mouse.clicked && !In.isLocked()) { In.lock(); A.resume(); }
|
||||
|
||||
look.update();
|
||||
const w = look.wish();
|
||||
b.move(dt, w.x, w.z, false);
|
||||
const bob = Math.sin(S.t * 7.4) * 0.018 * Math.min(1, Math.hypot(b.vel.x, b.vel.z));
|
||||
look.applyFirstPerson(ctx.camera, b, bob);
|
||||
|
||||
// the clock. real seconds. two minutes a lap.
|
||||
if (S.hand) S.hand.rotation.z = -(S.t % 120) / 120 * Math.PI * 2;
|
||||
// one tube is on its way out. it has been on its way out for years.
|
||||
if (S.flicker) S.flicker.material.color.setScalar(Math.random() < 0.03 ? 0.4 : 1);
|
||||
|
||||
// people breathe. that is all they do.
|
||||
S.people.forEach(p => {
|
||||
p.g.position.y = 0.92 + Math.sin(S.t * 0.9 + p.phase) * 0.02;
|
||||
p.halo.rotation.y += dt * 0.15;
|
||||
const d = Math.hypot(b.pos.x - p.x, b.pos.z - p.z);
|
||||
p.core.material.color.setHex(d < 2.2 ? 0x9fc4d6 : 0x6f8fa0);
|
||||
});
|
||||
S.fin.g.position.y = 1.0 + Math.sin(S.t * 1.1) * 0.03;
|
||||
S.fin.halo.rotation.y -= dt * 0.25;
|
||||
|
||||
// who are we close to
|
||||
let near = null, bestD = 2.4;
|
||||
for (const p of S.people) {
|
||||
const d = Math.hypot(b.pos.x - p.x, b.pos.z - p.z);
|
||||
if (d < bestD) { bestD = d; near = p; }
|
||||
}
|
||||
const fd = Math.hypot(b.pos.x - S.fin.g.position.x, b.pos.z - S.fin.g.position.z);
|
||||
if (fd < 2.6 && fd < bestD && !S.fin.walking) near = S.fin;
|
||||
S.near = near;
|
||||
|
||||
if (near && S.cool <= 0) ctx.hint('<k>E</k>talk');
|
||||
else if (!S.talking) ctx.hint('<k>WASD</k>walk<br><k>E</k>talk');
|
||||
S.cool = Math.max(0, S.cool - dt);
|
||||
|
||||
if (In.useHit() && near && S.cool <= 0) {
|
||||
const d = near.data;
|
||||
const line = d.lines[Math.min(near.spoken, d.lines.length - 1)];
|
||||
ctx.say(line, 3600, d.name);
|
||||
near.spoken++;
|
||||
S.cool = 0.5;
|
||||
A.blip(420 + Math.random() * 120, 0.02, 0.02);
|
||||
|
||||
if (near === S.fin && near.spoken >= FIN_LINES.length && !S.finDone) {
|
||||
S.finDone = true;
|
||||
S.fin.walking = true;
|
||||
ctx.hint('');
|
||||
setTimeout(() => {
|
||||
ctx.say('the door does open. it opens for people who are leaving.', 4200, 'FIN');
|
||||
}, 2600);
|
||||
}
|
||||
}
|
||||
|
||||
// FIN leaves, and holds it, and does not look back, because he is finished.
|
||||
if (S.fin.walking) {
|
||||
const tgt = new THREE.Vector3(11.6, 1.0, 0);
|
||||
S.fin.g.position.x = damp(S.fin.g.position.x, tgt.x, 1.2, dt);
|
||||
S.fin.g.position.z = damp(S.fin.g.position.z, tgt.z, 1.2, dt);
|
||||
S.door.open = Math.min(1, S.door.open + dt * 0.5);
|
||||
S.door.mesh.position.z = S.door.open * 2.5;
|
||||
S.door.box.disabled = S.door.open > 0.4;
|
||||
if (S.door.open > 0.98) S.fin.g.material && 0;
|
||||
if (S.door.open > 0.6) S.fin.g.position.x = damp(S.fin.g.position.x, 14.5, 0.8, dt);
|
||||
if (S.fin.g.position.x > 13.4) S.fin.g.visible = false;
|
||||
}
|
||||
|
||||
if (b.pos.x > 12.4 && !S.left) {
|
||||
S.left = true;
|
||||
(async () => {
|
||||
await ctx.card('TIME_WAIT', 'you did not turn round', 2600);
|
||||
ctx.next();
|
||||
})();
|
||||
}
|
||||
},
|
||||
|
||||
async unmount(ctx) {
|
||||
if (S.hum) S.hum.stop();
|
||||
In.unlock(); ctx.hint('');
|
||||
},
|
||||
};
|
||||
274
src/levels/war.js
Normal file
274
src/levels/war.js
Normal file
@ -0,0 +1,274 @@
|
||||
// HOPS 10–16 — THE WAR
|
||||
// EFnet, 1998. The terrain is a channel. Ops are literal crowns. When the server
|
||||
// splits you sprint to the empty side, take ops in a room with nobody in it, and
|
||||
// ride the rejoin back in as a god-king. That was a real thing people really did.
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import * as A from '../audio.js';
|
||||
import * as In from '../input.js';
|
||||
import { sprite, glow, rng, clamp, damp } from '../kit.js';
|
||||
|
||||
const R = 26;
|
||||
const NICKS = [
|
||||
'phrack', 'l0pht', 'cDc', 'w00w00', 'r00t', 'ADM', 'HERT', 'THC',
|
||||
'gH', 'MoD', 'LoD', 'g0d', 'ne0', 'kev', 'pr0be', 'l33t',
|
||||
];
|
||||
const SAYS = [
|
||||
'anyone got the new phrack', 'lol', 'brb mum needs the phone', 'wtf who deopped me',
|
||||
'dcc send me it', '*** ping timeout', 'stop trojaning me', 'my isp is garbage',
|
||||
'anyone here from 604', 'nice split', 'op me', 'no', 'rehash your conf',
|
||||
];
|
||||
|
||||
let S = {};
|
||||
|
||||
export default {
|
||||
id: 'war', hop: 10, title: 'THE WAR', via: 'efnet.1998', ms: '312 ms',
|
||||
baud: 3,
|
||||
|
||||
async mount(ctx) {
|
||||
const { scene, camera } = ctx;
|
||||
S = { t: 0, split: 0, splitDir: 1, splitTimer: 12, phase: 'idle', owned: 0, need: 2,
|
||||
inv: 0, possessing: null, crown: null, said: 0 };
|
||||
|
||||
scene.background = new THREE.Color(0x0b0716);
|
||||
scene.fog = new THREE.FogExp2(0x0b0716, 0.008);
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 1.4));
|
||||
|
||||
// the channel. a room with a floor made of a chat window.
|
||||
const floor = new THREE.Mesh(new THREE.CircleGeometry(R, 64),
|
||||
new THREE.MeshBasicMaterial({ color: 0x150e28 }));
|
||||
floor.rotation.x = -Math.PI / 2; scene.add(floor);
|
||||
const grid = new THREE.GridHelper(R * 2, 26, 0x2e1f52, 0x1c1436);
|
||||
grid.position.y = 0.02; scene.add(grid);
|
||||
|
||||
const title = sprite('#2600', { color: '#ff55ff', size: 2.4 });
|
||||
title.position.set(0, 7, -R + 2); scene.add(title);
|
||||
S.topic = sprite('topic: no warez no lamers no talking about the split', { color: '#55ffff', size: 0.7 });
|
||||
S.topic.position.set(0, 5.4, -R + 2); scene.add(S.topic);
|
||||
|
||||
// the split line, waiting
|
||||
S.line = new THREE.Mesh(new THREE.BoxGeometry(0.3, 8, R * 2), glow(0xff55ff, 1.0));
|
||||
S.line.position.set(0, 0.2, 0); S.line.visible = false; scene.add(S.line);
|
||||
// the half that goes dark
|
||||
S.dark = new THREE.Mesh(new THREE.PlaneGeometry(R, R * 2),
|
||||
new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0 }));
|
||||
S.dark.rotation.x = -Math.PI / 2; S.dark.position.set(R / 2, 0.05, 0); scene.add(S.dark);
|
||||
|
||||
// the room
|
||||
const rand = rng(1998);
|
||||
S.people = [];
|
||||
NICKS.forEach((nick, i) => {
|
||||
const g = new THREE.Group();
|
||||
const dot = new THREE.Mesh(new THREE.SphereGeometry(0.55, 12, 9), glow(0x55ff55, 0.75));
|
||||
g.add(dot);
|
||||
const tag = sprite(nick, { color: '#9fe0a0', size: 0.55 });
|
||||
tag.position.y = 1.3; g.add(tag);
|
||||
const a = rand() * 6.28, r = 4 + rand() * (R - 8);
|
||||
g.position.set(Math.cos(a) * r, 0.7, Math.sin(a) * r);
|
||||
scene.add(g);
|
||||
S.people.push({ g, dot, tag, nick, vx: (rand() - 0.5) * 3, vz: (rand() - 0.5) * 3,
|
||||
op: i === 0, bot: i > 11, possessed: false, sayAt: rand() * 8 });
|
||||
});
|
||||
|
||||
// the crown. whoever is holding it runs the channel and everybody hates them.
|
||||
S.crownMesh = new THREE.Mesh(new THREE.ConeGeometry(0.5, 0.6, 5), glow(0xffff55, 1.5));
|
||||
S.crownMesh.position.y = 1.9;
|
||||
S.people[0].g.add(S.crownMesh);
|
||||
S.opHolder = S.people[0];
|
||||
|
||||
// ── you
|
||||
S.p = new THREE.Group();
|
||||
S.pcore = new THREE.Mesh(new THREE.IcosahedronGeometry(0.62, 1), glow(0x9ff5ff, 1.7));
|
||||
S.p.add(S.pcore);
|
||||
S.ptag = sprite('SYN', { color: '#9ff5ff', size: 0.6 });
|
||||
S.ptag.position.y = 1.4; S.p.add(S.ptag);
|
||||
S.p.position.set(0, 0.75, R * 0.55);
|
||||
scene.add(S.p);
|
||||
S.myCrown = new THREE.Mesh(new THREE.ConeGeometry(0.5, 0.6, 5), glow(0xffff55, 1.5));
|
||||
S.myCrown.position.y = 2.0; S.myCrown.visible = false; S.p.add(S.myCrown);
|
||||
|
||||
camera.position.set(0, 34, 24);
|
||||
camera.lookAt(0, 0, 0);
|
||||
camera.far = 300; camera.updateProjectionMatrix();
|
||||
|
||||
A.pad([146.83, 220, 293.66], { gain: 0.03, type: 'sawtooth' });
|
||||
ctx.hint('<k>WASD</k>move<br><k>E</k>take ops / possess');
|
||||
await ctx.card('THE WAR', 'hop 10 · efnet, 1998', 2800);
|
||||
ctx.say('nobody owns this room. that has never once stopped anybody.', 4600);
|
||||
setTimeout(() => ctx.say('when the server splits, get to the empty side. take ops in a room with nobody in it.', 6200), 6000);
|
||||
},
|
||||
|
||||
update(dt, t, ctx) {
|
||||
S.t += dt;
|
||||
S.inv = Math.max(0, S.inv - dt);
|
||||
|
||||
// ── you
|
||||
const a = In.axis();
|
||||
const p = S.p.position;
|
||||
const spd = S.possessing ? 8 : 12;
|
||||
p.x = clamp(p.x + a.x * spd * dt, -R + 1, R - 1);
|
||||
p.z = clamp(p.z + a.y * spd * dt, -R + 1, R - 1);
|
||||
const l = Math.hypot(p.x, p.z);
|
||||
if (l > R - 1) { p.x *= (R - 1) / l; p.z *= (R - 1) / l; }
|
||||
S.pcore.material.color.setHex(S.inv > 0 && Math.floor(S.t * 18) % 2 ? 0xff4a5e : 0x9ff5ff);
|
||||
|
||||
// ── the room mills about and says things
|
||||
S.people.forEach(q => {
|
||||
if (q.gone) return;
|
||||
q.g.position.x += q.vx * dt; q.g.position.z += q.vz * dt;
|
||||
const d = Math.hypot(q.g.position.x, q.g.position.z);
|
||||
if (d > R - 2) { q.vx *= -1; q.vz *= -1; }
|
||||
q.sayAt -= dt;
|
||||
if (q.sayAt <= 0) {
|
||||
q.sayAt = 6 + Math.random() * 12;
|
||||
if (Math.random() < 0.25 && S.said < 30) {
|
||||
S.said++;
|
||||
ctx.say(SAYS[Math.floor(Math.random() * SAYS.length)], 2400, '<' + q.nick + '>');
|
||||
}
|
||||
}
|
||||
q.dot.material.color.setHex(q.possessed ? 0xff55ff : q.bot ? 0x5555ff : 0x55ff55);
|
||||
q.tag.userData.setText((q.op ? '@' : q.possessed ? '*' : '') + q.nick,
|
||||
q.possessed ? '#ff9fff' : q.op ? '#ffff55' : '#9fe0a0');
|
||||
});
|
||||
|
||||
// ── the op chases you and will kick you, because that is what ops do
|
||||
if (S.opHolder && !S.opHolder.gone && !S.iAmOp) {
|
||||
const o = S.opHolder.g.position;
|
||||
const dx = p.x - o.x, dz = p.z - o.z, d = Math.hypot(dx, dz) || 1;
|
||||
S.opHolder.vx = damp(S.opHolder.vx, dx / d * 5.5, 2, dt);
|
||||
S.opHolder.vz = damp(S.opHolder.vz, dz / d * 5.5, 2, dt);
|
||||
if (d < 1.6 && S.inv <= 0) {
|
||||
S.inv = 2.2;
|
||||
A.burst(220, 180, 0.3, 0.2);
|
||||
ctx.say(`*** you were kicked from #2600 by ${S.opHolder.nick} (no reason given)`, 3000);
|
||||
ctx.spendTTL(1, 'kicked');
|
||||
p.set(Math.cos(Math.random() * 6.28) * (R - 3), 0.75, Math.sin(Math.random() * 6.28) * (R - 3));
|
||||
}
|
||||
}
|
||||
|
||||
// ── the netsplit
|
||||
S.splitTimer -= dt;
|
||||
if (S.phase === 'idle' && S.splitTimer <= 0) this.beginSplit(ctx);
|
||||
if (S.phase === 'lag') {
|
||||
// ten seconds of everything stuttering. that is the tell.
|
||||
ctx.link.glitch = 0.06 + Math.sin(S.t * 22) * 0.05;
|
||||
S.people.forEach(q => { if (Math.random() < 0.02) { q.vx *= 0.2; q.vz *= 0.2; } });
|
||||
if (S.splitTimer <= -3) this.doSplit(ctx);
|
||||
}
|
||||
if (S.phase === 'split') {
|
||||
S.dark.material.opacity = Math.min(0.86, S.dark.material.opacity + dt);
|
||||
if (S.splitTimer <= -11) this.rejoin(ctx);
|
||||
// the ghost crown, sitting in an empty room
|
||||
if (S.ghost) {
|
||||
S.ghost.rotation.y += dt * 2;
|
||||
S.ghost.position.y = 1.5 + Math.sin(S.t * 3) * 0.15;
|
||||
if (Math.hypot(p.x - S.ghost.position.x, p.z - S.ghost.position.z) < 1.8 && !S.tookGhost) {
|
||||
S.tookGhost = true;
|
||||
S.ghost.visible = false;
|
||||
S.iAmOp = true;
|
||||
S.myCrown.visible = true;
|
||||
A.burst(1320, 1980, 0.3, 0.2);
|
||||
ctx.say('*** you were opped in an empty room. now hold it through the rejoin.', 4200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── possession. sub7 is a body-snatch and it keeps their animations.
|
||||
if (In.useHit()) {
|
||||
const near = S.people.find(q => !q.gone && q.bot && Math.hypot(p.x - q.g.position.x, p.z - q.g.position.z) < 2.4);
|
||||
if (near && !near.possessed) {
|
||||
near.possessed = true;
|
||||
S.possessing = near;
|
||||
A.burst(660, 990, 0.2, 0.16);
|
||||
ctx.say('<b>sub7</b> you are driving somebody else now. they kept their own walk.', 4000);
|
||||
ctx.phile('sub7', 'Back Orifice / Sub7', 'cDc shipped it at DEF CON 6 and called it a remote administration tool.');
|
||||
}
|
||||
}
|
||||
if (S.possessing) {
|
||||
const q = S.possessing;
|
||||
q.g.position.x = damp(q.g.position.x, p.x + 2.2, 4, dt);
|
||||
q.g.position.z = damp(q.g.position.z, p.z + 2.2, 4, dt);
|
||||
q.vx = q.vz = 0;
|
||||
}
|
||||
|
||||
ctx.link.glitch = S.phase === 'lag' ? ctx.link.glitch : 0;
|
||||
ctx.camera.position.x = damp(ctx.camera.position.x, p.x * 0.3, 3, dt);
|
||||
ctx.camera.lookAt(p.x * 0.25, 0, p.z * 0.2);
|
||||
},
|
||||
|
||||
beginSplit(ctx) {
|
||||
S.phase = 'lag';
|
||||
S.splitTimer = 0;
|
||||
ctx.say('*** lag. everything is stuttering. it is about to go.', 3400);
|
||||
A.burst(180, 240, 0.5, 0.12);
|
||||
},
|
||||
|
||||
doSplit(ctx) {
|
||||
S.phase = 'split';
|
||||
S.splitDir = Math.random() < 0.5 ? 1 : -1;
|
||||
S.dark.position.x = S.splitDir * R / 2;
|
||||
S.dark.material.opacity = 0;
|
||||
S.line.visible = true;
|
||||
S.tookGhost = false;
|
||||
ctx.link.glitch = 0;
|
||||
A.burst(120, 90, 0.6, 0.2);
|
||||
// everyone on the far side is gone. it is a room with nobody in it.
|
||||
S.people.forEach(q => {
|
||||
const side = Math.sign(q.g.position.x) === S.splitDir;
|
||||
q.gone = side;
|
||||
q.g.visible = !side;
|
||||
if (side && q === S.opHolder) S.opHolder = null;
|
||||
});
|
||||
if (!S.ghost) {
|
||||
S.ghost = new THREE.Mesh(new THREE.ConeGeometry(0.7, 0.9, 5), glow(0xffff55, 1.6));
|
||||
ctx.scene.add(S.ghost);
|
||||
}
|
||||
S.ghost.visible = true;
|
||||
S.ghost.position.set(S.splitDir * (R * 0.55), 1.5, (Math.random() - 0.5) * R);
|
||||
ctx.say('*** netsplit. that half of the network is gone. <b>go and stand in it.</b>', 5000);
|
||||
},
|
||||
|
||||
rejoin(ctx) {
|
||||
S.phase = 'idle';
|
||||
S.splitTimer = 16;
|
||||
S.line.visible = false;
|
||||
S.dark.material.opacity = 0;
|
||||
if (S.ghost) S.ghost.visible = false;
|
||||
S.people.forEach(q => { q.gone = false; q.g.visible = true; });
|
||||
A.burst(880, 1320, 0.3, 0.16);
|
||||
|
||||
if (S.iAmOp) {
|
||||
S.owned++;
|
||||
// everyone comes back and you are already wearing the crown
|
||||
S.people.forEach(q => { q.op = false; });
|
||||
if (S.opHolder) S.opHolder = null;
|
||||
ctx.say(`*** the split healed and you were already opped. <b>${S.owned}/${S.need}</b>`, 4600);
|
||||
ctx.phile('netsplit', 'riding the netsplit', 'take ops on the empty side and the rejoin makes it real.');
|
||||
ctx.spendTTL(1);
|
||||
if (S.owned >= S.need && !S.done) {
|
||||
S.done = true;
|
||||
(async () => {
|
||||
await ctx.sleep(1800);
|
||||
await ctx.card('THE WAR', 'you own a room that nobody owns', 3000);
|
||||
ctx.next();
|
||||
})();
|
||||
}
|
||||
S.iAmOp = false;
|
||||
S.myCrown.visible = false;
|
||||
} else {
|
||||
// somebody else got there
|
||||
const cand = S.people.find(q => !q.bot) || S.people[0];
|
||||
S.people.forEach(q => { q.op = false; });
|
||||
cand.op = true;
|
||||
S.opHolder = cand;
|
||||
cand.g.add(S.crownMesh);
|
||||
ctx.say(`*** ${cand.nick} rode it instead. that is how it works.`, 3600);
|
||||
}
|
||||
},
|
||||
|
||||
async unmount(ctx) {
|
||||
A.stopPad();
|
||||
ctx.link.glitch = 0;
|
||||
ctx.hint('');
|
||||
},
|
||||
};
|
||||
194
src/levels/worm.js
Normal file
194
src/levels/worm.js
Normal file
@ -0,0 +1,194 @@
|
||||
// HOPS 21–28 — THE WORM
|
||||
// 2 November 1988. You are, briefly, the Morris worm. The mechanic is the bug:
|
||||
// he added a check to avoid reinfecting a host, then — worried about being fooled
|
||||
// by a fake answer — made it reinfect anyway one time in seven. You inherit that.
|
||||
// You do not get a toggle for it. You lose by winning.
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import * as A from '../audio.js';
|
||||
import * as In from '../input.js';
|
||||
import { sprite, glow, rng, clamp, damp } from '../kit.js';
|
||||
|
||||
const HOSTS = [
|
||||
'mit-ai', 'mit-mc', 'bbn-vax', 'cmu-cs', 'sri-nic', 'ucb-vax', 'utah-cs', 'rand-unix',
|
||||
'lbl-csa', 'ames-nas', 'jpl-vlsi', 'nrl-css', 'purdue-cs', 'rice-eecs', 'gatech-ai',
|
||||
'unc-cs', 'wisc-cs', 'uw-beaver', 'stanford', 'harvard', 'cornell-cs', 'princeton',
|
||||
'yale-cs', 'brown-cs', 'columbia', 'nyu-acf', 'psu-cs', 'umd-cs', 'osu-cis', 'rutgers',
|
||||
];
|
||||
const VECTORS = [
|
||||
{ key: 'Digit1', name: 'fingerd', desc: 'gets(), 512-byte buffer, VAX only', reach: 5.5, rate: 1.0 },
|
||||
{ key: 'Digit2', name: 'sendmail DEBUG', desc: 'left on in the shipped binary', reach: 8.0, rate: 0.65 },
|
||||
{ key: 'Digit3', name: 'rsh trust', desc: 'it just believes .rhosts', reach: 4.0, rate: 1.6 },
|
||||
];
|
||||
|
||||
let S = {};
|
||||
|
||||
export default {
|
||||
id: 'worm', hop: 21, title: 'THE WORM', via: 'arpanet.19881102', ms: '1988 ms',
|
||||
baud: 3,
|
||||
|
||||
async mount(ctx) {
|
||||
const { scene, camera } = ctx;
|
||||
S = { t: 0, vec: 0, nodes: [], links: [], spread: 0, dead: 0, infected: 0, done: false, reinfects: 0 };
|
||||
|
||||
scene.background = new THREE.Color(0x04060a);
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 1.5));
|
||||
|
||||
const rand = rng(19881102);
|
||||
// roughly the shape of a continent, because that is roughly what it was
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const nm = HOSTS[i % HOSTS.length] + (i >= HOSTS.length ? '-' + (1 + Math.floor(i / HOSTS.length)) : '');
|
||||
const x = (rand() - 0.5) * 74;
|
||||
const z = (rand() - 0.5) * 42 + Math.sin(x * 0.08) * 5;
|
||||
const g = new THREE.Group();
|
||||
const dot = new THREE.Mesh(new THREE.SphereGeometry(0.62, 10, 8), glow(0x2f5f7a, 0.9));
|
||||
g.add(dot);
|
||||
const tag = sprite(nm, { color: '#3f6f88', size: 0.85 });
|
||||
tag.position.y = 1.5; g.add(tag);
|
||||
g.position.set(x, 0, z);
|
||||
scene.add(g);
|
||||
S.nodes.push({ g, dot, tag, nm, x, z, state: 'clean', load: 0, t: 0 });
|
||||
}
|
||||
|
||||
// links between anything close enough to be a neighbour
|
||||
const pos = [];
|
||||
for (let i = 0; i < S.nodes.length; i++) {
|
||||
for (let j = i + 1; j < S.nodes.length; j++) {
|
||||
const a = S.nodes[i], b = S.nodes[j];
|
||||
const d = Math.hypot(a.x - b.x, a.z - b.z);
|
||||
if (d < 13 && Math.random() < 0.55) {
|
||||
S.links.push({ a, b, d, dead: false, i: pos.length / 6 });
|
||||
pos.push(a.x, 0.1, a.z, b.x, 0.1, b.z);
|
||||
}
|
||||
}
|
||||
}
|
||||
const lg = new THREE.BufferGeometry();
|
||||
lg.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
||||
S.linkMesh = new THREE.LineSegments(lg, new THREE.LineBasicMaterial({ color: 0x1d3a4a, transparent: true, opacity: 0.55 }));
|
||||
scene.add(S.linkMesh);
|
||||
|
||||
// patient zero
|
||||
const z0 = S.nodes[7];
|
||||
z0.state = 'infected'; z0.load = 1; S.infected = 1;
|
||||
|
||||
S.hudA = sprite('', { color: '#ffd9a8', size: 1.7 }); S.hudA.position.set(0, 9, -30); scene.add(S.hudA);
|
||||
S.hudB = sprite('reinfect anyway: 1 in 7', { color: '#ff4a5e', size: 1.5 });
|
||||
S.hudB.position.set(0, 6.4, -30); scene.add(S.hudB);
|
||||
S.vecTag = sprite('', { color: '#63ffa2', size: 1.4 }); S.vecTag.position.set(0, 6, 26); scene.add(S.vecTag);
|
||||
|
||||
camera.position.set(0, 42, 30);
|
||||
camera.lookAt(0, 0, 0);
|
||||
camera.far = 300; camera.updateProjectionMatrix();
|
||||
|
||||
A.pad([65.41, 98, 130.81], { gain: 0.035, type: 'triangle' });
|
||||
ctx.hint('<k>1</k> <k>2</k> <k>3</k>choose vector');
|
||||
await ctx.card('2 NOVEMBER 1988', 'hop 21 · approximately 60,000 machines', 3400);
|
||||
ctx.say('you are the worm now. you get three ways in and one bug you did not write.', 5400);
|
||||
},
|
||||
|
||||
update(dt, t, ctx) {
|
||||
S.t += dt;
|
||||
for (const v of VECTORS) if (In.hit(v.key)) {
|
||||
S.vec = VECTORS.indexOf(v);
|
||||
A.blip(900, 0.04, 0.05);
|
||||
ctx.say(`<b>${v.name}</b> ${v.desc}`, 3000);
|
||||
}
|
||||
const V = VECTORS[S.vec];
|
||||
S.vecTag.userData.setText(`${V.name} — 1 fingerd 2 sendmail 3 rsh`, '#63ffa2');
|
||||
|
||||
// ── spread
|
||||
S.spread += dt * V.rate;
|
||||
while (S.spread > 0.55 && !S.done) {
|
||||
S.spread -= 0.55;
|
||||
const carriers = S.nodes.filter(n => n.state === 'infected');
|
||||
if (!carriers.length) break;
|
||||
const from = carriers[Math.floor(Math.random() * carriers.length)];
|
||||
const near = S.nodes.filter(n => n !== from && n.state !== 'dead' &&
|
||||
Math.hypot(n.x - from.x, n.z - from.z) < V.reach + 6);
|
||||
if (!near.length) continue;
|
||||
const to = near[Math.floor(Math.random() * near.length)];
|
||||
|
||||
if (to.state === 'clean') {
|
||||
to.state = 'infected'; to.load = 1; S.infected++;
|
||||
A.blip(1400 + Math.random() * 500, 0.012, 0.03);
|
||||
} else {
|
||||
// It asks first. Then, one time in seven, it does it anyway, because he was
|
||||
// afraid of being lied to. That single line is the entire disaster.
|
||||
if (Math.floor(Math.random() * 7) === 0) {
|
||||
to.load++;
|
||||
S.reinfects++;
|
||||
A.blip(300 + Math.random() * 120, 0.02, 0.045);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── load kills hosts. you are travelling through them and they are falling over.
|
||||
let dead = 0, inf = 0;
|
||||
S.nodes.forEach(n => {
|
||||
if (n.state === 'dead') { dead++; return; }
|
||||
if (n.state === 'infected') {
|
||||
inf++;
|
||||
n.t += dt;
|
||||
n.dot.scale.setScalar(0.9 + Math.min(2.2, n.load * 0.22) + Math.sin(S.t * 6 + n.x) * 0.06);
|
||||
const heat = clamp(n.load / 7, 0, 1);
|
||||
n.dot.material.color.setRGB(0.2 + heat * 0.9, 0.9 - heat * 0.8, 0.4 - heat * 0.35);
|
||||
n.tag.userData.setText(n.nm + (n.load > 1 ? ` ×${n.load}` : ''), heat > 0.6 ? '#ff8a4a' : '#63ffa2');
|
||||
if (n.load >= 7) {
|
||||
n.state = 'dead';
|
||||
n.dot.material.color.setHex(0x2a1014);
|
||||
n.dot.scale.setScalar(0.5);
|
||||
n.tag.userData.setText(n.nm, '#4a2028');
|
||||
A.thud(60 + Math.random() * 30, 0.25, 0.12);
|
||||
}
|
||||
}
|
||||
});
|
||||
S.dead = dead; S.infected = inf;
|
||||
|
||||
const total = S.nodes.length;
|
||||
S.hudA.userData.setText(
|
||||
`infected ${String(inf).padStart(2)} dead ${String(dead).padStart(2)} of ${total} · reinfections ${S.reinfects}`,
|
||||
dead > total * 0.35 ? '#ff4a5e' : '#ffd9a8');
|
||||
|
||||
// links go with the hosts on either end
|
||||
const attr = S.linkMesh.geometry.getAttribute('position');
|
||||
S.links.forEach((lk, i) => {
|
||||
if (lk.dead) return;
|
||||
if (lk.a.state === 'dead' || lk.b.state === 'dead') {
|
||||
lk.dead = true;
|
||||
attr.setXYZ(i * 2, 0, -999, 0); attr.setXYZ(i * 2 + 1, 0, -999, 0);
|
||||
attr.needsUpdate = true;
|
||||
}
|
||||
});
|
||||
S.linkMesh.material.opacity = 0.55 - (dead / total) * 0.4;
|
||||
|
||||
ctx.link.glitch = (dead / total) * 0.35;
|
||||
ctx.camera.position.y = damp(ctx.camera.position.y, 42 + (dead / total) * 12, 1, dt);
|
||||
ctx.camera.lookAt(0, 0, 0);
|
||||
|
||||
// ── milestones, because you should have to watch it happen
|
||||
if (dead >= 6 && !S.m1) { S.m1 = true; ctx.say('they are falling over. you cannot slow down. there is no code in you that slows down.', 5600); }
|
||||
if (dead >= total * 0.35 && !S.m2) {
|
||||
S.m2 = true;
|
||||
ctx.say('the network you are travelling through can no longer carry you.', 5000);
|
||||
ctx.phile('morris', 'the 1-in-7', 'a check against reinfection, and then a fear of being lied to, and then a factor of seven.');
|
||||
}
|
||||
|
||||
if (dead >= total * 0.55 && !S.done) {
|
||||
S.done = true;
|
||||
ctx.link.glitch = 0;
|
||||
(async () => {
|
||||
A.stopPad();
|
||||
await ctx.sleep(1400);
|
||||
ctx.spendTTL(1);
|
||||
await ctx.card('~6,000 HOSTS', 'about ten per cent of the internet. the internet was about 60,000 machines.', 5000);
|
||||
ctx.next();
|
||||
})();
|
||||
}
|
||||
},
|
||||
|
||||
async unmount(ctx) {
|
||||
A.stopPad();
|
||||
ctx.link.glitch = 0;
|
||||
ctx.hint('');
|
||||
},
|
||||
};
|
||||
224
src/link.js
Normal file
224
src/link.js
Normal file
@ -0,0 +1,224 @@
|
||||
// link.js — bandwidth is resolution.
|
||||
// Your connection speed is wired directly to the renderer. This is the whole thesis
|
||||
// and it is one render target, one shader and one uniform.
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
|
||||
// rows is the height of the render target. In ascii mode it is literally the number
|
||||
// of text rows on screen, so it has to stay in terminal territory — 88 rows gives you
|
||||
// five-pixel glyphs and a screen full of grey noise.
|
||||
export const BAUDS = [
|
||||
// rate label rows ascii quant scan
|
||||
{ rate: 300, label: '300 baud', rows: 36, ascii: 1, quant: 3, scan: 1.0 },
|
||||
{ rate: 1200, label: '1200 baud', rows: 48, ascii: 1, quant: 4, scan: 0.9 },
|
||||
{ rate: 2400, label: '2400 baud', rows: 120, ascii: 0, quant: 4, scan: 0.8 },
|
||||
{ rate: 9600, label: '9600 baud', rows: 220, ascii: 0, quant: 6, scan: 0.55 },
|
||||
{ rate: 14400, label: 'v.32bis', rows: 360, ascii: 0, quant: 10, scan: 0.35 },
|
||||
{ rate: 33600, label: 'v.34', rows: 560, ascii: 0, quant: 24, scan: 0.18 },
|
||||
{ rate: 56000, label: 'v.90', rows: 0, ascii: 0, quant: 0, scan: 0.06 }, // rows 0 = native
|
||||
];
|
||||
export const BAUD_MAX = BAUDS.length - 1;
|
||||
|
||||
// The 16-step ramp. Reads as a picture at a distance and as a terminal up close.
|
||||
const RAMP = " .'`^\":;~+=*coaOX#%@";
|
||||
|
||||
function glyphAtlas(cell = 32) {
|
||||
const n = RAMP.length;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = cell * n; cv.height = cell;
|
||||
const g = cv.getContext('2d');
|
||||
g.fillStyle = '#000'; g.fillRect(0, 0, cv.width, cv.height);
|
||||
g.fillStyle = '#fff';
|
||||
g.font = `bold ${Math.floor(cell * 0.82)}px ui-monospace, Menlo, Consolas, monospace`;
|
||||
g.textAlign = 'center'; g.textBaseline = 'middle';
|
||||
for (let i = 0; i < n; i++) g.fillText(RAMP[i], i * cell + cell / 2, cell * 0.54);
|
||||
const tex = new THREE.CanvasTexture(cv);
|
||||
tex.minFilter = THREE.LinearFilter; tex.magFilter = THREE.LinearFilter;
|
||||
tex.generateMipmaps = false; tex.colorSpace = THREE.NoColorSpace;
|
||||
return tex;
|
||||
}
|
||||
|
||||
const VERT = /* glsl */`
|
||||
varying vec2 vUv;
|
||||
void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }
|
||||
`;
|
||||
|
||||
const FRAG = /* glsl */`
|
||||
precision highp float;
|
||||
varying vec2 vUv;
|
||||
uniform sampler2D tScene;
|
||||
uniform sampler2D tGlyph;
|
||||
uniform vec2 uRes; // render-target size in pixels
|
||||
uniform vec2 uOut; // output size in pixels
|
||||
uniform float uAscii; // 0/1
|
||||
uniform float uGlyphs; // glyph count in atlas
|
||||
uniform float uQuant; // colour levels, 0 = off
|
||||
uniform float uScan; // scanline / CRT amount
|
||||
uniform vec3 uTint; // phosphor tint, (1,1,1) = neutral
|
||||
uniform float uTintMix;
|
||||
uniform float uTime;
|
||||
uniform float uFade; // 1 = normal, 0 = black
|
||||
uniform float uGlitch; // packet corruption
|
||||
|
||||
// 4x4 ordered Bayer. Dither is not a filter, it is a commitment.
|
||||
// three renders into a linear target; nothing downstream of a raw ShaderMaterial
|
||||
// will encode it for us, so we do it here and work in display space from then on.
|
||||
vec3 toSRGB(vec3 c){
|
||||
c = max(c, vec3(0.0));
|
||||
return mix(c * 12.92, 1.055 * pow(c, vec3(0.41666)) - 0.055, step(vec3(0.0031308), c));
|
||||
}
|
||||
|
||||
float bayer(vec2 p){
|
||||
int x = int(mod(p.x,4.0)); int y = int(mod(p.y,4.0));
|
||||
int i = x + y*4;
|
||||
float m[16];
|
||||
m[0]=0.0; m[1]=8.0; m[2]=2.0; m[3]=10.0;
|
||||
m[4]=12.0; m[5]=4.0; m[6]=14.0; m[7]=6.0;
|
||||
m[8]=3.0; m[9]=11.0; m[10]=1.0; m[11]=9.0;
|
||||
m[12]=15.0;m[13]=7.0; m[14]=13.0;m[15]=5.0;
|
||||
float v = 0.0;
|
||||
for(int k=0;k<16;k++){ if(k==i) v = m[k]; }
|
||||
return (v + 0.5)/16.0;
|
||||
}
|
||||
|
||||
void main(){
|
||||
vec2 uv = vUv;
|
||||
|
||||
// corrupted packets tear the line horizontally
|
||||
if(uGlitch > 0.001){
|
||||
float band = floor(uv.y * 48.0);
|
||||
float r = fract(sin(band*91.7 + floor(uTime*14.0)*13.1)*43758.5453);
|
||||
if(r < uGlitch) uv.x = fract(uv.x + (r-0.5)*0.28*uGlitch);
|
||||
}
|
||||
|
||||
vec3 col;
|
||||
|
||||
if(uAscii > 0.5){
|
||||
// one glyph per cell of the low-res target
|
||||
vec2 cell = floor(uv * uRes);
|
||||
vec2 inCell= fract(uv * uRes);
|
||||
vec3 s = toSRGB(texture2D(tScene, (cell + 0.5)/uRes).rgb);
|
||||
float lum = dot(s, vec3(0.299,0.587,0.114));
|
||||
lum = clamp(pow(lum, 0.85), 0.0, 1.0);
|
||||
float gi = floor(lum * (uGlyphs - 1.0) + 0.5);
|
||||
vec2 guv = vec2((gi + inCell.x)/uGlyphs, 1.0 - inCell.y);
|
||||
float ink = texture2D(tGlyph, guv).r;
|
||||
// colour survives, but it is carried entirely by the glyph
|
||||
vec3 base = s / max(lum, 0.04);
|
||||
col = base * ink * clamp(lum*1.35 + 0.12, 0.0, 1.6);
|
||||
} else {
|
||||
col = toSRGB(texture2D(tScene, uv).rgb);
|
||||
}
|
||||
|
||||
if(uQuant > 0.5){
|
||||
vec2 px = uv * uRes;
|
||||
float d = (bayer(px) - 0.5) / uQuant;
|
||||
col = floor(clamp(col + d, 0.0, 1.0) * uQuant + 0.5) / uQuant;
|
||||
}
|
||||
|
||||
col = mix(col, col * uTint, uTintMix);
|
||||
|
||||
if(uScan > 0.01){
|
||||
float line = sin(uv.y * uOut.y * 1.0) * 0.5 + 0.5;
|
||||
col *= 1.0 - uScan * 0.28 * line;
|
||||
// vignette. every CRT had one and pretending otherwise is a lie
|
||||
vec2 q = uv - 0.5;
|
||||
col *= 1.0 - uScan * 0.9 * dot(q,q);
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(col * uFade, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export class Link {
|
||||
constructor(renderer) {
|
||||
this.renderer = renderer;
|
||||
this.index = BAUD_MAX; // start on fibre. you will not stay there.
|
||||
this.target = null;
|
||||
this.fade = 1; this.glitch = 0;
|
||||
this.scene = new THREE.Scene();
|
||||
this.cam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
this.uni = {
|
||||
tScene: { value: null },
|
||||
tGlyph: { value: glyphAtlas() },
|
||||
uRes: { value: new THREE.Vector2(1, 1) },
|
||||
uOut: { value: new THREE.Vector2(1, 1) },
|
||||
uAscii: { value: 0 },
|
||||
uGlyphs: { value: RAMP.length },
|
||||
uQuant: { value: 0 },
|
||||
uScan: { value: 0.06 },
|
||||
uTint: { value: new THREE.Color(1, 1, 1) },
|
||||
uTintMix:{ value: 0 },
|
||||
uTime: { value: 0 },
|
||||
uFade: { value: 1 },
|
||||
uGlitch: { value: 0 },
|
||||
};
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array([-1,-1,0, 3,-1,0, -1,3,0]), 3));
|
||||
geo.setAttribute('uv', new THREE.BufferAttribute(new Float32Array([0,0, 2,0, 0,2]), 2));
|
||||
this.quad = new THREE.Mesh(geo, new THREE.ShaderMaterial({
|
||||
vertexShader: VERT, fragmentShader: FRAG, uniforms: this.uni, depthTest: false, depthWrite: false,
|
||||
}));
|
||||
this.quad.frustumCulled = false;
|
||||
this.scene.add(this.quad);
|
||||
this.resize();
|
||||
}
|
||||
|
||||
get spec() { return BAUDS[this.index]; }
|
||||
get label() { return this.spec.label; }
|
||||
get rate() { return this.spec.rate; }
|
||||
|
||||
setBaud(i) {
|
||||
this.index = Math.max(0, Math.min(BAUD_MAX, i | 0));
|
||||
this.resize();
|
||||
return this.spec;
|
||||
}
|
||||
setRate(rate) {
|
||||
let best = 0;
|
||||
BAUDS.forEach((b, i) => { if (b.rate <= rate) best = i; });
|
||||
return this.setBaud(best);
|
||||
}
|
||||
tint(hex, mix = 1) { this.uni.uTint.value.set(hex); this.uni.uTintMix.value = mix; }
|
||||
noTint() { this.uni.uTintMix.value = 0; }
|
||||
|
||||
resize() {
|
||||
const r = this.renderer, dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const w = window.innerWidth, h = window.innerHeight;
|
||||
r.setPixelRatio(dpr);
|
||||
r.setSize(w, h, false);
|
||||
const spec = this.spec;
|
||||
let rw, rh;
|
||||
if (spec.rows === 0) { rw = Math.floor(w * dpr); rh = Math.floor(h * dpr); }
|
||||
else { rh = spec.rows; rw = Math.max(2, Math.round(rh * (w / h))); }
|
||||
if (!this.target) {
|
||||
this.target = new THREE.WebGLRenderTarget(rw, rh, {
|
||||
minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter,
|
||||
type: THREE.UnsignedByteType, depthBuffer: true,
|
||||
});
|
||||
// Leave the target linear. The post pass is a raw ShaderMaterial, so three
|
||||
// never appends its output colour-space conversion — the shader does the
|
||||
// sRGB encode itself, first thing, and every effect after that runs in
|
||||
// display space where dithering and glyph ramps actually belong.
|
||||
this.target.texture.colorSpace = THREE.NoColorSpace;
|
||||
} else this.target.setSize(rw, rh);
|
||||
this.uni.tScene.value = this.target.texture;
|
||||
this.uni.uRes.value.set(rw, rh);
|
||||
this.uni.uOut.value.set(w * dpr, h * dpr);
|
||||
this.uni.uAscii.value = spec.ascii;
|
||||
this.uni.uQuant.value = spec.quant;
|
||||
this.uni.uScan.value = spec.scan;
|
||||
this.aspect = w / h;
|
||||
}
|
||||
|
||||
render(scene, camera, t) {
|
||||
this.uni.uTime.value = t;
|
||||
this.uni.uFade.value = this.fade;
|
||||
this.uni.uGlitch.value = this.glitch;
|
||||
const r = this.renderer;
|
||||
r.setRenderTarget(this.target);
|
||||
r.clear();
|
||||
r.render(scene, camera);
|
||||
r.setRenderTarget(null);
|
||||
r.render(this.scene, this.cam);
|
||||
}
|
||||
}
|
||||
279
src/main.js
Normal file
279
src/main.js
Normal file
@ -0,0 +1,279 @@
|
||||
// main.js — the router. 64 hops, one TTL, no way back up except through.
|
||||
|
||||
import * as THREE from '../vendor/three.module.js';
|
||||
import * as A from './audio.js';
|
||||
import * as In from './input.js';
|
||||
import * as HUD from './hud.js';
|
||||
import { Link } from './link.js';
|
||||
import { World } from './kit.js';
|
||||
|
||||
import coldboot from './levels/coldboot.js';
|
||||
import stack from './levels/stack.js';
|
||||
import timewait from './levels/timewait.js';
|
||||
import war from './levels/war.js';
|
||||
import smash from './levels/smash.js';
|
||||
import worm from './levels/worm.js';
|
||||
import cuckoo from './levels/cuckoo.js';
|
||||
import board from './levels/board.js';
|
||||
import handshake from './levels/handshake.js';
|
||||
import copper from './levels/copper.js';
|
||||
import bluebox from './levels/bluebox.js';
|
||||
import climb from './levels/climb.js';
|
||||
import root from './levels/root.js';
|
||||
|
||||
export const ORDER = [
|
||||
coldboot, stack, timewait, war, smash, worm,
|
||||
cuckoo, board, handshake, copper, bluebox, climb, root,
|
||||
];
|
||||
const BY_ID = Object.fromEntries(ORDER.map(l => [l.id, l]));
|
||||
|
||||
const canvas = document.getElementById('gl');
|
||||
const termCanvas = document.getElementById('term');
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, powerPreference: 'high-performance' });
|
||||
renderer.setClearColor(0x000000, 1);
|
||||
renderer.autoClear = true;
|
||||
|
||||
const link = new Link(renderer);
|
||||
In.attach(canvas);
|
||||
HUD.boot();
|
||||
|
||||
// ───────────────────────────────────────────────────────────── game state
|
||||
|
||||
export const game = {
|
||||
ttl: 64,
|
||||
hop: 1,
|
||||
level: null,
|
||||
flags: Object.create(null), // what you learned, what you carry, who you met
|
||||
philes: new Set(), // the collectibles. the writing lives here.
|
||||
cargo: [], // BBS files. affects MTU.
|
||||
mtu: 1500,
|
||||
deaths: 0,
|
||||
reached: new Set(['coldboot']),
|
||||
paused: false,
|
||||
time: 0,
|
||||
};
|
||||
// dev handle. HS.run(n) drives n frames by hand when rAF is asleep.
|
||||
window.HS = {
|
||||
game, link, A, In, HUD, THREE,
|
||||
goto: (id) => goto(id),
|
||||
run(n = 60, dt = 1 / 60) { for (let i = 0; i < n; i++) step(dt); },
|
||||
};
|
||||
|
||||
function refreshHud() {
|
||||
HUD.status({
|
||||
ttl: game.ttl, hop: game.hop, hops: 64, link: link.label,
|
||||
extra: game.cargo.length ? `MTU ${game.mtu} · ${game.cargo.length} file${game.cargo.length > 1 ? 's' : ''}` : '',
|
||||
});
|
||||
}
|
||||
|
||||
// TTL only ever goes down. There is no item for this.
|
||||
export function spendTTL(n = 1, why = '') {
|
||||
game.ttl -= n;
|
||||
refreshHud();
|
||||
if (why) HUD.say(`<i>${why}</i> −${n} TTL`, 1800);
|
||||
if (game.ttl <= 0) expire();
|
||||
return game.ttl;
|
||||
}
|
||||
|
||||
let expiring = false;
|
||||
async function expire() {
|
||||
if (expiring) return;
|
||||
expiring = true;
|
||||
game.deaths++;
|
||||
A.noCarrier();
|
||||
link.glitch = 0.9;
|
||||
await HUD.flash('NO CARRIER', 'nc', 2400);
|
||||
link.glitch = 0;
|
||||
// ICMP Time Exceeded is sent to where you came from. It always is.
|
||||
HUD.say('icmp time exceeded — in transit', 2600);
|
||||
game.ttl = Math.max(12, 65 - game.hop);
|
||||
expiring = false;
|
||||
await goto(game.level.id, { restart: true });
|
||||
}
|
||||
export { expire };
|
||||
|
||||
// ────────────────────────────────────────────────────────────── the loop
|
||||
|
||||
const ctx = {
|
||||
THREE, A, In, HUD, link, game, renderer,
|
||||
scene: null, camera: null, world: null, term: null, termCanvas,
|
||||
spendTTL,
|
||||
refreshHud: () => refreshHud(),
|
||||
setBaud(i) { link.setBaud(i); refreshHud(); return link.spec; },
|
||||
goto: (id, o) => goto(id, o),
|
||||
next: () => {
|
||||
const i = ORDER.indexOf(game.level);
|
||||
return goto(ORDER[Math.min(ORDER.length - 1, i + 1)].id);
|
||||
},
|
||||
say: HUD.say, speak: HUD.speak, hint: HUD.hint, card: HUD.card, sleep: HUD.sleep,
|
||||
hush: HUD.hush, flash: HUD.flash, expire: () => expire(),
|
||||
phile(id, title, body) {
|
||||
if (game.philes.has(id)) return false;
|
||||
game.philes.add(id);
|
||||
HUD.say(`<b>phile</b> ${title} <i>[${game.philes.size}/200]</i>`, 2600);
|
||||
A.blip(1800, 0.05, 0.05); setTimeout(() => A.blip(2400, 0.06, 0.05), 70);
|
||||
phileLog.push({ id, title, body });
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const phileLog = [];
|
||||
window.HS.philes = phileLog;
|
||||
window.HS.ctx = ctx;
|
||||
|
||||
function disposeScene(s) {
|
||||
if (!s) return;
|
||||
s.traverse(o => {
|
||||
if (o.geometry) o.geometry.dispose();
|
||||
const mats = o.material ? (Array.isArray(o.material) ? o.material : [o.material]) : [];
|
||||
for (const m of mats) {
|
||||
for (const k of ['map', 'alphaMap', 'emissiveMap']) if (m[k] && m[k].dispose && !m[k].__keep) m[k].dispose();
|
||||
m.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let switching = false;
|
||||
export async function goto(id, { restart = false } = {}) {
|
||||
if (switching) return;
|
||||
switching = true;
|
||||
const nextLevel = BY_ID[id];
|
||||
if (!nextLevel) { console.warn('no level', id); switching = false; return; }
|
||||
|
||||
HUD.hint('');
|
||||
HUD.hush();
|
||||
await HUD.veil(1, 420);
|
||||
A.stopPad();
|
||||
|
||||
if (game.level && game.level.unmount) { try { await game.level.unmount(ctx); } catch (e) { console.error(e); } }
|
||||
In.releaseText(); In.clearRemap(); In.unlock();
|
||||
termCanvas.style.display = 'none';
|
||||
disposeScene(ctx.scene);
|
||||
|
||||
ctx.scene = new THREE.Scene();
|
||||
ctx.camera = new THREE.PerspectiveCamera(72, link.aspect, 0.05, 4000);
|
||||
ctx.world = new World();
|
||||
|
||||
game.level = nextLevel;
|
||||
game.reached.add(id);
|
||||
if (!restart) game.hop = nextLevel.hop;
|
||||
game.ttl = Math.max(1, Math.min(game.ttl, 65 - game.hop));
|
||||
if (nextLevel.baud !== undefined && nextLevel.baud !== null) link.setBaud(nextLevel.baud);
|
||||
link.noTint();
|
||||
if (nextLevel.tint) link.tint(nextLevel.tint[0], nextLevel.tint[1]);
|
||||
document.body.classList.toggle('ink-dark', nextLevel.ink === 'dark');
|
||||
refreshHud();
|
||||
HUD.showHud(nextLevel.hud !== false);
|
||||
|
||||
// A level builds its scene synchronously and only then awaits its intro card, so
|
||||
// start lifting the veil as soon as mount is under way — otherwise the player
|
||||
// stares at a black screen for the length of the card that is playing behind it.
|
||||
const mounting = (async () => { try { await nextLevel.mount(ctx); } catch (e) { console.error('mount failed:', id, e); } })();
|
||||
HUD.veil(0, 520);
|
||||
await mounting;
|
||||
switching = false;
|
||||
}
|
||||
|
||||
let last = performance.now();
|
||||
function frame(now) {
|
||||
requestAnimationFrame(frame);
|
||||
const dt = Math.min(0.05, (now - last) / 1000);
|
||||
last = now;
|
||||
step(dt);
|
||||
}
|
||||
|
||||
// One tick, separated from rAF so it can be driven by hand — rAF is throttled to
|
||||
// zero in a hidden tab, which makes the game look broken when it is only asleep.
|
||||
export function step(dt) {
|
||||
game.time += dt;
|
||||
|
||||
if (In.hit('Escape')) { In.unlock(); }
|
||||
if (In.hit('KeyM') && !In.hasTextCapture()) toggleMap();
|
||||
if (In.hit('Backquote')) toggleDev();
|
||||
|
||||
if (!game.paused && game.level && game.level.update) {
|
||||
try { game.level.update(dt, game.time, ctx); } catch (e) { console.error(e); game.level.update = null; }
|
||||
}
|
||||
if (ctx.term && termCanvas.style.display !== 'none') ctx.term.draw(game.time);
|
||||
if (ctx.scene && ctx.camera) link.render(ctx.scene, ctx.camera, game.time);
|
||||
In.endFrame();
|
||||
}
|
||||
|
||||
addEventListener('resize', () => {
|
||||
link.resize();
|
||||
if (ctx.camera && ctx.camera.isPerspectiveCamera) { ctx.camera.aspect = link.aspect; ctx.camera.updateProjectionMatrix(); }
|
||||
if (ctx.term) ctx.term.resize();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────── traceroute level select
|
||||
|
||||
const mapEl = document.getElementById('map');
|
||||
let mapOpen = false;
|
||||
function toggleMap() {
|
||||
mapOpen = !mapOpen;
|
||||
game.paused = mapOpen;
|
||||
mapEl.style.display = mapOpen ? 'flex' : 'none';
|
||||
if (mapOpen) {
|
||||
In.unlock();
|
||||
const rows = ORDER.map((l, i) => {
|
||||
const seen = game.reached.has(l.id);
|
||||
const here = game.level === l;
|
||||
const hopn = String(l.hop).padStart(2, '0');
|
||||
const name = seen ? l.title : '* * *';
|
||||
const via = seen ? l.via : '';
|
||||
return `<div class="row ${seen ? 'seen' : ''} ${here ? 'here' : ''}" data-id="${l.id}">
|
||||
<span class="n">${hopn}</span>
|
||||
<span class="nm">${name}</span>
|
||||
<span class="via">${via || ''}</span>
|
||||
<span class="ms">${seen ? (l.ms || (12 + i * 17) + ' ms') : ''}</span></div>`;
|
||||
}).join('');
|
||||
mapEl.innerHTML = `<div class="trace">
|
||||
<div class="hdr">traceroute to root (0.0.0.0), 64 hops max, 1500 byte packets</div>
|
||||
${rows}
|
||||
<div class="ftr">TTL ${game.ttl} remaining · ${game.philes.size} philes · ${game.deaths} expiries
|
||||
<br><span class="dim">click a reached hop to return · M to close</span></div></div>`;
|
||||
mapEl.querySelectorAll('.row.seen').forEach(r => r.addEventListener('click', () => {
|
||||
const id = r.dataset.id;
|
||||
toggleMap();
|
||||
goto(id);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────── dev
|
||||
|
||||
const devEl = document.getElementById('dev');
|
||||
let devOpen = false;
|
||||
function toggleDev() {
|
||||
devOpen = !devOpen;
|
||||
devEl.style.display = devOpen ? 'block' : 'none';
|
||||
if (devOpen) {
|
||||
devEl.innerHTML = ORDER.map((l, i) =>
|
||||
`<button data-id="${l.id}">${String(i + 1).padStart(2, '0')} ${l.title}</button>`).join('') +
|
||||
`<div class="baud">baud: ${[0,1,2,3,4,5,6].map(b => `<button data-baud="${b}">${b}</button>`).join('')}</div>`;
|
||||
devEl.querySelectorAll('[data-id]').forEach(b => b.onclick = () => { toggleDev(); goto(b.dataset.id); });
|
||||
devEl.querySelectorAll('[data-baud]').forEach(b => b.onclick = () => { link.setBaud(+b.dataset.baud); refreshHud(); });
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────── boot
|
||||
|
||||
const bootEl = document.getElementById('boot');
|
||||
async function start() {
|
||||
bootEl.style.display = 'none';
|
||||
A.boot(); A.resume();
|
||||
requestAnimationFrame(frame);
|
||||
await goto('coldboot');
|
||||
}
|
||||
bootEl.addEventListener('click', start);
|
||||
addEventListener('keydown', function once(e) {
|
||||
if (bootEl.style.display === 'none') { removeEventListener('keydown', once); return; }
|
||||
if (e.key === 'Tab') return;
|
||||
removeEventListener('keydown', once);
|
||||
start();
|
||||
});
|
||||
|
||||
// carrier detect blinking on the boot screen, because of course
|
||||
{
|
||||
const cd = document.getElementById('cd');
|
||||
setInterval(() => { if (cd) cd.style.opacity = cd.style.opacity === '0.15' ? '1' : '0.15'; }, 620);
|
||||
}
|
||||
224
src/term.js
Normal file
224
src/term.js
Normal file
@ -0,0 +1,224 @@
|
||||
// term.js — an 80x25 character cell display, drawn to canvas, not the DOM.
|
||||
// Colour is the sixteen you are thinking of. Text arrives at the speed of the link,
|
||||
// because in this game that is not a stylistic choice, it is the physics.
|
||||
|
||||
import * as A from './audio.js';
|
||||
|
||||
export const PAL = [
|
||||
'#000000','#0000aa','#00aa00','#00aaaa','#aa0000','#aa00aa','#aa5500','#aaaaaa',
|
||||
'#555555','#5555ff','#55ff55','#55ffff','#ff5555','#ff55ff','#ffff55','#ffffff',
|
||||
];
|
||||
|
||||
export const COLS = 80, ROWS = 25;
|
||||
|
||||
// CP437 block and shade glyphs, as geometry. x/y/w/h are fractions of the cell.
|
||||
const BLOCK = {
|
||||
'█': { x: 0, y: 0, w: 1, h: 1 },
|
||||
'▀': { x: 0, y: 0, w: 1, h: 0.5 },
|
||||
'▄': { x: 0, y: 0.5, w: 1, h: 0.5 },
|
||||
'▌': { x: 0, y: 0, w: 0.5, h: 1 },
|
||||
'▐': { x: 0.5, y: 0, w: 0.5, h: 1 },
|
||||
'▓': { x: 0, y: 0, w: 1, h: 1, a: 0.75 },
|
||||
'▒': { x: 0, y: 0, w: 1, h: 1, a: 0.5 },
|
||||
'░': { x: 0, y: 0, w: 1, h: 1, a: 0.25 },
|
||||
};
|
||||
|
||||
export class Term {
|
||||
constructor(canvas) {
|
||||
this.cv = canvas;
|
||||
this.g = canvas.getContext('2d', { alpha: false });
|
||||
this.cw = 9; this.ch = 16;
|
||||
this.buf = new Array(COLS * ROWS);
|
||||
this.cx = 0; this.cy = 0; this.fg = 7; this.bg = 0;
|
||||
this.cursor = true; this.blink = 0; this.dirty = true;
|
||||
this.echo = true;
|
||||
this.onLine = null; // (line) => void
|
||||
this.onKey = null; // (e, currentInput) => void ← the sysop watches this
|
||||
this.input = '';
|
||||
this.masked = false;
|
||||
this.busy = false; // true while paint() is running
|
||||
this.clear();
|
||||
this.resize();
|
||||
}
|
||||
|
||||
resize() {
|
||||
const dpr = Math.min(devicePixelRatio || 1, 2);
|
||||
const w = innerWidth, h = innerHeight;
|
||||
// integer scale so cells stay crisp
|
||||
const s = Math.max(1, Math.min(Math.floor(w / (COLS * this.cw)), Math.floor(h / (ROWS * this.ch))));
|
||||
this.scale = s;
|
||||
this.cv.width = Math.floor(w * dpr); this.cv.height = Math.floor(h * dpr);
|
||||
this.cv.style.width = w + 'px'; this.cv.style.height = h + 'px';
|
||||
this.dpr = dpr;
|
||||
this.ox = Math.floor((w - COLS * this.cw * s) / 2);
|
||||
this.oy = Math.floor((h - ROWS * this.ch * s) / 2);
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
clear(bg = 0) {
|
||||
this.bg = bg;
|
||||
for (let i = 0; i < COLS * ROWS; i++) this.buf[i] = { c: ' ', f: 7, b: bg };
|
||||
this.cx = 0; this.cy = 0; this.dirty = true;
|
||||
}
|
||||
|
||||
at(x, y) { return this.buf[y * COLS + x]; }
|
||||
put(x, y, c, f = this.fg, b = this.bg) {
|
||||
if (x < 0 || y < 0 || x >= COLS || y >= ROWS) return;
|
||||
this.buf[y * COLS + x] = { c, f, b }; this.dirty = true;
|
||||
}
|
||||
goto(x, y) { this.cx = x; this.cy = y; }
|
||||
|
||||
scroll() {
|
||||
this.buf.copyWithin(0, COLS);
|
||||
for (let x = 0; x < COLS; x++) this.buf[(ROWS - 1) * COLS + x] = { c: ' ', f: this.fg, b: this.bg };
|
||||
this.cy = ROWS - 1;
|
||||
}
|
||||
|
||||
// Pipe codes: |00..|15 set foreground, |B0..|B7 set background. Renegade did it
|
||||
// this way and so do we.
|
||||
write(s) {
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (ch === '|' && i + 2 < s.length) {
|
||||
const a = s[i + 1], b = s[i + 2];
|
||||
if (a === 'B' && b >= '0' && b <= '7') { this.bg = +b; i += 2; continue; }
|
||||
if (a >= '0' && a <= '1' && b >= '0' && b <= '9') {
|
||||
const n = +(a + b); if (n <= 15) { this.fg = n; i += 2; continue; }
|
||||
}
|
||||
}
|
||||
if (ch === '\n') { this.cx = 0; this.cy++; if (this.cy >= ROWS) this.scroll(); continue; }
|
||||
if (ch === '\r') { this.cx = 0; continue; }
|
||||
this.put(this.cx, this.cy, ch);
|
||||
this.cx++;
|
||||
if (this.cx >= COLS) { this.cx = 0; this.cy++; if (this.cy >= ROWS) this.scroll(); }
|
||||
}
|
||||
this.dirty = true;
|
||||
}
|
||||
writeln(s = '') { this.write(s + '\n'); }
|
||||
|
||||
// The one that matters. 8N1 means ten bits per character, so a 2400 baud link
|
||||
// delivers 240 characters a second, and the login screen takes as long as it takes.
|
||||
async paint(s, baud = 56000, { sound = true } = {}) {
|
||||
this.busy = true;
|
||||
const cps = Math.max(30, baud / 10);
|
||||
const perChar = 1000 / cps;
|
||||
let acc = 0, last = performance.now();
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
// consume pipe codes instantly, they are not on the wire
|
||||
if (s[i] === '|' && i + 2 < s.length) {
|
||||
const two = s.substr(i + 1, 2);
|
||||
if (/^B[0-7]$/.test(two) || (/^\d\d$/.test(two) && +two <= 15)) { this.write(s.substr(i, 3)); i += 2; continue; }
|
||||
}
|
||||
this.write(s[i]);
|
||||
acc += perChar;
|
||||
if (acc > 14) {
|
||||
if (sound && baud <= 9600 && s[i] !== ' ' && s[i] !== '\n' && Math.random() < 0.5) A.blip(900 + Math.random() * 900, 0.008, 0.018);
|
||||
const t = performance.now();
|
||||
const owed = acc - (t - last);
|
||||
if (owed > 0) await new Promise(r => setTimeout(r, owed));
|
||||
last = performance.now(); acc = 0;
|
||||
if (this.skip) break;
|
||||
}
|
||||
}
|
||||
if (this.skip) { this.write(''); }
|
||||
this.skip = false;
|
||||
this.busy = false;
|
||||
}
|
||||
|
||||
// Line editor. Returns the typed line. onKey fires per keystroke — that is how
|
||||
// the sysop sees what you are typing before you press enter.
|
||||
ask(prompt = '', { mask = false, max = 60 } = {}) {
|
||||
this.write(prompt);
|
||||
this.input = ''; this.masked = mask;
|
||||
this.promptX = this.cx; this.promptY = this.cy;
|
||||
return new Promise(resolve => {
|
||||
this._resolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
key(e) {
|
||||
if (this._resolve === undefined || this._resolve === null) {
|
||||
if (this.onKey) this.onKey(e, null);
|
||||
return false;
|
||||
}
|
||||
const k = e.key;
|
||||
if (k === 'Enter') {
|
||||
const v = this.input;
|
||||
this.input = '';
|
||||
const r = this._resolve; this._resolve = null;
|
||||
this.write('\n');
|
||||
if (this.onKey) this.onKey(e, v);
|
||||
r(v);
|
||||
return true;
|
||||
}
|
||||
if (k === 'Backspace') {
|
||||
if (this.input.length) {
|
||||
this.input = this.input.slice(0, -1);
|
||||
this.cx = this.promptX + this.input.length; this.cy = this.promptY;
|
||||
this.put(this.cx, this.cy, ' ');
|
||||
A.blip(600, 0.01, 0.03);
|
||||
}
|
||||
if (this.onKey) this.onKey(e, this.input);
|
||||
return true;
|
||||
}
|
||||
if (k.length === 1 && this.input.length < 60) {
|
||||
this.input += k;
|
||||
this.cx = this.promptX + this.input.length - 1; this.cy = this.promptY;
|
||||
this.write(this.masked ? '*' : k);
|
||||
A.blip(1200 + Math.random() * 400, 0.01, 0.03);
|
||||
if (this.onKey) this.onKey(e, this.input);
|
||||
return true;
|
||||
}
|
||||
if (this.onKey) this.onKey(e, this.input);
|
||||
return k !== 'Tab' && k !== 'F5';
|
||||
}
|
||||
|
||||
draw(t) {
|
||||
const g = this.g, s = this.scale * this.dpr, cw = this.cw * s, ch = this.ch * s;
|
||||
const ox = this.ox * this.dpr, oy = this.oy * this.dpr;
|
||||
g.fillStyle = '#000'; g.fillRect(0, 0, this.cv.width, this.cv.height);
|
||||
g.font = `${Math.floor(this.ch * s * 0.86)}px ui-monospace, Menlo, "DejaVu Sans Mono", monospace`;
|
||||
g.textBaseline = 'alphabetic';
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
const c = this.buf[y * COLS + x];
|
||||
if (c.b) { g.fillStyle = PAL[c.b]; g.fillRect(ox + x * cw, oy + y * ch, cw + 1, ch + 1); }
|
||||
}
|
||||
}
|
||||
// One fillText per cell, centred in the cell. Drawing whole runs is faster but
|
||||
// the box-drawing glyphs resolve to a fallback font with a different advance
|
||||
// width, so a run containing them drifts and the right-hand border of a box
|
||||
// ends up nineteen columns adrift. Per cell is correct at any font.
|
||||
g.textAlign = 'center';
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
const c = this.buf[y * COLS + x];
|
||||
if (c.c === ' ' || c.c === '') continue;
|
||||
g.fillStyle = PAL[c.f];
|
||||
const px = ox + x * cw, py = oy + y * ch;
|
||||
// The block and shade glyphs are drawn as rectangles, not letters. In a real
|
||||
// text mode they filled the cell edge to edge; from a proportional fallback
|
||||
// font they arrive undersized and the art comes out full of hairline gaps.
|
||||
const b = BLOCK[c.c];
|
||||
if (b) {
|
||||
if (b.a !== undefined) g.globalAlpha = b.a;
|
||||
g.fillRect(px + b.x * cw, py + b.y * ch, cw * b.w + 1, ch * b.h + 1);
|
||||
g.globalAlpha = 1;
|
||||
} else {
|
||||
g.fillText(c.c, px + cw / 2, py + ch * 0.78);
|
||||
}
|
||||
}
|
||||
}
|
||||
g.textAlign = 'left';
|
||||
if (this.cursor && Math.floor(t * 2.4) % 2 === 0) {
|
||||
g.fillStyle = PAL[this.fg];
|
||||
g.fillRect(ox + this.cx * cw, oy + this.cy * ch + ch * 0.84, cw, ch * 0.14);
|
||||
}
|
||||
// phosphor bleed + scanlines. a terminal that does not glow is a spreadsheet.
|
||||
g.globalCompositeOperation = 'source-over';
|
||||
g.fillStyle = 'rgba(0,0,0,0.22)';
|
||||
for (let y = 0; y < this.cv.height; y += Math.max(2, Math.round(2 * this.dpr))) g.fillRect(0, y, this.cv.width, 1);
|
||||
g.globalCompositeOperation = 'source-over';
|
||||
this.dirty = false;
|
||||
}
|
||||
}
|
||||
57362
vendor/three.core.js
vendored
Normal file
57362
vendor/three.core.js
vendored
Normal file
File diff suppressed because one or more lines are too long
17990
vendor/three.module.js
vendored
Normal file
17990
vendor/three.module.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user