RPG
Qwen 3.7 Flash · composer

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 70,647 bytes · SHA-256 44e86b715368
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Realm of Echoes</title>
<style>
/* ── css: base ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a0a; overflow: hidden; display: flex; justify-content: center; align-items: center; height: 100vh; font-family: monospace; }
canvas { display: block; image-rendering: pixelated; border: 2px solid #333; cursor: crosshair; }
/* ── css: ui ── */
#game { border-radius: 4px; box-shadow: 0 0 30px rgba(0,50,100,0.3); }
#hud { position: fixed; top: 10px; left: 10px; pointer-events: none; color: #ccc; font-size: 14px; z-index: 10; }
#overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 20; }
</style>
</head>
<body>
<canvas id="game" width="800" height="600"></canvas>
<div id="hud"></div>
<div id="overlay"></div>
<script>
'use strict';
// ── const: TILE ──
const TILE = 32;
// ── const: MAP_W ──
const MAP_W = 80;
// ── const: MAP_H ──
const MAP_H = 60;
// ── const: VIEW_W ──
const VIEW_W = 800;
// ── const: VIEW_H ──
const VIEW_H = 600;
// ── const: COLS ──
const COLS = Math.floor(VIEW_W / TILE);
// ── const: ROWS ──
const ROWS = Math.floor(VIEW_H / TILE);
// ── const: COLORS ──
const COLORS = {
wall: '#2a2a3a', wallTop: '#3a3a4a', floor: '#1a1a2e', floorAlt: '#1e1e34',
door: '#8B7355', doorFrame: '#5a4a30', savePoint: '#4488ff',
player: '#44bb44', playerDark: '#228822',
sword: '#ccccdd', flameSword: '#ff6622', shadowBlade: '#8844cc',
bow: '#aa8855',
enemySlime: '#44aa44', enemyBat: '#7744aa', enemySkel: '#ccccaa', enemyDarkKnight: '#553366',
healthPotion: '#ff4466', money: '#ffcc00',
uiBg: 'rgba(0,0,0,0.85)', uiBorder: '#555577', uiText: '#dddde8',
heart: '#ff3355', heartEmpty: '#443333',
messageBg: 'rgba(10,10,20,0.9)',
};
// ── const: ENEMY_STATS ──
const ENEMY_STATS = {
slime: { hp: 6, dmg: 2, speed: 0.8, color: COLORS.enemySlime, size: 0.7, exp: 1 },
bat: { hp: 4, dmg: 1, speed: 1.8, color: COLORS.enemyBat, size: 0.6, exp: 1, flying: true },
skeleton: { hp: 10, dmg: 3, speed: 1, color: COLORS.enemySkel, size: 0.9, exp: 2 },
darkKnight: { hp: 20, dmg: 5, speed: 1.2, color: COLORS.enemyDarkKnight, size: 1.0, exp: 4 },
};
// ── function: createAudio ──
function createAudio() {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
function play(freq, type, duration, vol = 0.15, slide = 0) {
if (ctx.state === 'suspended') ctx.resume();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, ctx.currentTime);
if (slide) osc.frequency.linearRampToValueAtTime(freq + slide, ctx.currentTime + duration);
gain.gain.setValueAtTime(vol, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + duration);
}
return {
step() { play(80 + Math.random() * 40, 'square', 0.05, 0.04); },
swing() { play(300, 'sawtooth', 0.12, 0.1, -200); },
hit() { play(150, 'square', 0.15, 0.15, 50); },
hurt() { play(200, 'sawtooth', 0.3, 0.2, -100); },
pickup() { play(600, 'sine', 0.15, 0.12, 300); },
door() { play(120, 'triangle', 0.25, 0.1); play(90, 'triangle', 0.25, 0.08, 30); },
shopOpen() { play(440, 'sine', 0.2, 0.1); setTimeout(() => play(550, 'sine', 0.2, 0.1), 100); setTimeout(() => play(660, 'sine', 0.3, 0.1), 200); },
shopBuy() { play(800, 'sine', 0.15, 0.12, 200); },
heal() { play(500, 'sine', 0.2, 0.1); setTimeout(() => play(700, 'sine', 0.3, 0.1), 150); },
savePoint() { play(330, 'sine', 0.3, 0.1); setTimeout(() => play(440, 'sine', 0.3, 0.1), 200); setTimeout(() => play(550, 'sine', 0.4, 0.1), 400); },
jump() { play(300, 'triangle', 0.1, 0.08, 200); },
levelUp() { play(440, 'sine', 0.2, 0.15); setTimeout(() => play(554, 'sine', 0.2, 0.15), 150); setTimeout(() => play(659, 'sine', 0.3, 0.15), 300); }
};
}
// ── function: createParticles ──
function createParticles() {
let particles = [];
function emit(x, y, count, color, speed = 2, life = 30, size = 3) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const spd = speed * (0.5 + Math.random());
particles.push({ x, y, vx: Math.cos(angle)*spd, vy: Math.sin(angle)*spd, life, maxLife: life, color, size });
}
}
function emitLine(x, y, tx, ty, count, color, speed = 3, life = 20, size = 2) {
for (let i = 0; i < count; i++) {
const t = i / count;
const px = x + (tx - x) * t;
const py = y + (ty - y) * t;
const angle = Math.random() * Math.PI * 2;
const spd = speed * (0.3 + Math.random() * 0.7);
particles.push({ x: px, y: py, vx: Math.cos(angle)*spd, vy: Math.sin(angle)*spd, life, maxLife: life, color, size });
}
}
function update() {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx; p.y += p.vy;
p.vx *= 0.95; p.vy *= 0.95;
p.life--;
if (p.life <= 0) particles.splice(i, 1);
}
}
function render(ctx, cam) {
for (const p of particles) {
const sx = p.x - cam.x, sy = p.y - cam.y;
if (sx < -20 || sx > VIEW_W + 20 || sy < -20 || sy > VIEW_H + 20) continue;
const alpha = p.life / p.maxLife;
ctx.globalAlpha = alpha;
ctx.fillStyle = p.color;
ctx.fillRect(sx - p.size/2, sy - p.size/2, p.size, p.size);
}
ctx.globalAlpha = 1;
}
function clear() { particles = []; }
return { emit, emitLine, update, render, clear };
}
// ── function: createInput ──
function createInput() {
const keys = {};
const justPressed = {};
const justReleased = {};
const prevKeys = {};
window.addEventListener('keydown', e => { keys[e.key.toLowerCase()] = true; e.preventDefault(); });
window.addEventListener('keyup', e => { keys[e.key.toLowerCase()] = false; e.preventDefault(); });
function tick() {
for (const k in justPressed) justPressed[k] = false;
for (const k in keys) {
if (keys[k] && !prevKeys[k]) justPressed[k] = true;
}
for (const k in prevKeys) {
if (!keys[k] && prevKeys[k]) justReleased[k] = true;
}
Object.assign(prevKeys, keys);
}
return { keys, justPressed, justReleased, tick };
}
// ── function: createCamera ──
function createCamera() {
let x = 0, y = 0;
function update(target) {
const tx = target.x - VIEW_W / 2;
const ty = target.y - VIEW_H / 2;
x += (tx - x) * 0.12;
y += (ty - y) * 0.12;
x = Math.max(0, Math.min(MAP_W * TILE - VIEW_W, x));
y = Math.max(0, Math.min(MAP_H * TILE - VIEW_H, y));
}
return { get x() { return x; }, get y() { return y; }, update };
}
// ── function: makeMap ──
function makeMap(floor) {
// 0=floor 1=wall 2=door 3=savePoint 4=water 5=trap 6=decoration
const map = [];
for (let y = 0; y < MAP_H; y++) {
map[y] = [];
for (let x = 0; x < MAP_W; x++) {
if (x < 2 || x >= MAP_W-2 || y < 2 || y >= MAP_H-2) map[y][x] = 1;
else map[y][x] = 0;
}
}
return map;
}
// ── function: addRoom ──
function addRoom(map, x, y, w, h, type) {
for (let ry = y; ry < y + h && ry < MAP_H; ry++)
for (let rx = x; rx < x + w && rx < MAP_W; rx++)
map[ry][rx] = (type === 'water') ? 4 : (!(ry === y || ry === y+h-1 || rx === x || rx === x+w-1)) ? 0 : 1;
}
// ── function: addCorridor ──
function addCorridor(map, x1, y1, x2, y2) {
let x = x1, y = y1;
while (x !== x2) { x += (x < x2) ? 1 : -1; if (y > 0 && y < MAP_H-1 && x > 0 && x < MAP_W-1) map[y][x] = 0; }
while (y !== y2) { y += (y < y2) ? 1 : -1; if (y > 0 && y < MAP_H-1 && x > 0 && x < MAP_W-1) map[y][x] = 0; }
}
// ── function: addWalls ──
function addWalls(map, x, y, w, h, density) {
for (let ry = y+1; ry < y+h-1; ry++)
for (let rx = x+1; rx < x+w-1; rx++)
if (map[ry][rx] === 0 && Math.random() < density) map[ry][rx] = 1;
}
// ── function: addDecorations ──
function addDecorations(map, x, y, w, h) {
for (let ry = y+2; ry < y+h-2; ry++)
for (let rx = x+2; rx < x+w-2; rx++)
if (map[ry][rx] === 0 && Math.random() < 0.03) map[ry][rx] = 6;
}
// ── const: ALL_ROOMS ──
const ALL_ROOMS = [];
// ── function: buildFloor ──
function buildFloor(floor) {
const map = makeMap();
const rooms = [];
const room = (rx, ry, rw, rh) => {
addRoom(map, rx, ry, rw, rh);
rooms.push({ x: rx, y: ry, w: rw, h: rh });
};
if (floor === 0) {
// Starting town - large open area with some rooms
room(5, 5, 16, 10); // Player start
room(27, 4, 8, 6); // Item shop
room(5, 20, 12, 8); // Small storage
room(27, 18, 10, 7); // NPC/guide
addCorridor(map, 20, 9, 27, 9);
addCorridor(map, 10, 15, 10, 20);
addCorridor(map, 30, 9, 30, 18);
addCorridor(map, 10, 27, 27, 27);
// Extra walls for interest
addWalls(map, 5, 5, 16, 10, 0.08);
addWalls(map, 27, 4, 8, 6, 0.05);
addDecorations(map, 5, 5, 16, 10);
// Save point
map[8][12] = 3;
} else if (floor === 1) {
// Dungeon 1 - Goblin caves
room(5, 5, 10, 8); // entrance
room(20, 5, 12, 8);
room(37, 5, 10, 10);
room(5, 20, 15, 8);
room(30, 20, 12, 10);
room(50, 10, 10, 8);
addCorridor(map, 14, 8, 20, 8);
addCorridor(map, 31, 8, 37, 8);
addCorridor(map, 14, 23, 30, 23);
addCorridor(map, 40, 14, 50, 14);
addCorridor(map, 14, 23, 14, 30);
addWalls(map, 20, 5, 12, 8, 0.12);
addWalls(map, 5, 20, 15, 8, 0.1);
addDecorations(map, 20, 5, 12, 8);
map[8][25] = 3; // save
map[9][55] = 1; // trap
map[8][55] = 1; map[9][56] = 1; // more traps
} else if (floor === 2) {
// Dungeon 2 - Flame Sword area
room(5, 5, 12, 8);
room(22, 5, 8, 6);
room(35, 5, 10, 8);
room(5, 18, 15, 8);
room(25, 18, 10, 10);
room(45, 18, 15, 8);
room(5, 32, 12, 10);
room(25, 32, 15, 8);
room(48, 32, 10, 8);
room(25, 45, 20, 10); // Boss room
addCorridor(map, 16, 8, 22, 8);
addCorridor(map, 30, 8, 35, 8);
addCorridor(map, 15, 22, 25, 22);
addCorridor(map, 35, 22, 45, 22);
addCorridor(map, 10, 26, 10, 32);
addCorridor(map, 25, 28, 25, 32);
addCorridor(map, 35, 36, 48, 36);
addCorridor(map, 25, 40, 25, 45);
addWalls(map, 22, 5, 8, 6, 0.15);
addWalls(map, 35, 5, 10, 8, 0.1);
addWalls(map, 25, 18, 10, 10, 0.08);
addDecorations(map, 22, 5, 8, 6);
map[8][25] = 3;
map[9][40] = 6; // decoration
map[35][30] = 6;
} else if (floor === 3) {
// Dungeon 3 - Forest/Cave, Bow area
room(5, 5, 10, 8);
room(20, 5, 12, 8);
room(37, 5, 8, 6);
room(5, 18, 12, 10);
room(25, 18, 10, 8);
room(42, 18, 10, 8);
room(5, 33, 15, 8);
room(28, 33, 10, 8);
room(45, 33, 12, 8);
room(25, 45, 18, 10); // Boss
addCorridor(map, 14, 8, 20, 8);
addCorridor(map, 31, 8, 37, 8);
addCorridor(map, 12, 23, 25, 23);
addCorridor(map, 35, 22, 42, 22);
addCorridor(map, 15, 28, 15, 33);
addCorridor(map, 30, 26, 30, 33);
addCorridor(map, 15, 37, 28, 37);
addCorridor(map, 35, 37, 45, 37);
addCorridor(map, 28, 41, 28, 45);
addWalls(map, 20, 5, 12, 8, 0.1);
addWalls(map, 5, 18, 12, 10, 0.08);
addWalls(map, 42, 18, 10, 8, 0.12);
addDecorations(map, 20, 5, 12, 8);
map[8][25] = 3;
map[22][18] = 6;
} else if (floor === 4) {
// Final dungeon
room(5, 5, 12, 8); // entrance
room(22, 5, 8, 6);
room(35, 5, 10, 8);
room(5, 18, 10, 8);
room(22, 18, 12, 10);
room(42, 18, 10, 8);
room(5, 32, 15, 8);
room(28, 32, 10, 8);
room(45, 32, 12, 10);
room(10, 45, 20, 10); // Final boss
addCorridor(map, 16, 8, 22, 8);
addCorridor(map, 30, 8, 35, 8);
addCorridor(map, 14, 22, 22, 22);
addCorridor(map, 33, 22, 42, 22);
addCorridor(map, 10, 26, 10, 32);
addCorridor(map, 28, 28, 28, 32);
addCorridor(map, 15, 36, 28, 36);
addCorridor(map, 33, 36, 45, 36);
addCorridor(map, 18, 40, 18, 45);
addWalls(map, 22, 5, 8, 6, 0.12);
addWalls(map, 5, 18, 10, 8, 0.1);
addWalls(map, 22, 18, 12, 10, 0.08);
addDecorations(map, 22, 5, 8, 6);
map[8][25] = 3;
map[8][40] = 3;
map[22][15] = 3;
}
return { map, rooms };
}
// ── const: WEAPONS ──
const WEAPONS = [
{ name: 'Iron Sword', dmg: 3, range: 42, speed: 400, color: COLORS.sword, type: 'melee', upgrade: 0 },
{ name: 'Flame Sword', dmg: 6, range: 48, speed: 350, color: COLORS.flameSword, type: 'melee', burn: true, upgrade: 1 },
{ name: 'Shadow Blade', dmg: 10, range: 56, speed: 300, color: COLORS.shadowBlade, type: 'melee', shieldPierce: true, upgrade: 2 },
];
// ── const: BOW ──
const BOW = { name: 'Hunter Bow', dmg: 4, range: 200, speed: 600, color: COLORS.bow, type: 'ranged' };
// ── const: ITEMS_DB ──
const ITEMS_DB = [
{ id: 'health_potion', name: 'Health Potion', type: 'consumable', price: 15, desc: 'Restore 4 HP', heal: 4 },
{ id: 'gold_ring', name: 'Gold Ring', type: 'accessory', price: 50, desc: '+1 Damage', dmgBonus: 1 },
{ id: 'iron_shield', name: 'Iron Shield', type: 'accessory', price: 60, desc: '-1 Damage taken', defBonus: 1 },
{ id: 'speed_boots', name: 'Swift Boots', type: 'accessory', price: 40, desc: '+20% speed', speedMult: 1.2 },
{ id: 'crystal_orb', name: 'Crystal Orb', type: 'accessory', price: 80, desc: '+2 Max HP', maxHpBonus: 2 },
{ id: 'magic_stone', name: 'Magic Stone', type: 'consumable', price: 30, desc: 'Restore all HP', fullHeal: true },
{ id: 'mana_gem', name: 'Mana Gem', type: 'accessory', price: 100, desc: 'Arrow speed +30%', rangedSpd: 0.7 },
{ id: 'silver_amulet', name: 'Silver Amulet', type: 'accessory', price: 70, desc: 'Gold find +50%', goldBonus: 1.5 },
{ id: 'thorn_vest', name: 'Thorn Vest', type: 'accessory', price: 90, desc: 'Reflect 2 dmg', reflect: 2 },
{ id: 'lucky_charm', name: 'Lucky Charm', type: 'accessory', price: 120, desc: '2x drop chance', dropBonus: 2 },
];
// ── function: isWalkable ──
function isWalkable(map, tx, ty, passThroughDoor = true) {
if (tx < 0 || ty < 0 || tx >= MAP_W || ty >= MAP_H) return false;
const t = map[ty][tx];
return t === 0 || t === 2 || t === 3 || t === 6;
}
// ── function: dist ──
function dist(x1, y1, x2, y2) {
return Math.sqrt((x2-x1)**2 + (y2-y1)**2);
}
// ── function: ang ──
function ang(x1, y1, x2, y2) {
return Math.atan2(y2-y1, x2-x1);
}
// ── function: overlaps ──
function overlaps(a, b) {
const hw = (a.size + b.size) * TILE / 2 * 0.8;
return dist(a.x, a.y, b.x, b.y) < hw;
}
// ── function: rectOverlap ──
function rectOverlap(a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}
// ── function: rng ──
function rng(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
// ── function: lerp ──
function lerp(a, b, t) { return a + (b - a) * t; }
// ── function: getRoomCenter ──
function getRoomCenter(room) {
return { x: (room.x + room.w / 2) * TILE, y: (room.y + room.h / 2) * TILE };
}
// ── function: placeItemOnMap ──
function placeItemOnMap(map, items, enemies, savePoint, floor, center) {
if (!center) {
// random floor tile
let rx, ry;
do { rx = rng(3, MAP_W-4); ry = rng(3, MAP_H-4); } while (map[ry][rx] === 1);
center = { x: rx * TILE + TILE/2, y: ry * TILE + TILE/2 };
}
items.push({
x: center.x, y: center.y,
type: 'pickup', subType: floor === 0 ? 'gold' : (Math.random() < 0.3 ? 'gold' : 'item'),
item: floor === 0 ? null : ITEMS_DB[rng(0, Math.min(2, ITEMS_DB.length-1))],
gold: rng(5, 15),
size: 0.5, bobPhase: Math.random() * Math.PI * 2
});
}
// ── function: spawnEnemiesForFloor ──
function spawnEnemiesForFloor(enemies, map, floor) {
enemies.length = 0;
const floorMap = map;
if (floor === 0) return; // Town - no enemies
const types = [];
if (floor === 1) types.push('slime','slime','slime','bat','bat');
else if (floor === 2) types.push('slime','slime','bat','skeleton','skeleton');
else if (floor === 3) types.push('bat','bat','skeleton','skeleton','skeleton','bat');
else types.push('skeleton','skeleton','darkKnight','darkKnight','skeleton','bat');
for (const et of types) {
let rx, ry;
do { rx = rng(4, MAP_W-4); ry = rng(4, MAP_H-4); } while (floorMap[ry][rx] === 1);
const s = ENEMY_STATS[et];
enemies.push({
x: rx * TILE + TILE/2, y: ry * TILE + TILE/2,
type: et, hp: s.hp, maxHp: s.hp, dmg: s.dmg, speed: s.speed,
color: s.color, size: s.size, exp: s.exp, flying: s.flying || false,
alive: true, attackTimer: 0, state: 'patrol', patrolAngle: Math.random() * Math.PI * 2,
stateTimer: rng(60, 180), hurtTimer: 0, knockbackX: 0, knockbackY: 0
});
}
// Boss on floor 4
if (floor === 4) {
const bossRoom = null;
for (let ry = 45; ry < 55; ry++)
for (let rx = 10; rx < 30; rx++)
if (floorMap[ry][rx] === 0) {
enemies.push({
x: 20 * TILE + TILE/2, y: 49 * TILE + TILE/2,
type: 'darkKnight', hp: 40, maxHp: 40, dmg: 6, speed: 1.4,
color: COLORS.enemyDarkKnight, size: 1.3, exp: 10, flying: false,
alive: true, attackTimer: 0, state: 'idle', patrolAngle: 0,
stateTimer: 30, hurtTimer: 0, knockbackX: 0, knockbackY: 0,
isBoss: true
});
}
}
}
// ── function: placeItemsOnMap ──
function placeItemsOnMap(map, items, floor) {
items.length = 0;
if (floor === 0) {
// Starting gold
items.push({ x: 10*TILE+16, y: 10*TILE+16, type: 'pickup', subType: 'gold', gold: rng(10,25), size: 0.5, bobPhase: 0 });
}
if (floor === 1) {
[
{x:25*TILE+16, y:8*TILE+16}, {x:8*TILE+16, y:22*TILE+16},
{x:35*TILE+16, y:24*TILE+16}, {x:52*TILE+16, y:12*TILE+16}
].forEach(p => placeItemOnMap(map, items, null, null, floor, p));
// Extra gold pile
items.push({ x: 42*TILE+16, y: 10*TILE+16, type: 'pickup', subType: 'gold', gold: rng(15,30), size: 0.5, bobPhase: 1 });
}
if (floor === 2) {
[
{x:24*TILE+16, y:7*TILE+16}, {x:40*TILE+16, y:7*TILE+16},
{x:10*TILE+16, y:22*TILE+16}, {x:48*TILE+16, y:22*TILE+16},
{x:8*TILE+16, y:36*TILE+16}, {x:30*TILE+16, y:36*TILE+16}
].forEach(p => placeItemOnMap(map, items, null, null, floor, p));
}
if (floor === 3) {
[
{x:25*TILE+16, y:8*TILE+16}, {x:40*TILE+16, y:8*TILE+16},
{x:8*TILE+16, y:22*TILE+16}, {x:46*TILE+16, y:22*TILE+16},
{x:10*TILE+16, y:36*TILE+16}, {x:50*TILE+16, y:36*TILE+16}
].forEach(p => placeItemOnMap(map, items, null, null, floor, p));
}
if (floor === 4) {
[
{x:24*TILE+16, y:8*TILE+16}, {x:38*TILE+16, y:8*TILE+16},
{x:8*TILE+16, y:22*TILE+16}, {x:46*TILE+16, y:22*TILE+16},
{x:12*TILE+16, y:36*TILE+16}, {x:50*TILE+16, y:36*TILE+16}
].forEach(p => placeItemOnMap(map, items, null, null, floor, p));
}
}
// ── function: findDoor ──
function findDoor(map, tx, ty) {
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
for (const [dx,dy] of dirs) {
if (map[ty+dy] && map[ty+dy][tx+dx] === 2) return { x: (tx+dx)*TILE+TILE/2, y: (ty+dy)*TILE+TILE/2 };
}
return null;
}
// ── function: createPlayer ──
function createPlayer(startX, startY) {
return {
x: startX, y: startY,
size: 0.8,
hp: 6, maxHp: 6,
speed: 2.2,
weaponIdx: 0,
bowUnlocked: false,
inventory: [],
accessories: [],
money: 30,
facing: 0,
attackTimer: 0,
attackCooldown: 400,
hurtTimer: 0,
invincible: 0,
moveAnim: 0,
walking: false,
stepTimer: 0,
damage: 0,
bowAmmo: 10,
bowTimer: 0,
bowCooldown: 600,
xp: 0,
level: 1,
xpToNext: 10,
shieldPierce: false,
dmgBonus: 0,
defBonus: 0,
speedMult: 1,
reflect: 0,
dead: false,
deathTimer: 0
};
}
// ── function: getDmgBonus ──
function getDmgBonus(p) {
let b = 0;
for (const acc of p.accessories) { if (acc.dmgBonus) b += acc.dmgBonus; }
return b;
}
// ── function: getDefBonus ──
function getDefBonus(p) {
let d = 0;
for (const acc of p.accessories) { if (acc.defBonus) d += acc.defBonus; }
return d;
}
// ── function: getSpeedMult ──
function getSpeedMult(p) {
let m = 1;
for (const acc of p.accessories) { if (acc.speedMult) m *= acc.speedMult; }
return m;
}
// ── function: getReflect ──
function getReflect(p) {
let r = 0;
for (const acc of p.accessories) { if (acc.reflect) r += acc.reflect; }
return r;
}
// ── function: getRangedSpd ──
function getRangedSpd(p) {
let m = 1;
for (const acc of p.accessories) { if (acc.rangedSpd) m *= acc.rangedSpd; }
return m;
}
// ── function: addAccessory ──
function addAccessory(p, item) {
if (p.accessories.length >= 4) return false;
if (p.accessories.find(a => a.id === item.id)) return false;
p.accessories.push(item);
p.maxHp += (item.maxHpBonus || 0);
p.hp = Math.min(p.hp, p.maxHp);
p.dmgBonus = getDmgBonus(p);
p.defBonus = getDefBonus(p);
p.speedMult = getSpeedMult(p);
p.reflect = getReflect(p);
return true;
}
// ── function: createSaveSystem ──
function createSaveSystem() {
let savePoints = [];
let saveData = null;
function loadSave() { try { const s = localStorage.getItem('re_save'); if (s) saveData = JSON.parse(s); } catch(e) {} }
function saveToStorage(p, floor, mapData, enemies, items) {
// Deep copy essentials
const data = {
hp: p.hp, maxHp: p.maxHp, money: p.money, weaponIdx: p.weaponIdx,
bowUnlocked: p.bowUnlocked, inventory: p.inventory, accessories: p.accessories,
xp: p.xp, level: p.level, floor: floor,
x: p.x, y: p.y,
bowAmmo: p.bowAmmo,
savePoints: JSON.parse(JSON.stringify(savePoints))
};
localStorage.setItem('re_save', JSON.stringify(data));
}
function applyFromSave(p, saveData2) {
p.hp = saveData2.hp; p.maxHp = saveData2.maxHp; p.money = saveData2.money;
p.weaponIdx = saveData2.weaponIdx; p.bowUnlocked = saveData2.bowUnlocked;
p.inventory = saveData2.inventory || []; p.accessories = saveData2.accessories || [];
p.xp = saveData2.xp || 0; p.level = saveData2.level || 1;
p.x = saveData2.x || p.x; p.y = saveData2.y || p.y;
p.bowAmmo = saveData2.bowAmmo || 10;
p.inventory.forEach(id => { const it = ITEMS_DB.find(i => i.id === id); if(it) p.inventory.push(it); });
}
return { savePoints, saveData, loadSave, saveToStorage, applyFromSave };
}
// ── function: createShop ──
function createShop() {
let open = false;
let selectedIdx = 0;
let tick = 0;
function isOpen() { return open; }
function openShop() { open = true; selectedIdx = 0; tick = 0; }
function closeShop() { open = false; }
function canBuy(item) { return true; } // player money check in main
function tickShop(p) {
if (!open) return;
tick++;
}
return { isOpen, openShop, closeShop, canBuy, tickTick: tickShop };
}
// ── function: updateEnemy ──
function updateEnemy(e, dt, map, player) {
if (!e.alive || e.dead) return;
e.hurtTimer = Math.max(0, e.hurtTimer - dt);
e.attackTimer = Math.max(0, e.attackTimer - dt);
const dx = player.x - e.x;
const dy = player.y - e.y;
const d = dist(e.x, e.y, player.x, player.y);
const aggroRange = e.isBoss ? 250 : 200;
if (d < aggroRange && player.invincible <= 0) {
e.state = 'chase';
e.patrolAngle = ang(e.x, e.y, player.x, player.y);
} else {
e.stateTimer -= dt / 16;
if (e.stateTimer <= 0) {
e.state = 'patrol';
e.patrolAngle += (Math.random() - 0.5) * 2;
e.stateTimer = rng(60, 180);
}
}
const spd = e.speed * (dt / 16);
let moveX = 0, moveY = 0;
if (e.state === 'chase') {
const targetAngle = ang(e.x, e.y, player.x, player.y);
moveX = Math.cos(targetAngle);
moveY = e.flying ? Math.sin(targetAngle) * 0.5 : Math.sin(targetAngle);
e.stateTimer = 999;
} else if (e.state === 'patrol') {
moveX = Math.cos(e.patrolAngle);
moveY = e.flying ? Math.sin(e.patrolAngle) * 0.3 : Math.sin(e.patrolAngle);
}
// Apply knockback
if (e.knockbackX || e.knockbackY) {
e.x += e.knockbackX * dt / 16;
e.y += e.knockbackY * dt / 16;
e.knockbackX *= 0.85;
e.knockbackY *= 0.85;
return; // No movement during knockback
}
const s = e.size * TILE * 0.4;
const nx = e.x + moveX * spd;
const ny = e.y + moveY * spd;
const ntx = Math.floor(nx / TILE);
const nty = Math.floor(ny / TILE);
if (isWalkable(map, ntx, Math.floor(e.y / TILE))) e.x = nx;
if (isWalkable(map, Math.floor(e.x / TILE), nty)) e.y = ny;
// Attack
if (d < TILE * 1.2 && e.attackTimer <= 0) {
e.attackTimer = e.isBoss ? 800 : 1500;
if (player.hurtTimer <= 0 && player.invincible <= 0) {
let dmg = Math.max(1, e.dmg - getDefBonus(player));
player.hp -= dmg;
player.hurtTimer = 30;
player.invincible = 500;
player.knockbackX = moveX * 3;
player.knockbackY = moveY * 3;
return dmg;
}
}
return 0;
}
// ── function: attackPlayer ──
function attackPlayer(p, map, enemies, particles, audio) {
const w = WEAPONS[p.weaponIdx];
if (p.attackTimer > 0 || p.dead) return false;
p.attackTimer = w.speed;
audio.swing();
let hit = false;
for (const e of enemies) {
if (!e.alive) continue;
const wAngle = p.facing;
const eAngle = ang(p.x, p.y, e.x, e.y);
let angleDiff = Math.abs(eAngle - wAngle);
if (angleDiff > Math.PI) angleDiff = Math.PI * 2 - angleDiff;
const d = dist(p.x, p.y, e.x, e.y);
if (d < w.range && angleDiff < Math.PI / 2.5) {
let dmg = w.dmg + p.dmgBonus;
if (e.isBoss) dmg = Math.floor(dmg * 0.8); // Bosses take slightly less
e.hp -= dmg;
e.hurtTimer = 15;
const kba = ang(p.x, p.y, e.x, e.y);
e.knockbackX = Math.cos(kba) * 4;
e.knockbackY = Math.sin(kba) * 4;
if (w.burn) { e.hp -= 1; particles.emit(e.x, e.y, 5, '#ff6622', 2, 20); }
particles.emit(e.x, e.y, 4, '#ffffff', 2, 15);
audio.hit();
hit = true;
}
}
return hit;
}
// ── function: shootBow ──
function shootBow(p, map, enemies, particles, audio) {
if (p.bowTimer > 0 || p.dead) return;
if (p.bowAmmo <= 0) return;
p.bowAmmo--;
p.bowTimer = BOW.speed * getRangedSpd(p);
audio.swing();
const spd = 5;
const px = p.x + Math.cos(p.facing) * TILE;
const py = p.y + Math.sin(p.facing) * TILE;
projectiles.push({
x: px, y: py, vx: Math.cos(p.facing) * spd, vy: Math.sin(p.facing) * spd,
dmg: BOW.dmg + p.dmgBonus, range: BOW.range, traveled: 0,
color: '#aa8855', size: 3
});
particles.emit(px, py, 3, '#88aa66', 1, 10, 2);
}
// ── let: projectiles ──
let projectiles = [];
// ── function: updateProjectiles ──
function updateProjectiles(particles, enemies, audio) {
for (let i = projectiles.length - 1; i >= 0; i--) {
const proj = projectiles[i];
proj.x += proj.vx;
proj.y += proj.vy;
proj.traveled += dist(proj.x - proj.vx, proj.y - proj.vy, proj.x, proj.y);
if (proj.traveled > proj.range) { projectiles.splice(i, 1); continue; }
const tx = Math.floor(proj.x / TILE);
const ty = Math.floor(proj.y / TILE);
if (!isWalkable([null], tx, ty)) { projectiles.splice(i, 1); particles.emit(proj.x, proj.y, 3, proj.color, 1, 10, 2); continue; }
let hit = false;
for (const e of enemies) {
if (!e.alive) continue;
if (dist(proj.x, proj.y, e.x, e.y) < TILE * 0.6) {
e.hp -= proj.dmg;
e.hurtTimer = 15;
const kba = ang(proj.x, proj.y, e.x, e.y);
e.knockbackX = Math.cos(kba) * 2;
e.knockbackY = Math.sin(kba) * 2;
particles.emit(e.x, e.y, 3, '#ffcc44', 2, 15);
audio.hit();
hit = true;
break;
}
}
if (hit) projectiles.splice(i, 1);
}
}
// ── function: createMessageSystem ──
function createMessageSystem() {
let messages = [];
let showInventory = false;
let showShop = false;
let showDoorPrompt = false;
let showSavePrompt = false;
let showDeath = false;
let showVictory = false;
let showMessage = '';
const shopItems = [];
function addMessage(text) { messages.push({ text, timer: 180 }); }
function addMessages(texts) { texts.forEach(t => addMessage(t)); }
function tick() { for (let i = messages.length - 1; i >= 0; i--) { messages[i].timer--; if (messages[i].timer <= 0) messages.splice(i, 1); } }
function hasText() { return messages.length > 0 ? messages[messages.length-1].text : ''; }
return { messages, showInventory, showShop, showDoorPrompt, showSavePrompt, showDeath, showVictory,
showMessage, shopItems,
addMessage, addMessages, tick, hasText,
openInv() { showInventory = !showInventory; showShop = false; },
openShop(available) { showShop = true; showInventory = false; shopItems.length = 0; shopItems.push(...available); selectedShopIdx = 0; tickShop2 = 0; },
closeShop() { showShop = false; },
toggleDoor() { showDoorPrompt = !showDoorPrompt; },
toggleSave() { showSavePrompt = !showSavePrompt; },
renderDeath() { showDeath = true; },
renderVictory() { showVictory = true; },
clearMessage() { messages = []; },
showMessage(text) { showMessage = text; }
};
}
// ── const: msgSystem ──
const msgSystem = createMessageSystem();
// ── let: selectedShopIdx ──
let selectedShopIdx = 0;
// ── let: tickShop2 ──
let tickShop2 = 0;
// ── function: updatePlayer ──
function updatePlayer(p, input, map, particles, audio, floor) {
if (p.dead) { p.deathTimer += 1; return; }
p.hurtTimer = Math.max(0, p.hurtTimer - 1);
p.invincible = Math.max(0, p.invincible - 1);
p.attackTimer = Math.max(0, p.attackTimer - 1);
p.bowTimer = Math.max(0, p.bowTimer - 1);
var mx = 0, my = 0;
if (input.keys['arrowleft'] || input.keys['a']) mx -= 1;
if (input.keys['arrowright'] || input.keys['d']) mx += 1;
if (input.keys['arrowup'] || input.keys['w']) my -= 1;
if (input.keys['arrowdown'] || input.keys['s']) my += 1;
var walk = mx !== 0 || my !== 0;
p.walking = walk;
if (walk) {
var spd = p.speed * getSpeedMult(p) * (input.keys['shift'] ? 1.6 : 1);
var len = Math.sqrt(mx*mx + my*my);
mx /= len; my /= len;
var nx = p.x + mx * spd;
var ny = p.y + my * spd;
if (isWalkable(map, Math.floor(nx / TILE), Math.floor(p.y / TILE))) p.x = nx;
if (isWalkable(map, Math.floor(p.x / TILE), Math.floor(ny / TILE))) p.y = ny;
p.facing = Math.atan2(my, mx);
p.moveAnim += 0.25;
p.stepTimer -= 1;
if (p.stepTimer <= 0) { audio.step(); p.stepTimer = rng(10, 16); }
} else { p.stepTimer = 999; }
if (input.justPressed[' '] || input.justPressed['j']) attackPlayer(p, map, allEnemies, particles, audio);
if (input.justPressed['e']) {
if (p.bowUnlocked) shootBow(p, map, allEnemies, particles, audio);
}
if (input.justPressed['q']) { p.weaponIdx = (p.weaponIdx + 1) % WEAPONS.length; }
if (input.justPressed['j2']) { p.weaponIdx = (p.weaponIdx + 2) % WEAPONS.length; }
// Pickup items
for (var i = p._pickupIdx || 0; i < floorItems.length; i++) {
var item = floorItems[i];
if (dist(p.x, p.y, item.x, item.y) < TILE * 0.8) {
audio.pickup();
if (item.subType === 'gold') {
var mult = p.accessories.find(function(a){return a.goldBonus;}) ? 1.5 : 1;
p.money += Math.floor(item.gold * mult);
particles.emit(item.x, item.y, 6, COLORS.money, 2, 20, 3);
} else if (item.subType === 'item') {
var it = item.item;
if (it.type === 'consumable') {
if (it.heal) { p.hp = Math.min(p.hp + it.heal, p.maxHp); }
if (it.fullHeal) { p.hp = p.maxHp; }
} else if (it.type === 'accessory') {
addAccessory(p, it);
} else {
p.inventory.push(it);
}
particles.emit(item.x, item.y, 8, it.color || '#ffcc00', 3, 25, 3);
}
floorItems.splice(i, 1);
p._pickupIdx = -1;
break;
}
}
if (p.hp <= 0) {
p.dead = true;
msgSystem.renderDeath();
audio.hurt();
particles.emit(p.x, p.y, 20, '#ff3355', 4, 40, 4);
}
if (p.xp >= p.xpToNext) {
p.xp -= p.xpToNext;
p.level++;
p.maxHp += 2;
p.hp = Math.min(p.hp + 4, p.maxHp);
p.xpToNext = Math.floor(p.xpToNext * 1.5);
particles.emit(p.x, p.y, 15, '#44ff88', 3, 30, 4);
audio.levelUp();
}
}
// ── function: renderGame ──
function renderGame(ctx, cam, map, player, enemies, items, floor) {
ctx.fillStyle = '#0a0a14';
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
const startX = Math.floor(cam.x / TILE);
const startY = Math.floor(cam.y / TILE);
const endX = startX + COLS + 1;
const endY = startY + ROWS + 1;
for (let y = startY; y < endY && y < MAP_H; y++) {
for (let x = startX; x < endX && x < MAP_W; x++) {
const sx = Math.floor(x * TILE - cam.x);
const sy = Math.floor(y * TILE - cam.y);
const tile = map[y] ? map[y][x] : 1;
if (tile === 1) {
ctx.fillStyle = (x + y) % 2 === 0 ? COLORS.wallTop : COLORS.wall;
if (y > 0 && map[y-1][x] === 0) {
ctx.fillStyle = COLORS.wallTop;
ctx.fillRect(sx, sy, TILE, TILE);
} else {
ctx.fillRect(sx, sy, TILE, TILE);
}
} else if (tile === 0) {
ctx.fillStyle = (x + y) % 2 === 0 ? COLORS.floor : COLORS.floorAlt;
ctx.fillRect(sx, sy, TILE, TILE);
} else if (tile === 2) {
ctx.fillStyle = COLORS.wall;
ctx.fillRect(sx, sy, TILE, TILE);
ctx.fillStyle = COLORS.door;
ctx.fillRect(sx + 4, sy, TILE - 8, TILE);
ctx.fillStyle = '#5a4a30';
ctx.fillRect(sx + 2, sy, 2, TILE);
ctx.fillRect(sx + TILE - 4, sy, 2, TILE);
ctx.fillStyle = '#ccaa44';
ctx.beginPath();
ctx.arc(sx + TILE - 8, sy + TILE/2, 2, 0, Math.PI*2);
ctx.fill();
} else if (tile === 3) {
ctx.fillStyle = COLORS.floor;
ctx.fillRect(sx, sy, TILE, TILE);
const pulse = Math.sin(Date.now() / 300) * 0.3 + 0.7;
ctx.globalAlpha = pulse;
ctx.fillStyle = '#4488ff';
ctx.shadowColor = '#4488ff';
ctx.shadowBlur = 10;
ctx.fillRect(sx + 6, sy + 6, TILE - 12, TILE - 12);
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
ctx.strokeStyle = '#88bbff';
ctx.lineWidth = 1;
ctx.strokeRect(sx + 6, sy + 6, TILE - 12, TILE - 12);
} else if (tile === 4) {
ctx.fillStyle = '#1a1a3e';
ctx.fillRect(sx, sy, TILE, TILE);
ctx.fillStyle = '#2244aa';
ctx.globalAlpha = 0.5 + Math.sin(Date.now()/500 + x*2 + y*3) * 0.2;
ctx.fillRect(sx, sy, TILE, TILE);
ctx.globalAlpha = 1;
} else if (tile === 6) {
ctx.fillStyle = (x + y) % 2 === 0 ? COLORS.floor : COLORS.floorAlt;
ctx.fillRect(sx, sy, TILE, TILE);
ctx.fillStyle = '#1e3a1e';
ctx.fillRect(sx + 10, sy + 12, 4, 12);
ctx.beginPath();
ctx.arc(sx + 12, sy + 14, 6, 0, Math.PI*2);
ctx.fill();
}
}
}
// Draw items
for (const item of items) {
const sx = Math.floor(item.x - cam.x);
const sy = Math.floor(item.y - cam.y + Math.sin(Date.now()/400 + item.bobPhase) * 3);
if (sx < -20 || sx > VIEW_W + 20 || sy < -20 || sy > VIEW_H + 20) continue;
if (item.subType === 'gold') {
ctx.fillStyle = COLORS.money;
ctx.beginPath();
ctx.arc(sx, sy, 4, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = '#ffe844';
ctx.beginPath();
ctx.arc(sx - 1, sy - 1, 2, 0, Math.PI*2);
ctx.fill();
} else if (item.subType === 'item') {
ctx.fillStyle = item.item.type === 'consumable' ? COLORS.healthPotion : '#aa8855';
ctx.shadowColor = item.item.type === 'consumable' ? '#ff4466' : '#66aa44';
ctx.shadowBlur = 6;
ctx.fillRect(sx - 5, sy - 5, 10, 10);
ctx.shadowBlur = 0;
ctx.fillStyle = '#ffffff';
ctx.font = '8px monospace';
ctx.textAlign = 'center';
ctx.fillText(item.item.type === 'consumable' ? '+' : '✦', sx, sy + 3);
}
}
// Draw enemies
for (const e of enemies) {
if (!e.alive) continue;
const sx = Math.floor(e.x - cam.x);
const sy = Math.floor(e.y - cam.y);
if (sx < -40 || sx > VIEW_W + 40 || sy < -40 || sy > VIEW_H + 40) continue;
const flash = e.hurtTimer > 0 && e.hurtTimer % 4 < 2;
ctx.fillStyle = flash ? '#ffffff' : e.color;
if (e.flying) {
const wingFlap = Math.sin(Date.now() / 80) * 4;
ctx.beginPath();
ctx.ellipse(sx, sy, 8 + wingFlap, 6, 0, 0, Math.PI*2);
ctx.fill();
} else {
const bounce = Math.abs(Math.sin(Date.now()/200)) * 2;
ctx.beginPath();
ctx.arc(sx, sy - bounce, e.size * 10, 0, Math.PI*2);
ctx.fill();
}
// Eyes
if (e.type !== 'slime') {
ctx.fillStyle = '#ff3333';
ctx.fillRect(sx - 3, sy - e.size*8, 2, 2);
ctx.fillRect(sx + 1, sy - e.size*8, 2, 2);
}
// Boss crown
if (e.isBoss) {
ctx.fillStyle = '#ffcc00';
ctx.fillRect(sx - 6, sy - e.size * 14, 12, 4);
ctx.fillRect(sx - 6, sy - e.size * 14, 2, -4);
ctx.fillRect(sx - 2, sy - e.size * 14, 2, -5);
ctx.fillRect(sx + 2, sy - e.size * 14, 2, -4);
}
// HP bar
if (e.hp < e.maxHp) {
const barW = 20;
ctx.fillStyle = '#333';
ctx.fillRect(sx - barW/2, sy - e.size*16 - 6, barW, 3);
ctx.fillStyle = e.hp / e.maxHp > 0.5 ? '#44cc44' : (e.hp / e.maxHp > 0.25 ? '#cccc44' : '#cc4444');
ctx.fillRect(sx - barW/2, sy - e.size*16 - 6, barW * (e.hp / e.maxHp), 3);
}
}
// Draw projectiles
for (const proj of projectiles) {
const sx = Math.floor(proj.x - cam.x);
const sy = Math.floor(proj.y - cam.y);
ctx.fillStyle = proj.color;
ctx.beginPath();
ctx.arc(sx, sy, proj.size, 0, Math.PI*2);
ctx.fill();
// Trail
ctx.globalAlpha = 0.3;
ctx.beginPath();
ctx.arc(sx - proj.vx*2, sy - proj.vy*2, proj.size*0.7, 0, Math.PI*2);
ctx.fill();
ctx.globalAlpha = 1;
}
// Draw player
const px = Math.floor(player.x - cam.x);
const py = Math.floor(player.y - cam.y);
const pFlash = player.hurtTimer > 0 && player.hurtTimer % 4 < 2;
const pBlink = player.invincible > 0 && Math.floor(player.invincible / 4) % 2;
if (pBlink) ctx.globalAlpha = 0.4;
ctx.fillStyle = pFlash ? '#ffaaaa' : COLORS.player;
const bodyBounce = player.walking ? Math.abs(Math.sin(player.moveAnim)) * 2 : 0;
ctx.beginPath();
ctx.arc(px, py - bodyBounce, 11, 0, Math.PI*2);
ctx.fill();
// Body
ctx.fillStyle = COLORS.playerDark;
ctx.fillRect(px - 7, py - 4 - bodyBounce, 14, 12);
// Legs with walking animation
if (player.walking) {
const legAnim = Math.sin(player.moveAnim * 2) * 3;
ctx.fillStyle = COLORS.playerDark;
ctx.fillRect(px - 5, py + 8, 4, 4 + legAnim);
ctx.fillRect(px + 1, py + 8, 4, 4 - legAnim);
} else {
ctx.fillStyle = COLORS.playerDark;
ctx.fillRect(px - 5, py + 8, 4, 4);
ctx.fillRect(px + 1, py + 8, 4, 4);
}
// Weapon
const w = WEAPONS[player.weaponIdx];
const weaponAngle = player.attackTimer > w.speed * 0.6 ? player.facing + 0.8 : player.facing;
ctx.strokeStyle = w.color;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(px + Math.cos(player.facing) * 8, py - 4 + Math.sin(player.facing) * 8);
ctx.lineTo(px + Math.cos(weaponAngle) * w.range * 0.4, py - 4 + Math.sin(weaponAngle) * w.range * 0.4);
ctx.stroke();
ctx.lineWidth = 1.5;
if (w.burn) {
ctx.fillStyle = '#ff6622';
ctx.shadowColor = '#ff6622';
ctx.shadowBlur = 4;
ctx.beginPath();
ctx.arc(px + Math.cos(weaponAngle) * w.range * 0.4, py - 4 + Math.sin(weaponAngle) * w.range * 0.4, 3, 0, Math.PI*2);
ctx.fill();
ctx.shadowBlur = 0;
}
// Bow icon if unlocked
if (player.bowUnlocked) {
ctx.fillStyle = '#88aa66';
ctx.font = '10px monospace';
ctx.textAlign = 'right';
ctx.fillText('🏹', px + 16, py - 14);
}
ctx.globalAlpha = 1;
// Draw particles
particles.render(ctx, cam);
}
// ── function: renderHUD ──
function renderHUD(ctx, player, floor) {
// Top-left: Hearts
for (let i = 0; i < Math.ceil(player.maxHp / 2); i++) {
const hx = 16 + i * 22;
const hy = 16;
const full = player.hp >= (i + 1) * 2;
const half = player.hp === i * 2 + 1;
if (!full) {
ctx.fillStyle = COLORS.heartEmpty;
drawHeart(ctx, hx, hy, 9);
ctx.fillStyle = COLORS.heartEmpty;
ctx.fill();
ctx.strokeStyle = '#664444';
ctx.lineWidth = 0.5;
ctx.stroke();
} else {
ctx.fillStyle = COLORS.heart;
ctx.shadowColor = '#ff3355';
ctx.shadowBlur = 4;
drawHeart(ctx, hx, hy, 9);
ctx.fill();
ctx.shadowBlur = 0;
}
}
// HP number
ctx.fillStyle = COLORS.uiText;
ctx.font = '10px monospace';
ctx.textAlign = 'left';
ctx.fillText(`${player.hp}/${player.maxHp}`, 16, 42);
// Gold
ctx.fillStyle = COLORS.money;
ctx.font = '11px monospace';
ctx.fillText(`💰 ${player.money}`, 16, 58);
// Weapon
ctx.fillStyle = COLORS.uiText;
ctx.font = '10px monospace';
ctx.fillText(WEAPONS[player.weaponIdx].name, 16, 74);
// Level/XP
ctx.fillText(`Lv.${player.level} (${player.xp}/${player.xpToNext})`, 16, 90);
// Bow ammo
if (player.bowUnlocked) {
ctx.fillStyle = '#88aa66';
ctx.fillText(`🏹 ${player.bowAmmo}`, 16, 106);
}
// Right side: accessory display
for (let i = 0; i < 4; i++) {
const ax = VIEW_W - 24 - i * 20;
const ay = 16;
if (player.accessories[i]) {
ctx.fillStyle = '#4466aa';
ctx.fillRect(ax - 5, ay - 5, 10, 10);
ctx.strokeStyle = '#6688cc';
ctx.lineWidth = 1;
ctx.strokeRect(ax - 5, ay - 5, 10, 10);
ctx.fillStyle = COLORS.uiText;
ctx.font = '6px monospace';
ctx.textAlign = 'center';
ctx.fillText(accShortName(player.accessories[i]), ax, ay + 3);
} else {
ctx.fillStyle = '#222';
ctx.fillRect(ax - 5, ay - 5, 10, 10);
ctx.strokeStyle = '#444';
ctx.lineWidth = 0.5;
ctx.strokeRect(ax - 5, ay - 5, 10, 10);
}
}
// Controls hint (bottom)
ctx.fillStyle = 'rgba(255,255,255,0.3)';
ctx.font = '9px monospace';
ctx.textAlign = 'center';
ctx.fillText('WASD: Move | SPACE: Attack | E: Shoot | F: Interact | TAB: Inventory | 1-2: Weapon', VIEW_W/2, VIEW_H - 8);
}
// ── function: drawHeart ──
function drawHeart(ctx, x, y, size) {
ctx.beginPath();
const topY = y - size * 0.4;
ctx.moveTo(x, topY + size * 0.4);
ctx.bezierCurveTo(x, topY, x - size/2, topY, x - size/2, topY + size * 0.3);
ctx.bezierCurveTo(x - size/2, topY + size * 0.6, x, topY + size * 0.8, x, topY + size);
ctx.bezierCurveTo(x, topY + size * 0.8, x + size/2, topY + size * 0.6, x + size/2, topY + size * 0.3);
ctx.bezierCurveTo(x + size/2, topY, x, topY, x, topY + size * 0.4);
ctx.closePath();
}
// ── function: accShortName ──
function accShortName(acc) {
const m = { gold_ring: 'R', iron_shield: 'S', speed_boots: 'B', crystal_orb: 'O', magic_stone: 'M',
mana_gem: 'G', silver_amulet: 'A', thorn_vest: 'T', lucky_charm: 'L' };
return m[acc.id] || '?';
}
// ── function: renderInventory ──
function renderInventory(ctx, player, floor) {
const panelX = VIEW_W/2 - 180;
const panelY = 60;
const panelW = 360;
const panelH = 480;
ctx.fillStyle = COLORS.uiBg;
ctx.fillRect(panelX, panelY, panelW, panelH);
ctx.strokeStyle = COLORS.uiBorder;
ctx.lineWidth = 2;
ctx.strokeRect(panelX, panelY, panelW, panelH);
ctx.fillStyle = COLORS.uiText;
ctx.font = 'bold 16px monospace';
ctx.textAlign = 'center';
ctx.fillText('INVENTORY', panelX + panelW/2, panelY + 24);
// Tabs
const tabs = ['Equipment', 'Items', 'Stats'];
for (let i = 0; i < tabs.length; i++) {
const tabX = panelX + 20 + i * 115;
const tabY = panelY + 40;
ctx.fillStyle = i === invTab ? '#334' : '#222';
ctx.fillRect(tabX, tabY, 110, 22);
ctx.strokeStyle = COLORS.uiBorder;
ctx.lineWidth = 1;
ctx.strokeRect(tabX, tabY, 110, 22);
ctx.fillStyle = i === invTab ? '#fff' : '#888';
ctx.font = '11px monospace';
ctx.textAlign = 'center';
ctx.fillText(tabs[i], tabX + 55, tabY + 15);
}
if (invTab === 0) {
// Accessories
ctx.font = '11px monospace';
ctx.textAlign = 'left';
let idx = 0;
ctx.fillStyle = '#aaa';
ctx.fillText('Accessories:', panelX + 20, panelY + 80);
for (let i = 0; i < 4; i++) {
const slotX = panelX + 20 + (i % 2) * 160;
const slotY = panelY + 100 + Math.floor(i / 2) * 80;
ctx.fillStyle = '#111';
ctx.fillRect(slotX, slotY, 150, 36);
ctx.strokeStyle = player.accessories[i] ? '#557' : '#333';
ctx.strokeRect(slotX, slotY, 150, 36);
if (player.accessories[i]) {
ctx.fillStyle = '#aaaaff';
ctx.font = 'bold 10px monospace';
ctx.fillText(player.accessories[i].name, slotX + 5, slotY + 14);
ctx.fillStyle = '#888';
ctx.font = '9px monospace';
ctx.fillText(player.accessories[i].desc, slotX + 5, slotY + 28);
} else {
ctx.fillStyle = '#444';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.fillText('Empty', slotX + 75, slotY + 22);
ctx.textAlign = 'left';
}
}
// Current weapons
ctx.fillStyle = '#aaa';
ctx.fillText('Weapons:', panelX + 20, panelY + 280);
for (let i = 0; i < WEAPONS.length; i++) {
const wX = panelX + 20;
const wY = panelY + 295 + i * 30;
const w = WEAPONS[i];
const owned = i <= player.weaponIdx || (i === 0);
const equipped = i === player.weaponIdx;
if (owned) {
ctx.fillStyle = equipped ? '#334' : '#111';
ctx.fillRect(wX, wY, 320, 24);
if (equipped) ctx.strokeStyle = '#88f';
ctx.strokeStyle = '#333';
ctx.strokeRect(wX, wY, 320, 24);
ctx.fillStyle = w.color;
ctx.fillRect(wX + 2, wY + 2, 3, 20);
ctx.fillStyle = COLORS.uiText;
ctx.font = '10px monospace';
ctx.fillText(`${w.name} (DMG: ${w.dmg + player.dmgBonus})`, wX + 10, wY + 16);
if (equipped) { ctx.fillStyle = '#ff0'; ctx.fillText('✦', wX + 305, wY + 16); }
}
}
} else if (invTab === 1) {
// Owned items
ctx.font = '11px monospace';
ctx.textAlign = 'left';
let idx = 0;
const allOwned = [...player.accessories.filter(a => !idx++), ...player.inventory.filter(it => it)];
ctx.fillStyle = '#aaa';
ctx.fillText('Items Found:', panelX + 20, panelY + 80);
const items = [...player.inventory];
// Also show accessories in items list
for (const acc of player.accessories) {
if (!items.find(i2 => i2.id === acc.id)) items.push(acc);
}
const displayItems = [...new Map(items.map(it => [it.id, it])).values()];
for (let i = 0; i < displayItems.length; i++) {
const it = displayItems[i];
const itX = panelX + 20 + (i % 3) * 120;
const itY = panelY + 100 + Math.floor(i / 3) * 60;
if (itY > panelY + panelH - 60) break;
ctx.fillStyle = it.type === 'consumable' ? '#311' : '#112';
ctx.fillRect(itX, itY, 110, 50);
ctx.strokeStyle = it.type === 'consumable' ? '#633' : '#336';
ctx.strokeRect(itX, itY, 110, 50);
ctx.fillStyle = COLORS.uiText;
ctx.font = 'bold 9px monospace';
ctx.fillText(it.name, itX + 4, itY + 14);
ctx.fillStyle = '#888';
ctx.font = '7px monospace';
ctx.fillText(it.desc, itX + 4, itY + 28);
ctx.fillStyle = '#666';
ctx.fillText(`[${it.price}g]`, itX + 4, itY + 42);
}
if (displayItems.length === 0) {
ctx.fillStyle = '#555';
ctx.fillText('No items collected yet.', panelX + 20, panelY + 110);
}
} else {
// Stats
ctx.font = '11px monospace';
ctx.textAlign = 'left';
const stats = [
`Level: ${player.level}`,
`HP: ${player.hp}/${player.maxHp}`,
`Damage: ${WEAPONS[player.weaponIdx].dmg} + ${player.dmgBonus} = ${WEAPONS[player.weaponIdx].dmg + player.dmgBonus}`,
`Defense: ${player.defBonus}`,
`Speed: ${getSpeedMult(player).toFixed(1)}x`,
`Gold: ${player.money}`,
`Floor: ${floor}/5`,
`Bow: ${player.bowUnlocked ? 'Unlocked' : 'Locked'} (${player.bowAmmo} arrows)`,
`Kill XP: ${player.xp}/${player.xpToNext}`,
`Reflect: ${player.reflect} dmg`,
];
for (let i = 0; i < stats.length; i++) {
ctx.fillStyle = i === 0 ? '#ff0' : '#aaa';
ctx.fillText(` ${stats[i]}`, panelX + 30, panelY + 80 + i * 22);
}
}
}
// ── let: invTab ──
let invTab = 0;
// ── function: renderShop ──
function renderShop(ctx, p) {
const panelX = VIEW_W/2 - 200;
const panelY = 40;
const panelW = 400;
const panelH = 520;
ctx.fillStyle = COLORS.uiBg;
ctx.fillRect(panelX, panelY, panelW, panelH);
ctx.strokeStyle = COLORS.uiBorder;
ctx.lineWidth = 2;
ctx.strokeRect(panelX, panelY, panelW, panelH);
ctx.fillStyle = '#ffcc44';
ctx.font = 'bold 16px monospace';
ctx.textAlign = 'center';
ctx.fillText('⚒ FORGE SHOP ⚒', panelX + panelW/2, panelY + 24);
ctx.fillStyle = COLORS.money;
ctx.font = '12px monospace';
ctx.fillText(`Gold: ${p.money}g`, panelX + panelW/2, panelY + 44);
ctx.font = '11px monospace';
ctx.textAlign = 'left';
const shopItems = [
{ ...ITEMS_DB[0], shopId: 0 }, // Health Potion
{ ...ITEMS_DB[3], shopId: 3 }, // Speed Boots
{ ...ITEMS_DB[4], shopId: 4 }, // Crystal Orb (+2maxHp)
{ ...ITEMS_DB[5], shopId: 5 }, // Magic Stone
{ ...ITEMS_DB[6], shopId: 6 }, // Mana Gem
{ ...ITEMS_DB[7], shopId: 7 }, // Silver Amulet
{ ...ITEMS_DB[8], shopId: 8 }, // Thorn Vest
{ ...ITEMS_DB[9], shopId: 9 }, // Lucky Charm
];
// Upgrade weapons
if (p.weaponIdx === 0 && p.level >= 3) {
ctx.fillStyle = '#44aa44';
ctx.fillText('⬆ Upgrade to Flame Sword (50g)', panelX + 16, panelY + 70);
ctx.fillStyle = '#aaa';
ctx.fillText('More damage, burn effect', panelX + 16, panelY + 86);
}
if (p.weaponIdx === 1 && p.level >= 5) {
ctx.fillStyle = '#aa44ff';
ctx.fillText('⬆ Upgrade to Shadow Blade (100g)', panelX + 16, panelY + 108);
ctx.fillStyle = '#aaa';
ctx.fillText('+4 dmg, larger range', panelX + 16, panelY + 124);
}
let idx = (p.weaponIdx === 0 && p.level >= 3) ? 1 : 0;
idx += (p.weaponIdx === 1 && p.level >= 5) ? 1 : 0;
for (let i = 0; i < shopItems.length; i++) {
const item = shopItems[i];
const iy = panelY + 160 + i * 75;
if (iy > panelY + panelH - 80) break;
const selected = (idx + selectedShopIdx) % shopItems.length === i;
if (selected) {
ctx.fillStyle = '#222';
ctx.fillRect(panelX + 8, iy - 4, panelW - 16, 70);
ctx.strokeStyle = selected ? '#aaa' : '#333';
ctx.strokeRect(panelX + 8, iy - 4, panelW - 16, 70);
}
ctx.fillStyle = item.type === 'consumable' ? '#ff6688' : '#88aaff';
ctx.font = 'bold 11px monospace';
ctx.fillText(item.name, panelX + 16, iy + 14);
ctx.fillStyle = '#aaa';
ctx.font = '9px monospace';
ctx.fillText(item.desc, panelX + 16, iy + 30);
ctx.fillStyle = COLORS.money;
ctx.font = '11px monospace';
ctx.fillText(`${item.price}g`, panelX + 300, iy + 30);
ctx.fillStyle = '#666';
ctx.font = '9px monospace';
ctx.fillText('Press ENTER to buy', panelX + 16, iy + 50);
idx++;
}
ctx.fillStyle = '#555';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.fillText('↑↓: Select | ENTER: Buy | ESC: Close', panelX + panelW/2, panelY + panelH - 10);
}
// ── function: renderMessage ──
function renderMessage(ctx) {
if (msgSystem.messages.length === 0) return;
const msg = msgSystem.messages[msgSystem.messages.length - 1];
if (msg.timer <= 0) return;
const alpha = Math.min(1, msg.timer / 30);
ctx.globalAlpha = alpha;
ctx.fillStyle = COLORS.messageBg;
ctx.fillRect(VIEW_W/2 - 150, VIEW_H - 55, 300, 30);
ctx.strokeStyle = '#555';
ctx.lineWidth = 1;
ctx.strokeRect(VIEW_W/2 - 150, VIEW_H - 55, 300, 30);
ctx.fillStyle = COLORS.uiText;
ctx.font = '11px monospace';
ctx.textAlign = 'center';
ctx.fillText(msg.text, VIEW_W/2, VIEW_H - 36);
ctx.globalAlpha = 1;
}
// ── function: renderDeath ──
function renderDeath(ctx) {
ctx.fillStyle = 'rgba(20,0,0,0.85)';
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
ctx.fillStyle = '#ff3344';
ctx.font = 'bold 32px monospace';
ctx.textAlign = 'center';
ctx.fillText('YOU HAVE FALLEN', VIEW_W/2, VIEW_H/2 - 30);
ctx.fillStyle = '#aaa';
ctx.font = '14px monospace';
ctx.fillText(`Floor ${gameFloor} | Level ${player.level}`, VIEW_W/2, VIEW_H/2 + 10);
ctx.fillStyle = '#888';
ctx.font = '12px monospace';
ctx.fillText('Press R to respawn at last save', VIEW_W/2, VIEW_H/2 + 50);
ctx.fillText('Press X to return to title', VIEW_W/2, VIEW_H/2 + 75);
}
// ── function: renderVictory ──
function renderVictory(ctx) {
ctx.fillStyle = 'rgba(0,10,30,0.85)';
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
ctx.fillStyle = '#44ff88';
ctx.font = 'bold 28px monospace';
ctx.textAlign = 'center';
ctx.fillText('VICTORY!', VIEW_W/2, VIEW_H/2 - 50);
ctx.fillStyle = '#aaddff';
ctx.font = '16px monospace';
ctx.fillText('The echoes fade... the realm is saved.', VIEW_W/2, VIEW_H/2 - 10);
ctx.fillStyle = '#aaa';
ctx.font = '12px monospace';
ctx.fillText(`Level ${player.level} | ${player.money} Gold`, VIEW_W/2, VIEW_H/2 + 30);
ctx.fillText('Press X to play again', VIEW_W/2, VIEW_H/2 + 70);
}
// ── function: renderSavePointPrompt ──
function renderSavePointPrompt(ctx, p, floor) {
const panelX = VIEW_W/2 - 120;
const panelY = VIEW_H/2 - 40;
ctx.fillStyle = COLORS.uiBg;
ctx.fillRect(panelX, panelY, 240, 80);
ctx.strokeStyle = '#4488ff';
ctx.lineWidth = 2;
ctx.strokeRect(panelX, panelY, 240, 80);
ctx.fillStyle = '#88bbff';
ctx.font = '12px monospace';
ctx.textAlign = 'center';
ctx.fillText('Save Point', panelX + 120, panelY + 22);
ctx.fillStyle = COLORS.uiText;
ctx.font = '10px monospace';
ctx.fillText('Press ENTER to save', panelX + 120, panelY + 45);
ctx.fillStyle = '#666';
ctx.fillText('Press ESC to cancel', panelX + 120, panelY + 65);
}
// ── function: renderTitleScreen ──
function renderTitleScreen(ctx, p) {
ctx.fillStyle = '#0a0a14';
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
// Animated background particles
const t = Date.now() / 1000;
for (let i = 0; i < 30; i++) {
const px = VIEW_W/2 + Math.sin(t * 0.3 + i * 0.7) * 200 + Math.cos(t * 0.5 + i * 1.1) * 100;
const py = VIEW_H/2 + Math.cos(t * 0.4 + i * 0.5) * 150 + Math.sin(t * 0.3 + i * 0.8) * 80;
ctx.globalAlpha = 0.3;
ctx.fillStyle = ['#4488ff', '#44aa44', '#ff6644', '#aa44ff'][i % 4];
ctx.beginPath();
ctx.arc(px, py, 2 + Math.sin(t + i) , 0, Math.PI*2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.fillStyle = '#aaccff';
ctx.font = 'bold 36px monospace';
ctx.textAlign = 'center';
ctx.fillText('REALM OF ECHOES', VIEW_W/2, VIEW_H/2 - 80);
ctx.fillStyle = '#6688aa';
ctx.font = '13px monospace';
ctx.fillText('An action-adventure of discovery and growth', VIEW_W/2, VIEW_H/2 - 45);
ctx.fillStyle = '#88bbaa';
ctx.font = '14px monospace';
ctx.fillText('Press ENTER to begin your quest', VIEW_W/2, VIEW_H/2 + 20);
ctx.fillStyle = '#555';
ctx.font = '10px monospace';
ctx.fillText('WASD: Move | SPACE: Attack | TAB: Inventory\nF: Interact | E: Arrow Attack | Q: Switch Weapon', VIEW_W/2, VIEW_H - 30);
if (p && p.hp > 0) {
ctx.fillStyle = '#88aaff';
ctx.fillText('Press SHIFT to load saved game', VIEW_W/2, VIEW_H/2 + 60);
ctx.fillStyle = '#666';
ctx.fillText(`Floors explored: ${p.floor || 1} | Level: ${p.level || 1}`, VIEW_W/2, VIEW_H/2 + 90);
}
}
// ── let: gameFloor ──
let gameFloor = 0;
// ── let: player ──
let player = null;
// ── let: allEnemies ──
let allEnemies = [];
// ── let: floorItems ──
let floorItems = [];
// ── let: currentMap ──
let currentMap = null;
// ── let: gameCanvas ──
let gameCanvas = null;
// ── let: gameCtx ──
let gameCtx = null;
// ── let: gameAudio ──
let gameAudio = null;
// ── let: gameParticles ──
let gameParticles = null;
// ── let: gameInput ──
let gameInput = null;
// ── let: gameCamera ──
let gameCamera = null;
// ── let: saveSystem ──
let saveSystem = null;
// ── let: gameState ──
let gameState = 'title';
// ── let: savePointPos ──
// title, playing, inventory, shop, dead, victory
let savePointPos = null;
// ── let: hasLoaded ──
let hasLoaded = false;
// ── let: frameCount ──
let frameCount = 0;
// ── function: loadFloor ──
function loadFloor(floor, fromSave = false) {
gameFloor = Math.min(floor, 4);
const data = buildFloor(gameFloor);
currentMap = data.map;
allEnemies = [];
spawnEnemiesForFloor(allEnemies, currentMap, gameFloor);
floorItems = [];
placeItemsOnMap(currentMap, floorItems, gameFloor);
if (!fromSave) {
if (gameFloor === 0) {
const center = getRoomCenter(data.rooms[0]);
player = createPlayer(center.x, center.y);
} else {
player.x = player._lastX;
player.y = player._lastY;
}
}
// Save last positions before floor change
player._lastX = player.x;
player._lastY = player.y;
// Bow unlock at floor 3 (mid-game)
if (gameFloor >= 3 && !player.bowUnlocked) {
player.bowUnlocked = true;
player.bowAmmo = 15;
msgSystem.addMessages(["You found a Hunter's Bow!", "Press E to shoot arrows."]);
particles.emit(player.x, player.y, 10, '#88aa66', 3, 30, 3);
}
// Health upgrades
if (gameFloor === 2 && player.maxHp < 8) {
player.maxHp = 8;
player.hp = Math.min(player.hp + 2, player.maxHp);
msgSystem.addMessage("You recovered from a healing spring. Max HP +2!");
}
if (gameFloor === 4 && player.maxHp < 10) {
player.maxHp = 10;
player.hp = Math.min(player.hp + 2, player.maxHp);
msgSystem.addMessage("Ancient power flows through you. Max HP +2!");
}
}
// ── function: respawnPlayer ──
function respawnPlayer() {
if (!savePointPos) {
// No save point reached, restart from floor 0
gameFloor = 0;
const data = buildFloor(0);
currentMap = data.map;
allEnemies = [];
floorItems = [];
const center = getRoomCenter(data.rooms[0]);
player = createPlayer(center.x, center.y);
player.money = 30;
loadFloor(0);
} else {
player.x = savePointPos.x;
player.y = savePointPos.y;
player.hp = player.maxHp;
player.dead = false;
player.deathTimer = 0;
player.invincible = 1000;
gameState = 'playing';
msgSystem.addMessage("You revived at the save point.");
audio.heal();
particles.emit(player.x, player.y, 15, '#4488ff', 2, 30, 3);
// Respawn enemies
spawnEnemiesForFloor(allEnemies, currentMap, gameFloor);
}
}
// ── function: updateEnemyStates ──
function updateEnemyStates(dt) {
for (var i = 0; i < allEnemies.length; i++) {
var e = allEnemies[i];
if (!e.alive) continue;
updateEnemy(e, dt, currentMap, player);
for (var j = 0; j < projectiles.length; j++) {
var proj = projectiles[j];
if (dist(proj.x, proj.y, e.x, e.y) < TILE * 0.7) {
e.hp -= proj.dmg;
e.hurtTimer = 10;
gameAudio.hit();
gameParticles.emit(e.x, e.y, 3, '#ffcc44', 2, 10, 2);
proj.traveled = proj.range;
break;
}
}
if (e.hp <= 0) {
e.alive = false;
player.xp += e.exp;
if (e.isBoss) {
player.money += 100;
msgSystem.addMessages(["💀 BOSS DEFEATED!", "+100 Gold", "+10 XP"]);
gameParticles.emit(e.x, e.y, 30, '#ffcc00', 5, 50, 5);
if (!allEnemies.find(function(en){return en.alive && en.isBoss;})) {
gameState = 'victory';
msgSystem.renderVictory();
}
} else {
player.money += Math.floor(rng(3, 8));
if (Math.random() < 0.2) {
var drop = ITEMS_DB[rng(0, Math.min(2, ITEMS_DB.length-1))];
floorItems.push({x: e.x, y: e.y, type: 'pickup', subType: 'item', item: drop, size: 0.5, bobPhase: Math.random() * Math.PI * 2});
msgSystem.addMessage("Enemy dropped: " + drop.name);
}
gameParticles.emit(e.x, e.y, 10, e.color, 3, 25, 3);
}
}
}
for (var i = projectiles.length - 1; i >= 0; i--) {
if (projectiles[i].traveled >= projectiles[i].range) projectiles.splice(i, 1);
}
}
// ── let: pFlash ──
let pFlash = false;
// ── let: pBlink ──
let pBlink = false;
// ── function: handleDoorInteraction ──
function handleDoorInteraction(p, map, audio, particles, floor, gameFloor) {
if (!player) return;
var tx = Math.floor(p.x / TILE);
var ty = Math.floor(p.y / TILE);
var door = findDoor(map, tx, ty);
if (door) {
// Check left exit (next floor)
if (tx > 0 && map[ty] && map[ty][tx-1] === 2) {
if (gameFloor < 4) {
msgSystem.addMessage("You descend deeper into the darkness...");
audio.door();
gameState = 'transition';
setTimeout(function() {
loadFloor(gameFloor + 1);
player.invincible = 2000;
msgSystem.addMessage("Floor " + (gameFloor + 1));
gameState = 'playing';
}, 800);
}
return;
}
audio.door();
p.x = door.x; p.y = door.y;
}
// Check save point
if (map[ty] && map[ty][tx] === 3) {
var sdata = {hp:p.hp, maxHp:p.maxHp, money:p.money,
weaponIdx:p.weaponIdx, bowUnlocked:p.bowUnlocked,
inventory:p.inventory.map(function(i){return i.id;}),
accessories:p.accessories.map(function(i){return i.id;}),
xp:p.xp, level:p.level, x:p.x, y:p.y,
bowAmmo:p.bowAmmo, floor:gameFloor};
localStorage.setItem('re_save', JSON.stringify(sdata));
savePointPos = {x:p.x, y:p.y};
audio.savePoint();
msgSystem.addMessage("Game saved at beacon!");
particles.emit(p.x, p.y, 20, '#4488ff', 3, 40, 4);
}
// Check shop (floor 0)
if (gameFloor === 0) {
var sTx = Math.floor(p.x / TILE);
var sTy = Math.floor(p.y / TILE);
// Room 6,4-9 (shop room)
if (sTx >= 27 && sTx <= 33 && sTy >= 4 && sTy <= 9) {
msgSystem.openShop(buildShopItems());
gameState = 'shop';
audio.shopOpen();
}
}
}
// ── main ──
var canvas = document.getElementById('game');
var ctx = canvas.getContext('2d');
var input, camera;
gameInput = input = createInput();
gameCamera = camera = createCamera();
gameAudio = createAudio();
gameParticles = createParticles();
var lastTime = 0;
function buildShopItems() {
var items = [];
items.push({...ITEMS_DB[0]});
items.push({...ITEMS_DB[5]});
if (player.level >= 3 && player.weaponIdx === 0)
items.push({name:'Flame Sword', desc:'DMG 6+burn', price:50, color:COLORS.flameSword, upgradeWeapon:1, requiresWeapon:0});
if (player.level >= 5 && player.weaponIdx === 1)
items.push({name:'Shadow Blade', desc:'DMG 10+range', price:100, color:COLORS.shadowBlade, upgradeWeapon:2, requiresWeapon:1});
for (var i=0; i<ITEMS_DB.length; i++) {
var acc = ITEMS_DB[i];
if (acc.type === 'accessory' && !player.accessories.find(function(a){return a.id===acc.id;}))
items.push({...acc});
}
return items;
}
function startGame() {
hasLoaded = true;
loadFloor(0);
gameState = 'playing';
msgSystem.addMessages(["You awaken in an ancient town...", "Explore. Find weapons. Grow stronger.", "WASD:Move SP:Attack TAB:Inv F:Interact"]);
gameParticles.emit(player.x, player.y, 10, '#44bb44', 2, 40, 3);
}
function loadGS() {
try {
var d = JSON.parse(localStorage.getItem('re_save'));
if (d) {
loadFloor(Math.min(d.floor||0, 4), true);
player.hp = d.hp; player.maxHp = d.maxHp; player.money = d.money;
player.weaponIdx = d.weaponIdx; player.bowUnlocked = d.bowUnlocked||false;
player.xp = d.xp||0; player.level = d.level||1;
player.inventory = (d.inventory||[]).map(function(id){return ITEMS_DB.find(function(i){return i.id===id;})||null;}).filter(Boolean);
player.accessories = (d.accessories||[]).map(function(id){return ITEMS_DB.find(function(i){return i.id===id;})||null;}).filter(Boolean);
player.invincible = 1500;
msgSystem.addMessage("Game loaded!");
gameAudio.savePoint();
}
} catch(e) {}
}
function upProj() {
for (var i = projectiles.length-1; i>=0; i--) {
projectiles[i].x += projectiles[i].vx;
projectiles[i].y += projectiles[i].vy;
projectiles[i].traveled += 6;
if (projectiles[i].traveled >= projectiles[i].range) projectiles.splice(i,1);
}
}
function gameLoop(ts) {
var dt = ts - lastTime; lastTime = ts; frameCount++; input.tick();
ctx.clearRect(0,0,VIEW_W,VIEW_H);
if (gameState === 'title') {
renderTitleScreen(ctx, hasLoaded ? player : null);
if (input.justPressed['enter']) startGame();
if (input.justPressed['shift'] && hasLoaded) { loadGS(); gameState = 'playing'; }
}
else if (gameState === 'playing' || gameState === 'transition') {
if (gameState === 'transition') {
// Transitioning to new floor - just keep running for 800ms
gameParticles.update();
requestAnimationFrame(gameLoop);
return;
}
updatePlayer(player, input, currentMap, gameParticles, gameAudio, gameFloor);
if (player.dead) { gameState = 'dead'; player.deathTimer = 0; }
camera.update(player);
updateEnemyStates(16);
upProj();
gameParticles.update();
msgSystem.tick();
renderGame(ctx, camera, currentMap, player, allEnemies, floorItems, gameFloor);
renderHUD(ctx, player, gameFloor);
renderMessage(ctx);
gameInput.justPressed['f'] = false;
gameInput.justPressed['x'] = false;
if (input.justPressed['f'] || input.justPressed['x']) {
handleDoorInteraction(player, currentMap, gameAudio, gameParticles, gameFloor, gameFloor);
}
}
else if (gameState === 'inventory') {
updatePlayer(player, input, currentMap, gameParticles, gameAudio, gameFloor);
if (player.dead) { gameState = 'dead'; player.deathTimer = 0; }
camera.update(player);
updateEnemyStates(16);
upProj();
gameParticles.update();
msgSystem.tick();
renderGame(ctx, camera, currentMap, player, allEnemies, floorItems, gameFloor);
renderHUD(ctx, player, gameFloor);
renderInventory(ctx, player, gameFloor);
renderMessage(ctx);
if (input.justPressed['tab'] || input.justPressed['escape']) gameState = 'playing';
}
else if (gameState === 'shop') {
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(0,0,VIEW_W,VIEW_H);
renderShop(ctx, player);
renderHUD(ctx, player, gameFloor);
if (input.justPressed['arrowup']) selectedShopIdx = Math.max(0, selectedShopIdx-1);
if (input.justPressed['arrowdown']) selectedShopIdx = Math.min(buildShopItems().length-1, selectedShopIdx+1);
if (input.justPressed['enter']) {
var si = buildShopItems();
var item2 = si[selectedShopIdx];
if (item2) {
if (item2.upgradeWeapon && item2.requiresWeapon === player.weaponIdx) {
if (player.money >= item2.price) {
player.money -= item2.price; player.weaponIdx = item2.upgradeWeapon;
gameAudio.shopBuy(); gameParticles.emit(player.x,player.y,15,item2.color,3,30,3);
msgSystem.addMessage("Upgraded to " + WEAPONS[player.weaponIdx].name + "!");
} else msgSystem.addMessage("Not enough gold!");
} else if (item2.type === 'consumable') {
if (player.money >= item2.price) {
player.money -= item2.price; gameAudio.shopBuy();
if (item2.heal) { player.hp = Math.min(player.hp+item2.heal, player.maxHp); msgSystem.addMessage("Used " + item2.name); gameParticles.emit(player.x,player.y,8,'#ff4466',2,25,3); }
else if (item2.fullHeal) { player.hp = player.maxHp; msgSystem.addMessage("Fully restored!"); gameParticles.emit(player.x,player.y,12,'#ff4466',3,30,4); }
} else msgSystem.addMessage("Not enough gold!");
} else if (item2.type === 'accessory') {
if (player.money >= item2.price) {
player.money -= item2.price; gameAudio.shopBuy();
if (addAccessory(player, JSON.parse(JSON.stringify(item2)))) msgSystem.addMessage("Equipped " + item2.name);
else msgSystem.addMessage("Slot full!");
} else msgSystem.addMessage("Not enough gold!");
}
}
}
if (input.justPressed['escape']) { gameState = 'playing'; msgSystem.closeShop(); }
}
else if (gameState === 'dead') {
gameParticles.update();
player.deathTimer = (player.deathTimer||0) + 1;
ctx.fillStyle = 'rgba(20,0,0,0.85)'; ctx.fillRect(0,0,VIEW_W,VIEW_H);
renderDeath(ctx);
if (input.justPressed['r']) respawnPlayer();
if (input.justPressed['x']) { gameState='title'; hasLoaded=false; player=null; }
}
else if (gameState === 'victory') {
gameParticles.update();
gameParticles.emit(player.x+rng(-100,100),player.y+rng(-50,50),1,['#44ff88','#88aaff','#ffcc44'][rng(0,2)],2,40,3);
renderGame(ctx,camera,currentMap,player,allEnemies,floorItems,gameFloor);
renderHUD(ctx,player,gameFloor);
renderVictory(ctx);
if (input.justPressed['x']) { gameState='title'; player=null; hasLoaded=false; }
}
if (input.justPressed['tab'] && gameState==='playing') gameState='inventory';
if (input.justPressed['1'] && gameState==='playing') { player.weaponIdx=0; msgSystem.addMessage(WEAPONS[0].name+' equipped'); }
if (input.justPressed['2'] && player.bowUnlocked && gameState==='playing') { player.weaponIdx=Math.min(player.weaponIdx+1,WEAPONS.length-1); msgSystem.addMessage(WEAPONS[player.weaponIdx].name); }
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen/qwen3.7-flash","provider":"openrouter","persona":"composer","sessionId":"3ec6c2e6-79ab-4876-a80c-0a7b3c7f3099","tokensIn":5083322,"tokensOut":70283,"tokensTotal":5153605,"turns":80,"toolCalls":92,"failedToolCalls":4,"timestamp":"2026-07-30T01:10:14.583Z"} -->