Wasteland Bomber
by BraveBunny642169 lines68.1 KB
import React, { useState } from "react";
import { Shield, Sparkles, Package, Coins, Gift, Zap } from "lucide-react";
import { playClick } from "@/lib/gameAudio";
const CODE = "BRUH";
export default function Admin({ onGrant, onGrantAllSkins }) {
const [input, setInput] = useState("");
const [unlocked, setUnlocked] = useState(false);
const [error, setError] = useState("");
const [flash, setFlash] = useState("");
const submit = () => {
playClick();
if (input.trim().toUpperCase() === CODE) {
onGrant({ infiniteScrap: true, scrap: 999999 });
setUnlocked(true);
setError("");
} else {
setError("Invalid code.");
setUnlocked(false);
}
};
const pulse = (msg) => {
setFlash(msg);
playClick();
setTimeout(() => setFlash(""), 1800);
};
const powers = [
{
label: "Give All Skins",
desc: "Unlock every plane skin",
icon: Package,
color: "text-fuchsia-300",
ring: "hover:border-fuchsia-400/60",
action: () => {
onGrantAllSkins?.();
pulse("All skins granted!");
},
},
{
label: "+100K Scrap",
desc: "Top up your bank",
icon: Coins,
color: "text-amber-300",
ring: "hover:border-amber-400/60",
action: () => {
onGrant({ scrap: 100000 });
pulse("+100,000 scrap");
},
},
{
label: "+1M Scrap",
desc: "Go big",
icon: Zap,
color: "text-yellow-300",
ring: "hover:border-yellow-400/60",
action: () => {
onGrant({ scrap: 1000000 });
pulse("+1,000,000 scrap");
},
},
{
label: "Infinite Scrap",
desc: "Never run dry",
icon: Gift,
color: "text-emerald-300",
ring: "hover:border-emerald-400/60",
action: () => {
onGrant({ infiniteScrap: true });
pulse("Infinite scrap ON");
},
},
];
return (
<div className="w-full rounded-2xl bg-slate-900 border border-slate-800 p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-amber-400/20 text-amber-300">
<Shield className="h-6 w-6" />
</div>
<div>
<h2 className="text-lg font-bold text-amber-200">Admin Panel</h2>
<p className="text-xs text-slate-400">
{unlocked ? "Powers unlocked ā go wild." : "Enter a secret code to unlock powers."}
</p>
</div>
</div>
<div className="flex gap-2">
<input
value={input}
onChange={(e) => 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"
/>
<button
onClick={submit}
className="px-5 py-2.5 rounded-lg bg-amber-400 text-slate-900 font-semibold hover:bg-amber-300 transition"
>
Unlock
</button>
</div>
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
{unlocked && (
<>
<div className="mt-5 mb-3 flex items-center gap-2">
<Sparkles className="h-4 w-4 text-emerald-300" />
<h3 className="text-sm font-bold text-emerald-300 uppercase tracking-widest">
Secret Powers
</h3>
</div>
<div className="grid sm:grid-cols-2 gap-3">
{powers.map((p) => {
const Icon = p.icon;
return (
<button
key={p.label}
onClick={p.action}
className={`flex items-center gap-3 p-4 rounded-xl bg-slate-950 border border-slate-700 ${p.ring} transition text-left`}
>
<div className={`p-2 rounded-lg bg-slate-800 ${p.color}`}>
<Icon className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-semibold text-white">{p.label}</p>
<p className="text-xs text-slate-400">{p.desc}</p>
</div>
</button>
);
})}
</div>
{flash && (
<p className="mt-4 text-center text-sm font-semibold text-emerald-300 animate-pulse">
{flash}
</p>
)}
</>
)}
</div>
);
}
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 (
<div className="flex flex-col items-center gap-4 w-full max-w-[980px]">
<div className="flex items-center justify-between w-full px-1">
<h1 className="text-2xl font-bold tracking-tight text-amber-200">Wasteland Bomber</h1>
<div className="flex items-center gap-5 text-sm">
<span className="text-amber-300 font-semibold">Score: {score}</span>
<span className="text-sky-300 font-semibold">Bombs: {bombsLeft}</span>
</div>
</div>
<div className="relative w-full" style={{ aspectRatio: "960 / 600" }}>
<canvas
ref={canvasRef}
width={960}
height={600}
onMouseDown={onDown}
onMouseUp={onUp}
onMouseLeave={onUp}
onMouseMove={onMove}
onContextMenu={(e) => e.preventDefault()}
className="w-full h-full rounded-xl border border-slate-700 shadow-2xl shadow-black/50 cursor-none touch-none select-none"
/>
{(status === "won" || status === "lost") && (
<div className="absolute inset-0 flex flex-col items-center justify-center rounded-xl bg-black/70 backdrop-blur-sm">
<h2 className={`text-4xl font-extrabold tracking-tight ${status === "won" ? "text-emerald-400" : "text-rose-400"}`}>
{status === "won" ? "Boss Defeated ā Wasteland Cleared" : "You Were Destroyed"}
</h2>
<p className="mt-2 text-slate-300">
Final score: <span className="text-white font-bold">{score}</span>
</p>
<p className="mt-1 text-amber-300 text-sm">+{lastScrap} scrap earned</p>
<button
onClick={() => setResetKey((k) => k + 1)}
className="mt-6 px-6 py-2.5 rounded-lg bg-white text-slate-900 font-semibold hover:bg-slate-200 transition"
>
Play Again
</button>
</div>
)}
</div>
<p className="text-slate-400 text-sm text-center">
Aim with the mouse Ā·{" "}
<span className="text-amber-200 font-semibold">hold mouse</span> to fire the chain gun Ā·{" "}
<kbd className="px-1.5 py-0.5 rounded bg-slate-800 border border-slate-600 text-slate-200 text-xs">Space</kbd>{" "}
to drop a bomb Ā·{" "}
<kbd className="px-1.5 py-0.5 rounded bg-slate-800 border border-slate-600 text-slate-200 text-xs">ā</kbd>
<kbd className="px-1.5 py-0.5 rounded bg-slate-800 border border-slate-600 text-slate-200 text-xs ml-1">ā</kbd>
/{" "}
<kbd className="px-1.5 py-0.5 rounded bg-slate-800 border border-slate-600 text-slate-200 text-xs">W</kbd>
<kbd className="px-1.5 py-0.5 rounded bg-slate-800 border border-slate-600 text-slate-200 text-xs ml-1">S</kbd>{" "}
to fly Ā· red barrels chain-react Ā· survive the boss at the end
</p>
</div>
);
}
function drawBuilding(ctx, x, y, w, h, color, windows) {
// body
ctx.fillStyle = color;
ctx.fillRect(x, y - h, w, h);
// ruined jagged top
ctx.beginPath();
ctx.moveTo(x, y - h);
const steps = 5;
for (let i = 0; i <= steps; i++) {
const px = x + (w / steps) * i;
const py = y - h + (i % 2 === 0 ? 0 : rand(-6, -14));
ctx.lineTo(px, py);
}
ctx.lineTo(x + w, y - h);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
// shadow side
ctx.fillStyle = "rgba(0,0,0,0.28)";
ctx.fillRect(x + w - 8, y - h, 8, h);
// windows
const cols = Math.max(2, Math.floor(w / 16));
const rows = Math.max(2, Math.floor(h / 18));
for (let c = 0; c < cols; c++) {
for (let r = 0; r < rows - 1; r++) {
if (!windows[(r * cols + c) % windows.length]) continue;
const wx = x + 5 + c * 16;
const wy = y - 10 - r * 18;
if (wy < y - h + 8) continue;
ctx.fillStyle = "rgba(20,16,12,0.7)";
ctx.fillRect(wx, wy, 8, 11);
}
}
}
function drawPlaneTop(ctx, x, y, t, skin) {
const sk = skin || SKINS.default;
ctx.save();
ctx.translate(x, y);
const resolveFill = (solid, grad) => {
if (!grad || !grad.length) return solid;
const g = ctx.createLinearGradient(-40, 0, 40, 0);
grad.forEach((c, i) => g.addColorStop(i / Math.max(1, grad.length - 1), c));
return g;
};
const bodyFill = resolveFill(sk.body, sk.bodyGrad);
const wingFill = resolveFill(sk.wing, sk.wingGrad);
// shadow on ground (sun from upper-left)
ctx.fillStyle = "rgba(70,55,35,0.32)";
ctx.beginPath();
ctx.ellipse(18, 14, 36, 12, 0, 0, Math.PI * 2);
ctx.fill();
// wings (swept back)
ctx.fillStyle = wingFill;
ctx.beginPath();
ctx.moveTo(-6, -2);
ctx.lineTo(-30, -34);
ctx.lineTo(-12, -34);
ctx.lineTo(10, -2);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.moveTo(-6, 2);
ctx.lineTo(-30, 34);
ctx.lineTo(-12, 34);
ctx.lineTo(10, 2);
ctx.closePath();
ctx.fill();
// wing camo streaks
ctx.fillStyle = sk.wingStreak;
ctx.fillRect(-26, -30, 14, 4);
ctx.fillRect(-26, 26, 14, 4);
// tail fins
ctx.fillStyle = sk.tail;
ctx.beginPath();
ctx.moveTo(-34, -2);
ctx.lineTo(-46, -16);
ctx.lineTo(-30, -2);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.moveTo(-34, 2);
ctx.lineTo(-46, 16);
ctx.lineTo(-30, 2);
ctx.closePath();
ctx.fill();
// fuselage
ctx.fillStyle = bodyFill;
ctx.beginPath();
ctx.ellipse(-4, 0, 40, 9, 0, 0, Math.PI * 2);
ctx.fill();
// fuselage highlight
ctx.fillStyle = "rgba(255,255,255,0.12)";
ctx.beginPath();
ctx.ellipse(-4, -4, 36, 3, 0, 0, Math.PI * 2);
ctx.fill();
// cockpit
ctx.fillStyle = sk.cockpit;
ctx.beginPath();
ctx.ellipse(8, 0, 8, 5, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = sk.cockpitGlass;
ctx.beginPath();
ctx.ellipse(8, -1, 6, 3, 0, 0, Math.PI * 2);
ctx.fill();
// nose / propeller hub
ctx.fillStyle = sk.nose;
ctx.beginPath();
ctx.arc(34, 0, 4, 0, Math.PI * 2);
ctx.fill();
// spinning propeller (blurred disc)
const spin = (Math.sin(t / 50) + 1) / 2;
ctx.fillStyle = "rgba(220,220,220,0.18)";
ctx.beginPath();
ctx.ellipse(36, 0, 3, 16, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "rgba(30,30,30,0.5)";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(36, -14 * spin);
ctx.lineTo(36, 14 * spin);
ctx.stroke();
ctx.restore();
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
// Giant top-down mech boss that guards the end of the wasteland.
function drawBoss(ctx, b, t) {
ctx.save();
ctx.translate(b.x, b.y);
// shadow
ctx.fillStyle = "rgba(0,0,0,0.35)";
ctx.beginPath();
ctx.ellipse(8, b.h / 2 - 10, b.w * 0.7, 16, 0, 0, Math.PI * 2);
ctx.fill();
// legs / treads
ctx.fillStyle = "#3a3f44";
ctx.fillRect(-b.w / 2 + 14, b.h / 2 - 40, 36, 40);
ctx.fillRect(b.w / 2 - 50, b.h / 2 - 40, 36, 40);
ctx.fillStyle = "#2a2e32";
for (let i = 0; i < 5; i++) {
ctx.fillRect(-b.w / 2 + 16, b.h / 2 - 36 + i * 8, 32, 4);
ctx.fillRect(b.w / 2 - 48, b.h / 2 - 36 + i * 8, 32, 4);
}
// main body
ctx.fillStyle = "#5b6168";
roundRect(ctx, -b.w / 2, -b.h / 2 + 20, b.w, b.h - 60, 14);
ctx.fill();
ctx.fillStyle = "rgba(0,0,0,0.25)";
roundRect(ctx, b.w / 2 - 30, -b.h / 2 + 20, 30, b.h - 60, 14);
ctx.fill();
// armor plate lines
ctx.strokeStyle = "#3a3f44";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(-b.w / 2 + 10, -10);
ctx.lineTo(b.w / 2 - 10, -10);
ctx.moveTo(-b.w / 2 + 10, 30);
ctx.lineTo(b.w / 2 - 10, 30);
ctx.stroke();
// shoulder cannons
ctx.fillStyle = "#444a52";
roundRect(ctx, -b.w / 2 - 14, -b.h / 2 + 30, 18, 50, 6);
ctx.fill();
roundRect(ctx, b.w / 2 - 4, -b.h / 2 + 30, 18, 50, 6);
ctx.fill();
// head
ctx.fillStyle = "#6b7178";
roundRect(ctx, -26, -b.h / 2, 52, 40, 8);
ctx.fill();
// glowing eye
const eyePulse = 0.6 + 0.4 * Math.sin(t / 120);
ctx.fillStyle = `rgba(255,40,30,${eyePulse})`;
ctx.fillRect(-18, -b.h / 2 + 14, 36, 8);
ctx.fillStyle = "rgba(255,120,80,0.5)";
ctx.fillRect(-16, -b.h / 2 + 15, 32, 6);
// left arm cannon (points toward the player on the left)
ctx.fillStyle = "#3a3f44";
ctx.fillRect(-b.w / 2 - 30, -10, 30, 20);
ctx.fillStyle = "#2a2e32";
ctx.fillRect(-b.w / 2 - 34, -6, 8, 12);
// hit flash overlay
if (b.hitFlash > 0.05) {
ctx.fillStyle = `rgba(255,255,255,${Math.min(0.6, b.hitFlash)})`;
roundRect(ctx, -b.w / 2, -b.h / 2 + 20, b.w, b.h - 60, 14);
ctx.fill();
}
ctx.restore();
}
import React from "react";
import {
SKIN_LIST,
RARITY_COLORS,
RARITY_ORDER,
RARITY_LABELS,
RARITY_SELL_VALUES,
} from "@/lib/skins";
import { playClick } from "@/lib/gameAudio";
import { Lock, Check, Sparkles, DollarSign } from "lucide-react";
import PlanePreview from "@/components/games/PlanePreview";
export default function Collection({ data, onSelectSkin, onSellSkin }) {
const skins = data.skins || {};
const ownedCount = SKIN_LIST.filter((s) => (skins[s.id] || 0) > 0).length;
const total = SKIN_LIST.length;
const totalCopies = Object.values(skins).reduce((a, b) => a + (b || 0), 0);
const grouped = RARITY_ORDER.map((r) => ({
rarity: r,
skins: SKIN_LIST.filter((s) => s.rarity === r),
})).filter((g) => g.skins.length > 0);
const handleSell = (s) => {
if (onSellSkin && onSellSkin(s.id)) playClick();
};
return (
<div className="max-w-3xl mx-auto w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-amber-200 flex items-center gap-2">
<Sparkles className="h-5 w-5 text-amber-300" /> Your Collection
</h2>
<div className="flex items-center gap-2">
<span className="px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-700 text-sm">
<span className="text-amber-300 font-semibold">{totalCopies}</span>
<span className="text-slate-400"> copies</span>
</span>
<span className="px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-700 text-sm">
<span className="text-amber-300 font-semibold">{ownedCount}</span>
<span className="text-slate-400"> / {total} skins</span>
</span>
</div>
</div>
<div className="h-2 rounded-full bg-slate-800 overflow-hidden mb-6">
<div
className="h-full bg-gradient-to-r from-amber-400 to-amber-200 transition-all"
style={{ width: `${(ownedCount / total) * 100}%` }}
/>
</div>
<div className="space-y-6">
{grouped.map((g) => (
<div key={g.rarity}>
<h3
className="text-xs font-bold uppercase tracking-widest mb-2"
style={{ color: RARITY_COLORS[g.rarity] }}
>
{RARITY_LABELS[g.rarity]} Ā· {g.skins.filter((s) => (skins[s.id] || 0) > 0).length}/{g.skins.length}
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{g.skins.map((s) => {
const count = skins[s.id] || 0;
const has = count > 0;
const selected = data.selectedSkin === s.id;
const sellValue = RARITY_SELL_VALUES[s.rarity] || 50;
return (
<div
key={s.id}
className={`relative rounded-xl border p-3 transition ${
!has
? "border-slate-800 bg-slate-900/40"
: selected
? "border-amber-400 bg-amber-400/10"
: "border-slate-700 bg-slate-900/60"
}`}
style={has && !selected ? { borderColor: `${RARITY_COLORS[s.rarity]}55` } : undefined}
>
<button
disabled={!has}
onClick={() => {
onSelectSkin(s.id);
playClick();
}}
className={`w-full text-left ${!has ? "cursor-not-allowed" : ""}`}
>
<div
className={`relative h-16 rounded-lg border flex items-center justify-center mb-2 ${
has ? "bg-slate-950 border-slate-800" : "bg-slate-950/60 border-slate-800"
}`}
>
<div className={has ? "" : "opacity-30 grayscale"}>
<PlanePreview skin={s} />
</div>
{!has && (
<div className="absolute inset-0 flex items-center justify-center">
<Lock className="h-5 w-5 text-slate-600" />
</div>
)}
</div>
<div className="flex items-center justify-between">
<span className={`text-xs font-semibold ${has ? "text-white" : "text-slate-500"}`}>
{has ? s.name : "???"}
</span>
<div className="flex items-center gap-1">
{has && count > 1 && (
<span className="text-[10px] text-slate-400 font-semibold">Ć{count}</span>
)}
{selected && <Check className="h-3.5 w-3.5 text-amber-400" />}
</div>
</div>
<span
className="text-[10px] uppercase"
style={{ color: has ? RARITY_COLORS[s.rarity] : "#475569" }}
>
{RARITY_LABELS[s.rarity]}
</span>
</button>
{has && (
<button
onClick={() => handleSell(s)}
className="mt-2 w-full flex items-center justify-center gap-1 px-2 py-1 rounded-lg bg-slate-800 hover:bg-amber-400/15 border border-slate-700 hover:border-amber-400/50 text-[11px] text-slate-300 hover:text-amber-300 transition"
>
<DollarSign className="h-3 w-3" /> Sell Ā· {sellValue}
</button>
)}
</div>
);
})}
</div>
</div>
))}
</div>
</div>
);
}
import React, { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Package } from "lucide-react";
import { RARITY_COLORS, RARITY_LABELS } from "@/lib/skins";
import PlanePreview from "@/components/games/PlanePreview";
// Full-screen crate opening animation: the crate shakes and glows, bursts open,
// then reveals the unlocked skin with its rarity color.
export default function CrateOpening({ box, skin, owned, onClose }) {
const [phase, setPhase] = useState("shaking"); // "shaking" -> "reveal"
const [flash, setFlash] = useState(false);
const rarityColor = RARITY_COLORS[skin.rarity];
useEffect(() => {
const t1 = setTimeout(() => {
setFlash(true);
setPhase("reveal");
}, 1300);
const t2 = setTimeout(() => setFlash(false), 1700);
return () => {
clearTimeout(t1);
clearTimeout(t2);
};
}, []);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
onClick={phase === "reveal" ? onClose : undefined}
>
<AnimatePresence>
{flash && (
<motion.div
initial={{ opacity: 0.9 }}
animate={{ opacity: 0 }}
transition={{ duration: 0.4 }}
className="absolute inset-0 pointer-events-none"
style={{ background: `radial-gradient(circle at center, ${rarityColor}, transparent 70%)` }}
/>
)}
</AnimatePresence>
{phase === "shaking" ? (
<div className="flex flex-col items-center">
<motion.div
animate={{ rotate: [0, -7, 7, -7, 7, 0], x: [0, -3, 3, -3, 3, 0] }}
transition={{ duration: 0.35, repeat: Infinity, repeatType: "loop" }}
className="relative"
>
<motion.div
animate={{ scale: [1, 1.08, 1], opacity: [0.35, 0.75, 0.35] }}
transition={{ duration: 0.6, repeat: Infinity }}
className="absolute -inset-6 rounded-3xl blur-2xl"
style={{ background: `radial-gradient(circle, ${rarityColor}aa, transparent 70%)` }}
/>
<div
className="relative h-40 w-40 rounded-2xl bg-gradient-to-br from-slate-700 to-slate-950 border-2 flex items-center justify-center shadow-2xl"
style={{ borderColor: `${rarityColor}99` }}
>
<Package className="h-20 w-20 text-amber-300" />
</div>
</motion.div>
<p className="mt-6 text-amber-200 font-semibold tracking-wide animate-pulse">
Opening {box.name}ā¦
</p>
</div>
) : (
<motion.div
initial={{ scale: 0.5, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 220, damping: 18 }}
className="relative rounded-2xl bg-slate-900 border-2 p-6 max-w-sm w-full text-center"
style={{ borderColor: rarityColor, boxShadow: `0 0 50px ${rarityColor}66` }}
onClick={(e) => e.stopPropagation()}
>
<p
className="text-xs uppercase tracking-widest mb-1 font-semibold"
style={{ color: rarityColor }}
>
{RARITY_LABELS[skin.rarity] || skin.rarity}
</p>
<h3 className="text-xl font-bold text-white mb-3">{skin.name}</h3>
<div className="h-24 rounded-xl bg-slate-950 border border-slate-700 flex items-center justify-center mb-3">
<PlanePreview skin={skin} />
</div>
<p className="text-sm text-slate-300 mb-4">
{owned ? "Already owned ā re-equipped!" : "New skin unlocked & equipped!"}
</p>
<button
onClick={onClose}
className="px-5 py-2 rounded-lg bg-amber-400 text-slate-900 font-semibold hover:bg-amber-300 transition"
>
Nice!
</button>
</motion.div>
)}
</div>
);
}
import React, { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Package } from "lucide-react";
import { RARITY_COLORS, RARITY_LABELS } from "@/lib/skins";
import PlanePreview from "@/components/games/PlanePreview";
// Full-screen crate opening animation: the crate shakes and glows, bursts open,
// then reveals the unlocked skin with its rarity color.
export default function CrateOpening({ box, skin, owned, onClose }) {
const [phase, setPhase] = useState("shaking"); // "shaking" -> "reveal"
const [flash, setFlash] = useState(false);
const rarityColor = RARITY_COLORS[skin.rarity];
useEffect(() => {
const t1 = setTimeout(() => {
setFlash(true);
setPhase("reveal");
}, 1300);
const t2 = setTimeout(() => setFlash(false), 1700);
return () => {
clearTimeout(t1);
clearTimeout(t2);
};
}, []);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
onClick={phase === "reveal" ? onClose : undefined}
>
<AnimatePresence>
{flash && (
<motion.div
initial={{ opacity: 0.9 }}
animate={{ opacity: 0 }}
transition={{ duration: 0.4 }}
className="absolute inset-0 pointer-events-none"
style={{ background: `radial-gradient(circle at center, ${rarityColor}, transparent 70%)` }}
/>
)}
</AnimatePresence>
{phase === "shaking" ? (
<div className="flex flex-col items-center">
<motion.div
animate={{ rotate: [0, -7, 7, -7, 7, 0], x: [0, -3, 3, -3, 3, 0] }}
transition={{ duration: 0.35, repeat: Infinity, repeatType: "loop" }}
className="relative"
>
<motion.div
animate={{ scale: [1, 1.08, 1], opacity: [0.35, 0.75, 0.35] }}
transition={{ duration: 0.6, repeat: Infinity }}
className="absolute -inset-6 rounded-3xl blur-2xl"
style={{ background: `radial-gradient(circle, ${rarityColor}aa, transparent 70%)` }}
/>
<div
className="relative h-40 w-40 rounded-2xl bg-gradient-to-br from-slate-700 to-slate-950 border-2 flex items-center justify-center shadow-2xl"
style={{ borderColor: `${rarityColor}99` }}
>
<Package className="h-20 w-20 text-amber-300" />
</div>
</motion.div>
<p className="mt-6 text-amber-200 font-semibold tracking-wide animate-pulse">
Opening {box.name}ā¦
</p>
</div>
) : (
<motion.div
initial={{ scale: 0.5, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 220, damping: 18 }}
className="relative rounded-2xl bg-slate-900 border-2 p-6 max-w-sm w-full text-center"
style={{ borderColor: rarityColor, boxShadow: `0 0 50px ${rarityColor}66` }}
onClick={(e) => e.stopPropagation()}
>
<p
className="text-xs uppercase tracking-widest mb-1 font-semibold"
style={{ color: rarityColor }}
>
{RARITY_LABELS[skin.rarity] || skin.rarity}
</p>
<h3 className="text-xl font-bold text-white mb-3">{skin.name}</h3>
<div className="h-24 rounded-xl bg-slate-950 border border-slate-700 flex items-center justify-center mb-3">
<PlanePreview skin={skin} />
</div>
<p className="text-sm text-slate-300 mb-4">
{owned ? "Already owned ā re-equipped!" : "New skin unlocked & equipped!"}
</p>
<button
onClick={onClose}
className="px-5 py-2 rounded-lg bg-amber-400 text-slate-900 font-semibold hover:bg-amber-300 transition"
>
Nice!
</button>
</motion.div>
)}
</div>
);
}
import React from "react";
// Shared SVG preview of an airplane skin, used in the Shop, Collection, and crate reveal.
// Supports optional multi-color gradients via skin.bodyGrad / skin.wingGrad arrays.
export default function PlanePreview({ skin }) {
const gid = `bp-${skin.id}`;
const wgid = `wp-${skin.id}`;
const bodyFill = skin.bodyGrad ? `url(#${gid})` : skin.body;
const wingFill = skin.wingGrad ? `url(#${wgid})` : skin.wing;
const renderStops = (colors, id) =>
colors ? (
<linearGradient id={id} x1="0%" y1="0%" x2="100%" y2="0%">
{colors.map((c, i) => (
<stop key={i} offset={`${(i / Math.max(1, colors.length - 1)) * 100}%`} stopColor={c} />
))}
</linearGradient>
) : null;
return (
<svg viewBox="0 0 80 80" className="w-full h-full">
<defs>
{renderStops(skin.bodyGrad, gid)}
{renderStops(skin.wingGrad, wgid)}
</defs>
<g transform="translate(40,40)">
<ellipse cx="2" cy="2" rx="20" ry="6" fill="rgba(0,0,0,0.25)" />
<polygon points="-2,0 -16,-18 -6,-18 6,0" fill={wingFill} />
<polygon points="-2,0 -16,18 -6,18 6,0" fill={wingFill} />
<polygon points="-18,-1 -24,-9 -15,-1" fill={skin.tail} />
<polygon points="-18,1 -24,9 -15,1" fill={skin.tail} />
<ellipse cx="-2" cy="0" rx="22" ry="5" fill={bodyFill} />
<ellipse cx="4" cy="0" rx="4" ry="2.5" fill={skin.cockpit} />
<circle cx="18" cy="0" r="2.5" fill={skin.nose} />
</g>
</svg>
);
}
import React, { useState } from "react";
import { UPGRADES, upgradeCost } from "@/lib/upgrades";
import { playClick } from "@/lib/gameAudio";
import { BOXES, rollBox, RARITY_COLORS, SKINS } from "@/lib/skins";
import { Bomb, Maximize, Zap, Flame, Coins, Crosshair, Circle, Lock, Package, Sparkles, Check } from "lucide-react";
import CrateOpening from "@/components/games/CrateOpening";
import PlanePreview from "@/components/games/PlanePreview";
const ICONS = { Bomb, Maximize, Zap, Flame, Coins, Crosshair };
export default function Shop({ data, onBuy, onOpenBox, onSelectSkin }) {
const [opening, setOpening] = useState(null);
const handleOpen = (box) => {
const skin = rollBox(box);
const ok = onOpenBox(box.cost, skin.id);
if (ok) {
setOpening({ box, skin, owned: (data.skins[skin.id] || 0) > 0 });
playClick();
}
};
return (
<div className="max-w-3xl mx-auto w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-amber-200">Shop</h2>
<span className="px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-700 text-sm">
<span className="text-amber-300 font-semibold">
{data.infiniteScrap ? "ā" : data.scrap}
</span>{" "}
<span className="text-slate-400">scrap</span>
</span>
</div>
{/* Mystery Boxes */}
<div className="mb-6">
<h3 className="text-sm font-semibold text-slate-300 mb-2 flex items-center gap-2">
<Package className="h-4 w-4 text-amber-300" /> Mystery Boxes
</h3>
<div className="grid sm:grid-cols-3 gap-3">
{BOXES.map((b) => {
const afford = data.infiniteScrap || data.scrap >= b.cost;
return (
<div
key={b.id}
className="rounded-xl border border-slate-700 bg-slate-900/60 p-3 flex flex-col"
>
<div className="h-20 rounded-lg bg-gradient-to-br from-slate-800 to-slate-950 border border-slate-700 flex items-center justify-center mb-2">
<Package className="h-8 w-8 text-amber-300/70" />
</div>
<h4 className="font-semibold text-white text-sm">{b.name}</h4>
<p className="text-xs text-slate-400 mb-2">{b.desc}</p>
<button
disabled={!afford}
onClick={() => 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` : <span className="flex items-center justify-center gap-1"><Lock className="h-3.5 w-3.5" />{b.cost}</span>}
</button>
</div>
);
})}
</div>
</div>
{/* Crate opening animation */}
{opening && (
<CrateOpening
box={opening.box}
skin={opening.skin}
owned={opening.owned}
onClose={() => setOpening(null)}
/>
)}
{/* Owned skins */}
<div className="mb-6">
<h3 className="text-sm font-semibold text-slate-300 mb-2 flex items-center gap-2">
<Sparkles className="h-4 w-4 text-amber-300" /> Your Hangar
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{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 (
<button
key={id}
onClick={() => { 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"
}`}
>
<div className="h-16 rounded-lg bg-slate-950 border border-slate-800 flex items-center justify-center mb-2">
<PlanePreview skin={s} />
</div>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-white">{s.name}</span>
<div className="flex items-center gap-1">
{count > 1 && <span className="text-[10px] text-slate-400 font-semibold">Ć{count}</span>}
{selected && <Check className="h-3.5 w-3.5 text-amber-400" />}
</div>
</div>
<span className="text-[10px] uppercase" style={{ color: RARITY_COLORS[s.rarity] }}>
{s.rarity}
</span>
</button>
);
})}
</div>
</div>
{/* Upgrades */}
<h3 className="text-sm font-semibold text-slate-300 mb-2">Upgrades</h3>
<div className="grid gap-3">
{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 (
<div
key={u.id}
className="flex items-center gap-4 rounded-xl border border-slate-700 bg-slate-900/60 p-4"
>
<div className="h-11 w-11 shrink-0 rounded-lg bg-slate-800 flex items-center justify-center text-amber-300">
<Icon className="h-6 w-6" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-white">{u.name}</h3>
<span className="text-xs text-slate-400">
Lv {level}/{u.max}
</span>
</div>
<p className="text-sm text-slate-400">{u.desc}</p>
<div className="mt-1.5 flex gap-1">
{Array.from({ length: u.max }).map((_, i) => (
<span
key={i}
className={`h-1.5 w-6 rounded-full ${
i < level ? "bg-amber-400" : "bg-slate-700"
}`}
/>
))}
</div>
</div>
<button
disabled={maxed || !afford}
onClick={() => {
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`
) : (
<span className="flex items-center gap-1">
<Lock className="h-3.5 w-3.5" /> {cost}
</span>
)}
</button>
</div>
);
})}
</div>
</div>
);
}
Game Source: Wasteland Bomber
Creator: BraveBunny64
Libraries: none
Complexity: complex (2169 lines, 68.1 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: wasteland-bomber-bravebunny64" to link back to the original. Then publish at arcadelab.ai/publish.