@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
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@vylos/core",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./src/index.ts",
6
+ "types": "./src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./components/*": "./src/components/*",
10
+ "./stores/*": "./src/stores/*",
11
+ "./composables/*": "./src/composables/*"
12
+ },
13
+ "dependencies": {
14
+ "pinia": "^2.3.0",
15
+ "pinia-plugin-persistedstate": "^3.2.3",
16
+ "reflect-metadata": "^0.2.2",
17
+ "tsyringe": "^4.8.0",
18
+ "vue": "^3.5.13",
19
+ "zod": "^3.24.1"
20
+ },
21
+ "devDependencies": {
22
+ "@vue/test-utils": "^2.4.6",
23
+ "fake-indexeddb": "^6.2.5",
24
+ "happy-dom": "^15.11.7",
25
+ "typescript": "^5.7.2",
26
+ "vitest": "^2.1.8",
27
+ "vue-tsc": "^2.2.0"
28
+ },
29
+ "scripts": {
30
+ "test": "vitest run",
31
+ "test:watch": "vitest",
32
+ "type-check": "vue-tsc --noEmit"
33
+ }
34
+ }
@@ -0,0 +1,40 @@
1
+ <template>
2
+ <div class="relative w-full h-full overflow-hidden bg-black" style="container-type: size">
3
+ <!-- z-0: Background -->
4
+ <BackgroundLayer />
5
+
6
+ <!-- z-10: Foreground / character sprites -->
7
+ <ForegroundLayer />
8
+
9
+ <!-- z-15: Drawable events (clickable characters/objects) -->
10
+ <DrawableOverlay />
11
+
12
+ <!-- z-20: Location + Action overlays (left / right panels) -->
13
+ <LocationOverlay />
14
+ <ActionOverlay />
15
+
16
+ <!-- z-25: Top bar -->
17
+ <TopBar />
18
+
19
+ <!-- z-30: Dialogue box -->
20
+ <DialogueBox />
21
+
22
+ <!-- z-35: Choice panel (inside DialogueBox z-range) -->
23
+ <ChoicePanel />
24
+
25
+ <!-- z-40: Custom overlay -->
26
+ <CustomOverlay />
27
+ </div>
28
+ </template>
29
+
30
+ <script setup lang="ts">
31
+ import BackgroundLayer from '../core/BackgroundLayer.vue';
32
+ import ForegroundLayer from '../core/ForegroundLayer.vue';
33
+ import DrawableOverlay from '../core/DrawableOverlay.vue';
34
+ import DialogueBox from '../core/DialogueBox.vue';
35
+ import ChoicePanel from '../core/ChoicePanel.vue';
36
+ import CustomOverlay from '../core/CustomOverlay.vue';
37
+ import ActionOverlay from '../menu/ActionOverlay.vue';
38
+ import LocationOverlay from '../menu/LocationOverlay.vue';
39
+ import TopBar from '../menu/TopBar.vue';
40
+ </script>
@@ -0,0 +1,265 @@
1
+ <template>
2
+ <!-- Outer: full screen, centering -->
3
+ <div class="game-shell">
4
+ <!-- Blurred background that extends the game image to the edges -->
5
+ <div class="game-shell__bg" :style="shellBgStyle"></div>
6
+
7
+ <!-- Inner: ratio-locked viewport, container-query root -->
8
+ <div class="game-viewport" :style="viewportStyle" @click="handleViewportClick">
9
+ <!-- Loading -->
10
+ <LoadingScreen v-if="isLoading" />
11
+
12
+ <!-- Main menu -->
13
+ <MainMenu v-else-if="isMainMenu" />
14
+
15
+ <!-- Game view -->
16
+ <EngineView v-else-if="isRunning" />
17
+
18
+ <!-- Save/Load menu (modal, shown over game or main menu) -->
19
+ <SaveLoadMenu v-if="engineState.menuOpen === MenuType.Save || engineState.menuOpen === MenuType.Load" />
20
+
21
+ <!-- Settings menu (modal) -->
22
+ <SettingsMenu v-if="engineState.menuOpen === MenuType.Settings" />
23
+
24
+ <!-- Pause menu (modal, shown on Escape during gameplay) -->
25
+ <PauseMenu v-if="engineState.menuOpen === MenuType.PauseMenu" />
26
+ </div>
27
+ </div>
28
+ </template>
29
+
30
+ <script setup lang="ts">
31
+ import { computed, inject, onMounted, onUnmounted } from 'vue';
32
+ import { useEngineStateStore } from '../../stores/engineState';
33
+ import { EnginePhase, MenuType } from '../../engine/types';
34
+ import type { Engine } from '../../engine/core/Engine';
35
+ import type { HistoryStep } from '../../engine/core/EventRunner';
36
+ import { ENGINE_INJECT_KEY } from '../../composables/useEngine';
37
+ import { CONFIG_INJECT_KEY } from '../../composables/useConfig';
38
+ import { InputManager } from '../../engine/managers/InputManager';
39
+
40
+ import LoadingScreen from './LoadingScreen.vue';
41
+ import MainMenu from './MainMenu.vue';
42
+ import EngineView from './EngineView.vue';
43
+ import SaveLoadMenu from '../menu/SaveLoadMenu.vue';
44
+ import SettingsMenu from '../menu/SettingsMenu.vue';
45
+ import PauseMenu from '../menu/PauseMenu.vue';
46
+
47
+ const props = withDefaults(defineProps<{
48
+ gameTitle?: string;
49
+ projectId?: string;
50
+ }>(), {
51
+ projectId: 'default',
52
+ });
53
+
54
+ // Engine is provided by main.ts via app.provide()
55
+ const engine = inject<Engine>(ENGINE_INJECT_KEY);
56
+ const config = inject(CONFIG_INJECT_KEY);
57
+ const engineState = useEngineStateStore();
58
+ const inputManager = new InputManager();
59
+
60
+ // --- Shell blurred background ---
61
+
62
+ const MENU_BG = '/global/images/menu/main.png';
63
+
64
+ const shellBgStyle = computed(() => {
65
+ const bg = engineState.background || MENU_BG;
66
+ return {
67
+ backgroundImage: `url('${bg}')`,
68
+ };
69
+ });
70
+
71
+ // --- Viewport style ---
72
+
73
+ const viewportStyle = computed(() => {
74
+ if (!config) return { width: '100vw', height: '100vh' };
75
+ const ratio = config.resolution.width / config.resolution.height;
76
+ return {
77
+ width: `min(100vw, calc(100vh * ${ratio}))`,
78
+ height: `min(100vh, calc(100vw / ${ratio}))`,
79
+ };
80
+ });
81
+
82
+ // --- Computed ---
83
+
84
+ const isLoading = computed(() => engineState.phase === EnginePhase.Loading);
85
+ const isMainMenu = computed(() => engineState.phase === EnginePhase.MainMenu);
86
+ const isRunning = computed(() =>
87
+ engineState.phase === EnginePhase.Running ||
88
+ engineState.phase === EnginePhase.Paused
89
+ );
90
+
91
+ // --- History step helpers ---
92
+
93
+ function applyHistoryStep(step: HistoryStep): void {
94
+ if (step.type === 'say' && step.dialogue) {
95
+ engineState.setDialogue(step.dialogue);
96
+ engineState.setChoices(null);
97
+ } else if (step.type === 'choice' && step.choiceOptions) {
98
+ engineState.setDialogue(null);
99
+ engineState.setChoices({
100
+ prompt: null,
101
+ options: step.choiceOptions,
102
+ historyStepIndex: step.stepIndex,
103
+ historySelectedValue: step.choiceResult,
104
+ });
105
+ }
106
+ }
107
+
108
+ function restoreLiveDisplay(): void {
109
+ const live = engine!.eventRunner.getLiveDialogue();
110
+ if (live) {
111
+ engineState.setDialogue({
112
+ text: live.text,
113
+ speaker: live.speaker,
114
+ isNarration: !live.speaker,
115
+ });
116
+ }
117
+ engineState.setChoices(null);
118
+ }
119
+
120
+ // --- Keyboard continue (Space/Enter) ---
121
+
122
+ function handleKeyboardContinue(): void {
123
+ if (!engine) return;
124
+
125
+ // If browsing history, forward exits history first
126
+ if (engine.eventRunner.isBrowsingHistory) {
127
+ const step = engine.eventRunner.historyForward();
128
+ if (step) {
129
+ applyHistoryStep(step);
130
+ } else if (!engine.eventRunner.isBrowsingHistory) {
131
+ engineState.historyBrowsing = false;
132
+ restoreLiveDisplay();
133
+ }
134
+ return;
135
+ }
136
+
137
+ if (engineState.dialogue) {
138
+ // Inside a say() call — resolve the wait to advance dialogue
139
+ engine.eventRunner.resolveWait();
140
+ } else if (!engineState.choices) {
141
+ // Between events — tell the engine to continue the loop
142
+ engine.navigationManager.continue();
143
+ }
144
+ // If choices are showing, ignore continue (player must click a choice)
145
+ }
146
+
147
+ function handleBack(): void {
148
+ if (!engine) return;
149
+
150
+ if (engineState.dialogue || engine.eventRunner.isBrowsingHistory) {
151
+ // During dialogue or already browsing — browse text history
152
+ const step = engine.eventRunner.historyBack();
153
+ if (step) {
154
+ engineState.historyBrowsing = true;
155
+ applyHistoryStep(step);
156
+ }
157
+ } else {
158
+ engine.navigationManager.goBack();
159
+ }
160
+ }
161
+
162
+ function handleForward(): void {
163
+ if (!engine) return;
164
+
165
+ if (engine.eventRunner.isBrowsingHistory) {
166
+ // In history mode — advance through history
167
+ const step = engine.eventRunner.historyForward();
168
+ if (step) {
169
+ applyHistoryStep(step);
170
+ } else if (!engine.eventRunner.isBrowsingHistory) {
171
+ engineState.historyBrowsing = false;
172
+ restoreLiveDisplay();
173
+ }
174
+ } else if (engineState.dialogue) {
175
+ // At live dialogue — same as continue
176
+ engine.eventRunner.resolveWait();
177
+ } else {
178
+ engine.navigationManager.goForward();
179
+ }
180
+ }
181
+
182
+ // --- Mouse click navigation (left half = back, right half = forward) ---
183
+
184
+ function handleViewportClick(e: MouseEvent): void {
185
+ if (!engine) return;
186
+ // Only during Running phase, no menu open, no choices showing
187
+ if (!isRunning.value || engineState.menuOpen || engineState.choices) return;
188
+
189
+ const target = e.target as HTMLElement;
190
+ // Don't intercept clicks on interactive elements (buttons, overlays, etc.)
191
+ if (target.closest('button, a, [role="button"]')) return;
192
+
193
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
194
+ const midpoint = rect.left + rect.width / 2;
195
+
196
+ if (e.clientX >= midpoint) {
197
+ handleKeyboardContinue();
198
+ } else {
199
+ handleBack();
200
+ }
201
+ }
202
+
203
+ // --- Keyboard input ---
204
+
205
+ onMounted(() => {
206
+ inputManager.start((action) => {
207
+ // Don't process keyboard when a menu is open
208
+ if (engineState.menuOpen) {
209
+ if (action === 'menu') engineState.closeMenu();
210
+ return;
211
+ }
212
+
213
+ switch (action) {
214
+ case 'continue':
215
+ handleKeyboardContinue();
216
+ break;
217
+ case 'menu':
218
+ engineState.openMenu(MenuType.PauseMenu);
219
+ break;
220
+ case 'back':
221
+ handleBack();
222
+ break;
223
+ case 'forward':
224
+ handleForward();
225
+ break;
226
+ case 'skip-toggle':
227
+ inputManager.toggleSkip();
228
+ engineState.skipMode = !engineState.skipMode;
229
+ break;
230
+ }
231
+ });
232
+ });
233
+
234
+ onUnmounted(() => {
235
+ inputManager.stop();
236
+ });
237
+ </script>
238
+
239
+ <style scoped>
240
+ .game-shell {
241
+ position: relative;
242
+ width: 100vw;
243
+ height: 100vh;
244
+ display: flex;
245
+ align-items: center;
246
+ justify-content: center;
247
+ background: #000;
248
+ overflow: hidden;
249
+ }
250
+
251
+ .game-shell__bg {
252
+ position: absolute;
253
+ inset: -20px;
254
+ background-size: cover;
255
+ background-position: center;
256
+ filter: blur(30px) brightness(0.5);
257
+ pointer-events: none;
258
+ }
259
+
260
+ .game-viewport {
261
+ position: relative;
262
+ overflow: hidden;
263
+ container-type: size;
264
+ }
265
+ </style>
@@ -0,0 +1,10 @@
1
+ <template>
2
+ <div class="fixed inset-0 bg-black flex flex-col items-center justify-center z-50">
3
+ <div class="text-white text-2xl font-mono animate-pulse">Loading...</div>
4
+ <div class="mt-4 w-8 h-8 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
5
+ </div>
6
+ </template>
7
+
8
+ <script setup lang="ts">
9
+ // No props — this is a pure presentational loading screen.
10
+ </script>
@@ -0,0 +1,243 @@
1
+ <template>
2
+ <div class="main-menu">
3
+ <div class="main-menu__inner">
4
+ <!-- Title -->
5
+ <div class="main-menu__title-block">
6
+ <h1 class="main-menu__title">Vylos</h1>
7
+ <div class="main-menu__separator"></div>
8
+ <p class="main-menu__subtitle">Visual Novel Engine</p>
9
+ </div>
10
+
11
+ <!-- Buttons -->
12
+ <div class="main-menu__buttons">
13
+ <button class="main-menu__btn main-menu__btn--primary" @click="newGame">
14
+ <span>New Game</span>
15
+ <span class="main-menu__btn-icon">&#x2728;</span>
16
+ </button>
17
+
18
+ <button v-if="canContinue" class="main-menu__btn main-menu__btn--secondary" @click="continueGame">
19
+ <span>Continue</span>
20
+ <span class="main-menu__btn-icon">&#x25B6;&#xFE0F;</span>
21
+ </button>
22
+
23
+ <button class="main-menu__btn main-menu__btn--tertiary" @click="loadGame">
24
+ <span>Load</span>
25
+ <span class="main-menu__btn-icon">&#x1F4C2;</span>
26
+ </button>
27
+
28
+ <button class="main-menu__btn main-menu__btn--tertiary" @click="openSettings">
29
+ <span>Settings</span>
30
+ <span class="main-menu__btn-icon">&#x2699;&#xFE0F;</span>
31
+ </button>
32
+ </div>
33
+
34
+ <!-- Footer -->
35
+ <p class="main-menu__footer">Press ESC during gameplay to pause</p>
36
+ </div>
37
+ </div>
38
+ </template>
39
+
40
+ <script setup lang="ts">
41
+ import { computed } from 'vue';
42
+ import { useEngineStateStore } from '../../stores/engineState';
43
+ import { useGameStateStore } from '../../stores/gameState';
44
+ import { EnginePhase, MenuType } from '../../engine/types';
45
+
46
+ const engineState = useEngineStateStore();
47
+ const gameState = useGameStateStore();
48
+
49
+ const canContinue = computed(() => !!gameState.state.locationId);
50
+
51
+ function newGame() {
52
+ engineState.setPhase(EnginePhase.Running);
53
+ }
54
+
55
+ function continueGame() {
56
+ engineState.setPhase(EnginePhase.Running);
57
+ }
58
+
59
+ function loadGame() {
60
+ engineState.openMenu(MenuType.Load);
61
+ }
62
+
63
+ function openSettings() {
64
+ engineState.openMenu(MenuType.Settings);
65
+ }
66
+ </script>
67
+
68
+ <style scoped>
69
+ .main-menu {
70
+ position: absolute;
71
+ inset: 0;
72
+ z-index: 40;
73
+ display: flex;
74
+ align-items: center;
75
+ justify-content: center;
76
+ background: url('/global/images/menu/main.png') center / cover no-repeat;
77
+ container-type: size;
78
+ overflow: hidden;
79
+ }
80
+
81
+ .main-menu::after {
82
+ content: '';
83
+ position: absolute;
84
+ inset: 0;
85
+ background: rgba(0, 0, 0, 0.25);
86
+ pointer-events: none;
87
+ }
88
+
89
+ /* Subtle radial glow behind the content */
90
+ .main-menu::before {
91
+ content: '';
92
+ position: absolute;
93
+ z-index: 1;
94
+ top: 30%;
95
+ left: 50%;
96
+ translate: -50% -50%;
97
+ width: 60cqw;
98
+ height: 60cqw;
99
+ background: radial-gradient(circle, rgba(139, 92, 246, 0.08) 0%, transparent 70%);
100
+ pointer-events: none;
101
+ }
102
+
103
+ .main-menu__inner {
104
+ position: relative;
105
+ z-index: 1;
106
+ display: flex;
107
+ flex-direction: column;
108
+ align-items: center;
109
+ width: 30cqw;
110
+ max-height: calc(100cqh - 8cqh);
111
+ overflow-y: auto;
112
+ padding: 4cqh 3cqw;
113
+ background: rgba(0, 0, 0, 0.6);
114
+ backdrop-filter: blur(8px);
115
+ border: 1px solid rgba(255, 255, 255, 0.1);
116
+ border-radius: 1.5cqw;
117
+ }
118
+
119
+ /* Title block */
120
+ .main-menu__title-block {
121
+ text-align: center;
122
+ margin-bottom: 5cqh;
123
+ }
124
+
125
+ .main-menu__title {
126
+ font-size: 5cqw;
127
+ font-weight: 700;
128
+ color: white;
129
+ letter-spacing: 0.3em;
130
+ text-transform: uppercase;
131
+ margin: 0;
132
+ text-shadow: 0 0 40px rgba(139, 92, 246, 0.3);
133
+ }
134
+
135
+ .main-menu__separator {
136
+ width: 12cqw;
137
+ height: 0.25cqh;
138
+ margin: 1.5cqh auto;
139
+ background: linear-gradient(90deg, transparent, #8b5cf6, #ec4899, #8b5cf6, transparent);
140
+ border-radius: 9999px;
141
+ }
142
+
143
+ .main-menu__subtitle {
144
+ font-size: 1.4cqw;
145
+ color: rgba(255, 255, 255, 0.4);
146
+ letter-spacing: 0.25em;
147
+ text-transform: uppercase;
148
+ font-family: monospace;
149
+ margin: 0;
150
+ }
151
+
152
+ /* Buttons */
153
+ .main-menu__buttons {
154
+ display: flex;
155
+ flex-direction: column;
156
+ gap: 1.5cqh;
157
+ width: 100%;
158
+ }
159
+
160
+ .main-menu__btn {
161
+ position: relative;
162
+ display: flex;
163
+ align-items: center;
164
+ justify-content: space-between;
165
+ width: 100%;
166
+ padding: 1.8cqh 2cqw;
167
+ border-radius: 1cqw;
168
+ border: 1px solid rgba(255, 255, 255, 0.12);
169
+ background: rgba(255, 255, 255, 0.04);
170
+ backdrop-filter: blur(8px);
171
+ color: white;
172
+ font-size: 1.8cqw;
173
+ font-weight: 600;
174
+ cursor: pointer;
175
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
176
+ overflow: hidden;
177
+ }
178
+
179
+ .main-menu__btn::before {
180
+ content: '';
181
+ position: absolute;
182
+ inset: 0;
183
+ opacity: 0;
184
+ transition: opacity 0.25s;
185
+ border-radius: inherit;
186
+ }
187
+
188
+ .main-menu__btn:hover {
189
+ border-color: rgba(255, 255, 255, 0.3);
190
+ }
191
+
192
+ .main-menu__btn:hover::before {
193
+ opacity: 1;
194
+ }
195
+
196
+ .main-menu__btn:active {
197
+ opacity: 0.8;
198
+ }
199
+
200
+ /* Icon */
201
+ .main-menu__btn-icon {
202
+ font-size: 2.2cqw;
203
+ opacity: 0.5;
204
+ transition: opacity 0.25s;
205
+ }
206
+
207
+ .main-menu__btn:hover .main-menu__btn-icon {
208
+ opacity: 1;
209
+ }
210
+
211
+ /* Variants */
212
+ .main-menu__btn--primary::before {
213
+ background: linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(16, 185, 129, 0.1));
214
+ }
215
+
216
+ .main-menu__btn--primary:hover {
217
+ box-shadow: 0 0 30px rgba(34, 197, 94, 0.2);
218
+ }
219
+
220
+ .main-menu__btn--secondary::before {
221
+ background: linear-gradient(135deg, rgba(59, 130, 246, 0.15), rgba(99, 102, 241, 0.1));
222
+ }
223
+
224
+ .main-menu__btn--secondary:hover {
225
+ box-shadow: 0 0 30px rgba(59, 130, 246, 0.2);
226
+ }
227
+
228
+ .main-menu__btn--tertiary::before {
229
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03));
230
+ }
231
+
232
+ .main-menu__btn--tertiary:hover {
233
+ box-shadow: 0 0 20px rgba(255, 255, 255, 0.08);
234
+ }
235
+
236
+ /* Footer */
237
+ .main-menu__footer {
238
+ margin-top: 4cqh;
239
+ font-size: 1.1cqw;
240
+ color: rgba(255, 255, 255, 0.25);
241
+ font-family: monospace;
242
+ }
243
+ </style>
@@ -0,0 +1,27 @@
1
+ <template>
2
+ <Transition name="bg-fade">
3
+ <div
4
+ v-if="engineState.background"
5
+ :key="engineState.background"
6
+ class="absolute inset-0 bg-cover bg-center z-0"
7
+ :style="{ backgroundImage: `url('${engineState.background}')` }"
8
+ />
9
+ </Transition>
10
+ </template>
11
+
12
+ <script setup lang="ts">
13
+ import { useEngineStateStore } from '../../stores/engineState';
14
+
15
+ const engineState = useEngineStateStore();
16
+ </script>
17
+
18
+ <style scoped>
19
+ .bg-fade-enter-active,
20
+ .bg-fade-leave-active {
21
+ transition: opacity 0.6s ease;
22
+ }
23
+ .bg-fade-enter-from,
24
+ .bg-fade-leave-to {
25
+ opacity: 0;
26
+ }
27
+ </style>