silphius/app/server/engine.js
2024-09-13 17:54:49 -05:00

615 lines
17 KiB
JavaScript

import {LRUCache} from 'lru-cache';
import Ecs from '@/ecs/ecs.js';
import {decode, encode} from '@/net/packets/index.js';
import {
CHUNK_SIZE,
RESOLUTION,
TPS,
UPS,
} from '@/util/constants.js';
import {withResolvers} from '@/util/promise.js';
import createEcs from './create/ecs.js';
import createForest from './create/forest.js';
import createHomestead from './create/homestead.js';
import createHouse from './create/house.js';
import createPlayer from './create/player.js';
import createTown from './create/town.js';
const UPS_PER_S = 1 / UPS;
const cache = new LRUCache({
max: 128,
});
const textEncoder = new TextEncoder();
export default class Engine {
ackingActions = new Map();
connectedPlayers = new Map();
ecses = {};
frame = 0;
handle;
incomingActions = new Map();
last;
server;
updateElapsed = 0;
constructor(Server) {
this.ecses = {};
class SilphiusServer extends Server {
accept(connection, packed) {
super.accept(connection, decode(packed));
}
transmit(connection, packet) {
super.transmit(connection, encode(packet));
}
}
const engine = this;
const server = this.server = new SilphiusServer();
this.Ecs = class EngineEcs extends Ecs {
get frame() {
return engine.frame;
}
lookupPlayerEntity(id) {
return engine.lookupPlayerEntity(id);
}
async readAsset(uri) {
if (!cache.has(uri)) {
const {promise, resolve, reject} = withResolvers();
cache.set(uri, promise);
server.readAsset(uri)
.then(resolve)
.catch(reject);
}
return cache.get(uri);
}
async switchEcs(entity, path, updates) {
for (const [connection, connectedPlayer] of engine.connectedPlayers) {
if (entity !== connectedPlayer.entity) {
continue;
}
const {id} = entity.Player;
// remove entity link to connection to start queueing actions and pause updates
delete connectedPlayer.entity;
// forget previous state
connectedPlayer.memory = {
chunks: new Map(),
nearby: new Set(),
};
// inform client of the upcoming change
server.send(
connection,
{
type: 'EcsChange',
payload: {},
},
);
// dump entity state with updates for the transition
const dumped = {
...entity.toJSON(),
// manually transfer control
Controlled: {
moveUp: entity.Controlled.moveUp,
moveRight: entity.Controlled.moveRight,
moveDown: entity.Controlled.moveDown,
moveLeft: entity.Controlled.moveLeft,
},
Ecs: {path},
...updates,
};
const promises = [];
// load if necessary
if (!engine.ecses[path]) {
promises.push(engine.loadEcs(path));
}
// remove from old ECS
promises.push(this.destroy(entity.id));
Promise.all(promises).then(async () => {
// recreate the entity in the new ECS and again associate it with the connection
connectedPlayer.entity = engine.ecses[path].get(await engine.ecses[path].create(dumped));
connectedPlayer.entity.Player.id = id;
});
}
}
}
this.server.addPacketListener('Action', (connection, payload) => {
if (!this.incomingActions.has(connection)) {
this.incomingActions.set(connection, []);
}
this.incomingActions.get(connection).push(payload);
});
this.server.addPacketListener('AdminAction', (connection, payload) => {
// check...
if (!this.incomingActions.has(connection)) {
this.incomingActions.set(connection, []);
}
this.incomingActions.get(connection).push(payload);
});
this.server.addPacketListener('Heartbeat', (connection) => {
const playerData = this.connectedPlayers.get(connection);
const {distance} = playerData;
const now = performance.now();
distance.rtt = (now - distance.last) / 1000;
playerData.heartbeat = setTimeout(() => {
distance.last = performance.now();
this.server.send(
connection,
{
type: 'Heartbeat',
payload: {
rtt: distance.rtt,
},
},
);
}, 1000);
});
}
acceptActions() {
for (const [connection, payloads] of this.incomingActions) {
if (!this.connectedPlayers.get(connection)) {
continue;
}
const {entity} = this.connectedPlayers.get(connection);
if (!entity) {
continue;
}
const {
Controlled,
Ecs,
Interacts,
Interlocutor,
Inventory,
Wielder,
} = entity;
const ecs = this.ecses[Ecs.path];
for (const payload of payloads) {
switch (payload.type) {
case 'chat': {
if (payload.value.startsWith('/')) {
const [command, ...args] = payload.value.slice(1).split(' ');
switch (command) {
case 'dump': {
switch (args[0]) {
case 'tiles': {
const {TileLayers} = ecs.get(1);
this.server.send(
connection,
{
type: 'Download',
payload: {
data: TileLayers.layer(0).data,
filename: 'tiles.json',
},
},
);
break;
}
}
break;
}
}
break;
}
Interlocutor.dialogue({
body: payload.value,
linger: 5,
offset: {x: 0, y: -24},
origin: 'track',
position: 'track',
});
break;
}
case 'paint': {
const {TileLayers} = ecs.get(1);
const {brush, layer: paintLayer, stamp} = payload.value;
const layer = TileLayers.layer(paintLayer);
switch (brush) {
case 0: {
layer.stamp(stamp.at, stamp.data)
break;
}
}
break;
}
case 'changeSlot': {
if (!Controlled.locked) {
Wielder.activeSlot = payload.value - 1;
}
break;
}
case 'moveUp':
case 'moveRight':
case 'moveDown':
case 'moveLeft': {
Controlled[payload.type] = payload.value;
break;
}
case 'swapSlots': {
if (!Controlled.locked) {
const [l, other, r] = payload.value;
const {Inventory: OtherInventory} = ecs.get(other);
if (OtherInventory) {
Inventory.swapSlots(l, OtherInventory, r);
}
}
break;
}
case 'use': {
if (!Controlled.locked) {
Wielder.useActiveItem(payload.value);
}
break;
}
case 'interact': {
if (!Controlled.locked) {
if (payload.value) {
if (Interacts.willInteractWith) {
const subject = ecs.get(Interacts.willInteractWith);
subject.Interactive.interact(entity);
}
}
}
break;
}
}
if (payload.ack) {
if (!this.ackingActions.has(connection)) {
this.ackingActions.set(connection, []);
}
this.ackingActions.get(connection).push({
type: 'ActionAck',
payload: {
ack: payload.ack,
},
})
this.server.send(
connection,
{
type: 'ActionAck',
payload: {
ack: payload.ack,
},
},
);
}
}
this.incomingActions.set(connection, []);
}
}
async connectPlayer(connection, id) {
const entityJson = await this.loadPlayer(id);
if (!this.ecses[entityJson.Ecs.path]) {
await this.loadEcs(entityJson.Ecs.path);
}
const ecs = this.ecses[entityJson.Ecs.path];
const entity = ecs.get(await ecs.create(entityJson));
entity.Player.id = id;
const playerData = {
distance: {last: performance.now(), rtt: 0},
entity,
id,
memory: {
chunks: new Map(),
nearby: new Set(),
},
};
this.server.send(
connection,
{
type: 'Heartbeat',
payload: {
rtt: 0,
},
},
);
this.connectedPlayers.set(connection, playerData);
}
createEcs() {
return createEcs(this.Ecs);
}
async disconnectPlayer(connection) {
const connectedPlayer = this.connectedPlayers.get(connection);
if (!connectedPlayer) {
return;
}
this.ackingActions.delete(connection);
this.connectedPlayers.delete(connection);
this.incomingActions.delete(connection);
const {entity, heartbeat, id} = connectedPlayer;
clearTimeout(heartbeat);
const json = entity.toJSON();
const ecs = this.ecses[entity.Ecs.path];
return Promise.all([
ecs.destroy(entity.id),
this.savePlayer(id, json),
]);
}
async load() {
let townData;
try {
townData = await this.server.readData('town');
}
catch (error) {
if ('ENOENT' !== error.code) {
throw error;
}
const town = this.createEcs();
for (const entity of await createTown()) {
await town.create(entity);
}
await this.saveEcs('town', town);
townData = await this.server.readData('town');
}
this.ecses['town'] = await this.Ecs.deserialize(
this.createEcs(),
townData,
);
}
async loadEcs(path) {
this.ecses[path] = await this.Ecs.deserialize(
this.createEcs(),
await this.server.readData(path),
);
}
async loadPlayer(id) {
let player;
try {
player = await this.server.readJson(['players', `${id}`].join('/'))
}
catch (error) {
if ('ENOENT' !== error.code) {
throw error;
}
const homestead = this.createEcs();
for (const entity of await createHomestead(id)) {
await homestead.create(entity);
}
await this.saveEcs(
['homesteads', `${id}`].join('/'),
homestead,
);
const house = await createHouse(this.Ecs, id);
await this.saveEcs(
['houses', `${id}`].join('/'),
house,
);
const forest = this.createEcs();
for (const entity of await createForest()) {
await forest.create(entity);
}
await this.saveEcs(
['forests', `${id}`].join('/'),
forest,
);
player = await createPlayer(id);
await this.savePlayer(id, player);
}
return player;
}
lookupPlayerEntity(id) {
for (const [, player] of this.connectedPlayers) {
if (player.id == id) {
return player.entity;
}
}
}
async saveEcs(path, ecs) {
const view = this.Ecs.serialize(ecs);
await this.server.writeData(path, view);
}
async saveEcses() {
const promises = []
for (const i in this.ecses) {
promises.push(this.saveEcs(i, this.ecses[i]));
}
await Promise.all(promises);
}
async savePlayer(id, entity) {
const buffer = textEncoder.encode(JSON.stringify(entity));
await this.server.writeData(['players', `${id}`].join('/'), buffer);
}
setClean() {
for (const i in this.ecses) {
this.ecses[i].setClean();
}
}
start() {
this.last = performance.now() / 1000;
const loop = async () => {
const now = performance.now() / 1000;
const elapsed = now - this.last;
this.updateElapsed += elapsed;
this.last = now;
this.acceptActions();
this.tick(elapsed);
if (this.updateElapsed >= UPS_PER_S) {
this.update(this.updateElapsed);
this.setClean();
this.frame += 1;
this.updateElapsed = this.updateElapsed % UPS_PER_S;
}
this.handle = setTimeout(loop, 1000 / TPS);
};
loop();
}
stop() {
clearTimeout(this.handle);
this.handle = undefined;
}
tick(elapsed) {
for (const i in this.ecses) {
this.ecses[i].tick(elapsed);
}
}
update(elapsed) {
for (const [connection, {entity}] of this.connectedPlayers) {
if (this.ackingActions.has(connection)) {
for (const ack of this.ackingActions.get(connection)) {
this.server.send(connection, ack);
}
this.ackingActions.delete(connection);
}
if (!entity) {
continue;
}
this.server.send(
connection,
{
type: 'Tick',
payload: {
ecs: this.updateFor(connection),
elapsed,
frame: this.frame,
},
},
);
}
}
updateFor(connection) {
const update = {};
const {entity, memory} = this.connectedPlayers.get(connection);
const ecs = this.ecses[entity.Ecs.path];
// Entities within half a screen offscreen.
const x0 = entity.Position.x - RESOLUTION.x;
const y0 = entity.Position.y - RESOLUTION.y;
const nearby = ecs.system('VisibleAabbs').within({
x0,
x1: x0 + (RESOLUTION.x * 2),
y0,
y1: y0 + (RESOLUTION.y * 2),
});
// Master entity.
const master = ecs.get(1);
nearby.add(master);
const lastNearby = new Set(memory.nearby.values());
const firstUpdate = 0 === lastNearby.size;
for (const nearbyEntity of nearby) {
const {id} = nearbyEntity;
lastNearby.delete(id);
if (!memory.nearby.has(id)) {
update[id] = nearbyEntity.toNet(entity);
if (entity.id === id) {
update[id].MainEntity = {};
}
}
else if (ecs.diff[id]) {
const nearbyEntityDiff = {};
for (const componentName in ecs.diff[id]) {
nearbyEntityDiff[componentName] = nearbyEntity[componentName].toNet(
entity,
ecs.diff[id][componentName],
);
}
update[id] = nearbyEntityDiff;
}
memory.nearby.add(id);
}
for (const id of lastNearby) {
memory.nearby.delete(id);
update[id] = false;
}
entity.updateAttachments(update);
// Tile layer chunking
const {TileLayers} = master;
const {layers} = TileLayers;
let layerChange;
for (const i in layers) {
const layer = TileLayers.layer(i);
const cx = CHUNK_SIZE * layer.tileSize.x;
const cy = CHUNK_SIZE * layer.tileSize.y;
const rx = 1 + Math.ceil((RESOLUTION.x * 2) / cx);
const ry = 1 + Math.ceil((RESOLUTION.y * 2) / cy);
const lx = Math.floor((entity.Position.x - RESOLUTION.x) / cx);
const ly = Math.floor((entity.Position.y - RESOLUTION.y) / cy);
const ax = Math.ceil(layer.area.x / CHUNK_SIZE);
for (let wy = 0; wy < ry; ++wy) {
for (let wx = 0; wx < rx; ++wx) {
const iy = wy + ly;
const ix = wx + lx;
if (
ix >= 0
&& iy >= 0
&& ix < Math.ceil(layer.area.x / CHUNK_SIZE)
&& iy < Math.ceil(layer.area.y / CHUNK_SIZE)
) {
const chunk = iy * ax + ix;
if (!memory.chunks.has(i)) {
memory.chunks.set(i, new Set());
}
if (!memory.chunks.get(i).has(chunk)) {
memory.chunks.get(i).add(chunk);
if (!layerChange) {
layerChange = {};
}
if (!layerChange[i]) {
layerChange[i] = {};
}
for (let y = 0; y < CHUNK_SIZE; ++y) {
for (let x = 0; x < CHUNK_SIZE; ++x) {
const ty = (iy * CHUNK_SIZE) + y;
const tx = (ix * CHUNK_SIZE) + x;
if (
tx < 0
|| ty < 0
|| tx >= layers[i].area.x
|| ty >= layers[i].area.y
) {
continue;
}
const computed = ((iy * CHUNK_SIZE) + y) * layers[i].area.x + ((ix * CHUNK_SIZE) + x);
layerChange[i][computed] = layers[i].data[computed];
}
}
}
}
}
}
}
if (firstUpdate && update['1']) {
const {TileLayers} = update['1'];
if (TileLayers) {
const layersUpdate = [];
const {layers} = TileLayers;
for (const l in layers) {
layersUpdate[l] = {
...layers[l],
data: [],
};
}
update['1'].TileLayers = {layers: layersUpdate};
}
}
if (layerChange) {
if (!update['1']) {
update['1'] = {};
}
if (!update['1'].TileLayers) {
update['1'].TileLayers = {};
}
update['1'].TileLayers.layerChange = layerChange;
}
return update;
}
}