Compare commits

...

27 Commits

Author SHA1 Message Date
cha0s
782fdfc28c feat: script HMR 2024-10-21 03:47:55 -05:00
cha0s
a2a33536e7 refactor: hot systems perf 2024-10-21 03:25:17 -05:00
cha0s
70cc56c7b6 chore: opt 2024-10-21 02:41:37 -05:00
cha0s
f522c61307 chore: opt 2024-10-21 02:41:31 -05:00
cha0s
9b9b06f1dc refactor: item pickup 2024-10-21 02:37:27 -05:00
cha0s
739a3e4f95 refactor: entity $$extends 2024-10-21 02:36:41 -05:00
cha0s
34247e00db feat: isCollidinig 2024-10-21 02:36:11 -05:00
cha0s
26b3f5049c refactor: simple 2024-10-19 11:55:27 -05:00
cha0s
29471d8b5d refactor: json extends, projectile 2024-10-19 11:43:32 -05:00
cha0s
6e8f74b369 refactor: entity json 2024-10-19 10:56:57 -05:00
cha0s
2fe09d3cb2 chore: format 2024-10-19 10:56:49 -05:00
cha0s
6a6be49a61 refactor: resource types 2024-10-19 08:40:35 -05:00
cha0s
a8d32e2b01 refactor: shots 2024-10-19 05:07:19 -05:00
cha0s
6c815e7749 refactor: harm 2024-10-18 07:49:15 -05:00
cha0s
b2b52b5414 fix: control locking 2024-10-18 06:30:57 -05:00
cha0s
6a2dddef0e fix: given/qtyUpdated 2024-10-18 05:35:32 -05:00
cha0s
be1e162da5 perf: item collision group 2024-10-18 05:35:07 -05:00
cha0s
603d2035f0 fun: no magnet 2024-10-18 02:08:04 -05:00
cha0s
26f69b4d6b fix: typo 2024-10-18 02:07:53 -05:00
cha0s
66e6c4a727 fix: escapes 2024-10-18 01:38:16 -05:00
cha0s
6f094d02c2 fix: projection check 2024-10-18 01:26:02 -05:00
cha0s
2041b38678 refactor!: scrips n tickers 2024-10-18 00:37:31 -05:00
cha0s
bea551fa19 refactor: forces 2024-10-17 23:13:53 -05:00
cha0s
68b908ec43 feat: zIndex 2024-10-17 23:07:32 -05:00
cha0s
62f1584a42 refactor: minimal 2024-10-16 18:49:20 -05:00
cha0s
8b720619a5 chore: tree shaking 2024-10-16 18:47:48 -05:00
cha0s
611b41e96a refactor: sync 2024-10-16 06:05:06 -05:00
135 changed files with 35056 additions and 6955 deletions

View File

@ -28,6 +28,7 @@ module.exports = {
extends: ['eslint:recommended'],
rules: {
'no-constant-condition': ['error', {checkLoops: false}],
'require-yield': 0,
},
overrides: [
@ -60,10 +61,10 @@ module.exports = {
],
},
rules: {
'react/prop-types': 'off',
'jsx-a11y/label-has-associated-control': [2, {
controlComponents: ['SliderText'],
}],
'react/prop-types': 'off',
},
},
@ -80,14 +81,5 @@ module.exports = {
},
},
// Assets
{
files: [
'resources/**/*.js',
],
rules: {
'no-undef': 0,
},
}
],
};

View File

@ -58,7 +58,7 @@ const interpolate = () => {
}
requestAnimationFrame(interpolate);
onmessage = async (event) => {
onmessage = (event) => {
const packet = event.data;
switch (packet.type) {
case 'EcsChange': {

View File

@ -71,11 +71,9 @@ function applyClientActions(elapsed) {
}
}
let downPromise;
const pending = new Map();
onmessage = async (event) => {
onmessage = (event) => {
const [flow, packet] = event.data;
switch (flow) {
case Flow.UP: {
@ -114,53 +112,51 @@ onmessage = async (event) => {
break;
}
case Flow.DOWN: {
downPromise = Promise.resolve(downPromise).then(async () => {
switch (packet.type) {
case 'ActionAck': {
const action = actions.get(packet.payload.ack);
action.ack = true;
return;
}
case 'EcsChange': {
ecs = new PredictionEcs({Components, Systems});
mainEntityId = 0;
break;
}
case 'Tick': {
for (const entityId in packet.payload.ecs) {
if (packet.payload.ecs[entityId]) {
if (packet.payload.ecs[entityId].MainEntity) {
mainEntityId = parseInt(entityId);
}
}
}
await ecs.apply(packet.payload.ecs);
if (actions.size > 0) {
const main = ecs.get(mainEntityId);
const authoritative = structuredClone(main.toNet(main));
applyClientActions(packet.payload.elapsed);
if (ecs.diff[mainEntityId]) {
packet.payload.ecs[mainEntityId] ??= {};
ecs.mergeDiff(
packet.payload.ecs[mainEntityId],
ecs.diff[mainEntityId],
);
const reset = {};
for (const componentName in ecs.diff[mainEntityId]) {
reset[componentName] = {};
for (const property in ecs.diff[mainEntityId][componentName]) {
reset[componentName][property] = authoritative[componentName][property];
}
}
await ecs.apply({[mainEntityId]: reset});
}
}
ecs.setClean();
break;
}
switch (packet.type) {
case 'ActionAck': {
const action = actions.get(packet.payload.ack);
action.ack = true;
return;
}
postMessage([1, packet]);
});
case 'EcsChange': {
ecs = new PredictionEcs({Components, Systems});
mainEntityId = 0;
break;
}
case 'Tick': {
for (const entityId in packet.payload.ecs) {
if (packet.payload.ecs[entityId]) {
if (packet.payload.ecs[entityId].MainEntity) {
mainEntityId = parseInt(entityId);
}
}
}
ecs.apply(packet.payload.ecs);
if (actions.size > 0) {
const main = ecs.get(mainEntityId);
const authoritative = structuredClone(main.toNet(main));
applyClientActions(packet.payload.elapsed);
if (ecs.diff[mainEntityId]) {
packet.payload.ecs[mainEntityId] ??= {};
ecs.mergeDiff(
packet.payload.ecs[mainEntityId],
ecs.diff[mainEntityId],
);
const reset = {};
for (const componentName in ecs.diff[mainEntityId]) {
reset[componentName] = {};
for (const property in ecs.diff[mainEntityId][componentName]) {
reset[componentName][property] = authoritative[componentName][property];
}
}
ecs.apply({[mainEntityId]: reset});
}
}
ecs.setClean();
break;
}
}
postMessage([1, packet]);
break;
}
}

View File

@ -27,12 +27,12 @@ export default class Component {
return results;
}
async create(entityId, values) {
const [created] = await this.createMany([[entityId, values]]);
create(entityId, values) {
const [created] = this.createMany([[entityId, values]]);
return created;
}
async createMany(entries) {
createMany(entries) {
if (0 === entries.length) {
return [];
}
@ -61,7 +61,6 @@ export default class Component {
}
}
}
const promises = [];
for (let i = 0; i < entries.length; ++i) {
const [entityId, values] = entries[i];
const instance = allocated[i];
@ -78,9 +77,8 @@ export default class Component {
: defaults[key];
}
instance.initialize(values, defaultValues);
promises.push(this.load(instance));
this.load(instance);
}
await Promise.all(promises);
return allocated;
}
@ -125,8 +123,8 @@ export default class Component {
return this.instances[entityId];
}
async insertMany(entities) {
await this.createMany(entities);
insertMany(entities) {
this.createMany(entities);
}
instanceFromSchema() {
@ -161,7 +159,7 @@ export default class Component {
toJSON() {
return this.toFullJSON();
}
async update(values) {
update(values) {
for (const key in values) {
if (concrete.properties[key]) {
this[`$$${key}`] = values[key];
@ -198,7 +196,7 @@ export default class Component {
return Instance;
}
async load(instance) {
load(instance) {
return instance;
}
@ -228,13 +226,11 @@ export default class Component {
return this.constructor.schema.sizeOf(this.get(entityId));
}
async updateMany(entities) {
const promises = [];
updateMany(entities) {
for (let i = 0; i < entities.length; i++) {
const [entityId, values] = entities[i];
promises.push(this.get(entityId).update(values));
this.get(entityId).update(values)
}
return Promise.all(promises);
}
}

View File

@ -4,26 +4,26 @@ import Component from './component.js';
const fakeEcs = {markChange() {}};
test('creates instances', async () => {
test('creates instances', () => {
class CreatingComponent extends Component {
static properties = {
foo: {defaultValue: 'bar', type: 'string'},
};
}
const ComponentInstance = new CreatingComponent(fakeEcs);
await ComponentInstance.create(1);
ComponentInstance.create(1);
expect(ComponentInstance.get(1).entity)
.to.equal(1);
});
test('does not serialize default values', async () => {
test.skip('does not serialize default values', () => {
class CreatingComponent extends Component {
static properties = {
foo: {defaultValue: 'bar', type: 'string'}, bar: {type: 'uint8'},
};
}
const ComponentInstance = new CreatingComponent(fakeEcs);
await ComponentInstance.create(1)
ComponentInstance.create(1)
expect(ComponentInstance.get(1).toJSON())
.to.deep.equal({});
ComponentInstance.get(1).bar = 1;
@ -31,14 +31,14 @@ test('does not serialize default values', async () => {
.to.deep.equal({bar: 1});
});
test('reuses instances', async () => {
test('reuses instances', () => {
class ReusingComponent extends Component {
static properties = {
foo: {type: 'string'},
};
}
const ComponentInstance = new ReusingComponent(fakeEcs);
await ComponentInstance.create(1);
ComponentInstance.create(1);
const instance = ComponentInstance.get(1);
ComponentInstance.destroy(1);
expect(ComponentInstance.get(1))
@ -47,7 +47,7 @@ test('reuses instances', async () => {
ComponentInstance.destroy(1);
})
.to.throw();
await ComponentInstance.create(1);
ComponentInstance.create(1);
expect(ComponentInstance.get(1))
.to.equal(instance);
});

View File

@ -19,28 +19,27 @@ export default class Alive extends Component {
this.$$dead = true;
const {Ticking} = ecs.get(this.entity);
if (Ticking) {
this.$$death.context.entity = ecs.get(this.entity);
this.$$death.locals.entity = ecs.get(this.entity);
const ticker = this.$$death.ticker();
ecs.addDestructionDependency(this.entity.id, ticker);
Ticking.add(ticker);
ecs.addDestructionDependency(this.entity.id, Ticking.add(ticker));
}
}
};
}
async load(instance) {
load(instance) {
if (0 === instance.maxHealth) {
instance.maxHealth = instance.health;
}
// heavy handed...
if ('undefined' !== typeof window) {
return;
}
instance.$$death = await this.ecs.readScript(
instance.$$death = this.ecs.readScript(
instance.deathScript,
{
ecs: this.ecs,
},
);
if (0 === instance.maxHealth) {
instance.maxHealth = instance.health;
}
}
static properties = {
deathScript: {

View File

@ -0,0 +1,20 @@
import Component from '@/ecs/component.js';
export default class Arbitrary extends Component {
instanceFromSchema() {
return class ArbitraryInstance extends super.instanceFromSchema() {
bag = {};
toFullJSON() {
return {
blob: JSON.stringify(this.bag),
};
}
};
}
load(instance) {
instance.bag = JSON.parse(instance.blob);
}
static properties = {
blob: {type: 'string'},
};
}

View File

@ -8,27 +8,21 @@ export default class Behaving extends Component {
tick(elapsed) {
const routine = this.$$routineInstances[this.currentRoutine];
if (routine) {
routine.context.entity = ecs.get(this.entity);
routine.locals.ecs = ecs;
routine.locals.entity = ecs.get(this.entity);
routine.tick(elapsed);
}
}
};
}
async load(instance) {
load(instance) {
// heavy handed...
if ('undefined' !== typeof window) {
return;
}
const promises = [];
for (const key in instance.routines) {
promises.push(
this.ecs.readScript(instance.routines[key])
.then((script) => {
instance.$$routineInstances[key] = script;
}),
);
instance.$$routineInstances[key] = this.ecs.readScript(instance.routines[key]);
}
await Promise.all(promises);
}
static properties = {
currentRoutine: {defaultValue: 'initial', type: 'string'},

View File

@ -35,6 +35,9 @@ export default class Collider extends Component {
return aabbs;
}
checkCollision(other) {
if (!this.isColliding || !other.isColliding) {
return;
}
const otherEntity = ecs.get(other.entity);
const thisEntity = ecs.get(this.entity);
const intersections = this.intersectionsWith(other);
@ -76,23 +79,23 @@ export default class Collider extends Component {
if (!hasMatchingIntersection) {
if (this.$$collisionStart) {
const script = this.$$collisionStart.clone();
script.context.entity = thisEntity;
script.context.other = otherEntity;
script.context.pair = [body, otherBody];
script.locals.entity = thisEntity;
script.locals.other = otherEntity;
script.locals.pair = [body, otherBody];
const ticker = script.ticker();
ecs.addDestructionDependency(otherEntity.id, ticker);
ecs.addDestructionDependency(thisEntity.id, ticker);
thisEntity.Ticking.add(ticker);
const promise = thisEntity.Ticking.add(ticker);
ecs.addDestructionDependency(otherEntity.id, promise);
ecs.addDestructionDependency(thisEntity.id, promise);
}
if (other.$$collisionStart) {
const script = other.$$collisionStart.clone();
script.context.entity = otherEntity;
script.context.other = thisEntity;
script.context.pair = [otherBody, body];
script.locals.entity = otherEntity;
script.locals.other = thisEntity;
script.locals.pair = [otherBody, body];
const ticker = script.ticker();
ecs.addDestructionDependency(otherEntity.id, ticker);
ecs.addDestructionDependency(thisEntity.id, ticker);
otherEntity.Ticking.add(ticker);
const promise = otherEntity.Ticking.add(ticker);
ecs.addDestructionDependency(otherEntity.id, promise);
ecs.addDestructionDependency(thisEntity.id, promise);
}
activeIntersections.add(intersection);
}
@ -161,21 +164,21 @@ export default class Collider extends Component {
];
if (this.$$collisionEnd) {
const script = this.$$collisionEnd.clone();
script.context.other = otherEntity;
script.context.pair = [body, otherBody];
script.locals.other = otherEntity;
script.locals.pair = [body, otherBody];
const ticker = script.ticker();
ecs.addDestructionDependency(thisEntity.id, ticker);
ecs.addDestructionDependency(otherEntity.id, ticker);
thisEntity.Ticking.add(ticker);
const promise = thisEntity.Ticking.add(ticker);
ecs.addDestructionDependency(thisEntity.id, promise);
ecs.addDestructionDependency(otherEntity.id, promise);
}
if (other.$$collisionEnd) {
const script = other.$$collisionEnd.clone();
script.context.other = thisEntity;
script.context.pair = [otherBody, body];
script.locals.other = thisEntity;
script.locals.pair = [otherBody, body];
const ticker = script.ticker();
ecs.addDestructionDependency(thisEntity.id, ticker);
ecs.addDestructionDependency(otherEntity.id, ticker);
otherEntity.Ticking.add(ticker);
const promise = otherEntity.Ticking.add(ticker);
ecs.addDestructionDependency(thisEntity.id, promise);
ecs.addDestructionDependency(otherEntity.id, promise);
}
}
this.$$intersections.delete(other);
@ -255,7 +258,7 @@ export default class Collider extends Component {
}
}
}
async load(instance) {
load(instance) {
for (const i in instance.bodies) {
instance.bodies[i] = {
...this.constructor.schema.constructor.defaultValue(
@ -269,13 +272,13 @@ export default class Collider extends Component {
if ('undefined' !== typeof window) {
return;
}
instance.$$collisionEnd = await this.ecs.readScript(
instance.$$collisionEnd = this.ecs.readScript(
instance.collisionEndScript,
{
ecs: this.ecs,
},
);
instance.$$collisionStart = await this.ecs.readScript(
instance.$$collisionStart = this.ecs.readScript(
instance.collisionStartScript,
{
ecs: this.ecs,
@ -306,5 +309,6 @@ export default class Collider extends Component {
},
collisionEndScript: {type: 'string'},
collisionStartScript: {type: 'string'},
isColliding: {defaultValue: 1, type: 'uint8'},
};
}

View File

@ -6,21 +6,17 @@ export default class Controlled extends Component {
directionMove(direction) {
const x = Math.cos(direction);
if (x > 0) {
this.moveLeft = 0;
this.moveRight = x;
this.moveRight += x;
}
else {
this.moveLeft = -x;
this.moveRight = 0;
this.moveLeft -= x;
}
const y = Math.sin(direction);
if (y > 0) {
this.moveUp = 0;
this.moveDown = y;
this.moveDown += y;
}
else {
this.moveUp = -y;
this.moveDown = 0;
this.moveUp -= y;
}
}
stop() {
@ -35,6 +31,7 @@ export default class Controlled extends Component {
}
}
static properties = {
locked: {type: 'uint8'},
moveUp: {type: 'float32'},
moveRight: {type: 'float32'},
moveDown: {type: 'float32'},

View File

@ -1,7 +1,7 @@
import Component from '@/ecs/component.js';
import Emitter from '@/particles/emitter.js';
import {Ticker as TickerPromise} from '@/util/promise.js';
import Ticker from '@/util/ticker.js';
export default class EmitterComponent extends Component {
instanceFromSchema() {
@ -12,21 +12,20 @@ export default class EmitterComponent extends Component {
id = 0;
emit(specification) {
if (specification.server) {
const {Ticker} = ecs.get(1);
if (Ticker) {
const master = ecs.get(1);
if (master.Ticking) {
const emitter = new Emitter(ecs);
const promise = new Promise((resolve) => {
emitter.emit().onEnd(resolve);
});
Ticker.add(
new TickerPromise(
(resolve) => {
promise.then(resolve);
},
(elapsed) => {
master.Ticking.add(
new Ticker(function* () {
let emitting = true;
emitter.emit().onEnd(() => {
emitting = false;
});
while (emitting) {
const elapsed = yield;
this.emitter.tick(elapsed);
}
)
}),
);
}
}

View File

@ -4,6 +4,16 @@ export default class Forces extends Component {
instanceFromSchema() {
const {ecs} = this;
return class ForcesInstance extends super.instanceFromSchema() {
applyForce({x, y}) {
this.$$forceX += x;
this.$$forceY += y;
ecs.markChange(this.entity, {
Forces: {
forceX: this.$$forceX,
forceY: this.$$forceY,
},
});
}
applyImpulse({x, y}) {
this.$$impulseX += x;
this.$$impulseY += y;
@ -17,6 +27,8 @@ export default class Forces extends Component {
}
}
static properties = {
forceX: {type: 'float32'},
forceY: {type: 'float32'},
impulseX: {type: 'float32'},
impulseY: {type: 'float32'},
};

View File

@ -0,0 +1,3 @@
import Component from '@/ecs/component.js';
export default class Grabber extends Component {}

View File

@ -0,0 +1,31 @@
import Component from '@/ecs/component.js';
export default class Harmful extends Component {
instanceFromSchema() {
const {ecs} = this;
return class HarmfulInstance extends super.instanceFromSchema() {
harm(other) {
const entity = ecs.get(this.entity);
const script = this.$$harm.clone();
script.locals.other = other;
script.locals.entity = entity;
entity.Ticking.add(script.ticker());
}
}
}
load(instance) {
// heavy handed...
if ('undefined' !== typeof window) {
return;
}
instance.$$harm = this.ecs.readScript(
instance.harmScript,
{
ecs: this.ecs,
},
);
}
static properties = {
harmScript: {type: 'string'},
};
}

View File

@ -4,15 +4,30 @@ const Gathered = gather(
import.meta.glob(['./*.js', '!./*.test.js'], {eager: true, import: 'default'}),
);
let wrapComponent;
if (import.meta.env.PROD) {
wrapComponent = (componentName, Component) => (
class extends Component {
static componentName = componentName;
}
);
}
else {
const {default: ieval} = await import('@/util/eval.js');
wrapComponent = (componentName, Component) => (
ieval(`
((Component) => (
class ${componentName} extends Component {
static componentName = '${componentName}';
}
))
`)(Component)
);
}
const Components = {};
for (const componentName in Gathered) {
Components[componentName] = eval(`
((Gathered) => (
class ${componentName} extends Gathered['${componentName}'] {
static componentName = '${componentName}';
}
))
`)(Gathered);
Components[componentName] = wrapComponent(componentName, Gathered[componentName]);
}
export default Components;

View File

@ -7,9 +7,9 @@ export default class Interactive extends Component {
$$interact;
interact(initiator) {
const script = this.$$interact.clone();
script.context.initiator = initiator;
script.context.subject = ecs.get(this.entity);
const {Ticking} = script.context.subject;
script.locals.initiator = initiator;
script.locals.subject = ecs.get(this.entity);
const {Ticking} = script.locals.subject;
Ticking.add(script.ticker());
}
get interacting() {
@ -20,12 +20,12 @@ export default class Interactive extends Component {
}
}
}
async load(instance) {
load(instance) {
// heavy handed...
if ('undefined' !== typeof window) {
return;
}
instance.$$interact = await this.ecs.readScript(
instance.$$interact = this.ecs.readScript(
instance.interactScript,
{
ecs: this.ecs,

View File

@ -3,25 +3,29 @@ import Component from '@/ecs/component.js';
import {distribute} from '@/util/inventory.js';
class ItemProxy {
scripts = {};
constructor(Component, instance, slot) {
this.Component = Component;
this.instance = instance;
this.slot = slot;
}
async load(source) {
load(source) {
const {ecs} = this.Component;
const json = await ecs.readJson(source);
const json = ecs.readJson(source);
this.json = json;
const scripts = {};
if (json.projectionCheck) {
scripts.projectionCheckInstance = await ecs.readScript(json.projectionCheck);
scripts.projectionCheckInstance = ecs.readScript(json.projectionCheck);
}
if (json.start) {
scripts.startInstance = await ecs.readScript(json.start);
// heavy handed...
if ('undefined' === typeof window) {
if (json.start) {
scripts.startInstance = ecs.readScript(json.start);
}
if (json.stop) {
scripts.stopInstance = ecs.readScript(json.stop);
}
}
if (json.stop) {
scripts.stopInstance = await ecs.readScript(json.stop);
}
this.json = json;
this.scripts = scripts;
}
project(position, direction) {
@ -82,8 +86,8 @@ class ItemProxy {
}
}
if (this.scripts.projectionCheckInstance) {
this.scripts.projectionCheckInstance.context.ecs = this.Component.ecs;
this.scripts.projectionCheckInstance.context.projected = projected;
this.scripts.projectionCheckInstance.locals.ecs = this.Component.ecs;
this.scripts.projectionCheckInstance.locals.projected = projected;
return this.scripts.projectionCheckInstance.evaluate();
}
else {
@ -128,7 +132,7 @@ class ItemProxy {
}
export default class Inventory extends Component {
async updateMany(entities) {
updateMany(entities) {
for (const [id, {cleared, given, qtyUpdated, swapped}] of entities) {
const instance = this.get(id);
const {$$items, slots} = instance;
@ -141,7 +145,7 @@ export default class Inventory extends Component {
if (given) {
for (const slot in given) {
$$items[slot] = new ItemProxy(this, instance, slot);
await $$items[slot].load(given[slot].source);
$$items[slot].load(given[slot].source);
slots[slot] = given[slot];
}
}
@ -166,14 +170,14 @@ export default class Inventory extends Component {
}
}
}
await super.updateMany(entities);
super.updateMany(entities);
for (const [id, {slots}] of entities) {
if (slots) {
const instance = this.get(id);
instance.$$items = {};
for (const slot in slots) {
instance.$$items[slot] = new ItemProxy(this, instance, slot);
await instance.$$items[slot].load(instance.slots[slot].source);
instance.$$items[slot].load(instance.slots[slot].source);
}
}
}
@ -189,7 +193,7 @@ export default class Inventory extends Component {
delete this.slots[slot];
delete this.$$items[slot];
}
async distribute(slot, potentialDestinations) {
distribute(slot, potentialDestinations) {
const {slots} = this;
if (!slots[slot]) {
return;
@ -230,7 +234,7 @@ export default class Inventory extends Component {
Inventory.slots[destination] = {...stack};
Inventory.$$items[destination] = new ItemProxy(Component, Inventory, destination);
Component.markChange(entityId, 'given', {[destination]: {...stack}});
await Inventory.$$items[destination].load(source);
Inventory.$$items[destination].load(source);
}
}
// update qty of existing
@ -255,12 +259,17 @@ export default class Inventory extends Component {
item(slot) {
return this.$$items[slot];
}
async give(stack) {
give(stack) {
const {slots} = this;
for (let slot = 1; slot < 11; ++slot) {
if (slots[slot]?.source === stack.source) {
slots[slot].qty += stack.qty;
Component.markChange(this.entity, 'qtyUpdated', {[slot]: stack.qty});
if (ecs.diff[this.entity]?.Inventory?.given?.[slot]) {
ecs.diff[this.entity].Inventory.given[slot].qty += stack.qty;
}
else {
Component.markChange(this.entity, 'qtyUpdated', {[slot]: stack.qty});
}
return;
}
}
@ -269,7 +278,7 @@ export default class Inventory extends Component {
slots[slot] = {...stack};
this.$$items[slot] = new ItemProxy(Component, this, slot);
Component.markChange(this.entity, 'given', {[slot]: {...stack}});
await this.$$items[slot].load(stack.source);
this.$$items[slot].load(stack.source);
return;
}
}
@ -305,10 +314,10 @@ export default class Inventory extends Component {
}
}
}
async load(instance) {
load(instance) {
for (const slot in instance.slots) {
instance.$$items[slot] = new ItemProxy(this, instance, slot);
await instance.$$items[slot].load(instance.slots[slot].source);
instance.$$items[slot].load(instance.slots[slot].source);
}
}
mergeDiff(original, update) {

View File

@ -0,0 +1,21 @@
import Component from '@/ecs/component.js';
export default class ItemStack extends Component {
instanceFromSchema() {
const {ecs} = this;
return class ItemStackInstance extends super.instanceFromSchema() {
pickup(other) {
const {Collider} = ecs.get(this.entity);
if (Collider) {
Collider.isColliding = 0;
}
other.Inventory.give(this.toJSON());
ecs.destroy(this.entity);
}
};
}
static properties = {
qty: {type: 'uint32'},
source: {type: 'string'},
};
}

View File

@ -16,19 +16,19 @@ export default class Plant extends Component {
}
};
}
async load(instance) {
load(instance) {
// heavy handed...
if ('undefined' !== typeof window) {
return;
}
instance.$$grow = await this.ecs.readScript(
instance.$$grow = this.ecs.readScript(
instance.growScript,
{
ecs: this.ecs,
plant: instance,
},
);
instance.$$mayGrow = await this.ecs.readScript(
instance.$$mayGrow = this.ecs.readScript(
instance.mayGrowScript,
{
ecs: this.ecs,

View File

@ -191,9 +191,9 @@ export default class Sprite extends Component {
}
};
}
async load(instance) {
load(instance) {
if (instance.source) {
instance.$$sourceJson = await this.ecs.readJson(instance.source);
instance.$$sourceJson = this.ecs.readJson(instance.source);
}
}
markChange(entityId, key, value) {
@ -215,5 +215,6 @@ export default class Sprite extends Component {
source: {type: 'string'},
speed: {type: 'float32'},
tint: {defaultValue: 0xffffff, type: 'uint32'},
zIndex: {defaultValue: 65535, type: 'uint16'},
};
}

View File

@ -1,4 +1,5 @@
import Component from '@/ecs/component.js';
import {withResolvers} from '@/util/promise.js';
export default class Ticking extends Component {
instanceFromSchema() {
@ -7,13 +8,9 @@ export default class Ticking extends Component {
$$tickers = [];
add(ticker) {
this.$$tickers.push(ticker);
ticker.then(() => {
this.$$tickers.splice(
this.$$tickers.indexOf(ticker),
1,
);
});
const resolvers = withResolvers();
this.$$tickers.push({resolvers, ticker});
return resolvers.promise;
}
destroy() {
@ -21,8 +18,14 @@ export default class Ticking extends Component {
}
tick(elapsed) {
for (const ticker of this.$$tickers) {
ticker.tick(elapsed);
for (let i = 0; i < this.$$tickers.length; ++i) {
const {resolvers, ticker} = this.$$tickers[i];
const result = ticker.tick(elapsed);
if (result.done) {
resolvers.resolve();
this.$$tickers.splice(i, 1);
i -= 1;
}
}
}

View File

@ -98,9 +98,9 @@ class LayerProxy {
get layer() {
return this.instance.layers[this.index];
}
async load() {
load() {
this.$$sourceJson = this.layer.source
? await this.Component.ecs.readJson(this.layer.source)
? this.Component.ecs.readJson(this.layer.source)
: {};
}
get source() {
@ -139,7 +139,7 @@ class LayerProxy {
}
export default class TileLayers extends Component {
async createMany(entities) {
createMany(entities) {
for (const [, {layerChange, layers}] of entities) {
if (layers) {
for (const layer of layers) {
@ -164,7 +164,7 @@ export default class TileLayers extends Component {
}
return super.createMany(entities);
}
async updateMany(entities) {
updateMany(entities) {
for (const [, {layers}] of entities) {
if (layers) {
for (const layer of layers) {
@ -179,7 +179,7 @@ export default class TileLayers extends Component {
}
}
}
await super.updateMany(entities);
super.updateMany(entities);
for (const [id, {layerChange}] of entities) {
if (layerChange) {
const component = this.get(id);
@ -208,10 +208,10 @@ export default class TileLayers extends Component {
}
}
}
async load(instance) {
load(instance) {
for (const index in instance.layers) {
instance.$$layersProxies[index] = new LayerProxy(instance, this, index);
await instance.$$layersProxies[index].load();
instance.$$layersProxies[index].load();
}
}
mergeDiff(original, update) {

View File

@ -4,14 +4,14 @@ import Ecs from '@/ecs/ecs.js';
import TileLayers from './tile-layers.js';
test('creates hulls', async () => {
test('creates hulls', () => {
const Component = new TileLayers(new Ecs());
const data = Array(64).fill(0);
data[9] = 1;
data[10] = 1;
data[17] = 1;
data[18] = 1;
const layers = await Component.create(1, {
const layers = Component.create(1, {
layers: [
{
area: {x: 8, y: 8},

View File

@ -1,4 +1,6 @@
import Component from '@/ecs/component.js';
import * as Math from '@/util/math.js';
import Ticker from '@/util/ticker.js';
export const DamageTypes = {
PAIN: 0,
@ -21,12 +23,20 @@ export default class Vulnerable extends Component {
const Component = this;
return class VulnerableInstance extends super.instanceFromSchema() {
id = 0;
locked = new Set();
Types = DamageTypes;
damage(specification) {
damage(fullSpecification) {
if (this.isInvulnerable) {
return;
}
const {Alive} = Component.ecs.get(this.entity);
const {
lockout,
...specification
} = fullSpecification;
if (lockout && this.locked.has(lockout.subject)) {
return;
}
const {Alive, Forces, Position, Ticking} = Component.ecs.get(this.entity);
if (Alive) {
switch (specification.type) {
case DamageTypes.HEALING:
@ -35,6 +45,28 @@ export default class Vulnerable extends Component {
}
}
}
if (specification.knockback) {
const diff = Math.normalizeVector({
x: Position.x - specification.knockback.origin.x,
y: Position.y - specification.knockback.origin.y,
});
Forces.applyImpulse({
x: diff.x * specification.knockback.magnitude,
y: diff.y * specification.knockback.magnitude,
});
}
if (lockout) {
let {duration, subject} = lockout;
const self = this;
self.locked.add(subject);
Ticking.add(new Ticker(function*() {
while (duration > 0) {
const elapsed = yield;
duration -= elapsed;
}
self.locked.delete(subject);
}));
}
Component.markChange(
this.entity,
'damage',

View File

@ -17,10 +17,10 @@ export default class Wielder extends Component {
let script = state ? startInstance : stopInstance;
if (script) {
script = script.clone();
script.context.ecs = ecs;
script.context.item = activeItem;
script.context.where = where;
script.context.wielder = entity;
script.locals.ecs = ecs;
script.locals.item = activeItem;
script.locals.where = where;
script.locals.wielder = entity;
Ticking.add(script.ticker());
}
}

View File

@ -1,15 +1,10 @@
import {Encoder, Decoder} from '@msgpack/msgpack';
import {LRUCache} from 'lru-cache';
import {withResolvers} from '@/util/promise.js';
import Script from '@/util/script.js';
import EntityFactory from './entity-factory.js';
const cache = new LRUCache({
max: 128,
});
const decoder = new Decoder();
const encoder = new Encoder();
@ -58,7 +53,7 @@ export default class Ecs {
});
}
async apply(patch) {
apply(patch) {
const creating = [];
const destroying = new Set();
const inserting = [];
@ -111,17 +106,15 @@ export default class Ecs {
creating.push([entityId, componentsToUpdate]);
}
}
const promises = [];
if (inserting.length > 0) {
promises.push(this.insertMany(inserting));
this.insertMany(inserting);
}
if (updating.length > 0) {
promises.push(this.updateMany(updating));
this.updateMany(updating);
}
if (creating.length > 0) {
promises.push(this.createManySpecific(creating));
this.createManySpecific(creating);
}
await Promise.all(promises);
if (destroying.size > 0) {
this.destroyMany(destroying);
}
@ -175,17 +168,17 @@ export default class Ecs {
};
}
async create(components = {}) {
const [entityId] = await this.createMany([components]);
create(components = {}) {
const [entityId] = this.createMany([components]);
return entityId;
}
async createDetached(components = {}) {
const [entityId] = await this.createManyDetached([components]);
createDetached(components = {}) {
const [entityId] = this.createManyDetached([components]);
return entityId;
}
async createMany(componentsList) {
createMany(componentsList) {
const specificsList = [];
for (const components of componentsList) {
specificsList.push([this.$$caret, components]);
@ -194,7 +187,7 @@ export default class Ecs {
return this.createManySpecific(specificsList);
}
async createManyDetached(componentsList) {
createManyDetached(componentsList) {
const specificsList = [];
for (const components of componentsList) {
specificsList.push([this.$$caret, components]);
@ -205,14 +198,33 @@ export default class Ecs {
return this.createManySpecific(specificsList);
}
async createManySpecific(specificsList) {
if (0 === specificsList.length) {
return;
}
createManySpecific(specificsList) {
const entityIds = new Set();
if (0 === specificsList.length) {
return entityIds;
}
const creating = {};
const extendedSpecificsList = [];
for (let i = 0; i < specificsList.length; i++) {
const [entityId, components] = specificsList[i];
if (!specificsList[i][1].$$extends) {
extendedSpecificsList.push(specificsList[i]);
continue;
}
const [entityId, rawComponents] = specificsList[i];
let {
$$extends,
...components
} = rawComponents;
if ($$extends) {
components = {
...this.readJson($$extends),
...components,
};
}
extendedSpecificsList.push([entityId, components]);
}
for (let i = 0; i < extendedSpecificsList.length; i++) {
const [entityId, components] = extendedSpecificsList[i];
if (!this.$$detached.has(entityId)) {
this.deferredChanges[entityId] = [];
}
@ -231,13 +243,11 @@ export default class Ecs {
}
this.markChange(entityId, components);
}
const promises = [];
for (const i in creating) {
promises.push(this.Components[i].createMany(creating[i]));
this.Components[i].createMany(creating[i])
}
await Promise.all(promises);
for (let i = 0; i < specificsList.length; i++) {
const [entityId, components] = specificsList[i];
for (let i = 0; i < extendedSpecificsList.length; i++) {
const [entityId, components] = extendedSpecificsList[i];
this.rebuild(entityId, () => Object.keys(components));
if (this.$$detached.has(entityId)) {
continue;
@ -248,8 +258,8 @@ export default class Ecs {
return entityIds;
}
async createSpecific(entityId, components) {
const [created] = await this.createManySpecific([[entityId, components]]);
createSpecific(entityId, components) {
const [created] = this.createManySpecific([[entityId, components]]);
return created;
}
@ -272,7 +282,7 @@ export default class Ecs {
}
}
static async deserialize(ecs, view) {
static deserialize(ecs, view) {
const componentNames = Object.keys(ecs.Components);
const {entities, systems} = decoder.decode(view.buffer);
for (const system of systems) {
@ -294,7 +304,7 @@ export default class Ecs {
]);
}
ecs.$$caret = max + 1;
await ecs.createManySpecific(specifics);
ecs.createManySpecific(specifics);
return ecs;
}
@ -352,11 +362,11 @@ export default class Ecs {
return this.$$entities[entityId];
}
async insert(entityId, components) {
insert(entityId, components) {
return this.insertMany([[entityId, components]]);
}
async insertMany(entities) {
insertMany(entities) {
const inserting = {};
const unique = new Set();
for (const [entityId, components] of entities) {
@ -371,11 +381,9 @@ export default class Ecs {
unique.add(entityId);
this.markChange(entityId, diff);
}
const promises = [];
for (const componentName in inserting) {
promises.push(this.Components[componentName].insertMany(inserting[componentName]));
this.Components[componentName].insertMany(inserting[componentName]);
}
await Promise.all(promises);
for (const [entityId, components] of entities) {
this.rebuild(entityId, (componentNames) => [...new Set(componentNames.concat(Object.keys(components)))]);
}
@ -423,35 +431,16 @@ export default class Ecs {
}
readJson(uri) {
const key = ['$$json', uri].join(':');
if (!cache.has(key)) {
const buffer = this.readAsset(uri);
const json = buffer.byteLength > 0
? JSON.parse(textDecoder.decode(buffer))
: {};
cache.set(key, json);
}
return cache.get(key);
const buffer = this.readAsset(uri);
const {$$extends, ...json} = buffer.byteLength > 0 ? JSON.parse(textDecoder.decode(buffer)) : {};
return {
...json,
...$$extends && this.readJson($$extends),
};
}
readScript(uriOrCode, context = {}) {
if (!uriOrCode) {
return undefined;
}
let code = '';
if (!uriOrCode.startsWith('/')) {
code = uriOrCode;
}
else {
const buffer = this.readAsset(uriOrCode);
if (buffer.byteLength > 0) {
code = textDecoder.decode(buffer);
}
}
if (!code) {
return undefined;
}
return Script.fromCode(code, context);
readScript(path, locals = {}) {
return Script.load(path, locals);
}
rebuild(entityId, componentNames) {
@ -588,7 +577,7 @@ export default class Ecs {
};
}
async updateMany(entities) {
updateMany(entities) {
const updating = {};
const unique = new Set();
for (const [entityId, components] of entities) {
@ -600,11 +589,9 @@ export default class Ecs {
}
unique.add(entityId);
}
const promises = [];
for (const componentName in updating) {
promises.push(this.Components[componentName].updateMany(updating[componentName]));
this.Components[componentName].updateMany(updating[componentName]);
}
await Promise.all(promises);
}
}

View File

@ -1,6 +1,5 @@
import {expect, test} from 'vitest';
import Component from './component.js';
import Ecs from './ecs.js';
import System from './system.js';
import {wrapComponents} from './test-helper.js';
@ -19,24 +18,6 @@ const {
Name,
} = Components;
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;
@ -77,33 +58,33 @@ test('activates and deactivates systems at runtime', () => {
.to.equal(2);
});
test('creates entities with components', async () => {
test('creates entities with components', () => {
const ecs = new Ecs({Components: {Empty, Position}});
const entity = await ecs.create({Empty: {}, Position: {y: 128}});
const entity = ecs.create({Empty: {}, Position: {y: 128}});
expect(JSON.stringify(ecs.get(entity)))
.to.deep.equal(JSON.stringify({Empty: {}, Position: {y: 128}}));
.to.deep.equal(JSON.stringify({Empty: {}, Position: {x: 32, y: 128, z: 0}}));
});
test("removes entities' components", async () => {
test("removes entities' components", () => {
const ecs = new Ecs({Components: {Empty, Position}});
const entity = await ecs.create({Empty: {}, Position: {y: 128}});
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', async () => {
test('gets entities', () => {
const ecs = new Ecs({Components: {Empty, Position}});
const entity = await ecs.create({Empty: {}, Position: {y: 128}});
const entity = ecs.create({Empty: {}, Position: {y: 128}});
expect(JSON.stringify(ecs.get(entity)))
.to.deep.equal(JSON.stringify({Empty: {}, Position: {y: 128}}));
.to.deep.equal(JSON.stringify({Empty: {}, Position: {x: 32, y: 128, z: 0}}));
});
test('destroys entities', async () => {
test('destroys entities', () => {
const ecs = new Ecs({Components: {Empty, Position}});
const entity = await ecs.create({Empty: {}, Position: {y: 128}});
const entity = ecs.create({Empty: {}, Position: {y: 128}});
expect(JSON.stringify(ecs.get(entity)))
.to.deep.equal(JSON.stringify({Empty: {}, Position: {y: 128}}));
.to.deep.equal(JSON.stringify({Empty: {}, Position: {x: 32, y: 128, z: 0}}));
expect(ecs.get(entity))
.to.not.be.undefined;
ecs.destroyMany(new Set([entity]));
@ -115,18 +96,18 @@ test('destroys entities', async () => {
.to.throw();
});
test('inserts components into entities', async () => {
test('inserts components into entities', () => {
const ecs = new Ecs({Components: {Empty, Position}});
const entity = await ecs.create({Empty: {}});
await ecs.insert(entity, {Position: {y: 128}});
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}}));
await ecs.insert(entity, {Position: {y: 64}});
.to.deep.equal(JSON.stringify({Empty: {}, Position: {x: 32, y: 128, z: 0}}));
ecs.insert(entity, {Position: {y: 64}});
expect(JSON.stringify(ecs.get(entity)))
.to.deep.equal(JSON.stringify({Empty: {}, Position: {y: 64}}));
.to.deep.equal(JSON.stringify({Empty: {}, Position: {x: 32, y: 64, z: 0}}));
});
test('ticks systems', async () => {
test('ticks systems', () => {
const ecs = new Ecs({
Components: {Momentum, Position},
Systems: {
@ -150,7 +131,7 @@ test('ticks systems', async () => {
},
});
ecs.system('Physics').active = true;
const entity = await ecs.create({Momentum: {}, Position: {y: 128}});
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))
@ -158,10 +139,10 @@ test('ticks systems', async () => {
ecs.get(1).Momentum.y = 30;
ecs.tick(1);
expect(JSON.stringify(ecs.get(entity).Position))
.to.deep.equal(JSON.stringify({y: 128 + 30}));
.to.deep.equal(JSON.stringify({x: 32, y: 128 + 30, z: 0}));
});
test('schedules entities to be deleted when ticking systems', async () => {
test('schedules entities to be deleted when ticking systems', () => {
const ecs = new Ecs({
Components: {Empty},
Systems: {
@ -180,7 +161,7 @@ test('schedules entities to be deleted when ticking systems', async () => {
},
});
ecs.system('Despawn').active = true;
await ecs.create({Empty: {}});
ecs.create({Empty: {}});
ecs.tick(1);
expect(Array.from(ecs.system('Despawn').select('default')))
.to.have.lengthOf(0);
@ -188,7 +169,7 @@ test('schedules entities to be deleted when ticking systems', async () => {
.to.be.undefined;
});
test('skips indexing detached entities', async () => {
test('skips indexing detached entities', () => {
const ecs = new Ecs({
Components: {Empty},
Systems: {
@ -203,7 +184,7 @@ test('skips indexing detached entities', async () => {
});
const {$$map: map} = ecs.system('Indexer').queries.default;
ecs.system('Indexer').active = true;
const attached = await ecs.create({Empty: {}});
const attached = ecs.create({Empty: {}});
ecs.tick(0);
expect(Array.from(map.keys()))
.to.deep.equal([attached]);
@ -211,7 +192,7 @@ test('skips indexing detached entities', async () => {
ecs.tick(0);
expect(Array.from(map.keys()))
.to.deep.equal([]);
const detached = await ecs.createDetached({Empty: {}});
const detached = ecs.createDetached({Empty: {}});
ecs.tick(0);
expect(Array.from(map.keys()))
.to.deep.equal([]);
@ -221,20 +202,20 @@ test('skips indexing detached entities', async () => {
.to.deep.equal([]);
});
test('generates diffs for entity creation', async () => {
test('generates diffs for entity creation', () => {
const ecs = new Ecs();
let entity;
entity = await ecs.create();
entity = ecs.create();
expect(ecs.diff)
.to.deep.equal({[entity]: {}});
});
test('generates diffs for adding and removing components', async () => {
test('generates diffs for adding and removing components', () => {
const ecs = new Ecs({Components: {Position}});
let entity;
entity = await ecs.create();
entity = ecs.create();
ecs.setClean();
await ecs.insert(entity, {Position: {x: 64}});
ecs.insert(entity, {Position: {x: 64}});
expect(ecs.diff)
.to.deep.equal({[entity]: {Position: {x: 64}}});
ecs.setClean();
@ -245,10 +226,10 @@ test('generates diffs for adding and removing components', async () => {
.to.deep.equal({[entity]: {Position: false}});
});
test('generates diffs for empty components', async () => {
test('generates diffs for empty components', () => {
const ecs = new Ecs({Components: {Empty}});
let entity;
entity = await ecs.create({Empty: {}});
entity = ecs.create({Empty: {}});
expect(ecs.diff)
.to.deep.equal({[entity]: {Empty: {}}});
ecs.setClean();
@ -257,10 +238,10 @@ test('generates diffs for empty components', async () => {
.to.deep.equal({[entity]: {Empty: false}});
});
test('generates diffs for entity mutations', async () => {
test('generates diffs for entity mutations', () => {
const ecs = new Ecs({Components: {Position}});
let entity;
entity = await ecs.create({Position: {}});
entity = ecs.create({Position: {}});
ecs.setClean();
ecs.get(entity).Position.x = 128;
expect(ecs.diff)
@ -270,13 +251,13 @@ test('generates diffs for entity mutations', async () => {
.to.deep.equal({});
});
test('generates no diffs for detached entities', async () => {
test('generates no diffs for detached entities', () => {
const ecs = new Ecs({Components: {Position}});
let entity;
entity = await ecs.createDetached();
entity = ecs.createDetached();
expect(ecs.diff)
.to.deep.equal({});
await ecs.insert(entity, {Position: {x: 64}});
ecs.insert(entity, {Position: {x: 64}});
expect(ecs.diff)
.to.deep.equal({});
ecs.get(entity).Position.x = 128;
@ -287,22 +268,22 @@ test('generates no diffs for detached entities', async () => {
.to.deep.equal({});
});
test('generates coalesced diffs for components', async () => {
test('generates coalesced diffs for components', () => {
const ecs = new Ecs({Components: {Position}});
let entity;
entity = await ecs.create({Position});
entity = ecs.create({Position});
ecs.remove(entity, ['Position']);
expect(ecs.diff)
.to.deep.equal({[entity]: {Position: false}});
await ecs.insert(entity, {Position: {}});
ecs.insert(entity, {Position: {}});
expect(ecs.diff)
.to.deep.equal({[entity]: {Position: {}}});
});
test('generates coalesced diffs for mutations', async () => {
test('generates coalesced diffs for mutations', () => {
const ecs = new Ecs({Components: {Position}});
let entity;
entity = await ecs.create({Position});
entity = ecs.create({Position});
ecs.setClean();
ecs.get(entity).Position.x = 128;
ecs.get(entity).Position.x = 256;
@ -311,10 +292,10 @@ test('generates coalesced diffs for mutations', async () => {
.to.deep.equal({[entity]: {Position: {x: 512}}});
});
test('generates diffs for deletions', async () => {
test('generates diffs for deletions', () => {
const ecs = new Ecs();
let entity;
entity = await ecs.create();
entity = ecs.create();
ecs.setClean();
ecs.destroy(entity);
ecs.tick(0);
@ -322,66 +303,66 @@ test('generates diffs for deletions', async () => {
.to.deep.equal({[entity]: false});
});
test('applies creation patches', async () => {
test('applies creation patches', () => {
const ecs = new Ecs({Components: {Position}});
await ecs.apply({16: {Position: {x: 64}}});
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', async () => {
test('applies update patches', () => {
const ecs = new Ecs({Components: {Position}});
await ecs.createSpecific(16, {Position: {x: 64}});
await ecs.apply({16: {Position: {x: 128}}});
ecs.createSpecific(16, {Position: {x: 64}});
ecs.apply({16: {Position: {x: 128}}});
expect(Object.keys(ecs.$$entities).length)
.to.equal(1);
expect(ecs.get(16).Position.x)
.to.equal(128);
});
test('applies entity deletion patches', async () => {
test('applies entity deletion patches', () => {
const ecs = new Ecs({Components: {Position}});
await ecs.createSpecific(16, {Position: {x: 64}});
await ecs.apply({16: false});
ecs.createSpecific(16, {Position: {x: 64}});
ecs.apply({16: false});
expect(Array.from(ecs.entities).length)
.to.equal(0);
});
test('applies component deletion patches', async () => {
test('applies component deletion patches', () => {
const ecs = new Ecs({Components: {Empty, Position}});
await ecs.createSpecific(16, {Empty: {}, Position: {x: 64}});
ecs.createSpecific(16, {Empty: {}, Position: {x: 64}});
expect(ecs.get(16).constructor.componentNames)
.to.deep.equal(['Empty', 'Position']);
await ecs.apply({16: {Empty: false}});
ecs.apply({16: {Empty: false}});
expect(ecs.get(16).constructor.componentNames)
.to.deep.equal(['Position']);
});
test('calculates entity size', async () => {
test('calculates entity size', () => {
const ecs = new Ecs({Components: {Empty, Position}});
await ecs.createSpecific(1, {Empty: {}, Position: {}});
ecs.createSpecific(1, {Empty: {}, Position: {}});
// ID + # of components + Empty + Position + x + y + z
// 4 + 2 + 2 + 4 + 2 + 4 + 4 + 4 + 4 = 30
expect(ecs.get(1).size())
.to.equal(30);
});
test('serializes and deserializes', async () => {
test('serializes and deserializes', () => {
const ecs = new Ecs({Components: {Empty, Name, Position}});
await ecs.createSpecific(1, {Empty: {}, Position: {x: 64}});
await ecs.createSpecific(16, {Name: {name: 'foobar'}, Position: {x: 128}});
ecs.createSpecific(1, {Empty: {}, Position: {x: 64}});
ecs.createSpecific(16, {Name: {name: 'foobar'}, Position: {x: 128}});
expect(ecs.toJSON())
.to.deep.equal({
entities: {
1: {Empty: {}, Position: {x: 64}},
16: {Name: {name: 'foobar'}, Position: {x: 128}},
1: {Empty: {}, Position: {x: 64, y: 0, z: 0}},
16: {Name: {name: 'foobar'}, Position: {x: 128, y: 0, z: 0}},
},
systems: [],
});
const view = Ecs.serialize(ecs);
const deserialized = await Ecs.deserialize(
const deserialized = Ecs.deserialize(
new Ecs({Components: {Empty, Name, Position}}),
view,
);
@ -392,21 +373,21 @@ test('serializes and deserializes', async () => {
expect(deserialized.get(16).constructor.componentNames)
.to.deep.equal(['Name', 'Position']);
expect(JSON.stringify(deserialized.get(1)))
.to.equal(JSON.stringify({Empty: {}, Position: {x: 64}}))
.to.equal(JSON.stringify({Empty: {}, Position: {x: 64, y: 0, z: 0}}))
expect(JSON.stringify(deserialized.get(1).Position))
.to.equal(JSON.stringify({x: 64}));
.to.equal(JSON.stringify({x: 64, y: 0, z: 0}));
expect(JSON.stringify(deserialized.get(16).Position))
.to.equal(JSON.stringify({x: 128}));
.to.equal(JSON.stringify({x: 128, y: 0, z: 0}));
expect(deserialized.get(16).Name.name)
.to.equal('foobar');
});
test('deserializes from compatible ECS', async () => {
test('deserializes from compatible ECS', () => {
const ecs = new Ecs({Components: {Empty, Name, Position}});
await ecs.createSpecific(1, {Empty: {}, Position: {x: 64}});
await ecs.createSpecific(16, {Name: {name: 'foobar'}, Position: {x: 128}});
ecs.createSpecific(1, {Empty: {}, Position: {x: 64}});
ecs.createSpecific(16, {Name: {name: 'foobar'}, Position: {x: 128}});
const view = Ecs.serialize(ecs);
const deserialized = await Ecs.deserialize(
const deserialized = Ecs.deserialize(
new Ecs({Components: {Empty, Name}}),
view,
);
@ -415,30 +396,3 @@ test('deserializes from compatible ECS', async () => {
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

@ -13,7 +13,7 @@ const Components = wrapComponents([
const ecsTest = test.extend({
ecs: async ({}, use) => {
const ecs = new Ecs({Components});
await ecs.createManySpecific([
ecs.createManySpecific([
[1, {B: {}}],
[2, {A: {}, B: {}, C: {}}],
[3, {A: {}}],
@ -23,7 +23,7 @@ const ecsTest = test.extend({
},
});
async function testQuery(ecs, parameters, expected) {
function testQuery(ecs, parameters, expected) {
const query = new Query(parameters, ecs);
query.reindex([1, 2, 3]);
expect(query.count)
@ -34,21 +34,21 @@ async function testQuery(ecs, parameters, expected) {
}
}
ecsTest('can query all', async ({ecs}) => {
await testQuery(ecs, [], [1, 2, 3]);
ecsTest('can query all', ({ecs}) => {
testQuery(ecs, [], [1, 2, 3]);
});
ecsTest('can query some', async ({ecs}) => {
await testQuery(ecs, ['A'], [2, 3]);
await testQuery(ecs, ['A', 'B'], [2]);
ecsTest('can query some', ({ecs}) => {
testQuery(ecs, ['A'], [2, 3]);
testQuery(ecs, ['A', 'B'], [2]);
});
ecsTest('can query excluding', async ({ecs}) => {
await testQuery(ecs, ['!A'], [1]);
await testQuery(ecs, ['A', '!B'], [3]);
ecsTest('can query excluding', ({ecs}) => {
testQuery(ecs, ['!A'], [1]);
testQuery(ecs, ['A', '!B'], [3]);
});
ecsTest('can deindex', async ({ecs}) => {
ecsTest('can deindex', ({ecs}) => {
const query = new Query(['A'], ecs);
query.reindex([1, 2, 3]);
expect(query.count)
@ -58,7 +58,7 @@ ecsTest('can deindex', async ({ecs}) => {
.to.equal(1);
});
ecsTest('can reindex', async ({ecs}) => {
ecsTest('can reindex', ({ecs}) => {
const query = new Query(['B'], ecs);
query.reindex([1, 2]);
expect(query.count)
@ -69,7 +69,7 @@ ecsTest('can reindex', async ({ecs}) => {
.to.equal(1);
});
ecsTest('can select', async ({ecs}) => {
ecsTest('can select', ({ecs}) => {
const query = new Query(['A'], ecs);
query.reindex([1, 2, 3]);
const it = query.select();

24
app/ecs/systems/harm.js Normal file
View File

@ -0,0 +1,24 @@
import {System} from '@/ecs/index.js';
export default class Harm extends System {
static queries() {
return {
default: ['Collider', 'Harmful'],
};
}
tick() {
for (const entity of this.select('default')) {
const intersecting = new Set();
for (const [other] of entity.Collider.$$intersections) {
intersecting.add(this.ecs.get(other.entity));
}
for (const other of intersecting) {
entity.Harmful.harm(other);
}
}
}
}

View File

@ -23,18 +23,16 @@ export default class IntegratePhysics extends System {
if (!Forces || !Position) {
return;
}
Position.lastX = Position.$$x;
Position.$$x += elapsed * (Forces.$$impulseX);
Position.lastY = Position.$$y;
Position.$$y += elapsed * (Forces.$$impulseY);
this.ecs.markChange(
entity.id, {
Position: {
x: Position.$$x,
y: Position.$$y,
},
},
);
const xd = elapsed * (Forces.$$impulseX + Forces.$$forceX);
if (xd) {
Position.lastX = Position.$$x;
Position.x = Position.$$x + xd;
}
const yd = elapsed * (Forces.$$impulseY + Forces.$$forceY);;
if (yd) {
Position.lastY = Position.$$y;
Position.y = Position.$$y + yd;
}
}
}

View File

@ -0,0 +1,22 @@
import {System} from '@/ecs/index.js';
export default class InventoryCloser extends System {
static queries() {
return {
default: ['Collider', 'ItemStack'],
};
}
tick() {
for (const {Collider, ItemStack} of this.select('default')) {
for (const [other] of Collider.$$intersections) {
const otherEntity = this.ecs.get(other.entity);
if (otherEntity.Grabber && otherEntity.Inventory) {
ItemStack.pickup(otherEntity);
}
}
}
}
}

View File

@ -27,16 +27,12 @@ export default class ResetForces extends System {
if (!Forces) {
return;
}
Forces.$$impulseX = 0;
Forces.$$impulseY = 0;
this.ecs.markChange(
entity.id, {
Forces: {
impulseX: Forces.$$impulseX,
impulseY: Forces.$$impulseY,
},
},
);
if (Forces.$$impulseX) {
Forces.impulseX = 0;
}
if (Forces.$$impulseY) {
Forces.impulseY = 0;
}
}
}

View File

@ -8,7 +8,7 @@ export default class Emitter {
this.ecs = ecs;
this.scheduled = [];
}
async configure(entityId, {fields, shape}) {
configure(entityId, {fields, shape}) {
const entity = this.ecs.get(entityId);
if (shape) {
switch (shape.type) {
@ -64,8 +64,8 @@ export default class Emitter {
for (let i = 0; i < count * spurt; ++i) {
specifications[i] = entity;
}
const stream = K.stream(async (emitter) => {
const entityIds = await this.ecs.createManyDetached(specifications);
const stream = K.stream((emitter) => {
const entityIds = this.ecs.createManyDetached(specifications);
if (0 === frequency) {
this.ecs.attach(entityIds);
for (const entityId of entityIds) {

View File

@ -28,6 +28,8 @@ addEventListener('message', (particle) => {
.onEnd(() => {});
});
postMessage(null);
let last = performance.now();
function tick(now) {
const elapsed = (now - last) / 1000;

View File

@ -15,7 +15,6 @@ export default function Entities({monopolizers, particleWorker}) {
const [debug] = useDebug();
const ecsRef = useEcs();
const containerRef = useRef();
const latestTick = useRef();
const entities = useRef({});
const pool = useRef([]);
const mainEntityRef = useMainEntity();
@ -64,7 +63,7 @@ export default function Entities({monopolizers, particleWorker}) {
entities.current[key].setDebug(debug);
}
}, [debug]);
usePacket('EcsChange', async () => {
usePacket('EcsChange', () => {
for (const id in entities.current) {
entities.current[id].removeFromContainer();
}
@ -75,14 +74,12 @@ export default function Entities({monopolizers, particleWorker}) {
if (!particleWorker) {
return;
}
async function onMessage(diff) {
function onMessage(diff) {
if (!ecsRef.current) {
return;
}
latestTick.current = Promise.resolve(latestTick.current).then(async () => {
await ecsRef.current.apply(diff.data);
updateEntities(diff.data, ecsRef.current);
});
ecsRef.current.apply(diff.data);
updateEntities(diff.data, ecsRef.current);
}
particleWorker.addEventListener('message', onMessage);
return () => {

View File

@ -106,10 +106,17 @@ export default class Entity {
const {x, y} = this.entity.Position;
this.container.x = x;
this.container.y = y;
this.container.zIndex = y;
if (this.entity.Sprite?.zIndex === 65535) {
this.container.zIndex = y;
}
}
if (Sprite) {
const {diffuse, normals} = this;
if (!this.attached || 'zIndex' in Sprite) {
this.container.zIndex = this.entity.Sprite?.zIndex === 65535
? this.entity.Position.y
: this.entity.Sprite.zIndex;
}
if (!this.attached || 'alpha' in Sprite) {
diffuse.alpha = normals.alpha = this.entity.Sprite.alpha;
}

View File

@ -18,7 +18,7 @@ const TileLayerInternal = PixiComponent('TileLayer', {
},
applyProps: (container, {tileLayer: oldTileLayer}, props) => {
const {asset, group, renderer, tileLayer} = props;
const extless = tileLayer.source.slice('/resources/'.length, -'.json'.length);
const extless = tileLayer.source.slice('/resources/'.length, -'.sprite.json'.length);
const {textures} = asset;
if (tileLayer === oldTileLayer) {
return;

View File

@ -38,7 +38,6 @@ function Ui({disconnected}) {
// Key input.
const client = useClient();
const chatInputRef = useRef();
const latestTick = useRef();
const gameRef = useRef();
const pixiRef = useRef();
const mainEntityRef = useMainEntity();
@ -234,20 +233,18 @@ function Ui({disconnected}) {
mainEntityRef,
]);
usePacket('EcsChange', onEcsChangePacket);
const onTickPacket = useCallback(async (payload, client) => {
const onTickPacket = useCallback((payload, client) => {
if (!ecsRef.current || 0 === Object.keys(payload.ecs).length) {
return;
}
latestTick.current = Promise.resolve(latestTick.current).then(async () => {
try {
await ecsRef.current.apply(payload.ecs);
client.emitter.invoke(':Ecs', payload.ecs);
}
catch (error) {
ecsRef.current = undefined;
console.error('tick crash', error);
}
});
try {
ecsRef.current.apply(payload.ecs);
client.emitter.invoke(':Ecs', payload.ecs);
}
catch (error) {
ecsRef.current = undefined;
console.error('tick crash', error);
}
}, [ecsRef]);
usePacket('Tick', onTickPacket);
const onEcsTick = useCallback((payload, ecs) => {
@ -320,19 +317,19 @@ function Ui({disconnected}) {
}, [mainEntityRef]);
useEcsTick(onEcsTick);
const onEcsTickParticles = useCallback((payload, ecs) => {
if (!('1' in payload) || particleWorker) {
return
if (!payload[1]?.AreaSize) {
return;
}
if (particleWorker) {
particleWorker.terminate();
}
const localParticleWorker = new Worker(
new URL('./particle-worker.js', import.meta.url),
{type: 'module'},
);
localParticleWorker.postMessage(ecs.get(1).toJSON());
setParticleWorker((particleWorker) => {
if (particleWorker) {
particleWorker.terminate();
}
return localParticleWorker;
localParticleWorker.addEventListener('message', () => {
localParticleWorker.postMessage(ecs.get(1).toJSON());
setParticleWorker(localParticleWorker);
});
}, [particleWorker]);
useEcsTick(onEcsTickParticles);

View File

@ -118,7 +118,7 @@ function Gen() {
Math.ceil(area.x / CHUNK_SIZE) * Math.ceil(area.y / CHUNK_SIZE)
).fill(0).map(() => ({})),
data: Array(area.x * area.y).fill(1),
source: '/resources/tileset.json',
source: '/resources/tileset.sprite.json',
tileSize: {x: 16, y: 16},
};
const tileGenerator = new Generator({

View File

@ -9,6 +9,7 @@ export default function createEcs(Ecs) {
'Attract',
'ResetForces',
'ApplyControlMovement',
'Harm',
'IntegratePhysics',
'ClampPositions',
'PlantGrowth',
@ -17,6 +18,7 @@ export default function createEcs(Ecs) {
'MaintainColliderHash',
'Colliders',
'ControlDirection',
'PickupItems',
'SpriteDirection',
'RunAnimations',
'RunTickingPromises',

View File

@ -52,7 +52,7 @@ const Forest = new Generator({
],
});
export default async function createForest() {
export default function createForest() {
const area = {x: w, y: h};
const entities = [];
entities.push({
@ -63,13 +63,13 @@ export default async function createForest() {
{
area,
data: Array(w * h).fill(0),
source: '/resources/tileset.json',
source: '/resources/tileset.sprite.json',
tileSize: {x: 16, y: 16},
},
{
area,
data: Array(w * h).fill(0),
source: '/resources/tileset.json',
source: '/resources/tileset.sprite.json',
tileSize: {x: 16, y: 16},
},
],
@ -113,7 +113,7 @@ export default async function createForest() {
Position: entityPosition(x, y),
Sprite: {
anchorY: 0.7,
source: '/resources/ambient/shrub.json',
source: '/resources/ambient/shrub.sprite.json',
},
VisibleAabb: {},
});
@ -123,7 +123,7 @@ export default async function createForest() {
Position: entityPosition(x, y),
Sprite: {
anchorY: 0.875,
source: '/resources/ambient/tree.json',
source: '/resources/ambient/tree.sprite.json',
},
VisibleAabb: {},
});
@ -134,7 +134,7 @@ export default async function createForest() {
Position: entityPosition(x, y),
Sprite: {
anchorY: 0.7,
source: '/resources/ambient/flower.json',
source: '/resources/ambient/flower.sprite.json',
},
VisibleAabb: {},
});

View File

@ -32,13 +32,13 @@ function createMaster() {
{
area,
data,
source: '/resources/tileset.json',
source: '/resources/tileset.sprite.json',
tileSize: {x: 16, y: 16},
},
{
area,
data: Array(area.x * area.y).fill(0),
source: '/resources/tileset.json',
source: '/resources/tileset.sprite.json',
tileSize: {x: 16, y: 16},
},
],
@ -48,7 +48,7 @@ function createMaster() {
};
}
function createShitShack(id) {
function createShitShack() {
return {
Collider: {
bodies: [
@ -63,21 +63,18 @@ function createShitShack(id) {
},
],
},
Ecs: {
path: ['houses', `${id}`].join('/'),
},
Position: {x: 100, y: 100},
Sprite: {
anchorX: 0.5,
anchorY: 0.8,
source: '/resources/shit-shack/shit-shack.json',
source: '/resources/shit-shack/shit-shack.sprite.json',
},
Ticking: {},
VisibleAabb: {},
};
}
function createHouseTeleport(id) {
function createHouseTeleport() {
return {
Collider: {
bodies: [
@ -90,20 +87,7 @@ function createHouseTeleport(id) {
],
},
],
collisionStartScript: `
if (other.Player) {
ecs.switchEcs(
other,
'houses/${id}',
{
Position: {
x: 72,
y: 304,
},
},
);
}
`,
collisionStartScript: '/resources/homestead/house-teleport/collision-start.js',
},
Position: {x: 71, y: 113},
Ticking: {},
@ -127,35 +111,26 @@ function createChest() {
},
Interactive: {
interacting: 1,
interactScript: `
initiator.Player.openInventory = subject.Inventory;
// subject.Interlocutor.dialogue({
// body: "Sure, I'm a treasure chest. Probably. Do you really think that means you're about to get some treasure? Hah!",
// monopolizer: true,
// offset: {x: 0, y: -48},
// origin: 'track',
// position: 'track',
// })
`,
interactScript: '/resources/chest/interact.js',
},
Interlocutor: {},
Inventory: {
slots: {
2: {
qty: 1,
source: '/resources/watering-can/watering-can.json',
source: '/resources/watering-can/watering-can.item.json',
},
3: {
qty: 1,
source: '/resources/tomato-seeds/tomato-seeds.json',
source: '/resources/tomato-seeds/tomato-seeds.item.json',
},
4: {
qty: 1,
source: '/resources/hoe/hoe.json',
source: '/resources/hoe/hoe.item.json',
},
5: {
qty: 1,
source: '/resources/brush/brush.json',
source: '/resources/brush/brush.item.json',
},
},
},
@ -164,7 +139,7 @@ function createChest() {
Sprite: {
anchorX: 0.5,
anchorY: 0.7,
source: '/resources/chest/chest.json',
source: '/resources/chest/chest.sprite.json',
},
Ticking: {},
VisibleAabb: {},
@ -173,43 +148,11 @@ function createChest() {
function createTomato() {
return {
Collider: {
bodies: [
{
points: [
{x: -4, y: -4},
{x: 3, y: -4},
{x: 3, y: 3},
{x: -4, y: 3},
],
},
],
collisionStartScript: [
'if (other.Inventory) {',
' other.Inventory.give({',
' qty: 1,',
" source: '/resources/tomato/tomato.json',",
' })',
' ecs.destroy(entity.id)',
' undefined;',
'}',
].join('\n'),
},
Forces: {},
Magnetic: {},
$$extends: '/resources/tomato/tomato.entity.json',
Position: {
x: 168 + Math.random() * 30,
y: 448 + Math.random() * 30,
},
Sprite: {
anchorX: 0.5,
anchorY: 0.5,
scaleX: 0.333,
scaleY: 0.333,
source: '/resources/tomato/tomato-sprite.json',
},
Ticking: {},
VisibleAabb: {},
};
}
@ -236,23 +179,7 @@ function createTestKitten() {
},
Interactive: {
interacting: 1,
interactScript: `
const lines = [
'mrowwr',
'p<shake>rrr</shake>o<wave>wwwww</wave>',
'mew<rate frequency={0.5}> </rate>mew!',
'me<wave>wwwww</wave>',
'\\\\*pu<shake>rrrrr</shake>\\\\*',
];
const line = lines[Math.floor(Math.random() * lines.length)];
subject.Interlocutor.dialogue({
body: line,
linger: 2,
offset: {x: 0, y: -16},
origin: 'track',
position: 'track',
})
`,
interactScript: '/resources/kitty/interact.js',
},
Position: {
x: 250 + (Math.random() - 0.5) * 300,
@ -262,7 +189,7 @@ function createTestKitten() {
...animalJson.Sprite,
anchorX: 0.5,
anchorY: 0.7,
source: '/resources/kitty/kitty.json',
source: '/resources/kitty/kitty.sprite.json',
speed: 0.115,
},
Tags: {tags: ['kittan']},
@ -292,20 +219,7 @@ function createTestCow() {
},
Interactive: {
interacting: 1,
interactScript: `
const lines = [
'sno<shake>rr</shake>t',
'm<wave>ooooooooooo</wave>',
];
const line = lines[Math.floor(Math.random() * lines.length)];
subject.Interlocutor.dialogue({
body: line,
linger: 2,
offset: {x: 0, y: -16},
origin: 'track',
position: 'track',
})
`,
interactScript: '/resources/farm/animals/cow-adult/interact.js',
},
Position: {
x: 350 + (Math.random() - 0.5) * 300,
@ -315,7 +229,7 @@ function createTestCow() {
...animalJson.Sprite,
anchorX: 0.5,
anchorY: 0.8,
source: '/resources/farm/animals/cow-adult/cow-adult.json',
source: '/resources/farm/animals/cow-adult/cow-adult.sprite.json',
speed: 0.25,
},
};
@ -344,19 +258,7 @@ function createTestGoat() {
},
Interactive: {
interacting: 1,
interactScript: `
const lines = [
'Mind your own business, buddy.\\n\\ner, I mean, <shake>MEEHHHHHH</shake>',
];
const line = lines[Math.floor(Math.random() * lines.length)];
subject.Interlocutor.dialogue({
body: line,
linger: 2,
offset: {x: 0, y: -16},
origin: 'track',
position: 'track',
})
`,
interactScript: '/resources/farm/animals/goat-white/interact.js',
},
Position: {
x: 350 + (Math.random() - 0.5) * 300,
@ -366,7 +268,7 @@ function createTestGoat() {
...animalJson.Sprite,
anchorX: 0.5,
anchorY: 0.8,
source: '/resources/farm/animals/goat-white/goat-white.json',
source: '/resources/farm/animals/goat-white/goat-white.sprite.json',
speed: 0.25,
},
};
@ -385,20 +287,7 @@ function createTownTeleport() {
],
},
],
collisionStartScript: `
if (other.Player) {
ecs.switchEcs(
other,
'town',
{
Position: {
x: 940,
y: 480,
},
},
);
}
`,
collisionStartScript: '/resources/homestead/town-teleport/collision-start.js',
},
Position: {x: 8, y: 432},
@ -436,23 +325,25 @@ function createTomatoPlant() {
Sprite: {
anchorY: 0.75,
animation: 'stage/0',
source: '/resources/tomato-plant/tomato-plant.json',
source: '/resources/tomato-plant/tomato-plant.sprite.json',
zIndex: 0,
},
Ticking: {},
VisibleAabb: {},
};
}
export default async function createHomestead(id) {
export default function createHomestead() {
const entities = [];
entities.push(createMaster());
entities.push(createShitShack(id));
entities.push(createHouseTeleport(id));
entities.push(createChest());
// entities.push(createShitShack());
// entities.push(createHouseTeleport());
// entities.push(createTownTeleport());
// entities.push(createChest());
// for (let i = 0; i < 200; ++i) {
// entities.push(createTomato());
// }
entities.push(createTomatoPlant());
// entities.push(createTomatoPlant());
// for (let i = 0; i < 10; ++i) {
// entities.push(createTestKitten());
// }
@ -462,6 +353,5 @@ export default async function createHomestead(id) {
// for (let i = 0; i < 10; ++i) {
// entities.push(createTestGoat());
// }
entities.push(createTownTeleport());
return entities;
}

View File

@ -1,9 +1,9 @@
import createEcs from './ecs.js';
export default async function createHouse(Ecs, id) {
export default function createHouse(Ecs, id) {
const ecs = createEcs(Ecs);
const area = {x: 20, y: 20};
await ecs.create({
ecs.create({
AreaSize: {x: area.x * 16, y: area.y * 16},
Ticking: {},
TileLayers: {
@ -11,13 +11,13 @@ export default async function createHouse(Ecs, id) {
{
area,
data: Array(area.x * area.y).fill(0).map(() => 5 + Math.floor(Math.random() * 2)),
source: '/resources/tileset.json',
source: '/resources/tileset.sprite.json',
tileSize: {x: 16, y: 16},
}
],
},
});
await ecs.create({
ecs.create({
Collider: {
bodies: [
{
@ -29,20 +29,7 @@ export default async function createHouse(Ecs, id) {
],
},
],
collisionStartScript: `
if (other.Player) {
ecs.switchEcs(
other,
'homesteads/${id}',
{
Position: {
x: 74,
y: 128,
},
},
);
}
`,
collisionStartScript: '/resources/house/homestead-teleport/collision-start.js',
},
Position: {
x: 72,

View File

@ -1,4 +1,4 @@
export default async function createPlayer(id) {
export default function createPlayer(id) {
const player = {
Alive: {health: 100},
Camera: {},
@ -25,37 +25,38 @@ export default async function createPlayer(id) {
slots: {
1: {
qty: 100,
source: '/resources/potion/potion.json',
source: '/resources/potion/potion.item.json',
},
2: {
qty: 1,
source: '/resources/magic-swords/magic-swords.json',
source: '/resources/magic-swords/magic-swords.item.json',
},
3: {
qty: 1,
source: '/resources/watering-can/watering-can.json',
source: '/resources/watering-can/watering-can.item.json',
},
4: {
qty: 1,
source: '/resources/tomato-seeds/tomato-seeds.json',
source: '/resources/tomato-seeds/tomato-seeds.item.json',
},
5: {
qty: 1,
source: '/resources/hoe/hoe.json',
source: '/resources/hoe/hoe.item.json',
},
6: {
qty: 95,
source: '/resources/potion/potion.json',
source: '/resources/potion/potion.item.json',
},
7: {
qty: 95,
source: '/resources/potion/potion.json',
source: '/resources/potion/potion.item.json',
},
},
},
Grabber: {},
Health: {health: 100},
Light: {brightness: 0},
Magnet: {strength: 24},
// Magnet: {strength: 24},
Player: {},
Position: {x: 128, y: 448},
Speed: {speed: 100},
@ -65,7 +66,7 @@ export default async function createPlayer(id) {
anchorY: 0.9,
animation: 'moving:down',
frame: 0,
source: '/resources/dude/dude.json',
source: '/resources/dude/dude.sprite.json',
speed: 0.115,
},
Ticking: {},

View File

@ -1,6 +1,6 @@
import data from './town.json';
export default async function createTown() {
export default function createTown() {
const area = {x: 60, y: 60};
const entities = [];
entities.push({
@ -11,13 +11,13 @@ export default async function createTown() {
{
area,
data,
source: '/resources/tileset.json',
source: '/resources/tileset.sprite.json',
tileSize: {x: 16, y: 16},
},
{
area,
data: Array(area.x * area.y).fill(0),
source: '/resources/tileset.json',
source: '/resources/tileset.sprite.json',
tileSize: {x: 16, y: 16},
},
],
@ -37,20 +37,7 @@ export default async function createTown() {
],
},
],
collisionStartScript: `
if (other.Player) {
ecs.switchEcs(
other,
['homesteads', other.Player.id].join('/'),
{
Position: {
x: 20,
y: 438,
},
},
);
}
`,
collisionStartScript: '/resources/town/homestead-teleport/collision-start.js',
},
Position: {x: 952, y: 480},
Ticking: {},

View File

@ -397,7 +397,7 @@ export default class Engine {
throw error;
}
const homestead = this.createEcs();
for (const entity of await createHomestead(id)) {
for (const entity of await createHomestead()) {
await homestead.create(entity);
}
await this.saveEcs(

View File

@ -122,7 +122,7 @@ if (import.meta.hot) {
delete engine.ecses['homesteads/0'];
await engine.server.removeData('homesteads/0');
const homestead = createEcs(engine.Ecs);
for (const entity of await createHomestead('0')) {
for (const entity of await createHomestead()) {
await homestead.create(entity);
}
await engine.saveEcs('homesteads/0', homestead);

View File

@ -1,32 +1,36 @@
import {Ticker} from '@/util/promise.js';
import Ticker from '@/util/ticker.js';
export default function delta(object, properties) {
const deltas = {};
for (const key in properties) {
const keys = new Set(Object.keys(properties));
function stop() {
keys.clear();
}
for (const key of keys) {
const property = properties[key];
const delta = {
duration: Infinity,
elapsed: 0,
...property,
stop: () => {
keys.delete(key);
},
};
deltas[key] = delta;
}
let stop;
const promise = new Ticker(
(resolve) => {
stop = resolve;
},
(elapsed, resolve) => {
for (const key in deltas) {
const ticker = new Ticker(function* () {
while (keys.size > 0) {
const elapsed = yield;
for (const key of keys) {
deltas[key].elapsed += elapsed;
object[key] += deltas[key].delta * elapsed;
if (deltas[key].elapsed >= deltas[key].duration) {
object[key] += deltas[key].delta * (deltas[key].duration - deltas[key].elapsed);
resolve();
return;
deltas[key].stop();
break;
}
object[key] += deltas[key].delta * elapsed;
}
},
);
return {stop, deltas, promise};
}
});
return {deltas, stop, ticker};
}

51
app/util/delta.test.js Normal file
View File

@ -0,0 +1,51 @@
import {expect, test} from 'vitest';
import delta from './delta.js';
test('mutates', () => {
const O = {x: 10};
const {ticker} = delta(O, {x: {delta: 20}});
expect(O.x).to.equal(10);
ticker.tick(0.5);
expect(O.x).to.equal(20);
ticker.tick(0.5);
expect(O.x).to.equal(30);
ticker.tick(0.5);
expect(O.x).to.equal(40);
ticker.tick(0.5);
expect(O.x).to.equal(50);
});
test('does not overshoot', () => {
const O = {x: 10};
const {ticker} = delta(O, {x: {delta: 20, duration: 0.75}});
ticker.tick(0.5);
expect(O.x).to.equal(20);
ticker.tick(0.5);
expect(O.x).to.equal(25);
ticker.tick(0.5);
expect(O.x).to.equal(25);
});
test('stops mutating', () => {
const O = {x: 10, y: 20};
const {deltas, stop, ticker} = delta(O, {x: {delta: 20}, y: {delta: 10}});
ticker.tick(0.5);
expect(O.x).to.equal(20);
expect(O.y).to.equal(25);
deltas.x.stop();
ticker.tick(0.5);
expect(O.x).to.equal(20);
expect(O.y).to.equal(30);
stop();
expect(O.x).to.equal(20);
expect(O.y).to.equal(30);
});
test('exposes deltas', () => {
const O = {x: 10};
const {deltas, ticker} = delta(O, {x: {delta: 20}});
deltas.x.delta = 50;
ticker.tick(0.5);
expect(O.x).to.equal(35);
});

4
app/util/eval.js Normal file
View File

@ -0,0 +1,4 @@
// this is only here to avoid build warnings that should have been tree-shaken away
export default function importedEval(code) {
return eval(code);
}

View File

@ -2,7 +2,7 @@ import {expect, test} from 'vitest';
import {distribute} from './inventory.js';
test('distributes', async () => {
test('distributes', () => {
let item;
item = {maximumStack: 20, qty: 10};
expect(distribute(

View File

@ -1,4 +1,4 @@
import {Ticker, withResolvers} from '@/util/promise.js';
import Ticker from '@/util/ticker.js';
const Modulators = {
flat: () => 0.5,
@ -11,8 +11,11 @@ const Modulators = {
export default function lfo(object, properties) {
const oscillators = {};
const promises = [];
for (const key in properties) {
const keys = new Set(Object.keys(properties));
function stop() {
keys.clear();
}
for (const key of keys) {
const property = properties[key];
const oscillator = {
count: Infinity,
@ -35,38 +38,38 @@ export default function lfo(object, properties) {
}
}
oscillator.low = oscillator.median - oscillator.magnitude / 2;
({promise: oscillator.promise, resolve: oscillator.stop} = withResolvers());
oscillator.promise.then(() => {
delete oscillators[key];
});
promises.push(oscillator.promise);
oscillator.stop = () => {
keys.delete(key);
};
oscillator.compute = (elapsed) => {
const x = (oscillator.offset + (elapsed / oscillator.frequency)) % 1;
let y = 0;
for (const modulator of oscillator.modulators) {
y += modulator(x);
}
return oscillator.low + oscillator.magnitude * (y / oscillator.modulators.length);
}
oscillators[key] = oscillator;
}
let stop;
const promise = new Ticker(
(resolve) => {
stop = resolve;
Promise.all(promises).then(resolve);
},
(elapsed) => {
for (const key in oscillators) {
const ticker = new Ticker(function* () {
while (keys.size > 0) {
const elapsed = yield;
for (const key of keys) {
const oscillator = oscillators[key];
oscillator.elapsed += elapsed;
if (oscillator.elapsed >= oscillator.frequency) {
if (0 === --oscillator.count) {
oscillator.stop();
return;
}
oscillator.elapsed = oscillator.elapsed % oscillator.frequency;
const rollover = oscillator.elapsed >= oscillator.frequency;
if (rollover) {
oscillator.count -= 1;
oscillator.elapsed = 0 === oscillator.count
? oscillator.frequency
: oscillator.elapsed % oscillator.frequency;
}
const x = (oscillator.offset + (oscillator.elapsed / oscillator.frequency)) % 1;
let y = 0;
for (const modulator of oscillator.modulators) {
y += modulator(x);
object[key] = oscillator.compute(oscillator.elapsed);
if (rollover && 0 === oscillator.count) {
oscillator.stop();
}
object[key] = oscillator.low + oscillator.magnitude * (y / oscillator.modulators.length);
}
},
);
return {stop, oscillators, promise};
}
});
return {oscillators, stop, ticker};
}

103
app/util/lfo.test.js Normal file
View File

@ -0,0 +1,103 @@
import {expect, test} from 'vitest';
import lfo from './lfo.js';
test('mutates', () => {
const O = {x: 0.25};
const {ticker} = lfo(
O,
{
x: {
count: 1,
frequency: 0.5,
magnitude: 0.5,
modulators: ['sine'],
},
},
);
expect(O.x).to.equal(0.25);
ticker.tick(0.125);
expect(O.x).to.equal(0.5);
ticker.tick(0.125);
expect(O.x).to.be.closeTo(0.25, 0.0001);
ticker.tick(0.125);
expect(O.x).to.equal(0);
ticker.tick(0.125);
expect(O.x).to.equal(0.25);
ticker.tick(0.125);
expect(O.x).to.equal(0.25);
});
test('does not overshoot', () => {
const O = {x: 0.25};
const {oscillators, ticker} = lfo(
O,
{
x: {
count: 1,
frequency: 0.5,
magnitude: 0.5,
modulators: ['sine'],
},
},
);
ticker.tick(0.3);
expect(O.x).to.equal(oscillators.x.compute(0.3));
ticker.tick(0.3);
expect(O.x).to.equal(oscillators.x.compute(0.5));
});
test('stops mutating', () => {
const O = {x: 0.25, y: 0.5};
const {oscillators, stop, ticker} = lfo(
O,
{
x: {
count: 1,
frequency: 0.5,
magnitude: 0.5,
modulators: ['sine'],
},
y: {
count: 1,
frequency: 0.5,
magnitude: 0.5,
modulators: ['sine'],
offset: 0.25,
},
},
);
ticker.tick(0.2);
expect(O.x).to.equal(oscillators.x.compute(0.2));
expect(O.y).to.equal(oscillators.y.compute(0.2));
oscillators.x.stop();
ticker.tick(0.2);
expect(O.x).to.equal(oscillators.x.compute(0.2));
expect(O.y).to.equal(oscillators.y.compute(0.4));
stop();
ticker.tick(0.2);
expect(O.x).to.equal(oscillators.x.compute(0.2));
expect(O.y).to.equal(oscillators.y.compute(0.4));
});
test('exposes oscillators', () => {
const O = {x: 0};
const {oscillators, ticker} = lfo(
O,
{
x: {
count: 1,
frequency: 0.5,
magnitude: 0.5,
modulators: ['sine'],
},
},
);
ticker.tick(0.2);
expect(O.x).to.equal(oscillators.x.compute(0.2));
oscillators.x.count = 2;
ticker.tick(0.3);
expect(O.x).to.equal(oscillators.x.compute(0));
ticker.tick(0.5);
expect(O.x).to.equal(oscillators.x.compute(0.5));
});

View File

@ -2,7 +2,7 @@ import {expect, test} from 'vitest';
import * as MathUtil from './math.js';
test('transforms vertices', async () => {
test('transforms vertices', () => {
const expectCloseTo = (l, r) => {
expect(l.length)
.to.equal(r.length);

View File

@ -12,63 +12,3 @@ export function withResolvers() {
});
return {promise, reject, resolve};
}
export class Ticker extends Promise {
constructor(executor, ticker) {
let _reject;
let _resolve;
super((resolve, reject) => {
_reject = reject;
_resolve = resolve;
if (executor) {
executor(resolve, reject);
}
});
this.reject = _reject;
this.resolve = _resolve;
this.ticker = ticker;
}
static all(promises) {
const tickers = [];
for (let i = 0; i < promises.length; i++) {
const promise = promises[i];
if (promise instanceof Ticker) {
tickers.push(promise);
// After resolution, stop ticking the promise.
promise.then(() => {
tickers.splice(tickers.indexOf(promise), 1);
});
}
}
/* v8 ignore next 3 */
if (0 === tickers.length) {
return super.all(promises);
}
return new Ticker(
(resolve, reject) => {
super.all(promises)
.then(resolve)
/* v8 ignore next */
.catch(reject);
},
(elapsed) => {
for (let i = 0; i < tickers.length; i++) {
tickers[i].tick(elapsed);
}
},
);
}
tick(elapsed) {
this.ticker(elapsed, this.resolve, this.reject);
}
then(...args) {
const promise = super.then(...args);
promise.ticker = this.ticker;
return promise;
}
}

View File

@ -1,100 +0,0 @@
import {expect, test} from 'vitest';
import {Ticker} from './promise.js';
test('runs executor', async () => {
expect(
await new Ticker((resolve) => {
resolve(32);
}),
)
.to.equal(32);
expect(
async () => {
await new Ticker((resolve, reject) => {
reject(new Error(''));
})
}
)
.rejects.toThrowError('');
});
test('ticks and resolves', async () => {
let done = false;
let e = 0;
const tp = new Ticker(undefined, (elapsed, resolve) => {
e += elapsed;
if (1 === e) {
done = true;
resolve(16);
}
});
expect(done)
.to.be.false;
tp.tick(0.25);
expect(done)
.to.be.false;
tp.tick(0.25);
expect(done)
.to.be.false;
tp.tick(0.25);
expect(done)
.to.be.false;
tp.tick(0.25);
expect(done)
.to.be.true;
expect(await tp)
.to.equal(16);
});
test('ticks and rejects', async () => {
let caught = false;
const tp = new Ticker(undefined, (elapsed, resolve, reject) => {
reject(new Error());
});
tp.catch(() => {
caught = true;
});
expect(caught)
.to.be.false;
tp.tick(0.25);
await Promise.resolve();
expect(caught)
.to.be.true;
});
test('handles all', async () => {
let done = 0;
let e1 = 0, e2 = 0;
const tp1 = new Ticker(undefined, (elapsed, resolve) => {
e1 += elapsed;
if (1 === e1) {
done += 1;
resolve(16);
}
});
const tp2 = new Ticker(undefined, (elapsed, resolve) => {
e2 += elapsed;
if (2 === e2) {
done += 1;
resolve(32);
}
});
const tpa = Ticker.all([
Promise.resolve(8),
tp1,
tp2,
]);
expect(done)
.to.equal(0);
while (2 !== done) {
tpa.tick(0.25);
await Promise.resolve();
}
expect(e1)
.to.equal(1);
expect(e2)
.to.equal(2);
expect(await tpa)
.to.deep.equal([8, 16, 32]);
});

View File

@ -1,188 +1,122 @@
import {parse as acornParse} from 'acorn';
import {Runner} from 'astride';
import {LRUCache} from 'lru-cache';
import * as color from '@/util/color.js';
import delta from '@/util/delta.js';
import lfo from '@/util/lfo.js';
import * as MathUtil from '@/util/math.js';
import * as PromiseUtil from '@/util/promise.js';
import transition from '@/util/transition.js';
function parse(code, options = {}) {
return acornParse(code, {
ecmaVersion: 'latest',
sourceType: 'module',
...options,
})
}
const Populated = Symbol.for('sandbox.populated');
export const cache = new LRUCache({
max: 128,
});
import Ticker from '@/util/ticker.js';
export default class Script {
constructor(sandbox, code) {
this.code = code;
this.sandbox = sandbox;
this.promise = null;
static registered = {};
constructor(fn, locals) {
if (!fn) {
throw new TypeError('Script needs a function');
}
this.fn = fn;
this.iterator = null;
this.locals = locals;
this.$$ticker = null;
}
clone() {
return new this.constructor(this.sandbox.clone(), this.code);
}
get context() {
return this.sandbox.locals;
}
static contextDefaults() {
return {
color,
console,
delta,
lfo,
Math: MathUtil,
Promise: PromiseUtil,
transition,
wait: (seconds = 0) => (
new PromiseUtil.Ticker(
(resolve) => {
if (0 === seconds) {
resolve();
}
},
(elapsed, resolve) => {
seconds -= elapsed;
if (seconds <= 0) {
resolve();
}
}
)
),
};
}
static createContext(locals = {}) {
if (locals[Populated]) {
return locals;
}
return {
[Populated]: true,
...this.contextDefaults(),
...locals,
};
return new this.constructor(this.fn, this.locals);
}
evaluate() {
this.sandbox.reset();
try {
const {value} = this.sandbox.step();
return value;
return this.fn(this.locals).next().value;
}
static load(pathOrFunction, locals) {
let fn;
if (this.registered[pathOrFunction]) {
fn = this.registered[pathOrFunction];
}
catch (error) {
console.warn(this.sandbox.$$stack);
console.warn(error);
else if (pathOrFunction) {
try {
fn = eval(pathOrFunction);
}
catch (error) {
console.error("Couldn't eval script", pathOrFunction);
console.error(error);
}
}
if (!fn) {
return undefined;
}
}
static fromCode(code, context = {}) {
if (!cache.has(code)) {
cache.set(code, this.parse(code));
const script = new this(fn, locals);
if (import.meta.hot) {
import.meta.hot.accept('./scripts.js', ({default: scripts}) => {
script.fn = scripts[`../..${pathOrFunction}`];
});
}
return new this(
new Runner(cache.get(code), this.createContext(context)),
code,
);
return script;
}
static parse(code) {
return parse(
code,
{
allowReturnOutsideFunction: true,
},
);
static register(path, fn) {
this.registered[path] = fn;
}
reset() {
this.promise = null;
this.sandbox.reset();
this.iterator = null;
this.$$ticker = null;
}
tick(elapsed, resolve, reject) {
if (this.promise) {
if (this.promise instanceof PromiseUtil.Ticker) {
this.promise.tick(elapsed);
tick(elapsed) {
this.locals.elapsed = elapsed;
if (this.$$ticker) {
const result = this.$$ticker.tick(elapsed);
this.locals.elapsed -= result.value;
if (result.done) {
this.$$ticker = null;
}
return;
}
while (true) {
this.sandbox.locals.elapsed = elapsed;
let async, done, value;
try {
({async, done, value} = this.sandbox.step());
}
catch (error) {
console.warn(this.sandbox.$$stack);
console.warn(error);
if (resolve) {
resolve();
}
return;
}
if (async || value instanceof Promise) {
this.promise = value;
value
.catch(reject ? reject : () => {})
.then(() => {
if (done) {
if (resolve) {
resolve();
}
}
})
.finally(() => {
this.promise = null;
});
break;
}
if (done) {
if (resolve) {
resolve();
}
break;
else {
return false;
}
}
if (!this.iterator) {
this.iterator = this.fn(this.locals);
}
let result;
do {
result = this.iterator.next(this.locals.elapsed);
if (result.value instanceof Ticker) {
this.$$ticker = result.value;
const tickerResult = this.$$ticker.tick(elapsed);
this.locals.elapsed -= tickerResult.value;
if (tickerResult.done) {
this.$$ticker = null;
}
else {
break;
}
}
} while (this.locals.elapsed > 0 && !result.done);
if (result.done) {
this.reset();
}
return result.done;
}
ticker() {
return new PromiseUtil.Ticker(
() => {},
(elapsed, resolve, reject) => {
this.tick(elapsed, resolve, reject);
},
);
}
static ticker(code, context = {}) {
let ticker;
return new PromiseUtil.Ticker(
(resolve) => {
this.fromCode(code, context)
.then((script) => {
ticker = script.ticker();
resolve(ticker);
})
},
(elapsed) => {
ticker?.tick?.(elapsed);
},
);
const self = this;
return new Ticker(function* () {
while (true) {
const elapsed = yield;
if (self.tick(elapsed)) {
break;
}
}
});
}
}
function register({default: scripts}) {
for (const path in scripts) {
Script.register(path.slice('../..'.length), scripts[path]);
}
}
register(await import('./scripts.js'));
if (import.meta.hot) {
import.meta.hot.accept('./scripts.js', (M) => {
register(M);
});
}

5
app/util/scripts.js Normal file
View File

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

52
app/util/ticker.js Normal file
View File

@ -0,0 +1,52 @@
export default class Ticker {
constructor(fn) {
this.fn = fn;
this.reset();
}
static all(tickers) {
tickers = [...tickers];
return new this(function* all() {
let consumed = 0;
while (tickers.length > 0) {
const elapsed = yield consumed;
consumed = 0;
for (let i = 0; i < tickers.length; ++i) {
const result = tickers[i].tick(elapsed);
if (result.value > consumed) {
consumed = result.value;
}
if (result.done) {
tickers.splice(i, 1);
i -= 1;
}
}
}
return consumed;
});
}
reset() {
this.iterator = null;
}
tick(elapsed) {
let result;
if (!this.iterator) {
this.iterator = this.fn();
result = this.iterator.next();
}
if (!result || !result.done) {
result = this.iterator.next(elapsed);
}
if (result.done) {
this.reset();
}
return result;
}
static wait(seconds = 0) {
return new this(function* wait() {
while (seconds > 0) {
const elapsed = yield;
seconds -= elapsed;
}
});
}
}

35
app/util/ticker.test.js Normal file
View File

@ -0,0 +1,35 @@
import {expect, test} from 'vitest';
import Ticker from './ticker.js';
function wait(seconds = 0) {
return new Ticker(function* wait() {
let elapsed = 0;
while (seconds > 0) {
elapsed = yield elapsed;
seconds -= elapsed;
}
return seconds + elapsed;
});
}
test('runs ticker', async () => {
const ticker = wait(1);
expect(ticker.tick(0.4)).to.deep.equal({done: false, value: 0.4});
expect(ticker.tick(0.4)).to.deep.equal({done: false, value: 0.4});
const result = ticker.tick(0.4);
expect(result.done).to.be.true;
expect(result.value).to.be.closeTo(0.2, 0.0001);
});
test('runs all tickers', async () => {
const ticker = Ticker.all([
wait(0.5),
wait(0.3),
]);
expect(ticker.tick(0.2)).to.deep.equal({done: false, value: 0.2});
expect(ticker.tick(0.2)).to.deep.equal({done: false, value: 0.2});
const result = ticker.tick(0.2);
expect(result.done).to.be.true;
expect(result.value).to.be.closeTo(0.1, 0.0001);
});

View File

@ -1,10 +1,14 @@
import {Ticker} from '@/util/promise.js';
import Ticker from '@/util/ticker.js';
import * as Easing from './easing';
export default function transition(object, properties) {
const transitions = {};
for (const key in properties) {
const keys = new Set(Object.keys(properties));
function stop() {
keys.clear();
}
for (const key of keys) {
const property = properties[key];
const transition = {
elapsed: 0,
@ -19,30 +23,31 @@ export default function transition(object, properties) {
transition.easing = Easing[transition.easing];
}
}
transition.stop = () => {
keys.delete(key);
},
transitions[key] = transition;
}
let stop;
const promise = new Ticker(
(resolve) => {
stop = resolve;
},
(elapsed, resolve) => {
for (const key in transitions) {
const ticker = new Ticker(function* () {
while (keys.size > 0) {
const elapsed = yield;
for (const key of keys) {
const transition = transitions[key];
transition.elapsed += elapsed;
if (transition.elapsed >= transition.duration) {
object[key] = transition.start + transition.magnitude;
resolve();
return;
transition.stop();
}
else {
object[key] = transition.easing(
transition.elapsed,
transition.start,
transition.magnitude,
transition.duration,
);
}
object[key] = transition.easing(
transition.elapsed,
transition.start,
transition.magnitude,
transition.duration,
);
}
},
);
return {stop, transitions, promise};
}
});
return {stop, ticker, transitions};
}

View File

@ -0,0 +1,91 @@
import {expect, test} from 'vitest';
import transition from './transition.js';
test('mutates', () => {
const O = {x: 0};
const {ticker} = transition(
O,
{
x: {
duration: 1,
easing: 'linear',
magnitude: 0.5,
},
},
);
expect(O.x).to.equal(0);
ticker.tick(0.5);
expect(O.x).to.equal(0.25);
ticker.tick(0.5);
expect(O.x).to.equal(0.5);
ticker.tick(0.5);
expect(O.x).to.equal(0.5);
});
test('does not overshoot', () => {
const O = {x: 0};
const {ticker} = transition(
O,
{
x: {
duration: 1,
easing: 'linear',
magnitude: 0.5,
},
},
);
ticker.tick(0.6);
expect(O.x).to.equal(0.3);
ticker.tick(0.6);
expect(O.x).to.equal(0.5);
});
test('stops mutating', () => {
const O = {x: 0, y: 0.25};
const {stop, ticker, transitions} = transition(
O,
{
x: {
duration: 1,
easing: 'linear',
magnitude: 0.5,
},
y: {
duration: 1,
easing: 'linear',
magnitude: 0.5,
},
},
);
ticker.tick(0.3);
expect(O.x).to.equal(0.15);
expect(O.y).to.equal(0.4);
transitions.x.stop();
ticker.tick(0.3);
expect(O.x).to.equal(0.15);
expect(O.y).to.equal(0.55);
stop();
ticker.tick(0.3);
expect(O.x).to.equal(0.15);
expect(O.y).to.equal(0.55);
});
test('exposes transitions', () => {
const O = {x: 0};
const {ticker, transitions} = transition(
O,
{
x: {
duration: 1,
easing: 'linear',
magnitude: 0.5,
},
},
);
ticker.tick(0.6);
expect(O.x).to.equal(0.3);
transitions.x.duration = 1.8;
ticker.tick(0.6);
expect(O.x).to.be.closeTo(0.3333, 0.0001);
});

7217
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -27,16 +27,13 @@
"@remix-run/express": "^2.9.2",
"@remix-run/node": "^2.9.2",
"@remix-run/react": "^2.9.2",
"acorn": "^8.12.0",
"alea": "^1.0.1",
"astride": "file:../astride",
"compression": "^1.7.4",
"cross-env": "^7.0.3",
"express": "^4.18.2",
"idb-keyval": "^6.2.1",
"isbot": "^4.1.0",
"kefir": "^3.8.8",
"lru-cache": "^10.2.2",
"morgan": "^1.10.0",
"pixi.js": "^7.4.2",
"react": "^18.2.0",

View File

@ -1 +0,0 @@
{"frames":{"":{"frame":{"x":0,"y":0,"w":128,"h":160},"spriteSourceSize":{"x":0,"y":0,"w":128,"h":160},"sourceSize":{"w":128,"h":160}}},"meta":{"format":"RGBA8888","image":"./blossom.png","scale":1,"size":{"w":128,"h":160}}}

View File

@ -0,0 +1,31 @@
{
"frames": {
"": {
"frame": {
"x": 0,
"y": 0,
"w": 128,
"h": 160
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 128,
"h": 160
},
"sourceSize": {
"w": 128,
"h": 160
}
}
},
"meta": {
"format": "RGBA8888",
"image": "./blossom.png",
"scale": 1,
"size": {
"w": 128,
"h": 160
}
}
}

View File

@ -1 +0,0 @@
{"frames":{"":{"frame":{"x":0,"y":0,"w":32,"h":32},"spriteSourceSize":{"x":0,"y":0,"w":32,"h":32},"sourceSize":{"w":32,"h":32}}},"meta":{"format":"RGBA8888","image":"./flower.png","scale":1,"size":{"w":32,"h":32}}}

View File

@ -0,0 +1,31 @@
{
"frames": {
"": {
"frame": {
"x": 0,
"y": 0,
"w": 32,
"h": 32
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 32,
"h": 32
},
"sourceSize": {
"w": 32,
"h": 32
}
}
},
"meta": {
"format": "RGBA8888",
"image": "./flower.png",
"scale": 1,
"size": {
"w": 32,
"h": 32
}
}
}

View File

@ -1 +0,0 @@
{"frames":{"":{"frame":{"x":0,"y":0,"w":32,"h":32},"spriteSourceSize":{"x":0,"y":0,"w":32,"h":32},"sourceSize":{"w":32,"h":32}}},"meta":{"format":"RGBA8888","image":"./shrub.png","scale":1,"size":{"w":32,"h":32}}}

View File

@ -0,0 +1,31 @@
{
"frames": {
"": {
"frame": {
"x": 0,
"y": 0,
"w": 32,
"h": 32
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 32,
"h": 32
},
"sourceSize": {
"w": 32,
"h": 32
}
}
},
"meta": {
"format": "RGBA8888",
"image": "./shrub.png",
"scale": 1,
"size": {
"w": 32,
"h": 32
}
}
}

View File

@ -1 +0,0 @@
{"frames":{"":{"frame":{"x":0,"y":0,"w":96,"h":160},"spriteSourceSize":{"x":0,"y":0,"w":96,"h":160},"sourceSize":{"w":96,"h":160}}},"meta":{"format":"RGBA8888","image":"./tree.png","scale":1,"size":{"w":96,"h":160}}}

View File

@ -0,0 +1,31 @@
{
"frames": {
"": {
"frame": {
"x": 0,
"y": 0,
"w": 96,
"h": 160
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 96,
"h": 160
},
"sourceSize": {
"w": 96,
"h": 160
}
}
},
"meta": {
"format": "RGBA8888",
"image": "./tree.png",
"scale": 1,
"size": {
"w": 96,
"h": 160
}
}
}

View File

@ -1,110 +1,114 @@
const {Collider, Controlled, Interacts, Inventory, Sound, Sprite} = wielder
const entities = Collider.closest(Interacts.aabb());
for (const entity of entities) {
const {Emitter, Position, Tags} = entity;
if (Tags && Tags.has('kittan')) {
Controlled.locked = 1
const [, direction] = Sprite.animation.split(':')
for (let i = 0; i < 2; ++i) {
Sound.play('/resources/brush/brush.wav');
Sprite.animation = ['moving', direction].join(':');
await wait(0.3)
Sprite.animation = ['idle', direction].join(':');
await wait(0.1)
import Ticker from '@/util/ticker.js';
export default function*({wielder}) {
const {Collider, Controlled, Interacts, Inventory, Sound, Sprite} = wielder
const entities = Collider.closest(Interacts.aabb());
for (const entity of entities) {
const {Emitter, Position, Tags} = entity;
if (Tags && Tags.has('kittan')) {
Controlled.locked = 1
const [, direction] = Sprite.animation.split(':')
for (let i = 0; i < 2; ++i) {
Sound.play('/resources/brush/brush.wav');
Sprite.animation = ['moving', direction].join(':');
yield Ticker.wait(0.3)
Sprite.animation = ['idle', direction].join(':');
yield Ticker.wait(0.1)
}
Inventory.give({
qty: 1,
source: '/resources/furball/furball.item.json',
});
Controlled.locked = 0;
const heartParticles = {
behaviors: [
{
type: 'moveAcceleration',
config: {
accel: {
x: 0,
y: -100,
},
minStart: 0,
maxStart: 0,
rotate: false,
}
},
{
type: 'moveSpeed',
config: {
speed: {
list: [
{
time: 0,
value: 30
},
{
time: 1,
value: 0
}
]
}
}
},
{
type: 'scale',
config: {
scale: {
list: [
{
value: 0.5,
time: 0,
},
{
value: 0.125,
time: 1,
},
]
}
}
},
{
type: 'textureSingle',
config: {
texture: '/resources/heart/heart.png',
}
},
],
lifetime: {
min: 0.5,
max: 0.5,
},
frequency: 0.1,
emitterLifetime: 0.25,
pos: {
x: 0,
y: 0
},
rotation: 180,
};
Emitter.emit({
...heartParticles,
behaviors: [
...heartParticles.behaviors,
{
type: 'spawnShape',
config: {
type: 'rect',
data: {
x: Position.x - 8,
y: Position.y,
w: 16,
h: 1,
}
}
}
]
})
break;
}
Inventory.give({
qty: 1,
source: '/resources/furball/furball.json',
});
Controlled.locked = 0;
const heartParticles = {
behaviors: [
{
type: 'moveAcceleration',
config: {
accel: {
x: 0,
y: -100,
},
minStart: 0,
maxStart: 0,
rotate: false,
}
},
{
type: 'moveSpeed',
config: {
speed: {
list: [
{
time: 0,
value: 30
},
{
time: 1,
value: 0
}
]
}
}
},
{
type: 'scale',
config: {
scale: {
list: [
{
value: 0.5,
time: 0,
},
{
value: 0.125,
time: 1,
},
]
}
}
},
{
type: 'textureSingle',
config: {
texture: '/resources/heart/heart.png',
}
},
],
lifetime: {
min: 0.5,
max: 0.5,
},
frequency: 0.1,
emitterLifetime: 0.25,
pos: {
x: 0,
y: 0
},
rotation: 180,
};
Emitter.emit({
...heartParticles,
behaviors: [
...heartParticles.behaviors,
{
type: 'spawnShape',
config: {
type: 'rect',
data: {
x: Position.x - 8,
y: Position.y,
w: 16,
h: 1,
}
}
}
]
})
break;
}
}

View File

@ -1 +0,0 @@
{"frames":{"":{"frame":{"x":0,"y":0,"w":22,"h":22},"spriteSourceSize":{"x":0,"y":0,"w":22,"h":22},"sourceSize":{"w":22,"h":22}}},"meta":{"format":"RGBA8888","image":"./chest.png","scale":1,"size":{"w":22,"h":22}}}

View File

@ -0,0 +1,31 @@
{
"frames": {
"": {
"frame": {
"x": 0,
"y": 0,
"w": 22,
"h": 22
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 22,
"h": 22
},
"sourceSize": {
"w": 22,
"h": 22
}
}
},
"meta": {
"format": "RGBA8888",
"image": "./chest.png",
"scale": 1,
"size": {
"w": 22,
"h": 22
}
}
}

View File

@ -0,0 +1,10 @@
export default function* ({initiator, subject}) {
initiator.Player.openInventory = subject.Inventory;
// subject.Interlocutor.dialogue({
// body: "Sure, I'm a treasure chest. Probably. Do you really think that means you're about to get some treasure? Hah!",
// monopolizer: true,
// offset: {x: 0, y: -48},
// origin: 'track',
// position: 'track',
// })
}

View File

@ -0,0 +1,7 @@
{
"Controlled": {},
"Forces": {},
"Speed": {},
"Ticking": {},
"VisibleAabb": {}
}

View File

@ -0,0 +1,11 @@
import Ticker from '@/util/ticker.js';
export default function *({Controlled, Direction, Speed}, duration, speed) {
let accumulated = 0;
while (accumulated <= duration) {
Controlled.stop();
Controlled.directionMove(Direction.direction);
accumulated += yield Ticker.wait();
Speed.speed = speed * (1 - (accumulated / duration));
}
}

View File

@ -0,0 +1,9 @@
import * as Math from '@/util/math.js';
import Ticker from '@/util/ticker.js';
export default function*({Controlled, Direction, Position}, {x, y}, duration) {
const toward = Math.atan2(y - Position.y, x - Position.x);
Direction.direction = (Math.TAU + toward) % Math.TAU;
Controlled.directionMove(Direction.direction);
yield Ticker.wait(duration);
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +1,15 @@
entity.Direction.direction = Math.random() * Math.TAU;
import * as Math from '@/util/math.js';
import Ticker from '@/util/ticker.js';
entity.Controlled.directionMove(entity.Direction.direction);
await wait(0.25 + Math.random() * 2.25);
entity.Controlled.stop();
entity.Sprite.isAnimating = 0;
await wait(1 + Math.random() * 3);
entity.Direction.direction = Math.random() * Math.TAU;
await wait(0.5 + Math.random() * 2.5);
entity.Sprite.isAnimating = 1;
export default function*({entity}) {
const {Controlled, Direction, Sprite} = entity;
Direction.direction = Math.random() * Math.TAU;
Controlled.directionMove(Direction.direction);
yield Ticker.wait(0.25 + Math.random() * 2.25);
Controlled.stop();
Sprite.isAnimating = 0;
yield Ticker.wait(1 + Math.random() * 3);
Direction.direction = Math.random() * Math.TAU;
yield Ticker.wait(0.5 + Math.random() * 2.5);
Sprite.isAnimating = 1;
}

View File

@ -0,0 +1,16 @@
import * as Math from '@/util/math.js';
export default function* ({subject}) {
const lines = [
'sno<shake>rr</shake>t',
'm<wave>ooooooooooo</wave>',
];
const line = lines[Math.floor(Math.random() * lines.length)];
subject.Interlocutor.dialogue({
body: line,
linger: 2,
offset: {x: 0, y: -16},
origin: 'track',
position: 'track',
})
}

View File

@ -1,17 +1,15 @@
entity.Direction.direction = Math.random() * Math.TAU;
import * as Math from '@/util/math.js';
import Ticker from '@/util/ticker.js';
entity.Controlled.directionMove(entity.Direction.direction);
await wait(0.25 + Math.random() * 2.25);
entity.Controlled.stop();
entity.Sprite.isAnimating = 0;
await wait(1 + Math.random() * 3);
entity.Direction.direction = Math.random() * Math.TAU;
await wait(0.5 + Math.random() * 2.5);
entity.Sprite.isAnimating = 1;
export default function*({entity}) {
const {Controlled, Direction, Sprite} = entity;
Direction.direction = Math.random() * Math.TAU;
Controlled.directionMove(Direction.direction);
yield Ticker.wait(0.25 + Math.random() * 2.25);
Controlled.stop();
Sprite.isAnimating = 0;
yield Ticker.wait(1 + Math.random() * 3);
Direction.direction = Math.random() * Math.TAU;
yield Ticker.wait(0.5 + Math.random() * 2.5);
Sprite.isAnimating = 1;
}

View File

@ -0,0 +1,15 @@
import * as Math from '@/util/math.js';
export default function* ({subject}) {
const lines = [
'Mind your own business, buddy.\n\ner, I mean, <shake>MEEHHHHHH</shake>',
];
const line = lines[Math.floor(Math.random() * lines.length)];
subject.Interlocutor.dialogue({
body: line,
linger: 2,
offset: {x: 0, y: -16},
origin: 'track',
position: 'track',
})
}

View File

@ -0,0 +1,5 @@
import delta from '@/util/delta.js';
export default function*({entity}) {
yield delta(entity.Forces, {forceY: {delta: 480, duration: 0.125}}).ticker;
}

View File

@ -4,11 +4,9 @@
"price": 100,
"projectionCheck": "/resources/hoe/projection-check.js",
"projection": {
"distance": [3, -1],
"distance": [1, 0],
"grid": [
[1, 1, 1],
[1, 1, 1],
[1, 1, 1]
[1]
]
},
"start": "/resources/hoe/start.js"

View File

@ -1,15 +1,14 @@
const layer0 = ecs.get(1).TileLayers.layer(0)
const layer1 = ecs.get(1).TileLayers.layer(1)
const filtered = []
for (const position of projected) {
if (
[1, 2, 3, 4, 6].includes(layer0.tile(position))
&& ![7].includes(layer1.tile(position))
) {
filtered.push(position)
export default function*({ecs, projected}) {
const layer0 = ecs.get(1).TileLayers.layer(0);
const layer1 = ecs.get(1).TileLayers.layer(1);
const filtered = [];
for (const position of projected) {
if (
[1, 2, 3, 4, 6].includes(layer0.tile(position))
&& ![7].includes(layer1.tile(position))
) {
filtered.push(position);
}
}
return filtered;
}
return filtered

View File

@ -1,67 +1,71 @@
const {Direction, Position, Wielder} = wielder
const projected = Wielder.activeItem()?.project(Position.tile, Direction.quantize(4))
if (projected?.length > 0) {
import Ticker from '@/util/ticker.js';
const {Controlled, Emitter, Sound, Sprite} = wielder
const {TileLayers} = ecs.get(1)
const layer = TileLayers.layer(0)
export default function*({ecs, wielder}) {
const {Direction, Position, Wielder} = wielder;
const projected = Wielder.activeItem()?.project(Position.tile, Direction.quantize(4))
if (projected?.length > 0) {
Controlled.locked = 1
const [, direction] = Sprite.animation.split(':')
const {Controlled, Emitter, Sound, Sprite} = wielder
const {TileLayers} = ecs.get(1)
const layer = TileLayers.layer(0)
for (let i = 0; i < 2; ++i) {
Sound.play('/resources/hoe/dig.wav');
for (const {x, y} of projected) {
Emitter.emit({
entity: {
Behaving: {
routines: {
initial: 'await delta(entity.Forces, {forceY: {delta: 640, duration: 0.125}}).promise',
Controlled.locked = 1
const [, direction] = Sprite.animation.split(':')
for (let i = 0; i < 2; ++i) {
Sound.play('/resources/hoe/dig.wav');
for (const {x, y} of projected) {
Emitter.emit({
entity: {
Behaving: {
routines: {
initial: '/resources/hoe/dirt-particle.js',
},
},
Forces: {forceY: -80},
Position: {
x: x * layer.tileSize.x + (layer.tileSize.x / 2),
y: y * layer.tileSize.y + (layer.tileSize.y / 2),
},
Sprite: {
tint: 0x552200,
},
Ttl: {ttl: 0.35},
},
Forces: {forceY: -80},
Position: {
x: x * layer.tileSize.x + (layer.tileSize.x / 2),
y: y * layer.tileSize.y + (layer.tileSize.y / 2),
fields: [
{
path: ['Sprite', 'lightness'],
value: [0.05, 0.25],
},
{
path: ['Sprite', 'alpha'],
value: [0.5, 1],
},
{
path: ['Sprite', 'scale'],
value: [0.05, 0.1],
},
],
frequency: 0.05,
shape: {
type: 'filledRect',
payload: {width: 12, height: 12},
},
Sprite: {
tint: 0x552200,
},
Ttl: {ttl: 0.35},
},
fields: [
{
path: ['Sprite', 'lightness'],
value: [0.05, 0.25],
},
{
path: ['Sprite', 'alpha'],
value: [0.5, 1],
},
{
path: ['Sprite', 'scale'],
value: [0.05, 0.1],
},
],
frequency: 0.05,
shape: {
type: 'filledRect',
payload: {width: 12, height: 12},
},
spurt: 5,
ttl: 0.4,
});
spurt: 5,
ttl: 0.4,
});
}
Sprite.animation = ['moving', direction].join(':');
yield Ticker.wait(0.3)
Sprite.animation = ['idle', direction].join(':');
yield Ticker.wait(0.1)
}
Sprite.animation = ['moving', direction].join(':');
await wait(0.3)
Sprite.animation = ['idle', direction].join(':');
await wait(0.1)
for (const position of projected) {
TileLayers.layer(1).stamp(position, [[7]])
}
Controlled.locked = 0;
}
for (const position of projected) {
TileLayers.layer(1).stamp(position, [[7]])
}
Controlled.locked = 0;
}

View File

@ -0,0 +1,14 @@
export default function* ({ecs, other}) {
if (other.Player) {
ecs.switchEcs(
other,
['houses', other.Player.id].join('/'),
{
Position: {
x: 72,
y: 304,
},
},
);
}
}

View File

@ -0,0 +1,14 @@
export default function* ({ecs, other}) {
if (other.Player) {
ecs.switchEcs(
other,
'town',
{
Position: {
x: 940,
y: 480,
},
},
);
}
}

View File

@ -0,0 +1,14 @@
export default function* ({ecs, other}) {
if (other.Player) {
ecs.switchEcs(
other,
['homesteads', other.Player.id].join('/'),
{
Position: {
x: 74,
y: 128,
},
},
);
}
}

View File

@ -1,17 +1,15 @@
entity.Direction.direction = Math.random() * Math.TAU;
import * as Math from '@/util/math.js';
import Ticker from '@/util/ticker.js';
entity.Controlled.directionMove(entity.Direction.direction);
await wait(0.25 + Math.random() * 2.25);
entity.Controlled.stop();
entity.Sprite.isAnimating = 0;
await wait(1 + Math.random() * 3);
entity.Direction.direction = Math.random() * Math.TAU;
await wait(0.5 + Math.random() * 2.5);
entity.Sprite.isAnimating = 1;
export default function*({entity}) {
const {Controlled, Direction, Sprite} = entity;
Direction.direction = Math.random() * Math.TAU;
Controlled.directionMove(Direction.direction);
yield Ticker.wait(0.25 + Math.random() * 2.25);
Controlled.stop();
Sprite.isAnimating = 0;
yield Ticker.wait(1 + Math.random() * 3);
Direction.direction = Math.random() * Math.TAU;
yield Ticker.wait(0.5 + Math.random() * 2.5);
Sprite.isAnimating = 1;
}

View File

@ -0,0 +1,19 @@
import * as Math from '@/util/math.js';
export default function* ({subject}) {
const lines = [
'mrowwr',
'p<shake>rrr</shake>o<wave>wwwww</wave>',
'mew<rate frequency={0.5}> </rate>mew!',
'me<wave>wwwww</wave>',
'\\*pu<shake>rrrrr</shake>\\*',
];
const line = lines[Math.floor(Math.random() * lines.length)];
subject.Interlocutor.dialogue({
body: line,
linger: 2,
offset: {x: 0, y: -16},
origin: 'track',
position: 'track',
});
}

View File

@ -1,12 +0,0 @@
const playerEntity = ecs.lookupPlayerEntity(entity.Owned.owner);
if (playerEntity !== other && other.Vulnerable) {
const magnitude = Math.floor(Math.random() * 2)
other.Vulnerable.damage({
amount: -Math.floor(
Math.pow(10, magnitude)
+ Math.random() * (Math.pow(10, magnitude + 1) - Math.pow(10, magnitude)),
),
position: other.Position.toJSON(),
type: other.Vulnerable.Types.PAIN,
})
}

Some files were not shown because too many files have changed in this diff Show More