阶段五完成

This commit is contained in:
tianmo committed 2026-08-28 17:50:23 +08:00
1 parent 4e2e123269
commit f2fe653c04
6 files changed
+1423 -60

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
<template>
<div class="image-card">
<div class="imgrow">
<button class="img-thumb" title="选择本地图片" @click="$emit('pick', resource.id)">
<img v-if="resource.previewUrl" :src="resource.previewUrl" :alt="resource.textureName" />
<svg v-else viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#8a93bb" stroke-width="1.6"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.6"/><path d="M21 15l-5-5-9 9"/></svg>
</button>
<div class="img-fields">
<label v-if="showFolder" class="row"><span>子文件夹</span><input v-model="resource.textureFolder" type="text" class="inp" placeholder="subfolder/" /></label>
<label class="row"><span>名称</span><input v-model="resource.textureName" type="text" class="inp" :placeholder="namePlaceholder" /></label>
</div>
<div class="img-actions">
<button class="mini-btn2" :title="`恢复 ${defaultFile}`" @click="$emit('reset', resource.id)">⟳</button>
<button class="mini-btn2" title="删除图片资源" :disabled="resourceCount === 1" @click="$emit('remove', resource.id)">🗑</button>
</div>
</div>
<div class="thumb-note">{{ displayFileName }}</div>
<div class="resource-weight-row">
<NumSlider
class="resource-weight-control"
label="占比%"
:min="0"
:max="100"
:step="1"
:model-value="resource.weight"
:disabled="resource.locked || resourceCount === 1"
@update:model-value="$emit('updateWeight', resource.id, $event)"
/>
<button
class="weight-lock"
:class="{ on: resource.locked }"
:disabled="resourceCount === 1"
:title="resource.locked ? '解除占比锁定' : '锁定当前占比'"
@click="$emit('toggleLock', resource.id)"
>
<svg v-if="resource.locked" viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M16 10V7a4 4 0 0 0-7.5-2"/></svg>
</button>
</div>
<slot />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import NumSlider from './NumSlider.vue'
type CommonImageResource = {
id: number
previewUrl: string
textureName: string
textureFolder?: string
weight: number
locked: boolean
}
const props = withDefaults(defineProps<{
resource: CommonImageResource
resourceCount: number
defaultFile: string
namePlaceholder?: string
showFolder?: boolean
}>(), {
namePlaceholder: 'particle',
showFolder: true,
})
defineEmits<{
pick: [id: number]
reset: [id: number]
remove: [id: number]
updateWeight: [id: number, value: number]
toggleLock: [id: number]
}>()
const displayFileName = computed(() => {
const name = props.resource.textureName || props.defaultFile.replace(/\.[^.]+$/, '')
return name.includes('.') ? name : `${name}.png`
})
</script>
<style scoped>
.image-card { margin: 8px 0; padding: 9px; border: 1px solid #2e3c55; border-radius: 7px; background: #171d2a; }
.imgrow { display: flex; align-items: flex-start; gap: 8px; }
.img-thumb { width: 48px; height: 48px; padding: 0; border: 1px solid #3a4963; border-radius: 6px; background: #1a2232; display: flex; align-items: center; justify-content: center; flex-shrink: 0; cursor: pointer; overflow: hidden; }
.img-thumb:hover { border-color: #7774ff; }
.img-thumb img { display: block; width: 100%; height: 100%; object-fit: contain; }
.img-fields { flex: 1; min-width: 0; }
.img-actions { display: flex; flex-direction: column; gap: 4px; }
.row { display: flex; align-items: center; gap: 8px; margin: 6px 0; color: #aab; font-size: 12px; }
.row > span:first-child { width: 60px; flex-shrink: 0; color: #8a93bb; }
.inp { min-width: 0; flex: 1; padding: 3px 5px; border: 1px solid #2e2e44; border-radius: 5px; background: #1a1a28; color: #dde; font-size: 12px; }
.mini-btn2 { width: 26px; height: 24px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 5px; color: #aab; font-size: 12px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.mini-btn2:hover { background: #2b3a5c; }
.mini-btn2:disabled { opacity: 0.35; cursor: not-allowed; }
.thumb-note { margin: 4px 0 2px; color: #667; font-size: 11px; }
.resource-weight-row { display: grid; grid-template-columns: minmax(0, 1fr) 28px; align-items: center; gap: 7px; }
.resource-weight-control { width: 100%; min-width: 0; }
.resource-weight-control :deep(.ns-range) { min-width: 0; }
.resource-weight-control :deep(.ns-num) { flex: 0 0 56px; box-sizing: border-box; }
.weight-lock { width: 28px; height: 26px; flex: 0 0 28px; padding: 5px; border: 1px solid #35435c; border-radius: 5px; background: #202a3b; color: #78859e; cursor: pointer; }
.weight-lock svg { display: block; width: 100%; height: 100%; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.weight-lock:hover:not(:disabled) { border-color: #7774ff; color: #b8b6ff; }
.weight-lock.on { border-color: #7774ff; background: #514fd0; color: #fff; }
.weight-lock:disabled { opacity: 0.35; cursor: not-allowed; }
</style>
+293 -40
View File
@@ -279,44 +279,18 @@
<span>图片资源</span>
<button class="image-add" title="新增图片资源" @click="addImageResource">+</button>
</div>
<div v-for="resource in sys.config.imageResources" :key="resource.id" class="image-card">
<div class="imgrow">
<button class="img-thumb" title="选择本地图片" @click="openTexturePicker(resource.id)">
<img v-if="resource.previewUrl" :src="resource.previewUrl" :alt="resource.textureName" />
<svg v-else viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="#8a93bb" stroke-width="1.6"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.6"/><path d="M21 15l-5-5-9 9"/></svg>
</button>
<div class="img-fields">
<label class="row"><span>子文件夹</span><input type="text" v-model="resource.textureFolder" class="inp" placeholder="subfolder/" /></label>
<label class="row"><span>名称</span><input type="text" v-model="resource.textureName" class="inp" placeholder="particle" /></label>
</div>
<div class="img-actions">
<button class="mini-btn2" title="恢复 star.png" @click="resetTexture(resource.id)">⟳</button>
<button class="mini-btn2" title="删除图片资源" :disabled="sys.config.imageResources.length === 1" @click="removeImageResource(resource.id)">🗑</button>
</div>
</div>
<div class="thumb-note">{{ resource.textureName || 'star' }}{{ resource.textureName.includes('.') ? '' : '.png' }}</div>
<div class="resource-weight-row">
<NumSlider
class="resource-weight-control"
label="占比%"
:min="0"
:max="100"
:step="1"
:model-value="resource.weight"
:disabled="resource.locked || sys.config.imageResources.length === 1"
@update:model-value="setResourceWeight(resource.id, $event)"
/>
<button
class="weight-lock"
:class="{ on: resource.locked }"
:disabled="sys.config.imageResources.length === 1"
:title="resource.locked ? '解除占比锁定' : '锁定当前占比'"
@click="toggleResourceLock(resource.id)"
>
<svg v-if="resource.locked" viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M16 10V7a4 4 0 0 0-7.5-2"/></svg>
</button>
</div>
<ImageResourceCard
v-for="resource in sys.config.imageResources"
:key="resource.id"
:resource="resource"
:resource-count="sys.config.imageResources.length"
default-file="star.png"
@pick="openTexturePicker"
@reset="resetTexture"
@remove="removeImageResource"
@update-weight="setResourceWeight"
@toggle-lock="toggleResourceLock"
>
<NumSlider label="缩放" :min="0.1" :max="3" :step="0.05" v-model="resource.scale" />
<label class="row"><span>轴心 x,y</span><input type="number" step="0.05" class="inp tiny" v-model.number="resource.anchorX" /><input type="number" step="0.05" class="inp tiny" v-model.number="resource.anchorY" /></label>
@@ -359,7 +333,7 @@
<CurveEditor v-model="resource.alphaCurve" :default-value="RESOURCE_ALPHA_CURVE" />
</div>
</template>
</div>
</ImageResourceCard>
<input ref="textureFileInput" class="file-input" type="file" accept="image/png,image/jpeg,image/webp,image/gif" @change="onTextureFile" />
</div>
</div>
@@ -472,7 +446,94 @@
<div class="modifier-hint">延迟后从当前位置开始吸附 · 到达红色范围即死亡 · 可在画布内拖动吸附圆</div>
</div>
</div>
<label class="row"><span>拖尾</span><input type="checkbox" v-model="sys.config.trail" /></label>
<div class="modifier-item">
<label class="switch-row modifier-switch">
<input type="checkbox" v-model="sys.config.trail" />
<span class="switch-ui"></span><span>开启拖尾 (Trail)</span>
</label>
<div v-if="sys.config.trail" class="modifier-params trail-params">
<div class="trail-section-head">
<div class="trail-section-title">拖尾独立图片</div>
<button class="image-add" title="增加拖尾图片资源" @click="addTrailResource">+</button>
</div>
<ImageResourceCard
v-for="resource in sys.config.trailResources"
:key="resource.id"
:resource="resource"
:resource-count="sys.config.trailResources.length"
default-file="trail.png"
name-placeholder="trail"
:show-folder="false"
@pick="openTrailTexturePicker"
@reset="resetTrailTexture"
@remove="removeTrailResource"
@update-weight="setTrailResourceWeight"
@toggle-lock="toggleTrailResourceLock"
>
<div class="resource-divider"></div>
<NumSlider label="旋转" :min="-360" :max="360" :step="1" :model-value="resource.rotation" @update:model-value="onTrailResourceRotationChange(resource.id, $event)" />
<NumSlider label="拖尾骨骼数" :min="2" :max="16" :step="1" :model-value="resource.boneCount" @update:model-value="onTrailResourceBoneCountChange(resource.id, $event)" />
<NumSlider label="初始长度" :min="0" :max="1000" :step="1" v-model="resource.length" />
<NumSlider label="初始宽度" :min="0" :max="1000" :step="1" v-model="resource.width" />
<label class="row"><span>宽度缩放</span>
<select v-model="resource.widthMode" class="inp">
<option value="particle">跟随粒子</option>
<option value="fixed">固定</option>
</select>
</label>
<label class="switch-row trail-sub-switch">
<input type="checkbox" v-model="resource.lifeLengthEnabled" />
<span class="switch-ui"></span><span>生命周期长度曲线</span>
</label>
<CurveEditor v-if="resource.lifeLengthEnabled" v-model="resource.lifeLengthCurve" :default-value="FLAT_CURVE" />
<label class="switch-row trail-sub-switch">
<input type="checkbox" v-model="resource.shapeEnabled" />
<span class="switch-ui"></span><span>形状曲线(首端 → 尾端宽度)</span>
</label>
<CurveEditor v-if="resource.shapeEnabled" v-model="resource.shapeCurve" :default-value="TRAIL_SHAPE_CURVE" />
<div class="trail-mode-head"><span>颜色模式</span>
<div class="alpha-modes">
<button class="axis-mode" :class="{ on: resource.colorMode === 'particle' }" @click="resource.colorMode = 'particle'">跟随粒子</button>
<button class="axis-mode" :class="{ on: resource.colorMode === 'fixed' }" @click="resource.colorMode = 'fixed'">固定</button>
<button class="axis-mode" :class="{ on: resource.colorMode === 'curve' }" @click="resource.colorMode = 'curve'">曲线</button>
</div>
</div>
<label class="row"><span>混合模式</span>
<select v-model="resource.blend" class="inp">
<option value="normal">正常</option>
<option value="add">相加</option>
<option value="multiply">相乘</option>
<option value="screen">滤色</option>
</select>
</label>
<label v-if="resource.colorMode === 'fixed'" class="row trail-fixed-color"><span>固定颜色</span>
<input class="colr" type="color" v-model="resource.color" />
<input class="hex-input" :value="resource.color" @change="setTrailColor(resource, $event)" @keydown.enter.prevent="commitTrailColor(resource, $event)" />
</label>
<ColorGradientEditor v-else-if="resource.colorMode === 'curve'" v-model="resource.colorGradient" />
<div class="trail-mode-head"><span>透明度模式</span>
<div class="alpha-modes">
<button class="axis-mode" :class="{ on: resource.alphaMode === 'particle' }" @click="resource.alphaMode = 'particle'">跟随粒子</button>
<button class="axis-mode" :class="{ on: resource.alphaMode === 'fixed' }" @click="resource.alphaMode = 'fixed'">固定</button>
<button class="axis-mode" :class="{ on: resource.alphaMode === 'curve' }" @click="resource.alphaMode = 'curve'">曲线</button>
</div>
</div>
<NumSlider v-if="resource.alphaMode === 'fixed'" label="透明度" :min="0" :max="1" :step="0.01" v-model="resource.alpha" />
<CurveEditor v-if="resource.alphaMode === 'curve'" v-model="resource.alphaCurve" :default-value="TRAIL_ALPHA_CURVE" />
</ImageResourceCard>
<input ref="trailTextureFileInput" class="file-input" type="file" accept="image/png,image/jpeg,image/webp" @change="onTrailTextureFile" />
<div class="trail-grid-row">
<NumSlider label="网格行数" :min="2" :max="16" :step="1" :model-value="sys.config.trailGridRows" @update:model-value="onTrailRigChange('trailGridRows', $event)" />
<NumSlider label="网格列数" :min="2" :max="32" :step="1" :model-value="sys.config.trailGridCols" @update:model-value="onTrailRigChange('trailGridCols', $event)" />
</div>
<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>
<NumSlider label="阻尼" :min="0" :max="2" :step="0.05" v-model="sys.config.damping" />
@@ -502,6 +563,7 @@ import NumSlider from './NumSlider.vue'
import CurveEditor from './CurveEditor.vue'
import ParticleAttributeControl from './ParticleAttributeControl.vue'
import ColorGradientEditor from './ColorGradientEditor.vue'
import ImageResourceCard from './ImageResourceCard.vue'
const store = useParticleStore()
// 默认场景始终有一个可编辑的粒子系统;热更新保留现有系统时不重复创建。
@@ -528,6 +590,8 @@ const LINEAR_CURVE = [{ x: 0, y: 0 }, { x: 1, y: 1 }]
const FLAT_CURVE = [{ x: 0, y: 1 }, { x: 1, y: 1 }]
const ALPHA_CURVE = [{ x: 0, y: 0 }, { x: 0.2, y: 1 }, { x: 0.8, y: 1 }, { x: 1, y: 0 }]
const RESOURCE_ALPHA_CURVE = [{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.9, y: 1 }, { x: 1, y: 0 }]
const TRAIL_ALPHA_CURVE = [{ x: 0, y: 1 }, { x: 1, y: 1 }]
const TRAIL_SHAPE_CURVE = [{ x: 0, y: 1 }, { x: 1, y: 1 }]
const collapsed = reactive<Record<string, boolean>>({
scene: false,
@@ -595,6 +659,176 @@ function onShapeChange() {
// 重置/删除图片 → 回到默认 star.png
const textureFileInput = ref<HTMLInputElement | null>(null)
const pendingResourceId = ref<number | null>(null)
const trailTextureFileInput = ref<HTMLInputElement | null>(null)
const pendingTrailResourceId = ref<number | null>(null)
function addTrailResource() {
const config = sys.value?.config
if (!config) return
const nextId = Math.max(0, ...config.trailResources.map((resource) => resource.id)) + 1
config.trailResources.push({
id: nextId,
texture: null,
textureName: 'trail',
previewUrl: '/trails/trail.png',
weight: 100,
locked: false,
rotation: 0,
colorMode: 'particle',
color: '#ffffff',
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ffffff' }],
blend: 'normal',
alphaMode: 'particle',
alpha: 1,
alphaCurve: TRAIL_ALPHA_CURVE.map((point) => ({ ...point })),
boneCount: 3,
length: 100,
width: 100,
widthMode: 'particle',
lifeLengthEnabled: false,
lifeLengthCurve: FLAT_CURVE.map((point) => ({ ...point })),
shapeEnabled: false,
shapeCurve: TRAIL_SHAPE_CURVE.map((point) => ({ ...point })),
})
rebalanceTrailWeights()
}
function openTrailTexturePicker(resourceId: number) {
pendingTrailResourceId.value = resourceId
trailTextureFileInput.value?.click()
}
type TrailRigKey = 'trailGridRows' | 'trailGridCols'
function onTrailRigChange(key: TrailRigKey, value: number) {
const config = sys.value?.config
if (!config) return
const maximum = key === 'trailGridCols' ? 32 : 16
config[key] = Math.min(maximum, Math.max(2, Math.round(Number(value) || 2)))
// 只触发骨骼和蒙皮重建;所有正在使用的拖尾图片与粒子资源选择保持不变。
}
function onTrailResourceBoneCountChange(resourceId: number, value: number) {
const resource = sys.value?.config.trailResources.find((item) => item.id === resourceId)
if (!resource) return
resource.boneCount = Math.min(16, Math.max(2, Math.round(Number(value) || 2)))
// 骨骼数量包含在该资源的蒙皮签名中,运行层会保留图片并重新绑定。
}
function onTrailResourceRotationChange(resourceId: number, value: number) {
const resource = sys.value?.config.trailResources.find((item) => item.id === resourceId)
if (!resource) return
resource.rotation = Math.max(-360, Math.min(360, Number(value) || 0))
// 旋转属于图片默认方向;运行层将新的角度写入 UV 签名并重新绑定蒙皮。
}
async function onTrailTextureFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
const config = sys.value?.config
const resource = config?.trailResources.find((item) => item.id === pendingTrailResourceId.value)
if (!file || !resource) { input.value = ''; return }
const dataUrl = await readFileAsDataUrl(file)
resource.texture = markRaw(await Texture.fromURL(dataUrl)) as any
resource.previewUrl = dataUrl
resource.textureName = file.name.replace(/\.[^.]+$/, '') || 'trail'
input.value = ''
pendingTrailResourceId.value = null
}
async function resetTrailTexture(resourceId: number) {
const config = sys.value?.config
const resource = config?.trailResources.find((item) => item.id === resourceId)
if (!resource) return
resource.texture = markRaw(await Texture.fromURL('/trails/trail.png')) as any
resource.previewUrl = '/trails/trail.png'
resource.textureName = 'trail'
resource.weight = 100
resource.locked = false
resource.rotation = 0
resource.boneCount = 3
resource.length = 100
resource.width = 100
resource.widthMode = 'particle'
resource.lifeLengthEnabled = false
resource.lifeLengthCurve = FLAT_CURVE.map((point) => ({ ...point }))
resource.shapeEnabled = false
resource.shapeCurve = TRAIL_SHAPE_CURVE.map((point) => ({ ...point }))
resource.colorMode = 'particle'
resource.color = '#ffffff'
resource.colorGradient = [
{ position: 0, color: '#ffffff' },
{ position: 1, color: '#ffffff' },
]
resource.blend = 'normal'
resource.alphaMode = 'particle'
resource.alpha = 1
resource.alphaCurve = TRAIL_ALPHA_CURVE.map((point) => ({ ...point }))
rebalanceTrailWeights()
}
function removeTrailResource(resourceId: number) {
const resources = sys.value?.config.trailResources
if (!resources || resources.length <= 1) return
const index = resources.findIndex((resource) => resource.id === resourceId)
if (index >= 0) resources.splice(index, 1)
rebalanceTrailWeights()
}
function rebalanceTrailWeights() {
const resources = sys.value?.config.trailResources
if (!resources?.length) return
if (resources.length === 1) {
resources[0].weight = 100
resources[0].locked = false
return
}
const locked = resources.filter((resource) => resource.locked)
const unlocked = resources.filter((resource) => !resource.locked)
let lockedTotal = locked.reduce((sum, resource) => sum + Math.max(0, Number(resource.weight) || 0), 0)
if (lockedTotal > 100) {
distributeImageWeights(locked, 100, true)
lockedTotal = 100
}
distributeImageWeights(unlocked, 100 - lockedTotal)
}
function setTrailResourceWeight(resourceId: number, value: number) {
const resources = sys.value?.config.trailResources
if (!resources?.length) return
if (resources.length === 1) {
resources[0].weight = 100
resources[0].locked = false
return
}
const resource = resources.find((item) => item.id === resourceId)
if (!resource || resource.locked) return
const lockedTotal = resources
.filter((item) => item.id !== resourceId && item.locked)
.reduce((sum, item) => sum + Math.max(0, Number(item.weight) || 0), 0)
const available = Math.max(0, 100 - lockedTotal)
const others = resources.filter((item) => item.id !== resourceId && !item.locked)
resource.weight = roundWeight(Math.min(available, Math.max(0, Number(value) || 0)))
if (!others.length) resource.weight = roundWeight(available)
else distributeImageWeights(others, available - resource.weight, true)
}
function toggleTrailResourceLock(resourceId: number) {
const resources = sys.value?.config.trailResources
if (!resources || resources.length === 1) return
const resource = resources.find((item) => item.id === resourceId)
if (resource) resource.locked = !resource.locked
}
function setTrailColor(resource: { color: string }, event: Event) {
const input = event.target as HTMLInputElement
resource.color = normalizeHex(input.value, resource.color)
input.value = resource.color
}
function commitTrailColor(resource: { color: string }, event: KeyboardEvent) {
setTrailColor(resource, event)
;(event.target as HTMLInputElement).blur()
}
function addImageResource() {
const config = sys.value?.config
@@ -898,4 +1132,23 @@ function toggleResourceLock(resourceId: number) {
.gravity-curve-title span:last-child { color: #657188; font-size: 9px; }
.gravity-curve-axis { display: flex; justify-content: space-between; margin: 3px 34px 0 1px; color: #5f6b82; font-size: 9px; }
.gravity-params :deep(.ce-svg) { height: 96px; background: #0f1625; border-color: #30405a; }
.trail-params { padding-right: 2px; }
.trail-section-title { margin: 3px 0 8px; color: #aeb8cf; font-size: 12px; font-weight: 700; }
.trail-resource-row { display: flex; align-items: stretch; gap: 8px; margin-bottom: 10px; padding: 8px; border: 1px solid #2e3c55; border-radius: 7px; background: #171d2a; }
.trail-thumb { width: 58px; min-height: 58px; padding: 3px; flex: 0 0 58px; border: 1px solid #3a4963; border-radius: 6px; background: #101725; cursor: pointer; overflow: hidden; }
.trail-thumb:hover { border-color: #7774ff; }
.trail-thumb img { display: block; width: 100%; height: 100%; object-fit: contain; }
.trail-resource-fields { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 7px; }
.trail-color-row { display: flex; align-items: center; gap: 5px; color: #8a93bb; font-size: 11px; }
.trail-color-row > span { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trail-color-row .hex-input { min-width: 0; flex: 1; }
.trail-fixed-color .hex-input { min-width: 0; flex: 1; }
.trail-mode-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 10px 0 6px; color: #9ba7bd; font-size: 11px; }
.trail-sub-switch { margin: 11px 0 7px; padding-top: 9px; border-top: 1px solid #2a3549; }
.trail-grid-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
.trail-grid-row :deep(.ns) { min-width: 0; flex-wrap: wrap; align-items: center; }
.trail-grid-row :deep(.ns-label) { width: 100%; }
.trail-grid-row :deep(.ns-range) { min-width: 0; }
.trail-grid-row :deep(.ns-num) { width: 42px; }
.trail-params :deep(.ce-svg) { height: 92px; background: #0f1625; border-color: #30405a; }
</style>
+44 -13
View File
@@ -7,6 +7,7 @@
<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>
<!-- 根变换工具:拖动(默认) / 位移 / 旋转 / 缩放 -->
<div class="tool-group">
@@ -63,7 +64,7 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { Application, Container, Graphics, Text, Texture } from 'pixi.js'
import { ParticleEmitter, ensureEmitterConfig } from '../core/particleEmitter'
import { ParticleEmitter, ensureEmitterConfig, type ParticleState } from '../core/particleEmitter'
import { useParticleStore } from '../store/particleStore'
const store = useParticleStore()
@@ -71,6 +72,7 @@ const holder = ref<HTMLElement | null>(null)
const time = ref(0)
const showEmitter = ref(true)
const showBones = ref(false)
const showSkinMesh = ref(false)
const info = ref<{ systems: number; particles: number } | null>(null)
const showGrid = ref(true)
const showSettings = ref(false)
@@ -92,12 +94,18 @@ let axisLabels: Text[] = []
let lastT = 0
let raf = 0
let dotTex: Texture<any> | null = null
let trailTex: Texture<any> | null = null
const emitterMap = new Map<number, ParticleEmitter>()
async function loadTexture() {
if (dotTex) return dotTex
dotTex = await Texture.fromURL('/particles/star.png')
if (dotTex && trailTex) return dotTex
const [particleTexture, trailTexture] = await Promise.all([
Texture.fromURL('/particles/star.png'),
Texture.fromURL('/trails/trail.png'),
])
dotTex = particleTexture
trailTex = trailTexture
return dotTex
}
@@ -395,6 +403,11 @@ function syncEmitters() {
// 新增系统若还没贴图(dotTex 已加载后),补上默认点纹理,否则无法渲染
if (dotTex && !sys.config.texture) sys.config.texture = dotTex
const cfg = ensureEmitterConfig(sys.config as any)
if (trailTex) {
for (const resource of cfg.trailResources) {
if (!resource.texture && resource.previewUrl === '/trails/trail.png') resource.texture = trailTex
}
}
if (dotTex) {
for (const resource of cfg.imageResources) {
if (!resource.texture && resource.textureName === 'star') resource.texture = dotTex
@@ -609,7 +622,18 @@ function drawGizmo() {
}
// 粒子→骨骼映射:每个活动粒子 = 一根挂在 root 下的骨骼
function drawBones(bones: { boneName: string; x: number; y: number }[]) {
type BonePreview = { boneName: string; x: number; y: number; parentX?: number; parentY?: number; trail?: boolean }
function appendStateBones(target: BonePreview[], state: ParticleState) {
target.push({ boneName: state.boneName, x: state.x, y: state.y })
if (!state.trailState?.bones.length) return
state.trailState.bones.forEach((bone, index, bones) => {
const parent = index > 0 ? bones[index - 1] : { x: state.x, y: state.y }
target.push({ boneName: bone.boneName, x: bone.x, y: bone.y, parentX: parent.x, parentY: parent.y, trail: true })
})
}
function drawBones(bones: BonePreview[]) {
if (!boneGfx) return
const g = boneGfx
g.clear()
@@ -617,11 +641,12 @@ function drawBones(bones: { boneName: string; x: number; y: number }[]) {
const cx = 0, cy = 0
// root → 每颗粒子骨骼 的连线
for (const b of bones) {
g.lineStyle(1, 0xff8c5a, 0.35)
g.moveTo(cx, cy)
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)
// 骨骼点
g.beginFill(0xff8c5a, 0.9)
g.beginFill(color, 0.9)
g.drawCircle(b.x, b.y, 2.5)
g.endFill()
}
@@ -650,7 +675,7 @@ function loop() {
em.scale.set(c.rootScaleX, c.rootScaleY)
}
let total = 0
const collected: { boneName: string; x: number; y: number }[] = []
const collected: BonePreview[] = []
const tl = store.timeline
const fps = tl.fps
const dt = 1 / fps // 固定帧步长,保证帧对齐
@@ -660,7 +685,7 @@ function loop() {
if (tl.frame === 0) { for (const [, em] of emitterMap) em.reset() }
for (const [, em] of emitterMap) {
const st = em.update(dt)
for (const s of st) if (s.active) collected.push({ boneName: s.boneName, x: s.x, y: s.y })
for (const s of st) if (s.active) appendStateBones(collected, s)
total += em.activeCount
}
for (const sys of store.systems) {
@@ -676,7 +701,7 @@ function loop() {
for (const sys of store.systems) {
const em = emitterMap.get(sys.id)
const fr = sys.frames?.[tl.frame]
if (em && fr) { em.apply(fr); for (const s of fr) if (s.active) { collected.push({ boneName: s.boneName, x: s.x, y: s.y }); total++ } }
if (em && fr) { em.apply(fr); for (const s of fr) if (s.active) { appendStateBones(collected, s); total++ } }
}
tl.frame++
if (tl.frame >= tl.totalFrames) { if (tl.loop) tl.frame = 0; else { tl.frame = tl.totalFrames - 1; tl.playing = false } }
@@ -688,14 +713,15 @@ function loop() {
const fr = sys.frames?.[tl.frame]
if (em && tl.recorded && fr) {
em.apply(fr)
for (const s of fr) if (s.active) { collected.push({ boneName: s.boneName, x: s.x, y: s.y }); total++ }
for (const s of fr) if (s.active) { appendStateBones(collected, s); total++ }
} else if (em) {
const st = em.update(0) // 冻结预览
for (const s of st) if (s.active) collected.push({ boneName: s.boneName, x: s.x, y: s.y })
for (const s of st) if (s.active) appendStateBones(collected, s)
total += em.activeCount
}
}
}
for (const [, emitter] of emitterMap) emitter.drawTrailMeshDebug(showSkinMesh.value)
drawOrigin()
drawShapeRange()
drawForceField()
@@ -716,6 +742,11 @@ onMounted(async () => {
for (const sys of store.systems) {
sys.config.texture = tex
const cfg = ensureEmitterConfig(sys.config as any)
if (trailTex) {
for (const resource of cfg.trailResources) {
if (!resource.texture && resource.previewUrl === '/trails/trail.png') resource.texture = trailTex
}
}
for (const resource of cfg.imageResources) {
if (!resource.texture && resource.textureName === 'star') resource.texture = tex
}
@@ -737,7 +768,7 @@ onMounted(async () => {
watch(
() =>
store.systems
.map((s) => JSON.stringify(s.config, (k, v) => (k === 'texture' || k === 'previewUrl' ? undefined : v)))
.map((s) => JSON.stringify(s.config, (k, v) => (k === 'texture' || k === 'trailTexture' || k === 'previewUrl' ? undefined : v)))
.join('|'),
() => {
const frames = store.recalcTotalFrames()