@wasm-gaming/engine-specs 0.2.3 → 0.2.5

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.
@@ -0,0 +1,540 @@
1
+ <div class="esc-menu-wrapper">
2
+ <!-- Fullscreen Modal Overlay -->
3
+ <div :if="open" class="esc-overlay" @click.self="$emit('close')">
4
+ <div class="esc-panel" tabindex="-1">
5
+ <!-- Header -->
6
+ <header class="esc-header">
7
+ <span class="esc-badge">{{ messages.escMenuBadge || 'PAUSED' }}</span>
8
+ <h2>{{ title }}</h2>
9
+ <button type="button" class="esc-header-exit-btn" @click="$emit('exit')">
10
+ <i class="esc-icon exit"></i>
11
+ {{ messages.escMenuExit || 'Exit to Launcher' }}
12
+ </button>
13
+ <button type="button" class="esc-close-btn" @click="$emit('close')" aria-label="Close menu">&times;</button>
14
+ </header>
15
+
16
+ <!-- Scrollable Options Body -->
17
+ <div class="esc-body">
18
+ <div :each="group in optionGroups" class="esc-group">
19
+ <h3 class="esc-group-title">{{ group.label }}</h3>
20
+
21
+ <div :each="option in group.options" class="esc-row" :class.active="activeKey === option.key"
22
+ @mouseenter="onHoverOption(option)" @click="onSelectOption(option)">
23
+
24
+ <div class="esc-row-label">
25
+ {{ option.label }}
26
+ <span :if="option.requiresReset" class="esc-tag">{{ messages.escMenuNeedsResetTag || 'needs reset' }}</span>
27
+ </div>
28
+
29
+ <div class="esc-row-control">
30
+ <!-- Boolean Chips: Off / On -->
31
+ <div :if="option.type === 'boolean'" class="esc-chip-group">
32
+ <button type="button" class="esc-chip" :class.active="!option.value"
33
+ @click.stop="onChangeOption(option, false)">
34
+ Off
35
+ </button>
36
+ <button type="button" class="esc-chip" :class.active="option.value"
37
+ @click.stop="onChangeOption(option, true)">
38
+ On
39
+ </button>
40
+ </div>
41
+
42
+ <!-- Enum Choice Chips -->
43
+ <div :if="option.type === 'enum' || option.type === 'select'" class="esc-chip-group">
44
+ <button :each="choice in (option.values || option.choices || [])" type="button" class="esc-chip"
45
+ :class.active="option.value === choice.value" @click.stop="onChangeOption(option, choice.value)">
46
+ {{ choice.label }}
47
+ </button>
48
+ </div>
49
+ </div>
50
+ </div>
51
+ </div>
52
+ </div>
53
+
54
+ <!-- Footer with Description, Actions & Shortcut Guide -->
55
+ <footer class="esc-footer">
56
+ <p class="esc-desc">{{ activeDescription || 'Select an option to view details.' }}</p>
57
+
58
+ <div class="esc-actions">
59
+ <button type="button" class="esc-action-btn primary" @click="$emit('close')">
60
+ <i class="esc-icon resume"></i>
61
+ {{ messages.escMenuResume || 'Resume' }}
62
+ </button>
63
+
64
+ <button type="button" class="esc-action-btn" @click="$emit('restore-defaults')">
65
+ <i class="esc-icon defaults"></i>
66
+ {{ messages.escMenuRestoreDefaults || 'Restore defaults' }}
67
+ </button>
68
+
69
+ <button type="button" class="esc-action-btn" @click="$emit('reset')">
70
+ <i class="esc-icon reset"></i>
71
+ {{ messages.escMenuReset || 'Reset game' }}
72
+ </button>
73
+
74
+ <button :if="hasSaveStates" type="button" class="esc-action-btn" @click="$emit('save-state')">
75
+ <i class="esc-icon save"></i>
76
+ {{ messages.escMenuSaveState || 'Save State' }}
77
+ </button>
78
+
79
+ <button :if="hasSaveStates" type="button" class="esc-action-btn" @click="$emit('load-state')">
80
+ <i class="esc-icon load"></i>
81
+ {{ messages.escMenuLoadState || 'Load State' }}
82
+ </button>
83
+
84
+ <button :each="item in customItems" type="button" class="esc-action-btn custom" @click="$emit('custom-action', item)">
85
+ {{ item.label }}
86
+ </button>
87
+ </div>
88
+
89
+ <p class="esc-keys-hint">{{ messages.escMenuKeysHint || '↑ ↓ select · ← → change · Enter apply · Esc close' }}</p>
90
+ </footer>
91
+ </div>
92
+ </div>
93
+ </div>
94
+
95
+ <script :setup="{
96
+ open = false,
97
+ title = 'Settings',
98
+ subtitle = '',
99
+ hasSaveStates = false,
100
+ optionGroups = [],
101
+ customItems = [],
102
+ messages = {},
103
+ }">
104
+ let activeKey = optionGroups?.[0]?.options?.[0]?.key || "";
105
+ let activeDescription = optionGroups?.[0]?.options?.[0]?.description || "";
106
+
107
+ const getFlatOptions = () => {
108
+ const list = [];
109
+ for (const group of optionGroups || []) {
110
+ for (const opt of group.options || []) {
111
+ list.push(opt);
112
+ }
113
+ }
114
+ return list;
115
+ };
116
+
117
+ const onHoverOption = (option) => {
118
+ if (!option) return;
119
+ activeKey = option.key;
120
+ activeDescription = option.description || "";
121
+ };
122
+
123
+ const onSelectOption = (option) => {
124
+ onHoverOption(option);
125
+ };
126
+
127
+ const onChangeOption = (option, newValue) => {
128
+ option.value = newValue;
129
+ onHoverOption(option);
130
+ onOptionChange?.(option.key, newValue, option);
131
+ $emit("option-change", { key: option.key, value: newValue, option });
132
+ };
133
+
134
+ const handleKeyDown = (e) => {
135
+ if (!open) return;
136
+ const key = e.key;
137
+
138
+ if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter"].includes(key)) {
139
+ e.preventDefault();
140
+ e.stopPropagation();
141
+
142
+ const options = getFlatOptions();
143
+ if (options.length === 0) return;
144
+
145
+ let idx = options.findIndex((o) => o.key === activeKey);
146
+ if (idx < 0) idx = 0;
147
+
148
+ if (key === "ArrowDown") {
149
+ const nextIdx = (idx + 1) % options.length;
150
+ onHoverOption(options[nextIdx]);
151
+ } else if (key === "ArrowUp") {
152
+ const prevIdx = (idx - 1 + options.length) % options.length;
153
+ onHoverOption(options[prevIdx]);
154
+ } else if (key === "ArrowRight" || key === "ArrowLeft") {
155
+ const opt = options[idx];
156
+ if (!opt) return;
157
+
158
+ if (opt.type === "boolean") {
159
+ onChangeOption(opt, !opt.value);
160
+ } else if (opt.type === "enum" || opt.type === "select") {
161
+ const choices = opt.values || opt.choices || [];
162
+ if (choices.length > 0) {
163
+ const valIdx = choices.findIndex((c) => c.value === opt.value);
164
+ let nextValIdx;
165
+ if (key === "ArrowRight") {
166
+ nextValIdx = (valIdx + 1) % choices.length;
167
+ } else {
168
+ nextValIdx = (valIdx - 1 + choices.length) % choices.length;
169
+ }
170
+ onChangeOption(opt, choices[nextValIdx].value);
171
+ }
172
+ }
173
+ } else if (key === "Enter") {
174
+ onClose?.();
175
+ }
176
+ }
177
+ };
178
+
179
+ if (typeof window !== "undefined") {
180
+ window.addEventListener("keydown", handleKeyDown, true);
181
+ }
182
+ </script>
183
+
184
+ <style>
185
+ :host {
186
+ display: block;
187
+ pointer-events: none;
188
+ }
189
+
190
+ .esc-menu-wrapper {
191
+ pointer-events: auto;
192
+ }
193
+
194
+ /* Modal Scrim / Overlay - Translucent lighter backdrop with blur */
195
+ .esc-overlay {
196
+ position: fixed;
197
+ inset: 0;
198
+ z-index: 9999;
199
+ background: var(--demo-esc-bg, rgba(0, 0, 0, 0.45));
200
+ backdrop-filter: blur(8px);
201
+ -webkit-backdrop-filter: blur(8px);
202
+ display: flex;
203
+ align-items: center;
204
+ justify-content: center;
205
+ padding: clamp(0.5rem, 3vh, 2rem);
206
+ animation: escFadeIn 0.2s ease-out;
207
+ }
208
+
209
+ @keyframes escFadeIn {
210
+ from {
211
+ opacity: 0;
212
+ }
213
+
214
+ to {
215
+ opacity: 1;
216
+ }
217
+ }
218
+
219
+ /* Panel */
220
+ .esc-panel {
221
+ width: min(540px, 100%);
222
+ max-height: 90vh;
223
+ display: flex;
224
+ flex-direction: column;
225
+ background: var(--demo-esc-card-bg, rgba(15, 23, 42, 0.95));
226
+ border: 1px solid rgba(255, 255, 255, 0.15);
227
+ border-top: 2px solid var(--demo-accent, #ff007f);
228
+ border-radius: var(--demo-panel-radius, 12px);
229
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.7), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
230
+ overflow: hidden;
231
+ animation: escSlideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1);
232
+ }
233
+
234
+ @keyframes escSlideUp {
235
+ from {
236
+ opacity: 0;
237
+ transform: translateY(12px) scale(0.98);
238
+ }
239
+
240
+ to {
241
+ opacity: 1;
242
+ transform: translateY(0) scale(1);
243
+ }
244
+ }
245
+
246
+ /* Header */
247
+ header.esc-header {
248
+ display: flex;
249
+ align-items: center;
250
+ gap: 0.75rem;
251
+ padding: 0.85rem 1.25rem;
252
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
253
+ background: rgba(0, 0, 0, 0.2);
254
+ }
255
+
256
+ .esc-badge {
257
+ padding: 0.15rem 0.5rem;
258
+ background: rgba(255, 0, 127, 0.15);
259
+ border: 1px solid var(--demo-accent, #ff007f);
260
+ border-radius: 4px;
261
+ color: var(--demo-accent, #ff007f);
262
+ font-family: var(--demo-font-mono, monospace);
263
+ font-size: 0.7rem;
264
+ font-weight: 800;
265
+ letter-spacing: 0.1em;
266
+ text-transform: uppercase;
267
+ }
268
+
269
+ header.esc-header h2 {
270
+ margin: 0;
271
+ font-size: 1.1rem;
272
+ font-weight: 800;
273
+ letter-spacing: 0.1em;
274
+ text-transform: uppercase;
275
+ color: var(--demo-esc-text, #f8fafc);
276
+ }
277
+
278
+ .esc-header-exit-btn {
279
+ margin-left: auto;
280
+ padding: 0.4rem 0.8rem;
281
+ border-radius: 6px;
282
+ border: 1px solid rgba(239, 68, 68, 0.4);
283
+ background: rgba(239, 68, 68, 0.15);
284
+ color: #fca5a5;
285
+ font-family: var(--demo-font-sans, sans-serif);
286
+ font-size: 0.8rem;
287
+ font-weight: 600;
288
+ cursor: pointer;
289
+ display: inline-flex;
290
+ align-items: center;
291
+ gap: 0.4rem;
292
+ transition: all 0.15s ease;
293
+ text-transform: uppercase;
294
+ letter-spacing: 0.03em;
295
+ }
296
+
297
+ .esc-header-exit-btn:hover {
298
+ background: rgba(239, 68, 68, 0.3);
299
+ border-color: rgba(239, 68, 68, 0.7);
300
+ color: #ffffff;
301
+ }
302
+
303
+ .esc-close-btn {
304
+ background: rgba(255, 255, 255, 0.08);
305
+ border: 1px solid rgba(255, 255, 255, 0.15);
306
+ color: var(--demo-muted, #94a3b8);
307
+ border-radius: 6px;
308
+ width: 28px;
309
+ height: 28px;
310
+ display: flex;
311
+ align-items: center;
312
+ justify-content: center;
313
+ font-size: 1.2rem;
314
+ cursor: pointer;
315
+ transition: all 0.2s;
316
+ }
317
+
318
+ .esc-close-btn:hover {
319
+ background: rgba(255, 255, 255, 0.2);
320
+ color: #ffffff;
321
+ }
322
+
323
+ /* Scrollable Body */
324
+ .esc-body {
325
+ padding: 1rem 1.25rem;
326
+ overflow-y: auto;
327
+ display: flex;
328
+ flex-direction: column;
329
+ gap: 1.25rem;
330
+ max-height: 48vh;
331
+ }
332
+
333
+ .esc-group-title {
334
+ margin: 0 0 0.5rem;
335
+ font-size: 0.75rem;
336
+ font-weight: 700;
337
+ letter-spacing: 0.15em;
338
+ text-transform: uppercase;
339
+ color: var(--demo-accent, #ff007f);
340
+ opacity: 0.9;
341
+ }
342
+
343
+ .esc-row {
344
+ display: flex;
345
+ align-items: center;
346
+ justify-content: space-between;
347
+ padding: 0.5rem 0.75rem;
348
+ border-radius: 6px;
349
+ border: 1px solid transparent;
350
+ transition: all 0.15s ease;
351
+ gap: 1rem;
352
+ cursor: pointer;
353
+ }
354
+
355
+ .esc-row:hover,
356
+ .esc-row.active {
357
+ background: rgba(255, 255, 255, 0.06);
358
+ border-color: rgba(255, 255, 255, 0.12);
359
+ }
360
+
361
+ .esc-row-label {
362
+ font-size: 0.88rem;
363
+ font-weight: 600;
364
+ color: var(--demo-esc-text, #f8fafc);
365
+ display: flex;
366
+ align-items: center;
367
+ gap: 0.5rem;
368
+ }
369
+
370
+ .esc-tag {
371
+ font-size: 0.65rem;
372
+ font-family: var(--demo-font-mono, monospace);
373
+ padding: 0.1rem 0.35rem;
374
+ border-radius: 3px;
375
+ background: rgba(234, 179, 8, 0.15);
376
+ border: 1px solid rgba(234, 179, 8, 0.4);
377
+ color: #fde047;
378
+ text-transform: lowercase;
379
+ }
380
+
381
+ .esc-row-control {
382
+ display: flex;
383
+ align-items: center;
384
+ gap: 0.4rem;
385
+ }
386
+
387
+ .esc-chip-group {
388
+ display: inline-flex;
389
+ background: rgba(0, 0, 0, 0.3);
390
+ padding: 2px;
391
+ border-radius: 6px;
392
+ border: 1px solid rgba(255, 255, 255, 0.1);
393
+ }
394
+
395
+ .esc-chip {
396
+ padding: 0.25rem 0.6rem;
397
+ border: none;
398
+ background: transparent;
399
+ color: var(--demo-muted, #94a3b8);
400
+ font-family: var(--demo-font-sans, sans-serif);
401
+ font-size: 0.78rem;
402
+ font-weight: 600;
403
+ border-radius: 4px;
404
+ cursor: pointer;
405
+ transition: all 0.15s ease;
406
+ }
407
+
408
+ .esc-chip:hover {
409
+ color: #ffffff;
410
+ }
411
+
412
+ .esc-chip.active {
413
+ background: var(--demo-accent, #ff007f);
414
+ color: #ffffff;
415
+ box-shadow: 0 2px 8px rgba(255, 0, 127, 0.3);
416
+ }
417
+
418
+ /* Footer */
419
+ footer.esc-footer {
420
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
421
+ padding: 0.9rem 1.25rem;
422
+ background: rgba(0, 0, 0, 0.25);
423
+ display: flex;
424
+ flex-direction: column;
425
+ gap: 0.85rem;
426
+ }
427
+
428
+ .esc-desc {
429
+ margin: 0;
430
+ font-size: 0.82rem;
431
+ color: var(--demo-muted, #94a3b8);
432
+ font-family: var(--demo-font-mono, monospace);
433
+ min-height: 2.8rem;
434
+ line-height: 1.4;
435
+ }
436
+
437
+ .esc-actions {
438
+ display: flex;
439
+ flex-wrap: wrap;
440
+ gap: 0.6rem;
441
+ }
442
+
443
+ /* Action buttons */
444
+ .esc-action-btn {
445
+ padding: 0.6rem 1.1rem;
446
+ border-radius: 6px;
447
+ border: 1px solid rgba(255, 255, 255, 0.15);
448
+ background: rgba(255, 255, 255, 0.08);
449
+ color: var(--demo-esc-text, #f8fafc);
450
+ font-family: var(--demo-font-sans, sans-serif);
451
+ font-size: 0.85rem;
452
+ font-weight: 600;
453
+ cursor: pointer;
454
+ display: inline-flex;
455
+ align-items: center;
456
+ gap: 0.5rem;
457
+ transition: all 0.15s ease;
458
+ text-transform: uppercase;
459
+ letter-spacing: 0.04em;
460
+ }
461
+
462
+ .esc-action-btn:hover {
463
+ background: rgba(255, 255, 255, 0.2);
464
+ border-color: rgba(255, 255, 255, 0.35);
465
+ transform: translateY(-1px);
466
+ }
467
+
468
+ .esc-action-btn:active {
469
+ transform: translateY(0);
470
+ }
471
+
472
+ .esc-action-btn.primary {
473
+ background: var(--demo-accent, #ff007f);
474
+ border-color: var(--demo-accent, #ff007f);
475
+ color: #ffffff;
476
+ box-shadow: 0 4px 15px rgba(255, 0, 127, 0.3);
477
+ }
478
+
479
+ .esc-action-btn.primary:hover {
480
+ filter: brightness(1.15);
481
+ }
482
+
483
+ .esc-action-btn.custom {
484
+ background: rgba(56, 189, 248, 0.12);
485
+ border-color: rgba(56, 189, 248, 0.3);
486
+ color: #7dd3fc;
487
+ }
488
+
489
+ .esc-action-btn.custom:hover {
490
+ background: rgba(56, 189, 248, 0.25);
491
+ color: #ffffff;
492
+ }
493
+
494
+ /* Reliable CSS Mask Icon Implementation */
495
+ .esc-icon {
496
+ width: 16px;
497
+ height: 16px;
498
+ min-width: 16px;
499
+ min-height: 16px;
500
+ display: inline-block;
501
+ vertical-align: middle;
502
+ flex-shrink: 0;
503
+ background-color: currentColor;
504
+ -webkit-mask: var(--icon-url) no-repeat center / contain;
505
+ mask: var(--icon-url) no-repeat center / contain;
506
+ }
507
+
508
+ .esc-icon.resume {
509
+ --icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><polygon points="5,3 19,12 5,21"/></svg>');
510
+ }
511
+
512
+ .esc-icon.defaults {
513
+ --icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>');
514
+ }
515
+
516
+ .esc-icon.reset {
517
+ --icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/></svg>');
518
+ }
519
+
520
+ .esc-icon.exit {
521
+ --icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>');
522
+ }
523
+
524
+ .esc-icon.save {
525
+ --icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>');
526
+ }
527
+
528
+ .esc-icon.load {
529
+ --icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>');
530
+ }
531
+
532
+ .esc-keys-hint {
533
+ margin: 0;
534
+ text-align: center;
535
+ font-size: 0.75rem;
536
+ color: var(--demo-muted, #94a3b8);
537
+ font-family: var(--demo-font-mono, monospace);
538
+ opacity: 0.8;
539
+ }
540
+ </style>
@@ -1,30 +1,34 @@
1
1
  <form class="launcher" @submit.prevent="onLaunch">
2
2
  <section class="sdk">
3
- <CSdkInfo ...sdkInfo />
3
+ <CSdkInfo ...sdkInfo :messages="messages" />
4
4
  </section>
5
5
 
6
6
  <div class="drop-grid" :class.single="!showBIOS">
7
7
  <CDropArea
8
8
  :if="showBIOS"
9
- :prompt="requiresBIOS ? 'Drop BIOS File (Required)' : 'Drop BIOS File (Optional)'"
10
- :asset-hint="biosAssetHint"
9
+ :prompt="requiresBIOS ? (messages.dropBiosPromptRequired || 'Drop BIOS File (Required)') : (messages.dropBiosPromptOptional || 'Drop BIOS File (Optional)')"
10
+ :asset-hint="biosAssetHint || (requiresBIOS ? (messages.biosAssetHintRequired || 'required BIOS firmware file') : (messages.biosAssetHintOptional || 'optional BIOS firmware file'))"
11
11
  :accept="biosAccept"
12
- :button-text="'Pick BIOS'"
12
+ :button-text="messages.pickBiosButton || 'Pick BIOS'"
13
13
  :file-name="biosFile?.name"
14
+ :messages="messages"
14
15
  @select="onBiosSelect"
16
+ @remove="onBiosRemove"
15
17
  />
16
18
  <CDropArea
17
- :prompt="'Drop ROM File'"
18
- :asset-hint="romAssetHint"
19
+ :prompt="messages.dropRomPrompt || 'Drop ROM File'"
20
+ :asset-hint="romAssetHint || messages.romAssetHint || 'or choose ROM file from computer'"
19
21
  :accept="romAccept"
20
- :button-text="'Pick ROM'"
22
+ :button-text="messages.pickRomButton || 'Pick ROM'"
21
23
  :file-name="romFile?.name"
24
+ :messages="messages"
22
25
  @select="onRomSelect"
26
+ @remove="onRomRemove"
23
27
  />
24
28
  </div>
25
29
 
26
30
  <button type="submit" class="launch-btn" :disabled="!canLaunch">
27
- Launch Engine
31
+ {{ messages.launchButton || 'Launch Engine' }}
28
32
  </button>
29
33
 
30
34
  <div class="status">{{ status }}</div>
@@ -34,17 +38,15 @@
34
38
  CSdkInfo,
35
39
  CDropArea,
36
40
  sdkInfo,
37
- onLaunchFiles,
38
- onSaveBios,
39
- onSaveRom,
40
41
  initialRomFile = null,
41
42
  initialBiosFile = null,
42
43
  romAccept,
43
- romAssetHint = 'or choose ROM file from computer',
44
+ romAssetHint,
44
45
  biosAccept,
45
- biosAssetHint = 'optional BIOS firmware file',
46
+ biosAssetHint,
46
47
  requiresBIOS = false,
47
- showBIOS = true,
48
+ showBIOS = false,
49
+ messages = {},
48
50
  }">
49
51
  let romFile = initialRomFile;
50
52
  let biosFile = initialBiosFile;
@@ -55,13 +57,14 @@
55
57
  const updateStatus = () => {
56
58
  canLaunch = Boolean(romFile && (!requiresBIOS || biosFile));
57
59
  if (requiresBIOS && !biosFile && !romFile) {
58
- status = "Ready - Please select ROM and BIOS files to launch.";
60
+ status = messages.statusSelectBoth || "Ready - Please select ROM and BIOS files to launch.";
59
61
  } else if (requiresBIOS && !biosFile) {
60
- status = "BIOS file is required to launch.";
62
+ status = messages.statusBiosRequired || "BIOS file is required to launch.";
61
63
  } else if (!romFile) {
62
- status = "Ready - Please select a ROM file to launch.";
64
+ status = messages.statusSelectRom || "Ready - Please select a ROM file to launch.";
63
65
  } else {
64
- status = `Ready to launch! (${romFile.name}${biosFile ? ' + BIOS' : ''})`;
66
+ const readyMsg = messages.statusReady || "Ready to launch!";
67
+ status = `${readyMsg} (${romFile.name}${biosFile ? ' + BIOS' : ''})`;
65
68
  }
66
69
  };
67
70
 
@@ -77,18 +80,36 @@
77
80
 
78
81
  const onRomSelect = (arg) => {
79
82
  const file = extractFile(arg);
80
- if (!file) return;
83
+ if (!file) {
84
+ onRomRemove();
85
+ return;
86
+ }
81
87
  romFile = file;
82
88
  updateStatus();
83
- onSaveRom?.(file);
89
+ $emit("save:rom", file);
84
90
  };
85
91
 
86
92
  const onBiosSelect = (arg) => {
87
93
  const file = extractFile(arg);
88
- if (!file) return;
94
+ if (!file) {
95
+ onBiosRemove();
96
+ return;
97
+ }
89
98
  biosFile = file;
90
99
  updateStatus();
91
- onSaveBios?.(file);
100
+ $emit("save:bios", file);
101
+ };
102
+
103
+ const onRomRemove = () => {
104
+ romFile = null;
105
+ updateStatus();
106
+ $emit("remove:rom");
107
+ };
108
+
109
+ const onBiosRemove = () => {
110
+ biosFile = null;
111
+ updateStatus();
112
+ $emit("remove:bios");
92
113
  };
93
114
 
94
115
  const onLaunch = async () => {
@@ -97,8 +118,9 @@
97
118
  return;
98
119
  }
99
120
  try {
100
- status = `Launching with ${romFile.name}...`;
101
- await onLaunchFiles({ rom: romFile, bios: biosFile });
121
+ const launchingMsg = messages.launchingStatus || "Launching with";
122
+ status = `${launchingMsg} ${romFile.name}...`;
123
+ $emit("launch", { rom: romFile, bios: biosFile });
102
124
  } catch (error) {
103
125
  const message = error instanceof Error ? error.message : String(error);
104
126
  status = `Failed to load engine: ${message}`;
@@ -1,11 +1,23 @@
1
1
  <h1>{{ name }} ({{ id }})</h1>
2
2
  <p>{{ description }}</p>
3
3
  <div class="row">
4
- <span class="pill">Version {{ version }}</span>
4
+ <span class="pill">{{ (messages.versionPrefix || 'Version ') + version }}</span>
5
5
  <span class="pill">{{ baseWidth }}x{{ baseHeight }}</span>
6
- <span class="pill">Asset key: {{ assetKey }}</span>
6
+ <span class="pill">{{ (messages.assetKeyPrefix || 'Asset key: ') + assetKey }}</span>
7
7
  </div>
8
8
 
9
+ <script :setup="{
10
+ name,
11
+ id,
12
+ description,
13
+ version,
14
+ baseWidth,
15
+ baseHeight,
16
+ assetKey,
17
+ messages = {},
18
+ }">
19
+ </script>
20
+
9
21
  <style>
10
22
  h1 {
11
23
  margin: 0 0 0.4rem;