@aethervtt/shard-effects 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +2 -2
- package/dist/document.d.ts +57 -0
- package/dist/document.js +122 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +30 -0
- package/dist/methods.d.ts +2 -0
- package/dist/methods.js +85 -0
- package/dist/plugin.d.ts +1 -0
- package/dist/plugin.js +21 -0
- package/dist/runtime.d.ts +150 -0
- package/dist/runtime.js +576 -0
- package/package.json +39 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Patrick Dias
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @aethervtt/shard-effects
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Part of [Shard](https://github.com/aethervtt/shard), a WebGPU-first 2D/3D game engine in TypeScript. See the repository for documentation.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type Infer } from '@aethervtt/shard-core';
|
|
2
|
+
export declare function effectError(message: string, path?: string, code?: string): never;
|
|
3
|
+
export declare const EffectTrack: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").InferFields<{
|
|
4
|
+
id: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
5
|
+
kind: import("@aethervtt/shard-core").FieldType<"animation" | "audio" | "marker" | "particles" | "property" | "visual", "u8">;
|
|
6
|
+
target: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
7
|
+
targetPath: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
8
|
+
endpoint: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
9
|
+
start: import("@aethervtt/shard-core").FieldType<number, "f64">;
|
|
10
|
+
duration: import("@aethervtt/shard-core").FieldType<number, "f64">;
|
|
11
|
+
component: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
12
|
+
field: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
13
|
+
from: import("@aethervtt/shard-core").FieldType<number[], "object">;
|
|
14
|
+
to: import("@aethervtt/shard-core").FieldType<number[], "object">;
|
|
15
|
+
easing: import("@aethervtt/shard-core").FieldType<"linear" | "smoothstep", "u8">;
|
|
16
|
+
parameter: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
17
|
+
animation: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").AssetRef<"AnimationClip"> | null, "object">;
|
|
18
|
+
particles: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").AssetRef<"ParticleEffect"> | null, "object">;
|
|
19
|
+
audio: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").AssetRef<"audio/AudioEvent"> | null, "object">;
|
|
20
|
+
marker: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
21
|
+
}>, "object">;
|
|
22
|
+
export declare const EffectSequence: import("@aethervtt/shard-assets").DataType<{
|
|
23
|
+
readonly duration: import("@aethervtt/shard-core").FieldType<number, "f64">;
|
|
24
|
+
readonly parameters: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").InferFields<{
|
|
25
|
+
id: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
26
|
+
value: import("@aethervtt/shard-core").FieldType<number, "f64">;
|
|
27
|
+
min: import("@aethervtt/shard-core").FieldType<number, "f64">;
|
|
28
|
+
max: import("@aethervtt/shard-core").FieldType<number, "f64">;
|
|
29
|
+
}>[], "object">;
|
|
30
|
+
readonly targets: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").InferFields<{
|
|
31
|
+
id: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
32
|
+
prefab: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").AssetRef<"Prefab"> | null, "object">;
|
|
33
|
+
}>[], "object">;
|
|
34
|
+
readonly tracks: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").InferFields<{
|
|
35
|
+
id: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
36
|
+
kind: import("@aethervtt/shard-core").FieldType<"animation" | "audio" | "marker" | "particles" | "property" | "visual", "u8">;
|
|
37
|
+
target: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
38
|
+
targetPath: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
39
|
+
endpoint: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
40
|
+
start: import("@aethervtt/shard-core").FieldType<number, "f64">;
|
|
41
|
+
duration: import("@aethervtt/shard-core").FieldType<number, "f64">;
|
|
42
|
+
component: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
43
|
+
field: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
44
|
+
from: import("@aethervtt/shard-core").FieldType<number[], "object">;
|
|
45
|
+
to: import("@aethervtt/shard-core").FieldType<number[], "object">;
|
|
46
|
+
easing: import("@aethervtt/shard-core").FieldType<"linear" | "smoothstep", "u8">;
|
|
47
|
+
parameter: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
48
|
+
animation: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").AssetRef<"AnimationClip"> | null, "object">;
|
|
49
|
+
particles: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").AssetRef<"ParticleEffect"> | null, "object">;
|
|
50
|
+
audio: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").AssetRef<"audio/AudioEvent"> | null, "object">;
|
|
51
|
+
marker: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
52
|
+
}>[], "object">;
|
|
53
|
+
}, "effects/EffectSequence">;
|
|
54
|
+
export type EffectDocument = Infer<typeof EffectSequence>;
|
|
55
|
+
export type EffectTrackValue = EffectDocument['tracks'][number];
|
|
56
|
+
export declare function validateEffect(v: EffectDocument): void;
|
|
57
|
+
export declare function parseEffect(value: unknown): EffectDocument;
|
package/dist/document.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { defineDataType } from "@aethervtt/shard-assets";
|
|
2
|
+
import { ShardError, t } from "@aethervtt/shard-core";
|
|
3
|
+
function effectError(message, path = "", code = "invalid-sequence") {
|
|
4
|
+
throw new ShardError(`effects/${code}`, message, { path });
|
|
5
|
+
}
|
|
6
|
+
const EffectTrack = t.struct({
|
|
7
|
+
id: t.string({ required: true }),
|
|
8
|
+
kind: t.enum(["property", "animation", "particles", "audio", "marker", "visual"]),
|
|
9
|
+
target: t.string(),
|
|
10
|
+
targetPath: t.string(),
|
|
11
|
+
endpoint: t.string(),
|
|
12
|
+
start: t.f64({ min: 0 }),
|
|
13
|
+
duration: t.f64({ min: 0 }),
|
|
14
|
+
component: t.string(),
|
|
15
|
+
field: t.string(),
|
|
16
|
+
from: t.list(t.f64()),
|
|
17
|
+
to: t.list(t.f64()),
|
|
18
|
+
easing: t.enum(["linear", "smoothstep"]),
|
|
19
|
+
parameter: t.string(),
|
|
20
|
+
animation: t.handle("AnimationClip"),
|
|
21
|
+
particles: t.handle("ParticleEffect"),
|
|
22
|
+
audio: t.handle("audio/AudioEvent"),
|
|
23
|
+
marker: t.string()
|
|
24
|
+
});
|
|
25
|
+
const EffectSequence = defineDataType(
|
|
26
|
+
"effects/EffectSequence",
|
|
27
|
+
{
|
|
28
|
+
duration: t.f64({ required: true, min: 1e-6, max: 3600 }),
|
|
29
|
+
parameters: t.list(
|
|
30
|
+
t.struct({
|
|
31
|
+
id: t.string({ required: true }),
|
|
32
|
+
value: t.f64({ default: 1 }),
|
|
33
|
+
min: t.f64({ default: -1e6 }),
|
|
34
|
+
max: t.f64({ default: 1e6 })
|
|
35
|
+
})
|
|
36
|
+
),
|
|
37
|
+
targets: t.list(t.struct({ id: t.string({ required: true }), prefab: t.handle("Prefab") })),
|
|
38
|
+
tracks: t.list(EffectTrack)
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
extension: "effect-sequence",
|
|
42
|
+
description: "Coordinated presentation with host-bound targets and owned prefab lifetimes.",
|
|
43
|
+
lazyDependencies: true,
|
|
44
|
+
validate: (v) => validateEffect(v)
|
|
45
|
+
}
|
|
46
|
+
);
|
|
47
|
+
function names(items, path, max) {
|
|
48
|
+
if (items.length > max) effectError(`At most ${max} entries`, path);
|
|
49
|
+
const ids = /* @__PURE__ */ new Set();
|
|
50
|
+
items.forEach((item, i) => {
|
|
51
|
+
if (!/^[A-Za-z][\w-]*$/.test(item.id) || ids.has(item.id))
|
|
52
|
+
effectError("IDs must be unique identifiers beginning with a letter", `${path}/${i}/id`);
|
|
53
|
+
ids.add(item.id);
|
|
54
|
+
});
|
|
55
|
+
return ids;
|
|
56
|
+
}
|
|
57
|
+
function validateEffect(v) {
|
|
58
|
+
const targets = names(v.targets, "/targets", 128);
|
|
59
|
+
const parameters = names(v.parameters, "/parameters", 64);
|
|
60
|
+
names(v.tracks, "/tracks", 512);
|
|
61
|
+
for (let i = 0; i < v.parameters.length; i++) {
|
|
62
|
+
const p = v.parameters[i];
|
|
63
|
+
if (p.min > p.max || p.value < p.min || p.value > p.max)
|
|
64
|
+
effectError("Parameter default must be within min/max", `/parameters/${i}`);
|
|
65
|
+
}
|
|
66
|
+
for (let i = 0; i < v.tracks.length; i++) {
|
|
67
|
+
const a = v.tracks[i], path = `/tracks/${i}`;
|
|
68
|
+
if (a.start + a.duration > v.duration + 1e-9 || (a.kind === "marker" ? a.duration !== 0 : a.duration <= 0))
|
|
69
|
+
effectError("Track must fit inside the sequence; only markers have zero duration", path);
|
|
70
|
+
if (a.kind === "marker") {
|
|
71
|
+
if (!a.marker) effectError("Marker needs a name", `${path}/marker`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (!targets.has(a.target)) effectError("Unknown target", `${path}/target`);
|
|
75
|
+
if (a.targetPath.split("/").some((p) => p === ".." || p === "."))
|
|
76
|
+
effectError("Use a relative child path", `${path}/targetPath`);
|
|
77
|
+
if (a.kind === "property") {
|
|
78
|
+
if (!a.component || !a.field || !a.from.length || a.from.length > 4 || a.from.length !== a.to.length)
|
|
79
|
+
effectError(
|
|
80
|
+
"Property needs component/field and matching numeric endpoints (1\u20134 values)",
|
|
81
|
+
path
|
|
82
|
+
);
|
|
83
|
+
if (a.parameter && !parameters.has(a.parameter))
|
|
84
|
+
effectError("Unknown parameter", `${path}/parameter`);
|
|
85
|
+
} else if (a.kind === "visual") {
|
|
86
|
+
if (!["render/Trail", "render/Beam", "render/Decal"].includes(a.component))
|
|
87
|
+
effectError("Visual track needs a supported presentation component", `${path}/component`);
|
|
88
|
+
if (a.endpoint && (a.component !== "render/Beam" || !targets.has(a.endpoint)))
|
|
89
|
+
effectError("Only beams bind a named endpoint target", `${path}/endpoint`);
|
|
90
|
+
} else if (!a[a.kind]) effectError(`Missing ${a.kind} asset`, `${path}/${a.kind}`);
|
|
91
|
+
if ((a.kind === "animation" || a.kind === "particles" || a.kind === "visual") && !v.targets.find((t2) => t2.id === a.target).prefab)
|
|
92
|
+
effectError(
|
|
93
|
+
"Animation, particles and visuals require an owned prefab target",
|
|
94
|
+
`${path}/target`
|
|
95
|
+
);
|
|
96
|
+
for (let j = 0; j < i; j++) {
|
|
97
|
+
const b = v.tracks[j];
|
|
98
|
+
if (a.target !== b.target || a.targetPath !== b.targetPath || a.start >= b.start + b.duration - 1e-9 || b.start >= a.start + a.duration - 1e-9)
|
|
99
|
+
continue;
|
|
100
|
+
if (a.kind === b.kind && (a.kind === "animation" || a.kind === "particles" || a.kind === "visual" && a.component === b.component) || a.kind === "property" && b.kind === "property" && a.component === b.component && a.field === b.field)
|
|
101
|
+
effectError(`Overlaps track ${b.id}`, path);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function parseEffect(value) {
|
|
106
|
+
const errors = EffectSequence.validate(value);
|
|
107
|
+
if (errors.length)
|
|
108
|
+
throw new ShardError("effects/invalid-sequence", errors[0].message, {
|
|
109
|
+
path: errors[0].path,
|
|
110
|
+
details: errors
|
|
111
|
+
});
|
|
112
|
+
const document = EffectSequence.deserialize(value);
|
|
113
|
+
validateEffect(document);
|
|
114
|
+
return document;
|
|
115
|
+
}
|
|
116
|
+
export {
|
|
117
|
+
EffectSequence,
|
|
118
|
+
EffectTrack,
|
|
119
|
+
effectError,
|
|
120
|
+
parseEffect,
|
|
121
|
+
validateEffect
|
|
122
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { type EffectDocument, EffectSequence, EffectTrack, type EffectTrackValue, parseEffect, validateEffect, } from './document.js';
|
|
2
|
+
export { effectMethods } from './methods.js';
|
|
3
|
+
export { effectsPlugin } from './plugin.js';
|
|
4
|
+
export { cancelEffect, describeEffects, type EffectInstance, type PlayEffectOptions, type PreparedEffect, pauseEffect, playEffect, prepareEffect, updateEffects, } from './runtime.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EffectSequence,
|
|
3
|
+
EffectTrack,
|
|
4
|
+
parseEffect,
|
|
5
|
+
validateEffect
|
|
6
|
+
} from "./document.js";
|
|
7
|
+
import { effectMethods } from "./methods.js";
|
|
8
|
+
import { effectsPlugin } from "./plugin.js";
|
|
9
|
+
import {
|
|
10
|
+
cancelEffect,
|
|
11
|
+
describeEffects,
|
|
12
|
+
pauseEffect,
|
|
13
|
+
playEffect,
|
|
14
|
+
prepareEffect,
|
|
15
|
+
updateEffects
|
|
16
|
+
} from "./runtime.js";
|
|
17
|
+
export {
|
|
18
|
+
EffectSequence,
|
|
19
|
+
EffectTrack,
|
|
20
|
+
cancelEffect,
|
|
21
|
+
describeEffects,
|
|
22
|
+
effectMethods,
|
|
23
|
+
effectsPlugin,
|
|
24
|
+
parseEffect,
|
|
25
|
+
pauseEffect,
|
|
26
|
+
playEffect,
|
|
27
|
+
prepareEffect,
|
|
28
|
+
updateEffects,
|
|
29
|
+
validateEffect
|
|
30
|
+
};
|
package/dist/methods.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { defineSchema, isPlainObject, t } from "@aethervtt/shard-core";
|
|
2
|
+
import { effectError } from "./document.js";
|
|
3
|
+
import {
|
|
4
|
+
cancelEffect,
|
|
5
|
+
describeEffects,
|
|
6
|
+
Effects,
|
|
7
|
+
pauseEffect,
|
|
8
|
+
playEffect,
|
|
9
|
+
prepareEffect
|
|
10
|
+
} from "./runtime.js";
|
|
11
|
+
const effectMethods = [
|
|
12
|
+
{
|
|
13
|
+
name: "effects.prepare",
|
|
14
|
+
description: "Prepare a catalog asset or host-supplied sequence document. Returns a releasable preparation ID.",
|
|
15
|
+
params: defineSchema("effects/PrepareParams", { source: t.json({ required: true }) }),
|
|
16
|
+
handler: async ({ world }, p) => ({ preparation: (await prepareEffect(world, p.source)).id })
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: "effects.release",
|
|
20
|
+
description: "Release a preparation; active instances retain their assets.",
|
|
21
|
+
params: defineSchema("effects/ReleaseParams", { preparation: t.u32({ required: true }) }),
|
|
22
|
+
handler: ({ world }, p) => {
|
|
23
|
+
const plan = world.resource(Effects).plans.get(p.preparation);
|
|
24
|
+
const released = !!plan && !plan.released;
|
|
25
|
+
plan?.release();
|
|
26
|
+
return { released };
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "effects.play",
|
|
31
|
+
description: "Play a prepared sequence, binding borrowed targets to live entity IDs.",
|
|
32
|
+
params: defineSchema("effects/PlayParams", {
|
|
33
|
+
preparation: t.u32({ required: true }),
|
|
34
|
+
bindings: t.json({ default: {} }),
|
|
35
|
+
parameters: t.json({ default: {} }),
|
|
36
|
+
seed: t.u32(),
|
|
37
|
+
ownerEntity: t.entity(),
|
|
38
|
+
parent: t.entity()
|
|
39
|
+
}),
|
|
40
|
+
handler: ({ world }, p) => {
|
|
41
|
+
const plan = world.resource(Effects).plans.get(p.preparation);
|
|
42
|
+
if (!plan) effectError("Unknown preparation", "/preparation", "invalid-preparation");
|
|
43
|
+
if (!isPlainObject(p.bindings) || !isPlainObject(p.parameters))
|
|
44
|
+
effectError("Bindings and parameters must be objects");
|
|
45
|
+
for (const value of Object.values(p.bindings))
|
|
46
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
|
|
47
|
+
effectError("Binding must be an entity ID", "/bindings");
|
|
48
|
+
return {
|
|
49
|
+
instance: playEffect(world, plan, {
|
|
50
|
+
bindings: p.bindings,
|
|
51
|
+
parameters: p.parameters,
|
|
52
|
+
seed: p.seed,
|
|
53
|
+
parent: typeof p.parent === "number" && p.parent >= 0 ? p.parent : void 0,
|
|
54
|
+
ownerEntity: typeof p.ownerEntity === "number" && p.ownerEntity >= 0 ? p.ownerEntity : void 0
|
|
55
|
+
}).id
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "effects.cancel",
|
|
61
|
+
description: "Cancel an active sequence and restore/release its presentation state.",
|
|
62
|
+
params: defineSchema("effects/CancelParams", { instance: t.u32({ required: true }) }),
|
|
63
|
+
handler: ({ world }, p) => ({ cancelled: cancelEffect(world, p.instance) })
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "effects.pause",
|
|
67
|
+
description: "Pause/resume this sequence, including its particles and authored audio.",
|
|
68
|
+
params: defineSchema("effects/PauseParams", {
|
|
69
|
+
instance: t.u32({ required: true }),
|
|
70
|
+
paused: t.bool({ default: true })
|
|
71
|
+
}),
|
|
72
|
+
handler: ({ world }, p) => ({
|
|
73
|
+
updated: pauseEffect(world, p.instance, p.paused)
|
|
74
|
+
})
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "effects.inspect",
|
|
78
|
+
description: "Active sequences, prepared leases, target bindings and bounded cursor-based execution trace.",
|
|
79
|
+
params: defineSchema("effects/InspectParams", { cursor: t.u32() }),
|
|
80
|
+
handler: ({ world }, p) => describeEffects(world, p.cursor)
|
|
81
|
+
}
|
|
82
|
+
];
|
|
83
|
+
export {
|
|
84
|
+
effectMethods
|
|
85
|
+
};
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const effectsPlugin: import("@aethervtt/shard-runtime").Plugin;
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Update } from "@aethervtt/shard-core";
|
|
2
|
+
import { definePlugin } from "@aethervtt/shard-runtime";
|
|
3
|
+
import * as document from "./document.js";
|
|
4
|
+
import * as methods from "./methods.js";
|
|
5
|
+
import * as runtime from "./runtime.js";
|
|
6
|
+
const effectsPlugin = definePlugin({
|
|
7
|
+
name: "effects",
|
|
8
|
+
provides: [document, methods, runtime],
|
|
9
|
+
build(app) {
|
|
10
|
+
const state = app.world.initResource(runtime.Effects);
|
|
11
|
+
for (const plugin of app.describe().plugins) state.plugins.add(plugin.name);
|
|
12
|
+
app.addSystems(Update, runtime.updateEffects);
|
|
13
|
+
app.addMethod(...methods.effectMethods);
|
|
14
|
+
},
|
|
15
|
+
dispose(app) {
|
|
16
|
+
runtime.disposeEffects(app.world);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
export {
|
|
20
|
+
effectsPlugin
|
|
21
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { type AnimationLayerValue } from '@aethervtt/shard-animation';
|
|
2
|
+
import { type AudioLease } from '@aethervtt/shard-audio';
|
|
3
|
+
import { type ComponentDef, type ComponentStorage, type Entity, type Owner, type Table, type TypedArray, type World } from '@aethervtt/shard-core';
|
|
4
|
+
import { type EffectDocument, type EffectTrackValue } from './document.js';
|
|
5
|
+
export interface PreparedEffect {
|
|
6
|
+
readonly id: number;
|
|
7
|
+
readonly document: EffectDocument;
|
|
8
|
+
readonly released: boolean;
|
|
9
|
+
release(): void;
|
|
10
|
+
}
|
|
11
|
+
interface Plan extends PreparedEffect {
|
|
12
|
+
world: World;
|
|
13
|
+
refs: number;
|
|
14
|
+
owner: Owner;
|
|
15
|
+
audio: AudioLease[];
|
|
16
|
+
}
|
|
17
|
+
interface FieldSlot {
|
|
18
|
+
entity: Entity;
|
|
19
|
+
def: ComponentDef;
|
|
20
|
+
field: string;
|
|
21
|
+
width: number;
|
|
22
|
+
saved: Float64Array;
|
|
23
|
+
table: Table | null;
|
|
24
|
+
capacity: number;
|
|
25
|
+
row: number;
|
|
26
|
+
storage: ComponentStorage | null;
|
|
27
|
+
column: TypedArray | null;
|
|
28
|
+
key: string;
|
|
29
|
+
}
|
|
30
|
+
interface Track {
|
|
31
|
+
definition: EffectTrackValue;
|
|
32
|
+
entity: Entity | null;
|
|
33
|
+
slot: FieldSlot | null;
|
|
34
|
+
from: Float64Array;
|
|
35
|
+
to: Float64Array;
|
|
36
|
+
state: number;
|
|
37
|
+
audio: number;
|
|
38
|
+
visual: ComponentDef | null;
|
|
39
|
+
layer: AnimationLayerValue | null;
|
|
40
|
+
seed: number;
|
|
41
|
+
}
|
|
42
|
+
interface Boundary {
|
|
43
|
+
time: number;
|
|
44
|
+
index: number;
|
|
45
|
+
end: boolean;
|
|
46
|
+
}
|
|
47
|
+
export interface EffectInstance {
|
|
48
|
+
readonly id: number;
|
|
49
|
+
readonly seed: number;
|
|
50
|
+
readonly targets: ReadonlyMap<string, Entity>;
|
|
51
|
+
readonly time: number;
|
|
52
|
+
readonly state: 'playing' | 'paused' | 'completed' | 'cancelled' | 'failed';
|
|
53
|
+
readonly error: string | null;
|
|
54
|
+
}
|
|
55
|
+
interface Instance extends EffectInstance {
|
|
56
|
+
time: number;
|
|
57
|
+
state: EffectInstance['state'];
|
|
58
|
+
error: string | null;
|
|
59
|
+
plan: Plan;
|
|
60
|
+
owner: Owner;
|
|
61
|
+
ownerEntity: Entity | undefined;
|
|
62
|
+
tracks: Track[];
|
|
63
|
+
slots: FieldSlot[];
|
|
64
|
+
boundaries: Boundary[];
|
|
65
|
+
claims: string[];
|
|
66
|
+
cursor: number;
|
|
67
|
+
}
|
|
68
|
+
/** Ring storage: recording a boundary does not allocate. Reading diagnostics does. */
|
|
69
|
+
declare class Trace {
|
|
70
|
+
next: number;
|
|
71
|
+
times: Float64Array<ArrayBuffer>;
|
|
72
|
+
ids: Float64Array<ArrayBuffer>;
|
|
73
|
+
kinds: string[];
|
|
74
|
+
tracks: string[];
|
|
75
|
+
reasons: string[];
|
|
76
|
+
write(id: number, time: number, kind: string, track?: string, reason?: string): void;
|
|
77
|
+
read(cursor: number): {
|
|
78
|
+
entries: {
|
|
79
|
+
cursor: number;
|
|
80
|
+
instance: number | undefined;
|
|
81
|
+
time: number | undefined;
|
|
82
|
+
kind: string | undefined;
|
|
83
|
+
track: string | undefined;
|
|
84
|
+
reason: string | undefined;
|
|
85
|
+
}[];
|
|
86
|
+
oldestCursor: number;
|
|
87
|
+
nextCursor: number;
|
|
88
|
+
truncated: boolean;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
export declare const Effects: import("@aethervtt/shard-core").ResourceDef<{
|
|
92
|
+
plans: Map<number, Plan>;
|
|
93
|
+
active: Instance[];
|
|
94
|
+
claims: Map<string, number>;
|
|
95
|
+
plugins: Set<string>;
|
|
96
|
+
nextId: number;
|
|
97
|
+
nextPlan: number;
|
|
98
|
+
pending: number;
|
|
99
|
+
disposed: boolean;
|
|
100
|
+
trace: Trace;
|
|
101
|
+
}>;
|
|
102
|
+
/** All references go through the host's catalog. Never interprets a GUID as a URL/path. */
|
|
103
|
+
export declare function prepareEffect(world: World, source: unknown): Promise<PreparedEffect>;
|
|
104
|
+
export interface PlayEffectOptions {
|
|
105
|
+
bindings?: Readonly<Record<string, Entity>>;
|
|
106
|
+
parameters?: Readonly<Record<string, number>>;
|
|
107
|
+
seed?: number;
|
|
108
|
+
ownerEntity?: Entity;
|
|
109
|
+
/** Parent frame for owned targets; their authored transforms stay local. */
|
|
110
|
+
parent?: Entity;
|
|
111
|
+
}
|
|
112
|
+
/** Synchronous after preparation: validation and binding finish before any track starts. */
|
|
113
|
+
export declare function playEffect(world: World, prepared: PreparedEffect, options?: PlayEffectOptions): EffectInstance;
|
|
114
|
+
export declare function cancelEffect(world: World, id: number): boolean;
|
|
115
|
+
export declare function pauseEffect(world: World, id: number, paused?: boolean): boolean;
|
|
116
|
+
/** Exported for isolated performance measurement; App users install effectsPlugin. */
|
|
117
|
+
export declare const updateEffects: import("@aethervtt/shard-core").SystemDef<undefined>;
|
|
118
|
+
export declare function describeEffects(world: World, cursor?: number): {
|
|
119
|
+
pending: number;
|
|
120
|
+
preparations: {
|
|
121
|
+
id: number;
|
|
122
|
+
released: boolean;
|
|
123
|
+
references: number;
|
|
124
|
+
duration: number;
|
|
125
|
+
}[];
|
|
126
|
+
instances: {
|
|
127
|
+
id: number;
|
|
128
|
+
state: "cancelled" | "completed" | "failed" | "paused" | "playing";
|
|
129
|
+
time: number;
|
|
130
|
+
seed: number;
|
|
131
|
+
targets: {
|
|
132
|
+
[k: string]: number;
|
|
133
|
+
};
|
|
134
|
+
}[];
|
|
135
|
+
trace: {
|
|
136
|
+
entries: {
|
|
137
|
+
cursor: number;
|
|
138
|
+
instance: number | undefined;
|
|
139
|
+
time: number | undefined;
|
|
140
|
+
kind: string | undefined;
|
|
141
|
+
track: string | undefined;
|
|
142
|
+
reason: string | undefined;
|
|
143
|
+
}[];
|
|
144
|
+
oldestCursor: number;
|
|
145
|
+
nextCursor: number;
|
|
146
|
+
truncated: boolean;
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
export declare function disposeEffects(world: World): void;
|
|
150
|
+
export {};
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AnimationClips,
|
|
3
|
+
AnimationPlayer,
|
|
4
|
+
animationLayer
|
|
5
|
+
} from "@aethervtt/shard-animation";
|
|
6
|
+
import { assetServer } from "@aethervtt/shard-assets";
|
|
7
|
+
import {
|
|
8
|
+
pauseAudioEvent,
|
|
9
|
+
playAudioEvent,
|
|
10
|
+
prepareAudio,
|
|
11
|
+
stopAudioEvent
|
|
12
|
+
} from "@aethervtt/shard-audio";
|
|
13
|
+
import {
|
|
14
|
+
defineResource,
|
|
15
|
+
defineSystem,
|
|
16
|
+
findComponent,
|
|
17
|
+
ShardError
|
|
18
|
+
} from "@aethervtt/shard-core";
|
|
19
|
+
import { ParticleSystem } from "@aethervtt/shard-particles";
|
|
20
|
+
import { descendantPaths } from "@aethervtt/shard-render";
|
|
21
|
+
import { FrameDemand, Time } from "@aethervtt/shard-runtime";
|
|
22
|
+
import { loadPrefab, spawnPrefab } from "@aethervtt/shard-scene";
|
|
23
|
+
import {
|
|
24
|
+
EffectSequence,
|
|
25
|
+
effectError,
|
|
26
|
+
parseEffect
|
|
27
|
+
} from "./document.js";
|
|
28
|
+
class Trace {
|
|
29
|
+
next = 0;
|
|
30
|
+
times = new Float64Array(2048);
|
|
31
|
+
ids = new Float64Array(2048);
|
|
32
|
+
kinds = new Array(2048).fill("");
|
|
33
|
+
tracks = new Array(2048).fill("");
|
|
34
|
+
reasons = new Array(2048).fill("");
|
|
35
|
+
write(id, time, kind, track = "", reason = "") {
|
|
36
|
+
const s = this.next++ % 2048;
|
|
37
|
+
this.times[s] = time;
|
|
38
|
+
this.ids[s] = id;
|
|
39
|
+
this.kinds[s] = kind;
|
|
40
|
+
this.tracks[s] = track;
|
|
41
|
+
this.reasons[s] = reason;
|
|
42
|
+
}
|
|
43
|
+
read(cursor) {
|
|
44
|
+
const oldestCursor = Math.max(0, this.next - 2048);
|
|
45
|
+
const entries = [];
|
|
46
|
+
for (let i = Math.max(oldestCursor, cursor); i < this.next; i++) {
|
|
47
|
+
const s = i % 2048;
|
|
48
|
+
entries.push({
|
|
49
|
+
cursor: i,
|
|
50
|
+
instance: this.ids[s],
|
|
51
|
+
time: this.times[s],
|
|
52
|
+
kind: this.kinds[s],
|
|
53
|
+
track: this.tracks[s],
|
|
54
|
+
reason: this.reasons[s]
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return { entries, oldestCursor, nextCursor: this.next, truncated: cursor < oldestCursor };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const Effects = defineResource("effects/State", {
|
|
61
|
+
init: () => ({
|
|
62
|
+
plans: /* @__PURE__ */ new Map(),
|
|
63
|
+
active: [],
|
|
64
|
+
claims: /* @__PURE__ */ new Map(),
|
|
65
|
+
plugins: /* @__PURE__ */ new Set(),
|
|
66
|
+
nextId: 1,
|
|
67
|
+
nextPlan: 1,
|
|
68
|
+
pending: 0,
|
|
69
|
+
disposed: false,
|
|
70
|
+
trace: new Trace()
|
|
71
|
+
})
|
|
72
|
+
});
|
|
73
|
+
function stateOf(world) {
|
|
74
|
+
const state = world.tryResource(Effects);
|
|
75
|
+
if (!state || state.disposed)
|
|
76
|
+
effectError("Install the effects plugin in a live app", "", "no-plugin");
|
|
77
|
+
return state;
|
|
78
|
+
}
|
|
79
|
+
function dropPlan(plan) {
|
|
80
|
+
if (--plan.refs !== 0) return;
|
|
81
|
+
for (const lease of plan.audio) lease.release();
|
|
82
|
+
plan.world.owners.release(plan.owner);
|
|
83
|
+
plan.world.resource(Effects).plans.delete(plan.id);
|
|
84
|
+
}
|
|
85
|
+
async function prepareEffect(world, source) {
|
|
86
|
+
const state = stateOf(world);
|
|
87
|
+
if (state.plans.size + state.pending >= 128)
|
|
88
|
+
effectError("Preparation capacity (128) reached", "", "capacity");
|
|
89
|
+
state.pending++;
|
|
90
|
+
const id = state.nextPlan++;
|
|
91
|
+
const owner = world.owners.create(`effect-preparation-${id}`);
|
|
92
|
+
const audio = [];
|
|
93
|
+
const server = assetServer(world);
|
|
94
|
+
const load = async (ref, type) => {
|
|
95
|
+
if (state.disposed) effectError("App disposed during preparation", "", "disposed");
|
|
96
|
+
const entry = server.entry(ref);
|
|
97
|
+
if (!entry || entry.type !== type)
|
|
98
|
+
effectError(`Expected catalog asset ${type}`, "", "asset-missing");
|
|
99
|
+
server.lease(entry.guid, owner);
|
|
100
|
+
await server.load(entry.guid);
|
|
101
|
+
if (entry.error || entry.state !== "loaded")
|
|
102
|
+
effectError(`Could not load ${entry.path}`, "", "asset-failed");
|
|
103
|
+
return { type, guid: entry.guid, path: entry.path };
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
let document;
|
|
107
|
+
if (typeof source === "string" || source && typeof source === "object" && !("tracks" in source) && ("guid" in source || "path" in source)) {
|
|
108
|
+
const ref = await load(source, EffectSequence.name);
|
|
109
|
+
document = parseEffect(
|
|
110
|
+
EffectSequence.serialize(world.initResource(EffectSequence.store).require(ref))
|
|
111
|
+
);
|
|
112
|
+
} else document = parseEffect(source);
|
|
113
|
+
for (const target of document.targets)
|
|
114
|
+
if (target.prefab) {
|
|
115
|
+
if (!state.plugins.has("scene"))
|
|
116
|
+
effectError("Prefab targets require the scene plugin", "/targets", "missing-plugin");
|
|
117
|
+
target.prefab = await load(target.prefab, "Prefab");
|
|
118
|
+
await loadPrefab(world, target.prefab);
|
|
119
|
+
}
|
|
120
|
+
for (const track of document.tracks) {
|
|
121
|
+
const kind = track.kind;
|
|
122
|
+
if (kind === "property" || kind === "marker") continue;
|
|
123
|
+
const plugin = kind === "audio" ? "audio/events" : kind === "visual" ? track.component === "render/Decal" ? "render/decals" : "render/ribbons" : kind;
|
|
124
|
+
if (!state.plugins.has(plugin))
|
|
125
|
+
effectError(`Track ${track.id} requires ${plugin}`, "/tracks", "missing-plugin");
|
|
126
|
+
if (kind === "visual") continue;
|
|
127
|
+
if (kind === "audio") {
|
|
128
|
+
track.audio = await load(track.audio, "audio/AudioEvent");
|
|
129
|
+
audio.push(await prepareAudio(world, track.audio));
|
|
130
|
+
} else if (kind === "animation")
|
|
131
|
+
track.animation = await load(
|
|
132
|
+
track.animation,
|
|
133
|
+
"AnimationClip"
|
|
134
|
+
);
|
|
135
|
+
else
|
|
136
|
+
track.particles = await load(
|
|
137
|
+
track.particles,
|
|
138
|
+
"ParticleEffect"
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (state.disposed) effectError("App disposed during preparation", "", "disposed");
|
|
142
|
+
const freeze = (value) => {
|
|
143
|
+
if (value && typeof value === "object") {
|
|
144
|
+
for (const child of Object.values(value)) freeze(child);
|
|
145
|
+
Object.freeze(value);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
freeze(document);
|
|
149
|
+
let released = false;
|
|
150
|
+
const plan = {
|
|
151
|
+
id,
|
|
152
|
+
document,
|
|
153
|
+
world,
|
|
154
|
+
owner,
|
|
155
|
+
audio,
|
|
156
|
+
refs: 1,
|
|
157
|
+
get released() {
|
|
158
|
+
return released;
|
|
159
|
+
},
|
|
160
|
+
release() {
|
|
161
|
+
if (!released) {
|
|
162
|
+
released = true;
|
|
163
|
+
dropPlan(plan);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
state.plans.set(id, plan);
|
|
168
|
+
return plan;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
for (const lease of audio) lease.release();
|
|
171
|
+
world.owners.release(owner);
|
|
172
|
+
throw error;
|
|
173
|
+
} finally {
|
|
174
|
+
state.pending--;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function refresh(world, slot) {
|
|
178
|
+
if (!world.isAlive(slot.entity)) return false;
|
|
179
|
+
const table = world.entityTableUnchecked(slot.entity);
|
|
180
|
+
if (slot.table !== table || slot.capacity !== table.capacity) {
|
|
181
|
+
const storage = table.storage(slot.def);
|
|
182
|
+
if (!storage) return false;
|
|
183
|
+
slot.table = table;
|
|
184
|
+
slot.capacity = table.capacity;
|
|
185
|
+
slot.storage = storage;
|
|
186
|
+
slot.column = storage.byName[slot.field];
|
|
187
|
+
}
|
|
188
|
+
slot.row = world.entityRowUnchecked(slot.entity);
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
function property(world, entity, d, factor) {
|
|
192
|
+
const def = findComponent(d.component), field = def?.fields[d.field];
|
|
193
|
+
if (!def || !field || def.hostOnly || field.options.readonly || !["f32", "f64", "vec2", "vec3", "vec4", "color"].includes(field.kind) || field.stride !== d.from.length || !world.has(entity, def))
|
|
194
|
+
effectError(`Cannot animate ${d.component}.${d.field} on ${entity}`, "", "invalid-field");
|
|
195
|
+
for (const values of [d.from, d.to])
|
|
196
|
+
for (const value of values) {
|
|
197
|
+
const scaled = value * factor;
|
|
198
|
+
if (!Number.isFinite(scaled) || field.storage === "f32" && !Number.isFinite(Math.fround(scaled)) || scaled < (field.options.min ?? -Infinity) || scaled > (field.options.max ?? Infinity))
|
|
199
|
+
effectError("Scaled property endpoint exceeds schema bounds", "", "invalid-field");
|
|
200
|
+
}
|
|
201
|
+
const slot = {
|
|
202
|
+
entity,
|
|
203
|
+
def,
|
|
204
|
+
field: d.field,
|
|
205
|
+
width: field.stride,
|
|
206
|
+
saved: new Float64Array(field.stride),
|
|
207
|
+
table: null,
|
|
208
|
+
capacity: 0,
|
|
209
|
+
row: 0,
|
|
210
|
+
storage: null,
|
|
211
|
+
column: null,
|
|
212
|
+
key: `${entity}:${def.id}:${d.field}`
|
|
213
|
+
};
|
|
214
|
+
refresh(world, slot);
|
|
215
|
+
for (let i = 0; i < slot.width; i++) slot.saved[i] = slot.column[slot.row * slot.width + i];
|
|
216
|
+
return slot;
|
|
217
|
+
}
|
|
218
|
+
function playEffect(world, prepared, options = {}) {
|
|
219
|
+
const state = stateOf(world), plan = state.plans.get(prepared.id);
|
|
220
|
+
if (plan !== prepared || plan.released)
|
|
221
|
+
effectError("Preparation is released or belongs to another app", "", "invalid-preparation");
|
|
222
|
+
if (state.active.length >= 256)
|
|
223
|
+
effectError("Active instance capacity (256) reached", "", "capacity");
|
|
224
|
+
const seed = options.seed ?? 0;
|
|
225
|
+
if (!Number.isInteger(seed) || seed < 0 || seed > 4294967295)
|
|
226
|
+
effectError("Seed must be a u32", "/seed");
|
|
227
|
+
if (options.ownerEntity !== void 0 && !world.isAlive(options.ownerEntity))
|
|
228
|
+
effectError("Owner entity is dead", "", "dead-target");
|
|
229
|
+
if (options.parent !== void 0 && !world.isAlive(options.parent))
|
|
230
|
+
effectError("Parent entity is dead", "/parent", "dead-target");
|
|
231
|
+
const parameters = new Map(plan.document.parameters.map((p) => [p.id, p.value]));
|
|
232
|
+
for (const [key, value] of Object.entries(options.parameters ?? {})) {
|
|
233
|
+
const p = plan.document.parameters.find((p2) => p2.id === key);
|
|
234
|
+
if (!p || !Number.isFinite(value) || value < p.min || value > p.max)
|
|
235
|
+
effectError(`Invalid parameter ${key}`, "/parameters");
|
|
236
|
+
parameters.set(key, value);
|
|
237
|
+
}
|
|
238
|
+
for (const key of Object.keys(options.bindings ?? {})) {
|
|
239
|
+
const target = plan.document.targets.find((t) => t.id === key);
|
|
240
|
+
if (!target || target.prefab) effectError(`Unexpected borrowed binding ${key}`, "/bindings");
|
|
241
|
+
}
|
|
242
|
+
const id = state.nextId++;
|
|
243
|
+
const owner = world.owners.create(`effect-instance-${id}`);
|
|
244
|
+
const instance = {
|
|
245
|
+
id,
|
|
246
|
+
seed,
|
|
247
|
+
plan,
|
|
248
|
+
owner,
|
|
249
|
+
ownerEntity: options.ownerEntity ?? options.parent,
|
|
250
|
+
targets: /* @__PURE__ */ new Map(),
|
|
251
|
+
time: 0,
|
|
252
|
+
state: "playing",
|
|
253
|
+
error: null,
|
|
254
|
+
tracks: [],
|
|
255
|
+
slots: [],
|
|
256
|
+
boundaries: [],
|
|
257
|
+
claims: [],
|
|
258
|
+
cursor: 0
|
|
259
|
+
};
|
|
260
|
+
plan.refs++;
|
|
261
|
+
try {
|
|
262
|
+
for (const target of plan.document.targets) {
|
|
263
|
+
let entity;
|
|
264
|
+
if (target.prefab) {
|
|
265
|
+
entity = spawnPrefab(world, target.prefab, { parent: options.parent });
|
|
266
|
+
world.owners.adopt(owner, entity);
|
|
267
|
+
} else entity = options.bindings?.[target.id];
|
|
268
|
+
if (entity === void 0 || !world.isAlive(entity))
|
|
269
|
+
effectError(`Missing live binding ${target.id}`, "/bindings", "dead-target");
|
|
270
|
+
instance.targets.set(target.id, entity);
|
|
271
|
+
}
|
|
272
|
+
const slots = /* @__PURE__ */ new Map();
|
|
273
|
+
for (let i = 0; i < plan.document.tracks.length; i++) {
|
|
274
|
+
const d = plan.document.tracks[i];
|
|
275
|
+
let entity = d.kind === "marker" ? null : instance.targets.get(d.target);
|
|
276
|
+
if (entity !== null && d.targetPath)
|
|
277
|
+
entity = descendantPaths(world, entity).get(d.targetPath) ?? null;
|
|
278
|
+
if (d.kind !== "marker" && entity === null)
|
|
279
|
+
effectError(`Missing target path for ${d.id}`, `/tracks/${i}/targetPath`, "dead-target");
|
|
280
|
+
const factor = d.parameter ? parameters.get(d.parameter) : 1;
|
|
281
|
+
let slot = null;
|
|
282
|
+
if (d.kind === "property") {
|
|
283
|
+
slot = property(world, entity, d, factor);
|
|
284
|
+
const existing = slots.get(slot.key);
|
|
285
|
+
if (existing) slot = existing;
|
|
286
|
+
else {
|
|
287
|
+
if (state.claims.has(slot.key))
|
|
288
|
+
effectError(
|
|
289
|
+
`Field claimed by instance ${state.claims.get(slot.key)}`,
|
|
290
|
+
`/tracks/${i}`,
|
|
291
|
+
"conflict"
|
|
292
|
+
);
|
|
293
|
+
state.claims.set(slot.key, id);
|
|
294
|
+
instance.claims.push(slot.key);
|
|
295
|
+
slots.set(slot.key, slot);
|
|
296
|
+
instance.slots.push(slot);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const visual = d.kind === "visual" ? findComponent(d.component) : null;
|
|
300
|
+
if (d.kind === "visual" && (!visual || !world.has(entity, visual)))
|
|
301
|
+
effectError("Visual target lacks its component", `/tracks/${i}`, "invalid-field");
|
|
302
|
+
instance.tracks.push({
|
|
303
|
+
definition: d,
|
|
304
|
+
entity,
|
|
305
|
+
slot,
|
|
306
|
+
from: Float64Array.from(d.from, (v) => v * factor),
|
|
307
|
+
to: Float64Array.from(d.to, (v) => v * factor),
|
|
308
|
+
state: 0,
|
|
309
|
+
audio: 0,
|
|
310
|
+
layer: null,
|
|
311
|
+
visual: visual ?? null,
|
|
312
|
+
seed: (seed ^ Math.imul(i + 1, 2654435769)) >>> 0
|
|
313
|
+
});
|
|
314
|
+
instance.boundaries.push({ time: d.start, index: i, end: false });
|
|
315
|
+
if (d.kind !== "marker")
|
|
316
|
+
instance.boundaries.push({ time: d.start + d.duration, index: i, end: true });
|
|
317
|
+
}
|
|
318
|
+
const channels = instance.tracks.map((track) => {
|
|
319
|
+
if (track.slot) return [track.slot.key];
|
|
320
|
+
if (track.visual) return [`${track.entity}:${track.visual.id}:playing`];
|
|
321
|
+
if (track.definition.kind !== "animation") return [];
|
|
322
|
+
const paths = descendantPaths(world, track.entity);
|
|
323
|
+
return world.resource(AnimationClips).require(track.definition.animation).channels.map((channel) => {
|
|
324
|
+
const entity = paths.get(channel.target);
|
|
325
|
+
const def = findComponent(channel.component);
|
|
326
|
+
if (entity === void 0 || !def || !world.has(entity, def))
|
|
327
|
+
effectError("Animation channel has no live target/component", "", "dead-target");
|
|
328
|
+
const key = `${entity}:${def.id}:${channel.field}`;
|
|
329
|
+
const claimed = state.claims.get(key);
|
|
330
|
+
if (claimed !== void 0 && claimed !== id)
|
|
331
|
+
effectError("Animation field already claimed", "", "conflict");
|
|
332
|
+
if (claimed === void 0) {
|
|
333
|
+
state.claims.set(key, id);
|
|
334
|
+
instance.claims.push(key);
|
|
335
|
+
}
|
|
336
|
+
return key;
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
for (let i = 0; i < instance.tracks.length; i++)
|
|
340
|
+
for (let j = 0; j < i; j++) {
|
|
341
|
+
const a = instance.tracks[i].definition, b = instance.tracks[j].definition;
|
|
342
|
+
if (a.start < b.start + b.duration - 1e-9 && b.start < a.start + a.duration - 1e-9 && channels[i].some((key) => channels[j].includes(key)))
|
|
343
|
+
effectError(
|
|
344
|
+
"Resolved tracks write the same field concurrently",
|
|
345
|
+
`/tracks/${i}`,
|
|
346
|
+
"conflict"
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
for (const track of instance.tracks)
|
|
350
|
+
if (track.visual) {
|
|
351
|
+
world.set(track.entity, track.visual, { playing: false, paused: false });
|
|
352
|
+
if (track.definition.endpoint)
|
|
353
|
+
world.set(track.entity, track.visual, {
|
|
354
|
+
target: instance.targets.get(track.definition.endpoint)
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
instance.boundaries.sort(
|
|
358
|
+
(a, b) => Math.abs(a.time - b.time) > 1e-9 ? a.time - b.time : Number(b.end) - Number(a.end) || a.index - b.index
|
|
359
|
+
);
|
|
360
|
+
state.active.push(instance);
|
|
361
|
+
state.trace.write(id, 0, "play");
|
|
362
|
+
advance(world, instance, 0);
|
|
363
|
+
world.tryResource(FrameDemand)?.hold("effects");
|
|
364
|
+
return instance;
|
|
365
|
+
} catch (error) {
|
|
366
|
+
finish(
|
|
367
|
+
world,
|
|
368
|
+
instance,
|
|
369
|
+
"failed",
|
|
370
|
+
error instanceof ShardError ? error.code : "effects/activation-failed"
|
|
371
|
+
);
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function sample(world, track, time) {
|
|
376
|
+
const d = track.definition;
|
|
377
|
+
if (track.entity !== null && !world.isAlive(track.entity)) return false;
|
|
378
|
+
if (track.slot) {
|
|
379
|
+
const slot = track.slot;
|
|
380
|
+
if (!refresh(world, slot)) return false;
|
|
381
|
+
let f = Math.min(1, Math.max(0, (time - d.start) / d.duration));
|
|
382
|
+
if (d.easing === "smoothstep") f = f * f * (3 - 2 * f);
|
|
383
|
+
const offset = slot.row * slot.width;
|
|
384
|
+
for (let k = 0; k < slot.width; k++)
|
|
385
|
+
slot.column[offset + k] = track.from[k] + (track.to[k] - track.from[k]) * f;
|
|
386
|
+
slot.storage.changed[slot.row] = world.tick;
|
|
387
|
+
slot.storage.lastChanged = world.tick;
|
|
388
|
+
} else if (track.layer) track.layer.time = Math.max(0, Math.min(d.duration, time - d.start));
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
function startTrack(world, instance, track) {
|
|
392
|
+
const d = track.definition, entity = track.entity;
|
|
393
|
+
track.state = 1;
|
|
394
|
+
if (track.visual) world.set(entity, track.visual, { playing: true, paused: false });
|
|
395
|
+
else if (d.kind === "animation") {
|
|
396
|
+
const clip = world.resource(AnimationClips).require(d.animation);
|
|
397
|
+
track.layer = animationLayer(d.animation, { playing: false, loop: "once" });
|
|
398
|
+
if (!Number.isFinite(clip.duration)) effectError("Invalid animation clip", "", "asset-failed");
|
|
399
|
+
if (!world.has(entity, AnimationPlayer)) world.add(entity, AnimationPlayer);
|
|
400
|
+
world.set(entity, AnimationPlayer, { layers: [track.layer] });
|
|
401
|
+
track.layer = world.entityTable(entity).column(AnimationPlayer, "layers")[world.entityRow(entity)][0];
|
|
402
|
+
} else if (d.kind === "particles") {
|
|
403
|
+
if (!world.has(entity, ParticleSystem)) world.add(entity, ParticleSystem);
|
|
404
|
+
world.set(entity, ParticleSystem, { effect: d.particles, seed: track.seed, playing: true });
|
|
405
|
+
} else if (d.kind === "audio")
|
|
406
|
+
track.audio = playAudioEvent(world, d.audio, { entity, seed: track.seed });
|
|
407
|
+
world.resource(Effects).trace.write(instance.id, d.start, d.kind === "marker" ? "marker" : "start", d.id, d.marker);
|
|
408
|
+
if (d.kind === "marker") track.state = 2;
|
|
409
|
+
}
|
|
410
|
+
function endTrack(world, track, teardown = false) {
|
|
411
|
+
if (track.audio) {
|
|
412
|
+
stopAudioEvent(world, track.audio, { release: 0 });
|
|
413
|
+
track.audio = 0;
|
|
414
|
+
}
|
|
415
|
+
if (track.entity !== null && world.isAlive(track.entity)) {
|
|
416
|
+
if (track.visual && world.has(track.entity, track.visual))
|
|
417
|
+
world.set(track.entity, track.visual, { playing: false, paused: false });
|
|
418
|
+
if (track.definition.kind === "particles" && world.has(track.entity, ParticleSystem))
|
|
419
|
+
world.remove(track.entity, ParticleSystem);
|
|
420
|
+
if (teardown && track.layer && world.has(track.entity, AnimationPlayer))
|
|
421
|
+
world.remove(track.entity, AnimationPlayer);
|
|
422
|
+
}
|
|
423
|
+
if (teardown) track.layer = null;
|
|
424
|
+
track.state = 2;
|
|
425
|
+
}
|
|
426
|
+
function finish(world, instance, status, reason = "") {
|
|
427
|
+
if (instance.state === "completed" || instance.state === "cancelled" || instance.state === "failed")
|
|
428
|
+
return;
|
|
429
|
+
const state = world.resource(Effects);
|
|
430
|
+
instance.state = status;
|
|
431
|
+
instance.error = reason || null;
|
|
432
|
+
for (let i = 0; i < instance.tracks.length; i++) endTrack(world, instance.tracks[i], true);
|
|
433
|
+
for (let i = 0; i < instance.slots.length; i++) {
|
|
434
|
+
const slot = instance.slots[i];
|
|
435
|
+
if (refresh(world, slot)) {
|
|
436
|
+
for (let k = 0; k < slot.width; k++) slot.column[slot.row * slot.width + k] = slot.saved[k];
|
|
437
|
+
slot.storage.changed[slot.row] = world.tick;
|
|
438
|
+
slot.storage.lastChanged = world.tick;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
for (let i = 0; i < instance.claims.length; i++) state.claims.delete(instance.claims[i]);
|
|
442
|
+
world.owners.release(instance.owner);
|
|
443
|
+
dropPlan(instance.plan);
|
|
444
|
+
const at = state.active.indexOf(instance);
|
|
445
|
+
if (at !== -1) state.active.splice(at, 1);
|
|
446
|
+
state.trace.write(instance.id, instance.time, status, "", reason);
|
|
447
|
+
refreshDemand(world);
|
|
448
|
+
}
|
|
449
|
+
function advance(world, instance, time) {
|
|
450
|
+
const trace = world.resource(Effects).trace;
|
|
451
|
+
instance.time = Math.min(time, instance.plan.document.duration);
|
|
452
|
+
while (instance.cursor < instance.boundaries.length && instance.boundaries[instance.cursor].time <= instance.time + 1e-9) {
|
|
453
|
+
const b = instance.boundaries[instance.cursor++], track = instance.tracks[b.index], d = track.definition;
|
|
454
|
+
if (b.end) {
|
|
455
|
+
if (track.state === 1) {
|
|
456
|
+
if (!sample(world, track, b.time)) effectError("Target disappeared", "", "dead-target");
|
|
457
|
+
endTrack(world, track);
|
|
458
|
+
trace.write(instance.id, b.time, "end", d.id);
|
|
459
|
+
}
|
|
460
|
+
} else if ((d.kind === "audio" || d.kind === "particles" || d.kind === "visual") && d.start + d.duration <= instance.time) {
|
|
461
|
+
track.state = 2;
|
|
462
|
+
trace.write(instance.id, b.time, "skip", d.id, "missed-window");
|
|
463
|
+
} else startTrack(world, instance, track);
|
|
464
|
+
}
|
|
465
|
+
for (let i = 0; i < instance.tracks.length; i++) {
|
|
466
|
+
const track = instance.tracks[i];
|
|
467
|
+
if (track.state === 1 && !sample(world, track, instance.time))
|
|
468
|
+
effectError("Target disappeared", "", "dead-target");
|
|
469
|
+
}
|
|
470
|
+
if (instance.time >= instance.plan.document.duration - 1e-9) finish(world, instance, "completed");
|
|
471
|
+
}
|
|
472
|
+
function cancelEffect(world, id) {
|
|
473
|
+
const state = stateOf(world), instance = state.active.find((i) => i.id === id);
|
|
474
|
+
if (!instance) return false;
|
|
475
|
+
finish(world, instance, "cancelled");
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
function refreshDemand(world) {
|
|
479
|
+
const active = world.resource(Effects).active;
|
|
480
|
+
let playing = false;
|
|
481
|
+
for (let i = 0; i < active.length; i++)
|
|
482
|
+
if (active[i].state === "playing") {
|
|
483
|
+
playing = true;
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
world.tryResource(FrameDemand)?.set("effects", playing);
|
|
487
|
+
}
|
|
488
|
+
function pauseEffect(world, id, paused = true) {
|
|
489
|
+
const instance = stateOf(world).active.find((i) => i.id === id);
|
|
490
|
+
if (!instance) return false;
|
|
491
|
+
instance.state = paused ? "paused" : "playing";
|
|
492
|
+
for (const track of instance.tracks) {
|
|
493
|
+
if (track.audio) pauseAudioEvent(world, track.audio, paused);
|
|
494
|
+
if (track.visual && track.state !== 0 && world.isAlive(track.entity))
|
|
495
|
+
world.set(track.entity, track.visual, { paused });
|
|
496
|
+
if (track.state === 1 && track.definition.kind === "particles" && world.isAlive(track.entity))
|
|
497
|
+
world.set(track.entity, ParticleSystem, { playing: !paused });
|
|
498
|
+
}
|
|
499
|
+
refreshDemand(world);
|
|
500
|
+
world.resource(Effects).trace.write(id, instance.time, paused ? "pause" : "resume");
|
|
501
|
+
return true;
|
|
502
|
+
}
|
|
503
|
+
const updateEffects = defineSystem({
|
|
504
|
+
name: "effects/update",
|
|
505
|
+
run(_, world) {
|
|
506
|
+
const state = world.resource(Effects), delta = world.resource(Time).delta;
|
|
507
|
+
if (state.disposed) return;
|
|
508
|
+
for (let i = state.active.length - 1; i >= 0; i--) {
|
|
509
|
+
const instance = state.active[i];
|
|
510
|
+
if (instance.ownerEntity !== void 0 && !world.isAlive(instance.ownerEntity)) {
|
|
511
|
+
finish(world, instance, "cancelled", "owner-removed");
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
let dead = false;
|
|
515
|
+
for (let j = 0; j < instance.tracks.length; j++) {
|
|
516
|
+
const track = instance.tracks[j], entity = track.entity, endpoint = track.definition.endpoint ? instance.targets.get(track.definition.endpoint) : void 0;
|
|
517
|
+
if (entity !== null && !world.isAlive(entity) || endpoint !== void 0 && !world.isAlive(endpoint)) {
|
|
518
|
+
dead = true;
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (dead) {
|
|
523
|
+
finish(world, instance, "failed", "effects/dead-target");
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (instance.state === "paused") continue;
|
|
527
|
+
try {
|
|
528
|
+
advance(world, instance, instance.time + delta);
|
|
529
|
+
} catch (error) {
|
|
530
|
+
finish(
|
|
531
|
+
world,
|
|
532
|
+
instance,
|
|
533
|
+
"failed",
|
|
534
|
+
error instanceof ShardError ? error.code : "effects/update-failed"
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
function describeEffects(world, cursor = 0) {
|
|
541
|
+
const state = stateOf(world);
|
|
542
|
+
return {
|
|
543
|
+
pending: state.pending,
|
|
544
|
+
preparations: [...state.plans.values()].map((p) => ({
|
|
545
|
+
id: p.id,
|
|
546
|
+
released: p.released,
|
|
547
|
+
references: p.refs,
|
|
548
|
+
duration: p.document.duration
|
|
549
|
+
})),
|
|
550
|
+
instances: state.active.map((i) => ({
|
|
551
|
+
id: i.id,
|
|
552
|
+
state: i.state,
|
|
553
|
+
time: i.time,
|
|
554
|
+
seed: i.seed,
|
|
555
|
+
targets: Object.fromEntries(i.targets)
|
|
556
|
+
})),
|
|
557
|
+
trace: state.trace.read(cursor)
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
function disposeEffects(world) {
|
|
561
|
+
const state = world.resource(Effects);
|
|
562
|
+
while (state.active.length)
|
|
563
|
+
finish(world, state.active[state.active.length - 1], "cancelled", "disposed");
|
|
564
|
+
for (const plan of state.plans.values()) plan.release();
|
|
565
|
+
state.disposed = true;
|
|
566
|
+
}
|
|
567
|
+
export {
|
|
568
|
+
Effects,
|
|
569
|
+
cancelEffect,
|
|
570
|
+
describeEffects,
|
|
571
|
+
disposeEffects,
|
|
572
|
+
pauseEffect,
|
|
573
|
+
playEffect,
|
|
574
|
+
prepareEffect,
|
|
575
|
+
updateEffects
|
|
576
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aethervtt/shard-effects",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@aethervtt/shard-core": "0.2.1",
|
|
20
|
+
"@aethervtt/shard-runtime": "0.2.1",
|
|
21
|
+
"@aethervtt/shard-assets": "0.2.1",
|
|
22
|
+
"@aethervtt/shard-scene": "0.2.1",
|
|
23
|
+
"@aethervtt/shard-animation": "0.2.1",
|
|
24
|
+
"@aethervtt/shard-audio": "0.2.1",
|
|
25
|
+
"@aethervtt/shard-particles": "0.2.1",
|
|
26
|
+
"@aethervtt/shard-transform": "0.2.1",
|
|
27
|
+
"@aethervtt/shard-render": "0.2.1"
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/aethervtt/shard.git",
|
|
32
|
+
"directory": "packages/effects"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public",
|
|
36
|
+
"provenance": true
|
|
37
|
+
},
|
|
38
|
+
"shard": {
|
|
39
|
+
"buildSha": "772eb0d2b8a5fc499105708f4ae92c58788cc9ea"
|
|
40
|
+
}
|
|
41
|
+
}
|