@powly/sdk 0.4.1 → 0.8.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 +17 -2
- package/dist/powly.d.ts +210 -6
- package/dist/powly.js +372 -14
- package/dist/powly.mjs +372 -14
- package/package.json +2 -2
package/dist/powly.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
/* Powly SDK 0.
|
|
1
|
+
/* Powly SDK 0.8.0: ES module */
|
|
2
2
|
/** This SDK's version; Powly's Studio shows it next to the downloads. */
|
|
3
|
-
const SDK_VERSION = "0.
|
|
3
|
+
const SDK_VERSION = "0.8.0";
|
|
4
4
|
|
|
5
5
|
// The messages between a game (this SDK, inside Powly's iframe) and the Powly page that framed it.
|
|
6
6
|
// Shared with the host side (apps/web/components/player/sdk-host.ts).
|
|
@@ -12,16 +12,69 @@ const SDK_VERSION = "0.4.1";
|
|
|
12
12
|
// 3. From then on the game also reports to Powly Anti-Cheat over the port (`PacSample`, src/pac.ts).
|
|
13
13
|
const PROTOCOL = "powly-sdk";
|
|
14
14
|
const PROTOCOL_VERSION = 1;
|
|
15
|
+
/** The corners Powly's options button (its mascot, which opens the player's options) can be in. */
|
|
16
|
+
const OPTIONS_BUTTON_CORNERS = [
|
|
17
|
+
"top-left",
|
|
18
|
+
"top-right",
|
|
19
|
+
"bottom-left",
|
|
20
|
+
"bottom-right",
|
|
21
|
+
];
|
|
22
|
+
const isOptionsButtonCorner = (value) => OPTIONS_BUTTON_CORNERS.includes(value);
|
|
23
|
+
/** Channel ids: letters, digits, _ and -, starting with a letter, up to 32. */
|
|
24
|
+
const AUDIO_CHANNEL_ID = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
|
|
25
|
+
const MAX_AUDIO_CHANNELS = 8;
|
|
26
|
+
const MAX_AUDIO_LABEL = 32;
|
|
27
|
+
/** The channels a game sent, cleaned up: valid ids, short labels, no repeats, at most 8. */
|
|
28
|
+
function audioChannelsOf(value) {
|
|
29
|
+
if (!Array.isArray(value))
|
|
30
|
+
return [];
|
|
31
|
+
const channels = [];
|
|
32
|
+
for (const entry of value) {
|
|
33
|
+
if (typeof entry !== "object" || entry === null)
|
|
34
|
+
continue;
|
|
35
|
+
const { id, label, volume, toggle } = entry;
|
|
36
|
+
if (typeof id !== "string" || !AUDIO_CHANNEL_ID.test(id))
|
|
37
|
+
continue;
|
|
38
|
+
if (channels.some((channel) => channel.id === id))
|
|
39
|
+
continue;
|
|
40
|
+
const text = typeof label === "string" ? label.trim().slice(0, MAX_AUDIO_LABEL) : "";
|
|
41
|
+
const slider = volume !== false;
|
|
42
|
+
// Neither a slider nor a switch would show nothing: both, then.
|
|
43
|
+
const onOff = toggle !== false || !slider;
|
|
44
|
+
channels.push({ id, label: text || id, volume: slider, toggle: onOff });
|
|
45
|
+
if (channels.length === MAX_AUDIO_CHANNELS)
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
return channels;
|
|
49
|
+
}
|
|
50
|
+
/** A volume between 0 and 1, or null. */
|
|
51
|
+
const volumeOf = (value) => typeof value === "number" && Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : null;
|
|
52
|
+
/** What a game may ask the Powly page for. */
|
|
53
|
+
const HOST_METHODS = [
|
|
54
|
+
"getSessionToken",
|
|
55
|
+
"purchases.open",
|
|
56
|
+
"fullscreen.toggle",
|
|
57
|
+
"invites.open",
|
|
58
|
+
"players.open",
|
|
59
|
+
"twitch.connect",
|
|
60
|
+
"audio.set",
|
|
61
|
+
"audio.channels",
|
|
62
|
+
"quit",
|
|
63
|
+
];
|
|
64
|
+
/** "players.open": what Powly's player dialog opens on. */
|
|
65
|
+
const PLAYER_VIEWS = ["profile", "report"];
|
|
15
66
|
const isInitMessage = (data) => typeof data === "object" &&
|
|
16
67
|
data !== null &&
|
|
17
68
|
data.protocol === PROTOCOL &&
|
|
18
69
|
data.type === "init" &&
|
|
19
70
|
data.version === PROTOCOL_VERSION;
|
|
20
|
-
|
|
71
|
+
/** A request with a method this Powly doesn't know (from a newer SDK): answered with an error. */
|
|
72
|
+
const isRequestShaped = (data) => typeof data === "object" &&
|
|
21
73
|
data !== null &&
|
|
22
74
|
data.type === "request" &&
|
|
23
|
-
Number.isInteger(data.id)
|
|
24
|
-
|
|
75
|
+
Number.isInteger(data.id);
|
|
76
|
+
const isRequestMessage = (data) => isRequestShaped(data) &&
|
|
77
|
+
HOST_METHODS.includes(data.method);
|
|
25
78
|
|
|
26
79
|
// The Powly origins this build of the SDK trusts by default: a game only handshakes with a parent
|
|
27
80
|
// page on one of them (unless it passes `powlyOrigin` to `Powly.init()` itself). Set at build
|
|
@@ -47,8 +100,9 @@ class PowlyError extends Error {
|
|
|
47
100
|
// the server's to catch speed hacks), browser functions that were replaced (speed hack scripts and
|
|
48
101
|
// extensions patch timers), traces of automated browsers, and how input arrives (scripted events,
|
|
49
102
|
// clicks without pointer movement as AI agents make them, machine-regular timing as macros and
|
|
50
|
-
// auto-clickers make them)
|
|
51
|
-
//
|
|
103
|
+
// auto-clickers make them), and whether the player plays at all (any trusted input, gamepads
|
|
104
|
+
// included: Powly closes games nobody plays). Nothing is sent anywhere else, and nothing here
|
|
105
|
+
// reads what the player types: only event counts and timings.
|
|
52
106
|
//
|
|
53
107
|
// In real players' runs (not developer mode) the SDK also guards the game's code against being
|
|
54
108
|
// copied (`guard`): the browser's context menu and its view-source, save-page and developer-tools
|
|
@@ -60,6 +114,8 @@ class PowlyError extends Error {
|
|
|
60
114
|
// `tamperedFunctions` are shared with the Powly page (apps/web/components/player/pac-sensor.ts).
|
|
61
115
|
/** How often the SDK reports (ms). */
|
|
62
116
|
const PAC_SAMPLE_MS = 5000;
|
|
117
|
+
/** A gamepad axis past this counts as input (sticks rest a little off centre). */
|
|
118
|
+
const GAMEPAD_DEADZONE = 0.3;
|
|
63
119
|
const isPacSample = (data) => typeof data === "object" &&
|
|
64
120
|
data !== null &&
|
|
65
121
|
data.type === "pac" &&
|
|
@@ -208,6 +264,7 @@ function startPacSensor(port, { guard = false } = {}, win = window) {
|
|
|
208
264
|
synthetic: 0,
|
|
209
265
|
clicks: 0,
|
|
210
266
|
teleportClicks: 0,
|
|
267
|
+
activity: 0,
|
|
211
268
|
};
|
|
212
269
|
const keyTimes = [];
|
|
213
270
|
const clickTimes = [];
|
|
@@ -225,6 +282,9 @@ function startPacSensor(port, { guard = false } = {}, win = window) {
|
|
|
225
282
|
input.synthetic += 1;
|
|
226
283
|
return;
|
|
227
284
|
}
|
|
285
|
+
input.activity += 1;
|
|
286
|
+
if (event.type === "wheel")
|
|
287
|
+
return;
|
|
228
288
|
if (event.type === "pointermove" || event.type === "pointerover") {
|
|
229
289
|
const move = event;
|
|
230
290
|
pointer = { x: move.clientX, y: move.clientY };
|
|
@@ -250,7 +310,26 @@ function startPacSensor(port, { guard = false } = {}, win = window) {
|
|
|
250
310
|
}
|
|
251
311
|
pointer = { x: down.clientX, y: down.clientY };
|
|
252
312
|
};
|
|
253
|
-
const types = ["keydown", "pointerdown", "pointermove", "pointerover"];
|
|
313
|
+
const types = ["keydown", "pointerdown", "pointermove", "pointerover", "wheel"];
|
|
314
|
+
// Gamepads send no events: a pressed button or a pushed stick, looked at a few times a second.
|
|
315
|
+
const watchGamepads = () => {
|
|
316
|
+
let pads;
|
|
317
|
+
try {
|
|
318
|
+
pads = [...(win.navigator.getGamepads?.() ?? [])];
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
for (const pad of pads) {
|
|
324
|
+
if (!pad?.connected)
|
|
325
|
+
continue;
|
|
326
|
+
if (pad.buttons.some((button) => button.pressed) ||
|
|
327
|
+
pad.axes.some((axis) => Math.abs(axis) > GAMEPAD_DEADZONE)) {
|
|
328
|
+
input.activity += 1;
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
};
|
|
254
333
|
for (const type of types) {
|
|
255
334
|
win.addEventListener(type, onInput, { capture: true, passive: true });
|
|
256
335
|
}
|
|
@@ -318,6 +397,7 @@ function startPacSensor(port, { guard = false } = {}, win = window) {
|
|
|
318
397
|
input.synthetic = 0;
|
|
319
398
|
input.clicks = 0;
|
|
320
399
|
input.teleportClicks = 0;
|
|
400
|
+
input.activity = 0;
|
|
321
401
|
dateJump = 0;
|
|
322
402
|
devtools = false;
|
|
323
403
|
};
|
|
@@ -326,11 +406,13 @@ function startPacSensor(port, { guard = false } = {}, win = window) {
|
|
|
326
406
|
const clearInterval = win.clearInterval.bind(win);
|
|
327
407
|
const dateTimer = setInterval(watchDate, 1000);
|
|
328
408
|
const reportTimer = setInterval(report, PAC_SAMPLE_MS);
|
|
409
|
+
const gamepadTimer = setInterval(watchGamepads, 250);
|
|
329
410
|
const trapTimer = trap ? setInterval(springTrap, 1500) : undefined;
|
|
330
411
|
report();
|
|
331
412
|
return () => {
|
|
332
413
|
clearInterval(dateTimer);
|
|
333
414
|
clearInterval(reportTimer);
|
|
415
|
+
clearInterval(gamepadTimer);
|
|
334
416
|
if (trapTimer !== undefined)
|
|
335
417
|
clearInterval(trapTimer);
|
|
336
418
|
for (const type of types)
|
|
@@ -822,6 +904,11 @@ function createRooms(options) {
|
|
|
822
904
|
// });
|
|
823
905
|
// }
|
|
824
906
|
//
|
|
907
|
+
// A game with a Twitch mode among others (Twitch chat "optional" in Studio) passes
|
|
908
|
+
// `{ prompt: true }` when the player picks that mode: if they haven't connected Twitch, Powly asks
|
|
909
|
+
// them to in its own dialog (drawn by Powly, outside the game), and `connect()` resolves once
|
|
910
|
+
// they did or closed it.
|
|
911
|
+
//
|
|
825
912
|
// Chat is Twitch IRC over a WebSocket (https://dev.twitch.tv/docs/chat/irc/). The connection comes
|
|
826
913
|
// back by itself when it drops, and stops when the launch ends.
|
|
827
914
|
const TWITCH_CHAT_URL = "wss://irc-ws.chat.twitch.tv:443";
|
|
@@ -1062,8 +1149,12 @@ function createTwitch(options) {
|
|
|
1062
1149
|
};
|
|
1063
1150
|
return {
|
|
1064
1151
|
info: options.getInfo,
|
|
1065
|
-
async connect() {
|
|
1066
|
-
|
|
1152
|
+
async connect(connectOptions = {}) {
|
|
1153
|
+
let info = await options.getInfo();
|
|
1154
|
+
if (info.status === "not_connected" && connectOptions.prompt && options.prompt) {
|
|
1155
|
+
if (await options.prompt())
|
|
1156
|
+
info = await options.getInfo();
|
|
1157
|
+
}
|
|
1067
1158
|
const next = info.status === "connected" ? info.channel : null;
|
|
1068
1159
|
if (!next || stopped) {
|
|
1069
1160
|
disconnect();
|
|
@@ -1102,6 +1193,154 @@ function createTwitch(options) {
|
|
|
1102
1193
|
};
|
|
1103
1194
|
}
|
|
1104
1195
|
|
|
1196
|
+
// Powly's sound menu for games (`powly.audio`): the player's overall volume, a mute (Powly's M
|
|
1197
|
+
// key), and the kinds of sound the game registers (channels: music, drawing sounds…), each with a
|
|
1198
|
+
// volume slider and an on/off switch, or one of them. The player sets them in Powly's options
|
|
1199
|
+
// bar; the game hears "change".
|
|
1200
|
+
//
|
|
1201
|
+
// const powly = await Powly.init({
|
|
1202
|
+
// audio: { channels: [{ id: "music", label: "Music" }] },
|
|
1203
|
+
// });
|
|
1204
|
+
// await powly.audio.register({ id: "drawing", label: "Drawing sounds", volume: false });
|
|
1205
|
+
// music.volume = powly.audio.level("music");
|
|
1206
|
+
// powly.audio.on("change", () => { music.volume = powly.audio.level("music"); });
|
|
1207
|
+
// if (powly.audio.level("drawing") > 0) playScribble();
|
|
1208
|
+
//
|
|
1209
|
+
// The overall volume and mute apply by themselves (`auto`, on by default): the SDK puts a volume
|
|
1210
|
+
// control in front of the page's speakers, for Web Audio (every connection to an AudioContext's
|
|
1211
|
+
// destination goes through it) and for <audio>/<video> (their volume is scaled). So every game
|
|
1212
|
+
// on this SDK follows Powly's volume, and channels only need the channel's own value:
|
|
1213
|
+
// `level(channel)` is what to play at either way.
|
|
1214
|
+
/** Volume of the page's sound overall: 0 when muted. */
|
|
1215
|
+
const masterLevel = (state) => (state.muted ? 0 : state.volume);
|
|
1216
|
+
/** Live references to objects, for walking them later without keeping them alive. */
|
|
1217
|
+
function weakList() {
|
|
1218
|
+
const seen = new WeakSet();
|
|
1219
|
+
const refs = new Set();
|
|
1220
|
+
return {
|
|
1221
|
+
add(item) {
|
|
1222
|
+
if (seen.has(item))
|
|
1223
|
+
return false;
|
|
1224
|
+
seen.add(item);
|
|
1225
|
+
refs.add(new WeakRef(item));
|
|
1226
|
+
return true;
|
|
1227
|
+
},
|
|
1228
|
+
has: (item) => seen.has(item),
|
|
1229
|
+
*[Symbol.iterator]() {
|
|
1230
|
+
for (const ref of refs) {
|
|
1231
|
+
const item = ref.deref();
|
|
1232
|
+
if (item)
|
|
1233
|
+
yield item;
|
|
1234
|
+
else
|
|
1235
|
+
refs.delete(ref);
|
|
1236
|
+
}
|
|
1237
|
+
},
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Puts a volume control in front of the page's speakers: Web Audio connections to a real-time
|
|
1242
|
+
* AudioContext's destination go through a gain node per context, and media elements' volume is
|
|
1243
|
+
* scaled (the volume the game set still reads back unchanged; elements played through Web Audio
|
|
1244
|
+
* are left to the gain node). Returns how to set it.
|
|
1245
|
+
*/
|
|
1246
|
+
function installMasterGain(win = window) {
|
|
1247
|
+
let level = 1;
|
|
1248
|
+
// Web Audio.
|
|
1249
|
+
const gainOf = new WeakMap();
|
|
1250
|
+
const gains = weakList();
|
|
1251
|
+
const Destination = win.AudioDestinationNode;
|
|
1252
|
+
const Offline = win.OfflineAudioContext;
|
|
1253
|
+
const nodeProto = win.AudioNode?.prototype;
|
|
1254
|
+
const connect = nodeProto?.connect;
|
|
1255
|
+
const disconnect = nodeProto?.disconnect;
|
|
1256
|
+
/** The gain node standing in for `target` when it's a real-time context's destination. */
|
|
1257
|
+
const standIn = (target) => {
|
|
1258
|
+
if (!Destination || !(target instanceof Destination))
|
|
1259
|
+
return null;
|
|
1260
|
+
const context = target.context;
|
|
1261
|
+
if (Offline && context instanceof Offline)
|
|
1262
|
+
return null;
|
|
1263
|
+
let gain = gainOf.get(context);
|
|
1264
|
+
if (!gain) {
|
|
1265
|
+
gain = context.createGain();
|
|
1266
|
+
gain.gain.value = level;
|
|
1267
|
+
gains.add(gain);
|
|
1268
|
+
connect?.call(gain, target);
|
|
1269
|
+
gainOf.set(context, gain);
|
|
1270
|
+
}
|
|
1271
|
+
return gain;
|
|
1272
|
+
};
|
|
1273
|
+
if (nodeProto && connect && disconnect) {
|
|
1274
|
+
nodeProto.connect = function (target, ...rest) {
|
|
1275
|
+
const gain = gains.has(this) ? null : standIn(target);
|
|
1276
|
+
if (!gain)
|
|
1277
|
+
return connect.call(this, target, ...rest);
|
|
1278
|
+
connect.call(this, gain, ...rest);
|
|
1279
|
+
// Chaining keeps working: connect() answers what it was given.
|
|
1280
|
+
return target;
|
|
1281
|
+
};
|
|
1282
|
+
nodeProto.disconnect = function (...args) {
|
|
1283
|
+
const gain = gains.has(this) ? null : standIn(args[0]);
|
|
1284
|
+
return gain ? disconnect.call(this, gain, ...args.slice(1)) : disconnect.apply(this, args);
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
// Media elements: the volume the game set, and the one played (times `level`).
|
|
1288
|
+
const media = win.HTMLMediaElement?.prototype;
|
|
1289
|
+
const volume = media ? Object.getOwnPropertyDescriptor(media, "volume") : undefined;
|
|
1290
|
+
const wanted = new WeakMap();
|
|
1291
|
+
const elements = weakList();
|
|
1292
|
+
const routed = new WeakSet();
|
|
1293
|
+
const apply = (element) => {
|
|
1294
|
+
const own = wanted.get(element) ?? 1;
|
|
1295
|
+
volume?.set?.call(element, routed.has(element) ? own : own * level);
|
|
1296
|
+
};
|
|
1297
|
+
const track = (element) => {
|
|
1298
|
+
if (!wanted.has(element))
|
|
1299
|
+
wanted.set(element, volume?.get?.call(element) ?? 1);
|
|
1300
|
+
elements.add(element);
|
|
1301
|
+
apply(element);
|
|
1302
|
+
};
|
|
1303
|
+
if (media && volume?.get && volume.set) {
|
|
1304
|
+
const { get, set } = volume;
|
|
1305
|
+
Object.defineProperty(media, "volume", {
|
|
1306
|
+
configurable: true,
|
|
1307
|
+
enumerable: volume.enumerable,
|
|
1308
|
+
get() {
|
|
1309
|
+
return wanted.get(this) ?? get.call(this);
|
|
1310
|
+
},
|
|
1311
|
+
set(value) {
|
|
1312
|
+
// Out of range throws, like the browser's own setter.
|
|
1313
|
+
set.call(this, value);
|
|
1314
|
+
wanted.set(this, value);
|
|
1315
|
+
track(this);
|
|
1316
|
+
},
|
|
1317
|
+
});
|
|
1318
|
+
const play = media.play;
|
|
1319
|
+
media.play = function () {
|
|
1320
|
+
track(this);
|
|
1321
|
+
return play.call(this);
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
const createSource = win.AudioContext?.prototype.createMediaElementSource;
|
|
1325
|
+
if (createSource) {
|
|
1326
|
+
win.AudioContext.prototype.createMediaElementSource = function (element) {
|
|
1327
|
+
routed.add(element);
|
|
1328
|
+
track(element);
|
|
1329
|
+
return createSource.call(this, element);
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
return {
|
|
1333
|
+
set(next) {
|
|
1334
|
+
level = next;
|
|
1335
|
+
for (const gain of gains)
|
|
1336
|
+
gain.gain.value = level;
|
|
1337
|
+
for (const element of elements)
|
|
1338
|
+
apply(element);
|
|
1339
|
+
},
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
|
|
1105
1344
|
|
|
1106
1345
|
|
|
1107
1346
|
|
|
@@ -1119,6 +1358,8 @@ function createTwitch(options) {
|
|
|
1119
1358
|
// const token = await powly.getSessionToken(); // send it to your backend, which verifies it
|
|
1120
1359
|
// const room = await powly.rooms.create(); // realtime parties (src/rooms.ts)
|
|
1121
1360
|
// await powly.twitch.connect(); // the streamer's Twitch chat (src/twitch.ts)
|
|
1361
|
+
// await powly.players.open(playerId); // Powly's profile dialog: add, block, report
|
|
1362
|
+
// powly.audio.level("music"); // Powly's sound menu (src/audio.ts)
|
|
1122
1363
|
//
|
|
1123
1364
|
// Nothing here is a security boundary: the game runs in the player's browser. Your backend must
|
|
1124
1365
|
// verify session tokens (JWKS) or exchange them, and Powly checks everything server-side.
|
|
@@ -1163,6 +1404,12 @@ function init(options = {}) {
|
|
|
1163
1404
|
if (origins.length === 0) {
|
|
1164
1405
|
return Promise.reject(new PowlyError("This game only runs when launched from Powly."));
|
|
1165
1406
|
}
|
|
1407
|
+
const audioChannels = audioChannelsOf(options.audio?.channels ?? []);
|
|
1408
|
+
// Before the game's engine makes a sound, so its connections go through the volume control.
|
|
1409
|
+
const auto = options.audio?.auto !== false;
|
|
1410
|
+
if (auto)
|
|
1411
|
+
masterGain ??= installMasterGain();
|
|
1412
|
+
const master = auto ? masterGain : null;
|
|
1166
1413
|
return new Promise((resolve, reject) => {
|
|
1167
1414
|
const channels = origins.map((origin) => {
|
|
1168
1415
|
const channel = new MessageChannel();
|
|
@@ -1172,6 +1419,10 @@ function init(options = {}) {
|
|
|
1172
1419
|
type: "init",
|
|
1173
1420
|
sdkVersion: SDK_VERSION,
|
|
1174
1421
|
pac: 1,
|
|
1422
|
+
...(isOptionsButtonCorner(options.optionsButton) && {
|
|
1423
|
+
optionsButton: options.optionsButton,
|
|
1424
|
+
}),
|
|
1425
|
+
audio: { channels: audioChannels },
|
|
1175
1426
|
};
|
|
1176
1427
|
channel.port1.onmessage = (event) => {
|
|
1177
1428
|
const data = event.data;
|
|
@@ -1180,7 +1431,7 @@ function init(options = {}) {
|
|
|
1180
1431
|
for (const other of channels)
|
|
1181
1432
|
if (other !== channel)
|
|
1182
1433
|
other.port1.close();
|
|
1183
|
-
resolve(createSession(channel.port1, data));
|
|
1434
|
+
resolve(createSession(channel.port1, data, { channels: audioChannels, master }));
|
|
1184
1435
|
}
|
|
1185
1436
|
else if (data?.type === "failed") {
|
|
1186
1437
|
clearTimeout(timer);
|
|
@@ -1198,13 +1449,47 @@ function init(options = {}) {
|
|
|
1198
1449
|
}, options.timeoutMs ?? 10_000);
|
|
1199
1450
|
});
|
|
1200
1451
|
}
|
|
1201
|
-
|
|
1452
|
+
/** The page's volume control (src/audio.ts), put in once even if init runs again. */
|
|
1453
|
+
let masterGain = null;
|
|
1454
|
+
/** The sound settings Powly sent, with every channel (on, at full volume, when it sent none). */
|
|
1455
|
+
function audioStateOf(value, channels) {
|
|
1456
|
+
const sent = (typeof value === "object" && value !== null ? value : {});
|
|
1457
|
+
const levels = ((typeof sent.channels === "object" && sent.channels) || {});
|
|
1458
|
+
return {
|
|
1459
|
+
volume: volumeOf(sent.volume) ?? 1,
|
|
1460
|
+
muted: sent.muted === true,
|
|
1461
|
+
channels: Object.fromEntries(channels.map((channel) => {
|
|
1462
|
+
const level = levels[channel.id];
|
|
1463
|
+
return [channel.id, { volume: volumeOf(level?.volume) ?? 1, on: level?.on !== false }];
|
|
1464
|
+
})),
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
function createSession(port, ready, sound) {
|
|
1202
1468
|
let token = ready.token;
|
|
1203
1469
|
let expiresAt = Date.now() + ready.expiresIn * 1000;
|
|
1204
1470
|
let refreshing = null;
|
|
1205
1471
|
let nextId = 1;
|
|
1206
1472
|
const pending = new Map();
|
|
1207
1473
|
const listeners = new Map();
|
|
1474
|
+
// "shown" comes once; games that listen for it later still hear it.
|
|
1475
|
+
let shown = false;
|
|
1476
|
+
// The player's sound settings; the page's volume follows them (auto).
|
|
1477
|
+
let channels = sound.channels;
|
|
1478
|
+
let audio = audioStateOf(ready.audio, channels);
|
|
1479
|
+
const audioListeners = new Set();
|
|
1480
|
+
sound.master?.set(masterLevel(audio));
|
|
1481
|
+
const setAudio = (next) => {
|
|
1482
|
+
audio = next;
|
|
1483
|
+
sound.master?.set(masterLevel(audio));
|
|
1484
|
+
for (const listener of audioListeners) {
|
|
1485
|
+
try {
|
|
1486
|
+
listener(audio);
|
|
1487
|
+
}
|
|
1488
|
+
catch (error) {
|
|
1489
|
+
console.error("[powly] an audio listener failed", error);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
};
|
|
1208
1493
|
port.onmessage = (event) => {
|
|
1209
1494
|
const data = event.data;
|
|
1210
1495
|
if (data?.type === "response") {
|
|
@@ -1218,7 +1503,19 @@ function createSession(port, ready) {
|
|
|
1218
1503
|
waiting.reject(new PowlyError(data.error));
|
|
1219
1504
|
}
|
|
1220
1505
|
else if (data?.type === "event") {
|
|
1221
|
-
|
|
1506
|
+
const message = data;
|
|
1507
|
+
if (message.event === "audio") {
|
|
1508
|
+
if ("audio" in message)
|
|
1509
|
+
setAudio(audioStateOf(message.audio, channels));
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
const name = message.event;
|
|
1513
|
+
if (name === "shown") {
|
|
1514
|
+
if (shown)
|
|
1515
|
+
return;
|
|
1516
|
+
shown = true;
|
|
1517
|
+
}
|
|
1518
|
+
for (const listener of listeners.get(name) ?? [])
|
|
1222
1519
|
listener();
|
|
1223
1520
|
}
|
|
1224
1521
|
};
|
|
@@ -1278,7 +1575,10 @@ function createSession(port, ready) {
|
|
|
1278
1575
|
invite: sendInvite,
|
|
1279
1576
|
});
|
|
1280
1577
|
listeners.get("ended")?.add(() => realtime.stop());
|
|
1281
|
-
const twitch = createTwitch({
|
|
1578
|
+
const twitch = createTwitch({
|
|
1579
|
+
getInfo: () => api("/twitch"),
|
|
1580
|
+
prompt: async () => (await call("twitch.connect")).connected,
|
|
1581
|
+
});
|
|
1282
1582
|
listeners.get("ended")?.add(() => twitch.stop());
|
|
1283
1583
|
const saves = {
|
|
1284
1584
|
get: async (key) => {
|
|
@@ -1325,6 +1625,52 @@ function createSession(port, ready) {
|
|
|
1325
1625
|
return (await call("invites.open", { params, members }));
|
|
1326
1626
|
},
|
|
1327
1627
|
},
|
|
1628
|
+
players: {
|
|
1629
|
+
open: async (playerId, options = {}) => (await call("players.open", {
|
|
1630
|
+
playerId,
|
|
1631
|
+
...(options.view && { view: options.view }),
|
|
1632
|
+
})),
|
|
1633
|
+
},
|
|
1634
|
+
audio: {
|
|
1635
|
+
get volume() {
|
|
1636
|
+
return audio.volume;
|
|
1637
|
+
},
|
|
1638
|
+
get muted() {
|
|
1639
|
+
return audio.muted;
|
|
1640
|
+
},
|
|
1641
|
+
get channels() {
|
|
1642
|
+
return Object.fromEntries(Object.entries(audio.channels).map(([id, state]) => [id, { ...state }]));
|
|
1643
|
+
},
|
|
1644
|
+
level(channel) {
|
|
1645
|
+
const state = channel === undefined ? undefined : audio.channels[channel];
|
|
1646
|
+
const own = state ? (state.on ? state.volume : 0) : 1;
|
|
1647
|
+
return sound.master ? own : own * masterLevel(audio);
|
|
1648
|
+
},
|
|
1649
|
+
async register(channel) {
|
|
1650
|
+
const next = audioChannelsOf([
|
|
1651
|
+
...channels.filter((existing) => existing.id !== channel?.id),
|
|
1652
|
+
channel,
|
|
1653
|
+
]);
|
|
1654
|
+
if (!next.some((entry) => entry.id === channel?.id)) {
|
|
1655
|
+
throw new PowlyError(`A channel needs an id of letters, digits, _ and -, and a game has up to ${MAX_AUDIO_CHANNELS}.`);
|
|
1656
|
+
}
|
|
1657
|
+
// Same order as before; a changed channel keeps its place.
|
|
1658
|
+
channels = [
|
|
1659
|
+
...channels.map((existing) => next.find((entry) => entry.id === existing.id) ?? existing),
|
|
1660
|
+
...next.filter((entry) => !channels.some((existing) => existing.id === entry.id)),
|
|
1661
|
+
];
|
|
1662
|
+
const result = await call("audio.channels", { channels });
|
|
1663
|
+
setAudio(audioStateOf(result, channels));
|
|
1664
|
+
},
|
|
1665
|
+
async set(changes) {
|
|
1666
|
+
const result = await call("audio.set", { audio: changes });
|
|
1667
|
+
setAudio(audioStateOf(result, channels));
|
|
1668
|
+
},
|
|
1669
|
+
on(_event, listener) {
|
|
1670
|
+
audioListeners.add(listener);
|
|
1671
|
+
return () => audioListeners.delete(listener);
|
|
1672
|
+
},
|
|
1673
|
+
},
|
|
1328
1674
|
rooms: realtime.rooms,
|
|
1329
1675
|
twitch: {
|
|
1330
1676
|
info: twitch.info,
|
|
@@ -1360,12 +1706,24 @@ function createSession(port, ready) {
|
|
|
1360
1706
|
await call("fullscreen.toggle");
|
|
1361
1707
|
},
|
|
1362
1708
|
},
|
|
1709
|
+
quit: async () => {
|
|
1710
|
+
await call("quit");
|
|
1711
|
+
},
|
|
1363
1712
|
on(event, listener) {
|
|
1364
1713
|
const set = listeners.get(event) ?? new Set();
|
|
1365
1714
|
set.add(listener);
|
|
1366
1715
|
listeners.set(event, set);
|
|
1716
|
+
if (event === "shown" && shown) {
|
|
1717
|
+
queueMicrotask(() => {
|
|
1718
|
+
if (set.has(listener))
|
|
1719
|
+
listener();
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1367
1722
|
return () => set.delete(listener);
|
|
1368
1723
|
},
|
|
1724
|
+
get shown() {
|
|
1725
|
+
return shown;
|
|
1726
|
+
},
|
|
1369
1727
|
};
|
|
1370
1728
|
}
|
|
1371
1729
|
const Powly = { init, SDK_VERSION, PowlyError };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@powly/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Powly game SDK: launch handshake, session tokens and platform services.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -46,4 +46,4 @@
|
|
|
46
46
|
"example:zip": "node scripts/example-zip.ts",
|
|
47
47
|
"mirror": "node scripts/mirror.ts"
|
|
48
48
|
}
|
|
49
|
-
}
|
|
49
|
+
}
|