Replace HTTP API with WebSocket for true real-time bidirectional communication:
Backend Changes:
- Add Ratchet WebSocket library (cboden/ratchet) to composer.json
- New GameWebSocketServer class implementing MessageComponentInterface
- Handles client connections and session management
- Message types: login, game-input, sync-state, ping/pong
- Maintains client connections map and user sessions
- New websocket-server.php startup script
- Listens on port 9001
- Uses Ratchet with HttpServer wrapper
Frontend Changes:
- New game-ws.html with WebSocket implementation
- Replace HTTP requests with WebSocket messages
- Keep HTTP for authentication (login/register/status)
- WebSocket handles all game interactions
- Real-time status display with connection indicator
- Implements reconnection on disconnect
- 30-second heartbeat (ping/pong) to maintain connection
Message Protocol:
Client → Server:
login: { userId, username } - Authenticate and load game
game-input: { input } - Send game command
sync-state: {} - Request full state sync
ping - Heart beat
Server → Client:
welcome - Initial greeting
login-success - Auth successful, game loaded
game-output - Normal command output
battle-start/end - Battle state changes
state-sync - Full state snapshot
error - Error message
pong - Heartbeat response
Port Configuration:
- HTTP API: port 80 (web server)
- WebSocket: port 9001 (Ratchet server)
- Both services run independently
Usage:
1. Start web server: php -S 0.0.0.0:8080 web/server.php
2. Start WebSocket server: php websocket-server.php
3. Open browser: http://localhost:8080/game-ws.html
Benefits:
✓ True bidirectional real-time communication
✓ Can handle battle interactions in-game
✓ Better for multiplayer scenarios
✓ Persistent connections reduce latency
✓ Future support for spectating, PvP
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
677 lines
21 KiB
HTML
677 lines
21 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>凡人修仙传 - WebSocket版</title>
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
background: #1a1a2e;
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-family: 'Consolas', 'Monaco', monospace;
|
|
}
|
|
|
|
.container {
|
|
width: 100%;
|
|
max-width: 900px;
|
|
padding: 20px;
|
|
}
|
|
|
|
.header {
|
|
text-align: center;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.header h1 {
|
|
color: #eee;
|
|
font-size: 24px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.header p {
|
|
color: #888;
|
|
font-size: 14px;
|
|
}
|
|
|
|
/* 登录界面 */
|
|
#auth-panel {
|
|
background: #16213e;
|
|
border-radius: 10px;
|
|
padding: 30px;
|
|
max-width: 400px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
#auth-panel h2 {
|
|
color: #eee;
|
|
text-align: center;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
color: #aaa;
|
|
margin-bottom: 5px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.form-group input {
|
|
width: 100%;
|
|
padding: 10px 15px;
|
|
border: 1px solid #0f3460;
|
|
border-radius: 5px;
|
|
background: #1a1a2e;
|
|
color: #eee;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.form-group input:focus {
|
|
outline: none;
|
|
border-color: #e94560;
|
|
}
|
|
|
|
.btn-group {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.btn {
|
|
flex: 1;
|
|
padding: 12px;
|
|
border: none;
|
|
border-radius: 5px;
|
|
font-size: 16px;
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: #e94560;
|
|
color: white;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background: #ff6b6b;
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: #0f3460;
|
|
color: white;
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: #16213e;
|
|
}
|
|
|
|
.message {
|
|
padding: 10px;
|
|
border-radius: 5px;
|
|
margin-bottom: 15px;
|
|
text-align: center;
|
|
display: none;
|
|
}
|
|
|
|
.message.error {
|
|
background: rgba(233, 69, 96, 0.2);
|
|
color: #e94560;
|
|
display: block;
|
|
}
|
|
|
|
.message.success {
|
|
background: rgba(46, 213, 115, 0.2);
|
|
color: #2ed573;
|
|
display: block;
|
|
}
|
|
|
|
/* 游戏终端 */
|
|
#game-panel {
|
|
display: none;
|
|
}
|
|
|
|
.terminal-header {
|
|
background: #16213e;
|
|
padding: 10px 15px;
|
|
border-radius: 10px 10px 0 0;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.terminal-header .user-info {
|
|
color: #2ed573;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.terminal-header .status {
|
|
font-size: 12px;
|
|
color: #888;
|
|
}
|
|
|
|
.terminal-header .logout-btn {
|
|
background: #e94560;
|
|
color: white;
|
|
border: none;
|
|
padding: 5px 15px;
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.terminal-header .logout-btn:hover {
|
|
background: #ff6b6b;
|
|
}
|
|
|
|
#terminal {
|
|
background: #0d0d0d;
|
|
border-radius: 0 0 10px 10px;
|
|
padding: 10px;
|
|
}
|
|
|
|
.input-area {
|
|
background: #16213e;
|
|
padding: 10px;
|
|
border-radius: 0 0 10px 10px;
|
|
display: flex;
|
|
gap: 10px;
|
|
}
|
|
|
|
.input-area input {
|
|
flex: 1;
|
|
padding: 10px;
|
|
border: 1px solid #0f3460;
|
|
border-radius: 5px;
|
|
background: #1a1a2e;
|
|
color: #eee;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.input-area input:focus {
|
|
outline: none;
|
|
border-color: #e94560;
|
|
}
|
|
|
|
.input-area button {
|
|
padding: 10px 20px;
|
|
background: #e94560;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.quick-buttons {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 5px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.quick-btn {
|
|
padding: 8px 15px;
|
|
background: #0f3460;
|
|
color: #eee;
|
|
border: none;
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.quick-btn:hover {
|
|
background: #16213e;
|
|
}
|
|
|
|
.ws-status {
|
|
display: inline-block;
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
margin-left: 5px;
|
|
}
|
|
|
|
.ws-connected {
|
|
background: #2ed573;
|
|
}
|
|
|
|
.ws-disconnected {
|
|
background: #e94560;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>凡人修仙传 - WebSocket版</h1>
|
|
<p>Web Terminal Edition (Real-time)</p>
|
|
</div>
|
|
|
|
<!-- 登录/注册面板 -->
|
|
<div id="auth-panel">
|
|
<h2>欢迎</h2>
|
|
<div id="auth-message" class="message"></div>
|
|
<div class="form-group">
|
|
<label>用户名</label>
|
|
<input type="text" id="username" placeholder="请输入用户名">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>密码</label>
|
|
<input type="password" id="password" placeholder="请输入密码">
|
|
</div>
|
|
<div class="btn-group">
|
|
<button class="btn btn-primary" onclick="login()">登录</button>
|
|
<button class="btn btn-secondary" onclick="register()">注册</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 游戏面板 -->
|
|
<div id="game-panel">
|
|
<div class="terminal-header">
|
|
<span class="user-info">玩家: <span id="player-name">-</span></span>
|
|
<span class="status">
|
|
状态: <span id="game-status">连接中</span>
|
|
<span class="ws-status ws-disconnected" id="ws-indicator"></span>
|
|
</span>
|
|
<button class="logout-btn" onclick="logout()">退出登录</button>
|
|
</div>
|
|
<div id="terminal"></div>
|
|
<div class="input-area">
|
|
<input type="text" id="game-input" placeholder="输入命令..." onkeypress="handleKeyPress(event)">
|
|
<button onclick="sendInput()">发送</button>
|
|
</div>
|
|
<div class="quick-buttons">
|
|
<button class="quick-btn" onclick="quickSend('1')">1.战斗</button>
|
|
<button class="quick-btn" onclick="quickSend('2')">2.属性</button>
|
|
<button class="quick-btn" onclick="quickSend('3')">3.背包</button>
|
|
<button class="quick-btn" onclick="quickSend('4')">4.故人</button>
|
|
<button class="quick-btn" onclick="quickSend('5')">5.同伴</button>
|
|
<button class="quick-btn" onclick="quickSend('6')">6.天赋</button>
|
|
<button class="quick-btn" onclick="quickSend('7')">7.地图</button>
|
|
<button class="quick-btn" onclick="quickSend('8')">8.休息</button>
|
|
<button class="quick-btn" onclick="quickSend('0')">0.返回</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
|
|
<script>
|
|
let terminal = null;
|
|
let fitAddon = null;
|
|
let ws = null;
|
|
let isLoggedIn = false;
|
|
let currentUserId = null;
|
|
let currentUsername = null;
|
|
|
|
// 初始化终端
|
|
function initTerminal() {
|
|
if (terminal) return; // 已初始化
|
|
|
|
terminal = new Terminal({
|
|
cursorBlink: true,
|
|
fontSize: 16,
|
|
fontFamily: 'Consolas, Monaco, monospace',
|
|
theme: {
|
|
background: '#0d0d0d',
|
|
foreground: '#eee',
|
|
cursor: '#e94560',
|
|
cursorAccent: '#0d0d0d',
|
|
selection: 'rgba(233, 69, 96, 0.3)',
|
|
black: '#1a1a2e',
|
|
red: '#e94560',
|
|
green: '#2ed573',
|
|
yellow: '#ffa502',
|
|
blue: '#70a1ff',
|
|
magenta: '#ff6b81',
|
|
cyan: '#1e90ff',
|
|
white: '#eee',
|
|
brightBlack: '#666',
|
|
brightRed: '#ff6b6b',
|
|
brightGreen: '#7bed9f',
|
|
brightYellow: '#ffda79',
|
|
brightBlue: '#a4b0be',
|
|
brightMagenta: '#ff7f9f',
|
|
brightCyan: '#34ace0',
|
|
brightWhite: '#fff'
|
|
},
|
|
rows: 24,
|
|
cols: 80
|
|
});
|
|
|
|
fitAddon = new FitAddon.FitAddon();
|
|
terminal.loadAddon(fitAddon);
|
|
terminal.open(document.getElementById('terminal'));
|
|
fitAddon.fit();
|
|
|
|
window.addEventListener('resize', () => {
|
|
fitAddon.fit();
|
|
});
|
|
}
|
|
|
|
// WebSocket 连接
|
|
function connectWebSocket() {
|
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsUrl = protocol + '//' + location.hostname + ':9001';
|
|
|
|
ws = new WebSocket(wsUrl);
|
|
|
|
ws.onopen = () => {
|
|
console.log('[WebSocket] 已连接');
|
|
updateWSStatus(true);
|
|
terminal.writeln('\x1b[32m[✓] WebSocket 连接成功\x1b[0m');
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
handleMessage(data);
|
|
} catch (e) {
|
|
console.error('消息解析错误:', e);
|
|
}
|
|
};
|
|
|
|
ws.onerror = (error) => {
|
|
console.error('[WebSocket] 错误:', error);
|
|
updateWSStatus(false);
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
console.log('[WebSocket] 已断开');
|
|
updateWSStatus(false);
|
|
if (isLoggedIn) {
|
|
terminal.writeln('\x1b[31m[✗] WebSocket 连接已断开,尝试重新连接...\x1b[0m');
|
|
setTimeout(connectWebSocket, 3000);
|
|
}
|
|
};
|
|
}
|
|
|
|
// 处理服务器消息
|
|
function handleMessage(data) {
|
|
console.log('[收到] ', data.type, data);
|
|
|
|
switch (data.type) {
|
|
case 'welcome':
|
|
terminal.writeln('\x1b[33m' + data.message + '\x1b[0m');
|
|
break;
|
|
|
|
case 'login-success':
|
|
isLoggedIn = true;
|
|
currentUserId = data.userId;
|
|
currentUsername = data.username;
|
|
document.getElementById('player-name').textContent = data.username;
|
|
document.getElementById('game-status').textContent = 'MENU';
|
|
terminal.clear();
|
|
displayOutput(data.output);
|
|
document.getElementById('game-input').focus();
|
|
break;
|
|
|
|
case 'game-output':
|
|
terminal.clear();
|
|
displayOutput(data.output);
|
|
updateGameStatus(data.stateName);
|
|
document.getElementById('game-input').focus();
|
|
break;
|
|
|
|
case 'battle-start':
|
|
terminal.clear();
|
|
terminal.writeln('\x1b[33m' + data.message + '\x1b[0m');
|
|
document.getElementById('game-status').textContent = 'BATTLE';
|
|
break;
|
|
|
|
case 'battle-end':
|
|
terminal.writeln('\x1b[32m[战斗结束]\x1b[0m');
|
|
updateGameStatus(data.stateName);
|
|
document.getElementById('game-input').focus();
|
|
break;
|
|
|
|
case 'state-sync':
|
|
displayOutput(data.output);
|
|
updateGameStatus(data.stateName);
|
|
break;
|
|
|
|
case 'error':
|
|
terminal.writeln('\x1b[31m[错误] ' + data.message + '\x1b[0m');
|
|
break;
|
|
|
|
case 'pong':
|
|
// 心跳响应
|
|
break;
|
|
|
|
default:
|
|
console.warn('未知消息类型:', data.type);
|
|
}
|
|
}
|
|
|
|
// 发送消息给服务器
|
|
function sendMessage(data) {
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
terminal.writeln('\x1b[31m[错误] WebSocket 未连接\x1b[0m');
|
|
return false;
|
|
}
|
|
ws.send(JSON.stringify(data));
|
|
return true;
|
|
}
|
|
|
|
// 显示消息
|
|
function showMessage(message, type = 'error') {
|
|
const el = document.getElementById('auth-message');
|
|
el.textContent = message;
|
|
el.className = 'message ' + type;
|
|
}
|
|
|
|
// 登录
|
|
async function login() {
|
|
const username = document.getElementById('username').value.trim();
|
|
const password = document.getElementById('password').value;
|
|
|
|
if (!username || !password) {
|
|
showMessage('请输入用户名和密码');
|
|
return;
|
|
}
|
|
|
|
// 这里仍然使用HTTP登录进行认证
|
|
try {
|
|
const response = await fetch('/api/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, password }),
|
|
credentials: 'include'
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
showMessage('登录成功!连接游戏服务器...', 'success');
|
|
|
|
setTimeout(() => {
|
|
// 获取userId用于WebSocket登录
|
|
fetch('/api/status', { credentials: 'include' })
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.success && data.loggedIn) {
|
|
enterGame(username, data.userId);
|
|
}
|
|
});
|
|
}, 500);
|
|
} else {
|
|
showMessage(result.message || '登录失败');
|
|
}
|
|
} catch (e) {
|
|
showMessage('网络错误: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// 注册
|
|
async function register() {
|
|
const username = document.getElementById('username').value.trim();
|
|
const password = document.getElementById('password').value;
|
|
|
|
if (!username || !password) {
|
|
showMessage('请输入用户名和密码');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, password }),
|
|
credentials: 'include'
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
showMessage('注册成功!自动登录中...', 'success');
|
|
setTimeout(() => {
|
|
fetch('/api/status', { credentials: 'include' })
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.success && data.loggedIn) {
|
|
enterGame(username, data.userId);
|
|
}
|
|
});
|
|
}, 500);
|
|
} else {
|
|
showMessage(result.message || '注册失败');
|
|
}
|
|
} catch (e) {
|
|
showMessage('网络错误: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// 进入游戏
|
|
function enterGame(username, userId) {
|
|
isLoggedIn = true;
|
|
document.getElementById('auth-panel').style.display = 'none';
|
|
document.getElementById('game-panel').style.display = 'block';
|
|
document.getElementById('player-name').textContent = username;
|
|
|
|
if (!terminal) {
|
|
initTerminal();
|
|
}
|
|
|
|
terminal.clear();
|
|
terminal.writeln('\x1b[33m正在连接游戏服务器...\x1b[0m');
|
|
|
|
// 连接WebSocket
|
|
connectWebSocket();
|
|
|
|
// WebSocket连接后登录
|
|
setTimeout(() => {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
sendMessage({
|
|
type: 'login',
|
|
userId: userId,
|
|
username: username
|
|
});
|
|
}
|
|
}, 500);
|
|
}
|
|
|
|
// 退出登录
|
|
async function logout() {
|
|
isLoggedIn = false;
|
|
if (ws) {
|
|
ws.close();
|
|
}
|
|
await fetch('/api/logout', { credentials: 'include' });
|
|
document.getElementById('auth-panel').style.display = 'block';
|
|
document.getElementById('game-panel').style.display = 'none';
|
|
document.getElementById('password').value = '';
|
|
showMessage('', '');
|
|
}
|
|
|
|
// 发送输入
|
|
function sendInput() {
|
|
const inputEl = document.getElementById('game-input');
|
|
const input = inputEl.value.trim();
|
|
inputEl.value = '';
|
|
|
|
if (!input) return;
|
|
|
|
terminal.writeln('\x1b[36m> ' + input + '\x1b[0m');
|
|
terminal.writeln('');
|
|
|
|
if (sendMessage({
|
|
type: 'game-input',
|
|
input: input
|
|
})) {
|
|
console.log('[发送] 游戏输入:', input);
|
|
}
|
|
}
|
|
|
|
// 快捷按钮发送
|
|
function quickSend(value) {
|
|
document.getElementById('game-input').value = value;
|
|
sendInput();
|
|
}
|
|
|
|
// 按键处理
|
|
function handleKeyPress(event) {
|
|
if (event.key === 'Enter') {
|
|
sendInput();
|
|
}
|
|
}
|
|
|
|
// 显示输出
|
|
function displayOutput(output) {
|
|
if (!output) return;
|
|
const lines = output.split('\n');
|
|
lines.forEach(line => {
|
|
if (line.trim()) {
|
|
terminal.writeln(line);
|
|
}
|
|
});
|
|
}
|
|
|
|
// 更新游戏状态显示
|
|
function updateGameStatus(stateName) {
|
|
document.getElementById('game-status').textContent = stateName || '未知';
|
|
}
|
|
|
|
// 更新WebSocket状态指示器
|
|
function updateWSStatus(connected) {
|
|
const indicator = document.getElementById('ws-indicator');
|
|
if (connected) {
|
|
indicator.className = 'ws-status ws-connected';
|
|
} else {
|
|
indicator.className = 'ws-status ws-disconnected';
|
|
}
|
|
}
|
|
|
|
// 心跳保活
|
|
setInterval(() => {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
sendMessage({ type: 'ping' });
|
|
}
|
|
}, 30000);
|
|
|
|
// 页面加载完成
|
|
window.onload = async () => {
|
|
const result = await fetch('/api/status', { credentials: 'include' })
|
|
.then(r => r.json())
|
|
.catch(() => ({ success: false }));
|
|
|
|
if (result.success && result.loggedIn) {
|
|
enterGame(result.username, result.userId);
|
|
}
|
|
};
|
|
</script>
|
|
</body>
|
|
</html>
|