Add directional winding path system with canvas-drawn path and per-leg direction changes
This commit is contained in:
@@ -250,13 +250,12 @@ class Game {
|
|||||||
screen:'boot', playerName:'', questions:[], currentQ:0,
|
screen:'boot', playerName:'', questions:[], currentQ:0,
|
||||||
score:0, streak:0, maxStreak:0, correct:0, wrong:0,
|
score:0, streak:0, maxStreak:0, correct:0, wrong:0,
|
||||||
answering:false, introStep:0, twTimer:null,
|
answering:false, introStep:0, twTimer:null,
|
||||||
/* map — world coords in px, camera follows player */
|
worldX: 0, worldY: 0,
|
||||||
worldX: 0, /* player's X in world-px */
|
npcWorldX: 0, npcWorldY: 0,
|
||||||
worldY: 0, /* player's Y in world-px (0 = path centre) */
|
pathDir: 'right',
|
||||||
npcWorldX: 0, /* NPC world-px X */
|
legProgress: 0,
|
||||||
npcWorldY: 0,
|
legLength: 0,
|
||||||
lastDir: 'right',
|
lastDir: 'right',
|
||||||
/* battle cursor */
|
|
||||||
cursor:0,
|
cursor:0,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -363,80 +362,96 @@ class Game {
|
|||||||
p.className = 'map-player idle';
|
p.className = 'map-player idle';
|
||||||
/* Show the idle/middle frame for the last direction walked
|
/* Show the idle/middle frame for the last direction walked
|
||||||
Frames at 2× scale (32px each): down=32, up=128, left=224, right=320 */
|
Frames at 2× scale (32px each): down=32, up=128, left=224, right=320 */
|
||||||
const idleFrames = { down:'-32px', left:'-128px', up:'-224px', right:'-320px' };
|
const idleFrames = { right:'-32px', down:'-32px', left:'-128px', up:'-224px' };
|
||||||
p.style.backgroundPositionX = idleFrames[this.state.lastDir] || '-32px';
|
p.style.backgroundPositionX = idleFrames[this.state.pathDir] || '-32px';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_walkTick() {
|
_getViewport() {
|
||||||
if (this.state.screen !== 'map') return;
|
const w = document.getElementById('map-world');
|
||||||
|
return {
|
||||||
|
viewW: (w && w.offsetWidth) || 420,
|
||||||
|
viewH: (w && w.offsetHeight) || 300,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const world = document.getElementById('map-inner');
|
_legVector() {
|
||||||
if (!world) return;
|
const d = this.state.pathDir;
|
||||||
const viewW = world.parentElement.offsetWidth || 420;
|
return {
|
||||||
const viewH = world.parentElement.offsetHeight || 300;
|
dx: d === 'right' ? 1 : d === 'left' ? -1 : 0,
|
||||||
|
dy: d === 'down' ? 1 : d === 'up' ? -1 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const SPEED = 8; /* px per tick */
|
_playerScreenPos() {
|
||||||
let dx = 0, dy = 0, dir = '';
|
const {viewW, viewH} = this._getViewport();
|
||||||
|
return { sx: viewW / 2 - 16, sy: viewH / 2 - 32 };
|
||||||
|
}
|
||||||
|
|
||||||
if (this._heldKeys.has('right')) { dx = SPEED; dir = 'right'; }
|
_walkTick() {
|
||||||
if (this._heldKeys.has('left')) { dx = -SPEED; dir = 'left'; }
|
if (this.state.screen !== 'map') return;
|
||||||
if (this._heldKeys.has('up')) { dy = -SPEED; dir = 'up'; }
|
const {viewW, viewH} = this._getViewport();
|
||||||
if (this._heldKeys.has('down')) { dy = SPEED; dir = 'down'; }
|
const SPEED = 8;
|
||||||
if (!dir) return;
|
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
|
const {dx, dy} = this._legVector();
|
||||||
so the player can always walk far enough to reach the NPC */
|
const maxProg = this.state.legLength - 60;
|
||||||
const maxWorldX = this.state.npcWorldX - Math.round(viewW / 2) + 100;
|
this.state.legProgress = Math.min(maxProg, Math.max(0, this.state.legProgress + SPEED));
|
||||||
this.state.worldX = Math.max(0, Math.min(maxWorldX, this.state.worldX + dx));
|
this.state.lastDir = pd;
|
||||||
/* 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;
|
|
||||||
|
|
||||||
this._applyCamera();
|
this._applyCamera();
|
||||||
|
this._placePlayer(`walk-${pd}`);
|
||||||
/* 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._checkNPCProximity();
|
this._checkNPCProximity();
|
||||||
|
this._drawPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Slide the world and reposition NPC in screen-space every tick */
|
|
||||||
_applyCamera() {
|
_applyCamera() {
|
||||||
const world = document.getElementById('map-inner');
|
const {viewW, viewH} = this._getViewport();
|
||||||
if (world) world.style.transform = `translateX(${-this.state.worldX}px)`;
|
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();
|
this._positionNPCOnScreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
_positionNPCOnScreen() {
|
_positionNPCOnScreen() {
|
||||||
const world = document.getElementById('map-inner');
|
const {viewW, viewH} = this._getViewport();
|
||||||
const viewH = world ? world.parentElement.offsetHeight : 300;
|
const {dx, dy} = this._legVector();
|
||||||
|
const prog = this.state.legProgress;
|
||||||
|
const legLen = this.state.legLength;
|
||||||
const npcWrap = document.getElementById('map-npc-wrap');
|
const npcWrap = document.getElementById('map-npc-wrap');
|
||||||
if (npcWrap) {
|
if (npcWrap) {
|
||||||
const screenX = this.state.npcWorldX - this.state.worldX;
|
const screenX = viewW / 2 + (legLen - prog) * dx - 24;
|
||||||
npcWrap.style.left = (screenX - 24) + 'px';
|
const screenY = viewH / 2 + (legLen - prog) * dy - 24;
|
||||||
npcWrap.style.bottom = (viewH * 0.28) + 'px';
|
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() {
|
_checkNPCProximity() {
|
||||||
const world = document.getElementById('map-world');
|
const {viewW, viewH} = this._getViewport();
|
||||||
const viewW = (world && world.offsetWidth) ? world.offsetWidth : 420;
|
const remaining = this.state.legLength - this.state.legProgress;
|
||||||
const viewH = (world && world.offsetHeight) ? world.offsetHeight : 300;
|
const near = this.state.legLength > 0 && remaining < 60;
|
||||||
const playerWorldX = this.state.worldX + viewW / 2;
|
|
||||||
const dx = Math.abs(playerWorldX - this.state.npcWorldX);
|
|
||||||
const near = this.state.npcWorldX > 0 && dx < 60;
|
|
||||||
|
|
||||||
const hint = document.getElementById('map-talk-hint');
|
const hint = document.getElementById('map-talk-hint');
|
||||||
const bub = document.getElementById('npc-bubble');
|
const bub = document.getElementById('npc-bubble');
|
||||||
@@ -453,21 +468,36 @@ class Game {
|
|||||||
const arrow = document.getElementById('map-dir-arrow');
|
const arrow = document.getElementById('map-dir-arrow');
|
||||||
if (!arrow) return;
|
if (!arrow) return;
|
||||||
if (nearNPC) { arrow.style.display = 'none'; arrow.style.animation = 'none'; return; }
|
if (nearNPC) { arrow.style.display = 'none'; arrow.style.animation = 'none'; return; }
|
||||||
const world = document.getElementById('map-world');
|
const {viewW, viewH} = this._getViewport();
|
||||||
const viewH = (world && world.offsetHeight) ? world.offsetHeight : 300;
|
const pd = this.state.pathDir;
|
||||||
arrow.textContent = '▶';
|
const glyphs = { right:'▶', left:'◀', up:'▲', down:'▼' };
|
||||||
|
arrow.textContent = glyphs[pd] || '▶';
|
||||||
arrow.style.display = 'block';
|
arrow.style.display = 'block';
|
||||||
arrow.style.animation = 'arrowPulse .7s ease-in-out infinite';
|
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() {
|
_nearNPC() {
|
||||||
if (!this.state.npcWorldX) return false;
|
if (!this.state.legLength) return false;
|
||||||
const world = document.getElementById('map-world');
|
const remaining = this.state.legLength - this.state.legProgress;
|
||||||
const viewW = (world && world.offsetWidth) ? world.offsetWidth : 420;
|
return remaining < 60;
|
||||||
const playerWorldX = this.state.worldX + viewW / 2;
|
|
||||||
return Math.abs(playerWorldX - this.state.npcWorldX) < 60;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_talkToNPC() {
|
_talkToNPC() {
|
||||||
@@ -490,22 +520,14 @@ class Game {
|
|||||||
this.startQuestion();
|
this.startQuestion();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Place player at viewport centre, NPC via screen-space conversion */
|
|
||||||
_placeMapSprites() {
|
_placeMapSprites() {
|
||||||
const world = document.getElementById('map-inner');
|
const idleFrames = { right:'-32px', down:'-32px', left:'-128px', up:'-224px' };
|
||||||
const viewW = world ? world.parentElement.offsetWidth : 420;
|
|
||||||
const viewH = world ? world.parentElement.offsetHeight : 300;
|
|
||||||
|
|
||||||
const p = document.getElementById('map-player');
|
const p = document.getElementById('map-player');
|
||||||
if (p) {
|
if (p) {
|
||||||
const left = viewW / 2 - 16;
|
p.className = 'map-player idle';
|
||||||
const bottom = viewH * 0.28;
|
p.style.backgroundPositionX = idleFrames[this.state.pathDir] || '-32px';
|
||||||
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'; }
|
|
||||||
}
|
}
|
||||||
|
this._placePlayer('map-player idle');
|
||||||
this._applyCamera();
|
this._applyCamera();
|
||||||
const hint = document.getElementById('map-talk-hint');
|
const hint = document.getElementById('map-talk-hint');
|
||||||
if (hint) hint.style.display = 'none';
|
if (hint) hint.style.display = 'none';
|
||||||
@@ -983,34 +1005,35 @@ class Game {
|
|||||||
/* Generate randomly placed trees across the full scrollable world */
|
/* Generate randomly placed trees across the full scrollable world */
|
||||||
/* Generate trees for the current journey leg — from world origin to just past the NPC.
|
/* 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. */
|
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() {
|
_generateTrees() {
|
||||||
const container = document.getElementById('map-trees');
|
const container = document.getElementById('map-trees');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
/* Clear previous leg's trees */
|
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
const {viewW, viewH} = this._getViewport();
|
||||||
const world = document.getElementById('map-inner');
|
const legLen = this.state.legLength || viewW * 3;
|
||||||
const viewW = world ? world.parentElement.offsetWidth : 420;
|
const pd = this.state.pathDir;
|
||||||
const viewH = world ? world.parentElement.offsetHeight : 300;
|
const isHoriz = pd === 'right' || pd === 'left';
|
||||||
|
const worldW = isHoriz ? legLen + viewW : viewW * 2;
|
||||||
/* World spans from 0 to just past the NPC */
|
const worldH = isHoriz ? viewH * 2 : legLen + viewH;
|
||||||
const worldW = this.state.npcWorldX + viewW;
|
const inner = document.getElementById('map-inner');
|
||||||
if (world) world.style.width = worldW + 'px';
|
if (inner) { inner.style.width = worldW + 'px'; inner.style.height = worldH + 'px'; }
|
||||||
|
const COUNT = Math.max(8, Math.round(legLen / 100));
|
||||||
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;
|
|
||||||
|
|
||||||
for (let i = 0; i < COUNT; i++) {
|
for (let i = 0; i < COUNT; i++) {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.className = 'tree';
|
span.className = 'tree';
|
||||||
const size = 28 + Math.random() * 16;
|
const size = 28 + Math.random() * 16;
|
||||||
const x = i * zoneW + Math.random() * zoneW * 0.75;
|
const t = (i + Math.random()) / COUNT;
|
||||||
const y = treeBottomMin + Math.random() * (treeTopMax - treeBottomMin - size);
|
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.textContent = '🌳';
|
||||||
span.style.fontSize = size + 'px';
|
span.style.fontSize = size + 'px';
|
||||||
span.style.left = x + 'px';
|
span.style.left = x + 'px';
|
||||||
@@ -1021,15 +1044,21 @@ class Game {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Called on result screen — pre-builds trees for the next leg */
|
|
||||||
_preGenerateNextLeg() {
|
_preGenerateNextLeg() {
|
||||||
const world = document.getElementById('map-inner');
|
const {viewW, viewH} = this._getViewport();
|
||||||
const viewW = world ? world.parentElement.offsetWidth : 420;
|
const nextDir = this._pickNextDir(this.state.pathDir);
|
||||||
const nextNpcX = Math.round(viewW * 3.0 + Math.random() * viewW * 0.8);
|
const isHoriz = nextDir === 'right' || nextDir === 'left';
|
||||||
const savedNpcX = this.state.npcWorldX;
|
const range = isHoriz ? viewW : viewH;
|
||||||
this.state.npcWorldX = nextNpcX;
|
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._generateTrees();
|
||||||
this.state.npcWorldX = savedNpcX;
|
this.state.pathDir = savedDir;
|
||||||
|
this.state.legLength = savedLen;
|
||||||
|
this._nextLegDir = nextDir;
|
||||||
|
this._nextLegLen = nextLen;
|
||||||
}
|
}
|
||||||
|
|
||||||
showMap() {
|
showMap() {
|
||||||
@@ -1037,21 +1066,27 @@ class Game {
|
|||||||
this._showController(true);
|
this._showController(true);
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const world = document.getElementById('map-inner');
|
const {viewW, viewH} = this._getViewport();
|
||||||
const viewW = world ? world.parentElement.offsetWidth : 420;
|
|
||||||
|
|
||||||
if (this.state.currentQ === 0) {
|
if (this.state.currentQ === 0) {
|
||||||
this.state.worldX = 0;
|
this.state.pathDir = 'right';
|
||||||
this.state.worldY = 0;
|
this.state.legLength = Math.round(viewW * 3.5);
|
||||||
this.state.npcWorldX = Math.round(viewW * 3.5);
|
this.state.legProgress = 0;
|
||||||
this.state.npcWorldY = 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 {
|
} else {
|
||||||
this.state.worldX = 0;
|
const dir = this._pickNextDir(this.state.pathDir);
|
||||||
this.state.worldY = 0;
|
const isH = dir === 'right' || dir === 'left';
|
||||||
this.state.npcWorldX = Math.round(viewW * 3.0 + Math.random() * viewW * 0.8);
|
this.state.pathDir = dir;
|
||||||
this.state.npcWorldY = 0;
|
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();
|
this._generateTrees();
|
||||||
|
|
||||||
document.getElementById('hud-name').textContent = this.state.playerName||'ASH';
|
document.getElementById('hud-name').textContent = this.state.playerName||'ASH';
|
||||||
@@ -1063,6 +1098,7 @@ class Game {
|
|||||||
|
|
||||||
this._placeMapSprites();
|
this._placeMapSprites();
|
||||||
this._updateDirArrow(false);
|
this._updateDirArrow(false);
|
||||||
|
this._drawPath();
|
||||||
Music.play();
|
Music.play();
|
||||||
saveGame(this.state);
|
saveGame(this.state);
|
||||||
this.flashSaveDot();
|
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() {
|
updateHUD() {
|
||||||
const tot=this.state.questions.length||100, done=this.state.currentQ, pct=(done/tot)*100;
|
const tot=this.state.questions.length||100, done=this.state.currentQ, pct=(done/tot)*100;
|
||||||
document.getElementById('hud-score').textContent = this.state.score.toLocaleString();
|
document.getElementById('hud-score').textContent = this.state.score.toLocaleString();
|
||||||
|
|||||||
+2
-2
@@ -99,12 +99,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="overworld-bg map-world" id="map-world">
|
<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-inner" id="map-inner">
|
||||||
<div class="map-trees" id="map-trees"></div>
|
<div class="map-trees" id="map-trees"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Path stays fixed to the viewport — does not scroll -->
|
<!-- Path stays fixed to the viewport — does not scroll -->
|
||||||
<div class="map-ground"></div>
|
<!-- NPC in viewport layer — JS positions it using screen-space coords -->
|
||||||
<!-- NPC in viewport layer — JS positions it using screen-space X -->
|
|
||||||
<div class="map-npc-wrap" id="map-npc-wrap">
|
<div class="map-npc-wrap" id="map-npc-wrap">
|
||||||
<div class="npc-bubble" id="npc-bubble">!</div>
|
<div class="npc-bubble" id="npc-bubble">!</div>
|
||||||
<div class="map-npc" id="map-npc"></div>
|
<div class="map-npc" id="map-npc"></div>
|
||||||
|
|||||||
@@ -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-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-left { display:flex; align-items:center; gap:6px; }
|
||||||
.map-pin { font-size:12px; }
|
.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; }
|
.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-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; }
|
.prog-bar-inner { height:100%; width:0%; background:var(--hp-green); transition:width .5s ease; }
|
||||||
@@ -435,6 +435,7 @@ html, body {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0; bottom: 0; left: 0;
|
top: 0; bottom: 0; left: 0;
|
||||||
width: 400%;
|
width: 400%;
|
||||||
|
height: 400%;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
}
|
}
|
||||||
@@ -451,22 +452,6 @@ html, body {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.tree:nth-child(even) { animation-direction: reverse; }
|
.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 */
|
/* Directional nudge arrow */
|
||||||
.map-dir-arrow {
|
.map-dir-arrow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -476,7 +461,6 @@ html, body {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
filter: drop-shadow(0 0 4px rgba(248,192,48,.6));
|
filter: drop-shadow(0 0 4px rgba(248,192,48,.6));
|
||||||
right: 12px;
|
|
||||||
display: none;
|
display: none;
|
||||||
bottom: -9999px;
|
bottom: -9999px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user