| "use client"; |
|
|
| import { useCallback, useEffect, useRef, useState } from "react"; |
|
|
| const COLS = 10; |
| const ROWS = 18; |
| const CELL = 42; |
| const BOARD_WIDTH = COLS * CELL; |
| const BOARD_HEIGHT = ROWS * CELL; |
| const TARGET_ROW = 11; |
| const TARGET_LAYERS = ROWS - TARGET_ROW; |
|
|
| const LOGOS = [ |
| ["Anthropic", "anthropic-wordmark", "/anthropic-wordmark.png"], |
| ["AI21", "ai21", "/logos/ai21.png"], |
| ["AMD", "amd", "/logos/amd.png"], |
| ["American Innovators Network", "american-innovators-network", "/logos/american-innovators-network.png"], |
| ["AMP", "amp", "/logos/amp.png"], |
| ["Andreessen Horowitz", "andreessen-horowitz", "/logos/andreessen-horowitz.png"], |
| ["Arcee AI", "arcee-ai", "/logos/arcee-ai.png"], |
| ["Arena", "arena", "/logos/arena.png"], |
| ["Baseten", "baseten", "/logos/baseten.png"], |
| ["Black Forest Labs", "black-forest-labs", "/logos/black-forest-labs.png"], |
| ["Block", "block", "/logos/block.png"], |
| ["Box", "box", "/logos/box.png"], |
| ["Cisco", "cisco", "/logos/cisco.png"], |
| ["Cloudflare", "cloudflare", "/logos/cloudflare.png"], |
| ["Cohere", "cohere", "/logos/cohere.png"], |
| ["CrowdStrike", "crowdstrike", "/logos/crowdstrike.png"], |
| ["Dell Technologies", "dell-technologies", "/logos/dell-technologies.png"], |
| ["DoorDash", "doordash", "/logos/doordash.png"], |
| ["Emergence Capital", "emergence-capital", "/logos/emergence-capital.png"], |
| ["Fireworks AI", "fireworks-ai", "/logos/fireworks-ai.png"], |
| ["Genspark", "genspark", "/logos/genspark.png"], |
| ["GitHub", "github", "/logos/github.png"], |
| ["Google", "google", "/logos/google.png"], |
| ["Hugging Face", "hugging-face", "/logos/hugging-face.png"], |
| ["IBM", "ibm", "/logos/ibm.png"], |
| ["Inferact", "inferact", "/logos/inferact.png"], |
| ["Interconnects AI", "interconnects-ai", "/logos/interconnects-ai.png"], |
| ["The Linux Foundation", "linux-foundation", "/logos/linux-foundation.png"], |
| ["Mariana Minerals", "mariana-minerals", "/logos/mariana-minerals.png"], |
| ["Meta", "meta", "/logos/meta.png"], |
| ["Microsoft", "microsoft", "/logos/microsoft.png"], |
| ["Mistral", "mistral", "/logos/mistral.png"], |
| ["Morph", "morph", "/logos/morph.png"], |
| ["Mozilla", "mozilla", "/logos/mozilla.png"], |
| ["Nebius", "nebius", "/logos/nebius.png"], |
| ["Nous Research", "nous-research", "/logos/nous-research.png"], |
| ["NVIDIA", "nvidia", "/logos/nvidia.png"], |
| ["Ollama", "ollama", "/logos/ollama.png"], |
| ["OpenAI", "openai", "/logos/openai.png"], |
| ["OpenClaw", "openclaw", "/logos/openclaw.png"], |
| ["Palantir", "palantir", "/logos/palantir.png"], |
| ["Palo Alto Networks", "palo-alto-networks", "/logos/palo-alto-networks.png"], |
| ["Periodic Labs", "periodic-labs", "/logos/periodic-labs.png"], |
| ["Perplexity", "perplexity", "/logos/perplexity.png"], |
| ["Prime Intellect", "prime-intellect", "/logos/prime-intellect.png"], |
| ["Reflection", "reflection", "/logos/reflection.png"], |
| ["Replit", "replit", "/logos/replit.png"], |
| ["ServiceNow", "servicenow", "/logos/servicenow.png"], |
| ["Telnyx", "telnyx", "/logos/telnyx.png"], |
| ["Trajectory", "trajectory", "/logos/trajectory.png"], |
| ["Y Combinator", "y-combinator", "/logos/y-combinator.png"], |
| ] as const; |
|
|
| const SHAPES = { |
| I: [ |
| { x: 0, y: 0 }, |
| { x: 1, y: 0 }, |
| { x: 2, y: 0 }, |
| { x: 3, y: 0 }, |
| ], |
| O: [ |
| { x: 0, y: 0 }, |
| { x: 1, y: 0 }, |
| { x: 0, y: 1 }, |
| { x: 1, y: 1 }, |
| ], |
| T: [ |
| { x: 1, y: 0 }, |
| { x: 0, y: 1 }, |
| { x: 1, y: 1 }, |
| { x: 2, y: 1 }, |
| ], |
| L: [ |
| { x: 0, y: 0 }, |
| { x: 0, y: 1 }, |
| { x: 1, y: 1 }, |
| { x: 2, y: 1 }, |
| ], |
| J: [ |
| { x: 2, y: 0 }, |
| { x: 0, y: 1 }, |
| { x: 1, y: 1 }, |
| { x: 2, y: 1 }, |
| ], |
| S: [ |
| { x: 1, y: 0 }, |
| { x: 2, y: 0 }, |
| { x: 0, y: 1 }, |
| { x: 1, y: 1 }, |
| ], |
| Z: [ |
| { x: 0, y: 0 }, |
| { x: 1, y: 0 }, |
| { x: 1, y: 1 }, |
| { x: 2, y: 1 }, |
| ], |
| } as const; |
|
|
| type ShapeId = keyof typeof SHAPES; |
| type Status = "loading" | "ready" | "running" | "paused" | "won" | "lost"; |
| type Point = { x: number; y: number }; |
| type QueueEntry = { shapeId: ShapeId; logoIndex: number }; |
| type BoardCell = { groupId: number; logoIndex: number }; |
| type Piece = QueueEntry & { |
| cells: Point[]; |
| x: number; |
| y: number; |
| groupId: number; |
| }; |
| type Game = { |
| status: Status; |
| board: (BoardCell | null)[][]; |
| current: Piece | null; |
| queue: QueueEntry[]; |
| shapeBag: ShapeId[]; |
| logoBag: number[]; |
| held: QueueEntry | null; |
| holdUsed: boolean; |
| pieces: number; |
| completedLayers: number; |
| nextGroupId: number; |
| lastDrop: number; |
| images: HTMLImageElement[]; |
| }; |
| type Action = |
| | "start" |
| | "restart" |
| | "pause" |
| | "left" |
| | "right" |
| | "down" |
| | "rotate" |
| | "drop" |
| | "hold"; |
|
|
| const shuffle = <T,>(values: T[]): T[] => { |
| const result = [...values]; |
| for (let index = result.length - 1; index > 0; index -= 1) { |
| const swapIndex = Math.floor(Math.random() * (index + 1)); |
| [result[index], result[swapIndex]] = [result[swapIndex], result[index]]; |
| } |
| return result; |
| }; |
|
|
| const loadImage = (src: string) => |
| new Promise<HTMLImageElement>((resolve, reject) => { |
| const image = new Image(); |
| image.onload = () => resolve(image); |
| image.onerror = () => reject(new Error(`Could not load ${src}`)); |
| image.src = src; |
| }); |
|
|
| const normalizeCells = (cells: Point[]): Point[] => { |
| const minX = Math.min(...cells.map((cell) => cell.x)); |
| const minY = Math.min(...cells.map((cell) => cell.y)); |
| return cells.map((cell) => ({ x: cell.x - minX, y: cell.y - minY })); |
| }; |
|
|
| const rotateCells = (shapeId: ShapeId, cells: Point[]): Point[] => { |
| if (shapeId === "O") return cells; |
| return normalizeCells(cells.map((cell) => ({ x: -cell.y, y: cell.x }))); |
| }; |
|
|
| const refillQueue = (game: Game) => { |
| while (game.queue.length < 4) { |
| if (!game.shapeBag.length) { |
| game.shapeBag = shuffle(Object.keys(SHAPES) as ShapeId[]); |
| } |
| if (!game.logoBag.length) { |
| game.logoBag = shuffle(LOGOS.map((_, index) => index)); |
| } |
| game.queue.push({ |
| shapeId: game.shapeBag.shift() as ShapeId, |
| logoIndex: game.logoBag.shift() as number, |
| }); |
| } |
| }; |
|
|
| const canPlace = ( |
| game: Game, |
| cells: Point[], |
| offsetX: number, |
| offsetY: number, |
| ) => |
| cells.every((cell) => { |
| const x = offsetX + cell.x; |
| const y = offsetY + cell.y; |
| return ( |
| x >= 0 && |
| x < COLS && |
| y >= 0 && |
| y < ROWS && |
| game.board[y][x] === null |
| ); |
| }); |
|
|
| const makePiece = (game: Game, entry: QueueEntry): Piece => { |
| const cells = SHAPES[entry.shapeId].map((cell) => ({ ...cell })); |
| const width = Math.max(...cells.map((cell) => cell.x)) + 1; |
| return { |
| ...entry, |
| cells, |
| x: Math.floor((COLS - width) / 2), |
| y: 0, |
| groupId: game.nextGroupId++, |
| }; |
| }; |
|
|
| const spawnNext = (game: Game) => { |
| refillQueue(game); |
| const entry = game.queue.shift() as QueueEntry; |
| refillQueue(game); |
| game.current = makePiece(game, entry); |
| game.lastDrop = performance.now(); |
| if (!canPlace(game, game.current.cells, game.current.x, game.current.y)) { |
| game.status = "lost"; |
| game.current = null; |
| } |
| }; |
|
|
| const countCompletedLayers = (game: Game) => { |
| let layers = 0; |
| for (let row = ROWS - 1; row >= TARGET_ROW; row -= 1) { |
| if (game.board[row].every(Boolean)) layers += 1; |
| } |
| return layers; |
| }; |
|
|
| const lockPiece = (game: Game) => { |
| if (!game.current) return; |
| for (const cell of game.current.cells) { |
| const x = game.current.x + cell.x; |
| const y = game.current.y + cell.y; |
| game.board[y][x] = { |
| groupId: game.current.groupId, |
| logoIndex: game.current.logoIndex, |
| }; |
| } |
|
|
| game.pieces += 1; |
| game.completedLayers = countCompletedLayers(game); |
| game.current = null; |
| game.holdUsed = false; |
|
|
| if (game.completedLayers === TARGET_LAYERS) { |
| game.status = "won"; |
| return; |
| } |
|
|
| spawnNext(game); |
| }; |
|
|
| const createGame = (images: HTMLImageElement[]): Game => { |
| const game: Game = { |
| status: "ready", |
| board: Array.from({ length: ROWS }, () => |
| Array.from({ length: COLS }, () => null), |
| ), |
| current: null, |
| queue: [], |
| shapeBag: [], |
| logoBag: [], |
| held: null, |
| holdUsed: false, |
| pieces: 0, |
| completedLayers: 0, |
| nextGroupId: 1, |
| lastDrop: performance.now(), |
| images, |
| }; |
| refillQueue(game); |
| spawnNext(game); |
| return game; |
| }; |
|
|
| const absoluteCells = (piece: Piece, yOverride = piece.y) => |
| piece.cells.map((cell) => ({ |
| x: piece.x + cell.x, |
| y: yOverride + cell.y, |
| })); |
|
|
| const movePiece = (game: Game, x: number, y: number) => { |
| if (!game.current) return false; |
| if ( |
| !canPlace( |
| game, |
| game.current.cells, |
| game.current.x + x, |
| game.current.y + y, |
| ) |
| ) { |
| return false; |
| } |
| game.current.x += x; |
| game.current.y += y; |
| return true; |
| }; |
|
|
| const tryRotate = (game: Game) => { |
| if (!game.current) return false; |
| const rotated = rotateCells(game.current.shapeId, game.current.cells); |
| for (const kick of [0, -1, 1, -2, 2]) { |
| if (canPlace(game, rotated, game.current.x + kick, game.current.y)) { |
| game.current.cells = rotated; |
| game.current.x += kick; |
| return true; |
| } |
| } |
| return false; |
| }; |
|
|
| const ghostY = (game: Game) => { |
| if (!game.current) return 0; |
| let y = game.current.y; |
| while (canPlace(game, game.current.cells, game.current.x, y + 1)) y += 1; |
| return y; |
| }; |
|
|
| const drawLogoGroup = ( |
| context: CanvasRenderingContext2D, |
| cells: Point[], |
| image: HTMLImageElement, |
| alpha = 1, |
| ) => { |
| if (!cells.length) return; |
|
|
| const minX = Math.min(...cells.map((cell) => cell.x)); |
| const maxX = Math.max(...cells.map((cell) => cell.x)); |
| const minY = Math.min(...cells.map((cell) => cell.y)); |
| const maxY = Math.max(...cells.map((cell) => cell.y)); |
| const x = minX * CELL; |
| const y = minY * CELL; |
| const width = (maxX - minX + 1) * CELL; |
| const height = (maxY - minY + 1) * CELL; |
|
|
| context.save(); |
| context.globalAlpha = alpha; |
| context.fillStyle = "#efefec"; |
| for (const cell of cells) { |
| context.fillRect(cell.x * CELL, cell.y * CELL, CELL, CELL); |
| } |
|
|
| context.beginPath(); |
| for (const cell of cells) { |
| context.rect(cell.x * CELL, cell.y * CELL, CELL, CELL); |
| } |
| context.clip(); |
| context.globalCompositeOperation = "multiply"; |
|
|
| const maxWidth = Math.max(1, width - 12); |
| const maxHeight = Math.max(1, height - 12); |
| const scale = Math.min( |
| maxWidth / image.naturalWidth, |
| maxHeight / image.naturalHeight, |
| ); |
| const drawWidth = image.naturalWidth * scale; |
| const drawHeight = image.naturalHeight * scale; |
| context.drawImage( |
| image, |
| x + (width - drawWidth) / 2, |
| y + (height - drawHeight) / 2, |
| drawWidth, |
| drawHeight, |
| ); |
| context.restore(); |
| }; |
|
|
| export function LogoAlliance() { |
| const canvasRef = useRef<HTMLCanvasElement>(null); |
| const gameRef = useRef<Game | null>(null); |
| const soundRef = useRef<{ muted: boolean; context: AudioContext | null }>({ |
| muted: false, |
| context: null, |
| }); |
| const [status, setStatus] = useState<Status>("loading"); |
| const [pieces, setPieces] = useState(0); |
| const [layers, setLayers] = useState(0); |
| const [muted, setMuted] = useState(false); |
|
|
| const tone = useCallback((frequency: number, duration = 0.055) => { |
| if (soundRef.current.muted) return; |
| const AudioContextClass = |
| window.AudioContext || |
| ( |
| window as typeof window & { |
| webkitAudioContext?: typeof AudioContext; |
| } |
| ).webkitAudioContext; |
| if (!AudioContextClass) return; |
|
|
| const audioContext = |
| soundRef.current.context ?? new AudioContextClass(); |
| soundRef.current.context = audioContext; |
| const oscillator = audioContext.createOscillator(); |
| const gain = audioContext.createGain(); |
| const now = audioContext.currentTime; |
| oscillator.type = "sine"; |
| oscillator.frequency.setValueAtTime(frequency, now); |
| gain.gain.setValueAtTime(0.018, now); |
| gain.gain.exponentialRampToValueAtTime(0.0001, now + duration); |
| oscillator.connect(gain); |
| gain.connect(audioContext.destination); |
| oscillator.start(now); |
| oscillator.stop(now + duration); |
| }, []); |
|
|
| const syncUi = useCallback((game: Game) => { |
| setStatus(game.status); |
| setPieces(game.pieces); |
| setLayers(game.completedLayers); |
| }, []); |
|
|
| const dispatch = useCallback( |
| (action: Action) => { |
| let game = gameRef.current; |
| if (!game) return; |
|
|
| if (action === "restart") { |
| game = createGame(game.images); |
| gameRef.current = game; |
| syncUi(game); |
| return; |
| } |
|
|
| if (action === "start") { |
| if (game.status === "won" || game.status === "lost") { |
| game = createGame(game.images); |
| game.status = "running"; |
| gameRef.current = game; |
| syncUi(game); |
| tone(520, 0.08); |
| return; |
| } |
| if (game.status === "ready") { |
| game.status = "running"; |
| game.lastDrop = performance.now(); |
| syncUi(game); |
| tone(520, 0.08); |
| } |
| return; |
| } |
|
|
| if (action === "pause") { |
| if (game.status === "running") game.status = "paused"; |
| else if (game.status === "paused") { |
| game.status = "running"; |
| game.lastDrop = performance.now(); |
| } |
| syncUi(game); |
| return; |
| } |
|
|
| if (game.status !== "running" || !game.current) return; |
|
|
| if (action === "left" || action === "right") { |
| if (movePiece(game, action === "left" ? -1 : 1, 0)) tone(250, 0.025); |
| return; |
| } |
|
|
| if (action === "rotate") { |
| if (tryRotate(game)) tone(340, 0.04); |
| return; |
| } |
|
|
| if (action === "down") { |
| if (!movePiece(game, 0, 1)) { |
| const before = game.completedLayers; |
| lockPiece(game); |
| tone(game.status === "won" ? 880 : game.completedLayers > before ? 720 : 420, 0.09); |
| syncUi(game); |
| } |
| game.lastDrop = performance.now(); |
| return; |
| } |
|
|
| if (action === "drop") { |
| const landingY = ghostY(game); |
| game.current.y = landingY; |
| const before = game.completedLayers; |
| lockPiece(game); |
| tone(game.status === "won" ? 880 : game.completedLayers > before ? 720 : 420, 0.1); |
| syncUi(game); |
| return; |
| } |
|
|
| if (action === "hold" && !game.holdUsed) { |
| const currentEntry = { |
| shapeId: game.current.shapeId, |
| logoIndex: game.current.logoIndex, |
| }; |
| if (game.held) { |
| const heldEntry = game.held; |
| game.held = currentEntry; |
| game.current = makePiece(game, heldEntry); |
| if (!canPlace(game, game.current.cells, game.current.x, game.current.y)) { |
| game.status = "lost"; |
| game.current = null; |
| } |
| } else { |
| game.held = currentEntry; |
| game.current = null; |
| spawnNext(game); |
| } |
| game.holdUsed = true; |
| tone(300, 0.06); |
| syncUi(game); |
| } |
| }, |
| [syncUi, tone], |
| ); |
|
|
| useEffect(() => { |
| let cancelled = false; |
| Promise.all(LOGOS.map(([, , src]) => loadImage(src))) |
| .then((images) => { |
| if (cancelled) return; |
| const game = createGame(images); |
| gameRef.current = game; |
| syncUi(game); |
| }) |
| .catch(() => { |
| if (!cancelled) setStatus("lost"); |
| }); |
| return () => { |
| cancelled = true; |
| }; |
| }, [syncUi]); |
|
|
| useEffect(() => { |
| const keyDown = (event: KeyboardEvent) => { |
| const actionByKey: Record<string, Action | undefined> = { |
| ArrowLeft: "left", |
| ArrowRight: "right", |
| ArrowDown: "down", |
| ArrowUp: "rotate", |
| " ": status === "ready" ? "start" : "drop", |
| c: "hold", |
| C: "hold", |
| p: "pause", |
| P: "pause", |
| }; |
| const action = actionByKey[event.key]; |
| if (!action) return; |
| event.preventDefault(); |
| dispatch(action); |
| }; |
| window.addEventListener("keydown", keyDown); |
| return () => window.removeEventListener("keydown", keyDown); |
| }, [dispatch, status]); |
|
|
| useEffect(() => { |
| const canvas = canvasRef.current; |
| if (!canvas) return; |
| const context = canvas.getContext("2d"); |
| if (!context) return; |
|
|
| let frame = 0; |
|
|
| const draw = (game: Game) => { |
| context.clearRect(0, 0, BOARD_WIDTH, BOARD_HEIGHT); |
| context.fillStyle = "#ffffff"; |
| context.fillRect(0, 0, BOARD_WIDTH, BOARD_HEIGHT); |
|
|
| context.fillStyle = "rgba(0, 158, 209, 0.028)"; |
| context.fillRect( |
| 0, |
| TARGET_ROW * CELL, |
| BOARD_WIDTH, |
| TARGET_LAYERS * CELL, |
| ); |
|
|
| const groups = new Map< |
| number, |
| { cells: Point[]; logoIndex: number } |
| >(); |
| game.board.forEach((row, y) => { |
| row.forEach((cell, x) => { |
| if (!cell) return; |
| const group = groups.get(cell.groupId) ?? { |
| cells: [], |
| logoIndex: cell.logoIndex, |
| }; |
| group.cells.push({ x, y }); |
| groups.set(cell.groupId, group); |
| }); |
| }); |
|
|
| groups.forEach((group) => { |
| drawLogoGroup( |
| context, |
| group.cells, |
| game.images[group.logoIndex], |
| ); |
| }); |
|
|
| for (let row = TARGET_ROW; row < ROWS; row += 1) { |
| if (!game.board[row].every(Boolean)) continue; |
| context.fillStyle = "rgba(0, 158, 209, 0.11)"; |
| context.fillRect(0, row * CELL + 2, BOARD_WIDTH, CELL - 4); |
| } |
|
|
| if (game.current) { |
| const landingY = ghostY(game); |
| if (landingY !== game.current.y) { |
| const ghostCells = absoluteCells(game.current, landingY); |
| context.save(); |
| context.fillStyle = "rgba(17, 17, 17, 0.055)"; |
| for (const cell of ghostCells) { |
| context.fillRect( |
| cell.x * CELL + 4, |
| cell.y * CELL + 4, |
| CELL - 8, |
| CELL - 8, |
| ); |
| } |
| context.restore(); |
| } |
|
|
| drawLogoGroup( |
| context, |
| absoluteCells(game.current), |
| game.images[game.current.logoIndex], |
| ); |
| } |
|
|
| const lineY = TARGET_ROW * CELL; |
| context.save(); |
| context.strokeStyle = "#009ed1"; |
| context.lineWidth = 4; |
| context.beginPath(); |
| context.moveTo(0, lineY); |
| context.lineTo(BOARD_WIDTH, lineY); |
| context.stroke(); |
| context.fillStyle = "#009ed1"; |
| context.font = "700 11px ui-monospace, monospace"; |
| context.letterSpacing = "1.5px"; |
| context.fillText("ALLIANCE", 10, lineY - 10); |
| context.restore(); |
| }; |
|
|
| const loop = (time: number) => { |
| const game = gameRef.current; |
| if (game) { |
| if (game.status === "running" && game.current) { |
| const interval = Math.max(250, 720 - game.pieces * 8); |
| if (time - game.lastDrop >= interval) { |
| if (!movePiece(game, 0, 1)) { |
| const before = game.completedLayers; |
| lockPiece(game); |
| tone( |
| game.status === "won" |
| ? 880 |
| : game.completedLayers > before |
| ? 720 |
| : 420, |
| 0.09, |
| ); |
| syncUi(game); |
| } |
| game.lastDrop = time; |
| } |
| } |
| draw(game); |
| } else { |
| context.fillStyle = "#ffffff"; |
| context.fillRect(0, 0, BOARD_WIDTH, BOARD_HEIGHT); |
| } |
| frame = requestAnimationFrame(loop); |
| }; |
|
|
| frame = requestAnimationFrame(loop); |
| return () => cancelAnimationFrame(frame); |
| }, [syncUi, tone]); |
|
|
| const overlay = |
| status === "paused" |
| ? { |
| kicker: "Alliance suspended", |
| title: "Paused", |
| copy: "The structure is holding.", |
| action: "Resume", |
| } |
| : status === "won" |
| ? { |
| kicker: "Foundation complete", |
| title: "Alliance!", |
| copy: `${pieces} logo pieces formed seven permanent layers.`, |
| action: "Build again", |
| } |
| : status === "lost" |
| ? { |
| kicker: "Structure blocked", |
| title: "Try again", |
| copy: `${layers} of ${TARGET_LAYERS} alliance layers completed.`, |
| action: "Restart", |
| } |
| : status === "loading" |
| ? { |
| kicker: "Preparing the alliance", |
| title: "Loading logos", |
| copy: "Assembling all 51 participants.", |
| action: "Please wait", |
| } |
| : null; |
|
|
| return ( |
| <section className="alliance-game" aria-label="Alliance logo Tetris game"> |
| <header className="alliance-header"> |
| <div className="alliance-title"> |
| <span aria-hidden="true" /> |
| Alliance |
| </div> |
| <div className="alliance-hud" aria-live="polite"> |
| <div> |
| <span>Layers</span> |
| <strong> |
| {layers}/{TARGET_LAYERS} |
| </strong> |
| </div> |
| <div> |
| <span>Pieces</span> |
| <strong>{String(pieces).padStart(2, "0")}</strong> |
| </div> |
| </div> |
| <div className="alliance-actions"> |
| <button |
| type="button" |
| onClick={() => dispatch("pause")} |
| disabled={status !== "running" && status !== "paused"} |
| > |
| {status === "paused" ? "Resume" : "Pause"} |
| </button> |
| <button type="button" onClick={() => dispatch("restart")}> |
| Restart |
| </button> |
| </div> |
| </header> |
| |
| <div className="alliance-layout"> |
| <div className="board-shell"> |
| <canvas |
| ref={canvasRef} |
| width={BOARD_WIDTH} |
| height={BOARD_HEIGHT} |
| aria-label="Ten-column Tetris board with an Alliance target line" |
| /> |
| {status === "ready" && ( |
| <div className="alliance-overlay"> |
| <button |
| className="primary-action start-action" |
| type="button" |
| onClick={() => dispatch("start")} |
| > |
| Start |
| </button> |
| </div> |
| )} |
| {overlay && ( |
| <div className="alliance-overlay"> |
| <div className="status-card"> |
| <p>{overlay.kicker}</p> |
| <h2>{overlay.title}</h2> |
| <span>{overlay.copy}</span> |
| <button |
| className="primary-action" |
| type="button" |
| disabled={status === "loading"} |
| onClick={() => |
| dispatch(status === "paused" ? "pause" : "start") |
| } |
| > |
| {overlay.action} |
| </button> |
| </div> |
| </div> |
| )} |
| </div> |
| |
| </div> |
| |
| <div className="touch-controls" aria-label="Touch controls"> |
| <button type="button" onClick={() => dispatch("hold")}> |
| Hold |
| </button> |
| <button type="button" onClick={() => dispatch("left")}> |
| ← |
| </button> |
| <button type="button" onClick={() => dispatch("rotate")}> |
| ↻ |
| </button> |
| <button type="button" onClick={() => dispatch("right")}> |
| → |
| </button> |
| <button type="button" onClick={() => dispatch("down")}> |
| ↓ |
| </button> |
| <button type="button" onClick={() => dispatch("drop")}> |
| Drop |
| </button> |
| </div> |
| |
| <button |
| className="sound-toggle" |
| type="button" |
| onClick={() => { |
| const nextMuted = !muted; |
| setMuted(nextMuted); |
| soundRef.current.muted = nextMuted; |
| }} |
| aria-label={muted ? "Turn sound on" : "Turn sound off"} |
| > |
| {muted ? "Sound off" : "Sound on"} |
| </button> |
| </section> |
| ); |
| } |
|
|