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>
264 lines
9.7 KiB
JavaScript
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(); }
|
|
}
|
|
}
|