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 { listen } from '@tauri-apps/api/event';
import { useGameStore, buildReplayBoard } from '../../store/gameStore';
import {
computeBoardDimensions,
@@ -59,6 +60,20 @@ export default function BoardCanvas() {
return () => window.removeEventListener('resize', handleResize);
}, [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(
(e: React.MouseEvent<HTMLCanvasElement>) => {
if (status !== 'playing') return;
+2 -1
View File
@@ -10,6 +10,7 @@ export default function GameControls({ onBackToMenu }: Props) {
const { t } = useTranslation();
const undo = useGameStore((s) => s.undo);
const status = useGameStore((s) => s.status);
const mode = useGameStore((s) => s.mode);
const refreshBoard = useGameStore((s) => s.refreshBoard);
const handleUndo = () => {
@@ -38,7 +39,7 @@ export default function GameControls({ onBackToMenu }: Props) {
return (
<div className="game-controls">
<button onClick={handleUndo} disabled={status === 'game_over'}>
<button onClick={handleUndo} disabled={status === 'game_over' || mode === 'Online'}>
{t('game.undo')}
</button>
<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 GameInfo from './GameInfo';
import GameControls from './GameControls';
@@ -8,8 +12,33 @@ interface 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 (
<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 />
<div className="board-container">
<BoardCanvas />
+1 -7
View File
@@ -27,13 +27,7 @@ export default function MainMenu({ onGameStart, onReplayStart }: Props) {
<div className="menu-buttons">
<button onClick={() => setView('local')}>{t('menu.local_game')}</button>
<button onClick={() => setView('ai')}>{t('menu.ai_game')}</button>
<button
onClick={() => setView('online')}
disabled
title={t('menu.online_game_disabled')}
>
{t('menu.online_game')} ()
</button>
<button onClick={() => setView('online')}>{t('menu.online_game')}</button>
<button onClick={() => setView('replay')}>{t('menu.load_replay')}</button>
</div>
</div>
+57 -13
View File
@@ -1,6 +1,8 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
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 type { GameConfig } from '../../core/types';
@@ -9,24 +11,66 @@ interface Props { onBack: () => void; onStart: () => void; }
export default function OnlineSetup({ onBack, onStart }: Props) {
const { t } = useTranslation();
const startGame = useGameStore((s) => s.startGame);
const [ip, setIp] = useState('');
const [boardSize, setBoardSize] = useState(15);
const baseConfig: GameConfig = {
boardSize, useForbiddenRules: true, useTimer: false,
timeLimitSecs: 60, aiDifficulty: 3, playerColor: 'Black', isServer: false,
remoteAddress: '',
};
const [ip, setIp] = useState('');
const [myAddress, setMyAddress] = useState('');
const [isHosting, setIsHosting] = useState(false);
const handleHost = async () => {
await startGame('Online', { ...baseConfig, isServer: true });
try {
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 () => {
await startGame('Online', { ...baseConfig, remoteAddress: ip });
onStart();
try {
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 (
<div className="setup-panel">
@@ -35,13 +79,13 @@ export default function OnlineSetup({ onBack, onStart }: Props) {
{t('settings.board_size')}:
<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) => (
<option key={s} value={s}>{s}&times;{s}</option>
<option key={s} value={s}>{s}×{s}</option>
))}
</select>
</label>
<button onClick={handleHost}>{t('menu.host_room')}</button>
<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>
</div>
<button onClick={onBack} style={{ marginTop: 12 }}>{t('common.back')}</button>
+3 -1
View File
@@ -33,7 +33,9 @@
"new_game": "New Game",
"waiting_opponent": "Waiting for Opponent...",
"your_turn": "Your Turn",
"opponent_turn": "Opponent's Turn"
"opponent_turn": "Opponent's Turn",
"opponent_connected": "Opponent Connected",
"opponent_disconnected": "Opponent Disconnected"
},
"replay": {
"play": "Play",
+3 -1
View File
@@ -33,7 +33,9 @@
"new_game": "新游戏",
"waiting_opponent": "等待对手加入...",
"your_turn": "你的回合",
"opponent_turn": "对手回合"
"opponent_turn": "对手回合",
"opponent_connected": "对手已连接",
"opponent_disconnected": "对手已断开"
},
"replay": {
"play": "播放",