flow: net + ecs

This commit is contained in:
cha0s 2024-05-30 08:55:03 -05:00
parent 3dc7cf8a5b
commit 007ea66de1
44 changed files with 346 additions and 114 deletions

9
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "silphius", "name": "silphius",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",
"@pixi/react": "^7.1.2", "@pixi/react": "^7.1.2",
"pixi.js": "^8.1.5", "pixi.js": "^8.1.5",
"react": "^18.3.1", "react": "^18.3.1",
@ -2634,6 +2635,14 @@
"react": ">=16" "react": ">=16"
} }
}, },
"node_modules/@msgpack/msgpack": {
"version": "3.0.0-beta2",
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.0.0-beta2.tgz",
"integrity": "sha512-y+l1PNV0XDyY8sM3YtuMLK5vE3/hkfId+Do8pLo/OPxfxuFAUwcGz3oiiUuV46/aBpwTzZ+mRWVMtlSKbradhw==",
"engines": {
"node": ">= 14"
}
},
"node_modules/@ndelangen/get-tarball": { "node_modules/@ndelangen/get-tarball": {
"version": "3.0.9", "version": "3.0.9",
"resolved": "https://registry.npmjs.org/@ndelangen/get-tarball/-/get-tarball-3.0.9.tgz", "resolved": "https://registry.npmjs.org/@ndelangen/get-tarball/-/get-tarball-3.0.9.tgz",

View File

@ -30,6 +30,7 @@
"vitest": "^1.6.0" "vitest": "^1.6.0"
}, },
"dependencies": { "dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",
"@pixi/react": "^7.1.2", "@pixi/react": "^7.1.2",
"pixi.js": "^8.1.5", "pixi.js": "^8.1.5",
"react": "^18.3.1", "react": "^18.3.1",

View File

@ -9,6 +9,11 @@ import ClientContext from '../context/client';
import Entities from './entities'; import Entities from './entities';
import styles from './pixi.module.css'; import styles from './pixi.module.css';
import {Ecs} from '../ecs/index.js';
import * as Components from '../ecs/components.js';
const ecs = new Ecs(Components);
export default function Pixi() { export default function Pixi() {
const client = useContext(ClientContext); const client = useContext(ClientContext);
const [entities, setEntities] = useState([]); const [entities, setEntities] = useState([]);
@ -21,7 +26,18 @@ export default function Pixi() {
break; break;
} }
case 'tick': { case 'tick': {
setEntities(payload.entities); const {buffer, byteLength, byteOffset} = payload.entities;
const view = new DataView(buffer, byteOffset, byteLength);
ecs.decode(view);
const entities = [];
for (const entity of ecs.entities) {
const {Position, Visible} = ecs.get(entity);
entities.push({
image: Visible.image,
position: [Position.x, Position.y],
})
}
setEntities(entities);
break; break;
} }
default: default:

View File

@ -15,10 +15,10 @@ export default function Silphius() {
let Client; let Client;
switch (connectionTuple[0]) { switch (connectionTuple[0]) {
case 'local': case 'local':
({default: Client} = await import('../client/local.js')); ({default: Client} = await import('../net/client/local.js'));
break; break;
case 'remote': case 'remote':
({default: Client} = await import('../client/remote.js')); ({default: Client} = await import('../net/client/remote.js'));
break; break;
} }
const client = new Client(); const client = new Client();

View File

@ -1,7 +1,7 @@
import {useContext, useEffect} from 'react'; import {useContext, useEffect} from 'react';
import addKeyListener from '../add-key-listener.js'; import addKeyListener from '../add-key-listener.js';
import {RESOLUTION} from '../constants'; import {ACTION_MAP, RESOLUTION} from '../constants';
import ClientContext from '../context/client'; import ClientContext from '../context/client';
import Dom from './dom'; import Dom from './dom';
import Pixi from './pixi'; import Pixi from './pixi';
@ -9,13 +9,6 @@ import styles from './ui.module.css';
const ratio = RESOLUTION[0] / RESOLUTION[1]; const ratio = RESOLUTION[0] / RESOLUTION[1];
// Handle input.
const ACTION_MAP = {
w: 'moveUp',
d: 'moveRight',
s: 'moveDown',
a: 'moveLeft',
};
const KEY_MAP = { const KEY_MAP = {
keyDown: 1, keyDown: 1,
keyUp: 0, keyUp: 0,

View File

@ -2,3 +2,17 @@ export const RESOLUTION = [
800, 800,
450, 450,
]; ];
export const ACTION_MAP = {
w: 'moveUp',
d: 'moveRight',
s: 'moveDown',
a: 'moveLeft',
};
export const MOVE_MAP = {
'moveUp': 'up',
'moveRight': 'right',
'moveDown': 'down',
'moveLeft': 'left',
};

17
src/ecs/components.js Normal file
View File

@ -0,0 +1,17 @@
export const Controlled = {
up: 'float32',
right: 'float32',
down: 'float32',
left: 'float32',
};
export const Position = {
x: 'float32',
y: 'float32',
};
export const Visible = {
image: 'string',
};
export const Wandering = {};

View File

@ -3,6 +3,8 @@ import {createRoot} from 'react-dom/client';
import Silphius from './components/silphius.jsx'; import Silphius from './components/silphius.jsx';
await import('./isomorphinit.js');
// Setup DOM. // Setup DOM.
createRoot(document.querySelector('.silphius')) createRoot(document.querySelector('.silphius'))
.render(createElement(Silphius)); .render(createElement(Silphius));

7
src/isomorphinit.js Normal file
View File

@ -0,0 +1,7 @@
// Gathering.
const {default: PacketClass} = await import('./net/packet/packet.js');
Object.values(await import('./net/packet/packets.js'))
.forEach((Packet) => {
PacketClass.register(Packet);
});
PacketClass.mapRegistered();

View File

@ -1,10 +1,13 @@
import Packet from "../packet/packet";
export default class Client { export default class Client {
constructor() { constructor() {
this.listeners = []; this.listeners = [];
} }
accept(data) { accept(packed) {
const decoded = Packet.accept(packed);
for (const i in this.listeners) { for (const i in this.listeners) {
this.listeners[i](data); this.listeners[i](decoded);
} }
} }
addMessageListener(listener) { addMessageListener(listener) {
@ -16,4 +19,7 @@ export default class Client {
this.listeners.splice(index, 1); this.listeners.splice(index, 1);
} }
} }
send(packet) {
this.transmit(Packet.transmit(packet));
}
} }

View File

@ -2,12 +2,12 @@ import Client from './client.js';
export default class LocalClient extends Client { export default class LocalClient extends Client {
async connect() { async connect() {
this.worker = new Worker('../server/worker.js', {type: 'module'}); this.worker = new Worker('/net/server/worker.js', {type: 'module'});
this.worker.onmessage = (event) => { this.worker.onmessage = (event) => {
this.accept(event.data); this.accept(event.data);
}; };
} }
send(message) { transmit(packed) {
this.worker.postMessage(message); this.worker.postMessage(packed);
} }
} }

View File

@ -3,15 +3,15 @@ import Client from './client.js';
export default class RemoteClient extends Client { export default class RemoteClient extends Client {
async connect() { async connect() {
this.socket = new WebSocket(`ws://${window.location.host}/ws`); this.socket = new WebSocket(`ws://${window.location.host}/ws`);
this.socket.binaryType = 'arraybuffer';
this.socket.onmessage = (event) => { this.socket.onmessage = (event) => {
this.accept(JSON.parse(event.data)); this.accept(event.data);
}; };
await new Promise((resolve) => { await new Promise((resolve) => {
this.socket.onopen = resolve; this.socket.onopen = resolve;
}); });
} }
send(message) { transmit(packed) {
this.socket.send(JSON.stringify(message)); this.socket.send(packed);
} }
} }

33
src/net/packet/action.js Normal file
View File

@ -0,0 +1,33 @@
import Packet from "./packet.js";
const WIRE_MAP = {
'moveUp': 0,
'moveRight': 1,
'moveDown': 2,
'moveLeft': 3,
};
Object.entries(WIRE_MAP)
.forEach(([k, v]) => {
WIRE_MAP[v] = k;
});
export default class Action extends Packet {
static type = 'action';
static pack(payload) {
return super.pack({
type: WIRE_MAP[payload.type],
value: payload.value,
});
}
static unpack(packed) {
const unpacked = super.unpack(packed);
return {
type: WIRE_MAP[unpacked.type],
value: unpacked.value,
};
}
};

View File

@ -0,0 +1,7 @@
import Packet from "./packet.js";
export default class Connect extends Packet {
static type = 'connect';
};

View File

@ -0,0 +1,7 @@
import Packet from "./packet.js";
export default class Connected extends Packet {
static type = 'connected';
};

68
src/net/packet/packet.js Normal file
View File

@ -0,0 +1,68 @@
import {encode, decode} from '@msgpack/msgpack';
export default class Packet {
static registered = {};
constructor(payload = {}) {
this.payload = payload;
}
static accept(packed) {
const decoded = decode(packed);
const Packet = this.registered[decoded.id];
return {
type: Packet.type,
payload: Packet.unpack(decoded),
};
}
static decode(buffer) {
try {
return this.unpack(decode(buffer));
}
catch (error) {
throw new Error(`decoding ${this.type}: ${error.message}`);
}
}
static encode(payload) {
try {
return encode(this.pack(payload));
}
catch (error) {
throw new Error(`encoding ${this.type}: ${error.message}`);
}
}
static mapRegistered() {
this.registered = Object.fromEntries([
...Object.entries(this.registered),
...Object.entries(this.registered)
.map(([type, Packet], id) => {
Packet.id = id;
return [id, Packet];
}),
]);
}
static pack(payload) {
return {
id: this.id,
payload,
};
}
static register(Packet) {
this.registered[Packet.type] = Packet;
}
static transmit({type, payload}) {
return this.registered[type].encode(payload);
}
static unpack({payload}) {
return payload;
}
}

View File

@ -0,0 +1,4 @@
export {default as Action} from './action.js';
export {default as Connect} from './connect.js';
export {default as Connected} from './connected.js';
export {default as Tick} from './tick.js';

7
src/net/packet/tick.js Normal file
View File

@ -0,0 +1,7 @@
import Packet from "./packet.js";
export default class Tick extends Packet {
static type = 'tick';
};

11
src/net/server/cell.js Normal file
View File

@ -0,0 +1,11 @@
import {Ecs} from '../../ecs/index.js';
import * as Components from '../../ecs/components.js';
export default class Cell {
constructor() {
this.ecs = new Ecs(Components);
}
tick(elapsed) {
this.ecs.tick(elapsed);
}
}

119
src/net/server/server.js Normal file
View File

@ -0,0 +1,119 @@
import {MOVE_MAP} from '../../constants.js';
import Cell from './cell.js';
import {Ecs, System} from '../../ecs/index.js';
import * as Components from '../../ecs/components.js';
import Packet from "../packet/packet.js";
const SPEED = 100;
const TPS = 60;
await import ('../../isomorphinit.js');
class MovementSystem extends System {
static queries() {
return {
default: ['Position', 'Controlled'],
};
}
tick(elapsed) {
for (const [position, controlled] of this.select('default')) {
position.x += SPEED * elapsed * (controlled.right - controlled.left);
position.y += SPEED * elapsed * (controlled.down - controlled.up);
}
}
}
export default class Server {
constructor() {
this.cells = [new Cell()];
this.cells[0].ecs.addSystem(MovementSystem);
this.connections = [];
this.connectedPlayers = new Map();
this.frame = 0;
this.last = Date.now();
}
accept(connection, packed) {
const decoded = Packet.accept(packed);
const {payload, type} = decoded;
switch (type) {
case 'connect': {
this.send(
connection,
{
type: 'connected',
payload: [],
},
);
break;
}
case 'action': {
const {ecs} = this.cells[0];
const connectedPlayer = this.connectedPlayers.get(connection);
if (payload.type in MOVE_MAP) {
ecs.get(connectedPlayer).Controlled[MOVE_MAP[payload.type]] = payload.value;
}
break;
}
default:
}
}
connectPlayer(connection) {
this.connections.push(connection);
const {ecs} = this.cells[0];
const entity = ecs.create({
Controlled: {up: 0, right: 0, down: 0, left: 0},
Position: {x: 50, y: 50},
Visible: {image: './assets/bunny.png'},
})
this.connectedPlayers.set(connection, entity);
}
disconnectPlayer(connection) {
const entity = this.connectedPlayers.get(connection);
this.connectedPlayers.delete(connection);
this.connections.splice(this.connections.indexOf(connection), 1);
}
async load() {
}
send(connection, packet) {
this.transmit(connection, Packet.transmit(packet));
}
start() {
return setInterval(() => {
const elapsed = (Date.now() - this.last) / 1000;
this.last = Date.now();
this.tick(elapsed);
}, 1000 / TPS);
}
tick(elapsed) {
this.cells[0].ecs.tick(elapsed);
const {ecs} = this.cells[0];
const view = new DataView(new ArrayBuffer(ecs.sizeOf(ecs.entities)));
ecs.encode(ecs.entities, view);
for (const connection of this.connections) {
this.send(
connection,
{
type: 'tick',
payload: {
entities: view,
elapsed,
frame: this.frame,
},
},
);
}
this.frame += 1;
}
}

View File

@ -9,7 +9,7 @@ const {
const wss = new WebSocketServer({port: SILPHIUS_WEBSOCKET_PORT}); const wss = new WebSocketServer({port: SILPHIUS_WEBSOCKET_PORT});
const server = new class SocketServer extends Server { const server = new class SocketServer extends Server {
send(ws, data) { ws.send(JSON.stringify(data)); } transmit(ws, packed) { ws.send(packed); }
} }
await server.load(); await server.load();
@ -20,7 +20,7 @@ wss.on('connection', function connection(ws) {
ws.on('close', () => { ws.on('close', () => {
server.disconnectPlayer(ws); server.disconnectPlayer(ws);
}) })
ws.on('message', (data) => { ws.on('message', (packed) => {
server.accept(ws, JSON.parse(data)); server.accept(ws, packed);
}); });
}); });

View File

@ -1,7 +1,7 @@
import Server from './server.js'; import Server from './server.js';
const server = new class WorkerServer extends Server { const server = new class WorkerServer extends Server {
send(connection, data) { postMessage(data); } transmit(connection, packed) { postMessage(packed); }
} }
await server.load(); await server.load();
@ -9,4 +9,4 @@ server.start();
server.connectPlayer(undefined); server.connectPlayer(undefined);
onmessage = ({data}) => { server.accept(undefined, data); }; onmessage = (event) => { server.accept(undefined, event.data); };

View File

@ -1,89 +0,0 @@
const SPEED = 100;
const TPS = 20;
const MOVE_MAP = {
'moveUp': 0,
'moveRight': 1,
'moveDown': 2,
'moveLeft': 3,
};
export default class Server {
constructor() {
this.connections = [];
this.connectedPlayers = new Map();
this.entities = [];
this.frame = 0;
this.last = Date.now();
}
accept(connection, {type, payload}) {
switch (type) {
case 'connect': {
this.send(connection, {type: 'connected', payload: Array.from(this.connectedPlayers.values())});
break;
}
case 'action': {
const connectedPlayer = this.connectedPlayers.get(connection);
if (payload.type in MOVE_MAP) {
connectedPlayer.movement[MOVE_MAP[payload.type]] = payload.value;
}
break;
}
default:
}
}
connectPlayer(connection) {
this.connections.push(connection);
const entity = {
image: './assets/bunny.png',
movement: [0, 0, 0, 0],
position: [50, 50],
};
this.entities.push(entity);
this.connectedPlayers.set(connection, entity);
}
disconnectPlayer(connection) {
const entity = this.connectedPlayers.get(connection);
this.connectedPlayers.delete(connection);
this.connections.splice(this.connections.indexOf(connection), 1);
this.entities.splice(this.entities.indexOf(entity), 1);
}
async load() {
}
start() {
return setInterval(() => {
const elapsed = (Date.now() - this.last) / 1000;
this.last = Date.now();
this.tick(elapsed);
}, 1000 / TPS);
}
tick(elapsed) {
for (const connection of this.connections) {
const {movement, position} = this.connectedPlayers.get(connection);
position[0] += SPEED * elapsed * (movement[1] - movement[3]);
position[1] += SPEED * elapsed * (movement[2] - movement[0]);
}
for (const connection of this.connections) {
this.send(
connection,
{
type: 'tick',
payload: {
entities: this.entities,
elapsed,
frame: this.frame,
},
},
);
}
this.frame += 1;
}
}