diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4d76962
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+.idea/
+client/target/
+core/target/
+server/target/
+/*.iml
\ No newline at end of file
diff --git a/client/pom.xml b/client/pom.xml
new file mode 100644
index 0000000..f29ab6a
--- /dev/null
+++ b/client/pom.xml
@@ -0,0 +1,55 @@
+
+
+
+ ace-of-shades
+ nl.andrewlalis
+ 1.0-SNAPSHOT
+
+ 4.0.0
+
+ aos-client
+
+
+ 16
+ 16
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-assembly-plugin
+ 3.3.0
+
+
+
+ nl.andrewlalis.aos_client.Client
+
+
+
+ jar-with-dependencies
+
+
+
+
+ make-assembly
+ package
+
+ single
+
+
+
+
+
+
+
+
+
+ nl.andrewlalis
+ aos-core
+ ${parent.version}
+
+
+
\ No newline at end of file
diff --git a/client/src/main/java/module-info.java b/client/src/main/java/module-info.java
new file mode 100644
index 0000000..6c144d7
--- /dev/null
+++ b/client/src/main/java/module-info.java
@@ -0,0 +1,6 @@
+module aos_client {
+ requires java.logging;
+ requires java.se;
+
+ requires aos_core;
+}
\ No newline at end of file
diff --git a/client/src/main/java/nl/andrewlalis/aos_client/Client.java b/client/src/main/java/nl/andrewlalis/aos_client/Client.java
new file mode 100644
index 0000000..10837a4
--- /dev/null
+++ b/client/src/main/java/nl/andrewlalis/aos_client/Client.java
@@ -0,0 +1,184 @@
+package nl.andrewlalis.aos_client;
+
+import nl.andrewlalis.aos_client.view.GameFrame;
+import nl.andrewlalis.aos_client.view.GamePanel;
+import nl.andrewlalis.aos_core.model.PlayerControlState;
+import nl.andrewlalis.aos_core.model.World;
+import nl.andrewlalis.aos_core.net.ChatMessage;
+
+import javax.swing.*;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.ObjectOutputStream;
+import java.net.DatagramPacket;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * The main class for the client, which connects to a server to join and play.
+ */
+public class Client {
+ public static final int MAX_CHAT_MESSAGES = 10;
+
+ private final int udpPort;
+ private DatagramReceiver datagramReceiver;
+ private MessageTransceiver messageTransceiver;
+
+ private int playerId;
+ private PlayerControlState playerControlState;
+ private World world;
+
+ private final List chatMessages;
+ private boolean chatting = false;
+ private final StringBuilder chatBuffer;
+
+ private final GameRenderer renderer;
+ private final GamePanel gamePanel;
+ private final SoundManager soundManager;
+
+ public Client(int udpPort) {
+ this.udpPort = udpPort;
+ this.chatMessages = new LinkedList<>();
+ this.chatBuffer = new StringBuilder();
+ this.soundManager = new SoundManager();
+ this.gamePanel = new GamePanel(this);
+ this.renderer = new GameRenderer(this, gamePanel);
+ }
+
+ public void connect(String serverHost, int serverPort, String username) throws IOException, ClassNotFoundException {
+ this.datagramReceiver = new DatagramReceiver(this, this.udpPort);
+ this.datagramReceiver.start();
+ this.messageTransceiver = new MessageTransceiver(this);
+ this.messageTransceiver.connectToServer(serverHost, serverPort, username, this.udpPort);
+ this.messageTransceiver.start();
+
+ while (this.playerControlState == null) {
+ try {
+ System.out.println("Waiting for server response and player registration...");
+ Thread.sleep(100);
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+ }
+
+ GameFrame g = new GameFrame("Ace of Shades - " + serverHost + ":" + serverPort, this, this.gamePanel);
+ g.setVisible(true);
+ this.renderer.start();
+ }
+
+ public World getWorld() {
+ return world;
+ }
+
+ public void setWorld(World world) {
+ this.world = world;
+ for (String sound : this.world.getSoundsToPlay()) {
+ this.soundManager.play(sound);
+ }
+ }
+
+ public void initPlayerData(int playerId) {
+ this.playerId = playerId;
+ this.playerControlState = new PlayerControlState();
+ this.playerControlState.setPlayerId(playerId);
+ }
+
+ public int getPlayerId() {
+ return playerId;
+ }
+
+ public PlayerControlState getPlayerState() {
+ return playerControlState;
+ }
+
+ public void sendPlayerState() {
+ try {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ ObjectOutputStream oos = new ObjectOutputStream(bos);
+ oos.writeObject(this.playerControlState);
+ byte[] buffer = bos.toByteArray();
+ DatagramPacket packet = new DatagramPacket(buffer, buffer.length, this.messageTransceiver.getRemoteAddress(), this.messageTransceiver.getPort());
+ this.datagramReceiver.getDatagramSocket().send(packet);
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ public synchronized void addChatMessage(String text) {
+ this.chatMessages.add(text);
+ this.soundManager.play("chat.wav");
+ while (this.chatMessages.size() > MAX_CHAT_MESSAGES) {
+ this.chatMessages.remove(0);
+ }
+ }
+
+ public String[] getLatestChatMessages() {
+ return this.chatMessages.toArray(new String[0]);
+ }
+
+ public boolean isChatting() {
+ return this.chatting;
+ }
+
+ public void setChatting(boolean chatting) {
+ this.chatting = chatting;
+ if (this.chatting) {
+ this.chatBuffer.setLength(0);
+ }
+ }
+
+ public void appendToChat(char c) {
+ this.chatBuffer.append(c);
+ }
+
+ public void backspaceChat() {
+ if (this.chatBuffer.length() > 0) {
+ this.chatBuffer.setLength(this.chatBuffer.length() - 1);
+ }
+ }
+
+ public void sendChat() {
+ try {
+ this.messageTransceiver.send(new ChatMessage(this.chatBuffer.toString()));
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ this.setChatting(false);
+ }
+
+ public String getCurrentChatBuffer() {
+ return this.chatBuffer.toString();
+ }
+
+ public void shutdown() {
+ this.datagramReceiver.shutdown();
+ this.messageTransceiver.shutdown();
+ this.renderer.shutdown();
+ }
+
+
+
+ public static void main(String[] args) {
+ // Randomly choose a high-level UDP port that's probably open.
+ int udpPort = 20000 + ThreadLocalRandom.current().nextInt(0, 10000);
+
+ String hostAndPort = JOptionPane.showInputDialog("Enter server host and port (host:port):");
+ if (hostAndPort == null) throw new IllegalArgumentException("A host and port is required.");
+ String[] parts = hostAndPort.split(":");
+ if (parts.length != 2) throw new IllegalArgumentException("Invalid host:port.");
+ String host = parts[0].trim();
+ int port = Integer.parseInt(parts[1]);
+ String username = JOptionPane.showInputDialog("Enter a username:");
+ if (username == null || username.isBlank()) throw new IllegalArgumentException("Username is required.");
+
+ Client client = new Client(udpPort);
+ try {
+ client.connect(host, port, username);
+ } catch (IOException | ClassNotFoundException e) {
+ client.shutdown();
+ e.printStackTrace();
+ JOptionPane.showMessageDialog(null, "Could not connect:\n" + e.getMessage(), "Connection Error", JOptionPane.WARNING_MESSAGE);
+ }
+ }
+}
diff --git a/client/src/main/java/nl/andrewlalis/aos_client/DatagramReceiver.java b/client/src/main/java/nl/andrewlalis/aos_client/DatagramReceiver.java
new file mode 100644
index 0000000..3b1bc62
--- /dev/null
+++ b/client/src/main/java/nl/andrewlalis/aos_client/DatagramReceiver.java
@@ -0,0 +1,50 @@
+package nl.andrewlalis.aos_client;
+
+import nl.andrewlalis.aos_core.model.World;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.net.DatagramPacket;
+import java.net.DatagramSocket;
+import java.net.SocketException;
+
+public class DatagramReceiver extends Thread {
+ private final DatagramSocket datagramSocket;
+ private final Client client;
+
+ private volatile boolean running;
+
+ public DatagramReceiver(Client client, int port) throws SocketException {
+ this.datagramSocket = new DatagramSocket(port);
+ this.client = client;
+ }
+
+ public DatagramSocket getDatagramSocket() {
+ return datagramSocket;
+ }
+
+ public void shutdown() {
+ this.running = false;
+ this.datagramSocket.close();
+ }
+
+ @Override
+ public void run() {
+ this.running = true;
+ byte[] buffer = new byte[8192];
+ DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
+ while (this.running) {
+ try {
+ this.datagramSocket.receive(packet);
+ ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(packet.getData()));
+ Object obj = ois.readObject();
+ if (obj instanceof World) {
+ this.client.setWorld((World) obj);
+ }
+ } catch (IOException | ClassNotFoundException e) {
+ // Ignore any receive exception.d
+ }
+ }
+ }
+}
diff --git a/client/src/main/java/nl/andrewlalis/aos_client/GameRenderer.java b/client/src/main/java/nl/andrewlalis/aos_client/GameRenderer.java
new file mode 100644
index 0000000..7ac1933
--- /dev/null
+++ b/client/src/main/java/nl/andrewlalis/aos_client/GameRenderer.java
@@ -0,0 +1,64 @@
+package nl.andrewlalis.aos_client;
+
+import nl.andrewlalis.aos_client.view.GamePanel;
+import nl.andrewlalis.aos_core.model.Bullet;
+import nl.andrewlalis.aos_core.model.Player;
+import nl.andrewlalis.aos_core.model.World;
+
+/**
+ * This thread is responsible for updating the client's display periodically,
+ * and performing 'dumb' updates of the model in the interim period between
+ * updates from the server, by continuing objects' velocities.
+ */
+public class GameRenderer extends Thread {
+ public static final double FPS = 120.0;
+ public static final double MS_PER_FRAME = 1000.0 / FPS;
+
+ private volatile boolean running = true;
+
+ private final Client client;
+ private final GamePanel gamePanel;
+
+ public GameRenderer(Client client, GamePanel gamePanel) {
+ this.client = client;
+ this.gamePanel = gamePanel;
+ }
+
+ public void shutdown() {
+ this.running = false;
+ }
+
+ @Override
+ public void run() {
+ long lastFrame = System.currentTimeMillis();
+ while (this.running) {
+ long now = System.currentTimeMillis();
+ long msSinceLastFrame = now - lastFrame;
+ if (msSinceLastFrame >= MS_PER_FRAME) {
+ double elapsedSeconds = msSinceLastFrame / 1000.0;
+ this.gamePanel.repaint();
+ this.updateWorld(elapsedSeconds);
+ lastFrame = now;
+ msSinceLastFrame = 0;
+ }
+ long msUntilNextFrame = (long) (MS_PER_FRAME - msSinceLastFrame);
+ if (msUntilNextFrame > 0) {
+ try {
+ Thread.sleep(msUntilNextFrame);
+ } catch (InterruptedException e) {
+ System.err.println("Interrupted while waiting for next frame: " + e.getMessage());
+ }
+ }
+ }
+ }
+
+ private void updateWorld(double t) {
+ World world = this.client.getWorld();
+ for (Player p : world.getPlayers().values()) {
+ p.setPosition(p.getPosition().add(p.getVelocity().mul(t)));
+ }
+ for (Bullet b : world.getBullets()) {
+ b.setPosition(b.getPosition().add(b.getVelocity().mul(t)));
+ }
+ }
+}
diff --git a/client/src/main/java/nl/andrewlalis/aos_client/MessageTransceiver.java b/client/src/main/java/nl/andrewlalis/aos_client/MessageTransceiver.java
new file mode 100644
index 0000000..e8839ed
--- /dev/null
+++ b/client/src/main/java/nl/andrewlalis/aos_client/MessageTransceiver.java
@@ -0,0 +1,74 @@
+package nl.andrewlalis.aos_client;
+
+import nl.andrewlalis.aos_core.net.*;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.net.InetAddress;
+import java.net.Socket;
+
+/**
+ * This thread is responsible for handling TCP message communication with the
+ * server.
+ */
+public class MessageTransceiver extends Thread {
+ private final Client client;
+
+ private Socket socket;
+ private ObjectOutputStream out;
+ private ObjectInputStream in;
+
+ private volatile boolean running = true;
+
+ public MessageTransceiver(Client client) {
+ this.client = client;
+ }
+
+ public void connectToServer(String serverHost, int serverPort, String username, int udpPort) throws IOException, ClassNotFoundException {
+ this.socket = new Socket(serverHost, serverPort);
+ this.out = new ObjectOutputStream(this.socket.getOutputStream());
+ this.in = new ObjectInputStream(this.socket.getInputStream());
+ this.send(new IdentMessage(username, udpPort));
+ }
+
+ public void shutdown() {
+ this.running = false;
+ if (this.socket != null) {
+ try {
+ this.socket.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+
+ public InetAddress getRemoteAddress() {
+ return this.socket != null ? this.socket.getInetAddress() : null;
+ }
+
+ public int getPort() {
+ return this.socket.getPort();
+ }
+
+ public void send(Message message) throws IOException {
+ this.out.writeObject(message);
+ }
+
+ @Override
+ public void run() {
+ while (this.running) {
+ try {
+ Message msg = (Message) this.in.readObject();
+ if (msg.getType() == Type.PLAYER_REGISTERED) {
+ PlayerRegisteredMessage prm = (PlayerRegisteredMessage) msg;
+ this.client.initPlayerData(prm.getPlayerId());
+ } else if (msg.getType() == Type.CHAT) {
+ this.client.addChatMessage(((ChatMessage) msg).getText());
+ }
+ } catch (IOException | ClassNotFoundException e) {
+ // Ignore exceptions.
+ }
+ }
+ }
+}
diff --git a/client/src/main/java/nl/andrewlalis/aos_client/SoundManager.java b/client/src/main/java/nl/andrewlalis/aos_client/SoundManager.java
new file mode 100644
index 0000000..5d030b2
--- /dev/null
+++ b/client/src/main/java/nl/andrewlalis/aos_client/SoundManager.java
@@ -0,0 +1,54 @@
+package nl.andrewlalis.aos_client;
+
+import javax.sound.sampled.*;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.HashMap;
+import java.util.Map;
+
+public class SoundManager {
+ private final Map soundData = new HashMap<>();
+
+ public void play(String sound) {
+ var clip = this.getClip(sound);
+ if (clip != null) {
+ clip.start();
+ }
+ }
+
+ private Clip getClip(String sound) {
+ var soundBytes = this.soundData.get(sound);
+ if (soundBytes == null) {
+ InputStream is = Client.class.getResourceAsStream("/sound/" + sound);
+ if (is == null) {
+ System.err.println("Could not load sound: " + sound);
+ return null;
+ }
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ try {
+ is.transferTo(bos);
+ soundBytes = bos.toByteArray();
+ this.soundData.put(sound, soundBytes);
+ } catch (IOException e) {
+ e.printStackTrace();
+ return null;
+ }
+ }
+ try {
+ var ais = AudioSystem.getAudioInputStream(new ByteArrayInputStream(soundBytes));
+ var clip = AudioSystem.getClip();
+ clip.addLineListener(event -> {
+ if (event.getType() == LineEvent.Type.STOP) {
+ clip.close();
+ }
+ });
+ clip.open(ais);
+ return clip;
+ } catch (UnsupportedAudioFileException | IOException | LineUnavailableException e) {
+ e.printStackTrace();
+ return null;
+ }
+ }
+}
diff --git a/client/src/main/java/nl/andrewlalis/aos_client/control/PlayerKeyListener.java b/client/src/main/java/nl/andrewlalis/aos_client/control/PlayerKeyListener.java
new file mode 100644
index 0000000..60ea812
--- /dev/null
+++ b/client/src/main/java/nl/andrewlalis/aos_client/control/PlayerKeyListener.java
@@ -0,0 +1,65 @@
+package nl.andrewlalis.aos_client.control;
+
+import nl.andrewlalis.aos_client.Client;
+
+import java.awt.event.KeyAdapter;
+import java.awt.event.KeyEvent;
+
+public class PlayerKeyListener extends KeyAdapter {
+ private final Client client;
+
+ public PlayerKeyListener(Client client) {
+ this.client = client;
+ }
+
+ @Override
+ public void keyTyped(KeyEvent e) {
+ if (!this.client.isChatting() && (e.getKeyChar() == 't' || e.getKeyChar() == '/')) {
+ this.client.setChatting(true);
+ if (e.getKeyChar() == '/') this.client.appendToChat('/');
+ } else if (this.client.isChatting()) {
+ char c = e.getKeyChar();
+ if (c >= ' ' && c <= '~') {
+ this.client.appendToChat(c);
+ } else if (e.getKeyChar() == 8) {
+ this.client.backspaceChat();
+ } else if (e.getKeyChar() == 10) {
+ this.client.sendChat();
+ } else if (e.getKeyChar() == 27) {
+ this.client.setChatting(false);
+ }
+ }
+ }
+
+ @Override
+ public void keyPressed(KeyEvent e) {
+ if (client.isChatting()) return;
+ var state = client.getPlayerState();
+ if (e.getKeyCode() == KeyEvent.VK_W) {
+ state.setMovingForward(true);
+ } else if (e.getKeyCode() == KeyEvent.VK_S) {
+ state.setMovingBackward(true);
+ } else if (e.getKeyCode() == KeyEvent.VK_A) {
+ state.setMovingLeft(true);
+ } else if (e.getKeyCode() == KeyEvent.VK_D) {
+ state.setMovingRight(true);
+ }
+ this.client.sendPlayerState();
+ }
+
+ @Override
+ public void keyReleased(KeyEvent e) {
+ if (client.isChatting()) return;
+ var state = client.getPlayerState();
+ if (e.getKeyCode() == KeyEvent.VK_W) {
+ state.setMovingForward(false);
+ } else if (e.getKeyCode() == KeyEvent.VK_S) {
+ state.setMovingBackward(false);
+ } else if (e.getKeyCode() == KeyEvent.VK_A) {
+ state.setMovingLeft(false);
+ } else if (e.getKeyCode() == KeyEvent.VK_D) {
+ state.setMovingRight(false);
+ }
+ this.client.sendPlayerState();
+ }
+}
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
new file mode 100644
index 0000000..2cdc89b
--- /dev/null
+++ b/client/src/main/java/nl/andrewlalis/aos_client/control/PlayerMouseListener.java
@@ -0,0 +1,61 @@
+package nl.andrewlalis.aos_client.control;
+
+import nl.andrewlalis.aos_client.Client;
+import nl.andrewlalis.aos_client.view.GamePanel;
+import nl.andrewlalis.aos_core.geom.Vec2;
+
+import javax.swing.event.MouseInputAdapter;
+import java.awt.event.MouseEvent;
+import java.awt.event.MouseWheelEvent;
+
+public class PlayerMouseListener extends MouseInputAdapter {
+ private final Client client;
+ private final GamePanel gamePanel;
+
+ public PlayerMouseListener(Client client, GamePanel gamePanel) {
+ this.client = client;
+ this.gamePanel = gamePanel;
+ }
+
+ @Override
+ public void mousePressed(MouseEvent e) {
+ if (e.getButton() == MouseEvent.BUTTON1) {
+ client.getPlayerState().setShooting(true);
+ client.sendPlayerState();
+ }
+ }
+
+ @Override
+ public void mouseReleased(MouseEvent e) {
+ if (e.getButton() == MouseEvent.BUTTON1) {
+ client.getPlayerState().setShooting(false);
+ client.sendPlayerState();
+ }
+ }
+
+ @Override
+ public void mouseWheelMoved(MouseWheelEvent e) {
+ if (e.getWheelRotation() > 0) {
+ this.gamePanel.decrementScale();
+ } else if (e.getWheelRotation() < 0) {
+ this.gamePanel.incrementScale();
+ }
+ }
+
+ @Override
+ public void mouseMoved(MouseEvent e) {
+ Vec2 c = new Vec2(this.gamePanel.getWidth() / 2.0, this.gamePanel.getHeight() / 2.0);
+ Vec2 centeredMouseLocation = new Vec2(e.getX(), e.getY()).sub(c);
+ client.getPlayerState().setMouseLocation(centeredMouseLocation);
+ client.sendPlayerState();
+ }
+
+ @Override
+ public void mouseDragged(MouseEvent e) {
+ Vec2 c = new Vec2(this.gamePanel.getWidth() / 2.0, this.gamePanel.getHeight() / 2.0);
+ Vec2 centeredMouseLocation = new Vec2(e.getX(), e.getY()).sub(c);
+ client.getPlayerState().setMouseLocation(centeredMouseLocation);
+ client.getPlayerState().setShooting(true);
+ client.sendPlayerState();
+ }
+}
diff --git a/client/src/main/java/nl/andrewlalis/aos_client/view/GameFrame.java b/client/src/main/java/nl/andrewlalis/aos_client/view/GameFrame.java
new file mode 100644
index 0000000..0c944d5
--- /dev/null
+++ b/client/src/main/java/nl/andrewlalis/aos_client/view/GameFrame.java
@@ -0,0 +1,38 @@
+package nl.andrewlalis.aos_client.view;
+
+import nl.andrewlalis.aos_client.Client;
+import nl.andrewlalis.aos_client.control.PlayerKeyListener;
+import nl.andrewlalis.aos_client.control.PlayerMouseListener;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
+
+public class GameFrame extends JFrame {
+ public GameFrame(String title, Client client, GamePanel gamePanel) throws HeadlessException {
+ super(title);
+ this.setDefaultCloseOperation(DISPOSE_ON_CLOSE);
+
+ this.setResizable(false);
+ gamePanel.setPreferredSize(new Dimension(800, 800));
+ this.setContentPane(gamePanel);
+ gamePanel.setFocusable(true);
+ gamePanel.setRequestFocusEnabled(true);
+ var mouseListener = new PlayerMouseListener(client, gamePanel);
+ gamePanel.addKeyListener(new PlayerKeyListener(client));
+ gamePanel.addMouseListener(mouseListener);
+ gamePanel.addMouseMotionListener(mouseListener);
+ gamePanel.addMouseWheelListener(mouseListener);
+ this.addWindowListener(new WindowAdapter() {
+ @Override
+ public void windowClosing(WindowEvent e) {
+ System.out.println("Closing...");
+ client.shutdown();
+ }
+ });
+ this.pack();
+ gamePanel.requestFocusInWindow();
+ this.setLocationRelativeTo(null);
+ }
+}
diff --git a/client/src/main/java/nl/andrewlalis/aos_client/view/GamePanel.java b/client/src/main/java/nl/andrewlalis/aos_client/view/GamePanel.java
new file mode 100644
index 0000000..5f073d6
--- /dev/null
+++ b/client/src/main/java/nl/andrewlalis/aos_client/view/GamePanel.java
@@ -0,0 +1,158 @@
+package nl.andrewlalis.aos_client.view;
+
+import nl.andrewlalis.aos_client.Client;
+import nl.andrewlalis.aos_core.model.*;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.geom.AffineTransform;
+import java.awt.geom.Ellipse2D;
+import java.awt.geom.Rectangle2D;
+
+public class GamePanel extends JPanel {
+ private final Client client;
+
+ private final double[] scales = {1.0, 2.5, 5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 35.0};
+ private int scaleIndex = 3;
+
+ public GamePanel(Client client) {
+ this.client = client;
+ }
+
+ public void incrementScale() {
+ if (scaleIndex < scales.length - 1) {
+ scaleIndex++;
+ }
+ }
+
+ public void decrementScale() {
+ if (scaleIndex > 0) {
+ scaleIndex--;
+ }
+ }
+
+ @Override
+ protected void paintComponent(Graphics g) {
+ Graphics2D g2 = (Graphics2D) g;
+ g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+ g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+ g2.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_SPEED);
+
+ g2.setColor(Color.BLACK);
+ g2.setBackground(Color.BLACK);
+ g2.clearRect(0, 0, this.getWidth(), this.getHeight());
+
+ World world = client.getWorld();
+ if (world != null) drawWorld(g2, world);
+ drawChat(g2, client.getLatestChatMessages());
+ }
+
+ private void drawWorld(Graphics2D g2, World world) {
+ Player myPlayer = world.getPlayers().get(this.client.getPlayerId());
+ if (myPlayer == null) return;
+ double scale = this.scales[this.scaleIndex];
+ AffineTransform pre = g2.getTransform();
+ g2.setTransform(this.getWorldTransform(myPlayer, scale));
+ g2.setStroke(new BasicStroke((float) (1 / scale)));
+
+ this.drawField(g2, world);
+ this.drawPlayers(g2, world);
+ this.drawBullets(g2, world);
+ g2.setTransform(pre);
+ }
+
+ private AffineTransform getWorldTransform(Player player, double scale) {
+ AffineTransform tx = new AffineTransform();
+ tx.scale(scale, scale);
+ if (player.getTeam() != null) {
+ var dir = player.getTeam().getOrientation().perp();
+ tx.rotate(dir.x(), dir.y(), (this.getWidth() / scale / 2), (this.getHeight() / scale / 2));
+ }
+ double x = -player.getPosition().x() + (this.getWidth() / scale / 2);
+ double y = -player.getPosition().y() + (this.getHeight() / scale / 2);
+ tx.translate(x, y);
+ return tx;
+ }
+
+ private void drawField(Graphics2D g2, World world) {
+ g2.setColor(Color.LIGHT_GRAY);
+ g2.fill(new Rectangle2D.Double(0, 0, world.getSize().x(), world.getSize().y()));
+
+ g2.setColor(Color.DARK_GRAY);
+ for (Barricade b : world.getBarricades()) {
+ Rectangle2D.Double barricadeRect = new Rectangle2D.Double(
+ b.getPosition().x(),
+ b.getPosition().y(),
+ b.getSize().x(),
+ b.getSize().y()
+ );
+ g2.fill(barricadeRect);
+ }
+
+ for (Team t : world.getTeams()) {
+ g2.setColor(t.getColor());
+ Ellipse2D.Double spawnCircle = new Ellipse2D.Double(
+ t.getSpawnPoint().x() - Player.RADIUS,
+ t.getSpawnPoint().y() - Player.RADIUS,
+ Player.RADIUS * 2,
+ Player.RADIUS * 2
+ );
+ g2.draw(spawnCircle);
+ }
+ }
+
+ private void drawPlayers(Graphics2D g2, World world) {
+ for (Player p : world.getPlayers().values()) {
+ AffineTransform pre = g2.getTransform();
+ AffineTransform tx = g2.getTransform();
+
+ tx.translate(p.getPosition().x(), p.getPosition().y());
+ tx.rotate(p.getOrientation().x(), p.getOrientation().y());
+ g2.setTransform(tx);
+
+ Ellipse2D.Double dot = new Ellipse2D.Double(-Player.RADIUS, -Player.RADIUS, Player.RADIUS * 2, Player.RADIUS * 2);
+ Color playerColor = p.getTeam() != null ? p.getTeam().getColor() : Color.BLACK;
+ g2.setColor(playerColor);
+ g2.fill(dot);
+
+ g2.setColor(Color.GRAY);
+ Rectangle2D.Double gun = new Rectangle2D.Double(
+ 0,
+ 0.5,
+ 2,
+ 0.25
+ );
+ g2.fill(gun);
+
+ g2.setTransform(pre);
+ }
+ }
+
+ private void drawBullets(Graphics2D g2, World world) {
+ g2.setColor(Color.YELLOW);
+ double bulletSize = 0.5;
+ for (Bullet b : world.getBullets()) {
+ Ellipse2D.Double bulletShape = new Ellipse2D.Double(
+ b.getPosition().x() - bulletSize / 2,
+ b.getPosition().y() - bulletSize / 2,
+ bulletSize,
+ bulletSize
+ );
+ g2.fill(bulletShape);
+ }
+ }
+
+ private void drawChat(Graphics2D g2, String[] messages) {
+ int height = g2.getFontMetrics().getHeight();
+ int y = height;
+ g2.setColor(Color.WHITE);
+ for (String message : messages) {
+ g2.drawString(message, 5, y);
+ y += height;
+ }
+
+ if (this.client.isChatting()) {
+ g2.drawString("> " + this.client.getCurrentChatBuffer(), 5, height * 11);
+ }
+ }
+}
diff --git a/client/src/main/resources/sound/ak47shot1.wav b/client/src/main/resources/sound/ak47shot1.wav
new file mode 100644
index 0000000..fe9a259
Binary files /dev/null and b/client/src/main/resources/sound/ak47shot1.wav differ
diff --git a/client/src/main/resources/sound/bullet_impact_1.wav b/client/src/main/resources/sound/bullet_impact_1.wav
new file mode 100644
index 0000000..79de43e
Binary files /dev/null and b/client/src/main/resources/sound/bullet_impact_1.wav differ
diff --git a/client/src/main/resources/sound/bullet_impact_2.wav b/client/src/main/resources/sound/bullet_impact_2.wav
new file mode 100644
index 0000000..156fe5f
Binary files /dev/null and b/client/src/main/resources/sound/bullet_impact_2.wav differ
diff --git a/client/src/main/resources/sound/bullet_impact_3.wav b/client/src/main/resources/sound/bullet_impact_3.wav
new file mode 100644
index 0000000..14684cd
Binary files /dev/null and b/client/src/main/resources/sound/bullet_impact_3.wav differ
diff --git a/client/src/main/resources/sound/bullet_impact_4.wav b/client/src/main/resources/sound/bullet_impact_4.wav
new file mode 100644
index 0000000..67ce995
Binary files /dev/null and b/client/src/main/resources/sound/bullet_impact_4.wav differ
diff --git a/client/src/main/resources/sound/bullet_impact_5.wav b/client/src/main/resources/sound/bullet_impact_5.wav
new file mode 100644
index 0000000..7cfe1fa
Binary files /dev/null and b/client/src/main/resources/sound/bullet_impact_5.wav differ
diff --git a/client/src/main/resources/sound/chat.wav b/client/src/main/resources/sound/chat.wav
new file mode 100644
index 0000000..7b6c766
Binary files /dev/null and b/client/src/main/resources/sound/chat.wav differ
diff --git a/client/src/main/resources/sound/death.wav b/client/src/main/resources/sound/death.wav
new file mode 100644
index 0000000..8f5f2d1
Binary files /dev/null and b/client/src/main/resources/sound/death.wav differ
diff --git a/core/pom.xml b/core/pom.xml
new file mode 100644
index 0000000..9e938bd
--- /dev/null
+++ b/core/pom.xml
@@ -0,0 +1,19 @@
+
+
+
+ ace-of-shades
+ nl.andrewlalis
+ 1.0-SNAPSHOT
+
+ 4.0.0
+
+ aos-core
+
+
+ 16
+ 16
+
+
+
\ No newline at end of file
diff --git a/core/src/main/java/module-info.java b/core/src/main/java/module-info.java
new file mode 100644
index 0000000..3ce9b66
--- /dev/null
+++ b/core/src/main/java/module-info.java
@@ -0,0 +1,6 @@
+module aos_core {
+ requires java.desktop;
+ exports nl.andrewlalis.aos_core.net to aos_server, aos_client;
+ exports nl.andrewlalis.aos_core.model to aos_server, aos_client;
+ exports nl.andrewlalis.aos_core.geom to aos_server, aos_client;
+}
\ No newline at end of file
diff --git a/core/src/main/java/nl/andrewlalis/aos_core/geom/Vec2.java b/core/src/main/java/nl/andrewlalis/aos_core/geom/Vec2.java
new file mode 100644
index 0000000..8921a29
--- /dev/null
+++ b/core/src/main/java/nl/andrewlalis/aos_core/geom/Vec2.java
@@ -0,0 +1,59 @@
+package nl.andrewlalis.aos_core.geom;
+
+import java.io.Serializable;
+
+public record Vec2(double x, double y) implements Serializable {
+
+ public double mag() {
+ return Math.sqrt(x * x + y * y);
+ }
+
+ public Vec2 add(Vec2 other) {
+ return new Vec2(this.x + other.x, this.y + other.y);
+ }
+
+ public Vec2 sub(Vec2 other) {
+ return new Vec2(this.x - other.x, this.y - other.y);
+ }
+
+ public Vec2 mul(double factor) {
+ return new Vec2(this.x * factor, this.y * factor);
+ }
+
+ public Vec2 unit() {
+ double mag = this.mag();
+ return new Vec2(this.x / mag, this.y / mag);
+ }
+
+ public double dot(Vec2 other) {
+ return this.x * other.x + this.y * other.y;
+ }
+
+ public Vec2 perp() {
+ return new Vec2(-this.y, this.x);
+ }
+
+ public Vec2 perp2() {
+ return new Vec2(this.y, -this.x);
+ }
+
+ public double dist(Vec2 other) {
+ return other.sub(this).mag();
+ }
+
+ public Vec2 rotate(double theta) {
+ return new Vec2(
+ this.x * Math.cos(theta) - this.y * Math.sin(theta),
+ this.x * Math.sin(theta) + this.y * Math.cos(theta)
+ );
+ }
+
+ public double angle() {
+ return Math.atan2(this.y, this.x);
+ }
+
+ @Override
+ public String toString() {
+ return "[ " + x + ", " + y + " ]";
+ }
+}
diff --git a/core/src/main/java/nl/andrewlalis/aos_core/model/Barricade.java b/core/src/main/java/nl/andrewlalis/aos_core/model/Barricade.java
new file mode 100644
index 0000000..e694a24
--- /dev/null
+++ b/core/src/main/java/nl/andrewlalis/aos_core/model/Barricade.java
@@ -0,0 +1,35 @@
+package nl.andrewlalis.aos_core.model;
+
+import nl.andrewlalis.aos_core.geom.Vec2;
+
+import java.io.Serializable;
+
+public class Barricade implements Serializable {
+ /**
+ * The top-left position of this barricade, measured as the distance in
+ * meters from the top-left corner of the map.
+ */
+ private Vec2 position;
+
+ /**
+ * The size of the barricade, in meters.
+ */
+ private Vec2 size;
+
+ public Barricade(Vec2 position, Vec2 size) {
+ this.position = position;
+ this.size = size;
+ }
+
+ public Barricade(double x, double y, double w, double h) {
+ this(new Vec2(x, y), new Vec2(w, h));
+ }
+
+ public Vec2 getPosition() {
+ return position;
+ }
+
+ public Vec2 getSize() {
+ return size;
+ }
+}
diff --git a/core/src/main/java/nl/andrewlalis/aos_core/model/Bullet.java b/core/src/main/java/nl/andrewlalis/aos_core/model/Bullet.java
new file mode 100644
index 0000000..f1a5913
--- /dev/null
+++ b/core/src/main/java/nl/andrewlalis/aos_core/model/Bullet.java
@@ -0,0 +1,16 @@
+package nl.andrewlalis.aos_core.model;
+
+public class Bullet extends PhysicsObject {
+ public static final double SPEED = 100.0; // Meters per second.
+
+ private final int playerId;
+
+ public Bullet(Player player) {
+ super(player.getPosition().add(player.getOrientation().mul(1.5)), player.getOrientation(), player.getOrientation().mul(SPEED));
+ this.playerId = player.getId();
+ }
+
+ public int getPlayerId() {
+ return playerId;
+ }
+}
diff --git a/core/src/main/java/nl/andrewlalis/aos_core/model/Gun.java b/core/src/main/java/nl/andrewlalis/aos_core/model/Gun.java
new file mode 100644
index 0000000..50fb014
--- /dev/null
+++ b/core/src/main/java/nl/andrewlalis/aos_core/model/Gun.java
@@ -0,0 +1,5 @@
+package nl.andrewlalis.aos_core.model;
+
+public class Gun {
+
+}
diff --git a/core/src/main/java/nl/andrewlalis/aos_core/model/PhysicsObject.java b/core/src/main/java/nl/andrewlalis/aos_core/model/PhysicsObject.java
new file mode 100644
index 0000000..a8405c3
--- /dev/null
+++ b/core/src/main/java/nl/andrewlalis/aos_core/model/PhysicsObject.java
@@ -0,0 +1,48 @@
+package nl.andrewlalis.aos_core.model;
+
+import nl.andrewlalis.aos_core.geom.Vec2;
+
+import java.io.Serializable;
+
+/**
+ * Base class for all objects that have basic movement physics.
+ */
+public abstract class PhysicsObject implements Serializable {
+ private Vec2 position;
+ private Vec2 orientation;
+ private Vec2 velocity;
+
+ public PhysicsObject(Vec2 position, Vec2 orientation, Vec2 velocity) {
+ this.position = position;
+ this.orientation = orientation;
+ this.velocity = velocity;
+ }
+
+ public PhysicsObject() {
+ this(new Vec2(0, 0), new Vec2(0, -1), new Vec2(0, 0));
+ }
+
+ public Vec2 getPosition() {
+ return position;
+ }
+
+ public void setPosition(Vec2 position) {
+ this.position = position;
+ }
+
+ public Vec2 getOrientation() {
+ return orientation;
+ }
+
+ public void setOrientation(Vec2 orientation) {
+ this.orientation = orientation;
+ }
+
+ public Vec2 getVelocity() {
+ return velocity;
+ }
+
+ public void setVelocity(Vec2 velocity) {
+ this.velocity = velocity;
+ }
+}
diff --git a/core/src/main/java/nl/andrewlalis/aos_core/model/Player.java b/core/src/main/java/nl/andrewlalis/aos_core/model/Player.java
new file mode 100644
index 0000000..aee38d7
--- /dev/null
+++ b/core/src/main/java/nl/andrewlalis/aos_core/model/Player.java
@@ -0,0 +1,70 @@
+package nl.andrewlalis.aos_core.model;
+
+import java.util.Objects;
+
+public class Player extends PhysicsObject {
+ public static final double SHOT_COOLDOWN = 0.1; // Time between shots, in seconds.
+ public static final double MOVEMENT_SPEED = 10; // Movement speed, in m/s
+ public static final double RADIUS = 0.5; // Collision radius, in meters.
+
+ private final int id;
+ private final String name;
+ private Team team;
+ private PlayerControlState state;
+
+ private transient long lastShot;
+
+ public Player(int id, String name, Team team) {
+ this.id = id;
+ this.name = name;
+ this.team = team;
+ this.state = new PlayerControlState();
+ this.state.setPlayerId(this.id);
+ this.updateLastShot();
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setState(PlayerControlState state) {
+ this.state = state;
+ }
+
+ public PlayerControlState getState() {
+ return state;
+ }
+
+ public Team getTeam() {
+ return team;
+ }
+
+ public void setTeam(Team team) {
+ this.team = team;
+ }
+
+ public long getLastShot() {
+ return lastShot;
+ }
+
+ public void updateLastShot() {
+ this.lastShot = System.currentTimeMillis();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Player player = (Player) o;
+ return getId() == player.getId();
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(getId());
+ }
+}
diff --git a/core/src/main/java/nl/andrewlalis/aos_core/model/PlayerControlState.java b/core/src/main/java/nl/andrewlalis/aos_core/model/PlayerControlState.java
new file mode 100644
index 0000000..56d279b
--- /dev/null
+++ b/core/src/main/java/nl/andrewlalis/aos_core/model/PlayerControlState.java
@@ -0,0 +1,74 @@
+package nl.andrewlalis.aos_core.model;
+
+import nl.andrewlalis.aos_core.geom.Vec2;
+
+import java.io.Serializable;
+
+public class PlayerControlState implements Serializable {
+ private int playerId;
+
+ boolean movingLeft;
+ boolean movingRight;
+ boolean movingForward;
+ boolean movingBackward;
+
+ boolean shooting;
+
+ Vec2 mouseLocation;
+
+ public int getPlayerId() {
+ return playerId;
+ }
+
+ public void setPlayerId(int playerId) {
+ this.playerId = playerId;
+ }
+
+ public boolean isMovingLeft() {
+ return movingLeft;
+ }
+
+ public void setMovingLeft(boolean movingLeft) {
+ this.movingLeft = movingLeft;
+ }
+
+ public boolean isMovingRight() {
+ return movingRight;
+ }
+
+ public void setMovingRight(boolean movingRight) {
+ this.movingRight = movingRight;
+ }
+
+ public boolean isMovingForward() {
+ return movingForward;
+ }
+
+ public void setMovingForward(boolean movingForward) {
+ this.movingForward = movingForward;
+ }
+
+ public boolean isMovingBackward() {
+ return movingBackward;
+ }
+
+ public void setMovingBackward(boolean movingBackward) {
+ this.movingBackward = movingBackward;
+ }
+
+ public boolean isShooting() {
+ return shooting;
+ }
+
+ public void setShooting(boolean shooting) {
+ this.shooting = shooting;
+ }
+
+ public Vec2 getMouseLocation() {
+ return mouseLocation;
+ }
+
+ public void setMouseLocation(Vec2 mouseLocation) {
+ this.mouseLocation = mouseLocation;
+ }
+}
diff --git a/core/src/main/java/nl/andrewlalis/aos_core/model/Team.java b/core/src/main/java/nl/andrewlalis/aos_core/model/Team.java
new file mode 100644
index 0000000..ff9612e
--- /dev/null
+++ b/core/src/main/java/nl/andrewlalis/aos_core/model/Team.java
@@ -0,0 +1,45 @@
+package nl.andrewlalis.aos_core.model;
+
+import nl.andrewlalis.aos_core.geom.Vec2;
+
+import java.awt.*;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+public class Team implements Serializable {
+ private final String name;
+ private final java.awt.Color color;
+ private final Vec2 spawnPoint;
+ private final Vec2 orientation;
+
+ private final List players;
+
+ public Team(String name, Color color, Vec2 spawnPoint, Vec2 orientation) {
+ this.name = name;
+ this.color = color;
+ this.spawnPoint = spawnPoint;
+ this.orientation = orientation;
+ this.players = new ArrayList<>();
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public Color getColor() {
+ return color;
+ }
+
+ public Vec2 getSpawnPoint() {
+ return spawnPoint;
+ }
+
+ public Vec2 getOrientation() {
+ return orientation;
+ }
+
+ public List getPlayers() {
+ return players;
+ }
+}
diff --git a/core/src/main/java/nl/andrewlalis/aos_core/model/World.java b/core/src/main/java/nl/andrewlalis/aos_core/model/World.java
new file mode 100644
index 0000000..f9ce69d
--- /dev/null
+++ b/core/src/main/java/nl/andrewlalis/aos_core/model/World.java
@@ -0,0 +1,56 @@
+package nl.andrewlalis.aos_core.model;
+
+import nl.andrewlalis.aos_core.geom.Vec2;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The main game world, consisting of all players and other objects in the game.
+ */
+public class World implements Serializable {
+ private final Vec2 size;
+
+ private final List teams;
+ private final Map players;
+ private final List bullets;
+ private final List barricades;
+
+ private final List soundsToPlay;
+
+ public World(Vec2 size) {
+ this.size = size;
+ this.teams = new ArrayList<>();
+ this.players = new HashMap<>();
+ this.bullets = new ArrayList<>();
+ this.barricades = new ArrayList<>();
+ this.soundsToPlay = new ArrayList<>();
+ }
+
+ public Vec2 getSize() {
+ return size;
+ }
+
+ public List getTeams() {
+ return teams;
+ }
+
+ public Map getPlayers() {
+ return this.players;
+ }
+
+ public List getBullets() {
+ return bullets;
+ }
+
+ public List getBarricades() {
+ return barricades;
+ }
+
+ public List getSoundsToPlay() {
+ return soundsToPlay;
+ }
+}
diff --git a/core/src/main/java/nl/andrewlalis/aos_core/net/ChatMessage.java b/core/src/main/java/nl/andrewlalis/aos_core/net/ChatMessage.java
new file mode 100644
index 0000000..3c3f333
--- /dev/null
+++ b/core/src/main/java/nl/andrewlalis/aos_core/net/ChatMessage.java
@@ -0,0 +1,14 @@
+package nl.andrewlalis.aos_core.net;
+
+public class ChatMessage extends Message {
+ private final String text;
+
+ public ChatMessage(String text) {
+ super(Type.CHAT);
+ this.text = text;
+ }
+
+ public String getText() {
+ return text;
+ }
+}
diff --git a/core/src/main/java/nl/andrewlalis/aos_core/net/IdentMessage.java b/core/src/main/java/nl/andrewlalis/aos_core/net/IdentMessage.java
new file mode 100644
index 0000000..a603834
--- /dev/null
+++ b/core/src/main/java/nl/andrewlalis/aos_core/net/IdentMessage.java
@@ -0,0 +1,20 @@
+package nl.andrewlalis.aos_core.net;
+
+public class IdentMessage extends Message {
+ private final String name;
+ private final int datagramPort;
+
+ public IdentMessage(String name, int datagramPort) {
+ super(Type.IDENT);
+ this.name = name;
+ this.datagramPort = datagramPort;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public int getDatagramPort() {
+ return datagramPort;
+ }
+}
diff --git a/core/src/main/java/nl/andrewlalis/aos_core/net/Message.java b/core/src/main/java/nl/andrewlalis/aos_core/net/Message.java
new file mode 100644
index 0000000..8faf04e
--- /dev/null
+++ b/core/src/main/java/nl/andrewlalis/aos_core/net/Message.java
@@ -0,0 +1,22 @@
+package nl.andrewlalis.aos_core.net;
+
+import java.io.Serializable;
+
+public class Message implements Serializable {
+ private final Type type;
+
+ public Message(Type type) {
+ this.type = type;
+ }
+
+ public Type getType() {
+ return type;
+ }
+
+ @Override
+ public String toString() {
+ return "Message{" +
+ "type=" + type +
+ '}';
+ }
+}
diff --git a/core/src/main/java/nl/andrewlalis/aos_core/net/PlayerRegisteredMessage.java b/core/src/main/java/nl/andrewlalis/aos_core/net/PlayerRegisteredMessage.java
new file mode 100644
index 0000000..6dfbbce
--- /dev/null
+++ b/core/src/main/java/nl/andrewlalis/aos_core/net/PlayerRegisteredMessage.java
@@ -0,0 +1,14 @@
+package nl.andrewlalis.aos_core.net;
+
+public class PlayerRegisteredMessage extends Message {
+ private final int playerId;
+
+ public PlayerRegisteredMessage(int playerId) {
+ super(Type.PLAYER_REGISTERED);
+ this.playerId = playerId;
+ }
+
+ public int getPlayerId() {
+ return playerId;
+ }
+}
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
new file mode 100644
index 0000000..e226d1e
--- /dev/null
+++ b/core/src/main/java/nl/andrewlalis/aos_core/net/Type.java
@@ -0,0 +1,8 @@
+package nl.andrewlalis.aos_core.net;
+
+public enum Type {
+ IDENT,
+ ACK,
+ PLAYER_REGISTERED,
+ CHAT
+}
diff --git a/core/src/main/java/nl/andrewlalis/aos_core/package-info.java b/core/src/main/java/nl/andrewlalis/aos_core/package-info.java
new file mode 100644
index 0000000..87affef
--- /dev/null
+++ b/core/src/main/java/nl/andrewlalis/aos_core/package-info.java
@@ -0,0 +1,5 @@
+/**
+ * This is the main package of the core application, which contains all the info
+ * needed for both the client and the server to work together.
+ */
+package nl.andrewlalis.aos_core;
\ No newline at end of file
diff --git a/core/src/main/java/nl/andrewlalis/aos_core/util/ByteUtils.java b/core/src/main/java/nl/andrewlalis/aos_core/util/ByteUtils.java
new file mode 100644
index 0000000..1214f38
--- /dev/null
+++ b/core/src/main/java/nl/andrewlalis/aos_core/util/ByteUtils.java
@@ -0,0 +1,48 @@
+package nl.andrewlalis.aos_core.util;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+
+public class ByteUtils {
+ public static byte[] toBytes(int x) {
+ return new byte[] {
+ (byte) (x >> 24),
+ (byte) (x >> 16),
+ (byte) (x >> 8),
+ (byte) x
+ };
+ }
+
+ public static int intFromBytes(byte[] bytes) {
+ return ((bytes[0] & 0xFF) << 24) |
+ ((bytes[1] & 0xFF) << 16) |
+ ((bytes[2] & 0xFF) << 8 ) |
+ ((bytes[3] & 0xFF));
+ }
+
+ public static void write(int x, OutputStream os) throws IOException {
+ os.write(toBytes(x));
+ }
+
+ public static int readInt(InputStream is) throws IOException {
+ byte[] bytes = new byte[4];
+ int n = is.read(bytes);
+ if (n < bytes.length) throw new IOException("Could not read enough bytes to read an integer.");
+ return intFromBytes(bytes);
+ }
+
+ public static void write(String s, OutputStream os) throws IOException {
+ write(s.length(), os);
+ os.write(s.getBytes(StandardCharsets.UTF_8));
+ }
+
+ public static String readString(InputStream is) throws IOException {
+ int length = readInt(is);
+ byte[] strBytes = new byte[length];
+ int n = is.read(strBytes);
+ if (n != length) throw new IOException("Could not read enough bytes to read string.");
+ return new String(strBytes, StandardCharsets.UTF_8);
+ }
+}
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..60f9a87
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,34 @@
+
+
+ 4.0.0
+
+ nl.andrewlalis
+ ace-of-shades
+ pom
+ 1.0-SNAPSHOT
+
+ server
+ client
+ core
+
+
+
+ 16
+ 16
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.8.1
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/server/pom.xml b/server/pom.xml
new file mode 100644
index 0000000..bf9b7ce
--- /dev/null
+++ b/server/pom.xml
@@ -0,0 +1,55 @@
+
+
+
+ ace-of-shades
+ nl.andrewlalis
+ 1.0-SNAPSHOT
+
+ 4.0.0
+
+ aos-server
+
+
+ 16
+ 16
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-assembly-plugin
+ 3.3.0
+
+
+
+ nl.andrewlalis.aos_server.Server
+
+
+
+ jar-with-dependencies
+
+
+
+
+ make-assembly
+ package
+
+ single
+
+
+
+
+
+
+
+
+
+ nl.andrewlalis
+ aos-core
+ ${parent.version}
+
+
+
\ No newline at end of file
diff --git a/server/src/main/java/module-info.java b/server/src/main/java/module-info.java
new file mode 100644
index 0000000..9d1ef4b
--- /dev/null
+++ b/server/src/main/java/module-info.java
@@ -0,0 +1,5 @@
+module aos_server {
+ requires java.logging;
+ requires aos_core;
+ requires java.desktop;
+}
\ No newline at end of file
diff --git a/server/src/main/java/nl/andrewlalis/aos_server/ClientHandler.java b/server/src/main/java/nl/andrewlalis/aos_server/ClientHandler.java
new file mode 100644
index 0000000..8fbff1d
--- /dev/null
+++ b/server/src/main/java/nl/andrewlalis/aos_server/ClientHandler.java
@@ -0,0 +1,78 @@
+package nl.andrewlalis.aos_server;
+
+import nl.andrewlalis.aos_core.net.*;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.net.Socket;
+
+public class ClientHandler extends Thread {
+ private final Server server;
+ private final Socket socket;
+ private final ObjectOutputStream out;
+ private final ObjectInputStream in;
+
+ private int datagramPort = -1;
+ private int playerId;
+
+ private volatile boolean running = true;
+
+ public ClientHandler(Server server, Socket socket) throws IOException {
+ this.server = server;
+ this.socket = socket;
+ this.out = new ObjectOutputStream(socket.getOutputStream());
+ this.in = new ObjectInputStream(socket.getInputStream());
+ }
+
+ public Socket getSocket() {
+ return socket;
+ }
+
+ public int getDatagramPort() {
+ return datagramPort;
+ }
+
+ public int getPlayerId() {
+ return playerId;
+ }
+
+ public void shutdown() {
+ this.running = false;
+ }
+
+ public void send(Message message) throws IOException {
+ this.out.writeObject(message);
+ }
+
+ @Override
+ public void run() {
+ try {
+ while (this.running) {
+ try {
+ Message msg = (Message) this.in.readObject();
+ if (msg.getType() == Type.IDENT) {
+ IdentMessage ident = (IdentMessage) msg;
+ int id = this.server.registerNewPlayer(ident.getName());
+ this.playerId = id;
+ this.datagramPort = ident.getDatagramPort();
+ this.send(new PlayerRegisteredMessage(id));
+ } else if (msg.getType() == Type.CHAT) {
+ this.server.broadcastPlayerChat(this.playerId, (ChatMessage) msg);
+ }
+ } catch (ClassNotFoundException e) {
+ e.printStackTrace();
+ }
+ }
+ } catch (IOException e) {
+ // Ignore this exception, consider the client disconnected.
+ }
+ this.datagramPort = -1;
+ try {
+ this.socket.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ this.server.clientDisconnected(this);
+ }
+}
diff --git a/server/src/main/java/nl/andrewlalis/aos_server/DatagramCommunicationThread.java b/server/src/main/java/nl/andrewlalis/aos_server/DatagramCommunicationThread.java
new file mode 100644
index 0000000..bb7dd09
--- /dev/null
+++ b/server/src/main/java/nl/andrewlalis/aos_server/DatagramCommunicationThread.java
@@ -0,0 +1,41 @@
+package nl.andrewlalis.aos_server;
+
+import nl.andrewlalis.aos_core.model.PlayerControlState;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.net.DatagramPacket;
+import java.net.DatagramSocket;
+import java.net.SocketException;
+
+public class DatagramCommunicationThread extends Thread {
+ private final Server server;
+ private final DatagramSocket socket;
+
+ public DatagramCommunicationThread(Server server, int port) throws SocketException {
+ this.server = server;
+ this.socket = new DatagramSocket(port);
+ }
+
+ public DatagramSocket getSocket() {
+ return socket;
+ }
+
+ @Override
+ public void run() {
+ byte[] buffer = new byte[8192];
+ DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
+ while (true) {
+ try {
+ this.socket.receive(packet);
+ Object obj = new ObjectInputStream(new ByteArrayInputStream(buffer)).readObject();
+ if (obj instanceof PlayerControlState) {
+ this.server.updatePlayerState((PlayerControlState) obj);
+ }
+ } catch (IOException | ClassNotFoundException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+}
diff --git a/server/src/main/java/nl/andrewlalis/aos_server/Server.java b/server/src/main/java/nl/andrewlalis/aos_server/Server.java
new file mode 100644
index 0000000..2cab056
--- /dev/null
+++ b/server/src/main/java/nl/andrewlalis/aos_server/Server.java
@@ -0,0 +1,161 @@
+package nl.andrewlalis.aos_server;
+
+import nl.andrewlalis.aos_core.geom.Vec2;
+import nl.andrewlalis.aos_core.model.*;
+import nl.andrewlalis.aos_core.net.ChatMessage;
+import nl.andrewlalis.aos_core.net.Message;
+
+import java.awt.*;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.ObjectOutputStream;
+import java.net.DatagramPacket;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Scanner;
+import java.util.concurrent.ThreadLocalRandom;
+
+public class Server {
+ public static final int DEFAULT_PORT = 8035;
+
+ private final List clientHandlers;
+ private final ServerSocket serverSocket;
+ private final DatagramCommunicationThread datagramCommunicationThread;
+ private final World world;
+ private final WorldUpdater worldUpdater;
+
+ public Server(int port) throws IOException {
+ this.clientHandlers = new ArrayList<>();
+ this.serverSocket = new ServerSocket(port);
+ this.datagramCommunicationThread = new DatagramCommunicationThread(this, port);
+
+ this.world = new World(new Vec2(50, 70));
+ world.getBarricades().add(new Barricade(10, 10, 30, 5));
+ world.getBarricades().add(new Barricade(10, 55, 30, 5));
+ world.getBarricades().add(new Barricade(20, 30, 10, 10));
+ world.getBarricades().add(new Barricade(0, 30, 10, 10));
+ world.getBarricades().add(new Barricade(40, 30, 10, 10));
+
+ world.getTeams().add(new Team("Red", Color.RED, new Vec2(3, 3), new Vec2(0, 1)));
+ world.getTeams().add(new Team("Blue", Color.BLUE, new Vec2(world.getSize().x() - 3, world.getSize().y() - 3), new Vec2(0, -1)));
+
+ this.worldUpdater = new WorldUpdater(this, this.world);
+ System.out.println("Started AOS-Server TCP/UDP on port " + port);
+ }
+
+ public void acceptClientConnection() throws IOException {
+ Socket socket = this.serverSocket.accept();
+ var t = new ClientHandler(this, socket);
+ t.start();
+ synchronized (this.clientHandlers) {
+ this.clientHandlers.add(t);
+ }
+ }
+
+ public int registerNewPlayer(String name) {
+ int id = ThreadLocalRandom.current().nextInt(1, Integer.MAX_VALUE);
+ Team team = null;
+ for (Team t : this.world.getTeams()) {
+ if (team == null) {
+ team = t;
+ } else if (t.getPlayers().size() < team.getPlayers().size()) {
+ team = t;
+ }
+ }
+ Player p = new Player(id, name, team);
+ System.out.println("Client connected: " + p.getId() + ", " + p.getName());
+ this.broadcastMessage(new ChatMessage(name + " connected."));
+ this.world.getPlayers().put(p.getId(), p);
+ p.setPosition(new Vec2(this.world.getSize().x() / 2.0, this.world.getSize().y() / 2.0));
+ if (team != null) {
+ team.getPlayers().add(p);
+ p.setPosition(team.getSpawnPoint());
+ p.setOrientation(team.getOrientation());
+ this.broadcastMessage(new ChatMessage(name + " joined team " + team.getName()));
+ System.out.println("Player joined team " + team.getName());
+ }
+ return id;
+ }
+
+ public void clientDisconnected(ClientHandler clientHandler) {
+ Player player = this.world.getPlayers().get(clientHandler.getPlayerId());
+ synchronized (this.clientHandlers) {
+ this.clientHandlers.remove(clientHandler);
+ clientHandler.shutdown();
+ }
+ this.world.getPlayers().remove(player.getId());
+ if (player.getTeam() != null) {
+ player.getTeam().getPlayers().remove(player);
+ }
+ this.broadcastMessage(new ChatMessage(player.getName() + " disconnected."));
+ System.out.println("Client disconnected: " + player.getId() + ", " + player.getName());
+ }
+
+ public void sendWorldToClients() {
+ try {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ new ObjectOutputStream(bos).writeObject(this.world);
+ byte[] data = bos.toByteArray();
+ DatagramPacket packet = new DatagramPacket(data, data.length);
+ for (ClientHandler handler : this.clientHandlers) {
+ if (handler.getDatagramPort() == -1) continue;
+ packet.setAddress(handler.getSocket().getInetAddress());
+ packet.setPort(handler.getDatagramPort());
+ this.datagramCommunicationThread.getSocket().send(packet);
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ public void updatePlayerState(PlayerControlState state) {
+ Player p = this.world.getPlayers().get(state.getPlayerId());
+ if (p != null) {
+ p.setState(state);
+ }
+ }
+
+ public void broadcastMessage(Message message) {
+ for (ClientHandler handler : this.clientHandlers) {
+ try {
+ handler.send(message);
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+
+ public void broadcastPlayerChat(int playerId, ChatMessage msg) {
+ Player p = this.world.getPlayers().get(playerId);
+ if (p == null) return;
+ this.broadcastMessage(new ChatMessage(p.getName() + ": " + msg.getText()));
+ }
+
+
+
+ public static void main(String[] args) throws IOException {
+ System.out.println("Enter the port number to start the server on, or blank for default (" + DEFAULT_PORT + "):");
+ Scanner sc = new Scanner(System.in);
+ String input = sc.nextLine();
+ int port = DEFAULT_PORT;
+ if (input != null && !input.isBlank()) {
+ try {
+ port = Integer.parseInt(input.trim());
+ } catch (NumberFormatException e) {
+ System.err.println("Invalid port.");
+ return;
+ }
+ }
+
+ Server server = new Server(port);
+ server.datagramCommunicationThread.start();
+ server.worldUpdater.start();
+ while (true) {
+ server.acceptClientConnection();
+ }
+ }
+
+
+}
diff --git a/server/src/main/java/nl/andrewlalis/aos_server/WorldUpdater.java b/server/src/main/java/nl/andrewlalis/aos_server/WorldUpdater.java
new file mode 100644
index 0000000..18868d7
--- /dev/null
+++ b/server/src/main/java/nl/andrewlalis/aos_server/WorldUpdater.java
@@ -0,0 +1,168 @@
+package nl.andrewlalis.aos_server;
+
+import nl.andrewlalis.aos_core.geom.Vec2;
+import nl.andrewlalis.aos_core.model.Barricade;
+import nl.andrewlalis.aos_core.model.Bullet;
+import nl.andrewlalis.aos_core.model.Player;
+import nl.andrewlalis.aos_core.model.World;
+import nl.andrewlalis.aos_core.net.ChatMessage;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ThreadLocalRandom;
+
+public class WorldUpdater extends Thread {
+ public static final double TARGET_TPS = 120.0;
+ public static final double MS_PER_TICK = 1000.0 / TARGET_TPS;
+
+ private final Server server;
+ private final World world;
+ private volatile boolean running = true;
+
+ public WorldUpdater(Server server, World world) {
+ this.server = server;
+ this.world = world;
+ }
+
+ public void shutdown() {
+ this.running = false;
+ }
+
+ @Override
+ public void run() {
+ long lastTick = System.currentTimeMillis();
+ while (this.running) {
+ long now = System.currentTimeMillis();
+ long msSinceLastTick = now - lastTick;
+ if (msSinceLastTick >= MS_PER_TICK) {
+ double elapsedSeconds = msSinceLastTick / 1000.0;
+ this.tick(elapsedSeconds);
+ lastTick = now;
+ }
+ long msUntilNextTick = (long) (MS_PER_TICK - msSinceLastTick);
+ if (msUntilNextTick > 0) {
+ try {
+ Thread.sleep(msUntilNextTick);
+ } catch (InterruptedException e) {
+ System.err.println("Interrupted while sleeping until next tick: " + e.getMessage());
+ }
+ }
+ }
+ }
+
+ private void tick(double t) {
+ world.getSoundsToPlay().clear();
+ this.updateBullets(t);
+ this.updatePlayers(t);
+ this.server.sendWorldToClients();
+ }
+
+ private void updatePlayers(double t) {
+ for (Player p : this.world.getPlayers().values()) {
+ this.updatePlayerMovement(p, t);
+ this.updatePlayerShooting(p);
+ }
+ }
+
+ private void updatePlayerMovement(Player p, double t) {
+ if (p.getState().getMouseLocation() != null && p.getState().getMouseLocation().mag() > 0) {
+ Vec2 newOrientation = p.getState().getMouseLocation().unit();
+ if (p.getTeam() != null) {
+ double theta = p.getTeam().getOrientation().rotate(Math.PI / 2).angle();
+ newOrientation = newOrientation.rotate(-theta);
+ }
+ p.setOrientation(newOrientation);
+ }
+ double vx = 0;
+ double vy = 0;
+ if (p.getState().isMovingForward()) vy += Player.MOVEMENT_SPEED;
+ if (p.getState().isMovingBackward()) vy -= Player.MOVEMENT_SPEED;
+ if (p.getState().isMovingLeft()) vx -= Player.MOVEMENT_SPEED;
+ if (p.getState().isMovingRight()) vx += Player.MOVEMENT_SPEED;
+ Vec2 forwardVector = p.getOrientation().mul(vy);
+ Vec2 leftVector = p.getOrientation().perp().mul(vx);
+ Vec2 newPos = p.getPosition().add(forwardVector.mul(t)).add(leftVector.mul(t));
+ double nx = newPos.x();
+ double ny = newPos.y();
+
+ for (Barricade b : world.getBarricades()) {
+ // TODO: Improve barricade collision smoothness.
+ double x1 = b.getPosition().x();
+ double x2 = x1 + b.getSize().x();
+ double y1 = b.getPosition().y();
+ double y2 = y1 + b.getSize().y();
+ if (nx + Player.RADIUS > x1 && nx - Player.RADIUS < x2 && ny + Player.RADIUS > y1 && ny - Player.RADIUS < y2) {
+ double distanceLeft = Math.abs(nx - x1);
+ double distanceRight = Math.abs(nx - x2);
+ double distanceTop = Math.abs(ny - y1);
+ double distanceBottom = Math.abs(ny - y2);
+ if (distanceLeft < Player.RADIUS) {
+ nx = x1 - Player.RADIUS;
+ } else if (distanceRight < Player.RADIUS) {
+ nx = x2 + Player.RADIUS;
+ } else if (distanceTop < Player.RADIUS) {
+ ny = y1 - Player.RADIUS;
+ } else if (distanceBottom < Player.RADIUS) {
+ ny = y2 + Player.RADIUS;
+ }
+ }
+ }
+
+ if (nx - Player.RADIUS < 0) nx = Player.RADIUS;
+ if (nx + Player.RADIUS > this.world.getSize().x()) nx = this.world.getSize().x() - Player.RADIUS;
+ if (ny - Player.RADIUS < 0) ny = Player.RADIUS;
+ if (ny + Player.RADIUS > this.world.getSize().y()) ny = this.world.getSize().y() - Player.RADIUS;
+ p.setPosition(new Vec2(nx, ny));
+ }
+
+ private void updatePlayerShooting(Player p) {
+ if (p.getState().isShooting() && p.getLastShot() + Player.SHOT_COOLDOWN * 1000 < System.currentTimeMillis()) {
+ this.world.getBullets().add(new Bullet(p));
+ this.world.getSoundsToPlay().add("ak47shot1.wav");
+ p.updateLastShot();
+ }
+ }
+
+ private void updateBullets(double t) {
+ List bulletsToRemove = new ArrayList<>();
+ for (Bullet b : this.world.getBullets()) {
+ Vec2 oldPos = b.getPosition();
+ b.setPosition(b.getPosition().add(b.getVelocity().mul(t)));
+ Vec2 pos = b.getPosition();
+ if (pos.x() < 0 || pos.y() < 0 || pos.x() > this.world.getSize().x() || pos.y() > this.world.getSize().y()) {
+ bulletsToRemove.add(b);
+ }
+ for (Barricade bar : this.world.getBarricades()) {
+ if (
+ pos.x() > bar.getPosition().x() && pos.x() < bar.getPosition().x() + bar.getSize().x() &&
+ pos.y() > bar.getPosition().y() && pos.y() < bar.getPosition().y() + bar.getSize().y()
+ ) {
+ int n = ThreadLocalRandom.current().nextInt(1, 6);
+ this.world.getSoundsToPlay().add("bullet_impact_" + n + ".wav");
+ bulletsToRemove.add(b);
+ break;
+ }
+ }
+
+ double x1 = oldPos.x();
+ double x2 = b.getPosition().x();
+ double y1 = oldPos.y();
+ double y2 = b.getPosition().y();
+ double lineDist = oldPos.dist(b.getPosition());
+ for (Player p : this.world.getPlayers().values()) {
+ double n = ((p.getPosition().x() - x1) * (x2 - x1) + (p.getPosition().y() - y1) * (y2 - y1)) / lineDist;
+ n = Math.max(Math.min(n, 1), 0);
+ double dist = p.getPosition().dist(new Vec2(x1 + n * (x2 - x1), y1 + n * (y2 - y1)));
+ if (dist < Player.RADIUS) {
+ Player killer = this.world.getPlayers().get(b.getPlayerId());
+ this.server.broadcastMessage(new ChatMessage(p.getName() + " was shot by " + killer.getName() + "."));
+ world.getSoundsToPlay().add("death.wav");
+ if (p.getTeam() != null) {
+ p.setPosition(p.getTeam().getSpawnPoint());
+ }
+ }
+ }
+ }
+ this.world.getBullets().removeAll(bulletsToRemove);
+ }
+}