Rework sync procedure and add a loading indicator

The server will now send a sync message when the client connects via WebSocket. This removes the need to send another GET request to sync game data.
This commit is contained in:
Andrio Celos
2022-10-25 12:08:18 +11:00
parent 3ad1877a4c
commit d4e8d86571
14 changed files with 552 additions and 384 deletions

View File

@@ -1,31 +1,36 @@
const cardDatabase = {
cards: null as Card[] | null,
loadAsync() {
return new Promise<Card[]>((resolve, reject) => {
if (cardDatabase.cards != null) {
resolve(cardDatabase.cards);
return;
}
const cardListRequest = new XMLHttpRequest();
cardListRequest.open('GET', `${config.apiBaseUrl}/cards`);
cardListRequest.addEventListener('load', e => {
const cards = [ ];
if (cardListRequest.status == 200) {
const s = cardListRequest.responseText;
const response = JSON.parse(s) as object[];
for (const o of response) {
cards.push(Card.fromJson(o));
}
cardDatabase.cards = cards;
resolve(cards);
} else {
reject(new Error(`Error downloading card database: response was ${cardListRequest.status}`));
}
});
cardListRequest.addEventListener('error', e => {
reject(new Error('Error downloading card database: no further information.'))
});
cardListRequest.send();
});
}
}
const cardDatabase = {
cards: null as Card[] | null,
get(number: number) {
if (cardDatabase.cards == null) throw new Error('Card database not loaded');
if (number <= 0 || number > cardDatabase.cards.length) throw new RangeError(`No card with number ${number}`);
return cardDatabase.cards[number - 1];
},
loadAsync() {
return new Promise<Card[]>((resolve, reject) => {
if (cardDatabase.cards != null) {
resolve(cardDatabase.cards);
return;
}
const cardListRequest = new XMLHttpRequest();
cardListRequest.open('GET', `${config.apiBaseUrl}/cards`);
cardListRequest.addEventListener('load', e => {
const cards = [ ];
if (cardListRequest.status == 200) {
const s = cardListRequest.responseText;
const response = JSON.parse(s) as object[];
for (const o of response) {
cards.push(Card.fromJson(o));
}
cardDatabase.cards = cards;
resolve(cards);
} else {
reject(new Error(`Error downloading card database: response was ${cardListRequest.status}`));
}
});
cardListRequest.addEventListener('error', e => {
reject(new Error('Error downloading card database: no further information.'))
});
cardListRequest.send();
});
}
}

View File

@@ -13,6 +13,8 @@ let currentGame: {
webSocket: WebSocket
} | null;
let enterGameTimeout: number | null = null;
const playerList = document.getElementById('playerList')!;
const playerListItems: HTMLElement[] = [ ];

View File

@@ -1,12 +1,23 @@
const preGameForm = document.getElementById('preGameForm') as HTMLFormElement;
const newGameButton = document.getElementById('newGameButton')!;
const joinGameButton = document.getElementById('joinGameButton')!;
const nameBox = document.getElementById('nameBox') as HTMLInputElement;
const gameIDBox = document.getElementById('gameIDBox') as HTMLInputElement;
const maxPlayersBox = document.getElementById('maxPlayersBox') as HTMLSelectElement;
const preGameDeckEditorButton = document.getElementById('preGameDeckEditorButton') as HTMLLinkElement;
const preGameLoadingSection = document.getElementById('preGameLoadingSection')!;
const preGameLoadingLabel = document.getElementById('preGameLoadingLabel')!;
let shownMaxPlayersWarning = false;
function setLoadingMessage(message: string | null) {
if (message)
preGameLoadingLabel.innerText = message;
preGameLoadingSection.hidden = message == null;
for (const button of preGameForm.getElementsByTagName('button'))
button.disabled = message != null;
}
maxPlayersBox.addEventListener('change', () => {
if (!shownMaxPlayersWarning && maxPlayersBox.value != '2') {
if (confirm('Tableturf Battle is designed for two players and may not be well-balanced for more. Do you want to continue?'))
@@ -16,7 +27,7 @@ maxPlayersBox.addEventListener('change', () => {
}
});
(document.getElementById('preGameForm') as HTMLFormElement).addEventListener('submit', e => {
preGameForm.addEventListener('submit', e => {
e.preventDefault();
if (e.submitter?.id == 'newGameButton' || (e.submitter?.id == 'preGameImplicitSubmitButton' && !gameIDBox.value)) {
const name = nameBox.value;
@@ -41,6 +52,7 @@ maxPlayersBox.addEventListener('change', () => {
data.append('clientToken', clientToken);
data.append('maxPlayers', maxPlayersBox.value);
request.send(data.toString());
setLoadingMessage('Creating a room...');
} else {
const name = nameBox.value;
window.localStorage.setItem('name', name);
@@ -48,24 +60,6 @@ maxPlayersBox.addEventListener('change', () => {
}
});
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 tryJoinGame(name: string, idOrUrl: string, fromInitialLoad: boolean) {
const m = /(?:^|[#/])([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.exec(idOrUrl);
if (!m) {
@@ -98,6 +92,8 @@ function tryJoinGame(name: string, idOrUrl: string, fromInitialLoad: boolean) {
alert('The game has already started.');
else
alert('Unable to join the room.');
clearUrlFromGame();
setLoadingMessage(null);
if (fromInitialLoad)
clearPreGameForm(true);
else {
@@ -106,10 +102,14 @@ function tryJoinGame(name: string, idOrUrl: string, fromInitialLoad: boolean) {
}
}
});
request.addEventListener('error', () => {
communicationError();
});
let data = new URLSearchParams();
data.append('name', name);
data.append('clientToken', clientToken);
request.send(data.toString());
setLoadingMessage('Joining the game...');
}
function getGameInfo(gameID: string, myPlayerIndex: number | null) {
@@ -124,15 +124,7 @@ function backPreGameForm(updateUrl: boolean) {
document.getElementById('preGameJoinSection')!.hidden = true;
if (updateUrl) {
if (canPushState) {
try {
history.pushState(null, '', '../..');
} catch {
canPushState = false;
}
}
if (location.hash)
location.hash = '';
clearUrlFromGame();
}
}
function clearPreGameForm(updateUrl: boolean) {

View File

@@ -1,31 +1,31 @@
const stageDatabase = {
stages: null as Stage[] | null,
loadAsync() {
return new Promise<Stage[]>((resolve, reject) => {
if (stageDatabase.stages != null) {
resolve(stageDatabase.stages);
return;
}
const stageListRequest = new XMLHttpRequest();
stageListRequest.open('GET', `${config.apiBaseUrl}/stages`);
stageListRequest.addEventListener('load', e => {
const stages = [ ];
if (stageListRequest.status == 200) {
const s = stageListRequest.responseText;
const response = JSON.parse(s) as object[];
for (const o of response) {
stages.push(Stage.fromJson(o));
}
stageDatabase.stages = stages;
resolve(stages);
} else {
reject(new Error(`Error downloading stage database: response was ${stageListRequest.status}`));
}
});
stageListRequest.addEventListener('error', e => {
reject(new Error('Error downloading stage database: no further information.'))
});
stageListRequest.send();
});
}
}
const stageDatabase = {
stages: null as Stage[] | null,
loadAsync() {
return new Promise<Stage[]>((resolve, reject) => {
if (stageDatabase.stages != null) {
resolve(stageDatabase.stages);
return;
}
const stageListRequest = new XMLHttpRequest();
stageListRequest.open('GET', `${config.apiBaseUrl}/stages`);
stageListRequest.addEventListener('load', e => {
const stages = [ ];
if (stageListRequest.status == 200) {
const s = stageListRequest.responseText;
const response = JSON.parse(s) as object[];
for (const o of response) {
stages.push(Stage.fromJson(o));
}
stageDatabase.stages = stages;
resolve(stages);
} else {
reject(new Error(`Error downloading stage database: response was ${stageListRequest.status}`));
}
});
stageListRequest.addEventListener('error', e => {
reject(new Error('Error downloading stage database: no further information.'))
});
stageListRequest.send();
});
}
}

View File

@@ -24,6 +24,35 @@ function setClientToken(token: string) {
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 onGameStateChange(game: any, playerData: any) {
if (currentGame == null)
throw new Error('currentGame is null');
@@ -97,142 +126,166 @@ function communicationError() {
document.getElementById('errorModal')!.hidden = false;
}
function playerDataReviver(key: string, value: any) {
return !value ? value
: key == 'hand' || key == 'deck'
? (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, myPlayerIndex: number | null) {
const webSocket = new WebSocket(`${config.apiBaseUrl.replace(/(http)(s)?\:\/\//, 'ws$2://')}/websocket?gameID=${gameID}&clientToken=${clientToken}`);
webSocket.addEventListener('open', e => {
const request2 = new XMLHttpRequest();
request2.open('GET', `${config.apiBaseUrl}/games/${gameID}/playerData?clientToken=${clientToken}`);
request2.addEventListener('load', e => {
if (request2.status == 200) {
const response = JSON.parse(request2.responseText);
const playerData = response.playerData as PlayerData | null;
currentGame = {
id: gameID,
me: playerData != null ? { playerIndex: playerData.playerIndex, hand: playerData.hand?.map(Card.fromJson) || null, deck: playerData.deck?.map(Card.fromJson) || null, cardsUsed: playerData.cardsUsed, move: playerData.move } : null,
players: response.game.players,
maxPlayers: response.game.maxPlayers,
webSocket: webSocket
};
for (const li of playerListItems)
playerList.removeChild(li);
playerListItems.splice(0);
for (let i = 0; i < currentGame.maxPlayers; i++) {
var el = document.createElement('li');
playerListItems.push(el);
playerList.appendChild(el);
updatePlayerListItem(i);
}
for (let i = 0; i < playerBars.length; i++) {
playerBars[i].visible = i < currentGame.maxPlayers;
}
for (const button of stageButtons)
button.setStartSpaces(currentGame.maxPlayers);
onGameStateChange(response.game, playerData);
for (let i = 0; i < response.game.players.length; i++) {
if (response.game.players[i].isReady)
showReady(i);
}
if (playerData) {
myPlayerIndex = playerData.playerIndex;
if (playerData.move) {
canPlay = false;
board.autoHighlight = false;
if (!playerData.move.isPass) {
const move = playerData.move as PlayMove;
board.cardPlaying = Card.fromJson(move.card);
board.highlightX = move.x;
board.highlightY = move.y;
board.cardRotation = move.rotation;
board.specialAttack = move.isSpecialAttack;
board.refreshHighlight();
}
}
}
}
});
request2.send();
enterGameTimeout = setTimeout(() => {
webSocket.close(1002, 'Timeout waiting for a sync message');
enterGameTimeout = null;
communicationError();
}, 30000);
});
webSocket.addEventListener('message', e => {
if (currentGame == null)
return;
let s = e.data as string;
console.log(`>> ${s}`);
if (s) {
let payload = JSON.parse(s);
if (payload.event == 'join') {
if (payload.data.playerIndex == currentGame.players.length) {
currentGame.players.push(payload.data.player);
playerListItems[payload.data.playerIndex].innerText = payload.data.player.name;
updatePlayerListItem(payload.data.playerIndex);
let payload = JSON.parse(s, playerDataReviver);
if (payload.event == 'sync') {
if (enterGameTimeout != null) {
clearTimeout(enterGameTimeout);
enterGameTimeout = null;
}
else
communicationError();
} else if (payload.event == 'playerReady') {
currentGame.players[payload.data.playerIndex].isReady = true;
updatePlayerListItem(payload.data.playerIndex);
setLoadingMessage(null);
if (!e.data) {
webSocket.close();
alert('The game was not found.');
} else {
currentGame = {
id: gameID,
me: payload.playerData,
players: payload.data.players,
maxPlayers: payload.data.maxPlayers,
webSocket: webSocket
};
if (payload.data.playerIndex == currentGame.me?.playerIndex) {
lobbyStageSection.hidden = true;
lobbyDeckSection.hidden = true;
}
for (const li of playerListItems)
playerList.removeChild(li);
playerListItems.splice(0);
for (let i = 0; i < currentGame.maxPlayers; i++) {
var el = document.createElement('li');
playerListItems.push(el);
playerList.appendChild(el);
updatePlayerListItem(i);
}
if (playContainers[payload.data.playerIndex].getElementsByTagName('div').length == 0) {
showReady(payload.data.playerIndex);
}
} else if (payload.event == 'stateChange') {
clearReady();
onGameStateChange(payload.data, payload.playerData);
} else if (payload.event == 'turn' || payload.event == 'gameEnd') {
clearReady();
board.autoHighlight = false;
showPage('game');
for (let i = 0; i < playerBars.length; i++) {
playerBars[i].visible = i < currentGame.maxPlayers;
}
for (const button of stageButtons)
button.setStartSpaces(currentGame.maxPlayers);
onGameStateChange(payload.data, payload.playerData);
(async () => {
let anySpecialAttacks = false;
// Show the cards that were played.
clearPlayContainers();
for (let i = 0; i < currentGame.players.length; i++) {
const player = currentGame.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;
if (currentGame.players[i].isReady)
showReady(i);
}
const move = payload.data.moves[i];
const button = new CardButton('checkbox', move.card);
if (move.isSpecialAttack) {
anySpecialAttacks = true;
button.element.classList.add('specialAttack');
} else if (move.isPass) {
const el = document.createElement('div');
el.className = 'passLabel';
el.innerText = 'Pass';
button.element.appendChild(el);
if (currentGame.me) {
if (currentGame.me.move) {
canPlay = false;
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();
}
}
button.inputElement.hidden = true;
playContainers[i].append(button.element);
}
}
} else {
if (currentGame == null) {
communicationError();
return;
}
switch (payload.event) {
case 'join':
if (payload.data.playerIndex == currentGame.players.length) {
currentGame.players.push(payload.data.player);
playerListItems[payload.data.playerIndex].innerText = payload.data.player.name;
updatePlayerListItem(payload.data.playerIndex);
}
else
communicationError();
break;
case 'playerReady':
currentGame.players[payload.data.playerIndex].isReady = true;
updatePlayerListItem(payload.data.playerIndex);
if (payload.data.playerIndex == currentGame.me?.playerIndex) {
lobbyStageSection.hidden = true;
lobbyDeckSection.hidden = true;
}
if (playContainers[payload.data.playerIndex].getElementsByTagName('div').length == 0) {
showReady(payload.data.playerIndex);
}
break;
case 'stateChange':
clearReady();
onGameStateChange(payload.data, payload.playerData);
break;
case 'turn':
case 'gameEnd':
clearReady();
board.autoHighlight = false;
showPage('game');
let anySpecialAttacks = false;
// Show the cards that were played.
clearPlayContainers();
for (let i = 0; i < currentGame.players.length; i++) {
const player = currentGame.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;
await playInkAnimations(payload.data, anySpecialAttacks);
updateHand(payload.playerData.hand);
turnNumberLabel.setTurnNumber(payload.data.game.turnNumber);
clearPlayContainers();
if (payload.event == 'gameEnd') {
gameButtonsContainer.hidden = true;
gamePage.classList.add('gameEnded');
showResult();
} else {
canPlay = myPlayerIndex != null;
board.autoHighlight = canPlay;
setupControlsForPlay();
}
})();
const move = payload.data.moves[i];
const button = new CardButton('checkbox', move.card);
if (move.isSpecialAttack) {
anySpecialAttacks = true;
button.element.classList.add('specialAttack');
} else if (move.isPass) {
const el = document.createElement('div');
el.className = 'passLabel';
el.innerText = 'Pass';
button.element.appendChild(el);
}
button.inputElement.hidden = true;
playContainers[i].append(button.element);
}
(async () => {
await playInkAnimations(payload.data, anySpecialAttacks);
updateHand(payload.playerData.hand);
turnNumberLabel.setTurnNumber(payload.data.game.turnNumber);
clearPlayContainers();
if (payload.event == 'gameEnd') {
gameButtonsContainer.hidden = true;
gamePage.classList.add('gameEnded');
showResult();
} else {
canPlay = myPlayerIndex != null;
board.autoHighlight = canPlay;
setupControlsForPlay();
}
})();
break;
}
}
}
});
@@ -253,6 +306,7 @@ function processUrl() {
if (location.pathname.endsWith('/deckeditor') || location.hash == '#deckeditor')
showDeckList();
else {
showPage('preGame');
const m = /^(.*)\/game\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/.exec(location.toString());
if (m)
presetGameID(m[2]);
@@ -261,7 +315,6 @@ function processUrl() {
presetGameID(location.hash);
} else {
clearPreGameForm(false);
showPage('preGame');
}
}
return true;