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 { 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;
|
||||||
|
|||||||
@@ -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'}>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,24 +11,66 @@ 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 {
|
||||||
|
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();
|
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">
|
||||||
@@ -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}×{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
@@ -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
@@ -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": "播放",
|
||||||
|
|||||||
Reference in New Issue
Block a user