Raw WebGL fullscreen triangle, not three.js: one texture, one pass. The 8 prototype params keep their tuning verbatim; u_drift, u_brownout and u_flash are additions. Base feed repaints only when a visible glyph changes rather than per frame, so the texture upload stays off the hot path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
95 lines
3.3 KiB
TypeScript
95 lines
3.3 KiB
TypeScript
/**
|
|
* THE SCREEN's base content: procedural "clean programming".
|
|
* Sterile SMPTE-ish test pattern + camcorder overlay, ported from the prototype's
|
|
* makeBaseTexture and widened to 16:9. This is what the factory's output is painted
|
|
* ONTO — it must be bland enough that any corruption reads instantly.
|
|
*
|
|
* Cost control: the pattern is static, so we only repaint when a visible glyph changes
|
|
* (timecode ticks ~1/sec, chyron toggles once). Motion comes from the shader's slow
|
|
* drift, not from re-uploading 640x360 pixels every frame.
|
|
*/
|
|
|
|
export const BASE_W = 640;
|
|
export const BASE_H = 360;
|
|
|
|
export interface BaseFeed {
|
|
canvas: HTMLCanvasElement;
|
|
/** Repaint if anything visible changed. Returns true when the texture needs re-upload. */
|
|
update(timeMs: number, idle: boolean): boolean;
|
|
}
|
|
|
|
function timecode(timeMs: number): string {
|
|
// Tape-style running timecode, starting at a suspiciously specific point.
|
|
const total = Math.floor(timeMs / 1000) + 754; // 0:12:34
|
|
const h = Math.floor(total / 3600);
|
|
const m = Math.floor((total % 3600) / 60);
|
|
const s = total % 60;
|
|
return `SP ${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
|
}
|
|
|
|
export function createBaseFeed(): BaseFeed {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = BASE_W;
|
|
canvas.height = BASE_H;
|
|
const g = canvas.getContext('2d')!;
|
|
let lastTC = '';
|
|
let lastIdle: boolean | null = null;
|
|
|
|
function paint(tc: string, idle: boolean) {
|
|
const bars = ['#c0c0c0', '#c0c000', '#00c0c0', '#00c000', '#c000c0', '#c00000', '#0000c0'];
|
|
const bw = BASE_W / bars.length;
|
|
bars.forEach((col, i) => {
|
|
g.fillStyle = col;
|
|
g.fillRect(i * bw, 0, bw + 1, BASE_H * 0.62);
|
|
});
|
|
// castellations strip
|
|
const casts = ['#0000c0', '#131313', '#c000c0', '#131313', '#00c0c0', '#131313', '#c0c0c0'];
|
|
casts.forEach((col, i) => {
|
|
g.fillStyle = col;
|
|
g.fillRect(i * bw, BASE_H * 0.62, bw + 1, BASE_H * 0.1);
|
|
});
|
|
// pluge / ramp
|
|
g.fillStyle = '#101010';
|
|
g.fillRect(0, BASE_H * 0.72, BASE_W, BASE_H * 0.28);
|
|
const ramp = g.createLinearGradient(0, 0, BASE_W, 0);
|
|
ramp.addColorStop(0, '#000');
|
|
ramp.addColorStop(1, '#fff');
|
|
g.fillStyle = ramp;
|
|
g.fillRect(BASE_W * 0.55, BASE_H * 0.8, BASE_W * 0.4, 18);
|
|
|
|
// camcorder overlay
|
|
g.fillStyle = '#eee';
|
|
g.font = "bold 22px 'Courier New', monospace";
|
|
g.fillText('PLAY ▶', 20, BASE_H * 0.86);
|
|
g.fillText(tc, 20, BASE_H * 0.95);
|
|
g.font = "bold 18px 'Courier New', monospace";
|
|
g.fillStyle = '#f4f4f4';
|
|
g.fillText('WIMVEE FKTRY', BASE_W * 0.55, BASE_H * 0.95);
|
|
g.fillStyle = '#ff4444';
|
|
g.beginPath();
|
|
g.arc(BASE_W - 26, BASE_H * 0.83, 7, 0, Math.PI * 2);
|
|
g.fill();
|
|
|
|
// Idle chyron: OSHA-poster deadpan. Disappears forever once the factory ships.
|
|
if (idle) {
|
|
g.fillStyle = 'rgba(8,8,12,0.82)';
|
|
g.fillRect(0, BASE_H * 0.5, BASE_W, 34);
|
|
g.fillStyle = '#d8ffd8';
|
|
g.font = "bold 20px 'Courier New', monospace";
|
|
g.fillText('NOTHING IS WRONG', 18, BASE_H * 0.5 + 24);
|
|
}
|
|
}
|
|
|
|
return {
|
|
canvas,
|
|
update(timeMs: number, idle: boolean) {
|
|
const tc = timecode(timeMs);
|
|
if (tc === lastTC && idle === lastIdle) return false;
|
|
lastTC = tc;
|
|
lastIdle = idle;
|
|
paint(tc, idle);
|
|
return true;
|
|
},
|
|
};
|
|
}
|