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]; +}