资源拆分

This commit is contained in:
tianmo committed 2026-09-11 10:00:38 +08:00
1 parent f810e712ec
commit 83890faaea
38 files changed
+4586 -4144

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
import { Text, type Application, type Container, type Graphics } from 'pixi.js'
import type { Ref } from 'vue'
import type { useParticleStore, CollisionBody, ScenePath } from '../../store/particleStore'
import type { ParticleState } from '../../core/particleTypes'
import type { SpineRuntimeLayer } from '../../spine/SpineRuntimeLayer'
import type { BonePreview, SceneTransformTarget } from './stageTypes'
import { pathLocalToWorld } from './stageGeometry'
type EditorStore = ReturnType<typeof useParticleStore>
// 自适应刻度步进(仿时间轴):返回 1/2/5 序列里的最合适步长,保证屏幕间距合理
export function niceStep(target: number, minStep = 0): number {
if (target <= 0) return 1
const pow = Math.pow(10, Math.floor(Math.log10(target)))
const candidates = [1, 2, 5, 10, 20, 50, 100]
let best = 1
for (const c of candidates) {
if (c * pow >= target) { best = c * pow; break }
}
return Math.max(best, minStep)
}
// 画无限长 x/y 轴 + 自适应整数刻度 + 数值(0,0 在画布中心)
export function drawAxis(store: EditorStore, app: Application | null, axisGfx: Graphics | null, axisLabelLayer: Container | null, axisLabels: Text[]) {
if (!axisGfx) return
const g = axisGfx
g.clear()
const S = store.settings
if (!S.tickEnabled) { // 未启用刻度:隐藏整个坐标轴(轴+刻度+数值)
for (const t of axisLabels) t.visible = false
return
}
const cx = 0, cy = 0 // 世界原点(中心)
const halfW = app!.screen.width / store.editor.viewScale
const halfH = app!.screen.height / store.editor.viewScale
const axisColor = Number('0x' + S.axisColor.replace('#', ''))
// 轴线(贯穿到视口边缘外)
g.lineStyle(1.5, axisColor, 0.35)
g.moveTo(-halfW, cy); g.lineTo(halfW, cy) // x 轴
g.moveTo(cx, -halfH); g.lineTo(cx, halfH) // y 轴
// 刻度步进:目标 ~50px 一个刻度(世界单位 = px/viewScale)
const sc = store.editor.viewScale
const step = niceStep(50 / sc)
// 数值显示范围:只在中心 ±range 内标数值(避免远端堆积/干扰);范围随视口半宽
const rangeW = halfW
const rangeH = halfH
// 刻度线(贯穿整个视口;仅启用刻度时画)
const tickW = Math.max(1, S.tickWidth)
if (S.tickEnabled) {
for (let x = 0; x <= halfW; x += step) {
const v = Math.round(x / step) * step
if (v === 0) continue
g.lineStyle(tickW, axisColor, 0.25)
g.moveTo(v, cy - 4); g.lineTo(v, cy + 4)
g.moveTo(-v, cy - 4); g.lineTo(-v, cy + 4)
}
for (let y = 0; y <= halfH; y += step) {
const v = Math.round(y / step) * step
if (v === 0) continue
g.lineStyle(tickW, axisColor, 0.25)
g.moveTo(cx - 4, y); g.lineTo(cx + 4, y)
g.moveTo(cx - 4, -y); g.lineTo(cx + 4, -y)
}
}
// 数值标签(文字放 axisLabelLayer,不随 world 缩放,尺寸/清晰度恒定)
// 只在中心 ±range 内标注数值,超出不显示,避免看不清/干扰
const cxS = app!.screen.width / 2 + store.editor.viewX
const cyS = app!.screen.height * 0.5 + store.editor.viewY
const fs = S.axisFontSize // 字号由系统设置手动控制(默认 12px)
const labelColor = Number('0x' + S.axisColor.replace('#', ''))
while (axisLabels.length < 64) {
const t = new Text('', { fontFamily: 'monospace', fontSize: fs, fill: labelColor })
t.anchor.set(0.5, 0.5)
axisLabelLayer!.addChild(t)
axisLabels.push(t)
}
let li = 0
const showLbl = S.showAxisLabels && S.tickEnabled
// x 轴正/负数值(放在轴下方一点)
for (let x = 0; x <= halfW; x += step) {
const v = Math.round(x / step) * step
if (v === 0 || !showLbl || v > rangeW) continue
const lbl = axisLabels[li++]; if (!lbl) break
lbl.style.fontSize = fs
lbl.text = String(v); lbl.position.set(cxS + v * sc, cyS + 9); lbl.visible = true
const lvb = axisLabels[li++]; if (!lvb) break
lvb.style.fontSize = fs
lvb.text = String(-v); lvb.position.set(cxS - v * sc, cyS + 9); lvb.visible = true
}
// y 轴(数值放在轴右侧)
for (let y = 0; y <= halfH; y += step) {
const v = Math.round(y / step) * step
if (v === 0 || !showLbl || v > rangeH) continue
const lbl = axisLabels[li++]; if (!lbl) break
lbl.style.fontSize = fs
lbl.text = String(v); lbl.position.set(cxS + 8, cyS - y * sc); lbl.visible = true
const lvb = axisLabels[li++]; if (!lvb) break
lvb.style.fontSize = fs
lvb.text = String(-v); lvb.position.set(cxS + 8, cyS + y * sc); lvb.visible = true
}
// 隐藏多余标签
while (li < axisLabels.length) { const t = axisLabels[li++]; if (t) t.visible = false }
}
+104
View File
@@ -0,0 +1,104 @@
import type { Application, Container, Graphics, Text } from 'pixi.js'
import type { Ref } from 'vue'
import type { useParticleStore, CollisionBody, ScenePath } from '../../store/particleStore'
import type { ParticleState } from '../../core/particleTypes'
import type { SpineRuntimeLayer } from '../../spine/SpineRuntimeLayer'
import type { BonePreview, SceneTransformTarget } from './stageTypes'
import { pathLocalToWorld } from './stageGeometry'
type EditorStore = ReturnType<typeof useParticleStore>
export function appendStateBones(target: BonePreview[], state: ParticleState, parentX: number, parentY: number) {
// 粒子快照同时保存局部坐标和烘焙世界坐标。骨骼调试图层挂在 world 下,必须使用
// 世界坐标,才能与发生位移、旋转、缩放或跟随后的粒子保持逐点重合。
const hasWorldPosition = Number.isFinite(state.worldX) && Number.isFinite(state.worldY)
const hasWorldRotation = Number.isFinite(state.worldRotation)
target.push({
boneName: state.boneName,
x: hasWorldPosition ? state.worldX! : state.x,
y: hasWorldPosition ? -state.worldY! : state.y,
rotation: hasWorldRotation ? -state.worldRotation! : -state.rotation,
parentX,
parentY,
})
if (!state.trailState?.bones.length) return
state.trailState.bones.forEach((bone) => {
// 导出结构中拖尾骨骼全部直接挂在粒子系统骨骼下,画布连线也保持同级语义。
const hasBoneWorldPosition = Number.isFinite(bone.worldX) && Number.isFinite(bone.worldY)
const hasBoneWorldRotation = Number.isFinite(bone.worldRotation)
target.push({
boneName: bone.boneName,
x: hasBoneWorldPosition ? bone.worldX! : bone.x,
y: hasBoneWorldPosition ? -bone.worldY! : bone.y,
rotation: hasBoneWorldRotation ? -bone.worldRotation! : bone.rotation,
parentX,
parentY,
trail: true,
})
})
}
export function drawDirectionalAxis(
g: Graphics,
x: number,
y: number,
directionX: number,
directionY: number,
length: number,
width: number,
color: number,
) {
const negativeLength = length * 0.62
const tipX = x + directionX * length
const tipY = y + directionY * length
const headLength = Math.max(2.5, length * 0.32)
const headWidth = Math.max(1.7, length * 0.22)
const baseX = tipX - directionX * headLength
const baseY = tipY - directionY * headLength
const perpendicularX = -directionY
const perpendicularY = directionX
g.lineStyle(width, color, 0.95)
g.moveTo(x - directionX * negativeLength, y - directionY * negativeLength)
g.lineTo(baseX, baseY)
g.lineStyle(0)
g.beginFill(color, 0.95)
g.drawPolygon([
tipX, tipY,
baseX + perpendicularX * headWidth, baseY + perpendicularY * headWidth,
baseX - perpendicularX * headWidth, baseY - perpendicularY * headWidth,
])
g.endFill()
}
export function drawBones(bones: BonePreview[], store: EditorStore, boneGfx: Graphics | null) {
if (!boneGfx) return
const g = boneGfx
g.clear()
if (!store.settings.showBones) return
const cx = 0, cy = 0
const viewScale = Math.max(0.25, Math.abs(store.editor.viewScale) || 1)
const pointSize = Math.min(10, Math.max(0.1, Number(store.settings.bonePointDrawSize) || 1))
const axisSize = Math.min(10, Math.max(0.1, Number(store.settings.boneAxisDrawSize) || 2))
// 所有尺寸先定义为屏幕像素,再除以画布缩放,保证缩放画布时视觉大小不变。
const pointRadius = 2.5 * pointSize / viewScale
const forwardLength = 4.5 * axisSize / viewScale
const forwardWidth = Math.max(0.65, axisSize * 0.625) / viewScale
// root → 每颗粒子骨骼 的连线
for (const b of bones) {
const color = b.trail ? 0xff4d8d : 0xff8c5a
g.lineStyle(1, color, b.trail ? 0.65 : 0.35)
g.moveTo(b.parentX ?? cx, b.parentY ?? cy)
g.lineTo(b.x, b.y)
// 局部坐标轴为两根交叉轴线,仅在 +X、+Y 端绘制箭头。
const angle = b.rotation * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
drawDirectionalAxis(g, b.x, b.y, cos, sin, forwardLength, forwardWidth, 0xef5350)
drawDirectionalAxis(g, b.x, b.y, sin, -cos, forwardLength, forwardWidth, 0x55c96b)
// 骨骼点
g.beginFill(color, 0.9)
g.drawCircle(b.x, b.y, pointRadius)
g.endFill()
}
}
+160
View File
@@ -0,0 +1,160 @@
import type { Application, Container, Graphics, Text } from 'pixi.js'
import type { Ref } from 'vue'
import type { useParticleStore, CollisionBody, ScenePath } from '../../store/particleStore'
import type { ParticleState } from '../../core/particleTypes'
import type { SpineRuntimeLayer } from '../../spine/SpineRuntimeLayer'
import type { BonePreview, SceneTransformTarget } from './stageTypes'
import { pathLocalToWorld } from './stageGeometry'
type EditorStore = ReturnType<typeof useParticleStore>
import { normalizedRadialRange, normalizedRectRange } from '../../core/emitterShape'
export function drawOrigin(store: EditorStore, emitterPreviewTransform: Map<number, { x: number; y: number; rotation: number }>, originGfx: Graphics | null) {
if (!originGfx) return
const g = originGfx
g.clear()
if (!store.settings.showEmitter) return
// 每个系统对应一个发射点(坐标点);没有系统时不画
if (!store.systems.length) return
for (const sys of store.systems) {
if (sys.visible === false) continue
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)
g.drawCircle(cx, cy, 10)
g.beginFill(col, active ? 0.8 : 0.4)
g.drawCircle(cx, cy, 3)
g.endFill()
g.lineStyle(1, col, active ? 0.3 : 0.15)
g.moveTo(cx - 16, cy); g.lineTo(cx + 16, cy)
g.moveTo(cx, cy - 16); g.lineTo(cx, cy + 16)
}
}
// 绘制当前选中系统的发射器形状范围(点/路径不额外绘制),随参数实时响应。
export function drawShapeRange(store: EditorStore, emitterPreviewTransform: Map<number, { x: number; y: number; rotation: number }>, shapeGfx: Graphics | null) {
if (!shapeGfx) return
const g = shapeGfx
g.clear()
if (!store.settings.showEmitter) return
const sys = store.systems.find((s) => s.id === store.activeId)
if (!sys || sys.visible === false) return
const c = sys.config
const preview = emitterPreviewTransform.get(sys.id)
// root 中心(发射器容器 position = centerX/-centerY),数学坐标
const cx = preview?.x ?? c.centerX ?? 0
const cy = -(preview?.y ?? c.centerY ?? 0)
const shape = c.shape
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 // 数学上正 → 屏幕:逆时针为正
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] => {
// 对齐粒子:局部 y 为数学上正 → 屏幕取负;发射源偏移 offsetY(数学上正)→ 屏幕也取负
const X = lx + oxo, Y = -ly - oyo
const x = X * sxr, y = Y * syr
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 变换后的椭圆近似(逐点)
const steps = 48
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 range = normalizedRectRange(c)
g.lineStyle(2, 0x4a8cff, 0.7)
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 range = normalizedRadialRange(c.innerRadius, c.outerRadius)
const half = (c.coneAngle / 2) * Math.PI / 180
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(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 (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)
}
}
}
}
/** 绘制当前选中系统的全局力场;不应用粒子系统 root 变换。 */
export function drawForceField(store: EditorStore, forceFieldGfx: Graphics | null) {
if (!forceFieldGfx) return
const g = forceFieldGfx
g.clear()
const sys = store.systems.find((item) => item.id === store.activeId)
if (sys?.visible === false) return
const cfg = sys?.config
if (!cfg?.forceField || cfg.forceRadius <= 0) return
const x = cfg.forceCenterX
const y = -cfg.forceCenterY
const color = cfg.forceStrength >= 0 ? 0x58c7ff : 0xff6b7a
g.lineStyle(2, color, 0.85)
g.beginFill(color, 0.08)
g.drawCircle(x, y, cfg.forceRadius)
g.endFill()
g.lineStyle(1, color, 0.7)
g.moveTo(x - 9, y); g.lineTo(x + 9, y)
g.moveTo(x, y - 9); g.lineTo(x, y + 9)
g.beginFill(color, 0.95)
g.drawCircle(x, y, 4)
g.endFill()
}
/** 绘制全局吸附点与吸附完成半径;半径为 0 时显示为红色点。 */
export function drawAttraction(store: EditorStore, attractionGfx: Graphics | null) {
if (!attractionGfx) return
const g = attractionGfx
g.clear()
const sys = store.systems.find((item) => item.id === store.activeId)
if (sys?.visible === false) return
const cfg = sys?.config
if (!cfg?.attraction) return
const x = cfg.attractionCenterX
const y = -cfg.attractionCenterY
const radius = Math.max(0, cfg.attractionRadius)
const color = 0xff4d5f
if (radius > 0) {
g.lineStyle(2, color, 0.95)
g.beginFill(color, 0.09)
g.drawCircle(x, y, radius)
g.endFill()
}
g.lineStyle(1, color, 0.85)
g.moveTo(x - 10, y); g.lineTo(x + 10, y)
g.moveTo(x, y - 10); g.lineTo(x, y + 10)
g.beginFill(color, 1)
g.drawCircle(x, y, radius > 0 ? 4 : 6)
g.endFill()
}
+209
View File
@@ -0,0 +1,209 @@
import type { Application, Container, Graphics, Text } from 'pixi.js'
import type { Ref } from 'vue'
import type { useParticleStore, CollisionBody, ScenePath } from '../../store/particleStore'
import type { ParticleState } from '../../core/particleTypes'
import type { SpineRuntimeLayer } from '../../spine/SpineRuntimeLayer'
import type { BonePreview, SceneTransformTarget } from './stageTypes'
import { pathLocalToWorld } from './stageGeometry'
type EditorStore = ReturnType<typeof useParticleStore>
/** 标出层级树当前选中的骨骼;发射器骨骼跟随复用同一目标点。 */
export function drawBoneFollowTargets(store: EditorStore, spineRuntime: SpineRuntimeLayer | null, boneFollowTargetGfx: Graphics | null) {
if (!boneFollowTargetGfx) return
const g = boneFollowTargetGfx
g.parent?.addChild(g)
g.clear()
const targets = new Map<string, { spineId: number; boneName: string }>()
for (const spine of store.spines) {
if (spine.visible === false || !spine.selectedBone || spine.status !== 'ready') continue
targets.set(`${spine.id}:${spine.selectedBone}`, { spineId: spine.id, boneName: spine.selectedBone })
}
for (const system of store.systems) {
if (system.visible === false) continue
const config = system.config
if (!config.emitterFollow || config.emitterFollowMode !== 'bone') continue
if (!config.emitterFollowSpineId || !config.emitterFollowBoneName) continue
const spine = store.spines.find((item) => item.id === config.emitterFollowSpineId)
if (!spine || spine.visible === false) continue
targets.set(`${config.emitterFollowSpineId}:${config.emitterFollowBoneName}`, {
spineId: config.emitterFollowSpineId,
boneName: config.emitterFollowBoneName,
})
}
for (const item of targets.values()) {
const target = spineRuntime?.getBoneWorldTransform(item.spineId, item.boneName)
if (!target) continue
const x = target.x
const y = -target.y
g.lineStyle(2, 0xc48cff, 0.95)
g.beginFill(0xc48cff, 0.2)
g.drawCircle(x, y, 8)
g.endFill()
g.beginFill(0xf2e7ff, 1)
g.drawCircle(x, y, 3.5)
g.endFill()
}
}
// 当前场景对象的变换手柄:跟随其位置、旋转和缩放。
export function drawGizmo(activeTool: Ref<'' | 'translate' | 'rotate' | 'scale'>, selectedTransformTarget: () => SceneTransformTarget | null, gizmoGfx: Graphics | null) {
if (!gizmoGfx) return
const g = gizmoGfx
g.clear()
if (!activeTool.value) return
const target = selectedTransformTarget()
if (!target) return
const cx = target.x
const cy = -target.y
const sxr = target.scaleX, syr = target.scaleY
const rot = -target.rotation * Math.PI / 180
const cs = Math.cos(rot), sn = Math.sin(rot)
// 局部点(数学坐标,ly 上正) → 屏幕点(应用 root 缩放+旋转+平移到 root 中心)
const pt = (lx: number, ly: number): [number, number] => {
const x = lx * sxr, y = (-ly) * syr
return [cx + x * cs - y * sn, cy + (x * sn + y * cs)]
}
const line = (ax: number, ay: number, bx: number, by: number) => {
const a = pt(ax, ay), b = pt(bx, by)
g.moveTo(a[0], a[1]); g.lineTo(b[0], b[1])
}
const R = 26
// 中心点
g.lineStyle(2, 0x35d0ff, 0.9)
g.beginFill(0x35d0ff, 0.25)
g.drawCircle(cx, cy, 6)
g.endFill()
if (activeTool.value === 'translate') {
// 十字箭头
g.lineStyle(2, 0x35d0ff, 0.9)
line(-R, 0, R, 0); line(0, -R, 0, R)
line(R - 6, -4, R, 0); line(R, 0, R - 6, 4) // 右箭头
line(-(R - 6), -4, -R, 0); line(-R, 0, -(R - 6), 4) // 左箭头
line(-4, R - 6, 0, R); line(0, R, 4, R - 6) // 上箭头
line(-4, -(R - 6), 0, -R); line(0, -R, 4, -(R - 6)) // 下箭头
} else if (activeTool.value === 'rotate') {
// 旋转外圈(经缩放/旋转后可能是椭圆)
g.lineStyle(2, 0x35d0ff, 0.8)
const steps = 48
for (let i = 0; i <= steps; i++) {
const a = i / steps * Math.PI * 2
const [px, py] = pt(Math.cos(a) * R, Math.sin(a) * R)
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py)
}
// 指向点:固定于局部顶部(0° 刻度在 12 点方向),经 pt(含 root 旋转)后随 rootRotation 旋转。
// 默认(rootRotation=0)时指向点位于顶部;旋转时它随 root 一起转,反映当前旋转角。
const [tx, ty] = pt(0, R)
g.beginFill(0x35d0ff, 0.95)
g.drawCircle(tx, ty, 4)
g.endFill()
} else if (activeTool.value === 'scale') {
// 四角方点(经缩放/旋转后为旋转矩形)
g.lineStyle(2, 0x35d0ff, 0.9)
const corners: [number, number][] = [[-R, -R], [R, -R], [R, R], [-R, R]]
const pts = corners.map(([ox, oy]) => pt(ox, oy))
g.moveTo(pts[0][0], pts[0][1]); g.lineTo(pts[1][0], pts[1][1]); g.lineTo(pts[2][0], pts[2][1]); g.lineTo(pts[3][0], pts[3][1]); g.lineTo(pts[0][0], pts[0][1])
g.beginFill(0x35d0ff, 0.9)
for (const p of pts) g.drawRect(p[0] - 3, p[1] - 3, 6, 6)
g.endFill()
}
}
export function colorNumber(value: string, fallback = 0xffffff) {
const parsed = Number.parseInt(value.replace('#', ''), 16)
return Number.isFinite(parsed) ? parsed : fallback
}
export function drawCollisionBodies(store: EditorStore, previewColliders: CollisionBody[], collisionGfx: Graphics | null) {
if (!collisionGfx) return
const g = collisionGfx
g.clear()
for (const collider of previewColliders) {
if (collider.visible === false) continue
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 scaleX = Math.max(0.0001, Math.abs(collider.scaleX ?? 1))
const scaleY = Math.max(0.0001, Math.abs(collider.scaleY ?? 1))
const point = (x: number, y: number) => ({
x: collider.x + cos * x * scaleX - sin * y * scaleY,
y: -(collider.y + sin * x * scaleX + cos * y * scaleY),
})
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)
}
}
export function drawPaths(store: EditorStore, selectedPathNodes: Set<number>, pathGfx: Graphics | null) {
if (!pathGfx) return
const g = pathGfx
g.clear()
for (const path of store.paths) {
if (path.visible === false || !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()
})
}
}
+56
View File
@@ -0,0 +1,56 @@
import type { ScenePath } from '../../store/particleStore'
export function pathLocalToWorld(path: ScenePath, x: number, y: number) {
const angle = path.rotation * Math.PI / 180
const cos = Math.cos(angle), sin = Math.sin(angle)
const scaleX = Math.max(0.0001, Math.abs(path.scaleX ?? 1))
const scaleY = Math.max(0.0001, Math.abs(path.scaleY ?? 1))
return { x: path.x + cos * x * scaleX - sin * y * scaleY, y: path.y + sin * x * scaleX + cos * y * scaleY }
}
export 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 scaleX = Math.max(0.0001, Math.abs(path.scaleX ?? 1))
const scaleY = Math.max(0.0001, Math.abs(path.scaleY ?? 1))
const dx = x - path.x, dy = y - path.y
return { x: (cos * dx + sin * dy) / scaleX, y: (-sin * dx + cos * dy) / scaleY }
}
export type FollowCurvePoint = { x: number; y: number; inX?: number; inY?: number; outX?: number; outY?: number }
export function followCurveValue(points: FollowCurvePoint[], t: number, mode: 'linear' | 'bezier' = 'linear') {
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) {
let amount = (t - a.x) / Math.max(0.0001, b.x - a.x)
if (mode === 'bezier') {
const x1 = a.x + (a.outX ?? (b.x - a.x) / 3)
const x2 = b.x + (b.inX ?? -(b.x - a.x) / 3)
let low = 0, high = 1
for (let step = 0; step < 18; step++) {
const middle = (low + high) / 2
if (cubicPoint(a.x, x1, x2, b.x, middle) < t) low = middle
else high = middle
}
amount = (low + high) / 2
return cubicPoint(a.y, a.y + (a.outY ?? (b.y - a.y) / 3), b.y + (b.inY ?? -(b.y - a.y) / 3), b.y, amount)
}
return a.y + (b.y - a.y) * amount
}
}
return sorted[sorted.length - 1].y
}
export 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
}
+15
View File
@@ -0,0 +1,15 @@
// 粒子→骨骼映射:每个活动粒子 = 一根挂在 root 下的骨骼
export type BonePreview = { boneName: string; x: number; y: number; rotation: number; parentX?: number; parentY?: number; trail?: boolean }
export type SceneTransformTarget = {
x: number
y: number
rotation: number
scaleX: number
scaleY: number
setPosition: (x: number, y: number) => void
setRotation: (rotation: number) => void
setScale: (scaleX: number, scaleY: number) => void
}