Compare commits

..

16 Commits

Author SHA1 Message Date
cha0s
d88135c85f fun: planties! 2024-06-27 15:08:30 -05:00
cha0s
4bf9b8d891 fix: 0-length animations 2024-06-27 13:57:30 -05:00
cha0s
d63f835ebd fix: z-sorting 2024-06-27 13:57:18 -05:00
cha0s
b12183a6ee perf: assets 2024-06-27 13:57:02 -05:00
cha0s
c339491590 fix: slop 2024-06-27 12:03:58 -05:00
cha0s
f89c94b619 refactor: item use 2024-06-27 11:06:58 -05:00
cha0s
15674fb1d7 refactor: readAsset to first-class 2024-06-27 10:53:52 -05:00
cha0s
c6557bee39 fix: async 2024-06-27 10:17:47 -05:00
cha0s
2b4c5f5a8e fix: stale iterator 2024-06-27 10:17:04 -05:00
cha0s
c8622c6814 feat: engine readAsset 2024-06-27 07:37:20 -05:00
cha0s
d8528ad7a5 dev: CA 2024-06-27 07:37:01 -05:00
cha0s
0cb1624cd8 fix: changed broke on removed 2024-06-27 07:28:46 -05:00
cha0s
74ec36dfa8 feat: async creation 2024-06-27 06:58:47 -05:00
cha0s
76f18e09c7 refactor: useEcsTick (always after apply) 2024-06-27 05:44:34 -05:00
cha0s
438a0c3be5 refactor: gardening 2024-06-27 04:32:31 -05:00
cha0s
95b666e844 refactor: gardening 2024-06-27 04:08:52 -05:00
36 changed files with 612 additions and 339 deletions

View File

@ -41,7 +41,7 @@ module.exports = {
// React
{
files: ['**/*.{js,jsx,ts,tsx}'],
files: ['**/*.{jsx,tsx}'],
plugins: ['react', 'jsx-a11y'],
extends: [
'plugin:react/recommended',

View File

@ -248,6 +248,11 @@ export default class Sandbox {
}
}
reset() {
this.generator = undefined;
this.compile();
}
run(ops = 1000) {
let result;
for (let i = 0; i < ops; ++i) {
@ -265,8 +270,7 @@ export default class Sandbox {
}
const result = this.generator.next();
if (result.done) {
this.generator = undefined;
this.compile();
this.reset();
}
return result;
}

25
app/context/assets.js Normal file
View File

@ -0,0 +1,25 @@
import {Assets} from '@pixi/assets';
import {createContext, useContext, useEffect} from 'react';
const context = createContext();
export default context;
const loading = {};
export function useAsset(source) {
const [assets, setAssets] = useContext(context);
useEffect(() => {
if (!assets[source]) {
if (!loading[source]) {
(loading[source] = Assets.load(source)).then((asset) => {
setAssets((assets) => ({
...assets,
[source]: asset,
}));
});
}
}
}, [assets, setAssets, source]);
return assets[source];
}

View File

@ -1,3 +1,26 @@
import {createContext} from 'react';
import {createContext, useContext, useEffect} from 'react';
export default createContext();
const context = createContext();
export default context;
export function useClient() {
return useContext(context);
}
export function usePacket(type, fn, dependencies) {
const client = useClient();
useEffect(() => {
if (!client) {
return;
}
function listener(payload) {
fn(payload, client);
}
client.addPacketListener(type, listener);
return () => {
client.removePacketListener(type, listener);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client, ...dependencies]);
}

View File

@ -1,5 +1,7 @@
import {createContext, useContext} from 'react';
import {usePacket} from './client.js';
const context = createContext();
export default context;
@ -7,3 +9,8 @@ export default context;
export function useEcs() {
return useContext(context);
}
export function useEcsTick(fn, dependencies) {
const ecs = useEcs();
usePacket(':Ecs', fn, [ecs, ...dependencies]);
}

View File

@ -43,56 +43,60 @@ export default class Inventory extends Component {
instanceFromSchema() {
const Instance = super.instanceFromSchema();
const Component = this;
Instance.prototype.item = async function (slot) {
const {slots} = this;
if (!(slot in slots)) {
return undefined;
return class InventoryInstance extends Instance {
async item(slot) {
const {slots} = this;
if (!(slot in slots)) {
return undefined;
}
const {readAsset} = Component.ecs;
const chars = await readAsset([slots[slot].source, 'item.json'].join('/'))
const json = chars.byteLength > 0
? JSON.parse(
(new TextDecoder()).decode(chars),
)
: {};
const item = {
...slots[slot],
...json,
};
const instance = this;
const proxy = new Proxy(item, {
set(target, property, value) {
slots[slot][property] = value;
if ('qty' === property && value <= 0) {
Component.markChange(instance.entity, 'slotChange', {[slot]: false});
delete slots[slot];
}
else {
Component.markChange(instance.entity, 'slotChange', {[slot]: {[property]: value}});
}
return true;
},
});
return proxy;
}
const json = await (
fetch([slots[slot].source, 'item.json'].join('/'))
.then((response) => (response.ok ? response.json() : {}))
);
const item = {
...slots[slot],
...json,
};
const instance = this;
const proxy = new Proxy(item, {
set(target, property, value) {
slots[slot][property] = value;
if ('qty' === property && value <= 0) {
Component.markChange(instance.entity, 'slotChange', {[slot]: false});
delete slots[slot];
}
else {
Component.markChange(instance.entity, 'slotChange', {[slot]: {[property]: value}});
}
return true;
},
});
return proxy;
};
Instance.prototype.swapSlots = function(l, r) {
const {slots} = this;
const tmp = slots[l];
const change = {};
if (slots[r]) {
change[l] = slots[l] = slots[r];
swapSlots(l, r) {
const {slots} = this;
const tmp = slots[l];
const change = {};
if (slots[r]) {
change[l] = slots[l] = slots[r];
}
else {
change[l] = false;
delete slots[l];
}
if (tmp) {
change[r] = slots[r] = tmp;
}
else {
change[r] = false;
delete slots[r];
}
Component.markChange(this.entity, 'slotChange', change);
}
else {
change[l] = false;
delete slots[l];
}
if (tmp) {
change[r] = slots[r] = tmp;
}
else {
change[r] = false;
delete slots[r];
}
Component.markChange(this.entity, 'slotChange', change);
};
return Instance;
}
}
static properties = {
slots: {

View File

@ -0,0 +1,58 @@
import Component from '@/ecs/component.js';
import Script from '@/util/script.js';
export default class Plant extends Component {
instanceFromSchema() {
const {ecs} = this;
const Instance = super.instanceFromSchema();
return class PlantInstance extends Instance {
mayGrow() {
return this.mayGrowScriptInstance.evaluateSync();
}
grow() {
const {Ticking} = ecs.get(this.entity);
Ticking.addTickingPromise(this.growScriptInstance.tickingPromise());
}
};
}
async load(instance) {
// heavy handed...
if ('undefined' !== typeof window) {
return;
}
const {readAsset} = this.ecs;
await readAsset(instance.growScript)
.then(async (code) => {
if (code.byteLength > 0) {
const context = {
ecs: this.ecs,
plant: instance,
};
instance.growScriptInstance = await Script.fromCode((new TextDecoder()).decode(code), context);
}
});
await readAsset(instance.mayGrowScript)
.then(async (code) => {
if (code.byteLength > 0) {
const context = {
ecs: this.ecs,
plant: instance,
};
instance.mayGrowScriptInstance = await Script.fromCode((new TextDecoder()).decode(code), context);
}
});
}
// heavy handed...
markChange() {}
static properties = {
growScript: {type: 'string'},
growth: {type: 'uint16'},
mayGrowScript: {type: 'string'},
stage: {type: 'uint8'},
stages: {
type: 'array',
subtype: {type: 'uint16'},
},
};
}

View File

@ -4,34 +4,32 @@ export default class Ticking extends Component {
instanceFromSchema() {
const Instance = super.instanceFromSchema();
Instance.prototype.$$finished = [];
Instance.prototype.$$tickingPromises = [];
Instance.prototype.addTickingPromise = function(tickingPromise) {
this.$$tickingPromises.push(tickingPromise);
tickingPromise.then(() => {
this.$$finished.push(tickingPromise);
});
return class TickingInstance extends Instance {
$$finished = [];
$$tickingPromises = [];
addTickingPromise(tickingPromise) {
this.$$tickingPromises.push(tickingPromise);
tickingPromise.then(() => {
this.$$finished.push(tickingPromise);
});
}
tick(elapsed) {
for (const tickingPromise of this.$$finished) {
this.$$tickingPromises.splice(
this.$$tickingPromises.indexOf(tickingPromise),
1,
);
}
this.$$finished = [];
for (const tickingPromise of this.$$tickingPromises) {
tickingPromise.tick(elapsed);
}
}
}
Instance.prototype.tick = function(elapsed) {
for (const tickingPromise of this.$$finished) {
this.$$tickingPromises.splice(
this.$$tickingPromises.indexOf(tickingPromise),
1,
);
}
this.$$finished = [];
for (const tickingPromise of this.$$tickingPromises) {
tickingPromise.tick(elapsed);
}
for (const tickingPromise of this.$$finished) {
this.$$tickingPromises.splice(
this.$$tickingPromises.indexOf(tickingPromise),
1,
);
}
this.$$finished = [];
}
return Instance;
}
static properties = {
isTicking: {defaultValue: 1, type: 'uint8'},

View File

@ -37,53 +37,54 @@ export default class TileLayers extends Component {
instanceFromSchema() {
const Instance = super.instanceFromSchema();
const Component = this;
Instance.prototype.layer = function (index) {
const {layers} = this;
if (!(index in layers)) {
return undefined;
}
const instance = this;
class LayerProxy {
constructor(layer) {
this.layer = layer;
return class TileLayersInstance extends Instance {
layer(index) {
const {layers} = this;
if (!(index in layers)) {
return undefined;
}
get area() {
return this.layer.area;
}
get source() {
return this.layer.source;
}
stamp(at, data) {
const changes = {};
for (const row in data) {
const columns = data[row];
for (const column in columns) {
const tile = columns[column];
const x = at.x + parseInt(column);
const y = at.y + parseInt(row);
if (x < 0 || y < 0 || x >= this.layer.area.x || y >= this.layer.area.y) {
continue;
const instance = this;
class LayerProxy {
constructor(layer) {
this.layer = layer;
}
get area() {
return this.layer.area;
}
get source() {
return this.layer.source;
}
stamp(at, data) {
const changes = {};
for (const row in data) {
const columns = data[row];
for (const column in columns) {
const tile = columns[column];
const x = at.x + parseInt(column);
const y = at.y + parseInt(row);
if (x < 0 || y < 0 || x >= this.layer.area.x || y >= this.layer.area.y) {
continue;
}
const calculated = y * this.layer.area.x + x;
this.layer.data[calculated] = tile;
changes[calculated] = tile;
}
const calculated = y * this.layer.area.x + x;
this.layer.data[calculated] = tile;
changes[calculated] = tile;
}
Component.markChange(instance.entity, 'layerChange', {[index]: changes});
}
Component.markChange(instance.entity, 'layerChange', {[index]: changes});
}
tile({x, y}) {
if (x < 0 || y < 0 || x >= this.layer.area.x || y >= this.layer.area.y) {
return undefined;
tile({x, y}) {
if (x < 0 || y < 0 || x >= this.layer.area.x || y >= this.layer.area.y) {
return undefined;
}
return this.layer.data[y * this.layer.area.x + x];
}
get tileSize() {
return this.layer.tileSize;
}
return this.layer.data[y * this.layer.area.x + x];
}
get tileSize() {
return this.layer.tileSize;
}
return new LayerProxy(layers[index]);
}
return new LayerProxy(layers[index]);
};
return Instance;
}
}
static properties = {
layers: {

View File

@ -1,69 +1,91 @@
import Component from '@/ecs/component.js';
import Script from '@/util/script.js';
export default class Wielder extends Component {
instanceFromSchema() {
const {ecs} = this;
const Instance = super.instanceFromSchema();
const Component = this;
Instance.prototype.activeItem = async function () {
const {Inventory, Wielder} = Component.ecs.get(this.entity);
return Inventory.item(Wielder.activeSlot + 1);
};
Instance.prototype.project = function(position, projection) {
const {TileLayers: {layers: [layer]}} = Component.ecs.get(1);
const {Direction: {direction}} = Component.ecs.get(this.entity);
let startX = position.x;
let startY = position.y;
switch (direction) {
case 0:
startX += projection.distance[1];
startY -= projection.distance[0];
break;
case 1:
startX += projection.distance[0];
startY += projection.distance[1];
break;
case 2:
startX -= projection.distance[1];
startY += projection.distance[0];
break;
case 3:
startX -= projection.distance[0];
startY -= projection.distance[1];
break;
return class WielderInstance extends Instance {
async activeItem() {
const {Inventory, Wielder} = ecs.get(this.entity);
return Inventory.item(Wielder.activeSlot + 1);
}
const projected = [];
for (const row in projection.grid) {
const columns = projection.grid[row];
for (const column in columns) {
const targeted = projection.grid[row][column];
if (targeted) {
let axe;
switch (direction) {
case 0:
axe = [column, row];
break;
case 1:
axe = [-row, column];
break;
case 2:
axe = [-column, -row];
break;
case 3:
axe = [row, -column];
break;
project(position, projection) {
const {TileLayers: {layers: [layer]}} = ecs.get(1);
const {Direction: {direction}} = ecs.get(this.entity);
let startX = position.x;
let startY = position.y;
switch (direction) {
case 0:
startX += projection.distance[1];
startY -= projection.distance[0];
break;
case 1:
startX += projection.distance[0];
startY += projection.distance[1];
break;
case 2:
startX -= projection.distance[1];
startY += projection.distance[0];
break;
case 3:
startX -= projection.distance[0];
startY -= projection.distance[1];
break;
}
const projected = [];
for (const row in projection.grid) {
const columns = projection.grid[row];
for (const column in columns) {
const targeted = projection.grid[row][column];
if (targeted) {
let axe;
switch (direction) {
case 0:
axe = [column, row];
break;
case 1:
axe = [-row, column];
break;
case 2:
axe = [-column, -row];
break;
case 3:
axe = [row, -column];
break;
}
const x = startX + parseInt(axe[0]);
const y = startY + parseInt(axe[1]);
if (x < 0 || y < 0 || x >= layer.area.x || y >= layer.area.y) {
continue;
}
projected.push({x, y});
}
const x = startX + parseInt(axe[0]);
const y = startY + parseInt(axe[1]);
if (x < 0 || y < 0 || x >= layer.area.x || y >= layer.area.y) {
continue;
}
projected.push({x, y});
}
}
return projected;
}
async useActiveItem(state) {
const entity = ecs.get(this.entity);
const {Ticking} = entity;
const activeItem = await this.activeItem();
if (activeItem) {
ecs.readAsset([activeItem.source, state ? 'start.js' : 'stop.js'].join('/'))
.then((code) => {
if (code.byteLength > 0) {
const context = {
ecs,
item: activeItem,
wielder: entity,
};
Ticking.addTickingPromise(Script.tickingPromise((new TextDecoder()).decode(code), context));
}
});
}
}
return projected;
}
return Instance;
}
static properties = {
activeSlot: {type: 'uint16'},

View File

@ -0,0 +1,27 @@
import {System} from '@/ecs/index.js';
export default class PlantGrowth extends System {
static queries() {
return {
default: ['Plant'],
};
}
tick(elapsed) {
for (const {Plant} of this.select('default')) {
if (65535 === Plant.growth || !Plant.mayGrow()) {
continue;
}
const stage = Math.floor(Plant.stage);
Plant.growth = Math.min(
Plant.growth + (((Math.random() + 0.5) * elapsed) / Plant.stages[stage]) * 65535,
65535,
);
if (65535 === Plant.growth) {
Plant.grow();
}
}
}
}

View File

@ -10,6 +10,9 @@ export default class ControlMovement extends System {
tick(elapsed) {
for (const {Sprite} of this.select('default')) {
if (0 === Sprite.speed) {
continue;
}
Sprite.elapsed += elapsed / Sprite.speed;
while (Sprite.elapsed > 1) {
Sprite.elapsed -= 1;

View File

@ -28,22 +28,20 @@ export default class Component {
return results;
}
create(entityId, values) {
async create(entityId, values) {
this.createMany([[entityId, values]]);
}
createMany(entries) {
async createMany(entries) {
if (entries.length > 0) {
const allocated = this.allocateMany(entries.length);
const {properties} = this.constructor.schema.specification;
const keys = Object.keys(properties);
const promises = [];
for (let i = 0; i < entries.length; ++i) {
const [entityId, values = {}] = entries[i];
this.map[entityId] = allocated[i];
this.data[allocated[i]].entity = entityId;
if (false === values) {
continue;
}
for (let k = 0; k < keys.length; ++k) {
const j = keys[k];
const {defaultValue} = properties[j];
@ -54,7 +52,9 @@ export default class Component {
this.data[allocated[i]][j] = defaultValue;
}
}
promises.push(this.load(this.data[allocated[i]]));
}
await Promise.all(promises);
}
}
@ -108,7 +108,7 @@ export default class Component {
return this.data[this.map[entityId]];
}
insertMany(entities) {
async insertMany(entities) {
const creating = [];
for (let i = 0; i < entities.length; i++) {
const [entityId, values] = entities[i];
@ -122,7 +122,7 @@ export default class Component {
}
}
}
this.createMany(creating);
await this.createMany(creating);
}
instanceFromSchema() {
@ -170,6 +170,10 @@ export default class Component {
return Instance;
}
async load(instance) {
return instance;
}
markChange(entityId, key, value) {
this.ecs.markChange(entityId, {[this.constructor.componentName]: {[key]: value}})
}

View File

@ -28,7 +28,7 @@ export default class Ecs {
}
}
apply(patch) {
async apply(patch) {
const creating = [];
const destroying = [];
const removing = [];
@ -63,7 +63,7 @@ export default class Ecs {
this.destroyMany(destroying);
this.insertMany(updating);
this.removeMany(removing);
this.createManySpecific(creating);
await this.createManySpecific(creating);
}
changed(criteria) {
@ -75,6 +75,10 @@ export default class Ecs {
next: () => {
let result = it.next();
hasResult: while (!result.done) {
if (false === result.value[1]) {
result = it.next();
continue;
}
for (const componentName of criteria) {
if (!(componentName in result.value[1])) {
result = it.next();
@ -91,12 +95,12 @@ export default class Ecs {
};
}
create(components = {}) {
const [entityId] = this.createMany([components]);
async create(components = {}) {
const [entityId] = await this.createMany([components]);
return entityId;
}
createMany(componentsList) {
async createMany(componentsList) {
const specificsList = [];
for (const components of componentsList) {
specificsList.push([this.$$caret++, components]);
@ -104,7 +108,7 @@ export default class Ecs {
return this.createManySpecific(specificsList);
}
createManySpecific(specificsList) {
async createManySpecific(specificsList) {
const entityIds = [];
const creating = {};
for (let i = 0; i < specificsList.length; i++) {
@ -125,14 +129,16 @@ export default class Ecs {
}
this.markChange(entityId, components);
}
const promises = [];
for (const i in creating) {
this.Components[i].createMany(creating[i]);
promises.push(this.Components[i].createMany(creating[i]));
}
await Promise.all(promises);
this.reindex(entityIds);
return entityIds;
}
createSpecific(entityId, components) {
async createSpecific(entityId, components) {
return this.createManySpecific([[entityId, components]]);
}
@ -142,7 +148,7 @@ export default class Ecs {
}
}
static deserialize(ecs, view) {
static async deserialize(ecs, view) {
const componentNames = Object.keys(ecs.Components);
const {entities, systems} = decoder.decode(view.buffer);
for (const system of systems) {
@ -164,7 +170,7 @@ export default class Ecs {
]);
}
ecs.$$caret = max + 1;
ecs.createManySpecific(specifics);
await ecs.createManySpecific(specifics);
return ecs;
}
@ -220,11 +226,11 @@ export default class Ecs {
return this.$$entities[entityId];
}
insert(entityId, components) {
this.insertMany([[entityId, components]]);
async insert(entityId, components) {
return this.insertMany([[entityId, components]]);
}
insertMany(entities) {
async insertMany(entities) {
const inserting = {};
const unique = new Set();
for (const [entityId, components] of entities) {
@ -240,9 +246,11 @@ export default class Ecs {
unique.add(entityId);
this.markChange(entityId, diff);
}
const promises = [];
for (const componentName in inserting) {
this.Components[componentName].insertMany(inserting[componentName]);
promises.push(this.Components[componentName].insertMany(inserting[componentName]));
}
await Promise.all(promises);
this.reindex(unique.values());
}
@ -315,7 +323,7 @@ export default class Ecs {
for (const componentName in removing) {
this.Components[componentName].destroyMany(removing[componentName]);
}
this.reindex(unique.values());
this.reindex(unique);
}
static serialize(ecs, view) {

View File

@ -23,6 +23,24 @@ const Position = wrapProperties('Position', {
z: {type: 'int32'},
});
function asyncTimesTwo(x) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(x * 2)
}, 5);
});
}
class Async extends Component {
static componentName = 'Async';
static properties = {
foo: {type: 'uint8'},
};
async load(instance) {
instance.foo = await asyncTimesTwo(instance.foo);
}
}
test('activates and deactivates systems at runtime', () => {
let oneCount = 0;
let twoCount = 0;
@ -63,31 +81,31 @@ test('activates and deactivates systems at runtime', () => {
.to.equal(2);
});
test('creates entities with components', () => {
test('creates entities with components', async () => {
const ecs = new Ecs({Components: {Empty, Position}});
const entity = ecs.create({Empty: {}, Position: {y: 128}});
const entity = await 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", () => {
test("removes entities' components", async () => {
const ecs = new Ecs({Components: {Empty, Position}});
const entity = ecs.create({Empty: {}, Position: {y: 128}});
const entity = await 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', () => {
test('gets entities', async () => {
const ecs = new Ecs({Components: {Empty, Position}});
const entity = ecs.create({Empty: {}, Position: {y: 128}});
const entity = await ecs.create({Empty: {}, Position: {y: 128}});
expect(JSON.stringify(ecs.get(entity)))
.to.deep.equal(JSON.stringify({Empty: {}, Position: {y: 128}}));
});
test('destroys entities', () => {
test('destroys entities', async () => {
const ecs = new Ecs({Components: {Empty, Position}});
const entity = ecs.create({Empty: {}, Position: {y: 128}});
const entity = await 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))
@ -101,9 +119,9 @@ test('destroys entities', () => {
.to.throw();
});
test('inserts components into entities', () => {
test('inserts components into entities', async () => {
const ecs = new Ecs({Components: {Empty, Position}});
const entity = ecs.create({Empty: {}});
const entity = await ecs.create({Empty: {}});
ecs.insert(entity, {Position: {y: 128}});
expect(JSON.stringify(ecs.get(entity)))
.to.deep.equal(JSON.stringify({Empty: {}, Position: {y: 128}}));
@ -112,7 +130,7 @@ test('inserts components into entities', () => {
.to.deep.equal(JSON.stringify({Empty: {}, Position: {y: 64}}));
});
test('ticks systems', () => {
test('ticks systems', async () => {
const Momentum = wrapProperties('Momentum', {
x: {type: 'int32'},
y: {type: 'int32'},
@ -141,7 +159,7 @@ test('ticks systems', () => {
},
});
ecs.system('Physics').active = true;
const entity = ecs.create({Momentum: {}, Position: {y: 128}});
const entity = await ecs.create({Momentum: {}, Position: {y: 128}});
const position = JSON.stringify(ecs.get(entity).Position);
ecs.tick(1);
expect(JSON.stringify(ecs.get(entity).Position))
@ -213,8 +231,8 @@ test('schedules entities to be deleted when ticking systems', () => {
.to.be.undefined;
});
test('adds components to and remove components from entities when ticking systems', () => {
let addLength, removeLength;
test('adds components to and remove components from entities when ticking systems', async () => {
let promise;
const ecs = new Ecs({
Components: {Foo: wrapProperties('Foo', {bar: {type: 'uint8'}})},
Systems: {
@ -225,10 +243,7 @@ test('adds components to and remove components from entities when ticking system
};
}
tick() {
this.insertComponents(1, {Foo: {}});
}
finalize() {
addLength = Array.from(this.select('default')).length;
promise = this.insertComponents(1, {Foo: {}});
}
},
RemoveComponent: class extends System {
@ -240,40 +255,38 @@ test('adds components to and remove components from entities when ticking system
tick() {
this.removeComponents(1, ['Foo']);
}
finalize() {
removeLength = Array.from(this.select('default')).length;
}
},
},
});
ecs.system('AddComponent').active = true;
ecs.create();
ecs.tick(1);
expect(addLength)
await promise;
expect(Array.from(ecs.system('AddComponent').select('default')).length)
.to.equal(1);
expect(ecs.get(1).Foo)
.to.not.be.undefined;
ecs.system('AddComponent').active = false;
ecs.system('RemoveComponent').active = true;
ecs.tick(1);
expect(removeLength)
expect(Array.from(ecs.system('RemoveComponent').select('default')).length)
.to.equal(0);
expect(ecs.get(1).Foo)
.to.be.undefined;
});
test('generates coalesced diffs for entity creation', () => {
test('generates diffs for entity creation', async () => {
const ecs = new Ecs();
let entity;
entity = ecs.create();
entity = await ecs.create();
expect(ecs.diff)
.to.deep.equal({[entity]: {}});
});
test('generates diffs for adding and removing components', () => {
test('generates diffs for adding and removing components', async () => {
const ecs = new Ecs({Components: {Position}});
let entity;
entity = ecs.create();
entity = await ecs.create();
ecs.setClean();
ecs.insert(entity, {Position: {x: 64}});
expect(ecs.diff)
@ -286,10 +299,10 @@ test('generates diffs for adding and removing components', () => {
.to.deep.equal({[entity]: {Position: false}});
});
test('generates diffs for empty components', () => {
test('generates diffs for empty components', async () => {
const ecs = new Ecs({Components: {Empty}});
let entity;
entity = ecs.create({Empty: {}});
entity = await ecs.create({Empty: {}});
expect(ecs.diff)
.to.deep.equal({[entity]: {Empty: {}}});
ecs.setClean();
@ -298,10 +311,10 @@ test('generates diffs for empty components', () => {
.to.deep.equal({[entity]: {Empty: false}});
});
test('generates diffs for entity mutations', () => {
test('generates diffs for entity mutations', async () => {
const ecs = new Ecs({Components: {Position}});
let entity;
entity = ecs.create({Position: {}});
entity = await ecs.create({Position: {}});
ecs.setClean();
ecs.get(entity).Position.x = 128;
expect(ecs.diff)
@ -311,10 +324,10 @@ test('generates diffs for entity mutations', () => {
.to.deep.equal({});
});
test('generates coalesced diffs for components', () => {
test('generates coalesced diffs for components', async () => {
const ecs = new Ecs({Components: {Position}});
let entity;
entity = ecs.create({Position});
entity = await ecs.create({Position});
ecs.remove(entity, ['Position']);
expect(ecs.diff)
.to.deep.equal({[entity]: {Position: false}});
@ -323,10 +336,10 @@ test('generates coalesced diffs for components', () => {
.to.deep.equal({[entity]: {Position: {}}});
});
test('generates coalesced diffs for mutations', () => {
test('generates coalesced diffs for mutations', async () => {
const ecs = new Ecs({Components: {Position}});
let entity;
entity = ecs.create({Position});
entity = await ecs.create({Position});
ecs.setClean();
ecs.get(entity).Position.x = 128;
ecs.get(entity).Position.x = 256;
@ -335,10 +348,10 @@ test('generates coalesced diffs for mutations', () => {
.to.deep.equal({[entity]: {Position: {x: 512}}});
});
test('generates diffs for deletions', () => {
test('generates diffs for deletions', async () => {
const ecs = new Ecs();
let entity;
entity = ecs.create();
entity = await ecs.create();
ecs.setClean();
ecs.destroy(entity);
expect(ecs.diff)
@ -391,7 +404,7 @@ test('calculates entity size', () => {
.to.equal(30);
});
test('serializes and deserializes', () => {
test('serializes and deserializes', async () => {
const ecs = new Ecs({Components: {Empty, Name, Position}});
ecs.createSpecific(1, {Empty: {}, Position: {x: 64}});
ecs.createSpecific(16, {Name: {name: 'foobar'}, Position: {x: 128}});
@ -404,7 +417,7 @@ test('serializes and deserializes', () => {
systems: [],
});
const view = Ecs.serialize(ecs);
const deserialized = Ecs.deserialize(
const deserialized = await Ecs.deserialize(
new Ecs({Components: {Empty, Name, Position}}),
view,
);
@ -424,12 +437,12 @@ test('serializes and deserializes', () => {
.to.equal('foobar');
});
test('deserializes from compatible ECS', () => {
test('deserializes from compatible ECS', async () => {
const ecs = new Ecs({Components: {Empty, Name, Position}});
ecs.createSpecific(1, {Empty: {}, Position: {x: 64}});
ecs.createSpecific(16, {Name: {name: 'foobar'}, Position: {x: 128}});
const view = Ecs.serialize(ecs);
const deserialized = Ecs.deserialize(
const deserialized = await Ecs.deserialize(
new Ecs({Components: {Empty, Name}}),
view,
);
@ -438,3 +451,30 @@ test('deserializes from compatible ECS', () => {
expect(deserialized.get(16).toJSON())
.to.deep.equal({Name: {name: 'foobar'}});
});
test('creates entities asynchronously', async () => {
const ecs = new Ecs({Components: {Async}});
const entity = await ecs.create({Async: {foo: 64}});
expect(ecs.get(entity).toJSON())
.to.deep.equal({Async: {foo: 128}});
});
test('inserts components asynchronously', async () => {
const ecs = new Ecs({Components: {Async}});
const entity = await ecs.create();
await ecs.insert(entity, {Async: {foo: 64}});
expect(ecs.get(entity).toJSON())
.to.deep.equal({Async: {foo: 128}});
});
test('deserializes asynchronously', async () => {
const ecs = new Ecs({Components: {Async}});
await ecs.createSpecific(16, {Async: {foo: 16}});
const view = Ecs.serialize(ecs);
const deserialized = await Ecs.deserialize(
new Ecs({Components: {Async}}),
view,
);
expect(deserialized.get(16).toJSON())
.to.deep.equal({Async: {foo: 64}});
});

View File

@ -47,12 +47,12 @@ export default class System {
finalize() {}
insertComponents(entityId, components) {
this.ecs.insert(entityId, components);
async insertComponents(entityId, components) {
return this.ecs.insert(entityId, components);
}
insertManyComponents(components) {
this.ecs.insertMany(components);
async insertManyComponents(components) {
return this.ecs.insertMany(components);
}
static get priority() {

View File

@ -5,7 +5,6 @@ import Ecs from '@/ecs/ecs.js';
import Components from '@/ecs-components/index.js';
import Systems from '@/ecs-systems/index.js';
import {decode, encode} from '@/packets/index.js';
import Script from '@/util/script.js';
function join(...parts) {
return parts.join('/');
@ -32,7 +31,12 @@ export default class Engine {
super.transmit(connection, encode(packet));
}
}
this.server = new SilphiusServer();
const server = this.server = new SilphiusServer();
this.Ecs = class EngineEcs extends Ecs {
readAsset(uri) {
return server.readAsset(uri);
}
}
this.server.addPacketListener('Action', (connection, payload) => {
this.incomingActions.push([this.connectedPlayers.get(connection).entity, payload]);
});
@ -43,7 +47,7 @@ export default class Engine {
entity,
payload,
] of this.incomingActions) {
const {Controlled, Inventory, Ticking, Wielder} = entity;
const {Controlled, Inventory, Wielder} = entity;
switch (payload.type) {
case 'changeSlot': {
if (!Controlled.locked) {
@ -66,25 +70,7 @@ export default class Engine {
}
case 'use': {
if (!Controlled.locked) {
Inventory.item(Wielder.activeSlot + 1).then(async (item) => {
if (item) {
const code = await(
this.server.readAsset([
item.source,
payload.value ? 'start.js' : 'stop.js',
].join('/'))
.then((script) => (script.ok ? script.text() : ''))
);
if (code) {
const context = {
ecs: this.ecses[entity.Ecs.path],
item,
wielder: entity,
};
Ticking.addTickingPromise(Script.tickingPromise(code, context));
}
}
});
Wielder.useActiveItem(payload.value);
}
break;
}
@ -99,7 +85,7 @@ export default class Engine {
await this.loadEcs(entityJson.Ecs.path);
}
const ecs = this.ecses[entityJson.Ecs.path];
const entity = ecs.create(entityJson);
const entity = await ecs.create(entityJson);
this.connections.push(connection);
this.connectedPlayers.set(
connection,
@ -112,13 +98,13 @@ export default class Engine {
}
createEcs() {
return new Ecs({Components, Systems});
return new this.Ecs({Components, Systems});
}
async createHomestead(id) {
const ecs = this.createEcs();
const area = {x: 100, y: 60};
ecs.create({
await ecs.create({
AreaSize: {x: area.x * 16, y: area.y * 16},
Engine: {},
TileLayers: {
@ -132,11 +118,39 @@ export default class Engine {
],
},
});
const plant = {
Plant: {
growScript: '/assets/tomato-plant/grow.js',
mayGrowScript: '/assets/tomato-plant/may-grow.js',
stages: [300, 300, 300, 300, 300],
},
Sprite: {
anchor: {x: 0.5, y: 0.75},
animation: 'stage/0',
frame: 0,
frames: 1,
source: '/assets/tomato-plant/tomato-plant.json',
speed: 0,
},
Ticking: {},
VisibleAabb: {},
};
const promises = [];
for (let y = 0; y < 10; ++y) {
for (let x = 0; x < 10; ++x) {
promises.push(ecs.create({
...plant,
Position: {x: 8 + x * 16, y: 8 + y * 16},
}));
}
}
await Promise.all(promises);
const defaultSystems = [
'ResetForces',
'ApplyControlMovement',
'ApplyForces',
'ClampPositions',
'PlantGrowth',
'FollowCamera',
'CalculateAabbs',
'UpdateSpatialHash',
@ -217,11 +231,10 @@ export default class Engine {
}
async loadEcs(path) {
this.ecses[path] = Ecs.deserialize(
this.ecses[path] = await this.Ecs.deserialize(
this.createEcs(),
await this.server.readData(path),
);
this.ecses[path].get(1).Engine.engine = this;
}
async loadPlayer(id) {
@ -240,7 +253,7 @@ export default class Engine {
}
async saveEcs(path, ecs) {
const view = Ecs.serialize(ecs);
const view = this.Ecs.serialize(ecs);
await this.server.writeData(path, view);
}

View File

@ -29,7 +29,7 @@ test('visibility-based updates', async () => {
await engine.connectPlayer(0, 0);
const ecs = engine.ecses['homesteads/0'];
// Create an entity.
const entity = ecs.get(ecs.create({
const entity = ecs.get(await ecs.create({
Forces: {forceX: 1},
Position: {x: (RESOLUTION.x * 1.5) + 32 - 3, y: 20},
VisibleAabb: {},

View File

@ -1,15 +0,0 @@
import {Assets} from '@pixi/assets';
import {useEffect, useState} from 'react';
export default function useAsset(source) {
const [asset, setAsset] = useState();
useEffect(() => {
if (Assets.cache.has(source)) {
setAsset(Assets.get(source));
}
else {
Assets.load(source).then(setAsset);
}
}, [setAsset, source]);
return asset;
}

View File

@ -1,17 +0,0 @@
import {useContext, useEffect} from 'react';
import ClientContext from '@/context/client.js';
export default function usePacket(type, fn, dependencies) {
const client = useContext(ClientContext);
useEffect(() => {
if (!client) {
return;
}
client.addPacketListener(type, fn);
return () => {
client.removePacketListener(type, fn);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client, ...dependencies]);
}

View File

@ -14,7 +14,9 @@ class WorkerServer extends Server {
return ['UNIVERSE', path].join('/');
}
async readAsset(path) {
return fetch(path);
return fetch(path).then((response) => (
response.ok ? response.arrayBuffer() : new ArrayBuffer(0)
));
}
async readData(path) {
const data = await get(this.constructor.qualify(path));

View File

@ -2,9 +2,8 @@ import {Container} from '@pixi/react';
import {useEffect, useState} from 'react';
import {RESOLUTION} from '@/constants.js';
import {useEcs} from '@/context/ecs.js';
import {useEcs, useEcsTick} from '@/context/ecs.js';
import {useMainEntity} from '@/context/main-entity.js';
import usePacket from '@/hooks/use-packet.js';
import Entities from './entities.jsx';
import TargetingGhost from './targeting-ghost.jsx';
@ -25,14 +24,11 @@ export default function EcsComponent() {
});
}
}, [ecs, mainEntity]);
usePacket('Tick', (payload) => {
if (0 === Object.keys(payload.ecs).length) {
return;
}
useEcsTick((payload) => {
if (
mainEntity
&& payload.ecs[mainEntity]
&& (payload.ecs[mainEntity].Inventory || payload.ecs[mainEntity].Wielder)
&& payload[mainEntity]
&& (payload[mainEntity].Inventory || payload[mainEntity].Wielder)
) {
ecs.get(mainEntity)
.Wielder.activeItem()
@ -41,8 +37,8 @@ export default function EcsComponent() {
});
}
const updatedEntities = {...entities};
for (const id in payload.ecs) {
const update = payload.ecs[id];
for (const id in payload) {
const update = payload[id];
if (false === update) {
delete updatedEntities[id];
}

View File

@ -1,3 +1,5 @@
import {Container} from '@pixi/react';
import Entity from './entity.jsx';
export default function Entities({entities}) {
@ -10,5 +12,11 @@ export default function Entities({entities}) {
/>
);
}
return <>{renderables}</>;
return (
<Container
sortableChildren
>
{renderables}
</Container>
);
}

View File

@ -35,7 +35,9 @@ function Entities({entity}) {
return false;
}
return (
<Container>
<Container
zIndex={entity.Position?.y}
>
{entity.Sprite && (
<Sprite
entity={entity}

View File

@ -6,6 +6,7 @@ import {BaseTexture} from '@pixi/core';
import {createElement, useContext} from 'react';
import {RESOLUTION} from '@/constants.js';
import AssetsContext from '@/context/assets.js';
import ClientContext from '@/context/client.js';
import DebugContext from '@/context/debug.js';
import EcsContext from '@/context/ecs.js';
@ -16,7 +17,7 @@ import styles from './pixi.module.css';
BaseTexture.defaultOptions.scaleMode = SCALE_MODES.NEAREST;
const Contexts = [ClientContext, DebugContext, EcsContext, MainEntityContext];
const Contexts = [AssetsContext, ClientContext, DebugContext, EcsContext, MainEntityContext];
const ContextBridge = ({children, render}) => {
const contexts = Contexts.map(useContext);

View File

@ -1,6 +1,6 @@
import {Sprite as PixiSprite} from '@pixi/react';
import useAsset from '@/hooks/use-asset.js';
import {useAsset} from '@/context/assets.js';
export default function Sprite({entity}) {
const asset = useAsset(entity.Sprite.source);

View File

@ -2,7 +2,7 @@ import {PixiComponent} from '@pixi/react';
import '@pixi/spritesheet'; // NECESSARY!
import {CompositeTilemap} from '@pixi/tilemap';
import useAsset from '@/hooks/use-asset.js';
import {useAsset} from '@/context/assets.js';
const TileLayerInternal = PixiComponent('TileLayer', {
create: () => new CompositeTilemap(),

View File

@ -1,12 +1,11 @@
import {useContext, useEffect, useState} from 'react';
import {useEffect, useState} from 'react';
import addKeyListener from '@/add-key-listener.js';
import {RESOLUTION} from '@/constants.js';
import ClientContext from '@/context/client.js';
import {useClient, usePacket} from '@/context/client.js';
import {useDebug} from '@/context/debug.js';
import {useEcs} from '@/context/ecs.js';
import {useEcs, useEcsTick} from '@/context/ecs.js';
import {useMainEntity} from '@/context/main-entity.js';
import usePacket from '@/hooks/use-packet.js';
import Disconnected from './disconnected.jsx';
import Dom from './dom.jsx';
@ -22,7 +21,7 @@ function emptySlots() {
export default function Ui({disconnected}) {
// Key input.
const client = useContext(ClientContext);
const client = useClient();
const [mainEntity, setMainEntity] = useMainEntity();
const [debug, setDebug] = useDebug();
const [ecs] = useEcs();
@ -150,15 +149,20 @@ export default function Ui({disconnected}) {
}
});
}, [client, debug, setDebug]);
usePacket('Tick', (payload) => {
usePacket('Tick', async (payload, client) => {
if (0 === Object.keys(payload.ecs).length) {
return;
}
ecs.apply(payload.ecs);
await ecs.apply(payload.ecs);
for (const listener of client.listeners[':Ecs'] ?? []) {
listener(payload.ecs);
}
}, [hotbarSlots, mainEntity, setMainEntity]);
useEcsTick((payload) => {
let localMainEntity = mainEntity;
for (const id in payload.ecs) {
for (const id in payload) {
const entity = ecs.get(id);
const update = payload.ecs[id];
const update = payload[id];
if (update.Sound?.play) {
for (const sound of update.Sound.play) {
(new Audio(sound)).play();

View File

@ -2,6 +2,7 @@ import {json} from "@remix-run/node";
import {useEffect, useState} from 'react';
import {useOutletContext, useParams} from 'react-router-dom';
import AssetsContext from '@/context/assets.js';
import ClientContext from '@/context/client.js';
import DebugContext from '@/context/debug.js';
import EcsContext from '@/context/ecs.js';
@ -12,6 +13,31 @@ import Systems from '@/ecs-systems/index.js';
import Ui from '@/react-components/ui.jsx';
import {juggleSession} from '@/session.server';
import {LRUCache} from 'lru-cache';
export const cache = new LRUCache({
max: 128,
});
class ClientEcs extends Ecs {
readAsset(uri) {
if (!cache.has(uri)) {
let promise, resolve, reject;
promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
cache.set(uri, promise);
fetch(new URL(uri, window.location.origin))
.then(async (response) => {
resolve(response.ok ? response.arrayBuffer() : new ArrayBuffer(0));
})
.catch(reject);
}
return cache.get(uri);
}
}
export async function loader({request}) {
await juggleSession(request);
return json({});
@ -19,10 +45,11 @@ export async function loader({request}) {
export default function PlaySpecific() {
const Client = useOutletContext();
const assetsTuple = useState({});
const [client, setClient] = useState();
const mainEntityTuple = useState();
const debugTuple = useState(false);
const ecsTuple = useState(new Ecs({Components, Systems}));
const ecsTuple = useState(new ClientEcs({Components, Systems}));
const [disconnected, setDisconnected] = useState(false);
const params = useParams();
const [type, url] = params['*'].split('/');
@ -96,7 +123,9 @@ export default function PlaySpecific() {
<MainEntityContext.Provider value={mainEntityTuple}>
<EcsContext.Provider value={ecsTuple}>
<DebugContext.Provider value={debugTuple}>
<Ui disconnected={disconnected} />
<AssetsContext.Provider value={assetsTuple}>
<Ui disconnected={disconnected} />
</AssetsContext.Provider>
</DebugContext.Provider>
</EcsContext.Provider>
</MainEntityContext.Provider>

View File

@ -69,16 +69,18 @@ export default class Script {
// }
// }
evaluateSync() {
this.sandbox.reset();
const {value: {value}} = this.sandbox.step();
return value;
}
static async fromCode(code, context = {}) {
let ast;
if (cache.has(code)) {
ast = cache.get(code);
}
else {
cache.set(code, ast = await this.parse(code));
if (!cache.has(code)) {
cache.set(code, this.parse(code));
}
return new this(
new Sandbox(ast, this.createContext(context)),
new Sandbox(await cache.get(code), this.createContext(context)),
);
}

View File

@ -48,6 +48,15 @@ class SocketServer extends Server {
static qualify(path) {
return join(import.meta.dirname, 'data', 'remote', 'UNIVERSE', path);
}
async readAsset(path) {
const url = new URL(path, 'https://localhost:3000')
if ('production' === process.env.NODE_ENV) {
url.protocol = 'http:';
}
return fetch(url.href).then((response) => (
response.ok ? response.arrayBuffer() : new ArrayBuffer(0)
));
}
async readData(path) {
const qualified = this.constructor.qualify(path);
await this.ensurePath(dirname(qualified));

View File

@ -5,9 +5,9 @@
"type": "module",
"scripts": {
"build": "remix vite:build",
"dev": "node ./server.js",
"dev": "NODE_OPTIONS=--use-openssl-ca node ./server.js",
"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_OPTIONS=--use-openssl-ca npm run dev",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build",
"test": "vitest app"

View File

@ -0,0 +1,13 @@
const {Sprite} = ecs.get(plant.entity);
if (plant.stage < 3) {
plant.stage += 1
}
if (4 === plant.stage) {
plant.stage = 3
}
if (3 !== plant.stage) {
plant.growth = 0
}
Sprite.animation = ['stage', plant.stage].join('/')

View File

@ -0,0 +1 @@
3 !== plant.stage

View File

@ -0,0 +1 @@
{"animations":{"stage/0":["tomato-plant/tomato-plant/0"],"stage/1":["tomato-plant/tomato-plant/1"],"stage/2":["tomato-plant/tomato-plant/2"],"stage/3":["tomato-plant/tomato-plant/3"],"stage/4":["tomato-plant/tomato-plant/4"]},"frames":{"tomato-plant/tomato-plant/0":{"frame":{"x":0,"y":0,"w":16,"h":32},"spriteSourceSize":{"x":0,"y":0,"w":16,"h":32},"sourceSize":{"w":16,"h":32}},"tomato-plant/tomato-plant/1":{"frame":{"x":16,"y":0,"w":16,"h":32},"spriteSourceSize":{"x":0,"y":0,"w":16,"h":32},"sourceSize":{"w":16,"h":32}},"tomato-plant/tomato-plant/2":{"frame":{"x":32,"y":0,"w":16,"h":32},"spriteSourceSize":{"x":0,"y":0,"w":16,"h":32},"sourceSize":{"w":16,"h":32}},"tomato-plant/tomato-plant/3":{"frame":{"x":48,"y":0,"w":16,"h":32},"spriteSourceSize":{"x":0,"y":0,"w":16,"h":32},"sourceSize":{"w":16,"h":32}},"tomato-plant/tomato-plant/4":{"frame":{"x":64,"y":0,"w":16,"h":32},"spriteSourceSize":{"x":0,"y":0,"w":16,"h":32},"sourceSize":{"w":16,"h":32}}},"meta":{"format":"RGBA8888","image":"./tomato-plant.png","scale":1,"size":{"w":80,"h":32}}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB