mirror of
https://github.com/giongto35/cloud-game.git
synced 2026-07-18 09:06:00 +00:00
This PR contains refactored code.
**Changelog**
- Added new net code (the communication architecture was left intact).
- All network client IDs now have custom type `network.Uid` backed by github.com/rs/xid lib.
```
The string representation of a UUID takes 32 bytes, and the new type will take just 16.
Because of Golang JSON serialization problems with omitting zero-length empty slices (it can't)
and the need to use UID values as map keys (maps don't support slices as keys),
IDs are stored as strings (for now).
```
- A whole new WebSocket client/server implementation was added, as well as a new communication layer with synchronous and async call handlers.
- WebSocket connections now support dedicated Ping/Pong frames as opposed to original ping text messages.
- Used Gorilla WebSocket library doesn't allow concurrent (simultaneous) reads and writes, so this part was handled via send channel synchronization.
- New API structures can be found in the `pkg/api` folder.
- New communication protocol can be found in the `pkg/com/*` folder.
- Updated communication protocol is based on JSON-encoded messaging through WebSocket and has the following structure:
```
Packet
[id] string — a globally unique identification tag for the packet to track it trough a chain of requests.
t uint8 — contains packet type information (i.e. INIT_PACKET, SDP_OFFER_PACKET, ...).
[p] any — contains packet data (any type).
Each packet is a text message in JSON-serialized form (WebSocket control frames obviously not).
```
```
The main principle of this protocol and the duplex data exchange is:
the one who initializes connection is called a client, and
the one who is being connected to is called a server.
With the current architecture, the coordinator is the server, the user browsers and workers are the clients.
____ ____
↓ ↑ ↑ ↓
browser ⟶ coordinator ⟵ worker
(c) (s) (c)
One of the most crucial performance vise parts of these interactions is that
all the server-initiated calls to clients should be asynchronous!
```
- In order to track synchronous calls (packets) with an asynchronous protocol, such as WebSocket, each packet may have an `id` that should be copied in all subsequent requests/responses.
- The old `sessionID` param was replaced by `id` that should be stored inside the `p` (payload) part of the packet.
- It is possible to skip the default ping check for all connected workers on every user connection and just pick the first available with the new roundRobin param in the coordinator config file `coordinator.roundRobin: true/false`.
- Added a dedicated package for the system API (pkg/api/*).
- Added structured logging system (zerolog) for better logging and cloud services integration.
- Added a visual representation of the network message exchange in logs:
```
...
01:00:01.1078 3f98 INF w → c Handshake ws://localhost:8000/wso
01:00:01.1138 994 INF c ← w Handshake localhost:8000
01:00:01.1148 994 INF c ← w Connect cid=cep.hrg
01:00:01.1158 994 DBG c connection id has been changed to cepl7obdrc3jv66kp2ug cid=cep.hrg
01:00:01.1158 3f98 INF w → c Connect cid=cep.2ug
01:00:01.1158 994 INF c New worker / addr: localhost, ...
01:00:01.1158 3f98 INF w Connected to the coordinator localhost:8000 cid=cep.2ug
01:00:02.5834 994 INF c ← u Handshake localhost:8000
01:00:02.6175 994 INF c ← u Connect cid=cep.hs0
01:00:02.6209 994 INF c Search available workers cid=cep.hs0
01:00:02.6214 994 INF c Found next free worker cid=cep.hs0
01:00:02.6220 994 INF c → u InitSession cid=cep.hs0
01:00:02.6527 994 INF c ← u WebrtcInit cid=cep.hs0
01:00:02.6527 994 INF c → w ᵇWebrtcInit cid=cep.hrg
01:00:02.6537 3f98 INF w ← c WebrtcInit cid=cep.2ug
01:00:02.6537 3f98 INF w WebRTC start cid=cep.2ug
...
```
- Replaced a monstrous Prometheus metrics lib.
- Removed spflag dependency.
- Added new `version` config file param/constant for compatibility reasons.
- Bump the minimum required version for Go to 1.18 due to use of generics.
- Opus encoder now is cached and the default config is 96Kbps, complexity 5 (was 196Kbps, 8).
- Changed the default x264 quality parameters to `crf 23 / superfast / baseline` instead of `crf 17 / veryfast / main`.
- Added a separate WebRTC logging config param `webrtc.logLevel`.
- Worker now allocates much less memory.
- Optimized and fixed RGB to YUV converter.
- `--v=5` logging cmd flag was removed and replaced with the `debug` config parameter.
**Breaking changes (migration to v3)**
- Coordinator server API changes, see web/js/api/api.js.
- Coordinator client event API changes:
- c `GAME_PLAYER_IDX_CHANGE` (string) -> `GAME_PLAYER_IDX` (number)
- c `GAME_PLAYER_IDX` -> `GAME_PLAYER_IDX_SET`
- c `MEDIA_STREAM_INITIALIZED` -> `WEBRTC_NEW_CONNECTION`
- c `MEDIA_STREAM_SDP_AVAILABLE` -> `WEBRTC_SDP_OFFER`
- c `MEDIA_STREAM_CANDIDATE_ADD` -> `WEBRTC_ICE_CANDIDATE_RECEIVED`
- c `MEDIA_STREAM_CANDIDATE_FLUSH` -> `WEBRTC_ICE_CANDIDATES_FLUSH`
- x `MEDIA_STREAM_READY` -> **removed**
- c `CONNECTION_READY` -> `WEBRTC_CONNECTION_READY`
- c `CONNECTION_CLOSED` -> `WEBRTC_CONNECTION_CLOSED`
- c `GET_SERVER_LIST` -> `WORKER_LIST_FETCHED`
- x `KEY_STATE_UPDATED` -> **removed**
- n `WEBRTC_ICE_CANDIDATE_FOUND`
- n `WEBRTC_SDP_ANSWER`
- n `MESSAGE`
- `rtcp` module renamed to `webrtc`.
- Controller state equals Libretro controller state (changed order of bits), see: web/js/input/input.js.
- Added new `coordintaor.selector` config param that changes the selection algorithm for workers. By default it will select any free worker. Set this param to `ping` for the old behavior.
- Changed the name of the `webrtc.iceServers.url` config param to `webrtc.iceServers.urls`.
537 lines
18 KiB
JavaScript
537 lines
18 KiB
JavaScript
/**
|
|
* App controller module.
|
|
* @version 1
|
|
*/
|
|
(() => {
|
|
// application state
|
|
let state;
|
|
let lastState;
|
|
|
|
// first user interaction
|
|
let interacted = false;
|
|
|
|
// ping-pong
|
|
// let pingPong = 0;
|
|
|
|
const DIR = (() => {
|
|
return {
|
|
IDLE: 'idle',
|
|
UP: 'up',
|
|
DOWN: 'down',
|
|
}
|
|
})();
|
|
let prevDir = DIR.IDLE;
|
|
|
|
const menuScreen = document.getElementById('menu-screen');
|
|
const helpOverlay = document.getElementById('help-overlay');
|
|
const playerIndex = document.getElementById('playeridx');
|
|
|
|
// keymap
|
|
const keyButtons = {};
|
|
Object.keys(KEY).forEach(button => {
|
|
keyButtons[KEY[button]] = document.getElementById(`btn-${KEY[button]}`);
|
|
});
|
|
|
|
/**
|
|
* State machine transition.
|
|
* @param newState A new state strictly from app.state.*
|
|
* @example
|
|
* setState(app.state.eden)
|
|
*/
|
|
const setState = (newState = app.state.eden) => {
|
|
if (newState === state) return;
|
|
|
|
const prevState = state;
|
|
|
|
// keep the current state intact for one of the "uber" states
|
|
if (state && state._uber) {
|
|
// if we are done with the uber state
|
|
if (lastState === newState) state = newState;
|
|
lastState = newState;
|
|
} else {
|
|
lastState = state
|
|
state = newState;
|
|
}
|
|
|
|
if (log.level === log.DEBUG) {
|
|
const previous = prevState ? prevState.name : '???';
|
|
const current = state ? state.name : '???';
|
|
const kept = lastState ? lastState.name : '???';
|
|
|
|
log.debug(`[state] ${previous} -> ${current} [${kept}]`);
|
|
}
|
|
};
|
|
|
|
const onGameRoomAvailable = () => {
|
|
message.show('Now you can share you game!');
|
|
};
|
|
|
|
// const onWebrtcMessage = () => {
|
|
// event.pub(PING_RESPONSE);
|
|
// };
|
|
|
|
const onConnectionReady = () => {
|
|
// ping / pong
|
|
// if (pingPong === 0) {
|
|
// pingPong = setInterval(() => {
|
|
// if (!webrtc.message('x')) {
|
|
// clearInterval(pingPong);
|
|
// pingPong = 0;
|
|
// log.info("ping-pong was disabled due to remote channel error");
|
|
// }
|
|
// event.pub(PING_REQUEST, {time: Date.now()})
|
|
// }, 10000);
|
|
// }
|
|
|
|
// start a game right away or show the menu
|
|
if (room.getId()) {
|
|
startGame();
|
|
} else {
|
|
state.menuReady();
|
|
}
|
|
};
|
|
|
|
const onLatencyCheck = async (data) => {
|
|
message.show('Connecting to fastest server...');
|
|
const servers = await workerManager.checkLatencies(data);
|
|
const latencies = Object.assign({}, ...servers);
|
|
log.info('[ping] <->', latencies);
|
|
api.server.latencyCheck(data.packetId, latencies);
|
|
};
|
|
|
|
const helpScreen = {
|
|
// don't call $ if holding the button
|
|
shown: false,
|
|
// use function () if you need "this"
|
|
show: function (show, event) {
|
|
if (this.shown === show) return;
|
|
|
|
const isGameScreen = state === app.state.game
|
|
if (isGameScreen) {
|
|
stream.toggle(!show);
|
|
} else {
|
|
gui.toggle(menuScreen, !show);
|
|
}
|
|
|
|
gui.toggle(keyButtons[KEY.SAVE], show || isGameScreen);
|
|
gui.toggle(keyButtons[KEY.LOAD], show || isGameScreen);
|
|
|
|
gui.toggle(helpOverlay, show)
|
|
|
|
this.shown = show;
|
|
|
|
if (event) event.pub(HELP_OVERLAY_TOGGLED, {shown: show});
|
|
}
|
|
};
|
|
|
|
const showMenuScreen = () => {
|
|
log.debug('[control] loading menu screen');
|
|
|
|
stream.toggle(false);
|
|
gui.hide(keyButtons[KEY.SAVE]);
|
|
gui.hide(keyButtons[KEY.LOAD]);
|
|
|
|
gameList.show();
|
|
gui.show(menuScreen);
|
|
|
|
setState(app.state.menu);
|
|
};
|
|
|
|
const startGame = () => {
|
|
if (!webrtc.isConnected()) {
|
|
message.show('Game cannot load. Please refresh');
|
|
return;
|
|
}
|
|
|
|
if (!webrtc.isInputReady()) {
|
|
message.show('Game is not ready yet. Please wait');
|
|
return;
|
|
}
|
|
|
|
log.info('[control] game start');
|
|
|
|
setState(app.state.game);
|
|
|
|
stream.play()
|
|
|
|
// TODO get current game from the URL and not from the list?
|
|
// if we are opening a share link it will send the default game name to the server
|
|
// currently it's a game with the index 1
|
|
// on the server this game is ignored and the actual game will be extracted from the share link
|
|
// so there's no point in doing this and this' really confusing
|
|
|
|
api.game.start(
|
|
gameList.getCurrentGame(),
|
|
room.getId(),
|
|
recording.isActive(),
|
|
recording.getUser(),
|
|
+playerIndex.value - 1,
|
|
);
|
|
|
|
// clear menu screen
|
|
input.poll.disable();
|
|
gui.hide(menuScreen);
|
|
stream.toggle(true);
|
|
gui.show(keyButtons[KEY.SAVE]);
|
|
gui.show(keyButtons[KEY.LOAD]);
|
|
// end clear
|
|
input.poll.enable();
|
|
};
|
|
|
|
const saveGame = utils.debounce(() => api.game.save(), 1000);
|
|
const loadGame = utils.debounce(() => api.game.load(), 1000);
|
|
|
|
const onMessage = (message) => {
|
|
const {id, t, p: payload} = message;
|
|
switch (t) {
|
|
case api.endpoint.INIT:
|
|
event.pub(WEBRTC_NEW_CONNECTION, payload);
|
|
break;
|
|
case api.endpoint.OFFER:
|
|
event.pub(WEBRTC_SDP_OFFER, {sdp: payload});
|
|
break;
|
|
case api.endpoint.ICE_CANDIDATE:
|
|
event.pub(WEBRTC_ICE_CANDIDATE_RECEIVED, {candidate: payload});
|
|
break;
|
|
case api.endpoint.GAME_START:
|
|
event.pub(GAME_ROOM_AVAILABLE, {roomId: payload});
|
|
break;
|
|
case api.endpoint.GAME_SAVE:
|
|
event.pub(GAME_SAVED);
|
|
break;
|
|
case api.endpoint.GAME_LOAD:
|
|
event.pub(GAME_LOADED);
|
|
break;
|
|
case api.endpoint.GAME_SET_PLAYER_INDEX:
|
|
event.pub(GAME_PLAYER_IDX_SET, payload);
|
|
break;
|
|
case api.endpoint.GET_WORKER_LIST:
|
|
event.pub(WORKER_LIST_FETCHED, payload);
|
|
break;
|
|
case api.endpoint.LATENCY_CHECK:
|
|
event.pub(LATENCY_CHECK_REQUESTED, {packetId: id, addresses: payload});
|
|
break;
|
|
case api.endpoint.GAME_RECORDING:
|
|
event.pub(RECORDING_STATUS_CHANGED, payload);
|
|
break;
|
|
}
|
|
}
|
|
|
|
const _dpadArrowKeys = [KEY.UP, KEY.DOWN, KEY.LEFT, KEY.RIGHT];
|
|
|
|
// pre-state key press handler
|
|
const onKeyPress = (data) => {
|
|
const button = keyButtons[data.key];
|
|
|
|
if (_dpadArrowKeys.includes(data.key)) {
|
|
button.classList.add('dpad-pressed');
|
|
} else {
|
|
if (button) button.classList.add('pressed');
|
|
}
|
|
|
|
if (state !== app.state.settings) {
|
|
if (KEY.HELP === data.key) helpScreen.show(true, event);
|
|
}
|
|
|
|
state.keyPress(data.key);
|
|
};
|
|
|
|
// pre-state key release handler
|
|
const onKeyRelease = data => {
|
|
const button = keyButtons[data.key];
|
|
|
|
if (_dpadArrowKeys.includes(data.key)) {
|
|
button.classList.remove('dpad-pressed');
|
|
} else {
|
|
if (button) button.classList.remove('pressed');
|
|
}
|
|
|
|
if (state !== app.state.settings) {
|
|
if (KEY.HELP === data.key) helpScreen.show(false, event);
|
|
}
|
|
|
|
// maybe move it somewhere
|
|
if (!interacted) {
|
|
// unmute when there is user interaction
|
|
stream.audio.mute(false);
|
|
interacted = true;
|
|
}
|
|
|
|
// change app state if settings
|
|
if (KEY.SETTINGS === data.key) setState(app.state.settings);
|
|
|
|
state.keyRelease(data.key);
|
|
};
|
|
|
|
const updatePlayerIndex = idx => {
|
|
playerIndex.value = idx + 1;
|
|
api.game.setPlayerIndex(idx);
|
|
};
|
|
|
|
// noop function for the state
|
|
const _nil = () => ({/*_*/})
|
|
|
|
const onAxisChanged = (data) => {
|
|
// maybe move it somewhere
|
|
if (!interacted) {
|
|
// unmute when there is user interaction
|
|
stream.audio.mute(false);
|
|
interacted = true;
|
|
}
|
|
|
|
state.axisChanged(data.id, data.value);
|
|
};
|
|
|
|
const handleToggle = () => {
|
|
const toggle = document.getElementById('dpad-toggle');
|
|
toggle.checked = !toggle.checked;
|
|
event.pub(DPAD_TOGGLE, {checked: toggle.checked});
|
|
};
|
|
|
|
const handleRecording = (data) => {
|
|
const {recording, userName} = data;
|
|
api.game.toggleRecording(recording, userName);
|
|
}
|
|
|
|
const handleRecordingStatus = (data) => {
|
|
if (data === 'ok') {
|
|
message.show(`Recording ${recording.isActive() ? 'on' : 'off'}`, true)
|
|
if (recording.isActive()) {
|
|
recording.setIndicator(true)
|
|
}
|
|
} else {
|
|
message.show(`Recording failed ):`)
|
|
recording.setIndicator(false)
|
|
}
|
|
console.log("recording is ", recording.isActive())
|
|
}
|
|
|
|
const _default = {
|
|
name: 'default',
|
|
axisChanged: _nil,
|
|
keyPress: _nil,
|
|
keyRelease: _nil,
|
|
menuReady: _nil,
|
|
}
|
|
const app = {
|
|
state: {
|
|
eden: {
|
|
..._default,
|
|
name: 'eden',
|
|
menuReady: showMenuScreen
|
|
},
|
|
|
|
settings: {
|
|
..._default,
|
|
_uber: true,
|
|
name: 'settings',
|
|
keyRelease: key => {
|
|
if (key === KEY.SETTINGS) {
|
|
const isSettingsOpened = settings.ui.toggle();
|
|
if (!isSettingsOpened) setState(lastState);
|
|
}
|
|
},
|
|
menuReady: showMenuScreen
|
|
},
|
|
|
|
menu: {
|
|
..._default,
|
|
name: 'menu',
|
|
axisChanged: (id, value) => {
|
|
if (id === 1) { // Left Stick, Y Axis
|
|
let dir = DIR.IDLE;
|
|
if (value < -0.5) dir = DIR.UP;
|
|
if (value > 0.5) dir = DIR.DOWN;
|
|
if (dir !== prevDir) {
|
|
prevDir = dir;
|
|
switch (dir) {
|
|
case DIR.IDLE:
|
|
gameList.stopGamePickerTimer();
|
|
break;
|
|
case DIR.UP:
|
|
gameList.startGamePickerTimer(true);
|
|
break;
|
|
case DIR.DOWN:
|
|
gameList.startGamePickerTimer(false);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
keyPress: (key) => {
|
|
switch (key) {
|
|
case KEY.UP:
|
|
case KEY.DOWN:
|
|
gameList.startGamePickerTimer(key === KEY.UP);
|
|
break;
|
|
}
|
|
},
|
|
keyRelease: (key) => {
|
|
switch (key) {
|
|
case KEY.UP:
|
|
case KEY.DOWN:
|
|
gameList.stopGamePickerTimer();
|
|
break;
|
|
case KEY.JOIN:
|
|
case KEY.A:
|
|
case KEY.B:
|
|
case KEY.X:
|
|
case KEY.Y:
|
|
case KEY.START:
|
|
case KEY.SELECT:
|
|
startGame();
|
|
break;
|
|
case KEY.QUIT:
|
|
message.show('You are already in menu screen!');
|
|
break;
|
|
case KEY.LOAD:
|
|
message.show('Loading the game.');
|
|
break;
|
|
case KEY.SAVE:
|
|
message.show('Saving the game.');
|
|
break;
|
|
case KEY.STATS:
|
|
event.pub(STATS_TOGGLE);
|
|
break;
|
|
case KEY.SETTINGS:
|
|
break;
|
|
case KEY.DTOGGLE:
|
|
handleToggle();
|
|
break;
|
|
}
|
|
},
|
|
},
|
|
|
|
game: {
|
|
..._default,
|
|
name: 'game',
|
|
axisChanged: (id, value) => input.setAxisChanged(id, value),
|
|
keyPress: key => input.setKeyState(key, true),
|
|
keyRelease: function (key) {
|
|
input.setKeyState(key, false);
|
|
|
|
switch (key) {
|
|
case KEY.JOIN: // or SHARE
|
|
// save when click share
|
|
saveGame();
|
|
room.copyToClipboard();
|
|
message.show('Shared link copied to the clipboard!');
|
|
break;
|
|
case KEY.SAVE:
|
|
saveGame();
|
|
break;
|
|
case KEY.LOAD:
|
|
loadGame();
|
|
break;
|
|
case KEY.FULL:
|
|
stream.video.toggleFullscreen();
|
|
break;
|
|
|
|
// update player index
|
|
case KEY.PAD1:
|
|
updatePlayerIndex(0);
|
|
break;
|
|
case KEY.PAD2:
|
|
updatePlayerIndex(1);
|
|
break;
|
|
case KEY.PAD3:
|
|
updatePlayerIndex(2);
|
|
break;
|
|
case KEY.PAD4:
|
|
updatePlayerIndex(3);
|
|
break;
|
|
|
|
// toggle multitap
|
|
case KEY.MULTITAP:
|
|
api.game.toggleMultitap();
|
|
break;
|
|
|
|
// quit
|
|
case KEY.QUIT:
|
|
input.poll.disable();
|
|
|
|
api.game.quit(room.getId());
|
|
room.reset();
|
|
|
|
message.show('Quit!');
|
|
|
|
window.location = window.location.pathname;
|
|
break;
|
|
|
|
case KEY.STATS:
|
|
event.pub(STATS_TOGGLE);
|
|
break;
|
|
case KEY.DTOGGLE:
|
|
handleToggle();
|
|
break;
|
|
}
|
|
},
|
|
}
|
|
}
|
|
};
|
|
|
|
// subscriptions
|
|
event.sub(MESSAGE, onMessage);
|
|
|
|
event.sub(GAME_ROOM_AVAILABLE, onGameRoomAvailable, 2);
|
|
event.sub(GAME_SAVED, () => message.show('Saved'));
|
|
event.sub(GAME_LOADED, () => message.show('Loaded'));
|
|
event.sub(GAME_PLAYER_IDX, data => {
|
|
updatePlayerIndex(+data.index);
|
|
});
|
|
event.sub(GAME_PLAYER_IDX_SET, idx => {
|
|
if (!isNaN(+idx)) message.show(+idx + 1);
|
|
});
|
|
event.sub(WEBRTC_NEW_CONNECTION, (data) => {
|
|
// if (pingPong) {
|
|
// webrtc.setMessageHandler(onWebrtcMessage);
|
|
// }
|
|
workerManager.whoami(data.wid);
|
|
webrtc.start(data.ice);
|
|
api.server.initWebrtc()
|
|
gameList.set(data.games);
|
|
});
|
|
event.sub(WEBRTC_ICE_CANDIDATE_FOUND, (data) => api.server.sendIceCandidate(data.candidate));
|
|
event.sub(WEBRTC_SDP_ANSWER, (data) => api.server.sendSdp(data.sdp));
|
|
event.sub(WEBRTC_SDP_OFFER, (data) => webrtc.setRemoteDescription(data.sdp, stream.video.el()));
|
|
event.sub(WEBRTC_ICE_CANDIDATE_RECEIVED, (data) => webrtc.addCandidate(data.candidate));
|
|
event.sub(WEBRTC_ICE_CANDIDATES_FLUSH, () => webrtc.flushCandidates());
|
|
// event.sub(MEDIA_STREAM_READY, () => rtcp.start());
|
|
event.sub(WEBRTC_CONNECTION_READY, onConnectionReady);
|
|
event.sub(WEBRTC_CONNECTION_CLOSED, () => {
|
|
input.poll.disable();
|
|
// if (pingPong > 0) {
|
|
// clearInterval(pingPong);
|
|
// pingPong = 0;
|
|
// }
|
|
webrtc.stop();
|
|
});
|
|
event.sub(LATENCY_CHECK_REQUESTED, onLatencyCheck);
|
|
event.sub(GAMEPAD_CONNECTED, () => message.show('Gamepad connected'));
|
|
event.sub(GAMEPAD_DISCONNECTED, () => message.show('Gamepad disconnected'));
|
|
// touch stuff
|
|
event.sub(MENU_HANDLER_ATTACHED, (data) => {
|
|
menuScreen.addEventListener(data.event, data.handler, {passive: true});
|
|
});
|
|
event.sub(KEY_PRESSED, onKeyPress);
|
|
event.sub(KEY_RELEASED, onKeyRelease);
|
|
event.sub(SETTINGS_CHANGED, () => message.show('Settings have been updated'));
|
|
event.sub(SETTINGS_CLOSED, () => {
|
|
state.keyRelease(KEY.SETTINGS);
|
|
});
|
|
event.sub(AXIS_CHANGED, onAxisChanged);
|
|
event.sub(CONTROLLER_UPDATED, data => webrtc.input(data));
|
|
// recording
|
|
event.sub(RECORDING_TOGGLED, handleRecording);
|
|
event.sub(RECORDING_STATUS_CHANGED, handleRecordingStatus);
|
|
|
|
event.sub(SETTINGS_CHANGED, () => {
|
|
const newValue = settings.get()[opts.LOG_LEVEL];
|
|
if (newValue !== log.level) {
|
|
log.level = newValue;
|
|
}
|
|
});
|
|
|
|
// initial app state
|
|
setState(app.state.eden);
|
|
})(api, document, event, env, gameList, input, KEY, log, message, recording, room, settings, socket, stats, stream, utils, webrtc, workerManager);
|