阶段六开发进度
This commit is contained in:
+71
-27
@@ -272,6 +272,15 @@ export interface EmitterConfig {
|
||||
collision: boolean
|
||||
/** 开启路径跟随(预留) */
|
||||
pathFollow: boolean
|
||||
emitterFollow: boolean
|
||||
emitterFollowMode: 'none' | 'path'
|
||||
emitterFollowPathId: number
|
||||
emitterAngleMode: 'fixed' | 'direction'
|
||||
emitterFollowCurve: CurvePoint[]
|
||||
emitterFollowDuration: number
|
||||
emitterFollowSpace: 'local' | 'world'
|
||||
emitterFollowDirection: 'forward' | 'reverse'
|
||||
emitterFollowOffset: number
|
||||
}
|
||||
|
||||
/** 粒子瞬间状态(供骨骼同步 / 导出) */
|
||||
@@ -490,6 +499,15 @@ export function defaultConfig(): EmitterConfig {
|
||||
trailGridCols: 4,
|
||||
collision: false,
|
||||
pathFollow: false,
|
||||
emitterFollow: false,
|
||||
emitterFollowMode: 'none',
|
||||
emitterFollowPathId: 0,
|
||||
emitterAngleMode: 'fixed',
|
||||
emitterFollowCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
|
||||
emitterFollowDuration: 2,
|
||||
emitterFollowSpace: 'world',
|
||||
emitterFollowDirection: 'forward',
|
||||
emitterFollowOffset: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,6 +729,10 @@ export class ParticleEmitter extends Container {
|
||||
private _lastSeed = -1
|
||||
private trailDebugGfx = new Graphics()
|
||||
private colliders: SceneCollider[] = []
|
||||
private followSpawnX = 0
|
||||
private followSpawnY = 0
|
||||
private followSpawnAngle = 0
|
||||
private simulationTransform: { centerX: number; centerY: number; rotation: number } | null = null
|
||||
|
||||
constructor(config: EmitterConfig, maxParticles = MAX_PARTICLES) {
|
||||
super()
|
||||
@@ -766,12 +788,20 @@ export class ParticleEmitter extends Container {
|
||||
|
||||
setEmitterPos(fn: () => IPointData) { this._emitterPos = fn }
|
||||
setColliders(colliders: SceneCollider[]) { this.colliders = colliders }
|
||||
setEmitterFollowSpawn(x: number, y: number, angleDegrees: number) {
|
||||
this.followSpawnX = x
|
||||
this.followSpawnY = y
|
||||
this.followSpawnAngle = angleDegrees
|
||||
}
|
||||
setSimulationTransform(centerX: number, centerY: number, rotation: number) {
|
||||
this.simulationTransform = { centerX, centerY, rotation }
|
||||
}
|
||||
|
||||
private applyColliders(p: Particle, particleIndex: number) {
|
||||
if (!this.colliders.length) return false
|
||||
const cfg = this.cfg
|
||||
let world = localToWorldMath(cfg, p.x, p.y)
|
||||
let velocity = localVelocityToWorldMath(cfg, p.vx, p.vy)
|
||||
let world = localToWorldMath(cfg, p.x, p.y, this.simulationTransform)
|
||||
let velocity = localVelocityToWorldMath(cfg, p.vx, p.vy, this.simulationTransform)
|
||||
for (const collider of this.colliders) {
|
||||
if (!collider.enabled) continue
|
||||
const hit = resolveColliderPoint(collider, world.x, world.y)
|
||||
@@ -794,8 +824,8 @@ export class ParticleEmitter extends Container {
|
||||
velocity.x = tangentX * tangentScale + reflectedNormal * hit.nx
|
||||
velocity.y = tangentY * tangentScale + reflectedNormal * hit.ny
|
||||
}
|
||||
const local = worldMathToLocal(cfg, world.x, world.y)
|
||||
const localVelocity = worldVelocityToLocalMath(cfg, velocity.x, velocity.y)
|
||||
const local = worldMathToLocal(cfg, world.x, world.y, this.simulationTransform)
|
||||
const localVelocity = worldVelocityToLocalMath(cfg, velocity.x, velocity.y, this.simulationTransform)
|
||||
p.x = local.x
|
||||
p.y = local.y
|
||||
p.vx = localVelocity.x
|
||||
@@ -871,10 +901,12 @@ export class ParticleEmitter extends Container {
|
||||
if (!p.collisionPaused && cfg.forceField && cfg.forceStrength !== 0 && cfg.forceRadius > 0) {
|
||||
const rootScaleX = Math.max(0.0001, Math.abs(cfg.rootScaleX))
|
||||
const rootScaleY = Math.max(0.0001, Math.abs(cfg.rootScaleY))
|
||||
const rootAngle = -cfg.rootRotation * Math.PI / 180
|
||||
const rootAngle = -(this.simulationTransform?.rotation ?? cfg.rootRotation) * Math.PI / 180
|
||||
const cosRoot = Math.cos(rootAngle), sinRoot = Math.sin(rootAngle)
|
||||
const particleScreenX = cfg.centerX + cosRoot * rootScaleX * p.x - sinRoot * rootScaleY * p.y
|
||||
const particleScreenY = -cfg.centerY + sinRoot * rootScaleX * p.x + cosRoot * rootScaleY * p.y
|
||||
const centerX = this.simulationTransform?.centerX ?? cfg.centerX
|
||||
const centerY = this.simulationTransform?.centerY ?? cfg.centerY
|
||||
const particleScreenX = centerX + cosRoot * rootScaleX * p.x - sinRoot * rootScaleY * p.y
|
||||
const particleScreenY = -centerY + sinRoot * rootScaleX * p.x + cosRoot * rootScaleY * p.y
|
||||
const particleWorldX = particleScreenX
|
||||
const particleWorldY = -particleScreenY
|
||||
const dx = cfg.forceCenterX - particleWorldX
|
||||
@@ -914,7 +946,7 @@ export class ParticleEmitter extends Container {
|
||||
? age >= Math.max(0, cfg.attractionDelay)
|
||||
: this._elapsed - this._attractionEnabledAt >= Math.max(0, cfg.attractionDelay)
|
||||
if (delayReached) {
|
||||
const currentWorld = localToWorldMath(cfg, p.x, p.y)
|
||||
const currentWorld = localToWorldMath(cfg, p.x, p.y, this.simulationTransform)
|
||||
const targetX = cfg.attractionCenterX
|
||||
const targetY = cfg.attractionCenterY
|
||||
const radius = Math.max(0, cfg.attractionRadius)
|
||||
@@ -951,7 +983,7 @@ export class ParticleEmitter extends Container {
|
||||
this.kill(i)
|
||||
continue
|
||||
}
|
||||
const local = worldMathToLocal(cfg, worldX, worldY)
|
||||
const local = worldMathToLocal(cfg, worldX, worldY, this.simulationTransform)
|
||||
p.x = local.x
|
||||
p.y = local.y
|
||||
// 吸附期间位置由路径控制,清除速度以免抵达后发生额外积分偏移。
|
||||
@@ -1268,14 +1300,23 @@ export class ParticleEmitter extends Container {
|
||||
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 }
|
||||
return this.applyFollowToOrigin(ox + Math.cos(a) * r, 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 }
|
||||
return this.applyFollowToOrigin(ox + (this.rng() - 0.5) * cfg.rectW, oy + (this.rng() - 0.5) * cfg.rectH)
|
||||
} else if (s === 'cone') {
|
||||
// 锥体:粒子从中心(锥形顶点)发射,方向由 spawnOne 在 [direction ± coneAngle/2] 扇形内随机
|
||||
return { x: ox, y: oy }
|
||||
return this.applyFollowToOrigin(ox, oy)
|
||||
}
|
||||
return this.applyFollowToOrigin(ox, oy)
|
||||
}
|
||||
|
||||
private applyFollowToOrigin(x: number, y: number) {
|
||||
const angle = this.followSpawnAngle * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
return {
|
||||
x: this.followSpawnX + cos * x - sin * y,
|
||||
y: this.followSpawnY + sin * x + cos * y,
|
||||
}
|
||||
return { x: ox, y: oy }
|
||||
}
|
||||
|
||||
private spawnOne() {
|
||||
@@ -1288,9 +1329,9 @@ export class ParticleEmitter extends Container {
|
||||
// 锥体:发射方向用锥形开口角度(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
|
||||
dirAng = (cfg.direction + this.followSpawnAngle + 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
|
||||
dirAng = (cfg.direction + this.followSpawnAngle + lerp(-cfg.spread / 2, cfg.spread / 2, this.rng())) * Math.PI / 180
|
||||
}
|
||||
const p = this.pool[idx]
|
||||
const resource = this.pickImageResource()
|
||||
@@ -1499,40 +1540,43 @@ export class ParticleEmitter extends Container {
|
||||
|
||||
function lerp(a: number, b: number, t: number) { return a + (b - a) * t }
|
||||
function finite(value: number, fallback: number) { return Number.isFinite(value) ? value : fallback }
|
||||
function localToWorldMath(cfg: EmitterConfig, localX: number, localY: number) {
|
||||
type SimulationTransform = { centerX: number; centerY: number; rotation: number } | null
|
||||
function localToWorldMath(cfg: EmitterConfig, localX: number, localY: number, transform: SimulationTransform = null) {
|
||||
const scaleX = Math.max(0.0001, Math.abs(cfg.rootScaleX))
|
||||
const scaleY = Math.max(0.0001, Math.abs(cfg.rootScaleY))
|
||||
const angle = -cfg.rootRotation * Math.PI / 180
|
||||
const angle = -(transform?.rotation ?? cfg.rootRotation) * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const screenX = cfg.centerX + cos * scaleX * localX - sin * scaleY * localY
|
||||
const screenY = -cfg.centerY + sin * scaleX * localX + cos * scaleY * localY
|
||||
const centerX = transform?.centerX ?? cfg.centerX
|
||||
const centerY = transform?.centerY ?? cfg.centerY
|
||||
const screenX = centerX + cos * scaleX * localX - sin * scaleY * localY
|
||||
const screenY = -centerY + sin * scaleX * localX + cos * scaleY * localY
|
||||
return { x: screenX, y: -screenY }
|
||||
}
|
||||
function worldMathToLocal(cfg: EmitterConfig, worldX: number, worldY: number) {
|
||||
function worldMathToLocal(cfg: EmitterConfig, worldX: number, worldY: number, transform: SimulationTransform = null) {
|
||||
const scaleX = Math.max(0.0001, Math.abs(cfg.rootScaleX))
|
||||
const scaleY = Math.max(0.0001, Math.abs(cfg.rootScaleY))
|
||||
const angle = -cfg.rootRotation * Math.PI / 180
|
||||
const angle = -(transform?.rotation ?? cfg.rootRotation) * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const screenX = worldX - cfg.centerX
|
||||
const screenY = -worldY + cfg.centerY
|
||||
const screenX = worldX - (transform?.centerX ?? cfg.centerX)
|
||||
const screenY = -worldY + (transform?.centerY ?? cfg.centerY)
|
||||
return {
|
||||
x: (cos * screenX + sin * screenY) / scaleX,
|
||||
y: (-sin * screenX + cos * screenY) / scaleY,
|
||||
}
|
||||
}
|
||||
function localVelocityToWorldMath(cfg: EmitterConfig, vx: number, vy: number) {
|
||||
function localVelocityToWorldMath(cfg: EmitterConfig, vx: number, vy: number, transform: SimulationTransform = null) {
|
||||
const scaleX = Math.max(0.0001, Math.abs(cfg.rootScaleX))
|
||||
const scaleY = Math.max(0.0001, Math.abs(cfg.rootScaleY))
|
||||
const angle = -cfg.rootRotation * Math.PI / 180
|
||||
const angle = -(transform?.rotation ?? cfg.rootRotation) * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const screenX = cos * scaleX * vx - sin * scaleY * vy
|
||||
const screenY = sin * scaleX * vx + cos * scaleY * vy
|
||||
return { x: screenX, y: -screenY }
|
||||
}
|
||||
function worldVelocityToLocalMath(cfg: EmitterConfig, vx: number, vy: number) {
|
||||
function worldVelocityToLocalMath(cfg: EmitterConfig, vx: number, vy: number, transform: SimulationTransform = null) {
|
||||
const scaleX = Math.max(0.0001, Math.abs(cfg.rootScaleX))
|
||||
const scaleY = Math.max(0.0001, Math.abs(cfg.rootScaleY))
|
||||
const angle = -cfg.rootRotation * Math.PI / 180
|
||||
const angle = -(transform?.rotation ?? cfg.rootRotation) * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const screenY = -vy
|
||||
return {
|
||||
|
||||
@@ -127,7 +127,7 @@ export const useParticleStore = defineStore('particle', {
|
||||
const id = colliderSeq++
|
||||
const collider: CollisionBody = {
|
||||
id,
|
||||
name: `碰撞体 ${id}`,
|
||||
name: `Collider${id}`,
|
||||
tags: ['Default'],
|
||||
selectedTag: 'Default',
|
||||
shape: 'circle',
|
||||
@@ -155,7 +155,7 @@ export const useParticleStore = defineStore('particle', {
|
||||
const id = pathSeq++
|
||||
const path: ScenePath = {
|
||||
id,
|
||||
name: `路径 ${id}`,
|
||||
name: `Path${id}`,
|
||||
enabled: true,
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -202,14 +202,20 @@ export const useParticleStore = defineStore('particle', {
|
||||
this.timeline.frame = Math.max(0, Math.min(f, this.timeline.totalFrames - 1))
|
||||
},
|
||||
/** 依据所有粒子系统的"最晚消失时间"重算总帧数。
|
||||
* 单系统总时长 = delay(延迟) + duration(发射时长) + lifeMax(粒子最大生命),
|
||||
* 固定生命周期使用 lifeMin;随机生命周期使用上下限中的较大值。
|
||||
* 单系统总时长 = delay(延迟) + duration(发射时长) + 最大粒子生命,
|
||||
* 多系统取最大;转帧 = 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)
|
||||
const lifeMin = Math.max(0, Number(c.lifeMin) || 0)
|
||||
const lifeMax = Math.max(0, Number(c.lifeMax) || 0)
|
||||
const particleLife = c.lifeMode === 'fixed'
|
||||
? lifeMin
|
||||
: Math.max(lifeMin, lifeMax)
|
||||
const end = Math.max(0, Number(c.delay) || 0) + Math.max(0, Number(c.duration) || 0) + particleLife
|
||||
if (end > maxSec) maxSec = end
|
||||
}
|
||||
const frames = Math.max(1, Math.ceil(maxSec * fps))
|
||||
|
||||
+45
-11
@@ -88,26 +88,26 @@
|
||||
<label class="field-block check-field"><span>启用</span><input v-model="activeCollider.enabled" type="checkbox" /></label>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<NumSlider label="X 位置" :min="-2000" :max="2000" :step="1" v-model="activeCollider.x" />
|
||||
<NumSlider label="Y 位置" :min="-2000" :max="2000" :step="1" v-model="activeCollider.y" />
|
||||
<label class="compact-field"><span>X 位置</span><input v-model.number="activeCollider.x" type="number" class="inp" /></label>
|
||||
<label class="compact-field"><span>Y 位置</span><input v-model.number="activeCollider.y" type="number" class="inp" /></label>
|
||||
</div>
|
||||
<NumSlider label="旋转" :min="-360" :max="360" :step="1" v-model="activeCollider.rotation" />
|
||||
<NumSlider v-if="activeCollider.shape === 'circle'" label="半径" :min="1" :max="1000" :step="1" v-model="activeCollider.radius" />
|
||||
<div v-else-if="activeCollider.shape === 'ellipse'" class="field-grid">
|
||||
<NumSlider label="X 半径" :min="1" :max="1000" :step="1" v-model="activeCollider.radiusX" />
|
||||
<NumSlider label="Y 半径" :min="1" :max="1000" :step="1" v-model="activeCollider.radiusY" />
|
||||
<label class="compact-field"><span>X 半径</span><input v-model.number="activeCollider.radiusX" type="number" min="1" class="inp" /></label>
|
||||
<label class="compact-field"><span>Y 半径</span><input v-model.number="activeCollider.radiusY" type="number" min="1" class="inp" /></label>
|
||||
</div>
|
||||
<div v-else class="field-grid">
|
||||
<NumSlider label="宽度" :min="1" :max="2000" :step="1" v-model="activeCollider.width" />
|
||||
<NumSlider label="高度" :min="1" :max="2000" :step="1" v-model="activeCollider.height" />
|
||||
<label class="compact-field"><span>宽度</span><input v-model.number="activeCollider.width" type="number" min="1" class="inp" /></label>
|
||||
<label class="compact-field"><span>高度</span><input v-model.number="activeCollider.height" type="number" min="1" class="inp" /></label>
|
||||
</div>
|
||||
<NumSlider v-if="activeCollider.shape === 'polygon' || activeCollider.shape === 'custom'" label="边数" :min="3" :max="16" :step="1" v-model="activeCollider.sides" />
|
||||
<label class="field-block"><span>碰撞响应</span><select v-model="activeCollider.response" class="inp">
|
||||
<option value="physics">物理(弹跳/摩擦)</option><option value="pause">暂停(停止不动)</option><option value="destroy">消失(立即销毁)</option>
|
||||
</select></label>
|
||||
<div v-if="activeCollider.response === 'physics'" class="field-grid">
|
||||
<NumSlider label="弹性 (0-1)" :min="0" :max="1" :step="0.01" v-model="activeCollider.elasticity" />
|
||||
<NumSlider label="摩擦 (0-1)" :min="0" :max="1" :step="0.01" v-model="activeCollider.friction" />
|
||||
<label class="compact-field"><span>弹性 (0-1)</span><input v-model.number="activeCollider.elasticity" type="number" min="0" max="1" step="0.01" class="inp" /></label>
|
||||
<label class="compact-field"><span>摩擦 (0-1)</span><input v-model.number="activeCollider.friction" type="number" min="0" max="1" step="0.01" class="inp" /></label>
|
||||
</div>
|
||||
<label class="follow-path"><input v-model="activeCollider.followPath" type="checkbox" />跟随轨道移动</label>
|
||||
</template>
|
||||
@@ -619,8 +619,41 @@
|
||||
<div class="modifier-hint">每个粒子生成独立拖尾骨骼链;网格按行列自动重建,并由相邻骨骼权重共同驱动蒙皮变形</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<div class="modifier-item">
|
||||
<label class="switch-row modifier-switch">
|
||||
<input type="checkbox" v-model="sys.config.emitterFollow" />
|
||||
<span class="switch-ui"></span><span>开启发射器跟随 (Emitter Follow)</span>
|
||||
</label>
|
||||
<div v-if="sys.config.emitterFollow" class="modifier-params emitter-follow-params">
|
||||
<label class="field-block"><span>跟随模式</span><select v-model="sys.config.emitterFollowMode" class="inp">
|
||||
<option value="none">无</option>
|
||||
<option value="path">跟随轨道</option>
|
||||
</select></label>
|
||||
<label v-if="sys.config.emitterFollowMode === 'path'" class="field-block"><span>跟随轨道</span><select v-model.number="sys.config.emitterFollowPathId" class="inp">
|
||||
<option :value="0">无</option>
|
||||
<option v-for="path in store.paths" :key="path.id" :value="path.id">{{ path.name }}</option>
|
||||
</select></label>
|
||||
<label class="field-block"><span>发射器角度模式</span><select v-model="sys.config.emitterAngleMode" class="inp">
|
||||
<option value="fixed">固定角度</option>
|
||||
<option value="direction">跟随移动方向</option>
|
||||
</select></label>
|
||||
<div class="gravity-curve-title"><span>移动进度曲线 (0→1)</span></div>
|
||||
<CurveEditor v-model="sys.config.emitterFollowCurve" :default-value="LINEAR_CURVE" />
|
||||
<div class="field-grid follow-grid">
|
||||
<label class="compact-field"><span>移动时长 (s)</span><input v-model.number="sys.config.emitterFollowDuration" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||||
<label class="compact-field"><span>跟随空间</span><select v-model="sys.config.emitterFollowSpace" class="inp">
|
||||
<option value="local">局部空间(GEN 移动)</option>
|
||||
<option value="world">世界空间(粒子生成点移动)</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<label class="field-block"><span>移动方向</span><select v-model="sys.config.emitterFollowDirection" class="inp">
|
||||
<option value="forward">正向(起点 → 终点)</option>
|
||||
<option value="reverse">反向(终点 → 起点)</option>
|
||||
</select></label>
|
||||
<NumSlider label="偏移起始点 (0-1)" :min="0" :max="1" :step="0.01" v-model="sys.config.emitterFollowOffset" />
|
||||
<div class="modifier-hint">局部空间会移动整个发射器;世界空间只移动新粒子的生成点,已出生粒子留在世界位置。</div>
|
||||
</div>
|
||||
</div>
|
||||
<NumSlider label="阻尼" :min="0" :max="2" :step="0.05" v-model="sys.config.damping" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1207,7 +1240,8 @@ function toggleResourceLock(resourceId: number) {
|
||||
.field-block > .inp { width: 100%; box-sizing: border-box; min-height: 30px; }
|
||||
.field-block small { color: #5f6d86; line-height: 1.5; }
|
||||
.field-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; align-items: end; }
|
||||
.field-grid :deep(.num-slider) { min-width: 0; }
|
||||
.compact-field { display: flex; min-width: 0; flex-direction: column; gap: 5px; margin: 5px 0; color: #8f9ab3; font-size: 11px; }
|
||||
.compact-field .inp { width: 100%; min-height: 30px; box-sizing: border-box; }
|
||||
.check-field { align-items: flex-start; }
|
||||
.check-field input { width: 18px; height: 18px; margin: 4px 0; accent-color: #4f7ee8; }
|
||||
.tag-editor { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
|
||||
+134
-13
@@ -99,6 +99,7 @@ let dotTex: Texture<any> | null = null
|
||||
let trailTex: Texture<any> | null = null
|
||||
|
||||
const emitterMap = new Map<number, ParticleEmitter>()
|
||||
const emitterPreviewTransform = new Map<number, { x: number; y: number; rotation: number }>()
|
||||
const selectedPathNodes = new Set<number>()
|
||||
let suppressNextPathDoubleClick = false
|
||||
watch(() => [store.activeObjectType, store.activeObjectId], () => selectedPathNodes.clear())
|
||||
@@ -311,6 +312,23 @@ function activePath() {
|
||||
return store.paths.find((item) => item.id === store.activeObjectId) || null
|
||||
}
|
||||
|
||||
function pathInsertIndex(path: ScenePath, x: number, y: number) {
|
||||
if (path.points.length < 2) return path.points.length
|
||||
const segments = path.closed ? path.points.length : path.points.length - 1
|
||||
let bestIndex = path.points.length
|
||||
let bestDistance = Infinity
|
||||
for (let index = 0; index < segments; index++) {
|
||||
const a = path.points[index], b = path.points[(index + 1) % path.points.length]
|
||||
const dx = b.x - a.x, dy = b.y - a.y
|
||||
const lengthSq = Math.max(0.0001, dx * dx + dy * dy)
|
||||
const amount = Math.min(1, Math.max(0, ((x - a.x) * dx + (y - a.y) * dy) / lengthSq))
|
||||
const px = a.x + dx * amount, py = a.y + dy * amount
|
||||
const distance = Math.hypot(x - px, y - py)
|
||||
if (distance < bestDistance) { bestDistance = distance; bestIndex = index + 1 }
|
||||
}
|
||||
return bestIndex
|
||||
}
|
||||
|
||||
function onStageDoubleClick(e: MouseEvent) {
|
||||
const path = activePath()
|
||||
if (!path || !path.enabled) return
|
||||
@@ -324,9 +342,10 @@ function onStageDoubleClick(e: MouseEvent) {
|
||||
selectedPathNodes.clear()
|
||||
return
|
||||
}
|
||||
path.points.push({ x: local.x, y: local.y, inX: 0, inY: 0, outX: 0, outY: 0 })
|
||||
const insertIndex = pathInsertIndex(path, local.x, local.y)
|
||||
path.points.splice(insertIndex, 0, { x: local.x, y: local.y, inX: 0, inY: 0, outX: 0, outY: 0 })
|
||||
selectedPathNodes.clear()
|
||||
selectedPathNodes.add(path.points.length - 1)
|
||||
selectedPathNodes.add(insertIndex)
|
||||
}
|
||||
|
||||
function onPathPointerDown(e: PointerEvent) {
|
||||
@@ -339,9 +358,10 @@ function onPathPointerDown(e: PointerEvent) {
|
||||
const existing = path.points.findIndex((point) => Math.hypot(point.x - local.x, point.y - local.y) <= threshold)
|
||||
if (existing >= 0) return true
|
||||
const anchor = { x: local.x, y: local.y, inX: 0, inY: 0, outX: 0, outY: 0 }
|
||||
path.points.push(anchor)
|
||||
const insertIndex = pathInsertIndex(path, local.x, local.y)
|
||||
path.points.splice(insertIndex, 0, anchor)
|
||||
selectedPathNodes.clear()
|
||||
selectedPathNodes.add(path.points.length - 1)
|
||||
selectedPathNodes.add(insertIndex)
|
||||
suppressNextPathDoubleClick = true
|
||||
installPointerDrag((event) => {
|
||||
const worldPoint = pointerToWorldMath(event.clientX, event.clientY)
|
||||
@@ -603,9 +623,10 @@ function drawOrigin() {
|
||||
// 每个系统对应一个发射点(坐标点);没有系统时不画
|
||||
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 preview = emitterPreviewTransform.get(sys.id)
|
||||
const cx = preview?.x ?? sys.config.centerX ?? 0
|
||||
const cy = -(preview?.y ?? sys.config.centerY ?? 0)
|
||||
const active = store.activeObjectType === 'particle' && sys.id === store.activeId
|
||||
const col = active ? 0xffcc33 : 0x8a94b8
|
||||
const opacity = active ? 0.4 : 0.2
|
||||
g.lineStyle(1, col, opacity)
|
||||
@@ -628,14 +649,15 @@ function drawShapeRange() {
|
||||
const sys = store.systems.find((s) => s.id === store.activeId)
|
||||
if (!sys) return
|
||||
const c = sys.config
|
||||
const preview = emitterPreviewTransform.get(sys.id)
|
||||
// root 中心(发射器容器 position = centerX/-centerY),数学坐标
|
||||
const cx = c.centerX ?? 0
|
||||
const cy = -(c.centerY ?? 0)
|
||||
const cx = preview?.x ?? c.centerX ?? 0
|
||||
const cy = -(preview?.y ?? 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 rot = -(preview?.rotation ?? 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] => {
|
||||
@@ -919,6 +941,71 @@ function drawPaths() {
|
||||
}
|
||||
}
|
||||
|
||||
function followCurveValue(points: Array<{ x: number; y: number }>, t: number) {
|
||||
if (!points.length) return t
|
||||
const sorted = [...points].sort((a, b) => a.x - b.x)
|
||||
if (t <= sorted[0].x) return sorted[0].y
|
||||
for (let index = 1; index < sorted.length; index++) {
|
||||
const a = sorted[index - 1], b = sorted[index]
|
||||
if (t <= b.x) {
|
||||
const amount = (t - a.x) / Math.max(0.0001, b.x - a.x)
|
||||
return a.y + (b.y - a.y) * amount
|
||||
}
|
||||
}
|
||||
return sorted[sorted.length - 1].y
|
||||
}
|
||||
|
||||
function cubicPoint(a: number, b: number, c: number, d: number, t: number) {
|
||||
const u = 1 - t
|
||||
return u * u * u * a + 3 * u * u * t * b + 3 * u * t * t * c + t * t * t * d
|
||||
}
|
||||
|
||||
function sampleScenePath(path: ScenePath, progress: number) {
|
||||
if (!path.points.length) return null
|
||||
if (path.points.length === 1) {
|
||||
const point = pathLocalToWorld(path, path.points[0].x, path.points[0].y)
|
||||
return { ...point, angle: path.rotation }
|
||||
}
|
||||
const segmentCount = path.closed ? path.points.length : path.points.length - 1
|
||||
const samples: Array<{ x: number; y: number; distance: number }> = []
|
||||
let totalDistance = 0
|
||||
for (let segment = 0; segment < segmentCount; segment++) {
|
||||
const a = path.points[segment]
|
||||
const b = path.points[(segment + 1) % path.points.length]
|
||||
for (let step = 0; step <= 20; step++) {
|
||||
if (segment > 0 && step === 0) continue
|
||||
const t = step / 20
|
||||
const localX = cubicPoint(a.x, a.x + a.outX, b.x + b.inX, b.x, t)
|
||||
const localY = cubicPoint(a.y, a.y + a.outY, b.y + b.inY, b.y, t)
|
||||
const worldPoint = pathLocalToWorld(path, localX, localY)
|
||||
const previous = samples[samples.length - 1]
|
||||
if (previous) totalDistance += Math.hypot(worldPoint.x - previous.x, worldPoint.y - previous.y)
|
||||
samples.push({ ...worldPoint, distance: totalDistance })
|
||||
}
|
||||
}
|
||||
const target = Math.min(1, Math.max(0, progress)) * totalDistance
|
||||
let index = samples.findIndex((sample) => sample.distance >= target)
|
||||
if (index < 0) index = samples.length - 1
|
||||
const current = samples[index]
|
||||
const previous = samples[Math.max(0, index - 1)]
|
||||
const next = samples[Math.min(samples.length - 1, index + 1)]
|
||||
const beforeDistance = previous.distance
|
||||
const amount = index > 0 ? (target - beforeDistance) / Math.max(0.0001, current.distance - beforeDistance) : 0
|
||||
const x = previous.x + (current.x - previous.x) * Math.min(1, Math.max(0, amount))
|
||||
const y = previous.y + (current.y - previous.y) * Math.min(1, Math.max(0, amount))
|
||||
return { x, y, angle: Math.atan2(next.y - previous.y, next.x - previous.x) * 180 / Math.PI }
|
||||
}
|
||||
|
||||
function worldPointToEmitterLocal(config: { centerX: number; centerY: number; rootRotation: number; rootScaleX: number; rootScaleY: number }, worldX: number, worldY: number) {
|
||||
const scaleX = Math.max(0.0001, Math.abs(config.rootScaleX))
|
||||
const scaleY = Math.max(0.0001, Math.abs(config.rootScaleY))
|
||||
const angle = -config.rootRotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const screenX = worldX - config.centerX
|
||||
const screenY = -worldY + config.centerY
|
||||
return { x: (cos * screenX + sin * screenY) / scaleX, y: (-sin * screenX + cos * screenY) / scaleY }
|
||||
}
|
||||
|
||||
function loop() {
|
||||
raf = requestAnimationFrame(loop)
|
||||
const now = performance.now() / 1000
|
||||
@@ -933,14 +1020,48 @@ function loop() {
|
||||
drawAxis()
|
||||
syncEmitters()
|
||||
// 根节点(root)变换:位置(centerX/centerY)=位移,rotation/scale 绕 root 中心,与场景对象参数联动
|
||||
const followTime = store.timeline.frame / Math.max(1, store.timeline.fps)
|
||||
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)
|
||||
em.setColliders(store.colliders)
|
||||
em.setEmitterFollowSpawn(0, 0, 0)
|
||||
let preview = { x: c.centerX, y: c.centerY, rotation: c.rootRotation }
|
||||
const followPath = c.emitterFollow && c.emitterFollowMode === 'path'
|
||||
? store.paths.find((path) => path.id === c.emitterFollowPathId && path.enabled)
|
||||
: null
|
||||
const duration = Math.max(0.05, c.emitterFollowDuration)
|
||||
const normalizedTime = Math.min(1, Math.max(0, followTime / duration))
|
||||
const eased = Math.min(1, Math.max(0, followCurveValue(c.emitterFollowCurve, normalizedTime)))
|
||||
const offset = Math.min(1, Math.max(0, c.emitterFollowOffset))
|
||||
const pathProgress = c.emitterFollowDirection === 'reverse'
|
||||
? (1 - offset) * (1 - eased)
|
||||
: offset + (1 - offset) * eased
|
||||
const pathSample = followPath ? sampleScenePath(followPath, pathProgress) : null
|
||||
if (pathSample) {
|
||||
const movementAngle = pathSample.angle + (c.emitterFollowDirection === 'reverse' ? 180 : 0)
|
||||
const effectiveAngle = c.emitterAngleMode === 'direction' ? movementAngle : c.rootRotation
|
||||
preview = { x: pathSample.x, y: pathSample.y, rotation: effectiveAngle }
|
||||
if (c.emitterFollowSpace === 'local') {
|
||||
em.position.set(pathSample.x, -pathSample.y)
|
||||
em.rotation = -effectiveAngle * Math.PI / 180
|
||||
em.setSimulationTransform(pathSample.x, pathSample.y, effectiveAngle)
|
||||
} else {
|
||||
em.position.set(c.centerX, -c.centerY)
|
||||
em.rotation = -c.rootRotation * Math.PI / 180
|
||||
em.setSimulationTransform(c.centerX, c.centerY, c.rootRotation)
|
||||
const local = worldPointToEmitterLocal(c, pathSample.x, pathSample.y)
|
||||
const relativeDirection = c.emitterAngleMode === 'direction' ? -(movementAngle - c.rootRotation) : 0
|
||||
em.setEmitterFollowSpawn(local.x, local.y, relativeDirection)
|
||||
}
|
||||
} else {
|
||||
em.position.set(c.centerX, -c.centerY) // y 数学上正 → 屏幕取负
|
||||
em.rotation = -c.rootRotation * Math.PI / 180
|
||||
em.setSimulationTransform(c.centerX, c.centerY, c.rootRotation)
|
||||
}
|
||||
emitterPreviewTransform.set(sys.id, preview)
|
||||
}
|
||||
let total = 0
|
||||
const collected: BonePreview[] = []
|
||||
@@ -1039,7 +1160,7 @@ onMounted(async () => {
|
||||
() =>
|
||||
store.systems
|
||||
.map((s) => JSON.stringify(s.config, (k, v) => (k === 'texture' || k === 'trailTexture' || k === 'previewUrl' ? undefined : v)))
|
||||
.join('|') + `|colliders:${JSON.stringify(store.colliders)}`,
|
||||
.join('|') + `|colliders:${JSON.stringify(store.colliders)}|paths:${JSON.stringify(store.paths)}`,
|
||||
() => {
|
||||
const frames = store.recalcTotalFrames()
|
||||
store.timeline.recorded = false
|
||||
|
||||
+49
-11
@@ -70,6 +70,10 @@ const vpRef = ref<HTMLElement | null>(null)
|
||||
const fps = 60
|
||||
/** 视口当前能显示的帧数(滚轮缩放它)。视口起点恒为第 0 帧,不滚动 */
|
||||
const viewFrames = ref(51)
|
||||
// 拖动期间只更新本地预览值,松手后再一次性写回配置。
|
||||
// 避免 pointermove 每一帧都触发 Stage 清缓存、重置粒子模拟。
|
||||
const draftDelays = ref<Record<number, number>>({})
|
||||
const draftDurations = ref<Record<number, number>>({})
|
||||
|
||||
const totalFrames = computed(() => store.timeline.totalFrames)
|
||||
const zoomLabel = computed(() => (viewFrames.value <= 30 ? '放大' : viewFrames.value >= 300 ? '缩小' : '适中'))
|
||||
@@ -101,13 +105,23 @@ const playPx = computed(() => store.timeline.frame / pxPerF())
|
||||
function barLeftPx(sys: SysLike) {
|
||||
const s = (sys.config as any)
|
||||
// 左端 = 延迟发射时长(delay);左端之前是空白=延迟
|
||||
return ((s.delay || 0) * fps) / pxPerF()
|
||||
const delay = draftDelays.value[sys.id] ?? Math.max(0, Number(s.delay) || 0)
|
||||
return (delay * fps) / pxPerF()
|
||||
}
|
||||
|
||||
function maxParticleLife(sys: SysLike) {
|
||||
const s = (sys.config as any)
|
||||
const lifeMin = Math.max(0, Number(s.lifeMin) || 0)
|
||||
const lifeMax = Math.max(0, Number(s.lifeMax) || 0)
|
||||
return s.lifeMode === 'fixed' ? lifeMin : Math.max(lifeMin, lifeMax)
|
||||
}
|
||||
|
||||
function barWidthPx(sys: SysLike) {
|
||||
const s = (sys.config as any)
|
||||
// 宽度 = 粒子系统总时长 = 发射时长(duration) + 粒子最大生命(lifeMax)
|
||||
// 宽度 = 粒子系统总时长 = 发射时长(duration) + 当前模式下的最大粒子生命
|
||||
// 即从 delay 开始,覆盖到最后一个粒子消失,右端与 totalFrames 对齐
|
||||
return ((s.duration || 0) * fps + (s.lifeMax || 0) * fps) / pxPerF()
|
||||
const duration = draftDurations.value[sys.id] ?? Math.max(0, Number(s.duration) || 0)
|
||||
return ((duration + maxParticleLife(sys)) * fps) / pxPerF()
|
||||
}
|
||||
|
||||
// 滚轮缩放:只改变视口显示的帧数(左侧恒为 0 帧),不改变播放时长/totalFrames
|
||||
@@ -137,32 +151,56 @@ function onScrubDown(e: PointerEvent) {
|
||||
function onBarDown(e: PointerEvent, sys: SysLike) {
|
||||
e.stopPropagation()
|
||||
const startX = e.clientX
|
||||
const startDelay = (sys.config as any).delay || 0
|
||||
const startDelay = Math.max(0, Number((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 nd = Math.max(0, Math.min(startDelay + dSec, 600))
|
||||
draftDelays.value[sys.id] = Math.round(nd * fps) / fps
|
||||
}
|
||||
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
|
||||
const finish = (commit: boolean) => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
window.removeEventListener('pointercancel', cancel)
|
||||
const value = draftDelays.value[sys.id]
|
||||
delete draftDelays.value[sys.id]
|
||||
if (commit && value != null && value !== startDelay) (sys.config as any).delay = value
|
||||
}
|
||||
const up = () => finish(true)
|
||||
const cancel = () => finish(false)
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
window.addEventListener('pointercancel', cancel)
|
||||
}
|
||||
|
||||
// 拖动粒子条右缘:调整发射时长 duration
|
||||
function onBarResizeDown(e: PointerEvent, sys: SysLike) {
|
||||
e.stopPropagation()
|
||||
const startX = e.clientX
|
||||
const startDur = (sys.config as any).duration || 0.1
|
||||
const startDur = Math.max(0, Number((sys.config as any).duration) || 0)
|
||||
const secPerPx = pxPerF() / fps
|
||||
let moved = false
|
||||
const move = (ev: PointerEvent) => {
|
||||
moved = true
|
||||
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
|
||||
let nd = Math.max(0, Math.min(startDur + dSec, 600))
|
||||
// 有限发射最短为 1 帧;0 继续保留“无限发射”的既有语义。
|
||||
if (nd > 0) nd = Math.max(1 / fps, nd)
|
||||
draftDurations.value[sys.id] = Math.round(nd * fps) / fps
|
||||
}
|
||||
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
|
||||
const finish = (commit: boolean) => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
window.removeEventListener('pointercancel', cancel)
|
||||
const value = draftDurations.value[sys.id]
|
||||
delete draftDurations.value[sys.id]
|
||||
if (commit && moved && value != null && value !== startDur) (sys.config as any).duration = value
|
||||
}
|
||||
const up = () => finish(true)
|
||||
const cancel = () => finish(false)
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
window.addEventListener('pointercancel', cancel)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user