rpg1 / game.js
HelloSun's picture
Fix shrine entry seal and post-entry creator finale
dba13bb verified
Raw History Blame Contribute Delete
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);
})();