@netforge/engine 0.0.0-stage → 0.2.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 (245) hide show
  1. package/GAME-DESIGN.md +195 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +30 -0
  4. package/README.md +100 -2
  5. package/dist/api/appearance.d.ts +37 -0
  6. package/dist/api/appearance.js +41 -0
  7. package/dist/api/cards.d.ts +105 -0
  8. package/dist/api/cards.js +133 -0
  9. package/dist/api/collector.d.ts +114 -0
  10. package/dist/api/collector.js +204 -0
  11. package/dist/api/dungeon.d.ts +58 -0
  12. package/dist/api/dungeon.js +216 -0
  13. package/dist/api/harness.d.ts +149 -0
  14. package/dist/api/harness.js +549 -0
  15. package/dist/api/idle.d.ts +71 -0
  16. package/dist/api/idle.js +104 -0
  17. package/dist/api/race-harness.d.ts +3 -0
  18. package/dist/api/race-harness.js +148 -0
  19. package/dist/api/registry.d.ts +241 -0
  20. package/dist/api/registry.js +394 -0
  21. package/dist/api/scheduler.d.ts +56 -0
  22. package/dist/api/scheduler.js +160 -0
  23. package/dist/api/schemas.d.ts +958 -0
  24. package/dist/api/schemas.js +859 -0
  25. package/dist/api/tower.d.ts +127 -0
  26. package/dist/api/tower.js +183 -0
  27. package/dist/game/adapter.d.ts +69 -0
  28. package/dist/game/adapter.js +341 -0
  29. package/dist/game/arena-bot.d.ts +66 -0
  30. package/dist/game/arena-bot.js +188 -0
  31. package/dist/game/bot.d.ts +95 -0
  32. package/dist/game/bot.js +285 -0
  33. package/dist/game/compile.d.ts +97 -0
  34. package/dist/game/compile.js +476 -0
  35. package/dist/game/director.d.ts +42 -0
  36. package/dist/game/director.js +179 -0
  37. package/dist/game/earn-source.d.ts +122 -0
  38. package/dist/game/earn-source.js +386 -0
  39. package/dist/game/earn.d.ts +49 -0
  40. package/dist/game/earn.js +55 -0
  41. package/dist/game/index.d.ts +37 -0
  42. package/dist/game/index.js +25 -0
  43. package/dist/game/platformer.d.ts +156 -0
  44. package/dist/game/platformer.js +619 -0
  45. package/dist/game/profile.d.ts +97 -0
  46. package/dist/game/profile.js +140 -0
  47. package/dist/game/race.d.ts +177 -0
  48. package/dist/game/race.js +386 -0
  49. package/dist/game/racer.d.ts +98 -0
  50. package/dist/game/racer.js +379 -0
  51. package/dist/game/runner.d.ts +286 -0
  52. package/dist/game/runner.js +824 -0
  53. package/dist/game/threats.d.ts +23 -0
  54. package/dist/game/threats.js +49 -0
  55. package/dist/game/view.d.ts +47 -0
  56. package/dist/game/view.js +123 -0
  57. package/dist/index.d.ts +33 -0
  58. package/dist/index.js +24 -0
  59. package/dist/net/bits.d.ts +33 -0
  60. package/dist/net/bits.js +135 -0
  61. package/dist/net/client.d.ts +75 -0
  62. package/dist/net/client.js +151 -0
  63. package/dist/net/index.d.ts +23 -0
  64. package/dist/net/index.js +23 -0
  65. package/dist/net/input.d.ts +49 -0
  66. package/dist/net/input.js +112 -0
  67. package/dist/net/interpolation.d.ts +37 -0
  68. package/dist/net/interpolation.js +97 -0
  69. package/dist/net/netsim.d.ts +22 -0
  70. package/dist/net/netsim.js +52 -0
  71. package/dist/net/prediction.d.ts +31 -0
  72. package/dist/net/prediction.js +101 -0
  73. package/dist/net/profile-client.d.ts +75 -0
  74. package/dist/net/profile-client.js +173 -0
  75. package/dist/net/protocol.d.ts +215 -0
  76. package/dist/net/protocol.js +10 -0
  77. package/dist/net/room-client.d.ts +65 -0
  78. package/dist/net/room-client.js +156 -0
  79. package/dist/net/schema.d.ts +78 -0
  80. package/dist/net/schema.js +121 -0
  81. package/dist/net/sim-adapter.d.ts +134 -0
  82. package/dist/net/sim-adapter.js +22 -0
  83. package/dist/net/snapshot.d.ts +61 -0
  84. package/dist/net/snapshot.js +301 -0
  85. package/dist/rules/adapter.d.ts +53 -0
  86. package/dist/rules/adapter.js +261 -0
  87. package/dist/rules/cards.d.ts +115 -0
  88. package/dist/rules/cards.js +547 -0
  89. package/dist/rules/collector.d.ts +161 -0
  90. package/dist/rules/collector.js +732 -0
  91. package/dist/rules/genres.d.ts +13 -0
  92. package/dist/rules/genres.js +38 -0
  93. package/dist/rules/harness.d.ts +16 -0
  94. package/dist/rules/harness.js +127 -0
  95. package/dist/rules/idle.d.ts +115 -0
  96. package/dist/rules/idle.js +370 -0
  97. package/dist/rules/index.d.ts +16 -0
  98. package/dist/rules/index.js +14 -0
  99. package/dist/rules/kit.d.ts +171 -0
  100. package/dist/rules/kit.js +88 -0
  101. package/dist/rules/tower.d.ts +214 -0
  102. package/dist/rules/tower.js +655 -0
  103. package/dist/runtime/arena.d.ts +86 -0
  104. package/dist/runtime/arena.js +269 -0
  105. package/dist/runtime/assets.d.ts +26 -0
  106. package/dist/runtime/assets.js +35 -0
  107. package/dist/runtime/camera.d.ts +113 -0
  108. package/dist/runtime/camera.js +247 -0
  109. package/dist/runtime/crowd.d.ts +18 -0
  110. package/dist/runtime/crowd.js +62 -0
  111. package/dist/runtime/feel/audio.d.ts +150 -0
  112. package/dist/runtime/feel/audio.js +327 -0
  113. package/dist/runtime/feel/clock.d.ts +52 -0
  114. package/dist/runtime/feel/clock.js +80 -0
  115. package/dist/runtime/feel/flash.d.ts +28 -0
  116. package/dist/runtime/feel/flash.js +60 -0
  117. package/dist/runtime/feel/index.d.ts +27 -0
  118. package/dist/runtime/feel/index.js +16 -0
  119. package/dist/runtime/feel/kit.d.ts +159 -0
  120. package/dist/runtime/feel/kit.js +226 -0
  121. package/dist/runtime/feel/knockback.d.ts +41 -0
  122. package/dist/runtime/feel/knockback.js +70 -0
  123. package/dist/runtime/feel/noise.d.ts +6 -0
  124. package/dist/runtime/feel/noise.js +30 -0
  125. package/dist/runtime/feel/numbers.d.ts +91 -0
  126. package/dist/runtime/feel/numbers.js +172 -0
  127. package/dist/runtime/feel/rumble.d.ts +40 -0
  128. package/dist/runtime/feel/rumble.js +58 -0
  129. package/dist/runtime/feel/sound.d.ts +96 -0
  130. package/dist/runtime/feel/sound.js +176 -0
  131. package/dist/runtime/feel/spring.d.ts +42 -0
  132. package/dist/runtime/feel/spring.js +95 -0
  133. package/dist/runtime/feel/trauma.d.ts +48 -0
  134. package/dist/runtime/feel/trauma.js +76 -0
  135. package/dist/runtime/feel/tween.d.ts +66 -0
  136. package/dist/runtime/feel/tween.js +180 -0
  137. package/dist/runtime/feel/types.d.ts +30 -0
  138. package/dist/runtime/feel/types.js +5 -0
  139. package/dist/runtime/index.d.ts +28 -0
  140. package/dist/runtime/index.js +26 -0
  141. package/dist/runtime/input.d.ts +38 -0
  142. package/dist/runtime/input.js +159 -0
  143. package/dist/runtime/interpolate.d.ts +6 -0
  144. package/dist/runtime/interpolate.js +13 -0
  145. package/dist/runtime/render/animation.d.ts +63 -0
  146. package/dist/runtime/render/animation.js +128 -0
  147. package/dist/runtime/render/atmosphere.d.ts +32 -0
  148. package/dist/runtime/render/atmosphere.js +59 -0
  149. package/dist/runtime/render/crowd-motion.d.ts +68 -0
  150. package/dist/runtime/render/crowd-motion.js +163 -0
  151. package/dist/runtime/render/decals.d.ts +64 -0
  152. package/dist/runtime/render/decals.js +222 -0
  153. package/dist/runtime/render/highlight.d.ts +29 -0
  154. package/dist/runtime/render/highlight.js +110 -0
  155. package/dist/runtime/render/index.d.ts +35 -0
  156. package/dist/runtime/render/index.js +22 -0
  157. package/dist/runtime/render/lighting.d.ts +50 -0
  158. package/dist/runtime/render/lighting.js +142 -0
  159. package/dist/runtime/render/particles.d.ts +117 -0
  160. package/dist/runtime/render/particles.js +289 -0
  161. package/dist/runtime/render/post.d.ts +92 -0
  162. package/dist/runtime/render/post.js +191 -0
  163. package/dist/runtime/render/presets.d.ts +57 -0
  164. package/dist/runtime/render/presets.js +116 -0
  165. package/dist/runtime/render/quality.d.ts +113 -0
  166. package/dist/runtime/render/quality.js +180 -0
  167. package/dist/runtime/render/rig.d.ts +29 -0
  168. package/dist/runtime/render/rig.js +107 -0
  169. package/dist/runtime/render/sprite.d.ts +34 -0
  170. package/dist/runtime/render/sprite.js +58 -0
  171. package/dist/runtime/render/stack.d.ts +74 -0
  172. package/dist/runtime/render/stack.js +132 -0
  173. package/dist/runtime/render/vat.d.ts +78 -0
  174. package/dist/runtime/render/vat.js +219 -0
  175. package/dist/runtime/renderer.d.ts +19 -0
  176. package/dist/runtime/renderer.js +18 -0
  177. package/dist/runtime/stats.d.ts +11 -0
  178. package/dist/runtime/stats.js +29 -0
  179. package/dist/runtime/styles/handdrawn/ground.d.ts +11 -0
  180. package/dist/runtime/styles/handdrawn/ground.js +282 -0
  181. package/dist/runtime/styles/handdrawn/index.d.ts +4 -0
  182. package/dist/runtime/styles/handdrawn/index.js +94 -0
  183. package/dist/runtime/styles/handdrawn/ink.d.ts +31 -0
  184. package/dist/runtime/styles/handdrawn/ink.js +68 -0
  185. package/dist/runtime/styles/handdrawn/looks.d.ts +18 -0
  186. package/dist/runtime/styles/handdrawn/looks.js +42 -0
  187. package/dist/runtime/styles/handdrawn/paint.d.ts +59 -0
  188. package/dist/runtime/styles/handdrawn/paint.js +129 -0
  189. package/dist/runtime/styles/handdrawn/palette.d.ts +49 -0
  190. package/dist/runtime/styles/handdrawn/palette.js +70 -0
  191. package/dist/runtime/styles/handdrawn/textures.d.ts +27 -0
  192. package/dist/runtime/styles/handdrawn/textures.js +150 -0
  193. package/dist/runtime/styles/handdrawn/vfx.d.ts +14 -0
  194. package/dist/runtime/styles/handdrawn/vfx.js +73 -0
  195. package/dist/runtime/styles/index.d.ts +9 -0
  196. package/dist/runtime/styles/index.js +32 -0
  197. package/dist/runtime/styles/kit.d.ts +193 -0
  198. package/dist/runtime/styles/kit.js +1 -0
  199. package/dist/runtime/styles/pixel/index.d.ts +46 -0
  200. package/dist/runtime/styles/pixel/index.js +167 -0
  201. package/dist/runtime/styles/ps1/index.d.ts +29 -0
  202. package/dist/runtime/styles/ps1/index.js +41 -0
  203. package/dist/runtime/styles/ps1/lights.d.ts +47 -0
  204. package/dist/runtime/styles/ps1/lights.js +146 -0
  205. package/dist/runtime/styles/ps1/materials.d.ts +62 -0
  206. package/dist/runtime/styles/ps1/materials.js +285 -0
  207. package/dist/runtime/styles/ps1/pipeline.d.ts +52 -0
  208. package/dist/runtime/styles/ps1/pipeline.js +111 -0
  209. package/dist/runtime/styles/ps1/surfaces.d.ts +44 -0
  210. package/dist/runtime/styles/ps1/surfaces.js +228 -0
  211. package/dist/runtime/styles/toon/index.d.ts +5 -0
  212. package/dist/runtime/styles/toon/index.js +178 -0
  213. package/dist/runtime/styles/toon/ink.d.ts +39 -0
  214. package/dist/runtime/styles/toon/ink.js +73 -0
  215. package/dist/runtime/styles/toon/look.d.ts +10 -0
  216. package/dist/runtime/styles/toon/look.js +112 -0
  217. package/dist/runtime/styles/toon/material.d.ts +74 -0
  218. package/dist/runtime/styles/toon/material.js +208 -0
  219. package/dist/runtime/styles/toon/normals.d.ts +26 -0
  220. package/dist/runtime/styles/toon/normals.js +127 -0
  221. package/dist/runtime/styles/toon/shadows.d.ts +24 -0
  222. package/dist/runtime/styles/toon/shadows.js +42 -0
  223. package/dist/runtime/styles/toon/vfx.d.ts +15 -0
  224. package/dist/runtime/styles/toon/vfx.js +47 -0
  225. package/dist/runtime/styles/view.d.ts +35 -0
  226. package/dist/runtime/styles/view.js +34 -0
  227. package/dist/runtime/styles/voxel/core.d.ts +114 -0
  228. package/dist/runtime/styles/voxel/core.js +577 -0
  229. package/dist/runtime/styles/voxel/geometry.d.ts +27 -0
  230. package/dist/runtime/styles/voxel/geometry.js +40 -0
  231. package/dist/runtime/styles/voxel/index.d.ts +21 -0
  232. package/dist/runtime/styles/voxel/index.js +23 -0
  233. package/dist/runtime/styles/voxel/kit.d.ts +6 -0
  234. package/dist/runtime/styles/voxel/kit.js +11 -0
  235. package/dist/runtime/styles/voxel/look.d.ts +10 -0
  236. package/dist/runtime/styles/voxel/look.js +16 -0
  237. package/dist/runtime/styles/voxel/props.d.ts +9 -0
  238. package/dist/runtime/styles/voxel/props.js +52 -0
  239. package/dist/runtime/styles/voxel/shapes.d.ts +41 -0
  240. package/dist/runtime/styles/voxel/shapes.js +83 -0
  241. package/dist/runtime/styles/voxel/world.d.ts +49 -0
  242. package/dist/runtime/styles/voxel/world.js +422 -0
  243. package/llms-full.txt +460 -0
  244. package/llms.txt +183 -0
  245. package/package.json +104 -3
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Floating damage numbers. `NumberStack` is the DOM-free core (slots,
3
+ * merging, motion); `DamageNumbers` draws it with a fixed pool of DOM nodes
4
+ * moved by transforms only (no layout per frame).
5
+ */
6
+ const DRIFT = [-14, 9, -5, 13, -10, 4, 11, -7];
7
+ export class NumberStack {
8
+ slots;
9
+ /** Numbers shown (merges do not count). */
10
+ shown = 0;
11
+ merged = 0;
12
+ o;
13
+ seq = 0;
14
+ constructor(capacity = 48, opts = {}) {
15
+ this.o = { mergeMs: 120, lifeSeconds: 0.75, rise: 34, ...opts };
16
+ this.slots = Array.from({ length: Math.max(1, capacity) }, () => ({
17
+ active: false, id: -1, amount: 0, t: 1, x: 0, y: 0, drift: 0, big: false, kind: "damage", at: 0, gen: 0,
18
+ }));
19
+ }
20
+ /** Add a number; returns its slot index and whether it merged into an existing one. */
21
+ add(nowMs, n) {
22
+ const id = n.id ?? -1;
23
+ if (id >= 0) {
24
+ for (let i = 0; i < this.slots.length; i++) {
25
+ const s = this.slots[i];
26
+ if (s.active && s.id === id && nowMs - s.at <= this.o.mergeMs && s.kind === (n.kind ?? "damage")) {
27
+ s.amount += n.amount;
28
+ s.at = nowMs;
29
+ s.x = n.x;
30
+ s.y = n.y;
31
+ s.big = s.big || n.big === true;
32
+ // Re-pop without restarting the fade completely.
33
+ s.t = Math.min(s.t, 0.08);
34
+ this.merged++;
35
+ return { index: i, merged: true };
36
+ }
37
+ }
38
+ }
39
+ // A free slot, else recycle the oldest (cap on screen).
40
+ let index = -1;
41
+ let oldest = -1;
42
+ let oldestT = -1;
43
+ for (let i = 0; i < this.slots.length; i++) {
44
+ const s = this.slots[i];
45
+ if (!s.active) {
46
+ index = i;
47
+ break;
48
+ }
49
+ if (s.t > oldestT) {
50
+ oldestT = s.t;
51
+ oldest = i;
52
+ }
53
+ }
54
+ if (index < 0)
55
+ index = oldest;
56
+ const s = this.slots[index];
57
+ s.active = true;
58
+ s.id = id;
59
+ s.amount = n.amount;
60
+ s.t = 0;
61
+ s.x = n.x;
62
+ s.y = n.y;
63
+ s.drift = DRIFT[this.seq++ % DRIFT.length] ?? 0;
64
+ s.big = n.big === true;
65
+ s.kind = n.kind ?? "damage";
66
+ s.at = nowMs;
67
+ s.gen++;
68
+ this.shown++;
69
+ return { index, merged: false };
70
+ }
71
+ update(dt) {
72
+ if (dt <= 0)
73
+ return;
74
+ const k = dt / this.o.lifeSeconds;
75
+ for (const s of this.slots) {
76
+ if (!s.active)
77
+ continue;
78
+ s.t = Math.min(1, s.t + k);
79
+ if (s.t >= 1)
80
+ s.active = false;
81
+ }
82
+ }
83
+ /** Where and how to draw a slot: an arc (rise with ease-out, sideways drift), a pop, then a fade. */
84
+ draw(s) {
85
+ const t = s.t;
86
+ const rise = this.o.rise * (1 - (1 - t) * (1 - t)) * (s.big ? 1.3 : 1);
87
+ const pop = t < 0.12 ? 1 + (0.12 - t) * (s.big ? 7 : 4.5) : 1;
88
+ const base = s.big ? 1.35 : 1;
89
+ return {
90
+ x: s.x + s.drift * t,
91
+ y: s.y - rise,
92
+ scale: base * pop,
93
+ opacity: t < 0.6 ? 1 : 1 - (t - 0.6) / 0.4,
94
+ };
95
+ }
96
+ get active() {
97
+ let n = 0;
98
+ for (const s of this.slots)
99
+ if (s.active)
100
+ n++;
101
+ return n;
102
+ }
103
+ }
104
+ /** The DOM pool. Construct only in a browser (it creates nodes under `host`). */
105
+ export class DamageNumbers {
106
+ stack;
107
+ els = [];
108
+ colors;
109
+ format;
110
+ cls;
111
+ constructor(host, opts = {}) {
112
+ const o = typeof opts === "number" ? { capacity: opts } : opts;
113
+ this.stack = new NumberStack(o.capacity ?? 48, o);
114
+ this.colors = o.colors ?? {};
115
+ this.format = o.format ?? ((a) => String(Math.round(a)));
116
+ this.cls = o.className ?? "dmg";
117
+ const doc = host.ownerDocument ?? (typeof document !== "undefined" ? document : null);
118
+ if (!doc)
119
+ return;
120
+ for (let i = 0; i < this.stack.slots.length; i++) {
121
+ const el = doc.createElement("div");
122
+ el.className = this.cls;
123
+ el.style.opacity = "0";
124
+ el.style.willChange = "transform, opacity";
125
+ host.append(el);
126
+ this.els.push({ el, gen: -1, amount: Number.NaN, shown: false });
127
+ }
128
+ }
129
+ /** Numbers shown so far (playtest counters). */
130
+ get shown() {
131
+ return this.stack.shown;
132
+ }
133
+ /** The classic call: show(x, y, amount, big). */
134
+ show(x, y, amount, big = false) {
135
+ this.add({ x, y, amount, big });
136
+ }
137
+ add(n, nowMs = typeof performance !== "undefined" ? performance.now() : Date.now()) {
138
+ this.stack.add(nowMs, n);
139
+ }
140
+ update(dt) {
141
+ this.stack.update(dt);
142
+ const slots = this.stack.slots;
143
+ for (let i = 0; i < this.els.length; i++) {
144
+ const r = this.els[i];
145
+ const s = slots[i];
146
+ if (!r || !s)
147
+ continue;
148
+ if (!s.active) {
149
+ if (r.shown) {
150
+ r.el.style.opacity = "0";
151
+ r.shown = false;
152
+ }
153
+ continue;
154
+ }
155
+ if (r.gen !== s.gen) {
156
+ r.gen = s.gen;
157
+ r.el.className = s.big ? `${this.cls} big` : this.cls;
158
+ if (s.kind !== "damage")
159
+ r.el.classList.add(s.kind);
160
+ r.el.style.color = this.colors[s.kind] ?? "";
161
+ }
162
+ if (r.amount !== s.amount) {
163
+ r.amount = s.amount;
164
+ r.el.textContent = this.format(s.amount);
165
+ }
166
+ const d = this.stack.draw(s);
167
+ r.shown = true;
168
+ r.el.style.opacity = d.opacity.toFixed(3);
169
+ r.el.style.transform = `translate(${d.x.toFixed(1)}px, ${d.y.toFixed(1)}px) translate(-50%, -50%) scale(${d.scale.toFixed(3)})`;
170
+ }
171
+ }
172
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Controller rumble (Gamepad API dual-rumble) with a phone vibration
3
+ * fallback, rate-limited so a swarm of hits is one buzz, not fifty.
4
+ */
5
+ export interface RumbleActuator {
6
+ playEffect?(type: string, params: {
7
+ duration: number;
8
+ strongMagnitude: number;
9
+ weakMagnitude: number;
10
+ startDelay?: number;
11
+ }): Promise<unknown>;
12
+ }
13
+ export interface GamepadLike {
14
+ readonly connected?: boolean;
15
+ readonly vibrationActuator?: RumbleActuator | null;
16
+ }
17
+ export interface NavigatorLike {
18
+ getGamepads?(): readonly (GamepadLike | null)[];
19
+ vibrate?(pattern: number | number[]): boolean;
20
+ }
21
+ export interface RumbleOptions {
22
+ /** Least ms between pulses (a stronger pulse may still cut in). Default 70. */
23
+ readonly minGapMs?: number;
24
+ /** Use navigator.vibrate when no pad rumbles (phones). Default true. */
25
+ readonly vibrateFallback?: boolean;
26
+ readonly navigator?: NavigatorLike;
27
+ readonly now?: () => number;
28
+ }
29
+ export declare class Rumble {
30
+ enabled: boolean;
31
+ /** Pulses sent (playtest counters). */
32
+ pulses: number;
33
+ private last;
34
+ private lastStrength;
35
+ private lastUntil;
36
+ private readonly o;
37
+ constructor(opts?: RumbleOptions);
38
+ /** Rumble connected pads: `strong` and `weak` motors 0..1 for `ms`. Returns whether anything was sent. */
39
+ pulse(strong: number, weak?: number, ms?: number): boolean;
40
+ }
@@ -0,0 +1,58 @@
1
+ export class Rumble {
2
+ enabled = true;
3
+ /** Pulses sent (playtest counters). */
4
+ pulses = 0;
5
+ last = -Infinity;
6
+ lastStrength = 0;
7
+ lastUntil = 0;
8
+ o;
9
+ constructor(opts = {}) {
10
+ this.o = {
11
+ minGapMs: opts.minGapMs ?? 70,
12
+ vibrateFallback: opts.vibrateFallback ?? true,
13
+ nav: () => opts.navigator ?? (typeof navigator !== "undefined" ? navigator : undefined),
14
+ now: opts.now ?? (() => (typeof performance !== "undefined" ? performance.now() : Date.now())),
15
+ };
16
+ }
17
+ /** Rumble connected pads: `strong` and `weak` motors 0..1 for `ms`. Returns whether anything was sent. */
18
+ pulse(strong, weak = strong * 0.6, ms = 120) {
19
+ if (!this.enabled)
20
+ return false;
21
+ const s = Math.max(0, Math.min(1, strong));
22
+ const w = Math.max(0, Math.min(1, weak));
23
+ if (s === 0 && w === 0)
24
+ return false;
25
+ const now = this.o.now();
26
+ const busy = now < this.lastUntil;
27
+ const strength = Math.max(s, w);
28
+ // Rate limit, unless this one is clearly stronger than the one playing.
29
+ if (now - this.last < this.o.minGapMs && !(busy && strength > this.lastStrength * 1.5))
30
+ return false;
31
+ const nav = this.o.nav();
32
+ if (!nav)
33
+ return false;
34
+ let sent = false;
35
+ try {
36
+ for (const pad of nav.getGamepads?.() ?? []) {
37
+ const act = pad?.vibrationActuator;
38
+ if (!pad || pad.connected === false || !act?.playEffect)
39
+ continue;
40
+ void act.playEffect("dual-rumble", { duration: ms, strongMagnitude: s, weakMagnitude: w, startDelay: 0 }).catch(() => undefined);
41
+ sent = true;
42
+ }
43
+ if (!sent && this.o.vibrateFallback && typeof nav.vibrate === "function" && strength >= 0.25) {
44
+ sent = nav.vibrate(Math.round(ms * Math.min(1, strength + 0.2))) !== false;
45
+ }
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ if (sent) {
51
+ this.pulses++;
52
+ this.last = now;
53
+ this.lastStrength = strength;
54
+ this.lastUntil = now + ms;
55
+ }
56
+ return sent;
57
+ }
58
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The sound player: real CC0 samples for FeelKit's named cues and one
3
+ * music loop, with a mute that sticks.
4
+ *
5
+ * const sound = new SoundPlayer(feel.audio, { key: "emberwatch" });
6
+ * sound.load(soundBank(libraryBase)); // @netforge/asset-library: cue -> { url, gain }, music
7
+ * sound.unlockOnGesture(); // browsers start audio on the first press
8
+ * hudButton.onclick = () => label(sound.toggle());
9
+ *
10
+ * Effects decode into FeelAudio buffers under the same cue names, so every
11
+ * `feel.hit()`, `feel.kill()` and `audio.play("levelUp")` plays the sample
12
+ * and falls back to its synth recipe until it loads (or if it fails to
13
+ * decode). Music streams through an <audio> element (no decode of the whole
14
+ * track, no CORS needed for a CDN library), loops, fades in, pauses while
15
+ * the tab is hidden, and follows the mute. Nothing here runs without a
16
+ * browser: tests inject the element and the fetch.
17
+ */
18
+ import type { FeelAudio } from "./audio.js";
19
+ export interface SoundBankEntry {
20
+ readonly url: string;
21
+ /** The sample's mix level (1 = as recorded). */
22
+ readonly gain?: number;
23
+ }
24
+ /** Cue name -> sample, and an optional music loop (the asset library's `soundBank()` builds one). */
25
+ export interface SoundBank {
26
+ readonly cues: Readonly<Record<string, SoundBankEntry>>;
27
+ readonly music?: SoundBankEntry | null;
28
+ }
29
+ /** The slice of HTMLAudioElement the music uses. */
30
+ export interface MusicElement {
31
+ src: string;
32
+ loop: boolean;
33
+ volume: number;
34
+ muted: boolean;
35
+ preload: string;
36
+ readonly paused: boolean;
37
+ play(): Promise<void> | void;
38
+ pause(): void;
39
+ }
40
+ export interface SoundPlayerOptions {
41
+ /** Music volume 0..1 before the track's own gain. Default 0.4. */
42
+ readonly musicVolume?: number;
43
+ /** Effects volume multiplier on every sample's gain. Default 1. */
44
+ readonly sfxVolume?: number;
45
+ /** Start with music on (the mute still wins). Default true. */
46
+ readonly music?: boolean;
47
+ /** Storage key prefix for the remembered mute ("<key>:muted"). Default "netnet:sound". Null keeps nothing. */
48
+ readonly key?: string | null;
49
+ /** Seconds the music fades in over. Default 2. */
50
+ readonly fadeIn?: number;
51
+ /** Build the music element (tests). Default `new Audio()`. */
52
+ readonly createMusic?: () => MusicElement | null;
53
+ /** Fetch a sample's bytes (tests). Default `fetch`. */
54
+ readonly fetchBytes?: (url: string) => Promise<ArrayBuffer>;
55
+ /** Pause the music while the tab is hidden. Default true. */
56
+ readonly pauseWhenHidden?: boolean;
57
+ }
58
+ export declare class SoundPlayer {
59
+ readonly audio: FeelAudio;
60
+ /** Samples decoded so far, and ones that failed (they keep their synth recipe). */
61
+ readonly loaded: {
62
+ cues: number;
63
+ failed: number;
64
+ music: boolean;
65
+ };
66
+ private bank;
67
+ private music;
68
+ private musicGain;
69
+ private musicOn;
70
+ private mutedNow;
71
+ private fade;
72
+ private fadeTimer;
73
+ private readonly o;
74
+ constructor(audio: FeelAudio, opts?: SoundPlayerOptions);
75
+ /** Load a bank: samples decode once audio is unlocked; the music starts then too. */
76
+ load(bank: SoundBank): void;
77
+ private loadCue;
78
+ private startMusic;
79
+ private resumeMusic;
80
+ private applyMusic;
81
+ /** Start audio on the first pointer or key press (and music with it). */
82
+ unlockOnGesture(target?: EventTarget): void;
83
+ /** Muted: no effects, no music. Remembered in this browser. */
84
+ get muted(): boolean;
85
+ set muted(v: boolean);
86
+ /** Flip the mute; returns the new state (true = muted). */
87
+ toggle(): boolean;
88
+ /** Music on or off on its own (effects keep playing). */
89
+ get musicEnabled(): boolean;
90
+ set musicEnabled(v: boolean);
91
+ /** Whether the music element is playing right now. */
92
+ get musicPlaying(): boolean;
93
+ /** The bank in use (null before load). */
94
+ get current(): SoundBank | null;
95
+ dispose(): void;
96
+ }
@@ -0,0 +1,176 @@
1
+ function readStore(key) {
2
+ try {
3
+ return typeof localStorage !== "undefined" ? localStorage.getItem(key) : null;
4
+ }
5
+ catch {
6
+ return null;
7
+ }
8
+ }
9
+ function writeStore(key, value) {
10
+ try {
11
+ if (typeof localStorage !== "undefined")
12
+ localStorage.setItem(key, value);
13
+ }
14
+ catch {
15
+ // Private window or blocked storage: the choice lasts this page.
16
+ }
17
+ }
18
+ export class SoundPlayer {
19
+ audio;
20
+ /** Samples decoded so far, and ones that failed (they keep their synth recipe). */
21
+ loaded = { cues: 0, failed: 0, music: false };
22
+ bank = null;
23
+ music = null;
24
+ musicGain = 1;
25
+ musicOn;
26
+ mutedNow;
27
+ fade = 1;
28
+ fadeTimer = null;
29
+ o;
30
+ constructor(audio, opts = {}) {
31
+ this.audio = audio;
32
+ this.o = {
33
+ musicVolume: opts.musicVolume ?? 0.4,
34
+ sfxVolume: opts.sfxVolume ?? 1,
35
+ key: opts.key === undefined ? "netnet:sound" : opts.key,
36
+ fadeIn: opts.fadeIn ?? 2,
37
+ createMusic: opts.createMusic ?? (() => (typeof Audio !== "undefined" ? new Audio() : null)),
38
+ fetchBytes: opts.fetchBytes ?? (async (url) => (await fetch(url)).arrayBuffer()),
39
+ pauseWhenHidden: opts.pauseWhenHidden ?? true,
40
+ };
41
+ this.musicOn = opts.music ?? true;
42
+ this.mutedNow = this.o.key ? readStore(`${this.o.key}:muted`) === "1" : false;
43
+ audio.muted = this.mutedNow;
44
+ if (this.o.pauseWhenHidden && typeof document !== "undefined") {
45
+ document.addEventListener("visibilitychange", () => {
46
+ if (!this.music)
47
+ return;
48
+ if (document.hidden)
49
+ this.music.pause();
50
+ else
51
+ this.resumeMusic();
52
+ });
53
+ }
54
+ }
55
+ /** Load a bank: samples decode once audio is unlocked; the music starts then too. */
56
+ load(bank) {
57
+ this.bank = bank;
58
+ this.audio.whenUnlocked((ctx) => {
59
+ for (const [cue, e] of Object.entries(bank.cues))
60
+ void this.loadCue(ctx, cue, e);
61
+ if (bank.music)
62
+ this.startMusic(bank.music);
63
+ });
64
+ }
65
+ async loadCue(ctx, cue, e) {
66
+ try {
67
+ const buffer = await ctx.decodeAudioData(await this.o.fetchBytes(e.url));
68
+ // Levels are absolute: undo the synth master so a sample's gain means the same in every game.
69
+ const master = Math.max(0.01, this.audio.master);
70
+ await this.audio.addBuffer(cue, buffer, { gain: ((e.gain ?? 1) * this.o.sfxVolume) / master });
71
+ this.loaded.cues++;
72
+ }
73
+ catch {
74
+ // Unsupported format or a missing file: the synth recipe keeps playing.
75
+ this.loaded.failed++;
76
+ }
77
+ }
78
+ startMusic(e) {
79
+ const el = this.music ?? this.o.createMusic();
80
+ if (!el)
81
+ return;
82
+ this.music = el;
83
+ this.musicGain = e.gain ?? 1;
84
+ el.loop = true;
85
+ el.preload = "auto";
86
+ if (el.src !== e.url)
87
+ el.src = e.url;
88
+ this.fade = this.o.fadeIn > 0 ? 0 : 1;
89
+ this.applyMusic();
90
+ this.resumeMusic();
91
+ this.loaded.music = true;
92
+ if (this.fade < 1) {
93
+ const step = 0.1 / this.o.fadeIn;
94
+ this.fadeTimer = setInterval(() => {
95
+ this.fade = Math.min(1, this.fade + step);
96
+ this.applyMusic();
97
+ if (this.fade >= 1 && this.fadeTimer) {
98
+ clearInterval(this.fadeTimer);
99
+ this.fadeTimer = null;
100
+ }
101
+ }, 100);
102
+ }
103
+ }
104
+ resumeMusic() {
105
+ const el = this.music;
106
+ if (!el || this.mutedNow || !this.musicOn)
107
+ return;
108
+ if (typeof document !== "undefined" && document.hidden)
109
+ return;
110
+ try {
111
+ const p = el.play();
112
+ if (p && typeof p.catch === "function")
113
+ p.catch(() => undefined);
114
+ }
115
+ catch {
116
+ // Autoplay refused: the next gesture's unlock tries again.
117
+ }
118
+ }
119
+ applyMusic() {
120
+ const el = this.music;
121
+ if (!el)
122
+ return;
123
+ el.volume = Math.max(0, Math.min(1, this.o.musicVolume * this.musicGain * this.fade));
124
+ el.muted = this.mutedNow || !this.musicOn;
125
+ if (el.muted)
126
+ el.pause();
127
+ }
128
+ /** Start audio on the first pointer or key press (and music with it). */
129
+ unlockOnGesture(target) {
130
+ this.audio.unlockOnGesture(target);
131
+ }
132
+ /** Muted: no effects, no music. Remembered in this browser. */
133
+ get muted() {
134
+ return this.mutedNow;
135
+ }
136
+ set muted(v) {
137
+ this.mutedNow = v;
138
+ this.audio.muted = v;
139
+ if (this.o.key)
140
+ writeStore(`${this.o.key}:muted`, v ? "1" : "0");
141
+ this.applyMusic();
142
+ if (!v) {
143
+ this.audio.unlock();
144
+ this.resumeMusic();
145
+ }
146
+ }
147
+ /** Flip the mute; returns the new state (true = muted). */
148
+ toggle() {
149
+ this.muted = !this.mutedNow;
150
+ return this.mutedNow;
151
+ }
152
+ /** Music on or off on its own (effects keep playing). */
153
+ get musicEnabled() {
154
+ return this.musicOn;
155
+ }
156
+ set musicEnabled(v) {
157
+ this.musicOn = v;
158
+ this.applyMusic();
159
+ if (v)
160
+ this.resumeMusic();
161
+ }
162
+ /** Whether the music element is playing right now. */
163
+ get musicPlaying() {
164
+ return !!this.music && !this.music.paused && !this.music.muted;
165
+ }
166
+ /** The bank in use (null before load). */
167
+ get current() {
168
+ return this.bank;
169
+ }
170
+ dispose() {
171
+ if (this.fadeTimer)
172
+ clearInterval(this.fadeTimer);
173
+ this.music?.pause();
174
+ this.music = null;
175
+ }
176
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Damped springs for game feel: camera follow, FOV punch, knockback
3
+ * offsets, UI pops. `frequency` is the natural frequency in Hz and
4
+ * `damping` the damping ratio (1 = critically damped, no overshoot; below 1
5
+ * it wobbles). Integrated with fixed sub-steps, so any frame rate is stable.
6
+ */
7
+ export interface SpringOptions {
8
+ readonly frequency?: number;
9
+ readonly damping?: number;
10
+ }
11
+ /**
12
+ * Advance one spring axis toward `target` by `dt` seconds with the exact
13
+ * solution of the damped oscillator, so it is stable at any stiffness and
14
+ * frame rate. `omega` is the natural frequency in rad/s, `zeta` the damping
15
+ * ratio. Returns [value, velocity].
16
+ */
17
+ export declare function stepSpring(value: number, velocity: number, target: number, omega: number, zeta: number, dt: number): [number, number];
18
+ export declare class Spring {
19
+ value: number;
20
+ velocity: number;
21
+ target: number;
22
+ frequency: number;
23
+ damping: number;
24
+ constructor(value?: number, opts?: SpringOptions);
25
+ /** Add velocity (an impulse). */
26
+ kick(velocity: number): void;
27
+ /** Jump to a value with no motion. */
28
+ snap(value: number): void;
29
+ step(dt: number): number;
30
+ /** At rest within `eps` of the target. */
31
+ settled(eps?: number): boolean;
32
+ }
33
+ /** Three springs sharing settings (a position or offset). */
34
+ export declare class Spring3 {
35
+ readonly x: Spring;
36
+ readonly y: Spring;
37
+ readonly z: Spring;
38
+ constructor(x?: number, y?: number, z?: number, opts?: SpringOptions);
39
+ setTarget(x: number, y: number, z: number): void;
40
+ snap(x: number, y: number, z: number): void;
41
+ step(dt: number): void;
42
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Advance one spring axis toward `target` by `dt` seconds with the exact
3
+ * solution of the damped oscillator, so it is stable at any stiffness and
4
+ * frame rate. `omega` is the natural frequency in rad/s, `zeta` the damping
5
+ * ratio. Returns [value, velocity].
6
+ */
7
+ export function stepSpring(value, velocity, target, omega, zeta, dt) {
8
+ if (dt <= 0 || omega <= 0)
9
+ return [value, velocity];
10
+ const y0 = value - target;
11
+ const v0 = velocity;
12
+ const t = dt;
13
+ if (Math.abs(zeta - 1) < 1e-4) {
14
+ const e = Math.exp(-omega * t);
15
+ const c = v0 + omega * y0;
16
+ return [target + (y0 + c * t) * e, (v0 - omega * t * c) * e];
17
+ }
18
+ if (zeta < 1) {
19
+ const wd = omega * Math.sqrt(1 - zeta * zeta);
20
+ const e = Math.exp(-zeta * omega * t);
21
+ const co = Math.cos(wd * t);
22
+ const si = Math.sin(wd * t);
23
+ const y = e * (y0 * co + ((v0 + zeta * omega * y0) / wd) * si);
24
+ const v = e * (v0 * co - ((zeta * omega * v0 + omega * omega * y0) / wd) * si);
25
+ return [target + y, v];
26
+ }
27
+ const s = Math.sqrt(zeta * zeta - 1);
28
+ const r1 = -omega * (zeta - s);
29
+ const r2 = -omega * (zeta + s);
30
+ const c1 = (v0 - r2 * y0) / (r1 - r2);
31
+ const c2 = y0 - c1;
32
+ const e1 = Math.exp(r1 * t);
33
+ const e2 = Math.exp(r2 * t);
34
+ return [target + c1 * e1 + c2 * e2, c1 * r1 * e1 + c2 * r2 * e2];
35
+ }
36
+ export class Spring {
37
+ value;
38
+ velocity = 0;
39
+ target;
40
+ frequency;
41
+ damping;
42
+ constructor(value = 0, opts = {}) {
43
+ this.value = value;
44
+ this.target = value;
45
+ this.frequency = opts.frequency ?? 4;
46
+ this.damping = opts.damping ?? 1;
47
+ }
48
+ /** Add velocity (an impulse). */
49
+ kick(velocity) {
50
+ this.velocity += velocity;
51
+ }
52
+ /** Jump to a value with no motion. */
53
+ snap(value) {
54
+ this.value = this.target = value;
55
+ this.velocity = 0;
56
+ }
57
+ step(dt) {
58
+ if (dt <= 0)
59
+ return this.value;
60
+ const [x, v] = stepSpring(this.value, this.velocity, this.target, this.frequency * Math.PI * 2, this.damping, dt);
61
+ this.value = x;
62
+ this.velocity = v;
63
+ return x;
64
+ }
65
+ /** At rest within `eps` of the target. */
66
+ settled(eps = 1e-3) {
67
+ return Math.abs(this.value - this.target) < eps && Math.abs(this.velocity) < eps;
68
+ }
69
+ }
70
+ /** Three springs sharing settings (a position or offset). */
71
+ export class Spring3 {
72
+ x;
73
+ y;
74
+ z;
75
+ constructor(x = 0, y = 0, z = 0, opts = {}) {
76
+ this.x = new Spring(x, opts);
77
+ this.y = new Spring(y, opts);
78
+ this.z = new Spring(z, opts);
79
+ }
80
+ setTarget(x, y, z) {
81
+ this.x.target = x;
82
+ this.y.target = y;
83
+ this.z.target = z;
84
+ }
85
+ snap(x, y, z) {
86
+ this.x.snap(x);
87
+ this.y.snap(y);
88
+ this.z.snap(z);
89
+ }
90
+ step(dt) {
91
+ this.x.step(dt);
92
+ this.y.step(dt);
93
+ this.z.step(dt);
94
+ }
95
+ }