← RPG results

RPG

longcat 2.0 · 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 60,897 bytes · SHA-256 8e68070f256c
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Realm of Shadows - Action RPG</title>
<style>
/* ── css: main-styles ── */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  background: #0a0a0f;
  overflow: hidden;
  font-family: 'Courier New', monospace;
  color: #e0e0e0;
  user-select: none;
}

canvas#gameCanvas {
  display: block;
  image-rendering: pixelated;
  image-rendering: crisp-edges;
}

#ui-overlay {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

#hud {
  position: absolute;
  top: 10px;
  left: 10px;
  pointer-events: none;
  z-index: 10;
}

#minimap {
  position: absolute;
  top: 10px;
  right: 10px;
  border: 2px solid #444;
  border-radius: 4px;
  pointer-events: none;
  z-index: 10;
}

#inventory-panel {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: rgba(10, 10, 20, 0.95);
  border: 2px solid #666;
  border-radius: 8px;
  padding: 20px;
  pointer-events: auto;
  z-index: 100;
  min-width: 400px;
  display: none;
}

#inventory-panel.active {
  display: block;
}

#shop-panel {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: rgba(10, 10, 20, 0.95);
  border: 2px solid #c0a040;
  border-radius: 8px;
  padding: 20px;
  pointer-events: auto;
  z-index: 100;
  min-width: 450px;
  display: none;
}

#shop-panel.active {
  display: block;
}

#message-log {
  position: absolute;
  bottom: 10px;
  left: 50%;
  transform: translateX(-50%);
  pointer-events: none;
  z-index: 10;
  text-align: center;
  width: 500px;
}

#save-indicator {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  pointer-events: none;
  z-index: 50;
  font-size: 24px;
  color: #4af;
  opacity: 0;
  transition: opacity 0.3s;
  text-shadow: 0 0 10px #4af;
}

#save-indicator.active {
  opacity: 1;
}

#interaction-prompt {
  position: absolute;
  bottom: 80px;
  left: 50%;
  transform: translateX(-50%);
  pointer-events: none;
  z-index: 10;
  font-size: 14px;
  color: #ff0;
  opacity: 0;
  transition: opacity 0.2s;
  text-shadow: 0 0 4px #000;
}

#interaction-prompt.active {
  opacity: 1;
}

.item-slot {
  display: inline-block;
  width: 48px;
  height: 48px;
  border: 1px solid #555;
  border-radius: 4px;
  margin: 2px;
  background: rgba(30, 30, 50, 0.8);
  text-align: center;
  line-height: 48px;
  font-size: 20px;
  cursor: pointer;
  position: relative;
  vertical-align: middle;
}

.item-slot:hover {
  border-color: #aaa;
  background: rgba(50, 50, 80, 0.8);
}

.item-slot .count {
  position: absolute;
  bottom: 2px;
  right: 4px;
  font-size: 10px;
  color: #ff0;
}

.shop-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 12px;
  border: 1px solid #444;
  border-radius: 4px;
  margin: 4px 0;
  cursor: pointer;
  background: rgba(30, 30, 50, 0.6);
}

.shop-item:hover {
  border-color: #c0a040;
  background: rgba(50, 50, 30, 0.6);
}

#game-over {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.85);
  display: none;
  align-items: center;
  justify-content: center;
  flex-direction: column;
  pointer-events: auto;
  z-index: 200;
}

#game-over.active {
  display: flex;
}

h2 {
  margin-bottom: 10px;
  color: #fff;
}

button {
  padding: 10px 24px;
  background: #333;
  border: 1px solid #666;
  color: #fff;
  font-family: inherit;
  font-size: 14px;
  cursor: pointer;
  border-radius: 4px;
  margin: 5px;
}

button:hover {
  background: #555;
}

.health-heart {
  display: inline-block;
  font-size: 22px;
  margin: 0 2px;
}
</style>
</head>
<body>
<canvas id="gameCanvas"></canvas>
<div id="hud" style="position:absolute;top:0;left:0;width:0;height:0;overflow:hidden;">
  <div id="health-display"></div>
  <div id="money-display" style="margin-top:4px;color:#fd0;font-size:14px;"></div>
  <div id="weapon-display" style="margin-top:2px;font-size:12px;color:#aaa;"></div>
</div>
<canvas id="minimap" width="160" height="160"></canvas>
<div id="inventory-panel">
  <h2>Inventory [I]</h2>
  <div id="inventory-slots" style="margin:10px 0;"></div>
  <div id="equipment-slots" style="margin:10px 0;font-size:12px;color:#aaa;"></div>
  <p style="font-size:11px;color:#888;">Click item to use/equip. [I] to close.</p>
</div>
<div id="shop-panel">
  <h2 style="color:#c0a040;">Item Shop [E to close]</h2>
  <div id="shop-items" style="margin:10px 0;"></div>
  <p id="shop-balance" style="font-size:12px;margin-top:8px;"></p>
</div>
<div id="message-log"></div>
<div id="save-indicator">💾 Game Saved</div>
<div id="interaction-prompt">[E] to interact</div>
<div id="game-over">
  <h2 style="color:#f44;">You Have Fallen</h2>
  <p style="margin-bottom:20px;color:#aaa;">The shadows consume another soul...</p>
  <button id="respawn-btn">Respawn at Last Save</button>
</div>
<div id="start-screen" style="position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;align-items:center;justify-content:center;flex-direction:column;z-index:300;pointer-events:auto;">
  <h1 style="color:#c0a040;font-size:36px;margin-bottom:10px;text-shadow:0 0 20px #c0a040;">⚔️ Realm of Shadows</h1>
  <p style="color:#888;margin-bottom:20px;font-size:14px;">An Action-Adventure RPG</p>
  <div style="color:#aaa;font-size:12px;text-align:center;margin-bottom:20px;line-height:1.8;">
    <p>WASD - Move | Mouse - Aim | Click - Attack</p>
    <p>I - Inventory | E - Interact/Shop | Space - Roll</p>
    <p style="margin-top:8px;color:#666;">Explore • Fight • Collect • Upgrade • Survive</p>
  </div>
  <button id="start-btn" style="padding:15px 40px;font-size:18px;">Begin Adventure</button>
</div>
<script>
'use strict';

// ── class: AudioEngine ──
// ============================================================
// AUDIO ENGINE - Synthesized sound effects via Web Audio API
// ============================================================
class AudioEngine {
  constructor() {
    this.ctx = null;
    this.enabled = true;
    this.initialized = false;
  }

  init() {
    if (this.initialized) return;
    try {
      this.ctx = new (window.AudioContext || window.webkitAudioContext)();
      this.initialized = true;
    } catch(e) {
      this.enabled = false;
    }
  }

  play(type) {
    if (!this.enabled || !this.ctx) return;
    if (this.ctx.state === 'suspended') this.ctx.resume();
    
    switch(type) {
      case 'walk': this._walk(); break;
      case 'attack': this._attack(); break;
      case 'hit': this._hit(); break;
      case 'enemy_hit': this._enemyHit(); break;
      case 'pickup': this._pickup(); break;
      case 'coin': this._coin(); break;
      case 'hurt': this._hurt(); break;
      case 'save': this._save(); break;
      case 'shop': this._shop(); break;
      case 'upgrade': this._upgrade(); break;
      case 'roll': this._roll(); break;
      case 'shoot': this._shoot(); break;
      case 'heal': this._heal(); break;
      case 'save_point': this._savePoint(); break;
      case 'levelup': this._levelup(); break;
      case 'death': this._death(); break;
    }
  }

  _tone(freq, duration, type='square', vol=0.15) {
    const osc = this.ctx.createOscillator();
    const gain = this.ctx.createGain();
    osc.type = type;
    osc.frequency.value = freq;
    gain.gain.setValueAtTime(vol, this.ctx.currentTime);
    gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + duration);
    osc.connect(gain);
    gain.connect(this.ctx.destination);
    osc.start();
    osc.stop(this.ctx.currentTime + duration);
  }

  _noise(duration, vol=0.1) {
    const bufferSize = this.ctx.sampleRate * duration;
    const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
    const data = buffer.getChannelData(0);
    for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
    const source = this.ctx.createBufferSource();
    source.buffer = buffer;
    const gain = this.ctx.createGain();
    gain.gain.setValueAtTime(vol, this.ctx.currentTime);
    gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + duration);
    source.connect(gain);
    gain.connect(this.ctx.destination);
    source.start();
  }

  _walk() { this._noise(0.05, 0.03); }
  _attack() { this._tone(200, 0.1, 'sawtooth', 0.12); this._noise(0.06, 0.08); }
  _hit() { this._tone(150, 0.15, 'square', 0.15); this._noise(0.1, 0.1); }
  _enemyHit() { this._tone(300, 0.08, 'square', 0.1); this._noise(0.05, 0.06); }
  _pickup() { this._tone(600, 0.1, 'sine', 0.1); setTimeout(() => this._tone(800, 0.1, 'sine', 0.08), 80); }
  _coin() { this._tone(1200, 0.05, 'sine', 0.08); setTimeout(() => this._tone(1600, 0.08, 'sine', 0.06), 40); }
  _hurt() { this._tone(100, 0.3, 'sawtooth', 0.2); this._noise(0.15, 0.12); }
  _save() { this._tone(440, 0.15, 'sine', 0.1); setTimeout(() => this._tone(660, 0.15, 'sine', 0.1), 100); setTimeout(() => this._tone(880, 0.2, 'sine', 0.08), 200); }
  _shop() { this._tone(500, 0.08, 'triangle', 0.1); setTimeout(() => this._tone(700, 0.08, 'triangle', 0.08), 60); setTimeout(() => this._tone(900, 0.12, 'triangle', 0.06), 120); }
  _upgrade() { this._tone(400, 0.1, 'sine', 0.12); setTimeout(() => this._tone(600, 0.1, 'sine', 0.1), 80); setTimeout(() => this._tone(800, 0.15, 'sine', 0.1), 160); setTimeout(() => this._tone(1000, 0.2, 'sine', 0.08), 240); }
  _roll() { this._noise(0.15, 0.06); this._tone(300, 0.1, 'triangle', 0.04); }
  _shoot() { this._tone(900, 0.12, 'sawtooth', 0.08); this._noise(0.08, 0.06); }
  _heal() { this._tone(523, 0.15, 'sine', 0.1); setTimeout(() => this._tone(659, 0.15, 'sine', 0.1), 100); setTimeout(() => this._tone(784, 0.2, 'sine', 0.08), 200); this._noise(0.1, 0.02); }
  _savePoint() { this._tone(440, 0.2, 'sine', 0.12); setTimeout(() => this._tone(880, 0.3, 'sine', 0.1), 150); }
  _levelup() { this._tone(523, 0.1, 'sine', 0.1); setTimeout(() => this._tone(659, 0.1, 'sine', 0.1), 80); setTimeout(() => this._tone(784, 0.1, 'sine', 0.1), 160); setTimeout(() => this._tone(1047, 0.2, 'sine', 0.12), 240); }
  _death() { this._tone(200, 0.5, 'sawtooth', 0.15); setTimeout(() => this._tone(150, 0.5, 'sawtooth', 0.12), 200); setTimeout(() => this._tone(100, 0.8, 'sawtooth', 0.1), 400); }
}

// ── const: audio ──
const audio = new AudioEngine();

// ── class: Particle ──
// ============================================================
// PARTICLE SYSTEM
// ============================================================
class Particle {
  constructor(x, y, vx, vy, life, color, size, gravity=0) {
    this.x = x; this.y = y;
    this.vx = vx; this.vy = vy;
    this.life = life; this.maxLife = life;
    this.color = color; this.size = size;
    this.gravity = gravity;
    this.alpha = 1;
  }
  update(dt) {
    this.vx *= 0.98;
    this.vy *= 0.98;
    this.vy += this.gravity * dt;
    this.x += this.vx * dt;
    this.y += this.vy * dt;
    this.life -= dt;
    this.alpha = Math.max(0, this.life / this.maxLife);
  }
  get dead() { return this.life <= 0; }
}

// ── class: ParticleSystem ──
class ParticleSystem {
  constructor() { this.particles = []; }

  emit(x, y, count, opts={}) {
    for (let i = 0; i < count; i++) {
      const angle = Math.random() * Math.PI * 2;
      const speed = (opts.speed || 100) * (0.5 + Math.random());
      const vx = Math.cos(angle) * speed + (opts.vx || 0);
      const vy = Math.sin(angle) * speed + (opts.vy || 0);
      const life = (opts.life || 0.5) * (0.7 + Math.random() * 0.6);
      const color = opts.colors ? opts.colors[Math.floor(Math.random() * opts.colors.length)] : (opts.color || '#fff');
      const size = (opts.size || 3) * (0.7 + Math.random() * 0.6);
      this.particles.push(new Particle(x, y, vx, vy, life, color, size, opts.gravity || 0));
    }
  }

  update(dt) {
    for (let i = this.particles.length - 1; i >= 0; i--) {
      this.particles[i].update(dt);
      if (this.particles[i].dead) this.particles.splice(i, 1);
    }
  }

  render(ctx, cam) {
    for (const p of this.particles) {
      ctx.globalAlpha = p.alpha;
      ctx.fillStyle = p.color;
      const s = p.size * p.alpha;
      ctx.fillRect(p.x - cam.x - s/2, p.y - cam.y - s/2, s, s);
    }
    ctx.globalAlpha = 1;
  }
}

// ── const: particles ──
const particles = new ParticleSystem();

// ── const: TILE ──
// ============================================================
// GAME CONSTANTS
// ============================================================
const TILE = 32;

// ── const: MAP_W ──
const MAP_W = 50;

// ── const: MAP_H ──
const MAP_H = 40;

// ── const: CANVAS_W ──
const CANVAS_W = 960;

// ── const: CANVAS_H ──
const CANVAS_H = 640;

// ── const: TILE_EMPTY ──
const TILE_EMPTY = 0;

// ── const: TILE_WALL ──
const TILE_WALL = 1;

// ── const: TILE_DOOR ──
const TILE_DOOR = 2;

// ── const: TILE_SAVE ──
const TILE_SAVE = 3;

// ── const: TILE_SHOP ──
const TILE_SHOP = 4;

// ── const: TILE_CHEST ──
const TILE_CHEST = 5;

// ── const: TILE_WATER ──
const TILE_WATER = 6;

// ── const: TILE_GRASS ──
const TILE_GRASS = 7;

// ── const: SOLID_TILES ──
// Tiles that block movement
const SOLID_TILES = new Set([TILE_WALL, TILE_WATER]);

// ── const: WEAPONS ──
const WEAPONS = {
  dagger: { name: 'Rusty Dagger', damage: 1, range: 36, arc: 1.2, cooldown: 350, type: 'melee', color: '#888', width: 3 },
  sword: { name: 'Steel Sword', damage: 2, range: 48, arc: 1.4, cooldown: 400, type: 'melee', color: '#ccc', width: 4 },
  flamesword: { name: 'Flame Blade', damage: 3, range: 52, arc: 1.5, cooldown: 380, type: 'melee', color: '#f80', width: 5, special: 'burn' },
  bow: { name: 'Hunter Bow', damage: 2, range: 300, cooldown: 600, type: 'ranged', color: '#8b4513', speed: 400 }
};

// ── const: ITEMS ──
const ITEMS = {
  health_potion: { name: 'Health Potion', type: 'consumable', effect: 'heal', value: 2, icon: '🧪', desc: 'Restores 2 HP' },
  super_potion: { name: 'Super Potion', type: 'consumable', effect: 'heal', value: 4, icon: '💊', desc: 'Restores 4 HP' },
  shield_charm: { name: 'Shield Charm', type: 'accessory', effect: 'defense', value: 1, icon: '🛡️', desc: '+1 Defense' },
  speed_boots: { name: 'Swift Boots', type: 'accessory', effect: 'speed', value: 1.3, icon: '👢', desc: '+30% Speed' },
  lucky_coin: { name: 'Lucky Coin', type: 'accessory', effect: 'luck', value: 1.5, icon: '🍀', desc: '+50% Gold' },
  bomb: { name: 'Bomb', type: 'consumable', effect: 'damage', value: 5, icon: '💣', desc: '5 dmg to nearby enemies' }
};

// ── const: SHOP_ITEMS ──
const SHOP_ITEMS = [
  { id: 'health_potion', price: 15 },
  { id: 'super_potion', price: 30 },
  { id: 'bomb', price: 25 },
  { id: 'shield_charm', price: 50 },
  { id: 'speed_boots', price: 60 }
];

// ── const: ENEMY_TYPES ──
const ENEMY_TYPES = {
  slime: { name: 'Slime', hp: 3, damage: 1, speed: 30, color: '#4a4', size: 12, xp: 5, gold: 3, behavior: 'chase' },
  skeleton: { name: 'Skeleton', hp: 5, damage: 2, speed: 45, color: '#ddd', size: 14, xp: 10, gold: 5, behavior: 'chase' },
  bat: { name: 'Bat', hp: 2, damage: 1, speed: 70, color: '#633', size: 10, xp: 4, gold: 2, behavior: 'erratic' },
  knight: { name: 'Dark Knight', hp: 10, damage: 3, speed: 35, color: '#333', size: 16, xp: 20, gold: 10, behavior: 'chase' },
  wizard: { name: 'Shadow Wizard', hp: 6, damage: 2, speed: 25, color: '#609', size: 14, xp: 15, gold: 8, behavior: 'ranged' },
  boss: { name: 'Shadow Lord', hp: 40, damage: 4, speed: 40, color: '#900', size: 24, xp: 100, gold: 50, behavior: 'boss' }
};

// ── let: spawnTile ──
let spawnTile = { x: 2, y: 2 };

// ── let: savePoints ──
let savePoints = [];

// ── let: shopPos ──
let shopPos = { x: 0, y: 0 };

// ── let: weapon1Room ──
let weapon1Room = { x: 0, y: 0 };

// ── let: weapon2Room ──
let weapon2Room = { x: 0, y: 0 };

// ── let: rangedRoom ──
let rangedRoom = { x: 0, y: 0 };

// ── let: bossRoom ──
let bossRoom = { x: 0, y: 0, room: null };

// ── class: Enemy ──
class Enemy {
  constructor(x, y, type) {
    const def = ENEMY_TYPES[type];
    this.x = x;
    this.y = y;
    this.type = type;
    this.name = def.name;
    this.maxHp = def.hp;
    this.hp = def.hp;
    this.damage = def.damage;
    this.speed = def.speed;
    this.color = def.color;
    this.size = def.size;
    this.behavior = def.behavior;
    this.xp = def.xp;
    this.gold = def.gold;
    this.dir = { x: 0, y: 0 };
    this.attackCd = 0;
    this.knockback = { x: 0, y: 0 };
    this.burnTimer = 0;
    this.burnDmg = 0;
    this.stunTimer = 0;
    this.alerted = false;
    this.erraticTimer = 0;
    this.erraticDir = Math.random() * Math.PI * 2;
  }

  update(dt, game) {
    if (this.stunTimer > 0) {
      this.stunTimer -= dt;
      this.x += this.knockback.x * dt;
      this.y += this.knockback.y * dt;
      this.knockback.x *= 0.9;
      this.knockback.y *= 0.9;
      return;
    }

    if (this.burnTimer > 0) {
      this.burnTimer -= dt;
      if (Math.floor(this.burnTimer * 4) !== Math.floor((this.burnTimer + dt) * 4)) {
        this.hp -= this.burnDmg;
        particles.emit(this.x, this.y, 3, { speed: 30, colors: ['#f80', '#ff0'], life: 0.3, size: 2 });
      }
    }

    if (this.attackCd > 0) this.attackCd -= dt;

    const dx = game.player.x - this.x;
    const dy = game.player.y - this.y;
    const dist = Math.sqrt(dx * dx + dy * dy);

    if (dist < 250) this.alerted = true;
    if (!this.alerted) return;

    let moveX = 0, moveY = 0;

    if (this.behavior === 'chase') {
      if (dist > 1) { moveX = dx / dist; moveY = dy / dist; }
    } else if (this.behavior === 'erratic') {
      this.erraticTimer -= dt;
      if (this.erraticTimer <= 0) {
        this.erraticTimer = 0.5 + Math.random();
        this.erraticDir = Math.random() * Math.PI * 2;
      }
      if (dist < 120) {
        moveX = dx / dist; moveY = dy / dist;
      } else {
        moveX = Math.cos(this.erraticDir);
        moveY = Math.sin(this.erraticDir);
      }
    } else if (this.behavior === 'ranged') {
      if (dist > 100) {
        moveX = dx / dist * 0.5; moveY = dy / dist * 0.5;
      } else if (dist < 60) {
        moveX = -dx / dist; moveY = -dy / dist;
      }
      if (dist < 200 && this.attackCd <= 0) {
        this.attackCd = 2.0;
        const angle = Math.atan2(dy, dx);
        game.projectiles.push({
          x: this.x, y: this.y,
          vx: Math.cos(angle) * 200,
          vy: Math.sin(angle) * 200,
          damage: this.damage,
          life: 1.5,
          fromPlayer: false,
          color: '#a0f'
        });
        audio.play('shoot');
      }
    } else if (this.behavior === 'boss') {
      if (dist > 60) { moveX = dx / dist; moveY = dy / dist; }
      if (this.attackCd <= 0 && dist < 250) {
        this.attackCd = 1.5;
        const angle = Math.atan2(dy, dx);
        for (let i = -1; i <= 1; i++) {
          const a = angle + i * 0.3;
          game.projectiles.push({
            x: this.x, y: this.y,
            vx: Math.cos(a) * 180,
            vy: Math.sin(a) * 180,
            damage: this.damage,
            life: 2.0,
            fromPlayer: false,
            color: '#f0f'
          });
        }
        audio.play('shoot');
      }
    }

    const spd = this.speed;
    this.x += moveX * spd * dt;
    this.y += moveY * spd * dt;

    const tx = Math.floor(this.x / TILE);
    const ty = Math.floor(this.y / TILE);
    if (tx >= 0 && tx < MAP_W && ty >= 0 && ty < MAP_H && SOLID_TILES.has(game.map[ty][tx])) {
      this.x -= moveX * spd * dt * 2;
      this.y -= moveY * spd * dt * 2;
    }

    if (dist < this.size + game.player.size + 5 && this.attackCd <= 0 && this.behavior !== 'ranged' && this.behavior !== 'boss') {
      this.attackCd = 1.0;
      game.player.takeDamage(this.damage);
    } else if (this.behavior === 'boss' && dist < this.size + game.player.size + 5 && this.attackCd <= 0.5) {
      game.player.takeDamage(this.damage);
      this.attackCd = 1.2;
    }
  }

  takeDamage(amount, fromX, fromY, special) {
    this.hp -= amount;
    audio.play('hit');
    const angle = Math.atan2(this.y - fromY, this.x - fromX);
    this.knockback.x = Math.cos(angle) * 150;
    this.knockback.y = Math.sin(angle) * 150;
    this.stunTimer = 0.1;
    this.alerted = true;
    particles.emit(this.x, this.y, 6, { speed: 70, colors: ['#fff', this.color], life: 0.3, size: 3 });
    if (special === 'burn') {
      this.burnTimer = 3.0;
      this.burnDmg = 1;
    }
    return this.hp <= 0;
  }

  render(ctx, cam) {
    const sx = this.x - cam.x;
    const sy = this.y - cam.y;
    if (sx < -50 || sx > CANVAS_W + 50 || sy < -50 || sy > CANVAS_H + 50) return;
    ctx.save();
    ctx.translate(sx, sy);
    ctx.fillStyle = 'rgba(0,0,0,0.3)';
    ctx.beginPath();
    ctx.ellipse(0, this.size - 2, this.size * 0.7, 4, 0, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = this.color;
    if (this.type === 'slime') {
      ctx.beginPath();
      ctx.ellipse(0, 0, this.size, this.size * 0.8, 0, 0, Math.PI * 2);
      ctx.fill();
    } else if (this.type === 'bat') {
      ctx.fillRect(-this.size, -4, this.size * 2, 8);
      const wing = Math.sin(Date.now() * 0.01) * 4;
      ctx.fillRect(-this.size - 4, -8 + wing, 8, 12);
      ctx.fillRect(this.size - 4, -8 - wing, 8, 12);
    } else {
      ctx.beginPath();
      ctx.arc(0, 0, this.size, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.fillStyle = '#f00';
    ctx.fillRect(-4, -4, 3, 3);
    ctx.fillRect(2, -4, 3, 3);
    if (this.hp < this.maxHp) {
      ctx.fillStyle = '#300';
      ctx.fillRect(-this.size, -this.size - 8, this.size * 2, 4);
      ctx.fillStyle = '#f00';
      ctx.fillRect(-this.size, -this.size - 8, this.size * 2 * (this.hp / this.maxHp), 4);
    }
    if (this.burnTimer > 0) {
      ctx.fillStyle = 'rgba(255,100,0,0.3)';
      ctx.beginPath();
      ctx.arc(0, 0, this.size + 3, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.restore();
  }
}

// ── class: Player ──
class Player {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.size = 14;
    this.speed = 120;
    this.maxHp = 3;
    this.hp = 3;
    this.facing = { x: 1, y: 0 };
    this.weapon = 'dagger';
    this.inventory = [];
    this.gold = 0;
    this.xp = 0;
    this.level = 1;
    this.attackCooldown = 0;
    this.invincible = 0;
    this.defense = 0;
    this.hasRanged = false;
    this.weaponUpgrades = [];
    this.walkFrame = 0;
    this.walkTimer = 0;
    this.isMoving = false;
    this.attacking = false;
    this.attackAnim = 0;
    this.rolling = false;
    this.rollTimer = 0;
    this.rollCd = 0;
    this.rollDir = { x: 0, y: 0 };
    this.equipped = [];
  }

  get wepData() { return WEAPONS[this.weapon]; }
  get damage() { return this.wepData.damage; }

  get effectiveSpeed() {
    let s = this.speed;
    if (this.equipped.includes('speed_boots')) s *= 1.3;
    return s;
  }

  update(dt, input, game) {
    if (this.attackCooldown > 0) this.attackCooldown -= dt * 1000;
    if (this.invincible > 0) this.invincible -= dt;
    if (this.rollCd > 0) this.rollCd -= dt;
    if (this.attacking) {
      this.attackAnim -= dt;
      if (this.attackAnim <= 0) this.attacking = false;
    }

    if (this.rolling) {
      this.rollTimer -= dt;
      this.x += this.rollDir.x * 300 * dt;
      this.y += this.rollDir.y * 300 * dt;
      if (this.rollTimer <= 0) this.rolling = false;
      this._collision(game);
      return;
    }

    let dx = 0, dy = 0;
    if (input.keys['w'] || input.keys['arrowup']) dy = -1;
    if (input.keys['s'] || input.keys['arrowdown']) dy = 1;
    if (input.keys['a'] || input.keys['arrowleft']) dx = -1;
    if (input.keys['d'] || input.keys['arrowright']) dx = 1;

    this.isMoving = dx !== 0 || dy !== 0;

    if (this.isMoving) {
      const len = Math.sqrt(dx * dx + dy * dy);
      dx /= len; dy /= len;
      this.facing = { x: dx, y: dy };
      const spd = this.effectiveSpeed;
      this.x += dx * spd * dt;
      this.y += dy * spd * dt;
      this._collision(game);
      this.walkTimer += dt;
      if (this.walkTimer > 0.18) {
        this.walkTimer = 0;
        this.walkFrame = (this.walkFrame + 1) % 4;
        if (Math.random() < 0.2) audio.play('walk');
      }
    }

    if (input.mouseDown && this.attackCooldown <= 0) {
      this.attack(game, input);
    }

    if (input.keys[' '] && this.rollCd <= 0 && this.isMoving) {
      this.rolling = true;
      this.rollTimer = 0.25;
      this.rollCd = 0.8;
      this.invincible = Math.max(this.invincible, 0.3);
      this.rollDir = { x: dx || this.facing.x, y: dy || this.facing.y };
      audio.play('roll');
      particles.emit(this.x, this.y, 5, { speed: 50, colors: ['#aaa', '#888'], life: 0.3, size: 3 });
    }
  }

  attack(game, input) {
    this.attacking = true;
    this.attackAnim = 0.15;
    audio.play(this.wepData.type === 'ranged' ? 'shoot' : 'attack');

    if (this.wepData.type === 'ranged') {
      const angle = Math.atan2(input.mouseY - (this.y - game.cam.y), input.mouseX - (this.x - game.cam.x));
      game.projectiles.push({
        x: this.x, y: this.y,
        vx: Math.cos(angle) * this.wepData.speed,
        vy: Math.sin(angle) * this.wepData.speed,
        damage: this.damage,
        life: this.wepData.range / this.wepData.speed,
        fromPlayer: true,
        color: '#ff0'
      });
      this.attackCooldown = this.wepData.cooldown;
    } else {
      const angle = Math.atan2(this.facing.y, this.facing.x);
      game.meleeHits.push({
        x: this.x + Math.cos(angle) * 20,
        y: this.y + Math.sin(angle) * 20,
        angle,
        arc: this.wepData.arc,
        range: this.wepData.range,
        damage: this.damage,
        life: 0.15,
        hit: new Set(),
        special: this.wepData.special || null
      });
      particles.emit(
        this.x + Math.cos(angle) * 25,
        this.y + Math.sin(angle) * 25,
        4, { speed: 60, color: this.wepData.color, life: 0.2, size: 3 }
      );
      this.attackCooldown = this.wepData.cooldown;
    }
  }

  takeDamage(amount) {
    if (this.invincible > 0) return false;
    const dmg = Math.max(1, amount - this.defense);
    this.hp -= dmg;
    this.invincible = 1.0;
    audio.play('hurt');
    particles.emit(this.x, this.y, 8, { speed: 80, colors: ['#f00', '#f44'], life: 0.4, size: 3 });
    return true;
  }

  heal(amount) {
    this.hp = Math.min(this.maxHp, this.hp + amount);
    particles.emit(this.x, this.y, 10, { speed: 40, colors: ['#0f0', '#4f4', '#fff'], life: 0.5, size: 3, gravity: -20 });
    audio.play('heal');
  }

  addGold(amount) {
    let g = amount;
    if (this.equipped.includes('lucky_coin')) g = Math.floor(g * 1.5);
    this.gold += g;
    audio.play('coin');
  }

  _collision(game) {
    const r = this.size;
    const checks = [
      { x: this.x - r, y: this.y - r },
      { x: this.x + r, y: this.y - r },
      { x: this.x - r, y: this.y + r },
      { x: this.x + r, y: this.y + r }
    ];
    for (const c of checks) {
      const tx = Math.floor(c.x / TILE);
      const ty = Math.floor(c.y / TILE);
      if (tx < 0 || tx >= MAP_W || ty < 0 || ty >= MAP_H) { this._pushBack(); return; }
      if (SOLID_TILES.has(game.map[ty][tx])) { this._pushBack(); return; }
    }
  }

  _pushBack() {
    this.x -= this.facing.x * 4;
    this.y -= this.facing.y * 4;
  }

  render(ctx, cam) {
    const sx = this.x - cam.x;
    const sy = this.y - cam.y;
    if (this.invincible > 0 && Math.floor(this.invincible * 10) % 2 === 0) return;

    ctx.save();
    ctx.translate(sx, sy);
    const bobOffset = this.isMoving ? Math.sin(this.walkFrame * Math.PI / 2) * 2 : 0;

    ctx.fillStyle = 'rgba(0,0,0,0.3)';
    ctx.beginPath();
    ctx.ellipse(0, this.size - 2, this.size * 0.7, 4, 0, 0, Math.PI * 2);
    ctx.fill();

    let bodyColor = '#4488cc';
    if (this.weapon === 'flamesword') bodyColor = '#cc6644';
    else if (this.weapon === 'bow') bodyColor = '#668844';

    ctx.fillStyle = bodyColor;
    ctx.fillRect(-6, -8 + bobOffset, 12, 14);
    ctx.fillStyle = '#fca';
    ctx.beginPath();
    ctx.arc(0, -10 + bobOffset, 6, 0, Math.PI * 2);
    ctx.fill();

    const ex = this.facing.x * 3;
    const ey = this.facing.y * 2 - 10 + bobOffset;
    ctx.fillStyle = '#000';
    ctx.fillRect(ex - 2, ey - 1, 2, 2);
    ctx.fillRect(ex + 1, ey - 1, 2, 2);

    const wAngle = Math.atan2(this.facing.y, this.facing.x);
    ctx.save();
    ctx.rotate(wAngle);
    if (this.attacking) {
      ctx.rotate(Math.sin((0.15 - this.attackAnim) / 0.15 * Math.PI * 0.7) * 0.6);
    }

    if (this.weapon === 'bow') {
      ctx.strokeStyle = '#8b4513';
      ctx.lineWidth = 3;
      ctx.beginPath();
      ctx.arc(12, 0, 10, -1, 1);
      ctx.stroke();
      ctx.strokeStyle = '#ddd';
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.moveTo(12, -10);
      ctx.lineTo(12, 10);
      ctx.stroke();
    } else {
      const wData = this.wepData;
      ctx.strokeStyle = wData.color;
      ctx.lineWidth = wData.width;
      ctx.lineCap = 'round';
      const wLen = this.weapon === 'dagger' ? 14 : (this.weapon === 'sword' ? 18 : 22);
      ctx.beginPath();
      ctx.moveTo(8, 0);
      ctx.lineTo(8 + wLen, 0);
      ctx.stroke();
      ctx.strokeStyle = '#aa0';
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(7, -3);
      ctx.lineTo(7, 3);
      ctx.stroke();
      if (this.weapon === 'flamesword' && this.attacking) {
        ctx.fillStyle = 'rgba(255,100,0,0.6)';
        ctx.beginPath();
        ctx.arc(8 + wLen, 0, 5, 0, Math.PI * 2);
        ctx.fill();
      }
    }
    ctx.restore();
    ctx.restore();
  }
}

// ── const: XP_LEVELS ──
// XP thresholds for leveling. Each level-up increases max HP.
const XP_LEVELS = [0, 30, 80, 150, 250];

// ── const: HP_PER_LEVEL ──
const HP_PER_LEVEL = [3, 3, 4, 4, 5]; // max hp at each level

// ── function: generateMap ──
function generateMap() {
  let map, rooms;
  let attempts = 0;
  do {
    map = [];
    for (let y = 0; y < MAP_H; y++) { map[y] = []; for (let x = 0; x < MAP_W; x++) map[y][x] = TILE_WALL; }
    rooms = [];
    for (let i = 0; i < 50; i++) {
      const w = 3 + Math.floor(Math.random() * 5);
      const h = 3 + Math.floor(Math.random() * 4);
      const rx = 1 + Math.floor(Math.random() * (MAP_W - w - 2));
      const ry = 1 + Math.floor(Math.random() * (MAP_H - h - 2));
      let overlap = false;
      for (const r of rooms) { if (rx < r.x + r.w + 1 && rx + w + 1 > r.x && ry < r.y + r.h + 1 && ry + h + 1 > r.y) { overlap = true; break; } }
      if (overlap) continue;
      rooms.push({ x: rx, y: ry, w, h });
      for (let y = ry; y < ry + h; y++) for (let x = rx; x < rx + w; x++) { map[y][x] = TILE_EMPTY; if (Math.random() < 0.03) map[y][x] = TILE_GRASS; }
    }
    // Connect all rooms
    for (let i = 1; i < rooms.length; i++) {
      const a = rooms[i-1], b = rooms[i];
      let cx = Math.floor(a.x + a.w/2), cy = Math.floor(a.y + a.h/2);
      const bx = Math.floor(b.x + b.w/2), by = Math.floor(b.y + b.h/2);
      while (cx !== bx) { if (cy >= 0 && cy < MAP_H && cx >= 0 && cx < MAP_W) { map[cy][cx] = TILE_EMPTY; if (cy+1<MAP_H) map[cy+1][cx]=TILE_EMPTY; } cx += cx<bx?1:-1; }
      while (cy !== by) { if (cy >= 0 && cy < MAP_H && cx >= 0 && cx < MAP_W) { map[cy][cx] = TILE_EMPTY; if (cx+1<MAP_W) map[cy][cx+1]=TILE_EMPTY; } cy += cy<by?1:-1; }
    }
    attempts++;
  } while (rooms.length < 12 && attempts < 20);

  const R = rooms.length;
  // Spawn
  spawnTile = { x: Math.floor(rooms[0].x + rooms[0].w/2), y: Math.floor(rooms[0].y + rooms[0].h/2) };

  // Pick unique room indices for special placements
  var used = {};
  function pickIdx(preferred, minR) {
    if (preferred < R && !used[preferred]) { used[preferred] = true; return preferred; }
    for (var i = minR; i < R; i++) { if (!used[i]) { used[i] = true; return i; } }
    return Math.min(preferred, R-1);
  }
  function roomCenter(r) { return { x: Math.floor(r.x+r.w/2), y: Math.floor(r.y+r.h/2) }; }

  // Saves: rooms 4, 10, R-3
  savePoints = [];
  [4, 10, R-3].forEach(function(si) {
    si = Math.max(0, Math.min(si, R-1));
    var rc = roomCenter(rooms[si]); map[rc.y][rc.x] = TILE_SAVE; savePoints.push({ x: rc.x, y: rc.y, room: rooms[si] });
  });

  // Shop: room 6
  var shopR = rooms[pickIdx(6, 5)]; var sc = roomCenter(shopR); map[sc.y][sc.x] = TILE_SHOP; shopPos = { x: sc.x, y: sc.y };

  // Chests in various rooms
  for (var ci = 0; ci < 10; ci++) {
    var cIdx = 2 + ci * 2;
    if (cIdx >= R - 1) break;
    var crc = roomCenter(rooms[cIdx]); map[crc.y][crc.x] = TILE_CHEST;
  }

  // Water
  for (var wi = 0; wi < 4; wi++) {
    var wr = rooms[3 + Math.floor(Math.random() * Math.max(1, R - 5))];
    if (!wr) continue;
    var wx = wr.x + 1 + Math.floor(Math.random() * Math.max(1, wr.w - 2));
    var wy = wr.y + 1 + Math.floor(Math.random() * Math.max(1, wr.h - 2));
    if (wy < MAP_H && wx < MAP_W && map[wy][wx] === TILE_EMPTY) {
      map[wy][wx] = TILE_WATER; if (wy+1<MAP_H && map[wy+1][wx]===TILE_EMPTY) map[wy+1][wx]=TILE_WATER;
    }
  }

  // Weapon 1: room 3 (early)
  var w1idx = pickIdx(3, 2); var w1rc = roomCenter(rooms[w1idx]);
  weapon1Room = { x: w1rc.x * TILE + TILE/2, y: w1rc.y * TILE + TILE/2 };

  // Ranged: ~55% through
  var rangedIdx = pickIdx(Math.floor(R * 0.55), Math.floor(R * 0.4));
  var rangedRC = roomCenter(rooms[rangedIdx]);
  rangedRoom = { x: rangedRC.x * TILE + TILE/2, y: rangedRC.y * TILE + TILE/2 };

  // Weapon 2: ~75%
  var w2idx = pickIdx(Math.floor(R * 0.75), Math.floor(R * 0.6));
  var w2rc = roomCenter(rooms[w2idx]);
  weapon2Room = { x: w2rc.x * TILE + TILE/2, y: w2rc.y * TILE + TILE/2 };

  // Boss: last room
  var bossRoomIdx = R - 1;
  var bossRC = roomCenter(rooms[bossRoomIdx]);
  bossRoom = { x: bossRC.x * TILE + TILE/2, y: bossRC.y * TILE + TILE/2, room: rooms[bossRoomIdx] };
  for (var by = rooms[bossRoomIdx].y; by < rooms[bossRoomIdx].y + rooms[bossRoomIdx].h; by++)
    for (var bx = rooms[bossRoomIdx].x; bx < rooms[bossRoomIdx].x + rooms[bossRoomIdx].w; bx++)
      if (bx>=0&&bx<MAP_W&&by>=0&&by<MAP_H&&map[by][bx]!==TILE_WALL) map[by][bx]=TILE_EMPTY;

  return { map, rooms };
}

// ── class: Game ──
class Game {
  constructor() {
    this.canvas = document.getElementById('gameCanvas');
    this.canvas.width = CANVAS_W; this.canvas.height = CANVAS_H;
    this.ctx = this.canvas.getContext('2d');
    this.minimapCanvas = document.getElementById('minimap');
    this.minimapCtx = this.minimapCanvas.getContext('2d');
    this.cam = { x: 0, y: 0 };
    this.player = null;
    this.gameOver = false; this.paused = false;
    this.showInventory = false; this.showShop = false;
    this.bossSpawned = false; this.bossDefeated = false;
    this.input = { keys: {}, mouseX: 0, mouseY: 0, mouseDown: false };
    this.lastTime = 0; this.saveFlashTimer = 0;
    this.reset();
  }

  reset() {
    this.map = []; this.rooms = []; this.enemies = []; this.items = [];
    this.projectiles = []; this.meleeHits = []; this.chests = [];
    this.interactables = []; this.messages = [];
  }

  init() {
    this.reset();
    var gen = generateMap();
    this.map = gen.map; this.rooms = gen.rooms;
    var px = spawnTile.x * TILE + TILE / 2;
    var py = spawnTile.y * TILE + TILE / 2;
    this.player = new Player(px, py);
    this._calcLevel();
    this._spawnEnemies();
    this._placeItems();
    var self = this;
    savePoints.forEach(function(sp) { self.pushI('save', sp.x*TILE+TILE/2, sp.y*TILE+TILE/2); });
    if (shopPos.x > 0 && shopPos.y > 0) this.pushI('shop', shopPos.x*TILE+TILE/2, shopPos.y*TILE+TILE/2);
    this.pushI('weapon1', weapon1Room.x, weapon1Room.y, 'Steel Sword', 'sword');
    this.pushI('weapon2', weapon2Room.x, weapon2Room.y, 'Flame Blade', 'flamesword');
    this.pushI('ranged', rangedRoom.x, rangedRoom.y, 'Hunter Bow', 'bow');
    if (bossRoom.room && bossRoom.x > 0) this.pushI('boss', bossRoom.x, bossRoom.y);
    for (var y = 0; y < MAP_H; y++) for (var x = 0; x < MAP_W; x++) {
      if (this.map[y][x] === TILE_CHEST) this.chests.push({ x: x*TILE+TILE/2, y: y*TILE+TILE/2, opened: false, loot: this._rollChest() });
    }
    this._addMessage('Welcome to the Realm of Shadows...', '#ff0');
    this._addMessage('Find weapons, items, and defeat the Shadow Lord!', '#aaa');
  }

  pushI(type, x, y, label, weaponId) { this.interactables.push({ type: type, x: x, y: y, label: label||'', weaponId: weaponId||'' }); }

  _calcLevel() {
    var lvl = 1;
    for (var i = 0; i < XP_LEVELS.length; i++) if (this.player.xp >= XP_LEVELS[i]) lvl = i + 1;
    this.player.level = lvl;
    var idx = Math.min(lvl - 1, HP_PER_LEVEL.length - 1);
    this.player.maxHp = HP_PER_LEVEL[idx];
    this.player.hp = Math.min(this.player.hp, this.player.maxHp);
  }

  _spawnEnemies() {
    for (var i = 1; i < this.rooms.length; i++) {
      var r = this.rooms[i], count = 1 + Math.floor(Math.random() * 3);
      var types = ['slime'];
      if (i > 3) types.push('skeleton');
      if (i > 6) types.push('bat');
      if (i > 9) types.push('knight');
      if (i > 12) types.push('wizard');
      for (var j = 0; j < count; j++) {
        var ex = (r.x + 1 + Math.floor(Math.random() * Math.max(1,r.w-2))) * TILE + TILE/2;
        var ey = (r.y + 1 + Math.floor(Math.random() * Math.max(1,r.h-2))) * TILE + TILE/2;
        this.enemies.push(new Enemy(ex, ey, types[Math.floor(Math.random()*types.length)]));
      }
    }
  }

  _placeItems() {
    var types = ['gold','gold','gold','health_potion','bomb','gold'];
    for (var i = 0; i < 25; i++) {
      var r = this.rooms[Math.floor(Math.random() * Math.max(1, this.rooms.length))];
      if (!r) continue;
      var ix = (r.x+1+Math.floor(Math.random()*Math.max(1,r.w-2)))*TILE+TILE/2;
      var iy = (r.y+1+Math.floor(Math.random()*Math.max(1,r.h-2)))*TILE+TILE/2;
      var t = types[Math.floor(Math.random()*types.length)];
      if (t === 'gold') this.items.push({ x: ix, y: iy, type: 'gold', value: 2+Math.floor(Math.random()*5) });
      else this.items.push({ x: ix, y: iy, type: t });
    }
  }

  _rollChest() {
    var l = [[{type:'gold',value:15}],[{type:'health_potion'},{type:'gold',value:5}],[{type:'super_potion'}],[{type:'shield_charm'}],[{type:'lucky_coin'}],[{type:'bomb'},{type:'bomb'},{type:'gold',value:10}],[{type:'speed_boots'}]];
    return l[Math.floor(Math.random()*l.length)];
  }

  start() { this.init(); this.lastTime = performance.now(); this._loop(); }

  _loop() {
    var now = performance.now();
    var dt = Math.min((now - this.lastTime) / 1000, 0.05);
    this.lastTime = now;
    if (!this.paused && !this.gameOver) this._update(dt);
    this._render();
    var self = this;
    requestAnimationFrame(function() { self._loop(); });
  }

  _update(dt) {
    this.player.update(dt, this.input, this);
    this.cam.x = Math.max(0, Math.min(MAP_W*TILE-CANVAS_W, this.player.x - CANVAS_W/2));
    this.cam.y = Math.max(0, Math.min(MAP_H*TILE-CANVAS_H, this.player.y - CANVAS_H/2));
    var self = this;
    for (var i = this.enemies.length - 1; i >= 0; i--) {
      this.enemies[i].update(dt, this);
      if (this.enemies[i].hp <= 0) {
        var e = this.enemies[i];
        this.player.addGold(e.gold); this.player.xp += e.xp;
        particles.emit(e.x, e.y, 12, { speed: 80, colors: [e.color,'#fff'], life: 0.6, size: 4 });
        self._addMessage('Defeated '+e.name+'! +'+e.xp+' XP, +'+e.gold+'g', '#0f0');
        if (e.type === 'boss') { this.bossDefeated = true; self._addMessage('Victory! The Shadow Lord is slain!', '#ff0'); }
        var oldLvl = this.player.level;
        this._calcLevel();
        if (this.player.level > oldLvl) {
          self._addMessage('Level Up! Lv'+this.player.level+' Max HP: '+this.player.maxHp, '#f0f');
          audio.play('levelup');
          particles.emit(this.player.x, this.player.y, 20, { speed: 60, colors: ['#f0f','#ff0','#fff'], life: 0.8, size: 4, gravity: -20 });
        }
        this.enemies.splice(i, 1);
      }
    }
    for (var i = this.projectiles.length - 1; i >= 0; i--) {
      var p = this.projectiles[i];
      p.x += p.vx*dt; p.y += p.vy*dt; p.life -= dt;
      if (p.life <= 0) { this.projectiles.splice(i,1); continue; }
      var tx = Math.floor(p.x/TILE), ty = Math.floor(p.y/TILE);
      if (tx>=0&&tx<MAP_W&&ty>=0&&ty<MAP_H&&SOLID_TILES.has(this.map[ty][tx])) {
        particles.emit(p.x, p.y, 4, { speed: 40, color: p.color, life: 0.2, size: 2 });
        this.projectiles.splice(i,1); continue;
      }
      if (p.fromPlayer) {
        var hit = false;
        for (var e of this.enemies) {
          var dx=p.x-e.x, dy=p.y-e.y;
          if (dx*dx+dy*dy < (e.size+4)*(e.size+4)) { e.takeDamage(p.damage, this.player.x, this.player.y, p.special); particles.emit(p.x,p.y,6,{speed:60,color:'#ff0',life:0.3,size:3}); hit=true; break; }
        }
        if (hit) { this.projectiles.splice(i,1); continue; }
      } else {
        var dx=p.x-this.player.x, dy=p.y-this.player.y;
        if (dx*dx+dy*dy < (this.player.size+4)*(this.player.size+4)) { this.player.takeDamage(p.damage); particles.emit(p.x,p.y,6,{speed:60,color:'#f0f',life:0.3,size:3}); this.projectiles.splice(i,1); }
      }
    }
    for (var i = this.meleeHits.length - 1; i >= 0; i--) {
      var m = this.meleeHits[i]; m.life -= dt;
      if (m.life <= 0) { this.meleeHits.splice(i,1); continue; }
      for (var e of this.enemies) {
        if (m.hit.has(e)) continue;
        var dx=e.x-m.x, dy=e.y-m.y, dist=Math.sqrt(dx*dx+dy*dy);
        if (dist < m.range + e.size) {
          var diff=Math.atan2(dy,dx)-m.angle;
          while(diff>Math.PI) diff-=2*Math.PI; while(diff<-Math.PI) diff+=2*Math.PI;
          if (Math.abs(diff) < m.arc) { m.hit.add(e); e.takeDamage(m.damage, this.player.x, this.player.y, m.special); }
        }
      }
    }
    for (var i = this.items.length - 1; i >= 0; i--) {
      var item = this.items[i];
      var dx=item.x-this.player.x, dy=item.y-this.player.y;
      if (dx*dx+dy*dy < 400) {
        if (item.type === 'gold') { this.player.addGold(item.value); self._addMessage('+'+item.value+' gold', '#fd0'); }
        else { this.player.inventory.push(item.type); self._addMessage('Found '+ITEMS[item.type].name+'!', '#0ff'); audio.play('pickup'); }
        particles.emit(item.x, item.y, 6, { speed: 30, colors: ['#ff0','#ff'], life: 0.4, size: 3, gravity: -30 });
        this.items.splice(i, 1);
      }
    }
    particles.update(dt);
    if (this.player.hp <= 0 && !this.gameOver) this._playerDeath();
    if (this.saveFlashTimer > 0) this.saveFlashTimer -= dt;
  }

  _playerDeath() { this.gameOver = true; audio.play('death'); document.getElementById('game-over').classList.add('active'); }

  respawn() {
    this.gameOver = false; document.getElementById('game-over').classList.remove('active');
    this.player.hp = this.player.maxHp; this.player.invincible = 2;
    this.player.x = spawnTile.x*TILE+TILE/2; this.player.y = spawnTile.y*TILE+TILE/2;
    this.enemies = []; this._spawnEnemies();
    this._addMessage('Respawned...', '#f80');
  }

  interact() {
    var near = null, nearDist = 1600;
    for (var i = 0; i < this.interactables.length; i++) {
      var dx = this.interactables[i].x - this.player.x;
      var dy = this.interactables[i].y - this.player.y;
      if (dx*dx+dy*dy < nearDist) { nearDist = dx*dx+dy*dy; near = this.interactables[i]; }
    }
    for (var i = 0; i < this.chests.length; i++) {
      if (this.chests[i].opened) continue;
      var dx = this.chests[i].x - this.player.x;
      var dy = this.chests[i].y - this.player.y;
      if (dx*dx+dy*dy < 400) { this.openChest(this.chests[i]); return; }
    }
    if (!near) return;
    switch (near.type) {
      case 'save': this._saveGame(); break;
      case 'shop': this._openShop(); break;
      case 'weapon1': case 'weapon2': case 'ranged':
        if (!this.player.weaponUpgrades.includes(near.weaponId)) this.player.weaponUpgrades.push(near.weaponId);
        this.player.weapon = near.weaponId;
        if (near.weaponId === 'bow') this.player.hasRanged = true;
        this._addMessage('Acquired '+near.label+'!', '#f0f'); audio.play('upgrade');
        particles.emit(near.x, near.y, 15, { speed: 60, colors: ['#f0f','#ff0','#fff'], life: 0.6, size: 4 });
        break;
      case 'boss':
        if (!this.bossSpawned) { this.bossSpawned = true; this.enemies.push(new Enemy(near.x, near.y-50, 'boss')); this._addMessage('The Shadow Lord appears!', '#f00'); }
        break;
    }
  }

  openChest(chest) {
    if (chest.opened) return;
    chest.opened = true; audio.play('pickup');
    particles.emit(chest.x, chest.y, 12, { speed: 50, colors: ['#ff0','#fa0'], life: 0.5, size: 4 });
    var self = this;
    chest.loot.forEach(function(loot) { if (loot.type==='gold') self.player.addGold(loot.value); else { self.player.inventory.push(loot.type); self._addMessage('Found '+ITEMS[loot.type].name+'!','#0ff'); } });
  }

  _saveGame() {
    this.saveFlashTimer = 2;
    var el = document.getElementById('save-indicator');
    el.classList.add('active');
    var self = this;
    setTimeout(function() { el.classList.remove('active'); }, 2000);
    audio.play('save'); self._addMessage('Game Saved', '#4af');
  }
  _openShop() { this.showShop = !this.showShop; this.paused = this.showShop; if (this.showShop) this._renderShop(); }

  buyItem(itemId, price) {
    if (this.player.gold >= price) { this.player.gold -= price; this.player.inventory.push(itemId); audio.play('shop'); this._addMessage('Purchased '+ITEMS[itemId].name+' for '+price+'g', '#c0a040'); this._renderShop(); }
    else this._addMessage('Not enough gold!', '#f44');
  }

  useItem(itemId) {
    var item = ITEMS[itemId]; if (!item) return;
    if (item.type === 'consumable') {
      if (item.effect === 'heal') { this.player.heal(item.value); this._addMessage('Used '+item.name+', +'+item.value+' HP', '#0f0'); }
      else if (item.effect === 'damage') {
        var count = 0;
        for (var e of this.enemies) { var dx=e.x-this.player.x, dy=e.y-this.player.y; if (dx*dx+dy*dy<10000 && e.takeDamage(item.value,this.player.x,this.player.y,null)) count++; }
        particles.emit(this.player.x,this.player.y,20,{speed:100,colors:['#f80','#ff0','#f00'],life:0.5,size:5}); audio.play('hit'); this._addMessage('Boom! Hit '+count+' enemies','#f80');
      }
      var idx = this.player.inventory.indexOf(itemId); if (idx>=0) this.player.inventory.splice(idx,1);
    } else if (item.type === 'accessory') {
      if (!this.player.equipped.includes(itemId)) { this.player.equipped.push(itemId); if (item.effect==='defense') this.player.defense += item.value; this._addMessage('Equipped '+item.name,'#0ff'); audio.play('upgrade'); }
    }
  }

  _addMessage(text, color) { this.messages.push({ text: text, color: color||'#fff', time: 4 }); }

  _renderShop() {
    var panel = document.getElementById('shop-panel');
    if (!this.showShop) { panel.classList.remove('active'); return; }
    panel.classList.add('active');
    var container = document.getElementById('shop-items'); container.innerHTML = '';
    for (var i = 0; i < SHOP_ITEMS.length; i++) {
      var si = SHOP_ITEMS[i], item = ITEMS[si.id];
      var div = document.createElement('div'); div.className = 'shop-item';
      div.innerHTML = '<span>'+item.icon+' '+item.name+' - '+item.desc+'</span><span style="color:#fd0;">'+si.price+'g</span>';
      (function(id, pr) { div.onclick = function() { game.buyItem(id, pr); }; })(si.id, si.price);
      container.appendChild(div);
    }
    document.getElementById('shop-balance').textContent = 'Your gold: '+this.player.gold+'g';
  }

  renderInventory() {
    var panel = document.getElementById('inventory-panel');
    if (!this.showInventory) { panel.classList.remove('active'); return; }
    panel.classList.add('active');
    var container = document.getElementById('inventory-slots'); container.innerHTML = '';
    var counts = {};
    for (var i = 0; i < this.player.inventory.length; i++) { var id = this.player.inventory[i]; counts[id] = (counts[id]||0)+1; }
    for (var id in counts) {
      var item = ITEMS[id];
      var div = document.createElement('div'); div.className = 'item-slot'; div.title = item.desc;
      div.innerHTML = item.icon + '<span class="count">' + counts[id] + '</span>';
      (function(itemId) { div.onclick = function() { game.useItem(itemId); game.renderInventory(); }; })(id);
      container.appendChild(div);
    }
    var eqDiv = document.getElementById('equipment-slots');
    var eq = 'Equipped: '+(this.player.equipped.length===0?'None':this.player.equipped.map(function(id){return ITEMS[id].name;}).join(', '));
    eq += ' | HP: '+this.player.hp+'/'+this.player.maxHp+' | Def: '+this.player.defense;
    eqDiv.textContent = eq;
  }

  _drawHeart(ctx, x, y, size, filled) {
    ctx.save(); ctx.translate(x,y); ctx.scale(size/16,size/16);
    ctx.fillStyle = filled ? '#f22' : '#400';
    ctx.beginPath(); ctx.moveTo(0,-4);
    ctx.bezierCurveTo(-8,-16,-16,-4,0,8); ctx.bezierCurveTo(16,-4,8,-16,0,-4); ctx.fill();
    if (filled) { ctx.fillStyle='rgba(255,100,100,0.4)'; ctx.beginPath(); ctx.arc(-4,-6,3,0,Math.PI*2); ctx.fill(); }
    ctx.restore();
  }

  _render() {
    var ctx = this.ctx; ctx.clearRect(0,0,CANVAS_W,CANVAS_H);
    var startX=Math.max(0,Math.floor(this.cam.x/TILE)), startY=Math.max(0,Math.floor(this.cam.y/TILE));
    var endX=Math.min(MAP_W,Math.ceil((this.cam.x+CANVAS_W)/TILE)+1), endY=Math.min(MAP_H,Math.ceil((this.cam.y+CANVAS_H)/TILE)+1);

    for (var y=startY; y<endY; y++) for (var x=startX; x<endX; x++) {
      var tile=this.map[y][x], sx=x*TILE-this.cam.x, sy=y*TILE-this.cam.y;
      switch(tile) {
        case TILE_EMPTY: ctx.fillStyle='#1a1a2e'; ctx.fillRect(sx,sy,TILE,TILE); break;
        case TILE_WALL: ctx.fillStyle='#2a2a3e'; ctx.fillRect(sx,sy,TILE,TILE); ctx.fillStyle='#3a3a4e'; ctx.fillRect(sx+1,sy+1,TILE-2,TILE-2); break;
        case TILE_GRASS: ctx.fillStyle='#1a2a1e'; ctx.fillRect(sx,sy,TILE,TILE); ctx.fillStyle='#2a4a2e'; ctx.fillRect(sx+4,sy+4,3,3); ctx.fillRect(sx+14,sy+8,3,3); ctx.fillRect(sx+22,sy+2,3,3); break;
        case TILE_WATER: ctx.fillStyle='#1a2a4e'; ctx.fillRect(sx,sy,TILE,TILE); ctx.fillStyle='#2a3a6e'; ctx.fillRect(sx+4,sy+4,TILE-8,3); break;
        case TILE_SAVE:
          ctx.fillStyle='#1a1a2e'; ctx.fillRect(sx,sy,TILE,TILE); ctx.fillStyle='#4488ff';
          ctx.beginPath(); ctx.moveTo(sx+TILE/2,sy+6); ctx.lineTo(sx+TILE-8,sy+TILE/2);
          ctx.lineTo(sx+TILE/2,sy+TILE-6); ctx.lineTo(sx+8,sy+TILE/2); ctx.closePath(); ctx.fill();
          ctx.fillStyle='#88bbff'; ctx.beginPath(); ctx.arc(sx+TILE/2,sy+TILE/2,4,0,Math.PI*2); ctx.fill(); break;
        case TILE_SHOP:
          ctx.fillStyle='#2a2a1e'; ctx.fillRect(sx,sy,TILE,TILE);
          ctx.fillStyle='#c0a040'; ctx.font='18px serif'; ctx.textAlign='center'; ctx.fillText('$',sx+TILE/2,sy+TILE/2+6); ctx.textAlign='left'; break;
        case TILE_CHEST:
          ctx.fillStyle='#1a1a2e'; ctx.fillRect(sx,sy,TILE,TILE);
          var ch=this.chests.find(function(c){return Math.abs(c.x-(x*TILE+TILE/2))<1 && Math.abs(c.y-(y*TILE+TILE/2))<1;});
          if (ch && !ch.opened) { ctx.fillStyle='#8b4513'; ctx.fillRect(sx+6,sy+10,TILE-12,TILE-14); ctx.fillStyle='#c0a040'; ctx.fillRect(sx+6,sy+14,TILE-12,4); ctx.fillStyle='#ff0'; ctx.fillRect(sx+13,sy+12,6,6); }
          else { ctx.fillStyle='#5a3010'; ctx.fillRect(sx+6,sy+14,TILE-12,TILE-18); }
          break;
      }
    }

    for (var i = 0; i < this.interactables.length; i++) {
      var inter = this.interactables[i], sx=inter.x-this.cam.x, sy=inter.y-this.cam.y;
      if (sx<-50||sx>CANVAS_W+50||sy<-50||sy>CANVAS_H+50) continue;
      if (inter.type==='weapon1'||inter.type==='weapon2'||inter.type==='ranged') {
        if (inter.weaponId===this.player.weapon) continue;
        ctx.fillStyle='rgba(255,0,255,0.2)'; ctx.beginPath(); ctx.arc(sx,sy,18+Math.sin(Date.now()*0.003)*3,0,Math.PI*2); ctx.fill();
        ctx.fillStyle='#808'; ctx.fillRect(sx-10,sy-4,20,8);
        ctx.font='16px serif'; ctx.textAlign='center'; ctx.fillStyle='#f0f';
        ctx.fillText(inter.type==='ranged'?'///':'++',sx,sy+5); ctx.textAlign='left';
      } else if (inter.type==='boss' && !this.bossDefeated) {
        ctx.fillStyle='rgba(255,0,0,0.3)'; ctx.beginPath(); ctx.arc(sx,sy,20+Math.sin(Date.now()*0.005)*5,0,Math.PI*2); ctx.fill();
        ctx.fillStyle='#f00'; ctx.font='16px serif'; ctx.textAlign='center'; ctx.fillText('XX',sx,sy+5); ctx.textAlign='left';
      }
    }

    for (var i = 0; i < this.items.length; i++) {
      var item=this.items[i], sx=item.x-this.cam.x, sy=item.y-this.cam.y;
      if (sx<-20||sx>CANVAS_W+20||sy<-20||sy>CANVAS_H+20) continue;
      if (item.type==='gold') { ctx.fillStyle='#fd0'; ctx.beginPath(); ctx.arc(sx,sy,5,0,Math.PI*2); ctx.fill(); ctx.fillStyle='#a80'; ctx.beginPath(); ctx.arc(sx,sy,3,0,Math.PI*2); ctx.fill(); }
      else { ctx.fillStyle='rgba(0,100,255,0.5)'; ctx.beginPath(); ctx.arc(sx,sy,8,0,Math.PI*2); ctx.fill(); ctx.font='12px serif'; ctx.textAlign='center'; ctx.fillStyle='#fff'; ctx.fillText(ITEMS[item.type].icon,sx,sy+4); ctx.textAlign='left'; }
    }

    for (var i = 0; i < this.enemies.length; i++) this.enemies[i].render(ctx, this.cam);
    if (this.player) this.player.render(ctx, this.cam);

    for (var i = 0; i < this.meleeHits.length; i++) {
      var m=this.meleeHits[i], sx=m.x-this.cam.x, sy=m.y-this.cam.y;
      ctx.strokeStyle='rgba(255,255,255,'+(m.life/0.15)+')'; ctx.lineWidth=3;
      ctx.beginPath(); ctx.arc(sx,sy,m.range,m.angle-m.arc,m.angle+m.arc); ctx.stroke();
    }

    for (var i = 0; i < this.projectiles.length; i++) {
      var p=this.projectiles[i], sx=p.x-this.cam.x, sy=p.y-this.cam.y;
      ctx.fillStyle=p.color; ctx.beginPath(); ctx.arc(sx,sy,4,0,Math.PI*2); ctx.fill();
      ctx.fillStyle=p.color+'44'; ctx.beginPath(); ctx.arc(sx-p.vx*0.02,sy-p.vy*0.02,3,0,Math.PI*2); ctx.fill();
    }

    particles.render(ctx, this.cam);

    // HUD
    if (this.player) {
      var fullH=Math.floor(this.player.hp/2), half=this.player.hp%2===1, maxH=Math.ceil(this.player.maxHp/2);
      for (var i=0; i<maxH; i++) {
        var hx=16+i*22, hy=18;
        if (i<fullH) this._drawHeart(ctx,hx,hy,14,true);
        else if (i===fullH && half) {
          ctx.save(); ctx.beginPath(); ctx.rect(hx-8,hy-8,8,16); ctx.clip();
          this._drawHeart(ctx,hx,hy,14,true); ctx.restore();
          ctx.save(); ctx.globalAlpha=0.4; this._drawHeart(ctx,hx,hy,14,false); ctx.restore();
        } else this._drawHeart(ctx,hx,hy,14,false);
      }
      ctx.fillStyle='#fd0'; ctx.font='bold 16px Courier New'; ctx.textAlign='left';
      ctx.fillText('Gold: '+this.player.gold, 16, 50);
      ctx.fillStyle='#fff'; ctx.font='13px Courier New';
      ctx.fillText('Lv'+this.player.level+'  XP:'+this.player.xp+'  HP:'+this.player.hp+'/'+this.player.maxHp, 16, 68);
      var xpNext=XP_LEVELS[Math.min(this.player.level,XP_LEVELS.length-1)]||999;
      var xpPrev=XP_LEVELS[Math.max(0,this.player.level-2)]||0;
      var prog=this.player.level<XP_LEVELS.length?(this.player.xp-xpPrev)/(xpNext-xpPrev):1;
      ctx.fillStyle='#333'; ctx.fillRect(16,73,120,6);
      ctx.fillStyle='#a0f'; ctx.fillRect(16,73,Math.max(0,Math.min(120,prog*120)),6);
      ctx.fillStyle='#aaa'; ctx.font='12px Courier New';
      ctx.fillText(this.player.wepData.name+' (Atk '+this.player.damage+')', 16, 95);
      if (this.player.inventory.length > 0) {
        ctx.fillStyle='#888'; ctx.font='10px Courier New';
        ctx.fillText('Items: '+this.player.inventory.map(function(id){return ITEMS[id].icon;}).join(''), 16, 112);
      }
    }

    // Interaction prompt
    var nearI=null, nearD=1600;
    for (var i=0;i<this.interactables.length;i++) {
      var dx=this.interactables[i].x-this.player.x, dy=this.interactables[i].y-this.player.y;
      if (dx*dx+dy*dy<nearD) { nearD=dx*dx+dy*dy; nearI=this.interactables[i]; }
    }
    var promptEl=document.getElementById('interaction-prompt');
    if (nearI && !this.showShop) {
      var txt='[E] ';
      if (nearI.type==='save') txt+='Save Game';
      else if (nearI.type==='shop') txt+='Open Shop';
      else if (nearI.type==='weapon1'||nearI.type==='weapon2'||nearI.type==='ranged') txt+='Pick up '+nearI.label;
      else if (nearI.type==='boss') txt+='Boss Arena';
      promptEl.textContent=txt; promptEl.classList.add('active');
    } else promptEl.classList.remove('active');

    this._renderMessages();

    if (this.saveFlashTimer > 0) {
      ctx.fillStyle='rgba(68,170,255,'+Math.min(1,this.saveFlashTimer)+')';
      ctx.font='bold 24px Courier New'; ctx.textAlign='center';
      ctx.fillText('Game Saved!',CANVAS_W/2,CANVAS_H/2-50); ctx.textAlign='left';
    }
    this._renderMinimap();
  }

  _renderMessages() {
    var container=document.getElementById('message-log');
    for (var i=this.messages.length-1;i>=0;i--) { this.messages[i].time-=0.016; if(this.messages[i].time<=0) this.messages.splice(i,1); }
    container.innerHTML=this.messages.slice(-5).map(function(m){return '<div style="color:'+m.color+';font-size:13px;margin:2px 0;text-shadow:0 0 4px '+m.color+';">'+m.text+'</div>';}).join('');
  }

  _renderMinimap() {
    var ctx=this.minimapCtx, mw=this.minimapCanvas.width, mh=this.minimapCanvas.height;
    ctx.fillStyle='#111'; ctx.fillRect(0,0,mw,mh);
    var sx=mw/MAP_W, sy=mh/MAP_H;
    for (var y=0;y<MAP_H;y++) for (var x=0;x<MAP_W;x++) {
      var t=this.map[y][x];
      if (t===TILE_WALL) ctx.fillStyle='#333';
      else if (t===TILE_EMPTY||t===TILE_GRASS) ctx.fillStyle='#555';
      else if (t===TILE_WATER) ctx.fillStyle='#246';
      else if (t===TILE_SAVE) ctx.fillStyle='#4af';
      else if (t===TILE_SHOP) ctx.fillStyle='#ca4';
      else if (t===TILE_CHEST) ctx.fillStyle='#fa0';
      else continue;
      ctx.fillRect(x*sx,y*sy,sx+0.5,sy+0.5);
    }
    ctx.fillStyle='#0f0'; ctx.fillRect((this.player.x/TILE)*sx-1,(this.player.y/TILE)*sy-1,3,3);
    ctx.fillStyle='#f44';
    for (var i=0;i<this.enemies.length;i++) ctx.fillRect((this.enemies[i].x/TILE)*sx-1,(this.enemies[i].y/TILE)*sy-1,2,2);
  }
}

// ── var: game ──
var game = new Game();

// ── main ──
document.addEventListener('keydown', function(e) {
  var key = e.key.toLowerCase();
  game.input.keys[key] = true;
  if (key === 'i') { game.showInventory = !game.showInventory; game.paused = game.showInventory; game.renderInventory(); e.preventDefault(); }
  if (key === 'e') {
    if (game.showShop) { game.showShop = false; game.paused = false; document.getElementById('shop-panel').classList.remove('active'); }
    else game.interact();
    e.preventDefault();
  }
  if (key === 'escape') { game.showInventory = false; game.showShop = false; game.paused = false; document.getElementById('inventory-panel').classList.remove('active'); document.getElementById('shop-panel').classList.remove('active'); }
  if (key === ' ') e.preventDefault();
});

document.addEventListener('keyup', function(e) { game.input.keys[e.key.toLowerCase()] = false; });

game.canvas.addEventListener('mousemove', function(e) {
  var rect = game.canvas.getBoundingClientRect();
  game.input.mouseX = (e.clientX - rect.left) * (CANVAS_W / rect.width);
  game.input.mouseY = (e.clientY - rect.top) * (CANVAS_H / rect.height);
});

game.canvas.addEventListener('mousedown', function(e) { if (e.button === 0) { audio.init(); game.input.mouseDown = true; } });
game.canvas.addEventListener('mouseup', function(e) { if (e.button === 0) game.input.mouseDown = false; });
game.canvas.addEventListener('contextmenu', function(e) { e.preventDefault(); });

document.getElementById('start-btn').addEventListener('click', function() {
  audio.init();
  document.getElementById('start-screen').style.display = 'none';
  game.start();
});

document.getElementById('respawn-btn').addEventListener('click', function() { game.respawn(); });

function resizeCanvas() {
  var maxW = window.innerWidth - 20, maxH = window.innerHeight - 20;
  var scale = Math.min(maxW / CANVAS_W, maxH / CANVAS_H);
  game.canvas.style.width = (CANVAS_W * scale) + 'px';
  game.canvas.style.height = (CANVAS_H * scale) + 'px';
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
</script>
</body>
</html>
<!-- agent-meta {"model":"meituan/longcat-2.0","provider":"openrouter","persona":"composer","sessionId":"8314909c-0f02-4867-8791-29ecea0a71a4","tokensIn":4743640,"tokensOut":97667,"tokensTotal":4841307,"turns":68,"toolCalls":67,"failedToolCalls":7,"timestamp":"2026-07-20T18:06:40.597Z"} -->