加入碰撞和粒子发射路径

This commit is contained in:
tianmo
2026-09-05 18:18:05 +08:00
parent 026ee74d48
commit 72d8daa283
12 changed files with 1365 additions and 1276 deletions
+13 -2
View File
@@ -325,8 +325,9 @@ export interface EmitterConfig {
/** 蒙皮网格拓扑尺寸 */
trailGridRows: number
trailGridCols: number
/** 开启碰撞(预留) */
/** 开启碰撞;仅 collisionColliderIds 中显式选中的场景碰撞体参与计算。 */
collision: boolean
collisionColliderIds: number[]
/** 开启路径跟随(预留) */
pathFollow: boolean
emitterFollow: boolean
@@ -609,6 +610,7 @@ export function defaultConfig(): EmitterConfig {
trailGridRows: 4,
trailGridCols: 4,
collision: false,
collisionColliderIds: [],
pathFollow: false,
emitterFollow: false,
emitterFollowMode: 'none',
@@ -685,6 +687,15 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
config.innerRectH = Math.max(0, Number(config.innerRectH) || 0)
config.outerRectW = Math.max(config.innerRectW, Number(config.outerRectW) || 0)
config.outerRectH = Math.max(config.innerRectH, Number(config.outerRectH) || 0)
const normalizedCollisionColliderIds = Array.from(new Set(
(Array.isArray(config.collisionColliderIds) ? config.collisionColliderIds : [])
.map((id) => Math.round(Number(id)))
.filter((id) => Number.isFinite(id) && id > 0),
))
if (normalizedCollisionColliderIds.length !== config.collisionColliderIds.length
|| normalizedCollisionColliderIds.some((id, index) => id !== config.collisionColliderIds[index])) {
config.collisionColliderIds = normalizedCollisionColliderIds
}
if (config.streamBehavior !== 'normal' && config.streamBehavior !== 'loop') config.streamBehavior = 'normal'
if (needsLoopDurationMigration || !Number.isFinite(config.loopDurationFrames)) config.loopDurationFrames = Math.ceil(Math.max(1 / 30, Number(config.duration) || 1) * 30)
config.loopDurationFrames = Math.min(1800, Math.max(1, Math.ceil(config.loopDurationFrames)))
@@ -1013,7 +1024,7 @@ export class ParticleEmitter extends Container {
}
private applyColliders(p: Particle, particleIndex: number) {
if (!this.colliders.length) return false
if (!this.cfg.collision || !this.colliders.length) return false
const cfg = this.cfg
let world = localToWorldMath(cfg, p.x, p.y, this.simulationTransform)
let velocity = localVelocityToWorldMath(cfg, p.vx, p.vy, this.simulationTransform)
+9 -2
View File
@@ -76,6 +76,13 @@ function jsonClone<T>(value: T): T {
})) as T
}
function withoutLegacyColliderTags(collider: CollisionBody): CollisionBody {
const copy = cloneEditorValue(collider) as CollisionBody & { tags?: unknown; selectedTag?: unknown }
delete copy.tags
delete copy.selectedTag
return copy
}
export function createEditorConfig(store: EditorStore): EditorConfigFile {
const settings = captureProjectSettings(store.settings)
return jsonClone({
@@ -97,7 +104,7 @@ export function createEditorConfig(store: EditorStore): EditorConfigFile {
},
scene: {
systems: store.systems.map((system) => ({ ...cloneEditorValue(system), frames: undefined })) as unknown as ParticleSystem[],
colliders: cloneEditorValue(store.colliders) as unknown as CollisionBody[],
colliders: store.colliders.map(withoutLegacyColliderTags),
paths: cloneEditorValue(store.paths) as unknown as ScenePath[],
spines: cloneEditorValue(store.spines) as unknown as SpineSceneObject[],
},
@@ -210,7 +217,7 @@ function repairSelection(store: EditorStore, requested?: EditorConfigFile['selec
export async function applyEditorConfig(store: EditorStore, input: EditorConfigFile) {
const config = parseEditorConfig(JSON.stringify(input))
const systems = cloneEditorValue(config.scene.systems) as unknown as ParticleSystem[]
const colliders = cloneEditorValue(config.scene.colliders) as unknown as CollisionBody[]
const colliders = config.scene.colliders.map(withoutLegacyColliderTags)
const paths = cloneEditorValue(config.scene.paths) as unknown as ScenePath[]
const spines = cloneEditorValue(config.scene.spines) as unknown as SpineSceneObject[]
for (const spine of spines) {
+5 -5
View File
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { EmitterConfig, defaultConfig, ParticleState, type SceneCollider } from '../core/particleEmitter'
import { EmitterConfig, defaultConfig, ensureEmitterConfig, ParticleState, type SceneCollider } from '../core/particleEmitter'
import type { SpineSceneObject } from '../spine/spineTypes'
import { releaseSpineAsset, retainSpineAsset } from '../spine/spineAssetRegistry'
import { cloneEditorValue } from '../editor/editorClone'
@@ -20,8 +20,6 @@ export interface ParticleSystem {
export interface CollisionBody extends SceneCollider {
name: string
visible: boolean
tags: string[]
selectedTag: string
followPath: boolean
followPathId: number
followDuration: number
@@ -254,8 +252,6 @@ export const useParticleStore = defineStore('particle', {
id,
name: `Collider${id}`,
visible: true,
tags: ['Default'],
selectedTag: 'Default',
shape: 'circle',
enabled: true,
x: 0,
@@ -402,6 +398,10 @@ export const useParticleStore = defineStore('particle', {
removeCollider(id: number) {
const index = this.colliders.findIndex((item) => item.id === id)
if (index >= 0) this.colliders.splice(index, 1)
for (const system of this.systems) {
const config = ensureEmitterConfig(system.config as any)
config.collisionColliderIds = config.collisionColliderIds.filter((colliderId) => colliderId !== id)
}
if (this.activeObjectType === 'collision' && this.activeObjectId === id) {
const fallback = this.systems[0]
this.activeObjectType = fallback ? 'particle' : 'collision'
+90 -22
View File
@@ -118,13 +118,6 @@
<template v-else-if="activeCollider">
<div class="object-editor-title">碰撞体属性</div>
<label class="field-block"><span>碰撞体名称</span><input v-model="activeCollider.name" class="inp" /></label>
<div class="field-block"><span>碰撞体标签</span>
<div class="tag-editor">
<button v-for="tagName in activeCollider.tags" :key="tagName" class="object-tag" :class="{ on: activeCollider.selectedTag === tagName }" @click="activeCollider.selectedTag = tagName">{{ tagName }}</button>
<button class="tag-add" title="新增标签" @click="addColliderTag"></button>
</div>
<small>点击标签为当前碰撞体选择标签点击新增标签</small>
</div>
<div class="field-grid">
<label class="field-block"><span>类型</span><select v-model="activeCollider.shape" class="inp">
<option value="circle">圆形</option><option value="ellipse">椭圆</option><option value="rect">矩形</option><option value="polygon">多边形</option><option value="custom">自定义</option>
@@ -865,6 +858,48 @@
</template>
</div>
</div>
<div class="modifier-item">
<label class="switch-row modifier-switch">
<input type="checkbox" v-model="sys.config.collision" />
<span class="switch-ui"></span><span>开启碰撞</span>
</label>
<div v-if="sys.config.collision" class="modifier-params collision-filter-params">
<div class="collision-filter-title">参与的碰撞对象</div>
<div class="collision-filter-tags">
<span
v-for="collider in selectedCollisionBodies"
:key="collider.id"
class="collision-filter-tag"
:class="{ disabled: !collider.enabled }"
:title="collider.enabled ? collider.name : `${collider.name}(碰撞体已禁用)`"
>
<span>{{ collider.name }}</span>
<button type="button" :aria-label="`清除碰撞对象 ${collider.name}`" @click="removeCollisionBody(collider.id)">×</button>
</span>
<button
type="button"
class="collision-filter-add"
:class="{ active: collisionPickerOpen }"
:disabled="availableCollisionBodies.length === 0"
:title="availableCollisionBodies.length ? '加入场景碰撞对象' : '没有可加入的碰撞对象'"
@click="collisionPickerOpen = !collisionPickerOpen"
></button>
</div>
<div v-if="selectedCollisionBodies.length === 0" class="collision-filter-empty">未选择碰撞对象当前粒子不会发生碰撞</div>
<div v-if="collisionPickerOpen && availableCollisionBodies.length" class="collision-filter-picker">
<button
v-for="collider in availableCollisionBodies"
:key="collider.id"
type="button"
@click="addCollisionBody(collider.id)"
>
<span>{{ collider.name }}</span>
<small>{{ collider.enabled ? '可用' : '已禁用' }}</small>
</button>
</div>
<div class="modifier-hint">只有已加入且启用的场景碰撞对象会参与当前粒子系统的碰撞</div>
</div>
</div>
<NumSlider label="阻尼" :min="0" :max="2" :step="0.05" v-model="sys.config.damping" />
</div>
</div>
@@ -955,7 +990,7 @@
</template>
<script setup lang="ts">
import { computed, markRaw, onBeforeUnmount, onMounted, reactive, ref, watchEffect } from 'vue'
import { computed, markRaw, onBeforeUnmount, onMounted, reactive, ref, watch, watchEffect } from 'vue'
import { Texture } from 'pixi.js'
import { useParticleStore } from '../store/particleStore'
import type { ParticleSystem } from '../store/particleStore'
@@ -1246,6 +1281,32 @@ const boundSpineBone = computed(() => {
if (!spine || !spine.bones.some((bone) => bone.name === config.emitterFollowBoneName)) return null
return { spine, boneName: config.emitterFollowBoneName }
})
const collisionPickerOpen = ref(false)
const selectedCollisionBodies = computed(() => {
const ids = new Set(sys.value?.config.collisionColliderIds || [])
return store.colliders.filter((collider) => ids.has(collider.id))
})
const availableCollisionBodies = computed(() => {
const ids = new Set(sys.value?.config.collisionColliderIds || [])
return store.colliders.filter((collider) => !ids.has(collider.id))
})
watch(() => [sys.value?.id, sys.value?.config.collision], () => {
collisionPickerOpen.value = false
})
function addCollisionBody(colliderId: number) {
const config = sys.value?.config
if (!config || config.collisionColliderIds.includes(colliderId)) return
config.collisionColliderIds.push(colliderId)
collisionPickerOpen.value = false
}
function removeCollisionBody(colliderId: number) {
const config = sys.value?.config
if (!config) return
config.collisionColliderIds = config.collisionColliderIds.filter((id) => id !== colliderId)
}
// 兼容开发热更新和旧场景:缺少显隐字段的对象按“显示”处理。
watchEffect(() => {
@@ -1273,8 +1334,11 @@ watchEffect(() => {
// 每个粒子系统永久保存自己的 Spine ID 与骨骼名;层级树当前选择不会覆盖既有绑定。
// 仅当已绑定的 Spine 或骨骼确实不存在时,才回到“无”模式。
watchEffect(() => {
const colliderIds = new Set(store.colliders.map((collider) => collider.id))
for (const system of store.systems) {
const config = ensureEmitterConfig(system.config as any)
const validCollisionIds = config.collisionColliderIds.filter((id) => colliderIds.has(id))
if (validCollisionIds.length !== config.collisionColliderIds.length) config.collisionColliderIds = validCollisionIds
if (config.spawnPathId > 0 && !store.paths.some((path) => path.id === config.spawnPathId)) {
config.spawnPathId = 0
if (config.emissionAngleMode === 'path-normal' || config.emissionAngleMode === 'path-tangent') config.emissionAngleMode = 'fixed'
@@ -1474,7 +1538,7 @@ const MODULE_RESET_KEYS: Record<Exclude<ResetModule, 'export'>, string[]> = {
'trailColorGradient', 'trailAlphaMode', 'trailAlpha', 'trailAlphaCurve',
'trailBoneCount', 'trailLength', 'trailWidth', 'trailWidthMode',
'trailLifeLengthEnabled', 'trailLifeLengthCurve', 'trailShapeEnabled', 'trailShapeCurve',
'trailGridRows', 'trailGridCols', 'collision', 'pathFollow',
'trailGridRows', 'trailGridCols', 'collision', 'collisionColliderIds', 'pathFollow',
'emitterFollow', 'emitterFollowMode', 'emitterFollowPathId',
'emitterFollowSpineId', 'emitterFollowBoneName',
'emitterFollowBoneOffsetX', 'emitterFollowBoneOffsetY', 'emitterFollowBoneRotation',
@@ -1506,15 +1570,6 @@ function addCollider() { store.addCollider() }
function addPath() { store.addPath() }
function addSpine() { store.addSpine() }
function copySelectedObject() { store.duplicateSelectedObject() }
function addColliderTag() {
const collider = activeCollider.value
if (!collider) return
let index = collider.tags.length + 1
let name = `Tag ${index}`
while (collider.tags.includes(name)) name = `Tag ${++index}`
collider.tags.push(name)
collider.selectedTag = name
}
function pathAnchor(x: number, y: number, inX = 0, inY = 0, outX = 0, outY = 0) {
return { x, y, inX, inY, outX, outY }
}
@@ -2082,10 +2137,6 @@ function toggleResourceLock(resourceId: number) {
.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; }
.object-tag, .tag-add { height: 26px; padding: 0 8px; border: 1px solid #3b4861; border-radius: 5px; background: #242d40; color: #aeb8cf; cursor: pointer; }
.object-tag.on { border-color: #7774ff; background: #514fd0; color: #fff; }
.tag-add { width: 28px; padding: 0; border-style: dashed; font-size: 17px; }
.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; }
@@ -2235,6 +2286,23 @@ function toggleResourceLock(resourceId: number) {
.modifier-switch { font-weight: 600; }
.modifier-params { margin: 8px 0 0 10px; padding: 2px 0 2px 10px; border-left: 2px solid #344158; }
.modifier-hint { margin: 7px 0 0 2px; color: #647086; font-size: 9px; line-height: 1.5; }
.collision-filter-params { position: relative; padding-top: 5px; padding-bottom: 5px; }
.collision-filter-title { margin-bottom: 8px; color: #9ba7bd; font-size: 11px; font-weight: 600; }
.collision-filter-tags { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.collision-filter-tag { display: inline-flex; min-width: 0; align-items: center; gap: 6px; padding: 5px 5px 5px 8px; border: 1px solid #625ff1; border-radius: 6px; background: #514dcf; color: #fff; font-size: 11px; }
.collision-filter-tag > span { max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.collision-filter-tag.disabled { border-color: #4b5364; background: #343b4b; color: #929bad; }
.collision-filter-tag button { width: 18px; height: 18px; padding: 0; border: 0; border-radius: 4px; background: rgba(11, 15, 26, 0.22); color: inherit; line-height: 16px; cursor: pointer; }
.collision-filter-tag button:hover { background: rgba(11, 15, 26, 0.5); color: #fff; }
.collision-filter-add { width: 30px; height: 30px; padding: 0; border: 1px dashed #536079; border-radius: 6px; background: #182131; color: #98a5bd; font-size: 18px; line-height: 28px; cursor: pointer; }
.collision-filter-add:hover, .collision-filter-add.active { border-color: #7774ff; background: #262c52; color: #fff; }
.collision-filter-add:disabled { opacity: 0.42; cursor: not-allowed; }
.collision-filter-empty { margin-top: 7px; color: #6f7b91; font-size: 10px; }
.collision-filter-picker { display: grid; gap: 4px; margin-top: 7px; padding: 5px; border: 1px solid #344158; border-radius: 6px; background: #101725; }
.collision-filter-picker button { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 8px; border: 0; border-radius: 4px; background: transparent; color: #c7d0e3; font-size: 11px; text-align: left; cursor: pointer; }
.collision-filter-picker button:hover { background: #29344a; color: #fff; }
.collision-filter-picker button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.collision-filter-picker button small { flex: none; color: #68758d; font-size: 9px; }
.stream-loop-hint { margin: 9px 2px 3px; padding: 7px 8px; border: 1px solid #4a436e; border-radius: 5px; background: #211f35; color: #aaa4d7; }
.switch-row input:disabled + .switch-ui { opacity: 0.45; cursor: not-allowed; }
.bone-follow-target { margin: 7px 0 9px; padding: 7px 8px; overflow: hidden; color: #7f8ba3; background: #121a29; border: 1px solid #2f3c53; border-radius: 5px; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
+4 -1
View File
@@ -1442,7 +1442,10 @@ function loop() {
em.renderable = visible
const c = sys.config
em.scale.set(c.rootScaleX, c.rootScaleY)
em.setColliders(previewColliders)
const collisionIds = new Set(c.collisionColliderIds)
em.setColliders(c.collision
? previewColliders.filter((collider) => collisionIds.has(collider.id))
: [])
em.setEmitterFollowSpawn(0, 0, 0)
let preview = { x: c.centerX, y: c.centerY, rotation: c.rootRotation }
const followSpine = c.emitterFollow && c.emitterFollowMode === 'bone'