@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.
- package/LICENSE +201 -0
- package/NOTICE +2 -0
- package/README.md +43 -0
- package/dist/bin.js +10 -0
- package/dist/bin.js.map +1 -0
- package/dist/bin.mjs +12 -0
- package/dist/bin.mjs.map +1 -0
- package/dist/cli-BGXZ73ie.mjs +189 -0
- package/dist/cli-BGXZ73ie.mjs.map +1 -0
- package/dist/cli-JzwtwZhm.js +230 -0
- package/dist/cli-JzwtwZhm.js.map +1 -0
- package/dist/index.d.mts +101 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.mjs +2 -0
- package/package.json +61 -0
- package/templates/blank/README.md +59 -0
- package/templates/blank/app.json +10 -0
- package/templates/blank/babel.config.js +7 -0
- package/templates/blank/gitignore +8 -0
- package/templates/blank/index.ts +4 -0
- package/templates/blank/index.web.ts +17 -0
- package/templates/blank/metro.config.js +3 -0
- package/templates/blank/package.json +36 -0
- package/templates/blank/scripts/copy-canvaskit.mjs +15 -0
- package/templates/blank/src/App.tsx +45 -0
- package/templates/blank/tsconfig.json +15 -0
- package/templates/shmup/README.md +59 -0
- package/templates/shmup/app.json +10 -0
- package/templates/shmup/babel.config.js +7 -0
- package/templates/shmup/gitignore +8 -0
- package/templates/shmup/index.ts +4 -0
- package/templates/shmup/index.web.ts +17 -0
- package/templates/shmup/metro.config.js +3 -0
- package/templates/shmup/package.json +39 -0
- package/templates/shmup/scripts/copy-canvaskit.mjs +15 -0
- package/templates/shmup/src/App.tsx +171 -0
- package/templates/shmup/src/logic.ts +356 -0
- package/templates/shmup/tests/logic.test.ts +235 -0
- package/templates/shmup/tsconfig.json +15 -0
- package/templates/topdown/README.md +79 -0
- package/templates/topdown/app.json +10 -0
- package/templates/topdown/babel.config.js +7 -0
- package/templates/topdown/gitignore +8 -0
- package/templates/topdown/index.ts +4 -0
- package/templates/topdown/index.web.ts +17 -0
- package/templates/topdown/metro.config.js +3 -0
- package/templates/topdown/package.json +39 -0
- package/templates/topdown/scripts/copy-canvaskit.mjs +15 -0
- package/templates/topdown/src/App.tsx +138 -0
- package/templates/topdown/src/map.ts +126 -0
- package/templates/topdown/tests/tilemap.test.ts +201 -0
- package/templates/topdown/tsconfig.json +15 -0
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@expo/metro-runtime": "~57.0.0",
|
|
14
|
+
"@flare-engine/engine": "{{FLARE_VERSION}}",
|
|
15
|
+
"@flare-engine/react": "{{FLARE_VERSION}}",
|
|
16
|
+
"@shopify/react-native-skia": "2.6.0",
|
|
17
|
+
"canvaskit-wasm": "0.41.0",
|
|
18
|
+
"expo": "~57.0.0",
|
|
19
|
+
"expo-status-bar": "~57.0.0",
|
|
20
|
+
"react": "19.2.3",
|
|
21
|
+
"react-dom": "19.2.3",
|
|
22
|
+
"react-native": "0.86.0",
|
|
23
|
+
"react-native-reanimated": "~4.5.0",
|
|
24
|
+
"react-native-safe-area-context": "~5.6.0",
|
|
25
|
+
"react-native-web": "~0.21.0",
|
|
26
|
+
"react-native-worklets": "~0.10.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@babel/core": "^7.25.0",
|
|
30
|
+
"@types/react": "~19.2.0",
|
|
31
|
+
"typescript": "^5.5.0"
|
|
32
|
+
},
|
|
33
|
+
"trustedDependencies": [
|
|
34
|
+
"@shopify/react-native-skia"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
@@ -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,45 @@
|
|
|
1
|
+
import { Position, Sprite, createProceduralAtlas } from "@flare-engine/engine";
|
|
2
|
+
import type { GameContextValue } from "@flare-engine/engine";
|
|
3
|
+
import { GameView, skiaAtlasModule } from "@flare-engine/react";
|
|
4
|
+
import type React from "react";
|
|
5
|
+
import { StyleSheet, View } from "react-native";
|
|
6
|
+
import { SafeAreaProvider } from "react-native-safe-area-context";
|
|
7
|
+
|
|
8
|
+
// One cyan square rendered at world origin.
|
|
9
|
+
// Camera default is also (0, 0), so worldToScreen(0, 0) = screen center.
|
|
10
|
+
const ATLAS_FRAMES = [{ name: "sprite", w: 64, h: 64, color: 0xff6bcfff }] as const;
|
|
11
|
+
|
|
12
|
+
function onReady(ctx: GameContextValue): void {
|
|
13
|
+
// createProceduralAtlas draws colored rects onto a Skia surface.
|
|
14
|
+
// Must be called inside onReady — Skia context is live by this point.
|
|
15
|
+
const atlas = createProceduralAtlas(skiaAtlasModule, ATLAS_FRAMES);
|
|
16
|
+
const frame = atlas.sheet.frames.find((f) => f.name === "sprite");
|
|
17
|
+
if (!frame) return;
|
|
18
|
+
|
|
19
|
+
const entity = ctx.world.spawn();
|
|
20
|
+
ctx.world.add(entity, Position, { x: 0, y: 0 });
|
|
21
|
+
ctx.world.add(entity, Sprite, {
|
|
22
|
+
image: atlas.image,
|
|
23
|
+
frame,
|
|
24
|
+
z: 0,
|
|
25
|
+
width: frame.w,
|
|
26
|
+
height: frame.h,
|
|
27
|
+
anchor: { x: 0.5, y: 0.5 },
|
|
28
|
+
});
|
|
29
|
+
// GameView starts the loop after onReady returns — do NOT call ctx.loop.start().
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function App(): React.ReactElement {
|
|
33
|
+
return (
|
|
34
|
+
<SafeAreaProvider>
|
|
35
|
+
<View style={styles.root}>
|
|
36
|
+
<GameView onReady={onReady} style={styles.game} />
|
|
37
|
+
</View>
|
|
38
|
+
</SafeAreaProvider>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const styles = StyleSheet.create({
|
|
43
|
+
root: { flex: 1, backgroundColor: "#000" },
|
|
44
|
+
game: { flex: 1 },
|
|
45
|
+
});
|
|
@@ -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,59 @@
|
|
|
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
|
+
## Layout
|
|
46
|
+
|
|
47
|
+
- `src/App.tsx` — root component; mounts `<GameView>` and configures the Flare game loop.
|
|
48
|
+
- `index.ts` — Expo entry that registers `App` as the root component (native).
|
|
49
|
+
- `index.web.ts` — web entry; awaits `LoadSkiaWeb()` **before** importing `App`.
|
|
50
|
+
`package.json`'s `main` is extensionless so Metro resolves the right entry per
|
|
51
|
+
platform. The split is required: RN-Skia's web module reads `global.CanvasKit`
|
|
52
|
+
when it is first imported, so importing `App` any earlier leaves Skia hollow.
|
|
53
|
+
- `scripts/copy-canvaskit.mjs` — copies the CanvasKit wasm into `public/` for the
|
|
54
|
+
web build.
|
|
55
|
+
- `app.json` — Expo configuration.
|
|
56
|
+
|
|
57
|
+
## Docs
|
|
58
|
+
|
|
59
|
+
- [Flare engine](https://github.com/grzott/flare-engine)
|
|
@@ -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,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/ecs": "{{FLARE_VERSION}}",
|
|
16
|
+
"@flare-engine/engine": "{{FLARE_VERSION}}",
|
|
17
|
+
"@flare-engine/react": "{{FLARE_VERSION}}",
|
|
18
|
+
"@flare-engine/render": "{{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,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shmup starter template — thin React Native shell.
|
|
3
|
+
*
|
|
4
|
+
* The heavy logic lives in ./logic.ts (headless, no Skia dependency) so it
|
|
5
|
+
* can be unit-tested without mocks. This file owns only:
|
|
6
|
+
* - Atlas build (needs Skia surface)
|
|
7
|
+
* - ECS entity spawn (player)
|
|
8
|
+
* - Joystick → player movement
|
|
9
|
+
* - Score HUD
|
|
10
|
+
* - Wiring the frame loop
|
|
11
|
+
*
|
|
12
|
+
* Key conventions:
|
|
13
|
+
* DO NOT call ctx.loop.start() — GameView calls it after onReady returns.
|
|
14
|
+
* DO call ctx.world.step(dt) in onUpdate — GameLoop does NOT auto-step the world.
|
|
15
|
+
* Create all ECS queries in onReady (World.query() registers a permanent query).
|
|
16
|
+
*/
|
|
17
|
+
import { Position, Sprite, createProceduralAtlas } from "@flare-engine/engine";
|
|
18
|
+
import type { GameContextValue, VirtualJoystickState } from "@flare-engine/engine";
|
|
19
|
+
import { GameView, VirtualJoystick, skiaAtlasModule } from "@flare-engine/react";
|
|
20
|
+
import type React from "react";
|
|
21
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
22
|
+
import { StyleSheet, Text, View } from "react-native";
|
|
23
|
+
import { SafeAreaProvider } from "react-native-safe-area-context";
|
|
24
|
+
import {
|
|
25
|
+
createShmupState,
|
|
26
|
+
setupShmup,
|
|
27
|
+
stepShmup,
|
|
28
|
+
} from "./logic";
|
|
29
|
+
|
|
30
|
+
// ─── Colour palette ───────────────────────────────────────────────────────────
|
|
31
|
+
// ARGB hex constants. Change these to re-skin the whole game in seconds.
|
|
32
|
+
const COLOR_PLAYER = 0xff6bcfff; // cyan-ish ship
|
|
33
|
+
const COLOR_BULLET = 0xffffe566; // bright yellow
|
|
34
|
+
const COLOR_ENEMY = 0xffff5454; // hostile red
|
|
35
|
+
|
|
36
|
+
// ─── Atlas frames ─────────────────────────────────────────────────────────────
|
|
37
|
+
const ATLAS_FRAMES = [
|
|
38
|
+
{ name: "player", w: 16, h: 20, color: COLOR_PLAYER },
|
|
39
|
+
{ name: "bullet", w: 6, h: 12, color: COLOR_BULLET },
|
|
40
|
+
{ name: "enemy", w: 24, h: 24, color: COLOR_ENEMY },
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
const PLAYER_START_X = 160;
|
|
44
|
+
const PLAYER_START_Y = 600;
|
|
45
|
+
const PLAYER_HALF_W = 8;
|
|
46
|
+
const PLAYER_HALF_H = 10;
|
|
47
|
+
const PLAYER_SPEED = 200; // px/s
|
|
48
|
+
|
|
49
|
+
export function App(): React.ReactElement {
|
|
50
|
+
const dirRef = useRef({ x: 0, y: 0 });
|
|
51
|
+
const [score, setScore] = useState(0);
|
|
52
|
+
|
|
53
|
+
// GameView's onRenderBackground hook is optional; kept empty here — we rely
|
|
54
|
+
// on the built-in (Position, Sprite) render system.
|
|
55
|
+
const config = useMemo(() => ({}), []);
|
|
56
|
+
|
|
57
|
+
const handleReady = useCallback((ctx: GameContextValue): void => {
|
|
58
|
+
// ── Build atlas ───────────────────────────────────────────────────────────
|
|
59
|
+
// createProceduralAtlas draws solid-colour rects onto a Skia surface.
|
|
60
|
+
// Must be called inside onReady (Skia context is live by this point).
|
|
61
|
+
// skiaAtlasModule centralises the Skia → SkiaAtlasModuleLike adaptation.
|
|
62
|
+
const atlas = createProceduralAtlas(skiaAtlasModule, ATLAS_FRAMES);
|
|
63
|
+
|
|
64
|
+
const playerFrame = atlas.sheet.frames.find((f) => f.name === "player")!;
|
|
65
|
+
const bulletFrame = atlas.sheet.frames.find((f) => f.name === "bullet")!;
|
|
66
|
+
const enemyFrame = atlas.sheet.frames.find((f) => f.name === "enemy")!;
|
|
67
|
+
|
|
68
|
+
// ── Register engine systems + get the mutable bounds handle ──────────────
|
|
69
|
+
// setupShmup registers createMovementSystem (priority 0) and
|
|
70
|
+
// createBoundsCullSystem (priority 2). bounds is updated every frame in
|
|
71
|
+
// onUpdate from the live camera viewport so wide screens never misfire.
|
|
72
|
+
const { bounds, stateRef } = setupShmup(ctx.world);
|
|
73
|
+
|
|
74
|
+
// ── Spawn player ──────────────────────────────────────────────────────────
|
|
75
|
+
const player = ctx.world.spawn();
|
|
76
|
+
ctx.world.add(player, Position, { x: PLAYER_START_X, y: PLAYER_START_Y });
|
|
77
|
+
ctx.world.add(player, Sprite, {
|
|
78
|
+
image: atlas.image,
|
|
79
|
+
frame: playerFrame,
|
|
80
|
+
z: 1,
|
|
81
|
+
width: playerFrame.w,
|
|
82
|
+
height: playerFrame.h,
|
|
83
|
+
anchor: { x: 0.5, y: 0.5 },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ── Build shared sprite data for bullets and enemies ──────────────────────
|
|
87
|
+
// These objects are passed to every bullet/enemy entity by reference —
|
|
88
|
+
// no per-entity copy, satisfying the zero-alloc constraint.
|
|
89
|
+
const bulletSprite = {
|
|
90
|
+
image: atlas.image,
|
|
91
|
+
frame: bulletFrame,
|
|
92
|
+
z: 2,
|
|
93
|
+
width: bulletFrame.w,
|
|
94
|
+
height: bulletFrame.h,
|
|
95
|
+
anchor: { x: 0.5, y: 0.5 },
|
|
96
|
+
};
|
|
97
|
+
const enemySprite = {
|
|
98
|
+
image: atlas.image,
|
|
99
|
+
frame: enemyFrame,
|
|
100
|
+
z: 0,
|
|
101
|
+
width: enemyFrame.w,
|
|
102
|
+
height: enemyFrame.h,
|
|
103
|
+
anchor: { x: 0.5, y: 0.5 },
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// ── Create game state (queries registered inside, never per-frame) ─────────
|
|
107
|
+
const state = createShmupState(ctx.world, bulletSprite, enemySprite);
|
|
108
|
+
|
|
109
|
+
// Wire stateRef so the cull callback (registered in setupShmup) can access
|
|
110
|
+
// the pools. MUST happen before the first world.step call.
|
|
111
|
+
stateRef.current = state;
|
|
112
|
+
|
|
113
|
+
// ── Per-frame update ──────────────────────────────────────────────────────
|
|
114
|
+
ctx.loop.onUpdate.add((dt) => {
|
|
115
|
+
// Keep bounds in sync with the live viewport every frame.
|
|
116
|
+
// camera.viewportWidth is 0 at onReady (layout not measured yet), so we
|
|
117
|
+
// update here — pure number writes, no allocation.
|
|
118
|
+
const vw = ctx.camera.viewportWidth > 0 ? ctx.camera.viewportWidth : 400;
|
|
119
|
+
const vh = ctx.camera.viewportHeight > 0 ? ctx.camera.viewportHeight : 750;
|
|
120
|
+
bounds.minX = -32;
|
|
121
|
+
bounds.minY = -64;
|
|
122
|
+
bounds.maxX = vw + 32;
|
|
123
|
+
bounds.maxY = vh + 64;
|
|
124
|
+
|
|
125
|
+
// 1. Move player in-place from joystick, clamped to the live viewport.
|
|
126
|
+
const pos = ctx.world.get(player, Position);
|
|
127
|
+
if (pos !== undefined) {
|
|
128
|
+
pos.x = Math.max(PLAYER_HALF_W, Math.min(vw - PLAYER_HALF_W, pos.x + dirRef.current.x * PLAYER_SPEED * dt));
|
|
129
|
+
pos.y = Math.max(PLAYER_HALF_H, Math.min(vh - PLAYER_HALF_H, pos.y + dirRef.current.y * PLAYER_SPEED * dt));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 2. Step world systems: movement integrates velocities, cull removes
|
|
133
|
+
// entities that crossed the screen boundary.
|
|
134
|
+
// Note: player has no Velocity component, so the movement system
|
|
135
|
+
// skips it — the manual move above is the only player integration.
|
|
136
|
+
ctx.world.step(dt);
|
|
137
|
+
|
|
138
|
+
// 3. Game logic: fire bullets, spawn enemies, detect collisions.
|
|
139
|
+
const kills = stepShmup(ctx.world, state, dt, pos?.x ?? PLAYER_START_X, pos?.y ?? PLAYER_START_Y);
|
|
140
|
+
|
|
141
|
+
// Update score only on kills — not every frame — to keep React renders minimal.
|
|
142
|
+
if (kills > 0) setScore((s) => s + kills);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// NOTE: Do NOT call ctx.loop.start() — GameView calls it after onReady returns.
|
|
146
|
+
}, []); // biome-ignore lint/correctness/useExhaustiveDependencies: intentional mount-only
|
|
147
|
+
|
|
148
|
+
const handleJoystick = useCallback((state: VirtualJoystickState): void => {
|
|
149
|
+
dirRef.current.x = state.direction.x;
|
|
150
|
+
dirRef.current.y = state.direction.y;
|
|
151
|
+
}, []);
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<SafeAreaProvider>
|
|
155
|
+
<View style={styles.root}>
|
|
156
|
+
<GameView config={config} onReady={handleReady} style={styles.game}>
|
|
157
|
+
{/* Score overlay — updated only on kills, not per-frame */}
|
|
158
|
+
<Text style={styles.score}>Score: {score}</Text>
|
|
159
|
+
<VirtualJoystick style={styles.joystick} onStateChange={handleJoystick} />
|
|
160
|
+
</GameView>
|
|
161
|
+
</View>
|
|
162
|
+
</SafeAreaProvider>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const styles = StyleSheet.create({
|
|
167
|
+
root: { flex: 1, backgroundColor: "#000" },
|
|
168
|
+
game: { flex: 1 },
|
|
169
|
+
score: { position: "absolute", top: 24, right: 24, color: "#fff", fontSize: 18 },
|
|
170
|
+
joystick:{ position: "absolute", left: 24, bottom: 24 },
|
|
171
|
+
});
|