Compare commits
No commits in common. "962f867ed97a9bfb60589fd41201259120efa884" and "772308a2995e49469ff27d220ff56ff7ef9551b6" have entirely different histories.
962f867ed9
...
772308a299
|
@ -1,70 +0,0 @@
|
|||
/**
|
||||
* 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
7
.gitignore
vendored
|
@ -1,5 +1,2 @@
|
|||
node_modules
|
||||
|
||||
/.cache
|
||||
/build
|
||||
.env
|
||||
/indev
|
||||
/node_modules
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
process.env.STORYBOOK = 1
|
||||
|
||||
/** @type { import('@storybook/react-vite').StorybookConfig } */
|
||||
const config = {
|
||||
stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: [
|
||||
'@chromatic-com/storybook',
|
||||
'@storybook/addon-links',
|
||||
|
|
36
.vscode/launch.json
vendored
36
.vscode/launch.json
vendored
|
@ -4,13 +4,6 @@
|
|||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"name": "Silphius Chrome",
|
||||
"url": "https://localhost:3000",
|
||||
"webRoot": "${workspaceFolder}",
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
|
@ -21,14 +14,7 @@
|
|||
"resolveSourceMapLocations": [],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
},
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"name": "Storybook Chrome",
|
||||
"url": "http://localhost:6006",
|
||||
"webRoot": "${workspaceFolder}",
|
||||
"runtimeArgs": ["run", "dev", "--", "--host", "0.0.0.0"],
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
|
@ -42,21 +28,25 @@
|
|||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "storybook", "--", "--no-open"],
|
||||
},
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"name": "Chrome",
|
||||
"url": "",
|
||||
"webRoot": "${workspaceFolder}",
|
||||
"runtimeArgs": [
|
||||
"http://localhost:5173",
|
||||
"http://localhost:6006",
|
||||
]
|
||||
},
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Silphius",
|
||||
"configurations": [
|
||||
"Silphius Dev",
|
||||
"Silphius Chrome",
|
||||
],
|
||||
"stopAll": true,
|
||||
},
|
||||
{
|
||||
"name": "Storybook",
|
||||
"configurations": [
|
||||
"Storybook Dev",
|
||||
"Storybook Chrome",
|
||||
"Chrome",
|
||||
],
|
||||
"stopAll": true,
|
||||
}
|
||||
|
|
27
README.md
27
README.md
|
@ -1,27 +0,0 @@
|
|||
# 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.
|
|
@ -1,3 +0,0 @@
|
|||
export default {
|
||||
frame: {type: 'uint16'},
|
||||
};
|
|
@ -1,4 +0,0 @@
|
|||
export default {
|
||||
x: {type: 'uint16'},
|
||||
y: {type: 'uint16'},
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
export default {
|
||||
x: {type: 'uint16'},
|
||||
y: {type: 'uint16'},
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
export default {
|
||||
up: {type: 'float32'},
|
||||
right: {type: 'float32'},
|
||||
down: {type: 'float32'},
|
||||
left: {type: 'float32'},
|
||||
};
|
|
@ -1,3 +0,0 @@
|
|||
export default {
|
||||
direction: {type: 'uint8'},
|
||||
};
|
|
@ -1,3 +0,0 @@
|
|||
import gather from '@/engine/gather.js';
|
||||
|
||||
export default gather(import.meta.glob('./*.js', {eager: true, import: 'default'}));
|
|
@ -1 +0,0 @@
|
|||
export default {};
|
|
@ -1,4 +0,0 @@
|
|||
export default {
|
||||
x: {type: 'float32'},
|
||||
y: {type: 'float32'},
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
export default {
|
||||
x: {type: 'float32'},
|
||||
y: {type: 'float32'},
|
||||
};
|
|
@ -1 +0,0 @@
|
|||
export default {};
|
|
@ -1,8 +0,0 @@
|
|||
export default {
|
||||
animation: {type: 'string'},
|
||||
elapsed: {type: 'float32'},
|
||||
frame: {type: 'uint16'},
|
||||
frames: {type: 'uint16'},
|
||||
source: {type: 'string'},
|
||||
speed: {type: 'float32'},
|
||||
};
|
|
@ -1,16 +0,0 @@
|
|||
export default {
|
||||
layers: {
|
||||
type: 'array',
|
||||
subtype: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
subtype: {
|
||||
type: 'uint16',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
|
@ -1,6 +0,0 @@
|
|||
export default {
|
||||
x0: {type: 'float32'},
|
||||
x1: {type: 'float32'},
|
||||
y0: {type: 'float32'},
|
||||
y1: {type: 'float32'},
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export default {};
|
|
@ -1,3 +0,0 @@
|
|||
export default {
|
||||
world: {type: 'uint16'},
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
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(':');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,113 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
||||
}
|
|
@ -1,109 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
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);
|
||||
});
|
|
@ -1,98 +0,0 @@
|
|||
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();
|
||||
}
|
||||
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
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
401
app/ecs/ecs.js
|
@ -1,401 +0,0 @@
|
|||
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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,459 +0,0 @@
|
|||
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;
|
||||
});
|
|
@ -1,84 +0,0 @@
|
|||
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};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
}
|
|
@ -1,72 +0,0 @@
|
|||
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);
|
||||
});
|
|
@ -1,300 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
||||
}
|
|
@ -1,136 +0,0 @@
|
|||
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));
|
||||
});
|
||||
});
|
|
@ -1,112 +0,0 @@
|
|||
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
import Ecs from '@/ecs/ecs.js';
|
||||
import Types from '@/ecs-components/index.js';
|
||||
|
||||
class EngineEcs extends Ecs {
|
||||
static Types = Types;
|
||||
}
|
||||
|
||||
export default EngineEcs;
|
|
@ -1,200 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
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()});
|
||||
});
|
|
@ -1,23 +0,0 @@
|
|||
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;
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
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);
|
||||
});
|
|
@ -1,6 +0,0 @@
|
|||
export default class First {
|
||||
static gathered(id, key) {
|
||||
this.id = id;
|
||||
this.key = key;
|
||||
}
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
export default class Second {
|
||||
static gathered(id, key) {
|
||||
this.id = id;
|
||||
this.key = key;
|
||||
}
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
});
|
|
@ -1,150 +0,0 @@
|
|||
/**
|
||||
* 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);
|
||||
});
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
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]);
|
||||
}
|
|
@ -1,42 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,36 +0,0 @@
|
|||
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);
|
||||
};
|
|
@ -1,67 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
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'});
|
||||
});
|
|
@ -1,42 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
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();
|
||||
});
|
|
@ -1,3 +0,0 @@
|
|||
import Packet from '@/net/packet.js';
|
||||
|
||||
export default class ConnectionStatus extends Packet {}
|
|
@ -1,20 +0,0 @@
|
|||
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;
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
import Packet from '@/net/packet.js';
|
||||
|
||||
export default class Tick extends Packet {}
|
|
@ -1,27 +0,0 @@
|
|||
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's a problem with the connection.</p>
|
||||
<p>{rendered}Reconnection attempt in progress{rendered}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
.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;
|
||||
}
|
||||
}
|
|
@ -1,60 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
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)}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
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}
|
||||
/>
|
||||
);
|
||||
};
|
31
app/root.jsx
31
app/root.jsx
|
@ -1,31 +0,0 @@
|
|||
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 />;
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
.title {
|
||||
font-size: 10em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
font-size: 3em;
|
||||
list-style: none;
|
||||
text-align: center;
|
||||
li {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
.play {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: space-around;
|
||||
line-height: 0;
|
||||
width: 100%;
|
||||
}
|
|
@ -1,86 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
.main-menu {
|
||||
height: 100%;
|
||||
line-height: 1;
|
||||
width: 100%;
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -1,63 +0,0 @@
|
|||
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));
|
||||
});
|
||||
}
|
||||
}
|
25
eslint.config.js
Normal file
25
eslint.config.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
// 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',
|
||||
}
|
||||
}
|
||||
];
|
10110
package-lock.json
generated
10110
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
77
package.json
77
package.json
|
@ -1,57 +1,44 @@
|
|||
{
|
||||
"name": "silphius-next",
|
||||
"name": "silphius",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "remix vite:build",
|
||||
"dev": "node ./server.js",
|
||||
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
|
||||
"start": "cross-env NODE_ENV=production node ./server.js",
|
||||
"dev": "npm run vite",
|
||||
"build": "npm run vite build",
|
||||
"preview": "npm run vite preview",
|
||||
"server": "esbuild src/net/server/socket.js --bundle --format=esm --alias:@=`pwd`/src --platform=node --external:./node_modules/* | node --input-type=module -",
|
||||
"start": "npm run dev",
|
||||
"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": {
|
||||
"@msgpack/msgpack": "^3.0.0-beta2",
|
||||
"@pixi/react": "^7.1.2",
|
||||
"@pixi/spritesheet": "^7.4.2",
|
||||
"@pixi/tilemap": "^4.1.0",
|
||||
"@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",
|
||||
"pixi.js": "^8.1.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because one or more lines are too long
Binary file not shown.
Before Width: | Height: | Size: 9.2 KiB |
|
@ -1,40 +0,0 @@
|
|||
#!/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
Binary file not shown.
Before Width: | Height: | Size: 195 KiB |
Binary file not shown.
Before Width: | Height: | Size: 11 KiB |
83
server.js
83
server.js
|
@ -1,83 +0,0 @@
|
|||
import {createRequestHandler} from '@remix-run/express';
|
||||
import compression from 'compression';
|
||||
import express from 'express';
|
||||
import morgan from 'morgan';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
const app = express();
|
||||
|
||||
let server;
|
||||
if (isProduction) {
|
||||
const {createServer} = await import('node:http');
|
||||
server = createServer(app);
|
||||
}
|
||||
else {
|
||||
const {execSync} = await import('node:child_process');
|
||||
const {mkdirSync, readFileSync, statSync} = await import('node:fs');
|
||||
const cacheDirectory = `${import.meta.dirname}/node_modules/.cache`;
|
||||
mkdirSync(cacheDirectory, {recursive: true});
|
||||
try {
|
||||
statSync(`${cacheDirectory}/localhost-key.pem`);
|
||||
}
|
||||
catch (error) { // eslint-disable-line no-unused-vars
|
||||
execSync(`mkcert -cert-file ${cacheDirectory}/localhost.pem -key-file ${cacheDirectory}/localhost-key.pem localhost`)
|
||||
}
|
||||
const serverOptions = {
|
||||
key: readFileSync(`${cacheDirectory}/localhost-key.pem`),
|
||||
cert: readFileSync(`${cacheDirectory}/localhost.pem`),
|
||||
};
|
||||
const {createServer} = await import('node:https');
|
||||
server = createServer(serverOptions, app);
|
||||
}
|
||||
|
||||
const viteDevServer = isProduction
|
||||
? undefined
|
||||
: await import('vite').then((vite) =>
|
||||
vite.createServer({
|
||||
server: {middlewareMode: {server}},
|
||||
})
|
||||
);
|
||||
|
||||
const ssr = await (
|
||||
viteDevServer
|
||||
? viteDevServer.ssrLoadModule('virtual:remix/server-build')
|
||||
: import('./build/server/index.js')
|
||||
);
|
||||
|
||||
const remixHandler = createRequestHandler({
|
||||
build: () => ssr,
|
||||
});
|
||||
|
||||
await ssr.entry.module.websocket(server, viteDevServer);
|
||||
|
||||
app.use(compression());
|
||||
|
||||
// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header
|
||||
app.disable('x-powered-by');
|
||||
|
||||
// handle asset requests
|
||||
if (viteDevServer) {
|
||||
app.use(viteDevServer.middlewares);
|
||||
}
|
||||
else {
|
||||
// Vite fingerprints its assets so we can cache forever.
|
||||
app.use(
|
||||
'/assets',
|
||||
express.static('build/client/assets', { immutable: true, maxAge: '1y' })
|
||||
);
|
||||
}
|
||||
|
||||
// Everything else (like favicon.ico) is cached for an hour. You may want to be
|
||||
// more aggressive with this caching.
|
||||
app.use(express.static('build/client', { maxAge: '1h' }));
|
||||
|
||||
app.use(morgan('tiny'));
|
||||
|
||||
// handle SSR requests
|
||||
app.all('*', remixHandler);
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
server.listen(port, () =>
|
||||
console.log(`Express server listening at http${isProduction ? '' : 's'}://localhost:${port}`)
|
||||
);
|
BIN
src/assets/bunny.png
Normal file
BIN
src/assets/bunny.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 449 B |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
10
src/cell.js
Normal file
10
src/cell.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
import {Ecs} from '@/ecs/index.js';
|
||||
|
||||
export default class Cell {
|
||||
constructor() {
|
||||
this.ecs = new Ecs();
|
||||
}
|
||||
tick(elapsed) {
|
||||
this.ecs.tick(elapsed);
|
||||
}
|
||||
}
|
8
src/components/configuration.jsx
Normal file
8
src/components/configuration.jsx
Normal file
|
@ -0,0 +1,8 @@
|
|||
export default function Configuration() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Configuration</h1>
|
||||
<p>This is the configuration page.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
0
src/components/configuration.module.css
Normal file
0
src/components/configuration.module.css
Normal file
|
@ -17,7 +17,7 @@ export default function Dom({children}) {
|
|||
function onResize() {
|
||||
const {parentNode} = ref.current;
|
||||
const {width} = parentNode.getBoundingClientRect();
|
||||
setScale(width / RESOLUTION.x);
|
||||
setScale(width / RESOLUTION[0]);
|
||||
}
|
||||
window.addEventListener('resize', onResize);
|
||||
onResize();
|
||||
|
@ -28,12 +28,7 @@ export default function Dom({children}) {
|
|||
return (
|
||||
<div className={styles.dom} ref={ref}>
|
||||
{scale > 0 && (
|
||||
<style>{`
|
||||
.${styles.dom}{
|
||||
--scale: ${scale};
|
||||
--unit: calc(${RESOLUTION.x} / 1000);
|
||||
}
|
||||
`}</style>
|
||||
<style>{`.${styles.dom}{--scale:${scale}}`}</style>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
19
src/components/entities.jsx
Normal file
19
src/components/entities.jsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import {Sprite} from '@pixi/react';
|
||||
|
||||
export default function Entities({entities}) {
|
||||
return (
|
||||
<>
|
||||
{
|
||||
entities
|
||||
.map(({image, position: [x, y]}, i) => (
|
||||
<Sprite
|
||||
image={image}
|
||||
key={i}
|
||||
x={x}
|
||||
y={y}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -1,17 +1,15 @@
|
|||
.hotbar {
|
||||
--border: calc(var(--unit) * 3px);
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border: var(--border) solid #444444;
|
||||
border: 2px solid #999999;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
left: calc(var(--unit) * 225px);
|
||||
left: 135px;
|
||||
line-height: 0;
|
||||
position: absolute;
|
||||
top: calc(var(--unit) * 25px);
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
.slotWrapper {
|
||||
border: var(--border) solid #999999;
|
||||
border: 2px solid #999999;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
line-height: 0;
|
||||
|
@ -21,7 +19,7 @@
|
|||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
62
src/components/pixi.jsx
Normal file
62
src/components/pixi.jsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
import {
|
||||
Stage,
|
||||
Container,
|
||||
} from '@pixi/react';
|
||||
import {useContext, useEffect, useState} from 'react';
|
||||
|
||||
import {RESOLUTION} from '@/constants.js';
|
||||
import ClientContext from '@/context/client.js';
|
||||
import {Ecs} from '@/ecs/index.js';
|
||||
|
||||
import Entities from './entities.jsx';
|
||||
import styles from './pixi.module.css';
|
||||
|
||||
export default function Pixi() {
|
||||
const client = useContext(ClientContext);
|
||||
const [entities, setEntities] = useState([]);
|
||||
useEffect(() => {
|
||||
function onMessage(message) {
|
||||
const {type, payload} = message;
|
||||
switch (type) {
|
||||
case 'connected': {
|
||||
setEntities(payload);
|
||||
break;
|
||||
}
|
||||
case 'tick': {
|
||||
const {buffer, byteLength, byteOffset} = payload.entities;
|
||||
const view = new DataView(buffer, byteOffset, byteLength);
|
||||
const ecs = new Ecs();
|
||||
ecs.decode(view);
|
||||
const entities = [];
|
||||
for (const entity of ecs.entities) {
|
||||
const {Position, Visible} = ecs.get(entity);
|
||||
entities.push({
|
||||
image: Visible.image,
|
||||
position: [Position.x, Position.y],
|
||||
})
|
||||
}
|
||||
setEntities(entities);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
client.addMessageListener(onMessage);
|
||||
return () => {
|
||||
client.removeMessageListener(onMessage);
|
||||
};
|
||||
});
|
||||
return (
|
||||
<Stage
|
||||
className={styles.stage}
|
||||
width={RESOLUTION[0]}
|
||||
height={RESOLUTION[1]}
|
||||
options={{
|
||||
background: 0x1099bb,
|
||||
}}
|
||||
>
|
||||
<Entities entities={entities} />
|
||||
</Stage>
|
||||
);
|
||||
}
|
||||
|
46
src/components/silphius.jsx
Normal file
46
src/components/silphius.jsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
import {useEffect, useState} from 'react';
|
||||
|
||||
import ClientContext from '@/context/client.js';
|
||||
|
||||
import Title from './title.jsx';
|
||||
import Ui from './ui.jsx';
|
||||
|
||||
export default function Silphius() {
|
||||
const connectionTuple = useState();
|
||||
const [client, setClient] = useState();
|
||||
useEffect(() => {
|
||||
if (!connectionTuple[0]) {
|
||||
return;
|
||||
}
|
||||
async function connect() {
|
||||
let Client;
|
||||
switch (connectionTuple[0]) {
|
||||
case 'local':
|
||||
({default: Client} = await import('@/net/client/local.js'));
|
||||
break;
|
||||
case 'remote':
|
||||
({default: Client} = await import('@/net/client/remote.js'));
|
||||
break;
|
||||
}
|
||||
const client = new Client();
|
||||
await client.connect();
|
||||
client.send({type: 'connect'});
|
||||
setClient(client);
|
||||
}
|
||||
connect();
|
||||
}, [connectionTuple[0]]);
|
||||
return (
|
||||
connectionTuple[0]
|
||||
? (
|
||||
client
|
||||
? (
|
||||
<ClientContext.Provider value={client}>
|
||||
<Ui />
|
||||
</ClientContext.Provider>
|
||||
)
|
||||
: null
|
||||
)
|
||||
: <Title connectionTuple={connectionTuple} />
|
||||
);
|
||||
}
|
||||
|
0
src/components/silphius.module.css
Normal file
0
src/components/silphius.module.css
Normal file
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user