Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
339 changes: 339 additions & 0 deletions src/games/mecha-duel/Board.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,339 @@
import { useState } from 'react';
import type { BoardProps } from 'boardgame.io/react';
import type { MechaDuelState, PieceType, Piece } from './Game';
import {
BOARD_SIZE,
indexToPos,
isValidCommitTarget,
isValidKingStep,
} from './Game';

type ActionType = 'commit' | 'execute' | 'kingStep' | null;

const PIECE_SYMBOLS: Record<PieceType, string> = {
bishop: 'B',
rook: 'R',
knight: 'N',
queen: 'Q',
};

const CELL_SIZE = 50;

export function Board({ G, ctx, moves, playerID }: BoardProps<MechaDuelState>) {
const [selectedAction, setSelectedAction] = useState<ActionType>(null);
const [selectedPieceId, setSelectedPieceId] = useState<number | null>(null);
const [selectedExecutePieces, setSelectedExecutePieces] = useState<number[]>([]);

const currentPlayerId = (playerID ?? ctx.currentPlayer) as '0' | '1';
const isMyTurn = ctx.currentPlayer === currentPlayerId;
const kingPos = G.kings[currentPlayerId];

// Get player's pieces
const myPieces = G.pieces[currentPlayerId];
const readyPieces = myPieces.filter((p) => p.state === 'ready');
const committedPieces = G.committedPieces.filter((cp) => cp.playerId === currentPlayerId);

// Selected piece for commit
const selectedPiece = selectedPieceId !== null
? myPieces.find((p) => p.id === selectedPieceId)
: null;

const handleCellClick = (index: number) => {
if (ctx.gameover || !isMyTurn) return;

const pos = indexToPos(index);

if (selectedAction === 'commit' && selectedPiece && kingPos) {
if (isValidCommitTarget(selectedPiece.type, kingPos, pos)) {
moves.commit(selectedPiece.id, pos);
setSelectedAction(null);
setSelectedPieceId(null);
}
} else if (selectedAction === 'kingStep' && kingPos) {
if (isValidKingStep(kingPos, pos)) {
moves.kingStep(pos);
setSelectedAction(null);
}
}
};

const handleExecute = () => {
if (selectedExecutePieces.length > 0) {
moves.execute(selectedExecutePieces);
setSelectedAction(null);
setSelectedExecutePieces([]);
}
};

const handlePass = () => {
moves.pass();
setSelectedAction(null);
setSelectedPieceId(null);
setSelectedExecutePieces([]);
};

const toggleExecutePiece = (pieceId: number) => {
setSelectedExecutePieces((prev) =>
prev.includes(pieceId)
? prev.filter((id) => id !== pieceId)
: [...prev, pieceId]
);
};

// Calculate valid cells for current action
const getValidCells = (): Set<number> => {
const valid = new Set<number>();
if (!kingPos) return valid;

if (selectedAction === 'commit' && selectedPiece) {
for (let i = 0; i < BOARD_SIZE * BOARD_SIZE; i++) {
const pos = indexToPos(i);
if (isValidCommitTarget(selectedPiece.type, kingPos, pos)) {
valid.add(i);
}
}
} else if (selectedAction === 'kingStep') {
for (let i = 0; i < BOARD_SIZE * BOARD_SIZE; i++) {
const pos = indexToPos(i);
if (isValidKingStep(kingPos, pos)) {
valid.add(i);
}
}
}

return valid;
};

const validCells = getValidCells();

// Status message
let status: string;
if (ctx.gameover) {
status = `Winner: Player ${ctx.gameover.winner === '0' ? '1' : '2'}`;
} else if (!isMyTurn) {
status = `Waiting for Player ${ctx.currentPlayer === '0' ? '1' : '2'}`;
} else {
status = `Your turn (Player ${currentPlayerId === '0' ? '1' : '2'})`;
}

// Render cell content
const renderCell = (index: number) => {
const content = G.cells[index];
const pos = indexToPos(index);
const isValid = validCells.has(index);

// Check for committed pieces at this position
const committedHere = G.committedPieces.filter(
(cp) => cp.target.x === pos.x && cp.target.y === pos.y
);

let cellContent = '';
let cellColor = '#f5f5dc'; // default tan
let textColor = '#333';

if (content === 'king0') {
cellContent = 'K';
cellColor = '#4a90d9';
textColor = '#fff';
} else if (content === 'king1') {
cellContent = 'K';
cellColor = '#d94a4a';
textColor = '#fff';
} else if (content === 'pawn') {
cellContent = 'P';
cellColor = '#888';
textColor = '#fff';
}

// Show committed pieces
if (committedHere.length > 0) {
const cp = committedHere[0];
cellContent = PIECE_SYMBOLS[cp.type];
cellColor = cp.playerId === '0' ? '#7ab8e6' : '#e67a7a';
textColor = '#000';
}

// Highlight valid cells
if (isValid) {
cellColor = '#90EE90';
}

const cellStyle: React.CSSProperties = {
width: `${CELL_SIZE}px`,
height: `${CELL_SIZE}px`,
fontSize: '24px',
fontWeight: 'bold',
border: '1px solid #333',
backgroundColor: cellColor,
color: textColor,
cursor: isValid ? 'pointer' : 'default',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
};

return (
<div
key={index}
style={cellStyle}
onClick={() => handleCellClick(index)}
>
{cellContent}
</div>
);
};

const buttonStyle: React.CSSProperties = {
padding: '10px 20px',
margin: '5px',
fontSize: '14px',
cursor: 'pointer',
};

const activeButtonStyle: React.CSSProperties = {
...buttonStyle,
backgroundColor: '#4CAF50',
color: 'white',
};

const pieceButtonStyle = (piece: Piece, isSelected: boolean): React.CSSProperties => ({
padding: '8px 12px',
margin: '3px',
fontSize: '12px',
cursor: piece.state === 'ready' ? 'pointer' : 'default',
backgroundColor: isSelected ? '#4CAF50' : piece.state === 'ready' ? '#fff' : '#ccc',
color: isSelected ? 'white' : '#333',
border: '1px solid #333',
opacity: piece.state === 'exhausted' ? 0.5 : 1,
});

// Render rows in reverse order (row 7 at top, row 0 at bottom)
const renderBoard = () => {
const rows = [];
for (let y = BOARD_SIZE - 1; y >= 0; y--) {
const row = [];
for (let x = 0; x < BOARD_SIZE; x++) {
row.push(renderCell(y * BOARD_SIZE + x));
}
rows.push(<div key={y} style={{ display: 'flex' }}>{row}</div>);
}
return rows;
};

return (
<div style={{ fontFamily: 'sans-serif', padding: '20px' }}>
<h1>Mecha Duel</h1>
<p style={{ fontSize: '18px', marginBottom: '10px' }}>{status}</p>

{/* Board */}
<div style={{ marginBottom: '20px' }}>
{renderBoard()}
</div>

{/* Action Buttons */}
{isMyTurn && !ctx.gameover && (
<div style={{ marginBottom: '20px' }}>
<h3>Actions</h3>
<button
style={selectedAction === 'commit' ? activeButtonStyle : buttonStyle}
onClick={() => {
setSelectedAction('commit');
setSelectedPieceId(null);
setSelectedExecutePieces([]);
}}
disabled={readyPieces.length === 0}
>
Commit
</button>
<button
style={selectedAction === 'execute' ? activeButtonStyle : buttonStyle}
onClick={() => {
setSelectedAction('execute');
setSelectedPieceId(null);
setSelectedExecutePieces([]);
}}
disabled={committedPieces.length === 0}
>
Execute
</button>
<button
style={selectedAction === 'kingStep' ? activeButtonStyle : buttonStyle}
onClick={() => {
setSelectedAction('kingStep');
setSelectedPieceId(null);
setSelectedExecutePieces([]);
}}
>
King Step
</button>
<button style={buttonStyle} onClick={handlePass}>
Pass
</button>
</div>
)}

{/* Commit: Select Piece */}
{selectedAction === 'commit' && isMyTurn && (
<div style={{ marginBottom: '20px' }}>
<h3>Select Piece to Commit</h3>
{readyPieces.map((piece) => (
<button
key={piece.id}
style={pieceButtonStyle(piece, selectedPieceId === piece.id)}
onClick={() => setSelectedPieceId(piece.id)}
>
{PIECE_SYMBOLS[piece.type]}
</button>
))}
{selectedPiece && <p>Click a highlighted cell to commit {PIECE_SYMBOLS[selectedPiece.type]}</p>}
</div>
)}

{/* Execute: Select Pieces */}
{selectedAction === 'execute' && isMyTurn && (
<div style={{ marginBottom: '20px' }}>
<h3>Select Committed Pieces to Execute</h3>
{committedPieces.map((cp) => (
<button
key={cp.pieceId}
style={pieceButtonStyle(
{ type: cp.type, state: 'committed', id: cp.pieceId },
selectedExecutePieces.includes(cp.pieceId)
)}
onClick={() => toggleExecutePiece(cp.pieceId)}
>
{PIECE_SYMBOLS[cp.type]} @ ({cp.target.x},{cp.target.y})
</button>
))}
{selectedExecutePieces.length > 0 && (
<button style={activeButtonStyle} onClick={handleExecute}>
Execute Selected
</button>
)}
</div>
)}

{/* King Step instruction */}
{selectedAction === 'kingStep' && isMyTurn && (
<p>Click a highlighted cell to move your King</p>
)}

{/* Piece Supply */}
<div style={{ marginTop: '20px' }}>
<h3>Your Pieces</h3>
<div>
<strong>Ready:</strong>{' '}
{myPieces.filter((p) => p.state === 'ready').map((p) => PIECE_SYMBOLS[p.type]).join(', ') || 'None'}
</div>
<div>
<strong>Committed:</strong>{' '}
{myPieces.filter((p) => p.state === 'committed').map((p) => PIECE_SYMBOLS[p.type]).join(', ') || 'None'}
</div>
<div>
<strong>Exhausted:</strong>{' '}
{myPieces.filter((p) => p.state === 'exhausted').map((p) => PIECE_SYMBOLS[p.type]).join(', ') || 'None'}
</div>
</div>
</div>
);
}
Loading