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 (

Admin Panel

{unlocked ? "Powers unlocked — go wild." : "Enter a secret code to unlock powers."}

setInput(e.target.value)} onKeyDown={(e) => e.key === "Enter" && submit()} type="password" placeholder="Enter code..." className="flex-1 px-4 py-2.5 rounded-lg bg-slate-950 border border-slate-700 text-white placeholder-slate-500 focus:outline-none focus:border-amber-400" />
{error &&

{error}

} {unlocked && ( <>

Secret Powers

{powers.map((p) => { const Icon = p.icon; return ( ); })}
{flash && (

{flash}

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

Wasteland Bomber

Score: {score} Bombs: {bombsLeft}
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") && (

{status === "won" ? "Boss Defeated — Wasteland Cleared" : "You Were Destroyed"}

Final score: {score}

+{lastScrap} scrap earned

)}

Aim with the mouse ·{" "} hold mouse to fire the chain gun ·{" "} Space{" "} to drop a bomb ·{" "} /{" "} W S{" "} to fly · red barrels chain-react · survive the boss at the end

); } 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 (

Your Collection

{totalCopies} copies {ownedCount} / {total} skins
{grouped.map((g) => (

{RARITY_LABELS[g.rarity]} · {g.skins.filter((s) => (skins[s.id] || 0) > 0).length}/{g.skins.length}

{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 (
{has && ( )}
); })}
))}
); } 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 (
{flash && ( )} {phase === "shaking" ? (

Opening {box.name}…

) : ( e.stopPropagation()} >

{RARITY_LABELS[skin.rarity] || skin.rarity}

{skin.name}

{owned ? "Already owned — re-equipped!" : "New skin unlocked & equipped!"}

)}
); } 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 (
{flash && ( )} {phase === "shaking" ? (

Opening {box.name}…

) : ( e.stopPropagation()} >

{RARITY_LABELS[skin.rarity] || skin.rarity}

{skin.name}

{owned ? "Already owned — re-equipped!" : "New skin unlocked & equipped!"}

)}
); } 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 ? ( {colors.map((c, i) => ( ))} ) : null; return ( {renderStops(skin.bodyGrad, gid)} {renderStops(skin.wingGrad, wgid)} ); } 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 (

Shop

{data.infiniteScrap ? "∞" : data.scrap} {" "} scrap
{/* Mystery Boxes */}

Mystery Boxes

{BOXES.map((b) => { const afford = data.infiniteScrap || data.scrap >= b.cost; return (

{b.name}

{b.desc}

); })}
{/* Crate opening animation */} {opening && ( setOpening(null)} /> )} {/* Owned skins */}

Your Hangar

{Object.entries(data.skins || {}).filter(([, c]) => c > 0).map(([id, count]) => { const s = SKINS[id]; if (!s) return null; const selected = data.selectedSkin === id; return ( ); })}
{/* Upgrades */}

Upgrades

{UPGRADES.map((u) => { const level = data.upgrades[u.id] || 0; const maxed = level >= u.max; const cost = upgradeCost(u, level); const afford = data.infiniteScrap || data.scrap >= cost; const Icon = ICONS[u.icon] || Circle; return (

{u.name}

Lv {level}/{u.max}

{u.desc}

{Array.from({ length: u.max }).map((_, i) => ( ))}
); })}
); }