spine资源引入
This commit is contained in:
Generated
+32
@@ -8,6 +8,8 @@
|
||||
"name": "spine-particle",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@esotericsoftware/spine-core": "file:vendor/spine/spine-core",
|
||||
"@esotericsoftware/spine-pixi-v7": "file:vendor/spine/spine-pixi-v7",
|
||||
"pinia": "^4.0.3",
|
||||
"pixi.js": "^7.4.3",
|
||||
"vue": "^3.5.13"
|
||||
@@ -509,6 +511,14 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esotericsoftware/spine-core": {
|
||||
"resolved": "vendor/spine/spine-core",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@esotericsoftware/spine-pixi-v7": {
|
||||
"resolved": "vendor/spine/spine-pixi-v7",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
@@ -2735,6 +2745,28 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"vendor/spine/spine-core": {
|
||||
"name": "@esotericsoftware/spine-core",
|
||||
"version": "4.2.119",
|
||||
"license": "LicenseRef-LICENSE"
|
||||
},
|
||||
"vendor/spine/spine-pixi-v7": {
|
||||
"name": "@esotericsoftware/spine-pixi-v7",
|
||||
"version": "4.2.119",
|
||||
"license": "LicenseRef-LICENSE",
|
||||
"dependencies": {
|
||||
"@esotericsoftware/spine-core": "4.2.119"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pixi/assets": "^7.2.4",
|
||||
"@pixi/core": "^7.2.4",
|
||||
"@pixi/display": "^7.2.4",
|
||||
"@pixi/events": "^7.2.4",
|
||||
"@pixi/graphics": "^7.2.4",
|
||||
"@pixi/mesh": "^7.2.4",
|
||||
"@pixi/text": "^7.2.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esotericsoftware/spine-core": "file:vendor/spine/spine-core",
|
||||
"@esotericsoftware/spine-pixi-v7": "file:vendor/spine/spine-pixi-v7",
|
||||
"pinia": "^4.0.3",
|
||||
"pixi.js": "^7.4.3",
|
||||
"vue": "^3.5.13"
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
bingo.png
|
||||
size:1971,1949
|
||||
filter:Linear,Linear
|
||||
pma:true
|
||||
115342da4af40dkkbffubd
|
||||
bounds:2,1442,246,246
|
||||
offsets:5,5,256,256
|
||||
1_1
|
||||
bounds:942,1825,122,162
|
||||
offsets:1,1,124,164
|
||||
rotate:90
|
||||
1_2
|
||||
bounds:1106,1825,122,162
|
||||
offsets:1,1,124,164
|
||||
rotate:90
|
||||
1_3
|
||||
bounds:250,1389,1498,194
|
||||
offsets:1,1,1500,196
|
||||
1_3_light
|
||||
bounds:423,784,1546,242
|
||||
offsets:5,5,1556,252
|
||||
2_1
|
||||
bounds:2,1711,143,177
|
||||
offsets:1,1,145,179
|
||||
rotate:90
|
||||
2_1_light
|
||||
bounds:371,1651,188,185
|
||||
offsets:4,5,197,195
|
||||
2_3
|
||||
bounds:2,1187,1533,200
|
||||
offsets:1,1,1535,202
|
||||
2_3_light
|
||||
bounds:2,292,1575,250
|
||||
offsets:5,4,1586,258
|
||||
3_1
|
||||
bounds:181,1690,188,149
|
||||
offsets:1,1,190,151
|
||||
3_1_light
|
||||
bounds:561,1628,234,198
|
||||
offsets:5,4,244,206
|
||||
3_3
|
||||
bounds:2,544,1695,238
|
||||
offsets:1,1,1697,240
|
||||
3_3_light
|
||||
bounds:2,2,1731,288
|
||||
offsets:7,4,1745,296
|
||||
Dot_001
|
||||
bounds:1233,1604,216,222
|
||||
offsets:20,17,256,256
|
||||
rotate:90
|
||||
hj_glow_007
|
||||
bounds:691,1828,121,119
|
||||
offsets:4,6,128,128
|
||||
jiantou1
|
||||
bounds:797,1621,202,217
|
||||
rotate:90
|
||||
jinbi_00000
|
||||
bounds:1526,1821,126,127
|
||||
offsets:89,85,300,300
|
||||
rotate:90
|
||||
jinbi_00001
|
||||
bounds:1655,1821,126,126
|
||||
offsets:89,86,300,300
|
||||
jinbi_00002
|
||||
bounds:1270,1823,124,126
|
||||
offsets:90,86,300,300
|
||||
rotate:90
|
||||
jinbi_00003
|
||||
bounds:562,1832,115,127
|
||||
offsets:94,85,300,300
|
||||
rotate:90
|
||||
jinbi_00004
|
||||
bounds:309,1841,106,125
|
||||
offsets:98,86,300,300
|
||||
rotate:90
|
||||
jinbi_00005
|
||||
bounds:58,1856,91,123
|
||||
offsets:106,87,300,300
|
||||
rotate:90
|
||||
jinbi_00006
|
||||
bounds:183,1847,100,124
|
||||
offsets:101,87,300,300
|
||||
rotate:90
|
||||
jinbi_00007
|
||||
bounds:436,1838,109,124
|
||||
offsets:97,87,300,300
|
||||
rotate:90
|
||||
jinbi_00008
|
||||
bounds:814,1827,120,126
|
||||
offsets:91,86,300,300
|
||||
rotate:90
|
||||
jinbi_00009
|
||||
bounds:1398,1822,125,126
|
||||
offsets:89,86,300,300
|
||||
rotate:90
|
||||
particle05
|
||||
bounds:2,1893,54,54
|
||||
offsets:5,5,64,64
|
||||
quan1153
|
||||
bounds:1457,1595,232,224
|
||||
offsets:0,5,232,232
|
||||
quan1178_00000
|
||||
bounds:1016,1608,215,215
|
||||
offsets:23,23,256,256
|
||||
rpg_groundray
|
||||
bounds:2,802,383,419
|
||||
offsets:77,61,512,512
|
||||
rotate:90
|
||||
shexian_quan_huang_00000
|
||||
bounds:1537,1028,400,359
|
||||
offsets:53,93,512,512
|
||||
shuzhiguang1113
|
||||
bounds:1783,1819,128,128
|
||||
shuzhiguang1142_00001_00000
|
||||
bounds:1691,1585,232,232
|
||||
|
||||
bingo_2.png
|
||||
size:954,2038
|
||||
filter:Linear,Linear
|
||||
pma:true
|
||||
bg1
|
||||
bounds:2,2,950,2034
|
||||
offsets:1,1,952,2036
|
||||
+30005
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.7 MiB |
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
Spine Runtimes License Agreement
|
||||
Last updated April 5, 2025. Replaces all prior versions.
|
||||
|
||||
Copyright (c) 2013-2025, Esoteric Software LLC
|
||||
|
||||
Integration of the Spine Runtimes into software or otherwise creating
|
||||
derivative works of the Spine Runtimes is permitted under the terms and
|
||||
conditions of Section 2 of the Spine Editor License Agreement:
|
||||
https://esotericsoftware.com/spine-editor-license
|
||||
|
||||
Otherwise, it is permitted to integrate the Spine Runtimes into software
|
||||
or otherwise create derivative works of the Spine Runtimes (collectively,
|
||||
"Products"), provided that each user of the Products must obtain their own
|
||||
Spine Editor license and redistribution of the Products in any form must
|
||||
include this license and copyright notice.
|
||||
|
||||
THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
|
||||
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
|
||||
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
|
||||
BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
|
||||
THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
# Spine Runtimes
|
||||
|
||||
This GitHub project hosts the Spine Runtimes which are needed to use [Spine](http://esotericsoftware.com/) 2D skeletal animation data with various game toolkits.
|
||||
|
||||
## Licensing
|
||||
|
||||
You are welcome to evaluate the Spine Runtimes and the examples we provide in this repository free of charge.
|
||||
|
||||
You can integrate the Spine Runtimes into your software free of charge, but users of your software must have their own [Spine license](https://esotericsoftware.com/spine-purchase). Please make your users aware of this requirement! This option is often chosen by those making development tools, such as an SDK, game toolkit, or software library.
|
||||
|
||||
In order to distribute your software containing the Spine Runtimes to others that don't have a Spine license, you need a [Spine license](https://esotericsoftware.com/spine-purchase) at the time of integration. Then you can distribute your software containing the Spine Runtimes however you like, provided others don't modify it or use it to create new software. If others want to do that, they'll need their own Spine license.
|
||||
|
||||
For the official legal terms governing the Spine Runtimes, please read the [Spine Runtimes License Agreement](http://esotericsoftware.com/spine-runtimes-license) and Section 2 of the [Spine Editor License Agreement](http://esotericsoftware.com/spine-editor-license#s2).
|
||||
|
||||
## Documentation
|
||||
|
||||
See the [Spine Runtimes Guide](http://esotericsoftware.com/spine-runtimes-guide) for detailed information about using the Spine Runtimes. The Spine [documentation page](http://esotericsoftware.com/spine-documentation#runtimesTitle) provides further information about tools and data formats. For runtime specific documentation, refer to the `README.md` file in each runtime directory.
|
||||
|
||||
## Bugs, enhancements, and tasks
|
||||
|
||||
Review our backlog of bugs, enhancements, and tasks in the [spine-runtimes](https://github.com/EsotericSoftware/spine-runtimes/issues) and [spine-editor](https://github.com/EsotericSoftware/spine-editor/issues) issue trackers. Our [roadmap](http://esotericsoftware.com/spine-roadmap) provides a more convenient view of the same issue tracker information.
|
||||
|
||||
## Versioning
|
||||
|
||||
The default branch on GitHub is stable and works with data exported from the latest, non-beta version of the Spine editor. New development is done in an `X.X-beta` branch, which may be a work in progress. Important changes to the runtimes can be reviewed in the [CHANGELOG.md file](CHANGELOG.md). Occasionally the Spine Runtimes are [tagged](https://github.com/EsotericSoftware/spine-runtimes/releases) with the specific Spine editor version they work with.
|
||||
|
||||
It is highly suggested to [freeze](http://esotericsoftware.com/spine-settings#Version) the Spine editor version to match the Spine Runtimes source being used and to update them in lock step. See the [Spine Runtimes Guide](http://esotericsoftware.com/spine-runtime-architecture#Versioning) for more information.
|
||||
|
||||
## Contributing
|
||||
|
||||
In order to merge your contributions, we need a signed [contributor license agreement (CLA)](http://esotericsoftware.com/licenses/cla.txt) from you. You can send a copy of the CLA to contact@esotericsoftware.com.
|
||||
|
||||
When possible, it is best to base your contributions on the current beta branch (`X.X-beta`). Please be sure to follow the style and formatting you find in the respective runtime code to which you are contributing.
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
# Spine 运行时来源
|
||||
|
||||
- 官方仓库:`https://github.com/EsotericSoftware/spine-runtimes`
|
||||
- 兼容分支:`4.2`
|
||||
- 仓库提交:`b81e5a58ed38704aee4f866f0e0ac672623ce914`
|
||||
- 本地包版本:`@esotericsoftware/spine-core@4.2.119`
|
||||
- 本地包版本:`@esotericsoftware/spine-pixi-v7@4.2.119`
|
||||
- 接入目标:PixiJS 7
|
||||
|
||||
两个包已完整放入当前目录并通过 `file:` 依赖引用,项目安装和构建不需要从 npm 下载运行时代码。
|
||||
官方许可证保存在同目录的 `LICENSE`;分发或使用前应确认符合其中的 Spine Editor 许可要求。
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
Spine Runtimes License Agreement
|
||||
Last updated April 5, 2025. Replaces all prior versions.
|
||||
|
||||
Copyright (c) 2013-2025, Esoteric Software LLC
|
||||
|
||||
Integration of the Spine Runtimes into software or otherwise creating
|
||||
derivative works of the Spine Runtimes is permitted under the terms and
|
||||
conditions of Section 2 of the Spine Editor License Agreement:
|
||||
http://esotericsoftware.com/spine-editor-license
|
||||
|
||||
Otherwise, it is permitted to integrate the Spine Runtimes into software
|
||||
or otherwise create derivative works of the Spine Runtimes (collectively,
|
||||
"Products"), provided that each user of the Products must obtain their own
|
||||
Spine Editor license and redistribution of the Products in any form must
|
||||
include this license and copyright notice.
|
||||
|
||||
THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
|
||||
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
|
||||
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
|
||||
BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
|
||||
THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
# spine-ts core
|
||||
|
||||
Please see the top-level [README.md](../README.md) for more information.
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@esotericsoftware/spine-core",
|
||||
"version": "4.2.119",
|
||||
"description": "The official Spine Runtimes for the web.",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/esotericsoftware/spine-runtimes.git"
|
||||
},
|
||||
"keywords": [
|
||||
"gamedev",
|
||||
"animations",
|
||||
"2d",
|
||||
"spine",
|
||||
"game-dev",
|
||||
"runtimes",
|
||||
"skeletal"
|
||||
],
|
||||
"author": "Esoteric Software LLC",
|
||||
"license": "LicenseRef-LICENSE",
|
||||
"bugs": {
|
||||
"url": "https://github.com/esotericsoftware/spine-runtimes/issues"
|
||||
},
|
||||
"homepage": "https://github.com/esotericsoftware/spine-runtimes#readme"
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
# spine-ts Pixi.js v7
|
||||
|
||||
Please see the top-level [README.md](../README.md) for more information.
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@esotericsoftware/spine-pixi-v7",
|
||||
"version": "4.2.119",
|
||||
"description": "The official Spine Runtimes for the web PixiJS v7.",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/esotericsoftware/spine-runtimes.git"
|
||||
},
|
||||
"keywords": [
|
||||
"gamedev",
|
||||
"animations",
|
||||
"2d",
|
||||
"spine",
|
||||
"game-dev",
|
||||
"runtimes",
|
||||
"skeletal"
|
||||
],
|
||||
"author": "Esoteric Software LLC",
|
||||
"license": "LicenseRef-LICENSE",
|
||||
"bugs": {
|
||||
"url": "https://github.com/esotericsoftware/spine-runtimes/issues"
|
||||
},
|
||||
"homepage": "https://github.com/esotericsoftware/spine-runtimes#readme",
|
||||
"dependencies": {
|
||||
"@esotericsoftware/spine-core": "4.2.119"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pixi/core": "^7.2.4",
|
||||
"@pixi/display": "^7.2.4",
|
||||
"@pixi/graphics": "^7.2.4",
|
||||
"@pixi/text": "^7.2.4",
|
||||
"@pixi/assets": "^7.2.4",
|
||||
"@pixi/mesh": "^7.2.4",
|
||||
"@pixi/events": "^7.2.4"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user