From 18f65756602e9cb8fe75646c44a38ff297fe4bb7 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 18:20:50 +1000 Subject: [PATCH] [screen] regional moire, strain warps, 3-frame scram growthMask() is the reusable spatial-artifact template: an fbm-ragged growth front creeping outward from a deterministic seed. The raggedness is load-bearing -- a clean circle reads as a spotlight, not as spreading crystal. Moire separates area from intensity: intensity saturates early while the mask keeps growing with tally, so a big shipment reads as "the maze has spread" rather than "everything got slightly shimmery". Seeded from the tick the crystal first landed. Tremor is ~1/4 of the brownout's judder and fever is a smooth haze against tremor's random jitter, so dread, fever and seizure stay distinguishable. Also clamps dt at both ends: a negative dt (clock rewind) turned ease()'s 1-exp(-dt/tau) into a huge negative multiplier and threw eased values into orbit. Unreachable from main.ts's monotonic clock, but not a landmine worth keeping. Co-Authored-By: Claude Opus 4.8 --- fktry/src/screen/glitchStack.ts | 73 ++++++++++++++++++++++++++++----- fktry/src/screen/index.ts | 69 ++++++++++++++++++++++++++----- 2 files changed, 121 insertions(+), 21 deletions(-) diff --git a/fktry/src/screen/glitchStack.ts b/fktry/src/screen/glitchStack.ts index 1042cc9..222d161 100644 --- a/fktry/src/screen/glitchStack.ts +++ b/fktry/src/screen/glitchStack.ts @@ -29,6 +29,8 @@ uniform float t; uniform float u_mosh, u_melt, u_chroma, u_tear, u_roll, u_burn, u_noise, u_freeze; uniform float u_block, u_ring, u_moire; uniform float u_drift, u_brownout, u_flash; +uniform float u_strain, u_fever; +uniform vec2 u_moireSeed; uniform sampler2D u_stampTex, u_scramTex; uniform float u_stampAmt, u_stampScale, u_scram, u_scramY; @@ -41,6 +43,21 @@ float vnoise(vec2 p){ float fbm(vec2 p){ return 0.5*vnoise(p)+0.25*vnoise(p*2.3)+0.125*vnoise(p*5.1); } float lu(vec3 c){ return dot(c, vec3(0.299,0.587,0.114)); } +// REUSABLE SPATIAL-ARTIFACT TEMPLATE (round 2 proposal, round 3 build). +// An irregular growth front creeping outward from the seed point. grow=0 is nothing, grow=1 has +// swallowed the frame. The fbm term makes the front ragged and organic instead of a +// tidy circle, so the artifact spreads like the codex's maze walls rather than a spotlight. +// Any future artifact that should GROW FROM A PLACE rather than fade in everywhere should +// multiply its amount by this. +float growthMask(vec2 p, vec2 seed, float grow){ + vec2 d = p - seed; + d.x *= 1.78; // 16:9 correction: grow circles, not ellipses + float r = length(d); + float ragged = (fbm(d*3.2 + seed*7.0) - 0.5) * 0.38; + float front = grow * 1.35; // reach past the corners at grow=1 + return smoothstep(front + 0.10, front - 0.10, r + ragged); +} + void main(){ // freeze quantizes the timebase everything else animates on. // A brownout slams the whole timebase into a hard stutter regardless of freeze. @@ -51,6 +68,20 @@ void main(){ // slow drift of the clean feed: bounded sinusoid, so no wrap seam uv += vec2(sin(t*0.07)*0.004, cos(t*0.05)*0.003) * u_drift; + // signal strain: the reserve is draining. A tremor, not a seizure — deliberately about a + // quarter of the brownout's amplitude, so dread reads as dread and the brownout still lands. + if(u_strain > 0.001){ + float js = floor(tt*9.0); + uv.x += (hash(vec2(js, 17.0))-0.5) * u_strain * 0.017; + uv.y += (hash(vec2(js, 23.0))-0.5) * u_strain * 0.011; + } + + // fever: the decoders are running hot. Slow heat-haze shimmer, no judder. + if(u_fever > 0.001){ + uv.x += sin(uv.y*38.0 + tt*1.7) * u_fever * 0.0045; + uv.y += sin(uv.x*26.0 + tt*1.1) * u_fever * 0.0030; + } + // desync judder: the frame itself loses its footing during a brownout if(u_brownout > 0.001){ float j = floor(tt*12.0); @@ -125,15 +156,21 @@ void main(){ col += (col - blur) * u_ring * 1.5; // overshoot at the edge itself } - // ---- moire: two near-identical lattices beating against the sensor grid + // ---- moire: two near-identical lattices beating against the sensor grid. + // REGIONAL: the crystal GROWS from a seeded place and creeps outward with the tally, rather + // than fading in across the whole frame. Intensity saturates early while AREA keeps growing, + // so a big shipment reads as "the maze has spread", not "everything got slightly shimmery". if(u_moire > 0.001){ - float ang = 0.06 + 0.02*sin(tt*0.07); - vec2 r = vec2(vUv.x*cos(ang)-vUv.y*sin(ang), vUv.x*sin(ang)+vUv.y*cos(ang)); - float a = sin(vUv.x*640.0*0.55 + vUv.y*3.0); - float b = sin(r.x*640.0*0.575 + r.y*2.0 + tt*0.25); - float m = a*b; // the beat: swirling maze lattice - vec3 sheen = 0.5+0.5*cos(6.2831*(vec3(0.0,0.33,0.67) + m*1.2 + tt*0.03)); - col = mix(col, col*0.4 + sheen*0.7, u_moire*clamp(abs(m)*1.5, 0., 1.)); + float mo = clamp(u_moire*2.2, 0.0, 1.0) * growthMask(vUv, u_moireSeed, u_moire); + if(mo > 0.001){ + float ang = 0.06 + 0.02*sin(tt*0.07); + vec2 r = vec2(vUv.x*cos(ang)-vUv.y*sin(ang), vUv.x*sin(ang)+vUv.y*cos(ang)); + float a = sin(vUv.x*640.0*0.55 + vUv.y*3.0); + float b = sin(r.x*640.0*0.575 + r.y*2.0 + tt*0.25); + float m = a*b; // the beat: swirling maze lattice + vec3 sheen = 0.5+0.5*cos(6.2831*(vec3(0.0,0.33,0.67) + m*1.2 + tt*0.03)); + col = mix(col, col*0.4 + sheen*0.7, mo*clamp(abs(m)*1.5, 0., 1.)); + } } // static @@ -161,6 +198,9 @@ void main(){ col = mix(col, rim2, edge * (1.0 - smoothstep(0.05, 0.35, edge)) * 2.0 * u_burn); col = mix(col, vec3(0.02), smoothstep(0.3, 1.0, edge) * step(0.01, u_burn)); + // fever runs warm: the picture itself develops a temperature + col = mix(col, col*vec3(1.12,0.98,0.88), u_fever*0.55); + // faint scanlines for vibe (constant, not scored) col *= 0.92 + 0.08 * sin(vUv.y * 360.0 * 3.1415); @@ -191,7 +231,7 @@ void main(){ }`; type UniformKey = - | Param | 't' | 'drift' | 'brownout' | 'flash' + | Param | 't' | 'drift' | 'brownout' | 'flash' | 'strain' | 'fever' | 'moireSeed' | 'stampTex' | 'scramTex' | 'stampAmt' | 'stampScale' | 'scram' | 'scramY'; export interface DrawState { @@ -205,6 +245,12 @@ export interface DrawState { flash: number; /** nothing shipped yet — the clean feed still carries its chyron */ idle: boolean; + /** 0..1 tremor from the draining bandwidth reserve (weather, not climate) */ + strain: number; + /** 0..1 feverish shimmer from aggregate machine heat */ + fever: number; + /** where the moire crystal grows from, in screen uv */ + moireSeed: [number, number]; /** 0..1 opacity of the shipment stamp */ stampAmt: number; /** stamp size multiplier — bigger shipments stamp harder */ @@ -285,6 +331,9 @@ export function createGlitchStack( uni.drift = gl.getUniformLocation(prog, 'u_drift'); uni.brownout = gl.getUniformLocation(prog, 'u_brownout'); uni.flash = gl.getUniformLocation(prog, 'u_flash'); + uni.strain = gl.getUniformLocation(prog, 'u_strain'); + uni.fever = gl.getUniformLocation(prog, 'u_fever'); + uni.moireSeed = gl.getUniformLocation(prog, 'u_moireSeed'); uni.stampAmt = gl.getUniformLocation(prog, 'u_stampAmt'); uni.stampScale = gl.getUniformLocation(prog, 'u_stampScale'); uni.scram = gl.getUniformLocation(prog, 'u_scram'); @@ -299,7 +348,8 @@ export function createGlitchStack( gl.uniform1f(uni.drift, 1); return { - draw({ params, timeSec, drift, brownout, flash, idle, stampAmt, stampScale, scram, scramY, stampDirty }) { + draw({ params, timeSec, drift, brownout, flash, idle, strain, fever, moireSeed, + stampAmt, stampScale, scram, scramY, stampDirty }) { // Re-upload the clean feed only when it actually changed (timecode tick / chyron). if (base.update(timeSec * 1000, idle)) { gl.activeTexture(gl.TEXTURE0); @@ -315,6 +365,9 @@ export function createGlitchStack( gl.uniform1f(uni.drift, drift); gl.uniform1f(uni.brownout, brownout); gl.uniform1f(uni.flash, flash); + gl.uniform1f(uni.strain, strain); + gl.uniform1f(uni.fever, fever); + gl.uniform2f(uni.moireSeed, moireSeed[0], moireSeed[1]); gl.uniform1f(uni.stampAmt, stampAmt); gl.uniform1f(uni.stampScale, stampScale); gl.uniform1f(uni.scram, scram ? 1 : 0); diff --git a/fktry/src/screen/index.ts b/fktry/src/screen/index.ts index 61f032a..277c0dd 100644 --- a/fktry/src/screen/index.ts +++ b/fktry/src/screen/index.ts @@ -9,12 +9,13 @@ * have ever shipped decides the intensity. Shipments arrive as waves, not switches — * every param eases toward its target. */ -import type { GameData, ItemDef, ScreenFX, SimEvent } from '../contracts'; +import type { GameData, ItemDef, ScreenFX, SimEvent, SimSnapshot } from '../contracts'; import { createBaseFeed } from './baseTexture'; import { createGlitchStack, type GlitchStack } from './glitchStack'; import { createOverlays } from './overlays'; -import { resolveLedger, unmappedItems, ESCALATION_ITEMS } from './corruptionMap'; +import { corruptionFor, resolveLedger, unmappedItems, ESCALATION_ITEMS } from './corruptionMap'; import { PARAMS, ease, zeroParams, type ParamSet } from './params'; +import { computeStrain } from './strain'; /** Seconds for a param to travel ~63% toward its target. Shipments feel like waves. */ const EASE_TAU = 1.2; @@ -29,9 +30,27 @@ const WOBBLE_RATE = [0.13, 0.19, 0.11, 0.23, 0.17, 0.29, 0.31, 0.07, 0.09, 0.37, const STAMP_LIFE = 0.4; /** A burst of shipments must not become a strobe: at most this many stamps wait their turn. */ const STAMP_QUEUE_MAX = 2; +/** Round 3: one frame was a flinch, not a legend. Three frames is readable. */ +const SCRAM_FRAMES = 3; +/** Tremor tracks the reserve fairly tightly; fever is a slow burn. */ +const STRAIN_TAU = 0.35; +const FEVER_TAU = 1.5; interface Stamp { name: string; count: number; color: string; age: number } +/** + * Where the moire crystal takes root. Deterministic in the tick it first appeared, so a given + * run always grows the same maze, while different runs grow it somewhere else. Kept off the + * edges so the growth reads as spreading rather than creeping in from off-screen. + */ +function seedFromTick(tick: number): [number, number] { + const h = (n: number) => { + const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453; + return x - Math.floor(x); + }; + return [0.25 + h(tick + 1) * 0.5, 0.25 + h(tick + 99) * 0.5]; +} + const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); function smoothstep(e0: number, e1: number, x: number): number { const t = clamp01((x - e0) / (e1 - e0)); @@ -59,9 +78,14 @@ export function createScreenFX(): ScreenFX { const stampQueue: Stamp[] = []; let stamp: Stamp | null = null; let stampDirty = false; - let pendingScram = false; + let scramFrames = 0; let scramY = 0.5; + let strain = 0; + let fever = 0; + let moireSeed: [number, number] = [0.5, 0.44]; + let moireSeeded = false; + let lastMs = 0; let frameCost = 0; // rolling avg ms of this module's CPU work @@ -73,7 +97,7 @@ export function createScreenFX(): ScreenFX { ledgerDirty = false; } - function ship(item: string, count: number) { + function ship(item: string, count: number, tick = 0) { if (count <= 0) return; tally.set(item, (tally.get(item) ?? 0) + count); ledgerDirty = true; @@ -81,6 +105,11 @@ export function createScreenFX(): ScreenFX { hasShipped = true; pendingFlash = true; // the awakening } + // The crystal takes root where and when it first arrived, and grows from there. + if (!moireSeeded && (corruptionFor(item).weights.moire ?? 0) > 0) { + moireSeed = seedFromTick(tick); + moireSeeded = true; + } // One stamp per EVENT, not per unit: a 40-unit shipment is one big stamp. const def = items.get(item); stampQueue.push({ @@ -111,19 +140,26 @@ export function createScreenFX(): ScreenFX { (window as any).__fktryScreen = { ship, brownout: (on: boolean) => (brownoutTarget = on ? 1 : 0), - scram: () => { pendingScram = true; scramY = 0.5; }, + scram: () => { scramFrames = SCRAM_FRAMES; scramY = 0.5; }, reset: () => { tally.clear(); ledgerDirty = true; hasShipped = false; stamp = null; stampQueue.length = 0; + moireSeeded = false; + moireSeed = [0.5, 0.44]; + strain = 0; + fever = 0; for (const p of PARAMS) eased[p] = 0; }, stats: () => ({ units: +units.toFixed(2), total: +total.toFixed(3), idle: !hasShipped, + strain: +strain.toFixed(3), + fever: +fever.toFixed(3), + moireSeed: moireSeed.map((n) => +n.toFixed(3)), brownout: +brownout.toFixed(2), msPerFrame: +frameCost.toFixed(3), tally: Object.fromEntries(tally), @@ -135,26 +171,36 @@ export function createScreenFX(): ScreenFX { onEvents(events: SimEvent[]) { for (const e of events) { - if (e.kind === 'shipped') ship(e.item, e.count); + if (e.kind === 'shipped') ship(e.item, e.count, e.tick); else if (e.kind === 'brownout') brownoutTarget = e.on ? 1 : 0; else if (e.kind === 'scram' && e.on) { // A machine scrammed: the tape loses tracking. Bar position is derived from the // event rather than random, so the same run always wipes the same way. - pendingScram = true; + scramFrames = SCRAM_FRAMES; scramY = 0.15 + (((e.entity * 37 + e.tick * 13) % 100) / 100) * 0.7; } } }, - frame(timeMs: number) { + frame(timeMs: number, snap?: SimSnapshot) { if (!stack) return; const t0 = performance.now(); - const dt = lastMs === 0 ? 1 / 60 : Math.min(0.1, (timeMs - lastMs) / 1000); + // Clamp BOTH ends. A negative dt (clock rewind, a paused tab resuming oddly, a test + // harness restarting its timebase) turns ease()'s 1-exp(-dt/tau) into a huge negative + // multiplier and throws every eased value into orbit. The ceiling stops long stalls + // from snapping params; the floor stops that. + const dt = lastMs === 0 ? 1 / 60 : Math.min(0.1, Math.max(0, (timeMs - lastMs) / 1000)); lastMs = timeMs; if (ledgerDirty) recompute(); brownout = ease(brownout, brownoutTarget, dt, BROWNOUT_TAU); + // v3: read-only peek. Collapsed to two scalars immediately — the snapshot is never + // retained across frames, per the contract invariant. + const target = computeStrain(snap); + strain = ease(strain, target.tremor, dt, STRAIN_TAU); + fever = ease(fever, target.fever, dt, FEVER_TAU); + const timeSec = timeMs / 1000; const wobble = total > WOBBLE_THRESHOLD ? (total - WOBBLE_THRESHOLD) / (1 - WOBBLE_THRESHOLD) : 0; @@ -191,10 +237,11 @@ export function createScreenFX(): ScreenFX { const flash = pendingFlash ? 1 : 0; stack.draw({ params: out, timeSec, drift: 1, brownout, flash, idle: !hasShipped, - stampAmt, stampScale, scram: pendingScram, scramY, stampDirty, + strain, fever, moireSeed, + stampAmt, stampScale, scram: scramFrames > 0, scramY, stampDirty, }); pendingFlash = false; - pendingScram = false; + if (scramFrames > 0) scramFrames--; stampDirty = false; frameCost = frameCost * 0.95 + (performance.now() - t0) * 0.05;