创建碰撞与路径

This commit is contained in:
tianmo
2026-08-28 18:24:29 +08:00
parent f2fe653c04
commit f27167621c
4 changed files with 783 additions and 56 deletions
+167 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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; }