Initial new frontend rewrite (#129)

* Initial new frontend rewrite

* Refactor some module deps

* Fix options ref in the ajax module

* Refactor

* Add controller state

* Refactor
This commit is contained in:
sergystepanov 2019-11-04 21:44:19 +03:00 committed by giongto35
parent 1adcfb55cb
commit 3d1f1ee587
27 changed files with 1591 additions and 1496 deletions

4
web/css/main.css vendored
View file

@ -389,6 +389,10 @@ body {
background-color: #333 !important;
}
#bottom-screen {
/* popups under the screen fix */
z-index: -1;
}
#game-screen {
overflow: hidden;

28
web/game.html vendored
View file

@ -85,21 +85,25 @@
STUNTURN = {{.STUNTURN}};
</script>
<script src="/static/js/jquery-3.3.1.min.js?3"></script>
<script src="/static/js/utils.js?3"></script>
<script src="/static/js/lib/jquery-3.4.1.min.js"></script>
<!-- https://rawgit.com/Rillke/opus.js-sample/master/index.xhtml -->
<script src="/static/js/libopus.js?3"></script>
<script src="/static/js/opus.js?3"></script>
<script src="/static/js/log.js?v=1"></script>
<script src="/static/js/env.js?v=1"></script>
<script src="/static/js/event/event.js?v=1"></script>
<script src="/static/js/input/keys.js?v=1"></script>
<script src="/static/js/input/input.js?v=1"></script>
<script src="/static/js/gameList.js?v=1"></script>
<script src="/static/js/room.js?v=1"></script>
<script src="/static/js/controller.js?v=1"></script>
<script src="/static/js/input/keyboard.js?v=1"></script>
<script src="/static/js/input/touch.js?v=1"></script>
<script src="/static/js/input/joystick.js?v=1"></script>
<script src="/static/js/network/ajax.js?v=1"></script>
<script src="/static/js/network/socket.js?v=1"></script>
<script src="/static/js/network/rtcp.js?v=1"></script>
<script src="/static/js/global.js?3"></script>
<script src="/static/js/controller.js?3"></script>
<script src="/static/js/gesture_keyboard.js?3"></script>
<script src="/static/js/gesture_touch.js?3"></script>
<script src="/static/js/gesture_joystick.js?3"></script>
<script src="/static/js/ws.js?6"></script>
<script src="/static/js/init.js?v=1"></script>
<script src="/static/js/init.js?2"></script>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-145078282-1"></script>
<script>

545
web/js/controller.js vendored
View file

@ -1,262 +1,327 @@
/*
Menu Controller
*/
/**
* App controller module.
* @version 1
*/
/* const controller = */
(() => {
// current app state
let state;
function showHelpScreen() {
// show btn-save, btn-load
if (screenState === "menu") {
$("#btn-save").show();
$("#btn-load").show();
// flags
// first user interaction
// used for mute/unmute
let interacted = false;
$("#menu-screen").hide();
} else {
$("#game-screen").hide();
}
// show help overlay
$("#help-overlay").show();
}
function hideHelpScreen() {
//
if (screenState === "menu") {
$("#btn-save").hide();
$("#btn-load").hide();
$("#menu-screen").show();
} else {
$("#game-screen").show();
}
// show help overlay
$("#help-overlay").hide();
}
function reloadGameMenu() {
log("Load game menu");
// sort gameList first
gameList.sort(function (a, b) {
return a > b ? 1 : -1;
// UI elements
// use $element[0] for DOM element
const gameScreen = $('#game-screen');
const menuScreen = $('#menu-screen');
const helpOverlay = $('#help-overlay');
const popupBox = $('#noti-box');
// keymap
const keyButtons = {};
Object.keys(KEY).forEach(button => {
keyButtons[KEY[button]] = $(`#btn-${KEY[button]}`);
});
// generate html
var listbox = $("#menu-container");
listbox.html('');
gameList.forEach(function (game) {
listbox.append(`<div class="menu-item unselectable" unselectable="on"><div><span>${game}</span></div></div>`);
});
}
const setState = (newState) => {
log.debug(`[control] [s] ${state ? state.name : '???'} -> ${newState.name}`);
state = newState;
};
function showMenuScreen() {
// clear scenes
$("#game-screen").hide();
$("#menu-screen").hide();
$("#btn-save").hide();
$("#btn-load").hide();
$("#btn-join").html("play");
const onGameRoomAvailable = () => {
keyButtons[KEY.JOIN].html('share');
popup('Now you can share you game!');
};
// show menu scene
$("#game-screen").show().delay(DEBUG ? 0 : 0).fadeOut(DEBUG ? 0 : 0, function () {
log("Loading menu screen");
$("#menu-screen").fadeIn(DEBUG ? 0 : 0, function () {
pickGame(gameIdx);
screenState = "menu";
});
});
}
function pickGame(idx) {
// check boundaries
// cycle
if (idx < 0) idx = gameList.length - 1;
if (idx >= gameList.length) idx = 0;
// transition menu box
var listbox = $("#menu-container");
listbox.css("transition", "top 0.2s");
listbox.css("-moz-transition", "top 0.2s");
listbox.css("-webkit-transition", "top 0.2s");
menuTop = MENU_TOP_POSITION - idx * 36;
listbox.css("top", `${menuTop}px`);
// overflow marquee
$(".menu-item .pick").removeClass("pick");
$(`.menu-item:eq(${idx}) span`).addClass("pick");
gameIdx = idx;
log(`> [Pick] game ${gameIdx + 1}/${gameList.length} - ${gameList[gameIdx]}`);
}
function startGamePickerTimer(direction) {
if (gamePickerTimer === null) {
pickGame(gameIdx + (direction === "up" ? -1 : 1));
log("Start game picker timer");
// velocity?
gamePickerTimer = setInterval(function () {
pickGame(gameIdx + (direction === "up" ? -1 : 1));
}, 200);
}
}
function stopGamePickerTimer() {
if (gamePickerTimer !== null) {
log("Stop game picker timer");
clearInterval(gamePickerTimer);
gamePickerTimer = null;
}
}
/*
Game controller
*/
function sendKeyState() {
// check if state is changed
if (unchangePacket > 0) {
// pack keystate
var bits = "";
KEY_BIT.slice().reverse().forEach(elem => {
bits += keyState[elem] ? 1 : 0;
});
var data = parseInt(bits, 2);
var arrBuf = new Uint8Array(2);
arrBuf[0] = data & ((1 << 8) - 1);
arrBuf[1] = data >> 8;
inputChannel.send(arrBuf);
unchangePacket--;
}
}
function startGameInputTimer() {
if (gameInputTimer === null) {
log("Start game input timer");
gameInputTimer = setInterval(sendKeyState, 1000 / INPUT_FPS)
}
}
function stopGameInputTimer() {
if (gameInputTimer !== null) {
log("Stop game input timer");
clearInterval(gameInputTimer);
gameInputTimer = null;
}
}
function setKeyState(name, state) {
if (name in keyState) {
keyState[name] = state;
unchangePacket = INPUT_STATE_PACKET;
}
}
function doButtonDown(name) {
$(`#btn-${name}`).addClass("pressed");
if (screenState === "menu") {
if (name === "up" || name === "down") {
startGamePickerTimer(name);
const onConnectionReady = () => {
// start a game right away or show the menu
if (room.getId()) {
startGame();
} else {
state.menuReady();
}
} else if (screenState === "game") {
setKeyState(name, true);
}
};
if (name === "help") {
showHelpScreen();
}
}
const onLatencyCheckRequest = (data) => {
popup('Ping check...');
const timeoutMs = 2000;
const maxTimeoutMs = timeoutMs > ajax.defaultTimeoutMs() ? timeoutMs : ajax.defaultTimeoutMs();
Promise.all((data.addresses || []).map(address => {
let beforeTime = Date.now();
return ajax.fetch(`http://${address}:9000/echo?_=${beforeTime}`, {}, timeoutMs)
.then(() => ({[address]: Date.now() - beforeTime}), () => ({[address]: maxTimeoutMs}));
})).then(results => {
// const latencies = Object.assign({}, ...results);
const latencies = {};
results.map(latency => Object.keys(latency).forEach(address => latencies[address] = latency[address]));
log.info('[ping] <->', latencies);
socket.latency(latencies, data.packetId);
});
};
function doButtonUp(name) {
$(`#btn-${name}`).removeClass("pressed");
const helpScreen = {
// don't call $ if holding the button
shown: false,
// undo the state when release the button
prevState: null,
// use function () if you need "this"
show: function (show) {
if (this.shown === show) return;
if (screenState === "menu") {
switch (name) {
case "up":
case "down":
stopGamePickerTimer();
break;
case "join":
case "a":
case "b":
case "x":
case "y":
case "start":
case "select":
startGame();
break;
case "quit":
popup("You are already in menu screen!");
break;
case "load":
popup("Lets play to load game!");
break;
case "save":
popup("Lets play to save game!");
break;
// hack
if (state === app.state.game || this.prevState === app.state.game) {
gameScreen.toggle(!show);
} else {
keyButtons[KEY.SAVE].toggle(show);
keyButtons[KEY.LOAD].toggle(show);
menuScreen.toggle(!show);
}
helpOverlay.toggle(show);
this.shown = show;
if (show) {
this.prevState = state;
setState(app.state.help);
} else {
setState(this.prevState);
}
}
} else if (screenState === "game") {
setKeyState(name, false);
};
switch (name) {
case "join":
copyToClipboard(window.location.href.split('?')[0] + `?id=${encodeURIComponent(roomID)}`)
popup("Copy link to clipboard!")
break;
const showMenuScreen = () => {
// clear scenes
gameScreen.hide();
menuScreen.hide();
gameList.hide();
keyButtons[KEY.SAVE].hide();
keyButtons[KEY.LOAD].hide();
keyButtons[KEY.JOIN].html('play');
case "save":
conn.send(JSON.stringify({ "id": "save", "data": "" }));
break;
// show menu scene
gameScreen.show().delay(0).fadeOut(0, () => {
log.debug('[control] loading menu screen');
menuScreen.fadeIn(0, () => {
gameList.show();
setState(app.state.menu);
});
});
};
case "load":
conn.send(JSON.stringify({ "id": "load", "data": "" }));
break;
case "full":
// Fullscreen
screen = document.getElementById("game-screen");
const startGame = () => {
if (!rtcp.isConnected()) {
popup('Game cannot load. Please refresh');
return;
}
console.log(screen.height, window.innerHeight);
if (screen.height === window.innerHeight) {
closeFullscreen();
} else {
openFullscreen(screen);
if (!rtcp.isInputReady()) {
popup('Game is not ready yet. Please wait');
return;
}
log.info('[control] starting game screen');
setState(app.state.game);
const promise = gameScreen[0].play();
if (promise !== undefined) {
promise.then(() => log.info('Media can autoplay'))
.catch(error => {
// Usually error happens when we autoplay unmuted video, browser requires manual play.
// We already muted video and use separate audio encoding so it's fine now
log.error('Media Failed to autoplay');
log.error(error)
// TODO: Consider workaround
});
}
// 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
socket.startGame(gameList.getCurrentGame(), env.isMobileDevice(), room.getId(), 1);
// clear menu screen
input.poll().disable();
menuScreen.hide();
gameScreen.show();
keyButtons[KEY.SAVE].show();
keyButtons[KEY.LOAD].show();
// end clear
input.poll().enable();
};
// !to add debounce
const popup = (msg) => {
popupBox.html(msg);
popupBox.fadeIn().delay(0).fadeOut();
};
const onKeyPress = (data) => {
keyButtons[data.key].addClass('pressed');
if (KEY.HELP === data.key) helpScreen.show(true);
state.keyPress(data.key);
};
const onKeyRelease = (data) => {
keyButtons[data.key].removeClass('pressed');
if (KEY.HELP === data.key) helpScreen.show(false);
// maybe move it somewhere
if (!interacted) {
// unmute when there is user interaction
gameScreen[0].muted = false;
interacted = true;
}
state.keyRelease(data.key);
};
const app = {
state: {
eden: {
name: 'eden',
keyPress: () => {
},
keyRelease: () => {
},
menuReady: () => {
showMenuScreen()
}
break;
},
case "quit":
location.reload()
stopGameInputTimer();
showMenuScreen();
// TODO: Stop game
conn.send(JSON.stringify({ "id": "quit", "data": "", "room_id": roomID }));
help: {
name: 'help',
keyPress: () => {
},
keyRelease: () => {
},
menuReady: () => {
// show silently
gameScreen.hide();
menuScreen.hide();
gameList.hide();
keyButtons[KEY.JOIN].html('play');
roomID = ""
$("#room-txt").val(roomID);
popup("Quit!");
break;
gameList.show();
helpScreen.prevState = app.state.menu;
}
},
menu: {
name: 'menu',
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:
popup('You are already in menu screen!');
break;
case KEY.LOAD:
popup('Lets play to load game!');
break;
case KEY.SAVE:
popup('Lets play to save game!');
break;
}
},
menuReady: () => {
}
},
game: {
name: 'game',
keyPress: (key) => {
input.setKeyState(key, true);
},
keyRelease: function (key) {
input.setKeyState(key, false);
switch (key) {
// nani? why join / copy switch, it's confusing
case KEY.JOIN:
room.copyToClipboard();
popup('Copy link to clipboard!');
break;
case KEY.SAVE:
socket.saveGame();
break;
case KEY.LOAD:
socket.loadGame();
break;
case KEY.FULL:
env.display().toggleFullscreen(gameScreen.height() !== window.innerHeight, gameScreen[0]);
break;
case KEY.QUIT:
input.poll().disable();
// TODO: Stop game
socket.quitGame(room.getId());
room.reset();
popup('Quit!');
location.reload();
break;
}
},
menuReady: () => {
}
}
}
}
};
if (name === "help") {
hideHelpScreen();
}
}
// subscriptions
event.sub(GAME_ROOM_AVAILABLE, onGameRoomAvailable, 2);
event.sub(GAME_SAVED, () => popup('Saved'));
event.sub(GAME_LOADED, () => popup('Loaded'));
event.sub(MEDIA_STREAM_INITIALIZED, (data) => {
rtcp.start(data.stunturn);
gameList.set(data.games);
});
event.sub(MEDIA_STREAM_SDP_AVAILABLE, (data) => rtcp.setRemoteDescription(data.sdp, gameScreen[0]));
event.sub(MEDIA_STREAM_READY, () => rtcp.start());
event.sub(CONNECTION_READY, onConnectionReady);
event.sub(CONNECTION_CLOSED, () => input.poll().disable());
event.sub(LATENCY_CHECK_REQUESTED, onLatencyCheckRequest);
event.sub(GAMEPAD_CONNECTED, () => popup('Gamepad connected'));
event.sub(GAMEPAD_DISCONNECTED, () => popup('Gamepad disconnected'));
// touch stuff
event.sub(MENU_HANDLER_ATTACHED, (data) => {
menuScreen.on(data.event, data.handler);
});
event.sub(KEY_PRESSED, onKeyPress);
event.sub(KEY_RELEASED, onKeyRelease);
event.sub(KEY_STATE_UPDATED, data => rtcp.input(data));
// initial app state
setState(app.state.eden);
})($, document, event, env, gameList, input, KEY, log, room);

137
web/js/env.js vendored Normal file
View file

@ -0,0 +1,137 @@
const env = (() => {
const win = $(window);
const doc = $(document);
// Screen state
let isLayoutSwitched = false;
// Window rerender / rotate screen if needed
const fixScreenLayout = () => {
let targetWidth = doc.width() * 0.9;
let targetHeight = doc.height() * 0.9;
// mobile == full screen
if (env.getOs() === 'android') {
targetWidth = doc.width();
targetHeight = doc.height();
}
// Should have maximum box for desktop?
// targetWidth = 800; targetHeight = 600; // test on desktop
fixElementLayout($('#gamebody'), targetWidth, targetHeight);
const elem = $('#ribbon');
let st = '';
if (isLayoutSwitched) {
st = 'rotate(90deg)';
elem.css('bottom', 0);
elem.css('top', '');
} else {
elem.css('bottom', '');
elem.css('top', 0);
}
elem.css('transform', st);
elem.css('-webkit-transform', st);
elem.css('-moz-transform', st);
};
const fixElementLayout = (elem, targetWidth, targetHeight) => {
let st = 'translate(-50%, -50%) ';
// rotate portrait layout
if (isPortrait()) {
st += `rotate(90deg) `;
let tmp = targetHeight;
targetHeight = targetWidth;
targetWidth = tmp;
isLayoutSwitched = true;
} else {
isLayoutSwitched = false;
}
// scale, fit to target size
st += `scale(${Math.min(targetWidth / elem.width(), targetHeight / elem.height())}) `;
elem.css('transform', st);
elem.css('-webkit-transform', st);
elem.css('-moz-transform', st);
};
const getOS = () => {
// linux? ios?
let OSName = 'unknown';
if (navigator.appVersion.indexOf('Win') !== -1) OSName = 'win';
else if (navigator.appVersion.indexOf('Mac') !== -1) OSName = 'mac';
else if (navigator.userAgent.indexOf('Linux') !== -1) OSName = 'linux';
else if (navigator.userAgent.indexOf('Android') !== -1) OSName = 'android';
return OSName;
};
const getBrowser = () => {
let browserName = 'unknown';
if (navigator.userAgent.indexOf('Firefox') !== -1) browserName = 'firefox';
if (navigator.userAgent.indexOf('Chrome') !== -1) browserName = 'chrome';
if (navigator.userAgent.indexOf('Edge') !== -1) browserName = 'edge';
if (navigator.userAgent.indexOf('Version/') !== -1) browserName = 'safari';
if (navigator.userAgent.indexOf('UCBrowser') !== -1) browserName = 'uc';
return browserName;
};
// !to use more sophisticated approach / lib
const isPortrait = () => {
// ios / mobile app
switch (window.orientation) {
case 0:
case 180:
return true;
}
// desktop
const orientation = screen.msOrientation || screen.mozOrientation || (screen.orientation || {}).type;
return orientation === 'portrait-primary';
};
const toggleFullscreen = (enable, element) => {
const el = enable ? element : document;
if (enable) {
if (el.requestFullscreen) {
el.requestFullscreen();
} else if (el.mozRequestFullScreen) { /* Firefox */
el.mozRequestFullScreen();
} else if (el.webkitRequestFullscreen) { /* Chrome, Safari and Opera */
el.webkitRequestFullscreen();
} else if (el.msRequestFullscreen) { /* IE/Edge */
el.msRequestFullscreen();
}
} else {
if (el.exitFullscreen) {
el.exitFullscreen();
} else if (el.mozCancelFullScreen) { /* Firefox */
el.mozCancelFullScreen();
} else if (el.webkitExitFullscreen) { /* Chrome, Safari and Opera */
el.webkitExitFullscreen();
} else if (el.msExitFullscreen) { /* IE/Edge */
el.msExitFullscreen();
}
}
};
// bindings
win.on('resize', fixScreenLayout);
win.on('orientationchange', fixScreenLayout);
return {
getOs: getOS,
getBrowser: getBrowser,
// Check mobile type because different mobile can accept different video encoder.
isMobileDevice: () => (typeof window.orientation !== 'undefined') || (navigator.userAgent.indexOf('IEMobile') !== -1),
display: () => ({
isPortrait: isPortrait,
toggleFullscreen: toggleFullscreen,
fixScreenLayout: fixScreenLayout,
isLayoutSwitched: isLayoutSwitched
})
}
})($, document, log, navigator, screen, window);

80
web/js/event/event.js vendored Normal file
View file

@ -0,0 +1,80 @@
/**
* Event publishing / subscribe module.
* Just a simple observer pattern.
* @version 1
*/
const event = (() => {
const topics = {};
return {
/**
* Subscribes onto some event.
*
* @param topic The name of the event.
* @param listener A callback function to call during the event.
* @param order A number in a queue of event handlers to run callback in ordered manner.
* @returns {{unsub: unsub}} The function to remove this subscription.
* @example
* const sub01 = event.sub('rapture', () => {a}, 1)
* ...
* sub01.unsub()
*/
sub: (topic, listener, order) => {
if (!topics[topic]) topics[topic] = [];
// order handling stuff
const value = {order: order || 0, listener: listener};
topics[topic].push(value);
topics[topic].sort((a, b) => a.order - b.order);
const index = topics[topic].indexOf(value);
return {
unsub: () => {
topics[topic].splice(index, 1);
}
};
},
/**
* Publishes some event for handling.
*
* @param topic The name of the event.
* @param data Additional data for the event handling.
* Because of compatibility we have to use a dumb obj wrapper {a: a, b: b} for params instead of (topic, ...data).
* @example
* event.pub('rapture', {time: now()})
*/
pub: (topic, data) => {
if (!topics[topic] || topics[topic].length < 1) return;
topics[topic].forEach((listener) => {
listener.listener(data || {})
});
}
}
})();
// events
const LATENCY_CHECK_REQUESTED = 'latencyCheckRequested';
const GAME_ROOM_AVAILABLE = 'gameRoomAvailable';
const GAME_SAVED = 'gameSaved';
const GAME_LOADED = 'gameLoaded';
const CONNECTION_READY = 'connectionReady';
const CONNECTION_CLOSED = 'connectionClosed';
const MEDIA_STREAM_INITIALIZED = 'mediaStreamInitialized';
const MEDIA_STREAM_SDP_AVAILABLE = 'mediaStreamSdpAvailable';
const MEDIA_STREAM_READY = 'mediaStreamReady';
const GAMEPAD_CONNECTED = 'gamepadConnected';
const GAMEPAD_DISCONNECTED = 'gamepadDisconnected';
const MENU_HANDLER_ATTACHED = 'menuHandlerAttached';
const MENU_PRESSED = 'menuPressed';
const MENU_RELEASED = 'menuReleased';
const KEY_PRESSED = 'keyPressed';
const KEY_RELEASED = 'keyReleased';
const KEY_STATE_UPDATED = 'keyStateUpdated';

111
web/js/gameList.js vendored Normal file
View file

@ -0,0 +1,111 @@
/**
* Game list module.
* @version 1
*/
const gameList = (() => {
// state
let games = [];
let gameIndex = 1;
let gamePickTimer = null;
// UI
const listBox = $('#menu-container');
const menuItemChoice = $('#menu-item-choice');
const MENU_TOP_POSITION = 102;
let menuTop = MENU_TOP_POSITION;
const setGames = (gameList) => {
games = gameList.sort((a, b) => a > b ? 1 : -1);
};
const render = () => {
log.debug('[games] load game menu');
listBox.html(games
.map(game => `<div class="menu-item unselectable" unselectable="on"><div><span>${game}</span></div></div>`)
.join('')
);
};
const show = () => {
render();
menuItemChoice.show();
pickGame();
};
const hide = () => {
menuItemChoice.hide();
};
const pickGame = (index) => {
let idx = undefined !== index ? index : gameIndex;
// check boundaries
// cycle
if (idx < 0) idx = games.length - 1;
if (idx >= games.length) idx = 0;
// transition menu box
listBox.css('transition', 'top 0.2s');
listBox.css('-moz-transition', 'top 0.2s');
listBox.css('-webkit-transition', 'top 0.2s');
menuTop = MENU_TOP_POSITION - idx * 36;
listBox.css('top', `${menuTop}px`);
// overflow marquee
$('.menu-item .pick').removeClass('pick');
$(`.menu-item:eq(${idx}) span`).addClass('pick');
gameIndex = idx;
};
const startGamePickerTimer = (upDirection) => {
if (gamePickTimer !== null) return;
log.debug('[games] start game picker timer');
const shift = upDirection ? -1 : 1;
pickGame(gameIndex + shift);
// velocity?
// keep rolling the game list if the button is pressed
gamePickTimer = setInterval(() => {
pickGame(gameIndex + shift);
}, 200);
};
const stopGamePickerTimer = () => {
if (gamePickTimer === null) return;
log.debug('[games] stop game picker timer');
clearInterval(gamePickTimer);
gamePickTimer = null;
};
const onMenuPressed = (newPosition) => {
listBox.css('transition', '');
listBox.css('-moz-transition', '');
listBox.css('-webkit-transition', '');
listBox.css('top', `${menuTop - newPosition}px`);
};
const onMenuReleased = (position) => {
menuTop -= position;
const index = Math.round((menuTop - MENU_TOP_POSITION) / -36);
pickGame(index);
};
event.sub(MENU_PRESSED, onMenuPressed);
event.sub(MENU_RELEASED, onMenuReleased);
return {
startGamePickerTimer: startGamePickerTimer,
stopGamePickerTimer: stopGamePickerTimer,
pickGame: pickGame,
show: show,
hide: hide,
set: setGames,
getCurrentGame: () => games[gameIndex]
}
})($, event, log);

View file

@ -1,141 +0,0 @@
/*
Joystick gesture
*/
/*
cross == a <--> a
circle == b <--> b
square == x <--> start
triangle == y <--> select
share <--> load
option <--> save
L2 == LT <--> full
R2 == RT <--> quit
dpad <--> up down left right
axis 0, 1 <--> second dpad
*/
/*
change full to help (temporary)
*/
let joystickMap;
let joystickState;
let joystickIdx;
let joystickTimer = null;
// check state for each axis -> dpad
function checkJoystickAxisState(name, state) {
if (joystickState[name] !== state) {
joystickState[name] = state;
if (state === true) {
doButtonDown(name);
} else {
doButtonUp(name);
}
}
}
// loop timer for checking joystick state
function checkJoystickState() {
var gamepad = navigator.getGamepads()[joystickIdx];
if (gamepad) {
// axis -> dpad
var corX = gamepad.axes[0]; // -1 -> 1, left -> right
var corY = gamepad.axes[1]; // -1 -> 1, up -> down
checkJoystickAxisState("left", corX <= -0.5);
checkJoystickAxisState("right", corX >= 0.5);
checkJoystickAxisState("up", corY <= -0.5);
checkJoystickAxisState("down", corY >= 0.5);
// normal button map
Object.keys(joystickMap).forEach(function (btnIdx) {
var isPressed = false;
if (navigator.webkitGetGamepads) {
isPressed = (gamepad.buttons[btnIdx] === 1);
} else {
isPressed = (gamepad.buttons[btnIdx].value > 0 || gamepad.buttons[btnIdx].pressed === true);
}
if (joystickState[btnIdx] !== isPressed) {
joystickState[btnIdx] = isPressed;
if (isPressed === true) {
doButtonDown(joystickMap[btnIdx]);
} else {
doButtonUp(joystickMap[btnIdx]);
}
}
});
}
}
// we only capture the last plugged joystick
window.addEventListener("gamepadconnected", function (event) {
var gamepad = event.gamepad;
log(`Gamepad connected at index ${gamepad.index}: ${gamepad.id}. ${gamepad.buttons.length} buttons, ${gamepad.axes.length} axes.`);
joystickIdx = gamepad.index;
// Ref: https://github.com/giongto35/cloud-game/issues/14
// get mapping first (default KeyMap2)
var os = getOS();
var browser = getBrowser();
if (os === "android") {
// default of android is KeyMap1
joystickMap = { 2: "a", 0: "b", 3: "start", 4: "select", 10: "load", 11: "save", 8: "help", 9: "quit", 12: "up", 13: "down", 14: "left", 15: "right" };
} else {
// default of other OS is KeyMap2
joystickMap = { 0: "a", 1: "b", 2: "start", 3: "select", 8: "load", 9: "save", 6: "help", 7: "quit", 12: "up", 13: "down", 14: "left", 15: "right" };
}
if (os === "android" && (browser === "firefox" || browser === "uc")) { //KeyMap2
joystickMap = { 0: "a", 1: "b", 2: "start", 3: "select", 8: "load", 9: "save", 6: "help", 7: "quit", 12: "up", 13: "down", 14: "left", 15: "right" };
}
if (os === "win" && browser === "firefox") { //KeyMap3
joystickMap = { 1: "a", 2: "b", 0: "start", 3: "select", 8: "load", 9: "save", 6: "help", 7: "quit" };
}
if (os === "mac" && browser === "safari") { //KeyMap4
joystickMap = { 1: "a", 2: "b", 0: "start", 3: "select", 8: "load", 9: "save", 6: "help", 7: "quit", 14: "up", 15: "down", 16: "left", 17: "right" };
}
if (os === "mac" && browser === "firefox") { //KeyMap5
joystickMap = { 1: "a", 2: "b", 0: "start", 3: "select", 8: "load", 9: "save", 6: "help", 7: "quit", 14: "up", 15: "down", 16: "left", 17: "right" };
}
// reset state
joystickState = {
left: false,
right: false,
up: false,
down: false,
};
Object.keys(joystickMap).forEach(function (btnIdx) {
joystickState[btnIdx] = false;
});
// looper, too intense?
if (joystickTimer !== null) {
clearInterval(joystickTimer);
}
joystickTimer = setInterval(checkJoystickState, 10); // miliseconds per hit
});
// disconnected event is triggered
window.addEventListener("gamepaddisconnected", (event) => {
clearInterval(joystickTimer);
log(`Gamepad disconnected at index ${e.gamepad.index}`);
});

View file

@ -1,40 +0,0 @@
/*
Keyboard gesture
*/
const KEYBOARD_MAP = {
37: "left",
38: "up",
39: "right",
40: "down",
90: "a", // z
88: "b", // x
67: "x", // c
86: "y", // v
13: "start", // start
16: "select", // select
// non-game
81: "quit", // q
83: "save", // s
87: "join", // w
65: "load", // a
70: "full", // f
72: "help", // h
}
$("body").on("keyup", function (event) {
if (event.keyCode in KEYBOARD_MAP) {
doButtonUp(KEYBOARD_MAP[event.keyCode]);
// unmute when there is user interaction
document.getElementById("game-screen").muted = false;
}
});
$("body").on("keydown", function (event) {
if (event.keyCode in KEYBOARD_MAP) {
doButtonDown(KEYBOARD_MAP[event.keyCode]);
}
});

View file

@ -1,286 +0,0 @@
/*
Touch gesture
*/
// Virtual Gamepad / Joystick
// Ref: https://jsfiddle.net/aa0et7tr/5/
/*
Left panel - Dpad
*/
const MAX_DIFF = 20; // radius of circle boundary
// vpad state, use for mouse button down
let vpadState = {
up: false,
down: false,
left: false,
right: false,
};
$(".btn, .btn-big").each(function () {
vpadState[$(this).attr("value")] = false;
});
let vpadTouchIdx = null;
let vpadTouchDrag = null;
let vpadHolder = $("#circle-pad-holder");
let vpadCircle = $("#circle-pad");
function resetVpadState() {
// trigger up event?
checkVpadState("up", false);
checkVpadState("down", false);
checkVpadState("left", false);
checkVpadState("right", false);
vpadTouchDrag = null;
vpadTouchIdx = null;
$(".dpad").removeClass("pressed");
}
function checkVpadState(axis, state) {
if (state !== vpadState[axis]) {
vpadState[axis] = state;
if (state) {
doButtonDown(axis);
} else {
doButtonUp(axis);
}
}
}
function handleVpadJoystickDown(event) {
vpadCircle.css("transition", "0s");
vpadCircle.css("-moz-transition", "0s");
vpadCircle.css("-webkit-transition", "0s");
if (event.changedTouches) {
resetVpadState();
vpadTouchIdx = event.changedTouches[0].identifier;
event.clientX = event.changedTouches[0].clientX;
event.clientY = event.changedTouches[0].clientY;
}
vpadTouchDrag = {
x: event.clientX,
y: event.clientY,
};
}
function handleVpadJoystickUp(event) {
if (vpadTouchDrag === null) return;
vpadCircle.css("transition", ".2s");
vpadCircle.css("-moz-transition", ".2s");
vpadCircle.css("-webkit-transition", ".2s");
vpadCircle.css("transform", "translate3d(0px, 0px, 0px)");
vpadCircle.css("-moz-transform", "translate3d(0px, 0px, 0px)");
vpadCircle.css("-webkit-transform", "translate3d(0px, 0px, 0px)");
resetVpadState();
}
function handleVpadJoystickMove(event) {
if (vpadTouchDrag === null) return;
if (event.changedTouches) {
// check if moving source is from other touch?
for (var i = 0; i < event.changedTouches.length; i++) {
if (event.changedTouches[i].identifier === vpadTouchIdx) {
event.clientX = event.changedTouches[i].clientX;
event.clientY = event.changedTouches[i].clientY;
}
}
if (event.clientX === undefined || event.clientY === undefined)
return;
}
var xDiff = event.clientX - vpadTouchDrag.x;
var yDiff = event.clientY - vpadTouchDrag.y;
var angle = Math.atan2(yDiff, xDiff);
var distance = Math.min(MAX_DIFF, Math.hypot(xDiff, yDiff));
var xNew = distance * Math.cos(angle);
var yNew = distance * Math.sin(angle);
// check if screen is switched or not
if (isLayoutSwitched) {
tmp = xNew;
xNew = yNew;
yNew = -tmp;
}
style = `translate(${xNew}px, ${yNew}px)`;
vpadCircle.css("transform", style);
vpadCircle.css("-webkit-transform", style);
vpadCircle.css("-moz-transform", style);
var xRatio = xNew / MAX_DIFF;
var yRatio = yNew / MAX_DIFF;
checkVpadState("left", xRatio <= -0.5);
checkVpadState("right", xRatio >= 0.5);
checkVpadState("up", yRatio <= -0.5);
checkVpadState("down", yRatio >= 0.5);
}
// touch/mouse events for dpad. mouseup events is binded to window.
vpadHolder.on('mousedown', handleVpadJoystickDown);
vpadHolder.on('touchstart', handleVpadJoystickDown);
vpadHolder.on('touchend', handleVpadJoystickUp);
/*
Right side - Control buttons
*/
function handleButtonDown(event) {
checkVpadState($(this).attr("value"), true);
// add touchIdx?
}
function handleButtonUp(event) {
checkVpadState($(this).attr("value"), false);
}
// touch/mouse events for control buttons. mouseup events is binded to window.
$(".btn").on("mousedown", handleButtonDown);
$(".btn").on("touchstart", handleButtonDown);
$(".btn").on("touchend", handleButtonUp);
/*
Touch menu
*/
let menuTouchIdx = null;
let menuTouchDrag = null;
let menuTouchTime = null;
function handleMenuDown(event) {
// Identify of touch point
if (event.changedTouches) {
menuTouchIdx = event.changedTouches[0].identifier;
event.clientX = event.changedTouches[0].clientX;
event.clientY = event.changedTouches[0].clientY;
}
menuTouchDrag = {
x: event.clientX,
y: event.clientY,
};
menuTouchTime = Date.now();
}
function handleMenuMove(event) {
if (menuTouchDrag === null) return;
if (event.changedTouches) {
// check if moving source is from other touch?
for (var i = 0; i < event.changedTouches.length; i++) {
if (event.changedTouches[i].identifier === menuTouchIdx) {
event.clientX = event.changedTouches[i].clientX;
event.clientY = event.changedTouches[i].clientY;
}
}
if (event.clientX == undefined || event.clientY == undefined)
return;
}
var listbox = $("#menu-container");
listbox.css("transition", "");
listbox.css("-moz-transition", "");
listbox.css("-webkit-transition", "");
if (isLayoutSwitched) {
listbox.css("top", `${menuTop - (-menuTouchDrag.x + event.clientX)}px`);
} else {
listbox.css("top", `${menuTop - (menuTouchDrag.y - event.clientY)}px`);
}
}
function handleMenuUp(event) {
if (menuTouchDrag === null) return;
if (event.changedTouches) {
if (event.changedTouches[0].identifier !== menuTouchIdx)
return;
event.clientX = event.changedTouches[0].clientX;
event.clientY = event.changedTouches[0].clientY;
}
var interval = Date.now() - menuTouchTime; // 100ms?
if (isLayoutSwitched) {
newY = -menuTouchDrag.x + event.clientX;
} else {
newY = menuTouchDrag.y - event.clientY;
}
if (interval < 200) {
// calc velo
newY = newY/ interval * 250;
}
// current item?
menuTop -= newY;
idx = Math.round((menuTop - MENU_TOP_POSITION) / -36);
pickGame(idx);
menuTouchDrag = null;
}
// Bind events for menu
$("#menu-screen").on("mousedown", handleMenuDown);
$("#menu-screen").on("touchstart", handleMenuDown);
$("#menu-screen").on("touchend", handleMenuUp);
/*
Common events
*/
function handleWindowMove(event) {
event.preventDefault();
handleVpadJoystickMove(event);
handleMenuMove(event);
// moving touch
if (event.changedTouches) {
for (var i = 0; i < event.changedTouches.length; i++) {
if (event.changedTouches[i].identifier !== menuTouchIdx && event.changedTouches[i].identifier !== vpadTouchIdx) {
// check class
var elem = document.elementFromPoint(event.changedTouches[i].clientX, event.changedTouches[i].clientY);
if (elem.classList.contains("btn")) {
$(elem).trigger("touchstart");
} else {
$(".btn").trigger("touchend");
}
}
}
}
}
function handleWindowUp(event) {
// unmute when there is user interaction
document.getElementById("game-screen").muted = false;
handleVpadJoystickUp(event);
handleMenuUp(event);
$(".btn").trigger("touchend");
}
// Bind events for window
$(window).on("mousemove", handleWindowMove);
window.addEventListener("touchmove", handleWindowMove, {passive: false});
$(window).on("mouseup", handleWindowUp);

68
web/js/global.js vendored
View file

@ -1,68 +0,0 @@
/*
Global Constants
*/
const DEBUG = false;
const KEY_BIT = ["a", "b", "x", "y", "select", "start", "up", "down", "left", "right"];
const INPUT_FPS = 100;
const INPUT_STATE_PACKET = 1;
const PINGPONGPS = 5;
const MENU_TOP_POSITION = 102;
const ICE_TIMEOUT = 2000;
/*
Global variables
*/
// Game state
let screenState = "loader";
let gameList = [];
let gameIdx = 9; // contra
let gamePickerTimer = null;
let roomID = null;
// Game controller state
let keyState = {
// control
a: false,
b: false,
x: false,
y: false,
start: false,
select: false,
// dpad
up: false,
down: false,
left: false,
right: false
}
let unchangePacket = INPUT_STATE_PACKET;
let gameInputTimer = null;
// Network state
let pc, inputChannel;
let localSessionDescription = "";
let remoteSessionDescription = "";
let conn;
// Touch menu state
let menuDrag = null;
let menuTop = MENU_TOP_POSITION;
// Screen state
let isLayoutSwitched = false;
var gameReady = false;
var inputReady = false;
var audioReady = false;
var iceSuccess = true;
var iceSent = false; // TODO: set to false in some init event
var defaultICE = [{urls: "stun:stun.l.google.com:19302"}]

48
web/js/init.js vendored
View file

@ -1,43 +1,13 @@
log.setLevel('debug');
// Window rerender / rotate screen if needed
function fixScreenLayout() {
var targetWidth = $(document).width() * 0.9;
var targetHeight = $(document).height() * 0.9;
$(document).ready(() => {
env.display().fixScreenLayout();
// mobile == full screen
if (getOS() === "android") {
var targetWidth = $(document).width();
var targetHeight = $(document).height();
}
keyboard.init();
joystick.init();
touch.init();
// Should have maximum box for desktop?
// targetWidth = 800; targetHeight = 600; // test on desktop
fixElementLayout($("#gamebody"), targetWidth, targetHeight);
var elem = $("#ribbon");
var st = "";
if (isLayoutSwitched) {
var st = "rotate(90deg)";
elem.css("bottom", 0);
elem.css("top", "");
} else {
elem.css("bottom", "");
elem.css("top", 0);
}
elem.css("transform", st);
elem.css("-webkit-transform", st);
elem.css("-moz-transform", st);
}
$(window).on("resize", fixScreenLayout);
$(window).on("orientationchange", fixScreenLayout);
$(document).ready(function () {
fixScreenLayout();
$("#room-txt").val(roomID);
const roomId = room.loadMaybe();
// if from URL -> start game immediately!
socket.init(roomId);
});

75
web/js/input/input.js vendored Normal file
View file

@ -0,0 +1,75 @@
const input = (() => {
const INPUT_HZ = 100;
const INPUT_STATE_PACKET = 1;
const KEY_BITS = [KEY.A, KEY.B, KEY.X, KEY.Y, KEY.SELECT, KEY.START, KEY.UP, KEY.DOWN, KEY.LEFT, KEY.RIGHT];
let gameInputTimer = null;
let unchangePacket = INPUT_STATE_PACKET;
// Game controller state
let keyState = {
// control
[KEY.A]: false,
[KEY.B]: false,
[KEY.X]: false,
[KEY.Y]: false,
[KEY.START]: false,
[KEY.SELECT]: false,
// dpad
[KEY.UP]: false,
[KEY.DOWN]: false,
[KEY.LEFT]: false,
[KEY.RIGHT]: false
};
const poll = () => {
return {
enable: () => {
if (gameInputTimer !== null) return;
const inputPollInterval = 1000 / INPUT_HZ;
log.info(`[input] setting input polling interval to ${inputPollInterval}ms`);
gameInputTimer = setInterval(sendKeyState, inputPollInterval)
},
disable: () => {
if (gameInputTimer === null) return;
log.info('[input] stop game input timer');
clearInterval(gameInputTimer);
gameInputTimer = null;
}
}
};
// relatively slow method
const sendKeyState = () => {
// check if state is changed
if (unchangePacket > 0) {
// pack keys state
let bits = '';
KEY_BITS.slice().reverse().forEach(elem => {
bits += keyState[elem] ? 1 : 0;
});
let data = parseInt(bits, 2);
let arrBuf = new Uint8Array(2);
arrBuf[0] = data & ((1 << 8) - 1);
arrBuf[1] = data >> 8;
event.pub(KEY_STATE_UPDATED, arrBuf);
unchangePacket--;
}
};
const setKeyState = (name, state) => {
if (name in keyState) {
keyState[name] = state;
unchangePacket = INPUT_STATE_PACKET;
}
};
return {
poll: poll,
setKeyState: setKeyState
}
})(event, KEY);

200
web/js/input/joystick.js vendored Normal file
View file

@ -0,0 +1,200 @@
/**
* Joystick controls.
*
* cross == a <--> a
* circle == b <--> b
* square == x <--> start
* triangle == y <--> select
* share <--> load
* option <--> save
* L2 == LT <--> full
* R2 == RT <--> quit
* dpad <--> up down left right
* axis 0, 1 <--> second dpad
*
* change full to help (temporary)
*
* @version 1
*/
const joystick = (() => {
let joystickMap;
let joystickState;
let joystickIdx;
let joystickTimer = null;
// check state for each axis -> dpad
function checkJoystickAxisState(name, state) {
if (joystickState[name] !== state) {
joystickState[name] = state;
event.pub(state === true ? KEY_PRESSED : KEY_RELEASED, {key: name});
}
}
// loop timer for checking joystick state
function checkJoystickState() {
let gamepad = navigator.getGamepads()[joystickIdx];
if (gamepad) {
// axis -> dpad
let corX = gamepad.axes[0]; // -1 -> 1, left -> right
let corY = gamepad.axes[1]; // -1 -> 1, up -> down
checkJoystickAxisState(KEY.LEFT, corX <= -0.5);
checkJoystickAxisState(KEY.RIGHT, corX >= 0.5);
checkJoystickAxisState(KEY.UP, corY <= -0.5);
checkJoystickAxisState(KEY.DOWN, corY >= 0.5);
// normal button map
Object.keys(joystickMap).forEach(function (btnIdx) {
const buttonState = gamepad.buttons[btnIdx];
const isPressed = navigator.webkitGetGamepads ? buttonState === 1 :
buttonState.value > 0 || buttonState.pressed === true;
if (joystickState[btnIdx] !== isPressed) {
joystickState[btnIdx] = isPressed;
event.pub(isPressed === true ? KEY_PRESSED : KEY_RELEASED, {key: joystickMap[btnIdx]});
}
});
}
}
// we only capture the last plugged joystick
const onGamepadConnected = (event) => {
let gamepad = event.gamepad;
log.info(`Gamepad connected at index ${gamepad.index}: ${gamepad.id}. ${gamepad.buttons.length} buttons, ${gamepad.axes.length} axes.`);
joystickIdx = gamepad.index;
// Ref: https://github.com/giongto35/cloud-game/issues/14
// get mapping first (default KeyMap2)
let os = env.getOs();
let browser = env.getBrowser();
if (os === 'android') {
// default of android is KeyMap1
joystickMap = {
2: KEY.A,
0: KEY.B,
3: KEY.START,
4: KEY.SELECT,
10: KEY.LOAD,
11: KEY.SAVE,
8: KEY.HELP,
9: KEY.QUIT,
12: KEY.UP,
13: KEY.DOWN,
14: KEY.LEFT,
15: KEY.RIGHT
};
} else {
// default of other OS is KeyMap2
joystickMap = {
0: KEY.A,
1: KEY.B,
2: KEY.START,
3: KEY.SELECT,
8: KEY.LOAD,
9: KEY.SAVE,
6: KEY.HELP,
7: KEY.QUIT,
12: KEY.UP,
13: KEY.DOWN,
14: KEY.LEFT,
15: KEY.RIGHT
};
}
if (os === 'android' && (browser === 'firefox' || browser === 'uc')) { //KeyMap2
joystickMap = {
0: KEY.A,
1: KEY.B,
2: KEY.START,
3: KEY.SELECT,
8: KEY.LOAD,
9: KEY.SAVE,
6: KEY.HELP,
7: KEY.QUIT,
12: KEY.UP,
13: KEY.DOWN,
14: KEY.LEFT,
15: KEY.RIGHT
};
}
if (os === 'win' && browser === 'firefox') { //KeyMap3
joystickMap = {
1: KEY.A,
2: KEY.B,
0: KEY.START,
3: KEY.SELECT,
8: KEY.LOAD,
9: KEY.SAVE,
6: KEY.HELP,
7: KEY.QUIT
};
}
if (os === 'mac' && browser === 'safari') { //KeyMap4
joystickMap = {
1: KEY.A,
2: KEY.B,
0: KEY.START,
3: KEY.SELECT,
8: KEY.LOAD,
9: KEY.SAVE,
6: KEY.HELP,
7: KEY.QUIT,
14: KEY.UP,
15: KEY.DOWN,
16: KEY.LEFT,
17: KEY.RIGHT
};
}
if (os === 'mac' && browser === 'firefox') { //KeyMap5
joystickMap = {
1: KEY.A,
2: KEY.B,
0: KEY.START,
3: KEY.SELECT,
8: KEY.LOAD,
9: KEY.SAVE,
6: KEY.HELP,
7: KEY.QUIT,
14: KEY.UP,
15: KEY.DOWN,
16: KEY.LEFT,
17: KEY.RIGHT
};
}
// reset state
joystickState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false};
Object.keys(joystickMap).forEach(function (btnIdx) {
joystickState[btnIdx] = false;
});
// looper, too intense?
if (joystickTimer !== null) {
clearInterval(joystickTimer);
}
joystickTimer = setInterval(checkJoystickState, 10); // miliseconds per hit
event.pub(GAMEPAD_CONNECTED);
};
return {
init: () => {
// we only capture the last plugged joystick
window.addEventListener('gamepadconnected', onGamepadConnected);
// disconnected event is triggered
window.addEventListener('gamepaddisconnected', (event) => {
clearInterval(joystickTimer);
log.info(`Gamepad disconnected at index ${event.gamepad.index}`);
event.pub(GAMEPAD_DISCONNECTED);
});
log.info('[input] joystick has been initialized');
}
}
})(event, env, KEY, navigator, window);

40
web/js/input/keyboard.js vendored Normal file
View file

@ -0,0 +1,40 @@
/**
* Keyboard controls.
*
* @version 1
*/
const keyboard = (() => {
const KEYBOARD_MAP = {
37: KEY.LEFT,
38: KEY.UP,
39: KEY.RIGHT,
40: KEY.DOWN,
90: KEY.A, // z
88: KEY.B, // x
67: KEY.X, // c
86: KEY.Y, // v
13: KEY.START, // enter
16: KEY.SELECT, // shift
// non-game
81: KEY.QUIT, // q
83: KEY.SAVE, // s
87: KEY.JOIN, // w
65: KEY.LOAD, // a
70: KEY.FULL, // f
72: KEY.HELP, // h
};
const onKey = (code, callback) => {
if (code in KEYBOARD_MAP) callback(KEYBOARD_MAP[code]);
};
return {
init: () => {
const body = $('body');
body.on('keyup', ev => onKey(ev.keyCode, key => event.pub(KEY_RELEASED, {key: key})));
body.on('keydown', ev => onKey(ev.keyCode, key => event.pub(KEY_PRESSED, {key: key})));
log.info('[input] keyboard has been initialized');
}
}
})(event, KEY);

20
web/js/input/keys.js vendored Normal file
View file

@ -0,0 +1,20 @@
const KEY = (() => {
return {
A: 'a',
B: 'b',
X: 'x',
Y: 'y',
START: 'start',
SELECT: 'select',
LOAD: 'load',
SAVE: 'save',
HELP: 'help',
JOIN: 'join',
FULL: 'full',
QUIT: 'quit',
UP: 'up',
DOWN: 'down',
LEFT: 'left',
RIGHT: 'right',
}
})();

250
web/js/input/touch.js vendored Normal file
View file

@ -0,0 +1,250 @@
/**
* Touch controls.
*
* Virtual Gamepad / Joystick
* Left panel - Dpad
*
* @link https://jsfiddle.net/aa0et7tr/5/
* @version 1
*/
const touch = (() => {
const MAX_DIFF = 20; // radius of circle boundary
// vpad state, use for mouse button down
let vpadState = {[KEY.UP]: false, [KEY.DOWN]: false, [KEY.LEFT]: false, [KEY.RIGHT]: false};
let vpadTouchIdx = null;
let vpadTouchDrag = null;
let vpadHolder = $("#circle-pad-holder");
let vpadCircle = $("#circle-pad");
const window_ = $(window);
const buttons = $(".btn");
const dpad = $(".dpad");
function resetVpadState() {
// trigger up event?
checkVpadState(KEY.UP, false);
checkVpadState(KEY.DOWN, false);
checkVpadState(KEY.LEFT, false);
checkVpadState(KEY.RIGHT, false);
vpadTouchDrag = null;
vpadTouchIdx = null;
dpad.removeClass('pressed');
}
function checkVpadState(axis, state) {
if (state !== vpadState[axis]) {
vpadState[axis] = state;
event.pub(state ? KEY_PRESSED : KEY_RELEASED, {key: axis});
}
}
function handleVpadJoystickDown(event) {
vpadCircle.css('transition', '0s');
vpadCircle.css('-moz-transition', '0s');
vpadCircle.css('-webkit-transition', '0s');
if (event.changedTouches) {
resetVpadState();
vpadTouchIdx = event.changedTouches[0].identifier;
event.clientX = event.changedTouches[0].clientX;
event.clientY = event.changedTouches[0].clientY;
}
vpadTouchDrag = {x: event.clientX, y: event.clientY};
}
function handleVpadJoystickUp() {
if (vpadTouchDrag === null) return;
vpadCircle.css('transition', '.2s');
vpadCircle.css('-moz-transition', '.2s');
vpadCircle.css('-webkit-transition', '.2s');
vpadCircle.css('transform', 'translate3d(0px, 0px, 0px)');
vpadCircle.css('-moz-transform', 'translate3d(0px, 0px, 0px)');
vpadCircle.css('-webkit-transform', 'translate3d(0px, 0px, 0px)');
resetVpadState();
}
function handleVpadJoystickMove(event) {
if (vpadTouchDrag === null) return;
if (event.changedTouches) {
// check if moving source is from other touch?
for (let i = 0; i < event.changedTouches.length; i++) {
if (event.changedTouches[i].identifier === vpadTouchIdx) {
event.clientX = event.changedTouches[i].clientX;
event.clientY = event.changedTouches[i].clientY;
}
}
if (event.clientX === undefined || event.clientY === undefined)
return;
}
let xDiff = event.clientX - vpadTouchDrag.x;
let yDiff = event.clientY - vpadTouchDrag.y;
let angle = Math.atan2(yDiff, xDiff);
let distance = Math.min(MAX_DIFF, Math.hypot(xDiff, yDiff));
let xNew = distance * Math.cos(angle);
let yNew = distance * Math.sin(angle);
if (env.display().isLayoutSwitched) {
let tmp = xNew;
xNew = yNew;
yNew = -tmp;
}
let style = `translate(${xNew}px, ${yNew}px)`;
vpadCircle.css('transform', style);
vpadCircle.css('-webkit-transform', style);
vpadCircle.css('-moz-transform', style);
let xRatio = xNew / MAX_DIFF;
let yRatio = yNew / MAX_DIFF;
checkVpadState(KEY.LEFT, xRatio <= -0.5);
checkVpadState(KEY.RIGHT, xRatio >= 0.5);
checkVpadState(KEY.UP, yRatio <= -0.5);
checkVpadState(KEY.DOWN, yRatio >= 0.5);
}
/*
Right side - Control buttons
*/
function handleButtonDown() {
checkVpadState($(this).attr('value'), true);
// add touchIdx?
}
function handleButtonUp() {
checkVpadState($(this).attr('value'), false);
}
/*
Touch menu
*/
let menuTouchIdx = null;
let menuTouchDrag = null;
let menuTouchTime = null;
function handleMenuDown(event) {
// Identify of touch point
if (event.changedTouches) {
menuTouchIdx = event.changedTouches[0].identifier;
event.clientX = event.changedTouches[0].clientX;
event.clientY = event.changedTouches[0].clientY;
}
menuTouchDrag = {x: event.clientX, y: event.clientY,};
menuTouchTime = Date.now();
}
function handleMenuMove(evt) {
if (menuTouchDrag === null) return;
if (evt.changedTouches) {
// check if moving source is from other touch?
for (let i = 0; i < evt.changedTouches.length; i++) {
if (evt.changedTouches[i].identifier === menuTouchIdx) {
evt.clientX = evt.changedTouches[i].clientX;
evt.clientY = evt.changedTouches[i].clientY;
}
}
if (evt.clientX === undefined || evt.clientY === undefined)
return;
}
const pos = env.display().isLayoutSwitched ? evt.clientX - menuTouchDrag.x : menuTouchDrag.y - evt.clientY;
event.pub(MENU_PRESSED, pos);
}
function handleMenuUp(evt) {
if (menuTouchDrag === null) return;
if (evt.changedTouches) {
if (evt.changedTouches[0].identifier !== menuTouchIdx)
return;
evt.clientX = evt.changedTouches[0].clientX;
evt.clientY = evt.changedTouches[0].clientY;
}
let newY = env.display().isLayoutSwitched ? -menuTouchDrag.x + evt.clientX : menuTouchDrag.y - evt.clientY;
let interval = Date.now() - menuTouchTime; // 100ms?
if (interval < 200) {
// calc velo
newY = newY / interval * 250;
}
// current item?
event.pub(MENU_RELEASED, newY);
menuTouchDrag = null;
}
/*
Common events
*/
function handleWindowMove(event) {
event.preventDefault();
handleVpadJoystickMove(event);
handleMenuMove(event);
// moving touch
if (event.changedTouches) {
for (let i = 0; i < event.changedTouches.length; i++) {
if (event.changedTouches[i].identifier !== menuTouchIdx && event.changedTouches[i].identifier !== vpadTouchIdx) {
// check class
let elem = document.elementFromPoint(event.changedTouches[i].clientX, event.changedTouches[i].clientY);
if (elem.classList.contains('btn')) {
$(elem).trigger('touchstart');
} else {
buttons.trigger('touchend');
}
}
}
}
}
function handleWindowUp(ev) {
handleVpadJoystickUp(ev);
handleMenuUp(ev);
buttons.trigger('touchend');
}
// touch/mouse events for control buttons. mouseup events is binded to window.
buttons.on('mousedown', handleButtonDown);
buttons.on('touchstart', handleButtonDown);
buttons.on('touchend', handleButtonUp);
// touch/mouse events for dpad. mouseup events is binded to window.
vpadHolder.on('mousedown', handleVpadJoystickDown);
vpadHolder.on('touchstart', handleVpadJoystickDown);
vpadHolder.on('touchend', handleVpadJoystickUp);
// Bind events for menu
// TODO change this flow
event.pub(MENU_HANDLER_ATTACHED, {event: 'mousedown', handler: handleMenuDown});
event.pub(MENU_HANDLER_ATTACHED, {event: 'touchstart', handler: handleMenuDown});
event.pub(MENU_HANDLER_ATTACHED, {event: 'touchend', handler: handleMenuUp});
return {
init: () => {
// add buttons into the state 🤦
$('.btn, .btn-big').each((_, el) => {
vpadState[$(el).attr('value')] = false;
});
// Bind events for window
window_.on('mousemove', handleWindowMove);
window_[0].addEventListener('touchmove', handleWindowMove, {passive: false});
window_.on('mouseup', handleWindowUp);
log.info('[input] touch input has been initialized');
}
}
})($, document, event, KEY, window);

File diff suppressed because one or more lines are too long

2
web/js/lib/jquery-3.4.1.min.js vendored Normal file

File diff suppressed because one or more lines are too long

31
web/js/libopus.js vendored

File diff suppressed because one or more lines are too long

21
web/js/log.js vendored Normal file
View file

@ -0,0 +1,21 @@
const log = (() => {
let level = 'info';
const levels = {'trace': 0, 'debug': 1, 'error': 2, 'info': 3};
const atLeast = (lv) => (levels[lv] || -1) >= levels[level];
return {
info: function () {
atLeast('info') && console.info.apply(null, arguments)
},
debug: function () {
atLeast('debug') && console.debug.apply(null, arguments)
},
error: function () {
atLeast('error') && console.error.apply(null, arguments)
},
setLevel: (level_) => {
level = level_
}
}
})();

29
web/js/network/ajax.js vendored Normal file
View file

@ -0,0 +1,29 @@
/**
* AJAX request module.
* @version 1
*/
const ajax = (() => {
const defaultTimeout = 10000;
return {
fetch: (url, options, timeout = defaultTimeout) => new Promise((resolve, reject) => {
const controller = new AbortController();
const signal = controller.signal;
const allOptions = Object.assign({}, options, signal);
// fetch(url, {...options, signal})
fetch(url, allOptions)
.then(resolve, () => {
controller.abort();
return reject
});
// auto abort when a timeout reached
setTimeout(() => {
controller.abort();
reject();
}, timeout);
}),
defaultTimeoutMs: () => defaultTimeout
}
})();

125
web/js/network/rtcp.js vendored Normal file
View file

@ -0,0 +1,125 @@
/**
* RTCP connection module.
* @version 1
*/
const rtcp = (() => {
let connection;
let inputChannel;
let mediaStream;
let connected = false;
let inputReady = false;
const start = (iceservers) => {
log.info(`[rtcp] <- received stunturn from worker ${iceservers}`);
connection = new RTCPeerConnection({
iceServers: JSON.parse(iceservers)
});
mediaStream = new MediaStream();
// input channel, ordered + reliable, id 0
inputChannel = connection.createDataChannel('a', {ordered: true, negotiated: true, id: 0,});
inputChannel.onopen = () => {
log.debug('[rtcp] the input channel has opened');
inputReady = true;
event.pub(CONNECTION_READY)
};
inputChannel.onclose = () => log.debug('[rtcp] the input channel has closed');
connection.addTransceiver('video', {'direction': 'recvonly'});
connection.addTransceiver('audio', {'direction': 'recvonly'});
connection.oniceconnectionstatechange = ice.onIceConnectionStateChange;
connection.onicegatheringstatechange = ice.onIceStateChange;
connection.onicecandidate = ice.onIcecandidate;
connection.ontrack = event => mediaStream.addTrack(event.track);
connection.createOffer({offerToReceiveVideo: true, offerToReceiveAudio: true})
.then(offer => {
log.info(offer.sdp);
connection.setLocalDescription(offer).catch(log.error);
});
};
const ice = (() => {
let isGatheringDone = false;
let timeForIceGathering;
const ICE_TIMEOUT = 2000;
const sendCandidates = () => {
if (isGatheringDone) return;
const session = btoa(JSON.stringify(connection.localDescription));
const data = JSON.stringify({"sdp": session, "is_mobile": env.isMobileDevice()});
socket.send({"id": "initwebrtc", "data": data});
isGatheringDone = true;
};
return {
onIcecandidate: event => {
if (event.candidate && !isGatheringDone) {
log.info(JSON.stringify(event.candidate));
} else {
sendCandidates()
}
// TODO: Fix curPacketID
},
onIceStateChange: event => {
switch (event.target.iceGatheringState) {
case 'gathering':
log.info('[rtcp] ice gathering');
timeForIceGathering = setTimeout(() => {
log.info(`[rtcp] ice gathering was aborted due to timeout ${ICE_TIMEOUT}ms`);
sendCandidates();
}, ICE_TIMEOUT);
break;
case 'complete':
log.info('[rtcp] ice gathering completed');
if (timeForIceGathering) {
clearTimeout(timeForIceGathering);
}
}
},
onIceConnectionStateChange: () => {
log.info(`[rtcp] <- iceConnectionState: ${connection.iceConnectionState}`);
switch (connection.iceConnectionState) {
case 'connected': {
log.info('[rtcp] connected...');
connected = true;
break;
}
case 'disconnected': {
log.info('[rtcp] disconnected...');
connected = false;
event.pub(CONNECTION_CLOSED);
break;
}
case 'failed': {
log.error('[rtcp] connection failed, retry...');
connected = false;
connection.createOffer({iceRestart: true})
.then(description => connection.setLocalDescription(description).catch(log.error))
.catch(log.error);
break;
}
}
}
}
})();
return {
start: start,
setRemoteDescription: (data, media) => {
connection.setRemoteDescription(new RTCSessionDescription(JSON.parse(atob(data))))
// set media object stream
.then(() => {
media.srcObject = mediaStream;
})
},
input: (data) => inputChannel.send(data),
isConnected: () => connected,
isInputReady: () => inputReady
}
})(event, socket, env, log);

98
web/js/network/socket.js vendored Normal file
View file

@ -0,0 +1,98 @@
/**
* WebSocket connection module.
*
* Needs init() call.
*
* @version 1
*/
const socket = (() => {
const pingIntervalMs = 1000 / 5;
let conn;
let curPacketId = '';
const init = (roomId) => {
conn = new WebSocket(`ws://${location.host}/ws${roomId ? `?room_id=${roomId}` : ''}`);
// Clear old roomID
conn.onopen = () => {
log.info('[ws] <- open connection');
log.info(`[ws] -> setting ping interval to ${pingIntervalMs}ms`);
// !to add destructor if SPA
setInterval(ping, pingIntervalMs)
};
conn.onerror = error => log.error(`[ws] ${error}`);
conn.onclose = () => log.info('[ws] closed');
conn.onmessage = response => {
const data = JSON.parse(response.data);
const message = data.id;
if (message !== 'heartbeat') log.debug(`[ws] <- message '${message}' `, data);
switch (message) {
case 'init':
// TODO: Read from struct
// init package has 2 part [stunturn, game1, game2, game3 ...]
// const [stunturn, ...games] = data;
let serverData = JSON.parse(data.data);
event.pub(MEDIA_STREAM_INITIALIZED, {stunturn: serverData.shift(), games: serverData});
break;
case 'sdp':
event.pub(MEDIA_STREAM_SDP_AVAILABLE, {sdp: data.data});
break;
case 'requestOffer':
// !to remove? wtf
curPacketId = data.packet_id;
event.pub(MEDIA_STREAM_READY);
break;
case 'heartbeat':
// reserved
break;
case 'start':
event.pub(GAME_ROOM_AVAILABLE, {roomId: data.room_id});
break;
case 'save':
event.pub(GAME_SAVED);
break;
case 'load':
event.pub(GAME_LOADED);
break;
case 'checkLatency':
curPacketId = data.packet_id;
const addresses = data.data.split(',');
event.pub(LATENCY_CHECK_REQUESTED, {packetId: curPacketId, addresses: addresses});
}
};
};
// TODO: format the package with time
const ping = () => send({"id": "heartbeat", "data": Date.now().toString()});
const send = (data) => conn.send(JSON.stringify(data));
const latency = (workers, packetId) => send({
"id": "checkLatency",
"data": JSON.stringify(workers),
"packet_id": packetId
});
const saveGame = () => send({"id": "save", "data": ""});
const loadGame = () => send({"id": "load", "data": ""});
const startGame = (gameName, isMobile, roomId, playerIndex) => send({
"id": "start",
"data": JSON.stringify({
"game_name": gameName,
"is_mobile": isMobile
}),
"room_id": roomId != null ? roomId : '',
"player_index": playerIndex
});
const quitGame = (roomId) => send({"id": "quit", "data": "", "room_id": roomId});
return {
init: init,
send: send,
latency: latency,
saveGame: saveGame,
loadGame: loadGame,
startGame: startGame,
quitGame: quitGame
}
})($, event, log);

200
web/js/opus.js vendored
View file

@ -1,200 +0,0 @@
///<reference path="d.ts/asm.d.ts" />
///<reference path="d.ts/libopus.d.ts" />
var OpusApplication;
(function (OpusApplication) {
OpusApplication[OpusApplication["VoIP"] = 2048] = "VoIP";
OpusApplication[OpusApplication["Audio"] = 2049] = "Audio";
OpusApplication[OpusApplication["RestrictedLowDelay"] = 2051] = "RestrictedLowDelay";
})(OpusApplication || (OpusApplication = {}));
var OpusError;
(function (OpusError) {
OpusError[OpusError["OK"] = 0] = "OK";
OpusError[OpusError["BadArgument"] = -1] = "BadArgument";
OpusError[OpusError["BufferTooSmall"] = -2] = "BufferTooSmall";
OpusError[OpusError["InternalError"] = -3] = "InternalError";
OpusError[OpusError["InvalidPacket"] = -4] = "InvalidPacket";
OpusError[OpusError["Unimplemented"] = -5] = "Unimplemented";
OpusError[OpusError["InvalidState"] = -6] = "InvalidState";
OpusError[OpusError["AllocFail"] = -7] = "AllocFail";
})(OpusError || (OpusError = {}));
var Opus = (function () {
function Opus() {
}
Opus.getVersion = function () {
var ptr = _opus_get_version_string();
return Pointer_stringify(ptr);
};
Opus.getMaxFrameSize = function (numberOfStreams) {
if (numberOfStreams === void 0) { numberOfStreams = 1; }
return (1275 * 3 + 7) * numberOfStreams;
};
Opus.getMinFrameDuration = function () {
return 2.5;
};
Opus.getMaxFrameDuration = function () {
return 60;
};
Opus.validFrameDuration = function (x) {
return [2.5, 5, 10, 20, 40, 60].some(function (element) {
return element == x;
});
};
Opus.getMaxSamplesPerChannel = function (sampling_rate) {
return sampling_rate / 1000 * Opus.getMaxFrameDuration();
};
return Opus;
})();
var OpusEncoder = (function () {
function OpusEncoder(sampling_rate, channels, app, frame_duration) {
if (frame_duration === void 0) { frame_duration = 20; }
this.handle = 0;
this.frame_size = 0;
this.in_ptr = 0;
this.in_off = 0;
this.out_ptr = 0;
if (!Opus.validFrameDuration(frame_duration))
throw 'invalid frame duration';
this.frame_size = sampling_rate * frame_duration / 1000;
var err_ptr = allocate(4, 'i32', ALLOC_STACK);
this.handle = _opus_encoder_create(sampling_rate, channels, app, err_ptr);
if (getValue(err_ptr, 'i32') != 0 /* OK */)
throw 'opus_encoder_create failed: ' + getValue(err_ptr, 'i32');
this.in_ptr = _malloc(this.frame_size * channels * 4);
this.in_len = this.frame_size * channels;
this.in_i16 = HEAP16.subarray(this.in_ptr >> 1, (this.in_ptr >> 1) + this.in_len);
this.in_f32 = HEAPF32.subarray(this.in_ptr >> 2, (this.in_ptr >> 2) + this.in_len);
this.out_bytes = Opus.getMaxFrameSize();
this.out_ptr = _malloc(this.out_bytes);
this.out_buf = HEAPU8.subarray(this.out_ptr, this.out_ptr + this.out_bytes);
}
OpusEncoder.prototype.encode = function (pcm) {
var output = [];
var pcm_off = 0;
while (pcm.length - pcm_off >= this.in_len - this.in_off) {
if (this.in_off > 0) {
this.in_i16.set(pcm.subarray(pcm_off, pcm_off + this.in_len - this.in_off), this.in_off);
pcm_off += this.in_len - this.in_off;
this.in_off = 0;
}
else {
this.in_i16.set(pcm.subarray(pcm_off, pcm_off + this.in_len));
pcm_off += this.in_len;
}
var ret = _opus_encode(this.handle, this.in_ptr, this.frame_size, this.out_ptr, this.out_bytes);
if (ret <= 0)
throw 'opus_encode failed: ' + ret;
var packet = new ArrayBuffer(ret);
new Uint8Array(packet).set(this.out_buf.subarray(0, ret));
output.push(packet);
}
if (pcm_off < pcm.length) {
this.in_i16.set(pcm.subarray(pcm_off));
this.in_off = pcm.length - pcm_off;
}
return output;
};
OpusEncoder.prototype.encode_float = function (pcm) {
var output = [];
var pcm_off = 0;
while (pcm.length - pcm_off >= this.in_len - this.in_off) {
if (this.in_off > 0) {
this.in_f32.set(pcm.subarray(pcm_off, pcm_off + this.in_len - this.in_off), this.in_off);
pcm_off += this.in_len - this.in_off;
this.in_off = 0;
}
else {
this.in_f32.set(pcm.subarray(pcm_off, pcm_off + this.in_len));
pcm_off += this.in_len;
}
var ret = _opus_encode_float(this.handle, this.in_ptr, this.frame_size, this.out_ptr, this.out_bytes);
if (ret <= 0)
throw 'opus_encode failed: ' + ret;
var packet = new ArrayBuffer(ret);
new Uint8Array(packet).set(this.out_buf.subarray(0, ret));
output.push(packet);
}
if (pcm_off < pcm.length) {
this.in_f32.set(pcm.subarray(pcm_off));
this.in_off = pcm.length - pcm_off;
}
return output;
};
OpusEncoder.prototype.encode_final = function () {
if (this.in_off == 0)
return new ArrayBuffer(0);
for (var i = this.in_off; i < this.in_len; ++i)
this.in_i16[i] = 0;
var ret = _opus_encode(this.handle, this.in_ptr, this.frame_size, this.out_ptr, this.out_bytes);
if (ret <= 0)
throw 'opus_encode failed: ' + ret;
var packet = new ArrayBuffer(ret);
new Uint8Array(packet).set(this.out_buf.subarray(0, ret));
return packet;
};
OpusEncoder.prototype.encode_float_final = function () {
if (this.in_off == 0)
return new ArrayBuffer(0);
for (var i = this.in_off; i < this.in_len; ++i)
this.in_f32[i] = 0;
var ret = _opus_encode_float(this.handle, this.in_ptr, this.frame_size, this.out_ptr, this.out_bytes);
if (ret <= 0)
throw 'opus_encode failed: ' + ret;
var packet = new ArrayBuffer(ret);
new Uint8Array(packet).set(this.out_buf.subarray(0, ret));
return packet;
};
OpusEncoder.prototype.destroy = function () {
if (!this.handle)
return;
_opus_encoder_destroy(this.handle);
_free(this.in_ptr);
this.handle = this.in_ptr = 0;
};
return OpusEncoder;
})();
var OpusDecoder = (function () {
function OpusDecoder(sampling_rate, channels) {
this.handle = 0;
this.in_ptr = 0;
this.out_ptr = 0;
this.channels = channels;
var err_ptr = allocate(4, 'i32', ALLOC_STACK);
this.handle = _opus_decoder_create(sampling_rate, channels, err_ptr);
if (getValue(err_ptr, 'i32') != 0 /* OK */)
throw 'opus_decoder_create failed: ' + getValue(err_ptr, 'i32');
this.in_ptr = _malloc(Opus.getMaxFrameSize(channels));
this.in_buf = HEAPU8.subarray(this.in_ptr, this.in_ptr + Opus.getMaxFrameSize(channels));
this.out_len = Opus.getMaxSamplesPerChannel(sampling_rate);
var out_bytes = this.out_len * channels * 4;
this.out_ptr = _malloc(out_bytes);
this.out_i16 = HEAP16.subarray(this.out_ptr >> 1, (this.out_ptr + out_bytes) >> 1);
this.out_f32 = HEAPF32.subarray(this.out_ptr >> 2, (this.out_ptr + out_bytes) >> 2);
}
OpusDecoder.prototype.decode = function (packet) {
this.in_buf.set(new Uint8Array(packet));
var ret = _opus_decode(this.handle, this.in_ptr, packet.byteLength, this.out_ptr, this.out_len, 0);
if (ret < 0)
throw 'opus_decode failed: ' + ret;
var samples = new Int16Array(ret * this.channels);
samples.set(this.out_i16.subarray(0, samples.length));
return samples;
};
OpusDecoder.prototype.decode_float = function (packet) {
this.in_buf.set(new Uint8Array(packet));
var ret = _opus_decode_float(this.handle, this.in_ptr, packet.byteLength, this.out_ptr, this.out_len, 0);
if (ret < 0)
throw 'opus_decode failed: ' + ret;
var samples = new Float32Array(ret * this.channels);
samples.set(this.out_f32.subarray(0, samples.length));
return samples;
};
OpusDecoder.prototype.destroy = function () {
if (!this.handle)
return;
_opus_decoder_destroy(this.handle);
_free(this.in_ptr);
_free(this.out_ptr);
this.handle = this.in_ptr = this.out_ptr = 0;
};
return OpusDecoder;
})();

69
web/js/room.js vendored Normal file
View file

@ -0,0 +1,69 @@
/**
* Game room module.
* @version 1
*/
const room = (() => {
let id = '';
// UI
const roomLabel = $('#room-txt');
// !to rewrite
const parseURLForRoom = () => {
let queryDict = {};
location.search.substr(1)
.split('&')
.forEach((item) => {
queryDict[item.split('=')[0]] = item.split('=')[1]
});
if (typeof queryDict.id === 'string') {
return decodeURIComponent(queryDict.id);
}
return null;
};
event.sub(GAME_ROOM_AVAILABLE, data => {
room.setId(data.roomId);
room.save(data.roomId);
}, 1);
return {
getId: () => id,
setId: (id_) => {
id = id_;
roomLabel.val(id);
},
reset: () => {
id = '';
roomLabel.val(id);
},
save: (roomIndex) => {
localStorage.setItem('roomID', roomIndex);
},
load: () => localStorage.getItem('roomID'),
getLink: () => window.location.href.split('?')[0] + `?id=${encodeURIComponent(room.getId())}`,
loadMaybe: () => {
// localStorage first
//roomID = loadRoomID();
// Shared URL second
const parsedId = parseURLForRoom();
if (parsedId !== null) {
id = parsedId;
}
return id;
},
copyToClipboard: () => {
const el = document.createElement('textarea');
el.value = room.getLink();
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
}
}
})(document, event, location, localStorage, window);

124
web/js/utils.js vendored
View file

@ -1,124 +0,0 @@
function log(msg) {
// if (LOG) {
// document.getElementById('div').innerHTML += msg + '<br>'
// }
console.log(msg);
}
function popup(msg) {
$("#noti-box").html(msg);
$("#noti-box").fadeIn().delay(DEBUG ? 0 : 0).fadeOut();
}
function openFullscreen(elem) {
if (elem.requestFullscreen) {
elem.requestFullscreen();
} else if (elem.mozRequestFullScreen) { /* Firefox */
elem.mozRequestFullScreen();
} else if (elem.webkitRequestFullscreen) { /* Chrome, Safari and Opera */
elem.webkitRequestFullscreen();
} else if (elem.msRequestFullscreen) { /* IE/Edge */
elem.msRequestFullscreen();
}
}
function closeFullscreen() {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.mozCancelFullScreen) { /* Firefox */
document.mozCancelFullScreen();
} else if (document.webkitExitFullscreen) { /* Chrome, Safari and Opera */
document.webkitExitFullscreen();
} else if (document.msExitFullscreen) { /* IE/Edge */
document.msExitFullscreen();
}
}
function getOS() {
// linux? ios?
var OSName = "unknown";
if (navigator.appVersion.indexOf("Win") !== -1) OSName = "win";
else if (navigator.appVersion.indexOf("Mac") !== -1) OSName = "mac";
else if (navigator.userAgent.indexOf("Linux") !== -1) OSName = "linux";
else if (navigator.userAgent.indexOf("Android") !== -1) OSName = "android";
return OSName;
}
function getBrowser() {
var browserName = "unknown";
if (navigator.userAgent.indexOf("Firefox") !== -1) browserName = "firefox";
if (navigator.userAgent.indexOf("Chrome") !== -1) browserName = "chrome";
if (navigator.userAgent.indexOf("Edge") !== -1) browserName = "edge";
if (navigator.userAgent.indexOf("Version/") !== -1) browserName = "safari";
if (navigator.userAgent.indexOf("UCBrowser") !== -1) browserName = "uc";
return browserName;
}
function isPortrait() {
// ios / mobile app
switch (window.orientation) {
case 0:
case 180:
return true;
break;
}
// desktop
var orient = screen.msOrientation || screen.mozOrientation || (screen.orientation || {}).type;
if (orient === "portrait-primary") {
return true;
}
return false;
}
function fixElementLayout(elem, targetWidth, targetHeight) {
var width = elem.width();
var height = elem.height();
var st = "translate(-50%, -50%) ";
// rotate portrait layout
if (isPortrait()) {
st += `rotate(90deg) `;
var tmp = targetHeight;
targetHeight = targetWidth;
targetWidth = tmp;
isLayoutSwitched = true;
} else {
isLayoutSwitched = false;
}
// scale, fit to target size
st += `scale(${Math.min(targetWidth / width, targetHeight / height)}) `;
elem.css("transform", st);
elem.css("-webkit-transform", st);
elem.css("-moz-transform", st);
}
function loadRoomID() {
return localStorage.getItem("roomID");
}
function saveRoomID(roomIdx) {
localStorage.setItem("roomID", roomIdx);
}
function copyToClipboard(str) {
const el = document.createElement('textarea');
el.value = str;
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
};

313
web/js/ws.js vendored
View file

@ -1,313 +0,0 @@
var curPacketID = "";
var curSessionID = "";
// web socket
// localStorage first
//roomID = loadRoomID();
roomID = "";
// Shared URL second
var rid = parseURLForRoom();
if (rid !== null) {
roomID = rid;
}
// if from URL -> start game immediately!
console.log(`ws://${location.host}/ws?room_id=${roomID}`)
conn = new WebSocket(`ws://${location.host}/ws?room_id=${roomID}`);
// Clear old roomID
conn.onopen = () => {
log("WebSocket is opened. Send ping");
log("Send ping pong frequently")
pingpongTimer = setInterval(sendPing, 1000 / PINGPONGPS)
}
conn.onerror = error => {
log(`Websocket error: ${error}`);
}
conn.onclose = () => {
log("Websocket closed");
}
conn.onmessage = e => {
d = JSON.parse(e.data);
switch (d["id"]) {
case "init":
// TODO: Read from struct
// init package has 2 part [stunturn, gamelist]
// The first element is stunturn address
// The rest are list of game
data = JSON.parse(d["data"]);
stunturn = data[0]
startWebRTC(stunturn);
data.shift()
gameList = [];
data.forEach(name => {
gameList.push(name);
});
log("Received game list");
// change screen to menu
reloadGameMenu();
showMenuScreen();
break;
case "sdp":
log("Got remote sdp");
pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(atob(d["data"]))));
break;
case "requestOffer":
curPacketID = d["packet_id"];
log("Received request offer ", curPacketID)
startWebRTC();
case "heartbeat":
// TODO: Calc time
break;
case "start":
roomID = d["room_id"];
log(`Got start with room id: ${roomID}`);
popup("Started! You can share you game!")
saveRoomID(roomID);
$("#btn-join").html("share");
// TODO: remove
$("#room-txt").val(d["room_id"]);
break;
case "save":
log(`Got save response: ${d["data"]}`);
popup("Saved");
break;
case "load":
log(`Got load response: ${d["data"]}`);
popup("Loaded");
break;
case "checkLatency":
var s = d["data"];
var latencyList = [];
curPacketID = d["packet_id"];
latencyPacketID = curPacketID;
addrs = s.split(",")
var latenciesMap = {};
var cntResp = 0;
beforeTime = Date.now();
for (const addr of addrs) {
var sumLatency = 0
// TODO: Clean code, use async
var xmlHttp = new XMLHttpRequest();
xmlHttp.open( "GET", "http://"+addr+":9000/echo?_=" + beforeTime, true ); // false for synchronous request, add date to not calling cache
xmlHttp.timeout = 2000
xmlHttp.ontimeout = () => {
cntResp++;
afterTime = Date.now();
//sumLatency += afterTime - beforeTime
latenciesMap[addr] = afterTime - beforeTime
if (cntResp == addrs.length) {
log(`Send latency list`)
console.log(latenciesMap)
conn.send(JSON.stringify({"id": "checkLatency", "data": JSON.stringify(latenciesMap), "packet_id": latencyPacketID}));
//startWebRTC();
}
}
xmlHttp.onload = () => {
cntResp++;
afterTime = Date.now();
//sumLatency += afterTime - beforeTime
latenciesMap[addr] = afterTime - beforeTime
if (cntResp == addrs.length) {
log(`Send latency list ${latenciesMap}`)
console.log(latenciesMap)
//conn.send(JSON.stringify({"id": "checkLatency", "data": latenciesMap, "packet_id": latencyPacketID}));
conn.send(JSON.stringify({"id": "checkLatency", "data": JSON.stringify(latenciesMap), "packet_id": latencyPacketID}));
//startWebRTC();
}
}
xmlHttp.send( null );
}
}
}
function updateLatencies(beforeTime, addr, latenciesMap, cntResp, curPacketID) {
afterTime = Date.now();
//sumLatency += afterTime - beforeTime
latenciesMap[addr] = afterTime - beforeTime
if (cntResp == addrs.length) {
log(`Send latency list ${latenciesMap}`)
log(curPacketID)
conn.send(JSON.stringify({"id": "checkLatency", "data": latenciesMap, "packet_id": curPacketID}));
}
}
function sendPing() {
// TODO: format the package with time
conn.send(JSON.stringify({"id": "heartbeat", "data": Date.now().toString()}));
}
function startWebRTC(iceservers) {
log(`received stunturn from worker ${iceservers}`)
// webrtc
iceservers = JSON.parse(iceservers);
pc = new RTCPeerConnection({iceServers: iceservers });
// input channel, ordered + reliable, id 0
inputChannel = pc.createDataChannel('a', {
ordered: true,
negotiated: true,
id: 0,
});
inputChannel.onopen = () => {
log('inputChannel has opened');
inputReady = true;
// TODO: Event based
if (roomID != "") {
startGame()
}
}
inputChannel.onclose = () => log('inputChannel has closed');
pc.oniceconnectionstatechange = e => {
log(`iceConnectionState: ${pc.iceConnectionState}`);
if (pc.iceConnectionState === "connected") {
gameReady = true
iceSuccess = true
if (roomID != "") {
startGame()
}
}
else if (pc.iceConnectionState === "failed") {
gameReady = false
iceSuccess = false
log(`failed. Retry...`)
pc.createOffer({iceRestart: true }).then(d => {
pc.setLocalDescription(d).catch(log);
}).catch(log);
}
else if (pc.iceConnectionState === "disconnected") {
stopGameInputTimer()
}
}
var stream = new MediaStream();
document.getElementById("game-screen").srcObject = stream;
// video channel
pc.ontrack = function (event) {
stream.addTrack(event.track);
}
// candidate packet from STUN
pc.onicecandidate = event => {
if (event.candidate === null) {
// send to ws
if (!iceSent) {
session = btoa(JSON.stringify(pc.localDescription));
log("Send SDP to remote peer");
// TODO: Fix curPacketID
conn.send(JSON.stringify({"id": "initwebrtc", "data": JSON.stringify({"sdp": session, "is_mobile": isMobileDevice()})}));
iceSent = true
}
} else {
console.log(JSON.stringify(event.candidate));
// TODO: tidy up, setTimeout multiple time now
// timeout
setTimeout(() => {
if (!iceSent) {
log("Ice gathering timeout, send anyway")
session = btoa(JSON.stringify(pc.localDescription));
conn.send(JSON.stringify({"id": "initwebrtc", "data": JSON.stringify({"sdp": session, "is_mobile": isMobileDevice()})}));
iceSent = true;
}
}, ICE_TIMEOUT)
}
}
// receiver only tracks
pc.addTransceiver('video', {'direction': 'recvonly'});
pc.addTransceiver('audio', {'direction': 'recvonly'});
// create SDP
pc.createOffer({offerToReceiveVideo: true, offerToReceiveAudio: true}).then(d => {
log(d.sdp)
pc.setLocalDescription(d).catch(log);
})
}
function startGame() {
if (!iceSuccess) {
popup("Game cannot load. Please refresh");
return false;
}
// TODO: Add while loop
if (!gameReady || !inputReady) {
popup("Game is not ready yet. Please wait");
return false;
}
var promise = document.getElementById("game-screen").play();
if (promise !== undefined) {
promise.then(_ => {
console.log("Media can autoplay")
}).catch(error => {
// Usually error happens when we autoplay unmuted video, browser requires manual play.
// We already muted video and use separate audio encoding so it's fine now
console.log("Media Failed to autoplay")
console.log(error)
// TODO: Consider workaround
});
}
if (screenState != "menu") {
return false;
}
log("Starting game screen");
screenState = "game";
conn.send(JSON.stringify({"id": "start", "data": JSON.stringify({"game_name": gameList[gameIdx], "is_mobile": isMobileDevice()}), "room_id": roomID != null ? roomID : '', "player_index": 1}));
// clear menu screen
stopGameInputTimer();
$("#menu-screen").hide()
$("#game-screen").show();
$("#btn-save").show();
$("#btn-load").show();
// end clear
startGameInputTimer();
return true
}
// Check mobile type because different mobile can accept different video encoder.
function isMobileDevice() {
return (typeof window.orientation !== "undefined") || (navigator.userAgent.indexOf('IEMobile') !== -1);
};
function parseURLForRoom() {
var queryDict = {}
location.search.substr(1).split("&").forEach(function(item) {
queryDict[item.split("=")[0]] = item.split("=")[1]
});
if (typeof queryDict["id"] === "string") {
return decodeURIComponent(queryDict["id"]);
}
return null;
}