Skip to content

Commit 4404494

Browse files
authored
Merge PR #16: Add responsive controls and accessible feedback
Add mobile action controls and accessible game feedback
2 parents e45f52e + 6540394 commit 4404494

4 files changed

Lines changed: 113 additions & 31 deletions

File tree

index.html

Lines changed: 100 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,35 +8,63 @@
88
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Silkscreen:wght@400;700&display=swap" rel="stylesheet">
99
<style>
1010
*{margin:0;padding:0;box-sizing:border-box}
11-
body{background:#1a1510;overflow:hidden;width:100vw;height:100vh;display:flex;align-items:center;justify-content:center;font-family:'Press Start 2P',monospace;image-rendering:pixelated}
12-
#game-wrapper{position:relative;width:100vw;height:100vh;overflow:hidden}
13-
canvas{display:block;width:100%;height:100%;cursor:crosshair}
14-
#ui-overlay{position:absolute;top:0;left:0;right:0;pointer-events:none;z-index:10;display:flex;justify-content:center;padding:20px}
11+
body{background:#1a1510;overflow:hidden;width:100vw;height:100vh;height:100dvh;display:flex;align-items:center;justify-content:center;font-family:'Press Start 2P',monospace;image-rendering:pixelated}
12+
#game-wrapper{position:relative;width:100vw;height:100vh;height:100dvh;overflow:hidden}
13+
canvas{display:block;width:100%;height:100%;cursor:crosshair;touch-action:manipulation;outline:none}
14+
canvas:focus-visible{box-shadow:inset 0 0 0 3px #E8C040}
15+
#ui-overlay{position:absolute;top:0;left:0;right:0;pointer-events:none;z-index:10;display:flex;justify-content:center;padding:calc(20px + env(safe-area-inset-top)) 20px 20px}
1516
#title-bar{background:rgba(10,10,10,0.8);backdrop-filter:blur(6px);border:2px solid #5A8848;border-radius:4px;padding:10px 24px;display:flex;align-items:center;gap:14px;box-shadow:0 0 20px rgba(90,136,72,0.2)}
1617
#title-bar h1{font-family:'Silkscreen',monospace;font-size:18px;color:#D4A030;text-shadow:2px 2px 0px #6B4820;letter-spacing:2px}
1718
#title-bar .dot{width:8px;height:8px;background:#D4A030;border-radius:50%;animation:blink 1.6s ease-in-out infinite}
1819
@keyframes blink{0%,100%{opacity:1}50%{opacity:0.3}}
1920
#hint{position:absolute;bottom:24px;left:50%;transform:translateX(-50%);font-family:'Silkscreen',monospace;font-size:11px;color:#D4A030;opacity:0.6;pointer-events:none;z-index:10;text-shadow:1px 1px 0px #6B4820;animation:fadeHint 4s ease-in-out infinite}
21+
#hint.status-hidden{visibility:hidden}
2022
@keyframes fadeHint{0%,100%{opacity:0.6}50%{opacity:0.2}}
2123
#controls{position:absolute;bottom:20px;left:20px;pointer-events:none;z-index:10;font-family:'Silkscreen',monospace;font-size:9px;color:#8A9870;line-height:1.8;text-shadow:1px 1px 0px #1a1510;opacity:0.7}
2224
#controls .key{display:inline-block;background:rgba(90,136,72,0.15);border:1px solid rgba(90,136,72,0.3);border-radius:3px;padding:1px 5px;font-family:'Press Start 2P',monospace;font-size:7px;color:#A8C088;margin-right:4px;vertical-align:middle}
2325
#coords{position:absolute;bottom:24px;right:24px;font-family:'Press Start 2P',monospace;font-size:8px;color:#5A8848;pointer-events:none;z-index:10}
2426
#emote{position:absolute;pointer-events:none;z-index:15;font-size:24px;transition:transform 0.15s ease-out,opacity 0.15s;transform:scale(0);opacity:0}
2527
#emote.show{transform:scale(1);opacity:1}
28+
#action-button{position:absolute;right:max(20px,env(safe-area-inset-right));bottom:max(20px,env(safe-area-inset-bottom));z-index:20;display:none;min-width:72px;height:52px;padding:0 12px;border:2px solid #D4A030;border-radius:4px;background:rgba(10,10,10,0.88);color:#E8C040;font:700 12px 'Silkscreen',monospace;cursor:pointer;box-shadow:0 0 18px rgba(212,160,48,0.2)}
29+
#action-button:focus-visible{outline:3px solid #E8E0D0;outline-offset:3px}
30+
#action-button.cooldown{border-color:#6B9848;color:#A8C088}
31+
#status{position:absolute;left:50%;bottom:64px;transform:translateX(-50%);z-index:16;max-width:min(80vw,440px);padding:7px 10px;background:rgba(10,10,10,0.82);color:#E8C040;font:11px 'Silkscreen',monospace;text-align:center;opacity:0;transition:opacity .15s;pointer-events:none}
32+
#status.show{opacity:1}
33+
.sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}
34+
@media(max-width:600px){
35+
#ui-overlay{justify-content:flex-start;padding:calc(10px + env(safe-area-inset-top)) 10px 10px}
36+
#title-bar{padding:7px 10px;gap:7px;max-width:calc(100vw - 20px)}
37+
#title-bar h1{font-size:13px;letter-spacing:0;white-space:nowrap}
38+
#title-bar .dot{width:6px;height:6px;flex:0 0 auto}
39+
#controls,#coords{display:none}
40+
#hint{bottom:max(88px,calc(68px + env(safe-area-inset-bottom)));width:70vw;font-size:10px;text-align:center}
41+
#action-button{display:block}
42+
#status{bottom:max(84px,calc(64px + env(safe-area-inset-bottom)));font-size:10px}
43+
}
44+
@media(hover:none),(pointer:coarse){
45+
#action-button{display:block}
46+
}
47+
@media(prefers-reduced-motion:reduce){
48+
#title-bar .dot,#hint{animation:none}
49+
#emote,#status{transition:none}
50+
}
2651
</style>
2752
</head>
2853
<body>
2954
<div id="game-wrapper">
30-
<canvas id="game"></canvas>
55+
<canvas id="game" tabindex="0" role="application" aria-describedby="game-instructions" aria-label="Little Android exploration game. Use WASD or arrow keys to move and Space or Enter to scan or interact.">Your browser needs canvas support to play Little Android.</canvas>
3156
<div id="ui-overlay"><div id="title-bar"><div class="dot"></div><h1>littleandroid.com</h1><div class="dot"></div></div></div>
32-
<div id="hint">click anywhere to walk</div>
57+
<div id="hint">click or tap to walk</div>
3358
<div id="controls">
34-
<span class="key">WASD</span> move<br>
59+
<span class="key">WASD / ARROWS</span> move<br>
3560
<span class="key">CLICK</span> walk to<br>
3661
<span class="key">SPACE</span> scan
3762
</div>
3863
<div id="coords"></div>
3964
<div id="emote"></div>
65+
<div id="status" role="status" aria-live="polite"></div>
66+
<button id="action-button" type="button" aria-label="Scan or interact">SCAN</button>
67+
<p class="sr-only" id="game-instructions">Move with WASD, arrow keys, or by selecting a destination. Press Space or Enter, or use the Scan button, to scan nearby residents or interact with the character you face.</p>
4068
</div>
4169
<script src="src/game-logic.js"></script>
4270
<script>
@@ -48,6 +76,8 @@
4876
const coordsEl = document.getElementById('coords');
4977
const hintEl = document.getElementById('hint');
5078
const emoteEl = document.getElementById('emote');
79+
const statusEl = document.getElementById('status');
80+
const actionButton = document.getElementById('action-button');
5181

5282
const PX = 3;
5383
const TILE = 16;
@@ -565,6 +595,7 @@
565595
player.interactTarget = npc;
566596
player.interactArmTimer = 0.2;
567597
triggerNPCInteraction(npc);
598+
showStatus(`LINK: ${npc.scanLabel || npc.type.toUpperCase()}`);
568599
}
569600

570601
function requestPlayerInteraction(npc) {
@@ -758,7 +789,7 @@
758789
// PATHFINDING
759790
// ══════════════════════════════════════════════════════
760791
function findPath(sx,sy,ex,ey){
761-
return findWorldPath(sx, sy, ex, ey, isWalkable);
792+
return findWorldPath(sx, sy, ex, ey, isWalkable, { allowNearest: false });
762793
}
763794

764795
// ══════════════════════════════════════════════════════
@@ -769,6 +800,7 @@
769800
let clickMarker = null;
770801

771802
canvas.addEventListener('click', (e) => {
803+
canvas.focus({ preventScroll: true });
772804
const rect = canvas.getBoundingClientRect();
773805
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
774806
const camX = player.renderX * TS - W/2 + TS/2;
@@ -799,30 +831,20 @@
799831
const dir = dirToward(player.tileX, player.tileY, next.x, next.y);
800832
playerStartMove(dir);
801833
}
834+
} else if (tileX !== player.tileX || tileY !== player.tileY) {
835+
showStatus('PATH BLOCKED');
802836
}
803837
});
804838

805839
// ── WASD Keyboard Movement ──
806840
const keysDown = {};
807841
window.addEventListener('keydown', (e) => {
808842
const k = e.key.toLowerCase();
809-
if (['w','a','s','d','arrowup','arrowdown','arrowleft','arrowright',' '].includes(k)) e.preventDefault();
843+
if (['w','a','s','d','arrowup','arrowdown','arrowleft','arrowright',' ','enter'].includes(k)) e.preventDefault();
810844
keysDown[k] = true;
811845

812846
// Spacebar: action button / scan pulse
813-
if (k === ' ' && player.state === 'IDLE') {
814-
// Check for adjacent NPC in facing direction
815-
const d = DIR[player.facing];
816-
const fx = player.tileX + d.dx, fy = player.tileY + d.dy;
817-
const adj = isTileOccupiedByNPC(fx, fy, null);
818-
if (adj) {
819-
// Interact
820-
requestPlayerInteraction(adj);
821-
} else {
822-
// Scan pulse
823-
triggerScanPulse();
824-
}
825-
}
847+
if ((k === ' ' || k === 'enter') && !e.repeat) performAction();
826848
});
827849
window.addEventListener('keyup', (e) => { keysDown[e.key.toLowerCase()] = false; });
828850
window.addEventListener('blur', () => {
@@ -837,13 +859,58 @@
837859
return null;
838860
}
839861

862+
function getFacingNPC() {
863+
const direction = DIR[player.facing];
864+
return isTileOccupiedByNPC(
865+
player.tileX + direction.dx,
866+
player.tileY + direction.dy,
867+
null,
868+
);
869+
}
870+
871+
function performAction() {
872+
if (player.state !== 'IDLE') return;
873+
const adjacentNPC = getFacingNPC();
874+
if (adjacentNPC) requestPlayerInteraction(adjacentNPC);
875+
else triggerScanPulse();
876+
}
877+
878+
actionButton.addEventListener('click', () => {
879+
canvas.focus({ preventScroll: true });
880+
performAction();
881+
});
882+
883+
let statusTimer = 0;
884+
function showStatus(message, duration = 1.5) {
885+
statusEl.textContent = message;
886+
statusEl.classList.add('show');
887+
hintEl.classList.add('status-hidden');
888+
statusTimer = duration;
889+
}
890+
891+
function updateActionButton() {
892+
const canAct = player.state === 'IDLE';
893+
const adjacentNPC = canAct ? getFacingNPC() : null;
894+
const coolingDown = !adjacentNPC && scanCooldownTimer > 0;
895+
actionButton.textContent = adjacentNPC ? 'ACT' : coolingDown ? `${Math.ceil(scanCooldownTimer)}S` : 'SCAN';
896+
actionButton.classList.toggle('cooldown', coolingDown);
897+
actionButton.setAttribute(
898+
'aria-label',
899+
adjacentNPC ? `Interact with ${adjacentNPC.scanLabel || adjacentNPC.type}` :
900+
coolingDown ? `Scan recharging, ${Math.ceil(scanCooldownTimer)} seconds` : 'Scan nearby residents',
901+
);
902+
}
903+
840904
// ── Scan Pulse System ──
841905
let scanPulse = null;
842906
const SCAN_COOLDOWN = 1.5; // seconds
843907
let scanCooldownTimer = 0;
844908

845909
function triggerScanPulse() {
846-
if (scanCooldownTimer > 0) return;
910+
if (scanCooldownTimer > 0) {
911+
showStatus(`SCAN READY IN ${Math.ceil(scanCooldownTimer)}S`);
912+
return false;
913+
}
847914
scanPulse = {
848915
x: player.tileX, y: player.tileY,
849916
time: Date.now(),
@@ -855,6 +922,8 @@
855922
// Brief visor flash on player
856923
player.idleCoreOn = true;
857924
player.idleTimer = 0;
925+
showStatus('SCANNING...');
926+
return true;
858927
}
859928

860929
// ══════════════════════════════════════════════════════
@@ -865,6 +934,13 @@
865934
function update(dt) {
866935
// Scan cooldown
867936
if (scanCooldownTimer > 0) scanCooldownTimer -= dt;
937+
if (statusTimer > 0) {
938+
statusTimer -= dt;
939+
if (statusTimer <= 0) {
940+
statusEl.classList.remove('show');
941+
hintEl.classList.remove('status-hidden');
942+
}
943+
}
868944

869945
// ── Player State Machine ──
870946
switch (player.state) {
@@ -1000,6 +1076,7 @@
10001076
// ── Update all NPCs ──
10011077
for (const npc of npcs) updateNPC(npc, dt);
10021078
scanLabels = tickTimedItems(scanLabels, dt);
1079+
updateActionButton();
10031080

10041081
// ── Emote positioning ──
10051082
if (emoteNPC) {

scripts/check.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const fs = require('node:fs');
55
const html = fs.readFileSync('index.html', 'utf8');
66
const inlineScripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)];
77

8-
if (!html.includes('<canvas id="game">')) {
8+
if (!/<canvas\b[^>]*\bid="game"[^>]*>/i.test(html)) {
99
throw new Error('index.html must contain the game canvas');
1010
}
1111
if (!html.includes('src/game-logic.js')) {

src/game-logic.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,14 @@
108108
return { width, height, map };
109109
}
110110

111-
function findPath(startX, startY, endX, endY, isWalkable) {
111+
function findPath(startX, startY, endX, endY, isWalkable, options = {}) {
112112
const sx = Math.round(startX);
113113
const sy = Math.round(startY);
114114
let ex = Math.round(endX);
115115
let ey = Math.round(endY);
116116

117117
if (!isWalkable(ex, ey)) {
118+
if (options.allowNearest === false) return [];
118119
let best = null;
119120
let bestDistance = Infinity;
120121
for (let dy = -3; dy <= 3; dy++) {

tests/runtime-smoke.test.cjs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ test('runtime initializes and renders a frame', () => {
1717
);
1818
const element = () => ({
1919
style: {},
20-
classList: { add() {}, remove() {} },
20+
classList: { add() {}, remove() {}, toggle() {} },
21+
setAttribute() {},
2122
textContent: '',
23+
focus() {},
2224
getBoundingClientRect: () => ({ left: 0, top: 0 }),
2325
addEventListener: (name, handler) => { listeners[name] = handler; },
2426
getContext: () => context2d,
@@ -28,6 +30,8 @@ test('runtime initializes and renders a frame', () => {
2830
coords: element(),
2931
hint: element(),
3032
emote: element(),
33+
status: element(),
34+
'action-button': element(),
3135
};
3236
const sandbox = {
3337
LittleAndroidLogic,
@@ -38,7 +42,7 @@ test('runtime initializes and renders a frame', () => {
3842
clearTimeout,
3943
innerWidth: 800,
4044
innerHeight: 600,
41-
document: { getElementById: id => elements[id] },
45+
document: { hidden: false, getElementById: id => elements[id] },
4246
window: {
4347
innerWidth: 800,
4448
innerHeight: 600,
@@ -69,11 +73,11 @@ test('NPC movement rejects a destination reserved earlier in the frame', () => {
6973
{ get: (target, key) => key in target ? target[key] : () => {} },
7074
);
7175
const element = () => ({
72-
style: {}, classList: { add() {}, remove() {} }, textContent: '',
76+
style: {}, classList: { add() {}, remove() {}, toggle() {} }, setAttribute() {}, textContent: '', focus() {},
7377
getBoundingClientRect: () => ({ left: 0, top: 0 }),
7478
addEventListener() {}, getContext: () => context2d,
7579
});
76-
const elements = { game: element(), coords: element(), hint: element(), emote: element() };
80+
const elements = { game: element(), coords: element(), hint: element(), emote: element(), status: element(), 'action-button': element() };
7781
const sandbox = {
7882
LittleAndroidLogic, console, Date, Math, setTimeout, clearTimeout,
7983
document: { hidden: false, getElementById: id => elements[id] },
@@ -100,11 +104,11 @@ test('interaction waits for a moving NPC to finish its tile step', () => {
100104
{ get: (target, key) => key in target ? target[key] : () => {} },
101105
);
102106
const element = () => ({
103-
style: {}, classList: { add() {}, remove() {} }, textContent: '',
107+
style: {}, classList: { add() {}, remove() {}, toggle() {} }, setAttribute() {}, textContent: '', focus() {},
104108
getBoundingClientRect: () => ({ left: 0, top: 0 }), addEventListener() {},
105109
getContext: () => context2d,
106110
});
107-
const elements = { game: element(), coords: element(), hint: element(), emote: element() };
111+
const elements = { game: element(), coords: element(), hint: element(), emote: element(), status: element(), 'action-button': element() };
108112
const sandbox = {
109113
LittleAndroidLogic, console, Date, Math, setTimeout, clearTimeout,
110114
document: { hidden: false, getElementById: id => elements[id] },

0 commit comments

Comments
 (0)