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

View File

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

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() {
const {AreaSize} = this.ecs.get(1);
if (!AreaSize) {
return;
}
for (const {Position} of this.ecs.changed(['Position'])) {
if (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) {
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);

View File

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

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

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;
}
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}) {
<Entities
filters={filters}
monopolizers={monopolizers}
particleWorker={particleWorker}
/>
{projected?.length > 0 && layers[0] && (
<TargetingGhost

View File

@ -1,7 +1,7 @@
import {AdjustmentFilter} from '@pixi/filter-adjustment';
import {GlowFilter} from '@pixi/filter-glow';
import {Container} from '@pixi/react';
import {useState} from 'react';
import {useEffect, useState} from 'react';
import {usePacket} from '@/react/context/client.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';
export default function Entities({filters, monopolizers}) {
export default function Entities({filters, monopolizers, particleWorker}) {
const [ecs] = useEcs();
const [entities, setEntities] = useState({});
const [mainEntity] = useMainEntity();
@ -20,6 +20,37 @@ export default function Entities({filters, monopolizers}) {
new AdjustmentFilter(),
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;
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;

View File

@ -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 (
<Graphics draw={draw} x={x} y={y} />
<Graphics draw={draw} />
);
}
@ -52,31 +51,39 @@ function Entity({entity, ...rest}) {
if (!entity) {
return false;
}
const {Direction, id, Sprite} = entity;
return (
<Container
zIndex={entity.Position?.y || 0}
>
{entity.Sprite && (
<Sprite
entity={entity}
{...rest}
/>
)}
{entity.Emitter && (
<Emitter
entity={entity}
/>
)}
{entity.Light && (
<Light
brightness={entity.Light.brightness}
x={entity.Position.x}
y={entity.Position.y}
/>
)}
{debug && entity.Position && (
<Crosshair x={entity.Position.x} y={entity.Position.y} />
)}
<>
<Container
x={entity.Position.x}
y={entity.Position.y}
zIndex={entity.Position?.y || 0}
>
{entity.Sprite && (
<SpriteComponent
alpha={Sprite.alpha}
anchor={Sprite.anchor}
animation={Sprite.animation}
direction={Direction?.direction}
frame={Sprite.frame}
id={id}
scale={Sprite.scale}
rotates={Sprite.rotates}
rotation={Sprite.rotation}
source={Sprite.source}
tint={Sprite.tint}
{...rest}
/>
)}
{entity.Light && (
<Light
brightness={entity.Light.brightness}
/>
)}
{debug && entity.Position && (
<Crosshair />
)}
</Container>
{debug && (
<Aabb
color={0xff00ff}
@ -106,7 +113,7 @@ function Entity({entity, ...rest}) {
{...entity.Interacts.aabb()}
/>
)}
</Container>
</>
);
}

View File

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

View File

@ -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}
/>
)}
</>
);
}
}
export default memo(Sprite);

View File

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

View File

@ -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}
/>
<Dom>
@ -583,13 +604,15 @@ function Ui({disconnected}) {
)}
</Dom>
</div>
<div className={[styles.devtools, devtoolsIsOpen && styles.devtoolsIsOpen].filter(Boolean).join(' ')}>
<Devtools
applyFilters={applyFilters}
eventsChannel={devEventsChannel}
setApplyFilters={setApplyFilters}
/>
</div>
{devtoolsIsOpen && (
<div className={[styles.devtools, devtoolsIsOpen && styles.devtoolsIsOpen].filter(Boolean).join(' ')}>
<Devtools
applyFilters={applyFilters}
eventsChannel={devEventsChannel}
setApplyFilters={setApplyFilters}
/>
</div>
)}
</div>
);
}

View File

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

View File

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

View File

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

6
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

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

View File

@ -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(':');

View File

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