渲染顺序功能

This commit is contained in:
tianmo committed 2026-10-22 11:16:50 +08:00
1 parent aabf4ff693
commit c041503004
22 files changed
+1904 -1291

No files matched your search

+10
View File
@@ -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)
+9
View File
@@ -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
+5
View File
@@ -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 || ''
}
+94 -27
View File
@@ -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>
+74 -11
View File
@@ -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()
+13 -1
View File
@@ -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)
+10
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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>
+117 -1
View File
@@ -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 }
}
+99 -3
View File
@@ -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)
}
}
}
+70
View File
@@ -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,
}
}