Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion spx-gui/.env
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion spx-gui/install-spx.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,8 @@ function useVideoPlayer(videoRef: Ref<HTMLVideoElement | null>, 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
Expand All @@ -29,7 +28,9 @@ function useVideoPlayer(videoRef: Ref<HTMLVideoElement | null>, 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() {
Expand Down Expand Up @@ -67,21 +68,19 @@ function useVideoPlayer(videoRef: Ref<HTMLVideoElement | null>, 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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
Expand Down
36 changes: 28 additions & 8 deletions spx-gui/src/components/asset/scratch/LoadFromScratch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down
96 changes: 96 additions & 0 deletions spx-gui/src/components/editor/common/pivot-marker.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@

<script setup lang="ts">
import { computed, nextTick, ref, watchEffect } from 'vue'
import { debounce } from 'lodash'
import type Konva from 'konva'
import type { Node } from 'konva/lib/Node'
import { Sprite } from '@/models/spx/sprite'
import type { Widget } from '@/models/spx/widget'
import type { CustomTransformer, CustomTransformerConfig } from './custom-transformer'
import { getNodeId } from './common'
import { debounce } from 'lodash'

const props = defineProps<{
target: Sprite | Widget | null
Expand Down
87 changes: 77 additions & 10 deletions spx-gui/src/components/editor/common/viewer/SpriteNode.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<script lang="ts" setup>
import { computed, onMounted, ref, watchEffect } from 'vue'
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
import type { KonvaEventObject } from 'konva/lib/Node'
import type { Image, ImageConfig } from 'konva/lib/shapes/Image'
import type { Group, GroupConfig } from 'konva/lib/Group'
import type { SpxProject } from '@/models/spx/project'
import { LeftRight, RotationStyle, headingToLeftRight, leftRightToHeading } from '@/models/spx/sprite'
import type { Size } from '@/models/common'
Expand All @@ -10,15 +11,22 @@ import { useFileImg } from '@/utils/file'
import { cancelBubble, getNodeId } from './common'
import type { SpriteLocalConfig } from './quick-config/utils'
import type { TransformOp } from './custom-transformer'
import { getPivotMarkerConfigs } from '../pivot-marker'
import type Konva from 'konva'

const props = defineProps<{
localConfig: SpriteLocalConfig
selected: boolean
project: SpxProject
mapSize: Size
nodeReadyMap: Map<string, boolean>
}>()
const props = withDefaults(
defineProps<{
localConfig: SpriteLocalConfig
selected: boolean
project: SpxProject
mapSize: Size
nodeReadyMap: Map<string, boolean>
mapScale?: number
}>(),
{
mapScale: 1
}
)

export type CameraScrollNotifyFn = (
/** Delta of camera position (in game) change */
Expand Down Expand Up @@ -73,6 +81,42 @@ onMounted(() => {
}
})

// Keep the selected-only pivot marker inside SpriteNode instead of NodeTransformer. SpriteNode already
// maps the sprite pivot to the Konva node origin with offsetX/offsetY, so the marker can simply use
// the same local position as the sprite node. This is a small, low-risk patch compared with teaching
// CustomTransformer to support custom transform origins via Konva internal method overrides.
const pivotMarkerRef = ref<KonvaNodeInstance<Group>>()

// Keep the selected sprite's pivot marker above all sprite nodes.
watch(
[() => props.selected, () => props.project.zorder.length, pivotMarkerRef],
() => {
if (!props.selected) return
const pivotMarkerNode = pivotMarkerRef.value?.getNode()
if (pivotMarkerNode == null || pivotMarkerNode.getParent() == null) return
const zIndex = props.project.zorder.length
if (pivotMarkerNode.zIndex() === zIndex) return
pivotMarkerNode.zIndex(zIndex)
},
{ immediate: true }
)

/**
* Konva Transformer mutates the live node position directly and does not support using
* a custom sprite pivot as the transform origin. Its scale/rotate interaction is based on
* the node's geometric center rather than our sprite pivot semantics.
*
* To keep scale/rotate behavior aligned with quick config, we immediately restore the
* node to the pivot position captured at transform start instead of waiting for a later
* reactive re-render.
*/
function keepNodePivotPosition(node: Konva.Node) {
const snapshot = snapshotRef.value
if (snapshot == null) return
node.x(props.mapSize.width / 2 + snapshot.x)
node.y(props.mapSize.height / 2 - snapshot.y)
}

function updateLocalConfigByShape(node: Konva.Node) {
if (!props.selected) return
const localConfig = props.localConfig
Expand All @@ -87,6 +131,7 @@ function updateLocalConfigByShape(node: Konva.Node) {
localConfig.setHeading(heading)
emit('updateTransformOp', 'rotate')
}
keepNodePivotPosition(node)
// Sprite's pivot causes x or y to change when size or heading changes, so they need to be updated together
const { x, y } = toPosition(node)
if (oldX !== x || oldY !== y) {
Expand All @@ -96,6 +141,7 @@ function updateLocalConfigByShape(node: Konva.Node) {
}

function syncLocalConfigByShape(node: Konva.Node) {
keepNodePivotPosition(node)
const localConfig = props.localConfig
localConfig.setSize(toSize(node))
localConfig.setHeading(toHeading(node))
Expand Down Expand Up @@ -181,6 +227,20 @@ const config = computed<ImageConfig>(() => {
return config
})

// In map mode SpriteNode is rendered inside the scaled map layer, so the marker would be
// zoomed together with the map. Apply the inverse map scale to keep its screen size fixed.
const pivotMarkerGroupConfig = computed<GroupConfig>(() => {
const { x, y } = props.localConfig
const scale = 1 / props.mapScale
return {
x: props.mapSize.width / 2 + x,
y: props.mapSize.height / 2 - y,
scale: { x: scale, y: scale },
listening: false
}
})
const pivotMarkerConfigs = getPivotMarkerConfigs({ interactive: false })

function toPosition(node: Konva.Node) {
const { mapSize } = props
const x = round(node.x() - mapSize.width / 2)
Expand All @@ -196,8 +256,7 @@ function toHeading(node: Konva.Node) {
return heading
}
function toSize(node: Konva.Node) {
const size = round(Math.abs(node.scaleX()) * bitmapResolution.value, 2)
return size
return round(Math.abs(node.scaleX()) * bitmapResolution.value, 2)
}

function handleClick() {
Expand All @@ -216,4 +275,12 @@ function handleClick() {
@transformend="handleTransformEnd"
@click="handleClick"
/>
<v-group v-if="selected" ref="pivotMarkerRef" :config="pivotMarkerGroupConfig">
<v-group :config="pivotMarkerConfigs.drawingGroup">
<template v-for="(shape, idx) in pivotMarkerConfigs.shapes" :key="`sprite-pivot-marker-${idx}`">
<v-circle v-if="shape.kind === 'circle'" :config="shape.config" />
<v-rect v-else :config="shape.config" />
</template>
</v-group>
</v-group>
</template>
Loading