Add SceneManager core with diff-based scene loading and spring animations
Central object model replacing Fabric.js canvas management. Implements diff-based loadScene (reconcile existing/new/removed items), z-ordering, spring-animated add/remove, and TextureManager-backed image loading.
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* SceneManager — central object model replacing Fabric.js canvas object management.
|
||||
*
|
||||
* Owns the SceneItem map, handles diff-based scene loading, z-ordering,
|
||||
* and spring-animated add/remove operations.
|
||||
*/
|
||||
|
||||
import { Container, Sprite, Texture, Graphics, Text, TextStyle } from 'pixi.js';
|
||||
import type { Viewport } from 'pixi-viewport';
|
||||
import { TextureManager, LODTier } from './TextureManager';
|
||||
import { SpringManager, Spring, PRESETS } from './spring';
|
||||
import type {
|
||||
SceneData,
|
||||
AnySceneObject,
|
||||
ImageObject,
|
||||
VideoObject,
|
||||
TextObject,
|
||||
GroupObject,
|
||||
} from './scene-format';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SceneItem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SceneItem {
|
||||
id: string;
|
||||
type: 'image' | 'video' | 'text' | 'group';
|
||||
displayObject: Container;
|
||||
data: AnySceneObject;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SceneManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class SceneManager {
|
||||
readonly items: Map<string, SceneItem> = new Map();
|
||||
readonly viewport: Viewport;
|
||||
readonly textures: TextureManager;
|
||||
readonly springs: SpringManager;
|
||||
|
||||
private _onChange: (() => void) | null = null;
|
||||
private _zCounter: number = 0;
|
||||
|
||||
constructor(viewport: Viewport, textures: TextureManager, springs: SpringManager) {
|
||||
this.viewport = viewport;
|
||||
this.textures = textures;
|
||||
this.springs = springs;
|
||||
}
|
||||
|
||||
// -- onChange callback ----------------------------------------------------
|
||||
|
||||
set onChange(fn: (() => void) | null) {
|
||||
this._onChange = fn;
|
||||
}
|
||||
|
||||
get onChange(): (() => void) | null {
|
||||
return this._onChange;
|
||||
}
|
||||
|
||||
// -- Serialization -------------------------------------------------------
|
||||
|
||||
/** Convert all items to v2 scene format, sorted by z. */
|
||||
serialize(): SceneData {
|
||||
const objects = Array.from(this.items.values())
|
||||
.sort((a, b) => a.data.z - b.data.z)
|
||||
.map((item) => ({ ...item.data }));
|
||||
|
||||
return {
|
||||
v: 2,
|
||||
bg: '',
|
||||
objects,
|
||||
};
|
||||
}
|
||||
|
||||
// -- Scene Loading (diff-based) ------------------------------------------
|
||||
|
||||
/**
|
||||
* Load a scene using diff-based reconciliation:
|
||||
* 1. Remove items not present in incoming data
|
||||
* 2. Update existing items with new properties
|
||||
* 3. Create new items
|
||||
* 4. Apply z-ordering
|
||||
*/
|
||||
async loadScene(scene: SceneData, animate = false): Promise<void> {
|
||||
const incomingById = new Map<string, AnySceneObject>();
|
||||
for (const obj of scene.objects) {
|
||||
incomingById.set(obj.id, obj);
|
||||
}
|
||||
|
||||
// 1. Remove items not in incoming set
|
||||
const toRemove: string[] = [];
|
||||
for (const id of this.items.keys()) {
|
||||
if (!incomingById.has(id)) {
|
||||
toRemove.push(id);
|
||||
}
|
||||
}
|
||||
for (const id of toRemove) {
|
||||
const item = this.items.get(id)!;
|
||||
item.displayObject.destroy({ children: true });
|
||||
this.items.delete(id);
|
||||
}
|
||||
|
||||
// 2 & 3. Update existing, create new
|
||||
const loadPromises: Promise<void>[] = [];
|
||||
|
||||
for (const data of scene.objects) {
|
||||
const existing = this.items.get(data.id);
|
||||
if (existing) {
|
||||
this._updateItem(existing, data);
|
||||
} else {
|
||||
loadPromises.push(this._createItem(data, animate));
|
||||
}
|
||||
|
||||
// Track highest z for nextZ()
|
||||
if (data.z >= this._zCounter) {
|
||||
this._zCounter = data.z + 1;
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(loadPromises);
|
||||
|
||||
// 4. Apply z-ordering
|
||||
this._applyZOrder();
|
||||
|
||||
this._onChange?.();
|
||||
}
|
||||
|
||||
// -- Item Creation -------------------------------------------------------
|
||||
|
||||
/** Create a PixiJS display object from scene data and add it to the viewport. */
|
||||
async _createItem(data: AnySceneObject, animate: boolean): Promise<void> {
|
||||
let displayObject: Container;
|
||||
|
||||
switch (data.type) {
|
||||
case 'image': {
|
||||
const imgData = data as ImageObject;
|
||||
const sprite = new Sprite(Texture.EMPTY);
|
||||
sprite.width = imgData.w;
|
||||
sprite.height = imgData.h;
|
||||
|
||||
// Load texture asynchronously at appropriate LOD
|
||||
const zoom = this.viewport.scale.x;
|
||||
const tier = this.textures.tierForZoom(zoom);
|
||||
this.textures.load(imgData.asset, tier).then((tex) => {
|
||||
if (!sprite.destroyed) {
|
||||
sprite.texture = tex;
|
||||
sprite.width = imgData.w;
|
||||
sprite.height = imgData.h;
|
||||
}
|
||||
});
|
||||
|
||||
displayObject = sprite;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'video': {
|
||||
const vidData = data as VideoObject;
|
||||
const gfx = new Graphics();
|
||||
gfx.rect(0, 0, vidData.w, vidData.h);
|
||||
gfx.fill(0x333333);
|
||||
displayObject = gfx;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'text': {
|
||||
const txtData = data as TextObject;
|
||||
const style = new TextStyle({
|
||||
fontSize: txtData.fontSize,
|
||||
fill: txtData.fill,
|
||||
fontFamily: txtData.fontFamily,
|
||||
});
|
||||
displayObject = new Text({ text: txtData.text, style });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'group': {
|
||||
displayObject = new Container();
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
displayObject = new Container();
|
||||
break;
|
||||
}
|
||||
|
||||
// Common properties
|
||||
displayObject.position.set(data.x, data.y);
|
||||
displayObject.scale.set(data.sx, data.sy);
|
||||
displayObject.angle = data.angle;
|
||||
displayObject.alpha = data.opacity;
|
||||
displayObject.visible = data.visible;
|
||||
displayObject.eventMode = data.locked ? 'none' : 'static';
|
||||
displayObject.label = data.id;
|
||||
displayObject.cursor = 'pointer';
|
||||
|
||||
this.viewport.addChild(displayObject);
|
||||
|
||||
const item: SceneItem = {
|
||||
id: data.id,
|
||||
type: data.type,
|
||||
displayObject,
|
||||
data: { ...data },
|
||||
};
|
||||
|
||||
this.items.set(data.id, item);
|
||||
|
||||
// Animate entrance: spring scale from 0 → 1.05 → 1.0 with fade-in
|
||||
if (animate) {
|
||||
displayObject.scale.set(0, 0);
|
||||
displayObject.alpha = 0;
|
||||
|
||||
const scaleSpring = new Spring(0, 1.05, PRESETS.bounce);
|
||||
scaleSpring.onUpdate = (v) => {
|
||||
if (!displayObject.destroyed) {
|
||||
displayObject.scale.set(v * data.sx, v * data.sy);
|
||||
}
|
||||
};
|
||||
scaleSpring.onComplete = () => {
|
||||
// Second spring: 1.05 → 1.0
|
||||
const settleSpring = new Spring(1.05, 1.0, PRESETS.snappy);
|
||||
settleSpring.onUpdate = (v) => {
|
||||
if (!displayObject.destroyed) {
|
||||
displayObject.scale.set(v * data.sx, v * data.sy);
|
||||
}
|
||||
};
|
||||
this.springs.add(settleSpring);
|
||||
};
|
||||
this.springs.add(scaleSpring);
|
||||
|
||||
const alphaSpring = new Spring(0, data.opacity, PRESETS.gentle);
|
||||
alphaSpring.onUpdate = (v) => {
|
||||
if (!displayObject.destroyed) {
|
||||
displayObject.alpha = v;
|
||||
}
|
||||
};
|
||||
this.springs.add(alphaSpring);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Item Update ---------------------------------------------------------
|
||||
|
||||
/** Update an existing item's display object properties from new data. */
|
||||
_updateItem(item: SceneItem, data: AnySceneObject): void {
|
||||
const obj = item.displayObject;
|
||||
|
||||
obj.position.set(data.x, data.y);
|
||||
obj.scale.set(data.sx, data.sy);
|
||||
obj.angle = data.angle;
|
||||
obj.alpha = data.opacity;
|
||||
obj.visible = data.visible;
|
||||
obj.eventMode = data.locked ? 'none' : 'static';
|
||||
|
||||
// Update stored data
|
||||
item.data = { ...data };
|
||||
item.type = data.type;
|
||||
}
|
||||
|
||||
// -- Z-Ordering ----------------------------------------------------------
|
||||
|
||||
/** Sort items by z and reorder children in the viewport. */
|
||||
_applyZOrder(): void {
|
||||
const sorted = Array.from(this.items.values()).sort(
|
||||
(a, b) => a.data.z - b.data.z,
|
||||
);
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const child = sorted[i].displayObject;
|
||||
if (child.parent === this.viewport) {
|
||||
this.viewport.setChildIndex(child, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Lookup Helpers ------------------------------------------------------
|
||||
|
||||
getById(id: string): SceneItem | undefined {
|
||||
return this.items.get(id);
|
||||
}
|
||||
|
||||
getAllItems(): SceneItem[] {
|
||||
return Array.from(this.items.values());
|
||||
}
|
||||
|
||||
// -- Remove Item ---------------------------------------------------------
|
||||
|
||||
/** Remove an item, optionally animating scale→0.8 + fade out before destroy. */
|
||||
removeItem(id: string, animate = true): void {
|
||||
const item = this.items.get(id);
|
||||
if (!item) return;
|
||||
|
||||
if (!animate) {
|
||||
item.displayObject.destroy({ children: true });
|
||||
this.items.delete(id);
|
||||
this._onChange?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const obj = item.displayObject;
|
||||
|
||||
// Remove from map immediately to prevent double-remove
|
||||
this.items.delete(id);
|
||||
|
||||
const scaleSpring = new Spring(1.0, 0.8, PRESETS.snappy);
|
||||
scaleSpring.onUpdate = (v) => {
|
||||
if (!obj.destroyed) {
|
||||
obj.scale.set(v * (item.data.sx), v * (item.data.sy));
|
||||
}
|
||||
};
|
||||
this.springs.add(scaleSpring);
|
||||
|
||||
const alphaSpring = new Spring(obj.alpha, 0, PRESETS.snappy);
|
||||
alphaSpring.onUpdate = (v) => {
|
||||
if (!obj.destroyed) {
|
||||
obj.alpha = Math.max(0, v);
|
||||
}
|
||||
};
|
||||
alphaSpring.onComplete = () => {
|
||||
if (!obj.destroyed) {
|
||||
obj.destroy({ children: true });
|
||||
}
|
||||
};
|
||||
this.springs.add(alphaSpring);
|
||||
|
||||
this._onChange?.();
|
||||
}
|
||||
|
||||
// -- Z Counter -----------------------------------------------------------
|
||||
|
||||
/** Increment and return the next z value. */
|
||||
nextZ(): number {
|
||||
return this._zCounter++;
|
||||
}
|
||||
|
||||
// -- Convenience: Add Image From Upload ----------------------------------
|
||||
|
||||
/** Create an ImageObject from an upload and add it to the scene with animation. */
|
||||
addImageFromUpload(
|
||||
assetKey: string,
|
||||
w: number,
|
||||
h: number,
|
||||
x: number,
|
||||
y: number,
|
||||
): SceneItem {
|
||||
const data: ImageObject = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'image',
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
sx: 1,
|
||||
sy: 1,
|
||||
angle: 0,
|
||||
z: this.nextZ(),
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
name: '',
|
||||
visible: true,
|
||||
asset: assetKey,
|
||||
filters: [],
|
||||
};
|
||||
|
||||
// Fire-and-forget the async creation (animation handles visual feedback)
|
||||
this._createItem(data, true);
|
||||
|
||||
this._applyZOrder();
|
||||
this._onChange?.();
|
||||
|
||||
return this.items.get(data.id)!;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user