spine资源引入

This commit is contained in:
tianmo
2026-08-29 03:10:16 +08:00
parent 166b5015c2
commit 5f4667f736
25 changed files with 31151 additions and 9082 deletions
+22 -8
View File
@@ -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)
+217
View File
@@ -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>
+83
View File
@@ -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 })
}
}
+79
View File
@@ -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
}
}
+26
View File
@@ -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)
}
+32
View File
@@ -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
}
+84 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>