|
8 | 8 | <link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Silkscreen:wght@400;700&display=swap" rel="stylesheet"> |
9 | 9 | <style> |
10 | 10 | *{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} |
15 | 16 | #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)} |
16 | 17 | #title-bar h1{font-family:'Silkscreen',monospace;font-size:18px;color:#D4A030;text-shadow:2px 2px 0px #6B4820;letter-spacing:2px} |
17 | 18 | #title-bar .dot{width:8px;height:8px;background:#D4A030;border-radius:50%;animation:blink 1.6s ease-in-out infinite} |
18 | 19 | @keyframes blink{0%,100%{opacity:1}50%{opacity:0.3}} |
19 | 20 | #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} |
20 | 22 | @keyframes fadeHint{0%,100%{opacity:0.6}50%{opacity:0.2}} |
21 | 23 | #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} |
22 | 24 | #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} |
23 | 25 | #coords{position:absolute;bottom:24px;right:24px;font-family:'Press Start 2P',monospace;font-size:8px;color:#5A8848;pointer-events:none;z-index:10} |
24 | 26 | #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} |
25 | 27 | #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 | +} |
26 | 51 | </style> |
27 | 52 | </head> |
28 | 53 | <body> |
29 | 54 | <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> |
31 | 56 | <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> |
33 | 58 | <div id="controls"> |
34 | | - <span class="key">WASD</span> move<br> |
| 59 | + <span class="key">WASD / ARROWS</span> move<br> |
35 | 60 | <span class="key">CLICK</span> walk to<br> |
36 | 61 | <span class="key">SPACE</span> scan |
37 | 62 | </div> |
38 | 63 | <div id="coords"></div> |
39 | 64 | <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> |
40 | 68 | </div> |
41 | 69 | <script src="src/game-logic.js"></script> |
42 | 70 | <script> |
|
48 | 76 | const coordsEl = document.getElementById('coords'); |
49 | 77 | const hintEl = document.getElementById('hint'); |
50 | 78 | const emoteEl = document.getElementById('emote'); |
| 79 | +const statusEl = document.getElementById('status'); |
| 80 | +const actionButton = document.getElementById('action-button'); |
51 | 81 |
|
52 | 82 | const PX = 3; |
53 | 83 | const TILE = 16; |
|
565 | 595 | player.interactTarget = npc; |
566 | 596 | player.interactArmTimer = 0.2; |
567 | 597 | triggerNPCInteraction(npc); |
| 598 | + showStatus(`LINK: ${npc.scanLabel || npc.type.toUpperCase()}`); |
568 | 599 | } |
569 | 600 |
|
570 | 601 | function requestPlayerInteraction(npc) { |
|
758 | 789 | // PATHFINDING |
759 | 790 | // ══════════════════════════════════════════════════════ |
760 | 791 | function findPath(sx,sy,ex,ey){ |
761 | | - return findWorldPath(sx, sy, ex, ey, isWalkable); |
| 792 | + return findWorldPath(sx, sy, ex, ey, isWalkable, { allowNearest: false }); |
762 | 793 | } |
763 | 794 |
|
764 | 795 | // ══════════════════════════════════════════════════════ |
|
769 | 800 | let clickMarker = null; |
770 | 801 |
|
771 | 802 | canvas.addEventListener('click', (e) => { |
| 803 | + canvas.focus({ preventScroll: true }); |
772 | 804 | const rect = canvas.getBoundingClientRect(); |
773 | 805 | const mx = e.clientX - rect.left, my = e.clientY - rect.top; |
774 | 806 | const camX = player.renderX * TS - W/2 + TS/2; |
|
799 | 831 | const dir = dirToward(player.tileX, player.tileY, next.x, next.y); |
800 | 832 | playerStartMove(dir); |
801 | 833 | } |
| 834 | + } else if (tileX !== player.tileX || tileY !== player.tileY) { |
| 835 | + showStatus('PATH BLOCKED'); |
802 | 836 | } |
803 | 837 | }); |
804 | 838 |
|
805 | 839 | // ── WASD Keyboard Movement ── |
806 | 840 | const keysDown = {}; |
807 | 841 | window.addEventListener('keydown', (e) => { |
808 | 842 | 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(); |
810 | 844 | keysDown[k] = true; |
811 | 845 |
|
812 | 846 | // 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(); |
826 | 848 | }); |
827 | 849 | window.addEventListener('keyup', (e) => { keysDown[e.key.toLowerCase()] = false; }); |
828 | 850 | window.addEventListener('blur', () => { |
|
837 | 859 | return null; |
838 | 860 | } |
839 | 861 |
|
| 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 | + |
840 | 904 | // ── Scan Pulse System ── |
841 | 905 | let scanPulse = null; |
842 | 906 | const SCAN_COOLDOWN = 1.5; // seconds |
843 | 907 | let scanCooldownTimer = 0; |
844 | 908 |
|
845 | 909 | function triggerScanPulse() { |
846 | | - if (scanCooldownTimer > 0) return; |
| 910 | + if (scanCooldownTimer > 0) { |
| 911 | + showStatus(`SCAN READY IN ${Math.ceil(scanCooldownTimer)}S`); |
| 912 | + return false; |
| 913 | + } |
847 | 914 | scanPulse = { |
848 | 915 | x: player.tileX, y: player.tileY, |
849 | 916 | time: Date.now(), |
|
855 | 922 | // Brief visor flash on player |
856 | 923 | player.idleCoreOn = true; |
857 | 924 | player.idleTimer = 0; |
| 925 | + showStatus('SCANNING...'); |
| 926 | + return true; |
858 | 927 | } |
859 | 928 |
|
860 | 929 | // ══════════════════════════════════════════════════════ |
|
865 | 934 | function update(dt) { |
866 | 935 | // Scan cooldown |
867 | 936 | 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 | + } |
868 | 944 |
|
869 | 945 | // ── Player State Machine ── |
870 | 946 | switch (player.state) { |
|
1000 | 1076 | // ── Update all NPCs ── |
1001 | 1077 | for (const npc of npcs) updateNPC(npc, dt); |
1002 | 1078 | scanLabels = tickTimedItems(scanLabels, dt); |
| 1079 | + updateActionButton(); |
1003 | 1080 |
|
1004 | 1081 | // ── Emote positioning ── |
1005 | 1082 | if (emoteNPC) { |
|
0 commit comments