@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,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,4 @@
|
|
|
1
|
+
display_name: "Phaser I18n"
|
|
2
|
+
short_description: "localizing a Phaser 4 game — a typed string catalog so no user-facing text is hardcoded, runtime locale switching that re-renders open…"
|
|
3
|
+
default_prompt:
|
|
4
|
+
- "Use $phaser-i18n: localizing a Phaser 4 game — a typed string catalog so no user-facing text is hardcoded, runtime locale switching that re-renders open…."
|
|
@@ -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>
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
display_name: "Phaser Services"
|
|
2
|
+
short_description: "wiring cross-cutting services and shared state in a Phaser 4 game — the typed registry wrapper for global state, a single dedicated…"
|
|
3
|
+
default_prompt:
|
|
4
|
+
- "Use $phaser-services: wiring cross-cutting services and shared state in a Phaser 4 game — the typed registry wrapper for global state, a single dedicated…."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: phaser-testing
|
|
3
|
-
description: This skill should be used when writing or designing tests for a Phaser 4 game — unit-testing pure game logic with Vitest, keeping logic Phaser-free so it tests without a browser, the Phaser.HEADLESS renderer for logic-only boots, asset-manifest coverage tests, and
|
|
3
|
+
description: This skill should be used when writing or designing tests for a Phaser 4 game — unit-testing pure game logic with Vitest, keeping logic Phaser-free so it tests without a browser, the Phaser.HEADLESS renderer for logic-only boots, asset-manifest coverage tests, and the CI runtime gates (boot smoke, allocation/perf budget, leak gate, determinism gate, deterministic Playwright visual regression, bundle-size budget) that verify scenes and entities that are excluded from unit coverage. Use it when adding tests, setting up CI verification, or deciding what is testable at which level. Pairs with phaser-project-structure, phaser-assets, and phaser-services.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Phaser 4 Testing
|
|
@@ -13,10 +13,17 @@ pyramid, bottom-up:
|
|
|
13
13
|
1. **Vitest unit tests** over `src/logic/**` — the bulk of coverage.
|
|
14
14
|
2. **Manifest/contract tests** — cheap structural checks (asset packs, scene
|
|
15
15
|
keys, anim definitions).
|
|
16
|
-
3. **
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
3. **Headless integration** (sparingly) — `Phaser.HEADLESS` for things that
|
|
17
|
+
genuinely need the engine loop.
|
|
18
|
+
4. **Playwright smoke** — the game boots in a real browser, renders past the
|
|
19
|
+
Preloader, no console errors.
|
|
20
|
+
5. **CI runtime gates** — scenes/entities are excluded from unit coverage and
|
|
21
|
+
instead verified by deterministic runtime gates: boot smoke, allocation/perf
|
|
22
|
+
budget, leak gate, determinism gate, visual regression, bundle-size budget.
|
|
23
|
+
|
|
24
|
+
There is no official Phaser testing harness — this layering IS the strategy. The
|
|
25
|
+
dividing line: pure logic is unit-tested; everything that needs the engine is a
|
|
26
|
+
runtime gate, never a brittle unit test that mocks the world.
|
|
20
27
|
|
|
21
28
|
## Layer 1: pure logic under Vitest (the rule that makes it possible)
|
|
22
29
|
|
|
@@ -85,15 +92,101 @@ Convention: scenes set `window.__sceneReady = key` in `create()` (dev/test
|
|
|
85
92
|
builds) so tests await real readiness instead of sleeping. Run against
|
|
86
93
|
`bun run dev` (or `vite preview` in CI).
|
|
87
94
|
|
|
95
|
+
## Layer 5: CI runtime gates (how scenes get verified)
|
|
96
|
+
|
|
97
|
+
Scenes and entities are deliberately **excluded from unit coverage** — mocking
|
|
98
|
+
the engine to "unit test" a scene tests the mock. Instead, CI runs a set of
|
|
99
|
+
deterministic gates against a real (or headless) game. Each gate fails the build
|
|
100
|
+
on regression; together they are the contract that the engine-coupled layer
|
|
101
|
+
keeps working.
|
|
102
|
+
|
|
103
|
+
**Boot smoke** — the Playwright test above, hardened: page error listener, wait
|
|
104
|
+
for `__sceneReady`, assert zero console errors. This is the floor.
|
|
105
|
+
|
|
106
|
+
**Allocation / perf budget** — run the game for N frames and assert the frame
|
|
107
|
+
budget and heap growth. Per-frame allocation is what the `no-allocation-in-update`
|
|
108
|
+
and `no-create-in-update` lint rules forbid statically; this gate catches what
|
|
109
|
+
slips through dynamically.
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
test("steady-state frames stay within budget", async ({ page }) => {
|
|
113
|
+
await page.goto("/"); await bootTo(page, "Game");
|
|
114
|
+
const stats = await page.evaluate(async () => {
|
|
115
|
+
const g = (window as any).__game as Phaser.Game;
|
|
116
|
+
const frames: number[] = []; let last = performance.now();
|
|
117
|
+
for (let i = 0; i < 600; i++) { await new Promise(r => g.events.once("postrender", r)); const n = performance.now(); frames.push(n - last); last = n; }
|
|
118
|
+
return { p95: frames.sort((a,b)=>a-b)[Math.floor(frames.length*0.95)], heap: (performance as any).memory?.usedJSHeapSize };
|
|
119
|
+
});
|
|
120
|
+
expect(stats.p95).toBeLessThan(20); // ~50fps floor on CI hardware
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Leak gate** — start and stop a scene N times and assert that texture count,
|
|
125
|
+
event-listener count, active tweens, and timers all return to baseline. This is
|
|
126
|
+
the runtime enforcement of the `require-shutdown-cleanup` rule and the
|
|
127
|
+
on/off-discipline in [[phaser-services]].
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
test("scene start/stop leaves no leaks", async ({ page }) => {
|
|
131
|
+
await page.goto("/"); await bootTo(page, "MainMenu");
|
|
132
|
+
const before = await page.evaluate(() => (window as any).__game.textures.getTextureKeys().length);
|
|
133
|
+
for (let i = 0; i < 20; i++) await page.evaluate(async () => {
|
|
134
|
+
const g = (window as any).__game as Phaser.Game; g.scene.start("Game");
|
|
135
|
+
await new Promise(r => setTimeout(r, 50)); g.scene.stop("Game");
|
|
136
|
+
await new Promise(r => setTimeout(r, 50));
|
|
137
|
+
});
|
|
138
|
+
const after = await page.evaluate(() => (window as any).__game.textures.getTextureKeys().length);
|
|
139
|
+
expect(after).toBeLessThanOrEqual(before); // no per-cycle texture growth
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**Determinism gate** — boot twice with the same seed, drive the same inputs, and
|
|
144
|
+
assert an identical state hash. Logic exposes a serializable snapshot; the gate
|
|
145
|
+
hashes it. Same seed → same hash, always. This is the runtime backstop for the
|
|
146
|
+
no-`Math.random()`/`Date.now()`/`performance.now()` rules ([[phaser-services]]).
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
const run = (seed: number) => page.evaluate(s => (window as any).__sim(s, REPLAY), seed);
|
|
150
|
+
expect(hash(await run(1234))).toBe(hash(await run(1234)));
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**Deterministic visual regression** — `toHaveScreenshot` under **software GL**
|
|
154
|
+
(`--use-gl=swiftshader`), a **frozen frame** (pause the loop / step a fixed
|
|
155
|
+
number of ticks at a fixed delta), and **masked dynamic regions** (timers,
|
|
156
|
+
particles). Without all three, screenshots flake. Pin Playwright's browser and
|
|
157
|
+
OS in CI so the baseline is stable.
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
await page.evaluate(() => { const g=(window as any).__game; g.loop.sleep(); g.step(0,16.6); });
|
|
161
|
+
await expect(page).toHaveScreenshot("game.png", { mask: [page.locator("#timer")], maxDiffPixelRatio: 0.01 });
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Bundle-size budget** — assert the built bundle stays under a byte budget so a
|
|
165
|
+
stray dependency or an un-split `phaser` chunk fails the PR. Wire it to the prod
|
|
166
|
+
build's `manualChunks` split ([[phaser-build-deploy]]).
|
|
167
|
+
|
|
168
|
+
```jsonc
|
|
169
|
+
// size-limit / custom check after `bun run build`
|
|
170
|
+
[{ "path": "dist/assets/index-*.js", "limit": "60 kB" },
|
|
171
|
+
{ "path": "dist/assets/phaser-*.js", "limit": "400 kB" }]
|
|
172
|
+
```
|
|
173
|
+
|
|
88
174
|
## Project conventions
|
|
89
175
|
|
|
90
|
-
- `bun run test` = Vitest (layers 1–2, coverage-gated
|
|
91
|
-
|
|
176
|
+
- `bun run test` = Vitest (layers 1–2, coverage-gated **over `src/logic/**`
|
|
177
|
+
only** — scenes/entities are out of scope here). The layer-5 gates run as their
|
|
178
|
+
own CI jobs against a built `vite preview` (and a headless boot for the
|
|
179
|
+
determinism gate).
|
|
180
|
+
- Build pins are part of the contract: `phaser ^4.2.0`, Vite, TypeScript 6.
|
|
92
181
|
- Tests never assert on private scene fields; they assert on logic outputs
|
|
93
|
-
(layer 1)
|
|
182
|
+
(layer 1), observable behavior (layer 4), or the runtime invariants the gates
|
|
183
|
+
measure (layer 5).
|
|
94
184
|
|
|
95
185
|
## Verification
|
|
96
186
|
|
|
97
187
|
The testing setup itself is verified when `bun run test` passes with coverage
|
|
98
|
-
over `src/logic/**`,
|
|
99
|
-
|
|
188
|
+
over `src/logic/**`, the Playwright smoke fails when you deliberately break boot
|
|
189
|
+
(rename a pack file), and each layer-5 gate fails on its targeted regression —
|
|
190
|
+
leak gate when a listener loses its `.off()`, determinism gate when a
|
|
191
|
+
`Math.random()` sneaks in, visual gate when the frame changes. A gate that
|
|
192
|
+
can't fail is decoration.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: phaser-accessibility
|
|
3
|
+
description: This skill should be used when making a Phaser 4 game accessible — honoring prefers-reduced-motion, pausing on window/tab blur, keyboard navigation and visible focus for menus, colorblind-safe palettes, and a screen-reader live region that announces game state through the DOM. Use it when building menus, settings, HUD, or any player-facing UI, or when an accessibility requirement comes up. Pairs with phaser-services, phaser-i18n, and phaser-scenes.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Phaser 4 Accessibility
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
The canvas is opaque to assistive tech, so accessibility is something you build
|
|
11
|
+
in, not get for free. Five pillars carry most of the value: respect
|
|
12
|
+
`prefers-reduced-motion`, pause when the window loses focus, make menus fully
|
|
13
|
+
keyboard-navigable with visible focus, use colorblind-safe palettes, and mirror
|
|
14
|
+
game state into a DOM **live region** for screen readers. These wire through the
|
|
15
|
+
shared services ([[phaser-services]]) and use translated strings ([[phaser-i18n]]).
|
|
16
|
+
|
|
17
|
+
## prefers-reduced-motion
|
|
18
|
+
|
|
19
|
+
Read the media query once and gate non-essential motion (screen shake, parallax,
|
|
20
|
+
big tweens, particle bursts) behind it. Reduced motion means "convey the same
|
|
21
|
+
information with less movement", not "remove feedback".
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// src/services/a11y.ts
|
|
25
|
+
export const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
26
|
+
|
|
27
|
+
// in a scene
|
|
28
|
+
if (!reduceMotion) this.cameras.main.shake(120, 0.004);
|
|
29
|
+
this.tweens.add({ targets: panel, alpha: 1, duration: reduceMotion ? 0 : 250 });
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Expose it as a togglable setting too (some players want it on regardless of OS),
|
|
33
|
+
stored via SaveService ([[phaser-services]]).
|
|
34
|
+
|
|
35
|
+
## Pause on blur
|
|
36
|
+
|
|
37
|
+
A game that keeps running while the tab is hidden drains battery, desyncs audio,
|
|
38
|
+
and is hostile to players who switch away. Phaser already exposes blur/focus
|
|
39
|
+
events — pause the loop and audio, resume on focus.
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
this.game.events.on(Phaser.Core.Events.BLUR, () => { this.scene.pause(); this.sound.pauseAll(); });
|
|
43
|
+
this.game.events.on(Phaser.Core.Events.FOCUS, () => { this.scene.resume(); this.sound.resumeAll(); });
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Set `Phaser.Types.Core.GameConfig.autoFocus` and `disableContextMenu` as needed.
|
|
47
|
+
(These are listeners — remove them in `shutdown` per the on/off discipline in
|
|
48
|
+
[[phaser-services]].) For competitive/timed play, pausing on blur is also the
|
|
49
|
+
fair behavior.
|
|
50
|
+
|
|
51
|
+
## Keyboard navigation and visible focus
|
|
52
|
+
|
|
53
|
+
Every menu must be operable without a pointer: arrow keys / Tab move focus, Enter
|
|
54
|
+
or Space activates, Escape backs out. Keep a focus index over a typed list of
|
|
55
|
+
items and render an unmistakable focus indicator (outline/scale, not color alone).
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
private items: MenuItem[] = [];
|
|
59
|
+
private focus = 0;
|
|
60
|
+
|
|
61
|
+
private move(delta: number) {
|
|
62
|
+
this.items[this.focus].setFocused(false);
|
|
63
|
+
this.focus = Phaser.Math.Wrap(this.focus + delta, 0, this.items.length);
|
|
64
|
+
this.items[this.focus].setFocused(true); // visible outline + announce
|
|
65
|
+
this.announce(this.items[this.focus].label);
|
|
66
|
+
}
|
|
67
|
+
create() {
|
|
68
|
+
this.input.keyboard!.on("keydown-DOWN", () => this.move(1));
|
|
69
|
+
this.input.keyboard!.on("keydown-UP", () => this.move(-1));
|
|
70
|
+
this.input.keyboard!.on("keydown-ENTER",() => this.items[this.focus].activate());
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Use the semantic InputService actions ([[phaser-services]]) so the same bindings
|
|
75
|
+
serve gamepad and touch. Never rely on hover-only affordances.
|
|
76
|
+
|
|
77
|
+
## Colorblind-safe palettes
|
|
78
|
+
|
|
79
|
+
Never encode meaning in hue alone — pair every color cue with a shape, icon,
|
|
80
|
+
label, or pattern (red enemy *and* a spike icon; green/red status *and* a check/x).
|
|
81
|
+
Choose palettes that stay distinguishable across deuteranopia/protanopia
|
|
82
|
+
/tritanopia, and offer a colorblind palette option in settings.
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
// src/logic/palette.ts — pure, testable, no phaser import
|
|
86
|
+
export const palettes = {
|
|
87
|
+
default: { ally: 0x2e7d32, enemy: 0xc62828, neutral: 0xf9a825 },
|
|
88
|
+
deuter: { ally: 0x0072b2, enemy: 0xd55e00, neutral: 0xf0e442 }, // Okabe–Ito, CB-safe
|
|
89
|
+
} as const;
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Keep palette selection in `src/logic/**` so contrast/choice logic is unit-tested,
|
|
93
|
+
and store the player's choice via SaveService.
|
|
94
|
+
|
|
95
|
+
## Screen-reader live region
|
|
96
|
+
|
|
97
|
+
Mirror important state changes into an ARIA live region in the DOM (outside the
|
|
98
|
+
canvas) so screen readers announce them. One polite region for status, one
|
|
99
|
+
assertive for urgent events.
|
|
100
|
+
|
|
101
|
+
```html
|
|
102
|
+
<!-- index.html, alongside the game container -->
|
|
103
|
+
<div id="sr-status" aria-live="polite" class="sr-only"></div>
|
|
104
|
+
<div id="sr-alert" aria-live="assertive" class="sr-only"></div>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// src/services/a11y.ts
|
|
109
|
+
const status = document.getElementById("sr-status")!;
|
|
110
|
+
export function announce(msg: string, urgent = false) {
|
|
111
|
+
const el = urgent ? document.getElementById("sr-alert")! : status;
|
|
112
|
+
el.textContent = ""; el.textContent = msg; // clear+set so identical repeats re-announce
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Announce menu focus, score milestones, level changes, and game-over. Messages
|
|
117
|
+
come from the i18n catalog ([[phaser-i18n]]) — never hardcoded — and `.sr-only`
|
|
118
|
+
keeps the region visually hidden but readable by AT.
|
|
119
|
+
|
|
120
|
+
## Project conventions
|
|
121
|
+
|
|
122
|
+
- A11y plumbing lives in `src/services/a11y.ts`; palette/choice logic lives in
|
|
123
|
+
`src/logic/**` (unit-tested).
|
|
124
|
+
- Accessibility settings (reduced motion, colorblind palette) persist via
|
|
125
|
+
SaveService and apply on boot ([[phaser-services]]).
|
|
126
|
+
- All announced/visible text comes from the i18n catalog ([[phaser-i18n]]).
|
|
127
|
+
|
|
128
|
+
## Verification
|
|
129
|
+
|
|
130
|
+
Verified by manual passes that double as runtime checks: tab through every menu
|
|
131
|
+
with the pointer unplugged and confirm visible focus + activation; set the OS to
|
|
132
|
+
reduce-motion and confirm shake/parallax stop while feedback remains; blur the
|
|
133
|
+
tab and confirm the game pauses and audio stops; and run a screen reader (VoiceOver
|
|
134
|
+
/NVDA) to confirm the live region announces menu focus and game state.
|
|
135
|
+
</content>
|