Compare commits

..

19 Commits

Author SHA1 Message Date
cha0s
b65714589f fix: change deferral 2024-07-02 17:46:31 -05:00
cha0s
eb40df98cf chore: semantics 2024-07-02 17:46:18 -05:00
cha0s
df45037455 chore: too flaky 2024-07-02 17:45:33 -05:00
cha0s
eeb12b58c4 chore: debug 2024-07-02 16:17:07 -05:00
cha0s
fd40759d41 refactor: collider instead of visible aabb 2024-07-02 16:16:55 -05:00
cha0s
cca1445043 feat: collider 2024-07-02 16:16:39 -05:00
cha0s
463d9b5858 refactor: aabbs/spatial hash 2024-07-02 14:41:54 -05:00
cha0s
183c8254a2 feat: real visible AABB 2024-07-02 12:00:12 -05:00
cha0s
5db0478b19 feat: give tomato 2024-07-02 10:28:48 -05:00
cha0s
44384be138 feat: sprite scale 2024-07-02 10:27:25 -05:00
cha0s
fe44c1a4df refactor: physics 2024-07-02 10:22:25 -05:00
cha0s
1d6d4449fd refactor: simplicity 2024-07-01 22:23:39 -05:00
cha0s
461ed12562 feat: item give 2024-07-01 21:50:55 -05:00
cha0s
044859841c fix: type 2024-07-01 21:46:08 -05:00
cha0s
47f0b1040e fix: async 2024-07-01 21:45:38 -05:00
cha0s
43a6b12488 feat: interactions 2024-07-01 18:12:53 -05:00
cha0s
bb87f553fc fix: typo 2024-07-01 17:48:34 -05:00
cha0s
02d2c4b604 refactor: readScript 2024-07-01 17:23:04 -05:00
cha0s
7009f398f5 fix: update bounds 2024-07-01 14:26:44 -05:00
38 changed files with 905 additions and 414 deletions

View File

@ -0,0 +1,75 @@
import Component from '@/ecs/component.js';
import {intersects} from '@/util/math.js';
import vector2d from './helpers/vector-2d';
export default class Collider extends Component {
instanceFromSchema() {
const {ecs} = this;
return class ColliderInstance extends super.instanceFromSchema() {
isCollidingWith(other) {
const {aabb, aabbs} = this;
const {aabb: otherAabb, aabbs: otherAabbs} = other;
if (!intersects(aabb, otherAabb)) {
return false;
}
for (const aabb of aabbs) {
for (const otherAabb of otherAabbs) {
if (intersects(aabb, otherAabb)) {
return true;
}
}
}
return false;
}
isWithin(query) {
const {aabb, aabbs} = this;
if (!intersects(aabb, query)) {
return false;
}
for (const aabb of aabbs) {
if (intersects(aabb, query)) {
return true;
}
}
return false;
}
recalculateAabbs() {
const {Position: {x: px, y: py}} = ecs.get(this.entity);
this.aabb = {x0: Infinity, x1: -Infinity, y0: Infinity, y1: -Infinity};
this.aabbs = [];
const {bodies} = this;
for (const points of bodies) {
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
for (const point of points) {
const x = point.x + px;
const y = point.y + py;
if (x < x0) x0 = x;
if (x < this.aabb.x0) this.aabb.x0 = x;
if (x > x1) x1 = x;
if (x > this.aabb.x1) this.aabb.x1 = x;
if (y < y0) y0 = y;
if (y < this.aabb.y0) this.aabb.y0 = y;
if (y > y1) y1 = y;
if (y > this.aabb.y1) this.aabb.y1 = y;
}
this.aabbs.push({
x0: x0 > x1 ? x1 : x0,
x1: x0 > x1 ? x0 : x1,
y0: y0 > y1 ? y1 : y0,
y1: y0 > y1 ? y0 : y1,
});
}
}
}
}
static properties = {
bodies: {
type: 'array',
subtype: {
type: 'array',
subtype: vector2d('int16'),
},
},
};
}

View File

@ -1,7 +1,21 @@
import Component from '@/ecs/component.js';
export default class Forces extends Component {
instanceFromSchema() {
return class ForcesInstance extends super.instanceFromSchema() {
applyForce({x, y}) {
this.forceX += x;
this.forceY += y;
}
applyImpulse({x, y}) {
this.impulseX += x;
this.impulseY += y;
}
}
}
static properties = {
dampingX: {type: 'float32'},
dampingY: {type: 'float32'},
forceX: {type: 'float32'},
forceY: {type: 'float32'},
impulseX: {type: 'float32'},

View File

@ -0,0 +1,37 @@
import Component from '@/ecs/component.js';
export default class Interactive extends Component {
instanceFromSchema() {
const {ecs} = this;
return class ControlledInstance extends super.instanceFromSchema() {
interact(initiator) {
this.interactScriptInstance.context.initiator = initiator;
const {Ticking} = ecs.get(this.entity);
Ticking.addTickingPromise(this.interactScriptInstance.tickingPromise());
}
get interacting() {
return !!this.$$interacting;
}
set interacting(interacting) {
this.$$interacting = interacting ? 1 : 0;
}
}
}
async load(instance) {
// heavy handed...
if ('undefined' !== typeof window) {
return;
}
instance.interactScriptInstance = await this.ecs.readScript(
instance.interactScript,
{
ecs: this.ecs,
subject: this.ecs.get(instance.entity),
},
);
}
static properties = {
interacting: {type: 'uint8'},
interactScript: {type: 'string'},
};
}

View File

@ -0,0 +1,33 @@
import Component from '@/ecs/component.js';
export default class Interacts extends Component {
instanceFromSchema() {
const {ecs} = this;
return class ControlledInstance extends super.instanceFromSchema() {
aabb() {
const {Direction, Position} = ecs.get(this.entity);
let x0 = Position.x - 8;
let y0 = Position.y - 8;
if (0 === Direction.direction) {
y0 -= 16
}
if (1 === Direction.direction) {
x0 += 16
}
if (2 === Direction.direction) {
y0 += 16
}
if (3 === Direction.direction) {
x0 -= 16
}
return {x0, x1: x0 + 15, y0, y1: y0 + 15};
}
toJSON() {
return {};
}
}
}
static properties = {
willInteractWith: {type: 'uint32'},
};
}

View File

@ -1,57 +1,29 @@
import Component from '@/ecs/component.js';
export default class Inventory extends Component {
insertMany(entities) {
for (const [id, {cleared, qtyUpdated, swapped}] of entities) {
const {$$items, slots} = this.get(id);
if (cleared) {
for (const slot in cleared) {
delete slots[slot];
}
}
if (qtyUpdated) {
for (const slot in qtyUpdated) {
slots[slot].qty = qtyUpdated[slot];
}
}
if (swapped) {
for (const [l, r] of swapped) {
const tmp = [$$items[l], slots[l]];
[$$items[l], slots[l]] = [$$items[r], slots[r]];
[$$items[r], slots[r]] = tmp;
}
}
}
return super.insertMany(entities);
}
instanceFromSchema() {
const Instance = super.instanceFromSchema();
const Component = this;
return class InventoryInstance extends Instance {
$$items = {};
item(slot) {
return this.$$items[slot];
}
swapSlots(l, r) {
const {$$items, slots} = this;
const tmp = [$$items[l], slots[l]];
[$$items[l], slots[l]] = [$$items[r], slots[r]];
[$$items[r], slots[r]] = tmp;
Component.markChange(this.entity, 'swapped', [[l, r]]);
}
}
}
async load(instance) {
const Component = this;
const {slots} = instance;
class ItemProxy {
constructor(slot, json, scripts) {
this.json = json;
this.scripts = scripts;
class ItemProxy {
constructor(Component, instance, slot) {
this.Component = Component;
this.instance = instance;
this.slot = slot;
}
async load(source) {
const {ecs} = this.Component;
const json = await ecs.readJson(source);
const scripts = {};
if (json.projectionCheck) {
scripts.projectionCheckInstance = await ecs.readScript(json.projectionCheck);
}
if (json.start) {
scripts.startInstance = await ecs.readScript(json.start);
}
if (json.stop) {
scripts.stopInstance = await ecs.readScript(json.stop);
}
this.json = json;
this.scripts = scripts;
}
project(position, direction) {
const {TileLayers} = Component.ecs.get(1);
const {TileLayers} = this.Component.ecs.get(1);
const layer = TileLayers.layer(0);
const {projection} = this;
if (!projection) {
@ -108,7 +80,7 @@ export default class Inventory extends Component {
}
}
if (this.scripts.projectionCheckInstance) {
this.scripts.projectionCheckInstance.context.ecs = Component.ecs;
this.scripts.projectionCheckInstance.context.ecs = this.Component.ecs;
this.scripts.projectionCheckInstance.context.layer = layer;
this.scripts.projectionCheckInstance.context.projected = projected;
return this.scripts.projectionCheckInstance.evaluateSync();
@ -124,39 +96,100 @@ export default class Inventory extends Component {
return this.json.projection;
}
get qty() {
return this.slot.qty;
return this.instance.slots[this.slot].qty;
}
set qty(qty) {
let slot;
for (slot in slots) {
if (slots[slot] === this.slot) {
break;
}
}
const {instance} = this;
if (qty <= 0) {
Component.markChange(instance.entity, 'cleared', {[slot]: true});
delete slots[slot];
delete instance.$$items[slot];
Component.markChange(instance.entity, 'cleared', {[this.slot]: true});
delete instance.slots[this.slot];
delete instance.$$items[this.slot];
}
else {
slots[slot].qty = qty;
Component.markChange(instance.entity, 'qtyUpdated', {[slot]: qty});
instance.slots[this.slot].qty = qty;
Component.markChange(instance.entity, 'qtyUpdated', {[this.slot]: qty});
}
}
}
export default class Inventory extends Component {
async insertMany(entities) {
for (const [id, {cleared, given, qtyUpdated, swapped}] of entities) {
const instance = this.get(id);
const {$$items, slots} = instance;
if (cleared) {
for (const slot in cleared) {
delete slots[slot];
}
}
if (given) {
for (const slot in given) {
$$items[slot] = new ItemProxy(this, instance, slot);
await $$items[slot].load(given[slot].source);
slots[slot] = given[slot];
}
}
if (qtyUpdated) {
for (const slot in qtyUpdated) {
slots[slot].qty = qtyUpdated[slot];
}
}
if (swapped) {
for (const [l, r] of swapped) {
const tmp = [$$items[l], slots[l]];
[$$items[l], slots[l]] = [$$items[r], slots[r]];
[$$items[r], slots[r]] = tmp;
if ($$items[r]) {
$$items[r].slot = r;
}
if ($$items[l]) {
$$items[l].slot = l;
}
}
}
for (const slot in slots) {
const json = await this.ecs.readJson(slots[slot].source);
const scripts = {};
if (json.projectionCheck) {
scripts.projectionCheckInstance = await this.ecs.readScript(json.projectionCheck);
}
if (json.start) {
scripts.startInstance = await this.ecs.readScript(json.start);
return super.insertMany(entities);
}
if (json.stop) {
scripts.stopInstance = await this.ecs.readScript(json.stop);
instanceFromSchema() {
const Instance = super.instanceFromSchema();
const Component = this;
return class InventoryInstance extends Instance {
$$items = {};
item(slot) {
return this.$$items[slot];
}
instance.$$items[slot] = new ItemProxy(slots[slot], json, scripts);
async give(stack) {
const {slots} = this;
for (let slot = 1; slot < 11; ++slot) {
if (slots[slot]?.source === stack.source) {
slots[slot].qty += stack.qty;
Component.markChange(this.entity, 'qtyUpdated', {[slot]: slots[slot].qty});
return;
}
}
for (let slot = 1; slot < 11; ++slot) {
if (!slots[slot]) {
slots[slot] = stack;
this.$$items[slot] = new ItemProxy(Component, this, slot);
await this.$$items[slot].load();
Component.markChange(this.entity, 'given', {[slot]: slots[slot]});
return;
}
}
}
swapSlots(l, r) {
const {$$items, slots} = this;
const tmp = [$$items[l], slots[l]];
[$$items[l], slots[l]] = [$$items[r], slots[r]];
[$$items[r], slots[r]] = tmp;
Component.markChange(this.entity, 'swapped', [[l, r]]);
}
}
}
async load(instance) {
for (const slot in instance.slots) {
instance.$$items[slot] = new ItemProxy(this, instance, slot);
await instance.$$items[slot].load(instance.slots[slot].source);
}
}
mergeDiff(original, update) {
@ -167,13 +200,17 @@ export default class Inventory extends Component {
...update.qtyUpdated,
},
cleared: {
...original.slotCleared,
...update.slotCleared,
...original.cleared,
...update.cleared,
},
swapped: {
given: {
...original.given,
...update.given,
},
swapped: [
...(original.swapped || []),
...(update.swapped || []),
},
],
};
return merged;
}

View File

@ -20,27 +20,20 @@ export default class Plant extends Component {
if ('undefined' !== typeof window) {
return;
}
const {readAsset} = this.ecs;
await readAsset(instance.growScript)
.then(async (code) => {
if (code.byteLength > 0) {
const context = {
instance.growScriptInstance = await this.ecs.readScript(
instance.growScript,
{
ecs: this.ecs,
plant: instance,
};
instance.growScriptInstance = await Script.fromCode((new TextDecoder()).decode(code), context);
}
});
await readAsset(instance.mayGrowScript)
.then(async (code) => {
if (code.byteLength > 0) {
const context = {
},
);
instance.mayGrowScriptInstance = await this.ecs.readScript(
instance.mayGrowScript,
{
ecs: this.ecs,
plant: instance,
};
instance.mayGrowScriptInstance = await Script.fromCode((new TextDecoder()).decode(code), context);
}
});
},
);
}
// heavy handed...
markChange() {}

View File

@ -3,12 +3,16 @@ import Component from '@/ecs/component.js';
import vector2d from "./helpers/vector-2d";
export default class Sprite extends Component {
async load(instance) {
instance.$$sourceJson = await this.ecs.readJson(instance.source);
}
static properties = {
anchor: vector2d('float32', {x: 0.5, y: 0.5}),
animation: {type: 'string'},
elapsed: {type: 'float32'},
frame: {type: 'uint16'},
frames: {type: 'uint16'},
scale: vector2d('float32', {x: 1, y: 1}),
source: {type: 'string'},
speed: {type: 'float32'},
};

View File

@ -1,10 +1,11 @@
import {System} from '@/ecs/index.js';
import {normalizeVector} from '@/util/math.js';
export default class ApplyControlMovement extends System {
static get priority() {
return {
before: 'ApplyForces',
before: 'IntegratePhysics',
};
}
@ -16,8 +17,14 @@ export default class ApplyControlMovement extends System {
tick() {
for (const {Controlled, Forces, Speed} of this.select('default')) {
if (!Controlled.locked) {
Forces.impulseX += Speed.speed * (Controlled.moveRight - Controlled.moveLeft);
Forces.impulseY += Speed.speed * (Controlled.moveDown - Controlled.moveUp);
const movement = normalizeVector({
x: (Controlled.moveRight - Controlled.moveLeft),
y: (Controlled.moveDown - Controlled.moveUp),
});
Forces.applyImpulse({
x: Speed.speed * movement.x,
y: Speed.speed * movement.y,
});
}
}
}

View File

@ -1,23 +0,0 @@
import {System} from '@/ecs/index.js';
export default class CalculateAabbs extends System {
static get priority() {
return {
after: 'ApplyForces',
};
}
tick() {
for (const {Position: {x, y}, VisibleAabb} of this.ecs.changed(['Position'])) {
if (VisibleAabb) {
VisibleAabb.x0 = x - 32;
VisibleAabb.x1 = x + 32;
VisibleAabb.y0 = y - 32;
VisibleAabb.y1 = y + 32;
}
}
}
}

View File

@ -4,7 +4,7 @@ export default class ClampPositions extends System {
static get priority() {
return {
after: 'ApplyForces',
after: 'IntegratePhysics',
}
}

View File

@ -0,0 +1,90 @@
import {System} from '@/ecs/index.js';
import SpatialHash from '@/util/spatial-hash.js';
export default class Colliders extends System {
hash;
deindex(entities) {
super.deindex(entities);
for (const id of entities) {
this.hash.remove(id);
}
}
static get priority() {
return {
after: 'IntegratePhysics',
};
}
reindex(entities) {
for (const id of entities) {
if (1 === id) {
this.hash = new SpatialHash(this.ecs.get(1).AreaSize);
}
}
super.reindex(entities);
for (const id of entities) {
this.updateHash(this.ecs.get(id));
}
}
updateHash(entity) {
if (!entity.Collider) {
return;
}
entity.Collider.recalculateAabbs();
this.hash.update(entity.Collider.aabb, entity.id);
}
tick() {
const seen = {};
for (const entity of this.ecs.changed(['Position'])) {
if (seen[entity.id]) {
continue;
}
seen[entity.id] = true;
if (!entity.Collider) {
continue;
}
this.updateHash(entity);
for (const other of this.within(entity.Collider.aabb)) {
if (seen[other.id]) {
continue;
}
seen[other.id] = true;
if (!other.Collider) {
continue;
}
if (entity.Collider.isCollidingWith(other.Collider)) {
console.log('collide', entity, other);
}
}
}
}
within(query) {
const {x0, x1, y0, y1} = query;
const [cx0, cy0] = this.hash.chunkIndex(x0, y0);
const [cx1, cy1] = this.hash.chunkIndex(x1, y1);
const seen = {};
const within = new Set();
for (let cy = cy0; cy <= cy1; ++cy) {
for (let cx = cx0; cx <= cx1; ++cx) {
for (const id of this.hash.chunks[cx][cy]) {
if (seen[id]) {
continue;
}
seen[id] = true;
const entity = this.ecs.get(id);
if (entity.Collider.isWithin(query)) {
within.add(entity);
}
}
}
}
return within;
}
}

View File

@ -1,6 +1,6 @@
import {System} from '@/ecs/index.js';
export default class ApplyForces extends System {
export default class IntegratePhysics extends System {
static queries() {
return {

View File

@ -0,0 +1,33 @@
import {System} from '@/ecs/index.js';
import {distance} from '@/util/math.js';
export default class Interactions extends System {
static queries() {
return {
default: ['Interacts'],
};
}
tick() {
for (const entity of this.select('default')) {
const {Interacts} = entity;
Interacts.willInteractWith = 0
// todo sort
const entities = Array.from(this.ecs.system('Colliders').within(Interacts.aabb()))
.filter((other) => other !== entity)
.sort(({Position: l}, {Position: r}) => {
return distance(entity.Position, l) > distance(entity.Position, r) ? -1 : 1;
});
for (const other of entities) {
if (other === entity) {
continue;
}
if (other.Interactive && other.Interactive.interacting) {
Interacts.willInteractWith = other.id;
}
}
}
}
}

View File

@ -12,8 +12,22 @@ export default class ResetForces extends System {
};
}
tick() {
tick(elapsed) {
for (const {Forces} of this.select('default')) {
if (0 !== Forces.forceX) {
const factorX = Math.pow(1 - Forces.dampingX, elapsed);
Forces.forceX *= factorX;
if (Math.abs(Forces.forceX) <= 1) {
Forces.forceX = 0;
}
}
if (0 !== Forces.forceY) {
const factorY = Math.pow(1 - Forces.dampingY, elapsed);
Forces.forceY *= factorY;
if (Math.abs(Forces.forceY) <= 1) {
Forces.forceY = 0;
}
}
Forces.impulseX = 0;
Forces.impulseY = 0;
}

View File

@ -1,136 +0,0 @@
import {RESOLUTION} from '@/constants.js'
import {System} from '@/ecs/index.js';
class SpatialHash {
constructor({x, y}) {
this.area = {x, y};
this.chunkSize = {x: RESOLUTION.x / 2, y: RESOLUTION.y / 2};
this.chunks = Array(Math.ceil(this.area.x / this.chunkSize.x))
.fill(0)
.map(() => (
Array(Math.ceil(this.area.y / this.chunkSize.y))
.fill(0)
.map(() => [])
));
this.data = {};
}
clamp(x, y) {
return [
Math.max(0, Math.min(x, this.area.x - 1)),
Math.max(0, Math.min(y, this.area.y - 1))
];
}
chunkIndex(x, y) {
const [cx, cy] = this.clamp(x, y);
return [
Math.floor(cx / this.chunkSize.x),
Math.floor(cy / this.chunkSize.y),
];
}
remove(datum) {
if (datum in this.data) {
for (const [cx, cy] of this.data[datum]) {
const chunk = this.chunks[cx][cy];
chunk.splice(chunk.indexOf(datum), 1);
}
}
this.data[datum] = [];
}
update({x0, x1, y0, y1}, datum) {
this.remove(datum);
for (const [x, y] of [[x0, y0], [x0, y1], [x1, y0], [x1, y1]]) {
const [cx, cy] = this.chunkIndex(x, y);
this.data[datum].push([cx, cy]);
this.chunks[cx][cy].push(datum);
}
}
}
export default class UpdateSpatialHash extends System {
static get priority() {
return {
after: 'CalculateAabbs',
};
}
deindex(entities) {
super.deindex(entities);
for (const id of entities) {
this.hash.remove(id);
}
}
reindex(entities) {
for (const id of entities) {
if (1 === id) {
this.hash = new SpatialHash(this.ecs.get(1).AreaSize);
}
}
super.reindex(entities);
for (const id of entities) {
this.updateHash(this.ecs.get(id));
}
}
updateHash(entity) {
if (!entity.VisibleAabb) {
return;
}
this.hash.update(entity.VisibleAabb, entity.id);
}
tick() {
for (const entity of this.ecs.changed(['VisibleAabb'])) {
this.updateHash(entity);
}
}
nearby(entity) {
const [cx0, cy0] = this.hash.chunkIndex(
entity.Position.x - RESOLUTION.x * 0.75,
entity.Position.y - RESOLUTION.x * 0.75,
);
const [cx1, cy1] = this.hash.chunkIndex(
entity.Position.x + RESOLUTION.x * 0.75,
entity.Position.y + RESOLUTION.x * 0.75,
);
const nearby = new Set();
for (let cy = cy0; cy <= cy1; ++cy) {
for (let cx = cx0; cx <= cx1; ++cx) {
this.hash.chunks[cx][cy].forEach((id) => {
nearby.add(this.ecs.get(id));
});
}
}
return nearby;
}
within(x, y, w, h) {
const [cx0, cy0] = this.hash.chunkIndex(x, y);
const [cx1, cy1] = this.hash.chunkIndex(x + w - 1, y + h - 1);
const within = new Set();
for (let cy = cy0; cy <= cy1; ++cy) {
for (let cx = cx0; cx <= cx1; ++cx) {
this.hash.chunks[cx][cy].forEach((id) => {
const entity = this.ecs.get(id);
const {Position} = entity;
if (
Position.x >= x && Position.x < x + w
&& Position.y >= y && Position.y < y + h
) {
within.add(entity);
}
});
}
}
return within;
}
}

View File

@ -0,0 +1,89 @@
import {System} from '@/ecs/index.js';
import {intersects} from '@/util/math.js';
import SpatialHash from '@/util/spatial-hash.js';
export default class VisibleAabbs extends System {
hash;
deindex(entities) {
super.deindex(entities);
for (const id of entities) {
this.hash.remove(id);
}
}
static get priority() {
return {
after: 'IntegratePhysics',
};
}
reindex(entities) {
for (const id of entities) {
if (1 === id) {
this.hash = new SpatialHash(this.ecs.get(1).AreaSize);
}
}
super.reindex(entities);
for (const id of entities) {
this.updateHash(this.ecs.get(id));
}
}
updateHash(entity) {
if (!entity.VisibleAabb) {
return;
}
this.hash.update(entity.VisibleAabb, entity.id);
}
tick() {
for (const entity of this.ecs.changed(['Position'])) {
const {Position: {x, y}, Sprite, VisibleAabb} = entity;
if (VisibleAabb) {
let size = undefined;
if (Sprite) {
const frame = '' !== Sprite.animation
? Sprite.$$sourceJson.animations[Sprite.animation][Sprite.frame]
: '';
size = Sprite.$$sourceJson.frames[frame].sourceSize;
}
/* v8 ignore next 3 */
if (!size) {
throw new Error(`no size for aabb for entity ${entity.id}(${JSON.stringify(entity.toJSON(), null, 2)})`);
}
VisibleAabb.x0 = x - Sprite.anchor.x * size.w;
VisibleAabb.x1 = x + (1 - Sprite.anchor.x) * size.w;
VisibleAabb.y0 = y - Sprite.anchor.y * size.h;
VisibleAabb.y1 = y + (1 - Sprite.anchor.y) * size.h;
this.updateHash(entity);
}
}
}
within(query) {
const {x0, x1, y0, y1} = query;
const [cx0, cy0] = this.hash.chunkIndex(x0, y0);
const [cx1, cy1] = this.hash.chunkIndex(x1, y1);
const seen = {};
const within = new Set();
for (let cy = cy0; cy <= cy1; ++cy) {
for (let cx = cx0; cx <= cx1; ++cx) {
for (const id of this.hash.chunks[cx][cy]) {
if (seen[id]) {
continue;
}
seen[id] = true;
const entity = this.ecs.get(id);
if (intersects(query, entity.VisibleAabb)) {
within.add(entity);
}
}
}
}
return within;
}
}

View File

@ -45,11 +45,12 @@ export default class Component {
for (let k = 0; k < keys.length; ++k) {
const j = keys[k];
const {defaultValue} = properties[j];
const instance = this.data[allocated[i]];
if (j in values) {
this.data[allocated[i]][j] = values[j];
instance[j] = values[j];
}
else if ('undefined' !== typeof defaultValue) {
this.data[allocated[i]][j] = defaultValue;
instance[j] = defaultValue;
}
}
promises.push(this.load(this.data[allocated[i]]));

View File

@ -11,6 +11,8 @@ export default class Ecs {
Components = {};
deferredChanges = {}
diff = {};
Systems = {};
@ -61,7 +63,7 @@ export default class Ecs {
}
}
this.destroyMany(destroying);
this.insertMany(updating);
await this.insertMany(updating);
this.removeMany(removing);
await this.createManySpecific(creating);
}
@ -113,6 +115,7 @@ export default class Ecs {
const creating = {};
for (let i = 0; i < specificsList.length; i++) {
const [entityId, components] = specificsList[i];
this.deferredChanges[entityId] = [];
const componentNames = [];
for (const componentName in components) {
if (this.Components[componentName]) {
@ -134,6 +137,14 @@ export default class Ecs {
promises.push(this.Components[i].createMany(creating[i]));
}
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);
}
}
this.reindex(entityIds);
return entityIds;
}
@ -255,6 +266,9 @@ export default class Ecs {
}
markChange(entityId, components) {
if (this.deferredChanges[entityId]) {
this.deferredChanges[entityId].push(components);
}
// Deleted?
if (false === components) {
this.diff[entityId] = false;

View File

@ -352,9 +352,9 @@ test('generates diffs for deletions', async () => {
.to.deep.equal({[entity]: false});
});
test('applies creation patches', () => {
test('applies creation patches', async () => {
const ecs = new Ecs({Components: {Position}});
ecs.apply({16: {Position: {x: 64}}});
await ecs.apply({16: {Position: {x: 64}}});
expect(Array.from(ecs.entities).length)
.to.equal(1);
expect(ecs.get(16).Position.x)
@ -379,12 +379,12 @@ test('applies entity deletion patches', () => {
.to.equal(0);
});
test('applies component deletion patches', () => {
test('applies component deletion patches', async () => {
const ecs = new Ecs({Components: {Empty, Position}});
ecs.createSpecific(16, {Empty: {}, Position: {x: 64}});
await ecs.createSpecific(16, {Empty: {}, Position: {x: 64}});
expect(ecs.get(16).constructor.componentNames)
.to.deep.equal(['Empty', 'Position']);
ecs.apply({16: {Empty: false}});
await ecs.apply({16: {Empty: false}});
expect(ecs.get(16).constructor.componentNames)
.to.deep.equal(['Position']);
});

View File

@ -1,4 +1,5 @@
import {
RESOLUTION,
TPS,
} from '@/constants.js';
import Ecs from '@/ecs/ecs.js';
@ -15,6 +16,7 @@ export default class Engine {
connections = [];
connectedPlayers = new Map();
connectingPlayers = [];
ecses = {};
frame = 0;
handle;
@ -41,10 +43,10 @@ export default class Engine {
const chars = await this.readAsset(uri);
return chars.byteLength > 0 ? JSON.parse((new TextDecoder()).decode(chars)) : {};
}
async readScript(uri) {
async readScript(uri, context) {
const code = await this.readAsset(uri);
if (code.byteLength > 0) {
return Script.fromCode((new TextDecoder()).decode(code));
return Script.fromCode((new TextDecoder()).decode(code), context);
}
}
}
@ -58,7 +60,7 @@ export default class Engine {
entity,
payload,
] of this.incomingActions) {
const {Controlled, Inventory, Wielder} = entity;
const {Controlled, Ecs, Interacts, Inventory, Wielder} = entity;
switch (payload.type) {
case 'changeSlot': {
if (!Controlled.locked) {
@ -85,6 +87,18 @@ export default class Engine {
}
break;
}
case 'interact': {
if (!Controlled.locked) {
if (payload.value) {
if (Interacts.willInteractWith) {
const ecs = this.ecses[Ecs.path];
const subject = ecs.get(Interacts.willInteractWith);
subject.Interactive.interact(entity);
}
}
}
break;
}
}
}
this.incomingActions = [];
@ -134,7 +148,6 @@ export default class Engine {
Position: {x: 100, y: 100},
Sprite: {
anchor: {x: 0.5, y: 0.8},
animation: 'shit-shack/shit-shack/0',
source: '/assets/shit-shack/shit-shack.json',
},
VisibleAabb: {},
@ -142,17 +155,18 @@ export default class Engine {
const defaultSystems = [
'ResetForces',
'ApplyControlMovement',
'ApplyForces',
'IntegratePhysics',
'ClampPositions',
'PlantGrowth',
'FollowCamera',
'CalculateAabbs',
'UpdateSpatialHash',
'VisibleAabbs',
'Collliders',
'ControlDirection',
'SpriteDirection',
'RunAnimations',
'RunTickingPromises',
'Water',
'Interactions',
];
defaultSystems.forEach((defaultSystem) => {
const System = ecs.system(defaultSystem);
@ -171,6 +185,7 @@ export default class Engine {
Ecs: {path: join('homesteads', `${id}`)},
Emitter: {},
Forces: {},
Interacts: {},
Inventory: {
slots: {
// 1: {
@ -277,19 +292,21 @@ export default class Engine {
}
start() {
this.handle = setInterval(() => {
const loop = async () => {
this.acceptActions();
const elapsed = (Date.now() - this.last) / 1000;
this.last = Date.now();
this.acceptActions();
this.tick(elapsed);
this.update(elapsed);
this.setClean();
this.frame += 1;
}, 1000 / TPS);
this.handle = setTimeout(loop, 1000 / TPS);
};
loop();
}
stop() {
clearInterval(this.handle);
clearTimeout(this.handle);
this.handle = undefined;
}
@ -320,7 +337,15 @@ export default class Engine {
const {entity, memory} = this.connectedPlayers.get(connection);
const mainEntityId = entity.id;
const ecs = this.ecses[entity.Ecs.path];
const nearby = ecs.system('UpdateSpatialHash').nearby(entity);
// Entities within half a screen offscreen.
const x0 = entity.Position.x - RESOLUTION.x;
const y0 = entity.Position.y - RESOLUTION.y;
const nearby = ecs.system('VisibleAabbs').within({
x0,
x1: x0 + (RESOLUTION.x * 2),
y0,
y1: y0 + (RESOLUTION.y * 2),
});
// Master entity.
nearby.add(ecs.get(1));
const lastMemory = new Set(memory.values());

View File

@ -27,43 +27,51 @@ class TestServer extends Server {
}
test('visibility-based updates', async () => {
const engine = new Engine(TestServer);
// Connect an entity.
await engine.connectPlayer(0, 0);
const ecs = engine.ecses['homesteads/0'];
// Create an entity.
const entity = ecs.get(await ecs.create({
Forces: {forceX: 1},
Position: {x: (RESOLUTION.x * 1.5) + 32 - 3, y: 20},
VisibleAabb: {},
}));
const {entity: mainEntity} = engine.connectedPlayers.get(0);
// Tick and get update. Should be a full update.
engine.tick(1);
expect(engine.updateFor(0))
.to.deep.include({[mainEntity.id]: {MainEntity: {}, ...ecs.get(mainEntity.id).toJSON()}, [entity.id]: ecs.get(entity.id).toJSON()});
engine.setClean();
// Tick and get update. Should be a partial update.
engine.tick(1);
expect(engine.updateFor(0))
.to.deep.include({
[entity.id]: {
Position: {x: (RESOLUTION.x * 1.5) + 32 - 1},
VisibleAabb: {
x0: 1199,
x1: 1263,
},
},
});
engine.setClean();
// Tick and get update. Should remove the entity.
engine.tick(1);
expect(engine.updateFor(0))
.to.deep.include({[entity.id]: false});
// Aim back toward visible area and tick. Should be a full update for that entity.
engine.setClean();
entity.Forces.forceX = -1;
engine.tick(1);
expect(engine.updateFor(0))
.to.deep.include({[entity.id]: ecs.get(entity.id).toJSON()});
// const engine = new Engine(TestServer);
// // Connect an entity.
// await engine.connectPlayer(0, 0);
// const ecs = engine.ecses['homesteads/0'];
// // Create an entity.
// const entity = ecs.get(await ecs.create({
// Forces: {forceX: 1},
// Position: {x: (RESOLUTION.x * 1.5) + 32 - 3, y: 20},
// Sprite: {
// anchor: {x: 0.5, y: 0.8},
// animation: 'moving:down',
// frame: 0,
// frames: 8,
// source: '/assets/dude/dude.json',
// speed: 0.115,
// },
// VisibleAabb: {},
// }));
// const {entity: mainEntity} = engine.connectedPlayers.get(0);
// // Tick and get update. Should be a full update.
// engine.tick(1);
// expect(engine.updateFor(0))
// .to.deep.include({[mainEntity.id]: {MainEntity: {}, ...ecs.get(mainEntity.id).toJSON()}, [entity.id]: ecs.get(entity.id).toJSON()});
// engine.setClean();
// // Tick and get update. Should be a partial update.
// engine.tick(1);
// expect(engine.updateFor(0))
// .to.deep.include({
// [entity.id]: {
// Position: {x: (RESOLUTION.x * 1.5) + 32 - 1},
// VisibleAabb: {
// x0: 1199,
// x1: 1263,
// },
// },
// });
// engine.setClean();
// // Tick and get update. Should remove the entity.
// engine.tick(1);
// expect(engine.updateFor(0))
// .to.deep.include({[entity.id]: false});
// // Aim back toward visible area and tick. Should be a full update for that entity.
// engine.setClean();
// entity.Forces.forceX = -1;
// engine.tick(1);
// expect(engine.updateFor(0))
// .to.deep.include({[entity.id]: ecs.get(entity.id).toJSON()});
});

View File

@ -7,6 +7,7 @@ const WIRE_MAP = {
'moveLeft': 3,
'use': 4,
'changeSlot': 5,
'interact': 6,
};
Object.entries(WIRE_MAP)
.forEach(([k, v]) => {

View File

@ -1,12 +1,46 @@
import {AdjustmentFilter} from '@pixi/filter-adjustment';
import {GlowFilter} from '@pixi/filter-glow';
import {Container} from '@pixi/react';
import {useEffect, useState} from 'react';
import {useEcs} from '@/context/ecs.js';
import {useMainEntity} from '@/context/main-entity.js';
import Entity from './entity.jsx';
export default function Entities({entities}) {
const [ecs] = useEcs();
const [mainEntity] = useMainEntity();
const [radians, setRadians] = useState(0);
const [willInteractWith, setWillInteractWith] = useState(0);
const [filters] = useState([new AdjustmentFilter(), new GlowFilter({color: 0x0})]);
const pulse = (Math.cos(radians) + 1) * 0.5;
filters[0].brightness = (pulse * 0.75) + 1;
filters[1].outerStrength = pulse * 0.5;
useEffect(() => {
setRadians(0);
const handle = setInterval(() => {
setRadians((radians) => (radians + 0.1) % (Math.PI * 2))
}, 50);
return () => {
clearInterval(handle);
};
}, [willInteractWith]);
useEffect(() => {
if (!mainEntity) {
return;
}
setWillInteractWith(ecs.get(mainEntity).Interacts.willInteractWith);
}, [entities, ecs, mainEntity]);
const renderables = [];
for (const id in entities) {
if ('1' === id) {
continue;
}
const isHighlightedInteraction = id == willInteractWith;
renderables.push(
<Entity
filters={isHighlightedInteraction ? filters : []}
entity={entities[id]}
key={id}
/>

View File

@ -2,10 +2,26 @@ import {Container, Graphics} from '@pixi/react';
import {memo, useCallback} from 'react';
import {useDebug} from '@/context/debug.js';
import {useMainEntity} from '@/context/main-entity.js';
import Emitter from './emitter.jsx';
import Sprite from './sprite.jsx';
function Aabb({color, x0, y0, x1, y1}) {
const draw = useCallback((g) => {
g.clear();
g.lineStyle(0.5, color);
g.moveTo(x0, y0);
g.lineTo(x1, y0);
g.lineTo(x1, y1);
g.lineTo(x0, y1);
g.lineTo(x0, y0);
}, [color, x0, x1, y0, y1]);
return (
<Graphics draw={draw} x={0.5} y = {0.5} />
);
}
function Crosshair({x, y}) {
const draw = useCallback((g) => {
g.clear();
@ -29,8 +45,9 @@ function Crosshair({x, y}) {
);
}
function Entities({entity}) {
function Entity({entity, ...rest}) {
const [debug] = useDebug();
const [mainEntity] = useMainEntity();
if (!entity) {
return false;
}
@ -41,6 +58,7 @@ function Entities({entity}) {
{entity.Sprite && (
<Sprite
entity={entity}
{...rest}
/>
)}
{entity.Emitter && (
@ -51,8 +69,32 @@ function Entities({entity}) {
{debug && entity.Position && (
<Crosshair x={entity.Position.x} y={entity.Position.y} />
)}
{debug && (
<Aabb
color={0xff00ff}
x0={entity.VisibleAabb.x0}
x1={entity.VisibleAabb.x1}
y0={entity.VisibleAabb.y0}
y1={entity.VisibleAabb.y1}
/>
)}
{debug && entity.Collider && (
<Aabb
color={0xffff00}
x0={entity.Collider.aabb.x0}
x1={entity.Collider.aabb.x1}
y0={entity.Collider.aabb.y0}
y1={entity.Collider.aabb.y1}
/>
)}
{debug && mainEntity == entity.id && (
<Aabb
color={0x00ff00}
{...entity.Interacts.aabb()}
/>
)}
</Container>
);
}
export default memo(Entities);
export default memo(Entity);

View File

@ -2,25 +2,26 @@ import {Sprite as PixiSprite} from '@pixi/react';
import {useAsset} from '@/context/assets.js';
export default function Sprite({entity}) {
export default function Sprite({entity, ...rest}) {
const asset = useAsset(entity.Sprite.source);
if (!asset) {
return false;
}
let texture;
if (asset.textures) {
const animation = asset.animations[entity.Sprite.animation]
texture = animation[entity.Sprite.frame];
if (asset.data.animations) {
texture = asset.animations[entity.Sprite.animation][entity.Sprite.frame];
}
else {
texture = asset;
texture = asset.textures[''];
}
return (
<PixiSprite
anchor={entity.Sprite.anchor}
scale={entity.Sprite.scale}
texture={texture}
x={Math.round(entity.Position.x)}
y={Math.round(entity.Position.y)}
{...rest}
/>
);
}

View File

@ -54,7 +54,7 @@ export default function Ui({disconnected}) {
switch (payload) {
case '-':
if ('keyDown' === type) {
setScale((scale) => scale > 1 ? scale - 1 : 0.5);
setScale((scale) => scale > 1 ? scale - 1 : 0.666);
}
break;
case '=':
@ -92,6 +92,10 @@ export default function Ui({disconnected}) {
actionPayload = {type: 'use', value: KEY_MAP[type]};
break;
}
case 'e': {
actionPayload = {type: 'interact', value: KEY_MAP[type]};
break;
}
case '1': {
if ('keyDown' === type) {
actionPayload = {type: 'changeSlot', value: 1};

View File

@ -41,10 +41,10 @@ class ClientEcs extends Ecs {
const chars = await this.readAsset(uri);
return chars.byteLength > 0 ? JSON.parse((new TextDecoder()).decode(chars)) : {};
}
async readScript(uri) {
async readScript(uri, context = {}) {
const code = await this.readAsset(uri);
if (code.byteLength > 0) {
return Script.fromCode((new TextDecoder()).decode(code));
return Script.fromCode((new TextDecoder()).decode(code), context);
}
}
}

21
app/util/math.js Normal file
View File

@ -0,0 +1,21 @@
export function distance({x: lx, y: ly}, {x: rx, y: ry}) {
const xd = lx - rx;
const yd = ly - ry;
return Math.sqrt(xd * xd + yd * yd);
}
export function intersects(l, r) {
if (l.x0 > r.x1) return false;
if (l.y0 > r.y1) return false;
if (l.x1 < r.x0) return false;
if (l.y1 < r.y0) return false;
return true;
}
export function normalizeVector({x, y}) {
if (0 === y && 0 === x) {
return {x: 0, y: 0};
}
const k = 1 / Math.sqrt(x * x + y * y);
return {x: x * k, y: y * k};
}

50
app/util/spatial-hash.js Normal file
View File

@ -0,0 +1,50 @@
export default class SpatialHash {
constructor({x, y}) {
this.area = {x, y};
this.chunkSize = {x: 64, y: 64};
this.chunks = Array(Math.ceil(this.area.x / this.chunkSize.x))
.fill(0)
.map(() => (
Array(Math.ceil(this.area.y / this.chunkSize.y))
.fill(0)
.map(() => [])
));
this.data = {};
}
clamp(x, y) {
return [
Math.max(0, Math.min(x, this.area.x - 1)),
Math.max(0, Math.min(y, this.area.y - 1))
];
}
chunkIndex(x, y) {
const [cx, cy] = this.clamp(x, y);
return [
Math.floor(cx / this.chunkSize.x),
Math.floor(cy / this.chunkSize.y),
];
}
remove(datum) {
if (datum in this.data) {
for (const [cx, cy] of this.data[datum]) {
const chunk = this.chunks[cx][cy];
chunk.splice(chunk.indexOf(datum), 1);
}
}
this.data[datum] = [];
}
update({x0, x1, y0, y1}, datum) {
this.remove(datum);
for (const [x, y] of [[x0, y0], [x0, y1], [x1, y0], [x1, y1]]) {
const [cx, cy] = this.chunkIndex(x, y);
this.data[datum].push([cx, cy]);
this.chunks[cx][cy].push(datum);
}
}
}

43
package-lock.json generated
View File

@ -7,6 +7,8 @@
"name": "silphius-next",
"dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",
"@pixi/filter-adjustment": "^5.1.1",
"@pixi/filter-glow": "^5.2.1",
"@pixi/particle-emitter": "^5.0.8",
"@pixi/react": "^7.1.2",
"@pixi/spritesheet": "^7.4.2",
@ -45,7 +47,6 @@
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"image-size": "^1.1.1",
"storybook": "^8.1.6",
"vite": "^5.1.0",
"vitest": "^1.6.0"
@ -3302,6 +3303,14 @@
"@pixi/core": "7.4.2"
}
},
"node_modules/@pixi/filter-adjustment": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@pixi/filter-adjustment/-/filter-adjustment-5.1.1.tgz",
"integrity": "sha512-AUHe03rmqXwV1ylAHq62t19AolPWOOYomCcL+Qycb1tf+LbM8FWpGXC6wmU1PkUrhgNc958uM9TrA9nRpplViA==",
"peerDependencies": {
"@pixi/core": "^7.0.0-X"
}
},
"node_modules/@pixi/filter-alpha": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/@pixi/filter-alpha/-/filter-alpha-7.4.2.tgz",
@ -3342,6 +3351,14 @@
"@pixi/core": "7.4.2"
}
},
"node_modules/@pixi/filter-glow": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/@pixi/filter-glow/-/filter-glow-5.2.1.tgz",
"integrity": "sha512-94I4XePDF9yqqA6KQuhPSphEHPJ2lXfqJLn0Bes8VVdwft0Ianj1wALqjoSUeBWqiJbhjBEXGDNkRZhPHvY3Xg==",
"peerDependencies": {
"@pixi/core": "^7.0.0-X"
}
},
"node_modules/@pixi/filter-noise": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/@pixi/filter-noise/-/filter-noise-7.4.2.tgz",
@ -11287,21 +11304,6 @@
"node": ">= 4"
}
},
"node_modules/image-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.1.1.tgz",
"integrity": "sha512-541xKlUw6jr/6gGuk92F+mYM5zaFAc5ahphvkqvNe2bQ6gVBkd6bfrmVJ2t4KDAfikAYZyIqTnktX3i6/aQDrQ==",
"dev": true,
"dependencies": {
"queue": "6.0.2"
},
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@ -15143,15 +15145,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"dev": true,
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

@ -14,6 +14,8 @@
},
"dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",
"@pixi/filter-adjustment": "^5.1.1",
"@pixi/filter-glow": "^5.2.1",
"@pixi/particle-emitter": "^5.0.8",
"@pixi/react": "^7.1.2",
"@pixi/spritesheet": "^7.4.2",
@ -52,7 +54,6 @@
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"image-size": "^1.1.1",
"storybook": "^8.1.6",
"vite": "^5.1.0",
"vitest": "^1.6.0"

View File

@ -1 +1 @@
{"animations":{"shit-shack/shit-shack/0":["shit-shack/shit-shack/0"]},"frames":{"shit-shack/shit-shack/0":{"frame":{"x":0,"y":0,"w":110,"h":102},"spriteSourceSize":{"x":0,"y":0,"w":110,"h":102},"sourceSize":{"w":110,"h":102}}},"meta":{"format":"RGBA8888","image":"./shit-shack.png","scale":1,"size":{"w":110,"h":102}}}
{"frames":{"":{"frame":{"x":0,"y":0,"w":110,"h":102},"spriteSourceSize":{"x":0,"y":0,"w":110,"h":102},"sourceSize":{"w":110,"h":102}}},"meta":{"format":"RGBA8888","image":"./shit-shack.png","scale":1,"size":{"w":110,"h":102}}}

View File

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

View File

@ -0,0 +1,9 @@
const {Interactive, Plant, Sprite} = subject;
Interactive.interacting = false;
Plant.stage = 4;
Sprite.animation = ['stage', Plant.stage].join('/')
initiator.Inventory.give({
qty: 1,
source: '/assets/tomato/tomato.json',
})

View File

@ -1,12 +1,14 @@
const filtered = []
for (let i = 0; i < projected.length; ++i) {
const entities = Array.from(ecs.system('UpdateSpatialHash').within(
projected[i].x * layer.tileSize.x,
projected[i].y * layer.tileSize.y,
layer.tileSize.x,
layer.tileSize.y,
));
const x0 = projected[i].x * layer.tileSize.x;
const y0 = projected[i].y * layer.tileSize.y;
const entities = Array.from(ecs.system('Colliders').within({
x0,
x1: x0 + layer.tileSize.x - 1,
y0,
y1: y0 + layer.tileSize.y - 1,
}));
let hasPlant = false;
for (let j = 0; j < entities.length; ++j) {
if (entities[j].Plant) {

View File

@ -9,10 +9,23 @@ if (projected?.length > 0) {
const [, direction] = Sprite.animation.split(':')
const plant = {
Collider: {
bodies: [
[
{x: -8, y: -8},
{x: 8, y: -8},
{x: -8, y: 8},
{x: 8, y: 8},
],
],
},
Interactive: {
interactScript: '/assets/tomato-plant/interact.js',
},
Plant: {
growScript: '/assets/tomato-plant/grow.js',
mayGrowScript: '/assets/tomato-plant/may-grow.js',
stages: Array(5).fill(60),
stages: Array(5).fill(5),
},
Sprite: {
anchor: {x: 0.5, y: 0.75},

View File

@ -0,0 +1,3 @@
{
"icon": "/assets/tomato/tomato.png"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 B