refactor: particles

This commit is contained in:
cha0s 2024-07-30 09:56:53 -05:00
parent b719e3227b
commit 64df3b882f
27 changed files with 525 additions and 354 deletions

View File

@ -1,13 +1,38 @@
import Component from '@/ecs/component.js'; 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() { instanceFromSchema() {
const Component = this; const Component = this;
const {ecs} = this;
return class EmitterInstance extends super.instanceFromSchema() { return class EmitterInstance extends super.instanceFromSchema() {
emitting = {}; emitting = {};
id = 0; id = 0;
emit(specification) { 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});
}
} }
}; };
} }

View File

@ -3,9 +3,23 @@ import Component from '@/ecs/component.js';
export default class Sprite extends Component { export default class Sprite extends Component {
instanceFromSchema() { instanceFromSchema() {
return class SpriteInstance extends super.instanceFromSchema() { return class SpriteInstance extends super.instanceFromSchema() {
$$anchor = {x: 0.5, y: 0.5};
$$scale = {x: 1, y: 1};
$$sourceJson = {}; $$sourceJson = {};
get anchor() { 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() { get animation() {
return super.animation; return super.animation;
@ -56,7 +70,28 @@ export default class Sprite extends Component {
return this.$$sourceJson.meta.rotation; return this.$$sourceJson.meta.rotation;
} }
get scale() { 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) { toNet(recipient, data) {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
@ -66,7 +101,9 @@ export default class Sprite extends Component {
}; };
} }
async load(instance) { 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) { markChange(entityId, key, value) {
if ('elapsed' === key) { if ('elapsed' === key) {
@ -86,5 +123,6 @@ export default class Sprite extends Component {
scaleY: {defaultValue: 1, type: 'float32'}, scaleY: {defaultValue: 1, type: 'float32'},
source: {type: 'string'}, source: {type: 'string'},
speed: {type: 'float32'}, speed: {type: 'float32'},
tint: {type: 'uint32'},
}; };
} }

22
app/ecs/components/ttl.js Normal file
View File

@ -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'},
};
}

View File

@ -10,6 +10,9 @@ export default class ClampPositions extends System {
tick() { tick() {
const {AreaSize} = this.ecs.get(1); const {AreaSize} = this.ecs.get(1);
if (!AreaSize) {
return;
}
for (const {Position} of this.ecs.changed(['Position'])) { for (const {Position} of this.ecs.changed(['Position'])) {
if (Position.x < 0) { if (Position.x < 0) {
Position.x = 0; Position.x = 0;

View File

@ -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);
}
}
}

View File

@ -21,7 +21,10 @@ export default class MaintainColliderHash extends System {
reindex(entities) { reindex(entities) {
for (const id of entities) { for (const id of entities) {
if (1 === id) { 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); super.reindex(entities);
@ -31,7 +34,7 @@ export default class MaintainColliderHash extends System {
} }
updateHash(entity) { updateHash(entity) {
if (!entity.Collider) { if (!entity.Collider || !this.hash) {
return; return;
} }
this.hash.update(entity.Collider.aabb, entity.id); this.hash.update(entity.Collider.aabb, entity.id);

View File

@ -21,15 +21,18 @@ export default class VisibleAabbs extends System {
reindex(entities) { reindex(entities) {
for (const id of entities) { for (const id of entities) {
if (1 === id) { if (1 === id) {
const {x, y} = this.ecs.get(1).AreaSize; const {AreaSize} = this.ecs.get(1)
if ( if (AreaSize) {
!this.hash || const {x, y} = AreaSize;
( if (
this.hash.area.x !== x !this.hash ||
|| this.hash.area.y !== y (
) this.hash.area.x !== x
) { || this.hash.area.y !== y
this.hash = new SpatialHash(this.ecs.get(1).AreaSize); )
) {
this.hash = new SpatialHash(this.ecs.get(1).AreaSize);
}
} }
} }
} }
@ -40,6 +43,9 @@ export default class VisibleAabbs extends System {
} }
updateHash(entity) { updateHash(entity) {
if (!this.hash) {
return;
}
if (!entity.VisibleAabb) { if (!entity.VisibleAabb) {
this.hash.remove(entity.id); this.hash.remove(entity.id);
return; return;
@ -53,10 +59,7 @@ export default class VisibleAabbs extends System {
if (VisibleAabb) { if (VisibleAabb) {
let size = undefined; let size = undefined;
if (Sprite) { if (Sprite) {
const frame = Sprite.animation size = Sprite.size;
? Sprite.$$sourceJson.animations[Sprite.animation][Sprite.frame]
: '';
size = Sprite.$$sourceJson.frames[frame].sourceSize;
} }
/* v8 ignore next 3 */ /* v8 ignore next 3 */
if (!size) { if (!size) {

93
app/particles/emitter.js Normal file
View File

@ -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);
}
}
}

View File

@ -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;
});

View File

@ -23,7 +23,7 @@ export default class ClientEcs extends Ecs {
if (!cache.has(uri)) { if (!cache.has(uri)) {
const {promise, resolve, reject} = withResolvers(); const {promise, resolve, reject} = withResolvers();
cache.set(uri, promise); cache.set(uri, promise);
fetch(new URL(uri, window.location.origin)) fetch(new URL(uri, location.origin))
.then(async (response) => { .then(async (response) => {
resolve(response.ok ? response.arrayBuffer() : new ArrayBuffer(0)); resolve(response.ok ? response.arrayBuffer() : new ArrayBuffer(0));
}) })

View File

@ -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);

View File

@ -28,7 +28,7 @@ function calculateDarkness(hour) {
return Math.floor(darkness * 1000) / 1000; 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 [ecs] = useEcs();
const [filters, setFilters] = useState([]); const [filters, setFilters] = useState([]);
const [mainEntity] = useMainEntity(); const [mainEntity] = useMainEntity();
@ -154,6 +154,7 @@ export default function Ecs({applyFilters, camera, monopolizers, scale}) {
<Entities <Entities
filters={filters} filters={filters}
monopolizers={monopolizers} monopolizers={monopolizers}
particleWorker={particleWorker}
/> />
{projected?.length > 0 && layers[0] && ( {projected?.length > 0 && layers[0] && (
<TargetingGhost <TargetingGhost

View File

@ -1,7 +1,7 @@
import {AdjustmentFilter} from '@pixi/filter-adjustment'; import {AdjustmentFilter} from '@pixi/filter-adjustment';
import {GlowFilter} from '@pixi/filter-glow'; import {GlowFilter} from '@pixi/filter-glow';
import {Container} from '@pixi/react'; import {Container} from '@pixi/react';
import {useState} from 'react'; import {useEffect, useState} from 'react';
import {usePacket} from '@/react/context/client.js'; import {usePacket} from '@/react/context/client.js';
import {useEcs, useEcsTick} from '@/react/context/ecs.js'; import {useEcs, useEcsTick} from '@/react/context/ecs.js';
@ -10,7 +10,7 @@ import {useRadians} from '@/react/context/radians.js';
import Entity from './entity.jsx'; import Entity from './entity.jsx';
export default function Entities({filters, monopolizers}) { export default function Entities({filters, monopolizers, particleWorker}) {
const [ecs] = useEcs(); const [ecs] = useEcs();
const [entities, setEntities] = useState({}); const [entities, setEntities] = useState({});
const [mainEntity] = useMainEntity(); const [mainEntity] = useMainEntity();
@ -20,6 +20,37 @@ export default function Entities({filters, monopolizers}) {
new AdjustmentFilter(), new AdjustmentFilter(),
new GlowFilter({color: 0x0}), new GlowFilter({color: 0x0}),
]); ]);
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,
};
});
}
particleWorker.addEventListener('message', onMessage);
return () => {
particleWorker.removeEventListener('message', onMessage);
};
}, [ecs, particleWorker]);
const pulse = (Math.cos(radians / 4) + 1) * 0.5; const pulse = (Math.cos(radians / 4) + 1) * 0.5;
interactionFilters[0].brightness = (pulse * 0.75) + 1; interactionFilters[0].brightness = (pulse * 0.75) + 1;
interactionFilters[1].outerStrength = pulse * 0.5; interactionFilters[1].outerStrength = pulse * 0.5;
@ -43,10 +74,9 @@ export default function Entities({filters, monopolizers}) {
} }
updating[id] = ecs.get(id); updating[id] = ecs.get(id);
if (update.Emitter?.emit) { if (update.Emitter?.emit) {
updating[id].Emitter.emitting = { for (const id in update.Emitter.emit) {
...updating[id].Emitter.emitting, particleWorker?.postMessage(update.Emitter.emit[id]);
...update.Emitter.emit, }
};
} }
} }
setEntities((entities) => { setEntities((entities) => {
@ -58,7 +88,7 @@ export default function Entities({filters, monopolizers}) {
...updating, ...updating,
}; };
}); });
}, [ecs]); }, [ecs, particleWorker]);
useEcsTick(() => { useEcsTick(() => {
if (!ecs) { if (!ecs) {
return; return;

View File

@ -4,9 +4,8 @@ import {memo, useCallback} from 'react';
import {useDebug} from '@/react/context/debug.js'; import {useDebug} from '@/react/context/debug.js';
import {useMainEntity} from '@/react/context/main-entity.js'; import {useMainEntity} from '@/react/context/main-entity.js';
import Emitter from './emitter.jsx';
import Light from './light.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}) { function Aabb({color, width = 0.5, x0, y0, x1, y1, ...rest}) {
const draw = useCallback((g) => { 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) => { const draw = useCallback((g) => {
g.clear(); g.clear();
g.lineStyle(1, 0x000000); g.lineStyle(1, 0x000000);
@ -42,7 +41,7 @@ function Crosshair({x, y}) {
g.drawCircle(0, 0, 3); g.drawCircle(0, 0, 3);
}, []); }, []);
return ( return (
<Graphics draw={draw} x={x} y={y} /> <Graphics draw={draw} />
); );
} }
@ -52,31 +51,39 @@ function Entity({entity, ...rest}) {
if (!entity) { if (!entity) {
return false; return false;
} }
const {Direction, id, Sprite} = entity;
return ( return (
<Container <>
zIndex={entity.Position?.y || 0} <Container
> x={entity.Position.x}
{entity.Sprite && ( y={entity.Position.y}
<Sprite zIndex={entity.Position?.y || 0}
entity={entity} >
{...rest} {entity.Sprite && (
/> <SpriteComponent
)} alpha={Sprite.alpha}
{entity.Emitter && ( anchor={Sprite.anchor}
<Emitter animation={Sprite.animation}
entity={entity} direction={Direction?.direction}
/> frame={Sprite.frame}
)} id={id}
{entity.Light && ( scale={Sprite.scale}
<Light rotates={Sprite.rotates}
brightness={entity.Light.brightness} rotation={Sprite.rotation}
x={entity.Position.x} source={Sprite.source}
y={entity.Position.y} tint={Sprite.tint}
/> {...rest}
)} />
{debug && entity.Position && ( )}
<Crosshair x={entity.Position.x} y={entity.Position.y} /> {entity.Light && (
)} <Light
brightness={entity.Light.brightness}
/>
)}
{debug && entity.Position && (
<Crosshair />
)}
</Container>
{debug && ( {debug && (
<Aabb <Aabb
color={0xff00ff} color={0xff00ff}
@ -106,7 +113,7 @@ function Entity({entity, ...rest}) {
{...entity.Interacts.aabb()} {...entity.Interacts.aabb()}
/> />
)} )}
</Container> </>
); );
} }

View File

@ -3,13 +3,11 @@ import {PixiComponent} from '@pixi/react';
import {PointLight} from './lights.js'; import {PointLight} from './lights.js';
const LightInternal = PixiComponent('Light', { const LightInternal = PixiComponent('Light', {
create({brightness, x, y}) { create({brightness}) {
const light = new PointLight( const light = new PointLight(
0xffffff - 0x2244cc, 0xffffff - 0x2244cc,
0//brightness, brightness,
); );
light.position.set(x, y);
// light.shader.program.fragmentSrc = light.shader.program.fragmentSrc.replace( // light.shader.program.fragmentSrc = light.shader.program.fragmentSrc.replace(
// 'float D = length(lightVector)', // 'float D = length(lightVector)',
// 'float D = length(lightVector) / 1.0', // 'float D = length(lightVector) / 1.0',
@ -24,17 +22,12 @@ const LightInternal = PixiComponent('Light', {
// delete light.parentGroup; // delete light.parentGroup;
return light; 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 ( return (
<LightInternal <LightInternal
brightness={brightness} brightness={brightness}
x={x}
y={y}
/> />
) )
} }

View File

@ -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 ( return (
<Stage <Stage
className={styles.stage} className={styles.stage}
@ -71,6 +71,7 @@ export default function Pixi({applyFilters, camera, monopolizers, scale}) {
applyFilters={applyFilters} applyFilters={applyFilters}
camera={camera} camera={camera}
monopolizers={monopolizers} monopolizers={monopolizers}
particleWorker={particleWorker}
scale={scale} scale={scale}
/> />
</Stage> </Stage>

View File

@ -1,5 +1,5 @@
import {Sprite as PixiSprite} from '@pixi/react'; 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'; import {useAsset} from '@/react/context/assets.js';
@ -22,11 +22,23 @@ function textureFromAsset(asset, animation, frame) {
return texture; 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 [mounted, setMounted] = useState();
const [normals, setNormals] = useState(); const [normals, setNormals] = useState();
const [normalsMounted, setNormalsMounted] = useState(); const [normalsMounted, setNormalsMounted] = useState();
const {alpha, anchor, animation, frame, scale, rotates, rotation, source} = entity.Sprite;
const asset = useAsset(source); const asset = useAsset(source);
const normalsAsset = useAsset(normals); const normalsAsset = useAsset(normals);
useEffect(() => { useEffect(() => {
@ -65,11 +77,10 @@ export default function Sprite({entity, ...rest}) {
alpha={alpha} alpha={alpha}
anchor={anchor} anchor={anchor}
ref={setMounted} ref={setMounted}
{...(rotates ? {rotation: entity.Direction.direction + rotation} : {})} {...(rotates ? {rotation: direction + rotation} : {})}
scale={scale} scale={scale}
texture={texture} texture={texture}
x={Math.round(entity.Position.x)} {...(0 !== tint ? {tint} : {})}
y={Math.round(entity.Position.y)}
{...rest} {...rest}
/> />
)} )}
@ -78,14 +89,14 @@ export default function Sprite({entity, ...rest}) {
alpha={alpha} alpha={alpha}
anchor={anchor} anchor={anchor}
ref={setNormalsMounted} ref={setNormalsMounted}
{...(rotates ? {rotation: entity.Direction.direction + rotation} : {})} {...(rotates ? {rotation: direction + rotation} : {})}
scale={scale} scale={scale}
texture={normalsTexture} texture={normalsTexture}
x={Math.round(entity.Position.x)}
y={Math.round(entity.Position.y)}
{...rest} {...rest}
/> />
)} )}
</> </>
); );
} }
export default memo(Sprite);

View File

@ -10,12 +10,13 @@ const TargetingGhostInternal = PixiComponent('TargetingGhost', {
create: () => { create: () => {
// Solid target square. // Solid target square.
const target = new Graphics(); const target = new Graphics();
target.alpha = 0.7;
target.lineStyle(1, 0xffffff); target.lineStyle(1, 0xffffff);
target.drawRect(0.5, 0.5, tileSize.x, tileSize.y); target.drawRect(0.5, 0.5, tileSize.x, tileSize.y);
target.pivot = {x: tileSize.x / 2, y: tileSize.y / 2}; target.pivot = {x: tileSize.x / 2, y: tileSize.y / 2};
// Inner spinny part. // Inner spinny part.
const targetInner = new Graphics(); const targetInner = new Graphics();
targetInner.alpha = 0.6; targetInner.alpha = 0.3;
targetInner.lineStyle(3, 0x333333); targetInner.lineStyle(3, 0x333333);
targetInner.beginFill(0xdddddd); targetInner.beginFill(0xdddddd);
targetInner.pivot = {x: tileSize.x / 2, y: tileSize.y / 2}; targetInner.pivot = {x: tileSize.x / 2, y: tileSize.y / 2};

View File

@ -62,6 +62,10 @@ function Ui({disconnected}) {
const [isInventoryOpen, setIsInventoryOpen] = useState(false); const [isInventoryOpen, setIsInventoryOpen] = useState(false);
const [externalInventory, setExternalInventory] = useState(); const [externalInventory, setExternalInventory] = useState();
const [externalInventorySlots, setExternalInventorySlots] = useState(); const [externalInventorySlots, setExternalInventorySlots] = useState();
const [particleWorker, setParticleWorker] = useState();
const refreshEcs = useCallback(() => {
setEcs(new ClientEcs({Components, Systems}));
}, [Components, Systems, setEcs]);
useEffect(() => { useEffect(() => {
async function setEcsStuff() { async function setEcsStuff() {
const {default: Components} = await import('@/ecs/components/index.js'); const {default: Components} = await import('@/ecs/components/index.js');
@ -72,8 +76,8 @@ function Ui({disconnected}) {
setEcsStuff(); setEcsStuff();
}, []); }, []);
useEffect(() => { useEffect(() => {
setEcs(new ClientEcs({Components, Systems})); refreshEcs();
}, [Components, setEcs, Systems]); }, [refreshEcs]);
useEffect(() => { useEffect(() => {
let handle; let handle;
if (disconnected) { if (disconnected) {
@ -305,10 +309,10 @@ function Ui({disconnected}) {
setScale, setScale,
]); ]);
usePacket('EcsChange', async () => { usePacket('EcsChange', async () => {
setEcs(new ClientEcs({Components, Systems})); refreshEcs();
setMainEntity(undefined); setMainEntity(undefined);
setMonopolizers([]); setMonopolizers([]);
}, [Components, Systems, setEcs, setMainEntity]); }, [refreshEcs, setMainEntity, setMonopolizers]);
usePacket('Tick', async (payload, client) => { usePacket('Tick', async (payload, client) => {
if (0 === Object.keys(payload.ecs).length) { if (0 === Object.keys(payload.ecs).length) {
return; return;
@ -316,6 +320,22 @@ function Ui({disconnected}) {
await ecs.apply(payload.ecs); await ecs.apply(payload.ecs);
client.emitter.invoke(':Ecs', payload.ecs); client.emitter.invoke(':Ecs', payload.ecs);
}, [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) => { useEcsTick((payload) => {
let localMainEntity = mainEntity; let localMainEntity = mainEntity;
for (const id in payload) { for (const id in payload) {
@ -518,6 +538,7 @@ function Ui({disconnected}) {
applyFilters={applyFilters} applyFilters={applyFilters}
camera={camera} camera={camera}
monopolizers={monopolizers} monopolizers={monopolizers}
particleWorker={particleWorker}
scale={scale} scale={scale}
/> />
<Dom> <Dom>
@ -583,13 +604,15 @@ function Ui({disconnected}) {
)} )}
</Dom> </Dom>
</div> </div>
<div className={[styles.devtools, devtoolsIsOpen && styles.devtoolsIsOpen].filter(Boolean).join(' ')}> {devtoolsIsOpen && (
<Devtools <div className={[styles.devtools, devtoolsIsOpen && styles.devtoolsIsOpen].filter(Boolean).join(' ')}>
applyFilters={applyFilters} <Devtools
eventsChannel={devEventsChannel} applyFilters={applyFilters}
setApplyFilters={setApplyFilters} eventsChannel={devEventsChannel}
/> setApplyFilters={setApplyFilters}
</div> />
</div>
)}
</div> </div>
); );
} }

View File

@ -1,3 +1,4 @@
import {Texture} from '@pixi/core';
import {Assets} from '@pixi/assets'; import {Assets} from '@pixi/assets';
import {createContext, useContext, useEffect} from 'react'; import {createContext, useContext, useEffect} from 'react';
@ -11,7 +12,7 @@ export function useAsset(source) {
const [assets, setAssets] = useContext(context); const [assets, setAssets] = useContext(context);
useEffect(() => { useEffect(() => {
if (!source) { if (!source) {
return undefined; return;
} }
if (!assets[source]) { if (!assets[source]) {
if (!loading[source]) { if (!loading[source]) {
@ -24,5 +25,5 @@ export function useAsset(source) {
} }
} }
}, [assets, setAssets, source]); }, [assets, setAssets, source]);
return source ? assets[source] : undefined; return source ? assets[source] : {data: {meta: {}}, textures: {'': Texture.WHITE}};
} }

View File

@ -23,6 +23,7 @@ export default function createEcs(Ecs) {
'Water', 'Water',
'Interactions', 'Interactions',
'InventoryCloser', 'InventoryCloser',
'KillPerishable',
]; ];
defaultSystems.forEach((defaultSystem) => { defaultSystems.forEach((defaultSystem) => {
const System = ecs.system(defaultSystem); const System = ecs.system(defaultSystem);

View File

@ -33,7 +33,7 @@ export default async function createPlayer(id) {
}, },
}, },
Health: {health: 100}, Health: {health: 100},
Light: {brightness: 4}, Light: {brightness: 0},
Magnet: {strength: 24}, Magnet: {strength: 24},
Player: {}, Player: {},
Position: {x: 128, y: 448}, Position: {x: 128, y: 448},

6
package-lock.json generated
View File

@ -26,6 +26,7 @@
"express": "^4.18.2", "express": "^4.18.2",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"isbot": "^4.1.0", "isbot": "^4.1.0",
"kefir": "^3.8.8",
"lru-cache": "^10.2.2", "lru-cache": "^10.2.2",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"pixi.js": "^7.4.2", "pixi.js": "^7.4.2",
@ -12697,6 +12698,11 @@
"node": ">=4.0" "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": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",

View File

@ -33,6 +33,7 @@
"express": "^4.18.2", "express": "^4.18.2",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"isbot": "^4.1.0", "isbot": "^4.1.0",
"kefir": "^3.8.8",
"lru-cache": "^10.2.2", "lru-cache": "^10.2.2",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"pixi.js": "^7.4.2", "pixi.js": "^7.4.2",

View File

@ -9,104 +9,30 @@ if (projected?.length > 0) {
Controlled.locked = 1 Controlled.locked = 1
const [, direction] = Sprite.animation.split(':') 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) { for (let i = 0; i < 2; ++i) {
Sound.play('/assets/hoe/dig.wav'); Sound.play('/assets/hoe/dig.wav');
for (const {x, y} of projected) { for (const {x, y} of projected) {
Emitter.emit({ Emitter.emit({
...dirtParticles, count: 25,
behaviors: [ frequency: 0.01,
...dirtParticles.behaviors, shape: {
{ type: 'filledRect',
type: 'spawnShape', payload: {width: 16, height: 16},
config: { },
type: 'rect', entity: {
data: { Forces: {forceY: -50},
x: x * layer.tileSize.x, Position: {
y: y * layer.tileSize.y, x: x * layer.tileSize.x + (layer.tileSize.x / 2),
w: layer.tileSize.x, y: y * layer.tileSize.y + (layer.tileSize.y / 2),
h: layer.tileSize.y, },
} Sprite: {
} scaleX: 0.2,
} scaleY: 0.2,
] tint: 0x552200,
}) },
Ttl: {ttl: 0.125},
}
});
} }
Sprite.animation = ['moving', direction].join(':'); Sprite.animation = ['moving', direction].join(':');
await wait(0.3) await wait(0.3)

View File

@ -38,84 +38,27 @@ if (projected?.length > 0) {
VisibleAabb: {}, VisibleAabb: {},
}; };
const seedParticles = { Emitter.emit({
behaviors: [ count: 25,
{
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,
},
frequency: 0.01, frequency: 0.01,
emitterLifetime: 0.75, shape: {
pos: { type: 'filledRect',
x: Position.x, payload: {width: 16, height: 16},
y: Position.y,
}, },
}; entity: {
Forces: {forceY: -100},
Emitter.emit(seedParticles) 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'); Sound.play('/assets/sow.wav');
Sprite.animation = ['moving', direction].join(':'); Sprite.animation = ['moving', direction].join(':');

View File

@ -10,98 +10,32 @@ if (projected?.length > 0) {
const [, direction] = Sprite.animation.split(':') const [, direction] = Sprite.animation.split(':')
Sprite.animation = ['idle', direction].join(':'); 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'); Sound.play('/assets/watering-can/water.wav');
for (let i = 0; i < 2; ++i) {
for (const {x, y} of projected) { for (const {x, y} of projected) {
Emitter.emit({ Emitter.emit({
...waterParticles, count: 25,
behaviors: [ frequency: 0.01,
...waterParticles.behaviors, shape: {
{ type: 'filledRect',
type: 'spawnShape', payload: {width: 16, height: 16},
config: { },
type: 'rect', entity: {
data: { Forces: {forceY: 100},
x: x * layer.tileSize.x, Position: {
y: y * layer.tileSize.y - (layer.tileSize.y * 0.5), x: x * layer.tileSize.x + (layer.tileSize.x / 2),
w: layer.tileSize.x, y: y * layer.tileSize.y - layer.tileSize.y,
h: layer.tileSize.y, },
} Sprite: {
} scaleX: 0.2,
} scaleY: 0.2,
] tint: 0x0022aa,
}) },
} Ttl: {ttl: 0.25},
await wait(0.25); }
});
} }
await wait(0.5);
for (const {x, y} of projected) { for (const {x, y} of projected) {
const tileIndex = layer.area.x * y + x const tileIndex = layer.area.x * y + x