首次上传

This commit is contained in:
tianmo committed 2026-08-27 17:23:39 +08:00
1 parent af9bda066c
commit 9b156a32c9
29 files changed
+14567

No files matched your search

+664
View File
@@ -0,0 +1,664 @@
<template>
<div class="stage-wrap">
<div ref="holder" class="stage-holder" :class="{ grid: showGrid }" @wheel.prevent="onWheel" @pointerdown="onPanDown"></div>
<!-- 顶栏 -->
<div class="topbar">
<label class="brand">粒子发射编辑器</label>
<label class="chk"><input type="checkbox" v-model="showEmitter" />发射点</label>
<label class="chk"><input type="checkbox" v-model="showBones" />骨骼连线</label>
<!-- 根变换工具:拖动(默认) / 位移 / 旋转 / 缩放 -->
<div class="tool-group">
<button class="tool-btn" :class="{ on: activeTool === '' }" @click="setTool('')" title="拖动画布">✋</button>
<button class="tool-btn" :class="{ on: activeTool === 'translate' }" @click="setTool('translate')" title="位移">✥</button>
<button class="tool-btn" :class="{ on: activeTool === 'rotate' }" @click="setTool('rotate')" title="旋转">⟳</button>
<button class="tool-btn" :class="{ on: activeTool === 'scale' }" @click="setTool('scale')" title="缩放">⤢</button>
</div>
<!-- 工具按钮组(参考画布工具栏) -->
<div class="tool-group">
<button class="tool-btn" @click="zoomIn" title="放大">+</button>
<button class="tool-btn" @click="zoomOut" title="缩小">-</button>
<button class="tool-btn" @click="fitView" title="适配画布">⤢</button>
<button class="tool-btn" :class="{ on: showGrid }" @click="toggleGrid" title="切换网格">▦</button>
<button class="tool-btn" @click="resetView" title="重置视图">⟳</button>
</div>
<span class="zoom-pct">{{ Math.round(store.editor.viewScale * 100) }}%</span>
<span class="fps">t={{ time.toFixed(2) }}s</span>
<!-- 系统设置入口 -->
<button class="tool-btn settings-btn" @click="showSettings = !showSettings" title="系统设置">⚙</button>
</div>
<!-- 系统设置面板(右上角弹出) -->
<div class="settings-panel" v-if="showSettings">
<div class="sp-head">系统设置 <button class="sp-close" @click="showSettings = false">✕</button></div>
<label class="sp-chk"><input type="checkbox" v-model="store.settings.tickEnabled" />启用刻度</label>
<template v-if="store.settings.tickEnabled">
<label class="sp-row"><span>刻度文字大小</span>
<input type="range" min="8" max="40" step="1" v-model.number="store.settings.axisFontSize" />
<input type="number" min="8" max="40" v-model.number="store.settings.axisFontSize" class="sp-num" />
</label>
<label class="sp-row"><span>刻度线粗细</span>
<input type="range" min="1" max="5" step="0.5" v-model.number="store.settings.tickWidth" />
<input type="number" min="1" max="5" v-model.number="store.settings.tickWidth" class="sp-num" />
</label>
<label class="sp-row"><span>坐标轴颜色</span>
<input type="color" v-model="store.settings.axisColor" class="sp-colr" />
<span class="sp-hex">{{ store.settings.axisColor }}</span>
</label>
<label class="sp-chk"><input type="checkbox" v-model="store.settings.showAxisLabels" />显示刻度数值</label>
</template>
</div>
<div class="hud" v-if="info">
系统: <b>{{ info.systems }}</b> · 粒子(骨骼): <b>{{ info.particles }}</b> · 根骨骼: <b>root</b>
<span class="hud-tip">滚轮缩放 · 左键拖动画布</span>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { Application, Container, Graphics, Text, Texture } from 'pixi.js'
import { ParticleEmitter } from '../core/particleEmitter'
import { useParticleStore } from '../store/particleStore'
const store = useParticleStore()
const holder = ref<HTMLElement | null>(null)
const time = ref(0)
const showEmitter = ref(true)
const showBones = ref(false)
const info = ref<{ systems: number; particles: number } | null>(null)
const showGrid = ref(true)
const showSettings = ref(false)
// 根变换 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 originGfx: Graphics | null = null
let boneGfx: Graphics | null = null
let shapeGfx: Graphics | null = null
let gizmoGfx: Graphics | null = null
let axisGfx: Graphics | null = null
let axisLabelLayer: Container | null = null // 文字图层,不随 world 缩放,避免放大变糊
let axisLabels: Text[] = []
let lastT = 0
let raf = 0
let dotTex: Texture<any> | null = null
const emitterMap = new Map<number, ParticleEmitter>()
async function loadTexture() {
if (dotTex) return dotTex
dotTex = await Texture.fromURL('/particles/star.png')
return dotTex
}
function createLayers() {
world = new Container()
app!.stage.addChild(world)
axisLabelLayer = new Container() // 独立顶层,不随 world 缩放
app!.stage.addChild(axisLabelLayer)
axisGfx = new Graphics()
world.addChild(axisGfx)
originGfx = new Graphics()
world.addChild(originGfx)
shapeGfx = new Graphics() // 发射器形状范围(绿圆/蓝矩形/锥形)
world.addChild(shapeGfx)
gizmoGfx = new Graphics() // root 变换 gizmo 手柄
world.addChild(gizmoGfx)
boneGfx = new Graphics()
world.addChild(boneGfx)
}
// 自适应刻度步进(仿时间轴):返回 1/2/5 序列里的最合适步长,保证屏幕间距合理
function niceStep(target: number, minStep = 0): number {
if (target <= 0) return 1
const pow = Math.pow(10, Math.floor(Math.log10(target)))
const candidates = [1, 2, 5, 10, 20, 50, 100]
let best = 1
for (const c of candidates) {
if (c * pow >= target) { best = c * pow; break }
}
return Math.max(best, minStep)
}
// 画无限长 x/y 轴 + 自适应整数刻度 + 数值(0,0 在画布中心)
function drawAxis() {
if (!axisGfx) return
const g = axisGfx
g.clear()
const S = store.settings
if (!S.tickEnabled) { // 未启用刻度:隐藏整个坐标轴(轴+刻度+数值)
for (const t of axisLabels) t.visible = false
return
}
const cx = 0, cy = 0 // 世界原点(中心)
const halfW = app!.screen.width / store.editor.viewScale
const halfH = app!.screen.height / store.editor.viewScale
const axisColor = Number('0x' + S.axisColor.replace('#', ''))
// 轴线(贯穿到视口边缘外)
g.lineStyle(1.5, axisColor, 0.35)
g.moveTo(-halfW, cy); g.lineTo(halfW, cy) // x 轴
g.moveTo(cx, -halfH); g.lineTo(cx, halfH) // y 轴
// 刻度步进:目标 ~50px 一个刻度(世界单位 = px/viewScale)
const sc = store.editor.viewScale
const step = niceStep(50 / sc)
// 数值显示范围:只在中心 ±range 内标数值(避免远端堆积/干扰);范围随视口半宽
const rangeW = halfW
const rangeH = halfH
// 刻度线(贯穿整个视口;仅启用刻度时画)
const tickW = Math.max(1, S.tickWidth)
if (S.tickEnabled) {
for (let x = 0; x <= halfW; x += step) {
const v = Math.round(x / step) * step
if (v === 0) continue
g.lineStyle(tickW, axisColor, 0.25)
g.moveTo(v, cy - 4); g.lineTo(v, cy + 4)
}
for (let y = 0; y <= halfH; y += step) {
const v = Math.round(y / step) * step
if (v === 0) continue
g.lineStyle(tickW, axisColor, 0.25)
g.moveTo(cx - 4, y); g.lineTo(cx + 4, y)
}
}
// 数值标签(文字放 axisLabelLayer,不随 world 缩放,尺寸/清晰度恒定)
// 只在中心 ±range 内标注数值,超出不显示,避免看不清/干扰
const cxS = app!.screen.width / 2 + store.editor.viewX
const cyS = app!.screen.height * 0.5 + store.editor.viewY
const fs = S.axisFontSize // 字号由系统设置手动控制(默认 12px)
const labelColor = Number('0x' + S.axisColor.replace('#', ''))
while (axisLabels.length < 64) {
const t = new Text('', { fontFamily: 'monospace', fontSize: fs, fill: labelColor })
t.anchor.set(0.5, 0.5)
axisLabelLayer!.addChild(t)
axisLabels.push(t)
}
let li = 0
const showLbl = S.showAxisLabels && S.tickEnabled
// x 轴正/负数值(放在轴下方一点)
for (let x = 0; x <= halfW; x += step) {
const v = Math.round(x / step) * step
if (v === 0 || !showLbl || v > rangeW) continue
const lbl = axisLabels[li++]; if (!lbl) break
lbl.style.fontSize = fs
lbl.text = String(v); lbl.position.set(cxS + v * sc, cyS + 9); lbl.visible = true
const lvb = axisLabels[li++]; if (!lvb) break
lvb.style.fontSize = fs
lvb.text = String(-v); lvb.position.set(cxS - v * sc, cyS + 9); lvb.visible = true
}
// y 轴(数值放在轴右侧)
for (let y = 0; y <= halfH; y += step) {
const v = Math.round(y / step) * step
if (v === 0 || !showLbl || v > rangeH) continue
const lbl = axisLabels[li++]; if (!lbl) break
lbl.style.fontSize = fs
lbl.text = String(v); lbl.position.set(cxS + 8, cyS - y * sc); lbl.visible = true
const lvb = axisLabels[li++]; if (!lvb) break
lvb.style.fontSize = fs
lvb.text = String(-v); lvb.position.set(cxS + 8, cyS + y * sc); lvb.visible = true
}
// 隐藏多余标签
while (li < axisLabels.length) { const t = axisLabels[li++]; if (t) t.visible = false }
}
// ==== 视图控制:滚轮缩放 / 左键拖拽平移 ====
// 滚轮缩放画布(viewScale),基于鼠标所在位置缩放更平滑
function onWheel(e: WheelEvent) {
const rect = holder.value!.getBoundingClientRect()
const mx = e.clientX - rect.left
const my = e.clientY - rect.top
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12
const old = store.editor.viewScale
const nn = Math.max(0.01, old * factor)
// 保持鼠标下的世界点不动:调整 viewX/viewY
const cx = app!.screen.width / 2
const cy = app!.screen.height * 0.5
const oldVX = store.editor.viewX
const oldVY = store.editor.viewY
// 鼠标处 world 坐标(之前),需扣除当前平移偏移
const wx = (mx - cx - oldVX) / old
const wy = (my - cy - oldVY) / old
store.editor.viewX = mx - cx - wx * nn
store.editor.viewY = my - cy - wy * nn
store.editor.viewScale = nn
}
// 鼠标左键长按拖动画布(平移)
function onPanDown(e: PointerEvent) {
if (e.button !== 0) return
// gizmo 工具激活且选中系统 → 拖拽根节点变换;否则画布平移
if (activeTool.value && store.systems.length) {
onGizmoDown(e)
return
}
const startX = e.clientX
const startY = e.clientY
const vx0 = store.editor.viewX
const vy0 = store.editor.viewY
const move = (ev: PointerEvent) => {
store.editor.viewX = vx0 + (ev.clientX - startX)
store.editor.viewY = vy0 + (ev.clientY - startY)
}
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); window.removeEventListener('pointercancel', up) }
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
window.addEventListener('pointercancel', up)
}
// root gizmo 拖拽:位移 / 旋转 / 缩放(改当前选中系统的 config,由 loop 实时应用)
function onGizmoDown(e: PointerEvent) {
const sys = store.systems.find((s) => s.id === store.activeId)
if (!sys) return
const cfg = sys.config
const sc = store.editor.viewScale
// root 屏幕位置(中心)
const cx = app!.screen.width / 2 + store.editor.viewX + cfg.centerX * sc
const cy = app!.screen.height * 0.5 + store.editor.viewY + (-cfg.centerY) * sc
const startX = e.clientX, startY = e.clientY
const tool = activeTool.value
// 初始基准(按下时快照,后续基于快照 + 相对按下点的总增量,避免累积误差/抖动)
const sCx = cfg.centerX, sCy = cfg.centerY
const sRot = cfg.rootRotation
const sSx = cfg.rootScaleX, sSy = cfg.rootScaleY
const startDist = Math.hypot(startX - cx, startY - cy) || 1
const startAngle = Math.atan2(startY - cy, startX - cx)
const move = (ev: PointerEvent) => {
const dx = (ev.clientX - startX) / sc
const dy = (ev.clientY - startY) / sc
if (tool === 'translate') {
// 位移:按下基准 + 总位移(离手势,不累积)
cfg.centerX = sCx + dx
cfg.centerY = sCy - dy // y 数学上正
} else if (tool === 'rotate') {
const a = Math.atan2(ev.clientY - cy, ev.clientX - cx)
// 跟手:拖拽环动方向 = 旋转方向(逆时针环动→逆时针,顺时针环动→顺时针)。
// 渲染层 em.rotation=-rootRotation(屏幕坐标),故这里对屏幕 atan2 增量取反,使 rootRotation 在数学坐标下与拖拽环动同向。
cfg.rootRotation = sRot - (a - startAngle) * 180 / Math.PI
} else if (tool === 'scale') {
const nd = Math.hypot(ev.clientX - cx, ev.clientY - cy)
const f = nd / startDist
cfg.rootScaleX = Math.max(0.05, sSx * f)
cfg.rootScaleY = Math.max(0.05, sSy * f)
}
}
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); window.removeEventListener('pointercancel', up) }
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
window.addEventListener('pointercancel', up)
}
function zoomIn() { viewScaleBy(1.2) }
function zoomOut() { viewScaleBy(1 / 1.2) }
function viewScaleBy(factor: number) {
const nn = Math.max(0.01, store.editor.viewScale * factor)
store.editor.viewScale = nn
}
function fitView() { store.editor.viewScale = 1.0; store.editor.viewX = 0; store.editor.viewY = 0 }
function resetView() { store.editor.viewScale = 1.0; store.editor.viewX = 0; store.editor.viewY = 0 }
function toggleGrid() { showGrid.value = !showGrid.value }
function syncEmitters() {
if (!world) return
for (const [id, em] of emitterMap) {
if (!store.systems.some((s) => s.id === id)) { world.removeChild(em); em.destroy({ children: true }); emitterMap.delete(id) }
}
for (const sys of store.systems) {
// 新增系统若还没贴图(dotTex 已加载后),补上默认点纹理,否则无法渲染
if (dotTex && !sys.config.texture) sys.config.texture = dotTex
if (emitterMap.has(sys.id)) continue
const em = new ParticleEmitter(sys.config as any)
// 发射原点 = 当前系统的位置X/位置Y(centerX/centerY),改配置实时生效
em.setEmitterPos(() => ({ x: sys.config.centerX, y: sys.config.centerY }))
world.addChild(em)
emitterMap.set(sys.id, em)
}
}
function drawOrigin() {
if (!originGfx) return
const g = originGfx
g.clear()
if (!showEmitter.value) return
// 每个系统对应一个发射点(坐标点);没有系统时不画
if (!store.systems.length) return
for (const sys of store.systems) {
const cx = sys.config.centerX ?? 0
const cy = -(sys.config.centerY ?? 0)
const active = sys.id === store.activeId
const col = active ? 0xffcc33 : 0x8a94b8
const opacity = active ? 0.4 : 0.2
g.lineStyle(1, col, opacity)
g.drawCircle(cx, cy, 10)
g.beginFill(col, active ? 0.8 : 0.4)
g.drawCircle(cx, cy, 3)
g.endFill()
g.lineStyle(1, col, active ? 0.3 : 0.15)
g.moveTo(cx - 16, cy); g.lineTo(cx + 16, cy)
g.moveTo(cx, cy - 16); g.lineTo(cx, cy + 16)
}
}
// 绘制当前选中系统的发射器形状范围(点/轨道不绘制),随参数实时响应
function drawShapeRange() {
if (!shapeGfx) return
const g = shapeGfx
g.clear()
if (!showEmitter.value) return
const sys = store.systems.find((s) => s.id === store.activeId)
if (!sys) return
const c = sys.config
// root 中心(发射器容器 position = centerX/-centerY),数学坐标
const cx = c.centerX ?? 0
const cy = -(c.centerY ?? 0)
const shape = c.shape
if (shape === 'point' || shape === 'orbit') return // 点不绘制,轨道保留空位
// 相对 root 中心的局部点(lx,ly,数学坐标)先加偏移(发射源 = root 中心 + offset),再应用 root 缩放+旋转,加 root 中心 → 世界坐标
const sxr = c.rootScaleX ?? 1, syr = c.rootScaleY ?? 1
const rot = -(c.rootRotation ?? 0) * Math.PI / 180 // 数学上正 → 屏幕:逆时针为正
const cs = Math.cos(rot), sn = Math.sin(rot)
const oxo = c.offsetX ?? 0, oyo = c.offsetY ?? 0
const pt = (lx: number, ly: number): [number, number] => {
// 对齐粒子:局部 y 为数学上正 → 屏幕取负;发射源偏移 offsetY(数学上正)→ 屏幕也取负
const X = lx + oxo, Y = -ly - oyo
const x = X * sxr, y = Y * syr
return [cx + x * cs - y * sn, cy + (x * sn + y * cs)]
}
if (shape === 'circle') {
g.lineStyle(2, 0x3ce86a, 0.7)
// 圆:以 root 变换后的椭圆近似(逐点)
const steps = 48
for (let i = 0; i <= steps; i++) {
const a = i / steps * Math.PI * 2
const [px, py] = pt(Math.cos(a) * c.radius, Math.sin(a) * c.radius)
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py)
}
} else if (shape === 'rect') {
const hw = c.rectW / 2, hh = c.rectH / 2
const p1 = pt(-hw, -hh), p2 = pt(hw, -hh), p3 = pt(hw, hh), p4 = pt(-hw, hh)
g.lineStyle(2, 0x4a8cff, 0.7)
g.moveTo(p1[0], p1[1]); g.lineTo(p2[0], p2[1]); g.lineTo(p3[0], p3[1]); g.lineTo(p4[0], p4[1]); g.lineTo(p1[0], p1[1])
} else if (shape === 'cone') {
const r = c.radius
const half = (c.coneAngle / 2) * Math.PI / 180
const base = c.direction * Math.PI / 180
// 锥形两条边 + 弧
const edge1 = pt(Math.cos(base - half) * r, Math.sin(base - half) * r)
const edge2 = pt(Math.cos(base + half) * r, Math.sin(base + half) * r)
g.lineStyle(2, 0x3ce86a, 0.7)
g.moveTo(cx, cy); g.lineTo(edge1[0], edge1[1])
g.moveTo(cx, cy); g.lineTo(edge2[0], edge2[1])
// 弧
g.lineStyle(1, 0x3ce86a, 0.5)
const steps = 24
for (let i = 0; i <= steps; i++) {
const a = base - half + (2 * half * i / steps)
const [px, py] = pt(Math.cos(a) * r, Math.sin(a) * r)
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py)
}
}
}
// 根变换 gizmo 手柄:当前选中系统,按 activeTool 绘制(位移/旋转/缩放)。中心 = root 位置。
// 手柄几何在 root 局部坐标系定义(原点 = root 中心,ly 数学上正),经 root 缩放+旋转+平移到 root 中心,
// 与 drawShapeRange 的变换一致 → 手柄随 root 旋转/缩放/位移实时变化。
function drawGizmo() {
if (!gizmoGfx) return
const g = gizmoGfx
g.clear()
if (!activeTool.value) return
const sys = store.systems.find((s) => s.id === store.activeId)
if (!sys) return
const c = sys.config
const cx = c.centerX ?? 0
const cy = -(c.centerY ?? 0) // 数学上正(屏幕取负)
const sxr = c.rootScaleX ?? 1, syr = c.rootScaleY ?? 1
const rot = -(c.rootRotation ?? 0) * Math.PI / 180 // 数学上正 → 屏幕:逆时针为正
const cs = Math.cos(rot), sn = Math.sin(rot)
// 局部点(数学坐标,ly 上正) → 屏幕点(应用 root 缩放+旋转+平移到 root 中心)
const pt = (lx: number, ly: number): [number, number] => {
const x = lx * sxr, y = (-ly) * syr
return [cx + x * cs - y * sn, cy + (x * sn + y * cs)]
}
const line = (ax: number, ay: number, bx: number, by: number) => {
const a = pt(ax, ay), b = pt(bx, by)
g.moveTo(a[0], a[1]); g.lineTo(b[0], b[1])
}
const R = 26
// 中心点
g.lineStyle(2, 0x35d0ff, 0.9)
g.beginFill(0x35d0ff, 0.25)
g.drawCircle(cx, cy, 6)
g.endFill()
if (activeTool.value === 'translate') {
// 十字箭头
g.lineStyle(2, 0x35d0ff, 0.9)
line(-R, 0, R, 0); line(0, -R, 0, R)
line(R - 6, -4, R, 0); line(R, 0, R - 6, 4) // 右箭头
line(-(R - 6), -4, -R, 0); line(-R, 0, -(R - 6), 4) // 左箭头
line(-4, R - 6, 0, R); line(0, R, 4, R - 6) // 上箭头
line(-4, -(R - 6), 0, -R); line(0, -R, 4, -(R - 6)) // 下箭头
} else if (activeTool.value === 'rotate') {
// 旋转外圈(经缩放/旋转后可能是椭圆)
g.lineStyle(2, 0x35d0ff, 0.8)
const steps = 48
for (let i = 0; i <= steps; i++) {
const a = i / steps * Math.PI * 2
const [px, py] = pt(Math.cos(a) * R, Math.sin(a) * R)
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py)
}
// 指向点:固定于局部顶部(0° 刻度在 12 点方向),经 pt(含 root 旋转)后随 rootRotation 旋转。
// 默认(rootRotation=0)时指向点位于顶部;旋转时它随 root 一起转,反映当前旋转角。
const [tx, ty] = pt(0, R)
g.beginFill(0x35d0ff, 0.95)
g.drawCircle(tx, ty, 4)
g.endFill()
} else if (activeTool.value === 'scale') {
// 四角方点(经缩放/旋转后为旋转矩形)
g.lineStyle(2, 0x35d0ff, 0.9)
const corners: [number, number][] = [[-R, -R], [R, -R], [R, R], [-R, R]]
const pts = corners.map(([ox, oy]) => pt(ox, oy))
g.moveTo(pts[0][0], pts[0][1]); g.lineTo(pts[1][0], pts[1][1]); g.lineTo(pts[2][0], pts[2][1]); g.lineTo(pts[3][0], pts[3][1]); g.lineTo(pts[0][0], pts[0][1])
g.beginFill(0x35d0ff, 0.9)
for (const p of pts) g.drawRect(p[0] - 3, p[1] - 3, 6, 6)
g.endFill()
}
}
// 粒子→骨骼映射:每个活动粒子 = 一根挂在 root 下的骨骼
function drawBones(bones: { boneName: string; x: number; y: number }[]) {
if (!boneGfx) return
const g = boneGfx
g.clear()
if (!showBones.value) return
const cx = 0, cy = 0
// root → 每颗粒子骨骼 的连线
for (const b of bones) {
g.lineStyle(1, 0xff8c5a, 0.35)
g.moveTo(cx, cy)
g.lineTo(b.x, b.y)
// 骨骼点
g.beginFill(0xff8c5a, 0.9)
g.drawCircle(b.x, b.y, 2.5)
g.endFill()
}
}
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)
drawAxis()
syncEmitters()
// 根节点(root)变换:位置(centerX/centerY)=位移,rotation/scale 绕 root 中心,与场景对象参数联动
for (const sys of store.systems) {
const em = emitterMap.get(sys.id)
if (!em) continue
const c = sys.config
em.position.set(c.centerX, -c.centerY) // y 数学上正 → 屏幕取负
em.rotation = -c.rootRotation * Math.PI / 180 // 数学上正(顺时针为负)
em.scale.set(c.rootScaleX, c.rootScaleY)
}
let total = 0
const collected: { boneName: string; x: number; y: number }[] = []
const tl = store.timeline
const fps = tl.fps
const dt = 1 / fps // 固定帧步长,保证帧对齐
if (tl.playing) {
if (!tl.recorded) {
// 录制阶段:从头开始,逐帧模拟(固定步长)并记录每帧粒子
if (tl.frame === 0) { for (const [, em] of emitterMap) em.reset() }
for (const [, em] of emitterMap) {
const st = em.update(dt)
for (const s of st) if (s.active) collected.push({ boneName: s.boneName, x: s.x, y: s.y })
total += em.activeCount
}
for (const sys of store.systems) {
if (!sys.config.texture) continue
sys.frames = sys.frames || []
const em = emitterMap.get(sys.id)
if (em) sys.frames[tl.frame] = em.capture()
}
tl.frame++
if (tl.frame >= tl.totalFrames) { tl.frame = 0; tl.recorded = true } // 录完立即进入回放
} else {
// 回放阶段:按 frame 显示已录制帧,到尾帧时循环或停止
for (const sys of store.systems) {
const em = emitterMap.get(sys.id)
const fr = sys.frames?.[tl.frame]
if (em && fr) { em.apply(fr); for (const s of fr) if (s.active) { collected.push({ boneName: s.boneName, x: s.x, y: s.y }); total++ } }
}
tl.frame++
if (tl.frame >= tl.totalFrames) { if (tl.loop) tl.frame = 0; else { tl.frame = tl.totalFrames - 1; tl.playing = false } }
}
} else {
// 暂停:若已录制,显示当前帧;否则实时显示
for (const sys of store.systems) {
const em = emitterMap.get(sys.id)
const fr = sys.frames?.[tl.frame]
if (em && tl.recorded && fr) {
em.apply(fr)
for (const s of fr) if (s.active) { collected.push({ boneName: s.boneName, x: s.x, y: s.y }); total++ }
} else if (em) {
const st = em.update(0) // 冻结预览
for (const s of st) if (s.active) collected.push({ boneName: s.boneName, x: s.x, y: s.y })
total += em.activeCount
}
}
}
drawOrigin()
drawShapeRange()
drawGizmo()
drawBones(collected)
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, backgroundAlpha: 0, antialias: true })
el.appendChild(app.view as unknown as HTMLCanvasElement)
createLayers()
await loadTexture()
const tex = dotTex as Texture<any>
for (const sys of store.systems) sys.config.texture = tex
info.value = { systems: store.systems.length, particles: 0 }
// 默认从头播放并循环
store.timeline.recorded = false
store.timeline.frame = 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' ? undefined : v)))
.join('|'),
() => {
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
},
)
})
onUnmounted(() => {
cancelAnimationFrame(raf)
if (app) { app.destroy(true, { children: true }); app = null }
})
</script>
<style scoped>
.stage-wrap { position: relative; width: 100%; height: 100%; overflow: hidden; }
/* 画布:默认纯色;开启网格时显示深色棋盘格 */
.stage-holder {
width: 100%; height: 100%; cursor: grab; touch-action: none;
background-color: #0e1219;
}
.stage-holder.grid {
background: conic-gradient(#141a26 90deg, transparent 90deg 180deg, #141a26 180deg 270deg, transparent 270deg);
background-size: 28px 28px;
}
.stage-holder:active { cursor: grabbing; }
.topbar {
position: absolute; top: 10px; left: 10px; right: 10px; display: flex; align-items: center; gap: 10px;
padding: 8px 12px; background: rgba(20,20,32,0.85); border-radius: 10px; border: 1px solid rgba(255,255,255,0.08);
font-size: 12px; color: #aab; z-index: 5; flex-wrap: wrap;
}
.brand { font-weight: 700; color: #cdf; }
.topbar input[type=range] { background: #1a1a28; border: 1px solid #2e2e44; color: #dde; border-radius: 5px; }
.chk { display: flex; align-items: center; gap: 3px; }
/* 工具按钮组 */
.tool-group { display: flex; gap: 6px; margin-left: 6px; padding-left: 12px; border-left: 1px solid #2a2a3c; }
.tool-btn {
width: 30px; height: 26px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 6px;
color: #aab; font-size: 14px; cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.tool-btn:hover { background: #2b3a5c; color: #fff; }
.tool-btn.on { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
.zoom-pct { font-variant-numeric: tabular-nums; color: #9aa; min-width: 44px; text-align: center; }
.fps { margin-left: auto; color: #6f7a9a; }
.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-tip { margin-left: 12px; color: #5f6a8a; font-size: 11px; }
b { color: #ffcc33; }
.settings-btn { margin-left: 6px; }
.settings-panel {
position: absolute; top: 52px; right: 12px; width: 240px; z-index: 10;
background: rgba(20,20,32,0.97); border: 1px solid #2a2a3c; border-radius: 10px; padding: 10px 12px;
font-size: 12px; color: #aab; box-shadow: 0 6px 20px rgba(0,0,0,0.4);
}
.sp-head { display: flex; justify-content: space-between; align-items: center; font-weight: 700; color: #cdf; margin-bottom: 10px; }
.sp-close { background: #2e3a55; border: none; color: #cdf; border-radius: 4px; width: 18px; height: 18px; cursor: pointer; font-size: 11px; }
.sp-row { display: flex; align-items: center; gap: 8px; margin: 8px 0; }
.sp-row > span:first-child { width: 76px; flex-shrink: 0; color: #8a93bb; }
.sp-row input[type=range] { flex: 1; background: #1a1a28; accent-color: #3a5a8c; }
.sp-num { width: 52px; background: #1a1a28; border: 1px solid #2e2e44; border-radius: 5px; color: #dde; font-size: 12px; padding: 2px 4px; }
.sp-colr { width: 26px; height: 22px; border: none; background: none; cursor: pointer; }
.sp-hex { font-size: 11px; color: #889; font-variant-numeric: tabular-nums; }
.sp-chk { display: flex; align-items: center; gap: 6px; margin: 8px 0; color: #aab; }
</style>