创建碰撞与路径

This commit is contained in:
tianmo committed 2026-08-28 18:24:29 +08:00
1 parent f2fe653c04
commit f27167621c
4 files changed
+783 -56

No files matched your search

+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; }