808 lines
34 KiB
Vue
808 lines
34 KiB
Vue
<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>
|