From 64df3b882f513c4d548ba6bc56cd2cbe2c353564 Mon Sep 17 00:00:00 2001 From: cha0s Date: Tue, 30 Jul 2024 09:56:53 -0500 Subject: [PATCH] refactor: particles --- app/ecs/components/emitter.js | 29 ++++- app/ecs/components/sprite.js | 44 ++++++- app/ecs/components/ttl.js | 22 ++++ app/ecs/systems/clamp-positions.js | 3 + app/ecs/systems/kill-perishable.js | 17 +++ app/ecs/systems/maintain-collider-hash.js | 7 +- app/ecs/systems/visible-aabbs.js | 29 +++-- app/particles/emitter.js | 93 ++++++++++++++ app/particles/emitter.test.js | 52 ++++++++ app/react/components/client-ecs.js | 2 +- app/react/components/particle-worker.js | 36 ++++++ app/react/components/pixi/ecs.jsx | 3 +- app/react/components/pixi/entities.jsx | 44 +++++-- app/react/components/pixi/entity.jsx | 65 +++++----- app/react/components/pixi/light.jsx | 13 +- app/react/components/pixi/pixi.jsx | 3 +- app/react/components/pixi/sprite.jsx | 31 +++-- app/react/components/pixi/targeting-ghost.jsx | 3 +- app/react/components/ui.jsx | 45 +++++-- app/react/context/assets.js | 5 +- app/server/create/ecs.js | 1 + app/server/create/player.js | 2 +- package-lock.json | 6 + package.json | 1 + public/assets/hoe/start.js | 114 +++--------------- public/assets/tomato-seeds/start.js | 95 +++------------ public/assets/watering-can/start.js | 114 ++++-------------- 27 files changed, 525 insertions(+), 354 deletions(-) create mode 100644 app/ecs/components/ttl.js create mode 100644 app/ecs/systems/kill-perishable.js create mode 100644 app/particles/emitter.js create mode 100644 app/particles/emitter.test.js create mode 100644 app/react/components/particle-worker.js diff --git a/app/ecs/components/emitter.js b/app/ecs/components/emitter.js index 60e07dc..3186053 100644 --- a/app/ecs/components/emitter.js +++ b/app/ecs/components/emitter.js @@ -1,13 +1,38 @@ import Component from '@/ecs/component.js'; -export default class Emitter extends Component { +import Emitter from '@/particles/emitter.js'; +import {Ticker as TickerPromise} from '@/util/promise.js'; + +export default class EmitterComponent extends Component { instanceFromSchema() { const Component = this; + const {ecs} = this; return class EmitterInstance extends super.instanceFromSchema() { emitting = {}; id = 0; emit(specification) { - Component.markChange(this.entity, 'emit', {[this.id++]: specification}); + if (specification.server) { + const {Ticker} = ecs.get(1); + if (Ticker) { + const emitter = new Emitter(ecs); + const promise = new Promise((resolve) => { + emitter.emit().onEnd(resolve); + }); + Ticker.add( + new TickerPromise( + (resolve) => { + promise.then(resolve); + }, + (elapsed) => { + this.emitter.tick(elapsed); + } + ) + ); + } + } + else { + Component.markChange(this.entity, 'emit', {[this.id++]: specification}); + } } }; } diff --git a/app/ecs/components/sprite.js b/app/ecs/components/sprite.js index 96b76d0..2e1492c 100644 --- a/app/ecs/components/sprite.js +++ b/app/ecs/components/sprite.js @@ -3,9 +3,23 @@ import Component from '@/ecs/component.js'; export default class Sprite extends Component { instanceFromSchema() { return class SpriteInstance extends super.instanceFromSchema() { + $$anchor = {x: 0.5, y: 0.5}; + $$scale = {x: 1, y: 1}; $$sourceJson = {}; get anchor() { - return {x: this.anchorX, y: this.anchorY}; + return this.$$anchor; + } + get anchorX() { + return this.$$anchor.x; + } + set anchorX(anchorX) { + this.$$anchor = {x: anchorX, y: this.anchorY}; + } + get anchorY() { + return this.$$anchor.y; + } + set anchorY(anchorY) { + this.$$anchor = {x: this.anchorX, y: anchorY}; } get animation() { return super.animation; @@ -56,7 +70,28 @@ export default class Sprite extends Component { return this.$$sourceJson.meta.rotation; } get scale() { - return {x: this.scaleX, y: this.scaleY}; + return this.$$scale; + } + get scaleX() { + return this.$$scale.x; + } + set scaleX(scaleX) { + this.$$scale = {x: scaleX, y: this.scaleY}; + } + get scaleY() { + return this.$$scale.y; + } + set scaleY(scaleY) { + this.$$scale = {x: this.scaleX, y: scaleY}; + } + get size() { + if (!this.$$sourceJson.frames) { + return {x: 16, y: 16}; + } + const frame = this.animation + ? this.$$sourceJson.animations[this.animation][this.frame] + : ''; + return this.$$sourceJson.frames[frame].sourceSize; } toNet(recipient, data) { // eslint-disable-next-line no-unused-vars @@ -66,7 +101,9 @@ export default class Sprite extends Component { }; } async load(instance) { - instance.$$sourceJson = await this.ecs.readJson(instance.source); + if (instance.source) { + instance.$$sourceJson = await this.ecs.readJson(instance.source); + } } markChange(entityId, key, value) { if ('elapsed' === key) { @@ -86,5 +123,6 @@ export default class Sprite extends Component { scaleY: {defaultValue: 1, type: 'float32'}, source: {type: 'string'}, speed: {type: 'float32'}, + tint: {type: 'uint32'}, }; } diff --git a/app/ecs/components/ttl.js b/app/ecs/components/ttl.js new file mode 100644 index 0000000..3311011 --- /dev/null +++ b/app/ecs/components/ttl.js @@ -0,0 +1,22 @@ +import Component from '@/ecs/component.js'; + +export default class Ttl extends Component { + instanceFromSchema() { + const {ecs} = this; + return class TtlInstance extends super.instanceFromSchema() { + $$elapsed = 0; + $$reset() { + this.$$elapsed = 0; + } + tick(elapsed) { + this.$$elapsed += elapsed; + if (this.$$elapsed >= this.ttl) { + ecs.destroy(this.entity); + } + } + } + } + static properties = { + ttl: {type: 'float32'}, + }; +} diff --git a/app/ecs/systems/clamp-positions.js b/app/ecs/systems/clamp-positions.js index e3e208c..cfe49d7 100644 --- a/app/ecs/systems/clamp-positions.js +++ b/app/ecs/systems/clamp-positions.js @@ -10,6 +10,9 @@ export default class ClampPositions extends System { tick() { const {AreaSize} = this.ecs.get(1); + if (!AreaSize) { + return; + } for (const {Position} of this.ecs.changed(['Position'])) { if (Position.x < 0) { Position.x = 0; diff --git a/app/ecs/systems/kill-perishable.js b/app/ecs/systems/kill-perishable.js new file mode 100644 index 0000000..650300c --- /dev/null +++ b/app/ecs/systems/kill-perishable.js @@ -0,0 +1,17 @@ +import {System} from '@/ecs/index.js'; + +export default class KillPerishable extends System { + + static queries() { + return { + default: ['Ttl'], + }; + } + + tick(elapsed) { + for (const {Ttl} of this.select('default')) { + Ttl.tick(elapsed); + } + } + +} diff --git a/app/ecs/systems/maintain-collider-hash.js b/app/ecs/systems/maintain-collider-hash.js index e4862e0..4726915 100644 --- a/app/ecs/systems/maintain-collider-hash.js +++ b/app/ecs/systems/maintain-collider-hash.js @@ -21,7 +21,10 @@ export default class MaintainColliderHash extends System { reindex(entities) { for (const id of entities) { if (1 === id) { - this.hash = new SpatialHash(this.ecs.get(1).AreaSize); + const {AreaSize} = this.ecs.get(1); + if (AreaSize) { + this.hash = new SpatialHash(AreaSize); + } } } super.reindex(entities); @@ -31,7 +34,7 @@ export default class MaintainColliderHash extends System { } updateHash(entity) { - if (!entity.Collider) { + if (!entity.Collider || !this.hash) { return; } this.hash.update(entity.Collider.aabb, entity.id); diff --git a/app/ecs/systems/visible-aabbs.js b/app/ecs/systems/visible-aabbs.js index 823c730..392bc06 100644 --- a/app/ecs/systems/visible-aabbs.js +++ b/app/ecs/systems/visible-aabbs.js @@ -21,15 +21,18 @@ export default class VisibleAabbs extends System { reindex(entities) { for (const id of entities) { if (1 === id) { - const {x, y} = this.ecs.get(1).AreaSize; - if ( - !this.hash || - ( - this.hash.area.x !== x - || this.hash.area.y !== y - ) - ) { - this.hash = new SpatialHash(this.ecs.get(1).AreaSize); + const {AreaSize} = this.ecs.get(1) + if (AreaSize) { + const {x, y} = AreaSize; + if ( + !this.hash || + ( + this.hash.area.x !== x + || this.hash.area.y !== y + ) + ) { + this.hash = new SpatialHash(this.ecs.get(1).AreaSize); + } } } } @@ -40,6 +43,9 @@ export default class VisibleAabbs extends System { } updateHash(entity) { + if (!this.hash) { + return; + } if (!entity.VisibleAabb) { this.hash.remove(entity.id); return; @@ -53,10 +59,7 @@ export default class VisibleAabbs extends System { if (VisibleAabb) { let size = undefined; if (Sprite) { - const frame = Sprite.animation - ? Sprite.$$sourceJson.animations[Sprite.animation][Sprite.frame] - : ''; - size = Sprite.$$sourceJson.frames[frame].sourceSize; + size = Sprite.size; } /* v8 ignore next 3 */ if (!size) { diff --git a/app/particles/emitter.js b/app/particles/emitter.js new file mode 100644 index 0000000..cd0f304 --- /dev/null +++ b/app/particles/emitter.js @@ -0,0 +1,93 @@ +import K from 'kefir'; + +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)); + if (shape) { + switch (shape.type) { + 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); + break; + } + } + } + return allocated; + } + emit(particle) { + particle = { + ...particle, + entity: { + Position: {}, + Sprite: {}, + VisibleAabb: {}, + ...particle.entity, + }, + } + let {count = 1} = particle; + const {frequency = 0} = particle; + const stream = K.stream((emitter) => { + if (0 === frequency) { + const promises = []; + for (let i = 0; i < count; ++i) { + promises.push( + this.allocate(particle) + .then((entity) => { + emitter.emit(entity); + }), + ); + } + Promise.all(promises) + .then(() => { + emitter.end(); + }); + return; + } + const promise = this.allocate(particle) + .then((entity) => { + emitter.emit(entity); + }); + count -= 1; + if (0 === count) { + promise.then(() => { + 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); + }), + ); + accumulated -= frequency; + count -= 1; + } + if (0 === count) { + this.scheduled.splice(this.scheduled.indexOf(scheduled), 1); + Promise.all(promises).then(() => { + emitter.end(); + }); + } + }; + this.scheduled.push(scheduled); + }); + return stream; + } + + tick(elapsed) { + for (const ticker of this.scheduled) { + ticker(elapsed); + } + } + +} diff --git a/app/particles/emitter.test.js b/app/particles/emitter.test.js new file mode 100644 index 0000000..014502f --- /dev/null +++ b/app/particles/emitter.test.js @@ -0,0 +1,52 @@ +import {expect, test} from 'vitest'; + +import Components from '@/ecs/components/index.js'; +import Ecs from '@/ecs/ecs.js'; + +import Emitter from './emitter.js'; + +test('emits particles at once', async () => { + const ecs = new Ecs({ + Components, + }); + const emitter = new Emitter(ecs); + const stream = emitter.emit({ + count: 5, + frequency: 0, + entity: {}, + }); + expect(await stream.scan((r) => r + 1, 0).toPromise()).to.equal(5); +}); + +test('emits particles over time', async () => { + const ecs = new Ecs({ + Components, + }); + const emitter = new Emitter(ecs); + const stream = emitter.emit({ + count: 2, + frequency: 0.1, + }); + const current = stream.toProperty(); + expect(await new Promise((resolve) => { + current.onValue(resolve); + })) + .to.deep.include({id: 1}); + expect(ecs.get(1)) + .to.not.be.undefined; + expect(ecs.get(2)) + .to.be.undefined; + emitter.tick(0.06); + expect(await new Promise((resolve) => { + current.onValue(resolve); + })) + .to.deep.include({id: 1}); + emitter.tick(0.06); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(await new Promise((resolve) => { + current.onValue(resolve); + })) + .to.deep.include({id: 2}); + expect(ecs.get(2)) + .to.not.be.undefined; +}); diff --git a/app/react/components/client-ecs.js b/app/react/components/client-ecs.js index e210501..899844e 100644 --- a/app/react/components/client-ecs.js +++ b/app/react/components/client-ecs.js @@ -23,7 +23,7 @@ export default class ClientEcs extends Ecs { if (!cache.has(uri)) { const {promise, resolve, reject} = withResolvers(); cache.set(uri, promise); - fetch(new URL(uri, window.location.origin)) + fetch(new URL(uri, location.origin)) .then(async (response) => { resolve(response.ok ? response.arrayBuffer() : new ArrayBuffer(0)); }) diff --git a/app/react/components/particle-worker.js b/app/react/components/particle-worker.js new file mode 100644 index 0000000..2dd8920 --- /dev/null +++ b/app/react/components/particle-worker.js @@ -0,0 +1,36 @@ +import Emitter from '@/particles/emitter.js'; +import createEcs from '@/server/create/ecs.js'; + +import ClientEcs from './client-ecs.js'; + +const ecs = createEcs(ClientEcs); +ecs.$$caret = Math.pow(2, 31); + +const emitter = new Emitter(ecs); + +addEventListener('message', (particle) => { + if (!ecs.get(1)) { + ecs.createManySpecific([[1, particle.data]]); + return; + } + emitter.emit(particle.data) + .onEnd(() => {}); +}); + +let last = Date.now(); +function tick() { + const now = Date.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); +} +requestAnimationFrame(tick); diff --git a/app/react/components/pixi/ecs.jsx b/app/react/components/pixi/ecs.jsx index 399cf4e..e4d8dca 100644 --- a/app/react/components/pixi/ecs.jsx +++ b/app/react/components/pixi/ecs.jsx @@ -28,7 +28,7 @@ function calculateDarkness(hour) { return Math.floor(darkness * 1000) / 1000; } -export default function Ecs({applyFilters, camera, monopolizers, scale}) { +export default function Ecs({applyFilters, camera, monopolizers, particleWorker, scale}) { const [ecs] = useEcs(); const [filters, setFilters] = useState([]); const [mainEntity] = useMainEntity(); @@ -154,6 +154,7 @@ export default function Ecs({applyFilters, camera, monopolizers, scale}) { {projected?.length > 0 && layers[0] && ( { + 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, + }; + }); + } + 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; @@ -43,10 +74,9 @@ export default function Entities({filters, monopolizers}) { } updating[id] = ecs.get(id); if (update.Emitter?.emit) { - updating[id].Emitter.emitting = { - ...updating[id].Emitter.emitting, - ...update.Emitter.emit, - }; + for (const id in update.Emitter.emit) { + particleWorker?.postMessage(update.Emitter.emit[id]); + } } } setEntities((entities) => { @@ -58,7 +88,7 @@ export default function Entities({filters, monopolizers}) { ...updating, }; }); - }, [ecs]); + }, [ecs, particleWorker]); useEcsTick(() => { if (!ecs) { return; diff --git a/app/react/components/pixi/entity.jsx b/app/react/components/pixi/entity.jsx index aacd2c8..0060094 100644 --- a/app/react/components/pixi/entity.jsx +++ b/app/react/components/pixi/entity.jsx @@ -4,9 +4,8 @@ import {memo, useCallback} from 'react'; import {useDebug} from '@/react/context/debug.js'; import {useMainEntity} from '@/react/context/main-entity.js'; -import Emitter from './emitter.jsx'; import Light from './light.jsx'; -import Sprite from './sprite.jsx'; +import SpriteComponent from './sprite.jsx'; function Aabb({color, width = 0.5, x0, y0, x1, y1, ...rest}) { const draw = useCallback((g) => { @@ -23,7 +22,7 @@ function Aabb({color, width = 0.5, x0, y0, x1, y1, ...rest}) { ); } -function Crosshair({x, y}) { +function Crosshair() { const draw = useCallback((g) => { g.clear(); g.lineStyle(1, 0x000000); @@ -42,7 +41,7 @@ function Crosshair({x, y}) { g.drawCircle(0, 0, 3); }, []); return ( - + ); } @@ -52,31 +51,39 @@ function Entity({entity, ...rest}) { if (!entity) { return false; } + const {Direction, id, Sprite} = entity; return ( - - {entity.Sprite && ( - - )} - {entity.Emitter && ( - - )} - {entity.Light && ( - - )} - {debug && entity.Position && ( - - )} + <> + + {entity.Sprite && ( + + )} + {entity.Light && ( + + )} + {debug && entity.Position && ( + + )} + {debug && ( )} - + ); } diff --git a/app/react/components/pixi/light.jsx b/app/react/components/pixi/light.jsx index 7499d1c..5d607f4 100644 --- a/app/react/components/pixi/light.jsx +++ b/app/react/components/pixi/light.jsx @@ -3,13 +3,11 @@ import {PixiComponent} from '@pixi/react'; import {PointLight} from './lights.js'; const LightInternal = PixiComponent('Light', { - create({brightness, x, y}) { + create({brightness}) { const light = new PointLight( 0xffffff - 0x2244cc, - 0//brightness, + brightness, ); - - light.position.set(x, y); // light.shader.program.fragmentSrc = light.shader.program.fragmentSrc.replace( // 'float D = length(lightVector)', // 'float D = length(lightVector) / 1.0', @@ -24,17 +22,12 @@ const LightInternal = PixiComponent('Light', { // delete light.parentGroup; return light; }, - applyProps(light, oldProps, {x, y}) { - light.position.set(x, y); - }, }); -export default function Light({brightness, x, y}) { +export default function Light({brightness}) { return ( ) } diff --git a/app/react/components/pixi/pixi.jsx b/app/react/components/pixi/pixi.jsx index a0b7847..cfe7920 100644 --- a/app/react/components/pixi/pixi.jsx +++ b/app/react/components/pixi/pixi.jsx @@ -57,7 +57,7 @@ export const Stage = ({children, ...props}) => { ); }; -export default function Pixi({applyFilters, camera, monopolizers, scale}) { +export default function Pixi({applyFilters, camera, monopolizers, particleWorker, scale}) { return ( diff --git a/app/react/components/pixi/sprite.jsx b/app/react/components/pixi/sprite.jsx index 91c62ed..e4f62ab 100644 --- a/app/react/components/pixi/sprite.jsx +++ b/app/react/components/pixi/sprite.jsx @@ -1,5 +1,5 @@ import {Sprite as PixiSprite} from '@pixi/react'; -import {useEffect, useState} from 'react'; +import {memo, useEffect, useState} from 'react'; import {useAsset} from '@/react/context/assets.js'; @@ -22,11 +22,23 @@ function textureFromAsset(asset, animation, frame) { return texture; } -export default function Sprite({entity, ...rest}) { +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 {alpha, anchor, animation, frame, scale, rotates, rotation, source} = entity.Sprite; const asset = useAsset(source); const normalsAsset = useAsset(normals); useEffect(() => { @@ -65,11 +77,10 @@ export default function Sprite({entity, ...rest}) { alpha={alpha} anchor={anchor} ref={setMounted} - {...(rotates ? {rotation: entity.Direction.direction + rotation} : {})} + {...(rotates ? {rotation: direction + rotation} : {})} scale={scale} texture={texture} - x={Math.round(entity.Position.x)} - y={Math.round(entity.Position.y)} + {...(0 !== tint ? {tint} : {})} {...rest} /> )} @@ -78,14 +89,14 @@ export default function Sprite({entity, ...rest}) { alpha={alpha} anchor={anchor} ref={setNormalsMounted} - {...(rotates ? {rotation: entity.Direction.direction + rotation} : {})} + {...(rotates ? {rotation: direction + rotation} : {})} scale={scale} texture={normalsTexture} - x={Math.round(entity.Position.x)} - y={Math.round(entity.Position.y)} {...rest} /> )} ); -} \ No newline at end of file +} + +export default memo(Sprite); diff --git a/app/react/components/pixi/targeting-ghost.jsx b/app/react/components/pixi/targeting-ghost.jsx index 45dd88b..83d9651 100644 --- a/app/react/components/pixi/targeting-ghost.jsx +++ b/app/react/components/pixi/targeting-ghost.jsx @@ -10,12 +10,13 @@ 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.6; + targetInner.alpha = 0.3; targetInner.lineStyle(3, 0x333333); targetInner.beginFill(0xdddddd); targetInner.pivot = {x: tileSize.x / 2, y: tileSize.y / 2}; diff --git a/app/react/components/ui.jsx b/app/react/components/ui.jsx index 1605336..c503989 100644 --- a/app/react/components/ui.jsx +++ b/app/react/components/ui.jsx @@ -62,6 +62,10 @@ function Ui({disconnected}) { const [isInventoryOpen, setIsInventoryOpen] = useState(false); const [externalInventory, setExternalInventory] = useState(); const [externalInventorySlots, setExternalInventorySlots] = useState(); + const [particleWorker, setParticleWorker] = useState(); + const refreshEcs = useCallback(() => { + setEcs(new ClientEcs({Components, Systems})); + }, [Components, Systems, setEcs]); useEffect(() => { async function setEcsStuff() { const {default: Components} = await import('@/ecs/components/index.js'); @@ -72,8 +76,8 @@ function Ui({disconnected}) { setEcsStuff(); }, []); useEffect(() => { - setEcs(new ClientEcs({Components, Systems})); - }, [Components, setEcs, Systems]); + refreshEcs(); + }, [refreshEcs]); useEffect(() => { let handle; if (disconnected) { @@ -305,10 +309,10 @@ function Ui({disconnected}) { setScale, ]); usePacket('EcsChange', async () => { - setEcs(new ClientEcs({Components, Systems})); + refreshEcs(); setMainEntity(undefined); setMonopolizers([]); - }, [Components, Systems, setEcs, setMainEntity]); + }, [refreshEcs, setMainEntity, setMonopolizers]); usePacket('Tick', async (payload, client) => { if (0 === Object.keys(payload.ecs).length) { return; @@ -316,6 +320,22 @@ function Ui({disconnected}) { 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; + }); + }, [particleWorker]); useEcsTick((payload) => { let localMainEntity = mainEntity; for (const id in payload) { @@ -518,6 +538,7 @@ function Ui({disconnected}) { applyFilters={applyFilters} camera={camera} monopolizers={monopolizers} + particleWorker={particleWorker} scale={scale} /> @@ -583,13 +604,15 @@ function Ui({disconnected}) { )} -
- -
+ {devtoolsIsOpen && ( +
+ +
+ )} ); } diff --git a/app/react/context/assets.js b/app/react/context/assets.js index 42b0e19..7291498 100644 --- a/app/react/context/assets.js +++ b/app/react/context/assets.js @@ -1,3 +1,4 @@ +import {Texture} from '@pixi/core'; import {Assets} from '@pixi/assets'; import {createContext, useContext, useEffect} from 'react'; @@ -11,7 +12,7 @@ export function useAsset(source) { const [assets, setAssets] = useContext(context); useEffect(() => { if (!source) { - return undefined; + return; } if (!assets[source]) { if (!loading[source]) { @@ -24,5 +25,5 @@ export function useAsset(source) { } } }, [assets, setAssets, source]); - return source ? assets[source] : undefined; + return source ? assets[source] : {data: {meta: {}}, textures: {'': Texture.WHITE}}; } diff --git a/app/server/create/ecs.js b/app/server/create/ecs.js index 1366eb2..8f55fe4 100644 --- a/app/server/create/ecs.js +++ b/app/server/create/ecs.js @@ -23,6 +23,7 @@ export default function createEcs(Ecs) { 'Water', 'Interactions', 'InventoryCloser', + 'KillPerishable', ]; defaultSystems.forEach((defaultSystem) => { const System = ecs.system(defaultSystem); diff --git a/app/server/create/player.js b/app/server/create/player.js index 5373775..0a09e3c 100644 --- a/app/server/create/player.js +++ b/app/server/create/player.js @@ -33,7 +33,7 @@ export default async function createPlayer(id) { }, }, Health: {health: 100}, - Light: {brightness: 4}, + Light: {brightness: 0}, Magnet: {strength: 24}, Player: {}, Position: {x: 128, y: 448}, diff --git a/package-lock.json b/package-lock.json index e7542ff..46c337a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "express": "^4.18.2", "idb-keyval": "^6.2.1", "isbot": "^4.1.0", + "kefir": "^3.8.8", "lru-cache": "^10.2.2", "morgan": "^1.10.0", "pixi.js": "^7.4.2", @@ -12697,6 +12698,11 @@ "node": ">=4.0" } }, + "node_modules/kefir": { + "version": "3.8.8", + "resolved": "https://registry.npmjs.org/kefir/-/kefir-3.8.8.tgz", + "integrity": "sha512-xWga7QCZsR2Wjy2vNL3Kq/irT+IwxwItEWycRRlT5yhqHZK2fmEhziP+LzcJBWSTAMranGKtGTQ6lFpyJS3+jA==" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/package.json b/package.json index 6e703e9..6f388d5 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "express": "^4.18.2", "idb-keyval": "^6.2.1", "isbot": "^4.1.0", + "kefir": "^3.8.8", "lru-cache": "^10.2.2", "morgan": "^1.10.0", "pixi.js": "^7.4.2", diff --git a/public/assets/hoe/start.js b/public/assets/hoe/start.js index 5aab7fa..52dd897 100644 --- a/public/assets/hoe/start.js +++ b/public/assets/hoe/start.js @@ -9,104 +9,30 @@ if (projected?.length > 0) { Controlled.locked = 1 const [, direction] = Sprite.animation.split(':') - const dirtParticles = { - behaviors: [ - { - type: 'moveAcceleration', - config: { - accel: { - x: 0, - y: 200, - }, - minStart: 0, - maxStart: 0, - rotate: false, - } - }, - { - type: 'moveSpeed', - config: { - speed: { - list: [ - { - time: 0, - value: 60 - }, - { - time: 1, - value: 10 - } - ] - } - } - }, - { - type: 'rotation', - config: { - accel: 0, - minSpeed: 0, - maxSpeed: 0, - minStart: 225, - maxStart: 320 - } - }, - { - type: 'scale', - config: { - scale: { - list: [ - { - value: 0.25, - time: 0, - }, - { - value: 0.125, - time: 1, - }, - ] - } - } - }, - { - type: 'textureSingle', - config: { - texture: 'tileset/7', - } - }, - ], - lifetime: { - min: 0.25, - max: 0.25, - }, - frequency: 0.01, - emitterLifetime: 0.25, - pos: { - x: 0, - y: 0 - }, - }; - for (let i = 0; i < 2; ++i) { Sound.play('/assets/hoe/dig.wav'); for (const {x, y} of projected) { Emitter.emit({ - ...dirtParticles, - behaviors: [ - ...dirtParticles.behaviors, - { - type: 'spawnShape', - config: { - type: 'rect', - data: { - x: x * layer.tileSize.x, - y: y * layer.tileSize.y, - w: layer.tileSize.x, - h: layer.tileSize.y, - } - } - } - ] - }) + count: 25, + frequency: 0.01, + shape: { + type: 'filledRect', + payload: {width: 16, height: 16}, + }, + entity: { + Forces: {forceY: -50}, + 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}, + } + }); } Sprite.animation = ['moving', direction].join(':'); await wait(0.3) diff --git a/public/assets/tomato-seeds/start.js b/public/assets/tomato-seeds/start.js index e0010e2..7781925 100644 --- a/public/assets/tomato-seeds/start.js +++ b/public/assets/tomato-seeds/start.js @@ -38,84 +38,27 @@ if (projected?.length > 0) { VisibleAabb: {}, }; - const seedParticles = { - behaviors: [ - { - type: 'moveAcceleration', - config: { - accel: { - x: 0, - y: 400, - }, - minStart: 0, - maxStart: 0, - rotate: false, - } - }, - { - type: 'moveSpeed', - config: { - speed: { - list: [ - { - time: 0, - value: 100 - }, - { - time: 1, - value: 10 - } - ] - } - } - }, - { - type: 'rotation', - config: { - accel: 0, - minSpeed: 0, - maxSpeed: 0, - minStart: 225, - maxStart: 320 - } - }, - { - type: 'scale', - config: { - scale: { - list: [ - { - value: 0.75, - time: 0, - }, - { - value: 0.5, - time: 1, - }, - ] - } - } - }, - { - type: 'textureSingle', - config: { - texture: 'tomato-plant/tomato-plant/0', - } - }, - ], - lifetime: { - min: 0.25, - max: 0.25, - }, + Emitter.emit({ + count: 25, frequency: 0.01, - emitterLifetime: 0.75, - pos: { - x: Position.x, - y: Position.y, + shape: { + type: 'filledRect', + payload: {width: 16, height: 16}, }, - }; - - Emitter.emit(seedParticles) + entity: { + Forces: {forceY: -100}, + Position: { + x: Position.x, + y: Position.y, + }, + Sprite: { + scaleX: 0.125, + scaleY: 0.125, + tint: 0x221100, + }, + Ttl: {ttl: 0.25}, + } + }); Sound.play('/assets/sow.wav'); Sprite.animation = ['moving', direction].join(':'); diff --git a/public/assets/watering-can/start.js b/public/assets/watering-can/start.js index 176fc7c..0593208 100644 --- a/public/assets/watering-can/start.js +++ b/public/assets/watering-can/start.js @@ -10,98 +10,32 @@ if (projected?.length > 0) { const [, direction] = Sprite.animation.split(':') Sprite.animation = ['idle', direction].join(':'); - const waterParticles = { - behaviors: [ - { - type: 'moveAcceleration', - config: { - accel: { - x: 0, - y: 1500, - }, - minStart: 0, - maxStart: 0, - rotate: false, - } - }, - { - type: 'moveSpeed', - config: { - speed: { - list: [ - { - time: 0, - value: 30 - }, - { - time: 1, - value: 0 - } - ] - } - } - }, - { - type: 'scale', - config: { - scale: { - list: [ - { - value: 0.25, - time: 0, - }, - { - value: 0.125, - time: 1, - }, - ] - } - } - }, - { - type: 'textureSingle', - config: { - texture: 'tileset/38', - } - }, - ], - lifetime: { - min: 0.25, - max: 0.25, - }, - frequency: 0.01, - emitterLifetime: 0.25, - pos: { - x: 0, - y: 0 - }, - rotation: 0, - }; - Sound.play('/assets/watering-can/water.wav'); - for (let i = 0; i < 2; ++i) { - for (const {x, y} of projected) { - Emitter.emit({ - ...waterParticles, - behaviors: [ - ...waterParticles.behaviors, - { - type: 'spawnShape', - config: { - type: 'rect', - data: { - x: x * layer.tileSize.x, - y: y * layer.tileSize.y - (layer.tileSize.y * 0.5), - w: layer.tileSize.x, - h: layer.tileSize.y, - } - } - } - ] - }) - } - await wait(0.25); + + 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, + }, + Sprite: { + scaleX: 0.2, + scaleY: 0.2, + tint: 0x0022aa, + }, + Ttl: {ttl: 0.25}, + } + }); } + await wait(0.5); for (const {x, y} of projected) { const tileIndex = layer.area.x * y + x