阶段七落盘文档
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'
|
||||
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
# 网页粒子系统 · 阶段七
|
||||
|
||||
> 阶段定位:**Spine 4.2 骨架资源接入、统一场景变换与碰撞体路径跟随定版**
|
||||
> 完成日期:2026-08-29
|
||||
> 工程目录:`/Users/tianmokeji/Desktop/SpineParticle`
|
||||
> 技术栈:Vue 3 + TypeScript + Pinia + PixiJS 7 + Spine Runtime 4.2 + Vite
|
||||
|
||||
---
|
||||
|
||||
## 一、阶段结论
|
||||
|
||||
阶段七完成了 Spine 骨架场景对象的第一版完整接入,并补齐了时间轴、场景变换和碰撞体路径跟随能力。Spine 资源现在可以离线加载、显示、选择动画、查看骨骼层级,并与粒子系统共同受统一的 30 FPS 时间轴控制。
|
||||
|
||||
本阶段主要完成:
|
||||
|
||||
1. 接入 Spine 官方 4.2 PixiJS 7 运行时并离线落盘;
|
||||
2. 实现 Spine 骨架场景对象及独立属性面板;
|
||||
3. 支持 JSON、SKEL、Atlas 和多张纹理资源加载;
|
||||
4. 自动读取动画列表、动画时长和骨骼层级;
|
||||
5. Spine 动画关闭自身循环,改由编辑器时间轴统一驱动;
|
||||
6. 时间轴增加紫色 Spine 动画条和逐帧控制按钮;
|
||||
7. 修复动画按浏览器刷新次数推进导致的播放过快问题;
|
||||
8. 统一画布变换工具,使其作用于场景树当前选中的对象;
|
||||
9. 为碰撞体和路径补充缩放变换;
|
||||
10. 实现碰撞体沿路径移动、速度曲线和方向跟随;
|
||||
11. 统一“路径”术语,移除重复的移动轨迹显示开关;
|
||||
12. 完成旧场景字段补齐和热更新兼容处理。
|
||||
|
||||
---
|
||||
|
||||
## 二、Spine 官方运行时接入
|
||||
|
||||
### 2.1 运行时来源
|
||||
|
||||
项目使用 Spine 官方仓库中的 4.2 兼容版本:
|
||||
|
||||
| 项目 | 内容 |
|
||||
|---|---|
|
||||
| 官方仓库 | `https://github.com/EsotericSoftware/spine-runtimes` |
|
||||
| 兼容分支 | `4.2` |
|
||||
| 固定提交 | `b81e5a58ed38704aee4f866f0e0ac672623ce914` |
|
||||
| `spine-core` | `4.2.119` |
|
||||
| `spine-pixi-v7` | `4.2.119` |
|
||||
| 渲染目标 | PixiJS 7 |
|
||||
|
||||
运行时代码放置在 `vendor/spine/`,通过 `file:` 依赖接入:
|
||||
|
||||
```text
|
||||
@esotericsoftware/spine-core -> vendor/spine/spine-core
|
||||
@esotericsoftware/spine-pixi-v7 -> vendor/spine/spine-pixi-v7
|
||||
```
|
||||
|
||||
因此项目构建时不需要从 npm 在线下载 Spine 运行时代码。版本来源、提交号和许可提示记录在 `vendor/spine/SOURCE.md`,官方许可证保存在同目录中。
|
||||
|
||||
### 2.2 代码拆分
|
||||
|
||||
Spine 功能没有继续堆叠到主面板和主画布文件中,而是拆分为:
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `spineTypes.ts` | Spine 场景对象、动画和骨骼元数据类型 |
|
||||
| `spineAssetLoader.ts` | 读取骨架、Atlas 和纹理并生成 SkeletonData |
|
||||
| `spineAssetRegistry.ts` | 管理运行时资源和纹理释放 |
|
||||
| `SpineRuntimeLayer.ts` | 创建 Spine 显示对象并按编辑器帧同步动画 |
|
||||
| `SpineObjectPanel.vue` | 资源选择、属性、动画列表和层级树界面 |
|
||||
|
||||
这种拆分让主粒子系统逻辑与第三方骨架运行时保持边界,便于后续增加插槽、皮肤、混合和导出功能。
|
||||
|
||||
---
|
||||
|
||||
## 三、Spine 场景对象
|
||||
|
||||
### 3.1 创建与默认状态
|
||||
|
||||
点击场景对象模块中的 `+骨架` 会创建一个 Spine 对象,并自动成为当前选中对象。
|
||||
|
||||
| 参数 | 默认值 |
|
||||
|---|---:|
|
||||
| 名称 | `SpineSkeleton1` |
|
||||
| 启用 | 开启 |
|
||||
| X / Y | 0 / 0 |
|
||||
| 旋转 | 0° |
|
||||
| Scale X / Y | 1 / 1 |
|
||||
| 开始时间 | 0 秒 |
|
||||
| 资源状态 | 未加载 |
|
||||
| 当前动画 | 无 |
|
||||
|
||||
Spine 对象与粒子系统、碰撞体和路径统一显示在场景对象树中,支持选择和删除。删除对象时会同步释放已注册的 Spine 资源和纹理引用。
|
||||
|
||||
### 3.2 支持的资源
|
||||
|
||||
加载一套 Spine 资源时需要选择:
|
||||
|
||||
- 骨架数据:`.json` 或 `.skel`;
|
||||
- 图集描述:`.atlas`;
|
||||
- 纹理图片:`.png`、`.jpg`、`.jpeg`、`.webp`,允许多选;
|
||||
- 兼容文件选择器中的 `.wepb` 拼写。
|
||||
|
||||
加载器按 Atlas 页面的文件名匹配所选纹理。如果 Atlas 引用了未选择的纹理,会给出明确错误,不会创建不完整的骨架对象。
|
||||
|
||||
### 3.3 测试资源
|
||||
|
||||
项目 `public/spine/` 中的 Spine 4.2 测试资源用于本阶段验证:
|
||||
|
||||
- `bingo.json`;
|
||||
- `bingo.atlas`;
|
||||
- `bingo.png`;
|
||||
- `bingo_2.png`。
|
||||
|
||||
测试覆盖了 JSON 骨架、多纹理 Atlas、动画列表读取、画布渲染和骨骼层级展示。
|
||||
|
||||
---
|
||||
|
||||
## 四、动画与骨骼层级
|
||||
|
||||
### 4.1 动画列表
|
||||
|
||||
资源加载成功后,面板会从 SkeletonData 中读取全部动画:
|
||||
|
||||
- 显示动画名称;
|
||||
- 显示真实动画时长,单位为秒;
|
||||
- 支持选择“无”或任意动画;
|
||||
- 默认选择资源中的第一个动画;
|
||||
- 切换动画后重新计算时间轴总长度并回到第 0 帧。
|
||||
|
||||
Spine 动画自身不循环播放。动画到达末尾后停留在末帧,是否重新从头播放由编辑器总时间轴的循环按钮决定。
|
||||
|
||||
### 4.2 骨骼层级树
|
||||
|
||||
加载成功后同时读取骨骼名称、父级关系和深度,按层级显示为树结构。
|
||||
|
||||
层级树支持:
|
||||
|
||||
- 点击父骨骼前的箭头折叠或展开子级;
|
||||
- “全部折叠”和“全部展开”;
|
||||
- 折叠父级后隐藏其全部后代;
|
||||
- 长层级树在固定高度区域内滚动。
|
||||
|
||||
该树当前用于查看资源结构,尚未提供骨骼选择、单骨骼变换或插槽编辑。
|
||||
|
||||
### 4.3 Spine 资源显示设置
|
||||
|
||||
原属性面板中容易误解的“启用骨架显示”已改为系统设置中的“启用 Spine 资源显示”。
|
||||
|
||||
- 已加载包含动画的 Spine 资源时,可以全局显示或隐藏 Spine 资源;
|
||||
- 没有可用 Spine 动画时,该设置置灰;
|
||||
- 该开关只控制画布中的 Spine 资源,不影响编辑器自身的粒子拖尾骨骼、骨骼连线或蒙皮网格调试显示;
|
||||
- 对象自身的 `enabled` 状态仍会独立参与最终可见性判断。
|
||||
|
||||
---
|
||||
|
||||
## 五、统一时间轴
|
||||
|
||||
### 5.1 真实时间驱动
|
||||
|
||||
时间轴继续使用统一的 **30 FPS** 基准:
|
||||
|
||||
```text
|
||||
1 秒 = 30 帧
|
||||
当前动画时间 = 当前帧 / 30 - Spine 开始时间
|
||||
```
|
||||
|
||||
修复前,Spine 动画会在浏览器每次刷新画面时前进一帧。高刷新率屏幕上动画会明显快于 Spine 编辑器中的同一动画。
|
||||
|
||||
现在使用实际经过时间累积,并按 `1/30` 秒的固定步长推进显示帧。浏览器刷新率只影响画面刷新平滑度,不再改变动画播放速度。
|
||||
|
||||
### 5.2 Spine 紫色动画条
|
||||
|
||||
选择有效 Spine 动画后,时间轴新增紫色动画条:
|
||||
|
||||
- 条带长度来自 Spine 动画的真实时长;
|
||||
- 长度不可拖动修改;
|
||||
- 整条可以左右拖动;
|
||||
- 左侧空白表示延迟播放时间;
|
||||
- 拖动以 1 帧为单位吸附;
|
||||
- 没有动画数据或当前动画为“无”时不显示。
|
||||
|
||||
Spine 动画、粒子系统和碰撞体路径移动共同参与总时间轴长度计算:
|
||||
|
||||
```text
|
||||
总时长 = max(
|
||||
粒子系统最晚消失时间,
|
||||
Spine 开始时间 + Spine 动画时长,
|
||||
碰撞体路径移动时长
|
||||
)
|
||||
```
|
||||
|
||||
### 5.3 播放控制
|
||||
|
||||
时间轴控制区现在包括:
|
||||
|
||||
| 控制 | 行为 |
|
||||
|---|---|
|
||||
| 播放 / 暂停 | 切换整体时间轴播放状态 |
|
||||
| 回到第一帧 | 跳转到第 0 帧 |
|
||||
| 前一帧 | 自动暂停并后退 1 帧 |
|
||||
| 后一帧 | 自动暂停并前进 1 帧 |
|
||||
| 移动到最后一帧 | 自动暂停并跳转到最后帧 |
|
||||
| 循环 | 控制整体时间轴到达末尾后的行为 |
|
||||
|
||||
这些操作统一影响粒子模拟、Spine 动画和碰撞体路径移动。
|
||||
|
||||
---
|
||||
|
||||
## 六、场景对象变换统一
|
||||
|
||||
### 6.1 当前选中对象
|
||||
|
||||
画布左上角的位移、旋转和缩放工具不再固定操作粒子系统,而是读取场景树中的当前选中对象。
|
||||
|
||||
支持的对象包括:
|
||||
|
||||
- 粒子系统;
|
||||
- 碰撞体;
|
||||
- 路径;
|
||||
- Spine 骨架。
|
||||
|
||||
选择 Spine 对象后,变换工具只修改该 Spine 对象;选择碰撞体或路径后,同样只影响选中对象。
|
||||
|
||||
### 6.2 缩放补齐
|
||||
|
||||
碰撞体新增 `scaleX`、`scaleY`,路径继续使用独立的 `scaleX`、`scaleY`。两类对象均可通过属性面板或画布缩放工具修改。
|
||||
|
||||
路径的位移、旋转和缩放会同时作用于:
|
||||
|
||||
- 路径曲线显示;
|
||||
- 锚点和贝塞尔手柄;
|
||||
- 发射器路径采样;
|
||||
- 碰撞体路径跟随。
|
||||
|
||||
碰撞体缩放同时影响画布轮廓和实际粒子碰撞计算。
|
||||
|
||||
---
|
||||
|
||||
## 七、碰撞体路径跟随
|
||||
|
||||
阶段六预留的“跟随路径移动”在本阶段完成运行时实现。
|
||||
|
||||
### 7.1 默认参数
|
||||
|
||||
| 参数 | 默认值 |
|
||||
|---|---:|
|
||||
| 跟随路径移动 | 关闭 |
|
||||
| 目标路径 | 未选择 |
|
||||
| 移动时长 | 2 秒 |
|
||||
| 曲线模式 | 跟随导出 |
|
||||
| 移动进度曲线 | 0→1 线性 |
|
||||
| 方向沿路径 | 关闭 |
|
||||
|
||||
只有开启跟随并选择一条已启用且包含锚点的路径后,碰撞体才会开始移动。无效或已删除的目标路径不会改变碰撞体原始位置。
|
||||
|
||||
### 7.2 移动计算
|
||||
|
||||
```text
|
||||
归一化时间 = clamp(当前时间 / 移动时长, 0, 1)
|
||||
路径进度 = 移动进度曲线(归一化时间)
|
||||
碰撞体位置 = 路径按长度采样(路径进度)
|
||||
```
|
||||
|
||||
路径采样会考虑路径对象自身的位移、旋转和缩放。每段三次贝塞尔曲线先离散采样并累计实际距离,再按总长度查找位置,减少不同曲线段参数长度不一致造成的明显速度变化。
|
||||
|
||||
面板提供三种曲线模式:
|
||||
|
||||
- 跟随导出;
|
||||
- 线性;
|
||||
- 贝塞尔。
|
||||
|
||||
曲线编辑器横轴表示实时除以移动时长,纵轴表示路径进度。到达移动时长后,碰撞体停留在路径终点;编辑器时间轴循环时再从起点开始。
|
||||
|
||||
### 7.3 方向沿路径
|
||||
|
||||
“方向沿路径”默认关闭:
|
||||
|
||||
- 关闭时,碰撞体移动过程中保持自己的旋转值;
|
||||
- 开启时,根据路径当前位置前后采样点计算切线方向;
|
||||
- 碰撞体属性中的旋转值作为附加角度叠加,可继续微调朝向。
|
||||
|
||||
圆形碰撞体视觉上不容易观察旋转变化,矩形、椭圆和多边形更适合检查该功能。
|
||||
|
||||
### 7.4 碰撞运行时
|
||||
|
||||
路径跟随不仅改变画布上的碰撞体预览位置,也会把同一份动态位置和旋转传给粒子碰撞检测。因此粒子会与移动中的碰撞体发生真实交互,物理、暂停和销毁三种响应保持有效。
|
||||
|
||||
运行时使用预览副本计算动态位置,不会持续覆盖碰撞体存储的基础 X、Y 和旋转值。关闭跟随后,碰撞体会恢复到属性面板中设置的基础变换。
|
||||
|
||||
路径对象本身始终在画布显示,因此移除了重复的“显示移动轨迹线”开关和额外轨迹覆盖层。
|
||||
|
||||
---
|
||||
|
||||
## 八、术语与兼容性
|
||||
|
||||
### 8.1 路径术语统一
|
||||
|
||||
用于贝塞尔对象和跟随功能的界面术语统一为“路径”:
|
||||
|
||||
- 跟随路径移动;
|
||||
- 目标路径;
|
||||
- 路径进度;
|
||||
- 路径名称;
|
||||
- 闭合路径;
|
||||
- 路径颜色;
|
||||
- 发射器跟随路径。
|
||||
|
||||
发射器形状中的“轨道”仍表示粒子发射形状 `orbit`,不是场景路径对象,因此保留原名称。
|
||||
|
||||
### 8.2 旧数据补齐
|
||||
|
||||
旧场景或热更新状态中的碰撞体可能缺少路径跟随字段。面板会即时补齐:
|
||||
|
||||
- 目标路径;
|
||||
- 移动时长;
|
||||
- 曲线模式;
|
||||
- 移动进度曲线;
|
||||
- 方向沿路径。
|
||||
|
||||
曲线组件同时提供空值回退,避免旧数据加载或热更新瞬间因曲线为空导致界面渲染失败。
|
||||
|
||||
---
|
||||
|
||||
## 九、验证结果
|
||||
|
||||
本阶段完成以下验证:
|
||||
|
||||
- Spine 官方 4.2.119 本地运行时依赖和生产构建;
|
||||
- JSON、Atlas 和两张 PNG 测试资源加载;
|
||||
- 动画名称、真实时长和骨骼层级读取;
|
||||
- Spine 动画非循环播放及末帧保持;
|
||||
- 30 FPS 真实时间推进,不受浏览器刷新率影响;
|
||||
- 紫色 Spine 时间轴条长度和开始时间拖动;
|
||||
- 前一帧、后一帧和最后一帧自动暂停;
|
||||
- 骨骼层级单节点折叠和全部折叠;
|
||||
- Spine 资源全局显示开关和无动画时置灰;
|
||||
- 当前选中粒子、碰撞体、路径和 Spine 对象的画布变换;
|
||||
- 碰撞体和路径缩放与运行时表现同步;
|
||||
- 碰撞体目标路径、移动时长、进度曲线和方向开关;
|
||||
- 移动碰撞体参与实际粒子碰撞;
|
||||
- 路径术语统一及重复轨迹开关移除;
|
||||
- 旧碰撞体字段和曲线空值兼容;
|
||||
- 页面面板交互检查;
|
||||
- `npm run build` 生产构建通过;
|
||||
- `git diff --check` 通过。
|
||||
|
||||
构建仍有 Vite 对主产物体积超过 500 kB 的提示,不影响当前功能和构建结果,后续可通过按模块动态加载进一步优化。
|
||||
|
||||
---
|
||||
|
||||
## 十、当前限制
|
||||
|
||||
1. Spine 资源通过本地文件选择器加载,刷新页面后需要重新选择,尚未纳入工程保存格式;
|
||||
2. Spine 层级树当前只读,不能编辑骨骼、插槽、皮肤或约束;
|
||||
3. Spine 动画尚未支持混合、播放速度、反向和多轨道叠加;
|
||||
4. `.skel` 已接入官方二进制解析器,但本阶段主要使用 JSON 测试资源验证;
|
||||
5. 碰撞体路径跟随当前为单次起点到终点,尚未提供往返或独立循环模式;
|
||||
6. 自定义碰撞体仍使用规则多边形轮廓,尚未接入自由顶点编辑;
|
||||
7. 场景对象和资源尚未形成完整的保存、导入和导出格式。
|
||||
|
||||
---
|
||||
|
||||
## 十一、阶段八建议
|
||||
|
||||
1. 建立场景工程保存格式,持久化粒子、路径、碰撞体和 Spine 对象配置;
|
||||
2. 将 Spine 资源复制到工程资源目录,支持刷新后自动恢复;
|
||||
3. 增加 Spine 皮肤、插槽、混合时长和播放速度控制;
|
||||
4. 为碰撞体路径跟随增加循环、往返、反向和结束行为;
|
||||
5. 实现碰撞标签过滤和碰撞事件输出;
|
||||
6. 实现自定义碰撞体自由顶点编辑;
|
||||
7. 对 Spine 和曲线编辑器进行动态加载,降低主包体积;
|
||||
8. 建立可复现的场景回归测试,覆盖统一时间轴和多对象组合。
|
||||
Reference in New Issue
Block a user