diff --git a/package-lock.json b/package-lock.json index db0dd63..c08e27a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "silphius", "version": "1.0.0", "dependencies": { + "@msgpack/msgpack": "^3.0.0-beta2", "@pixi/react": "^7.1.2", "pixi.js": "^8.1.5", "react": "^18.3.1", @@ -2634,6 +2635,14 @@ "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": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@ndelangen/get-tarball/-/get-tarball-3.0.9.tgz", diff --git a/package.json b/package.json index e108d8a..6f67798 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "vitest": "^1.6.0" }, "dependencies": { + "@msgpack/msgpack": "^3.0.0-beta2", "@pixi/react": "^7.1.2", "pixi.js": "^8.1.5", "react": "^18.3.1", diff --git a/src/components/pixi.jsx b/src/components/pixi.jsx index 24926e4..5c6476a 100644 --- a/src/components/pixi.jsx +++ b/src/components/pixi.jsx @@ -9,6 +9,11 @@ import ClientContext from '../context/client'; import Entities from './entities'; 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() { const client = useContext(ClientContext); const [entities, setEntities] = useState([]); @@ -21,7 +26,18 @@ export default function Pixi() { break; } 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; } default: diff --git a/src/components/silphius.jsx b/src/components/silphius.jsx index c3720da..33ba5d4 100644 --- a/src/components/silphius.jsx +++ b/src/components/silphius.jsx @@ -15,10 +15,10 @@ export default function Silphius() { let Client; switch (connectionTuple[0]) { case 'local': - ({default: Client} = await import('../client/local.js')); + ({default: Client} = await import('../net/client/local.js')); break; case 'remote': - ({default: Client} = await import('../client/remote.js')); + ({default: Client} = await import('../net/client/remote.js')); break; } const client = new Client(); diff --git a/src/components/ui.jsx b/src/components/ui.jsx index 828e9de..51695ae 100644 --- a/src/components/ui.jsx +++ b/src/components/ui.jsx @@ -1,7 +1,7 @@ import {useContext, useEffect} from 'react'; import addKeyListener from '../add-key-listener.js'; -import {RESOLUTION} from '../constants'; +import {ACTION_MAP, RESOLUTION} from '../constants'; import ClientContext from '../context/client'; import Dom from './dom'; import Pixi from './pixi'; @@ -9,13 +9,6 @@ import styles from './ui.module.css'; const ratio = RESOLUTION[0] / RESOLUTION[1]; -// Handle input. -const ACTION_MAP = { - w: 'moveUp', - d: 'moveRight', - s: 'moveDown', - a: 'moveLeft', -}; const KEY_MAP = { keyDown: 1, keyUp: 0, diff --git a/src/constants.js b/src/constants.js index ed870a4..d7d729a 100644 --- a/src/constants.js +++ b/src/constants.js @@ -2,3 +2,17 @@ export const RESOLUTION = [ 800, 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', +}; diff --git a/src/server/ecs/bundle.js b/src/ecs/bundle.js similarity index 100% rename from src/server/ecs/bundle.js rename to src/ecs/bundle.js diff --git a/src/server/ecs/chain.js b/src/ecs/chain.js similarity index 100% rename from src/server/ecs/chain.js rename to src/ecs/chain.js diff --git a/src/server/ecs/chain.test.js b/src/ecs/chain.test.js similarity index 100% rename from src/server/ecs/chain.test.js rename to src/ecs/chain.test.js diff --git a/src/server/ecs/component.js b/src/ecs/component.js similarity index 100% rename from src/server/ecs/component.js rename to src/ecs/component.js diff --git a/src/server/ecs/component.test.js b/src/ecs/component.test.js similarity index 100% rename from src/server/ecs/component.test.js rename to src/ecs/component.test.js diff --git a/src/server/ecs/component/arbitrary.js b/src/ecs/component/arbitrary.js similarity index 100% rename from src/server/ecs/component/arbitrary.js rename to src/ecs/component/arbitrary.js diff --git a/src/server/ecs/component/arbitrary.test.js b/src/ecs/component/arbitrary.test.js similarity index 100% rename from src/server/ecs/component/arbitrary.test.js rename to src/ecs/component/arbitrary.test.js diff --git a/src/server/ecs/component/base.js b/src/ecs/component/base.js similarity index 100% rename from src/server/ecs/component/base.js rename to src/ecs/component/base.js diff --git a/src/server/ecs/component/flat.js b/src/ecs/component/flat.js similarity index 100% rename from src/server/ecs/component/flat.js rename to src/ecs/component/flat.js diff --git a/src/server/ecs/component/flat.test.js b/src/ecs/component/flat.test.js similarity index 100% rename from src/server/ecs/component/flat.test.js rename to src/ecs/component/flat.test.js diff --git a/src/ecs/components.js b/src/ecs/components.js new file mode 100644 index 0000000..2397e22 --- /dev/null +++ b/src/ecs/components.js @@ -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 = {}; diff --git a/src/server/ecs/ecs.js b/src/ecs/ecs.js similarity index 100% rename from src/server/ecs/ecs.js rename to src/ecs/ecs.js diff --git a/src/server/ecs/ecs.test.js b/src/ecs/ecs.test.js similarity index 100% rename from src/server/ecs/ecs.test.js rename to src/ecs/ecs.test.js diff --git a/src/server/ecs/entity-factory.js b/src/ecs/entity-factory.js similarity index 100% rename from src/server/ecs/entity-factory.js rename to src/ecs/entity-factory.js diff --git a/src/server/ecs/index.js b/src/ecs/index.js similarity index 100% rename from src/server/ecs/index.js rename to src/ecs/index.js diff --git a/src/server/ecs/query.js b/src/ecs/query.js similarity index 100% rename from src/server/ecs/query.js rename to src/ecs/query.js diff --git a/src/server/ecs/query.test.js b/src/ecs/query.test.js similarity index 100% rename from src/server/ecs/query.test.js rename to src/ecs/query.test.js diff --git a/src/server/ecs/schema.js b/src/ecs/schema.js similarity index 100% rename from src/server/ecs/schema.js rename to src/ecs/schema.js diff --git a/src/server/ecs/schema.test.js b/src/ecs/schema.test.js similarity index 100% rename from src/server/ecs/schema.test.js rename to src/ecs/schema.test.js diff --git a/src/server/ecs/serializer.js b/src/ecs/serializer.js similarity index 100% rename from src/server/ecs/serializer.js rename to src/ecs/serializer.js diff --git a/src/server/ecs/serializer.test.js b/src/ecs/serializer.test.js similarity index 100% rename from src/server/ecs/serializer.test.js rename to src/ecs/serializer.test.js diff --git a/src/server/ecs/system.js b/src/ecs/system.js similarity index 100% rename from src/server/ecs/system.js rename to src/ecs/system.js diff --git a/src/index.js b/src/index.js index f3cf467..04b3bab 100644 --- a/src/index.js +++ b/src/index.js @@ -3,6 +3,8 @@ import {createRoot} from 'react-dom/client'; import Silphius from './components/silphius.jsx'; +await import('./isomorphinit.js'); + // Setup DOM. createRoot(document.querySelector('.silphius')) .render(createElement(Silphius)); diff --git a/src/isomorphinit.js b/src/isomorphinit.js new file mode 100644 index 0000000..546396b --- /dev/null +++ b/src/isomorphinit.js @@ -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(); diff --git a/src/client/client.js b/src/net/client/client.js similarity index 64% rename from src/client/client.js rename to src/net/client/client.js index 83c0a47..23087e8 100644 --- a/src/client/client.js +++ b/src/net/client/client.js @@ -1,10 +1,13 @@ +import Packet from "../packet/packet"; + export default class Client { constructor() { this.listeners = []; } - accept(data) { + accept(packed) { + const decoded = Packet.accept(packed); for (const i in this.listeners) { - this.listeners[i](data); + this.listeners[i](decoded); } } addMessageListener(listener) { @@ -16,4 +19,7 @@ export default class Client { this.listeners.splice(index, 1); } } + send(packet) { + this.transmit(Packet.transmit(packet)); + } } diff --git a/src/client/local.js b/src/net/client/local.js similarity index 59% rename from src/client/local.js rename to src/net/client/local.js index 5605cb6..319df6b 100644 --- a/src/client/local.js +++ b/src/net/client/local.js @@ -2,12 +2,12 @@ import Client from './client.js'; export default class LocalClient extends Client { 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.accept(event.data); }; } - send(message) { - this.worker.postMessage(message); + transmit(packed) { + this.worker.postMessage(packed); } } diff --git a/src/client/remote.js b/src/net/client/remote.js similarity index 71% rename from src/client/remote.js rename to src/net/client/remote.js index 34647d9..81145aa 100644 --- a/src/client/remote.js +++ b/src/net/client/remote.js @@ -3,15 +3,15 @@ import Client from './client.js'; export default class RemoteClient extends Client { async connect() { this.socket = new WebSocket(`ws://${window.location.host}/ws`); + this.socket.binaryType = 'arraybuffer'; this.socket.onmessage = (event) => { - this.accept(JSON.parse(event.data)); + this.accept(event.data); }; await new Promise((resolve) => { this.socket.onopen = resolve; }); } - send(message) { - this.socket.send(JSON.stringify(message)); + transmit(packed) { + this.socket.send(packed); } } - diff --git a/src/net/packet/action.js b/src/net/packet/action.js new file mode 100644 index 0000000..08b4aff --- /dev/null +++ b/src/net/packet/action.js @@ -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, + }; + } + +}; diff --git a/src/net/packet/connect.js b/src/net/packet/connect.js new file mode 100644 index 0000000..911a3f2 --- /dev/null +++ b/src/net/packet/connect.js @@ -0,0 +1,7 @@ +import Packet from "./packet.js"; + +export default class Connect extends Packet { + + static type = 'connect'; + +}; diff --git a/src/net/packet/connected.js b/src/net/packet/connected.js new file mode 100644 index 0000000..36dde42 --- /dev/null +++ b/src/net/packet/connected.js @@ -0,0 +1,7 @@ +import Packet from "./packet.js"; + +export default class Connected extends Packet { + + static type = 'connected'; + +}; diff --git a/src/net/packet/packet.js b/src/net/packet/packet.js new file mode 100644 index 0000000..3f6ec76 --- /dev/null +++ b/src/net/packet/packet.js @@ -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; + } + +} diff --git a/src/net/packet/packets.js b/src/net/packet/packets.js new file mode 100644 index 0000000..8f6a13f --- /dev/null +++ b/src/net/packet/packets.js @@ -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'; diff --git a/src/net/packet/tick.js b/src/net/packet/tick.js new file mode 100644 index 0000000..d59de34 --- /dev/null +++ b/src/net/packet/tick.js @@ -0,0 +1,7 @@ +import Packet from "./packet.js"; + +export default class Tick extends Packet { + + static type = 'tick'; + +}; diff --git a/src/net/server/cell.js b/src/net/server/cell.js new file mode 100644 index 0000000..ec96fd5 --- /dev/null +++ b/src/net/server/cell.js @@ -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); + } +} \ No newline at end of file diff --git a/src/net/server/server.js b/src/net/server/server.js new file mode 100644 index 0000000..5222623 --- /dev/null +++ b/src/net/server/server.js @@ -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; + } + +} diff --git a/src/server/socket.js b/src/net/server/socket.js similarity index 78% rename from src/server/socket.js rename to src/net/server/socket.js index 3dd3895..a4d3f05 100644 --- a/src/server/socket.js +++ b/src/net/server/socket.js @@ -9,7 +9,7 @@ const { const wss = new WebSocketServer({port: SILPHIUS_WEBSOCKET_PORT}); const server = new class SocketServer extends Server { - send(ws, data) { ws.send(JSON.stringify(data)); } + transmit(ws, packed) { ws.send(packed); } } await server.load(); @@ -20,7 +20,7 @@ wss.on('connection', function connection(ws) { ws.on('close', () => { server.disconnectPlayer(ws); }) - ws.on('message', (data) => { - server.accept(ws, JSON.parse(data)); + ws.on('message', (packed) => { + server.accept(ws, packed); }); }); diff --git a/src/server/worker.js b/src/net/server/worker.js similarity index 57% rename from src/server/worker.js rename to src/net/server/worker.js index 8a79688..1eac253 100644 --- a/src/server/worker.js +++ b/src/net/server/worker.js @@ -1,7 +1,7 @@ import Server from './server.js'; const server = new class WorkerServer extends Server { - send(connection, data) { postMessage(data); } + transmit(connection, packed) { postMessage(packed); } } await server.load(); @@ -9,4 +9,4 @@ server.start(); server.connectPlayer(undefined); -onmessage = ({data}) => { server.accept(undefined, data); }; +onmessage = (event) => { server.accept(undefined, event.data); }; diff --git a/src/server/server.js b/src/server/server.js deleted file mode 100644 index cdb97af..0000000 --- a/src/server/server.js +++ /dev/null @@ -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; - } - -}