@dopaminefx/effect-comic 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Comic Impact Canvas2D PANEL drawing + bundled-font loading.
3
+ *
4
+ * The crisp, vector-y parts of Comic Impact — the jagged starburst and the
5
+ * blocky hand-lettered onomatopoeia word with bold ink outlines — are drawn into
6
+ * an OFFSCREEN Canvas2D each frame (cheap: a few paths) and uploaded as the
7
+ * "panel" texture; the fragment shader (comic-shader.ts) adds the Ben-Day
8
+ * halftone, action lines, flash and noir↔pop-art styling. `drawPanel` (the
9
+ * offscreen draw) and `ensureComicFonts` (the bundled-face loader) live here and
10
+ * are consumed by the Comic `EffectFactory` (effects/comic.ts), which owns the
11
+ * GL pass via the shared, program-cached context + the conductor.
12
+ *
13
+ * Panel channel encoding consumed by the shader:
14
+ * R = word fill · G = ink (all black contours) · B = starburst fill
15
+ */
16
+
17
+ import { type ComicRenderParams, isCheckmark } from "./comic-params.js";
18
+ import { mulberry32, type PanelDraw } from "@dopaminefx/core";
19
+ import { impactScale, impactPresence } from "./comic-tempo.js";
20
+ import { EMBEDDED_FACES } from "./comic-fonts.js";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // BUNDLED FONT LOADING
24
+ //
25
+ // The effect must NOT silently depend on a host font being installed, so the
26
+ // SIL OFL display faces (Bangers / Anton / Luckiest Guy) ship base64-embedded
27
+ // (comic-fonts.ts) and are registered via the FontFace API. We kick this off
28
+ // once at module import and await it before the first paint; if it fails for
29
+ // any reason the renderer still draws using the robust fallback stack (and the
30
+ // mood/whimsy difference still reads via the procedural treatment).
31
+ // ---------------------------------------------------------------------------
32
+
33
+ let fontsReady: Promise<void> | null = null;
34
+
35
+ function base64ToArrayBuffer(b64: string): ArrayBuffer {
36
+ const bin = atob(b64);
37
+ const len = bin.length;
38
+ const bytes = new Uint8Array(len);
39
+ for (let i = 0; i < len; i++) bytes[i] = bin.charCodeAt(i);
40
+ return bytes.buffer;
41
+ }
42
+
43
+ /** Register + load the embedded faces once. Resolves even if loading fails. */
44
+ export function ensureComicFonts(): Promise<void> {
45
+ if (fontsReady) return fontsReady;
46
+ if (
47
+ typeof document === "undefined" ||
48
+ typeof FontFace === "undefined" ||
49
+ !(document as Document).fonts
50
+ ) {
51
+ fontsReady = Promise.resolve();
52
+ return fontsReady;
53
+ }
54
+ fontsReady = (async () => {
55
+ await Promise.all(
56
+ EMBEDDED_FACES.map(async (f) => {
57
+ try {
58
+ // Skip if a face by this family is already registered (e.g. host has it).
59
+ const face = new FontFace(f.family, base64ToArrayBuffer(f.base64));
60
+ await face.load();
61
+ (document as Document).fonts.add(face);
62
+ } catch {
63
+ /* fall back to the system stack for this face */
64
+ }
65
+ }),
66
+ );
67
+ try {
68
+ await (document as Document).fonts.ready;
69
+ } catch {
70
+ /* ignore */
71
+ }
72
+ })();
73
+ return fontsReady;
74
+ }
75
+
76
+ // Begin loading as soon as the module is imported so faces are usually ready by
77
+ // the time the user fires the effect.
78
+ if (typeof document !== "undefined") void ensureComicFonts();
79
+
80
+ /**
81
+ * Draw the offscreen panel for this frame: a jagged starburst balloon, the
82
+ * onomatopoeia word in blocky outlined block caps, all at the current impact
83
+ * scale + a tiny rotation tilt. Encodes masks into channels:
84
+ * R = word fill, G = ink (contours), B = burst fill.
85
+ *
86
+ * We draw ink as the GREEN channel and the two fills as RED/BLUE so we can
87
+ * blend them independently in the shader. To keep channels independent we draw
88
+ * each layer onto the same 2D context but only write the intended channel.
89
+ */
90
+ /** Starburst + word size relative to the targeted element box (≈1.5×). See the
91
+ * Swift `COMIC_TARGET_FILL` — keep the two in sync. */
92
+ const COMIC_TARGET_FILL = 1.7;
93
+
94
+ /**
95
+ * The per-frame panel draw in the generic `PanelDraw` shape — the ONE
96
+ * code-shaped hook the data-driven factory wires (`registerDopePanelEffect`).
97
+ * Computes the DRAW-SIDE tempo (the per-letter slam SCALE + the panel presence,
98
+ * which stay code by design — the per-frame values the SHADER reads ride
99
+ * `tempo.frame`) and hands off to {@link drawPanel}.
100
+ */
101
+ export const drawComicFrame: PanelDraw = (pctx, w, h, params, info) => {
102
+ const p = params as unknown as ComicRenderParams;
103
+ const scale = impactScale(info.elapsedMs, p.overshoot);
104
+ const presence = impactPresence(info.life);
105
+ const span = Math.min(info.targetPx.width, info.targetPx.height);
106
+ drawPanel(pctx, w, h, p, scale, presence, info.dpr, info.centerPx, span);
107
+ };
108
+
109
+ export function drawPanel(
110
+ ctx: CanvasRenderingContext2D,
111
+ w: number,
112
+ h: number,
113
+ params: ComicRenderParams,
114
+ scale: number,
115
+ presence: number,
116
+ dpr: number,
117
+ center: { x: number; y: number },
118
+ span: number,
119
+ ): void {
120
+ ctx.clearRect(0, 0, w, h);
121
+ if (presence <= 0.001) return;
122
+
123
+ // Position + size the word/starburst to the targeted element (defaults to the
124
+ // canvas centre + full canvas, reproducing the old screen-centred pose).
125
+ const cx = center.x;
126
+ const cy = center.y;
127
+ // The starburst + word read at ~150% of the targeted element, clamped to the
128
+ // canvas so a full-page fire (target == canvas) keeps its original size. Kept in
129
+ // sync with ComicPanel.swift. TUNABLE.
130
+ const minDim = Math.min(span * COMIC_TARGET_FILL, Math.min(w, h));
131
+ const rng = mulberry32((params.comicSeed * 1000) >>> 0);
132
+
133
+ // Deterministic per-fire tilt so the panel feels hand-placed (a few degrees).
134
+ const tilt = ((params.comicSeed % 1) - 0.5) * 0.18; // ~±5deg
135
+
136
+ // ---------- STARBURST BALLOON (B channel) --------------------------------
137
+ // A classic many-pointed jagged star: alternating long/short radii with
138
+ // per-point jitter. Drawn solid into BLUE; its bold outline into GREEN.
139
+ const points = Math.max(8, Math.round(params.burstPoints));
140
+ const outerR = minDim * params.scale * 1.3 * scale;
141
+ const innerR = outerR * 0.64;
142
+ const burstPts: [number, number][] = [];
143
+ for (let i = 0; i < points * 2; i++) {
144
+ const t = i / (points * 2);
145
+ const a = t * Math.PI * 2 - Math.PI / 2 + tilt;
146
+ const even = i % 2 === 0;
147
+ const jitter = 0.82 + rng() * 0.36;
148
+ const r = (even ? outerR : innerR) * jitter;
149
+ burstPts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
150
+ }
151
+ const tracePath = () => {
152
+ ctx.beginPath();
153
+ burstPts.forEach(([x, y], i) => (i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)));
154
+ ctx.closePath();
155
+ };
156
+
157
+ const ink = params.inkWeight * dpr * scale;
158
+
159
+ // Burst FILL -> BLUE only.
160
+ ctx.save();
161
+ ctx.globalCompositeOperation = "lighter"; // additive into channels
162
+ tracePath();
163
+ ctx.fillStyle = `rgba(0,0,${Math.round(255 * presence)},1)`;
164
+ ctx.fill();
165
+ ctx.restore();
166
+
167
+ // Burst OUTLINE -> GREEN (ink). Thick bold contour.
168
+ ctx.save();
169
+ ctx.globalCompositeOperation = "lighter";
170
+ ctx.lineJoin = "miter";
171
+ ctx.miterLimit = 2;
172
+ tracePath();
173
+ ctx.lineWidth = ink * 1.3;
174
+ ctx.strokeStyle = `rgba(0,${Math.round(255 * presence)},0,1)`;
175
+ ctx.stroke();
176
+ ctx.restore();
177
+
178
+ // ---------- LETTERING (success word) or CHECKMARK ------------------------
179
+ // Mood selects the bundled display face + base character (skew/stretch/tilt);
180
+ // whimsy shifts the treatment from restrained inked caps (noir) to fat,
181
+ // inflated, multi-layer-inked, 3D-extruded, per-letter-bounced pop-art. The
182
+ // owner also wants a big bold ✓ as a selectable option — that's drawn as a
183
+ // VECTOR path (not a font glyph) so it's reliable everywhere.
184
+ const fillA = Math.round(255 * presence);
185
+ const inkStyle = `rgba(0,${fillA},0,1)`;
186
+ const fillStyle = `rgba(${fillA},0,0,1)`;
187
+ const round = params.inkRoundness;
188
+
189
+ // Per-letter / per-shape deterministic jitter, derived from the per-fire seed.
190
+ const jrng = mulberry32((params.comicSeed * 2654435761) >>> 0);
191
+
192
+ ctx.save();
193
+ ctx.translate(cx, cy);
194
+ ctx.rotate(tilt + params.fontTilt);
195
+ // Italic lean + non-uniform stretch as a shared transform on the whole word.
196
+ // matrix: [stretchX, 0, skewX, 1] (a=stretch horiz, c=shear).
197
+ ctx.transform(params.fontStretchX, 0, params.fontSkew, 1, 0, 0);
198
+ ctx.lineJoin = round > 0.5 ? "round" : "miter";
199
+ ctx.lineCap = round > 0.5 ? "round" : "butt";
200
+ ctx.miterLimit = 2;
201
+ ctx.globalCompositeOperation = "lighter"; // additive into channels
202
+
203
+ if (isCheckmark(params.word)) {
204
+ // ----- VECTOR CHECKMARK -----------------------------------------------
205
+ // A bold two-segment tick centred on the panel, sized to the burst's inner
206
+ // span. Drawn as a stroked path; ink contour + 3D extrude + bright fill use
207
+ // the same treatment knobs as the word path below.
208
+ const span = innerR * 1.25; // overall check width
209
+ const strokeW = span * 0.24 * (0.85 + round * 0.25);
210
+ const extrude = span * params.extrudeDepth;
211
+ // Check geometry (down-stroke then long up-flick), centred.
212
+ const pts: [number, number][] = [
213
+ [-span * 0.42, span * 0.02],
214
+ [-span * 0.12, span * 0.34],
215
+ [span * 0.46, -span * 0.36],
216
+ ];
217
+ const traceCheck = () => {
218
+ ctx.beginPath();
219
+ pts.forEach(([x, y], i) => (i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)));
220
+ };
221
+ // 3D extrude: stacked ink copies stepping down-right (pop-art only).
222
+ if (extrude > 0.5) {
223
+ const steps = 8;
224
+ for (let s = steps; s >= 1; s--) {
225
+ const dx = (extrude * s) / steps;
226
+ const dy = (extrude * s) / steps;
227
+ ctx.save();
228
+ ctx.translate(dx, dy);
229
+ traceCheck();
230
+ ctx.lineWidth = strokeW;
231
+ ctx.strokeStyle = inkStyle;
232
+ ctx.stroke();
233
+ ctx.restore();
234
+ }
235
+ }
236
+ // Bold ink contour (heavier toward pop-art via outlineLayers).
237
+ traceCheck();
238
+ ctx.lineWidth = strokeW + ink * (1.2 + params.outlineLayers * 0.5);
239
+ ctx.strokeStyle = inkStyle;
240
+ ctx.stroke();
241
+ // Bright fill body.
242
+ traceCheck();
243
+ ctx.lineWidth = strokeW;
244
+ ctx.strokeStyle = fillStyle;
245
+ ctx.stroke();
246
+ ctx.restore();
247
+ return;
248
+ }
249
+
250
+ // ----- WORD RUN ---------------------------------------------------------
251
+ const word = params.word;
252
+ ctx.textAlign = "center";
253
+ ctx.textBaseline = "middle";
254
+ const fontFor = (px: number) => `${px}px ${params.fontStack}`;
255
+
256
+ // Target size, then SHRINK-TO-FIT so longer words (GREAT!/DONE!) never spill
257
+ // out of the burst. Account for the extra horizontal stretch + tracking.
258
+ let fontPx = minDim * params.scale * 0.92 * scale;
259
+ ctx.font = fontFor(fontPx);
260
+ const chars = [...word];
261
+ const trackPx = () => fontPx * params.fontTracking;
262
+ const runWidth = (): number => {
263
+ let total = 0;
264
+ for (const ch of chars) total += ctx.measureText(ch).width + trackPx();
265
+ return Math.max(1, total - trackPx());
266
+ };
267
+ const maxW = (innerR * 1.7) / Math.max(0.6, params.fontStretchX);
268
+ let measured = runWidth();
269
+ if (measured > maxW) {
270
+ fontPx *= maxW / measured;
271
+ ctx.font = fontFor(fontPx);
272
+ measured = runWidth();
273
+ }
274
+
275
+ const extrude = fontPx * params.extrudeDepth;
276
+ const inkLine = ink * (1.3 + (params.outlineLayers - 1) * 0.7);
277
+
278
+ // Lay out letters individually so we can apply per-letter rotation/baseline
279
+ // jitter (the pop-art bounce). Start at the left edge of the centred run.
280
+ let penX = -measured / 2;
281
+ type Letter = { ch: string; x: number; rot: number; dy: number; wgt: number };
282
+ const letters: Letter[] = chars.map((ch) => {
283
+ const wpx = ctx.measureText(ch).width;
284
+ const x = penX + wpx / 2;
285
+ penX += wpx + trackPx();
286
+ const rot = (jrng() - 0.5) * 2 * params.letterRotJitter;
287
+ const dy = (jrng() - 0.5) * 2 * params.letterBaselineJitter * fontPx;
288
+ return { ch, x, rot, dy, wgt: jrng() };
289
+ });
290
+
291
+ const drawLetters = (
292
+ cb: (ctx: CanvasRenderingContext2D, l: Letter) => void,
293
+ ) => {
294
+ for (const l of letters) {
295
+ ctx.save();
296
+ ctx.translate(l.x, l.dy);
297
+ ctx.rotate(l.rot);
298
+ cb(ctx, l);
299
+ ctx.restore();
300
+ }
301
+ };
302
+
303
+ // 3D extrude / drop: stacked ink copies stepping down-right behind the body
304
+ // (pop-art pops, flat at noir).
305
+ if (extrude > 0.5) {
306
+ const steps = 8;
307
+ for (let s = steps; s >= 1; s--) {
308
+ const dx = (extrude * s) / steps;
309
+ const dy = (extrude * s) / steps;
310
+ drawLetters((c, l) => {
311
+ c.fillStyle = inkStyle;
312
+ c.fillText(l.ch, dx, dy);
313
+ });
314
+ }
315
+ }
316
+
317
+ // Bold INK contour — drawn under the fill so the outline frames the letters.
318
+ // outlineLayers stacks slightly fattening passes for the inflated balloon look.
319
+ for (let layer = params.outlineLayers; layer >= 1; layer--) {
320
+ const lw = inkLine * (1 + (layer - 1) * 0.5);
321
+ drawLetters((c, l) => {
322
+ c.lineJoin = round > 0.5 ? "round" : "miter";
323
+ c.lineWidth = lw;
324
+ c.strokeStyle = inkStyle;
325
+ c.strokeText(l.ch, 0, 0);
326
+ });
327
+ }
328
+
329
+ // Bright FILL body on top.
330
+ drawLetters((c, l) => {
331
+ c.fillStyle = fillStyle;
332
+ c.fillText(l.ch, 0, 0);
333
+ });
334
+
335
+ ctx.restore();
336
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * GLSL ES 3.00 source for **Comic Impact** — Dopamine's third success effect: a
3
+ * Golden/Silver-Age comic-book "BAM! POW!" fight-panel impact.
4
+ *
5
+ * This is a HYBRID effect. Crisp blocky vector lettering and bold ink contours
6
+ * are hard to do well in a pure fragment shader, so the renderer draws the
7
+ * onomatopoeia word + jagged starburst + ink outlines into an OFFSCREEN Canvas2D
8
+ * and hands it to this shader as a single "panel" texture. The shader then does
9
+ * everything that wants to be procedural and screen-space:
10
+ * - Ben-Day / halftone DOT shading (rotated screen, dot radius driven by the
11
+ * underlying value) — subtle/fine at the noir end, loud/large at pop-art.
12
+ * - RADIATING action / speed lines bursting from the impact centre.
13
+ * - A FLASH that throws colored light onto the page (the screen-blend cast).
14
+ * - The NOIR ↔ POP-ART styling: near-monochrome high-contrast chiaroscuro with
15
+ * one spot color → screaming saturated pop, keyed off uStyle/uSaturation.
16
+ *
17
+ * Everything is summed as light (canvas is black, composited via
18
+ * `mix-blend-mode: screen`, so black == no change, bright == cast light).
19
+ *
20
+ * Panel texture channel encoding (see comic-renderer.ts):
21
+ * R = word FILL mask (letter interiors)
22
+ * G = INK mask (all black ink: letter + burst + line outlines)
23
+ * B = burst FILL mask (starburst balloon interior, behind the word)
24
+ * A = unused
25
+ *
26
+ * Pure function of uniforms → frame-perfect & cheap under SwiftShader.
27
+ */
28
+
29
+ import {
30
+ GLSL_CONSTANTS,
31
+ GLSL_DITHER,
32
+ GLSL_HALFTONE,
33
+ GLSL_HASH,
34
+ GLSL_ROT2,
35
+ GLSL_TONEMAP_ACES,
36
+ } from "@dopaminefx/core";
37
+
38
+ export const COMIC_VERTEX_SRC = /* glsl */ `#version 300 es
39
+ out vec2 vUv;
40
+ void main() {
41
+ vec2 pos = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
42
+ vUv = pos;
43
+ gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0);
44
+ }`;
45
+
46
+ export const COMIC_FRAGMENT_SRC = /* glsl */ `#version 300 es
47
+ precision highp float;
48
+ in vec2 vUv;
49
+ out vec4 fragColor;
50
+
51
+ uniform sampler2D uPanel; // R=wordFill G=ink B=burstFill
52
+ uniform vec2 uResolution; // device pixels
53
+ uniform vec2 uTarget; // targeted element size (device px); scales the action lines
54
+ uniform vec2 uOrigin; // impact centre (the anchor), device px
55
+ uniform float uLife; // whole-effect progress 0..1
56
+ uniform float uTimeS; // elapsed seconds
57
+ uniform float uPresence; // panel opacity / presence 0..1
58
+ uniform float uFlash; // 0..1 impact flash amount (fast spike, decays)
59
+ uniform float uExposure; // cast-light gain
60
+ uniform float uHalftone; // 0..1 Ben-Day dot strength
61
+ uniform float uDotSize; // Ben-Day cell size in device px
62
+ uniform float uSaturation; // 0..1 panel color saturation (noir->pop)
63
+ uniform float uActionLines; // count of radiating speed lines
64
+ uniform float uInkBoost; // ink darkness/spread multiplier (pop fattens ink)
65
+ uniform float uSeed; // per-fire hash
66
+ uniform float uStyle; // 0..1 noir -> pop-art (whimsy)
67
+ uniform float uShadow; // 0 = light pass (screen), 1 = shadow pass (multiply)
68
+ uniform vec2 uShadowOffset; // device-px offset of the cast silhouette (away from light)
69
+ uniform float uShadowSoft; // penumbra softness in device px (blur tap radius)
70
+ uniform float uShadowStrength;// 0..1 max darkening of the multiply layer
71
+ uniform vec3 uC0; // word fill color
72
+ uniform vec3 uC1; // secondary / burst color
73
+ uniform vec3 uC2; // dot / accent color
74
+
75
+ ${GLSL_CONSTANTS}
76
+ ${GLSL_HASH}
77
+ ${GLSL_ROT2}
78
+ ${GLSL_HALFTONE}
79
+ ${GLSL_TONEMAP_ACES}
80
+ ${GLSL_DITHER}
81
+
82
+ void main(){
83
+ vec2 frag = vUv * uResolution;
84
+ vec2 res = uResolution;
85
+ float minDim = min(res.x, res.y);
86
+ // The word/burst (panel) are sized to the targeted element box; scale the
87
+ // radiating action lines to the SAME basis so they streak off the word, not the
88
+ // whole canvas. Clamped to the canvas so a full-page fire (uTarget == res) is
89
+ // unchanged. Must match the comic renderer's COMIC_TARGET_FILL (1.7).
90
+ float comicSpan = min(min(uTarget.x, uTarget.y) * 1.7, minDim);
91
+
92
+ // ---- SHADOW PASS (multiply layer) ---------------------------------------
93
+ // Cheap occlusion: the panel's solid forms (word fill + burst fill) sampled
94
+ // at an offset toward the implied key light, with a small ring blur for a
95
+ // penumbra. White = no shadow (multiply identity); darker = cast shadow. The
96
+ // panel already encodes presence, so the shadow fades with the effect.
97
+ if (uShadow > 0.5) {
98
+ vec2 px = 1.0 / res;
99
+ vec2 souv = vUv - uShadowOffset * px;
100
+ float occ = 0.0;
101
+ for (int i = 0; i < 8; i++) {
102
+ float a = float(i) / 8.0 * TAU;
103
+ vec2 o = vec2(cos(a), sin(a)) * uShadowSoft * px;
104
+ vec2 tuv = souv + o;
105
+ // Gate samples that fall OUTSIDE the panel: the texture is CLAMP_TO_EDGE,
106
+ // so without this an offset sample past an edge smears that edge row into
107
+ // a phantom band (the streaks at the top of the frame). Outside == no
108
+ // occluder == no shadow.
109
+ vec2 inb = step(vec2(0.0), tuv) * step(tuv, vec2(1.0));
110
+ float mask = inb.x * inb.y;
111
+ vec4 s = texture(uPanel, tuv);
112
+ occ += clamp(s.r + s.b, 0.0, 1.0) * mask;
113
+ }
114
+ occ /= 8.0;
115
+ float dark = clamp(occ * uShadowStrength, 0.0, 1.0);
116
+ fragColor = vec4(vec3(1.0 - dark), 1.0);
117
+ return;
118
+ }
119
+
120
+ vec2 fromC = frag - uOrigin;
121
+ float rad = length(fromC);
122
+ float ang = atan(fromC.y, fromC.x);
123
+
124
+ vec4 panel = texture(uPanel, vUv);
125
+ float wordFill = panel.r;
126
+ float inkMask = clamp(panel.g * uInkBoost, 0.0, 1.0);
127
+ float burstFill = panel.b;
128
+
129
+ vec3 col = vec3(0.0);
130
+
131
+ // ---- RADIATING ACTION / SPEED LINES -------------------------------------
132
+ // Thin wedges bursting outward from the impact centre. Procedural so they're
133
+ // crisp and cheap. They live in a ring OUTSIDE the burst balloon (so they
134
+ // read as motion lines streaking off the hit, not hatching on the word).
135
+ float lineN = max(uActionLines, 1.0);
136
+ float a01 = (ang / TAU) + 0.5; // 0..1 around the circle
137
+ float idx = floor(a01 * lineN);
138
+ // per-line random angular jitter + length so they aren't a clean fan.
139
+ float jr = hash11(idx + uSeed * 3.1);
140
+ float jr2 = hash11(idx * 1.7 + uSeed * 7.3);
141
+ float cellPhase = fract(a01 * lineN);
142
+ float wedge = abs(cellPhase - 0.5);
143
+ // Thin tapered streaks: a sharp spine that fattens slightly outward (classic
144
+ // motion-line wedge), kept narrow so they read as speed lines, not pie slices.
145
+ float thick = mix(0.05, 0.14, jr);
146
+ float lineBody = 1.0 - smoothstep(thick * 0.35, thick, wedge);
147
+ // radial extent: lines start OUTSIDE the burst and streak outward to the edge.
148
+ float innerR = comicSpan * (0.30 + 0.05 * jr2);
149
+ float outerR = comicSpan * (0.46 + 0.30 * jr);
150
+ float radialMask = smoothstep(innerR, innerR + comicSpan * 0.015, rad)
151
+ * (1.0 - smoothstep(outerR - comicSpan * 0.10, outerR, rad));
152
+ // fade the lines in fast on impact, hold, then they thin out late.
153
+ float linePresence = smoothstep(0.0, 0.06, uLife) * (1.0 - smoothstep(0.6, 1.0, uLife));
154
+ // taper opacity along the line so the inner end is boldest (ink-streak feel).
155
+ float taper = 1.0 - smoothstep(innerR, outerR, rad);
156
+ float lines = lineBody * radialMask * linePresence * taper;
157
+ // animate-on-twos flicker toward the pop end (snappy comic motion).
158
+ float beat = floor(uTimeS * 12.0);
159
+ float flick = mix(1.0, step(0.25, hash11(idx + beat + uSeed)), uStyle * 0.5);
160
+ lines *= flick;
161
+
162
+ // Action lines cast a thin streak of light off the hit. White/cool ink at the
163
+ // noir end (a hard glint), the accent hue at the pop end. Kept dim so they
164
+ // read as speed lines around the panel rather than flooding the frame.
165
+ vec3 lineCol = mix(vec3(0.7, 0.74, 0.82), uC2, uStyle);
166
+ col += lineCol * lines * 0.32 * uExposure;
167
+
168
+ // ---- STARBURST BALLOON (behind the word) --------------------------------
169
+ // Filled with the secondary hue; gets the strongest Ben-Day shading so it
170
+ // reads as a flat printed color field. In noir it's a pale near-white field
171
+ // with a fine subtle screen; in pop-art it's a saturated yellow/red blast.
172
+ vec3 burstBase = mix(vec3(0.9), uC1, uSaturation);
173
+ // tone for the dots: more dots where the field is "darker" value. We want a
174
+ // lively mid coverage so the classic dot field shows.
175
+ float burstTone = mix(0.35, 0.7, uHalftone);
176
+ float dots = benday(frag, uDotSize, burstTone, radians(15.0) + uSeed);
177
+ // Ben-Day strength: subtle at noir, dominant at pop. The dots ADD the accent
178
+ // color on the printed field.
179
+ vec3 burstCol = burstBase + (uC2 - burstBase) * dots * uHalftone * 0.55;
180
+ col += burstCol * burstFill * uPresence * uExposure;
181
+
182
+ // A second, finer rotated screen on the word fill for that printed sheen at
183
+ // the pop end (kept subtle so letters stay legible). The word is the HERO:
184
+ // a bright, saturated fill that screams off the page (pop) or a luminous
185
+ // near-white with a spot tint (noir). Brighter than the burst so it reads
186
+ // as the foreground shout.
187
+ float wordDots = benday(frag, uDotSize * 0.7, 0.5, radians(75.0) + uSeed);
188
+ vec3 wordBright = clamp(uC0 * 1.35 + 0.25, 0.0, 1.4);
189
+ vec3 wordBase = mix(vec3(0.96, 0.97, 1.0), wordBright, clamp(uSaturation + 0.2, 0.0, 1.0));
190
+ vec3 wordCol = wordBase + (uC2 - wordBase) * wordDots * uHalftone * 0.25 * uStyle;
191
+ // Word fill is largely PROTECTED from ink suppression (its own outline should
192
+ // frame it, not eat it), so render it after a softened ink mask below.
193
+ col += wordCol * wordFill * uPresence * uExposure * 1.7;
194
+
195
+ // ---- INK ----------------------------------------------------------------
196
+ // Bold black contours. Ink is the ABSENCE of light on a screen-blend canvas,
197
+ // so we can't literally darken the page from here — instead we let ink CARVE
198
+ // the lit shapes (it suppresses the fills it overlaps) and, at the noir end,
199
+ // we add a faint cool rim so the chiaroscuro edge still reads as light catches
200
+ // the ink ridge. The actual black is achieved by NOT lighting those pixels.
201
+ float ink = inkMask * uPresence;
202
+ // Suppress fills under ink (so outlines punch through as unlit black). But
203
+ // where the ink overlaps the WORD fill we soften the carve a lot, so the
204
+ // outline frames the letters instead of eating their bright bodies.
205
+ // Gentle carve: the outline should FRAME the burst, not gut it. A near-total
206
+ // carve (old 0.96) left the burst outline reading as a transparent gap that
207
+ // masked the balloon — worse at high whimsy, where inkBoost fattens the ink.
208
+ // Keep the word's soft carve (0.26 at wordFill=1) but darken the burst outline
209
+ // only partway so the balloon shows through.
210
+ float carve = ink * (0.45 - 0.19 * wordFill);
211
+ col *= (1.0 - carve);
212
+ // Subtle chiaroscuro rim-light on ink edges toward the noir end (a glint).
213
+ float rim = ink * (1.0 - uStyle) * 0.18;
214
+ col += mix(uC2, vec3(0.8, 0.85, 1.0), 0.5) * rim * uExposure;
215
+
216
+ // ---- IMPACT FLASH -------------------------------------------------------
217
+ // A hot radial flash at the moment of impact that throws colored light onto
218
+ // the page (the cast-light proof). Fast spike, quick decay (driven by uFlash).
219
+ float flashFall = exp(-rad / (minDim * 0.42));
220
+ vec3 flashCol = mix(mix(uC0, uC1, 0.5), vec3(1.0), 0.45 + 0.3 * uStyle);
221
+ col += flashCol * flashFall * uFlash * uExposure * 1.4;
222
+ // a tight white-hot core right at the centre on the very first frames.
223
+ float core = exp(-rad / (minDim * 0.10));
224
+ col += vec3(1.0) * core * uFlash * uFlash * 1.6;
225
+
226
+ // ---- TONE + FINISH ------------------------------------------------------
227
+ // ACES filmic tonemap (shared look/glsl) for a cleaner highlight rolloff than
228
+ // the old x/(1+x) compress — the impact flash highlights roll off gracefully
229
+ // while the saturated printed mids stay rich. A mild pre-exposure keeps the
230
+ // pop-art color from dimming.
231
+ col = tonemapACES(col * 0.85);
232
+
233
+ // Pop-art posterize: snap the lit panel to a few flat ink levels toward the
234
+ // pop end (flat printed color), leaving the dark page untouched so we don't
235
+ // shatter it into camouflage. Noir stays smooth chiaroscuro.
236
+ if (uStyle > 0.001) {
237
+ float lit = smoothstep(0.02, 0.2, max(max(col.r, col.g), col.b));
238
+ vec3 q = floor(col * 4.0 + 0.5) / 4.0;
239
+ col = mix(col, mix(col, q, lit), uStyle * 0.7);
240
+ }
241
+
242
+ // Ordered dither (shared look/glsl) to kill banding the screen-blend reveals
243
+ // (faded toward the pop end where the flat printed look is intended).
244
+ col = ditherAdd(col, frag, uTimeS, 1.0 - uStyle * 0.7);
245
+
246
+ fragColor = vec4(max(col, 0.0), 1.0);
247
+ }`;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Comic Impact bespoke timing — the slam/recoil + proud-hold-then-fade.
3
+ *
4
+ * The word arrives oversized and slams down past its rest size, recoils (a quick
5
+ * spring), holds, then eases out at the tail. Deliberately very short IMPACT so
6
+ * the word reads as a punch landing, not a tween. Built on `easeOutCubic`.
7
+ */
8
+
9
+ import { easeOutCubic, clamp01 } from "@dopaminefx/core";
10
+
11
+ /** Window (ms) over which the comic onomatopoeia word SLAMS in. */
12
+ export const IMPACT_MS = 200;
13
+
14
+ /** Hold (ms) the word sits proud at full size before it begins to settle out. */
15
+ export const IMPACT_HOLD_MS = 650;
16
+
17
+ /**
18
+ * Comic impact SCALE over elapsed ms. Returns a multiplier on rest size: large
19
+ * at t≈0, slamming to ≈1 by IMPACT_MS (with a small spring), then resting.
20
+ * `overshoot` scales the slam magnitude (driven by intensity).
21
+ */
22
+ export function impactScale(elapsedMs: number, overshoot = 1): number {
23
+ const t = elapsedMs;
24
+ if (t <= 0) return 1 + 0.85 * overshoot;
25
+ if (t < IMPACT_MS) {
26
+ const x = t / IMPACT_MS;
27
+ const eased = easeOutCubic(x);
28
+ const big = 1 + 0.85 * overshoot;
29
+ const dip = -0.12 * overshoot * Math.sin(x * Math.PI) * (1 - x);
30
+ return big + (1 - big) * eased + dip;
31
+ }
32
+ return 1;
33
+ }
34
+
35
+ /**
36
+ * Comic impact OPACITY/presence over normalized life (0..1). A near-instant
37
+ * appearance, a long proud hold, then a quick fade at the very end so the panel
38
+ * clears. The fade occupies the last ~18%.
39
+ */
40
+ export function impactPresence(life: number): number {
41
+ const t = clamp01(life);
42
+ if (t < 0.04) return easeOutCubic(t / 0.04); // snap in
43
+ if (t < 0.82) return 1;
44
+ const fade = clamp01(1 - (t - 0.82) / 0.18);
45
+ return Math.pow(fade, 1.4); // quick clean fade
46
+ }