feat: Online 模式前端 UI — 房间管理/连接状态/remote-move/禁悔棋

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 15:16:40 +08:00
parent 9aa9de6b74
commit bda917ce37
7 changed files with 111 additions and 24 deletions
+15
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useCallback, useMemo } from 'react'; import { useEffect, useRef, useCallback, useMemo } from 'react';
import { listen } from '@tauri-apps/api/event';
import { useGameStore, buildReplayBoard } from '../../store/gameStore'; import { useGameStore, buildReplayBoard } from '../../store/gameStore';
import { import {
computeBoardDimensions, computeBoardDimensions,
@@ -59,6 +60,20 @@ export default function BoardCanvas() {
return () => window.removeEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize);
}, [render]); }, [render]);
useEffect(() => {
if (mode !== 'Online') return;
let unlisten: (() => void) | undefined;
const setup = async () => {
unlisten = await listen<{ x: number; y: number }>('remote-move', (event) => {
placePiece(event.payload.x, event.payload.y);
});
};
setup();
return () => { unlisten?.(); };
}, [mode, placePiece]);
const handleClick = useCallback( const handleClick = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => { (e: React.MouseEvent<HTMLCanvasElement>) => {
if (status !== 'playing') return; if (status !== 'playing') return;
+2 -1
View File
@@ -10,6 +10,7 @@ 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 status = useGameStore((s) => s.status); const status = useGameStore((s) => s.status);
const mode = useGameStore((s) => s.mode);
const refreshBoard = useGameStore((s) => s.refreshBoard); const refreshBoard = useGameStore((s) => s.refreshBoard);
const handleUndo = () => { const handleUndo = () => {
@@ -38,7 +39,7 @@ export default function GameControls({ onBackToMenu }: Props) {
return ( return (
<div className="game-controls"> <div className="game-controls">
<button onClick={handleUndo} disabled={status === 'game_over'}> <button onClick={handleUndo} disabled={status === 'game_over' || mode === 'Online'}>
{t('game.undo')} {t('game.undo')}
</button> </button>
<button onClick={handleResign} disabled={status === 'game_over'}> <button onClick={handleResign} disabled={status === 'game_over'}>
+29
View File
@@ -1,3 +1,7 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { listen } from '@tauri-apps/api/event';
import { useGameStore } from '../../store/gameStore';
import BoardCanvas from '../board/BoardCanvas'; import BoardCanvas from '../board/BoardCanvas';
import GameInfo from './GameInfo'; import GameInfo from './GameInfo';
import GameControls from './GameControls'; import GameControls from './GameControls';
@@ -8,8 +12,33 @@ interface Props {
} }
export default function GameView({ onBackToMenu }: Props) { export default function GameView({ onBackToMenu }: Props) {
const { t } = useTranslation();
const mode = useGameStore((s) => s.mode);
const [connStatus, setConnStatus] = useState<string>('');
useEffect(() => {
if (mode !== 'Online') return;
let unlisten1: (() => void) | undefined;
let unlisten2: (() => void) | undefined;
const setup = async () => {
unlisten1 = await listen<string>('connection-status', (e) => setConnStatus(e.payload));
unlisten2 = await listen<number>('listening-port', (e) => setConnStatus('waiting:' + e.payload));
};
setup();
return () => { unlisten1?.(); unlisten2?.(); };
}, [mode]);
return ( return (
<div className="game-view"> <div className="game-view">
{mode === 'Online' && connStatus && (
<div style={{ fontSize: 14, opacity: 0.8 }}>
{connStatus.startsWith('waiting') ? '等待对手加入...' :
connStatus === 'connected' ? t('game.opponent_connected') :
connStatus === 'disconnected' ? t('game.opponent_disconnected') : ''}
</div>
)}
<GameInfo /> <GameInfo />
<div className="board-container"> <div className="board-container">
<BoardCanvas /> <BoardCanvas />
+1 -7
View File
@@ -27,13 +27,7 @@ export default function MainMenu({ onGameStart, onReplayStart }: Props) {
<div className="menu-buttons"> <div className="menu-buttons">
<button onClick={() => setView('local')}>{t('menu.local_game')}</button> <button onClick={() => setView('local')}>{t('menu.local_game')}</button>
<button onClick={() => setView('ai')}>{t('menu.ai_game')}</button> <button onClick={() => setView('ai')}>{t('menu.ai_game')}</button>
<button <button onClick={() => setView('online')}>{t('menu.online_game')}</button>
onClick={() => setView('online')}
disabled
title={t('menu.online_game_disabled')}
>
{t('menu.online_game')} ()
</button>
<button onClick={() => setView('replay')}>{t('menu.load_replay')}</button> <button onClick={() => setView('replay')}>{t('menu.load_replay')}</button>
</div> </div>
</div> </div>
+58 -14
View File
@@ -1,6 +1,8 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useGameStore } from '../../store/gameStore'; import { useGameStore } from '../../store/gameStore';
import { useState } from 'react'; import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { MIN_BOARD_SIZE, MAX_BOARD_SIZE } from '../../core/constants'; import { MIN_BOARD_SIZE, MAX_BOARD_SIZE } from '../../core/constants';
import type { GameConfig } from '../../core/types'; import type { GameConfig } from '../../core/types';
@@ -9,25 +11,67 @@ interface Props { onBack: () => void; onStart: () => void; }
export default function OnlineSetup({ onBack, onStart }: Props) { export default function OnlineSetup({ onBack, onStart }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const startGame = useGameStore((s) => s.startGame); const startGame = useGameStore((s) => s.startGame);
const [ip, setIp] = useState('');
const [boardSize, setBoardSize] = useState(15); const [boardSize, setBoardSize] = useState(15);
const [ip, setIp] = useState('');
const baseConfig: GameConfig = { const [myAddress, setMyAddress] = useState('');
boardSize, useForbiddenRules: true, useTimer: false, const [isHosting, setIsHosting] = useState(false);
timeLimitSecs: 60, aiDifficulty: 3, playerColor: 'Black', isServer: false,
remoteAddress: '',
};
const handleHost = async () => { const handleHost = async () => {
await startGame('Online', { ...baseConfig, isServer: true }); try {
onStart(); const port: number = await invoke('host_game', { port: 0 });
setMyAddress(`127.0.0.1:${port}`);
setIsHosting(true);
const unlisten = await listen<string>('connection-status', async (event) => {
if (event.payload === 'connected') {
unlisten();
const config: GameConfig = {
boardSize, useForbiddenRules: true, useTimer: false,
timeLimitSecs: 60, aiDifficulty: 3, playerColor: 'Black',
isServer: true, remoteAddress: '', hostPort: port,
useLlm: false, llmEndpoint: '', llmApiKey: '', llmModel: '',
};
await startGame('Online', config);
onStart();
}
});
} catch (e) {
alert('创建房间失败: ' + e);
}
}; };
const handleJoin = async () => { const handleJoin = async () => {
await startGame('Online', { ...baseConfig, remoteAddress: ip }); try {
onStart(); const [_, portStr] = ip.split(':');
const port = parseInt(portStr) || 0;
const config: GameConfig = {
boardSize, useForbiddenRules: true, useTimer: false,
timeLimitSecs: 60, aiDifficulty: 3, playerColor: 'White',
isServer: false, remoteAddress: ip, hostPort: port,
useLlm: false, llmEndpoint: '', llmApiKey: '', llmModel: '',
};
await startGame('Online', config);
await invoke('join_game', { address: ip });
onStart();
} catch (e) {
alert('加入房间失败: ' + e);
}
}; };
if (isHosting) {
return (
<div className="setup-panel">
<h2>{t('menu.online_game')}</h2>
<p style={{ fontSize: 18 }}>...</p>
<p style={{ fontSize: 24, fontFamily: 'monospace', background: '#F5DEB3', color: '#3C2415', padding: '8px 16px', borderRadius: 4 }}>
{myAddress}
</p>
<p style={{ fontSize: 14, opacity: 0.7 }}></p>
<button onClick={onBack}>{t('common.back')}</button>
</div>
);
}
return ( return (
<div className="setup-panel"> <div className="setup-panel">
<h2>{t('menu.online_game')}</h2> <h2>{t('menu.online_game')}</h2>
@@ -35,13 +79,13 @@ export default function OnlineSetup({ onBack, onStart }: Props) {
{t('settings.board_size')}: {t('settings.board_size')}:
<select value={boardSize} onChange={(e) => setBoardSize(Number(e.target.value))}> <select value={boardSize} onChange={(e) => setBoardSize(Number(e.target.value))}>
{Array.from({ length: MAX_BOARD_SIZE - MIN_BOARD_SIZE + 1 }, (_, i) => MIN_BOARD_SIZE + i).map((s) => ( {Array.from({ length: MAX_BOARD_SIZE - MIN_BOARD_SIZE + 1 }, (_, i) => MIN_BOARD_SIZE + i).map((s) => (
<option key={s} value={s}>{s}&times;{s}</option> <option key={s} value={s}>{s}×{s}</option>
))} ))}
</select> </select>
</label> </label>
<button onClick={handleHost}>{t('menu.host_room')}</button> <button onClick={handleHost}>{t('menu.host_room')}</button>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}> <div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<input value={ip} onChange={(e) => setIp(e.target.value)} placeholder={t('menu.ip_placeholder') as string} /> <input value={ip} onChange={(e) => setIp(e.target.value)} placeholder={t('menu.ip_placeholder')} />
<button onClick={handleJoin} disabled={!ip}>{t('menu.join_room')}</button> <button onClick={handleJoin} disabled={!ip}>{t('menu.join_room')}</button>
</div> </div>
<button onClick={onBack} style={{ marginTop: 12 }}>{t('common.back')}</button> <button onClick={onBack} style={{ marginTop: 12 }}>{t('common.back')}</button>
+3 -1
View File
@@ -33,7 +33,9 @@
"new_game": "New Game", "new_game": "New Game",
"waiting_opponent": "Waiting for Opponent...", "waiting_opponent": "Waiting for Opponent...",
"your_turn": "Your Turn", "your_turn": "Your Turn",
"opponent_turn": "Opponent's Turn" "opponent_turn": "Opponent's Turn",
"opponent_connected": "Opponent Connected",
"opponent_disconnected": "Opponent Disconnected"
}, },
"replay": { "replay": {
"play": "Play", "play": "Play",
+3 -1
View File
@@ -33,7 +33,9 @@
"new_game": "新游戏", "new_game": "新游戏",
"waiting_opponent": "等待对手加入...", "waiting_opponent": "等待对手加入...",
"your_turn": "你的回合", "your_turn": "你的回合",
"opponent_turn": "对手回合" "opponent_turn": "对手回合",
"opponent_connected": "对手已连接",
"opponent_disconnected": "对手已断开"
}, },
"replay": { "replay": {
"play": "播放", "play": "播放",