touch-coop 1.0.0 → 2.0.0
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/README.md +21 -2
- package/demos/gamepad/index.html +7 -2
- package/demos/match.html +7 -11
- package/media/demo.png +0 -0
- package/media/logo.png +0 -0
- package/package.json +13 -2
- package/src/match.ts +147 -82
- package/src/player.ts +107 -64
- package/vite.config.ts +1 -5
- package/dist/encoding.d.ts +0 -3
- package/dist/encoding.d.ts.map +0 -1
- package/dist/index.cjs +0 -8
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.global.js +0 -8
- package/dist/index.mjs +0 -1680
- package/dist/match.d.ts +0 -31
- package/dist/match.d.ts.map +0 -1
- package/dist/player.d.ts +0 -9
- package/dist/player.d.ts.map +0 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Touch-Coop
|
|
2
2
|
|
|
3
3
|
A TypeScript library that enables couch co-op gaming on the web, using mobile devices as controllers.
|
|
4
4
|
|
|
@@ -12,7 +12,26 @@ TouchCoop is intended for playing games on a TV or monitor, while players use th
|
|
|
12
12
|
|
|
13
13
|
TouchCoop is ideal for casual multiplayer games, such as platformers, puzzle games, or party games. TouchCoop is not intended for games that require low-latency input, such as first-person shooters.
|
|
14
14
|
|
|
15
|
-
TouchCoop does not require servers or user accounts. All communication is done using WebRTC, which allows for peer-to-peer connections between the players' devices.
|
|
15
|
+
TouchCoop does not require servers or user accounts for gameplay. However, it requires the PeerJS server (or your own signaling server) for initial connection setup. All communication is done using WebRTC, which allows for peer-to-peer connections between the players' devices.
|
|
16
|
+
|
|
17
|
+
## Powered by PeerJS
|
|
18
|
+
|
|
19
|
+
TouchCoop is powered by [PeerJS](https://peerjs.com/), which provides a simple API for WebRTC peer-to-peer connections. By default, TouchCoop uses the public PeerJS servers for signaling and STUN/TURN services.
|
|
20
|
+
|
|
21
|
+
- **Status Page**: Check the status of PeerJS public servers at [https://status.peerjs.com/](https://status.peerjs.com/).
|
|
22
|
+
- **Custom Servers**: If you need more control or reliability, you can deploy your own PeerJS server. See the [PeerJS documentation](https://peerjs.com/docs/#start) for instructions on setting up your own server. You can pass custom PeerJS options to the `Match` and `Player` constructors to connect to your own server.
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// Example: Using a custom PeerJS server
|
|
26
|
+
const customPeerConfig = {
|
|
27
|
+
host: 'your-peerjs-server.com',
|
|
28
|
+
port: 9000,
|
|
29
|
+
path: '/peerjs'
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const match = new Match(gamePadURL, handlePlayerEvent, customPeerConfig);
|
|
33
|
+
const player = new Player(customPeerConfig);
|
|
34
|
+
```
|
|
16
35
|
|
|
17
36
|
## Installation
|
|
18
37
|
|
package/demos/gamepad/index.html
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<h1>Gamepad Controller</h1>
|
|
16
|
+
<button id="joinBtn">Join Game</button>
|
|
17
|
+
<div id="gamepad" style="display: none;">
|
|
16
18
|
<div style="display: flex; flex-direction: row; gap: 80px; margin-top: 32px;">
|
|
17
19
|
<div class="gamepad" style="grid-template-columns: 60px 60px 60px;">
|
|
18
20
|
<div></div>
|
|
@@ -31,13 +33,16 @@
|
|
|
31
33
|
<button id="a">A</button>
|
|
32
34
|
<button id="b">B</button>
|
|
33
35
|
</div>
|
|
36
|
+
</div>
|
|
34
37
|
</div>
|
|
35
38
|
<script src="../../dist/index.global.js"></script>
|
|
36
39
|
<script>
|
|
37
40
|
const player = new TouchCoop.Player();
|
|
38
41
|
window.player = player;
|
|
39
42
|
|
|
40
|
-
(async () => {
|
|
43
|
+
document.getElementById("joinBtn").onclick = async () => {
|
|
44
|
+
document.getElementById("joinBtn").style.display = "none";
|
|
45
|
+
document.getElementById("gamepad").style.display = "block";
|
|
41
46
|
await player.joinMatch();
|
|
42
47
|
function sendButtonEvent(btn) {
|
|
43
48
|
player.sendMove(btn);
|
|
@@ -51,7 +56,7 @@
|
|
|
51
56
|
document.getElementById("b").onclick = () => sendButtonEvent("B");
|
|
52
57
|
document.getElementById("x").onclick = () => sendButtonEvent("X");
|
|
53
58
|
document.getElementById("y").onclick = () => sendButtonEvent("Y");
|
|
54
|
-
}
|
|
59
|
+
};
|
|
55
60
|
</script>
|
|
56
61
|
</body>
|
|
57
62
|
</html>
|
package/demos/match.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<head>
|
|
5
5
|
<meta charset="UTF-8" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
-
<title>Touch Coop
|
|
7
|
+
<title>Touch Coop Demo</title>
|
|
8
8
|
<style>
|
|
9
9
|
body { background-color: black; color: white; }
|
|
10
10
|
table { border-collapse: collapse; width: 100%; }
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
</style>
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
|
-
<image src="
|
|
19
|
-
<h1>
|
|
18
|
+
<image src="https://github.com/SlaneyEE/touch-coop/raw/main/media/logo.png" alt="Touch Coop Logo" style="width:200px; display:block; margin:auto; margin-top: 2rem;" />
|
|
19
|
+
<h1>TouchCoop Demo</h1>
|
|
20
20
|
<table id="players-table">
|
|
21
21
|
<tr>
|
|
22
22
|
<th>Player 1</th>
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
|
|
65
65
|
function updatePlayerIdDisplay(idx, playerId) {
|
|
66
66
|
const playerIdDiv = document.getElementById(`player-id-${idx}`);
|
|
67
|
-
if (playerId) {
|
|
67
|
+
if (playerId !== undefined) {
|
|
68
68
|
playerIdDiv.textContent = `Player ID: ${playerId}`;
|
|
69
69
|
} else {
|
|
70
70
|
playerIdDiv.textContent = '';
|
|
@@ -72,7 +72,6 @@
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
function handlePlayerEvent(event) {
|
|
75
|
-
// Find the player slot idx
|
|
76
75
|
let idx = -1;
|
|
77
76
|
for (let i = 0; i < 4; i++) {
|
|
78
77
|
const div = document.getElementById(`qr-${i}`);
|
|
@@ -81,7 +80,6 @@
|
|
|
81
80
|
break;
|
|
82
81
|
}
|
|
83
82
|
}
|
|
84
|
-
// If not found and this is a JOIN, assign to first available
|
|
85
83
|
if (idx === -1 && event.action === "JOIN") {
|
|
86
84
|
for (let i = 0; i < 4; i++) {
|
|
87
85
|
const div = document.getElementById(`qr-${i}`);
|
|
@@ -104,7 +102,7 @@
|
|
|
104
102
|
const qrImg = qrDiv.querySelector('img.qr');
|
|
105
103
|
if (qrImg) qrImg.remove();
|
|
106
104
|
areaDiv.style.display = '';
|
|
107
|
-
area.value
|
|
105
|
+
area.value = 'Player joined!\n';
|
|
108
106
|
updatePlayerIdDisplay(idx, event.playerId);
|
|
109
107
|
break;
|
|
110
108
|
}
|
|
@@ -113,7 +111,7 @@
|
|
|
113
111
|
if (joinBtn2) joinBtn2.style.display = '';
|
|
114
112
|
areaDiv.style.display = 'none';
|
|
115
113
|
area.value += 'Player left!\n';
|
|
116
|
-
updatePlayerIdDisplay(idx,
|
|
114
|
+
updatePlayerIdDisplay(idx, undefined);
|
|
117
115
|
delete qrDiv.dataset.playerId;
|
|
118
116
|
qrDiv.innerHTML = '<button onclick="joinPlayer(' + idx + ')">Join</button>';
|
|
119
117
|
break;
|
|
@@ -139,13 +137,11 @@
|
|
|
139
137
|
qrDiv.innerHTML = 'Loading...';
|
|
140
138
|
playerIdDiv.textContent = '';
|
|
141
139
|
try {
|
|
142
|
-
const { dataUrl,
|
|
143
|
-
qrDiv.dataset.playerId = playerId || '';
|
|
140
|
+
const { dataUrl, shareURL } = await match.requestNewPlayerToJoin();
|
|
144
141
|
qrDiv.innerHTML = `
|
|
145
142
|
<img class='qr' src='${dataUrl}' alt='QR Code' /></br>
|
|
146
143
|
<a href='${shareURL}' target='_blank'>Connect</a>
|
|
147
144
|
`;
|
|
148
|
-
updatePlayerIdDisplay(idx, playerId);
|
|
149
145
|
} catch (err) {
|
|
150
146
|
qrDiv.innerHTML = `<span style='color:red;'>Error</span>`;
|
|
151
147
|
}
|
package/media/demo.png
CHANGED
|
Binary file
|
package/media/logo.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "touch-coop",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Allow players to play couch co-op in your game using their mobile devices as controllers.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"couch-coop",
|
|
7
|
+
"mobile-controller",
|
|
8
|
+
"gaming",
|
|
9
|
+
"multiplayer",
|
|
10
|
+
"peerjs",
|
|
11
|
+
"webrtc",
|
|
12
|
+
"typescript",
|
|
13
|
+
"qrcode"
|
|
14
|
+
],
|
|
5
15
|
"main": "dist/index.cjs",
|
|
6
16
|
"typings": "dist/index.d.ts",
|
|
7
17
|
"exports": {
|
|
@@ -12,7 +22,7 @@
|
|
|
12
22
|
}
|
|
13
23
|
},
|
|
14
24
|
"scripts": {
|
|
15
|
-
"start": "
|
|
25
|
+
"start": "http-server",
|
|
16
26
|
"lint": "npx @biomejs/biome check",
|
|
17
27
|
"lint-and-format": "npx @biomejs/biome check --write --unsafe",
|
|
18
28
|
"build": "vite build && tsc --emitDeclarationOnly"
|
|
@@ -24,6 +34,7 @@
|
|
|
24
34
|
"vite": "^7.3.1"
|
|
25
35
|
},
|
|
26
36
|
"dependencies": {
|
|
37
|
+
"peerjs": "^1.5.5",
|
|
27
38
|
"qrcode": "^1.5.4"
|
|
28
39
|
}
|
|
29
40
|
}
|
package/src/match.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import Peer, { type DataConnection, type PeerOptions } from "peerjs";
|
|
1
2
|
import QRCode from "qrcode";
|
|
2
|
-
import { compressOfferData } from "./encoding";
|
|
3
3
|
|
|
4
4
|
export interface BasePlayerEvent {
|
|
5
5
|
playerId: string;
|
|
@@ -20,99 +20,164 @@ export type PlayerEvent = (JoinLeaveEvent | MoveEvent) & BasePlayerEvent;
|
|
|
20
20
|
type OnPlayerEventHandler = (data: PlayerEvent) => void;
|
|
21
21
|
|
|
22
22
|
export class Match {
|
|
23
|
-
private
|
|
24
|
-
private
|
|
23
|
+
private _peer: Peer;
|
|
24
|
+
private _playerConnections: Map<string, DataConnection> = new Map();
|
|
25
25
|
private _invitationAccepted: Map<string, boolean> = new Map();
|
|
26
|
+
private _connectionToPlayerId: Map<DataConnection, string> = new Map();
|
|
26
27
|
private _onPlayerEvent: OnPlayerEventHandler | null = null;
|
|
27
28
|
private _gamepadUiUrl: string;
|
|
28
|
-
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
gamepadUiUrl: string,
|
|
32
|
+
onPlayerEvent: OnPlayerEventHandler,
|
|
33
|
+
peerConfig?: PeerOptions,
|
|
34
|
+
) {
|
|
29
35
|
this._onPlayerEvent = onPlayerEvent;
|
|
30
36
|
this._gamepadUiUrl = gamepadUiUrl;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
|
|
38
|
+
this._peer = peerConfig ? new Peer(peerConfig) : new Peer();
|
|
39
|
+
|
|
40
|
+
this._peer.on("open", (hostId) => {
|
|
41
|
+
console.log(`Host PeerJS ID: ${hostId}`);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
this._peer.on("connection", (conn: DataConnection) => {
|
|
45
|
+
conn.on("open", () => {
|
|
46
|
+
console.log(`Connection open for PeerJS ID: ${conn.peer}`);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
conn.on("data", (rawData: unknown) => {
|
|
50
|
+
if (this._onPlayerEvent) {
|
|
51
|
+
let eventData: PlayerEvent;
|
|
52
|
+
if (typeof rawData === "string") {
|
|
53
|
+
try {
|
|
54
|
+
eventData = JSON.parse(rawData);
|
|
55
|
+
} catch {
|
|
56
|
+
console.warn("Invalid JSON from player", conn.peer, rawData);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
} else if (typeof rawData === "object" && rawData !== null) {
|
|
60
|
+
eventData = rawData as PlayerEvent;
|
|
61
|
+
} else {
|
|
62
|
+
console.warn(
|
|
63
|
+
"Unexpected data type from player",
|
|
64
|
+
conn.peer,
|
|
65
|
+
rawData,
|
|
66
|
+
);
|
|
67
|
+
return;
|
|
44
68
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
69
|
+
|
|
70
|
+
let playerId: string | undefined;
|
|
71
|
+
if (
|
|
72
|
+
"playerId" in eventData &&
|
|
73
|
+
typeof eventData.playerId === "string"
|
|
74
|
+
) {
|
|
75
|
+
playerId = eventData.playerId;
|
|
76
|
+
}
|
|
77
|
+
if (playerId === undefined) {
|
|
78
|
+
console.warn("Malformed event from player", conn.peer, eventData);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
eventData.playerId = playerId;
|
|
82
|
+
|
|
83
|
+
if ("action" in eventData && "timestamp" in eventData) {
|
|
84
|
+
if (eventData.action === "JOIN") {
|
|
85
|
+
this._playerConnections.set(eventData.playerId, conn);
|
|
86
|
+
this._invitationAccepted.set(eventData.playerId, true);
|
|
87
|
+
this._connectionToPlayerId.set(conn, eventData.playerId);
|
|
88
|
+
console.log(`Player ${eventData.playerId} joined`);
|
|
89
|
+
}
|
|
90
|
+
this._onPlayerEvent(eventData);
|
|
91
|
+
} else {
|
|
92
|
+
console.warn("Malformed event from player", conn.peer, eventData);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
conn.on("close", () => {
|
|
98
|
+
const playerId = this._connectionToPlayerId.get(conn);
|
|
99
|
+
if (playerId !== undefined) {
|
|
100
|
+
this._playerConnections.delete(playerId);
|
|
101
|
+
this._invitationAccepted.delete(playerId);
|
|
102
|
+
this._connectionToPlayerId.delete(conn);
|
|
103
|
+
if (this._onPlayerEvent) {
|
|
104
|
+
this._onPlayerEvent({
|
|
105
|
+
playerId,
|
|
106
|
+
action: "LEAVE",
|
|
107
|
+
timestamp: Date.now(),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
console.log(`Player ${playerId} disconnected`);
|
|
111
|
+
} else {
|
|
112
|
+
console.log(
|
|
113
|
+
`Connection closed for unknown player (PeerJS ID: ${conn.peer})`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
conn.on("error", (err) => {
|
|
119
|
+
const playerId = this._connectionToPlayerId.get(conn);
|
|
120
|
+
if (playerId !== undefined) {
|
|
121
|
+
console.error(`Connection error for player ${playerId}:`, err);
|
|
122
|
+
this._playerConnections.delete(playerId);
|
|
123
|
+
this._invitationAccepted.delete(playerId);
|
|
124
|
+
this._connectionToPlayerId.delete(conn);
|
|
125
|
+
} else {
|
|
126
|
+
console.error(
|
|
127
|
+
`Connection error for unknown player (PeerJS ID: ${conn.peer}):`,
|
|
128
|
+
err,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
this._peer.on("error", (err) => {
|
|
135
|
+
console.error("PeerJS error:", err);
|
|
58
136
|
});
|
|
59
137
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
});
|
|
86
|
-
const offerObject = {
|
|
87
|
-
playerId: newPlayer,
|
|
88
|
-
sdp: pc.localDescription,
|
|
89
|
-
};
|
|
90
|
-
const offerJSON = JSON.stringify(offerObject);
|
|
91
|
-
const compressedBase64OfferObject =
|
|
92
|
-
await compressOfferData(offerJSON);
|
|
93
|
-
const shareURL = `${this._gamepadUiUrl}?remoteSDP=${compressedBase64OfferObject}`;
|
|
94
|
-
QRCode.toDataURL(
|
|
138
|
+
|
|
139
|
+
async requestNewPlayerToJoin(): Promise<{
|
|
140
|
+
dataUrl: string;
|
|
141
|
+
shareURL: string;
|
|
142
|
+
}> {
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
if (this._peer.destroyed || !this._peer.open) {
|
|
145
|
+
return reject(new Error("Host Peer is not open or has been destroyed"));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const hostId = this._peer.id;
|
|
149
|
+
if (!hostId) {
|
|
150
|
+
return reject(new Error("Host Peer ID not yet assigned"));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const shareURL = `${this._gamepadUiUrl}?hostPeerId=${encodeURIComponent(hostId)}`;
|
|
154
|
+
|
|
155
|
+
QRCode.toDataURL(
|
|
156
|
+
shareURL,
|
|
157
|
+
{ errorCorrectionLevel: "M" },
|
|
158
|
+
(err, dataUrl) => {
|
|
159
|
+
if (err) return reject(err);
|
|
160
|
+
|
|
161
|
+
resolve({
|
|
162
|
+
dataUrl,
|
|
95
163
|
shareURL,
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
resolve({
|
|
102
|
-
dataUrl: dataUrl,
|
|
103
|
-
shareURL: shareURL,
|
|
104
|
-
playerId: newPlayer,
|
|
105
|
-
});
|
|
106
|
-
},
|
|
107
|
-
);
|
|
108
|
-
})();
|
|
109
|
-
},
|
|
110
|
-
);
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
});
|
|
111
168
|
}
|
|
112
169
|
|
|
113
|
-
// Returns true if invitation was accepted, false if still pending, undefined if not found
|
|
114
170
|
getInvitationStatus(playerId: string): boolean | undefined {
|
|
115
171
|
return this._invitationAccepted.get(playerId);
|
|
116
172
|
}
|
|
117
|
-
|
|
173
|
+
|
|
174
|
+
destroy() {
|
|
175
|
+
for (const playerId of this._playerConnections.keys()) {
|
|
176
|
+
this._invitationAccepted.delete(playerId);
|
|
177
|
+
}
|
|
178
|
+
this._playerConnections.clear();
|
|
179
|
+
this._invitationAccepted.clear();
|
|
180
|
+
this._connectionToPlayerId.clear();
|
|
181
|
+
this._peer.destroy();
|
|
182
|
+
}
|
|
118
183
|
}
|
package/src/player.ts
CHANGED
|
@@ -1,83 +1,118 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import Peer, { type DataConnection, type PeerOptions } from "peerjs";
|
|
2
2
|
import type { PlayerEvent } from "./match";
|
|
3
3
|
|
|
4
4
|
export class Player {
|
|
5
|
-
private
|
|
5
|
+
private _peer: Peer;
|
|
6
6
|
private _playerId: string | null = null;
|
|
7
|
-
private
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
7
|
+
private _dataConnection: DataConnection | null = null;
|
|
8
|
+
private _ownIdPromise: Promise<string>;
|
|
9
|
+
|
|
10
|
+
constructor(peerConfig?: PeerOptions) {
|
|
11
|
+
this._peer = peerConfig ? new Peer(peerConfig) : new Peer();
|
|
12
|
+
|
|
13
|
+
this._ownIdPromise = new Promise<string>((resolve, reject) => {
|
|
14
|
+
const timeout = setTimeout(
|
|
15
|
+
() => reject(new Error("Peer ID not assigned in time")),
|
|
16
|
+
10000,
|
|
17
|
+
);
|
|
18
|
+
this._peer.on("open", (id) => {
|
|
19
|
+
clearTimeout(timeout);
|
|
20
|
+
this._playerId = id;
|
|
21
|
+
resolve(id);
|
|
22
|
+
});
|
|
23
|
+
this._peer.on("error", (err) => {
|
|
24
|
+
clearTimeout(timeout);
|
|
25
|
+
reject(err);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
this._peer.on("error", (err) => {
|
|
30
|
+
console.error("PeerJS error:", err.type, err.message);
|
|
31
|
+
});
|
|
32
|
+
|
|
23
33
|
window.addEventListener("beforeunload", () => {
|
|
24
|
-
if (
|
|
25
|
-
|
|
26
|
-
this._dataChannel.readyState === "open" &&
|
|
27
|
-
this._playerId
|
|
28
|
-
) {
|
|
29
|
-
const leaveEvent = {
|
|
34
|
+
if (this._dataConnection?.open && this._playerId !== null) {
|
|
35
|
+
const leaveEvent: PlayerEvent = {
|
|
30
36
|
playerId: this._playerId,
|
|
31
37
|
action: "LEAVE",
|
|
32
|
-
button: "",
|
|
33
38
|
timestamp: Date.now(),
|
|
34
39
|
};
|
|
35
40
|
try {
|
|
36
|
-
this.
|
|
37
|
-
} catch {
|
|
41
|
+
this._dataConnection.send(leaveEvent);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
console.warn("Failed to send LEAVE:", err);
|
|
44
|
+
}
|
|
38
45
|
}
|
|
46
|
+
this._dataConnection?.close();
|
|
47
|
+
this._peer.destroy();
|
|
39
48
|
});
|
|
40
49
|
}
|
|
50
|
+
|
|
41
51
|
async joinMatch() {
|
|
42
52
|
const params = new URLSearchParams(window.location.search);
|
|
43
|
-
const
|
|
44
|
-
if (!
|
|
45
|
-
throw new Error("No
|
|
53
|
+
const hostPeerId = params.get("hostPeerId");
|
|
54
|
+
if (!hostPeerId) {
|
|
55
|
+
throw new Error("No hostPeerId found in URL parameters.");
|
|
46
56
|
}
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
|
|
58
|
+
const conn = this._peer.connect(hostPeerId, {
|
|
59
|
+
reliable: false,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
this._dataConnection = conn;
|
|
63
|
+
|
|
64
|
+
await new Promise<void>((resolve, reject) => {
|
|
65
|
+
const timeout = setTimeout(() => {
|
|
66
|
+
conn.close();
|
|
67
|
+
reject(new Error("Connection timeout"));
|
|
68
|
+
}, 15000);
|
|
69
|
+
|
|
70
|
+
conn.on("open", () => {
|
|
71
|
+
clearTimeout(timeout);
|
|
72
|
+
if (this._playerId) {
|
|
73
|
+
const joinEvent: PlayerEvent = {
|
|
74
|
+
playerId: this._playerId,
|
|
75
|
+
action: "JOIN",
|
|
76
|
+
timestamp: Date.now(),
|
|
77
|
+
};
|
|
78
|
+
conn.send(joinEvent);
|
|
79
|
+
console.log("JOIN sent, data connection open");
|
|
80
|
+
resolve();
|
|
81
|
+
} else {
|
|
82
|
+
this._ownIdPromise
|
|
83
|
+
.then((playerId) => {
|
|
84
|
+
const joinEvent: PlayerEvent = {
|
|
85
|
+
playerId,
|
|
86
|
+
action: "JOIN",
|
|
87
|
+
timestamp: Date.now(),
|
|
88
|
+
};
|
|
89
|
+
conn.send(joinEvent);
|
|
90
|
+
console.log("JOIN sent, data connection open");
|
|
91
|
+
resolve();
|
|
92
|
+
})
|
|
93
|
+
.catch(reject);
|
|
94
|
+
}
|
|
61
95
|
});
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
96
|
+
|
|
97
|
+
conn.on("error", (err) => {
|
|
98
|
+
clearTimeout(timeout);
|
|
99
|
+
reject(err);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
conn.on("data", (data: unknown) => {
|
|
104
|
+
console.log("Received from host:", data);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
conn.on("close", () => {
|
|
108
|
+
console.log("Data connection closed by host");
|
|
109
|
+
this._dataConnection = null;
|
|
110
|
+
});
|
|
77
111
|
}
|
|
112
|
+
|
|
78
113
|
async sendMove(button: string) {
|
|
79
|
-
if (this.
|
|
80
|
-
if (
|
|
114
|
+
if (this._dataConnection?.open) {
|
|
115
|
+
if (this._playerId === null) {
|
|
81
116
|
throw new Error("Player ID is not set. Cannot send data.");
|
|
82
117
|
}
|
|
83
118
|
const playerEvent: PlayerEvent = {
|
|
@@ -86,10 +121,18 @@ export class Player {
|
|
|
86
121
|
button: button,
|
|
87
122
|
timestamp: Date.now(),
|
|
88
123
|
};
|
|
89
|
-
|
|
90
|
-
this._dataChannel.send(playerEventJSON);
|
|
124
|
+
this._dataConnection.send(playerEvent);
|
|
91
125
|
} else {
|
|
92
|
-
console.warn("Data
|
|
126
|
+
console.warn("Data connection is not open. Cannot send move.");
|
|
93
127
|
}
|
|
94
128
|
}
|
|
129
|
+
|
|
130
|
+
get isConnected(): boolean {
|
|
131
|
+
return !!this._dataConnection?.open;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
destroy() {
|
|
135
|
+
this._dataConnection?.close();
|
|
136
|
+
this._peer.destroy();
|
|
137
|
+
}
|
|
95
138
|
}
|
package/vite.config.ts
CHANGED
|
@@ -18,18 +18,14 @@ export default defineConfig({
|
|
|
18
18
|
emptyOutDir: true,
|
|
19
19
|
rollupOptions: {
|
|
20
20
|
external: (id) => {
|
|
21
|
-
// For IIFE (browser), bundle all dependencies (including qrcode)
|
|
22
21
|
if (/qrcode/.test(id)) {
|
|
23
|
-
// Only externalize for es/cjs
|
|
24
22
|
const format = process.env.BUILD_FORMAT;
|
|
25
23
|
return format === "es" || format === "cjs";
|
|
26
24
|
}
|
|
27
25
|
return false;
|
|
28
26
|
},
|
|
29
27
|
output: {
|
|
30
|
-
globals: {
|
|
31
|
-
// No external globals needed for IIFE
|
|
32
|
-
},
|
|
28
|
+
globals: {},
|
|
33
29
|
},
|
|
34
30
|
},
|
|
35
31
|
},
|
package/dist/encoding.d.ts
DELETED
package/dist/encoding.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"encoding.d.ts","sourceRoot":"","sources":["../src/encoding.ts"],"names":[],"mappings":"AAAA,wBAAsB,iBAAiB,CAAC,YAAY,EAAE,MAAM,mBA0B3D;AAED,wBAAsB,mBAAmB,CACvC,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,MAAM,CAAC,CAiBjB"}
|