支持多人数对局与 AI 补位:玩法声明人数范围、匹配分队列与超时补 AI、房间玩家名册
This commit is contained in:
+121
-67
@@ -8,59 +8,91 @@
|
||||
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: 70px; }
|
||||
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.food { background: #ff5722; border-radius: 50%; }
|
||||
#log { width: 100%; height: 260px; font-family: Consolas, monospace; font-size: 12px; }
|
||||
.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">
|
||||
通用流程:① 选玩法 → ② A、B 各「取 token」→ ③ 各「连接」→ ④ 各「匹配」→ ⑤ 各「准备」。<br>
|
||||
五子棋 / 井字棋:在棋盘上点空格落子。贪吃蛇:<b>玩家 A 用方向键 ↑→↓←,玩家 B 用 W / D / S / A</b>(点一下本页获得键盘焦点)。
|
||||
通用流程:选玩法(贪吃蛇再选人数)→ 各玩家「取 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="resetBoard()">
|
||||
<option value="gobang">五子棋(15×15,连五胜)</option>
|
||||
<option value="snake">双人贪吃蛇(21×21,实时推进)</option>
|
||||
<option value="tictactoe">井字棋(3×3,示例玩法)</option>
|
||||
<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="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 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">
|
||||
@@ -70,25 +102,36 @@
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>消息日志(贪吃蛇的 tick 事件很密集,默认只记非 tick 消息)</h2>
|
||||
<h2>消息日志(贪吃蛇的 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; // 贪吃蛇最新画面数据
|
||||
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);
|
||||
@@ -117,7 +160,11 @@ function send(k, type, payload) {
|
||||
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 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', {}); }
|
||||
|
||||
@@ -127,6 +174,10 @@ function handle(k, env) {
|
||||
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;
|
||||
@@ -145,7 +196,7 @@ function handle(k, env) {
|
||||
}
|
||||
}
|
||||
|
||||
if (env.type === 'room.event' && env.payload && env.payload.event === 'tick' && gameId() === 'snake') {
|
||||
if (isTick && gameId() === 'snake') {
|
||||
const d = env.payload.payload || {};
|
||||
snakeData = { size: d.size, snakes: d.snakes, foods: d.foods || [] };
|
||||
renderSnake();
|
||||
@@ -159,7 +210,8 @@ function handle(k, env) {
|
||||
}
|
||||
|
||||
function resetBoard() {
|
||||
S.A.board = []; S.B.board = []; snakeData = null;
|
||||
KEYS.forEach(k => { S[k].board = []; });
|
||||
snakeData = null;
|
||||
document.getElementById('scores').textContent = '';
|
||||
render();
|
||||
}
|
||||
@@ -169,18 +221,17 @@ function render() {
|
||||
grid.innerHTML = '';
|
||||
if (gameId() === 'snake') { renderSnake(); return; }
|
||||
|
||||
const ref = S.A.board.length ? S.A : S.B;
|
||||
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 = n === 3 ? 60 : 26;
|
||||
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';
|
||||
el.className = 'cell' + (v === 1 ? ' stone1' : v === 2 ? ' stone2' : '');
|
||||
el.style.width = cell + 'px';
|
||||
el.style.height = cell + 'px';
|
||||
el.style.lineHeight = cell + 'px';
|
||||
@@ -188,7 +239,6 @@ function render() {
|
||||
el.onclick = () => place(i);
|
||||
grid.appendChild(el);
|
||||
}
|
||||
|
||||
const turnUid = (ref.players || [])[ref.turn || 0];
|
||||
document.getElementById('turn').textContent = turnUid ? '轮到 uid=' + turnUid : '等待开局';
|
||||
}
|
||||
@@ -202,7 +252,7 @@ function renderSnake() {
|
||||
const cell = 20;
|
||||
grid.style.gridTemplateColumns = 'repeat(' + n + ', ' + cell + 'px)';
|
||||
|
||||
const owner = {}; // 坐标 -> 蛇序号
|
||||
const owner = {};
|
||||
snakeData.snakes.forEach((sn, si) => {
|
||||
sn.body.forEach((b, bi) => { owner[b] = { si: si, head: bi === 0 }; });
|
||||
});
|
||||
@@ -220,38 +270,42 @@ function renderSnake() {
|
||||
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';
|
||||
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 = S.A.board.length ? S.A : S.B;
|
||||
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 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 });
|
||||
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 } });
|
||||
}
|
||||
|
||||
function turn(k, dir) {
|
||||
if (gameId() !== 'snake') return;
|
||||
send(k, 'room.action', { action: 'turn', payload: { dir: dir } });
|
||||
}
|
||||
// 四个玩家各自的键位
|
||||
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) => {
|
||||
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(); }
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user