← RPG results

RPG

Qwen 3.7 Flash · composer

Initial view of RPG

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"} -->