阶段六开发进度

This commit is contained in:
tianmo committed 2026-08-29 01:31:08 +08:00
1 parent f27167621c
commit b98d67fdab
5 files changed
+309 -66

No files matched your search

+45 -11
View File
@@ -88,26 +88,26 @@
<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" />
<label class="compact-field"><span>X 位置</span><input v-model.number="activeCollider.x" type="number" class="inp" /></label>
<label class="compact-field"><span>Y 位置</span><input v-model.number="activeCollider.y" type="number" class="inp" /></label>
</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" />
<label class="compact-field"><span>X 半径</span><input v-model.number="activeCollider.radiusX" type="number" min="1" class="inp" /></label>
<label class="compact-field"><span>Y 半径</span><input v-model.number="activeCollider.radiusY" type="number" min="1" class="inp" /></label>
</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" />
<label class="compact-field"><span>宽度</span><input v-model.number="activeCollider.width" type="number" min="1" class="inp" /></label>
<label class="compact-field"><span>高度</span><input v-model.number="activeCollider.height" type="number" min="1" class="inp" /></label>
</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" />
<label class="compact-field"><span>弹性 (0-1)</span><input v-model.number="activeCollider.elasticity" type="number" min="0" max="1" step="0.01" class="inp" /></label>
<label class="compact-field"><span>摩擦 (0-1)</span><input v-model.number="activeCollider.friction" type="number" min="0" max="1" step="0.01" class="inp" /></label>
</div>
<label class="follow-path"><input v-model="activeCollider.followPath" type="checkbox" />跟随轨道移动</label>
</template>
@@ -619,8 +619,41 @@
<div class="modifier-hint">每个粒子生成独立拖尾骨骼链;网格按行列自动重建,并由相邻骨骼权重共同驱动蒙皮变形</div>
</div>
</div>
<label class="row"><span>碰撞</span><input type="checkbox" v-model="sys.config.collision" /><span class="tag">预留</span></label>
<label class="row"><span>路径跟随</span><input type="checkbox" v-model="sys.config.pathFollow" /><span class="tag">预留</span></label>
<div class="modifier-item">
<label class="switch-row modifier-switch">
<input type="checkbox" v-model="sys.config.emitterFollow" />
<span class="switch-ui"></span><span>开启发射器跟随 (Emitter Follow)</span>
</label>
<div v-if="sys.config.emitterFollow" class="modifier-params emitter-follow-params">
<label class="field-block"><span>跟随模式</span><select v-model="sys.config.emitterFollowMode" class="inp">
<option value="none">无</option>
<option value="path">跟随轨道</option>
</select></label>
<label v-if="sys.config.emitterFollowMode === 'path'" class="field-block"><span>跟随轨道</span><select v-model.number="sys.config.emitterFollowPathId" class="inp">
<option :value="0">无</option>
<option v-for="path in store.paths" :key="path.id" :value="path.id">{{ path.name }}</option>
</select></label>
<label class="field-block"><span>发射器角度模式</span><select v-model="sys.config.emitterAngleMode" class="inp">
<option value="fixed">固定角度</option>
<option value="direction">跟随移动方向</option>
</select></label>
<div class="gravity-curve-title"><span>移动进度曲线 (0→1)</span></div>
<CurveEditor v-model="sys.config.emitterFollowCurve" :default-value="LINEAR_CURVE" />
<div class="field-grid follow-grid">
<label class="compact-field"><span>移动时长 (s)</span><input v-model.number="sys.config.emitterFollowDuration" type="number" min="0.05" step="0.05" class="inp" /></label>
<label class="compact-field"><span>跟随空间</span><select v-model="sys.config.emitterFollowSpace" class="inp">
<option value="local">局部空间(GEN 移动)</option>
<option value="world">世界空间(粒子生成点移动)</option>
</select></label>
</div>
<label class="field-block"><span>移动方向</span><select v-model="sys.config.emitterFollowDirection" class="inp">
<option value="forward">正向(起点 → 终点)</option>
<option value="reverse">反向(终点 → 起点)</option>
</select></label>
<NumSlider label="偏移起始点 (0-1)" :min="0" :max="1" :step="0.01" v-model="sys.config.emitterFollowOffset" />
<div class="modifier-hint">局部空间会移动整个发射器;世界空间只移动新粒子的生成点,已出生粒子留在世界位置。</div>
</div>
</div>
<NumSlider label="阻尼" :min="0" :max="2" :step="0.05" v-model="sys.config.damping" />
</div>
</div>
@@ -1207,7 +1240,8 @@ function toggleResourceLock(resourceId: number) {
.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; }
.compact-field { display: flex; min-width: 0; flex-direction: column; gap: 5px; margin: 5px 0; color: #8f9ab3; font-size: 11px; }
.compact-field .inp { width: 100%; min-height: 30px; box-sizing: border-box; }
.check-field { align-items: flex-start; }
.check-field input { width: 18px; height: 18px; margin: 4px 0; accent-color: #4f7ee8; }
.tag-editor { display: flex; flex-wrap: wrap; gap: 5px; }
+134 -13
View File
@@ -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
+49 -11
View File
@@ -70,6 +70,10 @@ const vpRef = ref<HTMLElement | null>(null)
const fps = 60
/** 视口当前能显示的帧数(滚轮缩放它)。视口起点恒为第 0 帧,不滚动 */
const viewFrames = ref(51)
// 拖动期间只更新本地预览值,松手后再一次性写回配置。
// 避免 pointermove 每一帧都触发 Stage 清缓存、重置粒子模拟。
const draftDelays = ref<Record<number, number>>({})
const draftDurations = ref<Record<number, number>>({})
const totalFrames = computed(() => store.timeline.totalFrames)
const zoomLabel = computed(() => (viewFrames.value <= 30 ? '放大' : viewFrames.value >= 300 ? '缩小' : '适中'))
@@ -101,13 +105,23 @@ const playPx = computed(() => store.timeline.frame / pxPerF())
function barLeftPx(sys: SysLike) {
const s = (sys.config as any)
// 左端 = 延迟发射时长(delay);左端之前是空白=延迟
return ((s.delay || 0) * fps) / pxPerF()
const delay = draftDelays.value[sys.id] ?? Math.max(0, Number(s.delay) || 0)
return (delay * fps) / pxPerF()
}
function maxParticleLife(sys: SysLike) {
const s = (sys.config as any)
const lifeMin = Math.max(0, Number(s.lifeMin) || 0)
const lifeMax = Math.max(0, Number(s.lifeMax) || 0)
return s.lifeMode === 'fixed' ? lifeMin : Math.max(lifeMin, lifeMax)
}
function barWidthPx(sys: SysLike) {
const s = (sys.config as any)
// 宽度 = 粒子系统总时长 = 发射时长(duration) + 粒子最大生命(lifeMax)
// 宽度 = 粒子系统总时长 = 发射时长(duration) + 当前模式下的最大粒子生命
// 即从 delay 开始,覆盖到最后一个粒子消失,右端与 totalFrames 对齐
return ((s.duration || 0) * fps + (s.lifeMax || 0) * fps) / pxPerF()
const duration = draftDurations.value[sys.id] ?? Math.max(0, Number(s.duration) || 0)
return ((duration + maxParticleLife(sys)) * fps) / pxPerF()
}
// 滚轮缩放:只改变视口显示的帧数(左侧恒为 0 帧),不改变播放时长/totalFrames
@@ -137,32 +151,56 @@ function onScrubDown(e: PointerEvent) {
function onBarDown(e: PointerEvent, sys: SysLike) {
e.stopPropagation()
const startX = e.clientX
const startDelay = (sys.config as any).delay || 0
const startDelay = Math.max(0, Number((sys.config as any).delay) || 0)
const secPerPx = pxPerF() / fps
const move = (ev: PointerEvent) => {
const dSec = (ev.clientX - startX) * secPerPx
let nd = Math.max(0, Math.min(startDelay + dSec, (store.timeline.totalFrames / fps)))
;(sys.config as any).delay = Math.round(nd * fps) / fps
const nd = Math.max(0, Math.min(startDelay + dSec, 600))
draftDelays.value[sys.id] = Math.round(nd * fps) / fps
}
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
const finish = (commit: boolean) => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
window.removeEventListener('pointercancel', cancel)
const value = draftDelays.value[sys.id]
delete draftDelays.value[sys.id]
if (commit && value != null && value !== startDelay) (sys.config as any).delay = value
}
const up = () => finish(true)
const cancel = () => finish(false)
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
window.addEventListener('pointercancel', cancel)
}
// 拖动粒子条右缘:调整发射时长 duration
function onBarResizeDown(e: PointerEvent, sys: SysLike) {
e.stopPropagation()
const startX = e.clientX
const startDur = (sys.config as any).duration || 0.1
const startDur = Math.max(0, Number((sys.config as any).duration) || 0)
const secPerPx = pxPerF() / fps
let moved = false
const move = (ev: PointerEvent) => {
moved = true
const dSec = (ev.clientX - startX) * secPerPx
let nd = Math.max(0.1, Math.min(startDur + dSec, store.timeline.totalFrames / fps))
;(sys.config as any).duration = Math.round(nd * fps) / fps
let nd = Math.max(0, Math.min(startDur + dSec, 600))
// 有限发射最短为 1 帧;0 继续保留“无限发射”的既有语义。
if (nd > 0) nd = Math.max(1 / fps, nd)
draftDurations.value[sys.id] = Math.round(nd * fps) / fps
}
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
const finish = (commit: boolean) => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
window.removeEventListener('pointercancel', cancel)
const value = draftDurations.value[sys.id]
delete draftDurations.value[sys.id]
if (commit && moved && value != null && value !== startDur) (sys.config as any).duration = value
}
const up = () => finish(true)
const cancel = () => finish(false)
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
window.addEventListener('pointercancel', cancel)
}
</script>