source
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>wizrad wave game - p5.js port</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.0/p5.min.js"></script>
<style>
html, body { margin: 0; padding: 0; background: #111; color: #eee; font-family: monospace; }
canvas { display: block; }
</style>
</head>
<body>
<script>
"use strict";
const TEAM_PLAYER = 0;
const TEAM_ENEMIES = 1;
const INVUL_TIME = 0.2;
const INVUL_DAMAGE_THRESHOLD = 10.0;
const LOOK_STRENGTH = 0.2;
const DEFAULT_ZOOM = 34.0;
const MIN_ZOOM = 16.0;
const MAX_ZOOM = 64.0;
const LEVEL_SIZE = 40.0;
const PLAYER_SPAWN_Y = LEVEL_SIZE * 0.5 - 2.0;
const ENEMY_SPAWN_Y = -LEVEL_SIZE * 0.5 + 2.0;
const SPELL = {
MISSILE: "Missile",
PULSE: "Pulse",
LIFESTEAL: "Lifesteal",
SUMMON_BLOODCASTERS: "SummonBloodcasters",
CONE_FLAMES: "ConeFlames",
SUMMON_RUSHERS: "SummonRushers",
SUMMON_SUMMONERS: "SummonSummoners",
FIREBALL: "Fireball",
FIRESTORM: "Firestorm",
WATER: "Water",
HEALING: "Healing",
HOMING: "Homing",
BARRAGE: "Barrage",
};
const SPELL_POOL = [
SPELL.MISSILE, SPELL.FIREBALL, SPELL.CONE_FLAMES, SPELL.PULSE, SPELL.FIRESTORM,
SPELL.LIFESTEAL, SPELL.SUMMON_BLOODCASTERS, SPELL.SUMMON_RUSHERS, SPELL.HOMING, SPELL.HEALING,
SPELL.WATER, SPELL.BARRAGE, SPELL.SUMMON_SUMMONERS,
];
let game = null;
let zoom = DEFAULT_ZOOM;
const input = {
heldKeys: new Set(),
keyDownEvents: new Set(),
heldLMB: false,
lmbDown: false,
lmbUp: false,
};
function v(x = 0, y = 0) { return { x, y }; }
function vAdd(a, b) { return v(a.x + b.x, a.y + b.y); }
function vSub(a, b) { return v(a.x - b.x, a.y - b.y); }
function vMul(a, s) { return v(a.x * s, a.y * s); }
function vLen(a) { return Math.hypot(a.x, a.y); }
function vNorm(a) { const l = vLen(a); return l > 1e-6 ? vMul(a, 1 / l) : v(0, 0); }
function vDist(a, b) { return vLen(vSub(a, b)); }
function vRot(a, r) {
const c = Math.cos(r), s = Math.sin(r);
return v(a.x * c - a.y * s, a.x * s + a.y * c);
}
function rand(a, b) { return a + Math.random() * (b - a); }
function rectSides(e) {
return {
left: e.x - e.w / 2, right: e.x + e.w / 2,
top: e.y - e.h / 2, bot: e.y + e.h / 2,
};
}
function overlapAmount(a1, a2, b1, b2) {
const a1InB = a1 >= b1 && a1 <= b2;
const a2InB = a2 >= b1 && a2 <= b2;
const b1InA = b1 >= a1 && b1 <= a2;
const b2InA = b2 >= a1 && b2 <= a2;
const aLeft = (a1 + a2) < (b1 + b2);
if (!a1InB && !a2InB && !b1InA && !b2InA) return 0;
if (a1InB && a2InB && aLeft) return a2 - b1;
if (a1InB && a2InB && !aLeft) return b2 - a1;
if (b1InA && b2InA && aLeft) return a2 - b1;
if (b1InA && b2InA && !aLeft) return b2 - a1;
if (a1InB) return b2 - a1;
if (b1InA) return -(a2 - b1);
return 0;
}
function collideRects(a, b) {
const ra = rectSides(a), rb = rectSides(b);
const xo = overlapAmount(ra.left, ra.right, rb.left, rb.right);
const yo = overlapAmount(ra.top, ra.bot, rb.top, rb.bot);
if (xo === 0 || yo === 0) return null;
return Math.abs(xo) < Math.abs(yo) ? v(xo, 0) : v(0, yo);
}
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const t = arr[i]; arr[i] = arr[j]; arr[j] = t;
}
}
function makeBaseEntity() {
return {
id: -1,
x: 0, y: 0, w: 1, h: 1,
vx: 0, vy: 0, oldX: 0, oldY: 0,
mass: 1.0,
team: null,
player: null,
ai: null,
aiCaster: null,
health: null,
caster: null,
projectile: null,
meleeDamage: null,
expiry: null,
emitter: null,
spawnList: null,
makeOnDeath: null,
color: [255, 255, 255],
lastHitBy: null,
};
}
function makePlayer(pos) {
const e = makeBaseEntity();
e.x = pos.x; e.y = pos.y; e.w = 1.0; e.h = 1.0;
e.mass = 1.0; e.team = TEAM_PLAYER;
e.player = { speed: 10.0, spellbook: [], spellCursor: 0, kills: 0 };
e.caster = { manaMax: 100, mana: 100, manaRegen: 30, lastCast: -10000 };
e.health = { max: 100, current: 100, regen: 1, invulTime: -1 };
e.color = [255, 60, 255];
return e;
}
function makeZerg(pos, team) {
const e = makeBaseEntity();
e.x = pos.x; e.y = pos.y; e.w = 0.5; e.h = 0.5;
e.mass = 0.25; e.team = team;
e.meleeDamage = { amount: 20 };
e.health = { max: 20, current: 20, regen: 1, invulTime: -1 };
e.ai = { dir: v(0, 0), acquisitionRange: 10, fleeRange: 0, speed: 7, accel: 6 };
e.color = [170, 20, 20];
return e;
}
function makeSummoner(pos, team) {
const e = makeBaseEntity();
e.x = pos.x; e.y = pos.y; e.w = 1.2; e.h = 1.2;
e.mass = 2.0; e.team = team;
e.health = { max: 100, current: 100, regen: 1, invulTime: -1 };
e.ai = { dir: v(0, 0), acquisitionRange: 10, fleeRange: 0, speed: 2, accel: 6 };
e.aiCaster = { spell: SPELL.SUMMON_RUSHERS, acquisitionRange: 10, rising: false, unleasher: false };
e.caster = { manaMax: 20, mana: 20, manaRegen: 2, lastCast: -10000 };
e.color = [130, 20, 20];
return e;
}
function makeSummonerSummoner(pos, team) {
const e = makeBaseEntity();
e.x = pos.x; e.y = pos.y; e.w = 1.8; e.h = 1.8;
e.mass = 4.0; e.team = team;
e.health = { max: 200, current: 200, regen: 1, invulTime: -1 };
e.ai = { dir: v(0, 0), acquisitionRange: 10, fleeRange: 0, speed: 1.6, accel: 6 };
e.aiCaster = { spell: SPELL.SUMMON_SUMMONERS, acquisitionRange: 12, rising: false, unleasher: false };
e.caster = { manaMax: 100, mana: 100, manaRegen: 3, lastCast: -10000 };
e.color = [80, 10, 10];
return e;
}
function makeMagicMissileCaster(pos, team) {
const e = makeBaseEntity();
e.x = pos.x; e.y = pos.y; e.w = 0.9; e.h = 0.9;
e.mass = 0.7; e.team = team;
e.health = { max: 20, current: 20, regen: 1, invulTime: -1 };
e.ai = { dir: v(0, 0), acquisitionRange: 10, fleeRange: 5, speed: 3, accel: 6 };
e.aiCaster = { spell: SPELL.MISSILE, acquisitionRange: 9, rising: false, unleasher: false };
e.caster = { manaMax: 10, mana: 10, manaRegen: 3, lastCast: -10000 };
e.color = [50, 190, 190];
return e;
}
function makeBloodcaster(pos, team) {
const e = makeBaseEntity();
e.x = pos.x; e.y = pos.y; e.w = 0.9; e.h = 0.9;
e.mass = 0.7; e.team = team;
e.health = { max: 40, current: 40, regen: 1, invulTime: -1 };
e.ai = { dir: v(0, 0), acquisitionRange: 10, fleeRange: 5, speed: 5, accel: 6 };
e.aiCaster = { spell: SPELL.LIFESTEAL, acquisitionRange: 7, rising: false, unleasher: false };
e.caster = { manaMax: 0, mana: 0, manaRegen: 0, lastCast: -10000 };
e.color = [105, 75, 75];
return e;
}
function makeBarrager(pos, team) {
const e = makeBaseEntity();
e.x = pos.x; e.y = pos.y; e.w = 1.2; e.h = 1.2;
e.mass = 2.0; e.team = team;
e.health = { max: 60, current: 60, regen: 1, invulTime: -1 };
e.ai = { dir: v(0, 0), acquisitionRange: 20, fleeRange: 10, speed: 3, accel: 6 };
e.aiCaster = { spell: SPELL.BARRAGE, acquisitionRange: 15, rising: false, unleasher: true };
e.caster = { manaMax: 50, mana: 50, manaRegen: 6, lastCast: -10000 };
e.color = [150, 20, 200];
return e;
}
function spawnListBuilder() {
return { t: 0, list: [], cursor: 0, timeCursor: 0 };
}
function spawnListSpawn(builder, makeFn) {
builder.list.push({ t: builder.timeCursor, makeFn });
}
function spawnListWait(builder, dt) {
builder.timeCursor += dt;
}
function makePortal2(pos, team) {
const e = makeBaseEntity();
e.x = pos.x; e.y = pos.y; e.w = 1.0; e.h = 2.0;
e.color = [210, 0, 255];
const s = spawnListBuilder();
for (let i = 0; i <= 40; i++) {
if (i % 2 === 0) spawnListSpawn(s, () => makeMagicMissileCaster(v(pos.x, pos.y), team));
spawnListSpawn(s, () => makeZerg(v(pos.x, pos.y), team));
spawnListWait(s, 0.5);
}
e.spawnList = s;
return e;
}
function makePortal3(pos, team) {
const e = makeBaseEntity();
e.x = pos.x; e.y = pos.y; e.w = 1.0; e.h = 2.0;
e.color = [220, 20, 20];
const s = spawnListBuilder();
for (let i = 0; i <= 40; i++) {
if (i % 4 === 0) spawnListSpawn(s, () => makeSummoner(v(pos.x, pos.y), team));
if (i % 10 === 0) {
spawnListSpawn(s, () => makeSummonerSummoner(v(pos.x, pos.y), team));
spawnListSpawn(s, () => makeBarrager(v(pos.x, pos.y), team));
}
spawnListWait(s, 0.75);
}
e.spawnList = s;
return e;
}
function addEntity(e) {
e.id = game.nextId++;
game.entities.set(e.id, e);
if (e.player) game.playerId = e.id;
return e.id;
}
function removeEntity(id) {
if (!game.entities.has(id)) return;
if (id === game.playerId) game.playerId = null;
game.entities.delete(id);
}
function createGame() {
const g = {
t: 0,
wave: 0,
pause: false,
lookCenter: v(0, 0),
entities: new Map(),
nextId: 1,
playerId: null,
spellMenu: null,
particles: [],
events: [],
};
game = g;
zoom = DEFAULT_ZOOM;
addEntity(makePlayer(v(0, PLAYER_SPAWN_Y)));
game.events.push({
cond: () => playerSpellbook().length === 0,
eff: () => openSpellMenu(),
});
game.events.push({
cond: () => playerSpellbook().length === 1,
eff: () => openSpellMenu(),
});
game.events.push({
cond: () => playerSpellbook().length === 2,
eff: () => { addEntity(makePortal3(v(0, ENEMY_SPAWN_Y), TEAM_ENEMIES)); game.wave = 1; },
});
game.events.push({
cond: () => {
const spawnEntity = Array.from(game.entities.values()).find((e) => e.spawnList);
if (!spawnEntity) return false;
const nEnemies = Array.from(game.entities.values()).filter((e) => e.team === TEAM_ENEMIES).length;
const lastT = spawnEntity.spawnList.list[spawnEntity.spawnList.list.length - 1].t;
return game.wave === 1 && nEnemies === 0 && spawnEntity.spawnList.t > lastT;
},
eff: () => {
const spawnEntity = Array.from(game.entities.values()).find((e) => e.spawnList);
if (spawnEntity) removeEntity(spawnEntity.id);
addEntity(makePortal2(v(0, ENEMY_SPAWN_Y), TEAM_ENEMIES));
game.wave = 2;
},
});
}
function playerEntity() {
return game.playerId != null ? game.entities.get(game.playerId) : null;
}
function playerSpellbook() {
const p = playerEntity();
return p && p.player ? p.player.spellbook : [];
}
function openSpellMenu() {
const current = new Set(playerSpellbook());
const pool = SPELL_POOL.filter((s) => !current.has(s));
if (pool.length < 3) return;
shuffle(pool);
game.spellMenu = { choices: [pool[0], pool[1], pool[2]], clickAfter: game.t + 0.3 };
}
function cameraState() {
const viewW = width / zoom;
const viewH = height / zoom;
const lookVec = v((mouseX / width - 0.5) * viewW, (mouseY / height - 0.5) * viewH);
const cx = game.lookCenter.x + LOOK_STRENGTH * lookVec.x;
const cy = game.lookCenter.y + LOOK_STRENGTH * lookVec.y;
return { cx, cy, viewW, viewH };
}
function screenToWorld(px, py) {
const cam = cameraState();
return v(cam.cx + (px - width * 0.5) / zoom, cam.cy + (py - height * 0.5) / zoom);
}
function worldToScreen(wx, wy) {
const cam = cameraState();
return v((wx - cam.cx) * zoom + width * 0.5, (wy - cam.cy) * zoom + height * 0.5);
}
function damageEntity(targetId, amount, srcId) {
const e = game.entities.get(targetId);
if (!e || !e.health) return;
if (game.t - e.health.invulTime > INVUL_TIME) {
if (amount > INVUL_DAMAGE_THRESHOLD) e.health.invulTime = game.t;
e.health.current -= amount;
e.lastHitBy = srcId;
}
}
function addProjectile(team, source, pos, vel, size, damage, color, extra = {}) {
const e = makeBaseEntity();
e.team = team;
e.mass = extra.mass ?? 1.0;
e.x = pos.x; e.y = pos.y;
e.w = size; e.h = size;
e.vx = vel.x; e.vy = vel.y;
e.projectile = {
source,
damage,
aoe: extra.aoe ?? 0,
splatDuration: extra.splatDuration ?? 0,
lifestealPercent: extra.lifestealPercent ?? 0,
homing: extra.homing ?? false,
};
e.expiry = extra.expiry ?? null;
if (extra.emitter) {
e.emitter = {
interval: extra.emitter.interval,
last: game.t,
color: extra.emitter.color,
size: extra.emitter.size,
speed: extra.emitter.speed,
lifespan: extra.emitter.lifespan,
};
}
e.color = color;
addEntity(e);
}
function updateEmittersAndParticles(dt) {
for (const e of game.entities.values()) {
if (!e.emitter) continue;
let emitted = 0;
const maxBurst = 4;
while (e.emitter.last + e.emitter.interval < game.t && emitted < maxBurst) {
e.emitter.last += e.emitter.interval;
const d = vNorm(v(rand(-1, 1), rand(-1, 1)));
game.particles.push({
x: e.x,
y: e.y,
w: e.emitter.size,
h: e.emitter.size,
vx: d.x * e.emitter.speed,
vy: d.y * e.emitter.speed,
color: e.emitter.color,
expiry: e.emitter.last + e.emitter.lifespan,
});
emitted += 1;
}
if (e.emitter.last + e.emitter.interval < game.t) {
// Drop overdue emissions to prevent single-frame particle explosions.
e.emitter.last = game.t - e.emitter.interval;
}
}
const next = [];
for (const p of game.particles) {
if (p.expiry <= game.t) continue;
p.x += p.vx * dt;
p.y += p.vy * dt;
next.push(p);
}
game.particles = next;
}
function castSpell(casterId, target, spell, repeat, dt) {
const caster = game.entities.get(casterId);
if (!caster || !caster.caster || caster.team == null) return;
const cc = caster.caster;
const casterPos = v(caster.x, caster.y);
const team = caster.team;
const aim = vSub(target, casterPos);
const dir = vLen(aim) > 1e-6 ? vNorm(aim) : v(0, -1);
if (spell === SPELL.CONE_FLAMES) {
if (cc.mana >= 1.0) {
cc.mana -= 1.0;
for (let i = 0; i < 4; i++) {
const spray = rand(-0.25, 0.25);
const vel = vMul(vRot(dir, spray), 10.0);
addProjectile(team, casterId, casterPos, vel, 0.2, 2.0, [255, rand(50, 220), 10], { mass: 0, expiry: game.t + rand(0.6, 0.8) });
}
}
return;
}
if (spell === SPELL.BARRAGE) {
if (cc.lastCast + 0.1 > game.t) return;
cc.lastCast = game.t;
if (cc.mana >= 5.0) {
cc.mana -= 5.0;
addProjectile(team, casterId, casterPos, vMul(vRot(dir, rand(-0.6, 0.6)), 9.0), 0.5, 30.0, [210, 40, 210], {
mass: 10,
emitter: { interval: 0.05, color: [150, 0, 200], speed: 2.0, lifespan: 0.7, size: 0.1 },
});
}
return;
}
if (spell === SPELL.WATER) {
if (cc.mana >= 0.4) {
cc.mana -= 0.4;
for (let i = 0; i < 7; i++) {
const vel = vMul(vRot(dir, rand(-0.6, 0.6)), 6.0);
addProjectile(team, casterId, casterPos, vel, 0.2, 0.0, [40, 120, 255], { mass: 20, expiry: game.t + rand(0.6, 0.8) });
}
}
return;
}
if (spell === SPELL.MISSILE) {
if (repeat || cc.lastCast + 0.3 > game.t) return;
cc.lastCast = game.t;
if (cc.mana >= 10.0) {
cc.mana -= 10.0;
addProjectile(team, casterId, casterPos, vMul(dir, 15.0), 0.4, 34.0, [220, 10, 220], {
mass: 10,
emitter: { interval: 0.05, color: [200, 0, 200], speed: 2.0, lifespan: 0.7, size: 0.1 },
});
}
return;
}
if (spell === SPELL.HOMING) {
if (repeat || cc.lastCast + 0.3 > game.t) return;
cc.lastCast = game.t;
if (cc.mana >= 25.0) {
cc.mana -= 25.0;
addProjectile(team, casterId, casterPos, vMul(dir, 5.0), 0.5, 20.0, [20, 110, 255], {
mass: 10, homing: true,
emitter: { interval: 0.05, color: [0, 100, 255], speed: 2.0, lifespan: 0.7, size: 0.1 },
});
addProjectile(team, casterId, casterPos, vMul(vRot(dir, -1.0), 5.0), 0.5, 20.0, [20, 110, 255], {
mass: 10, homing: true,
emitter: { interval: 0.05, color: [0, 100, 255], speed: 2.0, lifespan: 0.7, size: 0.1 },
});
addProjectile(team, casterId, casterPos, vMul(vRot(dir, 1.0), 5.0), 0.5, 20.0, [20, 110, 255], {
mass: 10, homing: true,
emitter: { interval: 0.05, color: [0, 100, 255], speed: 2.0, lifespan: 0.7, size: 0.1 },
});
}
return;
}
if (spell === SPELL.PULSE) {
if (cc.lastCast + 0.1 > game.t) return;
cc.lastCast = game.t;
if (cc.mana >= 6.0) {
cc.mana -= 6.0;
addProjectile(team, casterId, casterPos, vMul(dir, 25.0), 0.4, 34.0, [20, 220, 20], {
mass: 4, expiry: game.t + (4.0 / 25.0),
emitter: { interval: 0.05, color: [0, 200, 0], speed: 3.0, lifespan: 0.3, size: 0.1 },
});
}
return;
}
if (spell === SPELL.LIFESTEAL) {
if (cc.lastCast + 0.5 > game.t) return;
cc.lastCast = game.t;
if (caster.health && caster.health.current >= 10.0) {
caster.health.current -= 10.0;
addProjectile(team, casterId, casterPos, vMul(dir, 10.0), 0.4, 20.0, [220, 30, 30], {
mass: 4, lifestealPercent: 0.7,
emitter: { interval: 0.05, color: [200, 0, 0], speed: 2.0, lifespan: 0.7, size: 0.1 },
});
}
return;
}
if (spell === SPELL.FIREBALL) {
if (repeat || cc.lastCast + 0.3 > game.t) return;
cc.lastCast = game.t;
if (cc.mana >= 30.0) {
cc.mana -= 30.0;
addProjectile(team, casterId, casterPos, vMul(dir, 10.0), 0.5, 50.0, [255, 40, 0], {
mass: 1, aoe: 4.0, splatDuration: 0.7, expiry: game.t + 10.0,
emitter: { interval: 0.2, color: [80, 80, 80], speed: 0.3, lifespan: 0.5, size: 0.15 },
});
}
return;
}
if (spell === SPELL.FIRESTORM) {
if (cc.lastCast + 0.25 > game.t) return;
cc.lastCast = game.t;
if (cc.mana >= 8.0) {
cc.mana -= 8.0;
addProjectile(team, casterId, casterPos, vMul(dir, 15.0), 0.5, 18.0, [255, 0, 0], {
mass: 4, aoe: 2.0,
emitter: { interval: 0.05, color: [255, 0, 0], speed: 2.0, lifespan: 0.7, size: 0.15 },
});
}
return;
}
if (spell === SPELL.SUMMON_RUSHERS) {
if (repeat || cc.lastCast + 0.3 > game.t) return;
cc.lastCast = game.t;
if (cc.mana >= 20.0) {
cc.mana -= 20.0;
addEntity(makeZerg(v(casterPos.x + 1.0, casterPos.y), team));
addEntity(makeZerg(v(casterPos.x - 0.5, casterPos.y + 0.86), team));
addEntity(makeZerg(v(casterPos.x - 0.5, casterPos.y - 0.86), team));
}
return;
}
if (spell === SPELL.SUMMON_BLOODCASTERS) {
if (repeat || cc.lastCast + 0.3 > game.t) return;
cc.lastCast = game.t;
if (caster.health && caster.health.current >= 50.0) {
caster.health.current -= 50.0;
addEntity(makeBloodcaster(v(casterPos.x + 1.0, casterPos.y), team));
addEntity(makeBloodcaster(v(casterPos.x - 1.0, casterPos.y), team));
}
return;
}
if (spell === SPELL.SUMMON_SUMMONERS) {
if (repeat || cc.lastCast + 0.3 > game.t) return;
cc.lastCast = game.t;
if (cc.mana >= 100.0) {
cc.mana -= 100.0;
addEntity(makeSummoner(v(casterPos.x + 2.0, casterPos.y), team));
addEntity(makeSummoner(v(casterPos.x - 1.0, casterPos.y + 1.73), team));
addEntity(makeSummoner(v(casterPos.x - 1.0, casterPos.y - 1.73), team));
}
return;
}
if (spell === SPELL.HEALING) {
if (caster.health && cc.mana >= 30.0 * dt) {
caster.health.current += 30.0 * dt;
caster.health.current = Math.min(caster.health.current, caster.health.max);
cc.mana -= 30.0 * dt;
cc.lastCast = game.t;
}
}
}
function updateMovementAI(dt) {
const ents = Array.from(game.entities.values());
for (const e of ents) {
if (!e.ai) continue;
const enemies = ents.filter((o) => o.id !== e.id && o.team != null && o.team !== e.team && !o.projectile);
let target = null;
let bestDist = Infinity;
for (const o of enemies) {
const d = vDist(v(e.x, e.y), v(o.x, o.y));
if (d < e.ai.acquisitionRange && d < bestDist) {
bestDist = d;
target = o;
}
}
if (target) {
let dir = vNorm(vSub(v(target.x, target.y), v(e.x, e.y)));
if (bestDist < e.ai.fleeRange) dir = vMul(dir, -1);
const speed = Math.min(e.ai.speed, bestDist / Math.max(dt, 1e-4));
const tv = vMul(dir, speed);
e.vx = e.vx + e.ai.accel * dt * (tv.x - e.vx);
e.vy = e.vy + e.ai.accel * dt * (tv.y - e.vy);
e.ai.dir = dir;
} else {
e.ai.dir = vNorm(vAdd(e.ai.dir, vMul(vNorm(v(rand(-1, 1), rand(-1, 1))), dt * 0.02)));
const nx = e.x + e.ai.dir.x * 1.0;
const ny = e.y + e.ai.dir.y * 1.0;
if (Math.abs(nx) > LEVEL_SIZE / 2) e.ai.dir.x *= -1;
if (Math.abs(ny) > LEVEL_SIZE / 2) e.ai.dir.y *= -1;
const tv = vMul(e.ai.dir, 0.25 * e.ai.speed);
e.vx = tv.x + e.ai.accel * dt * (tv.x - e.vx);
e.vy = tv.y + e.ai.accel * dt * (tv.y - e.vy);
}
}
}
function updateCastingAI(dt) {
const ents = Array.from(game.entities.values());
for (const e of ents) {
if (!e.aiCaster || !e.caster) continue;
if (e.aiCaster.unleasher) {
if (e.caster.mana >= e.caster.manaMax) e.aiCaster.rising = false;
if (e.caster.mana < 5.0) e.aiCaster.rising = true;
if (e.aiCaster.rising) continue;
}
let target = null;
let bestDist = Infinity;
for (const o of ents) {
if (o.id === e.id || o.team == null || o.team === e.team || o.projectile) continue;
const d = vDist(v(e.x, e.y), v(o.x, o.y));
if (d < e.aiCaster.acquisitionRange && d < bestDist) {
bestDist = d;
target = o;
}
}
if (target) castSpell(e.id, v(target.x, target.y), e.aiCaster.spell, false, dt);
}
}
function moveEntities(dt) {
for (const e of game.entities.values()) {
e.oldX = e.x; e.oldY = e.y;
e.x += e.vx * dt;
e.y += e.vy * dt;
}
}
function collisions() {
const phys = Array.from(game.entities.values()).filter((e) => Number.isFinite(e.vx) && Number.isFinite(e.vy));
const cols = [];
for (let i = 0; i < phys.length; i++) {
for (let j = 0; j < phys.length; j++) {
if (i === j) continue;
const a = phys[i], b = phys[j];
const pen = collideRects(a, b);
if (pen) cols.push({ subject: a.id, object: b.id, penetration: pen });
}
}
return cols;
}
function fixOverlaps(cols, proportion) {
for (const c of cols) {
const s = game.entities.get(c.subject), o = game.entities.get(c.object);
if (!s || !o) continue;
const omass = o.mass ?? 1;
const smass = s.mass ?? 1;
const sw = smass / Math.max(smass + omass, 1e-6);
s.x += (1.0 - sw) * c.penetration.x * proportion;
s.y += (1.0 - sw) * c.penetration.y * proportion;
}
}
function fixVelocities(dt) {
for (const e of game.entities.values()) {
e.vx = (e.x - e.oldX) / Math.max(dt, 1e-6);
e.vy = (e.y - e.oldY) / Math.max(dt, 1e-6);
}
}
function updateGame(dt) {
game.t += dt;
const p = playerEntity();
if (p) game.lookCenter = v(p.x, p.y);
const triggered = [];
for (let i = 0; i < game.events.length; i++) if (game.events[i].cond()) triggered.push(i);
for (const i of triggered) game.events[i].eff();
triggered.sort((a, b) => b - a);
for (const i of triggered) game.events.splice(i, 1);
if (input.keyDownEvents.has("p")) game.pause = !game.pause;
if (input.keyDownEvents.has("-")) zoom = Math.max(MIN_ZOOM, zoom - 2.0);
if (input.keyDownEvents.has("=")) zoom = Math.min(MAX_ZOOM, zoom + 2.0);
if (input.keyDownEvents.has("m")) {
for (const e of Array.from(game.entities.values())) if (e.team === TEAM_ENEMIES) removeEntity(e.id);
}
if (input.keyDownEvents.has("r")) createGame();
if (!playerEntity()) return;
if (game.spellMenu) {
if (input.lmbUp && game.t > game.spellMenu.clickAfter) {
const y = height * 0.5, h = 120, w = 180, gap = 25;
const left = width * 0.5 - (w * 3 + gap * 2) * 0.5;
for (let i = 0; i < 3; i++) {
const rx = left + i * (w + gap);
if (mouseX >= rx && mouseX <= rx + w && mouseY >= y - h / 2 && mouseY <= y + h / 2) {
const pe = playerEntity();
if (pe && pe.player) pe.player.spellbook.push(game.spellMenu.choices[i]);
game.spellMenu = null;
break;
}
}
}
} else if (!game.pause) {
const pe = playerEntity();
if (pe && pe.player && pe.caster) {
const mv = v(
(input.heldKeys.has("d") ? 1 : 0) - (input.heldKeys.has("a") ? 1 : 0),
(input.heldKeys.has("s") ? 1 : 0) - (input.heldKeys.has("w") ? 1 : 0)
);
const dir = vNorm(mv);
pe.vx = dir.x * pe.player.speed;
pe.vy = dir.y * pe.player.speed;
if (input.keyDownEvents.has("q")) {
pe.player.spellCursor = Math.max(0, pe.player.spellCursor - 1);
pe.caster.lastCast = 0;
}
if (input.keyDownEvents.has("e")) {
pe.player.spellCursor = Math.min(pe.player.spellbook.length - 1, pe.player.spellCursor + 1);
pe.caster.lastCast = 0;
}
const mouseWorld = screenToWorld(mouseX, mouseY);
const sp = pe.player.spellbook[pe.player.spellCursor];
if (sp) {
if (input.lmbDown) castSpell(pe.id, mouseWorld, sp, false, dt);
else if (input.heldLMB) castSpell(pe.id, mouseWorld, sp, true, dt);
}
}
if (pe && pe.player) {
const needMenu = (
pe.player.spellbook.length < 1 ||
(pe.player.kills > 10 && pe.player.spellbook.length < 2) ||
(pe.player.kills > 100 && pe.player.spellbook.length < 3) ||
(pe.player.kills > 300 && pe.player.spellbook.length < 4) ||
(pe.player.kills > 600 && pe.player.spellbook.length < 5)
);
if (needMenu && !game.spellMenu) openSpellMenu();
}
updateMovementAI(dt);
updateCastingAI(dt);
updateEmittersAndParticles(dt);
// Basic homing behavior mirrors the original "ai on projectile" intent.
for (const e of game.entities.values()) {
if (!e.projectile || !e.projectile.homing) continue;
const targets = Array.from(game.entities.values()).filter((o) => o.team != null && o.team !== e.team && !o.projectile);
let best = null, bestDist = Infinity;
for (const t of targets) {
const d = vDist(v(e.x, e.y), v(t.x, t.y));
if (d < bestDist) { bestDist = d; best = t; }
}
if (best) {
const cur = vNorm(v(e.vx, e.vy));
const want = vNorm(vSub(v(best.x, best.y), v(e.x, e.y)));
const blended = vNorm(vAdd(vMul(cur, 0.9), vMul(want, 0.1)));
const speed = Math.max(1, vLen(v(e.vx, e.vy)));
e.vx = blended.x * speed;
e.vy = blended.y * speed;
}
}
moveEntities(dt);
let cols = collisions();
cols = cols.filter((ce) => {
const s = game.entities.get(ce.subject), o = game.entities.get(ce.object);
if (!s || !o || s.team == null || o.team == null) return false;
const sproj = !!s.projectile, oproj = !!o.projectile;
if (s.team === o.team && (sproj || oproj)) return false;
if (sproj && oproj) return false;
return true;
});
const dead = new Set();
const damageEvents = [];
for (const ce of cols) {
const s = game.entities.get(ce.subject), o = game.entities.get(ce.object);
if (!s || !o || !s.projectile) continue;
const proj = s.projectile;
const impact = v(o.x, o.y);
if (proj.aoe > 0) {
for (const t of game.entities.values()) {
if (t.team == null || t.team === s.team || !t.health) continue;
if (vDist(v(t.x, t.y), impact) <= proj.aoe) damageEvents.push({ src: proj.source, target: t.id, amount: proj.damage });
}
} else if (o.health) {
damageEvents.push({ src: proj.source, target: o.id, amount: proj.damage });
}
if (proj.lifestealPercent > 0) {
const src = game.entities.get(proj.source);
if (src && src.health) src.health.current = Math.min(src.health.max, src.health.current + proj.lifestealPercent * proj.damage);
}
dead.add(s.id);
}
for (const ce of cols) {
const s = game.entities.get(ce.subject), o = game.entities.get(ce.object);
if (!s || !o || !s.meleeDamage || s.team == null || o.team == null || s.team === o.team) continue;
damageEvents.push({ src: s.id, target: o.id, amount: s.meleeDamage.amount });
}
for (const de of damageEvents) damageEntity(de.target, de.amount, de.src);
for (const e of game.entities.values()) if (e.expiry != null && e.expiry < game.t) dead.add(e.id);
for (const e of game.entities.values()) if (e.health && e.health.current <= 0) dead.add(e.id);
fixOverlaps(cols, 1.0);
for (const e of game.entities.values()) {
const half = LEVEL_SIZE / 2;
const r = rectSides(e);
let hit = false;
if (r.top < -half) { e.y += -half - r.top; hit = true; }
if (r.bot > half) { e.y += half - r.bot; hit = true; }
if (r.left < -half) { e.x += -half - r.left; hit = true; }
if (r.right > half) { e.x += half - r.right; hit = true; }
if (hit && e.projectile) dead.add(e.id);
}
fixVelocities(dt);
for (const id of dead) {
const e = game.entities.get(id);
if (!e) continue;
if (e.makeOnDeath) e.makeOnDeath(game, e);
if (game.playerId != null && e.team === TEAM_ENEMIES && e.lastHitBy === game.playerId) {
const pe = game.entities.get(game.playerId);
if (pe && pe.player) pe.player.kills += 1;
}
removeEntity(id);
}
for (const e of game.entities.values()) {
if (e.caster) e.caster.mana = Math.min(e.caster.manaMax, e.caster.mana + e.caster.manaRegen * dt);
if (e.health) e.health.current = Math.min(e.health.max, e.health.current + e.health.regen * dt);
}
for (const e of game.entities.values()) {
if (!e.spawnList) continue;
e.spawnList.t += dt;
while (e.spawnList.cursor < e.spawnList.list.length && e.spawnList.list[e.spawnList.cursor].t <= e.spawnList.t) {
const spawned = e.spawnList.list[e.spawnList.cursor].makeFn();
spawned.x += rand(-0.05, 0.05);
spawned.y += rand(-0.05, 0.05);
addEntity(spawned);
e.spawnList.cursor += 1;
}
}
for (let i = 0; i < 4; i++) {
let c2 = collisions().filter((ce) => {
const s = game.entities.get(ce.subject), o = game.entities.get(ce.object);
if (!s || !o || s.team == null || o.team == null) return false;
const sproj = !!s.projectile, oproj = !!o.projectile;
if (s.team === o.team && (sproj || oproj)) return false;
if (sproj && oproj) return false;
return true;
});
fixOverlaps(c2, 0.25);
}
}
}
function drawWorld() {
push();
background(20);
const half = LEVEL_SIZE / 2;
const cell = LEVEL_SIZE / 20;
stroke(40);
strokeWeight(1);
noFill();
for (let i = 0; i <= 20; i++) {
const t = -half + cell * i;
const hA = worldToScreen(-half, t);
const hB = worldToScreen(half, t);
line(hA.x, hA.y, hB.x, hB.y);
const vA = worldToScreen(t, -half);
const vB = worldToScreen(t, half);
line(vA.x, vA.y, vB.x, vB.y);
}
stroke(110);
strokeWeight(2);
const tl = worldToScreen(-half, -half), br = worldToScreen(half, half);
rectMode(CORNERS);
noFill();
rect(tl.x, tl.y, br.x, br.y);
noStroke();
for (const p of game.particles) {
const pp = worldToScreen(p.x, p.y);
fill(p.color[0], p.color[1], p.color[2]);
rectMode(CENTER);
rect(pp.x, pp.y, p.w * zoom, p.h * zoom);
}
for (const e of game.entities.values()) {
const p = worldToScreen(e.x, e.y);
const ww = e.w * zoom, hh = e.h * zoom;
fill(e.color[0], e.color[1], e.color[2]);
rectMode(CENTER);
rect(p.x, p.y, ww, hh);
if (e.health) {
const frac = Math.max(0, e.health.current / e.health.max);
fill(0); rect(p.x, p.y - hh * 0.8, ww * 0.9, 5);
fill(255, 0, 0); rect(p.x - ww * 0.45 * (1 - frac), p.y - hh * 0.8, ww * 0.9 * frac, 5);
}
}
pop();
}
function drawHud() {
push();
rectMode(CORNER);
textAlign(LEFT, TOP);
noStroke();
const pe = playerEntity();
fill(255);
textSize(14);
text("WASD move | Q/E cycle spells | LMB cast | R reset | P pause | M clear enemies | -/+ zoom", 12, 10);
text(`wave: ${game.wave} entities: ${game.entities.size}`, 12, 28);
text(`zoom: ${zoom.toFixed(0)}`, 12, 46);
if (pe && pe.health && pe.caster && pe.player) {
const barW = 220, barH = 16;
fill(0); rect(16, height - 30, barW, barH);
fill(220, 40, 40); rect(16, height - 30, barW * (pe.health.current / pe.health.max), barH);
fill(0); rect(width - 16 - barW, height - 30, barW, barH);
fill(40, 120, 255); rect(width - 16 - barW, height - 30, barW * (pe.caster.mana / pe.caster.manaMax), barH);
fill(255);
textAlign(LEFT, BOTTOM);
text(`HP ${pe.health.current.toFixed(1)} / ${pe.health.max.toFixed(0)}`, 16, height - 34);
textAlign(RIGHT, BOTTOM);
text(`Mana ${pe.caster.mana.toFixed(1)} / ${pe.caster.manaMax.toFixed(0)}`, width - 16, height - 34);
textAlign(CENTER, BOTTOM);
const curSpell = pe.player.spellbook[pe.player.spellCursor] || "(no spell)";
text(`${curSpell} | kills: ${pe.player.kills}`, width / 2, height - 8);
}
if (!pe) {
textAlign(CENTER, CENTER);
textSize(24);
text("You died. Press R to reset.", width / 2, height * 0.75);
}
if (game.pause) {
textAlign(CENTER, CENTER);
textSize(40);
fill(255, 220);
text("PAUSED", width / 2, 70);
}
if (game.spellMenu) {
fill(0, 180);
rect(0, 0, width, height);
const y = height * 0.5, h = 120, w = 180, gap = 25;
const left = width * 0.5 - (w * 3 + gap * 2) * 0.5;
textAlign(CENTER, CENTER);
textSize(20);
fill(255);
text("Choose a spell", width * 0.5, y - 110);
for (let i = 0; i < 3; i++) {
const rx = left + i * (w + gap);
const hover = mouseX >= rx && mouseX <= rx + w && mouseY >= y - h / 2 && mouseY <= y + h / 2;
fill(hover ? 80 : 45);
rect(rx, y - h / 2, w, h, 10);
fill(255);
textSize(16);
text(game.spellMenu.choices[i], rx + w / 2, y);
}
}
pop();
}
function setup() {
createCanvas(windowWidth, windowHeight);
createGame();
}
function draw() {
const dt = Math.min(deltaTime / 1000, 1 / 20);
updateGame(dt);
drawWorld();
drawHud();
input.keyDownEvents.clear();
input.lmbDown = false;
input.lmbUp = false;
}
function windowResized() {
resizeCanvas(windowWidth, windowHeight);
}
function keyPressed() {
const k = key.toLowerCase();
input.heldKeys.add(k);
input.keyDownEvents.add(k);
}
function keyReleased() {
input.heldKeys.delete(key.toLowerCase());
}
function mousePressed() {
input.heldLMB = true;
input.lmbDown = true;
}
function mouseReleased() {
input.heldLMB = false;
input.lmbUp = true;
}
function mouseWheel(event) {
const step = event.delta > 0 ? -2.0 : 2.0;
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom + step));
return false;
}
</script>
</body>
</html>
remixes
no remixes yet... email to remix@gameslop.net
