Skip to content

Commit 3347cee

Browse files
authored
Merge PR #25: Add three-level campaign progression
Add Flooded Relay and Overgrown Archive with permanent sequential unlocks
2 parents cd3dfae + c91f2a7 commit 3347cee

11 files changed

Lines changed: 638 additions & 69 deletions

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ An explorable pixel-art micro-world rendered entirely in the browser. Walk throu
66

77
## Controls
88

9-
First-time players receive a short mission briefing. Reopen it at any time with the `?` control.
9+
First-time players receive a short mission briefing for the active zone. Reopen it at any time with the `?` control, or use the `L1`/`L2`/`L3` control to change unlocked zones.
1010

1111
| Action | Input |
1212
| --- | --- |
@@ -23,6 +23,10 @@ The current mobile experience supports tap-to-move. A dedicated touch action con
2323
- Grid-based movement and click-to-move pathfinding
2424
- A radial scan that reveals character identities
2525
- A three-part signal recovery quest with resident hints and daily challenge codes
26+
- A three-level campaign with permanent sequential unlocks:
27+
- **River Factory:** scan for three hidden signal carriers
28+
- **Flooded Relay:** activate control banks to open floodgates between platforms
29+
- **Overgrown Archive:** follow resident clues and restore memories in the daily sequence
2630
- Persistent progress, a discovery journal, replay, and completion sharing
2731
- Optional sound, haptic feedback, touch controls, and reduced-motion support
2832
- No runtime framework, backend, or asset bundle

index.html

Lines changed: 196 additions & 59 deletions
Large diffs are not rendered by default.

src/engagement.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@
9494
});
9595
}
9696

97-
function completionText(challengeCode) {
98-
return `I restored the Little Android factory (${challengeCode}). Explore it at https://littleandroid.com`;
97+
function completionText(challengeCode, levelName = 'factory') {
98+
return `I restored the Little Android ${levelName.toLowerCase()} (${challengeCode}). Explore it at https://littleandroid.com`;
9999
}
100100

101101
return Object.freeze({

src/game-content.js

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,86 @@
2121
dog: Object.freeze({ active: 'HAPPY STATIC. WARM TRAIL. GOOD TRAIL.', complete: 'FACTORY GOOD. YOU GOOD.' }),
2222
});
2323

24-
return Object.freeze({ SIGNAL_FRAGMENTS, NPC_DIALOGUE });
24+
const LEVELS = Object.freeze([
25+
Object.freeze({
26+
id: 'river-factory',
27+
number: 1,
28+
name: 'RIVER FACTORY',
29+
mode: 'scan',
30+
seed: 77,
31+
spawn: Object.freeze({ x: 4, y: 6, facing: 'south' }),
32+
objectiveLabel: 'SIGNALS',
33+
completeLabel: 'FACTORY ONLINE',
34+
briefing: 'Three signal carriers are dark. Explore the river factory, scan for their locations, and recover all three.',
35+
objectives: SIGNAL_FRAGMENTS,
36+
gates: Object.freeze([]),
37+
sign: Object.freeze({ x: 2, y: 3, width: 6, active: 'ROBOTS', complete: 'ONLINE' }),
38+
npcs: Object.freeze([
39+
Object.freeze({ type: 'worker', x: 3, y: 4, facing: 'south', label: 'UNIT-47' }),
40+
Object.freeze({ type: 'worker', x: 6, y: 4, facing: 'east', label: 'UNIT-12' }),
41+
Object.freeze({ type: 'scout', x: 8, y: 8, facing: 'south', label: 'RECON-3', patrol: Object.freeze([{x:8,y:8,f:'south'},{x:12,y:8,f:'east'},{x:12,y:6,f:'north'},{x:8,y:6,f:'west'}]) }),
42+
Object.freeze({ type: 'elder', x: 5, y: 3, facing: 'south', label: 'OVERSEER' }),
43+
Object.freeze({ type: 'farmer', x: 3, y: 14, facing: 'south', label: 'OLD DALE' }),
44+
Object.freeze({ type: 'mechanic', x: 9, y: 5, facing: 'north', label: 'SPARKS' }),
45+
Object.freeze({ type: 'child', x: 6, y: 12, facing: 'south', label: 'LIL BIT' }),
46+
Object.freeze({ type: 'dog', x: 7, y: 11, facing: 'east', label: 'GOOD BOY' }),
47+
]),
48+
}),
49+
Object.freeze({
50+
id: 'flooded-relay',
51+
number: 2,
52+
name: 'FLOODED RELAY',
53+
mode: 'gates',
54+
spawn: Object.freeze({ x: 3, y: 10, facing: 'east' }),
55+
objectiveLabel: 'RELAYS',
56+
completeLabel: 'CURRENT ROUTED',
57+
briefing: 'The relay platforms are isolated. Activate each control bank to open the next floodgate and route current across the water.',
58+
objectives: Object.freeze([
59+
Object.freeze({ id: 'intake-relay', name: 'INTAKE RELAY', x: 5, y: 6, visible: true }),
60+
Object.freeze({ id: 'spillway-relay', name: 'SPILLWAY RELAY', x: 12, y: 14, visible: true }),
61+
Object.freeze({ id: 'tower-relay', name: 'TOWER RELAY', x: 20, y: 6, visible: true }),
62+
]),
63+
gates: Object.freeze([
64+
Object.freeze({ x: 8, y: 10, after: 'intake-relay' }),
65+
Object.freeze({ x: 16, y: 10, after: 'spillway-relay' }),
66+
]),
67+
sign: Object.freeze({ x: 9, y: 7, width: 7, active: 'RELAY', complete: 'ROUTED' }),
68+
npcs: Object.freeze([
69+
Object.freeze({ type: 'mechanic', x: 3, y: 6, facing: 'east', label: 'VALVE' }),
70+
Object.freeze({ type: 'worker', x: 11, y: 5, facing: 'south', label: 'PUMP-8' }),
71+
Object.freeze({ type: 'scout', x: 18, y: 13, facing: 'north', label: 'WADER', patrol: Object.freeze([{x:18,y:13,f:'north'},{x:21,y:13,f:'east'},{x:21,y:9,f:'north'},{x:18,y:9,f:'west'}]) }),
72+
]),
73+
}),
74+
Object.freeze({
75+
id: 'overgrown-archive',
76+
number: 3,
77+
name: 'OVERGROWN ARCHIVE',
78+
mode: 'sequence',
79+
seed: 191,
80+
spawn: Object.freeze({ x: 3, y: 10, facing: 'north' }),
81+
objectiveLabel: 'MEMORY',
82+
completeLabel: 'ARCHIVE AWAKE',
83+
briefing: 'The archive terminals answer only in sequence. Speak with its residents, follow the current clue, and wake all three memories.',
84+
objectives: Object.freeze([
85+
Object.freeze({ id: 'root-index', name: 'ROOT INDEX', x: 4, y: 5, visible: true, clue: 'WEST WING · ABOVE THE OLD STONE' }),
86+
Object.freeze({ id: 'moss-ledger', name: 'MOSS LEDGER', x: 12, y: 6, visible: true, clue: 'CENTRAL COURT · NORTH OF THE BROKEN WALL' }),
87+
Object.freeze({ id: 'canopy-memory', name: 'CANOPY MEMORY', x: 20, y: 15, visible: true, clue: 'EAST GROVE · BELOW THE LAST ARCH' }),
88+
]),
89+
gates: Object.freeze([]),
90+
sign: Object.freeze({ x: 9, y: 9, width: 7, active: 'ARCHIVE', complete: 'AWAKE' }),
91+
npcs: Object.freeze([
92+
Object.freeze({ type: 'elder', x: 5, y: 10, facing: 'west', label: 'CURATOR', clue: true }),
93+
Object.freeze({ type: 'farmer', x: 11, y: 12, facing: 'north', label: 'GROUNDSKEEP', clue: true }),
94+
Object.freeze({ type: 'scout', x: 19, y: 8, facing: 'south', label: 'INDEXER', clue: true, patrol: Object.freeze([{x:19,y:8,f:'south'},{x:21,y:8,f:'east'},{x:21,y:12,f:'south'},{x:19,y:12,f:'west'}]) }),
95+
]),
96+
}),
97+
]);
98+
99+
const LEVEL_IDS = Object.freeze(LEVELS.map(level => level.id));
100+
101+
function getLevel(levelId) {
102+
return LEVELS.find(level => level.id === levelId) || LEVELS[0];
103+
}
104+
105+
return Object.freeze({ SIGNAL_FRAGMENTS, NPC_DIALOGUE, LEVELS, LEVEL_IDS, getLevel });
25106
});

src/game-logic.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,74 @@
108108
return { width, height, map };
109109
}
110110

111+
function createFloodedRelayMap() {
112+
const width = 24;
113+
const height = 20;
114+
const map = Array.from({ length: height }, () => Array(width).fill(5));
115+
116+
const carvePlatform = (x1, y1, x2, y2, tile) => {
117+
for (let y = y1; y <= y2; y++) {
118+
for (let x = x1; x <= x2; x++) map[y][x] = tile;
119+
}
120+
};
121+
carvePlatform(1, 3, 7, 16, 2);
122+
carvePlatform(9, 2, 15, 17, 10);
123+
carvePlatform(17, 3, 22, 16, 2);
124+
125+
for (const x of [7, 8, 9, 15, 16, 17]) map[10][x] = 12;
126+
map[10][8] = 18;
127+
map[10][16] = 18;
128+
129+
for (const [x, y] of [[2, 4], [6, 15], [10, 3], [14, 16], [18, 4], [21, 15]]) {
130+
map[y][x] = 15;
131+
}
132+
for (const [x, y] of [[3, 8], [12, 8], [20, 12]]) map[y][x] = 10;
133+
134+
return { width, height, map };
135+
}
136+
137+
function createOvergrownArchiveMap(seed = 191) {
138+
const width = 24;
139+
const height = 20;
140+
const map = Array.from({ length: height }, () => Array(width).fill(0));
141+
const random = mulberry32(seed);
142+
143+
for (let y = 0; y < height; y++) {
144+
for (let x = 0; x < width; x++) {
145+
if (random() < 0.38) map[y][x] = 1;
146+
}
147+
}
148+
for (let x = 0; x < width; x++) {
149+
map[0][x] = 14;
150+
map[height - 1][x] = 14;
151+
}
152+
for (let y = 0; y < height; y++) {
153+
map[y][0] = 14;
154+
map[y][width - 1] = 14;
155+
}
156+
157+
for (let y = 1; y <= 14; y++) map[y][8] = 8;
158+
for (const y of [5, 11]) map[y][8] = 2;
159+
for (let y = 5; y <= 18; y++) map[y][16] = 8;
160+
for (const y of [9, 15]) map[y][16] = 2;
161+
for (let x = 8; x <= 16; x++) map[9][x] = 9;
162+
map[9][12] = 2;
163+
164+
for (const [x, y] of [[3, 2], [6, 3], [3, 15], [6, 17], [11, 2], [14, 4], [11, 14], [14, 17], [19, 2], [21, 7], [19, 17]]) {
165+
map[y][x] = 6;
166+
if (y + 1 < height - 1) map[y + 1][x] = 7;
167+
}
168+
for (const [x, y] of [[4, 8], [12, 4], [20, 11]]) map[y][x] = 15;
169+
170+
return { width, height, map };
171+
}
172+
173+
function createLevelMap(levelId, seed) {
174+
if (levelId === 'flooded-relay') return createFloodedRelayMap();
175+
if (levelId === 'overgrown-archive') return createOvergrownArchiveMap(seed || 191);
176+
return createWorldMap(seed || 77);
177+
}
178+
111179
function findPath(startX, startY, endX, endY, isWalkable, options = {}) {
112180
const sx = Math.round(startX);
113181
const sy = Math.round(startY);
@@ -190,14 +258,29 @@
190258
return items.filter(item => item.timer > 0);
191259
}
192260

261+
function isGateOpen(gate, collectedObjectiveIds) {
262+
return Boolean(gate && Array.isArray(collectedObjectiveIds) && collectedObjectiveIds.includes(gate.after));
263+
}
264+
265+
function canCollectObjective(mode, orderedObjectiveIds, collectedObjectiveIds, objectiveId) {
266+
if (mode !== 'sequence') return true;
267+
const nextId = orderedObjectiveIds.find(id => !collectedObjectiveIds.includes(id));
268+
return nextId === objectiveId;
269+
}
270+
193271
return Object.freeze({
194272
DIR,
195273
oppositeDir,
196274
mulberry32,
197275
createWorldMap,
276+
createFloodedRelayMap,
277+
createOvergrownArchiveMap,
278+
createLevelMap,
198279
findPath,
199280
entityOccupiesTile,
200281
isTileReserved,
201282
tickTimedItems,
283+
isGateOpen,
284+
canCollectObjective,
202285
});
203286
});

src/progress.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
'use strict';
77

88
const SAVE_KEY = 'littleandroid.progress';
9+
const CAMPAIGN_KEY = 'littleandroid.campaign';
910
const SAVE_VERSION = 2;
1011

1112
function defaultProgress() {
@@ -114,8 +115,78 @@
114115
}
115116
}
116117

118+
function createLevelStorage(storage, levelId) {
119+
const levelKey = levelId === 'river-factory' ? SAVE_KEY : `${SAVE_KEY}.${levelId}`;
120+
return Object.freeze({
121+
getItem(key) {
122+
return storage && storage.getItem(key === SAVE_KEY ? levelKey : key);
123+
},
124+
setItem(key, value) {
125+
if (storage) storage.setItem(key === SAVE_KEY ? levelKey : key, value);
126+
},
127+
removeItem(key) {
128+
if (storage) storage.removeItem(key === SAVE_KEY ? levelKey : key);
129+
},
130+
});
131+
}
132+
133+
function defaultCampaign(levelIds = []) {
134+
return {
135+
activeLevelId: levelIds[0] || 'river-factory',
136+
completedLevelIds: [],
137+
};
138+
}
139+
140+
function parseCampaign(raw, levelIds = []) {
141+
const fallback = defaultCampaign(levelIds);
142+
try {
143+
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
144+
if (!parsed || typeof parsed !== 'object') return fallback;
145+
const completedLevelIds = normalizeStringList(parsed.completedLevelIds)
146+
.filter(levelId => levelIds.includes(levelId));
147+
return {
148+
activeLevelId: levelIds.includes(parsed.activeLevelId) ? parsed.activeLevelId : fallback.activeLevelId,
149+
completedLevelIds,
150+
};
151+
} catch {
152+
return fallback;
153+
}
154+
}
155+
156+
function loadCampaign(storage, levelIds = []) {
157+
try { return parseCampaign(storage && storage.getItem(CAMPAIGN_KEY), levelIds); }
158+
catch { return defaultCampaign(levelIds); }
159+
}
160+
161+
function saveCampaign(storage, campaign, levelIds = []) {
162+
try {
163+
if (!storage) return false;
164+
storage.setItem(CAMPAIGN_KEY, JSON.stringify(parseCampaign(campaign, levelIds)));
165+
return true;
166+
} catch {
167+
return false;
168+
}
169+
}
170+
171+
function isLevelUnlocked(campaign, levelId, levelIds = []) {
172+
const index = levelIds.indexOf(levelId);
173+
if (index <= 0) return index === 0;
174+
const normalized = parseCampaign(campaign, levelIds);
175+
return normalized.completedLevelIds.includes(levelIds[index - 1]);
176+
}
177+
178+
function completeCampaignLevel(campaign, levelId, levelIds = []) {
179+
const normalized = parseCampaign(campaign, levelIds);
180+
if (!levelIds.includes(levelId)) return normalized;
181+
return {
182+
...normalized,
183+
completedLevelIds: normalizeStringList([...normalized.completedLevelIds, levelId]),
184+
};
185+
}
186+
117187
return Object.freeze({
118188
SAVE_KEY,
189+
CAMPAIGN_KEY,
119190
SAVE_VERSION,
120191
defaultProgress,
121192
parseProgress,
@@ -124,5 +195,12 @@
124195
mergeProgress,
125196
saveProgress,
126197
clearProgress,
198+
createLevelStorage,
199+
defaultCampaign,
200+
parseCampaign,
201+
loadCampaign,
202+
saveCampaign,
203+
isLevelUnlocked,
204+
completeCampaignLevel,
127205
});
128206
});

tests/engagement.test.cjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,9 @@ test('completion copy contains only challenge and public URL', () => {
4848
'I restored the Little Android factory (D20260716-0042). Explore it at https://littleandroid.com',
4949
);
5050
});
51+
52+
test('completion copy identifies the restored campaign zone', () => {
53+
const text = completionText('D20260717-0001', 'OVERGROWN ARCHIVE');
54+
assert.match(text, /overgrown archive/);
55+
assert.match(text, /D20260717-0001/);
56+
});

tests/game-content.test.cjs

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
const test = require('node:test');
44
const assert = require('node:assert/strict');
5-
const { SIGNAL_FRAGMENTS, NPC_DIALOGUE } = require('../src/game-content.js');
6-
const { createWorldMap } = require('../src/game-logic.js');
5+
const { SIGNAL_FRAGMENTS, NPC_DIALOGUE, LEVELS, LEVEL_IDS } = require('../src/game-content.js');
6+
const { createWorldMap, createLevelMap, findPath, isGateOpen } = require('../src/game-logic.js');
77

88
test('signal fragments have unique identities and walkable positions', () => {
99
const { map, width, height } = createWorldMap(77);
@@ -31,3 +31,50 @@ test('every resident type has active and completed dialogue', () => {
3131
assert.ok(NPC_DIALOGUE[type].complete.length > 0);
3232
}
3333
});
34+
35+
test('all campaign levels have unique objectives on valid map tiles', () => {
36+
assert.deepEqual(LEVEL_IDS, ['river-factory', 'flooded-relay', 'overgrown-archive']);
37+
for (const level of LEVELS) {
38+
const world = createLevelMap(level.id, level.seed);
39+
const ids = new Set();
40+
for (const objective of level.objectives) {
41+
assert.equal(ids.has(objective.id), false, `${level.id} has duplicate objective ${objective.id}`);
42+
ids.add(objective.id);
43+
assert.ok(objective.x >= 0 && objective.x < world.width);
44+
assert.ok(objective.y >= 0 && objective.y < world.height);
45+
assert.equal([4, 5, 6, 7, 8, 9, 11, 13, 14, 17, 18].includes(world.map[objective.y][objective.x]), false);
46+
}
47+
assert.equal(level.objectives.length, 3);
48+
}
49+
});
50+
51+
test('flooded relay gates connect its three platforms in order', () => {
52+
const relay = LEVELS[1];
53+
const world = createLevelMap(relay.id);
54+
assert.deepEqual(relay.gates.map(gate => world.map[gate.y][gate.x]), [18, 18]);
55+
assert.deepEqual(relay.gates.map(gate => gate.after), ['intake-relay', 'spillway-relay']);
56+
});
57+
58+
test('every level objective is reachable under its progression rules', () => {
59+
const blockedTiles = new Set([4, 5, 6, 7, 8, 9, 11, 13, 14, 17]);
60+
for (const level of LEVELS) {
61+
const world = createLevelMap(level.id, level.seed);
62+
const collected = [];
63+
let position = level.spawn;
64+
const walkable = (x, y) => {
65+
if (x < 0 || y < 0 || x >= world.width || y >= world.height) return false;
66+
const tile = world.map[y][x];
67+
if (tile === 18) {
68+
const gate = level.gates.find(item => item.x === x && item.y === y);
69+
return isGateOpen(gate, collected);
70+
}
71+
return !blockedTiles.has(tile);
72+
};
73+
for (const objective of level.objectives) {
74+
const path = findPath(position.x, position.y, objective.x, objective.y, walkable, { allowNearest: false });
75+
assert.ok(path.length > 0, `${objective.name} must be reachable in ${level.name}`);
76+
collected.push(objective.id);
77+
position = objective;
78+
}
79+
}
80+
});

0 commit comments

Comments
 (0)