阶段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; }
|
||||
.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; }
|
||||
.timeline-area { flex-shrink: 0; overflow: clip; }
|
||||
.timeline-area { position: relative; z-index: 10; flex-shrink: 0; overflow: visible; }
|
||||
</style>
|
||||
|
||||
@@ -1505,12 +1505,14 @@ export class ParticleEmitter extends Container {
|
||||
for (const p of this.pool) {
|
||||
const st = byName.get(p.boneName)
|
||||
if (!st) {
|
||||
p.active = false
|
||||
p.sprite.visible = false
|
||||
if (p.trailMesh) p.trailMesh.visible = false
|
||||
continue
|
||||
}
|
||||
p.active = st.active
|
||||
const s = p.sprite
|
||||
s.visible = true
|
||||
s.visible = st.active
|
||||
s.x = st.x; s.y = st.y
|
||||
s.rotation = -st.rotation * Math.PI / 180
|
||||
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="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="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-name" :title="skeletonName">{{ skeletonName || 'JSON / SKEL...' }}</div>
|
||||
<button @click="skeletonInput?.click()">选择</button>
|
||||
@@ -111,6 +114,7 @@ const store = useParticleStore()
|
||||
const skeletonInput = ref<HTMLInputElement | null>(null)
|
||||
const atlasInput = ref<HTMLInputElement | null>(null)
|
||||
const textureInput = ref<HTMLInputElement | null>(null)
|
||||
const folderInput = ref<HTMLInputElement | null>(null)
|
||||
const collapsedBones = ref(new Set<string>())
|
||||
const boneSearch = ref('')
|
||||
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 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() {
|
||||
store.recalcTotalFrames()
|
||||
}
|
||||
@@ -209,6 +241,7 @@ function clearBundle() {
|
||||
if (skeletonInput.value) skeletonInput.value.value = ''
|
||||
if (atlasInput.value) atlasInput.value.value = ''
|
||||
if (textureInput.value) textureInput.value.value = ''
|
||||
if (folderInput.value) folderInput.value.value = ''
|
||||
props.spineObject.skeletonFileName = ''
|
||||
props.spineObject.atlasFileName = ''
|
||||
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:hover { filter: brightness(1.13); }
|
||||
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 .load { background: #5543df; border-color: #6959ec; }
|
||||
.asset-actions .clear { background: #702b34; border-color: #803842; }
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface LoadedSpineAsset {
|
||||
}
|
||||
|
||||
const assets = new Map<number, LoadedSpineAsset>()
|
||||
const assetReferences = new Map<LoadedSpineAsset, number>()
|
||||
|
||||
export function getSpineAsset(id: number) {
|
||||
return assets.get(id) || null
|
||||
@@ -15,12 +16,26 @@ export function getSpineAsset(id: number) {
|
||||
export function registerSpineAsset(id: number, asset: LoadedSpineAsset) {
|
||||
releaseSpineAsset(id)
|
||||
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) {
|
||||
const previous = assets.get(id)
|
||||
if (!previous) return
|
||||
for (const texture of previous.textures) texture.destroy(true)
|
||||
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 { EmitterConfig, defaultConfig, ParticleState, type SceneCollider } from '../core/particleEmitter'
|
||||
import type { SpineSceneObject } from '../spine/spineTypes'
|
||||
import { releaseSpineAsset } from '../spine/spineAssetRegistry'
|
||||
import { releaseSpineAsset, retainSpineAsset } from '../spine/spineAssetRegistry'
|
||||
|
||||
export interface ParticleSystem {
|
||||
id: number
|
||||
@@ -88,6 +88,29 @@ let colliderSeq = 1
|
||||
let pathSeq = 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', {
|
||||
state: () => ({
|
||||
editor: { viewScale: 1.0, viewX: 0, viewY: 0 } as EditorState,
|
||||
@@ -273,6 +296,59 @@ export const useParticleStore = defineStore('particle', {
|
||||
this.activeObjectId = spine.id
|
||||
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) {
|
||||
this.activeObjectType = type
|
||||
this.activeObjectId = id
|
||||
@@ -326,9 +402,16 @@ export const useParticleStore = defineStore('particle', {
|
||||
}
|
||||
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 },
|
||||
togglePlay() { this.timeline.playing = !this.timeline.playing },
|
||||
togglePlay() {
|
||||
if (this.timeline.playing) this.pause()
|
||||
else this.play()
|
||||
},
|
||||
toStart() { this.timeline.frame = 0 },
|
||||
previousFrame() {
|
||||
this.timeline.playing = false
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<button class="scene-add" title="添加碰撞体" @click.stop="addCollider">+碰撞</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" :disabled="!canCopyObject" title="复制当前选中的场景对象" @click.stop="copySelectedObject">+Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="group-body" v-show="!collapsed.scene">
|
||||
@@ -797,6 +798,12 @@ const activePath = computed(() => store.activeObjectType === 'path'
|
||||
const activeSpine = computed(() => store.activeObjectType === 'spine'
|
||||
? store.spines.find((item) => item.id === store.activeObjectId) || 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 spine = store.spines.find((item) => item.selectedBone && item.bones.some((bone) => bone.name === item.selectedBone))
|
||||
return spine ? { spine, boneName: spine.selectedBone } : null
|
||||
@@ -972,6 +979,7 @@ function addSys() { store.addSystem() }
|
||||
function addCollider() { store.addCollider() }
|
||||
function addPath() { store.addPath() }
|
||||
function addSpine() { store.addSpine() }
|
||||
function copySelectedObject() { store.duplicateSelectedObject() }
|
||||
function addColliderTag() {
|
||||
const collider = activeCollider.value
|
||||
if (!collider) return
|
||||
@@ -1445,6 +1453,7 @@ function toggleResourceLock(resourceId: number) {
|
||||
background: #2e3a55; color: #cdf; font-size: 10px; white-space: nowrap; cursor: pointer;
|
||||
}
|
||||
.scene-add:hover { border-color: #7774ff; background: #45426f; color: #fff; }
|
||||
.scene-add:disabled { opacity: 0.42; cursor: not-allowed; border-color: #343a4b; background: #252a38; color: #778095; }
|
||||
.group-body { padding: 8px 10px 12px; }
|
||||
.caret { display: inline-block; width: 12px; color: #667; font-size: 11px; }
|
||||
.divider { height: 1px; background: #2a2a3c; margin: 8px 0; }
|
||||
|
||||
+143
-5
@@ -4,7 +4,7 @@
|
||||
|
||||
<!-- 顶栏 -->
|
||||
<div class="topbar">
|
||||
<button class="settings-btn" @click="showSettings = !showSettings" title="系统设置">系统设置</button>
|
||||
<button class="settings-btn" @click="toggleSettings" title="系统设置">系统设置</button>
|
||||
|
||||
<!-- 根变换工具:拖动(默认) / 位移 / 旋转 / 缩放 -->
|
||||
<div class="tool-group">
|
||||
@@ -23,6 +23,38 @@
|
||||
<button class="tool-btn" @click="resetView" title="重置视图">⟳</button>
|
||||
</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>
|
||||
|
||||
</div>
|
||||
@@ -62,7 +94,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { useParticleStore, type CollisionBody, type ScenePath } from '../store/particleStore'
|
||||
import { SpineRuntimeLayer } from '../spine/SpineRuntimeLayer'
|
||||
@@ -75,12 +107,21 @@ const showSkinMesh = ref(false)
|
||||
const info = ref<{ systems: number; particles: number } | null>(null)
|
||||
const showGrid = ref(true)
|
||||
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' | ''(无)
|
||||
const activeTool = ref<'' | 'translate' | 'rotate' | 'scale'>('')
|
||||
function setTool(t: '' | 'translate' | 'rotate' | 'scale') { activeTool.value = activeTool.value === t ? '' : t }
|
||||
|
||||
let app: Application | 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 boneFollowTargetGfx: Graphics | null = null
|
||||
let boneGfx: Graphics | null = null
|
||||
@@ -124,6 +165,10 @@ function createLayers() {
|
||||
app!.stage.addChild(world)
|
||||
axisLabelLayer = new Container() // 独立顶层,不随 world 缩放
|
||||
app!.stage.addChild(axisLabelLayer)
|
||||
backgroundSprite = new Sprite(Texture.EMPTY)
|
||||
backgroundSprite.anchor.set(0.5)
|
||||
backgroundSprite.visible = false
|
||||
world.addChild(backgroundSprite)
|
||||
axisGfx = new Graphics()
|
||||
world.addChild(axisGfx)
|
||||
spineRuntime = new SpineRuntimeLayer()
|
||||
@@ -148,6 +193,58 @@ function createLayers() {
|
||||
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 序列里的最合适步长,保证屏幕间距合理
|
||||
function niceStep(target: number, minStep = 0): number {
|
||||
if (target <= 0) return 1
|
||||
@@ -1139,6 +1236,10 @@ function loop() {
|
||||
if (app && world) {
|
||||
world.scale.set(store.editor.viewScale)
|
||||
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()
|
||||
syncEmitters()
|
||||
@@ -1291,9 +1392,11 @@ function loop() {
|
||||
if (!tl.playing) displayFrame = tl.frame
|
||||
for (const sys of store.systems) {
|
||||
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)
|
||||
for (const state of frame) if (state.active) {
|
||||
if (sys.visible !== false) appendStateBones(collected, state)
|
||||
@@ -1377,6 +1480,8 @@ onUnmounted(() => {
|
||||
cancelAnimationFrame(raf)
|
||||
spineRuntime?.destroy()
|
||||
spineRuntime = null
|
||||
clearBackground(false)
|
||||
backgroundSprite = null
|
||||
if (app) { app.destroy(true, { children: true }); app = null }
|
||||
})
|
||||
</script>
|
||||
@@ -1407,6 +1512,39 @@ onUnmounted(() => {
|
||||
}
|
||||
.tool-btn:hover { background: #2b3a5c; color: #fff; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
@@ -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:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.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);
|
||||
}
|
||||
.tl-animation-menu-title { padding: 2px 4px 6px; color: #77839c; font-size: 10px; }
|
||||
|
||||
+124
-7
@@ -1,6 +1,6 @@
|
||||
# 网页粒子系统 · 阶段八
|
||||
|
||||
> 阶段定位:**Spine 骨骼选择、粒子独立骨骼跟随、对象显隐与所属动画管理定版**
|
||||
> 阶段定位:**Spine 骨骼选择、粒子独立骨骼跟随、对象显隐、编辑效率与画布参考功能定版**
|
||||
> 完成日期:2026-08-29
|
||||
> 工程目录:`/Users/tianmokeji/Desktop/SpineParticle`
|
||||
> 技术栈:Vue 3 + TypeScript + Pinia + PixiJS 7 + Spine Runtime 4.2 + Vite
|
||||
@@ -29,7 +29,11 @@
|
||||
12. 将时间轴“动画”升级为粒子系统“所属动画”管理;
|
||||
13. 支持所属动画新增、重命名、删除和最少一个动画保护;
|
||||
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 测试资源完成验证:
|
||||
|
||||
@@ -421,6 +522,15 @@ emitterFollowBoneScaleY
|
||||
- 所属动画新增、重命名和删除;
|
||||
- 删除最后一个动画被禁止;
|
||||
- 两个粒子系统分别保存 `animation` 与 `animation2`;
|
||||
- Spine 文件夹选择可以一次识别骨架数据、Atlas 和多纹理资源;
|
||||
- `+Copy` 可以分别复制粒子、碰撞体、路径和 Spine;
|
||||
- 修改粒子副本名称后,原粒子系统名称保持不变;
|
||||
- Spine 副本可以安全共享已加载资源;
|
||||
- 背景图下拉面板正常显示上传、清除、X/Y 和透明度控件;
|
||||
- `public/background/bg.png` 可以成功加载到画布底层;
|
||||
- 背景图 X/Y 偏移与透明度可以实时调整;
|
||||
- 未加载背景时“清除背景”按钮正确置灰;
|
||||
- 统计数字和时间轴帧数显示不再因位数变化产生布局跳动;
|
||||
- 页面交互过程中无控制台错误;
|
||||
- `npm run build` 生产构建通过;
|
||||
- `git diff --check` 通过。
|
||||
@@ -429,7 +539,7 @@ emitterFollowBoneScaleY
|
||||
|
||||
---
|
||||
|
||||
## 十、当前限制
|
||||
## 十一、当前限制
|
||||
|
||||
1. Spine 资源仍通过本地文件选择器加载,刷新后需要重新选择;
|
||||
2. Spine 皮肤、插槽、约束和动画混合尚未开放;
|
||||
@@ -438,11 +548,15 @@ emitterFollowBoneScaleY
|
||||
5. 所属动画当前只保存分组关系,尚未影响时间轴轨道筛选、播放范围或导出结果;
|
||||
6. 场景对象小眼睛状态尚未纳入持久化工程文件;
|
||||
7. Spine 预览动画当前一次控制一个预览对象,尚未提供多 Spine 联动选择界面;
|
||||
8. 主包仍包含 Spine 运行时和主要编辑器模块,尚未进行动态拆包。
|
||||
8. 主包仍包含 Spine 运行时和主要编辑器模块,尚未进行动态拆包;
|
||||
9. 背景图片来自浏览器本地文件选择,刷新页面后需要重新上传;
|
||||
10. 背景图当前只提供位置和透明度,不提供旋转、缩放、适配画布或锁定操作;
|
||||
11. 背景图仅用于编辑器显示,尚未纳入工程保存格式和最终导出;
|
||||
12. 场景对象复制当前在内存场景中完成,工程保存与跨项目粘贴仍待后续实现。
|
||||
|
||||
---
|
||||
|
||||
## 十一、阶段九建议
|
||||
## 十二、阶段九建议
|
||||
|
||||
1. 建立正式场景工程保存格式,持久化对象显隐、所属动画和骨骼绑定;
|
||||
2. 实现项目打开、保存、另存为和自动恢复;
|
||||
@@ -453,4 +567,7 @@ emitterFollowBoneScaleY
|
||||
7. 增加 Spine 皮肤、插槽和动画混合控制;
|
||||
8. 建立多 Spine、多粒子系统、多骨骼绑定的自动回归测试;
|
||||
9. 对 Spine 面板和曲线编辑器进行动态加载,降低主包体积;
|
||||
10. 补充资源丢失、骨骼改名和动画删除后的导入迁移提示。
|
||||
10. 补充资源丢失、骨骼改名和动画删除后的导入迁移提示;
|
||||
11. 将背景图路径、位置和透明度纳入工程文件,并支持重新定位丢失资源;
|
||||
12. 为背景图增加适配画布、原始尺寸、缩放和锁定操作;
|
||||
13. 为场景对象复制增加撤销/重做,以及跨工程复制粘贴支持。
|
||||
|
||||
Reference in New Issue
Block a user