spine资源引入
This commit is contained in:
@@ -23,6 +23,8 @@ export interface SceneCollider {
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
scaleX: number
|
||||
scaleY: number
|
||||
radius: number
|
||||
radiusX: number
|
||||
radiusY: number
|
||||
@@ -1590,15 +1592,27 @@ type ColliderHit = { x: number; y: number; nx: number; ny: number }
|
||||
function resolveColliderPoint(collider: SceneCollider, worldX: number, worldY: number): ColliderHit | null {
|
||||
const angle = collider.rotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const scaleX = Math.max(0.0001, Math.abs(collider.scaleX ?? 1))
|
||||
const scaleY = Math.max(0.0001, Math.abs(collider.scaleY ?? 1))
|
||||
const dx = worldX - collider.x, dy = worldY - collider.y
|
||||
const localX = cos * dx + sin * dy
|
||||
const localY = -sin * dx + cos * dy
|
||||
const toWorld = (x: number, y: number, nx: number, ny: number): ColliderHit => ({
|
||||
x: collider.x + cos * x - sin * y + (cos * nx - sin * ny) * 0.05,
|
||||
y: collider.y + sin * x + cos * y + (sin * nx + cos * ny) * 0.05,
|
||||
nx: cos * nx - sin * ny,
|
||||
ny: sin * nx + cos * ny,
|
||||
})
|
||||
const localX = (cos * dx + sin * dy) / scaleX
|
||||
const localY = (-sin * dx + cos * dy) / scaleY
|
||||
const toWorld = (x: number, y: number, nx: number, ny: number): ColliderHit => {
|
||||
// 非等比缩放下,法线使用逆转置缩放后再旋转,保证反弹方向正确。
|
||||
const scaledNormalX = nx / scaleX
|
||||
const scaledNormalY = ny / scaleY
|
||||
const normalLength = Math.max(0.0001, Math.hypot(scaledNormalX, scaledNormalY))
|
||||
const localNormalX = scaledNormalX / normalLength
|
||||
const localNormalY = scaledNormalY / normalLength
|
||||
const worldNormalX = cos * localNormalX - sin * localNormalY
|
||||
const worldNormalY = sin * localNormalX + cos * localNormalY
|
||||
return {
|
||||
x: collider.x + cos * x * scaleX - sin * y * scaleY + worldNormalX * 0.05,
|
||||
y: collider.y + sin * x * scaleX + cos * y * scaleY + worldNormalY * 0.05,
|
||||
nx: worldNormalX,
|
||||
ny: worldNormalY,
|
||||
}
|
||||
}
|
||||
if (collider.shape === 'circle') {
|
||||
const radius = Math.max(0.001, collider.radius)
|
||||
const distance = Math.hypot(localX, localY)
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<div class="spine-editor">
|
||||
<div class="editor-title">骨架属性</div>
|
||||
<label class="field wide"><span>骨架名称</span><input v-model="spineObject.name" /></label>
|
||||
<div class="grid three">
|
||||
<label class="field"><span>X 位置</span><input v-model.number="spineObject.x" type="number" /></label>
|
||||
<label class="field"><span>Y 位置</span><input v-model.number="spineObject.y" type="number" /></label>
|
||||
<label class="field"><span>旋转</span><input v-model.number="spineObject.rotation" type="number" /></label>
|
||||
</div>
|
||||
<div class="grid three">
|
||||
<label class="field"><span>Scale X</span><input v-model.number="spineObject.scaleX" type="number" step="0.05" /></label>
|
||||
<label class="field"><span>Scale Y</span><input v-model.number="spineObject.scaleY" type="number" step="0.05" /></label>
|
||||
<label class="field"><span>开始时间</span><input v-model.number="spineObject.timeOffset" type="number" min="0" step="0.033333" @change="recalcTimeline" /></label>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="section-title">骨架资源</div>
|
||||
<input ref="skeletonInput" hidden type="file" accept=".json,.skel,application/json,application/octet-stream" @change="selectSkeleton" />
|
||||
<input ref="atlasInput" hidden type="file" accept=".atlas,text/plain" @change="selectAtlas" />
|
||||
<input ref="textureInput" hidden type="file" multiple accept=".png,.jpg,.jpeg,.webp,.wepb,image/png,image/jpeg,image/webp" @change="selectTextures" />
|
||||
<div class="asset-row">
|
||||
<div class="asset-name" :title="skeletonName">{{ skeletonName || 'JSON / SKEL...' }}</div>
|
||||
<button @click="skeletonInput?.click()">选择</button>
|
||||
</div>
|
||||
<div class="asset-row">
|
||||
<div class="asset-name" :title="atlasName">{{ atlasName || 'Atlas...' }}</div>
|
||||
<button @click="atlasInput?.click()">选择</button>
|
||||
</div>
|
||||
<div class="asset-row">
|
||||
<div class="asset-name" :title="textureNames.join(', ')">{{ textureSummary }}</div>
|
||||
<button @click="textureInput?.click()">选择(多选)</button>
|
||||
</div>
|
||||
<div class="asset-actions">
|
||||
<button class="load" :disabled="spineObject.status === 'loading'" @click="loadBundle">
|
||||
{{ spineObject.status === 'loading' ? '加载中…' : '↧ 加载' }}
|
||||
</button>
|
||||
<button class="clear" @click="clearBundle">清除</button>
|
||||
</div>
|
||||
<div class="runtime-note">Spine 4.2.119 · 官方 PixiJS 7 运行时 · 本地离线</div>
|
||||
<div v-if="spineObject.error" class="error">{{ spineObject.error }}</div>
|
||||
|
||||
<div class="section-box">
|
||||
<div class="section-title">骨架动画</div>
|
||||
<div v-if="!spineObject.animations.length" class="empty">无可用动画</div>
|
||||
<label v-else class="field wide">
|
||||
<span>时间轴播放动画</span>
|
||||
<select v-model="spineObject.selectedAnimation" @change="onAnimationChange">
|
||||
<option value="">无</option>
|
||||
<option v-for="animation in spineObject.animations" :key="animation.name" :value="animation.name">
|
||||
{{ animation.name }}({{ animation.duration.toFixed(2) }}s)
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="section-box">
|
||||
<div class="tree-title-row">
|
||||
<div class="section-title">层级树</div>
|
||||
<button v-if="spineObject.bones.length" class="tree-action" @click="toggleAllBones">{{ allBonesCollapsed ? '全部展开' : '全部折叠' }}</button>
|
||||
</div>
|
||||
<div v-if="!spineObject.bones.length" class="empty">未加载骨架</div>
|
||||
<div v-else class="bone-tree">
|
||||
<div v-for="bone in visibleBones" :key="bone.name" class="bone" :style="{ paddingLeft: `${6 + bone.depth * 16}px` }">
|
||||
<button
|
||||
v-if="parentBoneNames.has(bone.name)"
|
||||
class="bone-toggle"
|
||||
:title="collapsedBones.has(bone.name) ? '展开子骨骼' : '折叠子骨骼'"
|
||||
@click="toggleBone(bone.name)"
|
||||
>{{ collapsedBones.has(bone.name) ? '▸' : '▾' }}</button>
|
||||
<span v-else class="bone-leaf">•</span>
|
||||
<span class="bone-name">{{ bone.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useParticleStore } from '../store/particleStore'
|
||||
import { parseSpineBundle } from './spineAssetLoader'
|
||||
import { registerSpineAsset, releaseSpineAsset } from './spineAssetRegistry'
|
||||
import type { SpineSceneObject } from './spineTypes'
|
||||
|
||||
const props = defineProps<{ spineObject: SpineSceneObject }>()
|
||||
const store = useParticleStore()
|
||||
const skeletonInput = ref<HTMLInputElement | null>(null)
|
||||
const atlasInput = ref<HTMLInputElement | null>(null)
|
||||
const textureInput = ref<HTMLInputElement | null>(null)
|
||||
const collapsedBones = ref(new Set<string>())
|
||||
const skeletonFile = ref<File | null>(null)
|
||||
const atlasFile = ref<File | null>(null)
|
||||
const textureFiles = ref<File[]>([])
|
||||
|
||||
const skeletonName = computed(() => skeletonFile.value?.name || props.spineObject.skeletonFileName)
|
||||
const atlasName = computed(() => atlasFile.value?.name || props.spineObject.atlasFileName)
|
||||
const textureNames = computed(() => textureFiles.value.length ? textureFiles.value.map((file) => file.name) : props.spineObject.textureFileNames)
|
||||
const textureSummary = computed(() => textureNames.value.length ? `${textureNames.value.length} 张:${textureNames.value.join(', ')}` : 'PNG / JPEG / WebP...')
|
||||
const parentBoneNames = computed(() => new Set(props.spineObject.bones.map((bone) => bone.parent).filter((name): name is string => !!name)))
|
||||
const boneByName = computed(() => new Map(props.spineObject.bones.map((bone) => [bone.name, bone])))
|
||||
const visibleBones = computed(() => props.spineObject.bones.filter((bone) => {
|
||||
let parent = bone.parent
|
||||
while (parent) {
|
||||
if (collapsedBones.value.has(parent)) return false
|
||||
parent = boneByName.value.get(parent)?.parent || null
|
||||
}
|
||||
return true
|
||||
}))
|
||||
const allBonesCollapsed = computed(() => parentBoneNames.value.size > 0 && [...parentBoneNames.value].every((name) => collapsedBones.value.has(name)))
|
||||
|
||||
function toggleBone(name: string) {
|
||||
if (collapsedBones.value.has(name)) collapsedBones.value.delete(name)
|
||||
else collapsedBones.value.add(name)
|
||||
}
|
||||
|
||||
function toggleAllBones() {
|
||||
collapsedBones.value = allBonesCollapsed.value ? new Set() : new Set(parentBoneNames.value)
|
||||
}
|
||||
|
||||
function firstFile(event: Event) {
|
||||
return (event.target as HTMLInputElement).files?.[0] || null
|
||||
}
|
||||
function selectSkeleton(event: Event) { skeletonFile.value = firstFile(event) }
|
||||
function selectAtlas(event: Event) { atlasFile.value = firstFile(event) }
|
||||
function selectTextures(event: Event) { textureFiles.value = Array.from((event.target as HTMLInputElement).files || []) }
|
||||
|
||||
function recalcTimeline() {
|
||||
store.recalcTotalFrames()
|
||||
}
|
||||
|
||||
function onAnimationChange() {
|
||||
recalcTimeline()
|
||||
store.timeline.frame = 0
|
||||
}
|
||||
|
||||
async function loadBundle() {
|
||||
const skeleton = skeletonFile.value
|
||||
const atlas = atlasFile.value
|
||||
if (!skeleton || !atlas || !textureFiles.value.length) {
|
||||
props.spineObject.status = 'error'
|
||||
props.spineObject.error = '请依次选择骨架数据、Atlas 和全部纹理图片'
|
||||
return
|
||||
}
|
||||
props.spineObject.status = 'loading'
|
||||
props.spineObject.error = ''
|
||||
try {
|
||||
const parsed = await parseSpineBundle(skeleton, atlas, textureFiles.value)
|
||||
registerSpineAsset(props.spineObject.id, parsed)
|
||||
props.spineObject.skeletonFileName = skeleton.name
|
||||
props.spineObject.atlasFileName = atlas.name
|
||||
props.spineObject.textureFileNames = textureFiles.value.map((file) => file.name)
|
||||
props.spineObject.animations = parsed.animations
|
||||
props.spineObject.bones = parsed.bones
|
||||
collapsedBones.value = new Set()
|
||||
props.spineObject.selectedAnimation = parsed.animations[0]?.name || ''
|
||||
props.spineObject.assetVersion++
|
||||
props.spineObject.status = 'ready'
|
||||
recalcTimeline()
|
||||
store.timeline.frame = 0
|
||||
} catch (error) {
|
||||
props.spineObject.status = 'error'
|
||||
props.spineObject.error = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
function clearBundle() {
|
||||
releaseSpineAsset(props.spineObject.id)
|
||||
skeletonFile.value = null
|
||||
atlasFile.value = null
|
||||
textureFiles.value = []
|
||||
if (skeletonInput.value) skeletonInput.value.value = ''
|
||||
if (atlasInput.value) atlasInput.value.value = ''
|
||||
if (textureInput.value) textureInput.value.value = ''
|
||||
props.spineObject.skeletonFileName = ''
|
||||
props.spineObject.atlasFileName = ''
|
||||
props.spineObject.textureFileNames = []
|
||||
props.spineObject.animations = []
|
||||
props.spineObject.bones = []
|
||||
collapsedBones.value = new Set()
|
||||
props.spineObject.selectedAnimation = ''
|
||||
props.spineObject.assetVersion++
|
||||
props.spineObject.status = 'empty'
|
||||
props.spineObject.error = ''
|
||||
recalcTimeline()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.spine-editor { display: grid; gap: 10px; color: #c5cbe0; }
|
||||
.editor-title,.section-title { color: #dce2f5; font-weight: 700; }
|
||||
.editor-title { padding-bottom: 9px; border-bottom: 1px solid #30394d; }
|
||||
.grid { display: grid; gap: 8px; }
|
||||
.grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.field { display: grid; gap: 5px; min-width: 0; color: #919bb7; font-size: 12px; }
|
||||
.field input,.field select,.asset-name { box-sizing: border-box; width: 100%; min-width: 0; height: 35px; padding: 0 10px; color: #e2e6f3; background: #101625; border: 1px solid #32405b; border-radius: 6px; }
|
||||
.divider { height: 1px; background: #30394d; margin: 2px 0; }
|
||||
.asset-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
|
||||
.asset-name { display: flex; align-items: center; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: #7f8aa7; }
|
||||
button { height: 35px; padding: 0 13px; border: 1px solid #3b4a67; border-radius: 6px; background: #33415b; color: #e2e6f3; cursor: pointer; }
|
||||
button:hover { filter: brightness(1.13); }
|
||||
button:disabled { opacity: .55; cursor: wait; }
|
||||
.asset-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
|
||||
.asset-actions .load { background: #5543df; border-color: #6959ec; }
|
||||
.asset-actions .clear { background: #702b34; border-color: #803842; }
|
||||
.runtime-note { color: #69758e; font-size: 11px; text-align: center; }
|
||||
.error { padding: 8px 10px; color: #ffabb2; background: #4b2028; border: 1px solid #76323e; border-radius: 6px; font-size: 12px; }
|
||||
.section-box { display: grid; gap: 8px; padding: 10px; background: #182132; border: 1px solid #303c54; border-radius: 7px; }
|
||||
.empty { color: #667187; font-size: 12px; padding: 5px 0; }
|
||||
.bone-tree { max-height: 240px; overflow: auto; padding: 4px 0; background: #111827; border-radius: 5px; }
|
||||
.tree-title-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.tree-action { height: 24px; padding: 0 8px; color: #9aa7c2; background: #242f44; border-color: #35445e; font-size: 10px; }
|
||||
.bone { display: flex; align-items: center; gap: 4px; min-height: 25px; color: #aeb8d2; font-size: 12px; border-bottom: 1px solid rgba(72,84,112,.22); }
|
||||
.bone-toggle { width: 22px; height: 22px; padding: 0; border: 0; background: transparent; color: #9aa7c2; }
|
||||
.bone-toggle:hover { background: #26324a; }
|
||||
.bone-leaf { display: inline-flex; width: 22px; justify-content: center; color: #58647d; }
|
||||
.bone-name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
@media (max-width: 430px) { .grid.three { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Container } from 'pixi.js'
|
||||
import { Spine } from '@esotericsoftware/spine-pixi-v7'
|
||||
import { getSpineAsset } from './spineAssetRegistry'
|
||||
import type { SpineSceneObject } from './spineTypes'
|
||||
|
||||
interface RuntimeEntry {
|
||||
root: Container
|
||||
spine: Spine
|
||||
assetVersion: number
|
||||
animation: string
|
||||
}
|
||||
|
||||
export class SpineRuntimeLayer {
|
||||
readonly container = new Container()
|
||||
private entries = new Map<number, RuntimeEntry>()
|
||||
|
||||
sync(objects: SpineSceneObject[], frame: number, fps: number, showResources = true) {
|
||||
for (const [id, entry] of this.entries) {
|
||||
if (objects.some((object) => object.id === id)) continue
|
||||
this.container.removeChild(entry.root)
|
||||
entry.root.destroy({ children: true })
|
||||
this.entries.delete(id)
|
||||
}
|
||||
|
||||
for (const object of objects) {
|
||||
const asset = getSpineAsset(object.id)
|
||||
let entry = this.entries.get(object.id)
|
||||
if (entry && entry.assetVersion !== object.assetVersion && !asset) {
|
||||
this.container.removeChild(entry.root)
|
||||
entry.root.destroy({ children: true })
|
||||
this.entries.delete(object.id)
|
||||
entry = undefined
|
||||
}
|
||||
if ((!entry || entry.assetVersion !== object.assetVersion) && asset) {
|
||||
if (entry) {
|
||||
this.container.removeChild(entry.root)
|
||||
entry.root.destroy({ children: true })
|
||||
}
|
||||
const root = new Container()
|
||||
const spine = new Spine({ skeletonData: asset.skeletonData, autoUpdate: false, darkTint: false })
|
||||
root.addChild(spine)
|
||||
this.container.addChild(root)
|
||||
entry = { root, spine, assetVersion: object.assetVersion, animation: '' }
|
||||
this.entries.set(object.id, entry)
|
||||
}
|
||||
if (!entry) continue
|
||||
|
||||
const root = entry.root
|
||||
const spine = entry.spine
|
||||
const visible = showResources && object.enabled
|
||||
root.visible = visible
|
||||
root.renderable = visible
|
||||
root.position.set(object.x, -object.y)
|
||||
root.rotation = -object.rotation * Math.PI / 180
|
||||
root.scale.set(object.scaleX, object.scaleY)
|
||||
|
||||
if (entry.animation !== object.selectedAnimation) {
|
||||
entry.animation = object.selectedAnimation
|
||||
spine.skeleton.setToSetupPose()
|
||||
spine.state.clearTracks()
|
||||
// 编辑器时间轴负责整体循环;单个 Spine 动画自身只播放一次并停在末帧。
|
||||
if (entry.animation) spine.state.setAnimation(0, entry.animation, false)
|
||||
}
|
||||
const track = spine.state.getCurrent(0)
|
||||
if (track) {
|
||||
const duration = object.animations.find((animation) => animation.name === object.selectedAnimation)?.duration || 0
|
||||
const localTime = Math.max(0, frame / Math.max(1, fps) - object.timeOffset)
|
||||
track.trackTime = duration > 0 ? Math.min(localTime, duration) : localTime
|
||||
}
|
||||
spine.update(0)
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
for (const entry of this.entries.values()) {
|
||||
entry.root.removeFromParent()
|
||||
entry.root.destroy({ children: true })
|
||||
}
|
||||
this.entries.clear()
|
||||
this.container.removeFromParent()
|
||||
this.container.destroy({ children: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
AtlasAttachmentLoader,
|
||||
SkeletonBinary,
|
||||
SkeletonJson,
|
||||
TextureAtlas,
|
||||
type SkeletonData,
|
||||
} from '@esotericsoftware/spine-core'
|
||||
import { SpineTexture } from '@esotericsoftware/spine-pixi-v7'
|
||||
import { Texture } from 'pixi.js'
|
||||
import type { LoadedSpineAsset } from './spineAssetRegistry'
|
||||
import type { SpineAnimationInfo, SpineBoneInfo } from './spineTypes'
|
||||
|
||||
export interface ParsedSpineBundle extends LoadedSpineAsset {
|
||||
animations: SpineAnimationInfo[]
|
||||
bones: SpineBoneInfo[]
|
||||
}
|
||||
|
||||
function basename(path: string) {
|
||||
return path.replace(/\\/g, '/').split('/').pop() || path
|
||||
}
|
||||
|
||||
async function readTexture(file: File) {
|
||||
const url = URL.createObjectURL(file)
|
||||
try {
|
||||
return await Texture.fromURL(url)
|
||||
} finally {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
function boneMetadata(skeletonData: SkeletonData): SpineBoneInfo[] {
|
||||
const depths = new Map<string, number>()
|
||||
return skeletonData.bones.map((bone) => {
|
||||
const parent = bone.parent?.name || null
|
||||
const depth = parent ? (depths.get(parent) || 0) + 1 : 0
|
||||
depths.set(bone.name, depth)
|
||||
return { name: bone.name, parent, depth }
|
||||
})
|
||||
}
|
||||
|
||||
export async function parseSpineBundle(skeletonFile: File, atlasFile: File, textureFiles: File[]): Promise<ParsedSpineBundle> {
|
||||
if (!/\.(json|skel)$/i.test(skeletonFile.name)) throw new Error('骨架数据仅支持 .json 或 .skel')
|
||||
if (!/\.atlas$/i.test(atlasFile.name)) throw new Error('图集文件仅支持 .atlas')
|
||||
if (!textureFiles.length) throw new Error('请至少选择一张纹理图片')
|
||||
|
||||
const atlas = new TextureAtlas(await atlasFile.text())
|
||||
const fileMap = new Map(textureFiles.map((file) => [basename(file.name).toLowerCase(), file]))
|
||||
const textures: Texture[] = []
|
||||
try {
|
||||
for (const page of atlas.pages) {
|
||||
const file = fileMap.get(basename(page.name).toLowerCase())
|
||||
if (!file) throw new Error(`Atlas 引用的纹理未选择:${page.name}`)
|
||||
const texture = await readTexture(file)
|
||||
textures.push(texture)
|
||||
page.setTexture(SpineTexture.from(texture.baseTexture))
|
||||
}
|
||||
|
||||
const attachmentLoader = new AtlasAttachmentLoader(atlas)
|
||||
let skeletonData: SkeletonData
|
||||
if (/\.skel$/i.test(skeletonFile.name)) {
|
||||
const reader = new SkeletonBinary(attachmentLoader)
|
||||
skeletonData = reader.readSkeletonData(await skeletonFile.arrayBuffer())
|
||||
} else {
|
||||
const reader = new SkeletonJson(attachmentLoader)
|
||||
skeletonData = reader.readSkeletonData(await skeletonFile.text())
|
||||
}
|
||||
|
||||
return {
|
||||
skeletonData,
|
||||
textures,
|
||||
animations: skeletonData.animations.map((animation) => ({ name: animation.name, duration: animation.duration })),
|
||||
bones: boneMetadata(skeletonData),
|
||||
}
|
||||
} catch (error) {
|
||||
for (const texture of textures) texture.destroy(true)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { SkeletonData } from '@esotericsoftware/spine-core'
|
||||
import type { Texture } from 'pixi.js'
|
||||
|
||||
export interface LoadedSpineAsset {
|
||||
skeletonData: SkeletonData
|
||||
textures: Texture[]
|
||||
}
|
||||
|
||||
const assets = new Map<number, LoadedSpineAsset>()
|
||||
|
||||
export function getSpineAsset(id: number) {
|
||||
return assets.get(id) || null
|
||||
}
|
||||
|
||||
export function registerSpineAsset(id: number, asset: LoadedSpineAsset) {
|
||||
releaseSpineAsset(id)
|
||||
assets.set(id, asset)
|
||||
}
|
||||
|
||||
export function releaseSpineAsset(id: number) {
|
||||
const previous = assets.get(id)
|
||||
if (!previous) return
|
||||
for (const texture of previous.textures) texture.destroy(true)
|
||||
assets.delete(id)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
export interface SpineAnimationInfo {
|
||||
name: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
export interface SpineBoneInfo {
|
||||
name: string
|
||||
parent: string | null
|
||||
depth: number
|
||||
}
|
||||
|
||||
export interface SpineSceneObject {
|
||||
id: number
|
||||
name: string
|
||||
enabled: boolean
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
scaleX: number
|
||||
scaleY: number
|
||||
timeOffset: number
|
||||
skeletonFileName: string
|
||||
atlasFileName: string
|
||||
textureFileNames: string[]
|
||||
selectedAnimation: string
|
||||
animations: SpineAnimationInfo[]
|
||||
bones: SpineBoneInfo[]
|
||||
assetVersion: number
|
||||
status: 'empty' | 'loading' | 'ready' | 'error'
|
||||
error: string
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { EmitterConfig, defaultConfig, ParticleState, type SceneCollider } from '../core/particleEmitter'
|
||||
import type { SpineSceneObject } from '../spine/spineTypes'
|
||||
import { releaseSpineAsset } from '../spine/spineAssetRegistry'
|
||||
|
||||
export interface ParticleSystem {
|
||||
id: number
|
||||
@@ -22,6 +24,8 @@ export interface ScenePath {
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
scaleX: number
|
||||
scaleY: number
|
||||
closed: boolean
|
||||
color: string
|
||||
preset: '' | 'circle' | 'square' | 'polygon' | 'star'
|
||||
@@ -48,6 +52,8 @@ export interface SettingsState {
|
||||
showAxisLabels: boolean
|
||||
/** 坐标轴数值颜色(#rrggbb) */
|
||||
axisColor: string
|
||||
/** 是否在画布中显示已加载的 Spine 动画资源 */
|
||||
showSpineResources: boolean
|
||||
}
|
||||
|
||||
export interface TimelineState {
|
||||
@@ -70,6 +76,7 @@ export interface TimelineState {
|
||||
let seq = 1
|
||||
let colliderSeq = 1
|
||||
let pathSeq = 1
|
||||
let spineSeq = 1
|
||||
|
||||
export const useParticleStore = defineStore('particle', {
|
||||
state: () => ({
|
||||
@@ -77,12 +84,13 @@ export const useParticleStore = defineStore('particle', {
|
||||
systems: [] as ParticleSystem[],
|
||||
colliders: [] as CollisionBody[],
|
||||
paths: [] as ScenePath[],
|
||||
spines: [] as SpineSceneObject[],
|
||||
activeId: 0,
|
||||
activeObjectType: 'particle' as SceneObjectType,
|
||||
activeObjectId: 0,
|
||||
// 与 Spine 编辑器默认时间基准对齐:1 秒 = 30 帧。
|
||||
timeline: { playing: true, loop: true, frame: 0, totalFrames: 60, fps: 30, recorded: false, animation: 'animation' } as TimelineState,
|
||||
settings: { tickEnabled: false, axisFontSize: 12, tickWidth: 1, showAxisLabels: true, axisColor: '#7a86ad' } as SettingsState,
|
||||
settings: { tickEnabled: false, axisFontSize: 12, tickWidth: 1, showAxisLabels: true, axisColor: '#7a86ad', showSpineResources: true } as SettingsState,
|
||||
}),
|
||||
getters: {
|
||||
activeSystemCount: (s) => s.systems.length,
|
||||
@@ -136,6 +144,8 @@ export const useParticleStore = defineStore('particle', {
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
radius: 50,
|
||||
radiusX: 70,
|
||||
radiusY: 45,
|
||||
@@ -161,6 +171,8 @@ export const useParticleStore = defineStore('particle', {
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
closed: false,
|
||||
color: '#ff933e',
|
||||
preset: '',
|
||||
@@ -171,6 +183,33 @@ export const useParticleStore = defineStore('particle', {
|
||||
this.activeObjectId = path.id
|
||||
return path
|
||||
},
|
||||
addSpine() {
|
||||
const id = spineSeq++
|
||||
const spine: SpineSceneObject = {
|
||||
id,
|
||||
name: `SpineSkeleton${id}`,
|
||||
enabled: true,
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
timeOffset: 0,
|
||||
skeletonFileName: '',
|
||||
atlasFileName: '',
|
||||
textureFileNames: [],
|
||||
selectedAnimation: '',
|
||||
animations: [],
|
||||
bones: [],
|
||||
assetVersion: 0,
|
||||
status: 'empty',
|
||||
error: '',
|
||||
}
|
||||
this.spines.push(spine)
|
||||
this.activeObjectType = 'spine'
|
||||
this.activeObjectId = spine.id
|
||||
return spine
|
||||
},
|
||||
selectSceneObject(type: SceneObjectType, id: number) {
|
||||
this.activeObjectType = type
|
||||
this.activeObjectId = id
|
||||
@@ -194,10 +233,48 @@ export const useParticleStore = defineStore('particle', {
|
||||
this.activeObjectId = fallback?.id ?? this.paths[0]?.id ?? 0
|
||||
}
|
||||
},
|
||||
removeSpine(id: number) {
|
||||
releaseSpineAsset(id)
|
||||
const index = this.spines.findIndex((item) => item.id === id)
|
||||
if (index >= 0) this.spines.splice(index, 1)
|
||||
if (this.activeObjectType === 'spine' && this.activeObjectId === id) {
|
||||
const particle = this.systems[0]
|
||||
const collider = this.colliders[0]
|
||||
const path = this.paths[0]
|
||||
const spine = this.spines[0]
|
||||
if (particle) {
|
||||
this.activeObjectType = 'particle'
|
||||
this.activeObjectId = particle.id
|
||||
this.activeId = particle.id
|
||||
} else if (collider) {
|
||||
this.activeObjectType = 'collision'
|
||||
this.activeObjectId = collider.id
|
||||
} else if (path) {
|
||||
this.activeObjectType = 'path'
|
||||
this.activeObjectId = path.id
|
||||
} else {
|
||||
this.activeObjectType = 'spine'
|
||||
this.activeObjectId = spine?.id ?? 0
|
||||
}
|
||||
}
|
||||
this.recalcTotalFrames()
|
||||
},
|
||||
play() { this.timeline.playing = true },
|
||||
pause() { this.timeline.playing = false },
|
||||
togglePlay() { this.timeline.playing = !this.timeline.playing },
|
||||
toStart() { this.timeline.frame = 0 },
|
||||
previousFrame() {
|
||||
this.timeline.playing = false
|
||||
this.timeline.frame = Math.max(0, this.timeline.frame - 1)
|
||||
},
|
||||
nextFrame() {
|
||||
this.timeline.playing = false
|
||||
this.timeline.frame = Math.min(Math.max(0, this.timeline.totalFrames - 1), this.timeline.frame + 1)
|
||||
},
|
||||
toEnd() {
|
||||
this.timeline.playing = false
|
||||
this.timeline.frame = Math.max(0, this.timeline.totalFrames - 1)
|
||||
},
|
||||
toggleLoop() { this.timeline.loop = !this.timeline.loop },
|
||||
setFrame(f: number) {
|
||||
this.timeline.frame = Math.max(0, Math.min(f, this.timeline.totalFrames - 1))
|
||||
@@ -220,6 +297,12 @@ export const useParticleStore = defineStore('particle', {
|
||||
const end = Math.max(0, Number(c.delay) || 0) + emissionDuration + particleLife
|
||||
if (end > maxSec) maxSec = end
|
||||
}
|
||||
for (const spine of this.spines) {
|
||||
const animation = spine.animations.find((item) => item.name === spine.selectedAnimation)
|
||||
if (!animation) continue
|
||||
const end = Math.max(0, Number(spine.timeOffset) || 0) + Math.max(0, animation.duration || 0)
|
||||
if (end > maxSec) maxSec = end
|
||||
}
|
||||
const frames = Math.max(1, Math.ceil(maxSec * fps))
|
||||
this.timeline.totalFrames = frames
|
||||
// clamp 当前帧
|
||||
|
||||
+35
-19
@@ -12,7 +12,7 @@
|
||||
<button class="scene-add" title="添加粒子系统" @click.stop="addSys">+粒子</button>
|
||||
<button class="scene-add" title="添加碰撞体" @click.stop="addCollider">+碰撞</button>
|
||||
<button class="scene-add" title="添加路径" @click.stop="addPath">+路径</button>
|
||||
<button class="scene-add placeholder-action" title="添加 Spine 骨架(待开发)" @click.stop="showScenePlaceholder('Spine 骨架')">+骨架</button>
|
||||
<button class="scene-add" title="添加 Spine 骨架" @click.stop="addSpine">+骨架</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="group-body" v-show="!collapsed.scene">
|
||||
@@ -53,7 +53,19 @@
|
||||
<span class="scene-object-kind">路径</span>
|
||||
<button class="scene-object-delete" title="删除路径" @click.stop="store.removePath(path.id)">✕</button>
|
||||
</div>
|
||||
<div v-if="!store.systems.length && !store.colliders.length && !store.paths.length" class="empty">场景中暂无对象</div>
|
||||
<div
|
||||
v-for="spine in store.spines"
|
||||
:key="`spine-${spine.id}`"
|
||||
class="tree-item scene-object-item"
|
||||
:class="{ on: activeObjectType === 'spine' && spine.id === store.activeObjectId }"
|
||||
@click="store.selectSceneObject('spine', spine.id)"
|
||||
>
|
||||
<span class="tdot spine-dot">◆</span>
|
||||
<span class="tlabel">{{ spine.name }}</span>
|
||||
<span class="scene-object-kind">骨架</span>
|
||||
<button class="scene-object-delete" title="删除骨架" @click.stop="store.removeSpine(spine.id)">✕</button>
|
||||
</div>
|
||||
<div v-if="!store.systems.length && !store.colliders.length && !store.paths.length && !store.spines.length" class="empty">场景中暂无对象</div>
|
||||
</div>
|
||||
<template v-if="activeObjectType === 'particle' && sys">
|
||||
<label class="row"><span>名称</span><input v-model="sys.config.name" class="inp" /></label>
|
||||
@@ -92,6 +104,10 @@
|
||||
<label class="compact-field"><span>Y 位置</span><input v-model.number="activeCollider.y" type="number" class="inp" /></label>
|
||||
</div>
|
||||
<NumSlider label="旋转" :min="-360" :max="360" :step="1" v-model="activeCollider.rotation" />
|
||||
<div class="field-grid">
|
||||
<label class="compact-field"><span>缩放 X</span><input v-model.number="activeCollider.scaleX" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||||
<label class="compact-field"><span>缩放 Y</span><input v-model.number="activeCollider.scaleY" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||||
</div>
|
||||
<NumSlider v-if="activeCollider.shape === 'circle'" label="半径" :min="1" :max="1000" :step="1" v-model="activeCollider.radius" />
|
||||
<div v-else-if="activeCollider.shape === 'ellipse'" class="field-grid">
|
||||
<label class="compact-field"><span>X 半径</span><input v-model.number="activeCollider.radiusX" type="number" min="1" class="inp" /></label>
|
||||
@@ -114,6 +130,15 @@
|
||||
<template v-else-if="activePath">
|
||||
<div class="object-editor-title">路径属性</div>
|
||||
<label class="field-block"><span>轨道名称</span><input v-model="activePath.name" class="inp" /></label>
|
||||
<div class="field-grid">
|
||||
<label class="compact-field"><span>X 位置</span><input v-model.number="activePath.x" type="number" class="inp" /></label>
|
||||
<label class="compact-field"><span>Y 位置</span><input v-model.number="activePath.y" type="number" class="inp" /></label>
|
||||
</div>
|
||||
<NumSlider label="旋转" :min="-360" :max="360" :step="1" v-model="activePath.rotation" />
|
||||
<div class="field-grid">
|
||||
<label class="compact-field"><span>缩放 X</span><input v-model.number="activePath.scaleX" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||||
<label class="compact-field"><span>缩放 Y</span><input v-model.number="activePath.scaleY" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||||
</div>
|
||||
<label class="setting-line"><span>闭合轨道</span><input v-model="activePath.closed" type="checkbox" /></label>
|
||||
<label class="setting-line"><span>轨道颜色</span><input v-model="activePath.color" type="color" class="path-color" /></label>
|
||||
<div class="path-help"><span>操作说明:</span><ul>
|
||||
@@ -127,8 +152,8 @@
|
||||
<button class="path-action primary" @click="applyPathPreset">应用预设</button>
|
||||
<button class="path-action" @click="clearPathAnchors">清空锚点</button>
|
||||
</template>
|
||||
<SpineObjectPanel v-else-if="activeSpine" :spine-object="activeSpine" />
|
||||
<div v-else class="panel-hint">使用右上角按钮创建场景对象</div>
|
||||
<div v-if="sceneActionHint" class="scene-action-hint">{{ sceneActionHint }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -681,7 +706,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, markRaw, onUnmounted, reactive, ref, watchEffect } from 'vue'
|
||||
import { computed, markRaw, reactive, ref, watchEffect } from 'vue'
|
||||
import { Texture } from 'pixi.js'
|
||||
import { useParticleStore } from '../store/particleStore'
|
||||
import { defaultConfig, ensureEmitterConfig } from '../core/particleEmitter'
|
||||
@@ -690,6 +715,7 @@ import CurveEditor from './CurveEditor.vue'
|
||||
import ParticleAttributeControl from './ParticleAttributeControl.vue'
|
||||
import ColorGradientEditor from './ColorGradientEditor.vue'
|
||||
import ImageResourceCard from './ImageResourceCard.vue'
|
||||
import SpineObjectPanel from '../spine/SpineObjectPanel.vue'
|
||||
|
||||
const store = useParticleStore()
|
||||
// 默认场景始终有一个可编辑的粒子系统;热更新保留现有系统时不重复创建。
|
||||
@@ -707,6 +733,9 @@ const activeCollider = computed(() => store.activeObjectType === 'collision'
|
||||
const activePath = computed(() => store.activeObjectType === 'path'
|
||||
? store.paths.find((item) => item.id === store.activeObjectId) || null
|
||||
: null)
|
||||
const activeSpine = computed(() => store.activeObjectType === 'spine'
|
||||
? store.spines.find((item) => item.id === store.activeObjectId) || null
|
||||
: null)
|
||||
|
||||
// 开发热更新会保留旧的 Pinia 对象;渲染属性控件前先补齐新增字段。
|
||||
watchEffect(() => {
|
||||
@@ -800,6 +829,7 @@ function select(i: number) { store.selectSystem(store.systems[i]?.id ?? 0) }
|
||||
function addSys() { store.addSystem() }
|
||||
function addCollider() { store.addCollider() }
|
||||
function addPath() { store.addPath() }
|
||||
function addSpine() { store.addSpine() }
|
||||
function addColliderTag() {
|
||||
const collider = activeCollider.value
|
||||
if (!collider) return
|
||||
@@ -840,16 +870,6 @@ function applyPathPreset() {
|
||||
function clearPathAnchors() {
|
||||
if (activePath.value) activePath.value.points = []
|
||||
}
|
||||
const sceneActionHint = ref('')
|
||||
let sceneActionHintTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function showScenePlaceholder(type: string) {
|
||||
sceneActionHint.value = `${type}对象将在后续阶段开发`
|
||||
if (sceneActionHintTimer) clearTimeout(sceneActionHintTimer)
|
||||
sceneActionHintTimer = setTimeout(() => { sceneActionHint.value = '' }, 2400)
|
||||
}
|
||||
onUnmounted(() => {
|
||||
if (sceneActionHintTimer) clearTimeout(sceneActionHintTimer)
|
||||
})
|
||||
function randomizeSeed() {
|
||||
if (sys.value) sys.value.config.seed = Math.floor(Math.random() * 999999) + 1
|
||||
}
|
||||
@@ -1283,11 +1303,6 @@ function toggleResourceLock(resourceId: number) {
|
||||
background: #2e3a55; color: #cdf; font-size: 10px; white-space: nowrap; cursor: pointer;
|
||||
}
|
||||
.scene-add:hover { border-color: #7774ff; background: #45426f; color: #fff; }
|
||||
.scene-add.placeholder-action { background: #232938; color: #9da7be; }
|
||||
.scene-action-hint {
|
||||
margin-top: 8px; padding: 6px 8px; border: 1px dashed #3a4963; border-radius: 5px;
|
||||
background: #181e2b; color: #8f9ab3; font-size: 11px;
|
||||
}
|
||||
.group-body { padding: 8px 10px 12px; }
|
||||
.caret { display: inline-block; width: 12px; color: #667; font-size: 11px; }
|
||||
.divider { height: 1px; background: #2a2a3c; margin: 8px 0; }
|
||||
@@ -1298,6 +1313,7 @@ function toggleResourceLock(resourceId: number) {
|
||||
.tree-item .tdot.dim { color: #556; }
|
||||
.tree-item .tdot.collision-dot { color: #ff7070; }
|
||||
.tree-item .tdot.path-dot { color: #ff933e; font-size: 14px; }
|
||||
.tree-item .tdot.spine-dot { color: #c08cff; font-size: 11px; }
|
||||
.tree-item .tlabel { flex: 1; }
|
||||
.scene-tree { padding-bottom: 8px; border-bottom: 1px solid #2a2a3c; }
|
||||
.scene-object-item { min-height: 28px; cursor: pointer; }
|
||||
|
||||
+159
-71
@@ -35,6 +35,9 @@
|
||||
<label class="sp-chk"><input type="checkbox" v-model="showEmitter" />发射点</label>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="showBones" />骨骼连线</label>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="showSkinMesh" />蒙皮网格</label>
|
||||
<label class="sp-chk" :class="{ disabled: !hasLoadedSpineAnimation }" :title="hasLoadedSpineAnimation ? '显示或隐藏已加载的 Spine 动画资源' : '加载包含动画的 Spine 资源后可用'">
|
||||
<input type="checkbox" v-model="store.settings.showSpineResources" :disabled="!hasLoadedSpineAnimation" />启用 Spine 资源显示
|
||||
</label>
|
||||
<div class="sp-divider"></div>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="store.settings.tickEnabled" />启用刻度</label>
|
||||
<template v-if="store.settings.tickEnabled">
|
||||
@@ -62,10 +65,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Application, Container, Graphics, Text, Texture } from 'pixi.js'
|
||||
import { ParticleEmitter, ensureEmitterConfig, type ParticleState } from '../core/particleEmitter'
|
||||
import { useParticleStore, type ScenePath } from '../store/particleStore'
|
||||
import { SpineRuntimeLayer } from '../spine/SpineRuntimeLayer'
|
||||
|
||||
const store = useParticleStore()
|
||||
const holder = ref<HTMLElement | null>(null)
|
||||
@@ -76,6 +80,8 @@ const showSkinMesh = ref(false)
|
||||
const info = ref<{ systems: number; particles: number } | null>(null)
|
||||
const showGrid = ref(true)
|
||||
const showSettings = ref(false)
|
||||
if (store.settings.showSpineResources == null) store.settings.showSpineResources = true
|
||||
const hasLoadedSpineAnimation = computed(() => store.spines.some((spine) => spine.status === 'ready' && spine.animations.length > 0))
|
||||
// 根变换 gizmo 工具:'translate' | 'rotate' | 'scale' | ''(无)
|
||||
const activeTool = ref<'' | 'translate' | 'rotate' | 'scale'>('')
|
||||
function setTool(t: '' | 'translate' | 'rotate' | 'scale') { activeTool.value = activeTool.value === t ? '' : t }
|
||||
@@ -92,8 +98,11 @@ let pathGfx: Graphics | null = null
|
||||
let gizmoGfx: Graphics | null = null
|
||||
let axisGfx: Graphics | null = null
|
||||
let axisLabelLayer: Container | null = null // 文字图层,不随 world 缩放,避免放大变糊
|
||||
let spineRuntime: SpineRuntimeLayer | null = null
|
||||
let axisLabels: Text[] = []
|
||||
let lastT = 0
|
||||
let timelineAccumulator = 0
|
||||
let timelineDisplayFrame = 0
|
||||
let raf = 0
|
||||
let dotTex: Texture<any> | null = null
|
||||
let trailTex: Texture<any> | null = null
|
||||
@@ -122,6 +131,8 @@ function createLayers() {
|
||||
app!.stage.addChild(axisLabelLayer)
|
||||
axisGfx = new Graphics()
|
||||
world.addChild(axisGfx)
|
||||
spineRuntime = new SpineRuntimeLayer()
|
||||
world.addChild(spineRuntime.container)
|
||||
originGfx = new Graphics()
|
||||
world.addChild(originGfx)
|
||||
shapeGfx = new Graphics() // 发射器形状范围(绿圆/蓝矩形/锥形)
|
||||
@@ -258,17 +269,17 @@ function onWheel(e: WheelEvent) {
|
||||
// 鼠标左键长按拖动画布(平移)
|
||||
function onPanDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
// 激活变换工具时,始终操作场景树中当前选中的对象。
|
||||
if (activeTool.value && selectedTransformTarget()) {
|
||||
onGizmoDown(e)
|
||||
return
|
||||
}
|
||||
if (onPathPointerDown(e)) return
|
||||
if (onColliderPointerDown(e)) return
|
||||
// 吸附点优先于力场;重叠时拖动红色吸附圆。
|
||||
if (onAttractionDown(e)) return
|
||||
// 力场使用全局坐标,按住圆内任意位置时优先拖动力场中心。
|
||||
if (onForceFieldDown(e)) return
|
||||
// gizmo 工具激活且选中系统 → 拖拽根节点变换;否则画布平移
|
||||
if (activeTool.value && store.systems.length) {
|
||||
onGizmoDown(e)
|
||||
return
|
||||
}
|
||||
const startX = e.clientX
|
||||
const startY = e.clientY
|
||||
const vx0 = store.editor.viewX
|
||||
@@ -297,14 +308,18 @@ function pointerToWorldMath(clientX: number, clientY: number) {
|
||||
function pathLocalToWorld(path: ScenePath, x: number, y: number) {
|
||||
const angle = path.rotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
return { x: path.x + cos * x - sin * y, y: path.y + sin * x + cos * y }
|
||||
const scaleX = Math.max(0.0001, Math.abs(path.scaleX ?? 1))
|
||||
const scaleY = Math.max(0.0001, Math.abs(path.scaleY ?? 1))
|
||||
return { x: path.x + cos * x * scaleX - sin * y * scaleY, y: path.y + sin * x * scaleX + cos * y * scaleY }
|
||||
}
|
||||
|
||||
function worldToPathLocal(path: ScenePath, x: number, y: number) {
|
||||
const angle = path.rotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const scaleX = Math.max(0.0001, Math.abs(path.scaleX ?? 1))
|
||||
const scaleY = Math.max(0.0001, Math.abs(path.scaleY ?? 1))
|
||||
const dx = x - path.x, dy = y - path.y
|
||||
return { x: cos * dx + sin * dy, y: -sin * dx + cos * dy }
|
||||
return { x: (cos * dx + sin * dy) / scaleX, y: (-sin * dx + cos * dy) / scaleY }
|
||||
}
|
||||
|
||||
function activePath() {
|
||||
@@ -458,8 +473,10 @@ function onColliderPointerDown(e: PointerEvent) {
|
||||
const pointer = pointerToWorldMath(e.clientX, e.clientY)
|
||||
const angle = collider.rotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const scaleX = Math.max(0.0001, Math.abs(collider.scaleX ?? 1))
|
||||
const scaleY = Math.max(0.0001, Math.abs(collider.scaleY ?? 1))
|
||||
const dx = pointer.x - collider.x, dy = pointer.y - collider.y
|
||||
const localX = cos * dx + sin * dy, localY = -sin * dx + cos * dy
|
||||
const localX = (cos * dx + sin * dy) / scaleX, localY = (-sin * dx + cos * dy) / scaleY
|
||||
const hit = collider.shape === 'circle'
|
||||
? Math.hypot(localX, localY) <= collider.radius
|
||||
: collider.shape === 'ellipse'
|
||||
@@ -534,21 +551,61 @@ function onAttractionDown(e: PointerEvent) {
|
||||
return true
|
||||
}
|
||||
|
||||
// root gizmo 拖拽:位移 / 旋转 / 缩放(改当前选中系统的 config,由 loop 实时应用)
|
||||
type SceneTransformTarget = {
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
scaleX: number
|
||||
scaleY: number
|
||||
setPosition: (x: number, y: number) => void
|
||||
setRotation: (rotation: number) => void
|
||||
setScale: (scaleX: number, scaleY: number) => void
|
||||
}
|
||||
|
||||
function selectedTransformTarget(): SceneTransformTarget | null {
|
||||
const id = store.activeObjectId
|
||||
if (store.activeObjectType === 'particle') {
|
||||
const system = store.systems.find((item) => item.id === id)
|
||||
if (!system) return null
|
||||
const config = system.config
|
||||
return {
|
||||
x: config.centerX, y: config.centerY, rotation: config.rootRotation,
|
||||
scaleX: config.rootScaleX ?? 1, scaleY: config.rootScaleY ?? 1,
|
||||
setPosition: (x, y) => { config.centerX = x; config.centerY = y },
|
||||
setRotation: (rotation) => { config.rootRotation = rotation },
|
||||
setScale: (scaleX, scaleY) => { config.rootScaleX = scaleX; config.rootScaleY = scaleY },
|
||||
}
|
||||
}
|
||||
const collection = store.activeObjectType === 'collision'
|
||||
? store.colliders
|
||||
: store.activeObjectType === 'path'
|
||||
? store.paths
|
||||
: store.spines
|
||||
const object = collection.find((item) => item.id === id) as any
|
||||
if (!object) return null
|
||||
return {
|
||||
x: Number(object.x) || 0, y: Number(object.y) || 0, rotation: Number(object.rotation) || 0,
|
||||
scaleX: Math.max(0.05, Number(object.scaleX) || 1), scaleY: Math.max(0.05, Number(object.scaleY) || 1),
|
||||
setPosition: (x, y) => { object.x = x; object.y = y },
|
||||
setRotation: (rotation) => { object.rotation = rotation },
|
||||
setScale: (scaleX, scaleY) => { object.scaleX = scaleX; object.scaleY = scaleY },
|
||||
}
|
||||
}
|
||||
|
||||
// 场景对象 gizmo 拖拽:作用于场景树当前选中对象。
|
||||
function onGizmoDown(e: PointerEvent) {
|
||||
const sys = store.systems.find((s) => s.id === store.activeId)
|
||||
if (!sys) return
|
||||
const cfg = sys.config
|
||||
const target = selectedTransformTarget()
|
||||
if (!target) return
|
||||
const sc = store.editor.viewScale
|
||||
// root 屏幕位置(中心)
|
||||
const cx = app!.screen.width / 2 + store.editor.viewX + cfg.centerX * sc
|
||||
const cy = app!.screen.height * 0.5 + store.editor.viewY + (-cfg.centerY) * sc
|
||||
const rect = holder.value!.getBoundingClientRect()
|
||||
const cx = rect.left + app!.screen.width / 2 + store.editor.viewX + target.x * sc
|
||||
const cy = rect.top + app!.screen.height * 0.5 + store.editor.viewY + (-target.y) * sc
|
||||
const startX = e.clientX, startY = e.clientY
|
||||
const tool = activeTool.value
|
||||
// 初始基准(按下时快照,后续基于快照 + 相对按下点的总增量,避免累积误差/抖动)
|
||||
const sCx = cfg.centerX, sCy = cfg.centerY
|
||||
const sRot = cfg.rootRotation
|
||||
const sSx = cfg.rootScaleX, sSy = cfg.rootScaleY
|
||||
const sCx = target.x, sCy = target.y
|
||||
const sRot = target.rotation
|
||||
const sSx = target.scaleX, sSy = target.scaleY
|
||||
const startDist = Math.hypot(startX - cx, startY - cy) || 1
|
||||
const startAngle = Math.atan2(startY - cy, startX - cx)
|
||||
|
||||
@@ -557,18 +614,16 @@ function onGizmoDown(e: PointerEvent) {
|
||||
const dy = (ev.clientY - startY) / sc
|
||||
if (tool === 'translate') {
|
||||
// 位移:按下基准 + 总位移(离手势,不累积)
|
||||
cfg.centerX = sCx + dx
|
||||
cfg.centerY = sCy - dy // y 数学上正
|
||||
target.setPosition(sCx + dx, sCy - dy)
|
||||
} else if (tool === 'rotate') {
|
||||
const a = Math.atan2(ev.clientY - cy, ev.clientX - cx)
|
||||
// 跟手:拖拽环动方向 = 旋转方向(逆时针环动→逆时针,顺时针环动→顺时针)。
|
||||
// 渲染层 em.rotation=-rootRotation(屏幕坐标),故这里对屏幕 atan2 增量取反,使 rootRotation 在数学坐标下与拖拽环动同向。
|
||||
cfg.rootRotation = sRot - (a - startAngle) * 180 / Math.PI
|
||||
target.setRotation(sRot - (a - startAngle) * 180 / Math.PI)
|
||||
} else if (tool === 'scale') {
|
||||
const nd = Math.hypot(ev.clientX - cx, ev.clientY - cy)
|
||||
const f = nd / startDist
|
||||
cfg.rootScaleX = Math.max(0.05, sSx * f)
|
||||
cfg.rootScaleY = Math.max(0.05, sSy * f)
|
||||
target.setScale(Math.max(0.05, sSx * f), Math.max(0.05, sSy * f))
|
||||
}
|
||||
}
|
||||
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); window.removeEventListener('pointercancel', up) }
|
||||
@@ -750,21 +805,18 @@ function drawAttraction() {
|
||||
g.endFill()
|
||||
}
|
||||
|
||||
// 根变换 gizmo 手柄:当前选中系统,按 activeTool 绘制(位移/旋转/缩放)。中心 = root 位置。
|
||||
// 手柄几何在 root 局部坐标系定义(原点 = root 中心,ly 数学上正),经 root 缩放+旋转+平移到 root 中心,
|
||||
// 与 drawShapeRange 的变换一致 → 手柄随 root 旋转/缩放/位移实时变化。
|
||||
// 当前场景对象的变换手柄:跟随其位置、旋转和缩放。
|
||||
function drawGizmo() {
|
||||
if (!gizmoGfx) return
|
||||
const g = gizmoGfx
|
||||
g.clear()
|
||||
if (!activeTool.value) return
|
||||
const sys = store.systems.find((s) => s.id === store.activeId)
|
||||
if (!sys) return
|
||||
const c = sys.config
|
||||
const cx = c.centerX ?? 0
|
||||
const cy = -(c.centerY ?? 0) // 数学上正(屏幕取负)
|
||||
const sxr = c.rootScaleX ?? 1, syr = c.rootScaleY ?? 1
|
||||
const rot = -(c.rootRotation ?? 0) * Math.PI / 180 // 数学上正 → 屏幕:逆时针为正
|
||||
const target = selectedTransformTarget()
|
||||
if (!target) return
|
||||
const cx = target.x
|
||||
const cy = -target.y
|
||||
const sxr = target.scaleX, syr = target.scaleY
|
||||
const rot = -target.rotation * Math.PI / 180
|
||||
const cs = Math.cos(rot), sn = Math.sin(rot)
|
||||
// 局部点(数学坐标,ly 上正) → 屏幕点(应用 root 缩放+旋转+平移到 root 中心)
|
||||
const pt = (lx: number, ly: number): [number, number] => {
|
||||
@@ -861,9 +913,11 @@ function drawCollisionBodies() {
|
||||
const color = collider.enabled ? (selected ? 0xff5e72 : 0xd4475b) : 0x586174
|
||||
const angle = collider.rotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const scaleX = Math.max(0.0001, Math.abs(collider.scaleX ?? 1))
|
||||
const scaleY = Math.max(0.0001, Math.abs(collider.scaleY ?? 1))
|
||||
const point = (x: number, y: number) => ({
|
||||
x: collider.x + cos * x - sin * y,
|
||||
y: -(collider.y + sin * x + cos * y),
|
||||
x: collider.x + cos * x * scaleX - sin * y * scaleY,
|
||||
y: -(collider.y + sin * x * scaleX + cos * y * scaleY),
|
||||
})
|
||||
let points: Array<{ x: number; y: number }> = []
|
||||
if (collider.shape === 'circle' || collider.shape === 'ellipse') {
|
||||
@@ -1067,49 +1121,77 @@ function loop() {
|
||||
const collected: BonePreview[] = []
|
||||
const tl = store.timeline
|
||||
const fps = tl.fps
|
||||
const dt = 1 / fps // 固定帧步长,保证帧对齐
|
||||
const frameDuration = 1 / Math.max(1, fps)
|
||||
let displayFrame = timelineDisplayFrame
|
||||
let advanceSteps = 0
|
||||
if (tl.playing) {
|
||||
if (!tl.recorded) {
|
||||
// 录制阶段:从头开始,逐帧模拟(固定步长)并记录每帧粒子
|
||||
if (tl.frame === 0) { for (const [, em] of emitterMap) em.reset() }
|
||||
for (const [, em] of emitterMap) {
|
||||
const st = em.update(dt)
|
||||
for (const s of st) if (s.active) appendStateBones(collected, s)
|
||||
total += em.activeCount
|
||||
// requestAnimationFrame 只负责刷新画面;真实时间累计满 1/fps 秒才推进一帧。
|
||||
timelineAccumulator += dt
|
||||
advanceSteps = Math.floor(timelineAccumulator / frameDuration)
|
||||
if (advanceSteps > 0) timelineAccumulator -= advanceSteps * frameDuration
|
||||
} else {
|
||||
// 暂停时清空余量,恢复播放后从完整的一帧时长重新计时。
|
||||
timelineAccumulator = 0
|
||||
}
|
||||
|
||||
if (tl.playing && advanceSteps > 0) {
|
||||
// dt 已限制为最多 0.1 秒,因此30 FPS下单次最多补算3帧,不会出现无界追帧。
|
||||
for (let step = 0; step < advanceSteps; step++) {
|
||||
displayFrame = tl.frame
|
||||
collected.length = 0
|
||||
total = 0
|
||||
if (!tl.recorded) {
|
||||
// 录制阶段:固定步长模拟并记录当前帧。
|
||||
if (tl.frame === 0) { for (const [, em] of emitterMap) em.reset() }
|
||||
for (const [, em] of emitterMap) {
|
||||
const states = em.update(frameDuration)
|
||||
for (const state of states) if (state.active) appendStateBones(collected, state)
|
||||
total += em.activeCount
|
||||
}
|
||||
for (const sys of store.systems) {
|
||||
if (!sys.config.texture) continue
|
||||
sys.frames = sys.frames || []
|
||||
const emitter = emitterMap.get(sys.id)
|
||||
if (emitter) sys.frames[tl.frame] = emitter.capture()
|
||||
}
|
||||
tl.frame++
|
||||
if (tl.frame >= tl.totalFrames) { tl.frame = 0; tl.recorded = true }
|
||||
} else {
|
||||
// 回放阶段:每经过一个固定帧时长才应用下一帧。
|
||||
for (const sys of store.systems) {
|
||||
const emitter = emitterMap.get(sys.id)
|
||||
const frame = sys.frames?.[tl.frame]
|
||||
if (emitter && frame) {
|
||||
emitter.apply(frame)
|
||||
for (const state of frame) if (state.active) { appendStateBones(collected, state); total++ }
|
||||
}
|
||||
}
|
||||
tl.frame++
|
||||
if (tl.frame >= tl.totalFrames) {
|
||||
if (tl.loop) tl.frame = 0
|
||||
else { tl.frame = tl.totalFrames - 1; tl.playing = false; break }
|
||||
}
|
||||
}
|
||||
for (const sys of store.systems) {
|
||||
if (!sys.config.texture) continue
|
||||
sys.frames = sys.frames || []
|
||||
const em = emitterMap.get(sys.id)
|
||||
if (em) sys.frames[tl.frame] = em.capture()
|
||||
}
|
||||
tl.frame++
|
||||
if (tl.frame >= tl.totalFrames) { tl.frame = 0; tl.recorded = true } // 录完立即进入回放
|
||||
} else {
|
||||
// 回放阶段:按 frame 显示已录制帧,到尾帧时循环或停止
|
||||
for (const sys of store.systems) {
|
||||
const em = emitterMap.get(sys.id)
|
||||
const fr = sys.frames?.[tl.frame]
|
||||
if (em && fr) { em.apply(fr); for (const s of fr) if (s.active) { appendStateBones(collected, s); total++ } }
|
||||
}
|
||||
tl.frame++
|
||||
if (tl.frame >= tl.totalFrames) { if (tl.loop) tl.frame = 0; else { tl.frame = tl.totalFrames - 1; tl.playing = false } }
|
||||
}
|
||||
} else {
|
||||
// 暂停:若已录制,显示当前帧;否则实时显示
|
||||
// 两个固定帧之间或暂停时保持当前画面,不推进粒子模拟。
|
||||
if (!tl.playing) displayFrame = tl.frame
|
||||
for (const sys of store.systems) {
|
||||
const em = emitterMap.get(sys.id)
|
||||
const fr = sys.frames?.[tl.frame]
|
||||
if (em && tl.recorded && fr) {
|
||||
em.apply(fr)
|
||||
for (const s of fr) if (s.active) { appendStateBones(collected, s); total++ }
|
||||
} else if (em) {
|
||||
const st = em.update(0) // 冻结预览
|
||||
for (const s of st) if (s.active) appendStateBones(collected, s)
|
||||
total += em.activeCount
|
||||
const emitter = emitterMap.get(sys.id)
|
||||
const frame = sys.frames?.[tl.frame]
|
||||
if (!tl.playing && tl.recorded && emitter && frame) {
|
||||
// 暂停、逐帧或手动拖动播放头时立即显示指定的录制帧。
|
||||
emitter.apply(frame)
|
||||
for (const state of frame) if (state.active) { appendStateBones(collected, state); total++ }
|
||||
} else if (emitter) {
|
||||
const states = emitter.update(0)
|
||||
for (const state of states) if (state.active) appendStateBones(collected, state)
|
||||
total += emitter.activeCount
|
||||
}
|
||||
}
|
||||
}
|
||||
timelineDisplayFrame = displayFrame
|
||||
spineRuntime?.sync(store.spines, displayFrame, fps, store.settings.showSpineResources)
|
||||
for (const [, emitter] of emitterMap) emitter.drawTrailMeshDebug(showSkinMesh.value)
|
||||
drawCollisionBodies()
|
||||
drawPaths()
|
||||
@@ -1146,6 +1228,8 @@ onMounted(async () => {
|
||||
// 默认从头播放并循环
|
||||
store.timeline.recorded = false
|
||||
store.timeline.frame = 0
|
||||
timelineAccumulator = 0
|
||||
timelineDisplayFrame = 0
|
||||
store.timeline.playing = true
|
||||
store.timeline.loop = true
|
||||
for (const sys of store.systems) sys.frames = []
|
||||
@@ -1174,6 +1258,8 @@ onMounted(async () => {
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelAnimationFrame(raf)
|
||||
spineRuntime?.destroy()
|
||||
spineRuntime = null
|
||||
if (app) { app.destroy(true, { children: true }); app = null }
|
||||
})
|
||||
</script>
|
||||
@@ -1230,4 +1316,6 @@ b { color: #ffcc33; }
|
||||
.sp-colr { width: 26px; height: 22px; border: none; background: none; cursor: pointer; }
|
||||
.sp-hex { font-size: 11px; color: #889; font-variant-numeric: tabular-nums; }
|
||||
.sp-chk { display: flex; align-items: center; gap: 6px; margin: 8px 0; color: #aab; }
|
||||
.sp-chk.disabled { color: #5f687d; cursor: not-allowed; }
|
||||
.sp-chk.disabled input { cursor: not-allowed; opacity: .55; }
|
||||
</style>
|
||||
|
||||
+72
-3
@@ -5,10 +5,13 @@
|
||||
<button class="tl-btn" :class="{ on: store.timeline.playing }" @click="store.togglePlay()" :title="store.timeline.playing ? '暂停' : '播放'">
|
||||
{{ store.timeline.playing ? '❚❚' : '▶' }}
|
||||
</button>
|
||||
<button class="tl-btn" @click="store.toStart()" title="回到起点">⏮</button>
|
||||
<button class="tl-btn" @click="store.toStart()" title="回到第一帧">⏮</button>
|
||||
<button class="tl-btn" @click="store.previousFrame()" title="前一帧">◀</button>
|
||||
<button class="tl-btn" @click="store.nextFrame()" title="后一帧">▶</button>
|
||||
<button class="tl-btn" @click="store.toEnd()" title="移动到最后一帧">⏭</button>
|
||||
<button class="tl-btn" :class="{ on: store.timeline.loop }" @click="store.toggleLoop()" title="循环">⟳</button>
|
||||
<span class="tl-frame">帧 {{ store.timeline.frame }} / {{ store.timeline.totalFrames }}</span>
|
||||
<span class="tl-legend"><i class="legend-emission"></i>持续发射 <i class="legend-life"></i>粒子存在</span>
|
||||
<span class="tl-legend"><i class="legend-emission"></i>持续发射 <i class="legend-life"></i>粒子存在 <i class="legend-spine"></i>骨架动画</span>
|
||||
<label class="tl-anim">动画
|
||||
<select v-model="store.timeline.animation" class="tl-sel">
|
||||
<option value="animation">animation</option>
|
||||
@@ -60,7 +63,19 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!store.systems.length" class="tl-empty">暂无粒子系统</div>
|
||||
<!-- Spine 动画条:紫色长度由动画数据决定,只允许整体拖动开始位置。 -->
|
||||
<div v-for="spine in spineLanes" :key="`spine-${spine.id}`" class="tl-lane spine-lane">
|
||||
<span class="lane-ico spine-ico">◆</span>
|
||||
<div
|
||||
class="spine-bar"
|
||||
:style="{ left: spineLeftPx(spine) + 'px', width: spineWidthPx(spine) + 'px' }"
|
||||
title="拖动调整动画开始时间"
|
||||
@pointerdown.stop="onSpineBarDown($event, spine)"
|
||||
>
|
||||
<span class="lane-name">{{ spine.name }} · {{ spine.selectedAnimation }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!store.systems.length && !spineLanes.length" class="tl-empty">暂无可显示的动画数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,6 +84,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useParticleStore } from '../store/particleStore'
|
||||
import type { SpineSceneObject } from '../spine/spineTypes'
|
||||
|
||||
type SysLike = { id: number; config: any }
|
||||
|
||||
@@ -83,9 +99,13 @@ const viewFrames = ref(120)
|
||||
// 避免 pointermove 每一帧都触发 Stage 清缓存、重置粒子模拟。
|
||||
const draftDelays = ref<Record<number, number>>({})
|
||||
const draftDurations = ref<Record<number, number>>({})
|
||||
const draftSpineStarts = ref<Record<number, number>>({})
|
||||
|
||||
const totalFrames = computed(() => store.timeline.totalFrames)
|
||||
const zoomLabel = computed(() => (viewFrames.value <= 30 ? '放大' : viewFrames.value >= 300 ? '缩小' : '适中'))
|
||||
const spineLanes = computed(() => store.spines.filter((spine) =>
|
||||
spine.selectedAnimation && spine.animations.some((animation) => animation.name === spine.selectedAnimation && animation.duration > 0),
|
||||
))
|
||||
|
||||
// 每像素代表的帧数(视口宽度内放下 viewFrames 帧)
|
||||
function pxPerF() {
|
||||
@@ -141,6 +161,19 @@ function lifeWidthPx(sys: SysLike) {
|
||||
return emissionWidthPx(sys) + (maxParticleLife(sys) * fps.value) / pxPerF()
|
||||
}
|
||||
|
||||
function selectedSpineDuration(spine: SpineSceneObject) {
|
||||
return spine.animations.find((animation) => animation.name === spine.selectedAnimation)?.duration || 0
|
||||
}
|
||||
|
||||
function spineLeftPx(spine: SpineSceneObject) {
|
||||
const start = draftSpineStarts.value[spine.id] ?? Math.max(0, Number(spine.timeOffset) || 0)
|
||||
return (start * fps.value) / pxPerF()
|
||||
}
|
||||
|
||||
function spineWidthPx(spine: SpineSceneObject) {
|
||||
return (selectedSpineDuration(spine) * fps.value) / pxPerF()
|
||||
}
|
||||
|
||||
// 滚轮缩放:只改变视口显示的帧数(左侧恒为 0 帧),不改变播放时长/totalFrames
|
||||
function onWheel(e: WheelEvent) {
|
||||
const factor = e.deltaY < 0 ? 0.8 : 1.25 // 滚上放大(显示更少帧),滚下缩小(显示更多帧)
|
||||
@@ -218,6 +251,34 @@ function onBarResizeDown(e: PointerEvent, sys: SysLike) {
|
||||
window.addEventListener('pointerup', up)
|
||||
window.addEventListener('pointercancel', cancel)
|
||||
}
|
||||
|
||||
// Spine 动画长度来自文件,不能缩放;拖动整条只修改延迟播放时间。
|
||||
function onSpineBarDown(e: PointerEvent, spine: SpineSceneObject) {
|
||||
e.stopPropagation()
|
||||
const startX = e.clientX
|
||||
const startTime = Math.max(0, Number(spine.timeOffset) || 0)
|
||||
const secPerPx = pxPerF() / fps.value
|
||||
const move = (ev: PointerEvent) => {
|
||||
const seconds = Math.max(0, Math.min(startTime + (ev.clientX - startX) * secPerPx, 600))
|
||||
draftSpineStarts.value[spine.id] = Math.round(seconds * fps.value) / fps.value
|
||||
}
|
||||
const finish = (commit: boolean) => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
window.removeEventListener('pointercancel', cancel)
|
||||
const value = draftSpineStarts.value[spine.id]
|
||||
delete draftSpineStarts.value[spine.id]
|
||||
if (commit && value != null && value !== startTime) {
|
||||
spine.timeOffset = value
|
||||
store.recalcTotalFrames()
|
||||
}
|
||||
}
|
||||
const up = () => finish(true)
|
||||
const cancel = () => finish(false)
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
window.addEventListener('pointercancel', cancel)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -230,6 +291,7 @@ function onBarResizeDown(e: PointerEvent, sys: SysLike) {
|
||||
.tl-legend i { display: inline-block; width: 10px; height: 7px; border-radius: 2px; }
|
||||
.legend-emission { margin-left: 4px; background: #2f8b62; }
|
||||
.legend-life { margin-left: 5px; background: #a54551; }
|
||||
.legend-spine { margin-left: 5px; background: #7448b8; }
|
||||
.tl-anim { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #aab; }
|
||||
.tl-sel { background: #1a1a28; border: 1px solid #2e2e44; color: #dde; border-radius: 5px; padding: 2px 6px; font-size: 12px; }
|
||||
.tl-zoom { margin-left: auto; font-size: 11px; color: #667; }
|
||||
@@ -248,5 +310,12 @@ function onBarResizeDown(e: PointerEvent, sys: SysLike) {
|
||||
.bar-emission { position: absolute; inset: 0 auto 0 0; z-index: 1; min-width: 1px; border: 1px solid #45a97d; border-radius: 5px 0 0 5px; background: #2f8b62; box-sizing: border-box; }
|
||||
.bar-handle { position: absolute; z-index: 4; right: -4px; top: -1px; bottom: -1px; width: 8px; cursor: ew-resize; background: rgba(255,255,255,0.18); }
|
||||
.bar-handle:hover { background: rgba(255,255,255,0.35); }
|
||||
.spine-ico { color: #c08cff; }
|
||||
.spine-bar {
|
||||
position: absolute; top: 4px; height: 18px; min-width: 1px; box-sizing: border-box;
|
||||
border: 1px solid #a575ed; border-radius: 5px; background: #7448b8; cursor: grab;
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,.05);
|
||||
}
|
||||
.spine-bar:active { cursor: grabbing; }
|
||||
.tl-empty { color: #667; font-size: 11px; padding: 8px; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user