Мир: остров, дороги-дамбы, биомы; LOD ступенями; консоль сервера; баны; постобработка
This commit is contained in:
@@ -66,9 +66,9 @@ public final class ClientSession implements Runnable {
|
||||
/** The identity of the player, as the launcher gave it. */
|
||||
private volatile String uuid = "";
|
||||
|
||||
/** The regions of the far view that were sent already. */
|
||||
private final java.util.Set<Integer> sentLodRegions =
|
||||
new java.util.LinkedHashSet<Integer>();
|
||||
/** The regions of the far view that were sent already, with their step. */
|
||||
private final java.util.Set<Long> sentLodRegions =
|
||||
new java.util.LinkedHashSet<Long>();
|
||||
|
||||
/** When the session runs out. */
|
||||
private volatile long sessionExpiresAt;
|
||||
@@ -386,6 +386,13 @@ public final class ClientSession implements Runnable {
|
||||
if (canonical == null) {
|
||||
canonical = claims.nick;
|
||||
}
|
||||
if (this.server.bans().isBanned(canonical)) {
|
||||
this.server.log("token of " + canonical + " refused: the account is banned");
|
||||
send(Packets.AUTH_ACK, Packets.AuthAck.refused(
|
||||
"the session was refused: the account is banned"));
|
||||
close();
|
||||
return;
|
||||
}
|
||||
this.server.throttle().succeeded(given.account);
|
||||
this.account = canonical;
|
||||
this.uuid = claims.uuid == null ? "" : claims.uuid;
|
||||
@@ -503,8 +510,12 @@ public final class ClientSession implements Runnable {
|
||||
/**
|
||||
* Sends the far view around the player.
|
||||
*
|
||||
* The far view is a grid of heights and colours, one region at a time. A
|
||||
* region is only sent once: the client keeps what it got.
|
||||
* The far view is a grid of heights and colours, one region at a time, and
|
||||
* the regions near the player are finer than the ones far away: a region
|
||||
* comes with every fourth block up to two regions away, then every
|
||||
* eighth, sixteenth, thirty-second and, at the horizon, every
|
||||
* sixty-fourth. The whole thing reaches further than any player can see, a
|
||||
* ring of regions at a time, and the client keeps the last of each.
|
||||
*/
|
||||
void sendLod() {
|
||||
if (this.player == null) {
|
||||
@@ -514,23 +525,41 @@ public final class ClientSession implements Runnable {
|
||||
int chunkZ = (int) Math.floor(this.player.z() / 16.0D);
|
||||
int regionX = net.rustnotmc.server.world.LodGenerator.regionChunk(chunkX);
|
||||
int regionZ = net.rustnotmc.server.world.LodGenerator.regionChunk(chunkZ);
|
||||
for (int dx = -2; dx <= 2; dx++) {
|
||||
for (int dz = -2; dz <= 2; dz++) {
|
||||
for (int dx = -LOD_RINGS; dx <= LOD_RINGS; dx++) {
|
||||
for (int dz = -LOD_RINGS; dz <= LOD_RINGS; dz++) {
|
||||
int rx = regionX + dx * net.rustnotmc.server.world.LodGenerator.REGION_CHUNKS;
|
||||
int rz = regionZ + dz * net.rustnotmc.server.world.LodGenerator.REGION_CHUNKS;
|
||||
Integer key = Integer.valueOf(rx * 100003 + rz);
|
||||
int ring = Math.max(Math.abs(dx), Math.abs(dz));
|
||||
int step = lodStep(ring);
|
||||
Long key = Long.valueOf(((rx & 0xFFFFL) << 48) | ((rz & 0xFFFFL) << 32)
|
||||
| (step & 0xFFFFL));
|
||||
if (this.sentLodRegions.contains(key)) {
|
||||
continue;
|
||||
}
|
||||
this.sentLodRegions.add(key);
|
||||
if (this.sentLodRegions.size() > 128) {
|
||||
if (this.sentLodRegions.size() > 1024) {
|
||||
this.sentLodRegions.clear();
|
||||
}
|
||||
send(Packets.LOD_REGION, this.server.lod().region(rx, rz));
|
||||
send(Packets.LOD_REGION, this.server.lod().region(rx, rz, step));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How many regions there are from the player out to the horizon. */
|
||||
private static final int LOD_RINGS = 6;
|
||||
|
||||
/** How far apart the samples of a ring are. */
|
||||
static int lodStep(int ring) {
|
||||
int step = net.rustnotmc.server.world.LodGenerator.STEP;
|
||||
for (int r = ring; r > 1; r--) {
|
||||
if (step >= 64) {
|
||||
break;
|
||||
}
|
||||
step *= 2;
|
||||
}
|
||||
return step;
|
||||
}
|
||||
|
||||
private void onAuth(Frame frame) {
|
||||
if (this.state != SessionState.HANDSHAKING) {
|
||||
return;
|
||||
@@ -578,6 +607,13 @@ public final class ClientSession implements Runnable {
|
||||
}
|
||||
|
||||
this.server.throttle().succeeded(auth.account);
|
||||
if (this.server.bans().isBanned(name)) {
|
||||
this.server.log("auth of " + name + " refused: the account is banned");
|
||||
send(Packets.AUTH_ACK, Packets.AuthAck.refused(
|
||||
"the session was refused: the account is banned"));
|
||||
close();
|
||||
return;
|
||||
}
|
||||
net.rustnotmc.auth.TokenService.Token issued = this.server.tokens().issue(name);
|
||||
this.account = name;
|
||||
this.token = issued.value;
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package net.rustnotmc.server;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
|
||||
/**
|
||||
* The console of the server.
|
||||
*
|
||||
* A server is not just a socket that answers; it is a machine somebody is
|
||||
* sitting at, and the person sitting at it needs to say things: who may do
|
||||
* what, who may not come in, and when to stop. Typing a line is the plainest
|
||||
* way to say it.
|
||||
*
|
||||
* The lines are the same ones the rest of the server uses, so a console
|
||||
* command and a change to a file agree with each other: the rights live in
|
||||
* files, the console only writes to the files, and what the file says is what
|
||||
* the world obeys.
|
||||
*/
|
||||
public final class ConsoleCommands implements Runnable {
|
||||
|
||||
private final Server server;
|
||||
|
||||
public ConsoleCommands(Server server) {
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(System.in));
|
||||
while (this.server.running()) {
|
||||
String line;
|
||||
try {
|
||||
line = reader.readLine();
|
||||
} catch (IOException closed) {
|
||||
// the console is gone; there is nobody left to talk to
|
||||
return;
|
||||
}
|
||||
if (line == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
command(line.trim());
|
||||
} catch (RuntimeException e) {
|
||||
System.out.println("[rsx] console: " + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Carries out one line the console was given. */
|
||||
void command(String line) {
|
||||
if (line.isEmpty() || line.startsWith("#")) {
|
||||
return;
|
||||
}
|
||||
String[] words = line.split("\\s+", 2);
|
||||
String command = words[0];
|
||||
String rest = words.length > 1 ? words[1].trim() : "";
|
||||
// a slash is what a player types; the console takes either
|
||||
if (command.startsWith("/")) {
|
||||
command = command.substring(1);
|
||||
}
|
||||
|
||||
switch (command.toLowerCase()) {
|
||||
case "help":
|
||||
System.out.println("[rsx] console commands:");
|
||||
System.out.println(" list who is here");
|
||||
System.out.println(" say <text> say something to everybody");
|
||||
System.out.println(" op <name> make somebody an administrator");
|
||||
System.out.println(" deop <name> take the right back");
|
||||
System.out.println(" kick <name> send somebody home");
|
||||
System.out.println(" ban <name> keep an account out for good");
|
||||
System.out.println(" unban <name> let an account come back");
|
||||
System.out.println(" save save accounts and players");
|
||||
System.out.println(" stop save everything and stop");
|
||||
break;
|
||||
case "list":
|
||||
list();
|
||||
break;
|
||||
case "say":
|
||||
if (rest.isEmpty()) {
|
||||
System.out.println("[rsx] say what?");
|
||||
break;
|
||||
}
|
||||
this.server.broadcastChat("Server", rest);
|
||||
System.out.println("[rsx] said to everybody: " + rest);
|
||||
break;
|
||||
case "op":
|
||||
if (rest.isEmpty()) {
|
||||
System.out.println("[rsx] op who?");
|
||||
break;
|
||||
}
|
||||
this.server.admins().give(rest);
|
||||
System.out.println("[rsx] " + rest + " is an administrator now");
|
||||
break;
|
||||
case "deop":
|
||||
if (rest.isEmpty()) {
|
||||
System.out.println("[rsx] deop who?");
|
||||
break;
|
||||
}
|
||||
this.server.admins().take(rest);
|
||||
System.out.println("[rsx] " + rest + " is not an administrator anymore");
|
||||
break;
|
||||
case "kick": {
|
||||
if (rest.isEmpty()) {
|
||||
System.out.println("[rsx] kick who?");
|
||||
break;
|
||||
}
|
||||
Player player = findOnline(rest);
|
||||
if (player == null) {
|
||||
System.out.println("[rsx] " + rest + " is not here");
|
||||
break;
|
||||
}
|
||||
player.session().close();
|
||||
System.out.println("[rsx] " + rest + " was sent home");
|
||||
break;
|
||||
}
|
||||
case "ban":
|
||||
if (rest.isEmpty()) {
|
||||
System.out.println("[rsx] ban who?");
|
||||
break;
|
||||
}
|
||||
this.server.bans().ban(rest);
|
||||
// an account that is here now does not stay
|
||||
for (Player player : this.server.players().values()) {
|
||||
if (player.account().equalsIgnoreCase(rest)) {
|
||||
player.session().close();
|
||||
}
|
||||
}
|
||||
System.out.println("[rsx] " + rest + " is banned");
|
||||
break;
|
||||
case "unban":
|
||||
if (rest.isEmpty()) {
|
||||
System.out.println("[rsx] unban who?");
|
||||
break;
|
||||
}
|
||||
this.server.bans().unban(rest);
|
||||
System.out.println("[rsx] " + rest + " may come back");
|
||||
break;
|
||||
case "save":
|
||||
saveEverything();
|
||||
System.out.println("[rsx] saved");
|
||||
break;
|
||||
case "stop":
|
||||
System.out.println("[rsx] stopping");
|
||||
saveEverything();
|
||||
this.server.stop();
|
||||
System.exit(0);
|
||||
break;
|
||||
default:
|
||||
System.out.println("[rsx] unknown console command: " + command);
|
||||
}
|
||||
}
|
||||
|
||||
/** Says who is in the world. */
|
||||
private void list() {
|
||||
java.util.Map<Integer, Player> players = this.server.players();
|
||||
if (players.isEmpty()) {
|
||||
System.out.println("[rsx] nobody is in the world");
|
||||
return;
|
||||
}
|
||||
for (Player player : players.values()) {
|
||||
System.out.println("[rsx] " + player.account()
|
||||
+ " at " + ((int) player.x()) + "," + ((int) player.y())
|
||||
+ "," + ((int) player.z()));
|
||||
}
|
||||
System.out.println("[rsx] " + players.size() + " online, "
|
||||
+ "sessions " + this.server.sessions().size());
|
||||
}
|
||||
|
||||
/** The player of an account, online right now, or null. */
|
||||
private Player findOnline(String account) {
|
||||
for (Player player : this.server.players().values()) {
|
||||
if (player.account().equalsIgnoreCase(account)) {
|
||||
return player;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Writes down what would be a shame to lose. */
|
||||
private void saveEverything() {
|
||||
for (Player player : this.server.players().values()) {
|
||||
this.server.savePlayer(player);
|
||||
}
|
||||
try {
|
||||
this.server.world().close();
|
||||
} catch (RuntimeException e) {
|
||||
System.out.println("[rsx] the world would not all be written: " + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,8 @@ public final class Server implements net.rustnotmc.server.status.StatusSession.I
|
||||
private volatile TokenAuth tokenAuth;
|
||||
private volatile boolean localAccounts = true;
|
||||
private volatile AdminRegistry admins = new AdminRegistry(null);
|
||||
private volatile net.rustnotmc.server.admin.BanRegistry bans =
|
||||
new net.rustnotmc.server.admin.BanRegistry(null);
|
||||
private volatile net.rustnotmc.server.player.PlayerData playerData =
|
||||
new net.rustnotmc.server.player.PlayerData(null);
|
||||
private volatile net.rustnotmc.server.world.LodGenerator lod;
|
||||
@@ -196,6 +198,21 @@ public final class Server implements net.rustnotmc.server.status.StatusSession.I
|
||||
this.admins = admins == null ? new AdminRegistry(null) : admins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who may not come in at all.
|
||||
*
|
||||
* The names live in a file beside the world, and every entrance is
|
||||
* checked against the file before the player is believed.
|
||||
*/
|
||||
public net.rustnotmc.server.admin.BanRegistry bans() {
|
||||
return this.bans;
|
||||
}
|
||||
|
||||
public void bans(net.rustnotmc.server.admin.BanRegistry bans) {
|
||||
this.bans = bans == null ? new net.rustnotmc.server.admin.BanRegistry(null)
|
||||
: bans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes down what a player leaves behind.
|
||||
*
|
||||
|
||||
@@ -135,6 +135,8 @@ public final class ServerMain {
|
||||
// The administrators are the server's own list.
|
||||
Path adminFile = adminsFile != null ? adminsFile : worldDir.resolve("admins.txt");
|
||||
server.admins(new net.rustnotmc.server.admin.AdminRegistry(adminFile));
|
||||
// who may not come in at all, in a file beside the world
|
||||
server.bans(new net.rustnotmc.server.admin.BanRegistry(worldDir.resolve("bans.txt")));
|
||||
// the far view of the world, kept beside it
|
||||
server.lod(new net.rustnotmc.server.world.LodGenerator(world.generator(),
|
||||
worldDir.resolve("lod")));
|
||||
@@ -213,6 +215,12 @@ public final class ServerMain {
|
||||
}
|
||||
}, "rsx-shutdown"));
|
||||
|
||||
// the console: whoever is sitting at the machine talks to the server
|
||||
// here, and the queue of words is answered as it comes
|
||||
Thread console = new Thread(new ConsoleCommands(server), "rsx-console");
|
||||
console.setDaemon(true);
|
||||
console.start();
|
||||
|
||||
long reportedAt = 0L;
|
||||
while (server.running()) {
|
||||
Thread.sleep(5_000L);
|
||||
|
||||
@@ -81,6 +81,50 @@ public final class AdminRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/** Names one of the administrators, in the file and at once. */
|
||||
public void give(String account) {
|
||||
if (account == null || account.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
reread();
|
||||
String name = account.toLowerCase(Locale.ROOT);
|
||||
if (!this.admins.add(name)) {
|
||||
return;
|
||||
}
|
||||
save();
|
||||
this.lastRead = -1L; // the file was just written; do not read it back
|
||||
}
|
||||
|
||||
/** Takes a right away, in the file and at once. */
|
||||
public void take(String account) {
|
||||
if (account == null || account.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
reread();
|
||||
String name = account.toLowerCase(Locale.ROOT);
|
||||
if (!this.admins.remove(name)) {
|
||||
return;
|
||||
}
|
||||
save();
|
||||
this.lastRead = -1L;
|
||||
}
|
||||
|
||||
/** Writes the administrators one per line, newest order forgotten. */
|
||||
private void save() {
|
||||
if (this.file == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
java.util.List<String> lines = new java.util.ArrayList<String>(
|
||||
new java.util.TreeSet<String>(this.admins));
|
||||
lines.add(0, "# one name of an administrator per line");
|
||||
Files.createDirectories(this.file.toAbsolutePath().getParent());
|
||||
Files.write(this.file, lines, StandardCharsets.UTF_8);
|
||||
} catch (IOException ignored) {
|
||||
// the list still holds for this run; the log says what happened
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the file. */
|
||||
private void read() {
|
||||
this.admins.clear();
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package net.rustnotmc.server.admin;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Who may not come in at all.
|
||||
*
|
||||
* A ban is a promise the server keeps without anybody looking: the names live
|
||||
* in a file beside the world, and every entrance is checked against it. A
|
||||
* crashed or a stopped server forgets nothing, because the file is the thing
|
||||
* that knows.
|
||||
*
|
||||
* The same file the registry of the administrators grows up from: one name per
|
||||
* line, read again whenever anything else writes to it, so that a console
|
||||
* command is law at once and not at the next restart.
|
||||
*/
|
||||
public final class BanRegistry {
|
||||
|
||||
/** The file the names are written in. */
|
||||
private final Path file;
|
||||
|
||||
private final Set<String> names = new HashSet<String>();
|
||||
|
||||
private long lastRead;
|
||||
|
||||
public BanRegistry(Path file) {
|
||||
this.file = file;
|
||||
read();
|
||||
}
|
||||
|
||||
/** The file the names are written in. */
|
||||
public Path file() {
|
||||
return this.file;
|
||||
}
|
||||
|
||||
/** Whether the account may not come in. */
|
||||
public boolean isBanned(String account) {
|
||||
if (account == null || account.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
reread();
|
||||
return this.names.contains(account.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
/** The names, as the file spells them. */
|
||||
public Set<String> bans() {
|
||||
reread();
|
||||
return Collections.unmodifiableSet(this.names);
|
||||
}
|
||||
|
||||
/** Bans an account, in the file and at once. */
|
||||
public void ban(String account) {
|
||||
if (account == null || account.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String name = account.toLowerCase(Locale.ROOT);
|
||||
if (!this.names.add(name)) {
|
||||
return;
|
||||
}
|
||||
save();
|
||||
// the file was just written; the clock must not read it again as new
|
||||
this.lastRead = -1L;
|
||||
}
|
||||
|
||||
/** Takes a ban away, in the file and at once. */
|
||||
public void unban(String account) {
|
||||
if (account == null || account.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String name = account.toLowerCase(Locale.ROOT);
|
||||
if (!this.names.remove(name)) {
|
||||
return;
|
||||
}
|
||||
save();
|
||||
this.lastRead = -1L;
|
||||
}
|
||||
|
||||
/** Reads the file again when it changed. */
|
||||
private void reread() {
|
||||
if (this.file == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
long changed = Files.getLastModifiedTime(this.file).toMillis();
|
||||
if (changed != this.lastRead) {
|
||||
read();
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
// the list stays as it was until the disk answers again
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the file. A missing one is an empty list. */
|
||||
private void read() {
|
||||
this.names.clear();
|
||||
this.lastRead = 0L;
|
||||
if (this.file == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!Files.isRegularFile(this.file)) {
|
||||
return;
|
||||
}
|
||||
this.lastRead = Files.getLastModifiedTime(this.file).toMillis();
|
||||
for (String line : Files.readAllLines(this.file, StandardCharsets.UTF_8)) {
|
||||
String name = line.trim();
|
||||
if (name.isEmpty() || name.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
this.names.add(name.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
// A list that cannot be read lets players in rather than locking
|
||||
// the door behind a hiccup of the disk.
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the names one per line. */
|
||||
private void save() {
|
||||
if (this.file == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
java.util.List<String> lines = new java.util.ArrayList<String>(
|
||||
new java.util.TreeSet<String>(this.names));
|
||||
lines.add(0, "# one banned account per line");
|
||||
Files.createDirectories(this.file.toAbsolutePath().getParent());
|
||||
Files.write(this.file, lines, StandardCharsets.UTF_8);
|
||||
} catch (IOException ignored) {
|
||||
// the ban holds for this run; the log says what happened
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,28 +51,42 @@ public final class LodGenerator {
|
||||
* The far view of one region.
|
||||
*
|
||||
* The region is read from the disk when it is there, and worked out when
|
||||
* it is not.
|
||||
* it is not. The step decides how far apart the samples are: near the
|
||||
* player the ground is known finely, far away the same region can come
|
||||
* with every second, fourth or eighth sample, and the client keeps the
|
||||
* last one it was given.
|
||||
*/
|
||||
public Packets.LodRegion region(int chunkX, int chunkZ) {
|
||||
public Packets.LodRegion region(int chunkX, int chunkZ, int step) {
|
||||
int startChunk = regionChunk(chunkX);
|
||||
int startZ = regionChunk(chunkZ);
|
||||
int originX = startChunk * 16;
|
||||
int originZ = startZ * 16;
|
||||
int count = samples(step);
|
||||
|
||||
byte[] heights = new byte[SAMPLES * SAMPLES];
|
||||
byte[] colors = new byte[SAMPLES * SAMPLES * 3];
|
||||
if (!read(startChunk, startZ, heights, colors)) {
|
||||
write(startChunk, startZ, heights, colors);
|
||||
byte[] heights = new byte[count * count];
|
||||
byte[] colors = new byte[count * count * 3];
|
||||
if (!read(startChunk, startZ, step, heights, colors)) {
|
||||
write(startChunk, startZ, step, heights, colors);
|
||||
}
|
||||
return new Packets.LodRegion(originX, originZ, STEP, SAMPLES, heights, colors);
|
||||
return new Packets.LodRegion(originX, originZ, step, count, heights, colors);
|
||||
}
|
||||
|
||||
/** Reads a region from the disk. */
|
||||
private boolean read(int chunkX, int chunkZ, byte[] heights, byte[] colors) {
|
||||
/** The far view of one region at the finest step. */
|
||||
public Packets.LodRegion region(int chunkX, int chunkZ) {
|
||||
return region(chunkX, chunkZ, STEP);
|
||||
}
|
||||
|
||||
/** How many samples there are on the side of a region, at a step. */
|
||||
public static int samples(int step) {
|
||||
return REGION_BLOCKS / Math.max(1, step);
|
||||
}
|
||||
|
||||
/** Reads a region from the disk, at a step. */
|
||||
private boolean read(int chunkX, int chunkZ, int step, byte[] heights, byte[] colors) {
|
||||
if (this.cache == null) {
|
||||
return false;
|
||||
}
|
||||
Path file = this.cache.resolve(chunkX + "_" + chunkZ + ".bin");
|
||||
Path file = fileOf(chunkX, chunkZ, step);
|
||||
if (!Files.isRegularFile(file)) {
|
||||
return false;
|
||||
}
|
||||
@@ -90,14 +104,20 @@ public final class LodGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
/** Works a region out and writes it down. */
|
||||
private void write(int chunkX, int chunkZ, byte[] heights, byte[] colors) {
|
||||
for (int z = 0; z < SAMPLES; z++) {
|
||||
for (int x = 0; x < SAMPLES; x++) {
|
||||
int worldX = chunkX * 16 + x * STEP;
|
||||
int worldZ = chunkZ * 16 + z * STEP;
|
||||
/** The file a region of a step lives in. */
|
||||
private Path fileOf(int chunkX, int chunkZ, int step) {
|
||||
return this.cache.resolve(chunkX + "_" + chunkZ + "_s" + step + ".bin");
|
||||
}
|
||||
|
||||
/** Works a region out and writes it down, at a step. */
|
||||
private void write(int chunkX, int chunkZ, int step, byte[] heights, byte[] colors) {
|
||||
int count = samples(step);
|
||||
for (int z = 0; z < count; z++) {
|
||||
for (int x = 0; x < count; x++) {
|
||||
int worldX = chunkX * 16 + x * step;
|
||||
int worldZ = chunkZ * 16 + z * step;
|
||||
int height = this.generator.heightAt(worldX, worldZ);
|
||||
int at = z * SAMPLES + x;
|
||||
int at = z * count + x;
|
||||
heights[at] = (byte) height;
|
||||
int colour = colourOf(worldX, worldZ, height);
|
||||
colors[at * 3] = (byte) ((colour >> 16) & 0xFF);
|
||||
@@ -113,7 +133,7 @@ public final class LodGenerator {
|
||||
byte[] data = new byte[heights.length + colors.length];
|
||||
System.arraycopy(heights, 0, data, 0, heights.length);
|
||||
System.arraycopy(colors, 0, data, heights.length, colors.length);
|
||||
Path file = this.cache.resolve(chunkX + "_" + chunkZ + ".bin");
|
||||
Path file = fileOf(chunkX, chunkZ, step);
|
||||
Path temp = file.resolveSibling(file.getFileName() + ".tmp");
|
||||
Files.write(temp, data);
|
||||
Files.move(temp, file, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
@@ -128,7 +148,7 @@ public final class LodGenerator {
|
||||
if (height < WorldGenerator.SEA_LEVEL) {
|
||||
return 0x2E5C8A;
|
||||
}
|
||||
byte top = topBlock(worldX, worldZ, height);
|
||||
byte top = this.generator.surfaceAt(worldX, worldZ);
|
||||
switch (top) {
|
||||
case Blocks.SAND:
|
||||
return 0xC8B47C;
|
||||
@@ -136,24 +156,12 @@ public final class LodGenerator {
|
||||
return 0x6E6E6E;
|
||||
case Blocks.ASPHALT:
|
||||
return 0x3A3A3A;
|
||||
case Blocks.SNOW:
|
||||
return 0xE9EDEF;
|
||||
case Blocks.WATER:
|
||||
return 0x2E5C8A;
|
||||
default:
|
||||
return 0x4E7A34;
|
||||
}
|
||||
}
|
||||
|
||||
/** The block on the top of a column. */
|
||||
private byte topBlock(int worldX, int worldZ, int height) {
|
||||
if (this.generator.isRoad(worldX, worldZ)) {
|
||||
return Blocks.ASPHALT;
|
||||
}
|
||||
if (height < WorldGenerator.SEA_LEVEL) {
|
||||
return Blocks.WATER;
|
||||
}
|
||||
if (height <= WorldGenerator.SEA_LEVEL + 1) {
|
||||
return Blocks.SAND;
|
||||
}
|
||||
return Blocks.GRASS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,26 @@ public final class RoadNetwork {
|
||||
return onStraightRoad(x, z) || onRing(x, z) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the column is on a road or within {@code extra} blocks of one.
|
||||
*
|
||||
* Things that need a clear view of the sky or the road, like the trees,
|
||||
* keep their distance with this: a road with a crown hanging over it
|
||||
* would be a road at the bottom of a green hole.
|
||||
*/
|
||||
public boolean nearRoad(int x, int z, int extra) {
|
||||
int reach = HALF_WIDTH + extra;
|
||||
boolean straights = Math.abs(x - centerLine(x)) <= reach
|
||||
|| Math.abs(z - centerLine(z)) <= reach;
|
||||
if (straights) {
|
||||
return true;
|
||||
}
|
||||
double dx = x - center();
|
||||
double dz = z - center();
|
||||
double distance = Math.sqrt(dx * dx + dz * dz);
|
||||
return Math.abs(distance - this.ringRadius) <= reach;
|
||||
}
|
||||
|
||||
/**
|
||||
* The points along the roads where a junk pile may stand.
|
||||
*
|
||||
|
||||
@@ -17,8 +17,20 @@ import net.rustnotmc.protocol.ChunkFormat;
|
||||
* function of the seed and the coordinates.
|
||||
*
|
||||
* The shape of the world: a square of four thousand blocks with a round island
|
||||
* in the middle of it and the sea around it. The roads of the road network run
|
||||
* over it.
|
||||
* in the middle of it and the sea around it. The land inside the island is a
|
||||
* broad plain with ridged mountains rising in the middle of it, and it does not
|
||||
* turn into lakes on its own: the inland ground sits at or above the sea, and
|
||||
* the open water stays outside the shore where it belongs.
|
||||
*
|
||||
* The roads of the road network run over it. A road is level across its width
|
||||
* and follows the land it runs over along its length, carried by a smooth rank
|
||||
* of the ground under its middle: where the ground falls to the sea the road
|
||||
* rides on to it as a causeway, so a road is never under water.
|
||||
*
|
||||
* The land wears three kinds of coat: grass in the temperate places, sand in
|
||||
* the dry ones and on the beaches, and snow on the cold tops of the mountains.
|
||||
* Trees grow on the temperate ground, evenly at random, and never close enough
|
||||
* to a road to hang over it.
|
||||
*/
|
||||
public final class WorldGenerator {
|
||||
|
||||
@@ -34,6 +46,18 @@ public final class WorldGenerator {
|
||||
/** How deep the sea is at its deepest. */
|
||||
public static final int SEA_DEPTH = 12;
|
||||
|
||||
/** Above this height the ground turns to bare rock. */
|
||||
public static final int ROCK_TOP = 86;
|
||||
|
||||
/** Above this height the rock keeps a coat of snow. */
|
||||
public static final int SNOW_TOP = 96;
|
||||
|
||||
/** How many blocks either side of a road's middle the rank looks at. */
|
||||
private static final int ROAD_RANK = 8;
|
||||
|
||||
/** How many blocks of canopy a tree may reach. */
|
||||
private static final int TREE_REACH = 2;
|
||||
|
||||
private final long seed;
|
||||
private final int size;
|
||||
private final int islandRadius;
|
||||
@@ -84,6 +108,9 @@ public final class WorldGenerator {
|
||||
* This is the whole trick behind a generator that survives being spread
|
||||
* across threads and months: any value in the world can be computed from a
|
||||
* coordinate without having visited any other coordinate.
|
||||
*
|
||||
* The middle coordinate is also the salt: two fields that must not be the
|
||||
* same field are drawn from two different salts.
|
||||
*/
|
||||
private long hash(int x, int y, int z) {
|
||||
long h = this.seed;
|
||||
@@ -98,6 +125,10 @@ public final class WorldGenerator {
|
||||
|
||||
/** Value noise in the range -1..1, smooth between lattice points. */
|
||||
public double noise(double x, double z) {
|
||||
return this.noise(x, z, 0);
|
||||
}
|
||||
|
||||
private double noise(double x, double z, int salt) {
|
||||
int x0 = (int) Math.floor(x);
|
||||
int z0 = (int) Math.floor(z);
|
||||
double fx = x - x0;
|
||||
@@ -106,30 +137,34 @@ public final class WorldGenerator {
|
||||
double u = fx * fx * (3.0D - 2.0D * fx);
|
||||
double v = fz * fz * (3.0D - 2.0D * fz);
|
||||
|
||||
double n00 = lattice(x0, z0);
|
||||
double n10 = lattice(x0 + 1, z0);
|
||||
double n01 = lattice(x0, z0 + 1);
|
||||
double n11 = lattice(x0 + 1, z0 + 1);
|
||||
double n00 = lattice(x0, z0, salt);
|
||||
double n10 = lattice(x0 + 1, z0, salt);
|
||||
double n01 = lattice(x0, z0 + 1, salt);
|
||||
double n11 = lattice(x0 + 1, z0 + 1, salt);
|
||||
|
||||
double top = n00 + u * (n10 - n00);
|
||||
double bottom = n01 + u * (n11 - n01);
|
||||
return top + v * (bottom - top);
|
||||
}
|
||||
|
||||
private double lattice(int x, int z) {
|
||||
long h = hash(x, 0, z);
|
||||
private double lattice(int x, int z, int salt) {
|
||||
long h = hash(x, salt, z);
|
||||
// take the top bits so the distribution is even across the range
|
||||
return ((h >>> 11) / (double) (1L << 53)) * 2.0D - 1.0D;
|
||||
}
|
||||
|
||||
/** Several octaves of noise, which is what makes it look like ground. */
|
||||
public double fBm(double x, double z, int octaves) {
|
||||
return this.fBm(x, z, octaves, 0);
|
||||
}
|
||||
|
||||
private double fBm(double x, double z, int octaves, int salt) {
|
||||
double sum = 0.0D;
|
||||
double amplitude = 1.0D;
|
||||
double frequency = 1.0D;
|
||||
double norm = 0.0D;
|
||||
for (int i = 0; i < octaves; i++) {
|
||||
sum += noise(x * frequency, z * frequency) * amplitude;
|
||||
sum += noise(x * frequency, z * frequency, salt) * amplitude;
|
||||
norm += amplitude;
|
||||
amplitude *= 0.5D;
|
||||
frequency *= 2.0D;
|
||||
@@ -137,6 +172,51 @@ public final class WorldGenerator {
|
||||
return norm == 0.0D ? 0.0D : sum / norm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ridge noise: the crest of a folded landscape.
|
||||
*
|
||||
* Where plain fBm builds hills that wander, rounded noise makes ranges:
|
||||
* long lines where the field drifts over zero, steep where it turns. The
|
||||
* salt keeps the mountain field from being the land field.
|
||||
*/
|
||||
private double ridged(double x, double z, int salt) {
|
||||
double n = this.noise(x, z, salt);
|
||||
n = 1.0D - Math.abs(n);
|
||||
return n * n;
|
||||
}
|
||||
|
||||
private double ridgedFbm(double x, double z, int salt) {
|
||||
double coarse = (1.0D - Math.abs(this.noise(x, z, salt)));
|
||||
coarse = coarse * coarse;
|
||||
double fine = (1.0D - Math.abs(this.noise(x * 3.0D, z * 3.0D, salt)));
|
||||
fine = fine * fine;
|
||||
return coarse * 0.75D + fine * 0.25D;
|
||||
}
|
||||
|
||||
/**
|
||||
* How warm a column is, from -1 (the coldest) to 1 (the hottest), salted
|
||||
* so it is not the same field as the land it lies on.
|
||||
*/
|
||||
private double temperature(int blockX, int blockZ) {
|
||||
return this.fBm(blockX / 300.0D, blockZ / 300.0D, 2, 1111);
|
||||
}
|
||||
|
||||
/** How damp a column is, from -1 (the driest) to 1 (the wettest). */
|
||||
private double humidity(int blockX, int blockZ) {
|
||||
return this.fBm(blockX / 240.0D, blockZ / 240.0D, 2, 2222);
|
||||
}
|
||||
|
||||
/** True when the column is in the dry belt that wears sand. */
|
||||
public boolean isDesert(int blockX, int blockZ) {
|
||||
return this.temperature(blockX, blockZ) > 0.4D
|
||||
&& this.humidity(blockX, blockZ) < 0.0D;
|
||||
}
|
||||
|
||||
/** True when the column is in the cold belt that wears snow. */
|
||||
public boolean isSnowCountry(int blockX, int blockZ) {
|
||||
return this.temperature(blockX, blockZ) < -0.45D;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the land there is at a column.
|
||||
*
|
||||
@@ -155,13 +235,36 @@ public final class WorldGenerator {
|
||||
return falloff * falloff * (3.0D - 2.0D * falloff);
|
||||
}
|
||||
|
||||
/** The height of solid ground at a block column. */
|
||||
public int heightAt(int blockX, int blockZ) {
|
||||
/**
|
||||
* The height of the ground with no road on it.
|
||||
*
|
||||
* The blend is the classic one: the land field slides from a plain under
|
||||
* the open sky down to the sea floor at the shore, and the sea asks for
|
||||
* nothing more. On top of that come the mountains, masked by the falloff
|
||||
* twice so that ranges stay off the beach.
|
||||
*/
|
||||
private int rawGround(int blockX, int blockZ) {
|
||||
double base = fBm(blockX / 160.0D, blockZ / 160.0D, 4);
|
||||
double detail = fBm(blockX / 32.0D, blockZ / 32.0D, 2) * 0.35D;
|
||||
double land = 62.0D + base * 26.0D + detail * 6.0D;
|
||||
double land = SEA_LEVEL + base * 26.0D + detail * 6.0D;
|
||||
double falloff = islandFalloff(blockX, blockZ);
|
||||
double height = (SEA_LEVEL - SEA_DEPTH) + falloff * (land - (SEA_LEVEL - SEA_DEPTH));
|
||||
double height = (SEA_LEVEL - SEA_DEPTH)
|
||||
+ falloff * (land - (SEA_LEVEL - SEA_DEPTH));
|
||||
|
||||
if (falloff > 0.18D) {
|
||||
double ridge = ridgedFbm(blockX / 420.0D, blockZ / 420.0D, 3333);
|
||||
double mountain = Math.pow(Math.max(0.0D, (ridge - 0.42D) / 0.58D),
|
||||
1.8D) * 54.0D * falloff * falloff;
|
||||
height += mountain;
|
||||
}
|
||||
|
||||
// the inland ground does not fall under the sea: where the plain would
|
||||
// grow a lake, it is a damp flat instead, and the open water keeps to
|
||||
// the shore and the sea beyond it
|
||||
if (falloff > 0.25D && height < SEA_LEVEL) {
|
||||
height = SEA_LEVEL + (falloff - 0.25D) * 4.0D;
|
||||
}
|
||||
|
||||
int h = (int) Math.round(height);
|
||||
if (h < 2) {
|
||||
h = 2;
|
||||
@@ -172,11 +275,232 @@ public final class WorldGenerator {
|
||||
return h;
|
||||
}
|
||||
|
||||
/** The height of solid ground at a block column. */
|
||||
public int heightAt(int blockX, int blockZ) {
|
||||
if (this.isRoad(blockX, blockZ)) {
|
||||
int rank = this.roadRank(blockX, blockZ);
|
||||
if (rank < SEA_LEVEL + 1) {
|
||||
rank = SEA_LEVEL + 1;
|
||||
}
|
||||
return this.clamp(rank);
|
||||
}
|
||||
return this.clamp(this.rawGround(blockX, blockZ));
|
||||
}
|
||||
|
||||
private int clamp(int h) {
|
||||
if (h < 2) {
|
||||
h = 2;
|
||||
}
|
||||
if (h > Chunk.HEIGHT - 2) {
|
||||
h = Chunk.HEIGHT - 2;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/**
|
||||
* The height a road rides at, taken from the land under its middle.
|
||||
*
|
||||
* A road is level across its width because every column of it reads the
|
||||
* same rank, and it follows the land along its length because the rank is
|
||||
* the average of the ground a few blocks up and down the road, which
|
||||
* smooths the ground without losing it. Where that rank has sunk under the
|
||||
* sea the road still rides on it, a causeway, and never drowns.
|
||||
*/
|
||||
private int roadRank(int blockX, int blockZ) {
|
||||
int cx = roads.centerLine(blockX);
|
||||
int cz = roads.centerLine(blockZ);
|
||||
boolean acrossX = Math.abs(blockZ - cz) <= RoadNetwork.HALF_WIDTH;
|
||||
boolean acrossZ = Math.abs(blockX - cx) <= RoadNetwork.HALF_WIDTH;
|
||||
|
||||
int level = SEA_LEVEL;
|
||||
if (acrossX) {
|
||||
level = rankOfStraight(blockX, blockZ, cz, true);
|
||||
}
|
||||
if (acrossZ) {
|
||||
int other = rankOfStraight(blockX, blockZ, cx, false);
|
||||
if (other > level) {
|
||||
level = other;
|
||||
}
|
||||
}
|
||||
if (roads.onRing(blockX, blockZ) != null) {
|
||||
int ring = rankOfRing(blockX, blockZ);
|
||||
if (ring > level) {
|
||||
level = ring;
|
||||
}
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rank of one straight road. For a road that runs along
|
||||
* {@code x} the cross line is the {@code z} the road sits on, and the
|
||||
* other way round.
|
||||
*/
|
||||
private int rankOfStraight(int blockX, int blockZ, int crossLine, boolean alongX) {
|
||||
long sum = 0L;
|
||||
int count = 0;
|
||||
for (int k = -ROAD_RANK; k <= ROAD_RANK; k++) {
|
||||
int sx = alongX ? blockX + k : crossLine;
|
||||
int sz = alongX ? crossLine : blockZ + k;
|
||||
sum += rawGround(sx, sz);
|
||||
count++;
|
||||
}
|
||||
int rank = (int) (sum / count);
|
||||
if (rank < SEA_LEVEL + 1) {
|
||||
rank = SEA_LEVEL + 1;
|
||||
}
|
||||
return rank;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rank of the ring road, smoothed along the ring itself.
|
||||
*/
|
||||
private int rankOfRing(int blockX, int blockZ) {
|
||||
double dx = blockX - roads.center();
|
||||
double dz = blockZ - roads.center();
|
||||
double angle = Math.atan2(dz, dx);
|
||||
double tx = -Math.sin(angle);
|
||||
double tz = Math.cos(angle);
|
||||
long sum = 0L;
|
||||
int count = 0;
|
||||
for (int k = -ROAD_RANK; k <= ROAD_RANK; k++) {
|
||||
int sx = blockX + (int) Math.round(tx * k);
|
||||
int sz = blockZ + (int) Math.round(tz * k);
|
||||
sum += rawGround(sx, sz);
|
||||
count++;
|
||||
}
|
||||
int rank = (int) (sum / count);
|
||||
if (rank < SEA_LEVEL + 1) {
|
||||
rank = SEA_LEVEL + 1;
|
||||
}
|
||||
return rank;
|
||||
}
|
||||
|
||||
/** True when the column is under the sea. */
|
||||
public boolean underWater(int blockX, int blockZ) {
|
||||
return heightAt(blockX, blockZ) < SEA_LEVEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* The block on the top of the ground at a column, before anything grows
|
||||
* over it.
|
||||
*
|
||||
* Roads wear asphalt, ground under the sea and near it wears sand, the dry
|
||||
* belt wears sand, the tops of the mountains wear rock, the coldest tops
|
||||
* wear snow, and the rest of the island is grass.
|
||||
*/
|
||||
public byte surfaceAt(int blockX, int blockZ) {
|
||||
if (isRoad(blockX, blockZ)) {
|
||||
return Blocks.ASPHALT;
|
||||
}
|
||||
int height = heightAt(blockX, blockZ);
|
||||
if (height < SEA_LEVEL) {
|
||||
return Blocks.SAND;
|
||||
}
|
||||
if (height <= SEA_LEVEL + 1) {
|
||||
return Blocks.SAND;
|
||||
}
|
||||
if (height >= SNOW_TOP) {
|
||||
return Blocks.SNOW;
|
||||
}
|
||||
if (height >= ROCK_TOP) {
|
||||
return Blocks.STONE;
|
||||
}
|
||||
if (this.isDesert(blockX, blockZ)) {
|
||||
return Blocks.SAND;
|
||||
}
|
||||
if (this.isSnowCountry(blockX, blockZ)) {
|
||||
return Blocks.SNOW;
|
||||
}
|
||||
return Blocks.GRASS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a tree stands at a column.
|
||||
*
|
||||
* A tree is picked by the hash of its own square, and a square is only a
|
||||
* candidate where the ground is grass and no road runs near enough for a
|
||||
* canopy to hang over it. Nothing spans the island edge: no tree grows in
|
||||
* sand or on rock or in snow, and none stands in the water.
|
||||
*/
|
||||
public boolean treeAt(int blockX, int blockZ) {
|
||||
if (this.isRoad(blockX, blockZ)) {
|
||||
return false;
|
||||
}
|
||||
if (this.roads.nearRoad(blockX, blockZ, TREE_REACH + 1)) {
|
||||
return false;
|
||||
}
|
||||
int height = this.heightAt(blockX, blockZ);
|
||||
if (height <= SEA_LEVEL + 1) {
|
||||
return false;
|
||||
}
|
||||
byte surface = this.surfaceAtRaw(blockX, blockZ, height);
|
||||
if (surface != Blocks.GRASS) {
|
||||
return false;
|
||||
}
|
||||
double humidity = this.humidity(blockX, blockZ);
|
||||
double temperature = this.temperature(blockX, blockZ);
|
||||
double density = pickDensity(humidity, temperature);
|
||||
double pick = treePick(blockX, blockZ);
|
||||
return pick < density;
|
||||
}
|
||||
|
||||
private double treePick(int blockX, int blockZ) {
|
||||
long h = hash(blockX, 7, blockZ);
|
||||
double pick = (h >>> 11) / (double) (1L << 53);
|
||||
if (pick > 1.0D) {
|
||||
pick = 1.0D;
|
||||
}
|
||||
return pick;
|
||||
}
|
||||
|
||||
private double pickDensity(double humidity, double temperature) {
|
||||
// the woods grow where the land is wet, the plains carry a tree here
|
||||
// and there, and the snow and the desert nearly none. In the wettest
|
||||
// woods about every sixth block carries a tree.
|
||||
if (temperature < -0.45D) {
|
||||
return 1.0D / 220.0D;
|
||||
}
|
||||
if (temperature > 0.4D && humidity < 0.0D) {
|
||||
return 0.0D;
|
||||
}
|
||||
if (humidity > 0.25D) {
|
||||
return 1.0D / 6.0D;
|
||||
}
|
||||
if (humidity > 0.0D) {
|
||||
return 1.0D / 26.0D;
|
||||
}
|
||||
return 1.0D / 150.0D;
|
||||
}
|
||||
|
||||
/** How tall the trunk of a tree is, from the ground up. */
|
||||
public int trunkHeight(int blockX, int blockZ) {
|
||||
return 4 + ((int) (hash(blockX, 8, blockZ) & 3));
|
||||
}
|
||||
|
||||
/** The coat of the ground under the road's own rules, for tests. */
|
||||
public byte surfaceAtRaw(int blockX, int blockZ, int height) {
|
||||
if (height < SEA_LEVEL) {
|
||||
return Blocks.SAND;
|
||||
}
|
||||
if (height <= SEA_LEVEL + 1) {
|
||||
return Blocks.SAND;
|
||||
}
|
||||
if (height >= SNOW_TOP) {
|
||||
return Blocks.SNOW;
|
||||
}
|
||||
if (height >= ROCK_TOP) {
|
||||
return Blocks.STONE;
|
||||
}
|
||||
if (this.isDesert(blockX, blockZ)) {
|
||||
return Blocks.SAND;
|
||||
}
|
||||
if (this.isSnowCountry(blockX, blockZ)) {
|
||||
return Blocks.SNOW;
|
||||
}
|
||||
return Blocks.GRASS;
|
||||
}
|
||||
|
||||
/** Fills one chunk. Pure: same seed and position, same bytes out. */
|
||||
public void generate(Chunk chunk) {
|
||||
ChunkPos pos = chunk.pos();
|
||||
@@ -187,44 +511,70 @@ public final class WorldGenerator {
|
||||
synchronized (chunk) {
|
||||
byte[] blocks = chunk.raw();
|
||||
|
||||
for (int z = 0; z < ChunkFormat.WIDTH; z++) {
|
||||
for (int x = 0; x < ChunkFormat.WIDTH; x++) {
|
||||
// the trees reach two blocks of canopy, so a chunk is planned not
|
||||
// only over its own squares but over the rank of squares around it
|
||||
int pad = TREE_REACH;
|
||||
int span = ChunkFormat.WIDTH + pad * 2;
|
||||
int[] spanHeights = new int[span * span];
|
||||
byte[] spanSurface = new byte[span * span];
|
||||
boolean[] spanTree = new boolean[span * span];
|
||||
for (int z = -pad; z < ChunkFormat.WIDTH + pad; z++) {
|
||||
for (int x = -pad; x < ChunkFormat.WIDTH + pad; x++) {
|
||||
int at = (z + pad) * span + (x + pad);
|
||||
int worldX = baseX + x;
|
||||
int worldZ = baseZ + z;
|
||||
int height = heightAt(worldX, worldZ);
|
||||
boolean road = isRoad(worldX, worldZ);
|
||||
boolean beach = height <= SEA_LEVEL + 1;
|
||||
spanHeights[at] = height;
|
||||
spanSurface[at] = surfaceAt(worldX, worldZ);
|
||||
spanTree[at] = treeAt(worldX, worldZ);
|
||||
}
|
||||
}
|
||||
|
||||
// a column: dirt under the surface, grass on top, stone below
|
||||
for (int z = 0; z < ChunkFormat.WIDTH; z++) {
|
||||
for (int x = 0; x < ChunkFormat.WIDTH; x++) {
|
||||
int at = (z + pad) * span + (x + pad);
|
||||
int worldX = baseX + x;
|
||||
int worldZ = baseZ + z;
|
||||
int height = spanHeights[at];
|
||||
byte surface = spanSurface[at];
|
||||
|
||||
// a column: dirt under the surface, its coat on top, stone
|
||||
// below, and roads are asphalt over packed ground
|
||||
for (int y = 0; y <= height; y++) {
|
||||
byte id;
|
||||
if (y == 0) {
|
||||
id = Blocks.STONE;
|
||||
} else if (y == height) {
|
||||
if (road) {
|
||||
id = Blocks.ASPHALT;
|
||||
} else if (beach) {
|
||||
id = Blocks.SAND;
|
||||
} else {
|
||||
id = Blocks.GRASS;
|
||||
}
|
||||
} else if (y > height - 4) {
|
||||
id = Blocks.DIRT;
|
||||
} else {
|
||||
id = surface;
|
||||
} else if (surface == Blocks.SAND) {
|
||||
id = y > height - 3 ? Blocks.SAND
|
||||
: (y > height - 4 ? Blocks.DIRT : Blocks.STONE);
|
||||
} else if (surface == Blocks.STONE || surface == Blocks.SNOW) {
|
||||
id = Blocks.STONE;
|
||||
} else {
|
||||
id = y > height - 4 ? Blocks.DIRT : Blocks.STONE;
|
||||
}
|
||||
blocks[ChunkFormat.index(x, y, z)] = id;
|
||||
}
|
||||
// and the sea on top of the ground that is below it
|
||||
for (int y = height + 1; y <= SEA_LEVEL; y++) {
|
||||
if (y > 0 && y < Chunk.HEIGHT) {
|
||||
blocks[ChunkFormat.index(x, y, z)] = Blocks.WATER;
|
||||
if (height < SEA_LEVEL) {
|
||||
for (int y = height + 1; y <= SEA_LEVEL; y++) {
|
||||
if (y > 0 && y < Chunk.HEIGHT) {
|
||||
blocks[ChunkFormat.index(x, y, z)] = Blocks.WATER;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int y = height + 1; y < Chunk.HEIGHT; y++) {
|
||||
if (blocks[ChunkFormat.index(x, y, z)] == Blocks.AIR) {
|
||||
// the air above the water stays air
|
||||
continue;
|
||||
|
||||
// the trees, if any stand over this column
|
||||
for (int dz = -pad; dz <= pad; dz++) {
|
||||
for (int dx = -pad; dx <= pad; dx++) {
|
||||
int source = (z + pad + dz) * span + (x + pad + dx);
|
||||
if (!spanTree[source]) {
|
||||
continue;
|
||||
}
|
||||
growTree(blocks, x, z, spanHeights[source],
|
||||
trunkHeight(baseX + x + dx, baseZ + z + dz),
|
||||
dx, dz);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,15 +583,58 @@ public final class WorldGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays the wood of one tree into a column of a chunk.
|
||||
*
|
||||
* The trunk stands on the tree's own square, and the leaves spread around
|
||||
* it as a rounded crown: two wide rings under the top and two narrow ones
|
||||
* above it. Only air is covered, so a trunk is never buried and two crowns
|
||||
* that meet interleave.
|
||||
*/
|
||||
private void growTree(byte[] blocks, int x, int z, int groundHeight,
|
||||
int trunk, int dx, int dz) {
|
||||
int top = groundHeight + trunk;
|
||||
int limit = Chunk.HEIGHT - 1;
|
||||
|
||||
if (dx == 0 && dz == 0) {
|
||||
for (int y = groundHeight + 1; y <= top && y < limit; y++) {
|
||||
blocks[ChunkFormat.index(x, y, z)] = Blocks.WOOD;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (int layer = 0; layer < 4; layer++) {
|
||||
int y = top - 2 + layer;
|
||||
if (y < 0 || y >= limit) {
|
||||
continue;
|
||||
}
|
||||
int reach = layer < 2 ? TREE_REACH : 1;
|
||||
if (Math.abs(dx) > reach || Math.abs(dz) > reach) {
|
||||
continue;
|
||||
}
|
||||
if (reach == TREE_REACH && Math.abs(dx) == TREE_REACH
|
||||
&& Math.abs(dz) == TREE_REACH) {
|
||||
continue; // a rounded crown, no corners
|
||||
}
|
||||
int index = ChunkFormat.index(x, y, z);
|
||||
if (blocks[index] == Blocks.AIR) {
|
||||
blocks[index] = Blocks.LEAVES;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a column is on one of the roads.
|
||||
*
|
||||
* The roads are the grid of the road network and the ring around the
|
||||
* island.
|
||||
* island, and they only run over the island: past the shore the network
|
||||
* has no road and the sea keeps it so.
|
||||
*/
|
||||
public boolean isRoad(int blockX, int blockZ) {
|
||||
RoadNetwork roads = new RoadNetwork(this.size);
|
||||
return roads.onRoad(blockX, blockZ);
|
||||
if (islandFalloff(blockX, blockZ) <= 0.0D) {
|
||||
return false;
|
||||
}
|
||||
return this.roads.onRoad(blockX, blockZ);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,6 +33,9 @@ public final class LodTest {
|
||||
run("the regions are numbered from the origin", new Throwing() {
|
||||
public void go() throws Exception { testNumbering(); }
|
||||
});
|
||||
run("a region far away comes coarser", new Throwing() {
|
||||
public void go() throws Exception { testSteps(work); }
|
||||
});
|
||||
|
||||
System.out.println();
|
||||
for (int i = 0; i < failures.size(); i++) {
|
||||
@@ -115,15 +118,40 @@ public final class LodTest {
|
||||
Path cache = work.resolve("lod-test");
|
||||
WorldGenerator generator = new WorldGenerator(1234L);
|
||||
LodGenerator lod = new LodGenerator(generator, cache);
|
||||
Packets.LodRegion first = lod.region(0, 0);
|
||||
yes("the region is written down", Files.isRegularFile(cache.resolve("0_0.bin")));
|
||||
Packets.LodRegion first = lod.region(0, 0, LodGenerator.STEP);
|
||||
yes("the region is written down",
|
||||
Files.isRegularFile(cache.resolve("0_0_s" + LodGenerator.STEP + ".bin")));
|
||||
|
||||
// the second time it is read back, and it is the same
|
||||
Packets.LodRegion second = lod.region(0, 0);
|
||||
Packets.LodRegion second = lod.region(0, 0, LodGenerator.STEP);
|
||||
yes("the same heights", java.util.Arrays.equals(first.heights, second.heights));
|
||||
yes("the same colours", java.util.Arrays.equals(first.colors, second.colors));
|
||||
}
|
||||
|
||||
private static void testSteps(Path work) throws Exception {
|
||||
Path cache = work.resolve("lod-steps");
|
||||
WorldGenerator generator = new WorldGenerator(1234L);
|
||||
LodGenerator lod = new LodGenerator(generator, cache);
|
||||
Packets.LodRegion mid = lod.region(0, 0, 16);
|
||||
eq("the step of the request is the step of the answer", Integer.valueOf(16),
|
||||
Integer.valueOf(mid.step));
|
||||
eq("the samples on a side", Integer.valueOf(16), Integer.valueOf(mid.size));
|
||||
eq("the heights", Integer.valueOf(256), Integer.valueOf(mid.heights.length));
|
||||
eq("the colours", Integer.valueOf(768), Integer.valueOf(mid.colors.length));
|
||||
|
||||
// the corner sample is the real ground at the corner
|
||||
int got = mid.heights[0] & 0xFF;
|
||||
eq("the corner height", Integer.valueOf(generator.heightAt(0, 0)),
|
||||
Integer.valueOf(got));
|
||||
|
||||
Packets.LodRegion coarse = lod.region(3, 7, 64);
|
||||
eq("and the coarsest step", Integer.valueOf(64), Integer.valueOf(coarse.step));
|
||||
eq("is the coarsest grid whatever the corner", Integer.valueOf(4),
|
||||
Integer.valueOf(coarse.size));
|
||||
yes("it is on disk too",
|
||||
Files.isRegularFile(cache.resolve("0_0_s64.bin")));
|
||||
}
|
||||
|
||||
private static void testNumbering() {
|
||||
eq("the first region", Integer.valueOf(0),
|
||||
Integer.valueOf(LodGenerator.regionChunk(0)));
|
||||
|
||||
@@ -55,6 +55,9 @@ public final class WorldTest {
|
||||
run("a chunk knows where its surface is", new Throwing() {
|
||||
public void go() throws Exception { testSurfaceHeight(); }
|
||||
});
|
||||
run("the island keeps its water at the shore and the roads dry", new Throwing() {
|
||||
public void go() throws Exception { testIslandAndRoads(); }
|
||||
});
|
||||
run("blocks can be written and read", new Throwing() {
|
||||
public void go() throws Exception { testBlockWrite(); }
|
||||
});
|
||||
@@ -336,16 +339,14 @@ public final class WorldTest {
|
||||
int height = generator.heightAt(worldX, worldZ);
|
||||
eq("the floor is stone at " + x + "," + z, Byte.valueOf(Blocks.STONE),
|
||||
Byte.valueOf(chunk.block(x, 0, z)));
|
||||
byte expectedTop = generator.isRoad(worldX, worldZ)
|
||||
? Blocks.ASPHALT
|
||||
: (generator.underWater(worldX, worldZ)
|
||||
? Blocks.SAND
|
||||
: (height <= WorldGenerator.SEA_LEVEL + 1
|
||||
? Blocks.SAND : Blocks.GRASS));
|
||||
byte expectedTop = generator.surfaceAt(worldX, worldZ);
|
||||
eq("the top is the ground at " + x + "," + z, Byte.valueOf(expectedTop),
|
||||
Byte.valueOf(chunk.block(x, height, z)));
|
||||
// under the sea the water stands on the ground, above the sea
|
||||
// there is air
|
||||
// there is air, except where a tree grows over the column
|
||||
if (anyTreeOver(generator, worldX, worldZ)) {
|
||||
continue;
|
||||
}
|
||||
byte expectedSky = height < WorldGenerator.SEA_LEVEL
|
||||
? Blocks.WATER : Blocks.AIR;
|
||||
eq("the sky is air or water at " + x + "," + z, Byte.valueOf(expectedSky),
|
||||
@@ -356,6 +357,18 @@ public final class WorldTest {
|
||||
}
|
||||
}
|
||||
|
||||
/** True when a tree stands in the canopy reach of the column. */
|
||||
private static boolean anyTreeOver(WorldGenerator generator, int worldX, int worldZ) {
|
||||
for (int dz = -2; dz <= 2; dz++) {
|
||||
for (int dx = -2; dx <= 2; dx++) {
|
||||
if (generator.treeAt(worldX + dx, worldZ + dz)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void testSurfaceHeight() {
|
||||
WorldGenerator generator = new WorldGenerator(808L);
|
||||
Chunk chunk = new Chunk(new ChunkPos(2, 2));
|
||||
@@ -364,6 +377,14 @@ public final class WorldTest {
|
||||
for (int x = 0; x < Chunk.WIDTH; x++) {
|
||||
int worldX = 2 * Chunk.WIDTH + x;
|
||||
int worldZ = 2 * Chunk.WIDTH + z;
|
||||
if (anyTreeOver(generator, worldX, worldZ)) {
|
||||
// a crown over the column lifts its surface past the
|
||||
// ground, unless the crown is buried in a hillside
|
||||
yes("a tree does not sink the surface at " + worldX + "," + worldZ,
|
||||
chunk.surfaceHeight(x, z) >= generator
|
||||
.heightAt(worldX, worldZ) + 1);
|
||||
continue;
|
||||
}
|
||||
eq("the surface is where the height says at " + x + "," + z,
|
||||
Integer.valueOf(generator.heightAt(worldX, worldZ) + 1),
|
||||
Integer.valueOf(chunk.surfaceHeight(x, z)));
|
||||
@@ -371,6 +392,66 @@ public final class WorldTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static void testIslandAndRoads() {
|
||||
WorldGenerator generator = new WorldGenerator(909L);
|
||||
double tallest = 0.0D;
|
||||
boolean mountain = false;
|
||||
for (int v = -1100; v <= 1100; v += 37) {
|
||||
for (int u = -1100; u <= 1100; u += 53) {
|
||||
double falloff = generator.islandFalloff(u, v);
|
||||
int height = generator.heightAt(u, v);
|
||||
if (falloff > 0.26D) {
|
||||
yes("no lake inland at " + u + "," + v,
|
||||
height >= WorldGenerator.SEA_LEVEL);
|
||||
}
|
||||
if (generator.treeAt(u, v) && falloff < 0.26D) {
|
||||
failed++;
|
||||
failures.add("a tree grew in the water at " + u + "," + v);
|
||||
}
|
||||
if (height > tallest) {
|
||||
tallest = height;
|
||||
}
|
||||
if (height >= 92) {
|
||||
mountain = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
yes("the island is dry where its plains are", tallest > 70.0D);
|
||||
yes("the island carries mountains", mountain);
|
||||
|
||||
// roads ride over the land they cross, level across their width
|
||||
RoadNetwork roads = new RoadNetwork(4000);
|
||||
for (int v = -1200; v <= 1200; v += 111) {
|
||||
for (int u = -1200; u <= 1200; u += 111) {
|
||||
if (!generator.isRoad(u, v)) {
|
||||
continue;
|
||||
}
|
||||
if (roads.onRoad(u, v) && generator.islandFalloff(u, v) < 0.001D) {
|
||||
failed++;
|
||||
failures.add("a road runs in the open sea at " + u + "," + v);
|
||||
}
|
||||
int height = generator.heightAt(u, v);
|
||||
yes("the road holds itself above the sea at " + u + "," + v,
|
||||
height >= WorldGenerator.SEA_LEVEL);
|
||||
// the same road a few blocks across: level with this column
|
||||
int dx = u > 0 ? 3 : -3;
|
||||
int dz = v > 0 ? 3 : -3;
|
||||
boolean sameX = roads.centerLine(u) == roads.centerLine(u + dx);
|
||||
boolean sameZ = roads.centerLine(v) == roads.centerLine(v + dz);
|
||||
if (sameX && generator.isRoad(u + dx, v) && roads.onRoad(u + dx, v)) {
|
||||
eq("the road is level across it at " + u + "," + v,
|
||||
Integer.valueOf(height),
|
||||
Integer.valueOf(generator.heightAt(u + dx, v)));
|
||||
}
|
||||
if (sameZ && generator.isRoad(u, v + dz) && roads.onRoad(u, v + dz)) {
|
||||
eq("and level across it the other way at " + u + "," + v,
|
||||
Integer.valueOf(height),
|
||||
Integer.valueOf(generator.heightAt(u, v + dz)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- storage
|
||||
|
||||
private static void testSaveLoadRoundTrip() throws IOException {
|
||||
|
||||
Reference in New Issue
Block a user