Compare commits

...

No commits in common. "772308a2995e49469ff27d220ff56ff7ef9551b6" and "962f867ed97a9bfb60589fd41201259120efa884" have entirely different histories.

151 changed files with 11381 additions and 5662 deletions

70
.eslintrc.cjs Normal file
View File

@ -0,0 +1,70 @@
/**
* This is intended to be a basic starting point for linting in your app.
* It relies on recommended configs out of the box for simplicity, but you can
* and should modify this configuration to best suit your team's needs.
*/
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
env: {
browser: true,
commonjs: true,
es6: true,
},
globals: {
process: false,
},
ignorePatterns: ['!**/.server', '!**/.client'],
// Base config
extends: ['eslint:recommended'],
overrides: [
// React
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: ['react', 'jsx-a11y'],
extends: [
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
'plugin:jsx-a11y/recommended',
],
settings: {
react: {
version: 'detect',
},
formComponents: ['Form'],
linkComponents: [
{ name: 'Link', linkAttribute: 'to' },
{ name: 'NavLink', linkAttribute: 'to' },
],
},
rules: {
'react/prop-types': 'off',
},
},
// Node
{
files: [
'app/websocket.js',
'.eslintrc.cjs',
'server.js',
'vite.config.js',
'public/assets/tileset.js',
],
env: {
node: true,
},
},
],
};

7
.gitignore vendored
View File

@ -1,2 +1,5 @@
/indev node_modules
/node_modules
/.cache
/build
.env

View File

@ -1,6 +1,8 @@
process.env.STORYBOOK = 1
/** @type { import('@storybook/react-vite').StorybookConfig } */ /** @type { import('@storybook/react-vite').StorybookConfig } */
const config = { const config = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [ addons: [
'@chromatic-com/storybook', '@chromatic-com/storybook',
'@storybook/addon-links', '@storybook/addon-links',

36
.vscode/launch.json vendored
View File

@ -4,6 +4,13 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Silphius Chrome",
"url": "https://localhost:3000",
"webRoot": "${workspaceFolder}",
},
{ {
"type": "node", "type": "node",
"request": "launch", "request": "launch",
@ -14,7 +21,14 @@
"resolveSourceMapLocations": [], "resolveSourceMapLocations": [],
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--host", "0.0.0.0"], "runtimeArgs": ["run", "dev"],
},
{
"type": "chrome",
"request": "launch",
"name": "Storybook Chrome",
"url": "http://localhost:6006",
"webRoot": "${workspaceFolder}",
}, },
{ {
"type": "node", "type": "node",
@ -28,25 +42,21 @@
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": ["run", "storybook", "--", "--no-open"], "runtimeArgs": ["run", "storybook", "--", "--no-open"],
}, },
{
"type": "chrome",
"request": "launch",
"name": "Chrome",
"url": "",
"webRoot": "${workspaceFolder}",
"runtimeArgs": [
"http://localhost:5173",
"http://localhost:6006",
]
},
], ],
"compounds": [ "compounds": [
{ {
"name": "Silphius", "name": "Silphius",
"configurations": [ "configurations": [
"Silphius Dev", "Silphius Dev",
"Silphius Chrome",
],
"stopAll": true,
},
{
"name": "Storybook",
"configurations": [
"Storybook Dev", "Storybook Dev",
"Chrome", "Storybook Chrome",
], ],
"stopAll": true, "stopAll": true,
} }

27
README.md Normal file
View File

@ -0,0 +1,27 @@
# Silphius
All the world's a game!
## Development
Run the dev server:
```shellscript
npm run dev
```
## Deployment
First, build your app for production:
```sh
npm run build
```
Then run the app in production mode:
```sh
npm start
```
Now you'll need to pick a host to deploy it to.

View File

@ -1,13 +1,13 @@
export const CLIENT_LATENCY = 100; export const CLIENT_LATENCY = 0;
export const CLIENT_PREDICTION = true; export const CLIENT_PREDICTION = true;
export const RESOLUTION = [ export const RESOLUTION = {
800, x: 800,
450, y: 450,
]; };
export const SERVER_LATENCY = 100; export const SERVER_LATENCY = 0;
export const TPS = 60; export const TPS = 60;

View File

@ -0,0 +1,3 @@
export default {
frame: {type: 'uint16'},
};

View File

@ -0,0 +1,4 @@
export default {
x: {type: 'uint16'},
y: {type: 'uint16'},
}

View File

@ -0,0 +1,4 @@
export default {
x: {type: 'uint16'},
y: {type: 'uint16'},
}

View File

@ -0,0 +1,6 @@
export default {
up: {type: 'float32'},
right: {type: 'float32'},
down: {type: 'float32'},
left: {type: 'float32'},
};

View File

@ -0,0 +1,3 @@
export default {
direction: {type: 'uint8'},
};

View File

@ -0,0 +1,3 @@
import gather from '@/engine/gather.js';
export default gather(import.meta.glob('./*.js', {eager: true, import: 'default'}));

View File

@ -0,0 +1 @@
export default {};

View File

@ -0,0 +1,4 @@
export default {
x: {type: 'float32'},
y: {type: 'float32'},
}

View File

@ -0,0 +1,4 @@
export default {
x: {type: 'float32'},
y: {type: 'float32'},
};

View File

@ -0,0 +1 @@
export default {};

View File

@ -0,0 +1,8 @@
export default {
animation: {type: 'string'},
elapsed: {type: 'float32'},
frame: {type: 'uint16'},
frames: {type: 'uint16'},
source: {type: 'string'},
speed: {type: 'float32'},
};

View File

@ -0,0 +1,16 @@
export default {
layers: {
type: 'array',
subtype: {
type: 'object',
properties: {
data: {
type: 'array',
subtype: {
type: 'uint16',
},
},
},
},
},
};

View File

@ -0,0 +1,6 @@
export default {
x0: {type: 'float32'},
x1: {type: 'float32'},
y0: {type: 'float32'},
y1: {type: 'float32'},
}

View File

@ -0,0 +1 @@
export default {};

View File

@ -0,0 +1,3 @@
export default {
world: {type: 'uint16'},
}

View File

@ -0,0 +1,18 @@
import {System} from '@/ecs/index.js';
export default class ApplyMomentum extends System {
static queries() {
return {
default: ['Position', 'Momentum'],
};
}
tick(elapsed) {
for (const [position, momentum] of this.select('default')) {
position.x += elapsed * momentum.x;
position.y += elapsed * momentum.y;
}
}
}

View File

@ -0,0 +1,21 @@
import {System} from '@/ecs/index.js';
export default class CalculateAabbs extends System {
tick() {
const {diff} = this.ecs;
for (const id in diff) {
if (diff[id].Position) {
const {Position: {x, y}, VisibleAabb} = this.ecs.get(id);
if (VisibleAabb) {
VisibleAabb.x0 = x - 32;
VisibleAabb.x1 = x + 32;
VisibleAabb.y0 = y - 32;
VisibleAabb.y1 = y + 32;
}
}
}
}
}

View File

@ -0,0 +1,27 @@
import {System} from '@/ecs/index.js';
export default class ClampPositions extends System {
tick() {
const {diff} = this.ecs;
const {AreaSize} = this.ecs.get(1);
for (const id in diff) {
if (diff[id].Position) {
const {Position} = this.ecs.get(id);
if (Position.x < 0) {
Position.x = 0;
}
if (Position.y < 0) {
Position.y = 0;
}
if (Position.x >= AreaSize.x) {
Position.x = AreaSize.x - 0.0001;
}
if (Position.y >= AreaSize.y) {
Position.y = AreaSize.y - 0.0001;
}
}
}
}
}

View File

@ -0,0 +1,27 @@
import {System} from '@/ecs/index.js';
export default class ControlDirection extends System {
tick() {
const {diff} = this.ecs;
for (const id in diff) {
const {Controlled} = diff[id];
if (Controlled) {
const {Controlled: {up, right, down, left}, Direction} = this.ecs.get(id);
if (up > 0) {
Direction.direction = 0;
}
if (down > 0) {
Direction.direction = 2;
}
if (left > 0) {
Direction.direction = 3;
}
if (right > 0) {
Direction.direction = 1;
}
}
}
}
}

View File

@ -0,0 +1,21 @@
import {System} from '@/ecs/index.js';
const SPEED = 100;
export default class ControlMovement extends System {
static queries() {
return {
default: ['Controlled', 'Momentum'],
};
}
tick() {
for (const [controlled, momentum] of this.select('default')) {
momentum.x = SPEED * (controlled.right - controlled.left);
momentum.y = SPEED * (controlled.down - controlled.up);
}
}
}

View File

@ -0,0 +1,32 @@
import {RESOLUTION} from '@/constants.js'
import {System} from '@/ecs/index.js';
export default class FollowCamera extends System {
reindex(entities) {
super.reindex(entities);
for (const id of entities) {
this.updateCamera(this.ecs.get(id));
}
}
tick() {
const {diff} = this.ecs;
for (const id in diff) {
if (diff[id].Position) {
this.updateCamera(this.ecs.get(id));
}
}
}
updateCamera(entity) {
const {Camera, Position} = entity;
if (Camera && Position) {
const {AreaSize: {x, y}} = this.ecs.get(1);
const [hx, hy] = [RESOLUTION.x / 2, RESOLUTION.y / 2];
Camera.x = Math.max(hx, Math.min(Position.x, x - hx));
Camera.y = Math.max(hy, Math.min(Position.y, y - hy));
}
}
}

View File

@ -0,0 +1,22 @@
import {System} from '@/ecs/index.js';
export default class ControlMovement extends System {
static queries() {
return {
default: ['Sprite'],
};
}
tick(elapsed) {
for (const [Sprite] of this.select('default')) {
Sprite.elapsed += elapsed / Sprite.speed;
while (Sprite.elapsed > 1) {
Sprite.elapsed -= 1;
Sprite.frame = (Sprite.frame + 1) % Sprite.frames;
}
}
}
}

View File

@ -0,0 +1,39 @@
import {System} from '@/ecs/index.js';
export default class SpriteDirection extends System {
static queries() {
return {
default: ['Sprite'],
};
}
tick() {
for (const [Sprite, entityId] of this.select('default')) {
const entity = this.ecs.get(entityId);
const parts = [];
if (entity.Controlled) {
const {up, right, down, left} = entity.Controlled;
if (up > 0 || right > 0 || down > 0 || left > 0) {
parts.push('moving');
}
else {
parts.push('idle');
}
}
if (entity.Direction) {
const name = {
0: 'up',
1: 'right',
2: 'down',
3: 'left',
};
parts.push(name[entity.Direction.direction]);
}
if (parts.length > 0) {
Sprite.animation = parts.join(':');
}
}
}
}

View File

@ -0,0 +1,113 @@
import {RESOLUTION} from '@/constants.js'
import {System} from '@/ecs/index.js';
class SpatialHash {
constructor({x, y}) {
this.area = {x, y};
this.chunkSize = {x: RESOLUTION.x / 2, y: RESOLUTION.y / 2};
this.chunks = Array(Math.ceil(this.area.x / this.chunkSize.x))
.fill(0)
.map(() => (
Array(Math.ceil(this.area.y / this.chunkSize.y))
.fill(0)
.map(() => [])
));
this.data = {};
}
clamp(x, y) {
return [
Math.max(0, Math.min(x, this.area.x - 1)),
Math.max(0, Math.min(y, this.area.y - 1))
];
}
chunkIndex(x, y) {
const [cx, cy] = this.clamp(x, y);
return [
Math.floor(cx / this.chunkSize.x),
Math.floor(cy / this.chunkSize.y),
];
}
remove(datum) {
if (datum in this.data) {
for (const [cx, cy] of this.data[datum]) {
const chunk = this.chunks[cx][cy];
chunk.splice(chunk.indexOf(datum), 1);
}
}
this.data[datum] = [];
}
update({x0, x1, y0, y1}, datum) {
this.remove(datum);
for (const [x, y] of [[x0, y0], [x0, y1], [x1, y0], [x1, y1]]) {
const [cx, cy] = this.chunkIndex(x, y);
this.data[datum].push([cx, cy]);
this.chunks[cx][cy].push(datum);
}
}
}
export default class UpdateSpatialHash extends System {
constructor(ecs) {
super(ecs);
const master = ecs.get(1);
this.hash = new SpatialHash(master.AreaSize);
}
deindex(entities) {
super.deindex(entities);
for (const id of entities) {
this.hash.remove(id);
}
}
reindex(entities) {
super.reindex(entities);
for (const id of entities) {
this.updateHash(this.ecs.get(id));
}
}
updateHash(entity) {
if (!entity.VisibleAabb) {
return;
}
this.hash.update(entity.VisibleAabb, entity.id);
}
tick() {
const {diff} = this.ecs;
for (const id in diff) {
if (diff[id].VisibleAabb) {
this.updateHash(this.ecs.get(id));
}
}
}
nearby(entity) {
const [cx0, cy0] = this.hash.chunkIndex(
entity.Position.x - RESOLUTION.x * 0.75,
entity.Position.y - RESOLUTION.x * 0.75,
);
const [cx1, cy1] = this.hash.chunkIndex(
entity.Position.x + RESOLUTION.x * 0.75,
entity.Position.y + RESOLUTION.x * 0.75,
);
const nearby = new Set();
for (let cy = cy0; cy <= cy1; ++cy) {
for (let cx = cx0; cx <= cx1; ++cx) {
this.hash.chunks[cx][cy].forEach((id) => {
nearby.add(this.ecs.get(id));
});
}
}
return nearby;
}
}

109
app/ecs/arbitrary.js Normal file
View File

@ -0,0 +1,109 @@
import Base from './base.js';
export default class Arbitrary extends Base {
data = [];
serializer;
Instance;
allocateMany(count) {
if (!this.Instance) {
this.Instance = this.instanceFromSchema();
}
const results = super.allocateMany(count);
count -= results.length; while (count--) {
results.push(this.data.push(new this.Instance()) - 1);
}
return results;
}
createMany(entries) {
if (entries.length > 0) {
const allocated = this.allocateMany(entries.length);
const keys = Object.keys(this.constructor.properties);
for (let i = 0; i < entries.length; ++i) {
const [entityId, values = {}] = entries[i];
this.map[entityId] = allocated[i];
this.data[allocated[i]].entity = entityId;
if (false === values) {
continue;
}
for (let k = 0; k < keys.length; ++k) {
const j = keys[k];
const {defaultValue} = this.constructor.properties[j];
if (j in values) {
this.data[allocated[i]][j] = values[j];
}
else if ('undefined' !== typeof defaultValue) {
this.data[allocated[i]][j] = defaultValue;
}
}
}
}
}
deserialize(entityId, view, offset) {
const {properties} = this.constructor;
const instance = this.get(entityId);
const deserialized = this.constructor.schema.deserialize(view, offset);
for (const key in properties) {
instance[key] = deserialized[key];
}
}
serialize(entityId, view, offset) {
this.constructor.schema.serialize(this.get(entityId), view, offset);
}
get(entityId) {
return this.data[this.map[entityId]];
}
instanceFromSchema() {
const Component = this;
const Instance = class {
$$entity = 0;
constructor() {
this.$$reset();
}
$$reset() {
const {properties} = Component.constructor;
for (const key in properties) {
const {defaultValue} = properties[key];
this[`$$${key}`] = defaultValue;
}
}
toJSON() {
return Component.constructor.filterDefaults(this);
}
};
const properties = {};
properties.entity = {
get: function get() {
return this.$$entity;
},
set: function set(v) {
this.$$entity = v;
this.$$reset();
},
};
for (const key in Component.constructor.properties) {
properties[key] = {
get: function get() {
return this[`$$${key}`];
},
set: function set(value) {
if (this[`$$${key}`] !== value) {
this[`$$${key}`] = value;
Component.markChange(this.entity, key, value);
}
},
};
}
Object.defineProperties(Instance.prototype, properties);
return Instance;
}
}

55
app/ecs/arbitrary.test.js Normal file
View File

@ -0,0 +1,55 @@
import {expect, test} from 'vitest';
import Schema from './schema.js';
import Arbitrary from './arbitrary.js';
test('creates instances', () => {
class CreatingArbitrary extends Arbitrary {
static schema = new Schema({
type: 'object',
properties: {foo: {defaultValue: 'bar', type: 'string'}},
});
}
const Component = new CreatingArbitrary();
Component.create(1);
expect(Component.get(1).entity)
.to.equal(1);
});
test('does not serialize default values', () => {
class CreatingArbitrary extends Arbitrary {
static schema = new Schema({
type: 'object',
properties: {foo: {defaultValue: 'bar', type: 'string'}, bar: {type: 'uint8'}},
});
}
const Component = new CreatingArbitrary();
Component.create(1)
expect(Component.get(1).toJSON())
.to.deep.equal({});
Component.get(1).bar = 1;
expect(Component.get(1).toJSON())
.to.deep.equal({bar: 1});
});
test('reuses instances', () => {
class ReusingArbitrary extends Arbitrary {
static schema = new Schema({
type: 'object',
properties: {foo: {type: 'string'}},
});
}
const Component = new ReusingArbitrary();
Component.create(1);
const instance = Component.get(1);
Component.destroy(1);
expect(Component.get(1))
.to.be.undefined;
expect(() => {
Component.destroy(1);
})
.to.throw();
Component.create(1);
expect(Component.get(1))
.to.equal(instance);
});

98
app/ecs/base.js Normal file
View File

@ -0,0 +1,98 @@
export default class Base {
map = [];
pool = [];
static schema;
allocateMany(count) {
const results = [];
while (count-- > 0 && this.pool.length > 0) {
results.push(this.pool.pop());
}
return results;
}
create(entityId, values) {
this.createMany([[entityId, values]]);
}
destroy(entityId) {
this.destroyMany([entityId]);
}
destroyMany(entities) {
this.freeMany(
entities
.map((entityId) => {
if ('undefined' !== typeof this.map[entityId]) {
return this.map[entityId];
}
throw new Error(`can't free for non-existent id ${entityId}`);
}),
);
for (let i = 0; i < entities.length; i++) {
this.map[entities[i]] = undefined;
}
}
static filterDefaults(instance) {
const json = {};
for (const key in this.properties) {
const {defaultValue} = this.properties[key];
if (key in instance && instance[key] !== defaultValue) {
json[key] = instance[key];
}
}
return json;
}
freeMany(indices) {
for (let i = 0; i < indices.length; ++i) {
this.pool.push(indices[i]);
}
}
insertMany(entities) {
const creating = [];
for (let i = 0; i < entities.length; i++) {
const [entityId, values] = entities[i];
if (!this.get(entityId)) {
creating.push([entityId, values]);
}
else {
const instance = this.get(entityId);
for (const i in values) {
instance[i] = values[i];
}
}
}
this.createMany(creating);
}
// eslint-disable-next-line no-unused-vars
markChange(entityId, components) {}
mergeDiff(original, update) {
return {...original, ...update};
}
static get properties() {
return this.schema.specification.properties;
}
sizeOf(entityId) {
return this.constructor.schema.sizeOf(this.get(entityId));
}
static wrap(name, ecs) {
class WrappedComponent extends this {
markChange(entityId, key, value) {
ecs.markChange(entityId, {[name]: {[key]: value}})
}
}
return new WrappedComponent();
}
}

14
app/ecs/component.js Normal file
View File

@ -0,0 +1,14 @@
import Arbitrary from './arbitrary.js';
import Base from './base.js';
import Schema from './schema.js';
export default function Component(specificationOrClass) {
if (specificationOrClass instanceof Base) {
return specificationOrClass;
}
// Why the rigamarole? Maybe we'll implement a flat component for direct binary storage
// eventually.
return class AdhocComponent extends Arbitrary {
static schema = new Schema({type: 'object', properties: specificationOrClass});
};
}

401
app/ecs/ecs.js Normal file
View File

@ -0,0 +1,401 @@
import Component from './component.js';
import EntityFactory from './entity-factory.js';
import System from './system.js';
export default class Ecs {
$$caret = 1;
diff = {};
static Types = {};
Types = {};
$$entities = {};
$$entityFactory = new EntityFactory();
$$systems = [];
constructor() {
const {Types} = this.constructor;
for (const i in Types) {
this.Types[i] = Component(Types[i]).wrap(i, this);
}
}
addSystem(source) {
const system = System.wrap(source, this);
this.$$systems.push(system);
system.reindex(this.entities);
}
apply(patch) {
const creating = [];
const destroying = [];
const removing = [];
const updating = [];
for (const entityId in patch) {
const components = patch[entityId];
if (false === components) {
destroying.push(entityId);
continue;
}
const componentsToRemove = [];
const componentsToUpdate = {};
for (const i in components) {
if (false === components[i]) {
componentsToRemove.push(i);
}
else {
componentsToUpdate[i] = components[i];
}
}
if (componentsToRemove.length > 0) {
removing.push([entityId, componentsToRemove]);
}
if (this.$$entities[entityId]) {
updating.push([entityId, componentsToUpdate]);
}
else {
creating.push([entityId, componentsToUpdate]);
}
}
this.destroyMany(destroying);
this.insertMany(updating);
this.removeMany(removing);
this.createManySpecific(creating);
}
create(components = {}) {
const [entityId] = this.createMany([components]);
return entityId;
}
createMany(componentsList) {
const specificsList = [];
for (const components of componentsList) {
specificsList.push([this.$$caret++, components]);
}
return this.createManySpecific(specificsList);
}
createManySpecific(specificsList) {
const entityIds = [];
const creating = {};
for (let i = 0; i < specificsList.length; i++) {
const [entityId, components] = specificsList[i];
const componentKeys = [];
for (const key of Object.keys(components)) {
if (this.Types[key]) {
componentKeys.push(key);
}
}
entityIds.push(entityId);
this.rebuild(entityId, () => componentKeys);
for (const component of componentKeys) {
if (!creating[component]) {
creating[component] = [];
}
creating[component].push([entityId, components[component]]);
}
this.markChange(entityId, components);
}
for (const i in creating) {
this.Types[i].createMany(creating[i]);
}
this.reindex(entityIds);
return entityIds;
}
createSpecific(entityId, components) {
return this.createManySpecific([[entityId, components]]);
}
deindex(entityIds) {
for (let i = 0; i < this.$$systems.length; i++) {
this.$$systems[i].deindex(entityIds);
}
}
static deserialize(view) {
const ecs = new this();
let cursor = 0;
const count = view.getUint32(cursor, true);
const keys = Object.keys(ecs.Types);
cursor += 4;
const creating = new Map();
const updating = new Map();
const cursors = new Map();
for (let i = 0; i < count; ++i) {
const entityId = view.getUint32(cursor, true);
if (!ecs.$$entities[entityId]) {
creating.set(entityId, {});
}
cursor += 4;
const componentCount = view.getUint16(cursor, true);
cursor += 2;
cursors.set(entityId, {});
const addedComponents = [];
for (let j = 0; j < componentCount; ++j) {
const componentId = view.getUint16(cursor, true);
cursor += 2;
const component = keys[componentId];
if (!component) {
throw new Error(`can't decode component ${componentId}`);
}
if (!ecs.$$entities[entityId]) {
creating.get(entityId)[component] = false;
}
else if (!ecs.$$entities[entityId].constructor.types.includes(component)) {
addedComponents.push(component);
if (!updating.has(component)) {
updating.set(component, []);
}
updating.get(component).push([entityId, false]);
}
cursors.get(entityId)[component] = cursor;
cursor += ecs.Types[component].constructor.schema.readSize(view, cursor);
}
if (addedComponents.length > 0 && ecs.$$entities[entityId]) {
ecs.rebuild(entityId, (types) => types.concat(addedComponents));
}
}
ecs.createManySpecific(Array.from(creating.entries()));
for (const [component, entityIds] of updating) {
ecs.Types[component].createMany(entityIds);
}
for (const [entityId, components] of cursors) {
for (const component in components) {
ecs.Types[component].deserialize(entityId, view, components[component]);
}
}
return ecs;
}
destroy(entityId) {
this.destroyMany([entityId]);
}
destroyAll() {
this.destroyMany(this.entities);
}
destroyMany(entityIds) {
const destroying = {};
this.deindex(entityIds);
for (const entityId of entityIds) {
if (!this.$$entities[entityId]) {
throw new Error(`can't destroy non-existent entity ${entityId}`);
}
for (const component of this.$$entities[entityId].constructor.types) {
if (!destroying[component]) {
destroying[component] = [];
}
destroying[component].push(entityId);
}
this.$$entities[entityId] = undefined;
this.diff[entityId] = false;
}
for (const i in destroying) {
this.Types[i].destroyMany(destroying[i]);
}
}
get entities() {
const it = Object.values(this.$$entities).values();
return {
[Symbol.iterator]() {
return this;
},
next: () => {
let result = it.next();
while (!result.done && !result.value) {
result = it.next();
}
if (result.done) {
return {done: true, value: undefined};
}
return {done: false, value: result.value.id};
},
};
}
get(entityId) {
return this.$$entities[entityId];
}
insert(entityId, components) {
this.insertMany([[entityId, components]]);
}
insertMany(entities) {
const inserting = {};
const unique = new Set();
for (const [entityId, components] of entities) {
this.rebuild(entityId, (types) => [...new Set(types.concat(Object.keys(components)))]);
const diff = {};
for (const component in components) {
if (!inserting[component]) {
inserting[component] = [];
}
diff[component] = {};
inserting[component].push([entityId, components[component]]);
}
unique.add(entityId);
this.markChange(entityId, diff);
}
for (const component in inserting) {
this.Types[component].insertMany(inserting[component]);
}
this.reindex(unique.values());
}
markChange(entityId, components) {
// Deleted?
if (false === components) {
this.diff[entityId] = false;
}
// Created?
else if (!this.diff[entityId]) {
const filtered = {};
for (const type in components) {
filtered[type] = false === components[type]
? false
: this.Types[type].constructor.filterDefaults(components[type]);
}
this.diff[entityId] = filtered;
}
// Otherwise, merge.
else {
for (const type in components) {
this.diff[entityId][type] = false === components[type]
? false
: this.Types[type].mergeDiff(
this.diff[entityId][type] || {},
components[type],
);
}
}
}
rebuild(entityId, types) {
let existing = [];
if (this.$$entities[entityId]) {
existing.push(...this.$$entities[entityId].constructor.types);
}
const Class = this.$$entityFactory.makeClass(types(existing), this.Types);
this.$$entities[entityId] = new Class(entityId);
}
reindex(entityIds) {
for (let i = 0; i < this.$$systems.length; i++) {
this.$$systems[i].reindex(entityIds);
}
}
remove(entityId, components) {
this.removeMany([[entityId, components]]);
}
removeMany(entities) {
const removing = {};
const unique = new Set();
for (const [entityId, components] of entities) {
unique.add(entityId);
const diff = {};
for (const component of components) {
diff[component] = false;
if (!removing[component]) {
removing[component] = [];
}
removing[component].push(entityId);
}
this.markChange(entityId, diff);
this.rebuild(entityId, (types) => types.filter((type) => !components.includes(type)));
}
for (const component in removing) {
this.Types[component].destroyMany(removing[component]);
}
this.reindex(unique.values());
}
removeSystem(SystemLike) {
const index = this.$$systems.findIndex((system) => SystemLike === system.source);
if (-1 !== index) {
this.$$systems.splice(index, 1);
}
}
static serialize(ecs, view) {
if (!view) {
view = new DataView(new ArrayBuffer(ecs.size()));
}
let cursor = 0;
let entitiesWritten = 0;
cursor += 4;
const keys = Object.keys(ecs.Types);
for (const entityId of ecs.entities) {
const entity = ecs.get(entityId);
entitiesWritten += 1;
view.setUint32(cursor, entityId, true);
cursor += 4;
const entityComponents = entity.constructor.types;
view.setUint16(cursor, entityComponents.length, true);
const componentsWrittenIndex = cursor;
cursor += 2;
for (const component of entityComponents) {
const instance = ecs.Types[component];
view.setUint16(cursor, keys.indexOf(component), true);
cursor += 2;
instance.serialize(entityId, view, cursor);
cursor += instance.sizeOf(entityId);
}
view.setUint16(componentsWrittenIndex, entityComponents.length, true);
}
view.setUint32(0, entitiesWritten, true);
return view;
}
setClean() {
this.diff = {};
}
size() {
// # of entities.
let size = 4;
for (const entityId of this.entities) {
size += this.get(entityId).size();
}
return size;
}
system(SystemLike) {
return this.$$systems.find((system) => SystemLike === system.source)
}
tick(elapsed) {
for (let i = 0; i < this.$$systems.length; i++) {
this.$$systems[i].tick(elapsed);
}
for (let i = 0; i < this.$$systems.length; i++) {
this.$$systems[i].finalize(elapsed);
}
this.tickDestruction();
}
tickDestruction() {
const unique = new Set();
for (let i = 0; i < this.$$systems.length; i++) {
for (let j = 0; j < this.$$systems[i].destroying.length; j++) {
unique.add(this.$$systems[i].destroying[j]);
}
this.$$systems[i].tickDestruction();
}
if (unique.size > 0) {
this.destroyMany(unique.values());
}
}
}

459
app/ecs/ecs.test.js Normal file
View File

@ -0,0 +1,459 @@
import {expect, test} from 'vitest';
import Ecs from './ecs.js';
import System from './system.js';
const Empty = {};
const Name = {
name: {type: 'string'},
};
const Position = {
x: {type: 'int32', defaultValue: 32},
y: {type: 'int32'},
z: {type: 'int32'},
};
test('adds and remove systems at runtime', () => {
const ecs = new Ecs();
let oneCount = 0;
let twoCount = 0;
const oneSystem = () => {
oneCount++;
};
ecs.addSystem(oneSystem);
ecs.tick();
expect(oneCount)
.to.equal(1);
const twoSystem = () => {
twoCount++;
};
ecs.addSystem(twoSystem);
ecs.tick();
expect(oneCount)
.to.equal(2);
expect(twoCount)
.to.equal(1);
ecs.removeSystem(oneSystem);
ecs.tick();
expect(oneCount)
.to.equal(2);
expect(twoCount)
.to.equal(2);
});
test('creates entities with components', () => {
class CreateEcs extends Ecs {
static Types = {Empty, Position};
}
const ecs = new CreateEcs();
const entity = ecs.create({Empty: {}, Position: {y: 128}});
expect(JSON.stringify(ecs.get(entity)))
.to.deep.equal(JSON.stringify({Empty: {}, Position: {y: 128}}));
});
test("removes entities' components", () => {
class RemoveEcs extends Ecs {
static Types = {Empty, Position};
}
const ecs = new RemoveEcs();
const entity = ecs.create({Empty: {}, Position: {y: 128}});
ecs.remove(entity, ['Position']);
expect(JSON.stringify(ecs.get(entity)))
.to.deep.equal(JSON.stringify({Empty: {}}));
});
test('gets entities', () => {
class GetEcs extends Ecs {
static Types = {Empty, Position};
}
const ecs = new GetEcs();
const entity = ecs.create({Empty: {}, Position: {y: 128}});
expect(JSON.stringify(ecs.get(entity)))
.to.deep.equal(JSON.stringify({Empty: {}, Position: {y: 128}}));
});
test('destroys entities', () => {
class DestroyEcs extends Ecs {
static Types = {Empty, Position};
}
const ecs = new DestroyEcs();
const entity = ecs.create({Empty: {}, Position: {y: 128}});
expect(JSON.stringify(ecs.get(entity)))
.to.deep.equal(JSON.stringify({Empty: {}, Position: {y: 128}}));
expect(ecs.get(entity))
.to.not.be.undefined;
ecs.destroyAll();
expect(ecs.get(entity))
.to.be.undefined;
expect(() => {
ecs.destroy(entity);
})
.to.throw();
});
test('inserts components into entities', () => {
class InsertEcs extends Ecs {
static Types = {Empty, Position};
}
const ecs = new InsertEcs();
const entity = ecs.create({Empty: {}});
ecs.insert(entity, {Position: {y: 128}});
expect(JSON.stringify(ecs.get(entity)))
.to.deep.equal(JSON.stringify({Empty: {}, Position: {y: 128}}));
ecs.insert(entity, {Position: {y: 64}});
expect(JSON.stringify(ecs.get(entity)))
.to.deep.equal(JSON.stringify({Empty: {}, Position: {y: 64}}));
});
test('ticks systems', () => {
const Momentum = {
x: {type: 'int32'},
y: {type: 'int32'},
z: {type: 'int32'},
};
class TickEcs extends Ecs {
static Types = {Momentum, Position};
}
const ecs = new TickEcs();
class Physics extends System {
static queries() {
return {
default: ['Position', 'Momentum'],
};
}
tick(elapsed) {
for (const [position, momentum] of this.select('default')) {
position.x += momentum.x * elapsed;
position.y += momentum.y * elapsed;
position.z += momentum.z * elapsed;
}
}
}
ecs.addSystem(Physics);
const entity = ecs.create({Momentum: {}, Position: {y: 128}});
const position = JSON.stringify(ecs.get(entity).Position);
ecs.tick(1);
expect(JSON.stringify(ecs.get(entity).Position))
.to.deep.equal(position);
ecs.get(1).Momentum.y = 30;
ecs.tick(1);
expect(JSON.stringify(ecs.get(entity).Position))
.to.deep.equal(JSON.stringify({y: 128 + 30}));
});
test('creates many entities when ticking systems', () => {
const ecs = new Ecs();
class Spawn extends System {
tick() {
this.createManyEntities(Array.from({length: 5}).map(() => []));
}
}
ecs.addSystem(Spawn);
ecs.create();
expect(ecs.get(5))
.to.be.undefined;
ecs.tick(1);
expect(ecs.get(5))
.to.not.be.undefined;
});
test('creates entities when ticking systems', () => {
const ecs = new Ecs();
class Spawn extends System {
tick() {
this.createEntity();
}
}
ecs.addSystem(Spawn);
ecs.create();
expect(ecs.get(2))
.to.be.undefined;
ecs.tick(1);
expect(ecs.get(2))
.to.not.be.undefined;
});
test('schedules entities to be deleted when ticking systems', () => {
const ecs = new Ecs();
let entity;
class Despawn extends System {
finalize() {
entity = ecs.get(1);
}
tick() {
this.destroyEntity(1);
}
}
ecs.addSystem(Despawn);
ecs.create();
ecs.tick(1);
expect(entity)
.to.not.be.undefined;
expect(ecs.get(1))
.to.be.undefined;
});
test('adds components to and remove components from entities when ticking systems', () => {
class TickingEcs extends Ecs {
static Types = {Foo: {bar: {type: 'uint8'}}};
}
const ecs = new TickingEcs();
let addLength, removeLength;
class AddComponent extends System {
static queries() {
return {
default: ['Foo'],
};
}
tick() {
this.insertComponents(1, {Foo: {}});
}
finalize() {
addLength = Array.from(this.select('default')).length;
}
}
class RemoveComponent extends System {
static queries() {
return {
default: ['Foo'],
};
}
tick() {
this.removeComponents(1, ['Foo']);
}
finalize() {
removeLength = Array.from(this.select('default')).length;
}
}
ecs.addSystem(AddComponent);
ecs.create();
ecs.tick(1);
expect(addLength)
.to.equal(1);
expect(ecs.get(1).Foo)
.to.not.be.undefined;
ecs.removeSystem(AddComponent);
ecs.addSystem(RemoveComponent);
ecs.tick(1);
expect(removeLength)
.to.equal(0);
expect(ecs.get(1).Foo)
.to.be.undefined;
});
test('generates coalesced diffs for entity creation', () => {
const ecs = new Ecs();
let entity;
entity = ecs.create();
expect(ecs.diff)
.to.deep.equal({[entity]: {}});
});
test('generates diffs for adding and removing components', () => {
class DiffedEcs extends Ecs {
static Types = {Position};
}
const ecs = new DiffedEcs();
let entity;
entity = ecs.create();
ecs.setClean();
ecs.insert(entity, {Position: {x: 64}});
expect(ecs.diff)
.to.deep.equal({[entity]: {Position: {x: 64}}});
ecs.setClean();
expect(ecs.diff)
.to.deep.equal({});
ecs.remove(entity, ['Position']);
expect(ecs.diff)
.to.deep.equal({[entity]: {Position: false}});
});
test('generates diffs for empty components', () => {
class DiffedEcs extends Ecs {
static Types = {Empty};
}
const ecs = new DiffedEcs();
let entity;
entity = ecs.create({Empty});
expect(ecs.diff)
.to.deep.equal({[entity]: {Empty: {}}});
ecs.setClean();
ecs.remove(entity, ['Empty']);
expect(ecs.diff)
.to.deep.equal({[entity]: {Empty: false}});
});
test('generates diffs for entity mutations', () => {
class DiffedEcs extends Ecs {
static Types = {Position};
}
const ecs = new DiffedEcs();
let entity;
entity = ecs.create({Position: {}});
ecs.setClean();
ecs.get(entity).Position.x = 128;
expect(ecs.diff)
.to.deep.equal({[entity]: {Position: {x: 128}}});
ecs.setClean();
expect(ecs.diff)
.to.deep.equal({});
});
test('generates coalesced diffs for components', () => {
class DiffedEcs extends Ecs {
static Types = {Position};
}
const ecs = new DiffedEcs();
let entity;
entity = ecs.create({Position});
ecs.remove(entity, ['Position']);
expect(ecs.diff)
.to.deep.equal({[entity]: {Position: false}});
ecs.insert(entity, {Position: {}});
expect(ecs.diff)
.to.deep.equal({[entity]: {Position: {}}});
});
test('generates coalesced diffs for mutations', () => {
class DiffedEcs extends Ecs {
static Types = {Position};
}
const ecs = new DiffedEcs();
let entity;
entity = ecs.create({Position});
ecs.setClean();
ecs.get(entity).Position.x = 128;
ecs.get(entity).Position.x = 256;
ecs.get(entity).Position.x = 512;
expect(ecs.diff)
.to.deep.equal({[entity]: {Position: {x: 512}}});
});
test('generates diffs for deletions', () => {
const ecs = new Ecs();
let entity;
entity = ecs.create();
ecs.setClean();
ecs.destroy(entity);
expect(ecs.diff)
.to.deep.equal({[entity]: false});
});
test('applies creation patches', () => {
class PatchedEcs extends Ecs {
static Types = {Position};
}
const ecs = new PatchedEcs();
ecs.apply({16: {Position: {x: 64}}});
expect(Array.from(ecs.entities).length)
.to.equal(1);
expect(ecs.get(16).Position.x)
.to.equal(64);
});
test('applies update patches', () => {
class PatchedEcs extends Ecs {
static Types = {Position};
}
const ecs = new PatchedEcs();
ecs.createSpecific(16, {Position: {x: 64}});
ecs.apply({16: {Position: {x: 128}}});
expect(Array.from(ecs.entities).length)
.to.equal(1);
expect(ecs.get(16).Position.x)
.to.equal(128);
});
test('applies entity deletion patches', () => {
class PatchedEcs extends Ecs {
static Types = {Position};
}
const ecs = new PatchedEcs();
ecs.createSpecific(16, {Position: {x: 64}});
ecs.apply({16: false});
expect(Array.from(ecs.entities).length)
.to.equal(0);
});
test('applies component deletion patches', () => {
class PatchedEcs extends Ecs {
static Types = {Empty, Position};
}
const ecs = new PatchedEcs();
ecs.createSpecific(16, {Empty: {}, Position: {x: 64}});
expect(ecs.get(16).constructor.types)
.to.deep.equal(['Empty', 'Position']);
ecs.apply({16: {Empty: false}});
expect(ecs.get(16).constructor.types)
.to.deep.equal(['Position']);
});
test('calculates entity size', () => {
class SizingEcs extends Ecs {
static Types = {Empty, Position};
}
const ecs = new SizingEcs();
ecs.createSpecific(1, {Empty: {}, Position: {}});
// ID + # of components + Empty + Position + x + y + z
// 4 + 2 + 2 + 2 + 4 + 4 + 4 = 22
expect(ecs.get(1).size())
.to.equal(22);
});
test('serializes and deserializes', () => {
class SerializingEcs extends Ecs {
static Types = {Empty, Name, Position};
}
const ecs = new SerializingEcs();
// ID + # of components + Empty + Position + x + y + z
// 4 + 2 + 2 + 2 + 4 + 4 + 4 = 22
ecs.createSpecific(1, {Empty: {}, Position: {x: 64}});
// ID + # of components + Name + 'foobar' + Position + x + y + z
// 4 + 2 + 2 + 4 + 6 + 2 + 4 + 4 + 4 = 32
ecs.createSpecific(16, {Name: {name: 'foobar'}, Position: {x: 128}});
const view = SerializingEcs.serialize(ecs);
// # of entities + Entity(1) + Entity(16)
// 4 + 22 + 32 = 58
expect(view.byteLength)
.to.equal(58);
// Entity values.
expect(view.getUint32(4 + 22 - 12, true))
.to.equal(64);
expect(view.getUint32(4 + 22 + 32 - 12, true))
.to.equal(128);
const deserialized = SerializingEcs.deserialize(view);
// # of entities.
expect(Array.from(deserialized.entities).length)
.to.equal(2);
// Composition of entities.
expect(deserialized.get(1).constructor.types)
.to.deep.equal(['Empty', 'Position']);
expect(deserialized.get(16).constructor.types)
.to.deep.equal(['Name', 'Position']);
// Entity values.
expect(JSON.stringify(deserialized.get(1)))
.to.equal(JSON.stringify({Empty: {}, Position: {x: 64}}))
expect(JSON.stringify(deserialized.get(1).Position))
.to.equal(JSON.stringify({x: 64}));
expect(JSON.stringify(deserialized.get(16).Position))
.to.equal(JSON.stringify({x: 128}));
expect(deserialized.get(16).Name.name)
.to.equal('foobar');
});
test('deserializes empty', () => {
class SerializingEcs extends Ecs {
static Types = {Empty, Name, Position};
}
const ecs = SerializingEcs.deserialize(new DataView(new Uint32Array([0]).buffer));
expect(ecs)
.to.not.be.undefined;
});

View File

@ -7,7 +7,7 @@ export default class EntityFactory {
$$tries = new Node(); $$tries = new Node();
makeClass(types, Components) { makeClass(types, Types) {
const sorted = types.toSorted(); const sorted = types.toSorted();
let walk = this.$$tries; let walk = this.$$tries;
let i = 0; let i = 0;
@ -20,16 +20,24 @@ export default class EntityFactory {
} }
if (!walk.class) { if (!walk.class) {
class Entity { class Entity {
dirty = true;
static types = sorted; static types = sorted;
constructor(id) { constructor(id) {
this.id = id; this.id = id;
} }
size() {
let size = 0;
for (const component of this.constructor.types) {
const instance = Types[component];
size += 2 + instance.constructor.schema.sizeOf(instance.get(this.id));
}
// ID + # of components.
return size + 4 + 2;
}
} }
const properties = {}; const properties = {};
for (const type of sorted) { for (const type of sorted) {
properties[type] = {}; properties[type] = {};
const get = Components[type].get.bind(Components[type]); const get = Types[type].get.bind(Types[type]);
properties[type].get = function() { properties[type].get = function() {
return get(this.id); return get(this.id);
}; };
@ -37,7 +45,7 @@ export default class EntityFactory {
Object.defineProperties(Entity.prototype, properties); Object.defineProperties(Entity.prototype, properties);
Entity.prototype.toJSON = new Function('', ` Entity.prototype.toJSON = new Function('', `
return { return {
${sorted.map((type) => `${type}: this.${type}`).join(', ')} ${sorted.map((type) => `${type}: this.${type}.toJSON()`).join(', ')}
}; };
`); `);
walk.class = Entity; walk.class = Entity;

84
app/ecs/query.js Normal file
View File

@ -0,0 +1,84 @@
export default class Query {
$$criteria = {with: [], without: []};
$$index = new Set();
constructor(parameters, Types) {
for (let i = 0; i < parameters.length; ++i) {
const parameter = parameters[i];
switch (parameter.charCodeAt(0)) {
case '!'.charCodeAt(0):
this.$$criteria.without.push(Types[parameter.slice(1)]);
break;
default:
this.$$criteria.with.push(Types[parameter]);
break;
}
}
}
get count() {
return this.$$index.size;
}
deindex(entityIds) {
for (let i = 0; i < entityIds.length; ++i) {
this.$$index.delete(entityIds[i]);
}
}
reindex(entityIds) {
if (0 === this.$$criteria.with.length && 0 === this.$$criteria.without.length) {
for (const entityId of entityIds) {
this.$$index.add(entityId);
}
return;
}
for (const entityId of entityIds) {
let should = true;
for (let j = 0; j < this.$$criteria.with.length; ++j) {
if ('undefined' === typeof this.$$criteria.with[j].get(entityId)) {
should = false;
break;
}
}
if (should) {
for (let j = 0; j < this.$$criteria.without.length; ++j) {
if ('undefined' !== typeof this.$$criteria.without[j].get(entityId)) {
should = false;
break;
}
}
}
if (should) {
this.$$index.add(entityId);
}
else if (!should) {
this.$$index.delete(entityId);
}
}
}
select() {
const it = this.$$index.values();
const value = [];
return {
[Symbol.iterator]() {
return this;
},
next: () => {
const result = it.next();
if (result.done) {
return {done: true, value: undefined};
}
for (let i = 0; i < this.$$criteria.with.length; ++i) {
value[i] = this.$$criteria.with[i].get(result.value);
}
value[this.$$criteria.with.length] = result.value;
return {done: false, value};
},
};
}
}

72
app/ecs/query.test.js Normal file
View File

@ -0,0 +1,72 @@
import {expect, test} from 'vitest';
import Component from './component.js';
import Query from './query.js';
const A = new (Component({a: {type: 'int32', defaultValue: 420}}));
const B = new (Component({b: {type: 'int32', defaultValue: 69}}));
const C = new (Component({c: {type: 'int32'}}));
const Types = {A, B, C};
Types.A.createMany([[2], [3]]);
Types.B.createMany([[1], [2]]);
Types.C.createMany([[2], [4]]);
function testQuery(parameters, expected) {
const query = new Query(parameters, Types);
query.reindex([1, 2, 3]);
expect(query.count)
.to.equal(expected.length);
for (const _ of query.select()) {
expect(_.length)
.to.equal(parameters.filter((spec) => '!'.charCodeAt(0) !== spec.charCodeAt(0)).length + 1);
expect(expected.includes(_.pop()))
.to.equal(true);
}
}
test('can query all', () => {
testQuery([], [1, 2, 3]);
});
test('can query some', () => {
testQuery(['A'], [2, 3]);
testQuery(['A', 'B'], [2]);
});
test('can query excluding', () => {
testQuery(['!A'], [1]);
testQuery(['A', '!B'], [3]);
});
test('can deindex', () => {
const query = new Query(['A'], Types);
query.reindex([1, 2, 3]);
expect(query.count)
.to.equal(2);
query.deindex([2]);
expect(query.count)
.to.equal(1);
});
test('can reindex', () => {
const Test = new (Component({a: {type: 'int32', defaultValue: 420}}));
Test.createMany([[2], [3]]);
const query = new Query(['Test'], {Test});
query.reindex([2, 3]);
expect(query.count)
.to.equal(2);
Test.destroy(2);
query.reindex([2, 3]);
expect(query.count)
.to.equal(1);
});
test('can select', () => {
const query = new Query(['A'], Types);
query.reindex([1, 2, 3]);
const it = query.select();
const result = it.next();
expect(result.value[0].a)
.to.equal(420);
});

300
app/ecs/schema.js Normal file
View File

@ -0,0 +1,300 @@
const encoder = new TextEncoder();
export default class Schema {
$$size = 0;
specification;
static viewGetMethods = {
uint8: 'getUint8',
int8: 'getInt8',
uint16: 'getUint16',
int16: 'getInt16',
uint32: 'getUint32',
int32: 'getInt32',
float32: 'getFloat32',
float64: 'getFloat64',
int64: 'getBigInt64',
uint64: 'getBigUint64',
};
static viewSetMethods = {
uint8: 'setUint8',
int8: 'setInt8',
uint16: 'setUint16',
int16: 'setInt16',
uint32: 'setUint32',
int32: 'setInt32',
float32: 'setFloat32',
float64: 'setFloat64',
int64: 'setBigInt64',
uint64: 'setBigUint64',
};
constructor(specification) {
this.specification = this.constructor.normalize(specification);
}
static deserialize(view, offset, specification) {
const viewGetMethod = this.viewGetMethods[specification.type];
if (viewGetMethod) {
const value = view[viewGetMethod](offset.value, true);
offset.value += this.size(specification);
return value;
}
switch (specification.type) {
case 'array': {
const length = view.getUint32(offset.value, true);
offset.value += 4;
const value = [];
for (let i = 0; i < length; ++i) {
value.push(this.deserialize(view, offset, specification.subtype));
}
return value;
}
case 'object': {
const value = {};
for (const key in specification.properties) {
value[key] = this.deserialize(view, offset, specification.properties[key]);
}
return value;
}
case 'string': {
const length = view.getUint32(offset.value, true);
offset.value += 4;
const {buffer, byteOffset} = view;
const decoder = new TextDecoder();
const value = decoder.decode(new DataView(buffer, byteOffset + offset.value, length));
offset.value += length;
return value;
}
}
}
deserialize(view, offset = 0) {
const wrapped = {value: offset};
return this.constructor.deserialize(view, wrapped, this.specification);
}
static defaultValue(specification) {
if (specification.defaultValue) {
return specification.defaultValue;
}
switch (specification.type) {
case 'uint8': case 'int8':
case 'uint16': case 'int16':
case 'uint32': case 'int32':
case 'uint64': case 'int64':
case 'float32': case 'float64': {
return 0;
}
case 'array': {
return [];
}
case 'object': {
const object = {};
for (const key in specification.properties) {
object[key] = this.defaultValue(specification.properties[key]);
}
return object;
}
case 'string': {
return '';
}
}
}
defaultValue() {
return this.constructor.defaultValue(this.specification);
}
has(key) {
return key in this.specification;
}
static normalize(specification) {
let normalized = specification;
switch (specification.type) {
case 'array': {
normalized.subtype = this.normalize(specification.subtype);
break;
}
case 'object': {
for (const key in specification.properties) {
normalized.properties[key] = this.normalize(specification.properties[key])
}
break;
}
case 'uint8':
case 'int8':
case 'uint16':
case 'int16':
case 'uint32':
case 'int32':
case 'uint64':
case 'int64':
case 'float32':
case 'float64':
case 'string': {
break;
}
default:
throw new TypeError(`invalid specification: ${JSON.stringify(specification)}`);
}
return {...normalized, defaultValue: this.defaultValue(normalized)};
}
static readSize(view, offset, specification) {
const size = this.size(specification);
if (size > 0) {
return size;
}
switch (specification.type) {
case 'array': {
const length = view.getUint32(offset.value, true);
offset.value += 4;
let arraySize = 0;
for (let i = 0; i < length; ++i) {
arraySize += this.readSize(view, offset, specification.subtype);
}
return 4 + arraySize;
}
case 'object': {
let objectSize = 0;
for (const key in specification.properties) {
objectSize += this.readSize(view, offset, specification.properties[key]);
}
return objectSize;
}
case 'string': {
const length = view.getUint32(offset.value, true);
offset.value += 4 + length;
return 4 + length;
}
}
}
readSize(view, offset) {
const wrapped = {value: offset};
return this.constructor.readSize(view, wrapped, this.specification);
}
static serialize(source, view, offset, specification) {
const viewSetMethod = this.viewSetMethods[specification.type];
if (viewSetMethod) {
view[viewSetMethod](offset, source, true);
return this.size(specification);
}
switch (specification.type) {
case 'array': {
view.setUint32(offset, source.length, true);
offset += 4;
let arraySize = 0;
for (const element of source) {
arraySize += this.serialize(
element,
view,
offset + arraySize,
specification.subtype,
);
}
return 4 + arraySize;
}
case 'object': {
let objectSize = 0;
for (const key in specification.properties) {
objectSize += this.serialize(
source[key],
view,
offset + objectSize,
specification.properties[key],
);
}
return objectSize;
}
case 'string': {
const encoder = new TextEncoder();
const bytes = encoder.encode(source);
view.setUint32(offset, bytes.length, true);
offset += 4;
for (let i = 0; i < bytes.length; ++i) {
view.setUint8(offset++, bytes[i]);
}
return 4 + bytes.length;
}
}
}
serialize(source, view, offset = 0) {
this.constructor.serialize(source, view, offset, this.specification);
}
static sizeOf(concrete, specification) {
const size = this.size(specification);
if (size > 0) {
return size;
}
let fullSize = 0;
const {type} = specification;
switch (type) {
case 'array': {
fullSize += 4;
for (const element of concrete) {
fullSize += this.sizeOf(element, specification.subtype);
}
break;
}
case 'object': {
for (const key in specification.properties) {
fullSize += this.sizeOf(concrete[key], specification.properties[key]);
}
break;
}
case 'string':
fullSize += 4;
fullSize += (encoder.encode(concrete)).length;
break;
}
return fullSize;
}
sizeOf(concrete) {
return this.constructor.sizeOf(concrete, this.specification);
}
static size(specification) {
switch (specification.type) {
case 'array': return 0;
case 'object': {
let size = 0;
for (const key in specification.properties) {
const propertySize = this.size(specification.properties[key]);
if (0 === propertySize) {
return 0;
}
size += propertySize;
}
return size;
}
case 'uint8': case 'int8': {
return 1;
}
case 'uint16': case 'int16': {
return 2;
}
case 'uint32': case 'int32': case 'float32': {
return 4;
}
case 'uint64': case 'int64': case 'float64': {
return 8;
}
default: return 0;
}
}
size() {
return this.constructor.size(this.specification);
}
}

136
app/ecs/schema.test.js Normal file
View File

@ -0,0 +1,136 @@
import {expect, test} from 'vitest';
import Schema from './schema.js';
test('defaults values', () => {
[
'uint8',
'int8',
'uint16',
'int16',
'uint32',
'int32',
'uint64',
'int64',
'float32',
'float64',
].forEach((type) => {
expect(Schema.defaultValue({type}))
.to.equal(0);
expect(new Schema({type}).specification.defaultValue)
.to.equal(0);
});
expect(Schema.defaultValue({type: 'string'}))
.to.equal('');
expect(new Schema({type: 'string'}).specification.defaultValue)
.to.equal('');
expect(Schema.defaultValue({type: 'array', subtype: {type: 'string'}}))
.to.deep.equal([]);
expect(new Schema({type: 'array', subtype: {type: 'string'}}).specification.defaultValue)
.to.deep.equal([]);
expect(Schema.defaultValue({
type: 'object',
properties: {
foo: {type: 'uint8'},
bar: {type: 'string'},
baz: {type: 'object', properties: {blah: {type: 'array', subtype: {type: 'string'}}}},
},
}))
.to.deep.equal({foo: 0, bar: '', baz: {blah: []}});
expect(new Schema({
type: 'object',
properties: {
foo: {type: 'uint8'},
bar: {type: 'string'},
baz: {type: 'object', properties: {blah: {type: 'array', subtype: {type: 'string'}}}},
},
}).specification.defaultValue)
.to.deep.equal({foo: 0, bar: '', baz: {blah: []}});
});
test('validates a schema', () => {
[
'uint8',
'int8',
'uint16',
'int16',
'uint32',
'int32',
'uint64',
'int64',
'float32',
'float64',
'string',
].forEach((type) => {
expect(() => {
new Schema({type});
new Schema({type: 'array', subtype: {type}});
new Schema({type: 'object', properties: {foo: {type}}});
})
.to.not.throw();
});
});
test('calculates the size of concrete instances', () => {
expect(
(new Schema({type: 'string'}))
.sizeOf('hi')
)
.to.equal(4 + (new TextEncoder().encode('hi')).length);
expect(
(new Schema(
{type: 'object', properties: {
foo: {type: 'uint8'},
bar: {type: 'uint32'},
baz: {type: 'string'},
}}
))
.sizeOf({foo: 69, bar: 420, baz: 'aα'})
)
.to.equal(
1
+ 4
+ 4 + (new TextEncoder().encode('aα')).length
);
expect(
(new Schema({type: 'array', subtype: {type: 'string'}}))
.sizeOf(['hallo', 'hαllo'])
)
.to.equal(
4
+ 4 + (new TextEncoder().encode('hallo')).length
+ 4 + (new TextEncoder().encode('hαllo')).length
);
});
test('encodes and decodes', () => {
const entries = [
[{type: 'uint8'}, 255],
[{type: 'int8'}, -128],
[{type: 'int8'}, 127],
[{type: 'uint16'}, 65535],
[{type: 'int16'}, -32768],
[{type: 'int16'}, 32767],
[{type: 'uint32'}, 4294967295],
[{type: 'int32'}, -2147483648],
[{type: 'int32'}, 2147483647],
[{type: 'uint64'}, 18446744073709551615n],
[{type: 'int64'}, -9223372036854775808n],
[{type: 'int64'}, 9223372036854775807n],
[{type: 'float32'}, 0.5],
[{type: 'float64'}, 1.234],
[{type: 'string'}, 'hello world'],
[{type: 'string'}, 'α'],
[{type: 'array', subtype: {type: 'uint8'}}, [1, 2, 3, 4]],
[{type: 'array', subtype: {type: 'string'}}, ['one', 'two', 'three', 'four']],
[{type: 'object', properties: {foo: {type: 'uint8'}, bar: {type: 'string'}}}, {foo: 64, bar: 'baz'}],
];
entries.forEach(([specification, concrete]) => {
const schema = new Schema(specification);
const view = new DataView(new ArrayBuffer(schema.sizeOf(concrete)));
schema.serialize(concrete, view);
expect(concrete)
.to.deep.equal(schema.deserialize(view));
});
});

112
app/ecs/system.js Normal file
View File

@ -0,0 +1,112 @@
import Query from './query.js';
export default class System {
destroying = [];
ecs;
queries = {};
constructor(ecs) {
this.ecs = ecs;
const queries = this.constructor.queries();
for (const i in queries) {
this.queries[i] = new Query(queries[i], ecs.Types);
}
}
deindex(entityIds) {
for (const i in this.queries) {
this.queries[i].deindex(entityIds);
}
}
destroyEntity(entityId) {
this.destroyManyEntities([entityId]);
}
destroyManyEntities(entityIds) {
for (let i = 0; i < entityIds.length; i++) {
this.destroying.push(entityIds[i]);
}
}
finalize() {}
static normalize(SystemLike) {
if (SystemLike.prototype instanceof System) {
return SystemLike;
}
if ('function' === typeof SystemLike) {
class TickingSystem extends System {}
TickingSystem.prototype.tick = SystemLike;
return TickingSystem;
}
/* v8 ignore next */
throw new TypeError(`Couldn't normalize '${SystemLike}' as a system`);
}
static queries() {
return {};
}
reindex(entityIds) {
for (const i in this.queries) {
this.queries[i].reindex(entityIds);
}
}
select(query) {
return this.queries[query].select();
}
tickDestruction() {
this.deindex(this.destroying);
this.destroying = [];
}
tick() {}
static wrap(source, ecs) {
class WrappedSystem extends System.normalize(source) {
constructor() {
super(ecs);
this.reindex(ecs.entities);
}
createEntity(components) {
return this.ecs.create(components);
}
createManyEntities(componentsList) {
return this.ecs.createMany(componentsList);
}
get source() {
return source;
}
insertComponents(entityId, components) {
this.ecs.insert(entityId, components);
}
insertManyComponents(components) {
this.ecs.insertMany(components);
}
removeComponents(entityId, components) {
this.ecs.remove(entityId, components);
}
removeManyComponents(entityIds) {
this.ecs.removeMany(entityIds);
}
}
return new WrappedSystem();
}
}

8
app/engine/ecs.js Normal file
View File

@ -0,0 +1,8 @@
import Ecs from '@/ecs/ecs.js';
import Types from '@/ecs-components/index.js';
class EngineEcs extends Ecs {
static Types = Types;
}
export default EngineEcs;

200
app/engine/engine.js Normal file
View File

@ -0,0 +1,200 @@
import {
MOVE_MAP,
RESOLUTION,
TPS,
} from '@/constants.js';
import ControlMovement from '@/ecs-systems/control-movement.js';
import ApplyMomentum from '@/ecs-systems/apply-momentum.js';
import CalculateAabbs from '@/ecs-systems/calculate-aabbs.js';
import ClampPositions from '@/ecs-systems/clamp-positions.js';
import FollowCamera from '@/ecs-systems/follow-camera.js';
import UpdateSpatialHash from '@/ecs-systems/update-spatial-hash.js';
import RunAnimations from '@/ecs-systems/run-animations.js';
import ControlDirection from '@/ecs-systems/control-direction.js';
import SpriteDirection from '@/ecs-systems/sprite-direction.js';
import Ecs from '@/engine/ecs.js';
import {decode, encode} from '@/packets/index.js';
const players = {
0: {
Camera: {},
Controlled: {up: 0, right: 0, down: 0, left: 0},
Direction: {direction: 2},
Momentum: {},
Position: {x: 368, y: 368},
VisibleAabb: {},
World: {world: 1},
Sprite: {
animation: 'moving:down',
frame: 0,
frames: 8,
source: '/assets/dude.json',
speed: 0.115,
},
},
};
export default class Engine {
static Ecs = Ecs;
incomingActions = [];
connections = [];
connectedPlayers = new Map();
ecses = {};
frame = 0;
last = Date.now();
server;
constructor(Server) {
const ecs = new this.constructor.Ecs();
const layerSize = {x: Math.ceil(RESOLUTION.x / 4), y: Math.ceil(RESOLUTION.y / 4)};
ecs.create({
AreaSize: {x: RESOLUTION.x * 4, y: RESOLUTION.y * 4},
TileLayers: {
layers: [
{
data: (
Array(layerSize.x * layerSize.y)
.fill(0)
.map(() => 1 + Math.floor(Math.random() * 4))
),
size: layerSize,
}
],
},
});
ecs.addSystem(ControlMovement);
ecs.addSystem(ApplyMomentum);
ecs.addSystem(ClampPositions);
ecs.addSystem(FollowCamera);
ecs.addSystem(CalculateAabbs);
ecs.addSystem(UpdateSpatialHash);
ecs.addSystem(ControlDirection);
ecs.addSystem(SpriteDirection);
ecs.addSystem(RunAnimations);
this.ecses = {
1: ecs,
};
class SilphiusServer extends Server {
accept(connection, packed) {
super.accept(connection, decode(packed));
}
transmit(connection, packet) {
super.transmit(connection, encode(packet));
}
}
this.server = new SilphiusServer();
this.server.addPacketListener('Action', (connection, payload) => {
this.incomingActions.push([this.connectedPlayers.get(connection).entity, payload]);
});
}
async connectPlayer(connection) {
this.connections.push(connection);
const entityJson = await this.loadPlayer(connection);
const ecs = this.ecses[entityJson.World.world];
const entity = ecs.create(entityJson);
this.connectedPlayers.set(
connection,
{
entity: ecs.get(entity),
memory: new Set(),
},
);
}
disconnectPlayer(connection) {
const {entity} = this.connectedPlayers.get(connection);
const ecs = this.ecses[entity.World.world];
players[0] = JSON.parse(JSON.stringify(entity.toJSON()));
ecs.destroy(entity.id);
this.connectedPlayers.delete(connection);
this.connections.splice(this.connections.indexOf(connection), 1);
}
async load() {
}
async loadPlayer() {
return players[0];
}
start() {
return setInterval(() => {
const elapsed = (Date.now() - this.last) / 1000;
this.last = Date.now();
this.tick(elapsed);
this.update(elapsed);
this.frame += 1;
}, 1000 / TPS);
}
tick(elapsed) {
for (const i in this.ecses) {
this.ecses[i].setClean();
}
for (const [{Controlled}, payload] of this.incomingActions) {
if (payload.type in MOVE_MAP) {
Controlled[MOVE_MAP[payload.type]] = payload.value;
}
}
this.incomingActions = [];
for (const i in this.ecses) {
this.ecses[i].tick(elapsed);
}
}
update(elapsed) {
for (const connection of this.connections) {
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 mainEntityId = entity.id;
const ecs = this.ecses[entity.World.world];
const nearby = ecs.system(UpdateSpatialHash).nearby(entity);
// Master entity.
nearby.add(ecs.get(1));
const lastMemory = new Set(memory.values());
for (const entity of nearby) {
const {id} = entity;
lastMemory.delete(id);
if (!memory.has(id)) {
update[id] = entity.toJSON();
if (mainEntityId === id) {
update[id].MainEntity = {};
}
}
else if (ecs.diff[id]) {
update[id] = ecs.diff[id];
}
memory.add(id);
}
for (const id of lastMemory) {
memory.delete(id);
update[id] = false;
}
return update;
}
}

43
app/engine/engine.test.js Normal file
View File

@ -0,0 +1,43 @@
import {expect, test} from 'vitest';
import {RESOLUTION} from '@/constants.js'
import Server from '@/net/server/server.js';
import Engine from './engine.js';
test('visibility-based updates', async () => {
const engine = new Engine(Server);
const ecs = engine.ecses[1];
// Create an entity.
const entity = ecs.get(ecs.create({
Momentum: {x: 1, y: 0},
Position: {x: (RESOLUTION.x * 1.5) + 32 - 3, y: 20},
VisibleAabb: {},
}));
// Connect an entity.
await engine.connectPlayer(undefined);
// Tick and get update. Should be a full update.
engine.tick(1);
expect(engine.updateFor(undefined))
.to.deep.include({2: ecs.get(2).toJSON(), 3: {MainEntity: {}, ...ecs.get(3).toJSON()}});
// Tick and get update. Should be a partial update.
engine.tick(1);
expect(engine.updateFor(undefined))
.to.deep.equal({
2: {
Position: {x: (RESOLUTION.x * 1.5) + 32 - 1},
VisibleAabb: {
x0: 1199,
x1: 1263,
},
},
});
// Tick and get update. Should remove the entity.
engine.tick(1);
expect(engine.updateFor(undefined))
.to.deep.equal({2: false});
// Aim back toward visible area and tick. Should be a full update for that entity.
entity.Momentum.x = -1;
engine.tick(1);
expect(engine.updateFor(undefined))
.to.deep.equal({2: ecs.get(2).toJSON()});
});

23
app/engine/gather.js Normal file
View File

@ -0,0 +1,23 @@
function capitalize(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
export default function gather(imports, options = {}) {
const {
mapperForPath = (path) => path.replace(/\.\/(.*)\.js/, '$1'),
} = options;
const Gathered = {};
let id = 1;
for (const [path, Component] of Object.entries(imports).sort(([l], [r]) => l < r ? -1 : 1)) {
const key = mapperForPath(path)
.split('-')
.map(capitalize)
.join('');
if (Component.gathered) {
Component.gathered(id, key);
}
Gathered[key] = Gathered[id] = Component;
id += 1;
}
return Gathered;
}

18
app/engine/gather.test.js Normal file
View File

@ -0,0 +1,18 @@
import {expect, test} from 'vitest';
import gather from './gather.js';
test('gathers', async () => {
const Gathered = gather(
import.meta.glob('./test/*.js', {eager: true, import: 'default'}),
{mapperForPath: (path) => path.replace(/\.\/test\/(.*)\.js/, '$1')},
);
expect(Gathered.First.key)
.to.equal('First');
expect(Gathered[1].id)
.to.equal(1);
expect(Gathered.Second.key)
.to.equal('Second');
expect(Gathered[2].id)
.to.equal(2);
});

6
app/engine/test/first.js Normal file
View File

@ -0,0 +1,6 @@
export default class First {
static gathered(id, key) {
this.id = id;
this.key = key;
}
}

View File

@ -0,0 +1,6 @@
export default class Second {
static gathered(id, key) {
this.id = id;
this.key = key;
}
}

18
app/entry.client.jsx Normal file
View File

@ -0,0 +1,18 @@
/**
* By default, Remix will handle hydrating your app on the client for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal`
* For more information, see https://remix.run/file-conventions/entry.client
*/
import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser />
</StrictMode>
);
});

150
app/entry.server.jsx Normal file
View File

@ -0,0 +1,150 @@
/**
* By default, Remix will handle generating the HTTP Response for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal`
* For more information, see https://remix.run/file-conventions/entry.server
*/
import { PassThrough } from "node:stream";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import { isbot } from "isbot";
import { renderToPipeableStream } from "react-dom/server";
const ABORT_DELAY = 5_000;
export async function websocket(server, viteDevServer) {
if (viteDevServer) {
const {createViteRuntime} = await import('vite');
const runtime = await createViteRuntime(viteDevServer);
(await runtime.executeEntrypoint('/app/websocket.js')).default(server);
}
else {
(await import('./websocket.js')).default(server);
}
}
export default function handleRequest(
request,
responseStatusCode,
responseHeaders,
remixContext,
// This is ignored so we can keep it in the template for visibility. Feel
// free to delete this parameter in your app if you're not using it!
// eslint-disable-next-line no-unused-vars
loadContext
) {
return isbot(request.headers.get("user-agent") || "")
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
);
}
function handleBotRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onAllReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error) {
reject(error);
},
onError(error) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}
function handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onShellReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error) {
reject(error);
},
onError(error) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}

16
app/hooks/use-packet.js Normal file
View File

@ -0,0 +1,16 @@
import {useContext, useEffect} from 'react';
import ClientContext from '@/context/client.js';
export default function usePacket(type, fn, dependencies) {
const client = useContext(ClientContext);
useEffect(() => {
if (!client) {
return;
}
client.addPacketListener(type, fn);
return () => {
client.removePacketListener(type, fn);
};
}, [client, ...dependencies]);
}

42
app/net/client/client.js Normal file
View File

@ -0,0 +1,42 @@
import {CLIENT_LATENCY} from '@/constants.js';
export default class Client {
constructor() {
this.listeners = {};
}
accept(packet) {
const listeners = this.listeners[packet.type];
if (!listeners) {
return;
}
for (const i in listeners) {
listeners[i](packet.payload);
}
}
addPacketListener(type, listener) {
if (!this.listeners[type]) {
this.listeners[type] = [];
}
this.listeners[type].push(listener);
}
removePacketListener(type, listener) {
const listeners = this.listeners[type];
if (!listeners) {
return;
}
const index = listeners.indexOf(listener);
if (-1 !== index) {
listeners.splice(index, 1);
}
}
send(packet) {
if (CLIENT_LATENCY > 0) {
setTimeout(() => {
this.transmit(packet);
}, CLIENT_LATENCY);
}
else {
this.transmit(packet);
}
}
}

View File

@ -3,13 +3,16 @@ 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( this.worker = new Worker(
'/net/server/worker.js', new URL('../server/worker.js', import.meta.url),
{type: 'module'}, {type: 'module'},
); );
this.worker.onmessage = (event) => { this.worker.onmessage = (event) => {
this.accept(event.data); this.accept(event.data);
}; };
} }
disconnect() {
this.worker.terminate();
}
transmit(packed) { transmit(packed) {
this.worker.postMessage(packed); this.worker.postMessage(packed);
} }

View File

@ -0,0 +1,36 @@
import {encode} from '@/packets/index.js';
let connected = false;
let socket;
function onMessage(event) {
postMessage(event.data);
}
onmessage = async (event) => {
if (!connected) {
const url = new URL(`wss://${event.data.host}/ws`)
if ('production' === process.env.NODE_ENV) {
url.protocol = 'ws:';
}
socket = new WebSocket(url.href);
socket.binaryType = 'arraybuffer';
const {promise, resolve} = Promise.withResolvers();
socket.addEventListener('open', resolve);
socket.addEventListener('error', () => {
postMessage(encode({type: 'ConnectionStatus', payload: 'aborted'}));
close();
});
await promise;
socket.removeEventListener('open', resolve);
socket.addEventListener('message', onMessage);
socket.addEventListener('close', () => {
postMessage(encode({type: 'ConnectionStatus', payload: 'aborted'}));
close();
});
postMessage(encode({type: 'ConnectionStatus', payload: 'connected'}));
connected = true;
return;
}
socket.send(event.data);
};

67
app/net/client/remote.js Normal file
View File

@ -0,0 +1,67 @@
import {CLIENT_PREDICTION} from '@/constants.js';
import {encode} from '@/packets/index.js';
import Client from './client.js';
export default class RemoteClient extends Client {
constructor() {
super();
if (CLIENT_PREDICTION) {
this.worker = undefined;
}
else {
this.socket = undefined;
}
}
async connect(host) {
if (CLIENT_PREDICTION) {
this.worker = new Worker(
new URL('../client/prediction.js', import.meta.url),
{type: 'module'},
);
this.worker.postMessage({host});
this.worker.onmessage = (event) => {
this.accept(event.data);
};
}
else {
const url = new URL(`wss://${host}/ws`)
if ('production' === process.env.NODE_ENV) {
url.protocol = 'ws:';
}
this.socket = new WebSocket(url.href);
this.socket.binaryType = 'arraybuffer';
const onMessage = (event) => {
this.accept(event.data);
}
const {promise, resolve} = Promise.withResolvers();
this.socket.addEventListener('open', resolve);
this.socket.addEventListener('error', () => {
this.accept(encode({type: 'ConnectionStatus', payload: 'aborted'}));
});
await promise;
this.socket.removeEventListener('open', resolve);
this.socket.addEventListener('message', onMessage);
this.socket.addEventListener('close', () => {
this.accept(encode({type: 'ConnectionStatus', payload: 'aborted'}));
});
this.accept(encode({type: 'ConnectionStatus', payload: 'connected'}));
}
}
disconnect() {
if (CLIENT_PREDICTION) {
this.worker.terminate();
}
else {
this.socket.close();
}
}
transmit(packed) {
if (CLIENT_PREDICTION) {
this.worker.postMessage(packed);
}
else {
this.socket.send(packed);
}
}
}

34
app/net/packet.js Normal file
View File

@ -0,0 +1,34 @@
import {Encoder, Decoder} from '@msgpack/msgpack';
const decoder = new Decoder();
const encoder = new Encoder();
export default class Packet {
static id;
static type;
static decode(view) {
return decoder.decode(new DataView(view.buffer, view.byteOffset + 2, view.byteLength - 2));
}
static encode(payload) {
encoder.pos = 2;
encoder.doEncode(payload)
return new DataView(encoder.bytes.buffer, 0, encoder.pos);
}
static gathered(id, type) {
this.id = id;
this.type = type;
}
static pack(payload) {
return payload;
}
static unpack(packed) {
return packed;
}
}

30
app/net/packet.test.js Normal file
View File

@ -0,0 +1,30 @@
import {expect, test} from 'vitest';
import Packet from './packet.js';
class PackedPacket extends Packet {
static map = {
1: 'one',
2: 'two',
one: 1,
two: 2,
};
static pack(payload) {
return Object.fromEntries(Object.entries(payload).map(([k, v]) => [k, this.map[v]]));
}
static unpack(payload) {
return Object.fromEntries(Object.entries(payload).map(([k, v]) => [k, this.map[v]]));
}
}
test('packs and unpacks', async () => {
const payload = {foo: 'one', bar: 'two'};
const encoded = PackedPacket.encode(payload);
expect(Packet.decode(encoded))
.to.deep.equal(payload);
const packed = PackedPacket.pack(payload)
expect(packed)
.to.deep.equal({foo: 1, bar: 2});
expect(PackedPacket.unpack(packed))
.to.deep.equal({foo: 'one', bar: 'two'});
});

42
app/net/server/server.js Normal file
View File

@ -0,0 +1,42 @@
import {SERVER_LATENCY} from '@/constants.js';
export default class Server {
constructor() {
this.listeners = {};
}
accept(connection, packet) {
const listeners = this.listeners[packet.type];
if (!listeners) {
return;
}
for (const i in listeners) {
listeners[i](connection, packet.payload);
}
}
addPacketListener(type, listener) {
if (!this.listeners[type]) {
this.listeners[type] = [];
}
this.listeners[type].push(listener);
}
removePacketListener(type, listener) {
const listeners = this.listeners[type];
if (!listeners) {
return;
}
const index = listeners.indexOf(listener);
if (-1 !== index) {
listeners.splice(index, 1);
}
}
send(connection, packet) {
if (SERVER_LATENCY > 0) {
setTimeout(() => {
this.transmit(connection, packet);
}, SERVER_LATENCY);
}
else {
this.transmit(connection, packet);
}
}
}

26
app/net/server/worker.js Normal file
View File

@ -0,0 +1,26 @@
import Engine from '../../engine/engine.js';
import {encode} from '@/packets/index.js';
import Server from './server.js';
class WorkerServer extends Server {
transmit(connection, packed) { postMessage(packed); }
}
const engine = new Engine(WorkerServer);
onmessage = (event) => {
engine.server.accept(undefined, event.data);
};
(async () => {
await engine.load();
engine.start();
await engine.connectPlayer(undefined);
postMessage(encode({type: 'ConnectionStatus', payload: 'connected'}));
})();
import.meta.hot.accept('../../engine/engine.js', () => {
postMessage(encode({type: 'ConnectionStatus', payload: 'aborted'}));
close();
});

View File

@ -1,4 +1,4 @@
import Packet from './packet.js'; import Packet from '@/net/packet.js';
const WIRE_MAP = { const WIRE_MAP = {
'moveUp': 0, 'moveUp': 0,
@ -13,8 +13,6 @@ Object.entries(WIRE_MAP)
export default class Action extends Packet { export default class Action extends Packet {
static type = 'action';
static pack(payload) { static pack(payload) {
return super.pack({ return super.pack({
type: WIRE_MAP[payload.type], type: WIRE_MAP[payload.type],
@ -30,4 +28,4 @@ export default class Action extends Packet {
}; };
} }
}; }

View File

@ -0,0 +1,3 @@
import Packet from '@/net/packet.js';
export default class ConnectionStatus extends Packet {}

20
app/packets/index.js Normal file
View File

@ -0,0 +1,20 @@
import gather from '@/engine/gather.js';
const Gathered = gather(import.meta.glob('./*.js', {eager: true, import: 'default'}));
export function decode(packed) {
const view = ArrayBuffer.isView(packed) ? packed : new DataView(packed);
const id = view.getUint16(0, true);
const Packet = Gathered[id];
return {
type: Packet.type,
payload: Packet.decode(view),
};
}
export function encode(packet) {
const Packet = Gathered[packet.type];
const encoded = Packet.encode(packet.payload);
encoded.setUint16(0, Packet.id, true);
return encoded;
}

3
app/packets/tick.js Normal file
View File

@ -0,0 +1,3 @@
import Packet from '@/net/packet.js';
export default class Tick extends Packet {}

View File

@ -0,0 +1,27 @@
import {useEffect, useState} from 'react';
import styles from './disconnected.module.css';
export default function Disconnected() {
const [dots, setDots] = useState(3);
const [delta, setDelta] = useState(1);
useEffect(() => {
const handle = setTimeout(() => {
const updated = dots + delta;
setDots(updated);
if (updated < 1 || updated > 5) {
setDelta(-delta);
}
}, 100);
return () => {
clearTimeout(handle);
};
}, [dots, delta]);
const rendered = Array(dots).fill('.').join('');
return (
<div className={styles.disconnected}>
<p>There&apos;s a problem with the connection.</p>
<p>{rendered}Reconnection attempt in progress{rendered}</p>
</div>
);
}

View File

@ -0,0 +1,14 @@
.disconnected {
align-items: center;
background-color: rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
text-align: center;
width: 100%;
p {
color: #cccccc;
font-size: 2em;
}
}

View File

@ -17,7 +17,7 @@ export default function Dom({children}) {
function onResize() { function onResize() {
const {parentNode} = ref.current; const {parentNode} = ref.current;
const {width} = parentNode.getBoundingClientRect(); const {width} = parentNode.getBoundingClientRect();
setScale(width / RESOLUTION[0]); setScale(width / RESOLUTION.x);
} }
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
onResize(); onResize();
@ -28,7 +28,12 @@ export default function Dom({children}) {
return ( return (
<div className={styles.dom} ref={ref}> <div className={styles.dom} ref={ref}>
{scale > 0 && ( {scale > 0 && (
<style>{`.${styles.dom}{--scale:${scale}}`}</style> <style>{`
.${styles.dom}{
--scale: ${scale};
--unit: calc(${RESOLUTION.x} / 1000);
}
`}</style>
)} )}
{children} {children}
</div> </div>

View File

@ -0,0 +1,60 @@
import {Container} from '@pixi/react';
import {useState} from 'react';
import {RESOLUTION} from '@/constants.js';
import Ecs from '@/engine/ecs.js';
import usePacket from '@/hooks/use-packet.js';
import Entities from './entities.jsx';
import TileLayer from './tile-layer.jsx';
export default function EcsComponent() {
const [ecs] = useState(new Ecs());
const [entities, setEntities] = useState({});
const [mainEntity, setMainEntity] = useState();
usePacket('Tick', (payload) => {
if (0 === Object.keys(payload.ecs).length) {
return;
}
ecs.apply(payload.ecs);
const updatedEntities = {...entities};
for (const id in payload.ecs) {
if (false === payload.ecs[id]) {
delete updatedEntities[id];
}
else {
updatedEntities[id] = ecs.get(id);
if (updatedEntities[id].MainEntity) {
setMainEntity(ecs.get(id));
}
}
}
setEntities(updatedEntities);
}, [entities, mainEntity]);
if (!mainEntity) {
return false;
}
const {Camera} = mainEntity;
const {TileLayers} = ecs.get(1);
const [cx, cy] = [
Math.round(Camera.x - RESOLUTION.x / 2),
Math.round(Camera.y - RESOLUTION.y / 2),
];
return (
<Container>
<TileLayer
size={TileLayers.layers[0].size}
tiles={TileLayers.layers[0].data}
tileset="/assets/tileset.json"
tileSize={{x: 16, y: 16}}
x={-cx}
y={-cy}
/>
<Entities
entities={entities}
x={-cx}
y={-cy}
/>
</Container>
)
}

View File

@ -0,0 +1,24 @@
import {Container} from '@pixi/react';
import Sprite from './sprite.jsx';
export default function Entities({entities, x, y}) {
const sprites = [];
for (const id in entities) {
const entity = entities[id];
if (!entity.Position || !entity.Sprite) {
continue;
}
sprites.push(
<Sprite
entity={entity}
key={id}
/>
);
}
return (
<Container x={x} y={y}>
{sprites}
</Container>
)
}

View File

@ -1,15 +1,17 @@
.hotbar { .hotbar {
border: 2px solid #999999; --border: calc(var(--unit) * 3px);
background-color: rgba(0, 0, 0, 0.2);
border: var(--border) solid #444444;
box-sizing: border-box; box-sizing: border-box;
display: inline-block; display: inline-block;
left: 135px; left: calc(var(--unit) * 225px);
line-height: 0; line-height: 0;
position: absolute; position: absolute;
top: 20px; top: calc(var(--unit) * 25px);
} }
.slotWrapper { .slotWrapper {
border: 2px solid #999999; border: var(--border) solid #999999;
box-sizing: border-box; box-sizing: border-box;
display: inline-block; display: inline-block;
line-height: 0; line-height: 0;
@ -19,7 +21,7 @@
} }
&:hover { &:hover {
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.2);
cursor: pointer; cursor: pointer;
} }
} }

View File

@ -0,0 +1,50 @@
import {
Stage as PixiStage,
} from '@pixi/react';
import {SCALE_MODES} from '@pixi/constants';
import {settings} from '@pixi/settings';
import {RESOLUTION} from '@/constants.js';
import ClientContext from '@/context/client.js';
import Ecs from './ecs.jsx';
import styles from './pixi.module.css';
settings.SCALE_MODE = SCALE_MODES.NEAREST;
const ContextBridge = ({ children, Context, render }) => {
return (
<Context.Consumer>
{(value) =>
render(<Context.Provider value={value}>{children}</Context.Provider>)
}
</Context.Consumer>
);
};
export const Stage = ({children, ...props}) => {
return (
<ContextBridge
Context={ClientContext}
render={(children) => <PixiStage {...props}>{children}</PixiStage>}
>
{children}
</ContextBridge>
);
};
export default function Pixi() {
return (
<Stage
className={styles.stage}
width={RESOLUTION.x}
height={RESOLUTION.y}
options={{
background: 0x1099bb,
}}
>
<Ecs />
</Stage>
);
}

View File

@ -1,32 +1,33 @@
.slot { .slot {
--size: 35px; --size: calc(var(--unit) * 50px);
--base: calc(var(--size) / 5); --space: calc(var(--unit) * 10px);
background-position: center; background-position: center;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: contain; background-size: contain;
box-sizing: border-box;
display: inline-block; display: inline-block;
height: var(--size);
image-rendering: pixelated; image-rendering: pixelated;
padding: var(--base);
user-select: none; user-select: none;
width: var(--size);
} }
.slotInner { .slotInner {
background-position: center; background-position: center;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: contain; background-size: contain;
height: calc(var(--base) * 5); height: calc(100% - var(--space) * 2);
padding: var(--space);
position: relative; position: relative;
width: calc(var(--base) * 5); width: calc(100% - var(--space) * 2);
} }
.qty { .qty {
bottom: calc(var(--base) / -1.25); bottom: calc(var(--space) / -1.25);
font-family: monospace; font-family: monospace;
font-size: calc(var(--base) * 2); font-size: calc(var(--space) * 2);
line-height: 1; line-height: 1;
position: absolute; position: absolute;
right: calc(var(--base) / -1.25); right: calc(var(--space) / -1.25);
text-shadow: text-shadow:
0px -1px 0px white, 0px -1px 0px white,
1px 0px 0px white, 1px 0px 0px white,
@ -34,12 +35,12 @@
-1px 0px 0px white -1px 0px 0px white
; ;
&:global(.q-2) { &:global(.q-2) {
font-size: calc(var(--base) * 1.75); font-size: calc(var(--space) * 1.75);
} }
&:global(.q-3) { &:global(.q-3) {
font-size: calc(var(--base) * 1.5); font-size: calc(var(--space) * 1.5);
} }
&:global(.q-4) { &:global(.q-4) {
font-size: calc(var(--base) * 1.25); font-size: calc(var(--space) * 1.25);
} }
} }

View File

@ -0,0 +1,35 @@
import {Assets} from '@pixi/assets';
import {Sprite as PixiSprite} from '@pixi/react';
import {useEffect, useState} from 'react';
export default function Sprite({entity}) {
const [asset, setAsset] = useState();
useEffect(() => {
const asset = Assets.get(entity.Sprite.source);
if (asset) {
setAsset(asset);
}
else {
Assets.load(entity.Sprite.source).then(setAsset);
}
}, [setAsset, entity.Sprite.source]);
if (!asset) {
return false;
}
let texture;
if (asset.textures) {
const animation = asset.animations[entity.Sprite.animation]
texture = animation[entity.Sprite.frame];
}
else {
texture = asset;
}
return (
<PixiSprite
anchor={0.5}
texture={texture}
x={Math.round(entity.Position.x)}
y={Math.round(entity.Position.y)}
/>
);
}

View File

@ -0,0 +1,50 @@
import {useEffect, useState} from 'react';
import {Assets} from '@pixi/assets';
import {PixiComponent} from '@pixi/react';
import '@pixi/spritesheet'; // NECESSARY!
import {CompositeTilemap} from '@pixi/tilemap';
const TileLayerInternal = PixiComponent('TileLayer', {
create: () => new CompositeTilemap(),
applyProps: (tilemap, {tiles: oldTiles}, props) => {
const {asset, tiles, tileset, tileSize, size, x, y} = props;
const extless = tileset.slice('/assets/'.length, -'.json'.length);
const {textures} = asset;
tilemap.position.x = x;
tilemap.position.y = y;
if (tiles === oldTiles) {
return;
}
tilemap.clear();
let i = 0;
for (let y = 0; y < size.y; ++y) {
for (let x = 0; x < size.x; ++x) {
tilemap.tile(textures[`${extless}/${tiles[i++]}`], tileSize.x * x, tileSize.y * y);
}
}
},
})
export default function TileLayer(props) {
const {tileset} = props;
const [asset, setAsset] = useState();
useEffect(() => {
const asset = Assets.get(tileset);
if (asset) {
setAsset(asset);
}
else {
Assets.load(tileset).then(setAsset);
}
}, [setAsset, tileset]);
if (!asset) {
return false;
}
return (
<TileLayerInternal
{...props}
asset={asset}
tileset={tileset}
/>
);
};

View File

@ -1,28 +1,45 @@
import {useContext, useEffect} from 'react'; import {useContext, useEffect, useState} from 'react';
import addKeyListener from '@/add-key-listener.js'; import addKeyListener from '@/add-key-listener.js';
import {ACTION_MAP, RESOLUTION} from '@/constants.js'; import {ACTION_MAP, RESOLUTION} from '@/constants.js';
import ClientContext from '@/context/client.js'; import ClientContext from '@/context/client.js';
import Disconnected from './disconnected.jsx';
import Dom from './dom.jsx'; import Dom from './dom.jsx';
import HotBar from './hotbar.jsx';
import Pixi from './pixi.jsx'; import Pixi from './pixi.jsx';
import styles from './ui.module.css'; import styles from './ui.module.css';
const ratio = RESOLUTION[0] / RESOLUTION[1]; const ratio = RESOLUTION.x / RESOLUTION.y;
const KEY_MAP = { const KEY_MAP = {
keyDown: 1, keyDown: 1,
keyUp: 0, keyUp: 0,
}; };
export default function Ui() { export default function Ui({disconnected}) {
// Key input. // Key input.
const client = useContext(ClientContext); const client = useContext(ClientContext);
const [showDisconnected, setShowDisconnected] = useState(false);
useEffect(() => {
let handle;
if (disconnected) {
handle = setTimeout(() => {
setShowDisconnected(true);
}, 200);
}
else {
setShowDisconnected(false)
}
return () => {
clearTimeout(handle);
};
}, [disconnected]);
useEffect(() => { useEffect(() => {
return addKeyListener(document.body, ({type, payload}) => { return addKeyListener(document.body, ({type, payload}) => {
if (type in KEY_MAP && payload in ACTION_MAP) { if (type in KEY_MAP && payload in ACTION_MAP) {
client.send({ client.send({
type: 'action', type: 'Action',
payload: { payload: {
type: ACTION_MAP[payload], type: ACTION_MAP[payload],
value: KEY_MAP[type], value: KEY_MAP[type],
@ -41,13 +58,10 @@ export default function Ui() {
</style> </style>
<Pixi /> <Pixi />
<Dom> <Dom>
<div <HotBar active={0} slots={Array(10).fill(0).map(() => {})} />
style={{ {showDisconnected && (
backgroundColor: 'rgba(0, 0, 0, 0.1)', <Disconnected />
height: '225px', )}
width: '400px',
}}
></div>
</Dom> </Dom>
</div> </div>
); );

View File

@ -6,11 +6,3 @@ html, body {
margin: 0; margin: 0;
width: 100%; width: 100%;
} }
.silphius {
display: flex;
height: 100%;
justify-content: space-around;
line-height: 1;
width: 100%;
}

31
app/root.jsx Normal file
View File

@ -0,0 +1,31 @@
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
import './root.css';
export function Layout({ children }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}

View File

@ -0,0 +1,13 @@
.title {
font-size: 10em;
text-align: center;
}
.actions {
font-size: 3em;
list-style: none;
text-align: center;
li {
margin-bottom: 1em;
}
}

View File

@ -0,0 +1,25 @@
import styles from './index.module.css';
export const meta = () => {
return [
{
title: 'Silphius',
},
{
name: 'description',
content: 'Silphius is an action RPG and homestead simulator',
},
];
};
export default function Index() {
return (
<div>
<h1 className={styles.title}>Silphius</h1>
<ul className={styles.actions}>
<li><a href="/play/local">Single-player</a></li>
<li><a href="/play/remote/localhost:3000">Multi-player</a></li>
</ul>
</div>
);
}

View File

@ -0,0 +1,7 @@
.play {
display: flex;
height: 100%;
justify-content: space-around;
line-height: 0;
width: 100%;
}

View File

@ -0,0 +1,86 @@
import {useEffect, useState} from 'react';
import {useParams} from 'react-router-dom';
import ClientContext from '@/context/client.js';
import LocalClient from '@/net/client/local.js';
import RemoteClient from '@/net/client/remote.js';
import {decode, encode} from '@/packets/index.js';
import Ui from '@/react-components/ui.jsx';
import styles from './play.module.css';
export default function Index() {
const [client, setClient] = useState();
const [disconnected, setDisconnected] = useState(false);
const params = useParams();
const [type, url] = params['*'].split('/');
useEffect(() => {
let Client;
switch (type) {
case 'local':
Client = LocalClient;
break;
case 'remote':
Client = RemoteClient;
break;
}
class SilphiusClient extends Client {
accept(packed) {
super.accept(decode(packed));
}
transmit(packet) {
super.transmit(encode(packet));
}
}
const client = new SilphiusClient();
async function connect() {
await client.connect(url);
setClient(client);
}
connect();
return () => {
client.disconnect();
};
}, [type, url]);
useEffect(() => {
if (!client) {
return;
}
function onConnectionStatus(status) {
switch (status) {
case 'aborted': {
setDisconnected(true);
break;
}
case 'connected': {
setDisconnected(false);
break;
}
}
}
client.addPacketListener('ConnectionStatus', onConnectionStatus);
return () => {
client.removePacketListener('ConnectionStatus', onConnectionStatus);
};
}, [client]);
useEffect(() => {
if (!disconnected) {
return;
}
async function reconnect() {
await client.connect(url);
}
reconnect();
const handle = setInterval(reconnect, 1000);
return () => {
clearInterval(handle);
};
}, [client, disconnected, url]);
return (
<div className={styles.play}>
<ClientContext.Provider value={client}>
<Ui disconnected={disconnected} />
</ClientContext.Provider>
</div>
);
}

View File

@ -0,0 +1,5 @@
.main-menu {
height: 100%;
line-height: 1;
width: 100%;
}

View File

@ -0,0 +1,11 @@
import {Outlet} from 'react-router-dom';
import styles from './main-menu.module.css';
export default function MainMenu() {
return (
<div className={styles['main-menu']}>
<Outlet />
</div>
);
}

63
app/websocket.js Normal file
View File

@ -0,0 +1,63 @@
import {WebSocketServer} from 'ws';
import Engine from './engine/engine.js';
import Server from './net/server/server.js';
const wss = new WebSocketServer({
noServer: true,
});
function onUpgrade(request, socket, head) {
const {pathname} = new URL(request.url, 'wss://base.url');
if (pathname === '/ws') {
wss.handleUpgrade(request, socket, head, function done(ws) {
wss.emit('connection', ws, request);
});
}
else {
socket.destroy();
}
}
export default async function listen(server) {
server.on('upgrade', onUpgrade);
class SocketServer extends Server {
transmit(ws, packed) { ws.send(packed); }
}
let onConnect;
function makeOnConnect(engine) {
return async (ws) => {
ws.on('close', () => {
engine.disconnectPlayer(ws);
})
ws.on('message', (packed) => {
engine.server.accept(ws, new DataView(packed.buffer, packed.byteOffset, packed.length));
});
await engine.connectPlayer(ws);
};
}
let engine;
async function makeEngine(Engine) {
const engine = new Engine(SocketServer);
await engine.load();
engine.start();
return engine;
}
engine = await makeEngine(Engine);
wss.on('connection', onConnect = makeOnConnect(engine));
if (import.meta.hot) {
import.meta.hot.accept('./engine/engine.js', async ({default: Engine}) => {
wss.off('connection', onConnect);
for (const [connection] of engine.connectedPlayers) {
connection.close();
}
engine = await makeEngine(Engine);
wss.on('connection', onConnect = makeOnConnect(engine));
});
}
}

View File

@ -1,25 +0,0 @@
// eslint.config.js
import globals from 'globals';
import js from '@eslint/js';
export default [
js.configs.recommended,
{
languageOptions: {
globals: {
...globals.browser,
process: false,
},
parserOptions: {
ecmaFeatures: {
impliedStrict: true,
jsx: true
},
},
},
rules: {
// 'no-unused-vars': 'warn',
// 'no-undef': 'warn',
}
}
];

10050
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,44 +1,57 @@
{ {
"name": "silphius", "name": "silphius-next",
"private": true, "private": true,
"version": "1.0.0", "sideEffects": false,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "npm run vite", "build": "remix vite:build",
"build": "npm run vite build", "dev": "node ./server.js",
"preview": "npm run vite preview", "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"server": "esbuild src/net/server/socket.js --bundle --format=esm --alias:@=`pwd`/src --platform=node --external:./node_modules/* | node --input-type=module -", "start": "cross-env NODE_ENV=production node ./server.js",
"start": "npm run dev",
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",
"storybook:build": "storybook build", "storybook:build": "storybook build"
"vite": "vite --config vite.config.js src"
},
"devDependencies": {
"@chromatic-com/storybook": "^1.4.0",
"@eslint/js": "^9.3.0",
"@storybook/addon-essentials": "^8.1.3",
"@storybook/addon-interactions": "^8.1.3",
"@storybook/addon-links": "^8.1.3",
"@storybook/addon-onboarding": "^8.1.3",
"@storybook/blocks": "^8.1.3",
"@storybook/react": "^8.1.3",
"@storybook/react-vite": "^8.1.3",
"@storybook/test": "^8.1.3",
"@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^1.6.0",
"eslint": "^9.3.0",
"globals": "^15.3.0",
"prop-types": "^15.8.1",
"storybook": "^8.1.3",
"vite": "^5.2.11",
"vitest": "^1.6.0"
}, },
"dependencies": { "dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2", "@msgpack/msgpack": "^3.0.0-beta2",
"@pixi/react": "^7.1.2", "@pixi/react": "^7.1.2",
"pixi.js": "^8.1.5", "@pixi/spritesheet": "^7.4.2",
"react": "^18.3.1", "@pixi/tilemap": "^4.1.0",
"react-dom": "^18.3.1", "@remix-run/express": "^2.9.2",
"@remix-run/node": "^2.9.2",
"@remix-run/react": "^2.9.2",
"compression": "^1.7.4",
"express": "^4.18.2",
"isbot": "^4.1.0",
"morgan": "^1.10.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"ws": "^8.17.0" "ws": "^8.17.0"
},
"devDependencies": {
"@chromatic-com/storybook": "^1.5.0",
"@remix-run/dev": "^2.9.2",
"@storybook/addon-essentials": "^8.1.6",
"@storybook/addon-interactions": "^8.1.6",
"@storybook/addon-links": "^8.1.6",
"@storybook/addon-onboarding": "^8.1.6",
"@storybook/blocks": "^8.1.6",
"@storybook/react": "^8.1.6",
"@storybook/react-vite": "^8.1.6",
"@storybook/test": "^8.1.6",
"@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "^1.6.0",
"cross-env": "^7.0.3",
"eslint": "^8.38.0",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"image-size": "^1.1.1",
"storybook": "^8.1.6",
"vite": "^5.1.0",
"vitest": "^1.6.0"
},
"engines": {
"node": ">=20.0.0"
} }
} }

1
public/assets/dude.json Normal file

File diff suppressed because one or more lines are too long

BIN
public/assets/dude.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

40
public/assets/tileset.js Executable file
View File

@ -0,0 +1,40 @@
#!/usr/bin/env node
import {writeFileSync} from 'node:fs';
import {basename, dirname, extname, join} from 'node:path';
import imageSize from 'image-size';
const tileset = process.argv[2];
const w = parseInt(process.argv[3]);
const h = parseInt(process.argv[4]);
const {width, height} = imageSize(tileset);
const json = {
frames: {},
meta: {
format: 'RGBA8888',
image: tileset,
scale: 1,
size: {w: width, h: height},
},
};
const extlessPath = join(dirname(tileset), basename(tileset, extname(tileset)));
let i = 0;
for (let y = 0; y < height; y += h) {
for (let x = 0; x < width; x += w) {
json.frames[join(extlessPath, `${i++}`)] = {
frame: {x, y, w, h},
spriteSourceSize: {x: 0, y: 0, w, h},
sourceSize: {w, h},
};
}
}
writeFileSync(
`${extlessPath}.json`,
JSON.stringify(json),
);

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More