Compare commits
No commits in common. "772308a2995e49469ff27d220ff56ff7ef9551b6" and "962f867ed97a9bfb60589fd41201259120efa884" have entirely different histories.
772308a299
...
962f867ed9
70
.eslintrc.cjs
Normal file
70
.eslintrc.cjs
Normal file
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* This is intended to be a basic starting point for linting in your app.
|
||||
* It relies on recommended configs out of the box for simplicity, but you can
|
||||
* and should modify this configuration to best suit your team's needs.
|
||||
*/
|
||||
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
commonjs: true,
|
||||
es6: true,
|
||||
},
|
||||
globals: {
|
||||
process: false,
|
||||
},
|
||||
ignorePatterns: ['!**/.server', '!**/.client'],
|
||||
|
||||
// Base config
|
||||
extends: ['eslint:recommended'],
|
||||
|
||||
overrides: [
|
||||
// React
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
plugins: ['react', 'jsx-a11y'],
|
||||
extends: [
|
||||
'plugin:react/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:jsx-a11y/recommended',
|
||||
],
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
formComponents: ['Form'],
|
||||
linkComponents: [
|
||||
{ name: 'Link', linkAttribute: 'to' },
|
||||
{ name: 'NavLink', linkAttribute: 'to' },
|
||||
],
|
||||
},
|
||||
rules: {
|
||||
'react/prop-types': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Node
|
||||
{
|
||||
files: [
|
||||
'app/websocket.js',
|
||||
'.eslintrc.cjs',
|
||||
'server.js',
|
||||
'vite.config.js',
|
||||
'public/assets/tileset.js',
|
||||
],
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
7
.gitignore
vendored
7
.gitignore
vendored
|
@ -1,2 +1,5 @@
|
|||
/indev
|
||||
/node_modules
|
||||
node_modules
|
||||
|
||||
/.cache
|
||||
/build
|
||||
.env
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
process.env.STORYBOOK = 1
|
||||
|
||||
/** @type { import('@storybook/react-vite').StorybookConfig } */
|
||||
const config = {
|
||||
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: [
|
||||
'@chromatic-com/storybook',
|
||||
'@storybook/addon-links',
|
||||
|
|
36
.vscode/launch.json
vendored
36
.vscode/launch.json
vendored
|
@ -4,6 +4,13 @@
|
|||
// 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",
|
||||
|
@ -14,7 +21,14 @@
|
|||
"resolveSourceMapLocations": [],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--host", "0.0.0.0"],
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
},
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"name": "Storybook Chrome",
|
||||
"url": "http://localhost:6006",
|
||||
"webRoot": "${workspaceFolder}",
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
|
@ -28,25 +42,21 @@
|
|||
"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",
|
||||
"Chrome",
|
||||
"Storybook Chrome",
|
||||
],
|
||||
"stopAll": true,
|
||||
}
|
||||
|
|
27
README.md
Normal file
27
README.md
Normal file
|
@ -0,0 +1,27 @@
|
|||
# Silphius
|
||||
|
||||
All the world's a game!
|
||||
|
||||
## Development
|
||||
|
||||
Run the dev server:
|
||||
|
||||
```shellscript
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
First, build your app for production:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
Then run the app in production mode:
|
||||
|
||||
```sh
|
||||
npm start
|
||||
```
|
||||
|
||||
Now you'll need to pick a host to deploy it to.
|
|
@ -1,13 +1,13 @@
|
|||
export const CLIENT_LATENCY = 100;
|
||||
export const CLIENT_LATENCY = 0;
|
||||
|
||||
export const CLIENT_PREDICTION = true;
|
||||
|
||||
export const RESOLUTION = [
|
||||
800,
|
||||
450,
|
||||
];
|
||||
export const RESOLUTION = {
|
||||
x: 800,
|
||||
y: 450,
|
||||
};
|
||||
|
||||
export const SERVER_LATENCY = 100;
|
||||
export const SERVER_LATENCY = 0;
|
||||
|
||||
export const TPS = 60;
|
||||
|
3
app/ecs-components/animation.js
Normal file
3
app/ecs-components/animation.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default {
|
||||
frame: {type: 'uint16'},
|
||||
};
|
4
app/ecs-components/area-size.js
Normal file
4
app/ecs-components/area-size.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
export default {
|
||||
x: {type: 'uint16'},
|
||||
y: {type: 'uint16'},
|
||||
}
|
4
app/ecs-components/camera.js
Normal file
4
app/ecs-components/camera.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
export default {
|
||||
x: {type: 'uint16'},
|
||||
y: {type: 'uint16'},
|
||||
}
|
6
app/ecs-components/controlled.js
Normal file
6
app/ecs-components/controlled.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
up: {type: 'float32'},
|
||||
right: {type: 'float32'},
|
||||
down: {type: 'float32'},
|
||||
left: {type: 'float32'},
|
||||
};
|
3
app/ecs-components/direction.js
Normal file
3
app/ecs-components/direction.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default {
|
||||
direction: {type: 'uint8'},
|
||||
};
|
3
app/ecs-components/index.js
Normal file
3
app/ecs-components/index.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import gather from '@/engine/gather.js';
|
||||
|
||||
export default gather(import.meta.glob('./*.js', {eager: true, import: 'default'}));
|
1
app/ecs-components/main-entity.js
Normal file
1
app/ecs-components/main-entity.js
Normal file
|
@ -0,0 +1 @@
|
|||
export default {};
|
4
app/ecs-components/momentum.js
Normal file
4
app/ecs-components/momentum.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
export default {
|
||||
x: {type: 'float32'},
|
||||
y: {type: 'float32'},
|
||||
}
|
4
app/ecs-components/position.js
Normal file
4
app/ecs-components/position.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
export default {
|
||||
x: {type: 'float32'},
|
||||
y: {type: 'float32'},
|
||||
};
|
1
app/ecs-components/rendered.js
Normal file
1
app/ecs-components/rendered.js
Normal file
|
@ -0,0 +1 @@
|
|||
export default {};
|
8
app/ecs-components/sprite.js
Normal file
8
app/ecs-components/sprite.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
export default {
|
||||
animation: {type: 'string'},
|
||||
elapsed: {type: 'float32'},
|
||||
frame: {type: 'uint16'},
|
||||
frames: {type: 'uint16'},
|
||||
source: {type: 'string'},
|
||||
speed: {type: 'float32'},
|
||||
};
|
16
app/ecs-components/tile-layers.js
Normal file
16
app/ecs-components/tile-layers.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
export default {
|
||||
layers: {
|
||||
type: 'array',
|
||||
subtype: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
subtype: {
|
||||
type: 'uint16',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
6
app/ecs-components/visible-aabb.js
Normal file
6
app/ecs-components/visible-aabb.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
x0: {type: 'float32'},
|
||||
x1: {type: 'float32'},
|
||||
y0: {type: 'float32'},
|
||||
y1: {type: 'float32'},
|
||||
}
|
1
app/ecs-components/wandering.js
Normal file
1
app/ecs-components/wandering.js
Normal file
|
@ -0,0 +1 @@
|
|||
export default {};
|
3
app/ecs-components/world.js
Normal file
3
app/ecs-components/world.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default {
|
||||
world: {type: 'uint16'},
|
||||
}
|
18
app/ecs-systems/apply-momentum.js
Normal file
18
app/ecs-systems/apply-momentum.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
import {System} from '@/ecs/index.js';
|
||||
|
||||
export default class ApplyMomentum extends System {
|
||||
|
||||
static queries() {
|
||||
return {
|
||||
default: ['Position', 'Momentum'],
|
||||
};
|
||||
}
|
||||
|
||||
tick(elapsed) {
|
||||
for (const [position, momentum] of this.select('default')) {
|
||||
position.x += elapsed * momentum.x;
|
||||
position.y += elapsed * momentum.y;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
21
app/ecs-systems/calculate-aabbs.js
Normal file
21
app/ecs-systems/calculate-aabbs.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
import {System} from '@/ecs/index.js';
|
||||
|
||||
export default class CalculateAabbs extends System {
|
||||
|
||||
tick() {
|
||||
const {diff} = this.ecs;
|
||||
for (const id in diff) {
|
||||
if (diff[id].Position) {
|
||||
const {Position: {x, y}, VisibleAabb} = this.ecs.get(id);
|
||||
if (VisibleAabb) {
|
||||
VisibleAabb.x0 = x - 32;
|
||||
VisibleAabb.x1 = x + 32;
|
||||
VisibleAabb.y0 = y - 32;
|
||||
VisibleAabb.y1 = y + 32;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
27
app/ecs-systems/clamp-positions.js
Normal file
27
app/ecs-systems/clamp-positions.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
import {System} from '@/ecs/index.js';
|
||||
|
||||
export default class ClampPositions extends System {
|
||||
|
||||
tick() {
|
||||
const {diff} = this.ecs;
|
||||
const {AreaSize} = this.ecs.get(1);
|
||||
for (const id in diff) {
|
||||
if (diff[id].Position) {
|
||||
const {Position} = this.ecs.get(id);
|
||||
if (Position.x < 0) {
|
||||
Position.x = 0;
|
||||
}
|
||||
if (Position.y < 0) {
|
||||
Position.y = 0;
|
||||
}
|
||||
if (Position.x >= AreaSize.x) {
|
||||
Position.x = AreaSize.x - 0.0001;
|
||||
}
|
||||
if (Position.y >= AreaSize.y) {
|
||||
Position.y = AreaSize.y - 0.0001;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
27
app/ecs-systems/control-direction.js
Normal file
27
app/ecs-systems/control-direction.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
import {System} from '@/ecs/index.js';
|
||||
|
||||
export default class ControlDirection extends System {
|
||||
|
||||
tick() {
|
||||
const {diff} = this.ecs;
|
||||
for (const id in diff) {
|
||||
const {Controlled} = diff[id];
|
||||
if (Controlled) {
|
||||
const {Controlled: {up, right, down, left}, Direction} = this.ecs.get(id);
|
||||
if (up > 0) {
|
||||
Direction.direction = 0;
|
||||
}
|
||||
if (down > 0) {
|
||||
Direction.direction = 2;
|
||||
}
|
||||
if (left > 0) {
|
||||
Direction.direction = 3;
|
||||
}
|
||||
if (right > 0) {
|
||||
Direction.direction = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
21
app/ecs-systems/control-movement.js
Normal file
21
app/ecs-systems/control-movement.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
import {System} from '@/ecs/index.js';
|
||||
|
||||
const SPEED = 100;
|
||||
|
||||
export default class ControlMovement extends System {
|
||||
|
||||
static queries() {
|
||||
return {
|
||||
default: ['Controlled', 'Momentum'],
|
||||
};
|
||||
}
|
||||
|
||||
tick() {
|
||||
for (const [controlled, momentum] of this.select('default')) {
|
||||
momentum.x = SPEED * (controlled.right - controlled.left);
|
||||
momentum.y = SPEED * (controlled.down - controlled.up);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
32
app/ecs-systems/follow-camera.js
Normal file
32
app/ecs-systems/follow-camera.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
import {RESOLUTION} from '@/constants.js'
|
||||
import {System} from '@/ecs/index.js';
|
||||
|
||||
export default class FollowCamera extends System {
|
||||
|
||||
reindex(entities) {
|
||||
super.reindex(entities);
|
||||
for (const id of entities) {
|
||||
this.updateCamera(this.ecs.get(id));
|
||||
}
|
||||
}
|
||||
|
||||
tick() {
|
||||
const {diff} = this.ecs;
|
||||
for (const id in diff) {
|
||||
if (diff[id].Position) {
|
||||
this.updateCamera(this.ecs.get(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateCamera(entity) {
|
||||
const {Camera, Position} = entity;
|
||||
if (Camera && Position) {
|
||||
const {AreaSize: {x, y}} = this.ecs.get(1);
|
||||
const [hx, hy] = [RESOLUTION.x / 2, RESOLUTION.y / 2];
|
||||
Camera.x = Math.max(hx, Math.min(Position.x, x - hx));
|
||||
Camera.y = Math.max(hy, Math.min(Position.y, y - hy));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
22
app/ecs-systems/run-animations.js
Normal file
22
app/ecs-systems/run-animations.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
import {System} from '@/ecs/index.js';
|
||||
|
||||
export default class ControlMovement extends System {
|
||||
|
||||
static queries() {
|
||||
return {
|
||||
default: ['Sprite'],
|
||||
};
|
||||
}
|
||||
|
||||
tick(elapsed) {
|
||||
for (const [Sprite] of this.select('default')) {
|
||||
Sprite.elapsed += elapsed / Sprite.speed;
|
||||
while (Sprite.elapsed > 1) {
|
||||
Sprite.elapsed -= 1;
|
||||
Sprite.frame = (Sprite.frame + 1) % Sprite.frames;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
39
app/ecs-systems/sprite-direction.js
Normal file
39
app/ecs-systems/sprite-direction.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
import {System} from '@/ecs/index.js';
|
||||
|
||||
export default class SpriteDirection extends System {
|
||||
|
||||
static queries() {
|
||||
return {
|
||||
default: ['Sprite'],
|
||||
};
|
||||
}
|
||||
|
||||
tick() {
|
||||
for (const [Sprite, entityId] of this.select('default')) {
|
||||
const entity = this.ecs.get(entityId);
|
||||
const parts = [];
|
||||
if (entity.Controlled) {
|
||||
const {up, right, down, left} = entity.Controlled;
|
||||
if (up > 0 || right > 0 || down > 0 || left > 0) {
|
||||
parts.push('moving');
|
||||
}
|
||||
else {
|
||||
parts.push('idle');
|
||||
}
|
||||
}
|
||||
if (entity.Direction) {
|
||||
const name = {
|
||||
0: 'up',
|
||||
1: 'right',
|
||||
2: 'down',
|
||||
3: 'left',
|
||||
};
|
||||
parts.push(name[entity.Direction.direction]);
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
Sprite.animation = parts.join(':');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
113
app/ecs-systems/update-spatial-hash.js
Normal file
113
app/ecs-systems/update-spatial-hash.js
Normal file
|
@ -0,0 +1,113 @@
|
|||
import {RESOLUTION} from '@/constants.js'
|
||||
import {System} from '@/ecs/index.js';
|
||||
|
||||
class SpatialHash {
|
||||
|
||||
constructor({x, y}) {
|
||||
this.area = {x, y};
|
||||
this.chunkSize = {x: RESOLUTION.x / 2, y: RESOLUTION.y / 2};
|
||||
this.chunks = Array(Math.ceil(this.area.x / this.chunkSize.x))
|
||||
.fill(0)
|
||||
.map(() => (
|
||||
Array(Math.ceil(this.area.y / this.chunkSize.y))
|
||||
.fill(0)
|
||||
.map(() => [])
|
||||
));
|
||||
this.data = {};
|
||||
}
|
||||
|
||||
clamp(x, y) {
|
||||
return [
|
||||
Math.max(0, Math.min(x, this.area.x - 1)),
|
||||
Math.max(0, Math.min(y, this.area.y - 1))
|
||||
];
|
||||
}
|
||||
|
||||
chunkIndex(x, y) {
|
||||
const [cx, cy] = this.clamp(x, y);
|
||||
return [
|
||||
Math.floor(cx / this.chunkSize.x),
|
||||
Math.floor(cy / this.chunkSize.y),
|
||||
];
|
||||
}
|
||||
|
||||
remove(datum) {
|
||||
if (datum in this.data) {
|
||||
for (const [cx, cy] of this.data[datum]) {
|
||||
const chunk = this.chunks[cx][cy];
|
||||
chunk.splice(chunk.indexOf(datum), 1);
|
||||
}
|
||||
}
|
||||
this.data[datum] = [];
|
||||
}
|
||||
|
||||
update({x0, x1, y0, y1}, datum) {
|
||||
this.remove(datum);
|
||||
for (const [x, y] of [[x0, y0], [x0, y1], [x1, y0], [x1, y1]]) {
|
||||
const [cx, cy] = this.chunkIndex(x, y);
|
||||
this.data[datum].push([cx, cy]);
|
||||
this.chunks[cx][cy].push(datum);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default class UpdateSpatialHash extends System {
|
||||
|
||||
constructor(ecs) {
|
||||
super(ecs);
|
||||
const master = ecs.get(1);
|
||||
this.hash = new SpatialHash(master.AreaSize);
|
||||
}
|
||||
|
||||
deindex(entities) {
|
||||
super.deindex(entities);
|
||||
for (const id of entities) {
|
||||
this.hash.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
reindex(entities) {
|
||||
super.reindex(entities);
|
||||
for (const id of entities) {
|
||||
this.updateHash(this.ecs.get(id));
|
||||
}
|
||||
}
|
||||
|
||||
updateHash(entity) {
|
||||
if (!entity.VisibleAabb) {
|
||||
return;
|
||||
}
|
||||
this.hash.update(entity.VisibleAabb, entity.id);
|
||||
}
|
||||
|
||||
tick() {
|
||||
const {diff} = this.ecs;
|
||||
for (const id in diff) {
|
||||
if (diff[id].VisibleAabb) {
|
||||
this.updateHash(this.ecs.get(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nearby(entity) {
|
||||
const [cx0, cy0] = this.hash.chunkIndex(
|
||||
entity.Position.x - RESOLUTION.x * 0.75,
|
||||
entity.Position.y - RESOLUTION.x * 0.75,
|
||||
);
|
||||
const [cx1, cy1] = this.hash.chunkIndex(
|
||||
entity.Position.x + RESOLUTION.x * 0.75,
|
||||
entity.Position.y + RESOLUTION.x * 0.75,
|
||||
);
|
||||
const nearby = new Set();
|
||||
for (let cy = cy0; cy <= cy1; ++cy) {
|
||||
for (let cx = cx0; cx <= cx1; ++cx) {
|
||||
this.hash.chunks[cx][cy].forEach((id) => {
|
||||
nearby.add(this.ecs.get(id));
|
||||
});
|
||||
}
|
||||
}
|
||||
return nearby;
|
||||
}
|
||||
|
||||
}
|
109
app/ecs/arbitrary.js
Normal file
109
app/ecs/arbitrary.js
Normal file
|
@ -0,0 +1,109 @@
|
|||
import Base from './base.js';
|
||||
|
||||
export default class Arbitrary extends Base {
|
||||
|
||||
data = [];
|
||||
|
||||
serializer;
|
||||
|
||||
Instance;
|
||||
|
||||
allocateMany(count) {
|
||||
if (!this.Instance) {
|
||||
this.Instance = this.instanceFromSchema();
|
||||
}
|
||||
const results = super.allocateMany(count);
|
||||
count -= results.length; while (count--) {
|
||||
results.push(this.data.push(new this.Instance()) - 1);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
createMany(entries) {
|
||||
if (entries.length > 0) {
|
||||
const allocated = this.allocateMany(entries.length);
|
||||
const keys = Object.keys(this.constructor.properties);
|
||||
for (let i = 0; i < entries.length; ++i) {
|
||||
const [entityId, values = {}] = entries[i];
|
||||
this.map[entityId] = allocated[i];
|
||||
this.data[allocated[i]].entity = entityId;
|
||||
if (false === values) {
|
||||
continue;
|
||||
}
|
||||
for (let k = 0; k < keys.length; ++k) {
|
||||
const j = keys[k];
|
||||
const {defaultValue} = this.constructor.properties[j];
|
||||
if (j in values) {
|
||||
this.data[allocated[i]][j] = values[j];
|
||||
}
|
||||
else if ('undefined' !== typeof defaultValue) {
|
||||
this.data[allocated[i]][j] = defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deserialize(entityId, view, offset) {
|
||||
const {properties} = this.constructor;
|
||||
const instance = this.get(entityId);
|
||||
const deserialized = this.constructor.schema.deserialize(view, offset);
|
||||
for (const key in properties) {
|
||||
instance[key] = deserialized[key];
|
||||
}
|
||||
}
|
||||
|
||||
serialize(entityId, view, offset) {
|
||||
this.constructor.schema.serialize(this.get(entityId), view, offset);
|
||||
}
|
||||
|
||||
get(entityId) {
|
||||
return this.data[this.map[entityId]];
|
||||
}
|
||||
|
||||
instanceFromSchema() {
|
||||
const Component = this;
|
||||
const Instance = class {
|
||||
$$entity = 0;
|
||||
constructor() {
|
||||
this.$$reset();
|
||||
}
|
||||
$$reset() {
|
||||
const {properties} = Component.constructor;
|
||||
for (const key in properties) {
|
||||
const {defaultValue} = properties[key];
|
||||
this[`$$${key}`] = defaultValue;
|
||||
}
|
||||
}
|
||||
toJSON() {
|
||||
return Component.constructor.filterDefaults(this);
|
||||
}
|
||||
};
|
||||
const properties = {};
|
||||
properties.entity = {
|
||||
get: function get() {
|
||||
return this.$$entity;
|
||||
},
|
||||
set: function set(v) {
|
||||
this.$$entity = v;
|
||||
this.$$reset();
|
||||
},
|
||||
};
|
||||
for (const key in Component.constructor.properties) {
|
||||
properties[key] = {
|
||||
get: function get() {
|
||||
return this[`$$${key}`];
|
||||
},
|
||||
set: function set(value) {
|
||||
if (this[`$$${key}`] !== value) {
|
||||
this[`$$${key}`] = value;
|
||||
Component.markChange(this.entity, key, value);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Object.defineProperties(Instance.prototype, properties);
|
||||
return Instance;
|
||||
}
|
||||
|
||||
}
|
55
app/ecs/arbitrary.test.js
Normal file
55
app/ecs/arbitrary.test.js
Normal file
|
@ -0,0 +1,55 @@
|
|||
import {expect, test} from 'vitest';
|
||||
|
||||
import Schema from './schema.js';
|
||||
import Arbitrary from './arbitrary.js';
|
||||
|
||||
test('creates instances', () => {
|
||||
class CreatingArbitrary extends Arbitrary {
|
||||
static schema = new Schema({
|
||||
type: 'object',
|
||||
properties: {foo: {defaultValue: 'bar', type: 'string'}},
|
||||
});
|
||||
}
|
||||
const Component = new CreatingArbitrary();
|
||||
Component.create(1);
|
||||
expect(Component.get(1).entity)
|
||||
.to.equal(1);
|
||||
});
|
||||
|
||||
test('does not serialize default values', () => {
|
||||
class CreatingArbitrary extends Arbitrary {
|
||||
static schema = new Schema({
|
||||
type: 'object',
|
||||
properties: {foo: {defaultValue: 'bar', type: 'string'}, bar: {type: 'uint8'}},
|
||||
});
|
||||
}
|
||||
const Component = new CreatingArbitrary();
|
||||
Component.create(1)
|
||||
expect(Component.get(1).toJSON())
|
||||
.to.deep.equal({});
|
||||
Component.get(1).bar = 1;
|
||||
expect(Component.get(1).toJSON())
|
||||
.to.deep.equal({bar: 1});
|
||||
});
|
||||
|
||||
test('reuses instances', () => {
|
||||
class ReusingArbitrary extends Arbitrary {
|
||||
static schema = new Schema({
|
||||
type: 'object',
|
||||
properties: {foo: {type: 'string'}},
|
||||
});
|
||||
}
|
||||
const Component = new ReusingArbitrary();
|
||||
Component.create(1);
|
||||
const instance = Component.get(1);
|
||||
Component.destroy(1);
|
||||
expect(Component.get(1))
|
||||
.to.be.undefined;
|
||||
expect(() => {
|
||||
Component.destroy(1);
|
||||
})
|
||||
.to.throw();
|
||||
Component.create(1);
|
||||
expect(Component.get(1))
|
||||
.to.equal(instance);
|
||||
});
|
98
app/ecs/base.js
Normal file
98
app/ecs/base.js
Normal file
|
@ -0,0 +1,98 @@
|
|||
export default class Base {
|
||||
|
||||
map = [];
|
||||
|
||||
pool = [];
|
||||
|
||||
static schema;
|
||||
|
||||
allocateMany(count) {
|
||||
const results = [];
|
||||
while (count-- > 0 && this.pool.length > 0) {
|
||||
results.push(this.pool.pop());
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
create(entityId, values) {
|
||||
this.createMany([[entityId, values]]);
|
||||
}
|
||||
|
||||
destroy(entityId) {
|
||||
this.destroyMany([entityId]);
|
||||
}
|
||||
|
||||
destroyMany(entities) {
|
||||
this.freeMany(
|
||||
entities
|
||||
.map((entityId) => {
|
||||
if ('undefined' !== typeof this.map[entityId]) {
|
||||
return this.map[entityId];
|
||||
}
|
||||
throw new Error(`can't free for non-existent id ${entityId}`);
|
||||
}),
|
||||
);
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
this.map[entities[i]] = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
static filterDefaults(instance) {
|
||||
const json = {};
|
||||
for (const key in this.properties) {
|
||||
const {defaultValue} = this.properties[key];
|
||||
if (key in instance && instance[key] !== defaultValue) {
|
||||
json[key] = instance[key];
|
||||
}
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
freeMany(indices) {
|
||||
for (let i = 0; i < indices.length; ++i) {
|
||||
this.pool.push(indices[i]);
|
||||
}
|
||||
}
|
||||
|
||||
insertMany(entities) {
|
||||
const creating = [];
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const [entityId, values] = entities[i];
|
||||
if (!this.get(entityId)) {
|
||||
creating.push([entityId, values]);
|
||||
}
|
||||
else {
|
||||
const instance = this.get(entityId);
|
||||
for (const i in values) {
|
||||
instance[i] = values[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
this.createMany(creating);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
markChange(entityId, components) {}
|
||||
|
||||
mergeDiff(original, update) {
|
||||
return {...original, ...update};
|
||||
}
|
||||
|
||||
static get properties() {
|
||||
return this.schema.specification.properties;
|
||||
}
|
||||
|
||||
sizeOf(entityId) {
|
||||
return this.constructor.schema.sizeOf(this.get(entityId));
|
||||
}
|
||||
|
||||
static wrap(name, ecs) {
|
||||
class WrappedComponent extends this {
|
||||
markChange(entityId, key, value) {
|
||||
ecs.markChange(entityId, {[name]: {[key]: value}})
|
||||
}
|
||||
}
|
||||
return new WrappedComponent();
|
||||
}
|
||||
|
||||
}
|
14
app/ecs/component.js
Normal file
14
app/ecs/component.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
import Arbitrary from './arbitrary.js';
|
||||
import Base from './base.js';
|
||||
import Schema from './schema.js';
|
||||
|
||||
export default function Component(specificationOrClass) {
|
||||
if (specificationOrClass instanceof Base) {
|
||||
return specificationOrClass;
|
||||
}
|
||||
// Why the rigamarole? Maybe we'll implement a flat component for direct binary storage
|
||||
// eventually.
|
||||
return class AdhocComponent extends Arbitrary {
|
||||
static schema = new Schema({type: 'object', properties: specificationOrClass});
|
||||
};
|
||||
}
|
401
app/ecs/ecs.js
Normal file
401
app/ecs/ecs.js
Normal file
|
@ -0,0 +1,401 @@
|
|||
import Component from './component.js';
|
||||
import EntityFactory from './entity-factory.js';
|
||||
import System from './system.js';
|
||||
|
||||
export default class Ecs {
|
||||
|
||||
$$caret = 1;
|
||||
|
||||
diff = {};
|
||||
|
||||
static Types = {};
|
||||
|
||||
Types = {};
|
||||
|
||||
$$entities = {};
|
||||
|
||||
$$entityFactory = new EntityFactory();
|
||||
|
||||
$$systems = [];
|
||||
|
||||
constructor() {
|
||||
const {Types} = this.constructor;
|
||||
for (const i in Types) {
|
||||
this.Types[i] = Component(Types[i]).wrap(i, this);
|
||||
}
|
||||
}
|
||||
|
||||
addSystem(source) {
|
||||
const system = System.wrap(source, this);
|
||||
this.$$systems.push(system);
|
||||
system.reindex(this.entities);
|
||||
}
|
||||
|
||||
apply(patch) {
|
||||
const creating = [];
|
||||
const destroying = [];
|
||||
const removing = [];
|
||||
const updating = [];
|
||||
for (const entityId in patch) {
|
||||
const components = patch[entityId];
|
||||
if (false === components) {
|
||||
destroying.push(entityId);
|
||||
continue;
|
||||
}
|
||||
const componentsToRemove = [];
|
||||
const componentsToUpdate = {};
|
||||
for (const i in components) {
|
||||
if (false === components[i]) {
|
||||
componentsToRemove.push(i);
|
||||
}
|
||||
else {
|
||||
componentsToUpdate[i] = components[i];
|
||||
}
|
||||
}
|
||||
if (componentsToRemove.length > 0) {
|
||||
removing.push([entityId, componentsToRemove]);
|
||||
}
|
||||
if (this.$$entities[entityId]) {
|
||||
updating.push([entityId, componentsToUpdate]);
|
||||
}
|
||||
else {
|
||||
creating.push([entityId, componentsToUpdate]);
|
||||
}
|
||||
}
|
||||
this.destroyMany(destroying);
|
||||
this.insertMany(updating);
|
||||
this.removeMany(removing);
|
||||
this.createManySpecific(creating);
|
||||
}
|
||||
|
||||
create(components = {}) {
|
||||
const [entityId] = this.createMany([components]);
|
||||
return entityId;
|
||||
}
|
||||
|
||||
createMany(componentsList) {
|
||||
const specificsList = [];
|
||||
for (const components of componentsList) {
|
||||
specificsList.push([this.$$caret++, components]);
|
||||
}
|
||||
return this.createManySpecific(specificsList);
|
||||
}
|
||||
|
||||
createManySpecific(specificsList) {
|
||||
const entityIds = [];
|
||||
const creating = {};
|
||||
for (let i = 0; i < specificsList.length; i++) {
|
||||
const [entityId, components] = specificsList[i];
|
||||
const componentKeys = [];
|
||||
for (const key of Object.keys(components)) {
|
||||
if (this.Types[key]) {
|
||||
componentKeys.push(key);
|
||||
}
|
||||
}
|
||||
entityIds.push(entityId);
|
||||
this.rebuild(entityId, () => componentKeys);
|
||||
for (const component of componentKeys) {
|
||||
if (!creating[component]) {
|
||||
creating[component] = [];
|
||||
}
|
||||
creating[component].push([entityId, components[component]]);
|
||||
}
|
||||
this.markChange(entityId, components);
|
||||
}
|
||||
for (const i in creating) {
|
||||
this.Types[i].createMany(creating[i]);
|
||||
}
|
||||
this.reindex(entityIds);
|
||||
return entityIds;
|
||||
}
|
||||
|
||||
createSpecific(entityId, components) {
|
||||
return this.createManySpecific([[entityId, components]]);
|
||||
}
|
||||
|
||||
deindex(entityIds) {
|
||||
for (let i = 0; i < this.$$systems.length; i++) {
|
||||
this.$$systems[i].deindex(entityIds);
|
||||
}
|
||||
}
|
||||
|
||||
static deserialize(view) {
|
||||
const ecs = new this();
|
||||
let cursor = 0;
|
||||
const count = view.getUint32(cursor, true);
|
||||
const keys = Object.keys(ecs.Types);
|
||||
cursor += 4;
|
||||
const creating = new Map();
|
||||
const updating = new Map();
|
||||
const cursors = new Map();
|
||||
for (let i = 0; i < count; ++i) {
|
||||
const entityId = view.getUint32(cursor, true);
|
||||
if (!ecs.$$entities[entityId]) {
|
||||
creating.set(entityId, {});
|
||||
}
|
||||
cursor += 4;
|
||||
const componentCount = view.getUint16(cursor, true);
|
||||
cursor += 2;
|
||||
cursors.set(entityId, {});
|
||||
const addedComponents = [];
|
||||
for (let j = 0; j < componentCount; ++j) {
|
||||
const componentId = view.getUint16(cursor, true);
|
||||
cursor += 2;
|
||||
const component = keys[componentId];
|
||||
if (!component) {
|
||||
throw new Error(`can't decode component ${componentId}`);
|
||||
}
|
||||
if (!ecs.$$entities[entityId]) {
|
||||
creating.get(entityId)[component] = false;
|
||||
}
|
||||
else if (!ecs.$$entities[entityId].constructor.types.includes(component)) {
|
||||
addedComponents.push(component);
|
||||
if (!updating.has(component)) {
|
||||
updating.set(component, []);
|
||||
}
|
||||
updating.get(component).push([entityId, false]);
|
||||
}
|
||||
cursors.get(entityId)[component] = cursor;
|
||||
cursor += ecs.Types[component].constructor.schema.readSize(view, cursor);
|
||||
}
|
||||
if (addedComponents.length > 0 && ecs.$$entities[entityId]) {
|
||||
ecs.rebuild(entityId, (types) => types.concat(addedComponents));
|
||||
}
|
||||
}
|
||||
ecs.createManySpecific(Array.from(creating.entries()));
|
||||
for (const [component, entityIds] of updating) {
|
||||
ecs.Types[component].createMany(entityIds);
|
||||
}
|
||||
for (const [entityId, components] of cursors) {
|
||||
for (const component in components) {
|
||||
ecs.Types[component].deserialize(entityId, view, components[component]);
|
||||
}
|
||||
}
|
||||
return ecs;
|
||||
}
|
||||
|
||||
destroy(entityId) {
|
||||
this.destroyMany([entityId]);
|
||||
}
|
||||
|
||||
destroyAll() {
|
||||
this.destroyMany(this.entities);
|
||||
}
|
||||
|
||||
destroyMany(entityIds) {
|
||||
const destroying = {};
|
||||
this.deindex(entityIds);
|
||||
for (const entityId of entityIds) {
|
||||
if (!this.$$entities[entityId]) {
|
||||
throw new Error(`can't destroy non-existent entity ${entityId}`);
|
||||
}
|
||||
for (const component of this.$$entities[entityId].constructor.types) {
|
||||
if (!destroying[component]) {
|
||||
destroying[component] = [];
|
||||
}
|
||||
destroying[component].push(entityId);
|
||||
}
|
||||
this.$$entities[entityId] = undefined;
|
||||
this.diff[entityId] = false;
|
||||
}
|
||||
for (const i in destroying) {
|
||||
this.Types[i].destroyMany(destroying[i]);
|
||||
}
|
||||
}
|
||||
|
||||
get entities() {
|
||||
const it = Object.values(this.$$entities).values();
|
||||
return {
|
||||
[Symbol.iterator]() {
|
||||
return this;
|
||||
},
|
||||
next: () => {
|
||||
let result = it.next();
|
||||
while (!result.done && !result.value) {
|
||||
result = it.next();
|
||||
}
|
||||
if (result.done) {
|
||||
return {done: true, value: undefined};
|
||||
}
|
||||
return {done: false, value: result.value.id};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
get(entityId) {
|
||||
return this.$$entities[entityId];
|
||||
}
|
||||
|
||||
insert(entityId, components) {
|
||||
this.insertMany([[entityId, components]]);
|
||||
}
|
||||
|
||||
insertMany(entities) {
|
||||
const inserting = {};
|
||||
const unique = new Set();
|
||||
for (const [entityId, components] of entities) {
|
||||
this.rebuild(entityId, (types) => [...new Set(types.concat(Object.keys(components)))]);
|
||||
const diff = {};
|
||||
for (const component in components) {
|
||||
if (!inserting[component]) {
|
||||
inserting[component] = [];
|
||||
}
|
||||
diff[component] = {};
|
||||
inserting[component].push([entityId, components[component]]);
|
||||
}
|
||||
unique.add(entityId);
|
||||
this.markChange(entityId, diff);
|
||||
}
|
||||
for (const component in inserting) {
|
||||
this.Types[component].insertMany(inserting[component]);
|
||||
}
|
||||
this.reindex(unique.values());
|
||||
}
|
||||
|
||||
markChange(entityId, components) {
|
||||
// Deleted?
|
||||
if (false === components) {
|
||||
this.diff[entityId] = false;
|
||||
}
|
||||
// Created?
|
||||
else if (!this.diff[entityId]) {
|
||||
const filtered = {};
|
||||
for (const type in components) {
|
||||
filtered[type] = false === components[type]
|
||||
? false
|
||||
: this.Types[type].constructor.filterDefaults(components[type]);
|
||||
}
|
||||
this.diff[entityId] = filtered;
|
||||
}
|
||||
// Otherwise, merge.
|
||||
else {
|
||||
for (const type in components) {
|
||||
this.diff[entityId][type] = false === components[type]
|
||||
? false
|
||||
: this.Types[type].mergeDiff(
|
||||
this.diff[entityId][type] || {},
|
||||
components[type],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rebuild(entityId, types) {
|
||||
let existing = [];
|
||||
if (this.$$entities[entityId]) {
|
||||
existing.push(...this.$$entities[entityId].constructor.types);
|
||||
}
|
||||
const Class = this.$$entityFactory.makeClass(types(existing), this.Types);
|
||||
this.$$entities[entityId] = new Class(entityId);
|
||||
}
|
||||
|
||||
reindex(entityIds) {
|
||||
for (let i = 0; i < this.$$systems.length; i++) {
|
||||
this.$$systems[i].reindex(entityIds);
|
||||
}
|
||||
}
|
||||
|
||||
remove(entityId, components) {
|
||||
this.removeMany([[entityId, components]]);
|
||||
}
|
||||
|
||||
removeMany(entities) {
|
||||
const removing = {};
|
||||
const unique = new Set();
|
||||
for (const [entityId, components] of entities) {
|
||||
unique.add(entityId);
|
||||
const diff = {};
|
||||
for (const component of components) {
|
||||
diff[component] = false;
|
||||
if (!removing[component]) {
|
||||
removing[component] = [];
|
||||
}
|
||||
removing[component].push(entityId);
|
||||
}
|
||||
this.markChange(entityId, diff);
|
||||
this.rebuild(entityId, (types) => types.filter((type) => !components.includes(type)));
|
||||
}
|
||||
for (const component in removing) {
|
||||
this.Types[component].destroyMany(removing[component]);
|
||||
}
|
||||
this.reindex(unique.values());
|
||||
}
|
||||
|
||||
removeSystem(SystemLike) {
|
||||
const index = this.$$systems.findIndex((system) => SystemLike === system.source);
|
||||
if (-1 !== index) {
|
||||
this.$$systems.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
static serialize(ecs, view) {
|
||||
if (!view) {
|
||||
view = new DataView(new ArrayBuffer(ecs.size()));
|
||||
}
|
||||
let cursor = 0;
|
||||
let entitiesWritten = 0;
|
||||
cursor += 4;
|
||||
const keys = Object.keys(ecs.Types);
|
||||
for (const entityId of ecs.entities) {
|
||||
const entity = ecs.get(entityId);
|
||||
entitiesWritten += 1;
|
||||
view.setUint32(cursor, entityId, true);
|
||||
cursor += 4;
|
||||
const entityComponents = entity.constructor.types;
|
||||
view.setUint16(cursor, entityComponents.length, true);
|
||||
const componentsWrittenIndex = cursor;
|
||||
cursor += 2;
|
||||
for (const component of entityComponents) {
|
||||
const instance = ecs.Types[component];
|
||||
view.setUint16(cursor, keys.indexOf(component), true);
|
||||
cursor += 2;
|
||||
instance.serialize(entityId, view, cursor);
|
||||
cursor += instance.sizeOf(entityId);
|
||||
}
|
||||
view.setUint16(componentsWrittenIndex, entityComponents.length, true);
|
||||
}
|
||||
view.setUint32(0, entitiesWritten, true);
|
||||
return view;
|
||||
}
|
||||
|
||||
setClean() {
|
||||
this.diff = {};
|
||||
}
|
||||
|
||||
size() {
|
||||
// # of entities.
|
||||
let size = 4;
|
||||
for (const entityId of this.entities) {
|
||||
size += this.get(entityId).size();
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
system(SystemLike) {
|
||||
return this.$$systems.find((system) => SystemLike === system.source)
|
||||
}
|
||||
|
||||
tick(elapsed) {
|
||||
for (let i = 0; i < this.$$systems.length; i++) {
|
||||
this.$$systems[i].tick(elapsed);
|
||||
}
|
||||
for (let i = 0; i < this.$$systems.length; i++) {
|
||||
this.$$systems[i].finalize(elapsed);
|
||||
}
|
||||
this.tickDestruction();
|
||||
}
|
||||
|
||||
tickDestruction() {
|
||||
const unique = new Set();
|
||||
for (let i = 0; i < this.$$systems.length; i++) {
|
||||
for (let j = 0; j < this.$$systems[i].destroying.length; j++) {
|
||||
unique.add(this.$$systems[i].destroying[j]);
|
||||
}
|
||||
this.$$systems[i].tickDestruction();
|
||||
}
|
||||
if (unique.size > 0) {
|
||||
this.destroyMany(unique.values());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
459
app/ecs/ecs.test.js
Normal file
459
app/ecs/ecs.test.js
Normal file
|
@ -0,0 +1,459 @@
|
|||
import {expect, test} from 'vitest';
|
||||
|
||||
import Ecs from './ecs.js';
|
||||
import System from './system.js';
|
||||
|
||||
const Empty = {};
|
||||
|
||||
const Name = {
|
||||
name: {type: 'string'},
|
||||
};
|
||||
|
||||
const Position = {
|
||||
x: {type: 'int32', defaultValue: 32},
|
||||
y: {type: 'int32'},
|
||||
z: {type: 'int32'},
|
||||
};
|
||||
|
||||
test('adds and remove systems at runtime', () => {
|
||||
const ecs = new Ecs();
|
||||
let oneCount = 0;
|
||||
let twoCount = 0;
|
||||
const oneSystem = () => {
|
||||
oneCount++;
|
||||
};
|
||||
ecs.addSystem(oneSystem);
|
||||
ecs.tick();
|
||||
expect(oneCount)
|
||||
.to.equal(1);
|
||||
const twoSystem = () => {
|
||||
twoCount++;
|
||||
};
|
||||
ecs.addSystem(twoSystem);
|
||||
ecs.tick();
|
||||
expect(oneCount)
|
||||
.to.equal(2);
|
||||
expect(twoCount)
|
||||
.to.equal(1);
|
||||
ecs.removeSystem(oneSystem);
|
||||
ecs.tick();
|
||||
expect(oneCount)
|
||||
.to.equal(2);
|
||||
expect(twoCount)
|
||||
.to.equal(2);
|
||||
});
|
||||
|
||||
test('creates entities with components', () => {
|
||||
class CreateEcs extends Ecs {
|
||||
static Types = {Empty, Position};
|
||||
}
|
||||
const ecs = new CreateEcs();
|
||||
const entity = ecs.create({Empty: {}, Position: {y: 128}});
|
||||
expect(JSON.stringify(ecs.get(entity)))
|
||||
.to.deep.equal(JSON.stringify({Empty: {}, Position: {y: 128}}));
|
||||
});
|
||||
|
||||
test("removes entities' components", () => {
|
||||
class RemoveEcs extends Ecs {
|
||||
static Types = {Empty, Position};
|
||||
}
|
||||
const ecs = new RemoveEcs();
|
||||
const entity = ecs.create({Empty: {}, Position: {y: 128}});
|
||||
ecs.remove(entity, ['Position']);
|
||||
expect(JSON.stringify(ecs.get(entity)))
|
||||
.to.deep.equal(JSON.stringify({Empty: {}}));
|
||||
});
|
||||
|
||||
test('gets entities', () => {
|
||||
class GetEcs extends Ecs {
|
||||
static Types = {Empty, Position};
|
||||
}
|
||||
const ecs = new GetEcs();
|
||||
const entity = ecs.create({Empty: {}, Position: {y: 128}});
|
||||
expect(JSON.stringify(ecs.get(entity)))
|
||||
.to.deep.equal(JSON.stringify({Empty: {}, Position: {y: 128}}));
|
||||
});
|
||||
|
||||
test('destroys entities', () => {
|
||||
class DestroyEcs extends Ecs {
|
||||
static Types = {Empty, Position};
|
||||
}
|
||||
const ecs = new DestroyEcs();
|
||||
const entity = ecs.create({Empty: {}, Position: {y: 128}});
|
||||
expect(JSON.stringify(ecs.get(entity)))
|
||||
.to.deep.equal(JSON.stringify({Empty: {}, Position: {y: 128}}));
|
||||
expect(ecs.get(entity))
|
||||
.to.not.be.undefined;
|
||||
ecs.destroyAll();
|
||||
expect(ecs.get(entity))
|
||||
.to.be.undefined;
|
||||
expect(() => {
|
||||
ecs.destroy(entity);
|
||||
})
|
||||
.to.throw();
|
||||
});
|
||||
|
||||
test('inserts components into entities', () => {
|
||||
class InsertEcs extends Ecs {
|
||||
static Types = {Empty, Position};
|
||||
}
|
||||
const ecs = new InsertEcs();
|
||||
const entity = ecs.create({Empty: {}});
|
||||
ecs.insert(entity, {Position: {y: 128}});
|
||||
expect(JSON.stringify(ecs.get(entity)))
|
||||
.to.deep.equal(JSON.stringify({Empty: {}, Position: {y: 128}}));
|
||||
ecs.insert(entity, {Position: {y: 64}});
|
||||
expect(JSON.stringify(ecs.get(entity)))
|
||||
.to.deep.equal(JSON.stringify({Empty: {}, Position: {y: 64}}));
|
||||
});
|
||||
|
||||
test('ticks systems', () => {
|
||||
const Momentum = {
|
||||
x: {type: 'int32'},
|
||||
y: {type: 'int32'},
|
||||
z: {type: 'int32'},
|
||||
};
|
||||
class TickEcs extends Ecs {
|
||||
static Types = {Momentum, Position};
|
||||
}
|
||||
const ecs = new TickEcs();
|
||||
class Physics extends System {
|
||||
|
||||
static queries() {
|
||||
return {
|
||||
default: ['Position', 'Momentum'],
|
||||
};
|
||||
}
|
||||
|
||||
tick(elapsed) {
|
||||
for (const [position, momentum] of this.select('default')) {
|
||||
position.x += momentum.x * elapsed;
|
||||
position.y += momentum.y * elapsed;
|
||||
position.z += momentum.z * elapsed;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
ecs.addSystem(Physics);
|
||||
const entity = ecs.create({Momentum: {}, Position: {y: 128}});
|
||||
const position = JSON.stringify(ecs.get(entity).Position);
|
||||
ecs.tick(1);
|
||||
expect(JSON.stringify(ecs.get(entity).Position))
|
||||
.to.deep.equal(position);
|
||||
ecs.get(1).Momentum.y = 30;
|
||||
ecs.tick(1);
|
||||
expect(JSON.stringify(ecs.get(entity).Position))
|
||||
.to.deep.equal(JSON.stringify({y: 128 + 30}));
|
||||
});
|
||||
|
||||
test('creates many entities when ticking systems', () => {
|
||||
const ecs = new Ecs();
|
||||
class Spawn extends System {
|
||||
tick() {
|
||||
this.createManyEntities(Array.from({length: 5}).map(() => []));
|
||||
}
|
||||
}
|
||||
ecs.addSystem(Spawn);
|
||||
ecs.create();
|
||||
expect(ecs.get(5))
|
||||
.to.be.undefined;
|
||||
ecs.tick(1);
|
||||
expect(ecs.get(5))
|
||||
.to.not.be.undefined;
|
||||
});
|
||||
|
||||
test('creates entities when ticking systems', () => {
|
||||
const ecs = new Ecs();
|
||||
class Spawn extends System {
|
||||
tick() {
|
||||
this.createEntity();
|
||||
}
|
||||
}
|
||||
ecs.addSystem(Spawn);
|
||||
ecs.create();
|
||||
expect(ecs.get(2))
|
||||
.to.be.undefined;
|
||||
ecs.tick(1);
|
||||
expect(ecs.get(2))
|
||||
.to.not.be.undefined;
|
||||
});
|
||||
|
||||
test('schedules entities to be deleted when ticking systems', () => {
|
||||
const ecs = new Ecs();
|
||||
let entity;
|
||||
class Despawn extends System {
|
||||
|
||||
finalize() {
|
||||
entity = ecs.get(1);
|
||||
}
|
||||
|
||||
tick() {
|
||||
this.destroyEntity(1);
|
||||
}
|
||||
|
||||
}
|
||||
ecs.addSystem(Despawn);
|
||||
ecs.create();
|
||||
ecs.tick(1);
|
||||
expect(entity)
|
||||
.to.not.be.undefined;
|
||||
expect(ecs.get(1))
|
||||
.to.be.undefined;
|
||||
});
|
||||
|
||||
test('adds components to and remove components from entities when ticking systems', () => {
|
||||
class TickingEcs extends Ecs {
|
||||
static Types = {Foo: {bar: {type: 'uint8'}}};
|
||||
}
|
||||
const ecs = new TickingEcs();
|
||||
let addLength, removeLength;
|
||||
class AddComponent extends System {
|
||||
static queries() {
|
||||
return {
|
||||
default: ['Foo'],
|
||||
};
|
||||
}
|
||||
tick() {
|
||||
this.insertComponents(1, {Foo: {}});
|
||||
}
|
||||
finalize() {
|
||||
addLength = Array.from(this.select('default')).length;
|
||||
}
|
||||
}
|
||||
class RemoveComponent extends System {
|
||||
static queries() {
|
||||
return {
|
||||
default: ['Foo'],
|
||||
};
|
||||
}
|
||||
tick() {
|
||||
this.removeComponents(1, ['Foo']);
|
||||
}
|
||||
finalize() {
|
||||
removeLength = Array.from(this.select('default')).length;
|
||||
}
|
||||
}
|
||||
ecs.addSystem(AddComponent);
|
||||
ecs.create();
|
||||
ecs.tick(1);
|
||||
expect(addLength)
|
||||
.to.equal(1);
|
||||
expect(ecs.get(1).Foo)
|
||||
.to.not.be.undefined;
|
||||
ecs.removeSystem(AddComponent);
|
||||
ecs.addSystem(RemoveComponent);
|
||||
ecs.tick(1);
|
||||
expect(removeLength)
|
||||
.to.equal(0);
|
||||
expect(ecs.get(1).Foo)
|
||||
.to.be.undefined;
|
||||
});
|
||||
|
||||
test('generates coalesced diffs for entity creation', () => {
|
||||
const ecs = new Ecs();
|
||||
let entity;
|
||||
entity = ecs.create();
|
||||
expect(ecs.diff)
|
||||
.to.deep.equal({[entity]: {}});
|
||||
});
|
||||
|
||||
test('generates diffs for adding and removing components', () => {
|
||||
class DiffedEcs extends Ecs {
|
||||
static Types = {Position};
|
||||
}
|
||||
const ecs = new DiffedEcs();
|
||||
let entity;
|
||||
entity = ecs.create();
|
||||
ecs.setClean();
|
||||
ecs.insert(entity, {Position: {x: 64}});
|
||||
expect(ecs.diff)
|
||||
.to.deep.equal({[entity]: {Position: {x: 64}}});
|
||||
ecs.setClean();
|
||||
expect(ecs.diff)
|
||||
.to.deep.equal({});
|
||||
ecs.remove(entity, ['Position']);
|
||||
expect(ecs.diff)
|
||||
.to.deep.equal({[entity]: {Position: false}});
|
||||
});
|
||||
|
||||
test('generates diffs for empty components', () => {
|
||||
class DiffedEcs extends Ecs {
|
||||
static Types = {Empty};
|
||||
}
|
||||
const ecs = new DiffedEcs();
|
||||
let entity;
|
||||
entity = ecs.create({Empty});
|
||||
expect(ecs.diff)
|
||||
.to.deep.equal({[entity]: {Empty: {}}});
|
||||
ecs.setClean();
|
||||
ecs.remove(entity, ['Empty']);
|
||||
expect(ecs.diff)
|
||||
.to.deep.equal({[entity]: {Empty: false}});
|
||||
});
|
||||
|
||||
test('generates diffs for entity mutations', () => {
|
||||
class DiffedEcs extends Ecs {
|
||||
static Types = {Position};
|
||||
}
|
||||
const ecs = new DiffedEcs();
|
||||
let entity;
|
||||
entity = ecs.create({Position: {}});
|
||||
ecs.setClean();
|
||||
ecs.get(entity).Position.x = 128;
|
||||
expect(ecs.diff)
|
||||
.to.deep.equal({[entity]: {Position: {x: 128}}});
|
||||
ecs.setClean();
|
||||
expect(ecs.diff)
|
||||
.to.deep.equal({});
|
||||
});
|
||||
|
||||
test('generates coalesced diffs for components', () => {
|
||||
class DiffedEcs extends Ecs {
|
||||
static Types = {Position};
|
||||
}
|
||||
const ecs = new DiffedEcs();
|
||||
let entity;
|
||||
entity = ecs.create({Position});
|
||||
ecs.remove(entity, ['Position']);
|
||||
expect(ecs.diff)
|
||||
.to.deep.equal({[entity]: {Position: false}});
|
||||
ecs.insert(entity, {Position: {}});
|
||||
expect(ecs.diff)
|
||||
.to.deep.equal({[entity]: {Position: {}}});
|
||||
});
|
||||
|
||||
test('generates coalesced diffs for mutations', () => {
|
||||
class DiffedEcs extends Ecs {
|
||||
static Types = {Position};
|
||||
}
|
||||
const ecs = new DiffedEcs();
|
||||
let entity;
|
||||
entity = ecs.create({Position});
|
||||
ecs.setClean();
|
||||
ecs.get(entity).Position.x = 128;
|
||||
ecs.get(entity).Position.x = 256;
|
||||
ecs.get(entity).Position.x = 512;
|
||||
expect(ecs.diff)
|
||||
.to.deep.equal({[entity]: {Position: {x: 512}}});
|
||||
});
|
||||
|
||||
test('generates diffs for deletions', () => {
|
||||
const ecs = new Ecs();
|
||||
let entity;
|
||||
entity = ecs.create();
|
||||
ecs.setClean();
|
||||
ecs.destroy(entity);
|
||||
expect(ecs.diff)
|
||||
.to.deep.equal({[entity]: false});
|
||||
});
|
||||
|
||||
test('applies creation patches', () => {
|
||||
class PatchedEcs extends Ecs {
|
||||
static Types = {Position};
|
||||
}
|
||||
const ecs = new PatchedEcs();
|
||||
ecs.apply({16: {Position: {x: 64}}});
|
||||
expect(Array.from(ecs.entities).length)
|
||||
.to.equal(1);
|
||||
expect(ecs.get(16).Position.x)
|
||||
.to.equal(64);
|
||||
});
|
||||
|
||||
test('applies update patches', () => {
|
||||
class PatchedEcs extends Ecs {
|
||||
static Types = {Position};
|
||||
}
|
||||
const ecs = new PatchedEcs();
|
||||
ecs.createSpecific(16, {Position: {x: 64}});
|
||||
ecs.apply({16: {Position: {x: 128}}});
|
||||
expect(Array.from(ecs.entities).length)
|
||||
.to.equal(1);
|
||||
expect(ecs.get(16).Position.x)
|
||||
.to.equal(128);
|
||||
});
|
||||
|
||||
test('applies entity deletion patches', () => {
|
||||
class PatchedEcs extends Ecs {
|
||||
static Types = {Position};
|
||||
}
|
||||
const ecs = new PatchedEcs();
|
||||
ecs.createSpecific(16, {Position: {x: 64}});
|
||||
ecs.apply({16: false});
|
||||
expect(Array.from(ecs.entities).length)
|
||||
.to.equal(0);
|
||||
});
|
||||
|
||||
test('applies component deletion patches', () => {
|
||||
class PatchedEcs extends Ecs {
|
||||
static Types = {Empty, Position};
|
||||
}
|
||||
const ecs = new PatchedEcs();
|
||||
ecs.createSpecific(16, {Empty: {}, Position: {x: 64}});
|
||||
expect(ecs.get(16).constructor.types)
|
||||
.to.deep.equal(['Empty', 'Position']);
|
||||
ecs.apply({16: {Empty: false}});
|
||||
expect(ecs.get(16).constructor.types)
|
||||
.to.deep.equal(['Position']);
|
||||
});
|
||||
|
||||
test('calculates entity size', () => {
|
||||
class SizingEcs extends Ecs {
|
||||
static Types = {Empty, Position};
|
||||
}
|
||||
const ecs = new SizingEcs();
|
||||
ecs.createSpecific(1, {Empty: {}, Position: {}});
|
||||
// ID + # of components + Empty + Position + x + y + z
|
||||
// 4 + 2 + 2 + 2 + 4 + 4 + 4 = 22
|
||||
expect(ecs.get(1).size())
|
||||
.to.equal(22);
|
||||
});
|
||||
|
||||
test('serializes and deserializes', () => {
|
||||
class SerializingEcs extends Ecs {
|
||||
static Types = {Empty, Name, Position};
|
||||
}
|
||||
const ecs = new SerializingEcs();
|
||||
// ID + # of components + Empty + Position + x + y + z
|
||||
// 4 + 2 + 2 + 2 + 4 + 4 + 4 = 22
|
||||
ecs.createSpecific(1, {Empty: {}, Position: {x: 64}});
|
||||
// ID + # of components + Name + 'foobar' + Position + x + y + z
|
||||
// 4 + 2 + 2 + 4 + 6 + 2 + 4 + 4 + 4 = 32
|
||||
ecs.createSpecific(16, {Name: {name: 'foobar'}, Position: {x: 128}});
|
||||
const view = SerializingEcs.serialize(ecs);
|
||||
// # of entities + Entity(1) + Entity(16)
|
||||
// 4 + 22 + 32 = 58
|
||||
expect(view.byteLength)
|
||||
.to.equal(58);
|
||||
// Entity values.
|
||||
expect(view.getUint32(4 + 22 - 12, true))
|
||||
.to.equal(64);
|
||||
expect(view.getUint32(4 + 22 + 32 - 12, true))
|
||||
.to.equal(128);
|
||||
const deserialized = SerializingEcs.deserialize(view);
|
||||
// # of entities.
|
||||
expect(Array.from(deserialized.entities).length)
|
||||
.to.equal(2);
|
||||
// Composition of entities.
|
||||
expect(deserialized.get(1).constructor.types)
|
||||
.to.deep.equal(['Empty', 'Position']);
|
||||
expect(deserialized.get(16).constructor.types)
|
||||
.to.deep.equal(['Name', 'Position']);
|
||||
// Entity values.
|
||||
expect(JSON.stringify(deserialized.get(1)))
|
||||
.to.equal(JSON.stringify({Empty: {}, Position: {x: 64}}))
|
||||
expect(JSON.stringify(deserialized.get(1).Position))
|
||||
.to.equal(JSON.stringify({x: 64}));
|
||||
expect(JSON.stringify(deserialized.get(16).Position))
|
||||
.to.equal(JSON.stringify({x: 128}));
|
||||
expect(deserialized.get(16).Name.name)
|
||||
.to.equal('foobar');
|
||||
});
|
||||
|
||||
test('deserializes empty', () => {
|
||||
class SerializingEcs extends Ecs {
|
||||
static Types = {Empty, Name, Position};
|
||||
}
|
||||
const ecs = SerializingEcs.deserialize(new DataView(new Uint32Array([0]).buffer));
|
||||
expect(ecs)
|
||||
.to.not.be.undefined;
|
||||
});
|
|
@ -7,7 +7,7 @@ export default class EntityFactory {
|
|||
|
||||
$$tries = new Node();
|
||||
|
||||
makeClass(types, Components) {
|
||||
makeClass(types, Types) {
|
||||
const sorted = types.toSorted();
|
||||
let walk = this.$$tries;
|
||||
let i = 0;
|
||||
|
@ -20,16 +20,24 @@ export default class EntityFactory {
|
|||
}
|
||||
if (!walk.class) {
|
||||
class Entity {
|
||||
dirty = true;
|
||||
static types = sorted;
|
||||
constructor(id) {
|
||||
this.id = id;
|
||||
}
|
||||
size() {
|
||||
let size = 0;
|
||||
for (const component of this.constructor.types) {
|
||||
const instance = Types[component];
|
||||
size += 2 + instance.constructor.schema.sizeOf(instance.get(this.id));
|
||||
}
|
||||
// ID + # of components.
|
||||
return size + 4 + 2;
|
||||
}
|
||||
}
|
||||
const properties = {};
|
||||
for (const type of sorted) {
|
||||
properties[type] = {};
|
||||
const get = Components[type].get.bind(Components[type]);
|
||||
const get = Types[type].get.bind(Types[type]);
|
||||
properties[type].get = function() {
|
||||
return get(this.id);
|
||||
};
|
||||
|
@ -37,7 +45,7 @@ export default class EntityFactory {
|
|||
Object.defineProperties(Entity.prototype, properties);
|
||||
Entity.prototype.toJSON = new Function('', `
|
||||
return {
|
||||
${sorted.map((type) => `${type}: this.${type}`).join(', ')}
|
||||
${sorted.map((type) => `${type}: this.${type}.toJSON()`).join(', ')}
|
||||
};
|
||||
`);
|
||||
walk.class = Entity;
|
84
app/ecs/query.js
Normal file
84
app/ecs/query.js
Normal file
|
@ -0,0 +1,84 @@
|
|||
export default class Query {
|
||||
|
||||
$$criteria = {with: [], without: []};
|
||||
|
||||
$$index = new Set();
|
||||
|
||||
constructor(parameters, Types) {
|
||||
for (let i = 0; i < parameters.length; ++i) {
|
||||
const parameter = parameters[i];
|
||||
switch (parameter.charCodeAt(0)) {
|
||||
case '!'.charCodeAt(0):
|
||||
this.$$criteria.without.push(Types[parameter.slice(1)]);
|
||||
break;
|
||||
default:
|
||||
this.$$criteria.with.push(Types[parameter]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get count() {
|
||||
return this.$$index.size;
|
||||
}
|
||||
|
||||
deindex(entityIds) {
|
||||
for (let i = 0; i < entityIds.length; ++i) {
|
||||
this.$$index.delete(entityIds[i]);
|
||||
}
|
||||
}
|
||||
|
||||
reindex(entityIds) {
|
||||
if (0 === this.$$criteria.with.length && 0 === this.$$criteria.without.length) {
|
||||
for (const entityId of entityIds) {
|
||||
this.$$index.add(entityId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const entityId of entityIds) {
|
||||
let should = true;
|
||||
for (let j = 0; j < this.$$criteria.with.length; ++j) {
|
||||
if ('undefined' === typeof this.$$criteria.with[j].get(entityId)) {
|
||||
should = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (should) {
|
||||
for (let j = 0; j < this.$$criteria.without.length; ++j) {
|
||||
if ('undefined' !== typeof this.$$criteria.without[j].get(entityId)) {
|
||||
should = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (should) {
|
||||
this.$$index.add(entityId);
|
||||
}
|
||||
else if (!should) {
|
||||
this.$$index.delete(entityId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
select() {
|
||||
const it = this.$$index.values();
|
||||
const value = [];
|
||||
return {
|
||||
[Symbol.iterator]() {
|
||||
return this;
|
||||
},
|
||||
next: () => {
|
||||
const result = it.next();
|
||||
if (result.done) {
|
||||
return {done: true, value: undefined};
|
||||
}
|
||||
for (let i = 0; i < this.$$criteria.with.length; ++i) {
|
||||
value[i] = this.$$criteria.with[i].get(result.value);
|
||||
}
|
||||
value[this.$$criteria.with.length] = result.value;
|
||||
return {done: false, value};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
}
|
72
app/ecs/query.test.js
Normal file
72
app/ecs/query.test.js
Normal file
|
@ -0,0 +1,72 @@
|
|||
import {expect, test} from 'vitest';
|
||||
|
||||
import Component from './component.js';
|
||||
import Query from './query.js';
|
||||
|
||||
const A = new (Component({a: {type: 'int32', defaultValue: 420}}));
|
||||
const B = new (Component({b: {type: 'int32', defaultValue: 69}}));
|
||||
const C = new (Component({c: {type: 'int32'}}));
|
||||
|
||||
const Types = {A, B, C};
|
||||
Types.A.createMany([[2], [3]]);
|
||||
Types.B.createMany([[1], [2]]);
|
||||
Types.C.createMany([[2], [4]]);
|
||||
|
||||
function testQuery(parameters, expected) {
|
||||
const query = new Query(parameters, Types);
|
||||
query.reindex([1, 2, 3]);
|
||||
expect(query.count)
|
||||
.to.equal(expected.length);
|
||||
for (const _ of query.select()) {
|
||||
expect(_.length)
|
||||
.to.equal(parameters.filter((spec) => '!'.charCodeAt(0) !== spec.charCodeAt(0)).length + 1);
|
||||
expect(expected.includes(_.pop()))
|
||||
.to.equal(true);
|
||||
}
|
||||
}
|
||||
|
||||
test('can query all', () => {
|
||||
testQuery([], [1, 2, 3]);
|
||||
});
|
||||
|
||||
test('can query some', () => {
|
||||
testQuery(['A'], [2, 3]);
|
||||
testQuery(['A', 'B'], [2]);
|
||||
});
|
||||
|
||||
test('can query excluding', () => {
|
||||
testQuery(['!A'], [1]);
|
||||
testQuery(['A', '!B'], [3]);
|
||||
});
|
||||
|
||||
test('can deindex', () => {
|
||||
const query = new Query(['A'], Types);
|
||||
query.reindex([1, 2, 3]);
|
||||
expect(query.count)
|
||||
.to.equal(2);
|
||||
query.deindex([2]);
|
||||
expect(query.count)
|
||||
.to.equal(1);
|
||||
});
|
||||
|
||||
test('can reindex', () => {
|
||||
const Test = new (Component({a: {type: 'int32', defaultValue: 420}}));
|
||||
Test.createMany([[2], [3]]);
|
||||
const query = new Query(['Test'], {Test});
|
||||
query.reindex([2, 3]);
|
||||
expect(query.count)
|
||||
.to.equal(2);
|
||||
Test.destroy(2);
|
||||
query.reindex([2, 3]);
|
||||
expect(query.count)
|
||||
.to.equal(1);
|
||||
});
|
||||
|
||||
test('can select', () => {
|
||||
const query = new Query(['A'], Types);
|
||||
query.reindex([1, 2, 3]);
|
||||
const it = query.select();
|
||||
const result = it.next();
|
||||
expect(result.value[0].a)
|
||||
.to.equal(420);
|
||||
});
|
300
app/ecs/schema.js
Normal file
300
app/ecs/schema.js
Normal file
|
@ -0,0 +1,300 @@
|
|||
const encoder = new TextEncoder();
|
||||
|
||||
export default class Schema {
|
||||
|
||||
$$size = 0;
|
||||
|
||||
specification;
|
||||
|
||||
static viewGetMethods = {
|
||||
uint8: 'getUint8',
|
||||
int8: 'getInt8',
|
||||
uint16: 'getUint16',
|
||||
int16: 'getInt16',
|
||||
uint32: 'getUint32',
|
||||
int32: 'getInt32',
|
||||
float32: 'getFloat32',
|
||||
float64: 'getFloat64',
|
||||
int64: 'getBigInt64',
|
||||
uint64: 'getBigUint64',
|
||||
};
|
||||
|
||||
static viewSetMethods = {
|
||||
uint8: 'setUint8',
|
||||
int8: 'setInt8',
|
||||
uint16: 'setUint16',
|
||||
int16: 'setInt16',
|
||||
uint32: 'setUint32',
|
||||
int32: 'setInt32',
|
||||
float32: 'setFloat32',
|
||||
float64: 'setFloat64',
|
||||
int64: 'setBigInt64',
|
||||
uint64: 'setBigUint64',
|
||||
};
|
||||
|
||||
constructor(specification) {
|
||||
this.specification = this.constructor.normalize(specification);
|
||||
}
|
||||
|
||||
static deserialize(view, offset, specification) {
|
||||
const viewGetMethod = this.viewGetMethods[specification.type];
|
||||
if (viewGetMethod) {
|
||||
const value = view[viewGetMethod](offset.value, true);
|
||||
offset.value += this.size(specification);
|
||||
return value;
|
||||
}
|
||||
switch (specification.type) {
|
||||
case 'array': {
|
||||
const length = view.getUint32(offset.value, true);
|
||||
offset.value += 4;
|
||||
const value = [];
|
||||
for (let i = 0; i < length; ++i) {
|
||||
value.push(this.deserialize(view, offset, specification.subtype));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case 'object': {
|
||||
const value = {};
|
||||
for (const key in specification.properties) {
|
||||
value[key] = this.deserialize(view, offset, specification.properties[key]);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case 'string': {
|
||||
const length = view.getUint32(offset.value, true);
|
||||
offset.value += 4;
|
||||
const {buffer, byteOffset} = view;
|
||||
const decoder = new TextDecoder();
|
||||
const value = decoder.decode(new DataView(buffer, byteOffset + offset.value, length));
|
||||
offset.value += length;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deserialize(view, offset = 0) {
|
||||
const wrapped = {value: offset};
|
||||
return this.constructor.deserialize(view, wrapped, this.specification);
|
||||
}
|
||||
|
||||
static defaultValue(specification) {
|
||||
if (specification.defaultValue) {
|
||||
return specification.defaultValue;
|
||||
}
|
||||
switch (specification.type) {
|
||||
case 'uint8': case 'int8':
|
||||
case 'uint16': case 'int16':
|
||||
case 'uint32': case 'int32':
|
||||
case 'uint64': case 'int64':
|
||||
case 'float32': case 'float64': {
|
||||
return 0;
|
||||
}
|
||||
case 'array': {
|
||||
return [];
|
||||
}
|
||||
case 'object': {
|
||||
const object = {};
|
||||
for (const key in specification.properties) {
|
||||
object[key] = this.defaultValue(specification.properties[key]);
|
||||
}
|
||||
return object;
|
||||
}
|
||||
case 'string': {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defaultValue() {
|
||||
return this.constructor.defaultValue(this.specification);
|
||||
}
|
||||
|
||||
has(key) {
|
||||
return key in this.specification;
|
||||
}
|
||||
|
||||
static normalize(specification) {
|
||||
let normalized = specification;
|
||||
switch (specification.type) {
|
||||
case 'array': {
|
||||
normalized.subtype = this.normalize(specification.subtype);
|
||||
break;
|
||||
}
|
||||
case 'object': {
|
||||
for (const key in specification.properties) {
|
||||
normalized.properties[key] = this.normalize(specification.properties[key])
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'uint8':
|
||||
case 'int8':
|
||||
case 'uint16':
|
||||
case 'int16':
|
||||
case 'uint32':
|
||||
case 'int32':
|
||||
case 'uint64':
|
||||
case 'int64':
|
||||
case 'float32':
|
||||
case 'float64':
|
||||
case 'string': {
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new TypeError(`invalid specification: ${JSON.stringify(specification)}`);
|
||||
}
|
||||
return {...normalized, defaultValue: this.defaultValue(normalized)};
|
||||
}
|
||||
|
||||
static readSize(view, offset, specification) {
|
||||
const size = this.size(specification);
|
||||
if (size > 0) {
|
||||
return size;
|
||||
}
|
||||
switch (specification.type) {
|
||||
case 'array': {
|
||||
const length = view.getUint32(offset.value, true);
|
||||
offset.value += 4;
|
||||
let arraySize = 0;
|
||||
for (let i = 0; i < length; ++i) {
|
||||
arraySize += this.readSize(view, offset, specification.subtype);
|
||||
}
|
||||
return 4 + arraySize;
|
||||
}
|
||||
case 'object': {
|
||||
let objectSize = 0;
|
||||
for (const key in specification.properties) {
|
||||
objectSize += this.readSize(view, offset, specification.properties[key]);
|
||||
}
|
||||
return objectSize;
|
||||
}
|
||||
case 'string': {
|
||||
const length = view.getUint32(offset.value, true);
|
||||
offset.value += 4 + length;
|
||||
return 4 + length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readSize(view, offset) {
|
||||
const wrapped = {value: offset};
|
||||
return this.constructor.readSize(view, wrapped, this.specification);
|
||||
}
|
||||
|
||||
static serialize(source, view, offset, specification) {
|
||||
const viewSetMethod = this.viewSetMethods[specification.type];
|
||||
if (viewSetMethod) {
|
||||
view[viewSetMethod](offset, source, true);
|
||||
return this.size(specification);
|
||||
}
|
||||
switch (specification.type) {
|
||||
case 'array': {
|
||||
view.setUint32(offset, source.length, true);
|
||||
offset += 4;
|
||||
let arraySize = 0;
|
||||
for (const element of source) {
|
||||
arraySize += this.serialize(
|
||||
element,
|
||||
view,
|
||||
offset + arraySize,
|
||||
specification.subtype,
|
||||
);
|
||||
}
|
||||
return 4 + arraySize;
|
||||
}
|
||||
case 'object': {
|
||||
let objectSize = 0;
|
||||
for (const key in specification.properties) {
|
||||
objectSize += this.serialize(
|
||||
source[key],
|
||||
view,
|
||||
offset + objectSize,
|
||||
specification.properties[key],
|
||||
);
|
||||
}
|
||||
return objectSize;
|
||||
}
|
||||
case 'string': {
|
||||
const encoder = new TextEncoder();
|
||||
const bytes = encoder.encode(source);
|
||||
view.setUint32(offset, bytes.length, true);
|
||||
offset += 4;
|
||||
for (let i = 0; i < bytes.length; ++i) {
|
||||
view.setUint8(offset++, bytes[i]);
|
||||
}
|
||||
return 4 + bytes.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serialize(source, view, offset = 0) {
|
||||
this.constructor.serialize(source, view, offset, this.specification);
|
||||
}
|
||||
|
||||
static sizeOf(concrete, specification) {
|
||||
const size = this.size(specification);
|
||||
if (size > 0) {
|
||||
return size;
|
||||
}
|
||||
let fullSize = 0;
|
||||
const {type} = specification;
|
||||
switch (type) {
|
||||
case 'array': {
|
||||
fullSize += 4;
|
||||
for (const element of concrete) {
|
||||
fullSize += this.sizeOf(element, specification.subtype);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'object': {
|
||||
for (const key in specification.properties) {
|
||||
fullSize += this.sizeOf(concrete[key], specification.properties[key]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'string':
|
||||
fullSize += 4;
|
||||
fullSize += (encoder.encode(concrete)).length;
|
||||
break;
|
||||
}
|
||||
return fullSize;
|
||||
}
|
||||
|
||||
sizeOf(concrete) {
|
||||
return this.constructor.sizeOf(concrete, this.specification);
|
||||
}
|
||||
|
||||
static size(specification) {
|
||||
switch (specification.type) {
|
||||
case 'array': return 0;
|
||||
case 'object': {
|
||||
let size = 0;
|
||||
for (const key in specification.properties) {
|
||||
const propertySize = this.size(specification.properties[key]);
|
||||
if (0 === propertySize) {
|
||||
return 0;
|
||||
}
|
||||
size += propertySize;
|
||||
}
|
||||
return size;
|
||||
}
|
||||
case 'uint8': case 'int8': {
|
||||
return 1;
|
||||
}
|
||||
case 'uint16': case 'int16': {
|
||||
return 2;
|
||||
}
|
||||
case 'uint32': case 'int32': case 'float32': {
|
||||
return 4;
|
||||
}
|
||||
case 'uint64': case 'int64': case 'float64': {
|
||||
return 8;
|
||||
}
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
size() {
|
||||
return this.constructor.size(this.specification);
|
||||
}
|
||||
|
||||
}
|
136
app/ecs/schema.test.js
Normal file
136
app/ecs/schema.test.js
Normal file
|
@ -0,0 +1,136 @@
|
|||
import {expect, test} from 'vitest';
|
||||
|
||||
import Schema from './schema.js';
|
||||
|
||||
test('defaults values', () => {
|
||||
[
|
||||
'uint8',
|
||||
'int8',
|
||||
'uint16',
|
||||
'int16',
|
||||
'uint32',
|
||||
'int32',
|
||||
'uint64',
|
||||
'int64',
|
||||
'float32',
|
||||
'float64',
|
||||
].forEach((type) => {
|
||||
expect(Schema.defaultValue({type}))
|
||||
.to.equal(0);
|
||||
expect(new Schema({type}).specification.defaultValue)
|
||||
.to.equal(0);
|
||||
});
|
||||
expect(Schema.defaultValue({type: 'string'}))
|
||||
.to.equal('');
|
||||
expect(new Schema({type: 'string'}).specification.defaultValue)
|
||||
.to.equal('');
|
||||
expect(Schema.defaultValue({type: 'array', subtype: {type: 'string'}}))
|
||||
.to.deep.equal([]);
|
||||
expect(new Schema({type: 'array', subtype: {type: 'string'}}).specification.defaultValue)
|
||||
.to.deep.equal([]);
|
||||
expect(Schema.defaultValue({
|
||||
type: 'object',
|
||||
properties: {
|
||||
foo: {type: 'uint8'},
|
||||
bar: {type: 'string'},
|
||||
baz: {type: 'object', properties: {blah: {type: 'array', subtype: {type: 'string'}}}},
|
||||
},
|
||||
}))
|
||||
.to.deep.equal({foo: 0, bar: '', baz: {blah: []}});
|
||||
expect(new Schema({
|
||||
type: 'object',
|
||||
properties: {
|
||||
foo: {type: 'uint8'},
|
||||
bar: {type: 'string'},
|
||||
baz: {type: 'object', properties: {blah: {type: 'array', subtype: {type: 'string'}}}},
|
||||
},
|
||||
}).specification.defaultValue)
|
||||
.to.deep.equal({foo: 0, bar: '', baz: {blah: []}});
|
||||
});
|
||||
|
||||
test('validates a schema', () => {
|
||||
[
|
||||
'uint8',
|
||||
'int8',
|
||||
'uint16',
|
||||
'int16',
|
||||
'uint32',
|
||||
'int32',
|
||||
'uint64',
|
||||
'int64',
|
||||
'float32',
|
||||
'float64',
|
||||
'string',
|
||||
].forEach((type) => {
|
||||
expect(() => {
|
||||
new Schema({type});
|
||||
new Schema({type: 'array', subtype: {type}});
|
||||
new Schema({type: 'object', properties: {foo: {type}}});
|
||||
})
|
||||
.to.not.throw();
|
||||
});
|
||||
});
|
||||
|
||||
test('calculates the size of concrete instances', () => {
|
||||
expect(
|
||||
(new Schema({type: 'string'}))
|
||||
.sizeOf('hi')
|
||||
)
|
||||
.to.equal(4 + (new TextEncoder().encode('hi')).length);
|
||||
|
||||
expect(
|
||||
(new Schema(
|
||||
{type: 'object', properties: {
|
||||
foo: {type: 'uint8'},
|
||||
bar: {type: 'uint32'},
|
||||
baz: {type: 'string'},
|
||||
}}
|
||||
))
|
||||
.sizeOf({foo: 69, bar: 420, baz: 'aα'})
|
||||
)
|
||||
.to.equal(
|
||||
1
|
||||
+ 4
|
||||
+ 4 + (new TextEncoder().encode('aα')).length
|
||||
);
|
||||
expect(
|
||||
(new Schema({type: 'array', subtype: {type: 'string'}}))
|
||||
.sizeOf(['hallo', 'hαllo'])
|
||||
)
|
||||
.to.equal(
|
||||
4
|
||||
+ 4 + (new TextEncoder().encode('hallo')).length
|
||||
+ 4 + (new TextEncoder().encode('hαllo')).length
|
||||
);
|
||||
});
|
||||
|
||||
test('encodes and decodes', () => {
|
||||
const entries = [
|
||||
[{type: 'uint8'}, 255],
|
||||
[{type: 'int8'}, -128],
|
||||
[{type: 'int8'}, 127],
|
||||
[{type: 'uint16'}, 65535],
|
||||
[{type: 'int16'}, -32768],
|
||||
[{type: 'int16'}, 32767],
|
||||
[{type: 'uint32'}, 4294967295],
|
||||
[{type: 'int32'}, -2147483648],
|
||||
[{type: 'int32'}, 2147483647],
|
||||
[{type: 'uint64'}, 18446744073709551615n],
|
||||
[{type: 'int64'}, -9223372036854775808n],
|
||||
[{type: 'int64'}, 9223372036854775807n],
|
||||
[{type: 'float32'}, 0.5],
|
||||
[{type: 'float64'}, 1.234],
|
||||
[{type: 'string'}, 'hello world'],
|
||||
[{type: 'string'}, 'α'],
|
||||
[{type: 'array', subtype: {type: 'uint8'}}, [1, 2, 3, 4]],
|
||||
[{type: 'array', subtype: {type: 'string'}}, ['one', 'two', 'three', 'four']],
|
||||
[{type: 'object', properties: {foo: {type: 'uint8'}, bar: {type: 'string'}}}, {foo: 64, bar: 'baz'}],
|
||||
];
|
||||
entries.forEach(([specification, concrete]) => {
|
||||
const schema = new Schema(specification);
|
||||
const view = new DataView(new ArrayBuffer(schema.sizeOf(concrete)));
|
||||
schema.serialize(concrete, view);
|
||||
expect(concrete)
|
||||
.to.deep.equal(schema.deserialize(view));
|
||||
});
|
||||
});
|
112
app/ecs/system.js
Normal file
112
app/ecs/system.js
Normal file
|
@ -0,0 +1,112 @@
|
|||
|
||||
import Query from './query.js';
|
||||
|
||||
export default class System {
|
||||
|
||||
destroying = [];
|
||||
|
||||
ecs;
|
||||
|
||||
queries = {};
|
||||
|
||||
constructor(ecs) {
|
||||
this.ecs = ecs;
|
||||
const queries = this.constructor.queries();
|
||||
for (const i in queries) {
|
||||
this.queries[i] = new Query(queries[i], ecs.Types);
|
||||
}
|
||||
}
|
||||
|
||||
deindex(entityIds) {
|
||||
for (const i in this.queries) {
|
||||
this.queries[i].deindex(entityIds);
|
||||
}
|
||||
}
|
||||
|
||||
destroyEntity(entityId) {
|
||||
this.destroyManyEntities([entityId]);
|
||||
}
|
||||
|
||||
destroyManyEntities(entityIds) {
|
||||
for (let i = 0; i < entityIds.length; i++) {
|
||||
this.destroying.push(entityIds[i]);
|
||||
}
|
||||
}
|
||||
|
||||
finalize() {}
|
||||
|
||||
static normalize(SystemLike) {
|
||||
if (SystemLike.prototype instanceof System) {
|
||||
return SystemLike;
|
||||
}
|
||||
if ('function' === typeof SystemLike) {
|
||||
class TickingSystem extends System {}
|
||||
TickingSystem.prototype.tick = SystemLike;
|
||||
return TickingSystem;
|
||||
}
|
||||
/* v8 ignore next */
|
||||
throw new TypeError(`Couldn't normalize '${SystemLike}' as a system`);
|
||||
}
|
||||
|
||||
static queries() {
|
||||
return {};
|
||||
}
|
||||
|
||||
reindex(entityIds) {
|
||||
for (const i in this.queries) {
|
||||
this.queries[i].reindex(entityIds);
|
||||
}
|
||||
}
|
||||
|
||||
select(query) {
|
||||
return this.queries[query].select();
|
||||
}
|
||||
|
||||
tickDestruction() {
|
||||
this.deindex(this.destroying);
|
||||
this.destroying = [];
|
||||
}
|
||||
|
||||
tick() {}
|
||||
|
||||
static wrap(source, ecs) {
|
||||
class WrappedSystem extends System.normalize(source) {
|
||||
|
||||
constructor() {
|
||||
super(ecs);
|
||||
this.reindex(ecs.entities);
|
||||
}
|
||||
|
||||
createEntity(components) {
|
||||
return this.ecs.create(components);
|
||||
}
|
||||
|
||||
createManyEntities(componentsList) {
|
||||
return this.ecs.createMany(componentsList);
|
||||
}
|
||||
|
||||
get source() {
|
||||
return source;
|
||||
}
|
||||
|
||||
insertComponents(entityId, components) {
|
||||
this.ecs.insert(entityId, components);
|
||||
}
|
||||
|
||||
insertManyComponents(components) {
|
||||
this.ecs.insertMany(components);
|
||||
}
|
||||
|
||||
removeComponents(entityId, components) {
|
||||
this.ecs.remove(entityId, components);
|
||||
}
|
||||
|
||||
removeManyComponents(entityIds) {
|
||||
this.ecs.removeMany(entityIds);
|
||||
}
|
||||
|
||||
}
|
||||
return new WrappedSystem();
|
||||
}
|
||||
|
||||
}
|
8
app/engine/ecs.js
Normal file
8
app/engine/ecs.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
import Ecs from '@/ecs/ecs.js';
|
||||
import Types from '@/ecs-components/index.js';
|
||||
|
||||
class EngineEcs extends Ecs {
|
||||
static Types = Types;
|
||||
}
|
||||
|
||||
export default EngineEcs;
|
200
app/engine/engine.js
Normal file
200
app/engine/engine.js
Normal file
|
@ -0,0 +1,200 @@
|
|||
import {
|
||||
MOVE_MAP,
|
||||
RESOLUTION,
|
||||
TPS,
|
||||
} from '@/constants.js';
|
||||
import ControlMovement from '@/ecs-systems/control-movement.js';
|
||||
import ApplyMomentum from '@/ecs-systems/apply-momentum.js';
|
||||
import CalculateAabbs from '@/ecs-systems/calculate-aabbs.js';
|
||||
import ClampPositions from '@/ecs-systems/clamp-positions.js';
|
||||
import FollowCamera from '@/ecs-systems/follow-camera.js';
|
||||
import UpdateSpatialHash from '@/ecs-systems/update-spatial-hash.js';
|
||||
import RunAnimations from '@/ecs-systems/run-animations.js';
|
||||
import ControlDirection from '@/ecs-systems/control-direction.js';
|
||||
import SpriteDirection from '@/ecs-systems/sprite-direction.js';
|
||||
import Ecs from '@/engine/ecs.js';
|
||||
import {decode, encode} from '@/packets/index.js';
|
||||
|
||||
const players = {
|
||||
0: {
|
||||
Camera: {},
|
||||
Controlled: {up: 0, right: 0, down: 0, left: 0},
|
||||
Direction: {direction: 2},
|
||||
Momentum: {},
|
||||
Position: {x: 368, y: 368},
|
||||
VisibleAabb: {},
|
||||
World: {world: 1},
|
||||
Sprite: {
|
||||
animation: 'moving:down',
|
||||
frame: 0,
|
||||
frames: 8,
|
||||
source: '/assets/dude.json',
|
||||
speed: 0.115,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default class Engine {
|
||||
|
||||
static Ecs = Ecs;
|
||||
|
||||
incomingActions = [];
|
||||
|
||||
connections = [];
|
||||
|
||||
connectedPlayers = new Map();
|
||||
|
||||
ecses = {};
|
||||
|
||||
frame = 0;
|
||||
|
||||
last = Date.now();
|
||||
|
||||
server;
|
||||
|
||||
constructor(Server) {
|
||||
const ecs = new this.constructor.Ecs();
|
||||
const layerSize = {x: Math.ceil(RESOLUTION.x / 4), y: Math.ceil(RESOLUTION.y / 4)};
|
||||
ecs.create({
|
||||
AreaSize: {x: RESOLUTION.x * 4, y: RESOLUTION.y * 4},
|
||||
TileLayers: {
|
||||
layers: [
|
||||
{
|
||||
data: (
|
||||
Array(layerSize.x * layerSize.y)
|
||||
.fill(0)
|
||||
.map(() => 1 + Math.floor(Math.random() * 4))
|
||||
),
|
||||
size: layerSize,
|
||||
}
|
||||
],
|
||||
},
|
||||
});
|
||||
ecs.addSystem(ControlMovement);
|
||||
ecs.addSystem(ApplyMomentum);
|
||||
ecs.addSystem(ClampPositions);
|
||||
ecs.addSystem(FollowCamera);
|
||||
ecs.addSystem(CalculateAabbs);
|
||||
ecs.addSystem(UpdateSpatialHash);
|
||||
ecs.addSystem(ControlDirection);
|
||||
ecs.addSystem(SpriteDirection);
|
||||
ecs.addSystem(RunAnimations);
|
||||
this.ecses = {
|
||||
1: ecs,
|
||||
};
|
||||
class SilphiusServer extends Server {
|
||||
accept(connection, packed) {
|
||||
super.accept(connection, decode(packed));
|
||||
}
|
||||
transmit(connection, packet) {
|
||||
super.transmit(connection, encode(packet));
|
||||
}
|
||||
}
|
||||
this.server = new SilphiusServer();
|
||||
this.server.addPacketListener('Action', (connection, payload) => {
|
||||
this.incomingActions.push([this.connectedPlayers.get(connection).entity, payload]);
|
||||
});
|
||||
}
|
||||
|
||||
async connectPlayer(connection) {
|
||||
this.connections.push(connection);
|
||||
const entityJson = await this.loadPlayer(connection);
|
||||
const ecs = this.ecses[entityJson.World.world];
|
||||
const entity = ecs.create(entityJson);
|
||||
this.connectedPlayers.set(
|
||||
connection,
|
||||
{
|
||||
entity: ecs.get(entity),
|
||||
memory: new Set(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
disconnectPlayer(connection) {
|
||||
const {entity} = this.connectedPlayers.get(connection);
|
||||
const ecs = this.ecses[entity.World.world];
|
||||
players[0] = JSON.parse(JSON.stringify(entity.toJSON()));
|
||||
ecs.destroy(entity.id);
|
||||
this.connectedPlayers.delete(connection);
|
||||
this.connections.splice(this.connections.indexOf(connection), 1);
|
||||
}
|
||||
|
||||
async load() {
|
||||
}
|
||||
|
||||
async loadPlayer() {
|
||||
return players[0];
|
||||
}
|
||||
|
||||
start() {
|
||||
return setInterval(() => {
|
||||
const elapsed = (Date.now() - this.last) / 1000;
|
||||
this.last = Date.now();
|
||||
this.tick(elapsed);
|
||||
this.update(elapsed);
|
||||
this.frame += 1;
|
||||
}, 1000 / TPS);
|
||||
}
|
||||
|
||||
tick(elapsed) {
|
||||
for (const i in this.ecses) {
|
||||
this.ecses[i].setClean();
|
||||
}
|
||||
for (const [{Controlled}, payload] of this.incomingActions) {
|
||||
if (payload.type in MOVE_MAP) {
|
||||
Controlled[MOVE_MAP[payload.type]] = payload.value;
|
||||
}
|
||||
}
|
||||
this.incomingActions = [];
|
||||
for (const i in this.ecses) {
|
||||
this.ecses[i].tick(elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
update(elapsed) {
|
||||
for (const connection of this.connections) {
|
||||
this.server.send(
|
||||
connection,
|
||||
{
|
||||
type: 'Tick',
|
||||
payload: {
|
||||
ecs: this.updateFor(connection),
|
||||
elapsed,
|
||||
frame: this.frame,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
updateFor(connection) {
|
||||
const update = {};
|
||||
const {entity, memory} = this.connectedPlayers.get(connection);
|
||||
const mainEntityId = entity.id;
|
||||
const ecs = this.ecses[entity.World.world];
|
||||
const nearby = ecs.system(UpdateSpatialHash).nearby(entity);
|
||||
// Master entity.
|
||||
nearby.add(ecs.get(1));
|
||||
const lastMemory = new Set(memory.values());
|
||||
for (const entity of nearby) {
|
||||
const {id} = entity;
|
||||
lastMemory.delete(id);
|
||||
if (!memory.has(id)) {
|
||||
update[id] = entity.toJSON();
|
||||
if (mainEntityId === id) {
|
||||
update[id].MainEntity = {};
|
||||
}
|
||||
}
|
||||
else if (ecs.diff[id]) {
|
||||
update[id] = ecs.diff[id];
|
||||
}
|
||||
memory.add(id);
|
||||
}
|
||||
for (const id of lastMemory) {
|
||||
memory.delete(id);
|
||||
update[id] = false;
|
||||
}
|
||||
return update;
|
||||
}
|
||||
|
||||
}
|
43
app/engine/engine.test.js
Normal file
43
app/engine/engine.test.js
Normal file
|
@ -0,0 +1,43 @@
|
|||
import {expect, test} from 'vitest';
|
||||
|
||||
import {RESOLUTION} from '@/constants.js'
|
||||
import Server from '@/net/server/server.js';
|
||||
import Engine from './engine.js';
|
||||
|
||||
test('visibility-based updates', async () => {
|
||||
const engine = new Engine(Server);
|
||||
const ecs = engine.ecses[1];
|
||||
// Create an entity.
|
||||
const entity = ecs.get(ecs.create({
|
||||
Momentum: {x: 1, y: 0},
|
||||
Position: {x: (RESOLUTION.x * 1.5) + 32 - 3, y: 20},
|
||||
VisibleAabb: {},
|
||||
}));
|
||||
// Connect an entity.
|
||||
await engine.connectPlayer(undefined);
|
||||
// Tick and get update. Should be a full update.
|
||||
engine.tick(1);
|
||||
expect(engine.updateFor(undefined))
|
||||
.to.deep.include({2: ecs.get(2).toJSON(), 3: {MainEntity: {}, ...ecs.get(3).toJSON()}});
|
||||
// Tick and get update. Should be a partial update.
|
||||
engine.tick(1);
|
||||
expect(engine.updateFor(undefined))
|
||||
.to.deep.equal({
|
||||
2: {
|
||||
Position: {x: (RESOLUTION.x * 1.5) + 32 - 1},
|
||||
VisibleAabb: {
|
||||
x0: 1199,
|
||||
x1: 1263,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Tick and get update. Should remove the entity.
|
||||
engine.tick(1);
|
||||
expect(engine.updateFor(undefined))
|
||||
.to.deep.equal({2: false});
|
||||
// Aim back toward visible area and tick. Should be a full update for that entity.
|
||||
entity.Momentum.x = -1;
|
||||
engine.tick(1);
|
||||
expect(engine.updateFor(undefined))
|
||||
.to.deep.equal({2: ecs.get(2).toJSON()});
|
||||
});
|
23
app/engine/gather.js
Normal file
23
app/engine/gather.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
function capitalize(string) {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
}
|
||||
|
||||
export default function gather(imports, options = {}) {
|
||||
const {
|
||||
mapperForPath = (path) => path.replace(/\.\/(.*)\.js/, '$1'),
|
||||
} = options;
|
||||
const Gathered = {};
|
||||
let id = 1;
|
||||
for (const [path, Component] of Object.entries(imports).sort(([l], [r]) => l < r ? -1 : 1)) {
|
||||
const key = mapperForPath(path)
|
||||
.split('-')
|
||||
.map(capitalize)
|
||||
.join('');
|
||||
if (Component.gathered) {
|
||||
Component.gathered(id, key);
|
||||
}
|
||||
Gathered[key] = Gathered[id] = Component;
|
||||
id += 1;
|
||||
}
|
||||
return Gathered;
|
||||
}
|
18
app/engine/gather.test.js
Normal file
18
app/engine/gather.test.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
import {expect, test} from 'vitest';
|
||||
|
||||
import gather from './gather.js';
|
||||
|
||||
test('gathers', async () => {
|
||||
const Gathered = gather(
|
||||
import.meta.glob('./test/*.js', {eager: true, import: 'default'}),
|
||||
{mapperForPath: (path) => path.replace(/\.\/test\/(.*)\.js/, '$1')},
|
||||
);
|
||||
expect(Gathered.First.key)
|
||||
.to.equal('First');
|
||||
expect(Gathered[1].id)
|
||||
.to.equal(1);
|
||||
expect(Gathered.Second.key)
|
||||
.to.equal('Second');
|
||||
expect(Gathered[2].id)
|
||||
.to.equal(2);
|
||||
});
|
6
app/engine/test/first.js
Normal file
6
app/engine/test/first.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
export default class First {
|
||||
static gathered(id, key) {
|
||||
this.id = id;
|
||||
this.key = key;
|
||||
}
|
||||
}
|
6
app/engine/test/second.js
Normal file
6
app/engine/test/second.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
export default class Second {
|
||||
static gathered(id, key) {
|
||||
this.id = id;
|
||||
this.key = key;
|
||||
}
|
||||
}
|
18
app/entry.client.jsx
Normal file
18
app/entry.client.jsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* By default, Remix will handle hydrating your app on the client for you.
|
||||
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
|
||||
* For more information, see https://remix.run/file-conventions/entry.client
|
||||
*/
|
||||
|
||||
import { RemixBrowser } from "@remix-run/react";
|
||||
import { startTransition, StrictMode } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<RemixBrowser />
|
||||
</StrictMode>
|
||||
);
|
||||
});
|
150
app/entry.server.jsx
Normal file
150
app/entry.server.jsx
Normal file
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* By default, Remix will handle generating the HTTP Response for you.
|
||||
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
|
||||
* For more information, see https://remix.run/file-conventions/entry.server
|
||||
*/
|
||||
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import { createReadableStreamFromReadable } from "@remix-run/node";
|
||||
import { RemixServer } from "@remix-run/react";
|
||||
import { isbot } from "isbot";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
|
||||
const ABORT_DELAY = 5_000;
|
||||
|
||||
export async function websocket(server, viteDevServer) {
|
||||
if (viteDevServer) {
|
||||
const {createViteRuntime} = await import('vite');
|
||||
const runtime = await createViteRuntime(viteDevServer);
|
||||
(await runtime.executeEntrypoint('/app/websocket.js')).default(server);
|
||||
}
|
||||
else {
|
||||
(await import('./websocket.js')).default(server);
|
||||
}
|
||||
}
|
||||
|
||||
export default function handleRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
remixContext,
|
||||
// This is ignored so we can keep it in the template for visibility. Feel
|
||||
// free to delete this parameter in your app if you're not using it!
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
loadContext
|
||||
) {
|
||||
return isbot(request.headers.get("user-agent") || "")
|
||||
? handleBotRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
remixContext
|
||||
)
|
||||
: handleBrowserRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
remixContext
|
||||
);
|
||||
}
|
||||
|
||||
function handleBotRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
remixContext
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<RemixServer
|
||||
context={remixContext}
|
||||
url={request.url}
|
||||
abortDelay={ABORT_DELAY}
|
||||
/>,
|
||||
{
|
||||
onAllReady() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
})
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error) {
|
||||
responseStatusCode = 500;
|
||||
// Log streaming rendering errors from inside the shell. Don't log
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setTimeout(abort, ABORT_DELAY);
|
||||
});
|
||||
}
|
||||
|
||||
function handleBrowserRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
remixContext
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<RemixServer
|
||||
context={remixContext}
|
||||
url={request.url}
|
||||
abortDelay={ABORT_DELAY}
|
||||
/>,
|
||||
{
|
||||
onShellReady() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
})
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error) {
|
||||
responseStatusCode = 500;
|
||||
// Log streaming rendering errors from inside the shell. Don't log
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setTimeout(abort, ABORT_DELAY);
|
||||
});
|
||||
}
|
16
app/hooks/use-packet.js
Normal file
16
app/hooks/use-packet.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
import {useContext, useEffect} from 'react';
|
||||
|
||||
import ClientContext from '@/context/client.js';
|
||||
|
||||
export default function usePacket(type, fn, dependencies) {
|
||||
const client = useContext(ClientContext);
|
||||
useEffect(() => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
client.addPacketListener(type, fn);
|
||||
return () => {
|
||||
client.removePacketListener(type, fn);
|
||||
};
|
||||
}, [client, ...dependencies]);
|
||||
}
|
42
app/net/client/client.js
Normal file
42
app/net/client/client.js
Normal file
|
@ -0,0 +1,42 @@
|
|||
import {CLIENT_LATENCY} from '@/constants.js';
|
||||
|
||||
export default class Client {
|
||||
constructor() {
|
||||
this.listeners = {};
|
||||
}
|
||||
accept(packet) {
|
||||
const listeners = this.listeners[packet.type];
|
||||
if (!listeners) {
|
||||
return;
|
||||
}
|
||||
for (const i in listeners) {
|
||||
listeners[i](packet.payload);
|
||||
}
|
||||
}
|
||||
addPacketListener(type, listener) {
|
||||
if (!this.listeners[type]) {
|
||||
this.listeners[type] = [];
|
||||
}
|
||||
this.listeners[type].push(listener);
|
||||
}
|
||||
removePacketListener(type, listener) {
|
||||
const listeners = this.listeners[type];
|
||||
if (!listeners) {
|
||||
return;
|
||||
}
|
||||
const index = listeners.indexOf(listener);
|
||||
if (-1 !== index) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
send(packet) {
|
||||
if (CLIENT_LATENCY > 0) {
|
||||
setTimeout(() => {
|
||||
this.transmit(packet);
|
||||
}, CLIENT_LATENCY);
|
||||
}
|
||||
else {
|
||||
this.transmit(packet);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,13 +3,16 @@ import Client from './client.js';
|
|||
export default class LocalClient extends Client {
|
||||
async connect() {
|
||||
this.worker = new Worker(
|
||||
'/net/server/worker.js',
|
||||
new URL('../server/worker.js', import.meta.url),
|
||||
{type: 'module'},
|
||||
);
|
||||
this.worker.onmessage = (event) => {
|
||||
this.accept(event.data);
|
||||
};
|
||||
}
|
||||
disconnect() {
|
||||
this.worker.terminate();
|
||||
}
|
||||
transmit(packed) {
|
||||
this.worker.postMessage(packed);
|
||||
}
|
36
app/net/client/prediction.js
Normal file
36
app/net/client/prediction.js
Normal file
|
@ -0,0 +1,36 @@
|
|||
import {encode} from '@/packets/index.js';
|
||||
|
||||
let connected = false;
|
||||
let socket;
|
||||
|
||||
function onMessage(event) {
|
||||
postMessage(event.data);
|
||||
}
|
||||
|
||||
onmessage = async (event) => {
|
||||
if (!connected) {
|
||||
const url = new URL(`wss://${event.data.host}/ws`)
|
||||
if ('production' === process.env.NODE_ENV) {
|
||||
url.protocol = 'ws:';
|
||||
}
|
||||
socket = new WebSocket(url.href);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
const {promise, resolve} = Promise.withResolvers();
|
||||
socket.addEventListener('open', resolve);
|
||||
socket.addEventListener('error', () => {
|
||||
postMessage(encode({type: 'ConnectionStatus', payload: 'aborted'}));
|
||||
close();
|
||||
});
|
||||
await promise;
|
||||
socket.removeEventListener('open', resolve);
|
||||
socket.addEventListener('message', onMessage);
|
||||
socket.addEventListener('close', () => {
|
||||
postMessage(encode({type: 'ConnectionStatus', payload: 'aborted'}));
|
||||
close();
|
||||
});
|
||||
postMessage(encode({type: 'ConnectionStatus', payload: 'connected'}));
|
||||
connected = true;
|
||||
return;
|
||||
}
|
||||
socket.send(event.data);
|
||||
};
|
67
app/net/client/remote.js
Normal file
67
app/net/client/remote.js
Normal file
|
@ -0,0 +1,67 @@
|
|||
import {CLIENT_PREDICTION} from '@/constants.js';
|
||||
import {encode} from '@/packets/index.js';
|
||||
|
||||
import Client from './client.js';
|
||||
|
||||
export default class RemoteClient extends Client {
|
||||
constructor() {
|
||||
super();
|
||||
if (CLIENT_PREDICTION) {
|
||||
this.worker = undefined;
|
||||
}
|
||||
else {
|
||||
this.socket = undefined;
|
||||
}
|
||||
}
|
||||
async connect(host) {
|
||||
if (CLIENT_PREDICTION) {
|
||||
this.worker = new Worker(
|
||||
new URL('../client/prediction.js', import.meta.url),
|
||||
{type: 'module'},
|
||||
);
|
||||
this.worker.postMessage({host});
|
||||
this.worker.onmessage = (event) => {
|
||||
this.accept(event.data);
|
||||
};
|
||||
}
|
||||
else {
|
||||
const url = new URL(`wss://${host}/ws`)
|
||||
if ('production' === process.env.NODE_ENV) {
|
||||
url.protocol = 'ws:';
|
||||
}
|
||||
this.socket = new WebSocket(url.href);
|
||||
this.socket.binaryType = 'arraybuffer';
|
||||
const onMessage = (event) => {
|
||||
this.accept(event.data);
|
||||
}
|
||||
const {promise, resolve} = Promise.withResolvers();
|
||||
this.socket.addEventListener('open', resolve);
|
||||
this.socket.addEventListener('error', () => {
|
||||
this.accept(encode({type: 'ConnectionStatus', payload: 'aborted'}));
|
||||
});
|
||||
await promise;
|
||||
this.socket.removeEventListener('open', resolve);
|
||||
this.socket.addEventListener('message', onMessage);
|
||||
this.socket.addEventListener('close', () => {
|
||||
this.accept(encode({type: 'ConnectionStatus', payload: 'aborted'}));
|
||||
});
|
||||
this.accept(encode({type: 'ConnectionStatus', payload: 'connected'}));
|
||||
}
|
||||
}
|
||||
disconnect() {
|
||||
if (CLIENT_PREDICTION) {
|
||||
this.worker.terminate();
|
||||
}
|
||||
else {
|
||||
this.socket.close();
|
||||
}
|
||||
}
|
||||
transmit(packed) {
|
||||
if (CLIENT_PREDICTION) {
|
||||
this.worker.postMessage(packed);
|
||||
}
|
||||
else {
|
||||
this.socket.send(packed);
|
||||
}
|
||||
}
|
||||
}
|
34
app/net/packet.js
Normal file
34
app/net/packet.js
Normal file
|
@ -0,0 +1,34 @@
|
|||
import {Encoder, Decoder} from '@msgpack/msgpack';
|
||||
|
||||
const decoder = new Decoder();
|
||||
const encoder = new Encoder();
|
||||
|
||||
export default class Packet {
|
||||
|
||||
static id;
|
||||
static type;
|
||||
|
||||
static decode(view) {
|
||||
return decoder.decode(new DataView(view.buffer, view.byteOffset + 2, view.byteLength - 2));
|
||||
}
|
||||
|
||||
static encode(payload) {
|
||||
encoder.pos = 2;
|
||||
encoder.doEncode(payload)
|
||||
return new DataView(encoder.bytes.buffer, 0, encoder.pos);
|
||||
}
|
||||
|
||||
static gathered(id, type) {
|
||||
this.id = id;
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
static pack(payload) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
static unpack(packed) {
|
||||
return packed;
|
||||
}
|
||||
|
||||
}
|
30
app/net/packet.test.js
Normal file
30
app/net/packet.test.js
Normal file
|
@ -0,0 +1,30 @@
|
|||
import {expect, test} from 'vitest';
|
||||
|
||||
import Packet from './packet.js';
|
||||
|
||||
class PackedPacket extends Packet {
|
||||
static map = {
|
||||
1: 'one',
|
||||
2: 'two',
|
||||
one: 1,
|
||||
two: 2,
|
||||
};
|
||||
static pack(payload) {
|
||||
return Object.fromEntries(Object.entries(payload).map(([k, v]) => [k, this.map[v]]));
|
||||
}
|
||||
static unpack(payload) {
|
||||
return Object.fromEntries(Object.entries(payload).map(([k, v]) => [k, this.map[v]]));
|
||||
}
|
||||
}
|
||||
|
||||
test('packs and unpacks', async () => {
|
||||
const payload = {foo: 'one', bar: 'two'};
|
||||
const encoded = PackedPacket.encode(payload);
|
||||
expect(Packet.decode(encoded))
|
||||
.to.deep.equal(payload);
|
||||
const packed = PackedPacket.pack(payload)
|
||||
expect(packed)
|
||||
.to.deep.equal({foo: 1, bar: 2});
|
||||
expect(PackedPacket.unpack(packed))
|
||||
.to.deep.equal({foo: 'one', bar: 'two'});
|
||||
});
|
42
app/net/server/server.js
Normal file
42
app/net/server/server.js
Normal file
|
@ -0,0 +1,42 @@
|
|||
import {SERVER_LATENCY} from '@/constants.js';
|
||||
|
||||
export default class Server {
|
||||
constructor() {
|
||||
this.listeners = {};
|
||||
}
|
||||
accept(connection, packet) {
|
||||
const listeners = this.listeners[packet.type];
|
||||
if (!listeners) {
|
||||
return;
|
||||
}
|
||||
for (const i in listeners) {
|
||||
listeners[i](connection, packet.payload);
|
||||
}
|
||||
}
|
||||
addPacketListener(type, listener) {
|
||||
if (!this.listeners[type]) {
|
||||
this.listeners[type] = [];
|
||||
}
|
||||
this.listeners[type].push(listener);
|
||||
}
|
||||
removePacketListener(type, listener) {
|
||||
const listeners = this.listeners[type];
|
||||
if (!listeners) {
|
||||
return;
|
||||
}
|
||||
const index = listeners.indexOf(listener);
|
||||
if (-1 !== index) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
send(connection, packet) {
|
||||
if (SERVER_LATENCY > 0) {
|
||||
setTimeout(() => {
|
||||
this.transmit(connection, packet);
|
||||
}, SERVER_LATENCY);
|
||||
}
|
||||
else {
|
||||
this.transmit(connection, packet);
|
||||
}
|
||||
}
|
||||
}
|
26
app/net/server/worker.js
Normal file
26
app/net/server/worker.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
import Engine from '../../engine/engine.js';
|
||||
import {encode} from '@/packets/index.js';
|
||||
|
||||
import Server from './server.js';
|
||||
|
||||
class WorkerServer extends Server {
|
||||
transmit(connection, packed) { postMessage(packed); }
|
||||
}
|
||||
|
||||
const engine = new Engine(WorkerServer);
|
||||
|
||||
onmessage = (event) => {
|
||||
engine.server.accept(undefined, event.data);
|
||||
};
|
||||
|
||||
(async () => {
|
||||
await engine.load();
|
||||
engine.start();
|
||||
await engine.connectPlayer(undefined);
|
||||
postMessage(encode({type: 'ConnectionStatus', payload: 'connected'}));
|
||||
})();
|
||||
|
||||
import.meta.hot.accept('../../engine/engine.js', () => {
|
||||
postMessage(encode({type: 'ConnectionStatus', payload: 'aborted'}));
|
||||
close();
|
||||
});
|
|
@ -1,4 +1,4 @@
|
|||
import Packet from './packet.js';
|
||||
import Packet from '@/net/packet.js';
|
||||
|
||||
const WIRE_MAP = {
|
||||
'moveUp': 0,
|
||||
|
@ -13,8 +13,6 @@ Object.entries(WIRE_MAP)
|
|||
|
||||
export default class Action extends Packet {
|
||||
|
||||
static type = 'action';
|
||||
|
||||
static pack(payload) {
|
||||
return super.pack({
|
||||
type: WIRE_MAP[payload.type],
|
||||
|
@ -30,4 +28,4 @@ export default class Action extends Packet {
|
|||
};
|
||||
}
|
||||
|
||||
};
|
||||
}
|
3
app/packets/connection-status.js
Normal file
3
app/packets/connection-status.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import Packet from '@/net/packet.js';
|
||||
|
||||
export default class ConnectionStatus extends Packet {}
|
20
app/packets/index.js
Normal file
20
app/packets/index.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
import gather from '@/engine/gather.js';
|
||||
|
||||
const Gathered = gather(import.meta.glob('./*.js', {eager: true, import: 'default'}));
|
||||
|
||||
export function decode(packed) {
|
||||
const view = ArrayBuffer.isView(packed) ? packed : new DataView(packed);
|
||||
const id = view.getUint16(0, true);
|
||||
const Packet = Gathered[id];
|
||||
return {
|
||||
type: Packet.type,
|
||||
payload: Packet.decode(view),
|
||||
};
|
||||
}
|
||||
|
||||
export function encode(packet) {
|
||||
const Packet = Gathered[packet.type];
|
||||
const encoded = Packet.encode(packet.payload);
|
||||
encoded.setUint16(0, Packet.id, true);
|
||||
return encoded;
|
||||
}
|
3
app/packets/tick.js
Normal file
3
app/packets/tick.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import Packet from '@/net/packet.js';
|
||||
|
||||
export default class Tick extends Packet {}
|
27
app/react-components/disconnected.jsx
Normal file
27
app/react-components/disconnected.jsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
import {useEffect, useState} from 'react';
|
||||
|
||||
import styles from './disconnected.module.css';
|
||||
|
||||
export default function Disconnected() {
|
||||
const [dots, setDots] = useState(3);
|
||||
const [delta, setDelta] = useState(1);
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
const updated = dots + delta;
|
||||
setDots(updated);
|
||||
if (updated < 1 || updated > 5) {
|
||||
setDelta(-delta);
|
||||
}
|
||||
}, 100);
|
||||
return () => {
|
||||
clearTimeout(handle);
|
||||
};
|
||||
}, [dots, delta]);
|
||||
const rendered = Array(dots).fill('.').join('');
|
||||
return (
|
||||
<div className={styles.disconnected}>
|
||||
<p>There's a problem with the connection.</p>
|
||||
<p>{rendered}Reconnection attempt in progress{rendered}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
14
app/react-components/disconnected.module.css
Normal file
14
app/react-components/disconnected.module.css
Normal file
|
@ -0,0 +1,14 @@
|
|||
.disconnected {
|
||||
align-items: center;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
p {
|
||||
color: #cccccc;
|
||||
font-size: 2em;
|
||||
}
|
||||
}
|
|
@ -17,7 +17,7 @@ export default function Dom({children}) {
|
|||
function onResize() {
|
||||
const {parentNode} = ref.current;
|
||||
const {width} = parentNode.getBoundingClientRect();
|
||||
setScale(width / RESOLUTION[0]);
|
||||
setScale(width / RESOLUTION.x);
|
||||
}
|
||||
window.addEventListener('resize', onResize);
|
||||
onResize();
|
||||
|
@ -28,7 +28,12 @@ export default function Dom({children}) {
|
|||
return (
|
||||
<div className={styles.dom} ref={ref}>
|
||||
{scale > 0 && (
|
||||
<style>{`.${styles.dom}{--scale:${scale}}`}</style>
|
||||
<style>{`
|
||||
.${styles.dom}{
|
||||
--scale: ${scale};
|
||||
--unit: calc(${RESOLUTION.x} / 1000);
|
||||
}
|
||||
`}</style>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
60
app/react-components/ecs.jsx
Normal file
60
app/react-components/ecs.jsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
import {Container} from '@pixi/react';
|
||||
import {useState} from 'react';
|
||||
|
||||
import {RESOLUTION} from '@/constants.js';
|
||||
import Ecs from '@/engine/ecs.js';
|
||||
import usePacket from '@/hooks/use-packet.js';
|
||||
|
||||
import Entities from './entities.jsx';
|
||||
import TileLayer from './tile-layer.jsx';
|
||||
|
||||
export default function EcsComponent() {
|
||||
const [ecs] = useState(new Ecs());
|
||||
const [entities, setEntities] = useState({});
|
||||
const [mainEntity, setMainEntity] = useState();
|
||||
usePacket('Tick', (payload) => {
|
||||
if (0 === Object.keys(payload.ecs).length) {
|
||||
return;
|
||||
}
|
||||
ecs.apply(payload.ecs);
|
||||
const updatedEntities = {...entities};
|
||||
for (const id in payload.ecs) {
|
||||
if (false === payload.ecs[id]) {
|
||||
delete updatedEntities[id];
|
||||
}
|
||||
else {
|
||||
updatedEntities[id] = ecs.get(id);
|
||||
if (updatedEntities[id].MainEntity) {
|
||||
setMainEntity(ecs.get(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
setEntities(updatedEntities);
|
||||
}, [entities, mainEntity]);
|
||||
if (!mainEntity) {
|
||||
return false;
|
||||
}
|
||||
const {Camera} = mainEntity;
|
||||
const {TileLayers} = ecs.get(1);
|
||||
const [cx, cy] = [
|
||||
Math.round(Camera.x - RESOLUTION.x / 2),
|
||||
Math.round(Camera.y - RESOLUTION.y / 2),
|
||||
];
|
||||
return (
|
||||
<Container>
|
||||
<TileLayer
|
||||
size={TileLayers.layers[0].size}
|
||||
tiles={TileLayers.layers[0].data}
|
||||
tileset="/assets/tileset.json"
|
||||
tileSize={{x: 16, y: 16}}
|
||||
x={-cx}
|
||||
y={-cy}
|
||||
/>
|
||||
<Entities
|
||||
entities={entities}
|
||||
x={-cx}
|
||||
y={-cy}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
24
app/react-components/entities.jsx
Normal file
24
app/react-components/entities.jsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import {Container} from '@pixi/react';
|
||||
|
||||
import Sprite from './sprite.jsx';
|
||||
|
||||
export default function Entities({entities, x, y}) {
|
||||
const sprites = [];
|
||||
for (const id in entities) {
|
||||
const entity = entities[id];
|
||||
if (!entity.Position || !entity.Sprite) {
|
||||
continue;
|
||||
}
|
||||
sprites.push(
|
||||
<Sprite
|
||||
entity={entity}
|
||||
key={id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Container x={x} y={y}>
|
||||
{sprites}
|
||||
</Container>
|
||||
)
|
||||
}
|
|
@ -1,15 +1,17 @@
|
|||
.hotbar {
|
||||
border: 2px solid #999999;
|
||||
--border: calc(var(--unit) * 3px);
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border: var(--border) solid #444444;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
left: 135px;
|
||||
left: calc(var(--unit) * 225px);
|
||||
line-height: 0;
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
top: calc(var(--unit) * 25px);
|
||||
}
|
||||
|
||||
.slotWrapper {
|
||||
border: 2px solid #999999;
|
||||
border: var(--border) solid #999999;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
line-height: 0;
|
||||
|
@ -19,7 +21,7 @@
|
|||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
50
app/react-components/pixi.jsx
Normal file
50
app/react-components/pixi.jsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import {
|
||||
Stage as PixiStage,
|
||||
} from '@pixi/react';
|
||||
import {SCALE_MODES} from '@pixi/constants';
|
||||
import {settings} from '@pixi/settings';
|
||||
|
||||
import {RESOLUTION} from '@/constants.js';
|
||||
import ClientContext from '@/context/client.js';
|
||||
|
||||
import Ecs from './ecs.jsx';
|
||||
import styles from './pixi.module.css';
|
||||
|
||||
settings.SCALE_MODE = SCALE_MODES.NEAREST;
|
||||
|
||||
const ContextBridge = ({ children, Context, render }) => {
|
||||
return (
|
||||
<Context.Consumer>
|
||||
{(value) =>
|
||||
render(<Context.Provider value={value}>{children}</Context.Provider>)
|
||||
}
|
||||
</Context.Consumer>
|
||||
);
|
||||
};
|
||||
|
||||
export const Stage = ({children, ...props}) => {
|
||||
return (
|
||||
<ContextBridge
|
||||
Context={ClientContext}
|
||||
render={(children) => <PixiStage {...props}>{children}</PixiStage>}
|
||||
>
|
||||
{children}
|
||||
</ContextBridge>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Pixi() {
|
||||
return (
|
||||
<Stage
|
||||
className={styles.stage}
|
||||
width={RESOLUTION.x}
|
||||
height={RESOLUTION.y}
|
||||
options={{
|
||||
background: 0x1099bb,
|
||||
}}
|
||||
>
|
||||
<Ecs />
|
||||
</Stage>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,32 +1,33 @@
|
|||
.slot {
|
||||
--size: 35px;
|
||||
--base: calc(var(--size) / 5);
|
||||
--size: calc(var(--unit) * 50px);
|
||||
--space: calc(var(--unit) * 10px);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
height: var(--size);
|
||||
image-rendering: pixelated;
|
||||
padding: var(--base);
|
||||
user-select: none;
|
||||
width: var(--size);
|
||||
}
|
||||
|
||||
.slotInner {
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
height: calc(var(--base) * 5);
|
||||
height: calc(100% - var(--space) * 2);
|
||||
padding: var(--space);
|
||||
position: relative;
|
||||
width: calc(var(--base) * 5);
|
||||
width: calc(100% - var(--space) * 2);
|
||||
}
|
||||
|
||||
.qty {
|
||||
bottom: calc(var(--base) / -1.25);
|
||||
bottom: calc(var(--space) / -1.25);
|
||||
font-family: monospace;
|
||||
font-size: calc(var(--base) * 2);
|
||||
font-size: calc(var(--space) * 2);
|
||||
line-height: 1;
|
||||
position: absolute;
|
||||
right: calc(var(--base) / -1.25);
|
||||
right: calc(var(--space) / -1.25);
|
||||
text-shadow:
|
||||
0px -1px 0px white,
|
||||
1px 0px 0px white,
|
||||
|
@ -34,12 +35,12 @@
|
|||
-1px 0px 0px white
|
||||
;
|
||||
&:global(.q-2) {
|
||||
font-size: calc(var(--base) * 1.75);
|
||||
font-size: calc(var(--space) * 1.75);
|
||||
}
|
||||
&:global(.q-3) {
|
||||
font-size: calc(var(--base) * 1.5);
|
||||
font-size: calc(var(--space) * 1.5);
|
||||
}
|
||||
&:global(.q-4) {
|
||||
font-size: calc(var(--base) * 1.25);
|
||||
font-size: calc(var(--space) * 1.25);
|
||||
}
|
||||
}
|
35
app/react-components/sprite.jsx
Normal file
35
app/react-components/sprite.jsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
import {Assets} from '@pixi/assets';
|
||||
import {Sprite as PixiSprite} from '@pixi/react';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
export default function Sprite({entity}) {
|
||||
const [asset, setAsset] = useState();
|
||||
useEffect(() => {
|
||||
const asset = Assets.get(entity.Sprite.source);
|
||||
if (asset) {
|
||||
setAsset(asset);
|
||||
}
|
||||
else {
|
||||
Assets.load(entity.Sprite.source).then(setAsset);
|
||||
}
|
||||
}, [setAsset, entity.Sprite.source]);
|
||||
if (!asset) {
|
||||
return false;
|
||||
}
|
||||
let texture;
|
||||
if (asset.textures) {
|
||||
const animation = asset.animations[entity.Sprite.animation]
|
||||
texture = animation[entity.Sprite.frame];
|
||||
}
|
||||
else {
|
||||
texture = asset;
|
||||
}
|
||||
return (
|
||||
<PixiSprite
|
||||
anchor={0.5}
|
||||
texture={texture}
|
||||
x={Math.round(entity.Position.x)}
|
||||
y={Math.round(entity.Position.y)}
|
||||
/>
|
||||
);
|
||||
}
|
50
app/react-components/tile-layer.jsx
Normal file
50
app/react-components/tile-layer.jsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import {useEffect, useState} from 'react';
|
||||
import {Assets} from '@pixi/assets';
|
||||
import {PixiComponent} from '@pixi/react';
|
||||
import '@pixi/spritesheet'; // NECESSARY!
|
||||
import {CompositeTilemap} from '@pixi/tilemap';
|
||||
|
||||
const TileLayerInternal = PixiComponent('TileLayer', {
|
||||
create: () => new CompositeTilemap(),
|
||||
applyProps: (tilemap, {tiles: oldTiles}, props) => {
|
||||
const {asset, tiles, tileset, tileSize, size, x, y} = props;
|
||||
const extless = tileset.slice('/assets/'.length, -'.json'.length);
|
||||
const {textures} = asset;
|
||||
tilemap.position.x = x;
|
||||
tilemap.position.y = y;
|
||||
if (tiles === oldTiles) {
|
||||
return;
|
||||
}
|
||||
tilemap.clear();
|
||||
let i = 0;
|
||||
for (let y = 0; y < size.y; ++y) {
|
||||
for (let x = 0; x < size.x; ++x) {
|
||||
tilemap.tile(textures[`${extless}/${tiles[i++]}`], tileSize.x * x, tileSize.y * y);
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export default function TileLayer(props) {
|
||||
const {tileset} = props;
|
||||
const [asset, setAsset] = useState();
|
||||
useEffect(() => {
|
||||
const asset = Assets.get(tileset);
|
||||
if (asset) {
|
||||
setAsset(asset);
|
||||
}
|
||||
else {
|
||||
Assets.load(tileset).then(setAsset);
|
||||
}
|
||||
}, [setAsset, tileset]);
|
||||
if (!asset) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
<TileLayerInternal
|
||||
{...props}
|
||||
asset={asset}
|
||||
tileset={tileset}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -1,28 +1,45 @@
|
|||
import {useContext, useEffect} from 'react';
|
||||
import {useContext, useEffect, useState} from 'react';
|
||||
|
||||
import addKeyListener from '@/add-key-listener.js';
|
||||
import {ACTION_MAP, RESOLUTION} from '@/constants.js';
|
||||
import ClientContext from '@/context/client.js';
|
||||
|
||||
import Disconnected from './disconnected.jsx';
|
||||
import Dom from './dom.jsx';
|
||||
import HotBar from './hotbar.jsx';
|
||||
import Pixi from './pixi.jsx';
|
||||
import styles from './ui.module.css';
|
||||
|
||||
const ratio = RESOLUTION[0] / RESOLUTION[1];
|
||||
const ratio = RESOLUTION.x / RESOLUTION.y;
|
||||
|
||||
const KEY_MAP = {
|
||||
keyDown: 1,
|
||||
keyUp: 0,
|
||||
};
|
||||
|
||||
export default function Ui() {
|
||||
export default function Ui({disconnected}) {
|
||||
// Key input.
|
||||
const client = useContext(ClientContext);
|
||||
const [showDisconnected, setShowDisconnected] = useState(false);
|
||||
useEffect(() => {
|
||||
let handle;
|
||||
if (disconnected) {
|
||||
handle = setTimeout(() => {
|
||||
setShowDisconnected(true);
|
||||
}, 200);
|
||||
}
|
||||
else {
|
||||
setShowDisconnected(false)
|
||||
}
|
||||
return () => {
|
||||
clearTimeout(handle);
|
||||
};
|
||||
}, [disconnected]);
|
||||
useEffect(() => {
|
||||
return addKeyListener(document.body, ({type, payload}) => {
|
||||
if (type in KEY_MAP && payload in ACTION_MAP) {
|
||||
client.send({
|
||||
type: 'action',
|
||||
type: 'Action',
|
||||
payload: {
|
||||
type: ACTION_MAP[payload],
|
||||
value: KEY_MAP[type],
|
||||
|
@ -41,13 +58,10 @@ export default function Ui() {
|
|||
</style>
|
||||
<Pixi />
|
||||
<Dom>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
height: '225px',
|
||||
width: '400px',
|
||||
}}
|
||||
></div>
|
||||
<HotBar active={0} slots={Array(10).fill(0).map(() => {})} />
|
||||
{showDisconnected && (
|
||||
<Disconnected />
|
||||
)}
|
||||
</Dom>
|
||||
</div>
|
||||
);
|
|
@ -6,11 +6,3 @@ html, body {
|
|||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.silphius {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: space-around;
|
||||
line-height: 1;
|
||||
width: 100%;
|
||||
}
|
31
app/root.jsx
Normal file
31
app/root.jsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import {
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
} from "@remix-run/react";
|
||||
|
||||
import './root.css';
|
||||
|
||||
export function Layout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <Outlet />;
|
||||
}
|
13
app/routes/_main-menu._index/index.module.css
Normal file
13
app/routes/_main-menu._index/index.module.css
Normal file
|
@ -0,0 +1,13 @@
|
|||
.title {
|
||||
font-size: 10em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
font-size: 3em;
|
||||
list-style: none;
|
||||
text-align: center;
|
||||
li {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
}
|
25
app/routes/_main-menu._index/route.jsx
Normal file
25
app/routes/_main-menu._index/route.jsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import styles from './index.module.css';
|
||||
|
||||
export const meta = () => {
|
||||
return [
|
||||
{
|
||||
title: 'Silphius',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
content: 'Silphius is an action RPG and homestead simulator',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className={styles.title}>Silphius</h1>
|
||||
<ul className={styles.actions}>
|
||||
<li><a href="/play/local">Single-player</a></li>
|
||||
<li><a href="/play/remote/localhost:3000">Multi-player</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
7
app/routes/_main-menu.play.$/play.module.css
Normal file
7
app/routes/_main-menu.play.$/play.module.css
Normal file
|
@ -0,0 +1,7 @@
|
|||
.play {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: space-around;
|
||||
line-height: 0;
|
||||
width: 100%;
|
||||
}
|
86
app/routes/_main-menu.play.$/route.jsx
Normal file
86
app/routes/_main-menu.play.$/route.jsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
import {useEffect, useState} from 'react';
|
||||
import {useParams} from 'react-router-dom';
|
||||
|
||||
import ClientContext from '@/context/client.js';
|
||||
import LocalClient from '@/net/client/local.js';
|
||||
import RemoteClient from '@/net/client/remote.js';
|
||||
import {decode, encode} from '@/packets/index.js';
|
||||
import Ui from '@/react-components/ui.jsx';
|
||||
|
||||
import styles from './play.module.css';
|
||||
|
||||
export default function Index() {
|
||||
const [client, setClient] = useState();
|
||||
const [disconnected, setDisconnected] = useState(false);
|
||||
const params = useParams();
|
||||
const [type, url] = params['*'].split('/');
|
||||
useEffect(() => {
|
||||
let Client;
|
||||
switch (type) {
|
||||
case 'local':
|
||||
Client = LocalClient;
|
||||
break;
|
||||
case 'remote':
|
||||
Client = RemoteClient;
|
||||
break;
|
||||
}
|
||||
class SilphiusClient extends Client {
|
||||
accept(packed) {
|
||||
super.accept(decode(packed));
|
||||
}
|
||||
transmit(packet) {
|
||||
super.transmit(encode(packet));
|
||||
}
|
||||
}
|
||||
const client = new SilphiusClient();
|
||||
async function connect() {
|
||||
await client.connect(url);
|
||||
setClient(client);
|
||||
}
|
||||
connect();
|
||||
return () => {
|
||||
client.disconnect();
|
||||
};
|
||||
}, [type, url]);
|
||||
useEffect(() => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
function onConnectionStatus(status) {
|
||||
switch (status) {
|
||||
case 'aborted': {
|
||||
setDisconnected(true);
|
||||
break;
|
||||
}
|
||||
case 'connected': {
|
||||
setDisconnected(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
client.addPacketListener('ConnectionStatus', onConnectionStatus);
|
||||
return () => {
|
||||
client.removePacketListener('ConnectionStatus', onConnectionStatus);
|
||||
};
|
||||
}, [client]);
|
||||
useEffect(() => {
|
||||
if (!disconnected) {
|
||||
return;
|
||||
}
|
||||
async function reconnect() {
|
||||
await client.connect(url);
|
||||
}
|
||||
reconnect();
|
||||
const handle = setInterval(reconnect, 1000);
|
||||
return () => {
|
||||
clearInterval(handle);
|
||||
};
|
||||
}, [client, disconnected, url]);
|
||||
return (
|
||||
<div className={styles.play}>
|
||||
<ClientContext.Provider value={client}>
|
||||
<Ui disconnected={disconnected} />
|
||||
</ClientContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
5
app/routes/_main-menu/main-menu.module.css
Normal file
5
app/routes/_main-menu/main-menu.module.css
Normal file
|
@ -0,0 +1,5 @@
|
|||
.main-menu {
|
||||
height: 100%;
|
||||
line-height: 1;
|
||||
width: 100%;
|
||||
}
|
11
app/routes/_main-menu/route.jsx
Normal file
11
app/routes/_main-menu/route.jsx
Normal file
|
@ -0,0 +1,11 @@
|
|||
import {Outlet} from 'react-router-dom';
|
||||
|
||||
import styles from './main-menu.module.css';
|
||||
|
||||
export default function MainMenu() {
|
||||
return (
|
||||
<div className={styles['main-menu']}>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
63
app/websocket.js
Normal file
63
app/websocket.js
Normal file
|
@ -0,0 +1,63 @@
|
|||
import {WebSocketServer} from 'ws';
|
||||
|
||||
import Engine from './engine/engine.js';
|
||||
import Server from './net/server/server.js';
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
noServer: true,
|
||||
});
|
||||
|
||||
function onUpgrade(request, socket, head) {
|
||||
const {pathname} = new URL(request.url, 'wss://base.url');
|
||||
if (pathname === '/ws') {
|
||||
wss.handleUpgrade(request, socket, head, function done(ws) {
|
||||
wss.emit('connection', ws, request);
|
||||
});
|
||||
}
|
||||
else {
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export default async function listen(server) {
|
||||
server.on('upgrade', onUpgrade);
|
||||
|
||||
class SocketServer extends Server {
|
||||
transmit(ws, packed) { ws.send(packed); }
|
||||
}
|
||||
|
||||
let onConnect;
|
||||
function makeOnConnect(engine) {
|
||||
return async (ws) => {
|
||||
ws.on('close', () => {
|
||||
engine.disconnectPlayer(ws);
|
||||
})
|
||||
ws.on('message', (packed) => {
|
||||
engine.server.accept(ws, new DataView(packed.buffer, packed.byteOffset, packed.length));
|
||||
});
|
||||
await engine.connectPlayer(ws);
|
||||
};
|
||||
}
|
||||
|
||||
let engine;
|
||||
async function makeEngine(Engine) {
|
||||
const engine = new Engine(SocketServer);
|
||||
await engine.load();
|
||||
engine.start();
|
||||
return engine;
|
||||
}
|
||||
|
||||
engine = await makeEngine(Engine);
|
||||
wss.on('connection', onConnect = makeOnConnect(engine));
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept('./engine/engine.js', async ({default: Engine}) => {
|
||||
wss.off('connection', onConnect);
|
||||
for (const [connection] of engine.connectedPlayers) {
|
||||
connection.close();
|
||||
}
|
||||
engine = await makeEngine(Engine);
|
||||
wss.on('connection', onConnect = makeOnConnect(engine));
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
// eslint.config.js
|
||||
import globals from 'globals';
|
||||
import js from '@eslint/js';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
process: false,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
impliedStrict: true,
|
||||
jsx: true
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// 'no-unused-vars': 'warn',
|
||||
// 'no-undef': 'warn',
|
||||
}
|
||||
}
|
||||
];
|
10050
package-lock.json
generated
10050
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
77
package.json
77
package.json
|
@ -1,44 +1,57 @@
|
|||
{
|
||||
"name": "silphius",
|
||||
"name": "silphius-next",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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",
|
||||
"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",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"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"
|
||||
"storybook:build": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@msgpack/msgpack": "^3.0.0-beta2",
|
||||
"@pixi/react": "^7.1.2",
|
||||
"pixi.js": "^8.1.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"@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",
|
||||
"ws": "^8.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^1.5.0",
|
||||
"@remix-run/dev": "^2.9.2",
|
||||
"@storybook/addon-essentials": "^8.1.6",
|
||||
"@storybook/addon-interactions": "^8.1.6",
|
||||
"@storybook/addon-links": "^8.1.6",
|
||||
"@storybook/addon-onboarding": "^8.1.6",
|
||||
"@storybook/blocks": "^8.1.6",
|
||||
"@storybook/react": "^8.1.6",
|
||||
"@storybook/react-vite": "^8.1.6",
|
||||
"@storybook/test": "^8.1.6",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@vitest/coverage-v8": "^1.6.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.38.0",
|
||||
"eslint-plugin-import": "^2.28.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"image-size": "^1.1.1",
|
||||
"storybook": "^8.1.6",
|
||||
"vite": "^5.1.0",
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
|
|
1
public/assets/dude.json
Normal file
1
public/assets/dude.json
Normal file
File diff suppressed because one or more lines are too long
BIN
public/assets/dude.png
Normal file
BIN
public/assets/dude.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.2 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
40
public/assets/tileset.js
Executable file
40
public/assets/tileset.js
Executable file
|
@ -0,0 +1,40 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import {writeFileSync} from 'node:fs';
|
||||
import {basename, dirname, extname, join} from 'node:path';
|
||||
|
||||
import imageSize from 'image-size';
|
||||
|
||||
const tileset = process.argv[2];
|
||||
const w = parseInt(process.argv[3]);
|
||||
const h = parseInt(process.argv[4]);
|
||||
|
||||
const {width, height} = imageSize(tileset);
|
||||
|
||||
const json = {
|
||||
frames: {},
|
||||
meta: {
|
||||
format: 'RGBA8888',
|
||||
image: tileset,
|
||||
scale: 1,
|
||||
size: {w: width, h: height},
|
||||
},
|
||||
};
|
||||
|
||||
const extlessPath = join(dirname(tileset), basename(tileset, extname(tileset)));
|
||||
|
||||
let i = 0;
|
||||
for (let y = 0; y < height; y += h) {
|
||||
for (let x = 0; x < width; x += w) {
|
||||
json.frames[join(extlessPath, `${i++}`)] = {
|
||||
frame: {x, y, w, h},
|
||||
spriteSourceSize: {x: 0, y: 0, w, h},
|
||||
sourceSize: {w, h},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
`${extlessPath}.json`,
|
||||
JSON.stringify(json),
|
||||
);
|
1
public/assets/tileset.json
Normal file
1
public/assets/tileset.json
Normal file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user