Compare commits
47 Commits
Author | SHA1 | Date |
---|---|---|
|
3e20031e11 | |
|
23d4cd6c8f | |
|
719f2a8edf | |
|
b99047d696 | |
|
532aacdd11 | |
|
ec86b8cf69 | |
|
c6d54d3f38 | |
|
f9d3d35be4 | |
|
0e926d628a | |
|
4481f1c028 | |
|
fa8c553041 | |
|
6df046dfea | |
|
cbcc8b3db1 | |
|
12daa6ab1b | |
|
b3483ade7e | |
|
6a6d367054 | |
|
7c62812783 | |
|
4ca67ad051 | |
|
b642a9b313 | |
|
4a55be5a7e | |
|
b8311cd0b5 | |
|
3b9519cbca | |
|
6e19524cd9 | |
|
a9e032119c | |
|
b72a5a8b7a | |
|
b0a289e35f | |
|
9c5cde199e | |
|
10eed8e8cd | |
|
93e623d5d4 | |
|
f50a2d72a3 | |
|
6eeb69d500 | |
|
04106657d4 | |
|
ac8b269fc8 | |
|
71a2cca824 | |
|
fb52091aa9 | |
|
dec56be4af | |
|
ee6d7a00cb | |
|
4e01673bf8 | |
|
09f5630a0c | |
|
fa001996ff | |
|
34dc31fbe6 | |
|
54e9ecdb5b | |
|
94f13c0753 | |
|
a4d2730c80 | |
|
4bc994d07e | |
|
bd02d89995 | |
|
49bb724f65 |
.github/ISSUE_TEMPLATE
.gitignoreREADME.mdclient
package.ps1pom.xml
src/main
java
module-info.java
nl/andrewlalis/aos_client
Client.javaMessageTransceiver.javaSoundManager.javaTester.java
control
launcher
Launcher.java
servers
net
sound
view
resources/nl/andrewlalis/aos_client
core
pom.xml
help.mdpom.xmlsrc/main/java
module-info.java
nl/andrewlalis/aos_core
server-registry
pom.xml
src/main
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
name: Bug Report
|
||||
about: Create a report to address an issue with the game.
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Evidence**
|
||||
If possible, add screenshots or videos showcasing the bug here.
|
||||
|
||||
**System Information**
|
||||
Your OS, Java version, hardware, etc.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
|
@ -2,4 +2,10 @@
|
|||
client/target/
|
||||
core/target/
|
||||
server/target/
|
||||
/*.iml
|
||||
server-registry/target/
|
||||
*.iml
|
||||
|
||||
# Server files for testing.
|
||||
/settings.yaml
|
||||
/*.log
|
||||
/icon.png
|
||||
|
|
28
README.md
28
README.md
|
@ -1,11 +1,27 @@
|
|||
# Ace Of Shades
|
||||
Top-Down 2D Team-Deathmatch shooter inspired by Ace of Spades
|
||||
# Ace of Shades
|
||||
Top-down 2D team-deathmatch shooter inspired by Ace of Spades, and originally made for the Java Discord server's June 2021 JavaJam.
|
||||
|
||||
## Download and Play
|
||||
|
||||
Here's a short demo video:
|
||||
Go to [releases](https://github.com/andrewlalis/AceOfShades/releases) to download the application (server and client). *This game requires [Java 16](https://adoptopenjdk.net/?variant=openjdk16&jvmVariant=hotspot)!*
|
||||
|
||||
https://user-images.githubusercontent.com/9953867/122651637-66ec4280-d13a-11eb-9e46-f9324c70c011.mp4
|
||||
For gameplay help and information on how to set up your own server, please see the [help page](https://github.com/andrewlalis/AceOfShades/blob/main/help.md).
|
||||
|
||||
This video game was made for the Java Discord server's June JavaJam.
|
||||
## Program Structure
|
||||
|
||||
Go to [releases](https://github.com/andrewlalis/AceOfShades/releases) to download the application (server and client). Note that this runs with Java 16!
|
||||
Ace of Shades is a modular application using Java 16 and multiple Maven modules for the different parts of the game. The modules listed below can be found under a directory of the same name inside the root of this project.
|
||||
|
||||
- **core** - Contains any utility classes that are used by both the server and client, like vectors and network message objects.
|
||||
- **server** - Multiplayer server that runs an instance of the game that clients can connect to. Includes all game logic.
|
||||
- **client** - The program that's run by a single user playing the game. This includes all of the game's rendering code.
|
||||
- **server-registry** - An HTTP server that acts as a global registry of game servers. Clients can query the registry for a list of available public servers, and servers can upload their metadata to the registry so that clients can see and connect to them.
|
||||
|
||||
## Contributing
|
||||
|
||||
This project is, and always will remain open source, and contributions are very much welcome! Take a look at the [list of issues](https://github.com/andrewlalis/AceOfShades/issues) to see if there's something you might be able to improve.
|
||||
|
||||
To submit your contribution, fork the repository, make your change, and then create a pull request to the main repository. Make sure to reference the issue that your pull request is for.
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
To report bugs or other issues you encounter while playing, please [create a new Bug Report here](https://github.com/andrewlalis/AceOfShades/issues/new/choose).
|
|
@ -0,0 +1,27 @@
|
|||
# This script prepares and runs the jpackage command to generate a Windows AOS Client installer.
|
||||
|
||||
Push-Location $PSScriptRoot\target
|
||||
|
||||
# Remove existing file if it exists.
|
||||
Write-Output "Removing existing exe file."
|
||||
Get-ChildItem *.exe | ForEach-Object { Remove-Item -Path $_.FullName -Force }
|
||||
Write-Output "Done."
|
||||
|
||||
# Get list of dependency modules that maven copied into the lib directory.
|
||||
$modules = Get-ChildItem -Path lib -Name | ForEach-Object { "lib\$_" }
|
||||
# Add our own main module.
|
||||
$mainModuleJar = Get-ChildItem -Name -Include "aos-client-*.jar" -Exclude "*-jar-with-dependencies.jar"
|
||||
$modules += $mainModuleJar
|
||||
Write-Output "Found modules: $modules"
|
||||
$modulePath = $modules -join ';'
|
||||
|
||||
Write-Output "Running jpackage..."
|
||||
jpackage `
|
||||
--type exe `
|
||||
--name "Ace-of-Shades" `
|
||||
--description "Top-down 2D shooter game inspired by Ace of Spades." `
|
||||
--module-path "$modulePath" `
|
||||
--module aos_client/nl.andrewlalis.aos_client.launcher.Launcher `
|
||||
--win-shortcut `
|
||||
--win-dir-chooser
|
||||
Write-Output "Done!"
|
|
@ -5,7 +5,7 @@
|
|||
<parent>
|
||||
<artifactId>ace-of-shades</artifactId>
|
||||
<groupId>nl.andrewlalis</groupId>
|
||||
<version>2.1</version>
|
||||
<version>0.5.0</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
|
@ -25,7 +25,7 @@
|
|||
<configuration>
|
||||
<archive>
|
||||
<manifest>
|
||||
<mainClass>nl.andrewlalis.aos_client.Client</mainClass>
|
||||
<mainClass>nl.andrewlalis.aos_client.launcher.Launcher</mainClass>
|
||||
</manifest>
|
||||
</archive>
|
||||
<descriptorRefs>
|
||||
|
@ -42,6 +42,22 @@
|
|||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-dependency-plugin</artifactId>
|
||||
<version>2.8</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>copy-dependencies</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<outputDirectory>${project.build.directory}/lib</outputDirectory>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
|
@ -49,7 +65,7 @@
|
|||
<dependency>
|
||||
<groupId>nl.andrewlalis</groupId>
|
||||
<artifactId>aos-core</artifactId>
|
||||
<version>${parent.version}</version>
|
||||
<version>${project.parent.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
|
@ -1,6 +1,7 @@
|
|||
module aos_client {
|
||||
requires java.logging;
|
||||
requires java.se;
|
||||
requires com.fasterxml.jackson.databind;
|
||||
|
||||
requires aos_core;
|
||||
}
|
|
@ -1,52 +1,52 @@
|
|||
package nl.andrewlalis.aos_client;
|
||||
|
||||
import nl.andrewlalis.aos_client.net.ChatManager;
|
||||
import nl.andrewlalis.aos_client.net.MessageTransceiver;
|
||||
import nl.andrewlalis.aos_client.sound.SoundManager;
|
||||
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_client.view.GameRenderer;
|
||||
import nl.andrewlalis.aos_core.model.Player;
|
||||
import nl.andrewlalis.aos_core.model.Team;
|
||||
import nl.andrewlalis.aos_core.model.World;
|
||||
import nl.andrewlalis.aos_core.net.PlayerControlStateMessage;
|
||||
import nl.andrewlalis.aos_core.net.chat.ChatMessage;
|
||||
import nl.andrewlalis.aos_core.net.chat.PlayerChatMessage;
|
||||
import nl.andrewlalis.aos_core.model.tools.Gun;
|
||||
import nl.andrewlalis.aos_core.net.data.DataTypes;
|
||||
import nl.andrewlalis.aos_core.net.data.PlayerDetailUpdate;
|
||||
import nl.andrewlalis.aos_core.net.data.WorldUpdate;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 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 MessageTransceiver messageTransceiver;
|
||||
|
||||
private MessageTransceiver messageTransceiver;
|
||||
|
||||
private int playerId;
|
||||
private PlayerControlState playerControlState;
|
||||
private World world;
|
||||
|
||||
private final List<ChatMessage> chatMessages;
|
||||
private boolean chatting = false;
|
||||
private final StringBuilder chatBuffer;
|
||||
private Player myPlayer;
|
||||
|
||||
private final GameRenderer renderer;
|
||||
private final GamePanel gamePanel;
|
||||
private final SoundManager soundManager;
|
||||
private final ChatManager chatManager;
|
||||
|
||||
public Client() {
|
||||
this.chatMessages = new LinkedList<>();
|
||||
this.chatBuffer = new StringBuilder();
|
||||
private final GameFrame frame;
|
||||
|
||||
/**
|
||||
* Initializes and starts the client, connecting immediately to a server
|
||||
* according to the given host, port, and username.
|
||||
* @param serverHost The server's host name or ip to connect to.
|
||||
* @param serverPort The server's port to connect to.
|
||||
* @param username The player's username to use when connecting.
|
||||
* @throws IOException If the connection could not be initialized.
|
||||
*/
|
||||
public Client(String serverHost, int serverPort, String username) throws IOException {
|
||||
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.messageTransceiver = new MessageTransceiver(this);
|
||||
this.messageTransceiver.connectToServer(serverHost, serverPort, username);
|
||||
this.chatManager = new ChatManager(this.soundManager);
|
||||
this.messageTransceiver = new MessageTransceiver(this, serverHost, serverPort, username);
|
||||
this.messageTransceiver.start();
|
||||
this.chatManager.bindTransceiver(this.messageTransceiver);
|
||||
|
||||
while (this.playerControlState == null || this.world == null) {
|
||||
while (this.myPlayer == null || this.world == null) {
|
||||
try {
|
||||
System.out.println("Waiting for server response and player registration...");
|
||||
Thread.sleep(100);
|
||||
|
@ -56,116 +56,101 @@ public class Client {
|
|||
}
|
||||
|
||||
System.out.println("Player and world data initialized.");
|
||||
GameFrame g = new GameFrame("Ace of Shades - " + serverHost + ":" + serverPort, this, this.gamePanel);
|
||||
g.setVisible(true);
|
||||
GamePanel gamePanel = new GamePanel(this);
|
||||
this.renderer = new GameRenderer(this, gamePanel);
|
||||
this.frame = new GameFrame("Ace of Shades - " + serverHost + ":" + serverPort, this, gamePanel);
|
||||
this.frame.setVisible(true);
|
||||
this.renderer.start();
|
||||
}
|
||||
|
||||
public ChatManager getChatManager() {
|
||||
return chatManager;
|
||||
}
|
||||
|
||||
public World getWorld() {
|
||||
return world;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the client's version of the world according to an update packet
|
||||
* that was received from the server.
|
||||
* @param update The update packet from the server.
|
||||
*/
|
||||
public void updateWorld(WorldUpdate update) {
|
||||
if (this.world == null) return;
|
||||
this.world.getBullets().clear();
|
||||
for (var u : update.getBulletUpdates()) {
|
||||
this.world.getBullets().add(u.toBullet());
|
||||
}
|
||||
for (var p : update.getPlayerUpdates()) {
|
||||
Player player = this.world.getPlayers().get(p.getId());
|
||||
if (player != null) {
|
||||
player.setPosition(p.getPosition());
|
||||
player.setOrientation(p.getOrientation());
|
||||
player.setVelocity(p.getVelocity());
|
||||
player.setGun(new Gun(this.world.getGunTypes().get(p.getGunTypeName())));
|
||||
if (player.getVelocity().mag() > 0) {
|
||||
this.soundManager.playWalking(player, myPlayer);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (var t : update.getTeamUpdates()) {
|
||||
Team team = this.world.getTeams().get(t.getId());
|
||||
if (team != null) {
|
||||
team.setScore(t.getScore());
|
||||
}
|
||||
}
|
||||
this.soundManager.play(update.getSoundsToPlay(), myPlayer);
|
||||
}
|
||||
|
||||
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 void setPlayer(Player player) {
|
||||
this.myPlayer = player;
|
||||
}
|
||||
|
||||
public int getPlayerId() {
|
||||
return playerId;
|
||||
public Player getPlayer() {
|
||||
return myPlayer;
|
||||
}
|
||||
|
||||
public PlayerControlState getPlayerState() {
|
||||
return playerControlState;
|
||||
/**
|
||||
* Updates the client's own player data according to an update from the
|
||||
* server.
|
||||
* @param update The updated player information from the server.
|
||||
*/
|
||||
public void updatePlayer(PlayerDetailUpdate update) {
|
||||
if (this.myPlayer == null) return;
|
||||
this.myPlayer.setHealth(update.getHealth());
|
||||
this.myPlayer.setReloading(update.isReloading());
|
||||
this.myPlayer.setGun(new Gun(this.myPlayer.getGun().getType(), update.getGunCurrentClipBulletCount(), update.getGunClipCount()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a player control state message to the server, which indicates that
|
||||
* the player's controls have been updated, due to a key or mouse event.
|
||||
*/
|
||||
public void sendPlayerState() {
|
||||
try {
|
||||
this.messageTransceiver.send(new PlayerControlStateMessage(this.playerControlState));
|
||||
this.messageTransceiver.sendData(DataTypes.PLAYER_CONTROL_STATE, myPlayer.getId(), myPlayer.getState().toBytes());
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void addChatMessage(ChatMessage message) {
|
||||
this.chatMessages.add(message);
|
||||
if (message.getClass() == PlayerChatMessage.class) {
|
||||
this.soundManager.play("chat.wav");
|
||||
}
|
||||
while (this.chatMessages.size() > MAX_CHAT_MESSAGES) {
|
||||
this.chatMessages.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
public ChatMessage[] getLatestChatMessages() {
|
||||
return this.chatMessages.toArray(new ChatMessage[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 PlayerChatMessage(this.playerId, this.chatBuffer.toString()));
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
this.setChatting(false);
|
||||
}
|
||||
|
||||
public String getCurrentChatBuffer() {
|
||||
return this.chatBuffer.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuts down the client.
|
||||
*/
|
||||
public void shutdown() {
|
||||
this.chatManager.unbindTransceiver();
|
||||
System.out.println("Chat manager shutdown.");
|
||||
this.messageTransceiver.shutdown();
|
||||
System.out.println("Message transceiver shutdown.");
|
||||
this.renderer.shutdown();
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static void main(String[] args) {
|
||||
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();
|
||||
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);
|
||||
}
|
||||
System.out.println("Renderer shutdown.");
|
||||
this.soundManager.close();
|
||||
System.out.println("Sound manager closed.");
|
||||
this.frame.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,81 +0,0 @@
|
|||
package nl.andrewlalis.aos_client;
|
||||
|
||||
import nl.andrewlalis.aos_core.model.World;
|
||||
import nl.andrewlalis.aos_core.net.*;
|
||||
import nl.andrewlalis.aos_core.net.chat.ChatMessage;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.ObjectInputStream;
|
||||
import java.io.ObjectOutputStream;
|
||||
import java.io.StreamCorruptedException;
|
||||
import java.net.Socket;
|
||||
import java.net.SocketException;
|
||||
|
||||
/**
|
||||
* 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) throws IOException {
|
||||
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));
|
||||
System.out.println("Sent identification packet.");
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
this.running = false;
|
||||
if (this.socket != null) {
|
||||
try {
|
||||
this.socket.close();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void send(Message message) throws IOException {
|
||||
this.out.reset();
|
||||
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);
|
||||
} else if (msg.getType() == Type.WORLD_UPDATE) {
|
||||
World world = ((WorldUpdateMessage) msg).getWorld();
|
||||
this.client.setWorld(world);
|
||||
}
|
||||
} catch (StreamCorruptedException e) {
|
||||
e.printStackTrace();
|
||||
this.running = false;
|
||||
} catch (SocketException e) {
|
||||
if (!e.getMessage().equalsIgnoreCase("Socket closed")) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
} catch (IOException | ClassNotFoundException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
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<String, byte[]> 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
package nl.andrewlalis.aos_client;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
public class Tester {
|
||||
private static final String[] names = {
|
||||
"andrew", "john", "william", "farnsworth", "xXx_noSc0p3r_xXx"
|
||||
};
|
||||
|
||||
public static void main(String[] args) {
|
||||
for (int i = 0; i < 2; i++) {
|
||||
try {
|
||||
new Client("localhost", 8035, names[ThreadLocalRandom.current().nextInt(names.length)]);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package nl.andrewlalis.aos_client.control;
|
||||
|
||||
import nl.andrewlalis.aos_client.net.ChatManager;
|
||||
import nl.andrewlalis.aos_client.Client;
|
||||
|
||||
import java.awt.event.KeyAdapter;
|
||||
|
@ -7,34 +8,47 @@ import java.awt.event.KeyEvent;
|
|||
|
||||
public class PlayerKeyListener extends KeyAdapter {
|
||||
private final Client client;
|
||||
private final ChatManager chatManager;
|
||||
|
||||
public PlayerKeyListener(Client client) {
|
||||
this.client = client;
|
||||
this.chatManager = client.getChatManager();
|
||||
}
|
||||
|
||||
@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()) {
|
||||
if (!this.chatManager.isChatting()) {
|
||||
if ((e.getKeyChar() == 't' || e.getKeyChar() == '/')) {
|
||||
this.chatManager.setChatting(true);
|
||||
var s = this.client.getPlayer().getState();
|
||||
s.setMovingForward(false);
|
||||
s.setMovingBackward(false);
|
||||
s.setMovingLeft(false);
|
||||
s.setMovingRight(false);
|
||||
s.setReloading(false);
|
||||
s.setSneaking(false);
|
||||
s.setSprinting(false);
|
||||
this.client.sendPlayerState();
|
||||
if (e.getKeyChar() == '/') this.chatManager.appendToChat('/');
|
||||
}
|
||||
} else if (this.chatManager.isChatting()) {
|
||||
char c = e.getKeyChar();
|
||||
if (c >= ' ' && c <= '~') {
|
||||
this.client.appendToChat(c);
|
||||
this.chatManager.appendToChat(c);
|
||||
} else if (e.getKeyChar() == 8) {
|
||||
this.client.backspaceChat();
|
||||
this.chatManager.backspaceChat();
|
||||
} else if (e.getKeyChar() == 10) {
|
||||
this.client.sendChat();
|
||||
this.chatManager.sendChat();
|
||||
} else if (e.getKeyChar() == 27) {
|
||||
this.client.setChatting(false);
|
||||
this.chatManager.setChatting(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void keyPressed(KeyEvent e) {
|
||||
if (client.isChatting()) return;
|
||||
var state = client.getPlayerState();
|
||||
if (this.chatManager.isChatting()) return;
|
||||
var state = client.getPlayer().getState();
|
||||
if (e.getKeyCode() == KeyEvent.VK_W) {
|
||||
state.setMovingForward(true);
|
||||
} else if (e.getKeyCode() == KeyEvent.VK_S) {
|
||||
|
@ -43,14 +57,20 @@ public class PlayerKeyListener extends KeyAdapter {
|
|||
state.setMovingLeft(true);
|
||||
} else if (e.getKeyCode() == KeyEvent.VK_D) {
|
||||
state.setMovingRight(true);
|
||||
} else if (e.getKeyCode() == KeyEvent.VK_R) {
|
||||
state.setReloading(true);
|
||||
} else if (e.getKeyCode() == KeyEvent.VK_SHIFT) {
|
||||
state.setSprinting(true);
|
||||
} else if (e.getKeyCode() == KeyEvent.VK_CONTROL) {
|
||||
state.setSneaking(true);
|
||||
}
|
||||
this.client.sendPlayerState();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void keyReleased(KeyEvent e) {
|
||||
if (client.isChatting()) return;
|
||||
var state = client.getPlayerState();
|
||||
if (this.chatManager.isChatting()) return;
|
||||
var state = client.getPlayer().getState();
|
||||
if (e.getKeyCode() == KeyEvent.VK_W) {
|
||||
state.setMovingForward(false);
|
||||
} else if (e.getKeyCode() == KeyEvent.VK_S) {
|
||||
|
@ -59,6 +79,12 @@ public class PlayerKeyListener extends KeyAdapter {
|
|||
state.setMovingLeft(false);
|
||||
} else if (e.getKeyCode() == KeyEvent.VK_D) {
|
||||
state.setMovingRight(false);
|
||||
} else if (e.getKeyCode() == KeyEvent.VK_R) {
|
||||
state.setReloading(false);
|
||||
} else if (e.getKeyCode() == KeyEvent.VK_SHIFT) {
|
||||
state.setSprinting(false);
|
||||
} else if (e.getKeyCode() == KeyEvent.VK_CONTROL) {
|
||||
state.setSneaking(false);
|
||||
}
|
||||
this.client.sendPlayerState();
|
||||
}
|
||||
|
|
|
@ -9,9 +9,14 @@ import java.awt.event.MouseEvent;
|
|||
import java.awt.event.MouseWheelEvent;
|
||||
|
||||
public class PlayerMouseListener extends MouseInputAdapter {
|
||||
private static final float MOUSE_UPDATES_PER_SECOND = 60.0f;
|
||||
private static final long MS_PER_MOUSE_UPDATE = (long) (1000.0f / MOUSE_UPDATES_PER_SECOND);
|
||||
|
||||
private final Client client;
|
||||
private final GamePanel gamePanel;
|
||||
|
||||
private long lastMouseMove = 0L;
|
||||
|
||||
public PlayerMouseListener(Client client, GamePanel gamePanel) {
|
||||
this.client = client;
|
||||
this.gamePanel = gamePanel;
|
||||
|
@ -20,7 +25,7 @@ public class PlayerMouseListener extends MouseInputAdapter {
|
|||
@Override
|
||||
public void mousePressed(MouseEvent e) {
|
||||
if (e.getButton() == MouseEvent.BUTTON1) {
|
||||
client.getPlayerState().setShooting(true);
|
||||
client.getPlayer().getState().setShooting(true);
|
||||
client.sendPlayerState();
|
||||
}
|
||||
}
|
||||
|
@ -28,7 +33,7 @@ public class PlayerMouseListener extends MouseInputAdapter {
|
|||
@Override
|
||||
public void mouseReleased(MouseEvent e) {
|
||||
if (e.getButton() == MouseEvent.BUTTON1) {
|
||||
client.getPlayerState().setShooting(false);
|
||||
client.getPlayer().getState().setShooting(false);
|
||||
client.sendPlayerState();
|
||||
}
|
||||
}
|
||||
|
@ -44,18 +49,26 @@ public class PlayerMouseListener extends MouseInputAdapter {
|
|||
|
||||
@Override
|
||||
public void mouseMoved(MouseEvent e) {
|
||||
Vec2 c = new Vec2(this.gamePanel.getWidth() / 2.0, this.gamePanel.getHeight() / 2.0);
|
||||
Vec2 c = new Vec2(this.gamePanel.getWidth() / 2.0f, this.gamePanel.getHeight() / 2.0f);
|
||||
Vec2 centeredMouseLocation = new Vec2(e.getX(), e.getY()).sub(c);
|
||||
client.getPlayerState().setMouseLocation(centeredMouseLocation);
|
||||
client.sendPlayerState();
|
||||
client.getPlayer().getState().setMouseLocation(centeredMouseLocation);
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - this.lastMouseMove > MS_PER_MOUSE_UPDATE) {
|
||||
client.sendPlayerState();
|
||||
this.lastMouseMove = now;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseDragged(MouseEvent e) {
|
||||
Vec2 c = new Vec2(this.gamePanel.getWidth() / 2.0, this.gamePanel.getHeight() / 2.0);
|
||||
Vec2 c = new Vec2(this.gamePanel.getWidth() / 2.0f, this.gamePanel.getHeight() / 2.0f);
|
||||
Vec2 centeredMouseLocation = new Vec2(e.getX(), e.getY()).sub(c);
|
||||
client.getPlayerState().setMouseLocation(centeredMouseLocation);
|
||||
client.getPlayerState().setShooting(true);
|
||||
client.sendPlayerState();
|
||||
client.getPlayer().getState().setMouseLocation(centeredMouseLocation);
|
||||
client.getPlayer().getState().setShooting(true);
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - this.lastMouseMove > MS_PER_MOUSE_UPDATE) {
|
||||
client.sendPlayerState();
|
||||
this.lastMouseMove = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,225 @@
|
|||
package nl.andrewlalis.aos_client.launcher;
|
||||
|
||||
import nl.andrewlalis.aos_client.Client;
|
||||
import nl.andrewlalis.aos_client.launcher.servers.*;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Launcher application for starting the game. Because the client is only
|
||||
* actually started when connecting to a server, this user interface serves as
|
||||
* the menu that the user interacts with before joining a game.
|
||||
*/
|
||||
public class Launcher extends JFrame {
|
||||
public static final Pattern addressPattern = Pattern.compile("(.+):(\\d+)");
|
||||
public static final Path DATA_DIR = Path.of(System.getProperty("user.home"), ".ace-of-shades");
|
||||
public static final Pattern usernamePattern = Pattern.compile("[a-zA-Z0-9_-]+");
|
||||
|
||||
public Launcher() throws HeadlessException {
|
||||
super("Ace of Shades - Launcher");
|
||||
this.setDefaultCloseOperation(EXIT_ON_CLOSE);
|
||||
this.setContentPane(this.buildContent());
|
||||
this.setPreferredSize(new Dimension(400, 500));
|
||||
this.pack();
|
||||
this.setLocationRelativeTo(null);
|
||||
}
|
||||
|
||||
private Container buildContent() {
|
||||
JTabbedPane mainPanel = new JTabbedPane(SwingConstants.TOP, JTabbedPane.SCROLL_TAB_LAYOUT);
|
||||
mainPanel.addTab("Servers", null, this.getServersPanel(), "View a list of available servers.");
|
||||
|
||||
return mainPanel;
|
||||
}
|
||||
|
||||
private Container getServersPanel() {
|
||||
JPanel panel = new JPanel(new BorderLayout());
|
||||
|
||||
ServerInfoListModel listModel = new ServerInfoListModel();
|
||||
JList<ServerInfo> serversList = new JList<>(listModel);
|
||||
serversList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
||||
serversList.setCellRenderer(new ServerInfoCellRenderer());
|
||||
JScrollPane scrollPane = new JScrollPane(serversList, ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||
panel.add(scrollPane, BorderLayout.CENTER);
|
||||
|
||||
serversList.addMouseListener(new MouseAdapter() {
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e) {
|
||||
if (SwingUtilities.isRightMouseButton(e)) {
|
||||
serversList.setSelectedIndex(serversList.locationToIndex(e.getPoint()));
|
||||
ServerInfo server = serversList.getSelectedValue();
|
||||
if (server == null) return;
|
||||
JPopupMenu menu = new JPopupMenu();
|
||||
JMenuItem connectItem = new JMenuItem("Connect");
|
||||
connectItem.addActionListener(a -> connect(server));
|
||||
menu.add(connectItem);
|
||||
JMenuItem editItem = new JMenuItem("Edit");
|
||||
editItem.addActionListener(a -> {
|
||||
EditServerDialog dialog = new EditServerDialog((Frame) SwingUtilities.getWindowAncestor(panel), server);
|
||||
dialog.setVisible(true);
|
||||
ServerInfo editedInfo = dialog.getServerInfo();
|
||||
if (editedInfo != null) {
|
||||
server.setName(editedInfo.getName());
|
||||
server.setHost(editedInfo.getHost());
|
||||
server.setUsername(editedInfo.getUsername());
|
||||
listModel.serverEdited();
|
||||
}
|
||||
});
|
||||
menu.add(editItem);
|
||||
JMenuItem removeItem = new JMenuItem("Remove");
|
||||
removeItem.addActionListener(a -> {
|
||||
int choice = JOptionPane.showConfirmDialog(panel, "Are you sure you want to remove this server?", "Confirm Server Removal", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE);
|
||||
if (choice == JOptionPane.OK_OPTION) {
|
||||
listModel.remove(serversList.getSelectedValue());
|
||||
}
|
||||
});
|
||||
menu.add(removeItem);
|
||||
menu.show(serversList, e.getX(), e.getY());
|
||||
} else if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2) {
|
||||
serversList.setSelectedIndex(serversList.locationToIndex(e.getPoint()));
|
||||
ServerInfo server = serversList.getSelectedValue();
|
||||
if (server == null) return;
|
||||
connect(server);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
JPanel buttonPanel = new JPanel();
|
||||
JButton addServerButton = new JButton("Add Server");
|
||||
addServerButton.setToolTipText("Add a new server to the list.");
|
||||
addServerButton.addActionListener(e -> {
|
||||
AddServerDialog dialog = new AddServerDialog(this);
|
||||
dialog.setVisible(true);
|
||||
ServerInfo server = dialog.getServerInfo();
|
||||
if (server != null) {
|
||||
listModel.add(server);
|
||||
}
|
||||
});
|
||||
buttonPanel.add(addServerButton);
|
||||
JButton directConnectButton = new JButton("Direct Connect");
|
||||
directConnectButton.setToolTipText("Connect to any server directly.");
|
||||
directConnectButton.addActionListener(e -> {
|
||||
JDialog dialog = new JDialog(this, true);
|
||||
dialog.setTitle("Direct Connect");
|
||||
dialog.setContentPane(getConnectPanel(dialog));
|
||||
dialog.pack();
|
||||
dialog.setLocationRelativeTo(this);
|
||||
dialog.setVisible(true);
|
||||
});
|
||||
buttonPanel.add(directConnectButton);
|
||||
|
||||
JButton searchButton = new JButton("Search");
|
||||
searchButton.setToolTipText("Search for servers online.");
|
||||
searchButton.addActionListener(e -> {
|
||||
SearchServersDialog dialog = new SearchServersDialog(this, listModel);
|
||||
dialog.setVisible(true);
|
||||
});
|
||||
buttonPanel.add(searchButton);
|
||||
|
||||
JButton helpButton = new JButton("Help");
|
||||
helpButton.setToolTipText("Show some helpful information for using this program.");
|
||||
helpButton.addActionListener(e -> {
|
||||
String uri = "https://github.com/andrewlalis/AceOfShades/blob/main/help.md";
|
||||
try {
|
||||
Desktop.getDesktop().browse(new URI(uri));
|
||||
} catch (IOException | URISyntaxException ex) {
|
||||
ex.printStackTrace();
|
||||
JOptionPane.showMessageDialog(this, "Could not open URI in browser. For help, please visit\n" + uri, "Error", JOptionPane.WARNING_MESSAGE);
|
||||
}
|
||||
});
|
||||
buttonPanel.add(helpButton);
|
||||
|
||||
panel.add(buttonPanel, BorderLayout.SOUTH);
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
private Container getConnectPanel(JDialog dialog) {
|
||||
JPanel inputPanel = new JPanel(new GridBagLayout());
|
||||
GridBagConstraints c = new GridBagConstraints();
|
||||
c.insets = new Insets(5, 5, 5, 5);
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy = 0;
|
||||
inputPanel.add(new JLabel("Address"), c);
|
||||
JTextField addressField = new JTextField(20);
|
||||
c.gridx = 1;
|
||||
inputPanel.add(addressField, c);
|
||||
|
||||
c.gridy = 1;
|
||||
c.gridx = 0;
|
||||
inputPanel.add(new JLabel("Username"), c);
|
||||
JTextField usernameField = new JTextField(20);
|
||||
c.gridx = 1;
|
||||
inputPanel.add(usernameField, c);
|
||||
|
||||
JPanel buttonPanel = new JPanel(new FlowLayout());
|
||||
JButton connectButton = new JButton("Connect");
|
||||
connectButton.addActionListener(e -> {
|
||||
if (validateConnectInput(addressField, usernameField)) {
|
||||
dialog.dispose();
|
||||
connect(new ServerInfo("Unknown", addressField.getText(), usernameField.getText()));
|
||||
}
|
||||
});
|
||||
buttonPanel.add(connectButton);
|
||||
|
||||
JPanel mainPanel = new JPanel(new BorderLayout());
|
||||
mainPanel.add(inputPanel, BorderLayout.CENTER);
|
||||
mainPanel.add(buttonPanel, BorderLayout.SOUTH);
|
||||
return mainPanel;
|
||||
}
|
||||
|
||||
private boolean validateConnectInput(JTextField addressField, JTextField usernameField) {
|
||||
List<String> warnings = new ArrayList<>();
|
||||
if (addressField.getText() == null || addressField.getText().isBlank()) {
|
||||
warnings.add("Address must not be empty.");
|
||||
}
|
||||
if (usernameField.getText() == null || usernameField.getText().isBlank()) {
|
||||
warnings.add("Username must not be empty.");
|
||||
}
|
||||
if (usernameField.getText() != null && usernameField.getText().length() > 16) {
|
||||
warnings.add("Username is too long.");
|
||||
}
|
||||
if (addressField.getText() != null && !addressPattern.matcher(addressField.getText()).matches()) {
|
||||
warnings.add("Address must be in the form HOST:PORT.");
|
||||
}
|
||||
if (!warnings.isEmpty()) {
|
||||
JOptionPane.showMessageDialog(
|
||||
this,
|
||||
String.join("\n", warnings),
|
||||
"Invalid Input",
|
||||
JOptionPane.WARNING_MESSAGE
|
||||
);
|
||||
}
|
||||
return warnings.isEmpty();
|
||||
}
|
||||
|
||||
private void connect(ServerInfo serverInfo) {
|
||||
String username = serverInfo.getUsername();
|
||||
if (username == null) {
|
||||
username = JOptionPane.showInputDialog(this, "Enter a username.", "Username", JOptionPane.PLAIN_MESSAGE);
|
||||
if (username == null || username.isBlank()) return;
|
||||
}
|
||||
try {
|
||||
new Client(serverInfo.getHostAddress(), serverInfo.getHostPort(), username);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
JOptionPane.showMessageDialog(this, "Could not connect:\n" + e.getMessage(), "Connection Error", JOptionPane.WARNING_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static void main(String[] args) {
|
||||
Launcher launcher = new Launcher();
|
||||
launcher.setVisible(true);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
package nl.andrewlalis.aos_client.launcher.servers;
|
||||
|
||||
import nl.andrewlalis.aos_client.launcher.Launcher;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class AddServerDialog extends JDialog {
|
||||
protected JTextField serverNameField;
|
||||
protected JTextField serverAddressField;
|
||||
protected JTextField usernameField;
|
||||
|
||||
private ServerInfo serverInfo;
|
||||
|
||||
public AddServerDialog(Frame owner) {
|
||||
super(owner, true);
|
||||
this.setTitle("Add Server");
|
||||
this.setContentPane(this.getContent());
|
||||
this.pack();
|
||||
this.setLocationRelativeTo(owner);
|
||||
}
|
||||
|
||||
public ServerInfo getServerInfo() {
|
||||
return this.serverInfo;
|
||||
}
|
||||
|
||||
private Container getContent() {
|
||||
JPanel container = new JPanel(new BorderLayout());
|
||||
JPanel inputPanel = new JPanel(new GridBagLayout());
|
||||
GridBagConstraints c = new GridBagConstraints();
|
||||
c.insets = new Insets(5, 5, 5, 5);
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy = 0;
|
||||
inputPanel.add(new JLabel("Name"), c);
|
||||
serverNameField = new JTextField(20);
|
||||
c.gridx++;
|
||||
inputPanel.add(serverNameField, c);
|
||||
|
||||
c.gridx = 0;
|
||||
c.gridy++;
|
||||
inputPanel.add(new JLabel("Address"), c);
|
||||
serverAddressField = new JTextField(20);
|
||||
c.gridx++;
|
||||
inputPanel.add(serverAddressField, c);
|
||||
|
||||
c.gridy++;
|
||||
c.gridx = 0;
|
||||
inputPanel.add(new JLabel("Username"), c);
|
||||
usernameField = new JTextField(20);
|
||||
c.gridx++;
|
||||
inputPanel.add(usernameField, c);
|
||||
|
||||
container.add(inputPanel, BorderLayout.CENTER);
|
||||
|
||||
JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT));
|
||||
JButton cancelButton = new JButton("Cancel");
|
||||
cancelButton.addActionListener(e -> {
|
||||
this.serverInfo = null;
|
||||
this.dispose();
|
||||
});
|
||||
JButton okButton = new JButton("Ok");
|
||||
okButton.addActionListener(e -> {
|
||||
var messages = this.validateInputs();
|
||||
if (!messages.isEmpty()) {
|
||||
String msg = String.join("\n", messages);
|
||||
JOptionPane.showMessageDialog(this, "The information you entered is not valid:\n" + msg, "Invalid Server Data", JOptionPane.WARNING_MESSAGE);
|
||||
} else {
|
||||
String username = this.usernameField.getText();
|
||||
if (username.isBlank()) {
|
||||
username = null;
|
||||
}
|
||||
this.serverInfo = new ServerInfo(this.serverNameField.getText(), this.serverAddressField.getText(), username);
|
||||
this.dispose();
|
||||
}
|
||||
});
|
||||
buttonPanel.add(okButton);
|
||||
buttonPanel.add(cancelButton);
|
||||
|
||||
container.add(buttonPanel, BorderLayout.SOUTH);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
private List<String> validateInputs() {
|
||||
String name = this.serverNameField.getText();
|
||||
String address = this.serverAddressField.getText();
|
||||
String username = this.usernameField.getText();
|
||||
List<String> messages = new ArrayList<>();
|
||||
|
||||
if (name == null || name.isBlank()) {
|
||||
messages.add("Server name cannot be blank.");
|
||||
}
|
||||
if (name != null && name.length() > 32) {
|
||||
messages.add("Server name is too long.");
|
||||
}
|
||||
if (address == null || address.isBlank()) {
|
||||
messages.add("Server address cannot be blank.");
|
||||
}
|
||||
if (address != null && !Launcher.addressPattern.matcher(address).matches()) {
|
||||
messages.add("Server address is not properly formatted as HOST:PORT.");
|
||||
}
|
||||
if (username != null && !username.isBlank() && username.length() > 16) {
|
||||
messages.add("Username is too long. Maximum of 16 characters.");
|
||||
}
|
||||
if (username != null && !username.isBlank() && !Launcher.usernamePattern.matcher(username).matches()) {
|
||||
messages.add("Username should contain only letters, numbers, underscores, and hyphens.");
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package nl.andrewlalis.aos_client.launcher.servers;
|
||||
|
||||
import java.awt.*;
|
||||
|
||||
public class EditServerDialog extends AddServerDialog {
|
||||
public EditServerDialog(Frame owner, ServerInfo server) {
|
||||
super(owner);
|
||||
this.setTitle("Edit Server - " + server.getName());
|
||||
this.serverNameField.setText(server.getName());
|
||||
this.serverAddressField.setText(server.getHost());
|
||||
this.usernameField.setText(server.getUsername());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
package nl.andrewlalis.aos_client.launcher.servers;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
|
||||
public record PublicServerInfo(
|
||||
String name,
|
||||
String address,
|
||||
String version,
|
||||
String description,
|
||||
String location,
|
||||
Image icon,
|
||||
int maxPlayers,
|
||||
int currentPlayers
|
||||
) {
|
||||
public static ListCellRenderer<PublicServerInfo> cellRenderer() {
|
||||
return (list, value, index, isSelected, cellHasFocus) -> {
|
||||
JPanel panel = new JPanel(new GridBagLayout());
|
||||
var c = new GridBagConstraints();
|
||||
|
||||
c.anchor = GridBagConstraints.CENTER;
|
||||
c.insets = new Insets(1, 1, 1, 1);
|
||||
c.gridx = 0;
|
||||
c.gridy = 0;
|
||||
c.gridheight = 4;
|
||||
c.weightx = 0.25;
|
||||
c.fill = GridBagConstraints.BOTH;
|
||||
if (value.icon() != null) {
|
||||
JLabel iconLabel = new JLabel(new ImageIcon(value.icon()));
|
||||
panel.add(iconLabel, c);
|
||||
}
|
||||
|
||||
c.anchor = GridBagConstraints.LINE_START;
|
||||
c.gridx = 1;
|
||||
c.gridy = 0;
|
||||
c.gridheight = 1;
|
||||
c.weightx = 0.75;
|
||||
c.fill = GridBagConstraints.HORIZONTAL;
|
||||
var nameLabel = new JLabel(value.name() + " [" + value.version() + "]");
|
||||
nameLabel.setFont(nameLabel.getFont().deriveFont(Font.BOLD));
|
||||
panel.add(nameLabel, c);
|
||||
c.gridy++;
|
||||
var addressLabel = new JLabel(value.address());
|
||||
addressLabel.setFont(new Font("monospaced", Font.PLAIN, 12));
|
||||
panel.add(addressLabel, c);
|
||||
c.gridy++;
|
||||
JTextArea descriptionArea = new JTextArea(value.description());
|
||||
descriptionArea.setFont(new Font("monospaced", Font.PLAIN, 12));
|
||||
descriptionArea.setEditable(false);
|
||||
descriptionArea.setWrapStyleWord(true);
|
||||
descriptionArea.setLineWrap(true);
|
||||
panel.add(descriptionArea, c);
|
||||
c.gridy++;
|
||||
panel.add(new JLabel(String.format("%d / %d Players", value.currentPlayers(), value.maxPlayers())), c);
|
||||
|
||||
if (isSelected) {
|
||||
panel.setBackground(list.getSelectionBackground());
|
||||
panel.setForeground(list.getSelectionForeground());
|
||||
descriptionArea.setForeground(list.getSelectionForeground());
|
||||
descriptionArea.setBackground(list.getSelectionBackground());
|
||||
} else {
|
||||
panel.setBackground(list.getBackground());
|
||||
panel.setForeground(list.getForeground());
|
||||
descriptionArea.setForeground(list.getForeground());
|
||||
descriptionArea.setBackground(list.getBackground());
|
||||
}
|
||||
|
||||
panel.revalidate();
|
||||
|
||||
return panel;
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,185 @@
|
|||
package nl.andrewlalis.aos_client.launcher.servers;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeType;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public class PublicServerListModel extends AbstractListModel<PublicServerInfo> {
|
||||
public static final String REGISTRY_URL = "http://37.97.207.39:25566/serverInfo";
|
||||
|
||||
private final List<PublicServerInfo> currentPageItems;
|
||||
private boolean firstPage;
|
||||
private boolean lastPage;
|
||||
private int currentPage;
|
||||
private String currentQuery;
|
||||
private String currentOrder;
|
||||
private String currentOrderDir;
|
||||
private int pageSize;
|
||||
|
||||
private final HttpClient client;
|
||||
private final ObjectMapper mapper;
|
||||
private final ScheduledExecutorService executorService;
|
||||
private ScheduledFuture<?> pageFetchFuture;
|
||||
private final List<Consumer<PublicServerListModel>> modelUpdateListeners;
|
||||
|
||||
public PublicServerListModel() {
|
||||
this.currentPageItems = new ArrayList<>();
|
||||
this.modelUpdateListeners = new ArrayList<>();
|
||||
this.executorService = Executors.newSingleThreadScheduledExecutor();
|
||||
this.client = HttpClient.newBuilder()
|
||||
.executor(this.executorService)
|
||||
.connectTimeout(Duration.ofSeconds(3))
|
||||
.build();
|
||||
this.mapper = new ObjectMapper();
|
||||
this.fetchPage(0, null, null, null);
|
||||
}
|
||||
|
||||
public void scheduleAutoPageFetch() {
|
||||
this.pageFetchFuture = this.executorService.scheduleAtFixedRate(
|
||||
() -> this.fetchPage(this.currentPage, this.currentQuery, this.currentOrder, this.currentOrderDir),
|
||||
5,
|
||||
5,
|
||||
TimeUnit.SECONDS
|
||||
);
|
||||
}
|
||||
|
||||
public void addListener(Consumer<PublicServerListModel> listener) {
|
||||
this.modelUpdateListeners.add(listener);
|
||||
}
|
||||
|
||||
public void fetchPage(int page) {
|
||||
this.fetchPage(page, this.currentQuery);
|
||||
}
|
||||
|
||||
public void fetchPage(int page, String query) {
|
||||
this.fetchPage(page, query, this.currentOrder, this.currentOrderDir);
|
||||
}
|
||||
|
||||
public void fetchPage(int page, String query, String order, String orderDir) {
|
||||
if (this.pageFetchFuture != null && !this.pageFetchFuture.isDone()) {
|
||||
this.pageFetchFuture.cancel(false);
|
||||
}
|
||||
String uri = REGISTRY_URL + "?page=" + page + "&size=" + this.pageSize;
|
||||
if (query != null && !query.isBlank()) {
|
||||
uri += "&q=" + URLEncoder.encode(query, StandardCharsets.UTF_8);
|
||||
}
|
||||
if (order != null && !order.isBlank()) {
|
||||
uri += "&order=" + URLEncoder.encode(order, StandardCharsets.UTF_8);
|
||||
}
|
||||
if (orderDir != null && !orderDir.isBlank()) {
|
||||
uri += "&dir=" + URLEncoder.encode(orderDir, StandardCharsets.UTF_8);
|
||||
}
|
||||
System.out.println("Fetching from " + uri);
|
||||
HttpRequest request;
|
||||
try {
|
||||
request = HttpRequest.newBuilder().GET().uri(new URI(uri)).header("Accept", "application/json").build();
|
||||
} catch (URISyntaxException e) {
|
||||
e.printStackTrace();
|
||||
return;
|
||||
}
|
||||
|
||||
var requestFuture = this.client.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream());
|
||||
requestFuture.whenCompleteAsync((response, throwable) -> {
|
||||
this.currentPageItems.clear();
|
||||
this.firstPage = true;
|
||||
this.lastPage = true;
|
||||
if (throwable != null) {
|
||||
System.err.println("Could not request data from registry: " + throwable);
|
||||
} else if (response.statusCode() != 200) {
|
||||
System.err.println("Non-OK status code: " + response.statusCode());
|
||||
} else {
|
||||
try {
|
||||
JsonNode json = this.mapper.readValue(response.body(), JsonNode.class);
|
||||
this.firstPage = json.get("firstPage").asBoolean();
|
||||
this.lastPage = json.get("lastPage").asBoolean();
|
||||
this.currentPage = json.get("currentPage").asInt();
|
||||
this.pageSize = json.get("pageSize").asInt();
|
||||
this.currentQuery = query;
|
||||
this.currentOrder = json.get("order").asText();
|
||||
this.currentOrderDir = json.get("orderDirection").asText();
|
||||
for (Iterator<JsonNode> it = json.get("contents").elements(); it.hasNext(); ) {
|
||||
this.addServerInfoFromJson(it.next());
|
||||
}
|
||||
this.scheduleAutoPageFetch();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
this.fireContentsChanged(this, 0, this.getSize());
|
||||
this.modelUpdateListeners.forEach(l -> l.accept(this));
|
||||
});
|
||||
}
|
||||
|
||||
public boolean isFirstPage() {
|
||||
return firstPage;
|
||||
}
|
||||
|
||||
public boolean isLastPage() {
|
||||
return lastPage;
|
||||
}
|
||||
|
||||
public int getCurrentPage() {
|
||||
return currentPage;
|
||||
}
|
||||
|
||||
public int getPageSize() {
|
||||
return pageSize;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSize() {
|
||||
return this.currentPageItems.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PublicServerInfo getElementAt(int index) {
|
||||
return this.currentPageItems.get(index);
|
||||
}
|
||||
|
||||
public void addServerInfoFromJson(JsonNode node) throws IOException {
|
||||
Image icon = null;
|
||||
JsonNode iconNode = node.get("icon");
|
||||
if (iconNode != null && iconNode.getNodeType() == JsonNodeType.STRING) {
|
||||
icon = ImageIO.read(new ByteArrayInputStream(Base64.getUrlDecoder().decode(iconNode.textValue())));
|
||||
}
|
||||
PublicServerInfo info = new PublicServerInfo(
|
||||
node.get("name").asText(),
|
||||
node.get("address").asText(),
|
||||
node.get("version").asText(),
|
||||
node.get("description").asText(),
|
||||
node.get("location").asText(),
|
||||
icon,
|
||||
node.get("maxPlayers").asInt(),
|
||||
node.get("currentPlayers").asInt()
|
||||
);
|
||||
this.currentPageItems.add(info);
|
||||
}
|
||||
|
||||
public void dispose() {
|
||||
this.modelUpdateListeners.clear();
|
||||
this.executorService.shutdown();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
package nl.andrewlalis.aos_client.launcher.servers;
|
||||
|
||||
import nl.andrewlalis.aos_client.Client;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.awt.event.KeyAdapter;
|
||||
import java.awt.event.KeyEvent;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.io.IOException;
|
||||
|
||||
public class SearchServersDialog extends JDialog {
|
||||
private final PublicServerListModel listModel;
|
||||
|
||||
public SearchServersDialog(Frame frame, ServerInfoListModel serverInfoListModel) {
|
||||
super(frame, true);
|
||||
this.setTitle("Search for Servers");
|
||||
this.setDefaultCloseOperation(DISPOSE_ON_CLOSE);
|
||||
|
||||
this.listModel = new PublicServerListModel();
|
||||
|
||||
this.setContentPane(this.getContent(serverInfoListModel));
|
||||
this.pack();
|
||||
this.setLocationRelativeTo(frame);
|
||||
}
|
||||
|
||||
private Container getContent(ServerInfoListModel serverInfoListModel) {
|
||||
JPanel panel = new JPanel(new BorderLayout());
|
||||
|
||||
JList<PublicServerInfo> serversList = new JList<>(listModel);
|
||||
serversList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
||||
serversList.setCellRenderer(PublicServerInfo.cellRenderer());
|
||||
JScrollPane scrollPane = new JScrollPane(serversList, ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||
scrollPane.setPreferredSize(new Dimension(400, 600));
|
||||
panel.add(scrollPane, BorderLayout.CENTER);
|
||||
|
||||
JPanel filtersPanel = new JPanel(new FlowLayout());
|
||||
JTextField searchField = new JTextField(15);
|
||||
searchField.setToolTipText("Search for a server by name.");
|
||||
filtersPanel.add(searchField);
|
||||
var prevButton = new JButton("<");
|
||||
var nextButton = new JButton(">");
|
||||
var refreshButton = new JButton("Refresh");
|
||||
prevButton.addActionListener(e -> listModel.fetchPage(listModel.getCurrentPage() - 1));
|
||||
filtersPanel.add(prevButton);
|
||||
nextButton.addActionListener(e -> listModel.fetchPage(listModel.getCurrentPage() + 1));
|
||||
filtersPanel.add(nextButton);
|
||||
refreshButton.addActionListener(e -> listModel.fetchPage(listModel.getCurrentPage()));
|
||||
filtersPanel.add(refreshButton);
|
||||
listModel.addListener(model -> {
|
||||
prevButton.setEnabled(!model.isFirstPage());
|
||||
nextButton.setEnabled(!model.isLastPage());
|
||||
});
|
||||
|
||||
panel.add(filtersPanel, BorderLayout.NORTH);
|
||||
|
||||
searchField.addKeyListener(new KeyAdapter() {
|
||||
@Override
|
||||
public void keyReleased(KeyEvent e) {
|
||||
listModel.fetchPage(0, searchField.getText().trim());
|
||||
}
|
||||
});
|
||||
|
||||
serversList.addMouseListener(new MouseAdapter() {
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e) {
|
||||
if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2) {
|
||||
serversList.setSelectedIndex(serversList.locationToIndex(e.getPoint()));
|
||||
PublicServerInfo info = serversList.getSelectedValue();
|
||||
if (info == null) return;
|
||||
connect(info);
|
||||
} else if (SwingUtilities.isRightMouseButton(e)) {
|
||||
serversList.setSelectedIndex(serversList.locationToIndex(e.getPoint()));
|
||||
PublicServerInfo info = serversList.getSelectedValue();
|
||||
if (info == null) return;
|
||||
JPopupMenu menu = new JPopupMenu();
|
||||
JMenuItem addToListItem = new JMenuItem("Add to My Servers");
|
||||
addToListItem.addActionListener(e1 -> {
|
||||
serverInfoListModel.add(new ServerInfo(
|
||||
info.name(),
|
||||
info.address(),
|
||||
null
|
||||
));
|
||||
dispose();
|
||||
});
|
||||
menu.add(addToListItem);
|
||||
menu.show(serversList, e.getX(), e.getY());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
private void connect(PublicServerInfo serverInfo) {
|
||||
String username = JOptionPane.showInputDialog(this, "Enter a username.", "Username", JOptionPane.PLAIN_MESSAGE);
|
||||
if (username == null || username.isBlank()) return;
|
||||
String[] parts = serverInfo.address().split(":");
|
||||
String host = parts[0];
|
||||
int port = Integer.parseInt(parts[1]);
|
||||
this.dispose();
|
||||
try {
|
||||
new Client(host, port, username);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
JOptionPane.showMessageDialog(this, "Could not connect:\n" + e.getMessage(), "Connection Error", JOptionPane.WARNING_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
this.listModel.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
package nl.andrewlalis.aos_client.launcher.servers;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Objects;
|
||||
|
||||
public class ServerInfo implements Comparable<ServerInfo>, Serializable {
|
||||
private String name;
|
||||
private String host;
|
||||
private String username;
|
||||
|
||||
public ServerInfo(String name, String host, String username) {
|
||||
this.name = name;
|
||||
this.host = host;
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getHost() {
|
||||
return host;
|
||||
}
|
||||
|
||||
public void setHost(String host) {
|
||||
this.host = host;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getHostAddress() {
|
||||
return this.host.split(":")[0];
|
||||
}
|
||||
|
||||
public int getHostPort() {
|
||||
String[] parts = this.host.split(":");
|
||||
if (parts.length < 2) return 8035;
|
||||
return Integer.parseInt(parts[1]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
ServerInfo that = (ServerInfo) o;
|
||||
return getName().equals(that.getName()) && getHost().equals(that.getHost()) && Objects.equals(getUsername(), that.getUsername());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(getName(), getHost(), getUsername());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(ServerInfo o) {
|
||||
return this.name.compareTo(o.name);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package nl.andrewlalis.aos_client.launcher.servers;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
|
||||
public class ServerInfoCellRenderer implements ListCellRenderer<ServerInfo> {
|
||||
@Override
|
||||
public Component getListCellRendererComponent(JList<? extends ServerInfo> list, ServerInfo value, int index, boolean isSelected, boolean cellHasFocus) {
|
||||
JPanel panel = new JPanel(new BorderLayout());
|
||||
panel.setBorder(BorderFactory.createTitledBorder(value.getName()));
|
||||
JTextField hostField = new JTextField(value.getHost());
|
||||
hostField.setEditable(false);
|
||||
panel.add(hostField, BorderLayout.CENTER);
|
||||
if (value.getUsername() != null) {
|
||||
panel.add(new JLabel("Username: " + value.getUsername()), BorderLayout.SOUTH);
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
panel.setBackground(list.getSelectionBackground());
|
||||
panel.setForeground(list.getSelectionForeground());
|
||||
} else {
|
||||
panel.setBackground(list.getBackground());
|
||||
panel.setForeground(list.getForeground());
|
||||
}
|
||||
|
||||
return panel;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
package nl.andrewlalis.aos_client.launcher.servers;
|
||||
|
||||
import nl.andrewlalis.aos_client.launcher.Launcher;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.io.IOException;
|
||||
import java.io.ObjectInputStream;
|
||||
import java.io.ObjectOutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Model which represents the list of servers. This model is backed by a file
|
||||
* containing the serialized list, which is updated any time a server is added,
|
||||
* edited, or removed.
|
||||
*/
|
||||
public class ServerInfoListModel extends AbstractListModel<ServerInfo> {
|
||||
public static final Path SERVERS_FILE = Launcher.DATA_DIR.resolve("servers.dat");
|
||||
|
||||
private final List<ServerInfo> servers;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public ServerInfoListModel() {
|
||||
List<ServerInfo> list = null;
|
||||
if (Files.exists(SERVERS_FILE)) {
|
||||
try (ObjectInputStream ois = new ObjectInputStream(Files.newInputStream(SERVERS_FILE))) {
|
||||
list = (ArrayList<ServerInfo>) ois.readObject();
|
||||
} catch (IOException | ClassNotFoundException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
if (list == null) list = new ArrayList<>();
|
||||
servers = list;
|
||||
}
|
||||
|
||||
public void add(ServerInfo server) {
|
||||
if (this.servers.contains(server)) return;
|
||||
this.servers.add(server);
|
||||
this.servers.sort(Comparator.naturalOrder());
|
||||
this.fireContentsChanged(this, 0, this.getSize());
|
||||
this.save();
|
||||
}
|
||||
|
||||
public void remove(ServerInfo server) {
|
||||
int index = this.servers.indexOf(server);
|
||||
if (index == -1) return;
|
||||
this.servers.remove(index);
|
||||
this.fireIntervalRemoved(this, index, index);
|
||||
this.save();
|
||||
}
|
||||
|
||||
public void serverEdited() {
|
||||
this.fireContentsChanged(this, 0, this.getSize());
|
||||
this.save();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSize() {
|
||||
return this.servers.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServerInfo getElementAt(int index) {
|
||||
return this.servers.get(index);
|
||||
}
|
||||
|
||||
private void save() {
|
||||
try {
|
||||
Files.createDirectories(SERVERS_FILE.getParent());
|
||||
ObjectOutputStream oos = new ObjectOutputStream(Files.newOutputStream(SERVERS_FILE));
|
||||
oos.writeObject(this.servers);
|
||||
oos.close();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
package nl.andrewlalis.aos_client.net;
|
||||
|
||||
import nl.andrewlalis.aos_client.sound.SoundManager;
|
||||
import nl.andrewlalis.aos_core.net.chat.ChatMessage;
|
||||
import nl.andrewlalis.aos_core.net.chat.ChatType;
|
||||
import nl.andrewlalis.aos_core.net.chat.PlayerChatMessage;
|
||||
import nl.andrewlalis.aos_core.net.data.SoundData;
|
||||
import nl.andrewlalis.aos_core.net.data.SoundType;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* This chat manager is responsible for storing the list of recent messages that
|
||||
* the client has received, so that they can be displayed in the user interface.
|
||||
* <p>
|
||||
* It is also responsible for managing the client's "chatting" state (if the
|
||||
* client is chatting or not), and uses a provided {@link MessageTransceiver}
|
||||
* to send chat messages to the server.
|
||||
* </p>
|
||||
*/
|
||||
public class ChatManager {
|
||||
public static final int MAX_CHAT_MESSAGES = 10;
|
||||
|
||||
private final List<ChatMessage> chatMessages;
|
||||
private boolean chatting = false;
|
||||
private final StringBuilder chatBuffer;
|
||||
|
||||
private final SoundManager soundManager;
|
||||
private MessageTransceiver messageTransceiver;
|
||||
|
||||
public ChatManager(SoundManager soundManager) {
|
||||
this.soundManager = soundManager;
|
||||
this.chatMessages = new LinkedList<>();
|
||||
this.chatBuffer = new StringBuilder();
|
||||
}
|
||||
|
||||
public void bindTransceiver(MessageTransceiver messageTransceiver) {
|
||||
this.messageTransceiver = messageTransceiver;
|
||||
}
|
||||
|
||||
public void unbindTransceiver() {
|
||||
this.messageTransceiver = null;
|
||||
}
|
||||
|
||||
public synchronized void addChatMessage(ChatMessage message) {
|
||||
this.chatMessages.add(message);
|
||||
if (message instanceof PlayerChatMessage) {
|
||||
this.soundManager.play(new SoundData(null, 1.0f, SoundType.CHAT));
|
||||
}
|
||||
while (this.chatMessages.size() > MAX_CHAT_MESSAGES) {
|
||||
this.chatMessages.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
public ChatMessage[] getLatestChatMessages() {
|
||||
if (this.chatMessages.isEmpty()) return new ChatMessage[0];
|
||||
return this.chatMessages.toArray(new ChatMessage[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() {
|
||||
String message = this.chatBuffer.toString().trim();
|
||||
if (!message.isBlank() && !message.equals("/") && this.messageTransceiver != null) {
|
||||
this.messageTransceiver.send(new ChatMessage(message, ChatType.PUBLIC_PLAYER_CHAT));
|
||||
}
|
||||
this.setChatting(false);
|
||||
}
|
||||
|
||||
public String getCurrentChatBuffer() {
|
||||
return this.chatBuffer.toString();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
package nl.andrewlalis.aos_client.net;
|
||||
|
||||
import nl.andrewlalis.aos_client.Client;
|
||||
import nl.andrewlalis.aos_core.net.data.DataTypes;
|
||||
import nl.andrewlalis.aos_core.net.data.PlayerDetailUpdate;
|
||||
import nl.andrewlalis.aos_core.net.data.WorldUpdate;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.DatagramPacket;
|
||||
import java.net.DatagramSocket;
|
||||
import java.net.InetAddress;
|
||||
import java.net.SocketException;
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/**
|
||||
* This thread is responsible for managing incoming datagram packets, and also
|
||||
* offers functionality to send packets back to the server.
|
||||
*/
|
||||
public class DataTransceiver extends Thread {
|
||||
private final Client client;
|
||||
private final DatagramSocket socket;
|
||||
|
||||
private final InetAddress serverAddress;
|
||||
private final int serverPort;
|
||||
|
||||
private volatile boolean running;
|
||||
|
||||
/**
|
||||
* Constructs a new data transceiver thread, and immediately initiates a
|
||||
* connection to the server to ensure we've got an "outbound" connection.
|
||||
* @param client A reference to the client this thread is for.
|
||||
* @param serverAddress The server's address to connect to.
|
||||
* @param serverPort The server's port to connect to.
|
||||
* @throws IOException If we could not open the socket and initialize the
|
||||
* connection.
|
||||
*/
|
||||
public DataTransceiver(Client client, InetAddress serverAddress, int serverPort) throws IOException {
|
||||
this.client = client;
|
||||
this.serverAddress = serverAddress;
|
||||
this.serverPort = serverPort;
|
||||
this.socket = new DatagramSocket();
|
||||
this.initiateConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates the "connection" to the server's UDP socket. This is so that
|
||||
* the router knows that this is an "outbound" connection which the client
|
||||
* initiated. Otherwise, we can't receive UDP packets from the server.
|
||||
* @throws IOException If we couldn't connect.
|
||||
*/
|
||||
private void initiateConnection() throws IOException {
|
||||
boolean established = false;
|
||||
int attempts = 0;
|
||||
while (!established && attempts < 100) {
|
||||
this.send(new byte[]{DataTypes.INIT});
|
||||
byte[] buffer = new byte[1400];
|
||||
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
|
||||
this.socket.receive(packet);
|
||||
if (packet.getLength() == 1 && packet.getData()[0] == DataTypes.INIT) {
|
||||
established = true;
|
||||
}
|
||||
attempts++;
|
||||
}
|
||||
if (!established) {
|
||||
throw new IOException("Could not initiate UDP connection after " + attempts + " attempts.");
|
||||
}
|
||||
System.out.println("Initiated UDP connection with server.");
|
||||
}
|
||||
|
||||
public int getLocalPort() {
|
||||
return this.socket.getLocalPort();
|
||||
}
|
||||
|
||||
public void send(byte[] bytes) throws IOException {
|
||||
if (this.socket.isClosed()) return;
|
||||
var packet = new DatagramPacket(bytes, bytes.length, this.serverAddress, this.serverPort);
|
||||
this.socket.send(packet);
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
this.running = false;
|
||||
if (!this.socket.isClosed()) {
|
||||
this.socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
this.running = true;
|
||||
byte[] buffer = new byte[1400];
|
||||
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
|
||||
while (this.running) {
|
||||
try {
|
||||
this.socket.receive(packet);
|
||||
ByteBuffer b = ByteBuffer.wrap(packet.getData(), 0, packet.getLength());
|
||||
byte type = b.get();
|
||||
if (type == DataTypes.WORLD_DATA) {
|
||||
byte[] worldData = new byte[b.remaining()];
|
||||
b.get(worldData);
|
||||
WorldUpdate update = WorldUpdate.fromBytes(worldData);
|
||||
this.client.updateWorld(update);
|
||||
} else if (type == DataTypes.PLAYER_DETAIL) {
|
||||
byte[] detailData = new byte[b.remaining()];
|
||||
b.get(detailData);
|
||||
PlayerDetailUpdate update = PlayerDetailUpdate.fromBytes(detailData);
|
||||
this.client.updatePlayer(update);
|
||||
}
|
||||
} catch (SocketException e) {
|
||||
if (!e.getMessage().equals("Socket closed")) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
this.shutdown();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,175 @@
|
|||
package nl.andrewlalis.aos_client.net;
|
||||
|
||||
import nl.andrewlalis.aos_client.Client;
|
||||
import nl.andrewlalis.aos_core.net.*;
|
||||
import nl.andrewlalis.aos_core.net.chat.ChatMessage;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.io.*;
|
||||
import java.net.Socket;
|
||||
import java.net.SocketException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* This thread is responsible for handling TCP message communication with the
|
||||
* server. During its {@link MessageTransceiver#run()} method, it will try to
|
||||
* receive objects from the server, and process them.
|
||||
* <p>
|
||||
* It also manages an internal UDP transceiver for sending and receiving
|
||||
* high volume, lightweight data packets about things like world updates and
|
||||
* player input events.
|
||||
* </p>
|
||||
*/
|
||||
public class MessageTransceiver extends Thread {
|
||||
/**
|
||||
* A reference to the client that this transceiver thread is working for.
|
||||
*/
|
||||
private final Client client;
|
||||
|
||||
/**
|
||||
* The TCP socket that's used for communication.
|
||||
*/
|
||||
private final Socket socket;
|
||||
|
||||
/**
|
||||
* An internal datagram transceiver that is used for UDP communication.
|
||||
*/
|
||||
private final DataTransceiver dataTransceiver;
|
||||
|
||||
/**
|
||||
* Output stream that is used for sending objects to the server.
|
||||
*/
|
||||
private final ObjectOutputStream out;
|
||||
|
||||
/**
|
||||
* Input stream that is used for receiving objects from the server.
|
||||
*/
|
||||
private final ObjectInputStream in;
|
||||
|
||||
/**
|
||||
* A single-threaded executor that is used to queue and send messages to the
|
||||
* server sequentially without blocking the main transceiver thread.
|
||||
*/
|
||||
private final ExecutorService writeService = Executors.newFixedThreadPool(1);
|
||||
|
||||
private volatile boolean running = true;
|
||||
|
||||
public MessageTransceiver(Client client, String serverHost, int serverPort, String username) throws IOException {
|
||||
this.client = client;
|
||||
this.socket = new Socket(serverHost, serverPort);
|
||||
this.dataTransceiver = new DataTransceiver(client, this.socket.getInetAddress(), this.socket.getPort());
|
||||
this.out = new ObjectOutputStream(this.socket.getOutputStream());
|
||||
this.in = new ObjectInputStream(this.socket.getInputStream());
|
||||
this.initializeConnection(username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the TCP connection to the server. This involves sending an
|
||||
* IDENT packet containing some data the server needs about this client, and
|
||||
* waiting for a {@link PlayerRegisteredMessage} response from the server,
|
||||
* which contains the basic data we need to start the game.
|
||||
* @param username The username for this client.
|
||||
* @throws IOException If the connection could not be initialized.
|
||||
*/
|
||||
private void initializeConnection(String username) throws IOException {
|
||||
boolean established = false;
|
||||
int attempts = 0;
|
||||
while (!established && attempts < 100) {
|
||||
this.send(new IdentMessage(username, this.dataTransceiver.getLocalPort()));
|
||||
try {
|
||||
Object obj = this.in.readObject();
|
||||
if (obj instanceof PlayerRegisteredMessage msg) {
|
||||
this.client.setPlayer(msg.getPlayer());
|
||||
this.client.setWorld(msg.getWorld());
|
||||
established = true;
|
||||
} else if (obj instanceof ConnectionRejectedMessage msg) {
|
||||
throw new IOException(msg.getMessage());
|
||||
}
|
||||
} catch (ClassNotFoundException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
attempts++;
|
||||
}
|
||||
if (!established) {
|
||||
throw new IOException("Could not initialize connection to server in " + attempts + " attempts.");
|
||||
}
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
this.running = false;
|
||||
this.dataTransceiver.shutdown();
|
||||
this.writeService.shutdown();
|
||||
try {
|
||||
this.out.close();
|
||||
this.in.close();
|
||||
this.socket.close();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message to the server, by submitting it to the write service's
|
||||
* queue.
|
||||
* @param message The message to send.
|
||||
*/
|
||||
public void send(Message message) {
|
||||
if (this.socket.isClosed()) return;
|
||||
this.writeService.submit(() -> {
|
||||
try {
|
||||
this.out.reset();
|
||||
this.out.writeObject(message);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a packet via UDP to the server.
|
||||
* @param type The type of data to send.
|
||||
* @param playerId The id of the player.
|
||||
* @param data The data to send.
|
||||
* @throws IOException If the data could not be sent.
|
||||
*/
|
||||
public void sendData(byte type, int playerId, byte[] data) throws IOException {
|
||||
ByteBuffer buffer = ByteBuffer.allocate(1 + Integer.BYTES + data.length);
|
||||
buffer.put(type);
|
||||
buffer.putInt(playerId);
|
||||
buffer.put(data);
|
||||
this.dataTransceiver.send(buffer.array());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
this.dataTransceiver.start();
|
||||
while (this.running) {
|
||||
try {
|
||||
Message msg = (Message) this.in.readObject();
|
||||
if (msg.getType() == Type.CHAT) {
|
||||
this.client.getChatManager().addChatMessage((ChatMessage) msg);
|
||||
} else if (msg.getType() == Type.PLAYER_JOINED && this.client.getWorld() != null) {
|
||||
PlayerUpdateMessage pum = (PlayerUpdateMessage) msg;
|
||||
this.client.getWorld().getPlayers().put(pum.getPlayer().getId(), pum.getPlayer());
|
||||
} else if (msg.getType() == Type.PLAYER_LEFT && this.client.getWorld() != null) {
|
||||
PlayerUpdateMessage pum = (PlayerUpdateMessage) msg;
|
||||
this.client.getWorld().getPlayers().remove(pum.getPlayer().getId());
|
||||
} else if (msg.getType() == Type.SERVER_SHUTDOWN) {
|
||||
this.client.shutdown();
|
||||
JOptionPane.showMessageDialog(null, "Server has been shut down.", "Server Shutdown", JOptionPane.WARNING_MESSAGE);
|
||||
}
|
||||
} catch (StreamCorruptedException | EOFException e) {
|
||||
this.shutdown();
|
||||
} catch (SocketException e) {
|
||||
if (!e.getMessage().equalsIgnoreCase("Socket closed")) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
this.shutdown();
|
||||
} catch (IOException | ClassNotFoundException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
package nl.andrewlalis.aos_client.sound;
|
||||
|
||||
import nl.andrewlalis.aos_core.model.Player;
|
||||
import nl.andrewlalis.aos_core.net.data.SoundData;
|
||||
|
||||
import javax.sound.sampled.AudioFormat;
|
||||
import javax.sound.sampled.AudioInputStream;
|
||||
import javax.sound.sampled.AudioSystem;
|
||||
import javax.sound.sampled.UnsupportedAudioFileException;
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* Simple container object for an in-memory audio clip. The contents of this
|
||||
* clip are played using a {@link javax.sound.sampled.SourceDataLine} during
|
||||
* runtime.
|
||||
* @see SoundManager#play(SoundData, Player)
|
||||
*/
|
||||
public class AudioClip {
|
||||
private final AudioFormat format;
|
||||
private final byte[] samples;
|
||||
|
||||
/**
|
||||
* Constructs a new audio clip, using the given resource name to load audio
|
||||
* data from a classpath resource.
|
||||
* @param resource The name of the classpath resource to load.
|
||||
* @throws IOException If the clip could not be loaded.
|
||||
*/
|
||||
public AudioClip(String resource) throws IOException {
|
||||
try {
|
||||
InputStream inputStream = AudioClip.class.getResourceAsStream(resource);
|
||||
if (inputStream == null) throw new IOException("Could not get resource as stream: " + resource);
|
||||
AudioInputStream in = AudioSystem.getAudioInputStream(new BufferedInputStream(inputStream));
|
||||
this.format = in.getFormat();
|
||||
this.samples = in.readAllBytes();
|
||||
} catch (UnsupportedAudioFileException e) {
|
||||
e.printStackTrace();
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public AudioFormat getFormat() {
|
||||
return format;
|
||||
}
|
||||
|
||||
public byte[] getSamples() {
|
||||
return samples;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,195 @@
|
|||
package nl.andrewlalis.aos_client.sound;
|
||||
|
||||
import nl.andrewlalis.aos_core.geom.Vec2;
|
||||
import nl.andrewlalis.aos_core.model.Player;
|
||||
import nl.andrewlalis.aos_core.net.data.SoundData;
|
||||
import nl.andrewlalis.aos_core.util.TimedCompletableFuture;
|
||||
|
||||
import javax.sound.sampled.AudioSystem;
|
||||
import javax.sound.sampled.FloatControl;
|
||||
import javax.sound.sampled.SourceDataLine;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
/**
|
||||
* The sound manager is responsible for playing game sounds, using a cached set
|
||||
* of audio clips that are loaded from sound resource files. Sounds are played
|
||||
* using a thread pool.
|
||||
*/
|
||||
public class SoundManager {
|
||||
/**
|
||||
* The range in which a player can hear a sound. If it's further than this
|
||||
* distance, the sound is not played.
|
||||
*/
|
||||
private static final float HEARING_RANGE = 50.0f;
|
||||
|
||||
/**
|
||||
* The size of the clip buffer used during audio playback, in seconds.
|
||||
*/
|
||||
private static final float CLIP_BUFFER_SIZE = 1.0f / 10.0f;
|
||||
|
||||
private final ExecutorService soundPlayerThreadPool = Executors.newCachedThreadPool();
|
||||
|
||||
/**
|
||||
* Cached set of audio clips that are used for playing sounds, instead of
|
||||
* loading each clip's bytes from the disk each time.
|
||||
*/
|
||||
private final Map<String, AudioClip> audioClips = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Map that keeps track of the last footstep sound made by any player, which
|
||||
* is used in determining when the next footstep sound should be played. We
|
||||
* use a {@link TimedCompletableFuture} so that we know how much time has
|
||||
* elapsed since the footstep started.
|
||||
*/
|
||||
private final Map<Player, TimedCompletableFuture<Void>> footstepAudioFutures = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Plays the given list of sounds from the player's perspective.
|
||||
* @param sounds The list of sounds to play.
|
||||
* @param player The player that's hearing the sounds.
|
||||
*/
|
||||
public void play(List<SoundData> sounds, Player player) {
|
||||
for (SoundData sound : sounds) {
|
||||
this.play(sound.getType().getSoundName(), sound.getPosition(), sound.getVolume(), player);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays a sound without any perspective, i.e. constant volume no matter
|
||||
* where the player is.
|
||||
* @param sound The sound to play.
|
||||
*/
|
||||
public void play(SoundData sound) {
|
||||
this.play(sound.getType().getSoundName(), sound.getPosition(), sound.getVolume(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the given sound from the player's perspective.
|
||||
* @param soundName The name of the sound.
|
||||
* @param soundOrigin The origin point of the sound.
|
||||
* @param originalVolume The original volume of the sound.
|
||||
* @param player The player that's hearing the sounds.
|
||||
* @return A future that completes when the sound is done playing.
|
||||
*/
|
||||
public TimedCompletableFuture<Void> play(String soundName, Vec2 soundOrigin, float originalVolume, Player player) {
|
||||
TimedCompletableFuture<Void> cf = new TimedCompletableFuture<>();
|
||||
final float volume = this.computeVolume(originalVolume, soundOrigin, player);
|
||||
if (volume <= 0.0f) {
|
||||
cf.complete(null); // Don't play the sound at all, if its volume is nothing.
|
||||
return cf;
|
||||
}
|
||||
final float pan = this.computePan(soundOrigin, player);
|
||||
this.soundPlayerThreadPool.submit(() -> {
|
||||
this.play(soundName, pan, volume);
|
||||
cf.complete(null);
|
||||
});
|
||||
return cf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the audio clip for a sound, using the given pan and volume settings.
|
||||
* <p>
|
||||
* This method is blocking, and should ideally be called in a separate
|
||||
* thread or submitted as a lambda expression to a thread pool.
|
||||
* </p>
|
||||
* @param soundName The sound to play.
|
||||
* @param pan The pan setting, from -1.0 (left) to 1.0 (right).
|
||||
* @param volume The volume, from 0.0 to 1.0.
|
||||
*/
|
||||
private void play(String soundName, float pan, float volume) {
|
||||
try {
|
||||
AudioClip clip = this.getAudioClip(soundName);
|
||||
final int bufferSize = clip.getFormat().getFrameSize() * Math.round(clip.getFormat().getSampleRate() * CLIP_BUFFER_SIZE);
|
||||
byte[] buffer = new byte[bufferSize];
|
||||
SourceDataLine line = AudioSystem.getSourceDataLine(clip.getFormat());
|
||||
line.open(clip.getFormat(), bufferSize);
|
||||
line.start();
|
||||
|
||||
// Set pan.
|
||||
FloatControl panControl = (FloatControl) line.getControl(FloatControl.Type.PAN);
|
||||
panControl.setValue(pan);
|
||||
// Set volume.
|
||||
volume = Math.max(Math.min(volume, 1.0f), 0.0f);
|
||||
FloatControl gainControl = (FloatControl) line.getControl(FloatControl.Type.MASTER_GAIN);
|
||||
gainControl.setValue(20f * (float) Math.log10(volume));
|
||||
|
||||
InputStream source = new ByteArrayInputStream(clip.getSamples());
|
||||
int bytesRead = 0;
|
||||
while (bytesRead != -1) {
|
||||
bytesRead = source.read(buffer, 0, bufferSize);
|
||||
if (bytesRead != -1) {
|
||||
line.write(buffer, 0, bytesRead);
|
||||
}
|
||||
}
|
||||
line.drain();
|
||||
line.close();
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public void playWalking(Player emitter, Player listener) {
|
||||
var f = this.footstepAudioFutures.get(emitter);
|
||||
long delay = 500;
|
||||
if (emitter.isSprinting()) {
|
||||
delay -= 150;
|
||||
} else if (emitter.isSneaking()) {
|
||||
delay += 150;
|
||||
}
|
||||
if (f == null || f.getElapsedMillis() > delay) {
|
||||
int choice = ThreadLocalRandom.current().nextInt(1, 5);
|
||||
var cf = this.play("footsteps" + choice + ".wav", emitter.getPosition(), 0.1f, listener);
|
||||
this.footstepAudioFutures.put(emitter, cf);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the volume that a sound should be played at, from the given
|
||||
* player's perspective.
|
||||
* @param originalVolume The original volume of the sound.
|
||||
* @param soundOrigin The origin point of the sound.
|
||||
* @param player The player that will be hearing the sound.
|
||||
* @return The volume the sound should be played at, from 0.0 to 1.0.
|
||||
*/
|
||||
private float computeVolume(float originalVolume, Vec2 soundOrigin, Player player) {
|
||||
if (player != null && soundOrigin != null) {
|
||||
float dist = player.getPosition().dist(soundOrigin);
|
||||
originalVolume *= (Math.max(HEARING_RANGE - dist, 0) / HEARING_RANGE);
|
||||
}
|
||||
return originalVolume;
|
||||
}
|
||||
|
||||
private float computePan(Vec2 soundOrigin, Player player) {
|
||||
float pan = 0.0f;
|
||||
if (player != null && player.getTeam() != null && soundOrigin != null) {
|
||||
Vec2 soundDir = soundOrigin
|
||||
.sub(player.getPosition())
|
||||
.rotate(player.getTeam().getOrientation().perp().angle())
|
||||
.unit();
|
||||
pan = Math.max(Math.min(soundDir.dot(Vec2.RIGHT), 1.0f), -1.0f);
|
||||
if (Float.isNaN(pan)) pan = 0f;
|
||||
}
|
||||
return pan;
|
||||
}
|
||||
|
||||
private AudioClip getAudioClip(String soundName) throws IOException {
|
||||
AudioClip clip = this.audioClips.get(soundName);
|
||||
if (clip == null) {
|
||||
clip = new AudioClip("/nl/andrewlalis/aos_client/sound/" + soundName);
|
||||
this.audioClips.put(soundName, clip);
|
||||
}
|
||||
return clip;
|
||||
}
|
||||
|
||||
public void close() {
|
||||
this.soundPlayerThreadPool.shutdown();
|
||||
}
|
||||
}
|
|
@ -4,17 +4,29 @@ import nl.andrewlalis.aos_client.Client;
|
|||
import nl.andrewlalis.aos_client.control.PlayerKeyListener;
|
||||
import nl.andrewlalis.aos_client.control.PlayerMouseListener;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.awt.event.WindowAdapter;
|
||||
import java.awt.event.WindowEvent;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
public class GameFrame extends JFrame {
|
||||
public GameFrame(String title, Client client, GamePanel gamePanel) throws HeadlessException {
|
||||
super(title);
|
||||
this.setDefaultCloseOperation(DISPOSE_ON_CLOSE);
|
||||
|
||||
InputStream iconInputStream = GameFrame.class.getClassLoader().getResourceAsStream("/nl/andrewlalis/aos_client/icon.png");
|
||||
if (iconInputStream != null) {
|
||||
try {
|
||||
this.setIconImage(ImageIO.read(iconInputStream));
|
||||
iconInputStream.close();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
this.setResizable(false);
|
||||
|
||||
gamePanel.setPreferredSize(new Dimension(800, 800));
|
||||
this.setContentPane(gamePanel);
|
||||
gamePanel.setFocusable(true);
|
||||
|
@ -24,6 +36,7 @@ public class GameFrame extends JFrame {
|
|||
gamePanel.addMouseListener(mouseListener);
|
||||
gamePanel.addMouseMotionListener(mouseListener);
|
||||
gamePanel.addMouseWheelListener(mouseListener);
|
||||
|
||||
this.addWindowListener(new WindowAdapter() {
|
||||
@Override
|
||||
public void windowClosing(WindowEvent e) {
|
||||
|
@ -31,7 +44,9 @@ public class GameFrame extends JFrame {
|
|||
client.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
this.pack();
|
||||
|
||||
gamePanel.requestFocusInWindow();
|
||||
this.setLocationRelativeTo(null);
|
||||
}
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
package nl.andrewlalis.aos_client.view;
|
||||
|
||||
import nl.andrewlalis.aos_client.net.ChatManager;
|
||||
import nl.andrewlalis.aos_client.Client;
|
||||
import nl.andrewlalis.aos_core.model.*;
|
||||
import nl.andrewlalis.aos_core.model.tools.Gun;
|
||||
import nl.andrewlalis.aos_core.net.chat.ChatMessage;
|
||||
import nl.andrewlalis.aos_core.net.chat.PlayerChatMessage;
|
||||
import nl.andrewlalis.aos_core.net.chat.ChatType;
|
||||
import nl.andrewlalis.aos_core.net.chat.SystemChatMessage;
|
||||
|
||||
import javax.swing.*;
|
||||
|
@ -12,22 +14,44 @@ import java.awt.geom.AffineTransform;
|
|||
import java.awt.geom.Ellipse2D;
|
||||
import java.awt.geom.Rectangle2D;
|
||||
|
||||
/**
|
||||
* The game panel is the component in which the entire game is rendered. It uses
|
||||
* a custom implementation of {@link JPanel#paintComponent(Graphics)} to draw
|
||||
* the current state of the game.
|
||||
*/
|
||||
public class GamePanel extends JPanel {
|
||||
/**
|
||||
* A reference to the client that will be the source of game data to render.
|
||||
*/
|
||||
private final Client client;
|
||||
|
||||
/**
|
||||
* The list of possible view scale factors.
|
||||
*/
|
||||
private final double[] scales = {1.0, 2.5, 5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 35.0};
|
||||
|
||||
/**
|
||||
* The view's current scale factor, as an index of the
|
||||
* {@link GamePanel#scales} array.
|
||||
*/
|
||||
private int scaleIndex = 3;
|
||||
|
||||
public GamePanel(Client client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments the client's view scaling by one index, if possible.
|
||||
*/
|
||||
public void incrementScale() {
|
||||
if (scaleIndex < scales.length - 1) {
|
||||
scaleIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrements the client's view scaling by one index, if possible.
|
||||
*/
|
||||
public void decrementScale() {
|
||||
if (scaleIndex > 0) {
|
||||
scaleIndex--;
|
||||
|
@ -46,12 +70,21 @@ public class GamePanel extends JPanel {
|
|||
g2.clearRect(0, 0, this.getWidth(), this.getHeight());
|
||||
|
||||
World world = client.getWorld();
|
||||
if (world != null) drawWorld(g2, world);
|
||||
drawChat(g2, world);
|
||||
if (world != null) {
|
||||
drawWorld(g2, world);
|
||||
drawStatus(g2, world);
|
||||
}
|
||||
drawChat(g2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the current game world, including all objects, players, bullets,
|
||||
* and other structures.
|
||||
* @param g2 The graphics context.
|
||||
* @param world The world to render.
|
||||
*/
|
||||
private void drawWorld(Graphics2D g2, World world) {
|
||||
Player myPlayer = world.getPlayers().get(this.client.getPlayerId());
|
||||
Player myPlayer = client.getPlayer();
|
||||
if (myPlayer == null) return;
|
||||
double scale = this.scales[this.scaleIndex];
|
||||
AffineTransform pre = g2.getTransform();
|
||||
|
@ -61,9 +94,33 @@ public class GamePanel extends JPanel {
|
|||
this.drawField(g2, world);
|
||||
this.drawPlayers(g2, world);
|
||||
this.drawBullets(g2, world);
|
||||
this.drawMarkers(g2, world, myPlayer);
|
||||
|
||||
g2.setTransform(pre);
|
||||
|
||||
// Put shadow gradient.
|
||||
RadialGradientPaint p = new RadialGradientPaint(
|
||||
this.getWidth() / 2.0f,
|
||||
this.getHeight() / 2.0f,
|
||||
(float) (25 * scale),
|
||||
new float[]{0.0f, 1.0f},
|
||||
new Color[]{new Color(0, 0, 0, 0), new Color(0, 0, 0, 255)},
|
||||
MultipleGradientPaint.CycleMethod.NO_CYCLE
|
||||
);
|
||||
g2.setPaint(p);
|
||||
g2.fillRect(0, 0, this.getWidth(), this.getHeight());
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtains a transformation matrix that can be used when rendering the world
|
||||
* so that everything is scaled according to the player's view scaling, and
|
||||
* their team's orientation, and makes sure that the player is in the center
|
||||
* of the view.
|
||||
* @param player The player to get the transform for.
|
||||
* @param scale The current view scale factor.
|
||||
* @return A transformation matrix that can be applied to all subsequent
|
||||
* rendering operations.
|
||||
*/
|
||||
private AffineTransform getWorldTransform(Player player, double scale) {
|
||||
AffineTransform tx = new AffineTransform();
|
||||
tx.scale(scale, scale);
|
||||
|
@ -77,6 +134,12 @@ public class GamePanel extends JPanel {
|
|||
return tx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the world's basic shape and objects. This includes the world's own
|
||||
* area, and any fixed barricades in it, as well as various team structures.
|
||||
* @param g2 The graphics context.
|
||||
* @param world The world to draw.
|
||||
*/
|
||||
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()));
|
||||
|
@ -92,23 +155,36 @@ public class GamePanel extends JPanel {
|
|||
g2.fill(barricadeRect);
|
||||
}
|
||||
|
||||
for (Team t : world.getTeams()) {
|
||||
for (Team t : world.getTeams().values()) {
|
||||
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
|
||||
t.getSpawnPoint().x() - Team.SPAWN_RADIUS,
|
||||
t.getSpawnPoint().y() - Team.SPAWN_RADIUS,
|
||||
Team.SPAWN_RADIUS * 2,
|
||||
Team.SPAWN_RADIUS * 2
|
||||
);
|
||||
g2.draw(spawnCircle);
|
||||
Rectangle2D.Double supplyMarker = new Rectangle2D.Double(
|
||||
t.getSupplyPoint().x() - Team.SUPPLY_POINT_RADIUS,
|
||||
t.getSupplyPoint().y() - Team.SUPPLY_POINT_RADIUS,
|
||||
Team.SUPPLY_POINT_RADIUS * 2,
|
||||
Team.SUPPLY_POINT_RADIUS * 2
|
||||
);
|
||||
g2.draw(supplyMarker);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws all players in the world. In order to make drawing the various
|
||||
* parts of the player easier, we apply a transformation on top of the world
|
||||
* transform, such that the player is at (0, 0) and facing upwards.
|
||||
* @param g2 The graphics context.
|
||||
* @param world The world to get the list of players from.
|
||||
*/
|
||||
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);
|
||||
|
@ -117,23 +193,35 @@ public class GamePanel extends JPanel {
|
|||
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);
|
||||
|
||||
this.drawGun(g2, p.getGun());
|
||||
g2.setTransform(pre);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a player's gun.
|
||||
* @param g2 The graphics context.
|
||||
* @param gun The gun to draw.
|
||||
*/
|
||||
private void drawGun(Graphics2D g2, Gun gun) {
|
||||
g2.setColor(Color.decode(gun.getType().getColor()));
|
||||
Rectangle2D.Double gunBarrel = new Rectangle2D.Double(
|
||||
0,
|
||||
0.5,
|
||||
2,
|
||||
0.25
|
||||
);
|
||||
g2.fill(gunBarrel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the list of bullets in the world.
|
||||
* @param g2 The graphics context.
|
||||
* @param world The world to get the list of bullets from.
|
||||
*/
|
||||
private void drawBullets(Graphics2D g2, World world) {
|
||||
g2.setColor(Color.YELLOW);
|
||||
double bulletSize = 0.5;
|
||||
g2.setColor(Color.BLACK);
|
||||
double bulletSize = 0.25;
|
||||
for (Bullet b : world.getBullets()) {
|
||||
Ellipse2D.Double bulletShape = new Ellipse2D.Double(
|
||||
b.getPosition().x() - bulletSize / 2,
|
||||
|
@ -145,10 +233,38 @@ public class GamePanel extends JPanel {
|
|||
}
|
||||
}
|
||||
|
||||
private void drawChat(Graphics2D g2, World world) {
|
||||
/**
|
||||
* Draws player-oriented markers such as the player's name. These are done
|
||||
* in a separate step from the player rendering, to ensure that names are
|
||||
* not obscured by other objects.
|
||||
* @param g2 The graphics context.
|
||||
* @param world The world.
|
||||
* @param myPlayer The reference to this client's player.
|
||||
*/
|
||||
private void drawMarkers(Graphics2D g2, World world, Player myPlayer) {
|
||||
g2.setColor(Color.WHITE);
|
||||
for (Player p : world.getPlayers().values()) {
|
||||
if (p.getId() == myPlayer.getId()) continue;
|
||||
AffineTransform pre = g2.getTransform();
|
||||
AffineTransform tx = g2.getTransform();
|
||||
tx.translate(p.getPosition().x(), p.getPosition().y());
|
||||
tx.rotate(myPlayer.getTeam().getOrientation().perp().angle());
|
||||
tx.scale(0.1, 0.1);
|
||||
g2.setTransform(tx);
|
||||
g2.drawString(p.getName(), 0, 0);
|
||||
g2.setTransform(pre);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the list of chat messages in the top-left corner of the screen.
|
||||
* @param g2 The graphics context.
|
||||
*/
|
||||
private void drawChat(Graphics2D g2) {
|
||||
int height = g2.getFontMetrics().getHeight();
|
||||
int y = height;
|
||||
for (ChatMessage message : this.client.getLatestChatMessages()) {
|
||||
var cm = this.client.getChatManager();
|
||||
for (ChatMessage message : cm.getLatestChatMessages()) {
|
||||
Color color = Color.WHITE;
|
||||
String text = message.getText();
|
||||
if (message instanceof SystemChatMessage sysMsg) {
|
||||
|
@ -159,22 +275,48 @@ public class GamePanel extends JPanel {
|
|||
} else if (sysMsg.getLevel() == SystemChatMessage.Level.SEVERE) {
|
||||
color = Color.RED;
|
||||
}
|
||||
} else if (message instanceof PlayerChatMessage pcm) {
|
||||
String author = Integer.toString(pcm.getPlayerId());
|
||||
if (world != null) {
|
||||
Player p = world.getPlayers().get(pcm.getPlayerId());
|
||||
if (p != null) author = p.getName();
|
||||
} else {
|
||||
if (message.getChatType() == ChatType.TEAM_PLAYER_CHAT) {
|
||||
color = Color.GREEN;
|
||||
} else if (message.getChatType() == ChatType.PRIVATE_PLAYER_CHAT) {
|
||||
color = Color.CYAN;
|
||||
}
|
||||
text = author + ": " + text;
|
||||
}
|
||||
g2.setColor(color);
|
||||
g2.drawString(text, 5, y);
|
||||
y += height;
|
||||
}
|
||||
|
||||
if (this.client.isChatting()) {
|
||||
if (cm.isChatting()) {
|
||||
g2.setColor(Color.WHITE);
|
||||
g2.drawString("> " + this.client.getCurrentChatBuffer(), 5, height * 11);
|
||||
g2.drawString("> " + cm.getCurrentChatBuffer(), 5, height * (ChatManager.MAX_CHAT_MESSAGES + 1));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws player status information in the bottom-left corner of the screen.
|
||||
* @param g2 The graphics context.
|
||||
* @param world The world containing the player's data.
|
||||
*/
|
||||
private void drawStatus(Graphics2D g2, World world) {
|
||||
Player myPlayer = this.client.getPlayer();
|
||||
if (myPlayer == null) return;
|
||||
|
||||
g2.setColor(Color.WHITE);
|
||||
if (myPlayer.isReloading()) {
|
||||
g2.drawString("Reloading...", 5, this.getHeight() - 10);
|
||||
}
|
||||
Gun gun = myPlayer.getGun();
|
||||
g2.drawString("Clips: " + gun.getClipCount() + " / " + gun.getType().getMaxClipCount(), 5, this.getHeight() - 20);
|
||||
g2.drawString("Bullets: " + gun.getCurrentClipBulletCount() + " / " + gun.getType().getClipSize(), 5, this.getHeight() - 30);
|
||||
g2.setColor(Color.GREEN);
|
||||
g2.drawString(String.format("Health: %.1f", myPlayer.getHealth()), 5, this.getHeight() - 40);
|
||||
|
||||
int y = this.getHeight() - 60;
|
||||
for (Team t : world.getTeams().values()) {
|
||||
g2.setColor(t.getColor());
|
||||
g2.drawString("Team " + t.getName() + ": " + t.getScore(), 5, y);
|
||||
y -= 15;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package nl.andrewlalis.aos_client;
|
||||
package nl.andrewlalis.aos_client.view;
|
||||
|
||||
import nl.andrewlalis.aos_client.view.GamePanel;
|
||||
import nl.andrewlalis.aos_client.Client;
|
||||
import nl.andrewlalis.aos_core.model.Bullet;
|
||||
import nl.andrewlalis.aos_core.model.Player;
|
||||
import nl.andrewlalis.aos_core.model.World;
|
||||
|
@ -35,7 +35,7 @@ public class GameRenderer extends Thread {
|
|||
long now = System.currentTimeMillis();
|
||||
long msSinceLastFrame = now - lastFrame;
|
||||
if (msSinceLastFrame >= MS_PER_FRAME) {
|
||||
double elapsedSeconds = msSinceLastFrame / 1000.0;
|
||||
float elapsedSeconds = msSinceLastFrame / 1000.0f;
|
||||
this.gamePanel.repaint();
|
||||
this.updateWorld(elapsedSeconds);
|
||||
lastFrame = now;
|
||||
|
@ -52,7 +52,7 @@ public class GameRenderer extends Thread {
|
|||
}
|
||||
}
|
||||
|
||||
private void updateWorld(double t) {
|
||||
private void updateWorld(float t) {
|
||||
World world = this.client.getWorld();
|
||||
for (Player p : world.getPlayers().values()) {
|
||||
p.setPosition(p.getPosition().add(p.getVelocity().mul(t)));
|
Binary file not shown.
After ![]() (image error) Size: 43 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
27
core/pom.xml
27
core/pom.xml
|
@ -5,7 +5,7 @@
|
|||
<parent>
|
||||
<artifactId>ace-of-shades</artifactId>
|
||||
<groupId>nl.andrewlalis</groupId>
|
||||
<version>2.1</version>
|
||||
<version>0.5.0</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
|
@ -16,4 +16,29 @@
|
|||
<maven.compiler.target>16</maven.compiler.target>
|
||||
</properties>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.8.1</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<dependencies>
|
||||
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>2.12.3</version>
|
||||
</dependency>
|
||||
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.dataformat/jackson-dataformat-yaml -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.dataformat</groupId>
|
||||
<artifactId>jackson-dataformat-yaml</artifactId>
|
||||
<version>2.12.3</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
|
@ -1,7 +1,13 @@
|
|||
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;
|
||||
exports nl.andrewlalis.aos_core.net.chat to aos_client, aos_server;
|
||||
exports nl.andrewlalis.aos_core.net.data to aos_server, aos_client;
|
||||
|
||||
exports nl.andrewlalis.aos_core.model to aos_server, aos_client;
|
||||
exports nl.andrewlalis.aos_core.model.tools to aos_client, aos_server;
|
||||
|
||||
exports nl.andrewlalis.aos_core.geom to aos_server, aos_client;
|
||||
exports nl.andrewlalis.aos_core.util to aos_server, aos_client;
|
||||
}
|
|
@ -1,11 +1,24 @@
|
|||
package nl.andrewlalis.aos_core.geom;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.Serializable;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
public record Vec2(double x, double y) implements Serializable {
|
||||
/**
|
||||
* A two-dimensional, floating-point vector consisting of an x- and y-component.
|
||||
*/
|
||||
public record Vec2(float x, float y) implements Serializable {
|
||||
public static final Vec2 ZERO = new Vec2(0, 0);
|
||||
public static final Vec2 UP = new Vec2(0, -1);
|
||||
public static final Vec2 DOWN = new Vec2(0, 1);
|
||||
public static final Vec2 RIGHT = new Vec2(1, 0);
|
||||
public static final Vec2 LEFT = new Vec2(-1, 0);
|
||||
|
||||
public double mag() {
|
||||
return Math.sqrt(x * x + y * y);
|
||||
|
||||
public float mag() {
|
||||
return (float) Math.sqrt(x * x + y * y);
|
||||
}
|
||||
|
||||
public Vec2 add(Vec2 other) {
|
||||
|
@ -16,16 +29,16 @@ public record Vec2(double x, double y) implements Serializable {
|
|||
return new Vec2(this.x - other.x, this.y - other.y);
|
||||
}
|
||||
|
||||
public Vec2 mul(double factor) {
|
||||
public Vec2 mul(float factor) {
|
||||
return new Vec2(this.x * factor, this.y * factor);
|
||||
}
|
||||
|
||||
public Vec2 unit() {
|
||||
double mag = this.mag();
|
||||
float mag = this.mag();
|
||||
return new Vec2(this.x / mag, this.y / mag);
|
||||
}
|
||||
|
||||
public double dot(Vec2 other) {
|
||||
public float dot(Vec2 other) {
|
||||
return this.x * other.x + this.y * other.y;
|
||||
}
|
||||
|
||||
|
@ -37,14 +50,14 @@ public record Vec2(double x, double y) implements Serializable {
|
|||
return new Vec2(this.y, -this.x);
|
||||
}
|
||||
|
||||
public double dist(Vec2 other) {
|
||||
public float 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)
|
||||
(float) (this.x * Math.cos(theta) - this.y * Math.sin(theta)),
|
||||
(float) (this.x * Math.sin(theta) + this.y * Math.cos(theta))
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -56,4 +69,15 @@ public record Vec2(double x, double y) implements Serializable {
|
|||
public String toString() {
|
||||
return "[ " + x + ", " + y + " ]";
|
||||
}
|
||||
|
||||
public static Vec2 random(float min, float max) {
|
||||
Random r = ThreadLocalRandom.current();
|
||||
float x = r.nextFloat() * (max - min) + min;
|
||||
float y = r.nextFloat() * (max - min) + min;
|
||||
return new Vec2(x, y);
|
||||
}
|
||||
|
||||
public static Vec2 read(DataInputStream in) throws IOException {
|
||||
return new Vec2(in.readFloat(), in.readFloat());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ public class Barricade implements Serializable {
|
|||
this.size = size;
|
||||
}
|
||||
|
||||
public Barricade(double x, double y, double w, double h) {
|
||||
public Barricade(float x, float y, float w, float h) {
|
||||
this(new Vec2(x, y), new Vec2(w, h));
|
||||
}
|
||||
|
||||
|
|
|
@ -1,16 +1,59 @@
|
|||
package nl.andrewlalis.aos_core.model;
|
||||
|
||||
import nl.andrewlalis.aos_core.geom.Vec2;
|
||||
import nl.andrewlalis.aos_core.model.tools.Gun;
|
||||
|
||||
/**
|
||||
* Represents a single projectile bullet fired from a player's gun. When shot by
|
||||
* a player, a newly-spawned bullet will be initialized with a velocity in the
|
||||
* general direction of the gun, with some perturbation according to the gun's
|
||||
* accuracy and player's sprinting/sneaking status.
|
||||
*/
|
||||
public class Bullet extends PhysicsObject {
|
||||
public static final double SPEED = 100.0; // Meters per second.
|
||||
|
||||
private final int playerId;
|
||||
private final Player player;
|
||||
private final Gun gun;
|
||||
|
||||
public Bullet(Player player) {
|
||||
super(player.getPosition().add(player.getOrientation().mul(1.5)), player.getOrientation(), player.getOrientation().mul(SPEED));
|
||||
public Bullet(Player player, float sneakAccuracyModifier, float sprintAccuracyModifier) {
|
||||
this.playerId = player.getId();
|
||||
this.player = player;
|
||||
this.gun = player.getGun();
|
||||
this.setPhysicsProperties(sneakAccuracyModifier, sprintAccuracyModifier);
|
||||
}
|
||||
|
||||
public Bullet(Vec2 position, Vec2 velocity) {
|
||||
super(position, new Vec2(0, -1), velocity);
|
||||
this.playerId = -1;
|
||||
this.player = null;
|
||||
this.gun = null;
|
||||
}
|
||||
|
||||
private void setPhysicsProperties(float sneakAccuracyModifier, float sprintAccuracyModifier) {
|
||||
this.setPosition(player.getPosition()
|
||||
.add(player.getOrientation().mul(1.5f))
|
||||
.add(player.getOrientation().perp().mul(Player.RADIUS))
|
||||
);
|
||||
this.setOrientation(player.getOrientation());
|
||||
float accuracy = player.getGun().getType().getAccuracy();
|
||||
if (player.isSneaking()) {
|
||||
accuracy *= sneakAccuracyModifier;
|
||||
} else if (player.isSprinting()) {
|
||||
accuracy *= sprintAccuracyModifier;
|
||||
}
|
||||
Vec2 perturbation = Vec2.random(-1, 1).mul(accuracy);
|
||||
Vec2 localVelocity = this.getOrientation().add(perturbation).mul(player.getGun().getType().getBulletSpeed());
|
||||
this.setVelocity(player.getVelocity().add(localVelocity));
|
||||
}
|
||||
|
||||
public int getPlayerId() {
|
||||
return playerId;
|
||||
}
|
||||
|
||||
public Player getPlayer() {
|
||||
return player;
|
||||
}
|
||||
|
||||
public Gun getGun() {
|
||||
return gun;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
package nl.andrewlalis.aos_core.model;
|
||||
|
||||
public class Gun {
|
||||
|
||||
}
|
|
@ -1,26 +1,43 @@
|
|||
package nl.andrewlalis.aos_core.model;
|
||||
|
||||
import nl.andrewlalis.aos_core.geom.Vec2;
|
||||
import nl.andrewlalis.aos_core.model.tools.Gun;
|
||||
import nl.andrewlalis.aos_core.model.tools.GunType;
|
||||
|
||||
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.
|
||||
public class Player extends PhysicsObject implements Comparable<Player> {
|
||||
public static final float MOVEMENT_THRESHOLD = 0.001f; // Threshold for stopping movement. Speeds slower than this are reduced to 0.
|
||||
public static final float RADIUS = 0.5f; // Collision radius, in meters.
|
||||
|
||||
private final int id;
|
||||
private final String name;
|
||||
private Team team;
|
||||
private PlayerControlState state;
|
||||
private Gun gun;
|
||||
private float health;
|
||||
|
||||
private transient long lastShot;
|
||||
private transient long reloadingStartedAt;
|
||||
private boolean reloading;
|
||||
private transient long lastResupply;
|
||||
|
||||
public Player(int id, String name, Team team) {
|
||||
// Stats
|
||||
private transient int killCount;
|
||||
private transient int deathCount;
|
||||
private transient int shotCount;
|
||||
private transient int resupplyCount;
|
||||
private transient int killStreak;
|
||||
|
||||
public Player(int id, String name, Team team, GunType gunType, float maxHealth) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.team = team;
|
||||
this.state = new PlayerControlState();
|
||||
this.state.setPlayerId(this.id);
|
||||
this.updateLastShot();
|
||||
this.gun = new Gun(gunType);
|
||||
this.health = maxHealth;
|
||||
this.useWeapon();
|
||||
this.lastShot = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
public int getId() {
|
||||
|
@ -47,12 +64,130 @@ public class Player extends PhysicsObject {
|
|||
this.team = team;
|
||||
}
|
||||
|
||||
public long getLastShot() {
|
||||
return lastShot;
|
||||
public Gun getGun() {
|
||||
return gun;
|
||||
}
|
||||
|
||||
public void updateLastShot() {
|
||||
public void setGun(Gun gun) {
|
||||
this.gun = gun;
|
||||
}
|
||||
|
||||
public void setHealth(float health) {
|
||||
this.health = health;
|
||||
}
|
||||
|
||||
public void setReloading(boolean reloading) {
|
||||
this.reloading = reloading;
|
||||
}
|
||||
|
||||
public boolean canUseWeapon() {
|
||||
return this.state.isShooting() &&
|
||||
!this.state.isReloading() &&
|
||||
!this.reloading &&
|
||||
this.gun.getCurrentClipBulletCount() > 0 &&
|
||||
this.lastShot + ((long) (this.gun.getType().getShotCooldownTime() * 1000)) < System.currentTimeMillis() &&
|
||||
(this.getTeam() == null || this.getTeam().getSpawnPoint().dist(this.getPosition()) > Team.SPAWN_RADIUS);
|
||||
}
|
||||
|
||||
public void useWeapon() {
|
||||
this.lastShot = System.currentTimeMillis();
|
||||
this.gun.decrementBulletCount();
|
||||
this.shotCount++;
|
||||
}
|
||||
|
||||
public void startReloading() {
|
||||
this.reloading = true;
|
||||
this.reloadingStartedAt = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
public void finishReloading() {
|
||||
this.gun.reload();
|
||||
this.reloading = false;
|
||||
}
|
||||
|
||||
public boolean isReloadingComplete() {
|
||||
long msSinceStart = System.currentTimeMillis() - this.reloadingStartedAt;
|
||||
return msSinceStart > this.gun.getType().getReloadTime() * 1000;
|
||||
}
|
||||
|
||||
public boolean isReloading() {
|
||||
return reloading;
|
||||
}
|
||||
|
||||
public boolean canResupply(float resupplyCooldown) {
|
||||
return this.team != null &&
|
||||
this.team.getSupplyPoint().dist(this.getPosition()) < Team.SUPPLY_POINT_RADIUS &&
|
||||
System.currentTimeMillis() - this.lastResupply > resupplyCooldown * 1000;
|
||||
}
|
||||
|
||||
public void resupply(float maxHealth) {
|
||||
this.lastResupply = System.currentTimeMillis();
|
||||
this.gun.refillClips();
|
||||
this.health = maxHealth;
|
||||
this.resupplyCount++;
|
||||
}
|
||||
|
||||
public float getHealth() {
|
||||
return health;
|
||||
}
|
||||
|
||||
public void takeDamage(float damage) {
|
||||
this.health = Math.max(this.health - damage, 0.0f);
|
||||
}
|
||||
|
||||
public void respawn(float maxHealth) {
|
||||
this.resupply(maxHealth);
|
||||
this.gun.emptyCurrentClip();
|
||||
if (this.team != null) {
|
||||
this.setPosition(this.team.getSpawnPoint().add(Vec2.random(-Team.SPAWN_RADIUS / 2, Team.SPAWN_RADIUS / 2)));
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isSneaking() {
|
||||
return this.state.isSneaking() &&
|
||||
!this.state.isSprinting();
|
||||
}
|
||||
|
||||
public boolean isSprinting() {
|
||||
return this.state.isSprinting() &&
|
||||
!this.state.isSneaking();
|
||||
}
|
||||
|
||||
public int getKillCount() {
|
||||
return killCount;
|
||||
}
|
||||
|
||||
public int getDeathCount() {
|
||||
return deathCount;
|
||||
}
|
||||
|
||||
public int getShotCount() {
|
||||
return shotCount;
|
||||
}
|
||||
|
||||
public int getResupplyCount() {
|
||||
return resupplyCount;
|
||||
}
|
||||
|
||||
public int getKillStreak() {
|
||||
return killStreak;
|
||||
}
|
||||
|
||||
public void incrementDeathCount() {
|
||||
this.deathCount++;
|
||||
this.killStreak = 0;
|
||||
}
|
||||
|
||||
public void incrementKillCount() {
|
||||
this.killCount++;
|
||||
this.killStreak++;
|
||||
}
|
||||
|
||||
public void resetStats() {
|
||||
this.killCount = 0;
|
||||
this.deathCount = 0;
|
||||
this.shotCount = 0;
|
||||
this.resupplyCount = 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -67,4 +202,11 @@ public class Player extends PhysicsObject {
|
|||
public int hashCode() {
|
||||
return Objects.hash(getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(Player o) {
|
||||
int r = this.name.compareTo(o.getName());
|
||||
if (r == 0) return Integer.compare(this.id, o.getId());
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,29 +1,24 @@
|
|||
package nl.andrewlalis.aos_core.model;
|
||||
|
||||
import nl.andrewlalis.aos_core.geom.Vec2;
|
||||
import nl.andrewlalis.aos_core.net.data.DataTypes;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
public class PlayerControlState implements Serializable {
|
||||
private int playerId;
|
||||
|
||||
boolean movingLeft;
|
||||
boolean movingRight;
|
||||
boolean movingForward;
|
||||
boolean movingBackward;
|
||||
boolean sprinting;
|
||||
boolean sneaking;
|
||||
|
||||
boolean shooting;
|
||||
boolean reloading;
|
||||
|
||||
Vec2 mouseLocation;
|
||||
|
||||
public int getPlayerId() {
|
||||
return playerId;
|
||||
}
|
||||
|
||||
public void setPlayerId(int playerId) {
|
||||
this.playerId = playerId;
|
||||
}
|
||||
|
||||
public boolean isMovingLeft() {
|
||||
return movingLeft;
|
||||
}
|
||||
|
@ -56,6 +51,22 @@ public class PlayerControlState implements Serializable {
|
|||
this.movingBackward = movingBackward;
|
||||
}
|
||||
|
||||
public boolean isSprinting() {
|
||||
return sprinting;
|
||||
}
|
||||
|
||||
public void setSprinting(boolean sprinting) {
|
||||
this.sprinting = sprinting;
|
||||
}
|
||||
|
||||
public boolean isSneaking() {
|
||||
return sneaking;
|
||||
}
|
||||
|
||||
public void setSneaking(boolean sneaking) {
|
||||
this.sneaking = sneaking;
|
||||
}
|
||||
|
||||
public boolean isShooting() {
|
||||
return shooting;
|
||||
}
|
||||
|
@ -64,6 +75,14 @@ public class PlayerControlState implements Serializable {
|
|||
this.shooting = shooting;
|
||||
}
|
||||
|
||||
public boolean isReloading() {
|
||||
return reloading;
|
||||
}
|
||||
|
||||
public void setReloading(boolean reloading) {
|
||||
this.reloading = reloading;
|
||||
}
|
||||
|
||||
public Vec2 getMouseLocation() {
|
||||
return mouseLocation;
|
||||
}
|
||||
|
@ -71,4 +90,37 @@ public class PlayerControlState implements Serializable {
|
|||
public void setMouseLocation(Vec2 mouseLocation) {
|
||||
this.mouseLocation = mouseLocation;
|
||||
}
|
||||
|
||||
public byte[] toBytes() {
|
||||
ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES + 2 * Float.BYTES);
|
||||
int flags = 0;
|
||||
if (this.movingLeft) flags |= 1;
|
||||
if (this.movingRight) flags |= 2;
|
||||
if (this.movingForward) flags |= 4;
|
||||
if (this.movingBackward) flags |= 8;
|
||||
if (this.shooting) flags |= 16;
|
||||
if (this.reloading) flags |= 32;
|
||||
if (this.sprinting) flags |= 64;
|
||||
if (this.sneaking) flags |= 128;
|
||||
buffer.putInt(flags);
|
||||
buffer.putFloat(this.mouseLocation.x());
|
||||
buffer.putFloat(this.mouseLocation.y());
|
||||
return buffer.array();
|
||||
}
|
||||
|
||||
public static PlayerControlState fromBytes(byte[] bytes) {
|
||||
var s = new PlayerControlState();
|
||||
ByteBuffer buffer = ByteBuffer.wrap(bytes);
|
||||
int flags = buffer.getInt();
|
||||
s.movingLeft = (flags & 1) > 0;
|
||||
s.movingRight = (flags & 2) > 0;
|
||||
s.movingForward = (flags & 4) > 0;
|
||||
s.movingBackward = (flags & 8) > 0;
|
||||
s.shooting = (flags & 16) > 0;
|
||||
s.reloading = (flags & 32) > 0;
|
||||
s.sprinting = (flags & 64) > 0;
|
||||
s.sneaking = (flags & 128) > 0;
|
||||
s.mouseLocation = new Vec2(buffer.getFloat(), buffer.getFloat());
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,21 +6,36 @@ import java.awt.*;
|
|||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public class Team implements Serializable {
|
||||
public static final float SPAWN_RADIUS = 3;
|
||||
public static final float SUPPLY_POINT_RADIUS = 2;
|
||||
|
||||
private final byte id;
|
||||
private final String name;
|
||||
private final java.awt.Color color;
|
||||
private final Vec2 spawnPoint;
|
||||
private final Vec2 supplyPoint;
|
||||
private final Vec2 orientation;
|
||||
|
||||
private final List<Player> players;
|
||||
|
||||
public Team(String name, Color color, Vec2 spawnPoint, Vec2 orientation) {
|
||||
private int score;
|
||||
|
||||
public Team(byte id, String name, Color color, Vec2 spawnPoint, Vec2 supplyPoint, Vec2 orientation) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.color = color;
|
||||
this.spawnPoint = spawnPoint;
|
||||
this.supplyPoint = supplyPoint;
|
||||
this.orientation = orientation;
|
||||
this.players = new ArrayList<>();
|
||||
this.score = 0;
|
||||
}
|
||||
|
||||
public byte getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
|
@ -35,6 +50,10 @@ public class Team implements Serializable {
|
|||
return spawnPoint;
|
||||
}
|
||||
|
||||
public Vec2 getSupplyPoint() {
|
||||
return supplyPoint;
|
||||
}
|
||||
|
||||
public Vec2 getOrientation() {
|
||||
return orientation;
|
||||
}
|
||||
|
@ -42,4 +61,33 @@ public class Team implements Serializable {
|
|||
public List<Player> getPlayers() {
|
||||
return players;
|
||||
}
|
||||
|
||||
public int getScore() {
|
||||
return score;
|
||||
}
|
||||
|
||||
public void incrementScore() {
|
||||
this.score++;
|
||||
}
|
||||
|
||||
public void resetScore() {
|
||||
this.score = 0;
|
||||
}
|
||||
|
||||
public void setScore(int score) {
|
||||
this.score = score;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
Team team = (Team) o;
|
||||
return getId() == team.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(getId());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
package nl.andrewlalis.aos_core.model;
|
||||
|
||||
import nl.andrewlalis.aos_core.geom.Vec2;
|
||||
import nl.andrewlalis.aos_core.model.tools.GunType;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
/**
|
||||
* The main game world, consisting of all players and other objects in the game.
|
||||
|
@ -14,30 +16,33 @@ import java.util.Map;
|
|||
public class World implements Serializable {
|
||||
private final Vec2 size;
|
||||
|
||||
private final List<Team> teams;
|
||||
private final Map<Byte, Team> teams;
|
||||
private final Map<String, GunType> gunTypes;
|
||||
private final Map<Integer, Player> players;
|
||||
private final List<Bullet> bullets;
|
||||
private final List<Barricade> barricades;
|
||||
|
||||
private final List<String> soundsToPlay;
|
||||
|
||||
public World(Vec2 size) {
|
||||
this.size = size;
|
||||
this.teams = new ArrayList<>();
|
||||
this.players = new HashMap<>();
|
||||
this.bullets = new ArrayList<>();
|
||||
this.teams = new ConcurrentHashMap<>();
|
||||
this.gunTypes = new ConcurrentHashMap<>();
|
||||
this.players = new ConcurrentHashMap<>();
|
||||
this.bullets = new CopyOnWriteArrayList<>();
|
||||
this.barricades = new ArrayList<>();
|
||||
this.soundsToPlay = new ArrayList<>();
|
||||
}
|
||||
|
||||
public Vec2 getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public List<Team> getTeams() {
|
||||
public Map<Byte, Team> getTeams() {
|
||||
return teams;
|
||||
}
|
||||
|
||||
public Map<String, GunType> getGunTypes() {
|
||||
return gunTypes;
|
||||
}
|
||||
|
||||
public Map<Integer, Player> getPlayers() {
|
||||
return this.players;
|
||||
}
|
||||
|
@ -49,8 +54,4 @@ public class World implements Serializable {
|
|||
public List<Barricade> getBarricades() {
|
||||
return barricades;
|
||||
}
|
||||
|
||||
public List<String> getSoundsToPlay() {
|
||||
return soundsToPlay;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
package nl.andrewlalis.aos_core.model.tools;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
public class Gun implements Serializable {
|
||||
GunType type;
|
||||
|
||||
/**
|
||||
* Number of bullets left in the current clip.
|
||||
*/
|
||||
private int currentClipBulletCount;
|
||||
/**
|
||||
* Number of clips remaining.
|
||||
*/
|
||||
private int clipCount;
|
||||
|
||||
public Gun(GunType type, int currentClipBulletCount, int clipCount) {
|
||||
this.type = type;
|
||||
this.currentClipBulletCount = currentClipBulletCount;
|
||||
this.clipCount = clipCount;
|
||||
}
|
||||
|
||||
public Gun(GunType type) {
|
||||
this(type, 0, type.getMaxClipCount());
|
||||
}
|
||||
|
||||
public GunType getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public int getCurrentClipBulletCount() {
|
||||
return currentClipBulletCount;
|
||||
}
|
||||
|
||||
public int getClipCount() {
|
||||
return clipCount;
|
||||
}
|
||||
|
||||
public void refillClips() {
|
||||
this.clipCount = this.type.getMaxClipCount();
|
||||
}
|
||||
|
||||
public void decrementBulletCount() {
|
||||
this.currentClipBulletCount = Math.max(this.currentClipBulletCount - 1, 0);
|
||||
}
|
||||
|
||||
public void emptyCurrentClip() {
|
||||
this.currentClipBulletCount = 0;
|
||||
}
|
||||
|
||||
public boolean canReload() {
|
||||
return this.clipCount > 0;
|
||||
}
|
||||
|
||||
public void reload() {
|
||||
if (this.clipCount > 0) {
|
||||
this.clipCount--;
|
||||
this.currentClipBulletCount = this.type.getClipSize();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
package nl.andrewlalis.aos_core.model.tools;
|
||||
|
||||
public enum GunCategory {
|
||||
SHOTGUN(0),
|
||||
SMG(1),
|
||||
RIFLE(2),
|
||||
MACHINE(3);
|
||||
|
||||
private final byte code;
|
||||
|
||||
GunCategory(int code) {
|
||||
this.code = (byte) code;
|
||||
}
|
||||
|
||||
public byte getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public static GunCategory get(byte code) {
|
||||
for (var val : values()) {
|
||||
if (val.code == code) return val;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,122 @@
|
|||
package nl.andrewlalis.aos_core.model.tools;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Relatively constant configuration information about a particular type of gun,
|
||||
* while not including state data for any single gun.
|
||||
*/
|
||||
public class GunType implements Serializable {
|
||||
/**
|
||||
* The name of this type of gun. Should be unique among all guns in a world.
|
||||
*/
|
||||
private final String name;
|
||||
/**
|
||||
* The category of gun.
|
||||
*/
|
||||
private final GunCategory category;
|
||||
/**
|
||||
* The color of this type of gun, in hex.
|
||||
*/
|
||||
private final String color;
|
||||
/**
|
||||
* Maximum number of clips a player can carry when using this gun.
|
||||
*/
|
||||
private final int maxClipCount;
|
||||
/**
|
||||
* Number of bullets in each clip.
|
||||
*/
|
||||
private final int clipSize;
|
||||
/**
|
||||
* Number of bullets that are fired simultaneously per round. Usually only
|
||||
* shotguns fire multiple.
|
||||
*/
|
||||
private final int bulletsPerRound;
|
||||
/**
|
||||
* How accurate shots from this gun are. 0 = never miss, 1 = complete random.
|
||||
*/
|
||||
private final float accuracy;
|
||||
/**
|
||||
* How long (in seconds) to wait after each shot, before another is shot.
|
||||
*/
|
||||
private final float shotCooldownTime;
|
||||
/**
|
||||
* How long (in seconds) for reloading a new clip.
|
||||
*/
|
||||
private final float reloadTime;
|
||||
/**
|
||||
* How fast the bullet travels (in m/s).
|
||||
*/
|
||||
private final float bulletSpeed;
|
||||
/**
|
||||
* How much damage the bullet does for a direct hit.
|
||||
*/
|
||||
private final float baseDamage;
|
||||
/**
|
||||
* How fast the gun pushes the player backwards when shot (in m/s).
|
||||
*/
|
||||
private final float recoil;
|
||||
|
||||
public GunType(String name, GunCategory category, String color, int maxClipCount, int clipSize, int bulletsPerRound, float accuracy, float shotCooldownTime, float reloadTime, float bulletSpeed, float baseDamage, float recoil) {
|
||||
this.name = name;
|
||||
this.category = category;
|
||||
this.color = color;
|
||||
this.maxClipCount = maxClipCount;
|
||||
this.clipSize = clipSize;
|
||||
this.bulletsPerRound = bulletsPerRound;
|
||||
this.accuracy = accuracy;
|
||||
this.shotCooldownTime = shotCooldownTime;
|
||||
this.reloadTime = reloadTime;
|
||||
this.bulletSpeed = bulletSpeed;
|
||||
this.baseDamage = baseDamage;
|
||||
this.recoil = recoil;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public GunCategory getCategory() {
|
||||
return category;
|
||||
}
|
||||
|
||||
public String getColor() {
|
||||
return color;
|
||||
}
|
||||
|
||||
public int getMaxClipCount() {
|
||||
return maxClipCount;
|
||||
}
|
||||
|
||||
public int getClipSize() {
|
||||
return clipSize;
|
||||
}
|
||||
|
||||
public int getBulletsPerRound() {
|
||||
return bulletsPerRound;
|
||||
}
|
||||
|
||||
public float getAccuracy() {
|
||||
return accuracy;
|
||||
}
|
||||
|
||||
public float getShotCooldownTime() {
|
||||
return shotCooldownTime;
|
||||
}
|
||||
|
||||
public float getReloadTime() {
|
||||
return reloadTime;
|
||||
}
|
||||
|
||||
public float getBulletSpeed() {
|
||||
return bulletSpeed;
|
||||
}
|
||||
|
||||
public float getBaseDamage() {
|
||||
return baseDamage;
|
||||
}
|
||||
|
||||
public float getRecoil() {
|
||||
return recoil;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package nl.andrewlalis.aos_core.net;
|
||||
|
||||
public class ConnectionRejectedMessage extends Message {
|
||||
private final String message;
|
||||
|
||||
public ConnectionRejectedMessage(String message) {
|
||||
super(Type.CONNECTION_REJECTED);
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
|
@ -3,12 +3,22 @@ package nl.andrewlalis.aos_core.net;
|
|||
public class IdentMessage extends Message {
|
||||
private final String name;
|
||||
|
||||
public IdentMessage(String name) {
|
||||
/**
|
||||
* The port that the client will use to send and receive UDP packets.
|
||||
*/
|
||||
private final int udpPort;
|
||||
|
||||
public IdentMessage(String name, int udpPort) {
|
||||
super(Type.IDENT);
|
||||
this.name = name;
|
||||
this.udpPort = udpPort;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public int getUdpPort() {
|
||||
return this.udpPort;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
package nl.andrewlalis.aos_core.net;
|
||||
|
||||
import nl.andrewlalis.aos_core.model.PlayerControlState;
|
||||
|
||||
public class PlayerControlStateMessage extends Message {
|
||||
private final PlayerControlState playerControlState;
|
||||
|
||||
public PlayerControlStateMessage(PlayerControlState pcs) {
|
||||
super(Type.PLAYER_CONTROL_STATE);
|
||||
this.playerControlState = pcs;
|
||||
}
|
||||
|
||||
public PlayerControlState getPlayerControlState() {
|
||||
return playerControlState;
|
||||
}
|
||||
}
|
|
@ -1,14 +1,23 @@
|
|||
package nl.andrewlalis.aos_core.net;
|
||||
|
||||
public class PlayerRegisteredMessage extends Message {
|
||||
private final int playerId;
|
||||
import nl.andrewlalis.aos_core.model.Player;
|
||||
import nl.andrewlalis.aos_core.model.World;
|
||||
|
||||
public PlayerRegisteredMessage(int playerId) {
|
||||
public class PlayerRegisteredMessage extends Message {
|
||||
private final Player player;
|
||||
private final World world;
|
||||
|
||||
public PlayerRegisteredMessage(Player player, World world) {
|
||||
super(Type.PLAYER_REGISTERED);
|
||||
this.playerId = playerId;
|
||||
this.player = player;
|
||||
this.world = world;
|
||||
}
|
||||
|
||||
public int getPlayerId() {
|
||||
return playerId;
|
||||
public Player getPlayer() {
|
||||
return player;
|
||||
}
|
||||
|
||||
public World getWorld() {
|
||||
return world;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
package nl.andrewlalis.aos_core.net;
|
||||
|
||||
import nl.andrewlalis.aos_core.model.Player;
|
||||
|
||||
public class PlayerUpdateMessage extends Message {
|
||||
private final Player player;
|
||||
|
||||
public PlayerUpdateMessage(Type type, Player player) {
|
||||
super(type);
|
||||
this.player = player;
|
||||
}
|
||||
|
||||
public Player getPlayer() {
|
||||
return player;
|
||||
}
|
||||
}
|
|
@ -2,9 +2,11 @@ package nl.andrewlalis.aos_core.net;
|
|||
|
||||
public enum Type {
|
||||
IDENT,
|
||||
ACK,
|
||||
PLAYER_REGISTERED,
|
||||
CHAT,
|
||||
PLAYER_CONTROL_STATE,
|
||||
WORLD_UPDATE
|
||||
PLAYER_JOINED,
|
||||
PLAYER_LEFT,
|
||||
PLAYER_TEAM_CHANGE,
|
||||
SERVER_SHUTDOWN,
|
||||
CONNECTION_REJECTED
|
||||
}
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
package nl.andrewlalis.aos_core.net;
|
||||
|
||||
import nl.andrewlalis.aos_core.model.World;
|
||||
|
||||
public class WorldUpdateMessage extends Message {
|
||||
private final World world;
|
||||
public WorldUpdateMessage(World world) {
|
||||
super(Type.WORLD_UPDATE);
|
||||
this.world = world;
|
||||
}
|
||||
|
||||
public World getWorld() {
|
||||
return world;
|
||||
}
|
||||
}
|
|
@ -5,13 +5,19 @@ import nl.andrewlalis.aos_core.net.Type;
|
|||
|
||||
public class ChatMessage extends Message {
|
||||
private final String text;
|
||||
private final ChatType chatType;
|
||||
|
||||
public ChatMessage(String text) {
|
||||
public ChatMessage(String text, ChatType chatType) {
|
||||
super(Type.CHAT);
|
||||
this.text = text;
|
||||
this.chatType = chatType;
|
||||
}
|
||||
|
||||
public String getText() {
|
||||
return text;
|
||||
}
|
||||
|
||||
public ChatType getChatType() {
|
||||
return chatType;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
package nl.andrewlalis.aos_core.net.chat;
|
||||
|
||||
public enum ChatType {
|
||||
PUBLIC_PLAYER_CHAT,
|
||||
TEAM_PLAYER_CHAT,
|
||||
PRIVATE_PLAYER_CHAT,
|
||||
SYSTEM_MESSAGE
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
package nl.andrewlalis.aos_core.net.chat;
|
||||
|
||||
public class CommandMessage extends PlayerChatMessage {
|
||||
public CommandMessage(int id, String text) {
|
||||
super(id, text);
|
||||
}
|
||||
}
|
|
@ -3,8 +3,8 @@ package nl.andrewlalis.aos_core.net.chat;
|
|||
public class PlayerChatMessage extends ChatMessage {
|
||||
private final int playerId;
|
||||
|
||||
public PlayerChatMessage(int id, String text) {
|
||||
super(text);
|
||||
public PlayerChatMessage(int id, String text, ChatType chatType) {
|
||||
super(text, chatType);
|
||||
this.playerId = id;
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ public class SystemChatMessage extends ChatMessage {
|
|||
private final Level level;
|
||||
|
||||
public SystemChatMessage(Level level, String text) {
|
||||
super(text);
|
||||
super(text, ChatType.SYSTEM_MESSAGE);
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
package nl.andrewlalis.aos_core.net.data;
|
||||
|
||||
import nl.andrewlalis.aos_core.geom.Vec2;
|
||||
import nl.andrewlalis.aos_core.model.Bullet;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
public class BulletUpdate {
|
||||
public static final int BYTES = 4 * Float.BYTES;
|
||||
|
||||
private final Vec2 position;
|
||||
private final Vec2 velocity;
|
||||
public BulletUpdate(Bullet bullet) {
|
||||
this.position = bullet.getPosition();
|
||||
this.velocity = bullet.getVelocity();
|
||||
}
|
||||
|
||||
private BulletUpdate(Vec2 position, Vec2 velocity) {
|
||||
this.position = position;
|
||||
this.velocity = velocity;
|
||||
}
|
||||
|
||||
public Vec2 getPosition() {
|
||||
return position;
|
||||
}
|
||||
|
||||
public Vec2 getVelocity() {
|
||||
return velocity;
|
||||
}
|
||||
|
||||
public Bullet toBullet() {
|
||||
return new Bullet(this.position, this.velocity);
|
||||
}
|
||||
|
||||
public void write(DataOutputStream out) throws IOException {
|
||||
out.writeFloat(this.position.x());
|
||||
out.writeFloat(this.position.y());
|
||||
out.writeFloat(this.velocity.x());
|
||||
out.writeFloat(this.velocity.y());
|
||||
}
|
||||
|
||||
public static BulletUpdate read(DataInputStream in) throws IOException {
|
||||
return new BulletUpdate(
|
||||
Vec2.read(in),
|
||||
Vec2.read(in)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package nl.andrewlalis.aos_core.net.data;
|
||||
|
||||
public class DataTypes {
|
||||
public static final byte INIT = 0;
|
||||
public static final byte PLAYER_CONTROL_STATE = 1;
|
||||
public static final byte WORLD_DATA = 2;
|
||||
public static final byte PLAYER_DETAIL = 3;
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
package nl.andrewlalis.aos_core.net.data;
|
||||
|
||||
import nl.andrewlalis.aos_core.model.Player;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
public class PlayerDetailUpdate {
|
||||
public static final int BYTES = Float.BYTES + 1 + 5 * Integer.BYTES;
|
||||
|
||||
private final float health;
|
||||
private final boolean reloading;
|
||||
|
||||
private final int gunMaxClipCount;
|
||||
private final int gunClipSize;
|
||||
private final int gunBulletsPerRound;
|
||||
private final int gunCurrentClipBulletCount;
|
||||
private final int gunClipCount;
|
||||
|
||||
public PlayerDetailUpdate(Player player) {
|
||||
this.health = player.getHealth();
|
||||
this.reloading = player.isReloading();
|
||||
|
||||
this.gunMaxClipCount = player.getGun().getType().getMaxClipCount();
|
||||
this.gunClipSize = player.getGun().getType().getClipSize();
|
||||
this.gunBulletsPerRound = player.getGun().getType().getBulletsPerRound();
|
||||
this.gunCurrentClipBulletCount = player.getGun().getCurrentClipBulletCount();
|
||||
this.gunClipCount = player.getGun().getClipCount();
|
||||
}
|
||||
|
||||
private PlayerDetailUpdate(float health, boolean reloading, int gunMaxClipCount, int gunClipSize, int gunBulletsPerRound, int gunCurrentClipBulletCount, int gunClipCount) {
|
||||
this.health = health;
|
||||
this.reloading = reloading;
|
||||
this.gunMaxClipCount = gunMaxClipCount;
|
||||
this.gunClipSize = gunClipSize;
|
||||
this.gunBulletsPerRound = gunBulletsPerRound;
|
||||
this.gunCurrentClipBulletCount = gunCurrentClipBulletCount;
|
||||
this.gunClipCount = gunClipCount;
|
||||
}
|
||||
|
||||
public float getHealth() {
|
||||
return health;
|
||||
}
|
||||
|
||||
public boolean isReloading() {
|
||||
return reloading;
|
||||
}
|
||||
|
||||
public int getGunMaxClipCount() {
|
||||
return gunMaxClipCount;
|
||||
}
|
||||
|
||||
public int getGunClipSize() {
|
||||
return gunClipSize;
|
||||
}
|
||||
|
||||
public int getGunBulletsPerRound() {
|
||||
return gunBulletsPerRound;
|
||||
}
|
||||
|
||||
public int getGunCurrentClipBulletCount() {
|
||||
return gunCurrentClipBulletCount;
|
||||
}
|
||||
|
||||
public int getGunClipCount() {
|
||||
return gunClipCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "PlayerDetailUpdate{" +
|
||||
"health=" + health +
|
||||
", reloading=" + reloading +
|
||||
", gunMaxClipCount=" + gunMaxClipCount +
|
||||
", gunClipSize=" + gunClipSize +
|
||||
", gunBulletsPerRound=" + gunBulletsPerRound +
|
||||
", gunCurrentClipBulletCount=" + gunCurrentClipBulletCount +
|
||||
", gunClipCount=" + gunClipCount +
|
||||
'}';
|
||||
}
|
||||
|
||||
public byte[] toBytes() {
|
||||
ByteBuffer buffer = ByteBuffer.allocate(BYTES);
|
||||
buffer.putFloat(health);
|
||||
buffer.put((byte) (this.reloading ? 1 : 0));
|
||||
buffer.putInt(this.gunMaxClipCount);
|
||||
buffer.putInt(this.gunClipSize);
|
||||
buffer.putInt(this.gunBulletsPerRound);
|
||||
buffer.putInt(this.gunCurrentClipBulletCount);
|
||||
buffer.putInt(this.gunClipCount);
|
||||
return buffer.array();
|
||||
}
|
||||
|
||||
public static PlayerDetailUpdate fromBytes(byte[] bytes) {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(bytes);
|
||||
return new PlayerDetailUpdate(
|
||||
buffer.getFloat(),
|
||||
buffer.get() == 1,
|
||||
buffer.getInt(),
|
||||
buffer.getInt(),
|
||||
buffer.getInt(),
|
||||
buffer.getInt(),
|
||||
buffer.getInt()
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
package nl.andrewlalis.aos_core.net.data;
|
||||
|
||||
import nl.andrewlalis.aos_core.geom.Vec2;
|
||||
import nl.andrewlalis.aos_core.model.Player;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* The data that's sent to all clients about a player, and contains only the
|
||||
* information needed to render the player on the screen.
|
||||
*/
|
||||
public class PlayerUpdate {
|
||||
public static final int BYTES = Integer.BYTES + 6 * Float.BYTES + 1;
|
||||
|
||||
private final int id;
|
||||
private final Vec2 position;
|
||||
private final Vec2 orientation;
|
||||
private final Vec2 velocity;
|
||||
private final String gunTypeName;
|
||||
|
||||
public PlayerUpdate(Player player) {
|
||||
this.id = player.getId();
|
||||
this.position = player.getPosition();
|
||||
this.orientation = player.getOrientation();
|
||||
this.velocity = player.getVelocity();
|
||||
this.gunTypeName = player.getGun().getType().getName();
|
||||
}
|
||||
|
||||
public PlayerUpdate(int id, Vec2 position, Vec2 orientation, Vec2 velocity, String gunTypeName) {
|
||||
this.id = id;
|
||||
this.position = position;
|
||||
this.orientation = orientation;
|
||||
this.velocity = velocity;
|
||||
this.gunTypeName = gunTypeName;
|
||||
}
|
||||
|
||||
public int getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Vec2 getPosition() {
|
||||
return position;
|
||||
}
|
||||
|
||||
public Vec2 getOrientation() {
|
||||
return orientation;
|
||||
}
|
||||
|
||||
public Vec2 getVelocity() {
|
||||
return velocity;
|
||||
}
|
||||
|
||||
public String getGunTypeName() {
|
||||
return gunTypeName;
|
||||
}
|
||||
|
||||
public void write(DataOutputStream out) throws IOException {
|
||||
out.writeInt(this.id);
|
||||
out.writeFloat(this.position.x());
|
||||
out.writeFloat(this.position.y());
|
||||
out.writeFloat(this.orientation.x());
|
||||
out.writeFloat(this.orientation.y());
|
||||
out.writeFloat(this.velocity.x());
|
||||
out.writeFloat(this.velocity.y());
|
||||
out.writeInt(this.gunTypeName.length());
|
||||
out.writeBytes(this.gunTypeName);
|
||||
}
|
||||
|
||||
public static PlayerUpdate read(DataInputStream in) throws IOException {
|
||||
int id = in.readInt();
|
||||
Vec2 position = Vec2.read(in);
|
||||
Vec2 orientation = Vec2.read(in);
|
||||
Vec2 velocity = Vec2.read(in);
|
||||
int gunTypeNameLength = in.readInt();
|
||||
String gunTypeName = new String(in.readNBytes(gunTypeNameLength));
|
||||
return new PlayerUpdate(id, position, orientation, velocity, gunTypeName);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
package nl.andrewlalis.aos_core.net.data;
|
||||
|
||||
import nl.andrewlalis.aos_core.geom.Vec2;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
public class SoundData {
|
||||
public static final int BYTES = 3 * Float.BYTES + 1;
|
||||
|
||||
private final Vec2 position;
|
||||
private final float volume;
|
||||
private final SoundType type;
|
||||
|
||||
public SoundData(Vec2 position, float volume, SoundType type) {
|
||||
this.position = position;
|
||||
this.volume = volume;
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public Vec2 getPosition() {
|
||||
return position;
|
||||
}
|
||||
|
||||
public float getVolume() {
|
||||
return volume;
|
||||
}
|
||||
|
||||
public SoundType getType() {
|
||||
return this.type;
|
||||
}
|
||||
|
||||
public void write(DataOutputStream out) throws IOException {
|
||||
out.writeFloat(this.position.x());
|
||||
out.writeFloat(this.position.y());
|
||||
out.writeFloat(this.volume);
|
||||
out.writeByte(this.type.getCode());
|
||||
}
|
||||
|
||||
public static SoundData read(DataInputStream in) throws IOException {
|
||||
return new SoundData(
|
||||
Vec2.read(in),
|
||||
in.readFloat(),
|
||||
SoundType.get(in.readByte())
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
package nl.andrewlalis.aos_core.net.data;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Encoding of all server-initiated game sounds with a unique byte value, for
|
||||
* efficient transmission to clients.
|
||||
*/
|
||||
public enum SoundType {
|
||||
SHOT_SMG(0, "ak47shot1.wav"),
|
||||
SHOT_RIFLE(1, "m1garand-shot1.wav"),
|
||||
SHOT_SHOTGUN(2, "shotgun-shot1.wav"),
|
||||
SHOT_MACHINE_GUN_1(11, "machine_gun-shot1.wav"),
|
||||
SHOT_MACHINE_GUN_2(12, "machine_gun-shot2.wav"),
|
||||
RELOAD(3, "reload.wav"),
|
||||
CHAT(4, "chat.wav"),
|
||||
DEATH(5, "death.wav"),
|
||||
BULLET_IMPACT_1(6, "bullet_impact_1.wav"),
|
||||
BULLET_IMPACT_2(7, "bullet_impact_2.wav"),
|
||||
BULLET_IMPACT_3(8, "bullet_impact_3.wav"),
|
||||
BULLET_IMPACT_4(9, "bullet_impact_4.wav"),
|
||||
BULLET_IMPACT_5(10, "bullet_impact_5.wav"),
|
||||
FOOTSTEPS_1(13, "footsteps1.wav");
|
||||
|
||||
private final byte code;
|
||||
private final String soundName;
|
||||
|
||||
SoundType(int code, String soundName) {
|
||||
this.code = (byte) code;
|
||||
this.soundName = soundName;
|
||||
}
|
||||
|
||||
public byte getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String getSoundName() {
|
||||
return soundName;
|
||||
}
|
||||
|
||||
private static final Map<Byte, SoundType> typeIndex = new HashMap<>();
|
||||
static {
|
||||
for (var val : values()) {
|
||||
typeIndex.put(val.getCode(), val);
|
||||
}
|
||||
}
|
||||
|
||||
public static SoundType get(byte code) {
|
||||
return typeIndex.get(code);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
package nl.andrewlalis.aos_core.net.data;
|
||||
|
||||
import nl.andrewlalis.aos_core.model.Team;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
public class TeamUpdate {
|
||||
public static final int BYTES = 1 + Integer.BYTES;
|
||||
|
||||
private final byte id;
|
||||
private final int score;
|
||||
|
||||
public TeamUpdate(Team team) {
|
||||
this.id = team.getId();
|
||||
this.score = team.getScore();
|
||||
}
|
||||
|
||||
public TeamUpdate(byte id, int score) {
|
||||
this.id = id;
|
||||
this.score = score;
|
||||
}
|
||||
|
||||
public byte getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public int getScore() {
|
||||
return score;
|
||||
}
|
||||
|
||||
public void write(DataOutputStream out) throws IOException {
|
||||
out.writeByte(this.id);
|
||||
out.writeInt(this.score);
|
||||
}
|
||||
|
||||
public static TeamUpdate read(DataInputStream in) throws IOException {
|
||||
return new TeamUpdate(
|
||||
in.readByte(),
|
||||
in.readInt()
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,134 @@
|
|||
package nl.andrewlalis.aos_core.net.data;
|
||||
|
||||
import nl.andrewlalis.aos_core.model.Bullet;
|
||||
import nl.andrewlalis.aos_core.model.Player;
|
||||
import nl.andrewlalis.aos_core.model.Team;
|
||||
|
||||
import java.io.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The minimal data that's sent to each client after every game tick. This
|
||||
* contains the most basic information on updates to bullets, object movement,
|
||||
* and sounds that need to be played, and other simple things.
|
||||
* <p>
|
||||
* This update doesn't contain all data about players and the world, and
|
||||
* this extra data is sent periodically to keep clients up-to-date without
|
||||
* sending too much data.
|
||||
* </p>
|
||||
*/
|
||||
public class WorldUpdate {
|
||||
private final List<PlayerUpdate> playerUpdates;
|
||||
private final List<BulletUpdate> bulletUpdates;
|
||||
private final List<TeamUpdate> teamUpdates;
|
||||
private final List<SoundData> soundsToPlay;
|
||||
|
||||
public WorldUpdate() {
|
||||
this(new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), new ArrayList<>());
|
||||
}
|
||||
|
||||
private WorldUpdate(List<PlayerUpdate> playerUpdates, List<BulletUpdate> bulletUpdates, List<TeamUpdate> teamUpdates, List<SoundData> soundsToPlay) {
|
||||
this.playerUpdates = playerUpdates;
|
||||
this.bulletUpdates = bulletUpdates;
|
||||
this.teamUpdates = teamUpdates;
|
||||
this.soundsToPlay = soundsToPlay;
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
this.playerUpdates.clear();
|
||||
this.bulletUpdates.clear();
|
||||
this.teamUpdates.clear();
|
||||
this.soundsToPlay.clear();
|
||||
}
|
||||
|
||||
public void addPlayer(Player p) {
|
||||
this.playerUpdates.add(new PlayerUpdate(p));
|
||||
}
|
||||
|
||||
public void addBullet(Bullet b) {
|
||||
this.bulletUpdates.add(new BulletUpdate(b));
|
||||
}
|
||||
|
||||
public void addTeam(Team team) {
|
||||
this.teamUpdates.add(new TeamUpdate(team));
|
||||
}
|
||||
|
||||
public void addSound(SoundData sound) {
|
||||
this.soundsToPlay.add(sound);
|
||||
}
|
||||
|
||||
public List<PlayerUpdate> getPlayerUpdates() {
|
||||
return playerUpdates;
|
||||
}
|
||||
|
||||
public List<BulletUpdate> getBulletUpdates() {
|
||||
return bulletUpdates;
|
||||
}
|
||||
|
||||
public List<TeamUpdate> getTeamUpdates() {
|
||||
return teamUpdates;
|
||||
}
|
||||
|
||||
public List<SoundData> getSoundsToPlay() {
|
||||
return soundsToPlay;
|
||||
}
|
||||
|
||||
public byte[] toBytes() throws IOException {
|
||||
int size = 3 * Integer.BYTES + // List size integers.
|
||||
this.playerUpdates.size() * PlayerUpdate.BYTES +
|
||||
this.bulletUpdates.size() * BulletUpdate.BYTES +
|
||||
this.teamUpdates.size() * TeamUpdate.BYTES +
|
||||
this.soundsToPlay.size() * SoundData.BYTES;
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(size);
|
||||
DataOutputStream dataOut = new DataOutputStream(out);
|
||||
dataOut.writeInt(this.playerUpdates.size());
|
||||
for (var u : this.playerUpdates) {
|
||||
u.write(dataOut);
|
||||
}
|
||||
dataOut.writeInt(this.bulletUpdates.size());
|
||||
for (var u : this.bulletUpdates) {
|
||||
u.write(dataOut);
|
||||
}
|
||||
dataOut.writeInt(this.teamUpdates.size());
|
||||
for (var u : this.teamUpdates) {
|
||||
u.write(dataOut);
|
||||
}
|
||||
dataOut.writeInt(this.soundsToPlay.size());
|
||||
for (var u : this.soundsToPlay) {
|
||||
u.write(dataOut);
|
||||
}
|
||||
byte[] data = out.toByteArray();
|
||||
dataOut.close();
|
||||
return data;
|
||||
}
|
||||
|
||||
public static WorldUpdate fromBytes(byte[] data) throws IOException {
|
||||
ByteArrayInputStream in = new ByteArrayInputStream(data);
|
||||
DataInputStream dataIn = new DataInputStream(in);
|
||||
|
||||
int players = dataIn.readInt();
|
||||
List<PlayerUpdate> playerUpdates = new ArrayList<>(players);
|
||||
for (int i = 0; i < players; i++) {
|
||||
playerUpdates.add(PlayerUpdate.read(dataIn));
|
||||
}
|
||||
int bullets = dataIn.readInt();
|
||||
List<BulletUpdate> bulletUpdates = new ArrayList<>(bullets);
|
||||
for (int i = 0; i < bullets; i++) {
|
||||
bulletUpdates.add(BulletUpdate.read(dataIn));
|
||||
}
|
||||
int teams = dataIn.readInt();
|
||||
List<TeamUpdate> teamUpdates = new ArrayList<>(teams);
|
||||
for (int i = 0; i < teams; i++) {
|
||||
teamUpdates.add(TeamUpdate.read(dataIn));
|
||||
}
|
||||
int sounds = dataIn.readInt();
|
||||
List<SoundData> soundsToPlay = new ArrayList<>(sounds);
|
||||
for (int i = 0; i < sounds; i++) {
|
||||
soundsToPlay.add(SoundData.read(dataIn));
|
||||
}
|
||||
var obj = new WorldUpdate(playerUpdates, bulletUpdates, teamUpdates, soundsToPlay);
|
||||
dataIn.close();
|
||||
return obj;
|
||||
}
|
||||
}
|
|
@ -45,4 +45,11 @@ public class ByteUtils {
|
|||
if (n != length) throw new IOException("Could not read enough bytes to read string.");
|
||||
return new String(strBytes, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public static byte[] prefix(byte pre, byte[] data) {
|
||||
byte[] full = new byte[data.length + 1];
|
||||
full[0] = pre;
|
||||
System.arraycopy(data, 0, full, 1, data.length);
|
||||
return full;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
package nl.andrewlalis.aos_core.util;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* A completable future which keeps track of how long it's been since it was
|
||||
* initialized.
|
||||
* @param <T> The type which is produced when this completes.
|
||||
*/
|
||||
public class TimedCompletableFuture<T> extends CompletableFuture<T> {
|
||||
private final long creationTimestamp;
|
||||
|
||||
public TimedCompletableFuture(long creationTimestamp) {
|
||||
this.creationTimestamp = creationTimestamp;
|
||||
}
|
||||
|
||||
public TimedCompletableFuture() {
|
||||
this(System.currentTimeMillis());
|
||||
}
|
||||
|
||||
public long getElapsedMillis() {
|
||||
return System.currentTimeMillis() - this.creationTimestamp;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
# Ace of Shades - Help Information
|
||||
In this document, we'll go over how to use the launcher, basic game controls, and some explanations of game mechanics so that you can start playing right away.
|
||||
|
||||
## Launcher
|
||||
The launcher is a small application that makes it easy to connect to different servers to join their hosted games. The first thing you'll see is a list of servers. **You can remove or edit a server by right-clicking on it**, and **you can add new servers using the Add Server button** at the bottom.
|
||||
|
||||
Each entry in the list is just a way to remember a specific address (and optionally your preferred username when connecting to that server), so that **you can double-click on a server to instantly connect**.
|
||||
|
||||
> Servers have an *address* which consists usually of an IP address and port number. For example, there might be a server whose address is `123.123.123.123:54321`.
|
||||
>
|
||||
> It's up to the server to decide whether to allow you to join, so pick a sensible username.
|
||||
|
||||
### Public Servers
|
||||
|
||||
Besides manually entering a server's address, you can search for available public servers via the **Search** button. It will open a new window where you can browse the list of all known public servers, and here **you can double-click to join the server directly**, or **right-click to see other options**, including copying the public server's information to your normal server list, so that you can connect to it later without searching.
|
||||
|
||||
## Controls
|
||||
To control your player in-game, the following are the default controls:
|
||||
|
||||
| Control | Description |
|
||||
| ------------ | ---------------------------------------------------- |
|
||||
| `WASD` | Move player forward, left, backward, and right. |
|
||||
| `SHIFT` | Sprint while moving. Shooting accuracy is decreased. |
|
||||
| `CTRL` | Sneak while moving. Shooting accuracy is increased. |
|
||||
| `R` | Reload your weapon. |
|
||||
| `T` | Start typing a message in chat. |
|
||||
| `/` | Start typing a command in chat. |
|
||||
| `LEFT-CLICK` | Use your weapon. |
|
||||
| `SCROLL` | Zoom in or out. |
|
||||
| `MOUSE-MOVE` | Aim your weapon. |
|
||||
| `ENTER` | Send your message or command in chat. |
|
||||
|
||||
> Be careful when typing a message or command in chat! Other players can and will try to kill you.
|
||||
|
||||
## Basic Mechanics
|
||||
In most scenarios, when you join a server, you'll be placed onto a *team*. Each team has a spawn point, which is where you'll start out. There's also a resupply area for each team, where you can replenish your health and ammunition when running low.
|
||||
|
||||
> You can only resupply once in a while. Different servers may set their own rules, but the default is **30 seconds**.
|
||||
|
||||
Each time you kill someone from another team, your own team's score increases. Different servers may come up with different rules for what constitutes a victory, but the premise is simple: **kill as many enemies as possible**.
|
||||
|
||||
You can quit at any time by closing the game window.
|
||||
|
||||
> Some servers may have policies which discourage *combat-logging* (disconnecting when about to die), and they may ban you from reconnecting! Take this into account, and play fair.
|
||||
|
||||
## Hosting a Server
|
||||
|
||||
Read ahead if you would like to learn about how to host an AOS server for your self and others to play on, either privately or publicly.
|
||||
|
||||
### Requirements
|
||||
|
||||
In order to run the server software, you will need at least Java 16 installed. This help document won't go into the specifics of how to do this, since there are many guides already on the internet. [You can start by downloading from AdoptOpenJDK's website.](https://adoptopenjdk.net/installation.html)
|
||||
|
||||
If you want players from outside your local network to be able to connect to your server, you will need to configure your router's port-forwarding rules to allow TCP and UDP traffic on the port that the server will use. By default, the server starts on port 8035. Port-forwarding is slightly different for every router, so if you're not sure how to do it, search online for a guide that's intended for your specific router.
|
||||
|
||||
### Running the Server
|
||||
|
||||
All you need to do is download the latest `aos-server-XXX.jar` file from this GitHub repository's [releases page](https://github.com/andrewlalis/AceOfShades/releases). Once you've done that, you should be able to start the server by running the following command:
|
||||
|
||||
```bash
|
||||
java -jar aos-server-XXX.jar
|
||||
```
|
||||
|
||||
> Replace `XXX` with the version of the server which you downloaded.
|
||||
|
||||
### Make it Public
|
||||
|
||||
There are a few things you need to configure before your server will appear in the global registry of servers that clients browse through.
|
||||
|
||||
1. You must set the `registry-settings.discoverable` property to `true`. When `discoverable` is false (it is by default false), the server will not appear in the registry, even if all other information is correct.
|
||||
2. The `registry-settings.registry-uri` property must point to the address of the global registry server. If you can paste the value into your browser followed by "/serverInfo", and you get some data, then you've most likely set this correctly. The current global registry server runs at
|
||||
3. Make sure that `registry-settings.update-interval` is set to a value no less than 10 seconds, and no higher than 300 seconds (5 minutes). Failure to do this may mean that your server could be permanently banned from the registry (an IP ban).
|
||||
4. Set the server's metadata, which will be shown to clients:
|
||||
`registry-settings.name` - The name of the server, as it will appear in the list. Make this short, easy to read, and recognizable. No more than 64 characters.
|
||||
`registry-settings.address` - The public address of the server that clients can use to connect. This should include both the IP address/hostname and port, in the form `IP:PORT` or `HOSTNAME:PORT`. No more than 255 characters.
|
||||
`registry-settings.description` - A short description of your server, so that clients can better decide if they want to join. No more than 1024 characters.
|
||||
`registry-settings.location` - The name of your server's location. Set this to a country or city name, so that clients can better decide if they want to join based on their connection.
|
||||
|
||||
Once all these things are done, you can restart your server, and it should appear shortly in clients' search results when they're browsing public servers.
|
||||
|
||||
> Note that this registry service is provided as-is, and any attempts to abuse the service or provide misleading or harmful information will result in a permanent IP ban for your server. This includes inappropriate or invalid server names, descriptions, addresses, or locations. If you have trouble deciding whether or not something would be considered inappropriate, assume that it is.
|
||||
|
6
pom.xml
6
pom.xml
|
@ -7,16 +7,20 @@
|
|||
<groupId>nl.andrewlalis</groupId>
|
||||
<artifactId>ace-of-shades</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
<version>2.1</version>
|
||||
<version>0.5.0</version>
|
||||
<modules>
|
||||
<module>server</module>
|
||||
<module>client</module>
|
||||
<module>core</module>
|
||||
<module>server-registry</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>16</maven.compiler.source>
|
||||
<maven.compiler.target>16</maven.compiler.target>
|
||||
<maven.compiler.release>16</maven.compiler.release>
|
||||
<java.version>16</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<build>
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<parent>
|
||||
<artifactId>ace-of-shades</artifactId>
|
||||
<groupId>nl.andrewlalis</groupId>
|
||||
<version>0.5.0</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<artifactId>server-registry</artifactId>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>16</maven.compiler.source>
|
||||
<maven.compiler.target>16</maven.compiler.target>
|
||||
</properties>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-assembly-plugin</artifactId>
|
||||
<version>3.3.0</version>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifest>
|
||||
<mainClass>nl.andrewlalis.aos_server_registry.ServerRegistry</mainClass>
|
||||
</manifest>
|
||||
</archive>
|
||||
<descriptorRefs>
|
||||
<descriptorRef>jar-with-dependencies</descriptorRef>
|
||||
</descriptorRefs>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>make-assembly</id>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>single</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>io.undertow</groupId>
|
||||
<artifactId>undertow-core</artifactId>
|
||||
<version>2.2.8.Final</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.undertow</groupId>
|
||||
<artifactId>undertow-servlet</artifactId>
|
||||
<version>2.2.8.Final</version>
|
||||
</dependency>
|
||||
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>2.12.3</version>
|
||||
</dependency>
|
||||
<!-- https://mvnrepository.com/artifact/com.h2database/h2 -->
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<version>1.4.200</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
|
@ -0,0 +1,14 @@
|
|||
module aos_server_registry {
|
||||
requires undertow.core;
|
||||
requires undertow.servlet;
|
||||
requires jdk.unsupported; // Needed for undertow support.
|
||||
requires java.servlet;
|
||||
requires com.fasterxml.jackson.databind;
|
||||
requires com.h2database;
|
||||
requires java.sql;
|
||||
|
||||
opens nl.andrewlalis.aos_server_registry to com.fasterxml.jackson.databind;
|
||||
opens nl.andrewlalis.aos_server_registry.servlet to com.fasterxml.jackson.databind;
|
||||
exports nl.andrewlalis.aos_server_registry.servlet to undertow.servlet;
|
||||
opens nl.andrewlalis.aos_server_registry.servlet.dto to com.fasterxml.jackson.databind;
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
package nl.andrewlalis.aos_server_registry;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.undertow.Undertow;
|
||||
import io.undertow.server.HttpHandler;
|
||||
import io.undertow.servlet.Servlets;
|
||||
import io.undertow.servlet.api.DeploymentInfo;
|
||||
import io.undertow.servlet.api.DeploymentManager;
|
||||
import nl.andrewlalis.aos_server_registry.data.ServerDataPruner;
|
||||
import nl.andrewlalis.aos_server_registry.servlet.ServerInfoServlet;
|
||||
|
||||
import javax.servlet.ServletException;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Properties;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class ServerRegistry {
|
||||
public static final String SETTINGS_FILE = "settings.properties";
|
||||
public static final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
public static void main(String[] args) throws ServletException, IOException {
|
||||
var props = loadProperties();
|
||||
startServer(Integer.parseInt(props.getProperty("port")));
|
||||
|
||||
// Every few minutes, prune all stale servers from the registry.
|
||||
long pruneDelaySeconds = Long.parseLong(props.getProperty("prune-delay"));
|
||||
long pruneThresholdMinutes = Long.parseLong(props.getProperty("prune-threshold-minutes"));
|
||||
System.out.printf("Will prune servers inactive for more than %d minutes, checking every %d seconds.\n", pruneThresholdMinutes, pruneDelaySeconds);
|
||||
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(3);
|
||||
scheduler.scheduleAtFixedRate(new ServerDataPruner(pruneThresholdMinutes), pruneDelaySeconds, pruneDelaySeconds, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the Undertow HTTP servlet container.
|
||||
* @param port The port to bind to.
|
||||
* @throws ServletException If the server could not be started.
|
||||
*/
|
||||
private static void startServer(int port) throws ServletException {
|
||||
System.out.println("Starting server on port " + port + ".");
|
||||
DeploymentInfo servletBuilder = Servlets.deployment()
|
||||
.setClassLoader(ServerRegistry.class.getClassLoader())
|
||||
.setContextPath("/")
|
||||
.setDeploymentName("AOS Server Registry")
|
||||
.addServlets(
|
||||
Servlets.servlet("ServersInfoServlet", ServerInfoServlet.class)
|
||||
.addMapping("/serverInfo")
|
||||
);
|
||||
DeploymentManager manager = Servlets.defaultContainer().addDeployment(servletBuilder);
|
||||
manager.deploy();
|
||||
HttpHandler servletHandler = manager.start();
|
||||
Undertow server = Undertow.builder()
|
||||
.addHttpListener(port, "0.0.0.0")
|
||||
.setHandler(servletHandler)
|
||||
.build();
|
||||
server.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads properties from all necessary locations.
|
||||
* @return The properties that were loaded.
|
||||
* @throws IOException If an error occurs while reading properties.
|
||||
*/
|
||||
private static Properties loadProperties() throws IOException {
|
||||
Properties props = new Properties();
|
||||
props.load(ServerRegistry.class.getResourceAsStream("/nl/andrewlalis/aos_server_registry/defaults.properties"));
|
||||
Path settingsPath = Path.of(SETTINGS_FILE);
|
||||
if (Files.exists(settingsPath)) {
|
||||
props.load(Files.newBufferedReader(settingsPath));
|
||||
} else {
|
||||
System.out.println("Using built-in default settings. Create a settings.properties file to configure.");
|
||||
}
|
||||
return props;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package nl.andrewlalis.aos_server_registry.data;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.PrintWriter;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
|
||||
public class DataManager {
|
||||
private static final String JDBC_URL = "jdbc:h2:mem:server_registry;MODE=MySQL";
|
||||
|
||||
private static DataManager instance;
|
||||
|
||||
public static DataManager getInstance() throws SQLException {
|
||||
if (instance == null) {
|
||||
instance = new DataManager();
|
||||
instance.resetDatabase();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
private final Connection connection;
|
||||
|
||||
private DataManager() throws SQLException {
|
||||
this.connection = DriverManager.getConnection(JDBC_URL);
|
||||
}
|
||||
|
||||
public Connection getConnection() {
|
||||
return this.connection;
|
||||
}
|
||||
|
||||
public void resetDatabase() throws SQLException {
|
||||
var in = DataManager.class.getResourceAsStream("/nl/andrewlalis/aos_server_registry/schema.sql");
|
||||
if (in == null) throw new SQLException("Missing schema.sql. Cannot reset database.");
|
||||
try {
|
||||
ScriptRunner runner = new ScriptRunner(this.connection, false, true);
|
||||
runner.setErrorLogWriter(new PrintWriter(System.err));
|
||||
runner.runScript(new InputStreamReader(in));
|
||||
System.out.println("Successfully reset database.");
|
||||
} catch (IOException e) {
|
||||
throw new SQLException(e);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,318 @@
|
|||
package nl.andrewlalis.aos_server_registry.data;
|
||||
/*
|
||||
* Slightly modified version of the com.ibatis.common.jdbc.ScriptRunner class
|
||||
* from the iBATIS Apache project. Only removed dependency on Resource class
|
||||
* and a constructor
|
||||
* GPSHansl, 06.08.2015: regex for delimiter, rearrange comment/delimiter detection, remove some ide warnings.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Copyright 2004 Clinton Begin
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import java.io.*;
|
||||
import java.sql.*;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Tool to run database scripts
|
||||
*/
|
||||
public class ScriptRunner {
|
||||
|
||||
private static final String DEFAULT_DELIMITER = ";";
|
||||
private static final Pattern SOURCE_COMMAND = Pattern.compile("^\\s*SOURCE\\s+(.*?)\\s*$", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
/**
|
||||
* regex to detect delimiter.
|
||||
* ignores spaces, allows delimiter in comment, allows an equals-sign
|
||||
*/
|
||||
public static final Pattern delimP = Pattern.compile("^\\s*(--)?\\s*delimiter\\s*=?\\s*([^\\s]+)+\\s*.*$", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private final Connection connection;
|
||||
|
||||
private final boolean stopOnError;
|
||||
private final boolean autoCommit;
|
||||
|
||||
@SuppressWarnings("UseOfSystemOutOrSystemErr")
|
||||
private PrintWriter logWriter = null;
|
||||
@SuppressWarnings("UseOfSystemOutOrSystemErr")
|
||||
private PrintWriter errorLogWriter = null;
|
||||
|
||||
private String delimiter = DEFAULT_DELIMITER;
|
||||
private boolean fullLineDelimiter = false;
|
||||
|
||||
private String userDirectory = System.getProperty("user.dir");
|
||||
|
||||
/**
|
||||
* Default constructor
|
||||
*/
|
||||
public ScriptRunner(Connection connection, boolean autoCommit,
|
||||
boolean stopOnError) {
|
||||
this.connection = connection;
|
||||
this.autoCommit = autoCommit;
|
||||
this.stopOnError = stopOnError;
|
||||
File logFile = new File("create_db.log");
|
||||
File errorLogFile = new File("create_db_error.log");
|
||||
try {
|
||||
if (logFile.exists()) {
|
||||
logWriter = new PrintWriter(new FileWriter(logFile, true));
|
||||
} else {
|
||||
logWriter = new PrintWriter(new FileWriter(logFile, false));
|
||||
}
|
||||
} catch(IOException e){
|
||||
System.err.println("Unable to access or create the db_create log");
|
||||
}
|
||||
try {
|
||||
if (errorLogFile.exists()) {
|
||||
errorLogWriter = new PrintWriter(new FileWriter(errorLogFile, true));
|
||||
} else {
|
||||
errorLogWriter = new PrintWriter(new FileWriter(errorLogFile, false));
|
||||
}
|
||||
} catch(IOException e){
|
||||
System.err.println("Unable to access or create the db_create error log");
|
||||
}
|
||||
String timeStamp = new SimpleDateFormat("dd/mm/yyyy HH:mm:ss").format(new java.util.Date());
|
||||
println("\n-------\n" + timeStamp + "\n-------\n");
|
||||
printlnError("\n-------\n" + timeStamp + "\n-------\n");
|
||||
}
|
||||
|
||||
public void setDelimiter(String delimiter, boolean fullLineDelimiter) {
|
||||
this.delimiter = delimiter;
|
||||
this.fullLineDelimiter = fullLineDelimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter for logWriter property
|
||||
*
|
||||
* @param logWriter - the new value of the logWriter property
|
||||
*/
|
||||
public void setLogWriter(PrintWriter logWriter) {
|
||||
this.logWriter = logWriter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter for errorLogWriter property
|
||||
*
|
||||
* @param errorLogWriter - the new value of the errorLogWriter property
|
||||
*/
|
||||
public void setErrorLogWriter(PrintWriter errorLogWriter) {
|
||||
this.errorLogWriter = errorLogWriter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current working directory. Source commands will be relative to this.
|
||||
*/
|
||||
public void setUserDirectory(String userDirectory) {
|
||||
this.userDirectory = userDirectory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an SQL script (read in using the Reader parameter)
|
||||
*
|
||||
* @param filepath - the filepath of the script to run. May be relative to the userDirectory.
|
||||
*/
|
||||
public void runScript(String filepath) throws IOException, SQLException {
|
||||
File file = new File(userDirectory, filepath);
|
||||
this.runScript(new BufferedReader(new FileReader(file)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an SQL script (read in using the Reader parameter)
|
||||
*
|
||||
* @param reader - the source of the script
|
||||
*/
|
||||
public void runScript(Reader reader) throws IOException, SQLException {
|
||||
try {
|
||||
boolean originalAutoCommit = connection.getAutoCommit();
|
||||
try {
|
||||
if (originalAutoCommit != this.autoCommit) {
|
||||
connection.setAutoCommit(this.autoCommit);
|
||||
}
|
||||
runScript(connection, reader);
|
||||
} finally {
|
||||
connection.setAutoCommit(originalAutoCommit);
|
||||
}
|
||||
} catch (IOException | SQLException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Error running script. Cause: " + e, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an SQL script (read in using the Reader parameter) using the
|
||||
* connection passed in
|
||||
*
|
||||
* @param conn - the connection to use for the script
|
||||
* @param reader - the source of the script
|
||||
* @throws SQLException if any SQL errors occur
|
||||
* @throws IOException if there is an error reading from the Reader
|
||||
*/
|
||||
private void runScript(Connection conn, Reader reader) throws IOException,
|
||||
SQLException {
|
||||
StringBuffer command = null;
|
||||
try {
|
||||
LineNumberReader lineReader = new LineNumberReader(reader);
|
||||
String line;
|
||||
while ((line = lineReader.readLine()) != null) {
|
||||
if (command == null) {
|
||||
command = new StringBuffer();
|
||||
}
|
||||
String trimmedLine = line.trim();
|
||||
final Matcher delimMatch = delimP.matcher(trimmedLine);
|
||||
if (trimmedLine.length() < 1
|
||||
|| trimmedLine.startsWith("//")) {
|
||||
// Do nothing
|
||||
} else if (delimMatch.matches()) {
|
||||
setDelimiter(delimMatch.group(2), false);
|
||||
} else if (trimmedLine.startsWith("--")) {
|
||||
println(trimmedLine);
|
||||
} else if (trimmedLine.length() < 1
|
||||
|| trimmedLine.startsWith("--")) {
|
||||
// Do nothing
|
||||
} else if (!fullLineDelimiter
|
||||
&& trimmedLine.endsWith(getDelimiter())
|
||||
|| fullLineDelimiter
|
||||
&& trimmedLine.equals(getDelimiter())) {
|
||||
command.append(line.substring(0, line
|
||||
.lastIndexOf(getDelimiter())));
|
||||
command.append(" ");
|
||||
this.execCommand(conn, command, lineReader);
|
||||
command = null;
|
||||
} else {
|
||||
command.append(line);
|
||||
command.append("\n");
|
||||
}
|
||||
}
|
||||
if (command != null) {
|
||||
this.execCommand(conn, command, lineReader);
|
||||
}
|
||||
if (!autoCommit) {
|
||||
conn.commit();
|
||||
}
|
||||
}
|
||||
catch (IOException e) {
|
||||
throw new IOException(String.format("Error executing '%s': %s", command, e.getMessage()), e);
|
||||
} finally {
|
||||
conn.rollback();
|
||||
flush();
|
||||
}
|
||||
}
|
||||
|
||||
private void execCommand(Connection conn, StringBuffer command,
|
||||
LineNumberReader lineReader) throws IOException, SQLException {
|
||||
|
||||
if (command.length() == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Matcher sourceCommandMatcher = SOURCE_COMMAND.matcher(command);
|
||||
if (sourceCommandMatcher.matches()) {
|
||||
this.runScriptFile(conn, sourceCommandMatcher.group(1));
|
||||
return;
|
||||
}
|
||||
|
||||
this.execSqlCommand(conn, command, lineReader);
|
||||
}
|
||||
|
||||
private void runScriptFile(Connection conn, String filepath) throws IOException, SQLException {
|
||||
File file = new File(userDirectory, filepath);
|
||||
this.runScript(conn, new BufferedReader(new FileReader(file)));
|
||||
}
|
||||
|
||||
private void execSqlCommand(Connection conn, StringBuffer command,
|
||||
LineNumberReader lineReader) throws SQLException {
|
||||
|
||||
Statement statement = conn.createStatement();
|
||||
|
||||
println(command);
|
||||
|
||||
boolean hasResults = false;
|
||||
try {
|
||||
hasResults = statement.execute(command.toString());
|
||||
} catch (SQLException e) {
|
||||
final String errText = String.format("Error executing '%s' (line %d): %s",
|
||||
command, lineReader.getLineNumber(), e.getMessage());
|
||||
printlnError(errText);
|
||||
System.err.println(errText);
|
||||
if (stopOnError) {
|
||||
throw new SQLException(errText, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (autoCommit && !conn.getAutoCommit()) {
|
||||
conn.commit();
|
||||
}
|
||||
|
||||
ResultSet rs = statement.getResultSet();
|
||||
if (hasResults && rs != null) {
|
||||
ResultSetMetaData md = rs.getMetaData();
|
||||
int cols = md.getColumnCount();
|
||||
for (int i = 1; i <= cols; i++) {
|
||||
String name = md.getColumnLabel(i);
|
||||
print(name + "\t");
|
||||
}
|
||||
println("");
|
||||
while (rs.next()) {
|
||||
for (int i = 1; i <= cols; i++) {
|
||||
String value = rs.getString(i);
|
||||
print(value + "\t");
|
||||
}
|
||||
println("");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
statement.close();
|
||||
} catch (Exception e) {
|
||||
// Ignore to workaround a bug in Jakarta DBCP
|
||||
}
|
||||
}
|
||||
|
||||
private String getDelimiter() {
|
||||
return delimiter;
|
||||
}
|
||||
|
||||
@SuppressWarnings("UseOfSystemOutOrSystemErr")
|
||||
|
||||
private void print(Object o) {
|
||||
if (logWriter != null) {
|
||||
logWriter.print(o);
|
||||
}
|
||||
}
|
||||
|
||||
private void println(Object o) {
|
||||
if (logWriter != null) {
|
||||
logWriter.println(o);
|
||||
}
|
||||
}
|
||||
|
||||
private void printlnError(Object o) {
|
||||
if (errorLogWriter != null) {
|
||||
errorLogWriter.println(o);
|
||||
}
|
||||
}
|
||||
|
||||
private void flush() {
|
||||
if (logWriter != null) {
|
||||
logWriter.flush();
|
||||
}
|
||||
if (errorLogWriter != null) {
|
||||
errorLogWriter.flush();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
package nl.andrewlalis.aos_server_registry.data;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.SQLException;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Scheduled task that runs once in a while and removes servers from the
|
||||
* registry which have not been updated in a while.
|
||||
*/
|
||||
public class ServerDataPruner implements Runnable {
|
||||
private static final Logger log = Logger.getLogger(ServerDataPruner.class.getName());
|
||||
|
||||
private final long intervalMinutes;
|
||||
|
||||
public ServerDataPruner(long intervalMinutes) {
|
||||
this.intervalMinutes = intervalMinutes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
var con = DataManager.getInstance().getConnection();
|
||||
String sql = """
|
||||
DELETE FROM servers
|
||||
WHERE DATEDIFF('MINUTE', servers.updated_at, CURRENT_TIMESTAMP(0)) > ?
|
||||
""";
|
||||
PreparedStatement stmt = con.prepareStatement(sql);
|
||||
stmt.setLong(1, this.intervalMinutes);
|
||||
int rowCount = stmt.executeUpdate();
|
||||
stmt.close();
|
||||
if (rowCount > 0) {
|
||||
log.info("Removed " + rowCount + " servers from registry due to inactivity.");
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package nl.andrewlalis.aos_server_registry.data;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
|
||||
public interface Transaction {
|
||||
void execute(Connection con) throws SQLException;
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
package nl.andrewlalis.aos_server_registry.servlet;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class Page<T> {
|
||||
private final List<T> contents;
|
||||
private final int elementCount;
|
||||
private final int pageSize;
|
||||
private final int currentPage;
|
||||
private final boolean firstPage;
|
||||
private final boolean lastPage;
|
||||
private final String order;
|
||||
private final String orderDirection;
|
||||
|
||||
public Page(List<T> contents, int currentPage, int pageSize, String order, String orderDirection) {
|
||||
this.contents = contents;
|
||||
this.elementCount = contents.size();
|
||||
this.pageSize = pageSize;
|
||||
this.currentPage = currentPage;
|
||||
this.firstPage = currentPage == 0;
|
||||
this.lastPage = this.elementCount < this.pageSize;
|
||||
this.order = order;
|
||||
this.orderDirection = orderDirection;
|
||||
}
|
||||
|
||||
public List<T> getContents() {
|
||||
return contents;
|
||||
}
|
||||
|
||||
public int getElementCount() {
|
||||
return elementCount;
|
||||
}
|
||||
|
||||
public int getPageSize() {
|
||||
return pageSize;
|
||||
}
|
||||
|
||||
public int getCurrentPage() {
|
||||
return currentPage;
|
||||
}
|
||||
|
||||
public boolean isFirstPage() {
|
||||
return firstPage;
|
||||
}
|
||||
|
||||
public boolean isLastPage() {
|
||||
return lastPage;
|
||||
}
|
||||
|
||||
public String getOrder() {
|
||||
return order;
|
||||
}
|
||||
|
||||
public String getOrderDirection() {
|
||||
return orderDirection;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package nl.andrewlalis.aos_server_registry.servlet;
|
||||
|
||||
public class ResponseStatusException extends Exception {
|
||||
private final int statusCode;
|
||||
private final String message;
|
||||
|
||||
public ResponseStatusException(int statusCode, String message) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public int getStatusCode() {
|
||||
return statusCode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,204 @@
|
|||
package nl.andrewlalis.aos_server_registry.servlet;
|
||||
|
||||
import nl.andrewlalis.aos_server_registry.data.DataManager;
|
||||
import nl.andrewlalis.aos_server_registry.servlet.dto.ServerInfoResponse;
|
||||
import nl.andrewlalis.aos_server_registry.servlet.dto.ServerInfoUpdate;
|
||||
import nl.andrewlalis.aos_server_registry.servlet.dto.ServerStatusUpdate;
|
||||
import nl.andrewlalis.aos_server_registry.util.Requests;
|
||||
import nl.andrewlalis.aos_server_registry.util.Responses;
|
||||
|
||||
import javax.servlet.http.HttpServlet;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public class ServerInfoServlet extends HttpServlet {
|
||||
private static final Logger log = Logger.getLogger(ServerInfoServlet.class.getName());
|
||||
|
||||
@Override
|
||||
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
|
||||
int page = Requests.getIntParam(req, "page", 0, i -> i >= 0);
|
||||
int size = Requests.getIntParam(req, "size", 20, i -> i >= 5 && i <= 50);
|
||||
String searchQuery = Requests.getStringParam(req, "q", null, s -> !s.isBlank());
|
||||
String order = Requests.getStringParam(req, "order", "name", s -> !s.isBlank() && (
|
||||
s.equalsIgnoreCase("name") ||
|
||||
s.equalsIgnoreCase("address") ||
|
||||
s.equalsIgnoreCase("location") ||
|
||||
s.equalsIgnoreCase("max_players") ||
|
||||
s.equalsIgnoreCase("current_players")
|
||||
));
|
||||
String orderDir = Requests.getStringParam(req, "dir", "ASC", s -> s.equalsIgnoreCase("ASC") || s.equalsIgnoreCase("DESC"));
|
||||
try {
|
||||
var results = this.getData(size, page, searchQuery, null, order, orderDir);
|
||||
Responses.ok(resp, new Page<>(results, page, size, order, orderDir));
|
||||
} catch (SQLException t) {
|
||||
t.printStackTrace();
|
||||
Responses.internalServerError(resp, "Database error.");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
|
||||
var info = Requests.getBody(req, ServerInfoUpdate.class);
|
||||
try {
|
||||
this.saveNewServer(info);
|
||||
Responses.ok(resp, Map.of("message", "Server icon saved."));
|
||||
} catch (ResponseStatusException e) {
|
||||
Responses.json(resp, e.getStatusCode(), Map.of("message", e.getMessage()));
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
Responses.internalServerError(resp, "Database error.");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doPut(HttpServletRequest req, HttpServletResponse resp) throws IOException {
|
||||
var status = Requests.getBody(req, ServerStatusUpdate.class);
|
||||
this.updateServerStatus(status, resp);
|
||||
}
|
||||
|
||||
private List<ServerInfoResponse> getData(int size, int page, String searchQuery, String versionQuery, String order, String orderDir) throws SQLException, IOException {
|
||||
final List<ServerInfoResponse> results = new ArrayList<>(20);
|
||||
var con = DataManager.getInstance().getConnection();
|
||||
String selectQuery = """
|
||||
SELECT name, address, version, updated_at, description, location, icon, max_players, current_players
|
||||
FROM servers
|
||||
//CONDITIONS
|
||||
ORDER BY name
|
||||
LIMIT ?
|
||||
OFFSET ?
|
||||
""";
|
||||
selectQuery = selectQuery.replace("ORDER BY name", "ORDER BY " + order + " " + orderDir);
|
||||
List<String> conditions = new ArrayList<>();
|
||||
List<Object> conditionParams = new ArrayList<>();
|
||||
if (searchQuery != null && !searchQuery.isBlank()) {
|
||||
conditions.add("UPPER(name) LIKE ?");
|
||||
conditionParams.add("%" + searchQuery.toUpperCase() + "%");
|
||||
}
|
||||
if (versionQuery != null && !versionQuery.isBlank()) {
|
||||
conditions.add("version = ?");
|
||||
conditionParams.add(versionQuery);
|
||||
}
|
||||
if (!conditions.isEmpty()) {
|
||||
selectQuery = selectQuery.replace("//CONDITIONS", "WHERE " + String.join(" AND ", conditions));
|
||||
}
|
||||
PreparedStatement stmt = con.prepareStatement(selectQuery);
|
||||
int index = 1;
|
||||
for (var param : conditionParams) {
|
||||
stmt.setObject(index++, param);
|
||||
}
|
||||
stmt.setInt(index++, size);
|
||||
stmt.setInt(index, page * size);
|
||||
ResultSet rs = stmt.executeQuery();
|
||||
while (rs.next()) {
|
||||
// Attempt to load the server's icon, if it is not null.
|
||||
InputStream iconInputStream = rs.getBinaryStream(7);
|
||||
String encodedIconImage = null;
|
||||
if (iconInputStream != null) {
|
||||
encodedIconImage = Base64.getUrlEncoder().encodeToString(iconInputStream.readAllBytes());
|
||||
}
|
||||
results.add(new ServerInfoResponse(
|
||||
rs.getString(1),
|
||||
rs.getString(2),
|
||||
rs.getString(3),
|
||||
rs.getTimestamp(4).toInstant().atOffset(ZoneOffset.UTC).toString(),
|
||||
rs.getString(5),
|
||||
rs.getString(6),
|
||||
encodedIconImage,
|
||||
rs.getInt(8),
|
||||
rs.getInt(9)
|
||||
));
|
||||
}
|
||||
stmt.close();
|
||||
return results;
|
||||
}
|
||||
|
||||
private void saveNewServer(ServerInfoUpdate info) throws SQLException, ResponseStatusException {
|
||||
var con = DataManager.getInstance().getConnection();
|
||||
PreparedStatement stmt = con.prepareStatement("SELECT name, address FROM servers WHERE name = ? AND address = ?");
|
||||
stmt.setString(1, info.name());
|
||||
stmt.setString(2, info.address());
|
||||
ResultSet rs = stmt.executeQuery();
|
||||
boolean exists = rs.next();
|
||||
stmt.close();
|
||||
String version = info.version() == null ? "Unknown" : info.version();
|
||||
if (!exists) {
|
||||
PreparedStatement createStmt = con.prepareStatement("""
|
||||
INSERT INTO servers (name, address, version, description, location, icon, max_players, current_players)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?);
|
||||
""");
|
||||
createStmt.setString(1, info.name());
|
||||
createStmt.setString(2, info.address());
|
||||
createStmt.setString(3, version);
|
||||
createStmt.setString(4, info.description());
|
||||
createStmt.setString(5, info.location());
|
||||
InputStream inputStream = null;
|
||||
if (info.icon() != null) {
|
||||
inputStream = new ByteArrayInputStream(Base64.getUrlDecoder().decode(info.icon()));
|
||||
}
|
||||
createStmt.setBinaryStream(6, inputStream);
|
||||
createStmt.setInt(7, info.maxPlayers());
|
||||
createStmt.setInt(8, info.currentPlayers());
|
||||
int rowCount = createStmt.executeUpdate();
|
||||
createStmt.close();
|
||||
if (rowCount != 1) throw new SQLException("Could not insert new server.");
|
||||
log.info("Registered new server " + info.name() + " @ " + info.address() + " running version " + version + ".");
|
||||
} else {
|
||||
PreparedStatement updateStmt = con.prepareStatement("""
|
||||
UPDATE servers SET version = ?, description = ?, location = ?, icon = ?, max_players = ?, current_players = ?
|
||||
WHERE name = ? AND address = ?;
|
||||
""");
|
||||
updateStmt.setString(1, version);
|
||||
updateStmt.setString(2, info.description());
|
||||
updateStmt.setString(3, info.location());
|
||||
InputStream inputStream = null;
|
||||
if (info.icon() != null) {
|
||||
inputStream = new ByteArrayInputStream(Base64.getUrlDecoder().decode(info.icon()));
|
||||
}
|
||||
updateStmt.setBinaryStream(4, inputStream);
|
||||
updateStmt.setInt(5, info.maxPlayers());
|
||||
updateStmt.setInt(6, info.currentPlayers());
|
||||
updateStmt.setString(7, info.name());
|
||||
updateStmt.setString(8, info.address());
|
||||
int rowCount = updateStmt.executeUpdate();
|
||||
updateStmt.close();
|
||||
if (rowCount != 1) throw new SQLException("Could not update server.");
|
||||
log.info("Updated server information for " + info.name() + " @ " + info.address());
|
||||
}
|
||||
}
|
||||
|
||||
private void updateServerStatus(ServerStatusUpdate status, HttpServletResponse resp) throws IOException {
|
||||
try {
|
||||
var con = DataManager.getInstance().getConnection();
|
||||
PreparedStatement stmt = con.prepareStatement("""
|
||||
UPDATE servers SET current_players = ?, updated_at = CURRENT_TIMESTAMP(0)
|
||||
WHERE name = ? AND address = ?
|
||||
""");
|
||||
stmt.setInt(1, status.currentPlayers());
|
||||
stmt.setString(2, status.name());
|
||||
stmt.setString(3, status.address());
|
||||
int rowCount = stmt.executeUpdate();
|
||||
stmt.close();
|
||||
if (rowCount != 1) {
|
||||
Responses.notFound(resp);
|
||||
} else {
|
||||
Responses.ok(resp);
|
||||
log.info("Status updated for " + status.name() + " @ " + status.address());
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
Responses.internalServerError(resp, "Database error.");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package nl.andrewlalis.aos_server_registry.servlet.dto;
|
||||
|
||||
public record ServerInfoResponse(
|
||||
String name,
|
||||
String address,
|
||||
String version,
|
||||
String updatedAt,
|
||||
String description,
|
||||
String location,
|
||||
String icon,
|
||||
int maxPlayers,
|
||||
int currentPlayers
|
||||
) {}
|
|
@ -0,0 +1,12 @@
|
|||
package nl.andrewlalis.aos_server_registry.servlet.dto;
|
||||
|
||||
public record ServerInfoUpdate (
|
||||
String name,
|
||||
String address,
|
||||
String version,
|
||||
String description,
|
||||
String location,
|
||||
String icon,
|
||||
int maxPlayers,
|
||||
int currentPlayers
|
||||
) {}
|
|
@ -0,0 +1,7 @@
|
|||
package nl.andrewlalis.aos_server_registry.servlet.dto;
|
||||
|
||||
public record ServerStatusUpdate (
|
||||
String name,
|
||||
String address,
|
||||
int currentPlayers
|
||||
) {}
|
|
@ -0,0 +1,38 @@
|
|||
package nl.andrewlalis.aos_server_registry.util;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.io.IOException;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static nl.andrewlalis.aos_server_registry.ServerRegistry.mapper;
|
||||
|
||||
/**
|
||||
* Helper methods for working with HTTP requests.
|
||||
*/
|
||||
public class Requests {
|
||||
public static <T> T getBody(HttpServletRequest req, Class<T> bodyClass) throws IOException {
|
||||
return mapper.readValue(req.getInputStream(), bodyClass);
|
||||
}
|
||||
|
||||
public static int getIntParam(HttpServletRequest req, String name, int defaultValue, Function<Integer, Boolean> validator) {
|
||||
return getParam(req, name, defaultValue, Integer::parseInt, validator);
|
||||
}
|
||||
|
||||
public static String getStringParam(HttpServletRequest req, String name, String defaultValue, Function<String, Boolean> validator) {
|
||||
return getParam(req, name, defaultValue, s -> s, validator);
|
||||
}
|
||||
|
||||
private static <T> T getParam(HttpServletRequest req, String name, T defaultValue, Function<String, T> parser, Function<T, Boolean> validator) {
|
||||
var values = req.getParameterValues(name);
|
||||
if (values == null || values.length == 0) return defaultValue;
|
||||
try {
|
||||
T value = parser.apply(values[0]);
|
||||
if (!validator.apply(value)) {
|
||||
return defaultValue;
|
||||
}
|
||||
return value;
|
||||
} catch (Exception e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
package nl.andrewlalis.aos_server_registry.util;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static nl.andrewlalis.aos_server_registry.ServerRegistry.mapper;
|
||||
|
||||
/**
|
||||
* Helper class which provides some convenience methods for returning simple
|
||||
* JSON responses.
|
||||
*/
|
||||
public class Responses {
|
||||
public static void ok(HttpServletResponse resp) {
|
||||
resp.setStatus(HttpServletResponse.SC_OK);
|
||||
}
|
||||
|
||||
public static void ok(HttpServletResponse resp, Object body) throws IOException {
|
||||
resp.setStatus(HttpServletResponse.SC_OK);
|
||||
resp.setContentType("application/json");
|
||||
mapper.writeValue(resp.getOutputStream(), body);
|
||||
}
|
||||
|
||||
public static void json(HttpServletResponse resp, int status, Object body) throws IOException {
|
||||
resp.setStatus(status);
|
||||
resp.setContentType("application/json");
|
||||
mapper.writeValue(resp.getOutputStream(), body);
|
||||
}
|
||||
|
||||
public static void badRequest(HttpServletResponse resp, String msg) throws IOException {
|
||||
respond(resp, HttpServletResponse.SC_BAD_REQUEST, msg);
|
||||
}
|
||||
|
||||
public static void notFound(HttpServletResponse resp) throws IOException {
|
||||
respond(resp, HttpServletResponse.SC_NOT_FOUND, "Not found.");
|
||||
}
|
||||
|
||||
public static void notFound(HttpServletResponse resp, String msg) throws IOException {
|
||||
respond(resp, HttpServletResponse.SC_NOT_FOUND, msg);
|
||||
}
|
||||
|
||||
public static void internalServerError(HttpServletResponse resp) throws IOException {
|
||||
respond(resp, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Internal server error.");
|
||||
}
|
||||
|
||||
public static void internalServerError(HttpServletResponse resp, String msg) throws IOException {
|
||||
respond(resp, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, msg);
|
||||
}
|
||||
|
||||
private static void respond(HttpServletResponse resp, int status, String msg) throws IOException {
|
||||
resp.setStatus(status);
|
||||
resp.setContentType("application/json");
|
||||
mapper.writeValue(resp.getOutputStream(), msg);
|
||||
}
|
||||
|
||||
private static record ResponseBody(String message) {}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
# Default properties for the AOS Server Registry
|
||||
|
||||
port=25566
|
||||
prune-delay=60
|
||||
prune-threshold-minutes=5
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue