šŸŽ®ArcadeLab

Wasteland Bomber

by BraveBunny64
2169 lines68.1 KB
ā–¶ Play
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.