SOURCE / PINNED RELEASE
Made of little things.
Wizard Duel
- Release
- 48b4ed933a32…
- Author-recorded commit
- 3ed8fcbbf62c…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-86005d75eb4
Archive hash verified: 4196bfb69e536a86…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// The duel itself: two wizards, an arena, flying spells and silly effects.
// The game draws onto a small 320×180 canvas that is scaled up as pixel art.
import { drawArrow, drawSprite, drawText, textWidth, type Dir } from './pixels.js';
import {
CHICKEN,
CHICKEN_PALETTE,
TOAD,
TOAD_PALETTE,
TURTLE,
HAT_ONLY,
ICONS,
WAND,
wizardPalette,
wizardRows,
type WizardColors,
type WizardPose,
} from './sprites.js';
import { sfx, unlockAudio } from './sound.js';
import { FAMILIARS, FAMILIAR_LIFE, FIRE_SPARK, MAX_FAMILIARS, type Familiar, type FamiliarKind } from './familiars.js';
import {
RANDOM_DIR,
RUBBER_CHICKEN,
SPELLS,
STATUS_LABELS,
findSpell,
spellById,
type Spell,
type StatusName,
wildMagic,
} from './spells.js';
export const VIEW_W = 320;
export const VIEW_H = 180;
const FLOOR = 162;
const CEILING = 22;
const WALL_L = 6;
const WALL_R = VIEW_W - 6;
const PLATFORM_THICKNESS = 6;
const PLATFORMS = [
{ x: 36, y: 124, w: 60 },
{ x: 224, y: 124, w: 60 },
{ x: 130, y: 92, w: 60 },
];
const GRAVITY = 520;
const MAX_HP = 100;
const MIN_COMBO = 3;
const MAX_COMBO = 6;
interface Controls {
left: string[];
right: string[];
up: string[];
down: string[];
cast: string[];
}
const PLAYER_SETUP: Array<{ name: string; colors: WizardColors; controls: Controls; startX: number }> = [
{
name: 'Zibble',
colors: { hat: '#8a4bff', hatDark: '#4f2496', band: '#ffd23f', robe: '#7340d8', robeDark: '#45227f' },
controls: {
left: ['KeyA', 'Pad1Left'],
right: ['KeyD', 'Pad1Right'],
up: ['KeyW', 'Pad1Up'],
down: ['KeyS', 'Pad1Down'],
cast: ['Space', 'ShiftLeft', 'KeyQ', 'Pad1Cast'],
},
startX: 60,
},
{
name: 'Fumblewick',
colors: { hat: '#ff7a2f', hatDark: '#a53d10', band: '#5ad1c4', robe: '#e0582b', robeDark: '#8f2f14' },
controls: {
left: ['ArrowLeft', 'Pad2Left'],
right: ['ArrowRight', 'Pad2Right'],
up: ['ArrowUp', 'Pad2Up'],
down: ['ArrowDown', 'Pad2Down'],
cast: ['Enter', 'NumpadEnter', 'ShiftRight', 'ControlRight', 'Numpad0', 'Pad2Cast'],
},
startX: 260,
},
];
/** Keyboard keys that pause or unpause a fight (controllers use Start). */
const PAUSE_KEYS = ['Escape', 'KeyP'];
export const GAME_KEYS = new Set([...PLAYER_SETUP.flatMap((p) => Object.values(p.controls).flat()), ...PAUSE_KEYS]);
export class Wizard {
x = 0;
y = 0;
vx = 0;
vy = 0;
facing: 1 | -1 = 1;
hp = MAX_HP;
wins = 0;
onGround = false;
dropTimer = 0;
casting = false;
castLocked = false;
combo: Dir[] = [];
cooldown = 0;
anim = 0;
flash = 0;
jumpQueued = false;
/** Puppet Strings: the wizard whose movement keys control this one. */
puppeteer: Wizard | null = null;
/** Which way Dance Fever is shuffling: -1 left, 1 right, 0 on the spot. */
danceMove = 0;
readonly status = new Map<StatusName, number>();
readonly timers = new Map<string, number>();
readonly palette: Record<string, string>;
constructor(
readonly index: number,
readonly name: string,
readonly colors: WizardColors,
readonly controls: Controls,
readonly startX: number,
) {
this.palette = wizardPalette(colors);
}
reset(): void {
this.x = this.startX;
this.y = FLOOR - 8;
this.vx = 0;
this.vy = 0;
this.facing = this.index === 0 ? 1 : -1;
this.hp = MAX_HP;
this.casting = false;
this.castLocked = false;
this.combo = [];
this.cooldown = 0;
this.flash = 0;
this.status.clear();
this.timers.clear();
this.puppeteer = null;
}
has(name: StatusName): boolean {
return (this.status.get(name) ?? 0) > 0;
}
get halfW(): number {
if (this.has('pancake')) return 8;
return this.has('tiny') ? 3 : 4;
}
get halfH(): number {
if (this.has('pancake')) return 3;
return this.has('tiny') ? 4 : 8;
}
/** 1 when gravity pulls down, -1 when it pulls up. */
gravitySign(): 1 | -1 {
let sign: 1 | -1 = this.has('revgrav') ? -1 : 1;
if (this.has('balloon')) sign = sign === 1 ? -1 : 1;
if (this.has('bubble')) sign = sign === 1 ? -1 : 1;
return sign;
}
canMove(): boolean {
return !this.has('frozen') && !this.has('stun') && !this.has('disco') && !this.has('tripped');
}
canCast(): boolean {
return this.canMove() && !this.has('chicken') && !this.has('toad');
}
}
interface Projectile {
x: number;
y: number;
vx: number;
vy: number;
spell: Spell;
owner: Wizard;
target: Wizard;
life: number;
/** Boomerang Curse: seconds until the spell turns around and flies home. */
returnAfter?: number;
/** Falling things (rubber chickens) speed up downwards. */
gravity?: number;
}
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
life: number;
maxLife: number;
color: string;
gravity: number;
size: number;
}
interface FloatText {
x: number;
y: number;
text: string;
color: string;
life: number;
scale: number;
}
type Phase = 'title' | 'fight' | 'ko';
export class Duel {
readonly wizards: Wizard[];
private readonly ctx: CanvasRenderingContext2D;
private readonly background: HTMLCanvasElement;
private readonly keys = new Set<string>();
private projectiles: Projectile[] = [];
private particles: Particle[] = [];
private texts: FloatText[] = [];
private familiars: Familiar[] = [];
private phase: Phase = 'title';
private phaseTime = 0;
private paused = false;
private time = 0;
private shakeTime = 0;
private winner: Wizard | null = null;
private lastFrame = 0;
screenShake = true;
touchOnly = false;
/** Which players have a game controller plugged in (shown on the title screen). */
readonly padConnected: [boolean, boolean] = [false, false];
/** Runs at the start of every frame, before the game updates (used to read controllers). */
beforeUpdate: (() => void) | null = null;
constructor(canvas: HTMLCanvasElement) {
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D is not available');
this.ctx = ctx;
canvas.width = VIEW_W;
canvas.height = VIEW_H;
this.wizards = PLAYER_SETUP.map(
(setup, index) => new Wizard(index, setup.name, setup.colors, setup.controls, setup.startX),
);
this.background = buildBackground();
for (const wizard of this.wizards) wizard.reset();
}
start(): void {
const frame = (now: number) => {
const dt = this.lastFrame ? Math.min(0.05, (now - this.lastFrame) / 1000) : 0;
this.lastFrame = now;
this.beforeUpdate?.();
this.update(dt);
this.draw();
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
}
// ---------- input ----------
keyDown(code: string, repeat: boolean): void {
unlockAudio();
if (repeat) return;
if (PAUSE_KEYS.includes(code)) {
this.pausePressed();
return;
}
if (this.paused) return;
this.keys.add(code);
if (this.phase === 'title') {
if (this.isAnyCastKey(code)) this.startRound();
return;
}
if (this.phase === 'ko') {
if (this.isAnyCastKey(code) && this.phaseTime > 1.5) this.startRound();
return;
}
for (const wizard of this.wizards) {
// Start casting right away so a very quick first tap still counts.
if (wizard.controls.cast.includes(code) && this.canBeginCast(wizard)) this.beginCast(wizard);
const dir = this.dirForKey(wizard, code);
if (dir && this.holdingCast(wizard) && !wizard.casting && this.canBeginCast(wizard)) this.beginCast(wizard);
if (dir && wizard.casting) {
this.addRune(wizard, dir);
} else if (dir && this.holdingCast(wizard)) {
// Holding the cast key means arrows are runes, never movement.
} else if (dir === 'up') {
wizard.jumpQueued = true;
// Puppet Strings: the puppet jumps when its puppeteer does.
for (const puppet of this.wizards) {
if (puppet !== wizard && puppet.has('puppet') && puppet.puppeteer === wizard) puppet.jumpQueued = true;
}
} else if (dir === 'down' && wizard.onGround && wizard.gravitySign() === 1) {
wizard.dropTimer = 0.25;
}
}
}
keyUp(code: string): void {
this.keys.delete(code);
if (this.phase !== 'fight') return;
for (const wizard of this.wizards) {
if (!wizard.controls.cast.includes(code)) continue;
if (this.holdingCast(wizard)) continue;
wizard.castLocked = false;
if (wizard.casting) this.releaseCast(wizard);
}
}
/** Called when the game loses keyboard focus: forget held keys, drop half-typed spells. */
releaseAll(): void {
this.keys.clear();
for (const wizard of this.wizards) {
wizard.casting = false;
wizard.combo = [];
wizard.castLocked = false;
}
}
pointerStart(): void {
unlockAudio();
if (this.phase === 'title' || (this.phase === 'ko' && this.phaseTime > 1.5)) this.startRound();
}
/** Start button, Esc or P: pause or unpause a fight. */
pausePressed(): void {
unlockAudio();
if (this.phase !== 'fight') return;
this.paused = !this.paused;
// Forget held buttons so nobody keeps running or typing runes when the duel comes back.
if (this.paused) this.releaseAll();
}
/** A player's controller was unplugged: drop their half-typed spell and say so over their wizard. */
padUnplugged(player: number): void {
const wizard = this.wizards[player];
if (!wizard) return;
wizard.casting = false;
wizard.combo = [];
if (this.phase === 'fight') this.addText(wizard.x, wizard.y - 30, 'CONTROLLER UNPLUGGED', '#ff9f5a');
}
private isAnyCastKey(code: string): boolean {
return this.wizards.some((w) => w.controls.cast.includes(code));
}
private holdingCast(wizard: Wizard): boolean {
return wizard.controls.cast.some((code) => this.keys.has(code));
}
private held(codes: string[]): boolean {
return codes.some((code) => this.keys.has(code));
}
private dirForKey(wizard: Wizard, code: string): Dir | null {
const c = wizard.controls;
if (c.up.includes(code)) return 'up';
if (c.down.includes(code)) return 'down';
if (c.left.includes(code)) return 'left';
if (c.right.includes(code)) return 'right';
return null;
}
// ---------- round flow ----------
private startRound(): void {
this.paused = false;
for (const wizard of this.wizards) {
wizard.reset();
// A cast key used to start the round must be let go before casting.
wizard.castLocked = this.holdingCast(wizard);
}
this.projectiles = [];
this.particles = [];
this.texts = [];
this.familiars = [];
this.winner = null;
this.phase = 'fight';
this.phaseTime = 0;
this.addText(VIEW_W / 2, 70, 'DUEL!', '#ffd23f', 2);
sfx.cast();
}
// ---------- casting ----------
private canBeginCast(wizard: Wizard): boolean {
return !wizard.casting && !wizard.castLocked && wizard.cooldown <= 0 && wizard.canCast();
}
private beginCast(wizard: Wizard): void {
wizard.casting = true;
wizard.combo = [];
}
private addRune(wizard: Wizard, pressed: Dir): void {
let dir = pressed;
if (wizard.has('drunk') && (dir === 'left' || dir === 'right')) dir = dir === 'left' ? 'right' : 'left';
if (wizard.has('butter')) dir = RANDOM_DIR();
if (wizard.combo.length >= MAX_COMBO + 2) return;
wizard.combo.push(dir);
sfx.rune(dir, wizard.combo.length);
this.burst(wizard.x, wizard.y - wizard.halfH - 10, wizard.colors.band, 3, 30);
}
private cancelCast(wizard: Wizard, message: string): void {
if (wizard.combo.length > 0) {
this.addText(wizard.x, wizard.y - 22, message, '#bbbbbb');
sfx.fizzle();
}
wizard.casting = false;
wizard.combo = [];
wizard.castLocked = true;
}
private releaseCast(wizard: Wizard): void {
const combo = wizard.combo;
wizard.casting = false;
wizard.combo = [];
if (combo.length === 0) return;
wizard.cooldown = wizard.has('sleepy') ? 1.0 : 0.45;
if (combo.length < MIN_COMBO) {
this.addText(wizard.x, wizard.y - 22, 'FIZZLE...', '#9a8fb0');
this.burst(wizard.x + wizard.facing * 6, wizard.y - 4, '#9a8fb0', 8, 40);
sfx.fizzle();
return;
}
const known = combo.length <= MAX_COMBO ? findSpell(combo) : undefined;
if (known) {
this.shout(wizard, `${known.name}!`, known.colors[0]);
if (known.familiar) this.summon(wizard, known.familiar);
else this.fire(wizard, known);
return;
}
// Unknown runes: wild magic. The same runes always make the same spell,
// and always backfire (or not), so players can learn these too.
const overload = combo.length > MAX_COMBO;
const { spell, backfire } = wildMagic(combo);
if (spell.familiar) {
// Wild summons work, but a backfire sends the pet to the other wizard!
this.shout(wizard, backfire ? 'BACKFIRE! WRONG WIZARD!' : `WILD MAGIC: ${spell.name}!`, backfire ? '#ff5a5a' : '#ff9cf5');
this.summon(backfire ? this.wizards[1 - wizard.index] : wizard, spell.familiar);
} else if (backfire) {
this.shout(wizard, overload ? 'OVERLOAD! BACKFIRE!' : 'BACKFIRE!', '#ff5a5a');
this.addText(wizard.x, wizard.y - 30, spell.name, spell.colors[0]);
this.burst(wizard.x, wizard.y, spell.colors[0], 16, 80);
this.applySpell(spell, wizard, wizard);
} else {
this.shout(wizard, `WILD MAGIC: ${spell.name}!`, '#ff9cf5');
this.fire(wizard, spell);
}
}
private fire(caster: Wizard, spell: Spell): void {
const target = this.wizards[1 - caster.index];
caster.facing = target.x >= caster.x ? 1 : -1;
const startX = caster.x + caster.facing * 8;
const startY = caster.y - 2;
let angle = Math.atan2(target.y - startY, target.x - startX);
if (caster.has('pie')) angle += (Math.random() - 0.5) * 1.6;
// Wet Noodle Arms: spells come out really slowly (and last longer to make up for it).
const floppy = caster.has('noodlearms');
const speed = spell.speed * (floppy ? 0.4 : 1);
// Boomerang Curse: this spell turns around and comes back for the caster.
const cursed = caster.has('boomerang');
if (cursed) {
caster.status.delete('boomerang');
this.addText(caster.x, caster.y - 26, 'BOOMERANG!', '#b07bff');
}
this.projectiles.push({
x: startX,
y: startY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
spell,
owner: caster,
target: cursed ? caster : target,
life: floppy ? 7 : cursed ? 4 : 3,
returnAfter: cursed ? 0.45 : undefined,
});
sfx.cast();
this.burst(startX, startY, spell.colors[1], 10, 60);
}
// ---------- familiars ----------
private summon(owner: Wizard, kind: FamiliarKind): void {
const mine = this.familiars.filter((f) => f.owner === owner);
if (mine.length >= MAX_FAMILIARS) this.dismiss(mine[0]);
const info = FAMILIARS[kind];
const x = owner.x - owner.facing * 10;
const y = owner.y - 12 * owner.gravitySign();
this.familiars.push({ kind, owner, x, y, life: FAMILIAR_LIFE, timer: info.every });
sfx.summon();
this.burst(x, y, info.palette[Object.keys(info.palette)[0]], 16, 70);
this.addText(x, y - 10, info.hello, '#ffffff');
}
/** A familiar poofs away in a puff of sparkles. */
private dismiss(f: Familiar): void {
f.life = 0;
this.familiars = this.familiars.filter((other) => other !== f);
this.burst(f.x, f.y, '#ffffff', 10, 50);
sfx.pop();
}
private familiarOf(w: Wizard, kind: FamiliarKind): Familiar | undefined {
return this.familiars.find((f) => f.owner === w && f.kind === kind);
}
// ---------- spell effects ----------
heal(wizard: Wizard, amount: number): void {
wizard.hp = Math.min(MAX_HP, wizard.hp + amount);
this.addText(wizard.x, wizard.y - 14, `+${amount}`, '#6bff8f');
}
private damage(wizard: Wizard, amount: number): void {
if (amount <= 0 || this.phase !== 'fight') return;
wizard.hp = Math.max(0, wizard.hp - amount);
wizard.flash = 0.2;
this.addText(wizard.x + (Math.random() - 0.5) * 8, wizard.y - 12, `-${amount}`, '#ff5a5a');
if (wizard.hp <= 0) this.knockOut(wizard);
}
private applySpell(spell: Spell, target: Wizard, caster: Wizard): void {
const oldHalfH = target.halfH;
if (spell.status) {
const [name, seconds] = spell.status;
target.status.set(name, Math.max(target.status.get(name) ?? 0, seconds));
}
spell.apply?.(target, caster, this);
// Shrinking or squashing keeps the feet on the ground.
target.y += (oldHalfH - target.halfH) * target.gravitySign();
if (target.casting && !target.canCast()) this.cancelCast(target, 'INTERRUPTED!');
spell.sound();
this.addText(target.x, target.y - 26, spell.hitText, spell.colors[0]);
this.shake(spell.damage >= 10 ? 0.3 : 0.12);
this.damage(target, spell.damage);
}
private explodePotato(w: Wizard): void {
sfx.boom();
this.burst(w.x, w.y, '#ff5a1a', 30, 140);
this.burst(w.x, w.y, '#ffd23f', 20, 100);
this.addText(w.x, w.y - 26, 'KABOOM!', '#ff5a1a', 2);
this.shake(0.5);
w.vy = -220 * w.gravitySign();
this.damage(w, 15);
}
private knockOut(loser: Wizard): void {
this.phase = 'ko';
this.phaseTime = 0;
this.winner = this.wizards[1 - loser.index];
this.winner.wins += 1;
this.projectiles = [];
for (const f of this.familiars.filter((f) => f.owner === loser)) this.dismiss(f);
for (const wizard of this.wizards) {
wizard.casting = false;
wizard.combo = [];
}
this.burst(loser.x, loser.y, loser.colors.hat, 40, 120);
this.shake(0.5);
sfx.ko();
sfx.win();
}
// ---------- update ----------
private update(dt: number): void {
this.time += dt;
if (this.paused) return;
this.phaseTime += dt;
this.shakeTime = Math.max(0, this.shakeTime - dt);
if (this.phase === 'fight') {
for (const wizard of this.wizards) this.updateWizard(wizard, dt);
this.updateFamiliars(dt);
this.updateProjectiles(dt);
} else if (this.phase === 'ko' && this.winner) {
this.winner.anim += dt;
if (Math.random() < dt * 6) {
this.burst(this.winner.x + (Math.random() - 0.5) * 40, this.winner.y - 20, randomSparkle(), 6, 50);
}
}
this.updateParticles(dt);
}
private tickTimer(wizard: Wizard, key: string, dt: number, every: () => number): boolean {
const left = (wizard.timers.get(key) ?? every()) - dt;
if (left <= 0) {
wizard.timers.set(key, every());
return true;
}
wizard.timers.set(key, left);
return false;
}
private updateWizard(w: Wizard, dt: number): void {
w.anim += dt;
w.flash = Math.max(0, w.flash - dt);
w.cooldown = Math.max(0, w.cooldown - dt);
w.dropTimer = Math.max(0, w.dropTimer - dt);
const oldHalfH = w.halfH;
for (const [name, left] of w.status) {
const next = left - dt;
if (next > 0) {
w.status.set(name, next);
continue;
}
w.status.delete(name);
w.timers.delete(name);
if (name === 'balloon') {
sfx.pop();
this.burst(w.x, w.y - 20, '#ff6b6b', 10, 60);
this.addText(w.x, w.y - 26, 'POP!', '#ff6b6b');
}
if (name === 'potato') this.explodePotato(w);
if (name === 'bubble') {
sfx.pop();
this.burst(w.x, w.y, '#9fe8ff', 14, 70);
this.addText(w.x, w.y - 26, 'POP!', '#9fe8ff');
}
if (name === 'frozen') this.burst(w.x, w.y, '#9fe8ff', 12, 70);
}
w.y += (oldHalfH - w.halfH) * w.gravitySign();
// Start casting when the cast key is held and the wizard is able to.
if (this.canBeginCast(w) && this.holdingCast(w)) {
this.beginCast(w);
}
if (w.casting && !w.canCast()) this.cancelCast(w, 'INTERRUPTED!');
this.updateSillyEffects(w, dt);
// ---- movement ----
const sign = w.gravitySign();
let input = 0;
if (this.held(w.controls.right)) input += 1;
if (this.held(w.controls.left)) input -= 1;
if (w.has('drunk')) input = -input;
if (!w.canMove() || w.casting) input = 0;
// Dance Fever takes over the feet, even while casting.
if (w.has('dance') && w.canMove()) input = w.danceMove;
// Scratching fleas or floating in a bubble: the feet don't help.
if (w.has('scratching') || w.has('bubble')) input = 0;
// Puppet Strings: the other wizard's movement keys walk this one around.
const puppeteer = w.has('puppet') ? w.puppeteer : null;
if (puppeteer && puppeteer !== w && w.canMove()) {
input = puppeteer.casting ? 0 : (this.held(puppeteer.controls.right) ? 1 : 0) - (this.held(puppeteer.controls.left) ? 1 : 0);
}
if (input !== 0 && !w.has('disco')) w.facing = input > 0 ? 1 : -1;
let speed = 78;
if (w.has('sleepy')) speed *= 0.4;
if (w.has('tiny')) speed *= 0.75;
if (w.has('rain')) speed *= 0.8;
if (w.has('mustache')) speed *= 0.85;
if (w.has('snail')) speed *= 0.35;
if (w.has('noodle')) speed *= 0.75;
if (w.has('turtle')) speed *= 0.5;
if (w.has('pancake')) speed *= 0.6;
if (w.has('hyper')) speed *= 2.6;
let target = input * speed;
if (w.has('hyper') && w.canMove() && !w.casting && input === 0) target = w.facing * speed;
if (w.has('drunk')) target += Math.sin(w.anim * 3.1) * 30;
if (w.has('toad')) {
// Toads can't walk, they hop.
if (w.onGround && input !== 0 && w.vy * sign >= 0) {
w.vx = input * 100;
w.vy = -170 * sign;
sfx.croak();
}
target = w.onGround ? 0 : w.vx;
}
if (w.has('glue') && w.onGround) {
target = 0;
w.vx = 0;
}
let accel = w.has('slippery') ? 110 : w.onGround ? 900 : 450;
// Slime: speeding up works, but slowing down or turning is a long slide.
const slowingDown = Math.abs(target) < Math.abs(w.vx) || Math.sign(target) !== Math.sign(w.vx);
if (w.has('slimy') && w.onGround && slowingDown) accel = 60;
// Turtle shell: slides around like a hockey puck.
if (w.has('turtle')) accel = slowingDown ? 25 : 150;
if (w.has('bubble')) accel = 60;
const diff = target - w.vx;
w.vx += Math.sign(diff) * Math.min(Math.abs(diff), accel * dt);
if (w.jumpQueued) {
w.jumpQueued = false;
if (w.canMove() && !w.casting) {
if (w.has('chicken')) {
w.vy = -150 * sign;
sfx.cluck();
} else if (w.onGround && !w.has('glue') && !w.has('turtle') && !w.has('bubble') && !w.has('pancake')) {
let jump = 235;
if (w.has('tiny')) jump *= 0.8;
if (w.has('sleepy')) jump *= 0.7;
if (w.has('snail')) jump *= 0.6;
w.vy = -jump * sign;
}
}
}
let gravity = GRAVITY;
if (w.has('lowgrav')) gravity *= 0.3;
if (w.has('bighead')) gravity *= 1.35;
if (w.has('balloon')) gravity *= 0.2;
if (w.has('bubble')) gravity *= 0.15;
w.vy += gravity * sign * dt;
w.vy = Math.max(-420, Math.min(420, w.vy));
const prevTop = w.y - w.halfH;
const prevBottom = w.y + w.halfH;
w.x += w.vx * dt;
w.y += w.vy * dt;
this.collide(w, prevTop, prevBottom, sign);
}
private collide(w: Wizard, prevTop: number, prevBottom: number, sign: 1 | -1): void {
const bounce = w.has('bouncy');
if (w.x - w.halfW < WALL_L) {
w.x = WALL_L + w.halfW;
w.vx = bounce || w.has('slippery') || w.has('turtle') ? Math.abs(w.vx) * 0.8 : 0;
}
if (w.x + w.halfW > WALL_R) {
w.x = WALL_R - w.halfW;
w.vx = bounce || w.has('slippery') || w.has('turtle') ? -Math.abs(w.vx) * 0.8 : 0;
}
w.onGround = false;
const land = (surfaceIsGround: boolean) => {
if (bounce && Math.abs(w.vy) > 70) {
w.vy = -w.vy * 0.85;
if (Math.abs(w.vy) > 120) sfx.boing();
} else {
w.vy = 0;
}
if (surfaceIsGround) w.onGround = true;
};
if (w.y + w.halfH >= FLOOR && w.vy >= 0) {
w.y = FLOOR - w.halfH;
land(sign === 1);
}
if (w.y - w.halfH <= CEILING && w.vy <= 0) {
w.y = CEILING + w.halfH;
land(sign === -1);
}
for (const p of PLATFORMS) {
if (w.x + w.halfW < p.x || w.x - w.halfW > p.x + p.w) continue;
const top = w.y - w.halfH;
const bottom = w.y + w.halfH;
if (sign === 1 && w.vy >= 0 && w.dropTimer <= 0 && prevBottom <= p.y + 1 && bottom >= p.y) {
w.y = p.y - w.halfH;
land(true);
} else if (sign === -1 && w.vy <= 0 && prevTop >= p.y + PLATFORM_THICKNESS - 1 && top <= p.y + PLATFORM_THICKNESS) {
w.y = p.y + PLATFORM_THICKNESS + w.halfH;
land(true);
}
}
}
/** Everything the funny spells do over time: sneezes, hiccups, whistles... */
private updateSillyEffects(w: Wizard, dt: number): void {
const sign = w.gravitySign();
if (w.has('burning')) {
if (Math.random() < dt * 30) this.spark(w.x + (Math.random() - 0.5) * 8, w.y + 4, Math.random() < 0.5 ? '#ff7a1a' : '#ffd23f', -40);
if (this.tickTimer(w, 'burning', dt, () => 0.5)) this.damage(w, 1);
}
if (w.has('hiccups') && this.tickTimer(w, 'hiccups', dt, () => 0.6 + Math.random() * 0.7)) {
w.vy = -150 * sign;
sfx.hic();
this.addText(w.x, w.y - 20, 'HIC!', '#ff9fd0');
}
if (w.has('sneeze') && this.tickTimer(w, 'sneeze', dt, () => 1.1 + Math.random() * 0.6)) {
w.vx = -w.facing * 260;
w.vy = -110 * sign;
sfx.sneeze();
this.addText(w.x, w.y - 20, 'ACHOO!', '#b8f2a0');
this.burst(w.x + w.facing * 6, w.y - 6, '#d8ffd0', 10, 90);
}
if (w.has('whistle') && this.tickTimer(w, 'whistle', dt, () => 1.2)) {
sfx.whistle();
this.addText(w.x, w.y - 20, 'TWEEE!', '#e0f7ff');
// Whistling nose makes it hard to concentrate: a stray rune sneaks in.
if (w.casting) this.addRune(w, RANDOM_DIR());
}
if (w.has('whistle') && Math.random() < dt * 4) {
this.spark(w.x + w.facing * 5, w.y - 8, '#e0f7ff', -25, 1.2);
}
if (w.has('rain')) {
if (Math.random() < dt * 25) this.spark(w.x + (Math.random() - 0.5) * 12, w.y - w.halfH - 16 * sign, '#4aa3ff', 0, 0.6, 140 * sign);
if (this.tickTimer(w, 'rain', dt, () => 0.7)) this.damage(w, 1);
}
if (w.has('sleepy') && this.tickTimer(w, 'sleepy', dt, () => 1.4)) {
sfx.snore();
this.addText(w.x + 6, w.y - 18, 'Z', '#a8b8ff');
}
if (w.has('chicken') && w.onGround && this.tickTimer(w, 'chicken', dt, () => 0.8 + Math.random())) {
w.vy = -110 * sign;
sfx.cluck();
}
if (w.has('disco')) {
if (this.tickTimer(w, 'disco', dt, () => 0.25)) w.facing = w.facing === 1 ? -1 : 1;
if (this.tickTimer(w, 'discoBeat', dt, () => 1)) sfx.disco();
if (Math.random() < dt * 20) this.spark(w.x + (Math.random() - 0.5) * 30, w.y - Math.random() * 20, randomSparkle(), 0, 0.3, 0);
}
if (w.has('hyper') && Math.random() < dt * 30) {
this.spark(w.x - w.facing * 6, w.y + (Math.random() - 0.5) * 12, '#7dff6b', 0, 0.25, 0);
}
if (w.has('lowgrav') && Math.random() < dt * 6) {
this.spark(w.x + (Math.random() - 0.5) * 12, w.y + 8, '#c9b6ff', -15, 1);
}
if (w.has('dance')) {
if (this.tickTimer(w, 'dance', dt, () => 0.35)) {
const move = Math.floor(Math.random() * 4);
w.danceMove = move === 0 ? -1 : move === 1 ? 1 : 0;
if (move === 2 && w.onGround) w.vy = -170 * w.gravitySign();
if (move === 3) w.facing = w.facing === 1 ? -1 : 1;
}
if (this.tickTimer(w, 'danceBeat', dt, () => 0.9)) sfx.dance();
if (Math.random() < dt * 5) this.spark(w.x + (Math.random() - 0.5) * 14, w.y - 10, Math.random() < 0.5 ? '#ffd23f' : '#ff4af0', -30, 0.8);
}
if (w.has('slimy')) {
if (Math.random() < dt * 6) this.spark(w.x + (Math.random() - 0.5) * 8, w.y + 2, '#5dff8a', 0, 0.8, 90);
// A slime trail is left behind while sliding.
if (w.onGround && Math.abs(w.vx) > 20 && Math.random() < dt * 30) {
this.spark(w.x, w.y + w.halfH * w.gravitySign() - (w.gravitySign() === 1 ? 1 : 0), '#1f9e4a', 0, 1.2, 0);
}
}
const walking = w.onGround && Math.abs(w.vx) > 25;
if (w.has('clown') && walking && this.tickTimer(w, 'clown', dt, () => 0.9 + Math.random() * 0.8)) {
w.status.set('tripped', 0.6);
w.vx *= 0.3;
sfx.honk();
this.addText(w.x, w.y - 20, 'OOF!', '#ff3a3a');
}
if (w.has('opera')) {
if (this.tickTimer(w, 'opera', dt, () => 1.8)) {
sfx.opera();
const songs = ['LA LA LAAA!', 'FIGARO!', 'MI MI MIII!', 'OH SOLE MIO!'];
this.addText(w.x, w.y - 20, songs[Math.floor(Math.random() * songs.length)], '#ff9cf5');
}
if (Math.random() < dt * 6) this.spark(w.x + w.facing * 4, w.y - 6, Math.random() < 0.5 ? '#ff9cf5' : '#ffffff', -30, 1);
}
if (w.has('squeaky') && walking && this.tickTimer(w, 'squeaky', dt, () => 0.28)) sfx.squeak();
if (w.has('invisible') && Math.random() < dt * 4) {
this.spark(w.x + (Math.random() - 0.5) * 10, w.y + (Math.random() - 0.5) * 14, '#e8f0ff', 0, 0.4, 0);
}
if (w.has('pogo') && w.onGround && !w.has('turtle')) {
w.vy = -210 * sign;
sfx.boing();
}
if (w.has('noodle') && walking && this.tickTimer(w, 'noodle', dt, () => 0.6 + Math.random() * 0.6)) {
w.vx = (Math.random() - 0.5) * 220;
w.vy = -80 * sign;
this.addText(w.x, w.y - 20, 'WOBBLE!', '#ffe08a');
}
if (w.has('fleas')) {
if (this.tickTimer(w, 'fleas', dt, () => 1 + Math.random() * 0.7)) {
w.status.set('scratching', 0.7);
sfx.scratch();
this.addText(w.x, w.y - 20, 'ITCHY!', '#e0b080');
}
if (Math.random() < dt * 10) this.spark(w.x + (Math.random() - 0.5) * 12, w.y + (Math.random() - 0.5) * 12, '#120a1f', -50, 0.4, 250);
}
if (w.has('snail') && walking && Math.random() < dt * 20) {
this.spark(w.x, w.y + w.halfH * sign - (sign === 1 ? 1 : 0), '#b8f2a0', 0, 1, 0);
}
// A bubble pops when it bumps the ceiling.
if (w.has('bubble') && w.y - w.halfH <= CEILING + 1) w.status.set('bubble', 0.001);
if (w.has('bees')) {
if (this.tickTimer(w, 'bees', dt, () => 0.5)) this.damage(w, 1);
if (this.tickTimer(w, 'beeBuzz', dt, () => 1.2)) {
sfx.buzz();
if (Math.random() < 0.5) this.addText(w.x, w.y - 20, 'OW!', '#ffd23f');
}
}
if (w.has('noodlearms') && Math.random() < dt * 4) {
this.spark(w.x + w.facing * 5, w.y, '#ffe08a', 0, 0.6, 120);
}
if (w.has('chickrain') && this.tickTimer(w, 'chickrain', dt, () => 0.55)) {
// A rubber chicken drops from the sky right above the wizard. Dodge it!
this.projectiles.push({
x: w.x + (Math.random() - 0.5) * 30,
y: CEILING + 3,
vx: 0,
vy: 20,
spell: RUBBER_CHICKEN,
owner: this.wizards[1 - w.index],
target: w,
life: 3,
gravity: 260,
});
}
if (w.has('potato')) {
const left = w.status.get('potato') ?? 0;
w.timers.set('potatoHeld', (w.timers.get('potatoHeld') ?? 0) + dt);
if (Math.random() < dt * (12 + (3 - left) * 15)) this.spark(w.x + w.facing * 6, w.y - 2, Math.random() < 0.5 ? '#ff5a1a' : '#ffd23f', -40, 0.5);
if (this.tickTimer(w, 'potatoCount', dt, () => 1)) this.addText(w.x, w.y - 20, `${Math.max(1, Math.ceil(left))}...`, '#ff5a1a');
// Bump into the other wizard to pass the potato to them!
const other = this.wizards[1 - w.index];
const touching = Math.abs(other.x - w.x) < 12 && Math.abs(other.y - w.y) < 14;
if (touching && !other.has('potato') && (w.timers.get('potatoHeld') ?? 0) > 0.4) {
other.status.set('potato', Math.max(left, 1) + 0.3);
other.timers.set('potatoHeld', 0);
w.status.delete('potato');
w.timers.delete('potatoCount');
const away = other.x >= w.x ? 1 : -1;
w.vx = -away * 150;
other.vx = away * 150;
sfx.pop();
this.addText(other.x, other.y - 26, 'PASSED!', '#ffd23f');
}
}
if (w.has('butter') && Math.random() < dt * 5) {
this.spark(w.x + w.facing * 5, w.y + 2, '#ffe08a', 0, 0.6, 120);
}
}
private updateFamiliars(dt: number): void {
for (const owner of this.wizards) {
const mine = this.familiars.filter((f) => f.owner === owner);
mine.forEach((f, slot) => {
// Float behind the wizard in a little line, bobbing up and down.
const sign = owner.gravitySign();
const bob = Math.sin(this.time * 4 + slot * 1.7) * 2;
const tx = owner.x - owner.facing * (11 + slot * 9);
const ty = owner.y - (10 + bob) * sign;
const follow = Math.min(1, dt * (7 - slot * 1.5));
f.x += (tx - f.x) * follow;
f.y += (ty - f.y) * follow;
f.x = Math.max(WALL_L + 3, Math.min(WALL_R - 3, f.x));
f.life -= dt;
if (f.life <= 0) {
this.dismiss(f);
return;
}
if (FAMILIARS[f.kind].every <= 0) return;
f.timer -= dt;
if (f.timer > 0) return;
f.timer = FAMILIARS[f.kind].every;
this.useFamiliarPower(f);
});
}
}
/** Each familiar's one special thing (the Guard Owl's is in updateProjectiles). */
private useFamiliarPower(f: Familiar): void {
const owner = f.owner;
const enemy = this.wizards[1 - owner.index];
if (f.kind === 'fairy') {
if (owner.hp >= MAX_HP) return;
this.heal(owner, 2);
this.burst(owner.x, owner.y - 4, '#ff9cf5', 6, 30);
} else if (f.kind === 'imp') {
const angle = Math.atan2(enemy.y - f.y, enemy.x - f.x);
this.projectiles.push({
x: f.x,
y: f.y,
vx: Math.cos(angle) * FIRE_SPARK.speed,
vy: Math.sin(angle) * FIRE_SPARK.speed,
spell: FIRE_SPARK,
owner,
target: enemy,
life: 3,
});
sfx.fire();
}
}
private updateProjectiles(dt: number): void {
for (const p of this.projectiles) {
if (p.gravity) p.vy += p.gravity * dt;
if (p.returnAfter !== undefined) {
p.returnAfter -= dt;
if (p.returnAfter <= 0) {
// Fly back home to the cursed caster.
const speed = Math.hypot(p.vx, p.vy);
const angle = Math.atan2(p.target.y - p.y, p.target.x - p.x);
p.vx = Math.cos(angle) * speed;
p.vy = Math.sin(angle) * speed;
}
}
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt;
if (Math.random() < dt * 40) this.spark(p.x, p.y, p.spell.colors[Math.random() < 0.5 ? 0 : 1], 0, 0.35, 0);
const t = p.target;
const hair = t.has('afro') ? 6 : 0;
const outbound = p.returnAfter !== undefined && p.returnAfter > 0;
const owl = p.owner !== t ? this.familiarOf(t, 'owl') : undefined;
if (owl && !outbound && Math.abs(p.x - t.x) <= t.halfW + 14 && Math.abs(p.y - t.y) <= t.halfH + 14) {
// The Guard Owl swoops in and catches the spell, then flies off with it.
p.life = -1;
owl.x = p.x;
owl.y = p.y;
this.addText(p.x, p.y - 10, 'CAUGHT!', '#d9a066');
this.burst(p.x, p.y, p.spell.colors[0], 12, 70);
this.dismiss(owl);
} else if (!outbound && Math.abs(p.x - t.x) <= t.halfW + 3 + hair && Math.abs(p.y - t.y) <= t.halfH + 3 + hair) {
p.life = -1;
this.burst(p.x, p.y, p.spell.colors[0], 14, 90);
this.applySpell(p.spell, t, p.owner);
if (this.phase !== 'fight') return;
} else if (p.x < 0 || p.x > VIEW_W || p.y < CEILING - 8 || p.y > FLOOR + 6) {
p.life = -1;
this.burst(p.x, p.y, p.spell.colors[0], 6, 40);
}
}
// Spells that meet in the middle cancel each other out with a big CLASH.
for (const a of this.projectiles) {
for (const b of this.projectiles) {
if (a === b || a.owner === b.owner || a.life < 0 || b.life < 0) continue;
if (Math.abs(a.x - b.x) < 7 && Math.abs(a.y - b.y) < 7) {
a.life = -1;
b.life = -1;
this.burst((a.x + b.x) / 2, (a.y + b.y) / 2, '#ffffff', 24, 120);
this.addText((a.x + b.x) / 2, a.y - 10, 'CLASH!', '#ffffff', 2);
this.shake(0.25);
sfx.clash();
}
}
}
this.projectiles = this.projectiles.filter((p) => p.life > 0);
}
private updateParticles(dt: number): void {
for (const p of this.particles) {
p.vy += p.gravity * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt;
}
this.particles = this.particles.filter((p) => p.life > 0);
for (const t of this.texts) {
t.y -= 14 * dt;
t.life -= dt;
}
this.texts = this.texts.filter((t) => t.life > 0);
}
// ---------- effects helpers ----------
private spark(x: number, y: number, color: string, vy = 0, life = 0.5, gravity = 0): void {
this.particles.push({ x, y, vx: (Math.random() - 0.5) * 10, vy, life, maxLife: life, color, gravity, size: 1 });
}
private burst(x: number, y: number, color: string, count: number, speed: number): void {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const s = speed * (0.3 + Math.random() * 0.7);
const life = 0.3 + Math.random() * 0.4;
this.particles.push({
x, y, vx: Math.cos(angle) * s, vy: Math.sin(angle) * s, life, maxLife: life, color,
gravity: 120, size: Math.random() < 0.3 ? 2 : 1,
});
}
}
private addText(x: number, y: number, text: string, color: string, scale = 1): void {
this.texts.push({ x, y, text, color, life: 1.1, scale });
}
private shout(wizard: Wizard, text: string, color: string): void {
this.addText(Math.max(textWidth(text) / 2 + 2, Math.min(VIEW_W - textWidth(text) / 2 - 2, wizard.x)), wizard.y - 34, text, color);
}
private shake(seconds: number): void {
this.shakeTime = Math.max(this.shakeTime, seconds);
}
// ---------- drawing ----------
private draw(): void {
const ctx = this.ctx;
ctx.save();
if (this.screenShake && this.shakeTime > 0) {
ctx.translate(Math.round((Math.random() - 0.5) * 4), Math.round((Math.random() - 0.5) * 4));
}
ctx.drawImage(this.background, 0, 0);
this.drawTorches();
for (const wizard of this.wizards) this.drawWizard(wizard);
for (const f of this.familiars) this.drawFamiliar(f);
for (const p of this.projectiles) this.drawProjectile(p);
for (const p of this.particles) {
ctx.globalAlpha = Math.min(1, p.life / p.maxLife + 0.2);
ctx.fillStyle = p.color;
ctx.fillRect(Math.round(p.x), Math.round(p.y), p.size, p.size);
}
ctx.globalAlpha = 1;
for (const t of this.texts) {
if (t.life < 0.25 && Math.floor(t.life * 20) % 2 === 0) continue;
drawText(ctx, t.text, clampText(t.x, t.text, t.scale), t.y, t.color, { align: 'center', scale: t.scale });
}
ctx.restore();
this.drawHud();
if (this.phase === 'title') this.drawTitle();
if (this.phase === 'ko') this.drawWinner();
if (this.paused) this.drawPaused();
}
private drawPaused(): void {
const ctx = this.ctx;
ctx.fillStyle = 'rgba(18,10,31,0.7)';
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
drawText(ctx, 'PAUSED', VIEW_W / 2, 62, '#ffd23f', { align: 'center', scale: 3 });
if (Math.floor(this.time * 2) % 2 === 0) {
drawText(ctx, 'PRESS START, ESC OR P TO KEEP DUELING', VIEW_W / 2, 90, '#ffffff', { align: 'center' });
}
}
private drawTorches(): void {
const ctx = this.ctx;
for (const x of [14, VIEW_W - 17]) {
const flicker = Math.sin(this.time * 13 + x) > 0 ? 1 : 0;
ctx.fillStyle = '#ff7a1a';
ctx.fillRect(x, 88 - flicker, 3, 3 + flicker);
ctx.fillStyle = '#ffd23f';
ctx.fillRect(x + 1, 89, 1, 2);
}
}
private drawWizard(w: Wizard): void {
const ctx = this.ctx;
// Only Reverse Gravity turns the wizard upside down; a balloon lifts them upright.
const sign = w.has('revgrav') ? -1 : 1;
const flipY = sign === -1;
if (this.phase === 'ko' && w !== this.winner) {
// The loser is gone in a puff of smoke... only the hat remains.
drawSprite(ctx, HAT_ONLY, w.palette, w.x - 5, FLOOR - HAT_ONLY.length);
return;
}
// Glue puddle under the feet.
if (w.has('glue')) {
ctx.fillStyle = '#8fb339';
const gy = sign === 1 ? w.y + w.halfH - 1 : w.y - w.halfH - 1;
ctx.fillRect(Math.round(w.x - 8), Math.round(gy), 16, 2);
ctx.fillStyle = '#c8e86b';
ctx.fillRect(Math.round(w.x - 6), Math.round(gy), 12, 1);
}
// Casting aura.
if (w.casting) {
const r = 11 + Math.round(Math.sin(this.time * 12));
ctx.fillStyle = w.colors.band;
for (let i = 0; i < 12; i++) {
const a = this.time * 3 + (i / 12) * Math.PI * 2;
ctx.fillRect(Math.round(w.x + Math.cos(a) * r), Math.round(w.y + Math.sin(a) * r), 1, 1);
}
}
// Balloon tied above the head.
if (w.has('balloon')) {
const top = sign === 1 ? w.y - w.halfH - 22 : w.y + w.halfH + 8;
drawSprite(ctx, ICONS.balloon, { a: '#ff6b6b', b: '#ffffff', w: '#ffffff' }, w.x - 7, top, { scale: 2, flipY });
}
const moving = Math.abs(w.vx) > 10 && w.onGround;
let pose: WizardPose = 'idle';
if (w.casting) pose = 'cast';
else if (w.has('scratching')) pose = Math.floor(w.anim * 12) % 2 === 0 ? 'cast' : 'idle';
else if (w.has('dance')) pose = Math.floor(w.anim * 6) % 2 === 0 ? 'cast' : moving ? 'walkA' : 'idle';
else if (moving) pose = Math.floor(w.anim * 8) % 2 === 0 ? 'walkA' : 'walkB';
else if (w.has('disco')) pose = Math.floor(w.anim * 4) % 2 === 0 ? 'cast' : 'idle';
const flash = w.flash > 0 && Math.floor(w.flash * 30) % 2 === 0;
const palette = flash || w.has('stun') && Math.floor(this.time * 20) % 2 === 0 ? whitePalette(w.palette) : w.palette;
const flipX = w.facing === -1;
if (w.has('toad')) {
const top = sign === 1 ? w.y + w.halfH - TOAD.length : w.y - w.halfH;
drawSprite(ctx, TOAD, flash ? whitePalette(TOAD_PALETTE) : TOAD_PALETTE, w.x - 5, top, { flipX, flipY });
} else if (w.has('chicken')) {
const top = sign === 1 ? w.y + w.halfH - CHICKEN.length : w.y - w.halfH;
drawSprite(ctx, CHICKEN, CHICKEN_PALETTE, w.x - 5, top, { flipX, flipY });
} else if (w.has('tiny')) {
const rows = shrink(wizardRows(pose));
const top = sign === 1 ? w.y + w.halfH - rows.length : w.y - w.halfH;
drawSprite(ctx, rows, palette, w.x - 2, top, { flipX, flipY });
} else if (w.has('pancake')) {
// Squashed flat: every third row of the sprite, twice as wide.
const rows = wizardRows('idle')
.filter((_, r) => r % 3 === 0)
.map((row) => row.replace(/./g, (c) => c + c));
const top = sign === 1 ? w.y + w.halfH - rows.length : w.y - w.halfH;
drawSprite(ctx, rows, palette, w.x - 10, top, { flipX, flipY });
} else if (w.has('turtle')) {
const top = sign === 1 ? w.y + w.halfH - TURTLE.length : w.y - w.halfH;
drawSprite(ctx, TURTLE, { ...w.palette, G: '#3fae4a', g: '#1f6b2a', y: '#e8d27a' }, w.x - 7, top, { flipX, flipY });
} else if (w.has('tripped')) {
// Face-plant: the whole wizard lies flat on the floor.
ctx.save();
ctx.translate(Math.round(w.x), Math.round(sign === 1 ? w.y + w.halfH - 5 : w.y - w.halfH + 5));
ctx.rotate((flipX ? -1 : 1) * sign * (Math.PI / 2));
drawSprite(ctx, wizardRows('idle'), palette, -5, sign === 1 ? -16 : 0, { flipX, flipY });
ctx.restore();
if (w.has('clown')) this.drawClownShoes(w, sign, w.x - w.facing * 7);
} else {
const rows = wizardRows(pose);
const bob = pose === 'idle' && Math.floor(w.anim * 2) % 2 === 0 ? 1 : 0;
const head = rows.slice(0, 10);
const body = rows.slice(10);
const bodyTop = sign === 1 ? w.y + w.halfH - body.length : w.y - w.halfH;
const big = w.has('bighead');
const headScale = big ? 2 : 1;
const headH = head.length * headScale;
const headTop = sign === 1 ? bodyTop - headH + bob : bodyTop + body.length - bob;
const headX = w.x - 5 - (big ? 5 : 0);
// Paint a rectangle in head-pixel units, following the head's flips and size.
const headPx = (col: number, row: number, wide: number, tall: number, color: string) => {
const c = flipX ? 10 - col - wide : col;
const r = flipY ? 10 - row - tall : row;
ctx.fillStyle = color;
ctx.fillRect(Math.round(headX + c * headScale), Math.round(headTop + r * headScale), wide * headScale, tall * headScale);
};
if (w.has('afro')) {
// A huge round puff of hair behind the head (the hat sits on top).
const rows = [[2, 6], [0, 10], [-2, 14], [-3, 16], [-4, 18], [-4, 18], [-4, 18], [-3, 16], [-2, 14], [0, 10]];
rows.forEach(([col, wide], i) => headPx(col, i, wide, 1, i % 3 === 0 ? '#5a3a1a' : '#3b2414'));
}
const invisible = w.has('invisible');
// Spaghetti legs wobble the body from side to side.
const jiggle = w.has('noodle') ? Math.round(Math.sin(w.anim * 14)) : 0;
if (!invisible) drawSprite(ctx, body, palette, w.x - 5 + jiggle, bodyTop, { flipX, flipY });
drawSprite(ctx, invisible ? head.slice(0, 7) : head, palette, headX, flipY && invisible ? headTop + 3 * headScale : headTop, {
flipX,
flipY,
scale: headScale,
});
if (w.has('mustache') && !invisible) {
headPx(1, 8, 8, 2, '#5a3a1a');
headPx(3, 8, 4, 1, '#8a5a2b');
// The two ends droop and flare out all the way down to the floor.
const feetY = sign === 1 ? w.y + w.halfH : w.y - w.halfH;
const startY = flipY ? headTop : headTop + 10 * headScale;
const drop = Math.abs(feetY - startY);
for (const side of [-1, 1]) {
const baseX = w.x + side * 4 * headScale;
for (let d = 0; d < drop; d++) {
const flare = Math.round(Math.sqrt(d / drop) * 6) * side;
ctx.fillStyle = d % 4 === 0 ? '#8a5a2b' : '#5a3a1a';
ctx.fillRect(Math.round(baseX + flare), Math.round(startY + d * sign), 2, 1);
}
}
}
if (w.has('clown')) this.drawClownShoes(w, sign, w.x);
if (w.has('snail') && !invisible) {
drawSprite(ctx, ICONS.shell, { a: '#8a5a2b', b: '#d9a066' }, w.x - w.facing * 5 - 3, w.y - 3, { flipX, flipY });
}
if (w.has('pogo')) {
// The pogo stick: a pole held in front, with a spring under the feet.
const feetY = sign === 1 ? w.y + w.halfH : w.y - w.halfH;
const poleX = Math.round(w.x + w.facing * 4);
ctx.fillStyle = '#c0c0d0';
ctx.fillRect(poleX, Math.round(Math.min(w.y - 2 * sign, feetY + 4 * sign)), 1, 12);
ctx.fillStyle = '#ff5fa2';
ctx.fillRect(poleX - 2, Math.round(w.y - 2 * sign), 5, 1);
for (let i = 0; i < 4; i++) ctx.fillRect(poleX - 1 + (i % 2), Math.round(feetY + i * sign), 2, 1);
}
if (pose === 'cast') {
const wandX = flipX ? w.x - 6 - 3 : w.x + 5;
const wandY = sign === 1 ? bodyTop - 3 : bodyTop + body.length;
drawSprite(ctx, WAND, palette, wandX, wandY, { flipX, flipY });
}
if (w.has('pie')) {
const faceY = sign === 1 ? headTop + 7 * headScale : headTop + 1 * headScale;
ctx.fillStyle = '#fff4e0';
ctx.fillRect(Math.round(w.x - 3 * headScale), Math.round(faceY), 7 * headScale, 3 * headScale);
ctx.fillStyle = '#ff9fc8';
ctx.fillRect(Math.round(w.x), Math.round(faceY), headScale, headScale);
}
}
if (w.has('puppet')) {
// Strings from the head up to a wooden cross near the ceiling.
const headY = Math.round(w.y - w.halfH);
const crossY = CEILING + 4;
ctx.fillStyle = '#d8d8e8';
for (const dx of [-3, 3]) ctx.fillRect(Math.round(w.x + dx), crossY, 1, Math.max(0, headY - crossY));
ctx.fillStyle = '#8a5a2b';
ctx.fillRect(Math.round(w.x - 5), crossY - 1, 11, 2);
ctx.fillRect(Math.round(w.x), crossY - 3, 1, 6);
}
if (w.has('potato')) {
// The hot potato glows redder as it gets closer to exploding.
const left = w.status.get('potato') ?? 0;
const blink = left < 1 && Math.floor(this.time * 12) % 2 === 0;
ctx.fillStyle = blink ? '#ffd23f' : left > 2 ? '#c8894a' : left > 1 ? '#e06a2a' : '#ff3a1a';
const px = Math.round(w.x + w.facing * 6 - 2);
const py = Math.round(w.y - 3);
ctx.fillRect(px, py + 1, 5, 2);
ctx.fillRect(px + 1, py, 3, 4);
}
if (w.has('bees')) {
// A buzzing swarm circling the head.
for (let i = 0; i < 7; i++) {
const a = this.time * (4 + i * 0.7) + i * 2.1;
const bx = Math.round(w.x + Math.cos(a) * (6 + (i % 3) * 3));
const by = Math.round(w.y - 6 + Math.sin(a * 1.3) * 5);
ctx.fillStyle = '#ffd23f';
ctx.fillRect(bx, by, 1, 1);
ctx.fillStyle = '#120a1f';
ctx.fillRect(bx + 1, by, 1, 1);
}
}
if (w.has('bubble')) {
// A wobbly soap bubble around the whole wizard.
const r = 12 + Math.round(Math.sin(this.time * 6) * 0.6);
ctx.globalAlpha = 0.15;
ctx.fillStyle = '#9fe8ff';
ctx.fillRect(Math.round(w.x - r + 3), Math.round(w.y - r + 3), r * 2 - 6, r * 2 - 6);
ctx.globalAlpha = 0.9;
for (let i = 0; i < 56; i++) {
const a = (i / 56) * Math.PI * 2;
ctx.fillStyle = i % 9 === 0 ? '#ff9cf5' : '#c8f4ff';
ctx.fillRect(Math.round(w.x + Math.cos(a) * r), Math.round(w.y + Math.sin(a) * r), 1, 1);
}
ctx.fillStyle = '#ffffff';
ctx.fillRect(Math.round(w.x - 6), Math.round(w.y - 7), 2, 1);
ctx.fillRect(Math.round(w.x - 7), Math.round(w.y - 6), 1, 2);
ctx.globalAlpha = 1;
}
if (w.has('frozen')) {
ctx.globalAlpha = 0.55;
ctx.fillStyle = '#9fe8ff';
ctx.fillRect(Math.round(w.x - 8), Math.round(w.y - w.halfH - 4), 16, w.halfH * 2 + 5);
ctx.globalAlpha = 1;
ctx.fillStyle = '#ffffff';
ctx.fillRect(Math.round(w.x - 6), Math.round(w.y - w.halfH - 2), 1, 6);
ctx.fillRect(Math.round(w.x - 5), Math.round(w.y - w.halfH - 2), 3, 1);
}
if (w.has('rain')) {
const cloudY = sign === 1 ? w.y - w.halfH - 22 : w.y + w.halfH + 12;
drawSprite(ctx, ICONS.cloud.slice(0, 4), { a: '#9aa7c7' }, w.x - 7, cloudY, { scale: 2, flipY });
}
if (w.has('drunk') || w.has('stun')) {
const headY = sign === 1 ? w.y - w.halfH - 6 : w.y + w.halfH + 5;
for (let i = 0; i < 3; i++) {
const a = this.time * 5 + (i / 3) * Math.PI * 2;
ctx.fillStyle = '#fff36b';
ctx.fillRect(Math.round(w.x + Math.cos(a) * 7), Math.round(headY + Math.sin(a) * 2), 1, 1);
}
}
// Runes typed so far float above the head.
if (w.casting && w.combo.length > 0) {
const overload = w.combo.length > MAX_COMBO;
const total = w.combo.length * 8 - 1;
const runeY = sign === 1 ? w.y - w.halfH - (w.has('bighead') ? 34 : 24) : w.y + w.halfH + 18;
let left = w.x - total / 2;
left = Math.max(2, Math.min(VIEW_W - total - 2, left));
w.combo.forEach((dir, i) => {
drawArrow(ctx, dir, left + i * 8 + 1, runeY, overload ? '#ff5a5a' : w.colors.band);
});
}
}
private drawClownShoes(w: Wizard, sign: 1 | -1, x: number): void {
const ctx = this.ctx;
const y = Math.round(sign === 1 ? w.y + w.halfH - 3 : w.y - w.halfH);
const toe = w.facing;
ctx.fillStyle = '#ff3a3a';
ctx.fillRect(Math.round(x - 4 + (toe === 1 ? 0 : -6)), y, 14, 3);
ctx.fillStyle = '#ffd23f';
ctx.fillRect(Math.round(x + toe * 7), sign === 1 ? y : y + 2, 2, 1);
}
private drawFamiliar(f: Familiar): void {
// Blink during the last 2 seconds so players know it's about to leave.
if (f.life < 2 && Math.floor(f.life * 8) % 2 === 0) return;
const info = FAMILIARS[f.kind];
drawSprite(this.ctx, info.sprite, info.palette, f.x - 3, f.y - 3, { flipX: f.owner.facing === -1 });
}
private drawProjectile(p: Projectile): void {
const icon = ICONS[p.spell.icon] ?? ICONS.orb;
const wobble = Math.round(Math.sin(this.time * 20) * 0.6);
drawSprite(
this.ctx,
icon,
{ a: p.spell.colors[0], b: p.spell.colors[1], w: '#ffffff' },
p.x - 3,
p.y - 3 + wobble,
{ flipX: p.vx < 0 },
);
}
private drawHud(): void {
const ctx = this.ctx;
ctx.fillStyle = '#120a1f';
ctx.fillRect(0, 0, VIEW_W, 20);
ctx.fillStyle = '#2b1c45';
ctx.fillRect(0, 19, VIEW_W, 1);
this.wizards.forEach((w, i) => {
const left = i === 0;
const barX = left ? 4 : VIEW_W - 4 - 100;
drawText(ctx, w.name, left ? 4 : VIEW_W - 4, 2, w.colors.hat, { align: left ? 'left' : 'right' });
for (let s = 0; s < w.wins; s++) {
const starX = left ? 4 + textWidth(w.name) + 4 + s * 5 : VIEW_W - 4 - textWidth(w.name) - 8 - s * 5;
ctx.fillStyle = '#ffd23f';
ctx.fillRect(starX + 1, 2, 1, 5);
ctx.fillRect(starX, 3, 3, 3);
}
ctx.fillStyle = '#3a2350';
ctx.fillRect(barX, 9, 100, 4);
const hpW = Math.round(w.hp);
ctx.fillStyle = w.hp > 30 ? w.colors.hat : '#ff3a3a';
ctx.fillRect(left ? barX : barX + 100 - hpW, 9, hpW, 4);
ctx.fillStyle = 'rgba(255,255,255,0.35)';
ctx.fillRect(left ? barX : barX + 100 - hpW, 9, hpW, 1);
const labels = [...w.status.keys()].map((s) => STATUS_LABELS[s]);
const line = labels.slice(0, 3).join(' ') + (labels.length > 3 ? ' +' : '');
drawText(ctx, line, left ? 4 : VIEW_W - 4, 14, '#d8c8ff', { align: left ? 'left' : 'right', shadow: null });
});
}
private drawTitle(): void {
const ctx = this.ctx;
ctx.fillStyle = 'rgba(18,10,31,0.86)';
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
const bounce = Math.round(Math.sin(this.time * 3) * 1.5);
drawText(ctx, 'WIZARD DUEL', VIEW_W / 2, 8 + bounce, '#ffd23f', { align: 'center', scale: 3 });
drawText(ctx, 'CAST SILLY SPELLS AT YOUR FRIEND!', VIEW_W / 2, 28, '#d8c8ff', { align: 'center' });
const [p1, p2] = this.wizards;
const col = (w: Wizard, x: number, keys: string[]) => {
drawText(ctx, w.name, x, 40, w.colors.hat, { scale: 2 });
keys.forEach((line, i) => drawText(ctx, line, x, 54 + i * 8, '#ffffff'));
};
col(p1, 14, ['MOVE: A D JUMP: W', 'HOLD SPACE, TAP W A S D', 'LET GO OF SPACE TO CAST!']);
col(p2, 170, ['MOVE: ARROWS JUMP: UP', 'HOLD ENTER, TAP ARROWS', 'LET GO OF ENTER TO CAST!']);
[p1, p2].forEach((_, i) => {
const ready = this.padConnected[i];
const line = ready ? 'PAD READY! HOLD A TRIGGER' : 'OR PLUG IN A CONTROLLER';
drawText(ctx, line, i === 0 ? 14 : 170, 77, ready ? '#7dff9a' : '#9a8fb0');
});
drawText(ctx, 'SPELL SCROLL', VIEW_W / 2, 84, '#ffd23f', { align: 'center' });
const starters = ['hiccups', 'banana', 'knockback', 'fireball'].map(spellById);
starters.forEach((spell, i) => {
const x = i % 2 === 0 ? 30 : 176;
const y = 94 + Math.floor(i / 2) * 10;
drawText(ctx, spell.name, x, y + 1, spell.colors[0]);
spell.combo!.forEach((dir, j) => drawArrow(ctx, dir, x + 62 + j * 8, y, '#ffffff', '#3a2350'));
});
drawText(ctx, `${SPELLS.length} SPELLS HIDE IN THE RUNES (3 TO 6 TAPS).`, VIEW_W / 2, 120, '#d8c8ff', { align: 'center' });
drawText(ctx, 'SHORT COMBOS ARE SILLY. LONG COMBOS ARE STRONG!', VIEW_W / 2, 129, '#ffd23f', { align: 'center' });
drawText(ctx, 'UNKNOWN RUNES MAKE WILD MAGIC - IT MIGHT BACKFIRE!', VIEW_W / 2, 138, '#ff9cf5', { align: 'center' });
if (this.touchOnly) {
drawText(ctx, 'NEEDS A KEYBOARD OR GAME CONTROLLERS', VIEW_W / 2, 148, '#ff9f5a', { align: 'center' });
}
if (Math.floor(this.time * 2) % 2 === 0) {
drawText(ctx, 'PRESS SPACE, ENTER OR A TO DUEL!', VIEW_W / 2, 160, '#ffffff', { align: 'center' });
}
}
private drawWinner(): void {
if (!this.winner) return;
const ctx = this.ctx;
ctx.fillStyle = 'rgba(18,10,31,0.55)';
ctx.fillRect(0, 48, VIEW_W, 44);
drawText(ctx, `${this.winner.name} WINS!`, VIEW_W / 2, 56, this.winner.colors.hat, { align: 'center', scale: 3 });
if (this.phaseTime > 1.5 && Math.floor(this.time * 2) % 2 === 0) {
drawText(ctx, 'PRESS SPACE, ENTER OR A FOR A REMATCH', VIEW_W / 2, 80, '#ffffff', { align: 'center' });
}
}
}
// ---------- small helpers ----------
function clampText(x: number, text: string, scale: number): number {
const half = textWidth(text.toUpperCase(), scale) / 2;
return Math.max(half + 2, Math.min(VIEW_W - half - 2, x));
}
function randomSparkle(): string {
const colors = ['#ffd23f', '#ff9cf5', '#7fe0ff', '#7dff6b', '#ffffff'];
return colors[Math.floor(Math.random() * colors.length)];
}
/** Halve a sprite by keeping every other pixel (used by the Shrink Ray). */
function shrink(rows: string[]): string[] {
return rows
.filter((_, r) => r % 2 === 0)
.map((row) => row.split('').filter((_, c) => c % 2 === 0).join(''));
}
const whiteCache = new WeakMap<Record<string, string>, Record<string, string>>();
function whitePalette(palette: Record<string, string>): Record<string, string> {
let white = whiteCache.get(palette);
if (!white) {
white = Object.fromEntries(Object.keys(palette).map((k) => [k, '#ffffff']));
whiteCache.set(palette, white);
}
return white;
}
/** Paint the castle arena once: night sky, moon, stars, stone floor and platforms. */
function buildBackground(): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = VIEW_W;
canvas.height = VIEW_H;
const ctx = canvas.getContext('2d')!;
const sky = ['#1a0f33', '#21123f', '#2a174d', '#33205a', '#3d2866'];
const band = Math.ceil(FLOOR / sky.length);
sky.forEach((color, i) => {
ctx.fillStyle = color;
ctx.fillRect(0, i * band, VIEW_W, band);
});
let seed = 7;
const rand = () => {
seed = (seed * 16807) % 2147483647;
return seed / 2147483647;
};
for (let i = 0; i < 60; i++) {
ctx.fillStyle = rand() < 0.2 ? '#ffd23f' : '#c8b8ff';
ctx.fillRect(Math.floor(rand() * VIEW_W), 20 + Math.floor(rand() * 90), 1, 1);
}
// Moon
ctx.fillStyle = '#fff4c8';
ctx.fillRect(250, 32, 14, 14);
ctx.fillRect(248, 34, 18, 10);
ctx.fillRect(252, 30, 10, 18);
ctx.fillStyle = '#e8d890';
ctx.fillRect(254, 36, 3, 3);
ctx.fillRect(259, 41, 2, 2);
// Distant castle towers
ctx.fillStyle = '#170d2b';
const towers = [[20, 70, 24], [70, 96, 16], [110, 60, 20], [190, 80, 22], [280, 66, 18]];
for (const [x, top, w] of towers) {
ctx.fillRect(x, top, w, FLOOR - top);
for (let b = 0; b < w; b += 4) ctx.fillRect(x + b, top - 3, 2, 3);
}
ctx.fillRect(0, 120, VIEW_W, FLOOR - 120);
ctx.fillStyle = '#ffd23f';
ctx.fillRect(116, 74, 2, 3);
ctx.fillRect(198, 92, 2, 3);
ctx.fillRect(286, 80, 2, 3);
// Side walls
ctx.fillStyle = '#2b1c45';
ctx.fillRect(0, 20, WALL_L, FLOOR - 20);
ctx.fillRect(WALL_R, 20, VIEW_W - WALL_R, FLOOR - 20);
// Torch holders
ctx.fillStyle = '#5a4320';
ctx.fillRect(14, 91, 3, 4);
ctx.fillRect(VIEW_W - 17, 91, 3, 4);
// Stone floor
ctx.fillStyle = '#4a3a63';
ctx.fillRect(0, FLOOR, VIEW_W, VIEW_H - FLOOR);
ctx.fillStyle = '#6b5a85';
ctx.fillRect(0, FLOOR, VIEW_W, 1);
ctx.fillStyle = '#2f2342';
for (let row = 0; row < 3; row++) {
const y = FLOOR + 1 + row * 6;
ctx.fillRect(0, y + 5, VIEW_W, 1);
for (let x = (row % 2) * 8; x < VIEW_W; x += 16) ctx.fillRect(x, y, 1, 5);
}
// Ceiling beam (where Reverse Gravity sends you)
ctx.fillStyle = '#2b1c45';
ctx.fillRect(0, 20, VIEW_W, CEILING - 20);
ctx.fillStyle = '#4a3a63';
ctx.fillRect(0, CEILING - 1, VIEW_W, 1);
// Floating platforms
for (const p of PLATFORMS) {
ctx.fillStyle = '#5c4a78';
ctx.fillRect(p.x, p.y, p.w, PLATFORM_THICKNESS);
ctx.fillStyle = '#8a76a8';
ctx.fillRect(p.x, p.y, p.w, 1);
ctx.fillStyle = '#2f2342';
ctx.fillRect(p.x, p.y + PLATFORM_THICKNESS - 1, p.w, 1);
for (let x = p.x + 6; x < p.x + p.w; x += 10) ctx.fillRect(x, p.y + 1, 1, 4);
ctx.fillStyle = '#7dff6b';
ctx.fillRect(p.x + 3, p.y + PLATFORM_THICKNESS, 1, 2);
ctx.fillRect(p.x + p.w - 8, p.y + PLATFORM_THICKNESS, 1, 3);
}
return canvas;
}
