diff --git a/src/levels/board.js b/src/levels/board.js index 688055f..9ace14e 100644 --- a/src/levels/board.js +++ b/src/levels/board.js @@ -151,6 +151,17 @@ i am telling you that because i want somebody to know it happened.`, phile: ['thenumber', 'just the number'] }, ]; +// ── TRADE WARS 2002. Ten sectors, seven ports, and the Ferrengi in nine. +const TW_TURNS = 24; +const TW_MAP = { + 1: [2, 3], 2: [1, 4, 5], 3: [1, 5, 6], 4: [2, 7], 5: [2, 3, 7, 8], + 6: [3, 8], 7: [4, 5, 9], 8: [5, 6, 9], 9: [7, 8, 10], 10: [9], +}; +// class string is fuel ore / organics / equipment — B: the port buys from you, +// S: the port sells to you. These are the real classes. +const TW_PORTS = { 2: 'BBS', 3: 'SBB', 5: 'SSB', 6: 'BSS', 7: 'SBS', 8: 'BBB', 9: 'SSS', 10: 'BBB' }; +const TW_BASE = { ore: 18, org: 32, equ: 61 }; + 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.' }, @@ -165,7 +176,7 @@ export default { async mount(ctx) { S = { t: 0, busyTries: 0, cargo: 0, uploads: 0, nup: false, elite: false, bombed: false, done: false, - sysopSeen: 0, day: 1, ratioOff: false }; + sysopSeen: 0, day: 1, ratioOff: false, twDone: false }; window.__S = S; // debug handle ctx.scene.background = new THREE.Color(0x000000); @@ -531,11 +542,130 @@ export default { 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('|08 2 |15TRADE WARS 2002|08 v2.00b ' + + (S.twDone ? '|08(you have used your turns today)' : '|10' + TW_TURNS + ' turns available')); term.writeln(''); const n = (await term.ask('|07door: |15')).trim(); - if (n !== '1') return; - await this.lord(ctx, term); + if (n === '1') await this.lord(ctx, term); + else if (n === '2' && !S.twDone) await this.tradewars(ctx, term); + }, + + // ── TRADE WARS 2002. + // Ten sectors, seven ports, a fixed number of turns, and a species of merchant + // who will take your cargo off you in sector nine. The port classes are the real + // ones: three letters for fuel ore, organics and equipment, B if the port buys + // from you and S if it sells to you, and the whole economy is working out which + // pair of adjacent ports disagree with each other most. + async tradewars(ctx, term) { + const g = { + sector: 1, credits: 1000, turns: TW_TURNS, holds: 20, + cargo: { ore: 0, org: 0, equ: 0 }, fights: 0, best: 1000, + }; + const NAME = { ore: 'fuel ore', org: 'organics', equ: 'equipment' }; + const KEY = ['ore', 'org', 'equ']; + const price = (k, sells) => Math.round(TW_BASE[k] * (sells ? 1 : 0.78) * (0.85 + ((g.sector * 7 + k.length * 13) % 30) / 100)); + + term.clear(); + await term.paint(`|12 + TRADE WARS 2002 |08v2.00b (c) 1986 Chris Sherrick +|08 ──────────────────────────────────────────────────────────────── +|07 you have |15${g.turns}|07 turns, |15${g.holds}|07 holds and |15${g.credits}|07 credits. +|07 buy low. sell high. do not go to sector nine with a full hold. +`, Math.max(1800, ctx.link.rate)); + + for (;;) { + if (g.turns <= 0) break; + const port = TW_PORTS[g.sector]; + const held = g.cargo.ore + g.cargo.org + g.cargo.equ; + term.writeln(''); + term.writeln(`|11 Sector |15${g.sector}|11 |08warps to |15${TW_MAP[g.sector].join(' ')}`); + term.writeln(`|08 credits |15${g.credits}|08 holds |15${held}/${g.holds}|08 turns |15${g.turns}`); + term.writeln(`|08 cargo: ore |15${g.cargo.ore}|08 org |15${g.cargo.org}|08 equ |15${g.cargo.equ}`); + if (port) term.writeln(`|10 Port class |15${port}|10 — ` + + KEY.map((k, i) => `${NAME[k]} ${port[i] === 'S' ? 'for sale ' + price(k, true) : 'wanted ' + price(k, false)}`).join(' · ')); + else term.writeln('|08 no port in this sector.'); + term.writeln('|08 [|151-10|08] warp [|15T|08] trade [|15Q|08] quit'); + + const cmd = (await term.ask('|07 command: |15')).trim().toUpperCase(); + if (cmd === 'Q') break; + + if (cmd === 'T') { + if (!port) { term.writeln('|12 there is no port here.'); continue; } + term.writeln('|08 which — [|15O|08]re [|15G|08] organics [|15E|08] equipment'); + const wk = (await term.ask('|07 : |15')).trim().toUpperCase(); + const k = wk === 'O' ? 'ore' : wk === 'G' ? 'org' : wk === 'E' ? 'equ' : null; + if (!k) continue; + const idx = KEY.indexOf(k); + const sells = port[idx] === 'S'; + const p = price(k, sells); + const qty = parseInt(await term.ask(`|07 how many (${sells ? 'buying at ' + p : 'selling at ' + p}): |15`), 10) || 0; + if (qty <= 0) continue; + if (sells) { + const canHold = g.holds - held; + const n = Math.min(qty, canHold, Math.floor(g.credits / p)); + if (n <= 0) { term.writeln('|12 no room, or no money.'); continue; } + g.cargo[k] += n; g.credits -= n * p; + await term.paint(`|10 bought ${n} ${NAME[k]} for ${n * p}.\n`, 3000); + } else { + const n = Math.min(qty, g.cargo[k]); + if (n <= 0) { term.writeln('|12 you have none.'); continue; } + g.cargo[k] -= n; g.credits += n * p; + await term.paint(`|10 sold ${n} ${NAME[k]} for ${n * p}.\n`, 3000); + } + g.best = Math.max(g.best, g.credits); + g.turns--; + A.blip(1200, 0.02, 0.04); + continue; + } + + const dest = parseInt(cmd, 10); + if (!TW_MAP[g.sector].includes(dest)) { term.writeln('|12 no warp link to that sector.'); continue; } + g.turns--; + g.sector = dest; + A.blip(700 + dest * 40, 0.03, 0.045); + await term.paint(`|03 warping to sector ${dest}...\n`, 4000); + + // ── the Ferrengi. They are in sector nine and they always were. + if (dest === 9 && (g.cargo.ore + g.cargo.org + g.cargo.equ) > 0 && !g.robbed) { + g.robbed = true; + await term.paint('|12\n *** FERRENGI BATTLESHIP DECLOAKING ***\n', 900); + await ctx.sleep(700); + A.burst(120, 90, 0.5, 0.2); + await term.paint('|07 "your cargo. all of it. now."\n', 2400); + term.writeln('|08 [|15F|08]ight [|15R|08]un [|15P|08]ay'); + const a = (await term.ask('|07 : |15')).trim().toUpperCase()[0]; + if (a === 'F') { + g.fights++; + if (Math.random() < 0.35) { + await term.paint('|10 you got a shot into their reactor. they run.\n', 2400); + g.credits += 500; + } else { + await term.paint('|12 they take everything and leave you the ship.\n', 2400); + g.cargo = { ore: 0, org: 0, equ: 0 }; + } + } else if (a === 'P') { + const toll = Math.min(g.credits, 400); + g.credits -= toll; + await term.paint(`|03 you pay ${toll} and they let you through.\n`, 2400); + } else { + g.turns -= 2; + g.sector = 7; + await term.paint('|03 you burn two turns getting out of there.\n', 2400); + } + } + } + + S.twDone = true; + const profit = g.credits - 1000; + term.writeln(''); + await term.paint(`|11 out of turns.\n|07 you finished with |15${g.credits}|07 credits — ` + + (profit > 0 ? `|10up ${profit}` : profit < 0 ? `|12down ${-profit}` : '|08exactly even') + '|07.\n', + Math.max(1800, ctx.link.rate)); + if (profit > 0) { + ctx.phile('tradewars'); + ctx.say('there is a second whole game in the corner of this level.', 4600); + } + await term.ask('|08 [enter]|07'); }, async lord(ctx, term) { diff --git a/src/levels/climb.js b/src/levels/climb.js index e14184d..de28bc4 100644 --- a/src/levels/climb.js +++ b/src/levels/climb.js @@ -103,6 +103,18 @@ export default { S.specToken = new THREE.Mesh(new THREE.OctahedronGeometry(0.9, 0), glow(0xff8ad8, 1.6)); S.specGroup.add(S.specToken); S.specGroup.visible = false; + // the second camera: the same world, from the branch that will be thrown away + S.specCam = new THREE.PerspectiveCamera(72, 1, 0.05, 4000); + S.specLight = new THREE.PointLight(0xff8ad8, 30, 80, 2); scene.add(S.specLight); + S.specLight.position.set(0, 0, -900); + const specTag = sprite('SPECULATIVE', { color: '#ff8ad8', size: 1.0 }); + specTag.position.set(0, 7.5, -26); S.specGroup.add(specTag); + // Layer 1 is the timeline that does not happen. Only the speculative camera can + // see it — same scene, two views, and one of them contains things the other + // never will. That is the entire point of the section. + S.specGroup.traverse(o => o.layers.set(1)); + S.specLight.layers.set(1); + S.specCam.layers.enable(1); // ── ROWHAMMER S.wall = new THREE.Mesh(new THREE.BoxGeometry(26, 22, 1.2), @@ -354,16 +366,24 @@ export default { // ── the three modern toys modern(dt, ctx) { - if (S.z > SPECTRE_AT && S.z < SPECTRE_AT + 120) { + if (S.z > SPECTRE_AT && S.z < SPECTRE_AT + 130) { if (!S.spec) { S.spec = { got: false, squashed: false }; S.specGroup.visible = true; - ctx.say('branch you are going to run down both sides of this. one of them is not going to have happened.', 5600); + // ── two viewports. left is the branch that happens. right is the one that + // does not, and you are in both of them, and only one of you is real. + ctx.splitCam = S.specCam; + ctx.say('branch you are going down both sides of this at once.
one of them is not going to have happened.', 5800); + ctx.hint('A Dsteer — the right-hand you is mirrored'); } - S.ghostMe.position.set(-S.x, 0, -8); - S.specToken.position.set(-4.5, 0, -26 + Math.sin(S.t * 2) * 3); + // the speculative you, mirrored, in a world tinted for the timeline that dies + S.ghostMe.position.set(-S.x, 0, -9); + S.specToken.position.set(-4.5, 0, -30 + Math.sin(S.t * 1.6) * 5); S.specToken.rotation.y += dt * 3; - if (!S.spec.got && Math.abs(-S.x - S.specToken.position.x) < 2.4 && S.z > SPECTRE_AT + 40) { + S.specCam.position.set(-S.x, Math.sin(S.t * 0.7) * 0.6, 0); + S.specCam.rotation.z = -cam.rotation.z; + S.specLight.position.set(-S.x, 0, -14); + if (!S.spec.got && Math.abs(-S.x - S.specToken.position.x) < 2.6 && S.z > SPECTRE_AT + 45) { S.spec.got = true; S.specToken.visible = false; A.burst(1760, 2640, 0.24, 0.18); @@ -372,6 +392,9 @@ export default { } else if (S.spec && !S.spec.squashed) { S.spec.squashed = true; S.specGroup.visible = false; + ctx.splitCam = null; + ctx.link.unsplit(ctx.camera); + ctx.hint('A Dsteer   SPACEseize   Euse'); A.thud(90, 0.4, 0.25); if (S.spec.got) { S.gateOpen = true; @@ -456,6 +479,8 @@ export default { }, async unmount(ctx) { + ctx.splitCam = null; + ctx.link.unsplit(ctx.camera); S.hiss?.stop(); S.gates?.forEach(g => g.seizing?.stop(0.05)); A.stopPad(); diff --git a/src/link.js b/src/link.js index c3d10b0..cc39738 100644 --- a/src/link.js +++ b/src/link.js @@ -59,8 +59,8 @@ uniform float uTintMix; uniform float uTime; uniform float uFade; // 1 = normal, 0 = black uniform float uGlitch; // packet corruption +uniform float uSplit; // 1 = two viewports, draw the seam -// 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){ @@ -68,6 +68,7 @@ vec3 toSRGB(vec3 c){ return mix(c * 12.92, 1.055 * pow(c, vec3(0.41666)) - 0.055, step(vec3(0.0031308), c)); } +// 4x4 ordered Bayer. Dither is not a filter, it is a commitment. 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; @@ -118,6 +119,12 @@ void main(){ col = mix(col, col * uTint, uTintMix); + if(uSplit > 0.5){ + float d = abs(uv.x - 0.5); + if(d < 0.0016) col = vec3(0.0); + else if(d < 0.004) col *= 0.35; + } + if(uScan > 0.01){ float line = sin(uv.y * uOut.y * 1.0) * 0.5 + 0.5; col *= 1.0 - uScan * 0.28 * line; @@ -152,6 +159,7 @@ export class Link { uTime: { value: 0 }, uFade: { value: 1 }, uGlitch: { value: 0 }, + uSplit: { 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)); @@ -221,4 +229,35 @@ export class Link { r.setRenderTarget(null); r.render(this.scene, this.cam); } + + // Two viewports into the same target, scissored, then one post pass over both. + // For the branch you run down twice: left is what happens, right is what does not. + renderSplit(scene, camL, camR, t) { + this.uni.uTime.value = t; + this.uni.uFade.value = this.fade; + this.uni.uGlitch.value = this.glitch; + const r = this.renderer, tgt = this.target; + const w = tgt.width, h = tgt.height, hw = Math.floor(w / 2); + const aspect = hw / h; + for (const c of [camL, camR]) { + if (c.isPerspectiveCamera && c.aspect !== aspect) { c.aspect = aspect; c.updateProjectionMatrix(); } + } + r.setRenderTarget(tgt); + r.setScissorTest(true); + r.setViewport(0, 0, hw, h); r.setScissor(0, 0, hw, h); + r.render(scene, camL); + r.setViewport(hw, 0, w - hw, h); r.setScissor(hw, 0, w - hw, h); + r.render(scene, camR); + r.setScissorTest(false); + r.setViewport(0, 0, w, h); r.setScissor(0, 0, w, h); + r.setRenderTarget(null); + this.uni.uSplit.value = 1; + r.render(this.scene, this.cam); + this.uni.uSplit.value = 0; + } + + // put a camera back the way the rest of the game expects it + unsplit(camera) { + if (camera && camera.isPerspectiveCamera) { camera.aspect = this.aspect; camera.updateProjectionMatrix(); } + } } diff --git a/src/main.js b/src/main.js index 7818928..7f0c7a3 100644 --- a/src/main.js +++ b/src/main.js @@ -157,6 +157,7 @@ export async function goto(id, { restart = false } = {}) { if (game.level && game.level.unmount) { try { await game.level.unmount(ctx); } catch (e) { console.error(e); } } In.releaseText(); In.clearRemap(); In.unlock(); + ctx.splitCam = null; termCanvas.style.display = 'none'; disposeScene(ctx.scene); @@ -220,7 +221,11 @@ export function step(dt) { 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); + if (ctx.scene && ctx.camera) { + // a level can ask for two viewports by setting ctx.splitCam + if (ctx.splitCam) link.renderSplit(ctx.scene, ctx.camera, ctx.splitCam, game.time); + else link.render(ctx.scene, ctx.camera, game.time); + } In.endFrame(); } diff --git a/src/philes.js b/src/philes.js index a0adffa..ab6dd64 100644 --- a/src/philes.js +++ b/src/philes.js @@ -461,6 +461,19 @@ nobody has built a better multiplayer economy since. they have all built faster ones.`, }, + 'tradewars': { + title: 'TRADE WARS 2002', + from: 'a door game, running on somebody\'s spare computer', + body: `ten sectors. seven ports. a fixed number of turns a day and then you have +to hang up and let somebody else have the line. + +the entire economy is one idea: find two adjacent ports that disagree with +each other about what something is worth, and run between them until they +stop disagreeing. + +people played this for years. on one phone line. taking turns.`, + }, + 'fido': { title: 'you posted to fidonet', from: 'zone 1, net 129, node 22',