@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,3 @@
1
+ const { getDefaultConfig } = require("expo/metro-config");
2
+
3
+ module.exports = getDefaultConfig(__dirname);
@@ -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,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
+ }
@@ -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/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
+ });