From d8111feb12fecd3b3a8bcca2b7f85efe63de1d06 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 13:51:03 +1000 Subject: [PATCH] [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 --- fktry/src/screen/baseTexture.ts | 121 ++++++++++++++++++++++++-------- fktry/src/screen/eras.test.ts | 54 ++++++++++++++ fktry/src/screen/eras.ts | 104 +++++++++++++++++++++++++++ 3 files changed, 248 insertions(+), 31 deletions(-) create mode 100644 fktry/src/screen/eras.test.ts create mode 100644 fktry/src/screen/eras.ts diff --git a/fktry/src/screen/baseTexture.ts b/fktry/src/screen/baseTexture.ts index 4fe77fe..cb03e72 100644 --- a/fktry/src/screen/baseTexture.ts +++ b/fktry/src/screen/baseTexture.ts @@ -1,25 +1,34 @@ /** * 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. + * A sterile SMPTE-ish test pattern + camcorder overlay, ported from the prototype and widened + * to 16:9. This is what the factory's output is painted ONTO — bland enough that any corruption + * 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_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 { canvas: HTMLCanvasElement; /** 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 { - // 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); @@ -27,29 +36,77 @@ function timecode(timeMs: number): string { 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 { 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; + let last = ''; - 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) => { + function paint(state: BaseState) { + const skin = eraSkin(state.era); + g.fillStyle = skin.bg; + g.fillRect(0, 0, BASE_W, BASE_H); + + const bw = BASE_W / skin.bars.length; + skin.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) => { + skin.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.fillStyle = skin.bg; 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'); @@ -58,36 +115,38 @@ export function createBaseFeed(): BaseFeed { g.fillRect(BASE_W * 0.55, BASE_H * 0.8, BASE_W * 0.4, 18); // camcorder overlay - g.fillStyle = '#eee'; + g.fillStyle = skin.ink; g.font = "bold 22px 'Courier New', monospace"; 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.fillStyle = '#f4f4f4'; g.fillText('WIMVEE FKTRY', BASE_W * 0.55, BASE_H * 0.95); - g.fillStyle = '#ff4444'; + g.fillStyle = skin.accent; 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) { + applyTreatment(g, skin); + + // 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.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); + g.fillText(state.chyron, 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); + update(state: BaseState) { + // Cache key of every visible input; repaint only when one changes. + const key = `${timecode(state.timeMs)}|${state.era}|${state.chyron}`; + if (key === last) return false; + last = key; + paint(state); return true; }, }; diff --git a/fktry/src/screen/eras.test.ts b/fktry/src/screen/eras.test.ts new file mode 100644 index 0000000..433d839 --- /dev/null +++ b/fktry/src/screen/eras.test.ts @@ -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)[t]; + +describe('era skins cover the codex', () => { + it('has a distinct skin for every era', () => { + const bars = new Set(); + 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; + } + }); +}); diff --git a/fktry/src/screen/eras.ts b/fktry/src/screen/eras.ts new file mode 100644 index 0000000..eff6a4a --- /dev/null +++ b/fktry/src/screen/eras.ts @@ -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 = { + 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]; +}