Add directional winding path system with canvas-drawn path and per-leg direction changes

This commit is contained in:
justin committed 2026-05-20 21:21:50 -07:00
1 parent ddf0a9cb08
commit 427381aecf
3 files changed
+233 -135

No files matched your search

+229 -115
View File
@@ -250,13 +250,12 @@ class Game {
screen:'boot', playerName:'', questions:[], currentQ:0,
score:0, streak:0, maxStreak:0, correct:0, wrong:0,
answering:false, introStep:0, twTimer:null,
/* map — world coords in px, camera follows player */
worldX: 0, /* player's X in world-px */
worldY: 0, /* player's Y in world-px (0 = path centre) */
npcWorldX: 0, /* NPC world-px X */
npcWorldY: 0,
worldX: 0, worldY: 0,
npcWorldX: 0, npcWorldY: 0,
pathDir: 'right',
legProgress: 0,
legLength: 0,
lastDir: 'right',
/* battle cursor */
cursor:0,
};
@@ -363,80 +362,96 @@ class Game {
p.className = 'map-player idle';
/* Show the idle/middle frame for the last direction walked
Frames at 2× scale (32px each): down=32, up=128, left=224, right=320 */
const idleFrames = { down:'-32px', left:'-128px', up:'-224px', right:'-320px' };
p.style.backgroundPositionX = idleFrames[this.state.lastDir] || '-32px';
const idleFrames = { right:'-32px', down:'-32px', left:'-128px', up:'-224px' };
p.style.backgroundPositionX = idleFrames[this.state.pathDir] || '-32px';
}
}
}
_walkTick() {
if (this.state.screen !== 'map') return;
_getViewport() {
const w = document.getElementById('map-world');
return {
viewW: (w && w.offsetWidth) || 420,
viewH: (w && w.offsetHeight) || 300,
};
}
const world = document.getElementById('map-inner');
if (!world) return;
const viewW = world.parentElement.offsetWidth || 420;
const viewH = world.parentElement.offsetHeight || 300;
_legVector() {
const d = this.state.pathDir;
return {
dx: d === 'right' ? 1 : d === 'left' ? -1 : 0,
dy: d === 'down' ? 1 : d === 'up' ? -1 : 0,
};
}
const SPEED = 8; /* px per tick */
let dx = 0, dy = 0, dir = '';
_playerScreenPos() {
const {viewW, viewH} = this._getViewport();
return { sx: viewW / 2 - 16, sy: viewH / 2 - 32 };
}
if (this._heldKeys.has('right')) { dx = SPEED; dir = 'right'; }
if (this._heldKeys.has('left')) { dx = -SPEED; dir = 'left'; }
if (this._heldKeys.has('up')) { dy = -SPEED; dir = 'up'; }
if (this._heldKeys.has('down')) { dy = SPEED; dir = 'down'; }
if (!dir) return;
_walkTick() {
if (this.state.screen !== 'map') return;
const {viewW, viewH} = this._getViewport();
const SPEED = 8;
const pd = this.state.pathDir;
const fwd = { right:'right', left:'left', up:'up', down:'down' }[pd];
if (!this._heldKeys.has(fwd)) return;
/* Clamp world X — left edge is 0, right edge is npcWorldX + half screen
so the player can always walk far enough to reach the NPC */
const maxWorldX = this.state.npcWorldX - Math.round(viewW / 2) + 100;
this.state.worldX = Math.max(0, Math.min(maxWorldX, this.state.worldX + dx));
/* Clamp Y within a narrow vertical band (path area) */
this.state.worldY = Math.max(-30, Math.min(30, this.state.worldY + dy));
this.state.lastDir = dir;
const {dx, dy} = this._legVector();
const maxProg = this.state.legLength - 60;
this.state.legProgress = Math.min(maxProg, Math.max(0, this.state.legProgress + SPEED));
this.state.lastDir = pd;
this._applyCamera();
/* Player sprite stays horizontally centred; only Y shifts */
const p = document.getElementById('map-player');
const bub = document.getElementById('player-bubble');
if (p) {
const centreX = viewW / 2 - 16;
const centreY = viewH * 0.65 + this.state.worldY;
const bottomPx = viewH - centreY - 40;
p.style.left = centreX + 'px';
p.style.bottom = bottomPx + 'px';
p.className = `map-player walk-${dir}`;
if (bub) { bub.style.left = (centreX - 4) + 'px'; bub.style.bottom = (bottomPx + 64) + 'px'; }
}
this._placePlayer(`walk-${pd}`);
this._checkNPCProximity();
this._drawPath();
}
/* Slide the world and reposition NPC in screen-space every tick */
_applyCamera() {
const world = document.getElementById('map-inner');
if (world) world.style.transform = `translateX(${-this.state.worldX}px)`;
const {viewW, viewH} = this._getViewport();
const {dx, dy} = this._legVector();
const prog = this.state.legProgress;
const tx = -(prog * dx);
const ty = -(prog * dy);
const inner = document.getElementById('map-inner');
if (inner) inner.style.transform = `translate(${tx}px,${ty}px)`;
this._positionNPCOnScreen();
}
_positionNPCOnScreen() {
const world = document.getElementById('map-inner');
const viewH = world ? world.parentElement.offsetHeight : 300;
const {viewW, viewH} = this._getViewport();
const {dx, dy} = this._legVector();
const prog = this.state.legProgress;
const legLen = this.state.legLength;
const npcWrap = document.getElementById('map-npc-wrap');
if (npcWrap) {
const screenX = this.state.npcWorldX - this.state.worldX;
npcWrap.style.left = (screenX - 24) + 'px';
npcWrap.style.bottom = (viewH * 0.28) + 'px';
const screenX = viewW / 2 + (legLen - prog) * dx - 24;
const screenY = viewH / 2 + (legLen - prog) * dy - 24;
npcWrap.style.left = screenX + 'px';
npcWrap.style.bottom = (viewH - screenY - 48) + 'px';
}
}
_placePlayer(cls) {
const {viewW, viewH} = this._getViewport();
const p = document.getElementById('map-player');
const bub = document.getElementById('player-bubble');
if (p) {
const cx = viewW / 2 - 16;
const cy = viewH / 2 - 32;
const bot = viewH - cy - 64;
p.style.left = cx + 'px';
p.style.bottom = bot + 'px';
p.className = 'map-player ' + cls;
if (bub) { bub.style.left = (cx - 4) + 'px'; bub.style.bottom = (bot + 66) + 'px'; }
}
}
_checkNPCProximity() {
const world = document.getElementById('map-world');
const viewW = (world && world.offsetWidth) ? world.offsetWidth : 420;
const viewH = (world && world.offsetHeight) ? world.offsetHeight : 300;
const playerWorldX = this.state.worldX + viewW / 2;
const dx = Math.abs(playerWorldX - this.state.npcWorldX);
const near = this.state.npcWorldX > 0 && dx < 60;
const {viewW, viewH} = this._getViewport();
const remaining = this.state.legLength - this.state.legProgress;
const near = this.state.legLength > 0 && remaining < 60;
const hint = document.getElementById('map-talk-hint');
const bub = document.getElementById('npc-bubble');
@@ -453,21 +468,36 @@ class Game {
const arrow = document.getElementById('map-dir-arrow');
if (!arrow) return;
if (nearNPC) { arrow.style.display = 'none'; arrow.style.animation = 'none'; return; }
const world = document.getElementById('map-world');
const viewH = (world && world.offsetHeight) ? world.offsetHeight : 300;
arrow.textContent = '▶';
const {viewW, viewH} = this._getViewport();
const pd = this.state.pathDir;
const glyphs = { right:'▶', left:'◀', up:'▲', down:'▼' };
arrow.textContent = glyphs[pd] || '▶';
arrow.style.display = 'block';
arrow.style.animation = 'arrowPulse .7s ease-in-out infinite';
arrow.style.removeProperty('left');
arrow.style.bottom = (viewH * 0.28) + 'px';
if (pd === 'right') {
arrow.style.removeProperty('left');
arrow.style.right = '12px';
arrow.style.bottom = (viewH / 2 - 16) + 'px';
} else if (pd === 'left') {
arrow.style.removeProperty('right');
arrow.style.left = '12px';
arrow.style.bottom = (viewH / 2 - 16) + 'px';
} else if (pd === 'up') {
arrow.style.removeProperty('right');
arrow.style.left = (viewW / 2 - 16) + 'px';
arrow.style.bottom = (viewH - 48) + 'px';
} else if (pd === 'down') {
arrow.style.removeProperty('right');
arrow.style.left = (viewW / 2 - 16) + 'px';
arrow.style.bottom = '12px';
}
}
_nearNPC() {
if (!this.state.npcWorldX) return false;
const world = document.getElementById('map-world');
const viewW = (world && world.offsetWidth) ? world.offsetWidth : 420;
const playerWorldX = this.state.worldX + viewW / 2;
return Math.abs(playerWorldX - this.state.npcWorldX) < 60;
if (!this.state.legLength) return false;
const remaining = this.state.legLength - this.state.legProgress;
return remaining < 60;
}
_talkToNPC() {
@@ -490,22 +520,14 @@ class Game {
this.startQuestion();
}
/* Place player at viewport centre, NPC via screen-space conversion */
_placeMapSprites() {
const world = document.getElementById('map-inner');
const viewW = world ? world.parentElement.offsetWidth : 420;
const viewH = world ? world.parentElement.offsetHeight : 300;
const idleFrames = { right:'-32px', down:'-32px', left:'-128px', up:'-224px' };
const p = document.getElementById('map-player');
if (p) {
const left = viewW / 2 - 16;
const bottom = viewH * 0.28;
p.style.left = left + 'px';
p.style.bottom = bottom + 'px';
p.className = 'map-player idle';
const bub = document.getElementById('player-bubble');
if (bub) { bub.style.left = (left - 4) + 'px'; bub.style.bottom = (bottom + 66) + 'px'; }
p.className = 'map-player idle';
p.style.backgroundPositionX = idleFrames[this.state.pathDir] || '-32px';
}
this._placePlayer('map-player idle');
this._applyCamera();
const hint = document.getElementById('map-talk-hint');
if (hint) hint.style.display = 'none';
@@ -983,34 +1005,35 @@ class Game {
/* Generate randomly placed trees across the full scrollable world */
/* Generate trees for the current journey leg — from world origin to just past the NPC.
Called fresh each time so trees always fill the visible path ahead. */
_pickNextDir(currentDir) {
const opposite = { right:'left', left:'right', up:'down', down:'up' };
const opp = opposite[currentDir];
const choices = ['right','left','up','down',currentDir,currentDir].filter(d => d !== opp);
return choices[Math.floor(Math.random() * choices.length)];
}
_generateTrees() {
const container = document.getElementById('map-trees');
if (!container) return;
/* Clear previous leg's trees */
container.innerHTML = '';
const world = document.getElementById('map-inner');
const viewW = world ? world.parentElement.offsetWidth : 420;
const viewH = world ? world.parentElement.offsetHeight : 300;
/* World spans from 0 to just past the NPC */
const worldW = this.state.npcWorldX + viewW;
if (world) world.style.width = worldW + 'px';
const treeTopMax = viewH * 0.50;
const treeBottomMin = viewH * 0.04;
/* One tree roughly every 120px across the leg */
const COUNT = Math.max(6, Math.round(worldW / 120));
const zoneW = worldW / COUNT;
const {viewW, viewH} = this._getViewport();
const legLen = this.state.legLength || viewW * 3;
const pd = this.state.pathDir;
const isHoriz = pd === 'right' || pd === 'left';
const worldW = isHoriz ? legLen + viewW : viewW * 2;
const worldH = isHoriz ? viewH * 2 : legLen + viewH;
const inner = document.getElementById('map-inner');
if (inner) { inner.style.width = worldW + 'px'; inner.style.height = worldH + 'px'; }
const COUNT = Math.max(8, Math.round(legLen / 100));
for (let i = 0; i < COUNT; i++) {
const span = document.createElement('span');
span.className = 'tree';
const size = 28 + Math.random() * 16;
const x = i * zoneW + Math.random() * zoneW * 0.75;
const y = treeBottomMin + Math.random() * (treeTopMax - treeBottomMin - size);
const t = (i + Math.random()) / COUNT;
const band = 60 + Math.random() * 80;
const side = Math.random() < 0.5 ? 1 : -1;
const x = isHoriz ? t * (legLen + viewW * 0.5) : viewW / 2 - 16 + side * band;
const y = isHoriz ? viewH / 2 - 32 + side * band : t * (legLen + viewH * 0.5);
span.textContent = '🌳';
span.style.fontSize = size + 'px';
span.style.left = x + 'px';
@@ -1021,15 +1044,21 @@ class Game {
}
}
/* Called on result screen — pre-builds trees for the next leg */
_preGenerateNextLeg() {
const world = document.getElementById('map-inner');
const viewW = world ? world.parentElement.offsetWidth : 420;
const nextNpcX = Math.round(viewW * 3.0 + Math.random() * viewW * 0.8);
const savedNpcX = this.state.npcWorldX;
this.state.npcWorldX = nextNpcX;
const {viewW, viewH} = this._getViewport();
const nextDir = this._pickNextDir(this.state.pathDir);
const isHoriz = nextDir === 'right' || nextDir === 'left';
const range = isHoriz ? viewW : viewH;
const nextLen = Math.round(range * 3.0 + Math.random() * range * 0.8);
const savedDir = this.state.pathDir;
const savedLen = this.state.legLength;
this.state.pathDir = nextDir;
this.state.legLength = nextLen;
this._generateTrees();
this.state.npcWorldX = savedNpcX;
this.state.pathDir = savedDir;
this.state.legLength = savedLen;
this._nextLegDir = nextDir;
this._nextLegLen = nextLen;
}
showMap() {
@@ -1037,21 +1066,27 @@ class Game {
this._showController(true);
requestAnimationFrame(() => {
const world = document.getElementById('map-inner');
const viewW = world ? world.parentElement.offsetWidth : 420;
const {viewW, viewH} = this._getViewport();
if (this.state.currentQ === 0) {
this.state.worldX = 0;
this.state.worldY = 0;
this.state.npcWorldX = Math.round(viewW * 3.5);
this.state.npcWorldY = 0;
this.state.pathDir = 'right';
this.state.legLength = Math.round(viewW * 3.5);
this.state.legProgress = 0;
} else if (this._nextLegDir) {
this.state.pathDir = this._nextLegDir;
this.state.legLength = this._nextLegLen;
this.state.legProgress = 0;
this._nextLegDir = null;
this._nextLegLen = null;
} else {
this.state.worldX = 0;
this.state.worldY = 0;
this.state.npcWorldX = Math.round(viewW * 3.0 + Math.random() * viewW * 0.8);
this.state.npcWorldY = 0;
const dir = this._pickNextDir(this.state.pathDir);
const isH = dir === 'right' || dir === 'left';
this.state.pathDir = dir;
this.state.legLength = Math.round((isH ? viewW : viewH) * 3.0 + Math.random() * (isH ? viewW : viewH) * 0.8);
this.state.legProgress = 0;
}
this.state.lastDir = this.state.pathDir;
this._generateTrees();
document.getElementById('hud-name').textContent = this.state.playerName||'ASH';
@@ -1063,6 +1098,7 @@ class Game {
this._placeMapSprites();
this._updateDirArrow(false);
this._drawPath();
Music.play();
saveGame(this.state);
this.flashSaveDot();
@@ -1075,6 +1111,84 @@ class Game {
});
}
_drawPath() {
const canvas = document.getElementById('map-path-canvas');
if (!canvas) return;
const {viewW, viewH} = this._getViewport();
canvas.width = viewW;
canvas.height = viewH;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, viewW, viewH);
const pd = this.state.pathDir;
const prog = this.state.legProgress;
const len = this.state.legLength;
const cx = viewW / 2;
const cy = viewH / 2;
const pathW = 80;
const R = 20;
ctx.save();
const ahead = len - prog;
let x1, y1, x2, y2;
if (pd === 'right') {
x1 = 0; y1 = cy - pathW / 2;
x2 = cx + Math.min(ahead, viewW); y2 = cy + pathW / 2;
} else if (pd === 'left') {
x1 = cx - Math.min(ahead, viewW); y1 = cy - pathW / 2;
x2 = viewW; y2 = cy + pathW / 2;
} else if (pd === 'down') {
x1 = cx - pathW / 2; y1 = 0;
x2 = cx + pathW / 2; y2 = cy + Math.min(ahead, viewH);
} else {
x1 = cx - pathW / 2; y1 = cy - Math.min(ahead, viewH);
x2 = cx + pathW / 2; y2 = viewH;
}
const rr = (x, y, w, h, r) => {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
};
const bx = Math.min(x1, x2), by = Math.min(y1, y2);
const bw = Math.abs(x2 - x1), bh = Math.abs(y2 - y1);
ctx.shadowColor = 'rgba(0,0,0,0.25)';
ctx.shadowBlur = 6;
ctx.fillStyle = '#b89858';
rr(bx, by, bw, bh, R);
ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = '#2d6e1a';
ctx.lineWidth = 3;
rr(bx, by, bw, bh, R);
ctx.stroke();
const dashes = pd === 'right' || pd === 'left' ? bh * 0.1 : bw * 0.1;
ctx.setLineDash([12, 10]);
ctx.strokeStyle = 'rgba(168,136,72,0.6)';
ctx.lineWidth = 2;
if (pd === 'right' || pd === 'left') {
ctx.beginPath(); ctx.moveTo(bx, cy); ctx.lineTo(bx + bw, cy); ctx.stroke();
} else {
ctx.beginPath(); ctx.moveTo(cx, by); ctx.lineTo(cx, by + bh); ctx.stroke();
}
ctx.setLineDash([]);
ctx.restore();
}
updateHUD() {
const tot=this.state.questions.length||100, done=this.state.currentQ, pct=(done/tot)*100;
document.getElementById('hud-score').textContent = this.state.score.toLocaleString();
+2 -2
View File
@@ -99,12 +99,12 @@
</div>
</div>
<div class="overworld-bg map-world" id="map-world">
<canvas id="map-path-canvas" style="position:absolute;inset:0;width:100%;height:100%;z-index:4;pointer-events:none;"></canvas>
<div class="map-inner" id="map-inner">
<div class="map-trees" id="map-trees"></div>
</div>
<!-- Path stays fixed to the viewport — does not scroll -->
<div class="map-ground"></div>
<!-- NPC in viewport layer — JS positions it using screen-space X -->
<!-- NPC in viewport layer — JS positions it using screen-space coords -->
<div class="map-npc-wrap" id="map-npc-wrap">
<div class="npc-bubble" id="npc-bubble">!</div>
<div class="map-npc" id="map-npc"></div>
+2 -18
View File
@@ -409,7 +409,7 @@ html, body {
.map-header { background:var(--dark-bg); padding:8px 12px; display:flex; align-items:center; justify-content:space-between; gap:8px; border-bottom:3px solid var(--black); flex-shrink:0; }
.map-left { display:flex; align-items:center; gap:6px; }
.map-pin { font-size:12px; }
.map-town-name { font-size:clamp(5.5px,1.6vw,7px); color:var(--yellow); letter-spacing:.5px; }
.map-town-name { font-size:clamp(5.5px,1.6vw,7px); color:var(--yellow); letter-spacing:.5px; word-spacing:-1px; }
.map-right { display:flex; align-items:center; gap:6px; }
.prog-bar-outer { width:80px; height:9px; background:var(--black); border:2px solid var(--lightgray); overflow:hidden; }
.prog-bar-inner { height:100%; width:0%; background:var(--hp-green); transition:width .5s ease; }
@@ -435,6 +435,7 @@ html, body {
position: absolute;
top: 0; bottom: 0; left: 0;
width: 400%;
height: 400%;
will-change: transform;
z-index: 5;
}
@@ -451,22 +452,6 @@ html, body {
line-height: 1;
}
.tree:nth-child(even) { animation-direction: reverse; }
.map-ground {
position: absolute;
bottom: 0; left: 0; right: 0;
height: 48%;
background:
linear-gradient(180deg,
transparent 0%, transparent 54%,
#3d8a28 54%, #4a9e30 58%, #3d8a28 62%, #4aaa32 66%, #3d8a28 100%
),
linear-gradient(180deg,
#4a9e30 0%, #3d8a28 8%, #2d6e1a 10%,
#c8aa6a 10%, #b89858 30%, #c0a060 50%, #b89858 70%, #a88848 88%,
#2d6e1a 90%, #3d8a28 92%, #4a9e30 100%
);
border-top: 3px solid #2d6e1a;
}
/* Directional nudge arrow */
.map-dir-arrow {
position: absolute;
@@ -476,7 +461,6 @@ html, body {
pointer-events: none;
z-index: 50;
filter: drop-shadow(0 0 4px rgba(248,192,48,.6));
right: 12px;
display: none;
bottom: -9999px;
}