flow: up to date

This commit is contained in:
cha0s 2024-06-10 22:42:30 -05:00
parent 2a544c35f2
commit a5e02abe16
89 changed files with 11813 additions and 66 deletions

View File

@ -52,7 +52,7 @@ module.exports = {
// Node // Node
{ {
files: ['.eslintrc.cjs', 'server.js'], files: ['.eslintrc.cjs', 'server.js', 'vite.config.js', 'websocket.js'],
env: { env: {
node: true, node: true,
}, },

17
.storybook/main.js Normal file
View File

@ -0,0 +1,17 @@
process.env.STORYBOOK = 1
/** @type { import('@storybook/react-vite').StorybookConfig } */
const config = {
stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@chromatic-com/storybook',
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
};
export default config;

13
.storybook/preview.js Normal file
View File

@ -0,0 +1,13 @@
/** @type { import('@storybook/react').Preview } */
const preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

64
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,64 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// 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",
"name": "Silphius Dev",
"skipFiles": [
"<node_internals>/**"
],
"resolveSourceMapLocations": [],
"cwd": "${workspaceFolder}",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
},
{
"type": "chrome",
"request": "launch",
"name": "Storybook Chrome",
"url": "http://localhost:6006",
"webRoot": "${workspaceFolder}",
},
{
"type": "node",
"request": "launch",
"name": "Storybook Dev",
"skipFiles": [
"<node_internals>/**"
],
"resolveSourceMapLocations": [],
"cwd": "${workspaceFolder}",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "storybook", "--", "--no-open"],
},
],
"compounds": [
{
"name": "Silphius",
"configurations": [
"Silphius Dev",
"Silphius Chrome",
],
"stopAll": true,
},
{
"name": "Storybook",
"configurations": [
"Storybook Dev",
"Storybook Chrome",
],
"stopAll": true,
}
]
}

View File

@ -1,6 +1,6 @@
# Welcome to Remix! # Silphius
- 📖 [Remix docs](https://remix.run/docs) All the world's a game!
## Development ## Development
@ -25,12 +25,3 @@ npm start
``` ```
Now you'll need to pick a host to deploy it to. Now you'll need to pick a host to deploy it to.
### DIY
If you're familiar with deploying Node applications, the built-in Remix app server is production-ready.
Make sure to deploy the output of `npm run build`
- `build/server`
- `build/client`

48
app/add-key-listener.js Normal file
View File

@ -0,0 +1,48 @@
// Handle lame OS key input event behavior. See: https://mzl.la/2Ob0WQE
// Also "up" all keys on blur.
export default function addKeyListener(target, listener) {
let keysDown = {};
let keyUpDelays = {};
function setAllKeysUp() {
for (const i in keyUpDelays) {
clearTimeout(keyUpDelays[i]);
}
keyUpDelays = {};
for (const key in keysDown) {
listener({type: 'keyUp', payload: key});
}
keysDown = {};
}
function onBlur() {
setAllKeysUp();
}
function onKeyDown(event) {
const {key} = event;
if (keysDown[key]) {
if (keyUpDelays[key]) {
clearTimeout(keyUpDelays[key]);
delete keyUpDelays[key];
}
return;
}
keysDown[key] = true;
listener({type: 'keyDown', payload: key});
}
function onKeyUp(event) {
const {key} = event;
keyUpDelays[key] = setTimeout(() => {
delete keyUpDelays[key];
delete keysDown[key];
listener({type: 'keyUp', payload: key});
}, 20);
}
window.addEventListener('blur', onBlur);
window.addEventListener('keyup', onKeyUp);
target.addEventListener('keydown', onKeyDown);
return () => {
setAllKeysUp();
target.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
};
}

37
app/components/dom.jsx Normal file
View File

@ -0,0 +1,37 @@
import {useEffect, useRef, useState} from 'react';
import {RESOLUTION} from '@/constants.js';
import styles from './dom.module.css';
/**
* Dom autoscales with resolution.
*/
export default function Dom({children}) {
const ref = useRef();
const [scale, setScale] = useState(0);
useEffect(() => {
if (!ref.current) {
return;
}
function onResize() {
const {parentNode} = ref.current;
const {width} = parentNode.getBoundingClientRect();
setScale(width / RESOLUTION[0]);
}
window.addEventListener('resize', onResize);
onResize();
return () => {
window.removeEventListener('resize', onResize);
}
}, [ref.current]);
return (
<div className={styles.dom} ref={ref}>
{scale > 0 && (
<style>{`.${styles.dom}{--scale:${scale}}`}</style>
)}
{children}
</div>
);
}

View File

@ -0,0 +1,8 @@
.dom {
height: calc(100% / var(--scale));
position: absolute;
top: 0;
transform: scale(var(--scale));
transform-origin: top left;
width: calc(100% / var(--scale));
}

43
app/components/ecs.jsx Normal file
View File

@ -0,0 +1,43 @@
import {Sprite} from '@pixi/react';
import {useState} from 'react';
import {Ecs} from '@/engine/ecs/index.js';
import usePacket from '@/hooks/use-packet';
export default function EcsComponent() {
const [ecs] = useState(new Ecs());
const [entities, setEntities] = 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(parseInt(id)).toJSON();
}
}
setEntities(updatedEntities);
}, [entities]);
const sprites = [];
for (const id in entities) {
const entity = entities[id];
sprites.push(
<Sprite
image={entity.Sprite.image}
key={id}
x={entity.Position.x}
y={entity.Position.y}
/>
);
}
return (
<>
{sprites}
</>
)
}

27
app/components/hotbar.jsx Normal file
View File

@ -0,0 +1,27 @@
import styles from './hotbar.module.css';
import Slot from './slot.jsx';
/**
* The hotbar. 10 slots of inventory with an active selection.
*/
export default function Hotbar({active, onActivate, slots}) {
const Slots = slots.map((slot, i) => (
<div
className={
[styles.slotWrapper, active === i && styles.active]
.filter(Boolean).join(' ')
}
onClick={() => onActivate(i)}
key={i}
>
<Slot {...slot} />
</div>
));
return (
<div
className={styles.hotbar}
>
{Slots}
</div>
);
}

View File

@ -0,0 +1,25 @@
.hotbar {
border: 2px solid #999999;
box-sizing: border-box;
display: inline-block;
left: 135px;
line-height: 0;
position: absolute;
top: 20px;
}
.slotWrapper {
border: 2px solid #999999;
box-sizing: border-box;
display: inline-block;
line-height: 0;
&.active {
border-color: yellow;
}
&:hover {
background-color: rgba(0, 0, 0, 0.1);
cursor: pointer;
}
}

46
app/components/pixi.jsx Normal file
View File

@ -0,0 +1,46 @@
import {
Stage as PixiStage,
} from '@pixi/react';
import {RESOLUTION} from '@/constants.js';
import ClientContext from '@/context/client.js';
import Ecs from './ecs.jsx';
import styles from './pixi.module.css';
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[0]}
height={RESOLUTION[1]}
options={{
background: 0x1099bb,
}}
>
<Ecs />
</Stage>
);
}

View File

@ -0,0 +1,7 @@
.stage {
height: 100% !important;
image-rendering: pixelated;
line-height: 0;
position: relative;
width: 100% !important;
}

29
app/components/slot.jsx Normal file
View File

@ -0,0 +1,29 @@
import styles from './slot.module.css';
/**
* An inventory slot. Displays an item image and the quantity of the item if > 1.
*/
export default function Slot({image, onClick, qty = 1}) {
return (
<div
className={styles.slot}
onClick={onClick}
>
<div
className={styles.slotInner}
style={image ? {backgroundImage: `url(${image})`} : {}}
>
{qty > 1 && (
<span
className={
[styles.qty, `q-${Math.round(Math.log10(qty))}`]
.filter(Boolean).join(' ')
}
>
{qty}
</span>
)}
</div>
</div>
);
}

View File

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

55
app/components/ui.jsx Normal file
View File

@ -0,0 +1,55 @@
import {useContext, useEffect} from 'react';
import addKeyListener from '@/add-key-listener.js';
import {ACTION_MAP, RESOLUTION} from '@/constants.js';
import ClientContext from '@/context/client.js';
import Dom from './dom.jsx';
import Pixi from './pixi.jsx';
import styles from './ui.module.css';
const ratio = RESOLUTION[0] / RESOLUTION[1];
const KEY_MAP = {
keyDown: 1,
keyUp: 0,
};
export default function Ui() {
// Key input.
const client = useContext(ClientContext);
useEffect(() => {
return addKeyListener(document.body, ({type, payload}) => {
if (type in KEY_MAP && payload in ACTION_MAP) {
client.send({
type: 'Action',
payload: {
type: ACTION_MAP[payload],
value: KEY_MAP[type],
},
});
}
});
});
return (
<div className={styles.ui}>
<style>
{`
@media (max-aspect-ratio: ${ratio}) { .${styles.ui} { width: 100%; } }
@media (min-aspect-ratio: ${ratio}) { .${styles.ui} { height: 100%; } }
`}
</style>
<Pixi />
<Dom>
<div
style={{
backgroundColor: 'rgba(0, 0, 0, 0.1)',
height: '225px',
width: '400px',
}}
></div>
</Dom>
</div>
);
}

View File

@ -0,0 +1,4 @@
.ui {
align-self: center;
position: relative;
}

26
app/constants.js Normal file
View File

@ -0,0 +1,26 @@
export const CLIENT_LATENCY = 100;
export const CLIENT_PREDICTION = true;
export const RESOLUTION = [
800,
450,
];
export const SERVER_LATENCY = 100;
export const TPS = 60;
export const ACTION_MAP = {
w: 'moveUp',
d: 'moveRight',
s: 'moveDown',
a: 'moveLeft',
};
export const MOVE_MAP = {
'moveUp': 'up',
'moveRight': 'right',
'moveDown': 'down',
'moveLeft': 'left',
};

3
app/context/client.js Normal file
View File

@ -0,0 +1,3 @@
import {createContext} from 'react';
export default createContext();

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

@ -0,0 +1,109 @@
import Serializer from './serializer.js';
import Base from './base.js';
export default class Arbitrary extends Base {
data = [];
serializer;
Instance;
constructor() {
super();
this.serializer = new Serializer(this.constructor.schema);
}
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.schema.specification);
for (let i = 0; i < entries.length; ++i) {
const [entity, values = {}] = entries[i];
this.map[entity] = allocated[i];
this.data[allocated[i]].entity = entity;
if (false === values) {
continue;
}
for (let k = 0; k < keys.length; ++k) {
const j = keys[k];
const {defaultValue} = this.constructor.schema.specification[j];
if (j in values) {
this.data[allocated[i]][j] = values[j];
}
else if ('undefined' !== typeof defaultValue) {
this.data[allocated[i]][j] = defaultValue;
}
}
}
}
}
deserialize(entity, view, offset) {
this.serializer.decode(view, this.get(entity), offset);
}
serialize(entity, view, offset) {
this.serializer.encode(this.get(entity), view, offset);
}
get(entity) {
return this.data[this.map[entity]];
}
instanceFromSchema() {
const Component = this;
const Instance = class {
$$entity = 0;
constructor() {
this.$$reset();
}
$$reset() {
for (const [i, {defaultValue}] of Component.constructor.schema) {
this[`$$${i}`] = 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 [i] of Component.constructor.schema) {
properties[i] = {
get: function get() {
return this[`$$${i}`];
},
set: function set(v) {
if (this[`$$${i}`] !== v) {
this[`$$${i}`] = v;
Component.markChange(this.entity, i, v);
}
},
};
}
Object.defineProperties(Instance.prototype, properties);
return Instance;
}
}

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

@ -0,0 +1,46 @@
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({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({foo: {defaultValue: 'bar', type: 'string'}, bar: '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({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);
});

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

@ -0,0 +1,93 @@
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(entity, values) {
this.createMany([[entity, values]]);
}
destroy(entity) {
this.destroyMany([entity]);
}
destroyMany(entities) {
this.freeMany(
entities
.map((entity) => {
if ('undefined' !== typeof this.map[entity]) {
return this.map[entity];
}
throw new Error(`can't free for non-existent entity ${entity}`);
}),
);
for (let i = 0; i < entities.length; i++) {
this.map[entities[i]] = undefined;
}
}
static filterDefaults(instance) {
const json = {};
for (const [i, {defaultValue}] of this.schema) {
if (i in instance && instance[i] !== defaultValue) {
json[i] = instance[i];
}
}
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 [entity, values] = entities[i];
if (!this.get(entity)) {
creating.push([entity, values]);
}
else {
const instance = this.get(entity);
for (const i in values) {
instance[i] = values[i];
}
}
}
this.createMany(creating);
}
// eslint-disable-next-line no-unused-vars
markChange(entity, components) {}
mergeDiff(original, update) {
return {...original, ...update};
}
sizeOf(entity) {
return this.constructor.schema.sizeOf(this.get(entity));
}
static wrap(name, ecs) {
class WrappedComponent extends this {
markChange(entity, key, value) {
ecs.markChange(entity, {[name]: {[key]: value}})
}
}
return new WrappedComponent();
}
}

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

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

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

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

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

@ -0,0 +1,457 @@
import {expect, test} from 'vitest';
import Ecs from './ecs.js';
import System from './system.js';
const Empty = {};
const Name = {
name: 'string',
};
const Position = {
x: {type: 'int32', defaultValue: 32},
y: 'int32',
z: '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: 'int32',
y: 'int32',
z: '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: '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).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;
});

56
app/ecs/entity-factory.js Normal file
View File

@ -0,0 +1,56 @@
class Node {
children = {};
class;
}
export default class EntityFactory {
$$tries = new Node();
makeClass(types, Types) {
const sorted = types.toSorted();
let walk = this.$$tries;
let i = 0;
while (i < sorted.length) {
if (!walk.children[sorted[i]]) {
walk.children[sorted[i]] = new Node();
}
walk = walk.children[sorted[i]];
i += 1;
}
if (!walk.class) {
class Entity {
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 = Types[type].get.bind(Types[type]);
properties[type].get = function() {
return get(this.id);
};
}
Object.defineProperties(Entity.prototype, properties);
Entity.prototype.toJSON = new Function('', `
return {
${sorted.map((type) => `${type}: this.${type}.toJSON()`).join(', ')}
};
`);
walk.class = Entity;
}
return walk.class;
}
}

3
app/ecs/index.js Normal file
View File

@ -0,0 +1,3 @@
/* v8 ignore start */
export {default as Ecs} from './ecs.js';
export {default as System} from './system.js';

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

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

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

@ -0,0 +1,72 @@
import {expect, test} from 'vitest';
import Component from './component.js';
import Query from './query.js';
const A = new (Component({a: {type: 'int32', defaultValue: 420}}));
const B = new (Component({b: {type: 'int32', defaultValue: 69}}));
const C = new (Component({c: '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);
});

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

@ -0,0 +1,164 @@
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);
// Try to calculate static size.
for (const i in this.specification) {
const {type} = this.specification[i];
const size = this.constructor.sizeOfType(type);
if (0 === size) {
this.$$size = 0;
break;
}
this.$$size += size;
}
}
[Symbol.iterator]() {
return Object.entries(this.specification).values();
}
static defaultValueForType(type) {
switch (type) {
case 'uint8': case 'int8':
case 'uint16': case 'int16':
case 'uint32': case 'int32':
case 'uint64': case 'int64':
case 'float32': case 'float64': {
return 0;
}
case 'string': {
return '';
}
}
}
has(key) {
return key in this.specification;
}
static normalize(specification) {
const normalized = Object.create(null);
for (const i in specification) {
normalized[i] = 'string' === typeof specification[i]
? {type: specification[i]}
: specification[i];
if (!this.validateType(normalized[i].type)) {
throw new TypeError(`unknown schema type: ${normalized[i].type}`);
}
normalized[i].defaultValue = normalized[i].defaultValue || this.defaultValueForType(normalized[i].type);
}
return normalized;
}
readSize(view, cursor) {
let fullSize = 0;
for (const i in this.specification) {
const {type} = this.specification[i];
const size = this.constructor.sizeOfType(type);
if (0 === size) {
switch (type) {
case 'string': {
const length = view.getUint32(cursor, true);
cursor += 4 + length;
fullSize += 4;
fullSize += length;
break;
}
}
}
else {
cursor += size;
fullSize += size;
}
}
return fullSize;
}
get size() {
return this.$$size;
}
sizeOf(concrete) {
let fullSize = 0;
for (const i in this.specification) {
const {type} = this.specification[i];
const size = this.constructor.sizeOfType(type);
if (0 === size) {
switch (type) {
case 'string':
fullSize += 4;
fullSize += (encoder.encode(concrete[i])).length;
break;
}
}
else {
fullSize += size;
}
}
return fullSize;
}
static sizeOfType(type) {
switch (type) {
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;
}
}
static validateType(type) {
return [
'uint8', 'int8',
'uint16', 'int16',
'uint32', 'int32',
'uint64', 'int64',
'float32', 'float64',
'string',
]
.includes(type);
}
}

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

@ -0,0 +1,22 @@
import {expect, test} from 'vitest';
import Schema from './schema.js';
test('validates a schema', () => {
expect(() => {
new Schema({test: 'unknown'})
})
.to.throw();
expect(() => {
new Schema({test: 'unknown'})
})
.to.throw();
});
test('calculates the size of an instance', () => {
expect((new Schema({foo: 'uint8', bar: 'uint32'})).sizeOf({foo: 69, bar: 420}))
.to.equal(5);
expect((new Schema({foo: 'string'})).sizeOf({foo: 'hi'}))
.to.equal(4 + (new TextEncoder().encode('hi')).length);
});

61
app/ecs/serializer.js Normal file
View File

@ -0,0 +1,61 @@
import Schema from './schema.js';
export default class Serializer {
constructor(schema) {
this.schema = schema instanceof Schema ? schema : new Schema(schema);
}
decode(view, destination, offset = 0) {
let cursor = offset;
for (const [key, {type}] of this.schema) {
const viewGetMethod = Schema.viewGetMethods[type];
let value;
if (viewGetMethod) {
value = view[viewGetMethod](cursor, true);
cursor += Schema.sizeOfType(type);
}
else {
switch (type) {
case 'string': {
const length = view.getUint32(cursor, true);
cursor += 4;
const {buffer, byteOffset} = view;
const decoder = new TextDecoder();
value = decoder.decode(new DataView(buffer, byteOffset + cursor, length));
cursor += length;
break;
}
}
}
destination[key] = value;
}
}
encode(source, view, offset = 0) {
let cursor = offset;
for (const [key, {type}] of this.schema) {
const viewSetMethod = Schema.viewSetMethods[type];
if (viewSetMethod) {
view[viewSetMethod](cursor, source[key], true);
cursor += Schema.sizeOfType(type);
}
else {
switch (type) {
case 'string': {
const lengthOffset = cursor;
cursor += 4;
const encoder = new TextEncoder();
const bytes = encoder.encode(source[key]);
for (let i = 0; i < bytes.length; ++i) {
view.setUint8(cursor++, bytes[i]);
}
view.setUint32(lengthOffset, bytes.length, true);
break;
}
}
}
}
}
}

View File

@ -0,0 +1,33 @@
import {expect, test} from 'vitest';
import Serializer from './serializer.js';
test('can encode and decode', () => {
const entries = [
['uint8', 255],
['int8', -128],
['int8', 127],
['uint16', 65535],
['int16', -32768],
['int16', 32767],
['uint32', 4294967295],
['int32', -2147483648],
['int32', 2147483647],
['uint64', 18446744073709551615n],
['int64', -9223372036854775808n],
['int64', 9223372036854775807n],
['float32', 0.5],
['float64', 1.234],
['string', 'hello world'],
['string', 'α'],
];
const schema = entries.reduce((r, [type]) => ({...r, [Object.keys(r).length]: type}), {});
const data = entries.reduce((r, [, value]) => ({...r, [Object.keys(r).length]: value}), {});
const serializer = new Serializer(schema);
const view = new DataView(new ArrayBuffer(serializer.schema.sizeOf(data)));
serializer.encode(data, view);
const result = {};
serializer.decode(view, result);
expect(data)
.to.deep.equal(result);
});

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

@ -0,0 +1,112 @@
import Query from './query.js';
export default class System {
destroying = [];
ecs;
queries = {};
constructor(ecs) {
this.ecs = ecs;
const queries = this.constructor.queries();
for (const i in queries) {
this.queries[i] = new Query(queries[i], ecs.Types);
}
}
deindex(entities) {
for (const i in this.queries) {
this.queries[i].deindex(entities);
}
}
destroyEntity(entity) {
this.destroyManyEntities([entity]);
}
destroyManyEntities(entities) {
for (let i = 0; i < entities.length; i++) {
this.destroying.push(entities[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(entities) {
for (const i in this.queries) {
this.queries[i].reindex(entities);
}
}
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(entity, components) {
this.ecs.insert(entity, components);
}
insertManyComponents(components) {
this.ecs.insertMany(components);
}
removeComponents(entity, components) {
this.ecs.remove(entity, components);
}
removeManyComponents(entities) {
this.ecs.removeMany(entities);
}
};
return new WrappedSystem();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
export default {
image: 'string',
};

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,98 @@
import {RESOLUTION} from '@/constants.js'
import {System} from '@/ecs/index.js';
class SpatialHash {
constructor(area) {
this.area = area;
this.chunkSize = [RESOLUTION[0] / 2, RESOLUTION[1] / 2];
this.chunks = [];
const chunkCount = [
Math.ceil(this.area[0] / this.chunkSize[0]),
Math.ceil(this.area[1] / this.chunkSize[1]),
];
this.chunks = Array(chunkCount[0])
.fill(0)
.map(() => (
Array(chunkCount[1])
.fill(0)
.map(() => [])
));
this.data = {};
}
clamp(x, y) {
return [
Math.max(0, Math.min(x, this.area[0] - 1)),
Math.max(0, Math.min(y, this.area[1] - 1))
];
}
chunkIndex(x, y) {
const [cx, cy] = this.clamp(x, y);
return [
Math.floor(cx / this.chunkSize[0]),
Math.floor(cy / this.chunkSize[1]),
];
}
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.x, master.AreaSize.y]);
}
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(parseInt(id)));
}
}
}
}

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

@ -0,0 +1,167 @@
import {
MOVE_MAP,
RESOLUTION,
TPS,
} from '@/constants.js';
import {Ecs} from './ecs/index.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 UpdateSpatialHash from './ecs/systems/update-spatial-hash.js';
import {decode, encode} from '@/engine/net/packets/index.js';
const players = {
0: {
Camera: {x: RESOLUTION[0] / 2, y : RESOLUTION[1] / 2},
Controlled: {up: 0, right: 0, down: 0, left: 0},
Momentum: {},
Position: {x: 50, y: 50},
VisibleAabb: {},
World: {world: 0},
Sprite: {image: '/assets/bunny.png'},
},
};
export default class Engine {
static Ecs = Ecs;
constructor(Server) {
const ecs = new this.constructor.Ecs();
ecs.create({
AreaSize: {x: RESOLUTION[0], y: RESOLUTION[1]},
});
ecs.addSystem(ControlMovement);
ecs.addSystem(ApplyMomentum);
ecs.addSystem(CalculateAabbs);
ecs.addSystem(UpdateSpatialHash);
this.ecses = {
0: ecs,
};
this.connections = [];
this.connectedPlayers = new Map();
this.frame = 0;
this.last = Date.now();
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((connection, packet) => {
this.accept(connection, packet);
});
}
accept(connection, {payload, type}) {
switch (type) {
case 'Action': {
const {entity} = this.connectedPlayers.get(connection);
if (payload.type in MOVE_MAP) {
entity.Controlled[MOVE_MAP[payload.type]] = payload.value;
}
break;
}
default:
}
}
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();
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 ecs = this.ecses[entity.World.world];
const {hash} = ecs.system(UpdateSpatialHash);
const nearby = new Set();
for (const [cx, cy] of hash.data[entity.id]) {
hash.chunks[cx][cy].forEach((id) => {
nearby.add(ecs.get(id));
});
}
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();
}
else if (ecs.diff[id]) {
update[id] = ecs.diff[id];
}
memory.add(id);
}
for (const id of lastMemory) {
memory.delete(id);
update[id] = false;
}
return update;
}
}

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

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

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

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

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

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

View File

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

View File

@ -0,0 +1,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;
}

View File

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

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

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

View File

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

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

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

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

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

19
app/net/client/local.js Normal file
View File

@ -0,0 +1,19 @@
import Client from './client.js';
export default class LocalClient extends Client {
async connect() {
this.worker = new Worker(
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);
}
}

View File

@ -0,0 +1,17 @@
let connected = false;
let socket;
onmessage = async (event) => {
if (!connected) {
socket = new WebSocket(`wss://${event.data.host}/ws`);
socket.binaryType = 'arraybuffer';
await new Promise((resolve) => {
socket.onopen = resolve;
});
socket.onmessage = (event) => {
postMessage(event.data);
};
connected = true;
return;
}
socket.send(event.data);
};

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

@ -0,0 +1,53 @@
import {CLIENT_PREDICTION} from '@/constants.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 {
this.socket = new WebSocket(`wss://${host}/ws`);
this.socket.binaryType = 'arraybuffer';
this.socket.onmessage = (event) => {
this.accept(event.data);
};
await new Promise((resolve) => {
this.socket.onopen = resolve;
});
}
}
disconnect() {
if (CLIENT_PREDICTION) {
this.worker.terminate();
}
else {
this.socket.close();
}
}
transmit(packed) {
if (CLIENT_PREDICTION) {
this.worker.postMessage(packed);
}
else {
this.socket.send(packed);
}
}
}

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

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

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

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

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

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

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

@ -0,0 +1,18 @@
import Engine from '@/engine/engine.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);
};
await engine.load();
engine.start();
await engine.connectPlayer(undefined);

8
app/root.css Normal file
View File

@ -0,0 +1,8 @@
html, body {
background-color: #333333;
box-sizing: border-box;
height: 100%;
line-height: 0;
margin: 0;
width: 100%;
}

View File

@ -6,6 +6,8 @@ import {
ScrollRestoration, ScrollRestoration,
} from "@remix-run/react"; } from "@remix-run/react";
import './root.css';
export function Layout({ children }) { export function Layout({ children }) {
return ( return (
<html lang="en"> <html lang="en">

View File

@ -1,39 +0,0 @@
export const meta = () => {
return [
{ title: "New Remix App" },
{ name: "description", content: "Welcome to Remix!" },
];
};
export default function Index() {
return (
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
<h1>Welcome to Remix</h1>
<ul>
<li>
<a
target="_blank"
href="https://remix.run/start/quickstart"
rel="noreferrer"
>
5m Quick Start
</a>
</li>
<li>
<a
target="_blank"
href="https://remix.run/start/tutorial"
rel="noreferrer"
>
30m Tutorial
</a>
</li>
<li>
<a target="_blank" href="https://remix.run/docs" rel="noreferrer">
Remix Docs
</a>
</li>
</ul>
</div>
);
}

View File

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

View File

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

View File

@ -0,0 +1,7 @@
import Ui from '@/components/ui.jsx';
export default function Index() {
return (
<Ui />
);
}

View File

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

View File

@ -0,0 +1,52 @@
import {useEffect, useState} from 'react';
import {Outlet, useParams} from 'react-router-dom';
import ClientContext from '@/context/client.js';
import {decode, encode} from '@/engine/net/packets/index.js';
import LocalClient from '@/net/client/local.js';
import RemoteClient from '@/net/client/remote.js';
import styles from './play.module.css';
export default function Index() {
const [client, setClient] = useState();
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]);
return (
<div className={styles.play}>
{client && (
<ClientContext.Provider value={client}>
<Outlet />
</ClientContext.Provider>
)}
</div>
);
}

View File

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

View File

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

8427
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,9 +7,13 @@
"build": "remix vite:build", "build": "remix vite:build",
"dev": "node ./server.js", "dev": "node ./server.js",
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"start": "cross-env NODE_ENV=production node ./server.js" "start": "cross-env NODE_ENV=production node ./server.js",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build"
}, },
"dependencies": { "dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",
"@pixi/react": "^7.1.2",
"@remix-run/express": "^2.9.2", "@remix-run/express": "^2.9.2",
"@remix-run/node": "^2.9.2", "@remix-run/node": "^2.9.2",
"@remix-run/react": "^2.9.2", "@remix-run/react": "^2.9.2",
@ -18,17 +22,31 @@
"isbot": "^4.1.0", "isbot": "^4.1.0",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-dom": "^18.2.0",
"ws": "^8.17.0"
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "^1.5.0",
"@remix-run/dev": "^2.9.2", "@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", "cross-env": "^7.0.3",
"eslint": "^8.38.0", "eslint": "^8.38.0",
"eslint-plugin-import": "^2.28.1", "eslint-plugin-import": "^2.28.1",
"eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2", "eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"vite": "^5.1.0" "storybook": "^8.1.6",
"vite": "^5.1.0",
"vitest": "^1.6.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"

BIN
public/assets/bunny.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 B

BIN
public/assets/potion.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -47,6 +47,18 @@ const remixHandler = createRequestHandler({
), ),
}); });
// WebSocket
let listen;
if (isProduction) {
({listen} = await import('./websocket.js'));
}
else {
const {createViteRuntime} = await import('vite');
const runtime = await createViteRuntime(viteDevServer);
({listen} = await runtime.executeEntrypoint('/websocket.js'));
}
await listen(server);
app.use(compression()); app.use(compression());
// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header // http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header

52
stories/dom-decorator.jsx Normal file
View File

@ -0,0 +1,52 @@
import {useEffect, useRef, useState} from 'react';
import Dom from '@/components/dom.jsx';
import {RESOLUTION} from '@/constants.js';
function Decorator({children, style}) {
const ref = useRef();
const [scale, setScale] = useState(0);
useEffect(() => {
if (!ref.current) {
return;
}
function onResize() {
const {parentNode} = ref.current;
const {width} = parentNode.getBoundingClientRect();
setScale(width / RESOLUTION[0]);
}
window.addEventListener('resize', onResize);
onResize();
return () => {
window.removeEventListener('resize', onResize);
}
}, [ref.current]);
return (
<div
ref={ref}
style={{
backgroundColor: '#1099bb',
opacity: 0 === scale ? 0 : 1,
position: 'relative',
height: `calc(${RESOLUTION[1]}px * ${scale})`,
width: '100%',
}}
>
<Dom>
<div style={style}>
{children}
</div>
</Dom>
</div>
);
}
export default function(options = {}) {
return function decorate(Decorating) {
return (
<Decorator style={options.style}>
<Decorating />
</Decorator>
);
};
}

47
stories/hotbar.stories.js Normal file
View File

@ -0,0 +1,47 @@
import {useArgs} from '@storybook/preview-api';
import {fn} from '@storybook/test';
import Hotbar from '@/components/hotbar.jsx';
import DomDecorator from './dom-decorator.jsx';
import potion from '/assets/potion.png?url';
const slots = Array(10).fill({});
slots[2] = {image: potion, qty: 24};
export default {
title: 'Dom/Inventory/Hotbar',
component: Hotbar,
decorators: [
(Hotbar, ctx) => {
const [, updateArgs] = useArgs();
const {onActivate} = ctx.args;
ctx.args.onActivate = (i) => {
updateArgs({active: i});
if (onActivate) {
onActivate(i);
}
};
return Hotbar();
},
DomDecorator(),
],
tags: ['autodocs'],
args: {
active: 0,
onActivate: fn(),
slots,
},
argTypes: {
active: {
control: {
type: 'number',
min: 0,
max: 9,
}
},
},
};
export const Default = {};

68
stories/slot.stories.js Normal file
View File

@ -0,0 +1,68 @@
import Slot from '@/components/slot.jsx';
import DomDecorator from './dom-decorator.jsx';
import potion from '/assets/potion.png?url';
export default {
title: 'Dom/Inventory/Slot',
component: Slot,
decorators: [
DomDecorator({
style: {
border: '2px solid #999',
lineHeight: 0,
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
},
}),
],
tags: ['autodocs'],
argTypes: {
image: {
control: 'text',
},
qty: {
control: {
type: 'number',
min: 1,
max: 9999,
}
},
},
args: {
image: potion,
},
};
export const Single = {
args: {
qty: 1,
},
};
export const OneDigit = {
args: {
qty: 9,
},
};
export const TwoDigit = {
args: {
qty: 99,
},
};
export const ThreeDigit = {
args: {
qty: 999,
},
};
export const FourDigit = {
args: {
qty: 9999,
},
};

View File

@ -1,12 +1,16 @@
import {readFileSync} from 'node:fs'; import {readFileSync} from 'node:fs';
import {fileURLToPath} from 'node:url'; import {fileURLToPath} from 'node:url';
import {vitePlugin as remix} from '@remix-run/dev'; import {vitePlugin as remix} from '@remix-run/dev';
import react from '@vitejs/plugin-react';
import {defineConfig} from 'vite'; import {defineConfig} from 'vite';
const cacheDirectory = `${import.meta.dirname}/node_modules/.cache`; const cacheDirectory = `${import.meta.dirname}/node_modules/.cache`;
export default defineConfig({ const plugins = [];
plugins: [
if (!process.env.STORYBOOK) {
plugins.push(
remix({ remix({
future: { future: {
v3_fetcherPersist: true, v3_fetcherPersist: true,
@ -14,7 +18,14 @@ export default defineConfig({
v3_throwAbortReason: true, v3_throwAbortReason: true,
}, },
}), }),
], );
}
else {
plugins.push(react());
}
export default defineConfig({
plugins,
resolve: { resolve: {
alias: [ alias: [
{ {

45
websocket.js Normal file
View File

@ -0,0 +1,45 @@
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 async function listen(server) {
server.on('upgrade', onUpgrade);
class SocketServer extends Server {
transmit(ws, packed) { ws.send(packed); }
}
const engine = new Engine(SocketServer);
await engine.load();
engine.start();
async function onConnect(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);
}
wss.on('connection', onConnect);
}