Skip to content
Open
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
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
99 changes: 99 additions & 0 deletions spx-gui/src/components/editor/common/PivotMarker.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { CircleConfig } from 'konva/lib/shapes/Circle'
import type { GroupConfig } from 'konva/lib/Group'
import type { RectConfig } from 'konva/lib/shapes/Rect'

const props = withDefaults(
defineProps<{
size?: number
primaryColor: string
opacity?: number
showHitArea?: boolean
}>(),
{
size: 16,
opacity: 1,
showHitArea: false
}
)

const markerViewBoxSize = 24

const drawingGroupConfig = computed<GroupConfig>(() => {
const scale = props.size / markerViewBoxSize
return {
x: (-markerViewBoxSize / 2) * scale,
y: (-markerViewBoxSize / 2) * scale,
scale: {
x: scale,
y: scale
},
opacity: props.opacity,
listening: false
}
})

const hitConfig = computed<CircleConfig>(
() =>
({
radius: props.size / 2,
fill: 'rgba(0, 0, 0, 0.01)'
}) satisfies CircleConfig
)

const circleConfig = computed<CircleConfig>(
() =>
({
x: markerViewBoxSize / 2,
y: markerViewBoxSize / 2,
radius: 9,
fill: 'white',
listening: false
}) satisfies CircleConfig
)

const outerTabConfigs = computed<RectConfig[]>(
() =>
[
{ x: 0, y: 10, width: 4, height: 4, cornerRadius: 2, fill: 'white', listening: false },
{ x: 20, y: 10, width: 4, height: 4, cornerRadius: 2, fill: 'white', listening: false },
{ x: 10, y: 0, width: 4, height: 4, cornerRadius: 2, fill: 'white', listening: false },
{ x: 10, y: 20, width: 4, height: 4, cornerRadius: 2, fill: 'white', listening: false }
] satisfies RectConfig[]
)

const innerShapeConfigs = computed<RectConfig[]>(
() =>
[
{ x: 1, y: 11, width: 4, height: 2, cornerRadius: 1, fill: props.primaryColor, listening: false },
{ x: 19, y: 11, width: 4, height: 2, cornerRadius: 1, fill: props.primaryColor, listening: false },
{ x: 11, y: 1, width: 2, height: 4, cornerRadius: 1, fill: props.primaryColor, listening: false },
{ x: 11, y: 19, width: 2, height: 4, cornerRadius: 1, fill: props.primaryColor, listening: false },
{ x: 9, y: 11, width: 6, height: 2, cornerRadius: 1, fill: props.primaryColor, listening: false },
{ x: 11, y: 9, width: 2, height: 6, cornerRadius: 1, fill: props.primaryColor, listening: false }
] satisfies RectConfig[]
)

const ringConfig = computed<CircleConfig>(
() =>
({
x: markerViewBoxSize / 2,
y: markerViewBoxSize / 2,
radius: 7,
stroke: props.primaryColor,
strokeWidth: 2,
listening: false
}) satisfies CircleConfig
)
</script>

<template>
<v-circle v-if="showHitArea" :config="hitConfig" />
<v-group :config="drawingGroupConfig">
<v-circle :config="circleConfig" />
<v-rect v-for="(rectConfig, idx) in outerTabConfigs" :key="`pivot-outer-${idx}`" :config="rectConfig" />
<v-rect v-for="(rectConfig, idx) in innerShapeConfigs" :key="`pivot-inner-${idx}`" :config="rectConfig" />
<v-circle :config="ringConfig" />
</v-group>
</template>
48 changes: 45 additions & 3 deletions spx-gui/src/components/editor/common/viewer/SpriteNode.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<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 { Group, GroupConfig } from 'konva/lib/Group'
import type { Image, ImageConfig } from 'konva/lib/shapes/Image'
import type { SpxProject } from '@/models/spx/project'
import { LeftRight, RotationStyle, headingToLeftRight, leftRightToHeading } from '@/models/spx/sprite'
Expand All @@ -11,12 +12,14 @@ import { cancelBubble, getNodeId } from './common'
import type { SpriteLocalConfig } from './quick-config/utils'
import type { TransformOp } from './custom-transformer'
import type Konva from 'konva'
import PivotMarker from '../PivotMarker.vue'

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

Expand All @@ -42,6 +45,7 @@ const emit = defineEmits<{
}>()

const nodeRef = ref<KonvaNodeInstance<Image>>()
const pivotMarkerRef = ref<KonvaNodeInstance<Group>>()
const costume = computed(() => props.localConfig.defaultCostume)
const bitmapResolution = computed(() => costume.value?.bitmapResolution ?? 1)
const [image, imageLoading] = useFileImg(() => costume.value?.img)
Expand All @@ -50,6 +54,7 @@ const rawSize = useAsyncComputedLegacy(async () => costume.value?.getRawSize() ?
const nodeId = computed(() => getNodeId(props.localConfig))

const snapshotRef = ref<ConfigGetter | null>(null)
const effectiveMapScale = computed(() => (props.mapScale == null ? 1 : props.mapScale))
const configGetter = computed(() => {
if (snapshotRef.value != null) return snapshotRef.value
return props.localConfig
Expand All @@ -73,6 +78,27 @@ onMounted(() => {
}
})

// 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 }
)

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) {
Comment thread
cn0809 marked this conversation as resolved.
if (!props.selected) return
const localConfig = props.localConfig
Expand All @@ -87,6 +113,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 +123,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 +209,18 @@ const config = computed<ImageConfig>(() => {
return config
})

const pivotMarkerGroupConfig = computed<GroupConfig | null>(() => {
if (!props.selected) return null
return {
x: props.mapSize.width / 2 + props.localConfig.x,
y: props.mapSize.height / 2 - props.localConfig.y,
visible: props.localConfig.visible,
scaleX: 1 / effectiveMapScale.value,
scaleY: 1 / effectiveMapScale.value,
listening: false
} satisfies GroupConfig
})

function toPosition(node: Konva.Node) {
const { mapSize } = props
const x = round(node.x() - mapSize.width / 2)
Expand All @@ -196,8 +236,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 +255,7 @@ function handleClick() {
@transformend="handleTransformEnd"
@click="handleClick"
/>
<v-group v-if="pivotMarkerGroupConfig != null" ref="pivotMarkerRef" :config="pivotMarkerGroupConfig">
<PivotMarker primary-color="#CBD2D8" :opacity="0.9" />
</v-group>
</template>
41 changes: 31 additions & 10 deletions spx-gui/src/components/editor/map-editor/SpriteBasicConfig.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import SpriteDirection from '@/components/editor/common/config/sprite/SpriteDire
import SpriteVisible from '@/components/editor/common/config/sprite/SpriteVisible.vue'
import SpritePhysics from '@/components/editor/common/config/sprite/SpritePhysics.vue'
import { UIButton, UIIcon, UITooltip, useModal } from '@/components/ui'
import SpriteCollisionEditorModal from '../sprite/SpriteCollisionEditorModal.vue'
import PivotCollisionEditorModal from '../sprite/PivotCollisionEditorModal.vue'
import { useRenameSprite } from '@/components/asset'
import AssetName from '@/components/asset/AssetName.vue'

Expand All @@ -29,18 +29,22 @@ const handleNameEdit = useMessageHandle(() => renameSprite(props.sprite), {
zh: '重命名精灵失败'
}).fn

const isCollisionSettingsEnabled = computed(() => {
const isCollisionEditingEnabled = computed(() => {
if (!props.project.stage.physics.enabled) return false
if (props.sprite.physicsMode === PhysicsMode.NoPhysics) return false
return true
})

const editSpriteCollision = useModal(SpriteCollisionEditorModal)
const handleEditCollision = useMessageHandle(
() => editSpriteCollision({ sprite: props.sprite, project: props.project }),
const editPivotCollision = useModal(PivotCollisionEditorModal)
const handleEditPivot = useMessageHandle(
() =>
editPivotCollision({
sprite: props.sprite,
enableCollisionEditing: isCollisionEditingEnabled.value
}),
{
en: 'Failed to update sprite collision',
zh: '更新精灵碰撞失败'
en: 'Failed to update sprite pivot or collision',
zh: '更新精灵参考点或碰撞体失败'
}
).fn
</script>
Expand Down Expand Up @@ -87,9 +91,26 @@ const handleEditCollision = useMessageHandle(
<div class="mr-4 whitespace-nowrap">{{ $t({ en: 'Physics', zh: '物理特性' }) }}</div>
<SpritePhysics :sprite="sprite" :project="project" />
</div>
<div v-if="isCollisionSettingsEnabled" class="flex items-center">
<div class="mr-4 whitespace-nowrap">{{ $t({ en: 'Collision settings', zh: '碰撞设置' }) }}</div>
<UIButton shape="square" icon="setting" type="white" @click="handleEditCollision"></UIButton>
<div class="flex items-center">
<UITooltip>
{{
$t({
en: `Set the pivot point of the sprite${isCollisionEditingEnabled ? ' and adjust the collision area' : ''}`,
zh: `设置精灵的坐标基准${isCollisionEditingEnabled ? ',并调整可发生碰撞的范围' : ''}`
})
}}
<template #trigger>
<div class="mr-4 whitespace-nowrap">
{{
$t({
en: `Pivot${isCollisionEditingEnabled ? ' & collision ' : ' '}settings`,
zh: `参考点${isCollisionEditingEnabled ? '和碰撞体' : ''}设置`
})
}}
</div>
</template>
</UITooltip>
<UIButton shape="square" icon="setting" type="white" @click="handleEditPivot"></UIButton>
</div>
</div>
</template>
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,7 @@ const handleWheel = (e: KonvaEventObject<WheelEvent>) => {
:selected="selectedSprite?.id === localConfig.id"
:project="props.project"
:map-size="mapSize"
:map-scale="mapScale"
:node-ready-map="nodeReadyMap"
@drag-move="handleSpriteDragMove"
@drag-end="handleSpriteDragEnd"
Expand Down
Loading