From 79ae2a1c927f8631fb59552480d6de954b58ef89 Mon Sep 17 00:00:00 2001 From: Hanxing Yang Date: Mon, 25 May 2026 14:32:36 +0800 Subject: [PATCH 1/6] Support finer backdrop control with pivot and actual size (#3201) * Support backdrop mode: actualSize & pivot * Fix pivot for backdrop & costume when loading from Scratch --- .../asset/scratch/LoadFromScratch.vue | 36 +++-- .../components/editor/common/viewer/common.ts | 3 +- .../map-editor/map-viewer/MapViewer.vue | 33 +++-- .../preview/stage-viewer/StageViewer.vue | 33 +++-- .../stage/backdrop/BackdropModeSelector.vue | 70 ++++++---- spx-gui/src/models/common/file.ts | 4 +- spx-gui/src/models/spx/backdrop.test.ts | 128 +++++++++++++++++- spx-gui/src/models/spx/backdrop.ts | 88 +++++++++++- spx-gui/src/models/spx/common/asset.ts | 45 +----- spx-gui/src/models/spx/costume.ts | 5 +- .../src/models/spx/gen/backdrop-gen.test.ts | 3 + spx-gui/src/models/spx/gen/backdrop-gen.ts | 4 +- .../src/models/spx/gen/costume-gen.test.ts | 1 + spx-gui/src/models/spx/gen/sprite-gen.test.ts | 1 + spx-gui/src/models/spx/stage.test.ts | 24 +++- spx-gui/src/models/spx/stage.ts | 10 +- spx-gui/src/utils/file.ts | 4 + spx-gui/src/utils/img.ts | 22 ++- 18 files changed, 394 insertions(+), 120 deletions(-) diff --git a/spx-gui/src/components/asset/scratch/LoadFromScratch.vue b/spx-gui/src/components/asset/scratch/LoadFromScratch.vue index b9c90bb4d7..3d5f1f6fbe 100644 --- a/spx-gui/src/components/asset/scratch/LoadFromScratch.vue +++ b/spx-gui/src/components/asset/scratch/LoadFromScratch.vue @@ -69,6 +69,8 @@ import { Backdrop } from '@/models/spx/backdrop' import { Costume } from '@/models/spx/costume' import { fromBlob } from '@/models/common/file' import { useMessageHandle } from '@/utils/exception' +import { isSvgMimeType } from '@/utils/file' +import { getSVGViewBoxRect } from '@/utils/img' import type { ExportedScratchCostume, ExportedScratchSound, ExportedScratchSprite } from '@/utils/scratch' import { type SpxProject } from '@/models/spx/project' import type { AssetModel } from '@/models/spx/common/asset' @@ -131,17 +133,33 @@ const selectBackdrop = (backdrop: ExportedScratchCostume) => { const scratchToSpxFile = (scratchFile: ExportedScratchFile) => { return fromBlob(`${scratchFile.name}.${scratchFile.extension}`, scratchFile.blob) } + +async function getPivotFromScratchCostume(asset: ExportedScratchCostume) { + let x = asset.rotationCenterX + let y = asset.rotationCenterY + // Scratch stores SVG rotation centers in the SVG viewBox coordinate space. + // Builder/SPX uses image-top-left as pivot origin, so subtract viewBox origin for SVG. + if (isSvgMimeType(asset.blob.type)) { + const svgText = await asset.blob.text() + const viewBoxRect = getSVGViewBoxRect(svgText) + x -= viewBoxRect.x + y -= viewBoxRect.y + } + return { + x: x / asset.bitmapResolution, + y: y / asset.bitmapResolution + } +} + const importSprite = async (asset: ExportedScratchSprite) => { const costumes = await Promise.all( - asset.costumes.map((costume) => - Costume.create(costume.name, scratchToSpxFile(costume), { + asset.costumes.map(async (costume) => { + const pivot = await getPivotFromScratchCostume(costume) + return Costume.create(costume.name, scratchToSpxFile(costume), { bitmapResolution: costume.bitmapResolution, - pivot: { - x: costume.rotationCenterX / costume.bitmapResolution, - y: costume.rotationCenterY / costume.bitmapResolution - } + pivot }) - ) + }) ) const sprite = Sprite.create(asset.name) for (const costume of costumes) { @@ -161,8 +179,10 @@ const importSound = async (asset: ExportedScratchSound) => { const importBackdrop = async (asset: ExportedScratchCostume) => { const file = scratchToSpxFile(asset) + const pivot = await getPivotFromScratchCostume(asset) const backdrop = await Backdrop.create(asset.name, file, { - bitmapResolution: asset.bitmapResolution + bitmapResolution: asset.bitmapResolution, + pivot }) props.project.stage.addBackdrop(backdrop) return backdrop diff --git a/spx-gui/src/components/editor/common/viewer/common.ts b/spx-gui/src/components/editor/common/viewer/common.ts index 161604f91c..d9fbdbb93f 100644 --- a/spx-gui/src/components/editor/common/viewer/common.ts +++ b/spx-gui/src/components/editor/common/viewer/common.ts @@ -1,6 +1,7 @@ +import type { KonvaEventObject } from 'konva/lib/Node' + import { Sprite } from '@/models/spx/sprite' import type { Widget } from '@/models/spx/widget' -import type { KonvaEventObject } from 'konva/lib/Node' import { SpriteLocalConfig, WidgetLocalConfig } from './quick-config/utils' 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..f961ceb82b 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 @@ -250,11 +250,16 @@ watchEffect(() => { }) }) -const konvaBackdropConfig = computed(() => { +const konvaBackdropRectConfig = computed(() => { if (backdropImg.value == null || stageConfig.value == null) { return null } + const backdrop = props.project.stage.defaultBackdrop + if (backdrop == null) { + return null + } + const stageWidth = mapSize.value.width const stageHeight = mapSize.value.height const imageWidth = backdropImg.value.width @@ -281,18 +286,28 @@ const konvaBackdropConfig = computed(() => { fillPatternScaleY: scale } } else if (props.project.stage.mapMode === MapMode.repeat) { - const offsetX = (stageWidth - imageWidth) / 2 - const offsetY = (stageHeight - imageHeight) / 2 - + const patternScale = 1 / backdrop.bitmapResolution return { fillPatternImage: backdropImg.value, width: stageWidth, height: stageHeight, fillPatternRepeat: 'repeat', - fillPatternX: offsetX, - fillPatternY: offsetY, - fillPatternScaleX: 1, - fillPatternScaleY: 1 + fillPatternX: 0, + fillPatternY: 0, + fillPatternScaleX: patternScale, + fillPatternScaleY: patternScale + } + } else if (props.project.stage.mapMode === MapMode.actualSize) { + const patternScale = 1 / backdrop.bitmapResolution + return { + fillPatternImage: backdropImg.value, + width: stageWidth, + height: stageHeight, + fillPatternRepeat: 'no-repeat', + fillPatternX: stageWidth / 2 - backdrop.pivot.x, + fillPatternY: stageHeight / 2 - backdrop.pivot.y, + fillPatternScaleX: patternScale, + fillPatternScaleY: patternScale } } console.warn('Unsupported map mode:', props.project.stage.mapMode) @@ -532,7 +547,7 @@ const handleWheel = (e: KonvaEventObject) => { > - + - + { }) }) -const konvaBackdropConfig = computed(() => { +const konvaBackdropRectConfig = computed(() => { if (backdropImg.value == null || stageConfig.value == null) { return null } + const backdrop = editorCtx.project.stage.defaultBackdrop + if (backdrop == null) { + return null + } + const stageWidth = mapSize.value.width const stageHeight = mapSize.value.height const imageWidth = backdropImg.value.width @@ -330,18 +335,28 @@ const konvaBackdropConfig = computed(() => { fillPatternScaleY: scale } } else if (editorCtx.project.stage.mapMode === MapMode.repeat) { - const offsetX = (stageWidth - imageWidth) / 2 - const offsetY = (stageHeight - imageHeight) / 2 - + const patternScale = 1 / backdrop.bitmapResolution return { fillPatternImage: backdropImg.value, width: stageWidth, height: stageHeight, fillPatternRepeat: 'repeat', - fillPatternX: offsetX, - fillPatternY: offsetY, - fillPatternScaleX: 1, - fillPatternScaleY: 1 + fillPatternX: 0, + fillPatternY: 0, + fillPatternScaleX: patternScale, + fillPatternScaleY: patternScale + } + } else if (editorCtx.project.stage.mapMode === MapMode.actualSize) { + const patternScale = 1 / backdrop.bitmapResolution + return { + fillPatternImage: backdropImg.value, + width: stageWidth, + height: stageHeight, + fillPatternRepeat: 'no-repeat', + fillPatternX: stageWidth / 2 - backdrop.pivot.x, + fillPatternY: stageHeight / 2 - backdrop.pivot.y, + fillPatternScaleX: patternScale, + fillPatternScaleY: patternScale } } console.warn('Unsupported map mode:', editorCtx.project.stage.mapMode) diff --git a/spx-gui/src/components/editor/stage/backdrop/BackdropModeSelector.vue b/spx-gui/src/components/editor/stage/backdrop/BackdropModeSelector.vue index b7284c0075..2536c9435d 100644 --- a/spx-gui/src/components/editor/stage/backdrop/BackdropModeSelector.vue +++ b/spx-gui/src/components/editor/stage/backdrop/BackdropModeSelector.vue @@ -1,26 +1,7 @@ @@ -87,9 +102,41 @@ const handleEditCollision = useMessageHandle(
{{ $t({ en: 'Physics', zh: '物理特性' }) }}
-
-
{{ $t({ en: 'Collision settings', zh: '碰撞设置' }) }}
- +
+
+ {{ + $t({ + en: `Pivot${isCollisionEditingEnabled ? ' and collision' : ''}`, + 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 f961ceb82b..370ced744d 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 @@ -563,6 +563,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" @@ -582,7 +583,7 @@ const handleWheel = (e: KonvaEventObject) => {
- +
diff --git a/spx-gui/src/components/editor/sprite/SpriteCollisionEditor.vue b/spx-gui/src/components/editor/sprite/PivotCollisionEditor.vue similarity index 71% rename from spx-gui/src/components/editor/sprite/SpriteCollisionEditor.vue rename to spx-gui/src/components/editor/sprite/PivotCollisionEditor.vue index 600fb0bec8..0337541686 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 { getPivotMarkerConfigs } from '../common/pivot-marker' 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 + collisionEditingEnabled: boolean }>() const emits = defineEmits<{ @@ -160,23 +159,12 @@ const pivotGroupConfig = computed(() => { } satisfies GroupConfig }) +const pivotMarkerConfigs = getPivotMarkerConfigs({ interactive: true }) + 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( () => ({ @@ -259,33 +247,73 @@ function handleColliderRectTransformEnd(e: KonvaEventObject) { node.scaleY(1) } -const { fn: handleConfirm } = useMessageHandle( +const { fn: savePivotAndCollision } = useMessageHandle( async () => { const sprite = props.sprite 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({ + await editorCtx.state.history.doAction( + { name: { en: 'Update sprite pivot and 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) + } + ) + dirty.value = false + emits('updateSuccess') + }, + { + en: 'Failed to update sprite pivot or collision', + zh: '更新精灵参考点或碰撞体失败' + }, + { + en: 'Save sprite pivot and collision successfully', + zh: '更新精灵参考点和碰撞体成功' + } +) + +const { fn: savePivot } = useMessageHandle( + async () => { + const sprite = props.sprite + 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 pivot', zh: '更新精灵参考点' } }, () => { + const dPivot = { 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) + } + sprite.applyCostumesPivotChange(dPivot) + + // 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 + }) + } }) dirty.value = false emits('updateSuccess') }, { - en: 'Failed to update sprite collision', - zh: '更新精灵碰撞失败' + en: 'Failed to update sprite pivot', + zh: '更新精灵参考点失败' }, { - en: 'Save sprite collision successfully', - zh: '更新精灵碰撞成功' + en: 'Save sprite pivot successfully', + zh: '更新精灵参考点成功' } ) @@ -297,28 +325,51 @@ const { fn: handleConfirm } = useMessageHandle( - - - + + + + - + {{ $t({ en: 'Save', zh: '保存' }) }} + + {{ $t({ en: 'Save', zh: '保存' }) }} diff --git a/spx-gui/src/components/editor/sprite/PivotCollisionEditorModal.vue b/spx-gui/src/components/editor/sprite/PivotCollisionEditorModal.vue new file mode 100644 index 0000000000..3f05c12ca0 --- /dev/null +++ b/spx-gui/src/components/editor/sprite/PivotCollisionEditorModal.vue @@ -0,0 +1,41 @@ + + + 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 351cb22379..6a4ef0a697 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' @@ -127,7 +128,7 @@ describe('SpriteGen', () => { await finishAnimationGen('jump', animationJumpGen) // Finish the whole sprite generation - const sprite = gen.finish() + const sprite = await gen.finish() expect(sprite.name).toBe('UpdatedSprite') expect(sprite.assetMetadata?.description).toBe('Updated description for A brave knight') expect(sprite.assetMetadata?.extraSettings).toEqual({ @@ -147,6 +148,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', () => { @@ -324,7 +326,7 @@ describe('SpriteGen', () => { await gen1.genImages() gen1.setImageIndex(0) await gen1.prepareContent() - const sprite1 = gen1.finish() + const sprite1 = await gen1.finish() expect(sprite1.rotationStyle).toBe(RotationStyle.LeftRight) // Test AngledTopDown perspective -> LeftRight rotation style @@ -334,7 +336,7 @@ describe('SpriteGen', () => { await gen2.genImages() gen2.setImageIndex(0) await gen2.prepareContent() - const sprite2 = gen2.finish() + const sprite2 = await gen2.finish() expect(sprite2.rotationStyle).toBe(RotationStyle.LeftRight) // Test Unspecified perspective -> Normal rotation style @@ -344,10 +346,60 @@ describe('SpriteGen', () => { await gen3.genImages() gen3.setImageIndex(0) await gen3.prepareContent() - const sprite3 = gen3.finish() + const sprite3 = await gen3.finish() expect(sprite3.rotationStyle).toBe(RotationStyle.Normal) }) + it('should infer feet pivot 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 = await gen.finish() + expect(getContentBoundingRect).toHaveBeenCalledTimes(1) + expect(toNativeFile).toHaveBeenCalledTimes(1) + 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..5e304d9c3e 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, @@ -18,14 +19,14 @@ import { } from '@/apis/aigc' import { SpxProject } from '../project' import { RotationStyle, Sprite, State } from '../sprite' -import { Costume } from '../costume' +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, @@ -129,9 +130,6 @@ export class SpriteGen extends Disposable { const { name, perspective } = 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 }) } @@ -422,7 +420,7 @@ export class SpriteGen extends Disposable { this.selectedItem = item } - finish() { + async finish() { const previewSprite = this.previewSprite const sprite = this.createSprite() for (const gen of this.costumes) { @@ -438,6 +436,14 @@ export class SpriteGen extends Disposable { sprite.addAnimation(animation) sprite.setAnimationBoundStates(animation.id, boundStates) } + if (shouldUseFeetPivot(this.settings.category, this.settings.perspective) && sprite.defaultCostume != null) { + const inferredPivotDelta = await getFeetPivotDelta(sprite.defaultCostume) + if (inferredPivotDelta != null) { + // Apply once on the final sprite so all generated costumes, including animation frames, + // receive the same inferred pivot adjustment. + sprite.applyCostumesPivotChange(inferredPivotDelta) + } + } sprite.setAssetMetadata({ description: this.settings.description, extraSettings: { @@ -620,3 +626,20 @@ function rotationStyleForPerspective(perspective: Perspective): RotationStyle { return RotationStyle.Normal } } + +function shouldUseFeetPivot(category: SpriteCategory, perspective: Perspective) { + return ( + category === SpriteCategory.Character && + [Perspective.SideScrolling, Perspective.AngledTopDown].includes(perspective) + ) +} + +async function getFeetPivotDelta(costume: Costume): Promise { + 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 + } +} From 61d0149dac5ab8539a7a277ed01ba151a4fba4a2 Mon Sep 17 00:00:00 2001 From: Aofei Sheng Date: Fri, 29 May 2026 15:41:12 +0800 Subject: [PATCH 6/6] chore(deps): bump github.com/goplus/xgolsw to 0.21.0 in /tools/spxls (#3222) Signed-off-by: Aofei Sheng --- tools/spxls/go.mod | 2 +- tools/spxls/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/spxls/go.mod b/tools/spxls/go.mod index 3fe0cc890d..faac72c596 100644 --- a/tools/spxls/go.mod +++ b/tools/spxls/go.mod @@ -6,7 +6,7 @@ tool github.com/goplus/xgolsw/cmd/pkgdatagen require ( github.com/goplus/builder/tools/ai v0.0.0 - github.com/goplus/xgolsw v0.20.2-0.20260527013549-11629a441695 + github.com/goplus/xgolsw v0.21.0 ) require ( diff --git a/tools/spxls/go.sum b/tools/spxls/go.sum index 0ac13d90cc..d833de9e68 100644 --- a/tools/spxls/go.sum +++ b/tools/spxls/go.sum @@ -15,8 +15,8 @@ github.com/goplus/spx/v2 v2.0.2 h1:pzPE3fbS+Zo15tY8EnUFtXiaBOPG3mgXgaAdF8l44WU= github.com/goplus/spx/v2 v2.0.2/go.mod h1:kZl4cD2y8UOrsOdyLuu4dSB/pJieCO35xBOHcog/mlY= github.com/goplus/xgo v1.7.2-0.20260414235301-df19f4a1b7c2 h1:kXIGYlJUTii+7nv1XTKR0ctMZSeTcgYMoQuEexZPr7A= github.com/goplus/xgo v1.7.2-0.20260414235301-df19f4a1b7c2/go.mod h1:fX3+ZaYEzBE6qkgFcJx83D6DbUo/vMO8Y1Allydy2ws= -github.com/goplus/xgolsw v0.20.2-0.20260527013549-11629a441695 h1:0Mi3HMQIsuM03TVhT1F6g3KiVUnAbg3Sgl7u3L9AEtI= -github.com/goplus/xgolsw v0.20.2-0.20260527013549-11629a441695/go.mod h1:p68CHqfgmLAc3muvEOohs6IiWvX5AlUdP0rvF2PpqBs= +github.com/goplus/xgolsw v0.21.0 h1:JYi7kR/XP70LFGfTxJRRJ0VhzJCudXQdyMTLzbHBCqE= +github.com/goplus/xgolsw v0.21.0/go.mod h1:p68CHqfgmLAc3muvEOohs6IiWvX5AlUdP0rvF2PpqBs= github.com/petermattis/goid v0.0.0-20250721140440-ea1c0173183e h1:D0bJD+4O3G4izvrQUmzCL80zazlN7EwJ0PPDhpJWC/I= github.com/petermattis/goid v0.0.0-20250721140440-ea1c0173183e/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=