Skip to content

Commit f39c0b6

Browse files
authored
Merge PR #21: Add narrative journal and persistent progress
Add resident dialogue, signal log, and persistent progress
2 parents ba92889 + c9b2029 commit f39c0b6

7 files changed

Lines changed: 416 additions & 19 deletions

File tree

index.html

Lines changed: 131 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,18 @@
3232
#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}
3333
#status.show{opacity:1}
3434
#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}
3636
#quest-reset.show{display:block}
3737
#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}
3847
.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}
3948
@media(max-width:600px){
4049
#ui-overlay{justify-content:flex-start;padding:calc(10px + env(safe-area-inset-top)) 10px 10px}
@@ -43,7 +52,8 @@
4352
#title-bar .dot{width:6px;height:6px;flex:0 0 auto}
4453
#controls,#coords{display:none}
4554
#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}
4757
#hint{bottom:max(88px,calc(68px + env(safe-area-inset-bottom)));width:70vw;font-size:10px;text-align:center}
4858
#action-button{display:block}
4959
#status{bottom:max(84px,calc(64px + env(safe-area-inset-bottom)));font-size:10px}
@@ -71,12 +81,19 @@
7181
<div id="emote"></div>
7282
<div id="status" role="status" aria-live="polite"></div>
7383
<div id="quest" aria-live="polite">SIGNALS 0/3</div>
84+
<button id="journal-button" type="button" aria-label="Open signal log">LOG</button>
7485
<button id="quest-reset" type="button" aria-label="Restart signal quest" title="Restart signal quest"></button>
7586
<button id="action-button" type="button" aria-label="Scan or interact">SCAN</button>
7687
<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>
7793
</div>
7894
<script src="src/game-logic.js"></script>
7995
<script src="src/game-content.js"></script>
96+
<script src="src/progress.js"></script>
8097
<script>
8198
// ══════════════════════════════════════════════════════
8299
// SETUP
@@ -90,6 +107,10 @@
90107
const actionButton = document.getElementById('action-button');
91108
const questEl = document.getElementById('quest');
92109
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');
93114

94115
const PX = 3;
95116
const TILE = 16;
@@ -148,7 +169,18 @@
148169
discovered: false,
149170
collected: false,
150171
}));
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+
};
152184

153185
function isWalkable(tx,ty){
154186
if(tx<0||ty<0||tx>=MW||ty>=MH)return false;
@@ -616,7 +648,9 @@
616648
player.interactTarget = npc;
617649
player.interactArmTimer = 0.2;
618650
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);
620654
}
621655

622656
function requestPlayerInteraction(npc) {
@@ -866,7 +900,13 @@
866900

867901
// ── WASD Keyboard Movement ──
868902
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+
869908
window.addEventListener('keydown', (e) => {
909+
if (!shouldHandleGameKey(e.target)) return;
870910
const k = e.key.toLowerCase();
871911
if (['w','a','s','d','arrowup','arrowdown','arrowleft','arrowright',' ','enter'].includes(k)) e.preventDefault();
872912
keysDown[k] = true;
@@ -923,6 +963,57 @@
923963
questResetButton.classList.toggle('show', quest.complete);
924964
}
925965

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+
9261017
function collectFragment(fragment) {
9271018
if (!fragment || fragment.collected) return false;
9281019
fragment.collected = true;
@@ -935,6 +1026,8 @@
9351026
showStatus(`${fragment.name} RECOVERED · ${collected}/${signalFragments.length}`, 2.5);
9361027
}
9371028
updateQuestUI();
1029+
persistProgress();
1030+
updateJournal();
9381031
return true;
9391032
}
9401033

@@ -947,17 +1040,34 @@
9471040
showStatus('CLEAR SIGNAL SITES BEFORE RESTART', 2.5);
9481041
return false;
9491042
}
1043+
scanPulse = null;
1044+
scanLabels = [];
1045+
scanCooldownTimer = 0;
9501046
for (const fragment of signalFragments) {
9511047
fragment.discovered = false;
9521048
fragment.collected = false;
9531049
}
1050+
discoveredNPCs.clear();
9541051
quest.complete = false;
9551052
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());
9561056
updateQuestUI();
1057+
updateJournal();
9571058
showStatus('SIGNAL SEARCH RESTARTED', 2);
9581059
return true;
9591060
}
9601061

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+
9611071
function performAction() {
9621072
if (player.state !== 'IDLE') return;
9631073
const adjacentFragment = getFacingFragment();
@@ -974,7 +1084,13 @@
9741084
canvas.focus({ preventScroll: true });
9751085
performAction();
9761086
});
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 }));
9781094

9791095
let statusTimer = 0;
9801096
function showStatus(message, duration = 1.5) {
@@ -1037,6 +1153,8 @@
10371153
);
10381154
if (distance >= previousRadius && distance <= radius) {
10391155
fragment.discovered = true;
1156+
persistProgress();
1157+
updateJournal();
10401158
showStatus(`SIGNAL FOUND: ${fragment.name}`, 2.5);
10411159
}
10421160
}
@@ -1305,6 +1423,7 @@
13051423
if (!scanLabels.find(s => s.npc === npc)) {
13061424
scanLabels.push({ npc, timer: 2.0 }); // 2 seconds visible
13071425
}
1426+
rememberNPC(npc);
13081427
}
13091428
}
13101429
}
@@ -1381,7 +1500,13 @@
13811500
requestAnimationFrame(gameLoop);
13821501
}
13831502
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+
}
13851510
requestAnimationFrame((t) => { lastTime = t; gameLoop(t); });
13861511
</script>
13871512
</body>

scripts/check.cjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ if (!html.includes('src/game-logic.js')) {
1414
if (!html.includes('src/game-content.js')) {
1515
throw new Error('index.html must load the shared game content');
1616
}
17+
if (!html.includes('src/progress.js')) {
18+
throw new Error('index.html must load the progress module');
19+
}
1720
if (!html.includes('#ui-overlay button{pointer-events:auto}')) {
1821
throw new Error('Overlay controls must accept pointer input');
1922
}
@@ -24,5 +27,6 @@ if (inlineScripts.length !== 1) {
2427
new Function(inlineScripts[0][1]);
2528
require('../src/game-logic.js');
2629
require('../src/game-content.js');
30+
require('../src/progress.js');
2731

2832
console.log('Static checks passed.');

src/game-content.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,15 @@
1111
Object.freeze({ id: 'garden-cell', name: 'GARDEN CELL', x: 10, y: 16 }),
1212
]);
1313

14-
return Object.freeze({ SIGNAL_FRAGMENTS });
14+
const NPC_DIALOGUE = Object.freeze({
15+
worker: Object.freeze({ active: 'THE LINE IS QUIET. THREE CARRIERS ARE STILL DARK.', complete: 'CURRENT STABLE. GOOD WORK, UNIT-01.' }),
16+
scout: Object.freeze({ active: 'I SAW A PULSE NEAR THE BRIDGE. SCAN FROM THE PATH.', complete: 'PERIMETER CLEAR. THE SIGNAL HOLDS.' }),
17+
elder: Object.freeze({ active: 'OLD NETWORKS WAKE IN PIECES. LISTEN FOR ALL THREE.', complete: 'THE FACTORY REMEMBERS ITS NAME.' }),
18+
farmer: Object.freeze({ active: 'SOMETHING HUMS WEST OF THE FIELD. NOT A BEE.', complete: 'EVEN THE SEEDLINGS CAN FEEL THAT CURRENT.' }),
19+
mechanic: Object.freeze({ active: 'BRING ME THE RELAYS AND I CAN CLOSE THE CIRCUIT.', complete: 'ONLINE AND CLEAN. THAT WILL DO.' }),
20+
child: Object.freeze({ active: 'I FOUND A SHINY NOISE! THEN IT RAN SOUTH.', complete: 'YOU MADE THE BIG SIGN CHANGE!' }),
21+
dog: Object.freeze({ active: 'HAPPY STATIC. WARM TRAIL. GOOD TRAIL.', complete: 'FACTORY GOOD. YOU GOOD.' }),
22+
});
23+
24+
return Object.freeze({ SIGNAL_FRAGMENTS, NPC_DIALOGUE });
1525
});

0 commit comments

Comments
 (0)