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',