Files
SpineParticlesWeb/src/views/CurveEditor.vue
T
2026-10-22 18:13:43 +08:00

217 lines
9.1 KiB
Vue

<template>
<div class="ce">
<div class="ce-editor">
<svg ref="svg" class="ce-svg" @dblclick="onDbl">
<!-- 网格线(0.25/0.5/0.75) -->
<g class="ce-grid">
<line v-for="g in [0.25,0.5,0.75]" :key="'v'+g" :x1="px(g)" y1="0" :x2="px(g)" :y2="py(0)" />
<line v-for="g in [0.25,0.5,0.75]" :key="'h'+g" x1="0" :y1="py(g)" :x2="px(1)" :y2="py(g)" />
</g>
<rect :x="px(0)" :y="py(1)" :width="px(1)-px(0)" :height="py(0)-py(1)" class="ce-frame" fill="none" />
<path :d="curvePath" class="ce-line" fill="none" />
<g v-if="mode === 'bezier'" class="ce-handles">
<template v-for="(pt, i) in pts" :key="'handles-'+i">
<line v-if="i > 0" :x1="px(pt.x)" :y1="py(pt.y)" :x2="px(inputHandle(i).x)" :y2="py(inputHandle(i).y)" />
<circle v-if="i > 0" :cx="px(inputHandle(i).x)" :cy="py(inputHandle(i).y)" r="4" @pointerdown="onHandleDown($event, i, 'in')" />
<line v-if="i < pts.length - 1" :x1="px(pt.x)" :y1="py(pt.y)" :x2="px(outputHandle(i).x)" :y2="py(outputHandle(i).y)" />
<circle v-if="i < pts.length - 1" :cx="px(outputHandle(i).x)" :cy="py(outputHandle(i).y)" r="4" @pointerdown="onHandleDown($event, i, 'out')" />
</template>
</g>
<!-- 节点:可见圈 r=5 + 命中圈 r=9 -->
<g v-for="(pt,i) in pts" :key="i">
<circle :cx="px(pt.x)" :cy="py(pt.y)" r="5" class="ce-node" />
<circle :cx="px(pt.x)" :cy="py(pt.y)" r="9" class="ce-hit"
@pointerdown="onNodeDown($event,i)" @dblclick="onNodeDbl(i,$event)" />
</g>
</svg>
<div class="ce-nav"><span class="ce-tip">{{ mode === 'bezier' ? $t('curve.bezierHint') : $t('curve.linearHint') }}</span></div>
</div>
<button class="ce-reset" type="button" :title="$t('curve.resetTitle')" @click="resetCurve">{{ $t('common.reset') }}</button>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
type CurvePoint = { x: number; y: number; inX?: number; inY?: number; outX?: number; outY?: number }
const props = withDefaults(defineProps<{
modelValue: CurvePoint[]
defaultValue?: CurvePoint[]
mode?: 'linear' | 'bezier'
}>(), {
defaultValue: () => [{ x: 0, y: 0 }, { x: 1, y: 1 }],
mode: 'linear',
})
const emit = defineEmits<{ (e: 'update:modelValue', v: CurvePoint[]): void }>()
const svg = ref<SVGSVGElement | null>(null)
const pad = 8 // 内边距(px),避免节点贴边
const W = ref(300)
const H = ref(110)
let ro: ResizeObserver | null = null
function measure() {
if (!svg.value) return
const r = svg.value.getBoundingClientRect()
W.value = r.width
H.value = r.height
}
// 归一化 x(0-1) → 像素
function px(x: number) { return pad + x * (W.value - pad * 2) }
// 归一化 y(0-1, y=1 在上) → 像素
function py(y: number) { return pad + (1 - y) * (H.value - pad * 2) }
function normalized(): CurvePoint[] {
return [...props.modelValue].sort((a, b) => a.x - b.x)
}
function set(v: CurvePoint[]) { emit('update:modelValue', [...v].sort((a, b) => a.x - b.x)) }
function resetCurve() {
set(props.defaultValue.map((point) => ({ ...point })))
}
const pts = computed(() => normalized())
function defaultInputHandle(index: number) {
const point = pts.value[index], previous = pts.value[Math.max(0, index - 1)]
return { x: point.x - (point.x - previous.x) / 3, y: point.y - (point.y - previous.y) / 3 }
}
function defaultOutputHandle(index: number) {
const point = pts.value[index], next = pts.value[Math.min(pts.value.length - 1, index + 1)]
return { x: point.x + (next.x - point.x) / 3, y: point.y + (next.y - point.y) / 3 }
}
function inputHandle(index: number) {
const point = pts.value[index], fallback = defaultInputHandle(index)
return { x: point.x + (point.inX ?? fallback.x - point.x), y: point.y + (point.inY ?? fallback.y - point.y) }
}
function outputHandle(index: number) {
const point = pts.value[index], fallback = defaultOutputHandle(index)
return { x: point.x + (point.outX ?? fallback.x - point.x), y: point.y + (point.outY ?? fallback.y - point.y) }
}
const curvePath = computed(() => {
if (!pts.value.length) return ''
let path = `M ${px(pts.value[0].x)} ${py(pts.value[0].y)}`
for (let index = 1; index < pts.value.length; index++) {
const point = pts.value[index]
if (props.mode === 'bezier') {
const output = outputHandle(index - 1), input = inputHandle(index)
path += ` C ${px(output.x)} ${py(output.y)} ${px(input.x)} ${py(input.y)} ${px(point.x)} ${py(point.y)}`
} else path += ` L ${px(point.x)} ${py(point.y)}`
}
return path
})
// 客户端坐标 → 归一化 (x:0-1 时间, y:0-1 透明度)
function toNorm(clientX: number, clientY: number) {
const r = svg.value!.getBoundingClientRect()
const nx = (clientX - r.left - pad) / (W.value - pad * 2)
const ny = (clientY - r.top - pad) / (H.value - pad * 2)
return { x: Math.min(1, Math.max(0, Math.round(nx * 100) / 100)), y: Math.min(1, Math.max(0, Math.round((1 - ny) * 100) / 100)) }
}
// 双击空白新建节点;若刚刚删除过(抑制窗口内)则忽略,避免删除后又立刻新建
let dblDeleteAt = 0
function onDbl(ev: MouseEvent) {
if ((ev.target as HTMLElement).classList.contains('ce-hit')) return
if (Date.now() - dblDeleteAt < 400) return // 删除后 400ms 内不新建
const { x, y } = toNorm(ev.clientX, ev.clientY)
const arr = normalized().filter(k => Math.abs(k.x - x) > 0.02)
arr.push({ x, y })
set(arr)
}
// 拖节点 + 双击删除检测(window 级监听,避免干扰;用时间/位移判断双击)
let lastClick = { t: 0, i: -1, x: 0, y: 0 }
function onNodeDown(ev: PointerEvent, i: number) {
ev.stopPropagation()
const idx = normalized().findIndex(k => Math.abs(k.x - pts.value[i].x) < 1e-6 && Math.abs(k.y - pts.value[i].y) < 1e-6)
if (idx < 0) return
// 双击检测:300ms 内再次点击同一节点且未大幅移动 → 删除
const now = Date.now()
if (now - lastClick.t < 300 && lastClick.i === i && Math.abs(ev.clientX - lastClick.x) < 8 && Math.abs(ev.clientY - lastClick.y) < 8) {
lastClick = { t: 0, i: -1, x: 0, y: 0 }
onNodeDbl(i)
return
}
lastClick = { t: now, i, x: ev.clientX, y: ev.clientY }
const move = (ev2: PointerEvent) => {
// 移动则取消双击候选
if (Math.abs(ev2.clientX - ev.clientX) > 8 || Math.abs(ev2.clientY - ev.clientY) > 8) lastClick = { t: 0, i: -1, x: 0, y: 0 }
const { x, y } = toNorm(ev2.clientX, ev2.clientY)
const arr = normalized()
arr[idx] = { x, y }
set(arr)
}
const up = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
function onHandleDown(ev: PointerEvent, index: number, side: 'in' | 'out') {
ev.stopPropagation()
const move = (event: PointerEvent) => {
const target = toNorm(event.clientX, event.clientY)
const arr = normalized()
const point = arr[index]
const minimumX = side === 'in' ? arr[Math.max(0, index - 1)].x : point.x
const maximumX = side === 'in' ? point.x : arr[Math.min(arr.length - 1, index + 1)].x
const handleX = Math.min(maximumX, Math.max(minimumX, target.x))
const dx = Math.round((handleX - point.x) * 100) / 100
const dy = Math.round((target.y - point.y) * 100) / 100
arr[index] = side === 'in' ? { ...point, inX: dx, inY: dy } : { ...point, outX: dx, outY: dy }
set(arr)
}
const up = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
// 双击节点删除
function onNodeDbl(i: number, ev?: MouseEvent) {
ev?.stopPropagation()
const arr = normalized()
if (arr.length <= 2) return
dblDeleteAt = Date.now()
set(arr.filter((_, j) => j !== i))
}
onMounted(() => {
measure()
ro = new ResizeObserver(measure)
if (svg.value) ro.observe(svg.value)
})
onUnmounted(() => { ro?.disconnect() })
</script>
<style scoped>
.ce { display: flex; align-items: flex-start; gap: 6px; width: 100%; }
.ce-editor { flex: 1; min-width: 0; }
.ce-reset {
flex: 0 0 auto; padding: 5px 7px; border: 1px solid #3b4b65; border-radius: 5px;
background: #242f42; color: #c4cee2; font-size: 10px; cursor: pointer;
}
.ce-reset:hover { border-color: #7774ff; background: #4f4dc2; color: #fff; }
.ce-svg { width: 100%; height: 110px; display: block; border: 1px solid #2a2a3c; border-radius: 6px; background: #10101a; cursor: crosshair; touch-action: none; }
.ce-grid line { stroke: #1e1e30; stroke-width: 1; }
.ce-frame { stroke: #2e2e44; stroke-width: 1; }
.ce-line { stroke: #6ea0e8; stroke-width: 2; stroke-linejoin: round; }
.ce-handles line { stroke: #8d96b5; stroke-width: 1; stroke-dasharray: 3 2; }
.ce-handles circle { fill: #b7c2dd; stroke: #344d94; stroke-width: 1.2; cursor: grab; }
.ce-handles circle:active { cursor: grabbing; }
.ce-node { fill: #ffffff; stroke: #3a55aa; stroke-width: 1.6; pointer-events: none; }
.ce-hit { fill: transparent; stroke: none; cursor: grab; }
.ce-hit:active { cursor: grabbing; }
.ce-nav { display: flex; justify-content: space-between; margin-top: 3px; }
.ce-tip { color: #566; font-size: 10px; }
</style>