mirror of
https://github.com/LHY0125/Gobang-Game.git
synced 2026-06-28 16:35:55 +08:00
fix: 代码审查修复 — serde camelCase/CSP/TS检查/replay/undo/AI禁手/星位/未使用依赖
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -12,3 +12,6 @@ target/
|
|||||||
.claude/
|
.claude/
|
||||||
.codegraph/
|
.codegraph/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
build/
|
||||||
|
bin/gobang_config.ini
|
||||||
|
*.png
|
||||||
|
|||||||
Generated
+3823
-91
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,4 @@ repository.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
renet = "2"
|
|
||||||
reqwest = { version = "0.12", features = ["json", "blocking"] }
|
reqwest = { version = "0.12", features = ["json", "blocking"] }
|
||||||
rand = "0.8"
|
|
||||||
|
|||||||
+9
-16
@@ -1,6 +1,7 @@
|
|||||||
use crate::ai::evaluate::evaluate_board;
|
use crate::ai::evaluate::evaluate_board;
|
||||||
use crate::ai::AiEngine;
|
use crate::ai::AiEngine;
|
||||||
use crate::board::Board;
|
use crate::board::Board;
|
||||||
|
use crate::rules;
|
||||||
use crate::types::{Color, Position};
|
use crate::types::{Color, Position};
|
||||||
|
|
||||||
/// Alpha-Beta AI 引擎
|
/// Alpha-Beta AI 引擎
|
||||||
@@ -25,6 +26,10 @@ impl AiEngine for AlphaBetaAi {
|
|||||||
let mut best_score = f64::NEG_INFINITY;
|
let mut best_score = f64::NEG_INFINITY;
|
||||||
|
|
||||||
for &pos in &candidates {
|
for &pos in &candidates {
|
||||||
|
// 禁手检查: 黑棋不能走禁手位置
|
||||||
|
if rules::is_forbidden(board, pos, color) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if let Ok(new_board) = board.place(pos, color) {
|
if let Ok(new_board) = board.place(pos, color) {
|
||||||
if new_board.check_win(pos) {
|
if new_board.check_win(pos) {
|
||||||
return Some(pos);
|
return Some(pos);
|
||||||
@@ -48,14 +53,7 @@ impl AiEngine for AlphaBetaAi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AlphaBetaAi {
|
impl AlphaBetaAi {
|
||||||
fn negamax(
|
fn negamax(&self, board: &Board, depth: usize, mut alpha: f64, beta: f64, color: Color) -> f64 {
|
||||||
&self,
|
|
||||||
board: &Board,
|
|
||||||
depth: usize,
|
|
||||||
mut alpha: f64,
|
|
||||||
beta: f64,
|
|
||||||
color: Color,
|
|
||||||
) -> f64 {
|
|
||||||
if depth == 0 {
|
if depth == 0 {
|
||||||
return evaluate_board(board, color);
|
return evaluate_board(board, color);
|
||||||
}
|
}
|
||||||
@@ -65,9 +63,10 @@ impl AlphaBetaAi {
|
|||||||
return evaluate_board(board, color);
|
return evaluate_board(board, color);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启发式排序:先评估每步棋,优先搜索高分走法
|
// 启发式排序:先评估每步棋,优先搜索高分走法 (跳过禁手)
|
||||||
let mut scored: Vec<(Position, f64)> = candidates
|
let mut scored: Vec<(Position, f64)> = candidates
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
.filter(|&pos| !rules::is_forbidden(board, pos, color))
|
||||||
.filter_map(|pos| {
|
.filter_map(|pos| {
|
||||||
board.place(pos, color).ok().map(|b| {
|
board.place(pos, color).ok().map(|b| {
|
||||||
if b.check_win(pos) {
|
if b.check_win(pos) {
|
||||||
@@ -87,13 +86,7 @@ impl AlphaBetaAi {
|
|||||||
if new_board.check_win(pos) {
|
if new_board.check_win(pos) {
|
||||||
return f64::INFINITY;
|
return f64::INFINITY;
|
||||||
}
|
}
|
||||||
let val = -self.negamax(
|
let val = -self.negamax(&new_board, depth - 1, -beta, -alpha, color.opponent());
|
||||||
&new_board,
|
|
||||||
depth - 1,
|
|
||||||
-beta,
|
|
||||||
-alpha,
|
|
||||||
color.opponent(),
|
|
||||||
);
|
|
||||||
if val > max_val {
|
if val > max_val {
|
||||||
max_val = val;
|
max_val = val;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ impl LlmAi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AiEngine for LlmAi {
|
impl AiEngine for LlmAi {
|
||||||
|
/// 获取 AI 最佳走法。
|
||||||
|
///
|
||||||
|
/// TODO: 当前使用阻塞 HTTP 客户端 (`reqwest::blocking`),
|
||||||
|
/// 在 GUI 线程调用会冻结界面。上层应在独立线程
|
||||||
|
/// (`std::thread::spawn` 或 `tauri::async_runtime::spawn_blocking`) 中调用此方法,
|
||||||
|
/// 或改用 async 版本。
|
||||||
fn best_move(&self, board: &Board, color: Color) -> Option<Position> {
|
fn best_move(&self, board: &Board, color: Color) -> Option<Position> {
|
||||||
let prompt = Self::board_to_prompt(board, color);
|
let prompt = Self::board_to_prompt(board, color);
|
||||||
let client = reqwest::blocking::Client::new();
|
let client = reqwest::blocking::Client::new();
|
||||||
|
|||||||
+8
-5
@@ -1,6 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use crate::board::Board;
|
use crate::board::Board;
|
||||||
use crate::types::{Color, Position};
|
use crate::types::{Color, Position};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// 对局棋谱
|
/// 对局棋谱
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -42,7 +42,10 @@ impl GameRecord {
|
|||||||
Color::Black => black.to_string(),
|
Color::Black => black.to_string(),
|
||||||
Color::White => white.to_string(),
|
Color::White => white.to_string(),
|
||||||
});
|
});
|
||||||
let moves = board.history().iter().map(|m| RecordMove {
|
let moves = board
|
||||||
|
.history()
|
||||||
|
.iter()
|
||||||
|
.map(|m| RecordMove {
|
||||||
x: m.position.x,
|
x: m.position.x,
|
||||||
y: m.position.y,
|
y: m.position.y,
|
||||||
color: match m.color {
|
color: match m.color {
|
||||||
@@ -50,7 +53,8 @@ impl GameRecord {
|
|||||||
Color::White => "White".into(),
|
Color::White => "White".into(),
|
||||||
},
|
},
|
||||||
turn: m.turn,
|
turn: m.turn,
|
||||||
}).collect();
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
version: "2.0".to_string(),
|
version: "2.0".to_string(),
|
||||||
@@ -101,8 +105,7 @@ mod tests {
|
|||||||
let board = board.place(Position::new(7, 7), Color::Black).unwrap();
|
let board = board.place(Position::new(7, 7), Color::Black).unwrap();
|
||||||
let board = board.place(Position::new(7, 8), Color::White).unwrap();
|
let board = board.place(Position::new(7, 8), Color::White).unwrap();
|
||||||
|
|
||||||
let record =
|
let record = GameRecord::from_board(&board, "Human", "AI-Lv3", Some(Color::Black));
|
||||||
GameRecord::from_board(&board, "Human", "AI-Lv3", Some(Color::Black));
|
|
||||||
let json = serde_json::to_string_pretty(&record).unwrap();
|
let json = serde_json::to_string_pretty(&record).unwrap();
|
||||||
|
|
||||||
let loaded: GameRecord = serde_json::from_str(&json).unwrap();
|
let loaded: GameRecord = serde_json::from_str(&json).unwrap();
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ pub struct GameResult {
|
|||||||
|
|
||||||
/// 游戏模式 (Tauri IPC 兼容 — 纯标签, 不含字段)
|
/// 游戏模式 (Tauri IPC 兼容 — 纯标签, 不含字段)
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub enum GameMode {
|
pub enum GameMode {
|
||||||
Local,
|
Local,
|
||||||
VsAi,
|
VsAi,
|
||||||
@@ -108,6 +109,7 @@ pub enum GameMode {
|
|||||||
|
|
||||||
/// 游戏配置
|
/// 游戏配置
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct GameConfig {
|
pub struct GameConfig {
|
||||||
pub board_size: usize,
|
pub board_size: usize,
|
||||||
pub use_forbidden_rules: bool,
|
pub use_forbidden_rules: bool,
|
||||||
@@ -116,6 +118,8 @@ pub struct GameConfig {
|
|||||||
pub ai_difficulty: u32,
|
pub ai_difficulty: u32,
|
||||||
pub player_color: Color,
|
pub player_color: Color,
|
||||||
pub is_server: bool,
|
pub is_server: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub remote_address: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for GameConfig {
|
impl Default for GameConfig {
|
||||||
@@ -128,6 +132,7 @@ impl Default for GameConfig {
|
|||||||
ai_difficulty: 3,
|
ai_difficulty: 3,
|
||||||
player_color: Color::Black,
|
player_color: Color::Black,
|
||||||
is_server: false,
|
is_server: false,
|
||||||
|
remote_address: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -98,6 +98,14 @@ pub fn undo(steps: u32, state: State<AppState>) -> Result<(), String> {
|
|||||||
board = board.undo().map_err(|e| e.to_string())?;
|
board = board.undo().map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 根据剩余步数修正当前颜色 (偶数 = 黑, 奇数 = 白)
|
||||||
|
let corrected_color = match board.history().last() {
|
||||||
|
Some(last_move) => last_move.color.opponent(),
|
||||||
|
None => state.config.lock().map_err(|e| e.to_string())?.player_color,
|
||||||
|
};
|
||||||
|
*state.current_color.lock().map_err(|e| e.to_string())? = corrected_color;
|
||||||
|
*state.game_over.lock().map_err(|e| e.to_string())? = false;
|
||||||
|
|
||||||
*board_opt = Some(board);
|
*board_opt = Some(board);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ipc: http://ipc.localhost"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import { useGameStore } from '../../store/gameStore';
|
import { useGameStore, buildReplayBoard } from '../../store/gameStore';
|
||||||
import {
|
import {
|
||||||
computeBoardDimensions,
|
computeBoardDimensions,
|
||||||
canvasToBoard,
|
canvasToBoard,
|
||||||
@@ -15,8 +15,26 @@ export default function BoardCanvas() {
|
|||||||
const placePiece = useGameStore((s) => s.placePiece);
|
const placePiece = useGameStore((s) => s.placePiece);
|
||||||
const aiMove = useGameStore((s) => s.aiMove);
|
const aiMove = useGameStore((s) => s.aiMove);
|
||||||
const moves = useGameStore((s) => s.moves);
|
const moves = useGameStore((s) => s.moves);
|
||||||
|
const replayStep = useGameStore((s) => s.replayStep);
|
||||||
|
|
||||||
const lastMove = moves.length > 0 ? moves[moves.length - 1].position : null;
|
// 复盘模式下根据 replayStep 重建棋盘
|
||||||
|
const displayBoard = useMemo(() => {
|
||||||
|
if (mode === 'Replay') {
|
||||||
|
return buildReplayBoard(boardSize, moves, replayStep);
|
||||||
|
}
|
||||||
|
return board;
|
||||||
|
}, [mode, board, boardSize, moves, replayStep]);
|
||||||
|
|
||||||
|
// 复盘模式下的最后一手
|
||||||
|
const displayLastMove = useMemo(() => {
|
||||||
|
if (mode !== 'Replay') {
|
||||||
|
return moves.length > 0 ? moves[moves.length - 1].position : null;
|
||||||
|
}
|
||||||
|
if (replayStep > 0 && replayStep <= moves.length) {
|
||||||
|
return moves[replayStep - 1].position;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [mode, moves, replayStep]);
|
||||||
|
|
||||||
const render = useCallback(() => {
|
const render = useCallback(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -31,8 +49,8 @@ export default function BoardCanvas() {
|
|||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
const cfg = computeBoardDimensions(boardSize, rect.width, rect.height);
|
const cfg = computeBoardDimensions(boardSize, rect.width, rect.height);
|
||||||
renderBoard(ctx, board, cfg, lastMove);
|
renderBoard(ctx, displayBoard, cfg, displayLastMove);
|
||||||
}, [board, boardSize, lastMove]);
|
}, [displayBoard, boardSize, displayLastMove]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
render();
|
render();
|
||||||
|
|||||||
@@ -6,6 +6,26 @@ export interface RenderConfig {
|
|||||||
boardSize: number;
|
boardSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据棋盘大小动态生成星位坐标。
|
||||||
|
* 标准五子棋/围棋星位: 四角和中心及边中点。
|
||||||
|
* 9x9 起可用,更小的棋盘仅使用中心点。
|
||||||
|
*/
|
||||||
|
export function computeStarPoints(boardSize: number): [number, number][] {
|
||||||
|
if (boardSize < 9) {
|
||||||
|
const mid = Math.floor(boardSize / 2);
|
||||||
|
return [[mid, mid]];
|
||||||
|
}
|
||||||
|
const a = Math.min(3, Math.floor(boardSize / 4));
|
||||||
|
const b = Math.floor(boardSize / 2);
|
||||||
|
const c = boardSize - 1 - a;
|
||||||
|
return [
|
||||||
|
[a, a], [a, b], [a, c],
|
||||||
|
[b, a], [b, b], [b, c],
|
||||||
|
[c, a], [c, b], [c, c],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function computeBoardDimensions(boardSize: number, canvasWidth: number, canvasHeight: number): RenderConfig {
|
export function computeBoardDimensions(boardSize: number, canvasWidth: number, canvasHeight: number): RenderConfig {
|
||||||
const maxBoardPixelSize = Math.min(canvasWidth, canvasHeight) * 0.85;
|
const maxBoardPixelSize = Math.min(canvasWidth, canvasHeight) * 0.85;
|
||||||
const cellSize = Math.floor(maxBoardPixelSize / (boardSize - 1));
|
const cellSize = Math.floor(maxBoardPixelSize / (boardSize - 1));
|
||||||
@@ -64,12 +84,8 @@ export function renderBoard(
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 星位
|
// 星位 — 根据棋盘大小动态计算
|
||||||
const starPoints = [
|
const starPoints = computeStarPoints(boardSize);
|
||||||
[3, 3], [3, 7], [3, 11],
|
|
||||||
[7, 3], [7, 7], [7, 11],
|
|
||||||
[11, 3], [11, 7], [11, 11],
|
|
||||||
];
|
|
||||||
ctx.fillStyle = '#8B7355';
|
ctx.fillStyle = '#8B7355';
|
||||||
for (const [r, c] of starPoints) {
|
for (const [r, c] of starPoints) {
|
||||||
if (r < boardSize && c < boardSize) {
|
if (r < boardSize && c < boardSize) {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ interface Props {
|
|||||||
export default function GameControls({ onBackToMenu }: Props) {
|
export default function GameControls({ onBackToMenu }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const undo = useGameStore((s) => s.undo);
|
const undo = useGameStore((s) => s.undo);
|
||||||
const mode = useGameStore((s) => s.mode);
|
|
||||||
const status = useGameStore((s) => s.status);
|
const status = useGameStore((s) => s.status);
|
||||||
|
|
||||||
const handleUndo = () => {
|
const handleUndo = () => {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export default function AiGameSetup({ onBack, onStart }: Props) {
|
|||||||
aiDifficulty: difficulty,
|
aiDifficulty: difficulty,
|
||||||
playerColor,
|
playerColor,
|
||||||
isServer: false,
|
isServer: false,
|
||||||
|
remoteAddress: '',
|
||||||
};
|
};
|
||||||
await startGame('VsAi', config);
|
await startGame('VsAi', config);
|
||||||
onStart();
|
onStart();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export default function LocalGameSetup({ onBack, onStart }: Props) {
|
|||||||
aiDifficulty: 3,
|
aiDifficulty: 3,
|
||||||
playerColor: 'Black',
|
playerColor: 'Black',
|
||||||
isServer: false,
|
isServer: false,
|
||||||
|
remoteAddress: '',
|
||||||
};
|
};
|
||||||
await startGame('Local', config);
|
await startGame('Local', config);
|
||||||
onStart();
|
onStart();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export default function OnlineSetup({ onBack, onStart }: Props) {
|
|||||||
const baseConfig: GameConfig = {
|
const baseConfig: GameConfig = {
|
||||||
boardSize: 15, useForbiddenRules: true, useTimer: false,
|
boardSize: 15, useForbiddenRules: true, useTimer: false,
|
||||||
timeLimitSecs: 60, aiDifficulty: 3, playerColor: 'Black', isServer: false,
|
timeLimitSecs: 60, aiDifficulty: 3, playerColor: 'Black', isServer: false,
|
||||||
|
remoteAddress: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHost = async () => {
|
const handleHost = async () => {
|
||||||
@@ -21,7 +22,7 @@ export default function OnlineSetup({ onBack, onStart }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleJoin = async () => {
|
const handleJoin = async () => {
|
||||||
await startGame('Online', { ...baseConfig });
|
await startGame('Online', { ...baseConfig, remoteAddress: ip });
|
||||||
onStart();
|
onStart();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,31 +12,34 @@ interface Props {
|
|||||||
export default function ReplayView({ onBackToMenu }: Props) {
|
export default function ReplayView({ onBackToMenu }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const moves = useGameStore((s) => s.moves);
|
const moves = useGameStore((s) => s.moves);
|
||||||
const [step, setStep] = useState(moves.length);
|
const replayStep = useGameStore((s) => s.replayStep);
|
||||||
|
const setReplayStep = useGameStore((s) => s.setReplayStep);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
|
||||||
|
const step = replayStep;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPlaying) return;
|
if (!isPlaying) return;
|
||||||
if (step >= moves.length) {
|
if (step >= moves.length) {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timer = setInterval(() => setStep((s) => s + 1), 500);
|
const timer = setInterval(() => setReplayStep(step + 1), 500);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [isPlaying, step, moves.length]);
|
}, [isPlaying, step, moves.length, setReplayStep]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="replay-view">
|
<div className="replay-view">
|
||||||
<div className="board-container">
|
<div className="board-container">
|
||||||
<BoardCanvas />
|
<BoardCanvas />
|
||||||
</div>
|
</div>
|
||||||
<StepSlider current={step} total={moves.length} onChange={setStep} />
|
<StepSlider current={step} total={moves.length} onChange={setReplayStep} />
|
||||||
<div>{t('replay.step', { current: step, total: moves.length })}</div>
|
<div>{t('replay.step', { current: step, total: moves.length })}</div>
|
||||||
<ReplayControls
|
<ReplayControls
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
onTogglePlay={() => setIsPlaying(!isPlaying)}
|
onTogglePlay={() => setIsPlaying(!isPlaying)}
|
||||||
onPrev={() => setStep(Math.max(0, step - 1))}
|
onPrev={() => setReplayStep(Math.max(0, step - 1))}
|
||||||
onNext={() => setStep(Math.min(moves.length, step + 1))}
|
onNext={() => setReplayStep(Math.min(moves.length, step + 1))}
|
||||||
/>
|
/>
|
||||||
<button onClick={onBackToMenu}>返回菜单</button>
|
<button onClick={onBackToMenu}>返回菜单</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
export const DEFAULT_BOARD_SIZE = 15;
|
export const DEFAULT_BOARD_SIZE = 15;
|
||||||
export const MIN_BOARD_SIZE = 9;
|
export const MIN_BOARD_SIZE = 9;
|
||||||
export const MAX_BOARD_SIZE = 19;
|
export const MAX_BOARD_SIZE = 19;
|
||||||
|
|
||||||
export const CELL_COLORS: Record<number, string> = {
|
|
||||||
0: 'transparent',
|
|
||||||
1: '#1a1a1a',
|
|
||||||
2: '#f5f5f5',
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface GameConfig {
|
|||||||
aiDifficulty: number;
|
aiDifficulty: number;
|
||||||
playerColor: Color;
|
playerColor: Color;
|
||||||
isServer: boolean;
|
isServer: boolean;
|
||||||
|
remoteAddress: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MoveResult {
|
export interface MoveResult {
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
import { useGameStore } from '../store/gameStore';
|
|
||||||
import type { GameConfig, GameModeType } from '../core/types';
|
|
||||||
|
|
||||||
export function useGame() {
|
|
||||||
const store = useGameStore();
|
|
||||||
|
|
||||||
const startGame = useCallback(async (mode: GameModeType, config: GameConfig) => {
|
|
||||||
await store.startGame(mode, config);
|
|
||||||
}, [store]);
|
|
||||||
|
|
||||||
return { ...store, startGame };
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
export function useTimer(seconds: number, active: boolean, onTimeout: () => void) {
|
|
||||||
const [time, setTime] = useState(seconds);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!active) return;
|
|
||||||
setTime(seconds);
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
setTime((t) => {
|
|
||||||
if (t <= 1) { clearInterval(timer); onTimeout(); return 0; }
|
|
||||||
return t - 1;
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [active, seconds, onTimeout]);
|
|
||||||
|
|
||||||
return time;
|
|
||||||
}
|
|
||||||
+21
-1
@@ -2,6 +2,17 @@ import { create } from 'zustand';
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import type { CellState, Color, GameConfig, GameModeType, GameStatus, Move, MoveResult } from '../core/types';
|
import type { CellState, Color, GameConfig, GameModeType, GameStatus, Move, MoveResult } from '../core/types';
|
||||||
|
|
||||||
|
/** 根据落子列表重建棋盘到指定步数 */
|
||||||
|
export function buildReplayBoard(boardSize: number, moves: Move[], step: number): CellState[][] {
|
||||||
|
const b: CellState[][] = Array.from({ length: boardSize }, () => Array(boardSize).fill(0) as CellState[]);
|
||||||
|
const limit = Math.min(step, moves.length);
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
const m = moves[i];
|
||||||
|
b[m.position.x][m.position.y] = (m.color === 'Black' ? 1 : 2) as CellState;
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
interface GameState {
|
interface GameState {
|
||||||
mode: GameModeType;
|
mode: GameModeType;
|
||||||
board: CellState[][];
|
board: CellState[][];
|
||||||
@@ -12,6 +23,7 @@ interface GameState {
|
|||||||
moves: Move[];
|
moves: Move[];
|
||||||
config: GameConfig;
|
config: GameConfig;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
|
replayStep: number;
|
||||||
|
|
||||||
startGame: (mode: GameModeType, config: GameConfig) => Promise<void>;
|
startGame: (mode: GameModeType, config: GameConfig) => Promise<void>;
|
||||||
placePiece: (x: number, y: number) => Promise<MoveResult>;
|
placePiece: (x: number, y: number) => Promise<MoveResult>;
|
||||||
@@ -19,6 +31,7 @@ interface GameState {
|
|||||||
aiMove: () => Promise<void>;
|
aiMove: () => Promise<void>;
|
||||||
refreshBoard: () => Promise<void>;
|
refreshBoard: () => Promise<void>;
|
||||||
loadReplayBoard: (board: CellState[][], moves: Move[]) => void;
|
loadReplayBoard: (board: CellState[][], moves: Move[]) => void;
|
||||||
|
setReplayStep: (step: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGameStore = create<GameState>((set, get) => ({
|
export const useGameStore = create<GameState>((set, get) => ({
|
||||||
@@ -37,8 +50,10 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
aiDifficulty: 3,
|
aiDifficulty: 3,
|
||||||
playerColor: 'Black',
|
playerColor: 'Black',
|
||||||
isServer: false,
|
isServer: false,
|
||||||
|
remoteAddress: '',
|
||||||
},
|
},
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
|
replayStep: 0,
|
||||||
|
|
||||||
startGame: async (mode, config) => {
|
startGame: async (mode, config) => {
|
||||||
await invoke('new_game', { mode, config });
|
await invoke('new_game', { mode, config });
|
||||||
@@ -50,6 +65,7 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
currentColor: 'Black',
|
currentColor: 'Black',
|
||||||
winner: null,
|
winner: null,
|
||||||
moves: [],
|
moves: [],
|
||||||
|
replayStep: 0,
|
||||||
});
|
});
|
||||||
await get().refreshBoard();
|
await get().refreshBoard();
|
||||||
},
|
},
|
||||||
@@ -92,6 +108,10 @@ export const useGameStore = create<GameState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
loadReplayBoard: (board, moves) => {
|
loadReplayBoard: (board, moves) => {
|
||||||
set({ board, moves, mode: 'Replay', status: 'playing' });
|
set({ board, moves, mode: 'Replay', status: 'playing', replayStep: moves.length });
|
||||||
|
},
|
||||||
|
|
||||||
|
setReplayStep: (step) => {
|
||||||
|
set({ replayStep: step });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Strict */
|
||||||
|
"strict": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.node.json" }
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./tsconfig.app.json" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user