Skip to content

Commit e45f52e

Browse files
authored
Merge PR #15: Fix movement reservations and frame timing
Fix entity reservations, elapsed timing, and resilient input
2 parents 671dc9a + 6a1f68f commit e45f52e

4 files changed

Lines changed: 212 additions & 33 deletions

File tree

index.html

Lines changed: 77 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,13 @@
5454
const TS = TILE * PX;
5555
let W, H, COLS, ROWS;
5656
function resize() {
57-
W = canvas.width = window.innerWidth;
58-
H = canvas.height = window.innerHeight;
57+
W = window.innerWidth;
58+
H = window.innerHeight;
59+
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
60+
canvas.width = Math.floor(W * pixelRatio);
61+
canvas.height = Math.floor(H * pixelRatio);
62+
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
63+
ctx.imageSmoothingEnabled = false;
5964
COLS = Math.ceil(W / TS) + 2;
6065
ROWS = Math.ceil(H / TS) + 2;
6166
}
@@ -85,7 +90,15 @@
8590
// ══════════════════════════════════════════════════════
8691
// MAP (24x20 River Factory Zone)
8792
// ══════════════════════════════════════════════════════
88-
const { DIR, oppositeDir, createWorldMap, findPath: findWorldPath } = LittleAndroidLogic;
93+
const {
94+
DIR,
95+
oppositeDir,
96+
createWorldMap,
97+
findPath: findWorldPath,
98+
entityOccupiesTile,
99+
isTileReserved,
100+
tickTimedItems,
101+
} = LittleAndroidLogic;
89102
const world = createWorldMap(77);
90103
const MW = world.width, MH = world.height, map = world.map;
91104

@@ -95,11 +108,7 @@
95108
return t!==4&&t!==5&&t!==6&&t!==7&&t!==8&&t!==9&&t!==11&&t!==13&&t!==14&&t!==17;
96109
}
97110
function isTileOccupiedByNPC(tx,ty,excludeNPC){
98-
for(const n of npcs){
99-
if(n===excludeNPC)continue;
100-
if(Math.round(n.tileX)===tx&&Math.round(n.tileY)===ty)return n;
101-
}
102-
return null;
111+
return npcs.find(n => n !== excludeNPC && entityOccupiesTile(n, tx, ty)) || null;
103112
}
104113

105114
// ══════════════════════════════════════════════════════
@@ -299,9 +308,9 @@
299308
const tx = npc.tileX + d.dx, ty = npc.tileY + d.dy;
300309
if (!isWalkable(tx, ty)) return false;
301310
// Check if player is on target tile
302-
if (Math.round(player.tileX) === tx && Math.round(player.tileY) === ty) return false;
311+
if (entityOccupiesTile(player, tx, ty)) return false;
303312
// Check other NPCs
304-
if (isTileOccupiedByNPC(tx, ty, npc)) return false;
313+
if (isTileReserved(npcs, tx, ty, npc)) return false;
305314
npc.facing = dir;
306315
npc.moveFromX = npc.tileX; npc.moveFromY = npc.tileY;
307316
npc.moveToX = tx; npc.moveToY = ty;
@@ -550,6 +559,31 @@
550559
}
551560
}
552561

562+
function beginPlayerInteraction(npc) {
563+
player.facing = dirToward(player.tileX, player.tileY, npc.tileX, npc.tileY);
564+
player.state = 'INTERACTING';
565+
player.interactTarget = npc;
566+
player.interactArmTimer = 0.2;
567+
triggerNPCInteraction(npc);
568+
}
569+
570+
function requestPlayerInteraction(npc) {
571+
if (!npc || npc.interacting) return false;
572+
player.interactTarget = npc;
573+
targetPath = [];
574+
if (npc.moving) {
575+
player.state = 'WAITING_INTERACTION';
576+
return true;
577+
}
578+
const distance = Math.abs(npc.tileX - player.tileX) + Math.abs(npc.tileY - player.tileY);
579+
if (distance !== 1) {
580+
player.interactTarget = null;
581+
return false;
582+
}
583+
beginPlayerInteraction(npc);
584+
return true;
585+
}
586+
553587
function endNPCInteraction(npc) {
554588
npc.interacting = false;
555589

@@ -747,11 +781,7 @@
747781
const adj = isTileOccupiedByNPC(tileX, tileY, null);
748782
if (adj && Math.abs(tileX - player.tileX) + Math.abs(tileY - player.tileY) === 1) {
749783
// Trigger interaction
750-
player.facing = dirToward(player.tileX, player.tileY, tileX, tileY);
751-
player.state = 'INTERACTING';
752-
player.interactTarget = adj;
753-
player.interactArmTimer = 0.2; // 200ms arm raise
754-
triggerNPCInteraction(adj);
784+
requestPlayerInteraction(adj);
755785
return;
756786
}
757787
}
@@ -776,7 +806,7 @@
776806
const keysDown = {};
777807
window.addEventListener('keydown', (e) => {
778808
const k = e.key.toLowerCase();
779-
if (['w','a','s','d',' '].includes(k)) e.preventDefault();
809+
if (['w','a','s','d','arrowup','arrowdown','arrowleft','arrowright',' '].includes(k)) e.preventDefault();
780810
keysDown[k] = true;
781811

782812
// Spacebar: action button / scan pulse
@@ -787,23 +817,23 @@
787817
const adj = isTileOccupiedByNPC(fx, fy, null);
788818
if (adj) {
789819
// Interact
790-
player.state = 'INTERACTING';
791-
player.interactTarget = adj;
792-
player.interactArmTimer = 0.2;
793-
triggerNPCInteraction(adj);
820+
requestPlayerInteraction(adj);
794821
} else {
795822
// Scan pulse
796823
triggerScanPulse();
797824
}
798825
}
799826
});
800827
window.addEventListener('keyup', (e) => { keysDown[e.key.toLowerCase()] = false; });
828+
window.addEventListener('blur', () => {
829+
for (const key of Object.keys(keysDown)) keysDown[key] = false;
830+
});
801831

802832
function getWASDDirection() {
803-
if (keysDown['w']) return 'north';
804-
if (keysDown['s']) return 'south';
805-
if (keysDown['a']) return 'west';
806-
if (keysDown['d']) return 'east';
833+
if (keysDown['w'] || keysDown['arrowup']) return 'north';
834+
if (keysDown['s'] || keysDown['arrowdown']) return 'south';
835+
if (keysDown['a'] || keysDown['arrowleft']) return 'west';
836+
if (keysDown['d'] || keysDown['arrowright']) return 'east';
807837
return null;
808838
}
809839

@@ -873,11 +903,7 @@
873903
const npcOnNext = isTileOccupiedByNPC(next.x, next.y, null);
874904
if (npcOnNext) {
875905
// Walk up to NPC, trigger interaction
876-
player.facing = dirToward(player.tileX, player.tileY, next.x, next.y);
877-
player.state = 'INTERACTING';
878-
player.interactTarget = npcOnNext;
879-
player.interactArmTimer = 0.2;
880-
triggerNPCInteraction(npcOnNext);
906+
requestPlayerInteraction(npcOnNext);
881907
targetPath = [];
882908
} else {
883909
const dir = dirToward(player.tileX, player.tileY, next.x, next.y);
@@ -937,6 +963,22 @@
937963
}
938964
break;
939965
}
966+
case 'WAITING_INTERACTION': {
967+
player.renderX = player.tileX;
968+
player.renderY = player.tileY;
969+
const target = player.interactTarget;
970+
if (!target) {
971+
player.state = 'IDLE';
972+
} else if (!target.moving) {
973+
const distance = Math.abs(target.tileX - player.tileX) + Math.abs(target.tileY - player.tileY);
974+
if (distance === 1) beginPlayerInteraction(target);
975+
else {
976+
player.interactTarget = null;
977+
player.state = 'IDLE';
978+
}
979+
}
980+
break;
981+
}
940982
case 'INTERACTING': {
941983
player.interactArmTimer -= dt;
942984
player.renderX = player.tileX;
@@ -957,6 +999,7 @@
957999

9581000
// ── Update all NPCs ──
9591001
for (const npc of npcs) updateNPC(npc, dt);
1002+
scanLabels = tickTimedItems(scanLabels, dt);
9601003

9611004
// ── Emote positioning ──
9621005
if (emoteNPC) {
@@ -1077,8 +1120,6 @@
10771120
// Draw persistent scan labels above NPCs
10781121
for (let i = scanLabels.length - 1; i >= 0; i--) {
10791122
const sl = scanLabels[i];
1080-
sl.timer -= 1/60; // approximate dt
1081-
if (sl.timer <= 0) { scanLabels.splice(i, 1); continue; }
10821123
const fadeAlpha = Math.min(1, sl.timer / 0.5); // fade out in last 0.5s
10831124
const nx = sl.npc.renderX * TS - camX + TS/2;
10841125
const ny = sl.npc.renderY * TS - camY - 4;
@@ -1118,6 +1159,11 @@
11181159
}
11191160

11201161
function gameLoop(ts) {
1162+
if (document.hidden) {
1163+
lastTime = ts;
1164+
requestAnimationFrame(gameLoop);
1165+
return;
1166+
}
11211167
const dt = Math.min((ts - lastTime) / 1000, 0.1);
11221168
lastTime = ts;
11231169
update(dt);

src/game-logic.js

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,5 +167,36 @@
167167
return [];
168168
}
169169

170-
return Object.freeze({ DIR, oppositeDir, mulberry32, createWorldMap, findPath });
170+
function entityOccupiesTile(entity, x, y) {
171+
if (!entity) return false;
172+
if (Math.round(entity.tileX) === x && Math.round(entity.tileY) === y) return true;
173+
const inTransit = entity.moving || entity.state === 'WALKING';
174+
return Boolean(
175+
inTransit &&
176+
Math.round(entity.moveToX) === x &&
177+
Math.round(entity.moveToY) === y,
178+
);
179+
}
180+
181+
function isTileReserved(entities, x, y, excludedEntity = null) {
182+
return entities.some(entity => (
183+
entity !== excludedEntity && entityOccupiesTile(entity, x, y)
184+
));
185+
}
186+
187+
function tickTimedItems(items, deltaSeconds) {
188+
for (const item of items) item.timer -= deltaSeconds;
189+
return items.filter(item => item.timer > 0);
190+
}
191+
192+
return Object.freeze({
193+
DIR,
194+
oppositeDir,
195+
mulberry32,
196+
createWorldMap,
197+
findPath,
198+
entityOccupiesTile,
199+
isTileReserved,
200+
tickTimedItems,
201+
});
171202
});

tests/game-logic.test.cjs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@
22

33
const test = require('node:test');
44
const assert = require('node:assert/strict');
5-
const { DIR, oppositeDir, createWorldMap, findPath } = require('../src/game-logic.js');
5+
const {
6+
DIR,
7+
oppositeDir,
8+
createWorldMap,
9+
findPath,
10+
entityOccupiesTile,
11+
isTileReserved,
12+
tickTimedItems,
13+
} = require('../src/game-logic.js');
614

715
test('direction helpers remain stable', () => {
816
assert.deepEqual(DIR.north, { dx: 0, dy: -1 });
@@ -34,3 +42,24 @@ test('pathfinding chooses a nearby walkable target for scenery clicks', () => {
3442
const isWalkable = (x, y) => x === 1 && y === 2;
3543
assert.deepEqual(findPath(1, 2, 2, 2, isWalkable), [{ x: 1, y: 2 }]);
3644
});
45+
46+
test('moving entities reserve their source and destination tiles', () => {
47+
const moving = {
48+
tileX: 9,
49+
tileY: 11,
50+
moving: true,
51+
moveToX: 10,
52+
moveToY: 11,
53+
};
54+
assert.equal(entityOccupiesTile(moving, 9, 11), true);
55+
assert.equal(entityOccupiesTile(moving, 10, 11), true);
56+
assert.equal(isTileReserved([moving], 10, 11), true);
57+
assert.equal(isTileReserved([moving], 10, 11, moving), false);
58+
});
59+
60+
test('timed labels use elapsed time instead of frame count', () => {
61+
const items = [{ timer: 2 }, { timer: 0.25 }];
62+
const remaining = tickTimedItems(items, 0.5);
63+
assert.equal(remaining.length, 1);
64+
assert.equal(remaining[0].timer, 1.5);
65+
});

tests/runtime-smoke.test.cjs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ test('runtime initializes and renders a frame', () => {
4242
window: {
4343
innerWidth: 800,
4444
innerHeight: 600,
45+
devicePixelRatio: 2,
4546
addEventListener: (name, handler) => { listeners[`window:${name}`] = handler; },
4647
},
4748
requestAnimationFrame: handler => { nextFrame = handler; },
@@ -56,4 +57,76 @@ test('runtime initializes and renders a frame', () => {
5657
sandbox,
5758
);
5859
assert.deepEqual({ ...snapshot }, { playerX: 4, playerY: 6, state: 'IDLE', npcCount: 8 });
60+
assert.equal(elements.game.width, 1600);
61+
assert.equal(elements.game.height, 1200);
62+
});
63+
64+
test('NPC movement rejects a destination reserved earlier in the frame', () => {
65+
const html = fs.readFileSync('index.html', 'utf8');
66+
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
67+
const context2d = new Proxy(
68+
{ measureText: text => ({ width: String(text).length * 9 }) },
69+
{ get: (target, key) => key in target ? target[key] : () => {} },
70+
);
71+
const element = () => ({
72+
style: {}, classList: { add() {}, remove() {} }, textContent: '',
73+
getBoundingClientRect: () => ({ left: 0, top: 0 }),
74+
addEventListener() {}, getContext: () => context2d,
75+
});
76+
const elements = { game: element(), coords: element(), hint: element(), emote: element() };
77+
const sandbox = {
78+
LittleAndroidLogic, console, Date, Math, setTimeout, clearTimeout,
79+
document: { hidden: false, getElementById: id => elements[id] },
80+
window: { innerWidth: 800, innerHeight: 600, devicePixelRatio: 1, addEventListener() {} },
81+
requestAnimationFrame() {},
82+
};
83+
vm.createContext(sandbox);
84+
vm.runInContext(script, sandbox);
85+
const result = vm.runInContext(`
86+
npcs.length = 0;
87+
const first = makeNPC('worker', 9, 11, 'east');
88+
const second = makeNPC('worker', 11, 11, 'west');
89+
npcs.push(first, second);
90+
({ first: npcStartMove(first, 'east', 200), second: npcStartMove(second, 'west', 200) });
91+
`, sandbox);
92+
assert.deepEqual({ ...result }, { first: true, second: false });
93+
});
94+
95+
test('interaction waits for a moving NPC to finish its tile step', () => {
96+
const html = fs.readFileSync('index.html', 'utf8');
97+
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
98+
const context2d = new Proxy(
99+
{ measureText: text => ({ width: String(text).length * 9 }) },
100+
{ get: (target, key) => key in target ? target[key] : () => {} },
101+
);
102+
const element = () => ({
103+
style: {}, classList: { add() {}, remove() {} }, textContent: '',
104+
getBoundingClientRect: () => ({ left: 0, top: 0 }), addEventListener() {},
105+
getContext: () => context2d,
106+
});
107+
const elements = { game: element(), coords: element(), hint: element(), emote: element() };
108+
const sandbox = {
109+
LittleAndroidLogic, console, Date, Math, setTimeout, clearTimeout,
110+
document: { hidden: false, getElementById: id => elements[id] },
111+
window: { innerWidth: 800, innerHeight: 600, devicePixelRatio: 1, addEventListener() {} },
112+
requestAnimationFrame() {},
113+
};
114+
vm.createContext(sandbox);
115+
vm.runInContext(script, sandbox);
116+
const state = vm.runInContext(`
117+
const target = makeNPC('worker', 4, 7, 'south');
118+
target.moving = true;
119+
target.moveToX = 5;
120+
target.moveToY = 7;
121+
npcs.length = 0;
122+
npcs.push(target);
123+
requestPlayerInteraction(target);
124+
const waiting = player.state;
125+
target.moving = false;
126+
target.tileX = 4;
127+
target.tileY = 7;
128+
update(0.016);
129+
({ waiting, afterStep: player.state });
130+
`, sandbox);
131+
assert.deepEqual({ ...state }, { waiting: 'WAITING_INTERACTION', afterStep: 'INTERACTING' });
59132
});

0 commit comments

Comments
 (0)