Added searchable servers dialog in launcher.

This commit is contained in:
Andrew Lalis 2021-06-30 17:17:10 +02:00
parent a9e032119c
commit 6e19524cd9
11 changed files with 379 additions and 15 deletions

View File

@ -1,6 +1,7 @@
module aos_client {
requires java.logging;
requires java.se;
requires com.fasterxml.jackson.databind;
requires aos_core;
}

View File

@ -117,6 +117,14 @@ public class Launcher extends JFrame {
});
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 -> {

View File

@ -105,7 +105,7 @@ public class AddServerDialog extends JDialog {
if (username != null && !username.isBlank() && username.length() > 16) {
messages.add("Username is too long. Maximum of 16 characters.");
}
if (username != null && !Launcher.usernamePattern.matcher(username).matches()) {
if (username != null && !username.isBlank() && !Launcher.usernamePattern.matcher(username).matches()) {
messages.add("Username should contain only letters, numbers, underscores, and hyphens.");
}
return messages;

View File

@ -0,0 +1,43 @@
package nl.andrewlalis.aos_client.launcher.servers;
import javax.swing.*;
import java.awt.*;
public record PublicServerInfo(
String name,
String address,
String description,
String location,
int maxPlayers,
int currentPlayers
) {
public static ListCellRenderer<PublicServerInfo> cellRenderer() {
return (list, value, index, isSelected, cellHasFocus) -> {
JPanel panel = new JPanel(new BorderLayout());
panel.setBorder(BorderFactory.createTitledBorder(value.name()));
panel.add(new JLabel("Address: " + value.address()), BorderLayout.NORTH);
JTextArea descriptionArea = new JTextArea(value.description());
descriptionArea.setEditable(false);
descriptionArea.setWrapStyleWord(true);
descriptionArea.setLineWrap(true);
panel.add(descriptionArea, BorderLayout.CENTER);
JPanel bottomPanel = new JPanel();
bottomPanel.add(new JLabel(String.format("Current players: %d / %d", value.currentPlayers(), value.maxPlayers())));
panel.add(bottomPanel, BorderLayout.SOUTH);
if (isSelected) {
panel.setBackground(list.getSelectionBackground());
panel.setForeground(list.getSelectionForeground());
} else {
panel.setBackground(list.getBackground());
panel.setForeground(list.getForeground());
}
return panel;
};
}
}

View File

@ -0,0 +1,146 @@
package nl.andrewlalis.aos_client.launcher.servers;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.swing.*;
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.Iterator;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class PublicServerListModel extends AbstractListModel<PublicServerInfo> {
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 final JButton prevButton;
private final JButton nextButton;
public PublicServerListModel(JButton prevButton, JButton nextButton) {
this.currentPageItems = new ArrayList<>();
this.prevButton = prevButton;
this.nextButton = nextButton;
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);
this.executorService.scheduleAtFixedRate(
() -> this.fetchPage(this.currentPage, this.currentQuery, this.currentOrder, this.currentOrderDir),
5,
5,
TimeUnit.SECONDS
);
}
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) {
String uri = "http://localhost:8567/serverInfo?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);
}
HttpRequest request;
try {
request = HttpRequest.newBuilder().GET().uri(new URI(uri)).header("Accept", "application/json").build();
} catch (URISyntaxException e) {
e.printStackTrace();
return;
}
this.client.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()).thenAcceptAsync(response -> {
if (response.statusCode() != 200) {
System.err.println("Non-OK status code: " + response.statusCode());
}
try {
JsonNode json = this.mapper.readValue(response.body(), JsonNode.class);
this.firstPage = json.get("firstPage").asBoolean();
this.prevButton.setEnabled(!this.firstPage);
this.lastPage = json.get("lastPage").asBoolean();
this.nextButton.setEnabled(!this.lastPage);
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();
this.currentPageItems.clear();
for (Iterator<JsonNode> it = json.get("contents").elements(); it.hasNext();) {
JsonNode node = it.next();
PublicServerInfo info = new PublicServerInfo(
node.get("name").asText(),
node.get("address").asText(),
node.get("description").asText(),
node.get("location").asText(),
node.get("maxPlayers").asInt(),
node.get("currentPlayers").asInt()
);
this.currentPageItems.add(info);
}
this.fireContentsChanged(this, 0, this.getSize());
} catch (IOException e) {
e.printStackTrace();
}
});
}
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);
}
}

View File

@ -0,0 +1,99 @@
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 {
public SearchServersDialog(Frame frame, ServerInfoListModel serverInfoListModel) {
super(frame, true);
this.setTitle("Search for Servers");
this.setContentPane(this.getContent(serverInfoListModel));
this.pack();
this.setLocationRelativeTo(frame);
}
private Container getContent(ServerInfoListModel serverInfoListModel) {
JPanel panel = new JPanel(new BorderLayout());
JButton prevButton = new JButton("<");
JButton nextButton = new JButton(">");
PublicServerListModel listModel = new PublicServerListModel(prevButton, nextButton);
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(20);
searchField.setToolTipText("Search for a server by name.");
filtersPanel.add(searchField);
prevButton.addActionListener(e -> listModel.fetchPage(listModel.getCurrentPage() - 1));
filtersPanel.add(prevButton);
nextButton.addActionListener(e -> listModel.fetchPage(listModel.getCurrentPage() + 1));
filtersPanel.add(nextButton);
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);
}
}
}

64
help.md
View File

@ -10,19 +10,25 @@ Each entry in the list is just a way to remember a specific address (and optiona
>
> 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. |
| `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. |
| 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.
@ -36,3 +42,41 @@ Each time you kill someone from another team, your own team's score increases. D
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.

View File

@ -9,14 +9,18 @@ public class Page<T> {
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) {
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() {
@ -42,4 +46,12 @@ public class Page<T> {
public boolean isLastPage() {
return lastPage;
}
public String getOrder() {
return order;
}
public String getOrderDirection() {
return orderDirection;
}
}

View File

@ -38,7 +38,7 @@ public class ServerInfoServlet extends HttpServlet {
String orderDir = Requests.getStringParam(req, "dir", "ASC", s -> s.equalsIgnoreCase("ASC") || s.equalsIgnoreCase("DESC"));
try {
var results = this.getData(size, page, searchQuery, order, orderDir);
Responses.ok(resp, new Page<>(results, page, size));
Responses.ok(resp, new Page<>(results, page, size, order, orderDir));
} catch (SQLException t) {
t.printStackTrace();
Responses.internalServerError(resp, "Database error.");
@ -146,7 +146,7 @@ public class ServerInfoServlet extends HttpServlet {
try {
var con = DataManager.getInstance().getConnection();
PreparedStatement stmt = con.prepareStatement("""
UPDATE servers SET current_players = ?
UPDATE servers SET current_players = ?, updated_at = CURRENT_TIMESTAMP(0)
WHERE name = ? AND address = ?
""");
stmt.setInt(1, status.currentPlayers());

View File

@ -4,7 +4,7 @@ CREATE TABLE servers (
name VARCHAR(64) NOT NULL,
address VARCHAR(255) NOT NULL,
created_at TIMESTAMP(0) WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP(0),
updated_at TIMESTAMP(0) WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0),
updated_at TIMESTAMP(0) WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP(0),
description VARCHAR(1024),
location VARCHAR(64),

View File

@ -1,15 +1,26 @@
# The port that this server uses for TCP and UDP communication.
port: 8035
# The maximum number of players that can be connected at once.
max-players: 32
# How many times per second should physics updates be calculated.
# WARNING: Changing this has a major impact on server performance.
ticks-per-second: 120
# Information for the public server registry.
registry-settings:
# Set this to true to allow other players to see this server and join it.
discoverable: true
# The URI which points to the registry server. This is only used if discoverable is true.
registry-uri: "http://localhost:8567"
# How often to send status updates to the registry server, in seconds.
update-interval: 10
# The name of this server.
name: "Testing Server"
# The address that clients can use to connect to this server.
address: "localhost:8035"
# A short description of this server.
description: "A simple testing server for development."
# Location of this server, to help players choose servers near to them.
location: "Earth"