mirror of
https://github.com/LHY0125/Gobang-Game.git
synced 2026-06-29 00:45:55 +08:00
feat(frontend): Canvas 棋盘渲染 — 木纹风格, 棋子渐变, 最后一手高亮
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useGameStore } from '../../store/gameStore';
|
||||||
|
import {
|
||||||
|
computeBoardDimensions,
|
||||||
|
canvasToBoard,
|
||||||
|
renderBoard,
|
||||||
|
} from './board-renderer';
|
||||||
|
|
||||||
|
export default function BoardCanvas() {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const board = useGameStore((s) => s.board);
|
||||||
|
const boardSize = useGameStore((s) => s.boardSize);
|
||||||
|
const status = useGameStore((s) => s.status);
|
||||||
|
const mode = useGameStore((s) => s.mode);
|
||||||
|
const placePiece = useGameStore((s) => s.placePiece);
|
||||||
|
const aiMove = useGameStore((s) => s.aiMove);
|
||||||
|
const moves = useGameStore((s) => s.moves);
|
||||||
|
|
||||||
|
const lastMove = moves.length > 0 ? moves[moves.length - 1].position : null;
|
||||||
|
|
||||||
|
const render = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = rect.width * dpr;
|
||||||
|
canvas.height = rect.height * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
const cfg = computeBoardDimensions(boardSize, rect.width, rect.height);
|
||||||
|
renderBoard(ctx, board, cfg, lastMove);
|
||||||
|
}, [board, boardSize, lastMove]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
render();
|
||||||
|
const handleResize = () => render();
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, [render]);
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
if (status !== 'playing') return;
|
||||||
|
if (mode === 'VsAi' && moves.length % 2 === 1) return;
|
||||||
|
if (mode === 'Replay') return;
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const cfg = computeBoardDimensions(boardSize, rect.width, rect.height);
|
||||||
|
const pos = canvasToBoard(e.clientX - rect.left, e.clientY - rect.top, cfg);
|
||||||
|
if (!pos) return;
|
||||||
|
|
||||||
|
placePiece(pos.x, pos.y).then((result) => {
|
||||||
|
if (!result.is_win && mode === 'VsAi') {
|
||||||
|
setTimeout(() => aiMove(), 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[status, mode, boardSize, moves.length, placePiece, aiMove]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
onClick={handleClick}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
cursor: status === 'playing' && mode !== 'Replay' ? 'pointer' : 'default',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import type { CellState, Position } from '../../core/types';
|
||||||
|
|
||||||
|
export interface RenderConfig {
|
||||||
|
cellSize: number;
|
||||||
|
padding: number;
|
||||||
|
boardSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeBoardDimensions(boardSize: number, canvasWidth: number, canvasHeight: number): RenderConfig {
|
||||||
|
const maxBoardPixelSize = Math.min(canvasWidth, canvasHeight) * 0.85;
|
||||||
|
const cellSize = Math.floor(maxBoardPixelSize / (boardSize - 1));
|
||||||
|
const actualBoardPixelSize = cellSize * (boardSize - 1);
|
||||||
|
const padding = Math.floor((Math.min(canvasWidth, canvasHeight) - actualBoardPixelSize) / 2);
|
||||||
|
return { cellSize, padding, boardSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canvasToBoard(
|
||||||
|
canvasX: number,
|
||||||
|
canvasY: number,
|
||||||
|
cfg: RenderConfig
|
||||||
|
): Position | null {
|
||||||
|
const col = Math.round((canvasX - cfg.padding) / cfg.cellSize);
|
||||||
|
const row = Math.round((canvasY - cfg.padding) / cfg.cellSize);
|
||||||
|
if (col < 0 || col >= cfg.boardSize || row < 0 || row >= cfg.boardSize) return null;
|
||||||
|
return { x: row, y: col };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function boardToCanvas(pos: Position, cfg: RenderConfig): { x: number; y: number } {
|
||||||
|
return {
|
||||||
|
x: cfg.padding + pos.y * cfg.cellSize,
|
||||||
|
y: cfg.padding + pos.x * cfg.cellSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderBoard(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
board: CellState[][],
|
||||||
|
cfg: RenderConfig,
|
||||||
|
lastMove: Position | null
|
||||||
|
): void {
|
||||||
|
const { cellSize, padding, boardSize } = cfg;
|
||||||
|
const width = padding * 2 + (boardSize - 1) * cellSize;
|
||||||
|
const height = width;
|
||||||
|
|
||||||
|
// 背景 (木纹色)
|
||||||
|
ctx.fillStyle = '#DEB887';
|
||||||
|
ctx.fillRect(0, 0, width + padding, height + padding);
|
||||||
|
|
||||||
|
// 棋盘区域
|
||||||
|
ctx.fillStyle = '#F5DEB3';
|
||||||
|
ctx.fillRect(padding - 10, padding - 10, (boardSize - 1) * cellSize + 20, (boardSize - 1) * cellSize + 20);
|
||||||
|
|
||||||
|
// 网格线
|
||||||
|
ctx.strokeStyle = '#8B7355';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let i = 0; i < boardSize; i++) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(padding, padding + i * cellSize);
|
||||||
|
ctx.lineTo(padding + (boardSize - 1) * cellSize, padding + i * cellSize);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(padding + i * cellSize, padding);
|
||||||
|
ctx.lineTo(padding + i * cellSize, padding + (boardSize - 1) * cellSize);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 星位
|
||||||
|
const starPoints = [
|
||||||
|
[3, 3], [3, 7], [3, 11],
|
||||||
|
[7, 3], [7, 7], [7, 11],
|
||||||
|
[11, 3], [11, 7], [11, 11],
|
||||||
|
];
|
||||||
|
ctx.fillStyle = '#8B7355';
|
||||||
|
for (const [r, c] of starPoints) {
|
||||||
|
if (r < boardSize && c < boardSize) {
|
||||||
|
const { x, y } = boardToCanvas({ x: r, y: c }, cfg);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 棋子
|
||||||
|
for (let x = 0; x < boardSize; x++) {
|
||||||
|
for (let y = 0; y < boardSize; y++) {
|
||||||
|
if (board[x]?.[y] === 0) continue;
|
||||||
|
const { x: cx, y: cy } = boardToCanvas({ x, y }, cfg);
|
||||||
|
const radius = cellSize * 0.43;
|
||||||
|
|
||||||
|
if (board[x][y] === 1) {
|
||||||
|
const gradient = ctx.createRadialGradient(cx - 2, cy - 2, 1, cx, cy, radius);
|
||||||
|
gradient.addColorStop(0, '#4a4a4a');
|
||||||
|
gradient.addColorStop(1, '#1a1a1a');
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
} else {
|
||||||
|
const gradient = ctx.createRadialGradient(cx - 2, cy - 2, 1, cx, cy, radius);
|
||||||
|
gradient.addColorStop(0, '#ffffff');
|
||||||
|
gradient.addColorStop(1, '#d0d0d0');
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
if (board[x][y] === 2) {
|
||||||
|
ctx.strokeStyle = '#b0b0b0';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最后一手高亮
|
||||||
|
if (lastMove) {
|
||||||
|
const { x, y } = boardToCanvas(lastMove, cfg);
|
||||||
|
ctx.strokeStyle = '#ff4444';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, cellSize * 0.2, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user