首次上传

This commit is contained in:
tianmo
2026-08-27 17:23:39 +08:00
parent af9bda066c
commit 9b156a32c9
29 changed files with 14567 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
import puppeteer from 'puppeteer-core'
const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const URL = 'http://localhost:5173/'
const b = await puppeteer.launch({
executablePath: CHROME, headless: 'new',
args: ['--no-sandbox','--use-gl=swiftshader','--enable-unsafe-swiftshader','--window-size=1400,900'],
})
try {
const p = await b.newPage()
await p.setViewport({ width: 1400, height: 900 })
const errs = []
p.on('pageerror', (e) => errs.push('PAGEERROR: ' + e.message))
await p.goto(URL, { waitUntil: 'networkidle2', timeout: 30000 })
await new Promise((r) => setTimeout(r, 2500))
// 1) 初期应为空态
const init = await p.evaluate(() => {
const empty = document.querySelector('.empty')
const animSelect = document.querySelector('.topbar select')
const sysNames = [...document.querySelectorAll('.sys-name')].map(e => e.textContent)
return {
emptyShown: !!empty,
emptyTitle: empty?.querySelector('.empty-title')?.textContent || null,
hasAnimSelect: !!animSelect,
systemCount: sysNames.length,
loadBtn: [...document.querySelectorAll('.topbar button')].map(e=>e.textContent).join('|'),
}
})
// 2) 点击"加载示例骨骼"
await p.evaluate(() => {
const btn = [...document.querySelectorAll('.topbar button')].find((b) => b.textContent.includes('加载示例骨骼'))
if (btn) btn.click()
})
await new Promise((r) => setTimeout(r, 3000))
const after = await p.evaluate(() => {
const animSelect = document.querySelector('.topbar select')
const opts = animSelect ? [...animSelect.querySelectorAll('option')].map(o => o.textContent) : []
return {
emptyShown: !!document.querySelector('.empty'),
animOptionCount: opts.length,
firstOpts: opts.slice(0, 6),
sysCount: [...document.querySelectorAll('.sys-name')].length,
hud: document.querySelector('.hud')?.innerText?.replace(/\s+/g,' ').trim() || null,
boneSelect: document.querySelector('.group select')?.textContent?.split('\n').map(s=>s.trim()).filter(Boolean).slice(0,8) || [],
}
})
await p.screenshot({ path: '/tmp/empty-after-load.png' })
console.log('=== 初始(应空态) ===')
console.log(JSON.stringify(init, null, 2))
console.log('=== 加载骨骼后 ===')
console.log(JSON.stringify(after, null, 2))
console.log('=== 错误 ===')
console.log(errs.join('\n') || '(无)')
} finally { await b.close() }
+30
View File
@@ -0,0 +1,30 @@
import puppeteer from 'puppeteer-core'
const CHROME='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const b=await puppeteer.launch({executablePath:CHROME,headless:'new',args:['--no-sandbox','--use-gl=swiftshader','--enable-unsafe-swiftshader','--window-size=1400,900']})
try{
const p=await b.newPage()
await p.setViewport({width:1400,height:900})
const errs=[]
p.on('pageerror',e=>errs.push('PAGEERR: '+e.message))
await p.goto('http://localhost:5173/',{waitUntil:'networkidle2',timeout:30000})
await new Promise(r=>setTimeout(r,2500))
const r=await p.evaluate(()=>({
groupTitles: [...document.querySelectorAll('.group-head')].map(e=>e.textContent.trim()),
hasDirectionModule: [...document.querySelectorAll('.group-head')].some(e=>e.textContent.includes('方向')),
shapeHasAngle: (()=>{const g=[...document.querySelectorAll('.group')].find(x=>x.querySelector('.group-head')?.textContent.includes('发射器形状'));const t=g?.querySelector('.group-body')?.textContent||'';return t.includes('角度°')&&t.includes('扩散°')})(),
emitHasBur: (()=>{const g=[...document.querySelectorAll('.group')].find(x=>x.querySelector('.group-head')?.textContent.includes('发射模式'));const t=g?.querySelector('.group-body')?.textContent||'';return t.includes('爆发')&&t.includes('持续')})(),
}))
// 点一下"发射器形状"折叠
await p.evaluate(()=>{const h=[...document.querySelectorAll('.group-head')].find(e=>e.textContent.includes('发射器形状'));h?.click()})
await new Promise(r=>setTimeout(r,300))
const collapsed=await p.evaluate(()=>{const g=[...document.querySelectorAll('.group')].find(x=>x.querySelector('.group-head')?.textContent.includes('发射器形状'));return {caret:g?.querySelector('.caret')?.textContent, visible: g? g.querySelector('.group-body')?.offsetParent ? true : false : '?'}})
await p.screenshot({path:'/tmp/panel-new.png'})
console.log('=== 分组标题 ===')
console.log(JSON.stringify(r.groupTitles,null,2))
console.log('方向独立模块?', r.hasDirectionModule)
console.log('形状组含角度/扩散?', r.shapeHasAngle)
console.log('发射模式组含持续/爆发?', r.emitHasBur)
console.log('折叠后:', JSON.stringify(collapsed))
console.log('=== 错误 ===')
console.log(errs.join('\n')||'(无)')
}finally{await b.close()}
+39
View File
@@ -0,0 +1,39 @@
import puppeteer from 'puppeteer-core'
const CHROME='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const b=await puppeteer.launch({executablePath:CHROME,headless:'new',args:['--no-sandbox','--use-gl=swiftshader','--enable-unsafe-swiftshader','--window-size=1400,900']})
try{
const p=await b.newPage()
await p.setViewport({width:1400,height:900})
const errs=[]
p.on('pageerror',e=>errs.push('PAGEERR: '+e.message))
await p.goto('http://localhost:5173/',{waitUntil:'networkidle2',timeout:30000})
await new Promise(r=>setTimeout(r,2500))
const r=await p.evaluate(()=>({
groupTitles:[...document.querySelectorAll('.group-head')].map(e=>e.textContent.trim()),
nsCount:document.querySelectorAll('.ns').length,
nsRange:document.querySelectorAll('.ns-range').length,
nsNum:document.querySelectorAll('.ns-num').length,
// 第一个数值滑块标签+当前值
firstNs:(()=>{const e=document.querySelector('.ns');return e?{label:e.querySelector('.ns-label')?.textContent,num:e.querySelector('.ns-num')?.value}:null})(),
}))
// 改一个数值框:把"速率/秒"的数字框改值
const before=await p.evaluate(()=>document.querySelector('.hud')?.innerText)
const changed=await p.evaluate(()=>{
// 找到 label=速率/秒 的 .ns-num
const ns=[...document.querySelectorAll('.ns')].find(n=>n.querySelector('.ns-label')?.textContent.includes('速率'))
const num=ns?.querySelector('.ns-num')
if(num){ const old=num.value; num.value='150'; num.dispatchEvent(new Event('input',{bubbles:true})); return {changed:true,old,new:num.value} }
return {changed:false}
})
await new Promise(r=>setTimeout(r,2500))
const after=await p.evaluate(()=>document.querySelector('.hud')?.innerText)
const rateVal=await p.evaluate(()=>{const ns=[...document.querySelectorAll('.ns')].find(n=>n.querySelector('.ns-label')?.textContent.includes('速率'));return ns?.querySelector('.ns-num')?.value})
await p.screenshot({path:'/tmp/panel-numslider.png'})
console.log('=== 分组 ==='); console.log(JSON.stringify(r.groupTitles))
console.log('NumSlider: 控件=',r.nsCount,'滑块=',r.nsRange,'数字框=',r.nsNum)
console.log('首控件=', JSON.stringify(r.firstNs))
console.log('改速率:', JSON.stringify(changed), '改后数字框值=', rateVal)
console.log('HUD before:', before?.replace(/\s+/g,' ').trim())
console.log('HUD after :', after?.replace(/\s+/g,' ').trim())
console.log('=== 错误 ==='); console.log(errs.join('\n')||'(无)')
}finally{await b.close()}
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spine Particle · 阶段1 Spike</title>
<style>
html, body { margin: 0; padding: 0; background: #111; color: #eee; font-family: system-ui, -apple-system, sans-serif; }
#app { display: flex; flex-direction: column; height: 100vh; }
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2740
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "spine-particle",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"pinia": "^4.0.3",
"pixi.js": "^7.4.3",
"vue": "^3.5.13"
},
"devDependencies": {
"@types/node": "^20.19.43",
"@vitejs/plugin-vue": "^5.2.1",
"puppeteer-core": "^25.9.0",
"typescript": "^5.7.2",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

File diff suppressed because one or more lines are too long
+66
View File
@@ -0,0 +1,66 @@
<template>
<div class="layout">
<!-- 左上:参数面板(宽度可拖) -->
<div class="left" :style="{ width: panelW + 'px' }">
<ParticlePanel />
</div>
<div class="resizer-col" @pointerdown="onPanelDrag"></div>
<!-- 右列:=画布区,=时间轴区(高度可拖) -->
<div class="right">
<div class="stage-area"><Stage /></div>
<div class="resizer-row" @pointerdown="onTimelineDrag" title="拖动调整时间轴高度"></div>
<div class="timeline-area" :style="{ height: timelineH + 'px' }">
<Timeline />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Stage from './views/Stage.vue'
import ParticlePanel from './views/ParticlePanel.vue'
import Timeline from './views/Timeline.vue'
const panelW = ref(360)
const timelineH = ref(180)
// 左面板宽度拖拽
function onPanelDrag(e: PointerEvent) {
e.preventDefault()
const move = (ev: PointerEvent) => {
const w = Math.max(240, Math.min(ev.clientX, window.innerWidth - 200))
panelW.value = w
}
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
// 时间轴高度拖拽
function onTimelineDrag(e: PointerEvent) {
e.preventDefault()
const startY = e.clientY
const startH = timelineH.value
const move = (ev: PointerEvent) => {
const h = Math.max(80, Math.min(startH + (startY - ev.clientY), window.innerHeight - 200))
timelineH.value = h
}
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
</script>
<style scoped>
.layout { display: flex; height: 100vh; overflow: hidden; }
.left { flex-shrink: 0; height: 100%; overflow: hidden; }
.resizer-col { width: 5px; cursor: col-resize; background: #1c1c2a; border-left: 1px solid #2a2a3c; border-right: 1px solid #2a2a3c; flex-shrink: 0; }
.resizer-col:hover { background: #3a5a8c; }
.right { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.stage-area { flex: 1; min-height: 0; }
.resizer-row { height: 4px; cursor: row-resize; background: #1c1c2a; border-top: 1px solid #2a2a3c; border-bottom: 1px solid #2a2a3c; flex-shrink: 0; }
.resizer-row:hover { background: #3a5a8c; }
.timeline-area { flex-shrink: 0; overflow: hidden; }
</style>
+465
View File
@@ -0,0 +1,465 @@
import { Container, Sprite, Texture, IPointData } from 'pixi.js'
/** 发射模式 */
export type EmitMode = 'stream' | 'burst'
/** 发射形状 */
export type EmitShape = 'point' | 'circle' | 'rect' | 'cone' | 'orbit'
/** 混合模式 */
export type Blend = 'normal' | 'add' | 'multiply' | 'screen'
/** 粒子配置(引用保持,面板改字段即实时生效) */
export interface EmitterConfig {
texture: Texture<any>
name: string
// 发射
mode: EmitMode
rate: number // 持续:每秒发射数
burstCount: number // 爆发:单次数量
burstLoop: boolean // 爆发是否每秒重复
/** 延迟发射(秒)= 粒子条左端位置 */
delay: number
/** 发射总时长(秒)= 粒子条宽度,超过后停止发射新粒子 */
duration: number
shape: EmitShape // 发射形状
radius: number
rectW: number
rectH: number
/** 发射器中心 X/Y */
centerX: number
centerY: number
// 根节点(root)变换:作用于整个粒子系统(旋转/缩放),与场景对象参数联动
rootRotation: number
rootScaleX: number
rootScaleY: number
// 方向
direction: number
spread: number
/** 锥形开口角度(发电器形状=锥体时,粒子在锥形扇区内均匀散开) */
coneAngle: number
// 属性
lifeMin: number
lifeMax: number
speedMin: number
speedMax: number
scaleMin: number
scaleMax: number
scaleEndRatio: number
alpha: number
alphaEnd: number
rotationSpeedMin: number
rotationSpeedMax: number
// 物理
gravity: number
/** 阻尼(速度衰减系数,0=无) */
damping: number
/** 随机种子(确定性随机,0=每次不同) */
seed: number
// 颜色
colorStart: string
colorEnd: string
colorOverLife: boolean
// 混合
blend: Blend
/** 透明度模式:fixed(固定)/ curve(曲线) */
alphaMode: 'fixed' | 'curve'
/** 曲线模式(透明度插值方式) */
curveMode: '跟随导出' | '线性' | '贝塞尔'
/** 透明度曲线关键帧:x=生命周期时刻(0~1),y=透明度(0~1,1=完全不透明) */
alphaCurve: { x: number; y: number }[]
/** 图片名称(贴图资源名,默认 dot) */
textureName: string
/** 子文件夹(图片资源路径,占位) */
textureFolder: string
/** 占比(0~1) */
alphaScale: number
/** 贴图锚点/轴心 x,y(0~1) */
anchorX: number
anchorY: number
/** 独立透明度:off / fixed / curve */
independentAlpha: 'off' | 'fixed' | 'curve'
// 发射点偏移
offsetX: number
offsetY: number
// ===== 修改器 (Modifiers) =====
/** 开启移动噪声 */
noise: boolean
noiseAmt: number
/** 开启重力(由 gravity 控制) */
useGravity: boolean
/** 开启风力 */
wind: boolean
windX: number
windY: number
/** 开启力场(吸力/斥力) */
forceField: boolean
forceStrength: number
forceRadius: number
/** 开启拖尾 */
trail: boolean
/** 开启碰撞(预留) */
collision: boolean
/** 开启路径跟随(预留) */
pathFollow: boolean
}
/** 粒子瞬间状态(供骨骼同步 / 导出) */
export interface ParticleState {
/** 对应骨骼在 root 下的命名 p_<i> */
boneName: string
x: number
y: number
rotation: number
scaleX: number
scaleY: number
alpha: number
colorHex: number
active: boolean
}
export function defaultConfig(): EmitterConfig {
return {
texture: null as unknown as Texture,
name: 'ParticleSystem1',
mode: 'stream',
rate: 20,
burstCount: 50,
burstLoop: false, // 一次性爆发(持续爆发开关已移除)
delay: 0,
duration: 0, // 0 = 无限发射/爆发,不受时长限制(发射时长 UI 已移除)
shape: 'point',
radius: 30,
rectW: 60,
rectH: 40,
centerX: 0,
centerY: 0,
rootRotation: 0,
rootScaleX: 1,
rootScaleY: 1,
direction: 0,
spread: 360,
coneAngle: 60,
lifeMin: 0.6,
lifeMax: 1.8,
speedMin: 30,
speedMax: 140,
scaleMin: 0.3,
scaleMax: 0.8,
scaleEndRatio: 0.3,
alpha: 0.9,
alphaEnd: 0,
rotationSpeedMin: -180,
rotationSpeedMax: 180,
gravity: 0,
damping: 0,
seed: 0,
colorStart: '#ffffff',
colorEnd: '#ff4d4d',
colorOverLife: false,
blend: 'add',
alphaMode: 'curve',
curveMode: '线性',
alphaCurve: [{ x: 0, y: 0 }, { x: 0.2, y: 1 }, { x: 0.8, y: 1 }, { x: 1, y: 0 }],
textureName: 'star',
textureFolder: '',
alphaScale: 1,
anchorX: 0.5,
anchorY: 0.5,
independentAlpha: 'off',
offsetX: 0,
offsetY: 0,
noise: false,
noiseAmt: 0,
useGravity: true,
wind: false,
windX: 0,
windY: 0,
forceField: false,
forceStrength: 0,
forceRadius: 100,
trail: false,
collision: false,
pathFollow: false,
}
}
interface Particle {
sprite: Sprite
active: boolean
boneName: string
life: number
maxLife: number
x: number; y: number
vx: number; vy: number
scaleStart: number; scaleEnd: number
alphaStart: number; alphaEnd: number
rotation: number; rotationSpeed: number
}
const BLEND_MAP: Record<Blend, number> = { normal: 0, add: 1, multiply: 2, screen: 3 }
const MAX_PARTICLES = 400
export class ParticleEmitter extends Container {
private pool: Particle[] = []
private cfg: EmitterConfig
private accumulator = 0
private burstTimer = 0
/** 发射器累计运行时间(秒) — 非响应式,避免污染 config */
private _elapsed = 0
private _emitterPos: (() => IPointData) | null = null // root 世界坐标(0,0)
/** 活动粒子状态快照(供骨架 / 导出) */
states: ParticleState[] = []
/** 确定性随机(种子>0 时可复现) */
private rng: () => number = Math.random
private _lastSeed = -1
constructor(config: EmitterConfig, maxParticles = MAX_PARTICLES) {
super()
this.cfg = config
this._ensurePool(maxParticles)
}
/** 仅在种子变化时重建随机源;种子不变时让序列持续推进,保证"确定性随机序列"语义 */
private reseed() {
const s = this.cfg.seed
if (s === this._lastSeed) return
this._lastSeed = s
if (s > 0) {
this.rng = mulberry32(s >>> 0)
} else {
this.rng = Math.random
}
}
private _ensurePool(n: number) {
while (this.pool.length < n) {
const sprite = new Sprite(this.cfg.texture)
sprite.blendMode = BLEND_MAP[this.cfg.blend] as any
sprite.anchor.set(0.5)
sprite.visible = false
this.addChild(sprite)
this.pool.push({
sprite, active: false, boneName: 'p_' + this.pool.length,
life: 0, maxLife: 1, x: 0, y: 0, vx: 0, vy: 0,
scaleStart: 1, scaleEnd: 1, alphaStart: 1, alphaEnd: 1, rotation: 0, rotationSpeed: 0,
})
}
}
setEmitterPos(fn: () => IPointData) { this._emitterPos = fn }
/** 每帧更新;返回活动粒子状态快照(与骨骼一一对应) */
update(dt: number): ParticleState[] {
const cfg = this.cfg
this.reseed() // 种子变化即重建随机源
// 累计发射时间
this._elapsed += dt
const inWindow = cfg.delay <= 0 || this._elapsed >= cfg.delay
const withinDuration = cfg.duration <= 0 || this._elapsed < cfg.delay + cfg.duration
// 发射(仅在窗口内且未超时长)
if (inWindow && withinDuration) {
if (cfg.mode === 'stream') {
this.accumulator += dt * cfg.rate
while (this.accumulator >= 1) { this.accumulator -= 1; this.spawnOne() }
} else {
this.burstTimer += dt
if (cfg.burstLoop) {
if (this.burstTimer >= 1) { this.burstTimer = 0; this.emitBurst() }
} else if (this.burstTimer <= dt) {
this.emitBurst()
}
}
}
const base = this._emitterPos ? this._emitterPos() : { x: this.cfg.offsetX, y: this.cfg.offsetY }
const states: ParticleState[] = []
for (let i = 0; i < this.pool.length; i++) {
const p = this.pool[i]
if (!p.active) continue
p.life -= dt
if (p.life <= 0) { this.kill(i); continue }
if (cfg.useGravity) p.vy += cfg.gravity * dt
// 风力
if (cfg.wind) { p.vx += cfg.windX * dt; p.vy += cfg.windY * dt }
// 力场(吸力/斥力,作用在发射点附近)
if (cfg.forceField && cfg.forceStrength !== 0) {
const dx = base.x - p.x, dy = base.y - p.y
const dist = Math.hypot(dx, dy) || 0.0001
if (dist < cfg.forceRadius) {
const f = cfg.forceStrength * (1 - dist / cfg.forceRadius)
p.vx += (dx / dist) * f * dt * 10
p.vy += (dy / dist) * f * dt * 10
}
}
// 阻尼:速度按衰减系数逐渐减小
if (cfg.damping > 0) {
const f = Math.max(0, 1 - cfg.damping * dt)
p.vx *= f; p.vy *= f
}
p.x += p.vx * dt
p.y += p.vy * dt
p.rotation += p.rotationSpeed * dt
const t = 1 - p.life / p.maxLife
const s = p.sprite
s.x = p.x; s.y = p.y; s.rotation = -p.rotation * Math.PI / 180
s.scale.set(p.scaleStart + (p.scaleEnd - p.scaleStart) * t)
s.alpha = cfg.alphaMode === 'curve' ? curveAt(cfg.alphaCurve, t) * cfg.alphaScale : (p.alphaStart + (p.alphaEnd - p.alphaStart) * t) * cfg.alphaScale
const rgb = cfg.colorOverLife ? blendRgb(cfg.colorStart, cfg.colorEnd, t) : hexToNumber(cfg.colorStart)
s.tint = rgb
states.push({ boneName: p.boneName, x: p.x, y: p.y, rotation: p.rotation, scaleX: s.scale.x, scaleY: s.scale.y, alpha: s.alpha, colorHex: rgb, active: true })
}
this.states = states
return states
}
private emitBurst() {
for (let i = 0; i < this.cfg.burstCount; i++) this.spawnOne()
}
private spawnOrigin(): { x: number; y: number } {
const cfg = this.cfg
// 局部原点 = 0(root 中心,centerX 由发射器容器 position 补回);粒子相对 root 中心 = 偏移X/Y
let ox = cfg.offsetX, oy = cfg.offsetY
// 采用数学坐标系(Y 向上为正):world 为屏幕坐标(向下为正),故 y 取负
oy = -oy
const s = cfg.shape
if (s === 'circle') {
const a = this.rng() * Math.PI * 2, r = this.rng() * cfg.radius
return { x: ox + Math.cos(a) * r, y: oy + Math.sin(a) * r }
} else if (s === 'rect') {
return { x: ox + (this.rng() - 0.5) * cfg.rectW, y: oy + (this.rng() - 0.5) * cfg.rectH }
} else if (s === 'cone') {
// 锥体:粒子从中心(锥形顶点)发射,方向由 spawnOne 在 [direction ± coneAngle/2] 扇形内随机
return { x: ox, y: oy }
}
return { x: ox, y: oy }
}
private spawnOne() {
const cfg = this.cfg
let idx = this.pool.findIndex((p) => !p.active)
if (idx === -1) return // 池满
const { x: ox, y: oy } = this.spawnOrigin()
const life = lerp(cfg.lifeMin, cfg.lifeMax, this.rng())
const speed = lerp(cfg.speedMin, cfg.speedMax, this.rng())
// 锥体:发射方向用锥形开口角度(coneAngle)散布,且 y 朝数学上正(与扇区绘制一致);其余形状沿用 spread + 屏幕坐标
let dirAng: number
if (cfg.shape === 'cone') {
dirAng = (cfg.direction + lerp(-cfg.coneAngle / 2, cfg.coneAngle / 2, this.rng())) * Math.PI / 180
} else {
dirAng = (cfg.direction + lerp(-cfg.spread / 2, cfg.spread / 2, this.rng())) * Math.PI / 180
}
const p = this.pool[idx]
p.active = true; p.life = life; p.maxLife = life
p.x = ox; p.y = oy
p.vx = Math.cos(dirAng) * speed
p.vy = (cfg.shape === 'cone' ? -Math.sin(dirAng) : Math.sin(dirAng)) * speed
p.scaleStart = lerp(cfg.scaleMin, cfg.scaleMax, this.rng())
p.scaleEnd = p.scaleStart * cfg.scaleEndRatio
p.alphaStart = cfg.alpha; p.alphaEnd = cfg.alphaEnd
p.rotation = this.rng() * 360; p.rotationSpeed = lerp(cfg.rotationSpeedMin, cfg.rotationSpeedMax, this.rng())
p.sprite.tint = hexToNumber(cfg.colorStart)
p.sprite.visible = true
}
private kill(i: number) {
const p = this.pool[i]
p.active = false
p.sprite.visible = false
}
clear() {
for (const p of this.pool) { p.active = false; p.sprite.visible = false }
}
get activeCount() {
let n = 0
for (const p of this.pool) if (p.active) n++
return n
}
/** 录制:返回当前活动粒子的深拷贝快照 */
capture(): ParticleState[] {
const out: ParticleState[] = []
for (const p of this.pool) {
if (!p.active) continue
out.push({
boneName: p.boneName, x: p.x, y: p.y, rotation: p.rotation,
scaleX: p.sprite.scale.x, scaleY: p.sprite.scale.y,
alpha: p.sprite.alpha, colorHex: p.sprite.tint as number, active: true,
})
}
return out
}
/** 回放:把快照还原到 sprite(非活动粒子隐藏) */
apply(states: ParticleState[]) {
const byName = new Map<string, ParticleState>()
for (const s of states) byName.set(s.boneName, s)
for (const p of this.pool) {
const st = byName.get(p.boneName)
if (!st) { p.sprite.visible = false; continue }
const s = p.sprite
s.visible = true
s.x = st.x; s.y = st.y
s.rotation = -st.rotation * Math.PI / 180
s.scale.set(st.scaleX, st.scaleY)
s.alpha = st.alpha
s.tint = st.colorHex
}
}
/** 清空所有粒子并重置发射计时 */
reset() {
for (const p of this.pool) { p.active = false; p.sprite.visible = false }
this.accumulator = 0
this.burstTimer = 0
this._elapsed = 0
// 从头重新模拟:若种子>0,重设随机源到序列头,保证同一种子每次从头录制结果一致
if (this.cfg.seed > 0) { this._lastSeed = this.cfg.seed; this.rng = mulberry32(this.cfg.seed >>> 0) }
}
}
function lerp(a: number, b: number, t: number) { return a + (b - a) * t }
/** 在透明度曲线关键帧上按 x(生命周期时刻 0~1)求 y(透明度 0~1)。保证有序、端点外取端点 */
function curveAt(pts: { x: number; y: number }[], x: number): number {
if (!pts || pts.length === 0) return 1
const p = [...pts].sort((a, b) => a.x - b.x)
if (x <= p[0].x) return p[0].y
if (x >= p[p.length - 1].x) return p[p.length - 1].y
for (let i = 0; i < p.length - 1; i++) {
if (x >= p[i].x && x <= p[i + 1].x) {
const t = (x - p[i].x) / (p[i + 1].x - p[i].x || 1)
return lerp(p[i].y, p[i + 1].y, t)
}
}
return p[p.length - 1].y
}
/** 确定性伪随机(mulberry32),种子相同则序列相同 */
function mulberry32(seed: number): () => number {
let a = seed >>> 0
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
function hexToNumber(hex: string): number {
let h = hex.replace('#', '')
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
return parseInt(h, 16)
}
function blendRgb(start: string, end: string, t: number): number {
const s = hexToRgb(start), e = hexToRgb(end)
const r = Math.round(s[0] + (e[0] - s[0]) * t)
const g = Math.round(s[1] + (e[1] - s[1]) * t)
const b = Math.round(s[2] + (e[2] - s[2]) * t)
return (r << 16) | (g << 8) | b
}
function hexToRgb(hex: string): [number, number, number] {
let h = hex.replace('#', '')
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
const n = parseInt(h, 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import './style.css'
createApp(App).use(createPinia()).mount('#app')
+114
View File
@@ -0,0 +1,114 @@
import { defineStore } from 'pinia'
import { EmitterConfig, defaultConfig, ParticleState } from '../core/particleEmitter'
export interface ParticleSystem {
id: number
config: EmitterConfig
/** 录制到的帧序列(每帧 = 该帧活动粒子快照) */
frames?: ParticleState[][]
}
export interface EditorState {
viewScale: number
viewX: number
viewY: number
}
/** 系统设置(可通过右上角设置面板调整) */
export interface SettingsState {
/** 是否启用刻度(主开关;启用=显示坐标轴+刻度线+数值,可调整字号/粗细) */
tickEnabled: boolean
/** 坐标轴刻度文字大小(px),默认 12 */
axisFontSize: number
/** 刻度线粗细(px) */
tickWidth: number
/** 是否显示坐标轴刻度数值 */
showAxisLabels: boolean
/** 坐标轴数值颜色(#rrggbb) */
axisColor: string
}
export interface TimelineState {
/** 播放模式 */
playing: boolean
/** 循环 */
loop: boolean
/** 当前帧 */
frame: number
/** 总帧数 */
totalFrames: number
/** 帧率 */
fps: number
/** 是否已录制 */
recorded: boolean
/** 动画名(暂用于显示) */
animation: string
}
let seq = 1
export const useParticleStore = defineStore('particle', {
state: () => ({
editor: { viewScale: 1.0, viewX: 0, viewY: 0 } as EditorState,
systems: [] as ParticleSystem[],
activeId: 0,
timeline: { playing: true, loop: true, frame: 0, totalFrames: 120, fps: 60, recorded: false, animation: 'animation' } as TimelineState,
settings: { tickEnabled: false, axisFontSize: 12, tickWidth: 1, showAxisLabels: true, axisColor: '#7a86ad' } as SettingsState,
}),
getters: {
activeSystemCount: (s) => s.systems.length,
},
actions: {
addSystem(initial?: Partial<{ config: Partial<EmitterConfig> }>) {
const cfg = defaultConfig()
if (initial?.config) Object.assign(cfg, initial.config)
// 自动生成不重复名字:若"ParticleSystemN"已存在,取已有数字最大值+1
const existing = new Set(this.systems.map((s) => s.config.name))
if (existing.has(cfg.name)) {
let maxN = 0
for (const s of this.systems) {
const m = /^ParticleSystem(\d+)$/.exec(s.config.name)
if (m) maxN = Math.max(maxN, parseInt(m[1], 10))
}
cfg.name = `ParticleSystem${maxN + 1}`
let i = 2
while (existing.has(cfg.name)) { cfg.name = `ParticleSystem${maxN + i}`; i++ }
}
const sys: ParticleSystem = { id: seq++, config: cfg }
this.systems.push(sys)
this.activeId = sys.id
return sys
},
removeSystem(id: number) {
const i = this.systems.findIndex((s) => s.id === id)
if (i >= 0) this.systems.splice(i, 1)
if (this.activeId === id) this.activeId = this.systems[0]?.id ?? 0
},
selectSystem(id: number) { this.activeId = id },
play() { this.timeline.playing = true },
pause() { this.timeline.playing = false },
togglePlay() { this.timeline.playing = !this.timeline.playing },
toStart() { this.timeline.frame = 0 },
toggleLoop() { this.timeline.loop = !this.timeline.loop },
setFrame(f: number) {
this.timeline.frame = Math.max(0, Math.min(f, this.timeline.totalFrames - 1))
},
/** 依据所有粒子系统的"最晚消失时间"重算总帧数。
* 单系统总时长 = delay(延迟) + duration(发射时长) + lifeMax(粒子最大生命),
* 多系统取最大;转帧 = ceil(sec * fps)。 */
recalcTotalFrames() {
const fps = this.timeline.fps || 60
let maxSec = 0
for (const sys of this.systems) {
const c = sys.config
const end = (c.delay || 0) + (c.duration || 0) + (c.lifeMax || 0)
if (end > maxSec) maxSec = end
}
const frames = Math.max(1, Math.ceil(maxSec * fps))
this.timeline.totalFrames = frames
// clamp 当前帧
if (this.timeline.frame >= frames) this.timeline.frame = frames - 1
return frames
},
},
})
+9
View File
@@ -0,0 +1,9 @@
html, body {
margin: 0;
padding: 0;
background: #0b0b12;
color: #e8e8f0;
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
}
* { box-sizing: border-box; }
#app { width: 100%; height: 100vh; }
+134
View File
@@ -0,0 +1,134 @@
<template>
<div class="ce">
<svg ref="svg" class="ce-svg" @dblclick="onDbl">
<!-- 网格线(0.25/0.5/0.75) -->
<g class="ce-grid">
<line v-for="g in [0.25,0.5,0.75]" :key="'v'+g" :x1="px(g)" y1="0" :x2="px(g)" :y2="py(0)" />
<line v-for="g in [0.25,0.5,0.75]" :key="'h'+g" x1="0" :y1="py(g)" :x2="px(1)" :y2="py(g)" />
</g>
<rect :x="px(0)" :y="py(1)" :width="px(1)-px(0)" :height="py(0)-py(1)" class="ce-frame" fill="none" />
<!-- 折线 -->
<polyline :points="polyPoints" class="ce-line" fill="none" />
<!-- 节点:可见圈 r=5 + 命中圈 r=9 -->
<g v-for="(pt,i) in pts" :key="i">
<circle :cx="px(pt.x)" :cy="py(pt.y)" r="5" class="ce-node" />
<circle :cx="px(pt.x)" :cy="py(pt.y)" r="9" class="ce-hit"
@pointerdown="onNodeDown($event,i)" @dblclick="onNodeDbl(i,$event)" />
</g>
</svg>
<div class="ce-nav"><span class="ce-tip">双击空白新建·双击节点删除·拖动节点调整</span></div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
const props = defineProps<{ modelValue: { x: number; y: number }[] }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: { x: number; y: number }[]): void }>()
const svg = ref<SVGSVGElement | null>(null)
const pad = 8 // 内边距(px),避免节点贴边
const W = ref(300)
const H = ref(110)
let ro: ResizeObserver | null = null
function measure() {
if (!svg.value) return
const r = svg.value.getBoundingClientRect()
W.value = r.width
H.value = r.height
}
// 归一化 x(0-1) → 像素
function px(x: number) { return pad + x * (W.value - pad * 2) }
// 归一化 y(0-1, y=1 在上) → 像素
function py(y: number) { return pad + (1 - y) * (H.value - pad * 2) }
function normalized(): { x: number; y: number }[] {
return [...props.modelValue].sort((a, b) => a.x - b.x)
}
function set(v: { x: number; y: number }[]) { emit('update:modelValue', [...v].sort((a, b) => a.x - b.x)) }
const pts = computed(() => normalized())
const polyPoints = computed(() => pts.value.map(p => `${px(p.x)},${py(p.y)}`).join(' '))
// 客户端坐标 → 归一化 (x:0-1 时间, y:0-1 透明度)
function toNorm(clientX: number, clientY: number) {
const r = svg.value!.getBoundingClientRect()
const nx = (clientX - r.left - pad) / (W.value - pad * 2)
const ny = (clientY - r.top - pad) / (H.value - pad * 2)
return { x: Math.min(1, Math.max(0, Math.round(nx * 100) / 100)), y: Math.min(1, Math.max(0, Math.round((1 - ny) * 100) / 100)) }
}
// 双击空白新建节点;若刚刚删除过(抑制窗口内)则忽略,避免删除后又立刻新建
let dblDeleteAt = 0
function onDbl(ev: MouseEvent) {
if ((ev.target as HTMLElement).classList.contains('ce-hit')) return
if (Date.now() - dblDeleteAt < 400) return // 删除后 400ms 内不新建
const { x, y } = toNorm(ev.clientX, ev.clientY)
const arr = normalized().filter(k => Math.abs(k.x - x) > 0.02)
arr.push({ x, y })
set(arr)
}
// 拖节点 + 双击删除检测(window 级监听,避免干扰;用时间/位移判断双击)
let lastClick = { t: 0, i: -1, x: 0, y: 0 }
function onNodeDown(ev: PointerEvent, i: number) {
ev.stopPropagation()
const idx = normalized().findIndex(k => Math.abs(k.x - pts.value[i].x) < 1e-6 && Math.abs(k.y - pts.value[i].y) < 1e-6)
if (idx < 0) return
// 双击检测:300ms 内再次点击同一节点且未大幅移动 → 删除
const now = Date.now()
if (now - lastClick.t < 300 && lastClick.i === i && Math.abs(ev.clientX - lastClick.x) < 8 && Math.abs(ev.clientY - lastClick.y) < 8) {
lastClick = { t: 0, i: -1, x: 0, y: 0 }
onNodeDbl(i)
return
}
lastClick = { t: now, i, x: ev.clientX, y: ev.clientY }
const move = (ev2: PointerEvent) => {
// 移动则取消双击候选
if (Math.abs(ev2.clientX - ev.clientX) > 8 || Math.abs(ev2.clientY - ev.clientY) > 8) lastClick = { t: 0, i: -1, x: 0, y: 0 }
const { x, y } = toNorm(ev2.clientX, ev2.clientY)
const arr = normalized()
arr[idx] = { x, y }
set(arr)
}
const up = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
// 双击节点删除
function onNodeDbl(i: number, ev?: MouseEvent) {
ev?.stopPropagation()
const arr = normalized()
if (arr.length <= 2) return
dblDeleteAt = Date.now()
set(arr.filter((_, j) => j !== i))
}
onMounted(() => {
measure()
ro = new ResizeObserver(measure)
if (svg.value) ro.observe(svg.value)
})
onUnmounted(() => { ro?.disconnect() })
</script>
<style scoped>
.ce { width: 100%; }
.ce-svg { width: 100%; height: 110px; display: block; border: 1px solid #2a2a3c; border-radius: 6px; background: #10101a; cursor: crosshair; touch-action: none; }
.ce-grid line { stroke: #1e1e30; stroke-width: 1; }
.ce-frame { stroke: #2e2e44; stroke-width: 1; }
.ce-line { stroke: #6ea0e8; stroke-width: 2; stroke-linejoin: round; }
.ce-node { fill: #ffffff; stroke: #3a55aa; stroke-width: 1.6; pointer-events: none; }
.ce-hit { fill: transparent; stroke: none; cursor: grab; }
.ce-hit:active { cursor: grabbing; }
.ce-nav { display: flex; justify-content: space-between; margin-top: 3px; }
.ce-tip { color: #566; font-size: 10px; }
</style>
+63
View File
@@ -0,0 +1,63 @@
<template>
<div class="ns">
<span class="ns-label">{{ label }}</span>
<input
type="range"
class="ns-range"
:min="min"
:max="max"
:step="step"
:value="modelValue"
@input="onRange"
/>
<input
type="number"
class="ns-num"
:step="step"
:value="numText"
@input="onNum"
@blur="onBlur"
/>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{
label: string
modelValue: number
min: number
max: number
step?: number
}>()
const emit = defineEmits<{ (e: 'update:modelValue', v: number): void }>()
const numText = ref(String(props.modelValue))
watch(() => props.modelValue, (v) => { numText.value = String(v) })
function onRange(e: Event) {
const v = parseFloat((e.target as HTMLInputElement).value)
if (!Number.isNaN(v)) emit('update:modelValue', v)
}
function onNum(e: Event) {
numText.value = (e.target as HTMLInputElement).value
const v = parseFloat(numText.value)
if (!Number.isNaN(v)) emit('update:modelValue', v)
}
// 失焦时若为空/非法,回退到 modelValue
function onBlur() {
const v = parseFloat(numText.value)
if (Number.isNaN(v)) numText.value = String(props.modelValue)
}
</script>
<style scoped>
.ns { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 12px; color: #aab; }
.ns-label { width: 60px; flex-shrink: 0; color: #8a93bb; }
.ns-range { flex: 1; accent-color: #3a7cd6; }
.ns-num { width: 56px; padding: 3px 5px; background: #1a1a28; border: 1px solid #2e2e44; border-radius: 5px; color: #dde; font-size: 12px; text-align: right; }
.ns-num::-webkit-inner-spin-button { opacity: 0.4; }
</style>
+331
View File
@@ -0,0 +1,331 @@
<template>
<div class="panel">
<div class="panel-header">
<span>粒子系统 · 编辑器</span>
<button class="mini-btn" @click="addSys">+ 系统</button>
</div>
<!-- 系统列表 = 场景对象层级 -->
<div class="sys-list">
<div
v-for="(sys, i) in store.systems"
:key="sys.id"
class="sys-item"
:class="{ active: i === activeIdx }"
@click="select(i)"
>
<span class="sys-name">{{ sys.config.name || '系统 ' + (i + 1) }}</span>
<button class="mini-btn danger" @click.stop="store.removeSystem(sys.id)"></button>
</div>
<div v-if="!store.systems.length" class="empty">尚无粒子系统</div>
</div>
<template v-if="sys">
<!-- 1. 场景对象 -->
<div class="group">
<div class="group-head" @click="toggle('scene')">
<span class="caret">{{ collapsed.scene ? '▸' : '▾' }}</span><span>场景对象</span>
</div>
<div class="group-body" v-show="!collapsed.scene">
<div class="tree">
<div class="tree-item on"><span class="tdot"></span><span class="tlabel">粒子系统</span></div>
<div class="tree-item"><span class="tdot dim"></span><span class="tlabel">轨道路径</span><span class="tag">预留</span></div>
<div class="tree-item"><span class="tdot dim"></span><span class="tlabel">碰撞体</span><span class="tag">预留</span></div>
</div>
<label class="row"><span>名称</span><input v-model="sys.config.name" class="inp" /></label>
<NumSlider label="位置X" :min="-2000" :max="2000" :step="1" v-model="sys.config.centerX" />
<NumSlider label="位置Y" :min="-2000" :max="2000" :step="1" v-model="sys.config.centerY" />
<NumSlider label="偏移X" :min="-2000" :max="2000" :step="1" v-model="sys.config.offsetX" />
<NumSlider label="偏移Y" :min="-2000" :max="2000" :step="1" v-model="sys.config.offsetY" />
<NumSlider label="旋转" :min="-360" :max="360" :step="1" v-model="sys.config.rootRotation" />
<NumSlider label="缩放X" :min="0.05" :max="10" :step="0.05" v-model="sys.config.rootScaleX" />
<NumSlider label="缩放Y" :min="0.05" :max="10" :step="0.05" v-model="sys.config.rootScaleY" />
</div>
</div>
<!-- 2. 发射模式 -->
<div class="group">
<div class="group-head" @click="toggle('emitMode')">
<span class="caret">{{ collapsed.emitMode ? '▸' : '▾' }}</span><span>发射模式</span>
</div>
<div class="group-body" v-show="!collapsed.emitMode">
<div class="segs">
<button class="seg" :class="{ on: sys.config.mode === 'stream' }" @click="sys.config.mode = 'stream'">持续</button>
<button class="seg" :class="{ on: sys.config.mode === 'burst' }" @click="sys.config.mode = 'burst'">爆发</button>
</div>
<NumSlider v-if="sys.config.mode === 'stream'" label="发射速率" :min="1" :max="300" :step="1" v-model="sys.config.rate" />
<template v-else>
<NumSlider label="单次数量" :min="1" :max="1000" :step="1" v-model="sys.config.burstCount" />
</template>
<NumSlider label="延迟时间" :min="0" :max="5" :step="0.1" v-model="sys.config.delay" />
</div>
</div>
<!-- 3. 发射器形状 -->
<div class="group">
<div class="group-head" @click="toggle('shape')">
<span class="caret">{{ collapsed.shape ? '▸' : '▾' }}</span><span>发射器形状</span>
</div>
<div class="group-body" v-show="!collapsed.shape">
<label class="row"><span>形状</span>
<select v-model="sys.config.shape" class="inp" @change="onShapeChange">
<option value="point"></option>
<option value="circle">圆形</option>
<option value="rect">矩形</option>
<option value="cone">锥体</option>
<option value="orbit">轨道</option>
</select>
</label>
<template v-if="sys.config.shape === 'circle' || sys.config.shape === 'cone'">
<NumSlider label="半径" :min="0" :max="300" :step="1" v-model="sys.config.radius" />
</template>
<template v-if="sys.config.shape === 'cone'">
<NumSlider label="锥形开口角度" :min="1" :max="360" :step="1" v-model="sys.config.coneAngle" />
</template>
<template v-else-if="sys.config.shape === 'rect'">
<NumSlider label="宽" :min="1" :max="400" :step="1" v-model="sys.config.rectW" />
<NumSlider label="高" :min="1" :max="400" :step="1" v-model="sys.config.rectH" />
</template>
<div class="divider"></div>
<NumSlider :label="sys.config.shape === 'cone' ? '锥形朝向' : '发射角度'" :min="-360" :max="360" :step="1" v-model="sys.config.direction" />
<NumSlider v-if="sys.config.shape !== 'cone'" label="扩散角度" :min="0" :max="360" :step="1" v-model="sys.config.spread" />
</div>
</div>
<!-- 4. 粒子属性 -->
<div class="group">
<div class="group-head" @click="toggle('attr')">
<span class="caret">{{ collapsed.attr ? '▸' : '▾' }}</span><span>粒子属性</span>
</div>
<div class="group-body" v-show="!collapsed.attr">
<NumSlider label="速度下限" :min="0" :max="400" :step="1" v-model="sys.config.speedMin" />
<NumSlider label="速度上限" :min="0" :max="600" :step="1" v-model="sys.config.speedMax" />
<NumSlider label="生命下限" :min="0.1" :max="5" :step="0.1" v-model="sys.config.lifeMin" />
<NumSlider label="生命上限" :min="0.1" :max="6" :step="0.1" v-model="sys.config.lifeMax" />
<NumSlider label="缩放下限" :min="0.05" :max="3" :step="0.05" v-model="sys.config.scaleMin" />
<NumSlider label="缩放上限" :min="0.05" :max="5" :step="0.05" v-model="sys.config.scaleMax" />
<NumSlider label="旋转速度" :min="-360" :max="360" :step="10" v-model="sys.config.rotationSpeedMin" />
<NumSlider label="随机种子" :min="0" :max="9999" :step="1" v-model="sys.config.seed" />
</div>
</div>
<!-- 5. 外观与资源 -->
<div class="group">
<div class="group-head" @click="toggle('look')">
<span class="caret">{{ collapsed.look ? '▸' : '▾' }}</span><span>外观与资源</span>
</div>
<div class="group-body" v-show="!collapsed.look">
<!-- 透明度 -->
<div class="look-title">透明度</div>
<div class="seg">
<button class="seg-btn" :class="{ active: sys.config.alphaMode === 'curve' }" @click="sys.config.alphaMode = 'curve'">曲线</button>
<button class="seg-btn" :class="{ active: sys.config.alphaMode === 'fixed' }" @click="sys.config.alphaMode = 'fixed'">固定</button>
</div>
<template v-if="sys.config.alphaMode === 'fixed'">
<NumSlider label="透明度" :min="0" :max="1" :step="0.01" v-model="sys.config.alpha" />
</template>
<template v-else>
<label class="row"><span>曲线模式</span>
<select v-model="sys.config.curveMode" class="inp">
<option>跟随导出</option><option>线性</option><option>贝塞尔</option>
</select>
</label>
<div class="curve-wrap">
<CurveEditor v-model="sys.config.alphaCurve" />
<div class="curve-labels"><span>0(透明)</span><span>生命周期 </span><span>1(不透明)</span></div>
</div>
</template>
<!-- 图片资源 -->
<div class="look-title">图片资源</div>
<div class="imgrow">
<div class="img-thumb"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#8a93bb" stroke-width="1.6"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.6"/><path d="M21 15l-5-5-9 9"/></svg></div>
<div class="img-fields">
<label class="row"><span>子文件夹</span><input type="text" v-model="sys.config.textureFolder" class="inp" placeholder="subfolder/" /></label>
<label class="row"><span>名称</span><input type="text" v-model="sys.config.textureName" class="inp" placeholder="particle" /></label>
</div>
<div class="img-actions">
<button class="mini-btn2" title="重置" @click="resetTexture()"></button>
<button class="mini-btn2" title="删除" @click="clearTexture()">🗑</button>
</div>
</div>
<div class="thumb-note">{{ sys.config.textureName || 'star' }}.png(默认 star.png)</div>
<!-- 颜色 -->
<div class="look-title">颜色</div>
<label class="row"><span>颜色类型</span>
<select :value="sys.config.colorOverLife ? '渐变' : '固定色'" class="inp" @change="sys.config.colorOverLife = ($event.target as any).value === '渐变'">
<option>固定色</option><option>渐变</option>
</select>
<select v-model="sys.config.blend" class="inp">
<option value="normal">正常</option><option value="add">相加</option>
<option value="multiply">相乘</option><option value="screen">滤色</option>
</select>
</label>
<template v-if="sys.config.colorOverLife">
<label class="row"><span>起始色</span><input type="color" v-model="sys.config.colorStart" class="colr" /></label>
<label class="row"><span>结束色</span><input type="color" v-model="sys.config.colorEnd" class="colr" /></label>
</template>
<template v-else>
<label class="row"><span>颜色</span><input type="color" v-model="sys.config.colorStart" class="colr" />
<span class="hex">{{ sys.config.colorStart }}</span></label>
</template>
<!-- 占比 / 缩放 / 轴心 / 独立透明度 -->
<div class="look-title">占比与缩放</div>
<NumSlider label="占比" :min="0" :max="1" :step="0.01" v-model="sys.config.alphaScale" />
<NumSlider label="缩放" :min="0.1" :max="3" :step="0.1" v-model="sys.config.scaleMax" />
<label class="row"><span>轴心 x,y</span><input type="text" class="inp tiny" v-model.number="sys.config.anchorX" /><input type="text" class="inp tiny" v-model.number="sys.config.anchorY" /></label>
<label class="row"><span>独立透明度</span>
<select v-model="sys.config.independentAlpha" class="inp">
<option value="off">关闭</option><option value="fixed">固定</option><option value="curve">曲线</option>
</select>
</label>
</div>
</div>
<!-- 6. 修改器 (Modifiers) -->
<div class="group">
<div class="group-head" @click="toggle('mods')">
<span class="caret">{{ collapsed.mods ? '▸' : '▾' }}</span><span>修改器 (Modifiers)</span>
</div>
<div class="group-body" v-show="!collapsed.mods">
<label class="row"><span>移动噪声</span><input type="checkbox" v-model="sys.config.noise" /></label>
<NumSlider v-if="sys.config.noise" label="噪声强度" :min="0" :max="200" :step="5" v-model="sys.config.noiseAmt" />
<label class="row"><span>重力</span><input type="checkbox" v-model="sys.config.useGravity" /></label>
<NumSlider v-if="sys.config.useGravity" label="重力大小" :min="-500" :max="500" :step="10" v-model="sys.config.gravity" />
<label class="row"><span>风力</span><input type="checkbox" v-model="sys.config.wind" /></label>
<template v-if="sys.config.wind">
<NumSlider label="风力X" :min="-300" :max="300" :step="1" v-model="sys.config.windX" />
<NumSlider label="风力Y" :min="-300" :max="300" :step="1" v-model="sys.config.windY" />
</template>
<label class="row"><span>力场</span><input type="checkbox" v-model="sys.config.forceField" /></label>
<template v-if="sys.config.forceField">
<NumSlider label="力场强度" :min="-300" :max="300" :step="5" v-model="sys.config.forceStrength" />
<NumSlider label="力场半径" :min="1" :max="500" :step="1" v-model="sys.config.forceRadius" />
</template>
<label class="row"><span>拖尾</span><input type="checkbox" v-model="sys.config.trail" /></label>
<label class="row"><span>碰撞</span><input type="checkbox" v-model="sys.config.collision" /><span class="tag">预留</span></label>
<label class="row"><span>路径跟随</span><input type="checkbox" v-model="sys.config.pathFollow" /><span class="tag">预留</span></label>
<NumSlider label="阻尼" :min="0" :max="2" :step="0.05" v-model="sys.config.damping" />
</div>
</div>
<!-- 7. 导出设置(预留) -->
<div class="group">
<div class="group-head" @click="toggle('export')">
<span class="caret">{{ collapsed.export ? '▸' : '▾' }}</span><span>导出设置</span><span class="tag">预留</span>
</div>
<div class="group-body" v-show="!collapsed.export">
<div class="placeholder">导出功能后续实现,已预留位置</div>
</div>
</div>
</template>
<div v-else-if="!store.systems.length" class="panel-hint">+ 系统创建粒子系统</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { useParticleStore } from '../store/particleStore'
import NumSlider from './NumSlider.vue'
import CurveEditor from './CurveEditor.vue'
const store = useParticleStore()
const activeIdx = computed(() => {
const i = store.systems.findIndex((s) => s.id === store.activeId)
return i >= 0 ? i : 0
})
const sys = computed(() => store.systems[activeIdx.value] || null)
const collapsed = reactive<Record<string, boolean>>({
scene: false, emitMode: false, shape: false, attr: false, look: false, mods: false, export: false,
})
function toggle(key: string) { collapsed[key] = !collapsed[key] }
function select(i: number) { store.selectSystem(store.systems[i]?.id ?? 0) }
function addSys() { store.addSystem() }
// 各发射器形状的默认参数:切换形状时应用(只影响形状相关字段,不牵连其他)
const SHAPE_DEFAULTS: Record<string, Partial<any>> = {
point: { direction: 0, spread: 360 },
circle: { radius: 100, direction: -90, spread: 0 },
rect: { rectW: 200, rectH: 100, direction: -90, spread: 0 },
cone: { radius: 100, coneAngle: 90, direction: 90 },
orbit: {},
}
function onShapeChange() {
const cfg = sys.value?.config
if (!cfg) return
const d = SHAPE_DEFAULTS[cfg.shape]
if (!d) return
Object.assign(cfg, d)
}
// 重置/删除图片 → 回到默认 star.png
function resetTexture() {
const c = sys.value?.config
if (c) { c.textureName = 'star'; c.textureFolder = '' }
}
function clearTexture() { resetTexture() }
</script>
<style scoped>
.panel { width: 100%; height: 100%; overflow-y: auto; background: #14141f; border-left: 1px solid #2a2a3c; padding: 12px; box-sizing: border-box; }
/* 深色主题滚动条:细窄、圆角、暗色,避免默认白条突兀 */
.panel::-webkit-scrollbar { width: 6px; }
.panel::-webkit-scrollbar-track { background: transparent; }
.panel::-webkit-scrollbar-thumb { background: #2e2e44; border-radius: 3px; }
.panel::-webkit-scrollbar-thumb:hover { background: #3a3a55; }
.panel { scrollbar-width: thin; scrollbar-color: #2e2e44 transparent; }
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; font-weight: 700; color: #ccd; }
.sys-list { margin-bottom: 10px; display: flex; flex-direction: column; gap: 4px; }
.sys-item { display: flex; justify-content: space-between; align-items: center; padding: 6px 8px; background: #1e1e2e; border-radius: 6px; cursor: pointer; }
.sys-item.active { background: #2b3a5c; }
.sys-name { font-size: 12px; }
.empty, .panel-hint { color: #667; font-size: 12px; padding: 8px 0; }
.group { border: 1px solid #2a2a3c; border-radius: 8px; margin-bottom: 10px; overflow: hidden; }
.group-head {
display: flex; align-items: center; gap: 6px; padding: 7px 10px; cursor: pointer;
font-size: 12px; font-weight: 600; color: #ccd; background: #191924; user-select: none;
}
.group-head:hover { background: #20202e; }
.group-body { padding: 8px 10px 12px; }
.caret { display: inline-block; width: 12px; color: #667; font-size: 11px; }
.divider { height: 1px; background: #2a2a3c; margin: 8px 0; }
.tree { margin-bottom: 8px; }
.tree-item { display: flex; align-items: center; gap: 6px; padding: 5px 6px; font-size: 12px; color: #bcd; border-radius: 6px; margin-bottom: 2px; }
.tree-item.on { background: #26324a; color: #ffe; }
.tree-item .tdot { color: #ffcc33; font-size: 10px; }
.tree-item .tdot.dim { color: #556; }
.tree-item .tlabel { flex: 1; }
.tag { font-size: 10px; color: #667; background: #1c1c2a; border-radius: 4px; padding: 1px 5px; }
.row { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 12px; color: #aab; }
.row > span:first-child { width: 60px; flex-shrink: 0; color: #8a93bb; }
.inp { flex: 1; min-width: 0; padding: 3px 5px; background: #1a1a28; border: 1px solid #2e2e44; border-radius: 5px; color: #dde; font-size: 12px; }
.pair { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.colr { width: 28px; height: 22px; border: none; background: none; cursor: pointer; }
.segs { display: flex; gap: 6px; margin: 6px 0; }
.seg { flex: 1; padding: 5px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 6px; color: #aab; font-size: 12px; cursor: pointer; }
.seg.on { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
.mini-btn { background: #2e3a55; border: 1px solid #3a4a6a; color: #cdf; border-radius: 5px; padding: 3px 8px; font-size: 11px; cursor: pointer; }
.mini-btn.danger { background: #4a2a2e; color: #fbb; }
.placeholder { color: #667; font-size: 11px; padding: 6px 0; }
/* 外观与资源 参考版式 */
.look-title { font-size: 12px; font-weight: 600; color: #ccd; margin: 10px 0 6px; padding-bottom: 4px; border-bottom: 1px solid #2a2a3c; }
.look-title:first-child { margin-top: 2px; }
.seg { display: flex; gap: 6px; margin: 6px 0; }
.seg-btn { flex: 1; padding: 5px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 6px; color: #aab; font-size: 12px; cursor: pointer; }
.seg-btn.active { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
.curve-hint { min-height: 54px; border: 1px dashed #2e2e44; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: #556; font-size: 11px; margin: 6px 0; }
.curve-wrap { margin: 6px 0; }
.curve-labels { display: flex; justify-content: space-between; color: #566; font-size: 10px; margin-top: 2px; }
.imgrow { display: flex; align-items: flex-start; gap: 8px; }
.img-thumb { width: 48px; height: 48px; border: 1px solid #2e2e44; border-radius: 6px; background: #1a1a28; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.img-fields { flex: 1; min-width: 0; }
.img-actions { display: flex; flex-direction: column; gap: 4px; }
.mini-btn2 { width: 26px; height: 24px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 5px; color: #aab; font-size: 12px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.mini-btn2:hover { background: #2b3a5c; }
.thumb-note { color: #667; font-size: 11px; margin: 4px 0 2px; }
.hex { font-size: 11px; color: #889; margin-left: auto; font-variant-numeric: tabular-nums; }
.inp.tiny { width: 46px; flex: 0 0 46px; text-align: center; }
</style>
+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>
+192
View File
@@ -0,0 +1,192 @@
<template>
<div class="timeline">
<!-- 控制按钮 -->
<div class="tl-controls">
<button class="tl-btn" :class="{ on: store.timeline.playing }" @click="store.togglePlay()" :title="store.timeline.playing ? '暂停' : '播放'">
{{ store.timeline.playing ? '❚❚' : '▶' }}
</button>
<button class="tl-btn" @click="store.toStart()" title="回到起点"></button>
<button class="tl-btn" :class="{ on: store.timeline.loop }" @click="store.toggleLoop()" title="循环"></button>
<span class="tl-frame"> {{ store.timeline.frame }} / {{ store.timeline.totalFrames }}</span>
<label class="tl-anim">动画
<select v-model="store.timeline.animation" class="tl-sel">
<option value="animation">animation</option>
</select>
</label>
<span class="tl-zoom">{{ zoomLabel }}</span>
</div>
<!-- 时间轴视口 -->
<div
class="tl-viewport"
ref="vpRef"
@wheel.prevent="onWheel"
@pointerdown="onScrubDown"
>
<!-- 帧刻度 -->
<div class="tl-ticks">
<div
v-for="t in ticks"
:key="t.f"
class="tl-tickline"
:style="{ left: t.px + 'px' }"
>
<span class="tl-ticklabel">{{ t.f }}f</span>
</div>
</div>
<!-- 播放头 -->
<div class="tl-playhead" :style="{ left: playPx + 'px' }"></div>
<!-- 每系统一条轨道(粒子条) -->
<div class="tl-lanes">
<div v-for="(sys, i) in store.systems" :key="sys.id" class="tl-lane">
<span class="lane-ico"></span>
<!-- 粒子条:left=start(delay), width=duration -->
<div
class="lane-bar"
:style="{ left: barLeftPx(sys) + 'px', width: barWidthPx(sys) + 'px' }"
@pointerdown.stop="onBarDown($event, sys)"
>
<span class="lane-name">{{ (sys.config as any).name || '系统 ' + (i + 1) }}</span>
<span class="bar-handle" @pointerdown.stop="onBarResizeDown($event, sys)"></span>
</div>
</div>
<div v-if="!store.systems.length" class="tl-empty">暂无粒子系统</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useParticleStore } from '../store/particleStore'
type SysLike = { id: number; config: any }
const store = useParticleStore()
const vpRef = ref<HTMLElement | null>(null)
const fps = 60
/** 视口当前能显示的帧数(滚轮缩放它)。视口起点恒为第 0 帧,不滚动 */
const viewFrames = ref(51)
const totalFrames = computed(() => store.timeline.totalFrames)
const zoomLabel = computed(() => (viewFrames.value <= 30 ? '放大' : viewFrames.value >= 300 ? '缩小' : '适中'))
// 每像素代表的帧数(视口宽度内放下 viewFrames 帧)
function pxPerF() {
const vw = vpRef.value?.clientWidth || 600
return viewFrames.value / vw
}
// 帧刻度:从第 0 帧开始,撑满 viewFrames;取整数帧,间隔随缩放自适应
const ticks = computed(() => {
const out: { f: number; px: number }[] = []
const endF = viewFrames.value
let step = 1
if (viewFrames.value <= 10) step = 1
else if (viewFrames.value <= 50) step = 5
else if (viewFrames.value <= 200) step = 10
else if (viewFrames.value <= 500) step = 50
else step = 100
for (let f = 0; f <= endF; f += step) {
out.push({ f, px: f / pxPerF() })
}
return out
})
const playPx = computed(() => store.timeline.frame / pxPerF())
function barLeftPx(sys: SysLike) {
const s = (sys.config as any)
// 左端 = 延迟发射时长(delay);左端之前是空白=延迟
return ((s.delay || 0) * fps) / pxPerF()
}
function barWidthPx(sys: SysLike) {
const s = (sys.config as any)
// 宽度 = 粒子系统总时长 = 发射时长(duration) + 粒子最大生命(lifeMax)
// 即从 delay 开始,覆盖到最后一个粒子消失,右端与 totalFrames 对齐
return ((s.duration || 0) * fps + (s.lifeMax || 0) * fps) / pxPerF()
}
// 滚轮缩放:只改变视口显示的帧数(左侧恒为 0 帧),不改变播放时长/totalFrames
function onWheel(e: WheelEvent) {
const factor = e.deltaY < 0 ? 0.8 : 1.25 // 滚上放大(显示更少帧),滚下缩小(显示更多帧)
let nv = viewFrames.value * factor
nv = Math.min(2000, Math.max(10, nv)) // 10f ~ 2000f
// 整数帧,最小值 0 起
viewFrames.value = Math.round(nv)
}
// 拖动时间轴滑动(scrub):更新 frame(0..totalFrames-1)
function onScrubDown(e: PointerEvent) {
const vp = vpRef.value!
const move = (ev: PointerEvent) => {
const rect = vp.getBoundingClientRect()
const f = Math.floor(((ev.clientX - rect.left) / rect.width) * viewFrames.value)
store.timeline.frame = Math.max(0, Math.min(f, totalFrames.value - 1))
}
move(e)
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
// 拖动粒子条:整体移动 = 改 delay
function onBarDown(e: PointerEvent, sys: SysLike) {
e.stopPropagation()
const startX = e.clientX
const startDelay = (sys.config as any).delay || 0
const secPerPx = pxPerF() / fps
const move = (ev: PointerEvent) => {
const dSec = (ev.clientX - startX) * secPerPx
let nd = Math.max(0, Math.min(startDelay + dSec, (store.timeline.totalFrames / fps)))
;(sys.config as any).delay = Math.round(nd * fps) / fps
}
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
// 拖动粒子条右缘:调整发射时长 duration
function onBarResizeDown(e: PointerEvent, sys: SysLike) {
e.stopPropagation()
const startX = e.clientX
const startDur = (sys.config as any).duration || 0.1
const secPerPx = pxPerF() / fps
const move = (ev: PointerEvent) => {
const dSec = (ev.clientX - startX) * secPerPx
let nd = Math.max(0.1, Math.min(startDur + dSec, store.timeline.totalFrames / fps))
;(sys.config as any).duration = Math.round(nd * fps) / fps
}
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
</script>
<style scoped>
.timeline { height: 100%; background: #12121c; border-top: 1px solid #2a2a3c; display: flex; flex-direction: column; padding: 6px 10px; gap: 4px; }
.tl-controls { display: flex; align-items: center; gap: 8px; }
.tl-btn { width: 30px; height: 26px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 6px; color: #aab; font-size: 12px; cursor: pointer; }
.tl-btn.on { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
.tl-frame { font-size: 12px; color: #aab; }
.tl-anim { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #aab; }
.tl-sel { background: #1a1a28; border: 1px solid #2e2e44; color: #dde; border-radius: 5px; padding: 2px 6px; font-size: 12px; }
.tl-zoom { margin-left: auto; font-size: 11px; color: #667; }
.tl-viewport { position: relative; flex: 1; overflow: hidden; background: #0d0d14; border: 1px solid #2a2a3c; border-radius: 6px; cursor: ew-resize; }
.tl-ticks { position: absolute; top: 0; left: 0; right: 0; height: 18px; }
.tl-tickline { position: absolute; top: 0; bottom: 0; width: 1px; background: #263040; }
.tl-ticklabel { position: absolute; top: 2px; transform: translateX(-50%); font-size: 9px; color: #667; }
.tl-playhead { position: absolute; top: 0; bottom: 0; width: 2px; background: #ffcc33; z-index: 5; pointer-events: none; }
.tl-lanes { position: absolute; top: 22px; left: 0; right: 0; bottom: 0; }
.tl-lane { position: relative; height: 26px; margin-bottom: 4px; }
.lane-ico { position: absolute; left: 4px; top: 6px; font-size: 11px; color: #dde; z-index: 2; pointer-events: none; }
.lane-bar { position: absolute; top: 4px; height: 18px; background: #2b4a8c; border: 1px solid #4a6aa8; border-radius: 5px; display: flex; align-items: center; padding-left: 14px; cursor: grab; box-sizing: border-box; }
.lane-bar:active { cursor: grabbing; }
.lane-name { font-size: 11px; color: #dfe; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bar-handle { position: absolute; right: 0; top: 0; bottom: 0; width: 8px; cursor: ew-resize; background: rgba(255,255,255,0.15); border-radius: 0 4px 4px 0; }
.bar-handle:hover { background: rgba(255,255,255,0.35); }
.tl-empty { color: #667; font-size: 11px; padding: 8px; }
</style>
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
host: true,
},
// 让 node_modules 里的 ESM 依赖能直接 work
optimizeDeps: {
include: ['@esotericsoftware/spine-pixi-v7'],
},
});
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
host: true,
},
// 让 node_modules 里的 ESM 依赖能直接 work
optimizeDeps: {
include: ['@esotericsoftware/spine-pixi-v7'],
},
})
+168
View File
@@ -0,0 +1,168 @@
# Spine 网页端粒子系统 · 开发计划(定稿 v1.0,已确认)
> **已确认的决策**
> - 产品形态:**独立编辑器/演示站**
> - 技术栈:**Vue 3 + Vite + TypeScript + Pinia**
> - Spine 预览运行时:**@pixi/spine + PixiJS 8**(主打 4.2,兼容 3.8/4.0/4.1 预览)
> - 粒子系统:自研轻量粒子池(预览用,勿黑盒库)
> - 导出:**A 方案 —— 工程配置 JSON**(不产出 Spine 动画 JSON;先做 A,MVP 优先,架构为后续 B 预留)
> - 部署:**纯前端静态构建 → Netlify/Vercel/GitHub Pages**,零后端
---
## 一、目标
让没有粒子系统的 Spine 动画,能在网页端**可视化地叠加粒子特效**。用户在网页里:
1. 上传/加载 Spine 骨骼动画(`.json + .atlas + .png`)或内置示例;
2. 在骨骼上配置粒子发射器(可**跟随骨骼**、调各种参数、应用预设);
3. **实时预览**效果;
4. 保存为"工程配置 JSON",下次可再次加载编辑。
> 对标:喵喵 Spine Particle / Spine Particle Studio。**注意**:本次范围是"编辑器+预览+存配置",暂不做那两个站的"导出 Spine 动画 JSON"核心烘焙功能(那属 B 方案,列为后续扩展,见第八节)。
---
## 一·补充、参考站实测核对(已于浏览器读取确认)
### 站1:喵喵 Spine Particle v0.14.1
- 保存/加载配置;**场景对象=粒子系统**(名称/所属动画/系统X/Y/旋转/ScaleX/ScaleY/**参与碰撞 Collision Filter+碰撞标签**)
- 发射:爆发/持续、延迟(秒/帧)、数量、形状(点/圆/矩形/锥体/**轨道**)、方向(随机/固定角度世界/局部/跟随轨道)
- 属性:初始速度/生命周期/缩放(统一与XY分离)/旋转(各支持 固定/随机/**曲线**)、生命周期内变化
- 外观:透明度(固定/曲线,跟随导出/强制线性/强制贝塞尔)、图片(单图/序列/子文件夹)、颜色(固定色/生命周期)、混合模式(正常/相加/相乘/滤色)、缩放/轴心/独立透明度
- 修改器:**发射器跟随**/移动噪声/重力/风力/力场(吸斥)/拖尾
- **导出 Spine JSON**(Spine 3.8/4.0/4.1/4.2、关键帧插值线性/贝塞尔/阶跃、导出FPS、整数帧对齐、**骨骼对象池**、导出骨架、排除无内容动画、图片路径)
- 操作:中键平移、Ctrl+滚轮缩放、Ctrl+Z 撤销、G/L 全局局部坐标、时间轴
- 附:页面头部含**官方飞书文档** <https://ecn2wbmd52pg.feishu.cn/docx/I0rVdOWMoofdzDxZGxDcKOqZnVb>
### 站2:Spine Particle Studio
- 系统列表/名称/粒子位置X/Y/发射器旋转
- 贴图:模式(单图/序列帧/雪碧图)、**内置贴图**(发光星/柔光点/发光环/六角雪花/十字火花/闪耀菱形)+ 自定义上传
- 发射:爆发/持续、方向/吸收(向外/向内)、数量、延迟、总时长、循环/无缝循环/**预热**、形状(点/圆/矩形/扇形)、发射角度/偏移
- 粒子属性:速度/生命/透明度/缩放(固定/随机/曲线,且带**曲线绘制器** 基础值/重置/START/LIFE TIME/END)、初始旋转模式(固定/随机/方向)、旋转模式(速度/曲线)、颜色(固定/生命周期)、混合模式
- 物理与随机:速度抑制/重力/抖动/移动噪声/吸引力场/**碰撞与阻碍物**/随机种子/路径跟随/死亡触发/**拖尾子粒子**(主粒子<20)
- 背景:参考图/上传背景/显示背景/透明度
- **导出 Spine JSON**(Spine 3.8/4.0/4.1/4.2、导出FPS、图片路径)、UI 主题、撤回0/10
### 两站共性(确认)
> 都是一个**粒子特效编辑器**:可视化配置 → 实时预览 → **导出为 Spine 骨骼动画 JSON** 供并入 Spine 工程。站1 更强调"骨骼跟随+骨骼对象池+曲线插值";站2 更强调"内置贴图+物理+拖尾子粒子+曲线绘制器"。
### 对本次 A 方案的定位(重要)
> 本次(A 方案)做的是完整"编辑器+实时预览+工程配置 JSON 存取",**不含**上述"导出 Spine JSON 烘焙"。两者的功能清单(属性/发射/修改器/贴图)在**预览层**基本一致,只是导出层不同;因此做 A 时,除导出外其余功能均按上述清单实现,架构为 B(导出)预留,见第八节。
---
## 二、核心功能清单
| 模块 | 明细 |
|---|---|
| 资源加载 | 上传 `.json+.atlas+.png` / 内置示例(`sample.json` 4.2.43 可当测试数据) |
| 动画控制 | 播放/暂停/停止、循环、动画切换、时间缩放、皮肤切换、骨架缩放/位移 |
| **粒子系统** | 发射器(爆发/持续)、发射形状(点/圆/矩形/扇形/锥体)、发射方向/角度、粒子数量、延迟、时长 |
| 粒子属性 | 速度/生命/透明度/缩放/旋转(各支持 固定/随机/曲线)、颜色(固定/生命周期)、混合模式 |
| **骨骼跟随** | 发射器绑定到某根骨骼/插槽,原点随骨骼逐帧更新(核心特性) |
| 修改器(可选增强) | 重力、风力、移动噪声、拖尾、力场 |
| 贴图资源 | 内置(发光星/柔光点/发光环/雪花/火花/菱形)+ 用户上传;支持 单图/序列帧/雪碧图 |
| 编辑器 UI | 场景对象(粒子层)管理、参数面板(滑块/颜色选择/曲线编辑)、预设模板 |
| 保存/加载 | 导出/导入**工程配置 JSON**;支持撤销/重做、随机种子 |
| 操作习惯 | 中键平移、滚轮缩放 |
---
## 三、技术选型
| 层 | 选型 |
|---|---|
| 前端 | Vue 3 + Vite + TypeScript + Pinia |
| Spine 渲染(预览) | @pixi/spine(runtime-4.2 主,兼容 3.8/4.0/4.1)+ PixiJS 8 |
| 粒子预览 | 自研粒子池(Pixi ParticleContainer / 自定义 WebGL 精灵) |
| 状态 | Pinia |
| 部署 | Vite 静态构建 → 静态托管 |
---
## 四、核心设计(预览层)
- 编辑器画布 = Spine 骨骼渲染 + 若干 `ParticleEmitter`
- `ParticleEmitter` 管理一个池化粒子容器;每帧:发射原点 = 所属骨骼世界坐标 + 偏移 → 发射粒子 → 更新(位置/速度/重力/寿命/颜色/旋转/alpha)→ 渲染。
- 参数用**归一化数据模型**描述(固定 = 定值 / 随机 = [min,max] / 曲线 = 贝塞尔控制点),同一份模型驱动预览渲染与将来导出。
- 贴图:内置一组 + 用户上传;支持单图/序列帧/雪碧图。
---
## 五、代码结构(建议)
```
SpineParticle/
├─ index.html
├─ vite.config.ts
├─ package.json
├─ src/
│ ├─ main.ts
│ ├─ App.vue
│ ├─ core/
│ │ ├─ spineLoader.ts # json/atlas/png 加载解析、多版本
│ │ ├─ particleEmitter.ts # 预览粒子发射器(池化)
│ │ ├─ particleSystem.ts # 多发射器/帧更新/骨骼跟随
│ │ ├─ curves.ts # 固定/随机/贝塞尔 求值
│ │ ├─ presets.ts # 内置贴图 + 预设模板
│ │ └─ types.ts # 数据模型 + 工程配置结构
│ ├─ views/
│ │ ├─ Stage.vue # 场景/预览画布
│ │ ├─ AnimationPanel.vue # 动画控制
│ │ ├─ ParticlePanel.vue # 粒子层列表 + 参数面板
│ │ └─ PresetPicker.vue # 预设模板
│ └─ store/
│ └─ particleStore.ts # spine 资源 + 粒子层状态
└─ 开发计划.md
```
---
## 六、开发阶段(先 MVP 后增强)
| 阶段 | 内容 | 产出 |
|---|---|---|
| **0 需求确认** | 完成(本计划定稿) | — |
| **1 技术验证 Spike** | pixi-spine 加载 `sample.json` 并播放;**一个粒子点跟随 `symbols` 骨骼** | 最小可演示 |
| **2 Spine 预览核心** | 资源加载(上传+内置)、动画/皮肤/缩放/控制、时间轴 | 可操作现有骨骼 |
| **3 粒子预览引擎** | 粒子池、发射器、基础参数、曲线求值、**骨骼跟随** | 骨骼上长出粒子 |
| **4 编辑器 UI** | 场景对象管理、参数面板(滑块/颜色/曲线)、内置贴图+预设 | 可视化编辑 |
| **5 保存/加载** | 导出/导入工程配置 JSON、撤销/重做、随机种子 | 可保存/分享 |
| **6 部署上线** | Vite 构建 → 静态托管,验证 URL | 线上可用 |
> 每阶段都有可运行成果,可随时停下验收。
---
## 七、验收标准(阶段 6 完成时)
- 打开网页 → 加载内置示例(或用你的 `sample.json`)→ 播放动画;
- 在任意骨骼上添加粒子层 → 粒子**跟随骨骼**运动;
- 通过面板调节速率/寿命/速度/颜色/缩放/alpha/重力等 → 实时可见;
- 切换预设 vs 自定义 → 生效;
- 保存工程配置 → 刷新后加载还原;
- 部署到静态托管,公网可访问。
---
## 八、后续扩展(本次不做,已明确后置)
**用户决策:不做烘焙导出,本次只做预览。烘焙这一"大头功能"放后续做。**
- **烘焙导出(后置大目标,即 B 方案)**:把粒子效果**烘焙导出为 Spine 骨骼动画 JSON**(对象池骨骼、逐帧采样、曲线插值线性/贝塞尔/阶跃、fps/整数帧对齐、多版本 3.8/4.0/4.1/4.2 schema)。本项目的"归一化数据模型 + 骨骼跟随"已为此预留,将来平滑接入,无需推翻现有架构。
- 物理系统补充:碰撞体/阻碍物、路径跟随、死亡触发、拖尾子粒子、物理吸引力场。
- 多版本**导出**支持(预览层面本项目已支持多版本)。
> 本次 A 方案与 B 方案只在"导出层"不同:预览层的功能清单(属性/发射/修改器/贴图)两者一致,因此当前实现的每一项都会成为将来 B 方案的直接输入。
---
## 附:交付物
- 一个可运行、可部署的 Vue 3 单页应用(代码 + `package.json`);
- 支持上传 Spine 资源、可视化配置粒子、骨骼跟随、实时预览、存/取配置;
- 开发计划本文件。
---
> ⚠️ 由于需在浏览器里实际调 spine + 粒子渲染与交互,**建议边开发边在浏览器里目视验收**,因此在阶段 1 用你的 `sample.json` 做技术验证(Spike)是关键第一步。
+74
View File
@@ -0,0 +1,74 @@
# 粒子系统开发 · 阶段一总结
定位:一个网页端粒子发射编辑器(Vue3 + Vite + TS + Pinia + PixiJS),负责「粒子发射系统的参数化编辑 + 实时预览」。**不依赖 Spine 运行时**,Spine 只是未来导出的目标格式,未做烘焙导出(按决定后置)。
目录:`/Users/tianmokeji/Desktop/SpineParticle`(dev server: `http://localhost:5173`)。
---
## 一、产出
### 核心文件
- `src/core/particleEmitter.ts` — 自研池化粒子发射器。排放模式(持续/爆发)、发射形状(点/圆/矩形/锥体)、速率、寿命、速度、缩放、旋转、颜色、混合、重力、风力、阻尼、随机种子、透明度曲线插值 `curveAt`
- `src/store/particleStore.ts` — Pinia 状态(多粒子系统、activeId 选中、时间轴、画布设置、`settings` 坐标轴配置)。
- `src/views/ParticlePanel.vue` — 左侧参数面板,7 个可折叠分组:场景对象/发射模式/发射器形状/粒子属性/外观与资源/修改器/导出设置(预留)。
- `src/views/Stage.vue` — Pixi 画布(网格背景、滚轮缩放、左键拖拽、无限坐标轴、系统设置面板、发射点标记)。
- `src/views/Timeline.vue` — 底部时间轴(播放/暂停/回到起点/循环、帧计数、滚轮缩放、每系统轨道条、拖动调延迟/时长)。
- `src/views/CurveEditor.vue` — 透明度生命周期曲线(横 0-1 时间、纵 0-1 透明度,节点拖动、双击新建/删除)。
- `src/views/NumSlider.vue` — 滑块+数字框复用控件。
- `public/particles/star.png` — 默认贴图。
### 已确认的关键行为
- Y 轴采用数学坐标系:**向上为正、向下为负**。
- 位置X/位置Y 是**锚点(黄点)**,偏移X/偏移Y 让**粒子相对黄点额外位移**,物理/力场源锚在锚点。
- 一个粒子系统 = 一个坐标点;无系统时画布无边线;选中系统高亮黄色,其余淡灰蓝。
- `totalFrames``delay + duration + lifeMax` 按生命周期自动算出,只在改这些属性时重算(签名监听,不卡)。
- 默认:发射速率 10/s、透明度用曲线(默认平台曲线 `(0,0)(0.2,1)(0.8,1)(1,0)`)、贴图 star.png、英文命名 ParticleSystem1 递增、画布默认 100%(最小 1%、无上限)、默认不显示骨骼连线、进入页面空白、默认循环播放。
- 设置面板「启用刻度」是坐标轴唯一主开关,默认关闭,内部含字号/线宽/颜色/显示刻度数值。
### 明确未做(预留)
导出 Spine JSON 烘焙、轨道路径/碰撞体/导出设置(界面占位)、真实 `.atlas+.png` 骨骼贴图。
---
## 二、踩过的坑
### 1. 方向性坑(代价最大)
- 一开始把 sample.json 当"要预览的素材",被纠正为"用来创建粒子系统",再被纠正为"用参数创建、json 是最终导出物",最后"不要强行匹配 spine,只负责粒子发射编辑器"。多轮方向错误导致大面积返工(一度引入 spine-pixi 依赖、做骨骼对象池架构)。
- 做了"加载示例骨骼"按钮,被否定(粒子系统创建应由编辑器参数决定)。
- 进页面自动建"系统1"、预置 item_11,被要求默认空白。
### 2. 技术坑
- **Pixi7 Color API**:`copyFrom``setValue(r,g,b)` 不存在,渐变插值需手动拼 RGB 再 `.toNumber()`
- **坐标轴文字放大变糊**:Text 放在被缩放的 world 容器会位图模糊;移到独立 `axisLabelLayer`(不随 world scale),用世界→屏幕坐标定位。
- **SVG 曲线编辑器点变椭圆/viewBox 拉伸**:`preserveAspectRatio="none"` 使 100×100 viewBox 拉成非正方形,圆变椭圆且命中区小;改为像素坐标系 + 加大命中圈。
- **曲线编辑器 `dblclick` 不触发**:透明命中圆上的 dblclick 被 pointer 捕获抑制;改为在 pointerdown 里检测两次点击(300ms 内、位移<8px)。
- **双击删除又新建**:删除节点后同一次双击的 dblclick 冒泡到 SVG 触发"双击空白新建";用删除抑制时间戳窗口解决。
- **录制→回放使面板改动不实时**:进入回放读旧帧,改参数(速率/颜色/曲线)不更新;把触发重录的 watch 改为对整个 config 做 `JSON.stringify`(排除 texture)的签名监听。
- **deep watch 引发每帧重算卡死**:之前 `watch(config, {deep:true})` 监听整个 config,emitter 每帧把 `_elapsed` 写进 config 导致深比较触发;修法:仅监听生命周期签名 + 把 `_elapsed` 移出 config 改为 emitter 私有字段。
- **发射点与位置X/Y 错位**:`setEmitterPos` 返回 centerX/centerY,而 `spawnOrigin` 又加一次 centerX,等于 `2×centerX+offsetX`;把锚点来源统一为 `_emitterPos()`,再加 offset。
- **Y 轴方向反了**:坐标轴标签是数学坐标(上正),world 用屏幕坐标(下正),位置Y 为正反而画向下;在发射点初始化处把锚点 Y 取负。
### 3. 协作/流程坑
- 反复来回挪同一设置面板里的选项(坐标轴颜色、显示刻度数值),属"反复折腾同一功能"。
- 有明确方向时仍多次用 ask_user_question 确认 A/B 方案,拖慢节奏。
---
## 三、经验
1. 先对齐"坐标系/数据模型"设计,再写编辑器代码。最贵的返工来自语义理解偏差,而非语法/API 问题。
2. 区分"一次功能 vs 一次架构"。小参数改动要最小侵入;方向级错误要敢于回滚,不要继续堆。
3. 性能敏感的循环里别 deep watch 可变的引用对象。粒子每帧改状态,任何 deep 监听都会拖垮帧率;用序列化签名 + 把每帧变化的场移到组件私有字段。
4. 坐标系不统一是渲染 bug 高发地。屏幕坐标(下正)与数学坐标(上正)混用,导致"位置反了/文字糊了/缩放不对";最好在数据入口一次转换,渲染层保持单一约定。
5. headless(puppeteer)自测有效。浏览器工具受限时,headless 截图 + `read_image` 能自主验证"位置/方向/颜色/数值是否生效"。
6. 事件优先级要兜底。曲线编辑器这类点击/拖动/双击/删除并存的小组件,事件易互相干扰(删了又建、拖了误删),需时间戳/位移阈值/抑制窗口做防抖。
---
## 四、现状与风险
- 还没做**导出**,一切围绕"调参 + 预览";导出 JSON(含烘焙 Spine)是后续大工程,现有"锚点 + 偏移 + 曲线 + 生命周期"数据模型已为此留接口。
- **发射方向(direction)/力场/移动噪声**这些修改器参数,物理方向在 Y 翻转后个别(发射方向角度、力场基准点 `base`)可能仍需按数学坐标复核;本次只改了黄点和粒子发射起点,`base`(力场)默认关闭未动,开启时需再验证。
- **爆发模式下 totalFrames 算法**(一次全出、无持续窗口)可能与预期不同,值得确认。
- 代码是反复迭代出来的,个别函数较长、命名一般;若有长期维护打算,建议做一轮小清理(非必须)。
+249
View File
@@ -0,0 +1,249 @@
# 网页粒子系统 · 阶段二
> 一个纯前端的 **Spine 粒子发射编辑器**(参考 喵喵 Spine Particle / Spine Particle Studio)。
> 本阶段聚焦「粒子发射编辑器的系统设计与显示」,不强行绑定 Spine 运行时;Spine 仅是未来导出格式的参考,`spine/sample.json` 只作素材,不做为编辑器模板。
> 工程目录:`/Users/tianmokeji/Desktop/SpineParticle`
---
## 一、项目定位与技术栈
**定位**:可视化配置粒子特效、实时预览,最终输出导出所需的数据模型(导出为后续阶段,现阶段为预览/编辑铺路)。
**技术栈**
- 前端框架:Vue 3 + TypeScript + Vite
- 状态管理:Pinia
- 渲染:PixiJS 7.4(`@esotericsoftware/spine-pixi-v7` **已卸载**,当前不依赖任何 Spine 运行时;`pixi.js` 用于画布)
- 粒子:自研粒子发射器 `ParticleEmitter`(池化,Sprite 池)
**明确不做(预留)**:导出 Spine JSON 烘焙、骨骼对象池匹配 Spine 运行时。(本阶段核心 = 编辑器 + 预览。)
---
## 二、已完成功能清单
### 2.1 面板(ParticlePanel)架构
按参考站分组、全部可折叠:
| 分组 | 内容 |
|---|---|
| **场景对象** | 树形层级(●粒子系统 / ◇轨道路径【预留】 / ◇碰撞体【预留】),名称、位置X、位置Y、偏移X、偏移Y、**旋转、缩放X、缩放Y** |
| **发射模式** | 持续(发射速率) / 爆发(单次数量) |
| **发射器形状** | 形状(点/圆/矩形/锥体/轨道),圆/锥(半径)、矩形(宽/高)、锥形开口角度、发射角度、扩散角度 |
| **粒子属性** | 速度下限/上限、生命下限/上限、缩放下限/上限、旋转速度、随机种子 |
| **外观与资源** | 透明度(曲线/固定)、曲线模式(跟随导出/线性/贝塞尔)、图片资源(子文件夹/名称)、颜色类型(固定色/渐变)、混合(正常/相加/相乘/滤色)、占比与缩放(占比/缩放/轴心/独立透明度) |
| **修改器 (Modifiers)** | 移动噪声、重力、风力、力场、拖尾、碰撞【预留】、路径跟随【预留】、阻尼 |
| **导出设置** | 预留(占位) |
**数值调节统一为滑块 + 数字框并存**(`NumSlider` 组件)。
### 2.2 画布(Stage)
- 网格背景(可切 ▦),深色棋盘格。
- 滚轮缩放画布:`默认 100%`,`最小 1%`,**无上限**;以鼠标为中心缩放(鼠标下的世界点不漂移)。
- 左键长按拖动画布(平移)。
- 顶部工具栏:
- **根变换工具**:✋ 拖动画布(默认)、✥ 位移、⟳ 旋转、⤢ 缩放(切换后用 gizmo 拖拽根节点)。
- 视图工具:+/- 缩放、⤢ 适配画布、▦ 网格、⟳ 重置视图。
- 开关:发射点、骨骼连线、⚙ 系统设置。
- 坐标轴:`center+center` 无限长 x/y 轴 + 自适应整数刻度,数字标签放**独立不缩放图层**(避免放大变糊);由系统设置"启用刻度"主开关控制。
- **发射点(黄点)**:每个系统一个,绑 位置X/位置Y(centerX/centerY);选中高亮黄,其余淡蓝灰;无系统不画。
- **发射器形状范围**:选中对应形状时绘制绿圆(圆形)、蓝矩形(矩形)、绿锥形(锥体),随参数实时响应;点/轨道不绘制。
- **根变换 gizmo**:选中工具后在 root 中心亮青色手柄(位移十字箭头 / 旋转圆环 / 缩放方框角点)。
### 2.3 时间轴(Timeline)
- 控制按钮:❚❚/▶ 播放暂停、⏮ 回到起点、⟳ 循环。
- 帧显示 `当前帧 / 总帧数`,动画名下拉。
- 标尺:整数帧刻度;**滚轮缩放只改变显示范围(视口帧数,左端恒为第 0 帧),不改变播放时长**;刻度为整数、最小 0。
- 每系统一条轨道;播放头可拖动滑动。
- **粒子条**:位置 = 延迟(delay)起点,宽度 = 发射时长(duration)+粒子生命(lifeMax) = 总时长;左端之前空白 = 延迟时长。可整体拖动(调 delay)、拖右缘(调 duration)。
### 2.4 系统设置(⚙ 面板)
- **启用刻度** 主开关(默认关闭):开启才显示坐标轴并展开其余配置项。
- 刻度文字大小(默认 12px)、刻度线粗细、坐标轴颜色、显示刻度数值(均随"启用刻度"显隐)。
### 2.5 默认值(按用户给定的截图定版)
| 项 | 默认 |
|---|---|
| 发射模式 | 持续,发射速率 = 20 |
| 爆发 | 单次数量 = 50 |
| 延迟时间 | 0 |
| 发射时长 | 0(= 无限,已在 UI 移除该项) |
| 持续爆发开关 | 已移除 |
| 形状 | 点;发射角度 0、扩散角度 360 |
| 圆形 | 半径 100,方向 -90,扩散 0 |
| 矩形 | 宽 200,高 100,方向 -90,扩散 0 |
| 锥体 | 半径 100,锥形开口 90,朝向 90 |
| 系统命名 | ParticleSystem1(英文,同名自动 +1) |
| 贴图 | star.png |
| 透明度 | 曲线模式,默认点 (0,0)(0.2,1)(0.8,1)(1,0) |
| 随机种子 | 0(每次不同) |
---
## 三、核心架构设计
### 3.1 数据流(单向)
```
ParticlePanel(Template) ⇄ Pinia Store (systems[]) ⇄ Stage.loop (emitterMap)
▲ │
└────── 面板 v-model 改 config ──────┘
```
- **`sys.config` 按引用保持**:面板直接改 config 字段,Pixi 发射器读到的是同一对象,改动实时生效。
- `emitterMap: Map<systemId, ParticleEmitter>` 由 Stage 维护,每帧 render。
### 3.2 坐标系(关键)
编辑器统一采用**数学坐标系:Y 向上为正、向下为负**。Pixi 内部是屏幕坐标系(Y 向下为正),因此:
- 发射点/黄点位置:`cy = -centerY`;
- 发射器容器 `em` position:`em.position.set(centerX, -centerY)`;
- 初始速度 y:`vy = -sin(ang)`(锥体);
- 形状范围、gizmo 绘制均遵循 `cy 取负`
**统一手法**:显示层(黄点、形状、gizmo、容器 position)都做 Y 取负转换;而**粒子物理/运动方向**保持屏幕坐标,二者在"发射方向(锥体)"这一处需显式对齐。
### 3.3 发射器(ParticleEmitter extends Container)
粒子是发射器容器的子项(Sprite 池),坐标在**发射器局部坐标**。因此对容器设 `position/rotation/scale` 即整体变换整簇粒子 = **"root 变换"**。
角色划分:
- **root 位移** = `centerX/centerY` → 容器 `position`;
- **root 旋转** = `rootRotation` → 容器 `rotation`(数学上正,顺时针为负);
- **root 缩放** = `rootScaleX/rootScaleY` → 容器 `scale`
**发射局部原点**:粒子发射位置 = `中心(centerX) + 偏移(offsetX/offsetY)`,其中 `spawnOrigin` 的局部原点只含 offset,中心由容器 position 补回。这样旋转/缩放绕 root 中心,且最终世界坐标不变。
### 3.4 发射模式与形状
- **持续(stream)**:`accumulator += dt * rate`,累积满 1 发射一个。
- **爆发(burst)**:一次性 `emitBurst()``burstCount` 个。
- **发射形状**(`spawnOrigin`):
- point:中心;
- circle:半径内圆散布;
- rect:宽高矩形散布;
- cone:**从锥形顶点(中心)发射,方向在 `direction ± coneAngle/2` 扇形内随机**、半径 0~radius(与圆不同,只限锥体弧度范围)。
- **速度方向**(`spawnOne`):锥体用 coneAngle 散布 + `vy=-sin`;其余用 spread + 屏幕坐标。
### 3.5 根变换 gizmo
- 3 种工具:位移 / 旋转 / 缩放,切换后画布按住拖动改 `config.centerX/centerY``rootRotation``rootScaleX/ScaleY`
- 与场景对象面板参数**双向联动**。
- 计算基于**按下时的快照 + 相对按下点的总增量**(位移/旋转/缩放都不累积),保证"跟手"。
### 3.6 录制 → 回放
- Stage.loop 每帧 `em.update(dt)`(固定 `dt = 1/fps` 保证帧对齐),产生的 `ParticleState[]` 深拷贝存到 `sys.frames[frame]`
- 录完后进入回放:`em.apply(frames[frame])`,到尾帧若 loop 则回 0,否则停末帧。
- 每帧也收集活动粒子状态 `collected`(供 HUD 数字化/骨骼连线)。
### 3.7 时间轴总帧数(自动计算)
- `totalFrames = ceil( max(对所有系统 (delay + duration + lifeMax)) * fps )`
- 即"最后一个粒子消失的时刻";改 delay/duration/lifeMax 时重算。
- 通过 **字段签名 watch** 触发重录(见踩坑点 #1)。
---
## 四、踩坑点(重要)
1. **签名 watch 粒度**
- 起初 `watch(store.systems.map(config), {deep:true})` 监听整个 config;但发射器每帧改 `_elapsed` 等内部字段 → 每帧触发 → 每帧重算 totalFrames + 清空重录 → **卡死**
- 修复:`JSON.stringify(s.config, 排除 texture)` 生成**签名字符串**比较,只有"影响渲染的参数"变化才触发;并把 `_elapsed/_delayLeft` 从 config 移至 emitter **私有字段** `_elapsed`,避免污染响应式。
2. **坐标轴文字放大变糊**
- Text 放 world 容器随 viewScale 放大变为位图模糊。
- 修复:文字放到**独立 `axisLabelLayer`(不随 world 缩放)**,用世界→屏幕坐标定位;字号由"系统设置"手动控制。
3. **曲线编辑器椭圆节点/选不中**
- SVG `viewBox + preserveAspectRatio="none"` 把圆拉伸成椭圆、命中区过小。
- 修复:改按实际像素绘制(ResizeObserver 量尺寸),节点为正圆,叠加大命中圈,Pointer 捕获拖动。
4. **曲线节点双击删除又新建**
- dblclick 冒泡到 SVG 触发"双击空白新建"。
- 修复:删除节点记录时间戳,`onDbl`(新建)在删除后 400ms 内忽略(抑制窗口)。
5. **Y 轴方向反**
- 坐标轴标签用数学坐标(上正),发射器/黄点用屏幕坐标(下正),方向相反。
- 修复:黄点/发射器容器 position 的 y 取负,统一到数学坐标系。
6. **锥体朝向与粒子方向相反**
- 粒子发射用 `-sin`(数学上正),形状范围绘制用 `+sin`,二者符号不一致。
- 修复:drawShapeRange 逐点做 `pt` 变换时统一 `Y = -ly - oyo`(与粒子一致)。
7. **锥体粒子散布在扇区内 vs 从中心发射**
- 初版把粒子位置散布在扇形区域(半径 × coneAngle),与圆的"中心全向"不同。
- 按用户意图修正:粒子**从锥形顶点(中心)发射,方向限 `direction ± coneAngle/2` 扇形**,类似"圆心向各方向,但只限锥体弧度范围"。
8. **偏移不影响形状范围**
- 形状范围中心画在 root 中心,没用 offset;粒子发射源 = root 中心 + 偏移,导致偏移时粒子相对形状范围偏移。
- 修复:drawShapeRange 的 `pt` 把偏移 `(oxo, oyo)` 并入局部原点;且 `offsetY` 转屏幕取负(`Y = -ly - oyo`)。
9. **发射时长 1 秒后停发**
- `duration` 默认 1.0,爆发/持续 1 秒后 `withinDuration=false` 停发,粒子随后消亡 → "没粒子"。
- 修复:配合 UI 移除"发射时长/持续爆发",`duration` 默认改 0(=无限),`burstLoop` 默认 false(一次性爆发)。
10. **gizmo 拖拽不跟手**
- 位移用 `cfg.centerX + dx`(基于当前值累加总位移 dx),鼠标一动重复叠加 → 越拖越偏。
- 修复:存按下时基准 `sCx/sCy`,位移 = 基准 + 总位移;旋转/缩放同样基于基准快照,并去掉 `Math.round` 取整抖动。
11. **默认贴图/命名等默认值**
- 曾把默认 textureName 设为 dot、并在用户改图后仍用旧缓存;后按用户要求默认 `star.png`,硬刷新即可。
- 系统命名:默认英文 `ParticleSystem1`,同名自动 +1(正则 `^ParticleSystem(\d+)$` 取最大值 +1)。
12. **新增系统不显示贴图**
- 初期只给 onMounted 时存在的系统赋 texture,后续 `addSystem` 新增系统 texture 为 null → Sprite 画不出。
- 修复:在 `syncEmitters()`(每帧)里,给 `dotTex 已加载但 sys.config.texture 为空` 的系统补上默认纹理。
---
## 五、根节点变换的坐标正确性(易错综述)
- 发射器容器(position/rotation/scale)承载 root 变换。
- **必须**把发射局部原点改为"只含 offset"(去 centerX),centerX 由容器 position 补回;否则 position 与粒子局部坐标双重叠加 → 位移重复。
- 旋转/缩放绕 root 中心成立,前提是"root 中心 = 容器局部原点(0,0)"。
- 数学坐标系下,一切显示层 y 取负;粒子运动方向(y 向)在锥体方向处需与形状绘制对齐。
---
## 六、响应式与性能要点
- **`sys.config` 保持引用**,面板 v-model 直接改属字段 → 实时生效,无深拷贝/重建。
- **避免 deep watch** 整个 config;用签名字符串监听 + 内部计时字段移出 config。
- **录制固定步长** `dt = 1/fps`,保证回放与实时帧对齐、可复现。
- **确定性随机(种子)**:`mulberry32`;`seed>0` 时可复现;**仅在种子变化时重建 RNG**,`reset()` 时若种子>0 重置到序列头,保证同种子从头录制结果一致。
---
## 七、文件结构
```
src/
main.ts # 入口,注册 Pinia
App.vue # 布局:左面板 / 右上画布 / 右下时间轴(宽度/高度可拖拽)
style.css # 全局样式(深色主题)
core/
particleEmitter.ts # 发射器:config + defaultConfig + 池化更新/捕获/回放/重置/确定性随机
store/
particleStore.ts # Pinia:systems[] + activeId + timeline + settings + recalcTotalFrames
views/
Stage.vue # 画布:world/粒子/坐标轴/黄点/形状范围/gizmo + 录制回放 + 根变换
ParticlePanel.vue # 参数面板(分组折叠 + NumSlider + 形状默认值切换)
Timeline.vue # 时间轴(控制/标尺缩放/轨道/粒子条拖拽)
CurveEditor.vue # 透明度曲线编辑器(节点可拖/双击删/双击空白新建)
NumSlider.vue # 滑块 + 数字框 双控组件
public/
particles/star.png # 默认粒子贴图(另有 dot.png / spark.png)
spine/sample.json # 仅作素材参考,不作编辑器模板
```
---
## 八、当前遗留 / 预留(后续阶段)
- **导出 Spine JSON 烘焙**(用户确认后置):现阶段编辑/预览,导出的数据模型(每粒子一根骨骼 `boneName=p_<i>`、ParticleState)已为此铺路。
- **轨道路径 / 碰撞体**:场景对象中为预留占位(`collision`/`pathFollow` 字段已存在)。
- **导出设置**:分组占位。
- **修改器实现程度**:重力/风力/力场/移动噪声/阻尼已接入 `emitter.update`;拖尾/碰撞/路径跟随为占位/预留。
---
## 九、常见调整指引
- **默认参数**集中在本文件 §2.5(来自 `particleEmitter.ts``defaultConfig()` 与面板 `SHAPE_DEFAULTS`)。
- **形状范围跟随 root 旋转/缩放/偏移**:改 `Stage.vue``drawShapeRange`(逐点 `pt` 变换)。
- **根变换 gizmo**:改 `Stage.vue``drawGizmo` / `onGizmoDown`
- **时间轴总帧**:改 `store.recalcTotalFrames`
- **坐标系**:凡涉及显示 y 方向,遵循 `Y 取负` 的数学坐标约定。
+83
View File
@@ -0,0 +1,83 @@
# Spine 粒子编辑器 · 骨骼对象池架构(重构设计 v1)
> 基于我们多轮确认的设计意图整理。**目标**:一个"用参数创建粒子系统"的编辑器,
> 粒子本质是**挂在动态骨骼下的图片**,最终可导出 Spine 骨骼 json。
>
> **与旧实现的根本区别**:不再"加载外部 Spine 骨骼、让粒子跟随某根骨骼",而是反过来——
> **编辑器自建骨架(仅 root 根骨骼),粒子生成骨骼并挂在 root 下**。
---
## 一、核心概念(最终确认)
1. **自建最小骨架**:编辑器打开时,**自建一棵只含一根 `root` 根骨骼的骨架**。
- 不加载任何外部 `.json/.atlas/.png`(禁止 sample.json 作模板、禁止"加载示例骨骼"入口)。
2. **骨骼对象池**:按**粒子发射数量**,在 `root` 下**动态生成对应数量的骨骼**。
- **一个粒子 = 一根骨骼**。
3. **粒子图片挂到骨骼**:每个粒子的图片(贴图光斑)作为 **child 挂载到对应该粒子的骨骼下面**
- 骨骼决定粒子的 **位置 / 旋转 / 缩放**;粒子图片跟随骨骼运动(这正是 Spine 动画的结构)。
4. **参数驱动一切**:粒子系统的创建与所有参数(发射/数量/形状/方向/速度/生命/缩放/透明度/旋转/颜色/混合/重力等)完全由**编辑器参数面板**决定。
5. **最终产出**:导出的 json 描述这些**在 root 下动态生成、随动画变化的骨骼**(即 Spine 骨骼动画 json)。
---
## 二、数据流(预览阶段)
```
参数面板(store) → 每帧生成/更新 骨骼池
粒子数量 N ⇒ 在 root 下创建 N 根骨骼(Bone i)
每根骨骼设置 pos/rot/scale = 对应粒子的状态
该粒子图片(Sprite) 作为 child 挂到该骨骼 → 骨骼带着图片动
```
- 单根 `root` 骨骼:静止于原点(0,0),承载所有生成的子骨骼。
- 生成的骨骼名:如 `p_0, p_1, ...`(与粒子一一对应),挂在 root 下。
---
## 三、渲染实现要点
- 画布用 Pixi 的 `Spine`(官方 spine-pixi)挂载骨架;但**不渲染 Spines 附件的贴图**(附件我们不用)。
- 为**每根粒子骨骼**添加一个 `Sprite`(粒子贴图)作为其 child(Pixi `Bone` 可通过 `Spine` 组件的 slot/骨骼挂载子对象)。
- 替代方案:不真正用 Spine 渲染骨骼,而是**自己维护一根"逻辑骨骼"+一个 Sprite**——每帧从参数计算粒子状态,直接移动 Sprite,同时记录这根"逻辑骨骼"便于导出。**这是更可控的实现**(避免 Spine 运行时挂载复杂)。
> 我建议用**方案 B(逻辑骨骼 + Sprite)**:预览时用 Sprite 显示粒子,同时维护"每颗粒子对应一根逻辑骨骼"的数据结构(名字/父级=root/位置/旋转/缩放随时间序列),既满足预览又为导出 json 提供干净的数据。避免陷入 Spine 附件的复杂挂载。若你要严格用 Spine 的 Bone 对象,我再切方案 A。
---
## 四、参数面板(保留现有字段,去掉"加载骨骼/跟随骨骼"相关)
保留并完善:
- 系统:名称
- 发射:持续/爆发、**粒子数量/速率**、发射形状(点/圆/矩形/锥体)
- 方向:角度、扩散
- 粒子属性:速度/生命/缩放/透明度/旋转(各 min→max)
- 外观:颜色(起始/结束/寿命渐变)、混合模式、粒子贴图(内置选择/上传)
- 物理:重力
移除/调整:
- ~~跟随骨骼开关~~、~~骨骼下拉~~(不再"跟随某骨骼",而是 root 下生成)
- ~~加载示例骨骼~~ 按钮(不要再有)
---
## 五、导出(后续阶段,先做预览)
- 把每根粒子逻辑骨骼的**位置/旋转/缩放随时间的序列** dump 成 Spine 骨骼时间轴 json。
- 骨架 = 1 个 root + N 根生成的子骨骼。
---
## 六、待你确认的实现方式(仅一点)
预览阶段粒子的"骨骼"用哪种实现:
| 方案 | 说明 | 取舍 |
|---|---|---|
| **B(推荐)** | 自维护"逻辑骨骼"数据 + Sprite 显示粒子;导出时从逻辑骨骼 dump json | 实现简单可控、与导出天然对齐 |
| **A** | 用 spine-pixi 的 `Bone` 对象,粒子 Sprite 真实挂到 Bone 下 | 更贴近 Spine,但挂载/渲染复杂 |
> 我倾向 **B**。请确认选 A 还是 B(或让我默认按 B 开始)。