[screen] the substrate ages: era-skinned clean programming

The base feed now wears the highest ratified era -- sepia film with sprocket
holes, phosphor broadcast with interlace, rainbow-on-black disc gloss, the
classic SMPTE stream, blinding fortress white. Corruption stays era-agnostic:
same shader, same params, only what they are painted onto changes.

Boot era is reel, so the world starts in the deepest stratum and climbs to the
surface. The old SMPTE look is now mid-game.

The chyron generalises from an idle-only placard to a line of text, which is
what lets the radio caption the sky in a later commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-18 13:51:03 +10:00
parent f2da81a9cf
commit d8111feb12
3 changed files with 248 additions and 31 deletions

View File

@ -1,25 +1,34 @@
/** /**
* THE SCREEN's base content: procedural "clean programming". * 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 * A sterile SMPTE-ish test pattern + camcorder overlay, ported from the prototype and widened
* (timecode ticks ~1/sec, chyron toggles once). Motion comes from the shader's slow * to 16:9. This is what the factory's output is painted ONTO bland enough that any corruption
* drift, not from re-uploading 640x360 pixels every frame. * reads instantly. Round 5: the SUBSTRATE ages with the highest ratified era (eras.ts); the
* corruption on top stays era-agnostic.
*
* Cost control: the pattern is static, so we only repaint when a visible input changes (timecode
* ~1/sec, era, chyron text). Motion comes from the shader's slow drift, not from re-uploading
* 640x360 pixels every frame.
*/ */
import { eraSkin, type Era, type EraSkin } from './eras';
export const BASE_W = 640; export const BASE_W = 640;
export const BASE_H = 360; export const BASE_H = 360;
export interface BaseState {
timeMs: number;
era: Era;
/** chyron line: 'NOTHING IS WRONG' idle era, a radio subtitle when tuned, or '' for none */
chyron: string;
}
export interface BaseFeed { export interface BaseFeed {
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
/** Repaint if anything visible changed. Returns true when the texture needs re-upload. */ /** Repaint if anything visible changed. Returns true when the texture needs re-upload. */
update(timeMs: number, idle: boolean): boolean; update(state: BaseState): boolean;
} }
function timecode(timeMs: number): string { 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 total = Math.floor(timeMs / 1000) + 754; // 0:12:34
const h = Math.floor(total / 3600); const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60); const m = Math.floor((total % 3600) / 60);
@ -27,29 +36,77 @@ function timecode(timeMs: number): string {
return `SP ${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; return `SP ${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
} }
function applyTreatment(g: CanvasRenderingContext2D, skin: EraSkin) {
const t = skin.treatment;
if (t.kind === 'none') return;
g.save();
if (t.kind === 'sepia') {
// warm the whole frame + gate-weave sprocket holes down both edges
g.globalCompositeOperation = 'overlay';
g.fillStyle = 'rgba(150,110,60,0.28)';
g.fillRect(0, 0, BASE_W, BASE_H);
g.globalCompositeOperation = 'source-over';
g.fillStyle = '#0d0904';
g.fillRect(0, 0, 22, BASE_H);
g.fillRect(BASE_W - 22, 0, 22, BASE_H);
g.fillStyle = '#efe2c0';
for (let y = 6; y < BASE_H; y += 34) {
g.fillRect(5, y, 12, 18);
g.fillRect(BASE_W - 17, y, 12, 18);
}
} else if (t.kind === 'phosphor') {
// green cathode wash + heavy interlace lines
g.globalCompositeOperation = 'multiply';
g.fillStyle = 'rgba(120,255,150,0.35)';
g.fillRect(0, 0, BASE_W, BASE_H);
g.globalCompositeOperation = 'source-over';
g.fillStyle = 'rgba(0,0,0,0.28)';
for (let y = 0; y < BASE_H; y += 2) g.fillRect(0, y, BASE_W, 1);
} else if (t.kind === 'gloss') {
// rainbow pit-and-land sheen sweeping across, plus a specular band
const grad = g.createLinearGradient(0, 0, BASE_W, BASE_H);
grad.addColorStop(0.0, 'rgba(255,0,180,0.10)');
grad.addColorStop(0.35, 'rgba(0,220,255,0.06)');
grad.addColorStop(0.7, 'rgba(120,255,120,0.10)');
grad.addColorStop(1.0, 'rgba(255,180,0,0.08)');
g.globalCompositeOperation = 'screen';
g.fillStyle = grad;
g.fillRect(0, 0, BASE_W, BASE_H);
g.fillStyle = 'rgba(255,255,255,0.10)';
g.fillRect(0, BASE_H * 0.2, BASE_W, 26);
g.globalCompositeOperation = 'source-over';
} else if (t.kind === 'bleach') {
// wash toward blinding white, kill contrast
g.fillStyle = 'rgba(250,250,250,0.6)';
g.fillRect(0, 0, BASE_W, BASE_H);
}
g.restore();
}
export function createBaseFeed(): BaseFeed { export function createBaseFeed(): BaseFeed {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = BASE_W; canvas.width = BASE_W;
canvas.height = BASE_H; canvas.height = BASE_H;
const g = canvas.getContext('2d')!; const g = canvas.getContext('2d')!;
let lastTC = ''; let last = '';
let lastIdle: boolean | null = null;
function paint(tc: string, idle: boolean) { function paint(state: BaseState) {
const bars = ['#c0c0c0', '#c0c000', '#00c0c0', '#00c000', '#c000c0', '#c00000', '#0000c0']; const skin = eraSkin(state.era);
const bw = BASE_W / bars.length; g.fillStyle = skin.bg;
bars.forEach((col, i) => { g.fillRect(0, 0, BASE_W, BASE_H);
const bw = BASE_W / skin.bars.length;
skin.bars.forEach((col, i) => {
g.fillStyle = col; g.fillStyle = col;
g.fillRect(i * bw, 0, bw + 1, BASE_H * 0.62); g.fillRect(i * bw, 0, bw + 1, BASE_H * 0.62);
}); });
// castellations strip skin.casts.forEach((col, i) => {
const casts = ['#0000c0', '#131313', '#c000c0', '#131313', '#00c0c0', '#131313', '#c0c0c0'];
casts.forEach((col, i) => {
g.fillStyle = col; g.fillStyle = col;
g.fillRect(i * bw, BASE_H * 0.62, bw + 1, BASE_H * 0.1); g.fillRect(i * bw, BASE_H * 0.62, bw + 1, BASE_H * 0.1);
}); });
// pluge / ramp // pluge / ramp
g.fillStyle = '#101010'; g.fillStyle = skin.bg;
g.fillRect(0, BASE_H * 0.72, BASE_W, BASE_H * 0.28); g.fillRect(0, BASE_H * 0.72, BASE_W, BASE_H * 0.28);
const ramp = g.createLinearGradient(0, 0, BASE_W, 0); const ramp = g.createLinearGradient(0, 0, BASE_W, 0);
ramp.addColorStop(0, '#000'); ramp.addColorStop(0, '#000');
@ -58,36 +115,38 @@ export function createBaseFeed(): BaseFeed {
g.fillRect(BASE_W * 0.55, BASE_H * 0.8, BASE_W * 0.4, 18); g.fillRect(BASE_W * 0.55, BASE_H * 0.8, BASE_W * 0.4, 18);
// camcorder overlay // camcorder overlay
g.fillStyle = '#eee'; g.fillStyle = skin.ink;
g.font = "bold 22px 'Courier New', monospace"; g.font = "bold 22px 'Courier New', monospace";
g.fillText('PLAY ▶', 20, BASE_H * 0.86); g.fillText('PLAY ▶', 20, BASE_H * 0.86);
g.fillText(tc, 20, BASE_H * 0.95); g.fillText(timecode(state.timeMs), 20, BASE_H * 0.95);
g.font = "bold 18px 'Courier New', monospace"; g.font = "bold 18px 'Courier New', monospace";
g.fillStyle = '#f4f4f4';
g.fillText('WIMVEE FKTRY', BASE_W * 0.55, BASE_H * 0.95); g.fillText('WIMVEE FKTRY', BASE_W * 0.55, BASE_H * 0.95);
g.fillStyle = '#ff4444'; g.fillStyle = skin.accent;
g.beginPath(); g.beginPath();
g.arc(BASE_W - 26, BASE_H * 0.83, 7, 0, Math.PI * 2); g.arc(BASE_W - 26, BASE_H * 0.83, 7, 0, Math.PI * 2);
g.fill(); g.fill();
// Idle chyron: OSHA-poster deadpan. Disappears forever once the factory ships. applyTreatment(g, skin);
if (idle) {
// Chyron band. Carries 'NOTHING IS WRONG' while idle, or a radio subtitle when tuned.
// Painted AFTER the era treatment so captions stay legible on any substrate.
if (state.chyron) {
g.fillStyle = 'rgba(8,8,12,0.82)'; g.fillStyle = 'rgba(8,8,12,0.82)';
g.fillRect(0, BASE_H * 0.5, BASE_W, 34); g.fillRect(0, BASE_H * 0.5, BASE_W, 34);
g.fillStyle = '#d8ffd8'; g.fillStyle = '#d8ffd8';
g.font = "bold 20px 'Courier New', monospace"; g.font = "bold 20px 'Courier New', monospace";
g.fillText('NOTHING IS WRONG', 18, BASE_H * 0.5 + 24); g.fillText(state.chyron, 18, BASE_H * 0.5 + 24);
} }
} }
return { return {
canvas, canvas,
update(timeMs: number, idle: boolean) { update(state: BaseState) {
const tc = timecode(timeMs); // Cache key of every visible input; repaint only when one changes.
if (tc === lastTC && idle === lastIdle) return false; const key = `${timecode(state.timeMs)}|${state.era}|${state.chyron}`;
lastTC = tc; if (key === last) return false;
lastIdle = idle; last = key;
paint(tc, idle); paint(state);
return true; return true;
}, },
}; };

View File

@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { ERA_ORDER, eraSkin, highestEra, type Era } from './eras';
const eraOf = (t: string): Era | undefined =>
({ a: 'reel', b: 'broadcast', c: 'disc', d: 'stream', e: 'fortress' } as Record<string, Era>)[t];
describe('era skins cover the codex', () => {
it('has a distinct skin for every era', () => {
const bars = new Set<string>();
for (const era of ERA_ORDER) {
const skin = eraSkin(era);
expect(skin.bars).toHaveLength(7); // the substrate keeps SMPTE structure; only palette ages
expect(skin.casts).toHaveLength(7);
bars.add(skin.bars.join(','));
}
expect(bars.size).toBe(ERA_ORDER.length); // no two eras look alike
});
it('is deterministic — the same era always paints the same substrate', () => {
expect(eraSkin('disc')).toEqual(eraSkin('disc'));
});
it('runs oldest to newest, reel first and fortress last', () => {
expect(ERA_ORDER[0]).toBe('reel');
expect(ERA_ORDER[ERA_ORDER.length - 1]).toBe('fortress');
});
});
describe('highest ratified era', () => {
it('starts in the deepest stratum when nothing is ratified', () => {
expect(highestEra([], eraOf)).toBe('reel');
});
it('takes the newest era present, not the last listed', () => {
expect(highestEra(['c', 'a', 'b'], eraOf)).toBe('disc');
expect(highestEra(['e', 'a'], eraOf)).toBe('fortress');
});
it('ignores tech it cannot place rather than falling over', () => {
// LANE-DATA can add tech ahead of me; unknown ids must not crash or reset the era.
expect(highestEra(['b', 'who-knows'], eraOf)).toBe('broadcast');
});
it('never goes backwards as more tech is ratified', () => {
let prev = -1;
const acc: string[] = [];
for (const t of ['a', 'b', 'c', 'd', 'e']) {
acc.push(t);
const idx = ERA_ORDER.indexOf(highestEra(acc, eraOf));
expect(idx).toBeGreaterThanOrEqual(prev);
prev = idx;
}
});
});

104
fktry/src/screen/eras.ts Normal file
View File

@ -0,0 +1,104 @@
/**
* Era skins for the clean programming (round 5 Part A).
*
* THE SCREEN's SUBSTRATE ages with the highest ratified era; the corruption on top stays
* era-agnostic. Media history is geology (codex §1), so the test pattern itself is a fossil
* of whatever format the player has climbed to. Codex §8 era skins drive the palette.
*
* Pure and data-only so it is trivially unit-testable and carries no DOM the base feed
* consumes an EraSkin, it does not decide one.
*/
export type Era = 'reel' | 'broadcast' | 'disc' | 'stream' | 'fortress';
/** Deepest → shallowest, i.e. oldest → newest. "Highest ratified" = last of these unlocked. */
export const ERA_ORDER: readonly Era[] = ['reel', 'broadcast', 'disc', 'stream', 'fortress'];
export interface EraSkin {
/** the seven SMPTE bars, era-recoloured */
bars: string[];
/** the castellation strip under the bars */
casts: string[];
/** backdrop behind the pattern (matters where bars don't cover) */
bg: string;
/** overlay text + timecode colour */
ink: string;
/** rec-dot / accent colour */
accent: string;
/**
* A full-canvas post-treatment applied after the pattern is drawn. Kept as a tagged union
* (not a callback) so it stays pure data the base feed interprets it.
*/
treatment:
| { kind: 'none' }
| { kind: 'sepia'; sprockets: true } // reel: warm film, sprocket holes
| { kind: 'phosphor' } // broadcast: green cast + interlace
| { kind: 'gloss' } // disc: rainbow-on-black sheen
| { kind: 'bleach' }; // fortress: blinding matte white
}
const STREAM_BARS = ['#c0c0c0', '#c0c000', '#00c0c0', '#00c000', '#c000c0', '#c00000', '#0000c0'];
const STREAM_CASTS = ['#0000c0', '#131313', '#c000c0', '#131313', '#00c0c0', '#131313', '#c0c0c0'];
const SKINS: Record<Era, EraSkin> = {
reel: {
bars: ['#b9a67c', '#c7b06a', '#9c9a72', '#8f9a63', '#a98c6e', '#a06a4e', '#6e5c46'],
casts: ['#5a4a34', '#241d14', '#6e5240', '#241d14', '#5c5a44', '#241d14', '#b9a67c'],
bg: '#181008',
ink: '#f0e0c0',
accent: '#d8a24a',
treatment: { kind: 'sepia', sprockets: true },
},
broadcast: {
// NTSC bars, but everything sunk into a phosphor-green cathode glow.
bars: ['#8fce8f', '#b6c664', '#4fb7b7', '#4fb44f', '#8a5aa8', '#9a4a4a', '#3a4fb0'],
casts: ['#12300f', '#0a140a', '#2a3a20', '#0a140a', '#1a3a30', '#0a140a', '#8fce8f'],
bg: '#03120a',
ink: '#c8ffce',
accent: '#8fffa0',
treatment: { kind: 'phosphor' },
},
disc: {
// Rigid, glossy, rainbow-on-black. DVD menu energy.
bars: ['#d0d0d0', '#d8d800', '#00d8d8', '#00d800', '#d800d8', '#e00000', '#2020ff'],
casts: ['#2020ff', '#000000', '#d800d8', '#000000', '#00d8d8', '#000000', '#d0d0d0'],
bg: '#000000',
ink: '#ffffff',
accent: '#ff3fd4',
treatment: { kind: 'gloss' },
},
stream: {
// The surface world / the original prototype look — the game's "now".
bars: STREAM_BARS,
casts: STREAM_CASTS,
bg: '#101010',
ink: '#f4f4f4',
accent: '#ff4444',
treatment: { kind: 'none' },
},
fortress: {
// The DCP citadel: mathematically perfect, blinding matte white, near-invisible bars.
bars: ['#f7f7f7', '#f2f2e8', '#e8f2f2', '#e8f2e8', '#f2e8f2', '#f2e8e8', '#e8e8f4'],
casts: ['#e6e6ee', '#f4f4f4', '#eee6ee', '#f4f4f4', '#e6eeee', '#f4f4f4', '#f7f7f7'],
bg: '#fbfbfb',
ink: '#20242a',
accent: '#2f7d3f',
treatment: { kind: 'bleach' },
},
};
/** The highest ratified era from a set of unlocked tech, given a tech→era lookup. */
export function highestEra(unlocked: readonly string[], eraOf: (tech: string) => Era | undefined): Era {
let best = 0; // default: reel (the world starts in the deepest stratum you can reach)
for (const t of unlocked) {
const e = eraOf(t);
if (e === undefined) continue;
const idx = ERA_ORDER.indexOf(e);
if (idx > best) best = idx;
}
return ERA_ORDER[best];
}
export function eraSkin(era: Era): EraSkin {
return SKINS[era];
}