cloud-game/web/js/controller.js
sergystepanov 8c55c6e142
Fix game sharing (#240)
Call save on the frontend and properly handle cloud save on the backend
2020-10-19 21:26:32 +08:00

468 lines
15 KiB
JavaScript
Vendored

/**
* App controller module.
* @version 1
*/
(() => {
// application state
let state;
let lastState;
// flags
// first user interaction
let interacted = false;
const DIR = (() => {
return {
IDLE: 'idle',
UP: 'up',
DOWN: 'down',
}
})();
let prevDir = DIR.IDLE;
// UI elements
// use $element[0] for DOM element
const gameScreen = $('#game-screen');
const menuScreen = $('#menu-screen');
const helpOverlay = $('#help-overlay');
const popupBox = $('#noti-box');
const playerIndex = document.getElementById('playeridx');
// keymap
const keyButtons = {};
Object.keys(KEY).forEach(button => {
keyButtons[KEY[button]] = $(`#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.is(log.level.debug)) {
const previous = prevState ? prevState.name : '???';
const current = state ? state.name : '???';
const kept = lastState ? lastState.name : '???';
log.debug(`[state] ${previous} -> ${current} [${kept}]`);
}
};
const onGameRoomAvailable = () => {
popup('Now you can share you game!');
};
const onConnectionReady = () => {
// start a game right away or show the menu
if (room.getId()) {
startGame();
} else {
state.menuReady();
}
};
const onLatencyCheck = (data) => {
popup('Connecting to fastest server...');
const timeoutMs = 1111;
// deduplicate
const addresses = [...new Set(data.addresses || [])];
Promise.all(addresses.map(address => {
const start = Date.now();
return ajax.fetch(`${address}?_=${start}`, {method: "GET", redirect: "follow"}, timeoutMs)
.then(() => ({[address]: Date.now() - start}))
.catch(() => ({[address]: 9999}));
})).then(servers => {
const latencies = Object.assign({}, ...servers);
log.info('[ping] <->', latencies);
socket.latency(latencies, data.packetId);
});
};
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;
if (state === app.state.game) {
gameScreen.toggle(!show);
} else {
menuScreen.toggle(!show);
}
keyButtons[KEY.SAVE].toggle(show);
keyButtons[KEY.LOAD].toggle(show);
helpOverlay.toggle(show);
this.shown = show;
if (event) event.pub(HELP_OVERLAY_TOGGLED, {shown: show});
}
};
const showMenuScreen = () => {
log.debug('[control] loading menu screen');
gameScreen.hide();
keyButtons[KEY.SAVE].hide();
keyButtons[KEY.LOAD].hide();
gameList.show();
menuScreen.show();
setState(app.state.menu);
};
const startGame = () => {
if (!rtcp.isConnected()) {
popup('Game cannot load. Please refresh');
return;
}
if (!rtcp.isInputReady()) {
popup('Game is not ready yet. Please wait');
return;
}
log.info('[control] game start');
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(), +playerIndex.value - 1);
// clear menu screen
input.poll().disable();
menuScreen.hide();
gameScreen.show();
keyButtons[KEY.SAVE].show();
keyButtons[KEY.LOAD].show();
// end clear
input.poll().enable();
};
const saveGame = utils.debounce(socket.saveGame, 1000);
const loadGame = utils.debounce(socket.loadGame, 1000);
const _popup = (message) => popupBox.html(message).fadeIn().fadeOut();
const popup = utils.throttle(_popup, 1000);
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.addClass('dpad-pressed');
} else {
if (button) button.addClass('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 (!button) return;
if (_dpadArrowKeys.includes(data.key)) {
button.removeClass('dpad-pressed');
} else {
if (button) button.removeClass('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
gameScreen[0].muted = 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;
socket.updatePlayerIndex(idx);
};
// noop function for the state
const _nil = () => {
}
const onAxisChanged = (data) => {
// maybe move it somewhere
if (!interacted) {
// unmute when there is user interaction
gameScreen[0].muted = false;
interacted = true;
}
state.axisChanged(data.id, data.value);
};
const handleToggle = () => {
var toggle = document.getElementById('dpad-toggle');
toggle.checked = !toggle.checked;
event.pub(DPAD_TOGGLE, {checked: toggle.checked});
};
const app = {
state: {
eden: {
name: 'eden',
axisChanged: _nil,
keyPress: _nil,
keyRelease: _nil,
menuReady: showMenuScreen
},
settings: {
_uber: true,
name: 'settings',
axisChanged: _nil,
keyPress: _nil,
keyRelease: key => {
if (key === KEY.SETTINGS) {
const isSettingsOpened = settings.ui.toggle();
if (!isSettingsOpened) setState(lastState);
}
},
menuReady: showMenuScreen
},
menu: {
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:
popup('You are already in menu screen!');
break;
case KEY.LOAD:
popup('Loading the game.');
break;
case KEY.SAVE:
popup('Saving the game.');
break;
case KEY.STATS:
event.pub(STATS_TOGGLE);
break;
case KEY.SETTINGS:
break;
case KEY.DTOGGLE:
handleToggle();
break;
}
},
menuReady: _nil
},
game: {
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();
popup('Shared link copied to the clipboard!');
break;
case KEY.SAVE:
saveGame();
break;
case KEY.LOAD:
loadGame();
break;
case KEY.FULL:
env.display().toggleFullscreen(gameScreen.height() !== window.innerHeight, gameScreen[0]);
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:
socket.toggleMultitap();
break;
// quit
case KEY.QUIT:
input.poll().disable();
// TODO: Stop game
socket.quitGame(room.getId());
room.reset();
popup('Quit!');
window.location = window.location.pathname;
break;
case KEY.STATS:
event.pub(STATS_TOGGLE);
break;
case KEY.DTOGGLE:
handleToggle();
break;
}
},
menuReady: _nil
}
}
};
// subscriptions
event.sub(GAME_ROOM_AVAILABLE, onGameRoomAvailable, 2);
event.sub(GAME_SAVED, () => popup('Saved'));
event.sub(GAME_LOADED, () => popup('Loaded'));
event.sub(GAME_PLAYER_IDX, idx => popup(+idx + 1));
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_CANDIDATE_ADD, (data) => rtcp.addCandidate(data.candidate));
event.sub(MEDIA_STREAM_CANDIDATE_FLUSH, () => rtcp.flushCandidate());
event.sub(MEDIA_STREAM_READY, () => rtcp.start());
event.sub(CONNECTION_READY, onConnectionReady);
event.sub(CONNECTION_CLOSED, () => input.poll().disable());
event.sub(LATENCY_CHECK_REQUESTED, onLatencyCheck);
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));
event.sub(SETTINGS_CHANGED, () => popup('Settings have been updated'));
event.sub(SETTINGS_CLOSED, () => {
state.keyRelease(KEY.SETTINGS);
});
event.sub(AXIS_CHANGED, onAxisChanged);
event.sub(CONTROLLER_UPDATED, data => rtcp.input(data));
// game screen stuff
gameScreen.on('loadstart', () => {
gameScreen[0].volume = 0.5;
gameScreen[0].poster = '/static/img/screen_loading.gif';
});
gameScreen.on('canplay', () => {
gameScreen[0].poster = '';
});
// initial app state
setState(app.state.eden);
})($, document, event, env, gameList, input, KEY, log, room, settings, socket, stats, utils);