preview
Mouse (move and aim) • E (inventory)
๐Ÿ”ฅ slow (GPU melter) ๐Ÿ“ฑ RIP mobile users
source
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
  <title>Survivors RPG</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { overflow: hidden; background: #111; }
    #game { display: block; width: 100vw; height: 100vh; }
    #inventory-overlay {
      display: none;
      position: fixed;
      inset: 0;
      background: rgba(0,0,0,0.7);
      justify-content: center;
      align-items: center;
      z-index: 100;
    }
    #inventory-overlay.open { display: flex; }
    #inventory-panel {
      background: #2a2a2a;
      border: 3px solid #555;
      border-radius: 8px;
      padding: 20px;
      display: flex;
      gap: 30px;
    }
    #equipment-slots {
      display: flex;
      flex-direction: column;
      gap: 8px;
      min-width: 80px;
    }
    .equip-slot {
      width: 56px; height: 56px;
      border: 2px solid #444;
      border-radius: 4px;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 28px;
      background: #1a1a1a;
      cursor: pointer;
    }
    .equip-slot:hover { border-color: #888; background: #252525; }
    .equip-slot.drag-over { border-color: #8af; background: #2a3a4a; }
    .equip-slot[draggable="true"] { cursor: grab; }
    .equip-slot[draggable="true"]:active { cursor: grabbing; }
    .equip-slot.common { text-shadow: -1px -1px 0 #888, 1px -1px 0 #888, -1px 1px 0 #888, 1px 1px 0 #888, 0 -1px 0 #888, 0 1px 0 #888, -1px 0 0 #888, 1px 0 0 #888; }
    .equip-slot.uncommon { text-shadow: -1px -1px 0 #4a9eff, 1px -1px 0 #4a9eff, -1px 1px 0 #4a9eff, 1px 1px 0 #4a9eff, 0 -1px 0 #4a9eff, 0 1px 0 #4a9eff, -1px 0 0 #4a9eff, 1px 0 0 #4a9eff; }
    .equip-slot.rare { text-shadow: -1px -1px 0 #ffd700, 1px -1px 0 #ffd700, -1px 1px 0 #ffd700, 1px 1px 0 #ffd700, 0 -1px 0 #ffd700, 0 1px 0 #ffd700, -1px 0 0 #ffd700, 1px 0 0 #ffd700; }
    .equip-slot-wrap { display: flex; flex-direction: column; align-items: center; gap: 2px; }
    .equip-slot-label { font-size: 10px; color: #888; text-align: center; }
    #inventory-grid {
      display: grid;
      grid-template-columns: repeat(6, 56px);
      grid-template-rows: repeat(4, 56px);
      gap: 4px;
    }
    .inv-slot {
      width: 56px; height: 56px;
      border: 2px solid #444;
      border-radius: 4px;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 28px;
      background: #1a1a1a;
      cursor: pointer;
    }
    .inv-slot:hover { border-color: #888; background: #252525; }
    .inv-slot.common { border-color: #888; text-shadow: -1px -1px 0 #888, 1px -1px 0 #888, -1px 1px 0 #888, 1px 1px 0 #888, 0 -1px 0 #888, 0 1px 0 #888, -1px 0 0 #888, 1px 0 0 #888; }
    .inv-slot.uncommon { border-color: #4a9eff; text-shadow: -1px -1px 0 #4a9eff, 1px -1px 0 #4a9eff, -1px 1px 0 #4a9eff, 1px 1px 0 #4a9eff, 0 -1px 0 #4a9eff, 0 1px 0 #4a9eff, -1px 0 0 #4a9eff, 1px 0 0 #4a9eff; }
    .inv-slot.rare { border-color: #ffd700; text-shadow: -1px -1px 0 #ffd700, 1px -1px 0 #ffd700, -1px 1px 0 #ffd700, 1px 1px 0 #ffd700, 0 -1px 0 #ffd700, 0 1px 0 #ffd700, -1px 0 0 #ffd700, 1px 0 0 #ffd700; }
    .inv-slot.selected { border-color: #fff; box-shadow: 0 0 8px #fff; }
    .inv-slot.drag-over { border-color: #8af; background: #2a3a4a; }
    .inv-slot[draggable="true"] { cursor: grab; }
    .inv-slot[draggable="true"]:active { cursor: grabbing; }
    #inventory-right { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
    .scrap-slot { border-color: #5a4a3a; }
    .scrap-slot:hover { border-color: #8a7a6a; }
    #trash-all-btn {
      padding: 6px 12px;
      font-size: 12px;
      cursor: pointer;
      background: #5a3a3a;
      border: 2px solid #7a4a4a;
      border-radius: 4px;
      color: #ccc;
      font-family: inherit;
    }
    #trash-all-btn:hover { background: #7a4a4a; border-color: #9a6a6a; color: #fff; }
    #tooltip-wrapper {
      position: fixed;
      display: none;
      flex-direction: row;
      gap: 12px;
      align-items: flex-start;
      pointer-events: none;
      z-index: 200;
    }
    #tooltip-wrapper.show { display: flex; }
    #tooltip-equipped,
    #tooltip {
      background: #1a1a1a;
      border: 2px solid #555;
      border-radius: 4px;
      padding: 8px 12px;
      font-family: monospace;
      font-size: 12px;
      color: #eee;
      max-width: 220px;
    }
    #tooltip-equipped { display: none; border-color: #6a6; }
    #tooltip-equipped.show { display: block; }
    .tooltip-name { font-weight: bold; margin-bottom: 4px; }
    .tooltip-name.common { color: #aaa; }
    .tooltip-name.uncommon { color: #4a9eff; }
    .tooltip-name.rare { color: #ffd700; }
    .tooltip-stat { color: #8f8; }
    .tooltip-mod { color: #8af; }
    #hud {
      position: fixed;
      top: 10px; left: 10px;
      color: #fff;
      font-family: monospace;
      font-size: 14px;
      z-index: 50;
    }
    #game-over {
      position: fixed;
      inset: 0;
      background: rgba(0,0,0,0.8);
      display: none;
      flex-direction: column;
      justify-content: center;
      align-items: center;
      color: #fff;
      font-size: 48px;
      z-index: 150;
    }
    #game-over.show { display: flex; }
    #game-over p { margin: 10px; font-size: 24px; }
    #restart-btn {
      margin-top: 20px;
      padding: 12px 24px;
      font-size: 18px;
      cursor: pointer;
      background: #4a4;
      border: none;
      border-radius: 4px;
    }
    #restart-btn:hover { background: #5b5; }
    #stats-panel {
      min-width: 140px;
      background: #1a1a1a;
      border: 2px solid #444;
      border-radius: 4px;
      padding: 12px;
      font-family: monospace;
      font-size: 12px;
      color: #eee;
    }
    #stats-panel h3 { font-size: 11px; color: #888; margin-bottom: 8px; text-transform: uppercase; }
    #stats-panel .stat-row { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 4px; }
    #stats-panel .stat-row span:first-child { color: #aaa; }
    #stats-panel .stat-row span:last-child { color: #8f8; }
  </style>
</head>
<body>
  <canvas id="game" tabindex="0"></canvas>
  <div id="hud"></div>
  <div id="inventory-overlay">
    <div id="inventory-panel">
      <div id="equipment-column">
        <div class="equip-slot-wrap"><div class="equip-slot" data-slot="helmet" title="Helmet"></div><div class="equip-slot-label">Helmet</div></div>
        <div class="equip-slot-wrap"><div class="equip-slot" data-slot="chest" title="Chest"></div><div class="equip-slot-label">Chest</div></div>
        <div class="equip-slot-wrap"><div class="equip-slot" data-slot="pants" title="Pants"></div><div class="equip-slot-label">Pants</div></div>
        <div class="equip-slot-wrap"><div class="equip-slot" data-slot="gloves" title="Gloves"></div><div class="equip-slot-label">Gloves</div></div>
        <div class="equip-slot-wrap"><div class="equip-slot" data-slot="leftHand" title="Left Hand"></div><div class="equip-slot-label">L Hand</div></div>
        <div class="equip-slot-wrap"><div class="equip-slot" data-slot="rightHand" title="Right Hand"></div><div class="equip-slot-label">R Hand</div></div>
        <div class="equip-slot-wrap"><div class="equip-slot" data-slot="boots" title="Boots"></div><div class="equip-slot-label">Boots</div></div>
      </div>
      <div id="inventory-right">
        <div id="inventory-grid"></div>
        <div id="scrap-slot" class="inv-slot scrap-slot" title="Drag items here to scrap">โ™ป๏ธ</div>
        <button id="trash-all-btn" type="button" title="Trash all inventory (keeps equipped items)">Trash All</button>
      </div>
      <div id="stats-panel">
        <h3>Stats</h3>
        <div id="stats-content"></div>
      </div>
    </div>
  </div>
  <div id="tooltip-wrapper">
    <div id="tooltip-equipped"></div>
    <div id="tooltip"></div>
  </div>
  <div id="game-over">
    <span>Game Over</span>
    <p id="survived-time"></p>
    <button id="restart-btn">Restart</button>
  </div>

  <script>
    // ========== 1. CONSTANTS & CONFIG ==========
    const STAT_KEYS = [
      'damage', 'fireRate', 'projectileSize', 'projectileSpeed', 'piercing', 'aoeRadius', 'aoePercent',
      'numProjectiles', 'knockback', 'hp', 'hpRegen', 'moveSpeed', 'size', 'contactDamage', 'lifesteal'
    ];
    const DEFAULT_STATS = {
      damage: 10, fireRate: 1, projectileSize: 8, projectileSpeed: 400, piercing: 1, aoeRadius: 0, aoePercent: 0,
      numProjectiles: 1, knockback: 0, hp: 100, hpRegen: 0.5, moveSpeed: 180, size: 16, contactDamage: 0, lifesteal: 0
    };
    const MELEE_IMPULSE = 120;  // fixed impulse magnitude when melee damage occurs (mass-weighted distribution)
    const PBD_ITERATIONS = 3;   // constraint solver iterations for stability
    const KNOCKBACK_VEL_FACTOR = 50;  // projectile knockback as velocity impulse
    const SPAWN_BUFFER = 250;
    const SPAWN_INTERVAL = 1 / 6;   // 50% of previous: ~6 spawns/sec
    const SPAWN_STEP_SECONDS = 30;   // 200% of previous: budget steps up every 30s
    const DESPAWN_MARGIN = 600;      // despawn entities this far past screen edge
    const GOODNESS_SCALE = 5;        // tanh scale for normalizeGoodness
    const CELL_SIZE = 120;
    const EQUIP_SLOTS = ['helmet', 'chest', 'pants', 'gloves', 'leftHand', 'rightHand', 'boots'];
    const STAT_DISPLAY = [
      { key: 'damage', label: 'Damage' },
      { key: 'fireRate', label: 'Fire Rate' },
      { key: 'piercing', label: 'Pierce' },
      { key: 'numProjectiles', label: 'Projectiles' },
      { key: 'aoeRadius', label: 'AOE Radius' },
      { key: 'aoePercent', label: 'AOE %' },
      { key: 'knockback', label: 'Knockback' },
      { key: 'hp', label: 'HP' },
      { key: 'hpRegen', label: 'HP Regen' },
      { key: 'moveSpeed', label: 'Move Speed' },
      { key: 'size', label: 'Size' },
      { key: 'contactDamage', label: 'Contact Dmg' },
      { key: 'lifesteal', label: 'Lifesteal %' },
      { key: 'projectileSize', label: 'Proj Size' },
      { key: 'projectileSpeed', label: 'Proj Speed' }
    ];
    const HAND_SLOTS = ['leftHand', 'rightHand'];
    const INV_COLS = 6, INV_ROWS = 4;

    // ========== 2. STATE ==========
    let canvas, ctx;
    let gameTime = 0;
    let gamePaused = false;
    let gameOver = false;
    const keys = {};
    let mouseX = 0, mouseY = 0;
    let mouseWorldX = 0, mouseWorldY = 0;
    let mouseLmbDown = false;
    let lastTime = 0;
    let fireCooldown = 0.5;
    let spawnAccum = 0;

    const player = {
      x: 0, y: 0, vx: 0, vy: 0,
      dirX: 1, dirY: 0,
      hp: 100, maxHp: 100,
      stats: { ...DEFAULT_STATS },
      equipment: { helmet: null, chest: null, pants: null, gloves: null, leftHand: null, rightHand: null, boots: null },
      hitInvuln: 0
    };

    const enemies = [];
    const projectiles = [];
    const itemDrops = [];
    const INV_SIZE = INV_COLS * INV_ROWS;
    let inventory = Array(INV_SIZE).fill(null);

    // ========== 3. DEFS ==========
    const ENEMY_DEFS = [
      { id: 'grub', name: 'Grub', points: 1, baseStats: { hp: 6, moveSpeed: 55, size: 14, contactDamage: 5 }, hue: 90, darkness: 0.6, weight: 1 },
      { id: 'imp', name: 'Imp', points: 2, baseStats: { hp: 8, moveSpeed: 95, size: 8, contactDamage: 5 }, hue: 20, darkness: 0.5, weight: 1 },
      { id: 'slime', name: 'Slime', points: 3, baseStats: { hp: 12, moveSpeed: 30, size: 12, contactDamage: 10 }, hue: 140, darkness: 0.6, weight: 1 },
      { id: 'skeleton', name: 'Skeleton', points: 2, baseStats: { hp: 18, moveSpeed: 70, size: 12, contactDamage: 10 }, hue: 0, darkness: 0.7, weight: 2 },
      { id: 'spider', name: 'Spider', points: 2, baseStats: { hp: 22, moveSpeed: 110, size: 10, contactDamage: 15 }, hue: 0, darkness: 0.65, weight: 2 },
      { id: 'zombie', name: 'Zombie', points: 2, baseStats: { hp: 28, moveSpeed: 45, size: 14, contactDamage: 15 }, hue: 85, darkness: 0.7, weight: 2 },
      { id: 'orc', name: 'Orc', points: 3, baseStats: { hp: 38, moveSpeed: 60, size: 18, contactDamage: 20 }, hue: 90, darkness: 0.75, weight: 1 },
      { id: 'ghoul', name: 'Ghoul', points: 3, baseStats: { hp: 35, moveSpeed: 85, size: 14, contactDamage: 20 }, hue: 180, darkness: 0.6, weight: 1 },
      { id: 'wraith', name: 'Wraith', points: 4, baseStats: { hp: 55, moveSpeed: 100, size: 14, contactDamage: 25 }, hue: 260, darkness: 0.4, weight: 1 },
      { id: 'troll', name: 'Troll', points: 5, baseStats: { hp: 85, moveSpeed: 50, size: 24, contactDamage: 30 }, hue: 140, darkness: 0.8, weight: 1 },
      { id: 'specter', name: 'Specter', points: 5, baseStats: { hp: 72, moveSpeed: 90, size: 16, contactDamage: 30 }, hue: 280, darkness: 0.45, weight: 1 },
      { id: 'beast', name: 'Beast', points: 6, baseStats: { hp: 100, moveSpeed: 95, size: 20, contactDamage: 35 }, hue: 25, darkness: 0.75, weight: 1 },
      { id: 'demon', name: 'Demon', points: 8, baseStats: { hp: 155, moveSpeed: 75, size: 22, contactDamage: 35 }, hue: 0, darkness: 0.85, weight: 1 },
      { id: 'knight', name: 'Dark Knight', points: 10, baseStats: { hp: 220, moveSpeed: 65, size: 26, contactDamage: 40 }, hue: 0, darkness: 0.9, weight: 1 }
    ];

    const ITEM_BASE_DEFS = [
      { id: 'cloth_helmet', name: 'Cloth Cap', slot: 'helmet', emoji: '๐Ÿช–', baseStat: { stat: 'hp', value: 5 }, tier: 1 },
      { id: 'cloth_chest', name: 'Cloth Vest', slot: 'chest', emoji: '๐Ÿ‘•', baseStat: { stat: 'hp', value: 10 }, tier: 1 },
      { id: 'cloth_pants', name: 'Cloth Leggings', slot: 'pants', emoji: '๐Ÿ‘–', baseStat: { stat: 'hpRegen', value: 0.2 }, tier: 1 },
      { id: 'cloth_gloves', name: 'Cloth Gloves', slot: 'gloves', emoji: '๐Ÿงค', baseStat: { stat: 'fireRate', value: 0.1 }, tier: 1 },
      { id: 'iron_gloves', name: 'Iron Gauntlets', slot: 'gloves', emoji: '๐ŸฅŠ', baseStat: { stat: 'fireRate', value: 0.2 }, tier: 2 },
      { id: 'plate_gloves', name: 'Plate Gauntlets', slot: 'gloves', emoji: '๐ŸฅŠ', baseStat: { stat: 'fireRate', value: 0.35 }, tier: 3 },
      { id: 'iron_helmet', name: 'Iron Helm', slot: 'helmet', emoji: 'โ›‘๏ธ', baseStat: { stat: 'hp', value: 15 }, tier: 2 },
      { id: 'iron_chest', name: 'Iron Armor', slot: 'chest', emoji: '๐Ÿ›ก๏ธ', baseStat: { stat: 'hp', value: 25 }, tier: 2 },
      { id: 'plate_helmet', name: 'Plate Helm', slot: 'helmet', emoji: '๐Ÿช–', baseStat: { stat: 'hp', value: 30 }, tier: 3 },
      { id: 'plate_chest', name: 'Plate Armor', slot: 'chest', emoji: '๐Ÿ›ก๏ธ', baseStat: { stat: 'hp', value: 50 }, tier: 3 },
      { id: 'dagger', name: 'Dagger', slot: 'leftHand', emoji: '๐Ÿ—ก๏ธ', baseStat: { stat: 'damage', value: 5 }, tier: 1 },
      { id: 'sword', name: 'Sword', slot: 'leftHand', emoji: 'โš”๏ธ', baseStat: { stat: 'damage', value: 12 }, tier: 2 },
      { id: 'greataxe', name: 'Greataxe', slot: 'leftHand', emoji: '๐Ÿช“', baseStat: { stat: 'damage', value: 25 }, tier: 3 },
      { id: 'wand', name: 'Wand', slot: 'leftHand', emoji: '๐Ÿช„', baseStat: { stat: 'fireRate', value: 0.3 }, tier: 2 },
      { id: 'boots', name: 'Boots', slot: 'boots', emoji: '๐Ÿ‘ข', baseStat: { stat: 'moveSpeed', value: 20 }, tier: 2 },
      { id: 'scrap', name: 'Scrap', slot: null, emoji: '๐Ÿ”ฉ', baseStat: null }
    ];

    const MODIFIER_DEFS = [
      { id: 'flat_hp', name: '+# HP', stat: 'hp', min: 5, max: 15, weight: 2 },
      { id: 'pct_hp', name: '+#% HP', stat: 'hp', min: 5, max: 20, weight: 1, pct: true },
      { id: 'flat_damage', name: '+# Damage', stat: 'damage', min: 2, max: 8, weight: 2 },
      { id: 'pct_damage', name: '+#% Damage', stat: 'damage', min: 5, max: 25, weight: 1, pct: true },
      { id: 'fire_rate', name: '+# Fire Rate', stat: 'fireRate', min: 0.1, max: 0.5, weight: 2, float: true },
      { id: 'pierce', name: '+# Pierce', stat: 'piercing', min: 1, max: 2, weight: 1 },
      { id: 'aoe_radius', name: '+# AOE Radius', stat: 'aoeRadius', min: 10, max: 30, weight: 1 },
      { id: 'aoe_pct', name: '+#% AOE Damage', stat: 'aoePercent', min: 10, max: 40, weight: 1, pct: true },
      { id: 'num_proj', name: '+# Projectiles', stat: 'numProjectiles', min: 1, max: 2, weight: 1 },
      { id: 'knockback', name: '+# Knockback', stat: 'knockback', min: 20, max: 60, weight: 1 },
      { id: 'regen', name: '+# HP Regen', stat: 'hpRegen', min: 0.2, max: 0.8, weight: 2, float: true },
      { id: 'speed', name: '+# Move Speed', stat: 'moveSpeed', min: 10, max: 40, weight: 2 },
      { id: 'proj_size', name: '+# Projectile Size', stat: 'projectileSize', min: 1, max: 3, weight: 1 },
      { id: 'proj_speed', name: '+# Proj Speed', stat: 'projectileSpeed', min: 30, max: 100, weight: 2 },
      { id: 'lifesteal', name: '+#% Lifesteal', stat: 'lifesteal', min: 2, max: 12, weight: 1, pct: true }
    ];

    // ========== 4. UTILS ==========
    function dist(ax, ay, bx, by) {
      return Math.hypot(bx - ax, by - ay);
    }
    function normalize(x, y) {
      const d = Math.hypot(x, y);
      return d > 0 ? [x / d, y / d] : [1, 0];
    }
    function getMass(entity) {
      const s = entity.stats?.size ?? 10;
      return s * s;
    }
    function rand(min, max) {
      return min + Math.random() * (max - min);
    }
    function randInt(min, max) {
      return Math.floor(rand(min, max + 1));
    }
    function fmtNum(n) {
      const num = Number(n);
      if (num === Math.floor(num) && Math.abs(num) < 1e12) return String(Math.round(num));
      if (Math.abs(num) >= 1e6 || (Math.abs(num) < 0.01 && num !== 0)) return num.toExponential(2);
      return parseFloat(num.toFixed(2)).toString();
    }
    function weightedPick(arr, weightKey = 'weight') {
      const total = arr.reduce((s, x) => s + (x[weightKey] ?? 1), 0);
      let r = Math.random() * total;
      for (const x of arr) {
        r -= (x[weightKey] ?? 1);
        if (r <= 0) return x;
      }
      return arr[arr.length - 1];
    }
    function normalizeGoodness(raw) {
      return Math.tanh(raw / GOODNESS_SCALE);
    }
    function tierWeight(tier, n) {
      const unlock = (tier - 1) / 2;
      if (n < unlock * 0.6) return 0;
      const excess = n - unlock * 0.6;
      return Math.pow(excess + 0.05, 1.2);
    }
    function pickBaseFromTieredPool(n) {
      const pool = ITEM_BASE_DEFS
        .filter(b => b.tier >= 1 && b.slot != null)
        .map(b => ({ ...b, weight: tierWeight(b.tier, n) }))
        .filter(b => b.weight > 0);
      if (pool.length === 0) return null;
      return weightedPick(pool, 'weight');
    }

    // ========== 5. STATS ==========
    function computePlayerStats() {
      const s = { ...DEFAULT_STATS };
      for (const slot of EQUIP_SLOTS) {
        const item = player.equipment[slot];
        if (!item) continue;
        const base = ITEM_BASE_DEFS.find(b => b.id === item.baseId);
        if (base?.baseStat) {
          const v = base.baseStat.value;
          s[base.baseStat.stat] = (s[base.baseStat.stat] ?? 0) + v;
        }
        for (const mod of item.modifiers || []) {
          const def = MODIFIER_DEFS.find(m => m.id === mod.modId);
          if (def) s[def.stat] = (s[def.stat] ?? 0) + mod.value;
        }
      }
      player.stats = s;
      player.maxHp = s.hp;
    }

    // ========== 6. ITEM GENERATION ==========
    function rollRarity(n) {
      const r = Math.random();
      const commonChance = 0.6 * Math.exp(-n * 2);
      const uncommonChance = 0.3 + 0.2 * (1 - Math.exp(-n * 2));
      if (r < commonChance) return 'common';
      if (r < commonChance + uncommonChance) return 'uncommon';
      return 'rare';
    }
    function rollModValue(def, n, rawGoodness) {
      const valueScale = Math.max(1, Math.pow(rawGoodness, 0.5));
      const effectiveMax = def.min + (def.max - def.min) * valueScale;
      const u = Math.random();
      const exp = 1 + n * n * 3;
      const t = Math.pow(u, 1 / exp);
      let value = def.min + (effectiveMax - def.min) * t;
      if (def.float) {
        value = Math.max(0.01, Math.round(value * 100) / 100);
      } else if (def.pct) {
        value = Math.max(0.01, value);
      } else {
        value = Math.max(def.min, Math.floor(value));
      }
      return value;
    }
    function generateItem(baseId, n, rawGoodness) {
      const base = ITEM_BASE_DEFS.find(b => b.id === baseId);
      if (!base) return null;
      const rarity = rollRarity(n);
      const modCount = rarity === 'common' ? 0 : rarity === 'uncommon' ? 2 : 4;
      const modifiers = [];
      const used = new Set();
      for (let i = 0; i < modCount; i++) {
        const pool = MODIFIER_DEFS.filter(m => !used.has(m.id));
        if (pool.length === 0) break;
        const def = weightedPick(pool, 'weight');
        used.add(def.id);
        const v = rollModValue(def, n, rawGoodness);
        modifiers.push({ modId: def.id, value: v });
      }
      return {
        id: 'i' + Date.now() + randInt(0, 9999),
        baseId: base.id,
        name: base.name,
        slot: base.slot,
        emoji: base.emoji,
        rarity,
        baseStat: { ...base.baseStat },
        modifiers
      };
    }

    // ========== 7. SPAWNER ==========
    function getSpawnBudget() {
      return 1 + Math.floor(gameTime / SPAWN_STEP_SECONDS);  // gentle ramp: 1โ†’2โ†’3... every 15s
    }
    function spawnEnemy() {
      const budget = getSpawnBudget();
      const candidates = ENEMY_DEFS.filter(e => e.points <= budget);
      if (candidates.length === 0) return;
      const def = weightedPick(candidates, 'weight');
      const px = player.x, py = player.y;
      const vw = canvas.width, vh = canvas.height;
      const angle = Math.random() * Math.PI * 2;
      const dist = Math.max(vw, vh) / 2 + SPAWN_BUFFER;
      const x = px + Math.cos(angle) * dist;
      const y = py + Math.sin(angle) * dist;
      const e = {
        x, y, vx: 0, vy: 0,
        defId: def.id,
        hp: def.baseStats.hp ?? 20,
        maxHp: def.baseStats.hp ?? 20,
        stats: { ...def.baseStats },
        hue: def.hue,
        darkness: def.darkness,
        hitIds: new Set()
      };
      enemies.push(e);
    }
    function runSpawner(dt) {
      spawnAccum += dt;
      if (spawnAccum < SPAWN_INTERVAL) return;
      spawnAccum -= SPAWN_INTERVAL;
      let budget = getSpawnBudget();
      const maxSpawns = 5;
      for (let i = 0; i < maxSpawns && budget > 0; i++) {
        const candidates = ENEMY_DEFS.filter(e => e.points <= budget);
        if (candidates.length === 0) break;
        const def = weightedPick(candidates, 'weight');
        if (def.points > budget) continue;
        budget -= def.points;
        const angle = Math.random() * Math.PI * 2;
        const d = Math.max(canvas.width, canvas.height) / 2 + SPAWN_BUFFER;
        const x = player.x + Math.cos(angle) * d;
        const y = player.y + Math.sin(angle) * d;
        enemies.push({
          x, y, vx: 0, vy: 0,
          defId: def.id,
          hp: def.baseStats.hp ?? 20,
          maxHp: def.baseStats.hp ?? 20,
          stats: { ...def.baseStats },
          hue: def.hue,
          darkness: def.darkness,
          hitIds: new Set()
        });
      }
    }

    // ========== 8. PROJECTILES ==========
    const projectilePool = [];
    function getProjectile() {
      if (projectilePool.length) return projectilePool.pop();
      return { x: 0, y: 0, vx: 0, vy: 0, damage: 0, size: 4, piercing: 1, aoeRadius: 0, aoePercent: 0, knockback: 0, hitIds: new Set() };
    }
    function spawnProjectile() {
      const s = player.stats;
      const count = Math.max(1, Math.floor(s.numProjectiles ?? 1));
      const spread = count > 1 ? 0.3 : 0;
      for (let i = 0; i < count; i++) {
        let dx = player.dirX, dy = player.dirY;
        if (count > 1) {
          const angle = Math.atan2(dy, dx) + (i - (count - 1) / 2) * spread;
          dx = Math.cos(angle);
          dy = Math.sin(angle);
        }
        const p = getProjectile();
        p.x = player.x + dx * (player.stats.size + 5);
        p.y = player.y + dy * (player.stats.size + 5);
        const speed = s.projectileSpeed ?? 400;
        p.vx = dx * speed;
        p.vy = dy * speed;
        p.damage = s.damage ?? 10;
        p.size = (s.projectileSize ?? 4);
        p.piercing = Math.max(1, Math.floor(s.piercing ?? 1));
        p.aoeRadius = s.aoeRadius ?? 0;
        p.aoePercent = (s.aoePercent ?? 0) / 100;
        p.knockback = s.knockback ?? 0;
        p.hitIds.clear();
        projectiles.push(p);
      }
    }

    // ========== 9. COLLISION & PBD PHYSICS ==========
    function circleOverlap(ax, ay, ar, bx, by, br) {
      return dist(ax, ay, bx, by) < ar + br;
    }
    function resolveCircleCollision(a, b, ar, br) {
      const d = dist(a.x, a.y, b.x, b.y);
      if (d <= 0) return;
      const overlap = ar + br - d;
      if (overlap <= 0) return;
      const nx = (a.x - b.x) / d;
      const ny = (a.y - b.y) / d;
      const ma = getMass(a);
      const mb = getMass(b);
      const total = ma + mb;
      a.x += nx * overlap * (mb / total);
      a.y += ny * overlap * (mb / total);
      b.x -= nx * overlap * (ma / total);
      b.y -= ny * overlap * (ma / total);
    }
    function updatePhysicsPBD(dt) {
      const pr = player.stats.size ?? 16;
      const pxPrev = player.x, pyPrev = player.y;
      const ePrev = enemies.map(e => ({ x: e.x, y: e.y }));

      for (const e of enemies) {
        const dx = player.x - e.x, dy = player.y - e.y;
        const d = Math.hypot(dx, dy);
        if (d > 0) {
          const [nx, ny] = normalize(dx, dy);
          e.vx = nx * (e.stats.moveSpeed ?? 80);
          e.vy = ny * (e.stats.moveSpeed ?? 80);
        }
      }
      player.x += player.vx * dt;
      player.y += player.vy * dt;
      for (let i = 0; i < enemies.length; i++) {
        enemies[i].x += enemies[i].vx * dt;
        enemies[i].y += enemies[i].vy * dt;
      }

      const meleeImpulses = [];
      const meleeDone = new Set();
      for (let iter = 0; iter < PBD_ITERATIONS; iter++) {
        for (const e of enemies) {
          const er = e.stats.size ?? 10;
          if (circleOverlap(player.x, player.y, pr, e.x, e.y, er)) {
            resolveCircleCollision(player, e, pr, er);
            if (!meleeDone.has(e) && player.hitInvuln <= 0 && (e.stats.contactDamage ?? 0) > 0) {
              player.hp -= (e.stats.contactDamage ?? 0);
              player.hitInvuln = 0.8;
              meleeDone.add(e);
              const d = dist(player.x, player.y, e.x, e.y);
              if (d > 0) meleeImpulses.push({ e, nx: (player.x - e.x) / d, ny: (player.y - e.y) / d });
            }
          }
        }
        for (let i = 0; i < enemies.length; i++) {
          const a = enemies[i];
          const ar = a.stats.size ?? 10;
          for (let j = i + 1; j < enemies.length; j++) {
            const b = enemies[j];
            const br = b.stats.size ?? 10;
            if (circleOverlap(a.x, a.y, ar, b.x, b.y, br)) {
              resolveCircleCollision(a, b, ar, br);
            }
          }
        }
      }

      player.vx = (player.x - pxPrev) / dt;
      player.vy = (player.y - pyPrev) / dt;
      for (let i = 0; i < enemies.length; i++) {
        const e = enemies[i];
        const p = ePrev[i];
        e.vx = (e.x - p.x) / dt;
        e.vy = (e.y - p.y) / dt;
      }
      for (const { e, nx, ny } of meleeImpulses) {
        const mp = getMass(player);
        const me = getMass(e);
        player.vx += (nx * MELEE_IMPULSE) / mp;
        player.vy += (ny * MELEE_IMPULSE) / mp;
        e.vx -= (nx * MELEE_IMPULSE) / me;
        e.vy -= (ny * MELEE_IMPULSE) / me;
      }
      if (player.hitInvuln > 0) player.hitInvuln -= dt;
    }
    function updateCollisions(dt) {
      const despawnDist = Math.max(canvas.width, canvas.height) / 2 + DESPAWN_MARGIN;
      for (let i = enemies.length - 1; i >= 0; i--) {
        if (dist(player.x, player.y, enemies[i].x, enemies[i].y) > despawnDist) enemies.splice(i, 1);
      }
      for (let i = itemDrops.length - 1; i >= 0; i--) {
        if (dist(player.x, player.y, itemDrops[i].x, itemDrops[i].y) > despawnDist) itemDrops.splice(i, 1);
      }
      updatePhysicsPBD(dt);

      const cellSize = CELL_SIZE;
      const getCell = (x, y) => Math.floor(x / cellSize) + 1000 * Math.floor(y / cellSize);
      const grid = {};
      for (const e of enemies) {
        const c = getCell(e.x, e.y);
        if (!grid[c]) grid[c] = [];
        grid[c].push(e);
      }

      for (let i = projectiles.length - 1; i >= 0; i--) {
        const p = projectiles[i];
        p.x += p.vx * dt;
        p.y += p.vy * dt;
        const margin = 300;
        if (Math.abs(p.x - player.x) > canvas.width + margin || Math.abs(p.y - player.y) > canvas.height + margin) {
          projectiles.splice(i, 1);
          projectilePool.push(p);
          continue;
        }
        const c = getCell(p.x, p.y);
        const nearby = new Set();
        for (const dy of [-1000, 0, 1000]) {
          for (const dx of [-1, 0, 1]) {
            const cc = c + dx + dy;
            if (grid[cc]) for (const e of grid[cc]) nearby.add(e);
          }
        }
        for (const e of nearby) {
          if (p.hitIds.has(e)) continue;
          if (!circleOverlap(p.x, p.y, p.size, e.x, e.y, e.stats.size ?? 10)) continue;
          p.hitIds.add(e);
          e.hp -= p.damage;
          const ls = (player.stats.lifesteal ?? 0) / 100;
          if (ls > 0) {
            player.hp = Math.min(player.maxHp, player.hp + p.damage * ls);
          }
          if (p.aoeRadius > 0) {
            for (const o of enemies) {
              if (o === e) continue;
              const d = dist(p.x, p.y, o.x, o.y);
              if (d < p.aoeRadius) o.hp -= p.damage * p.aoePercent;
            }
          }
          if (p.knockback > 0) {
            const [nx, ny] = normalize(e.x - p.x, e.y - p.y);
            e.vx += nx * p.knockback * KNOCKBACK_VEL_FACTOR;
            e.vy += ny * p.knockback * KNOCKBACK_VEL_FACTOR;
          }
          p.piercing--;
          if (p.piercing <= 0) break;
        }
        if (p.piercing <= 0) {
          projectiles.splice(i, 1);
          projectilePool.push(p);
        }
      }

      for (let i = enemies.length - 1; i >= 0; i--) {
        const e = enemies[i];
        if (e.hp <= 0) {
          const def = ENEMY_DEFS.find(d => d.id === e.defId);
          if (def && Math.random() < 0.04) {
            const rawGoodness = def.points;
            const n = normalizeGoodness(rawGoodness);
            const base = pickBaseFromTieredPool(n);
            if (base) {
              const item = generateItem(base.id, n, rawGoodness);
              if (item) itemDrops.push({ x: e.x, y: e.y, item, pickupRadius: 24 });
            }
          }
          enemies.splice(i, 1);
        }
      }

      for (let i = itemDrops.length - 1; i >= 0; i--) {
        const d = itemDrops[i];
        if (circleOverlap(player.x, player.y, player.stats.size + 10, d.x, d.y, d.pickupRadius)) {
          const free = inventory.findIndex(x => !x);
          if (free >= 0) {
            inventory[free] = d.item;
            itemDrops.splice(i, 1);
          }
        }
      }
    }

    // ========== 10. RENDER ==========
    function drawGround() {
      ctx.fillStyle = '#3d6b2f';
      ctx.fillRect(-10000, -10000, 20000, 20000);
    }
    function render() {
      ctx.fillStyle = '#3d6b2f';
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      ctx.save();
      ctx.translate(canvas.width / 2 - player.x, canvas.height / 2 - player.y);
      drawGround();
      const camX = player.x - canvas.width / 2;
      const camY = player.y - canvas.height / 2;
      const margin = 100;

      for (const d of itemDrops) {
        if (d.x < camX - margin || d.x > camX + canvas.width + margin || d.y < camY - margin || d.y > camY + canvas.height + margin) continue;
        ctx.font = '28px sans-serif';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        const r = d.item.rarity;
        const strokeColor = r === 'common' ? '#666' : r === 'uncommon' ? '#4a9eff' : '#ffd700';
        ctx.shadowColor = strokeColor;
        ctx.shadowBlur = 6;
        ctx.fillText(d.item.emoji, d.x, d.y);
        ctx.shadowColor = 'transparent';
        ctx.shadowBlur = 0;
        ctx.fillText(d.item.emoji, d.x, d.y);
      }

      for (const e of enemies) {
        if (e.x < camX - margin || e.x > camX + canvas.width + margin || e.y < camY - margin || e.y > camY + canvas.height + margin) continue;
        const r = e.stats.size ?? 10;
        if (e.hp < e.maxHp) {
          const barW = r * 2.5;
          const barH = 4;
          const barY = e.y - r - barH - 4;
          ctx.fillStyle = '#333';
          ctx.fillRect(e.x - barW / 2, barY, barW, barH);
          ctx.fillStyle = e.hp > e.maxHp * 0.3 ? '#4a4' : '#a44';
          ctx.fillRect(e.x - barW / 2, barY, barW * (e.hp / e.maxHp), barH);
          ctx.strokeStyle = '#555';
          ctx.strokeRect(e.x - barW / 2, barY, barW, barH);
        }
        ctx.beginPath();
        ctx.arc(e.x, e.y, r, 0, Math.PI * 2);
        const light = 50 - e.darkness * 40;
        ctx.fillStyle = `hsl(${e.hue}, 70%, ${light}%)`;
        ctx.fill();
        ctx.strokeStyle = '#000';
        ctx.lineWidth = 2;
        ctx.stroke();
      }

      for (const p of projectiles) {
        ctx.fillStyle = '#ffcc00';
        ctx.beginPath();
        ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
        ctx.fill();
      }

      ctx.fillStyle = player.hitInvuln > 0 ? '#88aaff' : '#4488ff';
      ctx.beginPath();
      ctx.arc(player.x, player.y, player.stats.size, 0, Math.PI * 2);
      ctx.fill();
      ctx.strokeStyle = '#fff';
      ctx.lineWidth = 2;
      ctx.beginPath();
      const endX = player.x + player.dirX * (player.stats.size + 8);
      const endY = player.y + player.dirY * (player.stats.size + 8);
      ctx.moveTo(player.x, player.y);
      ctx.lineTo(endX, endY);
      ctx.stroke();

      ctx.restore();

      const hpBarW = 380;
      const hpFillW = hpBarW - 4;
      ctx.fillStyle = '#333';
      ctx.fillRect(10, 10, hpBarW, 24);
      ctx.fillStyle = player.hp > player.maxHp * 0.3 ? '#4a4' : '#a44';
      ctx.fillRect(12, 12, Math.max(0, (player.hp / player.maxHp) * hpFillW), 20);
      ctx.strokeStyle = '#555';
      ctx.strokeRect(10, 10, hpBarW, 24);
      ctx.fillStyle = '#fff';
      ctx.font = '12px monospace';
      ctx.textAlign = 'left';
      ctx.fillText(`HP ${fmtNum(player.hp)}/${fmtNum(player.maxHp)}  Time ${Math.floor(gameTime)}s  [E] Inventory`, 14, 26);

      if (gameOver) {
        document.getElementById('game-over').classList.add('show');
        document.getElementById('survived-time').textContent = `Survived ${Math.floor(gameTime)} seconds`;
      }
    }

    // ========== 11. INVENTORY UI ==========
    let dragInvIndex = -1;
    let dragEquipSlot = null;
    function buildInventoryUI() {
      const grid = document.getElementById('inventory-grid');
      grid.innerHTML = '';
      for (let i = 0; i < INV_COLS * INV_ROWS; i++) {
        const slot = document.createElement('div');
        const item = inventory[i];
        slot.className = 'inv-slot' + (item ? ' ' + (item.rarity || 'common') : '') + (selectedInvIndex === i ? ' selected' : '');
        slot.dataset.index = String(i);
        slot.textContent = item ? (item.emoji + (item.count > 1 ? 'ร—' + item.count : '')) : '';
        slot.removeAttribute('title');
        if (item) {
          slot.draggable = true;
          slot.addEventListener('dragstart', (e) => { dragInvIndex = i; dragEquipSlot = null; e.dataTransfer.setData('text/plain', 'inv:' + i); e.dataTransfer.effectAllowed = 'move'; });
        } else {
          slot.draggable = false;
        }
        slot.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (dragInvIndex >= 0 || dragEquipSlot) slot.classList.add('drag-over'); });
        slot.addEventListener('dragleave', () => slot.classList.remove('drag-over'));
        slot.addEventListener('drop', (e) => {
          e.preventDefault();
          slot.classList.remove('drag-over');
          const data = e.dataTransfer.getData('text/plain');
          if (data.startsWith('inv:')) {
            const from = parseInt(data.slice(4), 10);
            if (from !== i) {
              const tmp = inventory[from];
              inventory[from] = inventory[i];
              inventory[i] = tmp;
            }
          } else if (data.startsWith('equip:')) {
            const eqSlot = data.slice(6);
            const eqItem = player.equipment[eqSlot];
            if (eqItem) {
              const invItem = inventory[i];
              const base = ITEM_BASE_DEFS.find(b => b.id === invItem?.baseId);
              const canEquip = base && (base.slot === eqSlot || (HAND_SLOTS.includes(eqSlot) && base.slot === 'leftHand'));
              if (canEquip) {
                player.equipment[eqSlot] = invItem;
                inventory[i] = eqItem;
                computePlayerStats();
              } else if (!invItem) {
                player.equipment[eqSlot] = null;
                inventory[i] = eqItem;
                computePlayerStats();
              }
            }
          }
          dragInvIndex = -1;
          buildInventoryUI();
        });
        slot.addEventListener('dragend', () => { dragInvIndex = -1; dragEquipSlot = null; document.querySelectorAll('.inv-slot, .equip-slot').forEach(s => s.classList.remove('drag-over')); buildInventoryUI(); });
        slot.addEventListener('click', () => onInvSlotClick(i));
        slot.addEventListener('mouseenter', (e) => showTooltip(e, item));
        slot.addEventListener('mouseleave', hideTooltip);
        grid.appendChild(slot);
      }
      const scrapSlotEl = document.getElementById('scrap-slot');
      scrapSlotEl.textContent = 'โ™ป๏ธ';
      scrapSlotEl.className = 'inv-slot scrap-slot';
      const equipSlots = document.querySelectorAll('.equip-slot');
      equipSlots.forEach(el => {
        const slot = el.dataset.slot;
        const item = player.equipment[slot];
        el.textContent = item?.emoji ?? '';
        el.className = 'equip-slot' + (item ? ' ' + item.rarity : '');
        el.draggable = !!item;
        if (item) el.removeAttribute('title'); else el.title = { helmet: 'Helmet', chest: 'Chest', pants: 'Pants', gloves: 'Gloves', leftHand: 'Left Hand', rightHand: 'Right Hand', boots: 'Boots' }[slot] || '';
        el.onclick = () => onEquipSlotClick(slot);
        el.onmouseenter = (e) => showTooltip(e, item);
        el.onmouseleave = hideTooltip;
      });
      buildStatsPanel();
    }
    const STAT_LABELS = {
      damage: 'Damage', fireRate: 'Fire Rate', projectileSize: 'Proj Size', projectileSpeed: 'Proj Speed', piercing: 'Pierce',
      aoeRadius: 'AOE Radius', aoePercent: 'AOE %', numProjectiles: 'Projectiles',
      knockback: 'Knockback', hp: 'HP', hpRegen: 'HP Regen', moveSpeed: 'Move Speed',
      size: 'Size', contactDamage: 'Contact Dmg', lifesteal: 'Lifesteal %'
    };
    const STAT_PCT = new Set(['aoePercent', 'lifesteal']);
    function buildStatsPanel() {
      const el = document.getElementById('stats-content');
      if (!el) return;
      el.innerHTML = '';
      const order = ['damage', 'fireRate', 'numProjectiles', 'piercing', 'projectileSize', 'projectileSpeed', 'aoeRadius', 'aoePercent', 'knockback', 'hp', 'hpRegen', 'lifesteal', 'moveSpeed', 'size', 'contactDamage'];
      for (const key of order) {
        const val = player.stats[key];
        if (val == null) continue;
        const row = document.createElement('div');
        row.className = 'stat-row';
        const label = STAT_LABELS[key] || key;
        const suffix = STAT_PCT.has(key) ? '%' : '';
        row.innerHTML = `<span>${label}</span><span>${fmtNum(val)}${suffix}</span>`;
        el.appendChild(row);
      }
    }
    function getTooltipText(item) {
      if (!item) return '';
      const base = ITEM_BASE_DEFS.find(b => b.id === item.baseId);
      if (!base) return item.name;
      let s = item.name + '\n';
      if (base.baseStat) s += `+${base.baseStat.value} ${base.baseStat.stat}\n`;
      for (const mod of item.modifiers || []) {
        const def = MODIFIER_DEFS.find(m => m.id === mod.modId);
        if (def) s += (def.pct ? `+${mod.value}%` : `+${mod.value}`) + ` ${def.stat}\n`;
      }
      return s.trim();
    }
    function populateTooltipEl(el, item, label) {
      if (!item) return;
      const base = ITEM_BASE_DEFS.find(b => b.id === item.baseId);
      el.innerHTML = '';
      if (label) {
        const labelEl = document.createElement('div');
        labelEl.className = 'tooltip-label';
        labelEl.textContent = label;
        el.appendChild(labelEl);
      }
      const nameEl = document.createElement('div');
      nameEl.className = 'tooltip-name ' + (item.rarity || 'common');
      nameEl.textContent = item.name + (item.count > 1 ? ' ร—' + item.count : '');
      el.appendChild(nameEl);
      if (base?.baseStat) {
        const statEl = document.createElement('div');
        statEl.className = 'tooltip-stat';
        statEl.textContent = `+${fmtNum(base.baseStat.value)} ${base.baseStat.stat}`;
        el.appendChild(statEl);
      }
      for (const mod of item.modifiers || []) {
        const def = MODIFIER_DEFS.find(m => m.id === mod.modId);
        if (def) {
          const modEl = document.createElement('div');
          modEl.className = 'tooltip-mod';
          modEl.textContent = def.name.replace('#', fmtNum(mod.value));
          el.appendChild(modEl);
        }
      }
    }
    function getEquippedForSlot(slot) {
      if (!slot) return null;
      const eq = player.equipment[slot];
      if (eq) return eq;
      if (HAND_SLOTS.includes(slot)) {
        for (const s of HAND_SLOTS) {
          const e = player.equipment[s];
          if (e) return e;
        }
      }
      return null;
    }
    function showTooltip(e, item) {
      if (!item) return;
      const tt = document.getElementById('tooltip');
      const ttEq = document.getElementById('tooltip-equipped');
      const wrapper = document.getElementById('tooltip-wrapper');
      populateTooltipEl(tt, item);
      const equippedItem = item.slot && item !== getEquippedForSlot(item.slot) ? getEquippedForSlot(item.slot) : null;
      if (equippedItem) {
        ttEq.innerHTML = '';
        const label = document.createElement('div');
        label.className = 'tooltip-equipped-label';
        label.textContent = 'Equipped';
        ttEq.appendChild(label);
        populateTooltipEl(ttEq, equippedItem);
        ttEq.classList.add('show');
      } else {
        ttEq.innerHTML = '';
        ttEq.classList.remove('show');
      }
      wrapper.classList.add('show');
      const gap = 12;
      const eqWidth = equippedItem ? (ttEq.getBoundingClientRect().width + gap) : 0;
      wrapper.style.left = (e.clientX - eqWidth) + 'px';
      wrapper.style.top = e.clientY + 'px';
      const rect = wrapper.getBoundingClientRect();
      if (rect.right > window.innerWidth) wrapper.style.left = (window.innerWidth - rect.width - 5) + 'px';
      if (rect.bottom > window.innerHeight) wrapper.style.top = (window.innerHeight - rect.height - 5) + 'px';
      if (rect.left < 0) wrapper.style.left = '5px';
    }
    function hideTooltip() {
      const wrapper = document.getElementById('tooltip-wrapper');
      const ttEq = document.getElementById('tooltip-equipped');
      wrapper.classList.remove('show');
      ttEq.classList.remove('show');
      ttEq.innerHTML = '';
    }
    let selectedInvIndex = -1;
    function onInvSlotClick(i) {
      const item = inventory[i];
      if (selectedInvIndex >= 0) {
        if (selectedInvIndex === i) { selectedInvIndex = -1; buildInventoryUI(); return; }
        const other = inventory[selectedInvIndex];
        inventory[selectedInvIndex] = item;
        inventory[i] = other;
        selectedInvIndex = -1;
      } else if (item) {
        selectedInvIndex = i;
      }
      buildInventoryUI();
    }
    function onEquipSlotClick(slot) {
      const item = player.equipment[slot];
      if (selectedInvIndex >= 0) {
        const invItem = inventory[selectedInvIndex];
        const base = ITEM_BASE_DEFS.find(b => b.id === invItem?.baseId);
        const canEquip = base && (base.slot === slot || (HAND_SLOTS.includes(slot) && base.slot === 'leftHand'));
        if (canEquip) {
          const old = player.equipment[slot];
          player.equipment[slot] = invItem;
          inventory[selectedInvIndex] = old;
          selectedInvIndex = -1;
          computePlayerStats();
        }
      } else if (item) {
        const free = inventory.findIndex(x => !x);
        if (free >= 0) {
          inventory[free] = item;
          player.equipment[slot] = null;
          computePlayerStats();
        }
      }
      buildInventoryUI();
    }

    // ========== 12. INPUT ==========
    function updateInput(dt) {
      const speed = (player.stats.moveSpeed ?? 150) * (gamePaused ? 0 : 1);
      if (mouseLmbDown) {
        // Mouse control: face and move toward cursor
        const tox = mouseWorldX - player.x;
        const toy = mouseWorldY - player.y;
        const d = Math.hypot(tox, toy);
        if (d > 0) {
          player.dirX = tox / d;
          player.dirY = toy / d;
          player.vx = player.dirX * speed;
          player.vy = player.dirY * speed;
        } else {
          player.vx = 0;
          player.vy = 0;
        }
      } else {
        // Keyboard control: only update direction when moving
        const dx = (keys['KeyD'] ? 1 : 0) - (keys['KeyA'] ? 1 : 0);
        const dy = (keys['KeyS'] ? 1 : 0) - (keys['KeyW'] ? 1 : 0);
        if (dx === 0 && dy === 0) {
          player.vx = 0;
          player.vy = 0;
        } else {
          const [nx, ny] = normalize(dx, dy);
          player.vx = nx * speed;
          player.vy = ny * speed;
          player.dirX = nx;
          player.dirY = ny;
        }
      }
    }

    // ========== 13. GAME LOOP ==========
    function update(dt) {
      if (gameOver) return;
      if (gamePaused) {
        render();
        requestAnimationFrame(gameLoop);
        return;
      }
      gameTime += dt;
      updateInput(dt);
      if (fireCooldown <= 0) {
        spawnProjectile();
        fireCooldown = 1 / (player.stats.fireRate ?? 1);
      }
      fireCooldown -= dt;
      player.hp = Math.min(player.maxHp, player.hp + (player.stats.hpRegen ?? 0) * dt);
      runSpawner(dt);
      updateCollisions(dt);
      if (player.hp <= 0) gameOver = true;
      render();
      requestAnimationFrame(gameLoop);
    }
    function gameLoop(now) {
      const dt = Math.min(0.05, (now - lastTime) / 1000);
      lastTime = now;
      update(dt);
    }

    // ========== 14. INIT ==========
    function init() {
      canvas = document.getElementById('game');
      ctx = canvas.getContext('2d');
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
      window.addEventListener('resize', () => {
        canvas.width = window.innerWidth;
        canvas.height = window.innerHeight;
      });
      document.addEventListener('keydown', e => {
        keys[e.code] = true;
        if (e.code === 'KeyP') {
          e.preventDefault();
          const equipBases = ITEM_BASE_DEFS.filter(b => b.slot != null);
          for (let i = 0; i < INV_SIZE; i++) {
            const base = equipBases[randInt(0, equipBases.length - 1)];
            const n = Math.random();
            const rawGoodness = rand(1, 6);
            inventory[i] = generateItem(base.id, n, rawGoodness);
          }
          if (gamePaused) buildInventoryUI();
        }
      });
      window.addEventListener('blur', () => { for (const k of Object.keys(keys)) keys[k] = false; mouseLmbDown = false; });
      document.addEventListener('keyup', e => {
        keys[e.code] = false;
        if (e.code === 'KeyE') {
          e.preventDefault();
          gamePaused = !gamePaused;
          document.getElementById('inventory-overlay').classList.toggle('open', gamePaused);
          if (gamePaused) {
            selectedInvIndex = -1;
            buildInventoryUI();
          }
        }
      });
      canvas.addEventListener('mousemove', e => {
        mouseX = e.clientX;
        mouseY = e.clientY;
        mouseWorldX = player.x - canvas.width / 2 + mouseX;
        mouseWorldY = player.y - canvas.height / 2 + mouseY;
      });
      canvas.addEventListener('mousedown', e => { if (e.button === 0) mouseLmbDown = true; });
      window.addEventListener('mouseup', e => { if (e.button === 0) mouseLmbDown = false; });
      document.getElementById('restart-btn').addEventListener('click', () => {
        gameOver = false;
        gameTime = 0;
        gamePaused = false;
        document.getElementById('inventory-overlay').classList.remove('open');
        document.getElementById('game-over').classList.remove('show');
        player.x = 0; player.y = 0; player.vx = 0; player.vy = 0;
        player.hp = 100; player.maxHp = 100;
        player.equipment = { helmet: null, chest: null, pants: null, gloves: null, leftHand: null, rightHand: null, boots: null };
        player.hitInvuln = 0;
        computePlayerStats();
        enemies.length = 0;
        projectiles.length = 0;
        itemDrops.length = 0;
        inventory = Array(INV_SIZE).fill(null);
        fireCooldown = 0.5;
        spawnAccum = 0;
        lastTime = performance.now();
        requestAnimationFrame(gameLoop);
      });
      const scrapSlotEl = document.getElementById('scrap-slot');
      scrapSlotEl.addEventListener('dragover', (e) => {
        e.preventDefault();
        e.dataTransfer.dropEffect = 'move';
        const canScrap = (dragInvIndex >= 0 && inventory[dragInvIndex]?.baseId !== 'scrap') || (dragEquipSlot && player.equipment[dragEquipSlot]);
        if (canScrap) scrapSlotEl.classList.add('drag-over');
      });
      scrapSlotEl.addEventListener('dragleave', () => scrapSlotEl.classList.remove('drag-over'));
      scrapSlotEl.addEventListener('drop', (e) => {
        e.preventDefault();
        scrapSlotEl.classList.remove('drag-over');
        const data = e.dataTransfer.getData('text/plain');
        let itemToScrap = null;
        if (data.startsWith('inv:')) {
          const from = parseInt(data.slice(4), 10);
          if (inventory[from]?.baseId !== 'scrap') {
            itemToScrap = inventory[from];
            inventory[from] = null;
          }
        } else if (data.startsWith('equip:')) {
          const eqSlot = data.slice(6);
          itemToScrap = player.equipment[eqSlot];
          if (itemToScrap) {
            player.equipment[eqSlot] = null;
            computePlayerStats();
          }
        }
        if (itemToScrap) {
          const existing = inventory.findIndex(x => x?.baseId === 'scrap');
          const scrapItem = { id: 'scrap' + Date.now(), baseId: 'scrap', name: 'Scrap', slot: null, emoji: '๐Ÿ”ฉ', rarity: 'common', count: 1 };
          if (existing >= 0) {
            inventory[existing].count = (inventory[existing].count || 1) + 1;
          } else {
            const free = inventory.findIndex(x => !x);
            if (free >= 0) inventory[free] = scrapItem;
          }
          dragInvIndex = -1;
          dragEquipSlot = null;
          selectedInvIndex = -1;
          buildInventoryUI();
        }
      });
      document.getElementById('trash-all-btn').addEventListener('click', () => {
        let scrapCount = 0;
        let itemsToScrap = 0;
        for (let i = 0; i < inventory.length; i++) {
          const it = inventory[i];
          if (!it) continue;
          if (it.baseId === 'scrap') {
            scrapCount += it.count || 1;
          } else {
            itemsToScrap++;
          }
        }
        const totalScrap = scrapCount + itemsToScrap;
        inventory = Array(INV_SIZE).fill(null);
        if (totalScrap > 0) {
          inventory[0] = { id: 'scrap' + Date.now(), baseId: 'scrap', name: 'Scrap', slot: null, emoji: '๐Ÿ”ฉ', rarity: 'common', count: totalScrap };
        }
        selectedInvIndex = -1;
        buildInventoryUI();
      });
      document.querySelectorAll('.equip-slot').forEach(el => {
        el.addEventListener('dragstart', (e) => {
          const slot = el.dataset.slot;
          if (player.equipment[slot]) {
            dragInvIndex = -1;
            dragEquipSlot = slot;
            e.dataTransfer.setData('text/plain', 'equip:' + slot);
            e.dataTransfer.effectAllowed = 'move';
          }
        });
        el.addEventListener('dragover', (e) => {
          e.preventDefault();
          e.dataTransfer.dropEffect = 'move';
          const slot = el.dataset.slot;
          if (dragInvIndex >= 0) {
            const invItem = inventory[dragInvIndex];
            const base = ITEM_BASE_DEFS.find(b => b.id === invItem?.baseId);
            const canEquip = base && (base.slot === slot || (HAND_SLOTS.includes(slot) && base.slot === 'leftHand'));
            if (canEquip) el.classList.add('drag-over');
          } else if (dragEquipSlot) el.classList.add('drag-over');
        });
        el.addEventListener('dragleave', () => el.classList.remove('drag-over'));
        el.addEventListener('drop', (e) => {
          e.preventDefault();
          el.classList.remove('drag-over');
          const data = e.dataTransfer.getData('text/plain');
          const slot = el.dataset.slot;
          if (data.startsWith('inv:')) {
            const from = parseInt(data.slice(4), 10);
            const invItem = inventory[from];
            const base = ITEM_BASE_DEFS.find(b => b.id === invItem?.baseId);
            const canEquip = base && (base.slot === slot || (HAND_SLOTS.includes(slot) && base.slot === 'leftHand'));
            if (canEquip) {
              const old = player.equipment[slot];
              player.equipment[slot] = invItem;
              inventory[from] = old;
              computePlayerStats();
            }
          } else if (data.startsWith('equip:')) {
            const fromSlot = data.slice(6);
            const eqItem = player.equipment[fromSlot];
            if (eqItem && fromSlot !== slot) {
              const base = ITEM_BASE_DEFS.find(b => b.id === eqItem.baseId);
              const canEquip = base && (base.slot === slot || (HAND_SLOTS.includes(slot) && base.slot === 'leftHand'));
              if (canEquip) {
                const tmp = player.equipment[slot];
                player.equipment[slot] = eqItem;
                player.equipment[fromSlot] = tmp;
                computePlayerStats();
              }
            }
          }
          dragInvIndex = -1;
          dragEquipSlot = null;
          buildInventoryUI();
        });
        el.addEventListener('dragend', () => { dragEquipSlot = null; document.querySelectorAll('.inv-slot, .equip-slot').forEach(s => s.classList.remove('drag-over')); buildInventoryUI(); });
      });
      computePlayerStats();
      lastTime = performance.now();
      requestAnimationFrame(gameLoop);
    }
    init();
  </script>
</body>
</html>

remixes

no remixes yet... email to remix@gameslop.net