phaser-hooks 0.5.0 → 0.6.1

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
@@ -69,807 +68,248 @@ pnpm add phaser-hooks
69
68
  # or
70
69
  yarn add phaser-hooks
71
70
  ```
71
+ > **Note:** This library uses "with" prefix (e.g., `withLocalState`) instead of "use" to avoid ESLint warnings in `.ts` files.
72
72
 
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 \| ((currentState: T) => T)` - New value to set or updater function | `void` |
89
- | `patch(value)` | Patches object state with partial updates | `value: Partial<T> \| ((currentState: T) => Partial<T>)` - Partial object or updater function | `void` |
90
- | `on('change', callback)` | Registers a callback for state changes | `event: 'change'`, `callback: () => void` | `() => void` - Unsubscribe function |
91
- | `once('change', callback)` | Registers a callback that fires only once | `event: 'change'`, `callback: () => void` | `() => void` - Unsubscribe function |
92
- | `off('change', callback)` | Removes an event listener | `event: 'change'`, `callback: () => void` | `void` |
93
- | `clearListeners()` | Removes all event listeners for this state | None | `void` |
94
-
95
- ### State Updater Functions
96
-
97
- The `set()` method supports both direct values and updater functions, similar to React's `useState`:
98
-
99
- ```typescript
100
- // Direct value assignment
101
- playerState.set({ hp: 100, level: 5 });
102
-
103
- // Using updater function (receives current state, returns new state)
104
- playerState.set((currentState) => ({
105
- ...currentState,
106
- level: currentState.level + 1
107
- }));
108
-
109
- // Equivalent to:
110
- const newState = { ...playerState.get(), level: playerState.get().level + 1 };
111
- playerState.set(newState);
112
- ```
113
-
114
- **Benefits of updater functions:**
115
- - ✅ **Immutable updates**: Always work with the latest state
116
- - ✅ **Race condition safe**: No risk of using stale state
117
- - ✅ **Cleaner code**: No need to manually get current state
118
- - ✅ **Functional approach**: Encourages immutable state patterns
119
-
120
- **Example with complex state updates:**
121
-
122
- ```typescript
123
- // Instead of this verbose approach:
124
- const currentPlayer = playerState.get();
125
- playerState.set({
126
- ...currentPlayer,
127
- hp: Math.min(currentPlayer.hp + 20, currentPlayer.maxHp),
128
- level: currentPlayer.exp >= 100 ? currentPlayer.level + 1 : currentPlayer.level,
129
- exp: currentPlayer.exp >= 100 ? 0 : currentPlayer.exp + 10
130
- });
131
-
132
- // Use this clean updater function:
133
- playerState.set((player) => ({
134
- ...player,
135
- hp: Math.min(player.hp + 20, player.maxHp),
136
- level: player.exp >= 100 ? player.level + 1 : player.level,
137
- exp: player.exp >= 100 ? 0 : player.exp + 10
138
- }));
139
- ```
140
-
141
- ### State Patching
142
-
143
- The `patch()` method allows you to update only specific properties of an object state, similar to React's state updates:
144
-
145
- ```typescript
146
- // Direct partial object patching
147
- playerState.patch({ life: 90 });
148
- // Only updates 'life', preserves other properties
149
-
150
- // Using updater function for patching
151
- playerState.patch((currentState) => ({
152
- life: currentState.life - 10,
153
- level: currentState.level + 1
154
- }));
155
- // Updates multiple properties based on current state
156
- ```
157
-
158
- **Benefits of patching:**
159
- - ✅ **Partial updates**: Only change the properties you need
160
- - ✅ **Preserves other data**: Unchanged properties remain untouched
161
- - ✅ **Deep merging**: Works with nested objects using lodash.merge
162
- - ✅ **Type safety**: TypeScript ensures you only patch valid properties
163
- - ✅ **Performance**: More efficient than full object replacement
164
-
165
- **Example with complex state updates:**
166
-
167
- ```typescript
168
- // Instead of this verbose approach:
169
- const currentPlayer = playerState.get();
170
- playerState.set({
171
- ...currentPlayer,
172
- stats: {
173
- ...currentPlayer.stats,
174
- hp: currentPlayer.stats.hp - 20,
175
- mp: currentPlayer.stats.mp + 10
176
- },
177
- position: {
178
- ...currentPlayer.position,
179
- x: currentPlayer.position.x + 5
180
- }
181
- });
182
-
183
- // Use this clean patch approach:
184
- playerState.patch({
185
- stats: {
186
- hp: playerState.get().stats.hp - 20,
187
- mp: playerState.get().stats.mp + 10
188
- },
189
- position: {
190
- x: playerState.get().position.x + 5
191
- }
192
- });
193
-
194
- // Or even cleaner with updater function:
195
- playerState.patch((player) => ({
196
- stats: {
197
- hp: player.stats.hp - 20,
198
- mp: player.stats.mp + 10
199
- },
200
- position: {
201
- x: player.position.x + 5
202
- }
203
- }));
204
- ```
73
+ ## 🌐 UMD/CDN (JavaScript)
205
74
 
206
- **Deep object patching:**
75
+ If you prefer not to use TypeScript or want to include the library via CDN, you can use the UMD build:
207
76
 
208
- ```typescript
209
- // Patch deeply nested properties
210
- gameState.patch({
211
- player: {
212
- character: {
213
- stats: {
214
- primary: {
215
- strength: 15
216
- }
217
- }
218
- }
219
- }
220
- });
221
- // Only updates the strength value, preserves all other nested properties
77
+ ```html
78
+ <script src="https://cdn.jsdelivr.net/npm/phaser-hooks@0.6.0/dist/phaser-hooks.min.js"></script>
222
79
  ```
223
80
 
224
- **Array property patching:**
81
+ The library will be available globally as `window.PhaserHooks`. You can use it like this:
225
82
 
226
- ```typescript
227
- // Update array properties
228
- inventoryState.patch({
229
- items: [...inventoryState.get().items, 'new-item']
230
- });
231
-
232
- // Or with updater function
233
- inventoryState.patch((inventory) => ({
234
- items: [...inventory.items, 'new-item']
235
- }));
83
+ ```javascript
84
+ // Create local state
85
+ const playerState = window.PhaserHooks.withLocalState(scene, 'player', { hp: 100 });
236
86
  ```
237
87
 
238
- ### Special Hook Methods
239
-
240
- Some hooks have additional methods beyond the standard `HookState` interface:
241
-
242
- #### `withUndoableState` Additional Methods:
88
+ > **⚠️ Note**: While UMD builds are available, we **strongly recommend using TypeScript** for better type safety, IntelliSense, and development experience. The TypeScript version provides better error detection and autocomplete features.
243
89
 
244
- | Method | Description | Parameters | Returns |
245
- | ---------------- | ----------------------------- | ---------- | -------------------------- |
246
- | `undo()` | Reverts to the previous state | None | `boolean` - Success status |
247
- | `redo()` | Advances to the next state | None | `boolean` - Success status |
248
- | `canUndo()` | Checks if undo is available | None | `boolean` |
249
- | `canRedo()` | Checks if redo is available | None | `boolean` |
250
- | `clearHistory()` | Clears the undo/redo history | None | `void` |
90
+ ## Quick Start
251
91
 
252
- ## Available Hooks
253
-
254
- ### Core Hooks
255
-
256
- #### `withLocalState`
257
-
258
- Scene-specific state management that gets cleaned up when the scene is destroyed.
92
+ Here's a complete example showing the basics:
259
93
 
260
94
  ```typescript
261
- type PlayerData = {
262
- hp: number;
263
- level: number;
264
- exp: number;
265
- };
266
-
267
- const playerState = withLocalState<PlayerData>(scene, 'player', {
268
- hp: 100,
269
- level: 1,
270
- exp: 0,
271
- });
272
- ```
273
-
274
- #### `withGlobalState`
95
+ // hooks/withPlayerState.ts
96
+ import { withLocalState } from 'phaser-hooks';
97
+ // or const { withLocalState } from 'phaser-hooks';
275
98
 
276
- Application-wide state that persists across all scenes.
99
+ const withPlayer = (scene: Phaser.Scene) => {
100
+ const player = withLocalState(scene, 'player', {
101
+ hp: 100,
102
+ maxHp: 100,
103
+ level: 1,
104
+ });
277
105
 
278
- ```typescript
279
- type GameSettings = {
280
- soundVolume: number;
281
- musicEnabled: true;
106
+ return player;
282
107
  };
283
108
 
284
- const settingsState = withGlobalState<GameSettings>(scene, 'settings', {
285
- soundVolume: 0.8,
286
- musicEnabled: true,
287
- });
288
- ```
289
-
290
- ### Enhanced Hooks
109
+ // hooks/withSettings.ts
110
+ import { withGlobalState } from 'phaser-hooks';
291
111
 
292
- #### `withPersistentState`
293
-
294
- State with automatic localStorage persistence.
295
-
296
- ```typescript
297
- type UserSettings = {
298
- volume: number;
299
- difficulty: 'easy' | 'normal' | 'hard';
300
- };
301
-
302
- const persistentSettings = withPersistentState<UserSettings>(
303
- 'settings',
304
- {
112
+ const withSettings = (scene: Phaser.Scene) => {
113
+ const settings = withGlobalState(scene, 'settings', {
305
114
  volume: 0.8,
306
- difficulty: 'normal',
307
- },
308
- 'local' // If you want only in sessionStorage, you can set 'session'
309
- );
310
- ```
311
-
312
- #### `withComputedState`
313
-
314
- Derived state that automatically updates when source state changes.
315
-
316
- ```typescript
317
- const healthPercentage = withComputedState(
318
- scene,
319
- 'healthPercent',
320
- playerState,
321
- player => (player.hp / player.maxHp) * 100
322
- );
323
- ```
324
-
325
- #### `withUndoableState`
326
-
327
- State with undo/redo functionality.
328
-
329
- ```typescript
330
- const undoableText = withUndoableState<string>(scene, 'text', 'initial', 10);
331
-
332
- undoableText.set('first change');
333
- undoableText.set('second change');
334
- undoableText.undo(); // Back to 'first change'
335
- undoableText.redo(); // Forward to 'second change'
336
- ```
337
-
338
- #### `withDebouncedState`
339
-
340
- State with debounced updates to prevent rapid successive changes.
341
-
342
- ```typescript
343
- const debouncedSearch = withDebouncedState<string>(scene, 'search', '', 300);
344
-
345
- // These rapid calls will be debounced
346
- debouncedSearch.set('a');
347
- debouncedSearch.set('ab');
348
- debouncedSearch.set('abc'); // Only this final value will be set after 300ms
349
- ```
350
-
351
- ### Utilities
352
-
353
- #### `validators`
354
-
355
- Pre-built validation functions for common patterns.
356
-
357
- ```typescript
358
- import { validators } from 'phaser-hooks';
115
+ difficulty: 'normal'
116
+ });
117
+ return settings;
118
+ };
359
119
 
360
- // Number range validation (0-1000)
361
- const scoreState = withGlobalState<number>(scene, 'score', 0, {
362
- validator: validators.numberRange(0, 1000),
363
- });
364
120
 
365
- // Non-empty string validation
366
- const nameState = withGlobalState<string>(scene, 'name', '', {
367
- validator: validators.nonEmptyString,
368
- });
121
+ // scenes/gameScene.ts
122
+ import { withLocalState, withGlobalState } from 'phaser-hooks';
369
123
 
370
- // Array length validation (2-4 items)
371
- const inventoryState = withLocalState<string[]>(scene, 'inventory', [], {
372
- validator: validators.arrayLength(2, 4),
373
- });
124
+ class GameScene extends Phaser.Scene {
125
+ private unsubscribe?: () => void;
374
126
 
375
- // One of allowed values validation
376
- const difficultyState = withGlobalState<'easy' | 'normal' | 'hard'>(
377
- scene,
378
- 'difficulty',
379
- 'normal',
380
- {
381
- validator: validators.oneOf(['easy', 'normal', 'hard']),
382
- }
383
- );
384
-
385
- // Custom validator example
386
- const healthState = withLocalState<number>(scene, 'health', 100, {
387
- validator: value => {
388
- const health = value as number;
389
- if (health < 0) return 'Health cannot be negative';
390
- if (health > 100) return 'Health cannot exceed 100';
391
- return true; // Valid
392
- },
393
- });
394
- ```
127
+ create() {
128
+ // 1. Local state (scene-specific, auto-cleanup)
129
+ const player = withPlayer(this); // clean and reusable within the same scene
395
130
 
396
- #### `batchStateUpdates`
131
+ // 2. Global state (persists across scenes)
132
+ const settings = withSettings(this); // the same instance in all scenes
397
133
 
398
- Utility for batching multiple state updates.
134
+ // 3. Update state
135
+ player.patch({ hp: 90 }); // Partial update
136
+ settings.set({ volume: 0.5, difficulty: 'hard' }); // Full update
399
137
 
400
- ```typescript
401
- batchStateUpdates(() => {
402
- playerState.set({ ...playerState.get(), hp: 90 });
403
- inventoryState.set([...inventoryState.get(), 'new-item']);
404
- scoreState.set(scoreState.get() + 100);
405
- });
406
- ```
138
+ // 4. Read state
139
+ console.log(player.get().hp); // 90
140
+ console.log(settings.get().volume); // 0.5
407
141
 
408
- ## Basic Usage Example
409
-
410
- ```typescript
411
- import { withLocalState, withGlobalState } from 'phaser-hooks';
412
-
413
- export class GameScene extends Phaser.Scene {
414
- create() {
415
- // Local state - specific to this scene
416
- const playerState = withLocalState<{ hp: number; mp: number }>(
417
- this,
418
- 'player',
419
- {
420
- hp: 100,
421
- mp: 50,
142
+ // 5. Listen to changes
143
+ this.unsubscribe = player.on('change', (newPlayer, oldPlayer) => {
144
+ if (newPlayer.hp < 20) {
145
+ console.warn(`Low health! Your old HP was ${oldPlayer.hp}`);
422
146
  }
423
- );
424
-
425
- // Global state - persists across scenes
426
- const gameState = withGlobalState<{ score: number; level: number }>(
427
- 'game',
428
- {
429
- score: 0,
430
- level: 1,
431
- }
432
- );
433
-
434
- // Listen to changes
435
- const ubsubscribe = playerState.on('change', (newPlayer, oldPlayer) => {
436
- console.log('Player health changed:', newPlayer.hp);
437
147
  });
438
-
439
- // Update state - using patch method (recommended for partial updates)
440
- playerState.patch({ hp: playerState.get().hp - 10 });
441
-
442
- // Alternative: using updater function with set
443
- // playerState.set((currentPlayer) => ({
444
- // ...currentPlayer,
445
- // hp: currentPlayer.hp - 10,
446
- // }));
447
-
448
- // Alternative: direct value assignment
449
- // playerState.set({
450
- // ...playerState.get(),
451
- // hp: playerState.get().hp - 10,
452
- // });
453
148
  }
454
- }
455
- ```
456
-
457
- ## Advanced Example
458
-
459
- ```typescript
460
- import {
461
- withPersistentState,
462
- withComputedState,
463
- withUndoableState,
464
- validators,
465
- } from 'phaser-hooks';
466
-
467
- export class AdvancedGameScene extends Phaser.Scene {
468
- create() {
469
- // Persistent settings
470
- const settings = withPersistentState<GameSettings>('settings', {
471
- soundVolume: 0.8,
472
- musicVolume: 0.6,
473
- difficulty: 'normal',
474
- });
475
-
476
- // Player state with validation
477
- const player = withLocalState<PlayerData>(
478
- this,
479
- 'player',
480
- {
481
- hp: 100,
482
- maxHp: 100,
483
- level: 1,
484
- },
485
- {
486
- validator: validators.oneOf(['easy', 'normal', 'hard']),
487
- }
488
- );
489
-
490
- // Computed health percentage
491
- const healthPercent = withComputedState(this, 'healthPercent', player, p =>
492
- Math.round((p.hp / p.maxHp) * 100)
493
- );
494
-
495
- // Undoable action system
496
- const actionHistory = withUndoableState<string>(this, 'actions', 'start');
497
149
 
498
- // Use the states
499
- console.log('Health:', healthPercent.get() + '%');
500
-
501
- if (healthPercent.get() < 20) {
502
- console.log('Low health warning!');
503
- }
150
+ shutdown() {
151
+ // 6. Clean up (local state auto-cleans, but it’s good practice)
152
+ this.unsubscribe?.();
504
153
  }
505
154
  }
506
155
  ```
507
156
 
508
- ### Composing Hooks
509
-
510
- 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.
511
-
512
- Example: Extracting a withPlayerEnergy hook from withPlayerState
513
-
514
- Imagine you have a local player state like this:
515
-
516
- ```ts
517
- interface PlayerAttributes {
518
- energy: number;
519
- stamina: number;
520
- strength: number;
521
- agility: number;
522
- }
523
-
524
- const playerState = withLocalState<PlayerAttributes>(scene, 'player', {
525
- energy: 100,
526
- stamina: 80,
527
- strength: 50,
528
- agility: 40,
529
- });
530
- ```
531
-
532
- You can now create a custom hook focused only on energy:
533
-
534
- ```ts
535
- function withPlayerEnergy(scene: Phaser.Scene) {
536
- const player = withLocalState<PlayerAttributes>(scene, 'player', {
537
- energy: 100,
538
- stamina: 80,
539
- strength: 50,
540
- agility: 40,
541
- });
542
-
543
- return {
544
- ...player,
545
- };
546
- }
547
- ```
548
-
549
- Usage in a scene
550
-
551
- ```ts
552
- const energy = withPlayerEnergy(this);
553
-
554
- console.log('Current energy:', energy.get());
555
-
556
- // Using updater function (recommended)
557
- energy.set((currentEnergy) => currentEnergy - 10);
157
+ **That's it!** You now have reactive, type-safe state management in your Phaser game.
558
158
 
559
- // Alternative: direct value
560
- // energy.set(energy.get() - 10);
561
-
562
- energy.on('change', () => {
563
- if (energy.get() <= 0) {
564
- console.warn('You are out of energy!');
565
- }
566
- });
567
- ```
568
-
569
- ## Unsubscribe Events
570
-
571
- 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.
572
-
573
- ### Method 1: Using the Return Value from `.on('change')`
574
-
575
- The `.on('change', callback)` method returns an unsubscribe function that you can call to remove the listener:
159
+ ### Recommended: Create Custom Hooks
576
160
 
161
+ **Just like in React**, the real power comes from creating reusable hooks (but without React):
577
162
  ```typescript
578
- export class GameScene extends Phaser.Scene {
579
- create() {
580
- const playerState = withLocalState<{ hp: number }>(this, 'player', {
581
- hp: 100,
582
- });
583
-
584
- // Subscribe to changes and get unsubscribe function
585
- const unsubscribe = playerState.on('change', (newPlayer, oldPlayer) => {
586
- console.log('Player health changed:', newPlayer.hp);
587
- });
588
-
589
- this.add
590
- .text(centerX, centerY, 'Go to another scene')
591
- .setInteractive()
592
- .on('pointerdown', () => {
593
- // Later, unsubscribe when needed
594
- unsubscribe();
163
+ // GameScene.ts
164
+ import { withPlayerState } from './hooks/withPlayerState';
595
165
 
596
- // To switch to another scene in Phaser, use:
597
- this.scene.start('OtherSceneKey');
598
- });
166
+ class GameScene extends Phaser.Scene {
167
+ create() {
168
+ const player = withPlayerState(this); // Clean and reusable!
169
+ player.patch({ hp: 90 });
599
170
  }
600
171
  }
601
- ```
602
-
603
- ### Method 2: Using `.off('change', callback)`
604
-
605
- You can also unsubscribe by passing the same callback function to `.off('change', callback)`:
606
172
 
607
- ```typescript
608
- export class GameScene extends Phaser.Scene {
609
- private healthCallback?: (newPlayer: any, oldPlayer: any) => void;
610
-
611
- create() {
612
- const playerState = withLocalState<{ hp: number }>(this, 'player', {
613
- hp: 100,
173
+ // HealthBar.ts - Access the SAME state!
174
+ class HealthBar extends Phaser.GameObjects.Container {
175
+ constructor(scene: Phaser.Scene) {
176
+ super(scene, 0, 0);
177
+
178
+ const player = withPlayerState(scene); // Same state instance!
179
+
180
+ player.on('change', (newPlayer) => {
181
+ this.updateDisplay(newPlayer.hp, newPlayer.maxHp);
614
182
  });
615
-
616
- // Define callback function
617
- this.healthCallback = (newPlayer, oldPlayer) => {
618
- console.log('Player health changed:', newPlayer.hp);
619
- };
620
-
621
- // Subscribe to changes
622
- playerState.on('change', this.healthCallback);
623
-
624
- this.add
625
- .text(centerX, centerY, 'Go to another scene')
626
- .setInteractive()
627
- .on('pointerdown', () => {
628
- // Later, unsubscribe when needed
629
- playerState.off('change', this.healthCallback);
630
-
631
- // To switch to another scene in Phaser, use:
632
- this.scene.start('OtherSceneKey');
633
- });
634
183
  }
635
184
  }
636
185
  ```
637
186
 
638
- > **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.
639
-
640
- ### Best Practices for Scene Cleanup
187
+ **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.
188
+ **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.
641
189
 
642
- **⚠️ IMPORTANT DISCLAIMER**: If you don't clean up event listeners when leaving a scene, you may encounter:
190
+ ### Advanced: Hooks with Custom Methods
643
191
 
644
- - Memory leaks
645
- - Unexpected behavior when returning to the scene
646
- - Callbacks firing on destroyed or inactive scenes
647
- - Performance issues over time
192
+ > 💡 If you’re not using TypeScript, don’t worry — all hooks work with plain JavaScript too.
648
193
 
649
- Always unsubscribe from events when transitioning between scenes:
194
+ 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.
650
195
 
651
196
  ```typescript
652
- export class GameScene extends Phaser.Scene {
653
- private unsubscribeFunctions: (() => void)[] = [];
654
-
655
- create() {
656
- const playerState = withLocalState<{ hp: number }>(this, 'player', {
657
- hp: 100,
658
- });
659
- const scoreState = withGlobalState<number>(this, 'score', 0);
660
-
661
- // Store unsubscribe functions
662
- this.unsubscribeFunctions.push(
663
- playerState.on('change', newPlayer => {
664
- console.log('Player updated:', newPlayer);
665
- })
666
- );
667
-
668
- this.unsubscribeFunctions.push(
669
- scoreState.on('change', newScore => {
670
- console.log('Score updated:', newScore);
671
- })
672
- );
673
- }
674
-
675
- // Clean up when scene is destroyed or when transitioning
676
- shutdown() {
677
- // Unsubscribe from all events
678
- this.unsubscribeFunctions.forEach(unsubscribe => unsubscribe());
679
- this.unsubscribeFunctions = [];
680
- }
197
+ // hooks/withPlayerState.ts
198
+ import { withLocalState, type HookState } from 'phaser-hooks';
681
199
 
682
- // Or clean up before transitioning to another scene
683
- goToNextScene() {
684
- // Clean up before changing scenes
685
- this.unsubscribeFunctions.forEach(unsubscribe => unsubscribe());
686
- this.unsubscribeFunctions = [];
687
-
688
- // Then transition
689
- this.scene.start('NextScene');
690
- }
691
- }
692
- ```
693
-
694
- ### Using `clearListeners()` for Easy Cleanup
695
-
696
- For easier cleanup, you can use the `clearListeners()` method to remove all event listeners at once:
200
+ export type PlayerState = {
201
+ hp: number;
202
+ maxHp: number;
203
+ level: number;
204
+ };
697
205
 
698
- ```typescript
699
- export class GameScene extends Phaser.Scene {
700
- private playerState: HookState<{ hp: number }>;
701
- private scoreState: HookState<number>;
206
+ export type PlayerHook = HookState<PlayerState> & {
207
+ takeDamage: (amount: number) => void;
208
+ heal: (amount: number) => void;
209
+ levelUp: () => void;
210
+ };
702
211
 
703
- create() {
704
- this.playerState = withLocalState<{ hp: number }>(this, 'player', { hp: 100 });
705
- this.scoreState = withGlobalState<number>(this, 'score', 0);
212
+ const initialPlayerState: PlayerState = {
213
+ hp: 100,
214
+ maxHp: 100,
215
+ level: 1,
216
+ };
706
217
 
707
- // Add listeners
708
- this.playerState.on('change', (newPlayer) => {
709
- console.log('Player updated:', newPlayer);
218
+ export function withPlayerState(scene: Phaser.Scene): PlayerHook {
219
+ const state = withLocalState<PlayerState>(scene, 'player', initialPlayerState);
220
+ const takeDamage = (amount: number): void => {
221
+ const current = state.get();
222
+ state.patch({
223
+ hp: Math.max(0, current.hp - amount),
710
224
  });
225
+ };
711
226
 
712
- this.scoreState.on('change', (newScore) => {
713
- console.log('Score updated:', newScore);
227
+ const heal = (amount: number): void => {
228
+ const current = state.get();
229
+ state.patch({
230
+ hp: Math.min(current.maxHp, current.hp + amount),
714
231
  });
715
- }
716
-
717
- shutdown() {
718
- // Clear all listeners at once - much easier!
719
- this.playerState.clearListeners();
720
- this.scoreState.clearListeners();
721
- }
722
- }
723
- ```
724
-
725
- #### Important Notes about `clearListeners()`:
726
-
727
- - **`withLocalState`**: Automatically cleans up when the scene is destroyed, but you can still use `clearListeners()` for manual cleanup
728
- - **`withGlobalState`**: **Requires manual cleanup** since global state persists across scenes. Always call `clearListeners()` when the scene is destroyed:
729
-
730
- ```typescript
731
- export class GameScene extends Phaser.Scene {
732
- private globalState: HookState<GameSettings>;
232
+ };
733
233
 
734
- create() {
735
- this.globalState = withGlobalState<GameSettings>(this, 'settings', defaultSettings);
736
-
737
- this.globalState.on('change', (newSettings) => {
738
- console.log('Settings updated:', newSettings);
234
+ const levelUp = (): void => {
235
+ const current = state.get();
236
+ state.patch({
237
+ level: current.level + 1,
238
+ maxHp: current.maxHp + 10,
239
+ hp: current.maxHp + 10,
739
240
  });
241
+ };
740
242
 
741
- // IMPORTANT: Clean up global state listeners when scene is destroyed
742
- this.events.once('destroy', () => {
743
- this.globalState.clearListeners();
744
- });
745
- }
243
+ return {
244
+ ...state, // get, set, patch, on, once, off, clearListeners
245
+ takeDamage,
246
+ heal,
247
+ levelUp,
248
+ };
746
249
  }
747
- ```
748
250
 
749
- ### Multiple Subscriptions Example
750
-
751
- You can have multiple listeners for the same state:
752
-
753
- ```typescript
754
- export class GameScene extends Phaser.Scene {
755
- create() {
756
- const playerState = withLocalState<{ hp: number; level: number }>(
757
- this,
758
- 'player',
759
- {
760
- hp: 100,
761
- level: 1,
762
- }
763
- );
764
-
765
- // Multiple listeners for the same state
766
- const unsubscribeHealth = playerState.on('change', newPlayer => {
767
- console.log('Health changed:', newPlayer.hp);
768
- });
769
-
770
- const unsubscribeLevel = playerState.on('change', newPlayer => {
771
- console.log('Level changed:', newPlayer.level);
772
- });
773
-
774
- // Unsubscribe specific listeners
775
- unsubscribeHealth(); // Only removes health listener
776
- // unsubscribeLevel still active
777
- }
778
- }
251
+ // Usage in your scene
252
+ const player = withPlayerState(this);
253
+ console.log(player.get());
254
+ player.takeDamage(30);
255
+ console.log(player.get());
256
+ player.heal(10);
257
+ console.log(player.get());
258
+ player.levelUp();
259
+ console.log(player.get());
260
+ /**
261
+ * Output:
262
+ * {hp: 100, maxHp: 100, level: 1}
263
+ * {hp: 70, maxHp: 100, level: 1}
264
+ * {hp: 80, maxHp: 100, level: 1}
265
+ * {hp: 110, maxHp: 110, level: 2}
266
+ */
779
267
  ```
780
268
 
781
- ### Using `.once()` for One-Time Events
782
-
783
- The `.once()` method registers a callback that will only fire once, then automatically unsubscribes:
784
-
785
- ```typescript
786
- export class GameScene extends Phaser.Scene {
787
- create() {
788
- const playerState = withLocalState<{ hp: number; level: number }>(
789
- this,
790
- 'player',
791
- {
792
- hp: 100,
793
- level: 1,
794
- }
795
- );
796
-
797
- // One-time listener - fires only once then auto-unsubscribes
798
- const unsubscribeOnce = playerState.once('change', newPlayer => {
799
- console.log('First level up detected!', newPlayer.level);
800
- // This callback will only run once, even if the state changes multiple times
801
- });
802
-
803
- // You can still manually unsubscribe if needed before it fires
804
- // unsubscribeOnce();
269
+ ### Next Steps
805
270
 
806
- // Simulate level up
807
- playerState.set({ hp: 100, level: 2 }); // Fires the once callback
808
- playerState.set({ hp: 100, level: 3 }); // Won't fire the once callback again
809
- }
810
- }
811
- ```
271
+ - 📚 [Full documentation and examples](https://toolkit.cassino.dev/phaser-hooks)
812
272
 
813
- ### Validation Error Handling
273
+ ## Core Concepts
814
274
 
815
- When using validators, invalid values will throw errors. Handle them appropriately:
275
+ ### Updater Functions
816
276
 
277
+ Both `set()` and `patch()` accept updater functions for race-condition-safe updates:
817
278
  ```typescript
818
- export class GameScene extends Phaser.Scene {
819
- create() {
820
- const healthState = withLocalState<number>(this, 'health', 100, {
821
- validator: validators.numberRange(0, 100),
822
- });
279
+ // Direct value
280
+ player.set({ hp: 90, level: 2 });
823
281
 
824
- try {
825
- healthState.set(150); // This will throw an error: "Value must be between 0 and 100"
826
- } catch (error) {
827
- console.error('Invalid health value:', error.message);
828
- // Handle the error appropriately
829
- }
282
+ // Updater function (recommended when based on current state)
283
+ player.set(current => ({ ...current, hp: current.hp - 10 }));
830
284
 
831
- // Valid value
832
- healthState.set(75); // This works fine
833
- }
834
- }
285
+ // Patch with updater
286
+ player.patch(current => ({ hp: current.hp + 20 }));
835
287
  ```
836
288
 
837
- ### Why use this pattern?
289
+ **Why use updater functions?** They always work with the latest state, preventing race conditions in async scenarios.
838
290
 
839
- ✅ Keeps your scene code focused on intent (e.g., energy.get()) rather than structure (player.get().energy)
291
+ ---
840
292
 
841
- Allows centralized validation, side effects, or formatting for specific state slices
842
-
843
- ✅ Makes it easier to refactor or share logic across scenes and systems
844
-
845
- You can extend this idea to compose computed hooks, persistent hooks, undoable hooks, and more — everything works with the same API.
846
-
847
- ## TypeScript Support
848
-
849
- All hooks are fully typed and provide excellent TypeScript support:
293
+ ### `set()` vs `patch()`
850
294
 
295
+ - **`set()`** - Full state replacement
296
+ - **`patch()`** - Partial update with deep merge (only for objects)
851
297
  ```typescript
852
- interface PlayerData {
853
- hp: number;
854
- maxHp: number;
855
- level: number;
856
- inventory: string[];
857
- }
858
-
859
- const playerState = withLocalState<PlayerData>(scene, 'player', {
860
- hp: 100,
861
- maxHp: 100,
862
- level: 1,
863
- inventory: [],
298
+ const player = withLocalState(this, 'player', {
299
+ hp: 100,
300
+ maxHp: 100,
301
+ level: 1
864
302
  });
865
303
 
866
- // TypeScript knows the exact type
867
- const currentPlayer: PlayerData = playerState.get();
304
+ player.set({ hp: 90, maxHp: 100, level: 1 }); // Must provide all properties
305
+ player.patch({ hp: 90 }); // Only updates hp, preserves maxHp and level
868
306
  ```
869
307
 
308
+ **Rule of thumb:** Use `patch()` for object states when you only need to update specific properties.
309
+
870
310
  ## Debug Mode / Dev tool
871
311
 
872
- 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.
312
+ 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.
873
313
 
874
314
  ### How to Enable Debug Mode
875
315
 
@@ -878,93 +318,50 @@ To enable debug mode, simply pass `{ debug: true }` in the options parameter whe
878
318
  ```typescript
879
319
  import { withLocalState } from 'phaser-hooks';
880
320
 
881
- export class GameScene extends Phaser.Scene {
882
- create() {
883
- // Enable debug mode for this state
884
- const playerState = withLocalState<{ hp: number; level: number }>(
885
- this,
886
- 'player',
887
- {
888
- hp: 100,
889
- level: 1,
890
- },
891
- { debug: true } // Enable debug logging
892
- );
893
-
894
- // All operations will now be logged to the console
895
- playerState.set({ hp: 90, level: 2 });
896
- const currentPlayer = playerState.get();
897
-
898
- // Listen to changes with debug info
899
- playerState.on('change', (newPlayer, oldPlayer) => {
900
- console.log('Player state changed:', newPlayer);
901
- });
902
- }
903
- }
904
- ```
905
-
906
- ### What Debug Mode Shows
907
-
908
- When debug mode is enabled, you'll see detailed logs in your browser's developer console for:
909
-
910
- - **State Initialization**: When a state is first created
911
- - **State Updates**: When values are set with old and new values
912
- - **State Retrieval**: When values are accessed
913
- - **Event Listeners**: When listeners are added, removed, or cleared
914
- - **Validation**: When validators are applied and their results
915
- - **Errors**: Detailed error information with context
916
-
917
- ### Viewing Debug Logs
321
+ export const withPlayer = (scene: Phaser.Scene) => {
322
+ const playerState = withLocalState<{ hp: number; level: number }>(
323
+ this,
324
+ 'player',
325
+ {
326
+ hp: 100,
327
+ level: 1,
328
+ },
329
+ { debug: true }, // Enable debug logging
330
+ );
918
331
 
919
- Debug logs appear in your browser's developer console. To view them:
332
+ return playerState;
333
+ }
920
334
 
921
- 1. Open your browser's Developer Tools (F12 or right-click → Inspect)
922
- 2. Go to the **Console** tab
923
- 3. Run your Phaser game
924
- 4. Look for logs prefixed with `[phaser-hooks]`
335
+ // in your scene
336
+ playerState.patch((current) => ({ hp: current.hp - 10 })); // Log here
337
+ ```
925
338
 
926
- ![Debug Console Screenshot](data/debug-mode.png)
927
- *Screenshot showing debug logs in browser console*
928
339
 
929
- ### Debug Log Format
340
+ ## Hook API Reference
930
341
 
931
- Debug logs follow a consistent format with timestamps and structured information:
342
+ All hooks return a `HookState<T>` object with the following methods:
932
343
 
933
- ```
934
- [phaser-hooks] 2024-01-15 10:30:45 [INIT] player - Initializing state with value: {hp: 100, level: 1}
935
- [phaser-hooks] 2024-01-15 10:30:46 [SET] player - Updating state: {hp: 90, level: 2} (was: {hp: 100, level: 1})
936
- [phaser-hooks] 2024-01-15 10:30:47 [GET] player - Retrieved state: {hp: 90, level: 2}
937
- [phaser-hooks] 2024-01-15 10:30:48 [EVENT] player - Added change listener
938
- ```
344
+ | Method | Description | Parameters | Returns |
345
+ |--------|-------------|------------|---------|
346
+ | `get()` | Gets the current state value | None | `T` - Current state value |
347
+ | `set(value)` | Sets a new state value and triggers change listeners | `value: T \| ((current: T) => T)` | `void` |
348
+ | `patch(value)` | Patches object state with partial updates (deep merge) | `value: Partial<T> \| ((current: T) => Partial<T>)` | `void` |
349
+ | `on('change', callback)` | Registers a callback for state changes | `callback: (newValue: T, oldValue: T) => void` | `() => void` - Unsubscribe function |
350
+ | `once('change', callback)` | Registers a callback that fires only once | `callback: (newValue: T, oldValue: T) => void` | `() => void` - Unsubscribe function |
351
+ | `off('change', callback)` | Removes a specific event listener | `callback: (newValue: T, oldValue: T) => void` | `void` |
352
+ | `clearListeners()` | Removes all event listeners for this state | None | `void` |
939
353
 
940
- ### Best Practices for Debug Mode
354
+ ### Notes
941
355
 
942
- - **Development Only**: Only enable debug mode during development. Remove `{ debug: true }` in production builds
943
- - **Selective Debugging**: Enable debug mode only for the specific states you're troubleshooting
944
- - **Performance**: Debug mode adds overhead, so avoid enabling it for all states in production
945
- - **Console Filtering**: Use browser console filters to focus on specific log types
356
+ - **`set()`** accepts either a value or an updater function for safe updates
357
+ - **`patch()`** only works with object states and performs deep merging
358
+ - **`on()`/`once()`/`off()`** only support the `'change'` event
359
+ - **`off()`** requires the exact same function reference that was passed to `on()`
946
360
 
947
- ### Example: Debugging State Issues
361
+ ### Example:
948
362
 
949
- ```typescript
950
- export class DebugScene extends Phaser.Scene {
951
- create() {
952
- // Enable debug for problematic state
953
- const inventoryState = withLocalState<string[]>(
954
- this,
955
- 'inventory',
956
- [],
957
- { debug: true }
958
- );
959
-
960
- // Debug logs will show exactly what's happening
961
- inventoryState.set(['sword', 'potion']);
962
- inventoryState.set([...inventoryState.get(), 'shield']);
963
-
964
- // Check console for detailed operation logs
965
- }
966
- }
967
- ```
363
+ ![Debug Console Screenshot](data/debug-mode.png)
364
+ *Screenshot showing debug logs in the browser console*
968
365
 
969
366
  ## License
970
367