drgame-cc 1.0.52 → 1.0.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/drscript/Define.ts +91 -5
- package/drscript/KeyAdapter.ts +11 -0
- package/drscript/PlatformAdapter.ts +6 -6
- package/drscript/README.md +1 -7
- package/drscript/component/ExitWin.ts +0 -4
- package/drscript/virtualgamepad/VirtualGamepad.ts +638 -0
- package/drscript/virtualgamepad/VirtualGamepadView.ts +311 -280
- package/package.json +1 -1
- package/drscript/virtualgamepad/VirtualGamepadClient.ts +0 -214
- package/drscript/virtualgamepad/VirtualGamepadConfig.ts +0 -70
- package/drscript/virtualgamepad/VirtualGamepadManager.ts +0 -809
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
// ========================================================================
|
|
2
|
+
// VirtualGamepad — WebSocket 客户端 + 手柄输入管理器
|
|
3
|
+
// ========================================================================
|
|
4
|
+
|
|
5
|
+
// ==================== 配置常量 ====================
|
|
6
|
+
|
|
7
|
+
export const VIRTUAL_GAMEPAD_GAME_ID = "rexueqiangshou";
|
|
8
|
+
export const VIRTUAL_GAMEPAD_PLAYER_ID = "player1";
|
|
9
|
+
export const VIRTUAL_GAMEPAD_DEVELOPMENT_WS_URL = "ws://10.40.92.42:3000";
|
|
10
|
+
export const VIRTUAL_GAMEPAD_PRODUCTION_WS_URL = "wss://hyp.huyingpai.com/huyingpai/lewo/ws";
|
|
11
|
+
export const VIRTUAL_GAMEPAD_H5_URL = "https://docp.daoran.tv/vconsole/index.html";
|
|
12
|
+
export const VIRTUAL_GAMEPAD_DEVELOPMENT_WXACODE_URL = "https://hyp.huyingpai.com/API_USER_CS/wxacode/unlimited";
|
|
13
|
+
export const VIRTUAL_GAMEPAD_PRODUCTION_WXACODE_URL = "https://hyp.huyingpai.com/API_USER/wxacode/unlimited";
|
|
14
|
+
export const VIRTUAL_GAMEPAD_WXACODE_PAGE = "pages/gamepad/gamepad";
|
|
15
|
+
export const VIRTUAL_GAMEPAD_WXACODE_PROJECT_CODE = "lewo";
|
|
16
|
+
export const VIRTUAL_GAMEPAD_MAX_MEMBERS = 2;
|
|
17
|
+
export const VIRTUAL_GAMEPAD_STOP_TIMEOUT_MS = 500;
|
|
18
|
+
|
|
19
|
+
export type VirtualGamepadEnvironment = "development" | "production";
|
|
20
|
+
export const VIRTUAL_GAMEPAD_DEFAULT_ENVIRONMENT: VirtualGamepadEnvironment = "production";
|
|
21
|
+
export type VirtualGamepadQrMode = "h5" | "json";
|
|
22
|
+
export const VIRTUAL_GAMEPAD_QR_MODE: VirtualGamepadQrMode = "h5";
|
|
23
|
+
|
|
24
|
+
declare const global: any;
|
|
25
|
+
|
|
26
|
+
function getRuntimeValue(key: string): string {
|
|
27
|
+
let runtimeRoot: any = typeof window != "undefined" ? window : (typeof global != "undefined" ? global : null);
|
|
28
|
+
if (runtimeRoot && runtimeRoot[key]) return String(runtimeRoot[key]);
|
|
29
|
+
try {
|
|
30
|
+
if (cc && cc.sys && cc.sys.localStorage) {
|
|
31
|
+
return cc.sys.localStorage.getItem(key) || "";
|
|
32
|
+
}
|
|
33
|
+
} catch (e) { /* ignore */ }
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getWsUrl(): string {
|
|
38
|
+
return getRuntimeValue("RXQS_GAMEPAD_WS_URL") ||
|
|
39
|
+
(getEnvironment() == "production" ? VIRTUAL_GAMEPAD_PRODUCTION_WS_URL : VIRTUAL_GAMEPAD_DEVELOPMENT_WS_URL);
|
|
40
|
+
}
|
|
41
|
+
function getWxacodeUrl(): string {
|
|
42
|
+
return getRuntimeValue("RXQS_GAMEPAD_WXACODE_URL") ||
|
|
43
|
+
(getEnvironment() == "production" ? VIRTUAL_GAMEPAD_PRODUCTION_WXACODE_URL : VIRTUAL_GAMEPAD_DEVELOPMENT_WXACODE_URL);
|
|
44
|
+
}
|
|
45
|
+
function getEnvironment(): VirtualGamepadEnvironment {
|
|
46
|
+
let env = getRuntimeValue("RXQS_GAMEPAD_ENVIRONMENT");
|
|
47
|
+
if (env == "development" || env == "production") return env;
|
|
48
|
+
return VIRTUAL_GAMEPAD_DEFAULT_ENVIRONMENT;
|
|
49
|
+
}
|
|
50
|
+
function getH5Url(): string { return getRuntimeValue("RXQS_GAMEPAD_H5_URL") || VIRTUAL_GAMEPAD_H5_URL; }
|
|
51
|
+
function getRoomId(): string { return getRuntimeValue("RXQS_GAMEPAD_ROOM_ID"); }
|
|
52
|
+
function getToken(): string { return getRuntimeValue("RXQS_GAMEPAD_TOKEN"); }
|
|
53
|
+
function getQrMode(): VirtualGamepadQrMode {
|
|
54
|
+
let mode = getRuntimeValue("RXQS_GAMEPAD_QR_MODE");
|
|
55
|
+
return mode == "json" ? "json" : VIRTUAL_GAMEPAD_QR_MODE;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ==================== Client ====================
|
|
59
|
+
|
|
60
|
+
export type VirtualGamepadConnectionState = "idle" | "connecting" | "open" | "joined" | "closed" | "error";
|
|
61
|
+
|
|
62
|
+
interface ClientOptions {
|
|
63
|
+
wsUrl: string;
|
|
64
|
+
roomId: string;
|
|
65
|
+
deviceId: string;
|
|
66
|
+
game: string;
|
|
67
|
+
userId: string;
|
|
68
|
+
userType: "xiaomi" | "guest";
|
|
69
|
+
guestId?: string;
|
|
70
|
+
token?: string;
|
|
71
|
+
maxMembers: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class Client {
|
|
75
|
+
private socket: WebSocket | null = null;
|
|
76
|
+
private options: ClientOptions | null = null;
|
|
77
|
+
private manualClose: boolean = false;
|
|
78
|
+
private reconnectCount: number = 0;
|
|
79
|
+
private heartbeatTimer: any = -1;
|
|
80
|
+
private reconnectTimer: any = -1;
|
|
81
|
+
private readonly maxReconnectCount: number = 3;
|
|
82
|
+
private readonly heartbeatMs: number = 10000;
|
|
83
|
+
|
|
84
|
+
public onStateChange: ((state: VirtualGamepadConnectionState, reason?: string) => void) | null = null;
|
|
85
|
+
public onMessage: ((message: any) => void) | null = null;
|
|
86
|
+
|
|
87
|
+
public connect(options: ClientOptions) {
|
|
88
|
+
this.options = options;
|
|
89
|
+
this.manualClose = false;
|
|
90
|
+
this.clearReconnect();
|
|
91
|
+
this.openSocket();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
public close() {
|
|
95
|
+
this.manualClose = true;
|
|
96
|
+
this.clearHeartbeat();
|
|
97
|
+
this.clearReconnect();
|
|
98
|
+
if (this.socket) {
|
|
99
|
+
this.socket.onopen = null;
|
|
100
|
+
this.socket.onmessage = null;
|
|
101
|
+
this.socket.onclose = null;
|
|
102
|
+
this.socket.onerror = null;
|
|
103
|
+
try { this.socket.close(); } catch (e) { /* ignore */ }
|
|
104
|
+
}
|
|
105
|
+
this.socket = null;
|
|
106
|
+
this.emitState("closed");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
public send(message: any): boolean {
|
|
110
|
+
if (!this.socket || this.socket.readyState != WebSocket.OPEN) return false;
|
|
111
|
+
try { this.socket.send(JSON.stringify(message)); return true; } catch (e) { return false; }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private openSocket() {
|
|
115
|
+
if (!this.options || !this.options.wsUrl) return;
|
|
116
|
+
this.clearHeartbeat();
|
|
117
|
+
this.emitState("connecting");
|
|
118
|
+
let opts = this.options;
|
|
119
|
+
let query: Record<string, string> = {
|
|
120
|
+
roomId: opts.roomId, deviceId: opts.deviceId, role: "game",
|
|
121
|
+
userId: opts.userId, userType: opts.userType, maxMembers: String(opts.maxMembers)
|
|
122
|
+
};
|
|
123
|
+
if (opts.guestId) query.guestId = opts.guestId;
|
|
124
|
+
if (opts.token) query.token = opts.token;
|
|
125
|
+
let url = this.appendParams(opts.wsUrl, query);
|
|
126
|
+
try {
|
|
127
|
+
this.socket = new WebSocket(url);
|
|
128
|
+
this.socket.onopen = this.handleOpen.bind(this);
|
|
129
|
+
this.socket.onmessage = this.handleMessage.bind(this);
|
|
130
|
+
this.socket.onclose = this.handleClose.bind(this);
|
|
131
|
+
this.socket.onerror = this.handleError.bind(this);
|
|
132
|
+
} catch (e) {
|
|
133
|
+
this.handleError(e);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private handleOpen() {
|
|
138
|
+
if (!this.options) return;
|
|
139
|
+
this.reconnectCount = 0;
|
|
140
|
+
this.emitState("open");
|
|
141
|
+
let msg: any = {
|
|
142
|
+
type: "join", roomId: this.options.roomId, role: "game",
|
|
143
|
+
deviceId: this.options.deviceId, userId: this.options.userId,
|
|
144
|
+
userType: this.options.userType, maxMembers: this.options.maxMembers, game: this.options.game
|
|
145
|
+
};
|
|
146
|
+
if (this.options.guestId) msg.guestId = this.options.guestId;
|
|
147
|
+
if (this.options.token) msg.token = this.options.token;
|
|
148
|
+
console.log("[VirtualGamepad][WSS][Send] join", { type: msg.type, roomId: msg.roomId, role: msg.role, deviceId: msg.deviceId, userId: msg.userId, userType: msg.userType, guestId: msg.guestId || "", maxMembers: msg.maxMembers, game: msg.game, hasToken: !!msg.token });
|
|
149
|
+
this.send(msg);
|
|
150
|
+
this.startHeartbeat();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private handleMessage(event: MessageEvent) {
|
|
154
|
+
let data = event && event.data;
|
|
155
|
+
let message: any = null;
|
|
156
|
+
try { message = typeof data == "string" ? JSON.parse(data) : data; } catch (e) { return; }
|
|
157
|
+
if (!message) return;
|
|
158
|
+
if (message.type != "pong") console.log("[VirtualGamepad][WSS][Receive]", message);
|
|
159
|
+
if (message.type == "joined" || message.type == "welcome") this.emitState("joined");
|
|
160
|
+
if (this.onMessage) this.onMessage(message);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private handleClose(event?: any) {
|
|
164
|
+
this.clearHeartbeat();
|
|
165
|
+
this.socket = null;
|
|
166
|
+
this.emitState("closed", event && event.reason);
|
|
167
|
+
if (!this.manualClose) this.scheduleReconnect();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private handleError(event?: any) {
|
|
171
|
+
this.emitState("error", event && event.message);
|
|
172
|
+
if (!this.manualClose && (!this.socket || this.socket.readyState == WebSocket.CLOSED)) this.scheduleReconnect();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private startHeartbeat() {
|
|
176
|
+
this.clearHeartbeat();
|
|
177
|
+
this.heartbeatTimer = setInterval(() => {
|
|
178
|
+
if (!this.options) return;
|
|
179
|
+
this.send({ type: "ping", roomId: this.options.roomId, deviceId: this.options.deviceId, role: "game", timestamp: Date.now(), ts: Date.now() });
|
|
180
|
+
}, this.heartbeatMs);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private scheduleReconnect() {
|
|
184
|
+
if (this.reconnectCount >= this.maxReconnectCount) return;
|
|
185
|
+
this.clearReconnect();
|
|
186
|
+
this.reconnectCount++;
|
|
187
|
+
this.reconnectTimer = setTimeout(() => this.openSocket(), 1200 * this.reconnectCount);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private appendParams(url: string, params: Record<string, string>): string {
|
|
191
|
+
let q = Object.keys(params).map(k => encodeURIComponent(k) + "=" + encodeURIComponent(params[k] || "")).join("&");
|
|
192
|
+
return url + (url.indexOf("?") >= 0 ? "&" : "?") + q;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private emitState(state: VirtualGamepadConnectionState, reason?: string) {
|
|
196
|
+
if (this.onStateChange) this.onStateChange(state, reason);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private clearHeartbeat() {
|
|
200
|
+
if (this.heartbeatTimer != -1) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = -1; }
|
|
201
|
+
}
|
|
202
|
+
private clearReconnect() {
|
|
203
|
+
if (this.reconnectTimer != -1) { clearTimeout(this.reconnectTimer); this.reconnectTimer = -1; }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ==================== Manager ====================
|
|
208
|
+
|
|
209
|
+
export type VirtualGamepadGameState = "ready" | "playing" | "paused" | "ended";
|
|
210
|
+
export type VirtualGamepadUserType = "xiaomi" | "guest";
|
|
211
|
+
export type VirtualGamepadSunCodeState = "idle" | "loading" | "ready" | "error";
|
|
212
|
+
|
|
213
|
+
export interface VirtualGamepadIdentity {
|
|
214
|
+
userId: string;
|
|
215
|
+
userType: VirtualGamepadUserType;
|
|
216
|
+
guestId?: string;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface VirtualGamepadMoveInput {
|
|
220
|
+
x: number;
|
|
221
|
+
y: number;
|
|
222
|
+
seq?: number;
|
|
223
|
+
ts?: number;
|
|
224
|
+
raw?: any;
|
|
225
|
+
holdMode?: boolean;
|
|
226
|
+
stopTimeoutMs?: number;
|
|
227
|
+
side?: string;
|
|
228
|
+
direction?: string;
|
|
229
|
+
action?: string;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export type VirtualGamepadControlType = "stick" | "button" | "shoulder" | "trigger" | "back";
|
|
233
|
+
|
|
234
|
+
export interface VirtualGamepadControlInput {
|
|
235
|
+
type: VirtualGamepadControlType;
|
|
236
|
+
action: string;
|
|
237
|
+
pressed: boolean;
|
|
238
|
+
side?: string;
|
|
239
|
+
key?: string;
|
|
240
|
+
direction?: string;
|
|
241
|
+
x?: number;
|
|
242
|
+
y?: number;
|
|
243
|
+
playerId?: string;
|
|
244
|
+
seq?: number;
|
|
245
|
+
ts?: number;
|
|
246
|
+
raw?: any;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface VirtualGamepadStatus {
|
|
250
|
+
connectionState: VirtualGamepadConnectionState;
|
|
251
|
+
controllerConnected: boolean;
|
|
252
|
+
roomId: string;
|
|
253
|
+
deviceId: string;
|
|
254
|
+
userId: string;
|
|
255
|
+
userType: VirtualGamepadUserType;
|
|
256
|
+
identityText: string;
|
|
257
|
+
hasToken: boolean;
|
|
258
|
+
maxMembers: number;
|
|
259
|
+
sunCodeState: VirtualGamepadSunCodeState;
|
|
260
|
+
sunCodeDataUrl: string;
|
|
261
|
+
sunCodeError?: string;
|
|
262
|
+
h5Url: string;
|
|
263
|
+
qrPayload: string;
|
|
264
|
+
statusText: string;
|
|
265
|
+
lastError?: string;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
type MoveListener = (input: VirtualGamepadMoveInput) => void;
|
|
269
|
+
type StatusListener = (status: VirtualGamepadStatus) => void;
|
|
270
|
+
type ControlListener = (input: VirtualGamepadControlInput) => void;
|
|
271
|
+
|
|
272
|
+
interface ListenerMap {
|
|
273
|
+
move: MoveListener;
|
|
274
|
+
status: StatusListener;
|
|
275
|
+
button: ControlListener;
|
|
276
|
+
shoulder: ControlListener;
|
|
277
|
+
trigger: ControlListener;
|
|
278
|
+
back: ControlListener;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export default class VirtualGamepadManager {
|
|
282
|
+
private static _instance: VirtualGamepadManager | null = null;
|
|
283
|
+
|
|
284
|
+
public static getInstance(): VirtualGamepadManager {
|
|
285
|
+
if (!VirtualGamepadManager._instance) VirtualGamepadManager._instance = new VirtualGamepadManager();
|
|
286
|
+
return VirtualGamepadManager._instance;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private client: Client | null = null;
|
|
290
|
+
private roomId: string = "";
|
|
291
|
+
private deviceId: string = "";
|
|
292
|
+
private identity: VirtualGamepadIdentity = { userId: "", userType: "guest" };
|
|
293
|
+
private started: boolean = false;
|
|
294
|
+
private connectionState: VirtualGamepadConnectionState = "idle";
|
|
295
|
+
private controllerConnected: boolean = false;
|
|
296
|
+
private lastError: string = "";
|
|
297
|
+
private lastInputSeq: number = -1;
|
|
298
|
+
private gameState: VirtualGamepadGameState = "ready";
|
|
299
|
+
private sunCodeState: VirtualGamepadSunCodeState = "idle";
|
|
300
|
+
private sunCodeDataUrl: string = "";
|
|
301
|
+
private sunCodeError: string = "";
|
|
302
|
+
private sunCodeRequestSerial: number = 0;
|
|
303
|
+
private _listeners = new Map<string, Function[]>();
|
|
304
|
+
|
|
305
|
+
private constructor() {
|
|
306
|
+
this.deviceId = this.getOrCreateDeviceId();
|
|
307
|
+
this.identity = this.resolveIdentity();
|
|
308
|
+
this.roomId = getRoomId() || this.identity.userId || this.createRoomId();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
public start() {
|
|
312
|
+
if (this.started) { this.emit("status", this.getStatus()); return; }
|
|
313
|
+
this.started = true;
|
|
314
|
+
this.controllerConnected = false;
|
|
315
|
+
this.lastInputSeq = -1;
|
|
316
|
+
this.lastError = "";
|
|
317
|
+
this.identity = this.resolveIdentity();
|
|
318
|
+
let configuredRoomId = getRoomId();
|
|
319
|
+
this.roomId = configuredRoomId || this.identity.userId || this.createRoomId();
|
|
320
|
+
if (configuredRoomId) console.warn("[VirtualGamepad][Identity] roomId uses runtime override", { roomId: configuredRoomId, configKey: "RXQS_GAMEPAD_ROOM_ID" });
|
|
321
|
+
let token = getToken();
|
|
322
|
+
this.client = new Client();
|
|
323
|
+
this.client.onStateChange = this.handleClientState.bind(this);
|
|
324
|
+
this.client.onMessage = this.handleMessage.bind(this);
|
|
325
|
+
this.client.connect({
|
|
326
|
+
wsUrl: getWsUrl(), roomId: this.roomId, deviceId: this.deviceId,
|
|
327
|
+
game: VIRTUAL_GAMEPAD_GAME_ID, userId: this.identity.userId,
|
|
328
|
+
userType: this.identity.userType, guestId: this.identity.guestId,
|
|
329
|
+
token: token, maxMembers: VIRTUAL_GAMEPAD_MAX_MEMBERS
|
|
330
|
+
});
|
|
331
|
+
console.log("[VirtualGamepad][Identity] connect", { environment: getEnvironment(), userType: this.identity.userType, userId: this.maskUserId(this.identity.userId), roomId: this.roomId, hasToken: !!token, maxMembers: VIRTUAL_GAMEPAD_MAX_MEMBERS });
|
|
332
|
+
this.requestSunCode();
|
|
333
|
+
this.emit("status", this.getStatus());
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
public stop() {
|
|
337
|
+
this.started = false;
|
|
338
|
+
this.controllerConnected = false;
|
|
339
|
+
this.lastInputSeq = -1;
|
|
340
|
+
this.sunCodeRequestSerial++;
|
|
341
|
+
if (this.client) { this.client.close(); this.client = null; }
|
|
342
|
+
this.connectionState = "closed";
|
|
343
|
+
this.emit("status", this.getStatus());
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
public setGameState(state: VirtualGamepadGameState) { this.gameState = state; }
|
|
347
|
+
|
|
348
|
+
public getStatus(): VirtualGamepadStatus {
|
|
349
|
+
return {
|
|
350
|
+
connectionState: this.connectionState,
|
|
351
|
+
controllerConnected: this.controllerConnected,
|
|
352
|
+
roomId: this.roomId,
|
|
353
|
+
deviceId: this.deviceId,
|
|
354
|
+
userId: this.identity.userId,
|
|
355
|
+
userType: this.identity.userType,
|
|
356
|
+
identityText: this.getIdentityText(),
|
|
357
|
+
hasToken: !!getToken(),
|
|
358
|
+
maxMembers: VIRTUAL_GAMEPAD_MAX_MEMBERS,
|
|
359
|
+
sunCodeState: this.sunCodeState,
|
|
360
|
+
sunCodeDataUrl: this.sunCodeDataUrl,
|
|
361
|
+
sunCodeError: this.sunCodeError,
|
|
362
|
+
h5Url: this.getH5Url(),
|
|
363
|
+
qrPayload: this.getQrPayload(),
|
|
364
|
+
statusText: this.getStatusText(),
|
|
365
|
+
lastError: this.lastError
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
public getQrPayload(): string {
|
|
370
|
+
if (getQrMode() == "json") {
|
|
371
|
+
return JSON.stringify({ wsUrl: getWsUrl(), roomId: this.roomId, maxMembers: VIRTUAL_GAMEPAD_MAX_MEMBERS, token: getToken() });
|
|
372
|
+
}
|
|
373
|
+
return this.getH5Url();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
public getH5Url(): string {
|
|
377
|
+
return this.appendParams(getH5Url(), {
|
|
378
|
+
game: VIRTUAL_GAMEPAD_GAME_ID, roomId: this.roomId, wsUrl: getWsUrl(),
|
|
379
|
+
playerId: VIRTUAL_GAMEPAD_PLAYER_ID, maxMembers: String(VIRTUAL_GAMEPAD_MAX_MEMBERS), token: getToken()
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
public on<K extends keyof ListenerMap>(type: K, listener: ListenerMap[K]) {
|
|
384
|
+
if (!listener) return;
|
|
385
|
+
let list = this._listeners.get(type);
|
|
386
|
+
if (!list) { list = []; this._listeners.set(type, list); }
|
|
387
|
+
if (list.indexOf(listener) < 0) list.push(listener);
|
|
388
|
+
}
|
|
389
|
+
public off<K extends keyof ListenerMap>(type: K, listener: ListenerMap[K]) {
|
|
390
|
+
let list = this._listeners.get(type);
|
|
391
|
+
if (!list) return;
|
|
392
|
+
let i = list.indexOf(listener);
|
|
393
|
+
if (i >= 0) list.splice(i, 1);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private handleClientState(state: VirtualGamepadConnectionState, reason?: string) {
|
|
397
|
+
this.connectionState = state;
|
|
398
|
+
if (state == "error") this.lastError = reason || "连接失败";
|
|
399
|
+
if (state == "closed" || state == "error") { this.controllerConnected = false; this.emit("move", { x: 0, y: 0 }); }
|
|
400
|
+
this.emit("status", this.getStatus());
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private handleMessage(message: any) {
|
|
404
|
+
if (!this.isCurrentRoomMessage(message)) {
|
|
405
|
+
console.warn("[VirtualGamepad][WSS] ignored message from another room", { expectedRoomId: this.roomId, receivedRoomId: message && message.roomId, type: message && message.type });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (message.type == "error") { this.lastError = message.message || message.error || "手柄服务返回错误"; this.connectionState = "error"; this.emit("status", this.getStatus()); return; }
|
|
409
|
+
if (message.type == "pong") return;
|
|
410
|
+
if (message.type == "room_state") { this.handleRoomState(message); return; }
|
|
411
|
+
if (this.isControllerJoinMessage(message)) { console.log("[VirtualGamepad][WSS][Presence] controller joined", message); this.setControllerConnected(true); return; }
|
|
412
|
+
if (this.isControllerLeaveMessage(message)) { console.log("[VirtualGamepad][WSS][Presence] controller left", message); this.setControllerConnected(false); this.emit("move", { x: 0, y: 0, raw: message }); return; }
|
|
413
|
+
if (message.type == "input" || message.action) this.handleInput(message);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private handleInput(message: any) {
|
|
417
|
+
let payload = message.input || message;
|
|
418
|
+
let playerId = this.getMessagePlayerId(message) || this.getMessagePlayerId(payload);
|
|
419
|
+
if (playerId && playerId != VIRTUAL_GAMEPAD_PLAYER_ID) return;
|
|
420
|
+
let seq = typeof payload.seq == "number" ? payload.seq : message.seq;
|
|
421
|
+
if (typeof seq == "number") { if (seq <= this.lastInputSeq) return; this.lastInputSeq = seq; }
|
|
422
|
+
this.setControllerConnected(true);
|
|
423
|
+
|
|
424
|
+
// 按 input.type 分发
|
|
425
|
+
if (payload && this.dispatchControl(message, payload)) return;
|
|
426
|
+
|
|
427
|
+
// 旧格式兼容:move / stop
|
|
428
|
+
if (payload.action == "move" || payload.action == "stop") {
|
|
429
|
+
this.emit("move", {
|
|
430
|
+
x: this.clampAxis(payload.action == "stop" ? 0 : Number(payload.x) || 0),
|
|
431
|
+
y: this.clampAxis(payload.action == "stop" ? 0 : Number(payload.y) || 0),
|
|
432
|
+
seq, ts: this.getMessageTimestamp(payload) || this.getMessageTimestamp(message), raw: message
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** 按 input.type 分发到对应事件,返回 true 表示已处理 */
|
|
438
|
+
private dispatchControl(message: any, input: any): boolean {
|
|
439
|
+
if (!input || !input.type) return false;
|
|
440
|
+
let t = String(input.type).toLowerCase();
|
|
441
|
+
let key = this.normalizeKey(input.key);
|
|
442
|
+
let pressed = input.pressed !== false && input.action != "release" && input.action != "up" && input.action != "stop";
|
|
443
|
+
let action = (input && input.action ? String(input.action).toLowerCase() : "press");
|
|
444
|
+
|
|
445
|
+
// 摇杆
|
|
446
|
+
if (t == "joystick" || t == "joystick_stick") {
|
|
447
|
+
let side = this.normalizeKey(input.side || "left");
|
|
448
|
+
this.emit("move", {
|
|
449
|
+
x: pressed ? this.clampAxis(Number(input.x) || 0) : 0,
|
|
450
|
+
y: pressed ? this.clampAxis(Number(input.y) || 0) : 0,
|
|
451
|
+
seq: input.seq || message.seq, ts: this.getMessageTimestamp(input) || this.getMessageTimestamp(message),
|
|
452
|
+
raw: message, holdMode: true, side, direction: input.direction || "", action
|
|
453
|
+
});
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// 按键
|
|
458
|
+
if (t == "button") {
|
|
459
|
+
let et = (key == "back") ? "back" as const : "button" as const;
|
|
460
|
+
this.emit(et, this.buildInput(message, { type: et, action, pressed, key }));
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// 肩键 / 返回 / 扳机
|
|
465
|
+
if (t == "shoulder" || t == "back" || t == "trigger") {
|
|
466
|
+
this.emit(t as any, this.buildInput(message, { type: t as any, action, pressed, key }));
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private buildInput(message: any, data: any): VirtualGamepadControlInput {
|
|
474
|
+
let rawInput = message && message.input ? message.input : message;
|
|
475
|
+
return {
|
|
476
|
+
type: data.type, action: data.action, pressed: data.pressed,
|
|
477
|
+
side: data.side, key: data.key, direction: data.direction,
|
|
478
|
+
x: typeof data.x == "number" ? data.x : undefined,
|
|
479
|
+
y: typeof data.y == "number" ? data.y : undefined,
|
|
480
|
+
playerId: this.getMessagePlayerId(message) || this.getMessagePlayerId(rawInput) || VIRTUAL_GAMEPAD_PLAYER_ID,
|
|
481
|
+
seq: rawInput && typeof rawInput.seq == "number" ? rawInput.seq : message.seq,
|
|
482
|
+
ts: this.getMessageTimestamp(rawInput) || this.getMessageTimestamp(message),
|
|
483
|
+
raw: message
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
private normalizeKey(key: any): string { return String(key || "").toLowerCase(); }
|
|
488
|
+
private isCurrentRoomMessage(message: any): boolean { return !message || !message.roomId || message.roomId == this.roomId; }
|
|
489
|
+
|
|
490
|
+
private isControllerJoinMessage(message: any): boolean {
|
|
491
|
+
if (!message || message.role == "game") return false;
|
|
492
|
+
if (message.type == "device_join") return this.isTargetPlayerMessage(message);
|
|
493
|
+
if (message.role == "controller" && (message.type == "join" || message.type == "joined")) return true;
|
|
494
|
+
return ["peer_joined", "player_joined", "controller_joined", "controller_connected"].indexOf(message.type) >= 0;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private isControllerLeaveMessage(message: any): boolean {
|
|
498
|
+
if (!message) return false;
|
|
499
|
+
if (message.type == "device_leave") return this.isTargetPlayerMessage(message);
|
|
500
|
+
return ["peer_left", "player_left", "controller_left", "controller_disconnected", "leave"].indexOf(message.type) >= 0;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private handleRoomState(message: any) {
|
|
504
|
+
let items = message.slots || message.devices;
|
|
505
|
+
if (items && items.length) { this.setControllerConnected(this.hasPlayerOneInList(items)); return; }
|
|
506
|
+
if (typeof message.playerCount == "number") { this.setControllerConnected(message.playerCount > 0); return; }
|
|
507
|
+
if (message.players && message.players[VIRTUAL_GAMEPAD_PLAYER_ID]) { this.setControllerConnected(true); return; }
|
|
508
|
+
if (message.devices && message.devices[VIRTUAL_GAMEPAD_PLAYER_ID]) this.setControllerConnected(true);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private isTargetPlayerMessage(message: any): boolean { let pid = this.getMessagePlayerId(message); return !pid || pid == VIRTUAL_GAMEPAD_PLAYER_ID; }
|
|
512
|
+
|
|
513
|
+
private getMessagePlayerId(message: any): string {
|
|
514
|
+
if (!message) return "";
|
|
515
|
+
if (message.playerId) return String(message.playerId);
|
|
516
|
+
if (message.from == "player1" || message.from == "player2") return String(message.from);
|
|
517
|
+
if (message.role == "player1" || message.role == "player2") return String(message.role);
|
|
518
|
+
return "";
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private hasPlayerOneInList(list: any): boolean {
|
|
522
|
+
if (!list || !list.length) return false;
|
|
523
|
+
return list.some((item: any) => item && item.role == VIRTUAL_GAMEPAD_PLAYER_ID && item.occupied !== false);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private getMessageTimestamp(message: any): number { return typeof message.ts == "number" ? message.ts : typeof message.timestamp == "number" ? message.timestamp : 0; }
|
|
527
|
+
private setControllerConnected(connected: boolean) {
|
|
528
|
+
if (this.controllerConnected == connected) return;
|
|
529
|
+
this.controllerConnected = connected;
|
|
530
|
+
if (!connected) this.emit("move", { x: 0, y: 0 });
|
|
531
|
+
this.emit("status", this.getStatus());
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private emit(type: string, ...args: any[]) {
|
|
535
|
+
let list = this._listeners.get(type);
|
|
536
|
+
if (list) { for (let i = list.length - 1; i >= 0; i--) list[i](...args); }
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private getStatusText(): string {
|
|
540
|
+
if (this.controllerConnected) return "虚拟手柄已连接";
|
|
541
|
+
if (this.connectionState == "connecting") return "正在连接手柄服务";
|
|
542
|
+
if (this.connectionState == "open" || this.connectionState == "joined") return "等待手柄扫码连接";
|
|
543
|
+
if (this.connectionState == "error") return this.lastError || "手柄服务连接失败";
|
|
544
|
+
return "虚拟手柄未连接";
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private appendParams(url: string, params: Record<string, string>): string {
|
|
548
|
+
let q = Object.keys(params).map(k => encodeURIComponent(k) + "=" + encodeURIComponent(params[k] || "")).join("&");
|
|
549
|
+
let hi = url.indexOf("#");
|
|
550
|
+
if (hi >= 0) { let p = url.substring(0, hi), h = url.substring(hi + 1); return p + "#" + h + (h.indexOf("?") >= 0 ? "&" : "?") + q; }
|
|
551
|
+
return url + (url.indexOf("?") >= 0 ? "&" : "?") + q;
|
|
552
|
+
}
|
|
553
|
+
private clampAxis(v: number): number { return v > 1 ? 1 : v < -1 ? -1 : v; }
|
|
554
|
+
|
|
555
|
+
private getOrCreateDeviceId(): string {
|
|
556
|
+
let key = "rxqs_virtual_gamepad_device_id";
|
|
557
|
+
try {
|
|
558
|
+
let ls = cc.sys && cc.sys.localStorage;
|
|
559
|
+
if (ls) { let s = ls.getItem(key); if (s) return s; let c = this.createId("game"); ls.setItem(key, c); return c; }
|
|
560
|
+
} catch (e) { /* ignore */ }
|
|
561
|
+
return this.createId("game");
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private requestSunCode() {
|
|
565
|
+
let serial = ++this.sunCodeRequestSerial;
|
|
566
|
+
this.sunCodeState = "loading";
|
|
567
|
+
this.sunCodeDataUrl = "";
|
|
568
|
+
this.sunCodeError = "";
|
|
569
|
+
this.emit("status", this.getStatus());
|
|
570
|
+
let xhr = new XMLHttpRequest();
|
|
571
|
+
xhr.timeout = 10000;
|
|
572
|
+
let url = getWxacodeUrl();
|
|
573
|
+
xhr.open("POST", url, true);
|
|
574
|
+
xhr.responseType = "arraybuffer";
|
|
575
|
+
xhr.setRequestHeader("Content-Type", "application/json");
|
|
576
|
+
xhr.onload = () => {
|
|
577
|
+
if (serial != this.sunCodeRequestSerial || !this.started) return;
|
|
578
|
+
if (xhr.status < 200 || xhr.status >= 300 || !xhr.response) { this.failSunCode("太阳码接口返回 " + xhr.status, serial); return; }
|
|
579
|
+
let bytes = new Uint8Array(xhr.response as ArrayBuffer);
|
|
580
|
+
let mime = this.detectImageMimeType(bytes);
|
|
581
|
+
if (!mime) { this.failSunCode("太阳码接口未返回有效图片", serial); return; }
|
|
582
|
+
this.sunCodeDataUrl = "data:" + mime + ";base64," + this.encodeBase64(bytes);
|
|
583
|
+
this.sunCodeState = "ready";
|
|
584
|
+
this.sunCodeError = "";
|
|
585
|
+
console.log("[VirtualGamepad][SunCode] ready", { environment: getEnvironment(), url, roomId: this.roomId, page: VIRTUAL_GAMEPAD_WXACODE_PAGE, projectCode: VIRTUAL_GAMEPAD_WXACODE_PROJECT_CODE, byteLength: bytes.length, mimeType: mime });
|
|
586
|
+
this.emit("status", this.getStatus());
|
|
587
|
+
};
|
|
588
|
+
xhr.onerror = () => this.failSunCode("太阳码接口网络错误", serial);
|
|
589
|
+
xhr.ontimeout = () => this.failSunCode("太阳码接口请求超时", serial);
|
|
590
|
+
xhr.send(JSON.stringify({ scene: this.roomId, page: VIRTUAL_GAMEPAD_WXACODE_PAGE, projectCode: VIRTUAL_GAMEPAD_WXACODE_PROJECT_CODE }));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private failSunCode(msg: string, serial: number) {
|
|
594
|
+
if (serial != this.sunCodeRequestSerial || !this.started) return;
|
|
595
|
+
this.sunCodeState = "error";
|
|
596
|
+
this.sunCodeDataUrl = "";
|
|
597
|
+
this.sunCodeError = msg;
|
|
598
|
+
console.warn("[VirtualGamepad][SunCode] unavailable", { roomId: this.roomId, message: msg });
|
|
599
|
+
this.emit("status", this.getStatus());
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
private detectImageMimeType(bytes: Uint8Array): string {
|
|
603
|
+
if (bytes.length >= 3 && bytes[0] == 0xff && bytes[1] == 0xd8 && bytes[2] == 0xff) return "image/jpeg";
|
|
604
|
+
if (bytes.length >= 8 && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4e && bytes[3] == 0x47) return "image/png";
|
|
605
|
+
return "";
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
private encodeBase64(bytes: Uint8Array): string {
|
|
609
|
+
let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
610
|
+
let out = "";
|
|
611
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
612
|
+
let a = bytes[i], b = i + 1 < bytes.length ? bytes[i + 1] : 0, c = i + 2 < bytes.length ? bytes[i + 2] : 0;
|
|
613
|
+
out += chars.charAt(a >> 2) + chars.charAt(((a & 3) << 4) | (b >> 4));
|
|
614
|
+
out += i + 1 < bytes.length ? chars.charAt(((b & 15) << 2) | (c >> 6)) : "=";
|
|
615
|
+
out += i + 2 < bytes.length ? chars.charAt(c & 63) : "=";
|
|
616
|
+
}
|
|
617
|
+
return out;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private resolveIdentity(): VirtualGamepadIdentity {
|
|
621
|
+
let guestId = this.getOrCreateGuestId();
|
|
622
|
+
return { userId: guestId, userType: "guest", guestId };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private getOrCreateGuestId(): string {
|
|
626
|
+
let key = "rxqs_virtual_gamepad_guest_id";
|
|
627
|
+
try {
|
|
628
|
+
let ls = cc.sys && cc.sys.localStorage;
|
|
629
|
+
if (ls) { let s = ls.getItem(key); if (s) return s; let c = this.createId("guest"); ls.setItem(key, c); return c; }
|
|
630
|
+
} catch (e) { /* ignore */ }
|
|
631
|
+
return this.createId("guest");
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
private getIdentityText(): string { let l = this.identity.userType == "xiaomi" ? "小米用户" : "游客"; return l + " " + this.maskUserId(this.identity.userId); }
|
|
635
|
+
private maskUserId(id: string): string { let v = String(id || ""); return v.length <= 8 ? v : v.substring(0, 4) + "..." + v.substring(v.length - 4); }
|
|
636
|
+
private createRoomId(): string { return this.createId("rxqs"); }
|
|
637
|
+
private createId(prefix: string): string { return prefix + "_" + Date.now().toString(36) + "_" + Math.floor(Math.random() * 100000).toString(36); }
|
|
638
|
+
}
|