加入碰撞和粒子发射路径

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
-1239
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1239
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -8,8 +8,8 @@
html, body { margin: 0; padding: 0; background: #111; color: #eee; font-family: system-ui, -apple-system, sans-serif; }
#app { display: flex; flex-direction: column; height: 100vh; }
</style>
<script type="module" crossorigin src="/assets/index-B-Do_aFq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CjobLXfp.css">
<script type="module" crossorigin src="/assets/index-qzW_me3K.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D3l7sHON.css">
</head>
<body>
<div id="app"></div>
+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'