创建碰撞与路径
This commit is contained in:
+167
-7
@@ -14,6 +14,26 @@ export type ScaleAxisMode = 'uniform' | 'separate'
|
||||
export interface CurvePoint { x: number; y: number }
|
||||
export interface ColorGradientStop { position: number; color: string }
|
||||
|
||||
export type ColliderShape = 'circle' | 'ellipse' | 'rect' | 'polygon' | 'custom'
|
||||
export type CollisionResponse = 'physics' | 'pause' | 'destroy'
|
||||
export interface SceneCollider {
|
||||
id: number
|
||||
shape: ColliderShape
|
||||
enabled: boolean
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
radius: number
|
||||
radiusX: number
|
||||
radiusY: number
|
||||
width: number
|
||||
height: number
|
||||
sides: number
|
||||
response: CollisionResponse
|
||||
elasticity: number
|
||||
friction: number
|
||||
}
|
||||
|
||||
export interface ParticleImageResource {
|
||||
id: number
|
||||
texture: Texture<any> | null
|
||||
@@ -665,6 +685,7 @@ interface Particle {
|
||||
trailCurrentColor: number
|
||||
trailResourceId: number
|
||||
resourceId: number
|
||||
collisionPaused: boolean
|
||||
}
|
||||
|
||||
const BLEND_MAP: Record<Blend, number> = { normal: 0, add: 1, multiply: 2, screen: 3 }
|
||||
@@ -689,6 +710,7 @@ export class ParticleEmitter extends Container {
|
||||
private rng: () => number = Math.random
|
||||
private _lastSeed = -1
|
||||
private trailDebugGfx = new Graphics()
|
||||
private colliders: SceneCollider[] = []
|
||||
|
||||
constructor(config: EmitterConfig, maxParticles = MAX_PARTICLES) {
|
||||
super()
|
||||
@@ -737,11 +759,50 @@ export class ParticleEmitter extends Container {
|
||||
trailCurrentColor: 0xffffff,
|
||||
trailResourceId: 1,
|
||||
resourceId: 1,
|
||||
collisionPaused: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
setEmitterPos(fn: () => IPointData) { this._emitterPos = fn }
|
||||
setColliders(colliders: SceneCollider[]) { this.colliders = colliders }
|
||||
|
||||
private applyColliders(p: Particle, particleIndex: number) {
|
||||
if (!this.colliders.length) return false
|
||||
const cfg = this.cfg
|
||||
let world = localToWorldMath(cfg, p.x, p.y)
|
||||
let velocity = localVelocityToWorldMath(cfg, p.vx, p.vy)
|
||||
for (const collider of this.colliders) {
|
||||
if (!collider.enabled) continue
|
||||
const hit = resolveColliderPoint(collider, world.x, world.y)
|
||||
if (!hit) continue
|
||||
if (collider.response === 'destroy') {
|
||||
this.kill(particleIndex)
|
||||
return true
|
||||
}
|
||||
world = { x: hit.x, y: hit.y }
|
||||
if (collider.response === 'pause') {
|
||||
p.collisionPaused = true
|
||||
velocity = { x: 0, y: 0 }
|
||||
} else {
|
||||
const normalSpeed = velocity.x * hit.nx + velocity.y * hit.ny
|
||||
const tangentX = velocity.x - normalSpeed * hit.nx
|
||||
const tangentY = velocity.y - normalSpeed * hit.ny
|
||||
const elasticity = Math.min(1, Math.max(0, collider.elasticity))
|
||||
const tangentScale = 1 - Math.min(1, Math.max(0, collider.friction))
|
||||
const reflectedNormal = normalSpeed < 0 ? -normalSpeed * elasticity : normalSpeed
|
||||
velocity.x = tangentX * tangentScale + reflectedNormal * hit.nx
|
||||
velocity.y = tangentY * tangentScale + reflectedNormal * hit.ny
|
||||
}
|
||||
const local = worldMathToLocal(cfg, world.x, world.y)
|
||||
const localVelocity = worldVelocityToLocalMath(cfg, velocity.x, velocity.y)
|
||||
p.x = local.x
|
||||
p.y = local.y
|
||||
p.vx = localVelocity.x
|
||||
p.vy = localVelocity.y
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 每帧更新;返回活动粒子状态快照(与骨骼一一对应) */
|
||||
update(dt: number): ParticleState[] {
|
||||
@@ -791,7 +852,7 @@ export class ParticleEmitter extends Container {
|
||||
// 粒子自身生命周期优先:即使正在吸附途中,生命耗尽也立即死亡,不因吸附时长延寿。
|
||||
if (p.life <= 0) { this.kill(i); continue }
|
||||
const lifeT = tFromLife(p)
|
||||
if (cfg.useGravity) {
|
||||
if (!p.collisionPaused && cfg.useGravity) {
|
||||
const gravityMultiplier = Math.min(1, Math.max(0, curveAt(cfg.gravityCurve, lifeT)))
|
||||
const gravityForce = (cfg.gravityMode === 'random' ? p.gravityStrength : cfg.gravityMin) * gravityMultiplier
|
||||
const gravityAngle = cfg.gravityDirection * Math.PI / 180
|
||||
@@ -799,7 +860,7 @@ export class ParticleEmitter extends Container {
|
||||
p.vy += -Math.sin(gravityAngle) * gravityForce * dt
|
||||
}
|
||||
// 风力
|
||||
if (cfg.wind) {
|
||||
if (!p.collisionPaused && cfg.wind) {
|
||||
const windMultiplier = Math.min(1, Math.max(0, curveAt(cfg.windCurve, lifeT)))
|
||||
const windForce = (cfg.windMode === 'random' ? p.windStrength : cfg.windMin) * windMultiplier
|
||||
const windAngle = cfg.windDirection * Math.PI / 180
|
||||
@@ -807,7 +868,7 @@ export class ParticleEmitter extends Container {
|
||||
p.vy += -Math.sin(windAngle) * windForce * dt
|
||||
}
|
||||
// 全局力场:先把粒子局部坐标转换到场景数学坐标,计算力后再转回发射器局部坐标。
|
||||
if (cfg.forceField && cfg.forceStrength !== 0 && cfg.forceRadius > 0) {
|
||||
if (!p.collisionPaused && cfg.forceField && cfg.forceStrength !== 0 && cfg.forceRadius > 0) {
|
||||
const rootScaleX = Math.max(0.0001, Math.abs(cfg.rootScaleX))
|
||||
const rootScaleY = Math.max(0.0001, Math.abs(cfg.rootScaleY))
|
||||
const rootAngle = -cfg.rootRotation * Math.PI / 180
|
||||
@@ -833,15 +894,21 @@ export class ParticleEmitter extends Container {
|
||||
}
|
||||
}
|
||||
// 阻尼:速度按衰减系数逐渐减小
|
||||
if (cfg.damping > 0) {
|
||||
if (!p.collisionPaused && cfg.damping > 0) {
|
||||
const f = Math.max(0, 1 - cfg.damping * dt)
|
||||
p.vx *= f; p.vy *= f
|
||||
}
|
||||
p.x += p.vx * dt
|
||||
p.y += p.vy * dt
|
||||
if (!p.collisionPaused) {
|
||||
p.x += p.vx * dt
|
||||
p.y += p.vy * dt
|
||||
if (this.applyColliders(p, i)) continue
|
||||
} else {
|
||||
p.vx = 0
|
||||
p.vy = 0
|
||||
}
|
||||
|
||||
// 吸附使用场景全局数学坐标。延迟结束时锁定当前位置,随后沿直线或确定性弧线到达目标。
|
||||
if (cfg.attraction) {
|
||||
if (cfg.attraction && !p.collisionPaused) {
|
||||
const age = p.maxLife - p.life
|
||||
const delayReached = cfg.attractionDelayMode === 'particle'
|
||||
? age >= Math.max(0, cfg.attractionDelay)
|
||||
@@ -1240,6 +1307,7 @@ export class ParticleEmitter extends Container {
|
||||
: cfg.windMin
|
||||
p.attractionActive = false
|
||||
p.attractionElapsed = 0
|
||||
p.collisionPaused = false
|
||||
p.attractionCurveSide = seededUnit(cfg.seed, idx, 0xa341316c) < 0.5 ? -1 : 1
|
||||
p.trailHistory.length = 0
|
||||
p.trailBones.length = 0
|
||||
@@ -1452,6 +1520,98 @@ function worldMathToLocal(cfg: EmitterConfig, worldX: number, worldY: number) {
|
||||
y: (-sin * screenX + cos * screenY) / scaleY,
|
||||
}
|
||||
}
|
||||
function localVelocityToWorldMath(cfg: EmitterConfig, vx: number, vy: number) {
|
||||
const scaleX = Math.max(0.0001, Math.abs(cfg.rootScaleX))
|
||||
const scaleY = Math.max(0.0001, Math.abs(cfg.rootScaleY))
|
||||
const angle = -cfg.rootRotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const screenX = cos * scaleX * vx - sin * scaleY * vy
|
||||
const screenY = sin * scaleX * vx + cos * scaleY * vy
|
||||
return { x: screenX, y: -screenY }
|
||||
}
|
||||
function worldVelocityToLocalMath(cfg: EmitterConfig, vx: number, vy: number) {
|
||||
const scaleX = Math.max(0.0001, Math.abs(cfg.rootScaleX))
|
||||
const scaleY = Math.max(0.0001, Math.abs(cfg.rootScaleY))
|
||||
const angle = -cfg.rootRotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const screenY = -vy
|
||||
return {
|
||||
x: (cos * vx + sin * screenY) / scaleX,
|
||||
y: (-sin * vx + cos * screenY) / scaleY,
|
||||
}
|
||||
}
|
||||
|
||||
type ColliderHit = { x: number; y: number; nx: number; ny: number }
|
||||
function resolveColliderPoint(collider: SceneCollider, worldX: number, worldY: number): ColliderHit | null {
|
||||
const angle = collider.rotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const dx = worldX - collider.x, dy = worldY - collider.y
|
||||
const localX = cos * dx + sin * dy
|
||||
const localY = -sin * dx + cos * dy
|
||||
const toWorld = (x: number, y: number, nx: number, ny: number): ColliderHit => ({
|
||||
x: collider.x + cos * x - sin * y + (cos * nx - sin * ny) * 0.05,
|
||||
y: collider.y + sin * x + cos * y + (sin * nx + cos * ny) * 0.05,
|
||||
nx: cos * nx - sin * ny,
|
||||
ny: sin * nx + cos * ny,
|
||||
})
|
||||
if (collider.shape === 'circle') {
|
||||
const radius = Math.max(0.001, collider.radius)
|
||||
const distance = Math.hypot(localX, localY)
|
||||
if (distance >= radius) return null
|
||||
const nx = distance > 0.0001 ? localX / distance : 1
|
||||
const ny = distance > 0.0001 ? localY / distance : 0
|
||||
return toWorld(nx * radius, ny * radius, nx, ny)
|
||||
}
|
||||
if (collider.shape === 'ellipse') {
|
||||
const radiusX = Math.max(0.001, collider.radiusX)
|
||||
const radiusY = Math.max(0.001, collider.radiusY)
|
||||
const normalized = Math.hypot(localX / radiusX, localY / radiusY)
|
||||
if (normalized >= 1) return null
|
||||
const scale = normalized > 0.0001 ? 1 / normalized : 1
|
||||
const x = normalized > 0.0001 ? localX * scale : radiusX
|
||||
const y = normalized > 0.0001 ? localY * scale : 0
|
||||
const gradientLength = Math.max(0.0001, Math.hypot(x / (radiusX * radiusX), y / (radiusY * radiusY)))
|
||||
return toWorld(x, y, x / (radiusX * radiusX) / gradientLength, y / (radiusY * radiusY) / gradientLength)
|
||||
}
|
||||
if (collider.shape === 'rect') {
|
||||
const halfWidth = Math.max(0.001, collider.width * 0.5)
|
||||
const halfHeight = Math.max(0.001, collider.height * 0.5)
|
||||
if (Math.abs(localX) >= halfWidth || Math.abs(localY) >= halfHeight) return null
|
||||
const penetrationX = halfWidth - Math.abs(localX)
|
||||
const penetrationY = halfHeight - Math.abs(localY)
|
||||
if (penetrationX < penetrationY) {
|
||||
const nx = localX >= 0 ? 1 : -1
|
||||
return toWorld(nx * halfWidth, localY, nx, 0)
|
||||
}
|
||||
const ny = localY >= 0 ? 1 : -1
|
||||
return toWorld(localX, ny * halfHeight, 0, ny)
|
||||
}
|
||||
const sides = Math.max(3, Math.min(16, Math.round(collider.sides || (collider.shape === 'custom' ? 5 : 6))))
|
||||
const radiusX = Math.max(0.001, collider.width * 0.5)
|
||||
const radiusY = Math.max(0.001, collider.height * 0.5)
|
||||
const points = Array.from({ length: sides }, (_, index) => {
|
||||
const theta = Math.PI * 0.5 + index / sides * Math.PI * 2
|
||||
return { x: Math.cos(theta) * radiusX, y: Math.sin(theta) * radiusY }
|
||||
})
|
||||
let inside = false
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
||||
const a = points[i], b = points[j]
|
||||
if ((a.y > localY) !== (b.y > localY) && localX < (b.x - a.x) * (localY - a.y) / (b.y - a.y) + a.x) inside = !inside
|
||||
}
|
||||
if (!inside) return null
|
||||
let nearestX = points[0].x, nearestY = points[0].y, nearestDistance = Infinity
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const a = points[i], b = points[(i + 1) % points.length]
|
||||
const edgeX = b.x - a.x, edgeY = b.y - a.y
|
||||
const edgeLengthSq = Math.max(0.0001, edgeX * edgeX + edgeY * edgeY)
|
||||
const amount = Math.min(1, Math.max(0, ((localX - a.x) * edgeX + (localY - a.y) * edgeY) / edgeLengthSq))
|
||||
const x = a.x + edgeX * amount, y = a.y + edgeY * amount
|
||||
const distance = Math.hypot(x - localX, y - localY)
|
||||
if (distance < nearestDistance) { nearestDistance = distance; nearestX = x; nearestY = y }
|
||||
}
|
||||
const normalLength = Math.max(0.0001, Math.hypot(nearestX - localX, nearestY - localY))
|
||||
return toWorld(nearestX, nearestY, (nearestX - localX) / normalLength, (nearestY - localY) / normalLength)
|
||||
}
|
||||
function tFromLife(particle: Pick<Particle, 'life' | 'maxLife'>) {
|
||||
return Math.min(1, Math.max(0, 1 - particle.life / Math.max(0.0001, particle.maxLife)))
|
||||
}
|
||||
|
||||
+110
-2
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { EmitterConfig, defaultConfig, ParticleState } from '../core/particleEmitter'
|
||||
import { EmitterConfig, defaultConfig, ParticleState, type SceneCollider } from '../core/particleEmitter'
|
||||
|
||||
export interface ParticleSystem {
|
||||
id: number
|
||||
@@ -8,6 +8,28 @@ export interface ParticleSystem {
|
||||
frames?: ParticleState[][]
|
||||
}
|
||||
|
||||
export interface CollisionBody extends SceneCollider {
|
||||
name: string
|
||||
tags: string[]
|
||||
selectedTag: string
|
||||
followPath: boolean
|
||||
}
|
||||
|
||||
export interface ScenePath {
|
||||
id: number
|
||||
name: string
|
||||
enabled: boolean
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
closed: boolean
|
||||
color: string
|
||||
preset: '' | 'circle' | 'square' | 'polygon' | 'star'
|
||||
points: Array<{ x: number; y: number; inX: number; inY: number; outX: number; outY: number }>
|
||||
}
|
||||
|
||||
export type SceneObjectType = 'particle' | 'collision' | 'path' | 'spine'
|
||||
|
||||
export interface EditorState {
|
||||
viewScale: number
|
||||
viewX: number
|
||||
@@ -46,12 +68,18 @@ export interface TimelineState {
|
||||
}
|
||||
|
||||
let seq = 1
|
||||
let colliderSeq = 1
|
||||
let pathSeq = 1
|
||||
|
||||
export const useParticleStore = defineStore('particle', {
|
||||
state: () => ({
|
||||
editor: { viewScale: 1.0, viewX: 0, viewY: 0 } as EditorState,
|
||||
systems: [] as ParticleSystem[],
|
||||
colliders: [] as CollisionBody[],
|
||||
paths: [] as ScenePath[],
|
||||
activeId: 0,
|
||||
activeObjectType: 'particle' as SceneObjectType,
|
||||
activeObjectId: 0,
|
||||
timeline: { playing: true, loop: true, frame: 0, totalFrames: 120, fps: 60, recorded: false, animation: 'animation' } as TimelineState,
|
||||
settings: { tickEnabled: false, axisFontSize: 12, tickWidth: 1, showAxisLabels: true, axisColor: '#7a86ad' } as SettingsState,
|
||||
}),
|
||||
@@ -77,14 +105,94 @@ export const useParticleStore = defineStore('particle', {
|
||||
const sys: ParticleSystem = { id: seq++, config: cfg }
|
||||
this.systems.push(sys)
|
||||
this.activeId = sys.id
|
||||
this.activeObjectType = 'particle'
|
||||
this.activeObjectId = sys.id
|
||||
return sys
|
||||
},
|
||||
removeSystem(id: number) {
|
||||
const i = this.systems.findIndex((s) => s.id === id)
|
||||
if (i >= 0) this.systems.splice(i, 1)
|
||||
if (this.activeId === id) this.activeId = this.systems[0]?.id ?? 0
|
||||
if (this.activeObjectType === 'particle' && this.activeObjectId === id) {
|
||||
const next = this.systems[0]
|
||||
this.activeObjectId = next?.id ?? 0
|
||||
}
|
||||
},
|
||||
selectSystem(id: number) {
|
||||
this.activeId = id
|
||||
this.activeObjectType = 'particle'
|
||||
this.activeObjectId = id
|
||||
},
|
||||
addCollider() {
|
||||
const id = colliderSeq++
|
||||
const collider: CollisionBody = {
|
||||
id,
|
||||
name: `碰撞体 ${id}`,
|
||||
tags: ['Default'],
|
||||
selectedTag: 'Default',
|
||||
shape: 'circle',
|
||||
enabled: true,
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
radius: 50,
|
||||
radiusX: 70,
|
||||
radiusY: 45,
|
||||
width: 120,
|
||||
height: 80,
|
||||
sides: 6,
|
||||
response: 'physics',
|
||||
elasticity: 0.8,
|
||||
friction: 0.3,
|
||||
followPath: false,
|
||||
}
|
||||
this.colliders.push(collider)
|
||||
this.activeObjectType = 'collision'
|
||||
this.activeObjectId = collider.id
|
||||
return collider
|
||||
},
|
||||
addPath() {
|
||||
const id = pathSeq++
|
||||
const path: ScenePath = {
|
||||
id,
|
||||
name: `路径 ${id}`,
|
||||
enabled: true,
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
closed: false,
|
||||
color: '#ff933e',
|
||||
preset: '',
|
||||
points: [],
|
||||
}
|
||||
this.paths.push(path)
|
||||
this.activeObjectType = 'path'
|
||||
this.activeObjectId = path.id
|
||||
return path
|
||||
},
|
||||
selectSceneObject(type: SceneObjectType, id: number) {
|
||||
this.activeObjectType = type
|
||||
this.activeObjectId = id
|
||||
if (type === 'particle') this.activeId = id
|
||||
},
|
||||
removeCollider(id: number) {
|
||||
const index = this.colliders.findIndex((item) => item.id === id)
|
||||
if (index >= 0) this.colliders.splice(index, 1)
|
||||
if (this.activeObjectType === 'collision' && this.activeObjectId === id) {
|
||||
const fallback = this.systems[0]
|
||||
this.activeObjectType = fallback ? 'particle' : 'collision'
|
||||
this.activeObjectId = fallback?.id ?? this.colliders[0]?.id ?? 0
|
||||
}
|
||||
},
|
||||
removePath(id: number) {
|
||||
const index = this.paths.findIndex((item) => item.id === id)
|
||||
if (index >= 0) this.paths.splice(index, 1)
|
||||
if (this.activeObjectType === 'path' && this.activeObjectId === id) {
|
||||
const fallback = this.systems[0]
|
||||
this.activeObjectType = fallback ? 'particle' : 'path'
|
||||
this.activeObjectId = fallback?.id ?? this.paths[0]?.id ?? 0
|
||||
}
|
||||
},
|
||||
selectSystem(id: number) { this.activeId = id },
|
||||
play() { this.timeline.playing = true },
|
||||
pause() { this.timeline.playing = false },
|
||||
togglePlay() { this.timeline.playing = !this.timeline.playing },
|
||||
|
||||
+218
-33
@@ -2,36 +2,60 @@
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span>粒子系统 · 编辑器</span>
|
||||
<button class="mini-btn" @click="addSys">+ 系统</button>
|
||||
</div>
|
||||
|
||||
<!-- 系统列表 = 场景对象层级 -->
|
||||
<div class="sys-list">
|
||||
<div
|
||||
v-for="(sys, i) in store.systems"
|
||||
:key="sys.id"
|
||||
class="sys-item"
|
||||
:class="{ active: i === activeIdx }"
|
||||
@click="select(i)"
|
||||
>
|
||||
<span class="sys-name">{{ sys.config.name || '系统 ' + (i + 1) }}</span>
|
||||
<button class="mini-btn danger" @click.stop="store.removeSystem(sys.id)">✕</button>
|
||||
</div>
|
||||
<div v-if="!store.systems.length" class="empty">尚无粒子系统</div>
|
||||
</div>
|
||||
|
||||
<template v-if="sys">
|
||||
<!-- 1. 场景对象 -->
|
||||
<div class="group">
|
||||
<div class="group-head" @click="toggle('scene')">
|
||||
<span class="caret">{{ collapsed.scene ? '▸' : '▾' }}</span><span>场景对象</span>
|
||||
<!-- 1. 场景对象:创建入口始终可用,即使当前没有粒子系统。 -->
|
||||
<div class="group">
|
||||
<div class="group-head scene-head" @click="toggle('scene')">
|
||||
<span class="caret">{{ collapsed.scene ? '▸' : '▾' }}</span><span>场景对象</span>
|
||||
<div class="scene-actions">
|
||||
<button class="scene-add" title="添加粒子系统" @click.stop="addSys">+粒子</button>
|
||||
<button class="scene-add" title="添加碰撞体" @click.stop="addCollider">+碰撞</button>
|
||||
<button class="scene-add" title="添加路径" @click.stop="addPath">+路径</button>
|
||||
<button class="scene-add placeholder-action" title="添加 Spine 骨架(待开发)" @click.stop="showScenePlaceholder('Spine 骨架')">+骨架</button>
|
||||
</div>
|
||||
<div class="group-body" v-show="!collapsed.scene">
|
||||
<div class="tree">
|
||||
<div class="tree-item on"><span class="tdot">●</span><span class="tlabel">粒子系统</span></div>
|
||||
<div class="tree-item"><span class="tdot dim">◇</span><span class="tlabel">轨道路径</span><span class="tag">预留</span></div>
|
||||
<div class="tree-item"><span class="tdot dim">◇</span><span class="tlabel">碰撞体</span><span class="tag">预留</span></div>
|
||||
</div>
|
||||
<div class="group-body" v-show="!collapsed.scene">
|
||||
<div class="tree scene-tree">
|
||||
<div
|
||||
v-for="(system, i) in store.systems"
|
||||
:key="`particle-${system.id}`"
|
||||
class="tree-item scene-object-item"
|
||||
:class="{ on: activeObjectType === 'particle' && system.id === store.activeObjectId }"
|
||||
@click="select(i)"
|
||||
>
|
||||
<span class="tdot">●</span>
|
||||
<span class="tlabel">{{ system.config.name || 'ParticleSystem' + (i + 1) }}</span>
|
||||
<span class="scene-object-kind">粒子</span>
|
||||
<button class="scene-object-delete" title="删除粒子系统" @click.stop="store.removeSystem(system.id)">✕</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="collider in store.colliders"
|
||||
:key="`collision-${collider.id}`"
|
||||
class="tree-item scene-object-item"
|
||||
:class="{ on: activeObjectType === 'collision' && collider.id === store.activeObjectId }"
|
||||
@click="store.selectSceneObject('collision', collider.id)"
|
||||
>
|
||||
<span class="tdot collision-dot">■</span>
|
||||
<span class="tlabel">{{ collider.name }}</span>
|
||||
<span class="scene-object-kind">碰撞</span>
|
||||
<button class="scene-object-delete" title="删除碰撞体" @click.stop="store.removeCollider(collider.id)">✕</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="path in store.paths"
|
||||
:key="`path-${path.id}`"
|
||||
class="tree-item scene-object-item"
|
||||
:class="{ on: activeObjectType === 'path' && path.id === store.activeObjectId }"
|
||||
@click="store.selectSceneObject('path', path.id)"
|
||||
>
|
||||
<span class="tdot path-dot">◇</span>
|
||||
<span class="tlabel">{{ path.name }}</span>
|
||||
<span class="scene-object-kind">路径</span>
|
||||
<button class="scene-object-delete" title="删除路径" @click.stop="store.removePath(path.id)">✕</button>
|
||||
</div>
|
||||
<div v-if="!store.systems.length && !store.colliders.length && !store.paths.length" class="empty">场景中暂无对象</div>
|
||||
</div>
|
||||
<template v-if="activeObjectType === 'particle' && sys">
|
||||
<label class="row"><span>名称</span><input v-model="sys.config.name" class="inp" /></label>
|
||||
<NumSlider label="位置X" :min="-2000" :max="2000" :step="1" v-model="sys.config.centerX" />
|
||||
<NumSlider label="位置Y" :min="-2000" :max="2000" :step="1" v-model="sys.config.centerY" />
|
||||
@@ -46,8 +70,69 @@
|
||||
<button class="seed-random" title="生成新的全局随机种子" @click="randomizeSeed">⚄</button>
|
||||
</div>
|
||||
<div class="seed-hint">控制初始随机参数、资源选择和修改器随机采样;相同种子可复现相同效果</div>
|
||||
</div>
|
||||
</template>
|
||||
<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>
|
||||
</select></label>
|
||||
<label class="field-block check-field"><span>启用</span><input v-model="activeCollider.enabled" type="checkbox" /></label>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<NumSlider label="X 位置" :min="-2000" :max="2000" :step="1" v-model="activeCollider.x" />
|
||||
<NumSlider label="Y 位置" :min="-2000" :max="2000" :step="1" v-model="activeCollider.y" />
|
||||
</div>
|
||||
<NumSlider label="旋转" :min="-360" :max="360" :step="1" v-model="activeCollider.rotation" />
|
||||
<NumSlider v-if="activeCollider.shape === 'circle'" label="半径" :min="1" :max="1000" :step="1" v-model="activeCollider.radius" />
|
||||
<div v-else-if="activeCollider.shape === 'ellipse'" class="field-grid">
|
||||
<NumSlider label="X 半径" :min="1" :max="1000" :step="1" v-model="activeCollider.radiusX" />
|
||||
<NumSlider label="Y 半径" :min="1" :max="1000" :step="1" v-model="activeCollider.radiusY" />
|
||||
</div>
|
||||
<div v-else class="field-grid">
|
||||
<NumSlider label="宽度" :min="1" :max="2000" :step="1" v-model="activeCollider.width" />
|
||||
<NumSlider label="高度" :min="1" :max="2000" :step="1" v-model="activeCollider.height" />
|
||||
</div>
|
||||
<NumSlider v-if="activeCollider.shape === 'polygon' || activeCollider.shape === 'custom'" label="边数" :min="3" :max="16" :step="1" v-model="activeCollider.sides" />
|
||||
<label class="field-block"><span>碰撞响应</span><select v-model="activeCollider.response" class="inp">
|
||||
<option value="physics">物理(弹跳/摩擦)</option><option value="pause">暂停(停止不动)</option><option value="destroy">消失(立即销毁)</option>
|
||||
</select></label>
|
||||
<div v-if="activeCollider.response === 'physics'" class="field-grid">
|
||||
<NumSlider label="弹性 (0-1)" :min="0" :max="1" :step="0.01" v-model="activeCollider.elasticity" />
|
||||
<NumSlider label="摩擦 (0-1)" :min="0" :max="1" :step="0.01" v-model="activeCollider.friction" />
|
||||
</div>
|
||||
<label class="follow-path"><input v-model="activeCollider.followPath" type="checkbox" />跟随轨道移动</label>
|
||||
</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="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>
|
||||
<li>Ctrl+点击/框选:加选节点</li><li>Alt+点击/框选:减选节点</li><li>Alt+单击空白处:清除全部选中</li><li>选中多个节点后可整体拖动</li>
|
||||
</ul></div>
|
||||
<label class="field-block"><span>应用预设形状</span><select v-model="activePath.preset" class="inp">
|
||||
<option value="">选择预设...</option><option value="circle">圆形</option><option value="square">方形</option><option value="polygon">多边形</option><option value="star">星形</option>
|
||||
</select></label>
|
||||
<button class="path-action primary" @click="applyPathPreset">应用预设</button>
|
||||
<button class="path-action" @click="clearPathAnchors">清空锚点</button>
|
||||
</template>
|
||||
<div v-else class="panel-hint">使用右上角按钮创建场景对象</div>
|
||||
<div v-if="sceneActionHint" class="scene-action-hint">{{ sceneActionHint }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="activeObjectType === 'particle' && sys">
|
||||
|
||||
<!-- 2. 发射模式 -->
|
||||
<div class="group">
|
||||
@@ -550,12 +635,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="!store.systems.length" class="panel-hint">点「+ 系统」创建粒子系统</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, markRaw, reactive, ref, watchEffect } from 'vue'
|
||||
import { computed, markRaw, onUnmounted, reactive, ref, watchEffect } from 'vue'
|
||||
import { Texture } from 'pixi.js'
|
||||
import { useParticleStore } from '../store/particleStore'
|
||||
import { ensureEmitterConfig } from '../core/particleEmitter'
|
||||
@@ -574,6 +658,13 @@ const activeIdx = computed(() => {
|
||||
return i >= 0 ? i : 0
|
||||
})
|
||||
const sys = computed(() => store.systems[activeIdx.value] || null)
|
||||
const activeObjectType = computed(() => store.activeObjectType)
|
||||
const activeCollider = computed(() => store.activeObjectType === 'collision'
|
||||
? store.colliders.find((item) => item.id === store.activeObjectId) || null
|
||||
: null)
|
||||
const activePath = computed(() => store.activeObjectType === 'path'
|
||||
? store.paths.find((item) => item.id === store.activeObjectId) || null
|
||||
: null)
|
||||
|
||||
// 开发热更新会保留旧的 Pinia 对象;渲染属性控件前先补齐新增字段。
|
||||
watchEffect(() => {
|
||||
@@ -606,6 +697,58 @@ function toggle(key: string) { collapsed[key] = !collapsed[key] }
|
||||
|
||||
function select(i: number) { store.selectSystem(store.systems[i]?.id ?? 0) }
|
||||
function addSys() { store.addSystem() }
|
||||
function addCollider() { store.addCollider() }
|
||||
function addPath() { store.addPath() }
|
||||
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 }
|
||||
}
|
||||
function applyPathPreset() {
|
||||
const path = activePath.value
|
||||
if (!path || !path.preset) return
|
||||
if (path.preset === 'circle') {
|
||||
const radius = 110, handle = radius * 0.5522848
|
||||
path.points = [
|
||||
pathAnchor(radius, 0, 0, -handle, 0, handle),
|
||||
pathAnchor(0, radius, handle, 0, -handle, 0),
|
||||
pathAnchor(-radius, 0, 0, handle, 0, -handle),
|
||||
pathAnchor(0, -radius, -handle, 0, handle, 0),
|
||||
]
|
||||
path.closed = true
|
||||
} else if (path.preset === 'square') {
|
||||
path.points = [pathAnchor(-100, -100), pathAnchor(100, -100), pathAnchor(100, 100), pathAnchor(-100, 100)]
|
||||
path.closed = true
|
||||
} else {
|
||||
const sides = path.preset === 'star' ? 10 : 6
|
||||
path.points = Array.from({ length: sides }, (_, index) => {
|
||||
const radius = path.preset === 'star' && index % 2 ? 50 : 110
|
||||
const angle = -Math.PI * 0.5 + index / sides * Math.PI * 2
|
||||
return pathAnchor(Math.cos(angle) * radius, Math.sin(angle) * radius)
|
||||
})
|
||||
path.closed = true
|
||||
}
|
||||
}
|
||||
function clearPathAnchors() {
|
||||
if (activePath.value) activePath.value.points = []
|
||||
}
|
||||
const sceneActionHint = ref('')
|
||||
let sceneActionHintTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function showScenePlaceholder(type: string) {
|
||||
sceneActionHint.value = `${type}对象将在后续阶段开发`
|
||||
if (sceneActionHintTimer) clearTimeout(sceneActionHintTimer)
|
||||
sceneActionHintTimer = setTimeout(() => { sceneActionHint.value = '' }, 2400)
|
||||
}
|
||||
onUnmounted(() => {
|
||||
if (sceneActionHintTimer) clearTimeout(sceneActionHintTimer)
|
||||
})
|
||||
function randomizeSeed() {
|
||||
if (sys.value) sys.value.config.seed = Math.floor(Math.random() * 999999) + 1
|
||||
}
|
||||
@@ -1020,10 +1163,6 @@ function toggleResourceLock(resourceId: number) {
|
||||
.panel::-webkit-scrollbar-thumb:hover { background: #3a3a55; }
|
||||
.panel { scrollbar-width: thin; scrollbar-color: #2e2e44 transparent; }
|
||||
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; font-weight: 700; color: #ccd; }
|
||||
.sys-list { margin-bottom: 10px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.sys-item { display: flex; justify-content: space-between; align-items: center; padding: 6px 8px; background: #1e1e2e; border-radius: 6px; cursor: pointer; }
|
||||
.sys-item.active { background: #2b3a5c; }
|
||||
.sys-name { font-size: 12px; }
|
||||
.empty, .panel-hint { color: #667; font-size: 12px; padding: 8px 0; }
|
||||
.group { border: 1px solid #2a2a3c; border-radius: 8px; margin-bottom: 10px; overflow: hidden; }
|
||||
.group-head {
|
||||
@@ -1031,6 +1170,18 @@ function toggleResourceLock(resourceId: number) {
|
||||
font-size: 12px; font-weight: 600; color: #ccd; background: #191924; user-select: none;
|
||||
}
|
||||
.group-head:hover { background: #20202e; }
|
||||
.scene-head { padding-right: 6px; }
|
||||
.scene-actions { margin-left: auto; display: flex; align-items: center; gap: 4px; }
|
||||
.scene-add {
|
||||
height: 24px; padding: 0 6px; border: 1px solid #3a4a6a; border-radius: 5px;
|
||||
background: #2e3a55; color: #cdf; font-size: 10px; white-space: nowrap; cursor: pointer;
|
||||
}
|
||||
.scene-add:hover { border-color: #7774ff; background: #45426f; color: #fff; }
|
||||
.scene-add.placeholder-action { background: #232938; color: #9da7be; }
|
||||
.scene-action-hint {
|
||||
margin-top: 8px; padding: 6px 8px; border: 1px dashed #3a4963; border-radius: 5px;
|
||||
background: #181e2b; color: #8f9ab3; font-size: 11px;
|
||||
}
|
||||
.group-body { padding: 8px 10px 12px; }
|
||||
.caret { display: inline-block; width: 12px; color: #667; font-size: 11px; }
|
||||
.divider { height: 1px; background: #2a2a3c; margin: 8px 0; }
|
||||
@@ -1039,7 +1190,41 @@ function toggleResourceLock(resourceId: number) {
|
||||
.tree-item.on { background: #26324a; color: #ffe; }
|
||||
.tree-item .tdot { color: #ffcc33; font-size: 10px; }
|
||||
.tree-item .tdot.dim { color: #556; }
|
||||
.tree-item .tdot.collision-dot { color: #ff7070; }
|
||||
.tree-item .tdot.path-dot { color: #ff933e; font-size: 14px; }
|
||||
.tree-item .tlabel { flex: 1; }
|
||||
.scene-tree { padding-bottom: 8px; border-bottom: 1px solid #2a2a3c; }
|
||||
.scene-object-item { min-height: 28px; cursor: pointer; }
|
||||
.scene-object-item:hover { background: #202b40; }
|
||||
.scene-object-kind { color: #75829c; font-size: 10px; }
|
||||
.scene-object-delete {
|
||||
width: 24px; height: 22px; padding: 0; border: 1px solid #59414a; border-radius: 5px;
|
||||
background: #3a272f; color: #efb4bd; font-size: 11px; cursor: pointer;
|
||||
}
|
||||
.scene-object-delete:hover { border-color: #a45c69; background: #57303a; color: #fff; }
|
||||
.object-editor-title { margin: 10px 0 8px; padding-top: 8px; border-top: 1px solid #2a2a3c; color: #cbd4e8; font-weight: 700; }
|
||||
.field-block { display: flex; flex-direction: column; gap: 5px; margin: 10px 0; color: #8f9ab3; font-size: 11px; }
|
||||
.field-block > .inp { width: 100%; box-sizing: border-box; min-height: 30px; }
|
||||
.field-block small { color: #5f6d86; line-height: 1.5; }
|
||||
.field-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; align-items: end; }
|
||||
.field-grid :deep(.num-slider) { min-width: 0; }
|
||||
.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; }
|
||||
.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; }
|
||||
.path-help ul { margin: 6px 0 0; padding-left: 20px; }
|
||||
.path-help li { margin: 3px 0; }
|
||||
.path-action { width: 100%; height: 31px; margin-top: 7px; border: 0; border-radius: 5px; background: #33425b; color: #c9d1e1; cursor: pointer; }
|
||||
.path-action:hover { filter: brightness(1.12); }
|
||||
.path-action.primary { background: #f05408; color: #fff; }
|
||||
.tag { font-size: 10px; color: #667; background: #1c1c2a; border-radius: 4px; padding: 1px 5px; }
|
||||
.row { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 12px; color: #aab; }
|
||||
.row > span:first-child { width: 60px; flex-shrink: 0; color: #8a93bb; }
|
||||
|
||||
+288
-14
@@ -1,13 +1,10 @@
|
||||
<template>
|
||||
<div class="stage-wrap">
|
||||
<div ref="holder" class="stage-holder" :class="{ grid: showGrid }" @wheel.prevent="onWheel" @pointerdown="onPanDown"></div>
|
||||
<div ref="holder" class="stage-holder" :class="{ grid: showGrid }" @wheel.prevent="onWheel" @pointerdown="onPanDown" @dblclick.prevent="onStageDoubleClick"></div>
|
||||
|
||||
<!-- 顶栏 -->
|
||||
<div class="topbar">
|
||||
<label class="brand">粒子发射编辑器</label>
|
||||
<label class="chk"><input type="checkbox" v-model="showEmitter" />发射点</label>
|
||||
<label class="chk"><input type="checkbox" v-model="showBones" />骨骼连线</label>
|
||||
<label class="chk"><input type="checkbox" v-model="showSkinMesh" />蒙皮网格</label>
|
||||
<button class="settings-btn" @click="showSettings = !showSettings" title="系统设置">系统设置</button>
|
||||
|
||||
<!-- 根变换工具:拖动(默认) / 位移 / 旋转 / 缩放 -->
|
||||
<div class="tool-group">
|
||||
@@ -29,13 +26,16 @@
|
||||
<span class="zoom-pct">{{ Math.round(store.editor.viewScale * 100) }}%</span>
|
||||
<span class="fps">t={{ time.toFixed(2) }}s</span>
|
||||
|
||||
<!-- 系统设置入口 -->
|
||||
<button class="tool-btn settings-btn" @click="showSettings = !showSettings" title="系统设置">⚙</button>
|
||||
</div>
|
||||
|
||||
<!-- 系统设置面板(右上角弹出) -->
|
||||
<!-- 系统设置面板(左上角弹出) -->
|
||||
<div class="settings-panel" v-if="showSettings">
|
||||
<div class="sp-head">系统设置 <button class="sp-close" @click="showSettings = false">✕</button></div>
|
||||
<div class="sp-section-title">画布辅助显示</div>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="showEmitter" />发射点</label>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="showBones" />骨骼连线</label>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="showSkinMesh" />蒙皮网格</label>
|
||||
<div class="sp-divider"></div>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="store.settings.tickEnabled" />启用刻度</label>
|
||||
<template v-if="store.settings.tickEnabled">
|
||||
<label class="sp-row"><span>刻度文字大小</span>
|
||||
@@ -65,7 +65,7 @@
|
||||
import { 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 } from '../store/particleStore'
|
||||
import { useParticleStore, type ScenePath } from '../store/particleStore'
|
||||
|
||||
const store = useParticleStore()
|
||||
const holder = ref<HTMLElement | null>(null)
|
||||
@@ -87,6 +87,8 @@ let boneGfx: Graphics | null = null
|
||||
let shapeGfx: Graphics | null = null
|
||||
let forceFieldGfx: Graphics | null = null
|
||||
let attractionGfx: Graphics | null = null
|
||||
let collisionGfx: Graphics | null = null
|
||||
let pathGfx: Graphics | null = null
|
||||
let gizmoGfx: Graphics | null = null
|
||||
let axisGfx: Graphics | null = null
|
||||
let axisLabelLayer: Container | null = null // 文字图层,不随 world 缩放,避免放大变糊
|
||||
@@ -97,6 +99,9 @@ let dotTex: Texture<any> | null = null
|
||||
let trailTex: Texture<any> | null = null
|
||||
|
||||
const emitterMap = new Map<number, ParticleEmitter>()
|
||||
const selectedPathNodes = new Set<number>()
|
||||
let suppressNextPathDoubleClick = false
|
||||
watch(() => [store.activeObjectType, store.activeObjectId], () => selectedPathNodes.clear())
|
||||
|
||||
async function loadTexture() {
|
||||
if (dotTex && trailTex) return dotTex
|
||||
@@ -124,6 +129,10 @@ function createLayers() {
|
||||
world.addChild(forceFieldGfx)
|
||||
attractionGfx = new Graphics() // 选中系统的全局吸附范围
|
||||
world.addChild(attractionGfx)
|
||||
collisionGfx = new Graphics()
|
||||
world.addChild(collisionGfx)
|
||||
pathGfx = new Graphics()
|
||||
world.addChild(pathGfx)
|
||||
gizmoGfx = new Graphics() // root 变换 gizmo 手柄
|
||||
world.addChild(gizmoGfx)
|
||||
boneGfx = new Graphics()
|
||||
@@ -248,6 +257,8 @@ function onWheel(e: WheelEvent) {
|
||||
// 鼠标左键长按拖动画布(平移)
|
||||
function onPanDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
if (onPathPointerDown(e)) return
|
||||
if (onColliderPointerDown(e)) return
|
||||
// 吸附点优先于力场;重叠时拖动红色吸附圆。
|
||||
if (onAttractionDown(e)) return
|
||||
// 力场使用全局坐标,按住圆内任意位置时优先拖动力场中心。
|
||||
@@ -282,6 +293,168 @@ function pointerToWorldMath(clientX: number, clientY: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function pathLocalToWorld(path: ScenePath, x: number, y: number) {
|
||||
const angle = path.rotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
return { x: path.x + cos * x - sin * y, y: path.y + sin * x + cos * y }
|
||||
}
|
||||
|
||||
function worldToPathLocal(path: ScenePath, x: number, y: number) {
|
||||
const angle = path.rotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const dx = x - path.x, dy = y - path.y
|
||||
return { x: cos * dx + sin * dy, y: -sin * dx + cos * dy }
|
||||
}
|
||||
|
||||
function activePath() {
|
||||
if (store.activeObjectType !== 'path') return null
|
||||
return store.paths.find((item) => item.id === store.activeObjectId) || null
|
||||
}
|
||||
|
||||
function onStageDoubleClick(e: MouseEvent) {
|
||||
const path = activePath()
|
||||
if (!path || !path.enabled) return
|
||||
if (suppressNextPathDoubleClick) { suppressNextPathDoubleClick = false; return }
|
||||
const pointer = pointerToWorldMath(e.clientX, e.clientY)
|
||||
const local = worldToPathLocal(path, pointer.x, pointer.y)
|
||||
const threshold = 9 / Math.max(0.01, store.editor.viewScale)
|
||||
const anchorIndex = path.points.findIndex((point) => Math.hypot(point.x - local.x, point.y - local.y) <= threshold)
|
||||
if (anchorIndex >= 0) {
|
||||
path.points.splice(anchorIndex, 1)
|
||||
selectedPathNodes.clear()
|
||||
return
|
||||
}
|
||||
path.points.push({ x: local.x, y: local.y, inX: 0, inY: 0, outX: 0, outY: 0 })
|
||||
selectedPathNodes.clear()
|
||||
selectedPathNodes.add(path.points.length - 1)
|
||||
}
|
||||
|
||||
function onPathPointerDown(e: PointerEvent) {
|
||||
const path = activePath()
|
||||
if (!path || !path.enabled) return false
|
||||
const pointer = pointerToWorldMath(e.clientX, e.clientY)
|
||||
const local = worldToPathLocal(path, pointer.x, pointer.y)
|
||||
const threshold = 9 / Math.max(0.01, store.editor.viewScale)
|
||||
if (e.detail >= 2) {
|
||||
const existing = path.points.findIndex((point) => Math.hypot(point.x - local.x, point.y - local.y) <= threshold)
|
||||
if (existing >= 0) return true
|
||||
const anchor = { x: local.x, y: local.y, inX: 0, inY: 0, outX: 0, outY: 0 }
|
||||
path.points.push(anchor)
|
||||
selectedPathNodes.clear()
|
||||
selectedPathNodes.add(path.points.length - 1)
|
||||
suppressNextPathDoubleClick = true
|
||||
installPointerDrag((event) => {
|
||||
const worldPoint = pointerToWorldMath(event.clientX, event.clientY)
|
||||
const next = worldToPathLocal(path, worldPoint.x, worldPoint.y)
|
||||
anchor.outX = next.x - anchor.x
|
||||
anchor.outY = next.y - anchor.y
|
||||
anchor.inX = -anchor.outX
|
||||
anchor.inY = -anchor.outY
|
||||
})
|
||||
return true
|
||||
}
|
||||
let handleHit: { index: number; side: 'in' | 'out' } | null = null
|
||||
for (const index of selectedPathNodes) {
|
||||
const point = path.points[index]
|
||||
if (!point) continue
|
||||
if (Math.hypot(point.x + point.inX - local.x, point.y + point.inY - local.y) <= threshold) handleHit = { index, side: 'in' }
|
||||
if (Math.hypot(point.x + point.outX - local.x, point.y + point.outY - local.y) <= threshold) handleHit = { index, side: 'out' }
|
||||
}
|
||||
if (handleHit) {
|
||||
const point = path.points[handleHit.index]
|
||||
const oppositeLength = handleHit.side === 'in' ? Math.hypot(point.outX, point.outY) : Math.hypot(point.inX, point.inY)
|
||||
const move = (event: PointerEvent) => {
|
||||
const worldPoint = pointerToWorldMath(event.clientX, event.clientY)
|
||||
const next = worldToPathLocal(path, worldPoint.x, worldPoint.y)
|
||||
const hx = next.x - point.x, hy = next.y - point.y
|
||||
if (handleHit!.side === 'in') { point.inX = hx; point.inY = hy } else { point.outX = hx; point.outY = hy }
|
||||
if (!event.altKey) {
|
||||
const length = Math.max(0.0001, Math.hypot(hx, hy))
|
||||
const mirrorLength = event.shiftKey ? length : oppositeLength
|
||||
if (handleHit!.side === 'in') { point.outX = -hx / length * mirrorLength; point.outY = -hy / length * mirrorLength }
|
||||
else { point.inX = -hx / length * mirrorLength; point.inY = -hy / length * mirrorLength }
|
||||
}
|
||||
}
|
||||
installPointerDrag(move)
|
||||
return true
|
||||
}
|
||||
const anchorIndex = path.points.findIndex((point) => Math.hypot(point.x - local.x, point.y - local.y) <= threshold)
|
||||
if (anchorIndex >= 0) {
|
||||
if (e.altKey) { selectedPathNodes.delete(anchorIndex); return true }
|
||||
if (e.ctrlKey || e.metaKey) selectedPathNodes.add(anchorIndex)
|
||||
else if (!selectedPathNodes.has(anchorIndex)) { selectedPathNodes.clear(); selectedPathNodes.add(anchorIndex) }
|
||||
const start = local
|
||||
const origins = [...selectedPathNodes].map((index) => ({ index, x: path.points[index].x, y: path.points[index].y }))
|
||||
const move = (event: PointerEvent) => {
|
||||
const worldPoint = pointerToWorldMath(event.clientX, event.clientY)
|
||||
const next = worldToPathLocal(path, worldPoint.x, worldPoint.y)
|
||||
for (const origin of origins) {
|
||||
path.points[origin.index].x = origin.x + next.x - start.x
|
||||
path.points[origin.index].y = origin.y + next.y - start.y
|
||||
}
|
||||
}
|
||||
installPointerDrag(move)
|
||||
return true
|
||||
}
|
||||
if (e.altKey && !e.ctrlKey && !e.metaKey) { selectedPathNodes.clear(); return true }
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) {
|
||||
const start = local
|
||||
const subtract = e.altKey
|
||||
const up = (event: PointerEvent) => {
|
||||
const endWorld = pointerToWorldMath(event.clientX, event.clientY)
|
||||
const end = worldToPathLocal(path, endWorld.x, endWorld.y)
|
||||
const minX = Math.min(start.x, end.x), maxX = Math.max(start.x, end.x)
|
||||
const minY = Math.min(start.y, end.y), maxY = Math.max(start.y, end.y)
|
||||
path.points.forEach((point, index) => {
|
||||
if (point.x >= minX && point.x <= maxX && point.y >= minY && point.y <= maxY) {
|
||||
if (subtract) selectedPathNodes.delete(index); else selectedPathNodes.add(index)
|
||||
}
|
||||
})
|
||||
window.removeEventListener('pointerup', up)
|
||||
window.removeEventListener('pointercancel', up)
|
||||
}
|
||||
window.addEventListener('pointerup', up)
|
||||
window.addEventListener('pointercancel', up)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function installPointerDrag(move: (event: PointerEvent) => void) {
|
||||
const up = () => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
window.removeEventListener('pointercancel', up)
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
window.addEventListener('pointercancel', up)
|
||||
}
|
||||
|
||||
function onColliderPointerDown(e: PointerEvent) {
|
||||
if (store.activeObjectType !== 'collision') return false
|
||||
const collider = store.colliders.find((item) => item.id === store.activeObjectId)
|
||||
if (!collider?.enabled) return false
|
||||
const pointer = pointerToWorldMath(e.clientX, e.clientY)
|
||||
const angle = collider.rotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const dx = pointer.x - collider.x, dy = pointer.y - collider.y
|
||||
const localX = cos * dx + sin * dy, localY = -sin * dx + cos * dy
|
||||
const hit = collider.shape === 'circle'
|
||||
? Math.hypot(localX, localY) <= collider.radius
|
||||
: collider.shape === 'ellipse'
|
||||
? Math.hypot(localX / Math.max(1, collider.radiusX), localY / Math.max(1, collider.radiusY)) <= 1
|
||||
: Math.abs(localX) <= collider.width * 0.5 && Math.abs(localY) <= collider.height * 0.5
|
||||
if (!hit) return false
|
||||
const startX = collider.x, startY = collider.y, start = pointer
|
||||
installPointerDrag((event) => {
|
||||
const next = pointerToWorldMath(event.clientX, event.clientY)
|
||||
collider.x = startX + next.x - start.x
|
||||
collider.y = startY + next.y - start.y
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
/** 命中当前选中系统的力场圆时开始拖拽;返回 true 表示事件已被力场接管。 */
|
||||
function onForceFieldDown(e: PointerEvent) {
|
||||
const sys = store.systems.find((item) => item.id === store.activeId)
|
||||
@@ -652,6 +825,100 @@ function drawBones(bones: BonePreview[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function colorNumber(value: string, fallback = 0xffffff) {
|
||||
const parsed = Number.parseInt(value.replace('#', ''), 16)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
function drawCollisionBodies() {
|
||||
if (!collisionGfx) return
|
||||
const g = collisionGfx
|
||||
g.clear()
|
||||
for (const collider of store.colliders) {
|
||||
const selected = store.activeObjectType === 'collision' && store.activeObjectId === collider.id
|
||||
const color = collider.enabled ? (selected ? 0xff5e72 : 0xd4475b) : 0x586174
|
||||
const angle = collider.rotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const point = (x: number, y: number) => ({
|
||||
x: collider.x + cos * x - sin * y,
|
||||
y: -(collider.y + sin * x + cos * y),
|
||||
})
|
||||
let points: Array<{ x: number; y: number }> = []
|
||||
if (collider.shape === 'circle' || collider.shape === 'ellipse') {
|
||||
const radiusX = collider.shape === 'circle' ? collider.radius : collider.radiusX
|
||||
const radiusY = collider.shape === 'circle' ? collider.radius : collider.radiusY
|
||||
points = Array.from({ length: 49 }, (_, index) => {
|
||||
const theta = index / 48 * Math.PI * 2
|
||||
return point(Math.cos(theta) * radiusX, Math.sin(theta) * radiusY)
|
||||
})
|
||||
} else if (collider.shape === 'rect') {
|
||||
const hw = collider.width * 0.5, hh = collider.height * 0.5
|
||||
points = [point(-hw, -hh), point(hw, -hh), point(hw, hh), point(-hw, hh), point(-hw, -hh)]
|
||||
} else {
|
||||
const sides = Math.max(3, Math.min(16, Math.round(collider.sides)))
|
||||
points = Array.from({ length: sides + 1 }, (_, index) => {
|
||||
const theta = Math.PI * 0.5 + (index % sides) / sides * Math.PI * 2
|
||||
return point(Math.cos(theta) * collider.width * 0.5, Math.sin(theta) * collider.height * 0.5)
|
||||
})
|
||||
}
|
||||
if (!points.length) continue
|
||||
g.lineStyle(selected ? 2.5 : 1.5, color, collider.enabled ? 0.95 : 0.45)
|
||||
g.beginFill(color, selected ? 0.12 : 0.055)
|
||||
g.moveTo(points[0].x, points[0].y)
|
||||
for (let i = 1; i < points.length; i++) g.lineTo(points[i].x, points[i].y)
|
||||
g.endFill()
|
||||
const center = point(0, 0)
|
||||
g.lineStyle(1, color, 0.8)
|
||||
g.moveTo(center.x - 7, center.y); g.lineTo(center.x + 7, center.y)
|
||||
g.moveTo(center.x, center.y - 7); g.lineTo(center.x, center.y + 7)
|
||||
}
|
||||
}
|
||||
|
||||
function drawPaths() {
|
||||
if (!pathGfx) return
|
||||
const g = pathGfx
|
||||
g.clear()
|
||||
for (const path of store.paths) {
|
||||
if (!path.enabled || !path.points.length) continue
|
||||
const selected = store.activeObjectType === 'path' && store.activeObjectId === path.id
|
||||
const color = colorNumber(path.color, 0xff933e)
|
||||
const screenPoint = (x: number, y: number) => {
|
||||
const worldPoint = pathLocalToWorld(path, x, y)
|
||||
return { x: worldPoint.x, y: -worldPoint.y }
|
||||
}
|
||||
const first = path.points[0]
|
||||
const start = screenPoint(first.x, first.y)
|
||||
g.lineStyle(selected ? 2.5 : 1.8, color, selected ? 1 : 0.75)
|
||||
g.moveTo(start.x, start.y)
|
||||
const segmentCount = path.closed ? path.points.length : path.points.length - 1
|
||||
for (let i = 0; i < segmentCount; i++) {
|
||||
const a = path.points[i]
|
||||
const b = path.points[(i + 1) % path.points.length]
|
||||
const controlA = screenPoint(a.x + a.outX, a.y + a.outY)
|
||||
const controlB = screenPoint(b.x + b.inX, b.y + b.inY)
|
||||
const end = screenPoint(b.x, b.y)
|
||||
g.bezierCurveTo(controlA.x, controlA.y, controlB.x, controlB.y, end.x, end.y)
|
||||
}
|
||||
if (!selected) continue
|
||||
path.points.forEach((anchor, index) => {
|
||||
const point = screenPoint(anchor.x, anchor.y)
|
||||
const input = screenPoint(anchor.x + anchor.inX, anchor.y + anchor.inY)
|
||||
const output = screenPoint(anchor.x + anchor.outX, anchor.y + anchor.outY)
|
||||
const hasInput = Math.hypot(anchor.inX, anchor.inY) > 0.01
|
||||
const hasOutput = Math.hypot(anchor.outX, anchor.outY) > 0.01
|
||||
g.lineStyle(1, 0xa9b4cb, 0.65)
|
||||
if (hasInput) { g.moveTo(point.x, point.y); g.lineTo(input.x, input.y) }
|
||||
if (hasOutput) { g.moveTo(point.x, point.y); g.lineTo(output.x, output.y) }
|
||||
if (hasInput) { g.beginFill(0xd5dcec, 0.95); g.drawCircle(input.x, input.y, 3); g.endFill() }
|
||||
if (hasOutput) { g.beginFill(0xd5dcec, 0.95); g.drawCircle(output.x, output.y, 3); g.endFill() }
|
||||
g.beginFill(selectedPathNodes.has(index) ? 0xffffff : color, 1)
|
||||
g.lineStyle(1.5, selectedPathNodes.has(index) ? 0x5d7cff : 0xffffff, 0.95)
|
||||
g.drawCircle(point.x, point.y, selectedPathNodes.has(index) ? 5 : 4)
|
||||
g.endFill()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function loop() {
|
||||
raf = requestAnimationFrame(loop)
|
||||
const now = performance.now() / 1000
|
||||
@@ -673,6 +940,7 @@ function loop() {
|
||||
em.position.set(c.centerX, -c.centerY) // y 数学上正 → 屏幕取负
|
||||
em.rotation = -c.rootRotation * Math.PI / 180 // 数学上正(顺时针为负)
|
||||
em.scale.set(c.rootScaleX, c.rootScaleY)
|
||||
em.setColliders(store.colliders)
|
||||
}
|
||||
let total = 0
|
||||
const collected: BonePreview[] = []
|
||||
@@ -722,6 +990,8 @@ function loop() {
|
||||
}
|
||||
}
|
||||
for (const [, emitter] of emitterMap) emitter.drawTrailMeshDebug(showSkinMesh.value)
|
||||
drawCollisionBodies()
|
||||
drawPaths()
|
||||
drawOrigin()
|
||||
drawShapeRange()
|
||||
drawForceField()
|
||||
@@ -769,7 +1039,7 @@ onMounted(async () => {
|
||||
() =>
|
||||
store.systems
|
||||
.map((s) => JSON.stringify(s.config, (k, v) => (k === 'texture' || k === 'trailTexture' || k === 'previewUrl' ? undefined : v)))
|
||||
.join('|'),
|
||||
.join('|') + `|colliders:${JSON.stringify(store.colliders)}`,
|
||||
() => {
|
||||
const frames = store.recalcTotalFrames()
|
||||
store.timeline.recorded = false
|
||||
@@ -804,9 +1074,7 @@ onUnmounted(() => {
|
||||
padding: 8px 12px; background: rgba(20,20,32,0.85); border-radius: 10px; border: 1px solid rgba(255,255,255,0.08);
|
||||
font-size: 12px; color: #aab; z-index: 5; flex-wrap: wrap;
|
||||
}
|
||||
.brand { font-weight: 700; color: #cdf; }
|
||||
.topbar input[type=range] { background: #1a1a28; border: 1px solid #2e2e44; color: #dde; border-radius: 5px; }
|
||||
.chk { display: flex; align-items: center; gap: 3px; }
|
||||
/* 工具按钮组 */
|
||||
.tool-group { display: flex; gap: 6px; margin-left: 6px; padding-left: 12px; border-left: 1px solid #2a2a3c; }
|
||||
.tool-btn {
|
||||
@@ -820,13 +1088,19 @@ onUnmounted(() => {
|
||||
.hud { position: absolute; bottom: 12px; left: 12px; padding: 8px 12px; background: rgba(20,20,32,0.75); border-radius: 8px; font-size: 12px; color: #a0a8cc; }
|
||||
.hud-tip { margin-left: 12px; color: #5f6a8a; font-size: 11px; }
|
||||
b { color: #ffcc33; }
|
||||
.settings-btn { margin-left: 6px; }
|
||||
.settings-btn {
|
||||
height: 28px; padding: 0 12px; flex: 0 0 auto; border: 1px solid #3a4963; border-radius: 6px;
|
||||
background: #202a3b; color: #cdf; font-size: 12px; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.settings-btn:hover { border-color: #7774ff; background: #2b3a5c; color: #fff; }
|
||||
.settings-panel {
|
||||
position: absolute; top: 52px; right: 12px; width: 240px; z-index: 10;
|
||||
position: absolute; top: 52px; left: 10px; width: 240px; z-index: 10;
|
||||
background: rgba(20,20,32,0.97); border: 1px solid #2a2a3c; border-radius: 10px; padding: 10px 12px;
|
||||
font-size: 12px; color: #aab; box-shadow: 0 6px 20px rgba(0,0,0,0.4);
|
||||
}
|
||||
.sp-head { display: flex; justify-content: space-between; align-items: center; font-weight: 700; color: #cdf; margin-bottom: 10px; }
|
||||
.sp-section-title { margin: 2px 0 8px; color: #8a93bb; font-size: 11px; }
|
||||
.sp-divider { height: 1px; margin: 10px 0; background: #2a2a3c; }
|
||||
.sp-close { background: #2e3a55; border: none; color: #cdf; border-radius: 4px; width: 18px; height: 18px; cursor: pointer; font-size: 11px; }
|
||||
.sp-row { display: flex; align-items: center; gap: 8px; margin: 8px 0; }
|
||||
.sp-row > span:first-child { width: 76px; flex-shrink: 0; color: #8a93bb; }
|
||||
|
||||
Reference in New Issue
Block a user