This commit is contained in:
Hymmel 2026-02-10 14:08:30 +01:00
parent 8dc716b4bf
commit 0937fc35de
21 changed files with 238 additions and 146 deletions

View file

@ -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`. - **JOIN <CODE>**: Joins an existing game. Server returns `JOIN_SUCCESS` or `ERROR`.
- **MOVE <ROW> <COL>**: Places your symbol (0-2). Validated by server. - **MOVE <ROW> <COL>**: Places your symbol (0-2). Validated by server.
- **SURRENDER**: Forfeits the game immediately. - **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 <SYMBOL>` or `DRAW`
- `OPPONENT_LEFT`: Sent when the other player leaves via the LEAVE command.
- `RESTART`: Sent when the game is restarted.
## Troubleshooting ## Troubleshooting

View file

@ -24,7 +24,7 @@ public class Main extends JFrame {
private char mySymbol = ' '; // assigned by server implied turn private char mySymbol = ' '; // assigned by server implied turn
public static void main(String[] args) { public static void main(String[] args) {
String host = "dokploy.lona-development.org"; String host = "38.242.130.81";
int port = 1870; int port = 1870;
if (args.length > 0) { if (args.length > 0) {
@ -126,9 +126,26 @@ public class Main extends JFrame {
} }
gamePanel.add(boardPanel, BorderLayout.CENTER); 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")); 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(menuPanel, "MENU");
mainPanel.add(gamePanel, "GAME"); mainPanel.add(gamePanel, "GAME");
@ -155,6 +172,7 @@ public class Main extends JFrame {
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
statusLabel.setText("Connection Failed."); statusLabel.setText("Connection Failed.");
JOptionPane.showMessageDialog(this, "Connection Error: " + e.getMessage()); JOptionPane.showMessageDialog(this, "Connection Error: " + e.getMessage());
// Don't reset to menu if we can't connect, let user see error
}); });
} }
}).start(); }).start();
@ -191,6 +209,12 @@ public class Main extends JFrame {
case "START": case "START":
statusLabel.setText("Game Started! X goes first."); statusLabel.setText("Game Started! X goes first.");
break; 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 ... case "BOARD": // BOARD X O X ...
String boardStr = msg.substring(6); // remove "BOARD " String boardStr = msg.substring(6); // remove "BOARD "
for (int i = 0; i < 9 && i < boardStr.length(); i++) { for (int i = 0; i < 9 && i < boardStr.length(); i++) {
@ -210,11 +234,29 @@ public class Main extends JFrame {
break; break;
case "WIN": case "WIN":
String winner = msg.substring(4); String winner = msg.substring(4);
JOptionPane.showMessageDialog(this, "Winner: " + winner); statusLabel.setText("Winner: " + winner);
resetGame(); int choice = JOptionPane.showConfirmDialog(this,
"Winner: " + winner + "\nPlay Again?",
"Game Over",
JOptionPane.YES_NO_OPTION);
if (choice == JOptionPane.YES_OPTION) {
send("RESTART");
}
break; break;
case "DRAW": 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(); resetGame();
break; break;
case "ERROR:": case "ERROR:":

View file

@ -1,5 +1,5 @@
#Generated by Maven #Generated by Maven
#Tue Feb 10 13:27:55 CET 2026 #Tue Feb 10 14:07:26 CET 2026
artifactId=client artifactId=client
groupId=com.lona.tictactoe groupId=com.lona.tictactoe
version=1.0-SNAPSHOT version=1.0-SNAPSHOT

View file

@ -85,6 +85,27 @@ public class ClientHandler implements Runnable {
} }
break; 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: default:
out.println("ERROR: Unknown command"); out.println("ERROR: Unknown command");
} }
@ -94,10 +115,13 @@ public class ClientHandler implements Runnable {
} finally { } finally {
if (gameCode != null) { if (gameCode != null) {
GameInstance g = GameManager.getGame(gameCode); GameInstance g = GameManager.getGame(gameCode);
if (g != null) if (g != null) {
g.disconnect(this); g.disconnect(this);
if (g.isEmpty()) {
GameManager.removeGame(gameCode); GameManager.removeGame(gameCode);
} }
}
}
try { try {
client.close(); client.close();
} catch (IOException e) { } catch (IOException e) {

View file

@ -35,36 +35,30 @@ public class GameInstance {
char symbol = (player == playerX) ? 'X' : 'O'; char symbol = (player == playerX) ? 'X' : 'O';
// --- ANTICHEAT / VALIDATION --- // --- ANTICHEAT / VALIDATION ---
// 1. Check if correct turn
if (symbol != currentTurn) { if (symbol != currentTurn) {
player.sendMessage("ERROR: It is not your turn!"); player.sendMessage("ERROR: It is not your turn!");
return; return;
} }
// 2. Check bounds
if (x < 0 || x > 2 || y < 0 || y > 2) { if (x < 0 || x > 2 || y < 0 || y > 2) {
player.sendMessage("ERROR: Invalid coordinates!"); player.sendMessage("ERROR: Invalid coordinates!");
return; return;
} }
// 3. Check if cell is empty
if (board[x][y] != ' ') { if (board[x][y] != ' ') {
player.sendMessage("ERROR: Cell occupied!"); player.sendMessage("ERROR: Cell occupied!");
return; return;
} }
// Apply move
board[x][y] = symbol; board[x][y] = symbol;
sendBoard(); sendBoard();
if (checkWin(symbol)) { if (checkWin(symbol)) {
finished = true; finished = true;
broadcast("WIN " + symbol); broadcast("WIN " + symbol);
createEndState();
} else if (checkDraw()) { } else if (checkDraw()) {
finished = true; finished = true;
broadcast("DRAW"); broadcast("DRAW");
createEndState();
} else { } else {
currentTurn = (currentTurn == 'X') ? 'O' : 'X'; currentTurn = (currentTurn == 'X') ? 'O' : 'X';
broadcast("TURN " + currentTurn); broadcast("TURN " + currentTurn);
@ -78,19 +72,42 @@ public class GameInstance {
char loser = (player == playerX) ? 'X' : 'O'; char loser = (player == playerX) ? 'X' : 'O';
char winner = (loser == 'X') ? 'O' : 'X'; char winner = (loser == 'X') ? 'O' : 'X';
broadcast("WIN " + winner + " (Surrender)"); 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) { public synchronized void disconnect(ClientHandler player) {
if (finished) leave(player);
return;
finished = true;
broadcast("ERROR: Opponent disconnected");
createEndState();
}
private void createEndState() {
// cleanup if needed
} }
private boolean checkWin(char s) { private boolean checkWin(char s) {

View file

@ -1,5 +1,5 @@
#Generated by Maven #Generated by Maven
#Tue Feb 10 13:26:46 CET 2026 #Tue Feb 10 14:07:14 CET 2026
artifactId=server artifactId=server
groupId=com.lona.tictactoe groupId=com.lona.tictactoe
version=1.0-SNAPSHOT version=1.0-SNAPSHOT

View file

@ -84,14 +84,18 @@ h1 {
} }
.secondary { .secondary {
background: var(--card-bg); /* Use card background for join button */ background: var(--card-bg);
color: white; /* Ensure text is visible */ /* Use card background for join button */
border: 1px solid var(--border); /* Add a border to distinguish it */ color: white;
/* Ensure text is visible */
border: 1px solid var(--border);
/* Add a border to distinguish it */
} }
/* Add hover effect for secondary button too */ /* Add hover effect for secondary button too */
.secondary:hover { .secondary:hover {
background: #27272a; /* Slightly lighter on hover */ background: #27272a;
/* Slightly lighter on hover */
} }
.danger { .danger {
@ -102,7 +106,8 @@ h1 {
input[type="text"] { input[type="text"] {
width: 100%; width: 100%;
box-sizing: border-box; /* This ensures padding is included in width */ box-sizing: border-box;
/* This ensures padding is included in width */
padding: 12px; padding: 12px;
margin: 10px 0; margin: 10px 0;
background: #27272a; background: #27272a;
@ -180,12 +185,20 @@ input[type="text"]:focus {
background: #3f3f46; background: #3f3f46;
} }
.game-actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
.cell.x { .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); text-shadow: 0 0 10px rgba(219, 39, 119, 0.6);
} }
.cell.o { .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); text-shadow: 0 0 10px rgba(16, 185, 129, 0.6);
} }

View file

@ -6,46 +6,17 @@ const joinBtn = document.getElementById('join-btn');
const joinInput = document.getElementById('join-code'); const joinInput = document.getElementById('join-code');
const displayCode = document.getElementById('display-code'); const displayCode = document.getElementById('display-code');
const turnStatus = document.getElementById('turn-status'); const turnStatus = document.getElementById('turn-status');
const surrenderBtn = document.getElementById('surrender-btn'); const leaveBtn = document.getElementById('leave-btn');
const cells = document.querySelectorAll('.cell');
let mySymbol = ''; // ... (other vars)
let ws = null;
let currentCode = '';
function connect() { // ...
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}/game`);
ws.onopen = () => { leaveBtn.addEventListener('click', () => {
statusDiv.textContent = 'Connected to Server'; if (confirm('Are you sure you want to leave the lobby?')) {
statusDiv.style.color = '#10b981'; send('LEAVE');
}; resetGame();
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);
}
}
createBtn.addEventListener('click', () => {
send('CREATE');
});
joinBtn.addEventListener('click', () => {
const code = joinInput.value.trim();
if (code) send(`JOIN ${code}`);
}); });
surrenderBtn.addEventListener('click', () => { 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) { function handleMessage(msg) {
console.log("Received:", msg); console.log("Received:", msg);
@ -83,6 +48,13 @@ function handleMessage(msg) {
case 'START': case 'START':
updateStatus("Game Started! X goes first."); updateStatus("Game Started! X goes first.");
break; break;
case 'RESTART':
updateStatus("Game Restarted! X goes first.");
cells.forEach(c => {
c.textContent = '';
c.className = 'cell';
});
break;
case 'BOARD': case 'BOARD':
const boardStr = msg.substring(6); const boardStr = msg.substring(6);
updateBoard(boardStr); updateBoard(boardStr);
@ -97,11 +69,21 @@ function handleMessage(msg) {
break; break;
case 'WIN': case 'WIN':
const winner = msg.substring(4); const winner = msg.substring(4);
alert(`Game Over! Winner: ${winner}`); setTimeout(() => {
resetGame(); if (confirm(`Game Over! Winner: ${winner}\nDo you want to play again?`)) {
send('RESTART');
}
}, 100);
break; break;
case 'DRAW': 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(); resetGame();
break; break;
case 'ERROR:': case 'ERROR:':

View file

@ -1,12 +1,15 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TicTacToe</title> <title>TicTacToe</title>
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=Roboto:wght@300;400&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=Roboto:wght@300;400&display=swap"
rel="stylesheet">
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<h1>TicTacToe</h1> <h1>TicTacToe</h1>
@ -26,7 +29,6 @@
</div> </div>
</div> </div>
<!-- Game Section -->
<div id="game" class="panel hidden"> <div id="game" class="panel hidden">
<div class="game-info"> <div class="game-info">
<div class="info-item"> <div class="info-item">
@ -52,10 +54,14 @@
<div class="cell" data-r="2" data-c="2"></div> <div class="cell" data-r="2" data-c="2"></div>
</div> </div>
<button id="surrender-btn" class="btn danger">Surrender / Give Up</button> <div class="game-actions">
<button id="leave-btn" class="btn secondary">Leave Lobby</button>
<button id="surrender-btn" class="btn danger">Surrender</button>
</div>
</div> </div>
</div> </div>
<script src="/js/app.js"></script> <script src="/js/app.js"></script>
</body> </body>
</html> </html>

View file

@ -84,14 +84,18 @@ h1 {
} }
.secondary { .secondary {
background: var(--card-bg); /* Use card background for join button */ background: var(--card-bg);
color: white; /* Ensure text is visible */ /* Use card background for join button */
border: 1px solid var(--border); /* Add a border to distinguish it */ color: white;
/* Ensure text is visible */
border: 1px solid var(--border);
/* Add a border to distinguish it */
} }
/* Add hover effect for secondary button too */ /* Add hover effect for secondary button too */
.secondary:hover { .secondary:hover {
background: #27272a; /* Slightly lighter on hover */ background: #27272a;
/* Slightly lighter on hover */
} }
.danger { .danger {
@ -102,7 +106,8 @@ h1 {
input[type="text"] { input[type="text"] {
width: 100%; width: 100%;
box-sizing: border-box; /* This ensures padding is included in width */ box-sizing: border-box;
/* This ensures padding is included in width */
padding: 12px; padding: 12px;
margin: 10px 0; margin: 10px 0;
background: #27272a; background: #27272a;
@ -180,12 +185,20 @@ input[type="text"]:focus {
background: #3f3f46; background: #3f3f46;
} }
.game-actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
.cell.x { .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); text-shadow: 0 0 10px rgba(219, 39, 119, 0.6);
} }
.cell.o { .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); text-shadow: 0 0 10px rgba(16, 185, 129, 0.6);
} }

View file

@ -6,46 +6,17 @@ const joinBtn = document.getElementById('join-btn');
const joinInput = document.getElementById('join-code'); const joinInput = document.getElementById('join-code');
const displayCode = document.getElementById('display-code'); const displayCode = document.getElementById('display-code');
const turnStatus = document.getElementById('turn-status'); const turnStatus = document.getElementById('turn-status');
const surrenderBtn = document.getElementById('surrender-btn'); const leaveBtn = document.getElementById('leave-btn');
const cells = document.querySelectorAll('.cell');
let mySymbol = ''; // ... (other vars)
let ws = null;
let currentCode = '';
function connect() { // ...
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}/game`);
ws.onopen = () => { leaveBtn.addEventListener('click', () => {
statusDiv.textContent = 'Connected to Server'; if (confirm('Are you sure you want to leave the lobby?')) {
statusDiv.style.color = '#10b981'; send('LEAVE');
}; resetGame();
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);
}
}
createBtn.addEventListener('click', () => {
send('CREATE');
});
joinBtn.addEventListener('click', () => {
const code = joinInput.value.trim();
if (code) send(`JOIN ${code}`);
}); });
surrenderBtn.addEventListener('click', () => { 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) { function handleMessage(msg) {
console.log("Received:", msg); console.log("Received:", msg);
@ -83,6 +48,13 @@ function handleMessage(msg) {
case 'START': case 'START':
updateStatus("Game Started! X goes first."); updateStatus("Game Started! X goes first.");
break; break;
case 'RESTART':
updateStatus("Game Restarted! X goes first.");
cells.forEach(c => {
c.textContent = '';
c.className = 'cell';
});
break;
case 'BOARD': case 'BOARD':
const boardStr = msg.substring(6); const boardStr = msg.substring(6);
updateBoard(boardStr); updateBoard(boardStr);
@ -97,11 +69,21 @@ function handleMessage(msg) {
break; break;
case 'WIN': case 'WIN':
const winner = msg.substring(4); const winner = msg.substring(4);
alert(`Game Over! Winner: ${winner}`); setTimeout(() => {
resetGame(); if (confirm(`Game Over! Winner: ${winner}\nDo you want to play again?`)) {
send('RESTART');
}
}, 100);
break; break;
case 'DRAW': 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(); resetGame();
break; break;
case 'ERROR:': case 'ERROR:':

View file

@ -1,12 +1,15 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TicTacToe</title> <title>TicTacToe</title>
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=Roboto:wght@300;400&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=Roboto:wght@300;400&display=swap"
rel="stylesheet">
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<h1>TicTacToe</h1> <h1>TicTacToe</h1>
@ -26,7 +29,6 @@
</div> </div>
</div> </div>
<!-- Game Section -->
<div id="game" class="panel hidden"> <div id="game" class="panel hidden">
<div class="game-info"> <div class="game-info">
<div class="info-item"> <div class="info-item">
@ -52,10 +54,14 @@
<div class="cell" data-r="2" data-c="2"></div> <div class="cell" data-r="2" data-c="2"></div>
</div> </div>
<button id="surrender-btn" class="btn danger">Surrender / Give Up</button> <div class="game-actions">
<button id="leave-btn" class="btn secondary">Leave Lobby</button>
<button id="surrender-btn" class="btn danger">Surrender</button>
</div>
</div> </div>
</div> </div>
<script src="/js/app.js"></script> <script src="/js/app.js"></script>
</body> </body>
</html> </html>