mirror of
https://github.com/AndrioCelos/TableturfBattleApp.git
synced 2026-04-17 14:25:50 -05:00
576 lines
18 KiB
TypeScript
576 lines
18 KiB
TypeScript
declare var baseUrl: string;
|
||
|
||
const defaultColours = [
|
||
[ { r: 236, g: 249, b: 1 }, { r: 250, g: 158, b: 0 }, { r: 249, g: 249, b: 31 } ],
|
||
[ { r: 74, g: 92, b: 252 }, { r: 1, g: 237, b: 254 }, { r: 213, g: 225, b: 225 } ],
|
||
[ { r: 249, g: 6, b: 224 }, { r: 128, g: 6, b: 249 }, { r: 235, g: 180, b: 253 } ],
|
||
[ { r: 6, g: 249, b: 148 }, { r: 6, g: 249, b: 6 }, { r: 180, g: 253, b: 199 } ]
|
||
];
|
||
let uiBaseColourIsSpecialColourOutOfGame = true;
|
||
|
||
const errorDialog = document.getElementById('errorDialog') as HTMLDialogElement;
|
||
const errorMessage = document.getElementById('errorMessage')!;
|
||
const errorDialogForm = document.getElementById('errorDialogForm') as HTMLFormElement;
|
||
|
||
let initialised = false;
|
||
let initialiseCallback: (() => void) | null = null;
|
||
let canPushState = isSecureContext && location.protocol != 'file:';
|
||
|
||
const decks = [ new SavedDeck('Starter Deck', 0, [ 6, 34, 159, 13, 45, 137, 22, 52, 141, 28, 55, 103, 40, 56, 92 ], new Array(15), true) ];
|
||
let selectedDeck: SavedDeck | null = null;
|
||
let editingDeck = false;
|
||
let deckModified = false;
|
||
let shouldConfirmLeavingGame = false;
|
||
|
||
function delay(ms: number, abortSignal?: AbortSignal) {
|
||
return new Promise((resolve, reject) => {
|
||
if (abortSignal?.aborted) {
|
||
reject(new DOMException('Operation cancelled', 'AbortError'));
|
||
return;
|
||
}
|
||
const timeout = setTimeout(() => resolve(null), ms);
|
||
abortSignal?.addEventListener('abort', _ => {
|
||
clearTimeout(timeout);
|
||
reject(new DOMException('Operation cancelled', 'AbortError'));
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Schedules the specified callback to run when game data is initialised, or runs it synchronously if already initialised.
|
||
* Only one method may be scheduled this way.
|
||
*/
|
||
function onInitialise(callback: () => void) {
|
||
if (initialised)
|
||
callback();
|
||
else
|
||
initialiseCallback = callback;
|
||
}
|
||
|
||
function initCardDatabase(cards: Card[]) {
|
||
deckEditInitCardDatabase(cards);
|
||
}
|
||
function initStageDatabase(stages: Stage[]) {
|
||
lobbyInitStageDatabase(stages);
|
||
deckEditInitStageDatabase(stages);
|
||
}
|
||
|
||
// Pages
|
||
const pages = new Map<string, HTMLDivElement>();
|
||
for (var id of [ 'noJS', 'preGame', 'lobby', 'game', 'deckList', 'deckEdit' ]) {
|
||
let el = document.getElementById(`${id}Page`) as HTMLDivElement;
|
||
if (!el) throw new EvalError(`Element not found: ${id}Page`);
|
||
pages.set(id, el);
|
||
}
|
||
|
||
function showPage(key: string) {
|
||
for (const [key2, el] of pages) {
|
||
el.hidden = key2 != key;
|
||
}
|
||
}
|
||
|
||
function setClientToken(token: string) {
|
||
localStorage.setItem('clientToken', token);
|
||
clientToken = token;
|
||
}
|
||
|
||
function setUrl(path: string) {
|
||
settingUrl = true;
|
||
try {
|
||
if (canPushState) {
|
||
try {
|
||
history.pushState(null, '', path);
|
||
} catch {
|
||
canPushState = false;
|
||
location.hash = `#${path}`;
|
||
}
|
||
} else
|
||
location.hash = `#${path}`;
|
||
} finally {
|
||
settingUrl = false;
|
||
}
|
||
}
|
||
function setGameUrl(gameID: string) { setUrl(`game/${gameID}`); }
|
||
function clearUrlFromGame() {
|
||
if (canPushState) {
|
||
try {
|
||
history.pushState(null, '', '../..');
|
||
} catch {
|
||
canPushState = false;
|
||
}
|
||
}
|
||
if (location.hash)
|
||
location.hash = '';
|
||
}
|
||
|
||
function onGameSettingsChange() {
|
||
if (currentGame == null) return;
|
||
if (lobbyTimeLimitBox.value != currentGame.game.turnTimeLimit?.toString() ?? '')
|
||
lobbyTimeLimitBox.value = currentGame.game.turnTimeLimit?.toString() ?? '';
|
||
}
|
||
|
||
function onGameStateChange(game: any, playerData: PlayerData | null) {
|
||
if (currentGame == null)
|
||
throw new Error('currentGame is null');
|
||
clearPlayContainers();
|
||
currentGame.game.state = game.state;
|
||
|
||
if (game.board) {
|
||
board.flip = playerData != null && playerData.playerIndex % 2 != 0;
|
||
if (board.flip) gamePage.classList.add('boardFlipped');
|
||
else gamePage.classList.remove('boardFlipped');
|
||
board.resize(game.board);
|
||
board.startSpaces = game.startSpaces;
|
||
board.refresh();
|
||
}
|
||
loadPlayers(game.players);
|
||
gamePage.dataset.myPlayerIndex = playerData ? playerData.playerIndex.toString() : '';
|
||
gamePage.dataset.uiBaseColourIsSpecialColour = (userConfig.colourLock
|
||
? (playerData?.playerIndex ?? 0) != 1
|
||
: game.players[playerData?.playerIndex ?? 0].uiBaseColourIsSpecialColour ?? true).toString();
|
||
|
||
if (game.state != GameState.WaitingForPlayers)
|
||
lobbyLockSettings(true);
|
||
|
||
redrawModal.hidden = true;
|
||
gamePage.classList.remove('gameEnded');
|
||
switch (game.state) {
|
||
case GameState.WaitingForPlayers:
|
||
case GameState.ChoosingStage:
|
||
initLobbyPage(window.location.toString());
|
||
showPage('lobby');
|
||
clearConfirmLeavingGame();
|
||
lobbySelectedStageSection.hidden = true;
|
||
lobbyStageSection.hidden = !playerData || game.players[playerData.playerIndex]?.isReady;
|
||
break;
|
||
case GameState.ChoosingDeck:
|
||
showPage('lobby');
|
||
if (currentGame.me) setConfirmLeavingGame();
|
||
if (selectedStageIndicator)
|
||
lobbySelectedStageSection.removeChild(selectedStageIndicator.buttonElement);
|
||
selectedStageIndicator = new StageButton(stageDatabase.stages?.find(s => s.name == game.stage)!);
|
||
selectedStageIndicator.buttonElement.id = 'selectedStageButton';
|
||
selectedStageIndicator.buttonElement.disabled = true;
|
||
selectedStageIndicator.setStartSpaces(game.players.length);
|
||
lobbySelectedStageSection.appendChild(selectedStageIndicator.buttonElement);
|
||
|
||
lobbySelectedStageSection.hidden = false;
|
||
initDeckSelection();
|
||
break;
|
||
case GameState.Redraw:
|
||
case GameState.Ongoing:
|
||
case GameState.GameEnded:
|
||
case GameState.SetEnded:
|
||
board.autoHighlight = false;
|
||
redrawModal.hidden = true;
|
||
if (playerData) {
|
||
updateHandAndDeck(playerData);
|
||
initGame();
|
||
} else
|
||
initSpectator();
|
||
|
||
currentGame.game.turnNumber = game.turnNumber;
|
||
gameButtonsContainer.hidden = currentGame.me == null || game.state == GameState.GameEnded || game.state == GameState.SetEnded;
|
||
|
||
switch (game.state) {
|
||
case GameState.Redraw:
|
||
if (currentGame.me) setConfirmLeavingGame();
|
||
redrawModal.hidden = currentGame.me == null || currentGame.game.players[currentGame.me.playerIndex].isReady;
|
||
turnNumberLabel.turnNumber = null;
|
||
canPlay = false;
|
||
timeLabel.faded = redrawModal.hidden;
|
||
timeLabel.paused = false;
|
||
break;
|
||
case GameState.Ongoing:
|
||
for (let i = 0; i < currentGame.game.players.length; i++)
|
||
showWaiting(i);
|
||
if (currentGame.me) setConfirmLeavingGame();
|
||
turnNumberLabel.turnNumber = game.turnNumber;
|
||
board.autoHighlight = true;
|
||
canPlay = currentGame.me != null && !currentGame.game.players[currentGame.me.playerIndex].isReady;
|
||
timeLabel.faded = !canPlay;
|
||
timeLabel.paused = false;
|
||
resetPlayControls();
|
||
break;
|
||
case GameState.GameEnded:
|
||
case GameState.SetEnded:
|
||
clearConfirmLeavingGame();
|
||
gamePage.classList.add('gameEnded');
|
||
turnNumberLabel.turnNumber = null;
|
||
timeLabel.hide();
|
||
canPlay = false;
|
||
showResult();
|
||
break;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
let errorDialogCallback: ((e: Event) => void) | null = null;
|
||
function communicationError(message?: string, showButton?: boolean, callback?: (e: Event) => void) {
|
||
clearConfirmLeavingGame();
|
||
preGameLoadingSection.hidden = true;
|
||
errorMessage.innerText = message ?? 'A communication error has occurred.\nPlease reload the page to rejoin.';
|
||
errorDialogCallback = callback ?? null;
|
||
errorDialogForm.hidden = showButton != true;
|
||
errorDialog.showModal();
|
||
}
|
||
errorDialog.addEventListener('close', e => {
|
||
if (errorDialogCallback) {
|
||
errorDialogCallback(e);
|
||
errorDialogCallback = null;
|
||
}
|
||
});
|
||
|
||
function playerDataReviver(key: string, value: any) {
|
||
return !value ? value
|
||
: key == 'hand' || key == 'cards'
|
||
? (value as (Card | number)[]).map(v => typeof v == 'number' ? cardDatabase.get(v) : Card.fromJson(v))
|
||
: key == 'card'
|
||
? typeof value == 'number' ? cardDatabase.get(value) : Card.fromJson(value)
|
||
: value;
|
||
}
|
||
|
||
function setupWebSocket(gameID: string) {
|
||
const webSocket = new WebSocket(`${config.apiBaseUrl.replace(/(http)(s)?\:\/\//, 'ws$2://')}/websocket?gameID=${gameID}&clientToken=${clientToken}`);
|
||
webSocket.addEventListener('open', _ => {
|
||
enterGameTimeout = setTimeout(() => {
|
||
webSocket.close(1002, 'Timeout waiting for a sync message');
|
||
enterGameTimeout = null;
|
||
communicationError();
|
||
}, 30000);
|
||
});
|
||
webSocket.addEventListener('message', e => {
|
||
let s = e.data as string;
|
||
console.log(`>> ${s}`);
|
||
if (s) {
|
||
let payload = JSON.parse(s, playerDataReviver);
|
||
if (payload.event == 'sync') {
|
||
if (enterGameTimeout != null) {
|
||
clearTimeout(enterGameTimeout);
|
||
enterGameTimeout = null;
|
||
}
|
||
setLoadingMessage(null);
|
||
if (!e.data) {
|
||
webSocket.close();
|
||
alert('The game was not found.');
|
||
} else {
|
||
currentGame = {
|
||
id: gameID,
|
||
game: {
|
||
state: payload.data.state,
|
||
players: payload.data.players,
|
||
maxPlayers: payload.data.maxPlayers,
|
||
turnNumber: payload.data.turnNumber,
|
||
turnTimeLimit: payload.data.turnTimeLimit,
|
||
turnTimeLeft: payload.data.turnTimeLeft,
|
||
goalWinCount: payload.data.goalWinCount,
|
||
},
|
||
me: payload.playerData,
|
||
webSocket: webSocket
|
||
};
|
||
updateColours();
|
||
|
||
lobbyResetSlots();
|
||
for (let i = 0; i < currentGame.game.players.length; i++)
|
||
lobbyAddPlayer(i);
|
||
onGameSettingsChange();
|
||
|
||
for (let i = 0; i < playerBars.length; i++) {
|
||
playerBars[i].visible = i < currentGame.game.maxPlayers;
|
||
}
|
||
|
||
for (const button of stageButtons.buttons)
|
||
(button as StageButton).setStartSpaces(currentGame.game.maxPlayers);
|
||
|
||
onGameStateChange(payload.data, payload.playerData);
|
||
|
||
for (let i = 0; i < currentGame.game.players.length; i++) {
|
||
if (currentGame.game.players[i].isReady) showReady(i);
|
||
}
|
||
|
||
if (currentGame.me) {
|
||
if (currentGame.me.move) {
|
||
canPlay = false;
|
||
timeLabel.faded = true;
|
||
board.autoHighlight = false;
|
||
if (!currentGame.me.move.isPass) {
|
||
const move = currentGame.me.move as PlayMove;
|
||
board.cardPlaying = move.card;
|
||
board.highlightX = move.x;
|
||
board.highlightY = move.y;
|
||
board.cardRotation = move.rotation;
|
||
board.specialAttack = move.isSpecialAttack;
|
||
board.refreshHighlight();
|
||
}
|
||
}
|
||
}
|
||
|
||
timeLabel.paused = false;
|
||
if (currentGame.game.turnTimeLeft) {
|
||
timeLabel.setTime(currentGame.game.turnTimeLeft);
|
||
timeLabel.show();
|
||
} else
|
||
timeLabel.hide();
|
||
}
|
||
} else {
|
||
if (currentGame == null) {
|
||
communicationError();
|
||
return;
|
||
}
|
||
switch (payload.event) {
|
||
case 'settingsChange':
|
||
currentGame.game.turnTimeLimit = payload.data.turnTimeLimit;
|
||
onGameSettingsChange();
|
||
break;
|
||
case 'join':
|
||
if (payload.data.playerIndex == currentGame.game.players.length) {
|
||
currentGame.game.players.push(payload.data.player);
|
||
lobbyAddPlayer(payload.data.playerIndex);
|
||
}
|
||
else
|
||
communicationError();
|
||
break;
|
||
case 'playerReady':
|
||
currentGame.game.players[payload.data.playerIndex].isReady = true;
|
||
lobbySetReady(payload.data.playerIndex);
|
||
|
||
if (payload.data.playerIndex == currentGame.me?.playerIndex) {
|
||
lobbyStageSection.hidden = true;
|
||
lobbyDeckSection.hidden = true;
|
||
}
|
||
|
||
showReady(payload.data.playerIndex);
|
||
break;
|
||
case 'stateChange':
|
||
clearReady();
|
||
onGameStateChange(payload.data, payload.playerData);
|
||
if (payload.data.turnTimeLeft) {
|
||
timeLabel.paused = false;
|
||
timeLabel.setTime(payload.data.turnTimeLeft);
|
||
timeLabel.show();
|
||
}
|
||
break;
|
||
case 'turn':
|
||
case 'gameEnd':
|
||
clearReady();
|
||
board.autoHighlight = false;
|
||
showPage('game');
|
||
currentGame.game.turnNumber = payload.data.game.turnNumber;
|
||
|
||
for (let i = 0; i < currentGame.game.players.length; i++) {
|
||
const player = currentGame.game.players[i];
|
||
player.specialPoints = payload.data.game.players[i].specialPoints;
|
||
player.totalSpecialPoints = payload.data.game.players[i].totalSpecialPoints;
|
||
player.passes = payload.data.game.players[i].passes;
|
||
player.gamesWon = payload.data.game.players[i].gamesWon;
|
||
player.isReady = payload.data.game.players[i].isReady;
|
||
lobbyWinCounters[i].wins = player.gamesWon;
|
||
}
|
||
timeLabel.paused = true;
|
||
if (payload.data.game.turnTimeLeft)
|
||
timeLabel.setTime(payload.data.game.turnTimeLeft);
|
||
|
||
(async () => {
|
||
await playInkAnimations(payload.data);
|
||
if (payload.playerData) updateHandAndDeck(payload.playerData);
|
||
turnNumberLabel.turnNumber = payload.data.game.turnNumber;
|
||
clearPlayContainers();
|
||
if (payload.event == 'gameEnd') {
|
||
currentGame.game.state = payload.data.game.state;
|
||
clearConfirmLeavingGame();
|
||
gameButtonsContainer.hidden = true;
|
||
passButton.enabled = false;
|
||
specialButton.enabled = false;
|
||
gamePage.classList.add('gameEnded');
|
||
timeLabel.hide();
|
||
showResult();
|
||
} else {
|
||
canPlay = currentGame.me != null;
|
||
board.autoHighlight = canPlay;
|
||
resetPlayControls();
|
||
timeLabel.faded = !canPlay;
|
||
timeLabel.paused = false;
|
||
if (payload.data.game.turnTimeLeft)
|
||
timeLabel.show();
|
||
for (let i = 0; i < currentGame.game.players.length; i++)
|
||
showWaiting(i);
|
||
}
|
||
})();
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
webSocket.addEventListener('close', webSocket_close);
|
||
}
|
||
|
||
function webSocket_close() {
|
||
communicationError();
|
||
}
|
||
|
||
function setConfirmLeavingGame() {
|
||
if (!shouldConfirmLeavingGame) {
|
||
shouldConfirmLeavingGame = true;
|
||
window.addEventListener('beforeunload', onBeforeUnload_game);
|
||
}
|
||
}
|
||
|
||
function clearConfirmLeavingGame() {
|
||
if (shouldConfirmLeavingGame) {
|
||
shouldConfirmLeavingGame = false;
|
||
window.removeEventListener('beforeunload', onBeforeUnload_game);
|
||
}
|
||
}
|
||
|
||
function clearGame() {
|
||
if (currentGame?.webSocket) {
|
||
currentGame.webSocket.removeEventListener('close', webSocket_close);
|
||
currentGame.webSocket.close();
|
||
}
|
||
currentGame = null;
|
||
currentReplay = null;
|
||
clearConfirmLeavingGame();
|
||
}
|
||
|
||
function processUrl() {
|
||
if (deckModified) {
|
||
if (!confirm('You have unsaved changes to your deck. Are you sure you want to leave?')) {
|
||
setUrl('deckeditor');
|
||
return false;
|
||
}
|
||
} else if (shouldConfirmLeavingGame) {
|
||
if (!confirm('This will disconnect you from a game in progress. Are you sure you want to leave?')) {
|
||
setUrl(`game/${currentGame!.id}`);
|
||
return false;
|
||
}
|
||
}
|
||
stopEditingDeck();
|
||
errorDialog.close();
|
||
clearGame();
|
||
if (location.pathname.endsWith('/deckeditor') || location.hash == '#deckeditor')
|
||
onInitialise(showDeckList);
|
||
else {
|
||
showPage('preGame');
|
||
if (location.pathname.endsWith('/help') || location.hash == '#help')
|
||
helpDialog.showModal();
|
||
else {
|
||
helpDialog.close();
|
||
const m = /[/#](?:(game)|replay)\/([A-Za-z0-9+/=\-_]+)$/.exec(location.toString());
|
||
if (m) {
|
||
if (m[1])
|
||
presetGameID(m[2]);
|
||
else
|
||
onInitialise(() => new ReplayLoader(m[2]).loadReplay());
|
||
} else if (location.hash) {
|
||
canPushState = false;
|
||
presetGameID(location.hash);
|
||
} else {
|
||
clearPreGameForm(false);
|
||
}
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function onBeforeUnload_game(e: BeforeUnloadEvent) {
|
||
e.preventDefault();
|
||
return 'This will disconnect you from a game in progress.';
|
||
}
|
||
|
||
function parseGameID(s: string) {
|
||
const m = /(?:^|[#/])([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.exec(s);
|
||
return m ? m[1] : null;
|
||
}
|
||
|
||
function presetGameID(url: string) {
|
||
showPage('preGame');
|
||
|
||
const gameID = parseGameID(url);
|
||
if (!gameID) {
|
||
joinGameError('Invalid game ID or link.', true);
|
||
return;
|
||
}
|
||
|
||
onInitialise(() => {
|
||
let request = new XMLHttpRequest();
|
||
request.open('GET', `${config.apiBaseUrl}/games/${gameID}/playerData` + (clientToken ? `?clientToken=${clientToken}` : ''));
|
||
request.addEventListener('load', () => {
|
||
switch (request.status) {
|
||
case 200:
|
||
let response = JSON.parse(request.responseText);
|
||
|
||
if (response.playerData) {
|
||
// We are already in the game; go to the game page immediately.
|
||
onInitialise(() => getGameInfo(gameID, response.playerData.playerIndex));
|
||
} else {
|
||
// We're not already in the game; offer the option to join or spectate.
|
||
document.getElementById('preGameDefaultSection')!.hidden = true;
|
||
document.getElementById('preGameJoinSection')!.hidden = false;
|
||
(document.getElementById('gameIDBox') as HTMLInputElement).value = gameID;
|
||
setLoadingMessage(null);
|
||
}
|
||
break;
|
||
case 404: joinGameError('The room was not found.', true); break;
|
||
default: joinGameError('Unable to join the room.', true); break;
|
||
}
|
||
});
|
||
request.addEventListener('error', () => {
|
||
joinGameError('Unable to join the room.', true);
|
||
});
|
||
request.send();
|
||
setLoadingMessage('Checking room info...');
|
||
});
|
||
}
|
||
|
||
function encodeToUrlSafeBase64(array: Uint8Array) {
|
||
let base64 = Base64.base64EncArr(array);
|
||
base64 = base64.replaceAll('+', '-');
|
||
base64 = base64.replaceAll('/', '_');
|
||
return base64;
|
||
}
|
||
|
||
function isInternetExplorer() {
|
||
return !!(window.document as any).documentMode; // This is a non-standard property implemented only by Internet Explorer.
|
||
}
|
||
|
||
if (isInternetExplorer()) {
|
||
alert('You seem to be using an unsupported browser. Some layout or features of this app may not work correctly.');
|
||
}
|
||
|
||
function clearChildren(el: Element) {
|
||
let el2;
|
||
while (el2 = el.firstChild)
|
||
el.removeChild(el2);
|
||
}
|
||
function resetAnimation(el: HTMLElement) {
|
||
el.style.animation = 'none';
|
||
el.offsetHeight; // Trigger a reflow.
|
||
el.style.animation = '';
|
||
}
|
||
|
||
document.getElementById('noJSPage')!.innerText = 'Loading client...';
|
||
|
||
if (config.discordUrl) {
|
||
(document.getElementById('discordLink') as HTMLLinkElement).href = config.discordUrl;
|
||
if (config.discordTitle)
|
||
(document.getElementById('discordLink') as HTMLLinkElement).innerText = `Discord – ${config.discordTitle}`;
|
||
} else
|
||
document.getElementById('discordSection')!.hidden = true;
|
||
|
||
Promise.all([ cardDatabase.loadAsync().then(initCardDatabase), stageDatabase.loadAsync().then(initStageDatabase) ])
|
||
.then(() => {
|
||
initialised = true;
|
||
setLoadingMessage(null);
|
||
if (initialiseCallback)
|
||
initialiseCallback();
|
||
}, initError);
|
||
|
||
function initError() {
|
||
preGameLoadingSection.hidden = true;
|
||
communicationError('Unable to load game data from the server.', false);
|
||
}
|
||
|
||
showPage('preGame');
|
||
window.addEventListener('load', processUrl);
|