diff --git a/README.md b/README.md index df719e2..a196253 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,13 @@ The server creates game sessions identified by a unique 6-character code. - **JOIN <CODE>**: Joins an existing game. Server returns `JOIN_SUCCESS` or `ERROR`. - **MOVE <ROW> <COL>**: Places your symbol (0-2). Validated by server. - **SURRENDER**: Forfeits the game immediately. +- **LEAVE**: Leaves the current lobby. If empty, the lobby acts as closed. +- **RESTART**: Resets the board for the current lobby so players can play again. + +**Server Messages:** +- `WIN ` or `DRAW` +- `OPPONENT_LEFT`: Sent when the other player leaves via the LEAVE command. +- `RESTART`: Sent when the game is restarted. ## Troubleshooting diff --git a/client/src/main/java/com/lona/tictactoe/client/Main.java b/client/src/main/java/com/lona/tictactoe/client/Main.java index b04f795..d4caaf8 100644 --- a/client/src/main/java/com/lona/tictactoe/client/Main.java +++ b/client/src/main/java/com/lona/tictactoe/client/Main.java @@ -24,7 +24,7 @@ public class Main extends JFrame { private char mySymbol = ' '; // assigned by server implied turn public static void main(String[] args) { - String host = "dokploy.lona-development.org"; + String host = "38.242.130.81"; int port = 1870; if (args.length > 0) { @@ -126,9 +126,26 @@ public class Main extends JFrame { } gamePanel.add(boardPanel, BorderLayout.CENTER); - JButton surrenderBtn = new JButton("Surrender / Give Up"); + // --- BUTTONS PANEL --- + JPanel buttonPanel = new JPanel(new GridLayout(1, 2, 10, 0)); + + JButton leaveBtn = new JButton("Leave Lobby"); + leaveBtn.setBackground(Color.GRAY); + leaveBtn.setForeground(Color.WHITE); + leaveBtn.addActionListener(e -> { + send("LEAVE"); + resetGame(); + }); + + JButton surrenderBtn = new JButton("Surrender"); + surrenderBtn.setBackground(new Color(220, 53, 69)); // Bootstrap Danger Red + surrenderBtn.setForeground(Color.WHITE); surrenderBtn.addActionListener(e -> send("SURRENDER")); - gamePanel.add(surrenderBtn, BorderLayout.SOUTH); + + buttonPanel.add(leaveBtn); + buttonPanel.add(surrenderBtn); + + gamePanel.add(buttonPanel, BorderLayout.SOUTH); mainPanel.add(menuPanel, "MENU"); mainPanel.add(gamePanel, "GAME"); @@ -155,6 +172,7 @@ public class Main extends JFrame { SwingUtilities.invokeLater(() -> { statusLabel.setText("Connection Failed."); JOptionPane.showMessageDialog(this, "Connection Error: " + e.getMessage()); + // Don't reset to menu if we can't connect, let user see error }); } }).start(); @@ -191,6 +209,12 @@ public class Main extends JFrame { case "START": statusLabel.setText("Game Started! X goes first."); break; + case "RESTART": + // clear board + for (JButton btn : buttons) + btn.setText(""); + statusLabel.setText("Game Restarted! X goes first."); + break; case "BOARD": // BOARD X O X ... String boardStr = msg.substring(6); // remove "BOARD " for (int i = 0; i < 9 && i < boardStr.length(); i++) { @@ -210,11 +234,29 @@ public class Main extends JFrame { break; case "WIN": String winner = msg.substring(4); - JOptionPane.showMessageDialog(this, "Winner: " + winner); - resetGame(); + statusLabel.setText("Winner: " + winner); + int choice = JOptionPane.showConfirmDialog(this, + "Winner: " + winner + "\nPlay Again?", + "Game Over", + JOptionPane.YES_NO_OPTION); + + if (choice == JOptionPane.YES_OPTION) { + send("RESTART"); + } break; case "DRAW": - JOptionPane.showMessageDialog(this, "Draw!"); + statusLabel.setText("Draw!"); + int drawChoice = JOptionPane.showConfirmDialog(this, + "Draw!\nPlay Again?", + "Game Over", + JOptionPane.YES_NO_OPTION); + + if (drawChoice == JOptionPane.YES_OPTION) { + send("RESTART"); + } + break; + case "OPPONENT_LEFT": + JOptionPane.showMessageDialog(this, "Opponent left the game."); resetGame(); break; case "ERROR:": diff --git a/client/target/classes/com/lona/tictactoe/client/Main.class b/client/target/classes/com/lona/tictactoe/client/Main.class index 3768a7d..79dff58 100644 Binary files a/client/target/classes/com/lona/tictactoe/client/Main.class and b/client/target/classes/com/lona/tictactoe/client/Main.class differ diff --git a/client/target/client-1.0-SNAPSHOT.jar b/client/target/client-1.0-SNAPSHOT.jar index ea3dd9b..b689861 100644 Binary files a/client/target/client-1.0-SNAPSHOT.jar and b/client/target/client-1.0-SNAPSHOT.jar differ diff --git a/client/target/maven-archiver/pom.properties b/client/target/maven-archiver/pom.properties index 4c4f104..f84148e 100644 --- a/client/target/maven-archiver/pom.properties +++ b/client/target/maven-archiver/pom.properties @@ -1,5 +1,5 @@ #Generated by Maven -#Tue Feb 10 13:27:55 CET 2026 +#Tue Feb 10 14:07:26 CET 2026 artifactId=client groupId=com.lona.tictactoe version=1.0-SNAPSHOT diff --git a/client/target/original-client-1.0-SNAPSHOT.jar b/client/target/original-client-1.0-SNAPSHOT.jar index d5b25a0..57ceb93 100644 Binary files a/client/target/original-client-1.0-SNAPSHOT.jar and b/client/target/original-client-1.0-SNAPSHOT.jar differ diff --git a/server/src/main/java/com/lona/tictactoe/server/ClientHandler.java b/server/src/main/java/com/lona/tictactoe/server/ClientHandler.java index 5337ffe..5e87886 100644 --- a/server/src/main/java/com/lona/tictactoe/server/ClientHandler.java +++ b/server/src/main/java/com/lona/tictactoe/server/ClientHandler.java @@ -85,6 +85,27 @@ public class ClientHandler implements Runnable { } break; + case "RESTART": + if (gameCode != null) { + GameInstance active = GameManager.getGame(gameCode); + if (active != null) + active.restart(); + } + break; + + case "LEAVE": + if (gameCode != null) { + GameInstance active = GameManager.getGame(gameCode); + if (active != null) { + active.leave(this); + if (active.isEmpty()) { + GameManager.removeGame(gameCode); + } + } + this.gameCode = null; + } + break; + default: out.println("ERROR: Unknown command"); } @@ -94,9 +115,12 @@ public class ClientHandler implements Runnable { } finally { if (gameCode != null) { GameInstance g = GameManager.getGame(gameCode); - if (g != null) + if (g != null) { g.disconnect(this); - GameManager.removeGame(gameCode); + if (g.isEmpty()) { + GameManager.removeGame(gameCode); + } + } } try { client.close(); diff --git a/server/src/main/java/com/lona/tictactoe/server/GameInstance.java b/server/src/main/java/com/lona/tictactoe/server/GameInstance.java index 1a8f1ff..c5b52b7 100644 --- a/server/src/main/java/com/lona/tictactoe/server/GameInstance.java +++ b/server/src/main/java/com/lona/tictactoe/server/GameInstance.java @@ -35,36 +35,30 @@ public class GameInstance { char symbol = (player == playerX) ? 'X' : 'O'; // --- ANTICHEAT / VALIDATION --- - // 1. Check if correct turn if (symbol != currentTurn) { player.sendMessage("ERROR: It is not your turn!"); return; } - // 2. Check bounds if (x < 0 || x > 2 || y < 0 || y > 2) { player.sendMessage("ERROR: Invalid coordinates!"); return; } - // 3. Check if cell is empty if (board[x][y] != ' ') { player.sendMessage("ERROR: Cell occupied!"); return; } - // Apply move board[x][y] = symbol; sendBoard(); if (checkWin(symbol)) { finished = true; broadcast("WIN " + symbol); - createEndState(); } else if (checkDraw()) { finished = true; broadcast("DRAW"); - createEndState(); } else { currentTurn = (currentTurn == 'X') ? 'O' : 'X'; broadcast("TURN " + currentTurn); @@ -78,19 +72,42 @@ public class GameInstance { char loser = (player == playerX) ? 'X' : 'O'; char winner = (loser == 'X') ? 'O' : 'X'; broadcast("WIN " + winner + " (Surrender)"); - createEndState(); + } + + public synchronized void restart() { + for (char[] row : board) + Arrays.fill(row, ' '); + currentTurn = 'X'; + finished = false; + broadcast("RESTART"); + sendBoard(); + broadcast("TURN " + currentTurn); + } + + public synchronized void leave(ClientHandler player) { + if (player == playerX) { + // Creator left, if playerO exists, tell them? + if (playerO != null) { + playerO.sendMessage("OPPONENT_LEFT"); + } + playerX = null; + } else if (player == playerO) { + if (playerX != null) { + playerX.sendMessage("OPPONENT_LEFT"); + } + playerO = null; + } + // If empty, GameManager will eventually remove if we allow it, + // but for now let's rely on GameManager to check if empty logic or just keep it + // simple. + } + + public synchronized boolean isEmpty() { + return playerX == null && playerO == null; } public synchronized void disconnect(ClientHandler player) { - if (finished) - return; - finished = true; - broadcast("ERROR: Opponent disconnected"); - createEndState(); - } - - private void createEndState() { - // cleanup if needed + leave(player); } private boolean checkWin(char s) { diff --git a/server/target/classes/com/lona/tictactoe/server/ClientHandler.class b/server/target/classes/com/lona/tictactoe/server/ClientHandler.class index f59a9d5..66d24fb 100644 Binary files a/server/target/classes/com/lona/tictactoe/server/ClientHandler.class and b/server/target/classes/com/lona/tictactoe/server/ClientHandler.class differ diff --git a/server/target/classes/com/lona/tictactoe/server/GameInstance.class b/server/target/classes/com/lona/tictactoe/server/GameInstance.class index bb7ffcc..90d6e32 100644 Binary files a/server/target/classes/com/lona/tictactoe/server/GameInstance.class and b/server/target/classes/com/lona/tictactoe/server/GameInstance.class differ diff --git a/server/target/maven-archiver/pom.properties b/server/target/maven-archiver/pom.properties index f92dd76..2bfbc85 100644 --- a/server/target/maven-archiver/pom.properties +++ b/server/target/maven-archiver/pom.properties @@ -1,5 +1,5 @@ #Generated by Maven -#Tue Feb 10 13:26:46 CET 2026 +#Tue Feb 10 14:07:14 CET 2026 artifactId=server groupId=com.lona.tictactoe version=1.0-SNAPSHOT diff --git a/server/target/original-server-1.0-SNAPSHOT.jar b/server/target/original-server-1.0-SNAPSHOT.jar index 8d1fd2e..6cfe39a 100644 Binary files a/server/target/original-server-1.0-SNAPSHOT.jar and b/server/target/original-server-1.0-SNAPSHOT.jar differ diff --git a/server/target/server-1.0-SNAPSHOT.jar b/server/target/server-1.0-SNAPSHOT.jar index 7deb4df..ba14234 100644 Binary files a/server/target/server-1.0-SNAPSHOT.jar and b/server/target/server-1.0-SNAPSHOT.jar differ diff --git a/web-client/src/main/resources/static/css/style.css b/web-client/src/main/resources/static/css/style.css index 1b04c69..1ab084c 100644 --- a/web-client/src/main/resources/static/css/style.css +++ b/web-client/src/main/resources/static/css/style.css @@ -84,14 +84,18 @@ h1 { } .secondary { - background: var(--card-bg); /* Use card background for join button */ - color: white; /* Ensure text is visible */ - border: 1px solid var(--border); /* Add a border to distinguish it */ + background: var(--card-bg); + /* Use card background for join button */ + color: white; + /* Ensure text is visible */ + border: 1px solid var(--border); + /* Add a border to distinguish it */ } /* Add hover effect for secondary button too */ .secondary:hover { - background: #27272a; /* Slightly lighter on hover */ + background: #27272a; + /* Slightly lighter on hover */ } .danger { @@ -101,8 +105,9 @@ h1 { } input[type="text"] { - width: 100%; - box-sizing: border-box; /* This ensures padding is included in width */ + width: 100%; + box-sizing: border-box; + /* This ensures padding is included in width */ padding: 12px; margin: 10px 0; background: #27272a; @@ -180,12 +185,20 @@ input[type="text"]:focus { background: #3f3f46; } +.game-actions { + display: flex; + gap: 10px; + margin-top: 20px; +} + .cell.x { - color: var(--secondary); /* Pink for X */ + color: var(--secondary); + /* Pink for X */ text-shadow: 0 0 10px rgba(219, 39, 119, 0.6); } .cell.o { - color: var(--accent); /* Green for O */ + color: var(--accent); + /* Green for O */ text-shadow: 0 0 10px rgba(16, 185, 129, 0.6); -} +} \ No newline at end of file diff --git a/web-client/src/main/resources/static/js/app.js b/web-client/src/main/resources/static/js/app.js index e6c6033..b3cd91f 100644 --- a/web-client/src/main/resources/static/js/app.js +++ b/web-client/src/main/resources/static/js/app.js @@ -6,46 +6,17 @@ const joinBtn = document.getElementById('join-btn'); const joinInput = document.getElementById('join-code'); const displayCode = document.getElementById('display-code'); const turnStatus = document.getElementById('turn-status'); -const surrenderBtn = document.getElementById('surrender-btn'); -const cells = document.querySelectorAll('.cell'); +const leaveBtn = document.getElementById('leave-btn'); -let mySymbol = ''; -let ws = null; -let currentCode = ''; +// ... (other vars) -function connect() { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - ws = new WebSocket(`${protocol}//${window.location.host}/game`); +// ... - ws.onopen = () => { - statusDiv.textContent = 'Connected to Server'; - statusDiv.style.color = '#10b981'; - }; - - ws.onclose = () => { - statusDiv.textContent = 'Disconnected. Reconnecting...'; - statusDiv.style.color = '#ef4444'; - setTimeout(connect, 2000); - }; - - ws.onmessage = (event) => { - handleMessage(event.data); - }; -} - -function send(msg) { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(msg); +leaveBtn.addEventListener('click', () => { + if (confirm('Are you sure you want to leave the lobby?')) { + send('LEAVE'); + resetGame(); } -} - -createBtn.addEventListener('click', () => { - send('CREATE'); -}); - -joinBtn.addEventListener('click', () => { - const code = joinInput.value.trim(); - if (code) send(`JOIN ${code}`); }); surrenderBtn.addEventListener('click', () => { @@ -54,13 +25,7 @@ surrenderBtn.addEventListener('click', () => { } }); -cells.forEach(cell => { - cell.addEventListener('click', () => { - const r = cell.dataset.r; - const c = cell.dataset.c; - send(`MOVE ${r} ${c}`); - }); -}); +// ... function handleMessage(msg) { console.log("Received:", msg); @@ -83,6 +48,13 @@ function handleMessage(msg) { case 'START': updateStatus("Game Started! X goes first."); break; + case 'RESTART': + updateStatus("Game Restarted! X goes first."); + cells.forEach(c => { + c.textContent = ''; + c.className = 'cell'; + }); + break; case 'BOARD': const boardStr = msg.substring(6); updateBoard(boardStr); @@ -97,11 +69,21 @@ function handleMessage(msg) { break; case 'WIN': const winner = msg.substring(4); - alert(`Game Over! Winner: ${winner}`); - resetGame(); + setTimeout(() => { + if (confirm(`Game Over! Winner: ${winner}\nDo you want to play again?`)) { + send('RESTART'); + } + }, 100); break; case 'DRAW': - alert("Game Over! It's a draw!"); + setTimeout(() => { + if (confirm("Game Over! It's a draw!\nDo you want to play again?")) { + send('RESTART'); + } + }, 100); + break; + case 'OPPONENT_LEFT': + alert("Opponent has left the lobby."); resetGame(); break; case 'ERROR:': diff --git a/web-client/src/main/resources/templates/index.html b/web-client/src/main/resources/templates/index.html index d29f35e..89a0d3d 100644 --- a/web-client/src/main/resources/templates/index.html +++ b/web-client/src/main/resources/templates/index.html @@ -1,16 +1,19 @@ + TicTacToe - + +

TicTacToe

- +
Connecting to server...
@@ -26,7 +29,6 @@
- - + + \ No newline at end of file diff --git a/web-client/target/classes/static/css/style.css b/web-client/target/classes/static/css/style.css index 1b04c69..1ab084c 100644 --- a/web-client/target/classes/static/css/style.css +++ b/web-client/target/classes/static/css/style.css @@ -84,14 +84,18 @@ h1 { } .secondary { - background: var(--card-bg); /* Use card background for join button */ - color: white; /* Ensure text is visible */ - border: 1px solid var(--border); /* Add a border to distinguish it */ + background: var(--card-bg); + /* Use card background for join button */ + color: white; + /* Ensure text is visible */ + border: 1px solid var(--border); + /* Add a border to distinguish it */ } /* Add hover effect for secondary button too */ .secondary:hover { - background: #27272a; /* Slightly lighter on hover */ + background: #27272a; + /* Slightly lighter on hover */ } .danger { @@ -101,8 +105,9 @@ h1 { } input[type="text"] { - width: 100%; - box-sizing: border-box; /* This ensures padding is included in width */ + width: 100%; + box-sizing: border-box; + /* This ensures padding is included in width */ padding: 12px; margin: 10px 0; background: #27272a; @@ -180,12 +185,20 @@ input[type="text"]:focus { background: #3f3f46; } +.game-actions { + display: flex; + gap: 10px; + margin-top: 20px; +} + .cell.x { - color: var(--secondary); /* Pink for X */ + color: var(--secondary); + /* Pink for X */ text-shadow: 0 0 10px rgba(219, 39, 119, 0.6); } .cell.o { - color: var(--accent); /* Green for O */ + color: var(--accent); + /* Green for O */ text-shadow: 0 0 10px rgba(16, 185, 129, 0.6); -} +} \ No newline at end of file diff --git a/web-client/target/classes/static/js/app.js b/web-client/target/classes/static/js/app.js index e6c6033..b3cd91f 100644 --- a/web-client/target/classes/static/js/app.js +++ b/web-client/target/classes/static/js/app.js @@ -6,46 +6,17 @@ const joinBtn = document.getElementById('join-btn'); const joinInput = document.getElementById('join-code'); const displayCode = document.getElementById('display-code'); const turnStatus = document.getElementById('turn-status'); -const surrenderBtn = document.getElementById('surrender-btn'); -const cells = document.querySelectorAll('.cell'); +const leaveBtn = document.getElementById('leave-btn'); -let mySymbol = ''; -let ws = null; -let currentCode = ''; +// ... (other vars) -function connect() { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - ws = new WebSocket(`${protocol}//${window.location.host}/game`); +// ... - ws.onopen = () => { - statusDiv.textContent = 'Connected to Server'; - statusDiv.style.color = '#10b981'; - }; - - ws.onclose = () => { - statusDiv.textContent = 'Disconnected. Reconnecting...'; - statusDiv.style.color = '#ef4444'; - setTimeout(connect, 2000); - }; - - ws.onmessage = (event) => { - handleMessage(event.data); - }; -} - -function send(msg) { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(msg); +leaveBtn.addEventListener('click', () => { + if (confirm('Are you sure you want to leave the lobby?')) { + send('LEAVE'); + resetGame(); } -} - -createBtn.addEventListener('click', () => { - send('CREATE'); -}); - -joinBtn.addEventListener('click', () => { - const code = joinInput.value.trim(); - if (code) send(`JOIN ${code}`); }); surrenderBtn.addEventListener('click', () => { @@ -54,13 +25,7 @@ surrenderBtn.addEventListener('click', () => { } }); -cells.forEach(cell => { - cell.addEventListener('click', () => { - const r = cell.dataset.r; - const c = cell.dataset.c; - send(`MOVE ${r} ${c}`); - }); -}); +// ... function handleMessage(msg) { console.log("Received:", msg); @@ -83,6 +48,13 @@ function handleMessage(msg) { case 'START': updateStatus("Game Started! X goes first."); break; + case 'RESTART': + updateStatus("Game Restarted! X goes first."); + cells.forEach(c => { + c.textContent = ''; + c.className = 'cell'; + }); + break; case 'BOARD': const boardStr = msg.substring(6); updateBoard(boardStr); @@ -97,11 +69,21 @@ function handleMessage(msg) { break; case 'WIN': const winner = msg.substring(4); - alert(`Game Over! Winner: ${winner}`); - resetGame(); + setTimeout(() => { + if (confirm(`Game Over! Winner: ${winner}\nDo you want to play again?`)) { + send('RESTART'); + } + }, 100); break; case 'DRAW': - alert("Game Over! It's a draw!"); + setTimeout(() => { + if (confirm("Game Over! It's a draw!\nDo you want to play again?")) { + send('RESTART'); + } + }, 100); + break; + case 'OPPONENT_LEFT': + alert("Opponent has left the lobby."); resetGame(); break; case 'ERROR:': diff --git a/web-client/target/classes/templates/index.html b/web-client/target/classes/templates/index.html index d29f35e..89a0d3d 100644 --- a/web-client/target/classes/templates/index.html +++ b/web-client/target/classes/templates/index.html @@ -1,16 +1,19 @@ + TicTacToe - + +

TicTacToe

- +
Connecting to server...
@@ -26,7 +29,6 @@
- - + + \ No newline at end of file diff --git a/web-client/target/web-client-1.0-SNAPSHOT.jar b/web-client/target/web-client-1.0-SNAPSHOT.jar index 3e284e4..3890a40 100644 Binary files a/web-client/target/web-client-1.0-SNAPSHOT.jar and b/web-client/target/web-client-1.0-SNAPSHOT.jar differ diff --git a/web-client/target/web-client-1.0-SNAPSHOT.jar.original b/web-client/target/web-client-1.0-SNAPSHOT.jar.original index c5bd5a8..9cff0d9 100644 Binary files a/web-client/target/web-client-1.0-SNAPSHOT.jar.original and b/web-client/target/web-client-1.0-SNAPSHOT.jar.original differ