Add Northcott's Game interactive
Implemented Northcott's Game as an interactive within the games theme. - Added game logic, board size selection, and misere mode toggle. - Implemented two-player mode with visual turn indicators.
This commit is contained in:
parent
8c9a8507c8
commit
bddd1eaae9
2 changed files with 340 additions and 0 deletions
|
|
@ -5,6 +5,7 @@ import { Badge } from '@/components/ui/badge';
|
||||||
import { Search } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
import Layout from '@/components/Layout';
|
import Layout from '@/components/Layout';
|
||||||
import GameOfSim from '@/components/GameOfSim';
|
import GameOfSim from '@/components/GameOfSim';
|
||||||
|
import NorthcottsGame from '@/components/NorthcottsGame';
|
||||||
|
|
||||||
interface Game {
|
interface Game {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -21,6 +22,13 @@ const games: Game[] = [
|
||||||
description: 'A strategic two-player game where you take turns coloring edges between vertices, trying to avoid creating a triangle of your own color.',
|
description: 'A strategic two-player game where you take turns coloring edges between vertices, trying to avoid creating a triangle of your own color.',
|
||||||
tags: ['strategy', 'graph-theory', 'two-player', 'logic', 'game-theory'],
|
tags: ['strategy', 'graph-theory', 'two-player', 'logic', 'game-theory'],
|
||||||
component: GameOfSim
|
component: GameOfSim
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'northcotts-game',
|
||||||
|
title: "Northcott's Game",
|
||||||
|
description: 'A classic strategy game played on a grid where players move pieces horizontally toward each other, trying to be the last one able to move.',
|
||||||
|
tags: ['strategy', 'two-player', 'grid-game', 'logic', 'movement'],
|
||||||
|
component: NorthcottsGame
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
332
src/components/NorthcottsGame.tsx
Normal file
332
src/components/NorthcottsGame.tsx
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { RotateCcw, Shuffle } from 'lucide-react';
|
||||||
|
import SocialShare from '@/components/SocialShare';
|
||||||
|
|
||||||
|
interface GameState {
|
||||||
|
board: ('L' | 'R' | null)[][];
|
||||||
|
currentPlayer: 'L' | 'R';
|
||||||
|
gameOver: boolean;
|
||||||
|
winner: 'L' | 'R' | null;
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
misereMode: boolean;
|
||||||
|
selectedPiece: { row: number; col: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NorthcottsGameProps {
|
||||||
|
showSocialShare?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NorthcottsGame: React.FC<NorthcottsGameProps> = ({ showSocialShare = false }) => {
|
||||||
|
const [gameState, setGameState] = useState<GameState>(() =>
|
||||||
|
initializeGame(3, 6, false)
|
||||||
|
);
|
||||||
|
|
||||||
|
function initializeGame(rows: number, cols: number, misereMode: boolean): GameState {
|
||||||
|
const board: ('L' | 'R' | null)[][] = Array(rows).fill(null).map(() => Array(cols).fill(null));
|
||||||
|
|
||||||
|
// Place L and R pieces in each row ensuring L is to the left of R
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
// Random positions ensuring L < R
|
||||||
|
const lPos = Math.floor(Math.random() * (cols - 1));
|
||||||
|
const rPos = lPos + 1 + Math.floor(Math.random() * (cols - lPos - 1));
|
||||||
|
|
||||||
|
board[row][lPos] = 'L';
|
||||||
|
board[row][rPos] = 'R';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
board,
|
||||||
|
currentPlayer: 'L',
|
||||||
|
gameOver: false,
|
||||||
|
winner: null,
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
misereMode,
|
||||||
|
selectedPiece: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const getValidMoves = (row: number, col: number, player: 'L' | 'R'): number[] => {
|
||||||
|
const moves: number[] = [];
|
||||||
|
const { board } = gameState;
|
||||||
|
|
||||||
|
if (player === 'L') {
|
||||||
|
// L can move right (forward towards R)
|
||||||
|
for (let newCol = col + 1; newCol < gameState.cols; newCol++) {
|
||||||
|
if (board[row][newCol] === 'R') break; // Can't jump over R
|
||||||
|
if (board[row][newCol] === null) moves.push(newCol);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// R can move left (forward towards L)
|
||||||
|
for (let newCol = col - 1; newCol >= 0; newCol--) {
|
||||||
|
if (board[row][newCol] === 'L') break; // Can't jump over L
|
||||||
|
if (board[row][newCol] === null) moves.push(newCol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return moves;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasAnyMoves = (player: 'L' | 'R'): boolean => {
|
||||||
|
const { board } = gameState;
|
||||||
|
for (let row = 0; row < gameState.rows; row++) {
|
||||||
|
for (let col = 0; col < gameState.cols; col++) {
|
||||||
|
if (board[row][col] === player) {
|
||||||
|
if (getValidMoves(row, col, player).length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCellClick = (row: number, col: number) => {
|
||||||
|
if (gameState.gameOver) return;
|
||||||
|
|
||||||
|
const { board, currentPlayer, selectedPiece } = gameState;
|
||||||
|
|
||||||
|
if (selectedPiece) {
|
||||||
|
// Try to move selected piece
|
||||||
|
const validMoves = getValidMoves(selectedPiece.row, selectedPiece.col, currentPlayer);
|
||||||
|
|
||||||
|
if (validMoves.includes(col) && row === selectedPiece.row) {
|
||||||
|
// Valid move
|
||||||
|
const newBoard = board.map(r => [...r]);
|
||||||
|
newBoard[selectedPiece.row][selectedPiece.col] = null;
|
||||||
|
newBoard[row][col] = currentPlayer;
|
||||||
|
|
||||||
|
const nextPlayer = currentPlayer === 'L' ? 'R' : 'L';
|
||||||
|
const hasMovesNext = hasAnyMovesForBoard(newBoard, nextPlayer);
|
||||||
|
|
||||||
|
setGameState({
|
||||||
|
...gameState,
|
||||||
|
board: newBoard,
|
||||||
|
currentPlayer: nextPlayer,
|
||||||
|
selectedPiece: null,
|
||||||
|
gameOver: !hasMovesNext,
|
||||||
|
winner: !hasMovesNext ? (gameState.misereMode ? nextPlayer : currentPlayer) : null
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Invalid move or selecting different piece
|
||||||
|
if (board[row][col] === currentPlayer) {
|
||||||
|
setGameState({ ...gameState, selectedPiece: { row, col } });
|
||||||
|
} else {
|
||||||
|
setGameState({ ...gameState, selectedPiece: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Select piece
|
||||||
|
if (board[row][col] === currentPlayer) {
|
||||||
|
setGameState({ ...gameState, selectedPiece: { row, col } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasAnyMovesForBoard = (board: ('L' | 'R' | null)[][], player: 'L' | 'R'): boolean => {
|
||||||
|
for (let row = 0; row < gameState.rows; row++) {
|
||||||
|
for (let col = 0; col < gameState.cols; col++) {
|
||||||
|
if (board[row][col] === player) {
|
||||||
|
const moves = getValidMovesForBoard(board, row, col, player);
|
||||||
|
if (moves.length > 0) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValidMovesForBoard = (board: ('L' | 'R' | null)[][], row: number, col: number, player: 'L' | 'R'): number[] => {
|
||||||
|
const moves: number[] = [];
|
||||||
|
|
||||||
|
if (player === 'L') {
|
||||||
|
for (let newCol = col + 1; newCol < gameState.cols; newCol++) {
|
||||||
|
if (board[row][newCol] === 'R') break;
|
||||||
|
if (board[row][newCol] === null) moves.push(newCol);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let newCol = col - 1; newCol >= 0; newCol--) {
|
||||||
|
if (board[row][newCol] === 'L') break;
|
||||||
|
if (board[row][newCol] === null) moves.push(newCol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return moves;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetGame = () => {
|
||||||
|
setGameState(initializeGame(gameState.rows, gameState.cols, gameState.misereMode));
|
||||||
|
};
|
||||||
|
|
||||||
|
const randomGame = () => {
|
||||||
|
const rows = 3 + Math.floor(Math.random() * 8); // 3-10
|
||||||
|
const cols = 3 + Math.floor(Math.random() * 8); // 3-10
|
||||||
|
setGameState(initializeGame(rows, cols, gameState.misereMode));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMisereMode = () => {
|
||||||
|
setGameState(prev => ({
|
||||||
|
...prev,
|
||||||
|
misereMode: !prev.misereMode
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeBoardSize = (rows: number, cols: number) => {
|
||||||
|
setGameState(initializeGame(rows, cols, gameState.misereMode));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCellClass = (row: number, col: number) => {
|
||||||
|
const { board, selectedPiece, currentPlayer } = gameState;
|
||||||
|
const piece = board[row][col];
|
||||||
|
let classes = 'w-12 h-12 border-2 flex items-center justify-center cursor-pointer transition-all duration-200 ';
|
||||||
|
|
||||||
|
if (selectedPiece && selectedPiece.row === row && selectedPiece.col === col) {
|
||||||
|
classes += 'border-primary bg-primary/20 ';
|
||||||
|
} else if (selectedPiece && selectedPiece.row === row) {
|
||||||
|
const validMoves = getValidMoves(selectedPiece.row, selectedPiece.col, currentPlayer);
|
||||||
|
if (validMoves.includes(col)) {
|
||||||
|
classes += 'border-green-500 bg-green-100 ';
|
||||||
|
} else {
|
||||||
|
classes += 'border-border bg-background ';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
classes += 'border-border bg-background hover:bg-muted/50 ';
|
||||||
|
}
|
||||||
|
|
||||||
|
return classes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPieceDisplay = (piece: 'L' | 'R' | null) => {
|
||||||
|
if (!piece) return '';
|
||||||
|
return (
|
||||||
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white font-bold text-sm ${
|
||||||
|
piece === 'L' ? 'bg-blue-600' : 'bg-red-600'
|
||||||
|
}`}>
|
||||||
|
{piece}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentPlayerColor = gameState.currentPlayer === 'L' ? 'blue' : 'red';
|
||||||
|
const bgClass = gameState.currentPlayer === 'L' ? 'bg-blue-50' : 'bg-red-50';
|
||||||
|
const borderClass = gameState.currentPlayer === 'L' ? 'border-blue-600' : 'border-red-900';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className={`${bgClass} ${borderClass} border-2`}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl font-bold text-center">Northcott's Game</CardTitle>
|
||||||
|
<div className="flex flex-wrap gap-4 items-center justify-center">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Label htmlFor="misere-mode">Misère Mode:</Label>
|
||||||
|
<Switch
|
||||||
|
id="misere-mode"
|
||||||
|
checked={gameState.misereMode}
|
||||||
|
onCheckedChange={toggleMisereMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Label>Rows:</Label>
|
||||||
|
<Select value={gameState.rows.toString()} onValueChange={(v) => changeBoardSize(parseInt(v), gameState.cols)}>
|
||||||
|
<SelectTrigger className="w-16">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Array.from({length: 8}, (_, i) => i + 3).map(n => (
|
||||||
|
<SelectItem key={n} value={n.toString()}>{n}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Label>Cols:</Label>
|
||||||
|
<Select value={gameState.cols.toString()} onValueChange={(v) => changeBoardSize(gameState.rows, parseInt(v))}>
|
||||||
|
<SelectTrigger className="w-16">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Array.from({length: 8}, (_, i) => i + 3).map(n => (
|
||||||
|
<SelectItem key={n} value={n.toString()}>{n}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={resetGame} variant="outline" size="sm">
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={randomGame} variant="outline" size="sm">
|
||||||
|
<Shuffle className="w-4 h-4 mr-2" />
|
||||||
|
Random
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
{gameState.gameOver ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Badge variant="destructive" className="text-lg py-1 px-4">
|
||||||
|
Game Over! {gameState.winner} Wins!
|
||||||
|
</Badge>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{gameState.misereMode ? 'Last to move loses (Misère)' : 'Last to move wins (Normal)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Badge variant="secondary" className="text-lg py-1 px-4">
|
||||||
|
Current Player: {gameState.currentPlayer} ({gameState.currentPlayer === 'L' ? 'Blue' : 'Red'})
|
||||||
|
</Badge>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{gameState.misereMode ? 'Misère Mode: Last to move loses' : 'Normal Mode: Last to move wins'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${gameState.cols}, 1fr)` }}>
|
||||||
|
{gameState.board.map((row, rowIndex) =>
|
||||||
|
row.map((cell, colIndex) => (
|
||||||
|
<div
|
||||||
|
key={`${rowIndex}-${colIndex}`}
|
||||||
|
className={getCellClass(rowIndex, colIndex)}
|
||||||
|
onClick={() => handleCellClick(rowIndex, colIndex)}
|
||||||
|
>
|
||||||
|
{getPieceDisplay(cell)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground space-y-1">
|
||||||
|
<p><strong>Rules:</strong> Players alternate turns. L (blue) moves right, R (red) moves left.</p>
|
||||||
|
<p>Pieces cannot jump over the opponent and must stay in their row.</p>
|
||||||
|
<p>Click a piece to select it, then click a valid destination to move.</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{showSocialShare && (
|
||||||
|
<SocialShare
|
||||||
|
title="Northcott's Game - Strategic Two-Player Grid Game"
|
||||||
|
description="Play this classic strategy game where players move pieces horizontally trying to be the last one able to move!"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NorthcottsGame;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue