diff --git a/spx-gui/src/components/asset/gen/animation/AnimationVideoPreview.vue b/spx-gui/src/components/asset/gen/animation/AnimationVideoPreview.vue index 9da0b8e2a9..ad5360d986 100644 --- a/spx-gui/src/components/asset/gen/animation/AnimationVideoPreview.vue +++ b/spx-gui/src/components/asset/gen/animation/AnimationVideoPreview.vue @@ -17,9 +17,8 @@ function useVideoPlayer(videoRef: Ref, rangeRef: WatchS /** Latest requested preview seek time in ms while a browser seek may still be in flight. */ let pendingSeekTime: number | null = null - function flushPendingSeek() { - const video = videoRef.value - if (video == null || pendingSeekTime == null || video.seeking) return + function flushPendingSeek(video: HTMLVideoElement) { + if (pendingSeekTime == null) return const nextTime = pendingSeekTime pendingSeekTime = null video.currentTime = nextTime / 1000 @@ -29,7 +28,9 @@ function useVideoPlayer(videoRef: Ref, rangeRef: WatchS const nextTime = Math.max(0, timeInMs) currentTime.value = nextTime pendingSeekTime = nextTime - flushPendingSeek() + const video = videoRef.value + if (video == null || video.seeking) return + flushPendingSeek(video) } function pausePlayback() { @@ -67,21 +68,19 @@ function useVideoPlayer(videoRef: Ref, rangeRef: WatchS { immediate: true } ) - function handleLoadedMetadata() { - const video = videoRef.value - if (video == null) return - duration.value = Number.isFinite(video.duration) ? Math.round(video.duration * 1000) : 0 - } - watch( videoRef, (video, _, onCleanup) => { if (video == null) return + const handleLoadedMetadata = () => { + duration.value = Number.isFinite(video.duration) ? Math.round(video.duration * 1000) : 0 + } video.addEventListener('loadedmetadata', handleLoadedMetadata) - video.addEventListener('seeked', flushPendingSeek) + const handleSeeked = () => flushPendingSeek(video) + video.addEventListener('seeked', handleSeeked) onCleanup(() => { video.removeEventListener('loadedmetadata', handleLoadedMetadata) - video.removeEventListener('seeked', flushPendingSeek) + video.removeEventListener('seeked', handleSeeked) }) }, { immediate: true } diff --git a/spx-gui/src/components/editor/common/PivotMarker.vue b/spx-gui/src/components/editor/common/PivotMarker.vue new file mode 100644 index 0000000000..d054b8ad55 --- /dev/null +++ b/spx-gui/src/components/editor/common/PivotMarker.vue @@ -0,0 +1,99 @@ + + + diff --git a/spx-gui/src/components/editor/common/viewer/SpriteNode.vue b/spx-gui/src/components/editor/common/viewer/SpriteNode.vue index 1a3b1c3dc3..c7d5bb123e 100644 --- a/spx-gui/src/components/editor/common/viewer/SpriteNode.vue +++ b/spx-gui/src/components/editor/common/viewer/SpriteNode.vue @@ -1,6 +1,7 @@ @@ -87,9 +91,26 @@ const handleEditCollision = useMessageHandle(
{{ $t({ en: 'Physics', zh: '物理特性' }) }}
-
-
{{ $t({ en: 'Collision settings', zh: '碰撞设置' }) }}
- +
+ + {{ + $t({ + en: `Set the pivot point of the sprite${isCollisionEditingEnabled ? ' and adjust the collision area' : ''}`, + zh: `设置精灵的坐标基准${isCollisionEditingEnabled ? ',并调整可发生碰撞的范围' : ''}` + }) + }} + + +
diff --git a/spx-gui/src/components/editor/map-editor/map-viewer/MapViewer.vue b/spx-gui/src/components/editor/map-editor/map-viewer/MapViewer.vue index ea450f03be..a102b2aa94 100644 --- a/spx-gui/src/components/editor/map-editor/map-viewer/MapViewer.vue +++ b/spx-gui/src/components/editor/map-editor/map-viewer/MapViewer.vue @@ -548,6 +548,7 @@ const handleWheel = (e: KonvaEventObject) => { :selected="selectedSprite?.id === localConfig.id" :project="props.project" :map-size="mapSize" + :map-scale="mapScale" :node-ready-map="nodeReadyMap" @drag-move="handleSpriteDragMove" @drag-end="handleSpriteDragEnd" diff --git a/spx-gui/src/components/editor/sprite/SpriteCollisionEditor.vue b/spx-gui/src/components/editor/sprite/PivotCollisionEditor.vue similarity index 77% rename from spx-gui/src/components/editor/sprite/SpriteCollisionEditor.vue rename to spx-gui/src/components/editor/sprite/PivotCollisionEditor.vue index 600fb0bec8..59145a24d7 100644 --- a/spx-gui/src/components/editor/sprite/SpriteCollisionEditor.vue +++ b/spx-gui/src/components/editor/sprite/PivotCollisionEditor.vue @@ -7,7 +7,6 @@ import type { KonvaEventObject } from 'konva/lib/Node' import type { GroupConfig } from 'konva/lib/Group' import type { LayerConfig } from 'konva/lib/Layer' import type { Rect, RectConfig } from 'konva/lib/shapes/Rect' -import type { CircleConfig } from 'konva/lib/shapes/Circle' import { useAsyncComputed } from '@/utils/utils' import { useI18n } from '@/utils/i18n' import { useFileImg } from '@/utils/file' @@ -17,15 +16,15 @@ import { toNativeFile } from '@/models/common/file' import { CollisionShapeType, type Sprite } from '@/models/spx/sprite' import type { Pivot as CostumePivot } from '@/models/spx/costume' import type { CustomTransformer, CustomTransformerConfig } from '../common/viewer/custom-transformer' +import PivotMarker from '../common/PivotMarker.vue' import CheckerboardBackground from './CheckerboardBackground.vue' import { UIButton } from '@/components/ui' import { useMessageHandle } from '@/utils/exception' -import type { SpxProject } from '@/models/spx/project' import { useEditorCtx } from '../EditorContextProvider.vue' const props = defineProps<{ - project: SpxProject sprite: Sprite + enableCollisionEditing: boolean }>() const emits = defineEmits<{ @@ -164,19 +163,6 @@ function handlePivotCircleGroupDragEnd(e: KonvaEventObject) { pivotPos.value = { x: e.target.x(), y: e.target.y() } } -const pivotCircleConfig = computed( - () => - ({ - radius: 8, - fill: 'rgba(10, 165, 190, 1)', - stroke: '#fff', - strokeWidth: 2, - shadowColor: 'rgba(51, 51, 51, 0.2)', - shadowBlur: 4, - shadowOffset: { x: 0, y: 2 } - }) satisfies CircleConfig -) - const pivotTitleConfig = computed( () => ({ @@ -265,27 +251,49 @@ const { fn: handleConfirm } = useMessageHandle( const defaultCostume = props.sprite.defaultCostume if (defaultCostume == null) throw new Error('Sprite has no default costume') - await editorCtx.state.history.doAction({ name: { en: 'Update sprite collision', zh: '更新精灵碰撞' } }, () => { - sprite.applyCostumesPivotChange({ - x: pivotPos.value.x - defaultCostume.pivot.x, - y: pivotPos.value.y - defaultCostume.pivot.y - }) - sprite.setCollisionPivot({ - x: colliderPos.value.x + colliderSize.value.width / 2 - pivotPos.value.x, - y: -(colliderPos.value.y + colliderSize.value.height / 2 - pivotPos.value.y) - }) - sprite.setCollisionShapeRect(colliderSize.value.width, colliderSize.value.height) - }) + await editorCtx.state.history.doAction( + { + name: props.enableCollisionEditing + ? { en: 'Update sprite pivot and collision', zh: '更新精灵参考点和碰撞体' } + : { en: 'Update sprite pivot', zh: '更新精灵参考点' } + }, + () => { + const dPivot = { + x: pivotPos.value.x - defaultCostume.pivot.x, + y: pivotPos.value.y - defaultCostume.pivot.y + } + sprite.applyCostumesPivotChange(dPivot) + + if (!props.enableCollisionEditing) { + // Even when collision editing is hidden, keep any persisted collision pivot aligned + // with the artwork after the sprite pivot moves. This intentionally includes Auto + // shapes as well, since Auto still carries a stored collisionPivot even though its + // bounds are derived from costume content. + if (sprite.collisionShapeType !== CollisionShapeType.None) { + sprite.setCollisionPivot({ + x: sprite.collisionPivot.x - dPivot.x, + y: sprite.collisionPivot.y + dPivot.y + }) + } + return + } + sprite.setCollisionPivot({ + x: colliderPos.value.x + colliderSize.value.width / 2 - pivotPos.value.x, + y: -(colliderPos.value.y + colliderSize.value.height / 2 - pivotPos.value.y) + }) + sprite.setCollisionShapeRect(colliderSize.value.width, colliderSize.value.height) + } + ) dirty.value = false emits('updateSuccess') }, { - en: 'Failed to update sprite collision', - zh: '更新精灵碰撞失败' + en: 'Failed to update sprite pivot or collision', + zh: '更新精灵参考点或碰撞体失败' }, { - en: 'Save sprite collision successfully', - zh: '更新精灵碰撞成功' + en: 'Save sprite pivot or collision successfully', + zh: '更新精灵参考点或碰撞体成功' } ) @@ -297,25 +305,30 @@ const { fn: handleConfirm } = useMessageHandle( - - - + + - +import type { Sprite } from '@/models/spx/sprite' +import { UIFormModal } from '@/components/ui' +import PivotCollisionEditor from './PivotCollisionEditor.vue' + +defineProps<{ + sprite: Sprite + enableCollisionEditing: boolean + visible: boolean +}>() + +const emit = defineEmits<{ + cancelled: [] + resolved: [] +}>() + + + diff --git a/spx-gui/src/components/editor/sprite/SpriteCollisionEditorModal.vue b/spx-gui/src/components/editor/sprite/SpriteCollisionEditorModal.vue deleted file mode 100644 index a6178289aa..0000000000 --- a/spx-gui/src/components/editor/sprite/SpriteCollisionEditorModal.vue +++ /dev/null @@ -1,39 +0,0 @@ - - - diff --git a/spx-gui/src/models/spx/gen/sprite-gen.test.ts b/spx-gui/src/models/spx/gen/sprite-gen.test.ts index a8cbb93c0f..580ce82e68 100644 --- a/spx-gui/src/models/spx/gen/sprite-gen.test.ts +++ b/spx-gui/src/models/spx/gen/sprite-gen.test.ts @@ -4,10 +4,11 @@ import { ArtStyle, Perspective, SpriteCategory } from '@/apis/common' import { setupAigcMock } from './aigc-mock' // Put me before importing `@/apis/aigc` to ensure the mock is set up correctly import { TaskStatus } from '@/apis/aigc' import { createI18n } from '@/utils/i18n' +import * as imgHelpers from '@/utils/img' import * as fileHelpers from '@/models/common/file' import { sndFiles } from '@/models/common/test' import { GenState } from '@/components/editor/gen' -import { RotationStyle, State } from '../sprite' +import { CollisionShapeType, RotationStyle, State } from '../sprite' import { makeSpxProject } from '../common/test' import type { CostumeGen } from './costume-gen' import type { AnimationGen } from './animation-gen' @@ -146,6 +147,7 @@ describe('SpriteGen', () => { expect(sprite.animations[1].name).toBe('jump') expect(sprite.getAnimationBoundStates(sprite.animations[0].id)).toEqual([State.Default]) expect(sprite.getAnimationBoundStates(sprite.animations[1].id)).toEqual([State.Step]) + expect(sprite.collisionShapeType).toBe(CollisionShapeType.None) }) it('should validate sprite name when parent is set', () => { @@ -347,6 +349,55 @@ describe('SpriteGen', () => { expect(sprite3.rotationStyle).toBe(RotationStyle.Normal) }) + it('should infer feet pivot and auto collision for generated character sprites', async () => { + const getContentBoundingRect = vi.spyOn(imgHelpers, 'getContentBoundingRect').mockResolvedValue({ + x: 10, + y: 8, + width: 20, + height: 30 + }) + const toNativeFile = vi + .spyOn(fileHelpers, 'toNativeFile') + .mockResolvedValue(new File(['sprite'], 'sprite.png', { type: 'image/png' })) + + try { + const project = makeSpxProject() + const gen = new SpriteGen(createI18n({ lang: 'en' }), project, 'A brave runner') + + await gen.enrich() + gen.setSettings({ + name: 'Runner', + category: SpriteCategory.Character, + perspective: Perspective.SideScrolling + }) + await gen.genImages() + gen.setImageIndex(0) + await gen.prepareContent() + + for (const costumeGen of gen.costumes.slice(1)) { + await finishCostumeGen(costumeGen.name, costumeGen) + } + for (const animationGen of gen.animations) { + await finishAnimationGen(animationGen.name, animationGen) + } + + const sprite = gen.finish() + expect(sprite.collisionShapeType).toBe(CollisionShapeType.Auto) + expect(sprite.defaultCostume?.pivot).toEqual({ x: 10, y: 19 }) + expect(sprite.costumes.map((costume) => costume.pivot)).toEqual([ + { x: 10, y: 19 }, + { x: 10, y: 19 }, + { x: 10, y: 19 } + ]) + for (const animation of sprite.animations) { + expect(animation.costumes.every((costume) => costume.pivot.x === 10 && costume.pivot.y === 19)).toBe(true) + } + } finally { + toNativeFile.mockRestore() + getContentBoundingRect.mockRestore() + } + }) + describe('export/load', () => { function getPreviewSpriteContentSnapshot(gen: SpriteGen) { const previewSprite = gen.previewSprite diff --git a/spx-gui/src/models/spx/gen/sprite-gen.ts b/spx-gui/src/models/spx/gen/sprite-gen.ts index d1b6b8f1cc..f9c3a2a87e 100644 --- a/spx-gui/src/models/spx/gen/sprite-gen.ts +++ b/spx-gui/src/models/spx/gen/sprite-gen.ts @@ -4,6 +4,7 @@ import type { Prettify } from '@/utils/types' import { extname } from '@/utils/path' import { Disposable } from '@/utils/disposable' import type { I18n, LocaleMessage } from '@/utils/i18n' +import { getContentBoundingRect } from '@/utils/img' import { ArtStyle, Perspective, SpriteCategory } from '@/apis/common' import { enrichSpriteSettings, @@ -17,15 +18,15 @@ import { adoptAsset } from '@/apis/aigc' import { SpxProject } from '../project' -import { RotationStyle, Sprite, State } from '../sprite' -import { Costume } from '../costume' +import { CollisionShapeType, RotationStyle, Sprite, State } from '../sprite' +import { Costume, type Pivot as CostumePivot } from '../costume' import type { Animation } from '../animation' import { getProjectSettings, mapPhaseResult, Phase, Task, type PhaseSerialized, type TaskSerialized } from './common' import { CostumeGen, type RawCostumeGenConfig } from './costume-gen' import { AnimationGen, type RawAnimationGenConfig } from './animation-gen' import { createFileWithUniversalUrl } from '../../common/cloud' import type { File, Files } from '../../common/file' -import { fromConfig, toConfig, listDirs } from '../../common/file' +import { fromConfig, toConfig, listDirs, toNativeFile } from '../../common/file' import { ensureValidSpriteName, getAnimationName, @@ -55,6 +56,7 @@ export type SpriteGenInits = { imageIndex?: number | null selectedItem?: SpriteGenSelected | null animationGenIdBindings?: Partial> + inferredPivotDelta?: CostumePivot | null enrichPhase?: Phase genImagesTask?: Task genImagesPhase?: Phase @@ -86,6 +88,7 @@ export class SpriteGen extends Disposable { private genImagesPhase: Phase private prepareContentPhase: Phase private animationGenIdBindings: Partial> = {} + private inferredPivotDelta: CostumePivot | null = null constructor(i18n: I18n, project: SpxProject, inits: SpriteGenInits | string = {}) { super() @@ -102,6 +105,7 @@ export class SpriteGen extends Disposable { this.costumes = inits.costumes ?? [] this.animations = inits.animations ?? [] this.animationGenIdBindings = inits.animationGenIdBindings ?? {} + this.inferredPivotDelta = inits.inferredPivotDelta ?? null this.settings = { name: '', category: SpriteCategory.Unspecified, @@ -126,15 +130,26 @@ export class SpriteGen extends Disposable { /** Create a sprite instance based on current settings. */ private createSprite() { - const { name, perspective } = this.settings + const { name, perspective, category } = this.settings return Sprite.create(name, '', { - rotationStyle: rotationStyleForPerspective(perspective) - // TODO: provide more initial settings when generated - // e.g., place the pivot at the feet for character sprites in side-scrolling or angled-top-down perspectives. - // For more details, see: https://github.com/goplus/builder/issues/2785 + rotationStyle: rotationStyleForPerspective(perspective), + collisionShapeType: collisionShapeTypeForCategory(category) }) } + // Pivot belongs to costumes rather than sprite, so unlike rotation/collision defaults we can only + // infer it after the generated default costume image is available. + private async inferPivotDelta(costume: Costume): Promise { + if (!shouldUseFeetPivot(this.settings.category, this.settings.perspective)) return null + if (costume.bitmapResolution <= 0) return null + const rect = await getContentBoundingRect(await toNativeFile(costume.img)) + if (rect.width <= 0 || rect.height <= 0) return null + return { + x: (rect.x + rect.width / 2) / costume.bitmapResolution - costume.pivot.x, + y: (rect.y + rect.height) / costume.bitmapResolution - costume.pivot.y + } + } + private parent: SpriteLikeParent | null = null setParent(parent: SpriteLikeParent | null) { this.parent = parent @@ -317,6 +332,9 @@ export class SpriteGen extends Disposable { defaultCostumeGen.setImage(image) const defaultCostume = await defaultCostumeGen.finish() this.costumes.push(defaultCostumeGen) + // Pre-compute the inferred delta here so `finish()` can stay synchronous while still using image analysis. + // We also persist this value to survive export/load before the sprite is finally adopted. + this.inferredPivotDelta = await this.inferPivotDelta(defaultCostume) // Generate additional costumes & animations const settings = await generateSpriteContentSettings(this.settings, this.i18n.lang.value) @@ -438,6 +456,11 @@ export class SpriteGen extends Disposable { sprite.addAnimation(animation) sprite.setAnimationBoundStates(animation.id, boundStates) } + if (this.inferredPivotDelta != null) { + // Apply once on the final sprite so all generated costumes, including animation frames, + // receive the same inferred pivot adjustment. + sprite.applyCostumesPivotChange(this.inferredPivotDelta) + } sprite.setAssetMetadata({ description: this.settings.description, extraSettings: { @@ -500,6 +523,7 @@ export class SpriteGen extends Disposable { imageIndex, selectedItem, animationGenIdBindings, + inferredPivotDelta, enrichPhaseSerialized, genImagesTaskSerialized, genImagesPhaseSerialized, @@ -517,6 +541,7 @@ export class SpriteGen extends Disposable { if (imageIndex != null) inits.imageIndex = imageIndex if (selectedItem != null) inits.selectedItem = selectedItem if (animationGenIdBindings != null) inits.animationGenIdBindings = animationGenIdBindings + if (isFiniteCostumePivot(inferredPivotDelta)) inits.inferredPivotDelta = inferredPivotDelta if (enrichPhaseSerialized != null) inits.enrichPhase = Phase.load(enrichPhaseSerialized) if (genImagesTaskSerialized != null) inits.genImagesTask = Task.load(genImagesTaskSerialized) if (genImagesPhaseSerialized != null) { @@ -566,6 +591,7 @@ export class SpriteGen extends Disposable { id: this.id, settings: this.settings, animationGenIdBindings: this.animationGenIdBindings, + inferredPivotDelta: this.inferredPivotDelta, enrichPhaseSerialized: this.enrichPhase.export(), genImagesTaskSerialized: this.genImagesTask?.export(), genImagesPhaseSerialized: mapPhaseResult(this.genImagesPhase.export(), (result) => @@ -620,3 +646,32 @@ function rotationStyleForPerspective(perspective: Perspective): RotationStyle { return RotationStyle.Normal } } + +function collisionShapeTypeForCategory(category: SpriteCategory): CollisionShapeType { + switch (category) { + case SpriteCategory.Character: + return CollisionShapeType.Auto + // The `Item` category is too broad to decide whether generated items should behave + // like collectible props, physical obstacles, or purely decorative assets, so this + // remains a product decision instead of an automatic default. + case SpriteCategory.Item: + case SpriteCategory.Effect: + case SpriteCategory.UI: + case SpriteCategory.Unspecified: + default: + return CollisionShapeType.None + } +} + +function shouldUseFeetPivot(category: SpriteCategory, perspective: Perspective) { + return ( + category === SpriteCategory.Character && + [Perspective.SideScrolling, Perspective.AngledTopDown].includes(perspective) + ) +} + +function isFiniteCostumePivot(value: unknown): value is CostumePivot { + if (typeof value !== 'object' || value == null || Array.isArray(value)) return false + const { x, y } = value as Partial> + return typeof x === 'number' && Number.isFinite(x) && typeof y === 'number' && Number.isFinite(y) +}