@codyswann/lisa 2.178.6 → 2.179.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/dist/configs/eslint/phaser.d.ts +2 -8
- package/dist/configs/eslint/phaser.d.ts.map +1 -1
- package/dist/configs/eslint/phaser.js +143 -17
- package/dist/configs/eslint/phaser.js.map +1 -1
- package/eslint-plugin-phaser/README.md +19 -0
- package/eslint-plugin-phaser/index.js +35 -0
- package/eslint-plugin-phaser/package.json +10 -0
- package/eslint-plugin-phaser/rules/no-allocation-in-update.js +189 -0
- package/eslint-plugin-phaser/rules/no-create-in-update.js +200 -0
- package/eslint-plugin-phaser/rules/require-shutdown-cleanup.js +191 -0
- package/package.json +5 -3
- package/phaser/package-lisa/package.lisa.json +11 -3
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/rules/phaser.md +111 -44
- package/plugins/lisa-phaser/skills/phaser-accessibility/SKILL.md +135 -0
- package/plugins/lisa-phaser/skills/phaser-accessibility/agents/openai.yaml +4 -0
- package/plugins/lisa-phaser/skills/phaser-asset-pipeline/SKILL.md +148 -0
- package/plugins/lisa-phaser/skills/phaser-asset-pipeline/agents/openai.yaml +4 -0
- package/plugins/lisa-phaser/skills/phaser-build-deploy/SKILL.md +148 -0
- package/plugins/lisa-phaser/skills/phaser-build-deploy/agents/openai.yaml +4 -0
- package/plugins/lisa-phaser/skills/phaser-i18n/SKILL.md +122 -0
- package/plugins/lisa-phaser/skills/phaser-i18n/agents/openai.yaml +4 -0
- package/plugins/lisa-phaser/skills/phaser-services/SKILL.md +208 -0
- package/plugins/lisa-phaser/skills/phaser-services/agents/openai.yaml +4 -0
- package/plugins/lisa-phaser/skills/phaser-testing/SKILL.md +103 -10
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/skills/phaser-accessibility/SKILL.md +135 -0
- package/plugins/lisa-phaser-agy/skills/phaser-asset-pipeline/SKILL.md +148 -0
- package/plugins/lisa-phaser-agy/skills/phaser-build-deploy/SKILL.md +148 -0
- package/plugins/lisa-phaser-agy/skills/phaser-i18n/SKILL.md +122 -0
- package/plugins/lisa-phaser-agy/skills/phaser-services/SKILL.md +208 -0
- package/plugins/lisa-phaser-agy/skills/phaser-testing/SKILL.md +103 -10
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/rules/phaser.md +111 -44
- package/plugins/lisa-phaser-copilot/skills/phaser-accessibility/SKILL.md +135 -0
- package/plugins/lisa-phaser-copilot/skills/phaser-asset-pipeline/SKILL.md +148 -0
- package/plugins/lisa-phaser-copilot/skills/phaser-build-deploy/SKILL.md +148 -0
- package/plugins/lisa-phaser-copilot/skills/phaser-i18n/SKILL.md +122 -0
- package/plugins/lisa-phaser-copilot/skills/phaser-services/SKILL.md +208 -0
- package/plugins/lisa-phaser-copilot/skills/phaser-testing/SKILL.md +103 -10
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/rules/phaser.mdc +111 -44
- package/plugins/lisa-phaser-cursor/skills/phaser-accessibility/SKILL.md +135 -0
- package/plugins/lisa-phaser-cursor/skills/phaser-asset-pipeline/SKILL.md +148 -0
- package/plugins/lisa-phaser-cursor/skills/phaser-build-deploy/SKILL.md +148 -0
- package/plugins/lisa-phaser-cursor/skills/phaser-i18n/SKILL.md +122 -0
- package/plugins/lisa-phaser-cursor/skills/phaser-services/SKILL.md +208 -0
- package/plugins/lisa-phaser-cursor/skills/phaser-testing/SKILL.md +103 -10
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/phaser/rules/phaser.md +111 -44
- package/plugins/src/phaser/skills/phaser-accessibility/SKILL.md +135 -0
- package/plugins/src/phaser/skills/phaser-asset-pipeline/SKILL.md +148 -0
- package/plugins/src/phaser/skills/phaser-build-deploy/SKILL.md +148 -0
- package/plugins/src/phaser/skills/phaser-i18n/SKILL.md +122 -0
- package/plugins/src/phaser/skills/phaser-services/SKILL.md +208 -0
- package/plugins/src/phaser/skills/phaser-testing/SKILL.md +103 -10
- package/tsconfig/phaser.json +8 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: phaser-asset-pipeline
|
|
3
|
+
description: This skill should be used when setting up or changing the build-time asset pipeline of a Phaser 4 game — packing raw art in assets/src into public/assets (free-tex-packer-core texture atlases, audiosprite audio sprites, BMFont bitmap fonts) and the codegen step that emits typed keys to src/assets.ts so a missing or renamed key is a compile error. Use it when adding source art, wiring packing into the build, regenerating typed keys, or eliminating raw string asset keys. Pairs with phaser-assets, phaser-build-deploy, and phaser-services.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Phaser 4 Asset Pipeline
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Runtime assets are **generated, not hand-placed**. Raw source art lives in
|
|
11
|
+
`assets/src/**` (PNGs, audio, font sources); a build step packs it into
|
|
12
|
+
`public/assets/**` (atlases, audio sprites, bitmap fonts) and **codegens typed
|
|
13
|
+
key constants into `src/assets.ts`**. The payoff: a missing or renamed asset is a
|
|
14
|
+
**compile error**, not a green square or silent-missing audio at runtime. No raw
|
|
15
|
+
string asset / scene / event keys anywhere — they all come from the generated
|
|
16
|
+
module ([[phaser-assets]] covers loading those keys at runtime).
|
|
17
|
+
|
|
18
|
+
## Layout
|
|
19
|
+
|
|
20
|
+
| Path | Role |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `assets/src/sprites/**` | Raw individual PNGs — the art source of truth |
|
|
23
|
+
| `assets/src/audio/**` | Raw audio clips (wav/ogg sources) |
|
|
24
|
+
| `assets/src/fonts/**` | Font sources for BMFont generation |
|
|
25
|
+
| `public/assets/**` | **Generated** packed output (atlases, audiosprites, fonts, `pack.json`) — git-ignored or committed as a build artifact |
|
|
26
|
+
| `src/assets.ts` | **Generated** typed key constants — never edited by hand |
|
|
27
|
+
| `scripts/pack-assets.mjs` | The pipeline driver (runs the three packers + codegen) |
|
|
28
|
+
|
|
29
|
+
## Step 1: texture atlases with free-tex-packer-core
|
|
30
|
+
|
|
31
|
+
`free-tex-packer-core` packs many PNGs into atlas pages plus a JSON manifest, in
|
|
32
|
+
a Node script (no GUI, runs in CI):
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
// scripts/pack-assets.mjs (excerpt)
|
|
36
|
+
import { packAsync } from "free-tex-packer-core";
|
|
37
|
+
import { glob } from "glob";
|
|
38
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
39
|
+
|
|
40
|
+
const images = await Promise.all(
|
|
41
|
+
(await glob("assets/src/sprites/**/*.png")).map(async p => ({
|
|
42
|
+
path: p.replace("assets/src/sprites/", ""), contents: await readFile(p),
|
|
43
|
+
})));
|
|
44
|
+
|
|
45
|
+
const files = await packAsync(images, {
|
|
46
|
+
textureName: "game", width: 2048, height: 2048,
|
|
47
|
+
padding: 2, allowRotation: false, allowTrim: true,
|
|
48
|
+
exporter: "JsonHash", removeFileExtension: true,
|
|
49
|
+
});
|
|
50
|
+
for (const f of files) await writeFile(`public/assets/atlases/${f.name}`, f.buffer ?? f.content);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The frame names in the manifest become the atlas frame keys you reference as
|
|
54
|
+
generated constants. Atlas everything that renders together — loose images break
|
|
55
|
+
sprite batching ([[phaser-assets]]).
|
|
56
|
+
|
|
57
|
+
## Step 2: audio sprites with audiosprite
|
|
58
|
+
|
|
59
|
+
`audiosprite` concatenates short SFX into one file (in multiple codecs) plus a
|
|
60
|
+
JSON map of `{ start, end }` per clip — one request, one decode, instead of dozens:
|
|
61
|
+
|
|
62
|
+
```jsonc
|
|
63
|
+
// package.json script
|
|
64
|
+
"pack:audio": "audiosprite --output public/assets/audio/sfx --export ogg,m4a --format howler2 assets/src/audio/*.wav"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Provide at least two codecs (`ogg` + `m4a`) so every browser can play one. Load
|
|
68
|
+
with `this.load.audioSprite(Audio.Sfx, "audio/sfx.json")` and play by sprite key.
|
|
69
|
+
|
|
70
|
+
## Step 3: bitmap fonts with BMFont
|
|
71
|
+
|
|
72
|
+
`BitmapText` is the performant choice for high-churn text (scores, timers) —
|
|
73
|
+
[[phaser-assets]] / [[phaser-i18n]]. Generate the `.fnt` (XML/JSON) + PNG page
|
|
74
|
+
from a font source with a BMFont tool (`msdf-bmfont-xml` or the `bmfont` CLI) in
|
|
75
|
+
the same script:
|
|
76
|
+
|
|
77
|
+
```jsonc
|
|
78
|
+
"pack:font": "msdf-bmfont -o public/assets/fonts/ui --font-size 42 --texture-size 1024 1024 assets/src/fonts/ui.ttf"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Load with `this.load.bitmapFont(Font.UI, "fonts/ui.png", "fonts/ui.fnt")`.
|
|
82
|
+
|
|
83
|
+
## Step 4: codegen typed keys into `src/assets.ts`
|
|
84
|
+
|
|
85
|
+
After packing, walk the generated manifests and emit `as const` key maps. This is
|
|
86
|
+
the step that turns runtime failures into compile failures:
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
// scripts/pack-assets.mjs (codegen excerpt)
|
|
90
|
+
const atlas = JSON.parse(await readFile("public/assets/atlases/game.json", "utf8"));
|
|
91
|
+
const frames = Object.keys(atlas.frames); // raw keys, e.g. "player/idle"
|
|
92
|
+
const sfx = Object.keys(JSON.parse(await readFile("public/assets/audio/sfx.json","utf8")).spritemap);
|
|
93
|
+
|
|
94
|
+
const out = `// AUTO-GENERATED by scripts/pack-assets.mjs — do not edit.
|
|
95
|
+
export const Tex = { GameAtlas: "game" } as const;
|
|
96
|
+
export const Frame = { ${frames.map(k => `${toIdent(k)}: "${k}"`).join(", ")} } as const;
|
|
97
|
+
export const Audio = { Sfx: "sfx" } as const;
|
|
98
|
+
export const Sfx = { ${sfx.map(s => `${toIdent(s)}: "${s}"`).join(", ")} } as const;
|
|
99
|
+
export const Font = { UI: "ui" } as const;
|
|
100
|
+
export const SceneKeys = { Boot:"Boot", Preloader:"Preloader", MainMenu:"MainMenu", Game:"Game" } as const;
|
|
101
|
+
export const GameEvent = { ScoreChanged:"score-changed", EnemyDied:"enemy-died" } as const;
|
|
102
|
+
`;
|
|
103
|
+
await writeFile("src/assets.ts", out);
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Scenes import `Tex`, `Frame`, `Sfx`, `Font`, `SceneKeys`, `GameEvent` — never an
|
|
107
|
+
inline string. Rename art, re-run the pipeline, and every now-stale reference
|
|
108
|
+
fails `bun run typecheck` immediately. (Scene/event keys are kept in the same
|
|
109
|
+
generated module so they share the no-raw-string discipline; edit them via the
|
|
110
|
+
codegen template, not by hand.)
|
|
111
|
+
|
|
112
|
+
## Step 5: wire into the build
|
|
113
|
+
|
|
114
|
+
The pipeline runs before dev and before build so generated output is never stale:
|
|
115
|
+
|
|
116
|
+
```jsonc
|
|
117
|
+
// package.json
|
|
118
|
+
"scripts": {
|
|
119
|
+
"assets": "node scripts/pack-assets.mjs",
|
|
120
|
+
"predev": "bun run assets",
|
|
121
|
+
"prebuild": "bun run assets",
|
|
122
|
+
"dev": "vite",
|
|
123
|
+
"build": "vite build"
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
For fast iteration, a `--watch` mode on the pack script re-packs changed source
|
|
128
|
+
art. In CI the pack step runs once before `vite build`; the build then hashes the
|
|
129
|
+
generated files for immutable caching ([[phaser-build-deploy]]).
|
|
130
|
+
|
|
131
|
+
## Project conventions
|
|
132
|
+
|
|
133
|
+
- `assets/src/**` is the source of truth; `public/assets/**` and `src/assets.ts`
|
|
134
|
+
are generated — treat them as build output (re-runnable, not hand-edited).
|
|
135
|
+
- One atlas per render group, one audiosprite per SFX set, BMFont for hot text.
|
|
136
|
+
- A contract test asserts every constant in `src/assets.ts` resolves to an entry
|
|
137
|
+
in the generated `pack.json`/manifests ([[phaser-testing]]).
|
|
138
|
+
- The generated `src/assets.ts` is the single source of asset/scene/event keys —
|
|
139
|
+
the no-raw-string-keys rule depends on it existing.
|
|
140
|
+
|
|
141
|
+
## Verification
|
|
142
|
+
|
|
143
|
+
The pipeline is verified by deleting a source PNG and re-running `bun run assets`:
|
|
144
|
+
the corresponding generated constant disappears and `bun run typecheck` fails at
|
|
145
|
+
every use site (proving keys are compile-checked). Then boot the game and confirm
|
|
146
|
+
the atlas/audiosprite/font load with no `FILE_LOAD_ERROR` and render/play
|
|
147
|
+
correctly.
|
|
148
|
+
</content>
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: phaser-build-deploy
|
|
3
|
+
description: This skill should be used when configuring the production build or deploying a Phaser 4 game — the Vite prod config (manualChunks to split the phaser vendor chunk, terser two-pass minification), content-hashed assets with immutable caching, the base path per host, vite-plugin-pwa (autoUpdate + globPatterns covering game assets), a bundle-size budget, and static hosting. Use it when setting up the build, fixing caching/base-path issues, adding the PWA, or enforcing bundle size. Pairs with phaser-asset-pipeline, phaser-project-structure, and phaser-testing.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Phaser 4 Build and Deploy
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
The game ships as static files built by **Vite** (with TypeScript 6, `phaser`
|
|
11
|
+
pinned `^4.2.0`). A good prod build does four things: split the large `phaser`
|
|
12
|
+
vendor chunk so app code can change without re-downloading the engine, minify
|
|
13
|
+
hard, content-hash everything for immutable caching, and register a PWA service
|
|
14
|
+
worker that precaches game assets. A bundle-size budget keeps the result honest.
|
|
15
|
+
The asset pipeline ([[phaser-asset-pipeline]]) runs first; this skill packages
|
|
16
|
+
its output.
|
|
17
|
+
|
|
18
|
+
## Vite production config
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// vite.config.ts
|
|
22
|
+
import { defineConfig } from "vite";
|
|
23
|
+
import { VitePWA } from "vite-plugin-pwa";
|
|
24
|
+
|
|
25
|
+
export default defineConfig({
|
|
26
|
+
base: process.env.BASE_PATH ?? "/", // "/" for root domains, "/repo/" for project pages
|
|
27
|
+
build: {
|
|
28
|
+
target: "es2022",
|
|
29
|
+
assetsInlineLimit: 0, // never inline game assets — keep them cacheable files
|
|
30
|
+
minify: "terser",
|
|
31
|
+
terserOptions: { compress: { passes: 2, drop_console: true }, format: { comments: false } },
|
|
32
|
+
rollupOptions: {
|
|
33
|
+
output: {
|
|
34
|
+
manualChunks: { phaser: ["phaser"] }, // engine in its own long-lived chunk
|
|
35
|
+
entryFileNames: "assets/[name]-[hash].js", // content hash on everything
|
|
36
|
+
chunkFileNames: "assets/[name]-[hash].js",
|
|
37
|
+
assetFileNames: "assets/[name]-[hash][extname]",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`manualChunks: { phaser: ["phaser"] }` is the single highest-value line: Phaser is
|
|
45
|
+
hundreds of KB and rarely changes, so isolating it means a gameplay tweak only
|
|
46
|
+
busts the small app chunk. Two-pass terser squeezes meaningfully more than one.
|
|
47
|
+
|
|
48
|
+
## Asset hashing and immutable caching
|
|
49
|
+
|
|
50
|
+
Every emitted file carries a content hash, so it can be cached forever and a
|
|
51
|
+
change produces a new filename (cache-busted automatically):
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
/assets/index-3f2a.js Cache-Control: public, max-age=31536000, immutable
|
|
55
|
+
/assets/phaser-9b1c.js Cache-Control: public, max-age=31536000, immutable
|
|
56
|
+
/index.html Cache-Control: no-cache (must revalidate to pick up new hashes)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`index.html` is the only file served `no-cache` — it points at the hashed assets,
|
|
60
|
+
so revalidating it is enough to roll the whole app forward. Game assets under
|
|
61
|
+
`public/assets/**` ([[phaser-asset-pipeline]]) are hashed by the PWA precache
|
|
62
|
+
manifest below even though Vite copies them verbatim.
|
|
63
|
+
|
|
64
|
+
## Base path per host
|
|
65
|
+
|
|
66
|
+
`base` must match where the game is served:
|
|
67
|
+
|
|
68
|
+
- Root domain / custom domain → `base: "/"`.
|
|
69
|
+
- GitHub Pages project site → `base: "/<repo>/"`.
|
|
70
|
+
- Subpath behind a reverse proxy → that subpath.
|
|
71
|
+
|
|
72
|
+
A wrong `base` is the classic "works on `bun run dev`, blank canvas on deploy"
|
|
73
|
+
bug — assets 404 because the URLs are absolute to the wrong root. Drive it from an
|
|
74
|
+
env var so the same build config serves every host.
|
|
75
|
+
|
|
76
|
+
## PWA: vite-plugin-pwa (on by default)
|
|
77
|
+
|
|
78
|
+
The PWA is on by default: install-to-home-screen, offline play, and auto-update.
|
|
79
|
+
`registerType: "autoUpdate"` ships new versions without a manual prompt; the
|
|
80
|
+
critical part is extending `globPatterns` to precache **game assets** (atlases,
|
|
81
|
+
audio sprites, fonts) — the Workbox default only catches JS/CSS/HTML and would
|
|
82
|
+
leave a "installed but can't load its art offline" game.
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
VitePWA({
|
|
86
|
+
registerType: "autoUpdate",
|
|
87
|
+
includeAssets: ["favicon.svg"],
|
|
88
|
+
workbox: {
|
|
89
|
+
globPatterns: ["**/*.{js,css,html,png,jpg,svg,webp,woff2,json,fnt,mp3,m4a,ogg}"],
|
|
90
|
+
// critically include packed game assets:
|
|
91
|
+
globDirectory: "dist",
|
|
92
|
+
additionalManifestEntries: [], // or widen globPatterns to assets/**/*.{png,json,fnt,ogg,m4a}
|
|
93
|
+
maximumFileSizeToCacheInBytes: 8 * 1024 * 1024, // raise above default 2MB for atlas pages
|
|
94
|
+
},
|
|
95
|
+
manifest: { name: "Game", short_name: "Game", display: "fullscreen", orientation: "landscape",
|
|
96
|
+
background_color: "#000000", theme_color: "#000000", icons: [/* 192/512 */] },
|
|
97
|
+
})
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Set `maximumFileSizeToCacheInBytes` above the default 2 MB or large atlas pages
|
|
101
|
+
silently fall out of the precache.
|
|
102
|
+
|
|
103
|
+
## Bundle-size budget
|
|
104
|
+
|
|
105
|
+
A byte budget, checked after `bun run build`, fails the PR when a stray dependency
|
|
106
|
+
or an un-split engine chunk bloats the download. This is the same budget the
|
|
107
|
+
bundle-size gate in [[phaser-testing]] enforces in CI.
|
|
108
|
+
|
|
109
|
+
```jsonc
|
|
110
|
+
// size-limit config (or a custom post-build check)
|
|
111
|
+
[
|
|
112
|
+
{ "name": "app", "path": "dist/assets/index-*.js", "limit": "60 kB" },
|
|
113
|
+
{ "name": "phaser", "path": "dist/assets/phaser-*.js", "limit": "400 kB" }
|
|
114
|
+
]
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Separate budgets per chunk catch the right regressions: app growth from your code
|
|
118
|
+
vs. an accidental second copy of a big dependency landing in the phaser chunk.
|
|
119
|
+
|
|
120
|
+
## Static deploy
|
|
121
|
+
|
|
122
|
+
Output is plain static files in `dist/` — host on any static CDN (GitHub Pages,
|
|
123
|
+
Netlify, Cloudflare Pages, S3+CloudFront). Requirements: serve over HTTPS (PWA +
|
|
124
|
+
Web Audio expect a secure context), set the cache headers above (immutable for
|
|
125
|
+
hashed assets, no-cache for `index.html`), and for client-routed builds add an
|
|
126
|
+
SPA fallback to `index.html`. Build with the host's base path:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
BASE_PATH=/my-game/ bun run build # then publish dist/
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Project conventions
|
|
133
|
+
|
|
134
|
+
- Run the asset pipeline before the build (`prebuild`) so `public/assets/**` and
|
|
135
|
+
`src/assets.ts` are current ([[phaser-asset-pipeline]]).
|
|
136
|
+
- `phaser` is always its own manualChunk; never let it merge into the app chunk.
|
|
137
|
+
- `base` comes from an env var, never hardcoded to one host.
|
|
138
|
+
- The bundle-size budget is committed and CI-gated ([[phaser-testing]]).
|
|
139
|
+
- Build pins: `phaser ^4.2.0`, Vite, TypeScript 6.
|
|
140
|
+
|
|
141
|
+
## Verification
|
|
142
|
+
|
|
143
|
+
Verified by `bun run build` succeeding under the size budget, then `bun run
|
|
144
|
+
preview` (served at the deploy `base`) booting the game with no 404s and no
|
|
145
|
+
console errors. Confirm the PWA registers (Application → Service Workers), load
|
|
146
|
+
once online then go offline and reload to confirm assets are precached, and
|
|
147
|
+
confirm a redeploy auto-updates clients without a manual cache clear.
|
|
148
|
+
</content>
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: phaser-i18n
|
|
3
|
+
description: This skill should be used when localizing a Phaser 4 game — a typed string catalog so no user-facing text is hardcoded, runtime locale switching that re-renders open text, interpolation/pluralization, and the BitmapText vs Text trade-offs (glyph coverage, RTL, CJK) localization forces. Use it when adding any player-facing string, building a language selector, or fixing missing-glyph/hardcoded-text issues. Pairs with phaser-services, phaser-accessibility, and phaser-asset-pipeline.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Phaser 4 Internationalization
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
No user-facing string is hardcoded in a scene. All player-visible text comes from
|
|
11
|
+
a **typed string catalog** keyed by typed constants, so a missing or misspelled
|
|
12
|
+
key is a compile error and every string has a home for translation. The catalog
|
|
13
|
+
is a small typed wrapper (no heavy dependency required); locale switching
|
|
14
|
+
re-renders any open text. Announced strings ([[phaser-accessibility]]) and
|
|
15
|
+
service messages ([[phaser-services]]) draw from the same catalog.
|
|
16
|
+
|
|
17
|
+
## The typed catalog
|
|
18
|
+
|
|
19
|
+
One module owns the locales and the lookup. The key type is derived from the
|
|
20
|
+
default locale so every locale must cover the same keys:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// src/i18n/catalog.ts
|
|
24
|
+
const en = {
|
|
25
|
+
"menu.play": "Play",
|
|
26
|
+
"menu.settings": "Settings",
|
|
27
|
+
"hud.score": "Score: {score}",
|
|
28
|
+
"result.cleared": "Level {level} cleared!",
|
|
29
|
+
"lives": "{n} life|{n} lives", // singular|plural
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
const es: Record<keyof typeof en, string> = {
|
|
33
|
+
"menu.play": "Jugar", "menu.settings": "Ajustes",
|
|
34
|
+
"hud.score": "Puntos: {score}", "result.cleared": "¡Nivel {level} superado!",
|
|
35
|
+
"lives": "{n} vida|{n} vidas",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type StringKey = keyof typeof en;
|
|
39
|
+
const locales = { en, es } as const;
|
|
40
|
+
export type Locale = keyof typeof locales;
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## The `t()` function: interpolation + pluralization
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// src/i18n/i18n.ts
|
|
47
|
+
let current: Locale = "en";
|
|
48
|
+
export function setLocale(l: Locale) { current = l; EventCenter.emit(GameEvent.LocaleChanged); }
|
|
49
|
+
export function getLocale() { return current; }
|
|
50
|
+
|
|
51
|
+
export function t(key: StringKey, params?: Record<string, string | number>): string {
|
|
52
|
+
let s = (locales[current][key] ?? locales.en[key]) as string; // fall back to en, never crash
|
|
53
|
+
if (s.includes("|") && params && "n" in params) // pick plural form
|
|
54
|
+
s = (Number(params.n) === 1 ? s.split("|")[0] : s.split("|")[1]);
|
|
55
|
+
return s.replace(/\{(\w+)\}/g, (_, k) => String(params?.[k] ?? `{${k}}`));
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Usage is always `t(...)` with a typed key — never a raw string in a scene:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
this.add.bitmapText(x, y, Font.UI, t("hud.score", { score: 0 }));
|
|
63
|
+
this.announce(t("result.cleared", { level })); // [[phaser-accessibility]] live region
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The plural/interpolation rules are pure functions — put them in `src/logic/**`
|
|
67
|
+
so Vitest covers them ([[phaser-testing]]).
|
|
68
|
+
|
|
69
|
+
## Locale switching at runtime
|
|
70
|
+
|
|
71
|
+
Changing language must update text that is already on screen. Emit a
|
|
72
|
+
`LocaleChanged` event on the EventsCenter ([[phaser-services]]); each scene with
|
|
73
|
+
visible text subscribes and re-applies `t()` to its labels, then removes the
|
|
74
|
+
listener in `shutdown` (the on/off discipline).
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
create() {
|
|
78
|
+
const refresh = () => this.scoreText.setText(t("hud.score", { score: this.score }));
|
|
79
|
+
EventCenter.on(GameEvent.LocaleChanged, refresh);
|
|
80
|
+
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => EventCenter.off(GameEvent.LocaleChanged, refresh));
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Persist the chosen locale via SaveService and apply it on boot; default from
|
|
85
|
+
`navigator.language` when there is no saved choice.
|
|
86
|
+
|
|
87
|
+
## BitmapText vs Text — the localization trade-off
|
|
88
|
+
|
|
89
|
+
The performance advice "use BitmapText for hot text" ([[phaser-gameobjects]])
|
|
90
|
+
collides with i18n: a bitmap font only contains the glyphs it was generated with.
|
|
91
|
+
|
|
92
|
+
- **BitmapText** — fastest, but the BMFont must include every glyph the locale
|
|
93
|
+
needs. Fine for digits/Latin HUD; generate per-script font pages
|
|
94
|
+
([[phaser-asset-pipeline]]) if you ship CJK/Cyrillic/etc. via BitmapText.
|
|
95
|
+
- **Text** (canvas) — renders any glyph the loaded web font supports, handles
|
|
96
|
+
diacritics and combining marks, and is the safe choice for arbitrary
|
|
97
|
+
translated body text and user-generated content. Cost is rasterization per
|
|
98
|
+
change, so it is for static/low-churn strings.
|
|
99
|
+
|
|
100
|
+
Rule of thumb: BitmapText for high-churn numeric/short HUD with a covered glyph
|
|
101
|
+
set; Text for translated prose and any locale whose script the bitmap font does
|
|
102
|
+
not include. For RTL locales (Arabic/Hebrew), use canvas `Text` with
|
|
103
|
+
`rtl: true`/right alignment and lay out mirrored — BitmapText does not shape RTL.
|
|
104
|
+
|
|
105
|
+
## Project conventions
|
|
106
|
+
|
|
107
|
+
- Every player-facing string is a `t(StringKey, …)` call — no inline literals in
|
|
108
|
+
scenes/entities (this mirrors the no-raw-string-keys discipline).
|
|
109
|
+
- The default locale defines the key type; other locales must satisfy it
|
|
110
|
+
(compile error on a missing key).
|
|
111
|
+
- Interpolation/plural logic lives in `src/logic/**`; the catalog and `t()` live
|
|
112
|
+
in `src/i18n/**`.
|
|
113
|
+
- Locale persists via SaveService and is announced through the live region for
|
|
114
|
+
screen-reader users ([[phaser-accessibility]]).
|
|
115
|
+
|
|
116
|
+
## Verification
|
|
117
|
+
|
|
118
|
+
Verified by switching locale at runtime and confirming on-screen text updates
|
|
119
|
+
live (no reload), a missing key fails `bun run typecheck`, and pluralization unit
|
|
120
|
+
tests pass for n=0/1/many. For non-Latin locales, confirm glyphs render (no tofu
|
|
121
|
+
boxes) — that is the signal you need a per-script BMFont page or canvas Text.
|
|
122
|
+
</content>
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: phaser-services
|
|
3
|
+
description: This skill should be used when wiring cross-cutting services and shared state in a Phaser 4 game — the typed registry wrapper for global state, a single dedicated EventsCenter bus (never game.events), the .on()/.off() listener discipline enforced in scene shutdown, SoundService (mobile audio unlock on first gesture), InputService (semantic actions instead of raw keys), and SaveService (versioned localStorage with a migration chain). Use it when adding global state, an event bus, audio/input/save plumbing, or fixing listener leaks and lost saves. Pairs with phaser-scenes, phaser-testing, and phaser-i18n.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Phaser 4 Services and Shared State
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Cross-cutting concerns — global state, app events, sound, input, save — live in
|
|
11
|
+
`src/services/**` as thin, typed singletons, **not** scattered through scenes.
|
|
12
|
+
Two rules anchor everything: state flows through a typed registry wrapper, and
|
|
13
|
+
app messaging flows through **one dedicated EventsCenter** that is never
|
|
14
|
+
`game.events`. Direct `localStorage` access outside `src/services/**` is
|
|
15
|
+
lint-banned — persistence goes through SaveService.
|
|
16
|
+
|
|
17
|
+
## Global state: a typed wrapper over the registry
|
|
18
|
+
|
|
19
|
+
`this.registry` is a game-wide key/value `DataManager` shared across scenes. Raw
|
|
20
|
+
access is stringly-typed and untestable, so wrap it once:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// src/services/state.ts
|
|
24
|
+
interface GameState { coins: number; level: number; muted: boolean; seed: number; }
|
|
25
|
+
const DEFAULTS: GameState = { coins: 0, level: 1, muted: false, seed: 0 };
|
|
26
|
+
|
|
27
|
+
export class GameStore {
|
|
28
|
+
constructor(private reg: Phaser.Data.DataManager) {
|
|
29
|
+
for (const k in DEFAULTS) if (!reg.has(k)) reg.set(k, (DEFAULTS as any)[k]);
|
|
30
|
+
}
|
|
31
|
+
get<K extends keyof GameState>(k: K): GameState[K] { return this.reg.get(k); }
|
|
32
|
+
set<K extends keyof GameState>(k: K, v: GameState[K]) { this.reg.set(k, v); }
|
|
33
|
+
onChange<K extends keyof GameState>(k: K, fn: (v: GameState[K]) => void) {
|
|
34
|
+
const h = (_p: unknown, key: string, v: GameState[K]) => { if (key === k) fn(v); };
|
|
35
|
+
this.reg.events.on(Phaser.Data.Events.CHANGE_DATA, h);
|
|
36
|
+
return () => this.reg.events.off(Phaser.Data.Events.CHANGE_DATA, h); // caller .off()s in shutdown
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Construct it once (in Boot) and read it via the registry. Keys are typed by
|
|
42
|
+
`GameState`, so a typo is a compile error. The registry holds *small* shared
|
|
43
|
+
state (settings, run seed, currency) — not GameObjects and not per-frame data.
|
|
44
|
+
|
|
45
|
+
## The EventsCenter bus (never `game.events`)
|
|
46
|
+
|
|
47
|
+
App-level messaging ("enemy-died", "score-changed", "level-cleared") uses a
|
|
48
|
+
single dedicated emitter. Reusing `game.events` (the engine's own bus) is
|
|
49
|
+
lint-banned — it mixes your events with engine lifecycle events and is
|
|
50
|
+
impossible to fully tear down.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// src/services/event-center.ts
|
|
54
|
+
export const EventCenter = new Phaser.Events.EventEmitter();
|
|
55
|
+
|
|
56
|
+
// typed key constants come from src/assets.ts (generated) — no raw strings
|
|
57
|
+
import { GameEvent } from "../assets";
|
|
58
|
+
EventCenter.emit(GameEvent.ScoreChanged, score);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Listener discipline: every `.on()` has a matching `.off()`
|
|
62
|
+
|
|
63
|
+
This is the leak rule the `require-shutdown-cleanup` lint rule and the testing
|
|
64
|
+
leak gate ([[phaser-testing]]) enforce. **Any** external listener — `EventCenter`,
|
|
65
|
+
`this.input`, `this.scale`, `this.time`, `window`, the registry — must be removed
|
|
66
|
+
in the scene's `shutdown`. Listeners on the scene's own display objects die with
|
|
67
|
+
the scene; external ones do not, and they fire again (doubled) after a restart.
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
export class Game extends Phaser.Scene {
|
|
71
|
+
private cleanups: Array<() => void> = [];
|
|
72
|
+
|
|
73
|
+
create() {
|
|
74
|
+
const onResize = (s: Phaser.Structs.Size) => this.layout(s.width, s.height);
|
|
75
|
+
this.scale.on(Phaser.Scale.Events.RESIZE, onResize);
|
|
76
|
+
this.cleanups.push(() => this.scale.off(Phaser.Scale.Events.RESIZE, onResize));
|
|
77
|
+
|
|
78
|
+
const onScore = (n: number) => this.hud.setScore(n);
|
|
79
|
+
EventCenter.on(GameEvent.ScoreChanged, onScore);
|
|
80
|
+
this.cleanups.push(() => EventCenter.off(GameEvent.ScoreChanged, onScore));
|
|
81
|
+
|
|
82
|
+
this.events.once(Phaser.Scenes.Events.SHUTDOWN, this.shutdown, this);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
shutdown() { this.cleanups.forEach(fn => fn()); this.cleanups.length = 0; }
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Keeping the matching `.off()` next to each `.on()` (a `cleanups` array, or named
|
|
90
|
+
handler methods you remove in `shutdown`) is the pattern that survives the leak
|
|
91
|
+
gate. Prefer `.once()` where a listener should fire only once.
|
|
92
|
+
|
|
93
|
+
## SoundService — audio unlock on first gesture
|
|
94
|
+
|
|
95
|
+
Web Audio starts **suspended** until a user gesture; sound played before then is
|
|
96
|
+
silently dropped ("works on my machine, silent on the phone"). Centralize the
|
|
97
|
+
unlock and all playback:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
// src/services/sound-service.ts
|
|
101
|
+
export class SoundService {
|
|
102
|
+
private unlocked = false;
|
|
103
|
+
constructor(private sound: Phaser.Sound.BaseSoundManager, private store: GameStore) {}
|
|
104
|
+
|
|
105
|
+
attachUnlock(scene: Phaser.Scene) {
|
|
106
|
+
if (this.unlocked) return;
|
|
107
|
+
scene.input.once(Phaser.Input.Events.POINTER_DOWN, () => this.resume());
|
|
108
|
+
// Phaser also fires its own unlock; resume() is idempotent
|
|
109
|
+
this.sound.once(Phaser.Sound.Events.UNLOCKED, () => this.resume());
|
|
110
|
+
}
|
|
111
|
+
private resume() { if ((this.sound as any).context?.state === "suspended") (this.sound as any).context.resume(); this.unlocked = true; }
|
|
112
|
+
play(key: string, cfg?: Phaser.Types.Sound.SoundConfig) { if (!this.store.get("muted")) this.sound.play(key, cfg); }
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Scenes call `soundService.play(Sfx.Jump)` — never `this.sound.play("jump")`.
|
|
117
|
+
Sound keys are generated constants ([[phaser-asset-pipeline]]).
|
|
118
|
+
|
|
119
|
+
## InputService — semantic actions, not raw keys
|
|
120
|
+
|
|
121
|
+
Scenes should ask "is *jump* down?", not "is the spacebar down?". A semantic
|
|
122
|
+
layer makes rebinding, gamepads, and touch swap in without touching game code,
|
|
123
|
+
and keeps replays deterministic (record actions, not hardware).
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
// src/services/input-service.ts
|
|
127
|
+
export type Action = "left" | "right" | "jump" | "fire" | "pause";
|
|
128
|
+
|
|
129
|
+
export class InputService {
|
|
130
|
+
private down = new Set<Action>();
|
|
131
|
+
private bindings: Record<number, Action> = {};
|
|
132
|
+
constructor(kb: Phaser.Input.Keyboard.KeyboardPlugin) {
|
|
133
|
+
this.bindings[Phaser.Input.Keyboard.KeyCodes.LEFT] = "left";
|
|
134
|
+
this.bindings[Phaser.Input.Keyboard.KeyCodes.SPACE] = "jump";
|
|
135
|
+
kb.on("keydown", (e: KeyboardEvent) => { const a = this.bindings[e.keyCode]; if (a) this.down.add(a); });
|
|
136
|
+
kb.on("keyup", (e: KeyboardEvent) => { const a = this.bindings[e.keyCode]; if (a) this.down.delete(a); });
|
|
137
|
+
}
|
|
138
|
+
isDown(a: Action) { return this.down.has(a); }
|
|
139
|
+
snapshot(): readonly Action[] { return [...this.down]; } // for replay capture
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`update()` reads `input.isDown("jump")` and forwards the action set to pure logic
|
|
144
|
+
in `src/logic/**`. (Register/remove the keyboard listeners with the same on/off
|
|
145
|
+
discipline above.)
|
|
146
|
+
|
|
147
|
+
## SaveService — versioned localStorage with a migration chain
|
|
148
|
+
|
|
149
|
+
Raw `localStorage` outside `src/services/**` is lint-banned. Saves carry a schema
|
|
150
|
+
`VERSION`; on load, unknown-but-older saves run forward through a migration chain,
|
|
151
|
+
so an old player's data is never silently lost or crash-loaded.
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
// src/services/save-service.ts
|
|
155
|
+
const KEY = "save:v"; const VERSION = 3;
|
|
156
|
+
interface SaveV3 { version: 3; coins: number; unlocked: string[]; settings: { muted: boolean } }
|
|
157
|
+
|
|
158
|
+
const migrations: Record<number, (s: any) => any> = {
|
|
159
|
+
1: s => ({ ...s, unlocked: [], version: 2 }), // v1 -> v2
|
|
160
|
+
2: s => ({ ...s, settings: { muted: false }, version: 3 }), // v2 -> v3
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export class SaveService {
|
|
164
|
+
load(): SaveV3 {
|
|
165
|
+
const raw = localStorage.getItem(KEY); // only legal localStorage access lives here
|
|
166
|
+
if (!raw) return this.fresh();
|
|
167
|
+
try {
|
|
168
|
+
let s = JSON.parse(raw);
|
|
169
|
+
while (s.version < VERSION) s = migrations[s.version](s);
|
|
170
|
+
return s as SaveV3;
|
|
171
|
+
} catch { return this.fresh(); } // corrupt save -> fresh, never crash
|
|
172
|
+
}
|
|
173
|
+
save(s: SaveV3) { localStorage.setItem(KEY, JSON.stringify({ ...s, version: VERSION })); }
|
|
174
|
+
private fresh(): SaveV3 { return { version: VERSION, coins: 0, unlocked: [], settings: { muted: false } }; }
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Bump `VERSION` and add the `n -> n+1` migration whenever the shape changes; never
|
|
179
|
+
mutate the read path to "just handle" an old shape inline.
|
|
180
|
+
|
|
181
|
+
## Errors and telemetry
|
|
182
|
+
|
|
183
|
+
A vendor-neutral telemetry/analytics abstraction (`src/services/telemetry.ts`)
|
|
184
|
+
exposes `track(event, props)` and `captureError(err, ctx)` behind a stable
|
|
185
|
+
interface so the concrete sink (PostHog, Sentry, none) swaps without touching
|
|
186
|
+
game code. Wire Phaser's error surfaces to it — `window.onerror`, the loader's
|
|
187
|
+
`FILE_LOAD_ERROR`, and `try/catch` around scene transitions all route to
|
|
188
|
+
`captureError`. Strings shown to players come from the i18n catalog
|
|
189
|
+
([[phaser-i18n]]), never hardcoded.
|
|
190
|
+
|
|
191
|
+
## Project conventions
|
|
192
|
+
|
|
193
|
+
- Services live in `src/services/**`; that directory is the **only** place raw
|
|
194
|
+
`localStorage` is permitted.
|
|
195
|
+
- One EventsCenter instance, exported once; never `game.events` for app events.
|
|
196
|
+
- Every external `.on()` is paired with an `.off()` removed in `shutdown` (lint:
|
|
197
|
+
`require-shutdown-cleanup`; verified by the leak gate in [[phaser-testing]]).
|
|
198
|
+
- Service logic that is pure (migrations, action mapping, mute gating) belongs in
|
|
199
|
+
or beside `src/logic/**` so Vitest covers it.
|
|
200
|
+
|
|
201
|
+
## Verification
|
|
202
|
+
|
|
203
|
+
Services are verified by the runtime gates: the leak gate ([[phaser-testing]])
|
|
204
|
+
proves listeners are removed (start/stop a scene N times, counts return to
|
|
205
|
+
baseline); a SaveService unit test feeds each old version through the migration
|
|
206
|
+
chain and asserts the current shape; and on a real phone, the first tap unlocks
|
|
207
|
+
audio (sound plays) — confirm before committing.
|
|
208
|
+
</content>
|