@wallarm-org/design-system 0.58.1 → 0.59.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.
Files changed (32) hide show
  1. package/dist/components/AnimatedBackground/AnimatedBackground.js +18 -82
  2. package/dist/components/AnimatedBackground/GameHud.d.ts +1 -0
  3. package/dist/components/AnimatedBackground/GameHud.js +2 -2
  4. package/dist/components/AnimatedBackground/module/celebration-renderer.d.ts +5 -0
  5. package/dist/components/AnimatedBackground/module/celebration-renderer.js +60 -0
  6. package/dist/components/AnimatedBackground/module/celebration.d.ts +102 -0
  7. package/dist/components/AnimatedBackground/module/celebration.js +628 -0
  8. package/dist/components/AnimatedBackground/module/engine-grid.d.ts +8 -1
  9. package/dist/components/AnimatedBackground/module/engine-grid.js +19 -5
  10. package/dist/components/AnimatedBackground/module/engine.d.ts +2 -0
  11. package/dist/components/AnimatedBackground/module/engine.js +24 -5
  12. package/dist/components/AnimatedBackground/module/game-logic.d.ts +8 -0
  13. package/dist/components/AnimatedBackground/module/game-logic.js +81 -37
  14. package/dist/components/AnimatedBackground/module/game-renderer.d.ts +1 -0
  15. package/dist/components/AnimatedBackground/module/game-renderer.js +51 -12
  16. package/dist/components/AnimatedBackground/module/index.d.ts +1 -0
  17. package/dist/components/AnimatedBackground/module/index.js +2 -1
  18. package/dist/components/AnimatedBackground/module/math.d.ts +4 -0
  19. package/dist/components/AnimatedBackground/module/math.js +10 -0
  20. package/dist/components/AnimatedBackground/module/sfx.d.ts +15 -0
  21. package/dist/components/AnimatedBackground/module/sfx.js +143 -0
  22. package/dist/components/AnimatedBackground/module/useGame.d.ts +22 -0
  23. package/dist/components/AnimatedBackground/module/useGame.js +112 -0
  24. package/dist/components/AnimatedBackground/module/useGameKeyboard.d.ts +1 -1
  25. package/dist/components/AnimatedBackground/module/useGameKeyboard.js +23 -14
  26. package/dist/components/Flex/Flex.d.ts +1 -1
  27. package/dist/components/SegmentedControl/SegmentedControlSeparator.d.ts +1 -1
  28. package/dist/components/Separator/Separator.d.ts +1 -1
  29. package/dist/components/Skeleton/Skeleton.d.ts +1 -1
  30. package/dist/components/Stack/Stack.d.ts +1 -1
  31. package/dist/metadata/components.json +2 -2
  32. package/package.json +1 -1
@@ -14,6 +14,8 @@ function createSweepEngine(canvas, options) {
14
14
  w: 0,
15
15
  h: 0,
16
16
  dots: [],
17
+ gridCols: 0,
18
+ gridSp: 0,
17
19
  opts,
18
20
  tanTilt: Math.tan(options.tilt * Math.PI / 180),
19
21
  exclusionBox: null
@@ -57,7 +59,10 @@ function createSweepEngine(canvas, options) {
57
59
  canvas.width = Math.round(host.w * dpr);
58
60
  canvas.height = Math.round(host.h * dpr);
59
61
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
60
- host.dots = buildGrid(host.w, host.h, opts.spacing);
62
+ const grid = buildGrid(host.w, host.h, opts.spacing);
63
+ host.dots = grid.dots;
64
+ host.gridCols = grid.cols;
65
+ host.gridSp = grid.sp;
61
66
  host.tanTilt = Math.tan(opts.tilt * Math.PI / 180);
62
67
  resolveColors();
63
68
  game.cannonX = Math.max(CANNON_HALF_W, Math.min(host.w - CANNON_HALF_W, game.cannonX));
@@ -119,9 +124,10 @@ function createSweepEngine(canvas, options) {
119
124
  ctx.fillRect(0, 0, host.w, host.h);
120
125
  if ('halftone' === opts.texture) {
121
126
  drawHalftone(t);
122
- gr.drawCaughtEffects(t);
123
127
  gr.drawCannon(t);
124
128
  gr.drawBullets();
129
+ gr.drawCelOverlay(t);
130
+ gr.drawCaughtEffects(t);
125
131
  } else drawClean(t);
126
132
  }
127
133
  const DT_CLAMP = 0.05;
@@ -137,7 +143,10 @@ function createSweepEngine(canvas, options) {
137
143
  if (lastLatch > -1 / 0) lastLatch += skip;
138
144
  }
139
145
  lastFrameT = now;
140
- if ('halftone' === opts.texture) game.gameSim(now, dt);
146
+ if ('halftone' === opts.texture) {
147
+ game.stepCel(now, dt, rc.caughtPalette[rc.caughtPalette.length - 1], rc.dotPalette[rc.dotPalette.length - 1]);
148
+ game.gameSim(now, dt);
149
+ }
141
150
  game.pruneCaughtEffects(now);
142
151
  frame(now);
143
152
  rafId = requestAnimationFrame(tick);
@@ -182,7 +191,12 @@ function createSweepEngine(canvas, options) {
182
191
  };
183
192
  host.opts = opts;
184
193
  host.tanTilt = Math.tan(opts.tilt * Math.PI / 180);
185
- if (spacingChanged) host.dots = buildGrid(host.w, host.h, opts.spacing);
194
+ if (spacingChanged) {
195
+ const grid = buildGrid(host.w, host.h, opts.spacing);
196
+ host.dots = grid.dots;
197
+ host.gridCols = grid.cols;
198
+ host.gridSp = grid.sp;
199
+ }
186
200
  resolveColors();
187
201
  if (!running) renderStatic();
188
202
  }
@@ -207,7 +221,12 @@ function createSweepEngine(canvas, options) {
207
221
  setCannonDir: (dir)=>game.setCannonDir(dir),
208
222
  setFiring: (on)=>game.setFiring(on),
209
223
  onStats: (cb)=>game.onStats(cb),
210
- setExclusion: (box)=>game.setExclusion(box)
224
+ setExclusion: (box)=>game.setExclusion(box),
225
+ celebrate: (score)=>{
226
+ if ('halftone' !== opts.texture || !running) return;
227
+ game.celebrate(score);
228
+ },
229
+ setSound: (on)=>game.setSound(on)
211
230
  };
212
231
  }
213
232
  export { createSweepEngine };
@@ -1,3 +1,4 @@
1
+ import type { CelState } from './celebration';
1
2
  import type { Dot } from './engine-grid';
2
3
  import type { EngineOptions, GameStats } from './types';
3
4
  export declare const ROUND_ATTACKS = 100;
@@ -59,6 +60,8 @@ export interface GameEngineHost {
59
60
  w: number;
60
61
  h: number;
61
62
  dots: Dot[];
63
+ gridCols: number;
64
+ gridSp: number;
62
65
  opts: EngineOptions;
63
66
  tanTilt: number;
64
67
  exclusionBox: {
@@ -75,7 +78,10 @@ export interface GameLogic {
75
78
  cannonX: number;
76
79
  armT: number;
77
80
  fontLoaded: boolean;
81
+ cel: CelState | null;
82
+ cannonAway: boolean;
78
83
  gameSim(t: number, dt: number): void;
84
+ stepCel(t: number, dt: number, caughtColor: string, dotColor: string): void;
79
85
  pruneCaughtEffects(t: number): void;
80
86
  adjustTimeMarkers(skip: number): void;
81
87
  setGameActive(active: boolean): void;
@@ -90,5 +96,7 @@ export interface GameLogic {
90
96
  width: number;
91
97
  height: number;
92
98
  } | null): void;
99
+ celebrate(score: number): void;
100
+ setSound(on: boolean): void;
93
101
  }
94
102
  export declare function createGameLogic(host: GameEngineHost): GameLogic;
@@ -1,5 +1,7 @@
1
+ import { adjustCelebrationTimeMarkers, startCelebration, stepCelebration, tierForScore } from "./celebration.js";
1
2
  import { ANOMALY_VIS_THRESHOLD, DOT_STEP_BASE, DOT_STEP_SCALE, IDLE_MARGIN_X, IDLE_MARGIN_Y } from "./constants.js";
2
3
  import { sweepX } from "./engine-grid.js";
4
+ import { playCoin, playFanfare, playPew, playPowerUp, playZap } from "./sfx.js";
3
5
  const ROUND_ATTACKS = 100;
4
6
  const MAX_TARGETS = 8;
5
7
  const MAX_BULLETS = 24;
@@ -43,7 +45,9 @@ function createGameLogic(host) {
43
45
  gameMode: 'idle',
44
46
  cannonX: 0,
45
47
  armT: 0,
46
- fontLoaded: false
48
+ fontLoaded: false,
49
+ cel: null,
50
+ cannonAway: false
47
51
  };
48
52
  const anomalies = [];
49
53
  const bullets = [];
@@ -57,11 +61,15 @@ function createGameLogic(host) {
57
61
  let roundSpawned = 0;
58
62
  let roundDone = false;
59
63
  let lastSpawn = -1 / 0;
64
+ let soundOn = false;
60
65
  let statsCb = null;
61
66
  let exclusionBox = null;
62
- if ("u" > typeof document) document.fonts.load('9px "Press Start 2P"').then(()=>{
63
- state.fontLoaded = true;
64
- }).catch(()=>{});
67
+ function loadFont() {
68
+ if (state.fontLoaded || "u" < typeof document) return;
69
+ document.fonts.load('9px "Press Start 2P"').then(()=>{
70
+ state.fontLoaded = true;
71
+ }).catch(()=>{});
72
+ }
65
73
  function emitStats() {
66
74
  statsCb?.({
67
75
  kills: killTotal,
@@ -181,7 +189,10 @@ function createGameLogic(host) {
181
189
  }
182
190
  function recordKill() {
183
191
  killTotal += 1;
184
- if ('armed' === state.gameMode) roundKills += 1;
192
+ if ('armed' === state.gameMode) {
193
+ roundKills += 1;
194
+ if (soundOn) playZap();
195
+ } else if (soundOn) playCoin();
185
196
  emitStats();
186
197
  }
187
198
  function endRound() {
@@ -190,6 +201,11 @@ function createGameLogic(host) {
190
201
  bullets.length = 0;
191
202
  firing = false;
192
203
  cannonDir = 0;
204
+ const faced = roundKills + roundEscaped;
205
+ const accuracy = faced > 0 ? Math.round(roundKills / faced * 100) : 100;
206
+ const now = performance.now() / 1000;
207
+ state.cel = startCelebration(accuracy, now, host);
208
+ if (soundOn && tierForScore(accuracy) >= 1) playFanfare();
193
209
  emitStats();
194
210
  }
195
211
  function isHittable(anomaly, t) {
@@ -210,6 +226,7 @@ function createGameLogic(host) {
210
226
  anomalies.length = kept;
211
227
  }
212
228
  function idleSim(t) {
229
+ if (state.cel) return void pruneExpired(t);
213
230
  let liveCount = 0;
214
231
  for (const anomaly of anomalies)if (!anomaly.caught) liveCount++;
215
232
  if (liveCount < 2 && t - lastSpawn > host.opts.anomalyInterval) {
@@ -233,6 +250,7 @@ function createGameLogic(host) {
233
250
  y: host.h - CANNON_BARREL_Y
234
251
  });
235
252
  lastFire = t;
253
+ if (soundOn) playPew();
236
254
  }
237
255
  }
238
256
  function moveBullets(dt) {
@@ -327,25 +345,42 @@ function createGameLogic(host) {
327
345
  for (const effect of caughtEffects)effect.t0 += skip;
328
346
  if (state.armT > 0) state.armT += skip;
329
347
  if (lastSpawn > -1 / 0 && 0 !== lastSpawn) lastSpawn += skip;
348
+ if (state.cel) adjustCelebrationTimeMarkers(state.cel, skip);
349
+ }
350
+ function resetRoundState() {
351
+ roundKills = 0;
352
+ roundEscaped = 0;
353
+ roundSpawned = 0;
354
+ roundDone = false;
355
+ bullets.length = 0;
356
+ anomalies.length = 0;
357
+ caughtEffects.length = 0;
358
+ firing = false;
359
+ cannonDir = 0;
360
+ state.cel = null;
361
+ state.cannonAway = false;
330
362
  }
331
363
  function setGameActive(active) {
332
364
  if (state.gameActive === active) return;
333
365
  state.gameActive = active;
334
- if (active) lastSpawn = -1 / 0;
335
- else {
336
- anomalies.length = 0;
337
- bullets.length = 0;
338
- caughtEffects.length = 0;
366
+ if (active) {
367
+ lastSpawn = -1 / 0;
368
+ loadFont();
369
+ } else {
370
+ resetRoundState();
339
371
  state.gameMode = 'idle';
340
372
  killTotal = 0;
341
- roundKills = 0;
342
- roundEscaped = 0;
343
- roundSpawned = 0;
344
- roundDone = false;
345
- firing = false;
346
- cannonDir = 0;
347
373
  }
348
374
  }
375
+ function celebrate(score) {
376
+ const now = performance.now() / 1000;
377
+ state.cel = null;
378
+ state.cannonAway = false;
379
+ anomalies.length = 0;
380
+ state.armT = now;
381
+ state.cel = startCelebration(score, now, host);
382
+ if (soundOn && tierForScore(score) >= 1) playFanfare();
383
+ }
349
384
  function catchAt(x, y, running) {
350
385
  if (!running) return false;
351
386
  const now = performance.now() / 1000;
@@ -372,38 +407,24 @@ function createGameLogic(host) {
372
407
  if ('idle' === mode) {
373
408
  state.gameMode = 'idle';
374
409
  lastSpawn = -1 / 0;
410
+ state.cel = null;
411
+ state.cannonAway = false;
375
412
  } else state.gameMode = 'armed';
376
413
  }
377
414
  function startRound() {
378
- const { w } = host;
379
415
  const now = performance.now() / 1000;
380
- roundKills = 0;
381
- roundEscaped = 0;
382
- roundSpawned = 0;
383
- roundDone = false;
416
+ resetRoundState();
384
417
  state.gameMode = 'armed';
385
- bullets.length = 0;
386
- anomalies.length = 0;
387
- caughtEffects.length = 0;
388
- firing = false;
389
- cannonDir = 0;
390
418
  state.armT = now;
391
419
  lastSpawn = now;
392
- state.cannonX = w / 2;
420
+ state.cannonX = host.w / 2;
421
+ if (soundOn) playPowerUp();
393
422
  emitStats();
394
423
  }
395
424
  function exitGame() {
396
- killTotal = 0;
397
- roundKills = 0;
398
- roundEscaped = 0;
399
- roundSpawned = 0;
400
- roundDone = false;
425
+ resetRoundState();
401
426
  state.gameMode = 'idle';
402
- bullets.length = 0;
403
- anomalies.length = 0;
404
- caughtEffects.length = 0;
405
- firing = false;
406
- cannonDir = 0;
427
+ killTotal = 0;
407
428
  lastSpawn = -1 / 0;
408
429
  emitStats();
409
430
  }
@@ -419,6 +440,9 @@ function createGameLogic(host) {
419
440
  function onStats(cb) {
420
441
  statsCb = cb;
421
442
  }
443
+ function setSound(on) {
444
+ soundOn = on;
445
+ }
422
446
  function setExclusion(box) {
423
447
  if (!box) {
424
448
  exclusionBox = null;
@@ -432,6 +456,11 @@ function createGameLogic(host) {
432
456
  exclusionBox = val;
433
457
  host.exclusionBox = val;
434
458
  }
459
+ function stepCel(t, dt, caughtColor, dotColor) {
460
+ if (!state.cel) return;
461
+ stepCelebration(state.cel, t, dt, host, caughtColor, dotColor);
462
+ if (state.cel.liftStarted) state.cannonAway = true;
463
+ }
435
464
  return {
436
465
  anomalies,
437
466
  bullets,
@@ -439,6 +468,7 @@ function createGameLogic(host) {
439
468
  gameSim,
440
469
  pruneCaughtEffects,
441
470
  adjustTimeMarkers,
471
+ stepCel,
442
472
  setGameActive,
443
473
  catchAt,
444
474
  setMode,
@@ -448,6 +478,20 @@ function createGameLogic(host) {
448
478
  setFiring,
449
479
  onStats,
450
480
  setExclusion,
481
+ celebrate,
482
+ setSound,
483
+ get cel () {
484
+ return state.cel;
485
+ },
486
+ set cel (v){
487
+ state.cel = v;
488
+ },
489
+ get cannonAway () {
490
+ return state.cannonAway;
491
+ },
492
+ set cannonAway (v){
493
+ state.cannonAway = v;
494
+ },
451
495
  get gameActive () {
452
496
  return state.gameActive;
453
497
  },
@@ -13,4 +13,5 @@ export declare function createGameRenderer(rc: GameRenderCtx, game: GameLogic, h
13
13
  drawCaughtEffects: (t: number) => void;
14
14
  drawCannon: (t: number) => void;
15
15
  drawBullets: () => void;
16
+ drawCelOverlay: (t: number) => void;
16
17
  };
@@ -1,4 +1,6 @@
1
- import { AMB_SCALE, ANOMALY_ALPHA_BASE, ANOMALY_VIS_THRESHOLD, DOT_STEP_BASE, DOT_STEP_SCALE, HALFTONE_BASE_ALPHA, HALFTONE_BLOOM_PEAK, LABEL_ALPHA_BOOST, LABEL_DRIFT, LABEL_MIN_Y, LABEL_OFFSET_Y, LABEL_SHADOW_ALPHA, MIN_DOT_VALUE, NOISE_FREQ_T, NOISE_FREQ_X, NOISE_FREQ_Y } from "./constants.js";
1
+ import { CEL_CAUGHT_COL, celDotEffect, computeCelFrameParams } from "./celebration.js";
2
+ import { drawCelebrationOverlay, getCelCannonOffset } from "./celebration-renderer.js";
3
+ import { AMB_SCALE, ANOMALY_ALPHA_BASE, ANOMALY_VIS_THRESHOLD, DOT_STEP_BASE, DOT_STEP_SCALE, HALFTONE_BASE_ALPHA, HALFTONE_BLOOM_PEAK, LABEL_ALPHA_BOOST, LABEL_DRIFT, LABEL_MIN_Y, LABEL_OFFSET_Y, LABEL_SHADOW_ALPHA, MIN_DOT_VALUE, NOISE_FREQ_T } from "./constants.js";
2
4
  import { ALPHA_STEPS, alphaIdx } from "./engine-colors.js";
3
5
  import { sweepX } from "./engine-grid.js";
4
6
  import { ANOMALY_R, ANOMALY_R_SQ, ARM_RISE, CANNON_BASE_OFFSET, CANNON_HALF_W, CAUGHT_DUR, REVEAL_DELAY } from "./game-logic.js";
@@ -6,7 +8,7 @@ function createGameRenderer(rc, game, host) {
6
8
  let envCache = [];
7
9
  let revealedCache = [];
8
10
  function drawGameDots(t) {
9
- const { ctx, dotPalette, accentPalette } = rc;
11
+ const { ctx, dotPalette, accentPalette, caughtPalette } = rc;
10
12
  const { w, h, dots, opts, tanTilt, exclusionBox } = host;
11
13
  const sx = sweepX(t, w, opts.sweepPeriod);
12
14
  const intensity = opts.intensity;
@@ -14,6 +16,9 @@ function createGameRenderer(rc, game, host) {
14
16
  const liveAnomalies = game.anomalies;
15
17
  const aLen = liveAnomalies.length;
16
18
  const isIdle = 'idle' === game.gameMode;
19
+ const cel = game.cel;
20
+ let celParams = null;
21
+ if (cel) celParams = computeCelFrameParams(cel, t, w, h);
17
22
  const exL = exclusionBox ? (w - exclusionBox.width) / 2 : 0;
18
23
  const exR = exclusionBox ? (w + exclusionBox.width) / 2 : 0;
19
24
  const exT = exclusionBox ? (h - exclusionBox.height) / 2 : 0;
@@ -32,10 +37,11 @@ function createGameRenderer(rc, game, host) {
32
37
  revealedCache[i] = isIdle ? true : anomaly.x < sx || t - anomaly.t0 > REVEAL_DELAY;
33
38
  }
34
39
  }
35
- for (const dot of dots){
40
+ for(let di = 0; di < dots.length; di++){
41
+ const dot = dots[di];
36
42
  if (exclusionBox && dot.x >= exL && dot.x <= exR && dot.y >= exT && dot.y <= exB) continue;
37
43
  const sxAt = sx + (h / 2 - dot.y) * tanTilt;
38
- const amb = AMB_SCALE * (0.5 + 0.5 * Math.sin(dot.x * NOISE_FREQ_X + dot.y * NOISE_FREQ_Y + t * NOISE_FREQ_T));
44
+ const amb = AMB_SCALE * (0.5 + 0.5 * Math.sin(dot.noiseSpatial + t * NOISE_FREQ_T));
39
45
  const distToSweep = Math.abs(dot.x - sxAt);
40
46
  const bloom = distToSweep < opts.bloomRadius ? HALFTONE_BLOOM_PEAK * (1 - distToSweep / opts.bloomRadius) : 0;
41
47
  let maxAo = 0;
@@ -54,12 +60,32 @@ function createGameRenderer(rc, game, host) {
54
60
  const ao = env * (1 - dist / ANOMALY_R);
55
61
  if (ao > maxAo) maxAo = ao;
56
62
  }
63
+ let celBoost = 0;
64
+ let celCol = null;
65
+ if (celParams) {
66
+ const eff = celDotEffect(di, dot.x, dot.y, celParams, w, h);
67
+ if (eff) {
68
+ celBoost = eff.celBoost;
69
+ celCol = eff.celCol;
70
+ }
71
+ }
57
72
  const val = Math.min(1, Math.max(amb + bloom, maxAo));
58
- if (val < MIN_DOT_VALUE) continue;
59
- const step = Math.round(5 * val);
73
+ if (val < MIN_DOT_VALUE && celBoost < 0.02) continue;
74
+ const effVal = Math.min(1, val + celBoost);
75
+ const step = Math.round(5 * effVal);
60
76
  const half = Math.min(step * DOT_STEP_SCALE + DOT_STEP_BASE, halfCap);
61
77
  if (maxAo > ANOMALY_VIS_THRESHOLD) ctx.fillStyle = accentPalette[alphaIdx(Math.min(1, ANOMALY_ALPHA_BASE + maxAo))];
62
- else ctx.fillStyle = dotPalette[alphaIdx((HALFTONE_BASE_ALPHA + val * (opts.bloomAlpha - HALFTONE_BASE_ALPHA)) * intensity)];
78
+ else if (celBoost > 0.02) {
79
+ const celAlpha = Math.min(1, 0.15 + 0.85 * effVal);
80
+ if (celCol === CEL_CAUGHT_COL) ctx.fillStyle = caughtPalette[alphaIdx(celAlpha)];
81
+ else if (celCol) {
82
+ ctx.globalAlpha = celAlpha;
83
+ ctx.fillStyle = celCol;
84
+ ctx.fillRect(dot.x - half, dot.y - half, 2 * half, 2 * half);
85
+ ctx.globalAlpha = 1;
86
+ continue;
87
+ } else ctx.fillStyle = caughtPalette[alphaIdx(celAlpha)];
88
+ } else ctx.fillStyle = dotPalette[alphaIdx((HALFTONE_BASE_ALPHA + val * (opts.bloomAlpha - HALFTONE_BASE_ALPHA)) * intensity)];
63
89
  ctx.fillRect(dot.x - half, dot.y - half, 2 * half, 2 * half);
64
90
  }
65
91
  }
@@ -94,8 +120,11 @@ function createGameRenderer(rc, game, host) {
94
120
  }
95
121
  function drawCannon(t) {
96
122
  const { ctx, dotPalette } = rc;
97
- if ('armed' !== game.gameMode && 'over' !== game.gameMode) return;
98
- if ('armed' === game.gameMode) {
123
+ const cel = game.cel;
124
+ const hasCel = null !== cel;
125
+ if ('armed' !== game.gameMode && 'over' !== game.gameMode && !hasCel) return;
126
+ const liftOffset = getCelCannonOffset(cel, game.cannonAway, t, host.h);
127
+ if ('armed' === game.gameMode && !hasCel) {
99
128
  const riseP = Math.min(1, (t - game.armT) / ARM_RISE);
100
129
  const ease = 1 - (1 - riseP) * (1 - riseP);
101
130
  const offsetY = (1 - ease) * 40;
@@ -103,24 +132,34 @@ function createGameRenderer(rc, game, host) {
103
132
  ctx.globalAlpha = ease;
104
133
  ctx.translate(0, offsetY);
105
134
  }
106
- const baseY = host.h - CANNON_BASE_OFFSET;
135
+ const baseY = host.h - CANNON_BASE_OFFSET - liftOffset;
136
+ if (baseY < -40) {
137
+ if ('armed' === game.gameMode && !hasCel) ctx.restore();
138
+ return;
139
+ }
107
140
  const cx = Math.round(game.cannonX);
108
141
  ctx.fillStyle = dotPalette[ALPHA_STEPS];
109
142
  ctx.fillRect(cx - CANNON_HALF_W, baseY, 2 * CANNON_HALF_W, 8);
110
143
  ctx.fillRect(cx - 6, baseY - 6, 12, 6);
111
144
  ctx.fillRect(cx - 2, baseY - 12, 4, 6);
112
- if ('armed' === game.gameMode) ctx.restore();
145
+ if ('armed' === game.gameMode && !hasCel) ctx.restore();
113
146
  }
114
147
  function drawBullets() {
115
148
  const { ctx, dotPalette } = rc;
116
149
  ctx.fillStyle = dotPalette[ALPHA_STEPS];
117
150
  for (const bullet of game.bullets)ctx.fillRect(bullet.x - 2, bullet.y - 7, 4, 14);
118
151
  }
152
+ function drawCelOverlay(t) {
153
+ const cel = game.cel;
154
+ if (!cel) return;
155
+ drawCelebrationOverlay(rc, cel, t, host, game.fontLoaded);
156
+ }
119
157
  return {
120
158
  drawGameDots,
121
159
  drawCaughtEffects,
122
160
  drawCannon,
123
- drawBullets
161
+ drawBullets,
162
+ drawCelOverlay
124
163
  };
125
164
  }
126
165
  export { createGameRenderer };
@@ -1,4 +1,5 @@
1
1
  export { createSweepEngine, type SweepEngine } from './engine';
2
2
  export { resolveOptions } from './lib';
3
3
  export type { AnimatedBackgroundProps, EngineOptions, GameStats, Texture } from './types';
4
+ export { useGame } from './useGame';
4
5
  export { useGameKeyboard } from './useGameKeyboard';
@@ -1,4 +1,5 @@
1
1
  import { createSweepEngine } from "./engine.js";
2
2
  import { resolveOptions } from "./lib.js";
3
+ import { useGame } from "./useGame.js";
3
4
  import { useGameKeyboard } from "./useGameKeyboard.js";
4
- export { createSweepEngine, resolveOptions, useGameKeyboard };
5
+ export { createSweepEngine, resolveOptions, useGame, useGameKeyboard };
@@ -0,0 +1,4 @@
1
+ /** Shared easing and clamping helpers used across game modules. */
2
+ export declare function easeOut(t: number): number;
3
+ export declare function easeIn(t: number): number;
4
+ export declare function clamp01(v: number): number;
@@ -0,0 +1,10 @@
1
+ function easeOut(t) {
2
+ return 1 - (1 - t) * (1 - t);
3
+ }
4
+ function easeIn(t) {
5
+ return t * t;
6
+ }
7
+ function clamp01(v) {
8
+ return v < 0 ? 0 : v > 1 ? 1 : v;
9
+ }
10
+ export { clamp01, easeIn, easeOut };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Synthesized 8-bit SFX for the shooter easter egg.
3
+ *
4
+ * All sounds use the Web Audio API — square waves + band-passed noise,
5
+ * the way original hardware made them. No audio files, no deps, no bundle weight.
6
+ *
7
+ * SSR-safe: nothing runs at import time (typeof window guard).
8
+ * Autoplay-safe: AudioContext created lazily on the first play call,
9
+ * which is always downstream of a user gesture.
10
+ */
11
+ export declare function playPew(): void;
12
+ export declare function playZap(): void;
13
+ export declare function playCoin(): void;
14
+ export declare function playPowerUp(): void;
15
+ export declare function playFanfare(): void;
@@ -0,0 +1,143 @@
1
+ const MASTER_VOLUME = 0.06;
2
+ let ctx = null;
3
+ let master = null;
4
+ function ensureAudio() {
5
+ if ("u" < typeof window) return null;
6
+ if (!ctx) {
7
+ ctx = new AudioContext();
8
+ master = ctx.createGain();
9
+ master.gain.value = MASTER_VOLUME;
10
+ master.connect(ctx.destination);
11
+ }
12
+ if ('suspended' === ctx.state) ctx.resume();
13
+ if (!master) return null;
14
+ return {
15
+ ac: ctx,
16
+ out: master
17
+ };
18
+ }
19
+ function scheduleNotes(ac, out, notes, step, volume = 0.4) {
20
+ const now = ac.currentTime;
21
+ for(let i = 0; i < notes.length; i++){
22
+ const t = now + i * step;
23
+ const osc = ac.createOscillator();
24
+ osc.type = 'square';
25
+ osc.frequency.value = notes[i];
26
+ const gain = ac.createGain();
27
+ gain.gain.setValueAtTime(volume, t);
28
+ gain.gain.setValueAtTime(volume, t + 0.85 * step);
29
+ gain.gain.linearRampToValueAtTime(0, t + step);
30
+ osc.connect(gain).connect(out);
31
+ osc.start(t);
32
+ osc.stop(t + step);
33
+ }
34
+ return now + notes.length * step;
35
+ }
36
+ function playPew() {
37
+ const audio = ensureAudio();
38
+ if (!audio) return;
39
+ const { ac, out } = audio;
40
+ const now = ac.currentTime;
41
+ const dur = 0.08;
42
+ const osc = ac.createOscillator();
43
+ osc.type = 'square';
44
+ osc.frequency.setValueAtTime(980, now);
45
+ osc.frequency.linearRampToValueAtTime(180, now + dur);
46
+ const gain = ac.createGain();
47
+ gain.gain.setValueAtTime(0.5, now);
48
+ gain.gain.linearRampToValueAtTime(0, now + dur);
49
+ osc.connect(gain).connect(out);
50
+ osc.start(now);
51
+ osc.stop(now + dur);
52
+ }
53
+ function playZap() {
54
+ const audio = ensureAudio();
55
+ if (!audio) return;
56
+ const { ac, out } = audio;
57
+ const now = ac.currentTime;
58
+ const osc = ac.createOscillator();
59
+ osc.type = 'square';
60
+ osc.frequency.setValueAtTime(320, now);
61
+ osc.frequency.linearRampToValueAtTime(70, now + 0.06);
62
+ const oscGain = ac.createGain();
63
+ oscGain.gain.setValueAtTime(0.45, now);
64
+ oscGain.gain.linearRampToValueAtTime(0, now + 0.06);
65
+ osc.connect(oscGain).connect(out);
66
+ osc.start(now);
67
+ osc.stop(now + 0.06);
68
+ const bufLen = Math.ceil(0.07 * ac.sampleRate);
69
+ const buf = ac.createBuffer(1, bufLen, ac.sampleRate);
70
+ const data = buf.getChannelData(0);
71
+ for(let i = 0; i < bufLen; i++)data[i] = 2 * Math.random() - 1;
72
+ const noise = ac.createBufferSource();
73
+ noise.buffer = buf;
74
+ const bp = ac.createBiquadFilter();
75
+ bp.type = 'bandpass';
76
+ bp.frequency.setValueAtTime(1400, now);
77
+ bp.frequency.linearRampToValueAtTime(300, now + 0.07);
78
+ bp.Q.value = 2;
79
+ const noiseGain = ac.createGain();
80
+ noiseGain.gain.setValueAtTime(0.4, now);
81
+ noiseGain.gain.linearRampToValueAtTime(0, now + 0.07);
82
+ noise.connect(bp).connect(noiseGain).connect(out);
83
+ noise.start(now);
84
+ noise.stop(now + 0.07);
85
+ }
86
+ function playCoin() {
87
+ const audio = ensureAudio();
88
+ if (!audio) return;
89
+ const { ac, out } = audio;
90
+ const now = ac.currentTime;
91
+ const osc1 = ac.createOscillator();
92
+ osc1.type = 'square';
93
+ osc1.frequency.value = 988;
94
+ const gain1 = ac.createGain();
95
+ gain1.gain.setValueAtTime(0.4, now);
96
+ gain1.gain.setValueAtTime(0.4, now + 0.07);
97
+ gain1.gain.linearRampToValueAtTime(0, now + 0.08);
98
+ osc1.connect(gain1).connect(out);
99
+ osc1.start(now);
100
+ osc1.stop(now + 0.08);
101
+ const osc2 = ac.createOscillator();
102
+ osc2.type = 'square';
103
+ osc2.frequency.value = 1319;
104
+ const gain2 = ac.createGain();
105
+ gain2.gain.setValueAtTime(0, now);
106
+ gain2.gain.setValueAtTime(0.4, now + 0.08);
107
+ gain2.gain.setValueAtTime(0.4, now + 0.38);
108
+ gain2.gain.linearRampToValueAtTime(0, now + 0.46);
109
+ osc2.connect(gain2).connect(out);
110
+ osc2.start(now + 0.08);
111
+ osc2.stop(now + 0.46);
112
+ }
113
+ function playPowerUp() {
114
+ const audio = ensureAudio();
115
+ if (!audio) return;
116
+ scheduleNotes(audio.ac, audio.out, [
117
+ 392,
118
+ 523,
119
+ 659,
120
+ 784
121
+ ], 0.06);
122
+ }
123
+ function playFanfare() {
124
+ const audio = ensureAudio();
125
+ if (!audio) return;
126
+ const { ac, out } = audio;
127
+ const finalT = scheduleNotes(ac, out, [
128
+ 523,
129
+ 659,
130
+ 784
131
+ ], 0.09);
132
+ const osc = ac.createOscillator();
133
+ osc.type = 'square';
134
+ osc.frequency.value = 1046;
135
+ const gain = ac.createGain();
136
+ gain.gain.setValueAtTime(0.45, finalT);
137
+ gain.gain.setValueAtTime(0.45, finalT + 0.16);
138
+ gain.gain.linearRampToValueAtTime(0, finalT + 0.22);
139
+ osc.connect(gain).connect(out);
140
+ osc.start(finalT);
141
+ osc.stop(finalT + 0.22);
142
+ }
143
+ export { playCoin, playFanfare, playPew, playPowerUp, playZap };
@@ -0,0 +1,22 @@
1
+ import type { PointerEvent, ReactElement, RefObject } from 'react';
2
+ import type { SweepEngine } from './index';
3
+ interface UseGameParams {
4
+ game: boolean;
5
+ isHalftone: boolean;
6
+ excludeCardSize?: {
7
+ width: number;
8
+ height: number;
9
+ };
10
+ canvasRef: RefObject<HTMLCanvasElement | null>;
11
+ }
12
+ interface UseGameReturn {
13
+ gameActive: boolean;
14
+ onPointerDown: ((e: PointerEvent<HTMLCanvasElement>) => void) | undefined;
15
+ hudElement: ReactElement | null;
16
+ onEngineCreated: (engine: SweepEngine) => void;
17
+ onEngineDestroyed: () => void;
18
+ soundOn: boolean;
19
+ toggleSound: () => void;
20
+ }
21
+ export declare const useGame: ({ game, isHalftone, excludeCardSize, canvasRef, }: UseGameParams) => UseGameReturn;
22
+ export {};