发射器形状新增
This commit is contained in:
1 parent
0bde824c88
commit
026ee74d48
11 files changed
+1657
-1336
No files matched your search
+56
-13
@@ -287,23 +287,53 @@
|
||||
<option value="point">点</option>
|
||||
<option value="circle">圆形</option>
|
||||
<option value="rect">矩形</option>
|
||||
<option value="cone">锥体</option>
|
||||
<option value="orbit">轨道</option>
|
||||
<option value="cone">锥形</option>
|
||||
<option value="path">路径</option>
|
||||
</select>
|
||||
</label>
|
||||
<template v-if="sys.config.shape === 'circle' || sys.config.shape === 'cone'">
|
||||
<NumSlider label="半径" :min="0" :max="300" :step="1" v-model="sys.config.radius" />
|
||||
<NumSlider :label="sys.config.shape === 'cone' ? '内径' : '内半径'" :min="0" :max="sys.config.outerRadius" :step="1" v-model="sys.config.innerRadius" />
|
||||
<NumSlider :label="sys.config.shape === 'cone' ? '外径' : '外半径'" :min="sys.config.innerRadius" :max="300" :step="1" v-model="sys.config.outerRadius" />
|
||||
</template>
|
||||
<template v-if="sys.config.shape === 'cone'">
|
||||
<NumSlider label="锥形朝向" :min="-360" :max="360" :step="1" v-model="sys.config.coneDirection" />
|
||||
<NumSlider label="锥形开口角度" :min="1" :max="360" :step="1" v-model="sys.config.coneAngle" />
|
||||
</template>
|
||||
<template v-else-if="sys.config.shape === 'rect'">
|
||||
<NumSlider label="宽" :min="1" :max="400" :step="1" v-model="sys.config.rectW" />
|
||||
<NumSlider label="高" :min="1" :max="400" :step="1" v-model="sys.config.rectH" />
|
||||
<NumSlider label="内宽" :min="0" :max="sys.config.outerRectW" :step="1" v-model="sys.config.innerRectW" />
|
||||
<NumSlider label="内高" :min="0" :max="sys.config.outerRectH" :step="1" v-model="sys.config.innerRectH" />
|
||||
<NumSlider label="外宽" :min="sys.config.innerRectW" :max="400" :step="1" v-model="sys.config.outerRectW" />
|
||||
<NumSlider label="外高" :min="sys.config.innerRectH" :max="400" :step="1" v-model="sys.config.outerRectH" />
|
||||
</template>
|
||||
<template v-else-if="sys.config.shape === 'path'">
|
||||
<label class="row"><span>选择路径</span>
|
||||
<select v-model.number="sys.config.spawnPathId" class="inp" @change="onSpawnPathChange">
|
||||
<option :value="0">无路径(点发射)</option>
|
||||
<option v-for="path in store.paths" :key="path.id" :value="path.id" :disabled="!path.enabled">{{ path.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="sys.config.spawnPathId > 0" class="row"><span>出生分布</span>
|
||||
<select v-model="sys.config.spawnPathDistribution" class="inp">
|
||||
<option value="fixed">等间距固定分布</option>
|
||||
<option value="random">随机分布</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
<div class="divider"></div>
|
||||
<NumSlider :label="sys.config.shape === 'cone' ? '锥形朝向' : '发射角度'" :min="-360" :max="360" :step="1" v-model="sys.config.direction" />
|
||||
<NumSlider v-if="sys.config.shape !== 'cone'" label="扩散角度" :min="0" :max="360" :step="1" v-model="sys.config.spread" />
|
||||
<label class="row"><span>发射角度</span>
|
||||
<select v-model="sys.config.emissionAngleMode" class="inp">
|
||||
<option value="fixed">固定</option>
|
||||
<option value="random">随机</option>
|
||||
<option v-if="sys.config.shape === 'circle' || sys.config.shape === 'rect' || sys.config.shape === 'cone'" value="radial">径向(中心向外)</option>
|
||||
<option v-if="sys.config.shape === 'path' && sys.config.spawnPathId > 0" value="path-normal">垂直于路径</option>
|
||||
<option v-if="sys.config.shape === 'path' && sys.config.spawnPathId > 0" value="path-tangent">沿路径方向</option>
|
||||
</select>
|
||||
</label>
|
||||
<NumSlider v-if="sys.config.emissionAngleMode === 'fixed'" label="固定角度" :min="-360" :max="360" :step="1" v-model="sys.config.direction" />
|
||||
<template v-else-if="sys.config.emissionAngleMode === 'random'">
|
||||
<NumSlider label="基准角度" :min="-360" :max="360" :step="1" v-model="sys.config.direction" />
|
||||
<NumSlider label="随机范围" :min="0" :max="360" :step="1" v-model="sys.config.spread" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1245,6 +1275,10 @@ watchEffect(() => {
|
||||
watchEffect(() => {
|
||||
for (const system of store.systems) {
|
||||
const config = ensureEmitterConfig(system.config as any)
|
||||
if (config.spawnPathId > 0 && !store.paths.some((path) => path.id === config.spawnPathId)) {
|
||||
config.spawnPathId = 0
|
||||
if (config.emissionAngleMode === 'path-normal' || config.emissionAngleMode === 'path-tangent') config.emissionAngleMode = 'fixed'
|
||||
}
|
||||
if (config.emitterFollowMode !== 'bone') continue
|
||||
const spine = store.spines.find((item) => item.id === config.emitterFollowSpineId)
|
||||
const targetExists = !!spine?.bones.some((bone) => bone.name === config.emitterFollowBoneName)
|
||||
@@ -1404,7 +1438,11 @@ const MODULE_RESET_KEYS: Record<Exclude<ResetModule, 'export'>, string[]> = {
|
||||
'generateLoopEndAnimation', 'loopEndUseCustomDuration', 'loopEndDurationFrames',
|
||||
'burstCount', 'burstLoop', 'delay', 'duration',
|
||||
],
|
||||
shape: ['shape', 'radius', 'rectW', 'rectH', 'direction', 'spread', 'coneAngle'],
|
||||
shape: [
|
||||
'shape', 'shapeRangeVersion',
|
||||
'innerRadius', 'outerRadius', 'innerRectW', 'innerRectH', 'outerRectW', 'outerRectH', 'spawnPathId', 'spawnPathDistribution',
|
||||
'radius', 'rectW', 'rectH', 'emissionAngleMode', 'direction', 'spread', 'coneDirection', 'coneAngle',
|
||||
],
|
||||
attr: [
|
||||
'lifeMin', 'lifeMax', 'lifeMode', 'lifeCurve',
|
||||
'speedMin', 'speedMax', 'speedMode', 'speedCurve',
|
||||
@@ -1545,11 +1583,11 @@ function setScaleOverLifeMode(mode: 'fixed' | 'random' | 'curve' | 'direction')
|
||||
|
||||
// 各发射器形状的默认参数:切换形状时应用(只影响形状相关字段,不牵连其他)
|
||||
const SHAPE_DEFAULTS: Record<string, Partial<any>> = {
|
||||
point: { direction: 0, spread: 360 },
|
||||
circle: { radius: 100, direction: -90, spread: 0 },
|
||||
rect: { rectW: 200, rectH: 100, direction: -90, spread: 0 },
|
||||
cone: { radius: 100, coneAngle: 90, direction: 90 },
|
||||
orbit: {},
|
||||
point: { emissionAngleMode: 'random', direction: 0, spread: 360 },
|
||||
circle: { innerRadius: 0, outerRadius: 100, emissionAngleMode: 'fixed', direction: -90, spread: 0 },
|
||||
rect: { innerRectW: 0, innerRectH: 0, outerRectW: 200, outerRectH: 100, emissionAngleMode: 'fixed', direction: -90, spread: 0 },
|
||||
cone: { innerRadius: 0, outerRadius: 100, coneAngle: 90, coneDirection: 90, emissionAngleMode: 'random', direction: 90, spread: 90 },
|
||||
path: { spawnPathId: 0, spawnPathDistribution: 'fixed', emissionAngleMode: 'fixed', direction: 0, spread: 0 },
|
||||
}
|
||||
function onShapeChange() {
|
||||
const cfg = sys.value?.config
|
||||
@@ -1558,6 +1596,11 @@ function onShapeChange() {
|
||||
if (!d) return
|
||||
Object.assign(cfg, d)
|
||||
}
|
||||
function onSpawnPathChange() {
|
||||
const cfg = sys.value?.config
|
||||
if (!cfg || cfg.spawnPathId > 0) return
|
||||
if (cfg.emissionAngleMode === 'path-normal' || cfg.emissionAngleMode === 'path-tangent') cfg.emissionAngleMode = 'fixed'
|
||||
}
|
||||
// 重置/删除图片 → 回到默认 star.png
|
||||
const textureFileInput = ref<HTMLInputElement | null>(null)
|
||||
const sequenceFileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
+57
-58
@@ -188,6 +188,8 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Application, Container, Graphics, Sprite, Text, Texture } from 'pixi.js'
|
||||
import { ParticleEmitter, ensureEmitterConfig, type ParticleState } from '../core/particleEmitter'
|
||||
import { normalizedRadialRange, normalizedRectRange } from '../core/emitterShape'
|
||||
import { sampleScenePath } from '../core/scenePathSampler'
|
||||
import { bakeLoopFrames } from '../core/loopFrameBaker'
|
||||
import { loopPlaybackRange } from '../core/loopSegmentTiming'
|
||||
import { useParticleStore, type CollisionBody, type ParticleSystem, type ScenePath } from '../store/particleStore'
|
||||
@@ -985,7 +987,7 @@ function drawBoneFollowTargets() {
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制当前选中系统的发射器形状范围(点/轨道不绘制),随参数实时响应
|
||||
// 绘制当前选中系统的发射器形状范围(点/路径不额外绘制),随参数实时响应。
|
||||
function drawShapeRange() {
|
||||
if (!shapeGfx) return
|
||||
const g = shapeGfx
|
||||
@@ -999,7 +1001,7 @@ function drawShapeRange() {
|
||||
const cx = preview?.x ?? c.centerX ?? 0
|
||||
const cy = -(preview?.y ?? c.centerY ?? 0)
|
||||
const shape = c.shape
|
||||
if (shape === 'point' || shape === 'orbit') return // 点不绘制,轨道保留空位
|
||||
if (shape === 'point' || shape === 'path') return
|
||||
// 相对 root 中心的局部点(lx,ly,数学坐标)先加偏移(发射源 = root 中心 + offset),再应用 root 缩放+旋转,加 root 中心 → 世界坐标
|
||||
const sxr = c.rootScaleX ?? 1, syr = c.rootScaleY ?? 1
|
||||
const rot = -(preview?.rotation ?? c.rootRotation ?? 0) * Math.PI / 180 // 数学上正 → 屏幕:逆时针为正
|
||||
@@ -1012,36 +1014,48 @@ function drawShapeRange() {
|
||||
return [cx + x * cs - y * sn, cy + (x * sn + y * cs)]
|
||||
}
|
||||
if (shape === 'circle') {
|
||||
const range = normalizedRadialRange(c.innerRadius, c.outerRadius)
|
||||
g.lineStyle(2, 0x3ce86a, 0.7)
|
||||
// 圆:以 root 变换后的椭圆近似(逐点)
|
||||
// 圆环:以 root 变换后的椭圆近似(逐点)
|
||||
const steps = 48
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const a = i / steps * Math.PI * 2
|
||||
const [px, py] = pt(Math.cos(a) * c.radius, Math.sin(a) * c.radius)
|
||||
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py)
|
||||
for (const radius of [range.outer, range.inner]) {
|
||||
if (radius <= 0) continue
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const a = i / steps * Math.PI * 2
|
||||
const [px, py] = pt(Math.cos(a) * radius, Math.sin(a) * radius)
|
||||
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py)
|
||||
}
|
||||
}
|
||||
} else if (shape === 'rect') {
|
||||
const hw = c.rectW / 2, hh = c.rectH / 2
|
||||
const p1 = pt(-hw, -hh), p2 = pt(hw, -hh), p3 = pt(hw, hh), p4 = pt(-hw, hh)
|
||||
const range = normalizedRectRange(c)
|
||||
g.lineStyle(2, 0x4a8cff, 0.7)
|
||||
g.moveTo(p1[0], p1[1]); g.lineTo(p2[0], p2[1]); g.lineTo(p3[0], p3[1]); g.lineTo(p4[0], p4[1]); g.lineTo(p1[0], p1[1])
|
||||
for (const [width, height] of [[range.outerW, range.outerH], [range.innerW, range.innerH]]) {
|
||||
if (width <= 0 || height <= 0) continue
|
||||
const hw = width / 2, hh = height / 2
|
||||
const p1 = pt(-hw, -hh), p2 = pt(hw, -hh), p3 = pt(hw, hh), p4 = pt(-hw, hh)
|
||||
g.moveTo(p1[0], p1[1]); g.lineTo(p2[0], p2[1]); g.lineTo(p3[0], p3[1]); g.lineTo(p4[0], p4[1]); g.lineTo(p1[0], p1[1])
|
||||
}
|
||||
} else if (shape === 'cone') {
|
||||
const r = c.radius
|
||||
const range = normalizedRadialRange(c.innerRadius, c.outerRadius)
|
||||
const half = (c.coneAngle / 2) * Math.PI / 180
|
||||
const base = c.direction * Math.PI / 180
|
||||
// 锥形两条边 + 弧
|
||||
const edge1 = pt(Math.cos(base - half) * r, Math.sin(base - half) * r)
|
||||
const edge2 = pt(Math.cos(base + half) * r, Math.sin(base + half) * r)
|
||||
const base = c.coneDirection * Math.PI / 180
|
||||
// 锥形环带的两条边、内弧和外弧。
|
||||
const innerEdge1 = pt(Math.cos(base - half) * range.inner, Math.sin(base - half) * range.inner)
|
||||
const innerEdge2 = pt(Math.cos(base + half) * range.inner, Math.sin(base + half) * range.inner)
|
||||
const outerEdge1 = pt(Math.cos(base - half) * range.outer, Math.sin(base - half) * range.outer)
|
||||
const outerEdge2 = pt(Math.cos(base + half) * range.outer, Math.sin(base + half) * range.outer)
|
||||
g.lineStyle(2, 0x3ce86a, 0.7)
|
||||
g.moveTo(cx, cy); g.lineTo(edge1[0], edge1[1])
|
||||
g.moveTo(cx, cy); g.lineTo(edge2[0], edge2[1])
|
||||
// 弧
|
||||
g.moveTo(innerEdge1[0], innerEdge1[1]); g.lineTo(outerEdge1[0], outerEdge1[1])
|
||||
g.moveTo(innerEdge2[0], innerEdge2[1]); g.lineTo(outerEdge2[0], outerEdge2[1])
|
||||
g.lineStyle(1, 0x3ce86a, 0.5)
|
||||
const steps = 24
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const a = base - half + (2 * half * i / steps)
|
||||
const [px, py] = pt(Math.cos(a) * r, Math.sin(a) * r)
|
||||
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py)
|
||||
for (const radius of [range.outer, range.inner]) {
|
||||
if (radius <= 0) continue
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const a = base - half + (2 * half * i / steps)
|
||||
const [px, py] = pt(Math.cos(a) * radius, Math.sin(a) * radius)
|
||||
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1343,42 +1357,6 @@ function cubicPoint(a: number, b: number, c: number, d: number, t: number) {
|
||||
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 buildPreviewColliders(timeSeconds: number) {
|
||||
previewColliders = store.colliders.map((collider) => {
|
||||
if (!collider.followPath || !collider.followPathId) return collider
|
||||
@@ -1417,6 +1395,20 @@ function worldPointToEmitterLocal(
|
||||
return { x: (cos * screenX + sin * screenY) / scaleX, y: (-sin * screenX + cos * screenY) / scaleY }
|
||||
}
|
||||
|
||||
function sampleSpawnPathForEmitter(
|
||||
config: { centerX: number; centerY: number; rootRotation: number; rootScaleX: number; rootScaleY: number },
|
||||
path: ScenePath,
|
||||
progress: number,
|
||||
) {
|
||||
const sample = sampleScenePath(path, progress)
|
||||
if (!sample) return null
|
||||
const local = worldPointToEmitterLocal(config, sample.x, sample.y)
|
||||
const radians = sample.angle * Math.PI / 180
|
||||
const tangentWorld = worldPointToEmitterLocal(config, sample.x + Math.cos(radians), sample.y + Math.sin(radians))
|
||||
const angle = Math.atan2(tangentWorld.y - local.y, tangentWorld.x - local.x) * 180 / Math.PI
|
||||
return { ...local, angle }
|
||||
}
|
||||
|
||||
function loop() {
|
||||
raf = requestAnimationFrame(loop)
|
||||
const now = performance.now() / 1000
|
||||
@@ -1517,6 +1509,13 @@ function loop() {
|
||||
em.rotation = -c.rootRotation * Math.PI / 180
|
||||
em.setSimulationTransform(c.centerX, c.centerY, c.rootRotation)
|
||||
}
|
||||
const spawnPath = c.shape === 'path' && c.spawnPathId > 0
|
||||
? store.paths.find((path) => path.id === c.spawnPathId && path.enabled && path.points.length > 0) || null
|
||||
: null
|
||||
em.setSpawnPathSampler(
|
||||
spawnPath ? (progress) => sampleSpawnPathForEmitter(c, spawnPath, progress) : null,
|
||||
spawnPath?.closed === true,
|
||||
)
|
||||
emitterPreviewTransform.set(sys.id, preview)
|
||||
}
|
||||
let total = 0
|
||||
|
||||
Reference in new issue
Block a user