实现游戏大厅与房间模块:玩法插件化、匹配与房间生命周期管理
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user