(() => { 'use strict'; const canvas = document.querySelector('#game'); const ctx = canvas.getContext('2d', { alpha: false }); const intro = document.querySelector('#intro'); const objectiveEl = document.querySelector('#objective'); const TILE = 48; const MAP_W = 50; const MAP_H = 35; const WORLD_W = MAP_W * TILE; const WORLD_H = MAP_H * TILE; const LAKE = { x: 20.5 * TILE, y: 25 * TILE, rx: 3.25 * TILE, ry: 2.05 * TILE }; const SHRINE_DOOR = { x: 25 * TILE, top: 5 * TILE, width: 2 * TILE }; const view = { w: canvas.width, h: canvas.height }; const keys = new Set(); const image = (src) => { const img = new Image(); img.src = src; return img; }; const sprites = { hero: image('assets/klc/midi-ow.png'), bug: image('assets/klc/pillbug.png'), }; const terrain = document.createElement('canvas'); terrain.width = WORLD_W; terrain.height = WORLD_H; const terrainCtx = terrain.getContext('2d'); let state; let lastTime = 0; let toast = ''; let toastTimer = 0; let swingTimer = 0; let hurtFlash = 0; let seed = 412907; let terrainReady = false; function random() { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; } function noise(x, y) { const n = Math.sin(x * 127.1 + y * 311.7 + 41.73) * 43758.5453; return n - Math.floor(n); } function fieldNoise(x, y) { const x0 = Math.floor(x), y0 = Math.floor(y); const smoothX = (x - x0) ** 2 * (3 - 2 * (x - x0)); const smoothY = (y - y0) ** 2 * (3 - 2 * (y - y0)); const top = noise(x0, y0) * (1 - smoothX) + noise(x0 + 1, y0) * smoothX; const bottom = noise(x0, y0 + 1) * (1 - smoothX) + noise(x0 + 1, y0 + 1) * smoothX; return top * (1 - smoothY) + bottom * smoothY; } const TRAIL = [ [[25, 34], [28, 31], [33, 31], [32, 27]], [[32, 27], [30, 22], [35, 19], [34, 15]], [[34, 15], [33, 12], [29, 12], [25, 10]], ]; function trailNearTile(x, y) { const px = x + .5, py = y + .5, radius = .75; for (const curve of TRAIL) { let lastX = curve[0][0], lastY = curve[0][1]; for (let i = 1; i <= 24; i++) { const t = i / 24, u = 1 - t; const nextX = u ** 3 * curve[0][0] + 3 * u ** 2 * t * curve[1][0] + 3 * u * t ** 2 * curve[2][0] + t ** 3 * curve[3][0]; const nextY = u ** 3 * curve[0][1] + 3 * u ** 2 * t * curve[1][1] + 3 * u * t ** 2 * curve[2][1] + t ** 3 * curve[3][1]; const dx = nextX - lastX, dy = nextY - lastY, length = dx * dx + dy * dy || 1; const nearest = Math.max(0, Math.min(1, ((px - lastX) * dx + (py - lastY) * dy) / length)); if (Math.hypot(px - lastX - dx * nearest, py - lastY - dy * nearest) < radius) return true; lastX = nextX; lastY = nextY; } } return false; } function makeTerrain() { // A single, connected, genuinely top-down grassy meadow. Generate subtle // smooth colour variation first; there's no per-cell fill or visible grid. const texture = document.createElement('canvas'); texture.width = 480; texture.height = 336; const tctx = texture.getContext('2d'); const pixels = tctx.createImageData(texture.width, texture.height); for (let y = 0; y < texture.height; y++) { for (let x = 0; x < texture.width; x++) { // Layered smooth noise gives the whole landscape natural, connected // colour changes without the checkerboard seams of independently // coloured map tiles or pinprick noise that looks like clutter. const broad = (fieldNoise(x / 62, y / 45) - .5) * 17; const middle = (fieldNoise(x / 24, y / 19) - .5) * 8; const fine = (fieldNoise(x / 9, y / 7) - .5) * 3; const base = broad + middle + fine; const i = (y * texture.width + x) * 4; pixels.data[i] = Math.round(70 + base); pixels.data[i + 1] = Math.round(114 + base); pixels.data[i + 2] = Math.round(68 + base * .58); pixels.data[i + 3] = 255; } } tctx.putImageData(pixels, 0, 0); const ground = terrainCtx; ground.clearRect(0, 0, WORLD_W, WORLD_H); ground.imageSmoothingEnabled = true; ground.drawImage(texture, 0, 0, WORLD_W, WORLD_H); // A connected forest trail leads from the southern entry to the shrine. // Muted stacked strokes blend into the same ground instead of introducing // a second checkerboard of path tiles. ground.save(); ground.lineCap = 'round'; ground.lineJoin = 'round'; ground.beginPath(); ground.moveTo(TRAIL[0][0][0] * TILE, TRAIL[0][0][1] * TILE); for (const curve of TRAIL) { ground.bezierCurveTo(curve[1][0] * TILE, curve[1][1] * TILE, curve[2][0] * TILE, curve[2][1] * TILE, curve[3][0] * TILE, curve[3][1] * TILE); } ground.strokeStyle = '#354a36'; ground.globalAlpha = .23; ground.lineWidth = 52; ground.stroke(); ground.strokeStyle = '#8d8059'; ground.globalAlpha = .48; ground.lineWidth = 42; ground.stroke(); ground.strokeStyle = '#a39569'; ground.globalAlpha = .16; ground.lineWidth = 30; ground.stroke(); ground.restore(); // Shape one natural, gently irregular lake in the same ground bitmap. // Dark bank, soft reeds and a smooth water fill make a single connected // landscape feature rather than unjoined square water/shore tiles. const shoreline = (rx, ry) => { ground.beginPath(); for (let i = 0; i <= 144; i++) { const angle = (i / 144) * Math.PI * 2; const warp = 1 + Math.sin(angle * 3 + .6) * .009 + Math.sin(angle * 7 + 1.9) * .004; const px = LAKE.x + Math.cos(angle) * rx * warp; const py = LAKE.y + Math.sin(angle) * ry * warp; if (i === 0) ground.moveTo(px, py); else ground.lineTo(px, py); } ground.closePath(); }; shoreline(LAKE.rx + 23, LAKE.ry + 19); ground.fillStyle = '#5c8051'; ground.fill(); shoreline(LAKE.rx + 11, LAKE.ry + 9); ground.fillStyle = '#6d9257'; ground.fill(); shoreline(LAKE.rx, LAKE.ry); const water = ground.createLinearGradient(0, LAKE.y - LAKE.ry, 0, LAKE.y + LAKE.ry); water.addColorStop(0, '#729986'); water.addColorStop(.22, '#5d887b'); water.addColorStop(.75, '#46756f'); water.addColorStop(1, '#385f5e'); ground.fillStyle = water; ground.fill(); const marsh = [ [-1.25, -.72], [-.68, -1.34], [.53, -1.29], [1.12, -.83], [1.2, -.06], [1.02, .7], [.43, 1.28], [-.72, 1.1], [-1.23, .52], ]; for (const [dx, dy] of marsh) { const reedX = LAKE.x + dx * LAKE.rx; const reedY = LAKE.y + dy * LAKE.ry; if (Math.abs(dx) > 1.16 || Math.abs(dy) > 1.24) continue; ground.fillStyle = '#75854f'; ground.fillRect(Math.round(reedX), Math.round(reedY), 5, 3); ground.fillStyle = '#8c9759'; ground.fillRect(Math.round(reedX - 2), Math.round(reedY - 2), 3, 4); ground.fillRect(Math.round(reedX + 3), Math.round(reedY - 3), 3, 4); } // Scattered tiny wave accents are contained within the water itself. ground.save(); shoreline(LAKE.rx - 2, LAKE.ry - 2); ground.clip(); for (let i = 0; i < 14; i++) { const x = LAKE.x + (noise(i + 58, 63) - .5) * LAKE.rx * 1.6; const y = LAKE.y + (noise(i + 85, 47) - .5) * LAKE.ry * 1.25; ground.fillStyle = i % 3 === 0 ? '#a0bb8c42' : '#7fac9342'; ground.fillRect(Math.round(x), Math.round(y), 9 + i % 4 * 3, 1); } ground.restore(); terrainReady = true; } function makeWorld() { seed = 412907; const obstacles = []; const props = []; const enemies = []; const temple = { x: 21, y: 3, w: 8, h: 5 }; for (let y = 0; y < MAP_H; y++) { for (let x = 0; x < MAP_W; x++) { if (x < 2 || y < 2 || x > MAP_W - 3 || y > MAP_H - 3) obstacles.push({ x, y, type: 'edge' }); } } // One collision tile covers the actual doorway, not the surrounding courtyard. obstacles.push({ x: 24, y: Math.floor(SHRINE_DOOR.top / TILE), type: 'seal' }); obstacles.push({ x: 25, y: Math.floor(SHRINE_DOOR.top / TILE), type: 'seal' }); const reserved = (x, y) => (x >= 15 && x <= 34 && y >= 2 && y <= 12) || (x >= 21 && x <= 29 && y >= 26 && y <= 33) || ((x + .5 - LAKE.x / TILE) ** 2 / 20 + (y + .5 - LAKE.y / TILE) ** 2 / 11 < 1.35) || trailNearTile(x, y); for (let i = 0; i < 165; i++) { const x = 3 + Math.floor(random() * (MAP_W - 6)); const y = 3 + Math.floor(random() * (MAP_H - 6)); if (reserved(x, y) || obstacles.some((o) => o.x === x && o.y === y)) continue; const kind = random() > 0.57 ? 'tree' : 'rock'; obstacles.push({ x, y, type: kind }); props.push({ x, y, type: kind, variant: Math.floor(random() * 3) }); } const spawnPoints = [[9, 26], [14, 23], [33, 27], [39, 18], [10, 14], [37, 11], [13, 8]]; for (let i = 0; i < spawnPoints.length; i++) { const [tx, ty] = spawnPoints[i]; enemies.push({ id: i, x: tx * TILE + 23, y: ty * TILE + 25, hp: 2, maxHp: 2, alive: true, cooldown: 0, phase: random() * 6, boss: false }); } return { obstacles, props, enemies, temple }; } function reset() { keys.clear(); const world = makeWorld(); state = { ...world, player: { x: 25 * TILE, y: 29 * TILE, facing: 'up', hp: 6, maxHp: 6, invuln: 0, walk: 0 }, shards: 0, boss: null, bossDefeated: false, ending: false, endingTime: 0, endingStage: '', creditsShown: false, chase: [], chaseSparkTimer: 0, started: false, won: false, time: 0, particles: [], projectiles: [], drops: [], }; toast = '沿著林間小徑前進,找到星火碎片。'; toastTimer = 3.5; swingTimer = 0; hurtFlash = 0; updateObjective(); } function updateObjective() { if (state.creditsShown) objectiveEl.textContent = '謝謝你和我們一起完成這場冒險。'; else if (state.ending) objectiveEl.textContent = '勇者和魔物的最後一場追逐!'; else if (state.bossDefeated) objectiveEl.textContent = '魔王已敗,走進敞開的神殿大門。'; else if (state.boss) objectiveEl.textContent = `擊敗神殿魔王 · ${'♥'.repeat(Math.max(0, state.boss.hp))}${'♡'.repeat(Math.max(0, state.boss.maxHp - state.boss.hp))}`; else objectiveEl.textContent = `找到星火碎片 · ${state.shards} / 5`; } function start() { if (state.won) reset(); state.started = true; intro.classList.add('hidden'); toast = '森林深處的神殿,正等著最後一場戰鬥。'; toastTimer = 3; } function showEnd() { if (state.ending) return; state.won = true; state.ending = true; state.endingTime = 0; state.endingStage = 'opening-credits'; state.creditsShown = false; state.chaseSparkTimer = 0; toast = ''; toastTimer = 0; keys.clear(); state.player.x = SHRINE_DOOR.x; state.player.y = SHRINE_DOOR.top - 20; state.player.facing = 'right'; state.chase = [ { id: 1, x: state.player.x - 72, y: state.player.y + 40, phase: 1, alive: true, hp: 1, flash: 0 }, { id: 2, x: state.player.x - 145, y: state.player.y - 29, phase: 2, alive: true, hp: 1, flash: 0 }, { id: 3, x: state.player.x - 218, y: state.player.y + 57, phase: 3, alive: true, hp: 1, flash: 0 }, ]; state.chaseSparkTimer = 0; intro.innerHTML = '
THE PEOPLE OF STARFIRE FOREST

星火之森

古老封印再度閃耀……

作者HelloSun協作GPT-6_Luna
神殿深處傳來急促的腳步聲
'; intro.classList.remove('hidden'); updateObjective(); } function showCredits() { state.creditsShown = true; state.endingStage = 'done'; intro.innerHTML = '
THE PEOPLE OF STARFIRE FOREST

星火之森

勇者成功逃離魔物追逐,謝謝你完成冒險!

作者HelloSun協作GPT-6_Luna
THANK YOU FOR PLAYING
'; intro.classList.remove('hidden'); intro.querySelector('#start').addEventListener('click', () => { reset(); showIntro(); intro.classList.remove('hidden'); }); updateObjective(); } function isSolid(x, y) { const tx = Math.floor(x / TILE); const ty = Math.floor(y / TILE); return state.obstacles.some((o) => { if (o.x !== tx || o.y !== ty) return false; if (o.type !== 'seal') return true; if (state.bossDefeated) return false; // Only the narrow doorway arch is sealed; the rest of the same map tile // is walkable along the temple facade. return Math.abs(x - SHRINE_DOOR.x) < SHRINE_DOOR.width / 2 && y >= SHRINE_DOOR.top - 4 && y < SHRINE_DOOR.top + 38; }); } function isLake(x, y) { const dx = (x - LAKE.x) / (LAKE.rx + 2); const dy = (y - LAKE.y) / (LAKE.ry + 2); return dx * dx + dy * dy < 1; } function canStand(x, y) { const r = 11; if (x < TILE * 2 + r || y < TILE * 2 + r || x > WORLD_W - TILE * 2 - r || y > WORLD_H - TILE * 2 - r) return false; if (isSolid(x - r, y) || isSolid(x + r, y) || isSolid(x, y - r) || isSolid(x, y + r)) return false; if ([[-r,0],[r,0],[0,-r],[0,r]].some(([dx,dy]) => isLake(x + dx, y + dy))) return false; for (const p of state.props) if (p.type === 'tree' && Math.hypot(x - (p.x + .5) * TILE, y - (p.y + .66) * TILE) < 21) return false; return true; } function say(text, duration = 2.3) { toast = text; toastTimer = duration; } function burst(x, y, color, count = 10) { for (let i = 0; i < count; i++) { const a = random() * Math.PI * 2; const speed = 35 + random() * 110; state.particles.push({ x, y, vx: Math.cos(a) * speed, vy: Math.sin(a) * speed - 20, life: .35 + random() * .55, maxLife: .9, color, size: 2 + Math.floor(random() * 4) }); } } function sword() { if (!state.started || state.won || state.ending || swingTimer > .1) return; swingTimer = .32; const p = state.player; const diagonal = Math.SQRT1_2; const vector = { up: [0, -1], 'up-right': [diagonal, -diagonal], right: [1, 0], 'down-right': [diagonal, diagonal], down: [0, 1], 'down-left': [-diagonal, diagonal], left: [-1, 0], 'up-left': [-diagonal, -diagonal], }[p.facing]; const cx = p.x + vector[0] * 39; const cy = p.y + vector[1] * 39; for (const enemy of [...state.enemies, ...(state.boss ? [state.boss] : [])]) { if (!enemy.alive && !enemy.boss) continue; if (enemy.boss && enemy.flash > .1) continue; const dx = enemy.x - cx; const dy = enemy.y - cy; const front = dx * vector[0] + dy * vector[1]; const side = Math.abs(dx * vector[1] - dy * vector[0]); if ((enemy.boss ? front > -14 && side < 58 && Math.hypot(dx, dy) < 92 : front > -18 && side < 36 && Math.abs(front) < 83)) { enemy.hp -= enemy.boss ? 1 : 1; enemy.flash = .19; burst(enemy.x, enemy.y - 5, enemy.boss ? '#ffd676' : '#f6eaa8', enemy.boss ? 13 : 7); if (enemy.hp <= 0) { if (enemy.boss) { state.bossDefeated = true; state.boss = null; burst(enemy.x, enemy.y, '#ffd879', 46); say('魔王化作星塵!只有大門封印解除了,走進神殿看看吧。', 4.2); updateObjective(); } else { enemy.alive = false; state.drops.push({ x: enemy.x, y: enemy.y, life: 18, kind: 'shard' }); say('魔物被打倒了!撿起掉落的星火碎片。', 2.4); } } } } } function hurt(amount, x, y) { const p = state.player; if (p.invuln > 0 || state.won) return; p.hp = Math.max(0, p.hp - amount); p.invuln = 1; hurtFlash = .25; burst(p.x, p.y, '#f08b78', 9); say(p.hp === 0 ? '勇者倒下了!星火碎片還在,站起來再試一次。' : '被魔物擊中了!小心閃避。'); if (p.hp === 0) { window.setTimeout(() => { if (state.started && !state.won) { p.hp = p.maxHp; p.x = 25 * TILE; p.y = 29 * TILE; p.invuln = 1; say('在森林入口醒來了,繼續前進吧!'); } }, 950); } } function summonBoss() { state.boss = { x: SHRINE_DOOR.x, y: 7.15 * TILE, hp: 12, maxHp: 12, boss: true, alive: true, flash: 0, attackTimer: 1.4, phase: 0, radius: 35 }; say('古老的封印裂開了……熔岩之王甦醒!', 3.2); updateObjective(); } function tickEnding(seconds) { const duration = Math.max(.001, seconds); for (let i = 0; i < 1000; i++) update(duration / 1000); } function moveEnemy(enemy, dt) { if (!enemy.alive || enemy.boss) return; enemy.flash = Math.max(0, (enemy.flash || 0) - dt); enemy.phase += dt * 4; enemy.cooldown = Math.max(0, enemy.cooldown - dt); const p = state.player; const dx = p.x - enemy.x; const dy = p.y - enemy.y; const distance = Math.hypot(dx, dy); if (distance < 380 && distance > 29) { const step = Math.min(distance - 25, 52 * dt); const nx = enemy.x + dx / distance * step; const ny = enemy.y + dy / distance * step; if (canStand(nx, enemy.y)) enemy.x = nx; if (canStand(enemy.x, ny)) enemy.y = ny; } if (distance < 34 && enemy.cooldown <= 0) { enemy.cooldown = 1.05; hurt(1, enemy.x, enemy.y); } } function showIntro() { intro.innerHTML = '
A LITTLE PIXEL ADVENTURE

星火之森

古老的神殿裡,沉睡著最後的火焰。

方向鍵移動 · Z 揮劍
'; intro.querySelector('#start').addEventListener('click', start); } function updateBoss(dt) { const boss = state.boss; if (!boss) return; boss.phase += dt; boss.flash = Math.max(0, boss.flash - dt); boss.attackTimer -= dt; const p = state.player; const dx = p.x - boss.x; const dy = p.y - boss.y; const distance = Math.hypot(dx, dy); const speed = boss.hp < 6 ? 49 : 34; if (distance > 86) { boss.x += dx / Math.max(1, distance) * speed * dt; boss.y += dy / Math.max(1, distance) * speed * dt; } if (distance < 72 && boss.attackTimer <= 0) { boss.attackTimer = boss.hp < 6 ? 1.2 : 1.75; const a = Math.atan2(dy, dx); for (const offset of (boss.hp < 6 ? [-.32, 0, .32] : [-.17, .17])) { const angle = a + offset; state.projectiles.push({ x: boss.x, y: boss.y - 6, vx: Math.cos(angle) * 178, vy: Math.sin(angle) * 178, life: 3, size: 8 }); } burst(boss.x, boss.y, '#ff8149', 7); } if (distance < 53 && boss.attackTimer < .9) hurt(1, boss.x, boss.y); } function update(dt) { if (state.ending) { state.endingTime += dt; if (state.endingStage === 'opening-credits') { if (state.endingTime >= 2.4) { state.endingStage = 'chase'; state.endingTime = 0; state.player.x = SHRINE_DOOR.x; state.player.y = SHRINE_DOOR.top - 24; intro.classList.add('hidden'); updateObjective(); } return; } if (state.endingStage === 'done') return; const time = state.endingTime; const p = state.player; const centerX = SHRINE_DOOR.x; // Race loops around the interior nave after crossing the visible door. const centerY = SHRINE_DOOR.top - 31; const lap = time * .84; const previousX = p.x, previousY = p.y; p.x = centerX + Math.cos(lap) * 63; p.y = centerY + Math.sin(lap * 2) * 10; const dxWalk = p.x - previousX, dyWalk = p.y - previousY; p.walk += dt * 18; if (Math.abs(dxWalk) > Math.abs(dyWalk) * 1.7) p.facing = dxWalk > 0 ? 'right' : 'left'; else if (Math.abs(dyWalk) > Math.abs(dxWalk) * 1.7) p.facing = dyWalk > 0 ? 'down' : 'up'; else p.facing = dyWalk > 0 ? (dxWalk > 0 ? 'down-right' : 'down-left') : (dxWalk > 0 ? 'up-right' : 'up-left'); const pace = Math.hypot(dxWalk, dyWalk); if (pace > 1) state.chaseSparkTimer -= dt; if (state.chaseSparkTimer <= 0 && pace > 1) { state.chaseSparkTimer = .13; state.particles.push({ x: previousX, y: previousY + 8, vx: (random() - .5) * 12, vy: -14, life: .3, maxLife: .3, color: '#f1d98a', size: 2 }); } for (const enemy of state.chase) { enemy.phase += dt * 11; const dx = p.x - enemy.x, dy = p.y - enemy.y; const length = Math.max(1, Math.hypot(dx, dy)); const speed = (31 + enemy.id * 4) * dt; const curl = enemy.id % 2 ? -12 : 12; enemy.x += dx / length * speed - dy / length * curl * dt; enemy.y += dy / length * speed + dx / length * curl * dt + Math.sin(enemy.phase) * 9 * dt; } for (const particle of state.particles) { particle.x += particle.vx * dt; particle.y += particle.vy * dt; particle.life -= dt; } state.particles = state.particles.filter((particle) => particle.life > 0); if (time >= 7.2 && !state.creditsShown) { showCredits(); state.endingStage = 'done'; } return; } if (!state.started || state.won) return; state.time += dt; const p = state.player; p.invuln = Math.max(0, p.invuln - dt); swingTimer = Math.max(0, swingTimer - dt); hurtFlash = Math.max(0, hurtFlash - dt); toastTimer = Math.max(0, toastTimer - dt); let dx = 0, dy = 0; if (keys.has('ArrowLeft') || keys.has('KeyA')) dx--; if (keys.has('ArrowRight') || keys.has('KeyD')) dx++; if (keys.has('ArrowUp') || keys.has('KeyW')) dy--; if (keys.has('ArrowDown') || keys.has('KeyS')) dy++; if (dx || dy) { const length = Math.hypot(dx, dy); dx = dx / length * 156 * dt; dy = dy / length * 156 * dt; p.facing = dy < 0 ? (dx < 0 ? 'up-left' : dx > 0 ? 'up-right' : 'up') : dy > 0 ? (dx < 0 ? 'down-left' : dx > 0 ? 'down-right' : 'down') : dx < 0 ? 'left' : 'right'; if (canStand(p.x + dx, p.y)) p.x += dx; if (canStand(p.x, p.y + dy)) p.y += dy; p.walk += dt * 11; } for (const enemy of state.enemies) moveEnemy(enemy, dt); updateBoss(dt); for (const projectile of state.projectiles) { projectile.x += projectile.vx * dt; projectile.y += projectile.vy * dt; projectile.life -= dt; if (Math.hypot(projectile.x - p.x, projectile.y - p.y) < 22) { hurt(1, projectile.x, projectile.y); projectile.life = -1; } } state.projectiles = state.projectiles.filter((p) => p.life > 0); for (const drop of state.drops) { drop.life -= dt; if (Math.hypot(drop.x - p.x, drop.y - p.y) < 35) { drop.life = 0; state.shards = Math.min(5, state.shards + 1); const healed = p.hp < p.maxHp; if (healed) p.hp = Math.min(p.maxHp, p.hp + 1); say(state.shards < 5 ? `撿起星火碎片 ${state.shards} / 5${healed ? ' · 恢復一顆愛心' : ''}` : `五枚星火碎片已收齊!${healed ? ' · 恢復一顆愛心 ' : ''}前往北方神殿。`, 2.6); updateObjective(); } } state.drops = state.drops.filter((d) => d.life > 0); for (const particle of state.particles) { particle.x += particle.vx * dt; particle.y += particle.vy * dt; particle.vy += 130 * dt; particle.life -= dt; } state.particles = state.particles.filter((p) => p.life > 0); const centeredOnDoor = Math.abs(p.x - SHRINE_DOOR.x) < SHRINE_DOOR.width * .5; const insideShrine = centeredOnDoor && p.y < SHRINE_DOOR.top - 28 && p.y > SHRINE_DOOR.top - 190; // Unlock triggers only when the player reaches the sealed arch itself. const atDoorThreshold = centeredOnDoor && p.y >= SHRINE_DOOR.top + 24 && p.y <= SHRINE_DOOR.top + 84; if (state.bossDefeated && !state.ending && insideShrine) { showEnd(); tickEnding(.24); } else if (atDoorThreshold && !state.bossDefeated && !state.boss) { if (state.shards >= 5) summonBoss(); else if (toastTimer <= 0) say(`只有神殿入口有封印,需要星火碎片 ${state.shards} / 5`, 2.5); } updateObjective(); } function drawGround(camX, camY) { if (terrainReady) { ctx.drawImage(terrain, camX, camY, view.w, view.h, 0, 0, view.w, view.h); return; } ctx.fillStyle = '#477248'; ctx.fillRect(0, 0, view.w, view.h); const lake = { x: LAKE.x - camX, y: LAKE.y - camY }; ctx.fillStyle = '#587b4d'; ctx.beginPath(); ctx.ellipse(lake.x, lake.y, LAKE.rx + 13, LAKE.ry + 11, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#557e75'; ctx.beginPath(); ctx.ellipse(lake.x, lake.y, LAKE.rx, LAKE.ry, 0, 0, Math.PI * 2); ctx.fill(); } function drawTree(x, y, variant, camX, camY) { const px = x * TILE - camX; const py = y * TILE - camY; const sway = Math.sin(state.time * .65 + x * 2) * 1.7; ctx.fillStyle = '#24342a'; ctx.fillRect(px + 13, py + 23, 23, 24); ctx.fillStyle = '#604f36'; ctx.fillRect(px + 19, py + 23, 10, 23); ctx.fillStyle = '#344a32'; ctx.beginPath(); ctx.ellipse(px + 25, py + 24, 23, 13, 0, 0, Math.PI * 2); ctx.fill(); const palettes = variant === 0 ? ['#315c3c', '#44784a', '#71a05c'] : ['#386447', '#50824e', '#84a967']; ctx.fillStyle = '#253a2b'; ctx.beginPath(); ctx.moveTo(px + 25 + sway, py - 7); ctx.lineTo(px + 2, py + 29); ctx.lineTo(px + 47, py + 29); ctx.closePath(); ctx.fill(); ctx.fillStyle = palettes[0]; ctx.beginPath(); ctx.moveTo(px + 25 + sway, py - 11); ctx.lineTo(px + 5, py + 20); ctx.lineTo(px + 44, py + 20); ctx.closePath(); ctx.fill(); ctx.fillStyle = palettes[1]; ctx.beginPath(); ctx.moveTo(px + 23 + sway, py - 9); ctx.lineTo(px + 11, py + 16); ctx.lineTo(px + 35, py + 16); ctx.closePath(); ctx.fill(); ctx.fillStyle = palettes[2]; ctx.fillRect(px + 20 + sway, py + 2, 4, 3); ctx.fillRect(px + 30 + sway, py + 10, 3, 3); ctx.fillStyle = '#ffffff13'; ctx.fillRect(px + 5, py + 22, 2, 5); } function drawRock(x, y, camX, camY) { const px = x * TILE - camX, py = y * TILE - camY; ctx.fillStyle = '#304331'; ctx.fillRect(px + 6, py + 32, 38, 11); ctx.fillStyle = '#7d866c'; ctx.fillRect(px + 9, py + 18, 29, 19); ctx.fillStyle = '#9fa183'; ctx.fillRect(px + 15, py + 11, 19, 12); ctx.fillStyle = '#b8b493'; ctx.fillRect(px + 20, py + 9, 11, 5); ctx.fillStyle = '#5c6b56'; ctx.fillRect(px + 9, py + 31, 22, 4); } function drawTemple(camX, camY) { const tx = state.temple; const x = tx.x * TILE - camX, y = tx.y * TILE - camY; ctx.fillStyle = '#30382f'; ctx.fillRect(x - 12, y + 133, tx.w * TILE + 24, 14); ctx.fillStyle = '#665f4b'; ctx.fillRect(x + 4, y + 55, tx.w * TILE - 8, 82); ctx.fillStyle = '#94876a'; ctx.fillRect(x, y + 43, tx.w * TILE, 17); ctx.fillStyle = '#c1ad7d'; ctx.fillRect(x + 6, y + 38, tx.w * TILE - 12, 7); for (let i = 0; i < 6; i++) { ctx.fillStyle = i % 2 ? '#a39876' : '#83795f'; ctx.fillRect(x + 8 + i * 63, y + 59, 49, 71); ctx.fillStyle = '#c0b18a'; ctx.fillRect(x + 12 + i * 63, y + 60, 5, 67); } ctx.fillStyle = '#413d32'; ctx.fillRect(x + 5, y + 125, tx.w * TILE - 10, 12); const doorX = SHRINE_DOOR.x - camX; const doorTop = SHRINE_DOOR.top - camY; const doorLeft = doorX - SHRINE_DOOR.width / 2; ctx.fillStyle = '#413d32'; ctx.fillRect(doorLeft - 8, doorTop - 15, SHRINE_DOOR.width + 16, 59); ctx.fillStyle = '#b8a57b'; ctx.fillRect(doorLeft - 6, doorTop - 11, 4, 54); ctx.fillStyle = '#4b4937'; ctx.fillRect(doorLeft + SHRINE_DOOR.width + 2, doorTop - 11, 4, 54); ctx.fillStyle = '#d0bb89'; ctx.fillRect(doorLeft - 9, doorTop - 15, SHRINE_DOOR.width + 18, 5); if (!state.bossDefeated) { // The seal covers only the actual archway, leaving the rest of the // temple façade, landing, and courtyard fully open to the player. ctx.fillStyle = '#191c19'; ctx.fillRect(doorLeft, doorTop, SHRINE_DOOR.width, 40); ctx.fillStyle = state.shards >= 5 ? '#e3bd70' : '#89513b'; ctx.fillRect(doorLeft + 20, doorTop + 9, 10, 25); ctx.fillStyle = state.shards >= 5 ? '#ffe6a0' : '#d2a363'; ctx.fillRect(doorLeft + 16, doorTop + 15, 18, 15); ctx.fillStyle = '#382c24'; ctx.fillRect(doorLeft + 22, doorTop + 17, 6, 6); if (state.shards >= 5) { ctx.strokeStyle = '#ffe39b'; ctx.globalAlpha = .4 + Math.sin(state.time * 3) * .2; ctx.lineWidth = 2; ctx.strokeRect(doorLeft - 3, doorTop - 3, SHRINE_DOOR.width + 6, 45); ctx.globalAlpha = 1; } } else { const welcome = ctx.createRadialGradient(doorX, doorTop + 20, 2, doorX, doorTop + 20, 54); welcome.addColorStop(0, '#ffe7a8e8'); welcome.addColorStop(.4, '#d4ab687a'); welcome.addColorStop(1, '#d4ab6800'); ctx.fillStyle = welcome; ctx.fillRect(doorLeft - 28, doorTop - 28, SHRINE_DOOR.width + 56, 96); ctx.fillStyle = '#101815'; ctx.fillRect(doorLeft, doorTop, SHRINE_DOOR.width, 40); ctx.fillStyle = '#beab7d'; ctx.fillRect(doorLeft - 4, doorTop - 6, 5, 49); ctx.fillRect(doorLeft + SHRINE_DOOR.width - 1, doorTop - 6, 5, 49); ctx.fillStyle = '#dfcb99'; ctx.fillRect(doorLeft - 5, doorTop - 7, SHRINE_DOOR.width + 10, 4); } ctx.fillStyle = '#ddd0a7'; ctx.fillRect(x - 13, y + 134, tx.w * TILE + 26, 5); } function drawBug(enemy, camX, camY) { const x = Math.round(enemy.x - camX); const y = Math.round(enemy.y - camY + Math.sin(enemy.phase) * 2); ctx.fillStyle = '#1c2c20a8'; ctx.beginPath(); ctx.ellipse(x, y + 12, 17, 6, 0, 0, Math.PI * 2); ctx.fill(); if (sprites.bug.complete && sprites.bug.naturalWidth) { const frame = Math.floor(state.time * 8 + enemy.id) % 6; ctx.drawImage(sprites.bug, frame * 32, 0, 32, 32, x - 20, y - 19, 40, 38); } else { ctx.fillStyle = '#bdd777'; ctx.beginPath(); ctx.ellipse(x, y, 15, 12, 0, 0, 7); ctx.fill(); } if (enemy.flash > 0) { ctx.fillStyle = '#fff9d6aa'; ctx.fillRect(x - 14, y - 13, 28, 24); } } function drawBoss(boss, camX, camY) { const x = Math.round(boss.x - camX), y = Math.round(boss.y - camY); const bob = Math.sin(boss.phase * 3) * 4; ctx.fillStyle = '#13190fb3'; ctx.beginPath(); ctx.ellipse(x, y + 26, 44, 16, 0, 0, 7); ctx.fill(); ctx.save(); ctx.translate(x, y + bob); ctx.fillStyle = boss.flash ? '#fff5c8' : '#521d1d'; ctx.beginPath(); ctx.ellipse(0, 3, 36, 33, 0, 0, 7); ctx.fill(); ctx.fillStyle = '#873329'; ctx.beginPath(); ctx.ellipse(-3, -3, 29, 26, 0, 0, 7); ctx.fill(); ctx.fillStyle = '#d44d32'; ctx.beginPath(); ctx.moveTo(-24,-14);ctx.lineTo(-33,-42);ctx.lineTo(-8,-26);ctx.lineTo(4,-47);ctx.lineTo(13,-25);ctx.lineTo(34,-37);ctx.lineTo(23,-8);ctx.closePath();ctx.fill(); ctx.fillStyle = '#f3c66e'; ctx.beginPath(); ctx.ellipse(-12, -5, 5, 7, 0, 0, 7); ctx.ellipse(12,-5,5,7,0,0,7);ctx.fill(); ctx.fillStyle = '#55211d'; ctx.fillRect(-12, 11, 24, 6); ctx.fillStyle = '#f3c66e'; ctx.fillRect(-8, 12, 4, 5); ctx.fillRect(4,12,4,5); ctx.fillStyle = '#e88943'; ctx.fillRect(-39, 5, 7, 20); ctx.fillRect(32, 5, 7, 20); ctx.restore(); ctx.fillStyle = '#30251e'; ctx.fillRect(x - 36, y - 58, 72, 9); ctx.fillStyle = '#ce5541'; ctx.fillRect(x - 34, y - 56, 68 * boss.hp / boss.maxHp, 5); } function drawHero(camX, camY) { const p = state.player; const x = Math.round(p.x - camX); const y = Math.round(p.y - camY + (keys.size ? Math.sin(p.walk) * 1.5 : 0)); if (p.invuln > 0 && Math.floor(state.time * 12) % 2 === 0) return; ctx.fillStyle = '#1a251a99'; ctx.beginPath(); ctx.ellipse(x, y + 10, 14, 5, 0, 0, 7); ctx.fill(); if (sprites.hero.complete && sprites.hero.naturalWidth) { // midi-ow.png is a 13 × 9 atlas of 22 × 22 sprites (286 × 198 px). // Each row is one of the eight directional walking animations; its // thirteen columns are animation frames, not separate 26 × 33 sprites. const row = { down: 0, 'down-right': 1, right: 2, 'up-right': 3, up: 4, 'up-left': 5, left: 6, 'down-left': 7 }[p.facing]; const col = keys.size ? Math.floor(p.walk) % 13 : 0; ctx.drawImage(sprites.hero, col * 22, row * 22, 22, 22, x - 22, y - 32, 44, 44); } else { ctx.fillStyle = '#e9ca77'; ctx.fillRect(x - 7, y - 20, 14, 22); ctx.fillStyle = '#558656'; ctx.fillRect(x - 8, y - 10, 16, 10); } if (swingTimer > 0) { const offset = { up:[0,-34], 'up-right':[25,-25], right:[35,0], 'down-right':[25,25], down:[0,34], 'down-left':[-25,25], left:[-35,0], 'up-left':[-25,-25], }[p.facing]; ctx.save(); ctx.translate(x + offset[0], y + offset[1]); ctx.rotate(Math.sin((swingTimer / .32) * Math.PI) * .35); ctx.fillStyle = '#fff2bc'; ctx.fillRect(-2, -22, 4, 31); ctx.fillStyle = '#d1ac64'; ctx.fillRect(-2, 4, 4, 9); ctx.fillRect(-7, 3, 14, 3); ctx.restore(); } } function drawChaseBug(enemy, camX, camY) { const x = Math.round(enemy.x - camX); const y = Math.round(enemy.y - camY + Math.sin(enemy.phase) * 2.5); ctx.fillStyle = '#15201685'; ctx.beginPath(); ctx.ellipse(x, y + 12, 15, 5, 0, 0, Math.PI * 2); ctx.fill(); if (sprites.bug.complete && sprites.bug.naturalWidth) { const frame = Math.floor(state.endingTime * 11 + enemy.id) % 6; ctx.drawImage(sprites.bug, frame * 32, 0, 32, 32, x - 19, y - 19, 38, 38); } else { ctx.fillStyle = '#b8c978'; ctx.beginPath(); ctx.ellipse(x, y, 14, 10, 0, 0, Math.PI * 2); ctx.fill(); } ctx.fillStyle = '#f4d37c'; ctx.globalAlpha = Math.min(1, Math.max(0, enemy.phase % 1)); ctx.fillRect(x - 2, y - 2, 3, 3); ctx.globalAlpha = 1; } function draw(camX, camY) { drawGround(camX, camY); if (state.ending) { const titleX = view.w / 2, titleY = view.h * .32; const float = Math.sin(state.endingTime * 2.4) * 4; ctx.fillStyle = '#18251ce8'; ctx.beginPath(); ctx.roundRect(titleX - 205, titleY - 34, 410, 69, 14); ctx.fill(); ctx.strokeStyle = '#e9d59362'; ctx.lineWidth = 1; ctx.stroke(); ctx.textAlign = 'center'; ctx.fillStyle = '#e6cf93'; ctx.font = '11px "DM Mono", monospace'; ctx.fillText('THE LAST CHASE', titleX, titleY - 8 + float); ctx.fillStyle = '#f5e6ba'; ctx.font = '15px "Noto Sans TC", sans-serif'; ctx.fillText(state.creditsShown ? '神殿製作名單' : '勇者與魔物的森林大追逐!', titleX, titleY + 18 + float); ctx.textAlign = 'start'; if (!state.creditsShown) { for (const bug of state.chase) drawChaseBug(bug, camX, camY); drawHero(camX, camY); } for (const particle of state.creditsShown ? [] : state.particles) { ctx.globalAlpha = Math.max(0, particle.life / particle.maxLife); ctx.fillStyle = particle.color; ctx.fillRect(Math.round(particle.x - camX), Math.round(particle.y - camY), particle.size, particle.size); } ctx.globalAlpha = 1; if (state.creditsShown) { const fade = Math.min(1, (state.endingTime - 8) * .8); ctx.globalAlpha = fade; const panelX = view.w / 2 - 185, panelY = titleY + 54; ctx.fillStyle = '#17241df2'; ctx.beginPath(); ctx.roundRect(panelX, panelY, 370, 134, 12); ctx.fill(); ctx.strokeStyle = '#e9ddb34d'; ctx.stroke(); ctx.textAlign = 'center'; ctx.fillStyle = '#aeb99e'; ctx.font = '11px "DM Mono", monospace'; ctx.fillText('PRODUCTION CREDITS', view.w / 2, panelY + 25); ctx.fillStyle = '#d3d5c1'; ctx.font = '12px "Noto Sans TC", sans-serif'; ctx.fillText('作者', view.w / 2, panelY + 53); ctx.fillStyle = '#f4dfa0'; ctx.font = 'bold 20px "DM Mono", monospace'; ctx.fillText('HelloSun', view.w / 2, panelY + 78); ctx.fillStyle = '#d3d5c1'; ctx.font = '12px "Noto Sans TC", sans-serif'; ctx.fillText('協作', view.w / 2, panelY + 105); ctx.fillStyle = '#f4dfa0'; ctx.font = 'bold 18px "DM Mono", monospace'; ctx.fillText('GPT-6_Luna', view.w / 2, panelY + 128); ctx.textAlign = 'start'; ctx.globalAlpha = 1; const restartX = view.w / 2 - 64, restartY = panelY + 151; ctx.fillStyle = '#17241df2'; ctx.beginPath(); ctx.roundRect(restartX, restartY, 128, 34, 7); ctx.fill(); ctx.strokeStyle = '#e9ddb34d'; ctx.stroke(); ctx.fillStyle = '#e6cf93'; ctx.font = '12px "Noto Sans TC", sans-serif'; ctx.textAlign = 'center'; ctx.fillText('重新開始 ↺', view.w / 2, restartY + 22); ctx.textAlign = 'start'; state.restartBounds = { x: restartX, y: restartY, w: 128, h: 34 }; } else if (state.endingTime > 6.1) { const fade = Math.min(1, (state.endingTime - 6.1) * .48); ctx.globalAlpha = fade; ctx.fillStyle = '#17241dd9'; ctx.beginPath(); ctx.roundRect(view.w / 2 - 112, view.h - 77, 224, 35, 8); ctx.fill(); ctx.fillStyle = '#f2e5bd'; ctx.font = '13px "Noto Sans TC", sans-serif'; ctx.textAlign = 'center'; ctx.fillText('進入神殿查看製作名單', view.w / 2, view.h - 55); ctx.textAlign = 'start'; ctx.globalAlpha = 1; } drawHud(camX, camY); return; } const objects = []; for (const prop of state.props) objects.push({ y: (prop.y + .8) * TILE, draw: () => prop.type === 'tree' ? drawTree(prop.x, prop.y, prop.variant, camX, camY) : drawRock(prop.x, prop.y, camX, camY) }); const tx = state.temple; objects.push({ y: (tx.y + tx.h) * TILE, draw: () => drawTemple(camX, camY) }); for (const enemy of state.enemies) if (enemy.alive) objects.push({ y: enemy.y + 12, draw: () => drawBug(enemy, camX, camY) }); if (state.boss) objects.push({ y: state.boss.y + 25, draw: () => drawBoss(state.boss, camX, camY) }); for (const drop of state.drops) objects.push({ y: drop.y + 5, draw: () => { const pulse = 1 + Math.sin(state.time * 6 + drop.x) * .16; ctx.fillStyle = '#f3d26b24'; ctx.beginPath(); ctx.arc(drop.x - camX, drop.y - camY, 19 * pulse, 0, 7); ctx.fill(); ctx.fillStyle = '#f9db75'; ctx.beginPath(); ctx.moveTo(drop.x-camX,drop.y-camY-10);ctx.lineTo(drop.x-camX+7,drop.y-camY);ctx.lineTo(drop.x-camX,drop.y-camY+9);ctx.lineTo(drop.x-camX-7,drop.y-camY);ctx.closePath();ctx.fill(); } }); objects.push({ y: state.player.y + 10, draw: () => drawHero(camX, camY) }); objects.sort((a,b) => a.y - b.y); for (const object of objects) object.draw(); for (const projectile of state.projectiles) { ctx.fillStyle = '#f1482d56'; ctx.beginPath(); ctx.arc(projectile.x - camX, projectile.y - camY, 15, 0, 7); ctx.fill(); ctx.fillStyle = '#ffc35b'; ctx.beginPath(); ctx.arc(projectile.x - camX, projectile.y - camY, projectile.size, 0, 7); ctx.fill(); } for (const particle of state.particles) { ctx.globalAlpha = Math.max(0, particle.life / particle.maxLife); ctx.fillStyle = particle.color; ctx.fillRect(Math.round(particle.x - camX), Math.round(particle.y - camY), particle.size, particle.size); } ctx.globalAlpha = 1; drawHud(camX, camY); } function drawHud(camX, camY) { const p = state.player; ctx.fillStyle = '#17241fde'; ctx.beginPath(); ctx.roundRect(17, 15, 214, 58, 8); ctx.fill(); ctx.strokeStyle = '#e9ddb33a'; ctx.lineWidth = 1; ctx.stroke(); for (let i = 0; i < p.maxHp; i++) { const x = 30 + i * 25, y = 28; ctx.fillStyle = i < p.hp ? '#e88570' : '#758072'; ctx.font = '20px serif'; ctx.fillText(i < p.hp ? '♥' : '♡', x, y + 1); } ctx.fillStyle = '#eed47f'; ctx.font = 'bold 12px "DM Mono", monospace'; ctx.fillText('✦', 31, 57); ctx.fillStyle = '#eee4c8'; ctx.font = '12px "DM Mono", monospace'; ctx.fillText(`${state.shards} / 5`, 50, 57); // A little compass and northbound shrine marker. ctx.fillStyle = '#17241dd9'; ctx.beginPath(); ctx.roundRect(view.w - 60, 16, 43, 43, 22); ctx.fill(); ctx.strokeStyle = '#dfd29a65'; ctx.stroke(); ctx.fillStyle = '#d8ddcb'; ctx.font = '10px "DM Mono", monospace'; ctx.fillText('N', view.w - 44, 29); const mx = view.w - 39 + ((25 * TILE - p.x) / WORLD_W) * 11; const my = 39 + ((7 * TILE - p.y) / WORLD_H) * 11; ctx.fillStyle = '#efd37c'; ctx.beginPath(); ctx.arc(mx, my, 3, 0, 7); ctx.fill(); if (toastTimer > 0 && toast) { ctx.font = '13px "Noto Sans TC", sans-serif'; const width = Math.min(view.w - 60, ctx.measureText(toast).width + 38); const x = (view.w - width) / 2; ctx.fillStyle = '#142019e8'; ctx.beginPath(); ctx.roundRect(x, view.h - 54, width, 34, 7); ctx.fill(); ctx.strokeStyle = '#e1ce8a38'; ctx.stroke(); ctx.fillStyle = '#eee5ce'; ctx.textAlign = 'center'; ctx.fillText(toast, view.w / 2, view.h - 32, width - 24); ctx.textAlign = 'start'; } if (hurtFlash > 0) { ctx.fillStyle = `rgba(205,67,58,${hurtFlash * .25})`; ctx.fillRect(0, 0, view.w, view.h); } // Keep the camera arguments available here for the minimap and future map overlays. void camX; void camY; } function loop(now) { const dt = Math.min(.035, (now - lastTime) / 1000 || 0); lastTime = now; update(dt); const targetX = state.player.x - view.w / 2; const targetY = state.player.y - view.h / 2; const camX = Math.max(0, Math.min(WORLD_W - view.w, targetX)); const camY = Math.max(0, Math.min(WORLD_H - view.h, targetY)); draw(camX, camY); requestAnimationFrame(loop); } window.addEventListener('keydown', (event) => { if (state.creditsShown && ['Enter', 'Space', 'KeyZ'].includes(event.code)) { event.preventDefault(); reset(); showIntro(); intro.classList.remove('hidden'); return; } if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(event.code)) event.preventDefault(); keys.add(event.code); if (event.code === 'KeyZ' || event.code === 'Space' || event.code === 'Enter') sword(); if (!state.started && ['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','KeyW','KeyA','KeyS','KeyD','Enter','Space'].includes(event.code)) start(); }); window.addEventListener('keyup', (event) => keys.delete(event.code)); window.addEventListener('blur', () => keys.clear()); canvas.addEventListener('pointerdown', (event) => { if (!state.creditsShown || !state.restartBounds) return; const rect = canvas.getBoundingClientRect(); const x = (event.clientX - rect.left) * canvas.width / rect.width; const y = (event.clientY - rect.top) * canvas.height / rect.height; const button = state.restartBounds; if (x >= button.x && x <= button.x + button.w && y >= button.y && y <= button.y + button.h) { reset(); showIntro(); intro.classList.remove('hidden'); } }); document.querySelector('#start').addEventListener('click', start); document.querySelector('#restart').addEventListener('click', () => { reset(); showIntro(); intro.classList.remove('hidden'); }); document.querySelectorAll('[data-key]').forEach((button) => { const key = button.dataset.key; const up = (event) => { event.preventDefault(); keys.delete(key); button.classList.remove('pressed'); }; button.addEventListener('pointerdown', (event) => { event.preventDefault(); button.setPointerCapture(event.pointerId); button.classList.add('pressed'); if (key === 'KeyZ') sword(); else { keys.add(key); if (!state.started) start(); } }); button.addEventListener('pointerup', up); button.addEventListener('pointercancel', up); button.addEventListener('lostpointercapture', up); }); makeTerrain(); reset(); requestAnimationFrame(loop); })();