@flare-engine/create 0.4.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 (55) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +2 -0
  3. package/README.md +43 -0
  4. package/dist/bin.js +10 -0
  5. package/dist/bin.js.map +1 -0
  6. package/dist/bin.mjs +12 -0
  7. package/dist/bin.mjs.map +1 -0
  8. package/dist/cli-BGXZ73ie.mjs +189 -0
  9. package/dist/cli-BGXZ73ie.mjs.map +1 -0
  10. package/dist/cli-JzwtwZhm.js +230 -0
  11. package/dist/cli-JzwtwZhm.js.map +1 -0
  12. package/dist/index.d.mts +101 -0
  13. package/dist/index.d.mts.map +1 -0
  14. package/dist/index.d.ts +101 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +9 -0
  17. package/dist/index.mjs +2 -0
  18. package/package.json +61 -0
  19. package/templates/blank/README.md +59 -0
  20. package/templates/blank/app.json +10 -0
  21. package/templates/blank/babel.config.js +7 -0
  22. package/templates/blank/gitignore +8 -0
  23. package/templates/blank/index.ts +4 -0
  24. package/templates/blank/index.web.ts +17 -0
  25. package/templates/blank/metro.config.js +3 -0
  26. package/templates/blank/package.json +36 -0
  27. package/templates/blank/scripts/copy-canvaskit.mjs +15 -0
  28. package/templates/blank/src/App.tsx +45 -0
  29. package/templates/blank/tsconfig.json +15 -0
  30. package/templates/shmup/README.md +59 -0
  31. package/templates/shmup/app.json +10 -0
  32. package/templates/shmup/babel.config.js +7 -0
  33. package/templates/shmup/gitignore +8 -0
  34. package/templates/shmup/index.ts +4 -0
  35. package/templates/shmup/index.web.ts +17 -0
  36. package/templates/shmup/metro.config.js +3 -0
  37. package/templates/shmup/package.json +39 -0
  38. package/templates/shmup/scripts/copy-canvaskit.mjs +15 -0
  39. package/templates/shmup/src/App.tsx +171 -0
  40. package/templates/shmup/src/logic.ts +356 -0
  41. package/templates/shmup/tests/logic.test.ts +235 -0
  42. package/templates/shmup/tsconfig.json +15 -0
  43. package/templates/topdown/README.md +79 -0
  44. package/templates/topdown/app.json +10 -0
  45. package/templates/topdown/babel.config.js +7 -0
  46. package/templates/topdown/gitignore +8 -0
  47. package/templates/topdown/index.ts +4 -0
  48. package/templates/topdown/index.web.ts +17 -0
  49. package/templates/topdown/metro.config.js +3 -0
  50. package/templates/topdown/package.json +39 -0
  51. package/templates/topdown/scripts/copy-canvaskit.mjs +15 -0
  52. package/templates/topdown/src/App.tsx +138 -0
  53. package/templates/topdown/src/map.ts +126 -0
  54. package/templates/topdown/tests/tilemap.test.ts +201 -0
  55. package/templates/topdown/tsconfig.json +15 -0
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Shmup game logic — RN/Skia-free core.
3
+ *
4
+ * This module contains ZERO React or Skia imports so it can be unit-tested
5
+ * in a plain bun/node environment without mocking any native modules.
6
+ *
7
+ * Design decisions:
8
+ * - Position objects are pooled (pre-allocated) so fireBullet/spawnEnemy
9
+ * never create heap garbage in steady state.
10
+ * - Velocity and Sprite are shared singleton references stored once on
11
+ * each entity; mutating them would affect all entities of that type.
12
+ * - Queries are registered once in createShmupState (World.query() is
13
+ * permanent — never call it inside a frame update).
14
+ * - hitBullets/hitEnemies scratch arrays are grow-only (length=0 reset
15
+ * each call, never re-allocated once grown to peak frame size).
16
+ * - world.step(dt) is NOT called here — the shell's onUpdate must call it
17
+ * before stepShmup so movement and bounds-cull run first.
18
+ */
19
+ import {
20
+ type CullBounds,
21
+ Position,
22
+ Velocity,
23
+ createBoundsCullSystem,
24
+ createMovementSystem,
25
+ defineComponent,
26
+ } from '@flare-engine/ecs';
27
+ import type { Query, World } from '@flare-engine/ecs';
28
+ import type { SpriteData } from '@flare-engine/render';
29
+ import { Sprite } from '@flare-engine/render';
30
+
31
+ // ─── Tag components ──────────────────────────────────────────────────────────
32
+
33
+ /** Marks an entity as a player bullet. */
34
+ export const Bullet = defineComponent('Bullet');
35
+
36
+ /** Marks an entity as an enemy. */
37
+ export const Enemy = defineComponent('Enemy');
38
+
39
+ // ─── Constants ───────────────────────────────────────────────────────────────
40
+
41
+ /** Bullet travel speed in px/s (upward). */
42
+ export const BULLET_SPEED = 420;
43
+ /** Enemy travel speed in px/s (downward). */
44
+ export const ENEMY_SPEED = 80;
45
+ /** Seconds between shots. */
46
+ export const FIRE_INTERVAL = 0.12;
47
+ /** Seconds between enemy spawns. */
48
+ export const SPAWN_INTERVAL = 1.4;
49
+ /** Maximum live bullets (bounds the pool size). */
50
+ export const MAX_BULLETS = 64;
51
+ /** Maximum live enemies (bounds the pool size). */
52
+ export const MAX_ENEMIES = 16;
53
+
54
+ /** AABB half-extents for collision detection (pixels). */
55
+ export const BULLET_HW = 3;
56
+ export const BULLET_HH = 6;
57
+ export const ENEMY_HW = 12;
58
+ export const ENEMY_HH = 12;
59
+
60
+ /** Deterministic spawn X positions across a ~320 px wide screen. */
61
+ const SPAWN_XS = [24, 72, 120, 168, 216, 264, 312] as const;
62
+
63
+ // ─── Types ───────────────────────────────────────────────────────────────────
64
+
65
+ /** Mutable version of CullBounds so the shell can resize after layout. */
66
+ export interface ShmupBounds {
67
+ minX: number;
68
+ minY: number;
69
+ maxX: number;
70
+ maxY: number;
71
+ }
72
+
73
+ /** Handle returned by setupShmup. */
74
+ export interface ShmupSetup {
75
+ /** Live screen bounds for the cull system — mutate after layout. */
76
+ bounds: ShmupBounds;
77
+ /**
78
+ * Ref the shell MUST populate after createShmupState so the onCull
79
+ * callback can return Positions to the right pool.
80
+ */
81
+ stateRef: { current: ShmupState | null };
82
+ }
83
+
84
+ /** All mutable game state for one shmup session. */
85
+ export interface ShmupState {
86
+ /** Pre-allocated Position free-list for bullets. */
87
+ bulletPool: Array<{ x: number; y: number }>;
88
+ /** Pre-allocated Position free-list for enemies. */
89
+ enemyPool: Array<{ x: number; y: number }>;
90
+ /** Shared velocity applied to every bullet entity. */
91
+ readonly bulletVel: { x: number; y: number };
92
+ /** Shared velocity applied to every enemy entity. */
93
+ readonly enemyVel: { x: number; y: number };
94
+ /** Shared SpriteData reference reused for every bullet entity. */
95
+ readonly bulletSprite: SpriteData;
96
+ /** Shared SpriteData reference reused for every enemy entity. */
97
+ readonly enemySprite: SpriteData;
98
+ /** Seconds elapsed since the last bullet was fired. */
99
+ fireTimer: number;
100
+ /** Seconds elapsed since the last enemy was spawned. */
101
+ spawnTimer: number;
102
+ /** Total enemies destroyed this session. */
103
+ score: number;
104
+ /** Permanent ECS query for (Position, Bullet) — created once, never inside onUpdate. */
105
+ readonly bulletQuery: Query;
106
+ /** Permanent ECS query for (Position, Enemy) — created once, never inside onUpdate. */
107
+ readonly enemyQuery: Query;
108
+ /** Incremented each spawn to cycle through SPAWN_XS deterministically. */
109
+ spawnCounter: number;
110
+ /** Scratch: bullet IDs involved in this frame's collisions — grow-only, reused. */
111
+ readonly hitBullets: number[];
112
+ /** Scratch: enemy IDs involved in this frame's collisions — grow-only, reused. */
113
+ readonly hitEnemies: number[];
114
+ }
115
+
116
+ // ─── Pure helpers ─────────────────────────────────────────────────────────────
117
+
118
+ /**
119
+ * Returns true when two axis-aligned boxes overlap (inclusive edge-touch).
120
+ *
121
+ * Parameters are center positions and half-extents (px).
122
+ */
123
+ export function aabbOverlap(
124
+ ax: number, ay: number, ahw: number, ahh: number,
125
+ bx: number, by: number, bhw: number, bhh: number,
126
+ ): boolean {
127
+ return Math.abs(ax - bx) <= ahw + bhw && Math.abs(ay - by) <= ahh + bhh;
128
+ }
129
+
130
+ // ─── Setup ────────────────────────────────────────────────────────────────────
131
+
132
+ /**
133
+ * Register the movement and bounds-cull world systems, then return a handle
134
+ * so the shell can size the bounds to the viewport and wire up the state pool.
135
+ *
136
+ * Usage in onReady:
137
+ * const { bounds, stateRef } = setupShmup(ctx.world);
138
+ * // optionally: bounds.maxX = camera.viewportWidth; ...
139
+ * const state = createShmupState(ctx.world, bulletSprite, enemySprite);
140
+ * stateRef.current = state; ← REQUIRED before the first frame runs
141
+ */
142
+ export function setupShmup(world: World): ShmupSetup {
143
+ const moveSys = createMovementSystem(world);
144
+ world.addSystem(moveSys._worldFn, 0); // priority 0: integrate positions first
145
+
146
+ // Sensible defaults for a portrait phone; shell mutates after layout if needed.
147
+ const bounds: ShmupBounds = { minX: -32, minY: -64, maxX: 432, maxY: 900 };
148
+
149
+ // Forward reference so onCull can access pools even though state is created after setup.
150
+ const stateRef: { current: ShmupState | null } = { current: null };
151
+
152
+ const cullSys = createBoundsCullSystem(
153
+ world,
154
+ bounds as CullBounds,
155
+ (id, w) => {
156
+ const state = stateRef.current;
157
+ if (state === null) return;
158
+
159
+ // Return pooled Position before destroying; player has neither tag → skip.
160
+ if (w.has(id, Bullet)) {
161
+ const pos = w.get(id, Position);
162
+ if (pos !== undefined) state.bulletPool.push(pos);
163
+ w.destroy(id);
164
+ } else if (w.has(id, Enemy)) {
165
+ const pos = w.get(id, Position);
166
+ if (pos !== undefined) state.enemyPool.push(pos);
167
+ w.destroy(id);
168
+ }
169
+ },
170
+ );
171
+ world.addSystem(cullSys._worldFn, 2); // priority 2: cull after movement
172
+
173
+ return { bounds, stateRef };
174
+ }
175
+
176
+ // ─── State ────────────────────────────────────────────────────────────────────
177
+
178
+ /**
179
+ * Allocate all mutable game state and register permanent ECS queries.
180
+ *
181
+ * Call ONCE in onReady, after setupShmup, then assign the result to
182
+ * stateRef.current so the cull callback can return pooled Positions.
183
+ */
184
+ export function createShmupState(
185
+ world: World,
186
+ bulletSprite: SpriteData,
187
+ enemySprite: SpriteData,
188
+ ): ShmupState {
189
+ // Pre-allocate Position free-lists — no heap writes during play.
190
+ const bulletPool: Array<{ x: number; y: number }> = [];
191
+ for (let i = 0; i < MAX_BULLETS; i++) bulletPool.push({ x: 0, y: 0 });
192
+
193
+ const enemyPool: Array<{ x: number; y: number }> = [];
194
+ for (let i = 0; i < MAX_ENEMIES; i++) enemyPool.push({ x: 0, y: 0 });
195
+
196
+ return {
197
+ bulletPool,
198
+ enemyPool,
199
+ bulletVel: { x: 0, y: -BULLET_SPEED },
200
+ enemyVel: { x: 0, y: ENEMY_SPEED },
201
+ bulletSprite,
202
+ enemySprite,
203
+ fireTimer: 0,
204
+ spawnTimer: 0,
205
+ score: 0,
206
+ // Registered here and never re-created — World.query() is permanent.
207
+ bulletQuery: world.query(Position, Bullet),
208
+ enemyQuery: world.query(Position, Enemy),
209
+ spawnCounter: 0,
210
+ hitBullets: [],
211
+ hitEnemies: [],
212
+ };
213
+ }
214
+
215
+ // ─── Per-entity helpers ───────────────────────────────────────────────────────
216
+
217
+ /**
218
+ * Fire one bullet from the pool at (x, y).
219
+ *
220
+ * Zero heap allocation: Position is taken from the pre-allocated bulletPool;
221
+ * Velocity and Sprite are stored as shared references (no copy).
222
+ * Silently skips when the pool is exhausted — no throw, no new allocation.
223
+ */
224
+ export function fireBullet(
225
+ world: World,
226
+ state: ShmupState,
227
+ x: number,
228
+ y: number,
229
+ sprite: SpriteData,
230
+ ): void {
231
+ const pos = state.bulletPool.pop();
232
+ if (pos === undefined) return; // pool exhausted — skip silently
233
+
234
+ pos.x = x;
235
+ pos.y = y;
236
+ const id = world.spawn();
237
+ world.add(id, Position, pos);
238
+ world.add(id, Velocity, state.bulletVel); // shared ref — no copy
239
+ world.add(id, Sprite, sprite); // shared ref — no copy
240
+ world.add(id, Bullet);
241
+ }
242
+
243
+ /**
244
+ * Spawn one enemy from the pool at (x, -16) moving downward.
245
+ *
246
+ * Zero heap allocation: same discipline as fireBullet.
247
+ */
248
+ export function spawnEnemy(
249
+ world: World,
250
+ state: ShmupState,
251
+ x: number,
252
+ sprite: SpriteData,
253
+ ): void {
254
+ const pos = state.enemyPool.pop();
255
+ if (pos === undefined) return; // pool exhausted — skip silently
256
+
257
+ pos.x = x;
258
+ pos.y = -16; // spawn above the visible area
259
+ const id = world.spawn();
260
+ world.add(id, Position, pos);
261
+ world.add(id, Velocity, state.enemyVel); // shared ref
262
+ world.add(id, Sprite, sprite); // shared ref
263
+ world.add(id, Enemy);
264
+ }
265
+
266
+ // ─── Frame update ─────────────────────────────────────────────────────────────
267
+
268
+ /**
269
+ * Advance game logic for one frame.
270
+ *
271
+ * Fires bullets and spawns enemies on timers, detects bullet-enemy AABB
272
+ * collisions, destroys hits, returns pooled Positions, and returns the
273
+ * number of new kills.
274
+ *
275
+ * No per-frame object literals or `new` expressions in this module.
276
+ * (Iterating an engine Query allocates a short-lived iterator on Hermes —
277
+ * an engine detail, not template garbage.)
278
+ * Other hot-path facts:
279
+ * - hitBullets/hitEnemies reset via `.length = 0` (no free/alloc)
280
+ * - Pool push reuses pre-allocated array capacity
281
+ *
282
+ * Calling order per onUpdate frame (shell responsibility):
283
+ * 1. Move player in-place from joystick input
284
+ * 2. ctx.world.step(dt) ← run movement + bounds-cull systems
285
+ * 3. stepShmup(...) ← fire, spawn, detect collisions
286
+ */
287
+ export function stepShmup(
288
+ world: World,
289
+ state: ShmupState,
290
+ dt: number,
291
+ playerX: number,
292
+ playerY: number,
293
+ ): number {
294
+ // ── Fire bullets ────────────────────────────────────────────────────────────
295
+ state.fireTimer += dt;
296
+ while (state.fireTimer >= FIRE_INTERVAL) {
297
+ state.fireTimer -= FIRE_INTERVAL;
298
+ fireBullet(world, state, playerX, playerY - 16, state.bulletSprite);
299
+ }
300
+
301
+ // ── Spawn enemies (deterministic x, NOT Math.random) ────────────────────────
302
+ state.spawnTimer += dt;
303
+ if (state.spawnTimer >= SPAWN_INTERVAL) {
304
+ state.spawnTimer -= SPAWN_INTERVAL;
305
+ const spawnX = SPAWN_XS[state.spawnCounter % SPAWN_XS.length] ?? SPAWN_XS[0]!;
306
+ state.spawnCounter++;
307
+ spawnEnemy(world, state, spawnX, state.enemySprite);
308
+ }
309
+
310
+ // ── Collect bullet-enemy collision pairs ────────────────────────────────────
311
+ // Reset scratch arrays without freeing — grow-only, zero steady-state alloc.
312
+ state.hitBullets.length = 0;
313
+ state.hitEnemies.length = 0;
314
+
315
+ // Indexed walk over the live id arrays: `for...of` over a Query allocates an
316
+ // iterator per loop (and, on Hermes, a result object per entity), and this
317
+ // nest would pay that once per bullet per frame.
318
+ const bulletIds = state.bulletQuery.entities;
319
+ const enemyIds = state.enemyQuery.entities;
320
+ for (let b = 0; b < bulletIds.length; b++) {
321
+ const bId = bulletIds[b]!;
322
+ const bPos = world.get(bId, Position);
323
+ if (bPos === undefined) continue;
324
+ for (let e = 0; e < enemyIds.length; e++) {
325
+ const eId = enemyIds[e]!;
326
+ const ePos = world.get(eId, Position);
327
+ if (ePos === undefined) continue;
328
+ if (aabbOverlap(bPos.x, bPos.y, BULLET_HW, BULLET_HH, ePos.x, ePos.y, ENEMY_HW, ENEMY_HH)) {
329
+ state.hitBullets.push(bId);
330
+ state.hitEnemies.push(eId);
331
+ break; // one bullet can only hit one enemy per frame
332
+ }
333
+ }
334
+ }
335
+
336
+ // ── Destroy hits AFTER the query loops (no mid-iteration splice) ────────────
337
+ let kills = 0;
338
+ for (let i = 0; i < state.hitBullets.length; i++) {
339
+ const bId = state.hitBullets[i]!;
340
+ const eId = state.hitEnemies[i]!;
341
+ if (world.exists(bId)) {
342
+ const bPos = world.get(bId, Position);
343
+ if (bPos !== undefined) state.bulletPool.push(bPos);
344
+ world.destroy(bId);
345
+ }
346
+ if (world.exists(eId)) {
347
+ const ePos = world.get(eId, Position);
348
+ if (ePos !== undefined) state.enemyPool.push(ePos);
349
+ world.destroy(eId);
350
+ kills++;
351
+ }
352
+ }
353
+
354
+ state.score += kills;
355
+ return kills;
356
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Headless tests for the shmup logic module.
3
+ *
4
+ * No Skia, no React Native — just a real World and the pure game logic.
5
+ * This is the "old shmup leaked Positions / new shmup pools them" proof.
6
+ *
7
+ * World.query() registers permanent queries — do NOT call _resetComponentIds()
8
+ * here; component IDs are module-level and stable across the whole test run.
9
+ */
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { Position, World } from '@flare-engine/ecs';
12
+ import type { SpriteData } from '@flare-engine/render';
13
+ import {
14
+ BULLET_HH,
15
+ BULLET_HW,
16
+ BULLET_SPEED,
17
+ ENEMY_HH,
18
+ ENEMY_HW,
19
+ MAX_BULLETS,
20
+ MAX_ENEMIES,
21
+ aabbOverlap,
22
+ createShmupState,
23
+ fireBullet,
24
+ setupShmup,
25
+ spawnEnemy,
26
+ stepShmup,
27
+ } from '../src/logic';
28
+
29
+ // Fake SpriteData — logic.ts stores the reference but never accesses fields.
30
+ const FAKE_SPRITE = {} as unknown as SpriteData;
31
+
32
+ // ─── aabbOverlap ─────────────────────────────────────────────────────────────
33
+
34
+ describe('aabbOverlap', () => {
35
+ test('true when boxes overlap', () => {
36
+ // A: center (0,0) hw=5 hh=5; B: center (3,3) hw=5 hh=5 — clearly overlapping
37
+ expect(aabbOverlap(0, 0, 5, 5, 3, 3, 5, 5)).toBe(true);
38
+ });
39
+
40
+ test('false when boxes are clearly separated', () => {
41
+ // A: center (0,0) hw=5; B: center (20,0) hw=5 — gap of 10
42
+ expect(aabbOverlap(0, 0, 5, 5, 20, 0, 5, 5)).toBe(false);
43
+ });
44
+
45
+ test('true on edge-touch (inclusive boundary)', () => {
46
+ // A right edge at 0+5=5; B left edge at 10-5=5 → touching, inclusive → true
47
+ expect(aabbOverlap(0, 0, 5, 5, 10, 0, 5, 5)).toBe(true);
48
+ });
49
+
50
+ test('false just outside touch boundary', () => {
51
+ // x-distance = 11, sum of hw = 10 → separated
52
+ expect(aabbOverlap(0, 0, 5, 5, 11, 0, 5, 5)).toBe(false);
53
+ });
54
+ });
55
+
56
+ // ─── AABB constants sanity ────────────────────────────────────────────────────
57
+
58
+ describe('AABB constants', () => {
59
+ test('BULLET_HW / BULLET_HH are positive', () => {
60
+ expect(BULLET_HW).toBeGreaterThan(0);
61
+ expect(BULLET_HH).toBeGreaterThan(0);
62
+ });
63
+
64
+ test('ENEMY_HW / ENEMY_HH are positive', () => {
65
+ expect(ENEMY_HW).toBeGreaterThan(0);
66
+ expect(ENEMY_HH).toBeGreaterThan(0);
67
+ });
68
+
69
+ test('bullet and enemy at same center overlap', () => {
70
+ expect(aabbOverlap(0, 0, BULLET_HW, BULLET_HH, 0, 0, ENEMY_HW, ENEMY_HH)).toBe(true);
71
+ });
72
+
73
+ test('BULLET_SPEED is positive', () => {
74
+ expect(BULLET_SPEED).toBeGreaterThan(0);
75
+ });
76
+ });
77
+
78
+ // ─── fireBullet pool behaviour ────────────────────────────────────────────────
79
+
80
+ describe('fireBullet', () => {
81
+ test('bullet query count rises after firing', () => {
82
+ const world = new World();
83
+ const { stateRef } = setupShmup(world);
84
+ const state = createShmupState(world, FAKE_SPRITE, FAKE_SPRITE);
85
+ stateRef.current = state;
86
+
87
+ fireBullet(world, state, 100, 400, FAKE_SPRITE);
88
+ fireBullet(world, state, 100, 400, FAKE_SPRITE);
89
+ fireBullet(world, state, 100, 400, FAKE_SPRITE);
90
+
91
+ let count = 0;
92
+ for (const _ of state.bulletQuery) count++;
93
+ expect(count).toBe(3);
94
+ });
95
+
96
+ test('bullets culled at top bound — query empties, pool fully returned (no leak)', () => {
97
+ // This is the fail-old / pass-new proof: the old shmup never returned
98
+ // Positions to a pool; the new one does so in the cull callback.
99
+ const world = new World();
100
+ const { bounds, stateRef } = setupShmup(world);
101
+ const state = createShmupState(world, FAKE_SPRITE, FAKE_SPRITE);
102
+ stateRef.current = state;
103
+
104
+ // Cull anything above y = 0 (bullets travel upward).
105
+ bounds.minY = 0;
106
+ bounds.maxY = 1000;
107
+ bounds.minX = -1000;
108
+ bounds.maxX = 1000;
109
+
110
+ // Fire 4 bullets from y = 10.
111
+ // BULLET_SPEED px/s upward; dt = 0.1 → dy = -42 → new y = -32 < minY = 0 → culled.
112
+ for (let i = 0; i < 4; i++) fireBullet(world, state, 100, 10, FAKE_SPRITE);
113
+
114
+ let countBefore = 0;
115
+ for (const _ of state.bulletQuery) countBefore++;
116
+ expect(countBefore).toBe(4);
117
+ expect(state.bulletPool.length).toBe(MAX_BULLETS - 4);
118
+
119
+ // world.step runs: movement (priority 0) → cull (priority 2).
120
+ world.step(0.1);
121
+
122
+ let countAfter = 0;
123
+ for (const _ of state.bulletQuery) countAfter++;
124
+ expect(countAfter).toBe(0); // all culled
125
+
126
+ // All Positions must be back in the pool — zero leak.
127
+ expect(state.bulletPool.length).toBe(MAX_BULLETS);
128
+ });
129
+
130
+ test('pool exhaustion silently skips — no throw, count stays at cap', () => {
131
+ const world = new World();
132
+ const { stateRef } = setupShmup(world);
133
+ const state = createShmupState(world, FAKE_SPRITE, FAKE_SPRITE);
134
+ stateRef.current = state;
135
+
136
+ for (let i = 0; i < MAX_BULLETS; i++) fireBullet(world, state, 100, 400, FAKE_SPRITE);
137
+ expect(state.bulletPool.length).toBe(0);
138
+
139
+ // Extra fires must silently no-op — no throw, no new entities.
140
+ expect(() => {
141
+ fireBullet(world, state, 100, 400, FAKE_SPRITE);
142
+ fireBullet(world, state, 100, 400, FAKE_SPRITE);
143
+ }).not.toThrow();
144
+
145
+ let count = 0;
146
+ for (const _ of state.bulletQuery) count++;
147
+ expect(count).toBe(MAX_BULLETS); // unchanged
148
+ });
149
+ });
150
+
151
+ // ─── spawnEnemy pool behaviour ────────────────────────────────────────────────
152
+
153
+ describe('spawnEnemy', () => {
154
+ test('enemy pool exhaustion silently skips', () => {
155
+ const world = new World();
156
+ const { stateRef } = setupShmup(world);
157
+ const state = createShmupState(world, FAKE_SPRITE, FAKE_SPRITE);
158
+ stateRef.current = state;
159
+
160
+ for (let i = 0; i < MAX_ENEMIES; i++) spawnEnemy(world, state, 100, FAKE_SPRITE);
161
+ expect(state.enemyPool.length).toBe(0);
162
+
163
+ expect(() => spawnEnemy(world, state, 100, FAKE_SPRITE)).not.toThrow();
164
+
165
+ let count = 0;
166
+ for (const _ of state.enemyQuery) count++;
167
+ expect(count).toBe(MAX_ENEMIES);
168
+ });
169
+ });
170
+
171
+ // ─── Collision and score ──────────────────────────────────────────────────────
172
+
173
+ describe('stepShmup collision', () => {
174
+ test('bullet-enemy overlap destroys both, increments score, returns pools', () => {
175
+ const world = new World();
176
+ const { bounds, stateRef } = setupShmup(world);
177
+ // Wide bounds so nothing is culled during this test.
178
+ bounds.minX = -9999; bounds.maxX = 9999;
179
+ bounds.minY = -9999; bounds.maxY = 9999;
180
+
181
+ const state = createShmupState(world, FAKE_SPRITE, FAKE_SPRITE);
182
+ stateRef.current = state;
183
+
184
+ // Spawn a bullet and an enemy.
185
+ fireBullet(world, state, 100, 100, FAKE_SPRITE);
186
+ spawnEnemy(world, state, 100, FAKE_SPRITE); // enemy starts at y=-16
187
+
188
+ // Move the enemy to the bullet's y so they overlap.
189
+ // world.get with the live Position ComponentDef (same module-cache entry).
190
+ let enemyId: number | undefined;
191
+ for (const id of state.enemyQuery) { enemyId = id; break; }
192
+ expect(enemyId).not.toBeUndefined();
193
+
194
+ const ePos = world.get(enemyId!, Position);
195
+ expect(ePos).not.toBeUndefined();
196
+ ePos!.y = 100; // bullet is at (100, 100); now enemy is too
197
+
198
+ // With dt = 0.001: fireTimer stays below FIRE_INTERVAL, spawnTimer stays
199
+ // below SPAWN_INTERVAL → only collision detection runs.
200
+ const kills = stepShmup(world, state, 0.001, 0, 900);
201
+
202
+ expect(kills).toBe(1);
203
+ expect(state.score).toBe(1);
204
+
205
+ // Both entities destroyed.
206
+ let bCount = 0;
207
+ for (const _ of state.bulletQuery) bCount++;
208
+ expect(bCount).toBe(0);
209
+
210
+ let eCount = 0;
211
+ for (const _ of state.enemyQuery) eCount++;
212
+ expect(eCount).toBe(0);
213
+
214
+ // Positions returned to pools — zero leak.
215
+ expect(state.bulletPool.length).toBe(MAX_BULLETS);
216
+ expect(state.enemyPool.length).toBe(MAX_ENEMIES);
217
+ });
218
+
219
+ test('non-overlapping bullet and enemy produce zero kills', () => {
220
+ const world = new World();
221
+ const { bounds, stateRef } = setupShmup(world);
222
+ bounds.minX = -9999; bounds.maxX = 9999;
223
+ bounds.minY = -9999; bounds.maxY = 9999;
224
+
225
+ const state = createShmupState(world, FAKE_SPRITE, FAKE_SPRITE);
226
+ stateRef.current = state;
227
+
228
+ fireBullet(world, state, 100, 100, FAKE_SPRITE);
229
+ spawnEnemy(world, state, 300, FAKE_SPRITE); // far away in x
230
+
231
+ const kills = stepShmup(world, state, 0.001, 0, 900);
232
+ expect(kills).toBe(0);
233
+ expect(state.score).toBe(0);
234
+ });
235
+ });
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "jsx": "react-native",
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "resolveJsonModule": true,
11
+ "isolatedModules": true,
12
+ "noUncheckedIndexedAccess": true
13
+ },
14
+ "include": ["src", "index.ts", "index.web.ts"]
15
+ }
@@ -0,0 +1,79 @@
1
+ # {{APP_NAME}}
2
+
3
+ A Flare engine app scaffolded from the `{{TEMPLATE_ID}}` template.
4
+
5
+ ## Develop
6
+
7
+ ```bash
8
+ bun install
9
+ bun run android # or: bun run ios
10
+ ```
11
+
12
+ `@shopify/react-native-skia` is a **native** module, so this template needs a
13
+ dev build — it cannot run in Expo Go. `bun run start` then pressing `a`/`i`
14
+ opens Expo Go and red-screens with a missing-native-module error. `bun run
15
+ android` builds and installs a dev client instead; after that first build,
16
+ `bun run start` attaches to it normally.
17
+
18
+ The first Android build takes a few minutes and needs the Android SDK plus a
19
+ JDK. Two things that will otherwise cost you an afternoon:
20
+
21
+ - **Build from a short path.** Skia's CMake step generates paths ~120
22
+ characters longer than your project directory, and Windows still fails at 260
23
+ (`CreateProcess error=2`). `C:\src\my-app` is fine; a deep temp path is not.
24
+ - **Do not remove `trustedDependencies` from `package.json`.** Bun blocks
25
+ install scripts it does not trust, and Skia's `postinstall` is what unpacks
26
+ its prebuilt binaries. Without it the install still succeeds and the Android
27
+ build fails minutes later with `Could not find libskia.a`.
28
+
29
+ ## Run in a browser
30
+
31
+ ```bash
32
+ bun run web
33
+ ```
34
+
35
+ The `web` script copies CanvasKit's wasm binary into `public/` before starting
36
+ Metro, because Skia on web is CanvasKit and it has to be served alongside the
37
+ bundle. Starting the dev server any other way — `bun run start` then pressing
38
+ `w` — skips that copy and the canvas stays blank.
39
+
40
+ The binary is ~8 MB and is `.gitignore`d: it is a copy of
41
+ `canvaskit-wasm/bin/full/canvaskit.wasm`, reproduced by the script on demand.
42
+ It must be the `full` build; the smaller default build is ABI-incompatible with
43
+ the loader.
44
+
45
+ ## What this template demonstrates
46
+
47
+ A top-down starter built around one scene: a 4x8 house on an open field,
48
+ with rocks and an openable door.
49
+
50
+ - `src/map.ts` — edit `MAP_ART` (ASCII art: `#` wall, `.` grass, `R` rock, `D` door) to reshape the map.
51
+ - Collision: `@flare-engine/physics` `buildSolidGrid` + `moveAndSlideInto` over wall, rock, and closed-door tiles; axis-separated slide so diagonal inputs slide along walls instead of stopping.
52
+ - **The door**: walking into it while closed opens it — `map.ts`'s `openDoor` flips its cell in the `SolidGrid.solid` array and swaps its tile GID so the render picks up the open frame, both plain zero-alloc index writes into arrays the engine already owns. That is a tile whose solidity changes at runtime, not just its colour; `isBlockedAABB`/`moveAndSlideInto` treat the door exactly like a wall until it's opened.
53
+ - Camera: `camera.follow` tracks the player; zoom is set to 2×.
54
+ - A `VirtualJoystick` drives analog movement — all 8 directions and diagonals work.
55
+
56
+ ## Layout
57
+
58
+ - `src/App.tsx` — root component; mounts `<GameView>` and configures the Flare game loop.
59
+ - `src/map.ts` — ASCII-art map, Tiled JSON builder, and `loadMap()`.
60
+ - `index.ts` — Expo entry that registers `App` as the root component (native).
61
+ - `index.web.ts` — web entry; awaits `LoadSkiaWeb()` **before** importing `App`.
62
+ `package.json`'s `main` is extensionless so Metro resolves the right entry per
63
+ platform. The split is required: RN-Skia's web module reads `global.CanvasKit`
64
+ when it is first imported, so importing `App` any earlier leaves Skia hollow.
65
+ - `scripts/copy-canvaskit.mjs` — copies the CanvasKit wasm into `public/` for the
66
+ web build.
67
+ - `app.json` — Expo configuration.
68
+
69
+ ## Tests
70
+
71
+ ```bash
72
+ bun test
73
+ ```
74
+
75
+ Runs `tests/tilemap.test.ts` — validates the map parse, collider extraction, solid-grid query, and that the closed door blocks while the open door and wall/rock collision are unaffected by opening it.
76
+
77
+ ## Docs
78
+
79
+ - [Flare engine](https://github.com/grzott/flare-engine)
@@ -0,0 +1,10 @@
1
+ {
2
+ "expo": {
3
+ "name": "{{APP_NAME}}",
4
+ "slug": "{{APP_NAME}}",
5
+ "version": "0.1.0",
6
+ "orientation": "portrait",
7
+ "platforms": ["android", "ios", "web"],
8
+ "newArchEnabled": true
9
+ }
10
+ }