|
32 | 32 | #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} |
33 | 33 | #status.show{opacity:1} |
34 | 34 | #quest{position:absolute;top:max(22px,env(safe-area-inset-top));right:max(22px,env(safe-area-inset-right));z-index:12;color:#E8C040;font:10px 'Silkscreen',monospace;text-shadow:1px 1px 0 #1a1510;pointer-events:none} |
35 | | -#quest-reset{position:absolute;top:max(48px,calc(26px + env(safe-area-inset-top)));right:max(22px,env(safe-area-inset-right));z-index:20;display:none;width:34px;height:34px;border:1px solid #D4A030;border-radius:4px;background:rgba(10,10,10,.82);color:#E8C040;font:20px monospace;cursor:pointer} |
| 35 | +#quest-reset{position:absolute;top:max(88px,calc(66px + env(safe-area-inset-top)));right:max(22px,env(safe-area-inset-right));z-index:20;display:none;width:34px;height:34px;border:1px solid #D4A030;border-radius:4px;background:rgba(10,10,10,.82);color:#E8C040;font:20px monospace;cursor:pointer} |
36 | 36 | #quest-reset.show{display:block} |
37 | 37 | #quest-reset:focus-visible{outline:3px solid #E8E0D0;outline-offset:2px} |
| 38 | +#journal-button{position:absolute;top:max(48px,calc(26px + env(safe-area-inset-top)));right:max(22px,env(safe-area-inset-right));z-index:20;width:42px;height:30px;border:1px solid #5A8848;border-radius:4px;background:rgba(10,10,10,.82);color:#A8C088;font:10px 'Silkscreen',monospace;cursor:pointer} |
| 39 | +#journal-button:focus-visible,#journal-close:focus-visible{outline:3px solid #E8E0D0;outline-offset:2px} |
| 40 | +#journal-dialog{width:min(440px,calc(100vw - 32px));max-height:min(70vh,560px);padding:18px;border:2px solid #5A8848;border-radius:4px;background:#181820;color:#E8E0D0;font-family:'Silkscreen',monospace} |
| 41 | +#journal-dialog::backdrop{background:rgba(10,10,10,.75)} |
| 42 | +#journal-dialog h2{font-size:16px;color:#E8C040;margin-bottom:14px} |
| 43 | +#journal-list{display:grid;gap:9px;max-height:46vh;overflow:auto;font-size:11px;line-height:1.7;white-space:pre-line} |
| 44 | +.journal-entry{padding-bottom:7px;border-bottom:1px solid #383030;color:#B0A890} |
| 45 | +.journal-entry.found{color:#E8E0D0} |
| 46 | +#journal-close{margin-top:16px;padding:8px 12px;border:1px solid #D4A030;border-radius:4px;background:#181820;color:#E8C040;font:11px 'Silkscreen',monospace;cursor:pointer} |
38 | 47 | .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} |
39 | 48 | @media(max-width:600px){ |
40 | 49 | #ui-overlay{justify-content:flex-start;padding:calc(10px + env(safe-area-inset-top)) 10px 10px} |
|
43 | 52 | #title-bar .dot{width:6px;height:6px;flex:0 0 auto} |
44 | 53 | #controls,#coords{display:none} |
45 | 54 | #quest{top:calc(54px + env(safe-area-inset-top));right:12px;font-size:9px} |
46 | | - #quest-reset{top:calc(74px + env(safe-area-inset-top));right:12px} |
| 55 | + #journal-button{top:calc(74px + env(safe-area-inset-top));right:12px} |
| 56 | + #quest-reset{top:calc(112px + env(safe-area-inset-top));right:12px} |
47 | 57 | #hint{bottom:max(88px,calc(68px + env(safe-area-inset-bottom)));width:70vw;font-size:10px;text-align:center} |
48 | 58 | #action-button{display:block} |
49 | 59 | #status{bottom:max(84px,calc(64px + env(safe-area-inset-bottom)));font-size:10px} |
|
71 | 81 | <div id="emote"></div> |
72 | 82 | <div id="status" role="status" aria-live="polite"></div> |
73 | 83 | <div id="quest" aria-live="polite">SIGNALS 0/3</div> |
| 84 | + <button id="journal-button" type="button" aria-label="Open signal log">LOG</button> |
74 | 85 | <button id="quest-reset" type="button" aria-label="Restart signal quest" title="Restart signal quest">↻</button> |
75 | 86 | <button id="action-button" type="button" aria-label="Scan or interact">SCAN</button> |
76 | 87 | <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> |
| 88 | + <dialog id="journal-dialog" aria-labelledby="journal-title"> |
| 89 | + <h2 id="journal-title">SIGNAL LOG</h2> |
| 90 | + <div id="journal-list"></div> |
| 91 | + <button id="journal-close" type="button">CLOSE</button> |
| 92 | + </dialog> |
77 | 93 | </div> |
78 | 94 | <script src="src/game-logic.js"></script> |
79 | 95 | <script src="src/game-content.js"></script> |
| 96 | +<script src="src/progress.js"></script> |
80 | 97 | <script> |
81 | 98 | // ══════════════════════════════════════════════════════ |
82 | 99 | // SETUP |
|
90 | 107 | const actionButton = document.getElementById('action-button'); |
91 | 108 | const questEl = document.getElementById('quest'); |
92 | 109 | const questResetButton = document.getElementById('quest-reset'); |
| 110 | +const journalButton = document.getElementById('journal-button'); |
| 111 | +const journalDialog = document.getElementById('journal-dialog'); |
| 112 | +const journalList = document.getElementById('journal-list'); |
| 113 | +const journalClose = document.getElementById('journal-close'); |
93 | 114 |
|
94 | 115 | const PX = 3; |
95 | 116 | const TILE = 16; |
|
148 | 169 | discovered: false, |
149 | 170 | collected: false, |
150 | 171 | })); |
151 | | -const quest = { complete: false }; |
| 172 | +let progressStorage = null; |
| 173 | +try { progressStorage = window.localStorage; } catch {} |
| 174 | +const savedProgress = LittleAndroidProgress.loadProgress(progressStorage); |
| 175 | +let progressGeneration = savedProgress.generation; |
| 176 | +for (const fragment of signalFragments) { |
| 177 | + fragment.discovered = savedProgress.discoveredFragments.includes(fragment.id); |
| 178 | + fragment.collected = savedProgress.collectedFragments.includes(fragment.id); |
| 179 | +} |
| 180 | +const discoveredNPCs = new Set(savedProgress.discoveredNPCs); |
| 181 | +const quest = { |
| 182 | + complete: savedProgress.complete || signalFragments.every(fragment => fragment.collected), |
| 183 | +}; |
152 | 184 |
|
153 | 185 | function isWalkable(tx,ty){ |
154 | 186 | if(tx<0||ty<0||tx>=MW||ty>=MH)return false; |
|
616 | 648 | player.interactTarget = npc; |
617 | 649 | player.interactArmTimer = 0.2; |
618 | 650 | triggerNPCInteraction(npc); |
619 | | - showStatus(`LINK: ${npc.scanLabel || npc.type.toUpperCase()}`); |
| 651 | + rememberNPC(npc); |
| 652 | + const dialogue = LittleAndroidContent.NPC_DIALOGUE[npc.type]; |
| 653 | + showStatus(dialogue ? (quest.complete ? dialogue.complete : dialogue.active) : `LINK: ${npc.scanLabel || npc.type.toUpperCase()}`, 3.5); |
620 | 654 | } |
621 | 655 |
|
622 | 656 | function requestPlayerInteraction(npc) { |
|
866 | 900 |
|
867 | 901 | // ── WASD Keyboard Movement ── |
868 | 902 | const keysDown = {}; |
| 903 | +function shouldHandleGameKey(target) { |
| 904 | + return !target || typeof target.closest !== 'function' || |
| 905 | + !target.closest('button, dialog, input, select, textarea, a[href]'); |
| 906 | +} |
| 907 | + |
869 | 908 | window.addEventListener('keydown', (e) => { |
| 909 | + if (!shouldHandleGameKey(e.target)) return; |
870 | 910 | const k = e.key.toLowerCase(); |
871 | 911 | if (['w','a','s','d','arrowup','arrowdown','arrowleft','arrowright',' ','enter'].includes(k)) e.preventDefault(); |
872 | 912 | keysDown[k] = true; |
|
923 | 963 | questResetButton.classList.toggle('show', quest.complete); |
924 | 964 | } |
925 | 965 |
|
| 966 | +function currentProgress() { |
| 967 | + return { |
| 968 | + version: LittleAndroidProgress.SAVE_VERSION, |
| 969 | + generation: progressGeneration, |
| 970 | + discoveredFragments: signalFragments.filter(fragment => fragment.discovered).map(fragment => fragment.id), |
| 971 | + collectedFragments: signalFragments.filter(fragment => fragment.collected).map(fragment => fragment.id), |
| 972 | + discoveredNPCs: [...discoveredNPCs], |
| 973 | + complete: quest.complete, |
| 974 | + }; |
| 975 | +} |
| 976 | + |
| 977 | +function persistProgress() { |
| 978 | + const latest = LittleAndroidProgress.loadProgress(progressStorage); |
| 979 | + const merged = LittleAndroidProgress.mergeProgress( |
| 980 | + latest, |
| 981 | + currentProgress(), |
| 982 | + signalFragments.map(fragment => fragment.id), |
| 983 | + ); |
| 984 | + progressGeneration = merged.generation; |
| 985 | + for (const fragment of signalFragments) { |
| 986 | + fragment.discovered = merged.discoveredFragments.includes(fragment.id); |
| 987 | + fragment.collected = merged.collectedFragments.includes(fragment.id); |
| 988 | + } |
| 989 | + discoveredNPCs.clear(); |
| 990 | + for (const identity of merged.discoveredNPCs) discoveredNPCs.add(identity); |
| 991 | + quest.complete = merged.complete; |
| 992 | + document.body.classList.toggle('world-restored', quest.complete); |
| 993 | + updateQuestUI(); |
| 994 | + LittleAndroidProgress.saveProgress(progressStorage, merged); |
| 995 | + return merged; |
| 996 | +} |
| 997 | + |
| 998 | +function rememberNPC(npc) { |
| 999 | + const identity = npc.scanLabel || npc.type.toUpperCase(); |
| 1000 | + if (discoveredNPCs.has(identity)) return; |
| 1001 | + discoveredNPCs.add(identity); |
| 1002 | + persistProgress(); |
| 1003 | + updateJournal(); |
| 1004 | +} |
| 1005 | + |
| 1006 | +function updateJournal() { |
| 1007 | + const fragmentLines = signalFragments.map(fragment => ( |
| 1008 | + `${fragment.collected ? '[OK]' : fragment.discovered ? '[ ]' : '[??]'} ${fragment.discovered ? fragment.name : 'UNKNOWN SIGNAL'}` |
| 1009 | + )); |
| 1010 | + const npcLines = npcs.map(npc => { |
| 1011 | + const identity = npc.scanLabel || npc.type.toUpperCase(); |
| 1012 | + return `${discoveredNPCs.has(identity) ? '[ID]' : '[??]'} ${discoveredNPCs.has(identity) ? identity : 'UNKNOWN RESIDENT'}`; |
| 1013 | + }); |
| 1014 | + journalList.textContent = `COMPONENTS\n${fragmentLines.join('\n')}\n\nRESIDENTS\n${npcLines.join('\n')}`; |
| 1015 | +} |
| 1016 | + |
926 | 1017 | function collectFragment(fragment) { |
927 | 1018 | if (!fragment || fragment.collected) return false; |
928 | 1019 | fragment.collected = true; |
|
935 | 1026 | showStatus(`${fragment.name} RECOVERED · ${collected}/${signalFragments.length}`, 2.5); |
936 | 1027 | } |
937 | 1028 | updateQuestUI(); |
| 1029 | + persistProgress(); |
| 1030 | + updateJournal(); |
938 | 1031 | return true; |
939 | 1032 | } |
940 | 1033 |
|
|
947 | 1040 | showStatus('CLEAR SIGNAL SITES BEFORE RESTART', 2.5); |
948 | 1041 | return false; |
949 | 1042 | } |
| 1043 | + scanPulse = null; |
| 1044 | + scanLabels = []; |
| 1045 | + scanCooldownTimer = 0; |
950 | 1046 | for (const fragment of signalFragments) { |
951 | 1047 | fragment.discovered = false; |
952 | 1048 | fragment.collected = false; |
953 | 1049 | } |
| 1050 | + discoveredNPCs.clear(); |
954 | 1051 | quest.complete = false; |
955 | 1052 | document.body.classList.remove('world-restored'); |
| 1053 | + const storedGeneration = LittleAndroidProgress.loadProgress(progressStorage).generation; |
| 1054 | + progressGeneration = Math.max(progressGeneration, storedGeneration) + 1; |
| 1055 | + LittleAndroidProgress.saveProgress(progressStorage, currentProgress()); |
956 | 1056 | updateQuestUI(); |
| 1057 | + updateJournal(); |
957 | 1058 | showStatus('SIGNAL SEARCH RESTARTED', 2); |
958 | 1059 | return true; |
959 | 1060 | } |
960 | 1061 |
|
| 1062 | +function requestQuestReset(confirmReset) { |
| 1063 | + const message = 'Restart the journey? Recovered signals and the resident log will be cleared.'; |
| 1064 | + const confirmed = typeof confirmReset === 'function' ? |
| 1065 | + confirmReset(message) : |
| 1066 | + typeof window.confirm === 'function' && window.confirm(message); |
| 1067 | + if (!confirmed) return false; |
| 1068 | + return resetQuest(); |
| 1069 | +} |
| 1070 | + |
961 | 1071 | function performAction() { |
962 | 1072 | if (player.state !== 'IDLE') return; |
963 | 1073 | const adjacentFragment = getFacingFragment(); |
|
974 | 1084 | canvas.focus({ preventScroll: true }); |
975 | 1085 | performAction(); |
976 | 1086 | }); |
977 | | -questResetButton.addEventListener('click', resetQuest); |
| 1087 | +questResetButton.addEventListener('click', () => requestQuestReset()); |
| 1088 | +journalButton.addEventListener('click', () => { |
| 1089 | + updateJournal(); |
| 1090 | + journalDialog.showModal(); |
| 1091 | +}); |
| 1092 | +journalClose.addEventListener('click', () => journalDialog.close()); |
| 1093 | +journalDialog.addEventListener('close', () => canvas.focus({ preventScroll: true })); |
978 | 1094 |
|
979 | 1095 | let statusTimer = 0; |
980 | 1096 | function showStatus(message, duration = 1.5) { |
|
1037 | 1153 | ); |
1038 | 1154 | if (distance >= previousRadius && distance <= radius) { |
1039 | 1155 | fragment.discovered = true; |
| 1156 | + persistProgress(); |
| 1157 | + updateJournal(); |
1040 | 1158 | showStatus(`SIGNAL FOUND: ${fragment.name}`, 2.5); |
1041 | 1159 | } |
1042 | 1160 | } |
|
1305 | 1423 | if (!scanLabels.find(s => s.npc === npc)) { |
1306 | 1424 | scanLabels.push({ npc, timer: 2.0 }); // 2 seconds visible |
1307 | 1425 | } |
| 1426 | + rememberNPC(npc); |
1308 | 1427 | } |
1309 | 1428 | } |
1310 | 1429 | } |
|
1381 | 1500 | requestAnimationFrame(gameLoop); |
1382 | 1501 | } |
1383 | 1502 | updateQuestUI(); |
1384 | | -showStatus('OBJECTIVE: LOCATE 3 SIGNALS', 4); |
| 1503 | +updateJournal(); |
| 1504 | +if (quest.complete) { |
| 1505 | + document.body.classList.add('world-restored'); |
| 1506 | + showStatus('FACTORY LINK RESTORED', 3); |
| 1507 | +} else { |
| 1508 | + showStatus('OBJECTIVE: LOCATE 3 SIGNALS', 4); |
| 1509 | +} |
1385 | 1510 | requestAnimationFrame((t) => { lastTime = t; gameLoop(t); }); |
1386 | 1511 | </script> |
1387 | 1512 | </body> |
|
0 commit comments