feat(frontend): 对局视图 + 回放视图 + 计时器 hook

This commit is contained in:
2026-05-31 00:25:41 +08:00
parent a4b3b5c380
commit 0138d80f2a
9 changed files with 209 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
import { useTranslation } from 'react-i18next';
import { useGameStore } from '../../store/gameStore';
interface Props {
onBackToMenu: () => void;
}
export default function GameControls({ onBackToMenu }: Props) {
const { t } = useTranslation();
const undo = useGameStore((s) => s.undo);
const mode = useGameStore((s) => s.mode);
const status = useGameStore((s) => s.status);
const handleUndo = () => {
undo(1);
};
return (
<div className="game-controls">
<button onClick={handleUndo} disabled={status === 'game_over'}>
{t('game.undo')}
</button>
<button onClick={onBackToMenu}>{t('game.new_game')}</button>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useTranslation } from 'react-i18next';
import { useGameStore } from '../../store/gameStore';
export default function GameInfo() {
const { t } = useTranslation();
const currentColor = useGameStore((s) => s.currentColor);
const status = useGameStore((s) => s.status);
const winner = useGameStore((s) => s.winner);
let text = '';
if (status === 'game_over' && winner) {
text = winner === 'Black' ? t('game.black_win') : t('game.white_win');
} else if (status === 'ai_thinking') {
text = t('game.ai_thinking');
} else if (status === 'playing') {
text = currentColor === 'Black' ? t('game.black_turn') : t('game.white_turn');
}
return <div className="game-info">{text}</div>;
}
+21
View File
@@ -0,0 +1,21 @@
import BoardCanvas from '../board/BoardCanvas';
import GameInfo from './GameInfo';
import GameControls from './GameControls';
import TimerDisplay from './TimerDisplay';
interface Props {
onBackToMenu: () => void;
}
export default function GameView({ onBackToMenu }: Props) {
return (
<div className="game-view">
<GameInfo />
<div className="board-container">
<BoardCanvas />
</div>
<TimerDisplay />
<GameControls onBackToMenu={onBackToMenu} />
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
import { useGameStore } from '../../store/gameStore';
export default function TimerDisplay() {
const config = useGameStore((s) => s.config);
const currentColor = useGameStore((s) => s.currentColor);
const status = useGameStore((s) => s.status);
const [time, setTime] = useState(config.timeLimitSecs);
useEffect(() => {
if (!config.useTimer || status !== 'playing') return;
setTime(config.timeLimitSecs);
const timer = setInterval(() => {
setTime((t) => {
if (t <= 1) { clearInterval(timer); return 0; }
return t - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [currentColor, config.useTimer, config.timeLimitSecs, status]);
if (!config.useTimer) return null;
return (
<div className={`timer-display ${time <= 10 ? 'timer-warning' : ''}`}>
{Math.floor(time / 60)}:{(time % 60).toString().padStart(2, '0')}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next';
interface Props {
isPlaying: boolean;
onTogglePlay: () => void;
onPrev: () => void;
onNext: () => void;
}
export default function ReplayControls({ isPlaying, onTogglePlay, onPrev, onNext }: Props) {
const { t } = useTranslation();
return (
<div className="replay-controls">
<button onClick={onPrev}>{t('replay.prev')}</button>
<button onClick={onTogglePlay}>{isPlaying ? t('replay.pause') : t('replay.play')}</button>
<button onClick={onNext}>{t('replay.next')}</button>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useGameStore } from '../../store/gameStore';
import BoardCanvas from '../board/BoardCanvas';
import StepSlider from './StepSlider';
import ReplayControls from './ReplayControls';
interface Props {
onBackToMenu: () => void;
}
export default function ReplayView({ onBackToMenu }: Props) {
const { t } = useTranslation();
const moves = useGameStore((s) => s.moves);
const [step, setStep] = useState(moves.length);
const [isPlaying, setIsPlaying] = useState(false);
useEffect(() => {
if (!isPlaying) return;
if (step >= moves.length) {
setIsPlaying(false);
return;
}
const timer = setInterval(() => setStep((s) => s + 1), 500);
return () => clearInterval(timer);
}, [isPlaying, step, moves.length]);
return (
<div className="replay-view">
<div className="board-container">
<BoardCanvas />
</div>
<StepSlider current={step} total={moves.length} onChange={setStep} />
<div>{t('replay.step', { current: step, total: moves.length })}</div>
<ReplayControls
isPlaying={isPlaying}
onTogglePlay={() => setIsPlaying(!isPlaying)}
onPrev={() => setStep(Math.max(0, step - 1))}
onNext={() => setStep(Math.min(moves.length, step + 1))}
/>
<button onClick={onBackToMenu}></button>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
interface Props {
current: number;
total: number;
onChange: (step: number) => void;
}
export default function StepSlider({ current, total, onChange }: Props) {
return (
<input
type="range"
min={0}
max={total}
value={current}
onChange={(e) => onChange(Number(e.target.value))}
className="step-slider"
/>
);
}
+13
View File
@@ -0,0 +1,13 @@
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 };
}
+19
View File
@@ -0,0 +1,19 @@
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;
}