← RPG results

RPG

Deepseek v4 Pro · middle

Initial view of RPG

Browser check: Artifact runtime error. This is a technical check, not a quality review.

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 53,409 bytes · SHA-256 49169fb60073
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Shard of the Verdant Blade</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;}
body{background:#0a0a0a;display:flex;justify-content:center;align-items:center;height:100vh;overflow:hidden;font-family:'Segoe UI',sans-serif;}
canvas{display:block;image-rendering:pixelated;border:2px solid #333;border-radius:4px;}
#ui-overlay{position:absolute;pointer-events:none;}
</style>
</head>
<body>
<canvas id="game"></canvas>
<script>
// ────────────────────────────────────────
// SHARD OF THE VERDANT BLADE - Action RPG
// ────────────────────────────────────────
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');

// Responsive sizing
const W = 800, H = 600;
canvas.width = W; canvas.height = H;
const TILE = 32;
const COLS = 25, ROWS = 19; // visible tiles

// ── AUDIO ENGINE (Web Audio API) ──
let audioCtx = null;
function initAudio() {
  if (audioCtx) return;
  audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
function playTone(freq, dur, type='square', vol=0.08, slide=0) {
  if (!audioCtx) return;
  const t = audioCtx.currentTime;
  const osc = audioCtx.createOscillator();
  const gain = audioCtx.createGain();
  osc.type = type;
  osc.frequency.setValueAtTime(freq, t);
  if (slide) osc.frequency.linearRampToValueAtTime(freq + slide, t + dur);
  gain.gain.setValueAtTime(vol, t);
  gain.gain.exponentialRampToValueAtTime(0.001, t + dur);
  osc.connect(gain); gain.connect(audioCtx.destination);
  osc.start(t); osc.stop(t + dur);
}
function sfxStep() { playTone(80, 0.06, 'sine', 0.03); }
function sfxSwing() { playTone(200, 0.12, 'sawtooth', 0.07, -80); }
function sfxHit() { playTone(60, 0.15, 'square', 0.1); playTone(40, 0.2, 'triangle', 0.08); }
function sfxPickup() { playTone(600, 0.08, 'sine', 0.06); setTimeout(()=>playTone(900, 0.1, 'sine', 0.06), 80); }
function sfxCoin() { playTone(1200, 0.05, 'square', 0.04); setTimeout(()=>playTone(1400, 0.06, 'square', 0.04), 50); }
function sfxShop() { playTone(400, 0.1, 'triangle', 0.06); setTimeout(()=>playTone(500, 0.12, 'triangle', 0.06), 100); }
function sfxSave() { playTone(300, 0.2, 'sine', 0.07); setTimeout(()=>playTone(500, 0.3, 'sine', 0.07), 150); }
function sfxBow() { playTone(300, 0.08, 'triangle', 0.05); playTone(150, 0.2, 'sine', 0.04); }
function sfxUpgrade() { playTone(440, 0.1, 'sine', 0.06); setTimeout(()=>playTone(660, 0.1, 'sine', 0.06), 100); setTimeout(()=>playTone(880, 0.15, 'sine', 0.07), 200); }
function sfxDamage() { playTone(50, 0.3, 'sawtooth', 0.12); }

// ── PARTICLE SYSTEM ──
let particles = [];
function spawnParticles(x, y, count, color, speed=2, life=20) {
  for (let i = 0; i < count; i++) {
    const a = Math.random() * Math.PI * 2;
    const s = speed * (0.5 + Math.random());
    particles.push({ x, y, vx: Math.cos(a)*s, vy: Math.sin(a)*s, life, maxLife: life, color });
  }
}

// ── INPUT ──
const keys = {};
window.addEventListener('keydown', e => {
  keys[e.key.toLowerCase()] = true;
  if (e.key.toLowerCase() === 'e') keys['interact'] = true;
  if (e.key.toLowerCase() === 'i') { STATE.inventoryOpen = !STATE.inventoryOpen; e.preventDefault(); return; }
  if (e.key.toLowerCase() === 'f') keys['attack'] = true;
  e.preventDefault();
});
window.addEventListener('keyup', e => {
  keys[e.key.toLowerCase()] = false;
  if (e.key.toLowerCase() === 'e') keys['interact'] = false;
  if (e.key.toLowerCase() === 'f') keys['attack'] = false;
  e.preventDefault();
});
canvas.addEventListener('click', () => initAudio());

// ── GAME STATE ──
const STATE = {
  player: {
    x: 5 * TILE + TILE / 2, y: 8 * TILE + TILE / 2,
    vx: 0, vy: 0,
    hp: 3, maxHp: 5,
    weapon: 0, // 0=rusty sword, 1=broadsword, 2=dual blades
    hasRanged: false, // discovered mid-game
    rangedAmmo: 10, maxAmmo: 20,
    gold: 0,
    inventory: [], // [{id, name, desc, type}] type: 'consumable','accessory','key'
    accessories: [], // equipped accessories (max 2)
    speed: 2.5,
    attackCooldown: 0,
    attackDuration: 0,
    facing: 0, // 0=down, 1=up, 2=left, 3=right
    invincible: 0,
    animFrame: 0,
    stepTimer: 0,
    currentRoom: 0,
  },
  rooms: [], // populated below
  enemies: [],
  projectiles: [],
  pickups: [], // ground items / coins
  savePoints: [],
  shopOpen: false,
  dialogText: '',
  dialogTimer: 0,
  gameTime: 0,
  totalKills: 0,
  totalGoldEarned: 0,
  upgradesFound: 0,
  rangedFound: false,
  gameComplete: false,
  inventoryOpen: false,
};

// ── MAP DEFINITIONS ──
// Legend: 0=void, 1=floor, 2=wall, 3=water, 4=door, 5=chest, 6=shop, 7=save
// Rooms are 20x15 tiles

function makeRoom(data) {
  // data: { tiles: 2D array, enemies: [{x,y,type}], pickups: [{x,y,type}], exits: {dir:roomIdx}, name }
  return data;
}

const ROOM_DEFS = [];

// Room 0: Village (start)
ROOM_DEFS.push({
  tiles: (()=>{
    const t = [];
    for (let y=0; y<15; y++) { t[y]=[]; for (let x=0; x<20; x++) {
      if (x===0||x===19||y===0||y===14) t[y][x]=2;
      else t[y][x]=1;
    }}
    // Buildings
    for (let x=2;x<=5;x++) for (let y=2;y<=4;y++) t[y][x]=2;
    t[2][3]=1; t[2][4]=1; // doorways
    for (let x=8;x<=11;x++) for (let y=3;y<=5;y++) t[y][x]=2;
    t[3][9]=1; t[5][8]=1;
    // Water well
    t[10][14]=3; t[10][15]=3; // water
    t[9][14]=2; t[11][14]=2;
    // Shop stall
    t[5][13]=6; t[5][14]=6;
    t[4][13]=2; t[6][13]=2; t[4][14]=2; t[6][14]=2;
    // Save point
    t[16][7]=7;
    t[15][7]=2; t[17][7]=2; t[16][6]=2; t[16][8]=2;
    // Path to exit (right)
    for (let x=17;x<20;x++) t[13][x]=1;
    // Also open a path at row 7 for return
    t[7][0]=1; t[7][1]=1;
    return t;
  })(),
  enemies: [],
  pickups: [{x:3*TILE+TILE/2, y:10*TILE+TILE/2, type:'coin', amount:10},
            {x:16*TILE+TILE/2, y:10*TILE+TILE/2, type:'coin', amount:5}],
  exits: { right: 1 },
  name: 'Village',
});

// Room 1: Forest Path
ROOM_DEFS.push({
  tiles: (()=>{
    const t = [];
    for (let y=0; y<15; y++) { t[y]=[]; for (let x=0; x<20; x++) {
      if (x===0||x===19||y===0||y===14) {
        if ((x===0&&y===7)||(x===19&&y===7)||(y===0&&x===10)||(y===14&&x===10)) t[y][x]=1;
        else t[y][x]=2;
      }
      else t[y][x]=1;
    }}
    // Trees/obstacles
    for (let i=0;i<8;i++) {
      const tx=3+Math.floor(Math.random()*14), ty=3+Math.floor(Math.random()*9);
      t[ty][tx]=2; t[ty][tx+1]=2; t[ty+1][tx]=2;
    }
    // Exit paths
    t[7][0]=1; t[7][19]=1;
    t[13][10]=1; t[14][10]=1;
    return t;
  })(),
  enemies: [
    {x:6*TILE+TILE/2, y:5*TILE+TILE/2, type:'slime'},
    {x:12*TILE+TILE/2, y:8*TILE+TILE/2, type:'slime'},
    {x:15*TILE+TILE/2, y:4*TILE+TILE/2, type:'slime'},
  ],
  pickups: [{x:8*TILE+TILE/2, y:10*TILE+TILE/2, type:'coin', amount:8},
            {x:14*TILE+TILE/2, y:11*TILE+TILE/2, type:'health_potion'}],
  exits: { left: 0, right: 2, down: 3 },
  name: 'Forest Path',
});

// Room 2: Forest Clearing (Weapon Upgrade 1)
ROOM_DEFS.push({
  tiles: (()=>{
    const t = [];
    for (let y=0; y<15; y++) { t[y]=[]; for (let x=0; x<20; x++) {
      if (x===0||x===19||y===0||y===14) t[y][x]=2;
      else t[y][x]=1;
    }}
    t[7][0]=1;
    // Central pedestal
    t[6][9]=2; t[6][10]=2; t[7][9]=5; t[7][10]=2; t[8][9]=2; t[8][10]=2;
    // Obstacles
    for (let i=0;i<5;i++) {
      t[3+i][4]=2; t[3+i][15]=2;
    }
    return t;
  })(),
  enemies: [
    {x:5*TILE+TILE/2, y:6*TILE+TILE/2, type:'skeleton'},
    {x:14*TILE+TILE/2, y:6*TILE+TILE/2, type:'skeleton'},
  ],
  pickups: [{x:7*TILE+TILE/2, y:9*TILE+TILE/2, type:'broadsword_upgrade'}],
  exits: { left: 1 },
  name: 'Forest Clearing',
});

// Room 3: Cave Entrance
ROOM_DEFS.push({
  tiles: (()=>{
    const t = [];
    for (let y=0; y<15; y++) { t[y]=[]; for (let x=0; x<20; x++) {
      if (x===0||x===19||y===0||y===14) {
        if ((y===0&&x===10)||(y===14&&x===10)) t[y][x]=1;
        else t[y][x]=2;
      }
      else t[y][x]=1;
    }}
    // Rocky formations
    for (let i=0;i<6;i++) {
      t[4][2+i*3]=2; t[4][3+i*3]=2;
      t[10][3+i*3]=2; t[10][4+i*3]=2;
    }
    // Exit paths
    t[0][10]=1; // up to Forest Path
    t[14][10]=1; // down to Cave Depths
    // Save point
    t[12][2]=7; t[12][3]=2;
    return t;
  })(),
  enemies: [
    {x:5*TILE+TILE/2, y:6*TILE+TILE/2, type:'slime'},
    {x:9*TILE+TILE/2, y:10*TILE+TILE/2, type:'skeleton'},
    {x:14*TILE+TILE/2, y:7*TILE+TILE/2, type:'slime'},
    {x:11*TILE+TILE/2, y:4*TILE+TILE/2, type:'skeleton'},
  ],
  pickups: [{x:5*TILE+TILE/2, y:11*TILE+TILE/2, type:'coin', amount:15},
            {x:16*TILE+TILE/2, y:5*TILE+TILE/2, type:'shield_accessory'}],
  exits: { up: 1, down: 4 },
  name: 'Cave Entrance',
});

// Room 4: Cave Depths (Ranged weapon discovery - mid game)
ROOM_DEFS.push({
  tiles: (()=>{
    const t = [];
    for (let y=0; y<15; y++) { t[y]=[]; for (let x=0; x<20; x++) {
      if (x===0||x===19||y===0||y===14) t[y][x]=2;
      else t[y][x]=1;
    }}
    t[0][10]=1; t[14][10]=1;
    // Glowing shrine in center
    t[7][9]=5; t[7][10]=5; t[7][8]=2; t[7][11]=2;
    t[6][9]=2; t[6][10]=2; t[8][9]=2; t[8][10]=2;
    // Water pools
    for (let x=2;x<=5;x++) { t[3][x]=3; t[4][x]=3; }
    for (let x=14;x<=17;x++) { t[10][x]=3; t[11][x]=3; }
    return t;
  })(),
  enemies: [
    {x:4*TILE+TILE/2, y:7*TILE+TILE/2, type:'skeleton'},
    {x:15*TILE+TILE/2, y:5*TILE+TILE/2, type:'skeleton'},
    {x:10*TILE+TILE/2, y:12*TILE+TILE/2, type:'ghost'},
    {x:8*TILE+TILE/2, y:4*TILE+TILE/2, type:'ghost'},
  ],
  pickups: [{x:7*TILE+TILE/2, y:9*TILE+TILE/2, type:'ranged_bow'}],
  exits: { up: 3, down: 5 },
  name: 'Cave Depths',
});

// Room 5: Deep Cave (Weapon Upgrade 2)
ROOM_DEFS.push({
  tiles: (()=>{
    const t = [];
    for (let y=0; y<15; y++) { t[y]=[]; for (let x=0; x<20; x++) {
      if (x===0||x===19||y===0||y===14) t[y][x]=2;
      else t[y][x]=1;
    }}
    t[0][10]=1; t[14][10]=1;
    // Lava cracks
    for (let i=0;i<10;i++) {
      const lx=2+Math.floor(Math.random()*16), ly=3+Math.floor(Math.random()*9);
      t[ly][lx]=3; if (ly+1<14) t[ly+1][lx]=3;
    }
    // Altar with upgrade
    t[3][16]=5; t[3][17]=2; t[4][16]=2; t[2][16]=2;
    // Save point
    t[1][3]=7;
    return t;
  })(),
  enemies: [
    {x:6*TILE+TILE/2, y:5*TILE+TILE/2, type:'ghost'},
    {x:13*TILE+TILE/2, y:8*TILE+TILE/2, type:'ghost'},
    {x:8*TILE+TILE/2, y:10*TILE+TILE/2, type:'skeleton'},
    {x:16*TILE+TILE/2, y:4*TILE+TILE/2, type:'ghost'},
  ],
  pickups: [{x:3*TILE+TILE/2, y:16*TILE+TILE/2, type:'dualblades_upgrade'},
            {x:12*TILE+TILE/2, y:3*TILE+TILE/2, type:'coin', amount:25},
            {x:8*TILE+TILE/2, y:6*TILE+TILE/2, type:'damage_amulet'}],
  exits: { up: 4, down: 6 },
  name: 'Deep Cave',
});

// Room 6: Boss Chamber
ROOM_DEFS.push({
  tiles: (()=>{
    const t = [];
    for (let y=0; y<15; y++) { t[y]=[]; for (let x=0; x<20; x++) {
      if (x===0||x===19||y===0||y===14) t[y][x]=2;
      else t[y][x]=1;
    }}
    t[0][10]=1; // entrance only
    // Pillars
    t[3][3]=2; t[3][16]=2; t[11][3]=2; t[11][16]=2;
    t[7][9]=2; t[7][10]=2;
    return t;
  })(),
  enemies: [
    {x:10*TILE+TILE/2, y:7*TILE+TILE/2, type:'boss'},
  ],
  pickups: [{x:10*TILE+TILE/2, y:12*TILE+TILE/2, type:'heart_container'}],
  exits: { up: 5 },
  name: 'Boss Chamber',
});

// Initialize rooms
STATE.rooms = ROOM_DEFS.map(def => ({
  tiles: def.tiles,
  enemies: def.enemies.map(e => ({
    x: e.x, y: e.y,
    type: e.type,
    hp: e.type==='slime'?2:e.type==='skeleton'?4:e.type==='ghost'?3:15,
    maxHp: e.type==='slime'?2:e.type==='skeleton'?4:e.type==='ghost'?3:15,
    speed: e.type==='slime'?0.8:e.type==='skeleton'?1.2:e.type==='ghost'?1.5:0.7,
    damage: e.type==='boss'?2:1,
    cooldown: 0,
    animTimer: 0,
    alive: true,
  })),
  pickups: def.pickups.map(p => ({...p, alive: true})),
  exits: def.exits,
  name: def.name,
}));

STATE.savePoints = [
  { room: 0, x: 16*TILE+TILE/2, y: 7*TILE+TILE/2, used: false },
  { room: 3, x: 12*TILE+TILE/2, y: 2*TILE+TILE/2, used: false },
  { room: 5, x: 1*TILE+TILE/2, y: 3*TILE+TILE/2, used: false },
];

// ── HELPER FUNCTIONS ──
function getRoom() { return STATE.rooms[STATE.player.currentRoom]; }
function tileAt(room, tx, ty) {
  if (tx < 0 || tx >= 20 || ty < 0 || ty >= 15) return 2; // walls at edges
  return room.tiles[ty][tx];
}
function isSolid(tile) { return tile === 2 || tile === 4; }
function isSolidPos(room, px, py) {
  const tx = Math.floor(px / TILE), ty = Math.floor(py / TILE);
  if (tx < 0 || tx >= 20 || ty < 0 || ty >= 15) return true;
  return isSolid(room.tiles[ty][tx]);
}

// ── WEAPON DEFINITIONS ──
const WEAPONS = [
  { name: 'Rusty Sword', damage: 1, speed: 25, range: 28, color: '#aab', desc: 'A worn blade.' },
  { name: 'Broadsword', damage: 2, speed: 40, range: 34, color: '#c94', desc: 'Heavy, devastating swings.' },
  { name: 'Dual Blades', damage: 1, speed: 14, range: 24, color: '#5cf', desc: 'Furious flurry of strikes.' },
];

// ── ACCESSORY DEFINITIONS ──
const ACCESSORIES = {
  shield_accessory: { name: 'Iron Shield', desc: 'Reduces damage taken by 1.', effect: 'shield' },
  damage_amulet: { name: 'Berserker Amulet', desc: '+1 damage to all attacks.', effect: 'dmgUp' },
  speed_boots: { name: 'Wind Boots', desc: 'Movement speed increased.', effect: 'speedUp' },
  life_ring: { name: 'Vitality Ring', desc: 'Slow HP regeneration.', effect: 'regen' },
};

// ── COLLISION DETECTION ──
function dist(a, b) {
  const dx = a.x - b.x, dy = a.y - b.y;
  return Math.sqrt(dx * dx + dy * dy);
}

function tryMove(entity, dx, dy, room) {
  const nx = entity.x + dx, ny = entity.y + dy;
  const r = 10; // collision radius
  // Check tile collisions at corners
  if (isSolidPos(room, nx-r, ny-r) || isSolidPos(room, nx+r, ny-r) ||
      isSolidPos(room, nx-r, ny+r) || isSolidPos(room, nx+r, ny+r)) return false;
  // Check other entities (enemies block each other)
  if (entity !== STATE.player) {
    for (const e of room.enemies) {
      if (e === entity || !e.alive) continue;
      if (dist({x:nx,y:ny}, e) < 18) return false;
    }
    if (dist({x:nx,y:ny}, STATE.player) < 16) return false;
  }
  entity.x = nx;
  entity.y = ny;
  return true;
}

// ── SAVE / LOAD ──
function saveGame(slot) {
  const data = {
    player: {
      x: STATE.player.x, y: STATE.player.y,
      hp: STATE.player.hp, maxHp: STATE.player.maxHp,
      weapon: STATE.player.weapon, hasRanged: STATE.player.hasRanged,
      rangedAmmo: STATE.player.rangedAmmo, gold: STATE.player.gold,
      inventory: STATE.player.inventory, accessories: STATE.player.accessories,
      currentRoom: STATE.player.currentRoom,
    },
    totalKills: STATE.totalKills, totalGoldEarned: STATE.totalGoldEarned,
    upgradesFound: STATE.upgradesFound, rangedFound: STATE.rangedFound,
    savePoints: STATE.savePoints,
    rooms: STATE.rooms.map(r => ({
      enemies: r.enemies.map(e => ({...e})),
      pickups: r.pickups.map(p => ({...p})),
    })),
  };
  localStorage.setItem('rpg_save_' + slot, JSON.stringify(data));
  STATE.dialogText = 'Progress saved!';
  STATE.dialogTimer = 90;
  sfxSave();
}

function loadGame(slot) {
  const raw = localStorage.getItem('rpg_save_' + slot);
  if (!raw) return false;
  try {
    const data = JSON.parse(raw);
    Object.assign(STATE.player, data.player);
    STATE.totalKills = data.totalKills;
    STATE.totalGoldEarned = data.totalGoldEarned;
    STATE.upgradesFound = data.upgradesFound;
    STATE.rangedFound = data.rangedFound;
    STATE.savePoints = data.savePoints;
    for (let i = 0; i < STATE.rooms.length; i++) {
      if (data.rooms[i]) {
        STATE.rooms[i].enemies = data.rooms[i].enemies;
        STATE.rooms[i].pickups = data.rooms[i].pickups;
      }
    }
    STATE.dialogText = 'Game loaded!';
    STATE.dialogTimer = 90;
    sfxSave();
    return true;
  } catch(e) { return false; }
}

// ── SHOP ITEMS ──
const SHOP_ITEMS = [
  { id: 'health_potion', name: 'Health Potion', desc: 'Restores 2 HP.', cost: 15, type: 'consumable' },
  { id: 'speed_boots', name: 'Wind Boots', desc: 'Move 30% faster.', cost: 40, type: 'accessory', effect: 'speedUp' },
  { id: 'life_ring', name: 'Vitality Ring', desc: 'Slow HP regen.', cost: 50, type: 'accessory', effect: 'regen' },
  { id: 'arrows', name: 'Arrow Bundle', desc: '+10 arrows.', cost: 20, type: 'ammo' },
];

// ── UPDATE ──
function update() {
  const p = STATE.player;
  const room = getRoom();

  // Dialog timer
  if (STATE.dialogTimer > 0) STATE.dialogTimer--;

  // Player movement
  let mx = 0, my = 0;
  if (keys['w'] || keys['arrowup']) my = -1;
  if (keys['s'] || keys['arrowdown']) my = 1;
  if (keys['a'] || keys['arrowleft']) mx = -1;
  if (keys['d'] || keys['arrowright']) mx = 1;

  if (mx !== 0 || my !== 0) {
    const len = Math.sqrt(mx*mx+my*my);
    mx /= len; my /= len;
    const spd = p.speed * (p.accessories.some(a=>a.effect==='speedUp')?1.3:1);
    
    // Check for room transitions before movement
    const exits = room.exits;
    const ctx2 = Math.floor(p.x / TILE), cty = Math.floor(p.y / TILE);
    
    if (ctx2 <= 0 && mx < 0 && exits.left !== undefined) { p.currentRoom = exits.left; p.x = 19*TILE+TILE/2-2; sfxStep(); }
    else if (ctx2 >= 19 && mx > 0 && exits.right !== undefined) { p.currentRoom = exits.right; p.x = TILE/2+2; sfxStep(); }
    else if (cty <= 0 && my < 0 && exits.up !== undefined) { p.currentRoom = exits.up; p.y = 14*TILE+TILE/2-2; sfxStep(); }
    else if (cty >= 14 && my > 0 && exits.down !== undefined) { p.currentRoom = exits.down; p.y = TILE/2+2; sfxStep(); }
    else {
      const movedX = tryMove(p, mx * spd, 0, room);
      const movedY = tryMove(p, 0, my * spd, room);
      if (movedX || movedY) {
        p.stepTimer++;
        if (p.stepTimer % 15 === 0) sfxStep();
      }
    }
    if (Math.abs(mx) > Math.abs(my)) p.facing = mx > 0 ? 3 : 2;
    else p.facing = my > 0 ? 0 : 1;
  }

  // Attack
  if (p.attackCooldown > 0) p.attackCooldown--;
  if (p.attackDuration > 0) p.attackDuration--;
  if (keys['attack'] && p.attackCooldown <= 0) {
    meleeAttack();
  }

  // Ranged attack (right-click or shift+F)
  if (keys['shift'] && keys['attack'] && p.hasRanged && p.rangedAmmo > 0 && p.attackCooldown <= 0) {
    rangedAttack();
  }

  // Interact
  if (keys['interact']) {
    keys['interact'] = false;
    // Check shop
    const stx = Math.floor(p.x/TILE), sty = Math.floor(p.y/TILE);
    if (sty < 15 && stx < 20 && sty >= 0 && stx >= 0 && room.tiles[sty][stx] === 6) {
      STATE.shopOpen = !STATE.shopOpen;
      if (STATE.shopOpen) sfxShop();
    }
    // Check save points
    for (const sp of STATE.savePoints) {
      if (sp.room === p.currentRoom && dist(p, sp) < 40) {
        saveGame(0);
        sp.used = true;
      }
    }
    // Check chests / pickups
    for (const pk of room.pickups) {
      if (!pk.alive) continue;
      if (dist(p, pk) < 30) {
        collectPickup(pk);
      }
    }
  }

  // Invincibility timer
  if (p.invincible > 0) p.invincible--;

  // Regen
  if (p.accessories.some(a=>a.effect==='regen') && p.hp < p.maxHp && STATE.gameTime % 180 === 0) {
    p.hp = Math.min(p.maxHp, p.hp + 1);
  }

  // Update enemies
  for (const enemy of room.enemies) {
    if (!enemy.alive) continue;
    enemy.animTimer++;
    if (enemy.cooldown > 0) enemy.cooldown--;

    // AI: move toward player
    const dx = p.x - enemy.x, dy = p.y - enemy.y;
    const d = Math.sqrt(dx*dx+dy*dy);
    if (d > 0 && d < 300) {
      const spd = enemy.speed;
      tryMove(enemy, (dx/d)*spd, (dy/d)*spd, room);
    }

    // Attack player
    if (d < 24 && enemy.cooldown <= 0 && p.invincible <= 0) {
      let dmg = enemy.damage;
      if (p.accessories.some(a=>a.effect==='shield')) dmg = Math.max(1, dmg-1);
      p.hp -= dmg;
      p.invincible = 40;
      sfxDamage();
      spawnParticles(p.x, p.y, 8, '#f44', 3, 25);
      if (p.hp <= 0) {
        p.hp = 0;
        playerDeath();
      }
      enemy.cooldown = 60;
    }
  }

  // Update projectiles
  for (let i = STATE.projectiles.length-1; i >= 0; i--) {
    const proj = STATE.projectiles[i];
    proj.x += proj.vx; proj.y += proj.vy;
    proj.life--;
    if (proj.life <= 0) { STATE.projectiles.splice(i,1); continue; }

    // Check wall collision
    if (isSolidPos(STATE.rooms[proj.room], proj.x, proj.y)) {
      spawnParticles(proj.x, proj.y, 5, '#ff0', 2, 12);
      STATE.projectiles.splice(i,1);
      continue;
    }

    // Check enemy hit
    for (const enemy of STATE.rooms[proj.room].enemies) {
      if (!enemy.alive) continue;
      if (dist(proj, enemy) < 14) {
        let dmg = WEAPONS[p.weapon].damage + (p.accessories.some(a=>a.effect==='dmgUp')?1:0);
        enemy.hp -= dmg;
        enemy.cooldown = 15;
        spawnParticles(enemy.x, enemy.y, 6, '#ff8', 3, 15);
        sfxHit();
        if (enemy.hp <= 0) {
          killEnemy(enemy);
        }
        STATE.projectiles.splice(i,1);
        break;
      }
    }
  }

  // Update particles
  for (let i = particles.length-1; i >= 0; i--) {
    const pt = particles[i];
    pt.x += pt.vx; pt.y += pt.vy;
    pt.life--;
    if (pt.life <= 0) particles.splice(i,1);
  }

  STATE.gameTime++;
}

function meleeAttack() {
  const p = STATE.player;
  const w = WEAPONS[p.weapon];
  p.attackCooldown = w.speed;
  p.attackDuration = 10;
  sfxSwing();

  // Attack hitbox in front of player
  let ax = p.x, ay = p.y;
  const range = w.range;
  if (p.facing === 0) ay += range;
  if (p.facing === 1) ay -= range;
  if (p.facing === 2) ax -= range;
  if (p.facing === 3) ax += range;

  spawnParticles(ax, ay, 4, w.color, 2, 10);

  const room = STATE.rooms[p.currentRoom];
  for (const enemy of room.enemies) {
    if (!enemy.alive) continue;
    if (dist({x:ax,y:ay}, enemy) < w.range) {
      let dmg = w.damage + (p.accessories.some(a=>a.effect==='dmgUp')?1:0);
      enemy.hp -= dmg;
      enemy.cooldown = 10;
      spawnParticles(enemy.x, enemy.y, 8, '#fff', 3, 18);
      sfxHit();
      if (enemy.hp <= 0) killEnemy(enemy);
    }
  }
}

function rangedAttack() {
  const p = STATE.player;
  p.rangedAmmo--;
  p.attackCooldown = 20;
  sfxBow();

  let vx = 0, vy = 0;
  const spd = 5;
  if (p.facing === 0) vy = spd;
  if (p.facing === 1) vy = -spd;
  if (p.facing === 2) vx = -spd;
  if (p.facing === 3) vx = spd;

  STATE.projectiles.push({
    x: p.x, y: p.y,
    vx, vy,
    room: p.currentRoom,
    life: 40,
  });
}

function killEnemy(enemy) {
  enemy.alive = false;
  STATE.totalKills++;
  const goldDrop = enemy.type==='boss'?50:enemy.type==='ghost'?12:6;
  STATE.player.gold += goldDrop;
  STATE.totalGoldEarned += goldDrop;
  spawnParticles(enemy.x, enemy.y, enemy.type==='boss'?40:15, enemy.type==='boss'?'#f80':'#ff0', enemy.type==='boss'?8:4, enemy.type==='boss'?60:30);
  sfxCoin();
  if (enemy.type === 'boss') {
    STATE.dialogText = 'THE OVERLORD IS DEFEATED! Collect the Heart Container!';
    STATE.dialogTimer = 180;
    sfxUpgrade();
  }
  // Drop pickup sometimes
  if (Math.random() < 0.3 || enemy.type==='boss') {
    const drops = ['coin', 'coin', 'health_potion'];
    const dropType = drops[Math.floor(Math.random()*drops.length)];
    const amount = dropType==='coin'?(5+Math.floor(Math.random()*10)):1;
    STATE.rooms[STATE.player.currentRoom].pickups.push({
      x: enemy.x, y: enemy.y, type: dropType, amount, alive: true
    });
  }
}

function collectPickup(pk) {
  const p = STATE.player;
  pk.alive = false;

  if (pk.type === 'coin') {
    p.gold += pk.amount;
    STATE.totalGoldEarned += pk.amount;
    sfxCoin();
    spawnParticles(pk.x, pk.y, 8, '#ff0', 2, 15);
    STATE.dialogText = `+${pk.amount} Gold`;
    STATE.dialogTimer = 45;
  } else if (pk.type === 'health_potion') {
    p.inventory.push({ id: 'health_potion', name: 'Health Potion', desc: 'Restores 2 HP.', type: 'consumable' });
    sfxPickup();
    spawnParticles(pk.x, pk.y, 10, '#f44', 2, 18);
    STATE.dialogText = 'Found: Health Potion!';
    STATE.dialogTimer = 60;
  } else if (pk.type === 'broadsword_upgrade') {
    if (p.weapon < 1) {
      p.weapon = 1;
      STATE.upgradesFound++;
      sfxUpgrade();
      spawnParticles(pk.x, pk.y, 20, '#fa0', 5, 35);
      STATE.dialogText = 'Upgrade: Broadsword! Heavy, devastating swings.';
      STATE.dialogTimer = 90;
    }
  } else if (pk.type === 'dualblades_upgrade') {
    if (p.weapon < 2) {
      p.weapon = 2;
      STATE.upgradesFound++;
      sfxUpgrade();
      spawnParticles(pk.x, pk.y, 20, '#5cf', 5, 35);
      STATE.dialogText = 'Upgrade: Dual Blades! Furious flurry of strikes.';
      STATE.dialogTimer = 90;
    }
  } else if (pk.type === 'ranged_bow') {
    p.hasRanged = true;
    p.rangedAmmo = 10;
    STATE.rangedFound = true;
    sfxUpgrade();
    spawnParticles(pk.x, pk.y, 25, '#0f0', 6, 40);
    STATE.dialogText = 'Discovered: Hunter\'s Bow! Press Shift+F to fire arrows.';
    STATE.dialogTimer = 120;
  } else if (pk.type === 'shield_accessory') {
    p.inventory.push({ id: 'shield_accessory', name: 'Iron Shield', desc: 'Reduces damage taken.', type: 'accessory', effect: 'shield' });
    sfxPickup();
    spawnParticles(pk.x, pk.y, 12, '#88f', 3, 20);
    STATE.dialogText = 'Found: Iron Shield!';
    STATE.dialogTimer = 60;
  } else if (pk.type === 'damage_amulet') {
    p.inventory.push({ id: 'damage_amulet', name: 'Berserker Amulet', desc: '+1 damage to attacks.', type: 'accessory', effect: 'dmgUp' });
    sfxPickup();
    spawnParticles(pk.x, pk.y, 12, '#f80', 3, 20);
    STATE.dialogText = 'Found: Berserker Amulet!';
    STATE.dialogTimer = 60;
  } else if (pk.type === 'heart_container') {
    if (p.maxHp < 5) {
      p.maxHp = 5;
      p.hp = Math.min(p.maxHp, p.hp + 2);
    }
    sfxUpgrade();
    spawnParticles(pk.x, pk.y, 30, '#f0f', 6, 45);
    STATE.dialogText = '❤ HEART CONTAINER! Max HP: 5 — QUEST COMPLETE! ❤';
    STATE.dialogTimer = 200;
    STATE.gameComplete = true;
  }
}

function playerDeath() {
  // Respawn at last save or start
  const lastSave = STATE.savePoints.find(sp => sp.used);
  if (lastSave) {
    STATE.player.currentRoom = lastSave.room;
    STATE.player.x = lastSave.x;
    STATE.player.y = lastSave.y;
  } else {
    STATE.player.currentRoom = 0;
    STATE.player.x = 5*TILE+TILE/2;
    STATE.player.y = 8*TILE+TILE/2;
  }
  STATE.player.hp = Math.max(1, STATE.player.maxHp - 2);
  STATE.player.invincible = 120;
  STATE.dialogText = 'You died... Respawning...';
  STATE.dialogTimer = 90;
  spawnParticles(STATE.player.x, STATE.player.y, 20, '#f00', 4, 40);
}

// ── RENDER ──
function drawTile(tile, x, y) {
  const px = x * TILE, py = y * TILE;
  switch(tile) {
    case 0: ctx.fillStyle = '#000'; ctx.fillRect(px, py, TILE, TILE); break;
    case 1: // floor
      ctx.fillStyle = (x+y)%2===0?'#2a2a1a':'#252515';
      ctx.fillRect(px, py, TILE, TILE);
      ctx.fillStyle = '#333322';
      ctx.fillRect(px+1, py+1, TILE-2, TILE-2);
      break;
    case 2: // wall
      ctx.fillStyle = '#445';
      ctx.fillRect(px, py, TILE, TILE);
      ctx.fillStyle = '#556';
      ctx.fillRect(px+2, py+2, TILE-4, TILE-4);
      ctx.fillStyle = '#667';
      ctx.fillRect(px+4, py+4, TILE-8, TILE-8);
      break;
    case 3: // water/lava
      ctx.fillStyle = (STATE.gameTime%60<30)?'#336':'#448';
      ctx.fillRect(px, py, TILE, TILE);
      break;
    case 5: // chest/altar
      ctx.fillStyle = '#1a1a2a'; ctx.fillRect(px, py, TILE, TILE);
      ctx.fillStyle = '#da4'; ctx.fillRect(px+4, py+8, TILE-8, TILE-12);
      ctx.fillStyle = '#fc6'; ctx.fillRect(px+8, py+12, TILE-16, 4);
      break;
    case 6: // shop
      ctx.fillStyle = '#1a1a2a'; ctx.fillRect(px, py, TILE, TILE);
      ctx.fillStyle = '#ca8'; ctx.fillRect(px+4, py+12, TILE-8, 8);
      ctx.fillStyle = '#da9'; ctx.fillRect(px+8, py+6, TILE-16, 6);
      break;
    case 7: // save point
      ctx.fillStyle = '#1a1a2a'; ctx.fillRect(px, py, TILE, TILE);
      ctx.fillStyle = '#adf'; ctx.fillRect(px+6, py+2, TILE-12, TILE-4);
      ctx.fillStyle = '#fff'; ctx.fillRect(px+10, py+4, 4, TILE-8);
      ctx.fillStyle = '#8cf';
      ctx.beginPath(); ctx.arc(px+TILE/2, py+TILE/2, 8, 0, Math.PI*2); ctx.fill();
      break;
    default: ctx.fillStyle = '#111'; ctx.fillRect(px, py, TILE, TILE);
  }
}

function drawPlayer() {
  const p = STATE.player;
  if (p.invincible > 0 && Math.floor(p.invincible/4)%2===0) return; // flicker

  const camX = p.x - W/2, camY = p.y - H/2;
  const sx = p.x - camX, sy = p.y - camY;

  // Shadow
  ctx.fillStyle = 'rgba(0,0,0,0.4)';
  ctx.beginPath(); ctx.ellipse(sx, sy+4, 8, 4, 0, 0, Math.PI*2); ctx.fill();

  // Body
  ctx.fillStyle = '#4a7';
  ctx.beginPath(); ctx.arc(sx, sy, 9, 0, Math.PI*2); ctx.fill();
  ctx.strokeStyle = '#285'; ctx.lineWidth = 2; ctx.stroke();

  // Eyes
  ctx.fillStyle = '#fff';
  let ex = sx, ey = sy-2;
  if (p.facing===2) ex-=3; if (p.facing===3) ex+=3;
  if (p.facing===1) ey-=2; if (p.facing===0) ey+=2;
  ctx.beginPath(); ctx.arc(ex-2, ey, 2.5, 0, Math.PI*2); ctx.fill();
  ctx.beginPath(); ctx.arc(ex+3, ey, 2.5, 0, Math.PI*2); ctx.fill();
  ctx.fillStyle = '#000';
  ctx.beginPath(); ctx.arc(ex-1.5, ey, 1.2, 0, Math.PI*2); ctx.fill();
  ctx.beginPath(); ctx.arc(ex+3.5, ey, 1.2, 0, Math.PI*2); ctx.fill();

  // Weapon
  if (p.attackDuration > 0) {
    const w = WEAPONS[p.weapon];
    ctx.strokeStyle = w.color;
    ctx.lineWidth = 4;
    ctx.beginPath();
    const swingProgress = 1 - (p.attackDuration / 10);
    const wx = sx, wy = sy;
    let ex2 = wx, ey2 = wy;
    const rng = w.range * 0.6;
    if (p.facing === 0) { ex2 = wx; ey2 = wy + rng; }
    if (p.facing === 1) { ex2 = wx; ey2 = wy - rng; }
    if (p.facing === 2) { ex2 = wx - rng; ey2 = wy; }
    if (p.facing === 3) { ex2 = wx + rng; ey2 = wy; }
    ctx.moveTo(wx, wy); ctx.lineTo(ex2, ey2); ctx.stroke();
    // Glow
    ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5;
    ctx.beginPath(); ctx.moveTo(wx, wy); ctx.lineTo(ex2, ey2); ctx.stroke();
  } else {
    // Weapon on back/side
    const w = WEAPONS[p.weapon];
    ctx.fillStyle = w.color;
    let wx2 = sx, wy2 = sy;
    if (p.facing === 0) { wx2 = sx + 8; wy2 = sy + 2; }
    if (p.facing === 1) { wx2 = sx - 8; wy2 = sy - 2; }
    if (p.facing === 2) { wx2 = sx - 2; wy2 = sy - 8; }
    if (p.facing === 3) { wx2 = sx + 2; wy2 = sy + 8; }
    ctx.fillRect(wx2-2, wy2-8, 4, 14);
    ctx.fillStyle = '#fff'; ctx.fillRect(wx2-1, wy2-7, 2, 4);
  }

  // Bow on back
  if (p.hasRanged) {
    ctx.strokeStyle = '#a64'; ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.arc(sx+6, sy-6, 8, -Math.PI*0.6, Math.PI*0.6);
    ctx.stroke();
    ctx.strokeStyle = '#863'; ctx.lineWidth = 0.8;
    ctx.beginPath(); ctx.moveTo(sx+6-7, sy-9); ctx.lineTo(sx+6+7, sy-9); ctx.stroke();
  }
}

function drawEnemy(enemy, camX, camY) {
  if (!enemy.alive) return;
  const sx = enemy.x - camX, sy = enemy.y - camY;

  if (enemy.type === 'slime') {
    const bob = Math.sin(enemy.animTimer*0.1)*3;
    ctx.fillStyle = '#3c3';
    ctx.beginPath(); ctx.ellipse(sx, sy+bob, 10, 7+Math.sin(enemy.animTimer*0.15)*2, 0, 0, Math.PI*2); ctx.fill();
    ctx.fillStyle = '#5e5'; ctx.beginPath(); ctx.ellipse(sx, sy-3+bob, 4, 3, 0, 0, Math.PI*2); ctx.fill();
    ctx.fillStyle = '#000'; ctx.beginPath(); ctx.arc(sx-2, sy-4+bob, 1.5, 0, Math.PI*2); ctx.fill();
    ctx.beginPath(); ctx.arc(sx+3, sy-4+bob, 1.5, 0, Math.PI*2); ctx.fill();
    // HP bar
    const hpPct = enemy.hp/enemy.maxHp;
    ctx.fillStyle = '#400'; ctx.fillRect(sx-8, sy-14, 16, 3);
    ctx.fillStyle = '#f00'; ctx.fillRect(sx-8, sy-14, 16*hpPct, 3);
  } else if (enemy.type === 'skeleton') {
    ctx.fillStyle = '#ddd';
    ctx.fillRect(sx-5, sy-8, 10, 16);
    ctx.fillStyle = '#fff'; ctx.fillRect(sx-3, sy-10, 6, 8); // skull
    ctx.fillStyle = '#000'; ctx.fillRect(sx-2, sy-8, 2, 2); ctx.fillRect(sx+1, sy-8, 2, 2); // eyes
    ctx.strokeStyle = '#999'; ctx.lineWidth = 2;
    ctx.beginPath(); ctx.moveTo(sx-5, sy+4); ctx.lineTo(sx-8, sy+12); ctx.stroke();
    ctx.beginPath(); ctx.moveTo(sx+5, sy+4); ctx.lineTo(sx+8, sy+12); ctx.stroke();
    const hpPct = enemy.hp/enemy.maxHp;
    ctx.fillStyle = '#400'; ctx.fillRect(sx-8, sy-16, 16, 3);
    ctx.fillStyle = '#f00'; ctx.fillRect(sx-8, sy-16, 16*hpPct, 3);
  } else if (enemy.type === 'ghost') {
    const bob = Math.sin(enemy.animTimer*0.08)*4;
    ctx.fillStyle = 'rgba(180,160,220,0.7)';
    ctx.beginPath(); ctx.arc(sx, sy+bob, 9, 0, Math.PI*2); ctx.fill();
    ctx.beginPath(); ctx.arc(sx, sy-5+bob, 6, 0, Math.PI*2); ctx.fill();
    ctx.fillStyle = 'rgba(255,255,255,0.9)';
    ctx.beginPath(); ctx.arc(sx-2, sy-6+bob, 2, 0, Math.PI*2); ctx.fill();
    ctx.beginPath(); ctx.arc(sx+3, sy-6+bob, 2, 0, Math.PI*2); ctx.fill();
    const hpPct = enemy.hp/enemy.maxHp;
    ctx.fillStyle = 'rgba(40,0,40,0.6)'; ctx.fillRect(sx-8, sy-15+bob, 16, 3);
    ctx.fillStyle = '#a0f'; ctx.fillRect(sx-8, sy-15+bob, 16*hpPct, 3);
  } else if (enemy.type === 'boss') {
    const bob = Math.sin(enemy.animTimer*0.05)*2;
    ctx.fillStyle = '#600';
    ctx.beginPath(); ctx.arc(sx, sy+bob, 18, 0, Math.PI*2); ctx.fill();
    ctx.fillStyle = '#800';
    ctx.beginPath(); ctx.arc(sx, sy-4+bob, 12, 0, Math.PI*2); ctx.fill();
    ctx.fillStyle = '#f00';
    ctx.beginPath(); ctx.arc(sx-5, sy-6+bob, 3, 0, Math.PI*2); ctx.fill();
    ctx.beginPath(); ctx.arc(sx+5, sy-6+bob, 3, 0, Math.PI*2); ctx.fill();
    // Horns
    ctx.fillStyle = '#400';
    ctx.beginPath(); ctx.moveTo(sx-6, sy-12+bob); ctx.lineTo(sx-10, sy-22+bob); ctx.lineTo(sx-3, sy-12+bob); ctx.fill();
    ctx.beginPath(); ctx.moveTo(sx+6, sy-12+bob); ctx.lineTo(sx+10, sy-22+bob); ctx.lineTo(sx+3, sy-12+bob); ctx.fill();
    // Health bar
    const hpPct = enemy.hp/enemy.maxHp;
    ctx.fillStyle = '#400'; ctx.fillRect(sx-20, sy-26+bob, 40, 5);
    ctx.fillStyle = '#f00'; ctx.fillRect(sx-20, sy-26+bob, 40*hpPct, 5);
    ctx.fillStyle = '#fff';
    ctx.font = '8px monospace'; ctx.fillText('OVERLORD', sx-18, sy-29+bob);
  }
}

function drawHUD() {
  // Health hearts
  const p = STATE.player;
  const heartsX = 16, heartsY = 16;
  for (let i = 0; i < Math.ceil(p.maxHp/2); i++) {
    const hx = heartsX + i * 28;
    const full = Math.min(2, Math.max(0, p.hp - i*2));
    // Heart shape
    ctx.fillStyle = '#333';
    drawHeart(hx, heartsY, 14);
    if (full === 2) {
      ctx.fillStyle = '#f22';
      drawHeart(hx, heartsY, 14);
    } else if (full === 1) {
      // Half heart
      ctx.save();
      ctx.beginPath();
      ctx.rect(hx-6, heartsY-8, 6, 16);
      ctx.clip();
      ctx.fillStyle = '#f22';
      drawHeart(hx, heartsY, 14);
      ctx.restore();
    }
  }

  // Weapon name
  ctx.fillStyle = '#fff';
  ctx.font = 'bold 11px monospace';
  ctx.fillText(WEAPONS[p.weapon].name, 16, 48);

  // Progression
  ctx.fillStyle = '#aaa';
  ctx.font = '9px monospace';
  const upgText = `Upgrades: ${STATE.upgradesFound}/2`;
  const rangedText = STATE.rangedFound ? ' | Bow: ✓' : ' | Bow: ?';
  ctx.fillText(upgText + rangedText, 16, 62);

  // Ammo
  if (p.hasRanged) {
    ctx.fillText(`Arrows: ${p.rangedAmmo}/${p.maxAmmo}`, 16, 76);
  }

  // Gold
  ctx.fillText(`Gold: ${p.gold}`, W - 120, 20);

  // Room name
  ctx.fillStyle = '#aaa';
  ctx.font = '10px monospace';
  ctx.fillText(getRoom().name, W - 150, H - 10);

  // Accessories
  if (p.accessories.length > 0) {
    ctx.fillStyle = '#dd8';
    ctx.font = '9px monospace';
    ctx.fillText('Equipped: ' + p.accessories.map(a=>a.name).join(', '), 16, H - 10);
  }

  // Dialog
  if (STATE.dialogTimer > 0) {
    const alpha = Math.min(1, STATE.dialogTimer / 20);
    ctx.fillStyle = `rgba(0,0,0,${alpha*0.7})`;
    const dw = ctx.measureText(STATE.dialogText).width + 40;
    ctx.fillRect(W/2-dw/2, H-70, dw, 30);
    ctx.fillStyle = `rgba(255,255,255,${alpha})`;
    ctx.font = '12px monospace';
    ctx.textAlign = 'center';
    ctx.fillText(STATE.dialogText, W/2, H-50);
    ctx.textAlign = 'start';
  }

  // Controls hint
  ctx.fillStyle = 'rgba(255,255,255,0.3)';
  ctx.font = '8px monospace';
  ctx.fillText('WASD:Move | F:Attack | Shift+F:Bow | E:Interact | I:Inventory', W/2-170, H-4);
}

function drawHeart(x, y, s) {
  ctx.beginPath();
  const r = s * 0.35;
  // Left circle
  ctx.arc(x - r*0.6, y - r*0.4, r, Math.PI, 0, false);
  // Right circle
  ctx.arc(x + r*0.6, y - r*0.4, r, Math.PI, 0, false);
  // Bottom point
  ctx.lineTo(x, y + s*0.45);
  ctx.closePath();
  ctx.fill();
}

function drawInventory() {
  if (!STATE.inventoryOpen) return;
  const p = STATE.player;

  // Backdrop
  ctx.fillStyle = 'rgba(0,0,0,0.85)';
  ctx.fillRect(0, 0, W, H);

  ctx.fillStyle = '#da4';
  ctx.font = 'bold 16px monospace';
  ctx.textAlign = 'center';
  ctx.fillText('INVENTORY', W/2, 40);
  ctx.textAlign = 'start';

  ctx.fillStyle = '#fff';
  ctx.font = '12px monospace';
  let iy = 70;
  ctx.fillText(`Gold: ${p.gold}`, 100, iy); iy += 25;
  ctx.fillText(`Weapon: ${WEAPONS[p.weapon].name} (${WEAPONS[p.weapon].desc})`, 100, iy); iy += 25;
  if (p.hasRanged) ctx.fillText(`Ranged: Hunter's Bow (Arrows: ${p.rangedAmmo})`, 100, iy);
  else ctx.fillText('Ranged: None discovered', 100, iy);
  iy += 30;

  ctx.fillStyle = '#da4';
  ctx.fillText('Items:', 100, iy); iy += 22;

  if (p.inventory.length === 0) {
    ctx.fillStyle = '#888';
    ctx.fillText('  (empty)', 100, iy);
  } else {
    for (let i = 0; i < p.inventory.length; i++) {
      const item = p.inventory[i];
      ctx.fillStyle = item.type==='consumable'?'#f88':item.type==='accessory'?'#adf':'#fff';
      ctx.fillText(`${i+1}. ${item.name} - ${item.desc}`, 100, iy);
      iy += 20;
      // Use/equip hint
      if (item.type === 'consumable') {
        ctx.fillStyle = '#888';
        ctx.fillText(`    Press ${i+1} to use`, 100, iy);
        iy += 18;
      } else if (item.type === 'accessory') {
        const equipped = p.accessories.some(a => a.id === item.id);
        ctx.fillStyle = equipped ? '#8f8' : '#888';
        ctx.fillText(`    Press ${i+1} to ${equipped?'unequip':'equip'}`, 100, iy);
        iy += 18;
      }
    }
  }

  iy += 15;
  ctx.fillStyle = '#888';
  ctx.font = '10px monospace';
  ctx.fillText('Press I to close | Press 1-5 to use/equip items', 100, iy);
}

function drawShop() {
  if (!STATE.shopOpen) return;

  ctx.fillStyle = 'rgba(0,0,0,0.85)';
  ctx.fillRect(0, 0, W, H);

  ctx.fillStyle = '#da4';
  ctx.font = 'bold 16px monospace';
  ctx.textAlign = 'center';
  ctx.fillText('SHOP', W/2, 40);
  ctx.fillStyle = '#fff';
  ctx.font = '11px monospace';
  ctx.fillText(`Your Gold: ${STATE.player.gold}`, W/2, 60);
  ctx.textAlign = 'start';

  let iy = 90;
  for (let i = 0; i < SHOP_ITEMS.length; i++) {
    const item = SHOP_ITEMS[i];
    const affordable = STATE.player.gold >= item.cost;
    ctx.fillStyle = affordable ? '#fff' : '#666';
    ctx.fillText(`${i+1}. ${item.name} - ${item.desc} [${item.cost}g]`, 120, iy);
    if (affordable) {
      ctx.fillStyle = '#8f8';
      ctx.fillText(`   Press ${i+1} to buy`, 120, iy+16);
    }
    iy += 34;
  }
  iy += 10;
  ctx.fillStyle = '#888';
  ctx.fillText('Press E to close shop', 120, iy);
}

function handleShopBuy(index) {
  if (index < 0 || index >= SHOP_ITEMS.length) return;
  const item = SHOP_ITEMS[index];
  const p = STATE.player;
  if (p.gold < item.cost) return;

  p.gold -= item.cost;
  sfxCoin();

  if (item.type === 'consumable') {
    p.inventory.push({ id: item.id, name: item.name, desc: item.desc, type: 'consumable' });
    STATE.dialogText = `Bought: ${item.name}!`;
  } else if (item.type === 'accessory') {
    // Check if already owned
    if (!p.inventory.some(inv=>inv.id===item.id) && !p.accessories.some(a=>a.id===item.id)) {
      p.inventory.push({ id: item.id, name: item.name, desc: item.desc, type: 'accessory', effect: item.effect });
      STATE.dialogText = `Bought: ${item.name}!`;
    } else {
      p.gold += item.cost; // refund
      STATE.dialogText = 'You already have this accessory!';
    }
  } else if (item.type === 'ammo') {
    if (!p.hasRanged) {
      p.gold += item.cost; // refund
      STATE.dialogText = 'You need to find a bow first!';
    } else {
      p.rangedAmmo = Math.min(p.maxAmmo, p.rangedAmmo + 10);
      STATE.dialogText = 'Bought: 10 Arrows!';
    }
  }
  STATE.dialogTimer = 60;
}

function handleInventoryUse(index) {
  const p = STATE.player;
  if (index < 0 || index >= p.inventory.length) return;
  const item = p.inventory[index];

  if (item.type === 'consumable') {
    if (item.id === 'health_potion') {
      if (p.hp >= p.maxHp) {
        STATE.dialogText = 'HP is already full!';
        STATE.dialogTimer = 30;
        return;
      }
      p.hp = Math.min(p.maxHp, p.hp + 2);
      p.inventory.splice(index, 1);
      sfxPickup();
      spawnParticles(p.x, p.y, 12, '#f44', 3, 20);
      STATE.dialogText = 'Used Health Potion! +2 HP';
      STATE.dialogTimer = 45;
    }
  } else if (item.type === 'accessory') {
    const equippedIdx = p.accessories.findIndex(a => a.id === item.id);
    if (equippedIdx >= 0) {
      p.accessories.splice(equippedIdx, 1);
      STATE.dialogText = `Unequipped: ${item.name}`;
    } else {
      if (p.accessories.length >= 2) {
        STATE.dialogText = 'Max 2 accessories equipped! Unequip one first.';
      } else {
        p.accessories.push({...item});
        STATE.dialogText = `Equipped: ${item.name}!`;
        sfxPickup();
      }
    }
    STATE.dialogTimer = 45;
  }
}

// Handle number keys for inventory/shop
window.addEventListener('keydown', e => {
  const num = parseInt(e.key);
  if (isNaN(num) || num < 1 || num > 5) return;

  if (STATE.shopOpen) {
    handleShopBuy(num - 1);
    return;
  }
  if (STATE.inventoryOpen) {
    handleInventoryUse(num - 1);
    return;
  }
});

function drawParticles(camX, camY) {
  for (const pt of particles) {
    const alpha = pt.life / pt.maxLife;
    const sx = pt.x - camX, sy = pt.y - camY;
    ctx.fillStyle = pt.color.replace(')', `,${alpha})`).replace('rgb', 'rgba');
    if (pt.color.startsWith('#')) {
      const r = parseInt(pt.color.slice(1,3), 16);
      const g = parseInt(pt.color.slice(3,5), 16);
      const b = parseInt(pt.color.slice(5,7), 16);
      ctx.fillStyle = `rgba(${r},${g},${b},${alpha})`;
    }
    const size = 2 + (1-alpha)*3;
    ctx.beginPath(); ctx.arc(sx, sy, size, 0, Math.PI*2); ctx.fill();
  }
}

function drawProjectiles(camX, camY) {
  for (const proj of STATE.projectiles) {
    const sx = proj.x - camX, sy = proj.y - camY;
    ctx.fillStyle = '#ff0';
    ctx.beginPath(); ctx.arc(sx, sy, 3, 0, Math.PI*2); ctx.fill();
    ctx.fillStyle = '#fff';
    ctx.beginPath(); ctx.arc(sx, sy, 1.5, 0, Math.PI*2); ctx.fill();
    // Trail
    ctx.fillStyle = 'rgba(255,255,200,0.4)';
    ctx.beginPath(); ctx.arc(sx-proj.vx*0.5, sy-proj.vy*0.5, 2, 0, Math.PI*2); ctx.fill();
  }
}

function drawPickups(camX, camY) {
  const room = getRoom();
  for (const pk of room.pickups) {
    if (!pk.alive) continue;
    const sx = pk.x - camX, sy = pk.y - camY;
    if (pk.type === 'coin') {
      ctx.fillStyle = '#fc0';
      ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.1)*2, 5, 0, Math.PI*2); ctx.fill();
      ctx.fillStyle = '#ff0';
      ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.1)*2, 2.5, 0, Math.PI*2); ctx.fill();
    } else if (pk.type === 'health_potion') {
      ctx.fillStyle = '#f44';
      ctx.fillRect(sx-4, sy-6+Math.sin(STATE.gameTime*0.08)*2, 8, 12);
      ctx.fillStyle = '#fff';
      ctx.fillRect(sx-2, sy-3+Math.sin(STATE.gameTime*0.08)*2, 4, 4);
    } else if (pk.type.includes('upgrade')) {
      const glow = Math.sin(STATE.gameTime*0.06)*0.3+0.7;
      ctx.fillStyle = `rgba(255,200,50,${glow})`;
      ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.05)*3, 8, 0, Math.PI*2); ctx.fill();
      ctx.fillStyle = '#fc0';
      ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.05)*3, 5, 0, Math.PI*2); ctx.fill();
    } else if (pk.type === 'ranged_bow') {
      const glow = Math.sin(STATE.gameTime*0.06)*0.3+0.7;
      ctx.fillStyle = `rgba(100,255,100,${glow})`;
      ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.05)*3, 8, 0, Math.PI*2); ctx.fill();
      ctx.strokeStyle = '#0f0'; ctx.lineWidth = 2;
      ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.05)*2, 12, -0.6, 2.5); ctx.stroke();
    } else if (pk.type === 'shield_accessory' || pk.type === 'damage_amulet') {
      ctx.fillStyle = pk.type==='shield_accessory'?'#88f':'#f80';
      ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.07)*2, 6, 0, Math.PI*2); ctx.fill();
      ctx.fillStyle = '#fff';
      ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.07)*2, 3, 0, Math.PI*2); ctx.fill();
    } else if (pk.type === 'heart_container') {
      const glow = Math.sin(STATE.gameTime*0.06)*0.3+0.7;
      ctx.fillStyle = `rgba(255,100,255,${glow})`;
      drawHeart(sx, sy+Math.sin(STATE.gameTime*0.05)*2, 12);
    }
  }
}

function drawSavePoints(camX, camY) {
  for (const sp of STATE.savePoints) {
    if (sp.room !== STATE.player.currentRoom) continue;
    const sx = sp.x - camX, sy = sp.y - camY;
    ctx.fillStyle = sp.used ? '#8cf' : '#adf';
    ctx.beginPath(); ctx.arc(sx, sy, 10+Math.sin(STATE.gameTime*0.04)*2, 0, Math.PI*2); ctx.fill();
    ctx.fillStyle = '#fff';
    ctx.font = '8px monospace';
    ctx.textAlign = 'center';
    ctx.fillText(sp.used?'S':'Save', sx, sy+3);
    ctx.textAlign = 'start';
  }
}

function render() {
  ctx.clearRect(0, 0, W, H);

  const p = STATE.player;
  const room = getRoom();
  const camX = p.x - W/2, camY = p.y - H/2;

  ctx.save();
  ctx.translate(-camX, -camY);

  // Draw all tiles
  for (let ty = 0; ty < 15; ty++) {
    for (let tx = 0; tx < 20; tx++) {
      drawTile(room.tiles[ty][tx], tx, ty);
    }
  }

  // Draw pickups
  for (const pk of room.pickups) {
    if (!pk.alive) continue;
    const sx = pk.x, sy = pk.y;
    if (pk.type === 'coin') {
      ctx.fillStyle = '#fc0';
      ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.1)*2, 5, 0, Math.PI*2); ctx.fill();
      ctx.fillStyle = '#ff0';
      ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.1)*2, 2.5, 0, Math.PI*2); ctx.fill();
    } else if (pk.type === 'health_potion') {
      ctx.fillStyle = '#f44';
      ctx.fillRect(sx-4, sy-6+Math.sin(STATE.gameTime*0.08)*2, 8, 12);
      ctx.fillStyle = '#fff'; ctx.fillRect(sx-2, sy-3+Math.sin(STATE.gameTime*0.08)*2, 4, 4);
    } else if (pk.type.includes('upgrade')) {
      const glow = Math.sin(STATE.gameTime*0.06)*0.3+0.7;
      ctx.fillStyle = `rgba(255,200,50,${glow})`;
      ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.05)*3, 8, 0, Math.PI*2); ctx.fill();
      ctx.fillStyle = '#fc0';
      ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.05)*3, 5, 0, Math.PI*2); ctx.fill();
    } else if (pk.type === 'ranged_bow') {
      const glow = Math.sin(STATE.gameTime*0.06)*0.3+0.7;
      ctx.fillStyle = `rgba(100,255,100,${glow})`;
      ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.05)*3, 8, 0, Math.PI*2); ctx.fill();
      ctx.strokeStyle = '#0f0'; ctx.lineWidth = 2;
      ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.05)*2, 12, -0.6, 2.5); ctx.stroke();
    } else if (pk.type === 'shield_accessory' || pk.type === 'damage_amulet') {
      ctx.fillStyle = pk.type==='shield_accessory'?'#88f':'#f80';
      ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.07)*2, 6, 0, Math.PI*2); ctx.fill();
      ctx.fillStyle = '#fff';
      ctx.beginPath(); ctx.arc(sx, sy+Math.sin(STATE.gameTime*0.07)*2, 3, 0, Math.PI*2); ctx.fill();
    } else if (pk.type === 'heart_container') {
      const glow = Math.sin(STATE.gameTime*0.06)*0.3+0.7;
      ctx.fillStyle = `rgba(255,100,255,${glow})`;
      ctx.save();
      ctx.translate(sx, sy+Math.sin(STATE.gameTime*0.05)*2);
      drawHeart(0, 0, 12);
      ctx.restore();
    }
  }

  // Draw save points
  for (const sp of STATE.savePoints) {
    if (sp.room !== STATE.player.currentRoom) continue;
    const sx = sp.x, sy = sp.y;
    ctx.fillStyle = sp.used ? '#8cf' : '#adf';
    ctx.beginPath(); ctx.arc(sx, sy, 10+Math.sin(STATE.gameTime*0.04)*2, 0, Math.PI*2); ctx.fill();
    ctx.fillStyle = '#fff';
    ctx.font = '8px monospace';
    ctx.textAlign = 'center';
    ctx.fillText(sp.used?'S':'Save', sx, sy+3);
    ctx.textAlign = 'start';
  }

  // Draw enemies
  for (const enemy of room.enemies) {
    drawEnemy(enemy, 0, 0);
  }

  // Draw projectiles
  for (const proj of STATE.projectiles) {
    if (proj.room !== STATE.player.currentRoom) continue;
    ctx.fillStyle = '#ff0';
    ctx.beginPath(); ctx.arc(proj.x, proj.y, 3, 0, Math.PI*2); ctx.fill();
    ctx.fillStyle = '#fff';
    ctx.beginPath(); ctx.arc(proj.x, proj.y, 1.5, 0, Math.PI*2); ctx.fill();
    ctx.fillStyle = 'rgba(255,255,200,0.4)';
    ctx.beginPath(); ctx.arc(proj.x-proj.vx*0.5, proj.y-proj.vy*0.5, 2, 0, Math.PI*2); ctx.fill();
  }

  // Draw player
  drawPlayerWorld();

  // Draw particles
  for (const pt of particles) {
    const alpha = pt.life / pt.maxLife;
    let r=255,g=255,b=255;
    if (pt.color.startsWith('#')) {
      r = parseInt(pt.color.slice(1,3), 16);
      g = parseInt(pt.color.slice(3,5), 16);
      b = parseInt(pt.color.slice(5,7), 16);
    }
    ctx.fillStyle = `rgba(${r},${g},${b},${alpha})`;
    const size = 2 + (1-alpha)*3;
    ctx.beginPath(); ctx.arc(pt.x, pt.y, size, 0, Math.PI*2); ctx.fill();
  }

  ctx.restore();

  // HUD (screen space)
  drawHUD();
  drawInventory();
  drawShop();

  // Victory overlay
  if (STATE.gameComplete) {
    ctx.fillStyle = 'rgba(0,0,0,0.6)';
    ctx.fillRect(0, 0, W, H);
    ctx.fillStyle = '#fc0';
    ctx.font = 'bold 28px monospace';
    ctx.textAlign = 'center';
    ctx.fillText('QUEST COMPLETE!', W/2, H/2-20);
    ctx.fillStyle = '#fff';
    ctx.font = '14px monospace';
    ctx.fillText('You defeated the Overlord and claimed the Heart Container!', W/2, H/2+20);
    ctx.fillText(`Kills: ${STATE.totalKills} | Gold Earned: ${STATE.totalGoldEarned} | Upgrades: ${STATE.upgradesFound}`, W/2, H/2+45);
    ctx.fillText('Thanks for playing! Refresh to start a new adventure.', W/2, H/2+70);
    ctx.textAlign = 'start';
  }
}

function drawPlayerWorld() {
  const p = STATE.player;
  if (p.invincible > 0 && Math.floor(p.invincible/4)%2===0) return;

  // Shadow
  ctx.fillStyle = 'rgba(0,0,0,0.4)';
  ctx.beginPath(); ctx.ellipse(p.x, p.y+4, 8, 4, 0, 0, Math.PI*2); ctx.fill();

  // Body
  ctx.fillStyle = '#4a7';
  ctx.beginPath(); ctx.arc(p.x, p.y, 9, 0, Math.PI*2); ctx.fill();
  ctx.strokeStyle = '#285'; ctx.lineWidth = 2; ctx.stroke();

  // Eyes
  ctx.fillStyle = '#fff';
  let ex = p.x, ey = p.y-2;
  if (p.facing===2) ex-=3; if (p.facing===3) ex+=3;
  if (p.facing===1) ey-=2; if (p.facing===0) ey+=2;
  ctx.beginPath(); ctx.arc(ex-2, ey, 2.5, 0, Math.PI*2); ctx.fill();
  ctx.beginPath(); ctx.arc(ex+3, ey, 2.5, 0, Math.PI*2); ctx.fill();
  ctx.fillStyle = '#000';
  ctx.beginPath(); ctx.arc(ex-1.5, ey, 1.2, 0, Math.PI*2); ctx.fill();
  ctx.beginPath(); ctx.arc(ex+3.5, ey, 1.2, 0, Math.PI*2); ctx.fill();

  // Weapon
  const w = WEAPONS[p.weapon];
  if (p.attackDuration > 0) {
    ctx.strokeStyle = w.color;
    ctx.lineWidth = 4;
    ctx.beginPath();
    const wx = p.x, wy = p.y;
    let ex2 = wx, ey2 = wy;
    const rng = w.range * 0.6;
    if (p.facing === 0) { ex2 = wx; ey2 = wy + rng; }
    if (p.facing === 1) { ex2 = wx; ey2 = wy - rng; }
    if (p.facing === 2) { ex2 = wx - rng; ey2 = wy; }
    if (p.facing === 3) { ex2 = wx + rng; ey2 = wy; }
    ctx.moveTo(wx, wy); ctx.lineTo(ex2, ey2); ctx.stroke();
    ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5;
    ctx.beginPath(); ctx.moveTo(wx, wy); ctx.lineTo(ex2, ey2); ctx.stroke();
  } else {
    let wx2 = p.x, wy2 = p.y;
    if (p.facing === 0) { wx2 = p.x + 8; wy2 = p.y + 2; }
    if (p.facing === 1) { wx2 = p.x - 8; wy2 = p.y - 2; }
    if (p.facing === 2) { wx2 = p.x - 2; wy2 = p.y - 8; }
    if (p.facing === 3) { wx2 = p.x + 2; wy2 = p.y + 8; }
    ctx.fillStyle = w.color;
    ctx.fillRect(wx2-2, wy2-8, 4, 14);
    ctx.fillStyle = '#fff'; ctx.fillRect(wx2-1, wy2-7, 2, 4);
  }

  // Bow on back
  if (p.hasRanged) {
    ctx.strokeStyle = '#a64'; ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.arc(p.x+6, p.y-6, 8, -Math.PI*0.6, Math.PI*0.6);
    ctx.stroke();
    ctx.strokeStyle = '#863'; ctx.lineWidth = 0.8;
    ctx.beginPath(); ctx.moveTo(p.x+6-7, p.y-9); ctx.lineTo(p.x+6+7, p.y-9); ctx.stroke();
  }
}

// ── GAME LOOP ──
function gameLoop() {
  update();
  render();
  requestAnimationFrame(gameLoop);
}

// ── START ──
initAudio();
// Try to load save
const hasSave = loadGame(0);
if (!hasSave) {
  STATE.dialogText = 'Welcome, adventurer! WASD to move, F to attack, E to interact.';
  STATE.dialogTimer = 150;
}
gameLoop();

console.log('Shard of the Verdant Blade - Ready');
console.log('Rooms:', STATE.rooms.map(r=>r.name).join(' → '));
console.log('Save points:', STATE.savePoints.length);
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4-pro","provider":"openrouter","persona":"middle","sessionId":"17502ab4-4004-47fb-96af-fcd6b049881e","tokensIn":1170778,"tokensOut":42294,"tokensTotal":1213072,"turns":33,"toolCalls":32,"failedToolCalls":0,"timestamp":"2026-07-18T18:21:14.845Z"} -->