Zum Inhalt

src/engine/fx/crt-sim.ts

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
// CRT simulation — composite of all signal-degradation artifacts a real CRT
// monitor would produce. Replaces the simple `glitch` clip-path animation.
//
// Per tick (interval scales inversely with intensity), the runner picks ONE
// artifact at random — weighted so common subtle effects (h-tear, brightness
// flicker) fire often, while rare violent ones (v-roll, rolling band) fire
// occasionally. Both frequency and amplitude scale with intensity.
//
// Artifacts implemented:
//   • H-sync tear        — sudden horizontal slice shift
//   • V-sync roll        — image rolls vertically and snaps back
//   • Brightness flicker — luminance pulse via filter:brightness
//   • Black frame drop   — momentary opacity 0 (~30 ms)
//   • Rolling band       — VHS-style bright band sliding top → bottom
//   • Chroma spike       — chromaticAberration uniform pushed up briefly
//   • Wave distortion    — temporary skew transform
//   • Static burst       — single-frame noise overlay (separate ks-noise)
//
// Side effects only on the WebGL canvas + a small overlay div for the rolling
// band. No DOM lookups in the hot loop — refs cached at construction.

import type { ScreensaverSettings } from '../data/defaults';
import type { Engine } from '../engine/core';

export class CrtSim {
  private rafId = 0;
  private nextTickAt = 0;

  private bandEl: HTMLDivElement | null = null;

  // Track active artifact timeouts so we can clear them on dispose.
  private timeouts: number[] = [];

  // Crash sequence (the diegetic loop seam): while true, the ambient tick
  // loop holds off so random artifacts don't fight the choreographed crash.
  private crashing = false;
  private crashOverlay: HTMLDivElement | null = null;
  private collapseEl: HTMLDivElement | null = null;

  // Saved baselines so we can restore cleanly when an artifact ends.
  private baseChromaOffset = 0;

  /**
   * @param scanlinesEl  optional reference to the HUD's scanline overlay div.
   *                     When provided, additional scanline-specific artifacts
   *                     fire (pulse, hum bar, interlace flicker, h-tear sync).
   */
  constructor(
    public canvas: HTMLCanvasElement,
    public overlayHost: HTMLElement,
    public engine: Engine,
    public settings: ScreensaverSettings,
    public scanlinesEl: HTMLElement | null = null,
  ) {
    this.baseChromaOffset = engine.chromaPass.uniforms.offset.value;
  }

  private humBarEl: HTMLDivElement | null = null;

  start() {
    if (this.rafId) return;   // idempotent — the power bridge may re-enter while running
    this.nextTickAt = performance.now() + 600;
    this.rafId = requestAnimationFrame(this.tick);
  }

  stop() {
    cancelAnimationFrame(this.rafId);
    this.rafId = 0;   // lets the start() idempotency guard re-arm
    this.timeouts.splice(0).forEach((id) => clearTimeout(id));
    // Clearing the timeouts above can orphan an in-flight artifact's restore
    // step — reset the transient styles so a tear/flicker isn't baked into a
    // frozen frame (power bridge freezes hold the last frame for minutes).
    this.canvas.style.transform = '';
    this.canvas.style.filter = '';
    this.canvas.style.opacity = '';
    if (this.scanlinesEl) this.scanlinesEl.style.transform = '';
    this.engine.chromaPass.uniforms.offset.value = this.baseChromaOffset;
  }

  dispose() {
    this.stop();
    this.bandEl?.remove();
    this.bandEl = null;
    this.humBarEl?.remove();
    this.humBarEl = null;
    this.crashing = false;
    this.crashOverlay?.remove();
    this.crashOverlay = null;
    this.collapseEl?.remove();
    this.collapseEl = null;
    // Reset any lingering canvas styles
    this.canvas.style.transform = '';
    this.canvas.style.filter = '';
    this.canvas.style.opacity = '';
    this.canvas.style.transition = '';
    // Reset scanline element styles we may have touched. (No backgroundPositionY
    // reset: the always-attached ks-drift CSS animation owns background-position-y
    // — see hud/index.ts buildDOM — and the interlace flicker uses transform.)
    if (this.scanlinesEl) {
      this.scanlinesEl.style.transform = '';
      this.scanlinesEl.style.transition = '';
    }
    // Reset chroma uniform if we changed it
    this.engine.chromaPass.uniforms.offset.value = this.baseChromaOffset;
  }

  /** Update settings ref (live tuning while screensaver is open). */
  setSettings(s: ScreensaverSettings) { this.settings = s; }

  // ── Tick scheduler ─────────────────────────────────────────────────────

  private tick = (now: number) => {
    this.rafId = requestAnimationFrame(this.tick);
    if (this.crashing) return;
    if (!this.settings.crtSim.on) return;
    if (now < this.nextTickAt) return;

    const i = Math.max(0, Math.min(1, this.settings.crtSim.intensity));
    // Tick interval: high intensity = frequent (≈400 ms), low = sparse (≈3500 ms).
    const baseInterval = 3500 - i * 3000;
    const jitter = baseInterval * 0.6;
    this.nextTickAt = now + baseInterval + Math.random() * jitter;

    this.fireOneArtifact(i);
  };

  /** Pick one artifact based on weighted probabilities, fire it. */
  private fireOneArtifact(i: number) {
    // Weights — common subtle effects high, rare violent ones low.
    // Scanline-targeted artifacts only contribute weight if scanlinesEl exists.
    const hasScan = this.scanlinesEl != null;
    const weights: Array<[() => void, number]> = [
      [() => this.fireHTear(i),                28],
      [() => this.fireBrightnessFlicker(i),    20],
      [() => this.fireChromaSpike(i),          12],
      [() => this.fireWaveDistort(i),          10],
      [() => this.fireBlackFrame(i),            8],
      [() => this.fireStaticBurst(i),           7],
      [() => this.fireRollingBand(i),           5],
      [() => this.fireVRoll(i),                 4],
      // Scanline-specific artifacts
      [() => this.fireScanlinePulse(i),         hasScan ? 14 : 0],
      [() => this.fireHumBar(i),                hasScan ? 7  : 0],
      [() => this.fireInterlaceFlicker(i),      hasScan ? 9  : 0],
      [() => this.fireScanlineHTear(i),         hasScan ? 6  : 0],
    ];
    const total = weights.reduce((a, [, w]) => a + w, 0);
    let r = Math.random() * total;
    for (const [fn, w] of weights) {
      r -= w;
      if (r <= 0) { fn(); return; }
    }
  }

  private after(ms: number, fn: () => void) {
    const id = window.setTimeout(() => {
      const ix = this.timeouts.indexOf(id);
      if (ix >= 0) this.timeouts.splice(ix, 1);
      fn();
    }, ms);
    this.timeouts.push(id);
  }

  // ── Artifacts ──────────────────────────────────────────────────────────

  /** Sudden horizontal shift simulating loss of horizontal sync. */
  private fireHTear(i: number) {
    const dx = (Math.random() - 0.5) * (8 + i * 32);
    this.canvas.style.transition = 'none';
    this.canvas.style.transform = `translateX(${dx.toFixed(1)}px)`;
    this.after(40 + Math.random() * 80, () => {
      this.canvas.style.transform = '';
    });
  }

  /** Image rolls vertically attempting to re-lock, then snaps back. */
  private fireVRoll(i: number) {
    const dy = (40 + Math.random() * 60) * i;
    this.canvas.style.transition = 'transform .15s ease-out';
    this.canvas.style.transform = `translateY(${dy.toFixed(1)}px)`;
    this.after(150, () => {
      this.canvas.style.transition = 'transform .35s ease-in';
      this.canvas.style.transform = '';
      this.after(380, () => { this.canvas.style.transition = ''; });
    });
  }

  /** Luminance pulse — image brightens or dims briefly. */
  private fireBrightnessFlicker(i: number) {
    const direction = Math.random() < 0.6 ? 'down' : 'up';
    const b = direction === 'down' ? 1 - 0.3 * i - Math.random() * 0.2 : 1 + 0.3 * i + Math.random() * 0.4;
    this.canvas.style.filter = `brightness(${b.toFixed(2)})`;
    this.after(60 + Math.random() * 90, () => {
      this.canvas.style.filter = '';
    });
  }

  /** Single-frame blackout — sync drop. */
  private fireBlackFrame(_i: number) {
    this.canvas.style.opacity = '0';
    this.after(20 + Math.random() * 30, () => {
      this.canvas.style.opacity = '';
    });
  }

  /** VHS-style bright band sliding top → bottom across the image. */
  private fireRollingBand(i: number) {
    if (!this.bandEl) {
      this.bandEl = document.createElement('div');
      this.bandEl.setAttribute('style',
        'position:absolute;left:0;right:0;height:80px;z-index:32;pointer-events:none;' +
        'background:linear-gradient(to bottom, transparent 0%, rgba(255,255,255,.18) 50%, transparent 100%);' +
        'mix-blend-mode:screen;top:-100px;opacity:0');
      this.overlayHost.appendChild(this.bandEl);
    }
    const el = this.bandEl;
    const dur = 1.2 + Math.random() * 1.0;
    const opacity = 0.35 + Math.random() * 0.4 * i;
    el.style.transition = 'none';
    el.style.top = '-100px';
    el.style.opacity = String(opacity);
    requestAnimationFrame(() => {
      el.style.transition = `top ${dur}s linear`;
      el.style.top = '110%';
      this.after(dur * 1000, () => { el.style.opacity = '0'; });
    });
  }

  /** Chromatic aberration uniform spikes briefly — RGB channels tear apart. */
  private fireChromaSpike(i: number) {
    const spike = this.baseChromaOffset + (0.003 + Math.random() * 0.006) * i;
    this.engine.chromaPass.uniforms.offset.value = spike;
    // Force pass on if intensity is meaningful enough to be visible
    const wasEnabled = this.engine.chromaPass.enabled;
    this.engine.chromaPass.enabled = true;
    this.after(80 + Math.random() * 120, () => {
      this.engine.chromaPass.uniforms.offset.value = this.baseChromaOffset;
      this.engine.chromaPass.enabled = wasEnabled;
    });
  }

  /** Skew distortion — magnetic interference / signal warp. */
  private fireWaveDistort(i: number) {
    const skewX = (Math.random() - 0.5) * 4 * i;
    const skewY = (Math.random() - 0.5) * 1.2 * i;
    this.canvas.style.transition = 'none';
    this.canvas.style.transform = `skew(${skewX.toFixed(2)}deg, ${skewY.toFixed(2)}deg)`;
    this.after(80 + Math.random() * 100, () => {
      this.canvas.style.transform = '';
    });
  }

  // ── Scanline-specific artifacts (only when scanlinesEl provided) ──────

  /** Scanline opacity briefly spikes — like the CRT phosphors flaring. */
  private fireScanlinePulse(i: number) {
    if (!this.scanlinesEl) return;
    const cur = this.scanlinesEl.style.opacity;
    const baseOp = parseFloat(cur || '0.5') || 0.5;
    const spike = Math.min(1.0, baseOp * (1.4 + i * 1.2));
    this.scanlinesEl.style.opacity = spike.toFixed(3);
    this.after(140 + Math.random() * 220, () => {
      if (this.scanlinesEl) this.scanlinesEl.style.opacity = cur;
    });
  }

  /**
   * VHS / electrical hum bar — thin band of darker scanlines slowly traveling
   * top → bottom. Distinct from the white rolling band: this one DARKENS.
   */
  private fireHumBar(i: number) {
    if (!this.humBarEl) {
      this.humBarEl = document.createElement('div');
      this.humBarEl.setAttribute('style',
        'position:absolute;left:0;right:0;height:48px;z-index:30;pointer-events:none;' +
        'background:repeating-linear-gradient(' +
          'to bottom,' +
          'transparent 0px, transparent 1px,' +
          'rgba(0,0,0,.45) 1px, rgba(0,0,0,.45) 2px,' +
          'transparent 2px, transparent 4px' +
        ');' +
        'mix-blend-mode:multiply;top:-60px;opacity:0');
      this.overlayHost.appendChild(this.humBarEl);
    }
    const el = this.humBarEl;
    const dur = 2.5 + Math.random() * 2.5;
    const opacity = 0.45 + Math.random() * 0.4 * i;
    el.style.transition = 'none';
    el.style.top = '-60px';
    el.style.opacity = opacity.toFixed(3);
    requestAnimationFrame(() => {
      el.style.transition = `top ${dur}s linear`;
      el.style.top = '110%';
      this.after(dur * 1000, () => { el.style.opacity = '0'; });
    });
  }

  /**
   * Interlace flicker — rapid toggle of scanline pattern offset, simulating
   * a 60-Hz CRT showing alternating odd/even scan fields out of phase.
   */
  private fireInterlaceFlicker(i: number) {
    if (!this.scanlinesEl) return;
    const flicks = 5 + Math.floor(i * 8);
    let count = 0;
    let toggle = false;
    // transform, NOT background-position-y: the always-attached ks-drift CSS
    // keyframes animation (hud/index.ts buildDOM) owns background-position-y
    // and beats inline styles in the cascade — an inline write there is a
    // silent no-op. translateY shifts the whole pattern instead and composes
    // with the drift. The chain restores transform to '' in its final timeout.
    const flick = () => {
      if (!this.scanlinesEl) return;
      if (count >= flicks) { this.scanlinesEl.style.transform = ''; return; }
      this.scanlinesEl.style.transform = toggle ? '' : 'translateY(2px)';
      toggle = !toggle;
      count++;
      this.after(35 + Math.random() * 25, flick);
    };
    flick();
  }

  /** Coordinated horizontal tear on canvas + scanline pattern (sync glitch). */
  private fireScanlineHTear(i: number) {
    this.fireHTear(i);
    if (!this.scanlinesEl) return;
    const dx = (Math.random() - 0.5) * (6 + 18 * i);
    const prevTrans = this.scanlinesEl.style.transition;
    const prevTransform = this.scanlinesEl.style.transform;
    this.scanlinesEl.style.transition = 'none';
    this.scanlinesEl.style.transform = `translateX(${dx.toFixed(1)}px)`;
    this.after(50 + Math.random() * 70, () => {
      if (!this.scanlinesEl) return;
      this.scanlinesEl.style.transform = prevTransform;
      this.scanlinesEl.style.transition = prevTrans;
    });
  }

  /** Single-frame static burst — random pixel noise overlay. */
  private fireStaticBurst(i: number) {
    const c = document.createElement('canvas');
    const W = 240, H = 140;
    c.width = W; c.height = H;
    const cx = c.getContext('2d');
    if (!cx) return;
    const img = cx.createImageData(W, H);
    const density = 0.35 + 0.55 * i;
    for (let p = 0; p < img.data.length; p += 4) {
      const v = Math.random() < density ? 255 : 0;
      img.data[p] = img.data[p + 1] = img.data[p + 2] = v;
      img.data[p + 3] = Math.random() * 110 * i;
    }
    cx.putImageData(img, 0, 0);
    const url = c.toDataURL();
    const overlay = document.createElement('div');
    overlay.setAttribute('style',
      `position:absolute;inset:0;z-index:33;pointer-events:none;background:url(${url});` +
      'mix-blend-mode:screen;opacity:0.55;background-size:cover');
    this.overlayHost.appendChild(overlay);
    this.after(40 + Math.random() * 50, () => overlay.remove());
  }

  // ── Crash sequence — the diegetic loop seam ────────────────────────────
  //
  // A deliberate, choreographed "system crash": signal failure escalates,
  // the picture collapses to a CRT power-off line, the screen goes black
  // (caller swaps terminal content here via onBlackout), then power flickers
  // back for a fresh shift. Plays regardless of crtSim.on — it is a narrative
  // beat, not ambient simulation. Resolves once the reboot flicker is done.
  async playCrash(onBlackout?: () => void): Promise<void> {
    if (this.crashing) return;
    this.crashing = true;
    const i = 1;
    // try/finally: a throw anywhere in here must NOT leave `crashing` stuck true
    // — that would silently swallow every future crash (the shift loop only ever
    // crashes once, then never again).
    try {
      // Phase A — escalating signal failure, bursts getting denser + faster.
      for (let k = 0; k < 7; k++) {
        this.fireHTear(i);
        if (k % 2 === 0) this.fireChromaSpike(i);
        if (k % 3 === 0) this.fireStaticBurst(i);
        this.fireScanlinePulse(i);
        if (k >= 4) this.fireInterlaceFlicker(i);
        await this.delay(Math.max(45, 130 - k * 14));
      }
      this.fireStaticBurst(i);

      // Phase B — collapse to black (CRT power-off), then blank the terminal
      // behind the black so the shift change is unseen. Hold all-black ~450ms:
      // long enough that the render pipeline's blackdetect reliably sees it
      // (the collapse line is bright, so only this hold is a fully black frame).
      await this.collapseToBlack();
      onBlackout?.();
      await this.delay(450);

      // Phase C — power returns in an unstable flicker, revealing the new shift.
      await this.rebootFlicker();
    } finally {
      this.crashing = false;
    }
  }

  /**
   * Promise-friendly delay. Deliberately NOT registered with the dispose-tracked
   * timeouts: if stop()/dispose() cleared it mid-crash the await would hang
   * forever, wedging both `crashing` and the narrative's shift-end promise.
   */
  private delay(ms: number): Promise<void> {
    return new Promise((resolve) => window.setTimeout(resolve, ms));
  }

  private makeCrashLayer(extra: string): HTMLDivElement {
    const el = document.createElement('div');
    el.setAttribute('style', 'position:absolute;pointer-events:none;' + extra);
    this.overlayHost.appendChild(el);
    return el;
  }

  /** White flash → black, then a horizontal scanline collapses to a dot. */
  private async collapseToBlack(): Promise<void> {
    const flash = this.makeCrashLayer('inset:0;z-index:61;background:#fff;opacity:0;transition:opacity 40ms linear');
    requestAnimationFrame(() => { flash.style.opacity = '0.85'; });
    await this.delay(55);
    flash.style.opacity = '0';

    this.crashOverlay = this.makeCrashLayer('inset:0;z-index:60;background:#000;opacity:0;transition:opacity 70ms linear');
    const ov = this.crashOverlay;
    requestAnimationFrame(() => { ov.style.opacity = '1'; });
    await this.delay(90);
    flash.remove();

    // Bright line across the middle that squeezes horizontally to a point.
    this.collapseEl = this.makeCrashLayer(
      'z-index:62;top:50%;left:0;right:0;height:3px;margin-top:-1.5px;' +
      'background:#dffbe9;box-shadow:0 0 14px 2px #8fe6b4;opacity:1;' +
      'transform:scaleX(1);transition:transform 210ms ease-in');
    const line = this.collapseEl;
    requestAnimationFrame(() => { line.style.transform = 'scaleX(0.004)'; });
    await this.delay(220);
    line.style.transition = 'opacity 240ms ease-out, height 240ms ease-out';
    line.style.height = '1px';
    line.style.opacity = '0';
  }

  /** Unstable power-on flicker that fades the black overlay away. */
  private async rebootFlicker(): Promise<void> {
    this.collapseEl?.remove();
    this.collapseEl = null;
    const ov = this.crashOverlay;
    if (!ov) return;
    ov.style.transition = 'opacity 45ms linear';
    const seq = [0.45, 1, 0.12, 0.8, 0.05, 0];
    for (const o of seq) {
      ov.style.opacity = String(o);
      if (o > 0.5) this.fireScanlinePulse(1);
      await this.delay(60 + Math.random() * 55);
    }
    ov.remove();
    this.crashOverlay = null;
    // Clear any canvas styles a Phase-A artifact may have left mid-flight.
    this.canvas.style.transform = '';
    this.canvas.style.filter = '';
    this.canvas.style.opacity = '';
  }
}