Compare commits

...

9 Commits

Author SHA1 Message Date
cha0s
6f7ec2e7ac fix: lint 2024-06-23 07:35:56 -05:00
cha0s
8eec34fd47 refactor: structure 2024-06-23 07:00:10 -05:00
cha0s
92b167237d fix: production 2024-06-23 03:04:48 -05:00
cha0s
db1baa5db4 polish: smooth camera 2024-06-23 02:45:05 -05:00
cha0s
a781b66cd1 fix: lint 2024-06-23 02:44:53 -05:00
cha0s
663e33a300 refactor: ECS context 2024-06-22 23:32:57 -05:00
cha0s
0536e7b4f2 refactor: gather 2024-06-22 22:33:54 -05:00
cha0s
1492884a87 refactor: efficient slot change diff 2024-06-22 22:04:24 -05:00
cha0s
62a613ce80 refactor: Q+D proxy 2024-06-22 20:33:44 -05:00
29 changed files with 193 additions and 103 deletions

View File

@ -26,6 +26,9 @@ module.exports = {
// Base config
extends: ['eslint:recommended'],
rules: {
'no-constant-condition': ['error', {checkLoops: false}],
},
overrides: [
// Tests
@ -81,7 +84,7 @@ module.exports = {
'assets/**/*.js',
],
rules: {
'no-undef': false,
'no-undef': 0,
},
}
],

9
app/context/ecs.js Normal file
View File

@ -0,0 +1,9 @@
import {createContext, useContext} from 'react';
const context = createContext();
export default context;
export function useEcs() {
return useContext(context);
}

View File

@ -7,4 +7,3 @@ export default context;
export function useMainEntity() {
return useContext(context);
}

View File

@ -1,7 +1,6 @@
import Arbitrary from '@/ecs/arbitrary.js';
import Base from '@/ecs/base.js';
import Schema from '@/ecs/schema.js';
import gather from '@/engine/gather.js';
import gather from '@/util/gather.js';
const specificationsAndOrDecorators = gather(
import.meta.glob('./*.js', {eager: true, import: 'default'}),
@ -17,7 +16,7 @@ for (const componentName in specificationsAndOrDecorators) {
if ('function' === typeof specificationOrDecorator) {
Components[componentName] = specificationOrDecorator(
class Decorated extends Arbitrary {
static name = componentName;
static componentName = componentName;
}
);
if (!Components[componentName]) {
@ -26,7 +25,7 @@ for (const componentName in specificationsAndOrDecorators) {
}
else {
Components[componentName] = class Component extends Arbitrary {
static name = componentName;
static componentName = componentName;
static schema = new Schema({
type: 'object',
properties: specificationOrDecorator,

View File

@ -2,13 +2,60 @@ import Schema from '@/ecs/schema.js';
export default function(Component) {
return class Inventory extends Component {
insertMany(entities) {
for (const [id, {slotChanged}] of entities) {
if (slotChanged) {
for (const slotIndex in slotChanged) {
this.get(id).slots[slotIndex] = {
...this.get(id).slots[slotIndex],
...slotChanged[slotIndex],
};
}
}
}
return super.insertMany(entities);
}
markChange(entityId, key, value) {
if ('slotChange' === key) {
const {index, change} = value;
return super.markChange(entityId, key, {[index]: change});
}
return super.markChange(entityId, key, value);
}
mergeDiff(original, update) {
if (update.slotChange) {
if (!original.slotChange) {
original.slotChange = {};
}
const merged = {};
for (const index in update.slotChange) {
merged[index] = {
...original.slotChange[index],
...update.slotChange[index],
};
}
return {slotChanged: merged};
}
return super.mergeDiff(original, update);
}
instanceFromSchema() {
const Instance = super.instanceFromSchema();
const Component = this;
Instance.prototype.item = function (slot) {
const {slots} = this;
for (const {slotIndex, source} of this.slots) {
if (slot === slotIndex) {
return source;
const instance = this;
for (const index in slots) {
const item = slots[index];
if (slot === item.slotIndex) {
const proxy = new Proxy(item, {
set(target, property, value) {
target[property] = value;
const change = {[property]: value};
Component.markChange(instance.entity, 'slotChange', {index, change});
return true;
},
});
return proxy;
}
}
};

View File

@ -1,28 +1,54 @@
import {RESOLUTION} from '@/constants.js'
import {System} from '@/ecs/index.js';
const [hx, hy] = [RESOLUTION.x / 2, RESOLUTION.y / 2];
export default class FollowCamera extends System {
static queries() {
return {
default: ['Camera', 'Position'],
};
}
reindex(entities) {
super.reindex(entities);
for (const id of entities) {
this.updateCamera(this.ecs.get(id));
this.updateCamera(1, this.ecs.get(id));
}
}
tick() {
for (const entity of this.ecs.changed(['Position'])) {
this.updateCamera(entity);
tick(elapsed) {
for (const [, , entityId] of this.select('default')) {
this.updateCamera(elapsed * 3, this.ecs.get(entityId));
}
}
updateCamera(entity) {
updateCamera(portion, entity) {
const {Camera, Position} = entity;
if (Camera && Position) {
const {AreaSize: {x, y}} = this.ecs.get(1);
const [hx, hy] = [RESOLUTION.x / 2, RESOLUTION.y / 2];
Camera.x = Math.max(hx, Math.min(Position.x, x - hx));
Camera.y = Math.max(hy, Math.min(Position.y, y - hy));
const [px, py] = [
Math.max(hx, Math.min(Math.round(Position.x), x - hx)),
Math.max(hy, Math.min(Math.round(Position.y), y - hy)),
];
if (Camera.x === px && Camera.y === py) {
return;
}
if (Math.abs(Camera.x - px) < 0.1) {
Camera.x = px;
}
if (Math.abs(Camera.y - py) < 0.1) {
Camera.y = py;
}
const [dx, dy] = [
(px - Camera.x) * portion,
(py - Camera.y) * portion,
];
[Camera.x, Camera.y] = [
Camera.x + (Math.sign(dx) * Math.max(0.1, Math.abs(dx))),
Camera.y + (Math.sign(dy) * Math.max(0.1, Math.abs(dy))),
];
}
}

View File

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

View File

@ -83,7 +83,7 @@ export default class Base {
}
markChange(entityId, key, value) {
this.ecs.markChange(entityId, {[this.constructor.name]: {[key]: value}})
this.ecs.markChange(entityId, {[this.constructor.componentName]: {[key]: value}})
}
mergeDiff(original, update) {

View File

@ -75,7 +75,6 @@ export default class Ecs {
},
next: () => {
let result = it.next();
let satisfied = false;
while (!result.done) {
for (const componentName of criteria) {
if (!(componentName in result.value[1])) {

View File

@ -114,7 +114,7 @@ export default class Engine {
Inventory: {
slots: [
{
qty: 10,
qty: 500,
slotIndex: 0,
source: '/assets/potion',
}
@ -221,10 +221,11 @@ export default class Engine {
entity,
payload,
] of this.incomingActions) {
const {Ecs, Controlled, id, Inventory, Position, Ticking, Wielder} = entity;
const {Controlled, Inventory, Ticking, Wielder} = entity;
switch (payload.type) {
case 'changeSlot': {
Wielder.activeSlot = payload.value - 1;
break;
}
case 'moveUp':
case 'moveRight':
@ -236,11 +237,11 @@ export default class Engine {
case 'use': {
if (payload.value) {
const item = Inventory.item(Wielder.activeSlot);
this.server.readAsset(item + '/start.js')
this.server.readAsset(item.source + '/start.js')
.then((response) => response.text())
.then((code) => {
Ticking.addTickingPromise(
Script.tickingPromise(code, {console, wielder: entity}),
Script.tickingPromise(code, {item, wielder: entity}),
);
});
}

View File

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

View File

@ -12,5 +12,6 @@ export default function usePacket(type, fn, dependencies) {
return () => {
client.removePacketListener(type, fn);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client, ...dependencies]);
}

View File

@ -5,9 +5,6 @@ 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));
}
@ -18,11 +15,6 @@ export default class Packet {
return new DataView(encoder.bytes.buffer, 0, encoder.pos);
}
static gathered(id, type) {
this.id = id;
this.type = type;
}
static pack(payload) {
return payload;
}

View File

@ -2,7 +2,7 @@ import {get, set} from 'idb-keyval';
import {encode} from '@/packets/index.js';
import Engine from '../../engine/engine.js';
import Engine from '../../engine.js';
import Server from './server.js';
class WorkerServer extends Server {
@ -51,8 +51,10 @@ onmessage = async (event) => {
postMessage(encode({type: 'ConnectionStatus', payload: 'connected'}));
})();
import.meta.hot.accept('../../engine/engine.js', async () => {
await engine.disconnectPlayer(0, 0);
postMessage(encode({type: 'ConnectionStatus', payload: 'aborted'}));
close();
});
if (import.meta.hot) {
import.meta.hot.accept('../engine/engine.js', async () => {
await engine.disconnectPlayer(0, 0);
postMessage(encode({type: 'ConnectionStatus', payload: 'aborted'}));
close();
});
}

View File

@ -1,20 +1,22 @@
import gather from '@/engine/gather.js';
import gather from '@/util/gather.js';
const Gathered = gather(import.meta.glob('./*.js', {eager: true, import: 'default'}));
const typeToId = new Map(Object.entries(Gathered).map(([type], id) => [type, id]));
const idToType = new Map(Object.entries(Gathered).map(([type], id) => [id, type]));
export function decode(packed) {
const view = ArrayBuffer.isView(packed) ? packed : new DataView(packed);
const id = view.getUint16(0, true);
const Packet = Gathered[id];
const type = idToType.get(view.getUint16(0, true));
const Packet = Gathered[type];
return {
type: Packet.type,
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);
const encoded = Gathered[packet.type].encode(packet.payload);
encoded.setUint16(0, typeToId.get(packet.type), true);
return encoded;
}

View File

@ -24,7 +24,7 @@ export default function Dom({children}) {
return () => {
window.removeEventListener('resize', onResize);
}
}, [ref.current]);
});
return (
<div className={styles.dom} ref={ref}>
{scale > 0 && (

View File

@ -2,10 +2,8 @@ import {Container} from '@pixi/react';
import {useState} from 'react';
import {RESOLUTION} from '@/constants.js';
import {useEcs} from '@/context/ecs.js';
import {useMainEntity} from '@/context/main-entity.js';
import Ecs from '@/ecs/ecs.js';
import Components from '@/ecs-components/index.js';
import Systems from '@/ecs-systems/index.js';
import usePacket from '@/hooks/use-packet.js';
import Entities from './entities.jsx';
@ -13,14 +11,13 @@ import TargetingGhost from './targeting-ghost.jsx';
import TileLayer from './tile-layer.jsx';
export default function EcsComponent() {
const [ecs] = useState(new Ecs({Components, Systems}));
const [ecs] = useEcs();
const [entities, setEntities] = useState({});
const [mainEntity] = useMainEntity();
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]) {

View File

@ -7,6 +7,7 @@ import {createElement, useContext} from 'react';
import {RESOLUTION} from '@/constants.js';
import ClientContext from '@/context/client.js';
import EcsContext from '@/context/ecs.js';
import MainEntityContext from '@/context/main-entity.js';
import Ecs from './ecs.jsx';
@ -14,7 +15,9 @@ import styles from './pixi.module.css';
BaseTexture.defaultOptions.scaleMode = SCALE_MODES.NEAREST;
const ContextBridge = ({children, Contexts, render}) => {
const Contexts = [ClientContext, EcsContext, MainEntityContext];
const ContextBridge = ({children, render}) => {
const contexts = Contexts.map(useContext);
return render(
<>
@ -33,7 +36,6 @@ const ContextBridge = ({children, Contexts, render}) => {
export const Stage = ({children, ...props}) => {
return (
<ContextBridge
Contexts={[ClientContext, MainEntityContext]}
render={(children) => <PixiStage {...props}>{children}</PixiStage>}
>
{children}

View File

@ -3,6 +3,7 @@ import {useContext, useEffect, useState} from 'react';
import addKeyListener from '@/add-key-listener.js';
import {RESOLUTION} from '@/constants.js';
import ClientContext from '@/context/client.js';
import {useEcs} from '@/context/ecs.js';
import {useMainEntity} from '@/context/main-entity.js';
import usePacket from '@/hooks/use-packet.js';
@ -18,6 +19,7 @@ export default function Ui({disconnected}) {
// Key input.
const client = useContext(ClientContext);
const [mainEntity, setMainEntity] = useMainEntity();
const [ecs] = useEcs();
const [showDisconnected, setShowDisconnected] = useState(false);
const [hotbarSlots, setHotbarSlots] = useState(Array(10).fill(0).map(() => {}));
const [activeSlot, setActiveSlot] = useState(0);
@ -136,25 +138,38 @@ export default function Ui({disconnected}) {
if (0 === Object.keys(payload.ecs).length) {
return;
}
ecs.apply(payload.ecs);
let localMainEntity = mainEntity;
for (const id in payload.ecs) {
if (payload.ecs[id]?.MainEntity) {
const update = payload.ecs[id];
if (update?.MainEntity) {
setMainEntity(localMainEntity = id);
}
if (localMainEntity === id) {
if (payload.ecs[id].Inventory) {
if (update.Inventory) {
const newHotbarSlots = [...hotbarSlots];
payload.ecs[id].Inventory.slots
.forEach(({qty, slotIndex, source}) => {
const {slots, slotChanged} = update.Inventory;
if (slotChanged) {
for (const slotIndex in slotChanged) {
newHotbarSlots[slotIndex] = {
image: source + '/icon.png',
qty,
...newHotbarSlots[slotIndex],
...slotChanged[slotIndex],
};
});
}
}
if (slots) {
slots
.forEach(({qty, slotIndex, source}) => {
newHotbarSlots[slotIndex] = {
image: source + '/icon.png',
qty,
};
});
}
setHotbarSlots(newHotbarSlots);
}
if (payload.ecs[id].Wielder && 'activeSlot' in payload.ecs[id].Wielder) {
setActiveSlot(payload.ecs[id].Wielder.activeSlot);
if (update.Wielder && 'activeSlot' in update.Wielder) {
setActiveSlot(update.Wielder.activeSlot);
}
}
}

View File

@ -3,8 +3,11 @@ import {useEffect, useState} from 'react';
import {useOutletContext, useParams} from 'react-router-dom';
import ClientContext from '@/context/client.js';
import EcsContext from '@/context/ecs.js';
import MainEntityContext from '@/context/main-entity.js';
import Ecs from '@/ecs/ecs.js';
import Components from '@/ecs-components/index.js';
import Systems from '@/ecs-systems/index.js';
import Ui from '@/react-components/ui.jsx';
import {juggleSession} from '@/session.server';
@ -17,6 +20,7 @@ export default function PlaySpecific() {
const Client = useOutletContext();
const [client, setClient] = useState();
const mainEntityTuple = useState();
const ecsTuple = useState(new Ecs({Components, Systems}));
const [disconnected, setDisconnected] = useState(false);
const params = useParams();
const [type, url] = params['*'].split('/');
@ -39,7 +43,7 @@ export default function PlaySpecific() {
if ('local' !== type) {
return;
}
async function onBeforeUnload(event) {
async function onBeforeUnload() {
client.disconnect();
function waitForSave() {
return new Promise((resolve) => setTimeout(resolve, 0));
@ -88,7 +92,9 @@ export default function PlaySpecific() {
return (
<ClientContext.Provider value={client}>
<MainEntityContext.Provider value={mainEntityTuple}>
<Ui disconnected={disconnected} />
<EcsContext.Provider value={ecsTuple}>
<Ui disconnected={disconnected} />
</EcsContext.Provider>
</MainEntityContext.Provider>
</ClientContext.Provider>
);

View File

@ -7,17 +7,13 @@ export default function gather(imports, options = {}) {
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;
Gathered[
mapperForPath(path)
.split('-')
.map(capitalize)
.join('')
] = Component;
}
return Gathered;
}

17
app/util/gather.test.js Normal file
View File

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

View File

@ -146,4 +146,4 @@ export default class Script {
);
}
};
}

View File

@ -4,7 +4,7 @@ import TickingPromise from './ticking-promise.js';
test('runs executor', async () => {
expect(
await new TickingPromise((resolve, reject) => {
await new TickingPromise((resolve) => {
resolve(32);
}),
)

View File

@ -6,7 +6,7 @@ import {WebSocketServer} from 'ws';
import Server from '@/net/server/server.js';
import {getSession} from '@/session.server.js';
import Engine from './engine/engine.js';
import Engine from './engine.js';
const wss = new WebSocketServer({
noServer: true,
@ -85,7 +85,7 @@ async function remakeServer(Engine) {
})();
if (import.meta.hot) {
import.meta.hot.accept('./engine/engine.js', async ({default: Engine}) => {
import.meta.hot.accept('./engine.js', async ({default: Engine}) => {
await remakeServer(Engine);
});
}

View File

@ -1,7 +1,2 @@
wielder.Health.health += 10
wielder.Inventory.slots = [
{
...wielder.Inventory.slots[0],
qty: wielder.Inventory.slots[0].qty - 1,
}
]
item.qty -= 1