Download game.js from HelloSun/rpg1: direct link, hf CLI and curl.
- Browser
- Download file 44.2 kB
-
https://huggingface.co/spaces/HelloSun/rpg1/resolve/main/game.js
- Command line
-
hf download hf://spaces/HelloSun/rpg1/game.js
-
curl -L -o game.js https://huggingface.co/spaces/HelloSun/rpg1/resolve/main/game.js
44.2 kB
| (() => { | |
| '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 = '<div class="title-card credits-card"><div class="title-eyebrow">THE PEOPLE OF STARFIRE FOREST</div><h1>星火之森</h1><p>古老封印再度閃耀……</p><div class="credits-list"><span>作者</span><strong>HelloSun</strong><span>協作</span><strong>GPT-6_Luna</strong></div><div class="title-hint">神殿深處傳來急促的腳步聲</div></div>'; | |
| intro.classList.remove('hidden'); | |
| updateObjective(); | |
| } | |
| function showCredits() { | |
| state.creditsShown = true; | |
| state.endingStage = 'done'; | |
| intro.innerHTML = '<div class="title-card credits-card"><div class="title-eyebrow">THE PEOPLE OF STARFIRE FOREST</div><h1>星火之森</h1><p>勇者成功逃離魔物追逐,謝謝你完成冒險!</p><div class="credits-list"><span>作者</span><strong>HelloSun</strong><span>協作</span><strong>GPT-6_Luna</strong></div><button id="start" class="start-button" type="button"><span>✦</span> 再玩一次 <span>✦</span></button><div class="title-hint">THANK YOU FOR PLAYING</div></div>'; | |
| 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 = '<div class="title-card"><div class="title-eyebrow">A LITTLE PIXEL ADVENTURE</div><h1>星火之森</h1><p>古老的神殿裡,沉睡著最後的火焰。</p><button id="start" class="start-button" type="button"><span>✦</span> 開始冒險 <span>✦</span></button><div class="title-hint">方向鍵移動 <span>·</span> Z 揮劍</div></div>'; | |
| 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); | |
| })(); | |