Skip to content

Commit aaeedcf

Browse files
committed
feat: enhance GameBoy controls and boot process with UI updates and mobile support
1 parent 17598dc commit aaeedcf

12 files changed

Lines changed: 319 additions & 39 deletions

File tree

playwright.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,24 @@ export default defineConfig({
1515
projects: [
1616
{
1717
name: 'chromium',
18+
testIgnore: /.*mobile-ui\.spec\.ts/,
1819
use: { ...devices['Desktop Chrome'] },
1920
},
2021
{
2122
name: 'firefox',
23+
testIgnore: /.*mobile-ui\.spec\.ts/,
2224
use: { ...devices['Desktop Firefox'] },
2325
},
2426
{
2527
name: 'webkit',
28+
testIgnore: /.*mobile-ui\.spec\.ts/,
2629
use: { ...devices['Desktop Safari'] },
2730
},
31+
{
32+
name: 'mobile-chromium',
33+
testMatch: /.*mobile-ui\.spec\.ts/,
34+
use: { ...devices['iPhone 12'] },
35+
},
2836
],
2937
webServer: {
3038
command: 'npm run build && npm run preview',

src/components/GameBoy.astro

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const aboutText = aboutEntry && aboutEntry.body ? aboutEntry.body.trim() : '';
4141
<div class="progress-container">
4242
<div class="progress-bar"></div>
4343
</div>
44+
<p class="boot-status" aria-live="polite">CONTROLS LOCKED · BOOTING…</p>
4445
</div>
4546

4647
<Hud />

src/components/gameboy/Controls.astro

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
<div class="left-controls">
22
<div class="dpad-area">
33
<div class="dpad">
4-
<button id="up" class="dpad-btn dpad-up" aria-label="Up"></button>
5-
<button id="right" class="dpad-btn dpad-right" aria-label="Right"></button>
6-
<button id="down" class="dpad-btn dpad-down" aria-label="Down"></button>
7-
<button id="left" class="dpad-btn dpad-left" aria-label="Left"></button>
4+
<button type="button" id="up" class="dpad-btn dpad-up" aria-label="Up"></button>
5+
<button type="button" id="right" class="dpad-btn dpad-right" aria-label="Right"></button>
6+
<button type="button" id="down" class="dpad-btn dpad-down" aria-label="Down"></button>
7+
<button type="button" id="left" class="dpad-btn dpad-left" aria-label="Left"></button>
88
<div class="dpad-center"></div>
99
</div>
1010
</div>
@@ -23,11 +23,11 @@
2323

2424
<div class="meta-controls">
2525
<div class="meta-group">
26-
<button class="pill-btn select" id="btn-select" aria-label="Select"></button>
26+
<button type="button" class="pill-btn select" id="btn-select" aria-label="Select"></button>
2727
<span class="pill-label label-select">SELECT</span>
2828
</div>
2929
<div class="meta-group">
30-
<button class="pill-btn start" id="btn-start" aria-label="Start"></button>
30+
<button type="button" class="pill-btn start" id="btn-start" aria-label="Start"></button>
3131
<span class="pill-label label-start">START</span>
3232
</div>
3333
</div>

src/lib/navigation-ui.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,29 +132,47 @@ export function toggleTurnLayout() {
132132
setTimeout(() => SoundEngine.playTone(440, 'triangle', (FRAME_DURATION * 30) / 1000, 0.1), FRAME_DURATION * 6);
133133
}
134134

135+
const TOUCH_CLICK_SUPPRESSION_MS = 450;
136+
let lastTouchStartAt = 0;
137+
138+
function markTouchInteraction() {
139+
lastTouchStartAt = Date.now();
140+
}
141+
142+
function isSyntheticClickAfterTouch() {
143+
return Date.now() - lastTouchStartAt < TOUCH_CLICK_SUPPRESSION_MS;
144+
}
145+
135146
export function bindPressAction(btn: HTMLElement | null, action: () => void) {
136147
if (!btn) return;
137148

138149
btn.addEventListener('click', () => {
150+
if (isSyntheticClickAfterTouch()) return;
139151
animatePress(btn);
140152
action();
141153
});
142154

143155
btn.addEventListener('touchstart', (e) => {
144156
e.preventDefault();
157+
markTouchInteraction();
145158
animatePress(btn);
146159
action();
147-
});
160+
}, { passive: false });
148161
}
149162

150163
export function bindTouchAction(btn: HTMLElement | null, action: () => void) {
151164
if (!btn) return;
152165

153-
btn.addEventListener('click', action);
166+
btn.addEventListener('click', () => {
167+
if (isSyntheticClickAfterTouch()) return;
168+
action();
169+
});
170+
154171
btn.addEventListener('touchstart', (e) => {
155172
e.preventDefault();
173+
markTouchInteraction();
156174
action();
157-
});
175+
}, { passive: false });
158176
}
159177

160178
export function getHoveredLinkIndex(target: EventTarget | null): number | null {

src/lib/navigation.ts

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { FRAME_DURATION } from './constants';
2-
import { isConsolePoweredOn, state } from './state';
2+
import { isConsoleInteractive, state } from './state';
33
import { SoundEngine } from './sound-engine';
44
import { handleThemeSwitch } from './theme-manager';
55
import { getNextIndex, getNextTab, isTypingTarget, type NavigationDirection, type TabDirection } from './navigation-core';
@@ -22,7 +22,7 @@ import {
2222
export { getNextIndex, getNextTab, updateActiveLink, updateTabUI };
2323

2424
function handleNavigation(direction: NavigationDirection) {
25-
if (!isConsolePoweredOn()) return;
25+
if (!isConsoleInteractive()) return;
2626

2727
if (state.currentTab === 0) {
2828
const links = getLinks();
@@ -40,12 +40,12 @@ function handleNavigation(direction: NavigationDirection) {
4040
}
4141

4242
function handleSelection() {
43-
if (!isConsolePoweredOn()) return;
43+
if (!isConsoleInteractive()) return;
4444
activateSelectedLink();
4545
}
4646

4747
export function switchTab(direction: TabDirection) {
48-
if (!isConsolePoweredOn()) return;
48+
if (!isConsoleInteractive()) return;
4949

5050
state.currentTab = getNextTab(state.currentTab, state.numTabs, direction);
5151

@@ -59,15 +59,25 @@ export function switchTab(direction: TabDirection) {
5959
}
6060

6161
export function switchToTab(index: number) {
62-
if (!isConsolePoweredOn()) return;
62+
if (!isConsoleInteractive()) return;
6363
if (index < 0 || index >= state.numTabs || index === state.currentTab) return;
6464

6565
state.currentTab = index;
6666
SoundEngine.switch();
67+
resetScreenScroll();
6768
updateTabUI();
69+
70+
if (state.currentTab === 0) {
71+
setTimeout(() => updateActiveLink(state.currentIndex), FRAME_DURATION * 3);
72+
}
6873
}
6974

7075
export function initNavigation() {
76+
const whenInteractive = (action: () => void) => () => {
77+
if (!isConsoleInteractive()) return;
78+
action();
79+
};
80+
7181
document.addEventListener('keyup', (e) => {
7282
toggleButtonState(e.key, false);
7383
});
@@ -78,21 +88,21 @@ export function initNavigation() {
7888
});
7989

8090
document.querySelectorAll('.tab-indicator').forEach((tab, index) => {
81-
tab.addEventListener('click', () => switchToTab(index));
91+
tab.addEventListener('click', whenInteractive(() => switchToTab(index)));
8292
});
8393

8494
document.addEventListener('keydown', (e) => {
8595
const target = e.target as HTMLElement;
8696
if (isTypingTarget(target) || e.isComposing || e.metaKey) return;
8797

8898
if ((e.key === 'h' || e.key === 'H') && !e.ctrlKey && !e.altKey && !e.metaKey) {
89-
if (!isConsolePoweredOn()) return;
99+
if (!isConsoleInteractive()) return;
90100
e.preventDefault();
91101
switchToTab(2);
92102
return;
93103
}
94104

95-
if (!e.repeat && isConsolePoweredOn()) toggleButtonState(e.key, true);
105+
if (!e.repeat && isConsoleInteractive()) toggleButtonState(e.key, true);
96106

97107
switch (e.key) {
98108
case 'ArrowUp': case 'w': case 'W':
@@ -117,7 +127,7 @@ export function initNavigation() {
117127
break;
118128
case 'x': case 'X':
119129
case 'q': case 'Q':
120-
if (!e.ctrlKey && !e.altKey && !e.metaKey && isConsolePoweredOn()) {
130+
if (!e.ctrlKey && !e.altKey && !e.metaKey && isConsoleInteractive()) {
121131
e.preventDefault();
122132
handleThemeSwitch();
123133
}
@@ -131,21 +141,21 @@ export function initNavigation() {
131141
}
132142
});
133143

134-
bindPressAction(document.getElementById('up'), () => handleNavigation('up'));
135-
bindPressAction(document.getElementById('down'), () => handleNavigation('down'));
136-
bindPressAction(document.getElementById('left'), () => switchTab('left'));
137-
bindPressAction(document.getElementById('right'), () => switchTab('right'));
138-
bindPressAction(document.getElementById('btn-a'), handleSelection);
139-
bindPressAction(document.getElementById('btn-b'), handleThemeSwitch);
140-
bindPressAction(document.getElementById('btn-select'), handleThemeSwitch);
141-
bindPressAction(document.getElementById('btn-start'), () => switchToTab(2));
144+
bindPressAction(document.getElementById('up'), whenInteractive(() => handleNavigation('up')));
145+
bindPressAction(document.getElementById('down'), whenInteractive(() => handleNavigation('down')));
146+
bindPressAction(document.getElementById('left'), whenInteractive(() => switchTab('left')));
147+
bindPressAction(document.getElementById('right'), whenInteractive(() => switchTab('right')));
148+
bindPressAction(document.getElementById('btn-a'), whenInteractive(handleSelection));
149+
bindPressAction(document.getElementById('btn-b'), whenInteractive(handleThemeSwitch));
150+
bindPressAction(document.getElementById('btn-select'), whenInteractive(handleThemeSwitch));
151+
bindPressAction(document.getElementById('btn-start'), whenInteractive(() => switchToTab(2)));
142152
bindTouchAction(document.getElementById('btn-turn'), toggleTurnLayout);
143153

144154
const linksContainer = document.querySelector('.links');
145155
if (linksContainer) {
146156
linksContainer.addEventListener('mouseover', (e) => {
147157
const index = getHoveredLinkIndex(e.target);
148-
if (index !== null && state.currentTab === 0 && isConsolePoweredOn()) {
158+
if (index !== null && state.currentTab === 0 && isConsoleInteractive()) {
149159
updateActiveLink(index, false, true);
150160
}
151161
});
@@ -156,7 +166,7 @@ export function initNavigation() {
156166
const LINK_SCROLL_THRESHOLD = 30;
157167

158168
document.addEventListener('wheel', (e) => {
159-
if (e.ctrlKey || !isConsolePoweredOn()) return;
169+
if (e.ctrlKey || !isConsoleInteractive()) return;
160170
e.preventDefault();
161171

162172
if (state.currentTab === 1 || state.currentTab === 2) {

src/lib/power-switch.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,33 +3,76 @@ import { state } from './state';
33
import { SoundEngine } from './sound-engine';
44
import { updateActiveLink, updateTabUI } from './navigation';
55

6+
const BOOT_DURATION_MS = 3500;
7+
68
export function initPowerSwitch() {
79
const switchTrack = document.getElementById('power-switch');
810
const consoleBody = document.querySelector('.console');
911
const screenDisplay = document.querySelector('.screen-display') as HTMLElement | null;
1012

1113
if (!switchTrack || !consoleBody) return;
1214

15+
let bootTimeout: ReturnType<typeof setTimeout> | null = null;
16+
let bootSequenceId = 0;
17+
18+
const interactiveButtons = document.querySelectorAll<HTMLButtonElement>(
19+
'.dpad-btn, .action-buttons button, .meta-controls button, .tab-indicator'
20+
);
21+
22+
const setControlsInteractive = (isInteractive: boolean) => {
23+
const isBusy = !isInteractive && state.isPoweredOn;
24+
25+
consoleBody.classList.toggle('console-booting', isBusy);
26+
consoleBody.setAttribute('aria-busy', String(isBusy));
27+
28+
interactiveButtons.forEach((button) => {
29+
button.disabled = !isInteractive;
30+
button.setAttribute('aria-disabled', String(!isInteractive));
31+
});
32+
33+
if (screenDisplay) {
34+
screenDisplay.toggleAttribute('inert', !isInteractive);
35+
screenDisplay.setAttribute('aria-busy', String(isBusy));
36+
}
37+
};
38+
39+
const clearPendingBoot = () => {
40+
if (bootTimeout) {
41+
clearTimeout(bootTimeout);
42+
bootTimeout = null;
43+
}
44+
bootSequenceId += 1;
45+
};
46+
1347
const togglePower = () => {
1448
state.isPoweredOn = !state.isPoweredOn;
1549

1650
switchTrack.setAttribute('aria-checked', String(state.isPoweredOn));
1751
const bootScreen = document.getElementById('boot-screen');
1852
const topHud = document.getElementById('top-hud') as HTMLElement | null;
1953

54+
clearPendingBoot();
55+
2056
if (state.isPoweredOn) {
57+
state.isBooting = true;
58+
setControlsInteractive(false);
2159
consoleBody.classList.remove('console-off');
2260
SoundEngine.playClick();
2361

2462
if (bootScreen) bootScreen.classList.add('active');
2563
if (topHud) topHud.style.opacity = '0';
2664

27-
setTimeout(() => {
28-
if (!state.isPoweredOn) return;
65+
const currentBootSequenceId = bootSequenceId;
66+
bootTimeout = setTimeout(() => {
67+
if (!state.isPoweredOn || currentBootSequenceId !== bootSequenceId) return;
68+
69+
state.isBooting = false;
70+
setControlsInteractive(true);
2971
SoundEngine.playTone(600, 'sine', (FRAME_DURATION * 24) / 1000);
3072
if (bootScreen) bootScreen.classList.remove('active');
3173
if (topHud) topHud.style.opacity = '1';
32-
}, 3500);
74+
bootTimeout = null;
75+
}, BOOT_DURATION_MS);
3376

3477
if (screenDisplay) {
3578
screenDisplay.style.animation = 'none';
@@ -39,6 +82,8 @@ export function initPowerSwitch() {
3982

4083
document.title = "Hey!";
4184
} else {
85+
state.isBooting = false;
86+
setControlsInteractive(false);
4287
SoundEngine.playClick();
4388
SoundEngine.playTone(50, 'square', (FRAME_DURATION * 18) / 1000, 0.2);
4489

src/lib/state.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ export type AppState = {
33
currentTab: number;
44
numTabs: number;
55
isPoweredOn: boolean;
6+
isBooting: boolean;
67
};
78

89
export const state: AppState = {
910
currentIndex: 0,
1011
currentTab: 0,
1112
numTabs: 3,
1213
isPoweredOn: true,
14+
isBooting: false,
1315
};
1416

1517
function prefersReducedMotion(): boolean {
@@ -27,3 +29,7 @@ export function getScrollBehavior(): ScrollBehavior {
2729
export function isConsolePoweredOn(): boolean {
2830
return state.isPoweredOn;
2931
}
32+
33+
export function isConsoleInteractive(): boolean {
34+
return state.isPoweredOn && !state.isBooting;
35+
}

0 commit comments

Comments
 (0)