mirror of
https://github.com/PengYiZhen/xymj-colyseus-server.git
synced 2026-05-06 22:10:13 +08:00
全域匹配机制更新
This commit is contained in:
@@ -37,7 +37,7 @@ DB_USERNAME=root
|
||||
# 数据库密码
|
||||
DB_PASSWORD=123456
|
||||
# 数据库名称
|
||||
DB_DATABASE=xymj-server
|
||||
DB_DATABASE=xymj-db
|
||||
# 是否自动同步数据库结构(生产环境建议设为 false)
|
||||
DB_SYNCHRONIZE=true
|
||||
# 是否启用 SQL 日志(开发环境建议设为 true)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"copy-package": "node scripts/copy-package.mjs",
|
||||
"generate-controllers-once": "node scripts/generate-controllers.mjs",
|
||||
"clean": "rimraf dist",
|
||||
"test": "mocha -r tsx test/**_test.ts --exit --timeout 15000"
|
||||
"test": "set NODE_ENV=test&& mocha -r tsx test/**_test.ts --exit --timeout 15000"
|
||||
},
|
||||
"author": "",
|
||||
"license": "UNLICENSED",
|
||||
|
||||
@@ -21,6 +21,7 @@ import { NearbyChatRoom } from "./rooms/chat/NearbyChatRoom";
|
||||
import { TeamChatRoom } from "./rooms/chat/TeamChatRoom";
|
||||
import { ChatRoomName } from "./rooms/chat/ChatRoomName";
|
||||
import { LoadTestRoom } from "./rooms/LoadTestRoom";
|
||||
import { MatchmakerRoom } from "./rooms/MatchmakerRoom";
|
||||
|
||||
/**
|
||||
* Import database and cache
|
||||
@@ -69,6 +70,11 @@ export default config({
|
||||
recordFrames: false, // 是否记录帧数据
|
||||
});
|
||||
|
||||
/**
|
||||
* 匹配入口(主动匹配/被动开房)
|
||||
*/
|
||||
gameServer.define("matchmaker_room", MatchmakerRoom);
|
||||
|
||||
/**
|
||||
* 聊天房间(世界/工会/附近/队伍)
|
||||
*/
|
||||
|
||||
@@ -68,6 +68,18 @@ export const swaggerSchemas: Record<string, any> = {
|
||||
nullable: true,
|
||||
example: 'https://example.com/avatar.jpg',
|
||||
},
|
||||
openid: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
example: 'oWZ9m5XXXXXX',
|
||||
description: '第三方 openid(例如微信/抖音)',
|
||||
},
|
||||
guildId: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
example: '10001',
|
||||
description: '工会ID',
|
||||
},
|
||||
status: {
|
||||
type: 'integer',
|
||||
example: 1,
|
||||
|
||||
@@ -33,10 +33,10 @@ export async function createConnection(): Promise<DataSource> {
|
||||
|
||||
try {
|
||||
await dataSource.initialize();
|
||||
console.log('✅ 数据库连接成功');
|
||||
console.log('[数据库]✅ 数据库连接成功');
|
||||
return dataSource;
|
||||
} catch (error) {
|
||||
console.error('❌ 数据库连接失败:', error);
|
||||
console.error('[数据库]❌ 数据库连接失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export async function closeConnection(): Promise<void> {
|
||||
if (dataSource && dataSource.isInitialized) {
|
||||
await dataSource.destroy();
|
||||
dataSource = null;
|
||||
console.log('✅ 数据库连接已关闭');
|
||||
console.log('[数据库]✅ 数据库连接已关闭');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,14 @@
|
||||
// 必须在最顶部导入 reflect-metadata,TypeORM 需要它
|
||||
import "reflect-metadata";
|
||||
|
||||
import { installConsolePrefix } from "./utils/log";
|
||||
import { listen } from "@colyseus/tools";
|
||||
|
||||
// Import Colyseus config
|
||||
import app from "./app.config";
|
||||
|
||||
// 统一控制台输出格式:[xymj][类别],并用蓝色前缀显示
|
||||
installConsolePrefix("系统");
|
||||
|
||||
// Create and listen on 2567 (or PORT environment variable.)
|
||||
listen(app);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { BaseEntity } from './BaseEntity';
|
||||
@Entity('users')
|
||||
@Index(['username'], { unique: true })
|
||||
@Index(['email'], { unique: true })
|
||||
@Index(['openid'], { unique: true })
|
||||
export class User extends BaseEntity {
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
@@ -46,6 +47,22 @@ export class User extends BaseEntity {
|
||||
})
|
||||
avatar?: string;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 64,
|
||||
nullable: true,
|
||||
comment: '第三方 openid(例如微信)',
|
||||
})
|
||||
openid?: string;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 64,
|
||||
nullable: true,
|
||||
comment: '工会ID',
|
||||
})
|
||||
guildId?: string;
|
||||
|
||||
@Column({
|
||||
type: 'tinyint',
|
||||
default: 1,
|
||||
|
||||
714
src/public/MatchmakingDemo.html
Normal file
714
src/public/MatchmakingDemo.html
Normal file
@@ -0,0 +1,714 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>匹配赛演示 - 小游码匠</title>
|
||||
<script src="https://unpkg.com/colyseus.js@^0.16.0/dist/colyseus.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
background: linear-gradient(135deg, #111827 0%, #4c1d95 55%, #2563eb 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
}
|
||||
.container { max-width: 1200px; margin: 0 auto; }
|
||||
.back-link {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: 18px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
padding: 10px 14px;
|
||||
background: rgba(255,255,255,0.14);
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.back-link:hover { background: rgba(255,255,255,0.22); }
|
||||
.header {
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
padding: 18px 0 26px;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 2.2rem;
|
||||
text-shadow: 0 2px 10px rgba(0,0,0,0.25);
|
||||
}
|
||||
.header p { margin: 0; opacity: 0.92; }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.card {
|
||||
background: rgba(255,255,255,0.96);
|
||||
border-radius: 14px;
|
||||
padding: 18px;
|
||||
box-shadow: 0 10px 24px rgba(0,0,0,0.18);
|
||||
border: 1px solid rgba(255,255,255,0.45);
|
||||
}
|
||||
.card h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1.25rem;
|
||||
color: #4f46e5;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid rgba(79,70,229,0.25);
|
||||
}
|
||||
label { display: block; font-size: 12px; color: #4b5563; margin-bottom: 6px; }
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { border-color: #6366f1; box-shadow: 0 0 0 3px rgba(99,102,241,0.15); }
|
||||
textarea { min-height: 78px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; font-size: 12.5px; }
|
||||
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||||
.row > * { flex: 1; min-width: 180px; }
|
||||
.row.tight > * { min-width: 140px; }
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
transition: transform 0.12s ease, filter 0.12s ease, opacity 0.12s ease;
|
||||
}
|
||||
.btn:hover { filter: brightness(1.05); transform: translateY(-1px); }
|
||||
.btn:disabled { opacity: 0.55; cursor: not-allowed; transform: none; }
|
||||
.btn.gray { background: #64748b; }
|
||||
.btn.danger { background: #dc2626; }
|
||||
.btn.success { background: #16a34a; }
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(79,70,229,0.10);
|
||||
color: #4338ca;
|
||||
font-weight: 800;
|
||||
font-size: 12px;
|
||||
border: 1px solid rgba(79,70,229,0.18);
|
||||
}
|
||||
.status {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.kv {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.kv b { display: block; font-size: 12px; color: #6b7280; margin-bottom: 4px; }
|
||||
.kv span { display: block; font-weight: 800; color: #111827; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.log {
|
||||
height: 340px;
|
||||
overflow: auto;
|
||||
background: #0b1020;
|
||||
color: #e5e7eb;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border: 1px solid rgba(255,255,255,0.10);
|
||||
}
|
||||
code { background: rgba(79,70,229,0.08); padding: 2px 6px; border-radius: 8px; }
|
||||
.hint { font-size: 12px; color: #6b7280; line-height: 1.65; margin-top: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="back-link" href="index.html">← 返回首页</a>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🎯 匹配赛演示(主动匹配 / 被动开房)</h1>
|
||||
<p>连接 <code>matchmaker_room</code>,收到 <code>match:found</code> 后自动加入 <code>game_room</code>,支持模拟断线与重连。</p>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div>
|
||||
<div class="card">
|
||||
<h2>🔐 连接与参数</h2>
|
||||
<div class="row">
|
||||
<div>
|
||||
<label>服务器地址(HTTP / WS 均可)</label>
|
||||
<input id="serverUrl" value="http://localhost:2567" />
|
||||
</div>
|
||||
<div>
|
||||
<label>JWT token(必填)</label>
|
||||
<input id="token" placeholder="从 /api/auth/login 拿到 accessToken" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row tight" style="margin-top: 10px;">
|
||||
<div>
|
||||
<label>demoUserId(演示用:同 token 多开请填不同)</label>
|
||||
<input id="demoUserId" placeholder="例如 u1 / u2(留空则用 token 的 userId)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row tight" style="margin-top: 10px;">
|
||||
<div>
|
||||
<label>modeId</label>
|
||||
<input id="modeId" value="ranked" />
|
||||
</div>
|
||||
<div>
|
||||
<label>playersPerMatch(动态人数)</label>
|
||||
<input id="playersPerMatch" type="number" min="2" max="100" value="2" />
|
||||
</div>
|
||||
<div>
|
||||
<label>region(可选)</label>
|
||||
<input id="region" placeholder="例如 cn1 / global" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" style="margin-top: 12px;">
|
||||
<button class="btn success" id="btnConnectMm" onclick="connectMatchmaker()">连接 matchmaker_room</button>
|
||||
<button class="btn danger" id="btnLeaveMm" onclick="leaveMatchmaker()" disabled>离开 matchmaker_room</button>
|
||||
</div>
|
||||
|
||||
<div class="status">
|
||||
<div class="kv"><b>Matchmaker 状态</b><span id="mmStatus">未连接</span></div>
|
||||
<div class="kv"><b>matchmaker_roomId</b><span id="mmRoomId">-</span></div>
|
||||
<div class="kv"><b>matchmaker_sessionId</b><span id="mmSessionId">-</span></div>
|
||||
<div class="kv"><b>queueKey</b><span id="queueKey">-</span></div>
|
||||
</div>
|
||||
|
||||
<div class="hint">
|
||||
- 需要后端能验证 JWT(`RequireAuth`)。<br/>
|
||||
- “动态人数”由接入方传入 `playersPerMatch`,框架只保证同队列内人数一致(`queueKey` 包含它)。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>⚔️ 主动匹配(大厅点开始)</h2>
|
||||
<div class="row">
|
||||
<button class="btn" id="btnFind" onclick="startActiveMatch()" disabled>match:find(开始匹配)</button>
|
||||
<button class="btn gray" id="btnCancel" onclick="cancelActiveMatch()" disabled>match:cancel(取消)</button>
|
||||
</div>
|
||||
<div class="hint">
|
||||
匹配成功会收到 <code>match:found</code>,页面会自动 <code>joinById(roomId)</code> 进入对局。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>🏠 被动开房(房间码 / 组队)</h2>
|
||||
<div class="row">
|
||||
<button class="btn" id="btnPartyCreate" onclick="partyCreate()" disabled>party:create(创建房间码)</button>
|
||||
<button class="btn success" id="btnPartyStart" onclick="partyStart()" disabled>party:start(房主进入游戏)</button>
|
||||
<button class="btn gray" id="btnPartyLeave" onclick="partyLeave()" disabled>party:leave(离开)</button>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 10px;">
|
||||
<div>
|
||||
<label>partyCode</label>
|
||||
<input id="partyCode" placeholder="例如 ABC123" />
|
||||
</div>
|
||||
<div style="flex:0.6; min-width: 160px;">
|
||||
<label> </label>
|
||||
<button class="btn success" id="btnPartyJoin" onclick="partyJoin()" disabled>party:join</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status">
|
||||
<div class="kv"><b>partyId</b><span id="partyId">-</span></div>
|
||||
<div class="kv"><b>当前人数</b><span id="partyCount">-</span></div>
|
||||
<div class="kv"><b>目标人数</b><span id="partyTarget">-</span></div>
|
||||
</div>
|
||||
<div class="hint">
|
||||
被动模式下:成员先加入 party,只有房主点击 <code>party:start</code> 后才会统一推送 <code>match:found</code> 并切入游戏页。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="card">
|
||||
<h2>🎮 对局(game_room)与重连</h2>
|
||||
<div class="status">
|
||||
<div class="kv"><b>game_roomId</b><span id="gameRoomId">-</span></div>
|
||||
<div class="kv"><b>game_sessionId</b><span id="gameSessionId">-</span></div>
|
||||
<div class="kv"><b>matchId</b><span id="matchId">-</span></div>
|
||||
<div class="kv"><b>seatIndex</b><span id="seatIndex">-</span></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 12px;" class="row">
|
||||
<button class="btn gray" id="btnLeaveGame" onclick="leaveGameRoom()" disabled>离开对局(正常)</button>
|
||||
<button class="btn danger" id="btnSimDrop" onclick="simulateDrop()" disabled>模拟断线(非正常)</button>
|
||||
<button class="btn success" id="btnReconnect" onclick="reconnectGameRoom()" disabled>重连(带 reconnectKey)</button>
|
||||
</div>
|
||||
|
||||
<div class="row" style="margin-top: 10px;">
|
||||
<div>
|
||||
<label>reconnectKey(收到 match:found 后保存)</label>
|
||||
<input id="reconnectKey" placeholder="自动填充" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" style="margin-top: 10px;">
|
||||
<div>
|
||||
<label>对局聊天(game_room 内 onMessage('chat'))</label>
|
||||
<input id="gameChatText" placeholder="输入并发送 chat" />
|
||||
</div>
|
||||
<div style="flex:0.5; min-width: 140px;">
|
||||
<label> </label>
|
||||
<button class="btn" id="btnGameChat" onclick="sendGameChat()" disabled>发送 chat</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hint">
|
||||
- 掉线后服务器会广播 <code>playerOffline</code>,重连成功会收到 <code>reconnect:ok</code>。<br/>
|
||||
- 重连窗口由服务端环境变量 <code>MATCH_RECONNECT_WINDOW_MS</code> 控制(默认 15000ms)。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<h2 style="margin:0;">🧾 日志</h2>
|
||||
<span class="pill" id="pillConn">未连接</span>
|
||||
</div>
|
||||
<div style="margin-top: 10px;" class="row">
|
||||
<button class="btn gray" onclick="clearLog()">清空日志</button>
|
||||
</div>
|
||||
<div class="log" id="logBox" style="margin-top: 10px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 注意:localStorage 在同一浏览器的所有窗口/标签页共享
|
||||
// 为避免“两个窗口填不同账号 token 却互相覆盖”,token/demoUserId 用 sessionStorage(每标签页独立)
|
||||
const LS = {
|
||||
serverUrl: "mmDemo_serverUrl",
|
||||
modeId: "mmDemo_modeId",
|
||||
playersPerMatch: "mmDemo_playersPerMatch",
|
||||
region: "mmDemo_region",
|
||||
};
|
||||
const SS = {
|
||||
token: "mmDemo_token",
|
||||
demoUserId: "mmDemo_demoUserId",
|
||||
};
|
||||
|
||||
let client = null;
|
||||
let mmRoom = null;
|
||||
let gameRoom = null;
|
||||
let partyState = {
|
||||
partyId: "",
|
||||
count: 0,
|
||||
playersPerMatch: 0,
|
||||
leaderUserId: "",
|
||||
isLeader: false,
|
||||
};
|
||||
|
||||
// 记住最近一次 match found,用于重连
|
||||
let lastFound = {
|
||||
roomId: "",
|
||||
matchId: "",
|
||||
seatIndex: -1,
|
||||
reconnectKey: "",
|
||||
};
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function now() { return new Date().toLocaleTimeString(); }
|
||||
function log(msg) {
|
||||
const el = $("logBox");
|
||||
el.textContent = `[${now()}] ${msg}\n` + el.textContent;
|
||||
}
|
||||
function clearLog() { $("logBox").textContent = ""; log("日志已清空"); }
|
||||
|
||||
function persistInputs() {
|
||||
localStorage.setItem(LS.serverUrl, $("serverUrl").value.trim());
|
||||
sessionStorage.setItem(SS.token, $("token").value.trim());
|
||||
sessionStorage.setItem(SS.demoUserId, ($("demoUserId") ? $("demoUserId").value.trim() : ""));
|
||||
localStorage.setItem(LS.modeId, $("modeId").value.trim());
|
||||
localStorage.setItem(LS.playersPerMatch, $("playersPerMatch").value.trim());
|
||||
localStorage.setItem(LS.region, $("region").value.trim());
|
||||
}
|
||||
function restoreInputs() {
|
||||
const v1 = localStorage.getItem(LS.serverUrl); if (v1) $("serverUrl").value = v1;
|
||||
const v2 = sessionStorage.getItem(SS.token); if (v2) $("token").value = v2;
|
||||
const v2b = sessionStorage.getItem(SS.demoUserId); if (v2b && $("demoUserId")) $("demoUserId").value = v2b;
|
||||
const v3 = localStorage.getItem(LS.modeId); if (v3) $("modeId").value = v3;
|
||||
const v4 = localStorage.getItem(LS.playersPerMatch); if (v4) $("playersPerMatch").value = v4;
|
||||
const v5 = localStorage.getItem(LS.region); if (v5) $("region").value = v5;
|
||||
}
|
||||
|
||||
function setMmStatus(text) {
|
||||
$("mmStatus").textContent = text;
|
||||
$("pillConn").textContent = text;
|
||||
}
|
||||
function setEnabled(id, enabled) { $(id).disabled = !enabled; }
|
||||
|
||||
function getJoinBaseOptions() {
|
||||
const token = $("token").value.trim();
|
||||
if (!token) throw new Error("请先填写 token");
|
||||
const demoUserId = ($("demoUserId") ? $("demoUserId").value.trim() : "");
|
||||
return demoUserId ? { token, demoUserId } : { token };
|
||||
}
|
||||
|
||||
async function connectMatchmaker() {
|
||||
if (typeof Colyseus === "undefined") {
|
||||
alert("未加载 colyseus.js");
|
||||
return;
|
||||
}
|
||||
persistInputs();
|
||||
const serverUrl = $("serverUrl").value.trim();
|
||||
if (!serverUrl) return alert("请输入 serverUrl");
|
||||
|
||||
try {
|
||||
if (!client) client = new Colyseus.Client(serverUrl);
|
||||
setMmStatus("连接中...");
|
||||
log(`连接 matchmaker_room -> ${serverUrl}`);
|
||||
|
||||
mmRoom = await client.joinOrCreate("matchmaker_room", getJoinBaseOptions());
|
||||
|
||||
$("mmRoomId").textContent = mmRoom.roomId || "-";
|
||||
$("mmSessionId").textContent = mmRoom.sessionId || "-";
|
||||
setMmStatus("已连接 matchmaker_room");
|
||||
|
||||
setEnabled("btnConnectMm", false);
|
||||
setEnabled("btnLeaveMm", true);
|
||||
setEnabled("btnFind", true);
|
||||
setEnabled("btnCancel", true);
|
||||
setEnabled("btnPartyCreate", true);
|
||||
setEnabled("btnPartyJoin", true);
|
||||
setEnabled("btnPartyLeave", true);
|
||||
setEnabled("btnPartyStart", false);
|
||||
|
||||
mmRoom.onLeave(() => {
|
||||
log("已离开 matchmaker_room");
|
||||
mmRoom = null;
|
||||
$("mmRoomId").textContent = "-";
|
||||
$("mmSessionId").textContent = "-";
|
||||
$("queueKey").textContent = "-";
|
||||
setMmStatus("未连接");
|
||||
|
||||
setEnabled("btnConnectMm", true);
|
||||
setEnabled("btnLeaveMm", false);
|
||||
setEnabled("btnFind", false);
|
||||
setEnabled("btnCancel", false);
|
||||
setEnabled("btnPartyCreate", false);
|
||||
setEnabled("btnPartyJoin", false);
|
||||
setEnabled("btnPartyLeave", false);
|
||||
setEnabled("btnPartyStart", false);
|
||||
});
|
||||
|
||||
mmRoom.onError((code, message) => {
|
||||
log(`matchmaker_room error [${code}]: ${message}`);
|
||||
});
|
||||
|
||||
mmRoom.onMessage("mm:ready", (msg) => log(`mm:ready sessionId=${msg.sessionId}`));
|
||||
mmRoom.onMessage("match:queued", (msg) => {
|
||||
$("queueKey").textContent = msg.queueKey || "-";
|
||||
log(`match:queued queueKey=${msg.queueKey}`);
|
||||
});
|
||||
mmRoom.onMessage("match:cancelled", (msg) => log(`match:cancelled ok=${!!msg.ok}`));
|
||||
mmRoom.onMessage("match:error", (msg) => log(`match:error ${JSON.stringify(msg)}`));
|
||||
|
||||
mmRoom.onMessage("party:created", (msg) => {
|
||||
$("partyId").textContent = msg.partyId || "-";
|
||||
$("partyCode").value = msg.partyCode || "";
|
||||
$("partyTarget").textContent = String(msg.playersPerMatch ?? "-");
|
||||
$("partyCount").textContent = "1";
|
||||
partyState.partyId = String(msg.partyId || "");
|
||||
partyState.count = 1;
|
||||
partyState.playersPerMatch = Number(msg.playersPerMatch || 0);
|
||||
partyState.leaderUserId = String(msg.leaderUserId || "");
|
||||
partyState.isLeader = !!msg.isLeader;
|
||||
setEnabled("btnPartyStart", partyState.isLeader);
|
||||
log(`party:created code=${msg.partyCode} target=${msg.playersPerMatch}`);
|
||||
});
|
||||
mmRoom.onMessage("party:joined", (msg) => {
|
||||
$("partyId").textContent = msg.partyId || "-";
|
||||
$("partyCode").value = msg.partyCode || $("partyCode").value;
|
||||
$("partyCount").textContent = String(msg.count ?? "-");
|
||||
$("partyTarget").textContent = String(msg.playersPerMatch ?? "-");
|
||||
partyState.partyId = String(msg.partyId || "");
|
||||
partyState.count = Number(msg.count || 0);
|
||||
partyState.playersPerMatch = Number(msg.playersPerMatch || 0);
|
||||
partyState.leaderUserId = String(msg.leaderUserId || "");
|
||||
partyState.isLeader = !!msg.isLeader;
|
||||
setEnabled("btnPartyStart", partyState.isLeader);
|
||||
log(`party:joined count=${msg.count}/${msg.playersPerMatch}`);
|
||||
});
|
||||
mmRoom.onMessage("party:update", (msg) => {
|
||||
$("partyId").textContent = msg.partyId || "-";
|
||||
$("partyCount").textContent = String(msg.count ?? "-");
|
||||
$("partyTarget").textContent = String(msg.playersPerMatch ?? "-");
|
||||
partyState.partyId = String(msg.partyId || "");
|
||||
partyState.count = Number(msg.count || 0);
|
||||
partyState.playersPerMatch = Number(msg.playersPerMatch || 0);
|
||||
partyState.leaderUserId = String(msg.leaderUserId || "");
|
||||
partyState.isLeader = !!msg.isLeader;
|
||||
setEnabled("btnPartyStart", partyState.isLeader);
|
||||
log(`party:update count=${msg.count}/${msg.playersPerMatch} isLeader=${partyState.isLeader}`);
|
||||
});
|
||||
mmRoom.onMessage("party:left", (msg) => log(`party:left ok=${!!msg.ok}`));
|
||||
mmRoom.onMessage("party:error", (msg) => log(`party:error ${msg.message || JSON.stringify(msg)}`));
|
||||
|
||||
mmRoom.onMessage("match:found", async (msg) => {
|
||||
log(`match:found roomId=${msg.roomId} seat=${msg.seatIndex} matchId=${msg.matchId}`);
|
||||
lastFound.roomId = msg.roomId || "";
|
||||
lastFound.matchId = msg.matchId || "";
|
||||
lastFound.seatIndex = typeof msg.seatIndex === "number" ? msg.seatIndex : -1;
|
||||
lastFound.reconnectKey = msg.reconnectKey || "";
|
||||
$("reconnectKey").value = lastFound.reconnectKey;
|
||||
|
||||
$("matchId").textContent = lastFound.matchId || "-";
|
||||
$("seatIndex").textContent = String(lastFound.seatIndex);
|
||||
|
||||
// 匹配成功后统一切入新页面(所有匹配到的人都会跳转)
|
||||
try {
|
||||
const payload = {
|
||||
serverUrl: $("serverUrl").value.trim(),
|
||||
token: $("token").value.trim(),
|
||||
demoUserId: ($("demoUserId") ? $("demoUserId").value.trim() : ""),
|
||||
roomId: lastFound.roomId,
|
||||
matchId: lastFound.matchId,
|
||||
seatIndex: lastFound.seatIndex,
|
||||
reconnectKey: lastFound.reconnectKey,
|
||||
};
|
||||
sessionStorage.setItem("xymj_match_found", JSON.stringify(payload));
|
||||
} catch (e) {}
|
||||
window.location.href = "MatchmakingGame.html";
|
||||
});
|
||||
|
||||
log("matchmaker_room 连接成功");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setMmStatus("连接失败");
|
||||
log("连接失败: " + (e.message || e));
|
||||
alert("连接失败: " + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async function leaveMatchmaker() {
|
||||
if (!mmRoom) return;
|
||||
try { await mmRoom.leave(); } catch (e) {}
|
||||
}
|
||||
|
||||
function buildMatchParams() {
|
||||
const modeId = ($("modeId").value || "default").trim();
|
||||
const playersPerMatch = parseInt($("playersPerMatch").value, 10) || 2;
|
||||
const regionRaw = ($("region").value || "").trim();
|
||||
const payload = { modeId, playersPerMatch };
|
||||
if (regionRaw) payload.region = regionRaw;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function startActiveMatch() {
|
||||
if (!mmRoom) return alert("请先连接 matchmaker_room");
|
||||
const payload = buildMatchParams();
|
||||
mmRoom.send("match:find", payload);
|
||||
log("send match:find " + JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function cancelActiveMatch() {
|
||||
if (!mmRoom) return;
|
||||
mmRoom.send("match:cancel", {});
|
||||
log("send match:cancel");
|
||||
}
|
||||
|
||||
function partyCreate() {
|
||||
if (!mmRoom) return alert("请先连接 matchmaker_room");
|
||||
const payload = buildMatchParams();
|
||||
mmRoom.send("party:create", payload);
|
||||
log("send party:create " + JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function partyJoin() {
|
||||
if (!mmRoom) return alert("请先连接 matchmaker_room");
|
||||
const partyCode = ($("partyCode").value || "").trim();
|
||||
if (!partyCode) return alert("请输入 partyCode");
|
||||
mmRoom.send("party:join", { partyCode });
|
||||
log("send party:join " + partyCode);
|
||||
}
|
||||
|
||||
function partyLeave() {
|
||||
if (!mmRoom) return;
|
||||
mmRoom.send("party:leave", {});
|
||||
log("send party:leave");
|
||||
$("partyId").textContent = "-";
|
||||
$("partyCount").textContent = "-";
|
||||
$("partyTarget").textContent = "-";
|
||||
partyState = { partyId: "", count: 0, playersPerMatch: 0, leaderUserId: "", isLeader: false };
|
||||
setEnabled("btnPartyStart", false);
|
||||
}
|
||||
|
||||
function partyStart() {
|
||||
if (!mmRoom) return alert("请先连接 matchmaker_room");
|
||||
if (!partyState.isLeader) return alert("只有房主可以开始游戏");
|
||||
mmRoom.send("party:start", {});
|
||||
log("send party:start");
|
||||
}
|
||||
|
||||
async function joinGameRoomByFound(foundMsg) {
|
||||
try {
|
||||
if (!client) throw new Error("client not ready");
|
||||
const roomId = foundMsg.roomId;
|
||||
if (!roomId) throw new Error("match:found missing roomId");
|
||||
|
||||
// 若已在房间,先离开
|
||||
if (gameRoom) {
|
||||
try { await gameRoom.leave(); } catch (e) {}
|
||||
gameRoom = null;
|
||||
}
|
||||
|
||||
const token = $("token").value.trim();
|
||||
const joinOpt = {
|
||||
token,
|
||||
matchId: foundMsg.matchId,
|
||||
seatIndex: foundMsg.seatIndex,
|
||||
reconnectKey: foundMsg.reconnectKey,
|
||||
};
|
||||
|
||||
log("joinById(game_room) -> " + roomId);
|
||||
gameRoom = await client.joinById(roomId, joinOpt);
|
||||
|
||||
$("gameRoomId").textContent = gameRoom.roomId || "-";
|
||||
$("gameSessionId").textContent = gameRoom.sessionId || "-";
|
||||
setEnabled("btnLeaveGame", true);
|
||||
setEnabled("btnSimDrop", true);
|
||||
setEnabled("btnReconnect", true);
|
||||
setEnabled("btnGameChat", true);
|
||||
|
||||
gameRoom.onLeave((code) => {
|
||||
log("game_room onLeave code=" + code);
|
||||
$("gameRoomId").textContent = "-";
|
||||
$("gameSessionId").textContent = "-";
|
||||
setEnabled("btnLeaveGame", false);
|
||||
setEnabled("btnSimDrop", false);
|
||||
setEnabled("btnGameChat", false);
|
||||
// 允许重连(如果我们有 lastFound)
|
||||
setEnabled("btnReconnect", !!lastFound.roomId);
|
||||
gameRoom = null;
|
||||
});
|
||||
|
||||
gameRoom.onError((code, message) => log(`game_room error [${code}]: ${message}`));
|
||||
gameRoom.onMessage("reconnect:ok", (msg) => log("reconnect:ok " + JSON.stringify(msg)));
|
||||
gameRoom.onMessage("playerOffline", (msg) => log("playerOffline " + JSON.stringify(msg)));
|
||||
gameRoom.onMessage("playerOnline", (msg) => log("playerOnline " + JSON.stringify(msg)));
|
||||
gameRoom.onMessage("chat", (msg) => log("game chat " + JSON.stringify(msg)));
|
||||
|
||||
// 简单展示 state 变化(玩家在线/座位)
|
||||
gameRoom.onStateChange((state) => {
|
||||
try {
|
||||
const ps = [];
|
||||
if (state.players && state.players.forEach) {
|
||||
state.players.forEach((p, sid) => {
|
||||
ps.push({
|
||||
sid: String(sid).slice(0, 8),
|
||||
userId: p.userId,
|
||||
seat: p.seatIndex,
|
||||
online: p.online,
|
||||
});
|
||||
});
|
||||
}
|
||||
if (ps.length) log("state players=" + JSON.stringify(ps));
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
log("✅ 已加入 game_room");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
log("join game_room 失败: " + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async function leaveGameRoom() {
|
||||
if (!gameRoom) return;
|
||||
try { await gameRoom.leave(); } catch (e) {}
|
||||
}
|
||||
|
||||
function simulateDrop() {
|
||||
if (!gameRoom) return;
|
||||
try {
|
||||
// 非正常断线:关闭底层连接
|
||||
if (gameRoom.connection && gameRoom.connection.transport && gameRoom.connection.transport.close) {
|
||||
gameRoom.connection.transport.close();
|
||||
} else if (gameRoom.connection && gameRoom.connection.close) {
|
||||
gameRoom.connection.close();
|
||||
} else {
|
||||
// fallback:快速 leave(不等于真实掉线,但保底)
|
||||
gameRoom.leave();
|
||||
}
|
||||
log("已触发模拟断线");
|
||||
} catch (e) {
|
||||
log("模拟断线失败: " + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async function reconnectGameRoom() {
|
||||
if (!client) return alert("client 未初始化");
|
||||
if (!lastFound.roomId) return alert("没有可重连的 roomId(需要先 match:found)");
|
||||
const token = $("token").value.trim();
|
||||
const reconnectKey = ($("reconnectKey").value || "").trim();
|
||||
if (!reconnectKey) return alert("没有 reconnectKey");
|
||||
|
||||
try {
|
||||
log("尝试重连 joinById -> " + lastFound.roomId);
|
||||
gameRoom = await client.joinById(lastFound.roomId, {
|
||||
token,
|
||||
matchId: lastFound.matchId,
|
||||
reconnectKey,
|
||||
seatIndex: lastFound.seatIndex,
|
||||
});
|
||||
$("gameRoomId").textContent = gameRoom.roomId || "-";
|
||||
$("gameSessionId").textContent = gameRoom.sessionId || "-";
|
||||
setEnabled("btnLeaveGame", true);
|
||||
setEnabled("btnSimDrop", true);
|
||||
setEnabled("btnGameChat", true);
|
||||
log("✅ 重连 joinById 成功(等服务端下发 reconnect:ok)");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
log("重连失败: " + (e.message || e));
|
||||
alert("重连失败: " + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
function sendGameChat() {
|
||||
if (!gameRoom) return;
|
||||
const text = ($("gameChatText").value || "").trim();
|
||||
if (!text) return;
|
||||
gameRoom.send("chat", text);
|
||||
$("gameChatText").value = "";
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
restoreInputs();
|
||||
["serverUrl","token","demoUserId","modeId","playersPerMatch","region"].forEach((id) => {
|
||||
$(id).addEventListener("input", persistInputs);
|
||||
});
|
||||
|
||||
// 初始禁用
|
||||
setEnabled("btnLeaveMm", false);
|
||||
setEnabled("btnFind", false);
|
||||
setEnabled("btnCancel", false);
|
||||
setEnabled("btnPartyCreate", false);
|
||||
setEnabled("btnPartyJoin", false);
|
||||
setEnabled("btnPartyLeave", false);
|
||||
setEnabled("btnPartyStart", false);
|
||||
setEnabled("btnLeaveGame", false);
|
||||
setEnabled("btnSimDrop", false);
|
||||
setEnabled("btnReconnect", false);
|
||||
setEnabled("btnGameChat", false);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
338
src/public/MatchmakingGame.html
Normal file
338
src/public/MatchmakingGame.html
Normal file
@@ -0,0 +1,338 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>对局准备 - 小游码匠</title>
|
||||
<script src="https://unpkg.com/colyseus.js@^0.16.0/dist/colyseus.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
background: radial-gradient(1200px 600px at 20% 10%, rgba(99,102,241,0.45), transparent 60%),
|
||||
radial-gradient(1000px 500px at 80% 20%, rgba(34,197,94,0.32), transparent 55%),
|
||||
linear-gradient(135deg, #0b1020 0%, #1f2a44 50%, #0b1020 100%);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.container { max-width: 1100px; margin: 0 auto; }
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
font-size: 14px;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
transition: transform 0.12s ease, filter 0.12s ease, opacity 0.12s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:hover { filter: brightness(1.05); transform: translateY(-1px); }
|
||||
.btn:disabled { opacity: 0.55; cursor: not-allowed; transform: none; }
|
||||
.btn.gray { background: #64748b; }
|
||||
.btn.danger { background: #dc2626; }
|
||||
.btn.success { background: #16a34a; }
|
||||
.title h1 { margin: 0; font-size: 1.8rem; }
|
||||
.title p { margin: 6px 0 0; opacity: 0.9; font-size: 13px; }
|
||||
.card {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.10);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 16px 40px rgba(0,0,0,0.35);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 960px) { .grid { grid-template-columns: 1fr; } }
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.kv .item {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.10);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.kv .item b { display:block; font-size: 12px; opacity: 0.75; margin-bottom: 4px; }
|
||||
.kv .item span { display:block; font-weight: 900; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(34,197,94,0.14);
|
||||
border: 1px solid rgba(34,197,94,0.25);
|
||||
color: #bbf7d0;
|
||||
font-weight: 900;
|
||||
font-size: 12px;
|
||||
}
|
||||
.pill.warn {
|
||||
background: rgba(245,158,11,0.14);
|
||||
border-color: rgba(245,158,11,0.25);
|
||||
color: #fde68a;
|
||||
}
|
||||
.log {
|
||||
height: 360px;
|
||||
overflow: auto;
|
||||
background: rgba(0,0,0,0.45);
|
||||
border: 1px solid rgba(255,255,255,0.10);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.players {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.player {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.10);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.player .name { font-weight: 900; }
|
||||
.player .meta { opacity: 0.85; font-size: 12px; margin-top: 4px; }
|
||||
code { background: rgba(99,102,241,0.14); padding: 2px 6px; border-radius: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="topbar">
|
||||
<div class="title">
|
||||
<h1>🎮 对局准备</h1>
|
||||
<p>来自匹配结果的统一入口页:自动加入 <code>game_room</code>,等待所有人到齐后开始。</p>
|
||||
</div>
|
||||
<div style="display:flex; gap:10px; flex-wrap:wrap; justify-content:flex-end;">
|
||||
<span class="pill" id="statusPill">准备中</span>
|
||||
<button class="btn gray" onclick="backToMatch()">返回匹配页</button>
|
||||
<button class="btn danger" id="btnLeave" onclick="leaveRoom()" disabled>离开对局</button>
|
||||
<button class="btn success" id="btnReconnect" onclick="reconnectRoom()" disabled>重连</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px;">
|
||||
<h2 style="margin:0;">📌 房间信息</h2>
|
||||
<button class="btn" id="btnJoin" onclick="joinRoom()">加入对局</button>
|
||||
</div>
|
||||
|
||||
<div class="kv">
|
||||
<div class="item"><b>serverUrl</b><span id="serverUrl">-</span></div>
|
||||
<div class="item"><b>roomId</b><span id="roomId">-</span></div>
|
||||
<div class="item"><b>matchId</b><span id="matchId">-</span></div>
|
||||
<div class="item"><b>seatIndex</b><span id="seatIndex">-</span></div>
|
||||
<div class="item"><b>reconnectKey</b><span id="reconnectKey">-</span></div>
|
||||
<div class="item"><b>sessionId</b><span id="sessionId">-</span></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:12px;">
|
||||
<h3 style="margin:0 0 10px;">👥 玩家列表(来自 state.players)</h3>
|
||||
<div class="players" id="playersBox"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px;">
|
||||
<h2 style="margin:0;">🧾 日志</h2>
|
||||
<button class="btn gray" onclick="clearLog()">清空</button>
|
||||
</div>
|
||||
<div class="log" id="logBox" style="margin-top:10px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let client = null;
|
||||
let room = null;
|
||||
let payload = null;
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function now() { return new Date().toLocaleTimeString(); }
|
||||
function log(msg) { $("logBox").textContent = `[${now()}] ${msg}\n` + $("logBox").textContent; }
|
||||
function clearLog() { $("logBox").textContent = ""; log("日志已清空"); }
|
||||
|
||||
function setPill(text, kind) {
|
||||
const el = $("statusPill");
|
||||
el.textContent = text;
|
||||
el.className = kind === "warn" ? "pill warn" : "pill";
|
||||
}
|
||||
|
||||
function loadPayload() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem("xymj_match_found");
|
||||
payload = raw ? JSON.parse(raw) : null;
|
||||
} catch (e) { payload = null; }
|
||||
|
||||
if (!payload || !payload.serverUrl || !payload.token || !payload.roomId) {
|
||||
setPill("缺少匹配信息", "warn");
|
||||
log("未找到 sessionStorage[xymj_match_found],请从 MatchmakingDemo.html 重新进入");
|
||||
$("btnJoin").disabled = true;
|
||||
$("btnReconnect").disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$("serverUrl").textContent = payload.serverUrl;
|
||||
$("roomId").textContent = payload.roomId;
|
||||
$("matchId").textContent = payload.matchId || "-";
|
||||
$("seatIndex").textContent = String(payload.seatIndex ?? "-");
|
||||
$("reconnectKey").textContent = payload.reconnectKey || "-";
|
||||
setPill("待加入对局");
|
||||
}
|
||||
|
||||
function renderPlayers(state) {
|
||||
const box = $("playersBox");
|
||||
box.innerHTML = "";
|
||||
const list = [];
|
||||
try {
|
||||
if (state && state.players && state.players.forEach) {
|
||||
state.players.forEach((p, sid) => {
|
||||
list.push({
|
||||
sid: String(sid),
|
||||
userId: p.userId,
|
||||
seatIndex: p.seatIndex,
|
||||
online: p.online,
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
if (!list.length) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "player";
|
||||
div.textContent = "暂无玩家信息(等待 state 同步)";
|
||||
box.appendChild(div);
|
||||
return;
|
||||
}
|
||||
|
||||
list.sort((a,b) => (a.seatIndex - b.seatIndex));
|
||||
for (const p of list) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "player";
|
||||
div.innerHTML =
|
||||
`<div class="name">seat ${p.seatIndex} · ${String(p.userId || "unknown")}</div>` +
|
||||
`<div class="meta">online=${p.online} · sid=${p.sid.slice(0,8)}</div>`;
|
||||
box.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
async function joinRoom() {
|
||||
if (!payload) return;
|
||||
if (typeof Colyseus === "undefined") return alert("未加载 colyseus.js");
|
||||
try {
|
||||
setPill("加入中...");
|
||||
log(`joinById -> ${payload.roomId}`);
|
||||
client = new Colyseus.Client(payload.serverUrl);
|
||||
room = await client.joinById(payload.roomId, {
|
||||
token: payload.token,
|
||||
demoUserId: payload.demoUserId || undefined,
|
||||
matchId: payload.matchId,
|
||||
seatIndex: payload.seatIndex,
|
||||
reconnectKey: payload.reconnectKey,
|
||||
});
|
||||
|
||||
$("sessionId").textContent = room.sessionId || "-";
|
||||
$("btnLeave").disabled = false;
|
||||
$("btnJoin").disabled = true;
|
||||
$("btnReconnect").disabled = false;
|
||||
|
||||
setPill("已进入房间,等待开始");
|
||||
log("✅ 已加入 game_room,准备开始游戏");
|
||||
|
||||
room.onMessage("reconnect:ok", (msg) => log("reconnect:ok " + JSON.stringify(msg)));
|
||||
room.onMessage("playerOffline", (msg) => log("playerOffline " + JSON.stringify(msg)));
|
||||
room.onMessage("playerOnline", (msg) => log("playerOnline " + JSON.stringify(msg)));
|
||||
room.onLeave((code) => {
|
||||
log("onLeave code=" + code);
|
||||
setPill("已离开,等待重连", "warn");
|
||||
$("btnJoin").disabled = true;
|
||||
$("btnLeave").disabled = true;
|
||||
$("btnReconnect").disabled = false;
|
||||
room = null;
|
||||
});
|
||||
room.onError((code, message) => log(`error [${code}] ${message}`));
|
||||
|
||||
// state
|
||||
room.onStateChange((state) => {
|
||||
renderPlayers(state);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setPill("加入失败", "warn");
|
||||
log("加入失败: " + (e.message || e));
|
||||
alert("加入失败: " + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async function leaveRoom() {
|
||||
if (!room) return;
|
||||
try { await room.leave(); } catch (e) {}
|
||||
}
|
||||
|
||||
async function reconnectRoom() {
|
||||
if (!payload) return;
|
||||
if (typeof Colyseus === "undefined") return alert("未加载 colyseus.js");
|
||||
try {
|
||||
setPill("重连中...");
|
||||
log(`reconnect joinById -> ${payload.roomId}`);
|
||||
client = new Colyseus.Client(payload.serverUrl);
|
||||
room = await client.joinById(payload.roomId, {
|
||||
token: payload.token,
|
||||
demoUserId: payload.demoUserId || undefined,
|
||||
matchId: payload.matchId,
|
||||
seatIndex: payload.seatIndex,
|
||||
reconnectKey: payload.reconnectKey,
|
||||
});
|
||||
$("sessionId").textContent = room.sessionId || "-";
|
||||
$("btnLeave").disabled = false;
|
||||
$("btnReconnect").disabled = false;
|
||||
setPill("已重连,等待开始");
|
||||
log("✅ 重连成功");
|
||||
room.onStateChange((state) => renderPlayers(state));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setPill("重连失败", "warn");
|
||||
log("重连失败: " + (e.message || e));
|
||||
alert("重连失败: " + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
function backToMatch() {
|
||||
window.location.href = "MatchmakingDemo.html";
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
loadPayload();
|
||||
// 自动加入(像“切入新页面准备开始游戏”)
|
||||
if ($("btnJoin").disabled !== true) {
|
||||
joinRoom();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -546,6 +546,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>🎯 匹配赛演示</h2>
|
||||
<div class="api-endpoint" style="background: #f5f3ff; border-left-color: #7c3aed;">
|
||||
<div>
|
||||
<span class="method get" style="background: #7c3aed;">DEMO</span>
|
||||
<span class="endpoint-path">MatchmakingDemo.html</span>
|
||||
<span class="status-badge status-info">主动匹配 / 被动开房 / 重连</span>
|
||||
</div>
|
||||
<div class="description">
|
||||
类王者荣耀:大厅点开始匹配(动态人数)与房间码开房加入;匹配成功自动进入 <code>game_room</code>,支持模拟断线与重连
|
||||
</div>
|
||||
<div style="margin-top: 15px;">
|
||||
<a href="MatchmakingDemo.html" class="swagger-link" style="color: #7c3aed; border-bottom-color: #7c3aed;">
|
||||
🎯 打开匹配赛演示程序 →
|
||||
</a>
|
||||
</div>
|
||||
<div class="description" style="margin-top: 10px; color:#666;">
|
||||
<strong>提示:</strong>匹配成功后会自动跳转到 <code>MatchmakingGame.html</code> 进行“对局准备/开始游戏”。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>📈 并发压测</h2>
|
||||
<div class="api-endpoint" style="background: #ecfdf5; border-left-color: #0d9488;">
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Client } from "@colyseus/core";
|
||||
import { FrameSyncRoom, ClientInput } from "../utils/FrameSync";
|
||||
import { MyRoomState, Player } from "./schema/MyRoomState";
|
||||
import { RequireAuth } from "../utils/decorators/RequireAuth";
|
||||
import crypto from "crypto";
|
||||
import RedisClient from "../utils/redis";
|
||||
|
||||
/**
|
||||
* 游戏房间示例 - 使用帧同步
|
||||
@@ -9,10 +11,25 @@ import { RequireAuth } from "../utils/decorators/RequireAuth";
|
||||
export class GameRoom extends FrameSyncRoom<MyRoomState> {
|
||||
maxClients = 4;
|
||||
state = new MyRoomState();
|
||||
private reconnectWindowMs = Number.parseInt(
|
||||
process.env.MATCH_RECONNECT_WINDOW_MS || "15000",
|
||||
10
|
||||
);
|
||||
private pendingReconnect = new Map<
|
||||
string,
|
||||
{ oldSessionId: string; expireAtMs: number; reconnectKey: string; matchId: string }
|
||||
>(); // userId -> info
|
||||
|
||||
@RequireAuth()
|
||||
onCreate(options: any) {
|
||||
console.log("[GameRoom] 房间创建:", this.roomId);
|
||||
|
||||
// 对接匹配赛:接入方可以在创建房间时传 playersPerMatch,平台框架做兜底
|
||||
if (options?.playersPerMatch) {
|
||||
const n = Number.parseInt(String(options.playersPerMatch), 10);
|
||||
if (!Number.isNaN(n) && n > 0) {
|
||||
this.maxClients = n;
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化帧同步(20 FPS)
|
||||
this.initFrameSync({
|
||||
@@ -31,13 +48,63 @@ export class GameRoom extends FrameSyncRoom<MyRoomState> {
|
||||
});
|
||||
}
|
||||
|
||||
// @RequireAuth()
|
||||
@RequireAuth()
|
||||
onJoin(client: Client, options: any) {
|
||||
console.log(`[GameRoom] ${client.sessionId} 加入房间`);
|
||||
|
||||
const userId = String(options.demoUserId ? String(options.demoUserId).trim() : options.userId);
|
||||
const matchId = options.matchId ? String(options.matchId) : "";
|
||||
const seatIndex =
|
||||
options.seatIndex !== undefined ? Number.parseInt(String(options.seatIndex), 10) : -1;
|
||||
const reconnectKey = options.reconnectKey ? String(options.reconnectKey) : "";
|
||||
|
||||
// 断线重连:若存在等待重连记录,则校验 reconnectKey 并把旧 session 迁移到新 session
|
||||
const migrated = this.tryConsumeReconnect(userId, matchId, reconnectKey);
|
||||
if (migrated) {
|
||||
const existing = this.state.players.get(migrated.oldSessionId);
|
||||
if (existing) {
|
||||
this.state.players.delete(migrated.oldSessionId);
|
||||
existing.sessionId = client.sessionId;
|
||||
existing.online = true;
|
||||
this.state.players.set(client.sessionId, existing);
|
||||
client.send("reconnect:ok", { matchId });
|
||||
|
||||
this.broadcast(
|
||||
"playerOnline",
|
||||
{ sessionId: client.sessionId, userId },
|
||||
{ except: client }
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底去重:即使没有命中 pendingReconnect,也保证同一 userId 在房间中只保留一个 player
|
||||
// 场景:页面刷新/重复连接时旧 session 仍在重连窗口,导致状态里出现同 userId 多条记录
|
||||
const duplicateSessionId = this.findSessionIdByUserId(userId);
|
||||
if (duplicateSessionId && duplicateSessionId !== client.sessionId) {
|
||||
const duplicate = this.state.players.get(duplicateSessionId);
|
||||
if (duplicate) {
|
||||
this.state.players.delete(duplicateSessionId);
|
||||
duplicate.sessionId = client.sessionId;
|
||||
duplicate.online = true;
|
||||
if (!Number.isNaN(seatIndex) && seatIndex >= 0) {
|
||||
duplicate.seatIndex = seatIndex;
|
||||
}
|
||||
this.state.players.set(client.sessionId, duplicate);
|
||||
|
||||
// 清理可能残留的重连记录,避免后续超时任务误删
|
||||
this.pendingReconnect.delete(userId);
|
||||
client.send("reconnect:ok", { matchId, dedup: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建玩家并添加到状态
|
||||
const player = new Player();
|
||||
player.sessionId = client.sessionId;
|
||||
player.userId = userId;
|
||||
player.seatIndex = Number.isNaN(seatIndex) ? -1 : seatIndex;
|
||||
player.online = true;
|
||||
player.x = Math.random() * 800; // 随机初始位置
|
||||
player.y = Math.random() * 500;
|
||||
this.state.players.set(client.sessionId, player);
|
||||
@@ -59,12 +126,59 @@ export class GameRoom extends FrameSyncRoom<MyRoomState> {
|
||||
|
||||
onLeave(client: Client, consented: boolean) {
|
||||
console.log(`[GameRoom] ${client.sessionId} 离开房间`);
|
||||
|
||||
// 从状态中移除玩家
|
||||
|
||||
const player = this.state.players.get(client.sessionId);
|
||||
const userId = player?.userId ? String(player.userId) : "";
|
||||
const matchId = ""; // 可后续从 options/metadata 注入
|
||||
|
||||
if (!consented && player && userId) {
|
||||
// 标记离线并给予重连窗口
|
||||
player.online = false;
|
||||
|
||||
const reconnectKey = crypto.randomUUID();
|
||||
const expireAtMs = Date.now() + this.reconnectWindowMs;
|
||||
this.pendingReconnect.set(userId, {
|
||||
oldSessionId: client.sessionId,
|
||||
expireAtMs,
|
||||
reconnectKey,
|
||||
matchId,
|
||||
});
|
||||
|
||||
void this.persistReconnect(
|
||||
userId,
|
||||
matchId,
|
||||
reconnectKey,
|
||||
Math.ceil(this.reconnectWindowMs / 1000)
|
||||
);
|
||||
|
||||
// 延迟真正移除
|
||||
this.clock.setTimeout(() => {
|
||||
const info = this.pendingReconnect.get(userId);
|
||||
if (!info) return;
|
||||
if (info.oldSessionId !== client.sessionId) return;
|
||||
if (Date.now() < info.expireAtMs) return;
|
||||
this.pendingReconnect.delete(userId);
|
||||
this.state.players.delete(client.sessionId);
|
||||
this.broadcast("playerLeft", {
|
||||
sessionId: client.sessionId,
|
||||
playerCount: this.state.players.size,
|
||||
});
|
||||
}, this.reconnectWindowMs);
|
||||
|
||||
this.broadcast("playerOffline", {
|
||||
sessionId: client.sessionId,
|
||||
userId,
|
||||
reconnectWindowMs: this.reconnectWindowMs,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 主动离开或无重连信息:直接移除
|
||||
this.state.players.delete(client.sessionId);
|
||||
|
||||
|
||||
console.log(`[GameRoom] 当前玩家数量: ${this.state.players.size}`);
|
||||
|
||||
|
||||
// 广播玩家离开消息
|
||||
this.broadcast("playerLeft", {
|
||||
sessionId: client.sessionId,
|
||||
@@ -77,6 +191,43 @@ export class GameRoom extends FrameSyncRoom<MyRoomState> {
|
||||
this.stopFrameSync();
|
||||
}
|
||||
|
||||
private tryConsumeReconnect(userId: string, matchId: string, reconnectKey: string) {
|
||||
if (!userId || !reconnectKey) return null;
|
||||
const info = this.pendingReconnect.get(userId);
|
||||
if (!info) return null;
|
||||
if (Date.now() > info.expireAtMs) {
|
||||
this.pendingReconnect.delete(userId);
|
||||
return null;
|
||||
}
|
||||
if (info.reconnectKey !== reconnectKey) return null;
|
||||
if (matchId && info.matchId && matchId !== info.matchId) return null;
|
||||
this.pendingReconnect.delete(userId);
|
||||
return info;
|
||||
}
|
||||
|
||||
private async persistReconnect(
|
||||
userId: string,
|
||||
matchId: string,
|
||||
reconnectKey: string,
|
||||
ttlSeconds: number
|
||||
) {
|
||||
try {
|
||||
const redis = RedisClient.getInstance();
|
||||
await redis.connect();
|
||||
const key = `mm:reconnect:${matchId || "none"}:${userId}`;
|
||||
await redis.set(key, reconnectKey, ttlSeconds);
|
||||
} catch {
|
||||
// ignore if redis not available
|
||||
}
|
||||
}
|
||||
|
||||
private findSessionIdByUserId(userId: string): string | null {
|
||||
for (const [sessionId, player] of this.state.players.entries()) {
|
||||
if (player.userId === userId) return sessionId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 帧更新 - 处理游戏逻辑
|
||||
*/
|
||||
|
||||
447
src/rooms/MatchmakerRoom.ts
Normal file
447
src/rooms/MatchmakerRoom.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
import type Redis from "ioredis";
|
||||
import { Room, Client, matchMaker } from "colyseus";
|
||||
import crypto from "crypto";
|
||||
import { RequireAuth } from "../utils/decorators/RequireAuth";
|
||||
import { MatchmakingService } from "../services/matchmaking/MatchmakingService";
|
||||
import type { MatchFindRequest, PartyCreateRequest, PartyJoinRequest } from "../services/matchmaking/types";
|
||||
import RedisClient from "../utils/redis";
|
||||
|
||||
type SessionUser = {
|
||||
userId: string;
|
||||
/** 演示用:允许同一 token 多开时区分不同“玩家” */
|
||||
demoUserId?: string;
|
||||
username?: string;
|
||||
ticketId?: string;
|
||||
queueKey?: string;
|
||||
playersPerMatch?: number;
|
||||
partyId?: string;
|
||||
};
|
||||
|
||||
function randomCode(len: number) {
|
||||
// 去掉容易混淆的字符
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
let out = "";
|
||||
for (let i = 0; i < len; i++) {
|
||||
out += alphabet[Math.floor(Math.random() * alphabet.length)];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function clampInt(n: any, min: number, max: number) {
|
||||
const v = Number.parseInt(String(n), 10);
|
||||
if (Number.isNaN(v)) return min;
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配入口房间
|
||||
* - 主动模式:排队凑人 -> 创建对局房
|
||||
* - 被动模式:创建 party(房间码) -> 其他人加入 -> 满人后创建对局房
|
||||
*/
|
||||
export class MatchmakerRoom extends Room {
|
||||
maxClients = 5000;
|
||||
private mm = new MatchmakingService();
|
||||
private users = new Map<string, SessionUser>(); // sessionId -> user
|
||||
private tick?: NodeJS.Timeout;
|
||||
private redisSub: Redis | null = null;
|
||||
private notifyChannel = "colyseus:mm:notify";
|
||||
|
||||
@RequireAuth()
|
||||
onCreate(options: any) {
|
||||
// 主动匹配
|
||||
this.onMessage("match:find", async (client, message: MatchFindRequest) => {
|
||||
const user = this.getUser(client);
|
||||
const modeId = String(message?.modeId || "default");
|
||||
const playersPerMatch = clampInt(message?.playersPerMatch, 2, 100);
|
||||
const region = message?.region ? String(message.region) : undefined;
|
||||
|
||||
const { queueKey, ticketId } = await this.mm.enqueue({
|
||||
userId: user.userId,
|
||||
sessionId: client.sessionId,
|
||||
username: user.username,
|
||||
modeId,
|
||||
playersPerMatch,
|
||||
region,
|
||||
skill: message?.skill,
|
||||
tags: message?.tags,
|
||||
enqueueAtMs: Date.now(),
|
||||
});
|
||||
|
||||
user.ticketId = ticketId;
|
||||
user.queueKey = queueKey;
|
||||
user.playersPerMatch = playersPerMatch;
|
||||
|
||||
client.send("match:queued", { queueKey });
|
||||
|
||||
// 尝试立刻组局(跨实例安全)
|
||||
await this.tryMatch(queueKey, playersPerMatch);
|
||||
});
|
||||
|
||||
this.onMessage("match:cancel", async (client) => {
|
||||
const user = this.getUser(client);
|
||||
if (!user.queueKey || !user.ticketId) {
|
||||
client.send("match:cancelled", { ok: true });
|
||||
return;
|
||||
}
|
||||
const ok = await this.mm.cancelEnqueue(user.queueKey, user.ticketId);
|
||||
user.queueKey = undefined;
|
||||
user.ticketId = undefined;
|
||||
client.send("match:cancelled", { ok });
|
||||
});
|
||||
|
||||
// 被动开房(party)
|
||||
this.onMessage("party:create", async (client, message: PartyCreateRequest) => {
|
||||
const user = this.getUser(client);
|
||||
const modeId = String(message?.modeId || "default");
|
||||
const playersPerMatch = clampInt(message?.playersPerMatch, 2, 100);
|
||||
const region = message?.region ? String(message.region) : undefined;
|
||||
|
||||
const partyId = crypto.randomUUID();
|
||||
const partyCode = randomCode(6);
|
||||
await this.mm.createParty({
|
||||
partyId,
|
||||
partyCode,
|
||||
modeId,
|
||||
playersPerMatch,
|
||||
region,
|
||||
leaderUserId: user.userId,
|
||||
createdAtMs: Date.now(),
|
||||
});
|
||||
user.partyId = partyId;
|
||||
client.send("party:created", {
|
||||
partyId,
|
||||
partyCode,
|
||||
modeId,
|
||||
playersPerMatch,
|
||||
region,
|
||||
leaderUserId: user.userId,
|
||||
isLeader: true,
|
||||
});
|
||||
});
|
||||
|
||||
this.onMessage("party:join", async (client, message: PartyJoinRequest) => {
|
||||
const user = this.getUser(client);
|
||||
const partyCode = String(message?.partyCode || "").trim().toUpperCase();
|
||||
if (!partyCode) {
|
||||
client.send("party:error", { message: "partyCode 不能为空" });
|
||||
return;
|
||||
}
|
||||
const partyId = await this.mm.getPartyIdByCode(partyCode);
|
||||
if (!partyId) {
|
||||
client.send("party:error", { message: "房间码不存在或已过期" });
|
||||
return;
|
||||
}
|
||||
|
||||
const party = await this.mm.getParty(partyId);
|
||||
if (!party) {
|
||||
client.send("party:error", { message: "房间已关闭" });
|
||||
return;
|
||||
}
|
||||
|
||||
const count = await this.mm.addPartyMember(partyId, {
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
joinedAtMs: Date.now(),
|
||||
});
|
||||
user.partyId = partyId;
|
||||
|
||||
const isLeader = party.leaderUserId === user.userId;
|
||||
client.send("party:joined", {
|
||||
partyId,
|
||||
partyCode,
|
||||
count,
|
||||
playersPerMatch: party.playersPerMatch,
|
||||
leaderUserId: party.leaderUserId,
|
||||
isLeader,
|
||||
});
|
||||
|
||||
// 广播 party 状态给当前在线成员(用于前端更新人数/房主标识)
|
||||
await this.broadcastPartyUpdate(partyId);
|
||||
});
|
||||
|
||||
this.onMessage("party:leave", async (client) => {
|
||||
const user = this.getUser(client);
|
||||
if (!user.partyId) {
|
||||
client.send("party:left", { ok: true });
|
||||
return;
|
||||
}
|
||||
const partyId = user.partyId;
|
||||
user.partyId = undefined;
|
||||
const remaining = await this.mm.removePartyMember(partyId, user.userId);
|
||||
if (remaining <= 0) {
|
||||
await this.mm.closeParty(partyId);
|
||||
} else {
|
||||
await this.broadcastPartyUpdate(partyId);
|
||||
}
|
||||
client.send("party:left", { ok: true });
|
||||
});
|
||||
|
||||
this.onMessage("party:start", async (client) => {
|
||||
const user = this.getUser(client);
|
||||
if (!user.partyId) {
|
||||
client.send("party:error", { message: "你当前不在房间队伍中" });
|
||||
return;
|
||||
}
|
||||
|
||||
const party = await this.mm.getParty(user.partyId);
|
||||
if (!party) {
|
||||
client.send("party:error", { message: "房间已关闭或过期" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (party.leaderUserId !== user.userId) {
|
||||
client.send("party:error", { message: "只有房主可以开始游戏" });
|
||||
return;
|
||||
}
|
||||
|
||||
const members = await this.mm.getPartyMemberInfos(user.partyId);
|
||||
if (members.length < party.playersPerMatch) {
|
||||
client.send("party:error", {
|
||||
message: `人数不足,当前 ${members.length}/${party.playersPerMatch}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.startMatchFromParty(user.partyId);
|
||||
});
|
||||
|
||||
// 定时扫:避免“只入队但没人触发 tryMatch”
|
||||
this.tick = setInterval(async () => {
|
||||
const seen = new Set<string>();
|
||||
for (const u of this.users.values()) {
|
||||
if (!u.queueKey || !u.playersPerMatch) continue;
|
||||
if (seen.has(u.queueKey)) continue;
|
||||
seen.add(u.queueKey);
|
||||
// 低频尝试,避免频繁抢锁
|
||||
await this.tryMatch(u.queueKey, u.playersPerMatch);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
void this.setupNotifyBridge();
|
||||
}
|
||||
|
||||
onJoin(client: Client, options: any) {
|
||||
// RequireAuth 会把 userId/username 写入 options
|
||||
const demoUserIdRaw = options?.demoUserId ? String(options.demoUserId).trim() : "";
|
||||
const effectiveUserId = demoUserIdRaw || String(options.userId);
|
||||
this.users.set(client.sessionId, {
|
||||
userId: effectiveUserId,
|
||||
demoUserId: demoUserIdRaw || undefined,
|
||||
username: options.username ? String(options.username) : undefined,
|
||||
});
|
||||
client.send("mm:ready", { sessionId: client.sessionId });
|
||||
}
|
||||
|
||||
async onLeave(client: Client) {
|
||||
const user = this.users.get(client.sessionId);
|
||||
if (user?.queueKey && user.ticketId) {
|
||||
await this.mm.cancelEnqueue(user.queueKey, user.ticketId);
|
||||
}
|
||||
if (user?.partyId) {
|
||||
const remaining = await this.mm.removePartyMember(user.partyId, user.userId);
|
||||
if (remaining > 0) {
|
||||
await this.broadcastPartyUpdate(user.partyId);
|
||||
} else {
|
||||
await this.mm.closeParty(user.partyId);
|
||||
}
|
||||
}
|
||||
this.users.delete(client.sessionId);
|
||||
}
|
||||
|
||||
onDispose() {
|
||||
if (this.tick) clearInterval(this.tick);
|
||||
void this.teardownNotifyBridge();
|
||||
}
|
||||
|
||||
private getUser(client: Client): SessionUser {
|
||||
const user = this.users.get(client.sessionId);
|
||||
if (!user) {
|
||||
throw new Error("user not found (did you join before sending messages?)");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private async tryMatch(queueKey: string, playersPerMatch: number) {
|
||||
const match = await this.mm.tryFormMatch(queueKey, playersPerMatch);
|
||||
if (!match) return;
|
||||
|
||||
// 创建对局房(复用 game_room)
|
||||
const roomName = "game_room";
|
||||
const room = await matchMaker.createRoom(roomName, {
|
||||
fps: 20,
|
||||
recordFrames: false,
|
||||
matchId: match.matchId,
|
||||
playersPerMatch,
|
||||
queueKey,
|
||||
});
|
||||
|
||||
await this.mm.updateMatchRoom(match.matchId, roomName, room.roomId);
|
||||
|
||||
const notifications = match.players.map((p) => ({
|
||||
userId: p.userId,
|
||||
sessionId: p.sessionId,
|
||||
payload: {
|
||||
roomName,
|
||||
roomId: room.roomId,
|
||||
matchId: match.matchId,
|
||||
seatIndex: p.seatIndex,
|
||||
reconnectKey: p.reconnectKey,
|
||||
joinOptions: {
|
||||
matchId: match.matchId,
|
||||
seatIndex: p.seatIndex,
|
||||
reconnectKey: p.reconnectKey,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// 本实例直发 + Redis Pub/Sub 广播(跨实例转发到在线用户)
|
||||
this.deliverNotifications(notifications);
|
||||
await this.publishNotify({ originRoomId: this.roomId, type: "match:found", notifications });
|
||||
}
|
||||
|
||||
private async startMatchFromParty(partyId: string) {
|
||||
const party = await this.mm.getParty(partyId);
|
||||
if (!party) return;
|
||||
|
||||
const members = await this.mm.getPartyMemberInfos(partyId);
|
||||
if (members.length < party.playersPerMatch) return;
|
||||
|
||||
const matchId = crypto.randomUUID();
|
||||
const roomName = "game_room";
|
||||
const room = await matchMaker.createRoom(roomName, {
|
||||
fps: 20,
|
||||
recordFrames: false,
|
||||
matchId,
|
||||
playersPerMatch: party.playersPerMatch,
|
||||
partyId,
|
||||
});
|
||||
|
||||
// 通知 party 内在线用户加入
|
||||
const notifications = members.slice(0, party.playersPerMatch).map((m, i) => ({
|
||||
userId: m.userId,
|
||||
sessionId: this.findSessionIdByUserId(m.userId) || "",
|
||||
payload: {
|
||||
roomName,
|
||||
roomId: room.roomId,
|
||||
matchId,
|
||||
seatIndex: i,
|
||||
reconnectKey: crypto.randomUUID(),
|
||||
joinOptions: { matchId, seatIndex: i, partyId },
|
||||
},
|
||||
}));
|
||||
|
||||
this.deliverNotifications(notifications);
|
||||
await this.publishNotify({ originRoomId: this.roomId, type: "match:found", notifications });
|
||||
|
||||
await this.mm.closeParty(partyId);
|
||||
}
|
||||
|
||||
private async broadcastPartyUpdate(partyId: string) {
|
||||
const party = await this.mm.getParty(partyId);
|
||||
if (!party) return;
|
||||
const members = await this.mm.getPartyMembers(partyId);
|
||||
const count = members.length;
|
||||
for (const userId of members) {
|
||||
const targetClient = this.findClientByUserId(userId);
|
||||
if (!targetClient) continue;
|
||||
targetClient.send("party:update", {
|
||||
partyId,
|
||||
count,
|
||||
playersPerMatch: party.playersPerMatch,
|
||||
leaderUserId: party.leaderUserId,
|
||||
isLeader: party.leaderUserId === userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private findClientByUserId(userId: string): Client | null {
|
||||
for (const [sessionId, u] of this.users.entries()) {
|
||||
if (u.userId === userId) {
|
||||
return this.clients.find((c) => c.sessionId === sessionId) || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private deliverNotifications(
|
||||
notifications: Array<{ userId: string; sessionId?: string; payload: any }>
|
||||
) {
|
||||
for (const n of notifications) {
|
||||
const targetClient =
|
||||
(n.sessionId ? this.clients.find((c) => c.sessionId === n.sessionId) || null : null) ||
|
||||
this.findClientByUserId(n.userId);
|
||||
if (!targetClient) continue;
|
||||
targetClient.send("match:found", n.payload);
|
||||
}
|
||||
}
|
||||
|
||||
private async publishNotify(envelope: any) {
|
||||
try {
|
||||
const redis = RedisClient.getInstance();
|
||||
await redis.connect();
|
||||
const client = redis.getClient();
|
||||
await client.publish(this.notifyChannel, JSON.stringify(envelope));
|
||||
} catch (e: any) {
|
||||
console.warn("[MatchmakerRoom] Redis notify publish 失败:", e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
private async setupNotifyBridge() {
|
||||
try {
|
||||
const redis = RedisClient.getInstance();
|
||||
await redis.connect();
|
||||
const dup = redis.getClient().duplicate();
|
||||
this.redisSub = dup;
|
||||
dup.on("error", (err: Error) => {
|
||||
console.error("[MatchmakerRoom] Redis notify 订阅连接错误:", err.message);
|
||||
});
|
||||
await dup.subscribe(this.notifyChannel);
|
||||
dup.on("message", (_channel: string, message: string) => {
|
||||
try {
|
||||
const data = JSON.parse(message) as any;
|
||||
if (data?.originRoomId && data.originRoomId === this.roomId) return;
|
||||
if (data?.type !== "match:found") return;
|
||||
const notifications = Array.isArray(data.notifications) ? data.notifications : [];
|
||||
this.deliverNotifications(
|
||||
notifications.map((n: any) => ({
|
||||
userId: String(n.userId),
|
||||
sessionId: n.sessionId ? String(n.sessionId) : undefined,
|
||||
payload: n.payload,
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
/* ignore malformed */
|
||||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.warn(
|
||||
"[MatchmakerRoom] Redis notify 桥接未启用(仅同实例可收 match:found):",
|
||||
e?.message || e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async teardownNotifyBridge() {
|
||||
if (!this.redisSub) return;
|
||||
const sub = this.redisSub;
|
||||
this.redisSub = null;
|
||||
try {
|
||||
await sub.unsubscribe(this.notifyChannel);
|
||||
await sub.quit();
|
||||
} catch {
|
||||
try {
|
||||
sub.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findSessionIdByUserId(userId: string): string | null {
|
||||
for (const [sessionId, u] of this.users.entries()) {
|
||||
if (u.userId === userId) return sessionId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import { Schema, type, MapSchema } from "@colyseus/schema";
|
||||
|
||||
export class Player extends Schema {
|
||||
@type("string") sessionId: string = "";
|
||||
@type("string") userId: string = "";
|
||||
@type("number") seatIndex: number = -1;
|
||||
@type("boolean") online: boolean = true;
|
||||
@type("number") x: number = 0;
|
||||
@type("number") y: number = 0;
|
||||
@type("boolean") attacking: boolean = false;
|
||||
|
||||
@@ -110,7 +110,17 @@ export class AuthService {
|
||||
// 查找用户(包含密码字段)
|
||||
const user = await this.userRepository.findOne({
|
||||
where: [{ username: dto.username }, { email: dto.username }],
|
||||
select: ['id', 'username', 'email', 'password', 'nickname', 'avatar', 'status'],
|
||||
select: [
|
||||
'id',
|
||||
'username',
|
||||
'email',
|
||||
'password',
|
||||
'nickname',
|
||||
'avatar',
|
||||
'openid',
|
||||
'guildId',
|
||||
'status',
|
||||
],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
|
||||
265
src/services/matchmaking/MatchmakingService.ts
Normal file
265
src/services/matchmaking/MatchmakingService.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import crypto from "crypto";
|
||||
import RedisClient from "../../utils/redis";
|
||||
|
||||
export interface QueueTicket {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
username?: string;
|
||||
modeId: string;
|
||||
playersPerMatch: number;
|
||||
region?: string;
|
||||
skill?: number;
|
||||
tags?: string[];
|
||||
enqueueAtMs: number;
|
||||
}
|
||||
|
||||
export interface PartyInfo {
|
||||
partyId: string;
|
||||
partyCode: string;
|
||||
modeId: string;
|
||||
playersPerMatch: number;
|
||||
region?: string;
|
||||
leaderUserId: string;
|
||||
createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface PartyMemberInfo {
|
||||
userId: string;
|
||||
username?: string;
|
||||
joinedAtMs: number;
|
||||
}
|
||||
|
||||
export interface MatchInfo {
|
||||
matchId: string;
|
||||
queueKey: string;
|
||||
createdAtMs: number;
|
||||
players: Array<{
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
username?: string;
|
||||
seatIndex: number;
|
||||
reconnectKey: string;
|
||||
}>;
|
||||
roomName?: string;
|
||||
roomId?: string;
|
||||
status: "created" | "room_created" | "closed";
|
||||
}
|
||||
|
||||
function clampInt(n: any, min: number, max: number) {
|
||||
const v = Number.parseInt(String(n), 10);
|
||||
if (Number.isNaN(v)) return min;
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
|
||||
export class MatchmakingService {
|
||||
private redis = RedisClient.getInstance();
|
||||
|
||||
/** queueKey 必须包含 playersPerMatch,确保同队列人数一致 */
|
||||
getQueueKey(params: { modeId: string; playersPerMatch: number; region?: string }) {
|
||||
const playersPerMatch = clampInt(params.playersPerMatch, 2, 100);
|
||||
const region = params.region ? String(params.region) : "global";
|
||||
return `queue:${params.modeId}:${playersPerMatch}:${region}`;
|
||||
}
|
||||
|
||||
private async getRawRedis() {
|
||||
const client = this.redis.getClient();
|
||||
return client;
|
||||
}
|
||||
|
||||
async enqueue(ticket: QueueTicket): Promise<{ queueKey: string; ticketId: string }> {
|
||||
const client = await this.getRawRedis();
|
||||
const queueKey = this.getQueueKey(ticket);
|
||||
const ticketId = crypto.randomUUID();
|
||||
const ticketKey = `mm:ticket:${ticketId}`;
|
||||
|
||||
const payload = {
|
||||
...ticket,
|
||||
ticketId,
|
||||
};
|
||||
|
||||
// ticket 保存 10 分钟,便于查找/取消/调试
|
||||
await client
|
||||
.multi()
|
||||
.set(ticketKey, JSON.stringify(payload), "EX", 60 * 10)
|
||||
// ZSET: score 使用时间戳,后续可改为匹配分数
|
||||
.zadd(queueKey, String(ticket.enqueueAtMs), ticketId)
|
||||
.exec();
|
||||
|
||||
return { queueKey, ticketId };
|
||||
}
|
||||
|
||||
async cancelEnqueue(queueKey: string, ticketId: string): Promise<boolean> {
|
||||
const client = await this.getRawRedis();
|
||||
const ticketKey = `mm:ticket:${ticketId}`;
|
||||
const res = await client.multi().zrem(queueKey, ticketId).del(ticketKey).exec();
|
||||
const zrem = Number(res?.[0]?.[1] ?? 0);
|
||||
return zrem > 0;
|
||||
}
|
||||
|
||||
async tryFormMatch(queueKey: string, playersPerMatch: number): Promise<MatchInfo | null> {
|
||||
const client = await this.getRawRedis();
|
||||
const lockKey = `mm:lock:${queueKey}`;
|
||||
const lockId = crypto.randomUUID();
|
||||
|
||||
// 短锁,避免多实例重复出队组局
|
||||
const lockOk = await client.set(lockKey, lockId, "PX", 2000, "NX");
|
||||
if (!lockOk) return null;
|
||||
|
||||
try {
|
||||
const n = clampInt(playersPerMatch, 2, 100);
|
||||
const lua = `
|
||||
local queueKey = KEYS[1]
|
||||
local n = tonumber(ARGV[1])
|
||||
local ids = redis.call('ZRANGE', queueKey, 0, n - 1)
|
||||
if (#ids < n) then
|
||||
return {}
|
||||
end
|
||||
redis.call('ZREM', queueKey, unpack(ids))
|
||||
return ids
|
||||
`;
|
||||
const ids = (await client.eval(lua, 1, queueKey, String(n))) as string[];
|
||||
if (!ids || ids.length !== n) return null;
|
||||
|
||||
const tickets = await Promise.all(ids.map((id) => client.get(`mm:ticket:${id}`)));
|
||||
const players = tickets
|
||||
.map((t) => {
|
||||
if (!t) return null;
|
||||
try {
|
||||
return JSON.parse(t) as QueueTicket & { ticketId: string };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as Array<QueueTicket & { ticketId: string }>;
|
||||
|
||||
if (players.length !== n) {
|
||||
// 有坏数据就放弃本次组局(简单起见);可扩展为回滚
|
||||
return null;
|
||||
}
|
||||
|
||||
const matchId = crypto.randomUUID();
|
||||
const createdAtMs = Date.now();
|
||||
const match: MatchInfo = {
|
||||
matchId,
|
||||
queueKey,
|
||||
createdAtMs,
|
||||
status: "created",
|
||||
players: players.map((p, idx) => ({
|
||||
userId: p.userId,
|
||||
sessionId: p.sessionId,
|
||||
username: p.username,
|
||||
seatIndex: idx,
|
||||
reconnectKey: crypto.randomUUID(),
|
||||
})),
|
||||
};
|
||||
|
||||
await client.set(`mm:match:${matchId}`, JSON.stringify(match), "EX", 60 * 10);
|
||||
// 清理 ticket(避免重复用)
|
||||
await Promise.all(ids.map((id) => client.del(`mm:ticket:${id}`)));
|
||||
|
||||
return match;
|
||||
} finally {
|
||||
// 只在锁仍属于自己时释放(简单校验)
|
||||
const val = await client.get(lockKey);
|
||||
if (val === lockId) {
|
||||
await client.del(lockKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateMatchRoom(matchId: string, roomName: string, roomId: string) {
|
||||
const client = await this.getRawRedis();
|
||||
const raw = await client.get(`mm:match:${matchId}`);
|
||||
if (!raw) return;
|
||||
let match: MatchInfo;
|
||||
try {
|
||||
match = JSON.parse(raw) as MatchInfo;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
match.roomName = roomName;
|
||||
match.roomId = roomId;
|
||||
match.status = "room_created";
|
||||
await client.set(`mm:match:${matchId}`, JSON.stringify(match), "EX", 60 * 10);
|
||||
}
|
||||
|
||||
async createParty(params: PartyInfo): Promise<void> {
|
||||
const client = await this.getRawRedis();
|
||||
await client.set(`mm:party:${params.partyId}`, JSON.stringify(params), "EX", 60 * 30);
|
||||
await client.sadd(`mm:party:${params.partyId}:members`, params.leaderUserId);
|
||||
await client.hset(
|
||||
`mm:party:${params.partyId}:memberInfo`,
|
||||
params.leaderUserId,
|
||||
JSON.stringify({
|
||||
userId: params.leaderUserId,
|
||||
joinedAtMs: Date.now(),
|
||||
})
|
||||
);
|
||||
await client.expire(`mm:party:${params.partyId}:members`, 60 * 30);
|
||||
await client.expire(`mm:party:${params.partyId}:memberInfo`, 60 * 30);
|
||||
await client.set(`mm:partyCode:${params.partyCode}`, params.partyId, "EX", 60 * 30);
|
||||
}
|
||||
|
||||
async getPartyIdByCode(partyCode: string): Promise<string | null> {
|
||||
const client = await this.getRawRedis();
|
||||
return await client.get(`mm:partyCode:${partyCode}`);
|
||||
}
|
||||
|
||||
async getParty(partyId: string): Promise<PartyInfo | null> {
|
||||
const client = await this.getRawRedis();
|
||||
const raw = await client.get(`mm:party:${partyId}`);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as PartyInfo;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async addPartyMember(partyId: string, member: PartyMemberInfo): Promise<number> {
|
||||
const client = await this.getRawRedis();
|
||||
await client.sadd(`mm:party:${partyId}:members`, member.userId);
|
||||
await client.hset(`mm:party:${partyId}:memberInfo`, member.userId, JSON.stringify(member));
|
||||
return await client.scard(`mm:party:${partyId}:members`);
|
||||
}
|
||||
|
||||
async getPartyMemberInfos(partyId: string): Promise<PartyMemberInfo[]> {
|
||||
const client = await this.getRawRedis();
|
||||
const raw = await client.hgetall(`mm:party:${partyId}:memberInfo`);
|
||||
const out: PartyMemberInfo[] = [];
|
||||
for (const v of Object.values(raw)) {
|
||||
try {
|
||||
out.push(JSON.parse(v) as PartyMemberInfo);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
// 稳定排序:先 joinedAtMs,再 userId
|
||||
out.sort((a, b) => (a.joinedAtMs - b.joinedAtMs) || a.userId.localeCompare(b.userId));
|
||||
return out;
|
||||
}
|
||||
|
||||
async removePartyMember(partyId: string, userId: string): Promise<number> {
|
||||
const client = await this.getRawRedis();
|
||||
await client.srem(`mm:party:${partyId}:members`, userId);
|
||||
await client.hdel(`mm:party:${partyId}:memberInfo`, userId);
|
||||
return await client.scard(`mm:party:${partyId}:members`);
|
||||
}
|
||||
|
||||
async getPartyMembers(partyId: string): Promise<string[]> {
|
||||
const client = await this.getRawRedis();
|
||||
return await client.smembers(`mm:party:${partyId}:members`);
|
||||
}
|
||||
|
||||
async closeParty(partyId: string) {
|
||||
const client = await this.getRawRedis();
|
||||
const party = await this.getParty(partyId);
|
||||
await client.del(`mm:party:${partyId}`);
|
||||
await client.del(`mm:party:${partyId}:members`);
|
||||
await client.del(`mm:party:${partyId}:memberInfo`);
|
||||
if (party?.partyCode) {
|
||||
await client.del(`mm:partyCode:${party.partyCode}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
33
src/services/matchmaking/types.ts
Normal file
33
src/services/matchmaking/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export type MatchMode = "active" | "passive";
|
||||
|
||||
export interface MatchFindRequest {
|
||||
modeId: string;
|
||||
/** 接入方决定一局人数 */
|
||||
playersPerMatch: number;
|
||||
/** 可选:段位/分数,用于后续扩展分段匹配 */
|
||||
skill?: number;
|
||||
/** 可选:大区 */
|
||||
region?: string;
|
||||
/** 可选:附加过滤维度 */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface PartyCreateRequest {
|
||||
modeId: string;
|
||||
playersPerMatch: number;
|
||||
region?: string;
|
||||
}
|
||||
|
||||
export interface PartyJoinRequest {
|
||||
partyCode: string;
|
||||
}
|
||||
|
||||
export interface MatchFoundPayload {
|
||||
roomName: string;
|
||||
roomId: string;
|
||||
matchId: string;
|
||||
seatIndex: number;
|
||||
reconnectKey: string;
|
||||
joinOptions: Record<string, any>;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ export function RequireAuth() {
|
||||
const redis = RedisClient.getInstance();
|
||||
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
// 单元测试环境跳过鉴权(测试通常不依赖真实 JWT/Redis)
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
return await originalMethod.apply(this, args);
|
||||
}
|
||||
|
||||
// 对于onJoin,第一个参数是client,第二个是options
|
||||
// 对于onCreate,第一个参数是options
|
||||
let options: any;
|
||||
|
||||
52
src/utils/log.ts
Normal file
52
src/utils/log.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
type LogLevel = "log" | "info" | "warn" | "error";
|
||||
|
||||
const BLUE = "\x1b[34m";
|
||||
const RESET = "\x1b[0m";
|
||||
|
||||
function prefix(category: string) {
|
||||
const cat = category && String(category).trim() ? String(category).trim() : "系统";
|
||||
return `${BLUE}[xymj][${cat}]${RESET}`;
|
||||
}
|
||||
|
||||
export function formatLogArgs(category: string, args: any[]) {
|
||||
// 保证 prefix 独立一个参数,避免影响原本对象打印
|
||||
return [prefix(category), ...args];
|
||||
}
|
||||
|
||||
export function installConsolePrefix(defaultCategory = "系统") {
|
||||
const orig: Record<LogLevel, (...args: any[]) => void> = {
|
||||
log: console.log.bind(console),
|
||||
info: console.info.bind(console),
|
||||
warn: console.warn.bind(console),
|
||||
error: console.error.bind(console),
|
||||
};
|
||||
|
||||
const wrap =
|
||||
(level: LogLevel) =>
|
||||
(...args: any[]) => {
|
||||
// 允许调用方用 console.log("[数据库]", "xxx") 这种方式指定类别
|
||||
let category = defaultCategory;
|
||||
if (typeof args[0] === "string") {
|
||||
const m = args[0].match(/^\s*\[([^\]]+)\]\s*/);
|
||||
if (m) {
|
||||
category = m[1];
|
||||
args = [args[0].replace(m[0], ""), ...args.slice(1)];
|
||||
if (args[0] === "") args = args.slice(1);
|
||||
}
|
||||
}
|
||||
orig[level](...formatLogArgs(category, args));
|
||||
};
|
||||
|
||||
console.log = wrap("log") as any;
|
||||
console.info = wrap("info") as any;
|
||||
console.warn = wrap("warn") as any;
|
||||
console.error = wrap("error") as any;
|
||||
|
||||
return () => {
|
||||
console.log = orig.log as any;
|
||||
console.info = orig.info as any;
|
||||
console.warn = orig.warn as any;
|
||||
console.error = orig.error as any;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,16 +47,16 @@ class RedisClient {
|
||||
});
|
||||
|
||||
this.client.on('connect', () => {
|
||||
console.log('✅ Redis 连接成功');
|
||||
console.log('[数据库]✅ Redis 连接成功');
|
||||
});
|
||||
|
||||
this.client.on('error', (err: any) => {
|
||||
const errorMsg = err?.message || err?.code || '未知错误';
|
||||
console.error(`❌ Redis 连接错误: ${errorMsg}`);
|
||||
console.error(`[数据库]❌ Redis 连接错误: ${errorMsg}`);
|
||||
});
|
||||
|
||||
this.client.on('close', () => {
|
||||
console.log('✅ Redis 连接已关闭');
|
||||
console.log('[数据库]✅ Redis 连接已关闭');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -23,9 +23,14 @@ describe("testing your Colyseus app", () => {
|
||||
// make your assertions
|
||||
assert.strictEqual(client1.sessionId, room.clients[0].sessionId);
|
||||
|
||||
// wait for state sync
|
||||
await room.waitForNextPatch();
|
||||
// wait for state sync (client-side)
|
||||
// (initial patch may arrive before we start awaiting)
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
assert.deepStrictEqual({ mySynchronizedProperty: "Hello world" }, client1.state.toJSON());
|
||||
assert.deepStrictEqual(client1.state.toJSON(), {
|
||||
mySynchronizedProperty: "Hello world",
|
||||
frame: 0,
|
||||
players: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user