Added improved init network flow, team colors, and teams.

This commit is contained in:
Andrew Lalis 2022-07-20 16:36:44 +02:00
parent 3d1b076c58
commit b88150fd3e
26 changed files with 506 additions and 202 deletions

6
.gitignore vendored
View File

@ -1,2 +1,6 @@
.idea/
target/
target/
# Ignore the ./config directory so that developers can put their config files
# there for server and client apps.
config

View File

@ -6,21 +6,25 @@ import nl.andrewl.aos2_client.control.PlayerInputKeyCallback;
import nl.andrewl.aos2_client.control.PlayerInputMouseClickCallback;
import nl.andrewl.aos2_client.control.PlayerViewCursorCallback;
import nl.andrewl.aos2_client.model.ClientPlayer;
import nl.andrewl.aos2_client.model.OtherPlayer;
import nl.andrewl.aos2_client.render.GameRenderer;
import nl.andrewl.aos_core.config.Config;
import nl.andrewl.aos_core.model.world.ColorPalette;
import nl.andrewl.aos_core.model.Player;
import nl.andrewl.aos_core.model.Team;
import nl.andrewl.aos_core.net.client.*;
import nl.andrewl.aos_core.net.world.ChunkDataMessage;
import nl.andrewl.aos_core.net.world.ChunkHashMessage;
import nl.andrewl.aos_core.net.world.ChunkUpdateMessage;
import nl.andrewl.aos_core.net.world.WorldInfoMessage;
import nl.andrewl.record_net.Message;
import org.joml.Vector3f;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class Client implements Runnable {
private static final Logger log = LoggerFactory.getLogger(Client.class);
@ -30,32 +34,36 @@ public class Client implements Runnable {
private final CommunicationHandler communicationHandler;
private final InputHandler inputHandler;
private GameRenderer gameRenderer;
private long lastPlayerUpdate = 0;
private final ClientWorld world;
private ClientPlayer player;
private ClientWorld world;
private ClientPlayer myPlayer;
private final Map<Integer, OtherPlayer> players;
private Map<Integer, Team> teams;
public Client(ClientConfig config) {
this.config = config;
this.players = new ConcurrentHashMap<>();
this.teams = new ConcurrentHashMap<>();
this.communicationHandler = new CommunicationHandler(this);
this.inputHandler = new InputHandler(this, communicationHandler);
this.world = new ClientWorld();
}
public ClientConfig getConfig() {
return config;
}
public ClientPlayer getPlayer() {
return player;
public ClientPlayer getMyPlayer() {
return myPlayer;
}
/**
* Called by the {@link CommunicationHandler} when a connection is
* established, and we need to begin tracking the player's state.
* @param player The player.
* @param myPlayer The player.
*/
public void setPlayer(ClientPlayer player) {
this.player = player;
public void setMyPlayer(ClientPlayer myPlayer) {
this.myPlayer = myPlayer;
}
@Override
@ -67,7 +75,7 @@ public class Client implements Runnable {
return;
}
gameRenderer = new GameRenderer(config.display, player, world);
gameRenderer = new GameRenderer(config.display, this);
gameRenderer.setupWindow(
new PlayerViewCursorCallback(config.input, this, gameRenderer.getCamera(), communicationHandler),
new PlayerInputKeyCallback(inputHandler),
@ -80,7 +88,7 @@ public class Client implements Runnable {
float dt = (now - lastFrameAt) / 1000f;
world.processQueuedChunkUpdates();
gameRenderer.getCamera().interpolatePosition(dt);
world.interpolatePlayers(dt);
interpolatePlayers(dt);
gameRenderer.draw();
lastFrameAt = now;
}
@ -89,9 +97,7 @@ public class Client implements Runnable {
}
public void onMessageReceived(Message msg) {
if (msg instanceof WorldInfoMessage worldInfo) {
world.setPalette(ColorPalette.fromArray(worldInfo.palette()));
} else if (msg instanceof ChunkDataMessage chunkDataMessage) {
if (msg instanceof ChunkDataMessage chunkDataMessage) {
world.addChunk(chunkDataMessage);
} else if (msg instanceof ChunkUpdateMessage u) {
world.updateChunk(u);
@ -100,29 +106,72 @@ public class Client implements Runnable {
communicationHandler.sendMessage(new ChunkHashMessage(u.cx(), u.cy(), u.cz(), -1));
}
} else if (msg instanceof PlayerUpdateMessage playerUpdate) {
if (playerUpdate.clientId() == player.getId()) {
player.getPosition().set(playerUpdate.px(), playerUpdate.py(), playerUpdate.pz());
player.getVelocity().set(playerUpdate.vx(), playerUpdate.vy(), playerUpdate.vz());
player.setCrouching(playerUpdate.crouching());
if (playerUpdate.clientId() == myPlayer.getId() && playerUpdate.timestamp() > lastPlayerUpdate) {
myPlayer.getPosition().set(playerUpdate.px(), playerUpdate.py(), playerUpdate.pz());
myPlayer.getVelocity().set(playerUpdate.vx(), playerUpdate.vy(), playerUpdate.vz());
myPlayer.setCrouching(playerUpdate.crouching());
if (gameRenderer != null) {
gameRenderer.getCamera().setToPlayer(player);
gameRenderer.getCamera().setToPlayer(myPlayer);
}
lastPlayerUpdate = playerUpdate.timestamp();
} else {
world.playerUpdated(playerUpdate);
OtherPlayer p = players.get(playerUpdate.clientId());
if (p != null) {
playerUpdate.apply(p);
p.setHeldItemId(playerUpdate.selectedItemId());
p.updateModelTransform();
}
}
} else if (msg instanceof ClientInventoryMessage inventoryMessage) {
player.setInventory(inventoryMessage.inv());
myPlayer.setInventory(inventoryMessage.inv());
} else if (msg instanceof InventorySelectedStackMessage selectedStackMessage) {
player.getInventory().setSelectedIndex(selectedStackMessage.index());
myPlayer.getInventory().setSelectedIndex(selectedStackMessage.index());
} else if (msg instanceof ItemStackMessage itemStackMessage) {
player.getInventory().getItemStacks().set(itemStackMessage.index(), itemStackMessage.stack());
myPlayer.getInventory().getItemStacks().set(itemStackMessage.index(), itemStackMessage.stack());
} else if (msg instanceof PlayerJoinMessage joinMessage) {
world.playerJoined(joinMessage);
Player p = joinMessage.toPlayer();
OtherPlayer op = new OtherPlayer(p.getId(), p.getUsername());
if (joinMessage.teamId() != -1) {
op.setTeam(teams.get(joinMessage.teamId()));
}
op.getPosition().set(p.getPosition());
op.getVelocity().set(p.getVelocity());
op.getOrientation().set(p.getOrientation());
op.setHeldItemId(joinMessage.selectedItemId());
players.put(op.getId(), op);
} else if (msg instanceof PlayerLeaveMessage leaveMessage) {
world.playerLeft(leaveMessage);
players.remove(leaveMessage.id());
}
}
public void setWorld(ClientWorld world) {
this.world = world;
}
public ClientWorld getWorld() {
return world;
}
public Map<Integer, Team> getTeams() {
return teams;
}
public void setTeams(Map<Integer, Team> teams) {
this.teams = teams;
}
public Map<Integer, OtherPlayer> getPlayers() {
return players;
}
public void interpolatePlayers(float dt) {
Vector3f movement = new Vector3f();
for (var player : players.values()) {
movement.set(player.getVelocity()).mul(dt);
player.getPosition().add(movement);
player.updateModelTransform();
}
}
public static void main(String[] args) throws IOException {
List<Path> configPaths = Config.getCommonConfigPaths();

View File

@ -1,17 +1,11 @@
package nl.andrewl.aos2_client;
import nl.andrewl.aos2_client.model.OtherPlayer;
import nl.andrewl.aos2_client.render.chunk.ChunkMesh;
import nl.andrewl.aos2_client.render.chunk.ChunkMeshGenerator;
import nl.andrewl.aos_core.model.Player;
import nl.andrewl.aos_core.model.world.Chunk;
import nl.andrewl.aos_core.model.world.World;
import nl.andrewl.aos_core.net.client.PlayerJoinMessage;
import nl.andrewl.aos_core.net.client.PlayerLeaveMessage;
import nl.andrewl.aos_core.net.client.PlayerUpdateMessage;
import nl.andrewl.aos_core.net.world.ChunkDataMessage;
import nl.andrewl.aos_core.net.world.ChunkUpdateMessage;
import org.joml.Vector3f;
import org.joml.Vector3i;
import java.util.*;
@ -29,44 +23,6 @@ public class ClientWorld extends World {
private final ChunkMeshGenerator chunkMeshGenerator = new ChunkMeshGenerator();
private final Map<Chunk, ChunkMesh> chunkMeshes = new ConcurrentHashMap<>();
private final Map<Integer, OtherPlayer> players = new HashMap<>();
public void playerJoined(PlayerJoinMessage joinMessage) {
Player p = joinMessage.toPlayer();
OtherPlayer op = new OtherPlayer(p.getId(), p.getUsername());
op.getPosition().set(p.getPosition());
op.getVelocity().set(p.getVelocity());
op.getOrientation().set(p.getOrientation());
op.setHeldItemId(joinMessage.selectedItemId());
players.put(op.getId(), op);
}
public void playerLeft(PlayerLeaveMessage leaveMessage) {
players.remove(leaveMessage.id());
}
public void playerUpdated(PlayerUpdateMessage playerUpdate) {
OtherPlayer p = players.get(playerUpdate.clientId());
if (p != null) {
playerUpdate.apply(p);
p.setHeldItemId(playerUpdate.selectedItemId());
p.updateModelTransform();
}
}
public Collection<OtherPlayer> getPlayers() {
return players.values();
}
public void interpolatePlayers(float dt) {
Vector3f movement = new Vector3f();
for (var player : getPlayers()) {
movement.set(player.getVelocity()).mul(dt);
player.getPosition().add(movement);
player.updateModelTransform();
}
}
@Override
public void addChunk(Chunk chunk) {
super.addChunk(chunk);

View File

@ -1,7 +1,12 @@
package nl.andrewl.aos2_client;
import nl.andrewl.aos2_client.model.ClientPlayer;
import nl.andrewl.aos2_client.model.OtherPlayer;
import nl.andrewl.aos_core.Net;
import nl.andrewl.aos_core.model.Team;
import nl.andrewl.aos_core.model.item.ItemStack;
import nl.andrewl.aos_core.model.world.World;
import nl.andrewl.aos_core.model.world.WorldIO;
import nl.andrewl.aos_core.net.*;
import nl.andrewl.aos_core.net.connect.ConnectAcceptMessage;
import nl.andrewl.aos_core.net.connect.ConnectRejectMessage;
@ -10,6 +15,7 @@ import nl.andrewl.aos_core.net.connect.DatagramInit;
import nl.andrewl.record_net.Message;
import nl.andrewl.record_net.util.ExtendedDataInputStream;
import nl.andrewl.record_net.util.ExtendedDataOutputStream;
import org.joml.Vector3f;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -31,6 +37,7 @@ public class CommunicationHandler {
private Socket socket;
private DatagramSocket datagramSocket;
private ExtendedDataOutputStream out;
private ExtendedDataInputStream in;
private int clientId;
public CommunicationHandler(Client client) {
@ -48,7 +55,7 @@ public class CommunicationHandler {
socket = new Socket(address, port);
socket.setSoTimeout(1000);
ExtendedDataInputStream in = Net.getInputStream(socket.getInputStream());
in = Net.getInputStream(socket.getInputStream());
out = Net.getOutputStream(socket.getOutputStream());
Net.write(new ConnectRequestMessage(username), out);
Message response = Net.read(in);
@ -58,7 +65,10 @@ public class CommunicationHandler {
}
if (response instanceof ConnectAcceptMessage acceptMessage) {
this.clientId = acceptMessage.clientId();
client.setPlayer(new ClientPlayer(clientId, username));
log.debug("Connection accepted. My client id is {}.", clientId);
client.setMyPlayer(new ClientPlayer(clientId, username));
receiveInitialData();
log.debug("Initial data received.");
establishDatagramConnection();
log.info("Connection to server established. My client id is {}.", clientId);
new Thread(new TcpReceiver(in, client::onMessageReceived)).start();
@ -127,4 +137,57 @@ public class CommunicationHandler {
public int getClientId() {
return clientId;
}
private void receiveInitialData() throws IOException {
// Read the world data.
World world = WorldIO.read(in);
ClientWorld clientWorld = new ClientWorld();
clientWorld.setPalette(world.getPalette());
for (var chunk : world.getChunkMap().values()) {
clientWorld.addChunk(chunk);
}
for (var spawnPoint : world.getSpawnPoints().entrySet()) {
clientWorld.setSpawnPoint(spawnPoint.getKey(), spawnPoint.getValue());
}
client.setWorld(clientWorld);
// Read the team data.
int teamCount = in.readInt();
for (int i = 0; i < teamCount; i++) {
int id = in.readInt();
client.getTeams().put(id, new Team(
id, in.readString(),
new Vector3f(in.readFloat(), in.readFloat(), in.readFloat()),
new Vector3f(in.readFloat(), in.readFloat(), in.readFloat())
));
}
// Read player data.
int playerCount = in.readInt();
for (int i = 0; i < playerCount; i++) {
OtherPlayer player = new OtherPlayer(in.readInt(), in.readString());
int teamId = in.readInt();
if (teamId != -1) player.setTeam(client.getTeams().get(teamId));
System.out.println(teamId);
player.getPosition().set(in.readFloat(), in.readFloat(), in.readFloat());
player.getVelocity().set(in.readFloat(), in.readFloat(), in.readFloat());
player.getOrientation().set(in.readFloat(), in.readFloat());
player.setCrouching(in.readBoolean());
player.setHeldItemId(in.readInt());
client.getPlayers().put(player.getId(), player);
}
// Read inventory data.
int itemStackCount = in.readInt();
var inv = client.getMyPlayer().getInventory();
for (int i = 0; i < itemStackCount; i++) {
inv.getItemStacks().add(ItemStack.read(in));
}
inv.setSelectedIndex(in.readInt());
// Read our own player data.
int teamId = in.readInt();
if (teamId != -1) client.getMyPlayer().setTeam(client.getTeams().get(teamId));
client.getMyPlayer().getPosition().set(in.readFloat(), in.readFloat(), in.readFloat());
}
}

View File

@ -23,7 +23,7 @@ public class InputHandler {
public void updateInputState(long window) {
// TODO: Allow customized keybindings.
int selectedInventoryIndex;
selectedInventoryIndex = client.getPlayer().getInventory().getSelectedIndex();
selectedInventoryIndex = client.getMyPlayer().getInventory().getSelectedIndex();
if (glfwGetKey(window, GLFW_KEY_1) == GLFW_PRESS) selectedInventoryIndex = 0;
if (glfwGetKey(window, GLFW_KEY_2) == GLFW_PRESS) selectedInventoryIndex = 1;
if (glfwGetKey(window, GLFW_KEY_3) == GLFW_PRESS) selectedInventoryIndex = 2;

View File

@ -48,17 +48,17 @@ public class PlayerViewCursorCallback implements GLFWCursorPosCallbackI {
float dy = y - lastMouseCursorY;
lastMouseCursorX = x;
lastMouseCursorY = y;
client.getPlayer().setOrientation(
client.getPlayer().getOrientation().x - dx * config.mouseSensitivity,
client.getPlayer().getOrientation().y - dy * config.mouseSensitivity
client.getMyPlayer().setOrientation(
client.getMyPlayer().getOrientation().x - dx * config.mouseSensitivity,
client.getMyPlayer().getOrientation().y - dy * config.mouseSensitivity
);
camera.setOrientationToPlayer(client.getPlayer());
camera.setOrientationToPlayer(client.getMyPlayer());
long now = System.currentTimeMillis();
if (lastOrientationUpdateSentAt + ORIENTATION_UPDATE_LIMIT < now) {
ForkJoinPool.commonPool().submit(() -> comm.sendDatagramPacket(new ClientOrientationState(
client.getPlayer().getId(),
client.getPlayer().getOrientation().x,
client.getPlayer().getOrientation().y
client.getMyPlayer().getId(),
client.getMyPlayer().getOrientation().x,
client.getMyPlayer().getOrientation().y
)));
lastOrientationUpdateSentAt = now;
}

View File

@ -33,7 +33,7 @@ public class ClientPlayer extends Player {
.translate(cam.getPosition())
.rotate((float) (cam.getOrientation().x + Math.PI), Camera.UP)
.rotate(-cam.getOrientation().y + (float) Math.PI / 2, Camera.RIGHT)
.translate(-0.35f, -0.4f, 1f);
.translate(-0.35f, -0.4f, 0.5f);
heldItemTransform.get(heldItemTransformData);
}

View File

@ -1,9 +1,8 @@
package nl.andrewl.aos2_client.render;
import nl.andrewl.aos2_client.Camera;
import nl.andrewl.aos2_client.ClientWorld;
import nl.andrewl.aos2_client.Client;
import nl.andrewl.aos2_client.config.ClientConfig;
import nl.andrewl.aos2_client.model.ClientPlayer;
import nl.andrewl.aos2_client.render.chunk.ChunkRenderer;
import nl.andrewl.aos2_client.render.gui.GUIRenderer;
import nl.andrewl.aos2_client.render.gui.GUITexture;
@ -14,7 +13,6 @@ import org.joml.Matrix4f;
import org.joml.Vector3f;
import org.lwjgl.glfw.*;
import org.lwjgl.opengl.GL;
import org.lwjgl.opengl.GLUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -39,8 +37,7 @@ public class GameRenderer {
private GUIRenderer guiRenderer;
private ModelRenderer modelRenderer;
private final Camera camera;
private final ClientPlayer clientPlayer;
private final ClientWorld world;
private final Client client;
// Standard models for various game components.
private Model playerModel;
@ -53,12 +50,11 @@ public class GameRenderer {
private final Matrix4f perspectiveTransform;
public GameRenderer(ClientConfig.DisplayConfig config, ClientPlayer clientPlayer, ClientWorld world) {
public GameRenderer(ClientConfig.DisplayConfig config, Client client) {
this.config = config;
this.clientPlayer = clientPlayer;
this.world = world;
this.client = client;
this.camera = new Camera();
camera.setToPlayer(clientPlayer);
camera.setToPlayer(client.getMyPlayer());
this.perspectiveTransform = new Matrix4f();
}
@ -76,7 +72,7 @@ public class GameRenderer {
long monitorId = glfwGetPrimaryMonitor();
GLFWVidMode primaryMonitorSettings = glfwGetVideoMode(monitorId);
if (primaryMonitorSettings == null) throw new IllegalStateException("Could not get information about the primary monitory.");
log.debug("Primary monitor settings: Width: {}, Height: {}", primaryMonitorSettings.width(), primaryMonitorSettings.height());
log.debug("Primary monitor settings: Width: {}, Height: {}, FOV: {}", primaryMonitorSettings.width(), primaryMonitorSettings.height(), config.fov);
if (config.fullscreen) {
screenWidth = primaryMonitorSettings.width();
screenHeight = primaryMonitorSettings.height();
@ -145,7 +141,13 @@ public class GameRenderer {
* Updates the rendering perspective used to render the game.
*/
private void updatePerspective() {
perspectiveTransform.setPerspective(config.fov, getAspectRatio(), Z_NEAR, Z_FAR);
float fovRad = (float) Math.toRadians(config.fov);
if (fovRad >= Math.PI) {
fovRad = (float) (Math.PI - 0.01f);
} else if (fovRad <= 0) {
fovRad = 0.01f;
}
perspectiveTransform.setPerspective(fovRad, getAspectRatio(), Z_NEAR, Z_FAR);
float[] data = new float[16];
perspectiveTransform.get(data);
if (chunkRenderer != null) chunkRenderer.setPerspective(data);
@ -166,36 +168,42 @@ public class GameRenderer {
public void draw() {
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
chunkRenderer.draw(camera, world.getChunkMeshesToDraw());
chunkRenderer.draw(camera, client.getWorld().getChunkMeshesToDraw());
// Draw models. Use one texture at a time for efficiency.
modelRenderer.start(camera.getViewTransformData());
clientPlayer.updateHeldItemTransform(camera);
client.getMyPlayer().updateHeldItemTransform(camera);
playerModel.bind();
for (var player : world.getPlayers()) {
// TODO: set aspect color based on player team color
modelRenderer.setAspectColor(new Vector3f(0.8f, 0.4f, 0));
for (var player : client.getPlayers().values()) {
if (player.getTeam() != null) {
modelRenderer.setAspectColor(player.getTeam().getColor());
} else {
modelRenderer.setAspectColor(new Vector3f(0.3f, 0.3f, 0.3f));
}
modelRenderer.render(playerModel, player.getModelTransformData());
}
playerModel.unbind();
rifleModel.bind();
if (clientPlayer.getInventory().getSelectedItemStack().getType().getId() == ItemTypes.RIFLE.getId()) {
modelRenderer.render(rifleModel, clientPlayer.getHeldItemTransformData());
if (client.getMyPlayer().getInventory().getSelectedItemStack().getType().getId() == ItemTypes.RIFLE.getId()) {
modelRenderer.render(rifleModel, client.getMyPlayer().getHeldItemTransformData());
}
for (var player : world.getPlayers()) {
for (var player : client.getPlayers().values()) {
if (player.getHeldItemId() == ItemTypes.RIFLE.getId()) {
modelRenderer.render(rifleModel, player.getHeldItemTransformData());
}
}
rifleModel.unbind();
blockModel.bind();
if (clientPlayer.getInventory().getSelectedItemStack().getType().getId() == ItemTypes.BLOCK.getId()) {
BlockItemStack stack = (BlockItemStack) clientPlayer.getInventory().getSelectedItemStack();
modelRenderer.setAspectColor(world.getPalette().getColor(stack.getSelectedValue()));
modelRenderer.render(blockModel, clientPlayer.getHeldItemTransformData());
if (client.getMyPlayer().getInventory().getSelectedItemStack().getType().getId() == ItemTypes.BLOCK.getId()) {
BlockItemStack stack = (BlockItemStack) client.getMyPlayer().getInventory().getSelectedItemStack();
modelRenderer.setAspectColor(client.getWorld().getPalette().getColor(stack.getSelectedValue()));
modelRenderer.render(blockModel, client.getMyPlayer().getHeldItemTransformData());
}
modelRenderer.setAspectColor(new Vector3f(0.5f, 0.5f, 0.5f));
for (var player : world.getPlayers()) {
for (var player : client.getPlayers().values()) {
if (player.getHeldItemId() == ItemTypes.BLOCK.getId()) {
modelRenderer.render(blockModel, player.getHeldItemTransformData());
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 45 KiB

View File

@ -8,7 +8,6 @@ import nl.andrewl.aos_core.net.connect.DatagramInit;
import nl.andrewl.aos_core.net.world.ChunkDataMessage;
import nl.andrewl.aos_core.net.world.ChunkHashMessage;
import nl.andrewl.aos_core.net.world.ChunkUpdateMessage;
import nl.andrewl.aos_core.net.world.WorldInfoMessage;
import nl.andrewl.record_net.Message;
import nl.andrewl.record_net.Serializer;
import nl.andrewl.record_net.util.ExtendedDataInputStream;
@ -39,11 +38,10 @@ public final class Net {
serializer.registerType(10, PlayerUpdateMessage.class);
serializer.registerType(11, PlayerJoinMessage.class);
serializer.registerType(12, PlayerLeaveMessage.class);
serializer.registerType(13, WorldInfoMessage.class);
// Separate serializers for client inventory messages.
serializer.registerTypeSerializer(14, new InventorySerializer());
serializer.registerTypeSerializer(15, new ItemStackSerializer());
serializer.registerType(16, InventorySelectedStackMessage.class);
serializer.registerTypeSerializer(13, new InventorySerializer());
serializer.registerTypeSerializer(14, new ItemStackSerializer());
serializer.registerType(15, InventorySelectedStackMessage.class);
}
public static ExtendedDataInputStream getInputStream(InputStream in) {

View File

@ -65,13 +65,23 @@ public class Player {
*/
protected final int id;
public Player(int id, String username) {
/**
* The team that this player belongs to. This might be null.
*/
protected Team team;
public Player(int id, String username, Team team) {
this.position = new Vector3f();
this.velocity = new Vector3f();
this.orientation = new Vector2f();
this.viewVector = new Vector3f();
this.id = id;
this.username = username;
this.team = team;
}
public Player(int id, String username) {
this(id, username, null);
}
public Vector3f getPosition() {
@ -116,6 +126,14 @@ public class Player {
return id;
}
public Team getTeam() {
return team;
}
public void setTeam(Team team) {
this.team = team;
}
public Vector3f getViewVector() {
return viewVector;
}

View File

@ -0,0 +1,13 @@
package nl.andrewl.aos_core.model;
/**
* Represents a group within a team that players can create and join to work
* together on some tasks.
*/
public class Squad {
private final int id;
public Squad(int id) {
this.id = id;
}
}

View File

@ -4,7 +4,8 @@ import org.joml.Vector3f;
/**
* A team is a group of players in a world that should work together to
* achieve some goal.
* achieve some goal. Teams belong to a server directly, and persist even if
* the world is changed.
*/
public class Team {
/**
@ -23,10 +24,16 @@ public class Team {
*/
private final Vector3f color;
public Team(int id, String name, Vector3f color) {
/**
* The team's spawn point, in the current world.
*/
private final Vector3f spawnPoint;
public Team(int id, String name, Vector3f color, Vector3f spawnPoint) {
this.id = id;
this.name = name;
this.color = color;
this.spawnPoint = spawnPoint;
}
public int getId() {
@ -40,4 +47,12 @@ public class Team {
public Vector3f getColor() {
return color;
}
public Vector3f getSpawnPoint() {
return spawnPoint;
}
public void setSpawnPoint(Vector3f p) {
spawnPoint.set(p);
}
}

View File

@ -21,6 +21,7 @@ public class World {
protected final Map<Vector3ic, Chunk> chunkMap = new HashMap<>();
protected ColorPalette palette;
protected final Map<String, Vector3f> spawnPoints = new HashMap<>();
public World(ColorPalette palette, Collection<Chunk> chunks) {
this.palette = palette;
@ -91,6 +92,30 @@ public class World {
return chunkMap.get(new Vector3i(x, y, z));
}
public Vector3f getSpawnPoint(String name) {
return spawnPoints.get(name);
}
public void setSpawnPoint(String name, Vector3f location) {
spawnPoints.put(name, location);
}
public void removeSpawnPoint(String name) {
spawnPoints.remove(name);
}
public Map<String, Vector3f> getSpawnPoints() {
return Collections.unmodifiableMap(spawnPoints);
}
/**
* Clears all data from the world.
*/
public void clear() {
chunkMap.clear();
spawnPoints.clear();
}
/**
* Gets the position that a system is looking at, within a distance limit.
* Usually used to determine where a player has interacted/clicked in the

View File

@ -1,51 +1,80 @@
package nl.andrewl.aos_core.model.world;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.joml.Vector3f;
import java.io.*;
import java.util.Map;
/**
* Utility class for reading and writing worlds to files.
*/
public final class WorldIO {
public static void write(World world, Path path) throws IOException {
/**
* Writes a world to an output stream.
* @param world The world to write.
* @param out The output stream to write to.
* @throws IOException If an exception occurs.
*/
public static void write(World world, OutputStream out) throws IOException {
var d = new DataOutputStream(out);
// Write color palette.
for (var v : world.getPalette().toArray()) {
d.writeFloat(v);
}
// Write spawn points.
var spawnPoints = world.getSpawnPoints();
d.writeInt(spawnPoints.size());
var sortedEntries = spawnPoints.entrySet().stream()
.sorted(Map.Entry.comparingByKey()).toList();
for (var entry : sortedEntries) {
d.writeUTF(entry.getKey());
d.writeFloat(entry.getValue().x());
d.writeFloat(entry.getValue().y());
d.writeFloat(entry.getValue().z());
}
// Write chunks.
var chunks = world.getChunkMap().values();
try (var os = Files.newOutputStream(path)) {
var out = new DataOutputStream(os);
for (var v : world.getPalette().toArray()) {
out.writeFloat(v);
}
out.writeInt(chunks.size());
for (var chunk : chunks) {
out.writeInt(chunk.getPosition().x);
out.writeInt(chunk.getPosition().y);
out.writeInt(chunk.getPosition().z);
out.write(chunk.getBlocks());
}
d.writeInt(chunks.size());
for (var chunk : chunks) {
d.writeInt(chunk.getPosition().x);
d.writeInt(chunk.getPosition().y);
d.writeInt(chunk.getPosition().z);
d.write(chunk.getBlocks());
}
}
public static World read(Path path) throws IOException {
/**
* Reads a world from an input stream.
* @param in The input stream to read from.
* @return The world which was read.
* @throws IOException If an exception occurs.
*/
public static World read(InputStream in) throws IOException {
World world = new World();
try (var is = Files.newInputStream(path)) {
var in = new DataInputStream(is);
ColorPalette palette = new ColorPalette();
for (int i = 0; i < ColorPalette.MAX_COLORS; i++) {
palette.setColor((byte) (i + 1), in.readFloat(), in.readFloat(), in.readFloat());
}
world.setPalette(palette);
int chunkCount = in.readInt();
for (int i = 0; i < chunkCount; i++) {
Chunk chunk = new Chunk(
in.readInt(),
in.readInt(),
in.readInt(),
in.readNBytes(Chunk.TOTAL_SIZE)
);
world.addChunk(chunk);
}
var d = new DataInputStream(in);
// Read color palette.
ColorPalette palette = new ColorPalette();
for (int i = 0; i < ColorPalette.MAX_COLORS; i++) {
palette.setColor((byte) (i + 1), d.readFloat(), d.readFloat(), d.readFloat());
}
world.setPalette(palette);
// Read spawn points.
int spawnPointCount = d.readInt();
for (int i = 0; i < spawnPointCount; i++) {
String name = d.readUTF();
Vector3f location = new Vector3f(d.readFloat(), d.readFloat(), d.readFloat());
world.setSpawnPoint(name, location);
}
// Read chunks.
int chunkCount = d.readInt();
for (int i = 0; i < chunkCount; i++) {
Chunk chunk = new Chunk(
d.readInt(),
d.readInt(),
d.readInt(),
d.readNBytes(Chunk.TOTAL_SIZE)
);
world.addChunk(chunk);
}
return world;
}

View File

@ -1,5 +1,6 @@
package nl.andrewl.aos_core.model.world;
import org.joml.Vector3f;
import org.joml.Vector3i;
import java.util.Collections;
@ -128,6 +129,8 @@ public final class Worlds {
}
}
world.setSpawnPoint("first", new Vector3f(0.5f, 0f, 0.5f));
return world;
}
}

View File

@ -9,6 +9,10 @@ import java.io.IOException;
import java.net.SocketException;
import java.util.function.Consumer;
/**
* A generic runnable that's meant to be run in its own thread, and handle
* receiving messages from a TCP socket.
*/
public class TcpReceiver implements Runnable {
private final ExtendedDataInputStream in;
private final Consumer<Message> messageConsumer;

View File

@ -9,7 +9,7 @@ import nl.andrewl.record_net.Message;
* joins, so that they can add that player to their world.
*/
public record PlayerJoinMessage(
int id, String username,
int id, String username, int teamId,
float px, float py, float pz,
float vx, float vy, float vz,
float ox, float oy,
@ -18,7 +18,7 @@ public record PlayerJoinMessage(
) implements Message {
public PlayerJoinMessage(Player player) {
this(
player.getId(), player.getUsername(),
player.getId(), player.getUsername(), player.getTeam() == null ? -1 : player.getTeam().getId(),
player.getPosition().x, player.getPosition().y, player.getPosition().z,
player.getVelocity().x, player.getVelocity().y, player.getVelocity().z,
player.getOrientation().x, player.getOrientation().y,

View File

@ -5,10 +5,13 @@ import nl.andrewl.record_net.Message;
/**
* This message is sent by the server to clients whenever a player has updated
* in some way, like movement or orientation or held items.
* in some way, like movement or orientation or held items. This is generally
* the thing that clients receive rapidly on each game tick, when players are
* moving.
*/
public record PlayerUpdateMessage(
int clientId,
long timestamp,
float px, float py, float pz,
float vx, float vy, float vz,
float ox, float oy,

View File

@ -1,16 +0,0 @@
package nl.andrewl.aos_core.net.world;
import nl.andrewl.aos_core.model.world.World;
import nl.andrewl.record_net.Message;
/**
* Message that the server sends to connecting clients with some metadata about
* the world.
*/
public record WorldInfoMessage(
float[] palette
) implements Message {
public WorldInfoMessage(World world) {
this(world.getPalette().toArray());
}
}

View File

@ -12,6 +12,15 @@ This workflow is involved in the establishment of a connection between the clien
2. The server will respond with either a `ConnectRejectMessage` with a `reason` for the rejection, or a `ConnectAcceptMessage` containing the client's `clientId`.
3. If the player received an acceptance message, they will then send a `DatagramInit` to the server's UDP socket (on the same address/port) containing the `clientId` received in the `ConnectAcceptMessage`. The player should keep sending such an init message until they receive a `DatagramInit` message echoed back as a response. The player should then stop sending init messages, and expect to begin receiving normal communication data through the datagram socket.
If the client's connection is accepted, then the server will need to begin sending all the initial data that's needed for the client to be able to start rendering the game. This includes the following data:
- The world's color palette.
- The world's chunks.
- All teams on the server (including squads).
- Each team's spawn point.
- All players that are already connected to the server.
- The client's own player inventory.
- The client's own player position.
### World Data
A combination of TCP and UDP communication is used to ensure that all connected clients have the latest information about the state of the world.

View File

@ -1,16 +1,15 @@
package nl.andrewl.aos2_server;
import nl.andrewl.aos_core.Net;
import nl.andrewl.aos_core.model.item.ItemStack;
import nl.andrewl.aos_core.model.world.Chunk;
import nl.andrewl.aos_core.net.*;
import nl.andrewl.aos_core.net.client.PlayerJoinMessage;
import nl.andrewl.aos_core.model.world.WorldIO;
import nl.andrewl.aos_core.net.TcpReceiver;
import nl.andrewl.aos_core.net.connect.ConnectAcceptMessage;
import nl.andrewl.aos_core.net.connect.ConnectRejectMessage;
import nl.andrewl.aos_core.net.connect.ConnectRequestMessage;
import nl.andrewl.aos_core.net.client.ClientInventoryMessage;
import nl.andrewl.aos_core.net.world.ChunkDataMessage;
import nl.andrewl.aos_core.net.world.ChunkHashMessage;
import nl.andrewl.aos_core.net.world.WorldInfoMessage;
import nl.andrewl.record_net.Message;
import nl.andrewl.record_net.util.ExtendedDataInputStream;
import nl.andrewl.record_net.util.ExtendedDataOutputStream;
@ -23,6 +22,7 @@ import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.Socket;
import java.util.LinkedList;
/**
* Component which manages the establishing and maintenance of a connection
@ -97,22 +97,8 @@ public class ClientCommunicationHandler {
this.player = server.getPlayerManager().register(this, connectMsg.username());
Net.write(new ConnectAcceptMessage(player.getId()), out);
log.debug("Sent connect accept message.");
sendTcpMessage(new WorldInfoMessage(server.getWorld()));
// Send player's inventory information.
sendTcpMessage(new ClientInventoryMessage(player.getInventory()));
// Send "join" info about all the players that are already connected, so the client is aware of them.
for (var player : server.getPlayerManager().getPlayers()) {
if (player.getId() != this.player.getId()) {
sendTcpMessage(new PlayerJoinMessage(player));
}
}
// Send chunk data.
for (var chunk : server.getWorld().getChunkMap().values()) {
sendTcpMessage(new ChunkDataMessage(chunk));
}
sendInitialData();
log.debug("Sent initial data.");
// Initiate a TCP receiver thread to accept incoming messages from the client.
TcpReceiver tcpReceiver = new TcpReceiver(in, this::handleTcpMessage)
.withShutdownHook(() -> server.getPlayerManager().deregister(this.player));
@ -166,4 +152,73 @@ public class ClientCommunicationHandler {
e.printStackTrace();
}
}
/**
* Dedicated method to send all initial data to the client when they first
* connect. We don't use record-net for this, but a custom stream writing
* operation to improve efficiency.
*/
private void sendInitialData() throws IOException {
// First world data. We send this in the same format that we'd use for files.
WorldIO.write(server.getWorld(), out);
// Team data.
var teams = server.getTeams().values();
out.writeInt(teams.size());
for (var team : teams) {
out.writeInt(team.getId());
out.writeString(team.getName());
out.writeFloat(team.getColor().x());
out.writeFloat(team.getColor().y());
out.writeFloat(team.getColor().z());
out.writeFloat(team.getSpawnPoint().x());
out.writeFloat(team.getSpawnPoint().y());
out.writeFloat(team.getSpawnPoint().z());
}
// Player data.
var otherPlayers = new LinkedList<>(server.getPlayerManager().getPlayers());
otherPlayers.remove(player);
out.writeInt(otherPlayers.size());
for (var player : otherPlayers) {
out.writeInt(player.getId());
out.writeString(player.getUsername());
if (player.getTeam() == null) {
out.writeInt(-1);
} else {
out.writeInt(player.getTeam().getId());
}
out.writeFloat(player.getPosition().x());
out.writeFloat(player.getPosition().y());
out.writeFloat(player.getPosition().z());
out.writeFloat(player.getVelocity().x());
out.writeFloat(player.getVelocity().y());
out.writeFloat(player.getVelocity().z());
out.writeFloat(player.getOrientation().x());
out.writeFloat(player.getOrientation().y());
out.writeBoolean(player.isCrouching());
out.writeInt(player.getInventory().getSelectedItemStack().getType().getId());
}
// Send the player's own inventory data.
out.writeInt(player.getInventory().getItemStacks().size());
for (var stack : player.getInventory().getItemStacks()) {
ItemStack.write(stack, out);
}
out.writeInt(player.getInventory().getSelectedIndex());
// Send the player's own player data.
if (player.getTeam() == null) {
out.writeInt(-1);
} else {
out.writeInt(player.getTeam().getId());
}
out.writeFloat(player.getPosition().x());
out.writeFloat(player.getPosition().y());
out.writeFloat(player.getPosition().z());
}
}

View File

@ -1,6 +1,7 @@
package nl.andrewl.aos2_server;
import nl.andrewl.aos_core.Net;
import nl.andrewl.aos_core.model.Team;
import nl.andrewl.aos_core.net.client.PlayerJoinMessage;
import nl.andrewl.aos_core.net.client.PlayerLeaveMessage;
import nl.andrewl.aos_core.net.connect.DatagramInit;
@ -15,23 +16,33 @@ import java.util.*;
/**
* This component is responsible for managing the set of players connected to
* the server.
* the server, and components related to that.
*/
public class PlayerManager {
private static final Logger log = LoggerFactory.getLogger(PlayerManager.class);
private final Server server;
private final Map<Integer, ServerPlayer> players = new HashMap<>();
private final Map<Integer, ClientCommunicationHandler> clientHandlers = new HashMap<>();
private int nextClientId = 1;
public PlayerManager(Server server) {
this.server = server;
}
public synchronized ServerPlayer register(ClientCommunicationHandler handler, String username) {
ServerPlayer player = new ServerPlayer(nextClientId++, username);
log.info("Registered player \"{}\" with id {}", player.getUsername(), player.getId());
players.put(player.getId(), player);
clientHandlers.put(player.getId(), handler);
log.info("Registered player \"{}\" with id {}", player.getUsername(), player.getId());
player.setPosition(new Vector3f(0, 64, 0));
Team team = findBestTeamForNewPlayer();
if (team != null) {
player.setTeam(team);
log.info("Player \"{}\" joined the \"{}\" team.", player.getUsername(), team.getName());
}
player.setPosition(getBestSpawnPoint(player));
// Tell all other players that this one has joined.
broadcastTcpMessageToAllBut(new PlayerJoinMessage(player), player);
broadcastUdpMessage(player.getUpdateMessage());
return player;
}
@ -67,6 +78,39 @@ public class PlayerManager {
return Collections.unmodifiableCollection(clientHandlers.values());
}
/**
* Finds the team that's best suited for adding a new player. This is the
* team that has the minimum (or tied for minimum) number of players.
* @return The best team to add a player to.
*/
private Team findBestTeamForNewPlayer() {
Team minTeam = null;
int minCount = Integer.MAX_VALUE;
for (var team : server.getTeams().values()) {
int playerCount = (int) players.values().stream()
.filter(p -> Objects.equals(p.getTeam(), team))
.count();
if (playerCount < minCount) {
minCount = playerCount;
minTeam = team;
}
}
return minTeam;
}
/**
* Determines the best location to spawn the given player at. This is
* usually the player's team spawn point, if they have a team. Otherwise, a
* spawn point is randomly chosen from the world. If no spawnpoint exists in
* the world, we resort to 0, 0, 0 as the last option.
* @param player The player to spawn.
* @return The best location to spawn the player at.
*/
private Vector3f getBestSpawnPoint(ServerPlayer player) {
if (player.getTeam() != null) return player.getTeam().getSpawnPoint();
return server.getWorld().getSpawnPoints().values().stream().findAny().orElse(new Vector3f(0, 0, 0));
}
public void handleUdpInit(DatagramInit init, DatagramPacket packet) {
var handler = getHandler(init.clientId());
if (handler != null) {

View File

@ -3,6 +3,7 @@ package nl.andrewl.aos2_server;
import nl.andrewl.aos2_server.config.ServerConfig;
import nl.andrewl.aos2_server.logic.WorldUpdater;
import nl.andrewl.aos_core.config.Config;
import nl.andrewl.aos_core.model.Team;
import nl.andrewl.aos_core.model.world.World;
import nl.andrewl.aos_core.model.world.Worlds;
import nl.andrewl.aos_core.net.UdpReceiver;
@ -10,13 +11,14 @@ import nl.andrewl.aos_core.net.client.ClientInputState;
import nl.andrewl.aos_core.net.client.ClientOrientationState;
import nl.andrewl.aos_core.net.connect.DatagramInit;
import nl.andrewl.record_net.Message;
import org.joml.Vector3f;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.*;
import java.nio.file.Path;
import java.util.List;
import java.util.*;
import java.util.concurrent.ForkJoinPool;
public class Server implements Runnable {
@ -27,6 +29,8 @@ public class Server implements Runnable {
private volatile boolean running;
private final ServerConfig config;
private final PlayerManager playerManager;
private final Map<Integer, Team> teams;
private final Map<Team, String> teamSpawnPoints;
private final World world;
private final WorldUpdater worldUpdater;
@ -36,9 +40,14 @@ public class Server implements Runnable {
this.serverSocket.setReuseAddress(true);
this.datagramSocket = new DatagramSocket(config.port);
this.datagramSocket.setReuseAddress(true);
this.playerManager = new PlayerManager();
this.playerManager = new PlayerManager(this);
this.worldUpdater = new WorldUpdater(this, config.ticksPerSecond);
this.world = Worlds.testingWorld();
this.teams = new HashMap<>();
this.teamSpawnPoints = new HashMap<>();
// TODO: Add some way to configure teams with config files.
teams.put(1, new Team(1, "Red", new Vector3f(0.8f, 0, 0), world.getSpawnPoint("first")));
teams.put(2, new Team(2, "Blue", new Vector3f(0, 0, 0.8f), world.getSpawnPoint("first")));
}
@Override
@ -61,20 +70,21 @@ public class Server implements Runnable {
}
public void handleUdpMessage(Message msg, DatagramPacket packet) {
long now = System.currentTimeMillis();
if (msg instanceof DatagramInit init) {
playerManager.handleUdpInit(init, packet);
} else if (msg instanceof ClientInputState inputState) {
ServerPlayer player = playerManager.getPlayer(inputState.clientId());
if (player != null) {
if (player.getActionManager().setLastInputState(inputState)) {
playerManager.broadcastUdpMessage(player.getUpdateMessage());
playerManager.broadcastUdpMessage(player.getUpdateMessage(now));
}
}
} else if (msg instanceof ClientOrientationState orientationState) {
ServerPlayer player = playerManager.getPlayer(orientationState.clientId());
if (player != null) {
player.setOrientation(orientationState.x(), orientationState.y());
playerManager.broadcastUdpMessageToAllBut(player.getUpdateMessage(), player);
playerManager.broadcastUdpMessageToAllBut(player.getUpdateMessage(now), player);
}
}
}
@ -111,6 +121,18 @@ public class Server implements Runnable {
return playerManager;
}
public Map<Integer, Team> getTeams() {
return teams;
}
public String getSpawnPoint(Team team) {
return teamSpawnPoints.get(team);
}
public Collection<ServerPlayer> getPlayersInTeam(Team team) {
return playerManager.getPlayers().stream().filter(p -> Objects.equals(p.getTeam(), team)).toList();
}
public static void main(String[] args) throws IOException {
List<Path> configPaths = Config.getCommonConfigPaths();
if (args.length > 0) {

View File

@ -43,9 +43,9 @@ public class ServerPlayer extends Player {
* various clients.
* @return The update message.
*/
public PlayerUpdateMessage getUpdateMessage() {
public PlayerUpdateMessage getUpdateMessage(long timestamp) {
return new PlayerUpdateMessage(
id,
id, timestamp,
position.x, position.y, position.z,
velocity.x, velocity.y, velocity.z,
orientation.x, orientation.y,

View File

@ -35,7 +35,7 @@ public class WorldUpdater implements Runnable {
running = true;
while (running) {
long start = System.nanoTime();
tick();
tick(System.currentTimeMillis());
long elapsedNs = System.nanoTime() - start;
if (elapsedNs > nsPerTick) {
log.warn("Took {} ns to do one tick, which is more than the desired {} ns per tick.", elapsedNs, nsPerTick);
@ -52,10 +52,12 @@ public class WorldUpdater implements Runnable {
}
}
private void tick() {
private void tick(long currentTimeMillis) {
for (var player : server.getPlayerManager().getPlayers()) {
player.getActionManager().tick(secondsPerTick, server.getWorld(), server);
if (player.getActionManager().isUpdated()) server.getPlayerManager().broadcastUdpMessage(player.getUpdateMessage());
if (player.getActionManager().isUpdated()) {
server.getPlayerManager().broadcastUdpMessage(player.getUpdateMessage(currentTimeMillis));
}
}
}
}