diff --git a/spx-gui/src/models/animation.ts b/spx-gui/src/models/animation.ts index 486098a13..0346cdd6c 100644 --- a/spx-gui/src/models/animation.ts +++ b/spx-gui/src/models/animation.ts @@ -22,28 +22,29 @@ type ActionConfig = { export const defaultFps = 10 export type AnimationInits = { - builder_id?: string - + id?: string /** Duration for animation to be played once */ duration?: number - onStart?: ActionConfig - - // not supported by builder: - isLoop?: boolean // TODO - onPlay?: ActionConfig + sound?: string } -export type RawAnimationConfig = Omit & { - anitype?: number +export type RawAnimationConfig = { + builder_id?: string frameFrom?: number | string frameTo?: number | string frameFps?: number + onStart?: ActionConfig // legacy APIs, for compatibility only: from?: number | string to?: number | string fps?: number + + // not supported by builder: duration?: number + anitype?: number + isLoop?: boolean + onPlay?: ActionConfig } export class Animation extends Disposable { @@ -68,21 +69,11 @@ export class Animation extends Disposable { return `__animation_${this.name}_` } - withCostumeNamePrefix(name: string) { - return this.costumeNamePrefix + name - } - - private stripCostumeNamePrefix(name: string) { - if (!name.startsWith(this.costumeNamePrefix)) return name - return name.slice(this.costumeNamePrefix.length) - } - costumes: Costume[] // For now, detailed methods to manipulate costumes are not needed, we may implement them later setCostumes(costumes: Costume[]) { for (const costume of costumes) { - let costumeName = this.stripCostumeNamePrefix(costume.name) - costumeName = ensureValidCostumeName(costumeName, this) + const costumeName = ensureValidCostumeName(costume.name, this) costume.setParent(this) costume.setName(costumeName) } @@ -107,12 +98,8 @@ export class Animation extends Disposable { this.name = name this.costumes = [] this.duration = inits?.duration ?? 0 - this.sound = inits?.onStart?.play ?? null - this.id = inits?.builder_id ?? nanoid() - - for (const field of ['isLoop', 'onPlay'] as const) { - if (inits?.[field] != null) console.warn(`unsupported field: ${field} for sprite ${name}`) - } + this.sound = inits?.sound ?? null + this.id = inits?.id ?? nanoid() return reactive(this) as this } @@ -130,43 +117,56 @@ export class Animation extends Disposable { static load( name: string, { + builder_id: id, frameFrom, frameTo, frameFps, from, to, fps, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - duration: _spxDuration, // drop spx `duration`, which is different from ours - ...inits + onStart, + duration: spxDuration, + isLoop, + onPlay, + anitype }: RawAnimationConfig, - sprite: Sprite, + costumes: Costume[], sounds: Sound[] - ) { + ): [animation: Animation, animationCostumeNames: string[]] { frameFrom = frameFrom ?? from frameTo = frameTo ?? to frameFps = frameFps ?? fps if (frameFrom == null || frameTo == null) throw new Error(`from and to expected for Animation ${name}`) - const fromIndex = getCostumeIndex(sprite.costumes, frameFrom) - const toIndex = getCostumeIndex(sprite.costumes, frameTo) - const costumes = sprite.costumes.slice(fromIndex, toIndex + 1) - const duration = costumes.length / (frameFps ?? defaultFps) - const soundId = - inits?.onStart?.play == null ? null : sounds.find((s) => s.name === inits?.onStart?.play)?.id - if (soundId === undefined) { - console.warn(`Sound ${inits?.onStart?.play} not found when creating animation ${name}`) + const fromIndex = getCostumeIndex(costumes, frameFrom) + const toIndex = getCostumeIndex(costumes, frameTo) + const animationCostumes = costumes.slice(fromIndex, toIndex + 1).map((c) => c.clone()) + const duration = animationCostumes.length / (frameFps ?? defaultFps) + // drop spx `duration`, which is different from ours + if (spxDuration != null) console.warn(`unsupported field: duration for animation ${name}`) + if (isLoop != null) console.warn(`unsupported field: isLoop for animation ${name}`) + if (onPlay != null) console.warn(`unsupported field: onPlay for animation ${name}`) + if (anitype != null) console.warn(`unsupported field: anitype for animation ${name}`) + let soundId: string | undefined = undefined + if (onStart?.play != null) { + const sound = sounds.find((s) => s.name === onStart.play) + if (sound == null) + console.warn(`Sound ${onStart.play} not found when creating animation ${name}`) + else soundId = sound.id } const animation = new Animation(name, { - ...inits, + id, duration, - onStart: { play: soundId ?? undefined } + sound: soundId }) - animation.setCostumes(costumes.map((costume) => costume.clone())) - for (const costume of costumes) { - sprite.removeCostume(costume.id) + const animationCostumeNames = animationCostumes.map((c) => c.name) + for (const costume of animationCostumes) { + if (costume.name.startsWith(animation.costumeNamePrefix)) { + costume.setName(costume.name.slice(animation.costumeNamePrefix.length)) + } } - return animation + animation.setCostumes(animationCostumes) + return [animation, animationCostumeNames] } export( @@ -180,7 +180,11 @@ export class Animation extends Disposable { const costumeConfigs: RawCostumeConfig[] = [] const files: Files = {} for (const costume of this.costumes) { - const [costumeConfig, costumeFiles] = costume.export({ basePath, includeId }) + const [costumeConfig, costumeFiles] = costume.export({ + basePath, + includeId, + namePrefix: this.costumeNamePrefix + }) costumeConfigs.push(costumeConfig) Object.assign(files, costumeFiles) } diff --git a/spx-gui/src/models/costume.ts b/spx-gui/src/models/costume.ts index 9e9bce888..28f3f0a51 100644 --- a/spx-gui/src/models/costume.ts +++ b/spx-gui/src/models/costume.ts @@ -1,24 +1,25 @@ import { reactive } from 'vue' +import { nanoid } from 'nanoid' import { extname, resolve } from '@/utils/path' import { adaptImg } from '@/utils/spx' import { Disposable } from '@/utils/disposable' import { File, type Files } from './common/file' import type { Size } from './common' -import type { Sprite } from './sprite' import { getCostumeName, validateCostumeName } from './common/asset-name' -import { Animation } from './animation' -import { nanoid } from 'nanoid' +import type { Sprite } from './sprite' +import type { Animation } from './animation' export type CostumeInits = { - builder_id?: string + id?: string x?: number y?: number faceRight?: number bitmapResolution?: number } -export type RawCostumeConfig = CostumeInits & { +export type RawCostumeConfig = Omit & { + builder_id?: string name?: string path?: string } @@ -98,7 +99,7 @@ export class Costume { this.y = inits?.y ?? 0 this.faceRight = inits?.faceRight ?? 0 this.bitmapResolution = inits?.bitmapResolution ?? 1 - this.id = inits?.builder_id ?? nanoid() + this.id = inits?.id ?? nanoid() return reactive(this) as this } @@ -137,7 +138,7 @@ export class Costume { } static load( - { name, path, ...inits }: RawCostumeConfig, + { builder_id: id, name, path, ...inits }: RawCostumeConfig, files: Files, /** Path of directory which contains the sprite's config file */ basePath: string @@ -146,19 +147,20 @@ export class Costume { if (path == null) throw new Error(`path expected for costume ${name}`) const file = files[resolve(basePath, path)] if (file == null) throw new Error(`file ${path} for costume ${name} not found`) - return new Costume(name, file, inits) + return new Costume(name, file, { ...inits, id }) } export({ basePath, - includeId = true + includeId = true, + namePrefix = '' }: { /** Path of directory which contains the sprite's config file */ basePath: string includeId?: boolean + namePrefix?: string }): [RawCostumeConfig, Files] { - const name = - this.parent instanceof Animation ? this.parent.withCostumeNamePrefix(this.name) : this.name + const name = namePrefix + this.name const filename = name + extname(this.img.name) const config: RawCostumeConfig = { x: this.x, diff --git a/spx-gui/src/models/sound.ts b/spx-gui/src/models/sound.ts index a4a082c4e..de2edc6a2 100644 --- a/spx-gui/src/models/sound.ts +++ b/spx-gui/src/models/sound.ts @@ -8,12 +8,13 @@ import type { Project } from './project' import { nanoid } from 'nanoid' export type SoundInits = { - builder_id?: string + id?: string rate?: number sampleCount?: number } -export type RawSoundConfig = SoundInits & { +export type RawSoundConfig = Omit & { + builder_id?: string path?: string } @@ -56,7 +57,7 @@ export class Sound extends Disposable { this.file = file this.rate = inits?.rate ?? 0 this.sampleCount = inits?.sampleCount ?? 0 - this.id = inits?.builder_id ?? nanoid() + this.id = inits?.id ?? nanoid() return reactive(this) as this } @@ -73,11 +74,11 @@ export class Sound extends Disposable { const pathPrefix = join(soundAssetPath, name) const configFile = files[join(pathPrefix, soundConfigFileName)] if (configFile == null) return null - const { path, ...inits } = (await toConfig(configFile)) as RawSoundConfig + const { builder_id: id, path, ...inits } = (await toConfig(configFile)) as RawSoundConfig if (path == null) throw new Error(`path expected for sound ${name}`) const file = files[resolve(pathPrefix, path)] if (file == null) throw new Error(`file ${path} for sound ${name} not found`) - return new Sound(name, file, inits) + return new Sound(name, file, { ...inits, id }) } static async loadAll(files: Files) { diff --git a/spx-gui/src/models/sprite.test.ts b/spx-gui/src/models/sprite.test.ts index 2e654c400..aeeee5dd2 100644 --- a/spx-gui/src/models/sprite.test.ts +++ b/spx-gui/src/models/sprite.test.ts @@ -4,15 +4,32 @@ import { Animation } from './animation' import { Costume } from './costume' import { File } from './common/file' +function makeCostume(name: string) { + return new Costume(name, new File(`${name}.png`, async () => new ArrayBuffer(0))) +} + describe('Sprite', () => { + it('should export & load animations correctly', async () => { + const sprite = new Sprite('Sprite') + sprite.addCostume(makeCostume('costume1')) + sprite.addCostume(makeCostume('costume2')) + const animation1 = new Animation('animation1') + animation1.setCostumes([makeCostume('costume3'), makeCostume('costume4')]) + const animation2 = new Animation('animation2') + animation2.setCostumes([makeCostume('costume5'), makeCostume('costume6')]) + sprite.addAnimation(animation1) + sprite.addAnimation(animation2) + const exported = sprite.export({ sounds: [] }) + const [loadedSprite] = await Sprite.loadAll(exported, []) + expect(loadedSprite.costumes.map((c) => c.name)).toEqual(['costume1', 'costume2']) + expect(loadedSprite.animations.map((c) => c.name)).toEqual(['animation1', 'animation2']) + expect(loadedSprite.animations[0].costumes.map((c) => c.name)).toEqual(['costume3', 'costume4']) + expect(loadedSprite.animations[1].costumes.map((c) => c.name)).toEqual(['costume5', 'costume6']) + }) it('should handle id-name conversion for animationBindings correctly', async () => { const sprite = new Sprite('Sprite') - const animation = new Animation('animation', { - builder_id: 'animation#' - }) - animation.costumes.push( - new Costume('costume', new File('costume', async () => new ArrayBuffer(0))) - ) + const animation = new Animation('animation') + animation.setCostumes([makeCostume('costume1')]) sprite.addAnimation(animation) sprite.setAnimationBoundStates(animation.id, [State.turn, State.die]) expect(sprite.getAnimationBoundStates(animation.id)).toEqual([State.die, State.turn]) diff --git a/spx-gui/src/models/sprite.ts b/spx-gui/src/models/sprite.ts index 250bb846e..827f6bc57 100644 --- a/spx-gui/src/models/sprite.ts +++ b/spx-gui/src/models/sprite.ts @@ -41,24 +41,27 @@ export type Pivot = { } export type SpriteInits = { - builder_id?: string + id?: string heading?: number x?: number y?: number size?: number rotationStyle?: string costumeIndex?: number - currentCostumeIndex?: number // For compatibility visible?: boolean isDraggable?: boolean - defaultAnimation?: string - animBindings?: Record + animationBindings?: Record pivot?: Pivot } -export type RawSpriteConfig = SpriteInits & { +export type RawSpriteConfig = Omit & { + builder_id?: string + /** Same as costumeIndex, for compatibility only */ + currentCostumeIndex?: number costumes?: RawCostumeConfig[] fAnimations?: Record + defaultAnimation?: string + animBindings?: Record // Not supported by builder: costumeSet?: unknown costumeMPSet?: unknown @@ -220,8 +223,7 @@ export class Sprite extends Disposable { this.addDisposer(() => { this.animations.splice(0).forEach((a) => a.dispose()) }) - // This should be set after the animations are created - this.animationBindings = { + this.animationBindings = inits?.animationBindings ?? { [State.default]: undefined, [State.die]: undefined, [State.step]: undefined, @@ -233,11 +235,11 @@ export class Sprite extends Disposable { this.y = inits?.y ?? 0 this.size = inits?.size ?? 0 this.rotationStyle = getRotationStyle(inits?.rotationStyle) - this.costumeIndex = inits?.costumeIndex ?? inits?.currentCostumeIndex ?? 0 + this.costumeIndex = inits?.costumeIndex ?? 0 this.visible = inits?.visible ?? false this.isDraggable = inits?.isDraggable ?? false this.pivot = inits?.pivot ?? { x: 0, y: 0 } - this.id = inits?.builder_id ?? nanoid() + this.id = inits?.id ?? nanoid() return reactive(this) as this } @@ -293,21 +295,25 @@ export class Sprite extends Disposable { const configFile = files[join(pathPrefix, spriteConfigFileName)] if (configFile == null) return null const { + builder_id: id, + currentCostumeIndex, costumes: costumeConfigs, costumeSet, costumeMPSet, fAnimations: animationConfigs, mAnimations, tAnimations, + defaultAnimation, + animBindings, ...inits } = (await toConfig(configFile)) as RawSpriteConfig const codeFile = files[getSpriteCodeFileName(name)] const code = codeFile != null ? await toText(codeFile) : '' - const sprite = new Sprite(name, code, inits) + + const costumes: Costume[] = [] if (costumeConfigs != null) { - const costumes = (costumeConfigs ?? []).map((c) => Costume.load(c, files, pathPrefix)) - for (const costume of costumes) { - sprite.addCostume(costume) + for (const config of costumeConfigs) { + costumes.push(Costume.load(config, files, pathPrefix)) } } else { if (costumeSet != null) console.warn(`unsupported field: costumeSet for sprite ${name}`) @@ -315,25 +321,41 @@ export class Sprite extends Disposable { console.warn(`unsupported field: costumeMPSet for sprite ${name}`) else console.warn(`no costume found for sprite: ${name}`) } + + const animationCostumeSet = new Set() + const animations: Animation[] = [] if (animationConfigs != null) { - const animations = Object.entries(animationConfigs).map(([name, config]) => - Animation.load(name, config!, sprite, sounds) - ) - const animationNameToId = (name?: string) => - name && animations.find((a) => a.name === name)?.id - sprite.animationBindings = { - [State.default]: animationNameToId(inits?.defaultAnimation), - [State.die]: animationNameToId(inits?.animBindings?.[State.die]), - [State.step]: animationNameToId(inits?.animBindings?.[State.step]), - [State.turn]: animationNameToId(inits?.animBindings?.[State.turn]), - [State.glide]: animationNameToId(inits?.animBindings?.[State.glide]) - } - for (const animation of animations) { - sprite.addAnimation(animation) + for (const [name, config] of Object.entries(animationConfigs)) { + const [animation, animationCostumes] = Animation.load(name, config!, costumes, sounds) + animations.push(animation) + for (const c of animationCostumes) animationCostumeSet.add(c) } } if (mAnimations != null) console.warn(`unsupported field: mAnimations for sprite ${name}`) if (tAnimations != null) console.warn(`unsupported field: tAnimations for sprite ${name}`) + + const animationNameToId = (name?: string) => name && animations.find((a) => a.name === name)?.id + + const sprite = new Sprite(name, code, { + ...inits, + id, + costumeIndex: inits.costumeIndex ?? currentCostumeIndex, + animationBindings: { + [State.default]: animationNameToId(defaultAnimation), + [State.die]: animationNameToId(animBindings?.[State.die]), + [State.step]: animationNameToId(animBindings?.[State.step]), + [State.turn]: animationNameToId(animBindings?.[State.turn]), + [State.glide]: animationNameToId(animBindings?.[State.glide]) + } + }) + for (const costume of costumes) { + // If this costume is included by any animation, exclude it from sprite's costume list + if (animationCostumeSet.has(costume.name)) continue + sprite.addCostume(costume) + } + for (const animation of animations) { + sprite.addAnimation(animation) + } return sprite } diff --git a/spx-gui/src/models/stage.test.ts b/spx-gui/src/models/stage.test.ts index 35434aaf0..b059ff8e7 100644 --- a/spx-gui/src/models/stage.test.ts +++ b/spx-gui/src/models/stage.test.ts @@ -51,7 +51,6 @@ describe('Stage', () => { name: 'monitor', mode: 1, target: '', - color: 15629590, label: 'label1', val: 'getVar:value1', x: 10, @@ -65,7 +64,6 @@ describe('Stage', () => { name: 'monitor2', mode: 1, target: '', - color: 15629590, label: 'label2', val: 'getVar:value2', x: 0, @@ -133,7 +131,6 @@ describe('Stage', () => { name: 'monitor', mode: 1, target: '', - color: 15629590, label: 'label1', val: 'getVar:value1', x: 10, @@ -147,7 +144,6 @@ describe('Stage', () => { name: 'monitor2', mode: 1, target: '', - color: 15629590, label: 'label2', val: 'getVar:value2', x: 0, @@ -177,7 +173,6 @@ describe('Stage', () => { name: 'monitor', mode: 1, target: '', - color: 15629590, label: 'label1', val: 'getVar:value1', x: 10, @@ -191,7 +186,6 @@ describe('Stage', () => { name: 'monitor2', mode: 1, target: '', - color: 15629590, label: 'label2', val: 'getVar:value2', x: 0, diff --git a/spx-gui/src/models/stage.ts b/spx-gui/src/models/stage.ts index 3863b89c6..9e0962f18 100644 --- a/spx-gui/src/models/stage.ts +++ b/spx-gui/src/models/stage.ts @@ -16,7 +16,7 @@ export type StageInits = { backdropIndex: number mapWidth?: number mapHeight?: number - mapMode: MapMode + mapMode?: MapMode } export type RawMapConfig = { @@ -186,14 +186,14 @@ export class Stage extends Disposable { this.backdrops = [] this.backdropIndex = inits?.backdropIndex ?? 0 this.widgets = [] + this.widgetsZorder = [] this.addDisposer(() => { this.widgets.splice(0).forEach((w) => w.dispose()) this.widgetsZorder = [] }) - this.widgetsZorder = [] this.mapWidth = inits?.mapWidth ?? defaultMapSize.width this.mapHeight = inits?.mapHeight ?? defaultMapSize.height - this.mapMode = getMapMode(inits?.mapMode) + this.mapMode = inits?.mapMode ?? MapMode.fillRatio return reactive(this) as this } diff --git a/spx-gui/src/models/widget/monitor.ts b/spx-gui/src/models/widget/monitor.ts index 49d7f7045..b259b2117 100644 --- a/spx-gui/src/models/widget/monitor.ts +++ b/spx-gui/src/models/widget/monitor.ts @@ -1,6 +1,6 @@ import { reactive } from 'vue' import { getWidgetName } from '../common/asset-name' -import { BaseWidget, type BaseWidgetInits } from './widget' +import { BaseWidget, type BaseWidgetInits, type BaseRawWidgetConfig } from './widget' import { defaultMapSize } from '../stage' export type MonitorInits = BaseWidgetInits & { @@ -9,13 +9,12 @@ export type MonitorInits = BaseWidgetInits & { variableName?: string } -export type RawMonitorConfig = Omit & { +export type RawMonitorConfig = BaseRawWidgetConfig & { type: 'monitor' - name?: string mode?: number + label?: string target?: string val?: string - color?: number // TODO: remove me } // There are different modes for monitor, but only `mode: 1` is supported @@ -60,8 +59,8 @@ export class Monitor extends BaseWidget { }) } - static load({ type, name, mode, target, val, ...inits }: RawMonitorConfig) { - if (type !== 'monitor') throw new Error(`unexoected type ${type}`) + static load({ builder_id: id, type, name, mode, target, val, ...inits }: RawMonitorConfig) { + if (type !== 'monitor') throw new Error(`unexpected type ${type}`) if (name == null) throw new Error('name expected for monitor') if (mode !== supportedMode) throw new Error(`unsupported mode: ${mode} for monitor ${name}`) if (target !== supportedTarget) @@ -70,7 +69,7 @@ export class Monitor extends BaseWidget { if (!val.startsWith(prefixForVariable)) throw new Error(`unexpected val: ${val} for monitor ${name}`) const variableName = val.slice(prefixForVariable.length) - return new Monitor(name, { ...inits, variableName }) + return new Monitor(name, { ...inits, id, variableName }) } export(): RawMonitorConfig { @@ -80,8 +79,7 @@ export class Monitor extends BaseWidget { label: this.label, val: `${prefixForVariable}${this.variableName}`, mode: supportedMode, - target: supportedTarget, - color: 15629590 // TODO: remove me + target: supportedTarget } } } diff --git a/spx-gui/src/models/widget/widget.ts b/spx-gui/src/models/widget/widget.ts index 28c504182..f27ffff1c 100644 --- a/spx-gui/src/models/widget/widget.ts +++ b/spx-gui/src/models/widget/widget.ts @@ -5,14 +5,15 @@ import type { Stage } from '../stage' import { nanoid } from 'nanoid' export type BaseWidgetInits = { + id?: string x?: number y?: number size?: number visible?: boolean - builder_id?: string } -export type BaseRawWidgetConfig = BaseWidgetInits & { +export type BaseRawWidgetConfig = Omit & { + builder_id?: string name?: string } @@ -58,13 +59,13 @@ export class BaseWidget extends Disposable { this.y = inits?.y ?? 0 this.size = inits?.size ?? 1 this.visible = inits?.visible ?? false - this.id = inits?.builder_id ?? nanoid() + this.id = inits?.id ?? nanoid() return reactive(this) as this } - static load({ name, ...inits }: BaseRawWidgetConfig) { + static load({ builder_id: id, name, ...inits }: BaseRawWidgetConfig) { if (name == null) throw new Error('name expected for widget') - return new BaseWidget(name, inits) + return new BaseWidget(name, { ...inits, id }) } export(): BaseRawWidgetConfig {