diff --git a/client/src/main/java/nl/andrewlalis/aos_client/Client.java b/client/src/main/java/nl/andrewlalis/aos_client/Client.java index 23f776b..835cd66 100644 --- a/client/src/main/java/nl/andrewlalis/aos_client/Client.java +++ b/client/src/main/java/nl/andrewlalis/aos_client/Client.java @@ -22,15 +22,14 @@ public class Client { private Player myPlayer; private final GameRenderer renderer; - private final GamePanel gamePanel; private final SoundManager soundManager; private final ChatManager chatManager; + private final GameFrame frame; + public Client(String serverHost, int serverPort, String username) throws IOException { this.soundManager = new SoundManager(); this.chatManager = new ChatManager(this.soundManager); - this.gamePanel = new GamePanel(this); - this.renderer = new GameRenderer(this, gamePanel); this.messageTransceiver = new MessageTransceiver(this, serverHost, serverPort, username); this.messageTransceiver.start(); this.chatManager.bindTransceiver(this.messageTransceiver); @@ -45,8 +44,10 @@ public class Client { } System.out.println("Player and world data initialized."); - GameFrame g = new GameFrame("Ace of Shades - " + serverHost + ":" + serverPort, this, this.gamePanel); - g.setVisible(true); + GamePanel gamePanel = new GamePanel(this); + this.renderer = new GameRenderer(this, gamePanel); + this.frame = new GameFrame("Ace of Shades - " + serverHost + ":" + serverPort, this, gamePanel); + this.frame.setVisible(true); this.renderer.start(); } @@ -119,6 +120,7 @@ public class Client { System.out.println("Renderer shutdown."); this.soundManager.close(); System.out.println("Sound manager closed."); + this.frame.dispose(); } diff --git a/client/src/main/java/nl/andrewlalis/aos_client/DataTransceiver.java b/client/src/main/java/nl/andrewlalis/aos_client/DataTransceiver.java index eeb10c3..6349c66 100644 --- a/client/src/main/java/nl/andrewlalis/aos_client/DataTransceiver.java +++ b/client/src/main/java/nl/andrewlalis/aos_client/DataTransceiver.java @@ -11,24 +11,68 @@ import java.net.InetAddress; import java.net.SocketException; import java.nio.ByteBuffer; +/** + * This thread is responsible for managing incoming datagram packets, and also + * offers functionality to send packets back to the server. + */ public class DataTransceiver extends Thread { private final Client client; private final DatagramSocket socket; + private final InetAddress serverAddress; + private final int serverPort; + private volatile boolean running; - public DataTransceiver(Client client) throws SocketException { + /** + * Constructs a new data transceiver thread, and immediately initiates a + * connection to the server to ensure we've got an "outbound" connection. + * @param client A reference to the client this thread is for. + * @param serverAddress The server's address to connect to. + * @param serverPort The server's port to connect to. + * @throws IOException If we could not open the socket and initialize the + * connection. + */ + public DataTransceiver(Client client, InetAddress serverAddress, int serverPort) throws IOException { this.client = client; + this.serverAddress = serverAddress; + this.serverPort = serverPort; this.socket = new DatagramSocket(); + this.initiateConnection(); + } + + /** + * Initiates the "connection" to the server's UDP socket. This is so that + * the router knows that this is an "outbound" connection which the client + * initiated. Otherwise, we can't receive UDP packets from the server. + * @throws IOException If we couldn't connect. + */ + private void initiateConnection() throws IOException { + boolean established = false; + int attempts = 0; + while (!established && attempts < 100) { + this.send(new byte[]{DataTypes.INIT}); + byte[] buffer = new byte[1400]; + DatagramPacket packet = new DatagramPacket(buffer, buffer.length); + this.socket.receive(packet); + if (packet.getLength() == 1 && packet.getData()[0] == DataTypes.INIT) { + established = true; + } + attempts++; + } + if (!established) { + throw new IOException("Could not initiate UDP connection after " + attempts + " attempts."); + } + System.out.println("Initiated UDP connection with server."); } public int getLocalPort() { return this.socket.getLocalPort(); } - public void send(byte[] bytes, InetAddress address, int port) throws IOException { + public void send(byte[] bytes) throws IOException { if (this.socket.isClosed()) return; - var packet = new DatagramPacket(bytes, bytes.length, address, port); + var packet = new DatagramPacket(bytes, bytes.length, this.serverAddress, this.serverPort); this.socket.send(packet); } @@ -42,7 +86,6 @@ public class DataTransceiver extends Thread { @Override public void run() { this.running = true; - System.out.println("Datagram socket opened on " + this.socket.getLocalAddress() + ":" + this.socket.getLocalPort()); byte[] buffer = new byte[1400]; DatagramPacket packet = new DatagramPacket(buffer, buffer.length); while (this.running) { diff --git a/client/src/main/java/nl/andrewlalis/aos_client/MessageTransceiver.java b/client/src/main/java/nl/andrewlalis/aos_client/MessageTransceiver.java index 503b662..64bffa2 100644 --- a/client/src/main/java/nl/andrewlalis/aos_client/MessageTransceiver.java +++ b/client/src/main/java/nl/andrewlalis/aos_client/MessageTransceiver.java @@ -3,8 +3,8 @@ package nl.andrewlalis.aos_client; import nl.andrewlalis.aos_core.net.*; import nl.andrewlalis.aos_core.net.chat.ChatMessage; +import javax.swing.*; import java.io.*; -import java.net.InetAddress; import java.net.Socket; import java.net.SocketException; import java.nio.ByteBuffer; @@ -29,11 +29,40 @@ public class MessageTransceiver extends Thread { public MessageTransceiver(Client client, String serverHost, int serverPort, String username) throws IOException { this.client = client; this.socket = new Socket(serverHost, serverPort); - this.dataTransceiver = new DataTransceiver(client); + this.dataTransceiver = new DataTransceiver(client, this.socket.getInetAddress(), this.socket.getPort()); this.out = new ObjectOutputStream(this.socket.getOutputStream()); this.in = new ObjectInputStream(this.socket.getInputStream()); - this.send(new IdentMessage(username, this.dataTransceiver.getLocalPort())); - System.out.println("Sent identification packet."); + this.initializeConnection(username); + } + + /** + * Initializes the TCP connection to the server. This involves sending an + * IDENT packet containing some data the server needs about this client, and + * waiting for a {@link PlayerRegisteredMessage} response from the server, + * which contains the basic data we need to start the game. + * @param username The username for this client. + * @throws IOException If the connection could not be initialized. + */ + private void initializeConnection(String username) throws IOException { + boolean established = false; + int attempts = 0; + while (!established && attempts < 100) { + this.send(new IdentMessage(username, this.dataTransceiver.getLocalPort())); + try { + Object obj = this.in.readObject(); + if (obj instanceof PlayerRegisteredMessage msg) { + this.client.setPlayer(msg.getPlayer()); + this.client.setWorld(msg.getWorld()); + established = true; + } + } catch (ClassNotFoundException e) { + e.printStackTrace(); + } + attempts++; + } + if (!established) { + throw new IOException("Could not initialize connection to server in " + attempts + " attempts."); + } } public void shutdown() { @@ -66,7 +95,7 @@ public class MessageTransceiver extends Thread { buffer.put(type); buffer.putInt(playerId); buffer.put(data); - this.dataTransceiver.send(buffer.array(), this.socket.getInetAddress(), this.socket.getPort()); + this.dataTransceiver.send(buffer.array()); } @Override @@ -75,12 +104,7 @@ public class MessageTransceiver extends Thread { while (this.running) { try { Message msg = (Message) this.in.readObject(); - if (msg.getType() == Type.PLAYER_REGISTERED) { - System.out.println("Received player registration response from server."); - PlayerRegisteredMessage prm = (PlayerRegisteredMessage) msg; - this.client.setPlayer(prm.getPlayer()); - this.client.setWorld(prm.getWorld()); - } else if (msg.getType() == Type.CHAT) { + if (msg.getType() == Type.CHAT) { this.client.getChatManager().addChatMessage((ChatMessage) msg); } else if (msg.getType() == Type.PLAYER_JOINED && this.client.getWorld() != null) { PlayerUpdateMessage pum = (PlayerUpdateMessage) msg; @@ -88,6 +112,9 @@ public class MessageTransceiver extends Thread { } else if (msg.getType() == Type.PLAYER_LEFT && this.client.getWorld() != null) { PlayerUpdateMessage pum = (PlayerUpdateMessage) msg; this.client.getWorld().getPlayers().remove(pum.getPlayer().getId()); + } else if (msg.getType() == Type.SERVER_SHUTDOWN) { + this.client.shutdown(); + JOptionPane.showMessageDialog(null, "Server has been shut down.", "Server Shutdown", JOptionPane.WARNING_MESSAGE); } } catch (StreamCorruptedException | EOFException e) { this.shutdown(); diff --git a/client/src/main/java/nl/andrewlalis/aos_client/Tester.java b/client/src/main/java/nl/andrewlalis/aos_client/Tester.java index 86cfee8..2fa9f15 100644 --- a/client/src/main/java/nl/andrewlalis/aos_client/Tester.java +++ b/client/src/main/java/nl/andrewlalis/aos_client/Tester.java @@ -9,7 +9,7 @@ public class Tester { }; public static void main(String[] args) { - for (int i = 0; i < 6; i++) { + for (int i = 0; i < 1; i++) { try { new Client("localhost", 8035, names[ThreadLocalRandom.current().nextInt(names.length)]); } catch (IOException e) { diff --git a/client/src/main/java/nl/andrewlalis/aos_client/control/PlayerMouseListener.java b/client/src/main/java/nl/andrewlalis/aos_client/control/PlayerMouseListener.java index 3248491..8a8f893 100644 --- a/client/src/main/java/nl/andrewlalis/aos_client/control/PlayerMouseListener.java +++ b/client/src/main/java/nl/andrewlalis/aos_client/control/PlayerMouseListener.java @@ -9,7 +9,7 @@ import java.awt.event.MouseEvent; import java.awt.event.MouseWheelEvent; public class PlayerMouseListener extends MouseInputAdapter { - private static final float MOUSE_UPDATES_PER_SECOND = 30.0f; + private static final float MOUSE_UPDATES_PER_SECOND = 60.0f; private static final long MS_PER_MOUSE_UPDATE = (long) (1000.0f / MOUSE_UPDATES_PER_SECOND); private final Client client; diff --git a/client/src/main/java/nl/andrewlalis/aos_client/launcher/Launcher.java b/client/src/main/java/nl/andrewlalis/aos_client/launcher/Launcher.java new file mode 100644 index 0000000..0d31b71 --- /dev/null +++ b/client/src/main/java/nl/andrewlalis/aos_client/launcher/Launcher.java @@ -0,0 +1,132 @@ +package nl.andrewlalis.aos_client.launcher; + +import nl.andrewlalis.aos_client.Client; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +public class Launcher extends JFrame { + private static final Pattern addressPattern = Pattern.compile("(.+):(\\d+)"); + + public Launcher() throws HeadlessException { + super("Ace of Shades - Launcher"); + this.setDefaultCloseOperation(EXIT_ON_CLOSE); + this.setContentPane(this.buildContent()); + this.pack(); + this.setLocationRelativeTo(null); + } + + private Container buildContent() { + JTabbedPane mainPanel = new JTabbedPane(SwingConstants.TOP, JTabbedPane.SCROLL_TAB_LAYOUT); + mainPanel.addTab("Connect", null, this.getConnectPanel(), "Connect to a server and play."); + + JPanel serversPanel = new JPanel(); + mainPanel.addTab("Servers", null, serversPanel, "View a list of available servers."); + + JPanel settingsPanel = new JPanel(); + mainPanel.addTab("Settings", null, settingsPanel, "Change game settings."); + + return mainPanel; + } + + private Container getConnectPanel() { + JPanel inputPanel = new JPanel(new GridBagLayout()); + GridBagConstraints c = new GridBagConstraints(); + c.insets = new Insets(5, 5, 5, 5); + + c.gridx = 0; + c.gridy = 0; + inputPanel.add(new JLabel("Address"), c); + JTextField addressField = new JTextField(20); + c.gridx = 1; + inputPanel.add(addressField, c); + + c.gridy = 1; + c.gridx = 0; + inputPanel.add(new JLabel("Username"), c); + JTextField usernameField = new JTextField(20); + c.gridx = 1; + inputPanel.add(usernameField, c); + + var enterListener = new KeyAdapter() { + @Override + public void keyPressed(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_ENTER) { + if (validateInput(addressField, usernameField)) { + connect(addressField, usernameField); + } + } + } + }; + addressField.addKeyListener(enterListener); + usernameField.addKeyListener(enterListener); + + JPanel buttonPanel = new JPanel(new FlowLayout()); + JButton cancelButton = new JButton("Cancel"); + cancelButton.addActionListener(e -> this.dispose()); + JButton connectButton = new JButton("Connect"); + connectButton.addActionListener(e -> { + if (validateInput(addressField, usernameField)) { + connect(addressField, usernameField); + } + }); + buttonPanel.add(cancelButton); + buttonPanel.add(connectButton); + + JPanel mainPanel = new JPanel(new BorderLayout()); + mainPanel.add(inputPanel, BorderLayout.CENTER); + mainPanel.add(buttonPanel, BorderLayout.SOUTH); + return mainPanel; + } + + private boolean validateInput(JTextField addressField, JTextField usernameField) { + List warnings = new ArrayList<>(); + if (addressField.getText() == null || addressField.getText().isBlank()) { + warnings.add("Address must not be empty."); + } + if (usernameField.getText() == null || usernameField.getText().isBlank()) { + warnings.add("Username must not be empty."); + } + if (usernameField.getText() != null && usernameField.getText().length() > 16) { + warnings.add("Username is too long."); + } + if (addressField.getText() != null && !addressPattern.matcher(addressField.getText()).matches()) { + warnings.add("Address must be in the form HOST:PORT."); + } + if (!warnings.isEmpty()) { + JOptionPane.showMessageDialog( + this, + String.join("\n", warnings), + "Invalid Input", + JOptionPane.WARNING_MESSAGE + ); + } + return warnings.isEmpty(); + } + + private void connect(JTextField addressField, JTextField usernameField) { + String hostAndPort = addressField.getText(); + String[] parts = hostAndPort.split(":"); + String host = parts[0].trim(); + int port = Integer.parseInt(parts[1]); + String username = usernameField.getText(); + try { + new Client(host, port, username); + } catch (IOException ex) { + ex.printStackTrace(); + JOptionPane.showMessageDialog(null, "Could not connect:\n" + ex.getMessage(), "Connection Error", JOptionPane.WARNING_MESSAGE); + } + } + + + public static void main(String[] args) { + Launcher launcher = new Launcher(); + launcher.setVisible(true); + } +} diff --git a/core/src/main/java/nl/andrewlalis/aos_core/net/PlayerControlStateMessage.java b/core/src/main/java/nl/andrewlalis/aos_core/net/PlayerControlStateMessage.java deleted file mode 100644 index 10f3a52..0000000 --- a/core/src/main/java/nl/andrewlalis/aos_core/net/PlayerControlStateMessage.java +++ /dev/null @@ -1,16 +0,0 @@ -package nl.andrewlalis.aos_core.net; - -import nl.andrewlalis.aos_core.model.PlayerControlState; - -public class PlayerControlStateMessage extends Message { - private final PlayerControlState playerControlState; - - public PlayerControlStateMessage(PlayerControlState pcs) { - super(Type.PLAYER_CONTROL_STATE); - this.playerControlState = pcs; - } - - public PlayerControlState getPlayerControlState() { - return playerControlState; - } -} diff --git a/core/src/main/java/nl/andrewlalis/aos_core/net/Type.java b/core/src/main/java/nl/andrewlalis/aos_core/net/Type.java index 7c7c976..287d44c 100644 --- a/core/src/main/java/nl/andrewlalis/aos_core/net/Type.java +++ b/core/src/main/java/nl/andrewlalis/aos_core/net/Type.java @@ -4,8 +4,8 @@ public enum Type { IDENT, PLAYER_REGISTERED, CHAT, - PLAYER_CONTROL_STATE, PLAYER_JOINED, PLAYER_LEFT, - PLAYER_TEAM_CHANGE + PLAYER_TEAM_CHANGE, + SERVER_SHUTDOWN } diff --git a/core/src/main/java/nl/andrewlalis/aos_core/net/data/DataTypes.java b/core/src/main/java/nl/andrewlalis/aos_core/net/data/DataTypes.java index fe0c899..5ad1258 100644 --- a/core/src/main/java/nl/andrewlalis/aos_core/net/data/DataTypes.java +++ b/core/src/main/java/nl/andrewlalis/aos_core/net/data/DataTypes.java @@ -1,6 +1,7 @@ package nl.andrewlalis.aos_core.net.data; public class DataTypes { + public static final byte INIT = 0; public static final byte PLAYER_CONTROL_STATE = 1; public static final byte WORLD_DATA = 2; public static final byte PLAYER_DETAIL = 3; diff --git a/server/src/main/java/nl/andrewlalis/aos_server/ClientHandler.java b/server/src/main/java/nl/andrewlalis/aos_server/ClientHandler.java index 5931a06..4a3f527 100644 --- a/server/src/main/java/nl/andrewlalis/aos_server/ClientHandler.java +++ b/server/src/main/java/nl/andrewlalis/aos_server/ClientHandler.java @@ -16,6 +16,7 @@ import java.net.Socket; import java.net.SocketException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; /** * Thread which handles communicating with a single client socket connection. @@ -41,6 +42,38 @@ public class ClientHandler extends Thread { this.in = new ObjectInputStream(socket.getInputStream()); } + /** + * Initializes the TCP connection to a client, by waiting for the client to + * send an IDENT packet containing the client's username and other details + * that are needed to start. + *

+ * Once we receive a valid IDENT packet, the player is registered into + * the server, and receives a {@link PlayerRegisteredMessage} response. + *

+ * @throws IOException If initialization could not be completed. + */ + private void initializeConnection() throws IOException { + boolean connectionEstablished = false; + int attempts = 0; + while (!connectionEstablished && attempts < 100) { + try { + Object obj = this.in.readObject(); + if (obj instanceof IdentMessage msg) { + this.player = this.server.registerNewPlayer(msg.getName()); + this.clientUdpPort = msg.getUdpPort(); + this.send(new PlayerRegisteredMessage(this.player, this.server.getWorld())); + connectionEstablished = true; + } + } catch (ClassNotFoundException e) { + e.printStackTrace(); + } + attempts++; + } + if (!connectionEstablished) { + throw new IOException("Could not establish connection after " + attempts + " attempts."); + } + } + public Player getPlayer() { return player; } @@ -55,12 +88,21 @@ public class ClientHandler extends Thread { public void shutdown() { this.running = false; + if (!this.socket.isClosed()) { + this.send(new Message(Type.SERVER_SHUTDOWN)); + } this.sendingQueue.shutdown(); try { + boolean terminated = false; + while (!terminated) { + terminated = this.sendingQueue.awaitTermination(1000, TimeUnit.MILLISECONDS); + } this.in.close(); this.out.close(); - this.socket.close(); - } catch (IOException e) { + if (!this.socket.isClosed()) { + this.socket.close(); + } + } catch (IOException | InterruptedException e) { System.err.println("Could not close streams when shutting down client handler for player " + this.player.getId() + ": " + e.getMessage()); } } @@ -72,22 +114,24 @@ public class ClientHandler extends Thread { this.out.reset(); this.out.writeObject(message); } catch (IOException e) { - e.printStackTrace(); + System.err.println("Could not send message " + message.getClass().getName() + ": " + e.getMessage()); } }); } @Override public void run() { + try { + this.initializeConnection(); + } catch (IOException e) { + System.err.println("Could not initialize connection to the client: " + e.getMessage()); + this.shutdown(); + return; + } while (this.running) { try { Message msg = (Message) this.in.readObject(); - if (msg.getType() == Type.IDENT) { - IdentMessage ident = (IdentMessage) msg; - this.player = this.server.registerNewPlayer(ident.getName()); - this.clientUdpPort = ident.getUdpPort(); - this.send(new PlayerRegisteredMessage(this.player, this.server.getWorld())); - } else if (msg.getType() == Type.CHAT) { + if (msg.getType() == Type.CHAT) { this.server.getChatManager().handlePlayerChat(this, this.player, (ChatMessage) msg); } } catch (SocketException e) { diff --git a/server/src/main/java/nl/andrewlalis/aos_server/DataTransceiver.java b/server/src/main/java/nl/andrewlalis/aos_server/DataTransceiver.java index 1e9a299..b342df5 100644 --- a/server/src/main/java/nl/andrewlalis/aos_server/DataTransceiver.java +++ b/server/src/main/java/nl/andrewlalis/aos_server/DataTransceiver.java @@ -47,8 +47,10 @@ public class DataTransceiver extends Thread { this.socket.receive(packet); ByteBuffer b = ByteBuffer.wrap(packet.getData(), 0, packet.getLength()); byte type = b.get(); - int playerId = b.getInt(); - if (type == DataTypes.PLAYER_CONTROL_STATE) { + if (type == DataTypes.INIT) { // New client is trying to initiate an outbound connection so simply echo packet. + this.send(new byte[]{DataTypes.INIT}, packet.getAddress(), packet.getPort()); + } else if (type == DataTypes.PLAYER_CONTROL_STATE) { + int playerId = b.getInt(); if (playerId < 1) continue; byte[] stateBuffer = new byte[b.remaining()]; b.get(stateBuffer); diff --git a/server/src/main/java/nl/andrewlalis/aos_server/Server.java b/server/src/main/java/nl/andrewlalis/aos_server/Server.java index 5d861d7..5afd364 100644 --- a/server/src/main/java/nl/andrewlalis/aos_server/Server.java +++ b/server/src/main/java/nl/andrewlalis/aos_server/Server.java @@ -197,7 +197,7 @@ public class Server { this.dataTransceiver.start(); this.worldUpdater.start(); this.cli.start(); - System.out.println("Started AOS-Server TCP on port " + this.serverSocket.getLocalPort() + "; now accepting connections."); + System.out.println("Started AOS-Server TCP/UDP on port " + this.serverSocket.getLocalPort() + "; now accepting connections."); while (this.running) { this.acceptClientConnection(); }