Skip to content

Commit 671dc9a

Browse files
authored
Merge PR #14: Extract deterministic game logic and add verification
Extract deterministic game logic and add verification
2 parents 16e4c74 + 3931656 commit 671dc9a

10 files changed

Lines changed: 351 additions & 55 deletions

File tree

.github/workflows/verify.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: Verify
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches: [main]
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
verify:
13+
runs-on: ubuntu-latest
14+
timeout-minutes: 5
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-node@v4
18+
with:
19+
node-version: 22
20+
cache: npm
21+
- run: npm install --ignore-scripts
22+
- run: npm run verify

CONTRIBUTING.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ The current production page has no build step. Serve the repository and open it
1414
python3 -m http.server 8000
1515
```
1616

17-
Before submitting, exercise movement, click-to-move, scanning, and at least one NPC interaction. Use the repository's automated checks as they are introduced under issue #4.
17+
Before submitting, run the automated checks and exercise movement, click-to-move, scanning, and at least one NPC interaction:
18+
19+
```sh
20+
npm run verify
21+
```
1822

1923
## Pull requests
2024

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,20 @@ The current mobile experience supports tap-to-move. A dedicated touch action con
2424

2525
## Run locally
2626

27-
The production version is a static page. Open `index.html` directly in a browser, or serve the directory with any static server:
27+
The production version is a static page. Serve the directory with any static server:
2828

2929
```sh
3030
python3 -m http.server 8000
3131
```
3232

3333
Then open `http://localhost:8000`.
3434

35+
Run the dependency-free verification suite with Node.js 22 or later:
36+
37+
```sh
38+
npm run verify
39+
```
40+
3541
## Project direction
3642

3743
The project is moving from an ambient interactive vignette toward a polished five-minute micro-game. The work is organized in the [master roadmap](https://github.com/mcvalosborne/littleandroid/issues/12):

index.html

Lines changed: 5 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
<div id="coords"></div>
3939
<div id="emote"></div>
4040
</div>
41+
<script src="src/game-logic.js"></script>
4142
<script>
4243
// ══════════════════════════════════════════════════════
4344
// SETUP
@@ -84,33 +85,9 @@
8485
// ══════════════════════════════════════════════════════
8586
// MAP (24x20 River Factory Zone)
8687
// ══════════════════════════════════════════════════════
87-
const MW=24,MH=20;
88-
const map=[];
89-
for(let y=0;y<MH;y++){map[y]=[];for(let x=0;x<MW;x++)map[y][x]=0}
90-
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;var t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296}}
91-
const rng=mulberry32(77);
92-
93-
// Factory
94-
for(let y=1;y<=5;y++)for(let x=1;x<=8;x++)map[y][x]=y===1?9:8;
95-
map[0][3]=11;map[0][7]=11;map[5][4]=10;map[5][5]=10;
96-
// River
97-
for(let y=0;y<MH;y++){map[y][17]=17;map[y][18]=5;map[y][19]=4;map[y][20]=5;if(21<MW)map[y][21]=17;if(22<MW)map[y][22]=0;if(23<MW)map[y][23]=0}
98-
// Bridge
99-
for(let x=17;x<=21;x++){map[9][x]=13;map[10][x]=12;map[11][x]=12;map[12][x]=13}
100-
// Path
101-
for(let y=6;y<=10;y++){map[y][4]=2;map[y][5]=2}
102-
for(let x=5;x<=17;x++){map[10][x]=2;map[11][x]=2}
103-
// Tall grass
104-
for(const[x1,y1,x2,y2]of[[10,2,14,5],[12,6,16,9],[1,13,6,17],[8,14,13,18]])
105-
for(let y=y1;y<=Math.min(y2,MH-1);y++)for(let x=x1;x<=Math.min(x2,MW-1);x++)
106-
if(map[y][x]===0&&rng()<0.6)map[y][x]=1;
107-
// Trees
108-
for(const[tx,cy]of[[15,2],[16,4],[15,6],[16,8],[15,14],[16,16],[13,3],[14,7],[13,13],[14,17],[3,8],[7,7],[10,8],[1,11],[8,12],[11,1],[12,3]]){
109-
if(cy>=0&&cy<MH&&tx<MW){if(map[cy][tx]===0||map[cy][tx]===1)map[cy][tx]=6;if(cy+1<MH&&(map[cy+1][tx]===0||map[cy+1][tx]===1))map[cy+1][tx]=7}}
110-
// Details
111-
for(const[rx,ry]of[[12,10],[2,15],[9,17],[16,13]])if(map[ry][rx]===0||map[ry][rx]===1)map[ry][rx]=14;
112-
if(map[9][6]===0||map[9][6]===2)map[9][6]=15;
113-
if(map[6][3]===0)map[6][3]=16;
88+
const { DIR, oppositeDir, createWorldMap, findPath: findWorldPath } = LittleAndroidLogic;
89+
const world = createWorldMap(77);
90+
const MW = world.width, MH = world.height, map = world.map;
11491

11592
function isWalkable(tx,ty){
11693
if(tx<0||ty<0||tx>=MW||ty>=MH)return false;
@@ -155,9 +132,6 @@
155132
// ══════════════════════════════════════════════════════
156133
// DIRECTION HELPERS
157134
// ══════════════════════════════════════════════════════
158-
const DIR={south:{dx:0,dy:1},north:{dx:0,dy:-1},east:{dx:1,dy:0},west:{dx:-1,dy:0}};
159-
function oppositeDir(d){return{south:'north',north:'south',east:'west',west:'east'}[d]}
160-
161135
// ══════════════════════════════════════════════════════
162136
// UNIT-01 PLAYER CHARACTER (Full State Machine)
163137
// ══════════════════════════════════════════════════════
@@ -750,29 +724,7 @@
750724
// PATHFINDING
751725
// ══════════════════════════════════════════════════════
752726
function findPath(sx,sy,ex,ey){
753-
sx=Math.round(sx);sy=Math.round(sy);ex=Math.round(ex);ey=Math.round(ey);
754-
if(!isWalkable(ex,ey)){
755-
let best=null,bd=Infinity;
756-
for(let dy=-3;dy<=3;dy++)for(let dx=-3;dx<=3;dx++)
757-
if(isWalkable(ex+dx,ey+dy)){const d=dx*dx+dy*dy;if(d<bd){bd=d;best={x:ex+dx,y:ey+dy}}}
758-
if(!best)return[];ex=best.x;ey=best.y;
759-
}
760-
const key=(x,y)=>`${x},${y}`;
761-
const open=[{x:sx,y:sy,g:0,h:0,f:0,parent:null}],closed=new Set(),gM={};gM[key(sx,sy)]=0;
762-
const h=(x,y)=>Math.abs(x-ex)+Math.abs(y-ey);
763-
let it=0;
764-
while(open.length>0&&it<3000){
765-
it++;open.sort((a,b)=>a.f-b.f);const c=open.shift();
766-
if(c.x===ex&&c.y===ey){const p=[];let n=c;while(n){p.unshift({x:n.x,y:n.y});n=n.parent}return p}
767-
closed.add(key(c.x,c.y));
768-
for(const[dx,dy]of[[-1,0],[1,0],[0,-1],[0,1]]){
769-
const nx=c.x+dx,ny=c.y+dy;
770-
if(closed.has(key(nx,ny))||!isWalkable(nx,ny))continue;
771-
const g=c.g+1;if(gM[key(nx,ny)]!==undefined&&g>=gM[key(nx,ny)])continue;
772-
gM[key(nx,ny)]=g;open.push({x:nx,y:ny,g,h:h(nx,ny),f:g+h(nx,ny),parent:c});
773-
}
774-
}
775-
return[];
727+
return findWorldPath(sx, sy, ex, ey, isWalkable);
776728
}
777729

778730
// ══════════════════════════════════════════════════════

package-lock.json

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "littleandroid",
3+
"version": "0.1.0",
4+
"private": true,
5+
"description": "Interactive pixel-art browser world",
6+
"scripts": {
7+
"check": "node scripts/check.cjs",
8+
"test": "node --test",
9+
"verify": "npm run check && npm test"
10+
},
11+
"license": "MIT"
12+
}

scripts/check.cjs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
'use strict';
2+
3+
const fs = require('node:fs');
4+
5+
const html = fs.readFileSync('index.html', 'utf8');
6+
const inlineScripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)];
7+
8+
if (!html.includes('<canvas id="game">')) {
9+
throw new Error('index.html must contain the game canvas');
10+
}
11+
if (!html.includes('src/game-logic.js')) {
12+
throw new Error('index.html must load the shared game logic');
13+
}
14+
if (inlineScripts.length !== 1) {
15+
throw new Error(`Expected one inline runtime script, found ${inlineScripts.length}`);
16+
}
17+
18+
new Function(inlineScripts[0][1]);
19+
require('../src/game-logic.js');
20+
21+
console.log('Static checks passed.');

src/game-logic.js

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
(function exposeGameLogic(root, factory) {
2+
const api = factory();
3+
if (typeof module === 'object' && module.exports) module.exports = api;
4+
if (root) root.LittleAndroidLogic = api;
5+
})(typeof globalThis !== 'undefined' ? globalThis : this, function createGameLogic() {
6+
'use strict';
7+
8+
const DIR = Object.freeze({
9+
south: Object.freeze({ dx: 0, dy: 1 }),
10+
north: Object.freeze({ dx: 0, dy: -1 }),
11+
east: Object.freeze({ dx: 1, dy: 0 }),
12+
west: Object.freeze({ dx: -1, dy: 0 }),
13+
});
14+
15+
const OPPOSITE_DIR = Object.freeze({
16+
south: 'north',
17+
north: 'south',
18+
east: 'west',
19+
west: 'east',
20+
});
21+
22+
function oppositeDir(direction) {
23+
return OPPOSITE_DIR[direction];
24+
}
25+
26+
function mulberry32(seed) {
27+
return function random() {
28+
let value = seed |= 0;
29+
seed = value + 0x6D2B79F5 | 0;
30+
value = Math.imul(seed ^ seed >>> 15, 1 | seed);
31+
value = value + Math.imul(value ^ value >>> 7, 61 | value) ^ value;
32+
return ((value ^ value >>> 14) >>> 0) / 4294967296;
33+
};
34+
}
35+
36+
function createWorldMap(seed = 77) {
37+
const width = 24;
38+
const height = 20;
39+
const map = Array.from({ length: height }, () => Array(width).fill(0));
40+
const random = mulberry32(seed);
41+
42+
for (let y = 1; y <= 5; y++) {
43+
for (let x = 1; x <= 8; x++) map[y][x] = y === 1 ? 9 : 8;
44+
}
45+
map[0][3] = 11;
46+
map[0][7] = 11;
47+
map[5][4] = 10;
48+
map[5][5] = 10;
49+
50+
for (let y = 0; y < height; y++) {
51+
map[y][17] = 17;
52+
map[y][18] = 5;
53+
map[y][19] = 4;
54+
map[y][20] = 5;
55+
map[y][21] = 17;
56+
}
57+
58+
for (let x = 17; x <= 21; x++) {
59+
map[9][x] = 13;
60+
map[10][x] = 12;
61+
map[11][x] = 12;
62+
map[12][x] = 13;
63+
}
64+
65+
for (let y = 6; y <= 10; y++) {
66+
map[y][4] = 2;
67+
map[y][5] = 2;
68+
}
69+
for (let x = 5; x <= 17; x++) {
70+
map[10][x] = 2;
71+
map[11][x] = 2;
72+
}
73+
74+
const grassPatches = [
75+
[10, 2, 14, 5],
76+
[12, 6, 16, 9],
77+
[1, 13, 6, 17],
78+
[8, 14, 13, 18],
79+
];
80+
for (const [x1, y1, x2, y2] of grassPatches) {
81+
for (let y = y1; y <= Math.min(y2, height - 1); y++) {
82+
for (let x = x1; x <= Math.min(x2, width - 1); x++) {
83+
if (map[y][x] === 0 && random() < 0.6) map[y][x] = 1;
84+
}
85+
}
86+
}
87+
88+
const trees = [
89+
[15, 2], [16, 4], [15, 6], [16, 8], [15, 14], [16, 16],
90+
[13, 3], [14, 7], [13, 13], [14, 17], [3, 8], [7, 7],
91+
[10, 8], [1, 11], [8, 12], [11, 1], [12, 3],
92+
];
93+
for (const [x, y] of trees) {
94+
if (y >= 0 && y < height && x < width) {
95+
if (map[y][x] === 0 || map[y][x] === 1) map[y][x] = 6;
96+
if (y + 1 < height && (map[y + 1][x] === 0 || map[y + 1][x] === 1)) {
97+
map[y + 1][x] = 7;
98+
}
99+
}
100+
}
101+
102+
for (const [x, y] of [[12, 10], [2, 15], [9, 17], [16, 13]]) {
103+
if (map[y][x] === 0 || map[y][x] === 1) map[y][x] = 14;
104+
}
105+
if (map[9][6] === 0 || map[9][6] === 2) map[9][6] = 15;
106+
if (map[6][3] === 0) map[6][3] = 16;
107+
108+
return { width, height, map };
109+
}
110+
111+
function findPath(startX, startY, endX, endY, isWalkable) {
112+
const sx = Math.round(startX);
113+
const sy = Math.round(startY);
114+
let ex = Math.round(endX);
115+
let ey = Math.round(endY);
116+
117+
if (!isWalkable(ex, ey)) {
118+
let best = null;
119+
let bestDistance = Infinity;
120+
for (let dy = -3; dy <= 3; dy++) {
121+
for (let dx = -3; dx <= 3; dx++) {
122+
if (!isWalkable(ex + dx, ey + dy)) continue;
123+
const distance = dx * dx + dy * dy;
124+
if (distance < bestDistance) {
125+
bestDistance = distance;
126+
best = { x: ex + dx, y: ey + dy };
127+
}
128+
}
129+
}
130+
if (!best) return [];
131+
ex = best.x;
132+
ey = best.y;
133+
}
134+
135+
const key = (x, y) => `${x},${y}`;
136+
const heuristic = (x, y) => Math.abs(x - ex) + Math.abs(y - ey);
137+
const open = [{ x: sx, y: sy, g: 0, f: heuristic(sx, sy), parent: null }];
138+
const closed = new Set();
139+
const bestCost = { [key(sx, sy)]: 0 };
140+
let iterations = 0;
141+
142+
while (open.length > 0 && iterations < 3000) {
143+
iterations++;
144+
open.sort((a, b) => a.f - b.f);
145+
const current = open.shift();
146+
if (current.x === ex && current.y === ey) {
147+
const path = [];
148+
for (let node = current; node; node = node.parent) {
149+
path.unshift({ x: node.x, y: node.y });
150+
}
151+
return path;
152+
}
153+
154+
closed.add(key(current.x, current.y));
155+
for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) {
156+
const x = current.x + dx;
157+
const y = current.y + dy;
158+
const tileKey = key(x, y);
159+
if (closed.has(tileKey) || !isWalkable(x, y)) continue;
160+
const cost = current.g + 1;
161+
if (bestCost[tileKey] !== undefined && cost >= bestCost[tileKey]) continue;
162+
bestCost[tileKey] = cost;
163+
open.push({ x, y, g: cost, f: cost + heuristic(x, y), parent: current });
164+
}
165+
}
166+
167+
return [];
168+
}
169+
170+
return Object.freeze({ DIR, oppositeDir, mulberry32, createWorldMap, findPath });
171+
});

0 commit comments

Comments
 (0)