haxtory/src/link.js
type-two 2d46960211 Trade Wars, and a branch you can see both sides of
TRADE WARS 2002 is real now. Ten sectors, seven ports, twenty-four turns.
The port classes are the actual ones — three letters for fuel ore,
organics and equipment, B where the port buys from you and S where it
sells to you — and the whole economy is finding two adjacent ports that
disagree with each other about what something is worth. The Ferrengi are
in sector nine and they want your cargo, and you can fight, run or pay.

SPECTRE is a real split screen. Two viewports scissored into the same
render target, two cameras, one post pass over both, and a hard seam down
the middle. The speculative timeline lives on render layer 1, which only
the second camera can see — so the right-hand pane genuinely contains
things the left-hand pane never will, which is the entire point of the
section. Steering is mirrored. The token you carry out of the branch that
gets discarded is only reachable in the pane that stops existing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 17:43:50 +10:00

264 lines
9.7 KiB
JavaScript

// 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
uniform float uSplit; // 1 = two viewports, draw the seam
// 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));
}
// 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;
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(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;
// 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 },
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));
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);
}
// 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(); }
}
}