@vylos/core 0.1.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 +34 -0
- package/src/components/app/EngineView.vue +40 -0
- package/src/components/app/GameShell.vue +265 -0
- package/src/components/app/LoadingScreen.vue +10 -0
- package/src/components/app/MainMenu.vue +243 -0
- package/src/components/core/BackgroundLayer.vue +27 -0
- package/src/components/core/ChoicePanel.vue +115 -0
- package/src/components/core/CustomOverlay.vue +27 -0
- package/src/components/core/DialogueBox.vue +144 -0
- package/src/components/core/DrawableOverlay.vue +109 -0
- package/src/components/core/ForegroundLayer.vue +31 -0
- package/src/components/menu/ActionOverlay.vue +126 -0
- package/src/components/menu/LocationOverlay.vue +136 -0
- package/src/components/menu/PauseMenu.vue +196 -0
- package/src/components/menu/SaveLoadMenu.vue +377 -0
- package/src/components/menu/SettingsMenu.vue +111 -0
- package/src/components/menu/TopBar.vue +65 -0
- package/src/composables/useConfig.ts +4 -0
- package/src/composables/useEngine.ts +16 -0
- package/src/composables/useGameState.ts +9 -0
- package/src/composables/useLanguage.ts +31 -0
- package/src/engine/core/CheckpointManager.ts +122 -0
- package/src/engine/core/Engine.ts +272 -0
- package/src/engine/core/EngineFactory.ts +102 -0
- package/src/engine/core/EventRunner.ts +488 -0
- package/src/engine/errors/EventEndError.ts +7 -0
- package/src/engine/errors/InterruptSignal.ts +10 -0
- package/src/engine/errors/JumpSignal.ts +10 -0
- package/src/engine/errors/StateValidationError.ts +13 -0
- package/src/engine/managers/ActionManager.ts +62 -0
- package/src/engine/managers/EventManager.ts +166 -0
- package/src/engine/managers/HistoryManager.ts +84 -0
- package/src/engine/managers/InputManager.ts +117 -0
- package/src/engine/managers/LanguageManager.ts +51 -0
- package/src/engine/managers/LocationManager.ts +76 -0
- package/src/engine/managers/NavigationManager.ts +75 -0
- package/src/engine/managers/SaveManager.ts +86 -0
- package/src/engine/managers/SettingsManager.ts +70 -0
- package/src/engine/managers/WaitManager.ts +47 -0
- package/src/engine/schemas/baseGameState.schema.ts +19 -0
- package/src/engine/schemas/checkpoint.schema.ts +11 -0
- package/src/engine/schemas/engineState.schema.ts +59 -0
- package/src/engine/schemas/location.schema.ts +21 -0
- package/src/engine/storage/VylosStorage.ts +131 -0
- package/src/engine/types/actions.ts +20 -0
- package/src/engine/types/checkpoint.ts +31 -0
- package/src/engine/types/config.ts +9 -0
- package/src/engine/types/dialogue.ts +15 -0
- package/src/engine/types/engine.ts +85 -0
- package/src/engine/types/events.ts +117 -0
- package/src/engine/types/game-state.ts +15 -0
- package/src/engine/types/index.ts +10 -0
- package/src/engine/types/locations.ts +32 -0
- package/src/engine/types/plugin.ts +11 -0
- package/src/engine/types/save.ts +40 -0
- package/src/engine/utils/TimeHelper.ts +39 -0
- package/src/engine/utils/logger.ts +43 -0
- package/src/env.d.ts +17 -0
- package/src/index.ts +74 -0
- package/src/stores/engineState.ts +127 -0
- package/src/stores/gameState.ts +49 -0
- package/tests/engine/ActionManager.test.ts +94 -0
- package/tests/engine/CheckpointManager.test.ts +136 -0
- package/tests/engine/EventManager.test.ts +145 -0
- package/tests/engine/EventRunner.test.ts +318 -0
- package/tests/engine/HistoryManager.test.ts +113 -0
- package/tests/engine/LocationManager.test.ts +128 -0
- package/tests/engine/schemas.test.ts +250 -0
- package/tests/engine/utils.test.ts +75 -0
- package/tests/integration/game-loop.test.ts +201 -0
- package/tests/safety/event-validation.test.ts +102 -0
- package/tests/safety/state-schema.test.ts +96 -0
- package/tests/setup.ts +2 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +16 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { VylosEvent, BaseGameState, DrawableEventEntry } from '../types';
|
|
2
|
+
import { EventStatus } from '../types';
|
|
3
|
+
import { logger } from '../utils/logger';
|
|
4
|
+
|
|
5
|
+
interface EventEntry {
|
|
6
|
+
event: VylosEvent;
|
|
7
|
+
status: EventStatus;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Manages event lifecycle: registration, condition evaluation, status transitions.
|
|
12
|
+
*
|
|
13
|
+
* Event lifecycle: NotReady → Unlocked → Running → Locked
|
|
14
|
+
* - NotReady: conditions not yet met
|
|
15
|
+
* - Unlocked: conditions met, ready to execute
|
|
16
|
+
* - Running: currently executing
|
|
17
|
+
* - Locked: completed (won't trigger again)
|
|
18
|
+
*/
|
|
19
|
+
export class EventManager {
|
|
20
|
+
private events = new Map<string, EventEntry>();
|
|
21
|
+
|
|
22
|
+
/** Register an event */
|
|
23
|
+
register(event: VylosEvent): void {
|
|
24
|
+
this.events.set(event.id, {
|
|
25
|
+
event,
|
|
26
|
+
status: EventStatus.NotReady,
|
|
27
|
+
});
|
|
28
|
+
logger.debug(`Event registered: ${event.id}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Register multiple events */
|
|
32
|
+
registerAll(events: VylosEvent[]): void {
|
|
33
|
+
for (const event of events) {
|
|
34
|
+
this.register(event);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Get an event by ID */
|
|
39
|
+
get(id: string): VylosEvent | undefined {
|
|
40
|
+
return this.events.get(id)?.event;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Get event status */
|
|
44
|
+
getStatus(id: string): EventStatus | undefined {
|
|
45
|
+
return this.events.get(id)?.status;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Evaluate conditions and update statuses. Returns newly unlocked events. */
|
|
49
|
+
evaluate(state: BaseGameState): VylosEvent[] {
|
|
50
|
+
const unlocked: VylosEvent[] = [];
|
|
51
|
+
|
|
52
|
+
for (const [id, entry] of this.events) {
|
|
53
|
+
if (entry.status !== EventStatus.NotReady) continue;
|
|
54
|
+
|
|
55
|
+
// Skip events bound to a different location
|
|
56
|
+
if (entry.event.locationId && entry.event.locationId !== state.locationId) continue;
|
|
57
|
+
|
|
58
|
+
const conditionsMet = !entry.event.conditions || entry.event.conditions(state);
|
|
59
|
+
if (conditionsMet) {
|
|
60
|
+
entry.status = EventStatus.Unlocked;
|
|
61
|
+
entry.event.unlocked?.(state);
|
|
62
|
+
unlocked.push(entry.event);
|
|
63
|
+
logger.debug(`Event unlocked: ${id}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return unlocked;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Get the next unlocked event matching the current location (first registered wins). Skips drawable events. */
|
|
71
|
+
getNextUnlocked(state: BaseGameState): VylosEvent | undefined {
|
|
72
|
+
for (const entry of this.events.values()) {
|
|
73
|
+
if (entry.status !== EventStatus.Unlocked) continue;
|
|
74
|
+
if (entry.event.draw) continue; // Drawable events don't auto-trigger
|
|
75
|
+
if (entry.event.locationId && entry.event.locationId !== state.locationId) continue;
|
|
76
|
+
return entry.event;
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Get all unlocked drawable events at the current location */
|
|
82
|
+
getDrawableEvents(state: BaseGameState, resolveText?: (text: string | Record<string, string>) => string): DrawableEventEntry[] {
|
|
83
|
+
const result: DrawableEventEntry[] = [];
|
|
84
|
+
for (const entry of this.events.values()) {
|
|
85
|
+
if (entry.status !== EventStatus.Unlocked) continue;
|
|
86
|
+
if (!entry.event.draw) continue;
|
|
87
|
+
if (entry.event.locationId && entry.event.locationId !== state.locationId) continue;
|
|
88
|
+
const draw = entry.event.draw;
|
|
89
|
+
const label = typeof draw.label === 'string'
|
|
90
|
+
? draw.label
|
|
91
|
+
: resolveText?.(draw.label) ?? Object.values(draw.label)[0] ?? entry.event.id;
|
|
92
|
+
result.push({
|
|
93
|
+
id: entry.event.id,
|
|
94
|
+
label,
|
|
95
|
+
position: draw.position ?? 'center',
|
|
96
|
+
icon: draw.icon,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Mark event as running */
|
|
103
|
+
setRunning(id: string): void {
|
|
104
|
+
const entry = this.events.get(id);
|
|
105
|
+
if (entry) {
|
|
106
|
+
entry.status = EventStatus.Running;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Mark event as locked (completed) */
|
|
111
|
+
setLocked(id: string, state: BaseGameState): void {
|
|
112
|
+
const entry = this.events.get(id);
|
|
113
|
+
if (entry) {
|
|
114
|
+
entry.status = EventStatus.Locked;
|
|
115
|
+
entry.event.locked?.(state);
|
|
116
|
+
logger.debug(`Event locked: ${id}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Reset an event back to NotReady */
|
|
121
|
+
reset(id: string): void {
|
|
122
|
+
const entry = this.events.get(id);
|
|
123
|
+
if (entry) {
|
|
124
|
+
entry.status = EventStatus.NotReady;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Reset all events */
|
|
129
|
+
resetAll(): void {
|
|
130
|
+
for (const entry of this.events.values()) {
|
|
131
|
+
entry.status = EventStatus.NotReady;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Get all events for a specific location */
|
|
136
|
+
getByLocation(locationId: string): VylosEvent[] {
|
|
137
|
+
const result: VylosEvent[] = [];
|
|
138
|
+
for (const entry of this.events.values()) {
|
|
139
|
+
if (entry.event.locationId === locationId) {
|
|
140
|
+
result.push(entry.event);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Get locked event IDs (for save data) */
|
|
147
|
+
getLockedIds(): string[] {
|
|
148
|
+
const ids: string[] = [];
|
|
149
|
+
for (const [id, entry] of this.events) {
|
|
150
|
+
if (entry.status === EventStatus.Locked) {
|
|
151
|
+
ids.push(id);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return ids;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Restore locked state from save data */
|
|
158
|
+
restoreLockedIds(ids: string[]): void {
|
|
159
|
+
for (const id of ids) {
|
|
160
|
+
const entry = this.events.get(id);
|
|
161
|
+
if (entry) {
|
|
162
|
+
entry.status = EventStatus.Locked;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Checkpoint } from '../types';
|
|
2
|
+
import { logger } from '../utils/logger';
|
|
3
|
+
|
|
4
|
+
export interface HistoryEntry {
|
|
5
|
+
eventId: string;
|
|
6
|
+
checkpoints: Checkpoint[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Manages navigation history: back/forward through past events and checkpoints.
|
|
11
|
+
* Each completed event is pushed as a HistoryEntry.
|
|
12
|
+
*/
|
|
13
|
+
export class HistoryManager {
|
|
14
|
+
private history: HistoryEntry[] = [];
|
|
15
|
+
private currentIndex = -1;
|
|
16
|
+
|
|
17
|
+
/** Total history entries */
|
|
18
|
+
get count(): number {
|
|
19
|
+
return this.history.length;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Current navigation index */
|
|
23
|
+
get index(): number {
|
|
24
|
+
return this.currentIndex;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether back navigation is possible */
|
|
28
|
+
get canGoBack(): boolean {
|
|
29
|
+
return this.currentIndex > 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whether forward navigation is possible */
|
|
33
|
+
get canGoForward(): boolean {
|
|
34
|
+
return this.currentIndex < this.history.length - 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Current history entry */
|
|
38
|
+
get current(): HistoryEntry | undefined {
|
|
39
|
+
return this.history[this.currentIndex];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Push a completed event to history */
|
|
43
|
+
push(eventId: string, checkpoints: Checkpoint[]): void {
|
|
44
|
+
// Trim forward history if we branched
|
|
45
|
+
if (this.currentIndex < this.history.length - 1) {
|
|
46
|
+
this.history.length = this.currentIndex + 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
this.history.push({ eventId, checkpoints });
|
|
50
|
+
this.currentIndex = this.history.length - 1;
|
|
51
|
+
logger.debug(`History: pushed ${eventId}, total ${this.history.length}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Go back one entry. Returns the entry to restore, or undefined. */
|
|
55
|
+
goBack(): HistoryEntry | undefined {
|
|
56
|
+
if (!this.canGoBack) return undefined;
|
|
57
|
+
this.currentIndex--;
|
|
58
|
+
return this.history[this.currentIndex];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Go forward one entry. Returns the entry to restore, or undefined. */
|
|
62
|
+
goForward(): HistoryEntry | undefined {
|
|
63
|
+
if (!this.canGoForward) return undefined;
|
|
64
|
+
this.currentIndex++;
|
|
65
|
+
return this.history[this.currentIndex];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Clear all history */
|
|
69
|
+
clear(): void {
|
|
70
|
+
this.history = [];
|
|
71
|
+
this.currentIndex = -1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Get all history for save data */
|
|
75
|
+
getAll(): HistoryEntry[] {
|
|
76
|
+
return structuredClone(this.history);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Restore history from save data */
|
|
80
|
+
restore(entries: HistoryEntry[], index: number): void {
|
|
81
|
+
this.history = structuredClone(entries);
|
|
82
|
+
this.currentIndex = Math.min(index, this.history.length - 1);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { logger } from '../utils/logger';
|
|
2
|
+
|
|
3
|
+
export type InputCallback = (action: string) => void;
|
|
4
|
+
|
|
5
|
+
type KeyboardLayout = 'unknown' | 'qwerty' | 'azerty';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Manages keyboard and mouse input for the game.
|
|
9
|
+
* Auto-detects QWERTY vs AZERTY layout on first relevant keypress.
|
|
10
|
+
*/
|
|
11
|
+
export class InputManager {
|
|
12
|
+
private callback: InputCallback | null = null;
|
|
13
|
+
private keyHandler: ((e: KeyboardEvent) => void) | null = null;
|
|
14
|
+
private skipMode = false;
|
|
15
|
+
private layout: KeyboardLayout = 'unknown';
|
|
16
|
+
|
|
17
|
+
/** Custom overrides set via setBinding() */
|
|
18
|
+
private customBindings = new Map<string, string>();
|
|
19
|
+
|
|
20
|
+
/** Start listening for input */
|
|
21
|
+
start(callback: InputCallback): void {
|
|
22
|
+
this.callback = callback;
|
|
23
|
+
|
|
24
|
+
this.keyHandler = (e: KeyboardEvent) => {
|
|
25
|
+
// Check custom bindings first
|
|
26
|
+
const custom = this.customBindings.get(e.key);
|
|
27
|
+
if (custom) {
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
this.callback?.(custom);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const action = this.resolveAction(e);
|
|
34
|
+
if (action) {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
this.callback?.(action);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
if (typeof window !== 'undefined') {
|
|
41
|
+
window.addEventListener('keydown', this.keyHandler);
|
|
42
|
+
logger.debug('InputManager started');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Stop listening */
|
|
47
|
+
stop(): void {
|
|
48
|
+
if (this.keyHandler && typeof window !== 'undefined') {
|
|
49
|
+
window.removeEventListener('keydown', this.keyHandler);
|
|
50
|
+
}
|
|
51
|
+
this.keyHandler = null;
|
|
52
|
+
this.callback = null;
|
|
53
|
+
logger.debug('InputManager stopped');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Override a key binding */
|
|
57
|
+
setBinding(key: string, action: string): void {
|
|
58
|
+
this.customBindings.set(key, action);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Get detected keyboard layout */
|
|
62
|
+
get detectedLayout(): KeyboardLayout {
|
|
63
|
+
return this.layout;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Get skip mode state */
|
|
67
|
+
get isSkipping(): boolean {
|
|
68
|
+
return this.skipMode;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Toggle skip mode */
|
|
72
|
+
toggleSkip(): void {
|
|
73
|
+
this.skipMode = !this.skipMode;
|
|
74
|
+
logger.debug(`Skip mode: ${this.skipMode}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Set skip mode */
|
|
78
|
+
setSkip(enabled: boolean): void {
|
|
79
|
+
this.skipMode = enabled;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Resolve a keyboard event to an action, with layout auto-detection */
|
|
83
|
+
private resolveAction(e: KeyboardEvent): string | null {
|
|
84
|
+
const key = e.key;
|
|
85
|
+
|
|
86
|
+
// Universal keys (work on all layouts)
|
|
87
|
+
if (key === ' ' || key === 'Enter') return 'continue';
|
|
88
|
+
if (key === 'Escape') return 'menu';
|
|
89
|
+
if (key === 'ArrowRight') return 'forward';
|
|
90
|
+
if (key === 'ArrowLeft') return 'back';
|
|
91
|
+
|
|
92
|
+
// Skip toggle — same key on both layouts
|
|
93
|
+
if (key === 's' || key === 'S') return 'skip-toggle';
|
|
94
|
+
|
|
95
|
+
// Forward key: E on both layouts
|
|
96
|
+
if (key === 'e' || key === 'E') return 'continue';
|
|
97
|
+
|
|
98
|
+
// Layout-dependent back key
|
|
99
|
+
if (key === 'q' || key === 'Q') {
|
|
100
|
+
this.detectLayout('qwerty');
|
|
101
|
+
return 'back';
|
|
102
|
+
}
|
|
103
|
+
if (key === 'a' || key === 'A') {
|
|
104
|
+
this.detectLayout('azerty');
|
|
105
|
+
return 'back';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Auto-detect layout on first relevant keypress */
|
|
112
|
+
private detectLayout(detected: 'qwerty' | 'azerty'): void {
|
|
113
|
+
if (this.layout !== 'unknown') return;
|
|
114
|
+
this.layout = detected;
|
|
115
|
+
logger.debug(`Keyboard layout detected: ${detected}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { TextEntry } from '../types';
|
|
2
|
+
import { interpolate } from '../utils/TimeHelper';
|
|
3
|
+
import { logger } from '../utils/logger';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Manages language selection and text resolution.
|
|
7
|
+
* Resolves TextEntry objects to the current language string.
|
|
8
|
+
*/
|
|
9
|
+
export class LanguageManager {
|
|
10
|
+
private currentLang = 'en';
|
|
11
|
+
private fallbackLang = 'en';
|
|
12
|
+
|
|
13
|
+
/** Get current language */
|
|
14
|
+
get language(): string {
|
|
15
|
+
return this.currentLang;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Set current language */
|
|
19
|
+
setLanguage(lang: string): void {
|
|
20
|
+
this.currentLang = lang;
|
|
21
|
+
logger.debug(`Language set to: ${lang}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Set fallback language */
|
|
25
|
+
setFallback(lang: string): void {
|
|
26
|
+
this.fallbackLang = lang;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve a text entry to a string in the current language.
|
|
31
|
+
* If entry is already a string, return it as-is.
|
|
32
|
+
* If it's a TextEntry record, look up current language, then fallback, then first key.
|
|
33
|
+
*/
|
|
34
|
+
resolve(entry: string | TextEntry): string {
|
|
35
|
+
if (typeof entry === 'string') return entry;
|
|
36
|
+
|
|
37
|
+
return entry[this.currentLang]
|
|
38
|
+
?? entry[this.fallbackLang]
|
|
39
|
+
?? Object.values(entry)[0]
|
|
40
|
+
?? '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve text with variable interpolation.
|
|
45
|
+
* "{varName}" in the resolved string is replaced with the value from variables.
|
|
46
|
+
*/
|
|
47
|
+
resolveWithVars(entry: string | TextEntry, variables: Record<string, string | number>): string {
|
|
48
|
+
const text = this.resolve(entry);
|
|
49
|
+
return interpolate(text, variables);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { VylosLocation, LocationLink, BaseGameState, BackgroundEntry } from '../types';
|
|
2
|
+
import { resolveBackground } from '../utils/TimeHelper';
|
|
3
|
+
import { logger } from '../utils/logger';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Manages locations: registration, linking, accessibility, background resolution.
|
|
7
|
+
*/
|
|
8
|
+
export class LocationManager {
|
|
9
|
+
private locations = new Map<string, VylosLocation>();
|
|
10
|
+
private links: LocationLink[] = [];
|
|
11
|
+
|
|
12
|
+
/** Register a location */
|
|
13
|
+
register(location: VylosLocation): void {
|
|
14
|
+
this.locations.set(location.id, location);
|
|
15
|
+
logger.debug(`Location registered: ${location.id}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Register multiple locations */
|
|
19
|
+
registerAll(locations: VylosLocation[]): void {
|
|
20
|
+
for (const loc of locations) {
|
|
21
|
+
this.register(loc);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Set the location link graph */
|
|
26
|
+
setLinks(links: LocationLink[]): void {
|
|
27
|
+
this.links = links;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Get a location by ID */
|
|
31
|
+
get(id: string): VylosLocation | undefined {
|
|
32
|
+
return this.locations.get(id);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Get all registered locations */
|
|
36
|
+
getAll(): VylosLocation[] {
|
|
37
|
+
return [...this.locations.values()];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Get locations accessible from a given location, considering state conditions */
|
|
41
|
+
getAccessibleFrom(locationId: string, state: BaseGameState): VylosLocation[] {
|
|
42
|
+
const linkedIds = this.links
|
|
43
|
+
.filter(link => {
|
|
44
|
+
if (link.from !== locationId) return false;
|
|
45
|
+
if (link.condition && !link.condition(state)) return false;
|
|
46
|
+
return true;
|
|
47
|
+
})
|
|
48
|
+
.map(link => link.to);
|
|
49
|
+
|
|
50
|
+
return linkedIds
|
|
51
|
+
.map(id => this.locations.get(id))
|
|
52
|
+
.filter((loc): loc is VylosLocation => {
|
|
53
|
+
if (!loc) return false;
|
|
54
|
+
if (loc.accessible && !loc.accessible(state)) return false;
|
|
55
|
+
return true;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Resolve the background image for a location at a given game time */
|
|
60
|
+
resolveBackground(locationId: string, gameTime: number): string | null {
|
|
61
|
+
const location = this.locations.get(locationId);
|
|
62
|
+
if (!location) return null;
|
|
63
|
+
return resolveBackground(location.backgrounds, gameTime);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Check if a location exists */
|
|
67
|
+
has(id: string): boolean {
|
|
68
|
+
return this.locations.has(id);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Clear all locations */
|
|
72
|
+
clear(): void {
|
|
73
|
+
this.locations.clear();
|
|
74
|
+
this.links = [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { WaitManager } from './WaitManager';
|
|
2
|
+
import { logger } from '../utils/logger';
|
|
3
|
+
|
|
4
|
+
export enum NavigationAction {
|
|
5
|
+
Continue = 'continue',
|
|
6
|
+
Back = 'back',
|
|
7
|
+
Forward = 'forward',
|
|
8
|
+
Action = 'action',
|
|
9
|
+
Location = 'location',
|
|
10
|
+
DrawableEvent = 'drawable_event',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface NavigationResult {
|
|
14
|
+
action: NavigationAction;
|
|
15
|
+
payload?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Manages navigation between game loop ticks.
|
|
20
|
+
* The engine waits here for player input after each event completes.
|
|
21
|
+
*/
|
|
22
|
+
export class NavigationManager {
|
|
23
|
+
private waitManager = new WaitManager();
|
|
24
|
+
|
|
25
|
+
/** Whether the engine is currently waiting for navigation input */
|
|
26
|
+
get isWaiting(): boolean {
|
|
27
|
+
return this.waitManager.isWaiting;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Wait for the next navigation action from the player */
|
|
31
|
+
async waitForNavigation(): Promise<NavigationResult> {
|
|
32
|
+
return this.waitManager.wait<NavigationResult>();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Player wants to continue (advance to next event) */
|
|
36
|
+
continue(): void {
|
|
37
|
+
logger.debug('Navigation: continue');
|
|
38
|
+
this.waitManager.resolve({ action: NavigationAction.Continue });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Player wants to go back */
|
|
42
|
+
goBack(): void {
|
|
43
|
+
logger.debug('Navigation: back');
|
|
44
|
+
this.waitManager.resolve({ action: NavigationAction.Back });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Player wants to go forward */
|
|
48
|
+
goForward(): void {
|
|
49
|
+
logger.debug('Navigation: forward');
|
|
50
|
+
this.waitManager.resolve({ action: NavigationAction.Forward });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Player selected an action */
|
|
54
|
+
selectAction(actionId: string): void {
|
|
55
|
+
logger.debug('Navigation: action', actionId);
|
|
56
|
+
this.waitManager.resolve({ action: NavigationAction.Action, payload: actionId });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Player selected a location to travel to */
|
|
60
|
+
selectLocation(locationId: string): void {
|
|
61
|
+
logger.debug('Navigation: location', locationId);
|
|
62
|
+
this.waitManager.resolve({ action: NavigationAction.Location, payload: locationId });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Player clicked a drawable event */
|
|
66
|
+
selectDrawableEvent(eventId: string): void {
|
|
67
|
+
logger.debug('Navigation: drawable event', eventId);
|
|
68
|
+
this.waitManager.resolve({ action: NavigationAction.DrawableEvent, payload: eventId });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Cancel waiting (e.g., on game reset). Resolves the wait with Continue to unblock. */
|
|
72
|
+
cancel(): void {
|
|
73
|
+
this.waitManager.resolve({ action: NavigationAction.Continue });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { SaveSlot, SaveMeta } from '../types';
|
|
2
|
+
import { VylosStorage } from '../storage/VylosStorage';
|
|
3
|
+
import { logger } from '../utils/logger';
|
|
4
|
+
|
|
5
|
+
const SAVE_VERSION = 1;
|
|
6
|
+
const STORE = 'saves';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Manages save/load with versioned IndexedDB slots.
|
|
10
|
+
*/
|
|
11
|
+
export class SaveManager {
|
|
12
|
+
private storage: VylosStorage;
|
|
13
|
+
private ready: Promise<void>;
|
|
14
|
+
|
|
15
|
+
constructor(storage: VylosStorage) {
|
|
16
|
+
this.storage = storage;
|
|
17
|
+
this.ready = storage.open();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Save a complete slot (timestamp and version are set automatically) */
|
|
21
|
+
async save(slot: number, saveData: Omit<SaveSlot, 'slot' | 'timestamp' | 'version'>): Promise<void> {
|
|
22
|
+
await this.ready;
|
|
23
|
+
const data: SaveSlot = {
|
|
24
|
+
slot,
|
|
25
|
+
timestamp: Date.now(),
|
|
26
|
+
version: SAVE_VERSION,
|
|
27
|
+
...JSON.parse(JSON.stringify(saveData)),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
await this.storage.put(STORE, slot, data);
|
|
32
|
+
logger.info(`Saved to slot ${slot}`);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
logger.error('Save failed:', error);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Load game state from a slot */
|
|
39
|
+
async load(slot: number): Promise<SaveSlot | null> {
|
|
40
|
+
await this.ready;
|
|
41
|
+
try {
|
|
42
|
+
const data = await this.storage.get<SaveSlot>(STORE, slot);
|
|
43
|
+
if (!data) return null;
|
|
44
|
+
|
|
45
|
+
if (data.version !== SAVE_VERSION) {
|
|
46
|
+
logger.warn(`Save version mismatch: expected ${SAVE_VERSION}, got ${data.version}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
logger.info(`Loaded from slot ${slot}`);
|
|
50
|
+
return data;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
logger.error('Load failed:', error);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Delete a save slot */
|
|
58
|
+
async delete(slot: number): Promise<void> {
|
|
59
|
+
await this.ready;
|
|
60
|
+
await this.storage.delete(STORE, slot);
|
|
61
|
+
logger.info(`Deleted slot ${slot}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Get metadata for all save slots */
|
|
65
|
+
async listSlots(): Promise<SaveMeta[]> {
|
|
66
|
+
await this.ready;
|
|
67
|
+
try {
|
|
68
|
+
const all = await this.storage.getAll<SaveSlot>(STORE);
|
|
69
|
+
return all.map(d => ({
|
|
70
|
+
slot: d.slot,
|
|
71
|
+
timestamp: d.timestamp,
|
|
72
|
+
label: d.label,
|
|
73
|
+
thumbnail: d.thumbnail,
|
|
74
|
+
}));
|
|
75
|
+
} catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Check if a slot has data */
|
|
81
|
+
async hasSlot(slot: number): Promise<boolean> {
|
|
82
|
+
await this.ready;
|
|
83
|
+
const data = await this.storage.get(STORE, slot);
|
|
84
|
+
return data !== undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|