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
