725 lines
33 KiB
Vue
725 lines
33 KiB
Vue
<template>
|
|
<div class="stage-wrap">
|
|
<div ref="holder" class="stage-holder" tabindex="0" @wheel.prevent="onWheelEvent" @pointerdown="onPanDown" @dblclick.prevent="onStageDoubleClick"></div>
|
|
<img
|
|
class="stage-watermark"
|
|
:class="{ 'watermark-left-panel': !store.settings.parameterPanelOnRight }"
|
|
src="/images/yeomai-watermark.png"
|
|
alt=""
|
|
aria-hidden="true"
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="stage-home-btn"
|
|
:class="{ 'home-right': !store.settings.parameterPanelOnRight }"
|
|
:title="$t('welcome.backToTools')"
|
|
:aria-label="$t('welcome.backToTools')"
|
|
data-matomo-category="页面导航"
|
|
data-matomo-event="editor.open-welcome"
|
|
@click="returnToWelcome"
|
|
>
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="m3.5 10.5 8.5-7 8.5 7V21h-6v-6h-5v6h-6z" />
|
|
</svg>
|
|
</button>
|
|
|
|
<StageToolbar
|
|
:ctx="stageContext"
|
|
:active-tool="activeTool"
|
|
:hud="info"
|
|
:show-settings="showSettings"
|
|
:show-help="showHelp"
|
|
@update:active-tool="activeTool = $event"
|
|
@update:show-settings="showSettings = $event"
|
|
@update:show-help="showHelp = $event"
|
|
@clear-background="clearBackground"
|
|
@background-file="onBackgroundFileChange"
|
|
/>
|
|
|
|
<StageSettingsPanel :show="showSettings" @close="showSettings = false" />
|
|
|
|
<HelpDialog :show="showHelp" @close="showHelp = false" />
|
|
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { type BonePreview } from './stage/stageTypes'
|
|
import { drawAxis } from './stage/axisGuides'
|
|
import { drawOrigin, drawShapeRange, drawForceField, drawVortex, drawAttraction } from './stage/emitterGuides'
|
|
import { drawBoneFollowTargets, drawGizmo, drawCollisionBodies, drawPaths } from './stage/sceneGuides'
|
|
import { appendStateBones, drawBones } from './stage/boneGuides'
|
|
|
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
|
import { Application, Container, Graphics, Texture, TilingSprite, type Text } from 'pixi.js'
|
|
import { ParticleEmitter, ensureEmitterConfig, type ParticleState } from '../core/particleEmitter'
|
|
import { bakeLoopFrames } from '../core/loopFrameBaker'
|
|
import { loopPlaybackRange } from '../core/loopSegmentTiming'
|
|
import { collectSubEmitterIds } from '../core/subEmitterIds'
|
|
import { useParticleStore, type CollisionBody, type ParticleSystem } from '../store/particleStore'
|
|
import { SpineRuntimeLayer } from '../spine/SpineRuntimeLayer'
|
|
import { editorHistoryState, installEditorHistory, undoLastOperation } from '../editor/editorHistory'
|
|
import { useEditorShortcuts } from '../editor/useEditorShortcuts'
|
|
import { PROJECT_SAVE_EVENT } from '../editor/projectLibrary'
|
|
import { translate } from '../i18n'
|
|
import HelpDialog from './HelpDialog.vue'
|
|
import StageToolbar from './StageToolbar.vue'
|
|
import StageSettingsPanel from './StageSettingsPanel.vue'
|
|
import { onWheel, panView, type StageContext } from './stage/viewControls'
|
|
import { BackgroundLayer } from './stage/backgroundLayer'
|
|
import { PathEditing } from './stage/pathEditing'
|
|
import { ObjectInteractions } from './stage/objectInteractions'
|
|
import { EmitterSync, buildPreviewColliders, sampleSpawnPathForEmitter, worldPointToEmitterLocal } from './stage/emitterSync'
|
|
import { loadDefaultTextures, attachDefaultTextures } from './stage/textureLoading'
|
|
import { sampleScenePath } from '../core/scenePathSampler'
|
|
import { followCurveValue } from './stage/stageGeometry'
|
|
|
|
const store = useParticleStore()
|
|
const emit = defineEmits<{ (e: 'home'): void }>()
|
|
const holder = ref<HTMLElement | null>(null)
|
|
const info = ref<{ systems: number; particles: number } | null>(null)
|
|
const showSettings = ref(false)
|
|
const showHelp = ref(false)
|
|
// 根变换 gizmo 工具:'' | 'translate' | 'rotate' | 'scale'
|
|
const activeTool = ref<'' | 'translate' | 'rotate' | 'scale'>('')
|
|
const canvasFocused = ref(false)
|
|
|
|
if (!(store.settings.undoLimit > 0)) store.settings.undoLimit = 20
|
|
if (store.settings.parameterPanelOnRight == null) store.settings.parameterPanelOnRight = true
|
|
if (!Number.isFinite(store.settings.backgroundX)) store.settings.backgroundX = 0
|
|
if (!Number.isFinite(store.settings.backgroundY)) store.settings.backgroundY = 0
|
|
if (!Number.isFinite(store.settings.backgroundOpacity)) store.settings.backgroundOpacity = 1
|
|
if (store.settings.backgroundImageName == null) store.settings.backgroundImageName = ''
|
|
if (store.settings.backgroundImageData == null) store.settings.backgroundImageData = ''
|
|
if (store.settings.showEmitter == null) store.settings.showEmitter = true
|
|
if (store.settings.showBones == null) store.settings.showBones = false
|
|
if (!(store.settings.bonePointDrawSize > 0)) store.settings.bonePointDrawSize = 1
|
|
if (!(store.settings.boneAxisDrawSize > 0)) store.settings.boneAxisDrawSize = 2
|
|
if (store.settings.showSkinMesh == null) store.settings.showSkinMesh = false
|
|
if (store.settings.showGrid == null) store.settings.showGrid = true
|
|
|
|
let app: Application | null = null
|
|
let stageResizeObserver: ResizeObserver | null = null
|
|
let world: Container | null = null
|
|
let canvasGrid: TilingSprite | null = null
|
|
let originGfx: Graphics | null = null
|
|
let boneFollowTargetGfx: Graphics | null = null
|
|
let boneGfx: Graphics | null = null
|
|
let shapeGfx: Graphics | null = null
|
|
let forceFieldGfx: Graphics | null = null
|
|
let vortexGfx: Graphics | null = null
|
|
let attractionGfx: Graphics | null = null
|
|
let collisionGfx: Graphics | null = null
|
|
let pathGfx: Graphics | null = null
|
|
let gizmoGfx: Graphics | null = null
|
|
let axisGfx: Graphics | null = null
|
|
let axisLabelLayer: Container | null = null // 文字图层,不随 world 缩放,避免放大变糊
|
|
let spineRuntime: SpineRuntimeLayer | null = null
|
|
let axisLabels: Text[] = []
|
|
let lastT = 0
|
|
let timelineAccumulator = 0
|
|
let timelineDisplayFrame = 0
|
|
let raf = 0
|
|
let dotTex: Texture<any> | null = null
|
|
let trailTex: Texture<any> | null = null
|
|
|
|
const background = new BackgroundLayer()
|
|
const emitterSync = new EmitterSync()
|
|
const stageContext: StageContext = {
|
|
store,
|
|
holder,
|
|
app: () => app,
|
|
}
|
|
const pathEditing = new PathEditing(stageContext)
|
|
const interactions = new ObjectInteractions(stageContext, () => activeTool.value)
|
|
|
|
let previewColliders: CollisionBody[] = []
|
|
const emitterPreviewTransform = new Map<number, { x: number; y: number; rotation: number }>()
|
|
|
|
function createLayers() {
|
|
// 棋盘格必须在 WebGL 底部,粒子混合模式才能读取它。
|
|
const checker = document.createElement('canvas')
|
|
checker.width = checker.height = 28
|
|
const checkerContext = checker.getContext('2d')!
|
|
checkerContext.fillStyle = '#141a26'
|
|
checkerContext.fillRect(14, 0, 14, 14)
|
|
checkerContext.fillRect(0, 14, 14, 14)
|
|
canvasGrid = new TilingSprite(Texture.from(checker), app!.screen.width, app!.screen.height)
|
|
canvasGrid.visible = store.settings.showGrid
|
|
app!.stage.addChild(canvasGrid)
|
|
world = new Container()
|
|
app!.stage.addChild(world)
|
|
axisLabelLayer = new Container() // 独立顶层,不随 world 缩放
|
|
app!.stage.addChild(axisLabelLayer)
|
|
background.createSprite(world)
|
|
axisGfx = new Graphics()
|
|
world.addChild(axisGfx)
|
|
spineRuntime = new SpineRuntimeLayer()
|
|
world.addChild(spineRuntime.container)
|
|
boneFollowTargetGfx = new Graphics()
|
|
world.addChild(boneFollowTargetGfx)
|
|
originGfx = new Graphics()
|
|
world.addChild(originGfx)
|
|
shapeGfx = new Graphics() // 发射器形状范围(绿圆/蓝矩形/锥形)
|
|
world.addChild(shapeGfx)
|
|
forceFieldGfx = new Graphics() // 选中系统的全局力场范围
|
|
world.addChild(forceFieldGfx)
|
|
vortexGfx = new Graphics() // 选中系统的全局漩涡范围
|
|
world.addChild(vortexGfx)
|
|
attractionGfx = new Graphics() // 选中系统的全局吸附范围
|
|
world.addChild(attractionGfx)
|
|
collisionGfx = new Graphics()
|
|
world.addChild(collisionGfx)
|
|
pathGfx = new Graphics()
|
|
world.addChild(pathGfx)
|
|
gizmoGfx = new Graphics() // root 变换 gizmo 手柄
|
|
world.addChild(gizmoGfx)
|
|
boneGfx = new Graphics()
|
|
world.addChild(boneGfx)
|
|
}
|
|
|
|
function onPanDown(e: PointerEvent) {
|
|
if (e.button !== 0) return
|
|
// 激活变换工具时,始终操作场景树中当前选中的对象。
|
|
if (activeTool.value && interactions.selectedTransformTarget()) {
|
|
interactions.onGizmoDown(e)
|
|
return
|
|
}
|
|
if (pathEditing.onPointerDown(e)) return
|
|
if (interactions.onColliderPointerDown(e)) return
|
|
// 吸附点优先于力场;重叠时拖动红色吸附圆。
|
|
if (interactions.onAttractionDown(e)) return
|
|
// 力场使用全局坐标,按住圆内任意位置时优先拖动力场中心。
|
|
if (interactions.onForceFieldDown(e)) return
|
|
panView(stageContext, e.clientX, e.clientY)
|
|
}
|
|
|
|
function onStageDoubleClick(e: MouseEvent) {
|
|
pathEditing.onStageDoubleClick(e)
|
|
}
|
|
|
|
function onBeforeUnload(event: BeforeUnloadEvent) {
|
|
if (!store.settings.unsavedChangesPrompt || !editorHistoryState.dirty) return
|
|
event.preventDefault()
|
|
event.returnValue = ''
|
|
}
|
|
|
|
function returnToWelcome() {
|
|
if (store.settings.unsavedChangesPrompt && editorHistoryState.dirty
|
|
&& !window.confirm(translate('settings.unsavedLeaveConfirm'))) return
|
|
emit('home')
|
|
}
|
|
|
|
function onWheelEvent(e: WheelEvent) {
|
|
onWheel(stageContext, e)
|
|
}
|
|
|
|
function clearBackground(resetControls = true) {
|
|
BackgroundLayer.clearStoreBackground(store, resetControls)
|
|
}
|
|
|
|
async function onBackgroundFileChange(event: Event) {
|
|
await BackgroundLayer.handleFileChange(store, event)
|
|
}
|
|
|
|
useEditorShortcuts({
|
|
canvasElement: holder,
|
|
canvasFocused,
|
|
undo: undoLastOperation,
|
|
save: () => window.dispatchEvent(new Event(PROJECT_SAVE_EVENT)),
|
|
togglePlay: () => store.togglePlay(),
|
|
toStart: () => store.toStart(),
|
|
setTool: (tool) => { activeTool.value = tool },
|
|
})
|
|
|
|
function syncEmitters() {
|
|
if (!world) return
|
|
emitterSync.sync(store, world, dotTex, trailTex, spineRuntime)
|
|
}
|
|
|
|
/** 事件互相连接的粒子系统共享最长暖场帧数,保证子发射器也能从父事件产生。 */
|
|
function warmupFramesBySystem() {
|
|
const byId = new Map(store.systems.map((system) => [system.id, system]))
|
|
const subEmitterIds = collectSubEmitterIds(store.systems)
|
|
const neighbors = new Map(store.systems.map((system) => [system.id, new Set<number>()]))
|
|
for (const system of store.systems) {
|
|
if (system.config.eventEnabled === false) continue
|
|
for (const rule of system.config.events || []) {
|
|
if (!byId.has(rule.targetSystemId) || rule.targetSystemId === system.id) continue
|
|
neighbors.get(system.id)!.add(rule.targetSystemId)
|
|
neighbors.get(rule.targetSystemId)!.add(system.id)
|
|
}
|
|
}
|
|
const result = new Map<number, number>()
|
|
for (const system of store.systems) {
|
|
if (result.has(system.id)) continue
|
|
const component: number[] = []
|
|
const pending = [system.id]
|
|
while (pending.length) {
|
|
const id = pending.pop()!
|
|
if (component.includes(id)) continue
|
|
component.push(id)
|
|
pending.push(...neighbors.get(id)!)
|
|
}
|
|
const frames = Math.max(0, ...component.map((id) => {
|
|
if (subEmitterIds.has(id)) return 0
|
|
const config = byId.get(id)?.config
|
|
if (config?.mode === 'stream' && config.streamBehavior === 'loop') return 0
|
|
return Math.floor(Number(config?.warmupFrames) || 0)
|
|
}))
|
|
for (const id of component) result.set(id, frames)
|
|
}
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* 同步烘焙一个系统的整段帧缓存。
|
|
* 与逐帧录制结果一致(同样的固定步长、同样的随机序列),因此不会改变预览内容,
|
|
* 只是把"参数变动时就该生成好的烘焙结果"补齐。
|
|
*/
|
|
function bakeSystemFrames(system: ParticleSystem, emitter: ParticleEmitter, fps: number, totalFrames: number, warmupFrames: number) {
|
|
const frameCount = Math.max(2, Math.floor(totalFrames) || 2)
|
|
// 循环持续本来就是一次性烘焙,直接复用同一套逻辑。
|
|
if (system.config.mode === 'stream' && system.config.streamBehavior === 'loop') {
|
|
bakeLoopFrames(system, emitter, fps, frameCount)
|
|
return
|
|
}
|
|
emitter.reset()
|
|
const frameDuration = 1 / Math.max(1, fps)
|
|
emitter.warmup(warmupFrames, frameDuration)
|
|
const frames: ParticleState[][] = []
|
|
for (let frame = 0; frame < frameCount; frame++) {
|
|
// 第 0 帧使用暖场后的状态;未设置暖场时仍保持从空白开始。
|
|
if (frame === 0) frames.push(warmupFrames > 0 ? emitter.capture() : [])
|
|
else frames.push(emitter.update(frameDuration))
|
|
}
|
|
system.frames = frames
|
|
}
|
|
|
|
/**
|
|
* 帧缓存缺失时补齐(例如参数刚改完就暂停/拖动时间轴)。
|
|
* 缓存本来就完整时不做任何事,保证拖动时间轴不会重新生成预览。
|
|
*/
|
|
function ensureFramesBaked(fps: number) {
|
|
const totalFrames = Math.max(2, Math.floor(store.timeline.totalFrames) || 2)
|
|
const subEmitterIds = collectSubEmitterIds(store.systems)
|
|
const warmupFrames = warmupFramesBySystem()
|
|
// 烘焙预热会大量出生/死亡粒子,期间暂停跨系统事件,避免注入预热粒子。
|
|
emitterSync.suspendEvents(true)
|
|
try {
|
|
for (const system of store.systems) {
|
|
// 子发射器由父粒子事件驱动,不能脱离父系统单独烘焙。
|
|
if (subEmitterIds.has(system.id)) continue
|
|
if ((system.frames?.length || 0) >= totalFrames) continue
|
|
const emitter = emitterSync.emitters.get(system.id)
|
|
if (!emitter) continue
|
|
bakeSystemFrames(system as unknown as ParticleSystem, emitter, fps, totalFrames, warmupFrames.get(system.id) || 0)
|
|
}
|
|
} finally {
|
|
emitterSync.suspendEvents(false)
|
|
}
|
|
// 子发射器必须通过父事件逐帧录制;只补齐根系统时不能把整段烘焙标记为完成。
|
|
if (store.systems.every((system) => (system.frames?.length || 0) >= totalFrames)) {
|
|
store.timeline.recorded = true
|
|
}
|
|
}
|
|
|
|
function loop() {
|
|
raf = requestAnimationFrame(loop)
|
|
const now = performance.now() / 1000
|
|
let dt = now - lastT
|
|
lastT = now
|
|
if (dt > 0.1) dt = 0.1
|
|
|
|
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)
|
|
background.applyTransform(store.settings.backgroundX, store.settings.backgroundY, store.settings.backgroundOpacity)
|
|
|
|
drawAxis(store, app, axisGfx, axisLabelLayer, axisLabels)
|
|
// 根节点(root)变换:位置(centerX/centerY)=位移,rotation/scale 绕 root 中心,与场景对象参数联动
|
|
const followFps = Math.max(1, store.timeline.fps)
|
|
const followFrame = store.timeline.frame
|
|
const followTime = followFrame / followFps
|
|
// 发射器读取骨骼变换、插槽 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)
|
|
if (!em) continue
|
|
em.setFollowExportCurveMode(store.settings.exportSettings.keyframeCurve === 'bezier' ? '贝塞尔' : '线性')
|
|
const visible = sys.visible !== false
|
|
em.visible = visible
|
|
em.renderable = visible
|
|
const c = sys.config
|
|
em.scale.set(c.rootScaleX, c.rootScaleY)
|
|
const collisionIds = new Set(c.collisionColliderIds)
|
|
em.setColliders(c.collision
|
|
? previewColliders.filter((collider) => collisionIds.has(collider.id))
|
|
: [])
|
|
em.setEmitterFollowSpawn(0, 0, 0)
|
|
let preview = { x: c.centerX, y: c.centerY, rotation: c.rootRotation }
|
|
const followSpine = c.emitterFollow && c.emitterFollowMode === 'bone'
|
|
? store.spines.find((spine) => spine.id === c.emitterFollowSpineId && spine.enabled && spine.status === 'ready')
|
|
: null
|
|
const boneTransform = followSpine
|
|
? spineRuntime?.getBoneWorldTransform(followSpine.id, c.emitterFollowBoneName) || null
|
|
: null
|
|
const followPath = c.emitterFollow && c.emitterFollowMode === 'path'
|
|
? store.paths.find((path) => path.id === c.emitterFollowPathId && path.enabled)
|
|
: null
|
|
const duration = Math.max(0.05, c.emitterFollowDuration)
|
|
const normalizedTime = Math.min(1, Math.max(0, followTime / duration))
|
|
const eased = Math.min(1, Math.max(0, followCurveValue(c.emitterFollowCurve, normalizedTime)))
|
|
const offset = Math.min(1, Math.max(0, c.emitterFollowOffset))
|
|
const pathProgress = c.emitterFollowDirection === 'reverse'
|
|
? (1 - offset) * (1 - eased)
|
|
: offset + (1 - offset) * eased
|
|
const pathSample = followPath ? sampleScenePath(followPath, pathProgress) : null
|
|
if (boneTransform) {
|
|
// 骨骼只提供位置;旋转和缩放不传递给粒子,偏移使用世界坐标单位。
|
|
const offsetX = c.emitterFollowBoneOffsetX
|
|
const offsetY = c.emitterFollowBoneOffsetY
|
|
const targetX = boneTransform.x + offsetX
|
|
const targetY = boneTransform.y + offsetY
|
|
const targetRotation = c.rootRotation + c.emitterFollowBoneRotation
|
|
const targetScaleX = Math.max(0.0001, Math.abs(c.rootScaleX * c.emitterFollowBoneScaleX))
|
|
const targetScaleY = Math.max(0.0001, Math.abs(c.rootScaleY * c.emitterFollowBoneScaleY))
|
|
preview = { x: targetX, y: targetY, rotation: targetRotation }
|
|
if (c.emitterFollowSpace === 'local') {
|
|
// 本地空间:整个发射器容器随骨骼移动,已出生粒子也继承后续骨骼变换。
|
|
em.position.set(targetX, -targetY)
|
|
em.rotation = -targetRotation * Math.PI / 180
|
|
em.scale.set(targetScaleX, targetScaleY)
|
|
em.setSimulationTransform(targetX, targetY, targetRotation, targetScaleX, targetScaleY)
|
|
} else {
|
|
// 世界空间:保持粒子系统根容器不动,只把新粒子的出生点和方向移动到骨骼。
|
|
em.position.set(c.centerX, -c.centerY)
|
|
em.rotation = -c.rootRotation * Math.PI / 180
|
|
em.scale.set(targetScaleX, targetScaleY)
|
|
em.setSimulationTransform(c.centerX, c.centerY, c.rootRotation, targetScaleX, targetScaleY)
|
|
const local = worldPointToEmitterLocal(c, targetX, targetY, targetScaleX, targetScaleY)
|
|
const relativeDirection = -(targetRotation - c.rootRotation)
|
|
em.setEmitterFollowSpawn(local.x, local.y, relativeDirection)
|
|
}
|
|
} else if (pathSample) {
|
|
const movementAngle = pathSample.angle + (c.emitterFollowDirection === 'reverse' ? 180 : 0)
|
|
const effectiveAngle = c.emitterAngleMode === 'direction' ? movementAngle : c.rootRotation
|
|
preview = { x: pathSample.x, y: pathSample.y, rotation: effectiveAngle }
|
|
if (c.emitterFollowSpace === 'local') {
|
|
em.position.set(pathSample.x, -pathSample.y)
|
|
em.rotation = -effectiveAngle * Math.PI / 180
|
|
em.setSimulationTransform(pathSample.x, pathSample.y, effectiveAngle)
|
|
} else {
|
|
em.position.set(c.centerX, -c.centerY)
|
|
em.rotation = -c.rootRotation * Math.PI / 180
|
|
em.setSimulationTransform(c.centerX, c.centerY, c.rootRotation)
|
|
const local = worldPointToEmitterLocal(c, pathSample.x, pathSample.y)
|
|
const relativeDirection = c.emitterAngleMode === 'direction' ? -(movementAngle - c.rootRotation) : 0
|
|
em.setEmitterFollowSpawn(local.x, local.y, relativeDirection)
|
|
}
|
|
} else {
|
|
em.position.set(c.centerX, -c.centerY) // y 数学上正 → 屏幕取负
|
|
em.rotation = -c.rootRotation * Math.PI / 180
|
|
em.setSimulationTransform(c.centerX, c.centerY, c.rootRotation)
|
|
}
|
|
const spawnPath = c.shape === 'path' && c.spawnPathId > 0
|
|
? store.paths.find((path) => path.id === c.spawnPathId && path.enabled && path.points.length > 0) || null
|
|
: null
|
|
em.setSpawnPathSampler(
|
|
spawnPath ? (progress) => sampleSpawnPathForEmitter(c, spawnPath, progress) : null,
|
|
spawnPath?.closed === true,
|
|
)
|
|
emitterPreviewTransform.set(sys.id, preview)
|
|
}
|
|
let total = 0
|
|
const collected: BonePreview[] = []
|
|
const selectedBoneSystemId = store.settings.showBones && store.activeObjectType === 'particle'
|
|
? store.activeObjectId
|
|
: 0
|
|
const collectSelectedBones = (system: { id: number; visible: boolean } | undefined, emitter: ParticleEmitter, state: ParticleState) => {
|
|
if (!system || system.id !== selectedBoneSystemId || system.visible === false) return
|
|
appendStateBones(collected, state, emitter.position.x, emitter.position.y)
|
|
}
|
|
const tl = store.timeline
|
|
const playbackRange = store.playbackRange
|
|
const fps = tl.fps
|
|
const frameDuration = 1 / Math.max(1, fps)
|
|
let displayFrame = timelineDisplayFrame
|
|
let advanceSteps = 0
|
|
if (tl.playing) {
|
|
// requestAnimationFrame 只负责刷新画面;真实时间累计满 1/fps 秒才推进一帧。
|
|
timelineAccumulator += dt
|
|
advanceSteps = Math.floor(timelineAccumulator / frameDuration)
|
|
if (advanceSteps > 0) timelineAccumulator -= advanceSteps * frameDuration
|
|
} else {
|
|
// 暂停时清空余量,恢复播放后从完整的一帧时长重新计时。
|
|
timelineAccumulator = 0
|
|
// 暂停(含拖动时间轴)时把还没烤完的帧缓存补齐:否则画布只能停在上一次的状态,
|
|
// 必须重新触发播放才能看到对应帧。缓存完整时这里不会做任何事。
|
|
ensureFramesBaked(fps)
|
|
}
|
|
|
|
if (tl.playing && advanceSteps > 0) {
|
|
// dt 已限制为最多 0.1 秒,因此30 FPS下单次最多补算3帧,不会出现无界追帧。
|
|
for (let step = 0; step < advanceSteps; step++) {
|
|
displayFrame = tl.frame
|
|
collected.length = 0
|
|
total = 0
|
|
const warmedSystems = new Set<number>()
|
|
if (!tl.recorded) {
|
|
// 录制阶段:固定步长模拟并记录当前帧。
|
|
emitterSync.suspendEvents(true)
|
|
try {
|
|
const subEmitterIds = collectSubEmitterIds(store.systems)
|
|
for (const sys of store.systems) {
|
|
const emitter = emitterSync.emitters.get(sys.id)
|
|
if (!emitter) continue
|
|
const isLoopStream = !subEmitterIds.has(sys.id)
|
|
&& sys.config.mode === 'stream'
|
|
&& sys.config.streamBehavior === 'loop'
|
|
// 循环持续是一次性烘焙:只要缓存还没有就补上。录制不一定从第 0 帧开始
|
|
// (播放头可能被播放范围钳到 A 帧),只按 tl.frame === 0 判断会漏烘焙,
|
|
// 导致整段回放都没有可用帧。
|
|
if (isLoopStream) {
|
|
if (!sys.frames?.length) bakeLoopFrames(sys as unknown as ParticleSystem, emitter, fps, tl.totalFrames)
|
|
} else if (tl.frame === 0) {
|
|
emitter.reset()
|
|
}
|
|
}
|
|
} finally {
|
|
emitterSync.suspendEvents(false)
|
|
}
|
|
if (tl.frame === 0) {
|
|
const warmupFrames = warmupFramesBySystem()
|
|
const targets = store.systems.flatMap((system) => {
|
|
const emitter = emitterSync.emitters.get(system.id)
|
|
const isLoopStream = system.config.mode === 'stream' && system.config.streamBehavior === 'loop'
|
|
const frames = warmupFrames.get(system.id) || 0
|
|
return emitter && !isLoopStream && frames > 0 ? [{ system, emitter, frames }] : []
|
|
})
|
|
const lastFrame = Math.max(0, ...targets.map(({ frames }) => frames))
|
|
for (let warmupFrame = 0; warmupFrame < lastFrame; warmupFrame++) {
|
|
for (const target of targets) {
|
|
if (warmupFrame < target.frames) target.emitter.update(frameDuration)
|
|
}
|
|
}
|
|
for (const { system } of targets) warmedSystems.add(system.id)
|
|
}
|
|
for (const [systemId, em] of emitterSync.emitters) {
|
|
const system = store.systems.find((item) => item.id === systemId)
|
|
const isLoopStream = system?.config.mode === 'stream' && system.config.streamBehavior === 'loop'
|
|
const loopFrame = isLoopStream
|
|
? system.frames?.[tl.frame]
|
|
: undefined
|
|
const forceEmptyInitialFrame = tl.frame === 0 && !isLoopStream && !warmedSystems.has(systemId)
|
|
const states = forceEmptyInitialFrame
|
|
? []
|
|
: loopFrame || (warmedSystems.has(systemId) ? em.capture() : em.update(frameDuration))
|
|
if (forceEmptyInitialFrame) em.apply([])
|
|
else if (loopFrame) em.apply(loopFrame)
|
|
if (system?.visible !== false) {
|
|
for (const state of states) if (state.active) collectSelectedBones(system, em, state)
|
|
}
|
|
total += states.length
|
|
}
|
|
for (const sys of store.systems) {
|
|
if (!sys.config.texture) continue
|
|
sys.frames = sys.frames || []
|
|
const emitter = emitterSync.emitters.get(sys.id)
|
|
if (emitter && !(sys.config.mode === 'stream' && sys.config.streamBehavior === 'loop')) sys.frames[tl.frame] = emitter.capture()
|
|
}
|
|
tl.frame++
|
|
if (tl.frame >= tl.totalFrames) {
|
|
tl.frame = playbackRange.start
|
|
tl.recorded = true
|
|
}
|
|
} else {
|
|
// 回放阶段:每经过一个固定帧时长才应用下一帧。
|
|
for (const sys of store.systems) {
|
|
const emitter = emitterSync.emitters.get(sys.id)
|
|
const frame = sys.frames?.[tl.frame]
|
|
if (emitter && frame) {
|
|
emitter.apply(frame)
|
|
for (const state of frame) if (state.active) {
|
|
collectSelectedBones(sys, emitter, state)
|
|
total++
|
|
}
|
|
}
|
|
}
|
|
tl.frame++
|
|
const activeLoopSystem = store.systems.find((system) => system.id === store.activeId)
|
|
const activeLoopRange = activeLoopSystem?.config.mode === 'stream' && activeLoopSystem.config.streamBehavior === 'loop'
|
|
? loopPlaybackRange(activeLoopSystem.config, fps)
|
|
: null
|
|
// 烘焙数据保留“末帧 = 首帧”以便导出闭环。预览精确循环段时,末帧只作为
|
|
// 数学边界,不再与下一轮首帧连续显示两次,避免衔接处停顿一帧。
|
|
const closesActiveLoop = tl.loop && tl.abLoop
|
|
&& activeLoopRange?.start === playbackRange.start
|
|
&& activeLoopRange.end === playbackRange.end
|
|
const playbackLastFrame = closesActiveLoop ? playbackRange.end - 1 : playbackRange.end
|
|
if (tl.frame > playbackLastFrame) {
|
|
if (tl.loop) tl.frame = playbackRange.start
|
|
else { tl.frame = playbackRange.end; tl.playing = false; break }
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// 两个固定帧之间或暂停时保持当前画面,不推进粒子模拟。
|
|
if (!tl.playing) displayFrame = tl.frame
|
|
for (const sys of store.systems) {
|
|
const emitter = emitterSync.emitters.get(sys.id)
|
|
// 播放中使用最近一次真正推进的显示帧;暂停/逐帧时读取播放头指定帧。
|
|
// 回放缓存存在时不再调用 update(0),避免用首次模拟结束后的旧活动状态覆盖数量。
|
|
const replayFrameIndex = tl.playing ? timelineDisplayFrame : tl.frame
|
|
const frame = sys.frames?.[replayFrameIndex]
|
|
const hasPreparedLoopFrames = sys.config.mode === 'stream' && sys.config.streamBehavior === 'loop' && !!frame
|
|
// 循环持续会在录制首帧一次性准备完整缓存。即使全局录制尚未结束,帧间也必须继续读取缓存,
|
|
// 不能调用 update(0) 回到预演结束时的内部粒子状态,否则画面会在两套状态之间高频闪烁。
|
|
if ((tl.recorded || hasPreparedLoopFrames) && emitter && frame) {
|
|
emitter.apply(frame)
|
|
for (const state of frame) if (state.active) {
|
|
collectSelectedBones(sys, emitter, state)
|
|
total++
|
|
}
|
|
} else if (emitter) {
|
|
// 尚未录制时,第 0 帧也必须保持绝对空白,不能由 update(0) 提前触发发射。
|
|
const states = tl.frame === 0 ? [] : emitter.update(0)
|
|
if (tl.frame === 0) emitter.apply([])
|
|
if (sys.visible !== false) {
|
|
for (const state of states) if (state.active) collectSelectedBones(sys, emitter, state)
|
|
}
|
|
total += emitter.activeCount
|
|
}
|
|
}
|
|
}
|
|
timelineDisplayFrame = displayFrame
|
|
spineRuntime?.sync(store.spines, displayFrame, fps)
|
|
for (const [, emitter] of emitterSync.emitters) emitter.drawTrailMeshDebug(store.settings.showSkinMesh)
|
|
drawCollisionBodies(store, previewColliders, collisionGfx)
|
|
drawPaths(store, pathEditing.selectedNodes, pathGfx)
|
|
drawBoneFollowTargets(store, spineRuntime, boneFollowTargetGfx)
|
|
drawOrigin(store, emitterPreviewTransform, originGfx)
|
|
drawShapeRange(store, emitterPreviewTransform, shapeGfx)
|
|
drawForceField(store, forceFieldGfx)
|
|
drawVortex(store, vortexGfx)
|
|
drawAttraction(store, attractionGfx)
|
|
drawGizmo(activeTool, interactions.selectedTransformTarget.bind(interactions), gizmoGfx)
|
|
drawBones(collected, store, boneGfx)
|
|
if (info.value) { info.value.systems = store.systems.length; info.value.particles = total }
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
const el = holder.value!
|
|
app = new Application({
|
|
width: el.clientWidth || 800,
|
|
height: el.clientHeight || 600,
|
|
backgroundColor: 0x0e1219,
|
|
backgroundAlpha: 1,
|
|
antialias: true,
|
|
})
|
|
el.appendChild(app.view as unknown as HTMLCanvasElement)
|
|
stageResizeObserver = new ResizeObserver(() => {
|
|
if (!app) return
|
|
const nextWidth = Math.max(1, el.clientWidth)
|
|
const previousHeight = app.screen.height
|
|
const nextHeight = Math.max(1, el.clientHeight)
|
|
// Stage 高度变化时补偿视图中心,避免底部面板折叠导致画布内容整体跳动。
|
|
if (Math.abs(nextHeight - previousHeight) > 0.5) store.editor.viewY -= (nextHeight - previousHeight) / 2
|
|
app.renderer.resize(nextWidth, nextHeight)
|
|
if (canvasGrid) {
|
|
canvasGrid.width = nextWidth
|
|
canvasGrid.height = nextHeight
|
|
}
|
|
})
|
|
stageResizeObserver.observe(el)
|
|
createLayers()
|
|
watch(() => store.settings.backgroundImageData, (value) => background.syncTexture(value), { immediate: true })
|
|
watch(() => store.settings.showGrid, (visible) => {
|
|
if (canvasGrid) canvasGrid.visible = visible
|
|
})
|
|
const textures = await loadDefaultTextures()
|
|
dotTex = textures.dotTex
|
|
trailTex = textures.trailTex
|
|
attachDefaultTextures(store, dotTex, trailTex)
|
|
info.value = { systems: store.systems.length, particles: 0 }
|
|
// 默认从头播放并循环
|
|
store.timeline.recorded = false
|
|
store.timeline.frame = 0
|
|
timelineAccumulator = 0
|
|
timelineDisplayFrame = 0
|
|
store.timeline.playing = true
|
|
store.timeline.loop = true
|
|
for (const sys of store.systems) sys.frames = []
|
|
// 依据粒子最晚消失时间自动算总帧数
|
|
store.recalcTotalFrames()
|
|
lastT = performance.now() / 1000
|
|
loop()
|
|
|
|
// 只在"时长相关属性"(delay/duration/lifeMax)真正变化时重算总帧数。
|
|
// 用签名字符串比较,避免 deep 监听 config(emit 每帧改 _elapsed 等内部字段)导致每帧重算卡顿。
|
|
watch(
|
|
() =>
|
|
store.systems
|
|
.map((s) => JSON.stringify(s.config, (k, v) => (k === 'texture' || k === 'trailTexture' || k === 'previewUrl' ? undefined : v)))
|
|
.join('|') + `|colliders:${JSON.stringify(store.colliders)}|paths:${JSON.stringify(store.paths)}`,
|
|
() => {
|
|
const frames = store.recalcTotalFrames()
|
|
store.timeline.recorded = false
|
|
store.timeline.frame = 0
|
|
for (const sys of store.systems) sys.frames = []
|
|
store.timeline.playing = true
|
|
store.timeline.loop = true
|
|
},
|
|
)
|
|
installEditorHistory(store)
|
|
window.addEventListener('beforeunload', onBeforeUnload)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('beforeunload', onBeforeUnload)
|
|
cancelAnimationFrame(raf)
|
|
stageResizeObserver?.disconnect()
|
|
stageResizeObserver = null
|
|
spineRuntime?.destroy()
|
|
spineRuntime = null
|
|
background.destroy()
|
|
canvasGrid?.texture.destroy(true)
|
|
canvasGrid = null
|
|
if (world && app) emitterSync.destroy(world)
|
|
if (app) { app.destroy(true, { children: true }); app = null }
|
|
})
|
|
|
|
watch(() => [store.activeObjectType, store.activeObjectId], () => pathEditing.resetSelection())
|
|
</script>
|
|
|
|
<style scoped>
|
|
.stage-wrap { position: relative; display: flex; width: 100%; height: 100%; min-height: 0; flex-direction: column; overflow: hidden; }
|
|
.stage-holder {
|
|
width: 100%; min-height: 0; flex: 1; cursor: grab; touch-action: none;
|
|
outline: none;
|
|
}
|
|
.stage-holder:active { cursor: grabbing; }
|
|
.stage-watermark {
|
|
position: absolute; top: 14px; left: 14px; z-index: 1;
|
|
width: clamp(105px, 13.5vw, 150px); height: auto;
|
|
opacity: .8; pointer-events: none; user-select: none;
|
|
}
|
|
.stage-watermark.watermark-left-panel { left: auto; right: 14px; }
|
|
.stage-home-btn {
|
|
position: absolute; top: 14px; left: calc(24px + clamp(105px, 13.5vw, 150px)); z-index: 2;
|
|
display: grid; width: 32px; height: 32px; padding: 7px; place-items: center;
|
|
border: 1px solid rgba(66, 77, 99, .76); border-radius: 7px;
|
|
background: rgba(15, 18, 27, .8); color: #aeb8cc; cursor: pointer;
|
|
backdrop-filter: blur(6px);
|
|
}
|
|
.stage-home-btn.home-right { left: auto; right: calc(24px + clamp(105px, 13.5vw, 150px)); }
|
|
.stage-home-btn:hover { border-color: #7774ff; background: rgba(43, 58, 92, .92); color: #fff; }
|
|
.stage-home-btn:focus-visible { outline: 2px solid #8b86ff; outline-offset: 2px; }
|
|
.stage-home-btn svg { display: block; width: 100%; height: 100%; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linejoin: round; }
|
|
</style>
|