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
+32
View File
@@ -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"
}
}
}
}
+2
View File
@@ -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"
+123
View File
@@ -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
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
+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>
+26
View File
@@ -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.
+33
View File
@@ -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.
+11
View File
@@ -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 许可要求。
+26
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
# spine-ts core
Please see the top-level [README.md](../README.md) for more information.
+33
View File
@@ -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"
}
+3
View File
@@ -0,0 +1,3 @@
# spine-ts Pixi.js v7
Please see the top-level [README.md](../README.md) for more information.
+45
View File
@@ -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"
}
}