From b6efeaae4d48da54778252d37560d389ca0cb896 Mon Sep 17 00:00:00 2001 From: Justin Oros Date: Thu, 14 May 2026 10:14:55 -0700 Subject: [PATCH] =?UTF-8?q?Scrolling=20camera=20map=20=E2=80=94=20world=20?= =?UTF-8?q?pans=20as=20player=20walks,=20NPC=20spawns=20off-screen=20right?= =?UTF-8?q?=20forcing=20real=20movement=20between=20questions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game.js | 198 +++++++++++++++++++++++++++-------------------------- index.html | 27 ++++---- style.css | 74 ++++++++++---------- 3 files changed, 150 insertions(+), 149 deletions(-) diff --git a/game.js b/game.js index 3d59455..4ad9a05 100644 --- a/game.js +++ b/game.js @@ -59,10 +59,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 movement */ - playerX:15, playerY:42, - npcX:72, npcY:38, - moving:false, walkDir:'', lastDir:'right', + /* 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, + lastDir: 'right', /* battle cursor */ cursor:0, }; @@ -175,81 +177,86 @@ class Game { _walkTick() { if (this.state.screen !== 'map') return; + const world = document.getElementById('map-inner'); + if (!world) return; + const viewW = world.parentElement.offsetWidth || 420; + const viewH = world.parentElement.offsetHeight || 300; + const worldW = world.offsetWidth || viewW * 4; + + const SPEED = 4; /* px per tick */ let dx = 0, dy = 0, dir = ''; - if (this._heldKeys.has('up')) { dy = 1.8; dir = 'up'; } - if (this._heldKeys.has('down')) { dy = -1.8; dir = 'down'; } - if (this._heldKeys.has('left')) { dx = -2.2; dir = 'left'; } - if (this._heldKeys.has('right')) { dx = 2.2; dir = 'right'; } + + 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; - let nx = Math.max(5, Math.min(88, this.state.playerX + dx)); - let ny = Math.max(30, Math.min(72, this.state.playerY + dy)); - - this.state.playerX = nx; - this.state.playerY = ny; + /* Clamp world X — can't walk left past origin or right past world edge */ + this.state.worldX = Math.max(0, Math.min(worldW - viewW, 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; + this._applyCamera(); + + /* Player sprite stays horizontally centred; only Y shifts */ const p = document.getElementById('map-player'); if (p) { - p.style.left = nx + '%'; - p.style.bottom = ny + '%'; + const centreX = viewW / 2 - 20; + const centreY = viewH * 0.45 + this.state.worldY; + p.style.left = centreX + 'px'; + p.style.bottom = (viewH - centreY - 40) + 'px'; p.className = `map-player walk-${dir}`; } this._checkNPCProximity(); } + /* Slide the world so the camera follows the player */ + _applyCamera() { + const world = document.getElementById('map-inner'); + if (world) world.style.transform = `translateX(${-this.state.worldX}px)`; + } + _checkNPCProximity() { - const dx = Math.abs(this.state.playerX - this.state.npcX); - const dy = Math.abs(this.state.playerY - this.state.npcY); - const near = dx < 12 && dy < 12; + const world = document.getElementById('map-inner'); + const viewW = world ? world.parentElement.offsetWidth : 420; + const viewH = world ? world.parentElement.offsetHeight : 300; + const playerWorldX = this.state.worldX + viewW / 2; + const dx = Math.abs(playerWorldX - this.state.npcWorldX); + const near = dx < 55; const hint = document.getElementById('map-talk-hint'); const bub = document.getElementById('npc-bubble'); if (hint) { hint.style.display = near ? 'block' : 'none'; - hint.style.left = (this.state.playerX + 4) + '%'; - hint.style.bottom= (this.state.playerY + 11) + '%'; + hint.style.left = (viewW / 2 - 28) + 'px'; + hint.style.bottom = (viewH * 0.56) + 'px'; } if (bub) bub.style.opacity = near ? '0' : '1'; - this._updateDirArrow(near); } - /* Point a blinking arrow toward the NPC so players know which way to walk */ _updateDirArrow(nearNPC) { const arrow = document.getElementById('map-dir-arrow'); if (!arrow) return; - - /* Hide arrow once the player is close enough — "Press A" takes over */ if (nearNPC) { arrow.style.display = 'none'; return; } - - const dx = this.state.npcX - this.state.playerX; - const dy = this.state.npcY - this.state.playerY; - - /* Pick the dominant axis and set the arrow character + animation direction */ - let glyph = '▶', rotation = '0deg'; - if (Math.abs(dx) >= Math.abs(dy)) { - glyph = dx > 0 ? '▶' : '◀'; - rotation = dx > 0 ? '0deg' : '180deg'; - } else { - glyph = dy > 0 ? '▲' : '▼'; - rotation = dy > 0 ? '270deg' : '90deg'; - } - - /* Position arrow near the player, offset so it doesn't overlap the sprite */ - const offsetX = dx > 0 ? 8 : -6; - const offsetY = dy > 0 ? 8 : -6; - arrow.textContent = glyph; - arrow.style.display = 'block'; - arrow.style.left = Math.max(2, Math.min(90, this.state.playerX + offsetX)) + '%'; - arrow.style.bottom = Math.max(28, Math.min(70, this.state.playerY + offsetY)) + '%'; + const world = document.getElementById('map-inner'); + const viewW = world ? world.parentElement.offsetWidth : 420; + const viewH = world ? world.parentElement.offsetHeight : 300; + const npcScreenX = this.state.npcWorldX - this.state.worldX; + arrow.textContent = npcScreenX >= viewW / 2 ? '▶' : '◀'; + arrow.style.display = 'block'; + arrow.style.left = (viewW / 2 + 30) + 'px'; + arrow.style.bottom = (viewH * 0.50) + 'px'; } _nearNPC() { - const dx = Math.abs(this.state.playerX - this.state.npcX); - const dy = Math.abs(this.state.playerY - this.state.npcY); - return dx < 12 && dy < 12; + const world = document.getElementById('map-inner'); + const viewW = world ? world.parentElement.offsetWidth : 420; + const playerWorldX = this.state.worldX + viewW / 2; + return Math.abs(playerWorldX - this.state.npcWorldX) < 55; } _talkToNPC() { @@ -258,19 +265,25 @@ class Game { this.startQuestion(); } - /* ── Position player & NPC on map ──────────────────────── */ + /* Place player at viewport centre, NPC at world-px position */ _placeMapSprites() { + const world = document.getElementById('map-inner'); + const viewW = world ? world.parentElement.offsetWidth : 420; + const viewH = world ? world.parentElement.offsetHeight : 300; + const p = document.getElementById('map-player'); if (p) { - p.style.left = this.state.playerX + '%'; - p.style.bottom = this.state.playerY + '%'; - p.className = 'map-player idle'; + p.style.left = (viewW / 2 - 20) + 'px'; + p.style.bottom = (viewH * 0.42) + 'px'; + p.style.transform = 'scaleX(-1)'; + p.className = 'map-player idle'; } const npcWrap = document.getElementById('map-npc-wrap'); if (npcWrap) { - npcWrap.style.left = (this.state.npcX - 3) + '%'; - npcWrap.style.bottom = this.state.npcY + '%'; + npcWrap.style.left = (this.state.npcWorldX - 24) + 'px'; + npcWrap.style.bottom = (viewH * 0.42) + 'px'; } + this._applyCamera(); const hint = document.getElementById('map-talk-hint'); if (hint) hint.style.display = 'none'; } @@ -430,8 +443,7 @@ class Game { playerName:save.playerName, currentQ:save.currentQ, score:save.score, streak:save.streak, maxStreak:save.maxStreak, correct:save.correct, wrong:save.wrong, - /* Treat restored game like mid-journey so showMap generates a fresh NPC position */ - playerX:10, playerY:40, npcX:68, npcY:38, + worldX:0, worldY:0, npcWorldX:0, npcWorldY:0, }); this.loadQuestions(() => this.showMap()); } @@ -527,57 +539,47 @@ class Game { /* ── MAP ──────────────────────────────────────────────── */ - /* Generate a new NPC position that is always to the RIGHT of the player, - so the route feels like a journey. NPC lands 25–40% ahead horizontally, - with a little vertical variance to keep it interesting. */ - _nextNPCPosition() { - const aheadX = 25 + Math.random() * 15; /* 25–40% to the right */ - const newX = Math.min(82, this.state.playerX + aheadX); - /* vertical variance — stays in the walkable band */ - const newY = 35 + Math.random() * 10; /* 35–45% */ - return { x: newX, y: newY }; - } - showMap() { this.show('map'); this._showController(true); - if (this.state.currentQ === 0) { - /* Very first map load — player starts far left, NPC far right */ - this.state.playerX = 10; - this.state.playerY = 40; - this.state.npcX = 68; - this.state.npcY = 38; - } else { - /* After answering — player steps forward to where the NPC was, - and a fresh NPC appears further ahead along the path */ - this.state.playerX = this.state.npcX - 8; /* just left of old NPC */ - this.state.playerY = this.state.npcY; - /* If player has walked near the right edge, wrap back left - so the path feels like it keeps going */ - if (this.state.playerX > 65) { - this.state.playerX = 10; - this.state.playerY = 40; + /* We need the viewport size — defer a tick so the DOM is laid out */ + requestAnimationFrame(() => { + const world = document.getElementById('map-inner'); + const viewW = world ? world.parentElement.offsetWidth : 420; + + if (this.state.currentQ === 0) { + /* First load — player at world origin, camera at 0, + NPC spawns 1.5 screen-widths ahead (off-screen right) */ + this.state.worldX = 0; + this.state.worldY = 0; + this.state.npcWorldX = Math.round(viewW * 1.5); + this.state.npcWorldY = 0; + } else { + /* After each question — player world-X advances to just before + where the NPC was, camera resets so player appears centred, + new NPC spawns another 1.5 screens ahead */ + this.state.worldX = Math.max(0, this.state.npcWorldX - Math.round(viewW / 2)); + this.state.worldY = 0; + this.state.npcWorldX = this.state.worldX + Math.round(viewW * 1.5 + Math.random() * viewW * 0.4); + this.state.npcWorldY = 0; } - const next = this._nextNPCPosition(); - this.state.npcX = next.x; - this.state.npcY = next.y; - } - document.getElementById('hud-name').textContent = this.state.playerName||'ASH'; - this.updateHUD(); + document.getElementById('hud-name').textContent = this.state.playerName||'ASH'; + this.updateHUD(); - const q = this.state.questions[this.state.currentQ]; - const npcEl = document.getElementById('map-npc'); - if (q && npcEl) npcEl.textContent = NPC[q.npc]||'🧑'; + const q = this.state.questions[this.state.currentQ]; + const npcEl = document.getElementById('map-npc'); + if (q && npcEl) npcEl.textContent = NPC[q.npc]||'🧑'; - this._placeMapSprites(); - this._updateDirArrow(false); - saveGame(this.state); - this.flashSaveDot(); + this._placeMapSprites(); + this._updateDirArrow(false); + saveGame(this.state); + this.flashSaveDot(); - const npcEl2 = document.getElementById('map-npc'); - if (npcEl2) npcEl2.onclick = ()=>this._talkToNPC(); + const npcEl2 = document.getElementById('map-npc'); + if (npcEl2) npcEl2.onclick = ()=>this._talkToNPC(); + }); } updateHUD() { diff --git a/index.html b/index.html index 937b38b..d2aaccb 100644 --- a/index.html +++ b/index.html @@ -101,22 +101,23 @@
-
- 🌲🌳 - 🌲🌳🌲 +
+
+ 🌲🌳 + 🌲🌳🌲 + 🌳🌲🌳 +
+
+ +
+
!
+
+
- -
- -
-
!
-
-
- +
🧢
- + -
diff --git a/style.css b/style.css index 7e7f64d..452c6b3 100644 --- a/style.css +++ b/style.css @@ -385,47 +385,48 @@ html, body { .prog-text { font-size:6px; color:var(--white); } .save-dot { font-size:12px; animation:blink 3s ease-in-out infinite; } -.map-world { position:relative; } -.map-trees { position:absolute; top:12%; left:0; right:0; display:flex; justify-content:space-around; padding:0 8px; } -.tree { font-size:clamp(24px,7vw,36px); display:inline-block; animation:treeSway 3s ease-in-out infinite; filter:drop-shadow(1px 2px 0 rgba(0,0,0,.3)); } +.map-world { + position: relative; + overflow: hidden; +} +/* Wide scrolling inner world — JS translates this to pan the camera */ +.map-inner { + position: absolute; + top: 0; bottom: 0; left: 0; + width: 400%; + will-change: transform; +} +.map-trees { + position: absolute; + top: 12%; + left: 0; right: 0; + display: flex; + justify-content: space-around; + padding: 0 2%; + width: 100%; +} +.tree { font-size:clamp(24px,7vw,36px); display:inline-block; animation:treeSway 3s ease-in-out infinite; filter:drop-shadow(1px 2px 0 rgba(0,0,0,.3)); } .tree:nth-child(2){animation-delay:.5s;} .tree:nth-child(3){animation-delay:1s;} .tree:nth-child(4){animation-delay:1.5s;} .tree:nth-child(5){animation-delay:2s;} +.tree:nth-child(6){animation-delay:2.5s;} .tree:nth-child(7){animation-delay:.8s;} .tree:nth-child(8){animation-delay:1.8s;} .map-ground { position: absolute; bottom: 0; left: 0; right: 0; - height: 48%; /* taller overall area so grass shows top and bottom */ + height: 48%; background: - /* bottom grass strip */ - linear-gradient( - 180deg, - transparent 0%, - transparent 54%, - #3d8a28 54%, - #4a9e30 58%, - #3d8a28 62%, - #4aaa32 66%, - #3d8a28 100% + linear-gradient(180deg, + transparent 0%, transparent 54%, + #3d8a28 54%, #4a9e30 58%, #3d8a28 62%, #4aaa32 66%, #3d8a28 100% ), - /* dirt path in the middle */ - linear-gradient( - 180deg, - #4a9e30 0%, /* top grass */ - #3d8a28 8%, - #2d6e1a 10%, /* sharp grass/dirt border top */ - #c8aa6a 10%, /* dirt path starts */ - #b89858 30%, - #c0a060 50%, - #b89858 70%, - #a88848 88%, /* dirt path ends */ - #2d6e1a 90%, /* sharp grass/dirt border bottom */ - #3d8a28 92%, /* bottom grass */ - #4a9e30 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 — floats at bottom of map, points toward NPC */ +/* Directional nudge arrow */ .map-dir-arrow { - position: absolute; + position: fixed; font-size: clamp(20px, 6vw, 28px); color: var(--yellow); text-shadow: 0 0 6px rgba(248,192,48,.8), 2px 2px 0 rgba(0,0,0,.5); @@ -434,18 +435,15 @@ html, body { animation: arrowPulse .7s ease-in-out infinite; filter: drop-shadow(0 0 4px rgba(248,192,48,.6)); } - -/* NPC on map */ -.map-npc-wrap { position:absolute; text-align:center; transition: left .4s ease, bottom .4s ease; } +/* NPC lives inside .map-inner, positioned in world-px via JS */ +.map-npc-wrap { position:absolute; text-align:center; } .npc-bubble { font-size:14px; font-family:var(--font); color:var(--black); background:var(--yellow); border:2px solid var(--black); width:20px; height:20px; display:flex; align-items:center; justify-content:center; margin:0 auto 2px; animation:bounce .7s ease-in-out infinite; } -.map-npc { font-size:clamp(36px,10vw,52px); filter:drop-shadow(2px 3px 0 rgba(0,0,0,.35)); } - -/* ── PLAYER SPRITE (JS-positioned) ───────────────────────── */ +.map-npc { font-size:clamp(36px,10vw,52px); filter:drop-shadow(2px 3px 0 rgba(0,0,0,.35)); cursor:pointer; } +/* Player is fixed to the viewport centre horizontally; only Y moves */ .map-player { position: absolute; font-size: clamp(28px, 8vw, 40px); filter: drop-shadow(2px 3px 0 rgba(0,0,0,.4)); - transition: left .12s linear, bottom .12s linear; z-index: 10; line-height: 1; }