阶段七落盘文档

This commit is contained in:
tianmo
2026-08-29 03:25:57 +08:00
parent 5f4667f736
commit 031359586d
4 changed files with 452 additions and 9 deletions
+15
View File
@@ -15,6 +15,11 @@ export interface CollisionBody extends SceneCollider {
tags: string[]
selectedTag: string
followPath: boolean
followPathId: number
followDuration: number
followPathRotation: boolean
followCurveMode: '跟随导出' | '线性' | '贝塞尔'
followCurve: Array<{ x: number; y: number }>
}
export interface ScenePath {
@@ -156,6 +161,11 @@ export const useParticleStore = defineStore('particle', {
elasticity: 0.8,
friction: 0.3,
followPath: false,
followPathId: 0,
followDuration: 2,
followPathRotation: false,
followCurveMode: '跟随导出',
followCurve: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
}
this.colliders.push(collider)
this.activeObjectType = 'collision'
@@ -303,6 +313,11 @@ export const useParticleStore = defineStore('particle', {
const end = Math.max(0, Number(spine.timeOffset) || 0) + Math.max(0, animation.duration || 0)
if (end > maxSec) maxSec = end
}
for (const collider of this.colliders) {
if (!collider.followPath || !collider.followPathId) continue
const end = Math.max(0, Number(collider.followDuration) || 0)
if (end > maxSec) maxSec = end
}
const frames = Math.max(1, Math.ceil(maxSec * fps))
this.timeline.totalFrames = frames
// clamp 当前帧
+41 -6
View File
@@ -125,11 +125,32 @@
<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>
<div class="collider-follow-section">
<label class="follow-path"><input v-model="activeCollider.followPath" type="checkbox" />跟随路径移动</label>
<template v-if="activeCollider.followPath">
<label class="field-block"><span>目标路径</span><select v-model.number="activeCollider.followPathId" class="inp">
<option :value="0">未选择</option>
<option v-for="path in store.paths" :key="path.id" :value="path.id">{{ path.name }}</option>
</select></label>
<div class="field-grid">
<label class="compact-field"><span>移动时长</span><input v-model.number="activeCollider.followDuration" type="number" min="0.05" step="0.05" class="inp" /></label>
<label class="compact-field"><span>曲线模式</span><select v-model="activeCollider.followCurveMode" class="inp">
<option>跟随导出</option><option>线性</option><option>贝塞尔</option>
</select></label>
</div>
<label class="follow-path"><input v-model="activeCollider.followPathRotation" type="checkbox" />方向沿路径</label>
<div class="collider-curve-title">移动进度曲线 <small>横轴实时/移动时长 纵轴路径进度0=起点 1=终点</small></div>
<CurveEditor
:model-value="activeCollider.followCurve || LINEAR_CURVE"
:default-value="LINEAR_CURVE"
@update:model-value="activeCollider.followCurve = $event"
/>
</template>
</div>
</template>
<template v-else-if="activePath">
<div class="object-editor-title">路径属性</div>
<label class="field-block"><span>轨道名称</span><input v-model="activePath.name" class="inp" /></label>
<label class="field-block"><span>路径名称</span><input v-model="activePath.name" class="inp" /></label>
<div class="field-grid">
<label class="compact-field"><span>X 位置</span><input v-model.number="activePath.x" type="number" class="inp" /></label>
<label class="compact-field"><span>Y 位置</span><input v-model.number="activePath.y" type="number" class="inp" /></label>
@@ -139,8 +160,8 @@
<label class="compact-field"><span>缩放 X</span><input v-model.number="activePath.scaleX" type="number" min="0.05" step="0.05" class="inp" /></label>
<label class="compact-field"><span>缩放 Y</span><input v-model.number="activePath.scaleY" type="number" min="0.05" step="0.05" class="inp" /></label>
</div>
<label class="setting-line"><span>闭合轨道</span><input v-model="activePath.closed" type="checkbox" /></label>
<label class="setting-line"><span>轨道颜色</span><input v-model="activePath.color" type="color" class="path-color" /></label>
<label class="setting-line"><span>闭合路径</span><input v-model="activePath.closed" type="checkbox" /></label>
<label class="setting-line"><span>路径颜色</span><input v-model="activePath.color" type="color" class="path-color" /></label>
<div class="path-help"><span>操作说明</span><ul>
<li>双击空白处曲线新建锚点</li><li>双击锚点删除节点</li><li>双击第二下按住拖动新建并拉出手柄</li>
<li>拖动锚点移动位置拖动手柄调整曲线</li><li>Shift拖动手柄两侧完全对称</li><li>Alt拖动手柄独立控制单侧</li>
@@ -660,9 +681,9 @@
<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>
<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">
<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>
@@ -749,6 +770,17 @@ watchEffect(() => {
if (config.mode === 'stream' && (!(config.duration > 0))) config.duration = 1
})
// HMR 或旧场景中的碰撞体缺少路径跟随字段时即时补齐。
watchEffect(() => {
for (const collider of store.colliders) {
if (collider.followPathId == null) collider.followPathId = 0
if (!(collider.followDuration > 0)) collider.followDuration = 2
if (collider.followPathRotation == null) collider.followPathRotation = false
if (!['跟随导出', '线性', '贝塞尔'].includes(collider.followCurveMode)) collider.followCurveMode = '跟随导出'
if (!Array.isArray(collider.followCurve) || collider.followCurve.length < 2) collider.followCurve = [{ x: 0, y: 0 }, { x: 1, y: 1 }]
}
})
const LINEAR_CURVE = [{ x: 0, y: 0 }, { x: 1, y: 1 }]
const FLAT_CURVE = [{ x: 0, y: 1 }, { x: 1, y: 1 }]
const ALPHA_CURVE = [{ x: 0, y: 0 }, { x: 0.2, y: 1 }, { x: 0.8, y: 1 }, { x: 1, y: 0 }]
@@ -1340,6 +1372,9 @@ function toggleResourceLock(resourceId: number) {
.follow-path, .setting-line { display: flex; align-items: center; gap: 8px; margin: 12px 0 4px; color: #aeb8cf; font-size: 12px; }
.setting-line { justify-content: space-between; }
.follow-path input, .setting-line input[type='checkbox'] { width: 17px; height: 17px; accent-color: #4f7ee8; }
.collider-follow-section { margin-top: 12px; padding-top: 4px; border-top: 1px solid #30394d; }
.collider-curve-title { display: flex; flex-direction: column; gap: 3px; margin: 12px 0 6px; color: #aeb8cf; font-size: 12px; }
.collider-curve-title small { color: #67738c; font-size: 10px; line-height: 1.45; }
.path-color { width: 48px; height: 28px; padding: 0; border: 1px solid #3a4963; background: transparent; cursor: pointer; }
.path-help { margin: 12px 0; color: #7d89a2; font-size: 11px; line-height: 1.55; }
.path-help > span { color: #aeb8cf; }
+28 -3
View File
@@ -68,7 +68,7 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { Application, Container, Graphics, Text, Texture } from 'pixi.js'
import { ParticleEmitter, ensureEmitterConfig, type ParticleState } from '../core/particleEmitter'
import { useParticleStore, type ScenePath } from '../store/particleStore'
import { useParticleStore, type CollisionBody, type ScenePath } from '../store/particleStore'
import { SpineRuntimeLayer } from '../spine/SpineRuntimeLayer'
const store = useParticleStore()
@@ -108,6 +108,7 @@ let dotTex: Texture<any> | null = null
let trailTex: Texture<any> | null = null
const emitterMap = new Map<number, ParticleEmitter>()
let previewColliders: CollisionBody[] = []
const emitterPreviewTransform = new Map<number, { x: number; y: number; rotation: number }>()
const selectedPathNodes = new Set<number>()
let suppressNextPathDoubleClick = false
@@ -908,7 +909,7 @@ function drawCollisionBodies() {
if (!collisionGfx) return
const g = collisionGfx
g.clear()
for (const collider of store.colliders) {
for (const collider of previewColliders) {
const selected = store.activeObjectType === 'collision' && store.activeObjectId === collider.id
const color = collider.enabled ? (selected ? 0xff5e72 : 0xd4475b) : 0x586174
const angle = collider.rotation * Math.PI / 180
@@ -1050,6 +1051,29 @@ function sampleScenePath(path: ScenePath, progress: number) {
return { x, y, angle: Math.atan2(next.y - previous.y, next.x - previous.x) * 180 / Math.PI }
}
function buildPreviewColliders(timeSeconds: number) {
previewColliders = store.colliders.map((collider) => {
if (!collider.followPath || !collider.followPathId) return collider
const path = store.paths.find((item) => item.id === collider.followPathId && item.enabled)
if (!path?.points.length) return collider
const duration = Math.max(0.05, Number(collider.followDuration) || 2)
const normalized = Math.min(1, Math.max(0, timeSeconds / duration))
const curve = Array.isArray(collider.followCurve) ? collider.followCurve : [{ x: 0, y: 0 }, { x: 1, y: 1 }]
const progress = collider.followCurveMode === '线性'
? normalized
: Math.min(1, Math.max(0, followCurveValue(curve, normalized)))
const sample = sampleScenePath(path, progress)
return sample
? {
...collider,
x: sample.x,
y: sample.y,
rotation: collider.rotation + (collider.followPathRotation ? sample.angle : 0),
}
: collider
})
}
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))
@@ -1075,12 +1099,13 @@ function loop() {
syncEmitters()
// 根节点(root)变换:位置(centerX/centerY)=位移,rotation/scale 绕 root 中心,与场景对象参数联动
const followTime = store.timeline.frame / Math.max(1, store.timeline.fps)
buildPreviewColliders(followTime)
for (const sys of store.systems) {
const em = emitterMap.get(sys.id)
if (!em) continue
const c = sys.config
em.scale.set(c.rootScaleX, c.rootScaleY)
em.setColliders(store.colliders)
em.setColliders(previewColliders)
em.setEmitterFollowSpawn(0, 0, 0)
let preview = { x: c.centerX, y: c.centerY, rotation: c.rootRotation }
const followPath = c.emitterFollow && c.emitterFollowMode === 'path'