diff --git a/spx-gui/.env b/spx-gui/.env index cf290beeef..6bc404cbce 100644 --- a/spx-gui/.env +++ b/spx-gui/.env @@ -50,7 +50,7 @@ VITE_CASDOOR_ORGANIZATION_NAME="" VITE_DISABLE_AIGC="false" # Version of spx, keep in sync with the version in `install-spx.sh`. -VITE_SPX_VERSION="2.0.1" +VITE_SPX_VERSION="2.0.2" # Whether to show the license information (including copyright) in the footer. VITE_SHOW_LICENSE="false" diff --git a/spx-gui/install-spx.sh b/spx-gui/install-spx.sh index 043b82fcac..2d7ccec22b 100755 --- a/spx-gui/install-spx.sh +++ b/spx-gui/install-spx.sh @@ -4,7 +4,7 @@ set -e cd "$(dirname "$0")" # Keep this version in sync with `VITE_SPX_VERSION` in `.env`. -SPX_VERSION="2.0.1" +SPX_VERSION="2.0.2" SPX_NAME="spx_${SPX_VERSION}" SPX_RELEASE_URL="https://github.com/goplus/spx/releases/download/v${SPX_VERSION}/spx_web.zip" 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/asset/gen/sprite/SpriteGenPhaseContent.vue b/spx-gui/src/components/asset/gen/sprite/SpriteGenPhaseContent.vue index eb7c0ab666..d30c8efd01 100644 --- a/spx-gui/src/components/asset/gen/sprite/SpriteGenPhaseContent.vue +++ b/spx-gui/src/components/asset/gen/sprite/SpriteGenPhaseContent.vue @@ -175,7 +175,7 @@ async function beforeSubmit() { const handleSubmit = useMessageHandle( async () => { await beforeSubmit() - const sprite = props.gen.finish() + const sprite = await props.gen.finish() props.gen.recordAdoption().catch((err) => { capture(err, 'failed to record sprite asset adoption') }) 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/pivot-marker.ts b/spx-gui/src/components/editor/common/pivot-marker.ts new file mode 100644 index 0000000000..83b0928043 --- /dev/null +++ b/spx-gui/src/components/editor/common/pivot-marker.ts @@ -0,0 +1,96 @@ +import type { CircleConfig } from 'konva/lib/shapes/Circle' +import type { GroupConfig } from 'konva/lib/Group' +import type { RectConfig } from 'konva/lib/shapes/Rect' + +type PivotMarkerOptions = { + size?: number + interactive: boolean +} + +export type PivotMarkerShapeConfig = + | { + kind: 'circle' + config: CircleConfig + } + | { + kind: 'rect' + config: RectConfig + } + +function getPrimaryColor(interactive: boolean) { + return interactive ? '#36C2CF' : '#CBD2D8' +} + +function getOpacity(interactive: boolean) { + return interactive ? 1 : 0.9 +} + +const defaultPivotMarkerSize = 16 +const pivotMarkerViewBoxSize = 24 + +export function getPivotMarkerConfigs({ size = defaultPivotMarkerSize, interactive }: PivotMarkerOptions): { + drawingGroup: GroupConfig + shapes: PivotMarkerShapeConfig[] +} { + const scale = size / pivotMarkerViewBoxSize + const primaryColor = getPrimaryColor(interactive) + return { + drawingGroup: { + x: (-pivotMarkerViewBoxSize / 2) * scale, + y: (-pivotMarkerViewBoxSize / 2) * scale, + scale: { + x: scale, + y: scale + }, + opacity: getOpacity(interactive), + listening: interactive + }, + shapes: [ + // Interactive marker needs a solid hit area; visible pieces are too sparse for reliable dragging. + ...(interactive + ? [ + { + kind: 'circle' as const, + config: { + x: pivotMarkerViewBoxSize / 2, + y: pivotMarkerViewBoxSize / 2, + radius: pivotMarkerViewBoxSize / 2, + fill: 'rgba(0, 0, 0, 0)' + } + } + ] + : []), + { + kind: 'circle', + config: { + x: pivotMarkerViewBoxSize / 2, + y: pivotMarkerViewBoxSize / 2, + radius: 9, + fill: 'white' + } + }, + ...[ + { x: 0, y: 10, width: 4, height: 4, cornerRadius: 2, fill: 'white' }, + { x: 20, y: 10, width: 4, height: 4, cornerRadius: 2, fill: 'white' }, + { x: 10, y: 0, width: 4, height: 4, cornerRadius: 2, fill: 'white' }, + { x: 10, y: 20, width: 4, height: 4, cornerRadius: 2, fill: 'white' }, + { x: 1, y: 11, width: 4, height: 2, cornerRadius: 1, fill: primaryColor }, + { x: 19, y: 11, width: 4, height: 2, cornerRadius: 1, fill: primaryColor }, + { x: 11, y: 1, width: 2, height: 4, cornerRadius: 1, fill: primaryColor }, + { x: 11, y: 19, width: 2, height: 4, cornerRadius: 1, fill: primaryColor }, + { x: 9, y: 11, width: 6, height: 2, cornerRadius: 1, fill: primaryColor }, + { x: 11, y: 9, width: 2, height: 6, cornerRadius: 1, fill: primaryColor } + ].map((config) => ({ kind: 'rect' as const, config })), + { + kind: 'circle', + config: { + x: pivotMarkerViewBoxSize / 2, + y: pivotMarkerViewBoxSize / 2, + radius: 7, + stroke: primaryColor, + strokeWidth: 2 + } + } + ] + } +} diff --git a/spx-gui/src/components/editor/common/viewer/NodeTransformer.vue b/spx-gui/src/components/editor/common/viewer/NodeTransformer.vue index 55c5abac93..b66774cab2 100644 --- a/spx-gui/src/components/editor/common/viewer/NodeTransformer.vue +++ b/spx-gui/src/components/editor/common/viewer/NodeTransformer.vue @@ -4,13 +4,13 @@ @@ -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 ea450f03be..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 @@ -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) => { > - + ) => { :selected="selectedSprite?.id === localConfig.id" :project="props.project" :map-size="mapSize" + :map-scale="mapScale" :node-ready-map="nodeReadyMap" @drag-move="handleSpriteDragMove" @drag-end="handleSpriteDragEnd" @@ -567,7 +583,7 @@ const handleWheel = (e: KonvaEventObject) => {
- +
diff --git a/spx-gui/src/components/editor/preview/stage-viewer/StageViewer.vue b/spx-gui/src/components/editor/preview/stage-viewer/StageViewer.vue index d1740667ca..e153636079 100644 --- a/spx-gui/src/components/editor/preview/stage-viewer/StageViewer.vue +++ b/spx-gui/src/components/editor/preview/stage-viewer/StageViewer.vue @@ -11,7 +11,7 @@ > - + { }) }) -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/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/components/editor/stage/backdrop/BackdropModeSelector.vue b/spx-gui/src/components/editor/stage/backdrop/BackdropModeSelector.vue index b7284c0075..efa3c340d0 100644 --- a/spx-gui/src/components/editor/stage/backdrop/BackdropModeSelector.vue +++ b/spx-gui/src/components/editor/stage/backdrop/BackdropModeSelector.vue @@ -1,46 +1,60 @@