repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
when blackout ?
stars
latest
clone command
git clone gitlawb://did:key:z6MkjiiY...3Cmt/when-blackoutgit clone gitlawb://did:key:z6MkjiiY.../when-blackout3914b18async from playground4h ago| #1 | import { useState, useEffect, useRef, useCallback, useMemo } from "react"; |
| #2 | |
| #3 | /* ============================================================ |
| #4 | BLOCKOUT 3D — Landing + Skill Tree + Harder Gameplay |
| #5 | ============================================================ */ |
| #6 | |
| #7 | // ===== TYPES ===== |
| #8 | |
| #9 | type CellColor = string | null; |
| #10 | type CellType = "normal" | "hazard" | "pressure"; |
| #11 | type Cell = { color: CellColor; type: CellType }; |
| #12 | type Board = Cell[][]; |
| #13 | type Coord = { r: number; c: number }; |
| #14 | type PieceDef = { id: string; cells: Coord[]; color: string }; |
| #15 | type LeaderboardEntry = { |
| #16 | id: string; username: string; ethAddress: string; |
| #17 | score: number; level: number; date: string; |
| #18 | }; |
| #19 | type Achievement = { id: string; name: string; desc: string; icon: string; check: (s: GameState) => boolean }; |
| #20 | type Challenge = { id: string; desc: string; goal: number; type: "score" | "clear" | "combo" | "place"; reward: string }; |
| #21 | type Skill = { id: string; name: string; desc: string; icon: string; branch: "speed" | "precision" | "power"; cost: number }; |
| #22 | type SkillId = string; |
| #23 | |
| #24 | interface GameState { |
| #25 | score: number; combo: number; maxCombo: number; |
| #26 | level: number; xp: number; totalCleared: number; |
| #27 | piecesPlaced: number; bombsUsed: number; |
| #28 | shufflesUsed: number; undosUsed: number; |
| #29 | challengeProgress: number; |
| #30 | } |
| #31 | |
| #32 | // ===== CONSTANTS ===== |
| #33 | |
| #34 | const BOARD_SIZE = 8; |
| #35 | const XP_PER_LEVEL = 100; |
| #36 | const COLORS = [ |
| #37 | "#7c3aed", "#3b82f6", "#10b981", "#f59e0b", |
| #38 | "#ef4444", "#ec4899", "#06b6d4", "#8b5cf6", |
| #39 | "#f97316", "#14b8a6", "#6366f1", "#d946ef", |
| #40 | ]; |
| #41 | |
| #42 | const PIECE_DEFS: Omit<PieceDef, "id" | "color">[] = [ |
| #43 | { cells: [{ r: 0, c: 0 }] }, |
| #44 | { cells: [{ r: 0, c: 0 }, { r: 0, c: 1 }] }, |
| #45 | { cells: [{ r: 0, c: 0 }, { r: 1, c: 0 }] }, |
| #46 | { cells: [{ r: 0, c: 0 }, { r: 0, c: 1 }, { r: 0, c: 2 }] }, |
| #47 | { cells: [{ r: 0, c: 0 }, { r: 1, c: 0 }, { r: 2, c: 0 }] }, |
| #48 | { cells: [{ r: 0, c: 0 }, { r: 1, c: 0 }, { r: 1, c: 1 }] }, |
| #49 | { cells: [{ r: 0, c: 0 }, { r: 0, c: 1 }, { r: 1, c: 0 }] }, |
| #50 | { cells: [{ r: 0, c: 1 }, { r: 1, c: 0 }, { r: 1, c: 1 }] }, |
| #51 | { cells: [{ r: 0, c: 0 }, { r: 0, c: 1 }, { r: 1, c: 1 }] }, |
| #52 | { cells: [{ r: 0, c: 0 }, { r: 0, c: 1 }, { r: 0, c: 2 }, { r: 1, c: 1 }] }, |
| #53 | { cells: [{ r: 0, c: 0 }, { r: 0, c: 1 }, { r: 1, c: 1 }, { r: 1, c: 2 }] }, |
| #54 | { cells: [{ r: 0, c: 1 }, { r: 0, c: 2 }, { r: 1, c: 0 }, { r: 1, c: 1 }] }, |
| #55 | { cells: [{ r: 0, c: 0 }, { r: 0, c: 1 }, { r: 1, c: 0 }, { r: 1, c: 1 }] }, |
| #56 | { cells: [{ r: 0, c: 1 }, { r: 1, c: 0 }, { r: 1, c: 1 }, { r: 1, c: 2 }, { r: 2, c: 1 }] }, |
| #57 | { cells: [{ r: 0, c: 0 }, { r: 0, c: 1 }, { r: 0, c: 2 }, { r: 0, c: 3 }] }, |
| #58 | { cells: [{ r: 0, c: 0 }, { r: 1, c: 0 }, { r: 2, c: 0 }, { r: 3, c: 0 }] }, |
| #59 | { cells: [{ r: 0, c: 0 }, { r: 1, c: 0 }, { r: 2, c: 0 }, { r: 2, c: 1 }] }, |
| #60 | { cells: [{ r: 0, c: 0 }, { r: 0, c: 1 }, { r: 0, c: 2 }, { r: 1, c: 0 }] }, |
| #61 | { cells: [{ r: 0, c: 0 }, { r: 0, c: 1 }, { r: 1, c: 1 }, { r: 1, c: 2 }, { r: 2, c: 2 }] }, |
| #62 | { cells: [{ r: 0, c: 0 }, { r: 0, c: 1 }, { r: 1, c: 0 }, { r: 2, c: 0 }] }, |
| #63 | ]; |
| #64 | |
| #65 | // Big/hard pieces for higher levels |
| #66 | const HARD_PIECE_DEFS: Omit<PieceDef, "id" | "color">[] = [ |
| #67 | { cells: [{ r: 0, c: 0 }, { r: 0, c: 1 }, { r: 0, c: 2 }, { r: 1, c: 0 }, { r: 1, c: 1 }, { r: 1, c: 2 }] }, |
| #68 | { cells: [{ r: 0, c: 0 }, { r: 1, c: 0 }, { r: 2, c: 0 }, { r: 0, c: 1 }, { r: 0, c: 2 }, { r: 2, c: 2 }] }, |
| #69 | { cells: [{ r: 0, c: 0 }, { r: 0, c: 1 }, { r: 0, c: 2 }, { r: 0, c: 3 }, { r: 1, c: 0 }, { r: 1, c: 3 }] }, |
| #70 | { cells: [{ r: 0, c: 1 }, { r: 0, c: 2 }, { r: 1, c: 0 }, { r: 1, c: 3 }, { r: 2, c: 0 }, { r: 2, c: 3 }] }, |
| #71 | { cells: [{ r: 0, c: 0 }, { r: 0, c: 1 }, { r: 0, c: 2 }, { r: 1, c: 1 }, { r: 2, c: 0 }, { r: 2, c: 1 }, { r: 2, c: 2 }] }, |
| #72 | ]; |
| #73 | |
| #74 | const ALL_PIECE_DEFS = [...PIECE_DEFS, ...HARD_PIECE_DEFS]; |
| #75 | |
| #76 | const ACHIEVEMENTS: Achievement[] = [ |
| #77 | { id: "first_place", name: "First Block", desc: "Place your first piece", icon: "🎯", check: (s) => s.piecesPlaced >= 1 }, |
| #78 | { id: "combo_3", name: "Combo Master", desc: "Reach a 3x combo", icon: "🔥", check: (s) => s.maxCombo >= 3 }, |
| #79 | { id: "combo_5", name: "Unstoppable", desc: "Reach a 5x combo", icon: "💥", check: (s) => s.maxCombo >= 5 }, |
| #80 | { id: "score_500", name: "Rising Star", desc: "Score 500 points", icon: "⭐", check: (s) => s.score >= 500 }, |
| #81 | { id: "score_2000", name: "Block Master", desc: "Score 2,000 points", icon: "👑", check: (s) => s.score >= 2000 }, |
| #82 | { id: "score_5000", name: "Legend", desc: "Score 5,000 points", icon: "🏆", check: (s) => s.score >= 5000 }, |
| #83 | { id: "cleared_50", name: "Line Destroyer", desc: "Clear 50 lines total", icon: "💎", check: (s) => s.totalCleared >= 50 }, |
| #84 | { id: "level_5", name: "Veteran", desc: "Reach level 5", icon: "🎖️", check: (s) => s.level >= 5 }, |
| #85 | { id: "level_10", name: "Elite", desc: "Reach level 10", icon: "🏅", check: (s) => s.level >= 10 }, |
| #86 | { id: "bomb_used", name: "Demolition", desc: "Use a bomb power-up", icon: "💣", check: (s) => s.bombsUsed >= 1 }, |
| #87 | { id: "pieces_100", name: "Architect", desc: "Place 100 pieces", icon: "🧱", check: (s) => s.piecesPlaced >= 100 }, |
| #88 | { id: "challenge_done", name: "Challenger", desc: "Complete a daily challenge", icon: "🎪", check: (s) => s.challengeProgress >= 1 }, |
| #89 | ]; |
| #90 | |
| #91 | const DAILY_CHALLENGES: Challenge[] = [ |
| #92 | { id: "dc1", desc: "Score 1,000 points", goal: 1000, type: "score", reward: "+2 Bombs" }, |
| #93 | { id: "dc2", desc: "Clear 15 lines", goal: 15, type: "clear", reward: "+3 Shuffles" }, |
| #94 | { id: "dc3", desc: "Reach 4x combo", goal: 4, type: "combo", reward: "+2 Undos" }, |
| #95 | { id: "dc4", desc: "Place 25 pieces", goal: 25, type: "place", reward: "+1 of each" }, |
| #96 | ]; |
| #97 | |
| #98 | // ===== SKILL TREE ===== |
| #99 | |
| #100 | const SKILLS: Skill[] = [ |
| #101 | // Speed branch |
| #102 | { id: "slow_time", name: "Slow Time", desc: "Hazards spawn 50% slower", icon: "⏳", branch: "speed", cost: 1 }, |
| #103 | { id: "quick_hands", name: "Quick Hands", desc: "+1 piece choice (4 instead of 3)", icon: "⚡", branch: "speed", cost: 2 }, |
| #104 | { id: "time_warp", name: "Time Warp", desc: "Pressure rows move 30% slower", icon: "🌀", branch: "speed", cost: 2 }, |
| #105 | // Precision branch |
| #106 | { id: "wide_bomb", name: "Wide Bomb", desc: "Bomb clears 5x5 area instead of 3x3", icon: "💥", branch: "precision", cost: 1 }, |
| #107 | { id: "ghost_guide", name: "Ghost Guide", desc: "Shows all valid placements at once", icon: "👁️", branch: "precision", cost: 2 }, |
| #108 | { id: "precision_score", name: "Precision", desc: "+50% score for single-line clears", icon: "🎯", branch: "precision", cost: 2 }, |
| #109 | // Power branch |
| #110 | { id: "second_chance", name: "Second Chance", desc: "Free undo once per game", icon: "💖", branch: "power", cost: 1 }, |
| #111 | { id: "combo_extend", name: "Combo Extend", desc: "Combo doesn't reset on empty placement", icon: "🔗", branch: "power", cost: 2 }, |
| #112 | { id: "nuclear", name: "Nuclear", desc: "Bomb also clears hazards in range", icon: "☢️", branch: "power", cost: 3 }, |
| #113 | ]; |
| #114 | |
| #115 | // ===== AI ENGINE ===== |
| #116 | |
| #117 | // AI scoring heuristic — evaluates how good a board position is |
| #118 | const evaluateBoard = (board: Board): number => { |
| #119 | let score = 0; |
| #120 | |
| #121 | // 1. Empty cells = more options (higher is better) |
| #122 | let emptyCount = 0; |
| #123 | for (let r = 0; r < BOARD_SIZE; r++) |
| #124 | for (let c = 0; c < BOARD_SIZE; c++) |
| #125 | if (board[r][c].color === null) emptyCount++; |
| #126 | score += emptyCount * 2; |
| #127 | |
| #128 | // 2. Near-complete rows/cols = closer to clearing (bonus) |
| #129 | for (let r = 0; r < BOARD_SIZE; r++) { |
| #130 | const filled = board[r].filter((c) => c.color !== null).length; |
| #131 | if (filled >= 6) score += filled * 3; // Almost done! |
| #132 | if (filled === BOARD_SIZE) score += 50; // Will clear |
| #133 | } |
| #134 | for (let c = 0; c < BOARD_SIZE; c++) { |
| #135 | let filled = 0; |
| #136 | for (let r = 0; r < BOARD_SIZE; r++) if (board[r][c].color !== null) filled++; |
| #137 | if (filled >= 6) score += filled * 3; |
| #138 | if (filled === BOARD_SIZE) score += 50; |
| #139 | } |
| #140 | |
| #141 | // 3. Clustered blocks = harder to clear (penalty) |
| #142 | for (let r = 0; r < BOARD_SIZE; r++) { |
| #143 | for (let c = 0; c < BOARD_SIZE; c++) { |
| #144 | if (board[r][c].color === null) continue; |
| #145 | // Count adjacent filled cells |
| #146 | let adjacent = 0; |
| #147 | for (const [dr, dc] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) { |
| #148 | const nr = r + dr, nc = c + dc; |
| #149 | if (nr >= 0 && nr < BOARD_SIZE && nc >= 0 && nc < BOARD_SIZE && board[nr][nc].color !== null) adjacent++; |
| #150 | } |
| #151 | if (adjacent >= 3) score -= 2; // Tight cluster penalty |
| #152 | } |
| #153 | } |
| #154 | |
| #155 | // 4. Isolated empty cells = hard to fill (penalty) |
| #156 | for (let r = 0; r < BOARD_SIZE; r++) { |
| #157 | for (let c = 0; c < BOARD_SIZE; c++) { |
| #158 | if (board[r][c].color !== null) continue; |
| #159 | let freeNeighbors = 0; |
| #160 | for (const [dr, dc] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) { |
| #161 | const nr = r + dr, nc = c + dc; |
| #162 | if (nr >= 0 && nr < BOARD_SIZE && nc >= 0 && nc < BOARD_SIZE && board[nr][nc].color === null) freeNeighbors++; |
| #163 | } |
| #164 | if (freeNeighbors === 0) score -= 10; // Isolated hole |
| #165 | } |
| #166 | } |
| #167 | |
| #168 | // 5. Hazard cells near edges = less disruptive (small bonus) |
| #169 | for (let r = 0; r < BOARD_SIZE; r++) { |
| #170 | for (let c = 0; c < BOARD_SIZE; c++) { |
| #171 | if (board[r][c].type === "hazard") { |
| #172 | if (r === 0 || r === BOARD_SIZE - 1 || c === 0 || c === BOARD_SIZE - 1) score += 1; |
| #173 | } |
| #174 | } |
| #175 | } |
| #176 | |
| #177 | return score; |
| #178 | }; |
| #179 | |
| #180 | // Find the best placement for a piece on the board |
| #181 | const findBestPlacement = (board: Board, piece: PieceDef): { pos: Coord; score: number; clears: number } | null => { |
| #182 | let best: { pos: Coord; score: number; clears: number } | null = null; |
| #183 | |
| #184 | for (let r = 0; r < BOARD_SIZE; r++) { |
| #185 | for (let c = 0; c < BOARD_SIZE; c++) { |
| #186 | if (!canPlace(board, piece, { r, c })) continue; |
| #187 | |
| #188 | const newBoard = placePiece(board, piece, { r, c }, piece.color); |
| #189 | const { board: clearedBoard, clearedRows, clearedCols } = clearLines(newBoard); |
| #190 | const clears = clearedRows.length + clearedCols.length; |
| #191 | const finalBoard = applyGravity(clearedBoard); |
| #192 | const boardScore = evaluateBoard(finalBoard) + clears * 100; |
| #193 | |
| #194 | if (!best || boardScore > best.score) { |
| #195 | best = { pos: { r, c }, score: boardScore, clears }; |
| #196 | } |
| #197 | } |
| #198 | } |
| #199 | |
| #200 | return best; |
| #201 | }; |
| #202 | |
| #203 | // AI Coach tips based on game state |
| #204 | const getAICoachTip = (state: { |
| #205 | board: Board; combo: number; level: number; piecesPlaced: number; |
| #206 | totalCleared: number; score: number; |
| #207 | }): string | null => { |
| #208 | const { board, combo, level, piecesPlaced, score } = state; |
| #209 | |
| #210 | // Count empty cells |
| #211 | let emptyCount = 0; |
| #212 | let hazardCount = 0; |
| #213 | for (let r = 0; r < BOARD_SIZE; r++) |
| #214 | for (let c = 0; c < BOARD_SIZE; c++) { |
| #215 | if (board[r][c].color === null) emptyCount++; |
| #216 | if (board[r][c].type === "hazard") hazardCount++; |
| #217 | } |
| #218 | |
| #219 | // Board is getting full |
| #220 | if (emptyCount < 15 && piecesPlaced > 10) { |
| #221 | return "Board is filling up! Use Bomb or focus on clearing lines."; |
| #222 | } |
| #223 | |
| #224 | // Combo opportunity |
| #225 | if (combo >= 2) { |
| #226 | return `${combo}x combo active! Keep clearing to chain it higher!`; |
| #227 | } |
| #228 | |
| #229 | // Near-complete row |
| #230 | for (let r = 0; r < BOARD_SIZE; r++) { |
| #231 | const filled = board[r].filter((c) => c.color !== null).length; |
| #232 | if (filled === BOARD_SIZE - 1) { |
| #233 | return `Row ${r + 1} is almost complete! One more block to clear it.`; |
| #234 | } |
| #235 | } |
| #236 | |
| #237 | // Near-complete column |
| #238 | for (let c = 0; c < BOARD_SIZE; c++) { |
| #239 | let filled = 0; |
| #240 | for (let r = 0; r < BOARD_SIZE; r++) if (board[r][c].color !== null) filled++; |
| #241 | if (filled === BOARD_SIZE - 1) { |
| #242 | return `Column ${c + 1} needs just one more block!`; |
| #243 | } |
| #244 | } |
| #245 | |
| #246 | // Hazards building up |
| #247 | if (hazardCount >= 3) { |
| #248 | return `${hazardCount} hazard blocks on board! Use Bomb (nuclear skill) to clear them.`; |
| #249 | } |
| #250 | |
| #251 | // First few moves |
| #252 | if (piecesPlaced < 5 && piecesPlaced > 0) { |
| #253 | return "Tip: Place pieces near edges to keep the center open for bigger shapes."; |
| #254 | } |
| #255 | |
| #256 | // Low score but many pieces placed |
| #257 | if (piecesPlaced > 20 && score < 200) { |
| #258 | return "Focus on completing full rows/columns for bonus points!"; |
| #259 | } |
| #260 | |
| #261 | // Good performance |
| #262 | if (combo >= 4) { |
| #263 | return "Incredible combo! You're playing like a pro!"; |
| #264 | } |
| #265 | |
| #266 | // Level milestone |
| #267 | if (level >= 5 && level % 5 === 0) { |
| #268 | return `Level ${level}! Timer is active — place pieces quickly!`; |
| #269 | } |
| #270 | |
| #271 | return null; |
| #272 | }; |
| #273 | |
| #274 | // ===== HELPERS ===== |
| #275 | |
| #276 | const emptyBoard = (): Board => |
| #277 | Array.from({ length: BOARD_SIZE }, () => |
| #278 | Array.from({ length: BOARD_SIZE }, () => ({ color: null, type: "normal" as CellType })) |
| #279 | ); |
| #280 | |
| #281 | const randomColor = (): string => COLORS[Math.floor(Math.random() * COLORS.length)]; |
| #282 | |
| #283 | const randomPieces = (count: number, level: number): (PieceDef | null)[] => { |
| #284 | // Higher levels mix in harder pieces |
| #285 | const pool = level >= 5 ? ALL_PIECE_DEFS : PIECE_DEFS; |
| #286 | const defs = [...pool].sort(() => Math.random() - 0.5).slice(0, count); |
| #287 | return defs.map((d, i) => ({ |
| #288 | id: `${Date.now()}_${i}_${Math.random().toString(36).slice(2, 6)}`, |
| #289 | cells: d.cells, |
| #290 | color: randomColor(), |
| #291 | })); |
| #292 | }; |
| #293 | |
| #294 | const rotatePiece = (cells: Coord[]): Coord[] => { |
| #295 | const maxR = Math.max(...cells.map((c) => c.r)); |
| #296 | return cells.map(({ r, c }) => ({ r: c, c: maxR - r })); |
| #297 | }; |
| #298 | |
| #299 | const canPlace = (board: Board, piece: PieceDef, offset: Coord): boolean => |
| #300 | piece.cells.every(({ r, c }) => { |
| #301 | const nr = offset.r + r; |
| #302 | const nc = offset.c + c; |
| #303 | return nr >= 0 && nr < BOARD_SIZE && nc >= 0 && nc < BOARD_SIZE && board[nr][nc].color === null; |
| #304 | }); |
| #305 | |
| #306 | const placePiece = (board: Board, piece: PieceDef, offset: Coord, color: string): Board => { |
| #307 | const newBoard = board.map((row) => row.map((c) => ({ ...c }))); |
| #308 | piece.cells.forEach(({ r, c }) => { |
| #309 | newBoard[offset.r + r][offset.c + c] = { color, type: "normal" }; |
| #310 | }); |
| #311 | return newBoard; |
| #312 | }; |
| #313 | |
| #314 | const clearLines = (board: Board): { board: Board; clearedRows: number[]; clearedCols: number[] } => { |
| #315 | const clearedRows: number[] = []; |
| #316 | const clearedCols: number[] = []; |
| #317 | const newBoard = board.map((row) => row.map((c) => ({ ...c }))); |
| #318 | |
| #319 | for (let r = 0; r < BOARD_SIZE; r++) { |
| #320 | if (newBoard[r].every((c) => c.color !== null || c.type === "hazard")) { |
| #321 | if (newBoard[r].some((c) => c.color !== null)) clearedRows.push(r); |
| #322 | } |
| #323 | } |
| #324 | for (let c = 0; c < BOARD_SIZE; c++) { |
| #325 | const col = []; |
| #326 | for (let r = 0; r < BOARD_SIZE; r++) col.push(newBoard[r][c]); |
| #327 | if (col.every((c) => c.color !== null || c.type === "hazard")) { |
| #328 | if (col.some((c) => c.color !== null)) clearedCols.push(c); |
| #329 | } |
| #330 | } |
| #331 | |
| #332 | clearedRows.forEach((r) => { |
| #333 | for (let c = 0; c < BOARD_SIZE; c++) newBoard[r][c] = { color: null, type: "normal" }; |
| #334 | }); |
| #335 | clearedCols.forEach((c) => { |
| #336 | for (let r = 0; r < BOARD_SIZE; r++) newBoard[r][c] = { color: null, type: "normal" }; |
| #337 | }); |
| #338 | |
| #339 | return { board: newBoard, clearedRows, clearedCols }; |
| #340 | }; |
| #341 | |
| #342 | const applyGravity = (board: Board): Board => { |
| #343 | const newBoard = board.map((row) => row.map((c) => ({ ...c }))); |
| #344 | for (let c = 0; c < BOARD_SIZE; c++) { |
| #345 | const column: Cell[] = []; |
| #346 | for (let r = 0; r < BOARD_SIZE; r++) { |
| #347 | if (newBoard[r][c].color !== null) column.push(newBoard[r][c]); |
| #348 | } |
| #349 | for (let r = BOARD_SIZE - 1; r >= 0; r--) { |
| #350 | const idx = column.length - (BOARD_SIZE - 1 - r); |
| #351 | newBoard[r][c] = idx >= 0 && idx < column.length ? column[idx] : { color: null, type: "normal" }; |
| #352 | } |
| #353 | } |
| #354 | return newBoard; |
| #355 | }; |
| #356 | |
| #357 | const canPlaceAny = (board: Board, pieces: (PieceDef | null)[]): boolean => { |
| #358 | if (!pieces.some(Boolean)) return false; |
| #359 | for (const piece of pieces) { |
| #360 | if (!piece) continue; |
| #361 | for (let r = 0; r < BOARD_SIZE; r++) { |
| #362 | for (let c = 0; c < BOARD_SIZE; c++) { |
| #363 | if (canPlace(board, piece, { r, c })) return true; |
| #364 | } |
| #365 | } |
| #366 | } |
| #367 | return false; |
| #368 | }; |
| #369 | |
| #370 | const pieceBounds = (cells: Coord[]) => { |
| #371 | const maxR = Math.max(...cells.map((c) => c.r)); |
| #372 | const maxC = Math.max(...cells.map((c) => c.c)); |
| #373 | return { rows: maxR + 1, cols: maxC + 1 }; |
| #374 | }; |
| #375 | |
| #376 | const getDailyChallenge = (): Challenge => { |
| #377 | const day = Math.floor(Date.now() / 86400000); |
| #378 | return DAILY_CHALLENGES[day % DAILY_CHALLENGES.length]; |
| #379 | }; |
| #380 | |
| #381 | const getStreak = (): number => { |
| #382 | try { |
| #383 | const data = JSON.parse(localStorage.getItem("blockout_streak") || '{"count":0,"lastDate":""}'); |
| #384 | const today = new Date().toISOString().slice(0, 10); |
| #385 | const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10); |
| #386 | if (data.lastDate === today) return data.count; |
| #387 | if (data.lastDate === yesterday) return data.count; |
| #388 | return 0; |
| #389 | } catch { return 0; } |
| #390 | }; |
| #391 | |
| #392 | const saveStreak = () => { |
| #393 | const today = new Date().toISOString().slice(0, 10); |
| #394 | const current = getStreak(); |
| #395 | localStorage.setItem("blockout_streak", JSON.stringify({ count: current + 1, lastDate: today })); |
| #396 | }; |
| #397 | |
| #398 | // ===== SOUND ENGINE ===== |
| #399 | |
| #400 | let _audioCtx: AudioContext | null = null; |
| #401 | const getAudioCtx = (): AudioContext | null => { |
| #402 | if (_audioCtx) return _audioCtx; |
| #403 | try { _audioCtx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)(); return _audioCtx; } |
| #404 | catch { return null; } |
| #405 | }; |
| #406 | |
| #407 | const playSound = (type: "place" | "clear" | "combo" | "gameover" | "bomb" | "levelup" | "rotate" | "click") => { |
| #408 | const ctx = getAudioCtx(); |
| #409 | if (!ctx) return; |
| #410 | const osc = ctx.createOscillator(); |
| #411 | const gain = ctx.createGain(); |
| #412 | osc.connect(gain); |
| #413 | gain.connect(ctx.destination); |
| #414 | gain.gain.value = 0.1; |
| #415 | const now = ctx.currentTime; |
| #416 | |
| #417 | switch (type) { |
| #418 | case "place": |
| #419 | osc.type = "sine"; osc.frequency.setValueAtTime(520, now); |
| #420 | osc.frequency.exponentialRampToValueAtTime(680, now + 0.06); |
| #421 | gain.gain.exponentialRampToValueAtTime(0.001, now + 0.1); |
| #422 | osc.start(now); osc.stop(now + 0.1); break; |
| #423 | case "clear": |
| #424 | osc.type = "triangle"; osc.frequency.setValueAtTime(400, now); |
| #425 | osc.frequency.exponentialRampToValueAtTime(900, now + 0.18); |
| #426 | gain.gain.exponentialRampToValueAtTime(0.001, now + 0.25); |
| #427 | osc.start(now); osc.stop(now + 0.25); break; |
| #428 | case "combo": |
| #429 | osc.type = "square"; osc.frequency.setValueAtTime(600, now); |
| #430 | osc.frequency.exponentialRampToValueAtTime(1200, now + 0.12); |
| #431 | gain.gain.setValueAtTime(0.06, now); |
| #432 | gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2); |
| #433 | osc.start(now); osc.stop(now + 0.2); break; |
| #434 | case "gameover": |
| #435 | osc.type = "sawtooth"; osc.frequency.setValueAtTime(400, now); |
| #436 | osc.frequency.exponentialRampToValueAtTime(80, now + 0.6); |
| #437 | gain.gain.exponentialRampToValueAtTime(0.001, now + 0.7); |
| #438 | osc.start(now); osc.stop(now + 0.7); break; |
| #439 | case "bomb": |
| #440 | osc.type = "sawtooth"; osc.frequency.setValueAtTime(180, now); |
| #441 | osc.frequency.exponentialRampToValueAtTime(40, now + 0.3); |
| #442 | gain.gain.setValueAtTime(0.12, now); |
| #443 | gain.gain.exponentialRampToValueAtTime(0.001, now + 0.35); |
| #444 | osc.start(now); osc.stop(now + 0.35); break; |
| #445 | case "levelup": |
| #446 | osc.type = "sine"; |
| #447 | osc.frequency.setValueAtTime(523, now); |
| #448 | osc.frequency.setValueAtTime(659, now + 0.08); |
| #449 | osc.frequency.setValueAtTime(784, now + 0.16); |
| #450 | osc.frequency.setValueAtTime(1047, now + 0.24); |
| #451 | gain.gain.exponentialRampToValueAtTime(0.001, now + 0.4); |
| #452 | osc.start(now); osc.stop(now + 0.4); break; |
| #453 | case "rotate": |
| #454 | osc.type = "sine"; osc.frequency.setValueAtTime(800, now); |
| #455 | osc.frequency.exponentialRampToValueAtTime(600, now + 0.06); |
| #456 | gain.gain.setValueAtTime(0.06, now); |
| #457 | gain.gain.exponentialRampToValueAtTime(0.001, now + 0.08); |
| #458 | osc.start(now); osc.stop(now + 0.08); break; |
| #459 | case "click": |
| #460 | osc.type = "sine"; osc.frequency.setValueAtTime(1000, now); |
| #461 | gain.gain.setValueAtTime(0.04, now); |
| #462 | gain.gain.exponentialRampToValueAtTime(0.001, now + 0.05); |
| #463 | osc.start(now); osc.stop(now + 0.05); break; |
| #464 | } |
| #465 | }; |
| #466 | |
| #467 | const haptic = (style: "light" | "medium" | "heavy" = "light") => { |
| #468 | try { navigator.vibrate?.(style === "light" ? 10 : style === "medium" ? 20 : 40); } catch {} |
| #469 | }; |
| #470 | |
| #471 | // ===== PARTICLE SYSTEM ===== |
| #472 | |
| #473 | interface Particle { |
| #474 | x: number; y: number; vx: number; vy: number; |
| #475 | life: number; maxLife: number; size: number; color: string; |
| #476 | shape: "circle" | "square" | "star"; |
| #477 | } |
| #478 | |
| #479 | const useParticles = (canvasRef: React.RefObject<HTMLCanvasElement | null>) => { |
| #480 | const particles = useRef<Particle[]>([]); |
| #481 | const raf = useRef<number>(0); |
| #482 | |
| #483 | const spawn = useCallback((x: number, y: number, color: string, count = 10) => { |
| #484 | for (let i = 0; i < count; i++) { |
| #485 | const angle = (Math.PI * 2 * i) / count + Math.random() * 0.4; |
| #486 | const speed = 1.5 + Math.random() * 3; |
| #487 | particles.current.push({ |
| #488 | x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - 1.5, |
| #489 | life: 1, maxLife: 0.5 + Math.random() * 0.5, |
| #490 | size: 2 + Math.random() * 4, color, |
| #491 | shape: (["circle", "square", "star"] as const)[Math.floor(Math.random() * 3)], |
| #492 | }); |
| #493 | } |
| #494 | }, []); |
| #495 | |
| #496 | const spawnConfetti = useCallback((cx: number, cy: number, count = 25) => { |
| #497 | for (let i = 0; i < count; i++) { |
| #498 | const angle = Math.random() * Math.PI * 2; |
| #499 | const speed = 2 + Math.random() * 5; |
| #500 | particles.current.push({ |
| #501 | x: cx + (Math.random() - 0.5) * 30, y: cy + (Math.random() - 0.5) * 30, |
| #502 | vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - 3, |
| #503 | life: 1, maxLife: 0.8 + Math.random() * 0.6, |
| #504 | size: 3 + Math.random() * 4, |
| #505 | color: COLORS[Math.floor(Math.random() * COLORS.length)], |
| #506 | shape: (["circle", "square"] as const)[Math.floor(Math.random() * 2)], |
| #507 | }); |
| #508 | } |
| #509 | }, []); |
| #510 | |
| #511 | useEffect(() => { |
| #512 | const canvas = canvasRef.current; |
| #513 | if (!canvas) return; |
| #514 | const ctx = canvas.getContext("2d"); |
| #515 | if (!ctx) return; |
| #516 | const resize = () => { canvas.width = window.innerWidth; canvas.height = window.innerHeight; }; |
| #517 | resize(); |
| #518 | window.addEventListener("resize", resize); |
| #519 | |
| #520 | const loop = () => { |
| #521 | ctx.clearRect(0, 0, canvas.width, canvas.height); |
| #522 | particles.current = particles.current.filter((p) => { |
| #523 | p.x += p.vx; p.y += p.vy; p.vy += 0.08; p.life -= 0.02; |
| #524 | if (p.life <= 0) return false; |
| #525 | const alpha = Math.max(0, p.life / p.maxLife); |
| #526 | ctx.globalAlpha = alpha; ctx.fillStyle = p.color; |
| #527 | if (p.shape === "circle") { |
| #528 | ctx.beginPath(); ctx.arc(p.x, p.y, p.size * alpha, 0, Math.PI * 2); ctx.fill(); |
| #529 | } else if (p.shape === "square") { |
| #530 | const s = p.size * alpha; ctx.fillRect(p.x - s / 2, p.y - s / 2, s, s); |
| #531 | } else { |
| #532 | const s = p.size * alpha; ctx.beginPath(); |
| #533 | for (let j = 0; j < 5; j++) { |
| #534 | const a = (j * Math.PI * 2) / 5 - Math.PI / 2; |
| #535 | const method = j === 0 ? "moveTo" : "lineTo"; |
| #536 | ctx[method](p.x + Math.cos(a) * s, p.y + Math.sin(a) * s); |
| #537 | ctx.lineTo(p.x + Math.cos(a + Math.PI / 5) * s * 0.4, p.y + Math.sin(a + Math.PI / 5) * s * 0.4); |
| #538 | } |
| #539 | ctx.closePath(); ctx.fill(); |
| #540 | } |
| #541 | return true; |
| #542 | }); |
| #543 | ctx.globalAlpha = 1; |
| #544 | raf.current = requestAnimationFrame(loop); |
| #545 | }; |
| #546 | raf.current = requestAnimationFrame(loop); |
| #547 | return () => { cancelAnimationFrame(raf.current); window.removeEventListener("resize", resize); }; |
| #548 | }, [canvasRef]); |
| #549 | |
| #550 | return { spawn, spawnConfetti }; |
| #551 | }; |
| #552 | |
| #553 | // ===== LANDING PAGE ===== |
| #554 | |
| #555 | function LandingPage({ onPlay }: { onPlay: () => void }) { |
| #556 | const canvasRef = useRef<HTMLCanvasElement>(null); |
| #557 | |
| #558 | useEffect(() => { |
| #559 | const canvas = canvasRef.current; |
| #560 | if (!canvas) return; |
| #561 | const ctx = canvas.getContext("2d"); |
| #562 | if (!ctx) return; |
| #563 | |
| #564 | canvas.width = window.innerWidth; |
| #565 | canvas.height = window.innerHeight; |
| #566 | |
| #567 | // Floating blocks |
| #568 | interface Block { |
| #569 | x: number; y: number; size: number; color: string; |
| #570 | vx: number; vy: number; rotation: number; rotSpeed: number; alpha: number; |
| #571 | } |
| #572 | |
| #573 | const blocks: Block[] = []; |
| #574 | for (let i = 0; i < 20; i++) { |
| #575 | blocks.push({ |
| #576 | x: Math.random() * canvas.width, |
| #577 | y: Math.random() * canvas.height, |
| #578 | size: 15 + Math.random() * 30, |
| #579 | color: COLORS[Math.floor(Math.random() * COLORS.length)], |
| #580 | vx: (Math.random() - 0.5) * 0.5, |
| #581 | vy: -0.3 - Math.random() * 0.5, |
| #582 | rotation: Math.random() * Math.PI * 2, |
| #583 | rotSpeed: (Math.random() - 0.5) * 0.02, |
| #584 | alpha: 0.15 + Math.random() * 0.25, |
| #585 | }); |
| #586 | } |
| #587 | |
| #588 | let raf = 0; |
| #589 | const loop = () => { |
| #590 | ctx.clearRect(0, 0, canvas.width, canvas.height); |
| #591 | |
| #592 | // Background gradient |
| #593 | const grad = ctx.createRadialGradient(canvas.width / 2, canvas.height / 2, 0, canvas.width / 2, canvas.height / 2, canvas.width * 0.7); |
| #594 | grad.addColorStop(0, "rgba(124, 58, 237, 0.08)"); |
| #595 | grad.addColorStop(0.5, "rgba(59, 130, 246, 0.04)"); |
| #596 | grad.addColorStop(1, "transparent"); |
| #597 | ctx.fillStyle = grad; |
| #598 | ctx.fillRect(0, 0, canvas.width, canvas.height); |
| #599 | |
| #600 | blocks.forEach((b) => { |
| #601 | b.x += b.vx; b.y += b.vy; b.rotation += b.rotSpeed; |
| #602 | if (b.y + b.size < 0) { b.y = canvas.height + b.size; b.x = Math.random() * canvas.width; } |
| #603 | if (b.x < -b.size) b.x = canvas.width + b.size; |
| #604 | if (b.x > canvas.width + b.size) b.x = -b.size; |
| #605 | |
| #606 | ctx.save(); |
| #607 | ctx.translate(b.x, b.y); |
| #608 | ctx.rotate(b.rotation); |
| #609 | ctx.globalAlpha = b.alpha; |
| #610 | ctx.fillStyle = b.color; |
| #611 | ctx.shadowColor = b.color; |
| #612 | ctx.shadowBlur = 15; |
| #613 | |
| #614 | // 3D block effect |
| #615 | const s = b.size; |
| #616 | // Top face |
| #617 | ctx.fillRect(-s / 2, -s / 2, s, s); |
| #618 | // Highlight |
| #619 | ctx.globalAlpha = b.alpha * 0.4; |
| #620 | ctx.fillStyle = "rgba(255,255,255,0.3)"; |
| #621 | ctx.fillRect(-s / 2, -s / 2, s, s / 3); |
| #622 | // Shadow edge |
| #623 | ctx.globalAlpha = b.alpha * 0.5; |
| #624 | ctx.fillStyle = "rgba(0,0,0,0.3)"; |
| #625 | ctx.fillRect(-s / 2, s / 6, s, s / 3); |
| #626 | |
| #627 | ctx.restore(); |
| #628 | }); |
| #629 | |
| #630 | raf = requestAnimationFrame(loop); |
| #631 | }; |
| #632 | raf = requestAnimationFrame(loop); |
| #633 | |
| #634 | const handleResize = () => { canvas.width = window.innerWidth; canvas.height = window.innerHeight; }; |
| #635 | window.addEventListener("resize", handleResize); |
| #636 | return () => { cancelAnimationFrame(raf); window.removeEventListener("resize", handleResize); }; |
| #637 | }, []); |
| #638 | |
| #639 | return ( |
| #640 | <div className="landing"> |
| #641 | <canvas ref={canvasRef} className="landing-bg" /> |
| #642 | <div className="landing-content"> |
| #643 | <div className="landing-logo">BLOCKOUT</div> |
| #644 | <p className="landing-subtitle"> |
| #645 | The ultimate 3D block puzzle. Place blocks, clear lines, chain combos, and climb the leaderboard. |
| #646 | </p> |
| #647 | <div className="landing-features"> |
| #648 | <div className="landing-feature"> |
| #649 | <span className="icon">🤖</span> |
| #650 | <span className="label">AI Coach</span> |
| #651 | </div> |
| #652 | <div className="landing-feature"> |
| #653 | <span className="icon">🧊</span> |
| #654 | <span className="label">3D Blocks</span> |
| #655 | </div> |
| #656 | <div className="landing-feature"> |
| #657 | <span className="icon">🔥</span> |
| #658 | <span className="label">Combos</span> |
| #659 | </div> |
| #660 | </div> |
| #661 | <button className="play-btn" onClick={() => { playSound("click"); onPlay(); }}> |
| #662 | PLAY NOW |
| #663 | </button> |
| #664 | <div className="landing-stats"> |
| #665 | <div className="landing-stat"> |
| #666 | <div className="num">20+</div> |
| #667 | <div className="lbl">Shapes</div> |
| #668 | </div> |
| #669 | <div className="landing-stat"> |
| #670 | <div className="num">9</div> |
| #671 | <div className="lbl">Skills</div> |
| #672 | </div> |
| #673 | <div className="landing-stat"> |
| #674 | <div className="num">12</div> |
| #675 | <div className="lbl">Achievements</div> |
| #676 | </div> |
| #677 | </div> |
| #678 | </div> |
| #679 | <div className="landing-footer">Tap to place · Drag to move · Chain combos for massive scores</div> |
| #680 | </div> |
| #681 | ); |
| #682 | } |
| #683 | |
| #684 | // ===== MAIN APP ===== |
| #685 | |
| #686 | export default function App() { |
| #687 | const [showLanding, setShowLanding] = useState(true); |
| #688 | const [board, setBoard] = useState<Board>(emptyBoard); |
| #689 | const [score, setScore] = useState(0); |
| #690 | const [combo, setCombo] = useState(0); |
| #691 | const [maxCombo, setMaxCombo] = useState(0); |
| #692 | const [level, setLevel] = useState(1); |
| #693 | const [xp, setXp] = useState(0); |
| #694 | const [totalCleared, setTotalCleared] = useState(0); |
| #695 | const [piecesPlaced, setPiecesPlaced] = useState(0); |
| #696 | const [gameOver, setGameOver] = useState(false); |
| #697 | const [shaking, setShaking] = useState(false); |
| #698 | |
| #699 | const [pieces, setPieces] = useState<(PieceDef | null)[]>(() => randomPieces(3, 1)); |
| #700 | const [selectedPiece, setSelectedPiece] = useState<number | null>(null); |
| #701 | const [ghostPos, setGhostPos] = useState<Coord | null>(null); |
| #702 | |
| #703 | // Power-ups |
| #704 | const [bombCount, setBombCount] = useState(1); |
| #705 | const [shuffleCount, setShuffleCount] = useState(2); |
| #706 | const [undoCount, setUndoCount] = useState(1); |
| #707 | const [bombMode, setBombMode] = useState(false); |
| #708 | const [prevBoard, setPrevBoard] = useState<Board | null>(null); |
| #709 | const [bombsUsed, setBombsUsed] = useState(0); |
| #710 | const [shufflesUsed, setShufflesUsed] = useState(0); |
| #711 | const [undosUsed, setUndosUsed] = useState(0); |
| #712 | |
| #713 | // UI |
| #714 | const [showProfile, setShowProfile] = useState(false); |
| #715 | const [showLeaderboard, setShowLeaderboard] = useState(false); |
| #716 | const [showExport, setShowExport] = useState(false); |
| #717 | const [showAchievements, setShowAchievements] = useState(false); |
| #718 | const [showSkills, setShowSkills] = useState(false); |
| #719 | const [levelUpBanner, setLevelUpBanner] = useState<number | null>(null); |
| #720 | const [achievementToast, setAchievementToast] = useState<Achievement | null>(null); |
| #721 | const [toast, setToast] = useState<string | null>(null); |
| #722 | const [earnedAchievements, setEarnedAchievements] = useState<Set<string>>(new Set()); |
| #723 | const [clearingCells, setClearingCells] = useState<Set<string>>(new Set()); |
| #724 | |
| #725 | // Profile |
| #726 | const [username, setUsername] = useState(() => localStorage.getItem("blockout_username") || ""); |
| #727 | const [ethAddress, setEthAddress] = useState(() => localStorage.getItem("blockout_eth") || ""); |
| #728 | |
| #729 | // Leaderboard |
| #730 | const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>(() => { |
| #731 | try { return JSON.parse(localStorage.getItem("blockout_leaderboard") || "[]"); } |
| #732 | catch { return []; } |
| #733 | }); |
| #734 | |
| #735 | // Daily challenge & streak |
| #736 | const dailyChallenge = useMemo(() => getDailyChallenge(), []); |
| #737 | const [streak, setStreak] = useState(() => getStreak()); |
| #738 | const [challengeProgress, setChallengeProgress] = useState(0); |
| #739 | const [challengeDone, setChallengeDone] = useState(false); |
| #740 | |
| #741 | // Difficulty: pressure rows & timer |
| #742 | const [, setPressureRow] = useState<number | null>(null); |
| #743 | const [timerMax, setTimerMax] = useState(0); |
| #744 | const [timerLeft, setTimerLeft] = useState(0); |
| #745 | |
| #746 | // Skill system |
| #747 | const [skillPoints, setSkillPoints] = useState(0); |
| #748 | const [unlockedSkills, setUnlockedSkills] = useState<Set<SkillId>>(() => { |
| #749 | try { return new Set(JSON.parse(localStorage.getItem("blockout_skills") || "[]")); } |
| #750 | catch { return new Set(); } |
| #751 | }); |
| #752 | |
| #753 | // AI features |
| #754 | const [aiHint, setAiHint] = useState<{ pos: Coord; clears: number } | null>(null); |
| #755 | const [aiCoachTip, setAiCoachTip] = useState<string | null>(null); |
| #756 | const [showAI, setShowAI] = useState(true); |
| #757 | const [aiAnalysis, setAiAnalysis] = useState<{ |
| #758 | bestMove: string; risk: string; suggestion: string; |
| #759 | } | null>(null); |
| #760 | |
| #761 | const [, setHazardTimer] = useState(0); |
| #762 | const [dragPiece, setDragPiece] = useState<number | null>(null); |
| #763 | |
| #764 | const boardRef = useRef<HTMLDivElement>(null); |
| #765 | const canvasRef = useRef<HTMLCanvasElement>(null); |
| #766 | const { spawn, spawnConfetti } = useParticles(canvasRef); |
| #767 | |
| #768 | // Derived |
| #769 | const xpForLevel = useMemo(() => level * XP_PER_LEVEL, [level]); |
| #770 | const xpPercent = useMemo(() => Math.min(100, (xp / xpForLevel) * 100), [xp, xpForLevel]); |
| #771 | const pieceCount = unlockedSkills.has("quick_hands") ? 4 : 3; |
| #772 | |
| #773 | const gameState: GameState = useMemo(() => ({ |
| #774 | score, combo, maxCombo, level, xp, |
| #775 | totalCleared, piecesPlaced, bombsUsed, shufflesUsed, undosUsed, |
| #776 | challengeProgress: challengeDone ? 1 : 0, |
| #777 | }), [score, combo, maxCombo, level, xp, totalCleared, piecesPlaced, bombsUsed, shufflesUsed, undosUsed, challengeDone]); |
| #778 | |
| #779 | // Save skills |
| #780 | useEffect(() => { |
| #781 | localStorage.setItem("blockout_skills", JSON.stringify([...unlockedSkills])); |
| #782 | }, [unlockedSkills]); |
| #783 | |
| #784 | // AI Coach — updates tips based on game state |
| #785 | useEffect(() => { |
| #786 | if (gameOver || !showAI || piecesPlaced < 2) { setAiCoachTip(null); return; } |
| #787 | const tip = getAICoachTip({ |
| #788 | board, combo, level, piecesPlaced, totalCleared, score, |
| #789 | }); |
| #790 | setAiCoachTip(tip); |
| #791 | }, [board, combo, level, piecesPlaced, totalCleared, score, pieces, gameOver, showAI]); |
| #792 | |
| #793 | // AI Hint — calculates best placement when piece is selected |
| #794 | useEffect(() => { |
| #795 | if (!showAI || gameOver) { setAiHint(null); return; } |
| #796 | const idx = selectedPiece ?? dragPiece; |
| #797 | if (idx === null || !pieces[idx]) { setAiHint(null); return; } |
| #798 | |
| #799 | // Debounce hint calculation |
| #800 | const timer = setTimeout(() => { |
| #801 | const best = findBestPlacement(board, pieces[idx]!); |
| #802 | if (best && best.clears > 0) { |
| #803 | setAiHint({ pos: best.pos, clears: best.clears }); |
| #804 | } else { |
| #805 | setAiHint(null); |
| #806 | } |
| #807 | }, 300); |
| #808 | return () => clearTimeout(timer); |
| #809 | }, [selectedPiece, dragPiece, pieces, board, gameOver, showAI]); |
| #810 | |
| #811 | // AI Post-Game Analysis |
| #812 | useEffect(() => { |
| #813 | if (!gameOver || !showAI) { setAiAnalysis(null); return; } |
| #814 | const emptyCount = board.flat().filter((c) => c.color === null).length; |
| #815 | const fillRate = Math.round((64 - emptyCount) / 64 * 100); |
| #816 | const avgScorePerPiece = piecesPlaced > 0 ? Math.round(score / piecesPlaced) : 0; |
| #817 | |
| #818 | let risk = "Low"; |
| #819 | if (fillRate > 80) risk = "Critical"; |
| #820 | else if (fillRate > 60) risk = "High"; |
| #821 | else if (fillRate > 40) risk = "Medium"; |
| #822 | |
| #823 | let suggestion = "Keep practicing! Try to clear multiple lines at once for combos."; |
| #824 | if (maxCombo >= 3) suggestion = "Great combo skills! Focus on setting up multi-line clears."; |
| #825 | if (totalCleared > 20) suggestion = "Excellent clearing rate! Work on maintaining higher combos."; |
| #826 | if (avgScorePerPiece > 30) suggestion = "High efficiency! You're maximizing points per move."; |
| #827 | if (fillRate > 70) suggestion = "Board filled up fast. Try placing pieces near edges first."; |
| #828 | |
| #829 | setAiAnalysis({ |
| #830 | bestMove: `${fillRate}% filled · ${totalCleared} lines cleared · ${avgScorePerPiece} pts/move`, |
| #831 | risk: `Board risk: ${risk}`, |
| #832 | suggestion, |
| #833 | }); |
| #834 | }, [gameOver, showAI, board, score, piecesPlaced, totalCleared, maxCombo]); |
| #835 | |
| #836 | // Challenge progress |
| #837 | useEffect(() => { |
| #838 | if (challengeDone) return; |
| #839 | let progress = 0; |
| #840 | switch (dailyChallenge.type) { |
| #841 | case "score": progress = score; break; |
| #842 | case "clear": progress = totalCleared; break; |
| #843 | case "combo": progress = maxCombo; break; |
| #844 | case "place": progress = piecesPlaced; break; |
| #845 | } |
| #846 | setChallengeProgress(progress); |
| #847 | if (progress >= dailyChallenge.goal) { |
| #848 | setChallengeDone(true); |
| #849 | showToast(`🎉 Challenge complete! ${dailyChallenge.reward}`); |
| #850 | if (dailyChallenge.reward.includes("Bomb")) setBombCount((b) => b + 2); |
| #851 | if (dailyChallenge.reward.includes("Shuffle")) setShuffleCount((s) => s + 3); |
| #852 | if (dailyChallenge.reward.includes("Undo")) setUndoCount((u) => u + 2); |
| #853 | if (dailyChallenge.reward.includes("each")) { |
| #854 | setBombCount((b) => b + 1); setShuffleCount((s) => s + 1); setUndoCount((u) => u + 1); |
| #855 | } |
| #856 | } |
| #857 | }, [score, totalCleared, maxCombo, piecesPlaced, dailyChallenge, challengeDone]); |
| #858 | |
| #859 | // Hazard spawner (slower with slow_time skill) |
| #860 | useEffect(() => { |
| #861 | if (gameOver) return; |
| #862 | const hazardInterval = unlockedSkills.has("slow_time") ? 9 : 6; |
| #863 | const interval = setInterval(() => { |
| #864 | setHazardTimer((prev) => { |
| #865 | const next = prev + 1; |
| #866 | if (piecesPlaced > 0 && piecesPlaced % hazardInterval === 0 && prev === 0) { |
| #867 | setBoard((b) => { |
| #868 | const newBoard = b.map((row) => row.map((c) => ({ ...c }))); |
| #869 | const empty: Coord[] = []; |
| #870 | for (let r = 0; r < BOARD_SIZE; r++) |
| #871 | for (let c = 0; c < BOARD_SIZE; c++) |
| #872 | if (newBoard[r][c].color === null && newBoard[r][c].type !== "hazard") empty.push({ r, c }); |
| #873 | if (empty.length > 0) { |
| #874 | const pos = empty[Math.floor(Math.random() * empty.length)]; |
| #875 | newBoard[pos.r][pos.c] = { color: "#666", type: "hazard" }; |
| #876 | } |
| #877 | return newBoard; |
| #878 | }); |
| #879 | } |
| #880 | return next; |
| #881 | }); |
| #882 | }, 1000); |
| #883 | return () => clearInterval(interval); |
| #884 | }, [gameOver, piecesPlaced, unlockedSkills]); |
| #885 | |
| #886 | // Pressure row system (starts at level 3) |
| #887 | useEffect(() => { |
| #888 | if (gameOver || level < 3) { setPressureRow(null); return; } |
| #889 | const speed = unlockedSkills.has("time_warp") ? 12000 : 8000; |
| #890 | const interval = setInterval(() => { |
| #891 | setBoard((b) => { |
| #892 | const newBoard = b.map((row) => row.map((c) => ({ ...c }))); |
| #893 | // Find bottom-most empty row |
| #894 | for (let r = BOARD_SIZE - 1; r >= 0; r--) { |
| #895 | const hasEmpty = newBoard[r].some((c) => c.color === null && c.type !== "hazard"); |
| #896 | if (hasEmpty) { |
| #897 | const emptyCols: number[] = []; |
| #898 | for (let c = 0; c < BOARD_SIZE; c++) { |
| #899 | if (newBoard[r][c].color === null && newBoard[r][c].type !== "hazard") emptyCols.push(c); |
| #900 | } |
| #901 | if (emptyCols.length > 0 && emptyCols.length < BOARD_SIZE) { |
| #902 | const col = emptyCols[Math.floor(Math.random() * emptyCols.length)]; |
| #903 | newBoard[r][col] = { color: "rgba(245,158,11,0.4)", type: "pressure" }; |
| #904 | setPressureRow(r); |
| #905 | } |
| #906 | break; |
| #907 | } |
| #908 | } |
| #909 | return newBoard; |
| #910 | }); |
| #911 | }, speed); |
| #912 | return () => clearInterval(interval); |
| #913 | }, [gameOver, level, unlockedSkills]); |
| #914 | |
| #915 | // Timer (starts at level 5) |
| #916 | useEffect(() => { |
| #917 | if (gameOver || level < 5) { setTimerMax(0); setTimerLeft(0); return; } |
| #918 | const max = Math.max(15, 30 - level); |
| #919 | setTimerMax(max); |
| #920 | setTimerLeft(max); |
| #921 | }, [level, gameOver]); |
| #922 | |
| #923 | useEffect(() => { |
| #924 | if (timerMax === 0 || gameOver) return; |
| #925 | const interval = setInterval(() => { |
| #926 | setTimerLeft((t) => { |
| #927 | if (t <= 1) { |
| #928 | // Time's up — place a random piece randomly |
| #929 | const empty: Coord[] = []; |
| #930 | for (let r = 0; r < BOARD_SIZE; r++) |
| #931 | for (let c = 0; c < BOARD_SIZE; c++) |
| #932 | if (board[r][c].color === null) empty.push({ r, c }); |
| #933 | if (empty.length > 0) { |
| #934 | const pos = empty[Math.floor(Math.random() * empty.length)]; |
| #935 | setBoard((b) => { |
| #936 | const nb = b.map((row) => row.map((c) => ({ ...c }))); |
| #937 | nb[pos.r][pos.c] = { color: "#666", type: "hazard" }; |
| #938 | return nb; |
| #939 | }); |
| #940 | showToast("⏰ Time's up! Hazard placed!"); |
| #941 | haptic("heavy"); |
| #942 | } |
| #943 | return timerMax; |
| #944 | } |
| #945 | return t - 1; |
| #946 | }); |
| #947 | }, 1000); |
| #948 | return () => clearInterval(interval); |
| #949 | }, [timerMax, gameOver, board]); |
| #950 | |
| #951 | // Achievement check |
| #952 | useEffect(() => { |
| #953 | for (const ach of ACHIEVEMENTS) { |
| #954 | if (!earnedAchievements.has(ach.id) && ach.check(gameState)) { |
| #955 | setEarnedAchievements((prev) => new Set(prev).add(ach.id)); |
| #956 | setAchievementToast(ach); |
| #957 | playSound("levelup"); |
| #958 | setTimeout(() => setAchievementToast(null), 3000); |
| #959 | break; |
| #960 | } |
| #961 | } |
| #962 | }, [gameState, earnedAchievements]); |
| #963 | |
| #964 | // Save |
| #965 | useEffect(() => { localStorage.setItem("blockout_username", username); }, [username]); |
| #966 | useEffect(() => { localStorage.setItem("blockout_eth", ethAddress); }, [ethAddress]); |
| #967 | useEffect(() => { localStorage.setItem("blockout_leaderboard", JSON.stringify(leaderboard)); }, [leaderboard]); |
| #968 | |
| #969 | const showToast = useCallback((msg: string) => { |
| #970 | setToast(msg); setTimeout(() => setToast(null), 2500); |
| #971 | }, []); |
| #972 | |
| #973 | // Rotate |
| #974 | const handleRotate = useCallback((idx: number) => { |
| #975 | setPieces((prev) => { |
| #976 | const newPieces = [...prev]; |
| #977 | const piece = newPieces[idx]; |
| #978 | if (!piece) return prev; |
| #979 | newPieces[idx] = { ...piece, cells: rotatePiece(piece.cells) }; |
| #980 | return newPieces; |
| #981 | }); |
| #982 | playSound("rotate"); haptic("light"); |
| #983 | }, []); |
| #984 | |
| #985 | // Unlock skill |
| #986 | const handleUnlockSkill = useCallback((skill: Skill) => { |
| #987 | if (unlockedSkills.has(skill.id)) return; |
| #988 | if (skillPoints < skill.cost) { showToast("Not enough skill points!"); return; } |
| #989 | setSkillPoints((p) => p - skill.cost); |
| #990 | setUnlockedSkills((prev) => new Set(prev).add(skill.id)); |
| #991 | showToast(`✨ Unlocked: ${skill.name}!`); |
| #992 | playSound("levelup"); |
| #993 | }, [skillPoints, unlockedSkills, showToast]); |
| #994 | |
| #995 | // Place piece |
| #996 | const handlePlace = useCallback((row: number, col: number) => { |
| #997 | if (gameOver) return; |
| #998 | |
| #999 | if (bombMode) { |
| #1000 | if (bombCount <= 0) return; |
| #1001 | setPrevBoard(board.map((r) => r.map((c) => ({ ...c })))); |
| #1002 | const newBoard = board.map((r) => r.map((c) => ({ ...c }))); |
| #1003 | const cleared: Coord[] = []; |
| #1004 | const bombRadius = unlockedSkills.has("wide_bomb") ? 2 : 1; |
| #1005 | for (let dr = -bombRadius; dr <= bombRadius; dr++) { |
| #1006 | for (let dc = -bombRadius; dc <= bombRadius; dc++) { |
| #1007 | const nr = row + dr; const nc = col + dc; |
| #1008 | if (nr >= 0 && nr < BOARD_SIZE && nc >= 0 && nc < BOARD_SIZE) { |
| #1009 | if (newBoard[nr][nc].color || newBoard[nr][nc].type === "hazard") { |
| #1010 | if (unlockedSkills.has("nuclear") || newBoard[nr][nc].type !== "hazard") { |
| #1011 | newBoard[nr][nc] = { color: null, type: "normal" }; |
| #1012 | cleared.push({ r: nr, c: nc }); |
| #1013 | } |
| #1014 | } |
| #1015 | } |
| #1016 | } |
| #1017 | } |
| #1018 | if (cleared.length > 0) { |
| #1019 | setBoard(newBoard); setBombCount((b) => b - 1); setBombsUsed((b) => b + 1); |
| #1020 | setBombMode(false); setSelectedPiece(null); |
| #1021 | playSound("bomb"); haptic("heavy"); |
| #1022 | const rect = boardRef.current?.getBoundingClientRect(); |
| #1023 | if (rect) { |
| #1024 | const cs = getComputedStyle(document.documentElement); |
| #1025 | const cellSize = parseInt(cs.getPropertyValue("--cell-size")) || 42; |
| #1026 | const gap = parseInt(cs.getPropertyValue("--gap")) || 3; |
| #1027 | const pad = parseInt(cs.getPropertyValue("--board-pad")) || 8; |
| #1028 | cleared.forEach(({ r, c }) => { |
| #1029 | spawn(rect.left + pad + c * (cellSize + gap) + cellSize / 2, rect.top + pad + r * (cellSize + gap) + cellSize / 2, "#ef4444", 6); |
| #1030 | }); |
| #1031 | } |
| #1032 | showToast(`💣 Bomb cleared ${cleared.length} blocks!`); |
| #1033 | } else { |
| #1034 | setShaking(true); setTimeout(() => setShaking(false), 400); |
| #1035 | } |
| #1036 | return; |
| #1037 | } |
| #1038 | |
| #1039 | const pieceIdx = selectedPiece ?? dragPiece; |
| #1040 | if (pieceIdx === null) return; |
| #1041 | const piece = pieces[pieceIdx]; |
| #1042 | if (!piece) return; |
| #1043 | |
| #1044 | if (!canPlace(board, piece, { r: row, c: col })) { |
| #1045 | setShaking(true); setTimeout(() => setShaking(false), 400); haptic("heavy"); return; |
| #1046 | } |
| #1047 | |
| #1048 | setPrevBoard(board.map((r) => r.map((c) => ({ ...c })))); |
| #1049 | const newBoard = placePiece(board, piece, { r: row, c: col }, piece.color); |
| #1050 | setPiecesPlaced((p) => p + 1); |
| #1051 | playSound("place"); haptic("light"); |
| #1052 | |
| #1053 | // Reset timer on placement |
| #1054 | if (timerMax > 0) setTimerLeft(timerMax); |
| #1055 | |
| #1056 | const { board: clearedBoard, clearedRows, clearedCols } = clearLines(newBoard); |
| #1057 | const linesCleared = clearedRows.length + clearedCols.length; |
| #1058 | |
| #1059 | if (linesCleared > 0) { |
| #1060 | const clearSet = new Set<string>(); |
| #1061 | clearedRows.forEach((r) => { for (let c = 0; c < BOARD_SIZE; c++) clearSet.add(`${r}-${c}`); }); |
| #1062 | clearedCols.forEach((c) => { for (let r = 0; r < BOARD_SIZE; r++) clearSet.add(`${r}-${c}`); }); |
| #1063 | setClearingCells(clearSet); |
| #1064 | setTimeout(() => setClearingCells(new Set()), 500); |
| #1065 | |
| #1066 | const baseScore = linesCleared * 10 * level; |
| #1067 | const newCombo = combo + 1; |
| #1068 | const multiplier = 1 + (newCombo - 1) * 0.5; |
| #1069 | const precisionBonus = unlockedSkills.has("precision_score") && linesCleared === 1 ? 1.5 : 1; |
| #1070 | const gained = Math.round(baseScore * multiplier * precisionBonus); |
| #1071 | |
| #1072 | setCombo(newCombo); setMaxCombo((m) => Math.max(m, newCombo)); |
| #1073 | setScore((s) => s + gained); setTotalCleared((t) => t + linesCleared); |
| #1074 | |
| #1075 | // XP + skill points |
| #1076 | const xpGain = linesCleared * 15 + (newCombo > 1 ? newCombo * 5 : 0); |
| #1077 | let newXp = xp + xpGain; |
| #1078 | let newLevel = level; |
| #1079 | let spGained = 0; |
| #1080 | while (newXp >= newLevel * XP_PER_LEVEL) { |
| #1081 | newXp -= newLevel * XP_PER_LEVEL; |
| #1082 | newLevel++; |
| #1083 | spGained++; |
| #1084 | setLevelUpBanner(newLevel); |
| #1085 | playSound("levelup"); haptic("medium"); |
| #1086 | setTimeout(() => setLevelUpBanner(null), 1800); |
| #1087 | } |
| #1088 | setXp(newXp); |
| #1089 | if (newLevel !== level) { setLevel(newLevel); setSkillPoints((p) => p + spGained); } |
| #1090 | if (spGained > 0) showToast(`⬆️ Level up! +${spGained} skill point${spGained > 1 ? "s" : ""}`); |
| #1091 | |
| #1092 | if (newCombo >= 3) { |
| #1093 | playSound("combo"); haptic("medium"); |
| #1094 | const rect = boardRef.current?.getBoundingClientRect(); |
| #1095 | if (rect) spawnConfetti(rect.left + rect.width / 2, rect.top + rect.height / 2); |
| #1096 | } else { |
| #1097 | playSound("clear"); |
| #1098 | } |
| #1099 | |
| #1100 | const rect = boardRef.current?.getBoundingClientRect(); |
| #1101 | if (rect) { |
| #1102 | const cs = getComputedStyle(document.documentElement); |
| #1103 | const cellSize = parseInt(cs.getPropertyValue("--cell-size")) || 42; |
| #1104 | const gap = parseInt(cs.getPropertyValue("--gap")) || 3; |
| #1105 | const pad = parseInt(cs.getPropertyValue("--board-pad")) || 8; |
| #1106 | clearedRows.forEach((r) => { |
| #1107 | for (let c = 0; c < BOARD_SIZE; c++) { |
| #1108 | spawn(rect.left + pad + c * (cellSize + gap) + cellSize / 2, rect.top + pad + r * (cellSize + gap) + cellSize / 2, newBoard[r][c].color || COLORS[0], 4); |
| #1109 | } |
| #1110 | }); |
| #1111 | clearedCols.forEach((c) => { |
| #1112 | for (let r = 0; r < BOARD_SIZE; r++) { |
| #1113 | spawn(rect.left + pad + c * (cellSize + gap) + cellSize / 2, rect.top + pad + r * (cellSize + gap) + cellSize / 2, newBoard[r][c].color || COLORS[0], 4); |
| #1114 | } |
| #1115 | }); |
| #1116 | } |
| #1117 | |
| #1118 | showToast(newCombo > 1 ? `🔥 ${linesCleared} lines! ${newCombo}x combo! +${gained}` : `✨ ${linesCleared} cleared! +${gained}`); |
| #1119 | } else { |
| #1120 | // Combo extend skill: don't reset combo if enabled |
| #1121 | if (!unlockedSkills.has("combo_extend")) setCombo(0); |
| #1122 | } |
| #1123 | |
| #1124 | const finalBoard = applyGravity(clearedBoard); |
| #1125 | setBoard(finalBoard); |
| #1126 | |
| #1127 | const newPieces = [...pieces]; |
| #1128 | newPieces[pieceIdx] = null; |
| #1129 | setSelectedPiece(null); setDragPiece(null); |
| #1130 | |
| #1131 | if (newPieces.every((p) => p === null)) { |
| #1132 | setPieces(randomPieces(pieceCount, level)); |
| #1133 | } else { |
| #1134 | setPieces(newPieces); |
| #1135 | } |
| #1136 | |
| #1137 | setTimeout(() => { |
| #1138 | const checkPieces = newPieces.every((p) => p === null) ? randomPieces(pieceCount, level) : newPieces; |
| #1139 | if (!canPlaceAny(finalBoard, checkPieces)) { |
| #1140 | // Second chance: free undo |
| #1141 | if (unlockedSkills.has("second_chance") && undosUsed === 0 && prevBoard) { |
| #1142 | setBoard(prevBoard); |
| #1143 | setUndosUsed((u) => u + 1); |
| #1144 | setPieces(randomPieces(pieceCount, level)); |
| #1145 | showToast("💖 Second Chance used!"); |
| #1146 | haptic("medium"); |
| #1147 | return; |
| #1148 | } |
| #1149 | setGameOver(true); playSound("gameover"); haptic("heavy"); |
| #1150 | saveStreak(); setStreak(getStreak()); |
| #1151 | } |
| #1152 | }, 100); |
| #1153 | }, [board, gameOver, selectedPiece, dragPiece, pieces, bombMode, bombCount, combo, level, xp, spawn, spawnConfetti, showToast, unlockedSkills, pieceCount, timerMax, undosUsed, prevBoard]); |
| #1154 | |
| #1155 | // Ghost preview |
| #1156 | const handleCellHover = useCallback((row: number, col: number) => { |
| #1157 | const idx = selectedPiece ?? dragPiece; |
| #1158 | if (idx === null || gameOver) { setGhostPos(null); return; } |
| #1159 | const piece = pieces[idx]; |
| #1160 | if (!piece) { setGhostPos(null); return; } |
| #1161 | setGhostPos(canPlace(board, piece, { r: row, c: col }) ? { r: row, c: col } : null); |
| #1162 | }, [selectedPiece, dragPiece, pieces, board, gameOver]); |
| #1163 | |
| #1164 | const handleTouchStart = useCallback((idx: number) => { |
| #1165 | if (!pieces[idx]) return; |
| #1166 | setSelectedPiece(idx); setDragPiece(idx); haptic("light"); |
| #1167 | }, [pieces]); |
| #1168 | |
| #1169 | // Power-ups |
| #1170 | const handleShuffle = useCallback(() => { |
| #1171 | if (shuffleCount <= 0) return; |
| #1172 | setPieces(randomPieces(pieceCount, level)); |
| #1173 | setShuffleCount((s) => s - 1); setShufflesUsed((s) => s + 1); |
| #1174 | setSelectedPiece(null); showToast("🔀 Shuffled!"); |
| #1175 | }, [shuffleCount, showToast, pieceCount, level]); |
| #1176 | |
| #1177 | const handleUndo = useCallback(() => { |
| #1178 | if (undoCount <= 0 || !prevBoard) return; |
| #1179 | setBoard(prevBoard); setUndoCount((u) => u - 1); setUndosUsed((u) => u + 1); |
| #1180 | setPrevBoard(null); showToast("↩️ Undone!"); |
| #1181 | }, [undoCount, prevBoard, showToast]); |
| #1182 | |
| #1183 | const handleBomb = useCallback(() => { |
| #1184 | if (bombCount <= 0) return; |
| #1185 | setBombMode(!bombMode); setSelectedPiece(null); |
| #1186 | showToast(bombMode ? "💣 Bomb off" : "💣 Tap to bomb"); |
| #1187 | }, [bombCount, bombMode, showToast]); |
| #1188 | |
| #1189 | // Reset |
| #1190 | const handleReset = useCallback(() => { |
| #1191 | if (score > 0 && username) { |
| #1192 | setLeaderboard((prev) => [...prev, { |
| #1193 | id: Date.now().toString(), username, ethAddress, score, level, |
| #1194 | date: new Date().toISOString(), |
| #1195 | }].sort((a, b) => b.score - a.score).slice(0, 50)); |
| #1196 | } |
| #1197 | |
| #1198 | setBoard(emptyBoard()); setScore(0); setCombo(0); setLevel(1); setXp(0); |
| #1199 | setTotalCleared(0); setPiecesPlaced(0); setGameOver(false); |
| #1200 | setPieces(randomPieces(3, 1)); setSelectedPiece(null); setGhostPos(null); |
| #1201 | setBombCount(1); setShuffleCount(2); setUndoCount(1); |
| #1202 | setBombMode(false); setPrevBoard(null); |
| #1203 | setBombsUsed(0); setShufflesUsed(0); setUndosUsed(0); |
| #1204 | setEarnedAchievements(new Set()); setMaxCombo(0); |
| #1205 | setChallengeProgress(0); setChallengeDone(false); setHazardTimer(0); |
| #1206 | setClearingCells(new Set()); setDragPiece(null); |
| #1207 | setPressureRow(null); setTimerMax(0); setTimerLeft(0); |
| #1208 | }, [score, username, ethAddress, level]); |
| #1209 | |
| #1210 | const handleExport = useCallback(() => { |
| #1211 | const data = leaderboard.map((e) => ({ |
| #1212 | username: e.username, ethAddress: e.ethAddress, |
| #1213 | score: e.score, level: e.level, date: e.date, |
| #1214 | })); |
| #1215 | const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); |
| #1216 | const url = URL.createObjectURL(blob); |
| #1217 | const a = document.createElement("a"); |
| #1218 | a.href = url; a.download = `blockout-addresses-${new Date().toISOString().slice(0, 10)}.json`; |
| #1219 | a.click(); URL.revokeObjectURL(url); |
| #1220 | }, [leaderboard]); |
| #1221 | |
| #1222 | // Keyboard |
| #1223 | useEffect(() => { |
| #1224 | const handleKey = (e: KeyboardEvent) => { |
| #1225 | if (showProfile || showLeaderboard || showExport || showAchievements || showSkills) { |
| #1226 | if (e.key === "Escape") { |
| #1227 | setShowProfile(false); setShowLeaderboard(false); |
| #1228 | setShowExport(false); setShowAchievements(false); setShowSkills(false); |
| #1229 | } |
| #1230 | return; |
| #1231 | } |
| #1232 | if (gameOver && (e.key === "r" || e.key === "R")) { handleReset(); return; } |
| #1233 | if (e.key === "1") setSelectedPiece(pieces[0] ? 0 : null); |
| #1234 | if (e.key === "2") setSelectedPiece(pieces[1] ? 1 : null); |
| #1235 | if (e.key === "3") setSelectedPiece(pieces[2] ? 2 : null); |
| #1236 | if (e.key === "4" && pieceCount > 3) setSelectedPiece(pieces[3] ? 3 : null); |
| #1237 | if (e.key === "Escape") { setSelectedPiece(null); setBombMode(false); setGhostPos(null); setDragPiece(null); } |
| #1238 | if (e.key === "b" || e.key === "B") handleBomb(); |
| #1239 | if (e.key === "s" || e.key === "S") handleShuffle(); |
| #1240 | if (e.key === "u" || e.key === "U") handleUndo(); |
| #1241 | if (e.key === "r" || e.key === "R") { |
| #1242 | const idx = selectedPiece ?? 0; |
| #1243 | if (pieces[idx]) handleRotate(idx); |
| #1244 | } |
| #1245 | }; |
| #1246 | window.addEventListener("keydown", handleKey); |
| #1247 | return () => window.removeEventListener("keydown", handleKey); |
| #1248 | }, [showProfile, showLeaderboard, showExport, showAchievements, showSkills, gameOver, pieces, selectedPiece, pieceCount, handleReset, handleBomb, handleShuffle, handleUndo, handleRotate]); |
| #1249 | |
| #1250 | // Render helpers |
| #1251 | const isGhostCell = (r: number, c: number): boolean => { |
| #1252 | const idx = selectedPiece ?? dragPiece; |
| #1253 | if (!ghostPos || idx === null) return false; |
| #1254 | const piece = pieces[idx]; |
| #1255 | if (!piece) return false; |
| #1256 | return piece.cells.some((cell) => ghostPos.r + cell.r === r && ghostPos.c + cell.c === c); |
| #1257 | }; |
| #1258 | |
| #1259 | const renderPiecePreview = (piece: PieceDef | null) => { |
| #1260 | if (!piece) return null; |
| #1261 | const bounds = pieceBounds(piece.cells); |
| #1262 | return ( |
| #1263 | <div className="piece-grid" style={{ gridTemplateColumns: `repeat(${bounds.cols}, 16px)` }}> |
| #1264 | {Array.from({ length: bounds.rows * bounds.cols }).map((_, i) => { |
| #1265 | const r = Math.floor(i / bounds.cols); |
| #1266 | const c = i % bounds.cols; |
| #1267 | const filled = piece.cells.some((cell) => cell.r === r && cell.c === c); |
| #1268 | return ( |
| #1269 | <div key={i} className={`piece-cell ${filled ? "" : "empty"}`} |
| #1270 | style={filled ? { background: piece.color } : undefined} /> |
| #1271 | ); |
| #1272 | })} |
| #1273 | </div> |
| #1274 | ); |
| #1275 | }; |
| #1276 | |
| #1277 | const rankClass = (i: number) => i === 0 ? "gold" : i === 1 ? "silver" : i === 2 ? "bronze" : ""; |
| #1278 | |
| #1279 | // Landing page |
| #1280 | if (showLanding) return <LandingPage onPlay={() => setShowLanding(false)} />; |
| #1281 | |
| #1282 | return ( |
| #1283 | <div className="app"> |
| #1284 | <canvas ref={canvasRef} className="particle-canvas" /> |
| #1285 | |
| #1286 | {/* Header — minimal */} |
| #1287 | <div className="header"> |
| #1288 | <div className="header-left"> |
| #1289 | <span className="logo" onClick={() => setShowLanding(true)} style={{ cursor: "pointer" }}>BLOCKOUT</span> |
| #1290 | {streak > 0 && <span className="streak-badge">🔥 {streak}d</span>} |
| #1291 | </div> |
| #1292 | <div className="header-right"> |
| #1293 | {skillPoints > 0 && ( |
| #1294 | <span className="skill-points-badge" onClick={() => setShowSkills(true)}> |
| #1295 | ⚡ {skillPoints} |
| #1296 | </span> |
| #1297 | )} |
| #1298 | <button className={`btn btn-sm ${showAI ? "btn-ai-on" : ""}`} onClick={() => setShowAI(!showAI)} title="AI">🤖</button> |
| #1299 | <button className="btn btn-sm" onClick={() => setShowLeaderboard(true)}>🏆</button> |
| #1300 | <button className="btn btn-sm" onClick={() => setShowSkills(true)}>⚡</button> |
| #1301 | {!username && <button className="btn btn-sm btn-accent" onClick={() => setShowProfile(true)}>Setup</button>} |
| #1302 | {username && ( |
| #1303 | <div className="profile-badge" onClick={() => setShowProfile(true)}> |
| #1304 | <div className="avatar">{username[0].toUpperCase()}</div> |
| #1305 | </div> |
| #1306 | )} |
| #1307 | </div> |
| #1308 | </div> |
| #1309 | |
| #1310 | {/* Timer (level 5+) */} |
| #1311 | {timerMax > 0 && ( |
| #1312 | <div className="timer-bar-wrap"> |
| #1313 | <div className={`timer-bar-fill ${timerLeft < timerMax * 0.3 ? "danger" : timerLeft < timerMax * 0.6 ? "warning" : ""}`} |
| #1314 | style={{ width: `${(timerLeft / timerMax) * 100}%` }} /> |
| #1315 | </div> |
| #1316 | )} |
| #1317 | |
| #1318 | {/* Main Layout: Left Panel | Board | Right Panel */} |
| #1319 | <div className="game-layout"> |
| #1320 | |
| #1321 | {/* LEFT PANEL — Stats + Challenge */} |
| #1322 | <div className="side-panel left-panel"> |
| #1323 | <div className="panel-section"> |
| #1324 | <div className="stat-row"> |
| #1325 | <span className="stat-label">Score</span> |
| #1326 | <span className="stat-value score">{score.toLocaleString()}</span> |
| #1327 | </div> |
| #1328 | <div className="stat-row"> |
| #1329 | <span className="stat-label">Combo</span> |
| #1330 | <span className="stat-value combo">{combo > 0 ? `${combo}x` : "—"}</span> |
| #1331 | </div> |
| #1332 | <div className="stat-row"> |
| #1333 | <span className="stat-label">Level</span> |
| #1334 | <span className="stat-value level">{level}</span> |
| #1335 | </div> |
| #1336 | <div className="stat-row"> |
| #1337 | <span className="stat-label">XP</span> |
| #1338 | <span className="stat-value xp">{xp}/{xpForLevel}</span> |
| #1339 | </div> |
| #1340 | <div className="xp-bar-mini"> |
| #1341 | <div className="xp-bar-mini-fill" style={{ width: `${xpPercent}%` }} /> |
| #1342 | </div> |
| #1343 | </div> |
| #1344 | |
| #1345 | <div className="panel-section"> |
| #1346 | <div className="challenge-mini"> |
| #1347 | <span className="challenge-mini-icon">🎪</span> |
| #1348 | <div className="challenge-mini-info"> |
| #1349 | <span className="challenge-mini-desc">{dailyChallenge.desc}</span> |
| #1350 | <span className="challenge-mini-goal"> |
| #1351 | {challengeDone ? "✅ Done!" : `${challengeProgress}/${dailyChallenge.goal}`} |
| #1352 | </span> |
| #1353 | </div> |
| #1354 | </div> |
| #1355 | </div> |
| #1356 | |
| #1357 | <div className="panel-section"> |
| #1358 | <div className="panel-buttons"> |
| #1359 | <button className="btn btn-sm" onClick={() => setShowAchievements(true)}>🎖️ Awards</button> |
| #1360 | <button className="btn btn-sm btn-green" onClick={() => setShowExport(true)}>📦 Export</button> |
| #1361 | {username && <button className="btn btn-sm" onClick={() => setShowProfile(true)}>👤 Profile</button>} |
| #1362 | </div> |
| #1363 | </div> |
| #1364 | </div> |
| #1365 | |
| #1366 | {/* CENTER — Board + Pieces */} |
| #1367 | <div className="game-center"> |
| #1368 | <div className="board-wrapper"> |
| #1369 | <div ref={boardRef} className={`board ${shaking ? "shake" : ""}`} |
| #1370 | onMouseLeave={() => setGhostPos(null)}> |
| #1371 | {board.map((row, r) => |
| #1372 | row.map((cell, c) => { |
| #1373 | const isGhost = isGhostCell(r, c); |
| #1374 | const isClearing = clearingCells.has(`${r}-${c}`); |
| #1375 | const isBombTarget = bombMode && ghostPos && Math.abs(ghostPos.r - r) <= 1 && Math.abs(ghostPos.c - c) <= 1; |
| #1376 | const isAIHint = aiHint && selectedPiece !== null && pieces[selectedPiece] && |
| #1377 | pieces[selectedPiece].cells.some((pc) => aiHint.pos.r + pc.r === r && aiHint.pos.c + pc.c === c); |
| #1378 | const classes = [ |
| #1379 | "cell", |
| #1380 | cell.color ? "filled" : "", |
| #1381 | isGhost ? "ghost" : "", |
| #1382 | isClearing ? "clearing" : "", |
| #1383 | bombMode && isBombTarget ? "invalid-flash" : "", |
| #1384 | cell.type === "hazard" ? "hazard" : "", |
| #1385 | cell.type === "pressure" ? "pressure" : "", |
| #1386 | isAIHint ? "ai-hint" : "", |
| #1387 | ].filter(Boolean).join(" "); |
| #1388 | |
| #1389 | const style: React.CSSProperties | undefined = |
| #1390 | cell.color && cell.type !== "pressure" && !isGhost ? { background: cell.color } : |
| #1391 | isGhost && (selectedPiece ?? dragPiece) !== null && pieces[selectedPiece ?? dragPiece!] ? { background: pieces[selectedPiece ?? dragPiece!]!.color } : |
| #1392 | undefined; |
| #1393 | |
| #1394 | return ( |
| #1395 | <div key={`${r}-${c}`} className={classes} style={style} |
| #1396 | onClick={() => handlePlace(r, c)} |
| #1397 | onMouseEnter={() => handleCellHover(r, c)} |
| #1398 | onTouchStart={(e) => { e.preventDefault(); handlePlace(r, c); }} /> |
| #1399 | ); |
| #1400 | }) |
| #1401 | )} |
| #1402 | {combo >= 2 && <div className="combo-badge" key={combo}>🔥 {combo}x</div>} |
| #1403 | </div> |
| #1404 | |
| #1405 | {gameOver && ( |
| #1406 | <div className="game-over-overlay"> |
| #1407 | <div className="game-over-card"> |
| #1408 | <h2>GAME OVER</h2> |
| #1409 | <div className="final-score">{score.toLocaleString()}</div> |
| #1410 | <div className="stats"> |
| #1411 | Level {level} · {totalCleared} lines · Best {maxCombo}x combo |
| #1412 | {streak > 0 && <><br />🔥 {streak} day streak!</>} |
| #1413 | {username && <><br />Playing as <strong>{username}</strong></>} |
| #1414 | </div> |
| #1415 | {showAI && aiAnalysis && ( |
| #1416 | <div className="ai-analysis"> |
| #1417 | <div className="ai-analysis-title">🤖 AI Analysis</div> |
| #1418 | <div className="ai-analysis-row"> |
| #1419 | <span className="ai-label">Performance</span> |
| #1420 | <span className="ai-value">{aiAnalysis.bestMove}</span> |
| #1421 | </div> |
| #1422 | <div className="ai-analysis-row"> |
| #1423 | <span className="ai-label">Risk</span> |
| #1424 | <span className="ai-value">{aiAnalysis.risk}</span> |
| #1425 | </div> |
| #1426 | <div className="ai-analysis-suggestion"> |
| #1427 | 💡 {aiAnalysis.suggestion} |
| #1428 | </div> |
| #1429 | </div> |
| #1430 | )} |
| #1431 | <button className="btn btn-accent" onClick={handleReset}>Play Again</button> |
| #1432 | <button className="btn" onClick={() => setShowLeaderboard(true)}>Leaderboard</button> |
| #1433 | </div> |
| #1434 | </div> |
| #1435 | )} |
| #1436 | </div> |
| #1437 | |
| #1438 | {/* Piece Tray — below board */} |
| #1439 | <div className="piece-tray"> |
| #1440 | {pieces.map((piece, i) => ( |
| #1441 | <div key={piece?.id ?? `empty-${i}`} |
| #1442 | className={`piece-slot ${selectedPiece === i ? "selected" : ""} ${!piece ? "used" : ""}`} |
| #1443 | onClick={() => piece && setSelectedPiece(selectedPiece === i ? null : i)} |
| #1444 | onTouchStart={() => handleTouchStart(i)}> |
| #1445 | <span className="hotkey">{i + 1}</span> |
| #1446 | {renderPiecePreview(piece)} |
| #1447 | {piece && selectedPiece === i && ( |
| #1448 | <button className="rotate-btn" onClick={(e) => { e.stopPropagation(); handleRotate(i); }}>↻</button> |
| #1449 | )} |
| #1450 | </div> |
| #1451 | ))} |
| #1452 | </div> |
| #1453 | </div> |
| #1454 | |
| #1455 | {/* RIGHT PANEL — AI + Power-ups */} |
| #1456 | <div className="side-panel right-panel"> |
| #1457 | {/* AI Coach */} |
| #1458 | {showAI && aiCoachTip && ( |
| #1459 | <div className="panel-section ai-section"> |
| #1460 | <div className="panel-section-title">🤖 AI Coach</div> |
| #1461 | <div className="ai-coach-text">{aiCoachTip}</div> |
| #1462 | </div> |
| #1463 | )} |
| #1464 | |
| #1465 | {/* AI Hint */} |
| #1466 | {showAI && aiHint && selectedPiece !== null && ( |
| #1467 | <div className="panel-section ai-hint-section"> |
| #1468 | <div className="panel-section-title">💡 Hint</div> |
| #1469 | <div className="ai-hint-text"> |
| #1470 | Row {aiHint.pos.r + 1}, Col {aiHint.pos.c + 1} |
| #1471 | {aiHint.clears > 0 && <span className="ai-hint-clears"> — {aiHint.clears} line{aiHint.clears > 1 ? "s" : ""}!</span>} |
| #1472 | </div> |
| #1473 | </div> |
| #1474 | )} |
| #1475 | |
| #1476 | {/* Power-ups */} |
| #1477 | <div className="panel-section"> |
| #1478 | <div className="panel-section-title">Power-ups</div> |
| #1479 | <div className="powerups-col"> |
| #1480 | <button className="powerup-btn" disabled={bombCount <= 0} onClick={handleBomb} |
| #1481 | style={bombMode ? { borderColor: "#ef4444", background: "rgba(239,68,68,0.15)" } : undefined}> |
| #1482 | <span className="icon">💣</span> |
| #1483 | <span>Bomb</span> |
| #1484 | <span className="powerup-count">{bombCount}</span> |
| #1485 | </button> |
| #1486 | <button className="powerup-btn" disabled={shuffleCount <= 0} onClick={handleShuffle}> |
| #1487 | <span className="icon">🔀</span> |
| #1488 | <span>Shuffle</span> |
| #1489 | <span className="powerup-count">{shuffleCount}</span> |
| #1490 | </button> |
| #1491 | <button className="powerup-btn" disabled={undoCount <= 0 || !prevBoard} onClick={handleUndo}> |
| #1492 | <span className="icon">↩️</span> |
| #1493 | <span>Undo</span> |
| #1494 | <span className="powerup-count">{undoCount}</span> |
| #1495 | </button> |
| #1496 | </div> |
| #1497 | </div> |
| #1498 | </div> |
| #1499 | |
| #1500 | </div> |
| #1501 | |
| #1502 | {/* Level Up Banner */} |
| #1503 | {levelUpBanner && ( |
| #1504 | <div className="level-up-banner" key={levelUpBanner}> |
| #1505 | 🎉 LEVEL {levelUpBanner}! |
| #1506 | <div className="sub">+1 Skill Point · +1 Bomb · +1 Shuffle · +1 Undo</div> |
| #1507 | </div> |
| #1508 | )} |
| #1509 | |
| #1510 | {/* Achievement Toast */} |
| #1511 | {achievementToast && ( |
| #1512 | <div className="achievement-toast"> |
| #1513 | <span className="icon">{achievementToast.icon}</span> |
| #1514 | <div className="info"> |
| #1515 | <div className="name">{achievementToast.name}</div> |
| #1516 | <div className="desc">{achievementToast.desc}</div> |
| #1517 | </div> |
| #1518 | </div> |
| #1519 | )} |
| #1520 | |
| #1521 | {toast && <div className="toast">{toast}</div>} |
| #1522 | |
| #1523 | {/* ===== MODALS ===== */} |
| #1524 | |
| #1525 | {/* Profile Modal */} |
| #1526 | {showProfile && ( |
| #1527 | <div className="modal-overlay" onClick={() => setShowProfile(false)}> |
| #1528 | <div className="modal" onClick={(e) => e.stopPropagation()}> |
| #1529 | <h2>🎮 Player Profile</h2> |
| #1530 | <div className="form-group"> |
| #1531 | <label>X / Twitter Username</label> |
| #1532 | <input type="text" placeholder="@yourusername" value={username} |
| #1533 | onChange={(e) => setUsername(e.target.value)} /> |
| #1534 | </div> |
| #1535 | <div className="form-group"> |
| #1536 | <label>ETH Address (Base Network)</label> |
| #1537 | <input type="text" placeholder="0x... (for airdrop)" value={ethAddress} |
| #1538 | onChange={(e) => setEthAddress(e.target.value)} /> |
| #1539 | <div style={{ fontSize: "0.55rem", color: "var(--text-dim)", marginTop: "0.2rem" }}> |
| #1540 | Top players may receive $Blockout airdrops on Base. |
| #1541 | </div> |
| #1542 | </div> |
| #1543 | <div className="form-actions"> |
| #1544 | <button className="btn btn-accent" onClick={() => setShowProfile(false)}>Save</button> |
| #1545 | <button className="btn" onClick={() => setShowProfile(false)}>Cancel</button> |
| #1546 | </div> |
| #1547 | </div> |
| #1548 | </div> |
| #1549 | )} |
| #1550 | |
| #1551 | {/* Skill Tree Modal */} |
| #1552 | {showSkills && ( |
| #1553 | <div className="modal-overlay" onClick={() => setShowSkills(false)}> |
| #1554 | <div className="modal" onClick={(e) => e.stopPropagation()}> |
| #1555 | <h2>⚡ Skill Tree <span style={{ fontSize: "0.7rem", color: "var(--blue)", fontWeight: 600 }}>({skillPoints} SP)</span></h2> |
| #1556 | <div className="skill-tree"> |
| #1557 | {(["speed", "precision", "power"] as const).map((branch) => ( |
| #1558 | <div key={branch} className="skill-branch"> |
| #1559 | <div className={`skill-branch-title ${branch}`}> |
| #1560 | {branch === "speed" ? "⏳" : branch === "precision" ? "🎯" : "💥"} {branch} |
| #1561 | </div> |
| #1562 | <div className="skill-list"> |
| #1563 | {SKILLS.filter((s) => s.branch === branch).map((skill) => { |
| #1564 | const unlocked = unlockedSkills.has(skill.id); |
| #1565 | const canAfford = skillPoints >= skill.cost; |
| #1566 | return ( |
| #1567 | <div key={skill.id} |
| #1568 | className={`skill-item ${unlocked ? "unlocked" : !canAfford ? "locked" : ""}`} |
| #1569 | onClick={() => !unlocked && handleUnlockSkill(skill)}> |
| #1570 | <span className="icon">{skill.icon}</span> |
| #1571 | <div className="info"> |
| #1572 | <div className="name">{skill.name}</div> |
| #1573 | <div className="desc">{skill.desc}</div> |
| #1574 | </div> |
| #1575 | {unlocked ? ( |
| #1576 | <span style={{ color: "var(--green)", fontSize: "0.75rem" }}>✓</span> |
| #1577 | ) : ( |
| #1578 | <span className="cost">{skill.cost} SP</span> |
| #1579 | )} |
| #1580 | </div> |
| #1581 | ); |
| #1582 | })} |
| #1583 | </div> |
| #1584 | </div> |
| #1585 | ))} |
| #1586 | </div> |
| #1587 | </div> |
| #1588 | </div> |
| #1589 | )} |
| #1590 | |
| #1591 | {/* Leaderboard Modal */} |
| #1592 | {showLeaderboard && ( |
| #1593 | <div className="modal-overlay" onClick={() => setShowLeaderboard(false)}> |
| #1594 | <div className="modal" onClick={(e) => e.stopPropagation()}> |
| #1595 | <h2>🏆 Leaderboard</h2> |
| #1596 | {leaderboard.length === 0 ? ( |
| #1597 | <p style={{ color: "var(--text-dim)", textAlign: "center", padding: "1.5rem 0", fontSize: "0.75rem" }}> |
| #1598 | No scores yet. Play to get on the board! |
| #1599 | </p> |
| #1600 | ) : ( |
| #1601 | <div className="leaderboard-list"> |
| #1602 | {leaderboard.slice(0, 20).map((entry, i) => ( |
| #1603 | <div key={entry.id} className={`lb-entry ${entry.username === username ? "me" : ""}`}> |
| #1604 | <div className={`lb-rank ${rankClass(i)}`}>{i + 1}</div> |
| #1605 | <div className="lb-info"> |
| #1606 | <div className="lb-name">{entry.username || "Anonymous"}</div> |
| #1607 | {entry.ethAddress && <div className="lb-addr">{entry.ethAddress.slice(0, 6)}...{entry.ethAddress.slice(-4)}</div>} |
| #1608 | <div className="lb-date">Lv.{entry.level} · {new Date(entry.date).toLocaleDateString()}</div> |
| #1609 | </div> |
| #1610 | <div className="lb-score">{entry.score.toLocaleString()}</div> |
| #1611 | </div> |
| #1612 | ))} |
| #1613 | </div> |
| #1614 | )} |
| #1615 | </div> |
| #1616 | </div> |
| #1617 | )} |
| #1618 | |
| #1619 | {/* Achievements Modal */} |
| #1620 | {showAchievements && ( |
| #1621 | <div className="modal-overlay" onClick={() => setShowAchievements(false)}> |
| #1622 | <div className="modal" onClick={(e) => e.stopPropagation()}> |
| #1623 | <h2>🎖️ Achievements</h2> |
| #1624 | <div className="leaderboard-list"> |
| #1625 | {ACHIEVEMENTS.map((ach) => ( |
| #1626 | <div key={ach.id} className={`lb-entry ${earnedAchievements.has(ach.id) ? "me" : ""}`} |
| #1627 | style={{ opacity: earnedAchievements.has(ach.id) ? 1 : 0.35 }}> |
| #1628 | <div style={{ fontSize: "1.1rem" }}>{ach.icon}</div> |
| #1629 | <div className="lb-info"> |
| #1630 | <div className="lb-name">{ach.name}</div> |
| #1631 | <div className="lb-date">{ach.desc}</div> |
| #1632 | </div> |
| #1633 | {earnedAchievements.has(ach.id) && <span style={{ color: "var(--green)", fontSize: "0.75rem" }}>✓</span>} |
| #1634 | </div> |
| #1635 | ))} |
| #1636 | </div> |
| #1637 | </div> |
| #1638 | </div> |
| #1639 | )} |
| #1640 | |
| #1641 | {/* Export Panel */} |
| #1642 | {showExport && ( |
| #1643 | <div className="export-panel" onClick={() => setShowExport(false)}> |
| #1644 | <div className="export-content" onClick={(e) => e.stopPropagation()}> |
| #1645 | <h2>📦 Export Addresses</h2> |
| #1646 | <p style={{ color: "var(--text-dim)", fontSize: "0.7rem" }}> |
| #1647 | Collected X usernames and ETH addresses for Base airdrop distribution. |
| #1648 | </p> |
| #1649 | <div className="export-summary"> |
| #1650 | <div className="export-stat"><div className="num">{leaderboard.length}</div><div className="lbl">Players</div></div> |
| #1651 | <div className="export-stat"><div className="num">{leaderboard.filter((e) => e.ethAddress).length}</div><div className="lbl">ETH</div></div> |
| #1652 | <div className="export-stat"><div className="num">{leaderboard.filter((e) => e.username).length}</div><div className="lbl">X Users</div></div> |
| #1653 | </div> |
| #1654 | {leaderboard.length > 0 && ( |
| #1655 | <table className="export-table"> |
| #1656 | <thead><tr><th>User</th><th>ETH</th><th>Score</th><th>Date</th></tr></thead> |
| #1657 | <tbody> |
| #1658 | {leaderboard.map((e) => ( |
| #1659 | <tr key={e.id}> |
| #1660 | <td>{e.username || "—"}</td> |
| #1661 | <td>{e.ethAddress ? `${e.ethAddress.slice(0, 6)}...${e.ethAddress.slice(-4)}` : "—"}</td> |
| #1662 | <td>{e.score.toLocaleString()}</td> |
| #1663 | <td>{new Date(e.date).toLocaleDateString()}</td> |
| #1664 | </tr> |
| #1665 | ))} |
| #1666 | </tbody> |
| #1667 | </table> |
| #1668 | )} |
| #1669 | <div className="export-actions"> |
| #1670 | <button className="btn btn-green" onClick={handleExport}>📥 Download</button> |
| #1671 | <button className="btn" onClick={() => { navigator.clipboard?.writeText(JSON.stringify(leaderboard, null, 2)); showToast("Copied!"); }}>📋 Copy</button> |
| #1672 | <button className="btn" onClick={() => setShowExport(false)}>Close</button> |
| #1673 | </div> |
| #1674 | </div> |
| #1675 | </div> |
| #1676 | )} |
| #1677 | </div> |
| #1678 | ); |
| #1679 | } |
| #1680 |