Admin Panel
{unlocked ? "Powers unlocked — go wild." : "Enter a secret code to unlock powers."}
setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
type="password"
placeholder="Enter code..."
className="flex-1 px-4 py-2.5 rounded-lg bg-slate-950 border border-slate-700 text-white placeholder-slate-500 focus:outline-none focus:border-amber-400"
/>
Unlock
{error &&
{error}
}
{unlocked && (
<>
Secret Powers
{powers.map((p) => {
const Icon = p.icon;
return (
);
})}
{flash && (
{flash}
)}
>
)}
);
}
import React, { useRef, useEffect, useState, useCallback } from "react";
import { playExplosion, playCrunch, playBarrel, playShot } from "@/lib/gameAudio";
import { applyUpgrades } from "@/lib/upgrades";
import { SKINS } from "@/lib/skins";
// Top-down wasteland bomber.
// Camera sits above and behind the plane. The wasteland scrolls left (plane flies east).
// The plane strafes up/down the screen to aim. Bombs fall down the screen and detonate
// on the ruins, rubble, barrels and dead trees below.
const WIDTH = 960;
const HEIGHT = 600;
const WORLD_W = 5200;
const SCROLL_SPEED = 2.0;
const PLANE_X = WIDTH * 0.34;
const GRAVITY = 0.14;
const BOSS_MAX_HP = 10000;
const PLAYER_MAX_HP = 200;
const BOSS_HIT_MIN = 10;
const BOSS_HIT_MAX = 50;
const BOSS_DMG_MIN = 10;
const BOSS_DMG_MAX = 100;
const BUILDING_COLORS = ["#c7b894", "#b8a784", "#d2c4a2", "#a89878", "#bdac86"];
const RUBBLE_COLORS = ["#9a8868", "#ab9878", "#867558"];
function rand(a, b) {
return Math.random() * (b - a) + a;
}
function randi(a, b) {
return Math.floor(rand(a, b));
}
function pick(arr) {
return arr[randi(0, arr.length)];
}
function makeWorld() {
const structs = [];
let x = 120;
while (x < WORLD_W - 120) {
const roll = Math.random();
const y = rand(HEIGHT * 0.32, HEIGHT * 0.88);
if (roll < 0.34) {
const w = rand(54, 96);
const h = rand(46, 120);
structs.push({
type: "building",
x,
y,
w,
h,
hp: 3 + Math.floor(h / 40),
maxHp: 3 + Math.floor(h / 40),
color: pick(BUILDING_COLORS),
dead: false,
windows: Array.from({ length: randi(3, 7) }, () => Math.random() < 0.4),
});
x += w + rand(24, 70);
} else if (roll < 0.55) {
const w = rand(44, 86);
const h = rand(14, 28);
structs.push({ type: "rubble", x, y, w, h, hp: 1, color: pick(RUBBLE_COLORS), dead: false });
x += w + rand(20, 50);
} else if (roll < 0.72) {
structs.push({ type: "barrel", x, y, w: 22, h: 24, hp: 1, color: "#a04535", explosive: true, dead: false });
x += rand(34, 80);
} else {
structs.push({ type: "tree", x, y, w: 16, h: 38, hp: 1, dead: false });
x += rand(34, 70);
}
}
// scenery: craters + toxic pools (indestructible)
for (let i = 0; i < 22; i++) {
structs.push({ type: "crater", x: rand(0, WORLD_W), y: rand(HEIGHT * 0.3, HEIGHT * 0.92), w: rand(28, 56), dead: true });
}
for (let i = 0; i < 8; i++) {
structs.push({ type: "pool", x: rand(0, WORLD_W), y: rand(HEIGHT * 0.3, HEIGHT * 0.92), w: rand(44, 86), dead: true });
}
// ground detail: cracks, patches, pebbles
const detail = [];
for (let i = 0; i < 420; i++) {
detail.push({
x: rand(0, WORLD_W),
y: rand(0, HEIGHT),
k: Math.random() < 0.5 ? "patch" : Math.random() < 0.7 ? "crack" : "pebble",
s: rand(6, 40),
});
}
return { structs, detail };
}
function makeDust() {
return Array.from({ length: 45 }, () => ({
x: rand(0, WIDTH),
y: rand(0, HEIGHT),
vx: rand(-0.4, -0.1),
vy: rand(-0.08, 0.08),
r: rand(1, 2.4),
a: rand(0.15, 0.5),
}));
}
export default function CityBomber({ player, onRunEnd }) {
const canvasRef = useRef(null);
const stateRef = useRef(null);
const keysRef = useRef(new Set());
const mouseRef = useRef({ x: WIDTH * 0.6, y: HEIGHT * 0.5 });
const firingRef = useRef(false);
const playerRef = useRef(player);
const onRunEndRef = useRef(onRunEnd);
const skinRef = useRef(player?.selectedSkin || "default");
const [score, setScore] = useState(0);
const [bombsLeft, setBombsLeft] = useState(25);
const [status, setStatus] = useState("playing");
const [lastScrap, setLastScrap] = useState(0);
const [resetKey, setResetKey] = useState(0);
useEffect(() => {
playerRef.current = player;
skinRef.current = player?.selectedSkin || "default";
}, [player]);
useEffect(() => {
onRunEndRef.current = onRunEnd;
}, [onRunEnd]);
const initGame = useCallback(() => {
const world = makeWorld();
const stats = applyUpgrades(playerRef.current?.upgrades);
stateRef.current = {
planeY: HEIGHT * 0.5,
planeVY: 0,
scroll: 0,
bombs: [],
bullets: [],
explosions: [],
particles: [],
smoke: [],
chunks: [],
world,
stats,
bombsLeft: stats.startBombs,
score: 0,
status: "playing",
lastDrop: 0,
lastShot: 0,
shake: 0,
dust: makeDust(),
awarded: false,
totalTargets: world.structs.filter((s) => !s.dead).length,
playerHp: PLAYER_MAX_HP,
boss: null,
bossBullets: [],
hurtFlash: 0,
bossWarn: 0,
};
setScore(0);
setBombsLeft(stats.startBombs);
setLastScrap(0);
setStatus("playing");
}, []);
useEffect(() => {
initGame();
}, [initGame, resetKey]);
const dropBomb = useCallback(() => {
const s = stateRef.current;
if (!s || (s.status !== "playing" && s.status !== "boss") || s.bombsLeft <= 0) return;
const now = performance.now();
if (now - s.lastDrop < 240) return;
s.lastDrop = now;
const m = mouseRef.current;
const sx = PLANE_X + 18;
const sy = s.planeY + 6;
const tx = m.x;
const ty = m.y;
const T = s.stats.bombT; // frames to reach the crosshair
const vx = (tx - sx) / T;
const vy = (ty - sy) / T - 0.5 * GRAVITY * T;
s.bombs.push({ x: sx, y: sy, vx, vy, r: 5, trail: [], tx, ty, life: T + 24 });
s.bombsLeft -= 1;
setBombsLeft(s.bombsLeft);
}, []);
const onMove = useCallback((e) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * WIDTH;
const y = ((e.clientY - rect.top) / rect.height) * HEIGHT;
mouseRef.current = { x, y };
}, []);
const onDown = useCallback((e) => {
e.preventDefault();
firingRef.current = true;
onMove(e);
}, [onMove]);
const onUp = useCallback(() => {
firingRef.current = false;
}, []);
// input
useEffect(() => {
const down = (e) => {
if (["ArrowUp", "ArrowDown", "KeyW", "KeyS", "Space"].includes(e.code)) e.preventDefault();
keysRef.current.add(e.code);
if (e.code === "Space") {
const st = stateRef.current?.status;
if (st === "playing" || st === "boss") dropBomb();
else setResetKey((k) => k + 1);
}
};
const up = (e) => keysRef.current.delete(e.code);
window.addEventListener("keydown", down);
window.addEventListener("keyup", up);
return () => {
window.removeEventListener("keydown", down);
window.removeEventListener("keyup", up);
};
}, [dropBomb]);
// main loop
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
let raf;
let running = true;
const spawnChunks = (s, st) => {
const count = st.type === "building" ? 14 : st.type === "rubble" ? 4 : 3;
const color = st.color || (st.type === "tree" ? "#5a4632" : "#7a6a52");
for (let i = 0; i < count; i++) {
s.chunks.push({
x: st.x - s.scroll + rand(0, st.w),
y: st.y - rand(0, st.h),
vx: rand(-4, 4),
vy: rand(-7, -2),
w: st.type === "building" ? rand(10, 24) : rand(5, 12),
h: st.type === "building" ? rand(9, 20) : rand(4, 9),
rot: rand(0, Math.PI * 2),
vrot: rand(-0.3, 0.3),
color,
gy: st.y,
life: 1,
settled: false,
});
}
};
const explode = (s, x, y, power = 1, chain = 0) => {
playExplosion(power);
s.explosions.push({ x, y, r: 6, max: 52 * power * s.stats.blastMult, life: 1, power });
s.shake = Math.min(16, s.shake + 6 * power);
// smoke
for (let i = 0; i < 10 * power; i++) {
s.smoke.push({
x: x + rand(-8, 8),
y: y + rand(-8, 8),
r: rand(8, 16),
vy: rand(-0.6, -1.4),
vx: rand(-0.4, 0.4),
life: 1,
decay: rand(0.006, 0.012),
});
}
// debris
for (let i = 0; i < 16 * power; i++) {
s.particles.push({
x,
y,
vx: rand(-4, 4),
vy: rand(-5, -1),
size: rand(2, 5),
life: 1,
col: pick(["120,90,60", "90,70,50", "150,120,80", "70,60,55"]),
});
}
// damage structures in radius (screen space)
const radius = 46 * power * s.stats.blastMult;
let hitBuilding = false;
let hitBarrel = false;
for (const st of s.world.structs) {
if (st.dead) continue;
const cx = st.x + st.w / 2 - s.scroll;
const cy = st.y;
const d = Math.hypot(cx - x, cy - y);
if (d < radius) {
if (st.type === "building" && !st.dead && st.collapse == null) {
st.collapse = 0;
st.collapseTilt = rand(-0.5, 0.5);
} else {
st.dead = true;
}
s.score += st.type === "barrel" ? 150 : st.type === "building" ? 100 : 60;
spawnChunks(s, st);
if (st.type === "building") hitBuilding = true;
if (st.type === "barrel") hitBarrel = true;
// chain reaction for barrels
if (st.type === "barrel" && chain < 3) {
setTimeout(() => {
const cur = stateRef.current;
if (cur)
explode(cur, st.x + st.w / 2 - cur.scroll, st.y, 1.4 * s.stats.chainMult, chain + 1);
}, 90);
}
}
}
if (hitBarrel) playBarrel();
else if (hitBuilding) playCrunch();
setScore(s.score);
};
// bullet impact on a structure (screen-space hit point)
const hitWithBullet = (s, st, hx, hy) => {
// spark burst
for (let i = 0; i < 6; i++) {
s.particles.push({
x: hx,
y: hy,
vx: rand(-3, 3),
vy: rand(-3, 1),
size: rand(1.5, 3),
life: 1,
col: "255,220,120",
});
}
if (st.type === "barrel") {
explode(s, st.x + st.w / 2 - s.scroll, st.y, 1.6);
return;
}
if (st.type === "building") {
st.hp = (st.hp ?? 1) - 1;
if (st.hp <= 0 && st.collapse == null) {
st.collapse = 0;
st.collapseTilt = rand(-0.5, 0.5);
s.score += 100;
spawnChunks(s, st);
playCrunch();
setScore(s.score);
} else {
// chip a little debris off
for (let i = 0; i < 3; i++) {
s.chunks.push({
x: hx,
y: hy,
vx: rand(-2.5, 2.5),
vy: rand(-3, -0.5),
w: rand(5, 10),
h: rand(4, 8),
rot: rand(0, Math.PI * 2),
vrot: rand(-0.25, 0.25),
color: st.color,
gy: st.y,
life: 1,
settled: false,
});
}
}
return;
}
// rubble / tree: one-shot
st.dead = true;
s.score += 60;
spawnChunks(s, st);
setScore(s.score);
};
// damage the boss with a bullet or bomb hit (10-50 random damage)
const damageBoss = (s, hx, hy) => {
const bs = s.boss;
if (!bs) return;
const dmg = Math.round(rand(BOSS_HIT_MIN, BOSS_HIT_MAX));
bs.hp = Math.max(0, bs.hp - dmg);
bs.hitFlash = 1;
s.score += dmg;
for (let i = 0; i < 8; i++) {
s.particles.push({
x: hx,
y: hy,
vx: rand(-4, 4),
vy: rand(-4, 2),
size: rand(2, 4),
life: 1,
col: "255,180,80",
});
}
setScore(s.score);
};
const draw = () => {
if (!running) return;
const s = stateRef.current;
if (!s) {
raf = requestAnimationFrame(draw);
return;
}
if (s.status === "playing" || s.status === "boss") {
// plane vertical movement
const up = keysRef.current.has("ArrowUp") || keysRef.current.has("KeyW");
const dn = keysRef.current.has("ArrowDown") || keysRef.current.has("KeyS");
const accel = 0.5;
if (up) s.planeVY -= accel;
if (dn) s.planeVY += accel;
s.planeVY *= 0.9;
s.planeY += s.planeVY;
s.planeY = Math.max(40, Math.min(HEIGHT - 40, s.planeY));
// scroll (stops once the boss fight begins)
if (s.status === "playing") s.scroll += SCROLL_SPEED;
// chain gun firing
if (firingRef.current) {
const now = performance.now();
if (now - s.lastShot > s.stats.gunRate) {
s.lastShot = now;
const m = mouseRef.current;
const sx = PLANE_X + 30;
const sy = s.planeY;
const dx = m.x - sx;
const dy = m.y - sy;
const d = Math.hypot(dx, dy) || 1;
const sp = 16;
s.bullets.push({
x: sx,
y: sy,
vx: (dx / d) * sp,
vy: (dy / d) * sp,
life: 70,
px: sx,
py: sy,
});
playShot();
}
}
// bullets
const liveBullets = [];
for (const b of s.bullets) {
b.px = b.x;
b.py = b.y;
b.x += b.vx;
b.y += b.vy;
b.life -= 1;
let hit = false;
for (const st of s.world.structs) {
if (st.dead || st.collapse != null) continue;
const sx = st.x - s.scroll;
if (
b.x > sx &&
b.x < sx + st.w &&
b.y > st.y - st.h &&
b.y < st.y + 6
) {
hitWithBullet(s, st, b.x, b.y);
hit = true;
break;
}
}
if (!hit && s.boss) {
const bs = s.boss;
if (
b.x > bs.x - bs.w / 2 &&
b.x < bs.x + bs.w / 2 &&
b.y > bs.y - bs.h / 2 &&
b.y < bs.y + bs.h / 2
) {
damageBoss(s, b.x, b.y);
hit = true;
}
}
if (!hit && b.life > 0 && b.x > -20 && b.x < WIDTH + 20 && b.y > -20 && b.y < HEIGHT + 20) {
liveBullets.push(b);
}
}
s.bullets = liveBullets;
// bombs
for (const b of s.bombs) {
b.trail.push({ x: b.x, y: b.y });
if (b.trail.length > 12) b.trail.shift();
b.vy += GRAVITY;
b.x += b.vx;
b.y += b.vy;
b.life -= 1;
}
// collisions / detonation at crosshair
const alive = [];
for (const b of s.bombs) {
let hit = false;
for (const st of s.world.structs) {
if (st.dead) continue;
const sx = st.x - s.scroll;
if (
b.x > sx - 4 &&
b.x < sx + st.w + 4 &&
b.y > st.y - st.h &&
b.y < st.y + 6
) {
const power = st.type === "barrel" ? 1.6 : 1;
explode(s, b.x, b.y, power);
hit = true;
break;
}
}
if (!hit && s.boss) {
const bs = s.boss;
if (
b.x > bs.x - bs.w / 2 &&
b.x < bs.x + bs.w / 2 &&
b.y > bs.y - bs.h / 2 &&
b.y < bs.y + bs.h / 2
) {
damageBoss(s, b.x, b.y);
explode(s, b.x, b.y, 0.8);
hit = true;
}
}
if (!hit) {
const d = Math.hypot(b.x - b.tx, b.y - b.ty);
if (d < 12 || b.life <= 0 || b.y > HEIGHT - 6) {
explode(s, b.x, Math.min(b.y, HEIGHT - 6), 0.85);
hit = true;
}
}
if (!hit) alive.push(b);
}
s.bombs = alive;
// explosions
for (const ex of s.explosions) {
ex.r += 3.2;
ex.life -= 0.045;
}
s.explosions = s.explosions.filter((e) => e.life > 0);
// smoke
for (const sm of s.smoke) {
sm.x += sm.vx;
sm.y += sm.vy;
sm.r += 0.4;
sm.life -= sm.decay;
}
s.smoke = s.smoke.filter((m) => m.life > 0);
// particles
for (const p of s.particles) {
p.vy += 0.2;
p.x += p.vx;
p.y += p.vy;
p.life -= 0.018;
}
s.particles = s.particles.filter((p) => p.life > 0);
// chunks (building debris physics)
for (const c of s.chunks) {
c.vy += 0.32;
c.x += c.vx - SCROLL_SPEED;
c.y += c.vy;
c.rot += c.vrot;
if (c.y >= c.gy) {
c.y = c.gy;
c.vy *= -0.32;
c.vx *= 0.55;
c.vrot *= 0.5;
if (Math.abs(c.vy) < 0.6) {
c.vy = 0;
c.vx *= 0.7;
c.vrot *= 0.6;
c.settled = true;
}
}
if (c.settled) c.life -= 0.004;
}
s.chunks = s.chunks.filter((c) => c.life > 0 && c.x > -40 && c.x < WIDTH + 80);
// building collapse progression
for (const st of s.world.structs) {
if (st.collapse == null || st.dead) continue;
st.collapse += 0.05;
// shed chunks as it falls
if (Math.random() < 0.4) {
s.chunks.push({
x: st.x - s.scroll + rand(0, st.w),
y: st.y - rand(0, st.h) * (1 - st.collapse),
vx: rand(-2.5, 2.5),
vy: rand(-3, 0),
w: rand(8, 18),
h: rand(6, 14),
rot: rand(0, Math.PI * 2),
vrot: rand(-0.25, 0.25),
color: st.color,
gy: st.y,
life: 1,
settled: false,
});
}
if (st.collapse >= 1) {
st.dead = true;
st.collapse = null;
}
}
// dust drift
for (const dd of s.dust) {
dd.x += dd.vx;
dd.y += dd.vy;
if (dd.x < -5) {
dd.x = WIDTH + 5;
dd.y = rand(0, HEIGHT);
}
if (dd.y < 0) dd.y = HEIGHT;
if (dd.y > HEIGHT) dd.y = 0;
}
// ---- boss fight update ----
if (s.status === "boss" && s.boss) {
const bs = s.boss;
// bob up and down
bs.y += bs.vy;
if (bs.y < HEIGHT * 0.22) { bs.y = HEIGHT * 0.22; bs.vy = Math.abs(bs.vy); }
if (bs.y > HEIGHT * 0.82) { bs.y = HEIGHT * 0.82; bs.vy = -Math.abs(bs.vy); }
bs.hitFlash *= 0.85;
// fire missiles at the plane
const now = performance.now();
if (now - bs.lastShot > 900) {
bs.lastShot = now;
const dx = PLANE_X - bs.x;
const dy = s.planeY - bs.y;
const d = Math.hypot(dx, dy) || 1;
const sp = 3.6;
s.bossBullets.push({
x: bs.x - bs.w / 2,
y: bs.y,
vx: (dx / d) * sp,
vy: (dy / d) * sp,
r: 6,
life: 240,
trail: [],
});
playShot();
}
}
// boss projectiles travel + collide with the plane
const liveBossBullets = [];
for (const bb of s.bossBullets) {
bb.trail.push({ x: bb.x, y: bb.y });
if (bb.trail.length > 10) bb.trail.shift();
bb.x += bb.vx;
bb.y += bb.vy;
bb.life -= 1;
let hit = false;
if (s.status === "boss") {
const d = Math.hypot(bb.x - PLANE_X, bb.y - s.planeY);
if (d < 22) {
const dmg = Math.round(rand(BOSS_DMG_MIN, BOSS_DMG_MAX));
s.playerHp = Math.max(0, s.playerHp - dmg);
s.hurtFlash = 1;
s.shake = Math.min(16, s.shake + 6);
explode(s, bb.x, bb.y, 0.6);
hit = true;
}
}
if (!hit && bb.life > 0 && bb.x > -30 && bb.x < WIDTH + 30 && bb.y > -30 && bb.y < HEIGHT + 30) {
liveBossBullets.push(bb);
}
}
s.bossBullets = liveBossBullets;
s.hurtFlash *= 0.9;
s.shake *= 0.85;
// win/lose / boss transition
const quiet =
s.bombs.length === 0 &&
s.bullets.length === 0 &&
s.bossBullets.length === 0 &&
s.particles.length === 0 &&
s.smoke.length === 0 &&
s.chunks.length === 0;
const worldDone = s.scroll >= WORLD_W - WIDTH - 40;
if (s.status === "playing" && worldDone && quiet && !s.awarded) {
// reached the end of the wasteland -> spawn the boss
s.status = "boss";
s.boss = {
hp: BOSS_MAX_HP,
maxHp: BOSS_MAX_HP,
x: WIDTH + 120,
y: HEIGHT * 0.5,
vy: 1.3,
w: 150,
h: 210,
lastShot: performance.now() + 1200,
hitFlash: 0,
};
s.bossWarn = 120;
setStatus("boss");
} else if (s.status === "boss" && s.boss && !s.awarded) {
// slide the boss into position from the right
if (s.boss.x > WIDTH - 150) s.boss.x -= 2.5;
if (s.boss.hp <= 0) {
s.awarded = true;
s.status = "won";
s.score += 2000;
const bx = s.boss.x;
const by = s.boss.y;
for (let i = 0; i < 4; i++) {
setTimeout(() => {
const cur = stateRef.current;
if (cur) explode(cur, bx + rand(-40, 40), by + rand(-60, 60), 2);
}, i * 180);
}
const scrap = Math.round(s.score * s.stats.scrapMult);
setLastScrap(scrap);
setStatus("won");
if (onRunEndRef.current) onRunEndRef.current(scrap);
} else if (s.playerHp <= 0) {
s.awarded = true;
s.status = "lost";
explode(s, PLANE_X, s.planeY, 2);
const scrap = Math.round(s.score * s.stats.scrapMult);
setLastScrap(scrap);
setStatus("lost");
if (onRunEndRef.current) onRunEndRef.current(scrap);
}
}
if (s.bossWarn > 0) s.bossWarn -= 1;
}
// ---- RENDER ----
ctx.save();
if (s.shake > 0.3) {
ctx.translate(rand(-s.shake, s.shake), rand(-s.shake, s.shake));
}
// sun-bleached wasteland ground
const g = ctx.createLinearGradient(0, 0, 0, HEIGHT);
g.addColorStop(0, "#d8c9a0");
g.addColorStop(0.5, "#e3d4ac");
g.addColorStop(1, "#c9b284");
ctx.fillStyle = g;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
// sun glare (upper-left)
const sun = ctx.createRadialGradient(120, 90, 20, 120, 90, HEIGHT);
sun.addColorStop(0, "rgba(255,250,220,0.5)");
sun.addColorStop(0.4, "rgba(255,244,200,0.16)");
sun.addColorStop(1, "rgba(255,244,200,0)");
ctx.fillStyle = sun;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
// sunlit dust motes
for (const dd of s.dust) {
ctx.fillStyle = `rgba(255,248,220,${dd.a})`;
ctx.beginPath();
ctx.arc(dd.x, dd.y, dd.r, 0, Math.PI * 2);
ctx.fill();
}
// ground detail (scrolling)
for (const d of s.world.detail) {
const sx = d.x - s.scroll;
if (sx < -50 || sx > WIDTH + 50) continue;
if (d.k === "patch") {
ctx.fillStyle = "rgba(150,120,80,0.4)";
ctx.beginPath();
ctx.ellipse(sx, d.y, d.s, d.s * 0.6, 0, 0, Math.PI * 2);
ctx.fill();
} else if (d.k === "crack") {
ctx.strokeStyle = "rgba(120,95,60,0.45)";
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(sx, d.y);
ctx.lineTo(sx + d.s * 0.7, d.y + rand(-4, 4));
ctx.stroke();
} else {
ctx.fillStyle = "rgba(180,160,120,0.6)";
ctx.fillRect(sx, d.y, 3, 3);
}
}
// structure shadows (sun from upper-left)
for (const st of s.world.structs) {
if (st.dead) continue;
const sx = st.x - s.scroll;
if (sx < -120 || sx > WIDTH + 120) continue;
ctx.fillStyle = "rgba(80,60,40,0.28)";
ctx.beginPath();
ctx.ellipse(sx + st.w / 2 + 10, st.y + 4, st.w * 0.7, 8, 0, 0, Math.PI * 2);
ctx.fill();
}
// structures
for (const st of s.world.structs) {
const sx = st.x - s.scroll;
if (sx < -120 || sx > WIDTH + 120) continue;
if (st.type === "crater") {
ctx.fillStyle = "rgba(20,16,12,0.65)";
ctx.beginPath();
ctx.ellipse(sx, st.y, st.w, st.w * 0.6, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "rgba(60,50,40,0.6)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(sx, st.y, st.w, st.w * 0.6, 0, 0, Math.PI * 2);
ctx.stroke();
} else if (st.type === "pool") {
ctx.fillStyle = "rgba(80,120,40,0.55)";
ctx.beginPath();
ctx.ellipse(sx, st.y, st.w, st.w * 0.55, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(120,160,70,0.3)";
ctx.beginPath();
ctx.ellipse(sx - 6, st.y - 4, st.w * 0.5, st.w * 0.3, 0, 0, Math.PI * 2);
ctx.fill();
} else if (st.collapse != null) {
// collapsing building -> sinks & tilts into rubble
const c = st.collapse;
const drop = c * st.h * 0.7;
ctx.save();
ctx.translate(sx + st.w / 2, st.y);
ctx.rotate(st.collapseTilt * c * 0.6);
ctx.translate(-(sx + st.w / 2), -st.y);
drawBuilding(ctx, sx, st.y + drop, st.w, st.h * (1 - c * 0.65), st.color, st.windows);
// dust at base
ctx.fillStyle = `rgba(120,100,70,${0.5 * c})`;
ctx.beginPath();
ctx.ellipse(sx + st.w / 2, st.y, st.w * (0.6 + c * 0.4), 8 + c * 6, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
} else if (st.dead) {
// destroyed -> scorch mark
ctx.fillStyle = "rgba(15,12,9,0.7)";
ctx.beginPath();
ctx.ellipse(sx + st.w / 2, st.y, st.w * 0.7, 10, 0, 0, Math.PI * 2);
ctx.fill();
} else if (st.type === "building") {
drawBuilding(ctx, sx, st.y, st.w, st.h, st.color, st.windows);
} else if (st.type === "rubble") {
ctx.fillStyle = st.color;
ctx.beginPath();
ctx.moveTo(sx, st.y);
ctx.lineTo(sx + st.w * 0.2, st.y - st.h);
ctx.lineTo(sx + st.w * 0.5, st.y - st.h * 0.7);
ctx.lineTo(sx + st.w * 0.8, st.y - st.h);
ctx.lineTo(sx + st.w, st.y);
ctx.closePath();
ctx.fill();
ctx.fillStyle = "rgba(0,0,0,0.3)";
ctx.fillRect(sx, st.y - 2, st.w, 3);
} else if (st.type === "barrel") {
ctx.fillStyle = "#3a2a22";
ctx.fillRect(sx + 2, st.y - st.h + 2, st.w, 4);
ctx.fillStyle = st.color;
ctx.fillRect(sx, st.y - st.h, st.w, st.h);
ctx.fillStyle = "rgba(255,255,255,0.15)";
ctx.fillRect(sx + 3, st.y - st.h + 3, 4, st.h - 6);
ctx.fillStyle = "#1a1410";
ctx.fillRect(sx, st.y - 4, st.w, 4);
} else if (st.type === "tree") {
ctx.strokeStyle = "#3a2c1e";
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(sx + st.w / 2, st.y);
ctx.lineTo(sx + st.w / 2, st.y - st.h * 0.6);
ctx.stroke();
ctx.strokeStyle = "#4a3a28";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(sx + st.w / 2, st.y - st.h * 0.5);
ctx.lineTo(sx + st.w / 2 - 12, st.y - st.h);
ctx.moveTo(sx + st.w / 2, st.y - st.h * 0.5);
ctx.lineTo(sx + st.w / 2 + 12, st.y - st.h);
ctx.moveTo(sx + st.w / 2, st.y - st.h * 0.7);
ctx.lineTo(sx + st.w / 2 - 8, st.y - st.h * 0.95);
ctx.stroke();
}
}
// building chunks (debris)
for (const c of s.chunks) {
ctx.save();
ctx.translate(c.x, c.y);
ctx.rotate(c.rot);
ctx.globalAlpha = Math.max(0, Math.min(1, c.life));
ctx.fillStyle = c.color;
ctx.fillRect(-c.w / 2, -c.h / 2, c.w, c.h);
ctx.fillStyle = "rgba(0,0,0,0.28)";
ctx.fillRect(-c.w / 2, c.h / 2 - 3, c.w, 3);
ctx.restore();
}
ctx.globalAlpha = 1;
// bomb trails + bombs
for (const b of s.bombs) {
for (let i = 0; i < b.trail.length; i++) {
const t = b.trail[i];
ctx.fillStyle = `rgba(255,170,80,${(i / b.trail.length) * 0.4})`;
ctx.beginPath();
ctx.arc(t.x, t.y, 1.5 + i * 0.2, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = "#1a1a1a";
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#ff6a2c";
ctx.beginPath();
ctx.arc(b.x, b.y, b.r - 2, 0, Math.PI * 2);
ctx.fill();
}
// bullets (tracer rounds)
ctx.lineWidth = 2;
for (const b of s.bullets) {
ctx.strokeStyle = "rgba(255,230,140,0.85)";
ctx.beginPath();
ctx.moveTo(b.px, b.py);
ctx.lineTo(b.x, b.y);
ctx.stroke();
ctx.fillStyle = "#fff6c8";
ctx.beginPath();
ctx.arc(b.x, b.y, 1.6, 0, Math.PI * 2);
ctx.fill();
}
// smoke (under plane layer)
for (const sm of s.smoke) {
ctx.fillStyle = `rgba(40,38,36,${sm.life * 0.6})`;
ctx.beginPath();
ctx.arc(sm.x, sm.y, sm.r, 0, Math.PI * 2);
ctx.fill();
}
// explosions
for (const ex of s.explosions) {
const a = Math.max(0, ex.life);
const rad = ctx.createRadialGradient(ex.x, ex.y, 0, ex.x, ex.y, ex.r);
rad.addColorStop(0, `rgba(255,250,210,${a})`);
rad.addColorStop(0.35, `rgba(255,150,40,${a * 0.9})`);
rad.addColorStop(0.7, `rgba(200,60,20,${a * 0.6})`);
rad.addColorStop(1, "rgba(80,20,10,0)");
ctx.fillStyle = rad;
ctx.beginPath();
ctx.arc(ex.x, ex.y, ex.r, 0, Math.PI * 2);
ctx.fill();
// shockwave ring
ctx.strokeStyle = `rgba(255,230,180,${a * 0.5})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(ex.x, ex.y, ex.r * 1.05, 0, Math.PI * 2);
ctx.stroke();
}
// debris particles
for (const p of s.particles) {
ctx.fillStyle = `rgba(${p.col},${p.life})`;
ctx.fillRect(p.x, p.y, p.size, p.size);
}
// boss
if (s.boss) drawBoss(ctx, s.boss, performance.now());
// boss projectiles (enemy missiles)
for (const bb of s.bossBullets) {
for (let i = 0; i < bb.trail.length; i++) {
const t = bb.trail[i];
ctx.fillStyle = `rgba(255,80,60,${(i / bb.trail.length) * 0.5})`;
ctx.beginPath();
ctx.arc(t.x, t.y, 1.5 + i * 0.2, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = "#1a1a1a";
ctx.beginPath();
ctx.arc(bb.x, bb.y, bb.r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#ff4d3a";
ctx.beginPath();
ctx.arc(bb.x, bb.y, bb.r - 2, 0, Math.PI * 2);
ctx.fill();
}
// plane + shadow
drawPlaneTop(ctx, PLANE_X, s.planeY, performance.now(), SKINS[skinRef.current] || SKINS.default);
ctx.restore();
// aim line + crosshair
const m = mouseRef.current;
ctx.strokeStyle = "rgba(200,40,30,0.22)";
ctx.setLineDash([5, 7]);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PLANE_X, s.planeY);
ctx.lineTo(m.x, m.y);
ctx.stroke();
ctx.setLineDash([]);
ctx.strokeStyle = "rgba(220,50,35,0.95)";
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.arc(m.x, m.y, 15, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(m.x - 24, m.y); ctx.lineTo(m.x - 7, m.y);
ctx.moveTo(m.x + 7, m.y); ctx.lineTo(m.x + 24, m.y);
ctx.moveTo(m.x, m.y - 24); ctx.lineTo(m.x, m.y - 7);
ctx.moveTo(m.x, m.y + 7); ctx.lineTo(m.x, m.y + 24);
ctx.stroke();
ctx.fillStyle = "rgba(220,50,35,0.95)";
ctx.fillRect(m.x - 1, m.y - 1, 2, 2);
// vignette
const vg = ctx.createRadialGradient(WIDTH / 2, HEIGHT / 2, HEIGHT * 0.3, WIDTH / 2, HEIGHT / 2, HEIGHT * 0.75);
vg.addColorStop(0, "rgba(0,0,0,0)");
vg.addColorStop(1, "rgba(0,0,0,0.22)");
ctx.fillStyle = vg;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
// hurt flash (red pulse when the plane takes damage)
if (s.hurtFlash > 0.02) {
ctx.fillStyle = `rgba(220,40,30,${Math.min(0.4, s.hurtFlash * 0.4)})`;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
}
// player hull bar
const hpPct = Math.max(0, s.playerHp / PLAYER_MAX_HP);
ctx.fillStyle = "rgba(0,0,0,0.55)";
ctx.fillRect(16, 16, 220, 18);
ctx.fillStyle = hpPct > 0.5 ? "#4ade80" : hpPct > 0.25 ? "#facc15" : "#ef4444";
ctx.fillRect(20, 20, 212 * hpPct, 10);
ctx.strokeStyle = "rgba(255,255,255,0.4)";
ctx.lineWidth = 1;
ctx.strokeRect(16, 16, 220, 18);
ctx.fillStyle = "#fff";
ctx.font = "bold 11px ui-sans-serif, system-ui, sans-serif";
ctx.fillText(`HULL ${Math.max(0, Math.round(s.playerHp))}/${PLAYER_MAX_HP}`, 24, 26);
// boss health bar
if (s.boss) {
const bp = Math.max(0, s.boss.hp / s.boss.maxHp);
const bw = 560;
const bx = (WIDTH - bw) / 2;
ctx.fillStyle = "rgba(0,0,0,0.6)";
ctx.fillRect(bx - 4, 44, bw + 8, 22);
ctx.fillStyle = "#ef4444";
ctx.fillRect(bx, 50, bw * bp, 12);
ctx.strokeStyle = "rgba(255,80,80,0.7)";
ctx.lineWidth = 1;
ctx.strokeRect(bx - 4, 44, bw + 8, 22);
ctx.fillStyle = "#ffd9d9";
ctx.font = "bold 12px ui-sans-serif, system-ui, sans-serif";
ctx.textAlign = "center";
ctx.fillText("COLOSSUS-9", WIDTH / 2, 61);
ctx.textAlign = "left";
}
// boss warning banner
if (s.bossWarn > 0) {
const a = Math.min(1, s.bossWarn / 60);
ctx.fillStyle = `rgba(220,40,30,${0.25 * a})`;
ctx.fillRect(0, HEIGHT / 2 - 50, WIDTH, 100);
ctx.fillStyle = `rgba(255,80,70,${a})`;
ctx.font = "bold 40px ui-sans-serif, system-ui, sans-serif";
ctx.textAlign = "center";
ctx.fillText("WARNING BOSS DETECTED", WIDTH / 2, HEIGHT / 2 + 6);
ctx.textAlign = "left";
}
raf = requestAnimationFrame(draw);
};
raf = requestAnimationFrame(draw);
return () => {
running = false;
cancelAnimationFrame(raf);
};
}, [resetKey]);
return (
Shop
{data.infiniteScrap ? "∞" : data.scrap}
{" "}
scrap
{/* Mystery Boxes */}
Mystery Boxes
{BOXES.map((b) => {
const afford = data.infiniteScrap || data.scrap >= b.cost;
return (
{b.name}
{b.desc}
handleOpen(b)}
className={`mt-auto px-3 py-2 rounded-lg font-semibold text-sm transition ${
afford
? "bg-amber-400 text-slate-900 hover:bg-amber-300"
: "bg-slate-800 text-slate-500 cursor-not-allowed"
}`}
>
{afford ? `${b.cost} scrap` : {b.cost} }
);
})}
{/* Crate opening animation */}
{opening && (
setOpening(null)}
/>
)}
{/* Owned skins */}
Your Hangar
{Object.entries(data.skins || {}).filter(([, c]) => c > 0).map(([id, count]) => {
const s = SKINS[id];
if (!s) return null;
const selected = data.selectedSkin === id;
return (
{ onSelectSkin(id); playClick(); }}
className={`rounded-xl border p-3 text-left transition ${
selected
? "border-amber-400 bg-amber-400/10"
: "border-slate-700 bg-slate-900/60 hover:border-slate-500"
}`}
>
{s.name}
{count > 1 && ×{count} }
{selected && }
{s.rarity}
);
})}
{/* Upgrades */}
Upgrades
{UPGRADES.map((u) => {
const level = data.upgrades[u.id] || 0;
const maxed = level >= u.max;
const cost = upgradeCost(u, level);
const afford = data.infiniteScrap || data.scrap >= cost;
const Icon = ICONS[u.icon] || Circle;
return (
{u.name}
Lv {level}/{u.max}
{u.desc}
{Array.from({ length: u.max }).map((_, i) => (
))}
{
if (onBuy(u.id)) playClick();
}}
className={`shrink-0 px-4 py-2 rounded-lg font-semibold text-sm transition ${
maxed
? "bg-slate-800 text-slate-500 cursor-default"
: afford
? "bg-amber-400 text-slate-900 hover:bg-amber-300"
: "bg-slate-800 text-slate-500 cursor-not-allowed"
}`}
>
{maxed ? (
"MAX"
) : afford ? (
`${cost} scrap`
) : (
{cost}
)}
);
})}
);
}