渲染顺序功能
This commit is contained in:
1 parent
aabf4ff693
commit
c041503004
22 files changed
+1904
-1291
No files matched your search
@@ -65,6 +65,11 @@ export function defaultConfig(): EmitterConfig {
|
||||
rootRotation: 0,
|
||||
rootScaleX: 1,
|
||||
rootScaleY: 1,
|
||||
renderLayerMode: 'top',
|
||||
renderLayerSpineId: 0,
|
||||
renderLayerSlotName: '',
|
||||
renderLayerSlotPosition: 'above',
|
||||
renderLayerFollowAttachment: false,
|
||||
emissionAngleMode: 'random',
|
||||
direction: 0,
|
||||
spread: 360,
|
||||
@@ -296,6 +301,11 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
|
||||
if (config.shape !== 'path' && (config.emissionAngleMode === 'path-normal' || config.emissionAngleMode === 'path-tangent')) config.emissionAngleMode = 'fixed'
|
||||
config.spawnPathId = Math.max(0, Math.round(Number(config.spawnPathId) || 0))
|
||||
if (config.spawnPathDistribution !== 'fixed' && config.spawnPathDistribution !== 'random') config.spawnPathDistribution = 'fixed'
|
||||
if (config.renderLayerMode !== 'top' && config.renderLayerMode !== 'slot') config.renderLayerMode = 'top'
|
||||
config.renderLayerSpineId = Math.max(0, Math.round(Number(config.renderLayerSpineId) || 0))
|
||||
if (typeof config.renderLayerSlotName !== 'string') config.renderLayerSlotName = ''
|
||||
if (config.renderLayerSlotPosition !== 'above' && config.renderLayerSlotPosition !== 'below') config.renderLayerSlotPosition = 'above'
|
||||
if (typeof config.renderLayerFollowAttachment !== 'boolean') config.renderLayerFollowAttachment = false
|
||||
config.innerRadius = Math.max(0, Number(config.innerRadius) || 0)
|
||||
config.outerRadius = Math.max(config.innerRadius, Number(config.outerRadius) || 0)
|
||||
config.innerRectW = Math.max(0, Number(config.innerRectW) || 0)
|
||||
|
||||
@@ -13,6 +13,9 @@ export type AttributeMode = 'fixed' | 'random' | 'curve'
|
||||
export type RotationMode = AttributeMode | 'direction'
|
||||
export type ScaleAxisMode = 'uniform' | 'separate'
|
||||
export type FieldFalloff = 'constant' | 'linear' | 'inverse'
|
||||
/** 粒子系统在 Spine 绘制顺序中的位置(仅影响编辑器预览)。 */
|
||||
export type RenderLayerMode = 'top' | 'slot'
|
||||
export type RenderLayerPosition = 'above' | 'below'
|
||||
export interface CurvePoint { x: number; y: number; inX?: number; inY?: number; outX?: number; outY?: number }
|
||||
export interface ColorGradientStop { position: number; color: string }
|
||||
|
||||
@@ -168,6 +171,12 @@ export interface EmitterConfig {
|
||||
rootRotation: number
|
||||
rootScaleX: number
|
||||
rootScaleY: number
|
||||
// ==== 渲染层级(仅编辑器预览;不写入 Spine 导出数据)====
|
||||
renderLayerMode: RenderLayerMode
|
||||
renderLayerSpineId: number
|
||||
renderLayerSlotName: string
|
||||
renderLayerSlotPosition: RenderLayerPosition
|
||||
renderLayerFollowAttachment: boolean
|
||||
// 方向
|
||||
emissionAngleMode: EmissionAngleMode
|
||||
direction: number
|
||||
|
||||
@@ -223,6 +223,9 @@ export async function applyEditorConfig(store: EditorStore, input: EditorConfigF
|
||||
for (const spine of spines) {
|
||||
if (spine.sourceBundle == null) spine.sourceBundle = null
|
||||
if (spine.dataVersion == null) spine.dataVersion = ''
|
||||
if (!Array.isArray(spine.slots)) spine.slots = []
|
||||
if (typeof spine.selectedBone !== 'string') spine.selectedBone = ''
|
||||
if (typeof spine.selectedSlot !== 'string') spine.selectedSlot = ''
|
||||
}
|
||||
|
||||
await hydrateParticleResources(systems)
|
||||
@@ -257,6 +260,7 @@ export async function applyEditorConfig(store: EditorStore, input: EditorConfigF
|
||||
spine.error = spine.skeletonFileName ? '配置未包含可复用的 Spine 原始资源' : ''
|
||||
spine.animations = []
|
||||
spine.bones = []
|
||||
spine.slots = []
|
||||
spine.selectedAnimation = ''
|
||||
continue
|
||||
}
|
||||
@@ -264,6 +268,7 @@ export async function applyEditorConfig(store: EditorStore, input: EditorConfigF
|
||||
spine.dataVersion = parsed.dataVersion
|
||||
spine.animations = parsed.animations
|
||||
spine.bones = parsed.bones
|
||||
spine.slots = parsed.slots
|
||||
if (!parsed.animations.some((animation) => animation.name === spine.selectedAnimation)) {
|
||||
spine.selectedAnimation = parsed.animations[0]?.name || ''
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
<div class="tree-title-row">
|
||||
<div class="section-title">层级树</div>
|
||||
<button
|
||||
v-if="treeTab === 'bone'"
|
||||
class="selected-bone"
|
||||
:class="{ active: spineObject.selectedBone }"
|
||||
:disabled="!spineObject.selectedBone"
|
||||
@@ -71,36 +72,71 @@
|
||||
<template v-if="spineObject.selectedBone">清除选中骨骼:<strong>{{ spineObject.selectedBone }}</strong></template>
|
||||
<template v-else>当前骨骼:<strong>未选择</strong></template>
|
||||
</button>
|
||||
<button v-if="spineObject.bones.length" class="tree-action" @click="toggleAllBones">{{ allBonesCollapsed ? '全部展开' : '全部折叠' }}</button>
|
||||
<button v-else class="selected-bone" :class="{ active: spineObject.selectedSlot }" :disabled="!spineObject.selectedSlot" :title="spineObject.selectedSlot ? '点击清除插槽选择' : '当前未选择插槽'" @click="spineObject.selectedSlot = ''">
|
||||
<template v-if="spineObject.selectedSlot">清除选中插槽:<strong>{{ spineObject.selectedSlot }}</strong></template>
|
||||
<template v-else>当前插槽:<strong>未选择</strong></template>
|
||||
</button>
|
||||
<button v-if="treeTab === 'bone' && spineObject.bones.length" class="tree-action" @click="toggleAllBones">{{ allBonesCollapsed ? '全部展开' : '全部折叠' }}</button>
|
||||
</div>
|
||||
<div v-if="!spineObject.bones.length" class="empty">未加载 Spine</div>
|
||||
<div v-else class="bone-search">
|
||||
<input v-model.trim="boneSearch" type="search" aria-label="搜索骨骼" placeholder="搜索骨骼名称…" />
|
||||
<button v-if="boneSearch" title="清空搜索" aria-label="清空骨骼搜索" @click="boneSearch = ''">✕</button>
|
||||
<div class="tree-tabs">
|
||||
<button :class="{ on: treeTab === 'bone' }" @click="treeTab = 'bone'">骨骼</button>
|
||||
<button :class="{ on: treeTab === 'slot' }" @click="treeTab = 'slot'">插槽</button>
|
||||
</div>
|
||||
<div v-if="spineObject.bones.length && !visibleBones.length" class="empty">未找到匹配的骨骼</div>
|
||||
<div v-else-if="spineObject.bones.length" class="bone-tree">
|
||||
<div
|
||||
v-for="bone in visibleBones"
|
||||
:key="bone.name"
|
||||
class="bone"
|
||||
:class="{ selected: spineObject.selectedBone === bone.name }"
|
||||
:style="{ paddingLeft: `${6 + bone.depth * 16}px` }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="selectBone(bone.name)"
|
||||
@keydown.enter.prevent="selectBone(bone.name)"
|
||||
>
|
||||
<button
|
||||
v-if="parentBoneNames.has(bone.name)"
|
||||
class="bone-toggle"
|
||||
:title="collapsedBones.has(bone.name) ? '展开子骨骼' : '折叠子骨骼'"
|
||||
@click.stop="toggleBone(bone.name)"
|
||||
>{{ collapsedBones.has(bone.name) ? '▸' : '▾' }}</button>
|
||||
<span v-else class="bone-leaf">•</span>
|
||||
<span class="bone-name">{{ bone.name }}</span>
|
||||
<template v-if="treeTab === 'bone'">
|
||||
<div v-if="!spineObject.bones.length" class="empty">未加载 Spine</div>
|
||||
<div v-else class="bone-search">
|
||||
<input v-model.trim="boneSearch" type="search" aria-label="搜索骨骼" placeholder="搜索骨骼名称…" />
|
||||
<button v-if="boneSearch" title="清空搜索" aria-label="清空骨骼搜索" @click="boneSearch = ''">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="spineObject.bones.length && !visibleBones.length" class="empty">未找到匹配的骨骼</div>
|
||||
<div v-else-if="spineObject.bones.length" class="bone-tree">
|
||||
<div
|
||||
v-for="bone in visibleBones"
|
||||
:key="bone.name"
|
||||
class="bone"
|
||||
:class="{ selected: spineObject.selectedBone === bone.name }"
|
||||
:style="{ paddingLeft: `${6 + bone.depth * 16}px` }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="selectBone(bone.name)"
|
||||
@keydown.enter.prevent="selectBone(bone.name)"
|
||||
>
|
||||
<button
|
||||
v-if="parentBoneNames.has(bone.name)"
|
||||
class="bone-toggle"
|
||||
:title="collapsedBones.has(bone.name) ? '展开子骨骼' : '折叠子骨骼'"
|
||||
@click.stop="toggleBone(bone.name)"
|
||||
>{{ collapsedBones.has(bone.name) ? '▸' : '▾' }}</button>
|
||||
<span v-else class="bone-leaf">•</span>
|
||||
<span class="bone-name">{{ bone.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="!spineObject.slots.length" class="empty">未加载 Spine 插槽</div>
|
||||
<div v-else class="bone-search">
|
||||
<input v-model.trim="slotSearch" type="search" aria-label="搜索插槽" placeholder="搜索插槽或附件名称…" />
|
||||
<button v-if="slotSearch" title="清空搜索" aria-label="清空插槽搜索" @click="slotSearch = ''">✕</button>
|
||||
</div>
|
||||
<div v-if="spineObject.slots.length && !visibleSlots.length" class="empty">未找到匹配的插槽</div>
|
||||
<div v-else-if="spineObject.slots.length" class="bone-tree slot-tree">
|
||||
<div
|
||||
v-for="slot in visibleSlots"
|
||||
:key="slot.name"
|
||||
class="bone slot-row"
|
||||
:class="{ selected: spineObject.selectedSlot === slot.name }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:title="`${slot.name}(骨骼:${slot.bone || '-'},附件:${slot.attachment || '空'})`"
|
||||
@click="selectSlot(slot.name)"
|
||||
@keydown.enter.prevent="selectSlot(slot.name)"
|
||||
>
|
||||
<span class="slot-order">{{ slot.order + 1 }}</span>
|
||||
<span class="bone-name">{{ slot.name }}</span>
|
||||
<span class="slot-attachment">{{ slot.attachment || '空' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -121,12 +157,16 @@ const textureInput = ref<HTMLInputElement | null>(null)
|
||||
const folderInput = ref<HTMLInputElement | null>(null)
|
||||
const collapsedBones = ref(new Set<string>())
|
||||
const boneSearch = ref('')
|
||||
const treeTab = ref<'bone' | 'slot'>('bone')
|
||||
const slotSearch = ref('')
|
||||
const skeletonFile = ref<File | null>(null)
|
||||
const atlasFile = ref<File | null>(null)
|
||||
const textureFiles = ref<File[]>([])
|
||||
|
||||
// 兼容热更新或旧场景数据;骨骼选择默认保持为空。
|
||||
if (props.spineObject.selectedBone == null) props.spineObject.selectedBone = ''
|
||||
if (props.spineObject.selectedSlot == null) props.spineObject.selectedSlot = ''
|
||||
if (!Array.isArray(props.spineObject.slots)) props.spineObject.slots = []
|
||||
if (props.spineObject.sourceBundle == null) props.spineObject.sourceBundle = null
|
||||
if (props.spineObject.dataVersion == null) props.spineObject.dataVersion = ''
|
||||
|
||||
@@ -147,6 +187,14 @@ const visibleBones = computed(() => props.spineObject.bones.filter((bone) => {
|
||||
return true
|
||||
}))
|
||||
const allBonesCollapsed = computed(() => parentBoneNames.value.size > 0 && [...parentBoneNames.value].every((name) => collapsedBones.value.has(name)))
|
||||
const visibleSlots = computed(() => {
|
||||
const query = slotSearch.value.trim().toLocaleLowerCase()
|
||||
const slots = [...props.spineObject.slots].sort((a, b) => a.order - b.order)
|
||||
if (!query) return slots
|
||||
return slots.filter((slot) =>
|
||||
slot.name.toLocaleLowerCase().includes(query) || slot.attachment.toLocaleLowerCase().includes(query),
|
||||
)
|
||||
})
|
||||
|
||||
function selectBone(name: string) {
|
||||
const shouldClear = props.spineObject.selectedBone === name
|
||||
@@ -154,6 +202,12 @@ function selectBone(name: string) {
|
||||
if (!shouldClear) props.spineObject.selectedBone = name
|
||||
}
|
||||
|
||||
function selectSlot(name: string) {
|
||||
const shouldClear = props.spineObject.selectedSlot === name
|
||||
for (const spine of store.spines) spine.selectedSlot = ''
|
||||
if (!shouldClear) props.spineObject.selectedSlot = name
|
||||
}
|
||||
|
||||
function toggleBone(name: string) {
|
||||
if (collapsedBones.value.has(name)) collapsedBones.value.delete(name)
|
||||
else collapsedBones.value.add(name)
|
||||
@@ -228,8 +282,11 @@ async function loadBundle() {
|
||||
props.spineObject.dataVersion = parsed.dataVersion
|
||||
props.spineObject.animations = parsed.animations
|
||||
props.spineObject.bones = parsed.bones
|
||||
props.spineObject.slots = parsed.slots
|
||||
props.spineObject.selectedBone = ''
|
||||
props.spineObject.selectedSlot = ''
|
||||
boneSearch.value = ''
|
||||
slotSearch.value = ''
|
||||
collapsedBones.value = new Set()
|
||||
props.spineObject.selectedAnimation = parsed.animations[0]?.name || ''
|
||||
props.spineObject.assetVersion++
|
||||
@@ -266,8 +323,11 @@ function clearBundle() {
|
||||
props.spineObject.sourceBundle = null
|
||||
props.spineObject.animations = []
|
||||
props.spineObject.bones = []
|
||||
props.spineObject.slots = []
|
||||
props.spineObject.selectedBone = ''
|
||||
props.spineObject.selectedSlot = ''
|
||||
boneSearch.value = ''
|
||||
slotSearch.value = ''
|
||||
collapsedBones.value = new Set()
|
||||
props.spineObject.selectedAnimation = ''
|
||||
props.spineObject.assetVersion++
|
||||
@@ -302,6 +362,10 @@ button:disabled { opacity: .55; cursor: wait; }
|
||||
.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-tabs { display: flex; gap: 3px; margin: 6px 0; }
|
||||
.tree-tabs button { height: 24px; padding: 0 8px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: #8d98ae; font-size: 10px; cursor: pointer; }
|
||||
.tree-tabs button:hover { background: #273246; }
|
||||
.tree-tabs button.on { border-color: #7774ff; background: #35336b; color: #fff; }
|
||||
.selected-bone { width: auto; min-width: 0; max-width: 100%; height: 24px; flex: 0 1 auto; overflow: hidden; padding: 0 7px; color: #75819c; background: #182132; border: 1px solid transparent; border-radius: 5px; font-size: 10px; text-align: right; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.selected-bone.active { color: #aeb9d4; background: #242f44; border-color: #35445e; cursor: pointer; }
|
||||
.selected-bone.active:hover { border-color: #675ec4; background: #302b57; }
|
||||
@@ -320,5 +384,8 @@ button:disabled { opacity: .55; cursor: wait; }
|
||||
.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; }
|
||||
.slot-row { padding-right: 8px; }
|
||||
.slot-order { width: 24px; flex: 0 0 24px; color: #66758e; font-size: 10px; font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.slot-attachment { margin-left: auto; max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #6d7c96; font-size: 10px; }
|
||||
@media (max-width: 430px) { .grid.three { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Container } from 'pixi.js'
|
||||
import { Container, Matrix } from 'pixi.js'
|
||||
import { getSpineAsset } from './spineAssetRegistry'
|
||||
import type { SpineSceneObject } from './spineTypes'
|
||||
|
||||
@@ -22,11 +22,79 @@ export class SpineRuntimeLayer {
|
||||
readonly container = new Container()
|
||||
private entries = new Map<number, RuntimeEntry>()
|
||||
|
||||
/** 供渲染层级使用:取某个场景 Spine 的运行时实例。 */
|
||||
getSpine(objectId: number) {
|
||||
return this.entries.get(objectId)?.spine || null
|
||||
}
|
||||
|
||||
/** 当前帧的绘制顺序(会跟随动画里的 drawOrder timeline)。 */
|
||||
getDrawOrder(objectId: number): string[] | null {
|
||||
const spine = this.entries.get(objectId)?.spine
|
||||
try {
|
||||
const drawOrder = spine?.skeleton?.drawOrder
|
||||
return Array.isArray(drawOrder) ? drawOrder.map((slot: any) => String(slot?.data?.name || '')) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 插槽 wrapper 相对 Spine 的局部矩阵(与 SpinePixi updateSlotObject 保持一致)。 */
|
||||
getSlotLocalMatrix(objectId: number, slotName: string): Matrix | null {
|
||||
const spine = this.entries.get(objectId)?.spine
|
||||
const slot = spine?.skeleton?.findSlot?.(slotName)
|
||||
const bone = slot?.bone
|
||||
if (!bone?.active) return null
|
||||
return new Matrix(bone.a, bone.c, -bone.b, -bone.d, bone.worldX, bone.worldY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 插槽 wrapper 在编辑器 world 空间中的父矩阵 = 场景对象 root 变换 × 插槽骨骼矩阵。
|
||||
* 粒子发射器挂进插槽后用它做逆变换补偿,保持原有的世界坐标表现。
|
||||
*/
|
||||
getSlotParentMatrix(
|
||||
objectId: number,
|
||||
slotName: string,
|
||||
root: { x: number; y: number; rotation: number; scaleX: number; scaleY: number },
|
||||
): Matrix | null {
|
||||
const local = this.getSlotLocalMatrix(objectId, slotName)
|
||||
if (!local) return null
|
||||
const radians = -root.rotation * Math.PI / 180
|
||||
const cos = Math.cos(radians), sin = Math.sin(radians)
|
||||
const rootMatrix = new Matrix(
|
||||
cos * root.scaleX, sin * root.scaleX,
|
||||
-sin * root.scaleY, cos * root.scaleY,
|
||||
root.x, -root.y,
|
||||
)
|
||||
return rootMatrix.append(local)
|
||||
}
|
||||
|
||||
attachSlotObject(objectId: number, slotName: string, container: Container, followAttachmentTimeline: boolean) {
|
||||
const spine = this.entries.get(objectId)?.spine
|
||||
if (!spine?.addSlotObject) return false
|
||||
try {
|
||||
spine.addSlotObject(slotName, container, { followAttachmentTimeline })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
detachSlotObject(objectId: number, slotName: string, container: Container) {
|
||||
const spine = this.entries.get(objectId)?.spine
|
||||
try { spine?.removeSlotObject?.(slotName, container) } catch { /* 运行时已被释放时忽略 */ }
|
||||
}
|
||||
|
||||
private destroyEntry(entry: RuntimeEntry) {
|
||||
// 先摘掉渲染层级 wrapper,避免随 root 一起销毁编辑器里的粒子发射器。
|
||||
try { entry.spine?.removeSlotObjects?.() } catch { /* ignore */ }
|
||||
this.container.removeChild(entry.root)
|
||||
entry.root.destroy({ children: true })
|
||||
}
|
||||
|
||||
sync(objects: SpineSceneObject[], frame: number, fps: number) {
|
||||
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.destroyEntry(entry)
|
||||
this.entries.delete(id)
|
||||
}
|
||||
|
||||
@@ -34,16 +102,12 @@ export class SpineRuntimeLayer {
|
||||
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.destroyEntry(entry)
|
||||
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 })
|
||||
}
|
||||
if (entry) this.destroyEntry(entry)
|
||||
const root = new Container()
|
||||
const spine = new asset.SpineClass({ skeletonData: asset.skeletonData, autoUpdate: false, darkTint: false })
|
||||
if (asset.skeletonData.defaultSkin) spine.skeleton.setSkin(asset.skeletonData.defaultSkin)
|
||||
@@ -115,8 +179,7 @@ export class SpineRuntimeLayer {
|
||||
|
||||
destroy() {
|
||||
for (const entry of this.entries.values()) {
|
||||
entry.root.removeFromParent()
|
||||
entry.root.destroy({ children: true })
|
||||
this.destroyEntry(entry)
|
||||
}
|
||||
this.entries.clear()
|
||||
this.container.removeFromParent()
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Texture } from 'pixi.js'
|
||||
import type { LoadedSpineAsset } from './spineAssetRegistry'
|
||||
import { parseBinary42, parseJson42 } from './runtime/spineRuntime42'
|
||||
import { parseBinary43, parseJson43 } from './runtime/spineRuntime43'
|
||||
import type { SpineAnimationInfo, SpineBoneInfo } from './spineTypes'
|
||||
import type { SpineAnimationInfo, SpineBoneInfo, SpineSlotInfo } from './spineTypes'
|
||||
import { normalizeSpine38JsonFor42 } from './spine38Compatibility'
|
||||
import { normalizeSpineVersion, spineRuntimeVersion, supportedSpineVersionsText, type SupportedSpineVersion } from './spineVersion'
|
||||
|
||||
@@ -11,6 +11,7 @@ type CompatibleSkeletonData = LoadedSpineAsset['skeletonData']
|
||||
export interface ParsedSpineBundle extends LoadedSpineAsset {
|
||||
animations: SpineAnimationInfo[]
|
||||
bones: SpineBoneInfo[]
|
||||
slots: SpineSlotInfo[]
|
||||
dataVersion: SupportedSpineVersion
|
||||
}
|
||||
|
||||
@@ -40,6 +41,16 @@ function boneMetadata(skeletonData: CompatibleSkeletonData): SpineBoneInfo[] {
|
||||
})
|
||||
}
|
||||
|
||||
/** setup 姿态下的插槽顺序(即 Spine 层级面板中的绘制顺序)。 */
|
||||
function slotMetadata(skeletonData: CompatibleSkeletonData): SpineSlotInfo[] {
|
||||
return skeletonData.slots.map((slot) => ({
|
||||
name: slot.name,
|
||||
order: Number(slot.index) || 0,
|
||||
bone: slot.boneData?.name || '',
|
||||
attachment: slot.attachmentName || '',
|
||||
}))
|
||||
}
|
||||
|
||||
export async function parseSpineBundle(skeletonFile: File, atlasFile: File, textureFiles: File[]): Promise<ParsedSpineBundle> {
|
||||
if (!/\.(json|skel)$/i.test(skeletonFile.name)) throw new Error('Spine 数据仅支持 .json 或 .skel')
|
||||
if (!/\.atlas$/i.test(atlasFile.name)) throw new Error('图集文件仅支持 .atlas')
|
||||
@@ -98,6 +109,7 @@ export async function parseSpineBundle(skeletonFile: File, atlasFile: File, text
|
||||
dataVersion,
|
||||
animations: skeletonData.animations.map((animation) => ({ name: animation.name, duration: animation.duration })),
|
||||
bones: boneMetadata(skeletonData),
|
||||
slots: slotMetadata(skeletonData),
|
||||
}
|
||||
} catch (error) {
|
||||
for (const texture of textures) texture.destroy(true)
|
||||
|
||||
@@ -12,6 +12,14 @@ export interface SpineBoneInfo {
|
||||
depth: number
|
||||
}
|
||||
|
||||
/** 插槽信息;order 为 setup 姿态下的绘制顺序(Spine drawOrder 可能被动画改变)。 */
|
||||
export interface SpineSlotInfo {
|
||||
name: string
|
||||
order: number
|
||||
bone: string
|
||||
attachment: string
|
||||
}
|
||||
|
||||
export interface SpineSceneObject {
|
||||
id: number
|
||||
name: string
|
||||
@@ -32,8 +40,10 @@ export interface SpineSceneObject {
|
||||
sourceBundle: SpineSourceBundle | null
|
||||
selectedAnimation: string
|
||||
selectedBone: string
|
||||
selectedSlot: string
|
||||
animations: SpineAnimationInfo[]
|
||||
bones: SpineBoneInfo[]
|
||||
slots: SpineSlotInfo[]
|
||||
assetVersion: number
|
||||
status: 'empty' | 'loading' | 'ready' | 'error'
|
||||
error: string
|
||||
|
||||
@@ -322,8 +322,10 @@ export const useParticleStore = defineStore('particle', {
|
||||
sourceBundle: null,
|
||||
selectedAnimation: '',
|
||||
selectedBone: '',
|
||||
selectedSlot: '',
|
||||
animations: [],
|
||||
bones: [],
|
||||
slots: [],
|
||||
assetVersion: 0,
|
||||
status: 'empty',
|
||||
error: '',
|
||||
@@ -381,6 +383,7 @@ export const useParticleStore = defineStore('particle', {
|
||||
copy.name = copyName(source.name, this.spines.map((item) => item.name))
|
||||
// 骨骼选择是层级树的临时操作状态,不在两个对象之间复用。
|
||||
copy.selectedBone = ''
|
||||
copy.selectedSlot = ''
|
||||
retainSpineAsset(source.id, copy.id)
|
||||
this.spines.push(copy)
|
||||
this.activeObjectId = copy.id
|
||||
|
||||
+5
-3
@@ -189,7 +189,7 @@ useEditorShortcuts({
|
||||
|
||||
function syncEmitters() {
|
||||
if (!world) return
|
||||
emitterSync.sync(store, world, dotTex, trailTex)
|
||||
emitterSync.sync(store, world, dotTex, trailTex, spineRuntime)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,13 +243,15 @@ function loop() {
|
||||
background.applyTransform(store.settings.backgroundX, store.settings.backgroundY, store.settings.backgroundOpacity)
|
||||
|
||||
drawAxis(store, app, axisGfx, axisLabelLayer, axisLabels)
|
||||
syncEmitters()
|
||||
// 根节点(root)变换:位置(centerX/centerY)=位移,rotation/scale 绕 root 中心,与场景对象参数联动
|
||||
const followFps = Math.max(1, store.timeline.fps)
|
||||
const followFrame = store.timeline.frame
|
||||
const followTime = followFrame / followFps
|
||||
// 发射器读取骨骼变换前,先把 Spine 运行时同步到同一时间轴帧。
|
||||
// 发射器读取骨骼变换、插槽 drawOrder 之前,先把 Spine 运行时同步到同一时间轴帧。
|
||||
spineRuntime?.sync(store.spines, followFrame, followFps)
|
||||
syncEmitters()
|
||||
// 同步完成后计算插槽层级补偿,抵消插槽骨骼变换,保持粒子系统原有世界坐标表现。
|
||||
emitterSync.updateRenderCompensation(store, spineRuntime)
|
||||
previewColliders = buildPreviewColliders(store, followTime)
|
||||
for (const sys of store.systems) {
|
||||
const em = emitterSync.emitters.get(sys.id)
|
||||
|
||||
@@ -27,6 +27,59 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 渲染层级(仅编辑器预览,不影响导出数据) -->
|
||||
<div class="look-title">渲染层级(Spine 插槽)</div>
|
||||
<div class="seg">
|
||||
<button class="seg-btn" :class="{ active: sys.config.renderLayerMode === 'top' }" @click="sys.config.renderLayerMode = 'top'">最上层</button>
|
||||
<button class="seg-btn" :class="{ active: sys.config.renderLayerMode === 'slot' }" @click="setRenderLayerSlotMode">Spine 插槽</button>
|
||||
</div>
|
||||
<template v-if="sys.config.renderLayerMode === 'slot'">
|
||||
<label class="row"><span>Spine</span>
|
||||
<select class="inp" :value="sys.config.renderLayerSpineId" @change="selectRenderLayerSpine">
|
||||
<option :value="0">{{ renderLayerReadySpines.length ? '请选择 Spine' : '无可用 Spine' }}</option>
|
||||
<option v-for="spine in renderLayerReadySpines" :key="spine.id" :value="spine.id">{{ spine.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<!-- 没有可用的 Spine 时,插槽选择与层级说明整体隐藏。 -->
|
||||
<template v-if="renderLayerSpineReady">
|
||||
<div class="render-layer-current">
|
||||
<span class="render-layer-text" :title="renderLayerSlotLabel">{{ renderLayerSlotLabel || '尚未选择插槽' }}</span>
|
||||
<button class="mini-btn" @click="renderLayerSlotPickerOpen = !renderLayerSlotPickerOpen">{{ renderLayerSlotLabel ? '重新选择' : '选择插槽' }}</button>
|
||||
<button v-if="sys.config.renderLayerSlotName" class="mini-btn ghost" @click="clearRenderLayerSlot">清除</button>
|
||||
</div>
|
||||
<div v-if="renderLayerSlotPickerOpen" class="slot-picker">
|
||||
<input v-model.trim="renderLayerSlotSearch" class="inp" type="search" placeholder="搜索插槽或附件名称…" aria-label="搜索插槽" />
|
||||
<div class="slot-list">
|
||||
<button
|
||||
v-for="slot in renderLayerFilteredSlots"
|
||||
:key="slot.name"
|
||||
class="slot-item"
|
||||
:class="{ on: sys.config.renderLayerSlotName === slot.name }"
|
||||
@click="pickRenderLayerSlot(slot)"
|
||||
>
|
||||
<span class="slot-order">{{ slot.order + 1 }}</span>
|
||||
<span class="slot-name">{{ slot.name }}</span>
|
||||
<span class="slot-attachment">{{ slot.attachment || '空插槽' }}</span>
|
||||
</button>
|
||||
<div v-if="!renderLayerFilteredSlots.length" class="modifier-hint">没有匹配的插槽</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="sys.config.renderLayerSlotName">
|
||||
<div class="seg">
|
||||
<button class="seg-btn" :class="{ active: sys.config.renderLayerSlotPosition === 'above' }" @click="sys.config.renderLayerSlotPosition = 'above'">在此插槽图片之上</button>
|
||||
<button class="seg-btn" :class="{ active: sys.config.renderLayerSlotPosition === 'below' }" @click="sys.config.renderLayerSlotPosition = 'below'">在此插槽图片之下</button>
|
||||
</div>
|
||||
<label class="switch-row render-layer-switch">
|
||||
<input v-model="sys.config.renderLayerFollowAttachment" type="checkbox" :disabled="sys.config.renderLayerSlotPosition === 'below'" />
|
||||
<span class="switch-ui"></span><span>随插槽附件显隐</span>
|
||||
</label>
|
||||
<div v-if="renderLayerResolvedLabel" class="modifier-hint">{{ renderLayerResolvedLabel }}</div>
|
||||
</template>
|
||||
<div v-if="renderLayerWarning" class="modifier-hint render-layer-warning">{{ renderLayerWarning }}</div>
|
||||
<div class="modifier-hint">调整显示层级只影响编辑器预览,不影响导出数据。</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 图片资源 -->
|
||||
<div class="look-title image-title">
|
||||
<span>图片资源</span>
|
||||
@@ -105,7 +158,7 @@ import ColorGradientEditor from '../ColorGradientEditor.vue'
|
||||
import ImageResourceCard from '../ImageResourceCard.vue'
|
||||
import SpineObjectPanel from '../../spine/SpineObjectPanel.vue'
|
||||
import { useParticlePanelContext } from './particlePanelContext'
|
||||
const { resolvedCurveEditorMode, sys, textureFileInput, sequenceFileInput, addImageResource, openTexturePicker, openSequencePicker, onTextureFile, onSequenceFiles, resetTexture, setResourceFixedColor, commitResourceFixedColor, removeImageResource, setResourceWeight, toggleResourceLock, collapsed, toggle, resetModule, select, ALPHA_CURVE, RESOURCE_ALPHA_CURVE } = useParticlePanelContext()
|
||||
const { resolvedCurveEditorMode, sys, textureFileInput, sequenceFileInput, addImageResource, openTexturePicker, openSequencePicker, onTextureFile, onSequenceFiles, resetTexture, setResourceFixedColor, commitResourceFixedColor, removeImageResource, setResourceWeight, toggleResourceLock, collapsed, toggle, resetModule, select, renderLayerSlotPickerOpen, renderLayerSlotSearch, renderLayerReadySpines, renderLayerSpineReady, renderLayerFilteredSlots, renderLayerSlotLabel, renderLayerResolvedLabel, renderLayerWarning, setRenderLayerSlotMode, selectRenderLayerSpine, pickRenderLayerSlot, clearRenderLayerSlot, ALPHA_CURVE, RESOURCE_ALPHA_CURVE } = useParticlePanelContext()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -154,4 +207,27 @@ const { resolvedCurveEditorMode, sys, textureFileInput, sequenceFileInput, addIm
|
||||
.file-input { display: none; }
|
||||
.hex { font-size: 11px; color: #889; margin-left: auto; font-variant-numeric: tabular-nums; }
|
||||
.inp.tiny { width: 46px; flex: 0 0 46px; text-align: center; }
|
||||
.render-layer-current { display: flex; align-items: center; gap: 6px; margin: 6px 0; }
|
||||
.render-layer-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #9aa6c2; font-size: 11px; }
|
||||
.mini-btn { flex: 0 0 auto; height: 24px; padding: 0 7px; border: 1px solid #3d4a66; border-radius: 5px; background: #202a3e; color: #a9b5cf; font-size: 10px; cursor: pointer; }
|
||||
.mini-btn:hover { border-color: #7774ff; color: #fff; }
|
||||
.mini-btn.ghost { background: transparent; }
|
||||
.slot-picker { margin: 6px 0; padding: 6px; border: 1px solid #2e3a52; border-radius: 6px; background: #111a28; }
|
||||
.slot-list { max-height: 180px; overflow: auto; margin-top: 6px; }
|
||||
.slot-item { display: flex; align-items: center; gap: 6px; width: 100%; padding: 4px 6px; border: 1px solid transparent; border-radius: 4px; background: transparent; color: #aab4cc; font-size: 11px; text-align: left; cursor: pointer; }
|
||||
.slot-item:hover { background: #1d2a3f; }
|
||||
.slot-item.on { border-color: #7774ff; background: #2b2b55; color: #fff; }
|
||||
.slot-order { width: 26px; flex: 0 0 26px; color: #66758e; font-variant-numeric: tabular-nums; }
|
||||
.slot-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.slot-attachment { max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #6d7c96; font-size: 10px; }
|
||||
.switch-row { display: flex; align-items: center; gap: 9px; color: #b8c1d5; font-size: 12px; cursor: pointer; }
|
||||
.switch-row input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.switch-ui { position: relative; width: 32px; height: 18px; border-radius: 10px; background: #354258; transition: background .16s ease; }
|
||||
.switch-ui::after { content: ''; position: absolute; left: 2px; top: 2px; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: transform .16s ease; }
|
||||
.switch-row input:checked + .switch-ui { background: #625ff1; }
|
||||
.switch-row input:checked + .switch-ui::after { transform: translateX(14px); }
|
||||
.switch-row input:disabled + .switch-ui { opacity: .45; cursor: not-allowed; }
|
||||
.render-layer-switch { margin: 6px 0; }
|
||||
.modifier-hint { margin: 7px 0 0 2px; color: #647086; font-size: 9px; line-height: 1.5; }
|
||||
.render-layer-warning { color: #d8a45e; }
|
||||
</style>
|
||||
@@ -83,6 +83,104 @@ const boundSpineBone = computed(() => {
|
||||
if (!spine || !spine.bones.some((bone) => bone.name === config.emitterFollowBoneName)) return null
|
||||
return { spine, boneName: config.emitterFollowBoneName }
|
||||
})
|
||||
|
||||
// ===== 渲染层级(仅编辑器预览) =====
|
||||
const renderLayerSlotPickerOpen = ref(false)
|
||||
const renderLayerSlotSearch = ref('')
|
||||
const renderLayerReadySpines = computed(() =>
|
||||
store.spines.filter((spine) => spine.enabled && spine.visible !== false && spine.status === 'ready'),
|
||||
)
|
||||
const renderLayerSpine = computed(() =>
|
||||
store.spines.find((spine) => spine.id === sys.value?.config.renderLayerSpineId) || null,
|
||||
)
|
||||
const renderLayerSpineReady = computed(() => {
|
||||
const spine = renderLayerSpine.value
|
||||
return !!spine && spine.enabled && spine.visible !== false && spine.status === 'ready'
|
||||
})
|
||||
const renderLayerSlots = computed(() =>
|
||||
[...(renderLayerSpine.value?.slots || [])].sort((a, b) => a.order - b.order),
|
||||
)
|
||||
const renderLayerFilteredSlots = computed(() => {
|
||||
const query = renderLayerSlotSearch.value.trim().toLocaleLowerCase()
|
||||
if (!query) return renderLayerSlots.value
|
||||
return renderLayerSlots.value.filter((slot) =>
|
||||
slot.name.toLocaleLowerCase().includes(query) || slot.attachment.toLocaleLowerCase().includes(query),
|
||||
)
|
||||
})
|
||||
const renderLayerTargetSlot = computed(() =>
|
||||
renderLayerSlots.value.find((slot) => slot.name === sys.value?.config.renderLayerSlotName) || null,
|
||||
)
|
||||
const renderLayerPreviousSlot = computed(() => {
|
||||
const target = renderLayerTargetSlot.value
|
||||
if (!target) return null
|
||||
return renderLayerSlots.value.filter((slot) => slot.order < target.order).sort((a, b) => b.order - a.order)[0] || null
|
||||
})
|
||||
const renderLayerSlotLabel = computed(() => {
|
||||
const config = sys.value?.config
|
||||
const spine = renderLayerSpine.value
|
||||
const target = renderLayerTargetSlot.value
|
||||
if (!config || !spine || !target) return ''
|
||||
return `${spine.name} / ${target.name} · 图片${config.renderLayerSlotPosition === 'below' ? '之下' : '之上'}`
|
||||
})
|
||||
const renderLayerResolvedLabel = computed(() => {
|
||||
const config = sys.value?.config
|
||||
const target = renderLayerTargetSlot.value
|
||||
if (!config || !target) return ''
|
||||
if (config.renderLayerSlotPosition === 'above') return `渲染在 ${target.name} 图片之上、其后插槽之下`
|
||||
return renderLayerPreviousSlot.value
|
||||
? `渲染在 ${renderLayerPreviousSlot.value.name} 与 ${target.name} 之间`
|
||||
: ''
|
||||
})
|
||||
const renderLayerWarning = computed(() => {
|
||||
const config = sys.value?.config
|
||||
if (!config || config.renderLayerMode !== 'slot') return ''
|
||||
if (!renderLayerReadySpines.value.length) return '没有已加载的 Spine,粒子临时显示在最上层。'
|
||||
if (!config.renderLayerSpineId) return '尚未选择 Spine,粒子临时显示在最上层。'
|
||||
const spine = renderLayerSpine.value
|
||||
if (!spine) return '目标 Spine 不存在,粒子临时显示在最上层。'
|
||||
if (!spine.enabled || spine.status !== 'ready') return '目标 Spine 未就绪,粒子临时显示在最上层。'
|
||||
// 未选择插槽时面板本身已显示“尚未选择插槽”,这里不再重复提示。
|
||||
if (!config.renderLayerSlotName) return ''
|
||||
if (!renderLayerTargetSlot.value) return '目标插槽不存在(资源可能已更换),粒子临时显示在最上层。'
|
||||
if (config.renderLayerSlotPosition === 'below' && !renderLayerPreviousSlot.value) {
|
||||
return '这是第一个插槽,无法显示在其下方,粒子临时显示在最上层。'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
function setRenderLayerSlotMode() {
|
||||
const config = sys.value?.config
|
||||
if (!config) return
|
||||
config.renderLayerMode = 'slot'
|
||||
if (!config.renderLayerSpineId && renderLayerReadySpines.value.length) {
|
||||
config.renderLayerSpineId = renderLayerReadySpines.value[0].id
|
||||
}
|
||||
renderLayerSlotPickerOpen.value = !config.renderLayerSlotName
|
||||
}
|
||||
|
||||
function selectRenderLayerSpine(event: Event) {
|
||||
const config = sys.value?.config
|
||||
if (!config) return
|
||||
config.renderLayerSpineId = Math.max(0, Math.round(Number((event.target as HTMLSelectElement).value) || 0))
|
||||
config.renderLayerSlotName = ''
|
||||
renderLayerSlotPickerOpen.value = true
|
||||
}
|
||||
|
||||
function pickRenderLayerSlot(slot: { name: string }) {
|
||||
const config = sys.value?.config
|
||||
if (!config) return
|
||||
config.renderLayerSlotName = slot.name
|
||||
renderLayerSlotPickerOpen.value = false
|
||||
renderLayerSlotSearch.value = ''
|
||||
}
|
||||
|
||||
function clearRenderLayerSlot() {
|
||||
const config = sys.value?.config
|
||||
if (!config) return
|
||||
config.renderLayerSlotName = ''
|
||||
config.renderLayerSlotPosition = 'above'
|
||||
}
|
||||
|
||||
const collisionPickerOpen = ref(false)
|
||||
const selectedCollisionBodies = computed(() => {
|
||||
const ids = new Set(sys.value?.config.collisionColliderIds || [])
|
||||
@@ -96,6 +194,10 @@ const availableCollisionBodies = computed(() => {
|
||||
watch(() => [sys.value?.id, sys.value?.config.collision], () => {
|
||||
collisionPickerOpen.value = false
|
||||
})
|
||||
watch(() => sys.value?.id, () => {
|
||||
renderLayerSlotPickerOpen.value = false
|
||||
renderLayerSlotSearch.value = ''
|
||||
})
|
||||
|
||||
function addCollisionBody(colliderId: number) {
|
||||
const config = sys.value?.config
|
||||
@@ -156,6 +258,18 @@ watchEffect(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 渲染层级:仅当 Spine 对象被删除时清空绑定;资源尚未就绪时保留绑定,等加载完成后自动恢复。
|
||||
watchEffect(() => {
|
||||
for (const system of store.systems) {
|
||||
const config = ensureEmitterConfig(system.config as any)
|
||||
if (config.renderLayerMode !== 'slot' || !config.renderLayerSpineId) continue
|
||||
if (!store.spines.some((spine) => spine.id === config.renderLayerSpineId)) {
|
||||
config.renderLayerSpineId = 0
|
||||
config.renderLayerSlotName = ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// HMR 或旧场景中的碰撞体缺少路径跟随字段时即时补齐。
|
||||
watchEffect(() => {
|
||||
for (const collider of store.colliders) {
|
||||
@@ -320,6 +434,8 @@ const MODULE_RESET_KEYS: Record<Exclude<ResetModule, 'export'>, string[]> = {
|
||||
'alpha', 'alphaEnd', 'colorStart', 'colorEnd', 'colorOverLife', 'blend',
|
||||
'alphaMode', 'curveMode', 'alphaCurve', 'textureName', 'textureFolder',
|
||||
'alphaScale', 'anchorX', 'anchorY', 'independentAlpha',
|
||||
'renderLayerMode', 'renderLayerSpineId', 'renderLayerSlotName',
|
||||
'renderLayerSlotPosition', 'renderLayerFollowAttachment',
|
||||
],
|
||||
mods: [
|
||||
'noise', 'noiseAmt', 'noiseRange', 'noiseSpeed',
|
||||
@@ -455,5 +571,5 @@ function onSpawnPathChange() {
|
||||
// 重置/删除图片 → 回到默认 star.png
|
||||
|
||||
|
||||
return { store, exportSettings, exportBusy, exportTarget, exportMessage, exportError, connectorPairUrl, connectorStatusText, connectorStatusClass, connectorReady, connectorAuthorizationUrl, connectorDisabledReason, connectorButtonTitle, refreshConnectorHealth, exportSpineJson, exportSpineProject, exportSpineImages, configFileInput, selectedPreset, presetOptions, presetLoading, configMessage, configError, saveConfig, onConfigFileChange, refreshPresets, loadSelectedPreset, resolvedCurveEditorMode, sys, textureFileInput, sequenceFileInput, trailTextureFileInput, addTrailResource, openTrailTexturePicker, onTrailRigChange, onTrailResourceBoneCountChange, onTrailResourceRotationChange, onTrailTextureFile, resetTrailTexture, removeTrailResource, setTrailResourceWeight, toggleTrailResourceLock, setTrailColor, commitTrailColor, addImageResource, openTexturePicker, openSequencePicker, onTextureFile, onSequenceFiles, resetTexture, setResourceFixedColor, commitResourceFixedColor, removeImageResource, setResourceWeight, toggleResourceLock, automaticLoopDuration, loopDurationFrames, activeObjectType, activeCollider, activePath, activeSpine, canCopyObject, selectedSpineBone, boundSpineBone, collisionPickerOpen, selectedCollisionBodies, availableCollisionBodies, addCollisionBody, removeCollisionBody, collapsed, toggle, setEmitMode, setLoopDurationFrames, setStreamBehavior, setLoopSection, setLoopSectionDuration, setLoopCustomDuration, onEmitterFollowModeChange, bindSelectedBone, resetModule, normalizeExportFps, select, addSys, addCollider, addPath, addSpine, copySelectedObject, applyPathPreset, clearPathAnchors, randomizeSeed, setSpeedOverLifeMode, prepareScaleOverLifeCurve, setScaleOverLifeMode, onShapeChange, onSpawnPathChange, LINEAR_CURVE, FLAT_CURVE, ALPHA_CURVE, RESOURCE_ALPHA_CURVE, TRAIL_ALPHA_CURVE, TRAIL_SHAPE_CURVE }
|
||||
return { store, exportSettings, exportBusy, exportTarget, exportMessage, exportError, connectorPairUrl, connectorStatusText, connectorStatusClass, connectorReady, connectorAuthorizationUrl, connectorDisabledReason, connectorButtonTitle, refreshConnectorHealth, exportSpineJson, exportSpineProject, exportSpineImages, configFileInput, selectedPreset, presetOptions, presetLoading, configMessage, configError, saveConfig, onConfigFileChange, refreshPresets, loadSelectedPreset, resolvedCurveEditorMode, sys, textureFileInput, sequenceFileInput, trailTextureFileInput, addTrailResource, openTrailTexturePicker, onTrailRigChange, onTrailResourceBoneCountChange, onTrailResourceRotationChange, onTrailTextureFile, resetTrailTexture, removeTrailResource, setTrailResourceWeight, toggleTrailResourceLock, setTrailColor, commitTrailColor, addImageResource, openTexturePicker, openSequencePicker, onTextureFile, onSequenceFiles, resetTexture, setResourceFixedColor, commitResourceFixedColor, removeImageResource, setResourceWeight, toggleResourceLock, automaticLoopDuration, loopDurationFrames, activeObjectType, activeCollider, activePath, activeSpine, canCopyObject, selectedSpineBone, boundSpineBone, renderLayerSlotPickerOpen, renderLayerSlotSearch, renderLayerReadySpines, renderLayerSpineReady, renderLayerSlots, renderLayerFilteredSlots, renderLayerSlotLabel, renderLayerResolvedLabel, renderLayerWarning, setRenderLayerSlotMode, selectRenderLayerSpine, pickRenderLayerSlot, clearRenderLayerSlot, collisionPickerOpen, selectedCollisionBodies, availableCollisionBodies, addCollisionBody, removeCollisionBody, collapsed, toggle, setEmitMode, setLoopDurationFrames, setStreamBehavior, setLoopSection, setLoopSectionDuration, setLoopCustomDuration, onEmitterFollowModeChange, bindSelectedBone, resetModule, normalizeExportFps, select, addSys, addCollider, addPath, addSpine, copySelectedObject, applyPathPreset, clearPathAnchors, randomizeSeed, setSpeedOverLifeMode, prepareScaleOverLifeCurve, setScaleOverLifeMode, onShapeChange, onSpawnPathChange, LINEAR_CURVE, FLAT_CURVE, ALPHA_CURVE, RESOURCE_ALPHA_CURVE, TRAIL_ALPHA_CURVE, TRAIL_SHAPE_CURVE }
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Texture, type Container } from 'pixi.js'
|
||||
import { Container, Texture } from 'pixi.js'
|
||||
import { ParticleEmitter, ensureEmitterConfig } from '../../core/particleEmitter'
|
||||
import { sampleScenePath } from '../../core/scenePathSampler'
|
||||
import type { EmitterConfig } from '../../core/particleTypes'
|
||||
import { followCurveValue } from './stageGeometry'
|
||||
import type { useParticleStore, CollisionBody } from '../../store/particleStore'
|
||||
import type { ScenePath } from '../../store/particleStore'
|
||||
import type { SpineRuntimeLayer } from '../../spine/SpineRuntimeLayer'
|
||||
import { resolveRenderTarget, type RenderTarget } from './renderLayer'
|
||||
|
||||
type EditorStore = ReturnType<typeof useParticleStore>
|
||||
|
||||
@@ -67,17 +69,28 @@ export function buildPreviewColliders(store: EditorStore, timeSeconds: number):
|
||||
export class EmitterSync {
|
||||
readonly emitters = new Map<number, ParticleEmitter>()
|
||||
private configs = new Map<number, object>()
|
||||
/** 渲染层级:一个插槽对应一个 wrapper(同插槽多系统共用),内部 compensator 抵消插槽骨骼变换。 */
|
||||
private slotGroups = new Map<string, {
|
||||
key: string
|
||||
spineId: number
|
||||
slotName: string
|
||||
wrapper: Container
|
||||
compensator: Container
|
||||
followAttachment: boolean
|
||||
attached: boolean
|
||||
}>()
|
||||
|
||||
sync(
|
||||
store: EditorStore,
|
||||
world: Container,
|
||||
dotTex: Texture<any> | null,
|
||||
trailTex: Texture<any> | null,
|
||||
spineRuntime: SpineRuntimeLayer | null = null,
|
||||
) {
|
||||
for (const [id, em] of this.emitters) {
|
||||
const system = store.systems.find((item) => item.id === id)
|
||||
if (!system || this.configs.get(id) !== system.config) {
|
||||
world.removeChild(em)
|
||||
em.removeFromParent()
|
||||
em.destroy({ children: true })
|
||||
this.emitters.delete(id)
|
||||
this.configs.delete(id)
|
||||
@@ -103,15 +116,98 @@ export class EmitterSync {
|
||||
this.emitters.set(sys.id, em)
|
||||
this.configs.set(sys.id, sys.config)
|
||||
}
|
||||
|
||||
const activeGroups = new Set<string>()
|
||||
for (const sys of store.systems) {
|
||||
const em = this.emitters.get(sys.id)
|
||||
if (!em) continue
|
||||
const drawOrder = spineRuntime && sys.config.renderLayerMode === 'slot'
|
||||
? spineRuntime.getDrawOrder(sys.config.renderLayerSpineId)
|
||||
: null
|
||||
const target = resolveRenderTarget(store.spines, sys.config, drawOrder)
|
||||
const group = target && spineRuntime ? this.ensureSlotGroup(target, spineRuntime, sys.config) : null
|
||||
if (group) {
|
||||
activeGroups.add(group.key)
|
||||
if (em.parent !== group.compensator) group.compensator.addChild(em)
|
||||
} else if (em.parent !== world) {
|
||||
world.addChild(em)
|
||||
}
|
||||
}
|
||||
for (const [key, group] of [...this.slotGroups]) {
|
||||
if (activeGroups.has(key)) continue
|
||||
for (const child of [...group.compensator.children]) {
|
||||
if (child instanceof ParticleEmitter && child.parent === group.compensator) world.addChild(child)
|
||||
}
|
||||
spineRuntime?.detachSlotObject(group.spineId, group.slotName, group.wrapper)
|
||||
group.wrapper.removeFromParent()
|
||||
group.wrapper.removeChild(group.compensator)
|
||||
group.compensator.destroy()
|
||||
group.wrapper.destroy()
|
||||
this.slotGroups.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
private ensureSlotGroup(
|
||||
target: RenderTarget,
|
||||
spineRuntime: SpineRuntimeLayer,
|
||||
config: { renderLayerFollowAttachment: boolean; renderLayerSlotPosition: string },
|
||||
) {
|
||||
// 以“用户配置的目标插槽 + 上/下”分组;drawOrder 变化导致实际挂载插槽改变时只重挂,不重建分组。
|
||||
const key = `${target.spineId}:${target.targetSlotName}:${config.renderLayerSlotPosition}`
|
||||
let group = this.slotGroups.get(key)
|
||||
if (!group) {
|
||||
const wrapper = new Container()
|
||||
const compensator = new Container()
|
||||
wrapper.addChild(compensator)
|
||||
group = {
|
||||
key,
|
||||
spineId: target.spineId,
|
||||
slotName: target.slotName,
|
||||
wrapper,
|
||||
compensator,
|
||||
followAttachment: config.renderLayerFollowAttachment,
|
||||
attached: false,
|
||||
}
|
||||
this.slotGroups.set(key, group)
|
||||
}
|
||||
// 同一插槽的多个系统共用一个 wrapper,跟随设置以首个挂载的系统为准,避免逐帧反复重挂。
|
||||
if (!group.attached || !group.wrapper.parent || group.slotName !== target.slotName) {
|
||||
if (group.wrapper.parent) spineRuntime.detachSlotObject(group.spineId, group.slotName, group.wrapper)
|
||||
group.slotName = target.slotName
|
||||
group.attached = spineRuntime.attachSlotObject(group.spineId, group.slotName, group.wrapper, target.followAttachment)
|
||||
}
|
||||
return group.attached ? group : null
|
||||
}
|
||||
|
||||
/** 每帧在 Spine 同步之后调用:把插槽骨骼变换抵消掉,粒子仍保持在编辑器世界坐标中。 */
|
||||
updateRenderCompensation(store: EditorStore, spineRuntime: SpineRuntimeLayer | null) {
|
||||
if (!spineRuntime) return
|
||||
for (const group of this.slotGroups.values()) {
|
||||
if (!group.attached || !group.wrapper.parent) continue
|
||||
const spine = store.spines.find((item) => item.id === group.spineId)
|
||||
if (!spine) continue
|
||||
const parent = spineRuntime.getSlotParentMatrix(group.spineId, group.slotName, spine)
|
||||
if (!parent) continue
|
||||
parent.invert()
|
||||
group.compensator.transform.setFromMatrix(parent)
|
||||
}
|
||||
}
|
||||
|
||||
/** 卸载时销毁全部发射器。 */
|
||||
destroy(world: Container) {
|
||||
for (const group of this.slotGroups.values()) {
|
||||
for (const child of [...group.compensator.children]) group.compensator.removeChild(child)
|
||||
group.wrapper.removeFromParent()
|
||||
group.compensator.destroy()
|
||||
group.wrapper.destroy()
|
||||
}
|
||||
this.slotGroups.clear()
|
||||
for (const [id, em] of this.emitters) {
|
||||
world.removeChild(em)
|
||||
em.removeFromParent()
|
||||
em.destroy({ children: true })
|
||||
this.emitters.delete(id)
|
||||
this.configs.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/** 配置中的渲染层级字段(仅编辑器预览)。 */
|
||||
export interface RenderLayerConfig {
|
||||
renderLayerMode: string
|
||||
renderLayerSpineId: number
|
||||
renderLayerSlotName: string
|
||||
renderLayerSlotPosition: string
|
||||
renderLayerFollowAttachment: boolean
|
||||
}
|
||||
|
||||
export interface RenderLayerSlot {
|
||||
name: string
|
||||
order: number
|
||||
}
|
||||
|
||||
export interface RenderLayerSpine {
|
||||
id: number
|
||||
enabled: boolean
|
||||
status: string
|
||||
slots?: RenderLayerSlot[]
|
||||
}
|
||||
|
||||
/** 实际要挂载的插槽;below 会解析到当前 drawOrder 中的前一个插槽。 */
|
||||
export interface RenderTarget {
|
||||
spineId: number
|
||||
/** 实际挂载的插槽 */
|
||||
slotName: string
|
||||
/** 用户配置的目标插槽(用于分组与重挂判断) */
|
||||
targetSlotName: string
|
||||
followAttachment: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* SpinePixi 的 addSlotObject 渲染在该插槽附件之上,所以「在此插槽图片之下」
|
||||
* 需要挂到当前 drawOrder 中紧邻的前一个插槽。传入 drawOrder 时按动画实时顺序解析,
|
||||
* 否则回退到 setup 顺序(用于没有运行时或旧数据)。
|
||||
*/
|
||||
export function resolveRenderTarget(
|
||||
spines: readonly RenderLayerSpine[],
|
||||
config: RenderLayerConfig,
|
||||
drawOrder?: readonly string[] | null,
|
||||
): RenderTarget | null {
|
||||
if (config.renderLayerMode !== 'slot') return null
|
||||
const spine = spines.find((item) => item.id === config.renderLayerSpineId && item.enabled && item.status === 'ready')
|
||||
if (!spine) return null
|
||||
const slots = spine.slots || []
|
||||
const target = slots.find((slot) => slot.name === config.renderLayerSlotName)
|
||||
if (!target) return null
|
||||
if (config.renderLayerSlotPosition === 'below') {
|
||||
let attachSlotName = ''
|
||||
if (drawOrder?.length) {
|
||||
const index = drawOrder.indexOf(target.name)
|
||||
if (index === 0) return null
|
||||
if (index > 0) attachSlotName = drawOrder[index - 1]
|
||||
}
|
||||
if (!attachSlotName) {
|
||||
const previous = slots
|
||||
.filter((slot) => slot.order < target.order)
|
||||
.sort((a, b) => b.order - a.order)[0]
|
||||
if (!previous) return null
|
||||
attachSlotName = previous.name
|
||||
}
|
||||
return { spineId: spine.id, slotName: attachSlotName, targetSlotName: target.name, followAttachment: false }
|
||||
}
|
||||
return {
|
||||
spineId: spine.id,
|
||||
slotName: target.name,
|
||||
targetSlotName: target.name,
|
||||
followAttachment: config.renderLayerFollowAttachment,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user