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); + } +}