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.epic { text-shadow: -1px -1px 0 #9333ea, 1px -1px 0 #9333ea, -1px 1px 0 #9333ea, 1px 1px 0 #9333ea, 0 -1px 0 #9333ea, 0 1px 0 #9333ea, -1px 0 0 #9333ea, 1px 0 0 #9333ea; }
.equip-slot.legendary { text-shadow: -1px -1px 0 #f97316, 1px -1px 0 #f97316, -1px 1px 0 #f97316, 1px 1px 0 #f97316, 0 -1px 0 #f97316, 0 1px 0 #f97316, -1px 0 0 #f97316, 1px 0 0 #f97316; }
.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.epic { border-color: #9333ea; text-shadow: -1px -1px 0 #9333ea, 1px -1px 0 #9333ea, -1px 1px 0 #9333ea, 1px 1px 0 #9333ea, 0 -1px 0 #9333ea, 0 1px 0 #9333ea, -1px 0 0 #9333ea, 1px 0 0 #9333ea; }
.inv-slot.legendary { border-color: #f97316; text-shadow: -1px -1px 0 #f97316, 1px -1px 0 #f97316, -1px 1px 0 #f97316, 1px 1px 0 #f97316, 0 -1px 0 #f97316, 0 1px 0 #f97316, -1px 0 0 #f97316, 1px 0 0 #f97316; }
.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: stretch; gap: 8px; }
#inventory-grid, #scrap-slot, #trash-all-btn { align-self: flex-end; }
#scrap-resource {
align-self: flex-end;
min-width: 148px;
padding: 8px 12px;
border: 2px solid #6f5a43;
border-radius: 6px;
background: #1d1712;
color: #f0d7a8;
font-family: monospace;
font-size: 14px;
text-align: right;
}
#station-panel {
display: none;
width: 100%;
background: #241c18;
border: 2px solid #5c4a3d;
border-radius: 6px;
padding: 12px;
color: #eee;
font-family: monospace;
}
#station-panel.open { display: flex; flex-direction: column; gap: 10px; }
#station-title { font-size: 16px; font-weight: bold; }
#station-description { font-size: 12px; color: #cfcfcf; line-height: 1.35; }
#station-inputs { display: flex; gap: 10px; flex-wrap: wrap; }
.station-input-wrap { display: flex; flex-direction: column; gap: 4px; align-items: center; }
.station-input-label { font-size: 11px; color: #9a9a9a; text-transform: uppercase; text-align: center; }
.station-input-slot.selected { border-color: #fff; box-shadow: 0 0 8px rgba(255,255,255,0.55); }
#station-content { display: flex; flex-direction: column; gap: 10px; align-items: stretch; }
#station-output { display: flex; flex-direction: column; gap: 6px; flex: 1; }
.station-section-label { font-size: 11px; color: #888; text-transform: uppercase; }
#station-output-grid {
display: grid;
grid-template-columns: repeat(6, 56px);
grid-template-rows: repeat(2, 56px);
gap: 4px;
}
#station-controls {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
justify-content: flex-end;
min-width: 160px;
}
#station-status { font-size: 13px; color: #ddd; text-align: right; flex: 1; }
#station-action-btn, #station-close-btn {
padding: 10px 16px;
font-size: 13px;
cursor: pointer;
border-radius: 4px;
font-family: inherit;
}
#station-action-btn:hover:not(:disabled) { filter: brightness(1.1); }
#station-action-btn:disabled { opacity: 0.4; cursor: not-allowed; }
#station-close-btn { background: #3a3a3a; border: 2px solid #555; color: #ccc; }
#station-close-btn:hover { background: #4a4a4a; color: #fff; }
.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; }
#tooltip-equipped.show { display: block; }
#tooltip.common, #tooltip-equipped.common { border-color: #888; }
#tooltip.uncommon, #tooltip-equipped.uncommon { border-color: #4a9eff; }
#tooltip.rare, #tooltip-equipped.rare { border-color: #ffd700; }
#tooltip.epic, #tooltip-equipped.epic { border-color: #9333ea; }
#tooltip.legendary, #tooltip-equipped.legendary { border-color: #f97316; }
.tooltip-name { font-weight: bold; margin-bottom: 4px; }
.tooltip-name.common { color: #aaa; }
.tooltip-name.uncommon { color: #4a9eff; }
.tooltip-name.rare { color: #ffd700; }
.tooltip-name.epic { color: #9333ea; }
.tooltip-name.legendary { color: #f97316; }
.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 {
width: 280px;
min-width: 280px;
flex-shrink: 0;
overflow: hidden;
background: #1a1a1a;
border: 2px solid #444;
border-radius: 4px;
padding: 12px;
font-family: monospace;
font-size: 12px;
color: #fff;
}
#stats-panel h3 { font-size: 11px; color: #888; margin-bottom: 8px; text-transform: uppercase; }
#stats-panel #stats-content { width: 100%; box-sizing: border-box; border-radius: 2px; overflow: hidden; }
#stats-panel .stat-row {
display: grid;
grid-template-columns: minmax(100px, 1fr) 56px 52px;
column-gap: 12px;
row-gap: 0;
align-items: baseline;
padding: 2px 6px;
}
#stats-panel .stat-row:nth-child(odd) { background: #1e1e1e; }
#stats-panel .stat-row:nth-child(even) { background: #252525; }
#stats-panel .stat-label {
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
display: block;
}
#stats-panel .stat-value { color: #fff; text-align: right; }
#stats-panel .stat-diff { min-width: 52px; text-align: left; }
#stats-panel .stat-diff-good { color: #4a4; }
#stats-panel .stat-diff-bad { color: #c44; }
</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="Primary"></div><div class="equip-slot-label">Primary</div></div>
<div class="equip-slot-wrap"><div class="equip-slot" data-slot="rightHand" title="Secondary"></div><div class="equip-slot-label">Secondary</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="station-panel">
<div id="station-title"></div>
<div id="station-description"></div>
<div id="station-inputs"></div>
<div id="station-content">
<div id="station-output">
<div class="station-section-label">Output</div>
<div id="station-output-grid"></div>
</div>
<div id="station-controls">
<div id="station-status"></div>
<button id="station-action-btn" type="button"></button>
<button id="station-close-btn" type="button">Close</button>
</div>
</div>
</div>
<div id="inventory-grid"></div>
<div id="scrap-resource">Scrap: 0</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', 'damagePercent', 'fireRate', 'projectileSize', 'projectileSpeed', 'piercing', 'aoeRadius', 'aoePercent',
'numProjectiles', 'knockback', 'hp', 'hpRegen', 'moveSpeed', 'size', 'contactDamage'
];
const DEFAULT_STATS = {
damage: 10, damagePercent: 0, fireRate: 1, projectileSize: 8, projectileSpeed: 400, piercing: 1, aoeRadius: 0, aoePercent: 10,
numProjectiles: 1, knockback: 10, hp: 100, hpRegen: 0.5, moveSpeed: 180, size: 16, contactDamage: 0
};
const MELEE_IMPULSE = 120; // fixed impulse magnitude when melee damage occurs (mass-weighted distribution)
const PBD_ITERATIONS = 2; // constraint solver iterations for stability
const KNOCKBACK_VEL_FACTOR = 10; // converts projectile knockback stat into velocity impulse
const IMPULSE_DAMPING_PER_SECOND = 0.015;
const CORPSE_FADE_SECONDS = 0.75;
const CORPSE_IMPACT_MIN_SPEED = 45;
const CORPSE_IMPACT_IMPULSE_FACTOR = 0.45;
const SPAWN_INTERVAL = 1 / 3; // 50% of previous rate
const SPAWN_STEP_SECONDS = 30; // 200% of previous: budget steps up every 30s
const GOODNESS_SCALE = 5; // tanh scale for normalizeGoodness
const CELL_SIZE = 30;
const EQUIP_SLOTS = ['helmet', 'chest', 'pants', 'gloves', 'leftHand', 'rightHand', 'boots'];
const ARENA_SIZE = 2400;
const ARENA_HALF = ARENA_SIZE / 2;
const WALL_THICKNESS = 16;
const HOLE_WIDTH = 80;
const PORTS_PER_SIDE = 2;
const OBJECTIVE_RADIUS = 120;
const CAPTURE_TIME = 2.0;
const NUM_OBJECTIVES = 3;
const BOMB_RADIUS = 600;
const MAGNET_PULL_SPEED = 1400;
const TRADER_OUTPUT_SLOTS = 12;
const TRADE_SCRAP_COST = 5;
const STAT_DISPLAY = [
{ key: 'damage', label: 'Damage' },
{ key: 'damagePercent', 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: 'projectileSize', label: 'Proj Size' },
{ key: 'projectileSpeed', label: 'Proj Speed' }
];
const HAND_SLOTS = ['leftHand', 'rightHand'];
const PRIMARY_HAND_SLOT = 'leftHand';
const SECONDARY_HAND_SLOT = 'rightHand';
const INV_COLS = 6, INV_ROWS = 4;
// ========== 2. STATE ==========
let canvas, ctx;
let gameTime = 0;
let pityCounters = {};
let dropsReceived = 0;
function rollBoolWithPity(chance, key, backstop) {
const c = (pityCounters[key] || 0) + 1;
if (Math.random() < chance || c >= backstop) {
pityCounters[key] = 0;
return true;
}
pityCounters[key] = c;
return false;
}
let gamePaused = false;
let gameOver = false;
const keys = {};
let mouseX = 0, mouseY = 0;
let mouseWorldX = 0, mouseWorldY = 0;
let mouseLmbDown = false;
let mouseRmbDown = false;
let lastTime = 0;
let fireCooldown = 0.5;
let spawnAccum = 0;
let screenShake = 0;
const player = {
x: 0, y: 0, vx: 0, vy: 0,
impulseVx: 0, impulseVy: 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,
abilityCooldowns: { leftHand: 0, rightHand: 0 }
};
const abilityVisuals = [];
const worldParticles = [];
const enemies = [];
const projectiles = [];
const itemDrops = [];
const mapObjectives = [];
let traderOpen = false;
let activeTraderObjective = null;
let activeStation = null;
const traderOutput = Array(TRADER_OUTPUT_SLOTS).fill(null);
const INV_SIZE = INV_COLS * INV_ROWS;
let inventory = Array(INV_SIZE).fill(null);
let scrap = 0;
// ========== 3. DEFS ==========
const ENEMY_DEFS = [
{ id: 'grub', name: 'Grub', points: 1, baseStats: { hp: 10, moveSpeed: 55, size: 14, contactDamage: 5 }, hue: 90, darkness: 0.6, weight: 1 },
{ id: 'slime', name: 'Slime', points: 5, baseStats: { hp: 26, moveSpeed: 30, size: 16, contactDamage: 10 }, hue: 140, darkness: 0.6, weight: 1 },
{ id: 'imp', name: 'Imp', points: 8, baseStats: { hp: 8, moveSpeed: 115, size: 10, contactDamage: 5 }, hue: 20, darkness: 0.5, weight: 1 },
{ id: 'wraith', name: 'Wraith', points: 12, baseStats: { hp: 30, moveSpeed: 100, size: 14, contactDamage: 20 }, hue: 260, darkness: 0.4, weight: 1 },
{ id: 'troll', name: 'Troll', points: 18, baseStats: { hp: 60, moveSpeed: 50, size: 24, contactDamage: 25 }, hue: 140, darkness: 0.8, weight: 1 },
{ id: 'specter', name: 'Specter', points: 24, baseStats: { hp: 50, moveSpeed: 120, size: 14, contactDamage: 25 }, hue: 280, darkness: 0.45, weight: 1 },
{ id: 'beast', name: 'Beast', points: 30, baseStats: { hp: 100, moveSpeed: 95, size: 20, contactDamage: 35 }, hue: 25, darkness: 0.75, weight: 1 },
{ id: 'demon', name: 'Demon', points: 40, baseStats: { hp: 250, moveSpeed: 75, size: 22, contactDamage: 35 }, hue: 0, darkness: 0.8, weight: 1 },
{ id: 'knight', name: 'Dark Knight', points: 50, baseStats: { hp: 420, moveSpeed: 70, size: 26, contactDamage: 60 }, hue: 120, darkness: 0.9, weight: 1 },
// Summoners
{ id: 'imp_summoner', name: 'Imp Summoner', points: 16, baseStats: { hp: 28, moveSpeed: 60, size: 14, contactDamage: 8 }, hue: 25, darkness: 0.6, weight: 1, summoner: { summonDefId: 'imp', count: 1, interval: 5 } },
{ id: 'imp_summoner_summoner', name: 'Imp Summoner Summoner', points: 35, baseStats: { hp: 80, moveSpeed: 35, size: 22, contactDamage: 15 }, hue: 30, darkness: 0.7, weight: 1, summoner: { summonDefId: 'imp_summoner', count: 1, interval: 15 } },
{ id: 'demon_summoner', name: 'Demon Summoner', points: 55, baseStats: { hp: 32, moveSpeed: 50, size: 18, contactDamage: 25 }, hue: 0, darkness: 0.88, weight: 1, summoner: { summonDefId: 'demon', count: 2, interval: 6 } },
{ id: 'demon_summoner_summoner', name: 'Demon Summoner Summoner', points: 80, baseStats: { hp: 450, moveSpeed: 30, size: 20, contactDamage: 35 }, hue: 340, darkness: 0.92, weight: 1, summoner: { summonDefId: 'demon_summoner', count: 2, interval: 18 } },
// Ranged enemies (1 per 2 melee, skip first 2 point levels); points x2, Shooter nerf: fire/3, proj half speed, proj 2x size
{ id: 'shooter', name: 'Shooter', points: 20, ranged: true, baseStats: { hp: 10, moveSpeed: 50, size: 14, contactDamage: 0 }, hue: 120, darkness: 0.2, weight: 1, rangedStats: { projSpeed: 175, projDamage: 8, fireRate: 0.277, numProjectiles: 1, projSize: 10 } },
{ id: 'scatter', name: 'Scatter', points: 33, ranged: true, baseStats: { hp: 32, moveSpeed: 55, size: 16, contactDamage: 0 }, hue: 110, darkness: 0.4, weight: 1, rangedStats: { projSpeed: 175, projDamage: 6, fireRate: 0.2, numProjectiles: 3, projSize: 10, spread: 0.25 } },
{ id: 'sniper', name: 'Sniper', points: 51, ranged: true, baseStats: { hp: 60, moveSpeed: 70, size: 14, contactDamage: 0 }, hue: 0, darkness: 0.6, weight: 1, rangedStats: { projSpeed: 600, projDamage: 15, fireRate: 0.3, numProjectiles: 1, projSize: 10 } },
{ id: 'warlock', name: 'Warlock', points: 80, ranged: true, baseStats: { hp: 85, moveSpeed: 60, size: 18, contactDamage: 0 }, hue: 100, darkness: 0.8, weight: 1, rangedStats: { projSpeed: 300, projDamage: 8, fireRate: 0.2, numProjectiles: 5, projSize: 15, spread: 0.35 } }
];
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: 'bow', name: 'Bow', slot: 'leftHand', emoji: '๐น', baseStat: { stat: 'damage', value: 10 }, tier: 2 },
{ id: 'shield', name: 'Shield', slot: 'leftHand', emoji: '๐ก๏ธ', baseStat: { stat: 'hp', value: 15 }, tier: 1 },
{ id: 'boots', name: 'Boots', slot: 'boots', emoji: '๐ข', baseStat: { stat: 'moveSpeed', value: 20 }, tier: 2 }
];
const ABILITY_DEFS = {
dagger: { kind: 'toggle', speedMult: 0.75, moveBackward: true, displayName: 'Sneak' },
shield: { kind: 'toggle', speedMult: 0.2, reflectProjectiles: true, displayName: 'Reflect' },
wand: { kind: 'cooldown', cooldown: 5, action: 'teleport', displayName: 'Blink' },
bow: { kind: 'cooldown', cooldown: 5, action: 'super_shot', sizeBonus: 5, pierceBonus: 10, displayName: 'Power Shot' },
sword: { kind: 'cooldown', cooldown: 5, action: 'aoe', baseRadius: 150, baseAoePct: 50, displayName: 'Cleave' },
greataxe: { kind: 'continuous', action: 'whirlwind', moveTowardCursor: true, radius: 120, baseAoePct: 50, displayName: 'Whirlwind' }
};
const SHIELD_REFLECT_RADIUS = 80;
const OBJECTIVE_DEFS = [
{ id: 'item', name: 'Item', color: '#ffd700' },
{ id: 'trader', name: 'Scrap Trader', color: '#c08040' },
{ id: 'mods_transfer', name: 'Mods Transfer', color: '#8b5cf6' },
{ id: 'merge', name: 'Merge', color: '#f59e0b' },
{ id: 'reforge', name: 'Reforge', color: '#06b6d4' },
{ id: 'upgrade', name: 'Upgrade', color: '#ec4899' },
{ id: 'heal', name: 'Heal', color: '#4ade80' },
{ id: 'magnet', name: 'Magnet', color: '#60a5fa' },
{ id: 'bomb', name: 'Bomb', color: '#ef4444' }
];
const STATION_OBJECTIVE_IDS = ['trader', 'mods_transfer', 'merge', 'reforge', 'upgrade'];
const OBJECTIVE_CATEGORY_IDS = ['station', 'bomb', 'heal', 'item', 'magnet'];
const MODIFIER_DEFS = [
{ id: 'flat_hp', name: '+# HP', stat: 'hp', weight: 2, valueFn: g => Math.floor(6 + g * 5.0) },
{ id: 'flat_damage', name: '+# Damage', stat: 'damage', weight: 2, valueFn: g => Math.max(1, Math.floor(1 + g * 0.75)) },
{ id: 'pct_damage', name: '+#% Damage', stat: 'damagePercent', weight: 1, pct: true, valueFn: g => roundTo(3 + Math.sqrt(g) * 4, 1) },
{ id: 'fire_rate', name: '+# Fire Rate', stat: 'fireRate', weight: 2, float: true, valueFn: g => roundTo(0.03 + Math.sqrt(g) * 0.07, 2) },
{ id: 'pierce', name: '+# Pierce', stat: 'piercing', weight: 1, valueFn: g => Math.max(1, Math.floor(Math.sqrt(g) / 2.2)) },
{ id: 'aoe_radius', name: '+# AOE Radius', stat: 'aoeRadius', weight: 1, valueFn: g => Math.floor(8 + Math.sqrt(g) * 8) },
{ id: 'aoe_pct', name: '+#% AOE Damage', stat: 'aoePercent', weight: 1, pct: true, valueFn: g => roundTo(10 + Math.sqrt(g) * 6, 1) },
{ id: 'num_proj', name: '+# Projectiles', stat: 'numProjectiles', weight: 1, valueFn: g => Math.max(1, Math.floor(Math.sqrt(g) / 2.2)) },
{ id: 'knockback', name: '+# Knockback', stat: 'knockback', weight: 1, valueFn: g => Math.floor(10 + Math.sqrt(g) * 10) },
{ id: 'regen', name: '+# HP Regen', stat: 'hpRegen', weight: 2, float: true, valueFn: g => roundTo(0.1 + g * 0.07, 2) },
{ id: 'speed', name: '+# Move Speed', stat: 'moveSpeed', weight: 2, valueFn: g => Math.floor(5 + Math.sqrt(g) * 6) },
{ id: 'proj_size', name: '+# Projectile Size', stat: 'projectileSize', weight: 1, valueFn: g => Math.max(1, Math.floor(1 + Math.sqrt(g) * 0.65)) },
{ id: 'proj_speed', name: '+# Proj Speed', stat: 'projectileSpeed', weight: 2, valueFn: g => Math.floor(20 + Math.sqrt(g) * 20) }
];
// ========== 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 roundTo(value, places = 2) {
const scale = Math.pow(10, places);
return Math.round(value * scale) / scale;
}
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 pointsToGoodness(points) {
return Math.max(1, points ?? 1);
}
function objectiveTimeToGoodness(timeSeconds) {
return 2 + Math.min(6, timeSeconds / 45);
}
function traderTimeToGoodness(timeSeconds) {
return 4 + Math.min(8, timeSeconds / 30);
}
function rerollGoodnessForItem(item) {
const base = ITEM_BASE_DEFS.find(b => b.id === item.baseId);
return Math.max(2, (base?.tier || 1) * 2 + getRarityIndex(item.rarity));
}
function upgradeGoodnessForItem(item, nextRarity) {
const base = ITEM_BASE_DEFS.find(b => b.id === item.baseId);
return Math.max(3, (base?.tier || 1) * 2 + getRarityIndex(nextRarity) * 2);
}
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;
}
function computeStatsFromEquipment(equipment) {
const s = { ...DEFAULT_STATS };
for (const slot of EQUIP_SLOTS) {
const item = 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;
}
}
return s;
}
function getEquipTargetSlot(item) {
const base = ITEM_BASE_DEFS.find(b => b.id === item?.baseId);
if (!base?.slot) return null;
if (HAND_SLOTS.includes(base.slot)) {
return !player.equipment.leftHand ? 'leftHand' : !player.equipment.rightHand ? 'rightHand' : 'leftHand';
}
return base.slot;
}
// ========== 6. ITEM GENERATION ==========
const RARITY_WEIGHTS = [
{ id: 'common', weight: 60 },
{ id: 'uncommon', weight: 30 },
{ id: 'rare', weight: 10 },
{ id: 'epic', weight: 2.5 },
{ id: 'legendary', weight: 0.625 }
];
const RARITY_ORDER = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
const RARITY_SCRAP_VALUE = { common: 1, uncommon: 2, rare: 4, epic: 8, legendary: 16 };
function getRarityModCount(rarity) {
return rarity === 'common' ? 0 : rarity === 'uncommon' ? 2 : rarity === 'rare' ? 4 : rarity === 'epic' ? 5 : 7;
}
function getRarityIndex(rarity) {
return Math.max(0, RARITY_ORDER.indexOf(rarity || 'common'));
}
function getNextRarity(rarity) {
const idx = getRarityIndex(rarity);
return idx < RARITY_ORDER.length - 1 ? RARITY_ORDER[idx + 1] : null;
}
function getScrapValue(item) {
return RARITY_SCRAP_VALUE[item?.rarity || 'common'] || 1;
}
function rollRarity(n) {
let rarity = 'common';
for (let i = 1; i < RARITY_ORDER.length; i++) {
const next = RARITY_ORDER[i];
if (!rollBoolWithPity(0.2, 'rarity_' + next, 5)) break;
rarity = next;
}
return rarity;
}
function getModValue(def, goodness) {
if (typeof def.valueFn !== 'function') return 0;
return def.valueFn(Math.max(1, goodness ?? 1));
}
function generateItem(baseId, n, goodness, forcedRarity) {
const base = ITEM_BASE_DEFS.find(b => b.id === baseId);
if (!base) return null;
const rarity = forcedRarity || rollRarity(n);
const modCount = getRarityModCount(rarity);
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 = getModValue(def, goodness);
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
};
}
function cloneItem(item) {
if (!item) return null;
return {
...item,
baseStat: item.baseStat ? { ...item.baseStat } : null,
modifiers: (item.modifiers || []).map(mod => ({ ...mod }))
};
}
function rollModifierSet(rarity, goodness = 4, excludeIds = []) {
const count = getRarityModCount(rarity);
const modifiers = [];
const used = new Set(excludeIds);
for (let i = 0; i < count; 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);
modifiers.push({ modId: def.id, value: getModValue(def, goodness) });
}
return modifiers;
}
function rerollItemLike(item) {
const goodness = rerollGoodnessForItem(item);
return {
...cloneItem(item),
modifiers: rollModifierSet(item.rarity, goodness)
};
}
function upgradeItemLike(item) {
const nextRarity = getNextRarity(item.rarity);
if (!nextRarity) return null;
const upgraded = cloneItem(item);
upgraded.rarity = nextRarity;
const goodness = upgradeGoodnessForItem(item, nextRarity);
const targetCount = getRarityModCount(nextRarity);
const used = new Set((upgraded.modifiers || []).map(m => m.modId));
while ((upgraded.modifiers || []).length < targetCount) {
const pool = MODIFIER_DEFS.filter(m => !used.has(m.id));
if (pool.length === 0) break;
const def = weightedPick(pool, 'weight');
used.add(def.id);
upgraded.modifiers.push({ modId: def.id, value: getModValue(def, goodness) });
}
return upgraded;
}
function mergeItemsLike(target, sacrifice) {
const merged = cloneItem(target);
const highestRarity = RARITY_ORDER[Math.max(getRarityIndex(target.rarity), getRarityIndex(sacrifice.rarity))];
merged.rarity = highestRarity;
const combined = new Map();
for (const mod of [...(target.modifiers || []), ...(sacrifice.modifiers || [])]) {
combined.set(mod.modId, (combined.get(mod.modId) || 0) + mod.value);
}
const pool = Array.from(combined.entries()).map(([modId, value]) => ({ modId, value }));
for (let i = pool.length - 1; i > 0; i--) {
const j = randInt(0, i);
const tmp = pool[i];
pool[i] = pool[j];
pool[j] = tmp;
}
merged.modifiers = pool.slice(0, getRarityModCount(highestRarity)).map(mod => ({ ...mod }));
return merged;
}
// ========== 7. SPAWNER ==========
function getSpawnBudget() {
return 1 + Math.floor(gameTime / SPAWN_STEP_SECONDS); // gentle ramp: 1โ2โ3... every 15s
}
function getTopUnlockedEnemies(budget, count = 3) {
const unlocked = ENEMY_DEFS.filter(e => e.points <= budget);
const topTiers = [...new Set(unlocked.map(e => e.points))].sort((a, b) => b - a).slice(0, count);
return unlocked.filter(e => topTiers.includes(e.points));
}
const SPAWN_PORTS = (() => {
const ports = [];
const segs = PORTS_PER_SIDE + 1;
for (let i = 1; i <= PORTS_PER_SIDE; i++) {
const t = i / segs;
const along = -ARENA_HALF + ARENA_SIZE * t;
ports.push({ side: 'top', x: along, y: -ARENA_HALF });
ports.push({ side: 'bottom', x: along, y: ARENA_HALF });
ports.push({ side: 'left', x: -ARENA_HALF, y: along });
ports.push({ side: 'right', x: ARENA_HALF, y: along });
}
return ports;
})();
function pickSpawnPosition(size) {
const port = SPAWN_PORTS[randInt(0, SPAWN_PORTS.length - 1)];
const inset = size + 4;
let x = port.x, y = port.y;
if (port.side === 'top') y += inset;
if (port.side === 'bottom') y -= inset;
if (port.side === 'left') x += inset;
if (port.side === 'right') x -= inset;
return { x, y };
}
function runSpawner(dt) {
spawnAccum += dt;
if (spawnAccum < SPAWN_INTERVAL) return;
spawnAccum -= SPAWN_INTERVAL;
const candidates = getTopUnlockedEnemies(getSpawnBudget());
if (candidates.length === 0) return;
const def = weightedPick(candidates, 'weight');
const size = def.baseStats.size ?? 14;
const { x, y } = pickSpawnPosition(size);
const enemy = {
x, y, vx: 0, vy: 0, impulseVx: 0, impulseVy: 0,
defId: def.id,
def,
hp: def.baseStats.hp ?? 20,
maxHp: def.baseStats.hp ?? 20,
stats: { ...def.baseStats },
hue: def.hue,
darkness: def.darkness,
hitIds: new Set(),
corpseHitIds: new Set()
};
if (def.ranged) enemy.fireCooldown = 0;
if (def.summoner) enemy.summonCooldown = def.summoner.interval;
enemies.push(enemy);
}
// ========== 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(opts) {
const sizeBonus = opts?.sizeBonus ?? 0;
const pierceBonus = opts?.pierceBonus ?? 0;
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;
const flatDmg = s.damage ?? 10;
const pctMulti = 1 + (s.damagePercent ?? 0) / 100;
p.damage = flatDmg * pctMulti;
p.size = (s.projectileSize ?? 4) + sizeBonus;
p.piercing = Math.max(1, Math.floor(s.piercing ?? 1)) + pierceBonus;
p.aoeRadius = s.aoeRadius ?? 0;
p.aoePercent = (s.aoePercent ?? 0) / 100;
p.knockback = s.knockback ?? 0;
p.hitIds.clear();
p.enemy = false;
projectiles.push(p);
}
}
function spawnEnemyProjectile(enemy) {
const def = enemy.def;
if (!def?.rangedStats) return;
const rs = def.rangedStats;
const dx = player.x - enemy.x;
const dy = player.y - enemy.y;
const d = Math.hypot(dx, dy);
if (d <= 0) return;
const [baseDx, baseDy] = normalize(dx, dy);
const count = Math.max(1, rs.numProjectiles ?? 1);
const spread = rs.spread ?? 0;
for (let i = 0; i < count; i++) {
let ndx = baseDx, ndy = baseDy;
if (count > 1 && spread > 0) {
const angle = Math.atan2(baseDy, baseDx) + (i - (count - 1) / 2) * spread;
ndx = Math.cos(angle);
ndy = Math.sin(angle);
}
const p = getProjectile();
p.x = enemy.x + ndx * (enemy.stats.size + 5);
p.y = enemy.y + ndy * (enemy.stats.size + 5);
const speed = rs.projSpeed ?? 350;
p.vx = ndx * speed;
p.vy = ndy * speed;
p.damage = rs.projDamage ?? 8;
p.size = rs.projSize ?? 5;
p.piercing = 1;
p.aoeRadius = 0;
p.aoePercent = 0;
p.knockback = 0;
p.hitIds.clear();
p.enemy = true;
projectiles.push(p);
}
}
// ========== 9. COLLISION & PBD PHYSICS ==========
function circleOverlap(ax, ay, ar, bx, by, br) {
return dist(ax, ay, bx, by) < ar + br;
}
function clampToArena(entity) {
const r = entity.stats?.size ?? 8;
const lo = -ARENA_HALF + r;
const hi = ARENA_HALF - r;
if (entity.x < lo) entity.x = lo;
if (entity.x > hi) entity.x = hi;
if (entity.y < lo) entity.y = lo;
if (entity.y > hi) entity.y = hi;
}
function isOutsideArena(x, y) {
return x < -ARENA_HALF || x > ARENA_HALF || y < -ARENA_HALF || y > ARENA_HALF;
}
function clampPointToArena(x, y, r = 0) {
const lo = -ARENA_HALF + r;
const hi = ARENA_HALF - r;
return {
x: Math.max(lo, Math.min(hi, x)),
y: Math.max(lo, Math.min(hi, y))
};
}
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 addImpulse(entity, vx, vy) {
entity.impulseVx = (entity.impulseVx ?? 0) + vx;
entity.impulseVy = (entity.impulseVy ?? 0) + vy;
}
function dampImpulse(entity, dt) {
const keep = Math.pow(IMPULSE_DAMPING_PER_SECOND, dt);
entity.impulseVx = (entity.impulseVx ?? 0) * keep;
entity.impulseVy = (entity.impulseVy ?? 0) * keep;
}
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 }));
const RANGED_RANGE_MIN = 150, RANGED_RANGE_MAX = 250;
for (const e of enemies) {
if (e.dead) {
e.vx = 0;
e.vy = 0;
dampImpulse(e, dt);
continue;
}
const def = e.def;
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);
let vx = nx * (e.stats.moveSpeed ?? 80);
let vy = ny * (e.stats.moveSpeed ?? 80);
if (def?.ranged) {
if (d < RANGED_RANGE_MIN) {
vx = -nx * (e.stats.moveSpeed ?? 80);
vy = -ny * (e.stats.moveSpeed ?? 80);
} else if (d > RANGED_RANGE_MAX) {
vx = nx * (e.stats.moveSpeed ?? 80);
vy = ny * (e.stats.moveSpeed ?? 80);
} else {
vx = vy = 0;
}
}
e.vx = vx;
e.vy = vy;
}
dampImpulse(e, dt);
}
dampImpulse(player, dt);
player.x += (player.vx + (player.impulseVx ?? 0)) * dt;
player.y += (player.vy + (player.impulseVy ?? 0)) * dt;
clampToArena(player);
for (let i = 0; i < enemies.length; i++) {
enemies[i].x += (enemies[i].vx + (enemies[i].impulseVx ?? 0)) * dt;
enemies[i].y += (enemies[i].vy + (enemies[i].impulseVy ?? 0)) * dt;
clampToArena(enemies[i]);
}
const meleeImpulses = [];
const meleeDone = new Set();
const cellSize = CELL_SIZE;
const getCell = (x, y) => Math.floor(x / cellSize) + 1000 * Math.floor(y / cellSize);
let grid = {};
for (let iter = 0; iter < PBD_ITERATIONS; iter++) {
grid = {};
for (let i = 0; i < enemies.length; i++) {
const e = enemies[i];
const c = getCell(e.x, e.y);
if (!grid[c]) grid[c] = [];
grid[c].push({ e, i });
}
for (const e of enemies) {
if (e.dead) continue;
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.4;
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;
const ac = getCell(a.x, a.y);
const seen = new Set();
for (const dy of [-1000, 0, 1000]) {
for (const dx of [-1, 0, 1]) {
const cc = ac + dx + dy;
if (!grid[cc]) continue;
for (const { e: b, i: bIdx } of grid[cc]) {
if (b === a || seen.has(b)) continue;
if (bIdx <= i) continue;
seen.add(b);
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];
if (e.dead) {
e.vx = 0;
e.vy = 0;
} else {
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);
addImpulse(player, (nx * MELEE_IMPULSE) / mp, (ny * MELEE_IMPULSE) / mp);
addImpulse(e, -(nx * MELEE_IMPULSE) / me, -(ny * MELEE_IMPULSE) / me);
}
if (player.hitInvuln > 0) player.hitInvuln -= dt;
return { grid, getCell, cellSize };
}
function spawnSummonedEnemy(summoner, summonDefId) {
const def = ENEMY_DEFS.find(d => d.id === summonDefId);
if (!def) return;
const offset = 25 + (summoner.stats.size ?? 10);
const angle = Math.random() * Math.PI * 2;
const x = summoner.x + Math.cos(angle) * offset;
const y = summoner.y + Math.sin(angle) * offset;
const e = {
x, y, vx: 0, vy: 0, impulseVx: 0, impulseVy: 0,
defId: def.id,
def,
hp: def.baseStats.hp ?? 20,
maxHp: def.baseStats.hp ?? 20,
stats: { ...def.baseStats },
hue: def.hue,
darkness: def.darkness,
hitIds: new Set(),
corpseHitIds: new Set()
};
if (def.ranged) e.fireCooldown = 0;
if (def.summoner) e.summonCooldown = def.summoner.interval;
enemies.push(e);
}
function updateSummoners(dt) {
for (const e of enemies) {
if (e.dead) continue;
const def = e.def;
const sum = def?.summoner;
if (!sum) continue;
if (e.summonCooldown == null) e.summonCooldown = sum.interval;
e.summonCooldown -= dt;
if (e.summonCooldown <= 0) {
e.summonCooldown = sum.interval;
for (let i = 0; i < (sum.count ?? 3); i++) spawnSummonedEnemy(e, sum.summonDefId);
}
}
}
const RANGED_FIRE_RANGE = 500;
function updateRangedEnemies(dt) {
for (const e of enemies) {
if (e.dead) continue;
const def = e.def;
if (!def?.ranged || !def.rangedStats) continue;
if (e.fireCooldown != null) e.fireCooldown -= dt;
if ((e.fireCooldown ?? 0) > 0) continue;
const d = dist(player.x, player.y, e.x, e.y);
if (d > RANGED_FIRE_RANGE) continue;
spawnEnemyProjectile(e);
e.fireCooldown = 1 / (def.rangedStats.fireRate ?? 1);
}
}
function dropLootForEnemy(e) {
const def = e.def;
if (!def) return;
const backstop = Math.min(20, 3 + dropsReceived);
if (rollBoolWithPity(0.04, 'enemy_drop', backstop)) {
const goodness = pointsToGoodness(def.points);
const n = normalizeGoodness(goodness);
const base = pickBaseFromTieredPool(n);
if (base) {
const item = generateItem(base.id, n, goodness);
if (item) {
itemDrops.push({ x: e.x, y: e.y, item, pickupRadius: 24 });
dropsReceived++;
}
}
}
}
function killEnemy(e) {
if (!e || e.dead) return;
dropLootForEnemy(e);
e.dead = true;
e.deathAge = 0;
e.deathMaxAge = CORPSE_FADE_SECONDS;
e.stats.contactDamage = 0;
e.fireCooldown = null;
e.summonCooldown = null;
e.corpseHitIds = new Set();
}
function applyCorpseImpacts(corpse) {
if (!corpse.dead) return;
const speed = Math.hypot(
(corpse.vx || 0) + (corpse.impulseVx || 0),
(corpse.vy || 0) + (corpse.impulseVy || 0)
);
if (speed < CORPSE_IMPACT_MIN_SPEED) return;
const cr = corpse.stats.size ?? 10;
for (const e of enemies) {
if (e === corpse || e.dead || corpse.corpseHitIds?.has(e)) continue;
const er = e.stats.size ?? 10;
if (!circleOverlap(corpse.x, corpse.y, cr, e.x, e.y, er)) continue;
corpse.corpseHitIds.add(e);
const [nx, ny] = normalize(e.x - corpse.x, e.y - corpse.y);
addImpulse(e, nx * speed * CORPSE_IMPACT_IMPULSE_FACTOR, ny * speed * CORPSE_IMPACT_IMPULSE_FACTOR);
addImpulse(corpse, -nx * speed * 0.12, -ny * speed * 0.12);
}
}
function updateCollisions(dt) {
const { grid, getCell, cellSize } = updatePhysicsPBD(dt);
const pr = player.stats.size ?? 16;
for (let i = projectiles.length - 1; i >= 0; i--) {
const p = projectiles[i];
p.x += p.vx * dt;
p.y += p.vy * dt;
if (isOutsideArena(p.x, p.y)) {
projectiles.splice(i, 1);
delete p.enemy;
projectilePool.push(p);
continue;
}
if (p.enemy) {
if (player.hitInvuln <= 0 && circleOverlap(p.x, p.y, p.size, player.x, player.y, pr)) {
player.hp -= p.damage;
player.hitInvuln = 0.8;
projectiles.splice(i, 1);
delete p.enemy;
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 (e.dead) continue;
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;
if (p.aoeRadius > 0) {
const r = p.aoeRadius;
const minCx = Math.floor((p.x - r) / cellSize);
const maxCx = Math.floor((p.x + r) / cellSize);
const minCy = Math.floor((p.y - r) / cellSize);
const maxCy = Math.floor((p.y + r) / cellSize);
for (let cy = minCy; cy <= maxCy; cy++) {
for (let cx = minCx; cx <= maxCx; cx++) {
const cc = cx + 1000 * cy;
if (!grid[cc]) continue;
for (const { e: o } of grid[cc]) {
if (o === e || o.dead) continue;
const d = dist(p.x, p.y, o.x, o.y);
if (d < r) o.hp -= p.damage * p.aoePercent;
}
}
}
}
if (p.knockback > 0) {
const [nx, ny] = normalize(e.x - p.x, e.y - p.y);
addImpulse(
e,
nx * p.knockback * KNOCKBACK_VEL_FACTOR,
ny * p.knockback * KNOCKBACK_VEL_FACTOR
);
}
p.piercing--;
if (p.piercing <= 0) break;
}
if (p.piercing <= 0) {
projectiles.splice(i, 1);
delete p.enemy;
projectilePool.push(p);
}
}
for (let i = enemies.length - 1; i >= 0; i--) {
const e = enemies[i];
if (e.hp <= 0 && !e.dead) killEnemy(e);
if (e.dead) {
e.deathAge += dt;
applyCorpseImpacts(e);
if (e.deathAge >= (e.deathMaxAge || CORPSE_FADE_SECONDS)) {
enemies.splice(i, 1);
}
}
}
for (let i = itemDrops.length - 1; i >= 0; i--) {
const d = itemDrops[i];
if (d.targetX != null) {
const dx = d.targetX - d.x;
const dy = d.targetY - d.y;
const dd = Math.hypot(dx, dy);
if (dd <= 1) {
d.x = d.targetX;
d.y = d.targetY;
d.targetX = d.targetY = null;
} else {
const step = Math.min(dd, MAGNET_PULL_SPEED * dt);
d.x += (dx / dd) * step;
d.y += (dy / dd) * step;
}
}
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);
}
}
}
}
// ========== 9a. WEAPON ABILITIES ==========
function getEquippedAbility(slot) {
const item = player.equipment[slot];
if (!item) return null;
return ABILITY_DEFS[item.baseId] || null;
}
function getPrimaryAbility() {
return getEquippedAbility(PRIMARY_HAND_SLOT);
}
function getAbilityInfo(item, slot) {
if (!item) return null;
const ability = ABILITY_DEFS[item.baseId];
if (!ability) return null;
const slotName = slot === PRIMARY_HAND_SLOT ? 'Primary' : slot === SECONDARY_HAND_SLOT ? 'Secondary' : slot;
return {
ability,
slot,
slotName,
name: ability.displayName || item.name,
itemName: item.name,
inactiveText: slot === SECONDARY_HAND_SLOT ? '(inactive - move to primary)' : ''
};
}
function abilityDamage(baseAoePct) {
const flat = player.stats.damage ?? 0;
const pctMulti = 1 + (player.stats.damagePercent ?? 0) / 100;
const aoeMult = baseAoePct / 100 + (player.stats.aoePercent ?? 0) / 100;
return flat * pctMulti * aoeMult;
}
function abilityRadius(baseRadius) {
return baseRadius + (player.stats.aoeRadius ?? 0);
}
function fireCooldownAbility(ab) {
if (ab.action === 'aoe') {
const r = abilityRadius(ab.baseRadius);
const dmg = abilityDamage(ab.baseAoePct);
for (const e of enemies) {
if (!e.dead && dist(player.x, player.y, e.x, e.y) <= r) e.hp -= dmg;
}
abilityVisuals.push({ kind: 'aoe', x: player.x, y: player.y, radius: r, age: 0, maxAge: 0.48, color: '#ffd54a' });
spawnAbilityParticles(player.x, player.y, '#ffd54a', { count: 28, speedMin: 110, speedMax: 320, radiusMin: 5, radiusMax: 11 });
screenShake = Math.max(screenShake, 9);
} else if (ab.action === 'teleport') {
const fromX = player.x;
const fromY = player.y;
const target = clampPointToArena(mouseWorldX, mouseWorldY, player.stats.size ?? 16);
player.x = target.x;
player.y = target.y;
player.vx = 0;
player.vy = 0;
player.impulseVx = 0;
player.impulseVy = 0;
abilityVisuals.push({ kind: 'aoe', x: fromX, y: fromY, radius: 70, age: 0, maxAge: 0.28, color: '#ffd54a' });
abilityVisuals.push({ kind: 'aoe', x: player.x, y: player.y, radius: 90, age: 0, maxAge: 0.36, color: '#ffd54a' });
spawnAbilityParticles(fromX, fromY, '#ffd54a', { count: 16, speedMin: 70, speedMax: 180, radiusMin: 3, radiusMax: 7 });
spawnAbilityParticles(player.x, player.y, '#ffd54a', { count: 24, speedMin: 100, speedMax: 260, radiusMin: 4, radiusMax: 9 });
screenShake = Math.max(screenShake, 6);
} else if (ab.action === 'super_shot') {
spawnProjectile({ sizeBonus: ab.sizeBonus, pierceBonus: ab.pierceBonus });
abilityVisuals.push({ kind: 'flash', x: player.x, y: player.y, dirX: player.dirX, dirY: player.dirY, age: 0, maxAge: 0.22, color: '#ffd54a' });
spawnAbilityParticles(player.x + player.dirX * 28, player.y + player.dirY * 28, '#ffd54a', {
count: 16, speedMin: 120, speedMax: 260, radiusMin: 4, radiusMax: 8, dir: Math.atan2(player.dirY, player.dirX), spread: 0.8
});
screenShake = Math.max(screenShake, 6);
} else if (ab.action === 'arc') {
const range = abilityRadius(ab.range);
const dmg = abilityDamage(ab.baseAoePct);
const half = ab.arcAngle / 2;
const facing = Math.atan2(player.dirY, player.dirX);
for (const e of enemies) {
if (e.dead) continue;
const dx = e.x - player.x, dy = e.y - player.y;
const d = Math.hypot(dx, dy);
if (d > range + (e.stats.size ?? 10)) continue;
let delta = Math.atan2(dy, dx) - facing;
while (delta > Math.PI) delta -= Math.PI * 2;
while (delta < -Math.PI) delta += Math.PI * 2;
if (Math.abs(delta) <= half) e.hp -= dmg;
}
abilityVisuals.push({ kind: 'arc', x: player.x, y: player.y, dirX: player.dirX, dirY: player.dirY, range, halfArc: half, age: 0, maxAge: 0.34, color: '#fff1a8' });
spawnAbilityParticles(player.x + player.dirX * (range * 0.45), player.y + player.dirY * (range * 0.45), '#fff1a8', {
count: 20, speedMin: 90, speedMax: 240, radiusMin: 4, radiusMax: 9, dir: facing, spread: ab.arcAngle
});
screenShake = Math.max(screenShake, 7);
}
}
function applyContinuousAbility(ab, dt) {
if (ab.action === 'whirlwind') {
const r = abilityRadius(ab.radius);
const dps = abilityDamage(ab.baseAoePct);
for (const e of enemies) {
if (!e.dead && dist(player.x, player.y, e.x, e.y) <= r + (e.stats.size ?? 10)) e.hp -= dps * dt;
}
abilityVisuals.push({ kind: 'whirlwind', x: player.x, y: player.y, radius: r, age: 0, maxAge: 0.08, color: '#ff9a3c' });
if (Math.random() < dt * 24) {
const angle = Math.random() * Math.PI * 2;
spawnAbilityParticles(player.x + Math.cos(angle) * r * 0.65, player.y + Math.sin(angle) * r * 0.65, '#ff9a3c', {
count: 4, speedMin: 40, speedMax: 120, radiusMin: 3, radiusMax: 6, dir: angle + Math.PI / 2, spread: 0.9
});
}
screenShake = Math.max(screenShake, 2.2);
}
}
function applyShieldReflect() {
for (const p of projectiles) {
if (!p.enemy) continue;
const dx = p.x - player.x, dy = p.y - player.y;
const d = Math.hypot(dx, dy);
if (d > SHIELD_REFLECT_RADIUS) continue;
const speed = Math.hypot(p.vx, p.vy);
if (d > 0) {
p.vx = (dx / d) * speed;
p.vy = (dy / d) * speed;
}
p.enemy = false;
p.hitIds.clear();
}
}
function tickAbilities(dt) {
for (const slot of HAND_SLOTS) {
if (player.abilityCooldowns[slot] > 0) player.abilityCooldowns[slot] -= dt;
}
for (let i = abilityVisuals.length - 1; i >= 0; i--) {
abilityVisuals[i].age += dt;
if (abilityVisuals[i].age >= abilityVisuals[i].maxAge) abilityVisuals.splice(i, 1);
}
if (!mouseRmbDown) return;
const slot = PRIMARY_HAND_SLOT;
const ab = getEquippedAbility(slot);
if (!ab) return;
if (ab.kind === 'cooldown' && player.abilityCooldowns[slot] <= 0) {
fireCooldownAbility(ab);
player.abilityCooldowns[slot] = ab.cooldown;
} else if (ab.kind === 'continuous') {
applyContinuousAbility(ab, dt);
} else if (ab.kind === 'toggle' && ab.reflectProjectiles) {
applyShieldReflect();
}
}
// ========== 9b. MAP OBJECTIVES ==========
function objectiveOverlapsAny(x, y, r) {
const minSep = r * 2 + 40;
for (const o of mapObjectives) {
if (dist(x, y, o.x, o.y) < minSep) return true;
}
return false;
}
function pickObjectivePosition() {
const margin = OBJECTIVE_RADIUS + 80;
const lo = -ARENA_HALF + margin;
const hi = ARENA_HALF - margin;
const portClearance = OBJECTIVE_RADIUS + 120;
for (let attempt = 0; attempt < 200; attempt++) {
const x = rand(lo, hi);
const y = rand(lo, hi);
if (dist(x, y, player.x, player.y) < OBJECTIVE_RADIUS + 100) continue;
if (objectiveOverlapsAny(x, y, OBJECTIVE_RADIUS)) continue;
let nearPort = false;
for (const p of SPAWN_PORTS) {
if (dist(x, y, p.x, p.y) < portClearance) { nearPort = true; break; }
}
if (nearPort) continue;
return { x, y };
}
return null;
}
function spawnMapObjective() {
const pos = pickObjectivePosition();
if (!pos) return false;
const def = pickObjectiveDef();
if (!def) return false;
mapObjectives.push({
x: pos.x, y: pos.y,
defId: def.id,
def,
captureProgress: 0
});
return true;
}
function getObjectiveCategoryId(defId) {
return STATION_OBJECTIVE_IDS.includes(defId) ? 'station' : defId;
}
function pickObjectiveDef() {
const usedCategories = new Set(mapObjectives.map(o => getObjectiveCategoryId(o.defId)));
const availableCategories = OBJECTIVE_CATEGORY_IDS.filter(id => !usedCategories.has(id));
if (availableCategories.length === 0) return null;
const categoryId = availableCategories[randInt(0, availableCategories.length - 1)];
if (categoryId === 'station') {
const stationPool = OBJECTIVE_DEFS.filter(d => STATION_OBJECTIVE_IDS.includes(d.id));
return stationPool[randInt(0, stationPool.length - 1)];
}
return OBJECTIVE_DEFS.find(d => d.id === categoryId) || null;
}
function spawnInitialObjectives() {
for (let i = 0; i < NUM_OBJECTIVES; i++) spawnMapObjective();
}
function updateMapObjectives(dt) {
while (mapObjectives.length < NUM_OBJECTIVES) {
if (!spawnMapObjective()) break;
}
for (let i = mapObjectives.length - 1; i >= 0; i--) {
const o = mapObjectives[i];
const inside = dist(player.x, player.y, o.x, o.y) <= OBJECTIVE_RADIUS;
if (inside) {
o.captureProgress += dt;
if (o.captureProgress >= CAPTURE_TIME) {
triggerObjective(o);
mapObjectives.splice(i, 1);
}
} else if (o.captureProgress > 0) {
o.captureProgress = Math.max(0, o.captureProgress - dt * 0.5);
}
}
}
function rollObjectiveItemRarity() {
return weightedPick([
{ id: 'uncommon', weight: 70 },
{ id: 'rare', weight: 25 },
{ id: 'epic', weight: 5 }
], 'weight').id;
}
function spawnObjectiveItem(x, y) {
const goodness = objectiveTimeToGoodness(gameTime);
const n = normalizeGoodness(goodness);
const base = pickBaseFromTieredPool(n);
if (!base) return;
const rarity = rollObjectiveItemRarity();
const item = generateItem(base.id, n, goodness, rarity);
if (item) itemDrops.push({ x, y, item, pickupRadius: 24 });
}
function applyMagnet() {
for (const d of itemDrops) {
d.targetX = player.x;
d.targetY = player.y;
}
}
function applyBomb(x, y) {
for (const e of enemies) {
if (!e.dead && e.hp > 0 && dist(x, y, e.x, e.y) <= BOMB_RADIUS) {
spawnAbilityParticles(e.x, e.y, '#ef4444', { count: 10, speedMin: 60, speedMax: 210, radiusMin: 3, radiusMax: 8 });
e.hp = 0;
}
}
abilityVisuals.push({ kind: 'aoe', x, y, radius: BOMB_RADIUS, age: 0, maxAge: 0.58, color: '#ef4444' });
spawnAbilityParticles(x, y, '#ef4444', { count: 42, speedMin: 160, speedMax: 520, radiusMin: 6, radiusMax: 14 });
screenShake = Math.max(screenShake, 18);
}
function hexToRgba(hex, alpha) {
const clean = hex.replace('#', '');
const value = parseInt(clean, 16);
const r = (value >> 16) & 255;
const g = (value >> 8) & 255;
const b = value & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
function spawnCaptureParticles(x, y, color) {
for (let i = 0; i < 32; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 80 + Math.random() * 260;
worldParticles.push({
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
radius: 4 + Math.random() * 8,
color,
age: 0,
maxAge: 0.45 + Math.random() * 0.35
});
}
for (let i = 0; i < 10; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 40 + Math.random() * 120;
worldParticles.push({
x: x + Math.cos(angle) * (OBJECTIVE_RADIUS * 0.4),
y: y + Math.sin(angle) * (OBJECTIVE_RADIUS * 0.4),
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 30,
radius: 10 + Math.random() * 12,
color,
age: 0,
maxAge: 0.3 + Math.random() * 0.2
});
}
}
function spawnAbilityParticles(x, y, color, opts = {}) {
const count = opts.count ?? 18;
const speedMin = opts.speedMin ?? 90;
const speedMax = opts.speedMax ?? 220;
const radiusMin = opts.radiusMin ?? 3;
const radiusMax = opts.radiusMax ?? 8;
const spread = opts.spread ?? (Math.PI * 2);
const dir = opts.dir ?? 0;
for (let i = 0; i < count; i++) {
const angle = dir + (Math.random() - 0.5) * spread;
const speed = speedMin + Math.random() * (speedMax - speedMin);
worldParticles.push({
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
radius: radiusMin + Math.random() * (radiusMax - radiusMin),
color,
age: 0,
maxAge: 0.2 + Math.random() * 0.22
});
}
}
function triggerCaptureFeedback(o) {
spawnCaptureParticles(o.x, o.y, o.def.color);
screenShake = Math.max(screenShake, 14);
}
function triggerObjective(o) {
triggerCaptureFeedback(o);
const id = o.defId;
if (id === 'item') {
spawnObjectiveItem(o.x, o.y);
} else if (id === 'heal') {
player.hp = player.maxHp;
} else if (id === 'magnet') {
applyMagnet();
} else if (id === 'bomb') {
applyBomb(o.x, o.y);
} else if (id === 'trader') {
openTrader(o);
} else if (id === 'mods_transfer') {
openStation(makeModsTransferStation(o));
} else if (id === 'merge') {
openStation(makeMergeStation(o));
} else if (id === 'reforge') {
openStation(makeReforgeStation(o));
} else if (id === 'upgrade') {
openStation(makeUpgradeStation(o));
}
}
// ========== 10. RENDER ==========
function seeded(x, y, s) {
const n = Math.sin(x * 12.9898 + y * 78.233 + (s || 0)) * 43758.5453;
return n - Math.floor(n);
}
function warp(gx, gy, scale, seed) {
return (seeded(gx * scale, gy * scale, seed) - 0.5) * 2;
}
function drawBackgroundDecor(camX, camY, margin) {
const GRID = 280;
const JITTER = GRID * 0.1;
const warpAmt = GRID * 0.4;
const minGx = Math.floor((camX - margin) / GRID);
const maxGx = Math.ceil((camX + canvas.width + margin) / GRID);
const minGy = Math.floor((camY - margin) / GRID);
const maxGy = Math.ceil((camY + canvas.height + margin) / GRID);
for (let gx = minGx; gx <= maxGx; gx++) {
for (let gy = minGy; gy <= maxGy; gy++) {
const wx = gx * GRID + GRID / 2
+ warp(gx, gy, 0.2, 1) * warpAmt
+ (seeded(gx, gy, 3) - 0.5) * 2 * JITTER;
const wy = gy * GRID + GRID / 2
+ warp(gx, gy, 0.2, 2) * warpAmt
+ (seeded(gx, gy, 4) - 0.5) * 2 * JITTER;
if (wx < -ARENA_HALF || wx > ARENA_HALF || wy < -ARENA_HALF || wy > ARENA_HALF) continue;
const r = seeded(gx, gy, 5);
if (r < 0.75) {
ctx.fillStyle = '#2d5016';
for (let b = 0; b < 3; b++) {
const splay = (b - 1) * 0.35;
const h = 14;
const lean = Math.sin(splay) * h * 0.4;
const baseX = wx + (b - 1) * 2;
const tipX = baseX + lean;
const tipY = wy - h;
const baseW = 1.2;
ctx.beginPath();
ctx.moveTo(baseX - baseW, wy);
ctx.lineTo(tipX, tipY);
ctx.lineTo(baseX + baseW, wy);
ctx.closePath();
ctx.fill();
}
} else {
const w = 20;
const h = 6;
ctx.fillStyle = '#4a4a4a';
ctx.beginPath();
ctx.moveTo(wx - w / 2, wy);
ctx.lineTo(wx - w * 0.2, wy - h * 0.65);
ctx.lineTo(wx + w * 0.15, wy - h);
ctx.lineTo(wx + w / 2, wy);
ctx.closePath();
ctx.fill();
}
}
}
}
function drawGround() {
ctx.fillStyle = '#1a2a18';
ctx.fillRect(-10000, -10000, 20000, 20000);
ctx.fillStyle = '#3d6b2f';
ctx.fillRect(-ARENA_HALF, -ARENA_HALF, ARENA_SIZE, ARENA_SIZE);
}
function drawArenaWalls() {
ctx.fillStyle = '#5a4a3a';
ctx.strokeStyle = '#1c1410';
ctx.lineWidth = 2;
const t = WALL_THICKNESS;
const half = ARENA_HALF;
const segs = PORTS_PER_SIDE + 1;
const drawHorizSegments = (y) => {
const cuts = [];
cuts.push(-half);
for (let i = 1; i <= PORTS_PER_SIDE; i++) {
const px = -half + ARENA_SIZE * (i / segs);
cuts.push(px - HOLE_WIDTH / 2);
cuts.push(px + HOLE_WIDTH / 2);
}
cuts.push(half);
for (let i = 0; i < cuts.length; i += 2) {
const x0 = cuts[i], x1 = cuts[i + 1];
ctx.fillRect(x0, y, x1 - x0, t);
ctx.strokeRect(x0, y, x1 - x0, t);
}
};
const drawVertSegments = (x) => {
const cuts = [];
cuts.push(-half);
for (let i = 1; i <= PORTS_PER_SIDE; i++) {
const py = -half + ARENA_SIZE * (i / segs);
cuts.push(py - HOLE_WIDTH / 2);
cuts.push(py + HOLE_WIDTH / 2);
}
cuts.push(half);
for (let i = 0; i < cuts.length; i += 2) {
const y0 = cuts[i], y1 = cuts[i + 1];
ctx.fillRect(x, y0, t, y1 - y0);
ctx.strokeRect(x, y0, t, y1 - y0);
}
};
drawHorizSegments(-half - t);
drawHorizSegments(half);
drawVertSegments(-half - t);
drawVertSegments(half);
}
function drawMapObjectives() {
ctx.lineWidth = 4;
ctx.font = 'bold 16px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const o of mapObjectives) {
ctx.strokeStyle = o.def.color;
ctx.fillStyle = o.def.color;
ctx.beginPath();
ctx.arc(o.x, o.y, OBJECTIVE_RADIUS, 0, Math.PI * 2);
ctx.stroke();
if (o.captureProgress > 0) {
const frac = Math.min(1, o.captureProgress / CAPTURE_TIME);
ctx.lineWidth = 8;
ctx.beginPath();
ctx.arc(o.x, o.y, OBJECTIVE_RADIUS - 6, -Math.PI / 2, -Math.PI / 2 + frac * Math.PI * 2);
ctx.stroke();
ctx.lineWidth = 4;
}
ctx.fillText(o.def.name, o.x, o.y);
}
}
function render() {
ctx.fillStyle = '#3d6b2f';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.save();
const shakeX = screenShake > 0 ? rand(-screenShake, screenShake) : 0;
const shakeY = screenShake > 0 ? rand(-screenShake, screenShake) : 0;
ctx.translate(canvas.width / 2 + shakeX - player.x, canvas.height / 2 + shakeY - player.y);
drawGround();
const camX = player.x - canvas.width / 2;
const camY = player.y - canvas.height / 2;
const margin = 100;
drawBackgroundDecor(camX, camY, margin);
drawMapObjectives();
drawArenaWalls();
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 = { uncommon: '#4a9eff', rare: '#ffd700', epic: '#9333ea', legendary: '#f97316' }[r];
if (strokeColor) {
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;
const corpseAlpha = e.dead ? Math.max(0, 1 - (e.deathAge || 0) / (e.deathMaxAge || CORPSE_FADE_SECONDS)) : 1;
if (!e.dead && 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 * Math.max(0, e.hp / e.maxHp), barH);
ctx.strokeStyle = '#555';
ctx.strokeRect(e.x - barW / 2, barY, barW, barH);
}
ctx.save();
ctx.globalAlpha = corpseAlpha;
ctx.beginPath();
ctx.arc(e.x, e.y, r, 0, Math.PI * 2);
const light = 50 - e.darkness * 40;
const sat = 70;
ctx.fillStyle = `hsl(${e.hue}, ${sat}%, ${light}%)`;
ctx.fill();
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
}
for (const p of projectiles) {
ctx.fillStyle = p.enemy ? '#fff' : '#ffcc00';
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
if (p.enemy) {
ctx.strokeStyle = '#c00';
ctx.lineWidth = 2;
ctx.stroke();
}
}
for (const p of worldParticles) {
const life = 1 - (p.age / p.maxAge);
ctx.fillStyle = hexToRgba(p.color, 0.2 + life * 0.8);
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius * (0.5 + life * 0.8), 0, Math.PI * 2);
ctx.fill();
}
const activePrimaryAbility = mouseRmbDown && !gamePaused ? getPrimaryAbility() : null;
const primaryItem = player.equipment[PRIMARY_HAND_SLOT];
if (primaryItem?.baseId === 'shield' && activePrimaryAbility) {
const pulse = 0.75 + Math.sin(gameTime * 12) * 0.15;
ctx.strokeStyle = `rgba(160, 220, 255, ${0.75 * pulse})`;
ctx.lineWidth = 5;
ctx.beginPath();
ctx.arc(player.x, player.y, SHIELD_REFLECT_RADIUS, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = `rgba(140, 210, 255, ${0.08 * pulse})`;
ctx.beginPath();
ctx.arc(player.x, player.y, SHIELD_REFLECT_RADIUS, 0, Math.PI * 2);
ctx.fill();
}
for (const v of abilityVisuals) {
const t = 1 - (v.age / v.maxAge);
if (v.kind === 'aoe') {
const aoeColor = v.color || '#ffd54a';
ctx.strokeStyle = hexToRgba(aoeColor, 0.9 * t);
ctx.lineWidth = 8 * Math.max(0.45, t);
ctx.beginPath();
ctx.arc(v.x, v.y, v.radius * (1 - 0.28 * (1 - t)), 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = hexToRgba(aoeColor, 0.2 * t);
ctx.beginPath();
ctx.arc(v.x, v.y, v.radius * (0.82 + 0.18 * t), 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = hexToRgba('#fff3b0', 0.65 * t);
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(v.x, v.y, v.radius * (0.56 + 0.22 * t), 0, Math.PI * 2);
ctx.stroke();
} else if (v.kind === 'flash') {
const len = 46 + 24 * t;
const side = 16 + 12 * t;
const px = -v.dirY;
const py = v.dirX;
const tipX = v.x + v.dirX * len;
const tipY = v.y + v.dirY * len;
const baseX = v.x + v.dirX * 12;
const baseY = v.y + v.dirY * 12;
ctx.fillStyle = hexToRgba(v.color || '#ffd54a', 0.9 * t);
ctx.beginPath();
ctx.moveTo(tipX, tipY);
ctx.lineTo(baseX + px * side, baseY + py * side);
ctx.lineTo(baseX - px * side, baseY - py * side);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = 'rgba(255, 250, 210, 0.9)';
ctx.lineWidth = 2;
ctx.stroke();
} else if (v.kind === 'arc') {
const angle = Math.atan2(v.dirY, v.dirX);
const arcColor = v.color || '#fff1a8';
ctx.strokeStyle = hexToRgba(arcColor, 0.95 * t);
ctx.lineWidth = 12 * Math.max(0.45, t);
ctx.beginPath();
ctx.arc(v.x, v.y, v.range, angle - v.halfArc, angle + v.halfArc);
ctx.stroke();
ctx.fillStyle = hexToRgba(arcColor, 0.14 * t);
ctx.beginPath();
ctx.moveTo(v.x, v.y);
ctx.arc(v.x, v.y, v.range, angle - v.halfArc, angle + v.halfArc);
ctx.closePath();
ctx.fill();
} else if (v.kind === 'whirlwind') {
ctx.strokeStyle = hexToRgba(v.color || '#ff9a3c', 0.58 * t);
ctx.lineWidth = 6;
for (let i = 0; i < 3; i++) {
const spin = gameTime * 9 + i * (Math.PI * 2 / 3) + v.age * 14;
ctx.beginPath();
ctx.arc(v.x, v.y, v.radius * (0.45 + i * 0.2), spin, spin + Math.PI * 0.9);
ctx.stroke();
}
}
}
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.stroke();
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();
const pr = player.stats.size ?? 16;
const barW = pr * 5;
const barH = 8;
const barY = player.y - pr - barH - 8;
ctx.fillStyle = '#333';
ctx.fillRect(player.x - barW / 2, barY, barW, barH);
ctx.fillStyle = player.hp > player.maxHp * 0.3 ? '#4a4' : '#a44';
ctx.fillRect(player.x - barW / 2, barY, barW * (player.hp / player.maxHp), barH);
ctx.strokeStyle = '#555';
ctx.strokeRect(player.x - barW / 2, barY, barW, barH);
ctx.restore();
ctx.fillStyle = '#fff';
ctx.font = '12px monospace';
ctx.textAlign = 'left';
ctx.fillText(`Time ${Math.floor(gameTime)}s [E] Inventory`, 10, 24);
const primaryAbility = getPrimaryAbility();
if (primaryItem && primaryAbility) {
const primaryInfo = getAbilityInfo(primaryItem, PRIMARY_HAND_SLOT);
const cd = Math.max(0, player.abilityCooldowns[PRIMARY_HAND_SLOT] || 0);
const status = primaryAbility.kind === 'cooldown'
? (cd > 0 ? `${cd.toFixed(1)}s` : 'ready')
: (mouseRmbDown && !gamePaused ? 'active' : 'idle');
ctx.fillText(`RMB ${primaryItem.name} (${status})`, 10, 42);
ctx.fillText(`${primaryInfo.name} (${primaryItem.name.toLowerCase()})`, 10, 58);
}
if (primaryItem && primaryAbility) {
const widgetW = 208;
const widgetH = 88;
const x = canvas.width - widgetW - 16;
const y = canvas.height - widgetH - 16;
const cd = Math.max(0, player.abilityCooldowns[PRIMARY_HAND_SLOT] || 0);
const cooldownFrac = primaryAbility.kind === 'cooldown' && primaryAbility.cooldown > 0
? Math.min(1, cd / primaryAbility.cooldown)
: 0;
const pressed = mouseRmbDown && !gamePaused;
const primaryInfo = getAbilityInfo(primaryItem, PRIMARY_HAND_SLOT);
ctx.save();
ctx.fillStyle = pressed ? '#3e4f29' : '#1d1d1d';
ctx.fillRect(x, y, widgetW, widgetH);
ctx.fillStyle = '#2a2a2a';
ctx.fillRect(x, y, widgetW, widgetH);
if (cooldownFrac > 0) {
const barH = widgetH * cooldownFrac;
ctx.fillStyle = 'rgba(15, 15, 15, 0.72)';
ctx.fillRect(x, y, widgetW, barH);
}
ctx.strokeStyle = pressed ? '#d9ff8a' : '#666';
ctx.lineWidth = 3;
ctx.strokeRect(x, y, widgetW, widgetH);
ctx.fillStyle = '#fff';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.font = '36px sans-serif';
ctx.fillText(primaryItem.emoji, x + 12, y + 12);
ctx.font = 'bold 16px monospace';
ctx.fillText('RMB', x + 64, y + 12);
ctx.font = 'bold 15px monospace';
ctx.fillText(primaryInfo.name, x + 64, y + 34);
ctx.font = '12px monospace';
ctx.fillStyle = '#d8d8d8';
ctx.fillText(primaryItem.name, x + 64, y + 54);
if (primaryAbility.kind === 'cooldown' && cd > 0) {
ctx.fillStyle = '#fff';
ctx.textAlign = 'right';
ctx.fillText(cd.toFixed(1) + 's', x + widgetW - 12, y + 12);
}
ctx.restore();
}
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;
let dragStationSource = null;
function clearDragState() {
dragInvIndex = -1;
dragEquipSlot = null;
dragStationSource = null;
}
function clearDragHighlights() {
document.querySelectorAll('.inv-slot, .equip-slot').forEach(s => s.classList.remove('drag-over'));
}
function parseStationDragData(data) {
const match = /^station:(input|output):(\d+)$/.exec(data || '');
if (!match) return null;
return { kind: match[1], index: parseInt(match[2], 10) };
}
function getStationSourceItem(source) {
if (!activeStation || !source) return null;
if (source.kind === 'input') return getStationEntryItem(activeStation.inputs?.[source.index]);
if (source.kind === 'output') return activeStation.output?.[source.index] || null;
return null;
}
function canPutItemInStationSource(source, item) {
if (!activeStation || !source || !item) return false;
if (source.kind === 'output') return true;
return !activeStation.used && activeStation.canAcceptInput(source.index, item);
}
function setStationSourceItem(source, item) {
if (!activeStation || !source) return;
if (source.kind === 'input') activeStation.inputs[source.index] = item ? makeStationEntry(item, { kind: 'station' }) : null;
else if (source.kind === 'output') activeStation.output[source.index] = item || null;
}
function moveStationItemToInventorySlot(source, index) {
const item = getStationSourceItem(source);
if (!item) return false;
const existing = inventory[index];
if (existing && !canPutItemInStationSource(source, existing)) return false;
inventory[index] = item;
setStationSourceItem(source, existing || null);
selectedInvIndex = -1;
return true;
}
function moveStationItemToEquipSlot(source, slot) {
const item = getStationSourceItem(source);
const base = ITEM_BASE_DEFS.find(b => b.id === item?.baseId);
const canEquip = base && (base.slot === slot || (HAND_SLOTS.includes(slot) && base.slot === 'leftHand'));
if (!canEquip) return false;
const existing = player.equipment[slot];
if (existing && !canPutItemInStationSource(source, existing)) return false;
player.equipment[slot] = item;
setStationSourceItem(source, existing || null);
selectedInvIndex = -1;
computePlayerStats();
return true;
}
function buildInventoryUI() {
buildStationUI();
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; dragStationSource = 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 || dragStationSource) 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();
}
}
} else {
const stationSource = parseStationDragData(data);
if (stationSource) moveStationItemToInventorySlot(stationSource, i);
}
clearDragState();
buildInventoryUI();
});
slot.addEventListener('dragend', () => { clearDragState(); clearDragHighlights(); buildInventoryUI(); });
slot.addEventListener('click', () => onInvSlotClick(i));
slot.addEventListener('contextmenu', (e) => { e.preventDefault(); onInvSlotRightClick(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 scrapResourceEl = document.getElementById('scrap-resource');
if (scrapResourceEl) scrapResourceEl.textContent = `Scrap: ${scrap}`;
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: 'Primary', rightHand: 'Secondary', boots: 'Boots' }[slot] || '';
el.onclick = () => onEquipSlotClick(slot);
el.oncontextmenu = (e) => { e.preventDefault(); onEquipSlotRightClick(slot); };
el.onmouseenter = (e) => showTooltip(e, item);
el.onmouseleave = hideTooltip;
});
buildStatsPanel();
}
const STAT_LABELS = {
damage: 'Damage', damagePercent: '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'
};
const STAT_PCT = new Set(['aoePercent', 'damagePercent']);
const STAT_LOWER_BETTER = new Set(['size']);
function buildStatsPanel(previewItem) {
const el = document.getElementById('stats-content');
if (!el) return;
el.innerHTML = '';
const order = ['damage', 'damagePercent', 'fireRate', 'numProjectiles', 'piercing', 'projectileSize', 'projectileSpeed', 'aoeRadius', 'aoePercent', 'knockback', 'hp', 'hpRegen', 'moveSpeed', 'size', 'contactDamage'];
let hypotheticalStats = null;
if (previewItem && getEquipTargetSlot(previewItem)) {
const targetSlot = getEquipTargetSlot(previewItem);
const equip = { ...player.equipment, [targetSlot]: previewItem };
hypotheticalStats = computeStatsFromEquipment(equip);
}
for (const key of order) {
const val = player.stats[key];
if (val == null) continue;
const label = STAT_LABELS[key] || key;
const suffix = STAT_PCT.has(key) ? '%' : '';
const diff = hypotheticalStats ? (hypotheticalStats[key] ?? 0) - (val ?? 0) : 0;
const invert = STAT_LOWER_BETTER.has(key);
const isGood = diff === 0 ? null : (diff > 0 && !invert) || (diff < 0 && invert);
let diffHtml = '';
if (diff !== 0) {
const sign = diff > 0 ? '+' : '';
const cls = isGood ? 'stat-diff-good' : 'stat-diff-bad';
diffHtml = `<span class="${cls}">(${sign}${fmtNum(diff)}${suffix})</span>`;
}
const row = document.createElement('div');
row.className = 'stat-row';
row.innerHTML = `<span class="stat-label">${label}</span><span class="stat-value">${fmtNum(val)}${suffix}</span><span class="stat-diff">${diffHtml}</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);
const equippedSlot = EQUIP_SLOTS.find(slot => player.equipment[slot] === item) ?? item.slot;
const abilityInfo = getAbilityInfo(item, equippedSlot);
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 (abilityInfo) {
const abilityEl = document.createElement('div');
abilityEl.className = 'tooltip-stat';
abilityEl.textContent = `A ${abilityInfo.name} ${abilityInfo.inactiveText}`.trim();
el.appendChild(abilityEl);
}
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);
tt.classList.remove('common', 'uncommon', 'rare', 'epic', 'legendary');
tt.classList.add(item.rarity || 'common');
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.remove('common', 'uncommon', 'rare', 'epic', 'legendary');
ttEq.classList.add(equippedItem.rarity || 'common');
ttEq.classList.add('show');
} else {
ttEq.innerHTML = '';
ttEq.classList.remove('show', 'common', 'uncommon', 'rare', 'epic', 'legendary');
}
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';
const isEquippable = item?.slot && getEquipTargetSlot(item);
const isAlreadyEquipped = EQUIP_SLOTS.some(s => player.equipment[s] === item);
buildStatsPanel(isEquippable && !isAlreadyEquipped ? item : null);
}
function hideTooltip() {
const wrapper = document.getElementById('tooltip-wrapper');
const ttEq = document.getElementById('tooltip-equipped');
wrapper.classList.remove('show');
ttEq.classList.remove('show');
ttEq.innerHTML = '';
buildStatsPanel();
}
function getStationEntryItem(entry) {
return entry?.item || null;
}
function makeStationEntry(item, origin) {
return { item, origin };
}
function restoreStationEntry(entry) {
if (!entry?.item) return true;
const origin = entry.origin;
if (origin?.kind === 'equipment' && !player.equipment[origin.slot]) {
player.equipment[origin.slot] = entry.item;
computePlayerStats();
return true;
}
if (origin?.kind === 'inventory' && inventory[origin.index] == null) {
inventory[origin.index] = entry.item;
return true;
}
return addItemToInventory(entry.item);
}
function clearStationSlot(index) {
if (!activeStation?.inputs?.[index]) return;
const entry = activeStation.inputs[index];
if (entry && restoreStationEntry(entry)) {
activeStation.inputs[index] = null;
activeStation.selectedInput = index;
}
buildInventoryUI();
}
function moveDraggedItemToStationInput(index, data) {
if (!activeStation?.inputs || activeStation.used || activeStation.inputs[index]) return false;
let item = null;
let origin = null;
let commit = null;
if (data.startsWith('inv:')) {
const from = parseInt(data.slice(4), 10);
item = inventory[from];
origin = { kind: 'inventory', index: from };
commit = () => { inventory[from] = null; };
} else if (data.startsWith('equip:')) {
const eqSlot = data.slice(6);
item = player.equipment[eqSlot];
origin = { kind: 'equipment', slot: eqSlot };
commit = () => {
player.equipment[eqSlot] = null;
computePlayerStats();
};
}
if (!item || !commit || !activeStation.canAcceptInput(index, item)) return false;
activeStation.inputs[index] = makeStationEntry(item, origin);
activeStation.selectedInput = activeStation.inputs.findIndex((entry, idx) => !entry && activeStation.canAcceptInput(idx, item));
if (activeStation.selectedInput < 0) activeStation.selectedInput = null;
commit();
selectedInvIndex = -1;
return true;
}
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 onInvSlotRightClick(i) {
const item = inventory[i];
if (!item || !item.slot) return;
const base = ITEM_BASE_DEFS.find(b => b.id === item.baseId);
if (!base || !base.slot) return;
let targetSlot = base.slot;
if (HAND_SLOTS.includes(base.slot)) {
targetSlot = !player.equipment[PRIMARY_HAND_SLOT] ? PRIMARY_HAND_SLOT : !player.equipment[SECONDARY_HAND_SLOT] ? SECONDARY_HAND_SLOT : PRIMARY_HAND_SLOT;
} else if (!EQUIP_SLOTS.includes(targetSlot)) return;
const equipped = player.equipment[targetSlot];
player.equipment[targetSlot] = item;
inventory[i] = equipped;
selectedInvIndex = -1;
computePlayerStats();
buildInventoryUI();
}
function onEquipSlotRightClick(slot) {
const item = player.equipment[slot];
if (!item) return;
const free = inventory.findIndex(x => !x);
if (free < 0) return;
inventory[free] = item;
player.equipment[slot] = null;
computePlayerStats();
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();
}
// ========== 11b. TRADER UI ==========
function countInventoryScrap() {
return scrap;
}
function addScrapToInventory(amount) {
if (amount <= 0) return false;
scrap += amount;
return true;
}
function consumeScrap(amount) {
if (scrap < amount) return false;
scrap -= amount;
return true;
}
function addItemToInventory(item) {
const free = inventory.findIndex(x => !x);
if (free >= 0) { inventory[free] = item; return true; }
return false;
}
function makeSlotEl(item, opts) {
const slot = document.createElement('div');
slot.className = 'inv-slot' + (item ? ' ' + (item.rarity || 'common') : '');
slot.textContent = item ? (item.emoji + (item.count > 1 ? 'ร' + item.count : '')) : '';
if (item) {
slot.addEventListener('click', () => opts.onClick && opts.onClick());
slot.addEventListener('contextmenu', (e) => {
e.preventDefault();
if (opts.onRightClick) opts.onRightClick();
else if (opts.onClick) opts.onClick();
});
slot.addEventListener('mouseenter', (e) => showTooltip(e, item));
slot.addEventListener('mouseleave', hideTooltip);
}
return slot;
}
function stationDropOrInventory(item, x, y) {
if (!item) return;
if (!addItemToInventory(item)) {
const angle = Math.random() * Math.PI * 2;
const r = 20 + Math.random() * 20;
itemDrops.push({ x: x + Math.cos(angle) * r, y: y + Math.sin(angle) * r, item, pickupRadius: 24 });
}
}
function createOutputArray(size = 1) {
return Array(size).fill(null);
}
function stationHasFreeOutput(station) {
return station.output.some(x => !x);
}
function stationPushOutput(station, item) {
const idx = station.output.findIndex(x => !x);
if (idx < 0) return false;
station.output[idx] = item;
return true;
}
function getStationInputItem(index) {
return getStationEntryItem(activeStation?.inputs?.[index]);
}
function closeActiveStation() {
const dropX = activeStation?.objective?.x ?? player.x;
const dropY = activeStation?.objective?.y ?? player.y;
if (activeStation?.inputs) {
for (let i = 0; i < activeStation.inputs.length; i++) {
const entry = activeStation.inputs[i];
if (!entry?.item) continue;
if (!restoreStationEntry(entry)) stationDropOrInventory(entry.item, dropX, dropY);
activeStation.inputs[i] = null;
}
}
if (activeStation?.output) {
for (let i = 0; i < activeStation.output.length; i++) {
const it = activeStation.output[i];
if (!it) continue;
stationDropOrInventory(it, dropX, dropY);
activeStation.output[i] = null;
}
}
traderOpen = false;
activeTraderObjective = null;
activeStation = null;
gamePaused = false;
selectedInvIndex = -1;
document.getElementById('inventory-overlay').classList.remove('open');
buildInventoryUI();
}
function openStation(station) {
activeStation = station;
gamePaused = true;
selectedInvIndex = -1;
document.getElementById('inventory-overlay').classList.add('open');
buildInventoryUI();
}
function buildStationUI() {
const panel = document.getElementById('station-panel');
const titleEl = document.getElementById('station-title');
const descEl = document.getElementById('station-description');
const inputsEl = document.getElementById('station-inputs');
const outputEl = document.getElementById('station-output-grid');
const statusEl = document.getElementById('station-status');
const actionBtn = document.getElementById('station-action-btn');
const closeBtn = document.getElementById('station-close-btn');
if (!panel || !titleEl || !descEl || !inputsEl || !outputEl || !statusEl || !actionBtn || !closeBtn) return;
if (!activeStation) {
panel.classList.remove('open');
descEl.textContent = '';
inputsEl.innerHTML = '';
outputEl.innerHTML = '';
statusEl.textContent = '';
actionBtn.textContent = '';
actionBtn.onclick = null;
closeBtn.onclick = null;
return;
}
panel.classList.add('open');
panel.style.borderColor = activeStation.accent;
titleEl.style.color = activeStation.accent;
titleEl.textContent = activeStation.title;
descEl.textContent = activeStation.description || '';
inputsEl.innerHTML = '';
for (let i = 0; i < (activeStation.inputLabels?.length || 0); i++) {
const wrap = document.createElement('div');
wrap.className = 'station-input-wrap';
const label = document.createElement('div');
label.className = 'station-input-label';
label.textContent = activeStation.inputLabels[i];
const entry = activeStation.inputs[i];
const item = getStationEntryItem(entry);
const slot = makeSlotEl(item, {
onClick: () => clearStationSlot(i)
});
slot.classList.add('station-input-slot');
if (item) {
slot.draggable = true;
slot.addEventListener('dragstart', (e) => {
dragInvIndex = -1;
dragEquipSlot = null;
dragStationSource = { kind: 'input', index: i };
e.dataTransfer.setData('text/plain', `station:input:${i}`);
e.dataTransfer.effectAllowed = 'move';
});
slot.addEventListener('dragend', () => { clearDragState(); clearDragHighlights(); buildInventoryUI(); });
}
if (activeStation.selectedInput === i) slot.classList.add('selected');
if (!item) {
slot.textContent = '+';
slot.style.color = '#777';
slot.addEventListener('click', () => {
activeStation.selectedInput = activeStation.selectedInput === i ? null : i;
buildInventoryUI();
});
slot.addEventListener('contextmenu', (e) => e.preventDefault());
}
slot.addEventListener('dragover', (e) => {
if (activeStation.used || activeStation.inputs[i]) return;
const dragged = dragInvIndex >= 0 ? inventory[dragInvIndex] : dragEquipSlot ? player.equipment[dragEquipSlot] : null;
if (!dragged || !activeStation.canAcceptInput(i, dragged)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
slot.classList.add('drag-over');
});
slot.addEventListener('dragleave', () => slot.classList.remove('drag-over'));
slot.addEventListener('drop', (e) => {
e.preventDefault();
slot.classList.remove('drag-over');
if (moveDraggedItemToStationInput(i, e.dataTransfer.getData('text/plain'))) {
clearDragState();
buildInventoryUI();
}
});
wrap.appendChild(label);
wrap.appendChild(slot);
inputsEl.appendChild(wrap);
}
outputEl.innerHTML = '';
for (let i = 0; i < activeStation.output.length; i++) {
const item = activeStation.output[i];
const slot = makeSlotEl(item, {
onClick: () => activeStation.onTakeOutput?.(i)
});
if (item) {
slot.draggable = true;
slot.addEventListener('dragstart', (e) => {
dragInvIndex = -1;
dragEquipSlot = null;
dragStationSource = { kind: 'output', index: i };
e.dataTransfer.setData('text/plain', `station:output:${i}`);
e.dataTransfer.effectAllowed = 'move';
});
slot.addEventListener('dragend', () => { clearDragState(); clearDragHighlights(); buildInventoryUI(); });
}
outputEl.appendChild(slot);
}
statusEl.textContent = activeStation.getStatusText();
actionBtn.textContent = activeStation.getActionLabel();
if (activeStation.used) statusEl.textContent = 'This station has been used.';
actionBtn.disabled = activeStation.used || !activeStation.canAction();
actionBtn.style.background = activeStation.buttonBg;
actionBtn.style.border = `2px solid ${activeStation.accent}`;
actionBtn.style.color = '#fff';
actionBtn.onclick = () => {
if (activeStation.used || !activeStation.canAction()) return;
const didUse = activeStation.onAction();
if (didUse && !activeStation.reusable) activeStation.used = true;
buildInventoryUI();
};
closeBtn.onclick = () => closeActiveStation();
}
function makeBaseStation(kind, objective, cfg) {
return {
kind,
objective,
title: cfg.title,
description: cfg.description,
accent: cfg.accent,
buttonBg: cfg.buttonBg,
inputLabels: cfg.inputLabels || [],
inputs: Array((cfg.inputLabels || []).length).fill(null),
selectedInput: (cfg.inputLabels || []).length ? 0 : null,
output: cfg.output || createOutputArray(cfg.outputSize || 1),
reusable: !!cfg.reusable,
used: false,
getStatusText: cfg.getStatusText,
getActionLabel: cfg.getActionLabel,
canAction: cfg.canAction,
canAcceptInput: cfg.canAcceptInput || (() => false),
onAction: cfg.onAction,
onTakeOutput: cfg.onTakeOutput || ((i) => {
const it = cfg.outputRef ? cfg.outputRef()[i] : activeStation.output[i];
if (!it) return;
if (addItemToInventory(it)) {
if (cfg.outputRef) cfg.outputRef()[i] = null;
else activeStation.output[i] = null;
buildInventoryUI();
}
})
};
}
function makeTraderStation(objective) {
traderOpen = true;
activeTraderObjective = objective;
return makeBaseStation('trader', objective, {
title: 'Scrap Trader',
description: 'Trade scrap for a freshly rolled item. Inventory and scrapping stay fully usable below.',
accent: '#c08040',
buttonBg: '#5a4030',
output: traderOutput,
outputRef: () => traderOutput,
outputSize: TRADER_OUTPUT_SLOTS,
reusable: true,
getStatusText: () => `Scrap: ${countInventoryScrap()}`,
getActionLabel: () => `Trade ${TRADE_SCRAP_COST} Scrap`,
canAction: () => countInventoryScrap() >= TRADE_SCRAP_COST && traderOutput.findIndex(x => !x) >= 0,
onAction: () => traderTrade()
});
}
function makeModsTransferStation(objective) {
return makeBaseStation('mods_transfer', objective, {
title: 'Mods Transfer',
description: 'Pick a target item and a sacrifice item. The target keeps its base item, but rarity and modifiers are overwritten by the sacrifice. The sacrifice is destroyed.',
accent: '#8b5cf6',
buttonBg: '#55308f',
inputLabels: ['Target', 'Sacrifice'],
getStatusText: () => 'Drag items into the slots.',
getActionLabel: () => 'Transfer Mods',
canAcceptInput: (idx, item) => item.baseId !== 'scrap',
canAction: () => getStationInputItem(0) && getStationInputItem(1) && stationHasFreeOutput(activeStation),
onAction: () => {
const target = getStationInputItem(0);
const sacrifice = getStationInputItem(1);
if (!target || !sacrifice || !stationHasFreeOutput(activeStation)) return false;
const out = cloneItem(target);
out.rarity = sacrifice.rarity;
out.modifiers = (sacrifice.modifiers || []).map(mod => ({ ...mod }));
if (stationPushOutput(activeStation, out)) {
activeStation.inputs[0] = null;
activeStation.inputs[1] = null;
return true;
}
return false;
}
});
}
function makeMergeStation(objective) {
return makeBaseStation('merge', objective, {
title: 'Merge',
description: 'Pick a target and a sacrifice. The target keeps its base item, gains the highest rarity of the two, and rolls a new modifier set from both items combined. Matching modifiers add together before selection.',
accent: '#f59e0b',
buttonBg: '#7a4a12',
inputLabels: ['Target', 'Sacrifice'],
getStatusText: () => 'Drag two items into the slots.',
getActionLabel: () => 'Merge Items',
canAcceptInput: (idx, item) => item.baseId !== 'scrap',
canAction: () => getStationInputItem(0) && getStationInputItem(1) && stationHasFreeOutput(activeStation),
onAction: () => {
const target = getStationInputItem(0);
const sacrifice = getStationInputItem(1);
if (!target || !sacrifice || !stationHasFreeOutput(activeStation)) return false;
if (stationPushOutput(activeStation, mergeItemsLike(target, sacrifice))) {
activeStation.inputs[0] = null;
activeStation.inputs[1] = null;
return true;
}
return false;
}
});
}
function makeReforgeStation(objective) {
return makeBaseStation('reforge', objective, {
title: 'Reforge',
description: 'Reroll modifiers while keeping the base item and rarity.',
accent: '#06b6d4',
buttonBg: '#0d5b69',
inputLabels: ['Item'],
getStatusText: () => 'Drag an item into the slot.',
getActionLabel: () => 'Reforge Item',
canAcceptInput: (idx, item) => item.baseId !== 'scrap',
canAction: () => getStationInputItem(0) && stationHasFreeOutput(activeStation),
onAction: () => {
const target = getStationInputItem(0);
if (!target || !stationHasFreeOutput(activeStation)) return false;
if (stationPushOutput(activeStation, rerollItemLike(target))) {
activeStation.inputs[0] = null;
return true;
}
return false;
}
});
}
function makeUpgradeStation(objective) {
return makeBaseStation('upgrade', objective, {
title: 'Upgrade',
description: 'Upgrade an item by one rarity tier. Existing modifiers stay, and new ones are rolled only if the next rarity needs more.',
accent: '#ec4899',
buttonBg: '#7b1e4f',
inputLabels: ['Item'],
getStatusText: () => {
const item = getStationInputItem(0);
if (!item) return 'Drag an item into the slot.';
const next = getNextRarity(item.rarity);
return next ? `${item.rarity} -> ${next}` : 'Legendary items cannot be upgraded further.';
},
getActionLabel: () => 'Upgrade Item',
canAcceptInput: (idx, item) => item.baseId !== 'scrap',
canAction: () => {
const item = getStationInputItem(0);
if (!item || !stationHasFreeOutput(activeStation)) return false;
return !!getNextRarity(item.rarity);
},
onAction: () => {
const item = getStationInputItem(0);
if (!item || !stationHasFreeOutput(activeStation)) return false;
const upgraded = upgradeItemLike(item);
if (upgraded && stationPushOutput(activeStation, upgraded)) {
activeStation.inputs[0] = null;
return true;
}
return false;
}
});
}
function generateTraderItem() {
const goodness = traderTimeToGoodness(gameTime);
const n = normalizeGoodness(goodness);
const base = pickBaseFromTieredPool(n);
if (!base) return null;
return generateItem(base.id, n, goodness);
}
function traderTrade() {
const free = traderOutput.findIndex(x => !x);
if (free < 0) return false;
if (!consumeScrap(TRADE_SCRAP_COST)) return false;
const item = generateTraderItem();
if (item) traderOutput[free] = item;
buildInventoryUI();
return !!item;
}
function openTrader(objective) {
openStation(makeTraderStation(objective));
}
// ========== 12. INPUT ==========
function updateInput(dt) {
let speed = (player.stats.moveSpeed ?? 150) * (gamePaused ? 0 : 1);
let moveX = 0, moveY = 0;
let faceX = player.dirX, faceY = player.dirY;
let moving = false;
let forceFacing = false;
if (mouseLmbDown) {
const tox = mouseWorldX - player.x;
const toy = mouseWorldY - player.y;
const d = Math.hypot(tox, toy);
if (d > 0) {
moveX = tox / d;
moveY = toy / d;
faceX = moveX; faceY = moveY;
moving = true;
}
} else {
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) {
const [nx, ny] = normalize(dx, dy);
moveX = nx; moveY = ny;
faceX = nx; faceY = ny;
moving = true;
}
}
if (mouseRmbDown && !gamePaused) {
let speedMult = 1;
let backward = false;
let cursorOverride = false;
const ab = getPrimaryAbility();
const cursorDx = mouseWorldX - player.x;
const cursorDy = mouseWorldY - player.y;
const cursorDist = Math.hypot(cursorDx, cursorDy);
if (cursorDist > 0) {
const aimMult = ab?.shootAwayFromCursor ? -1 : 1;
faceX = (cursorDx / cursorDist) * aimMult;
faceY = (cursorDy / cursorDist) * aimMult;
forceFacing = true;
}
if (ab?.kind === 'continuous' && ab.moveTowardCursor) cursorOverride = true;
if (ab?.kind === 'toggle') {
if (ab.speedMult != null) speedMult = Math.min(speedMult, ab.speedMult);
if (ab.moveBackward) backward = true;
if (ab.moveTowardCursor && cursorDist > 0) {
moveX = cursorDx / cursorDist;
moveY = cursorDy / cursorDist;
moving = true;
}
if (cursorDist > 0 && backward) {
moveX = -faceX;
moveY = -faceY;
moving = true;
}
}
if (cursorOverride) {
if (cursorDist > 0) {
moveX = cursorDx / cursorDist;
moveY = cursorDy / cursorDist;
faceX = moveX; faceY = moveY;
moving = true;
}
}
if (backward && moving && !ab?.moveBackward) { moveX = -moveX; moveY = -moveY; }
speed *= speedMult;
}
if (moving) {
player.vx = moveX * speed;
player.vy = moveY * speed;
} else {
player.vx = 0;
player.vy = 0;
}
if (moving || forceFacing) {
player.dirX = faceX;
player.dirY = faceY;
}
}
// ========== 13. GAME LOOP ==========
function update(dt) {
if (gameOver) return;
for (let i = worldParticles.length - 1; i >= 0; i--) {
const p = worldParticles[i];
p.age += dt;
if (p.age >= p.maxAge) {
worldParticles.splice(i, 1);
continue;
}
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vx *= Math.pow(0.12, dt);
p.vy = p.vy * Math.pow(0.16, dt) - 180 * dt;
}
screenShake = Math.max(0, screenShake - 38 * dt);
if (gamePaused) {
render();
requestAnimationFrame(gameLoop);
return;
}
gameTime += dt;
updateInput(dt);
tickAbilities(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);
updateSummoners(dt);
updateRangedEnemies(dt);
updateMapObjectives(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 === 'Escape' && gamePaused) {
e.preventDefault();
if (activeStation) {
closeActiveStation();
} else {
gamePaused = false;
document.getElementById('inventory-overlay').classList.remove('open');
}
}
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 goodness = rand(1, 6);
const n = normalizeGoodness(goodness);
inventory[i] = generateItem(base.id, n, goodness);
}
if (gamePaused) buildInventoryUI();
}
});
window.addEventListener('blur', () => { for (const k of Object.keys(keys)) keys[k] = false; mouseLmbDown = false; mouseRmbDown = false; });
document.addEventListener('keyup', e => {
keys[e.code] = false;
if (e.code === 'KeyE') {
e.preventDefault();
if (activeStation) {
closeActiveStation();
return;
}
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;
if (e.button === 2) mouseRmbDown = true;
});
document.addEventListener('contextmenu', e => e.preventDefault());
window.addEventListener('mouseup', e => {
if (e.button === 0) mouseLmbDown = false;
if (e.button === 2) mouseRmbDown = false;
});
document.getElementById('restart-btn').addEventListener('click', () => {
gameOver = false;
gameTime = 0;
pityCounters = {};
dropsReceived = 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.impulseVx = 0; player.impulseVy = 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;
player.abilityCooldowns = { leftHand: 0, rightHand: 0 };
abilityVisuals.length = 0;
worldParticles.length = 0;
screenShake = 0;
mouseRmbDown = false;
computePlayerStats();
enemies.length = 0;
projectiles.length = 0;
itemDrops.length = 0;
mapObjectives.length = 0;
for (let i = 0; i < traderOutput.length; i++) traderOutput[i] = null;
traderOpen = false;
activeTraderObjective = null;
activeStation = null;
inventory = Array(INV_SIZE).fill(null);
scrap = 0;
fireCooldown = 0.5;
spawnAccum = 0;
spawnInitialObjectives();
lastTime = performance.now();
requestAnimationFrame(gameLoop);
});
const scrapSlotEl = document.getElementById('scrap-slot');
scrapSlotEl.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const stationItem = getStationSourceItem(dragStationSource);
const canScrap = (dragInvIndex >= 0 && inventory[dragInvIndex]?.baseId !== 'scrap') || (dragEquipSlot && player.equipment[dragEquipSlot]) || (stationItem && stationItem.baseId !== 'scrap');
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();
}
} else {
const stationSource = parseStationDragData(data);
itemToScrap = getStationSourceItem(stationSource);
if (itemToScrap?.baseId === 'scrap') itemToScrap = null;
if (itemToScrap) setStationSourceItem(stationSource, null);
}
if (itemToScrap) {
addScrapToInventory(getScrapValue(itemToScrap));
clearDragState();
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 += getScrapValue(it);
}
}
const totalScrap = scrapCount + itemsToScrap;
inventory = Array(INV_SIZE).fill(null);
addScrapToInventory(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;
dragStationSource = null;
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');
else if (dragStationSource) {
const stationItem = getStationSourceItem(dragStationSource);
const base = ITEM_BASE_DEFS.find(b => b.id === stationItem?.baseId);
const canEquip = base && (base.slot === slot || (HAND_SLOTS.includes(slot) && base.slot === 'leftHand'));
if (canEquip) 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();
}
}
} else {
const stationSource = parseStationDragData(data);
if (stationSource) moveStationItemToEquipSlot(stationSource, slot);
}
clearDragState();
buildInventoryUI();
});
el.addEventListener('dragend', () => { clearDragState(); clearDragHighlights(); buildInventoryUI(); });
});
computePlayerStats();
spawnInitialObjectives();
lastTime = performance.now();
requestAnimationFrame(gameLoop);
}
init();
</script>
</body>
</html>
remixes
no remixes yet... email to remix@gameslop.net
