diff --git a/backend/src/index.ts b/backend/src/index.ts index fc2b951f4475ee2833fcf9a04a9694f78d0664bf..c73a1f1f6f8fdf0f93a8702ae36f5b752a0f6350 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -18,6 +18,8 @@ app.use((req, res, next) => { next(); }); +app.use(express.json()); + app.get('/', (req, res) => { res.send('Hello, World!'); }); @@ -123,26 +125,43 @@ app.get('/highscores', async (req, res) => { }); // join a lobby with specific id -app.post('/lobby/:id', async (req, res) => { - if (gameHandler.getLobbyById(req.params.id)) { - const lobby = gameHandler.getLobbyById(req.params.id); +app.post('/lobby/create', async (req, res) => { + // if lobby doesn't exist, create a new lobby + const lobby = gameHandler.createLobby(); + lobby.addUser(req.body.userId); + res.status(201).send({ lobbyId: lobby.getId() }); +}); - if (!lobby) { - res.status(404).send('Lobby not found'); - return; - } +// Inform client if requested lobby is full or not +app.get('/lobby/:id/status', async (req, res) => { + const lobby = gameHandler.getLobbyById(req.params.id); - lobby.addUser(req.body.userId); - gameHandler.createGame(lobby); // start game since 2 players are in lobby - res.status(200).send(lobby.getId()); + if (!lobby) { + res.status(404).send('Lobby not found'); + return; + } + + if (lobby.isFull()) { + res.status(200).send({ isFull: true }); } else { - // if lobby doesn't exist, create a new lobby - const lobby = gameHandler.createLobby(); - lobby.addUser(req.body.userId); - res.status(201).send(lobby.getId()); + res.status(200).send({ isFull: false }); } }); +// join a lobby with specific id +app.post('/lobby/:id/join', async (req, res) => { + const lobby = gameHandler.getLobbyById(req.params.id); + + if (!lobby) { + res.status(404).send('Lobby not found'); + return; + } + + lobby.addUser(req.body.userId); + gameHandler.createGame(lobby); // start game since 2 players are in lobby + res.status(200).send({ lobbyId: lobby.getId() }); +}); + // leave a lobby with specific id app.post('/lobby/:id/leave', async (req, res) => { const lobby = gameHandler.getLobbyById(req.params.id); @@ -151,7 +170,7 @@ app.post('/lobby/:id/leave', async (req, res) => { if (lobby?.getUsers().length === 0) { gameHandler.removeLobby(lobby); } - res.status(200).send(lobby?.getId()); + res.status(200).send({ lobbyId: lobby?.getId() }); }); // Polling endpoint to check if its your turn (sends username) diff --git a/backend/src/interfaces/ILobby.ts b/backend/src/interfaces/ILobby.ts index 46edf40f6606d9719f81da94586b2bde9ca473ea..072b103f46334b6c72e3d1ccb5dc07946e083589 100644 --- a/backend/src/interfaces/ILobby.ts +++ b/backend/src/interfaces/ILobby.ts @@ -6,5 +6,6 @@ export interface ILobby { getUsers(): User[]; id: string; getId(): string; + isFull(): boolean; // maybe add a relation to a game? } diff --git a/backend/src/lobby/Lobby.ts b/backend/src/lobby/Lobby.ts index 2c3acecff54164234e47f1a964993f18c3d3c977..34e0f12cf16e8b1390d695bd1c3d6702d8fb9149 100644 --- a/backend/src/lobby/Lobby.ts +++ b/backend/src/lobby/Lobby.ts @@ -44,4 +44,8 @@ export class Lobby implements ILobby { getUsers() { return this.users; } + + isFull() { + return this.game != undefined; + } } diff --git a/frontend/assets/menu-textures.atlas b/frontend/assets/menu-textures.atlas index 046381c0b71c99f89d48c22fd37f0195871832d0..a9e0a1c361a9a9e07a8aa6a454b49e5ab93529eb 100644 --- a/frontend/assets/menu-textures.atlas +++ b/frontend/assets/menu-textures.atlas @@ -67,13 +67,20 @@ logo orig: 134, 40 offset: 0, 0 index: -1 -transparent-white-box +semi-transparent-white-box rotate: false xy: 921, 1009 size: 1, 1 orig: 1, 1 offset: 0, 0 index: -1 +transparent-white-box + rotate: false + xy: 892, 973 + size: 1, 1 + orig: 1, 1 + offset: 0, 0 + index: -1 typing-cursor rotate: false xy: 2, 2 diff --git a/frontend/assets/menu-textures.png b/frontend/assets/menu-textures.png index 6b223eced211d505301197211b096bcdbc94a6c7..bd0676b21eceb450b41ae64eee8bc8f276ba16f6 100644 Binary files a/frontend/assets/menu-textures.png and b/frontend/assets/menu-textures.png differ diff --git a/frontend/core/src/com/game/tankwars/HTTPRequestHandler.java b/frontend/core/src/com/game/tankwars/HTTPRequestHandler.java new file mode 100644 index 0000000000000000000000000000000000000000..dace91d89e8b477ede77918ff814e459ff088cd2 --- /dev/null +++ b/frontend/core/src/com/game/tankwars/HTTPRequestHandler.java @@ -0,0 +1,74 @@ +package com.game.tankwars; + +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.Net; +import com.badlogic.gdx.Net.HttpResponse; + +/** + * Handles request and response from the HTTP request using + * a passed callback function that treats request success, failure and cancellation. + * Implements the retry tactic by resending the request on failure + * a few times with increasing backoff time between resends. + */ +public class HTTPRequestHandler implements Net.HttpResponseListener { + + public static final String PROTOCOL = "http"; + public static final String HOST = "localhost"; + public static final int PORT = 3000; + + private final Callback callback; + private final Net.HttpRequest request; + private int attempts = 0; + private final int MAX_ATTEMPTS = 3; + private final int BACKOFF_TIME = 300; + + public HTTPRequestHandler(Callback callback, Net.HttpRequest request) { + this.callback = callback; + this.request = request; + } + + /** + * Send the HTTP request + */ + public void sendRequest() { + Gdx.net.sendHttpRequest(request, this); + } + + /** + * Request was successful and response received. Passes response body + * to callback. + * + * @param httpResponse The {@link HttpResponse} with the HTTP response values. + */ + public void handleHttpResponse(HttpResponse httpResponse) { + callback.onResult(httpResponse.getResultAsString()); + } + + /** + * Request failed. Request will be retried until the attempts + * have been exhausted with an increasing backoff time between each retry. + * + * @param t If the HTTP request failed because an Exception, t encapsulates it to give more information. + */ + public void failed(Throwable t) { + if (attempts < MAX_ATTEMPTS) { + attempts++; + + try { + Thread.sleep((long) attempts * BACKOFF_TIME); + sendRequest(); + } catch(InterruptedException e) { + System.err.println(e.getMessage()); + } + } else { + callback.onFailed(t); + } + } + + /** + * Request was cancelled + */ + public void cancelled() { + System.out.println("Request cancelled"); + } +} \ No newline at end of file diff --git a/frontend/core/src/com/game/tankwars/ReceiverHandler.java b/frontend/core/src/com/game/tankwars/ReceiverHandler.java index 4f16b98b91e6479f626057606e936d81817d1629..a0adc82af98ba35ab1ba6c3a01d627cc94fdb67b 100644 --- a/frontend/core/src/com/game/tankwars/ReceiverHandler.java +++ b/frontend/core/src/com/game/tankwars/ReceiverHandler.java @@ -19,6 +19,10 @@ public class ReceiverHandler implements Net.HttpResponseListener { private final Callback callback; public static final int MAX_RETRIES = 5; + public static final String PROTOCOL = "http"; + public static final String HOST = "localhost"; + public static final int PORT = 3000; + // Constructor to initialize the Callback instance public ReceiverHandler(Callback callback) { this.callback = callback; diff --git a/frontend/core/src/com/game/tankwars/TankWarsGame.java b/frontend/core/src/com/game/tankwars/TankWarsGame.java index 2e86cff9a98de774ec67e1f01e572798a3b07ef3..3c93ec84373938149727812059eb8324ddda054a 100644 --- a/frontend/core/src/com/game/tankwars/TankWarsGame.java +++ b/frontend/core/src/com/game/tankwars/TankWarsGame.java @@ -4,6 +4,7 @@ package com.game.tankwars; import com.badlogic.gdx.Game; +import com.game.tankwars.view.FindGameScreen; import com.game.tankwars.view.LoginScreen; public class TankWarsGame extends Game { diff --git a/frontend/core/src/com/game/tankwars/controller/FindGameController.java b/frontend/core/src/com/game/tankwars/controller/FindGameController.java index 4730e36abefc93d7700358d63f8bf5f9329b9a4d..f98492c6f7f5e7c71adf3d9edad3eff526226c62 100644 --- a/frontend/core/src/com/game/tankwars/controller/FindGameController.java +++ b/frontend/core/src/com/game/tankwars/controller/FindGameController.java @@ -1,71 +1,104 @@ package com.game.tankwars.controller; +import com.badlogic.gdx.Gdx; +import com.badlogic.gdx.Net; +import com.badlogic.gdx.net.HttpRequestBuilder; +import com.badlogic.gdx.scenes.scene2d.EventListener; import com.badlogic.gdx.scenes.scene2d.InputEvent; import com.badlogic.gdx.scenes.scene2d.InputListener; import com.badlogic.gdx.scenes.scene2d.Stage; import com.badlogic.gdx.scenes.scene2d.ui.Button; +import com.badlogic.gdx.scenes.scene2d.ui.Label; import com.badlogic.gdx.scenes.scene2d.ui.TextButton; import com.badlogic.gdx.scenes.scene2d.ui.TextField; import com.badlogic.gdx.scenes.scene2d.utils.ClickListener; +import com.badlogic.gdx.utils.Json; +import com.badlogic.gdx.utils.SerializationException; +import com.game.tankwars.Callback; +import com.game.tankwars.ConfigReader; +import com.game.tankwars.HTTPRequestHandler; import com.game.tankwars.ResourceManager; import com.game.tankwars.TankWarsGame; +import com.game.tankwars.model.CurrentUser; +import com.game.tankwars.model.LobbyId; +import com.game.tankwars.model.LobbyStatus; +import com.game.tankwars.view.FindGameScreen; import com.game.tankwars.view.GameScreen; import com.game.tankwars.view.MainMenuScreen; + public class FindGameController { private final TankWarsGame tankWarsGame; + private final FindGameScreen screen; private final Stage stage; private final TextField gamePinField; - private final TextButton joinLobbyButton, createLobbyButton; + private final TextButton joinLobbyButton, createLobbyButton, cancelButton; private final Button backButton; + private final Label gamePinWaitingLabel; + + private EventListener backButtonInputListener, gamePinFieldInputListener, + gamePinFieldClickListener, joinLobbyButtonInputListener, + createLobbyButtonInputListener, cancelButtonInputListener; + + private String lobbyId = null; + private final Runnable gameScreenTransition; + /** + * TODO: Ensure that user is logged in -> e.g. auth method in Auth/Utils class * Sets the event listeners of the buttons and the text field of the FindGameScreen, - * and allows for transitioning to MainMenuScreen and to GameScreen. + * and allows for transitioning to MainMenuScreen and to GameScreen. Handles communication + * with server to create, join and leave a lobbies. */ - public FindGameController(final TankWarsGame tankWarsGame, + public FindGameController(final TankWarsGame tankWarsGame, FindGameScreen screen, TextField gamePinField, TextButton joinLobbyButton, - TextButton createLobbyButton, Button backButton, final Stage stage) { + TextButton createLobbyButton, Button backButton, + TextButton cancelButton, Label gamePinWaitingLabel, + final Stage stage) { this.tankWarsGame = tankWarsGame; + this.screen = screen; + this.gamePinField = gamePinField; this.joinLobbyButton = joinLobbyButton; this.createLobbyButton = createLobbyButton; this.backButton = backButton; + this.cancelButton = cancelButton; + this.gamePinWaitingLabel = gamePinWaitingLabel; this.stage = stage; - setEventListeners(); + // This Runnable will be passed to the "render" thread + // using Gdx.app.postRunnable() from the threads of HTTP requests + gameScreenTransition = new Runnable() { + @Override + public void run() { + ResourceManager.getInstance().clear(); + tankWarsGame.setScreen(new GameScreen(tankWarsGame)); + } + }; + + defineEventListeners(); + setMainListeners(); } - public void setEventListeners() { + private void defineEventListeners() { /* - * Transitions back to MainMenuScreen + * Transition back to MainMenuScreen */ - backButton.addListener(new InputListener() { + backButtonInputListener = new InputListener() { @Override public boolean touchDown(InputEvent event, float x, float y, int pointer, int button) { tankWarsGame.setScreen(new MainMenuScreen(tankWarsGame)); return true; } - }); + }; /* - * Filters text field input: - * Max 4 characters long and only digits + * Remove keyboard and reset camera position when "Enter" button is pressed. + * Enable the joinLobbyButton when gamePinField contains at least one character. */ - gamePinField.setTextFieldFilter(new TextField.TextFieldFilter() { - @Override - public boolean acceptChar(TextField textField, char c) { - return textField.getText().length() < 4 && Character.isDigit(c); - } - }); - - /* - * Enables the joinLobbyButton when the gamePinField contains 4 digits, - * and disables it otherwise - */ - gamePinField.addListener(new InputListener() { + gamePinFieldInputListener = new InputListener() { @Override public boolean keyTyped(InputEvent event, char character) { super.keyTyped(event, character); @@ -77,50 +110,236 @@ public class FindGameController { stage.getViewport().apply(); } - joinLobbyButton.setDisabled(gamePinField.getText().length() != 4); - + joinLobbyButton.setDisabled(gamePinField.getText().length() == 0); return true; } - }); + }; /* * Move camera down when text field is clicked * to make the field appear above the keyboard. */ - gamePinField.addListener(new ClickListener() { + gamePinFieldClickListener = new ClickListener() { @Override public void clicked(InputEvent event, float x, float y) { super.clicked(event, x, y); stage.getViewport().setScreenY((int) (2 * stage.getHeight() / 3)); stage.getViewport().apply(); } - }); + }; /* - * Disables input listener when the button is disabled. - * TODO: Join a lobby by sending a request to the backend + * Send HTTP request to join lobby with game pin specified in text field. + * Avoid sending request if joinLobbyButton is disabled. */ - joinLobbyButton.addListener(new InputListener() { + joinLobbyButtonInputListener = new InputListener() { @Override public boolean touchDown(InputEvent event, float x, float y, int pointer, int button) { if (joinLobbyButton.isDisabled()) return true; - System.out.println("Game pin: " + gamePinField.getText() + " - yet to be implemented"); + joinLobby(); return true; } - }); + }; /* - * TODO: Create a lobby by sending request to backend - Transition to waiting screen? + * Create new lobby and display waiting window. + * Poll server for lobby status to know when to transition to game screen. */ - createLobbyButton.addListener(new InputListener() { + createLobbyButtonInputListener = new InputListener() { @Override public boolean touchDown(InputEvent event, float x, float y, int pointer, int button) { - ResourceManager.getInstance().clear(); - tankWarsGame.setScreen(new GameScreen(tankWarsGame)); + createLobby(); return true; } - }); + }; + + /* + * Exit from waiting window and render normal FindGameScreen + */ + cancelButtonInputListener = new InputListener() { + @Override + public boolean touchDown(InputEvent event, float x, float y, int pointer, int button) { + exitLobby(); + return true; + } + }; + } + + + public void setMainListeners() { + backButton.addListener(backButtonInputListener); + gamePinField.addListener(gamePinFieldInputListener); + gamePinField.addListener(gamePinFieldClickListener); + joinLobbyButton.addListener(joinLobbyButtonInputListener); + createLobbyButton.addListener(createLobbyButtonInputListener); + } + + public void removeMainListeners() { + backButton.removeListener(backButtonInputListener); + gamePinField.removeListener(gamePinFieldInputListener); + gamePinField.removeListener(gamePinFieldClickListener); + joinLobbyButton.removeListener(joinLobbyButtonInputListener); + createLobbyButton.removeListener(createLobbyButtonInputListener); + } + + public void setWaitingWindowListeners() { + cancelButton.addListener(cancelButtonInputListener); + } + + public void removeWaitingWindowListeners() { + cancelButton.removeListener(cancelButtonInputListener); } + + /** + * Send HTTP request to create a new lobby. + * On success, a waiting window is shown, and polling for + * the lobby status is initiated. + */ + private void createLobby() { + new HTTPRequestHandler(new Callback() { + @Override + public void onResult(String result) { + try { + LobbyId lobbyIdClass = new Json().fromJson(LobbyId.class, result); + lobbyId = lobbyIdClass.getLobbyId(); + + removeMainListeners(); + setWaitingWindowListeners(); + + gamePinWaitingLabel.setText("Game pin: " + lobbyId); + screen.showWaitingWindow(); + + checkLobbyStatus(); + } catch (SerializationException e) { + System.err.println("Invalid HTTP response on create lobby"); + } + } + + @Override + public void onFailed(Throwable t) { + System.err.println("Create lobby request failed:\n" + t); + } + }, new HttpRequestBuilder() + .newRequest() + .url(String.format("%s/lobby/create", ConfigReader.getProperty("backend.url"))) + .method(Net.HttpMethods.POST) + .header("Content-Type", "application/json") + .content(String.format("{\"userId\": \"%s\"}", CurrentUser.getCurrentUser().getUser().id)) + .build()) + .sendRequest(); + } + + /** + * Send HTTP request while waiting for lobby to fill, + * polling for the lobby's fill status with a set backoff period between requests. + * Transition to game screen when lobby is full. + */ + private void checkLobbyStatus() { + new HTTPRequestHandler(new Callback() { + @Override + public void onResult(String result) { + try { + LobbyStatus lobbyStatus = new Json().fromJson(LobbyStatus.class, result); + + if (lobbyStatus.isFull()) { + Gdx.app.postRunnable(gameScreenTransition); + } else if (lobbyId != null) { + System.out.println("Awaiting opponent..."); + + try { + Thread.sleep(1500); + if (lobbyId != null) checkLobbyStatus(); + } catch (InterruptedException e) { + System.out.println(e.getMessage()); + exitLobby(); + } + } + } catch (SerializationException e) { + System.err.println("Invalid HTTP response on check lobby status"); + exitLobby(); + } + } + + @Override + public void onFailed(Throwable t) { + System.err.println("Check lobby status request failed:\n" + t); + exitLobby(); + } + }, new HttpRequestBuilder() + .newRequest() + .url(String.format("%s/lobby/%s/status", + ConfigReader.getProperty("backend.url"), lobbyId)) + .method(Net.HttpMethods.GET) + .build()) + .sendRequest(); + } + + /** + * Send HTTP request to exit the joined lobby while the lobby + * is not yet full. Hide the waiting window. + */ + private void exitLobby() { + new HTTPRequestHandler(new Callback() { + @Override + public void onResult(String result) { + removeWaitingWindowListeners(); + setMainListeners(); + + lobbyId = null; + screen.hideWaitingWindow(); + } + + @Override + public void onFailed(Throwable t) { + System.err.println("Exit lobby request failed:\n" + t); + removeWaitingWindowListeners(); + setMainListeners(); + + lobbyId = null; + screen.hideWaitingWindow(); + } + }, new HttpRequestBuilder() + .newRequest() + .url(String.format("%s/lobby/%s/leave", + ConfigReader.getProperty("backend.url"), lobbyId)) + .method(Net.HttpMethods.POST) + .header("Content-Type", "application/json") + .content(String.format("{\"userId\": \"%s\"}", CurrentUser.getCurrentUser().getUser().id)) + .build()) + .sendRequest(); + } + + /** + * Send HTTP request to join a lobby using the game pin in the text field. + * On success, transition to game screen. + */ + private void joinLobby() { + new HTTPRequestHandler(new Callback() { + @Override + public void onResult(String result) { + try { + new Json().fromJson(LobbyId.class, result); + + Gdx.app.postRunnable(gameScreenTransition); + } catch (SerializationException e) { + System.out.println(result); + } + } + + @Override + public void onFailed(Throwable t) { + System.err.println("Join lobby request failed:\n" + t); + } + }, new HttpRequestBuilder() + .newRequest() + .url(String.format("%s/lobby/%s/join", + ConfigReader.getProperty("backend.url"), gamePinField.getText())) + .method(Net.HttpMethods.POST) + .header("Content-Type", "application/json") + .content(String.format("{\"userId\": \"%s\"}", CurrentUser.getCurrentUser().getUser().id)) + .build()) + .sendRequest(); + } } diff --git a/frontend/core/src/com/game/tankwars/model/LobbyId.java b/frontend/core/src/com/game/tankwars/model/LobbyId.java new file mode 100644 index 0000000000000000000000000000000000000000..a2cb3750e1a5fdfc4e40eebb724e0aa8324ae9a8 --- /dev/null +++ b/frontend/core/src/com/game/tankwars/model/LobbyId.java @@ -0,0 +1,14 @@ +package com.game.tankwars.model; + +public class LobbyId { + + private String lobbyId; + + public String getLobbyId() { + return lobbyId; + } + + public void setLobbyId(String lobbyId) { + this.lobbyId = lobbyId; + } +} diff --git a/frontend/core/src/com/game/tankwars/model/LobbyStatus.java b/frontend/core/src/com/game/tankwars/model/LobbyStatus.java new file mode 100644 index 0000000000000000000000000000000000000000..2c0bc324af9d7050e8ceb36727f0eb61b93bd010 --- /dev/null +++ b/frontend/core/src/com/game/tankwars/model/LobbyStatus.java @@ -0,0 +1,13 @@ +package com.game.tankwars.model; + +public class LobbyStatus { + private boolean isFull; + + public boolean isFull() { + return isFull; + } + + public void setFull(boolean full) { + isFull = full; + } +} diff --git a/frontend/core/src/com/game/tankwars/view/FindGameScreen.java b/frontend/core/src/com/game/tankwars/view/FindGameScreen.java index 3c4f4885e950c37fe2f3687069b30765da3052ce..bf5f2c5ba2174e5aa080463523d8ce01a9d6537e 100644 --- a/frontend/core/src/com/game/tankwars/view/FindGameScreen.java +++ b/frontend/core/src/com/game/tankwars/view/FindGameScreen.java @@ -28,6 +28,8 @@ public class FindGameScreen implements Screen { private final TankWarsGame tankWarsGame; private Stage stage; + private Group layoutGroup; + private Table windowTable; public FindGameScreen(final TankWarsGame tankWarsGame) { this.tankWarsGame = tankWarsGame; @@ -65,7 +67,7 @@ public class FindGameScreen implements Screen { float rw = stage.getWidth() - lw; Table rootTable = new Table(); - rootTable.setFillParent(true); + rootTable.setBounds(0, 0, stage.getWidth(), stage.getHeight()); Group leftGroup = new Group(); leftGroup.setSize(lw, stage.getHeight()); @@ -96,9 +98,42 @@ public class FindGameScreen implements Screen { rootTable.add(leftGroup).width(lw).height(stage.getHeight()); rootTable.add(rightTable).expandX().height(stage.getHeight()); - stage.addActor(rootTable); - new FindGameController(tankWarsGame, gamePinField, joinLobbyButton, createLobbyButton, backButton, stage); + //--- Awaiting opponent window + windowTable = new Table(); + float ww = 3 * stage.getWidth() / 5f; + float wh = 3 * stage.getHeight() / 5f; + + windowTable.setBounds(stage.getWidth() / 2f - ww / 2f, stage.getHeight() / 2f - wh / 2f, ww, wh); + + Drawable windowBackground = skin.getDrawable("semi-transparent-white-box"); + Label waitingLabel = new Label("Awaiting opponent...", skin.get("default", Label.LabelStyle.class)); + Label gamePinWaitingLabel = new Label("Game pin: ---", skin.get("default", Label.LabelStyle.class)); + TextButton cancelButton = new TextButton("Cancel", skin.get("default", TextButton.TextButtonStyle.class)); + + windowTable.background(windowBackground); + windowTable.row().expand(); + windowTable.add(waitingLabel); + windowTable.row().expand(); + windowTable.add(gamePinWaitingLabel); + windowTable.row().expand(); + windowTable.add(cancelButton).width(2 * ww / 3f).height(30); + + + layoutGroup = new Group(); + layoutGroup.addActor(rootTable); + + stage.addActor(layoutGroup); + + new FindGameController(tankWarsGame, this, gamePinField, + joinLobbyButton, createLobbyButton, + backButton, cancelButton, gamePinWaitingLabel, stage); + } + + public void showWaitingWindow() { layoutGroup.addActor(windowTable); } + + public void hideWaitingWindow() { + layoutGroup.removeActor(windowTable); } @Override