312 lines
12 KiB
HTML
312 lines
12 KiB
HTML
<!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; }
|
||
.row { display: flex; flex-wrap: wrap; gap: 12px; }
|
||
.row > .panel { flex: 1 1 260px; }
|
||
button { margin-right: 6px; padding: 4px 10px; cursor: pointer; }
|
||
input { width: 60px; }
|
||
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.snake2 { background: #ff9800; }
|
||
.cell.snake3 { background: #9c27b0; }
|
||
.cell.head0 { background: #2e7d32; }
|
||
.cell.head1 { background: #1565c0; }
|
||
.cell.head2 { background: #e65100; }
|
||
.cell.head3 { background: #6a1b9a; }
|
||
.cell.food { background: #f44336; border-radius: 50%; }
|
||
.cell.stone1 { color: #333; }
|
||
.cell.stone2 { color: #888; }
|
||
#log { width: 100%; height: 240px; font-family: Consolas, monospace; font-size: 12px; }
|
||
.hint { color: #888; font-size: 12px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>WebSocket 联调测试台</h1>
|
||
<p class="hint">
|
||
通用流程:选玩法(贪吃蛇再选人数)→ 各玩家「取 token」→「连接」→「匹配」→「准备」。<br>
|
||
<b>五子棋</b>:点棋盘空格落子。<b>贪吃蛇</b>:玩家1 方向键 ↑→↓←,玩家2 W/D/S/A,玩家3 I/L/K/J,玩家4 T/G/H/F(先点一下本页获取键盘焦点)。
|
||
</p>
|
||
|
||
<div class="panel">
|
||
<h2>玩法与人数</h2>
|
||
玩法
|
||
<select id="game" onchange="onGameChange()">
|
||
<option value="gobang">五子棋(15×15,2 人,回合制)</option>
|
||
<option value="snake">贪吃蛇(21×21,实时 200ms/步)</option>
|
||
</select>
|
||
<span id="seatsWrap" style="display:none">
|
||
人数
|
||
<select id="seats">
|
||
<option value="2">2 人场</option>
|
||
<option value="3">3 人场</option>
|
||
<option value="4">4 人场</option>
|
||
</select>
|
||
</span>
|
||
<span class="status" id="turn">等待开局</span>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="panel"><h2>玩家 1</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>
|
||
<div class="status" id="stA">未连接</div>
|
||
</div>
|
||
<div class="panel"><h2>玩家 2</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>
|
||
<div class="status" id="stB">未连接</div>
|
||
</div>
|
||
<div class="panel" id="panelC" style="display:none"><h2>玩家 3</h2>
|
||
用户ID <input id="uidC" value="3">
|
||
<button onclick="getToken('C')">取 token</button>
|
||
<button onclick="connect('C')">连接</button>
|
||
<button onclick="match('C')">匹配</button>
|
||
<button onclick="ready('C')">准备</button>
|
||
<button onclick="leave('C')">离开</button>
|
||
<div class="status" id="stC">未连接</div>
|
||
</div>
|
||
<div class="panel" id="panelD" style="display:none"><h2>玩家 4</h2>
|
||
用户ID <input id="uidD" value="4">
|
||
<button onclick="getToken('D')">取 token</button>
|
||
<button onclick="connect('D')">连接</button>
|
||
<button onclick="match('D')">匹配</button>
|
||
<button onclick="ready('D')">准备</button>
|
||
<button onclick="leave('D')">离开</button>
|
||
<div class="status" id="stD">未连接</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<h2>棋盘</h2>
|
||
<div class="grid" id="board"></div>
|
||
<div id="scores" class="hint"></div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<h2>消息日志(贪吃蛇的 tick 很密集,默认不记录)</h2>
|
||
<label class="hint"><input type="checkbox" id="logTick"> 记录 tick 事件</label>
|
||
<textarea id="log" readonly></textarea>
|
||
</div>
|
||
|
||
<script>
|
||
const KEYS = ['A', 'B', 'C', 'D'];
|
||
const S = {};
|
||
KEYS.forEach(k => { S[k] = { uid: 0, token: '', ws: null, seq: 1, room: '', size: 0, players: [], turn: 0, board: [] }; });
|
||
let snakeData = null;
|
||
|
||
function gameId() { return document.getElementById('game').value; }
|
||
function seats() { return parseInt(document.getElementById('seats').value, 10); }
|
||
|
||
function log(msg) {
|
||
const el = document.getElementById('log');
|
||
el.value += new Date().toLocaleTimeString() + ' ' + msg + '\n';
|
||
el.scrollTop = el.scrollHeight;
|
||
}
|
||
|
||
function onGameChange() {
|
||
const isSnake = gameId() === 'snake';
|
||
document.getElementById('seatsWrap').style.display = isSnake ? 'inline' : 'none';
|
||
const n = isSnake ? seats() : 2;
|
||
document.getElementById('panelC').style.display = n >= 3 ? '' : 'none';
|
||
document.getElementById('panelD').style.display = n >= 4 ? '' : 'none';
|
||
resetBoard();
|
||
}
|
||
document.getElementById('seats').addEventListener('change', onGameChange);
|
||
|
||
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) {
|
||
const p = gameId() === 'snake' ? { game_id: gameId(), seats: seats() } : { game_id: gameId() };
|
||
send(k, 'hall.match', p);
|
||
}
|
||
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 === 'hall.matched') {
|
||
log(k + ' 匹配成功:' + 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 (isTick && 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() {
|
||
KEYS.forEach(k => { S[k].board = []; });
|
||
snakeData = null;
|
||
document.getElementById('scores').textContent = '';
|
||
render();
|
||
}
|
||
|
||
function render() {
|
||
const grid = document.getElementById('board');
|
||
grid.innerHTML = '';
|
||
if (gameId() === 'snake') { renderSnake(); return; }
|
||
|
||
const ref = KEYS.map(k => S[k]).find(s => s.board.length) || S.A;
|
||
const board = ref.board || [];
|
||
if (!board.length) { document.getElementById('turn').textContent = '等待开局'; return; }
|
||
|
||
const n = ref.size || Math.round(Math.sqrt(board.length));
|
||
const cell = 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' + (v === 1 ? ' stone1' : v === 2 ? ' stone2' : '');
|
||
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);
|
||
}
|
||
|
||
document.getElementById('scores').textContent = snakeData.snakes
|
||
.map(sn => 'uid=' + sn.user_id + ' 分' + sn.score + (sn.alive ? '' : '(阵亡)')).join(' | ');
|
||
document.getElementById('turn').textContent = '贪吃蛇进行中:1=方向键 2=WASD 3=IJKL 4=TFGH';
|
||
}
|
||
|
||
function place(index) {
|
||
if (gameId() === 'snake') return;
|
||
const ref = KEYS.map(k => S[k]).find(s => s.board.length) || S.A;
|
||
const n = ref.size || Math.round(Math.sqrt(ref.board.length));
|
||
const turnUid = (ref.players || [])[ref.turn || 0];
|
||
const who = KEYS.find(k => S[k].uid === turnUid);
|
||
if (!who) { log('现在不是已连接玩家的回合,或尚未开局'); return; }
|
||
send(who, 'room.action', { action: 'place', payload: { row: Math.floor(index / n), col: index % n } });
|
||
}
|
||
|
||
// 四个玩家各自的键位
|
||
const KEYMAP = {
|
||
A: { ArrowUp: 'up', ArrowRight: 'right', ArrowDown: 'down', ArrowLeft: 'left' },
|
||
B: { w: 'up', d: 'right', s: 'down', a: 'left' },
|
||
C: { i: 'up', l: 'right', k: 'down', j: 'left' },
|
||
D: { t: 'up', g: 'right', h: 'down', f: 'left' },
|
||
};
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
if (gameId() !== 'snake') return;
|
||
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
||
for (const k of KEYS) {
|
||
if (KEYMAP[k][key]) {
|
||
send(k, 'room.action', { action: 'turn', payload: { dir: KEYMAP[k][key] } });
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
}
|
||
});
|
||
|
||
onGameChange();
|
||
</script>
|
||
</body>
|
||
</html>
|