Files
SpineParticlesWeb/src/views/Stage.vue
T
2026-08-28 16:06:28 +08:00

808 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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, ensureEmitterConfig } 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 forceFieldGfx: Graphics | null = null
let attractionGfx: 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)
forceFieldGfx = new Graphics() // 选中系统的全局力场范围
world.addChild(forceFieldGfx)
attractionGfx = new Graphics() // 选中系统的全局吸附范围
world.addChild(attractionGfx)
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
// 吸附点优先于力场;重叠时拖动红色吸附圆。
if (onAttractionDown(e)) return
// 力场使用全局坐标,按住圆内任意位置时优先拖动力场中心。
if (onForceFieldDown(e)) 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)
}
function pointerToWorldMath(clientX: number, clientY: number) {
const rect = holder.value!.getBoundingClientRect()
const scale = Math.max(0.0001, store.editor.viewScale)
const screenX = clientX - rect.left
const screenY = clientY - rect.top
return {
x: (screenX - app!.screen.width / 2 - store.editor.viewX) / scale,
y: -(screenY - app!.screen.height * 0.5 - store.editor.viewY) / scale,
}
}
/** 命中当前选中系统的力场圆时开始拖拽;返回 true 表示事件已被力场接管。 */
function onForceFieldDown(e: PointerEvent) {
const sys = store.systems.find((item) => item.id === store.activeId)
const cfg = sys?.config
if (!cfg?.forceField || cfg.forceRadius <= 0) return false
const startPointer = pointerToWorldMath(e.clientX, e.clientY)
const distance = Math.hypot(startPointer.x - cfg.forceCenterX, startPointer.y - cfg.forceCenterY)
if (distance > cfg.forceRadius) return false
const startX = cfg.forceCenterX
const startY = cfg.forceCenterY
if (holder.value) holder.value.style.cursor = 'grabbing'
const move = (event: PointerEvent) => {
const pointer = pointerToWorldMath(event.clientX, event.clientY)
cfg.forceCenterX = startX + pointer.x - startPointer.x
cfg.forceCenterY = startY + pointer.y - startPointer.y
}
const up = () => {
if (holder.value) holder.value.style.cursor = ''
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
window.removeEventListener('pointercancel', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
window.addEventListener('pointercancel', up)
e.preventDefault()
return true
}
/** 命中吸附圆(半径为 0 时使用屏幕 12px 热区)时拖动全局吸附点。 */
function onAttractionDown(e: PointerEvent) {
const sys = store.systems.find((item) => item.id === store.activeId)
const cfg = sys?.config
if (!cfg?.attraction) return false
const startPointer = pointerToWorldMath(e.clientX, e.clientY)
const hitRadius = Math.max(cfg.attractionRadius, 12 / Math.max(0.0001, store.editor.viewScale))
const distance = Math.hypot(startPointer.x - cfg.attractionCenterX, startPointer.y - cfg.attractionCenterY)
if (distance > hitRadius) return false
const startX = cfg.attractionCenterX
const startY = cfg.attractionCenterY
if (holder.value) holder.value.style.cursor = 'grabbing'
const move = (event: PointerEvent) => {
const pointer = pointerToWorldMath(event.clientX, event.clientY)
cfg.attractionCenterX = startX + pointer.x - startPointer.x
cfg.attractionCenterY = startY + pointer.y - startPointer.y
}
const up = () => {
if (holder.value) holder.value.style.cursor = ''
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
window.removeEventListener('pointercancel', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
window.addEventListener('pointercancel', up)
e.preventDefault()
return true
}
// 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
const cfg = ensureEmitterConfig(sys.config as any)
if (dotTex) {
for (const resource of cfg.imageResources) {
if (!resource.texture && resource.textureName === 'star') resource.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)
}
}
}
/** 绘制当前选中系统的全局力场;不应用粒子系统 root 变换。 */
function drawForceField() {
if (!forceFieldGfx) return
const g = forceFieldGfx
g.clear()
const sys = store.systems.find((item) => item.id === store.activeId)
const cfg = sys?.config
if (!cfg?.forceField || cfg.forceRadius <= 0) return
const x = cfg.forceCenterX
const y = -cfg.forceCenterY
const color = cfg.forceStrength >= 0 ? 0x58c7ff : 0xff6b7a
g.lineStyle(2, color, 0.85)
g.beginFill(color, 0.08)
g.drawCircle(x, y, cfg.forceRadius)
g.endFill()
g.lineStyle(1, color, 0.7)
g.moveTo(x - 9, y); g.lineTo(x + 9, y)
g.moveTo(x, y - 9); g.lineTo(x, y + 9)
g.beginFill(color, 0.95)
g.drawCircle(x, y, 4)
g.endFill()
}
/** 绘制全局吸附点与吸附完成半径;半径为 0 时显示为红色点。 */
function drawAttraction() {
if (!attractionGfx) return
const g = attractionGfx
g.clear()
const sys = store.systems.find((item) => item.id === store.activeId)
const cfg = sys?.config
if (!cfg?.attraction) return
const x = cfg.attractionCenterX
const y = -cfg.attractionCenterY
const radius = Math.max(0, cfg.attractionRadius)
const color = 0xff4d5f
if (radius > 0) {
g.lineStyle(2, color, 0.95)
g.beginFill(color, 0.09)
g.drawCircle(x, y, radius)
g.endFill()
}
g.lineStyle(1, color, 0.85)
g.moveTo(x - 10, y); g.lineTo(x + 10, y)
g.moveTo(x, y - 10); g.lineTo(x, y + 10)
g.beginFill(color, 1)
g.drawCircle(x, y, radius > 0 ? 4 : 6)
g.endFill()
}
// 根变换 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()
drawForceField()
drawAttraction()
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
const cfg = ensureEmitterConfig(sys.config as any)
for (const resource of cfg.imageResources) {
if (!resource.texture && resource.textureName === 'star') resource.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' || k === 'previewUrl' ? 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>