阶段10开发完成
This commit is contained in:
1 parent
cffe02c1ec
commit
c239171cc4
21 files changed
+997
-36
No files matched your search
+127
-4
@@ -216,8 +216,24 @@
|
||||
<button class="seg" :class="{ on: sys.config.mode === 'burst' }" @click="setEmitMode('burst')">爆发</button>
|
||||
</div>
|
||||
<template v-if="sys.config.mode === 'stream'">
|
||||
<div class="segs stream-behavior-segs">
|
||||
<button class="seg" :class="{ on: sys.config.streamBehavior === 'normal' }" @click="setStreamBehavior('normal')">常规持续</button>
|
||||
<button class="seg" :class="{ on: sys.config.streamBehavior === 'loop' }" @click="setStreamBehavior('loop')">循环持续</button>
|
||||
</div>
|
||||
<NumSlider label="发射速率" :min="1" :max="300" :step="1" v-model="sys.config.rate" />
|
||||
<NumSlider label="发射时长" :min="1 / 30" :max="60" :step="1 / 30" v-model="sys.config.duration" />
|
||||
<NumSlider v-if="sys.config.streamBehavior === 'normal'" label="发射时长" :min="1 / 30" :max="60" :step="1 / 30" v-model="sys.config.duration" />
|
||||
<NumSlider v-else label="发射时长 (f)" :min="1" :max="1800" :step="1" :model-value="loopDurationFrames" @update:model-value="setLoopDurationFrames" />
|
||||
<div v-if="sys.config.streamBehavior === 'loop'" class="stream-loop-options">
|
||||
<label class="switch-row">
|
||||
<input type="checkbox" :checked="sys.config.generateLoopStartAnimation" @change="setLoopSection('start', $event)" />
|
||||
<span class="switch-ui"></span><span>生成开始循环动画</span>
|
||||
</label>
|
||||
<label class="switch-row">
|
||||
<input type="checkbox" :checked="sys.config.generateLoopEndAnimation" @change="setLoopSection('end', $event)" />
|
||||
<span class="switch-ui"></span><span>生成结束循环动画</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="sys.config.streamBehavior === 'loop'" class="modifier-hint stream-loop-hint">循环持续模式不支持骨骼跟随和路径跟随;进入或退出时会将粒子跟随设置恢复默认值。</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NumSlider label="单次数量" :min="1" :max="1000" :step="1" v-model="sys.config.burstCount" />
|
||||
@@ -446,8 +462,10 @@
|
||||
:key="resource.id"
|
||||
:resource="resource"
|
||||
:resource-count="sys.config.imageResources.length"
|
||||
supports-sequence
|
||||
default-file="star.png"
|
||||
@pick="openTexturePicker"
|
||||
@pick-sequence="openSequencePicker"
|
||||
@reset="resetTexture"
|
||||
@remove="removeImageResource"
|
||||
@update-weight="setResourceWeight"
|
||||
@@ -497,6 +515,7 @@
|
||||
</template>
|
||||
</ImageResourceCard>
|
||||
<input ref="textureFileInput" class="file-input" type="file" accept="image/png,image/jpeg,image/webp,image/gif" @change="onTextureFile" />
|
||||
<input ref="sequenceFileInput" class="file-input" type="file" multiple accept="image/png,image/jpeg,image/webp,image/gif" @change="onSequenceFiles" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -699,10 +718,10 @@
|
||||
</div>
|
||||
<div class="modifier-item">
|
||||
<label class="switch-row modifier-switch">
|
||||
<input type="checkbox" v-model="sys.config.emitterFollow" />
|
||||
<input type="checkbox" v-model="sys.config.emitterFollow" :disabled="sys.config.streamBehavior === 'loop'" />
|
||||
<span class="switch-ui"></span><span>开启发射器跟随</span>
|
||||
</label>
|
||||
<div v-if="sys.config.emitterFollow" class="modifier-params emitter-follow-params">
|
||||
<div v-if="sys.config.emitterFollow && sys.config.streamBehavior !== 'loop'" class="modifier-params emitter-follow-params">
|
||||
<label class="field-block"><span>跟随模式</span><select :value="sys.config.emitterFollowMode" class="inp" @change="onEmitterFollowModeChange($event, sys.config)">
|
||||
<option value="none">无</option>
|
||||
<option value="path">跟随路径</option>
|
||||
@@ -865,6 +884,11 @@ const activeIdx = computed(() => {
|
||||
return i >= 0 ? i : 0
|
||||
})
|
||||
const sys = computed(() => store.systems[activeIdx.value] || null)
|
||||
const loopDurationFrames = computed(() => {
|
||||
const config = sys.value?.config
|
||||
if (!config) return 1
|
||||
return Math.max(1, Math.ceil(Number(config.loopDurationFrames) || 1))
|
||||
})
|
||||
const activeObjectType = computed(() => store.activeObjectType)
|
||||
const activeCollider = computed(() => store.activeObjectType === 'collision'
|
||||
? store.colliders.find((item) => item.id === store.activeObjectId) || null
|
||||
@@ -911,6 +935,9 @@ watchEffect(() => {
|
||||
if (config.scaleMode === 'curve') config.scaleMode = 'random'
|
||||
if (config.initialRotationMode === 'curve') config.initialRotationMode = 'random'
|
||||
if (config.mode === 'stream' && (!(config.duration > 0))) config.duration = 1
|
||||
if (config.streamBehavior === 'loop') {
|
||||
if (config.emitterFollow || config.emitterFollowMode !== 'none') resetParticleFollow(config)
|
||||
}
|
||||
})
|
||||
|
||||
// 每个粒子系统永久保存自己的 Spine ID 与骨骼名;层级树当前选择不会覆盖既有绑定。
|
||||
@@ -965,6 +992,62 @@ function setEmitMode(mode: 'stream' | 'burst') {
|
||||
if (mode === 'stream' && (!(config.duration > 0))) config.duration = 1
|
||||
}
|
||||
|
||||
function setLoopDurationFrames(value: number) {
|
||||
const config = sys.value?.config
|
||||
if (!config) return
|
||||
const frames = Math.min(1800, Math.max(1, Math.ceil(Number(value) || 1)))
|
||||
config.loopDurationFrames = frames
|
||||
}
|
||||
|
||||
function resetParticleFollow(config: any) {
|
||||
const defaults = defaultConfig()
|
||||
config.emitterFollow = false
|
||||
config.emitterFollowMode = 'none'
|
||||
config.emitterFollowPathId = defaults.emitterFollowPathId
|
||||
config.emitterFollowSpineId = defaults.emitterFollowSpineId
|
||||
config.emitterFollowBoneName = defaults.emitterFollowBoneName
|
||||
config.emitterFollowBoneOffsetX = defaults.emitterFollowBoneOffsetX
|
||||
config.emitterFollowBoneOffsetY = defaults.emitterFollowBoneOffsetY
|
||||
config.emitterFollowBoneRotation = defaults.emitterFollowBoneRotation
|
||||
config.emitterFollowBoneScaleX = defaults.emitterFollowBoneScaleX
|
||||
config.emitterFollowBoneScaleY = defaults.emitterFollowBoneScaleY
|
||||
config.emitterAngleMode = defaults.emitterAngleMode
|
||||
config.emitterFollowCurve = defaults.emitterFollowCurve.map((point) => ({ ...point }))
|
||||
config.emitterFollowDuration = defaults.emitterFollowDuration
|
||||
config.emitterFollowSpace = defaults.emitterFollowSpace
|
||||
config.emitterFollowDirection = defaults.emitterFollowDirection
|
||||
config.emitterFollowOffset = defaults.emitterFollowOffset
|
||||
}
|
||||
|
||||
function setStreamBehavior(behavior: 'normal' | 'loop') {
|
||||
const config = sys.value?.config
|
||||
if (!config || config.streamBehavior === behavior) return
|
||||
const entering = behavior === 'loop'
|
||||
const message = entering
|
||||
? '进入循环持续模式后,粒子跟随设置会失效并恢复默认值。是否继续?'
|
||||
: '退出循环持续模式后,粒子跟随设置会失效并恢复默认值。是否继续?'
|
||||
if (!window.confirm(message)) return
|
||||
resetParticleFollow(config)
|
||||
config.streamBehavior = behavior
|
||||
if (!entering) {
|
||||
config.generateLoopStartAnimation = false
|
||||
config.generateLoopEndAnimation = false
|
||||
}
|
||||
}
|
||||
|
||||
function setLoopSection(section: 'start' | 'end', event: Event) {
|
||||
const config = sys.value?.config
|
||||
if (!config) return
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
if (section === 'start') {
|
||||
config.generateLoopStartAnimation = checked
|
||||
if (checked) config.generateLoopEndAnimation = false
|
||||
} else {
|
||||
config.generateLoopEndAnimation = checked
|
||||
if (checked) config.generateLoopStartAnimation = false
|
||||
}
|
||||
}
|
||||
|
||||
function onEmitterFollowModeChange(event: Event, config: {
|
||||
emitterFollowMode: 'none' | 'path' | 'bone'
|
||||
emitterFollowSpineId: number
|
||||
@@ -1000,7 +1083,10 @@ function bindSelectedBone(config: {
|
||||
type ResetModule = 'emitMode' | 'shape' | 'attr' | 'look' | 'mods' | 'export'
|
||||
|
||||
const MODULE_RESET_KEYS: Record<Exclude<ResetModule, 'export'>, string[]> = {
|
||||
emitMode: ['mode', 'rate', 'burstCount', 'burstLoop', 'delay', 'duration'],
|
||||
emitMode: [
|
||||
'mode', 'rate', 'streamBehavior', 'loopDurationFrames', 'generateLoopStartAnimation', 'generateLoopEndAnimation',
|
||||
'burstCount', 'burstLoop', 'delay', 'duration',
|
||||
],
|
||||
shape: ['shape', 'radius', 'rectW', 'rectH', 'direction', 'spread', 'coneAngle'],
|
||||
attr: [
|
||||
'lifeMin', 'lifeMax', 'lifeMode', 'lifeCurve',
|
||||
@@ -1149,6 +1235,7 @@ function onShapeChange() {
|
||||
}
|
||||
// 重置/删除图片 → 回到默认 star.png
|
||||
const textureFileInput = ref<HTMLInputElement | null>(null)
|
||||
const sequenceFileInput = ref<HTMLInputElement | null>(null)
|
||||
const pendingResourceId = ref<number | null>(null)
|
||||
const trailTextureFileInput = ref<HTMLInputElement | null>(null)
|
||||
const pendingTrailResourceId = ref<number | null>(null)
|
||||
@@ -1331,6 +1418,10 @@ function addImageResource() {
|
||||
textureName: 'star',
|
||||
textureFolder: '',
|
||||
previewUrl: '/particles/star.png',
|
||||
imageMode: 'fixed',
|
||||
sequencePlayback: 'loop-forward',
|
||||
sequenceFps: 30,
|
||||
sequenceFrames: [],
|
||||
weight: 100,
|
||||
locked: false,
|
||||
scale: 1,
|
||||
@@ -1354,6 +1445,11 @@ function openTexturePicker(resourceId: number) {
|
||||
textureFileInput.value?.click()
|
||||
}
|
||||
|
||||
function openSequencePicker(resourceId: number) {
|
||||
pendingResourceId.value = resourceId
|
||||
sequenceFileInput.value?.click()
|
||||
}
|
||||
|
||||
async function onTextureFile(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
@@ -1370,6 +1466,25 @@ async function onTextureFile(event: Event) {
|
||||
pendingResourceId.value = null
|
||||
}
|
||||
|
||||
async function onSequenceFiles(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = Array.from(input.files || []).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }))
|
||||
const config = sys.value?.config
|
||||
const resource = config?.imageResources.find((item) => item.id === pendingResourceId.value)
|
||||
if (!files.length || !resource) { input.value = ''; return }
|
||||
resource.sequenceFrames = await Promise.all(files.map(async (file) => {
|
||||
const previewUrl = await readFileAsDataUrl(file)
|
||||
return {
|
||||
name: file.name,
|
||||
previewUrl,
|
||||
texture: markRaw(await Texture.fromURL(previewUrl)) as any,
|
||||
}
|
||||
}))
|
||||
resource.imageMode = 'sequence'
|
||||
input.value = ''
|
||||
pendingResourceId.value = null
|
||||
}
|
||||
|
||||
function readFileAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
@@ -1387,6 +1502,10 @@ function resetTexture(resourceId: number) {
|
||||
resource.textureName = 'star'
|
||||
resource.textureFolder = ''
|
||||
resource.previewUrl = '/particles/star.png'
|
||||
resource.imageMode = 'fixed'
|
||||
resource.sequencePlayback = 'loop-forward'
|
||||
resource.sequenceFps = 30
|
||||
resource.sequenceFrames = []
|
||||
resource.scale = 1
|
||||
resource.anchorX = 0.5
|
||||
resource.anchorY = 0.5
|
||||
@@ -1605,6 +1724,8 @@ function toggleResourceLock(resourceId: number) {
|
||||
.pair { display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
||||
.colr { width: 28px; height: 22px; border: none; background: none; cursor: pointer; }
|
||||
.segs { display: flex; gap: 6px; margin: 6px 0; }
|
||||
.stream-behavior-segs { margin-top: 10px; }
|
||||
.stream-loop-options { display: grid; gap: 8px; margin: 8px 0 4px; padding: 9px 10px; border: 1px solid #32405a; border-radius: 6px; background: #171f2e; }
|
||||
.seg { flex: 1; padding: 5px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 6px; color: #aab; font-size: 12px; cursor: pointer; }
|
||||
.seg.on { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
|
||||
.mini-btn { background: #2e3a55; border: 1px solid #3a4a6a; color: #cdf; border-radius: 5px; padding: 3px 8px; font-size: 11px; cursor: pointer; }
|
||||
@@ -1685,6 +1806,8 @@ function toggleResourceLock(resourceId: number) {
|
||||
.modifier-switch { font-weight: 600; }
|
||||
.modifier-params { margin: 8px 0 0 10px; padding: 2px 0 2px 10px; border-left: 2px solid #344158; }
|
||||
.modifier-hint { margin: 7px 0 0 2px; color: #647086; font-size: 9px; line-height: 1.5; }
|
||||
.stream-loop-hint { margin: 9px 2px 3px; padding: 7px 8px; border: 1px solid #4a436e; border-radius: 5px; background: #211f35; color: #aaa4d7; }
|
||||
.switch-row input:disabled + .switch-ui { opacity: 0.45; cursor: not-allowed; }
|
||||
.bone-follow-target { margin: 7px 0 9px; padding: 7px 8px; overflow: hidden; color: #7f8ba3; background: #121a29; border: 1px solid #2f3c53; border-radius: 5px; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bone-follow-target strong { color: #c8d0e4; font-weight: 600; }
|
||||
.bone-follow-missing { margin: 7px 0 9px; padding: 7px 8px; color: #d49aa3; background: #2a1e26; border: 1px solid #513540; border-radius: 5px; font-size: 10px; line-height: 1.5; }
|
||||
|
||||
Reference in new issue
Block a user