阶段8落盘
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 3.1 MiB |
+1
-1
@@ -62,5 +62,5 @@ function onTimelineDrag(e: PointerEvent) {
|
|||||||
.stage-area { flex: 1; min-height: 0; overflow: clip; }
|
.stage-area { flex: 1; min-height: 0; overflow: clip; }
|
||||||
.resizer-row { height: 4px; cursor: row-resize; background: #1c1c2a; border-top: 1px solid #2a2a3c; border-bottom: 1px solid #2a2a3c; flex-shrink: 0; }
|
.resizer-row { height: 4px; cursor: row-resize; background: #1c1c2a; border-top: 1px solid #2a2a3c; border-bottom: 1px solid #2a2a3c; flex-shrink: 0; }
|
||||||
.resizer-row:hover { background: #3a5a8c; }
|
.resizer-row:hover { background: #3a5a8c; }
|
||||||
.timeline-area { flex-shrink: 0; overflow: clip; }
|
.timeline-area { position: relative; z-index: 10; flex-shrink: 0; overflow: visible; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1505,12 +1505,14 @@ export class ParticleEmitter extends Container {
|
|||||||
for (const p of this.pool) {
|
for (const p of this.pool) {
|
||||||
const st = byName.get(p.boneName)
|
const st = byName.get(p.boneName)
|
||||||
if (!st) {
|
if (!st) {
|
||||||
|
p.active = false
|
||||||
p.sprite.visible = false
|
p.sprite.visible = false
|
||||||
if (p.trailMesh) p.trailMesh.visible = false
|
if (p.trailMesh) p.trailMesh.visible = false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
p.active = st.active
|
||||||
const s = p.sprite
|
const s = p.sprite
|
||||||
s.visible = true
|
s.visible = st.active
|
||||||
s.x = st.x; s.y = st.y
|
s.x = st.x; s.y = st.y
|
||||||
s.rotation = -st.rotation * Math.PI / 180
|
s.rotation = -st.rotation * Math.PI / 180
|
||||||
s.scale.set(st.scaleX, st.scaleY)
|
s.scale.set(st.scaleX, st.scaleY)
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
<input ref="skeletonInput" hidden type="file" accept=".json,.skel,application/json,application/octet-stream" @change="selectSkeleton" />
|
<input ref="skeletonInput" hidden type="file" accept=".json,.skel,application/json,application/octet-stream" @change="selectSkeleton" />
|
||||||
<input ref="atlasInput" hidden type="file" accept=".atlas,text/plain" @change="selectAtlas" />
|
<input ref="atlasInput" hidden type="file" accept=".atlas,text/plain" @change="selectAtlas" />
|
||||||
<input ref="textureInput" hidden type="file" multiple accept=".png,.jpg,.jpeg,.webp,.wepb,image/png,image/jpeg,image/webp" @change="selectTextures" />
|
<input ref="textureInput" hidden type="file" multiple accept=".png,.jpg,.jpeg,.webp,.wepb,image/png,image/jpeg,image/webp" @change="selectTextures" />
|
||||||
|
<input ref="folderInput" hidden type="file" multiple webkitdirectory directory @change="selectFolder" />
|
||||||
|
<button class="folder-select" :disabled="spineObject.status === 'loading'" @click="folderInput?.click()">选择文件夹并加载</button>
|
||||||
|
<div class="folder-hint">一次读取文件夹中的 JSON / SKEL、Atlas 和全部纹理;仍可在下方单独替换资源。</div>
|
||||||
<div class="asset-row">
|
<div class="asset-row">
|
||||||
<div class="asset-name" :title="skeletonName">{{ skeletonName || 'JSON / SKEL...' }}</div>
|
<div class="asset-name" :title="skeletonName">{{ skeletonName || 'JSON / SKEL...' }}</div>
|
||||||
<button @click="skeletonInput?.click()">选择</button>
|
<button @click="skeletonInput?.click()">选择</button>
|
||||||
@@ -111,6 +114,7 @@ const store = useParticleStore()
|
|||||||
const skeletonInput = ref<HTMLInputElement | null>(null)
|
const skeletonInput = ref<HTMLInputElement | null>(null)
|
||||||
const atlasInput = ref<HTMLInputElement | null>(null)
|
const atlasInput = ref<HTMLInputElement | null>(null)
|
||||||
const textureInput = ref<HTMLInputElement | null>(null)
|
const textureInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const folderInput = ref<HTMLInputElement | null>(null)
|
||||||
const collapsedBones = ref(new Set<string>())
|
const collapsedBones = ref(new Set<string>())
|
||||||
const boneSearch = ref('')
|
const boneSearch = ref('')
|
||||||
const skeletonFile = ref<File | null>(null)
|
const skeletonFile = ref<File | null>(null)
|
||||||
@@ -160,6 +164,34 @@ function selectSkeleton(event: Event) { skeletonFile.value = firstFile(event) }
|
|||||||
function selectAtlas(event: Event) { atlasFile.value = firstFile(event) }
|
function selectAtlas(event: Event) { atlasFile.value = firstFile(event) }
|
||||||
function selectTextures(event: Event) { textureFiles.value = Array.from((event.target as HTMLInputElement).files || []) }
|
function selectTextures(event: Event) { textureFiles.value = Array.from((event.target as HTMLInputElement).files || []) }
|
||||||
|
|
||||||
|
function fileStem(file: File) {
|
||||||
|
return file.name.replace(/\.[^.]+$/, '').toLocaleLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectFolder(event: Event) {
|
||||||
|
const files = Array.from((event.target as HTMLInputElement).files || [])
|
||||||
|
if (!files.length) return
|
||||||
|
const skeletons = files.filter((file) => /\.(json|skel)$/i.test(file.name))
|
||||||
|
const atlases = files.filter((file) => /\.atlas$/i.test(file.name))
|
||||||
|
const textures = files.filter((file) => /\.(png|jpe?g|webp|wepb)$/i.test(file.name))
|
||||||
|
const matched = atlases
|
||||||
|
.map((atlas) => ({ atlas, skeleton: skeletons.find((skeleton) => fileStem(skeleton) === fileStem(atlas)) }))
|
||||||
|
.find((pair) => pair.skeleton)
|
||||||
|
const skeleton = matched?.skeleton || skeletons[0] || null
|
||||||
|
const atlas = matched?.atlas || atlases[0] || null
|
||||||
|
|
||||||
|
skeletonFile.value = skeleton
|
||||||
|
atlasFile.value = atlas
|
||||||
|
textureFiles.value = textures
|
||||||
|
if (!skeleton || !atlas || !textures.length) {
|
||||||
|
const missing = [!skeleton && 'JSON / SKEL', !atlas && 'Atlas', !textures.length && '纹理图片'].filter(Boolean).join('、')
|
||||||
|
props.spineObject.status = 'error'
|
||||||
|
props.spineObject.error = `所选文件夹缺少:${missing}`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await loadBundle()
|
||||||
|
}
|
||||||
|
|
||||||
function recalcTimeline() {
|
function recalcTimeline() {
|
||||||
store.recalcTotalFrames()
|
store.recalcTotalFrames()
|
||||||
}
|
}
|
||||||
@@ -209,6 +241,7 @@ function clearBundle() {
|
|||||||
if (skeletonInput.value) skeletonInput.value.value = ''
|
if (skeletonInput.value) skeletonInput.value.value = ''
|
||||||
if (atlasInput.value) atlasInput.value.value = ''
|
if (atlasInput.value) atlasInput.value.value = ''
|
||||||
if (textureInput.value) textureInput.value.value = ''
|
if (textureInput.value) textureInput.value.value = ''
|
||||||
|
if (folderInput.value) folderInput.value.value = ''
|
||||||
props.spineObject.skeletonFileName = ''
|
props.spineObject.skeletonFileName = ''
|
||||||
props.spineObject.atlasFileName = ''
|
props.spineObject.atlasFileName = ''
|
||||||
props.spineObject.textureFileNames = []
|
props.spineObject.textureFileNames = []
|
||||||
@@ -239,6 +272,8 @@ function clearBundle() {
|
|||||||
button { height: 35px; padding: 0 13px; border: 1px solid #3b4a67; border-radius: 6px; background: #33415b; color: #e2e6f3; cursor: pointer; }
|
button { height: 35px; padding: 0 13px; border: 1px solid #3b4a67; border-radius: 6px; background: #33415b; color: #e2e6f3; cursor: pointer; }
|
||||||
button:hover { filter: brightness(1.13); }
|
button:hover { filter: brightness(1.13); }
|
||||||
button:disabled { opacity: .55; cursor: wait; }
|
button:disabled { opacity: .55; cursor: wait; }
|
||||||
|
.folder-select { width: 100%; border-color: #6659c7; background: #4940a8; color: #fff; }
|
||||||
|
.folder-hint { margin-top: -5px; color: #69758e; font-size: 10px; line-height: 1.45; }
|
||||||
.asset-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
|
.asset-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
|
||||||
.asset-actions .load { background: #5543df; border-color: #6959ec; }
|
.asset-actions .load { background: #5543df; border-color: #6959ec; }
|
||||||
.asset-actions .clear { background: #702b34; border-color: #803842; }
|
.asset-actions .clear { background: #702b34; border-color: #803842; }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface LoadedSpineAsset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const assets = new Map<number, LoadedSpineAsset>()
|
const assets = new Map<number, LoadedSpineAsset>()
|
||||||
|
const assetReferences = new Map<LoadedSpineAsset, number>()
|
||||||
|
|
||||||
export function getSpineAsset(id: number) {
|
export function getSpineAsset(id: number) {
|
||||||
return assets.get(id) || null
|
return assets.get(id) || null
|
||||||
@@ -15,12 +16,26 @@ export function getSpineAsset(id: number) {
|
|||||||
export function registerSpineAsset(id: number, asset: LoadedSpineAsset) {
|
export function registerSpineAsset(id: number, asset: LoadedSpineAsset) {
|
||||||
releaseSpineAsset(id)
|
releaseSpineAsset(id)
|
||||||
assets.set(id, asset)
|
assets.set(id, asset)
|
||||||
|
assetReferences.set(asset, (assetReferences.get(asset) || 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 让另一个场景对象复用同一套只读 Spine 解析资源。 */
|
||||||
|
export function retainSpineAsset(sourceId: number, targetId: number) {
|
||||||
|
const asset = assets.get(sourceId)
|
||||||
|
if (!asset) return false
|
||||||
|
registerSpineAsset(targetId, asset)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function releaseSpineAsset(id: number) {
|
export function releaseSpineAsset(id: number) {
|
||||||
const previous = assets.get(id)
|
const previous = assets.get(id)
|
||||||
if (!previous) return
|
if (!previous) return
|
||||||
for (const texture of previous.textures) texture.destroy(true)
|
|
||||||
assets.delete(id)
|
assets.delete(id)
|
||||||
|
const references = assetReferences.get(previous) || 1
|
||||||
|
if (references > 1) {
|
||||||
|
assetReferences.set(previous, references - 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assetReferences.delete(previous)
|
||||||
|
for (const texture of previous.textures) texture.destroy(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { EmitterConfig, defaultConfig, ParticleState, type SceneCollider } from '../core/particleEmitter'
|
import { EmitterConfig, defaultConfig, ParticleState, type SceneCollider } from '../core/particleEmitter'
|
||||||
import type { SpineSceneObject } from '../spine/spineTypes'
|
import type { SpineSceneObject } from '../spine/spineTypes'
|
||||||
import { releaseSpineAsset } from '../spine/spineAssetRegistry'
|
import { releaseSpineAsset, retainSpineAsset } from '../spine/spineAssetRegistry'
|
||||||
|
|
||||||
export interface ParticleSystem {
|
export interface ParticleSystem {
|
||||||
id: number
|
id: number
|
||||||
@@ -88,6 +88,29 @@ let colliderSeq = 1
|
|||||||
let pathSeq = 1
|
let pathSeq = 1
|
||||||
let spineSeq = 1
|
let spineSeq = 1
|
||||||
|
|
||||||
|
/** 深拷贝编辑器配置,但保留 Texture 等运行时类实例的共享引用。 */
|
||||||
|
function cloneEditorValue<T>(value: T): T {
|
||||||
|
if (Array.isArray(value)) return value.map((item) => cloneEditorValue(item)) as T
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const prototype = Object.getPrototypeOf(value)
|
||||||
|
if (prototype === Object.prototype || prototype === null) {
|
||||||
|
const copy: Record<string, unknown> = {}
|
||||||
|
for (const [key, item] of Object.entries(value)) copy[key] = cloneEditorValue(item)
|
||||||
|
return copy as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyName(name: string, names: string[]) {
|
||||||
|
const existing = new Set(names)
|
||||||
|
const base = `${name} Copy`
|
||||||
|
if (!existing.has(base)) return base
|
||||||
|
let index = 2
|
||||||
|
while (existing.has(`${base} ${index}`)) index++
|
||||||
|
return `${base} ${index}`
|
||||||
|
}
|
||||||
|
|
||||||
export const useParticleStore = defineStore('particle', {
|
export const useParticleStore = defineStore('particle', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
editor: { viewScale: 1.0, viewX: 0, viewY: 0 } as EditorState,
|
editor: { viewScale: 1.0, viewX: 0, viewY: 0 } as EditorState,
|
||||||
@@ -273,6 +296,59 @@ export const useParticleStore = defineStore('particle', {
|
|||||||
this.activeObjectId = spine.id
|
this.activeObjectId = spine.id
|
||||||
return spine
|
return spine
|
||||||
},
|
},
|
||||||
|
duplicateSelectedObject() {
|
||||||
|
const id = this.activeObjectId
|
||||||
|
if (!id) return null
|
||||||
|
|
||||||
|
if (this.activeObjectType === 'particle') {
|
||||||
|
const source = this.systems.find((item) => item.id === id)
|
||||||
|
if (!source) return null
|
||||||
|
const copy = cloneEditorValue(source)
|
||||||
|
copy.id = seq++
|
||||||
|
copy.config.name = copyName(source.config.name, this.systems.map((item) => item.config.name))
|
||||||
|
// 录制帧属于运行时缓存,副本由相同配置重新独立生成。
|
||||||
|
copy.frames = undefined
|
||||||
|
this.systems.push(copy)
|
||||||
|
this.activeId = copy.id
|
||||||
|
this.activeObjectId = copy.id
|
||||||
|
this.timeline.animation = copy.animation
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.activeObjectType === 'collision') {
|
||||||
|
const source = this.colliders.find((item) => item.id === id)
|
||||||
|
if (!source) return null
|
||||||
|
const copy = cloneEditorValue(source)
|
||||||
|
copy.id = colliderSeq++
|
||||||
|
copy.name = copyName(source.name, this.colliders.map((item) => item.name))
|
||||||
|
this.colliders.push(copy)
|
||||||
|
this.activeObjectId = copy.id
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.activeObjectType === 'path') {
|
||||||
|
const source = this.paths.find((item) => item.id === id)
|
||||||
|
if (!source) return null
|
||||||
|
const copy = cloneEditorValue(source)
|
||||||
|
copy.id = pathSeq++
|
||||||
|
copy.name = copyName(source.name, this.paths.map((item) => item.name))
|
||||||
|
this.paths.push(copy)
|
||||||
|
this.activeObjectId = copy.id
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = this.spines.find((item) => item.id === id)
|
||||||
|
if (!source) return null
|
||||||
|
const copy = cloneEditorValue(source)
|
||||||
|
copy.id = spineSeq++
|
||||||
|
copy.name = copyName(source.name, this.spines.map((item) => item.name))
|
||||||
|
// 骨骼选择是层级树的临时操作状态,不在两个对象之间复用。
|
||||||
|
copy.selectedBone = ''
|
||||||
|
retainSpineAsset(source.id, copy.id)
|
||||||
|
this.spines.push(copy)
|
||||||
|
this.activeObjectId = copy.id
|
||||||
|
return copy
|
||||||
|
},
|
||||||
selectSceneObject(type: SceneObjectType, id: number) {
|
selectSceneObject(type: SceneObjectType, id: number) {
|
||||||
this.activeObjectType = type
|
this.activeObjectType = type
|
||||||
this.activeObjectId = id
|
this.activeObjectId = id
|
||||||
@@ -326,9 +402,16 @@ export const useParticleStore = defineStore('particle', {
|
|||||||
}
|
}
|
||||||
this.recalcTotalFrames()
|
this.recalcTotalFrames()
|
||||||
},
|
},
|
||||||
play() { this.timeline.playing = true },
|
play() {
|
||||||
|
// 非循环播放停在末帧后,再次播放应从头开始;中途暂停则继续当前位置。
|
||||||
|
if (this.timeline.frame >= Math.max(0, this.timeline.totalFrames - 1)) this.timeline.frame = 0
|
||||||
|
this.timeline.playing = true
|
||||||
|
},
|
||||||
pause() { this.timeline.playing = false },
|
pause() { this.timeline.playing = false },
|
||||||
togglePlay() { this.timeline.playing = !this.timeline.playing },
|
togglePlay() {
|
||||||
|
if (this.timeline.playing) this.pause()
|
||||||
|
else this.play()
|
||||||
|
},
|
||||||
toStart() { this.timeline.frame = 0 },
|
toStart() { this.timeline.frame = 0 },
|
||||||
previousFrame() {
|
previousFrame() {
|
||||||
this.timeline.playing = false
|
this.timeline.playing = false
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<button class="scene-add" title="添加碰撞体" @click.stop="addCollider">+碰撞</button>
|
<button class="scene-add" title="添加碰撞体" @click.stop="addCollider">+碰撞</button>
|
||||||
<button class="scene-add" title="添加路径" @click.stop="addPath">+路径</button>
|
<button class="scene-add" title="添加路径" @click.stop="addPath">+路径</button>
|
||||||
<button class="scene-add" title="添加 Spine" @click.stop="addSpine">+Spine</button>
|
<button class="scene-add" title="添加 Spine" @click.stop="addSpine">+Spine</button>
|
||||||
|
<button class="scene-add" :disabled="!canCopyObject" title="复制当前选中的场景对象" @click.stop="copySelectedObject">+Copy</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="group-body" v-show="!collapsed.scene">
|
<div class="group-body" v-show="!collapsed.scene">
|
||||||
@@ -797,6 +798,12 @@ const activePath = computed(() => store.activeObjectType === 'path'
|
|||||||
const activeSpine = computed(() => store.activeObjectType === 'spine'
|
const activeSpine = computed(() => store.activeObjectType === 'spine'
|
||||||
? store.spines.find((item) => item.id === store.activeObjectId) || null
|
? store.spines.find((item) => item.id === store.activeObjectId) || null
|
||||||
: null)
|
: null)
|
||||||
|
const canCopyObject = computed(() => {
|
||||||
|
if (store.activeObjectType === 'particle') return store.systems.some((item) => item.id === store.activeObjectId)
|
||||||
|
if (store.activeObjectType === 'collision') return store.colliders.some((item) => item.id === store.activeObjectId)
|
||||||
|
if (store.activeObjectType === 'path') return store.paths.some((item) => item.id === store.activeObjectId)
|
||||||
|
return store.spines.some((item) => item.id === store.activeObjectId)
|
||||||
|
})
|
||||||
const selectedSpineBone = computed(() => {
|
const selectedSpineBone = computed(() => {
|
||||||
const spine = store.spines.find((item) => item.selectedBone && item.bones.some((bone) => bone.name === item.selectedBone))
|
const spine = store.spines.find((item) => item.selectedBone && item.bones.some((bone) => bone.name === item.selectedBone))
|
||||||
return spine ? { spine, boneName: spine.selectedBone } : null
|
return spine ? { spine, boneName: spine.selectedBone } : null
|
||||||
@@ -972,6 +979,7 @@ function addSys() { store.addSystem() }
|
|||||||
function addCollider() { store.addCollider() }
|
function addCollider() { store.addCollider() }
|
||||||
function addPath() { store.addPath() }
|
function addPath() { store.addPath() }
|
||||||
function addSpine() { store.addSpine() }
|
function addSpine() { store.addSpine() }
|
||||||
|
function copySelectedObject() { store.duplicateSelectedObject() }
|
||||||
function addColliderTag() {
|
function addColliderTag() {
|
||||||
const collider = activeCollider.value
|
const collider = activeCollider.value
|
||||||
if (!collider) return
|
if (!collider) return
|
||||||
@@ -1445,6 +1453,7 @@ function toggleResourceLock(resourceId: number) {
|
|||||||
background: #2e3a55; color: #cdf; font-size: 10px; white-space: nowrap; cursor: pointer;
|
background: #2e3a55; color: #cdf; font-size: 10px; white-space: nowrap; cursor: pointer;
|
||||||
}
|
}
|
||||||
.scene-add:hover { border-color: #7774ff; background: #45426f; color: #fff; }
|
.scene-add:hover { border-color: #7774ff; background: #45426f; color: #fff; }
|
||||||
|
.scene-add:disabled { opacity: 0.42; cursor: not-allowed; border-color: #343a4b; background: #252a38; color: #778095; }
|
||||||
.group-body { padding: 8px 10px 12px; }
|
.group-body { padding: 8px 10px 12px; }
|
||||||
.caret { display: inline-block; width: 12px; color: #667; font-size: 11px; }
|
.caret { display: inline-block; width: 12px; color: #667; font-size: 11px; }
|
||||||
.divider { height: 1px; background: #2a2a3c; margin: 8px 0; }
|
.divider { height: 1px; background: #2a2a3c; margin: 8px 0; }
|
||||||
|
|||||||
+143
-5
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
<!-- 顶栏 -->
|
<!-- 顶栏 -->
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<button class="settings-btn" @click="showSettings = !showSettings" title="系统设置">系统设置</button>
|
<button class="settings-btn" @click="toggleSettings" title="系统设置">系统设置</button>
|
||||||
|
|
||||||
<!-- 根变换工具:拖动(默认) / 位移 / 旋转 / 缩放 -->
|
<!-- 根变换工具:拖动(默认) / 位移 / 旋转 / 缩放 -->
|
||||||
<div class="tool-group">
|
<div class="tool-group">
|
||||||
@@ -23,6 +23,38 @@
|
|||||||
<button class="tool-btn" @click="resetView" title="重置视图">⟳</button>
|
<button class="tool-btn" @click="resetView" title="重置视图">⟳</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="background-menu" @click.stop>
|
||||||
|
<button
|
||||||
|
class="background-btn"
|
||||||
|
:class="{ on: showBackgroundMenu }"
|
||||||
|
title="背景图显示设置"
|
||||||
|
@click="toggleBackgroundMenu"
|
||||||
|
>背景图</button>
|
||||||
|
<div v-if="showBackgroundMenu" class="background-panel">
|
||||||
|
<button class="background-upload" @click="backgroundFileInput?.click()">
|
||||||
|
{{ backgroundFileName ? '更换图像' : '上传图像' }}
|
||||||
|
</button>
|
||||||
|
<button class="background-clear" :disabled="!backgroundFileName" @click="clearBackground()">清除背景</button>
|
||||||
|
<input
|
||||||
|
ref="backgroundFileInput"
|
||||||
|
class="background-file-input"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
@change="onBackgroundFileChange"
|
||||||
|
/>
|
||||||
|
<div class="background-file-name" :title="backgroundFileName">{{ backgroundFileName || '未上传图像' }}</div>
|
||||||
|
<div class="background-position-row">
|
||||||
|
<label><span>X</span><input v-model.number="backgroundX" type="number" step="1" aria-label="背景图 X 偏移" /></label>
|
||||||
|
<label><span>Y</span><input v-model.number="backgroundY" type="number" step="1" aria-label="背景图 Y 偏移" /></label>
|
||||||
|
</div>
|
||||||
|
<label class="background-opacity-row">
|
||||||
|
<span>透明度</span>
|
||||||
|
<input v-model.number="backgroundOpacity" type="range" min="0" max="1" step="0.01" aria-label="背景图透明度" />
|
||||||
|
<b>{{ Math.round(backgroundOpacity * 100) }}%</b>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span class="zoom-pct">{{ Math.round(store.editor.viewScale * 100) }}%</span>
|
<span class="zoom-pct">{{ Math.round(store.editor.viewScale * 100) }}%</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -62,7 +94,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { Application, Container, Graphics, Text, Texture } from 'pixi.js'
|
import { Application, Container, Graphics, Sprite, Text, Texture } from 'pixi.js'
|
||||||
import { ParticleEmitter, ensureEmitterConfig, type ParticleState } from '../core/particleEmitter'
|
import { ParticleEmitter, ensureEmitterConfig, type ParticleState } from '../core/particleEmitter'
|
||||||
import { useParticleStore, type CollisionBody, type ScenePath } from '../store/particleStore'
|
import { useParticleStore, type CollisionBody, type ScenePath } from '../store/particleStore'
|
||||||
import { SpineRuntimeLayer } from '../spine/SpineRuntimeLayer'
|
import { SpineRuntimeLayer } from '../spine/SpineRuntimeLayer'
|
||||||
@@ -75,12 +107,21 @@ const showSkinMesh = ref(false)
|
|||||||
const info = ref<{ systems: number; particles: number } | null>(null)
|
const info = ref<{ systems: number; particles: number } | null>(null)
|
||||||
const showGrid = ref(true)
|
const showGrid = ref(true)
|
||||||
const showSettings = ref(false)
|
const showSettings = ref(false)
|
||||||
|
const showBackgroundMenu = ref(false)
|
||||||
|
const backgroundFileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const backgroundFileName = ref('')
|
||||||
|
const backgroundX = ref(0)
|
||||||
|
const backgroundY = ref(0)
|
||||||
|
const backgroundOpacity = ref(1)
|
||||||
// 根变换 gizmo 工具:'translate' | 'rotate' | 'scale' | ''(无)
|
// 根变换 gizmo 工具:'translate' | 'rotate' | 'scale' | ''(无)
|
||||||
const activeTool = ref<'' | 'translate' | 'rotate' | 'scale'>('')
|
const activeTool = ref<'' | 'translate' | 'rotate' | 'scale'>('')
|
||||||
function setTool(t: '' | 'translate' | 'rotate' | 'scale') { activeTool.value = activeTool.value === t ? '' : t }
|
function setTool(t: '' | 'translate' | 'rotate' | 'scale') { activeTool.value = activeTool.value === t ? '' : t }
|
||||||
|
|
||||||
let app: Application | null = null
|
let app: Application | null = null
|
||||||
let world: Container | null = null
|
let world: Container | null = null
|
||||||
|
let backgroundSprite: Sprite | null = null
|
||||||
|
let backgroundTexture: Texture | null = null
|
||||||
|
let backgroundObjectUrl = ''
|
||||||
let originGfx: Graphics | null = null
|
let originGfx: Graphics | null = null
|
||||||
let boneFollowTargetGfx: Graphics | null = null
|
let boneFollowTargetGfx: Graphics | null = null
|
||||||
let boneGfx: Graphics | null = null
|
let boneGfx: Graphics | null = null
|
||||||
@@ -124,6 +165,10 @@ function createLayers() {
|
|||||||
app!.stage.addChild(world)
|
app!.stage.addChild(world)
|
||||||
axisLabelLayer = new Container() // 独立顶层,不随 world 缩放
|
axisLabelLayer = new Container() // 独立顶层,不随 world 缩放
|
||||||
app!.stage.addChild(axisLabelLayer)
|
app!.stage.addChild(axisLabelLayer)
|
||||||
|
backgroundSprite = new Sprite(Texture.EMPTY)
|
||||||
|
backgroundSprite.anchor.set(0.5)
|
||||||
|
backgroundSprite.visible = false
|
||||||
|
world.addChild(backgroundSprite)
|
||||||
axisGfx = new Graphics()
|
axisGfx = new Graphics()
|
||||||
world.addChild(axisGfx)
|
world.addChild(axisGfx)
|
||||||
spineRuntime = new SpineRuntimeLayer()
|
spineRuntime = new SpineRuntimeLayer()
|
||||||
@@ -148,6 +193,58 @@ function createLayers() {
|
|||||||
world.addChild(boneGfx)
|
world.addChild(boneGfx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleBackgroundMenu() {
|
||||||
|
showBackgroundMenu.value = !showBackgroundMenu.value
|
||||||
|
if (showBackgroundMenu.value) showSettings.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSettings() {
|
||||||
|
showSettings.value = !showSettings.value
|
||||||
|
if (showSettings.value) showBackgroundMenu.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onBackgroundFileChange(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file || !backgroundSprite) return
|
||||||
|
const nextUrl = URL.createObjectURL(file)
|
||||||
|
try {
|
||||||
|
const nextTexture = await Texture.fromURL(nextUrl)
|
||||||
|
const previousTexture = backgroundTexture
|
||||||
|
const previousUrl = backgroundObjectUrl
|
||||||
|
backgroundTexture = nextTexture
|
||||||
|
backgroundObjectUrl = nextUrl
|
||||||
|
backgroundSprite.texture = nextTexture
|
||||||
|
backgroundSprite.visible = true
|
||||||
|
backgroundFileName.value = file.name
|
||||||
|
if (previousTexture) previousTexture.destroy(true)
|
||||||
|
if (previousUrl) URL.revokeObjectURL(previousUrl)
|
||||||
|
} catch (error) {
|
||||||
|
URL.revokeObjectURL(nextUrl)
|
||||||
|
console.error('背景图加载失败', error)
|
||||||
|
} finally {
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBackground(resetControls = true) {
|
||||||
|
if (backgroundSprite) {
|
||||||
|
backgroundSprite.texture = Texture.EMPTY
|
||||||
|
backgroundSprite.visible = false
|
||||||
|
}
|
||||||
|
if (backgroundTexture) backgroundTexture.destroy(true)
|
||||||
|
if (backgroundObjectUrl) URL.revokeObjectURL(backgroundObjectUrl)
|
||||||
|
backgroundTexture = null
|
||||||
|
backgroundObjectUrl = ''
|
||||||
|
backgroundFileName.value = ''
|
||||||
|
if (backgroundFileInput.value) backgroundFileInput.value.value = ''
|
||||||
|
if (resetControls) {
|
||||||
|
backgroundX.value = 0
|
||||||
|
backgroundY.value = 0
|
||||||
|
backgroundOpacity.value = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 自适应刻度步进(仿时间轴):返回 1/2/5 序列里的最合适步长,保证屏幕间距合理
|
// 自适应刻度步进(仿时间轴):返回 1/2/5 序列里的最合适步长,保证屏幕间距合理
|
||||||
function niceStep(target: number, minStep = 0): number {
|
function niceStep(target: number, minStep = 0): number {
|
||||||
if (target <= 0) return 1
|
if (target <= 0) return 1
|
||||||
@@ -1139,6 +1236,10 @@ function loop() {
|
|||||||
if (app && world) {
|
if (app && world) {
|
||||||
world.scale.set(store.editor.viewScale)
|
world.scale.set(store.editor.viewScale)
|
||||||
world.position.set(app.screen.width / 2 + store.editor.viewX, app.screen.height * 0.5 + store.editor.viewY)
|
world.position.set(app.screen.width / 2 + store.editor.viewX, app.screen.height * 0.5 + store.editor.viewY)
|
||||||
|
if (backgroundSprite) {
|
||||||
|
backgroundSprite.position.set(Number(backgroundX.value) || 0, -(Number(backgroundY.value) || 0))
|
||||||
|
backgroundSprite.alpha = Math.min(1, Math.max(0, Number(backgroundOpacity.value) || 0))
|
||||||
|
}
|
||||||
|
|
||||||
drawAxis()
|
drawAxis()
|
||||||
syncEmitters()
|
syncEmitters()
|
||||||
@@ -1291,9 +1392,11 @@ function loop() {
|
|||||||
if (!tl.playing) displayFrame = tl.frame
|
if (!tl.playing) displayFrame = tl.frame
|
||||||
for (const sys of store.systems) {
|
for (const sys of store.systems) {
|
||||||
const emitter = emitterMap.get(sys.id)
|
const emitter = emitterMap.get(sys.id)
|
||||||
const frame = sys.frames?.[tl.frame]
|
// 播放中使用最近一次真正推进的显示帧;暂停/逐帧时读取播放头指定帧。
|
||||||
if (!tl.playing && tl.recorded && emitter && frame) {
|
// 回放缓存存在时不再调用 update(0),避免用首次模拟结束后的旧活动状态覆盖数量。
|
||||||
// 暂停、逐帧或手动拖动播放头时立即显示指定的录制帧。
|
const replayFrameIndex = tl.playing ? timelineDisplayFrame : tl.frame
|
||||||
|
const frame = sys.frames?.[replayFrameIndex]
|
||||||
|
if (tl.recorded && emitter && frame) {
|
||||||
emitter.apply(frame)
|
emitter.apply(frame)
|
||||||
for (const state of frame) if (state.active) {
|
for (const state of frame) if (state.active) {
|
||||||
if (sys.visible !== false) appendStateBones(collected, state)
|
if (sys.visible !== false) appendStateBones(collected, state)
|
||||||
@@ -1377,6 +1480,8 @@ onUnmounted(() => {
|
|||||||
cancelAnimationFrame(raf)
|
cancelAnimationFrame(raf)
|
||||||
spineRuntime?.destroy()
|
spineRuntime?.destroy()
|
||||||
spineRuntime = null
|
spineRuntime = null
|
||||||
|
clearBackground(false)
|
||||||
|
backgroundSprite = null
|
||||||
if (app) { app.destroy(true, { children: true }); app = null }
|
if (app) { app.destroy(true, { children: true }); app = null }
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -1407,6 +1512,39 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.tool-btn:hover { background: #2b3a5c; color: #fff; }
|
.tool-btn:hover { background: #2b3a5c; color: #fff; }
|
||||||
.tool-btn.on { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
|
.tool-btn.on { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
|
||||||
|
.background-menu { position: relative; flex: 0 0 auto; }
|
||||||
|
.background-btn {
|
||||||
|
height: 28px; padding: 0 11px; border: 1px solid #3a4963; border-radius: 6px;
|
||||||
|
background: #202a3b; color: #cbd7ed; font-size: 12px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.background-btn:hover, .background-btn.on { border-color: #7774ff; background: #343b63; color: #fff; }
|
||||||
|
.background-panel {
|
||||||
|
position: absolute; top: calc(100% + 9px); right: 0; width: 248px; box-sizing: border-box; padding: 10px;
|
||||||
|
border: 1px solid #343b50; border-radius: 9px; background: rgba(20,20,32,.98); color: #aeb8cc;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,.48); z-index: 12;
|
||||||
|
}
|
||||||
|
.background-upload {
|
||||||
|
width: 100%; height: 30px; border: 1px solid #5652b5; border-radius: 6px;
|
||||||
|
background: #4540a0; color: #fff; cursor: pointer;
|
||||||
|
}
|
||||||
|
.background-upload:hover { border-color: #8982ff; background: #554dc2; }
|
||||||
|
.background-clear {
|
||||||
|
width: 100%; height: 28px; margin-top: 6px; border: 1px solid #70404a; border-radius: 6px;
|
||||||
|
background: #512a33; color: #efb5bd; cursor: pointer;
|
||||||
|
}
|
||||||
|
.background-clear:hover:not(:disabled) { border-color: #b65b6a; background: #6a303b; color: #fff; }
|
||||||
|
.background-clear:disabled { opacity: .42; cursor: not-allowed; }
|
||||||
|
.background-file-input { display: none; }
|
||||||
|
.background-file-name { margin: 7px 2px 9px; overflow: hidden; color: #737f98; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.background-position-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
.background-position-row label { display: flex; align-items: center; gap: 5px; color: #8995ad; font-size: 11px; }
|
||||||
|
.background-position-row input {
|
||||||
|
width: 0; min-width: 0; flex: 1; box-sizing: border-box; height: 28px; padding: 0 6px;
|
||||||
|
border: 1px solid #343e55; border-radius: 5px; background: #101522; color: #e1e6f2;
|
||||||
|
}
|
||||||
|
.background-opacity-row { display: grid; grid-template-columns: 46px 1fr 34px; align-items: center; gap: 7px; margin-top: 10px; font-size: 11px; }
|
||||||
|
.background-opacity-row input { min-width: 0; accent-color: #675de0; }
|
||||||
|
.background-opacity-row b { color: #aeb8cc; font-size: 10px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
.zoom-pct { margin-left: auto; font-variant-numeric: tabular-nums; color: #9aa; min-width: 44px; text-align: center; }
|
.zoom-pct { margin-left: auto; font-variant-numeric: tabular-nums; color: #9aa; min-width: 44px; text-align: center; }
|
||||||
.hud { position: absolute; bottom: 12px; left: 12px; padding: 8px 12px; background: rgba(20,20,32,0.75); border-radius: 8px; font-size: 12px; color: #a0a8cc; }
|
.hud { position: absolute; bottom: 12px; left: 12px; padding: 8px 12px; background: rgba(20,20,32,0.75); border-radius: 8px; font-size: 12px; color: #a0a8cc; }
|
||||||
.hud-number { display: inline-block; box-sizing: border-box; text-align: right; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum' 1; }
|
.hud-number { display: inline-block; box-sizing: border-box; text-align: right; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum' 1; }
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ function onSpineBarDown(e: PointerEvent, spine: SpineSceneObject) {
|
|||||||
.tl-animation-trigger > i { flex: 0 0 auto; color: #8c96ad; font-style: normal; }
|
.tl-animation-trigger > i { flex: 0 0 auto; color: #8c96ad; font-style: normal; }
|
||||||
.tl-animation-trigger:disabled { opacity: .5; cursor: not-allowed; }
|
.tl-animation-trigger:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
.tl-animation-menu {
|
.tl-animation-menu {
|
||||||
position: absolute; z-index: 30; top: calc(100% + 6px); right: 0; width: 226px; padding: 7px;
|
position: absolute; z-index: 30; right: 0; bottom: calc(100% + 6px); width: 226px; padding: 7px;
|
||||||
border: 1px solid #3c4760; border-radius: 8px; background: #171b29; box-shadow: 0 10px 24px rgba(0,0,0,.45);
|
border: 1px solid #3c4760; border-radius: 8px; background: #171b29; box-shadow: 0 10px 24px rgba(0,0,0,.45);
|
||||||
}
|
}
|
||||||
.tl-animation-menu-title { padding: 2px 4px 6px; color: #77839c; font-size: 10px; }
|
.tl-animation-menu-title { padding: 2px 4px 6px; color: #77839c; font-size: 10px; }
|
||||||
|
|||||||
+124
-7
@@ -1,6 +1,6 @@
|
|||||||
# 网页粒子系统 · 阶段八
|
# 网页粒子系统 · 阶段八
|
||||||
|
|
||||||
> 阶段定位:**Spine 骨骼选择、粒子独立骨骼跟随、对象显隐与所属动画管理定版**
|
> 阶段定位:**Spine 骨骼选择、粒子独立骨骼跟随、对象显隐、编辑效率与画布参考功能定版**
|
||||||
> 完成日期:2026-08-29
|
> 完成日期:2026-08-29
|
||||||
> 工程目录:`/Users/tianmokeji/Desktop/SpineParticle`
|
> 工程目录:`/Users/tianmokeji/Desktop/SpineParticle`
|
||||||
> 技术栈:Vue 3 + TypeScript + Pinia + PixiJS 7 + Spine Runtime 4.2 + Vite
|
> 技术栈:Vue 3 + TypeScript + Pinia + PixiJS 7 + Spine Runtime 4.2 + Vite
|
||||||
@@ -29,7 +29,11 @@
|
|||||||
12. 将时间轴“动画”升级为粒子系统“所属动画”管理;
|
12. 将时间轴“动画”升级为粒子系统“所属动画”管理;
|
||||||
13. 支持所属动画新增、重命名、删除和最少一个动画保护;
|
13. 支持所属动画新增、重命名、删除和最少一个动画保护;
|
||||||
14. 移除画布右上角重复的秒数显示;
|
14. 移除画布右上角重复的秒数显示;
|
||||||
15. 完成 Spine 4.2 测试资源下的交互与生产构建验证。
|
15. 增加 Spine 资源文件夹一键加载;
|
||||||
|
16. 修复统计数字、帧数和所属动画菜单的显示稳定性;
|
||||||
|
17. 为四类场景对象增加独立复制功能;
|
||||||
|
18. 增加画布背景图上传、偏移、透明度和清除功能;
|
||||||
|
19. 完成 Spine 4.2 与背景图测试资源下的交互及生产构建验证。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -395,7 +399,104 @@ emitterFollowBoneScaleY
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 九、验证结果
|
## 九、编辑效率与画布参考功能
|
||||||
|
|
||||||
|
### 9.1 Spine 文件夹一键加载
|
||||||
|
|
||||||
|
Spine 资源面板在原有骨架数据、Atlas 和纹理分别选择的方式之外,增加文件夹加载入口。
|
||||||
|
|
||||||
|
选择文件夹后会自动识别:
|
||||||
|
|
||||||
|
- 一个 `.json` 或 `.skel` 骨架数据文件;
|
||||||
|
- 一个 `.atlas` 图集文件;
|
||||||
|
- 文件夹中的 `.png`、`.jpg`、`.jpeg`、`.webp` 纹理;
|
||||||
|
- Atlas 实际引用的多张纹理页面。
|
||||||
|
|
||||||
|
识别完成后直接执行与原三步选择相同的 Spine 4.2 加载流程,保留原有分文件选择方式作为兼容入口。
|
||||||
|
|
||||||
|
### 9.2 当前场景对象独立复制
|
||||||
|
|
||||||
|
场景对象标题栏在 `+Spine` 后增加 `+Copy` 按钮。按钮读取场景树当前选中项,支持复制:
|
||||||
|
|
||||||
|
- 粒子系统;
|
||||||
|
- 碰撞体;
|
||||||
|
- 路径;
|
||||||
|
- Spine。
|
||||||
|
|
||||||
|
复制完成后,新对象自动成为当前选中对象,并使用不重复名称:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ParticleSystem1 Copy
|
||||||
|
ParticleSystem1 Copy 2
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
复制规则如下:
|
||||||
|
|
||||||
|
- 面板参数、曲线、标签、路径点和 Spine 元数据进行深度复制;
|
||||||
|
- 修改副本参数不会反向修改原对象;
|
||||||
|
- 粒子录制帧属于运行时缓存,不复制旧缓存,由副本按照相同配置重新生成;
|
||||||
|
- Spine 层级树的临时骨骼选择不复制,副本默认不选中骨骼;
|
||||||
|
- 已加载的 Spine 解析结果和纹理作为只读资源安全共享;
|
||||||
|
- Spine 资源注册表使用引用计数,删除任一副本不会提前销毁其他副本仍在使用的纹理;
|
||||||
|
- 粒子系统原有的 Spine 骨骼跟随目标、所属动画和全部效果参数保持不变。
|
||||||
|
|
||||||
|
当场景树不存在有效选中对象时,`+Copy` 自动置灰。
|
||||||
|
|
||||||
|
### 9.3 时间轴与统计显示稳定性
|
||||||
|
|
||||||
|
本阶段补充修复了以下界面问题:
|
||||||
|
|
||||||
|
- 画布左下角系统数量和粒子骨骼数量使用固定宽度与等宽数字,单位数和多位数切换时不再推动后方文字;
|
||||||
|
- 粒子骨骼数量保持左对齐,数值区域位置固定;
|
||||||
|
- 时间轴当前帧与总帧作为一个整体固定显示,不再被布局拆开;
|
||||||
|
- 非循环播放到末帧后再次播放会从头开始;
|
||||||
|
- 再次播放时,粒子活动数量和粒子骨骼数量持续按当前回放帧更新,不再停留在第一次播放结果;
|
||||||
|
- “所属动画”菜单改为向上展开,避免靠近屏幕底部时被裁切;
|
||||||
|
- 修复向上菜单因时间轴父级裁切和层级关系导致点击无效的问题。
|
||||||
|
|
||||||
|
### 9.4 画布背景图
|
||||||
|
|
||||||
|
画布顶部工具栏增加“背景图”按钮。下拉面板提供:
|
||||||
|
|
||||||
|
| 控件 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| 上传图像 / 更换图像 | 选择 `.png`、`.jpg`、`.jpeg` 或 `.webp` 图片 |
|
||||||
|
| 清除背景 | 移除当前背景并释放纹理资源 |
|
||||||
|
| X / Y | 调整背景在画布世界坐标中的位置偏移 |
|
||||||
|
| 透明度 | 在 0%~100% 范围内调整背景显示透明度 |
|
||||||
|
|
||||||
|
背景图片锚点位于图片中心,使用与其他场景对象一致的世界坐标方向,并跟随画布整体缩放和平移。
|
||||||
|
|
||||||
|
背景 Sprite 是 `world` 容器中的第一个绘制节点,因此显示优先级低于:
|
||||||
|
|
||||||
|
- Spine;
|
||||||
|
- 粒子系统;
|
||||||
|
- 碰撞体;
|
||||||
|
- 路径;
|
||||||
|
- 发射点、变换工具和其他画布辅助图形。
|
||||||
|
|
||||||
|
背景图只用于编辑器预览,不参与粒子碰撞、路径采样、时间轴长度或导出数据计算。
|
||||||
|
|
||||||
|
“清除背景”在没有图片时置灰。执行清除后:
|
||||||
|
|
||||||
|
```text
|
||||||
|
背景资源:无
|
||||||
|
X / Y:0 / 0
|
||||||
|
透明度:100%
|
||||||
|
```
|
||||||
|
|
||||||
|
同时撤销临时对象 URL 并释放 PixiJS 纹理,避免反复更换或清除图片产生资源泄漏。
|
||||||
|
|
||||||
|
项目中的测试图片为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
public/background/bg.png
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、验证结果
|
||||||
|
|
||||||
本阶段使用 `public/spine/` 中的 Spine 4.2 测试资源完成验证:
|
本阶段使用 `public/spine/` 中的 Spine 4.2 测试资源完成验证:
|
||||||
|
|
||||||
@@ -421,6 +522,15 @@ emitterFollowBoneScaleY
|
|||||||
- 所属动画新增、重命名和删除;
|
- 所属动画新增、重命名和删除;
|
||||||
- 删除最后一个动画被禁止;
|
- 删除最后一个动画被禁止;
|
||||||
- 两个粒子系统分别保存 `animation` 与 `animation2`;
|
- 两个粒子系统分别保存 `animation` 与 `animation2`;
|
||||||
|
- Spine 文件夹选择可以一次识别骨架数据、Atlas 和多纹理资源;
|
||||||
|
- `+Copy` 可以分别复制粒子、碰撞体、路径和 Spine;
|
||||||
|
- 修改粒子副本名称后,原粒子系统名称保持不变;
|
||||||
|
- Spine 副本可以安全共享已加载资源;
|
||||||
|
- 背景图下拉面板正常显示上传、清除、X/Y 和透明度控件;
|
||||||
|
- `public/background/bg.png` 可以成功加载到画布底层;
|
||||||
|
- 背景图 X/Y 偏移与透明度可以实时调整;
|
||||||
|
- 未加载背景时“清除背景”按钮正确置灰;
|
||||||
|
- 统计数字和时间轴帧数显示不再因位数变化产生布局跳动;
|
||||||
- 页面交互过程中无控制台错误;
|
- 页面交互过程中无控制台错误;
|
||||||
- `npm run build` 生产构建通过;
|
- `npm run build` 生产构建通过;
|
||||||
- `git diff --check` 通过。
|
- `git diff --check` 通过。
|
||||||
@@ -429,7 +539,7 @@ emitterFollowBoneScaleY
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十、当前限制
|
## 十一、当前限制
|
||||||
|
|
||||||
1. Spine 资源仍通过本地文件选择器加载,刷新后需要重新选择;
|
1. Spine 资源仍通过本地文件选择器加载,刷新后需要重新选择;
|
||||||
2. Spine 皮肤、插槽、约束和动画混合尚未开放;
|
2. Spine 皮肤、插槽、约束和动画混合尚未开放;
|
||||||
@@ -438,11 +548,15 @@ emitterFollowBoneScaleY
|
|||||||
5. 所属动画当前只保存分组关系,尚未影响时间轴轨道筛选、播放范围或导出结果;
|
5. 所属动画当前只保存分组关系,尚未影响时间轴轨道筛选、播放范围或导出结果;
|
||||||
6. 场景对象小眼睛状态尚未纳入持久化工程文件;
|
6. 场景对象小眼睛状态尚未纳入持久化工程文件;
|
||||||
7. Spine 预览动画当前一次控制一个预览对象,尚未提供多 Spine 联动选择界面;
|
7. Spine 预览动画当前一次控制一个预览对象,尚未提供多 Spine 联动选择界面;
|
||||||
8. 主包仍包含 Spine 运行时和主要编辑器模块,尚未进行动态拆包。
|
8. 主包仍包含 Spine 运行时和主要编辑器模块,尚未进行动态拆包;
|
||||||
|
9. 背景图片来自浏览器本地文件选择,刷新页面后需要重新上传;
|
||||||
|
10. 背景图当前只提供位置和透明度,不提供旋转、缩放、适配画布或锁定操作;
|
||||||
|
11. 背景图仅用于编辑器显示,尚未纳入工程保存格式和最终导出;
|
||||||
|
12. 场景对象复制当前在内存场景中完成,工程保存与跨项目粘贴仍待后续实现。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十一、阶段九建议
|
## 十二、阶段九建议
|
||||||
|
|
||||||
1. 建立正式场景工程保存格式,持久化对象显隐、所属动画和骨骼绑定;
|
1. 建立正式场景工程保存格式,持久化对象显隐、所属动画和骨骼绑定;
|
||||||
2. 实现项目打开、保存、另存为和自动恢复;
|
2. 实现项目打开、保存、另存为和自动恢复;
|
||||||
@@ -453,4 +567,7 @@ emitterFollowBoneScaleY
|
|||||||
7. 增加 Spine 皮肤、插槽和动画混合控制;
|
7. 增加 Spine 皮肤、插槽和动画混合控制;
|
||||||
8. 建立多 Spine、多粒子系统、多骨骼绑定的自动回归测试;
|
8. 建立多 Spine、多粒子系统、多骨骼绑定的自动回归测试;
|
||||||
9. 对 Spine 面板和曲线编辑器进行动态加载,降低主包体积;
|
9. 对 Spine 面板和曲线编辑器进行动态加载,降低主包体积;
|
||||||
10. 补充资源丢失、骨骼改名和动画删除后的导入迁移提示。
|
10. 补充资源丢失、骨骼改名和动画删除后的导入迁移提示;
|
||||||
|
11. 将背景图路径、位置和透明度纳入工程文件,并支持重新定位丢失资源;
|
||||||
|
12. 为背景图增加适配画布、原始尺寸、缩放和锁定操作;
|
||||||
|
13. 为场景对象复制增加撤销/重做,以及跨工程复制粘贴支持。
|
||||||
|
|||||||
Reference in New Issue
Block a user