@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,7 @@
1
+ module.exports = function (api) {
2
+ api.cache(true);
3
+ return {
4
+ presets: ['babel-preset-expo'],
5
+ plugins: ['react-native-worklets/plugin'],
6
+ };
7
+ };
@@ -0,0 +1,8 @@
1
+ node_modules
2
+ public/canvaskit.wasm
3
+ .expo
4
+ dist
5
+ *.log
6
+ .DS_Store
7
+ android
8
+ ios
@@ -0,0 +1,4 @@
1
+ import { registerRootComponent } from "expo";
2
+ import { App } from "./src/App";
3
+
4
+ registerRootComponent(App);
@@ -0,0 +1,17 @@
1
+ import { LoadSkiaWeb } from "@shopify/react-native-skia/lib/module/web";
2
+ import { registerRootComponent } from "expo";
3
+
4
+ // Metro picks this file over index.ts on web because package.json's `main` is
5
+ // extensionless. Keeping the web entry separate is not cosmetic: RN-Skia's web
6
+ // module builds its Skia object from global.CanvasKit at module-eval time, so
7
+ // anything that imports Skia must be loaded only after LoadSkiaWeb resolves.
8
+ // A static `import { App }` here would leave Skia permanently hollow and
9
+ // GameView would throw on Skia.Paint.
10
+ //
11
+ // `bun run web` copies canvaskit.wasm into public/, which Expo serves at the
12
+ // web root — that is what locateFile points at.
13
+ void (async () => {
14
+ await LoadSkiaWeb({ locateFile: (file: string) => `/${file}` });
15
+ const { App } = await import("./src/App");
16
+ registerRootComponent(App);
17
+ })();
@@ -0,0 +1,3 @@
1
+ const { getDefaultConfig } = require('expo/metro-config');
2
+
3
+ module.exports = getDefaultConfig(__dirname);
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "{{APP_NAME}}",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "main": "index",
6
+ "scripts": {
7
+ "start": "expo start",
8
+ "android": "expo run:android",
9
+ "ios": "expo run:ios",
10
+ "web": "node scripts/copy-canvaskit.mjs && expo start --web",
11
+ "test": "bun test"
12
+ },
13
+ "dependencies": {
14
+ "@expo/metro-runtime": "~57.0.0",
15
+ "@flare-engine/engine": "{{FLARE_VERSION}}",
16
+ "@flare-engine/physics": "{{FLARE_VERSION}}",
17
+ "@flare-engine/react": "{{FLARE_VERSION}}",
18
+ "@flare-engine/tilemap": "{{FLARE_VERSION}}",
19
+ "@shopify/react-native-skia": "2.6.0",
20
+ "canvaskit-wasm": "0.41.0",
21
+ "expo": "~57.0.0",
22
+ "expo-status-bar": "~57.0.0",
23
+ "react": "19.2.3",
24
+ "react-dom": "19.2.3",
25
+ "react-native": "0.86.0",
26
+ "react-native-reanimated": "~4.5.0",
27
+ "react-native-safe-area-context": "~5.6.0",
28
+ "react-native-web": "~0.21.0",
29
+ "react-native-worklets": "~0.10.0"
30
+ },
31
+ "devDependencies": {
32
+ "@babel/core": "^7.25.0",
33
+ "@types/react": "~19.2.0",
34
+ "typescript": "^5.5.0"
35
+ },
36
+ "trustedDependencies": [
37
+ "@shopify/react-native-skia"
38
+ ]
39
+ }
@@ -0,0 +1,15 @@
1
+ import { copyFile, mkdir } from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+ import { join } from "node:path";
4
+
5
+ // LoadSkiaWeb imports canvaskit-wasm/bin/full/canvaskit, so the binary served
6
+ // at / must be the `full` build (~8 MB). bin/canvaskit.wasm is a smaller,
7
+ // ABI-incompatible variant — serving it fails at runtime, not at build time.
8
+ const require = createRequire(import.meta.url);
9
+ const source = require.resolve("canvaskit-wasm/bin/full/canvaskit.wasm");
10
+ const targetDir = join(process.cwd(), "public");
11
+
12
+ await mkdir(targetDir, { recursive: true });
13
+ await copyFile(source, join(targetDir, "canvaskit.wasm"));
14
+
15
+ console.log("copy-canvaskit: public/canvaskit.wasm");
@@ -0,0 +1,138 @@
1
+ import {
2
+ Position,
3
+ Sprite,
4
+ TilemapRenderer,
5
+ buildSolidGrid,
6
+ createProceduralAtlas,
7
+ extractColliders,
8
+ moveAndSlideInto,
9
+ } from "@flare-engine/engine";
10
+ import type {
11
+ GameContextValue,
12
+ VirtualJoystickState,
13
+ } from "@flare-engine/engine";
14
+ import { GameView, VirtualJoystick, skiaAtlasModule } from "@flare-engine/react";
15
+ import type React from "react";
16
+ import { useMemo, useRef } from "react";
17
+ import { StyleSheet, View } from "react-native";
18
+ import { SafeAreaProvider } from "react-native-safe-area-context";
19
+ import { DOOR_COL, DOOR_ROW, TILE_SIZE, isTouchingDoor, loadMap, openDoor } from "./map";
20
+
21
+ const SPEED = 96;
22
+ const PLAYER_HALF = 6;
23
+ const PLAYER_START = { x: 24, y: 152 };
24
+
25
+ // Frame order (grass/wall/rock/door-closed/door-open at localId 0-4, x=0/16/32/48/64)
26
+ // must stay aligned with the tileset GID math in map.ts (TILE_GRASS=1 → localId=0, etc.).
27
+ // Reordering frames here without updating CHAR_TO_GID silently breaks tile rendering.
28
+ const ATLAS_FRAMES = [
29
+ { name: "grass", w: TILE_SIZE, h: TILE_SIZE, color: 0xff3a7d44 },
30
+ { name: "wall", w: TILE_SIZE, h: TILE_SIZE, color: 0xff5a5a66 },
31
+ { name: "rock", w: TILE_SIZE, h: TILE_SIZE, color: 0xff8c8c8c },
32
+ { name: "doorClosed", w: TILE_SIZE, h: TILE_SIZE, color: 0xff8b5a2b },
33
+ { name: "doorOpen", w: TILE_SIZE, h: TILE_SIZE, color: 0xffd9b273 },
34
+ { name: "player", w: 12, h: 12, color: 0xfff2e9d8 },
35
+ ] as const;
36
+
37
+ /**
38
+ * Top-down starter: a 4x8 house on an open field, with rocks and an openable
39
+ * door. Walking into the closed door opens it — a `SolidGrid` cell flip plus
40
+ * a tile-GID swap for the render, both zero-alloc (see `openDoor` in
41
+ * ./map.ts). Walls, rocks, and the door while closed all block movement the
42
+ * same way; the door stops blocking once opened. A virtual joystick drives
43
+ * the player; the camera follows.
44
+ */
45
+ export function App(): React.ReactElement {
46
+ const dirRef = useRef({ x: 0, y: 0 });
47
+ const tilemapRef = useRef<TilemapRenderer | null>(null);
48
+
49
+ const config = useMemo(
50
+ () => ({
51
+ onRenderBackground: (ctx: GameContextValue) => {
52
+ tilemapRef.current?.drawInto(ctx.camera, ctx.batch);
53
+ },
54
+ }),
55
+ [],
56
+ );
57
+
58
+ const handleReady = (ctx: GameContextValue): void => {
59
+ const atlas = createProceduralAtlas(skiaAtlasModule, ATLAS_FRAMES);
60
+ const map = loadMap();
61
+
62
+ // Narrow to the single tile layer once, up front — openDoor needs a
63
+ // mutable reference to its `tiles` array every time the door opens.
64
+ const groundLayer = map.layers[0];
65
+ if (groundLayer === undefined || groundLayer.type !== "tile") return;
66
+
67
+ const tilemap = new TilemapRenderer(map);
68
+ tilemap.setTilesetImage("tiles", atlas.image);
69
+ tilemapRef.current = tilemap;
70
+
71
+ const grid = buildSolidGrid(
72
+ map.width,
73
+ map.height,
74
+ TILE_SIZE,
75
+ extractColliders(map),
76
+ );
77
+ const doorIndex = DOOR_ROW * grid.width + DOOR_COL;
78
+
79
+ const player = ctx.world.spawn();
80
+ ctx.world.add(player, Position, { x: PLAYER_START.x, y: PLAYER_START.y });
81
+ const playerFrame = atlas.sheet.frames.find((f) => f.name === "player");
82
+ if (playerFrame) {
83
+ ctx.world.add(player, Sprite, {
84
+ image: atlas.image,
85
+ frame: playerFrame,
86
+ z: 1,
87
+ width: playerFrame.w,
88
+ height: playerFrame.h,
89
+ anchor: { x: 0.5, y: 0.5 },
90
+ });
91
+ }
92
+
93
+ ctx.camera.zoom = 2;
94
+ const target = { x: PLAYER_START.x, y: PLAYER_START.y };
95
+ ctx.camera.follow(target);
96
+
97
+ ctx.loop.onUpdate.add((dt) => {
98
+ const pos = ctx.world.get(player, Position);
99
+ if (!pos) return;
100
+
101
+ const dx = dirRef.current.x * SPEED * dt;
102
+ const dy = dirRef.current.y * SPEED * dt;
103
+
104
+ // Bump-to-open: the joystick is the only input this template wires up,
105
+ // so walking into the closed door is the open action — no extra button.
106
+ // Checked against the intended (pre-collision) position so the door
107
+ // opens the same frame the player walks into it, not one frame late.
108
+ if (grid.solid[doorIndex] !== 0 && isTouchingDoor(pos.x + dx, pos.y + dy, PLAYER_HALF, PLAYER_HALF)) {
109
+ openDoor(groundLayer, grid);
110
+ }
111
+
112
+ moveAndSlideInto(grid, pos.x, pos.y, dx, dy, PLAYER_HALF, PLAYER_HALF, pos);
113
+ target.x = pos.x;
114
+ target.y = pos.y;
115
+ });
116
+ };
117
+
118
+ const handleJoystick = (state: VirtualJoystickState): void => {
119
+ dirRef.current.x = state.direction.x;
120
+ dirRef.current.y = state.direction.y;
121
+ };
122
+
123
+ return (
124
+ <SafeAreaProvider>
125
+ <View style={styles.root}>
126
+ <GameView config={config} onReady={handleReady} style={styles.game}>
127
+ <VirtualJoystick style={styles.joystick} onStateChange={handleJoystick} />
128
+ </GameView>
129
+ </View>
130
+ </SafeAreaProvider>
131
+ );
132
+ }
133
+
134
+ const styles = StyleSheet.create({
135
+ root: { flex: 1, backgroundColor: "#000" },
136
+ game: { flex: 1 },
137
+ joystick: { position: "absolute", left: 24, bottom: 24 },
138
+ });
@@ -0,0 +1,126 @@
1
+ import type { SolidGrid } from '@flare-engine/physics';
2
+ import { parseTiled } from '@flare-engine/tilemap';
3
+ import type { TileLayer, TilemapData } from '@flare-engine/tilemap';
4
+
5
+ export const TILE_SIZE = 16;
6
+
7
+ export const TILE_GRASS = 1;
8
+ export const TILE_WALL = 2;
9
+ export const TILE_ROCK = 3;
10
+ export const TILE_DOOR_CLOSED = 4;
11
+ export const TILE_DOOR_OPEN = 5;
12
+
13
+ const CHAR_TO_GID: Record<string, number> = {
14
+ '.': TILE_GRASS,
15
+ '#': TILE_WALL,
16
+ R: TILE_ROCK,
17
+ D: TILE_DOOR_CLOSED,
18
+ };
19
+
20
+ /** ASCII source for a 4x8 house on an open field. `#` wall, `.` grass,
21
+ * `R` rock, `D` door (starts closed). Walls, rocks, and the closed door all
22
+ * collide; the door stops colliding once opened at runtime — see
23
+ * `openDoor` below. */
24
+ export const MAP_ART: readonly string[] = [
25
+ '............',
26
+ '....####....',
27
+ '....#..#....',
28
+ '..R.#..#....',
29
+ '....#..#....',
30
+ '....#..#.R..',
31
+ '....#..#....',
32
+ '....#..#....',
33
+ '....#D##....',
34
+ '....R.......',
35
+ ];
36
+
37
+ /** Scan `art` for the door character once at module load; throws if a future
38
+ * edit to MAP_ART drops the door, instead of silently shipping a doorless map. */
39
+ function findDoorTile(art: readonly string[]): { row: number; col: number } {
40
+ for (let row = 0; row < art.length; row++) {
41
+ const col = art[row]?.indexOf('D') ?? -1;
42
+ if (col !== -1) return { row, col };
43
+ }
44
+ throw new Error('map.ts: MAP_ART has no door ("D") tile');
45
+ }
46
+
47
+ const DOOR_TILE = findDoorTile(MAP_ART);
48
+ export const DOOR_ROW = DOOR_TILE.row;
49
+ export const DOOR_COL = DOOR_TILE.col;
50
+ export const DOOR_WORLD_X = DOOR_COL * TILE_SIZE + TILE_SIZE / 2;
51
+ export const DOOR_WORLD_Y = DOOR_ROW * TILE_SIZE + TILE_SIZE / 2;
52
+
53
+ /** Build a Tiled `.tmj`-shaped JSON object from the ASCII art.
54
+ * Swapping in a real editor-made map is a drop-in change:
55
+ * `parseTiled(require('./zone1.tmj'))`. */
56
+ export function buildTiledMapJson(art: readonly string[] = MAP_ART): unknown {
57
+ const height = art.length;
58
+ const width = art[0]?.length ?? 0;
59
+ const data: number[] = [];
60
+ for (const row of art) {
61
+ for (const ch of row) {
62
+ data.push(CHAR_TO_GID[ch] ?? 0);
63
+ }
64
+ }
65
+ return {
66
+ width,
67
+ height,
68
+ tilewidth: TILE_SIZE,
69
+ tileheight: TILE_SIZE,
70
+ orientation: 'orthogonal',
71
+ layers: [
72
+ {
73
+ type: 'tilelayer',
74
+ name: 'ground',
75
+ width,
76
+ height,
77
+ data,
78
+ visible: true,
79
+ opacity: 1,
80
+ },
81
+ ],
82
+ tilesets: [
83
+ {
84
+ firstgid: 1,
85
+ name: 'tiles',
86
+ tilewidth: TILE_SIZE,
87
+ tileheight: TILE_SIZE,
88
+ columns: 5,
89
+ tilecount: 5,
90
+ image: 'procedural://tiles',
91
+ imagewidth: 80,
92
+ imageheight: 16,
93
+ tiles: [
94
+ { id: TILE_WALL - 1, properties: [{ name: 'solid', type: 'bool', value: true }] },
95
+ { id: TILE_ROCK - 1, properties: [{ name: 'solid', type: 'bool', value: true }] },
96
+ { id: TILE_DOOR_CLOSED - 1, properties: [{ name: 'solid', type: 'bool', value: true }] },
97
+ ],
98
+ },
99
+ ],
100
+ };
101
+ }
102
+
103
+ export function loadMap(): TilemapData {
104
+ return parseTiled(buildTiledMapJson());
105
+ }
106
+
107
+ /** True while an AABB centered at (`x`, `y`) with half-extents (`halfW`,
108
+ * `halfH`) overlaps the door tile. Pure and zero-alloc — safe to call every
109
+ * frame from the game loop. */
110
+ export function isTouchingDoor(x: number, y: number, halfW: number, halfH: number): boolean {
111
+ const halfTile = TILE_SIZE / 2;
112
+ return Math.abs(x - DOOR_WORLD_X) <= halfW + halfTile && Math.abs(y - DOOR_WORLD_Y) <= halfH + halfTile;
113
+ }
114
+
115
+ /**
116
+ * Open the door: flips its cell in the collision grid so it stops blocking
117
+ * movement, and swaps its tile GID so the renderer draws the open frame
118
+ * instead of the closed one. Both are plain index writes into arrays the
119
+ * engine already owns (`SolidGrid.solid`, `TileLayer.tiles`) — no grid
120
+ * rebuild, no allocation. Idempotent: calling it again is a no-op.
121
+ */
122
+ export function openDoor(groundLayer: TileLayer, grid: SolidGrid): void {
123
+ const idx = DOOR_ROW * grid.width + DOOR_COL;
124
+ grid.solid[idx] = 0;
125
+ groundLayer.tiles[idx] = TILE_DOOR_OPEN;
126
+ }
@@ -0,0 +1,201 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ buildSolidGrid,
4
+ isBlockedAABB,
5
+ moveAndSlideInto,
6
+ } from '@flare-engine/physics';
7
+ import {
8
+ extractColliders,
9
+ } from '@flare-engine/tilemap';
10
+ import {
11
+ DOOR_COL,
12
+ DOOR_ROW,
13
+ DOOR_WORLD_X,
14
+ DOOR_WORLD_Y,
15
+ TILE_DOOR_CLOSED,
16
+ TILE_DOOR_OPEN,
17
+ TILE_ROCK,
18
+ TILE_SIZE,
19
+ TILE_WALL,
20
+ buildTiledMapJson,
21
+ isTouchingDoor,
22
+ loadMap,
23
+ openDoor,
24
+ } from '../src/map';
25
+
26
+ const MAP_WIDTH = 12;
27
+ const MAP_HEIGHT = 10;
28
+ const PLAYER_HALF = 6;
29
+ // PLAYER_START from App.tsx: {x:24, y:152} -> tile col=1, row=9, char='.'
30
+ const PLAYER_X = 24;
31
+ const PLAYER_Y = 152;
32
+
33
+ // House top wall (row=1, col=4). World center = (4*16+8, 1*16+8).
34
+ const WALL_X = 72;
35
+ const WALL_Y = 24;
36
+
37
+ // Rock at (row=3, col=2). World center = (2*16+8, 3*16+8).
38
+ const ROCK_X = 40;
39
+ const ROCK_Y = 56;
40
+
41
+ /** Load a fresh map + ground layer + solid grid for one test — every test
42
+ * gets its own SolidGrid so openDoor in one test cannot leak into another. */
43
+ function setup() {
44
+ const map = loadMap();
45
+ const groundLayer = map.layers[0];
46
+ if (groundLayer === undefined || groundLayer.type !== 'tile') {
47
+ throw new Error('expected a tile layer at index 0');
48
+ }
49
+ const grid = buildSolidGrid(map.width, map.height, TILE_SIZE, extractColliders(map));
50
+ return { map, groundLayer, grid };
51
+ }
52
+
53
+ describe('buildTiledMapJson', () => {
54
+ test('produces correct shape, dimensions and known GIDs', () => {
55
+ const json = buildTiledMapJson() as {
56
+ width: number;
57
+ height: number;
58
+ tilewidth: number;
59
+ tileheight: number;
60
+ layers: Array<{ type: string; data: number[] }>;
61
+ tilesets: Array<{ name: string; firstgid: number }>;
62
+ };
63
+
64
+ expect(json.width).toBe(MAP_WIDTH);
65
+ expect(json.height).toBe(MAP_HEIGHT);
66
+ expect(json.tilewidth).toBe(TILE_SIZE);
67
+ expect(json.tileheight).toBe(TILE_SIZE);
68
+
69
+ const layer = json.layers[0];
70
+ expect(layer?.type).toBe('tilelayer');
71
+
72
+ const data = layer?.data ?? [];
73
+ // House top wall corner (row=1, col=4) -> '#' -> TILE_WALL
74
+ expect(data[1 * MAP_WIDTH + 4]).toBe(TILE_WALL);
75
+ // Rock (row=3, col=2) -> 'R' -> TILE_ROCK
76
+ expect(data[3 * MAP_WIDTH + 2]).toBe(TILE_ROCK);
77
+ // Door (row=8, col=5) -> 'D' -> starts closed
78
+ expect(data[DOOR_ROW * MAP_WIDTH + DOOR_COL]).toBe(TILE_DOOR_CLOSED);
79
+
80
+ expect(json.tilesets[0]?.name).toBe('tiles');
81
+ });
82
+ });
83
+
84
+ describe('parseTiled round-trip', () => {
85
+ test('produces correct TilemapData with solid tile marks', () => {
86
+ const map = loadMap();
87
+
88
+ expect(map.width).toBe(MAP_WIDTH);
89
+ expect(map.height).toBe(MAP_HEIGHT);
90
+ expect(map.tileWidth).toBe(TILE_SIZE);
91
+ expect(map.tileHeight).toBe(TILE_SIZE);
92
+
93
+ expect(map.layers.length).toBe(1);
94
+ expect(map.layers[0]?.type).toBe('tile');
95
+
96
+ expect(map.tilesets.length).toBe(1);
97
+ const ts = map.tilesets[0];
98
+ expect(ts?.name).toBe('tiles');
99
+
100
+ // wall, rock, and the closed door must be solid; localId = gid - firstGid
101
+ expect(ts?.solidTiles.has(TILE_WALL - 1)).toBe(true);
102
+ expect(ts?.solidTiles.has(TILE_ROCK - 1)).toBe(true);
103
+ expect(ts?.solidTiles.has(TILE_DOOR_CLOSED - 1)).toBe(true);
104
+ // grass (localId=0) is not solid
105
+ expect(ts?.solidTiles.has(0)).toBe(false);
106
+ });
107
+ });
108
+
109
+ describe('extractColliders', () => {
110
+ test('house walls, rocks, and the closed door are solid; interior floor is not', () => {
111
+ const map = loadMap();
112
+ const colliders = extractColliders(map);
113
+ const solidSet = new Set(colliders.map((c) => `${c.row},${c.col}`));
114
+
115
+ // House top wall corner and a rock
116
+ expect(solidSet.has('1,4')).toBe(true);
117
+ expect(solidSet.has('3,2')).toBe(true);
118
+ // Closed door
119
+ expect(solidSet.has(`${DOOR_ROW},${DOOR_COL}`)).toBe(true);
120
+ // Interior floor tile (row=4, col=5)
121
+ expect(solidSet.has('4,5')).toBe(false);
122
+ });
123
+ });
124
+
125
+ describe('isTouchingDoor', () => {
126
+ test('true when the AABB overlaps the door tile, false far away', () => {
127
+ expect(isTouchingDoor(DOOR_WORLD_X, DOOR_WORLD_Y, PLAYER_HALF, PLAYER_HALF)).toBe(true);
128
+ expect(isTouchingDoor(PLAYER_X, PLAYER_Y, PLAYER_HALF, PLAYER_HALF)).toBe(false);
129
+ });
130
+ });
131
+
132
+ describe('openDoor', () => {
133
+ test('flips the solid-grid cell and swaps the tile GID', () => {
134
+ const { groundLayer, grid } = setup();
135
+ const idx = DOOR_ROW * grid.width + DOOR_COL;
136
+
137
+ expect(grid.solid[idx]).toBe(1);
138
+ expect(groundLayer.tiles[idx]).toBe(TILE_DOOR_CLOSED);
139
+
140
+ openDoor(groundLayer, grid);
141
+
142
+ expect(grid.solid[idx]).toBe(0);
143
+ expect(groundLayer.tiles[idx]).toBe(TILE_DOOR_OPEN);
144
+ });
145
+ });
146
+
147
+ describe('collision: wall, rock, and door', () => {
148
+ test('wall and rock block; PLAYER_START is open', () => {
149
+ const { grid } = setup();
150
+
151
+ expect(isBlockedAABB(grid, WALL_X, WALL_Y, PLAYER_HALF, PLAYER_HALF)).toBe(true);
152
+ expect(isBlockedAABB(grid, ROCK_X, ROCK_Y, PLAYER_HALF, PLAYER_HALF)).toBe(true);
153
+ expect(isBlockedAABB(grid, PLAYER_X, PLAYER_Y, PLAYER_HALF, PLAYER_HALF)).toBe(false);
154
+ });
155
+
156
+ test('closed door blocks; open door does not; wall and rock are unaffected by opening it', () => {
157
+ const { groundLayer, grid } = setup();
158
+
159
+ expect(isBlockedAABB(grid, DOOR_WORLD_X, DOOR_WORLD_Y, PLAYER_HALF, PLAYER_HALF)).toBe(true);
160
+
161
+ openDoor(groundLayer, grid);
162
+
163
+ expect(isBlockedAABB(grid, DOOR_WORLD_X, DOOR_WORLD_Y, PLAYER_HALF, PLAYER_HALF)).toBe(false);
164
+ // Opening the door must not touch any other cell.
165
+ expect(isBlockedAABB(grid, WALL_X, WALL_Y, PLAYER_HALF, PLAYER_HALF)).toBe(true);
166
+ expect(isBlockedAABB(grid, ROCK_X, ROCK_Y, PLAYER_HALF, PLAYER_HALF)).toBe(true);
167
+ });
168
+
169
+ test('moveAndSlideInto: closed door blocks approach; opening it lets the same move through', () => {
170
+ // Just south of the door tile (row=8 spans y=128..144), moving north (dy<0).
171
+ const { groundLayer, grid } = setup();
172
+ const startX = DOOR_WORLD_X;
173
+ const startY = 150;
174
+
175
+ const blocked = { x: startX, y: startY };
176
+ moveAndSlideInto(grid, startX, startY, 0, -10, PLAYER_HALF, PLAYER_HALF, blocked);
177
+ expect(blocked.y).toBe(startY); // door still closed — no movement
178
+
179
+ openDoor(groundLayer, grid);
180
+
181
+ const opened = { x: startX, y: startY };
182
+ moveAndSlideInto(grid, startX, startY, 0, -10, PLAYER_HALF, PLAYER_HALF, opened);
183
+ expect(opened.y).toBe(startY - 10); // door open — the same move now succeeds
184
+ });
185
+
186
+ test('moveAndSlideInto: blocks x-movement into a wall; allows y-movement into open space', () => {
187
+ const { grid } = setup();
188
+
189
+ // House right side wall is col=7 (x starts at 112). Approach from just west of it.
190
+ const wallBlockX = 105;
191
+ const out1 = { x: wallBlockX, y: WALL_Y + 32 };
192
+ moveAndSlideInto(grid, wallBlockX, WALL_Y + 32, 10, 0, PLAYER_HALF, PLAYER_HALF, out1);
193
+ expect(out1.x).toBe(wallBlockX); // x unchanged — wall blocks it
194
+
195
+ // PLAYER_START position with dy=2 stays on open field grass -> movement applies
196
+ const out2 = { x: PLAYER_X, y: PLAYER_Y };
197
+ moveAndSlideInto(grid, PLAYER_X, PLAYER_Y, 0, 2, PLAYER_HALF, PLAYER_HALF, out2);
198
+ expect(out2.x).toBe(PLAYER_X); // x unchanged — no dx
199
+ expect(out2.y).toBe(PLAYER_Y + 2); // y advances
200
+ });
201
+ });
@@ -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
+ }