阶段六开发进度
This commit is contained in:
1 parent
f27167621c
commit
b98d67fdab
5 files changed
+309
-66
No files matched your search
+134
-13
@@ -99,6 +99,7 @@ let dotTex: Texture<any> | null = null
|
||||
let trailTex: Texture<any> | null = null
|
||||
|
||||
const emitterMap = new Map<number, ParticleEmitter>()
|
||||
const emitterPreviewTransform = new Map<number, { x: number; y: number; rotation: number }>()
|
||||
const selectedPathNodes = new Set<number>()
|
||||
let suppressNextPathDoubleClick = false
|
||||
watch(() => [store.activeObjectType, store.activeObjectId], () => selectedPathNodes.clear())
|
||||
@@ -311,6 +312,23 @@ function activePath() {
|
||||
return store.paths.find((item) => item.id === store.activeObjectId) || null
|
||||
}
|
||||
|
||||
function pathInsertIndex(path: ScenePath, x: number, y: number) {
|
||||
if (path.points.length < 2) return path.points.length
|
||||
const segments = path.closed ? path.points.length : path.points.length - 1
|
||||
let bestIndex = path.points.length
|
||||
let bestDistance = Infinity
|
||||
for (let index = 0; index < segments; index++) {
|
||||
const a = path.points[index], b = path.points[(index + 1) % path.points.length]
|
||||
const dx = b.x - a.x, dy = b.y - a.y
|
||||
const lengthSq = Math.max(0.0001, dx * dx + dy * dy)
|
||||
const amount = Math.min(1, Math.max(0, ((x - a.x) * dx + (y - a.y) * dy) / lengthSq))
|
||||
const px = a.x + dx * amount, py = a.y + dy * amount
|
||||
const distance = Math.hypot(x - px, y - py)
|
||||
if (distance < bestDistance) { bestDistance = distance; bestIndex = index + 1 }
|
||||
}
|
||||
return bestIndex
|
||||
}
|
||||
|
||||
function onStageDoubleClick(e: MouseEvent) {
|
||||
const path = activePath()
|
||||
if (!path || !path.enabled) return
|
||||
@@ -324,9 +342,10 @@ function onStageDoubleClick(e: MouseEvent) {
|
||||
selectedPathNodes.clear()
|
||||
return
|
||||
}
|
||||
path.points.push({ x: local.x, y: local.y, inX: 0, inY: 0, outX: 0, outY: 0 })
|
||||
const insertIndex = pathInsertIndex(path, local.x, local.y)
|
||||
path.points.splice(insertIndex, 0, { x: local.x, y: local.y, inX: 0, inY: 0, outX: 0, outY: 0 })
|
||||
selectedPathNodes.clear()
|
||||
selectedPathNodes.add(path.points.length - 1)
|
||||
selectedPathNodes.add(insertIndex)
|
||||
}
|
||||
|
||||
function onPathPointerDown(e: PointerEvent) {
|
||||
@@ -339,9 +358,10 @@ function onPathPointerDown(e: PointerEvent) {
|
||||
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)
|
||||
const insertIndex = pathInsertIndex(path, local.x, local.y)
|
||||
path.points.splice(insertIndex, 0, anchor)
|
||||
selectedPathNodes.clear()
|
||||
selectedPathNodes.add(path.points.length - 1)
|
||||
selectedPathNodes.add(insertIndex)
|
||||
suppressNextPathDoubleClick = true
|
||||
installPointerDrag((event) => {
|
||||
const worldPoint = pointerToWorldMath(event.clientX, event.clientY)
|
||||
@@ -603,9 +623,10 @@ function drawOrigin() {
|
||||
// 每个系统对应一个发射点(坐标点);没有系统时不画
|
||||
if (!store.systems.length) return
|
||||
for (const sys of store.systems) {
|
||||
const cx = sys.config.centerX ?? 0
|
||||
const cy = -(sys.config.centerY ?? 0)
|
||||
const active = sys.id === store.activeId
|
||||
const preview = emitterPreviewTransform.get(sys.id)
|
||||
const cx = preview?.x ?? sys.config.centerX ?? 0
|
||||
const cy = -(preview?.y ?? sys.config.centerY ?? 0)
|
||||
const active = store.activeObjectType === 'particle' && sys.id === store.activeId
|
||||
const col = active ? 0xffcc33 : 0x8a94b8
|
||||
const opacity = active ? 0.4 : 0.2
|
||||
g.lineStyle(1, col, opacity)
|
||||
@@ -628,14 +649,15 @@ function drawShapeRange() {
|
||||
const sys = store.systems.find((s) => s.id === store.activeId)
|
||||
if (!sys) return
|
||||
const c = sys.config
|
||||
const preview = emitterPreviewTransform.get(sys.id)
|
||||
// root 中心(发射器容器 position = centerX/-centerY),数学坐标
|
||||
const cx = c.centerX ?? 0
|
||||
const cy = -(c.centerY ?? 0)
|
||||
const cx = preview?.x ?? c.centerX ?? 0
|
||||
const cy = -(preview?.y ?? c.centerY ?? 0)
|
||||
const shape = c.shape
|
||||
if (shape === 'point' || shape === 'orbit') return // 点不绘制,轨道保留空位
|
||||
// 相对 root 中心的局部点(lx,ly,数学坐标)先加偏移(发射源 = root 中心 + offset),再应用 root 缩放+旋转,加 root 中心 → 世界坐标
|
||||
const sxr = c.rootScaleX ?? 1, syr = c.rootScaleY ?? 1
|
||||
const rot = -(c.rootRotation ?? 0) * Math.PI / 180 // 数学上正 → 屏幕:逆时针为正
|
||||
const rot = -(preview?.rotation ?? c.rootRotation ?? 0) * Math.PI / 180 // 数学上正 → 屏幕:逆时针为正
|
||||
const cs = Math.cos(rot), sn = Math.sin(rot)
|
||||
const oxo = c.offsetX ?? 0, oyo = c.offsetY ?? 0
|
||||
const pt = (lx: number, ly: number): [number, number] => {
|
||||
@@ -919,6 +941,71 @@ function drawPaths() {
|
||||
}
|
||||
}
|
||||
|
||||
function followCurveValue(points: Array<{ x: number; y: number }>, t: number) {
|
||||
if (!points.length) return t
|
||||
const sorted = [...points].sort((a, b) => a.x - b.x)
|
||||
if (t <= sorted[0].x) return sorted[0].y
|
||||
for (let index = 1; index < sorted.length; index++) {
|
||||
const a = sorted[index - 1], b = sorted[index]
|
||||
if (t <= b.x) {
|
||||
const amount = (t - a.x) / Math.max(0.0001, b.x - a.x)
|
||||
return a.y + (b.y - a.y) * amount
|
||||
}
|
||||
}
|
||||
return sorted[sorted.length - 1].y
|
||||
}
|
||||
|
||||
function cubicPoint(a: number, b: number, c: number, d: number, t: number) {
|
||||
const u = 1 - t
|
||||
return u * u * u * a + 3 * u * u * t * b + 3 * u * t * t * c + t * t * t * d
|
||||
}
|
||||
|
||||
function sampleScenePath(path: ScenePath, progress: number) {
|
||||
if (!path.points.length) return null
|
||||
if (path.points.length === 1) {
|
||||
const point = pathLocalToWorld(path, path.points[0].x, path.points[0].y)
|
||||
return { ...point, angle: path.rotation }
|
||||
}
|
||||
const segmentCount = path.closed ? path.points.length : path.points.length - 1
|
||||
const samples: Array<{ x: number; y: number; distance: number }> = []
|
||||
let totalDistance = 0
|
||||
for (let segment = 0; segment < segmentCount; segment++) {
|
||||
const a = path.points[segment]
|
||||
const b = path.points[(segment + 1) % path.points.length]
|
||||
for (let step = 0; step <= 20; step++) {
|
||||
if (segment > 0 && step === 0) continue
|
||||
const t = step / 20
|
||||
const localX = cubicPoint(a.x, a.x + a.outX, b.x + b.inX, b.x, t)
|
||||
const localY = cubicPoint(a.y, a.y + a.outY, b.y + b.inY, b.y, t)
|
||||
const worldPoint = pathLocalToWorld(path, localX, localY)
|
||||
const previous = samples[samples.length - 1]
|
||||
if (previous) totalDistance += Math.hypot(worldPoint.x - previous.x, worldPoint.y - previous.y)
|
||||
samples.push({ ...worldPoint, distance: totalDistance })
|
||||
}
|
||||
}
|
||||
const target = Math.min(1, Math.max(0, progress)) * totalDistance
|
||||
let index = samples.findIndex((sample) => sample.distance >= target)
|
||||
if (index < 0) index = samples.length - 1
|
||||
const current = samples[index]
|
||||
const previous = samples[Math.max(0, index - 1)]
|
||||
const next = samples[Math.min(samples.length - 1, index + 1)]
|
||||
const beforeDistance = previous.distance
|
||||
const amount = index > 0 ? (target - beforeDistance) / Math.max(0.0001, current.distance - beforeDistance) : 0
|
||||
const x = previous.x + (current.x - previous.x) * Math.min(1, Math.max(0, amount))
|
||||
const y = previous.y + (current.y - previous.y) * Math.min(1, Math.max(0, amount))
|
||||
return { x, y, angle: Math.atan2(next.y - previous.y, next.x - previous.x) * 180 / Math.PI }
|
||||
}
|
||||
|
||||
function worldPointToEmitterLocal(config: { centerX: number; centerY: number; rootRotation: number; rootScaleX: number; rootScaleY: number }, worldX: number, worldY: number) {
|
||||
const scaleX = Math.max(0.0001, Math.abs(config.rootScaleX))
|
||||
const scaleY = Math.max(0.0001, Math.abs(config.rootScaleY))
|
||||
const angle = -config.rootRotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const screenX = worldX - config.centerX
|
||||
const screenY = -worldY + config.centerY
|
||||
return { x: (cos * screenX + sin * screenY) / scaleX, y: (-sin * screenX + cos * screenY) / scaleY }
|
||||
}
|
||||
|
||||
function loop() {
|
||||
raf = requestAnimationFrame(loop)
|
||||
const now = performance.now() / 1000
|
||||
@@ -933,14 +1020,48 @@ function loop() {
|
||||
drawAxis()
|
||||
syncEmitters()
|
||||
// 根节点(root)变换:位置(centerX/centerY)=位移,rotation/scale 绕 root 中心,与场景对象参数联动
|
||||
const followTime = store.timeline.frame / Math.max(1, store.timeline.fps)
|
||||
for (const sys of store.systems) {
|
||||
const em = emitterMap.get(sys.id)
|
||||
if (!em) continue
|
||||
const c = sys.config
|
||||
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)
|
||||
em.setEmitterFollowSpawn(0, 0, 0)
|
||||
let preview = { x: c.centerX, y: c.centerY, rotation: c.rootRotation }
|
||||
const followPath = c.emitterFollow && c.emitterFollowMode === 'path'
|
||||
? store.paths.find((path) => path.id === c.emitterFollowPathId && path.enabled)
|
||||
: null
|
||||
const duration = Math.max(0.05, c.emitterFollowDuration)
|
||||
const normalizedTime = Math.min(1, Math.max(0, followTime / duration))
|
||||
const eased = Math.min(1, Math.max(0, followCurveValue(c.emitterFollowCurve, normalizedTime)))
|
||||
const offset = Math.min(1, Math.max(0, c.emitterFollowOffset))
|
||||
const pathProgress = c.emitterFollowDirection === 'reverse'
|
||||
? (1 - offset) * (1 - eased)
|
||||
: offset + (1 - offset) * eased
|
||||
const pathSample = followPath ? sampleScenePath(followPath, pathProgress) : null
|
||||
if (pathSample) {
|
||||
const movementAngle = pathSample.angle + (c.emitterFollowDirection === 'reverse' ? 180 : 0)
|
||||
const effectiveAngle = c.emitterAngleMode === 'direction' ? movementAngle : c.rootRotation
|
||||
preview = { x: pathSample.x, y: pathSample.y, rotation: effectiveAngle }
|
||||
if (c.emitterFollowSpace === 'local') {
|
||||
em.position.set(pathSample.x, -pathSample.y)
|
||||
em.rotation = -effectiveAngle * Math.PI / 180
|
||||
em.setSimulationTransform(pathSample.x, pathSample.y, effectiveAngle)
|
||||
} else {
|
||||
em.position.set(c.centerX, -c.centerY)
|
||||
em.rotation = -c.rootRotation * Math.PI / 180
|
||||
em.setSimulationTransform(c.centerX, c.centerY, c.rootRotation)
|
||||
const local = worldPointToEmitterLocal(c, pathSample.x, pathSample.y)
|
||||
const relativeDirection = c.emitterAngleMode === 'direction' ? -(movementAngle - c.rootRotation) : 0
|
||||
em.setEmitterFollowSpawn(local.x, local.y, relativeDirection)
|
||||
}
|
||||
} else {
|
||||
em.position.set(c.centerX, -c.centerY) // y 数学上正 → 屏幕取负
|
||||
em.rotation = -c.rootRotation * Math.PI / 180
|
||||
em.setSimulationTransform(c.centerX, c.centerY, c.rootRotation)
|
||||
}
|
||||
emitterPreviewTransform.set(sys.id, preview)
|
||||
}
|
||||
let total = 0
|
||||
const collected: BonePreview[] = []
|
||||
@@ -1039,7 +1160,7 @@ onMounted(async () => {
|
||||
() =>
|
||||
store.systems
|
||||
.map((s) => JSON.stringify(s.config, (k, v) => (k === 'texture' || k === 'trailTexture' || k === 'previewUrl' ? undefined : v)))
|
||||
.join('|') + `|colliders:${JSON.stringify(store.colliders)}`,
|
||||
.join('|') + `|colliders:${JSON.stringify(store.colliders)}|paths:${JSON.stringify(store.paths)}`,
|
||||
() => {
|
||||
const frames = store.recalcTotalFrames()
|
||||
store.timeline.recorded = false
|
||||
|
||||
Reference in new issue
Block a user