phaser-hooks 0.4.0 → 0.6.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/README.md CHANGED
@@ -1,65 +1,64 @@
1
- <p align="center">
1
+ <p align="center" style="margin: 0 auto;">
2
2
  <img src="data/image.png" alt="logo" style="max-width: 300px">
3
3
  </p>
4
4
 
5
- [![NPM Version](https://img.shields.io/npm/v/phaser-wind)](https://www.npmjs.com/package/phaser-wind)
5
+ [![NPM Version](https://img.shields.io/npm/v/phaser-hooks)](https://www.npmjs.com/package/phaser-hooks)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
7
  [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org/)
8
8
 
9
- # Phaser Hooks (like "use" hooks in React)
9
+ # Phaser Hooks
10
10
 
11
- A comprehensive state management library for Phaser games with React-like hooks pattern.
11
+ React-like state management for Phaser 3 games. Simple, type-safe, and powerful.
12
12
 
13
13
  ## Why phaser-hooks?
14
14
 
15
- Phaser already gives you two ways of storing state:
16
-
17
- - `registry` → global state across the game
18
- - `data` → local state inside a scene or game object
19
-
20
- They work, but the API is a bit… verbose:
15
+ Phaser gives you `registry` (global) and `data` (local) for state management. They work fine, but the API is verbose and error-prone:
21
16
 
22
17
  ```ts
23
- // Using registry (global)
24
- this.game.registry.set('volume', 0.5); // too boring
18
+ // Phaser's built-in way - this == scene
19
+ this.game.registry.set('volume', 0.5);
25
20
  const volume = this.game.registry.get('volume');
21
+
26
22
  this.game.registry.events.on('changedata-volume', (game, value) => {
27
23
  console.log('Volume changed to', value);
28
24
  });
29
25
 
30
- // Using scene.data (local)
31
- this.data.set('score', 42); // too boring too
26
+ this.data.set('score', 42);
32
27
  const score = this.data.get('score');
33
28
 
34
- const onChangeFn = (scene, value) => {
29
+ this.onChangeFn = (scene, value) => {
35
30
  console.log('Score updated to', value);
36
31
  };
37
- this.data.events.on('changedata-score', onChangeFn); // If you pass an anonymous function, you cannot unsubscribe :(
32
+ this.data.events.on('changedata-score', this.onChangeFn); // if you pass an anonymous function, you cannot unsubscribe
38
33
 
39
34
  // when move to another scene, you must unsubscribe. Boring and easy to forget
40
- this.data.events.off('changeset-score', onChangeFn);
35
+ this.data.events.off('changedata-score', this.onChangeFn);
41
36
  ```
42
37
 
43
- With _phaser-hooks_, you get a simple, React-like API:
38
+ **With _phaser-hooks_, you get a simple, React-like API:**
44
39
 
45
40
  ```ts
46
- // Global state
47
- const volume = withGlobalState(scene, 'volume', 0.5); // woow! awesome
48
- volume.get(); // 0.5
49
- volume.set(0.8); // updates value
41
+ const volume = withGlobalState(this, 'volume', 0.5);
42
+ volume.get(); // Returns: 0.5
43
+ volume.set(0.8); // updates the value
50
44
 
51
- const unsubscribe = volume.on('change', v =>
52
- console.log('Volume changed →', v)
53
- ); // Nice callback in event <3 - Return the easy unsubscribe function
45
+ this.unsubscribe = volume.on('change', () => {
46
+ console.log('Volume changed →', volume.get())
47
+ }); // Returns the easy unsubscribe function
54
48
 
55
- // when move to another scene, just call :)
56
- unsubscribe();
57
-
58
- // Persisted state (localStorage / sessionStorage)
59
- const score = withPersistState(scene, 'score', 0, { storage: 'local' }); // Wow! Saving in localStorage
60
- score.set(100); // Update localStorage!! Wow! I love this lib <3
49
+ // when changing scenes
50
+ this.unsubscribe();
61
51
  ```
62
52
 
53
+ ### Key Benefits
54
+
55
+ - ✅ **React-like patterns** - Hooks work just like React: same key = same state
56
+ - ✅ **Type-safe** - Full TypeScript support with inference
57
+ - ✅ **Memory safe** - Auto-cleanup prevents memory leaks
58
+ - ✅ **Feature-rich** - Persistence, computed state, undo/redo, validation
59
+ - ✅ **Familiar** - React-like patterns for easier onboarding
60
+
61
+
63
62
  ## Installation
64
63
 
65
64
  ```bash
@@ -70,651 +69,231 @@ pnpm add phaser-hooks
70
69
  yarn add phaser-hooks
71
70
  ```
72
71
 
73
- ## Why "with" instead of "use"?
74
-
75
- While React hooks traditionally use the "use" prefix (e.g., useState, useEffect), this library intentionally uses "with" to avoid linting issues. Many linting configurations, including ESLint's built-in hooks rules, expect functions starting with "use" to be used only within React components and in .jsx/.tsx files.
76
-
77
- Since this library is designed to work with Phaser games, which typically use plain TypeScript/JavaScript files (.ts/.js), using the "with" prefix helps avoid false positives from linters while maintaining a clear and consistent naming convention that indicates the hook-like pattern these functions follow.
78
-
79
- This approach allows you to use these state management utilities in your Phaser games without having to modify your linting configuration or suppress warnings.
80
-
81
- ## Hook API Reference
82
-
83
- All hooks return a `HookState` object with the following methods:
84
-
85
- | Method | Description | Parameters | Returns |
86
- | -------------------------- | ---------------------------------------------------- | ----------------------------------------- | ----------------------------------- |
87
- | `get()` | Gets the current state value | None | `T` - Current state value |
88
- | `set(value)` | Sets a new state value and triggers change listeners | `value: T` - New value to set | `void` |
89
- | `on('change', callback)` | Registers a callback for state changes | `event: 'change'`, `callback: () => void` | `() => void` - Unsubscribe function |
90
- | `once('change', callback)` | Registers a callback that fires only once | `event: 'change'`, `callback: () => void` | `() => void` - Unsubscribe function |
91
- | `off('change', callback)` | Removes an event listener | `event: 'change'`, `callback: () => void` | `void` |
92
- | `clearListeners()` | Removes all event listeners for this state | None | `void` |
93
-
94
- ### Special Hook Methods
95
-
96
- Some hooks have additional methods beyond the standard `HookState` interface:
97
-
98
- #### `withUndoableState` Additional Methods:
72
+ > **Note:** This library uses "with" prefix (e.g., `withLocalState`) instead of "use" to avoid ESLint warnings in `.ts` files.
99
73
 
100
- | Method | Description | Parameters | Returns |
101
- | ---------------- | ----------------------------- | ---------- | -------------------------- |
102
- | `undo()` | Reverts to the previous state | None | `boolean` - Success status |
103
- | `redo()` | Advances to the next state | None | `boolean` - Success status |
104
- | `canUndo()` | Checks if undo is available | None | `boolean` |
105
- | `canRedo()` | Checks if redo is available | None | `boolean` |
106
- | `clearHistory()` | Clears the undo/redo history | None | `void` |
74
+ ## Quick Start
107
75
 
108
- ## Available Hooks
109
-
110
- ### Core Hooks
111
-
112
- #### `withLocalState`
113
-
114
- Scene-specific state management that gets cleaned up when the scene is destroyed.
76
+ Here's a complete example showing the basics:
115
77
 
116
78
  ```typescript
117
- type PlayerData = {
118
- hp: number;
119
- level: number;
120
- exp: number;
121
- };
122
-
123
- const playerState = withLocalState<PlayerData>(scene, 'player', {
124
- hp: 100,
125
- level: 1,
126
- exp: 0,
127
- });
128
- ```
129
-
130
- #### `withGlobalState`
79
+ // hooks/withPlayerState.ts
80
+ import { withLocalState } from 'phaser-hooks';
81
+ // or const { withLocalState } from 'phaser-hooks';
131
82
 
132
- Application-wide state that persists across all scenes.
83
+ const withPlayer = (scene: Phaser.Scene) => {
84
+ const player = withLocalState(scene, 'player', {
85
+ hp: 100,
86
+ maxHp: 100,
87
+ level: 1,
88
+ });
133
89
 
134
- ```typescript
135
- type GameSettings = {
136
- soundVolume: number;
137
- musicEnabled: true;
90
+ return player;
138
91
  };
139
92
 
140
- const settingsState = withGlobalState<GameSettings>(scene, 'settings', {
141
- soundVolume: 0.8,
142
- musicEnabled: true,
143
- });
144
- ```
145
-
146
- ### Enhanced Hooks
147
-
148
- #### `withPersistentState`
149
-
150
- State with automatic localStorage persistence.
93
+ // hooks/withSettings.ts
94
+ import { withGlobalState } from 'phaser-hooks';
151
95
 
152
- ```typescript
153
- type UserSettings = {
154
- volume: number;
155
- difficulty: 'easy' | 'normal' | 'hard';
156
- };
157
-
158
- const persistentSettings = withPersistentState<UserSettings>(
159
- 'settings',
160
- {
96
+ const withSettings = (scene: Phaser.Scene) => {
97
+ const settings = withGlobalState(scene, 'settings', {
161
98
  volume: 0.8,
162
- difficulty: 'normal',
163
- },
164
- 'local' // If you want only in sessionStorage, you can set 'session'
165
- );
166
- ```
167
-
168
- #### `withComputedState`
169
-
170
- Derived state that automatically updates when source state changes.
171
-
172
- ```typescript
173
- const healthPercentage = withComputedState(
174
- scene,
175
- 'healthPercent',
176
- playerState,
177
- player => (player.hp / player.maxHp) * 100
178
- );
179
- ```
180
-
181
- #### `withUndoableState`
182
-
183
- State with undo/redo functionality.
184
-
185
- ```typescript
186
- const undoableText = withUndoableState<string>(scene, 'text', 'initial', 10);
187
-
188
- undoableText.set('first change');
189
- undoableText.set('second change');
190
- undoableText.undo(); // Back to 'first change'
191
- undoableText.redo(); // Forward to 'second change'
192
- ```
193
-
194
- #### `withDebouncedState`
195
-
196
- State with debounced updates to prevent rapid successive changes.
197
-
198
- ```typescript
199
- const debouncedSearch = withDebouncedState<string>(scene, 'search', '', 300);
200
-
201
- // These rapid calls will be debounced
202
- debouncedSearch.set('a');
203
- debouncedSearch.set('ab');
204
- debouncedSearch.set('abc'); // Only this final value will be set after 300ms
205
- ```
206
-
207
- ### Utilities
208
-
209
- #### `validators`
210
-
211
- Pre-built validation functions for common patterns.
212
-
213
- ```typescript
214
- import { validators } from 'phaser-hooks';
215
-
216
- // Number range validation (0-1000)
217
- const scoreState = withGlobalState<number>(scene, 'score', 0, {
218
- validator: validators.numberRange(0, 1000),
219
- });
220
-
221
- // Non-empty string validation
222
- const nameState = withGlobalState<string>(scene, 'name', '', {
223
- validator: validators.nonEmptyString,
224
- });
99
+ difficulty: 'normal'
100
+ });
101
+ return settings;
102
+ };
225
103
 
226
- // Array length validation (2-4 items)
227
- const inventoryState = withLocalState<string[]>(scene, 'inventory', [], {
228
- validator: validators.arrayLength(2, 4),
229
- });
230
104
 
231
- // One of allowed values validation
232
- const difficultyState = withGlobalState<'easy' | 'normal' | 'hard'>(
233
- scene,
234
- 'difficulty',
235
- 'normal',
236
- {
237
- validator: validators.oneOf(['easy', 'normal', 'hard']),
238
- }
239
- );
240
-
241
- // Custom validator example
242
- const healthState = withLocalState<number>(scene, 'health', 100, {
243
- validator: value => {
244
- const health = value as number;
245
- if (health < 0) return 'Health cannot be negative';
246
- if (health > 100) return 'Health cannot exceed 100';
247
- return true; // Valid
248
- },
249
- });
250
- ```
105
+ // scenes/gameScene.ts
106
+ import { withLocalState, withGlobalState } from 'phaser-hooks';
251
107
 
252
- #### `batchStateUpdates`
108
+ class GameScene extends Phaser.Scene {
109
+ private unsubscribe?: () => void;
253
110
 
254
- Utility for batching multiple state updates.
111
+ create() {
112
+ // 1. Local state (scene-specific, auto-cleanup)
113
+ const player = withPlayer(this); // clean and reusable within the same scene
255
114
 
256
- ```typescript
257
- batchStateUpdates(() => {
258
- playerState.set({ ...playerState.get(), hp: 90 });
259
- inventoryState.set([...inventoryState.get(), 'new-item']);
260
- scoreState.set(scoreState.get() + 100);
261
- });
262
- ```
115
+ // 2. Global state (persists across scenes)
116
+ const settings = withSettings(this); // the same instance in all scenes
263
117
 
264
- ## Basic Usage Example
118
+ // 3. Update state
119
+ player.patch({ hp: 90 }); // Partial update
120
+ settings.set({ volume: 0.5, difficulty: 'hard' }); // Full update
265
121
 
266
- ```typescript
267
- import { withLocalState, withGlobalState } from 'phaser-hooks';
122
+ // 4. Read state
123
+ console.log(player.get().hp); // 90
124
+ console.log(settings.get().volume); // 0.5
268
125
 
269
- export class GameScene extends Phaser.Scene {
270
- create() {
271
- // Local state - specific to this scene
272
- const playerState = withLocalState<{ hp: number; mp: number }>(
273
- this,
274
- 'player',
275
- {
276
- hp: 100,
277
- mp: 50,
126
+ // 5. Listen to changes
127
+ this.unsubscribe = player.on('change', (newPlayer, oldPlayer) => {
128
+ if (newPlayer.hp < 20) {
129
+ console.warn(`Low health! Your old HP was ${oldPlayer.hp}`);
278
130
  }
279
- );
280
-
281
- // Global state - persists across scenes
282
- const gameState = withGlobalState<{ score: number; level: number }>(
283
- 'game',
284
- {
285
- score: 0,
286
- level: 1,
287
- }
288
- );
289
-
290
- // Listen to changes
291
- const ubsubscribe = playerState.on('change', (newPlayer, oldPlayer) => {
292
- console.log('Player health changed:', newPlayer.hp);
293
- });
294
-
295
- // Update state
296
- playerState.set({
297
- ...playerState.get(),
298
- hp: playerState.get().hp - 10,
299
131
  });
300
132
  }
301
- }
302
- ```
303
-
304
- ## Advanced Example
305
-
306
- ```typescript
307
- import {
308
- withPersistentState,
309
- withComputedState,
310
- withUndoableState,
311
- validators,
312
- } from 'phaser-hooks';
313
-
314
- export class AdvancedGameScene extends Phaser.Scene {
315
- create() {
316
- // Persistent settings
317
- const settings = withPersistentState<GameSettings>('settings', {
318
- soundVolume: 0.8,
319
- musicVolume: 0.6,
320
- difficulty: 'normal',
321
- });
322
-
323
- // Player state with validation
324
- const player = withLocalState<PlayerData>(
325
- this,
326
- 'player',
327
- {
328
- hp: 100,
329
- maxHp: 100,
330
- level: 1,
331
- },
332
- {
333
- validator: validators.oneOf(['easy', 'normal', 'hard']),
334
- }
335
- );
336
-
337
- // Computed health percentage
338
- const healthPercent = withComputedState(this, 'healthPercent', player, p =>
339
- Math.round((p.hp / p.maxHp) * 100)
340
- );
341
-
342
- // Undoable action system
343
- const actionHistory = withUndoableState<string>(this, 'actions', 'start');
344
-
345
- // Use the states
346
- console.log('Health:', healthPercent.get() + '%');
347
133
 
348
- if (healthPercent.get() < 20) {
349
- console.log('Low health warning!');
350
- }
134
+ shutdown() {
135
+ // 6. Clean up (local state auto-cleans, but it’s good practice)
136
+ this.unsubscribe?.();
351
137
  }
352
138
  }
353
139
  ```
354
140
 
355
- ### Composing Hooks
356
-
357
- You can compose your own hooks using other with\* hooks — similar to how custom React hooks are built. This is a powerful way to isolate logic, reuse behavior, and keep your scenes clean and focused.
358
-
359
- Example: Extracting a withPlayerEnergy hook from withPlayerState
360
-
361
- Imagine you have a local player state like this:
362
-
363
- ```ts
364
- interface PlayerAttributes {
365
- energy: number;
366
- stamina: number;
367
- strength: number;
368
- agility: number;
369
- }
370
-
371
- const playerState = withLocalState<PlayerAttributes>(scene, 'player', {
372
- energy: 100,
373
- stamina: 80,
374
- strength: 50,
375
- agility: 40,
376
- });
377
- ```
378
-
379
- You can now create a custom hook focused only on energy:
380
-
381
- ```ts
382
- function withPlayerEnergy(scene: Phaser.Scene) {
383
- const player = withLocalState<PlayerAttributes>(scene, 'player', {
384
- energy: 100,
385
- stamina: 80,
386
- strength: 50,
387
- agility: 40,
388
- });
389
-
390
- return {
391
- get: () => player.get().energy,
392
- set: (value: number) => player.set({ ...player.get(), energy: value }),
393
- ...player,
394
- };
395
- }
396
- ```
397
-
398
- Usage in a scene
399
-
400
- ```ts
401
- const energy = withPlayerEnergy(this);
402
-
403
- console.log('Current energy:', energy.get());
404
-
405
- energy.set(energy.get() - 10);
406
-
407
- energy.on('change', () => {
408
- if (energy.get() <= 0) {
409
- console.warn('You are out of energy!');
410
- }
411
- });
412
- ```
413
-
414
- ## Unsubscribe Events
415
-
416
- When you subscribe to state changes using `.on('change', callback)`, it's crucial to properly unsubscribe to prevent memory leaks and unexpected behavior. Phaser Hooks provides two ways for unsubscribing from events.
141
+ **That's it!** You now have reactive, type-safe state management in your Phaser game.
417
142
 
418
- ### Method 1: Using the Return Value from `.on('change')`
419
-
420
- The `.on('change', callback)` method returns an unsubscribe function that you can call to remove the listener:
143
+ ### Recommended: Create Custom Hooks
421
144
 
145
+ **Just like in React**, the real power comes from creating reusable hooks (but without React):
422
146
  ```typescript
423
- export class GameScene extends Phaser.Scene {
424
- create() {
425
- const playerState = withLocalState<{ hp: number }>(this, 'player', {
426
- hp: 100,
427
- });
428
-
429
- // Subscribe to changes and get unsubscribe function
430
- const unsubscribe = playerState.on('change', (newPlayer, oldPlayer) => {
431
- console.log('Player health changed:', newPlayer.hp);
432
- });
147
+ // GameScene.ts
148
+ import { withPlayerState } from './hooks/withPlayerState';
433
149
 
434
- this.add
435
- .text(centerX, centerY, 'Go to another scene')
436
- .setInteractive()
437
- .on('pointerdown', () => {
438
- // Later, unsubscribe when needed
439
- unsubscribe();
440
-
441
- // To switch to another scene in Phaser, use:
442
- this.scene.start('OtherSceneKey');
443
- });
150
+ class GameScene extends Phaser.Scene {
151
+ create() {
152
+ const player = withPlayerState(this); // Clean and reusable!
153
+ player.patch({ hp: 90 });
444
154
  }
445
155
  }
446
- ```
447
-
448
- ### Method 2: Using `.off('change', callback)`
449
156
 
450
- You can also unsubscribe by passing the same callback function to `.off('change', callback)`:
451
-
452
- ```typescript
453
- export class GameScene extends Phaser.Scene {
454
- private healthCallback?: (newPlayer: any, oldPlayer: any) => void;
455
-
456
- create() {
457
- const playerState = withLocalState<{ hp: number }>(this, 'player', {
458
- hp: 100,
157
+ // HealthBar.ts - Access the SAME state!
158
+ class HealthBar extends Phaser.GameObjects.Container {
159
+ constructor(scene: Phaser.Scene) {
160
+ super(scene, 0, 0);
161
+
162
+ const player = withPlayerState(scene); // Same state instance!
163
+
164
+ player.on('change', (newPlayer) => {
165
+ this.updateDisplay(newPlayer.hp, newPlayer.maxHp);
459
166
  });
460
-
461
- // Define callback function
462
- this.healthCallback = (newPlayer, oldPlayer) => {
463
- console.log('Player health changed:', newPlayer.hp);
464
- };
465
-
466
- // Subscribe to changes
467
- playerState.on('change', this.healthCallback);
468
-
469
- this.add
470
- .text(centerX, centerY, 'Go to another scene')
471
- .setInteractive()
472
- .on('pointerdown', () => {
473
- // Later, unsubscribe when needed
474
- playerState.off('change', this.healthCallback);
475
-
476
- // To switch to another scene in Phaser, use:
477
- this.scene.start('OtherSceneKey');
478
- });
479
167
  }
480
168
  }
481
169
  ```
482
170
 
483
- > **Note:** When using `.off`, you must pass the exact same function instance that was used with `.on`. This means you cannot use an inline closure or anonymous function—use a named function or store the callback reference to unsubscribe properly.
484
-
485
- ### Best Practices for Scene Cleanup
171
+ **Key insight:** Using the same `key` returns the same state instance, just like React hooks! This allows you to access state from anywhere: scenes, components, systems, etc.
172
+ **Initial value:** The initial value is only applied during the first execution. On subsequent calls, the same state instance is reused — just like in React Hooks.
486
173
 
487
- **⚠️ IMPORTANT DISCLAIMER**: If you don't clean up event listeners when leaving a scene, you may encounter:
174
+ ### Advanced: Hooks with Custom Methods
488
175
 
489
- - Memory leaks
490
- - Unexpected behavior when returning to the scene
491
- - Callbacks firing on destroyed or inactive scenes
492
- - Performance issues over time
176
+ > 💡 If you’re not using TypeScript, don’t worry — all hooks work with plain JavaScript too.
493
177
 
494
- Always unsubscribe from events when transitioning between scenes:
178
+ However, defining full types for your state object, hook return, and custom methods gives you complete end-to-end type safety with full IntelliSense for every method and return value.
495
179
 
496
180
  ```typescript
497
- export class GameScene extends Phaser.Scene {
498
- private unsubscribeFunctions: (() => void)[] = [];
499
-
500
- create() {
501
- const playerState = withLocalState<{ hp: number }>(this, 'player', {
502
- hp: 100,
503
- });
504
- const scoreState = withGlobalState<number>(this, 'score', 0);
505
-
506
- // Store unsubscribe functions
507
- this.unsubscribeFunctions.push(
508
- playerState.on('change', newPlayer => {
509
- console.log('Player updated:', newPlayer);
510
- })
511
- );
512
-
513
- this.unsubscribeFunctions.push(
514
- scoreState.on('change', newScore => {
515
- console.log('Score updated:', newScore);
516
- })
517
- );
518
- }
519
-
520
- // Clean up when scene is destroyed or when transitioning
521
- shutdown() {
522
- // Unsubscribe from all events
523
- this.unsubscribeFunctions.forEach(unsubscribe => unsubscribe());
524
- this.unsubscribeFunctions = [];
525
- }
526
-
527
- // Or clean up before transitioning to another scene
528
- goToNextScene() {
529
- // Clean up before changing scenes
530
- this.unsubscribeFunctions.forEach(unsubscribe => unsubscribe());
531
- this.unsubscribeFunctions = [];
532
-
533
- // Then transition
534
- this.scene.start('NextScene');
535
- }
536
- }
537
- ```
538
-
539
- ### Using `clearListeners()` for Easy Cleanup
181
+ // hooks/withPlayerState.ts
182
+ import { withLocalState, type HookState } from 'phaser-hooks';
540
183
 
541
- For easier cleanup, you can use the `clearListeners()` method to remove all event listeners at once:
184
+ export type PlayerState = {
185
+ hp: number;
186
+ maxHp: number;
187
+ level: number;
188
+ };
542
189
 
543
- ```typescript
544
- export class GameScene extends Phaser.Scene {
545
- private playerState: HookState<{ hp: number }>;
546
- private scoreState: HookState<number>;
190
+ export type PlayerHook = HookState<PlayerState> & {
191
+ takeDamage: (amount: number) => void;
192
+ heal: (amount: number) => void;
193
+ levelUp: () => void;
194
+ };
547
195
 
548
- create() {
549
- this.playerState = withLocalState<{ hp: number }>(this, 'player', { hp: 100 });
550
- this.scoreState = withGlobalState<number>(this, 'score', 0);
196
+ const initialPlayerState: PlayerState = {
197
+ hp: 100,
198
+ maxHp: 100,
199
+ level: 1,
200
+ };
551
201
 
552
- // Add listeners
553
- this.playerState.on('change', (newPlayer) => {
554
- console.log('Player updated:', newPlayer);
202
+ export function withPlayerState(scene: Phaser.Scene): PlayerHook {
203
+ const state = withLocalState<PlayerState>(scene, 'player', initialPlayerState);
204
+ const takeDamage = (amount: number): void => {
205
+ const current = state.get();
206
+ state.patch({
207
+ hp: Math.max(0, current.hp - amount),
555
208
  });
209
+ };
556
210
 
557
- this.scoreState.on('change', (newScore) => {
558
- console.log('Score updated:', newScore);
211
+ const heal = (amount: number): void => {
212
+ const current = state.get();
213
+ state.patch({
214
+ hp: Math.min(current.maxHp, current.hp + amount),
559
215
  });
560
- }
561
-
562
- shutdown() {
563
- // Clear all listeners at once - much easier!
564
- this.playerState.clearListeners();
565
- this.scoreState.clearListeners();
566
- }
567
- }
568
- ```
569
-
570
- #### Important Notes about `clearListeners()`:
571
-
572
- - **`withLocalState`**: Automatically cleans up when the scene is destroyed, but you can still use `clearListeners()` for manual cleanup
573
- - **`withGlobalState`**: **Requires manual cleanup** since global state persists across scenes. Always call `clearListeners()` when the scene is destroyed:
574
-
575
- ```typescript
576
- export class GameScene extends Phaser.Scene {
577
- private globalState: HookState<GameSettings>;
216
+ };
578
217
 
579
- create() {
580
- this.globalState = withGlobalState<GameSettings>(this, 'settings', defaultSettings);
581
-
582
- this.globalState.on('change', (newSettings) => {
583
- console.log('Settings updated:', newSettings);
218
+ const levelUp = (): void => {
219
+ const current = state.get();
220
+ state.patch({
221
+ level: current.level + 1,
222
+ maxHp: current.maxHp + 10,
223
+ hp: current.maxHp + 10,
584
224
  });
225
+ };
585
226
 
586
- // IMPORTANT: Clean up global state listeners when scene is destroyed
587
- this.events.once('destroy', () => {
588
- this.globalState.clearListeners();
589
- });
590
- }
227
+ return {
228
+ ...state, // get, set, patch, on, once, off, clearListeners
229
+ takeDamage,
230
+ heal,
231
+ levelUp,
232
+ };
591
233
  }
592
- ```
593
-
594
- ### Multiple Subscriptions Example
595
-
596
- You can have multiple listeners for the same state:
597
-
598
- ```typescript
599
- export class GameScene extends Phaser.Scene {
600
- create() {
601
- const playerState = withLocalState<{ hp: number; level: number }>(
602
- this,
603
- 'player',
604
- {
605
- hp: 100,
606
- level: 1,
607
- }
608
- );
609
-
610
- // Multiple listeners for the same state
611
- const unsubscribeHealth = playerState.on('change', newPlayer => {
612
- console.log('Health changed:', newPlayer.hp);
613
- });
614
-
615
- const unsubscribeLevel = playerState.on('change', newPlayer => {
616
- console.log('Level changed:', newPlayer.level);
617
- });
618
234
 
619
- // Unsubscribe specific listeners
620
- unsubscribeHealth(); // Only removes health listener
621
- // unsubscribeLevel still active
622
- }
623
- }
235
+ // Usage in your scene
236
+ const player = withPlayerState(this);
237
+ console.log(player.get());
238
+ player.takeDamage(30);
239
+ console.log(player.get());
240
+ player.heal(10);
241
+ console.log(player.get());
242
+ player.levelUp();
243
+ console.log(player.get());
244
+ /**
245
+ * Output:
246
+ * {hp: 100, maxHp: 100, level: 1}
247
+ * {hp: 70, maxHp: 100, level: 1}
248
+ * {hp: 80, maxHp: 100, level: 1}
249
+ * {hp: 110, maxHp: 110, level: 2}
250
+ */
624
251
  ```
625
252
 
626
- ### Using `.once()` for One-Time Events
627
-
628
- The `.once()` method registers a callback that will only fire once, then automatically unsubscribes:
629
-
630
- ```typescript
631
- export class GameScene extends Phaser.Scene {
632
- create() {
633
- const playerState = withLocalState<{ hp: number; level: number }>(
634
- this,
635
- 'player',
636
- {
637
- hp: 100,
638
- level: 1,
639
- }
640
- );
641
-
642
- // One-time listener - fires only once then auto-unsubscribes
643
- const unsubscribeOnce = playerState.once('change', newPlayer => {
644
- console.log('First level up detected!', newPlayer.level);
645
- // This callback will only run once, even if the state changes multiple times
646
- });
647
-
648
- // You can still manually unsubscribe if needed before it fires
649
- // unsubscribeOnce();
253
+ ### Next Steps
650
254
 
651
- // Simulate level up
652
- playerState.set({ hp: 100, level: 2 }); // Fires the once callback
653
- playerState.set({ hp: 100, level: 3 }); // Won't fire the once callback again
654
- }
655
- }
656
- ```
255
+ - 📚 [Full documentation and examples](https://toolkit.cassino.dev/phaser-hooks)
657
256
 
658
- ### Validation Error Handling
257
+ ## Core Concepts
659
258
 
660
- When using validators, invalid values will throw errors. Handle them appropriately:
259
+ ### Updater Functions
661
260
 
261
+ Both `set()` and `patch()` accept updater functions for race-condition-safe updates:
662
262
  ```typescript
663
- export class GameScene extends Phaser.Scene {
664
- create() {
665
- const healthState = withLocalState<number>(this, 'health', 100, {
666
- validator: validators.numberRange(0, 100),
667
- });
263
+ // Direct value
264
+ player.set({ hp: 90, level: 2 });
668
265
 
669
- try {
670
- healthState.set(150); // This will throw an error: "Value must be between 0 and 100"
671
- } catch (error) {
672
- console.error('Invalid health value:', error.message);
673
- // Handle the error appropriately
674
- }
266
+ // Updater function (recommended when based on current state)
267
+ player.set(current => ({ ...current, hp: current.hp - 10 }));
675
268
 
676
- // Valid value
677
- healthState.set(75); // This works fine
678
- }
679
- }
269
+ // Patch with updater
270
+ player.patch(current => ({ hp: current.hp + 20 }));
680
271
  ```
681
272
 
682
- ### Why use this pattern?
683
-
684
- ✅ Keeps your scene code focused on intent (e.g., energy.get()) rather than structure (player.get().energy)
685
-
686
- ✅ Allows centralized validation, side effects, or formatting for specific state slices
687
-
688
- ✅ Makes it easier to refactor or share logic across scenes and systems
689
-
690
- You can extend this idea to compose computed hooks, persistent hooks, undoable hooks, and more — everything works with the same API.
273
+ **Why use updater functions?** They always work with the latest state, preventing race conditions in async scenarios.
691
274
 
692
- ## TypeScript Support
275
+ ---
693
276
 
694
- All hooks are fully typed and provide excellent TypeScript support:
277
+ ### `set()` vs `patch()`
695
278
 
279
+ - **`set()`** - Full state replacement
280
+ - **`patch()`** - Partial update with deep merge (only for objects)
696
281
  ```typescript
697
- interface PlayerData {
698
- hp: number;
699
- maxHp: number;
700
- level: number;
701
- inventory: string[];
702
- }
703
-
704
- const playerState = withLocalState<PlayerData>(scene, 'player', {
705
- hp: 100,
706
- maxHp: 100,
707
- level: 1,
708
- inventory: [],
282
+ const player = withLocalState(this, 'player', {
283
+ hp: 100,
284
+ maxHp: 100,
285
+ level: 1
709
286
  });
710
287
 
711
- // TypeScript knows the exact type
712
- const currentPlayer: PlayerData = playerState.get();
288
+ player.set({ hp: 90, maxHp: 100, level: 1 }); // Must provide all properties
289
+ player.patch({ hp: 90 }); // Only updates hp, preserves maxHp and level
713
290
  ```
714
291
 
292
+ **Rule of thumb:** Use `patch()` for object states when you only need to update specific properties.
293
+
715
294
  ## Debug Mode / Dev tool
716
295
 
717
- Phaser Hooks includes a built-in debug mode that provides detailed logging for state operations. This is extremely useful for development and troubleshooting state management issues.
296
+ Phaser Hooks includes a built-in debug mode that provides detailed logging for state operations. This is extremely useful when developing or debugging state-related issues.
718
297
 
719
298
  ### How to Enable Debug Mode
720
299
 
@@ -723,93 +302,50 @@ To enable debug mode, simply pass `{ debug: true }` in the options parameter whe
723
302
  ```typescript
724
303
  import { withLocalState } from 'phaser-hooks';
725
304
 
726
- export class GameScene extends Phaser.Scene {
727
- create() {
728
- // Enable debug mode for this state
729
- const playerState = withLocalState<{ hp: number; level: number }>(
730
- this,
731
- 'player',
732
- {
733
- hp: 100,
734
- level: 1,
735
- },
736
- { debug: true } // Enable debug logging
737
- );
738
-
739
- // All operations will now be logged to the console
740
- playerState.set({ hp: 90, level: 2 });
741
- const currentPlayer = playerState.get();
742
-
743
- // Listen to changes with debug info
744
- playerState.on('change', (newPlayer, oldPlayer) => {
745
- console.log('Player state changed:', newPlayer);
746
- });
747
- }
748
- }
749
- ```
750
-
751
- ### What Debug Mode Shows
752
-
753
- When debug mode is enabled, you'll see detailed logs in your browser's developer console for:
754
-
755
- - **State Initialization**: When a state is first created
756
- - **State Updates**: When values are set with old and new values
757
- - **State Retrieval**: When values are accessed
758
- - **Event Listeners**: When listeners are added, removed, or cleared
759
- - **Validation**: When validators are applied and their results
760
- - **Errors**: Detailed error information with context
761
-
762
- ### Viewing Debug Logs
305
+ export const withPlayer = (scene: Phaser.Scene) => {
306
+ const playerState = withLocalState<{ hp: number; level: number }>(
307
+ this,
308
+ 'player',
309
+ {
310
+ hp: 100,
311
+ level: 1,
312
+ },
313
+ { debug: true }, // Enable debug logging
314
+ );
763
315
 
764
- Debug logs appear in your browser's developer console. To view them:
316
+ return playerState;
317
+ }
765
318
 
766
- 1. Open your browser's Developer Tools (F12 or right-click → Inspect)
767
- 2. Go to the **Console** tab
768
- 3. Run your Phaser game
769
- 4. Look for logs prefixed with `[phaser-hooks]`
319
+ // in your scene
320
+ playerState.patch((current) => ({ hp: current.hp - 10 })); // Log here
321
+ ```
770
322
 
771
- ![Debug Console Screenshot](data/debug-mode.png)
772
- *Screenshot showing debug logs in browser console*
773
323
 
774
- ### Debug Log Format
324
+ ## Hook API Reference
775
325
 
776
- Debug logs follow a consistent format with timestamps and structured information:
326
+ All hooks return a `HookState<T>` object with the following methods:
777
327
 
778
- ```
779
- [phaser-hooks] 2024-01-15 10:30:45 [INIT] player - Initializing state with value: {hp: 100, level: 1}
780
- [phaser-hooks] 2024-01-15 10:30:46 [SET] player - Updating state: {hp: 90, level: 2} (was: {hp: 100, level: 1})
781
- [phaser-hooks] 2024-01-15 10:30:47 [GET] player - Retrieved state: {hp: 90, level: 2}
782
- [phaser-hooks] 2024-01-15 10:30:48 [EVENT] player - Added change listener
783
- ```
328
+ | Method | Description | Parameters | Returns |
329
+ |--------|-------------|------------|---------|
330
+ | `get()` | Gets the current state value | None | `T` - Current state value |
331
+ | `set(value)` | Sets a new state value and triggers change listeners | `value: T \| ((current: T) => T)` | `void` |
332
+ | `patch(value)` | Patches object state with partial updates (deep merge) | `value: Partial<T> \| ((current: T) => Partial<T>)` | `void` |
333
+ | `on('change', callback)` | Registers a callback for state changes | `callback: (newValue: T, oldValue: T) => void` | `() => void` - Unsubscribe function |
334
+ | `once('change', callback)` | Registers a callback that fires only once | `callback: (newValue: T, oldValue: T) => void` | `() => void` - Unsubscribe function |
335
+ | `off('change', callback)` | Removes a specific event listener | `callback: (newValue: T, oldValue: T) => void` | `void` |
336
+ | `clearListeners()` | Removes all event listeners for this state | None | `void` |
784
337
 
785
- ### Best Practices for Debug Mode
338
+ ### Notes
786
339
 
787
- - **Development Only**: Only enable debug mode during development. Remove `{ debug: true }` in production builds
788
- - **Selective Debugging**: Enable debug mode only for the specific states you're troubleshooting
789
- - **Performance**: Debug mode adds overhead, so avoid enabling it for all states in production
790
- - **Console Filtering**: Use browser console filters to focus on specific log types
340
+ - **`set()`** accepts either a value or an updater function for safe updates
341
+ - **`patch()`** only works with object states and performs deep merging
342
+ - **`on()`/`once()`/`off()`** only support the `'change'` event
343
+ - **`off()`** requires the exact same function reference that was passed to `on()`
791
344
 
792
- ### Example: Debugging State Issues
345
+ ### Example:
793
346
 
794
- ```typescript
795
- export class DebugScene extends Phaser.Scene {
796
- create() {
797
- // Enable debug for problematic state
798
- const inventoryState = withLocalState<string[]>(
799
- this,
800
- 'inventory',
801
- [],
802
- { debug: true }
803
- );
804
-
805
- // Debug logs will show exactly what's happening
806
- inventoryState.set(['sword', 'potion']);
807
- inventoryState.set([...inventoryState.get(), 'shield']);
808
-
809
- // Check console for detailed operation logs
810
- }
811
- }
812
- ```
347
+ ![Debug Console Screenshot](data/debug-mode.png)
348
+ *Screenshot showing debug logs in the browser console*
813
349
 
814
350
  ## License
815
351