diff --git a/src/components/pente/GameOverDrawer.jsx b/src/components/pente/GameOverDrawer.jsx new file mode 100644 index 0000000..c7eea1b --- /dev/null +++ b/src/components/pente/GameOverDrawer.jsx @@ -0,0 +1,160 @@ +import React from 'react' +import Link from 'next/link' +import { track } from 'src/lib/analytics' +import { PLAYER_COLORS } from 'src/lib/pente/constants' + +/** + * The bottom drawer shown after a local (non-multiplayer) game ends — + * winner banner, play-again/analyze actions, the move-by-move analysis + * list, and the consulting CTA. Pure presentation over already-computed + * game state; every mutation goes back up through the callback props. + */ +export default function GameOverDrawer({ + gameOver, + isOnline, + winner, + gameMode, + resetLocalBoard, + moveHistory, + gameAnalysis, + handleAnalyze, + analysisViewTurn, + setAnalysisViewTurn, + consultingCtaDismissed, + setConsultingCtaDismissed, + botEnabled, + humanColor, +}) { + if (!gameOver || isOnline) return null + + return ( +
+
+

+ + {PLAYER_COLORS[winner]?.name || 'Unknown'} Wins! + {gameMode?.teams && ( + + (Team {gameMode.teams.findIndex(t => t.includes(winner)) + 1}) + + )} +

+
+ + {moveHistory.length > 0 && !gameAnalysis && (!gameMode || gameMode.key === 'classic') && ( + + )} +
+
+ + track('cta_click', { + page: '/posts/pente', + metadata: { location: 'pente_ingame_tip' }, + beacon: true, + }) + } + className="block mb-2.5 text-sm text-candy-500 hover:text-candy-400 transition-colors" + > + Enjoying Pente? Support development → + + + {gameAnalysis && ( +
+ {gameAnalysis.map((entry, idx) => { + const isBlunder = entry.annotation.includes('Blunder'); + const isMistake = entry.annotation.includes('Mistake'); + const isViewing = analysisViewTurn === idx; + const mover = moveHistory[idx]?.moveMadeBy; + return ( + + ); + })} + {analysisViewTurn !== null && ( +

+ Viewing move #{analysisViewTurn + 1}.{' '} + +

+ )} +
+ )} + + {!consultingCtaDismissed && ( +
+

+ Enjoying the game? I build stuff like this professionally.{' '} + { + const result = botEnabled + ? (winner === humanColor ? 'win' : 'loss') + : 'win'; + track('consulting_from_game', { + page: '/posts/pente', + metadata: { game: 'pente', result }, + beacon: true, + }); + }} + className="font-semibold text-candy-300 hover:text-candy-200 transition-colors whitespace-nowrap" + > + Work with me → + +

+ +
+ )} +
+ ) +} diff --git a/src/components/pente/RulesPanel.jsx b/src/components/pente/RulesPanel.jsx new file mode 100644 index 0000000..91222fe --- /dev/null +++ b/src/components/pente/RulesPanel.jsx @@ -0,0 +1,54 @@ +import React from 'react' +import { PLAYER_COLORS } from 'src/lib/pente/constants' +import { MODE_RULES } from 'src/lib/pente/boardDisplay' + +/** + * The collapsible rules blurb under the header. Pure presentation over the + * active game mode — no state of its own, no callbacks. Rendered only while + * the parent's `showRules` toggle is on. + */ +export default function RulesPanel({ gameMode }) { + const rules = gameMode && MODE_RULES[gameMode.key] + + return ( +
+

+ {rules + ? {rules.title} + : <>19×19 board. First to five-in-a-row or{' '} + five captured pairs wins. + } +

+ +
+ ) +} diff --git a/src/components/pente/TurnStatusBar.jsx b/src/components/pente/TurnStatusBar.jsx new file mode 100644 index 0000000..df29837 --- /dev/null +++ b/src/components/pente/TurnStatusBar.jsx @@ -0,0 +1,70 @@ +import React from 'react' +import { WHITE, PLAYER_COLORS } from 'src/lib/pente/constants' + +/** + * Turn indicator + per-player score/capture row under the header. Pure + * presentation over already-derived state — no game logic, no callbacks. + */ +export default function TurnStatusBar({ + showLobby, + gameStatus, + currentPlayer, + playerName, + botEnabled, + humanColor, + botThinking, + moveCount, + lastBotStats, + activePlayers, + gameMode, + captures, +}) { + if (showLobby || gameStatus === 'error') return null + + return ( +
+ {/* Turn dot */} +
+ + {playerName} + {botEnabled && currentPlayer !== humanColor ? ' (Bot)' : ''} + {botThinking ? '…' : '’s turn'} + + {moveCount > 0 && ( + #{moveCount} + )} + {lastBotStats && botEnabled && !botThinking && ( + + d{lastBotStats.depth} {lastBotStats.nodes > 1000 ? `${(lastBotStats.nodes / 1000).toFixed(1)}k` : lastBotStats.nodes}n + + )} + + {/* Score + captures — right-aligned */} +
+ {/* Compact score display for all active players */} + {activePlayers.map((p, i) => ( + + {i > 0 && {i === 1 ? '–' : ':'}} + + + {gameMode?.teams + ? (captures[`team${gameMode.teams.findIndex(t => t.includes(p))}`] || 0) + : (captures[p] || 0) + } + + /{gameMode?.captureThreshold || 5} + + ))} +
+
+ ) +} diff --git a/src/lib/pente/boardDisplay.js b/src/lib/pente/boardDisplay.js new file mode 100644 index 0000000..304354e --- /dev/null +++ b/src/lib/pente/boardDisplay.js @@ -0,0 +1,76 @@ +import { BLACK, WHITE, RED, BLUE, PLAYER_COLORS } from 'src/lib/pente/constants' + +/** + * Pure presentation helpers for the Pente board UI — cell/style class + * lookups plus the static mode-preset and rules-copy tables. No React, no + * game state. Split out of src/pages/posts/pente.js so the page component + * stops growing with content that never changes at runtime. + */ + +// Map cell value to CSS class +export function cellClass(cell) { + switch (cell) { + case BLACK: return 'black' + case WHITE: return 'white' + case RED: return 'red' + case BLUE: return 'blue' + default: return '' + } +} + +// Map cell value to capture-eject CSS class +export function captureClass(color) { + switch (color) { + case BLACK: return 'capture-black' + case WHITE: return 'capture-white' + case RED: return 'capture-red' + case BLUE: return 'capture-blue' + default: return 'capture-black' + } +} + +// Get hover class for current player +export function hoverClass(player) { + return `board-hover-${PLAYER_COLORS[player]?.css || 'black'}` +} + +// Game mode presets for the mode selector +export const MODE_PRESETS = [ + { key: 'local', label: 'Local', modeKey: null, bots: false }, + { key: 'bot1v1', label: 'vs Bot', modeKey: 'classic', bots: true }, + { key: 'bot4ffa', label: 'vs 3 Bots', modeKey: 'ffa4', bots: true }, + { key: 'bot2v2', label: '2v2 Bots', modeKey: 'team2v2', bots: true }, + { key: 'online', label: 'Online', modeKey: null, bots: false }, +] + +// Rules text per game mode +export const MODE_RULES = { + classic: { + title: 'Classic Pente', + captures: 'Bracket exactly two opponent stones with yours in a straight line to capture them.', + }, + ffa4: { + title: 'Free-for-All (4 Players)', + captures: 'You can capture any opponent\'s pair. All three other players are opponents. Pairs must be the same color — you can\'t capture a mixed pair.', + }, + team2v2: { + title: '2v2 Team Pente', + captures: 'You and your teammate share a capture count. Your teammate\'s stones count as brackets for captures — their stone at one end and yours at the other can capture an opponent pair between you. Five-in-a-row must be your stones only.', + }, +} + +export function modeBtnClass(active) { + return `px-3 py-2 text-xs transition-colors ${ + active + ? 'bg-forest-700/70 text-white' + : 'bg-forest-900/60 text-forest-400 hover:text-forest-200' + }` +} + +export function actionBtnClass(active = false) { + return `text-xs px-3 py-2 rounded-lg border transition-colors min-h-[36px] ${ + active + ? 'bg-cyan-900/50 text-cyan-300 border-cyan-600/50' + : 'bg-forest-900/60 text-forest-400 hover:text-forest-200 border-forest-700/40 hover:border-forest-500' + }` +} diff --git a/src/pages/posts/pente.js b/src/pages/posts/pente.js index dfca854..6e8bc3a 100644 --- a/src/pages/posts/pente.js +++ b/src/pages/posts/pente.js @@ -30,58 +30,17 @@ import QueueBanner from 'src/components/pente/QueueBanner'; import MatchConfirmModal from 'src/components/pente/MatchConfirmModal'; import useBoardTheme from 'src/hooks/useBoardTheme'; import BoardCustomizer from 'src/components/pente/BoardCustomizer'; - -// Map cell value to CSS class -function cellClass(cell) { - switch (cell) { - case BLACK: return 'black'; - case WHITE: return 'white'; - case RED: return 'red'; - case BLUE: return 'blue'; - default: return ''; - } -} - -// Map cell value to capture-eject CSS class -function captureClass(color) { - switch (color) { - case BLACK: return 'capture-black'; - case WHITE: return 'capture-white'; - case RED: return 'capture-red'; - case BLUE: return 'capture-blue'; - default: return 'capture-black'; - } -} - -// Get hover class for current player -function hoverClass(player) { - return `board-hover-${PLAYER_COLORS[player]?.css || 'black'}`; -} - -// Game mode presets for the mode selector -const MODE_PRESETS = [ - { key: 'local', label: 'Local', modeKey: null, bots: false }, - { key: 'bot1v1', label: 'vs Bot', modeKey: 'classic', bots: true }, - { key: 'bot4ffa', label: 'vs 3 Bots', modeKey: 'ffa4', bots: true }, - { key: 'bot2v2', label: '2v2 Bots', modeKey: 'team2v2', bots: true }, - { key: 'online', label: 'Online', modeKey: null, bots: false }, -]; - -// Rules text per game mode -const MODE_RULES = { - classic: { - title: 'Classic Pente', - captures: 'Bracket exactly two opponent stones with yours in a straight line to capture them.', - }, - ffa4: { - title: 'Free-for-All (4 Players)', - captures: 'You can capture any opponent\'s pair. All three other players are opponents. Pairs must be the same color \u2014 you can\'t capture a mixed pair.', - }, - team2v2: { - title: '2v2 Team Pente', - captures: 'You and your teammate share a capture count. Your teammate\'s stones count as brackets for captures \u2014 their stone at one end and yours at the other can capture an opponent pair between you. Five-in-a-row must be your stones only.', - }, -}; +import TurnStatusBar from 'src/components/pente/TurnStatusBar'; +import RulesPanel from 'src/components/pente/RulesPanel'; +import GameOverDrawer from 'src/components/pente/GameOverDrawer'; +import { + cellClass, + captureClass, + hoverClass, + MODE_PRESETS, + modeBtnClass, + actionBtnClass, +} from 'src/lib/pente/boardDisplay'; const GameBoard = () => { const router = useRouter(); @@ -635,22 +594,6 @@ const GameBoard = () => { const playerName_ = PLAYER_COLORS[currentPlayer]?.name || 'Unknown'; const showEval = !isOnline && !showLobby && (!gameMode || gameMode.key === 'classic'); - // ───────────────────────────────────────────────────────────────────────── - // Style helpers - const modeBtn = (active) => - `px-3 py-2 text-xs transition-colors ${ - active - ? 'bg-forest-700/70 text-white' - : 'bg-forest-900/60 text-forest-400 hover:text-forest-200' - }`; - - const actionBtn = (active = false) => - `text-xs px-3 py-2 rounded-lg border transition-colors min-h-[36px] ${ - active - ? 'bg-cyan-900/50 text-cyan-300 border-cyan-600/50' - : 'bg-forest-900/60 text-forest-400 hover:text-forest-200 border-forest-700/40 hover:border-forest-500' - }`; - // ───────────────────────────────────────────────────────────────────────── const penteZone = getZone(gameElo); @@ -694,7 +637,7 @@ const GameBoard = () => { {MODE_PRESETS.map((preset, i) => ( )} {!isOnline && ( - )} - {moveHistory.length > 0 && !gameAnalysis && (!gameMode || gameMode.key === 'classic') && ( - - )} -
- - - track('cta_click', { - page: '/posts/pente', - metadata: { location: 'pente_ingame_tip' }, - beacon: true, - }) - } - className="block mb-2.5 text-sm text-candy-500 hover:text-candy-400 transition-colors" - > - Enjoying Pente? Support development → - - - {gameAnalysis && ( -
- {gameAnalysis.map((entry, idx) => { - const isBlunder = entry.annotation.includes('Blunder'); - const isMistake = entry.annotation.includes('Mistake'); - const isViewing = analysisViewTurn === idx; - const mover = moveHistory[idx]?.moveMadeBy; - return ( - - ); - })} - {analysisViewTurn !== null && ( -

- Viewing move #{analysisViewTurn + 1}.{' '} - -

- )} -
- )} - - {!consultingCtaDismissed && ( -
-

- Enjoying the game? I build stuff like this professionally.{' '} - { - const result = botEnabled - ? (winner === humanColor ? 'win' : 'loss') - : 'win'; - track('consulting_from_game', { - page: '/posts/pente', - metadata: { game: 'pente', result }, - beacon: true, - }); - }} - className="font-semibold text-candy-300 hover:text-candy-200 transition-colors whitespace-nowrap" - > - Work with me → - -

- -
- )} - - )} + {/* Post-multiplayer game result */} {isOnline && mp.gameStatus === 'finished' && (