阶段七落盘文档
This commit is contained in:
@@ -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 当前帧
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user