A particle is a function of its age, not a thing that is stepped (#73)
game-maker / suite (push) Failing after 4m22s
game-maker / suite (push) Failing after 4m22s
The asset type `particles` is placeable and carries a graph at the same time — `behavior` was the blueprint. Because a continuous emitter gives birth to particle n at time n/rate, the whole field is a function of the clock rather than of the frames: no ring-buffer bookkeeping, exact time jumps, reproducibility as a measurable property. compile.ts follows compileShader's frame but emits JS closures into a Float64Array instead of GLSL, and without eval; a node with two consumers is evaluated once. Drawing is one Pixi ParticleContainer per source, so one draw call. Collision, wind and braking in flight are out for good — that is the price of the formula model, not an omission. The tab count goes from nine to ten, which is exactly the case #84 was built for one day earlier: at 1280 px the row no longer holds every tab, so guard 14b now asks for hidden.length > 0 instead of === 0.
This commit is contained in:
1 parent
f83c568999
commit
0f9e1611d1
39 files changed
+2386
-32
No files matched your search
+21
-2
@@ -21,6 +21,7 @@ import { HierarchyPanel } from './panels/HierarchyPanel';
|
||||
import { Toolbar } from './panels/Toolbar';
|
||||
import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession';
|
||||
import { ProjectSession } from './ProjectSession';
|
||||
import { ParticlesEditor } from './particles/ParticlesEditor';
|
||||
import { ShaderEditor } from './shader/ShaderEditor';
|
||||
import { StateMachineEditor } from './statemachine/StateMachineEditor';
|
||||
import { installShortcuts } from './shortcuts';
|
||||
@@ -29,7 +30,16 @@ import { TextureEditor } from './texture/TextureEditor';
|
||||
import { installFileImport } from './fileImport';
|
||||
|
||||
export type CentreTab =
|
||||
'scene' | 'graph' | 'statemachine' | 'shader' | 'game' | 'texture' | 'animation' | 'container' | 'list';
|
||||
| 'scene'
|
||||
| 'graph'
|
||||
| 'statemachine'
|
||||
| 'shader'
|
||||
| 'particles'
|
||||
| 'game'
|
||||
| 'texture'
|
||||
| 'animation'
|
||||
| 'container'
|
||||
| 'list';
|
||||
|
||||
const TAB_ORDER = [
|
||||
'scene',
|
||||
@@ -37,6 +47,7 @@ const TAB_ORDER = [
|
||||
'graph',
|
||||
'statemachine',
|
||||
'shader',
|
||||
'particles',
|
||||
'texture',
|
||||
'animation',
|
||||
'container',
|
||||
@@ -49,6 +60,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
|
||||
graph: 'Behavior',
|
||||
statemachine: 'State machine',
|
||||
shader: 'Shader',
|
||||
particles: 'Particles',
|
||||
texture: 'Texture',
|
||||
animation: 'Animation',
|
||||
container: 'Container',
|
||||
@@ -61,6 +73,7 @@ const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
|
||||
behavior: 'graph',
|
||||
statemachine: 'statemachine',
|
||||
shader: 'shader',
|
||||
particles: 'particles',
|
||||
texture: 'texture',
|
||||
animation: 'animation',
|
||||
container: 'container',
|
||||
@@ -87,6 +100,7 @@ export class Editor implements PlayHost {
|
||||
readonly graphEditor: GraphEditor;
|
||||
readonly stateMachineEditor: StateMachineEditor;
|
||||
readonly shaderEditor: ShaderEditor;
|
||||
readonly particlesEditor: ParticlesEditor;
|
||||
readonly textureEditor: TextureEditor;
|
||||
readonly animationEditor: AnimationEditor;
|
||||
readonly containerEditor: ContainerEditor;
|
||||
@@ -129,6 +143,7 @@ export class Editor implements PlayHost {
|
||||
this.graphEditor = new GraphEditor(state);
|
||||
this.stateMachineEditor = new StateMachineEditor(state);
|
||||
this.shaderEditor = new ShaderEditor(state);
|
||||
this.particlesEditor = new ParticlesEditor(state);
|
||||
this.textureEditor = new TextureEditor(state);
|
||||
this.animationEditor = new AnimationEditor(state);
|
||||
this.containerEditor = new ContainerEditor(state);
|
||||
@@ -175,6 +190,7 @@ export class Editor implements PlayHost {
|
||||
graph: this.graphEditor,
|
||||
statemachine: this.stateMachineEditor,
|
||||
shader: this.shaderEditor,
|
||||
particles: this.particlesEditor,
|
||||
texture: this.textureEditor,
|
||||
animation: this.animationEditor,
|
||||
container: this.containerEditor,
|
||||
@@ -233,7 +249,9 @@ export class Editor implements PlayHost {
|
||||
? 'statemachine'
|
||||
: state.selectedShaderId
|
||||
? 'shader'
|
||||
: 'graph';
|
||||
: state.selectedParticlesId
|
||||
? 'particles'
|
||||
: 'graph';
|
||||
if (this.tab !== tab) this.showTab(tab);
|
||||
}
|
||||
if (s?.kind === 'frame' && this.tab !== 'texture') this.showTab('texture');
|
||||
@@ -305,6 +323,7 @@ export class Editor implements PlayHost {
|
||||
this.gameView.animate();
|
||||
this.console.refresh();
|
||||
if (this.tab === 'animation') this.animationEditor.tick(dt);
|
||||
if (this.tab === 'particles') this.particlesEditor.tick();
|
||||
}
|
||||
|
||||
private measureFps(dt: number): void {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { resolveObjects } from '../core/model/instance';
|
||||
import { findObjectHolder } from '../core/model/Project';
|
||||
import type { FrameRect } from '../core/model/texture';
|
||||
import { activeDrag, Button, ui, Widget } from '../ui';
|
||||
import { particleLibrary } from '../viewport/ParticleLibrary';
|
||||
import { shaderLibrary } from '../viewport/ShaderLibrary';
|
||||
import { textureLibrary } from '../viewport/TextureLibrary';
|
||||
import type { CentreTab, Editor } from './Editor';
|
||||
@@ -280,6 +281,26 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
center: () => centreOf(editor.stateMachineEditor.canvas),
|
||||
snapping: () => editor.stateMachineEditor.canvas.snapEnabled,
|
||||
},
|
||||
particles: {
|
||||
current: () => editor.particlesEditor.currentAssetId,
|
||||
pickerAt: () => centreOf(editor.particlesEditor.picker),
|
||||
info: () => editor.particlesEditor.shownInfo,
|
||||
nodeAt: (nodeId: string) => editor.particlesEditor.canvas.nodeGlobal(nodeId),
|
||||
nodeLabel: (nodeId: string) => editor.particlesEditor.canvas.nodeLabel(nodeId),
|
||||
nodeRect: (nodeId: string) => editor.particlesEditor.canvas.nodeRect(nodeId),
|
||||
nodeProblem: (nodeId: string) => editor.particlesEditor.canvas.nodeProblem(nodeId),
|
||||
canvasRect: () => editor.particlesEditor.canvas.canvasRect(),
|
||||
emptySpot: () => editor.particlesEditor.canvas.emptySpotGlobal(),
|
||||
center: () => centreOf(editor.particlesEditor.canvas),
|
||||
/** The strip below the graph: how many particles it draws and which second it stands at. */
|
||||
preview: () => ({
|
||||
count: editor.particlesEditor.preview.count,
|
||||
time: Math.round(editor.particlesEditor.preview.time * 100) / 100,
|
||||
holding: editor.particlesEditor.preview.holding,
|
||||
}),
|
||||
/** Jumps the preview to a second – exact, because a particle is a function of its age. */
|
||||
hold: (at: number | null) => editor.particlesEditor.preview.hold(at),
|
||||
},
|
||||
shader: {
|
||||
current: () => editor.shaderEditor.currentAssetId,
|
||||
pickerAt: () => centreOf(editor.shaderEditor.picker),
|
||||
@@ -425,6 +446,17 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
},
|
||||
/** Holds the shader clock at `at` seconds, so a Time shader's pixels can be measured; null releases it. */
|
||||
shaderTime: (at: number | null) => shaderLibrary.freezeTime(at),
|
||||
/** The same for particle time, kept apart so one can be held without the other. */
|
||||
particleTime: (at: number | null) => particleLibrary.freezeTime(at),
|
||||
particleCompiles: () => particleLibrary.compiles,
|
||||
/** Every source's clock back to zero on the current (usually frozen) second. */
|
||||
particlesRestart: () => {
|
||||
viewport.restartParticles(particleLibrary.now());
|
||||
gameView.restartParticles(particleLibrary.now());
|
||||
},
|
||||
/** What a source is drawing in the scene tab, or in the game view while playing. */
|
||||
particlesOf: (objectId: string, where: 'scene' | 'game' = 'scene', limit = 8) =>
|
||||
(where === 'game' ? gameView : viewport).particleInfo(objectId, limit),
|
||||
/** Graphs compiled so far; a moved node must not add one (#65). */
|
||||
shaderCompiles: () => shaderLibrary.compiles,
|
||||
/** Which views redraw every tick because a Time shader is on screen. */
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '../../core/graph';
|
||||
import type { GraphAsset } from '../../core/model/Asset';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
import { particleLibrary } from '../../viewport/ParticleLibrary';
|
||||
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||||
import { findAsset, findGraphAsset } from '../../core/model/Project';
|
||||
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
|
||||
@@ -219,8 +220,13 @@ export class GraphCanvas extends Widget {
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const lookup = graphLookup(this.state.project);
|
||||
// A shader node's problem is the compiler's, not a call's.
|
||||
const shaderProblems = asset.type === 'shader' ? shaderLibrary.buildFor(asset).problems : null;
|
||||
// In a compiled graph a node's problem is the compiler's, not a call's.
|
||||
const compilerProblems =
|
||||
asset.type === 'shader'
|
||||
? shaderLibrary.buildFor(asset).problems
|
||||
: asset.type === 'particles'
|
||||
? particleLibrary.buildFor(asset).problems
|
||||
: null;
|
||||
for (const node of asset.graph.nodes) {
|
||||
const def = resolveDef(node, asset.graph, lookup);
|
||||
if (!def) continue;
|
||||
@@ -233,8 +239,8 @@ export class GraphCanvas extends Widget {
|
||||
this.views.set(node.id, view);
|
||||
this.nodeLayer.addChild(view);
|
||||
}
|
||||
const problem = shaderProblems
|
||||
? (shaderProblems[node.id] ?? null)
|
||||
const problem = compilerProblems
|
||||
? (compilerProblems[node.id] ?? null)
|
||||
: callProblem(node, lookup, asset.id);
|
||||
view.sync(node, asset.graph, this.state.project, def, problem);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ export function categoryColor(category: NodeCategory): number {
|
||||
return c.categoryState;
|
||||
case 'shader':
|
||||
return c.categoryShader;
|
||||
case 'particles':
|
||||
return c.categoryParticles;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,14 @@ import type { GraphAsset } from '../../core/model/Asset';
|
||||
import { graphLookup } from '../../core/model/graphLookup';
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import type { Project } from '../../core/model/Project';
|
||||
import {
|
||||
isParticlePropertyNode,
|
||||
PARTICLE_PROPERTY,
|
||||
PROPERTY_NAME_PARAM as PARTICLE_NAME_PARAM,
|
||||
propertyNameOf,
|
||||
} from '../../core/properties/particles';
|
||||
import { nextPropertyName, PROPERTY_NAME_PARAM, SHADER_PROPERTY } from '../../core/properties/shaders';
|
||||
import { nextParticlePropertyName, PARTICLE_NODES, PARTICLE_OUTPUT } from '../../core/particles/nodes';
|
||||
import { SHADER_NODES, SHADER_OUTPUT } from '../../core/shader/nodes';
|
||||
|
||||
/**
|
||||
@@ -48,9 +55,19 @@ export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntr
|
||||
return { id: def.type, hint: def.category, def, params };
|
||||
});
|
||||
}
|
||||
if (asset.type === 'particles') {
|
||||
const hasOutput = asset.graph.nodes.some((n) => n.type === PARTICLE_OUTPUT);
|
||||
const taken = new Set(asset.graph.nodes.filter(isParticlePropertyNode).map(propertyNameOf));
|
||||
return PARTICLE_NODES.filter((def) => def.type !== PARTICLE_OUTPUT || !hasOutput).map((def) => {
|
||||
const params: PropertyBag =
|
||||
def.type === PARTICLE_PROPERTY ? { [PARTICLE_NAME_PARAM]: nextParticlePropertyName(taken) } : {};
|
||||
return { id: def.type, hint: def.category, def, params };
|
||||
});
|
||||
}
|
||||
const entries: PaletteEntry[] = [];
|
||||
for (const def of allNodeDefs()) {
|
||||
if (def.type === RUN_BEHAVIOR || def.category === 'state' || def.category === 'shader') continue;
|
||||
if (def.type === RUN_BEHAVIOR || def.category === 'state') continue;
|
||||
if (def.category === 'shader' || def.category === 'particles') continue;
|
||||
if (isTerminalType(def.type) && terminalNode(asset.graph, def.type)) continue;
|
||||
entries.push({ id: def.type, hint: def.category, def, params: {} });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Container, Graphics, Texture } from 'pixi.js';
|
||||
import type { ParticlesAsset } from '../../core/model/Asset';
|
||||
import { translation } from '../../core/model/matrix';
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import { defaultsFor } from '../../core/properties/PropertySchema';
|
||||
import { type Size, Theme, Widget } from '../../ui';
|
||||
import { ParticleField } from '../../viewport/particles';
|
||||
import { particleLibrary } from '../../viewport/ParticleLibrary';
|
||||
|
||||
const MIN_HEIGHT = 40;
|
||||
|
||||
/**
|
||||
* The asset's own particles, emitted from the middle of the strip by a source with the type's
|
||||
* default rows. It is the one place a formula graph can be judged: the numbers say nothing. The
|
||||
* picture is deliberately a plain square – which texture a source throws belongs to the placed
|
||||
* object, not to the asset the tab is showing.
|
||||
*/
|
||||
export class ParticlePreview extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
private readonly content = new Container();
|
||||
private readonly clip = new Graphics();
|
||||
private readonly field = new ParticleField();
|
||||
private asset: ParticlesAsset | null = null;
|
||||
/** Null: the preview runs on the library clock. A number: it stands at that second. */
|
||||
private held: number | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.minHeight = MIN_HEIGHT;
|
||||
this.content.addChild(this.field.container);
|
||||
this.content.mask = this.clip;
|
||||
this.addChild(this.bg, this.content, this.clip);
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
return { w: Math.max(this.minWidth, 100), h: Math.max(this.minHeight, MIN_HEIGHT) };
|
||||
}
|
||||
|
||||
show(asset: ParticlesAsset | null): void {
|
||||
this.asset = asset;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/** Seconds the shown source has been emitting, whether held or running. */
|
||||
get time(): number {
|
||||
return this.held ?? Math.max(0, this.field.elapsed(particleLibrary.now()));
|
||||
}
|
||||
|
||||
get holding(): boolean {
|
||||
return this.held !== null;
|
||||
}
|
||||
|
||||
hold(at: number | null): void {
|
||||
this.held = at;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
restart(): void {
|
||||
this.field.restart(particleLibrary.now());
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/** How many particles the preview is drawing – what the checks read. */
|
||||
get count(): number {
|
||||
return this.field.count;
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
const c = Theme.colors;
|
||||
// A ground of its own and a rule along the top: on the graph's own background the strip was
|
||||
// invisible as a thing, and a particle standing still read as a node that had lost its box.
|
||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.panel);
|
||||
this.bg.rect(0, 0, this.w, 1).fill(c.border);
|
||||
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
|
||||
this.field.container.visible = this.asset !== null;
|
||||
if (!this.asset) return;
|
||||
const build = particleLibrary.buildFor(this.asset);
|
||||
const rows = build.compiled?.params ?? [];
|
||||
const props = previewProps(rows);
|
||||
const params = rows.map((row) => Number(props[row.key] ?? row.default));
|
||||
// The source sits in the middle of the strip; a fountain then has room above and below it.
|
||||
const m = translation(this.w / 2, this.h / 2);
|
||||
this.field.sync(build.compiled, props, params, m, Texture.WHITE, this.clock());
|
||||
}
|
||||
|
||||
/** Holding at second t means handing the field a clock that reads exactly t since it started. */
|
||||
private clock(): number {
|
||||
const now = particleLibrary.now();
|
||||
if (this.held === null) return now;
|
||||
return now - this.field.elapsed(now) + this.held;
|
||||
}
|
||||
|
||||
override destroy(): void {
|
||||
this.field.destroy();
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/** A source as the asset alone describes it: the type's defaults, the graph's parameters at theirs. */
|
||||
function previewProps(params: readonly { key: string; default: number }[]): PropertyBag {
|
||||
const props = defaultsFor('particles');
|
||||
for (const row of params) props[row.key] = row.default;
|
||||
return props;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Asset, ParticlesAsset } from '../../core/model/Asset';
|
||||
import { LIFETIME_KEY } from '../../core/properties/particles';
|
||||
import { defaultsFor } from '../../core/properties/PropertySchema';
|
||||
import { particleLibrary } from '../../viewport/ParticleLibrary';
|
||||
import { Button, clamp, HStack, Label, Slider, Spacer, Splitter, Theme } from '../../ui';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import { GraphCanvas } from '../graph/GraphCanvas';
|
||||
import { SnapToggle } from '../graph/SnapToggle';
|
||||
import type { EditorState } from '../state';
|
||||
import { ParticlePreview } from './ParticlePreview';
|
||||
|
||||
/** Room kept for "N parameters", or for the reason the graph does not compile. */
|
||||
const INFO_WIDTH = 220;
|
||||
/** How far the time slider reaches: three lifetimes, so a particle's whole arc fits with room after. */
|
||||
const SPAN_LIFETIMES = 3;
|
||||
const MIN_SPAN = 2;
|
||||
/** The strip can be dragged shut to its bar and open to half a tall window. */
|
||||
const MIN_PREVIEW = 60;
|
||||
const MAX_PREVIEW = 500;
|
||||
|
||||
/**
|
||||
* The nodes of one particle asset, above a strip that actually emits them. A formula graph cannot be
|
||||
* read as a picture, so the tab shows one – and because every particle is a function of its age, the
|
||||
* slider can jump to any second exactly, without simulating the way there.
|
||||
*/
|
||||
export class ParticlesEditor extends AssetTabEditor<ParticlesAsset, GraphCanvas> {
|
||||
readonly preview = new ParticlePreview();
|
||||
private readonly snap = new SnapToggle(() => this.canvas);
|
||||
private readonly slider = new Slider(0, 0, MIN_SPAN, 140);
|
||||
private readonly holdButton = new Button('Hold', { onClick: () => this.toggleHold() });
|
||||
private readonly timeLabel = new Label('0.00 s', { style: 'small' });
|
||||
readonly splitter = new Splitter('y');
|
||||
private span = MIN_SPAN;
|
||||
private previewHeight = 180;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Particles', infoWidth: INFO_WIDTH });
|
||||
this.slider.onChange = (value) => {
|
||||
this.preview.hold(value);
|
||||
this.syncTime();
|
||||
};
|
||||
this.mount(new GraphCanvas(state, 'particles'), this.snap.widgets);
|
||||
// The clock belongs to the strip, not to the asset, so its controls sit above the strip: the
|
||||
// header is already carrying the picker, Snap, Fit and the readout.
|
||||
const bar = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
||||
bar.minHeight = Theme.controlHeight + Theme.padding * 2;
|
||||
bar.add(new Label('Preview', { style: 'muted' }));
|
||||
bar.add(this.holdButton);
|
||||
bar.add(this.slider);
|
||||
bar.add(this.timeLabel);
|
||||
bar.add(new Button('Restart', { onClick: () => this.preview.restart() }));
|
||||
bar.add(new Spacer(), 1);
|
||||
// Draggable like the console: how much of the tab is graph and how much is picture is a matter
|
||||
// of what is being worked on, and on a short window a fixed strip eats the canvas.
|
||||
this.splitter.onDrag = (delta) => {
|
||||
this.previewHeight = clamp(this.previewHeight - delta, MIN_PREVIEW, MAX_PREVIEW);
|
||||
this.preview.minHeight = this.previewHeight;
|
||||
this.relayout();
|
||||
};
|
||||
this.add(this.splitter);
|
||||
this.add(bar);
|
||||
this.preview.minHeight = this.previewHeight;
|
||||
this.add(this.preview);
|
||||
state.on('mode', () => this.refresh());
|
||||
}
|
||||
|
||||
/** Called every frame while the tab is showing: the strip is a moving picture. */
|
||||
tick(): void {
|
||||
this.preview.invalidate();
|
||||
if (!this.preview.holding) this.syncTime();
|
||||
}
|
||||
|
||||
private toggleHold(): void {
|
||||
this.preview.hold(this.preview.holding ? null : this.preview.time);
|
||||
this.holdButton.setText(this.preview.holding ? 'Run' : 'Hold');
|
||||
this.syncTime();
|
||||
}
|
||||
|
||||
private syncTime(): void {
|
||||
const time = this.preview.time;
|
||||
this.timeLabel.setText(`${time.toFixed(2)} s`);
|
||||
if (!this.preview.holding) this.slider.setValue(time % this.span);
|
||||
}
|
||||
|
||||
protected override selectedId(): string {
|
||||
return this.state.selectedParticlesId ?? '';
|
||||
}
|
||||
|
||||
protected override isMine(asset: Asset): asset is ParticlesAsset {
|
||||
return asset.type === 'particles';
|
||||
}
|
||||
|
||||
protected override infoText(asset: ParticlesAsset): string {
|
||||
const build = particleLibrary.buildFor(asset);
|
||||
if (!build.compiled) return build.reason;
|
||||
const count = build.compiled.params.length;
|
||||
return `${count} ${count === 1 ? 'parameter' : 'parameters'}`;
|
||||
}
|
||||
|
||||
protected override syncExtras(asset: ParticlesAsset | null): void {
|
||||
this.snap.setEnabled(Boolean(asset));
|
||||
this.preview.show(asset);
|
||||
const lifetime = Number(defaultsFor('particles')[LIFETIME_KEY] ?? 1);
|
||||
this.span = Math.max(MIN_SPAN, lifetime * SPAN_LIFETIMES);
|
||||
this.slider.setRange(0, this.span);
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -376,7 +376,12 @@ export class EditorState {
|
||||
return this.selectedGraphId('shader');
|
||||
}
|
||||
|
||||
private selectedGraphId(type: 'behavior' | 'statemachine' | 'shader'): string | null {
|
||||
/** The particle asset the selection refers to: the asset, a placed source, or one of its nodes. */
|
||||
get selectedParticlesId(): string | null {
|
||||
return this.selectedGraphId('particles');
|
||||
}
|
||||
|
||||
private selectedGraphId(type: 'behavior' | 'statemachine' | 'shader' | 'particles'): string | null {
|
||||
const s = this.selection;
|
||||
if (!s) return null;
|
||||
if (s.kind === 'frame') return null;
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Container, type FederatedPointerEvent, Graphics, type Mesh, Text, TextStyle } from 'pixi.js';
|
||||
import {
|
||||
Container,
|
||||
type FederatedPointerEvent,
|
||||
Graphics,
|
||||
type Mesh,
|
||||
Text,
|
||||
Texture,
|
||||
TextStyle,
|
||||
} from 'pixi.js';
|
||||
import { isSymbolType } from '../../core/model/AssetTypes';
|
||||
import { type Extent, scaleOf } from '../../core/model/matrix';
|
||||
import { sortByZ, type Vec2, textOf } from '../../core/model/Properties';
|
||||
@@ -8,6 +16,10 @@ import type { EditorState } from '../state';
|
||||
import { type Size, Theme, Widget } from '../../ui';
|
||||
import { fontLibrary } from '../../viewport/FontLibrary';
|
||||
import type { DrawItem } from '../../viewport/items';
|
||||
import { hashId } from '../../core/particles/nodes';
|
||||
import { ParticleField } from '../../viewport/particles';
|
||||
import { particleLibrary } from '../../viewport/ParticleLibrary';
|
||||
import { SHEET_EDGE } from '../../core/shader/nodes';
|
||||
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||||
import { type ShaderSource, wornShader } from '../../viewport/shaders';
|
||||
import {
|
||||
@@ -39,6 +51,8 @@ export abstract class WorldViewport extends Widget {
|
||||
private readonly texts = new Map<string, Text>();
|
||||
/** Pictures of sprite items: siblings as well, each right under its node. */
|
||||
private readonly sprites = new Map<string, Mesh>();
|
||||
/** The drawn particles of each source, one container – and one draw call – per source. */
|
||||
private readonly fields = new Map<string, ParticleField>();
|
||||
private visibleCount = 0;
|
||||
private animated = false;
|
||||
protected lastItems: DrawItem[] = [];
|
||||
@@ -99,6 +113,40 @@ export abstract class WorldViewport extends Widget {
|
||||
return { rendering: this.currentView() !== null, visibleItems: this.visibleCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* The particles of one source as drawn, in world units, oldest first – what a check reads instead
|
||||
* of guessing from pixels. `limit` keeps a stream of 500 from becoming 500 lines of JSON.
|
||||
*/
|
||||
particleInfo(
|
||||
objectId: string,
|
||||
limit = 8,
|
||||
): {
|
||||
count: number;
|
||||
drawn: { x: number; y: number; scaleX: number; alpha: number; tint: number }[];
|
||||
} | null {
|
||||
const field = this.fields.get(objectId);
|
||||
if (!field || !field.container.visible) return null;
|
||||
return {
|
||||
count: field.count,
|
||||
drawn: field.container.particleChildren.slice(0, limit).map((p) => ({
|
||||
x: Math.round(p.x * 100) / 100,
|
||||
y: Math.round(p.y * 100) / 100,
|
||||
scaleX: Math.round(p.scaleX * 1000) / 1000,
|
||||
alpha: Math.round(p.alpha * 1000) / 1000,
|
||||
tint: p.tint,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts every source's clock over at `now`. With particle time frozen this is what makes a
|
||||
* measurement exact: freeze, restart, move the clock to the second wanted, read.
|
||||
*/
|
||||
restartParticles(now: number): void {
|
||||
for (const field of this.fields.values()) field.restart(now);
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** True after a redraw that drew a sprite wearing a Time shader: the picture moves by itself. */
|
||||
get animates(): boolean {
|
||||
return this.animated;
|
||||
@@ -179,8 +227,9 @@ export abstract class WorldViewport extends Widget {
|
||||
const size = footprintOf(item.type, item.props);
|
||||
const extent = footprintExtent(item.type, item.props, item.m);
|
||||
// A switched-off object is not drawn at all, in the scene view as in the game; it is
|
||||
// selected and switched back on from the hierarchy panel.
|
||||
const onScreen = item.enabled && rectsIntersect(extent, bounds);
|
||||
// selected and switched back on from the hierarchy panel. A source is never culled by its
|
||||
// own little square: its particles may be anywhere on screen while it is not.
|
||||
const onScreen = item.enabled && (item.type === 'particles' || rectsIntersect(extent, bounds));
|
||||
let node = this.nodes.get(item.id);
|
||||
if (!node) {
|
||||
node = new Graphics();
|
||||
@@ -195,6 +244,7 @@ export abstract class WorldViewport extends Widget {
|
||||
this.syncDecorations(item, onScreen, view.scale, extent);
|
||||
this.syncText(item, onScreen, view.scale);
|
||||
this.syncSprite(item, onScreen, size, shaders);
|
||||
this.syncParticles(item, onScreen);
|
||||
if (!onScreen) continue;
|
||||
this.visibleCount++;
|
||||
drawItem(node, item, size, view.scale * scaleOf(placementOf(item.type, item.m)));
|
||||
@@ -207,6 +257,8 @@ export abstract class WorldViewport extends Widget {
|
||||
const sprite = this.sprites.get(id);
|
||||
if (sprite) disposeSpriteMesh(sprite);
|
||||
this.sprites.delete(id);
|
||||
this.fields.get(id)?.destroy();
|
||||
this.fields.delete(id);
|
||||
node.destroy();
|
||||
this.nodes.delete(id);
|
||||
this.disposeDecorations(id);
|
||||
@@ -230,9 +282,46 @@ export abstract class WorldViewport extends Widget {
|
||||
if (node) this.objects.addChild(node);
|
||||
const text = this.texts.get(item.id);
|
||||
if (text) this.objects.addChild(text);
|
||||
const field = this.fields.get(item.id);
|
||||
if (field) this.objects.addChild(field.container);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The particles of one source. Their positions are world units, like everything else under
|
||||
* `objects`, so the container itself carries no transform: the source's own goes into each
|
||||
* particle, which is what lets one stay where it was born while its source flies on.
|
||||
*/
|
||||
private syncParticles(item: DrawItem, onScreen: boolean): void {
|
||||
let field = this.fields.get(item.id);
|
||||
if (item.type !== 'particles' || !item.particles || !onScreen) {
|
||||
if (field) field.container.visible = false;
|
||||
return;
|
||||
}
|
||||
if (!field) {
|
||||
field = new ParticleField();
|
||||
this.fields.set(item.id, field);
|
||||
this.objects.addChild(field.container);
|
||||
}
|
||||
field.container.visible = true;
|
||||
// A source is a moving picture by definition, so the view keeps redrawing while one is on screen.
|
||||
this.animated = true;
|
||||
const build = particleLibrary.buildFor(item.particles);
|
||||
const compiled = build.compiled;
|
||||
const params = compiled ? compiled.params.map((row) => Number(item.props[row.key] ?? row.default)) : [];
|
||||
const texture = item.texture ? textureLibrary.get(item.texture, SHEET_EDGE) : null;
|
||||
field.sync(
|
||||
compiled,
|
||||
item.props,
|
||||
params,
|
||||
item.m,
|
||||
texture ?? Texture.WHITE,
|
||||
particleLibrary.now(),
|
||||
item.emits,
|
||||
hashId(item.id),
|
||||
);
|
||||
}
|
||||
|
||||
private syncSprite(item: DrawItem, onScreen: boolean, size: Size, shaders: ShaderSource): void {
|
||||
let sprite = this.sprites.get(item.id);
|
||||
if (item.type !== 'sprite' || !onScreen) {
|
||||
|
||||
Reference in new issue
Block a user