@react-text-game/core 0.5.6 → 0.5.7
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 +587 -528
- package/dist/audio/constants.d.ts.map +1 -1
- package/dist/audio/constants.js.map +1 -1
- package/dist/audio/index.d.ts +1 -1
- package/dist/audio/index.d.ts.map +1 -1
- package/dist/game.d.ts +26 -0
- package/dist/game.d.ts.map +1 -1
- package/dist/game.js +28 -0
- package/dist/game.js.map +1 -1
- package/dist/gameObjects/index.d.ts +3 -3
- package/dist/gameObjects/index.js +3 -3
- package/dist/gameObjects/simpleObject.d.ts.map +1 -1
- package/dist/gameObjects/simpleObject.js.map +1 -1
- package/dist/hooks/index.d.ts +5 -5
- package/dist/hooks/index.js +5 -5
- package/dist/hooks/useCurrentPassage.d.ts +10 -5
- package/dist/hooks/useCurrentPassage.d.ts.map +1 -1
- package/dist/hooks/useCurrentPassage.js +14 -6
- package/dist/hooks/useCurrentPassage.js.map +1 -1
- package/dist/hooks/useGameIsStarted.d.ts.map +1 -1
- package/dist/hooks/useGameIsStarted.js +1 -1
- package/dist/hooks/useGameIsStarted.js.map +1 -1
- package/dist/i18n/hooks/index.d.ts +1 -1
- package/dist/i18n/hooks/index.js +1 -1
- package/dist/i18n/index.d.ts +4 -4
- package/dist/i18n/index.js +4 -4
- package/dist/i18n/init.js +2 -2
- package/dist/i18n/init.js.map +1 -1
- package/dist/i18n/utils.js +1 -1
- package/dist/logger.js +1 -1
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js.map +1 -1
- package/dist/passages/story/types.d.ts.map +1 -1
- package/dist/passages/types/index.d.ts +2 -2
- package/dist/saves/db.js.map +1 -1
- package/dist/saves/hooks/useRestartGame.d.ts.map +1 -1
- package/dist/saves/hooks/useRestartGame.js +4 -1
- package/dist/saves/hooks/useRestartGame.js.map +1 -1
- package/dist/saves/hooks/useSaveSlots.d.ts.map +1 -1
- package/dist/saves/hooks/useSaveSlots.js +1 -1
- package/dist/saves/hooks/useSaveSlots.js.map +1 -1
- package/dist/saves/index.d.ts +4 -4
- package/dist/saves/index.js +3 -3
- package/dist/saves/migrations/EXAMPLE.d.ts.map +1 -1
- package/dist/saves/migrations/EXAMPLE.js.map +1 -1
- package/dist/saves/migrations/runner.d.ts.map +1 -1
- package/dist/saves/migrations/runner.js.map +1 -1
- package/dist/saves/migrations/types.d.ts.map +1 -1
- package/dist/tests/audio.test.js +22 -23
- package/dist/tests/audio.test.js.map +1 -1
- package/dist/tests/game.test.js +48 -0
- package/dist/tests/game.test.js.map +1 -1
- package/dist/tests/i18n.test.js +5 -3
- package/dist/tests/i18n.test.js.map +1 -1
- package/dist/tests/migrations.test.js.map +1 -1
- package/dist/tests/simpleObject.test.js +2 -1
- package/dist/tests/simpleObject.test.js.map +1 -1
- package/dist/tests/storage.test.js +1 -3
- package/dist/tests/storage.test.js.map +1 -1
- package/dist/tests/story.test.js.map +1 -1
- package/dist/types.d.ts +1 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,35 +33,35 @@ pnpm add @react-text-game/core
|
|
|
33
33
|
## Quick Start
|
|
34
34
|
|
|
35
35
|
```tsx
|
|
36
|
-
import { Game, createEntity, newStory } from
|
|
36
|
+
import { Game, createEntity, newStory } from "@react-text-game/core";
|
|
37
37
|
|
|
38
38
|
// IMPORTANT: Initialize the game first
|
|
39
39
|
await Game.init({
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
40
|
+
gameName: "My Adventure",
|
|
41
|
+
translations: {
|
|
42
|
+
defaultLanguage: "en",
|
|
43
|
+
fallbackLanguage: "en",
|
|
44
|
+
resources: {
|
|
45
|
+
en: {
|
|
46
|
+
passages: { intro: "Welcome to the Game" },
|
|
47
|
+
common: { save: "Save", load: "Load" },
|
|
48
|
+
},
|
|
49
|
+
ru: {
|
|
50
|
+
passages: { intro: "Добро пожаловать в игру" },
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
// ...other options
|
|
55
55
|
});
|
|
56
56
|
|
|
57
57
|
// Create a game entity with the factory (recommended)
|
|
58
|
-
const player = createEntity(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
58
|
+
const player = createEntity("player", {
|
|
59
|
+
name: "Hero",
|
|
60
|
+
stats: {
|
|
61
|
+
health: 100,
|
|
62
|
+
mana: 50,
|
|
63
|
+
},
|
|
64
|
+
inventory: [] as string[],
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
// Direct property updates automatically stay reactive
|
|
@@ -71,25 +71,25 @@ player.stats.health -= 10;
|
|
|
71
71
|
player.save();
|
|
72
72
|
|
|
73
73
|
// Create a story passage
|
|
74
|
-
const introStory = newStory(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
74
|
+
const introStory = newStory("intro", () => [
|
|
75
|
+
{
|
|
76
|
+
type: "header",
|
|
77
|
+
content: "Welcome to the Game",
|
|
78
|
+
props: { level: 1 },
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
type: "text",
|
|
82
|
+
content: `Hello, ${player.name}!`,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
type: "actions",
|
|
86
|
+
content: [
|
|
87
|
+
{
|
|
88
|
+
label: "Start Adventure",
|
|
89
|
+
action: () => Game.jumpTo("adventure"),
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
93
|
]);
|
|
94
94
|
|
|
95
95
|
// Navigate to passage
|
|
@@ -115,13 +115,13 @@ The core engine includes a comprehensive audio system with reactive state manage
|
|
|
115
115
|
### Quick Start
|
|
116
116
|
|
|
117
117
|
```typescript
|
|
118
|
-
import { createAudio, AudioManager } from
|
|
118
|
+
import { createAudio, AudioManager } from "@react-text-game/core/audio";
|
|
119
119
|
|
|
120
120
|
// Create an audio track
|
|
121
|
-
const bgMusic = createAudio(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
121
|
+
const bgMusic = createAudio("/audio/background.mp3", {
|
|
122
|
+
id: "bg-music",
|
|
123
|
+
volume: 0.7,
|
|
124
|
+
loop: true,
|
|
125
125
|
});
|
|
126
126
|
|
|
127
127
|
// Play the track
|
|
@@ -144,20 +144,20 @@ AudioManager.pauseAll();
|
|
|
144
144
|
Use the `createAudio` factory function to create audio tracks:
|
|
145
145
|
|
|
146
146
|
```typescript
|
|
147
|
-
import { createAudio } from
|
|
147
|
+
import { createAudio } from "@react-text-game/core/audio";
|
|
148
148
|
|
|
149
149
|
// Basic audio track
|
|
150
|
-
const sfx = createAudio(
|
|
150
|
+
const sfx = createAudio("/audio/click.mp3");
|
|
151
151
|
|
|
152
152
|
// With options
|
|
153
|
-
const music = createAudio(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
153
|
+
const music = createAudio("/audio/theme.mp3", {
|
|
154
|
+
id: "theme-music", // Required for persistence
|
|
155
|
+
volume: 0.6, // 0.0 to 1.0 (default: 1.0)
|
|
156
|
+
loop: true, // Auto-loop (default: false)
|
|
157
|
+
playbackRate: 1.0, // Playback speed (default: 1.0)
|
|
158
|
+
muted: false, // Start muted (default: false)
|
|
159
|
+
autoPlay: false, // Auto-play on creation (default: false)
|
|
160
|
+
preload: "metadata", // 'none', 'metadata', or 'auto' (default: 'metadata')
|
|
161
161
|
});
|
|
162
162
|
```
|
|
163
163
|
|
|
@@ -167,31 +167,31 @@ Each audio track provides comprehensive playback controls:
|
|
|
167
167
|
|
|
168
168
|
```typescript
|
|
169
169
|
// Playback control
|
|
170
|
-
await audio.play();
|
|
171
|
-
audio.pause();
|
|
172
|
-
audio.resume();
|
|
173
|
-
audio.stop();
|
|
170
|
+
await audio.play(); // Start playback (returns Promise)
|
|
171
|
+
audio.pause(); // Pause playback
|
|
172
|
+
audio.resume(); // Resume from pause
|
|
173
|
+
audio.stop(); // Stop and reset to beginning
|
|
174
174
|
|
|
175
175
|
// Volume and settings
|
|
176
|
-
audio.setVolume(0.5);
|
|
177
|
-
audio.setLoop(true);
|
|
178
|
-
audio.setPlaybackRate(1.5);
|
|
179
|
-
audio.setMuted(true);
|
|
176
|
+
audio.setVolume(0.5); // Set volume (0.0 to 1.0)
|
|
177
|
+
audio.setLoop(true); // Enable/disable looping
|
|
178
|
+
audio.setPlaybackRate(1.5); // Set playback speed
|
|
179
|
+
audio.setMuted(true); // Mute/unmute
|
|
180
180
|
|
|
181
181
|
// Seeking
|
|
182
|
-
audio.seek(30);
|
|
182
|
+
audio.seek(30); // Seek to 30 seconds
|
|
183
183
|
|
|
184
184
|
// Fade effects
|
|
185
|
-
await audio.fadeIn(2000);
|
|
186
|
-
await audio.fadeOut(1500);
|
|
185
|
+
await audio.fadeIn(2000); // Fade in over 2 seconds
|
|
186
|
+
await audio.fadeOut(1500); // Fade out over 1.5 seconds
|
|
187
187
|
|
|
188
188
|
// State and persistence
|
|
189
189
|
const state = audio.getState(); // Get reactive state
|
|
190
|
-
audio.save();
|
|
191
|
-
audio.load();
|
|
190
|
+
audio.save(); // Save state to storage
|
|
191
|
+
audio.load(); // Load state from storage
|
|
192
192
|
|
|
193
193
|
// Cleanup
|
|
194
|
-
audio.dispose();
|
|
194
|
+
audio.dispose(); // Remove and clean up
|
|
195
195
|
```
|
|
196
196
|
|
|
197
197
|
### Reactive State
|
|
@@ -199,21 +199,21 @@ audio.dispose(); // Remove and clean up
|
|
|
199
199
|
Audio tracks use Valtio for reactive state management, making them perfect for React integration:
|
|
200
200
|
|
|
201
201
|
```typescript
|
|
202
|
-
const audio = createAudio(
|
|
202
|
+
const audio = createAudio("/audio/music.mp3", { id: "music" });
|
|
203
203
|
|
|
204
204
|
// Get reactive state
|
|
205
205
|
const state = audio.getState();
|
|
206
206
|
|
|
207
207
|
// Access state properties
|
|
208
|
-
console.log(state.isPlaying);
|
|
209
|
-
console.log(state.isPaused);
|
|
210
|
-
console.log(state.isStopped);
|
|
211
|
-
console.log(state.currentTime);
|
|
212
|
-
console.log(state.duration);
|
|
213
|
-
console.log(state.volume);
|
|
214
|
-
console.log(state.loop);
|
|
208
|
+
console.log(state.isPlaying); // boolean
|
|
209
|
+
console.log(state.isPaused); // boolean
|
|
210
|
+
console.log(state.isStopped); // boolean
|
|
211
|
+
console.log(state.currentTime); // number (seconds)
|
|
212
|
+
console.log(state.duration); // number (seconds)
|
|
213
|
+
console.log(state.volume); // number (0.0 to 1.0)
|
|
214
|
+
console.log(state.loop); // boolean
|
|
215
215
|
console.log(state.playbackRate); // number
|
|
216
|
-
console.log(state.muted);
|
|
216
|
+
console.log(state.muted); // boolean
|
|
217
217
|
```
|
|
218
218
|
|
|
219
219
|
### Global Audio Manager
|
|
@@ -221,30 +221,31 @@ console.log(state.muted); // boolean
|
|
|
221
221
|
The `AudioManager` provides global controls for all registered audio tracks:
|
|
222
222
|
|
|
223
223
|
```typescript
|
|
224
|
-
import { AudioManager } from
|
|
224
|
+
import { AudioManager } from "@react-text-game/core/audio";
|
|
225
225
|
|
|
226
226
|
// Master volume control
|
|
227
|
-
AudioManager.setMasterVolume(0.5);
|
|
227
|
+
AudioManager.setMasterVolume(0.5); // Set master volume (0.0 to 1.0)
|
|
228
228
|
const volume = AudioManager.getMasterVolume(); // Get master volume
|
|
229
229
|
|
|
230
230
|
// Global playback control
|
|
231
|
-
AudioManager.pauseAll();
|
|
232
|
-
AudioManager.resumeAll();
|
|
233
|
-
AudioManager.stopAll();
|
|
231
|
+
AudioManager.pauseAll(); // Pause all playing tracks
|
|
232
|
+
AudioManager.resumeAll(); // Resume all paused tracks
|
|
233
|
+
AudioManager.stopAll(); // Stop all tracks
|
|
234
234
|
|
|
235
235
|
// Global mute control
|
|
236
|
-
AudioManager.muteAll();
|
|
237
|
-
AudioManager.unmuteAll();
|
|
236
|
+
AudioManager.muteAll(); // Mute all tracks
|
|
237
|
+
AudioManager.unmuteAll(); // Unmute all tracks
|
|
238
238
|
|
|
239
239
|
// Track management
|
|
240
|
-
const tracks = AudioManager.getAllTracks();
|
|
241
|
-
const music = AudioManager.getTrackById(
|
|
240
|
+
const tracks = AudioManager.getAllTracks(); // Get all registered tracks
|
|
241
|
+
const music = AudioManager.getTrackById("bg-music"); // Get specific track by ID
|
|
242
242
|
|
|
243
243
|
// Cleanup
|
|
244
244
|
AudioManager.disposeAll(); // Dispose all tracks
|
|
245
245
|
```
|
|
246
246
|
|
|
247
247
|
**Master Volume Behavior:**
|
|
248
|
+
|
|
248
249
|
- Master volume is a multiplier applied to all track volumes
|
|
249
250
|
- Does not modify individual track volume settings
|
|
250
251
|
- Example: Track at 0.8 volume with 0.5 master = 0.4 effective volume
|
|
@@ -259,37 +260,40 @@ The audio system includes React hooks for seamless component integration:
|
|
|
259
260
|
Monitor individual audio track state with automatic re-renders:
|
|
260
261
|
|
|
261
262
|
```tsx
|
|
262
|
-
import { createAudio } from
|
|
263
|
-
import { useAudio } from
|
|
263
|
+
import { createAudio } from "@react-text-game/core/audio";
|
|
264
|
+
import { useAudio } from "@react-text-game/core";
|
|
264
265
|
|
|
265
|
-
const bgMusic = createAudio(
|
|
266
|
-
|
|
267
|
-
|
|
266
|
+
const bgMusic = createAudio("/audio/background.mp3", {
|
|
267
|
+
id: "bg-music",
|
|
268
|
+
loop: true,
|
|
268
269
|
});
|
|
269
270
|
|
|
270
271
|
function MusicPlayer() {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
272
|
+
const audioState = useAudio(bgMusic);
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<div>
|
|
276
|
+
<p>Status: {audioState.isPlaying ? "Playing" : "Stopped"}</p>
|
|
277
|
+
<p>
|
|
278
|
+
Time: {audioState.currentTime.toFixed(1)}s /{" "}
|
|
279
|
+
{audioState.duration.toFixed(1)}s
|
|
280
|
+
</p>
|
|
281
|
+
<p>Volume: {(audioState.volume * 100).toFixed(0)}%</p>
|
|
282
|
+
|
|
283
|
+
<button onClick={() => bgMusic.play()}>Play</button>
|
|
284
|
+
<button onClick={() => bgMusic.pause()}>Pause</button>
|
|
285
|
+
<button onClick={() => bgMusic.stop()}>Stop</button>
|
|
286
|
+
|
|
287
|
+
<input
|
|
288
|
+
type="range"
|
|
289
|
+
min="0"
|
|
290
|
+
max="1"
|
|
291
|
+
step="0.01"
|
|
292
|
+
value={audioState.volume}
|
|
293
|
+
onChange={(e) => bgMusic.setVolume(parseFloat(e.target.value))}
|
|
294
|
+
/>
|
|
295
|
+
</div>
|
|
296
|
+
);
|
|
293
297
|
}
|
|
294
298
|
```
|
|
295
299
|
|
|
@@ -298,38 +302,40 @@ function MusicPlayer() {
|
|
|
298
302
|
Access global audio controls in React components:
|
|
299
303
|
|
|
300
304
|
```tsx
|
|
301
|
-
import { useAudioManager } from
|
|
305
|
+
import { useAudioManager } from "@react-text-game/core";
|
|
302
306
|
|
|
303
307
|
function AudioSettings() {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
308
|
+
const audioManager = useAudioManager();
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<div>
|
|
312
|
+
<h2>Audio Settings</h2>
|
|
313
|
+
|
|
314
|
+
<label>
|
|
315
|
+
Master Volume: {(audioManager.masterVolume * 100).toFixed(0)}%
|
|
316
|
+
<input
|
|
317
|
+
type="range"
|
|
318
|
+
min="0"
|
|
319
|
+
max="1"
|
|
320
|
+
step="0.01"
|
|
321
|
+
value={audioManager.masterVolume}
|
|
322
|
+
onChange={(e) =>
|
|
323
|
+
audioManager.setMasterVolume(parseFloat(e.target.value))
|
|
324
|
+
}
|
|
325
|
+
/>
|
|
326
|
+
</label>
|
|
327
|
+
|
|
328
|
+
<div>
|
|
329
|
+
<button onClick={audioManager.muteAll}>Mute All</button>
|
|
330
|
+
<button onClick={audioManager.unmuteAll}>Unmute All</button>
|
|
331
|
+
<button onClick={audioManager.pauseAll}>Pause All</button>
|
|
332
|
+
<button onClick={audioManager.resumeAll}>Resume All</button>
|
|
333
|
+
</div>
|
|
334
|
+
|
|
335
|
+
<p>Muted: {audioManager.isMuted ? "Yes" : "No"}</p>
|
|
336
|
+
<p>Active Tracks: {audioManager.getAllTracks().length}</p>
|
|
337
|
+
</div>
|
|
338
|
+
);
|
|
333
339
|
}
|
|
334
340
|
```
|
|
335
341
|
|
|
@@ -339,10 +345,10 @@ Audio tracks with an `id` automatically persist their state:
|
|
|
339
345
|
|
|
340
346
|
```typescript
|
|
341
347
|
// Create audio with ID for persistence
|
|
342
|
-
const music = createAudio(
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
348
|
+
const music = createAudio("/audio/theme.mp3", {
|
|
349
|
+
id: "theme-music",
|
|
350
|
+
volume: 0.7,
|
|
351
|
+
loop: true,
|
|
346
352
|
});
|
|
347
353
|
|
|
348
354
|
// State is automatically saved when it changes
|
|
@@ -351,13 +357,14 @@ music.setVolume(0.5);
|
|
|
351
357
|
// State saved automatically
|
|
352
358
|
|
|
353
359
|
// On game restart/reload
|
|
354
|
-
const music = createAudio(
|
|
355
|
-
|
|
360
|
+
const music = createAudio("/audio/theme.mp3", {
|
|
361
|
+
id: "theme-music", // Same ID
|
|
356
362
|
});
|
|
357
363
|
music.load(); // Restores volume, position, playing state
|
|
358
364
|
```
|
|
359
365
|
|
|
360
366
|
**What Gets Persisted:**
|
|
367
|
+
|
|
361
368
|
- Volume level
|
|
362
369
|
- Loop setting
|
|
363
370
|
- Playback rate
|
|
@@ -372,19 +379,16 @@ music.load(); // Restores volume, position, playing state
|
|
|
372
379
|
#### Background Music with Crossfade
|
|
373
380
|
|
|
374
381
|
```typescript
|
|
375
|
-
const oldMusic = AudioManager.getTrackById(
|
|
376
|
-
const newMusic = createAudio(
|
|
377
|
-
|
|
378
|
-
|
|
382
|
+
const oldMusic = AudioManager.getTrackById("current-music");
|
|
383
|
+
const newMusic = createAudio("/audio/new-theme.mp3", {
|
|
384
|
+
id: "current-music",
|
|
385
|
+
loop: true,
|
|
379
386
|
});
|
|
380
387
|
|
|
381
388
|
// Crossfade between tracks
|
|
382
389
|
if (oldMusic) {
|
|
383
|
-
|
|
384
|
-
oldMusic.
|
|
385
|
-
newMusic.fadeIn(1000)
|
|
386
|
-
]);
|
|
387
|
-
oldMusic.dispose();
|
|
390
|
+
await Promise.all([oldMusic.fadeOut(1000), newMusic.fadeIn(1000)]);
|
|
391
|
+
oldMusic.dispose();
|
|
388
392
|
}
|
|
389
393
|
```
|
|
390
394
|
|
|
@@ -393,32 +397,32 @@ if (oldMusic) {
|
|
|
393
397
|
```typescript
|
|
394
398
|
// Create sound effect without ID (no persistence needed)
|
|
395
399
|
function playSoundEffect(src: string) {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
400
|
+
const sfx = createAudio(src, {
|
|
401
|
+
volume: 0.8,
|
|
402
|
+
});
|
|
399
403
|
|
|
400
|
-
|
|
404
|
+
sfx.play();
|
|
401
405
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
+
// Auto-cleanup when finished
|
|
407
|
+
sfx.audioElement.addEventListener("ended", () => {
|
|
408
|
+
sfx.dispose();
|
|
409
|
+
});
|
|
406
410
|
}
|
|
407
411
|
|
|
408
|
-
playSoundEffect(
|
|
412
|
+
playSoundEffect("/audio/click.mp3");
|
|
409
413
|
```
|
|
410
414
|
|
|
411
415
|
#### Pause Audio During Dialogue
|
|
412
416
|
|
|
413
417
|
```typescript
|
|
414
418
|
function showDialogue() {
|
|
415
|
-
|
|
416
|
-
|
|
419
|
+
// Pause background music
|
|
420
|
+
AudioManager.pauseAll();
|
|
417
421
|
|
|
418
|
-
|
|
422
|
+
// Show dialogue...
|
|
419
423
|
|
|
420
|
-
|
|
421
|
-
|
|
424
|
+
// Resume when done
|
|
425
|
+
AudioManager.resumeAll();
|
|
422
426
|
}
|
|
423
427
|
```
|
|
424
428
|
|
|
@@ -427,25 +431,29 @@ function showDialogue() {
|
|
|
427
431
|
Modern browsers restrict audio autoplay without user interaction. The audio system handles this gracefully:
|
|
428
432
|
|
|
429
433
|
```typescript
|
|
430
|
-
const music = createAudio(
|
|
431
|
-
|
|
434
|
+
const music = createAudio("/audio/theme.mp3", {
|
|
435
|
+
autoPlay: true, // May be blocked by browser
|
|
432
436
|
});
|
|
433
437
|
|
|
434
438
|
// Autoplay failures are logged but don't throw errors
|
|
435
439
|
// Manually play after user interaction:
|
|
436
|
-
document.addEventListener(
|
|
437
|
-
|
|
438
|
-
|
|
440
|
+
document.addEventListener(
|
|
441
|
+
"click",
|
|
442
|
+
async () => {
|
|
443
|
+
await music.play(); // Will work after user interaction
|
|
444
|
+
},
|
|
445
|
+
{ once: true }
|
|
446
|
+
);
|
|
439
447
|
```
|
|
440
448
|
|
|
441
449
|
### TypeScript Types
|
|
442
450
|
|
|
443
451
|
```typescript
|
|
444
452
|
import type {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
} from
|
|
453
|
+
AudioOptions,
|
|
454
|
+
AudioState,
|
|
455
|
+
AudioSaveState,
|
|
456
|
+
} from "@react-text-game/core/audio";
|
|
449
457
|
|
|
450
458
|
// All types include comprehensive JSDoc documentation
|
|
451
459
|
```
|
|
@@ -453,11 +461,13 @@ import type {
|
|
|
453
461
|
### API Reference
|
|
454
462
|
|
|
455
463
|
**createAudio(src, options?)**
|
|
464
|
+
|
|
456
465
|
- `src: string` - Audio file URL
|
|
457
466
|
- `options?: AudioOptions` - Configuration options
|
|
458
467
|
- Returns: `AudioTrack`
|
|
459
468
|
|
|
460
469
|
**AudioTrack Methods:**
|
|
470
|
+
|
|
461
471
|
- `play(): Promise<void>` - Start playback
|
|
462
472
|
- `pause(): void` - Pause playback
|
|
463
473
|
- `resume(): void` - Resume from pause
|
|
@@ -475,6 +485,7 @@ import type {
|
|
|
475
485
|
- `dispose(): void` - Clean up and remove
|
|
476
486
|
|
|
477
487
|
**AudioManager Methods:**
|
|
488
|
+
|
|
478
489
|
- `setMasterVolume(volume: number): void` - Set master volume
|
|
479
490
|
- `getMasterVolume(): number` - Get master volume
|
|
480
491
|
- `muteAll(): void` - Mute all tracks
|
|
@@ -495,28 +506,28 @@ The core engine ships with first-class i18n based on `i18next` and `react-i18nex
|
|
|
495
506
|
Pass an `I18nConfig` via the `translations` field when you call `Game.init()`:
|
|
496
507
|
|
|
497
508
|
```ts
|
|
498
|
-
import type { I18nConfig } from
|
|
509
|
+
import type { I18nConfig } from "@react-text-game/core/i18n";
|
|
499
510
|
|
|
500
511
|
const translations: I18nConfig = {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
512
|
+
defaultLanguage: "en",
|
|
513
|
+
fallbackLanguage: "en",
|
|
514
|
+
debug: false,
|
|
515
|
+
resources: {
|
|
516
|
+
en: {
|
|
517
|
+
passages: { intro: "Welcome to the game" },
|
|
518
|
+
common: { save: "Save", load: "Load" },
|
|
519
|
+
},
|
|
520
|
+
es: {
|
|
521
|
+
passages: { intro: "¡Bienvenido al juego!" },
|
|
522
|
+
},
|
|
508
523
|
},
|
|
509
|
-
|
|
510
|
-
passages: { intro: '¡Bienvenido al juego!' }
|
|
511
|
-
}
|
|
512
|
-
},
|
|
513
|
-
modules: []
|
|
524
|
+
modules: [],
|
|
514
525
|
};
|
|
515
526
|
|
|
516
527
|
await Game.init({
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
528
|
+
gameName: "My Adventure",
|
|
529
|
+
translations,
|
|
530
|
+
// ...other options
|
|
520
531
|
});
|
|
521
532
|
```
|
|
522
533
|
|
|
@@ -531,23 +542,27 @@ A saved language preference is loaded from the settings store before i18next ini
|
|
|
531
542
|
Use the `useGameTranslation` hook from `@react-text-game/core/i18n`:
|
|
532
543
|
|
|
533
544
|
```tsx
|
|
534
|
-
import { useGameTranslation } from
|
|
545
|
+
import { useGameTranslation } from "@react-text-game/core/i18n";
|
|
535
546
|
|
|
536
547
|
export function LanguageSwitcher() {
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
548
|
+
const { t, languages, currentLanguage, changeLanguage } =
|
|
549
|
+
useGameTranslation("common");
|
|
550
|
+
|
|
551
|
+
return (
|
|
552
|
+
<div>
|
|
553
|
+
<p>{t("currentLanguage", { language: currentLanguage })}</p>
|
|
554
|
+
<select
|
|
555
|
+
value={currentLanguage}
|
|
556
|
+
onChange={(event) => changeLanguage(event.target.value)}
|
|
557
|
+
>
|
|
558
|
+
{languages.map((lang) => (
|
|
559
|
+
<option key={lang} value={lang}>
|
|
560
|
+
{lang}
|
|
561
|
+
</option>
|
|
562
|
+
))}
|
|
563
|
+
</select>
|
|
564
|
+
</div>
|
|
565
|
+
);
|
|
551
566
|
}
|
|
552
567
|
```
|
|
553
568
|
|
|
@@ -558,10 +573,10 @@ The hook filters out the `cimode` debug language unless you enable `debug` and p
|
|
|
558
573
|
For game logic or utilities, grab a namespace-specific translator with `getGameTranslation`:
|
|
559
574
|
|
|
560
575
|
```ts
|
|
561
|
-
import { getGameTranslation } from
|
|
576
|
+
import { getGameTranslation } from "@react-text-game/core/i18n";
|
|
562
577
|
|
|
563
|
-
const t = getGameTranslation(
|
|
564
|
-
const intro = t(
|
|
578
|
+
const t = getGameTranslation("passages");
|
|
579
|
+
const intro = t("forest.description");
|
|
565
580
|
```
|
|
566
581
|
|
|
567
582
|
### UI package integration
|
|
@@ -584,7 +599,7 @@ The `Game` class is the central orchestrator that manages:
|
|
|
584
599
|
```typescript
|
|
585
600
|
// Initialize the game (REQUIRED)
|
|
586
601
|
await Game.init({
|
|
587
|
-
|
|
602
|
+
// your options
|
|
588
603
|
});
|
|
589
604
|
|
|
590
605
|
// Register entities
|
|
@@ -594,7 +609,7 @@ Game.registerEntity(player, inventory, quest);
|
|
|
594
609
|
Game.registerPassage(intro, chapter1, finalBattle);
|
|
595
610
|
|
|
596
611
|
// Navigate
|
|
597
|
-
Game.jumpTo(
|
|
612
|
+
Game.jumpTo("chapter1");
|
|
598
613
|
|
|
599
614
|
// Save/Load
|
|
600
615
|
const savedState = Game.getState();
|
|
@@ -619,19 +634,19 @@ wraps it in a `SimpleObject` that:
|
|
|
619
634
|
- Requires explicit `save()` calls so you stay in control of persistence cadence
|
|
620
635
|
|
|
621
636
|
```typescript
|
|
622
|
-
import { createEntity } from
|
|
623
|
-
|
|
624
|
-
const player = createEntity(
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
637
|
+
import { createEntity } from "@react-text-game/core";
|
|
638
|
+
|
|
639
|
+
const player = createEntity("player", {
|
|
640
|
+
name: "Hero",
|
|
641
|
+
health: 100,
|
|
642
|
+
inventory: {
|
|
643
|
+
gold: 50,
|
|
644
|
+
items: [] as string[],
|
|
645
|
+
},
|
|
631
646
|
});
|
|
632
647
|
|
|
633
648
|
player.health -= 5; // direct property access
|
|
634
|
-
player.inventory.items.push(
|
|
649
|
+
player.inventory.items.push("sword");
|
|
635
650
|
player.save(); // persist changes when you decide to
|
|
636
651
|
```
|
|
637
652
|
|
|
@@ -642,20 +657,20 @@ Prefer a class-based design, private fields, or inheritance? Extend
|
|
|
642
657
|
available:
|
|
643
658
|
|
|
644
659
|
```typescript
|
|
645
|
-
import { BaseGameObject } from
|
|
660
|
+
import { BaseGameObject } from "@react-text-game/core";
|
|
646
661
|
|
|
647
662
|
class Inventory extends BaseGameObject<{ items: string[] }> {
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
663
|
+
constructor() {
|
|
664
|
+
super({
|
|
665
|
+
id: "inventory",
|
|
666
|
+
variables: { items: [] },
|
|
667
|
+
});
|
|
668
|
+
}
|
|
654
669
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
670
|
+
addItem(item: string) {
|
|
671
|
+
this._variables.items.push(item);
|
|
672
|
+
this.save();
|
|
673
|
+
}
|
|
659
674
|
}
|
|
660
675
|
```
|
|
661
676
|
|
|
@@ -668,69 +683,74 @@ Passages represent different screens or scenes in your game. Three types are ava
|
|
|
668
683
|
Text-based narrative passages with rich components:
|
|
669
684
|
|
|
670
685
|
```typescript
|
|
671
|
-
import { newStory } from
|
|
672
|
-
|
|
673
|
-
const myStory = newStory(
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
686
|
+
import { newStory } from "@react-text-game/core";
|
|
687
|
+
|
|
688
|
+
const myStory = newStory(
|
|
689
|
+
"my-story",
|
|
690
|
+
(props) => [
|
|
691
|
+
{
|
|
692
|
+
type: "header",
|
|
693
|
+
content: "Chapter 1",
|
|
694
|
+
props: { level: 1 },
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
type: "text",
|
|
698
|
+
content: "Once upon a time...",
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
type: "image",
|
|
702
|
+
content: "/assets/scene.jpg",
|
|
703
|
+
props: { alt: "A beautiful scene" },
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
type: "video",
|
|
707
|
+
content: "/assets/intro.mp4",
|
|
708
|
+
props: { controls: true, autoPlay: false },
|
|
709
|
+
},
|
|
710
|
+
{
|
|
711
|
+
type: "conversation",
|
|
712
|
+
content: [
|
|
713
|
+
{
|
|
714
|
+
content: "Hello there!",
|
|
715
|
+
who: { name: "NPC", avatar: "/avatars/npc.png" },
|
|
716
|
+
side: "left",
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
content: "Hi!",
|
|
720
|
+
who: { name: "Player" },
|
|
721
|
+
side: "right",
|
|
722
|
+
},
|
|
723
|
+
],
|
|
724
|
+
props: { variant: "messenger" },
|
|
725
|
+
appearance: "atOnce",
|
|
726
|
+
},
|
|
727
|
+
{
|
|
728
|
+
type: "actions",
|
|
729
|
+
content: [
|
|
730
|
+
{
|
|
731
|
+
label: "Continue",
|
|
732
|
+
action: () => Game.jumpTo("chapter-2"),
|
|
733
|
+
color: "primary",
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
label: "Go Back",
|
|
737
|
+
action: () => Game.jumpTo("intro"),
|
|
738
|
+
color: "secondary",
|
|
739
|
+
variant: "bordered",
|
|
740
|
+
},
|
|
741
|
+
],
|
|
742
|
+
props: { direction: "horizontal" },
|
|
743
|
+
},
|
|
706
744
|
],
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
content: [
|
|
713
|
-
{
|
|
714
|
-
label: 'Continue',
|
|
715
|
-
action: () => Game.jumpTo('chapter-2'),
|
|
716
|
-
color: 'primary'
|
|
717
|
-
},
|
|
718
|
-
{
|
|
719
|
-
label: 'Go Back',
|
|
720
|
-
action: () => Game.jumpTo('intro'),
|
|
721
|
-
color: 'secondary',
|
|
722
|
-
variant: 'bordered'
|
|
723
|
-
}
|
|
724
|
-
],
|
|
725
|
-
props: { direction: 'horizontal' }
|
|
726
|
-
}
|
|
727
|
-
], {
|
|
728
|
-
background: { image: '/bg.jpg' },
|
|
729
|
-
classNames: { container: 'story-container' }
|
|
730
|
-
});
|
|
745
|
+
{
|
|
746
|
+
background: { image: "/bg.jpg" },
|
|
747
|
+
classNames: { container: "story-container" },
|
|
748
|
+
}
|
|
749
|
+
);
|
|
731
750
|
```
|
|
732
751
|
|
|
733
752
|
**Available Components:**
|
|
753
|
+
|
|
734
754
|
- `text` - Text content with ReactNode support and custom styling
|
|
735
755
|
- `header` - Semantic headers (h1-h6) with configurable levels
|
|
736
756
|
- `image` - Images with built-in modal viewer and custom click handlers
|
|
@@ -744,78 +764,85 @@ const myStory = newStory('my-story', (props) => [
|
|
|
744
764
|
Map-based interactive passages with hotspots:
|
|
745
765
|
|
|
746
766
|
```typescript
|
|
747
|
-
import { newInteractiveMap } from
|
|
748
|
-
|
|
749
|
-
const worldMap = newInteractiveMap(
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
767
|
+
import { newInteractiveMap } from "@react-text-game/core";
|
|
768
|
+
|
|
769
|
+
const worldMap = newInteractiveMap("world-map", {
|
|
770
|
+
caption: "World Map",
|
|
771
|
+
image: "/maps/world.jpg",
|
|
772
|
+
bgImage: "/maps/world-bg.jpg",
|
|
773
|
+
props: { bgOpacity: 0.3 },
|
|
774
|
+
hotspots: [
|
|
775
|
+
// Map label hotspot - positioned on the map
|
|
776
|
+
{
|
|
777
|
+
type: "label",
|
|
778
|
+
content: "Village",
|
|
779
|
+
position: { x: 30, y: 40 }, // Percentage-based (0-100)
|
|
780
|
+
action: () => Game.jumpTo("village"),
|
|
781
|
+
props: { color: "primary", variant: "solid" },
|
|
782
|
+
},
|
|
783
|
+
// Map image hotspot - with state-dependent images
|
|
784
|
+
{
|
|
785
|
+
type: "image",
|
|
786
|
+
content: {
|
|
787
|
+
idle: "/icons/chest.png",
|
|
788
|
+
hover: "/icons/chest-glow.png",
|
|
789
|
+
active: "/icons/chest-open.png",
|
|
790
|
+
disabled: "/icons/chest-locked.png",
|
|
791
|
+
},
|
|
792
|
+
position: { x: 60, y: 70 },
|
|
793
|
+
action: () => openChest(),
|
|
794
|
+
isDisabled: () => !player.hasKey,
|
|
795
|
+
tooltip: {
|
|
796
|
+
content: () => (player.hasKey ? "Open chest" : "Locked"),
|
|
797
|
+
position: "top",
|
|
798
|
+
},
|
|
799
|
+
props: { zoom: "150%" },
|
|
800
|
+
},
|
|
801
|
+
// Conditional hotspot - only visible if discovered
|
|
802
|
+
() =>
|
|
803
|
+
player.hasDiscovered("forest")
|
|
804
|
+
? {
|
|
805
|
+
type: "label",
|
|
806
|
+
content: "Forest",
|
|
807
|
+
position: { x: 80, y: 50 },
|
|
808
|
+
action: () => Game.jumpTo("forest"),
|
|
809
|
+
}
|
|
810
|
+
: undefined,
|
|
811
|
+
// Side hotspot - positioned on edge
|
|
812
|
+
{
|
|
813
|
+
type: "label",
|
|
814
|
+
content: "Menu",
|
|
815
|
+
position: "top", // top/bottom/left/right
|
|
816
|
+
action: () => openMenu(),
|
|
817
|
+
},
|
|
818
|
+
// Context menu - multiple choices at a location
|
|
819
|
+
{
|
|
820
|
+
type: "menu",
|
|
821
|
+
position: { x: 50, y: 50 },
|
|
822
|
+
direction: "vertical",
|
|
823
|
+
items: [
|
|
824
|
+
{ type: "label", content: "Examine", action: () => examine() },
|
|
825
|
+
{ type: "label", content: "Take", action: () => take() },
|
|
826
|
+
() =>
|
|
827
|
+
player.hasMagic
|
|
828
|
+
? {
|
|
829
|
+
type: "label",
|
|
830
|
+
content: "Cast Spell",
|
|
831
|
+
action: () => castSpell(),
|
|
832
|
+
}
|
|
833
|
+
: undefined,
|
|
834
|
+
],
|
|
835
|
+
},
|
|
836
|
+
],
|
|
837
|
+
classNames: {
|
|
838
|
+
container: "bg-gradient-to-b from-sky-900 to-indigo-900",
|
|
839
|
+
topHotspots: "bg-muted/50 backdrop-blur-sm",
|
|
794
840
|
},
|
|
795
|
-
// Context menu - multiple choices at a location
|
|
796
|
-
{
|
|
797
|
-
type: 'menu',
|
|
798
|
-
position: { x: 50, y: 50 },
|
|
799
|
-
direction: 'vertical',
|
|
800
|
-
items: [
|
|
801
|
-
{ type: 'label', content: 'Examine', action: () => examine() },
|
|
802
|
-
{ type: 'label', content: 'Take', action: () => take() },
|
|
803
|
-
() => player.hasMagic ? {
|
|
804
|
-
type: 'label',
|
|
805
|
-
content: 'Cast Spell',
|
|
806
|
-
action: () => castSpell()
|
|
807
|
-
} : undefined
|
|
808
|
-
]
|
|
809
|
-
}
|
|
810
|
-
],
|
|
811
|
-
classNames: {
|
|
812
|
-
container: 'bg-gradient-to-b from-sky-900 to-indigo-900',
|
|
813
|
-
topHotspots: 'bg-muted/50 backdrop-blur-sm'
|
|
814
|
-
}
|
|
815
841
|
});
|
|
816
842
|
```
|
|
817
843
|
|
|
818
844
|
**Hotspot Types:**
|
|
845
|
+
|
|
819
846
|
- `MapLabelHotspot` - Text buttons positioned on map using percentage coordinates (x/y: 0-100)
|
|
820
847
|
- `MapImageHotspot` - Image buttons with state variants (idle/hover/active/disabled) and zoom support
|
|
821
848
|
- `SideLabelHotspot` - Text buttons on map edges (top/bottom/left/right)
|
|
@@ -823,6 +850,7 @@ const worldMap = newInteractiveMap('world-map', {
|
|
|
823
850
|
- `MapMenu` - Contextual menu with multiple items at a specific position
|
|
824
851
|
|
|
825
852
|
**Dynamic Features:**
|
|
853
|
+
|
|
826
854
|
- Hotspots can be functions returning `undefined` for conditional visibility
|
|
827
855
|
- Images and positions support dynamic functions: `image: () => '/maps/' + season + '.jpg'`
|
|
828
856
|
- Disabled states with custom tooltips explaining why actions are unavailable
|
|
@@ -832,14 +860,15 @@ const worldMap = newInteractiveMap('world-map', {
|
|
|
832
860
|
Custom React components as passages:
|
|
833
861
|
|
|
834
862
|
```tsx
|
|
835
|
-
import { newWidget } from
|
|
836
|
-
|
|
837
|
-
const customUI = newWidget(
|
|
838
|
-
|
|
839
|
-
<
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
863
|
+
import { newWidget } from "@react-text-game/core";
|
|
864
|
+
|
|
865
|
+
const customUI = newWidget(
|
|
866
|
+
"custom-ui",
|
|
867
|
+
<div>
|
|
868
|
+
<h1>Custom Interface</h1>
|
|
869
|
+
<MyCustomComponent />
|
|
870
|
+
</div>
|
|
871
|
+
);
|
|
843
872
|
```
|
|
844
873
|
|
|
845
874
|
### Storage
|
|
@@ -847,13 +876,13 @@ const customUI = newWidget('custom-ui', (
|
|
|
847
876
|
JSONPath-based storage system using the `jsonpath` library:
|
|
848
877
|
|
|
849
878
|
```typescript
|
|
850
|
-
import { Storage } from
|
|
879
|
+
import { Storage } from "@react-text-game/core";
|
|
851
880
|
|
|
852
881
|
// Get values
|
|
853
|
-
const health = Storage.getValue<number>(
|
|
882
|
+
const health = Storage.getValue<number>("$.player.health");
|
|
854
883
|
|
|
855
884
|
// Set values
|
|
856
|
-
Storage.setValue(
|
|
885
|
+
Storage.setValue("$.player.health", 75);
|
|
857
886
|
|
|
858
887
|
// Full state
|
|
859
888
|
const state = Storage.getState();
|
|
@@ -861,6 +890,7 @@ Storage.setState(state);
|
|
|
861
890
|
```
|
|
862
891
|
|
|
863
892
|
**Key Features:**
|
|
893
|
+
|
|
864
894
|
- JSONPath queries for flexible data access
|
|
865
895
|
- Protected system paths (prefixed with `$._system`)
|
|
866
896
|
- Automatic path creation
|
|
@@ -874,18 +904,19 @@ The engine includes a comprehensive save/load system built on IndexedDB (via Dex
|
|
|
874
904
|
|
|
875
905
|
```typescript
|
|
876
906
|
import {
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
} from
|
|
907
|
+
useSaveSlots,
|
|
908
|
+
useSaveGame,
|
|
909
|
+
useLoadGame,
|
|
910
|
+
useDeleteGame,
|
|
911
|
+
useLastLoadGame,
|
|
912
|
+
useExportSaves,
|
|
913
|
+
useImportSaves,
|
|
914
|
+
useRestartGame,
|
|
915
|
+
} from "@react-text-game/core/saves";
|
|
886
916
|
```
|
|
887
917
|
|
|
888
918
|
**Features:**
|
|
919
|
+
|
|
889
920
|
- **Persistent Storage** - IndexedDB for browser-based saves
|
|
890
921
|
- **Multiple Save Slots** - Unlimited save slots with metadata
|
|
891
922
|
- **Export/Import** - Encrypted file export/import (`.sx` format)
|
|
@@ -899,20 +930,26 @@ import {
|
|
|
899
930
|
|
|
900
931
|
```tsx
|
|
901
932
|
function SavesList() {
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
933
|
+
const slots = useSaveSlots({ count: 5 });
|
|
934
|
+
|
|
935
|
+
return (
|
|
936
|
+
<div>
|
|
937
|
+
{slots.map((slot, index) => (
|
|
938
|
+
<div key={index}>
|
|
939
|
+
<p>
|
|
940
|
+
Slot {index}: {slot.data ? "Saved" : "Empty"}
|
|
941
|
+
</p>
|
|
942
|
+
<button onClick={() => slot.save()}>Save</button>
|
|
943
|
+
<button onClick={() => slot.load()} disabled={!slot.data}>
|
|
944
|
+
Load
|
|
945
|
+
</button>
|
|
946
|
+
<button onClick={() => slot.delete()} disabled={!slot.data}>
|
|
947
|
+
Delete
|
|
948
|
+
</button>
|
|
949
|
+
</div>
|
|
950
|
+
))}
|
|
912
951
|
</div>
|
|
913
|
-
|
|
914
|
-
</div>
|
|
915
|
-
);
|
|
952
|
+
);
|
|
916
953
|
}
|
|
917
954
|
```
|
|
918
955
|
|
|
@@ -920,20 +957,16 @@ function SavesList() {
|
|
|
920
957
|
|
|
921
958
|
```tsx
|
|
922
959
|
function SaveButton({ slotNumber }) {
|
|
923
|
-
|
|
960
|
+
const saveGame = useSaveGame();
|
|
924
961
|
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
962
|
+
const handleSave = async () => {
|
|
963
|
+
const result = await saveGame(slotNumber);
|
|
964
|
+
if (result?.success === false) {
|
|
965
|
+
alert(result.message);
|
|
966
|
+
}
|
|
967
|
+
};
|
|
931
968
|
|
|
932
|
-
|
|
933
|
-
<button onClick={handleSave}>
|
|
934
|
-
Save to Slot {slotNumber}
|
|
935
|
-
</button>
|
|
936
|
-
);
|
|
969
|
+
return <button onClick={handleSave}>Save to Slot {slotNumber}</button>;
|
|
937
970
|
}
|
|
938
971
|
```
|
|
939
972
|
|
|
@@ -941,16 +974,16 @@ function SaveButton({ slotNumber }) {
|
|
|
941
974
|
|
|
942
975
|
```tsx
|
|
943
976
|
function LoadButton({ saveId }) {
|
|
944
|
-
|
|
977
|
+
const loadGame = useLoadGame();
|
|
945
978
|
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
979
|
+
const handleLoad = async () => {
|
|
980
|
+
const result = await loadGame(saveId);
|
|
981
|
+
if (result?.success === false) {
|
|
982
|
+
alert(result.message);
|
|
983
|
+
}
|
|
984
|
+
};
|
|
952
985
|
|
|
953
|
-
|
|
986
|
+
return <button onClick={handleLoad}>Load Game</button>;
|
|
954
987
|
}
|
|
955
988
|
```
|
|
956
989
|
|
|
@@ -958,16 +991,16 @@ function LoadButton({ saveId }) {
|
|
|
958
991
|
|
|
959
992
|
```tsx
|
|
960
993
|
function DeleteButton({ saveId }) {
|
|
961
|
-
|
|
994
|
+
const deleteGame = useDeleteGame();
|
|
962
995
|
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
996
|
+
const handleDelete = async () => {
|
|
997
|
+
const result = await deleteGame(saveId);
|
|
998
|
+
if (result?.success === false) {
|
|
999
|
+
alert(result.message);
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
969
1002
|
|
|
970
|
-
|
|
1003
|
+
return <button onClick={handleDelete}>Delete Save</button>;
|
|
971
1004
|
}
|
|
972
1005
|
```
|
|
973
1006
|
|
|
@@ -975,17 +1008,17 @@ function DeleteButton({ saveId }) {
|
|
|
975
1008
|
|
|
976
1009
|
```tsx
|
|
977
1010
|
function ContinueButton() {
|
|
978
|
-
|
|
1011
|
+
const { hasLastSave, loadLastGame, isLoading } = useLastLoadGame();
|
|
979
1012
|
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1013
|
+
if (isLoading) {
|
|
1014
|
+
return <div>Loading...</div>;
|
|
1015
|
+
}
|
|
983
1016
|
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1017
|
+
return (
|
|
1018
|
+
<button onClick={loadLastGame} disabled={!hasLastSave}>
|
|
1019
|
+
Continue Last Game
|
|
1020
|
+
</button>
|
|
1021
|
+
);
|
|
989
1022
|
}
|
|
990
1023
|
```
|
|
991
1024
|
|
|
@@ -993,18 +1026,18 @@ function ContinueButton() {
|
|
|
993
1026
|
|
|
994
1027
|
```tsx
|
|
995
1028
|
function ExportButton() {
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1029
|
+
const exportSaves = useExportSaves();
|
|
1030
|
+
|
|
1031
|
+
const handleExport = async () => {
|
|
1032
|
+
const result = await exportSaves();
|
|
1033
|
+
if (result.success) {
|
|
1034
|
+
console.log("Saves exported successfully");
|
|
1035
|
+
} else {
|
|
1036
|
+
alert(`Export failed: ${result.error}`);
|
|
1037
|
+
}
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
return <button onClick={handleExport}>Export Saves</button>;
|
|
1008
1041
|
}
|
|
1009
1042
|
```
|
|
1010
1043
|
|
|
@@ -1012,18 +1045,18 @@ function ExportButton() {
|
|
|
1012
1045
|
|
|
1013
1046
|
```tsx
|
|
1014
1047
|
function ImportButton() {
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1048
|
+
const importSaves = useImportSaves();
|
|
1049
|
+
|
|
1050
|
+
const handleImport = async () => {
|
|
1051
|
+
const result = await importSaves();
|
|
1052
|
+
if (result.success) {
|
|
1053
|
+
console.log(`Imported ${result.count} saves`);
|
|
1054
|
+
} else {
|
|
1055
|
+
alert(`Import failed: ${result.error}`);
|
|
1056
|
+
}
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1059
|
+
return <button onClick={handleImport}>Import Saves</button>;
|
|
1027
1060
|
}
|
|
1028
1061
|
```
|
|
1029
1062
|
|
|
@@ -1031,13 +1064,9 @@ function ImportButton() {
|
|
|
1031
1064
|
|
|
1032
1065
|
```tsx
|
|
1033
1066
|
function RestartButton() {
|
|
1034
|
-
|
|
1067
|
+
const restartGame = useRestartGame();
|
|
1035
1068
|
|
|
1036
|
-
|
|
1037
|
-
<button onClick={restartGame}>
|
|
1038
|
-
Restart Game
|
|
1039
|
-
</button>
|
|
1040
|
-
);
|
|
1069
|
+
return <button onClick={restartGame}>Restart Game</button>;
|
|
1041
1070
|
}
|
|
1042
1071
|
```
|
|
1043
1072
|
|
|
@@ -1047,15 +1076,15 @@ For advanced use cases, you can access the database directly:
|
|
|
1047
1076
|
|
|
1048
1077
|
```typescript
|
|
1049
1078
|
import {
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
} from
|
|
1079
|
+
saveGame,
|
|
1080
|
+
loadGame,
|
|
1081
|
+
getAllSaves,
|
|
1082
|
+
deleteSave,
|
|
1083
|
+
db,
|
|
1084
|
+
} from "@react-text-game/core/saves";
|
|
1056
1085
|
|
|
1057
1086
|
// Save game manually
|
|
1058
|
-
await saveGame(
|
|
1087
|
+
await saveGame("my-save", gameData, "Description", screenshotBase64);
|
|
1059
1088
|
|
|
1060
1089
|
// Load by ID
|
|
1061
1090
|
const save = await loadGame(1);
|
|
@@ -1067,12 +1096,13 @@ const allSaves = await getAllSaves();
|
|
|
1067
1096
|
await deleteSave(1);
|
|
1068
1097
|
|
|
1069
1098
|
// Direct Dexie access
|
|
1070
|
-
await db.saves.where(
|
|
1099
|
+
await db.saves.where("name").equals("my-save").first();
|
|
1071
1100
|
```
|
|
1072
1101
|
|
|
1073
1102
|
#### Save File Encryption
|
|
1074
1103
|
|
|
1075
1104
|
Exported save files are encrypted using AES encryption with PBKDF2 key derivation:
|
|
1105
|
+
|
|
1076
1106
|
- **Algorithm**: AES-256-CBC
|
|
1077
1107
|
- **Key Derivation**: PBKDF2 with 1000 iterations
|
|
1078
1108
|
- **Salt & IV**: Randomly generated for each export
|
|
@@ -1082,21 +1112,39 @@ Exported save files are encrypted using AES encryption with PBKDF2 key derivatio
|
|
|
1082
1112
|
|
|
1083
1113
|
#### useCurrentPassage
|
|
1084
1114
|
|
|
1085
|
-
Get the current passage with reactive updates:
|
|
1115
|
+
Get the current passage with reactive updates. Returns a tuple containing the current passage and a unique render ID that changes on each navigation:
|
|
1086
1116
|
|
|
1087
1117
|
```tsx
|
|
1088
|
-
import { useCurrentPassage } from
|
|
1118
|
+
import { useCurrentPassage } from "@react-text-game/core";
|
|
1089
1119
|
|
|
1090
1120
|
function GameScreen() {
|
|
1091
|
-
|
|
1121
|
+
const [passage, renderId] = useCurrentPassage();
|
|
1092
1122
|
|
|
1093
|
-
|
|
1123
|
+
if (!passage) return <div>Loading...</div>;
|
|
1094
1124
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1125
|
+
// Render based on passage type
|
|
1126
|
+
if (passage.type === "story") {
|
|
1127
|
+
const { components } = passage.display();
|
|
1128
|
+
// Render story components
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
```
|
|
1132
|
+
|
|
1133
|
+
**Render ID Purpose:**
|
|
1134
|
+
The `renderId` is automatically generated on each `Game.jumpTo()` call, ensuring that React components re-render even when navigating to the same passage multiple times. This is useful for forcing component remounts, resetting animations, or clearing component state.
|
|
1135
|
+
|
|
1136
|
+
**Usage Example:**
|
|
1137
|
+
|
|
1138
|
+
```tsx
|
|
1139
|
+
function PassageRenderer() {
|
|
1140
|
+
const [passage, renderId] = useCurrentPassage();
|
|
1141
|
+
|
|
1142
|
+
// Use renderId as a React key to force remount on navigation
|
|
1143
|
+
return (
|
|
1144
|
+
<div key={renderId} className="animate-fade-in">
|
|
1145
|
+
{passage && <PassageContent passage={passage} />}
|
|
1146
|
+
</div>
|
|
1147
|
+
);
|
|
1100
1148
|
}
|
|
1101
1149
|
```
|
|
1102
1150
|
|
|
@@ -1105,17 +1153,17 @@ function GameScreen() {
|
|
|
1105
1153
|
Monitor entity changes with automatic re-renders:
|
|
1106
1154
|
|
|
1107
1155
|
```tsx
|
|
1108
|
-
import { useGameEntity } from
|
|
1156
|
+
import { useGameEntity } from "@react-text-game/core";
|
|
1109
1157
|
|
|
1110
1158
|
function PlayerStats({ player }) {
|
|
1111
|
-
|
|
1159
|
+
const reactivePlayer = useGameEntity(player);
|
|
1112
1160
|
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1161
|
+
return (
|
|
1162
|
+
<div>
|
|
1163
|
+
Health: {reactivePlayer.health}
|
|
1164
|
+
{/* Direct property access stays reactive */}
|
|
1165
|
+
</div>
|
|
1166
|
+
);
|
|
1119
1167
|
}
|
|
1120
1168
|
```
|
|
1121
1169
|
|
|
@@ -1124,12 +1172,12 @@ function PlayerStats({ player }) {
|
|
|
1124
1172
|
Check if game has started:
|
|
1125
1173
|
|
|
1126
1174
|
```tsx
|
|
1127
|
-
import { useGameIsStarted } from
|
|
1175
|
+
import { useGameIsStarted } from "@react-text-game/core";
|
|
1128
1176
|
|
|
1129
1177
|
function GameUI() {
|
|
1130
|
-
|
|
1178
|
+
const isStarted = useGameIsStarted();
|
|
1131
1179
|
|
|
1132
|
-
|
|
1180
|
+
return isStarted ? <GameScreen /> : <MainMenu />;
|
|
1133
1181
|
}
|
|
1134
1182
|
```
|
|
1135
1183
|
|
|
@@ -1147,6 +1195,7 @@ function GameUI() {
|
|
|
1147
1195
|
### Registry Pattern
|
|
1148
1196
|
|
|
1149
1197
|
The engine uses two registries:
|
|
1198
|
+
|
|
1150
1199
|
- `objectRegistry` - Stores all game entities as Valtio proxies
|
|
1151
1200
|
- `passagesRegistry` - Stores all passages
|
|
1152
1201
|
|
|
@@ -1155,6 +1204,7 @@ All objects are automatically wrapped in Valtio proxies for reactive state manag
|
|
|
1155
1204
|
### Save System
|
|
1156
1205
|
|
|
1157
1206
|
The save system consists of:
|
|
1207
|
+
|
|
1158
1208
|
- **Entity State** - Each entity's `_variables` stored at `$.{entityId}`
|
|
1159
1209
|
- **Game State** - Current passage stored at `$._system.game`
|
|
1160
1210
|
- **JSONPath Access** - Flexible queries for any state data
|
|
@@ -1165,6 +1215,7 @@ The save system consists of:
|
|
|
1165
1215
|
### Game
|
|
1166
1216
|
|
|
1167
1217
|
Static methods:
|
|
1218
|
+
|
|
1168
1219
|
- `init(options)` - **Initialize the game (REQUIRED - must be called first)**
|
|
1169
1220
|
- `registerEntity(...objects)` - Register game objects
|
|
1170
1221
|
- `registerPassage(...passages)` - Register passages
|
|
@@ -1180,6 +1231,7 @@ Static methods:
|
|
|
1180
1231
|
- `clearAutoSave()` - Clear auto-saved state
|
|
1181
1232
|
|
|
1182
1233
|
Properties:
|
|
1234
|
+
|
|
1183
1235
|
- `currentPassage` - Get current passage
|
|
1184
1236
|
- `selfState` - Get game internal state
|
|
1185
1237
|
- `options` - Get game options
|
|
@@ -1187,30 +1239,36 @@ Properties:
|
|
|
1187
1239
|
### BaseGameObject
|
|
1188
1240
|
|
|
1189
1241
|
Constructor:
|
|
1242
|
+
|
|
1190
1243
|
- `new BaseGameObject({ id, variables? })`
|
|
1191
1244
|
|
|
1192
1245
|
Properties:
|
|
1246
|
+
|
|
1193
1247
|
- `id` - Unique identifier
|
|
1194
1248
|
- `variables` - Entity variables (readonly)
|
|
1195
1249
|
- `_variables` - Internal variables (protected)
|
|
1196
1250
|
|
|
1197
1251
|
Methods:
|
|
1252
|
+
|
|
1198
1253
|
- `save()` - Save to storage
|
|
1199
1254
|
- `load()` - Load from storage
|
|
1200
1255
|
|
|
1201
1256
|
### Passage Types
|
|
1202
1257
|
|
|
1203
1258
|
**Story:**
|
|
1259
|
+
|
|
1204
1260
|
```typescript
|
|
1205
1261
|
newStory(id: string, content: StoryContent, options?: StoryOptions): Story
|
|
1206
1262
|
```
|
|
1207
1263
|
|
|
1208
1264
|
**Interactive Map:**
|
|
1265
|
+
|
|
1209
1266
|
```typescript
|
|
1210
1267
|
newInteractiveMap(id: string, options: InteractiveMapOptions): InteractiveMap
|
|
1211
1268
|
```
|
|
1212
1269
|
|
|
1213
1270
|
**Widget:**
|
|
1271
|
+
|
|
1214
1272
|
```typescript
|
|
1215
1273
|
newWidget(id: string, content: ReactNode): Widget
|
|
1216
1274
|
```
|
|
@@ -1222,48 +1280,49 @@ Full TypeScript support with comprehensive types and detailed JSDoc documentatio
|
|
|
1222
1280
|
```typescript
|
|
1223
1281
|
// Import types from main package
|
|
1224
1282
|
import type {
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
} from
|
|
1283
|
+
GameSaveState,
|
|
1284
|
+
JsonPath,
|
|
1285
|
+
InitVarsType,
|
|
1286
|
+
PassageType,
|
|
1287
|
+
ButtonColor,
|
|
1288
|
+
ButtonVariant,
|
|
1289
|
+
} from "@react-text-game/core";
|
|
1232
1290
|
|
|
1233
1291
|
// Import story passage types
|
|
1234
1292
|
import type {
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
} from
|
|
1293
|
+
Component,
|
|
1294
|
+
StoryContent,
|
|
1295
|
+
StoryOptions,
|
|
1296
|
+
TextComponent,
|
|
1297
|
+
HeaderComponent,
|
|
1298
|
+
ImageComponent,
|
|
1299
|
+
VideoComponent,
|
|
1300
|
+
ActionsComponent,
|
|
1301
|
+
ConversationComponent,
|
|
1302
|
+
AnotherStoryComponent,
|
|
1303
|
+
ActionType,
|
|
1304
|
+
ConversationBubble,
|
|
1305
|
+
ConversationVariant,
|
|
1306
|
+
ConversationAppearance,
|
|
1307
|
+
} from "@react-text-game/core/passages";
|
|
1250
1308
|
|
|
1251
1309
|
// Import interactive map types
|
|
1252
1310
|
import type {
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
} from
|
|
1311
|
+
InteractiveMapOptions,
|
|
1312
|
+
InteractiveMapType,
|
|
1313
|
+
AnyHotspot,
|
|
1314
|
+
MapLabelHotspot,
|
|
1315
|
+
MapImageHotspot,
|
|
1316
|
+
SideLabelHotspot,
|
|
1317
|
+
SideImageHotspot,
|
|
1318
|
+
MapMenu,
|
|
1319
|
+
LabelHotspot,
|
|
1320
|
+
ImageHotspot,
|
|
1321
|
+
} from "@react-text-game/core/passages";
|
|
1264
1322
|
```
|
|
1265
1323
|
|
|
1266
1324
|
All types include comprehensive JSDoc comments with:
|
|
1325
|
+
|
|
1267
1326
|
- Detailed descriptions of each property
|
|
1268
1327
|
- Usage examples and code snippets
|
|
1269
1328
|
- Default value annotations
|