pomium 1.0.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 (65) hide show
  1. package/README.md +96 -0
  2. package/assets/atlases.json +38 -0
  3. package/assets/desktop/char_01_desktop.json +783 -0
  4. package/assets/desktop/char_01_desktop.webp +0 -0
  5. package/assets/desktop/char_02_desktop.json +783 -0
  6. package/assets/desktop/char_02_desktop.webp +0 -0
  7. package/assets/desktop/char_03_desktop.json +783 -0
  8. package/assets/desktop/char_03_desktop.webp +0 -0
  9. package/assets/desktop/char_04_desktop.json +783 -0
  10. package/assets/desktop/char_04_desktop.webp +0 -0
  11. package/assets/desktop/char_05_desktop.json +783 -0
  12. package/assets/desktop/char_05_desktop.webp +0 -0
  13. package/assets/desktop/char_06_desktop.json +783 -0
  14. package/assets/desktop/char_06_desktop.webp +0 -0
  15. package/assets/desktop/char_07_desktop.json +783 -0
  16. package/assets/desktop/char_07_desktop.webp +0 -0
  17. package/assets/desktop/char_08_desktop.json +783 -0
  18. package/assets/desktop/char_08_desktop.webp +0 -0
  19. package/assets/desktop/char_09_desktop.json +783 -0
  20. package/assets/desktop/char_09_desktop.webp +0 -0
  21. package/assets/desktop/char_10_desktop.json +783 -0
  22. package/assets/desktop/char_10_desktop.webp +0 -0
  23. package/assets/desktop/shockwave_desktop.json +151 -0
  24. package/assets/desktop/shockwave_desktop.webp +0 -0
  25. package/assets/mobile/char_01_mobile.json +783 -0
  26. package/assets/mobile/char_01_mobile.webp +0 -0
  27. package/assets/mobile/char_02_mobile.json +783 -0
  28. package/assets/mobile/char_02_mobile.webp +0 -0
  29. package/assets/mobile/char_03_mobile.json +783 -0
  30. package/assets/mobile/char_03_mobile.webp +0 -0
  31. package/assets/mobile/char_04_mobile.json +783 -0
  32. package/assets/mobile/char_04_mobile.webp +0 -0
  33. package/assets/mobile/char_05_mobile.json +783 -0
  34. package/assets/mobile/char_05_mobile.webp +0 -0
  35. package/assets/mobile/char_06_mobile.json +783 -0
  36. package/assets/mobile/char_06_mobile.webp +0 -0
  37. package/assets/mobile/char_07_mobile.json +783 -0
  38. package/assets/mobile/char_07_mobile.webp +0 -0
  39. package/assets/mobile/char_08_mobile.json +783 -0
  40. package/assets/mobile/char_08_mobile.webp +0 -0
  41. package/assets/mobile/char_09_mobile.json +783 -0
  42. package/assets/mobile/char_09_mobile.webp +0 -0
  43. package/assets/mobile/char_10_mobile.json +783 -0
  44. package/assets/mobile/char_10_mobile.webp +0 -0
  45. package/assets/mobile/shockwave_mobile.json +151 -0
  46. package/assets/mobile/shockwave_mobile.webp +0 -0
  47. package/bin/pomium.js +20 -0
  48. package/package.json +32 -0
  49. package/src/main.js +114 -0
  50. package/src/pom/atlas-loader.js +21 -0
  51. package/src/pom/atlas.js +83 -0
  52. package/src/pom/config.js +41 -0
  53. package/src/pom/coords.js +10 -0
  54. package/src/pom/engine.js +103 -0
  55. package/src/pom/geometry.js +43 -0
  56. package/src/pom/index.js +173 -0
  57. package/src/pom/loop.js +68 -0
  58. package/src/pom/overlay.js +42 -0
  59. package/src/pom/package.json +1 -0
  60. package/src/pom/render.js +32 -0
  61. package/src/pom/sprites.js +66 -0
  62. package/src/renderer/index.html +25 -0
  63. package/src/renderer/renderer.js +197 -0
  64. package/src/renderer/styles.css +148 -0
  65. package/src/webview-preload.js +68 -0
@@ -0,0 +1,151 @@
1
+ {"frames": {
2
+
3
+ "shockwave_00000":
4
+ {
5
+ "frame": {"x":0,"y":0,"w":3,"h":3},
6
+ "rotated": false,
7
+ "trimmed": true,
8
+ "spriteSourceSize": {"x":0,"y":0,"w":3,"h":3},
9
+ "sourceSize": {"w":256,"h":128}
10
+ },
11
+ "shockwave_00001":
12
+ {
13
+ "frame": {"x":6,"y":0,"w":69,"h":15},
14
+ "rotated": false,
15
+ "trimmed": true,
16
+ "spriteSourceSize": {"x":85,"y":59,"w":69,"h":15},
17
+ "sourceSize": {"w":256,"h":128}
18
+ },
19
+ "shockwave_00002":
20
+ {
21
+ "frame": {"x":75,"y":0,"w":163,"h":49},
22
+ "rotated": false,
23
+ "trimmed": true,
24
+ "spriteSourceSize": {"x":38,"y":39,"w":163,"h":49},
25
+ "sourceSize": {"w":256,"h":128}
26
+ },
27
+ "shockwave_00003":
28
+ {
29
+ "frame": {"x":0,"y":109,"w":181,"h":61},
30
+ "rotated": false,
31
+ "trimmed": true,
32
+ "spriteSourceSize": {"x":25,"y":35,"w":181,"h":61},
33
+ "sourceSize": {"w":256,"h":128}
34
+ },
35
+ "shockwave_00004":
36
+ {
37
+ "frame": {"x":238,"y":0,"w":175,"h":50},
38
+ "rotated": false,
39
+ "trimmed": true,
40
+ "spriteSourceSize": {"x":38,"y":40,"w":175,"h":50},
41
+ "sourceSize": {"w":256,"h":128}
42
+ },
43
+ "shockwave_00005":
44
+ {
45
+ "frame": {"x":0,"y":50,"w":191,"h":56},
46
+ "rotated": false,
47
+ "trimmed": true,
48
+ "spriteSourceSize": {"x":31,"y":37,"w":191,"h":56},
49
+ "sourceSize": {"w":256,"h":128}
50
+ },
51
+ "shockwave_00006":
52
+ {
53
+ "frame": {"x":191,"y":50,"w":198,"h":59},
54
+ "rotated": false,
55
+ "trimmed": true,
56
+ "spriteSourceSize": {"x":28,"y":36,"w":198,"h":59},
57
+ "sourceSize": {"w":256,"h":128}
58
+ },
59
+ "shockwave_00007":
60
+ {
61
+ "frame": {"x":181,"y":109,"w":204,"h":62},
62
+ "rotated": false,
63
+ "trimmed": true,
64
+ "spriteSourceSize": {"x":26,"y":34,"w":204,"h":62},
65
+ "sourceSize": {"w":256,"h":128}
66
+ },
67
+ "shockwave_00008":
68
+ {
69
+ "frame": {"x":0,"y":171,"w":209,"h":64},
70
+ "rotated": false,
71
+ "trimmed": true,
72
+ "spriteSourceSize": {"x":24,"y":33,"w":209,"h":64},
73
+ "sourceSize": {"w":256,"h":128}
74
+ },
75
+ "shockwave_00009":
76
+ {
77
+ "frame": {"x":209,"y":171,"w":215,"h":65},
78
+ "rotated": false,
79
+ "trimmed": true,
80
+ "spriteSourceSize": {"x":22,"y":32,"w":215,"h":65},
81
+ "sourceSize": {"w":256,"h":128}
82
+ },
83
+ "shockwave_00010":
84
+ {
85
+ "frame": {"x":0,"y":236,"w":220,"h":67},
86
+ "rotated": false,
87
+ "trimmed": true,
88
+ "spriteSourceSize": {"x":19,"y":31,"w":220,"h":67},
89
+ "sourceSize": {"w":256,"h":128}
90
+ },
91
+ "shockwave_00011":
92
+ {
93
+ "frame": {"x":220,"y":236,"w":220,"h":68},
94
+ "rotated": false,
95
+ "trimmed": true,
96
+ "spriteSourceSize": {"x":16,"y":31,"w":220,"h":68},
97
+ "sourceSize": {"w":256,"h":128}
98
+ },
99
+ "shockwave_00012":
100
+ {
101
+ "frame": {"x":0,"y":373,"w":221,"h":71},
102
+ "rotated": false,
103
+ "trimmed": true,
104
+ "spriteSourceSize": {"x":19,"y":29,"w":221,"h":71},
105
+ "sourceSize": {"w":256,"h":128}
106
+ },
107
+ "shockwave_00013":
108
+ {
109
+ "frame": {"x":0,"y":304,"w":226,"h":69},
110
+ "rotated": false,
111
+ "trimmed": true,
112
+ "spriteSourceSize": {"x":17,"y":31,"w":226,"h":69},
113
+ "sourceSize": {"w":256,"h":128}
114
+ },
115
+ "shockwave_00014":
116
+ {
117
+ "frame": {"x":221,"y":373,"w":232,"h":72},
118
+ "rotated": false,
119
+ "trimmed": true,
120
+ "spriteSourceSize": {"x":14,"y":28,"w":232,"h":72},
121
+ "sourceSize": {"w":256,"h":128}
122
+ },
123
+ "shockwave_00015":
124
+ {
125
+ "frame": {"x":226,"y":304,"w":161,"h":69},
126
+ "rotated": false,
127
+ "trimmed": true,
128
+ "spriteSourceSize": {"x":88,"y":30,"w":161,"h":69},
129
+ "sourceSize": {"w":256,"h":128}
130
+ },
131
+ "shockwave_00016":
132
+ {
133
+ "frame": {"x":3,"y":0,"w":3,"h":3},
134
+ "rotated": false,
135
+ "trimmed": true,
136
+ "spriteSourceSize": {"x":0,"y":0,"w":3,"h":3},
137
+ "sourceSize": {"w":256,"h":128}
138
+ }},
139
+ "animations": {
140
+ "shockwave": ["shockwave_00000","shockwave_00001","shockwave_00002","shockwave_00003","shockwave_00004","shockwave_00005","shockwave_00006","shockwave_00007","shockwave_00008","shockwave_00009","shockwave_00010","shockwave_00011","shockwave_00012","shockwave_00013","shockwave_00014","shockwave_00015","shockwave_00016"]
141
+ },
142
+ "meta": {
143
+ "app": "https://www.codeandweb.com/texturepacker",
144
+ "version": "1.1",
145
+ "image": "shockwave_mobile.webp",
146
+ "format": "RGBA8888",
147
+ "size": {"w":453,"h":445},
148
+ "scale": "0.5",
149
+ "smartupdate": "$TexturePacker:SmartUpdate:38ba804774b36de93151b5e6b16553ed:de098fb154ddaaf6411fc2cbaf8eb78d:3fe8173d39f5b7146a0303f627b097c0$"
150
+ }
151
+ }
package/bin/pomium.js ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // npm's own bin shim always runs this under plain Node, never under Electron,
5
+ // so `electron` resolves to the path of its packaged binary rather than the
6
+ // Electron API. Spawning it against the package root (one level up from this
7
+ // file) works from any install location, since main.js and everything it
8
+ // loads resolve their own paths from __dirname rather than the caller's cwd.
9
+
10
+ const { spawn } = require('node:child_process');
11
+ const path = require('node:path');
12
+
13
+ const electronPath = require('electron');
14
+ const appRoot = path.join(__dirname, '..');
15
+
16
+ const child = spawn(electronPath, [appRoot], { stdio: 'inherit' });
17
+
18
+ child.on('close', (code) => {
19
+ process.exitCode = code === null ? 1 : code;
20
+ });
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "pomium",
3
+ "version": "1.0.0",
4
+ "description": "A Chromium-based desktop browser where every click bombs the screen with a Pomeranian.",
5
+ "main": "src/main.js",
6
+ "author": "Diogo Nicolau",
7
+ "bin": {
8
+ "pomium": "bin/pomium.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "assets"
14
+ ],
15
+ "engines": {
16
+ "node": ">=21"
17
+ },
18
+ "scripts": {
19
+ "start": "electron .",
20
+ "test": "node --test \"test/**/*.test.js\"",
21
+ "dist": "electron-builder --publish never",
22
+ "dist:mac": "electron-builder --mac --publish never",
23
+ "dist:win": "electron-builder --win --publish never"
24
+ },
25
+ "optionalDependencies": {
26
+ "electron": "^44.2.0"
27
+ },
28
+ "devDependencies": {
29
+ "electron-builder": "^26.15.3"
30
+ },
31
+ "license": "MIT"
32
+ }
package/src/main.js ADDED
@@ -0,0 +1,114 @@
1
+ const { app, BrowserWindow, ipcMain } = require('electron');
2
+ const path = require('path');
3
+
4
+ let mainWindow = null;
5
+
6
+ // The window physically jolts on every spawn, so its resting position has to
7
+ // be remembered and restored. It is re-read whenever the shake has settled,
8
+ // otherwise moving the window by hand between shakes would teleport it back
9
+ // to wherever it sat during the last one.
10
+ //
11
+ // Only x/y are tracked: pinning width/height would revert a resize the user
12
+ // starts mid-shake, and a held drag re-arms the shake every other update, so
13
+ // the window would be unresizable for as long as the pointer is down.
14
+ let restingBounds = null;
15
+
16
+ // Where to put the window back once it leaves maximise or fullscreen.
17
+ // Entering either snapshots the window's *current* frame as the one to
18
+ // restore to later, and if a shake had it displaced at that instant the
19
+ // snapshot is the displaced spot — every such transition would drift the
20
+ // window by another jolt. Handing the true resting position back on the way
21
+ // out corrects that regardless of what the OS captured.
22
+ let preTransitionBounds = null;
23
+
24
+ function createWindow() {
25
+ const win = new BrowserWindow({
26
+ width: 1280,
27
+ height: 800,
28
+ title: 'Pomium',
29
+ backgroundColor: '#1e1f24',
30
+ webPreferences: {
31
+ // Trusted host chrome (our own UI, not remote content) gets Node so
32
+ // renderer.js can manage <webview> tabs directly.
33
+ nodeIntegration: true,
34
+ contextIsolation: false,
35
+ webviewTag: true,
36
+ },
37
+ });
38
+
39
+ win.loadFile(path.join(__dirname, 'renderer', 'index.html'));
40
+ mainWindow = win;
41
+
42
+ // These fire on every route into maximise/fullscreen — the window buttons,
43
+ // F11, double-clicking the draggable tab strip — which polling isMaximized()
44
+ // from the shake handler cannot cover, since by then the OS has already
45
+ // snapshotted the displaced frame.
46
+ const enterTransition = () => {
47
+ if (!restingBounds) return;
48
+ preTransitionBounds = restingBounds;
49
+ restingBounds = null;
50
+ };
51
+ const leaveTransition = () => {
52
+ if (!preTransitionBounds) return;
53
+ const bounds = preTransitionBounds;
54
+ preTransitionBounds = null;
55
+ if (win.isDestroyed()) return;
56
+ win.setBounds({ x: bounds.x, y: bounds.y });
57
+ };
58
+ win.on('maximize', enterTransition);
59
+ win.on('enter-full-screen', enterTransition);
60
+ win.on('unmaximize', leaveTransition);
61
+ win.on('leave-full-screen', leaveTransition);
62
+
63
+ win.on('closed', () => {
64
+ if (mainWindow === win) {
65
+ mainWindow = null;
66
+ restingBounds = null;
67
+ preTransitionBounds = null;
68
+ }
69
+ });
70
+ }
71
+
72
+ // Assumes a single window: the handler always targets mainWindow rather than
73
+ // the sender. That holds today because `activate` only ever creates one
74
+ // window when none exist. If a "new window" feature is added, this would
75
+ // need to resolve the sender via BrowserWindow.fromWebContents instead.
76
+ ipcMain.on('pom-shake', (_event, payload = {}) => {
77
+ const { x = 0, y = 0, settled = false } = payload;
78
+ const win = mainWindow;
79
+ if (!win || win.isDestroyed()) return;
80
+ // Moot while maximised or fullscreen. The transition handlers in
81
+ // createWindow already took the resting position off restingBounds, so
82
+ // there is no stale base left here to teleport the window with later.
83
+ if (win.isFullScreen() || win.isMaximized()) return;
84
+
85
+ if (settled) {
86
+ if (restingBounds) {
87
+ win.setBounds({ x: restingBounds.x, y: restingBounds.y });
88
+ restingBounds = null;
89
+ }
90
+ return;
91
+ }
92
+
93
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return;
94
+
95
+ if (!restingBounds) {
96
+ const { x: restX, y: restY } = win.getBounds();
97
+ restingBounds = { x: restX, y: restY };
98
+ }
99
+ win.setBounds({
100
+ x: Math.round(restingBounds.x + x),
101
+ y: Math.round(restingBounds.y + y),
102
+ });
103
+ });
104
+
105
+ app.whenReady().then(() => {
106
+ createWindow();
107
+ app.on('activate', () => {
108
+ if (BrowserWindow.getAllWindows().length === 0) createWindow();
109
+ });
110
+ });
111
+
112
+ app.on('window-all-closed', () => {
113
+ if (process.platform !== 'darwin') app.quit();
114
+ });
@@ -0,0 +1,21 @@
1
+ import { parseAtlas } from './atlas.js';
2
+
3
+ // The renderer runs with nodeIntegration, so atlases are read straight off
4
+ // disk. The extension had to fetch them to dodge page CSP; that constraint
5
+ // does not exist inside our own chrome.
6
+ export function createAtlasLoader({ tier, assetsDir }) {
7
+ return async function load(key) {
8
+ const { readFile } = require('node:fs/promises');
9
+ const path = require('node:path');
10
+
11
+ const base = path.join(assetsDir, tier, `${key}_${tier}`);
12
+ const json = JSON.parse(await readFile(`${base}.json`, 'utf8'));
13
+ const parsed = parseAtlas(json);
14
+
15
+ const bytes = await readFile(path.join(assetsDir, tier, parsed.image));
16
+ const blob = new Blob([bytes], { type: 'image/webp' });
17
+ const image = await createImageBitmap(blob);
18
+
19
+ return { image, frames: parsed.frames };
20
+ };
21
+ }
@@ -0,0 +1,83 @@
1
+ import { ATLAS_CACHE_LIMIT } from './config.js';
2
+
3
+ // TexturePacker sheets here are never rotated, so a frame is a plain sub-rect
4
+ // plus the trim offset inside a constant source box.
5
+ export function parseAtlas(json) {
6
+ const animationNames = Object.keys(json.animations);
7
+ const order = json.animations[animationNames[0]];
8
+ const frames = order.map((key) => {
9
+ const entry = json.frames[key];
10
+ return {
11
+ sx: entry.frame.x,
12
+ sy: entry.frame.y,
13
+ sw: entry.frame.w,
14
+ sh: entry.frame.h,
15
+ ox: entry.spriteSourceSize.x,
16
+ oy: entry.spriteSourceSize.y,
17
+ sourceW: entry.sourceSize.w,
18
+ sourceH: entry.sourceSize.h,
19
+ };
20
+ });
21
+ return { image: json.meta.image, frames };
22
+ }
23
+
24
+ export function createAtlasCache({ limit = ATLAS_CACHE_LIMIT, load }) {
25
+ const entries = new Map(); // key -> atlas, insertion order is LRU order
26
+ const pending = new Map(); // key -> promise
27
+ let cleared = false;
28
+
29
+ function release(atlas) {
30
+ if (atlas && atlas.image && typeof atlas.image.close === 'function') {
31
+ atlas.image.close();
32
+ }
33
+ }
34
+
35
+ return {
36
+ async get(key) {
37
+ if (entries.has(key)) {
38
+ const atlas = entries.get(key);
39
+ entries.delete(key);
40
+ entries.set(key, atlas);
41
+ return atlas;
42
+ }
43
+ if (pending.has(key)) return pending.get(key);
44
+
45
+ const promise = load(key)
46
+ .then((atlas) => {
47
+ pending.delete(key);
48
+ // clear() (teardown mid-load) may have already run while this
49
+ // load was outstanding. Storing into a cleared cache would
50
+ // resurrect a bitmap nobody will ever release; close it instead
51
+ // of caching it.
52
+ if (cleared) {
53
+ release(atlas);
54
+ return atlas;
55
+ }
56
+ entries.set(key, atlas);
57
+ while (entries.size > limit) {
58
+ const oldest = entries.keys().next().value;
59
+ release(entries.get(oldest));
60
+ entries.delete(oldest);
61
+ }
62
+ return atlas;
63
+ })
64
+ .catch((error) => {
65
+ pending.delete(key);
66
+ throw error;
67
+ });
68
+ pending.set(key, promise);
69
+ return promise;
70
+ },
71
+
72
+ peek(key) {
73
+ return entries.get(key);
74
+ },
75
+
76
+ clear() {
77
+ cleared = true;
78
+ for (const atlas of entries.values()) release(atlas);
79
+ entries.clear();
80
+ pending.clear();
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,41 @@
1
+ // Motion constants lifted verbatim from screen.toys/poms/app.js.
2
+ export const MOVE_ANGLE_DEG = 50;
3
+ export const SPREAD_STRENGTH = -40;
4
+ export const BASE_SPEED = 4;
5
+ export const MAX_SPEED_FACTOR = 3.0;
6
+ export const SPAWN_RANDOMNESS = 1.0;
7
+ export const SHOCKWAVE_SPEED_MULT = 0.3;
8
+
9
+ export const SHAKE_DURATION = 8;
10
+ export const SHAKE_MAX_X = 2;
11
+ export const SHAKE_MAX_Y = 10;
12
+ export const SHAKE_MAX_ROT_DEG = 0.5;
13
+
14
+ export const SPAWN_LINE_START = { x: 0.05, y: 0.2 };
15
+ export const SPAWN_LINE_END = { x: 0.7, y: 0.0 };
16
+
17
+ export const MIN_SCALE = 0.5;
18
+ export const MAX_SCALE = 2.0;
19
+ export const CULL_MARGIN = 800;
20
+
21
+ export const POM_ANCHOR = { x: 0.5, y: 0.7 };
22
+ export const SHOCKWAVE_ANCHOR = { x: 0.45, y: 0.25 };
23
+
24
+ export const CHARACTER_COUNT = 10;
25
+ export const MOBILE_BREAKPOINT = 800;
26
+
27
+ // Introduced here; see the plan's Global Constraints for why.
28
+ export const UPDATES_PER_SECOND = 30;
29
+ export const DRAG_SPAWN_UPDATES = 2;
30
+ // A held drag naturally ceils at 56 live sprites (48 poms + 8 shockwaves);
31
+ // MAX_ACTIVE is a safety ceiling above that, not an active fill-rate
32
+ // control. See the plan's Global Constraints.
33
+ export const MAX_ACTIVE = 60;
34
+ export const IDLE_TEARDOWN_MS = 30000;
35
+ export const ROSTER_SIZE = 3;
36
+ // Must stay ROSTER_SIZE + 1: one atlas slot per live roster character plus
37
+ // the shared shockwave atlas. main.js keeps exactly that many warm at all
38
+ // times; raising ROSTER_SIZE without raising this would let the LRU cache
39
+ // evict a still-on-screen sprite's atlas mid-flight. Derived rather than a
40
+ // separate literal so the two can't drift — see test/config.test.js.
41
+ export const ATLAS_CACHE_LIMIT = ROSTER_SIZE + 1;
@@ -0,0 +1,10 @@
1
+ // A <webview> is a separate frame, so a click inside a page reports
2
+ // coordinates relative to that pane. The overlay canvas spans the whole
3
+ // window, so those have to be shifted by where the pane sits.
4
+ //
5
+ // Only the horizontal offset is needed: poms spawn along a line whose
6
+ // position is parameterised by x alone (see engine.spawnPair), so a y
7
+ // translation would have nothing to feed.
8
+ export function windowX(pageX, paneRect) {
9
+ return pageX + (paneRect ? paneRect.left : 0);
10
+ }
@@ -0,0 +1,103 @@
1
+ import {
2
+ BASE_SPEED, SHOCKWAVE_SPEED_MULT, DRAG_SPAWN_UPDATES,
3
+ SHAKE_DURATION, SHAKE_MAX_X, SHAKE_MAX_Y, SHAKE_MAX_ROT_DEG,
4
+ MAX_ACTIVE, CHARACTER_COUNT, ROSTER_SIZE,
5
+ } from './config.js';
6
+ import { jitterT, spawnPoint, velocity } from './geometry.js';
7
+ import { createSprite, integrate, advanceFrame, SpriteList } from './sprites.js';
8
+
9
+ function characterKey(index) {
10
+ return `char_${String(index).padStart(2, '0')}`;
11
+ }
12
+
13
+ // Only ROSTER_SIZE characters are ever spawned on a page, so the atlas cache
14
+ // never thrashes. See the plan's Global Constraints.
15
+ export function createRoster(random = Math.random, size = ROSTER_SIZE, total = CHARACTER_COUNT) {
16
+ const pool = Array.from({ length: total }, (_, i) => characterKey(i + 1));
17
+ for (let i = pool.length - 1; i > 0; i--) {
18
+ const j = Math.floor(random() * (i + 1));
19
+ [pool[i], pool[j]] = [pool[j], pool[i]];
20
+ }
21
+ return pool.slice(0, size);
22
+ }
23
+
24
+ export function defaultPickCharacter(roster, random = Math.random) {
25
+ const index = Math.min(roster.length - 1, Math.floor(random() * roster.length));
26
+ return roster[index];
27
+ }
28
+
29
+ export function createEngine({
30
+ width, height,
31
+ random = Math.random,
32
+ roster = createRoster(random),
33
+ pickCharacter = (rng) => defaultPickCharacter(roster, rng),
34
+ frameCountFor,
35
+ }) {
36
+ const list = new SpriteList(MAX_ACTIVE);
37
+ const camera = { x: 0, y: 0, rotation: 0 };
38
+
39
+ let shakeTimer = 0;
40
+ let held = false;
41
+ let pointerX = 0;
42
+ let updatesSinceSpawn = 0;
43
+
44
+ function spawnPair(t) {
45
+ const finalT = jitterT(t, random);
46
+ const { x, y } = spawnPoint(finalT, width, height);
47
+ const characterKey = pickCharacter(random);
48
+
49
+ for (const isShockwave of [true, false]) {
50
+ const key = isShockwave ? 'shockwave' : characterKey;
51
+ const speed = isShockwave ? BASE_SPEED * SHOCKWAVE_SPEED_MULT : BASE_SPEED;
52
+ const { vx, vy } = velocity(finalT, speed);
53
+ list.add(createSprite({
54
+ key, isShockwave, x, y, vx, vy,
55
+ frameCount: frameCountFor(key),
56
+ }));
57
+ }
58
+
59
+ shakeTimer = SHAKE_DURATION;
60
+ updatesSinceSpawn = 0;
61
+ }
62
+
63
+ function applyShake() {
64
+ if (shakeTimer <= 0) {
65
+ camera.x = 0; camera.y = 0; camera.rotation = 0;
66
+ return;
67
+ }
68
+ const intensity = shakeTimer / SHAKE_DURATION;
69
+ camera.x = (random() * 2 - 1) * SHAKE_MAX_X * intensity;
70
+ camera.y = (random() * 2 - 1) * SHAKE_MAX_Y * intensity;
71
+ camera.rotation =
72
+ (random() * 2 - 1) * ((SHAKE_MAX_ROT_DEG * Math.PI) / 180) * intensity;
73
+ shakeTimer -= 1;
74
+ }
75
+
76
+ function update() {
77
+ updatesSinceSpawn += 1;
78
+ if (held && updatesSinceSpawn >= DRAG_SPAWN_UPDATES) {
79
+ spawnPair(pointerX / width);
80
+ }
81
+
82
+ applyShake();
83
+
84
+ for (const sprite of list.items) {
85
+ integrate(sprite, height);
86
+ advanceFrame(sprite);
87
+ }
88
+ list.prune(width, height);
89
+ list.items.sort((a, b) => a.zIndex - b.zIndex);
90
+ }
91
+
92
+ return {
93
+ spawnPair,
94
+ update,
95
+ resize(nextWidth, nextHeight) { width = nextWidth; height = nextHeight; },
96
+ pointerDown(x) { held = true; pointerX = x; spawnPair(x / width); },
97
+ pointerMove(x) { pointerX = x; },
98
+ pointerUp() { held = false; },
99
+ get sprites() { return list.items; },
100
+ get camera() { return camera; },
101
+ get isIdle() { return list.items.length === 0; },
102
+ };
103
+ }
@@ -0,0 +1,43 @@
1
+ import {
2
+ MOVE_ANGLE_DEG, SPREAD_STRENGTH, SPAWN_RANDOMNESS,
3
+ SPAWN_LINE_START, SPAWN_LINE_END,
4
+ MIN_SCALE, MAX_SCALE, MAX_SPEED_FACTOR,
5
+ } from './config.js';
6
+
7
+ export function jitterT(t, random = Math.random) {
8
+ const offset = (random() - 0.5) * (1 / 9) * SPAWN_RANDOMNESS;
9
+ return Math.max(0, Math.min(1, t + offset));
10
+ }
11
+
12
+ export function spawnPoint(t, width, height) {
13
+ const startX = width * SPAWN_LINE_START.x;
14
+ const startY = height * SPAWN_LINE_START.y;
15
+ const endX = width * SPAWN_LINE_END.x;
16
+ const endY = height * SPAWN_LINE_END.y;
17
+ return {
18
+ x: startX + t * (endX - startX),
19
+ y: startY + t * (endY - startY),
20
+ };
21
+ }
22
+
23
+ export function headingRadians(t) {
24
+ const degrees = MOVE_ANGLE_DEG + (t - 0.5) * SPREAD_STRENGTH;
25
+ return (degrees * Math.PI) / 180;
26
+ }
27
+
28
+ export function velocity(t, speed) {
29
+ const radians = headingRadians(t);
30
+ return { vx: Math.cos(radians) * speed, vy: Math.sin(radians) * speed };
31
+ }
32
+
33
+ export function depthFactor(y, height) {
34
+ return Math.max(0, y / height);
35
+ }
36
+
37
+ export function scaleForDepth(d) {
38
+ return MIN_SCALE + (MAX_SCALE - MIN_SCALE) * d;
39
+ }
40
+
41
+ export function speedMultForDepth(d) {
42
+ return 1 + (MAX_SPEED_FACTOR - 1) * d;
43
+ }