INFINITE TERRAIN!!!!
This commit is contained in:
parent
2503664723
commit
5de6e74323
21 changed files with 648 additions and 64 deletions
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"port": 25565,
|
"port": 25565,
|
||||||
"onlineMode": false,
|
"onlineMode": false,
|
||||||
"maxPlayers": 20
|
"maxPlayers": 20,
|
||||||
|
"seed": "really janky"
|
||||||
}
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
export interface Config {
|
export interface Config {
|
||||||
port: number,
|
port: number,
|
||||||
onlineMode: boolean,
|
onlineMode: boolean,
|
||||||
maxPlayers: number
|
maxPlayers: number,
|
||||||
|
seed: number|string,
|
||||||
}
|
}
|
195
external/OpenSimplex2D.ts
vendored
Normal file
195
external/OpenSimplex2D.ts
vendored
Normal file
|
@ -0,0 +1,195 @@
|
||||||
|
// This is free and unencumbered software released into the public domain
|
||||||
|
|
||||||
|
import shuffleSeed from "./shuffle_seed";
|
||||||
|
|
||||||
|
const NORM_2D = 1.0 / 47.0;
|
||||||
|
const SQUISH_2D = (Math.sqrt(2 + 1) - 1) / 2;
|
||||||
|
const STRETCH_2D = (1 / Math.sqrt(2 + 1) - 1) / 2;
|
||||||
|
|
||||||
|
export type Noise2D = (x: number, y: number) => number;
|
||||||
|
|
||||||
|
interface Contribution2D {
|
||||||
|
dx: number;
|
||||||
|
dy: number;
|
||||||
|
next?: Contribution2D;
|
||||||
|
xsb: number;
|
||||||
|
ysb: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contribution2D(
|
||||||
|
multiplier: number,
|
||||||
|
xsb: number,
|
||||||
|
ysb: number,
|
||||||
|
): Contribution2D {
|
||||||
|
return {
|
||||||
|
dx: -xsb - multiplier * SQUISH_2D,
|
||||||
|
dy: -ysb - multiplier * SQUISH_2D,
|
||||||
|
xsb,
|
||||||
|
ysb,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeNoise2D(clientSeed: number): Noise2D {
|
||||||
|
const contributions: Contribution2D[] = [];
|
||||||
|
for (let i = 0; i < p2D.length; i += 4) {
|
||||||
|
const baseSet = base2D[p2D[i]];
|
||||||
|
let previous: Contribution2D | null = null;
|
||||||
|
let current: Contribution2D | null = null;
|
||||||
|
for (let k = 0; k < baseSet.length; k += 3) {
|
||||||
|
current = contribution2D(baseSet[k], baseSet[k + 1], baseSet[k + 2]);
|
||||||
|
if (previous === null) contributions[i / 4] = current;
|
||||||
|
else previous.next = current;
|
||||||
|
previous = current;
|
||||||
|
}
|
||||||
|
current!.next = contribution2D(p2D[i + 1], p2D[i + 2], p2D[i + 3]);
|
||||||
|
}
|
||||||
|
const lookup: Contribution2D[] = [];
|
||||||
|
for (let i = 0; i < lookupPairs2D.length; i += 2) {
|
||||||
|
lookup[lookupPairs2D[i]] = contributions[lookupPairs2D[i + 1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
const perm = new Uint8Array(256);
|
||||||
|
const perm2D = new Uint8Array(256);
|
||||||
|
const source = new Uint8Array(256);
|
||||||
|
for (let i = 0; i < 256; i++) source[i] = i;
|
||||||
|
let seed = new Uint32Array(1);
|
||||||
|
seed[0] = clientSeed;
|
||||||
|
seed = shuffleSeed(shuffleSeed(shuffleSeed(seed)));
|
||||||
|
for (let i = 255; i >= 0; i--) {
|
||||||
|
seed = shuffleSeed(seed);
|
||||||
|
const r = new Uint32Array(1);
|
||||||
|
r[0] = (seed[0] + 31) % (i + 1);
|
||||||
|
if (r[0] < 0) r[0] += i + 1;
|
||||||
|
perm[i] = source[r[0]];
|
||||||
|
perm2D[i] = perm[i] & 0x0e;
|
||||||
|
source[r[0]] = source[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (x: number, y: number): number => {
|
||||||
|
const stretchOffset = (x + y) * STRETCH_2D;
|
||||||
|
|
||||||
|
const xs = x + stretchOffset;
|
||||||
|
const ys = y + stretchOffset;
|
||||||
|
|
||||||
|
const xsb = Math.floor(xs);
|
||||||
|
const ysb = Math.floor(ys);
|
||||||
|
|
||||||
|
const squishOffset = (xsb + ysb) * SQUISH_2D;
|
||||||
|
|
||||||
|
const dx0 = x - (xsb + squishOffset);
|
||||||
|
const dy0 = y - (ysb + squishOffset);
|
||||||
|
|
||||||
|
const xins = xs - xsb;
|
||||||
|
const yins = ys - ysb;
|
||||||
|
|
||||||
|
const inSum = xins + yins;
|
||||||
|
const hash = (xins - yins + 1) |
|
||||||
|
(inSum << 1) |
|
||||||
|
((inSum + yins) << 2) |
|
||||||
|
((inSum + xins) << 4);
|
||||||
|
|
||||||
|
let value = 0;
|
||||||
|
|
||||||
|
for (
|
||||||
|
let c: Contribution2D | undefined = lookup[hash];
|
||||||
|
c !== undefined;
|
||||||
|
c = c.next
|
||||||
|
) {
|
||||||
|
const dx = dx0 + c.dx;
|
||||||
|
const dy = dy0 + c.dy;
|
||||||
|
|
||||||
|
const attn = 2 - dx * dx - dy * dy;
|
||||||
|
if (attn > 0) {
|
||||||
|
const px = xsb + c.xsb;
|
||||||
|
const py = ysb + c.ysb;
|
||||||
|
|
||||||
|
const indexPartA = perm[px & 0xff];
|
||||||
|
const index = perm2D[(indexPartA + py) & 0xff];
|
||||||
|
|
||||||
|
const valuePart = gradients2D[index] * dx + gradients2D[index + 1] * dy;
|
||||||
|
|
||||||
|
value += attn * attn * attn * attn * valuePart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value * NORM_2D;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const base2D = [
|
||||||
|
[1, 1, 0, 1, 0, 1, 0, 0, 0],
|
||||||
|
[1, 1, 0, 1, 0, 1, 2, 1, 1],
|
||||||
|
];
|
||||||
|
|
||||||
|
const gradients2D = [
|
||||||
|
5,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
5,
|
||||||
|
-5,
|
||||||
|
2,
|
||||||
|
-2,
|
||||||
|
5,
|
||||||
|
5,
|
||||||
|
-2,
|
||||||
|
2,
|
||||||
|
-5,
|
||||||
|
-5,
|
||||||
|
-2,
|
||||||
|
-2,
|
||||||
|
-5,
|
||||||
|
];
|
||||||
|
|
||||||
|
const lookupPairs2D = [
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
4,
|
||||||
|
1,
|
||||||
|
17,
|
||||||
|
0,
|
||||||
|
20,
|
||||||
|
2,
|
||||||
|
21,
|
||||||
|
2,
|
||||||
|
22,
|
||||||
|
5,
|
||||||
|
23,
|
||||||
|
5,
|
||||||
|
26,
|
||||||
|
4,
|
||||||
|
39,
|
||||||
|
3,
|
||||||
|
42,
|
||||||
|
4,
|
||||||
|
43,
|
||||||
|
3,
|
||||||
|
];
|
||||||
|
|
||||||
|
const p2D = [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
-1,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
];
|
8
external/shuffle_seed.ts
vendored
Normal file
8
external/shuffle_seed.ts
vendored
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
// This is free and unencumbered software released into the public domain
|
||||||
|
|
||||||
|
export default function shuffleSeed(seed: Uint32Array): Uint32Array {
|
||||||
|
const newSeed = new Uint32Array(1);
|
||||||
|
newSeed[0] = seed[0] * 1664525 + 1013904223;
|
||||||
|
return newSeed;
|
||||||
|
}
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
|
import { FunkyArray } from "../funkyArray";
|
||||||
import { Block } from "./blocks/Block";
|
import { Block } from "./blocks/Block";
|
||||||
|
import { Player } from "./entities/Player";
|
||||||
import { World } from "./World";
|
import { World } from "./World";
|
||||||
|
|
||||||
export class Chunk {
|
export class Chunk {
|
||||||
private readonly MAX_HEIGHT:number = 128;
|
private readonly MAX_HEIGHT:number = 128;
|
||||||
private readonly world:World;
|
private readonly world:World;
|
||||||
private readonly x:number;
|
public readonly x:number;
|
||||||
private readonly z:number;
|
public readonly z:number;
|
||||||
|
public readonly playersInChunk:FunkyArray<number, Player>;
|
||||||
|
|
||||||
private blocks:Uint8Array;
|
private blocks:Uint8Array;
|
||||||
|
|
||||||
|
@ -17,6 +20,8 @@ export class Chunk {
|
||||||
this.world = world;
|
this.world = world;
|
||||||
this.x = x;
|
this.x = x;
|
||||||
this.z = z;
|
this.z = z;
|
||||||
|
this.playersInChunk = new FunkyArray<number, Player>();
|
||||||
|
|
||||||
this.blocks = new Uint8Array(16 * 16 * this.MAX_HEIGHT);
|
this.blocks = new Uint8Array(16 * 16 * this.MAX_HEIGHT);
|
||||||
|
|
||||||
this.world.generator.generate(this);
|
this.world.generator.generate(this);
|
||||||
|
|
|
@ -1,16 +1,64 @@
|
||||||
import { Socket } from "net";
|
import { Socket } from "net";
|
||||||
import { IEntity } from "./entities/IEntity";
|
import { Reader, Writer } from "../bufferStuff";
|
||||||
import { Writer } from "../bufferStuff";
|
import { Packets } from "./enums/Packets";
|
||||||
|
import { PacketPlayer } from "./packets/Player";
|
||||||
|
import { PacketPlayerPosition } from "./packets/PlayerPosition";
|
||||||
|
import { PacketPlayerLook } from "./packets/PlayerLook";
|
||||||
|
import { PacketPlayerPositionLook } from "./packets/PlayerPositionLook";
|
||||||
|
import { Player } from "./entities/Player";
|
||||||
|
import { PacketChat } from "./packets/Chat";
|
||||||
|
|
||||||
export class MPClient {
|
export class MPClient {
|
||||||
private readonly socket:Socket;
|
private readonly socket:Socket;
|
||||||
private readonly entity:IEntity;
|
public readonly entity:Player;
|
||||||
|
|
||||||
public constructor(socket:Socket, entity:IEntity) {
|
public constructor(socket:Socket, entity:Player) {
|
||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
this.entity = entity;
|
this.entity = entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handlePacket(reader:Reader) {
|
||||||
|
const packetId = reader.readUByte();
|
||||||
|
|
||||||
|
switch (packetId) {
|
||||||
|
case Packets.Chat: this.handleChat(new PacketChat().readData(reader)); break;
|
||||||
|
case Packets.Player: this.handlePacketPlayer(new PacketPlayer().readData(reader)); break;
|
||||||
|
case Packets.PlayerPosition: this.handlePacketPlayerPosition(new PacketPlayerPosition().readData(reader)); break;
|
||||||
|
case Packets.PlayerLook: this.handlePacketPlayerLook(new PacketPlayerLook().readData(reader)); break;
|
||||||
|
case Packets.PlayerPositionLook: this.handlePacketPlayerPositionLook(new PacketPlayerPositionLook().readData(reader)); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleChat(packet:PacketChat) {
|
||||||
|
const message = packet.message.split(" ");
|
||||||
|
if (message[0] === "/tp") {
|
||||||
|
this.send(new PacketPlayerPositionLook(parseFloat(message[1]), parseFloat(message[2]), parseFloat(message[2]) + 0.62, parseFloat(message[3]), 0, 0, false).writeData());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePacketPlayer(packet:PacketPlayer) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePacketPlayerPosition(packet:PacketPlayerPosition) {
|
||||||
|
this.entity.x = packet.x;
|
||||||
|
this.entity.y = packet.y;
|
||||||
|
this.entity.z = packet.z;
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePacketPlayerLook(packet:PacketPlayerLook) {
|
||||||
|
this.entity.yaw = packet.yaw;
|
||||||
|
this.entity.pitch = packet.pitch;
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePacketPlayerPositionLook(packet:PacketPlayerPositionLook) {
|
||||||
|
this.entity.x = packet.x;
|
||||||
|
this.entity.y = packet.y;
|
||||||
|
this.entity.z = packet.z;
|
||||||
|
this.entity.yaw = packet.yaw;
|
||||||
|
this.entity.pitch = packet.pitch;
|
||||||
|
}
|
||||||
|
|
||||||
send(buffer:Buffer|Writer) {
|
send(buffer:Buffer|Writer) {
|
||||||
if (buffer instanceof Writer) {
|
if (buffer instanceof Writer) {
|
||||||
this.socket.write(buffer.toBuffer());
|
this.socket.write(buffer.toBuffer());
|
||||||
|
|
|
@ -17,6 +17,7 @@ import { Chunk } from "./Chunk";
|
||||||
import { PacketMapChunk } from "./packets/MapChunk";
|
import { PacketMapChunk } from "./packets/MapChunk";
|
||||||
import { PacketPlayerPositionLook } from "./packets/PlayerPositionLook";
|
import { PacketPlayerPositionLook } from "./packets/PlayerPositionLook";
|
||||||
import { PacketPreChunk } from "./packets/PreChunk";
|
import { PacketPreChunk } from "./packets/PreChunk";
|
||||||
|
import { PacketChat } from "./packets/Chat";
|
||||||
|
|
||||||
export class MinecraftServer {
|
export class MinecraftServer {
|
||||||
private static readonly PROTOCOL_VERSION = 14;
|
private static readonly PROTOCOL_VERSION = 14;
|
||||||
|
@ -29,25 +30,56 @@ export class MinecraftServer {
|
||||||
private server:Server;
|
private server:Server;
|
||||||
private serverClock:NodeJS.Timer;
|
private serverClock:NodeJS.Timer;
|
||||||
private tickCounter:number = 0;
|
private tickCounter:number = 0;
|
||||||
private clients:FunkyArray<number, MPClient>;
|
private clients:FunkyArray<string, MPClient>;
|
||||||
private worlds:FunkyArray<number, World>;
|
private worlds:FunkyArray<number, World>;
|
||||||
|
private overworld:World;
|
||||||
|
|
||||||
|
// https://stackoverflow.com/a/7616484
|
||||||
|
// Good enough for the world seed.
|
||||||
|
private hashCode(string:string) : number {
|
||||||
|
let hash = 0, i, chr;
|
||||||
|
if (string.length === 0) {
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
for (i = 0; i < string.length; i++) {
|
||||||
|
chr = string.charCodeAt(i);
|
||||||
|
hash = ((hash << 5) - hash) + chr;
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
public constructor(config:Config) {
|
public constructor(config:Config) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
|
||||||
this.clients = new FunkyArray<number, MPClient>();
|
this.clients = new FunkyArray<string, MPClient>();
|
||||||
|
|
||||||
|
// Convert seed if needed
|
||||||
|
const worldSeed = typeof(this.config.seed) === "string" ? this.hashCode(this.config.seed) : this.config.seed;
|
||||||
|
|
||||||
this.worlds = new FunkyArray<number, World>();
|
this.worlds = new FunkyArray<number, World>();
|
||||||
this.worlds.set(0, new World());
|
this.worlds.set(0, this.overworld = new World(worldSeed));
|
||||||
|
|
||||||
|
// Generate spawn area (overworld)
|
||||||
|
const generateStartTime = Date.now();
|
||||||
|
Console.printInfo("[Overworld] Generating spawn area...");
|
||||||
|
let generatedCount = 0;
|
||||||
|
for (let x = -3; x < 3; x++) {
|
||||||
|
for (let z = -3; z < 3; z++) {
|
||||||
|
this.overworld.getChunk(x, z);
|
||||||
|
if (generatedCount++ % 5 === 0) {
|
||||||
|
Console.printInfo(`[Overworld] Generating spawn area... ${Math.floor(generatedCount / 36 * 100)}%`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Console.printInfo(`Done! Took ${Date.now() - generateStartTime}ms`);
|
||||||
|
|
||||||
this.serverClock = setInterval(() => {
|
this.serverClock = setInterval(() => {
|
||||||
// Every 1 sec
|
// Every 1 sec
|
||||||
if (this.tickCounter % MinecraftServer.TICK_RATE === 0) {
|
if (this.tickCounter % MinecraftServer.TICK_RATE === 0) {
|
||||||
if (this.clients.length !== 0) {
|
if (this.clients.length !== 0) {
|
||||||
const timePacket = new PacketTimeUpdate(this.tickCounter).writeData();
|
|
||||||
this.clients.forEach(client => {
|
this.clients.forEach(client => {
|
||||||
client.send(this.keepalivePacket);
|
client.send(this.keepalivePacket);
|
||||||
client.send(timePacket);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -70,11 +102,26 @@ export class MinecraftServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
onConnection(socket:Socket) {
|
onConnection(socket:Socket) {
|
||||||
|
let mpClient:MPClient;
|
||||||
|
|
||||||
|
const playerDisconnect = (err:Error) => {
|
||||||
|
mpClient.entity.world.removeEntity(mpClient.entity);
|
||||||
|
this.clients.remove(mpClient.entity.username);
|
||||||
|
this.sendToAllClients(new PacketChat(`\u00a7e${mpClient.entity.username} left the game`).writeData());
|
||||||
|
}
|
||||||
|
socket.on("close", playerDisconnect.bind(this));
|
||||||
|
socket.on("error", playerDisconnect.bind(this));
|
||||||
|
|
||||||
socket.on("data", chunk => {
|
socket.on("data", chunk => {
|
||||||
const reader = new Reader(chunk);
|
const reader = new Reader(chunk);
|
||||||
|
|
||||||
|
// Let mpClient take over if it exists
|
||||||
|
if (mpClient instanceof MPClient) {
|
||||||
|
mpClient.handlePacket(reader);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const packetId = reader.readUByte();
|
const packetId = reader.readUByte();
|
||||||
//console.log(packetId);
|
|
||||||
switch (packetId) {
|
switch (packetId) {
|
||||||
// Handle timeouts at some point, idk.
|
// Handle timeouts at some point, idk.
|
||||||
case Packets.KeepAlive:
|
case Packets.KeepAlive:
|
||||||
|
@ -95,20 +142,17 @@ export class MinecraftServer {
|
||||||
if (world instanceof World) {
|
if (world instanceof World) {
|
||||||
const clientEntity = new Player(this, world, loginPacket.username);
|
const clientEntity = new Player(this, world, loginPacket.username);
|
||||||
world.addEntity(clientEntity);
|
world.addEntity(clientEntity);
|
||||||
socket.write(new PacketLoginRequest(clientEntity.entityId, "", 0, -1).writeData());
|
|
||||||
|
const client = mpClient = new MPClient(socket, clientEntity);
|
||||||
|
clientEntity.mpClient = client;
|
||||||
|
this.clients.set(loginPacket.username, client);
|
||||||
|
|
||||||
|
this.sendToAllClients(new PacketChat(`\u00a7e${loginPacket.username} joined the game`).writeData());
|
||||||
|
|
||||||
|
socket.write(new PacketLoginRequest(clientEntity.entityId, "", 0, 0).writeData());
|
||||||
socket.write(new PacketSpawnPosition(8, 64, 8).writeData());
|
socket.write(new PacketSpawnPosition(8, 64, 8).writeData());
|
||||||
|
|
||||||
socket.write(new PacketPreChunk(0, 0, true).writeData());
|
socket.write(new PacketPlayerPositionLook(8, 70, 70.62, 8, 0, 0, false).writeData());
|
||||||
const chunk = world.getChunk(0, 0);
|
|
||||||
if (chunk instanceof Chunk) {
|
|
||||||
(async () => {
|
|
||||||
const chunkData = await (new PacketMapChunk(0, 0, 0, 15, 127, 15, chunk).writeData());
|
|
||||||
socket.write(chunkData);
|
|
||||||
socket.write(new PacketPlayerPositionLook(8, 66, 66.62, 8, 0, 0, false).writeData());
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
const client = new MPClient(socket, clientEntity);
|
|
||||||
this.clients.set(this.totalClients++, client);
|
|
||||||
} else {
|
} else {
|
||||||
socket.write(new PacketDisconnectKick("Failed to find world to put player in.").writeData());
|
socket.write(new PacketDisconnectKick("Failed to find world to put player in.").writeData());
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
|
import { Console } from "../console";
|
||||||
import { FunkyArray } from "../funkyArray";
|
import { FunkyArray } from "../funkyArray";
|
||||||
import { Chunk } from "./Chunk";
|
import { Chunk } from "./Chunk";
|
||||||
import { IEntity } from "./entities/IEntity";
|
import { IEntity } from "./entities/IEntity";
|
||||||
|
import { Player } from "./entities/Player";
|
||||||
import { FlatGenerator } from "./generators/Flat";
|
import { FlatGenerator } from "./generators/Flat";
|
||||||
|
import { HillyGenerator } from "./generators/Hilly";
|
||||||
import { IGenerator } from "./generators/IGenerator";
|
import { IGenerator } from "./generators/IGenerator";
|
||||||
|
|
||||||
export class World {
|
export class World {
|
||||||
|
@ -10,30 +13,78 @@ export class World {
|
||||||
|
|
||||||
public generator:IGenerator;
|
public generator:IGenerator;
|
||||||
|
|
||||||
public constructor() {
|
public constructor(seed:number) {
|
||||||
this.chunks = new FunkyArray<number, Chunk>();
|
this.chunks = new FunkyArray<number, Chunk>();
|
||||||
this.entites = new FunkyArray<number, IEntity>();
|
this.entites = new FunkyArray<number, IEntity>();
|
||||||
this.generator = new FlatGenerator();
|
this.generator = new HillyGenerator(seed);
|
||||||
this.chunks.set(Chunk.CreateCoordPair(0, 0), new Chunk(this, 0, 0));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public addEntity(entity:IEntity) {
|
public addEntity(entity:IEntity) {
|
||||||
this.entites.set(entity.entityId, entity);
|
this.entites.set(entity.entityId, entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeEntity(entity:IEntity|number) {
|
// TODO: getChunkByCoordPair failed in here during removeEntity, figure out why.
|
||||||
if (typeof(entity) === "number") {
|
public removeEntity(entity:IEntity) {
|
||||||
return this.entites.remove(entity);
|
if (entity instanceof Player) {
|
||||||
|
for (let coordPair of entity.loadedChunks) {
|
||||||
|
const chunk = this.getChunkByCoordPair(coordPair);
|
||||||
|
chunk.playersInChunk.remove(entity.entityId);
|
||||||
|
|
||||||
|
if (chunk.playersInChunk.length === 0) {
|
||||||
|
this.unloadChunk(coordPair);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.entites.remove(entity.entityId);
|
this.entites.remove(entity.entityId);
|
||||||
|
// TODO: Inform clients about entity removal
|
||||||
}
|
}
|
||||||
|
|
||||||
public getChunk(x:number, z:number) {
|
public getChunk(x:number, z:number, generate:boolean = true) {
|
||||||
return this.chunks.get(Chunk.CreateCoordPair(x, z));
|
const coordPair = Chunk.CreateCoordPair(x, z);
|
||||||
|
const existingChunk = this.chunks.get(coordPair);
|
||||||
|
if (!(existingChunk instanceof Chunk)) {
|
||||||
|
if (generate) {
|
||||||
|
return this.chunks.set(coordPair, new Chunk(this, x, z));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`BADLOOKUP: Chunk [${x}, ${z}] does not exist.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return existingChunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getChunkByCoordPair(coordPair:number) {
|
||||||
|
const existingChunk = this.chunks.get(coordPair);
|
||||||
|
if (!(existingChunk instanceof Chunk)) {
|
||||||
|
throw new Error(`BADLOOKUP: Chunk ${coordPair} does not exist.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return existingChunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
public unloadChunk(coordPair:number) {
|
||||||
|
// TODO: Save to disk
|
||||||
|
this.chunks.remove(coordPair);
|
||||||
}
|
}
|
||||||
|
|
||||||
public tick(tickCount:number) {
|
public tick(tickCount:number) {
|
||||||
|
this.entites.forEach(entity => {
|
||||||
|
if (entity instanceof Player) {
|
||||||
|
if (entity.justUnloaded.length > 0) {
|
||||||
|
for (let coordPair of entity.justUnloaded) {
|
||||||
|
const chunkToUnload = this.getChunkByCoordPair(coordPair);
|
||||||
|
chunkToUnload.playersInChunk.remove(entity.entityId);
|
||||||
|
if (chunkToUnload.playersInChunk.length === 0) {
|
||||||
|
this.unloadChunk(coordPair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.justUnloaded = new Array<number>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.onTick();
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -12,4 +12,15 @@ export class Block {
|
||||||
|
|
||||||
|
|
||||||
static bedrock = new Block(7);
|
static bedrock = new Block(7);
|
||||||
|
|
||||||
|
static waterStill = new Block(9);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
static wood = new Block(17);
|
||||||
|
static leaves = new Block(18);
|
||||||
}
|
}
|
|
@ -20,4 +20,10 @@ export class Entity implements IEntity {
|
||||||
this.world = world;
|
this.world = world;
|
||||||
this.x = this.y = this.z = this.lastX = this.lastY = this.lastZ = 0;
|
this.x = this.y = this.z = this.lastX = this.lastY = this.lastZ = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onTick() {
|
||||||
|
this.lastX = this.x;
|
||||||
|
this.lastY = this.y;
|
||||||
|
this.lastZ = this.z;
|
||||||
|
}
|
||||||
}
|
}
|
18
server/entities/EntityLiving.ts
Normal file
18
server/entities/EntityLiving.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import { World } from "../World";
|
||||||
|
import { Entity } from "./Entity";
|
||||||
|
|
||||||
|
export class EntityLiving extends Entity {
|
||||||
|
public yaw:number;
|
||||||
|
public pitch:number;
|
||||||
|
|
||||||
|
public constructor(world:World) {
|
||||||
|
super(world);
|
||||||
|
|
||||||
|
this.yaw = 0;
|
||||||
|
this.pitch = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
onTick() {
|
||||||
|
super.onTick();
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,10 @@
|
||||||
export interface IEntity {
|
export interface IEntity {
|
||||||
entityId:number
|
entityId:number,
|
||||||
|
x:number,
|
||||||
|
y:number,
|
||||||
|
z:number,
|
||||||
|
lastX:number,
|
||||||
|
lastY:number,
|
||||||
|
lastZ:number,
|
||||||
|
onTick:() => void
|
||||||
}
|
}
|
|
@ -1,18 +1,81 @@
|
||||||
|
import { FunkyArray } from "../../funkyArray";
|
||||||
|
import { Chunk } from "../Chunk";
|
||||||
|
import { MPClient } from "../MPClient";
|
||||||
import { MinecraftServer } from "../MinecraftServer";
|
import { MinecraftServer } from "../MinecraftServer";
|
||||||
import { World } from "../World";
|
import { World } from "../World";
|
||||||
|
import { PacketMapChunk } from "../packets/MapChunk";
|
||||||
|
import { EntityLiving } from "./EntityLiving";
|
||||||
import { Entity } from "./Entity";
|
import { Entity } from "./Entity";
|
||||||
|
import { Socket } from "net";
|
||||||
|
import { PacketPreChunk } from "../packets/PreChunk";
|
||||||
|
|
||||||
export class Player extends Entity {
|
export class Player extends EntityLiving {
|
||||||
public username:string;
|
public username:string;
|
||||||
private server:MinecraftServer;
|
private server:MinecraftServer;
|
||||||
|
private firstUpdate:boolean;
|
||||||
|
public loadedChunks:Array<number>;
|
||||||
|
public justUnloaded:Array<number>;
|
||||||
|
public mpClient?:MPClient;
|
||||||
|
|
||||||
public constructor(server:MinecraftServer, world:World, username:string) {
|
public constructor(server:MinecraftServer, world:World, username:string) {
|
||||||
super(world);
|
super(world);
|
||||||
this.server = server;
|
this.server = server;
|
||||||
|
this.firstUpdate = true;
|
||||||
|
this.loadedChunks = new Array<number>();
|
||||||
|
this.justUnloaded = new Array<number>();
|
||||||
|
|
||||||
this.username = username;
|
this.username = username;
|
||||||
this.x = 8;
|
this.x = 8;
|
||||||
this.y = 64;
|
this.y = 64;
|
||||||
this.z = 8;
|
this.z = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onTick() {
|
||||||
|
const bitX = this.x >> 4;
|
||||||
|
const bitZ = this.z >> 4;
|
||||||
|
if (bitX != this.lastX >> 4 || bitZ != this.lastZ >> 4 || this.firstUpdate) {
|
||||||
|
if (this.firstUpdate) {
|
||||||
|
this.firstUpdate = false;
|
||||||
|
this.mpClient?.send(new PacketPreChunk(0, 0, true).writeData());
|
||||||
|
const chunk = this.world.getChunk(0, 0);
|
||||||
|
(async () => {
|
||||||
|
const chunkData = await (new PacketMapChunk(0, 0, 0, 15, 127, 15, chunk).writeData());
|
||||||
|
this.mpClient?.send(chunkData);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load or keep any chunks we need
|
||||||
|
const currentLoads = [];
|
||||||
|
for (let x = bitX - 6; x < bitX + 6; x++) {
|
||||||
|
for (let z = bitZ - 6; z < bitZ + 6; z++) {
|
||||||
|
const coordPair = Chunk.CreateCoordPair(x, z);
|
||||||
|
if (!this.loadedChunks.includes(coordPair)) {
|
||||||
|
const chunk = this.world.getChunk(x, z);
|
||||||
|
this.mpClient?.send(new PacketPreChunk(x, z, true).writeData());
|
||||||
|
this.loadedChunks.push(coordPair);
|
||||||
|
chunk.playersInChunk.set(this.entityId, this);
|
||||||
|
(async () => {
|
||||||
|
const chunkData = await (new PacketMapChunk(x, 0, z, 15, 127, 15, chunk).writeData());
|
||||||
|
this.mpClient?.send(chunkData);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
currentLoads.push(coordPair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark any unaccounted chunks for unload
|
||||||
|
for (let coordPair of this.loadedChunks) {
|
||||||
|
if (!currentLoads.includes(coordPair)) {
|
||||||
|
this.justUnloaded.push(coordPair);
|
||||||
|
const chunkToUnload = this.world.getChunkByCoordPair(coordPair);
|
||||||
|
this.mpClient?.send(new PacketPreChunk(chunkToUnload.x, chunkToUnload.z, false).writeData());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overwrite loaded chunks
|
||||||
|
this.loadedChunks = currentLoads;
|
||||||
|
}
|
||||||
|
|
||||||
|
super.onTick();
|
||||||
|
}
|
||||||
}
|
}
|
94
server/generators/Hilly.ts
Normal file
94
server/generators/Hilly.ts
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import { Block } from "../blocks/Block";
|
||||||
|
import { Chunk } from "../Chunk";
|
||||||
|
import { IGenerator } from "./IGenerator";
|
||||||
|
import { Noise2D, makeNoise2D } from "../../external/OpenSimplex2D";
|
||||||
|
import shuffle_seed from "../../external/shuffle_seed";
|
||||||
|
|
||||||
|
export class HillyGenerator implements IGenerator {
|
||||||
|
private seed:number;
|
||||||
|
private generator:Noise2D;
|
||||||
|
private generator1:Noise2D;
|
||||||
|
private generator2:Noise2D;
|
||||||
|
private generator3:Noise2D;
|
||||||
|
private generator4:Noise2D;
|
||||||
|
private generator5:Noise2D;
|
||||||
|
private generator6:Noise2D;
|
||||||
|
private oceanGenerator:Noise2D;
|
||||||
|
private mountainGenerator:Noise2D;
|
||||||
|
|
||||||
|
public constructor(seed:number) {
|
||||||
|
this.seed = seed;
|
||||||
|
|
||||||
|
const generatorSeed = this.mulberry32(this.seed);
|
||||||
|
this.generator = makeNoise2D(generatorSeed() * Number.MAX_SAFE_INTEGER);
|
||||||
|
this.generator1 = makeNoise2D(generatorSeed() * Number.MAX_SAFE_INTEGER);
|
||||||
|
this.generator2 = makeNoise2D(generatorSeed() * Number.MAX_SAFE_INTEGER);
|
||||||
|
this.generator3 = makeNoise2D(generatorSeed() * Number.MAX_SAFE_INTEGER);
|
||||||
|
this.generator4 = makeNoise2D(generatorSeed() * Number.MAX_SAFE_INTEGER);
|
||||||
|
this.generator5 = makeNoise2D(generatorSeed() * Number.MAX_SAFE_INTEGER);
|
||||||
|
this.generator6 = makeNoise2D(generatorSeed() * Number.MAX_SAFE_INTEGER);
|
||||||
|
this.oceanGenerator = makeNoise2D(generatorSeed() * Number.MAX_SAFE_INTEGER);
|
||||||
|
this.mountainGenerator = makeNoise2D(generatorSeed() * Number.MAX_SAFE_INTEGER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://stackoverflow.com/a/47593316
|
||||||
|
// This is good enough (and fast enough) for what is needed here.
|
||||||
|
private mulberry32(a:number) {
|
||||||
|
return function() {
|
||||||
|
var t = a += 0x6D2B79F5;
|
||||||
|
t = Math.imul(t ^ t >>> 15, t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
||||||
|
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public generate(chunk:Chunk) {
|
||||||
|
const treeRNG = this.mulberry32(this.seed + chunk.x + chunk.z);
|
||||||
|
let colY = 0, colDirtMin = 0, colWaterY = 0, orgColY = 0;
|
||||||
|
for (let x = 0; x < 16; x++) {
|
||||||
|
for (let z = 0; z < 16; z++) {
|
||||||
|
const oceanValue = this.oceanGenerator((chunk.x * 16 + x) / 128, (chunk.z * 16 + z) / 128) * 100;
|
||||||
|
orgColY = colWaterY = colY = 60 + (
|
||||||
|
this.generator((chunk.x * 16 + x) / 16, (chunk.z * 16 + z) / 16) * 16 +
|
||||||
|
this.generator1((chunk.x * 16 + x) / 16, (chunk.z * 16 + z) / 16) * 16 +
|
||||||
|
this.generator2((chunk.x * 16 + x) / 8, (chunk.z * 16 + z) / 8) * 8 +
|
||||||
|
this.generator3((chunk.x * 16 + x) / 4, (chunk.z * 16 + z) / 4) * 4 +
|
||||||
|
this.generator4((chunk.x * 16 + x) / 4, (chunk.z * 16 + z) / 4) * 4 +
|
||||||
|
this.generator5((chunk.x * 16 + x) / 10, (chunk.z * 16 + z) / 10) * 10 +
|
||||||
|
this.generator6((chunk.x * 16 + x) / 16, (chunk.z * 16 + z) / 16) * 16 +
|
||||||
|
oceanValue +
|
||||||
|
(Math.max(this.mountainGenerator((chunk.x * 16 + x) / 128, (chunk.z * 16 + z) / 128), 0) * 50 + Math.min(oceanValue, 0))
|
||||||
|
) / 9;
|
||||||
|
colDirtMin = colY - 2;
|
||||||
|
chunk.setBlock(Block.grass.blockId, x, colY, z);
|
||||||
|
|
||||||
|
while (colY-- > 0) {
|
||||||
|
if (colY >= colDirtMin) {
|
||||||
|
chunk.setBlock(Block.dirt.blockId, x, colY, z);
|
||||||
|
} else if (colY === 0) {
|
||||||
|
chunk.setBlock(Block.bedrock.blockId, x, colY, z);
|
||||||
|
} else {
|
||||||
|
chunk.setBlock(Block.stone.blockId, x, colY, z);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (colWaterY <= 58) {
|
||||||
|
chunk.setBlock(Block.dirt.blockId, x, colWaterY, z);
|
||||||
|
}
|
||||||
|
while (colWaterY <= 58) {
|
||||||
|
colWaterY++;
|
||||||
|
chunk.setBlock(Block.waterStill.blockId, x, colWaterY, z);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunk.getBlockId(x, orgColY + 1, z) !== Block.waterStill.blockId && chunk.getBlockId(x, orgColY, z) === Block.grass.blockId && treeRNG() > 0.995) {
|
||||||
|
chunk.setBlock(Block.dirt.blockId, x, orgColY, z);
|
||||||
|
let tY = orgColY + 1;
|
||||||
|
while (tY < orgColY + 5) {
|
||||||
|
chunk.setBlock(Block.wood.blockId, x, tY, z);
|
||||||
|
tY++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,8 +6,12 @@ export class PacketChat implements IPacket {
|
||||||
public packetId = Packets.Chat;
|
public packetId = Packets.Chat;
|
||||||
public message:string;
|
public message:string;
|
||||||
|
|
||||||
public constructor(message:string) {
|
public constructor(message?:string) {
|
||||||
this.message = message;
|
if (typeof(message) === "string") {
|
||||||
|
this.message = message;
|
||||||
|
} else {
|
||||||
|
this.message = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public readData(reader:Reader) {
|
public readData(reader:Reader) {
|
||||||
|
|
|
@ -61,7 +61,7 @@ export class PacketMapChunk implements IPacket {
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(new Writer(18).writeUByte(this.packetId).writeInt(this.x).writeShort(this.y).writeInt(this.z).writeUByte(this.sizeX).writeUByte(this.sizeY).writeUByte(this.sizeZ).writeInt(data.length).writeBuffer(data).toBuffer());
|
resolve(new Writer(18).writeUByte(this.packetId).writeInt(this.x << 4).writeShort(this.y).writeInt(this.z << 4).writeUByte(this.sizeX).writeUByte(this.sizeY).writeUByte(this.sizeZ).writeInt(data.length).writeBuffer(data).toBuffer());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,8 +6,12 @@ export class PacketPlayer implements IPacket {
|
||||||
public packetId = Packets.Player;
|
public packetId = Packets.Player;
|
||||||
public onGround:boolean;
|
public onGround:boolean;
|
||||||
|
|
||||||
public constructor(onGround:boolean = false) {
|
public constructor(onGround?:boolean) {
|
||||||
this.onGround = onGround;
|
if (typeof(onGround) === "boolean") {
|
||||||
|
this.onGround = onGround;
|
||||||
|
} else {
|
||||||
|
this.onGround = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public readData(reader:Reader) {
|
public readData(reader:Reader) {
|
||||||
|
|
|
@ -8,10 +8,16 @@ export class PacketPlayerLook implements IPacket {
|
||||||
public pitch:number;
|
public pitch:number;
|
||||||
public onGround:boolean;
|
public onGround:boolean;
|
||||||
|
|
||||||
public constructor(yaw:number, pitch:number, onGround:boolean = false) {
|
public constructor(yaw?:number, pitch?:number, onGround?:boolean) {
|
||||||
this.yaw = yaw;
|
if (typeof(yaw) === "number" && typeof(pitch) === "number" && typeof(onGround) === "boolean") {
|
||||||
this.pitch = pitch;
|
this.yaw = yaw;
|
||||||
this.onGround = onGround;
|
this.pitch = pitch;
|
||||||
|
this.onGround = onGround;
|
||||||
|
} else {
|
||||||
|
this.yaw = Number.MIN_VALUE;
|
||||||
|
this.pitch = Number.MIN_VALUE;
|
||||||
|
this.onGround = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public readData(reader:Reader) {
|
public readData(reader:Reader) {
|
||||||
|
|
|
@ -10,12 +10,20 @@ export class PacketPlayerPosition implements IPacket {
|
||||||
public z:number;
|
public z:number;
|
||||||
public onGround:boolean;
|
public onGround:boolean;
|
||||||
|
|
||||||
public constructor(x:number, y:number, stance:number, z:number, onGround:boolean = false) {
|
public constructor(x?:number, y?:number, stance?:number, z?:number, onGround?:boolean) {
|
||||||
this.x = x;
|
if (typeof(x) === "number" && typeof(y) === "number" && typeof(stance) === "number" && typeof(z) === "number" && typeof(onGround) === "boolean") {
|
||||||
this.y = y;
|
this.x = x;
|
||||||
this.stance = stance;
|
this.y = y;
|
||||||
this.z = z;
|
this.stance = stance;
|
||||||
this.onGround = onGround;
|
this.z = z;
|
||||||
|
this.onGround = onGround;
|
||||||
|
} else {
|
||||||
|
this.x = Number.MIN_VALUE;
|
||||||
|
this.y = Number.MIN_VALUE;
|
||||||
|
this.stance = Number.MIN_VALUE;
|
||||||
|
this.z = Number.MIN_VALUE;
|
||||||
|
this.onGround = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public readData(reader:Reader) {
|
public readData(reader:Reader) {
|
||||||
|
|
|
@ -12,14 +12,24 @@ export class PacketPlayerPositionLook implements IPacket {
|
||||||
public pitch:number;
|
public pitch:number;
|
||||||
public onGround:boolean;
|
public onGround:boolean;
|
||||||
|
|
||||||
public constructor(x:number, y:number, stance:number, z:number, yaw:number, pitch:number, onGround:boolean = false) {
|
public constructor(x?:number, y?:number, stance?:number, z?:number, yaw?:number, pitch?:number, onGround?:boolean) {
|
||||||
this.x = x;
|
if (typeof(x) === "number" && typeof(y) === "number" && typeof(stance) === "number" && typeof(z) === "number" && typeof(yaw) === "number" && typeof(pitch) === "number" && typeof(onGround) === "boolean") {
|
||||||
this.y = y;
|
this.x = x;
|
||||||
this.stance = stance;
|
this.y = y;
|
||||||
this.z = z;
|
this.stance = stance;
|
||||||
this.yaw = yaw;
|
this.z = z;
|
||||||
this.pitch = pitch;
|
this.yaw = yaw;
|
||||||
this.onGround = onGround;
|
this.pitch = pitch;
|
||||||
|
this.onGround = onGround;
|
||||||
|
} else {
|
||||||
|
this.x = Number.MIN_VALUE;
|
||||||
|
this.y = Number.MIN_VALUE;
|
||||||
|
this.stance = Number.MIN_VALUE;
|
||||||
|
this.z = Number.MIN_VALUE;
|
||||||
|
this.yaw = Number.MIN_VALUE;
|
||||||
|
this.pitch = Number.MIN_VALUE;
|
||||||
|
this.onGround = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public readData(reader:Reader) {
|
public readData(reader:Reader) {
|
||||||
|
|
|
@ -4,14 +4,14 @@ import { IPacket } from "./IPacket";
|
||||||
|
|
||||||
export class PacketTimeUpdate implements IPacket {
|
export class PacketTimeUpdate implements IPacket {
|
||||||
public packetId = Packets.TimeUpdate;
|
public packetId = Packets.TimeUpdate;
|
||||||
public time:number;
|
public time:bigint;
|
||||||
|
|
||||||
public constructor(time:number) {
|
public constructor(time:bigint) {
|
||||||
this.time = time;
|
this.time = time;
|
||||||
}
|
}
|
||||||
|
|
||||||
public readData(reader:Reader) {
|
public readData(reader:Reader) {
|
||||||
this.time = Number(reader.readLong());
|
this.time = reader.readLong();
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue