mirror of
https://github.com/LHY0125/Gobang-Game.git
synced 2026-06-28 16:35:55 +08:00
feat: Online 模式前端 UI — 房间管理/连接状态/remote-move/禁悔棋
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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'}>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}×{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
@@ -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
@@ -33,7 +33,9 @@
|
||||
"new_game": "新游戏",
|
||||
"waiting_opponent": "等待对手加入...",
|
||||
"your_turn": "你的回合",
|
||||
"opponent_turn": "对手回合"
|
||||
"opponent_turn": "对手回合",
|
||||
"opponent_connected": "对手已连接",
|
||||
"opponent_disconnected": "对手已断开"
|
||||
},
|
||||
"replay": {
|
||||
"play": "播放",
|
||||
|
||||
Reference in New Issue
Block a user