Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
직접 명령은 검토 Prompt를 거치지 않습니다. 실행하기 전에 소스를 확인하세요.
npx skills add https://github.com/CodySwannGT/lisa --skill phaser-services명령은 한 줄로 유지됩니다. 복사하기 전에 가로로 스크롤해 전체 내용을 확인하세요.
로컬 사본을 원하시나요? SkillsMP에서 현재 제공할 수 있는 파일을 다운로드하세요.
This skill should be used for any non-trivial request — features, bugs, stories, epics, spikes, or multi-step tasks. It accepts a ticket URL (Jira, Linear, GitHub), a file path containing a spec, or a plain-text prompt. It assembles an agent team, breaks the work into structured tasks, and manages the full lifecycle from research through implementation, code review, deploy, and empirical verification.
any non-trivial request —…
This skill should be used for any non-trivial request — features, bugs, stories, epics, spikes, or multi-step tasks. It accepts a ticket URL (Jira, Linear, GitHub), a file path containing a spec, or a plain-text prompt. It assembles an agent team, breaks the work into structured tasks, and manages the full lifecycle from research through implementation, code review, deploy, and empirical verification.
SOC 직업 분류 기준
SKILL.md 표시 중
| name | phaser-services |
| description | wiring cross-cutting services… |
Cross-cutting concerns — global state, app events, sound, input, save — live in
src/services/** as thin, typed singletons, not scattered through scenes.
Two rules anchor everything: state flows through a typed registry wrapper, and
app messaging flows through one dedicated EventsCenter that is never
game.events. Direct localStorage access outside src/services/** is
lint-banned — persistence goes through SaveService.
this.registry is a game-wide key/value DataManager shared across scenes. Raw
access is stringly-typed and untestable, so wrap it once:
// src/services/state.ts
interface GameState { coins: number; level: number; muted: boolean; seed: number; }
const DEFAULTS: GameState = { coins: 0, level: 1, muted: false, seed: 0 };
export class GameStore {
constructor(private reg: Phaser.Data.DataManager) {
for (const k in DEFAULTS) if (!reg.has(k)) reg.set(k, (DEFAULTS as any)[k]);
}
get<K extends keyof GameState>(k: K): GameState[K] { return this.reg.get(k); }
set<K extends keyof GameState>(k: K, v: GameState[K]) { this.reg.set(k, v); }
onChange<K extends keyof GameState>(k: K, fn: (v: GameState[K]) => void) {
const h = (_p: unknown, key: string, v: GameState[K]) => { if (key === k) fn(v); };
this.reg.events.on(Phaser.Data.Events.CHANGE_DATA, h);
return () => this.reg.events.off(Phaser.Data.Events.CHANGE_DATA, h); // caller .off()s in shutdown
}
}
Construct it once (in Boot) and read it via the registry. Keys are typed by
GameState, so a typo is a compile error. The registry holds small shared
state (settings, run seed, currency) — not GameObjects and not per-frame data.
game.events)App-level messaging ("enemy-died", "score-changed", "level-cleared") uses a
single dedicated emitter. Reusing game.events (the engine's own bus) is
lint-banned — it mixes your events with engine lifecycle events and is
impossible to fully tear down.
// src/services/event-center.ts
export const EventCenter = new Phaser.Events.EventEmitter();
// typed key constants come from src/assets.ts (generated) — no raw strings
import { GameEvent } from "../assets";
EventCenter.emit(GameEvent.ScoreChanged, score);
.on() has a matching .off()This is the leak rule the require-shutdown-cleanup lint rule and the testing
leak gate ([[phaser-testing]]) enforce. Any external listener — EventCenter,
this.input, this.scale, this.time, window, the registry — must be removed
in the scene's shutdown. Listeners on the scene's own display objects die with
the scene; external ones do not, and they fire again (doubled) after a restart.
export class Game extends Phaser.Scene {
private cleanups: Array<() => void> = [];
create() {
const onResize = (s: Phaser.Structs.Size) => this.layout(s.width, s.height);
this.scale.on(Phaser.Scale.Events.RESIZE, onResize);
this.cleanups.push(() => this.scale.off(Phaser.Scale.Events.RESIZE, onResize));
const onScore = (n: number) => this.hud.setScore(n);
EventCenter.on(GameEvent.ScoreChanged, onScore);
this..( .(., onScore));
..(..., ., );
}
() { ..( ()); .. = ; }
}
Keeping the matching .off() next to each .on() (a cleanups array, or named
handler methods you remove in shutdown) is the pattern that survives the leak
gate. Prefer .once() where a listener should fire only once.
Web Audio starts suspended until a user gesture; sound played before then is silently dropped ("works on my machine, silent on the phone"). Centralize the unlock and all playback:
// src/services/sound-service.ts
export class SoundService {
private unlocked = false;
constructor(private sound: Phaser.Sound.BaseSoundManager, private store: GameStore) {}
attachUnlock(scene: Phaser.Scene) {
if (this.unlocked) return;
scene.input.once(Phaser.Input.Events.POINTER_DOWN, () => this.resume());
// Phaser also fires its own unlock; resume() is idempotent
this.sound.once(Phaser.Sound.Events.UNLOCKED, () => this.resume());
}
private resume() { if ((this.sound as any).?. === ) (. )..(); . = ; }
() { (!..()) ..(key, cfg); }
}
Scenes call soundService.play(Sfx.Jump) — never this.sound.play("jump").
Sound keys are generated constants ([[phaser-asset-pipeline]]).
Scenes should ask "is jump down?", not "is the spacebar down?". A semantic layer makes rebinding, gamepads, and touch swap in without touching game code, and keeps replays deterministic (record actions, not hardware).
// src/services/input-service.ts
export type Action = "left" | "right" | "jump" | "fire" | "pause";
export class InputService {
private down = new Set<Action>();
private bindings: Record<number, Action> = {};
constructor(kb: Phaser.Input.Keyboard.KeyboardPlugin) {
this.bindings[Phaser.Input.Keyboard.KeyCodes.LEFT] = "left";
this.bindings[Phaser.Input.Keyboard.KeyCodes.SPACE] = "jump";
kb.on("keydown", (e: KeyboardEvent) => { const a = this.bindings[e.keyCode]; (a) ..(a); });
kb.(, { a = .[e.]; (a) ..(a); });
}
() { ..(a); }
(): [] { [....]; }
}
update() reads input.isDown("jump") and forwards the action set to pure logic
in src/logic/**. (Register/remove the keyboard listeners with the same on/off
discipline above.)
Raw localStorage outside src/services/** is lint-banned. Saves carry a schema
VERSION; on load, unknown-but-older saves run forward through a migration chain,
so an old player's data is never silently lost or crash-loaded.
// src/services/save-service.ts
const KEY = "save:v"; const VERSION = 3;
interface SaveV3 { version: 3; coins: number; unlocked: string[]; settings: { muted: boolean } }
const migrations: Record<number, (s: any) => any> = {
1: s => ({ ...s, unlocked: [], version: 2 }), // v1 -> v2
2: s => ({ ...s, settings: { muted: false }, version: 3 }), // v2 -> v3
};
export class SaveService {
load(): SaveV3 {
const raw = localStorage.getItem(KEY); // only legal localStorage access lives here
if (!raw) .();
{
s = .(raw);
(s. < ) s = migrations[s.](s);
s ;
} { .(); }
}
() { .(, .({ ...s, : })); }
(): { { : , : , : [], : { : } }; }
}
Bump VERSION and add the n -> n+1 migration whenever the shape changes; never
mutate the read path to "just handle" an old shape inline.
A vendor-neutral telemetry/analytics abstraction (src/services/telemetry.ts)
exposes track(event, props) and captureError(err, ctx) behind a stable
interface so the concrete sink (PostHog, Sentry, none) swaps without touching
game code. Wire Phaser's error surfaces to it — window.onerror, the loader's
FILE_LOAD_ERROR, and try/catch around scene transitions all route to
captureError. Strings shown to players come from the i18n catalog
([[phaser-i18n]]), never hardcoded.
src/services/**; that directory is the only place raw
localStorage is permitted.game.events for app events..on() is paired with an .off() removed in shutdown (lint:
require-shutdown-cleanup; verified by the leak gate in [[phaser-testing]]).src/logic/** so Vitest covers it.Services are verified by the runtime gates: the leak gate ([[phaser-testing]]) proves listeners are removed (start/stop a scene N times, counts return to baseline); a SaveService unit test feeds each old version through the migration chain and asserts the current shape; and on a real phone, the first tap unlocks audio (sound plays) — confirm before committing.