实现游戏大厅与房间模块:玩法插件化、匹配与房间生命周期管理

This commit is contained in:
2026-09-28 00:10:11 +08:00
parent 99d1827262
commit fb5a730ef3
19 changed files with 2492 additions and 18 deletions
+257
View File
@@ -0,0 +1,257 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>WebSocket 联调测试台</title>
<style>
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; margin: 20px; color: #222; }
h1 { font-size: 20px; }
.panel { border: 1px solid #ddd; border-radius: 6px; padding: 12px; margin-bottom: 12px; }
.panel h2 { font-size: 15px; margin: 0 0 8px; }
button { margin-right: 6px; padding: 4px 10px; cursor: pointer; }
input { width: 70px; }
select { padding: 3px; }
.status { font-weight: bold; margin-left: 8px; }
.grid { display: grid; gap: 1px; margin: 8px 0; width: max-content; background: #eee; padding: 1px; }
.cell { background: #fff; text-align: center; cursor: pointer; user-select: none; }
.cell.snake0 { background: #4caf50; }
.cell.snake1 { background: #2196f3; }
.cell.head0 { background: #2e7d32; }
.cell.head1 { background: #1565c0; }
.cell.food { background: #ff5722; border-radius: 50%; }
#log { width: 100%; height: 260px; font-family: Consolas, monospace; font-size: 12px; }
.hint { color: #888; font-size: 12px; }
</style>
</head>
<body>
<h1>WebSocket 联调测试台</h1>
<p class="hint">
通用流程:① 选玩法 → ② A、B 各「取 token」→ ③ 各「连接」→ ④ 各「匹配」→ ⑤ 各「准备」。<br>
五子棋 / 井字棋:在棋盘上点空格落子。贪吃蛇:<b>玩家 A 用方向键 ↑→↓←,玩家 B 用 W / D / S / A</b>(点一下本页获得键盘焦点)。
</p>
<div class="panel">
<h2>玩法</h2>
选择
<select id="game" onchange="resetBoard()">
<option value="gobang">五子棋(15×15,连五胜)</option>
<option value="snake">双人贪吃蛇(21×21,实时推进)</option>
<option value="tictactoe">井字棋(3×3,示例玩法)</option>
</select>
<span class="status" id="turn">等待开局</span>
</div>
<div class="panel">
<h2>玩家 A</h2>
用户ID <input id="uidA" value="1">
<button onclick="getToken('A')">取 token</button>
<button onclick="connect('A')">连接</button>
<button onclick="match('A')">匹配</button>
<button onclick="ready('A')">准备</button>
<button onclick="leave('A')">离开</button>
<span class="status" id="stA">未连接</span>
</div>
<div class="panel">
<h2>玩家 B</h2>
用户ID <input id="uidB" value="2">
<button onclick="getToken('B')">取 token</button>
<button onclick="connect('B')">连接</button>
<button onclick="match('B')">匹配</button>
<button onclick="ready('B')">准备</button>
<button onclick="leave('B')">离开</button>
<span class="status" id="stB">未连接</span>
</div>
<div class="panel">
<h2>棋盘</h2>
<div class="grid" id="board"></div>
<div id="scores" class="hint"></div>
</div>
<div class="panel">
<h2>消息日志(贪吃蛇的 tick 事件很密集,默认只记非 tick 消息)</h2>
<label class="hint"><input type="checkbox" id="logTick"> 记录 tick 事件</label>
<textarea id="log" readonly></textarea>
</div>
<script>
const S = {
A: { uid: 1, token: '', ws: null, seq: 1, room: '', size: 0, players: [], turn: 0, board: [] },
B: { uid: 2, token: '', ws: null, seq: 1, room: '', size: 0, players: [], turn: 0, board: [] },
};
let snakeData = null; // 贪吃蛇最新画面数据
function gameId() { return document.getElementById('game').value; }
function log(msg) {
const el = document.getElementById('log');
el.value += new Date().toLocaleTimeString() + ' ' + msg + '\n';
el.scrollTop = el.scrollHeight;
}
async function getToken(k) {
const uid = document.getElementById('uid' + k).value;
const res = await fetch('/api/v1/dev/token?user_id=' + uid);
const json = await res.json();
if (json.code !== 0) { log(k + ' 取 token 失败: ' + JSON.stringify(json)); return; }
S[k].uid = parseInt(uid, 10);
S[k].token = json.data.access_token;
log(k + ' 已获取 token(uid=' + S[k].uid + ')');
}
function connect(k) {
if (!S[k].token) { log(k + ' 请先取 token'); return; }
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(scheme + '://' + location.host + '/ws?token=' + S[k].token);
S[k].ws = ws;
ws.onopen = () => { setStatus(k, '已连接'); log(k + ' ws 已连接'); };
ws.onclose = (e) => { setStatus(k, '已断开(' + e.code + ')'); log(k + ' ws 断开: ' + e.code); };
ws.onerror = () => log(k + ' ws 出错(见服务端日志)');
ws.onmessage = (e) => { handle(k, JSON.parse(e.data)); };
}
function setStatus(k, text) { document.getElementById('st' + k).textContent = text; }
function send(k, type, payload) {
if (!S[k].ws || S[k].ws.readyState !== 1) { log(k + ' 未连接'); return; }
S[k].ws.send(JSON.stringify({ type: type, seq: S[k].seq++, payload: payload }));
}
function match(k) { send(k, 'hall.match', { game_id: gameId() }); }
function ready(k) { send(k, 'room.ready', { ready: true }); }
function leave(k) { send(k, 'room.leave', {}); }
function handle(k, env) {
const isTick = env.type === 'room.event' && env.payload && env.payload.event === 'tick';
if (!isTick || document.getElementById('logTick').checked) {
log(k + ' ← ' + env.type + ' ' + (env.payload ? JSON.stringify(env.payload) : ''));
}
if (env.type === 'room.state') {
const p = env.payload || {};
S[k].room = p.room_id || S[k].room;
const st = p.state || {};
if (gameId() === 'snake' && st.snakes) {
snakeData = { size: st.size, snakes: st.snakes, foods: st.foods || [] };
renderSnake();
return;
}
if (st.board) {
S[k].size = st.size || Math.round(Math.sqrt(st.board.length));
S[k].players = st.players || S[k].players;
S[k].turn = st.turn || 0;
S[k].board = st.board;
render();
}
}
if (env.type === 'room.event' && env.payload && env.payload.event === 'tick' && gameId() === 'snake') {
const d = env.payload.payload || {};
snakeData = { size: d.size, snakes: d.snakes, foods: d.foods || [] };
renderSnake();
}
if (env.type === 'room.finished') {
const w = (env.payload || {}).winner;
document.getElementById('turn').textContent = w ? '本局结束,获胜者 uid=' + w : '本局结束:平局';
}
if (env.type === 'room.closed') { log(k + ' 房间已关闭: ' + (env.payload || {}).reason); }
}
function resetBoard() {
S.A.board = []; S.B.board = []; snakeData = null;
document.getElementById('scores').textContent = '';
render();
}
function render() {
const grid = document.getElementById('board');
grid.innerHTML = '';
if (gameId() === 'snake') { renderSnake(); return; }
const ref = S.A.board.length ? S.A : S.B;
const board = ref.board || [];
if (!board.length) { document.getElementById('turn').textContent = '等待开局'; return; }
const n = ref.size || Math.round(Math.sqrt(board.length));
const cell = n === 3 ? 60 : 26;
grid.style.gridTemplateColumns = 'repeat(' + n + ', ' + cell + 'px)';
for (let i = 0; i < board.length; i++) {
const el = document.createElement('div');
const v = board[i] || 0;
el.className = 'cell';
el.style.width = cell + 'px';
el.style.height = cell + 'px';
el.style.lineHeight = cell + 'px';
el.textContent = v === 1 ? '●' : (v === 2 ? '○' : '');
el.onclick = () => place(i);
grid.appendChild(el);
}
const turnUid = (ref.players || [])[ref.turn || 0];
document.getElementById('turn').textContent = turnUid ? '轮到 uid=' + turnUid : '等待开局';
}
function renderSnake() {
const grid = document.getElementById('board');
grid.innerHTML = '';
if (!snakeData) { document.getElementById('turn').textContent = '等待开局'; return; }
const n = snakeData.size;
const cell = 20;
grid.style.gridTemplateColumns = 'repeat(' + n + ', ' + cell + 'px)';
const owner = {}; // 坐标 -> 蛇序号
snakeData.snakes.forEach((sn, si) => {
sn.body.forEach((b, bi) => { owner[b] = { si: si, head: bi === 0 }; });
});
const foods = {};
(snakeData.foods || []).forEach(f => { foods[f] = true; });
for (let i = 0; i < n * n; i++) {
const el = document.createElement('div');
el.style.width = cell + 'px';
el.style.height = cell + 'px';
let cls = 'cell';
if (owner[i]) { cls += owner[i].head ? ' head' + owner[i].si : ' snake' + owner[i].si; }
else if (foods[i]) { cls += ' food'; }
el.className = cls;
grid.appendChild(el);
}
const parts = snakeData.snakes.map(sn => 'uid=' + sn.user_id + ' 分' + sn.score + (sn.alive ? '' : '(已阵亡)'));
document.getElementById('scores').textContent = parts.join(' | ');
document.getElementById('turn').textContent = '贪吃蛇进行中:A 用方向键,B 用 WASD';
}
function place(index) {
if (gameId() === 'snake') return;
const ref = S.A.board.length ? S.A : S.B;
const n = ref.size || Math.round(Math.sqrt(ref.board.length));
const row = Math.floor(index / n);
const col = index % n;
const turnUid = (ref.players || [])[ref.turn || 0];
let who = null;
if (turnUid && S.A.uid === turnUid) who = 'A';
else if (turnUid && S.B.uid === turnUid) who = 'B';
if (!who) { log('现在不是 A/B 的回合,或尚未开局'); return; }
const payload = n === 3 ? { index: index } : { row: row, col: col };
send(who, 'room.action', { action: 'place', payload: payload });
}
function turn(k, dir) {
if (gameId() !== 'snake') return;
send(k, 'room.action', { action: 'turn', payload: { dir: dir } });
}
document.addEventListener('keydown', (e) => {
const arrow = { ArrowUp: 'up', ArrowRight: 'right', ArrowDown: 'down', ArrowLeft: 'left' };
const wasd = { w: 'up', d: 'right', s: 'down', a: 'left' };
if (arrow[e.key]) { turn('A', arrow[e.key]); e.preventDefault(); return; }
const k = (e.key || '').toLowerCase();
if (wasd[k]) { turn('B', wasd[k]); e.preventDefault(); }
});
</script>
</body>
</html>