Compare commits

...

52 Commits

Author SHA1 Message Date
cha0s
219ee71c2c fun: animules 2024-08-05 23:23:00 -05:00
cha0s
8e4bfaf6b7 fun: animules 2024-08-05 22:56:59 -05:00
cha0s
b3f162b323 fun: vrooom 2024-08-05 22:56:32 -05:00
cha0s
591c1201f4 fix: reset rotation 2024-08-05 22:47:55 -05:00
cha0s
65f1fe6270 chore: lint 2024-08-05 13:57:15 -05:00
cha0s
06066e5c43 refactor: real time damage position 2024-08-05 13:33:32 -05:00
cha0s
d8323ed9f3 fix: sprite animation 2024-08-05 13:33:09 -05:00
cha0s
02a124cb3e chore: format 2024-08-05 13:32:59 -05:00
cha0s
84ee8ee997 fix: initialization 2024-08-05 13:32:33 -05:00
cha0s
d9f5869a37 fix: timeout 2024-08-05 13:13:25 -05:00
cha0s
9afb5bba81 perf: lazy 2024-08-05 13:12:58 -05:00
cha0s
d6af0199c9 fix: context entities 2024-08-05 03:21:11 -05:00
cha0s
917465a35f refactor: consolidate 2024-08-05 03:06:31 -05:00
cha0s
14add7e8bb perf: entity updates 2024-08-05 02:23:41 -05:00
cha0s
51bdda5eb9 flow+perf: indexing, ticking, flat components, etc. 2024-08-05 01:48:01 -05:00
cha0s
b5698cd392 perf: internals 2024-08-05 01:45:10 -05:00
cha0s
ecd26f0ddb perf: hsl 2024-08-05 01:41:21 -05:00
cha0s
c1a090688e refactor: dynamic names 2024-08-04 23:08:46 -05:00
cha0s
4378ef2a12 perf: less memory churn 2024-08-04 22:34:49 -05:00
cha0s
030bf436c4 perf: initialize 2024-08-04 21:56:31 -05:00
cha0s
e99ae1136b perf: less particle systems 2024-08-04 21:51:48 -05:00
cha0s
91af56c9ff chore: tidy 2024-08-04 20:55:10 -05:00
cha0s
16a094806e refactor: indexing 2024-08-04 20:54:56 -05:00
cha0s
eaec1d0022 feat: spurt + ttl 2024-08-03 16:02:09 -05:00
cha0s
623aabf525 perf: use detached entities 2024-08-03 15:52:52 -05:00
cha0s
172b457a8c perf: detached entities 2024-08-03 15:25:30 -05:00
cha0s
097bf9505f fix: set internal defaults first 2024-08-03 11:34:57 -05:00
cha0s
4cb29e56cd fix: query tests 2024-08-03 11:20:08 -05:00
cha0s
2f5032762a chore: tidy 2024-08-03 00:24:00 -05:00
cha0s
29f6987a48 refactor: component instances 2024-08-01 21:24:54 -05:00
cha0s
bf299e718e refactor: initialization 2024-08-01 21:10:16 -05:00
cha0s
c4f2c4e7d4 refactor: action 2024-08-01 15:44:54 -05:00
cha0s
b467874608 fun: space 2024-08-01 15:44:38 -05:00
cha0s
2e183559ad Revert "perf: micro"
This reverts commit 05350e6ccf.
2024-08-01 14:59:52 -05:00
cha0s
72d114ac74 feat: shapes 2024-08-01 14:32:20 -05:00
cha0s
770a63897d fix: promise result 2024-08-01 14:32:09 -05:00
cha0s
f5d092efaf fix: memory 2024-08-01 14:31:54 -05:00
cha0s
38d76791f7 feat: numeric scale 2024-08-01 14:31:18 -05:00
cha0s
a1873c5295 fix: water immediately 2024-08-01 14:31:08 -05:00
cha0s
c1eb862af2 fix: desync 2024-08-01 13:37:07 -05:00
cha0s
05350e6ccf perf: micro 2024-08-01 13:23:23 -05:00
cha0s
5d352bb367 fix: synchronized updates 2024-08-01 13:23:10 -05:00
cha0s
0e5fcc3ea3 chore: tidy 2024-08-01 13:22:48 -05:00
cha0s
8583772652 refactor: pass through promise 2024-08-01 13:00:21 -05:00
cha0s
93cb69e99a refactor: targeting ghost 2024-08-01 00:40:06 -05:00
cha0s
30caab6c9e feat: fields and colors 2024-08-01 00:39:54 -05:00
cha0s
a4949bd7a0 perf: huge entity gainz 2024-07-31 21:25:11 -05:00
cha0s
b53d7e3d35 perf: entities 2024-07-31 13:01:33 -05:00
cha0s
cb3d9ad8c6 perf: damage 2024-07-31 12:38:46 -05:00
cha0s
2997370e3b refactor: components 2024-07-31 10:16:21 -05:00
cha0s
170cd4e0d1 refactor: useAnimationFrame 2024-07-31 09:47:51 -05:00
cha0s
e4cd769ee2 refactor: hook deps 2024-07-31 09:29:33 -05:00
60 changed files with 1935 additions and 1000 deletions

View File

@ -704,7 +704,10 @@ export default class Sandbox {
switch (result.yield) {
case YIELD_PROMISE: {
stepResult.async = true;
stepResult.value = Promise.resolve(result.value)
const promise = result.value instanceof Promise
? result.value
: Promise.resolve(result.value);
promise
.then((value) => {
const top = this.$$execution.stack[this.$$execution.stack.length - 1];
this.$$execution.deferred.set(top, {
@ -712,6 +715,7 @@ export default class Sandbox {
yield: YIELD_NONE,
});
});
stepResult.value = promise;
break;
}
case YIELD_LOOP_UPDATE: {

View File

@ -2,10 +2,9 @@ import Schema from './schema.js';
export default class Component {
data = [];
ecs;
Instance;
map = {};
instances = {};
pool = [];
static properties = {};
static $$schema;
@ -21,7 +20,7 @@ export default class Component {
results.push(
this.pool.length > 0
? this.pool.pop()
: this.data.push(new this.Instance()) - 1,
: new this.Instance(),
)
count -= 1;
}
@ -38,35 +37,51 @@ export default class Component {
return [];
}
const allocated = this.allocateMany(entries.length);
const {properties} = this.constructor.schema.specification.concrete;
const Schema = this.constructor.schema.constructor;
const keys = Object.keys(properties);
const promises = [];
for (let i = 0; i < entries.length; ++i) {
const [entityId, values = {}] = entries[i];
this.map[entityId] = allocated[i];
this.data[allocated[i]].entity = entityId;
for (let k = 0; k < keys.length; ++k) {
const j = keys[k];
const instance = this.data[allocated[i]];
if (j in values) {
instance[j] = values[j];
}
else {
const defaultValue = Schema.defaultValue(properties[j]);
if ('undefined' !== typeof defaultValue) {
instance[j] = defaultValue;
}
const {
constructor: Schema,
specification: {concrete: {properties}},
} = this.constructor.schema;
const defaults = {};
for (const key in properties) {
defaults[key] = ((key) => () => Schema.defaultValue(properties[key]))(key);
switch (properties[key].type) {
case 'float32':
case 'float64':
case 'int8':
case 'int16':
case 'int32':
case 'int64':
case 'string':
case 'uint8':
case 'uint16':
case 'uint32':
case 'uint64': {
defaults[key] = defaults[key]();
break;
}
}
promises.push(this.load(this.data[allocated[i]]));
}
const keys = new Set(Object.keys(defaults));
const promises = [];
for (let i = 0; i < entries.length; ++i) {
const [entityId, values] = entries[i];
const instance = allocated[i];
instance.entity = entityId;
this.instances[entityId] = instance;
for (const key in values) {
keys.delete(key);
}
const defaultValues = {};
for (const key of keys) {
defaultValues[key] = 'function' === typeof defaults[key]
? defaults[key]()
: defaults[key];
}
instance.initialize(values, defaultValues);
promises.push(this.load(instance));
}
await Promise.all(promises);
const created = [];
for (let i = 0; i < allocated.length; ++i) {
created.push(this.data[allocated[i]]);
}
return created;
return allocated;
}
deserialize(entityId, view, offset) {
@ -79,22 +94,18 @@ export default class Component {
}
destroy(entityId) {
this.destroyMany([entityId]);
this.destroyMany(new Set([entityId]));
}
destroyMany(entities) {
this.freeMany(
entities
.map((entityId) => {
if ('undefined' !== typeof this.map[entityId]) {
return this.map[entityId];
}
throw new Error(`can't free for non-existent id ${entityId}`);
}),
);
for (let i = 0; i < entities.length; i++) {
this.data[this.map[entities[i]]].destroy();
this.map[entities[i]] = undefined;
destroyMany(entityIds) {
for (const entityId of entityIds) {
const instance = this.instances[entityId];
if ('undefined' === typeof instance) {
throw new Error(`can't free for non-existent id ${entityId}`);
}
instance.destroy();
this.pool.push(instance);
delete this.instances[entityId];
}
}
@ -110,14 +121,8 @@ export default class Component {
return json;
}
freeMany(indices) {
for (let i = 0; i < indices.length; ++i) {
this.pool.push(indices[i]);
}
}
get(entityId) {
return this.data[this.map[entityId]];
return this.instances[entityId];
}
async insertMany(entities) {
@ -127,25 +132,25 @@ export default class Component {
instanceFromSchema() {
const Component = this;
const {concrete} = Component.constructor.schema.specification;
const Schema = Component.constructor.schema.constructor;
const Instance = class {
$$entity = 0;
constructor() {
this.$$reset();
}
$$reset() {
for (const key in concrete.properties) {
this[`$$${key}`] = Schema.defaultValue(concrete.properties[key]);
}
}
destroy() {}
initialize(values, defaults) {
for (const key in values) {
this[`$$${key}`] = values[key];
}
for (const key in defaults) {
this[`$$${key}`] = defaults[key];
}
Component.ecs.markChange(this.entity, {[Component.constructor.componentName]: values})
}
toNet(recipient, data) {
return data || Component.constructor.filterDefaults(this);
}
toJSON() {
return Component.constructor.filterDefaults(this);
}
update(values) {
async update(values) {
for (const key in values) {
if (concrete.properties[key]) {
this[`$$${key}`] = values[key];
@ -163,7 +168,6 @@ export default class Component {
},
set: function set(v) {
this.$$entity = v;
this.$$reset();
},
};
for (const key in concrete.properties) {
@ -214,10 +218,12 @@ export default class Component {
}
async updateMany(entities) {
const promises = [];
for (let i = 0; i < entities.length; i++) {
const [entityId, values] = entities[i];
this.get(entityId).update(values);
promises.push(this.get(entityId).update(values));
}
return Promise.all(promises);
}
}

View File

@ -19,6 +19,7 @@ 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);
const ticker = this.$$death.ticker();
ecs.addDestructionDependency(this.entity.id, ticker);
Ticking.add(ticker);
@ -35,7 +36,6 @@ export default class Alive extends Component {
instance.deathScript,
{
ecs: this.ecs,
entity: this.ecs.get(instance.entity),
},
);
if (0 === instance.maxHealth) {

View File

@ -2,11 +2,13 @@ import Component from '@/ecs/component.js';
export default class Behaving extends Component {
instanceFromSchema() {
const {ecs} = this;
return class BehavingInstance extends super.instanceFromSchema() {
$$routineInstances = {};
tick(elapsed) {
const routine = this.$$routineInstances[this.currentRoutine];
if (routine) {
routine.context.entity = ecs.get(this.entity);
routine.tick(elapsed);
}
}
@ -20,12 +22,7 @@ export default class Behaving extends Component {
const promises = [];
for (const key in instance.routines) {
promises.push(
this.ecs.readScript(
instance.routines[key],
{
entity: this.ecs.get(instance.entity),
},
)
this.ecs.readScript(instance.routines[key])
.then((script) => {
instance.$$routineInstances[key] = script;
}),

View File

@ -106,6 +106,7 @@ 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];
const ticker = script.ticker();
@ -115,6 +116,7 @@ export default class Collider extends Component {
}
if (other.$$collisionStart) {
const script = other.$$collisionStart.clone();
script.context.entity = otherEntity;
script.context.other = thisEntity;
script.context.pair = [otherBody, body];
const ticker = script.ticker();
@ -229,7 +231,7 @@ export default class Collider extends Component {
this.$$aabb = {x0: Infinity, x1: -Infinity, y0: Infinity, y1: -Infinity};
this.$$aabbs = [];
const {bodies} = this;
const {Direction: {direction = 0} = {}} = ecs.get(this.entity);
const {Direction: {direction = 0} = {}} = ecs.get(this.entity) || {};
for (const body of bodies) {
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
for (const point of transform(body.points, {rotation: direction})) {
@ -271,14 +273,12 @@ export default class Collider extends Component {
instance.collisionEndScript,
{
ecs: this.ecs,
entity: this.ecs.get(instance.entity),
},
);
instance.$$collisionStart = await this.ecs.readScript(
instance.collisionStartScript,
{
ecs: this.ecs,
entity: this.ecs.get(instance.entity),
},
);
}

View File

@ -6,9 +6,13 @@ const Gathered = gather(
const Components = {};
for (const componentName in Gathered) {
Components[componentName] = class Named extends Gathered[componentName] {
static componentName = componentName;
};
Components[componentName] = eval(`
((Gathered) => (
class ${componentName} extends Gathered['${componentName}'] {
static componentName = '${componentName}';
}
))
`)(Gathered);
}
export default Components;

View File

@ -8,7 +8,8 @@ export default class Interactive extends Component {
interact(initiator) {
const script = this.$$interact.clone();
script.context.initiator = initiator;
const {Ticking} = ecs.get(this.entity);
script.context.subject = ecs.get(this.entity);
const {Ticking} = script.context.subject;
Ticking.add(script.ticker());
}
get interacting() {
@ -28,7 +29,6 @@ export default class Interactive extends Component {
instance.interactScript,
{
ecs: this.ecs,
subject: this.ecs.get(instance.entity),
},
);
}

View File

@ -1,9 +1,13 @@
import Component from '@/ecs/component.js';
import {hexToHsl, hslToHex} from '@/util/color.js';
export default class Sprite extends Component {
instanceFromSchema() {
return class SpriteInstance extends super.instanceFromSchema() {
$$anchor = {x: 0.5, y: 0.5};
$$hue = 0;
$$saturation = 0;
$$lightness = 1;
$$scale = {x: 1, y: 1};
$$sourceJson = {};
get anchor() {
@ -14,12 +18,14 @@ export default class Sprite extends Component {
}
set anchorX(anchorX) {
this.$$anchor = {x: anchorX, y: this.anchorY};
super.anchorX = anchorX;
}
get anchorY() {
return this.$$anchor.y;
}
set anchorY(anchorY) {
this.$$anchor = {x: this.anchorX, y: anchorY};
super.anchorY = anchorY;
}
get animation() {
return super.animation;
@ -57,6 +63,37 @@ export default class Sprite extends Component {
}
return true;
}
get hue() {
return this.$$hue;
}
set hue(hue) {
this.$$hue = hue;
const [, s, l] = hexToHsl(this.$$tint);
super.tint = hslToHex(hue, s, l);
}
initialize(values, defaults) {
let {
animation = defaults.animation,
anchorX = defaults.anchorX,
anchorY = defaults.anchorY,
frame = defaults.frame,
scaleX = defaults.scaleX,
scaleY = defaults.scaleY,
} = values;
this.$$anchor = {x: anchorX, y: anchorY};
this.$$scale = {x: scaleX, y: scaleY};
super.initialize(values, defaults);
this.frame = frame;
this.animation = animation;
}
get lightness() {
return this.$$lightness;
}
set lightness(lightness) {
this.$$lightness = lightness;
const [h, s] = hexToHsl(this.$$tint);
super.tint = hslToHex(h, s, lightness);
}
get rotates() {
if (!this.$$sourceJson.meta) {
return false;
@ -69,20 +106,37 @@ export default class Sprite extends Component {
}
return this.$$sourceJson.meta.rotation;
}
get saturation() {
return this.$$saturation;
}
set saturation(saturation) {
this.$$saturation = saturation;
const [h, , l] = hexToHsl(this.$$tint);
super.tint = hslToHex(h, saturation, l);
}
get scale() {
return this.$$scale;
}
set scale(scale) {
if ('number' === typeof scale) {
scale = {x: scale, y: scale};
}
this.scaleX = scale.x;
this.scaleY = scale.y;
}
get scaleX() {
return this.$$scale.x;
}
set scaleX(scaleX) {
this.$$scale = {x: scaleX, y: this.scaleY};
super.scaleX = scaleX;
}
get scaleY() {
return this.$$scale.y;
}
set scaleY(scaleY) {
this.$$scale = {x: this.scaleX, y: scaleY};
super.scaleY = scaleY;
}
get size() {
if (!this.$$sourceJson.frames) {
@ -93,11 +147,48 @@ export default class Sprite extends Component {
: '';
return this.$$sourceJson.frames[frame].sourceSize;
}
get tint() {
return super.tint;
}
set tint(tint) {
[
this.$$hue,
this.$$saturation,
this.$$lightness,
] = hexToHsl(tint)
super.tint = tint;
}
toNet(recipient, data) {
// eslint-disable-next-line no-unused-vars
const {elapsed, ...rest} = super.toNet(recipient, data);
return rest;
}
update(values) {
for (const key in values) {
switch (key) {
case 'anchorX': {
this.$$anchor.x = values[key];
break;
}
case 'anchorY': {
this.$$anchor.y = values[key];
break;
}
case 'scaleX': {
this.$$scale.x = values[key];
break;
}
case 'scaleY': {
this.$$scale.y = values[key];
break;
}
default: {
continue;
}
}
}
return super.update(values);
}
};
}
async load(instance) {
@ -123,6 +214,6 @@ export default class Sprite extends Component {
scaleY: {defaultValue: 1, type: 'float32'},
source: {type: 'string'},
speed: {type: 'float32'},
tint: {type: 'uint32'},
tint: {defaultValue: 0xffffff, type: 'uint32'},
};
}

View File

@ -5,9 +5,15 @@ export default class Ttl extends Component {
const {ecs} = this;
return class TtlInstance extends super.instanceFromSchema() {
$$elapsed = 0;
$$reset() {
destroy() {
this.$$elapsed = 0;
}
get elapsed() {
return this.$$elapsed;
}
set elapsed(elapsed) {
this.$$elapsed = elapsed;
}
tick(elapsed) {
this.$$elapsed += elapsed;
if (this.$$elapsed >= this.ttl) {

View File

@ -1,7 +1,7 @@
import {Encoder, Decoder} from '@msgpack/msgpack';
import {LRUCache} from 'lru-cache';
import {Ticker, withResolvers} from '@/util/promise.js';
import {withResolvers} from '@/util/promise.js';
import Script from '@/util/script.js';
import EntityFactory from './entity-factory.js';
@ -23,16 +23,22 @@ export default class Ecs {
deferredChanges = {}
$$deindexing = new Set();
$$destructionDependencies = new Map();
diff = {};
$$detached = new Set();
Systems = {};
diff = {};
$$entities = {};
$$entityFactory = new EntityFactory();
$$reindexing = new Set();
Systems = {};
constructor({Systems, Components} = {}) {
for (const componentName in Components) {
this.Components[componentName] = new Components[componentName](this);
@ -58,7 +64,7 @@ export default class Ecs {
async apply(patch) {
const creating = [];
const destroying = [];
const destroying = new Set();
const inserting = [];
const removing = [];
const updating = [];
@ -66,7 +72,7 @@ export default class Ecs {
const entityId = parseInt(entityIdString);
const components = patch[entityId];
if (false === components) {
destroying.push(entityId);
destroying.add(entityId);
continue;
}
const componentsToRemove = [];
@ -82,22 +88,26 @@ export default class Ecs {
if (componentsToRemove.length > 0) {
removing.push([entityId, componentsToRemove]);
}
if (this.$$entities[entityId]) {
const entity = this.$$entities[entityId];
if (this.$$entities[entityIdString]) {
const entity = this.$$entities[entityIdString];
let isInserting = false;
const entityInserts = {};
let isUpdating = false;
const entityUpdates = {};
for (const componentName in componentsToUpdate) {
if (entity[componentName]) {
entityUpdates[componentName] = componentsToUpdate[componentName];
isUpdating = true;
}
else {
entityInserts[componentName] = componentsToUpdate[componentName];
isInserting = true;
}
}
if (Object.keys(entityInserts).length > 0) {
if (isInserting) {
inserting.push([entityId, entityInserts]);
}
if (Object.keys(entityUpdates).length > 0) {
if (isUpdating) {
updating.push([entityId, entityUpdates]);
}
}
@ -105,9 +115,6 @@ export default class Ecs {
creating.push([entityId, componentsToUpdate]);
}
}
if (destroying.length > 0) {
this.destroyMany(destroying);
}
const promises = [];
if (inserting.length > 0) {
promises.push(this.insertMany(inserting));
@ -119,11 +126,30 @@ export default class Ecs {
promises.push(this.createManySpecific(creating));
}
await Promise.all(promises);
if (destroying.size > 0) {
this.destroyMany(destroying);
}
if (removing.length > 0) {
this.removeMany(removing);
}
}
applyDeferredChanges(entityId) {
const changes = this.deferredChanges[entityId];
delete this.deferredChanges[entityId];
for (const components of changes) {
this.markChange(entityId, components);
}
}
attach(entityIds) {
for (const entityId of entityIds) {
this.$$detached.delete(entityId);
this.$$reindexing.add(entityId);
this.applyDeferredChanges(entityId);
}
}
changed(criteria) {
const it = Object.entries(this.diff).values();
return {
@ -158,10 +184,27 @@ export default class Ecs {
return entityId;
}
async createDetached(components = {}) {
const [entityId] = await this.createManyDetached([components]);
return entityId;
}
async createMany(componentsList) {
const specificsList = [];
for (const components of componentsList) {
specificsList.push([this.$$caret++, components]);
specificsList.push([this.$$caret, components]);
this.$$caret += 1;
}
return this.createManySpecific(specificsList);
}
async createManyDetached(componentsList) {
const specificsList = [];
for (const components of componentsList) {
specificsList.push([this.$$caret, components]);
this.$$detached.add(this.$$caret);
this.deferredChanges[this.$$caret] = [];
this.$$caret += 1;
}
return this.createManySpecific(specificsList);
}
@ -170,19 +213,20 @@ export default class Ecs {
if (0 === specificsList.length) {
return;
}
const entityIds = [];
const entityIds = new Set();
const creating = {};
for (let i = 0; i < specificsList.length; i++) {
const [entityId, components] = specificsList[i];
this.deferredChanges[entityId] = [];
if (!this.$$detached.has(entityId)) {
this.deferredChanges[entityId] = [];
}
const componentNames = [];
for (const componentName in components) {
if (this.Components[componentName]) {
componentNames.push(componentName);
}
}
entityIds.push(entityId);
this.rebuild(entityId, () => componentNames);
entityIds.add(entityId);
for (const componentName of componentNames) {
if (!creating[componentName]) {
creating[componentName] = [];
@ -197,28 +241,38 @@ export default class Ecs {
}
await Promise.all(promises);
for (let i = 0; i < specificsList.length; i++) {
const [entityId] = specificsList[i];
const changes = this.deferredChanges[entityId];
delete this.deferredChanges[entityId];
for (const components of changes) {
this.markChange(entityId, components);
const [entityId, components] = specificsList[i];
this.$$reindexing.add(entityId);
this.rebuild(entityId, () => Object.keys(components));
if (this.$$detached.has(entityId)) {
continue;
}
this.applyDeferredChanges(entityId);
}
this.reindex(entityIds);
return entityIds;
}
async createSpecific(entityId, components) {
return this.createManySpecific([[entityId, components]]);
const [created] = await this.createManySpecific([[entityId, components]]);
return created;
}
deindex(entityIds) {
// Stage 4 Draft / July 6, 2024
// const attached = entityIds.difference(this.$$detached);
const attached = new Set(entityIds);
for (const detached of this.$$detached) {
attached.delete(detached);
}
if (0 === attached.size) {
return;
}
for (const systemName in this.Systems) {
const System = this.Systems[systemName];
if (!System.active) {
continue;
}
System.deindex(entityIds);
System.deindex(attached);
}
}
@ -259,14 +313,11 @@ export default class Ecs {
return dependencies.resolvers.promise;
}
destroyAll() {
this.destroyMany(this.entities);
}
destroyMany(entityIds) {
const destroying = {};
this.deindex(entityIds);
// this.deindex(entityIds);
for (const entityId of entityIds) {
this.$$deindexing.add(entityId);
if (!this.$$entities[entityId]) {
throw new Error(`can't destroy non-existent entity ${entityId}`);
}
@ -286,6 +337,13 @@ export default class Ecs {
}
}
detach(entityIds) {
for (const entityId of entityIds) {
this.$$deindexing.add(entityId);
this.$$detached.add(entityId);
}
}
get entities() {
const ids = [];
for (const entity of Object.values(this.$$entities)) {
@ -306,7 +364,6 @@ export default class Ecs {
const inserting = {};
const unique = new Set();
for (const [entityId, components] of entities) {
this.rebuild(entityId, (componentNames) => [...new Set(componentNames.concat(Object.keys(components)))]);
const diff = {};
for (const componentName in components) {
if (!inserting[componentName]) {
@ -323,7 +380,10 @@ export default class Ecs {
promises.push(this.Components[componentName].insertMany(inserting[componentName]));
}
await Promise.all(promises);
this.reindex(unique.values());
for (const [entityId, components] of entities) {
this.$$reindexing.add(entityId);
this.rebuild(entityId, (componentNames) => [...new Set(componentNames.concat(Object.keys(components)))]);
}
}
markChange(entityId, components) {
@ -337,13 +397,7 @@ export default class Ecs {
}
// Created?
else if (!this.diff[entityId]) {
const filtered = {};
for (const componentName in components) {
filtered[componentName] = false === components[componentName]
? false
: components[componentName];
}
this.diff[entityId] = filtered;
this.diff[entityId] = components;
}
// Otherwise, merge.
else {
@ -415,12 +469,21 @@ export default class Ecs {
}
reindex(entityIds) {
// Stage 4 Draft / July 6, 2024
// const attached = entityIds.difference(this.$$detached);
const attached = new Set(entityIds);
for (const detached of this.$$detached) {
attached.delete(detached);
}
if (0 === attached.size) {
return;
}
for (const systemName in this.Systems) {
const System = this.Systems[systemName];
if (!System.active) {
continue;
}
System.reindex(entityIds);
System.reindex(attached);
}
}
@ -442,12 +505,14 @@ export default class Ecs {
removing[componentName].push(entityId);
}
this.markChange(entityId, diff);
this.rebuild(entityId, (componentNames) => componentNames.filter((type) => !components.includes(type)));
}
for (const componentName in removing) {
this.Components[componentName].destroyMany(removing[componentName]);
}
this.reindex(unique);
for (const [entityId, components] of entities) {
this.$$reindexing.add(entityId);
this.rebuild(entityId, (componentNames) => componentNames.filter((type) => !components.includes(type)));
}
}
static serialize(ecs, view) {
@ -468,6 +533,7 @@ export default class Ecs {
}
tick(elapsed) {
// tick systems
for (const systemName in this.Systems) {
const System = this.Systems[systemName];
if (!System.active) {
@ -483,6 +549,7 @@ export default class Ecs {
System.elapsed -= System.frequency;
}
}
// destroy entities
const destroying = new Set();
for (const [entityId, {promises}] of this.$$destructionDependencies) {
if (0 === promises.size) {
@ -496,6 +563,15 @@ export default class Ecs {
this.$$destructionDependencies.delete(entityId);
}
}
// update indices
if (this.$$deindexing.size > 0) {
this.deindex(this.$$deindexing);
this.$$deindexing.clear();
}
if (this.$$reindexing.size > 0) {
this.reindex(this.$$reindexing);
this.$$reindexing.clear();
}
}
toJSON() {
@ -528,6 +604,7 @@ export default class Ecs {
}
updating[componentName].push([entityId, components[componentName]]);
}
this.$$reindexing.add(entityId);
unique.add(entityId);
}
const promises = [];
@ -535,7 +612,6 @@ export default class Ecs {
promises.push(this.Components[componentName].updateMany(updating[componentName]));
}
await Promise.all(promises);
this.reindex(unique.values());
}
}

View File

@ -110,11 +110,11 @@ test('destroys entities', async () => {
.to.deep.equal(JSON.stringify({Empty: {}, Position: {y: 128}}));
expect(ecs.get(entity))
.to.not.be.undefined;
ecs.destroyAll();
ecs.destroyMany(new Set([entity]));
expect(ecs.get(entity))
.to.be.undefined;
expect(() => {
ecs.destroyMany([entity]);
ecs.destroyMany(new Set([entity]));
})
.to.throw();
});
@ -122,10 +122,10 @@ test('destroys entities', async () => {
test('inserts components into entities', async () => {
const ecs = new Ecs({Components: {Empty, Position}});
const entity = await ecs.create({Empty: {}});
ecs.insert(entity, {Position: {y: 128}});
await ecs.insert(entity, {Position: {y: 128}});
expect(JSON.stringify(ecs.get(entity)))
.to.deep.equal(JSON.stringify({Empty: {}, Position: {y: 128}}));
ecs.insert(entity, {Position: {y: 64}});
await ecs.insert(entity, {Position: {y: 64}});
expect(JSON.stringify(ecs.get(entity)))
.to.deep.equal(JSON.stringify({Empty: {}, Position: {y: 64}}));
});
@ -197,6 +197,39 @@ test('schedules entities to be deleted when ticking systems', async () => {
.to.be.undefined;
});
test('skips indexing detached entities', async () => {
const ecs = new Ecs({
Components: {Empty},
Systems: {
Indexer: class extends System {
static queries() {
return {
default: ['Empty'],
};
}
},
},
});
const {$$map: map} = ecs.system('Indexer').queries.default;
ecs.system('Indexer').active = true;
const attached = await ecs.create({Empty: {}});
ecs.tick(0);
expect(Array.from(map.keys()))
.to.deep.equal([attached]);
ecs.destroyMany(new Set([attached]));
ecs.tick(0);
expect(Array.from(map.keys()))
.to.deep.equal([]);
const detached = await ecs.createDetached({Empty: {}});
ecs.tick(0);
expect(Array.from(map.keys()))
.to.deep.equal([]);
ecs.destroyMany(new Set([detached]));
ecs.tick(0);
expect(Array.from(map.keys()))
.to.deep.equal([]);
});
test('generates diffs for entity creation', async () => {
const ecs = new Ecs();
let entity;
@ -210,7 +243,7 @@ test('generates diffs for adding and removing components', async () => {
let entity;
entity = await ecs.create();
ecs.setClean();
ecs.insert(entity, {Position: {x: 64}});
await ecs.insert(entity, {Position: {x: 64}});
expect(ecs.diff)
.to.deep.equal({[entity]: {Position: {x: 64}}});
ecs.setClean();
@ -246,6 +279,23 @@ test('generates diffs for entity mutations', async () => {
.to.deep.equal({});
});
test('generates no diffs for detached entities', async () => {
const ecs = new Ecs({Components: {Position}});
let entity;
entity = await ecs.createDetached();
expect(ecs.diff)
.to.deep.equal({});
await ecs.insert(entity, {Position: {x: 64}});
expect(ecs.diff)
.to.deep.equal({});
ecs.get(entity).Position.x = 128;
expect(ecs.diff)
.to.deep.equal({});
ecs.remove(entity, ['Position']);
expect(ecs.diff)
.to.deep.equal({});
});
test('generates coalesced diffs for components', async () => {
const ecs = new Ecs({Components: {Position}});
let entity;
@ -253,7 +303,7 @@ test('generates coalesced diffs for components', async () => {
ecs.remove(entity, ['Position']);
expect(ecs.diff)
.to.deep.equal({[entity]: {Position: false}});
ecs.insert(entity, {Position: {}});
await ecs.insert(entity, {Position: {}});
expect(ecs.diff)
.to.deep.equal({[entity]: {Position: {}}});
});
@ -290,11 +340,11 @@ test('applies creation patches', async () => {
.to.equal(64);
});
test('applies update patches', () => {
test('applies update patches', async () => {
const ecs = new Ecs({Components: {Position}});
ecs.createSpecific(16, {Position: {x: 64}});
ecs.apply({16: {Position: {x: 128}}});
expect(Array.from(ecs.entities).length)
await ecs.createSpecific(16, {Position: {x: 64}});
await ecs.apply({16: {Position: {x: 128}}});
expect(Object.keys(ecs.$$entities).length)
.to.equal(1);
expect(ecs.get(16).Position.x)
.to.equal(128);
@ -318,9 +368,9 @@ test('applies component deletion patches', async () => {
.to.deep.equal(['Position']);
});
test('calculates entity size', () => {
test('calculates entity size', async () => {
const ecs = new Ecs({Components: {Empty, Position}});
ecs.createSpecific(1, {Empty: {}, Position: {}});
await 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())
@ -329,8 +379,8 @@ test('calculates entity size', () => {
test('serializes and deserializes', async () => {
const ecs = new Ecs({Components: {Empty, Name, Position}});
ecs.createSpecific(1, {Empty: {}, Position: {x: 64}});
ecs.createSpecific(16, {Name: {name: 'foobar'}, Position: {x: 128}});
await ecs.createSpecific(1, {Empty: {}, Position: {x: 64}});
await ecs.createSpecific(16, {Name: {name: 'foobar'}, Position: {x: 128}});
expect(ecs.toJSON())
.to.deep.equal({
entities: {
@ -362,8 +412,8 @@ test('serializes and deserializes', async () => {
test('deserializes from compatible ECS', async () => {
const ecs = new Ecs({Components: {Empty, Name, Position}});
ecs.createSpecific(1, {Empty: {}, Position: {x: 64}});
ecs.createSpecific(16, {Name: {name: 'foobar'}, Position: {x: 128}});
await ecs.createSpecific(1, {Empty: {}, Position: {x: 64}});
await ecs.createSpecific(16, {Name: {name: 'foobar'}, Position: {x: 128}});
const view = Ecs.serialize(ecs);
const deserialized = await Ecs.deserialize(
new Ecs({Components: {Empty, Name}}),

View File

@ -23,6 +23,9 @@ export default class EntityFactory {
static componentNames = sorted;
constructor(id) {
this.id = id;
for (const type of sorted) {
this[type] = Components[type].get(id);
}
}
size() {
let size = 0;
@ -34,15 +37,6 @@ export default class EntityFactory {
return size + 4 + 2;
}
}
const properties = {};
for (const type of sorted) {
properties[type] = {};
const get = Components[type].get.bind(Components[type]);
properties[type].get = function() {
return get(this.id);
};
}
Object.defineProperties(Entity.prototype, properties);
Entity.prototype.updateAttachments = new Function('update', `
${
sorted

View File

@ -2,7 +2,7 @@ export default class Query {
$$criteria = {with: [], without: []};
$$ecs;
$$index = new Set();
$$map = new Map();
constructor(parameters, ecs) {
this.$$ecs = ecs;
@ -20,19 +20,19 @@ export default class Query {
}
get count() {
return this.$$index.size;
return this.$$map.size;
}
deindex(entityIds) {
for (const entityId of entityIds) {
this.$$index.delete(entityId);
this.$$map.delete(entityId);
}
}
reindex(entityIds) {
if (0 === this.$$criteria.with.length && 0 === this.$$criteria.without.length) {
for (const entityId of entityIds) {
this.$$index.add(entityId);
this.$$map.set(entityId, this.$$ecs.get(entityId));
}
return;
}
@ -53,28 +53,16 @@ export default class Query {
}
}
if (should) {
this.$$index.add(entityId);
this.$$map.set(entityId, this.$$ecs.get(entityId));
}
else if (!should) {
this.$$index.delete(entityId);
this.$$map.delete(entityId);
}
}
}
select() {
const it = this.$$index.values();
return {
[Symbol.iterator]() {
return this;
},
next: () => {
const result = it.next();
if (result.done) {
return {done: true, value: undefined};
}
return {done: false, value: this.$$ecs.get(result.value)};
},
};
return this.$$map.values();
}
}

View File

@ -1,37 +1,39 @@
import {expect, test} from 'vitest';
import Ecs from './ecs';
import Component from './component.js';
import Query from './query.js';
function wrapProperties(name, properties) {
return class WrappedComponent extends Component {
static name = name;
static properties = properties;
};
}
const Components = [
['A', {a: {type: 'int32', defaultValue: 64}}],
['B', {b: {type: 'int32', defaultValue: 32}}],
['C', {c: {type: 'int32'}}],
]
.reduce((Components, [componentName, properties]) => {
return {
...Components,
[componentName]: class extends Component {
static componentName = componentName;
static properties = properties;
},
};
}, {})
const A = new (wrapProperties('A', {a: {type: 'int32', defaultValue: 420}}));
const B = new (wrapProperties('B', {b: {type: 'int32', defaultValue: 69}}));
const C = new (wrapProperties('C', {c: {type: 'int32'}}));
const Components = {A, B, C};
Components.A.createMany([[2], [3]]);
Components.B.createMany([[1], [2]]);
Components.C.createMany([[2], [4]]);
const fakeEcs = (Components) => ({
Components,
get(id) {
return Object.fromEntries(
Object.entries(Components)
.map(([componentName, Component]) => [componentName, Component.get(id)])
.concat([['id', id]])
);
const ecsTest = test.extend({
ecs: async ({}, use) => {
const ecs = new Ecs({Components});
await ecs.createManySpecific([
[1, {B: {}}],
[2, {A: {}, B: {}, C: {}}],
[3, {A: {}}],
[4, {C: {}}],
]);
await use(ecs);
},
});
function testQuery(parameters, expected) {
const query = new Query(parameters, fakeEcs(Components));
async function testQuery(ecs, parameters, expected) {
const query = new Query(parameters, ecs);
query.reindex([1, 2, 3]);
expect(query.count)
.to.equal(expected.length);
@ -41,22 +43,22 @@ function testQuery(parameters, expected) {
}
}
test('can query all', () => {
testQuery([], [1, 2, 3]);
ecsTest('can query all', async ({ecs}) => {
await testQuery(ecs, [], [1, 2, 3]);
});
test('can query some', () => {
testQuery(['A'], [2, 3]);
testQuery(['A', 'B'], [2]);
ecsTest('can query some', async ({ecs}) => {
await testQuery(ecs, ['A'], [2, 3]);
await testQuery(ecs, ['A', 'B'], [2]);
});
test('can query excluding', () => {
testQuery(['!A'], [1]);
testQuery(['A', '!B'], [3]);
ecsTest('can query excluding', async ({ecs}) => {
await testQuery(ecs, ['!A'], [1]);
await testQuery(ecs, ['A', '!B'], [3]);
});
test('can deindex', () => {
const query = new Query(['A'], fakeEcs(Components));
ecsTest('can deindex', async ({ecs}) => {
const query = new Query(['A'], ecs);
query.reindex([1, 2, 3]);
expect(query.count)
.to.equal(2);
@ -65,24 +67,22 @@ test('can deindex', () => {
.to.equal(1);
});
test('can reindex', () => {
const Test = new (wrapProperties('Test', {a: {type: 'int32', defaultValue: 420}}));
Test.createMany([[2], [3]]);
const query = new Query(['Test'], fakeEcs({Test}));
query.reindex([2, 3]);
ecsTest('can reindex', async ({ecs}) => {
const query = new Query(['B'], ecs);
query.reindex([1, 2]);
expect(query.count)
.to.equal(2);
Test.destroy(2);
query.reindex([2, 3]);
ecs.destroyMany(new Set([2]));
query.reindex([1, 2]);
expect(query.count)
.to.equal(1);
});
test('can select', () => {
const query = new Query(['A'], fakeEcs(Components));
ecsTest('can select', async ({ecs}) => {
const query = new Query(['A'], ecs);
query.reindex([1, 2, 3]);
const it = query.select();
const {value: {A}} = it.next();
expect(A.a)
.to.equal(420);
.to.equal(64);
});

View File

@ -35,6 +35,7 @@ export default class Schema {
return {
$: this.$$types[type],
concrete: $$type.normalize ? $$type.normalize(rest) : rest,
type,
};
}

View File

@ -20,7 +20,6 @@ export default class System {
for (const i in queries) {
this.queries[i] = new Query(queries[i], ecs);
}
this.reindex(ecs.entities);
}
deindex(entityIds) {
@ -45,6 +44,10 @@ export default class System {
}
}
schedule() {
this.elapsed = this.frequency;
}
select(query) {
return this.queries[query].select();
}

View File

@ -10,8 +10,8 @@ export default class IntegratePhysics extends System {
tick(elapsed) {
for (const {Position, Forces} of this.select('default')) {
Position.x += elapsed * (Forces.impulseX + Forces.forceX);
Position.y += elapsed * (Forces.impulseY + Forces.forceY);
Position.x = Position.$$x + elapsed * (Forces.$$impulseX + Forces.$$forceX);
Position.y = Position.$$y + elapsed * (Forces.$$impulseY + Forces.$$forceY);
}
}

View File

@ -54,8 +54,10 @@ export default class MaintainColliderHash extends System {
within(query) {
const within = new Set();
for (const id of this.hash.within(query)) {
within.add(this.ecs.get(id));
if (this.hash) {
for (const id of this.hash.within(query)) {
within.add(this.ecs.get(id));
}
}
return within;
}

View File

@ -1,22 +1,50 @@
import K from 'kefir';
import * as easings from '@/util/easing.js';
import {TAU} from '@/util/math.js';
export default class Emitter {
constructor(ecs) {
this.ecs = ecs;
this.scheduled = [];
}
async allocate({entity, shape}) {
const allocated = this.ecs.get(await this.ecs.create(entity));
async configure(entityId, {fields, shape}) {
const entity = this.ecs.get(entityId);
if (shape) {
switch (shape.type) {
case 'circle': {
const r = Math.random() * TAU;
entity.Position.x += Math.cos(r) * shape.payload.radius;
entity.Position.y += Math.sin(r) * shape.payload.radius;
break;
}
case 'filledCircle': {
const r = Math.random() * TAU;
entity.Position.x += Math.cos(r) * Math.random() * shape.payload.radius;
entity.Position.y += Math.sin(r) * Math.random() * shape.payload.radius;
break;
}
case 'filledRect': {
allocated.Position.x += Math.random() * shape.payload.width - (shape.payload.width / 2);
allocated.Position.y += Math.random() * shape.payload.height - (shape.payload.height / 2);
entity.Position.x += Math.random() * shape.payload.width - (shape.payload.width / 2);
entity.Position.y += Math.random() * shape.payload.height - (shape.payload.height / 2);
break;
}
}
}
return allocated;
if (fields) {
for (const {easing = 'linear', path, value} of fields) {
let walk = entity;
const pathCopy = path.slice(0);
const final = pathCopy.pop();
for (const key of pathCopy) {
walk = walk[key];
}
const c = Math.random() * (value.length - 1);
const i = Math.floor(c);
walk[final] = easings[easing](c - i, value[i], value[i + 1] - value[i], 1);
}
}
return entity;
}
emit(particle) {
particle = {
@ -24,59 +52,60 @@ export default class Emitter {
entity: {
Position: {},
Sprite: {},
VisibleAabb: {},
...particle.entity,
},
}
let {count = 1} = particle;
const {frequency = 0} = particle;
const stream = K.stream((emitter) => {
const {entity, frequency = 0, spurt = 1, ttl = 0} = particle;
if (ttl > 0) {
count = Math.floor(ttl / frequency);
}
const specifications = Array(count);
for (let i = 0; i < count * spurt; ++i) {
specifications[i] = entity;
}
const stream = K.stream(async (emitter) => {
const entityIds = await this.ecs.createManyDetached(specifications);
if (0 === frequency) {
const promises = [];
for (let i = 0; i < count; ++i) {
promises.push(
this.allocate(particle)
.then((entity) => {
emitter.emit(entity);
}),
);
this.ecs.attach(entityIds);
for (const entityId of entityIds) {
emitter.emit(this.configure(entityId, particle));
}
Promise.all(promises)
.then(() => {
emitter.end();
});
emitter.end();
return;
}
const promise = this.allocate(particle)
.then((entity) => {
emitter.emit(entity);
});
const it = entityIds.values();
const batch = new Set();
for (let i = 0; i < spurt; ++i) {
batch.add(it.next().value);
}
this.ecs.attach(batch);
for (const entityId of batch) {
emitter.emit(this.configure(entityId, particle));
}
count -= 1;
if (0 === count) {
promise.then(() => {
emitter.end();
});
emitter.end();
return;
}
const promises = [promise];
let accumulated = 0;
const scheduled = (elapsed) => {
accumulated += elapsed;
while (accumulated > frequency && count > 0) {
promises.push(
this.allocate(particle)
.then((entity) => {
emitter.emit(entity);
}),
);
const batch = new Set();
for (let i = 0; i < spurt; ++i) {
batch.add(it.next().value);
}
this.ecs.attach(batch);
for (const entityId of batch) {
emitter.emit(this.configure(entityId, particle));
}
accumulated -= frequency;
count -= 1;
}
if (0 === count) {
this.scheduled.splice(this.scheduled.indexOf(scheduled), 1);
Promise.all(promises).then(() => {
emitter.end();
});
emitter.end();
}
};
this.scheduled.push(scheduled);

View File

@ -32,10 +32,8 @@ test('emits particles over time', async () => {
current.onValue(resolve);
}))
.to.deep.include({id: 1});
expect(ecs.get(1))
.to.not.be.undefined;
expect(ecs.get(2))
.to.be.undefined;
expect(Array.from(ecs.$$detached))
.to.deep.equal([2]);
emitter.tick(0.06);
expect(await new Promise((resolve) => {
current.onValue(resolve);
@ -47,6 +45,6 @@ test('emits particles over time', async () => {
current.onValue(resolve);
}))
.to.deep.include({id: 2});
expect(ecs.get(2))
.to.not.be.undefined;
expect(Array.from(ecs.$$detached))
.to.deep.equal([]);
});

View File

@ -1,8 +1,8 @@
import {useState} from 'react';
import {useCallback, useState} from 'react';
import {Tab, Tabs, TabList, TabPanel} from 'react-tabs';
import 'react-tabs/style/react-tabs.css';
import {useEcs, useEcsTick} from '@/react/context/ecs.js';
import {useEcsTick} from '@/react/context/ecs.js';
import {useMainEntity} from '@/react/context/main-entity.js';
import styles from './devtools.module.css';
@ -12,15 +12,15 @@ import Tiles from './devtools/tiles.jsx';
export default function Devtools({
eventsChannel,
}) {
const [ecs] = useEcs();
const [mainEntity] = useMainEntity();
const [mainEntityJson, setMainEntityJson] = useState('');
useEcsTick(() => {
if (!ecs || !mainEntity) {
const onEcsTick = useCallback((payload, ecs) => {
if (!mainEntity) {
return;
}
setMainEntityJson(JSON.stringify(ecs.get(mainEntity), null, 2));
}, [ecs, mainEntity]);
}, [mainEntity]);
useEcsTick(onEcsTick);
return (
<div className={styles.devtools}>
<Tabs>

View File

@ -1,6 +1,7 @@
import {memo, useCallback, useRef} from 'react';
import {DamageTypes} from '@/ecs/components/vulnerable.js';
import {useEcsTick} from '@/react/context/ecs.js';
import useAnimationFrame from '@/react/hooks/use-animation-frame.js';
import {easeInOutExpo, easeInQuint, easeOutQuad, linear} from '@/util/easing.js';
@ -25,142 +26,143 @@ function damageHue(type) {
return hue;
}
function createDamageNode() {
const damage = document.createElement('div');
damage.classList.add(styles.damage);
damage.appendChild(document.createElement('p'));
return damage;
class Damage {
elapsed = 0;
hue = [0, 30];
offsetX = 0;
offsetY = 0;
step = 0;
constructor() {
const element = document.createElement('div');
element.classList.add(styles.damage);
element.appendChild(document.createElement('p'));
this.element = element;
}
reset(key, scale, {amount, position, type}) {
this.elapsed = 0;
[this.hueStart, this.hueEnd] = damageHue(type);
this.offsetX = 20 * scale * 1 * (Math.random() - 0.5);
this.offsetY = -1 * (10 + Math.random() * 45 * scale);
this.step = 0;
const {element} = this;
element.style.setProperty('rotate', `${Math.random() * (Math.PI / 16) - (Math.PI / 32)}rad`);
element.style.setProperty('--magnitude', Math.max(1, Math.floor(Math.log10(Math.abs(amount)))));
element.style.setProperty('--positionX', `${position.x * scale}px`);
element.style.setProperty('--positionY', `${position.y * scale}px`);
element.style.setProperty('zIndex', key);
const p = element.querySelector('p');
p.style.scale = scale / 2;
p.innerText = Math.abs(amount);
}
tick(elapsed, stepSize) {
this.elapsed += elapsed;
this.step += elapsed;
// offset
let offsetX = 0, offsetY = 0;
if (this.elapsed <= 0.5) {
offsetX = easeOutQuad(this.elapsed, 0, this.offsetX, 0.5);
offsetY = easeOutQuad(this.elapsed, 0, this.offsetY, 0.5);
}
else if (this.elapsed > 1.375) {
offsetX = easeOutQuad(this.elapsed - 1.375, this.offsetX, -this.offsetX, 0.125);
offsetY = easeOutQuad(this.elapsed - 1.375, this.offsetY, -this.offsetY, 0.125);
}
else {
offsetX = this.offsetX;
offsetY = this.offsetY;
}
this.element.style.setProperty('--offsetX', offsetX);
this.element.style.setProperty('--offsetY', offsetY);
if (this.step > stepSize) {
this.step = this.step % stepSize;
// scale
let scale = 0.35;
if (this.elapsed <= 0.5) {
scale = easeOutQuad(this.elapsed, 0, 1, 0.5);
}
else if (this.elapsed > 0.5 && this.elapsed < 0.6) {
scale = linear(this.elapsed - 0.5, 1, 0.5, 0.1);
}
else if (this.elapsed > 0.6 && this.elapsed < 0.675) {
scale = easeInQuint(this.elapsed - 0.6, 1, 0.25, 0.075);
}
else if (this.elapsed > 0.675 && this.elapsed < 1.375) {
scale = 1;
}
else if (this.elapsed > 1.375) {
scale = easeOutQuad(this.elapsed - 1.375, 1, -1, 0.125);
}
this.element.style.setProperty('--scale', scale);
// opacity
let opacity = 0.75;
if (this.elapsed <= 0.375) {
opacity = linear(this.elapsed, 0.75, 0.25, 0.375);
}
else if (this.elapsed > 0.375 && this.elapsed < 1.375) {
opacity = 1;
}
else if (this.elapsed > 1.375) {
opacity = linear(this.elapsed - 1.375, 1, -1, 0.125);
}
this.element.style.setProperty('--opacity', opacity);
// hue
this.element.style.setProperty(
'--hue',
easeInOutExpo(
Math.abs((this.elapsed % 0.3) - 0.15) / 0.15,
this.hueStart,
this.hueEnd,
1,
),
);
}
}
}
function Damages({damages, scale}) {
const animations = useRef({});
const pool = useRef([]);
function Damages({scale}) {
const damages = useRef({});
const damagesRef = useRef();
const pool = useRef([]);
const onEcsTick = useCallback((payload) => {
for (const id in payload) {
const update = payload[id];
if (false === update || !update.Vulnerable?.damage) {
continue;
}
const {damage: damageUpdate} = update.Vulnerable;
for (const key in damageUpdate) {
const damage = pool.current.length > 0 ? pool.current.pop() : new Damage();
damage.reset(key, scale, damageUpdate[key]);
damages.current[key] = damage;
damagesRef.current.appendChild(damage.element);
}
}
}, [scale]);
useEcsTick(onEcsTick);
const frame = useCallback((elapsed) => {
if (!damagesRef.current) {
return;
}
if (0 === pool.current.length) {
for (let i = 0; i < 512; ++i) {
const damage = createDamageNode();
damagesRef.current.appendChild(damage);
pool.current.push(damage);
const keys = Object.keys(damages.current);
if (0 === keys.length && 0 === pool.current.length) {
for (let i = 0; i < 500; ++i) {
pool.current.push(new Damage());
}
}
const keys = Object.keys(animations.current);
const stepSize = keys.length > 150 ? (keys.length / 500) * 0.5 : elapsed;
for (const key of keys) {
const animation = animations.current[key];
if (!damages[key]) {
if (animation.element) {
if (pool.current.length < 512) {
pool.current.push(animation.element);
}
animation.element = undefined;
const damage = damages.current[key];
damage.tick(elapsed, stepSize);
if (damage.elapsed > 1.5) {
damagesRef.current.removeChild(damage.element);
delete damages.current[key];
if (pool.current.length < 1000) {
pool.current.push(damage);
}
delete animations.current[key];
continue;
}
if (!animation.element) {
const {amount, position} = damages[key];
let damage;
if (pool.current.length > 0) {
damage = pool.current.pop();
}
else {
damage = createDamageNode();
damagesRef.current.appendChild(damage);
}
const p = damage.querySelector('p');
p.style.scale = scale / 2;
p.innerText = Math.abs(amount);
damage.style.setProperty('--randomnessX', animation['--randomnessX']);
damage.style.setProperty('--randomnessY', animation['--randomnessY']);
damage.style.setProperty('rotate', animation['rotate']);
damage.style.setProperty('rotate', animation['rotate']);
damage.style.setProperty('--magnitude', Math.max(1, Math.floor(Math.log10(Math.abs(amount)))));
damage.style.setProperty('--positionX', `${position.x * scale}px`);
damage.style.setProperty('--positionY', `${position.y * scale}px`);
damage.style.setProperty('zIndex', key);
animation.element = damage;
}
animation.elapsed += elapsed;
animation.step += elapsed;
const offsetX = 20 * scale * animation['--randomnessX'];
const offsetY = -1 * (10 + animation['--randomnessY'] * 45 * scale);
// offset
if (animation.elapsed <= 0.5) {
animation['--offsetX'] = easeOutQuad(animation.elapsed, 0, offsetX, 0.5);
animation['--offsetY'] = easeOutQuad(animation.elapsed, 0, offsetY, 0.5);
}
else if (animation.elapsed > 1.375) {
animation['--offsetX'] = offsetX - easeOutQuad(animation.elapsed - 1.375, 0, offsetX, 0.125);
animation['--offsetY'] = offsetY - easeOutQuad(animation.elapsed - 1.375, 0, offsetY, 0.125);
}
// scale
if (animation.elapsed <= 0.5) {
animation['--scale'] = easeOutQuad(animation.elapsed, 0, 1, 0.5);
}
else if (animation.elapsed > 0.5 && animation.elapsed < 0.6) {
animation['--scale'] = linear(animation.elapsed - 0.5, 1, 0.5, 0.1);
}
else if (animation.elapsed > 0.6 && animation.elapsed < 0.675) {
animation['--scale'] = 1.5 - easeInQuint(animation.elapsed - 0.6, 1, 0.5, 0.075);
}
else if (animation.elapsed > 0.675 && animation.elapsed < 1.375) {
animation['--scale'] = 1;
}
else if (animation.elapsed > 1.375) {
animation['--scale'] = 1 - easeOutQuad(animation.elapsed - 1.375, 0, 1, 0.125);
}
// fade
if (animation.elapsed <= 0.375) {
animation['--opacity'] = linear(animation.elapsed, 0.75, 0.25, 0.375);
}
else if (animation.elapsed > 0.375 && animation.elapsed < 1.375) {
animation['--opacity'] = 1;
}
else if (animation.elapsed > 1.375) {
animation['--opacity'] = 1 - linear(animation.elapsed - 1.375, 0, 1, 0.125);
}
// hue
const h = Math.abs((animation.elapsed % 0.3) - 0.15) / 0.15;
animation['--hue'] = easeInOutExpo(h, animation.hue[0], animation.hue[1], 1);
const step = keys.length > 150 ? (keys.length / 500) * 0.25 : elapsed;
if (animation.step > step) {
animation.step = animation.step % step;
animation.element.style.setProperty('--hue', animation['--hue']);
animation.element.style.setProperty('--opacity', animation['--opacity']);
animation.element.style.setProperty('--offsetX', animation['--offsetX']);
animation.element.style.setProperty('--offsetY', animation['--offsetY']);
animation.element.style.setProperty('--scale', animation['--scale']);
}
if (animation.elapsed > 1.5) {
if (pool.current.length < 512) {
pool.current.push(animation.element);
}
animation.element.style.setProperty('--opacity', 0);
animation.element = undefined;
damages[key].onClose();
}
}
}, [damages, scale]);
}, []);
useAnimationFrame(frame);
for (const key in damages) {
if (!animations.current[key]) {
animations.current[key] = {
elapsed: 0,
hue: damageHue(damages[key].type),
rotate: `${Math.random() * (Math.PI / 16) - (Math.PI / 32)}rad`,
step: 0,
'--scale': 0.35,
'--opacity': 0.75,
'--offsetX': 0,
'--offsetY': 0,
'--randomnessX': 1 * (Math.random() - 0.5),
'--randomnessY': Math.random(),
};
}
}
return (
<div
className={styles.damages}

View File

@ -23,22 +23,10 @@
inherits: false;
syntax: '<number>';
}
@property --randomnessX {
initial-value: 0;
inherits: false;
syntax: '<number>';
}
@property --randomnessY {
initial-value: 0;
inherits: false;
syntax: '<number>';
}
.damage {
--hue: 0;
--opacity: 0.75;
--randomnessX: 0;
--randomnessY: 0;
--scale: 0.35;
--background: hsl(var(--hue) 100% 12.5%);
--foreground: hsl(var(--hue) 100% 50%);

View File

@ -1,7 +1,8 @@
import {useEffect, useMemo, useRef, useState} from 'react';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useDomScale} from '@/react/context/dom-scale.js';
import {useRadians} from '@/react/context/radians.js';
import useAnimationFrame from '@/react/hooks/use-animation-frame.js';
import {RESOLUTION} from '@/util/constants.js';
import {render} from '@/util/dialogue.js';
@ -56,20 +57,13 @@ export default function Dialogue({
clearTimeout(handle);
}
}, [caret, dialogue]);
useEffect(() => {
let handle;
function track() {
if (ref.current) {
const {height, width} = ref.current.getBoundingClientRect();
setDimensions({h: height / domScale, w: width / domScale});
}
handle = requestAnimationFrame(track);
const updateDimensions = useCallback(() => {
if (ref.current) {
const {height, width} = ref.current.getBoundingClientRect();
setDimensions({h: height / domScale, w: width / domScale});
}
handle = requestAnimationFrame(track);
return () => {
cancelAnimationFrame(handle);
};
}, [dialogue, domScale, ref]);
}, [domScale]);
useAnimationFrame(updateDimensions);
const localRender = useMemo(
() => render(dialogue.letters, styles.letter),
[dialogue.letters],

View File

@ -17,6 +17,7 @@
;
top: 0;
transform: translate(-50%, -50%);
transition: translate 100ms;
user-select: none;
max-width: 66%;
}

View File

@ -1,7 +1,7 @@
import {useState} from 'react';
import {useCallback, useState} from 'react';
import {usePacket} from '@/react/context/client.js';
import {useEcs, useEcsTick} from '@/react/context/ecs.js';
import {useEcsTick} from '@/react/context/ecs.js';
import {parseLetters} from '@/util/dialogue.js';
import Damages from './damages.jsx';
@ -13,17 +13,11 @@ export default function Entities({
setChatMessages,
setMonopolizers,
}) {
const [ecs] = useEcs();
const [entities, setEntities] = useState({});
const [damages, setDamages] = useState({});
const [pendingDamage] = useState({accumulated: [], handle: undefined});
usePacket('EcsChange', async () => {
setEntities({});
}, [setEntities]);
useEcsTick((payload) => {
if (!ecs) {
return;
}
});
const onEcsTick = useCallback((payload, ecs) => {
const deleting = {};
const updating = {};
for (const id in payload) {
@ -91,36 +85,6 @@ export default function Entities({
};
}
}
const {damage} = update.Vulnerable || {};
if (damage) {
for (const key in damage) {
damage[key].onClose = () => {
setDamages((damages) => {
const {[key]: _, ...rest} = damages; // eslint-disable-line no-unused-vars
return rest;
})
};
}
pendingDamage.accumulated.push(damage);
if (!pendingDamage.handle) {
pendingDamage.handle = setTimeout(() => {
const update = {};
for (const damage of pendingDamage.accumulated) {
for (const key in damage) {
update[key] = damage[key];
}
}
pendingDamage.accumulated.length = 0;
setDamages((damages) => {
return {
...damages,
...update,
};
});
pendingDamage.handle = undefined;
}, 50);
}
}
}
setEntities((entities) => {
for (const id in deleting) {
@ -131,7 +95,8 @@ export default function Entities({
...updating,
};
});
}, [ecs, setMonopolizers]);
}, [setChatMessages, setMonopolizers]);
useEcsTick(onEcsTick);
const renderables = [];
for (const id in entities) {
renderables.push(
@ -154,7 +119,6 @@ export default function Entities({
>
{renderables}
<Damages
damages={damages}
scale={scale}
/>
</div>

View File

@ -4,6 +4,17 @@ import createEcs from '@/server/create/ecs.js';
import ClientEcs from './client-ecs.js';
const ecs = createEcs(ClientEcs);
[
'ClampPositions',
'Colliders',
'MaintainColliderHash',
'VisibleAabbs',
]
.forEach((system) => {
ecs.system(system).active = false;
})
ecs.$$caret = Math.pow(2, 31);
const emitter = new Emitter(ecs);
@ -17,20 +28,22 @@ addEventListener('message', (particle) => {
.onEnd(() => {});
});
let last = Date.now();
function tick() {
const now = Date.now();
let last = performance.now();
function tick(now) {
const elapsed = (now - last) / 1000;
last = now;
if (ecs.get(1)) {
ecs.tick(elapsed);
emitter.tick(elapsed);
if ('1' in ecs.diff) {
delete ecs.diff['1'];
}
postMessage(ecs.diff);
ecs.setClean();
}
requestAnimationFrame(tick);
if (!ecs.get(1)) {
return;
}
ecs.tick(elapsed);
emitter.tick(elapsed);
if ('1' in ecs.diff) {
delete ecs.diff['1'];
}
if (Object.keys(ecs.diff).length > 0) {
postMessage(ecs.diff);
}
ecs.setClean();
}
requestAnimationFrame(tick);

View File

@ -0,0 +1,18 @@
import {Graphics} from '@pixi/graphics';
export default class Aabb extends Graphics {
constructor({color, width = 0.5}) {
super();
this.$$color = color;
this.$$width = width;
}
redraw({x0, y0, x1, y1}) {
this.clear();
this.lineStyle(this.$$width, this.$$color);
this.moveTo(x0, y0);
this.lineTo(x1, y0);
this.lineTo(x1, y1);
this.lineTo(x0, y1);
this.lineTo(x0, y0);
}
}

View File

@ -0,0 +1,21 @@
import {Graphics} from '@pixi/graphics';
export default function crosshair() {
const g = new Graphics();
g.clear();
g.lineStyle(1, 0x000000);
g.moveTo(-5, 0);
g.lineTo(5, 0);
g.moveTo(0, -5);
g.lineTo(0, 5);
g.lineStyle(0.5, 0xffffff);
g.moveTo(-5, 0);
g.lineTo(5, 0);
g.moveTo(0, -5);
g.lineTo(0, 5);
g.lineStyle(1, 0x000000);
g.drawCircle(0, 0, 3);
g.lineStyle(0.5, 0xffffff);
g.drawCircle(0, 0, 3);
return g;
}

View File

@ -1,7 +1,7 @@
import {Container} from '@pixi/react';
import {useState} from 'react';
import {useCallback, useState} from 'react';
import {useEcs, useEcsTick} from '@/react/context/ecs.js';
import {useEcsTick} from '@/react/context/ecs.js';
import {useMainEntity} from '@/react/context/main-entity.js';
import Entities from './entities.jsx';
@ -11,14 +11,13 @@ import TileLayer from './tile-layer.jsx';
import Water from './water.jsx';
export default function Ecs({camera, monopolizers, particleWorker, scale}) {
const [ecs] = useEcs();
const [mainEntity] = useMainEntity();
const [layers, setLayers] = useState([]);
const [hour, setHour] = useState(10);
const [projected, setProjected] = useState([]);
const [position, setPosition] = useState({x: 0, y: 0});
const [water, setWater] = useState();
useEcsTick((payload) => {
const onEcsTick = useCallback((payload, ecs) => {
const entity = ecs.get(mainEntity);
for (const id in payload) {
const update = payload[id];
@ -43,7 +42,8 @@ export default function Ecs({camera, monopolizers, particleWorker, scale}) {
setPosition(Position.toJSON());
setProjected(Wielder.activeItem()?.project(Position.tile, Direction.quantize(4)));
}
}, [ecs, mainEntity, scale]);
}, [mainEntity]);
useEcsTick(onEcsTick);
return (
<Container
scale={scale}
@ -71,16 +71,16 @@ export default function Ecs({camera, monopolizers, particleWorker, scale}) {
y={position.y}
/>
)}
<Entities
monopolizers={monopolizers}
particleWorker={particleWorker}
/>
{projected?.length > 0 && layers[0] && (
<TargetingGhost
projected={projected}
tileLayer={layers[0]}
/>
)}
<Entities
monopolizers={monopolizers}
particleWorker={particleWorker}
/>
</Container>
)
}

View File

@ -1,52 +0,0 @@
import {Container} from '@pixi/display';
import {PixiComponent} from '@pixi/react';
import * as particles from '@pixi/particle-emitter';
const EmitterInternal = PixiComponent('Emitter', {
$$emitter: undefined,
$$raf: undefined,
create() {
return new Container();
},
applyProps(container, oldProps, newProps) {
if (!this.$$emitter) {
const {onComplete, particle} = newProps;
this.$$emitter = new particles.Emitter(container, particle);
this.$$emitter._completeCallback = onComplete;
let last = Date.now();
const render = () => {
this.$$raf = requestAnimationFrame(render);
const now = Date.now();
this.$$emitter.update((now - last) / 1000);
last = now;
};
this.$$emitter.emit = true;
render();
}
},
willUnmount() {
if (this.$$emitter) {
this.$$emitter.emit = false;
cancelAnimationFrame(this.$$raf);
}
}
});
export default function Emitter({entity}) {
const {Emitter} = entity;
const emitters = [];
for (const id in Emitter.emitting) {
const particle = Emitter.emitting[id];
emitters.push(
<EmitterInternal
key={id}
onComplete={() => {
delete Emitter.emitting[id];
}}
particle={particle}
/>
);
}
return <>{emitters}</>;
}

View File

@ -1,119 +1,126 @@
import {AdjustmentFilter} from '@pixi/filter-adjustment';
import {GlowFilter} from '@pixi/filter-glow';
import {Container} from '@pixi/react';
import {useEffect, useState} from 'react';
import {useCallback, useEffect, useRef, useState} from 'react';
import {usePacket} from '@/react/context/client.js';
import {useDebug} from '@/react/context/debug.js';
import {useEcs, useEcsTick} from '@/react/context/ecs.js';
import {useMainEntity} from '@/react/context/main-entity.js';
import {useRadians} from '@/react/context/radians.js';
import Entity from './entity.jsx';
import Entity from './entity.js';
export default function Entities({monopolizers, particleWorker}) {
const [debug] = useDebug();
const [ecs] = useEcs();
const [entities, setEntities] = useState({});
const containerRef = useRef();
const latestTick = useRef();
const entities = useRef({});
const pool = useRef([]);
const [mainEntity] = useMainEntity();
const radians = useRadians();
const [willInteractWith, setWillInteractWith] = useState(0);
const willInteractWith = useRef(0);
const [interactionFilters] = useState([
new AdjustmentFilter(),
new GlowFilter({color: 0x0}),
]);
const pulse = (Math.cos(radians / 4) + 1) * 0.5;
interactionFilters[0].brightness = (pulse * 0.75) + 1;
interactionFilters[1].outerStrength = pulse * 0.5;
const updateEntities = useCallback((payload) => {
for (const id in payload) {
if ('1' === id) {
continue;
}
if (!payload[id]) {
entities.current[id].removeFromContainer();
pool.current.push(entities.current[id]);
delete entities.current[id];
continue;
}
if (!entities.current[id]) {
entities.current[id] = pool.current.length > 0
? pool.current.pop()
: new Entity();
entities.current[id].reset(ecs.get(id), debug);
if (mainEntity === id) {
entities.current[id].setMainEntity();
}
}
entities.current[id].update(payload[id], containerRef.current);
}
}, [debug, ecs, mainEntity])
useEffect(() => {
if (0 === pool.current.length) {
for (let i = 0; i < 1000; ++i) {
pool.current.push(new Entity());
}
}
}, []);
useEffect(() => {
for (const key in entities.current) {
entities.current[key].setDebug(debug);
}
}, [debug]);
usePacket('EcsChange', async () => {
for (const id in entities.current) {
entities.current[id].removeFromContainer();
}
entities.current = {};
});
const onEcsTickEntities = useCallback((payload) => {
updateEntities(payload);
}, [updateEntities]);
useEcsTick(onEcsTickEntities);
useEffect(() => {
if (!ecs || !particleWorker) {
return;
}
async function onMessage(diff) {
await ecs.apply(diff.data);
const deleted = {};
const updated = {};
for (const id in diff.data) {
if (!diff.data[id]) {
deleted[id] = true;
}
else {
updated[id] = ecs.get(id);
}
}
setEntities((entities) => {
for (const id in deleted) {
delete entities[id];
}
return {
...entities,
...updated,
};
latestTick.current = Promise.resolve(latestTick.current).then(async () => {
await ecs.apply(diff.data);
updateEntities(diff.data);
});
}
particleWorker.addEventListener('message', onMessage);
return () => {
particleWorker.removeEventListener('message', onMessage);
};
}, [ecs, particleWorker]);
const pulse = (Math.cos(radians / 4) + 1) * 0.5;
interactionFilters[0].brightness = (pulse * 0.75) + 1;
interactionFilters[1].outerStrength = pulse * 0.5;
usePacket('EcsChange', async () => {
setEntities({});
}, [setEntities]);
useEcsTick((payload) => {
if (!ecs) {
return;
}
const deleting = {};
const updating = {};
}, [ecs, particleWorker, updateEntities]);
const onEcsTickParticles = useCallback((payload) => {
for (const id in payload) {
if ('1' === id) {
continue;
}
const update = payload[id];
if (false === update) {
deleting[id] = true;
continue;
}
updating[id] = ecs.get(id);
if (update.Emitter?.emit) {
for (const id in update.Emitter.emit) {
particleWorker?.postMessage(update.Emitter.emit[id]);
}
}
}
setEntities((entities) => {
for (const id in deleting) {
delete entities[id];
}
return {
...entities,
...updating,
};
});
}, [ecs, particleWorker]);
useEcsTick(() => {
if (!ecs) {
return;
}
}, [particleWorker]);
useEcsTick(onEcsTickParticles);
const onEcsTickInteractions = useCallback((payload, ecs) => {
const main = ecs.get(mainEntity);
if (main) {
setWillInteractWith(main.Interacts.willInteractWith);
if (willInteractWith.current !== main.Interacts.willInteractWith) {
if (entities.current[willInteractWith.current]) {
entities.current[willInteractWith.current].diffuse.filters = [];
}
willInteractWith.current = main.Interacts.willInteractWith;
}
const interacting = entities.current[main.Interacts.willInteractWith];
if (interacting) {
interacting.diffuse.filters = 0 === monopolizers.length
? interactionFilters
: [];
}
}
}, [ecs, mainEntity]);
const renderables = [];
for (const id in entities) {
const isHighlightedInteraction = 0 === monopolizers.length && id == willInteractWith;
renderables.push(
<Entity
filters={isHighlightedInteraction ? interactionFilters : null}
entity={entities[id]}
key={id}
/>
);
}
}, [interactionFilters, mainEntity, monopolizers]);
useEcsTick(onEcsTickInteractions);
return (
<Container
ref={containerRef}
sortableChildren
>
{renderables}
</Container>
/>
);
}

View File

@ -0,0 +1,197 @@
import {Assets} from '@pixi/assets';
import {Container as PixiContainer} from '@pixi/display';
import {Sprite} from '@pixi/sprite';
import Aabb from './aabb.js';
import createCrosshair from './crosshair.js';
import {deferredLighting, PointLight} from './lights.js';
export default class Entity {
static assets = {};
attached = false;
colliderAabb = new Aabb({color: 0xffffff});
container = new PixiContainer();
crosshair = createCrosshair();
debug = new PixiContainer();
diffuse = new Sprite();
isMainEntity = false;
normals = new Sprite();
point;
sprite = new PixiContainer();
visibleAabb = new Aabb({color: 0xff00ff});
constructor() {
this.diffuse.parentGroup = deferredLighting.diffuseGroup;
this.normals.parentGroup = deferredLighting.normalGroup;
this.container.addChild(this.diffuse);
this.container.addChild(this.normals);
this.container.addChild(this.crosshair);
this.debug.addChild(this.colliderAabb);
this.debug.addChild(this.visibleAabb);
}
static async loadAsset(source = '') {
if (!this.assets['']) {
const {Texture} = await import('@pixi/core');
this.assets[''] = {data: {meta: {}}, textures: {'': Texture.WHITE}};
}
if (!this.assets[source]) {
this.assets[source] = Assets.load(source);
}
return this.assets[source];
}
removeFromContainer() {
this.container.parent.removeChild(this.container);
this.debug.parent.removeChild(this.debug);
this.attached = false;
}
reset(entity, debug) {
this.entity = entity;
if (this.light) {
this.container.removeChild(this.light);
this.light = undefined;
}
this.setDebug(debug);
this.isMainEntity = false;
if (this.interactionAabb) {
this.debug.removeChild(this.interactionAabb);
}
this.interactionAabb = undefined;
}
setDebug(isDebugging) {
if (isDebugging) {
this.crosshair.alpha = 1;
this.debug.alpha = 1;
}
else {
this.crosshair.alpha = 0;
this.debug.alpha = 0;
}
}
setMainEntity() {
if (this.isMainEntity) {
return;
}
this.isMainEntity = true;
this.interactionAabb = new Aabb({color: 0x00ff00});
this.debug.addChild(this.interactionAabb);
}
static textureFromAsset(asset, animation, frame) {
if (!asset) {
return undefined;
}
let texture;
if (asset.data.animations) {
if (!animation) {
return undefined;
}
texture = asset.animations[animation][frame];
}
else {
texture = asset.textures[''];
}
return texture;
}
update({Direction, Light, Position, Sprite, VisibleAabb}, container) {
if (Light) {
if (!this.light) {
this.light = new PointLight(
0xffffff - 0x2244cc,
0,
);
}
this.light.brightness = Light.brightness;
}
if (Position) {
const {x, y} = this.entity.Position;
this.container.x = x;
this.container.y = y;
this.container.zIndex = y;
}
if (Sprite) {
const {diffuse, normals} = this;
if (!this.attached || 'alpha' in Sprite) {
diffuse.alpha = normals.alpha = this.entity.Sprite.alpha;
}
if (!this.attached || 'anchorX' in Sprite) {
diffuse.anchor.x = normals.anchor.x = this.entity.Sprite.anchorX;
}
if (!this.attached || 'anchorY' in Sprite) {
diffuse.anchor.y = normals.anchor.y = this.entity.Sprite.anchorY;
}
if (!this.attached || 'scaleX' in Sprite) {
diffuse.scale.x = normals.scale.x = this.entity.Sprite.scaleX;
}
if (!this.attached || 'scaleY' in Sprite) {
diffuse.scale.y = normals.scale.y = this.entity.Sprite.scaleY;
}
if (!this.attached || 'tint' in Sprite) {
diffuse.tint = normals.tint = this.entity.Sprite.tint;
}
if (
!this.attached
|| ('source' in Sprite)
|| ('animation' in Sprite)
|| ('frame' in Sprite)
) {
this.constructor.loadAsset(this.entity.Sprite.source).then(async (asset) => {
if (!this.entity?.Sprite) {
return;
}
const texture = await this.constructor.textureFromAsset(
asset,
this.entity.Sprite.animation,
this.entity.Sprite.frame,
);
diffuse.texture = texture;
if (asset.data.meta.normals) {
const {pathname} = new URL(
Sprite.source
.split('/')
.slice(0, -1)
.concat(asset.data.meta.normals)
.join('/'),
'http://example.org',
);
this.constructor.loadAsset(pathname).then(async (asset) => {
const texture = await this.constructor.textureFromAsset(
asset,
this.entity.Sprite.animation,
this.entity.Sprite.frame,
);
normals.texture = texture;
});
}
});
}
}
if (!this.attached) {
const {diffuse, normals} = this;
diffuse.rotation = 0;
normals.rotation = 0;
}
if (Direction) {
const {diffuse, normals} = this;
if (!this.attached || 'direction' in Direction) {
if (this.entity.Sprite.rotates) {
const rotation = this.entity.Direction.direction + this.entity.Sprite.rotation;
diffuse.rotation = rotation;
normals.rotation = rotation;
}
}
}
if (this.entity.Collider) {
this.colliderAabb.redraw(this.entity.Collider.aabb);
}
if (VisibleAabb) {
this.visibleAabb.redraw(this.entity.VisibleAabb);
}
if (this.isMainEntity) {
this.interactionAabb.redraw(this.entity.Interacts.aabb());
}
if (this.attached || !container) {
return;
}
container.addChild(this.container);
container.addChild(this.debug);
this.attached = true;
}
}

View File

@ -1,120 +0,0 @@
import {Container, Graphics} from '@pixi/react';
import {memo, useCallback} from 'react';
import {useDebug} from '@/react/context/debug.js';
import {useMainEntity} from '@/react/context/main-entity.js';
import Light from './light.jsx';
import SpriteComponent from './sprite.jsx';
function Aabb({color, width = 0.5, x0, y0, x1, y1, ...rest}) {
const draw = useCallback((g) => {
g.clear();
g.lineStyle(width, color);
g.moveTo(x0, y0);
g.lineTo(x1, y0);
g.lineTo(x1, y1);
g.lineTo(x0, y1);
g.lineTo(x0, y0);
}, [color, width, x0, x1, y0, y1]);
return (
<Graphics draw={draw} x={0} y={0} {...rest} />
);
}
function Crosshair() {
const draw = useCallback((g) => {
g.clear();
g.lineStyle(1, 0x000000);
g.moveTo(-5, 0);
g.lineTo(5, 0);
g.moveTo(0, -5);
g.lineTo(0, 5);
g.lineStyle(0.5, 0xffffff);
g.moveTo(-5, 0);
g.lineTo(5, 0);
g.moveTo(0, -5);
g.lineTo(0, 5);
g.lineStyle(1, 0x000000);
g.drawCircle(0, 0, 3);
g.lineStyle(0.5, 0xffffff);
g.drawCircle(0, 0, 3);
}, []);
return (
<Graphics draw={draw} />
);
}
function Entity({entity, ...rest}) {
const [debug] = useDebug();
const [mainEntity] = useMainEntity();
if (!entity) {
return false;
}
const {Direction, id, Sprite} = entity;
return (
<>
<Container
x={entity.Position.x}
y={entity.Position.y}
zIndex={entity.Position?.y || 0}
>
{entity.Sprite && (
<SpriteComponent
alpha={Sprite.alpha}
anchor={Sprite.anchor}
animation={Sprite.animation}
direction={Direction?.direction}
frame={Sprite.frame}
id={id}
scale={Sprite.scale}
rotates={Sprite.rotates}
rotation={Sprite.rotation}
source={Sprite.source}
tint={Sprite.tint}
{...rest}
/>
)}
{entity.Light && (
<Light
brightness={entity.Light.brightness}
/>
)}
{debug && entity.Position && (
<Crosshair />
)}
</Container>
{debug && (
<Aabb
color={0xff00ff}
{...entity.VisibleAabb}
/>
)}
{debug && entity.Collider && (
<>
<Aabb
color={0xffffff}
width={0.5}
{...entity.Collider.aabb}
/>
{entity.Collider.aabbs.map((aabb, i) => (
<Aabb
color={0xffff00}
width={0.25}
key={i}
{...aabb}
/>
))}
</>
)}
{debug && mainEntity == entity.id && (
<Aabb
color={0x00ff00}
{...entity.Interacts.aabb()}
/>
)}
</>
);
}
export default memo(Entity);

View File

@ -1,34 +0,0 @@
import {PixiComponent} from '@pixi/react';
import {PointLight} from './lights.js';
const LightInternal = PixiComponent('Light', {
create({brightness}) {
const light = new PointLight(
0xffffff - 0x2244cc,
brightness,
);
// light.shader.program.fragmentSrc = light.shader.program.fragmentSrc.replace(
// 'float D = length(lightVector)',
// 'float D = length(lightVector) / 1.0',
// );
// light.shader.program.fragmentSrc = light.shader.program.fragmentSrc.replace(
// 'intensity = diffuse * attenuation',
// 'intensity = diffuse * (attenuation * 2.0)',
// );
// light.falloff = [0.5, 5, 50];
// light.falloff = light.falloff.map((n, i) => n / (2 + i));
// light.parentGroup = deferredLighting.lightGroup;
// delete light.parentGroup;
return light;
},
});
export default function Light({brightness}) {
return (
<LightInternal
brightness={brightness}
/>
)
}

View File

@ -1,102 +0,0 @@
import {Sprite as PixiSprite} from '@pixi/react';
import {memo, useEffect, useState} from 'react';
import {useAsset} from '@/react/context/assets.js';
import {deferredLighting} from './lights.js';
function textureFromAsset(asset, animation, frame) {
if (!asset) {
return undefined;
}
let texture;
if (asset.data.animations) {
if (!animation) {
return undefined;
}
texture = asset.animations[animation][frame];
}
else {
texture = asset.textures[''];
}
return texture;
}
function Sprite(props) {
const {
alpha,
anchor,
animation,
direction,
frame,
scale,
rotates,
rotation,
source,
tint,
...rest
} = props;
const [mounted, setMounted] = useState();
const [normals, setNormals] = useState();
const [normalsMounted, setNormalsMounted] = useState();
const asset = useAsset(source);
const normalsAsset = useAsset(normals);
useEffect(() => {
if (!asset) {
return;
}
const {normals} = asset.data.meta;
if (normals) {
const {pathname} = new URL(
source.split('/').slice(0, -1).concat(normals).join('/'),
'http://example.org',
);
setNormals(pathname);
}
}, [asset, source]);
const texture = textureFromAsset(
asset,
animation,
frame,
);
const normalsTexture = textureFromAsset(
normalsAsset,
animation,
frame,
);
if (mounted) {
mounted.parentGroup = deferredLighting.diffuseGroup;
}
if (normalsMounted) {
normalsMounted.parentGroup = deferredLighting.normalGroup;
}
return (
<>
{texture && (
<PixiSprite
alpha={alpha}
anchor={anchor}
ref={setMounted}
{...(rotates ? {rotation: direction + rotation} : {})}
scale={scale}
texture={texture}
{...(0 !== tint ? {tint} : {})}
{...rest}
/>
)}
{normalsTexture && (
<PixiSprite
alpha={alpha}
anchor={anchor}
ref={setNormalsMounted}
{...(rotates ? {rotation: direction + rotation} : {})}
scale={scale}
texture={normalsTexture}
{...rest}
/>
)}
</>
);
}
export default memo(Sprite);

View File

@ -4,19 +4,19 @@ import {PixiComponent} from '@pixi/react';
import {useRadians} from '@/react/context/radians.js';
import {deferredLighting} from './lights.js';
const tileSize = {x: 16, y: 16};
const TargetingGhostInternal = PixiComponent('TargetingGhost', {
create: () => {
// Solid target square.
const target = new Graphics();
target.alpha = 0.7;
target.lineStyle(1, 0xffffff);
target.drawRect(0.5, 0.5, tileSize.x, tileSize.y);
target.pivot = {x: tileSize.x / 2, y: tileSize.y / 2};
// Inner spinny part.
const targetInner = new Graphics();
targetInner.alpha = 0.3;
targetInner.lineStyle(3, 0x333333);
targetInner.beginFill(0xdddddd);
targetInner.pivot = {x: tileSize.x / 2, y: tileSize.y / 2};
@ -25,7 +25,9 @@ const TargetingGhostInternal = PixiComponent('TargetingGhost', {
targetInner.position.y = 0.5;
// ...
const container = new Container();
container.alpha = 0.4;
container.addChild(target, targetInner);
container.parentGroup = deferredLighting.diffuseGroup;
return container;
},
applyProps: (container, oldProps, {x, y, radians}) => {

View File

@ -35,6 +35,7 @@ function Ui({disconnected}) {
// Key input.
const client = useClient();
const chatInputRef = useRef();
const latestTick = useRef();
const gameRef = useRef();
const [mainEntity, setMainEntity] = useMainEntity();
const [debug, setDebug] = useDebug();
@ -310,35 +311,23 @@ function Ui({disconnected}) {
setDebug,
setScale,
]);
usePacket('EcsChange', async () => {
const onEcsChangePacket = useCallback(() => {
refreshEcs();
setMainEntity(undefined);
setMonopolizers([]);
}, [refreshEcs, setMainEntity, setMonopolizers]);
usePacket('Tick', async (payload, client) => {
}, [refreshEcs, setMainEntity]);
usePacket('EcsChange', onEcsChangePacket);
const onTickPacket = useCallback(async (payload, client) => {
if (0 === Object.keys(payload.ecs).length) {
return;
}
await ecs.apply(payload.ecs);
client.emitter.invoke(':Ecs', payload.ecs);
}, [ecs]);
useEcsTick((payload) => {
if (!('1' in payload) || particleWorker) {
return
}
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;
latestTick.current = Promise.resolve(latestTick.current).then(async () => {
await ecs.apply(payload.ecs);
client.emitter.invoke(':Ecs', payload.ecs);
});
}, [particleWorker]);
useEcsTick((payload) => {
}, [ecs]);
usePacket('Tick', onTickPacket);
const onEcsTick = useCallback((payload, ecs) => {
let localMainEntity = mainEntity;
for (const id in payload) {
const entity = ecs.get(id);
@ -346,14 +335,6 @@ function Ui({disconnected}) {
if (!update) {
continue;
}
if (update.Direction && entity.Collider) {
entity.Collider.updateAabbs();
}
if (update.Sound?.play) {
for (const sound of update.Sound.play) {
(new Audio(sound)).play();
}
}
if (update.MainEntity) {
setMainEntity(localMainEntity = id);
}
@ -390,15 +371,60 @@ function Ui({disconnected}) {
}
}
}
if (localMainEntity) {
const mainEntityEntity = ecs.get(localMainEntity);
}, [hotbarHideHandle, mainEntity, setMainEntity]);
useEcsTick(onEcsTick);
const onEcsTickParticles = useCallback((payload, ecs) => {
if (!('1' in payload) || particleWorker) {
return
}
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;
});
}, [particleWorker]);
useEcsTick(onEcsTickParticles);
const onEcsTickSound = useCallback((payload) => {
for (const id in payload) {
const update = payload[id];
if (update.Sound?.play) {
for (const sound of update.Sound.play) {
(new Audio(sound)).play();
}
}
}
}, []);
useEcsTick(onEcsTickSound);
const onEcsTickAabbs = useCallback((payload, ecs) => {
for (const id in payload) {
const entity = ecs.get(id);
const update = payload[id];
if (!update) {
continue;
}
if (update.Direction && entity.Collider) {
entity.Collider.updateAabbs();
}
}
}, []);
useEcsTick(onEcsTickAabbs);
const onEcsTickCamera = useCallback((payload, ecs) => {
if (mainEntity) {
const mainEntityEntity = ecs.get(mainEntity);
const x = Math.round((mainEntityEntity.Camera.x * scale) - RESOLUTION.x / 2);
const y = Math.round((mainEntityEntity.Camera.y * scale) - RESOLUTION.y / 2);
if (x !== camera.x || y !== camera.y) {
setCamera({x, y});
}
}
}, [camera, ecs, hotbarHideHandle, mainEntity, scale]);
}, [camera, mainEntity, scale]);
useEcsTick(onEcsTickCamera);
useEffect(() => {
function onContextMenu(event) {
event.preventDefault();

View File

@ -8,7 +8,7 @@ export function useClient() {
return useContext(context);
}
export function usePacket(type, fn, dependencies) {
export function usePacket(type, fn) {
const client = useClient();
useEffect(() => {
if (!client) {
@ -21,6 +21,5 @@ export function usePacket(type, fn, dependencies) {
return () => {
client.removePacketListener(type, listener);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client, ...dependencies]);
}, [client, fn]);
}

View File

@ -1,4 +1,4 @@
import {createContext, useContext} from 'react';
import {createContext, useCallback, useContext} from 'react';
import {usePacket} from './client.js';
@ -10,7 +10,13 @@ export function useEcs() {
return useContext(context);
}
export function useEcsTick(fn, dependencies) {
export function useEcsTick(fn) {
const [ecs] = useEcs();
usePacket(':Ecs', fn, [ecs, ...dependencies]);
const memo = useCallback((payload) => {
if (!ecs) {
return;
}
fn(payload, ecs);
}, [ecs, fn]);
usePacket(':Ecs', memo);
}

View File

@ -1,5 +1,5 @@
import {json} from "@remix-run/node";
import {useEffect, useState} from 'react';
import {useCallback, useEffect, useState} from 'react';
import {useOutletContext, useParams} from 'react-router-dom';
import Ui from '@/react/components/ui.jsx';
@ -9,6 +9,7 @@ import DebugContext from '@/react/context/debug.js';
import EcsContext from '@/react/context/ecs.js';
import MainEntityContext from '@/react/context/main-entity.js';
import RadiansContext from '@/react/context/radians.js';
import useAnimationFrame from '@/react/hooks/use-animation-frame.js';
import {juggleSession} from '@/server/session.server.js';
import {TAU} from '@/util/math.js';
@ -29,23 +30,10 @@ export default function PlaySpecific() {
const params = useParams();
const [type, url] = params['*'].split('/');
const [radians, setRadians] = useState(0);
useEffect(() => {
let handle;
let last;
const spin = (ts) => {
if ('undefined' === typeof last) {
last = ts;
}
const elapsed = (ts - last) / 1000;
last = ts;
setRadians((radians) => radians + (elapsed * TAU));
handle = requestAnimationFrame(spin);
};
handle = requestAnimationFrame(spin);
return () => {
cancelAnimationFrame(handle);
};
const spin = useCallback((elapsed) => {
setRadians((radians) => radians + (elapsed * TAU));
}, []);
useAnimationFrame(spin);
useEffect(() => {
if (!Client) {
return;

View File

@ -1,5 +1,27 @@
import data from '../../../public/assets/dev/homestead.json';
function animal() {
return {
Alive: {health: 100},
Behaving: {},
Collider: {},
Controlled: {},
Direction: {},
Emitter: {},
Forces: {},
Interlocutor: {},
Position: {},
Speed: {speed: 20},
Sprite: {
animation: 'moving:down',
},
Tags: {},
Ticking: {},
VisibleAabb: {},
Vulnerable: {},
};
}
export default async function createHomestead(id) {
const area = {x: 100, y: 60};
const entities = [];
@ -138,65 +160,163 @@ export default async function createHomestead(id) {
Ticking: {},
VisibleAabb: {},
});
const kitty = {
Alive: {health: 100},
Behaving: {
routines: {
initial: '/assets/kitty/initial.js',
},
},
Collider: {
bodies: [
{
points: [
{x: -3.5, y: -3.5},
{x: 3.5, y: -3.5},
{x: 3.5, y: 3.5},
{x: -3.5, y: 3.5},
],
},
],
},
Controlled: {},
Direction: {},
Emitter: {},
Forces: {},
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',
})
`,
},
Interlocutor: {},
Position: {x: 250, y: 250},
Speed: {speed: 20},
Sprite: {
anchorX: 0.5,
anchorY: 0.7,
source: '/assets/kitty/kitty.json',
speed: 0.115,
},
Tags: {tags: ['kittan']},
Ticking: {},
VisibleAabb: {},
Vulnerable: {},
};
const animalJson = animal();
for (let i = 0; i < 50; ++i) {
entities.push(kitty);
entities.push({
...animalJson,
Behaving: {
routines: {
initial: '/assets/kitty/initial.js',
},
},
Collider: {
bodies: [
{
points: [
{x: -3.5, y: -3.5},
{x: 3.5, y: -3.5},
{x: 3.5, y: 3.5},
{x: -3.5, y: 3.5},
],
},
],
},
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',
})
`,
},
Position: {
x: 250 + (Math.random() - 0.5) * 300,
y: 250 + (Math.random() - 0.5) * 300,
},
Sprite: {
...animalJson.Sprite,
anchorX: 0.5,
anchorY: 0.7,
source: '/assets/kitty/kitty.json',
speed: 0.115,
},
Tags: {tags: ['kittan']},
});
}
for (let i = 0; i < 50; ++i) {
entities.push({
...animalJson,
Behaving: {
routines: {
initial: '/assets/farm/animals/cow-adult/initial.js',
},
},
Collider: {
bodies: [
{
points: [
{x: -3.5, y: -3.5},
{x: 3.5, y: -3.5},
{x: 3.5, y: 3.5},
{x: -3.5, y: 3.5},
],
},
],
},
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',
})
`,
},
Position: {
x: 350 + (Math.random() - 0.5) * 300,
y: 350 + (Math.random() - 0.5) * 300,
},
Sprite: {
...animalJson.Sprite,
anchorX: 0.5,
anchorY: 0.8,
source: '/assets/farm/animals/cow-adult/cow-adult.json',
speed: 0.25,
},
});
}
for (let i = 0; i < 50; ++i) {
entities.push({
...animalJson,
Behaving: {
routines: {
initial: '/assets/farm/animals/goat-white/initial.js',
},
},
Collider: {
bodies: [
{
points: [
{x: -7, y: -3.5},
{x: 7, y: -3.5},
{x: 7, y: 3.5},
{x: -7, y: 3.5},
],
},
],
},
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',
})
`,
},
Position: {
x: 350 + (Math.random() - 0.5) * 300,
y: 150 + (Math.random() - 0.5) * 300,
},
Sprite: {
...animalJson.Sprite,
anchorX: 0.5,
anchorY: 0.8,
source: '/assets/farm/animals/goat-white/goat-white.json',
speed: 0.25,
},
});
}
entities.push({
Collider: {

View File

@ -37,7 +37,7 @@ export default async function createPlayer(id) {
Magnet: {strength: 24},
Player: {},
Position: {x: 128, y: 448},
Speed: {speed: 100},
Speed: {speed: 300},
Sound: {},
Sprite: {
anchorX: 0.5,
@ -55,4 +55,3 @@ export default async function createPlayer(id) {
};
return player;
}

61
app/util/color.js Normal file
View File

@ -0,0 +1,61 @@
const {min, max, round} = Math;
export function hslToHex(h, s, l) {
const [r, g, b] = hslToRgb(h, s, l);
return (r << 16) | (g << 8) | b;
}
export function hslToRgb(h, s, l) {
let r, g, b;
if (s === 0) {
r = g = b = l;
}
else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hueToRgb(p, q, h + 1/3);
g = hueToRgb(p, q, h);
b = hueToRgb(p, q, h - 1/3);
}
return [round(r * 255), round(g * 255), round(b * 255)];
}
function hueToRgb(p, q, t) {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
}
export function hexToHsl(i) {
const [r, g, b] = hexToRgb(i);
return rgbToHsl(r, g, b);
}
export function hexToRgb(i) {
const o = i > 16777215 ? 8 : 0;
return [
(i >> o + 16) & 255,
(i >> o + 8 ) & 255,
(i >> o + 0 ) & 255,
];
}
export function rgbToHsl(r, g, b) {
(r /= 255), (g /= 255), (b /= 255);
const vmax = max(r, g, b), vmin = min(r, g, b);
let h, s, l = (vmax + vmin) / 2;
if (vmax === vmin) {
return [0, 0, l];
}
const d = vmax - vmin;
s = l > 0.5 ? d / (2 - vmax - vmin) : d / (vmax + vmin);
if (vmax === r) h = (g - b) / d + (g < b ? 6 : 0);
if (vmax === g) h = (b - r) / d + 2;
if (vmax === b) h = (r - g) / d + 4;
h /= 6;
return [h, s, l];
}

View File

@ -2,6 +2,7 @@ import {parse as acornParse} from 'acorn';
import {LRUCache} from 'lru-cache';
import Sandbox from '@/astride/sandbox.js';
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';
@ -40,6 +41,7 @@ export default class Script {
static contextDefaults() {
return {
color,
console,
delta,
lfo,
@ -117,7 +119,7 @@ export default class Script {
}
return;
}
if (async) {
if (async || value instanceof Promise) {
this.promise = value;
value
.catch(reject ? reject : () => {})

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -0,0 +1,263 @@
{
"animations": {
"idle:up": [
"farm/animals/cow-adult/cow-adult/10"
],
"idle:right": [
"farm/animals/cow-adult/cow-adult/7"
],
"idle:down": [
"farm/animals/cow-adult/cow-adult/1"
],
"idle:left": [
"farm/animals/cow-adult/cow-adult/4"
],
"moving:down": [
"farm/animals/cow-adult/cow-adult/0",
"farm/animals/cow-adult/cow-adult/1",
"farm/animals/cow-adult/cow-adult/2"
],
"moving:left": [
"farm/animals/cow-adult/cow-adult/3",
"farm/animals/cow-adult/cow-adult/4",
"farm/animals/cow-adult/cow-adult/5"
],
"moving:right": [
"farm/animals/cow-adult/cow-adult/6",
"farm/animals/cow-adult/cow-adult/7",
"farm/animals/cow-adult/cow-adult/8"
],
"moving:up": [
"farm/animals/cow-adult/cow-adult/9",
"farm/animals/cow-adult/cow-adult/10",
"farm/animals/cow-adult/cow-adult/11"
]
},
"frames": {
"farm/animals/cow-adult/cow-adult/0": {
"frame": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
},
"farm/animals/cow-adult/cow-adult/1": {
"frame": {
"x": 42,
"y": 0,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
},
"farm/animals/cow-adult/cow-adult/2": {
"frame": {
"x": 84,
"y": 0,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
},
"farm/animals/cow-adult/cow-adult/3": {
"frame": {
"x": 0,
"y": 36,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
},
"farm/animals/cow-adult/cow-adult/4": {
"frame": {
"x": 42,
"y": 36,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
},
"farm/animals/cow-adult/cow-adult/5": {
"frame": {
"x": 84,
"y": 36,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
},
"farm/animals/cow-adult/cow-adult/6": {
"frame": {
"x": 0,
"y": 72,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
},
"farm/animals/cow-adult/cow-adult/7": {
"frame": {
"x": 42,
"y": 72,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
},
"farm/animals/cow-adult/cow-adult/8": {
"frame": {
"x": 84,
"y": 72,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
},
"farm/animals/cow-adult/cow-adult/9": {
"frame": {
"x": 0,
"y": 108,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
},
"farm/animals/cow-adult/cow-adult/10": {
"frame": {
"x": 42,
"y": 108,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
},
"farm/animals/cow-adult/cow-adult/11": {
"frame": {
"x": 84,
"y": 108,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
}
},
"meta": {
"format": "RGBA8888",
"image": "./cow-adult.png",
"scale": 1,
"size": {
"w": 126,
"h": 144
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@ -0,0 +1,17 @@
entity.Direction.direction = Math.random() * Math.TAU;
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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -0,0 +1,263 @@
{
"animations": {
"idle:up": [
"farm/animals/goat-white/goat-white/10"
],
"idle:right": [
"farm/animals/goat-white/goat-white/7"
],
"idle:down": [
"farm/animals/goat-white/goat-white/1"
],
"idle:left": [
"farm/animals/goat-white/goat-white/4"
],
"moving:down": [
"farm/animals/goat-white/goat-white/0",
"farm/animals/goat-white/goat-white/1",
"farm/animals/goat-white/goat-white/2"
],
"moving:left": [
"farm/animals/goat-white/goat-white/3",
"farm/animals/goat-white/goat-white/4",
"farm/animals/goat-white/goat-white/5"
],
"moving:right": [
"farm/animals/goat-white/goat-white/6",
"farm/animals/goat-white/goat-white/7",
"farm/animals/goat-white/goat-white/8"
],
"moving:up": [
"farm/animals/goat-white/goat-white/9",
"farm/animals/goat-white/goat-white/10",
"farm/animals/goat-white/goat-white/11"
]
},
"frames": {
"farm/animals/goat-white/goat-white/0": {
"frame": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
},
"farm/animals/goat-white/goat-white/1": {
"frame": {
"x": 42,
"y": 0,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
},
"farm/animals/goat-white/goat-white/2": {
"frame": {
"x": 84,
"y": 0,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
},
"farm/animals/goat-white/goat-white/3": {
"frame": {
"x": 0,
"y": 36,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
},
"farm/animals/goat-white/goat-white/4": {
"frame": {
"x": 42,
"y": 36,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
},
"farm/animals/goat-white/goat-white/5": {
"frame": {
"x": 84,
"y": 36,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
},
"farm/animals/goat-white/goat-white/6": {
"frame": {
"x": 0,
"y": 72,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
},
"farm/animals/goat-white/goat-white/7": {
"frame": {
"x": 42,
"y": 72,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
},
"farm/animals/goat-white/goat-white/8": {
"frame": {
"x": 84,
"y": 72,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
},
"farm/animals/goat-white/goat-white/9": {
"frame": {
"x": 0,
"y": 108,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
},
"farm/animals/goat-white/goat-white/10": {
"frame": {
"x": 42,
"y": 108,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
},
"farm/animals/goat-white/goat-white/11": {
"frame": {
"x": 84,
"y": 108,
"w": 42,
"h": 36
},
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 42,
"h": 36
},
"sourceSize": {
"w": 42,
"h": 36
}
}
},
"meta": {
"format": "RGBA8888",
"image": "./goat-white.png",
"scale": 1,
"size": {
"w": 126,
"h": 144
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -0,0 +1,17 @@
entity.Direction.direction = Math.random() * Math.TAU;
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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -13,25 +13,43 @@ if (projected?.length > 0) {
Sound.play('/assets/hoe/dig.wav');
for (const {x, y} of projected) {
Emitter.emit({
count: 25,
frequency: 0.01,
shape: {
type: 'filledRect',
payload: {width: 16, height: 16},
},
entity: {
Forces: {forceY: -50},
Behaving: {
routines: {
initial: 'await delta(entity.Forces, {forceY: {delta: 640, duration: 0.125}}).promise',
},
},
Forces: {forceY: -80},
Position: {
x: x * layer.tileSize.x + (layer.tileSize.x / 2),
y: y * layer.tileSize.y + (layer.tileSize.y / 2),
},
Sprite: {
scaleX: 0.2,
scaleY: 0.2,
tint: 0x552200,
},
Ttl: {ttl: 0.125},
}
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,
});
}
Sprite.animation = ['moving', direction].join(':');

View File

@ -75,7 +75,7 @@ while (shots.length > 0) {
)
shot.Speed.speed = 400;
shot.Direction.direction = (Math.TAU + toward) % Math.TAU;
if (Math.distance(where, shot.Position) < 4) {
if (accumulated[shot.id] > 1.5 || Math.distance(where, shot.Position) < 4) {
delete accumulated[shot.id];
shot.Sprite.alpha = 0;
ecs.destroy(shot.id);

View File

@ -14,25 +14,31 @@ if (projected?.length > 0) {
for (const {x, y} of projected) {
Emitter.emit({
count: 25,
frequency: 0.01,
shape: {
type: 'filledRect',
payload: {width: 16, height: 16},
},
entity: {
Forces: {forceY: 100},
Position: {
x: x * layer.tileSize.x + (layer.tileSize.x / 2),
y: y * layer.tileSize.y - layer.tileSize.y,
y: y * layer.tileSize.y - (layer.tileSize.y / 4),
},
Sprite: {
scaleX: 0.2,
scaleY: 0.2,
scaleX: 0.05,
scaleY: 0.15,
tint: 0x0022aa,
},
Ttl: {ttl: 0.25},
}
Ttl: {ttl: 0.1},
},
fields: [
{
path: ['Sprite', 'lightness'],
value: [0.111, 0.666],
},
],
frequency: 0.01,
shape: {
type: 'circle',
payload: {radius: 4},
},
ttl: 0.5,
});
}
await wait(0.5);
@ -49,6 +55,8 @@ if (projected?.length > 0) {
Water.water[tileIndex] = Math.min(255, 64 + w);
}
ecs.system('Water').schedule();
Controlled.locked = 0;
}