@vylos/core 0.5.4 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/app/GameShell.vue +1 -0
- package/src/components/core/ForegroundLayer.vue +19 -5
- package/src/engine/core/CheckpointManager.ts +2 -0
- package/src/engine/core/EventRunner.ts +14 -1
- package/src/engine/managers/LocationManager.ts +14 -2
- package/src/engine/types/checkpoint.ts +2 -0
- package/src/engine/types/config.ts +2 -0
- package/src/engine/types/plugin.ts +29 -1
- package/src/index.ts +4 -0
- package/src/setup.ts +211 -0
- package/tests/bootstrap.test.ts +171 -0
package/package.json
CHANGED
|
@@ -92,6 +92,7 @@ const isRunning = computed(() =>
|
|
|
92
92
|
// --- History step helpers ---
|
|
93
93
|
|
|
94
94
|
function applyHistoryStep(step: HistoryStep): void {
|
|
95
|
+
engineState.setForeground(step.foreground ?? null);
|
|
95
96
|
if (step.type === 'say' && step.dialogue) {
|
|
96
97
|
engineState.setDialogue(step.dialogue);
|
|
97
98
|
engineState.setChoices(null);
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<Transition name="fg-fade">
|
|
3
|
-
<
|
|
3
|
+
<div
|
|
4
4
|
v-if="engineState.foreground"
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
class="absolute inset-0 z-10 pointer-events-none overflow-hidden"
|
|
6
|
+
>
|
|
7
|
+
<img
|
|
8
|
+
:src="fgUrl"
|
|
9
|
+
alt=""
|
|
10
|
+
class="fg-blur absolute inset-0 w-full h-full object-cover"
|
|
11
|
+
/>
|
|
12
|
+
<img
|
|
13
|
+
:src="fgUrl"
|
|
14
|
+
alt=""
|
|
15
|
+
class="absolute inset-0 w-full h-full object-contain"
|
|
16
|
+
/>
|
|
17
|
+
</div>
|
|
9
18
|
</Transition>
|
|
10
19
|
</template>
|
|
11
20
|
|
|
@@ -20,6 +29,11 @@ const fgUrl = computed(() => engineState.foreground ? assetUrl(engineState.foreg
|
|
|
20
29
|
</script>
|
|
21
30
|
|
|
22
31
|
<style scoped>
|
|
32
|
+
.fg-blur {
|
|
33
|
+
filter: blur(80px);
|
|
34
|
+
transform: scale(1.2);
|
|
35
|
+
}
|
|
36
|
+
|
|
23
37
|
.fg-fade-enter-active,
|
|
24
38
|
.fg-fade-leave-active {
|
|
25
39
|
transition: opacity 0.3s ease;
|
|
@@ -5,6 +5,7 @@ import type { DialogueState } from '../types/engine';
|
|
|
5
5
|
export interface CaptureDisplayData {
|
|
6
6
|
dialogue?: DialogueState | null;
|
|
7
7
|
background?: string | null;
|
|
8
|
+
foreground?: string | null;
|
|
8
9
|
choiceOptions?: ChoiceOption[];
|
|
9
10
|
}
|
|
10
11
|
|
|
@@ -46,6 +47,7 @@ export class CheckpointManager {
|
|
|
46
47
|
choiceResult,
|
|
47
48
|
dialogue: display?.dialogue ? structuredClone(toRaw(display.dialogue)) : undefined,
|
|
48
49
|
background: display?.background,
|
|
50
|
+
foreground: display?.foreground,
|
|
49
51
|
choiceOptions: display?.choiceOptions ? structuredClone(toRaw(display.choiceOptions)) : undefined,
|
|
50
52
|
};
|
|
51
53
|
|
|
@@ -50,6 +50,7 @@ export interface EventRunnerCallbacks {
|
|
|
50
50
|
export interface HistoryStep {
|
|
51
51
|
type: 'say' | 'choice';
|
|
52
52
|
dialogue?: DialogueState | null;
|
|
53
|
+
foreground?: string | null;
|
|
53
54
|
choiceOptions?: ChoiceOption[];
|
|
54
55
|
choiceResult?: string;
|
|
55
56
|
stepIndex: number;
|
|
@@ -77,6 +78,8 @@ export class EventRunner implements VylosAPI {
|
|
|
77
78
|
private liveDialogue: { text: string; speaker: VylosCharacter | null } | null = null;
|
|
78
79
|
/** Current background path (tracked for checkpoint storage) */
|
|
79
80
|
private currentBackground: string | null = null;
|
|
81
|
+
/** Current foreground path (tracked for checkpoint storage) */
|
|
82
|
+
private currentForeground: string | null = null;
|
|
80
83
|
|
|
81
84
|
/** Snapshot of game state before event started (for redo) */
|
|
82
85
|
private initialState: VylosGameState | null = null;
|
|
@@ -196,6 +199,8 @@ export class EventRunner implements VylosAPI {
|
|
|
196
199
|
await this.runEventExecution(event);
|
|
197
200
|
} finally {
|
|
198
201
|
this.callbacks.onClear();
|
|
202
|
+
logger.debug(`Event ended — clearing foreground`);
|
|
203
|
+
this.callbacks.onSetForeground(null);
|
|
199
204
|
this.initialState = null;
|
|
200
205
|
this.currentEvent = null;
|
|
201
206
|
}
|
|
@@ -223,6 +228,8 @@ export class EventRunner implements VylosAPI {
|
|
|
223
228
|
throw error;
|
|
224
229
|
} finally {
|
|
225
230
|
this.callbacks.onClear();
|
|
231
|
+
logger.debug(`Rollback ended — clearing foreground`);
|
|
232
|
+
this.callbacks.onSetForeground(null);
|
|
226
233
|
}
|
|
227
234
|
}
|
|
228
235
|
|
|
@@ -244,6 +251,8 @@ export class EventRunner implements VylosAPI {
|
|
|
244
251
|
await this.runEventExecution(event);
|
|
245
252
|
} finally {
|
|
246
253
|
this.callbacks.onClear();
|
|
254
|
+
logger.debug(`Resume ended — clearing foreground`);
|
|
255
|
+
this.callbacks.onSetForeground(null);
|
|
247
256
|
this.initialState = null;
|
|
248
257
|
this.currentEvent = null;
|
|
249
258
|
}
|
|
@@ -297,6 +306,7 @@ export class EventRunner implements VylosAPI {
|
|
|
297
306
|
{
|
|
298
307
|
dialogue: { text: resolvedText, speaker, isNarration: !speaker },
|
|
299
308
|
background: this.currentBackground,
|
|
309
|
+
foreground: this.currentForeground,
|
|
300
310
|
},
|
|
301
311
|
);
|
|
302
312
|
|
|
@@ -342,7 +352,7 @@ export class EventRunner implements VylosAPI {
|
|
|
342
352
|
this.callbacks.getState(),
|
|
343
353
|
'choice' as CheckpointType,
|
|
344
354
|
result,
|
|
345
|
-
{ choiceOptions: resolvedOptions },
|
|
355
|
+
{ choiceOptions: resolvedOptions, foreground: this.currentForeground },
|
|
346
356
|
);
|
|
347
357
|
|
|
348
358
|
this.callbacks.onClear();
|
|
@@ -356,6 +366,7 @@ export class EventRunner implements VylosAPI {
|
|
|
356
366
|
}
|
|
357
367
|
|
|
358
368
|
setForeground(path: string | null): void {
|
|
369
|
+
this.currentForeground = path;
|
|
359
370
|
this.callbacks.onSetForeground(path);
|
|
360
371
|
}
|
|
361
372
|
|
|
@@ -438,6 +449,7 @@ export class EventRunner implements VylosAPI {
|
|
|
438
449
|
if (cp.choiceOptions) {
|
|
439
450
|
return {
|
|
440
451
|
type: 'choice',
|
|
452
|
+
foreground: cp.foreground,
|
|
441
453
|
choiceOptions: cp.choiceOptions,
|
|
442
454
|
choiceResult: cp.choiceResult,
|
|
443
455
|
stepIndex: idx,
|
|
@@ -446,6 +458,7 @@ export class EventRunner implements VylosAPI {
|
|
|
446
458
|
return {
|
|
447
459
|
type: 'say',
|
|
448
460
|
dialogue: cp.dialogue,
|
|
461
|
+
foreground: cp.foreground,
|
|
449
462
|
stepIndex: idx,
|
|
450
463
|
};
|
|
451
464
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { VylosLocation, LocationLink, VylosGameState
|
|
1
|
+
import type { VylosLocation, LocationLink, VylosGameState } from '../types';
|
|
2
2
|
import { resolveBackground } from '../utils/TimeHelper';
|
|
3
3
|
import { logger } from '../utils/logger';
|
|
4
4
|
|
|
@@ -22,11 +22,23 @@ export class LocationManager {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/** Set the location link graph */
|
|
25
|
+
/** Set the location link graph (replaces all existing links) */
|
|
26
26
|
setLinks(links: LocationLink[]): void {
|
|
27
27
|
this.links = links;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** Add directed links from one location to one or more others (additive) */
|
|
31
|
+
link(
|
|
32
|
+
fromId: string,
|
|
33
|
+
toIds: string | string[],
|
|
34
|
+
options?: { condition?: (state: VylosGameState) => boolean },
|
|
35
|
+
): void {
|
|
36
|
+
const targets = Array.isArray(toIds) ? toIds : [toIds];
|
|
37
|
+
for (const toId of targets) {
|
|
38
|
+
this.links.push({ from: fromId, to: toId, ...options });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
30
42
|
/** Get a location by ID */
|
|
31
43
|
get(id: string): VylosLocation | undefined {
|
|
32
44
|
return this.locations.get(id);
|
|
@@ -15,6 +15,8 @@ export interface Checkpoint {
|
|
|
15
15
|
dialogue?: DialogueState | null;
|
|
16
16
|
/** Background path at this point */
|
|
17
17
|
background?: string | null;
|
|
18
|
+
/** Foreground path at this point */
|
|
19
|
+
foreground?: string | null;
|
|
18
20
|
/** Available choice options at this step (for history redo) */
|
|
19
21
|
choiceOptions?: ChoiceOption[];
|
|
20
22
|
}
|
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
import type { DependencyContainer } from 'tsyringe';
|
|
2
|
-
import type { Component } from 'vue';
|
|
2
|
+
import type { Component, Ref } from 'vue';
|
|
3
|
+
import type { VylosGameState } from './game-state';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Minimal contract for a game state store.
|
|
7
|
+
* Compatible with the default `useGameStateStore` return type.
|
|
8
|
+
* Projects with custom game state can implement this with additional properties.
|
|
9
|
+
*/
|
|
10
|
+
export interface VylosGameStore {
|
|
11
|
+
/** Reactive game state ref */
|
|
12
|
+
state: Ref<VylosGameState>;
|
|
13
|
+
/** Get current (unwrapped) game state */
|
|
14
|
+
getState(): VylosGameState;
|
|
15
|
+
/** Replace the entire game state */
|
|
16
|
+
setState(newState: VylosGameState): void;
|
|
17
|
+
/** Deep-clone the current state for saving */
|
|
18
|
+
getSnapshot(): VylosGameState;
|
|
19
|
+
/** Restore a previously saved snapshot */
|
|
20
|
+
restoreSnapshot(snapshot: VylosGameState): void;
|
|
21
|
+
/** Reset to default initial state */
|
|
22
|
+
$reset(): void;
|
|
23
|
+
}
|
|
3
24
|
|
|
4
25
|
/** Plugin interface for project-level engine customization */
|
|
5
26
|
export interface VylosPlugin {
|
|
@@ -8,4 +29,11 @@ export interface VylosPlugin {
|
|
|
8
29
|
|
|
9
30
|
/** Override Vue components by component ID */
|
|
10
31
|
components?: Record<string, Component>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Provide a custom Pinia game state store factory.
|
|
35
|
+
* When set, setupVylos will use this instead of the default `useGameStateStore`.
|
|
36
|
+
* Useful for projects that extend VylosGameState with additional fields.
|
|
37
|
+
*/
|
|
38
|
+
gameStore?(pinia?: any): VylosGameStore;
|
|
11
39
|
}
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,10 @@ export { assetUrl } from './utils/assetUrl';
|
|
|
20
20
|
export { useEngineStateStore } from './stores/engineState';
|
|
21
21
|
export { useGameStateStore } from './stores/gameState';
|
|
22
22
|
|
|
23
|
+
// Setup
|
|
24
|
+
export { setupVylos } from './setup';
|
|
25
|
+
export type { SetupOptions } from './setup';
|
|
26
|
+
|
|
23
27
|
// Core engine
|
|
24
28
|
export { Engine } from './engine/core/Engine';
|
|
25
29
|
export type { EngineLoopCallbacks } from './engine/core/Engine';
|
package/src/setup.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { createApp, watch, toRaw } from 'vue';
|
|
3
|
+
import { createPinia } from 'pinia';
|
|
4
|
+
import GameShell from './components/app/GameShell.vue';
|
|
5
|
+
import { createEngine } from './engine/core/EngineFactory';
|
|
6
|
+
import { useEngineStateStore } from './stores/engineState';
|
|
7
|
+
import { useGameStateStore } from './stores/gameState';
|
|
8
|
+
import { ENGINE_INJECT_KEY } from './composables/useEngine';
|
|
9
|
+
import { CONFIG_INJECT_KEY } from './composables/useConfig';
|
|
10
|
+
import { LocationManager } from './engine/managers/LocationManager';
|
|
11
|
+
import { ActionManager } from './engine/managers/ActionManager';
|
|
12
|
+
import { LanguageManager } from './engine/managers/LanguageManager';
|
|
13
|
+
import { EnginePhase } from './engine/types/engine';
|
|
14
|
+
import type { VylosConfig } from './engine/types/config';
|
|
15
|
+
import type { VylosPlugin, VylosGameStore } from './engine/types/plugin';
|
|
16
|
+
import type { VylosLocation } from './engine/types/locations';
|
|
17
|
+
import type { VylosEvent, TextEntry } from './engine/types/events';
|
|
18
|
+
import type { VylosAction } from './engine/types/actions';
|
|
19
|
+
import type { VylosCharacter } from './engine/types/dialogue';
|
|
20
|
+
import type { EventRunnerCallbacks } from './engine/core/EventRunner';
|
|
21
|
+
import type { EngineLoopCallbacks } from './engine/core/Engine';
|
|
22
|
+
|
|
23
|
+
export interface SetupOptions {
|
|
24
|
+
config: VylosConfig;
|
|
25
|
+
plugin?: VylosPlugin;
|
|
26
|
+
locations?: VylosLocation[];
|
|
27
|
+
events?: VylosEvent[];
|
|
28
|
+
actions?: VylosAction[];
|
|
29
|
+
initLinks?: (lm: LocationManager) => void;
|
|
30
|
+
/** When true, skip the MainMenu phase and go directly to Running (triggers startGame). */
|
|
31
|
+
skipMainMenu?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One-call setup function that replaces all main.ts boilerplate.
|
|
36
|
+
* Creates the Vue app, Pinia stores, engine, and wires everything together.
|
|
37
|
+
*/
|
|
38
|
+
export function setupVylos(options: SetupOptions): void {
|
|
39
|
+
const { config, plugin, locations = [], events = [], actions = [], initLinks, skipMainMenu = false } = options;
|
|
40
|
+
|
|
41
|
+
const app = createApp(GameShell);
|
|
42
|
+
const pinia = createPinia();
|
|
43
|
+
app.use(pinia);
|
|
44
|
+
|
|
45
|
+
const gameStore: VylosGameStore = plugin?.gameStore
|
|
46
|
+
? plugin.gameStore(pinia)
|
|
47
|
+
: useGameStateStore(pinia);
|
|
48
|
+
const engineState = useEngineStateStore(pinia);
|
|
49
|
+
|
|
50
|
+
const languageManager = config.languages.length > 1
|
|
51
|
+
? createLanguageManager(config)
|
|
52
|
+
: null;
|
|
53
|
+
const resolveText = buildResolveText(languageManager, config);
|
|
54
|
+
|
|
55
|
+
const locationManager = new LocationManager();
|
|
56
|
+
locationManager.registerAll(locations);
|
|
57
|
+
|
|
58
|
+
if (locations.length > 0 && !locationManager.has(config.defaultLocation)) {
|
|
59
|
+
console.error(
|
|
60
|
+
`[Vylos] Default location "${config.defaultLocation}" is not registered. ` +
|
|
61
|
+
`Registered locations: [${locationManager.getAll().map(l => l.id).join(', ')}]. ` +
|
|
62
|
+
`Check your vylos.config.ts defaultLocation value.`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const actionManager = new ActionManager();
|
|
67
|
+
actionManager.registerAll(actions);
|
|
68
|
+
|
|
69
|
+
const callbacks = buildCallbacks(engineState, gameStore, locationManager, resolveText);
|
|
70
|
+
const engine = createEngine({ callbacks, projectId: config.id, plugin });
|
|
71
|
+
|
|
72
|
+
app.provide(ENGINE_INJECT_KEY, engine);
|
|
73
|
+
app.provide(CONFIG_INJECT_KEY, config);
|
|
74
|
+
app.mount('#app');
|
|
75
|
+
|
|
76
|
+
if (initLinks) {
|
|
77
|
+
initLinks(locationManager);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (skipMainMenu) {
|
|
81
|
+
engineState.setPhase(EnginePhase.Running);
|
|
82
|
+
startGame();
|
|
83
|
+
} else {
|
|
84
|
+
engineState.setPhase(EnginePhase.MainMenu);
|
|
85
|
+
|
|
86
|
+
const stopWatch = watch(() => engineState.phase, (newPhase) => {
|
|
87
|
+
if (newPhase === EnginePhase.Running) {
|
|
88
|
+
stopWatch();
|
|
89
|
+
startGame();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function startGame(): void {
|
|
95
|
+
const startGameTime = config.startGameTime ?? 12;
|
|
96
|
+
gameStore.setState({
|
|
97
|
+
...gameStore.getState(),
|
|
98
|
+
locationId: config.defaultLocation,
|
|
99
|
+
gameTime: startGameTime,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
engineState.setLocation(config.defaultLocation);
|
|
103
|
+
|
|
104
|
+
const bg = locationManager.resolveBackground(config.defaultLocation, startGameTime);
|
|
105
|
+
if (bg) engineState.setBackground(bg);
|
|
106
|
+
|
|
107
|
+
const loopCallbacks: EngineLoopCallbacks = {
|
|
108
|
+
onTick(state) {
|
|
109
|
+
const locs = locationManager.getAccessibleFrom(state.locationId, state);
|
|
110
|
+
engineState.setLocations(locs.map(l => ({
|
|
111
|
+
id: l.id,
|
|
112
|
+
name: resolveText(l.name),
|
|
113
|
+
accessible: true,
|
|
114
|
+
})));
|
|
115
|
+
|
|
116
|
+
const acts = actionManager.getAvailable(state.locationId, state);
|
|
117
|
+
engineState.setActions(acts.map(a => ({
|
|
118
|
+
id: a.id,
|
|
119
|
+
label: resolveText(a.label),
|
|
120
|
+
locationId: a.locationId ?? '',
|
|
121
|
+
})));
|
|
122
|
+
|
|
123
|
+
engineState.setDrawableEvents(
|
|
124
|
+
engine.eventManager.getDrawableEvents(state, resolveText),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const bg = locationManager.resolveBackground(state.locationId, state.gameTime);
|
|
128
|
+
if (bg) engineState.setBackground(bg);
|
|
129
|
+
},
|
|
130
|
+
onAction(actionId, state) {
|
|
131
|
+
actionManager.execute(actionId, state);
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
engine.run(events, () => gameStore.getState(), loopCallbacks).catch(console.error);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Helpers
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
function createLanguageManager(config: VylosConfig): LanguageManager {
|
|
144
|
+
const lm = new LanguageManager();
|
|
145
|
+
lm.setLanguage(config.defaultLanguage ?? config.languages[0]);
|
|
146
|
+
return lm;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function buildResolveText(
|
|
150
|
+
languageManager: LanguageManager | null,
|
|
151
|
+
config: VylosConfig,
|
|
152
|
+
): (entry: string | TextEntry) => string {
|
|
153
|
+
if (languageManager) {
|
|
154
|
+
return (entry) => languageManager.resolve(entry);
|
|
155
|
+
}
|
|
156
|
+
const lang = config.defaultLanguage ?? 'en';
|
|
157
|
+
return (entry) =>
|
|
158
|
+
typeof entry === 'string'
|
|
159
|
+
? entry
|
|
160
|
+
: entry[lang] ?? Object.values(entry)[0] ?? '';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
type EngineStateStore = ReturnType<typeof useEngineStateStore>;
|
|
164
|
+
|
|
165
|
+
function buildCallbacks(
|
|
166
|
+
engineState: EngineStateStore,
|
|
167
|
+
gameStore: VylosGameStore,
|
|
168
|
+
locationManager: LocationManager,
|
|
169
|
+
resolveText: (entry: string | TextEntry) => string,
|
|
170
|
+
): EventRunnerCallbacks {
|
|
171
|
+
return {
|
|
172
|
+
onSay(text: string, speaker: VylosCharacter | null) {
|
|
173
|
+
engineState.setDialogue({ text, speaker, isNarration: !speaker });
|
|
174
|
+
},
|
|
175
|
+
onChoice(options) {
|
|
176
|
+
engineState.setChoices({ prompt: null, options });
|
|
177
|
+
},
|
|
178
|
+
onSetBackground(path) {
|
|
179
|
+
engineState.setBackground(path);
|
|
180
|
+
},
|
|
181
|
+
onSetForeground(path) {
|
|
182
|
+
engineState.setForeground(path);
|
|
183
|
+
},
|
|
184
|
+
onShowOverlay(componentId, props) {
|
|
185
|
+
engineState.setOverlay(componentId, props);
|
|
186
|
+
},
|
|
187
|
+
onHideOverlay() {
|
|
188
|
+
engineState.setOverlay(null);
|
|
189
|
+
},
|
|
190
|
+
onSetLocation(locationId) {
|
|
191
|
+
const state = gameStore.getState();
|
|
192
|
+
state.locationId = locationId;
|
|
193
|
+
engineState.setLocation(locationId);
|
|
194
|
+
const bg = locationManager.resolveBackground(locationId, state.gameTime);
|
|
195
|
+
if (bg) engineState.setBackground(bg);
|
|
196
|
+
},
|
|
197
|
+
onClear() {
|
|
198
|
+
engineState.setDialogue(null);
|
|
199
|
+
engineState.setChoices(null);
|
|
200
|
+
},
|
|
201
|
+
resolveText(entry) {
|
|
202
|
+
return resolveText(entry);
|
|
203
|
+
},
|
|
204
|
+
getState() {
|
|
205
|
+
return JSON.parse(JSON.stringify(gameStore.getState()));
|
|
206
|
+
},
|
|
207
|
+
setState(newState) {
|
|
208
|
+
gameStore.setState(newState);
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { LocationManager } from '../src/engine/managers/LocationManager';
|
|
3
|
+
import type { VylosLocation, VylosGameState } from '../src/engine/types';
|
|
4
|
+
|
|
5
|
+
function makeState(overrides: Partial<VylosGameState> = {}): VylosGameState {
|
|
6
|
+
return {
|
|
7
|
+
locationId: 'room_a',
|
|
8
|
+
gameTime: 12,
|
|
9
|
+
flags: {},
|
|
10
|
+
counters: {},
|
|
11
|
+
player: { id: 'player', name: 'Player' },
|
|
12
|
+
inventories: {},
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const roomA: VylosLocation = {
|
|
18
|
+
id: 'room_a',
|
|
19
|
+
name: 'Room A',
|
|
20
|
+
backgrounds: [{ path: '/room_a.jpg' }],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const roomB: VylosLocation = {
|
|
24
|
+
id: 'room_b',
|
|
25
|
+
name: 'Room B',
|
|
26
|
+
backgrounds: [{ path: '/room_b.jpg' }],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const roomC: VylosLocation = {
|
|
30
|
+
id: 'room_c',
|
|
31
|
+
name: 'Room C',
|
|
32
|
+
backgrounds: [{ path: '/room_c.jpg' }],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const roomD: VylosLocation = {
|
|
36
|
+
id: 'room_d',
|
|
37
|
+
name: 'Room D',
|
|
38
|
+
backgrounds: [{ path: '/room_d.jpg' }],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe('LocationManager.link()', () => {
|
|
42
|
+
let lm: LocationManager;
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
lm = new LocationManager();
|
|
46
|
+
lm.registerAll([roomA, roomB, roomC, roomD]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('creates directed links from one location to multiple targets', () => {
|
|
50
|
+
lm.link('room_a', ['room_b', 'room_c']);
|
|
51
|
+
|
|
52
|
+
const state = makeState();
|
|
53
|
+
const accessible = lm.getAccessibleFrom('room_a', state);
|
|
54
|
+
expect(accessible.map(l => l.id)).toEqual(['room_b', 'room_c']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('creates a single directed link with string shorthand', () => {
|
|
58
|
+
lm.link('room_a', 'room_b');
|
|
59
|
+
|
|
60
|
+
const state = makeState();
|
|
61
|
+
const accessible = lm.getAccessibleFrom('room_a', state);
|
|
62
|
+
expect(accessible.map(l => l.id)).toEqual(['room_b']);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('creates a link with a condition', () => {
|
|
66
|
+
lm.link('room_a', ['room_b'], {
|
|
67
|
+
condition: (state) => state.flags['has_key'] === true,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const stateWithout = makeState();
|
|
71
|
+
expect(lm.getAccessibleFrom('room_a', stateWithout)).toHaveLength(0);
|
|
72
|
+
|
|
73
|
+
const stateWith = makeState({ flags: { has_key: true } });
|
|
74
|
+
expect(lm.getAccessibleFrom('room_a', stateWith).map(l => l.id)).toEqual(['room_b']);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('is additive across multiple calls', () => {
|
|
78
|
+
lm.link('room_a', 'room_b');
|
|
79
|
+
lm.link('room_a', 'room_c');
|
|
80
|
+
|
|
81
|
+
const state = makeState();
|
|
82
|
+
const accessible = lm.getAccessibleFrom('room_a', state);
|
|
83
|
+
expect(accessible.map(l => l.id)).toEqual(['room_b', 'room_c']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('accumulates links alongside setLinks()', () => {
|
|
87
|
+
// link() first
|
|
88
|
+
lm.link('room_a', 'room_b');
|
|
89
|
+
|
|
90
|
+
// setLinks() replaces everything
|
|
91
|
+
lm.setLinks([{ from: 'room_a', to: 'room_c' }]);
|
|
92
|
+
|
|
93
|
+
const state = makeState();
|
|
94
|
+
// After setLinks, the link() entry is gone — only room_c remains
|
|
95
|
+
expect(lm.getAccessibleFrom('room_a', state).map(l => l.id)).toEqual(['room_c']);
|
|
96
|
+
|
|
97
|
+
// Now link() adds on top of the setLinks result
|
|
98
|
+
lm.link('room_a', 'room_d');
|
|
99
|
+
expect(lm.getAccessibleFrom('room_a', state).map(l => l.id)).toEqual(['room_c', 'room_d']);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('link() after setLinks() accumulates on existing links', () => {
|
|
103
|
+
lm.setLinks([
|
|
104
|
+
{ from: 'room_a', to: 'room_b' },
|
|
105
|
+
{ from: 'room_b', to: 'room_a' },
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
lm.link('room_a', 'room_c');
|
|
109
|
+
|
|
110
|
+
const state = makeState();
|
|
111
|
+
const accessible = lm.getAccessibleFrom('room_a', state);
|
|
112
|
+
expect(accessible.map(l => l.id)).toEqual(['room_b', 'room_c']);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('does not create reverse links (directed only)', () => {
|
|
116
|
+
lm.link('room_a', 'room_b');
|
|
117
|
+
|
|
118
|
+
const state = makeState({ locationId: 'room_b' });
|
|
119
|
+
const accessible = lm.getAccessibleFrom('room_b', state);
|
|
120
|
+
expect(accessible).toHaveLength(0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('handles mixed conditional and unconditional links', () => {
|
|
124
|
+
lm.link('room_a', 'room_b');
|
|
125
|
+
lm.link('room_a', ['room_c'], {
|
|
126
|
+
condition: (state) => state.gameTime >= 18,
|
|
127
|
+
});
|
|
128
|
+
lm.link('room_a', 'room_d');
|
|
129
|
+
|
|
130
|
+
const daytime = makeState({ gameTime: 12 });
|
|
131
|
+
expect(lm.getAccessibleFrom('room_a', daytime).map(l => l.id)).toEqual(['room_b', 'room_d']);
|
|
132
|
+
|
|
133
|
+
const nighttime = makeState({ gameTime: 20 });
|
|
134
|
+
expect(lm.getAccessibleFrom('room_a', nighttime).map(l => l.id)).toEqual([
|
|
135
|
+
'room_b',
|
|
136
|
+
'room_c',
|
|
137
|
+
'room_d',
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('clear() removes links added via link()', () => {
|
|
142
|
+
lm.link('room_a', ['room_b', 'room_c']);
|
|
143
|
+
lm.clear();
|
|
144
|
+
|
|
145
|
+
const state = makeState();
|
|
146
|
+
// Locations are gone too, so getAccessibleFrom returns empty
|
|
147
|
+
expect(lm.getAccessibleFrom('room_a', state)).toHaveLength(0);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* bootstrapVylos unit tests are intentionally omitted.
|
|
153
|
+
*
|
|
154
|
+
* bootstrapVylos() is a high-level orchestration function that:
|
|
155
|
+
* - Creates a Vue application with createApp(GameShell)
|
|
156
|
+
* - Sets up Pinia stores
|
|
157
|
+
* - Creates the engine via createEngine() (which uses tsyringe DI)
|
|
158
|
+
* - Mounts the app to #app
|
|
159
|
+
* - Sets up a phase watcher to start the game loop
|
|
160
|
+
*
|
|
161
|
+
* Testing it in isolation would require mocking nearly every dependency
|
|
162
|
+
* (Vue createApp, Pinia, GameShell component, createEngine, stores, etc.),
|
|
163
|
+
* which would produce brittle tests that test the mock wiring rather than
|
|
164
|
+
* real behavior. The function's correctness is better validated through:
|
|
165
|
+
*
|
|
166
|
+
* 1. Integration tests via the example projects (pnpm dev:basic, pnpm dev:romance)
|
|
167
|
+
* 2. Unit tests of the individual pieces it orchestrates (LocationManager, ActionManager,
|
|
168
|
+
* EventRunner, stores, etc.) which are already well-covered
|
|
169
|
+
* 3. The helper functions (buildResolveText, buildCallbacks) are private to the module
|
|
170
|
+
* and tested indirectly through their integration with the managers and stores
|
|
171
|
+
*/
|