加入碰撞和粒子发射路径
This commit is contained in:
Vendored
-1239
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+1239
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user