flow: up to date
This commit is contained in:
parent
2a544c35f2
commit
a5e02abe16
|
@ -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
17
.storybook/main.js
Normal 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
13
.storybook/preview.js
Normal 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
64
.vscode/launch.json
vendored
Normal 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,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
13
README.md
13
README.md
|
@ -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
48
app/add-key-listener.js
Normal 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
37
app/components/dom.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
8
app/components/dom.module.css
Normal file
8
app/components/dom.module.css
Normal 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
43
app/components/ecs.jsx
Normal 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
27
app/components/hotbar.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
25
app/components/hotbar.module.css
Normal file
25
app/components/hotbar.module.css
Normal 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
46
app/components/pixi.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
7
app/components/pixi.module.css
Normal file
7
app/components/pixi.module.css
Normal 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
29
app/components/slot.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
45
app/components/slot.module.css
Normal file
45
app/components/slot.module.css
Normal 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
55
app/components/ui.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
4
app/components/ui.module.css
Normal file
4
app/components/ui.module.css
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
.ui {
|
||||||
|
align-self: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
26
app/constants.js
Normal file
26
app/constants.js
Normal 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
3
app/context/client.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import {createContext} from 'react';
|
||||||
|
|
||||||
|
export default createContext();
|
109
app/ecs/arbitrary.js
Normal file
109
app/ecs/arbitrary.js
Normal 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
46
app/ecs/arbitrary.test.js
Normal 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
93
app/ecs/base.js
Normal 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
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(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 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
457
app/ecs/ecs.test.js
Normal 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
56
app/ecs/entity-factory.js
Normal 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
3
app/ecs/index.js
Normal 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
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(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
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: '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
164
app/ecs/schema.js
Normal 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
22
app/ecs/schema.test.js
Normal 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
61
app/ecs/serializer.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
33
app/ecs/serializer.test.js
Normal file
33
app/ecs/serializer.test.js
Normal 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
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(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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
4
app/engine/ecs/components/area-size.js
Normal file
4
app/engine/ecs/components/area-size.js
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export default {
|
||||||
|
x: 'uint16',
|
||||||
|
y: 'uint16',
|
||||||
|
}
|
4
app/engine/ecs/components/camera.js
Normal file
4
app/engine/ecs/components/camera.js
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export default {
|
||||||
|
x: 'uint16',
|
||||||
|
y: 'uint16',
|
||||||
|
}
|
6
app/engine/ecs/components/controlled.js
Normal file
6
app/engine/ecs/components/controlled.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
up: 'float32',
|
||||||
|
right: 'float32',
|
||||||
|
down: 'float32',
|
||||||
|
left: 'float32',
|
||||||
|
};
|
3
app/engine/ecs/components/index.js
Normal file
3
app/engine/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'}));
|
4
app/engine/ecs/components/momentum.js
Normal file
4
app/engine/ecs/components/momentum.js
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export default {
|
||||||
|
x: 'float32',
|
||||||
|
y: 'float32',
|
||||||
|
}
|
4
app/engine/ecs/components/position.js
Normal file
4
app/engine/ecs/components/position.js
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export default {
|
||||||
|
x: 'float32',
|
||||||
|
y: 'float32',
|
||||||
|
};
|
1
app/engine/ecs/components/rendered.js
Normal file
1
app/engine/ecs/components/rendered.js
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export default {};
|
3
app/engine/ecs/components/sprite.js
Normal file
3
app/engine/ecs/components/sprite.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export default {
|
||||||
|
image: 'string',
|
||||||
|
};
|
6
app/engine/ecs/components/visible-aabb.js
Normal file
6
app/engine/ecs/components/visible-aabb.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
x0: 'float32',
|
||||||
|
x1: 'float32',
|
||||||
|
y0: 'float32',
|
||||||
|
y1: 'float32',
|
||||||
|
}
|
1
app/engine/ecs/components/wandering.js
Normal file
1
app/engine/ecs/components/wandering.js
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export default {};
|
3
app/engine/ecs/components/world.js
Normal file
3
app/engine/ecs/components/world.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export default {
|
||||||
|
world: {defaultValue: -1, type: 'uint16'},
|
||||||
|
}
|
8
app/engine/ecs/index.js
Normal file
8
app/engine/ecs/index.js
Normal 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};
|
18
app/engine/ecs/systems/apply-momentum.js
Normal file
18
app/engine/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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
27
app/engine/ecs/systems/calculate-aabbs.js
Normal file
27
app/engine/ecs/systems/calculate-aabbs.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
21
app/engine/ecs/systems/control-movement.js
Normal file
21
app/engine/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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
98
app/engine/ecs/systems/update-spatial-hash.js
Normal file
98
app/engine/ecs/systems/update-spatial-hash.js
Normal 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
167
app/engine/engine.js
Normal 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
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[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
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);
|
||||||
|
});
|
31
app/engine/net/packets/action.js
Normal file
31
app/engine/net/packets/action.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
20
app/engine/net/packets/index.js
Normal file
20
app/engine/net/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/engine/net/packets/tick.js
Normal file
3
app/engine/net/packets/tick.js
Normal 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
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/hooks/use-packet.js
Normal file
18
app/hooks/use-packet.js
Normal 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
31
app/net/client/client.js
Normal 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
19
app/net/client/local.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
17
app/net/client/prediction.js
Normal file
17
app/net/client/prediction.js
Normal 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
53
app/net/client/remote.js
Normal 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
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'});
|
||||||
|
});
|
31
app/net/server/server.js
Normal file
31
app/net/server/server.js
Normal 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
18
app/net/server/worker.js
Normal 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
8
app/root.css
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
html, body {
|
||||||
|
background-color: #333333;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 100%;
|
||||||
|
line-height: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
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.$/route.jsx
Normal file
7
app/routes/_main-menu.play.$/route.jsx
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import Ui from '@/components/ui.jsx';
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
return (
|
||||||
|
<Ui />
|
||||||
|
);
|
||||||
|
}
|
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: 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
52
app/routes/_main-menu.play/route.jsx
Normal file
52
app/routes/_main-menu.play/route.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
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>
|
||||||
|
);
|
||||||
|
}
|
8427
package-lock.json
generated
8427
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
|
@ -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
BIN
public/assets/bunny.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 449 B |
BIN
public/assets/potion.png
Normal file
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 |
12
server.js
12
server.js
|
@ -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
52
stories/dom-decorator.jsx
Normal 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
47
stories/hotbar.stories.js
Normal 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
68
stories/slot.stories.js
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
|
@ -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
45
websocket.js
Normal 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);
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user