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>Prismo</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background: #111; }
#game {
display: block;
width: 100vw;
height: calc(100vh - 32px);
margin-top: 32px;
cursor: crosshair;
}
#xp-bar-wrap {
position: fixed;
top: 0; left: 0; right: 0;
width: 100%;
height: 32px;
z-index: 60;
border: none;
outline: none;
}
#xp-bar-track {
position: relative;
width: 100%;
height: 100%;
background: #111;
overflow: hidden;
border: none;
outline: none;
}
#xp-bar-fill {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 0%;
background: #6a8a9e;
}
#xp-bar-meta {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: ui-monospace, monospace;
font-size: 12px;
color: #e8e8e8;
white-space: nowrap;
pointer-events: none;
z-index: 1;
}
#stance-key-hud {
position: fixed;
top: 36px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
z-index: 55;
pointer-events: none;
font-family: ui-monospace, monospace;
}
.key-cap {
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.02em;
transition: transform 0.07s ease, box-shadow 0.12s ease, filter 0.12s ease;
}
/* Red — Q (matches RGB.r) */
.key-cap-q {
background: linear-gradient(180deg, #f05555 0%, #e54545 40%, #a01818 100%);
border: 2px solid #ff8888;
color: #fff8f8;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
box-shadow:
0 3px 0 #501010,
inset 0 1px 0 rgba(255, 200, 200, 0.45);
}
.key-cap-q.active {
box-shadow:
0 3px 0 #501010,
inset 0 1px 0 rgba(255, 220, 220, 0.55),
0 0 0 2px rgba(255, 255, 255, 0.85),
0 0 18px rgba(255, 120, 120, 0.95);
filter: brightness(1.12);
}
.key-cap-q.press-flash {
transform: translateY(1px);
filter: brightness(1.38) saturate(1.15);
box-shadow:
0 1px 0 #401010,
inset 0 1px 0 rgba(255, 240, 240, 0.7),
0 0 0 2px #ffb8b8,
0 0 24px rgba(255, 90, 90, 1);
}
/* Green — W (matches RGB.g) */
.key-cap-w {
background: linear-gradient(180deg, #58f080 0%, #45e565 40%, #108828 100%);
border: 2px solid #88ffaa;
color: #f0fff5;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
box-shadow:
0 3px 0 #105018,
inset 0 1px 0 rgba(200, 255, 220, 0.4);
}
.key-cap-w.active {
box-shadow:
0 3px 0 #105018,
inset 0 1px 0 rgba(220, 255, 235, 0.55),
0 0 0 2px rgba(255, 255, 255, 0.85),
0 0 18px rgba(100, 255, 160, 0.95);
filter: brightness(1.12);
}
.key-cap-w.press-flash {
transform: translateY(1px);
filter: brightness(1.38) saturate(1.15);
box-shadow:
0 1px 0 #0c4018,
inset 0 1px 0 rgba(230, 255, 240, 0.7),
0 0 0 2px #b8ffd0,
0 0 24px rgba(80, 255, 140, 1);
}
/* Blue — E (matches RGB.b) */
.key-cap-e {
background: linear-gradient(180deg, #7090ff 0%, #4565ff 40%, #2030b0 100%);
border: 2px solid #88aaff;
color: #f4f7ff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
box-shadow:
0 3px 0 #101850,
inset 0 1px 0 rgba(200, 215, 255, 0.45);
}
.key-cap-e.active {
box-shadow:
0 3px 0 #101850,
inset 0 1px 0 rgba(220, 230, 255, 0.55),
0 0 0 2px rgba(255, 255, 255, 0.85),
0 0 18px rgba(120, 160, 255, 0.98);
filter: brightness(1.12);
}
.key-cap-e.press-flash {
transform: translateY(1px);
filter: brightness(1.38) saturate(1.15);
box-shadow:
0 1px 0 #0c1438,
inset 0 1px 0 rgba(235, 240, 255, 0.7),
0 0 0 2px #b8c8ff,
0 0 24px rgba(100, 150, 255, 1);
}
#hud {
position: fixed;
top: 42px; left: 10px;
color: #fff;
font-family: monospace;
font-size: 14px;
z-index: 50;
line-height: 1.5;
text-shadow: 0 1px 2px #000;
}
#game-over {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
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;
color: #fff;
}
#restart-btn:hover { background: #5b5; }
#levelup-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.75);
z-index: 200;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 24px;
}
#levelup-overlay.open { display: flex; }
#levelup-overlay h2 {
color: #e8c84a;
font-family: ui-monospace, monospace;
font-size: clamp(22px, 4vw, 32px);
margin-bottom: 24px;
letter-spacing: 0.02em;
}
#levelup-options {
display: flex;
flex-wrap: wrap;
gap: 20px;
justify-content: center;
align-items: stretch;
max-width: min(1100px, 100%);
}
.levelup-card {
width: clamp(200px, 26vw, 260px);
aspect-ratio: 3 / 4;
padding: 22px 18px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: stretch;
border: 3px solid;
border-radius: 0;
font-family: ui-monospace, monospace;
font-size: 15px;
line-height: 1.5;
cursor: pointer;
text-align: center;
transition: border-color 0.1s, background-color 0.1s, color 0.1s;
}
/* Neutral — white / gray */
.levelup-card.theme-n {
background: #1e1e22;
border-color: #c8c8d0;
color: #ececf0;
}
.levelup-card.theme-n:hover { background: #28282e; border-color: #e8e8ee; }
.levelup-card.theme-n .up-title { color: #f4f4f8; }
.levelup-card.theme-n .up-desc { color: #b0b0b8; }
.levelup-card.theme-n:focus-visible { outline: 2px solid #e8e8ee; outline-offset: 3px; }
/* Red */
.levelup-card.theme-r {
background: #241818;
border-color: #c04040;
color: #f0e4e4;
}
.levelup-card.theme-r:hover { background: #2e2020; border-color: #e85858; }
.levelup-card.theme-r .up-title { color: #ff9090; }
.levelup-card.theme-r .up-desc { color: #c8a8a8; }
.levelup-card.theme-r:focus-visible { outline: 2px solid #e85858; outline-offset: 3px; }
/* Green */
.levelup-card.theme-g {
background: #142418;
border-color: #38a858;
color: #e8f0ea;
}
.levelup-card.theme-g:hover { background: #1a3020; border-color: #50d070; }
.levelup-card.theme-g .up-title { color: #78ffa0; }
.levelup-card.theme-g .up-desc { color: #a0c8a8; }
.levelup-card.theme-g:focus-visible { outline: 2px solid #50d070; outline-offset: 3px; }
/* Blue */
.levelup-card.theme-b {
background: #141824;
border-color: #4068d8;
color: #e8ecf8;
}
.levelup-card.theme-b:hover { background: #1a2030; border-color: #5888ff; }
.levelup-card.theme-b .up-title { color: #90b8ff; }
.levelup-card.theme-b .up-desc { color: #a8b8d8; }
.levelup-card.theme-b:focus-visible { outline: 2px solid #5888ff; outline-offset: 3px; }
</style>
</head>
<body>
<div id="xp-bar-wrap">
<div id="xp-bar-track">
<div id="xp-bar-fill"></div>
<span id="xp-bar-meta">Lv 1</span>
</div>
</div>
<div id="stance-key-hud" aria-hidden="true">
<div class="key-cap key-cap-q" id="key-cap-q" data-code="KeyQ">Q</div>
<div class="key-cap key-cap-w" id="key-cap-w" data-code="KeyW">W</div>
<div class="key-cap key-cap-e" id="key-cap-e" data-code="KeyE">E</div>
</div>
<canvas id="game" tabindex="0"></canvas>
<div id="hud"></div>
<div id="levelup-overlay">
<h2 id="levelup-title">Level up!</h2>
<div id="levelup-options"></div>
</div>
<div id="game-over">
<span>Game Over</span>
<p id="survived-time"></p>
<button id="restart-btn" type="button">Restart</button>
</div>
<script>
const MELEE_IMPULSE = 120;
const PBD_ITERATIONS = 4;
const SPAWN_BUFFER = 100;
const SPAWN_INTERVAL = 1 / 10;
const SPAWN_STEP_SECONDS = 30;
const DESPAWN_MARGIN = 300;
const CELL_SIZE = 30;
const MANA_MAX = 100;
const MANA_REGEN = 3.2;
const STANCE_COST = 14;
const XP_BASE = 50;
const XP_PER_LEVEL = 18;
function xpToNext(level) {
return XP_BASE + (level - 1)*(level - 1) * XP_PER_LEVEL;
}
const GEM_MAGNET = 220;
const GEM_PICKUP_BASE = 28;
const GEM_PULL_SPEED = 380;
/** Verdant surge — keep in sync with `effectiveMoveSpeed` / `tryStance` green branch. */
const GREEN_SURGE_SPEED_BONUS_PER_STACK = 0.55;
const GREEN_SURGE_SECONDS_PER_STACK = 2;
const UPGRADE_DEFS = [
{
id: 'moveSpeed',
affinity: null,
title: 'Swift',
desc: '+5% move speed (per stack).',
apply() { player.upgrades.moveSpeed++; }
},
{
id: 'greenHeal',
affinity: 'g',
title: 'Verdant shift',
desc: '+5 HP healed when you switch to green stance (per stack).',
apply() { player.upgrades.greenHeal++; }
},
{
id: 'redNova',
affinity: 'r',
title: 'Crimson burst',
desc: '+10 damage per stack in an area around you when you switch to red (radius grows with stacks).',
apply() { player.upgrades.redNova++; }
},
{
id: 'blueMana',
affinity: 'b',
title: 'Azure flow',
desc: '+20% mana regen while in blue stance (per stack).',
apply() { player.upgrades.blueMana++; }
},
{
id: 'maxHp',
affinity: null,
title: 'Vitality',
desc: '+10 max HP (per stack).',
apply() {
player.upgrades.maxHp++;
player.maxHp += 10;
player.hp += 10;
}
},
{
id: 'redKillHp',
affinity: 'r',
title: 'Sanguine toll',
desc: '+2 HP when you kill an enemy while in red stance (per stack).',
apply() { player.upgrades.redKillHp++; }
},
{
id: 'blueKillMana',
affinity: 'b',
title: 'Soul siphon',
desc: '+5 mana when you kill an enemy while in blue stance (per stack).',
apply() { player.upgrades.blueKillMana++; }
},
{
id: 'greenSurge',
affinity: 'g',
title: 'Verdant surge',
desc: `On switching to green: +${Math.round(GREEN_SURGE_SPEED_BONUS_PER_STACK * 100)}% move speed for ${GREEN_SURGE_SECONDS_PER_STACK}s per stack (while active). Ends if you leave green.`,
apply() { player.upgrades.greenSurge++; }
}
];
const DEFAULT_STATS = {
hp: 100, moveSpeed: 180, size: 16, contactDamage: 0
};
const RGB = {
r: { key: 'r', label: 'R', fill: '#e54545', stroke: '#ff8888' },
g: { key: 'g', label: 'G', fill: '#45e565', stroke: '#88ffaa' },
b: { key: 'b', label: 'B', fill: '#4565ff', stroke: '#88aaff' }
};
function randomRgb() {
const k = Math.floor(Math.random() * 3);
return k === 0 ? 'r' : k === 1 ? 'g' : 'b';
}
const ENEMY_DEFS = [
{ id: 'grub', name: 'Grub', points: 1, baseStats: { hp: 10, moveSpeed: 55, size: 24, contactDamage: 5 }, darkness: 0.6, weight: 1 },
{ id: 'slime', name: 'Slime', points: 2, baseStats: { hp: 26, moveSpeed: 30, size: 26, contactDamage: 10 }, darkness: 0.6, weight: 1 },
{ id: 'imp', name: 'Imp', points: 3, baseStats: { hp: 8, moveSpeed: 115, size: 15, contactDamage: 5 }, darkness: 0.5, weight: 1 },
{ id: 'wraith', name: 'Wraith', points: 6, baseStats: { hp: 55, moveSpeed: 100, size: 28, contactDamage: 25 }, darkness: 0.4, weight: 1 },
{ id: 'troll', name: 'Troll', points: 7, baseStats: { hp: 140, moveSpeed: 50, size: 38, contactDamage: 30 }, darkness: 0.8, weight: 1 },
{ id: 'specter', name: 'Specter', points: 8, baseStats: { hp: 92, moveSpeed: 100, size: 26, contactDamage: 30 }, darkness: 0.45, weight: 1 },
{ id: 'beast', name: 'Beast', points: 9, baseStats: { hp: 100, moveSpeed: 95, size: 30, contactDamage: 35 }, darkness: 0.75, weight: 1 },
{ id: 'demon', name: 'Demon', points: 10, baseStats: { hp: 255, moveSpeed: 75, size: 32, contactDamage: 35 }, darkness: 0.8, weight: 1 },
{ id: 'knight', name: 'Dark Knight', points: 17, baseStats: { hp: 420, moveSpeed: 70, size: 36, contactDamage: 60 }, darkness: 0.9, weight: 1 },
{ id: 'imp_summoner', name: 'Imp Summoner', points: 7, baseStats: { hp: 28, moveSpeed: 60, size: 24, contactDamage: 8 }, darkness: 0.6, weight: 1, summoner: { summonDefId: 'imp', count: 3, interval: 5 } },
{ id: 'imp_summoner_summoner', name: 'Imp Summoner Summoner', points: 13, baseStats: { hp: 80, moveSpeed: 35, size: 22, contactDamage: 15 }, darkness: 0.7, weight: 1, summoner: { summonDefId: 'imp_summoner', count: 3, interval: 15 } },
{ id: 'demon_summoner', name: 'Demon Summoner', points: 16, baseStats: { hp: 200, moveSpeed: 50, size: 28, contactDamage: 25 }, darkness: 0.88, weight: 1, summoner: { summonDefId: 'demon', count: 3, interval: 6 } },
{ id: 'demon_summoner_summoner', name: 'Demon Summoner Summoner', points: 20, baseStats: { hp: 450, moveSpeed: 30, size: 20, contactDamage: 35 }, darkness: 0.92, weight: 1, summoner: { summonDefId: 'demon_summoner', count: 3, interval: 18 } },
{ id: 'shooter', name: 'Shooter', points: 5, ranged: true, baseStats: { hp: 10, moveSpeed: 50, size: 14, contactDamage: 0 }, darkness: 0.2, weight: 1, rangedStats: { projSpeed: 175, projDamage: 8, fireRate: 0.277, numProjectiles: 1, projSize: 10 } },
{ id: 'scatter', name: 'Scatter', points: 10, ranged: true, baseStats: { hp: 32, moveSpeed: 55, size: 16, contactDamage: 0 }, 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: 13, ranged: true, baseStats: { hp: 60, moveSpeed: 70, size: 14, contactDamage: 0 }, darkness: 0.6, weight: 1, rangedStats: { projSpeed: 600, projDamage: 15, fireRate: 0.3, numProjectiles: 1, projSize: 10 } },
{ id: 'warlock', name: 'Warlock', points: 15, ranged: true, baseStats: { hp: 85, moveSpeed: 60, size: 24, contactDamage: 0 }, darkness: 0.8, weight: 1, rangedStats: { projSpeed: 300, projDamage: 8, fireRate: 0.2, numProjectiles: 5, projSize: 15, spread: 0.35 } }
];
const MAX_ENEMY_RADIUS = ENEMY_DEFS.reduce((m, d) => Math.max(m, d.baseStats?.size ?? 10), 10);
let canvas, ctx;
let gameTime = 0;
let gameOver = false;
let mouseX = 0, mouseY = 0;
let mouseWorldX = 0, mouseWorldY = 0;
let mouseLmbDown = false;
let lastTime = 0;
let spawnAccum = 0;
let stanceFlash = 0;
let levelUpOpen = false;
let pendingLevelUps = 0;
/** @type {{ t: number, maxT: number, radius: number } | null} */
let crimsonBurstFx = null;
/** Green stance surge timer (seconds); cleared on leaving green. */
let greenSurgeTimer = 0;
const player = {
x: 0, y: 0, vx: 0, vy: 0,
dirX: 1, dirY: 0,
hp: 100, maxHp: 100,
stats: { ...DEFAULT_STATS },
rgb: 'r',
mana: MANA_MAX,
hitInvuln: 0,
level: 1,
xp: 0,
upgrades: {
moveSpeed: 0,
greenHeal: 0,
redNova: 0,
blueMana: 0,
maxHp: 0,
redKillHp: 0,
blueKillMana: 0,
greenSurge: 0
}
};
const enemies = [];
const projectiles = [];
const xpGems = [];
function effectiveMoveSpeed() {
const base = player.stats.moveSpeed ?? 150;
const pct = player.upgrades.moveSpeed * 0.05;
let mult = 1 + pct;
if (greenSurgeTimer > 0 && player.rgb === 'g' && player.upgrades.greenSurge > 0) {
mult *= 1 + GREEN_SURGE_SPEED_BONUS_PER_STACK * player.upgrades.greenSurge;
}
return base * mult;
}
function effectiveManaRegen() {
let r = MANA_REGEN;
if (player.rgb === 'b') r *= 1 + player.upgrades.blueMana * 0.2;
return r;
}
function gemPickupRadius() {
return GEM_PICKUP_BASE;
}
function gainXp(amount) {
player.xp += amount;
while (player.xp >= xpToNext(player.level)) {
player.xp -= xpToNext(player.level);
pendingLevelUps++;
}
if (pendingLevelUps > 0 && !levelUpOpen) openLevelUp();
}
function pickThreeUpgrades() {
const pool = [...UPGRADE_DEFS];
const out = [];
while (out.length < 3 && pool.length > 0) {
const i = Math.floor(Math.random() * pool.length);
out.push(pool.splice(i, 1)[0]);
}
return out;
}
function openLevelUp() {
if (pendingLevelUps <= 0) return;
levelUpOpen = true;
const overlay = document.getElementById('levelup-overlay');
const opts = document.getElementById('levelup-options');
const title = document.getElementById('levelup-title');
const nextLv = player.level + 1;
title.textContent = `Level ${nextLv}! Choose an upgrade`;
opts.innerHTML = '';
const choices = pickThreeUpgrades();
for (const u of choices) {
const card = document.createElement('button');
card.type = 'button';
const theme = u.affinity == null ? 'n' : u.affinity;
card.className = `levelup-card theme-${theme}`;
card.innerHTML = `<div class="up-title">${u.title}</div><div class="up-desc">${u.desc}</div>`;
card.addEventListener('click', () => {
u.apply();
player.level++;
pendingLevelUps--;
overlay.classList.remove('open');
levelUpOpen = false;
if (pendingLevelUps > 0) openLevelUp();
});
opts.appendChild(card);
}
overlay.classList.add('open');
}
function applyRedNova() {
const stacks = player.upgrades.redNova;
if (stacks <= 0) return;
// stacks*10 alone is too small to hit nearby foes; base matches readable AOE + VFX ring
const radius = 52 + stacks * 10;
const damage = stacks * 10;
crimsonBurstFx = { t: 0.42, maxT: 0.42, radius };
for (const e of enemies) {
if (e.hp <= 0) continue;
const er = e.stats.size ?? 10;
if (dist(player.x, player.y, e.x, e.y) <= radius + er) {
e.hp -= damage;
}
}
}
function spawnXpGem(x, y, value) {
xpGems.push({ x, y, value, age: 0 });
}
function drawXpGemWorld(gx, gy, time) {
const a = 12;
const b = 7;
const omega = 3.2;
const colors = ['#e54545', '#45e565', '#4565ff'];
for (let i = 0; i < 3; i++) {
const alpha = i * (2 * Math.PI / 3);
const theta = omega * time + i * (2 * Math.PI / 3);
const lx = a * Math.cos(theta);
const ly = b * Math.sin(theta);
const px = gx + lx * Math.cos(alpha) - ly * Math.sin(alpha);
const py = gy + lx * Math.sin(alpha) + ly * Math.cos(alpha);
ctx.fillStyle = colors[i];
ctx.beginPath();
ctx.arc(px, py, 6, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.35)';
ctx.lineWidth = 1;
ctx.stroke();
}
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.beginPath();
ctx.arc(gx, gy, 3.5, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
ctx.lineWidth = 1;
ctx.stroke();
}
const XP_GEM_ARM_SECONDS = 0.14;
function updateXpGems(dt) {
const pr = player.stats.size ?? 16;
const magnet = GEM_MAGNET;
const pickup = pr + gemPickupRadius();
const despawnDist = Math.max(canvas.width, canvas.height) / 2 + DESPAWN_MARGIN;
for (let i = xpGems.length - 1; i >= 0; i--) {
const g = xpGems[i];
if (g.age == null) g.age = 0;
g.age += dt;
if (dist(player.x, player.y, g.x, g.y) > despawnDist) {
xpGems.splice(i, 1);
continue;
}
const d = dist(player.x, player.y, g.x, g.y);
if (g.age >= XP_GEM_ARM_SECONDS && d <= pickup) {
gainXp(g.value);
xpGems.splice(i, 1);
continue;
}
if (g.age >= XP_GEM_ARM_SECONDS && d < magnet && d > 1) {
const sp = GEM_PULL_SPEED * dt;
const step = Math.min(sp, d - 0.5);
g.x += ((player.x - g.x) / d) * step;
g.y += ((player.y - g.y) / d) * step;
}
}
}
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 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 getSpawnBudget() {
return 1 + Math.floor(gameTime / SPAWN_STEP_SECONDS);
}
function getTopUnlockedEnemies(budget, count = 1) {
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));
}
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 d = Math.max(canvas.width, canvas.height) / 2 + SPAWN_BUFFER;
const angle = Math.random() * Math.PI * 2;
const x = player.x + Math.cos(angle) * d;
const y = player.y + Math.sin(angle) * d;
const enemy = {
x, y, vx: 0, vy: 0,
defId: def.id,
def,
rgb: randomRgb(),
hp: def.baseStats.hp ?? 20,
maxHp: def.baseStats.hp ?? 20,
stats: { ...def.baseStats },
darkness: def.darkness,
hitIds: new Set()
};
if (def.ranged) enemy.fireCooldown = 0;
if (def.summoner) enemy.summonCooldown = def.summoner.interval;
enemies.push(enemy);
}
const projectilePool = [];
function getProjectile() {
if (projectilePool.length) return projectilePool.pop();
return { x: 0, y: 0, vx: 0, vy: 0, damage: 0, size: 4, rgb: 'r' };
}
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.rgb = enemy.rgb;
projectiles.push(p);
}
}
function circleOverlap(ax, ay, ar, bx, by, br) {
return dist(ax, ay, bx, by) < ar + br;
}
function resolveCircleCollision(a, b, ar, br) {
const dx = a.x - b.x;
const dy = a.y - b.y;
let d = Math.hypot(dx, dy);
const overlap = ar + br - d;
if (overlap <= 0) return;
let nx = 0;
let ny = 0;
if (d <= 1e-6) {
// If centers are identical, choose any axis so they can still separate.
const angle = Math.random() * Math.PI * 2;
nx = Math.cos(angle);
ny = Math.sin(angle);
d = 0;
} else {
nx = dx / d;
ny = dy / 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 wrongColorTouchDamage(e) {
const c = e.stats.contactDamage ?? 0;
return c > 0 ? c : 10;
}
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.hp <= 0) 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;
}
}
player.x += player.vx * dt;
player.y += player.vy * dt;
for (let i = 0; i < enemies.length; i++) {
if (enemies[i].hp > 0) {
enemies[i].x += enemies[i].vx * dt;
enemies[i].y += enemies[i].vy * dt;
}
}
const meleeImpulses = [];
const meleeDone = new Set();
const cellSize = CELL_SIZE;
// Pairs can overlap even when their centers are several cells apart.
const neighborCellRange = Math.ceil((MAX_ENEMY_RADIUS * 2) / cellSize);
const getCellCoords = (x, y) => ({
cx: Math.floor(x / cellSize),
cy: Math.floor(y / cellSize)
});
const getCellKey = (cx, cy) => `${cx},${cy}`;
let grid = {};
for (let iter = 0; iter < PBD_ITERATIONS; iter++) {
grid = {};
for (let i = 0; i < enemies.length; i++) {
const e = enemies[i];
if (e.hp <= 0) continue;
const { cx, cy } = getCellCoords(e.x, e.y);
const c = getCellKey(cx, cy);
if (!grid[c]) grid[c] = [];
grid[c].push({ e, i });
}
for (const e of enemies) {
if (e.hp <= 0) 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)) {
if (e.rgb === player.rgb) {
e.hp = 0;
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 });
} else if (player.hitInvuln <= 0) {
player.hp -= wrongColorTouchDamage(e);
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];
if (a.hp <= 0) continue;
const ar = a.stats.size ?? 10;
const { cx, cy } = getCellCoords(a.x, a.y);
const seen = new Set();
for (let oy = -neighborCellRange; oy <= neighborCellRange; oy++) {
for (let ox = -neighborCellRange; ox <= neighborCellRange; ox++) {
const cc = getCellKey(cx + ox, cy + oy);
if (!grid[cc]) continue;
for (const { e: b, i: bIdx } of grid[cc]) {
if (b === a || seen.has(b)) continue;
if (bIdx <= i) continue;
if (b.hp <= 0) 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];
e.vx = (e.x - p.x) / dt;
e.vy = (e.y - p.y) / dt;
}
for (const { e, nx, ny } of meleeImpulses) {
if (e.hp <= 0) continue;
const mp = getMass(player);
const me = getMass(e);
player.vx += (nx * MELEE_IMPULSE) / mp;
player.vy += (ny * MELEE_IMPULSE) / mp;
e.vx -= (nx * MELEE_IMPULSE) / me;
e.vy -= (ny * MELEE_IMPULSE) / me;
}
if (player.hitInvuln > 0) player.hitInvuln -= dt;
}
function 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,
defId: def.id,
def,
rgb: randomRgb(),
hp: def.baseStats.hp ?? 20,
maxHp: def.baseStats.hp ?? 20,
stats: { ...def.baseStats },
darkness: def.darkness,
hitIds: 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.hp <= 0) 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.hp <= 0) 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 updateCollisions(dt) {
const despawnDist = Math.max(canvas.width, canvas.height) / 2 + DESPAWN_MARGIN;
for (let i = enemies.length - 1; i >= 0; i--) {
if (dist(player.x, player.y, enemies[i].x, enemies[i].y) > despawnDist) enemies.splice(i, 1);
}
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;
const margin = 300;
if (Math.abs(p.x - player.x) > canvas.width + margin || Math.abs(p.y - player.y) > canvas.height + margin) {
projectiles.splice(i, 1);
projectilePool.push(p);
continue;
}
if (circleOverlap(p.x, p.y, p.size, player.x, player.y, pr)) {
if (p.rgb === player.rgb) {
projectiles.splice(i, 1);
projectilePool.push(p);
} else if (player.hitInvuln <= 0) {
player.hp -= p.damage;
player.hitInvuln = 0.8;
projectiles.splice(i, 1);
projectilePool.push(p);
}
continue;
}
}
for (let i = enemies.length - 1; i >= 0; i--) {
const e = enemies[i];
if (e.hp <= 0) {
const def = e.def;
const v = Math.max(3, Math.floor((def?.points ?? 1) * 2.5));
if (player.upgrades.redKillHp > 0 && player.rgb === 'r') {
player.hp = Math.min(player.maxHp, player.hp + player.upgrades.redKillHp * 2);
}
if (player.upgrades.blueKillMana > 0 && player.rgb === 'b') {
player.mana = Math.min(MANA_MAX, player.mana + player.upgrades.blueKillMana * 5);
}
spawnXpGem(e.x, e.y, v);
enemies.splice(i, 1);
}
}
if (stanceFlash > 0) stanceFlash -= dt;
}
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;
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 = '#3d6b2f';
ctx.fillRect(-10000, -10000, 20000, 20000);
}
function rgbEnemyFill(rgb, darkness) {
const base = RGB[rgb].fill;
const r = parseInt(base.slice(1, 3), 16);
const g = parseInt(base.slice(3, 5), 16);
const b = parseInt(base.slice(5, 7), 16);
const dim = 0.35 + (1 - darkness) * 0.45;
return `rgb(${Math.round(r * dim)},${Math.round(g * dim)},${Math.round(b * dim)})`;
}
function render() {
ctx.fillStyle = '#3d6b2f';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(canvas.width / 2 - player.x, canvas.height / 2 - player.y);
drawGround();
const camX = player.x - canvas.width / 2;
const camY = player.y - canvas.height / 2;
const margin = 100;
drawBackgroundDecor(camX, camY, margin);
for (const e of enemies) {
if (e.hp <= 0) continue;
if (e.x < camX - margin || e.x > camX + canvas.width + margin || e.y < camY - margin || e.y > camY + canvas.height + margin) continue;
const r = e.stats.size ?? 10;
if (e.hp < e.maxHp) {
const barW = r * 2.5;
const barH = 4;
const barY = e.y - r - barH - 4;
ctx.fillStyle = '#333';
ctx.fillRect(e.x - barW / 2, barY, barW, barH);
ctx.fillStyle = e.hp > e.maxHp * 0.3 ? '#4a4' : '#a44';
ctx.fillRect(e.x - barW / 2, barY, barW * (e.hp / e.maxHp), barH);
ctx.strokeStyle = '#555';
ctx.strokeRect(e.x - barW / 2, barY, barW, barH);
}
ctx.beginPath();
ctx.arc(e.x, e.y, r, 0, Math.PI * 2);
ctx.fillStyle = rgbEnemyFill(e.rgb, e.darkness);
ctx.fill();
ctx.strokeStyle = RGB[e.rgb].stroke;
ctx.lineWidth = 3;
ctx.stroke();
ctx.lineWidth = 1;
}
for (const p of projectiles) {
ctx.fillStyle = RGB[p.rgb].fill;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.stroke();
}
const pc = RGB[player.rgb];
const hurt = player.hitInvuln > 0;
ctx.fillStyle = hurt ? '#ffffff' : pc.fill;
if (stanceFlash > 0) {
ctx.globalAlpha = 0.5 + 0.5 * Math.sin(stanceFlash * 40);
}
ctx.beginPath();
ctx.arc(player.x, player.y, player.stats.size, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = hurt ? '#f0f0f0' : pc.stroke;
ctx.lineWidth = 3;
ctx.stroke();
ctx.lineWidth = 1;
const pr = player.stats.size ?? 16;
const barW = pr * 5;
const barH = 6;
const pad = 4;
const gap = 2;
const manaY = player.y - pr - barH - pad;
const hpY = manaY - barH - gap;
ctx.fillStyle = '#333';
ctx.fillRect(player.x - barW / 2, hpY, barW, barH);
ctx.fillStyle = player.hp > player.maxHp * 0.3 ? '#4a4' : '#a44';
ctx.fillRect(player.x - barW / 2, hpY, barW * (player.hp / player.maxHp), barH);
ctx.strokeStyle = '#555';
ctx.strokeRect(player.x - barW / 2, hpY, barW, barH);
const manaLow = player.mana < STANCE_COST;
ctx.fillStyle = manaLow ? '#2a1818' : '#333';
ctx.fillRect(player.x - barW / 2, manaY, barW, barH);
ctx.fillStyle = manaLow ? '#d04040' : '#48a8ff';
ctx.fillRect(player.x - barW / 2, manaY, barW * (player.mana / MANA_MAX), barH);
ctx.strokeStyle = manaLow ? '#882828' : '#555';
ctx.strokeRect(player.x - barW / 2, manaY, barW, barH);
if (crimsonBurstFx) {
const fx = crimsonBurstFx;
const life = fx.t / fx.maxT;
const r = fx.radius;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.fillStyle = `rgba(255, 80, 60, ${0.14 * life})`;
ctx.beginPath();
ctx.arc(player.x, player.y, r, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = `rgba(255, 140, 110, ${0.35 + 0.45 * life})`;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(player.x, player.y, r, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
for (const g of xpGems) {
if (g.x < camX - margin || g.x > camX + canvas.width + margin || g.y < camY - margin || g.y > camY + canvas.height + margin) continue;
drawXpGemWorld(g.x, g.y, gameTime);
}
ctx.restore();
const xpNeed = xpToNext(player.level);
const xpPct = xpNeed > 0 ? Math.min(100, (player.xp / xpNeed) * 100) : 100;
document.getElementById('xp-bar-fill').style.width = xpPct + '%';
document.getElementById('xp-bar-meta').textContent =
`Lv ${player.level}` + (pendingLevelUps > 0 ? ' · +' + pendingLevelUps : '');
document.getElementById('hud').textContent = `Time ${Math.floor(gameTime)}s`;
const kq = document.getElementById('key-cap-q');
const kw = document.getElementById('key-cap-w');
const ke = document.getElementById('key-cap-e');
if (kq && kw && ke) {
kq.classList.remove('active');
kw.classList.remove('active');
ke.classList.remove('active');
if (player.rgb === 'r') kq.classList.add('active');
else if (player.rgb === 'g') kw.classList.add('active');
else if (player.rgb === 'b') ke.classList.add('active');
}
if (gameOver) {
document.getElementById('game-over').classList.add('show');
document.getElementById('survived-time').textContent = `Survived ${Math.floor(gameTime)} seconds`;
}
}
function updateInput() {
const speed = effectiveMoveSpeed();
if (mouseLmbDown) {
const tox = mouseWorldX - player.x;
const toy = mouseWorldY - player.y;
const d = Math.hypot(tox, toy);
if (d > 4) {
player.dirX = tox / d;
player.dirY = toy / d;
player.vx = player.dirX * speed;
player.vy = player.dirY * speed;
} else {
player.vx = 0;
player.vy = 0;
}
} else {
player.vx = 0;
player.vy = 0;
}
}
function flashKeyCap(code) {
const id = code === 'KeyQ' ? 'key-cap-q' : code === 'KeyW' ? 'key-cap-w' : code === 'KeyE' ? 'key-cap-e' : null;
if (!id) return;
const el = document.getElementById(id);
if (!el) return;
el.classList.add('press-flash');
clearTimeout(el._pressFlashT);
el._pressFlashT = setTimeout(() => {
el.classList.remove('press-flash');
}, 110);
}
function tryStance(targetRgb) {
if (levelUpOpen) return;
if (targetRgb === player.rgb) return;
if (player.mana < STANCE_COST) {
stanceFlash = 0.15;
return;
}
player.mana -= STANCE_COST;
player.rgb = targetRgb;
stanceFlash = 0.12;
if (targetRgb === 'g') {
if (player.upgrades.greenSurge > 0) {
greenSurgeTimer = GREEN_SURGE_SECONDS_PER_STACK * player.upgrades.greenSurge;
}
if (player.upgrades.greenHeal > 0) {
player.hp = Math.min(player.maxHp, player.hp + player.upgrades.greenHeal * 5);
}
}
if (targetRgb === 'r' && player.upgrades.redNova > 0) {
applyRedNova();
}
}
function update(dt) {
if (gameOver) return;
if (crimsonBurstFx) {
crimsonBurstFx.t -= dt;
if (crimsonBurstFx.t <= 0) crimsonBurstFx = null;
}
if (levelUpOpen) {
render();
requestAnimationFrame(gameLoop);
return;
}
gameTime += dt;
if (greenSurgeTimer > 0) {
if (player.rgb !== 'g') greenSurgeTimer = 0;
else greenSurgeTimer -= dt;
}
updateInput();
player.mana = Math.min(MANA_MAX, player.mana + effectiveManaRegen() * dt);
player.hp = Math.min(player.maxHp, player.hp + 0.5 * dt);
runSpawner(dt);
updateSummoners(dt);
updateRangedEnemies(dt);
updateCollisions(dt);
updateXpGems(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);
}
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 => {
if (levelUpOpen) return;
if (e.code === 'KeyQ') { e.preventDefault(); flashKeyCap('KeyQ'); tryStance('r'); }
else if (e.code === 'KeyW') { e.preventDefault(); flashKeyCap('KeyW'); tryStance('g'); }
else if (e.code === 'KeyE') { e.preventDefault(); flashKeyCap('KeyE'); tryStance('b'); }
});
window.addEventListener('blur', () => { mouseLmbDown = false; });
canvas.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
mouseX = e.clientX - rect.left;
mouseY = e.clientY - rect.top;
mouseWorldX = player.x - canvas.width / 2 + mouseX;
mouseWorldY = player.y - canvas.height / 2 + mouseY;
});
canvas.addEventListener('mousedown', e => { if (e.button === 0) mouseLmbDown = true; });
canvas.addEventListener('contextmenu', e => e.preventDefault());
window.addEventListener('mouseup', e => { if (e.button === 0) mouseLmbDown = false; });
document.getElementById('restart-btn').addEventListener('click', () => {
gameOver = false;
gameTime = 0;
document.getElementById('game-over').classList.remove('show');
document.getElementById('levelup-overlay').classList.remove('open');
levelUpOpen = false;
pendingLevelUps = 0;
player.x = 0; player.y = 0; player.vx = 0; player.vy = 0;
player.hp = 100; player.maxHp = 100;
player.rgb = 'r';
player.mana = MANA_MAX;
player.hitInvuln = 0;
stanceFlash = 0;
crimsonBurstFx = null;
greenSurgeTimer = 0;
player.level = 1;
player.xp = 0;
player.upgrades = {
moveSpeed: 0, greenHeal: 0, redNova: 0, blueMana: 0, maxHp: 0,
redKillHp: 0, blueKillMana: 0, greenSurge: 0
};
enemies.length = 0;
projectiles.length = 0;
xpGems.length = 0;
spawnAccum = 0;
lastTime = performance.now();
requestAnimationFrame(gameLoop);
});
canvas.focus();
lastTime = performance.now();
requestAnimationFrame(gameLoop);
}
init();
</script>
</body>
</html>
remixes
no remixes yet... email to remix@gameslop.net
