@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.
Files changed (75) hide show
  1. package/package.json +34 -0
  2. package/src/components/app/EngineView.vue +40 -0
  3. package/src/components/app/GameShell.vue +265 -0
  4. package/src/components/app/LoadingScreen.vue +10 -0
  5. package/src/components/app/MainMenu.vue +243 -0
  6. package/src/components/core/BackgroundLayer.vue +27 -0
  7. package/src/components/core/ChoicePanel.vue +115 -0
  8. package/src/components/core/CustomOverlay.vue +27 -0
  9. package/src/components/core/DialogueBox.vue +144 -0
  10. package/src/components/core/DrawableOverlay.vue +109 -0
  11. package/src/components/core/ForegroundLayer.vue +31 -0
  12. package/src/components/menu/ActionOverlay.vue +126 -0
  13. package/src/components/menu/LocationOverlay.vue +136 -0
  14. package/src/components/menu/PauseMenu.vue +196 -0
  15. package/src/components/menu/SaveLoadMenu.vue +377 -0
  16. package/src/components/menu/SettingsMenu.vue +111 -0
  17. package/src/components/menu/TopBar.vue +65 -0
  18. package/src/composables/useConfig.ts +4 -0
  19. package/src/composables/useEngine.ts +16 -0
  20. package/src/composables/useGameState.ts +9 -0
  21. package/src/composables/useLanguage.ts +31 -0
  22. package/src/engine/core/CheckpointManager.ts +122 -0
  23. package/src/engine/core/Engine.ts +272 -0
  24. package/src/engine/core/EngineFactory.ts +102 -0
  25. package/src/engine/core/EventRunner.ts +488 -0
  26. package/src/engine/errors/EventEndError.ts +7 -0
  27. package/src/engine/errors/InterruptSignal.ts +10 -0
  28. package/src/engine/errors/JumpSignal.ts +10 -0
  29. package/src/engine/errors/StateValidationError.ts +13 -0
  30. package/src/engine/managers/ActionManager.ts +62 -0
  31. package/src/engine/managers/EventManager.ts +166 -0
  32. package/src/engine/managers/HistoryManager.ts +84 -0
  33. package/src/engine/managers/InputManager.ts +117 -0
  34. package/src/engine/managers/LanguageManager.ts +51 -0
  35. package/src/engine/managers/LocationManager.ts +76 -0
  36. package/src/engine/managers/NavigationManager.ts +75 -0
  37. package/src/engine/managers/SaveManager.ts +86 -0
  38. package/src/engine/managers/SettingsManager.ts +70 -0
  39. package/src/engine/managers/WaitManager.ts +47 -0
  40. package/src/engine/schemas/baseGameState.schema.ts +19 -0
  41. package/src/engine/schemas/checkpoint.schema.ts +11 -0
  42. package/src/engine/schemas/engineState.schema.ts +59 -0
  43. package/src/engine/schemas/location.schema.ts +21 -0
  44. package/src/engine/storage/VylosStorage.ts +131 -0
  45. package/src/engine/types/actions.ts +20 -0
  46. package/src/engine/types/checkpoint.ts +31 -0
  47. package/src/engine/types/config.ts +9 -0
  48. package/src/engine/types/dialogue.ts +15 -0
  49. package/src/engine/types/engine.ts +85 -0
  50. package/src/engine/types/events.ts +117 -0
  51. package/src/engine/types/game-state.ts +15 -0
  52. package/src/engine/types/index.ts +10 -0
  53. package/src/engine/types/locations.ts +32 -0
  54. package/src/engine/types/plugin.ts +11 -0
  55. package/src/engine/types/save.ts +40 -0
  56. package/src/engine/utils/TimeHelper.ts +39 -0
  57. package/src/engine/utils/logger.ts +43 -0
  58. package/src/env.d.ts +17 -0
  59. package/src/index.ts +74 -0
  60. package/src/stores/engineState.ts +127 -0
  61. package/src/stores/gameState.ts +49 -0
  62. package/tests/engine/ActionManager.test.ts +94 -0
  63. package/tests/engine/CheckpointManager.test.ts +136 -0
  64. package/tests/engine/EventManager.test.ts +145 -0
  65. package/tests/engine/EventRunner.test.ts +318 -0
  66. package/tests/engine/HistoryManager.test.ts +113 -0
  67. package/tests/engine/LocationManager.test.ts +128 -0
  68. package/tests/engine/schemas.test.ts +250 -0
  69. package/tests/engine/utils.test.ts +75 -0
  70. package/tests/integration/game-loop.test.ts +201 -0
  71. package/tests/safety/event-validation.test.ts +102 -0
  72. package/tests/safety/state-schema.test.ts +96 -0
  73. package/tests/setup.ts +2 -0
  74. package/tsconfig.json +14 -0
  75. package/vitest.config.ts +16 -0
@@ -0,0 +1,196 @@
1
+ <template>
2
+ <div class="pause-menu" @click.self="resume">
3
+ <div class="pause-menu__inner">
4
+ <h2 class="pause-menu__title">Paused</h2>
5
+ <div class="pause-menu__separator"></div>
6
+
7
+ <div class="pause-menu__buttons">
8
+ <button class="pause-menu__btn pause-menu__btn--primary" @click="resume">
9
+ <span>Continue</span>
10
+ <span class="pause-menu__btn-icon">&#x25B6;&#xFE0F;</span>
11
+ </button>
12
+
13
+ <button class="pause-menu__btn pause-menu__btn--tertiary" @click="save">
14
+ <span>Save</span>
15
+ <span class="pause-menu__btn-icon">&#x1F4BE;</span>
16
+ </button>
17
+
18
+ <button class="pause-menu__btn pause-menu__btn--tertiary" @click="load">
19
+ <span>Load</span>
20
+ <span class="pause-menu__btn-icon">&#x1F4C2;</span>
21
+ </button>
22
+
23
+ <button class="pause-menu__btn pause-menu__btn--tertiary" @click="settings">
24
+ <span>Settings</span>
25
+ <span class="pause-menu__btn-icon">&#x2699;&#xFE0F;</span>
26
+ </button>
27
+
28
+ <button class="pause-menu__btn pause-menu__btn--danger" @click="mainMenu">
29
+ <span>Main Menu</span>
30
+ <span class="pause-menu__btn-icon">&#x1F3E0;</span>
31
+ </button>
32
+ </div>
33
+
34
+ <p class="pause-menu__footer">ESC to resume</p>
35
+ </div>
36
+ </div>
37
+ </template>
38
+
39
+ <script setup lang="ts">
40
+ import { useEngineStateStore } from '../../stores/engineState';
41
+ import { EnginePhase, MenuType } from '../../engine/types';
42
+
43
+ const engineState = useEngineStateStore();
44
+
45
+ function resume() {
46
+ engineState.closeMenu();
47
+ }
48
+
49
+ function save() {
50
+ engineState.openMenu(MenuType.Save);
51
+ }
52
+
53
+ function load() {
54
+ engineState.openMenu(MenuType.Load);
55
+ }
56
+
57
+ function settings() {
58
+ engineState.openMenu(MenuType.Settings);
59
+ }
60
+
61
+ function mainMenu() {
62
+ engineState.closeMenu();
63
+ engineState.setPhase(EnginePhase.MainMenu);
64
+ }
65
+ </script>
66
+
67
+ <style scoped>
68
+ .pause-menu {
69
+ position: absolute;
70
+ inset: 0;
71
+ z-index: 50;
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: center;
75
+ background: rgba(0, 0, 0, 0.7);
76
+ backdrop-filter: blur(4px);
77
+ container-type: size;
78
+ }
79
+
80
+ .pause-menu__inner {
81
+ display: flex;
82
+ flex-direction: column;
83
+ align-items: center;
84
+ width: 28cqw;
85
+ padding: 3.5cqh 2.5cqw;
86
+ background: rgba(0, 0, 0, 0.6);
87
+ backdrop-filter: blur(8px);
88
+ border: 1px solid rgba(255, 255, 255, 0.1);
89
+ border-radius: 1.5cqw;
90
+ }
91
+
92
+ .pause-menu__title {
93
+ font-size: 3cqw;
94
+ font-weight: 700;
95
+ color: white;
96
+ letter-spacing: 0.2em;
97
+ text-transform: uppercase;
98
+ margin: 0;
99
+ }
100
+
101
+ .pause-menu__separator {
102
+ width: 10cqw;
103
+ height: 0.25cqh;
104
+ margin: 1.5cqh auto;
105
+ background: linear-gradient(90deg, transparent, #8b5cf6, #ec4899, #8b5cf6, transparent);
106
+ border-radius: 9999px;
107
+ }
108
+
109
+ .pause-menu__buttons {
110
+ display: flex;
111
+ flex-direction: column;
112
+ gap: 1.2cqh;
113
+ width: 100%;
114
+ }
115
+
116
+ .pause-menu__btn {
117
+ position: relative;
118
+ display: flex;
119
+ align-items: center;
120
+ justify-content: space-between;
121
+ width: 100%;
122
+ padding: 1.5cqh 2cqw;
123
+ border-radius: 1cqw;
124
+ border: 1px solid rgba(255, 255, 255, 0.12);
125
+ background: rgba(255, 255, 255, 0.04);
126
+ backdrop-filter: blur(8px);
127
+ color: white;
128
+ font-size: 1.6cqw;
129
+ font-weight: 600;
130
+ cursor: pointer;
131
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
132
+ overflow: hidden;
133
+ }
134
+
135
+ .pause-menu__btn::before {
136
+ content: '';
137
+ position: absolute;
138
+ inset: 0;
139
+ opacity: 0;
140
+ transition: opacity 0.25s;
141
+ border-radius: inherit;
142
+ }
143
+
144
+ .pause-menu__btn:hover {
145
+ border-color: rgba(255, 255, 255, 0.3);
146
+ }
147
+
148
+ .pause-menu__btn:hover::before {
149
+ opacity: 1;
150
+ }
151
+
152
+ .pause-menu__btn:active {
153
+ opacity: 0.8;
154
+ }
155
+
156
+ .pause-menu__btn-icon {
157
+ font-size: 2cqw;
158
+ opacity: 0.5;
159
+ transition: opacity 0.25s;
160
+ }
161
+
162
+ .pause-menu__btn:hover .pause-menu__btn-icon {
163
+ opacity: 1;
164
+ }
165
+
166
+ .pause-menu__btn--primary::before {
167
+ background: linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(16, 185, 129, 0.1));
168
+ }
169
+
170
+ .pause-menu__btn--primary:hover {
171
+ box-shadow: 0 0 30px rgba(34, 197, 94, 0.2);
172
+ }
173
+
174
+ .pause-menu__btn--tertiary::before {
175
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03));
176
+ }
177
+
178
+ .pause-menu__btn--tertiary:hover {
179
+ box-shadow: 0 0 20px rgba(255, 255, 255, 0.08);
180
+ }
181
+
182
+ .pause-menu__btn--danger::before {
183
+ background: linear-gradient(135deg, rgba(239, 68, 68, 0.15), rgba(220, 38, 38, 0.1));
184
+ }
185
+
186
+ .pause-menu__btn--danger:hover {
187
+ box-shadow: 0 0 30px rgba(239, 68, 68, 0.2);
188
+ }
189
+
190
+ .pause-menu__footer {
191
+ margin-top: 3cqh;
192
+ font-size: 1.1cqw;
193
+ color: rgba(255, 255, 255, 0.25);
194
+ font-family: monospace;
195
+ }
196
+ </style>
@@ -0,0 +1,377 @@
1
+ <template>
2
+ <div class="sl-overlay" @click.self="engineState.closeMenu()">
3
+ <div class="sl-panel">
4
+ <!-- Header -->
5
+ <div class="sl-header">
6
+ <div class="sl-tabs">
7
+ <button
8
+ class="sl-tab"
9
+ :class="{ 'sl-tab--active': activeTab === 'save' }"
10
+ @click="activeTab = 'save'"
11
+ >
12
+ &#x1F4BE; Save
13
+ </button>
14
+ <button
15
+ class="sl-tab"
16
+ :class="{ 'sl-tab--active': activeTab === 'load' }"
17
+ @click="activeTab = 'load'"
18
+ >
19
+ &#x1F4C2; Load
20
+ </button>
21
+ </div>
22
+ <button class="sl-close" @click="engineState.closeMenu()">&#x2715;</button>
23
+ </div>
24
+
25
+ <div class="sl-separator"></div>
26
+
27
+ <!-- Slot grid -->
28
+ <div class="sl-grid">
29
+ <button
30
+ v-for="slot in pageSlots"
31
+ :key="slot"
32
+ class="sl-slot"
33
+ :class="{ 'sl-slot--empty': !slotMeta[slot] }"
34
+ @click="handleSlot(slot)"
35
+ >
36
+ <!-- Indicator -->
37
+ <div class="sl-slot__header">
38
+ <span class="sl-slot__number">Slot {{ slot }}</span>
39
+ <span v-if="slotMeta[slot]" class="sl-slot__dot"></span>
40
+ </div>
41
+
42
+ <!-- Content -->
43
+ <div v-if="slotMeta[slot]" class="sl-slot__info">
44
+ <span class="sl-slot__label">{{ slotMeta[slot]!.label }}</span>
45
+ <span class="sl-slot__time">{{ formatTime(slotMeta[slot]!.timestamp) }}</span>
46
+ </div>
47
+ <div v-else class="sl-slot__empty-text">Empty</div>
48
+ </button>
49
+ </div>
50
+
51
+ <!-- Pagination -->
52
+ <div v-if="totalPages > 1" class="sl-pagination">
53
+ <button class="sl-page-btn" :disabled="page === 0" @click="page--">
54
+ &#x25C0;
55
+ </button>
56
+ <span class="sl-page-info">{{ page + 1 }} / {{ totalPages }}</span>
57
+ <button class="sl-page-btn" :disabled="page >= totalPages - 1" @click="page++">
58
+ &#x25B6;
59
+ </button>
60
+ </div>
61
+
62
+ <!-- Footer -->
63
+ <p class="sl-footer">ESC to close</p>
64
+ </div>
65
+ </div>
66
+ </template>
67
+
68
+ <script setup lang="ts">
69
+ import { ref, computed, inject, onMounted, watch } from 'vue';
70
+ import { useEngineStateStore } from '../../stores/engineState';
71
+ import { useGameStateStore } from '../../stores/gameState';
72
+ import { ENGINE_INJECT_KEY } from '../../composables/useEngine';
73
+ import { MenuType } from '../../engine/types';
74
+ import type { Engine } from '../../engine/core/Engine';
75
+ import type { SaveMeta } from '../../engine/types';
76
+
77
+ const engine = inject<Engine>(ENGINE_INJECT_KEY);
78
+ const engineState = useEngineStateStore();
79
+ const gameState = useGameStateStore();
80
+
81
+ const TOTAL_SLOTS = 24;
82
+ const PER_PAGE = 8;
83
+
84
+ const activeTab = ref<'save' | 'load'>(
85
+ engineState.menuOpen === MenuType.Load ? 'load' : 'save',
86
+ );
87
+ const page = ref(0);
88
+ const slotMeta = ref<Record<number, SaveMeta>>({});
89
+
90
+ const totalPages = computed(() => Math.ceil(TOTAL_SLOTS / PER_PAGE));
91
+ const pageSlots = computed(() => {
92
+ const start = page.value * PER_PAGE + 1;
93
+ return Array.from({ length: PER_PAGE }, (_, i) => start + i);
94
+ });
95
+
96
+ // Sync tab when menu type changes externally
97
+ watch(() => engineState.menuOpen, (val) => {
98
+ if (val === MenuType.Save) activeTab.value = 'save';
99
+ else if (val === MenuType.Load) activeTab.value = 'load';
100
+ });
101
+
102
+ async function refreshSlots(): Promise<void> {
103
+ if (!engine) return;
104
+ const list = await engine.saveManager.listSlots();
105
+ const map: Record<number, SaveMeta> = {};
106
+ for (const meta of list) {
107
+ map[meta.slot] = meta;
108
+ }
109
+ slotMeta.value = map;
110
+ }
111
+
112
+ onMounted(refreshSlots);
113
+
114
+ async function handleSlot(slot: number): Promise<void> {
115
+ if (!engine) return;
116
+ if (activeTab.value === 'save') {
117
+ await engine.saveManager.save(slot, {
118
+ gameState: gameState.state,
119
+ eventId: engine.eventRunner.currentEventId,
120
+ stepNumber: engine.eventRunner.checkpoints.count,
121
+ label: `Save ${slot}`,
122
+ thumbnail: null,
123
+ checkpoints: engine.eventRunner.currentEventId
124
+ ? engine.eventRunner.checkpoints.getAll()
125
+ : undefined,
126
+ initialState: engine.eventRunner.getInitialState() ?? undefined,
127
+ history: engine.historyManager.getAll(),
128
+ historyIndex: engine.historyManager.index,
129
+ lockedEventIds: engine.eventManager.getLockedIds(),
130
+ });
131
+ await refreshSlots();
132
+ } else {
133
+ const data = await engine.saveManager.load(slot);
134
+ if (data) {
135
+ engine.loadSave(data, (s) => gameState.setState(s));
136
+ if (data.gameState.locationId) {
137
+ engineState.setLocation(data.gameState.locationId);
138
+ }
139
+ engineState.historyBrowsing = false;
140
+ engineState.setDialogue(null);
141
+ engineState.setChoices(null);
142
+ engineState.closeMenu();
143
+ }
144
+ }
145
+ }
146
+
147
+ function formatTime(ts: number): string {
148
+ return new Date(ts).toLocaleString(undefined, {
149
+ month: 'short', day: 'numeric',
150
+ hour: '2-digit', minute: '2-digit',
151
+ });
152
+ }
153
+ </script>
154
+
155
+ <style scoped>
156
+ .sl-overlay {
157
+ position: absolute;
158
+ inset: 0;
159
+ z-index: 50;
160
+ display: flex;
161
+ align-items: center;
162
+ justify-content: center;
163
+ background: rgba(0, 0, 0, 0.7);
164
+ backdrop-filter: blur(4px);
165
+ container-type: size;
166
+ }
167
+
168
+ .sl-panel {
169
+ display: flex;
170
+ flex-direction: column;
171
+ width: 62cqw;
172
+ max-height: 88cqh;
173
+ padding: 3cqh 3cqw;
174
+ background: rgba(0, 0, 0, 0.6);
175
+ backdrop-filter: blur(8px);
176
+ border: 1px solid rgba(255, 255, 255, 0.1);
177
+ border-radius: 1.5cqw;
178
+ overflow-y: auto;
179
+ }
180
+
181
+ /* Header */
182
+ .sl-header {
183
+ display: flex;
184
+ align-items: center;
185
+ justify-content: space-between;
186
+ }
187
+
188
+ .sl-tabs {
189
+ display: flex;
190
+ gap: 0.8cqw;
191
+ }
192
+
193
+ .sl-tab {
194
+ padding: 1cqh 2cqw;
195
+ border-radius: 0.8cqw;
196
+ border: 1px solid rgba(255, 255, 255, 0.1);
197
+ background: rgba(255, 255, 255, 0.03);
198
+ color: rgba(255, 255, 255, 0.5);
199
+ font-size: 1.5cqw;
200
+ font-weight: 600;
201
+ cursor: pointer;
202
+ transition: all 0.2s ease;
203
+ }
204
+
205
+ .sl-tab:hover {
206
+ color: rgba(255, 255, 255, 0.8);
207
+ border-color: rgba(255, 255, 255, 0.25);
208
+ }
209
+
210
+ .sl-tab--active {
211
+ background: rgba(255, 255, 255, 0.1);
212
+ border-color: rgba(255, 255, 255, 0.3);
213
+ color: white;
214
+ }
215
+
216
+ .sl-close {
217
+ background: none;
218
+ border: none;
219
+ color: rgba(255, 255, 255, 0.4);
220
+ font-size: 2cqw;
221
+ cursor: pointer;
222
+ padding: 0.5cqh 0.8cqw;
223
+ transition: color 0.2s;
224
+ }
225
+
226
+ .sl-close:hover {
227
+ color: white;
228
+ }
229
+
230
+ /* Separator */
231
+ .sl-separator {
232
+ width: 100%;
233
+ height: 1px;
234
+ margin: 1.5cqh 0;
235
+ background: linear-gradient(90deg, transparent, rgba(139, 92, 246, 0.4), rgba(236, 72, 153, 0.4), rgba(139, 92, 246, 0.4), transparent);
236
+ }
237
+
238
+ /* Grid */
239
+ .sl-grid {
240
+ display: grid;
241
+ grid-template-columns: repeat(4, 1fr);
242
+ gap: 1.2cqh 1cqw;
243
+ }
244
+
245
+ .sl-slot {
246
+ position: relative;
247
+ display: flex;
248
+ flex-direction: column;
249
+ padding: 1.2cqh 1.2cqw;
250
+ border-radius: 0.8cqw;
251
+ border: 1px solid rgba(255, 255, 255, 0.12);
252
+ background: rgba(255, 255, 255, 0.04);
253
+ cursor: pointer;
254
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
255
+ min-height: 8cqh;
256
+ }
257
+
258
+ .sl-slot:hover {
259
+ border-color: rgba(255, 255, 255, 0.3);
260
+ background: rgba(255, 255, 255, 0.08);
261
+ box-shadow: 0 0 20px rgba(59, 130, 246, 0.15);
262
+ transform: scale(1.02);
263
+ }
264
+
265
+ .sl-slot:active {
266
+ opacity: 0.8;
267
+ }
268
+
269
+ .sl-slot--empty {
270
+ opacity: 0.6;
271
+ }
272
+
273
+ .sl-slot--empty:hover {
274
+ opacity: 1;
275
+ }
276
+
277
+ /* Slot content */
278
+ .sl-slot__header {
279
+ display: flex;
280
+ align-items: center;
281
+ justify-content: space-between;
282
+ margin-bottom: 0.6cqh;
283
+ }
284
+
285
+ .sl-slot__number {
286
+ font-size: 1cqw;
287
+ color: rgba(255, 255, 255, 0.45);
288
+ font-family: monospace;
289
+ }
290
+
291
+ .sl-slot__dot {
292
+ width: 0.6cqw;
293
+ height: 0.6cqw;
294
+ border-radius: 50%;
295
+ background: #4ade80;
296
+ box-shadow: 0 0 6px rgba(74, 222, 128, 0.6);
297
+ animation: sl-pulse 2s ease-in-out infinite;
298
+ }
299
+
300
+ @keyframes sl-pulse {
301
+ 0%, 100% { opacity: 1; }
302
+ 50% { opacity: 0.4; }
303
+ }
304
+
305
+ .sl-slot__info {
306
+ display: flex;
307
+ flex-direction: column;
308
+ gap: 0.3cqh;
309
+ }
310
+
311
+ .sl-slot__label {
312
+ font-size: 1.2cqw;
313
+ color: white;
314
+ font-weight: 600;
315
+ white-space: nowrap;
316
+ overflow: hidden;
317
+ text-overflow: ellipsis;
318
+ }
319
+
320
+ .sl-slot__time {
321
+ font-size: 0.9cqw;
322
+ color: rgba(255, 255, 255, 0.35);
323
+ font-family: monospace;
324
+ }
325
+
326
+ .sl-slot__empty-text {
327
+ font-size: 1.1cqw;
328
+ color: rgba(255, 255, 255, 0.2);
329
+ font-style: italic;
330
+ }
331
+
332
+ /* Pagination */
333
+ .sl-pagination {
334
+ display: flex;
335
+ align-items: center;
336
+ justify-content: center;
337
+ gap: 2cqw;
338
+ margin-top: 2cqh;
339
+ }
340
+
341
+ .sl-page-btn {
342
+ background: rgba(255, 255, 255, 0.06);
343
+ border: 1px solid rgba(255, 255, 255, 0.15);
344
+ border-radius: 0.6cqw;
345
+ color: rgba(255, 255, 255, 0.6);
346
+ font-size: 1.2cqw;
347
+ padding: 0.5cqh 1.2cqw;
348
+ cursor: pointer;
349
+ transition: all 0.2s;
350
+ }
351
+
352
+ .sl-page-btn:hover:not(:disabled) {
353
+ background: rgba(255, 255, 255, 0.1);
354
+ border-color: rgba(255, 255, 255, 0.3);
355
+ color: white;
356
+ }
357
+
358
+ .sl-page-btn:disabled {
359
+ opacity: 0.3;
360
+ cursor: not-allowed;
361
+ }
362
+
363
+ .sl-page-info {
364
+ font-size: 1.1cqw;
365
+ color: rgba(255, 255, 255, 0.4);
366
+ font-family: monospace;
367
+ }
368
+
369
+ /* Footer */
370
+ .sl-footer {
371
+ margin-top: 2cqh;
372
+ text-align: center;
373
+ font-size: 1cqw;
374
+ color: rgba(255, 255, 255, 0.2);
375
+ font-family: monospace;
376
+ }
377
+ </style>
@@ -0,0 +1,111 @@
1
+ <template>
2
+ <div class="fixed inset-0 z-50 bg-black/70 flex items-center justify-center" @click.self="engineState.closeMenu()">
3
+ <div class="bg-gray-900 border border-white/20 rounded-lg w-full max-w-lg p-6">
4
+ <!-- Header -->
5
+ <div class="flex items-center justify-between mb-6">
6
+ <h2 class="text-white font-bold text-lg">&#9881; Settings</h2>
7
+ <button class="text-white/50 hover:text-white text-xl" @click="engineState.closeMenu()">&times;</button>
8
+ </div>
9
+
10
+ <div class="flex flex-col gap-5 text-sm text-white/80">
11
+ <!-- Text Speed -->
12
+ <label class="flex items-center justify-between gap-4">
13
+ <span>Text Speed</span>
14
+ <input type="range" min="1" max="10" v-model.number="local.textSpeed" class="flex-1 max-w-48" />
15
+ <span class="w-4 text-right">{{ local.textSpeed }}</span>
16
+ </label>
17
+
18
+ <!-- Auto Speed -->
19
+ <label class="flex items-center justify-between gap-4">
20
+ <span>Auto Speed</span>
21
+ <input type="range" min="1" max="10" v-model.number="local.autoSpeed" class="flex-1 max-w-48" />
22
+ <span class="w-4 text-right">{{ local.autoSpeed }}</span>
23
+ </label>
24
+
25
+ <!-- Volume: Master -->
26
+ <label class="flex items-center justify-between gap-4">
27
+ <span>&#128266; Master</span>
28
+ <input type="range" min="0" max="100" v-model.number="local.volume.master" class="flex-1 max-w-48" />
29
+ <span class="w-8 text-right">{{ local.volume.master }}</span>
30
+ </label>
31
+
32
+ <!-- Volume: Music -->
33
+ <label class="flex items-center justify-between gap-4">
34
+ <span>&#127925; Music</span>
35
+ <input type="range" min="0" max="100" v-model.number="local.volume.music" class="flex-1 max-w-48" />
36
+ <span class="w-8 text-right">{{ local.volume.music }}</span>
37
+ </label>
38
+
39
+ <!-- Volume: SFX -->
40
+ <label class="flex items-center justify-between gap-4">
41
+ <span>&#128232; SFX</span>
42
+ <input type="range" min="0" max="100" v-model.number="local.volume.sfx" class="flex-1 max-w-48" />
43
+ <span class="w-8 text-right">{{ local.volume.sfx }}</span>
44
+ </label>
45
+
46
+ <!-- Language -->
47
+ <label class="flex items-center justify-between gap-4">
48
+ <span>&#127760; Language</span>
49
+ <select v-model="local.language" class="bg-gray-800 border border-white/20 text-white rounded px-2 py-1">
50
+ <option v-for="lang in languages" :key="lang.code" :value="lang.code">{{ lang.label }}</option>
51
+ </select>
52
+ </label>
53
+
54
+ <!-- Fullscreen -->
55
+ <label class="flex items-center justify-between gap-4 cursor-pointer">
56
+ <span>Fullscreen</span>
57
+ <input type="checkbox" v-model="local.fullscreen" class="w-4 h-4" />
58
+ </label>
59
+ </div>
60
+
61
+ <!-- Actions -->
62
+ <div class="flex justify-end gap-3 mt-8">
63
+ <button class="px-4 py-2 text-white/60 hover:text-white transition-colors" @click="engineState.closeMenu()">
64
+ Cancel
65
+ </button>
66
+ <button
67
+ class="px-4 py-2 bg-white text-black font-semibold rounded hover:bg-gray-200 transition-colors"
68
+ @click="apply"
69
+ >
70
+ Apply
71
+ </button>
72
+ </div>
73
+ </div>
74
+ </div>
75
+ </template>
76
+
77
+ <script setup lang="ts">
78
+ import { reactive, inject, onMounted } from 'vue';
79
+ import { useEngineStateStore } from '../../stores/engineState';
80
+ import { ENGINE_INJECT_KEY } from '../../composables/useEngine';
81
+ import type { Engine } from '../../engine/core/Engine';
82
+ import type { EngineSettings } from '../../engine/types';
83
+
84
+ const engine = inject<Engine>(ENGINE_INJECT_KEY);
85
+ const engineState = useEngineStateStore();
86
+
87
+ // Local copy — only committed on Apply
88
+ const local = reactive<EngineSettings>({
89
+ textSpeed: 50,
90
+ autoSpeed: 3,
91
+ volume: { master: 80, music: 70, sfx: 80, voice: 100 },
92
+ language: 'en',
93
+ fullscreen: false,
94
+ });
95
+
96
+ const languages = [{ code: 'en', label: 'English' }];
97
+
98
+ onMounted(() => {
99
+ if (engine) {
100
+ const current = engine.settingsManager.settings;
101
+ Object.assign(local, structuredClone(current));
102
+ }
103
+ });
104
+
105
+ async function apply(): Promise<void> {
106
+ if (engine) {
107
+ await engine.settingsManager.update({ ...local });
108
+ }
109
+ engineState.closeMenu();
110
+ }
111
+ </script>