资源与外观调整

This commit is contained in:
tianmo committed 2026-08-27 19:06:25 +08:00
1 parent 2ad0b76d11
commit f0f4104b0c
6 files changed
+684 -60

No files matched your search

+295 -47
View File
@@ -275,50 +275,92 @@
</template>
<!-- 图片资源 -->
<div class="look-title">图片资源</div>
<div class="imgrow">
<div class="img-thumb"><svg 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></div>
<div class="img-fields">
<label class="row"><span>子文件夹</span><input type="text" v-model="sys.config.textureFolder" class="inp" placeholder="subfolder/" /></label>
<label class="row"><span>名称</span><input type="text" v-model="sys.config.textureName" class="inp" placeholder="particle" /></label>
</div>
<div class="img-actions">
<button class="mini-btn2" title="重置" @click="resetTexture()">⟳</button>
<button class="mini-btn2" title="删除" @click="clearTexture()">🗑</button>
</div>
<div class="look-title image-title">
<span>图片资源</span>
<button class="image-add" title="新增图片资源" @click="addImageResource">+</button>
</div>
<div class="thumb-note">{{ sys.config.textureName || 'star' }}.png(默认 star.png)</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>
<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>
<!-- 颜色 -->
<div class="look-title">颜色</div>
<label class="row"><span>颜色类型</span>
<select :value="sys.config.colorOverLife ? '渐变' : '固定色'" class="inp" @change="sys.config.colorOverLife = ($event.target as any).value === '渐变'">
<option>固定色</option><option>渐变</option>
</select>
<select v-model="sys.config.blend" class="inp">
<option value="normal">正常</option><option value="add">相加</option>
<option value="multiply">相乘</option><option value="screen">滤色</option>
</select>
</label>
<template v-if="sys.config.colorOverLife">
<label class="row"><span>起始色</span><input type="color" v-model="sys.config.colorStart" class="colr" /></label>
<label class="row"><span>结束色</span><input type="color" v-model="sys.config.colorEnd" class="colr" /></label>
</template>
<template v-else>
<label class="row"><span>颜色</span><input type="color" v-model="sys.config.colorStart" class="colr" />
<span class="hex">{{ sys.config.colorStart }}</span></label>
</template>
<div class="resource-divider"></div>
<label class="row resource-color-row"><span>颜色</span>
<select v-model="resource.colorMode" class="inp">
<option value="fixed">固定色</option>
<option value="lifetime">生命周期</option>
</select>
<select v-model="resource.blend" class="inp blend-select">
<option value="normal">正常</option>
<option value="add">相加</option>
<option value="multiply">相乘</option>
<option value="screen">滤色</option>
</select>
</label>
<template v-if="resource.colorMode === 'fixed'">
<label class="row resource-color-picker"><span>固定色</span><input type="color" v-model="resource.colorStart" class="colr" /><input type="text" class="hex-input" :value="resource.colorStart" spellcheck="false" aria-label="固定颜色值" @change="setResourceFixedColor(resource, $event)" @keydown.enter="commitResourceFixedColor(resource, $event)" /></label>
</template>
<template v-else>
<ColorGradientEditor v-model="resource.colorGradient" />
</template>
<!-- 占比 / 缩放 / 轴心 / 独立透明度 -->
<div class="look-title">占比与缩放</div>
<NumSlider label="占比" :min="0" :max="1" :step="0.01" v-model="sys.config.alphaScale" />
<NumSlider label="缩放" :min="0.1" :max="3" :step="0.1" v-model="sys.config.scaleMax" />
<label class="row"><span>轴心 x,y</span><input type="text" class="inp tiny" v-model.number="sys.config.anchorX" /><input type="text" class="inp tiny" v-model.number="sys.config.anchorY" /></label>
<label class="row"><span>独立透明度</span>
<select v-model="sys.config.independentAlpha" class="inp">
<option value="off">关闭</option><option value="fixed">固定</option><option value="curve">曲线</option>
</select>
</label>
<div class="resource-alpha-head">
<span>独立透明度</span>
<div class="alpha-modes">
<button :class="{ on: resource.independentAlpha === 'off' }" @click="resource.independentAlpha = 'off'">关闭</button>
<button :class="{ on: resource.independentAlpha === 'fixed' }" @click="resource.independentAlpha = 'fixed'">固定</button>
<button :class="{ on: resource.independentAlpha === 'curve' }" @click="resource.independentAlpha = 'curve'">曲线</button>
</div>
</div>
<NumSlider v-if="resource.independentAlpha === 'fixed'" label="透明度" :min="0" :max="1" :step="0.01" v-model="resource.alpha" />
<template v-else-if="resource.independentAlpha === 'curve'">
<label class="row"><span>曲线模式</span>
<select v-model="resource.alphaCurveMode" class="inp">
<option>跟随导出</option><option>线性</option><option>贝塞尔</option>
</select>
</label>
<div class="resource-alpha-curve">
<CurveEditor v-model="resource.alphaCurve" :default-value="RESOURCE_ALPHA_CURVE" />
</div>
</template>
</div>
<input ref="textureFileInput" class="file-input" type="file" accept="image/png,image/jpeg,image/webp,image/gif" @change="onTextureFile" />
</div>
</div>
@@ -364,12 +406,14 @@
</template>
<script setup lang="ts">
import { computed, reactive, ref, watchEffect } from 'vue'
import { computed, markRaw, reactive, ref, watchEffect } from 'vue'
import { Texture } from 'pixi.js'
import { useParticleStore } from '../store/particleStore'
import { ensureEmitterConfig } from '../core/particleEmitter'
import NumSlider from './NumSlider.vue'
import CurveEditor from './CurveEditor.vue'
import ParticleAttributeControl from './ParticleAttributeControl.vue'
import ColorGradientEditor from './ColorGradientEditor.vue'
const store = useParticleStore()
const activeIdx = computed(() => {
@@ -392,6 +436,7 @@ watchEffect(() => {
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 collapsed = reactive<Record<string, boolean>>({
scene: false, emitMode: false, shape: false, attr: false, look: false, mods: false, export: false,
@@ -451,11 +496,188 @@ function onShapeChange() {
Object.assign(cfg, d)
}
// 重置/删除图片 → 回到默认 star.png
function resetTexture() {
const c = sys.value?.config
if (c) { c.textureName = 'star'; c.textureFolder = '' }
const textureFileInput = ref<HTMLInputElement | null>(null)
const pendingResourceId = ref<number | null>(null)
function addImageResource() {
const config = sys.value?.config
if (!config) return
const nextId = Math.max(0, ...config.imageResources.map((resource) => resource.id)) + 1
config.imageResources.push({
id: nextId,
texture: config.texture,
textureName: 'star',
textureFolder: '',
previewUrl: '/particles/star.png',
weight: 100,
locked: false,
scale: 1,
anchorX: 0.5,
anchorY: 0.5,
colorMode: 'fixed',
colorStart: '#ffffff',
colorEnd: '#ff4d4d',
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ff4d4d' }],
blend: 'normal',
independentAlpha: 'off',
alpha: 1,
alphaCurveMode: '跟随导出',
alphaCurve: RESOURCE_ALPHA_CURVE.map((point) => ({ ...point })),
} as any)
rebalanceImageWeights()
}
function openTexturePicker(resourceId: number) {
pendingResourceId.value = resourceId
textureFileInput.value?.click()
}
async function onTextureFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
const config = sys.value?.config
const resource = config?.imageResources.find((item) => item.id === pendingResourceId.value)
if (!file || !resource) { input.value = ''; return }
const dataUrl = await readFileAsDataUrl(file)
const texture = markRaw(await Texture.fromURL(dataUrl))
resource.texture = texture as any
resource.previewUrl = dataUrl
resource.textureFolder = ''
resource.textureName = file.name.replace(/\.[^.]+$/, '') || 'particle'
input.value = ''
pendingResourceId.value = null
}
function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(file)
})
}
function resetTexture(resourceId: number) {
const config = sys.value?.config
const resource = config?.imageResources.find((item) => item.id === resourceId)
if (!config || !resource) return
resource.texture = config.texture
resource.textureName = 'star'
resource.textureFolder = ''
resource.previewUrl = '/particles/star.png'
resource.scale = 1
resource.anchorX = 0.5
resource.anchorY = 0.5
resource.colorMode = 'fixed'
resource.colorStart = '#ffffff'
resource.colorEnd = '#ff4d4d'
resource.colorGradient = [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ff4d4d' }]
resource.blend = 'normal'
resource.independentAlpha = 'off'
resource.alpha = 1
resource.alphaCurveMode = '跟随导出'
resource.alphaCurve = RESOURCE_ALPHA_CURVE.map((point) => ({ ...point }))
resource.locked = false
rebalanceImageWeights()
}
function normalizeHex(value: string, fallback: string) {
let hex = value.trim().replace(/^#/, '')
if (/^[0-9a-f]{3}$/i.test(hex)) hex = hex.split('').map((character) => character + character).join('')
return /^[0-9a-f]{6}$/i.test(hex) ? `#${hex.toLowerCase()}` : fallback
}
function setResourceFixedColor(resource: { colorStart: string }, event: Event) {
const input = event.target as HTMLInputElement
resource.colorStart = normalizeHex(input.value, resource.colorStart)
input.value = resource.colorStart
}
function commitResourceFixedColor(resource: { colorStart: string }, event: KeyboardEvent) {
setResourceFixedColor(resource, event)
;(event.target as HTMLInputElement).blur()
}
function removeImageResource(resourceId: number) {
const resources = sys.value?.config.imageResources
if (!resources || resources.length <= 1) return
const index = resources.findIndex((resource) => resource.id === resourceId)
if (index >= 0) resources.splice(index, 1)
rebalanceImageWeights()
}
function roundWeight(value: number) {
return Math.round(value)
}
type WeightResource = { weight: number; locked: boolean }
function distributeImageWeights(resources: WeightResource[], total: number, proportional = false) {
if (!resources.length) return
const safeTotal = Math.max(0, roundWeight(total))
const sourceTotal = proportional
? resources.reduce((sum, resource) => sum + Math.max(0, Number(resource.weight) || 0), 0)
: 0
let assigned = 0
resources.forEach((resource, index) => {
const isLast = index === resources.length - 1
const next = isLast
? roundWeight(safeTotal - assigned)
: roundWeight(sourceTotal > 0
? safeTotal * Math.max(0, Number(resource.weight) || 0) / sourceTotal
: safeTotal / resources.length)
resource.weight = next
assigned += next
})
}
function rebalanceImageWeights() {
const resources = sys.value?.config.imageResources
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 setResourceWeight(resourceId: number, value: number) {
const resources = sys.value?.config.imageResources
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)
return
}
distributeImageWeights(others, available - resource.weight, true)
}
function toggleResourceLock(resourceId: number) {
const resources = sys.value?.config.imageResources
if (!resources || resources.length === 1) return
const resource = resources.find((item) => item.id === resourceId)
if (resource) resource.locked = !resource.locked
}
function clearTexture() { resetTexture() }
</script>
<style scoped>
@@ -508,13 +730,39 @@ function clearTexture() { resetTexture() }
.curve-hint { min-height: 54px; border: 1px dashed #2e2e44; border-radius: 6px; display: flex; align-items: center; justify-content: center; color: #556; font-size: 11px; margin: 6px 0; }
.curve-wrap { margin: 6px 0; }
.curve-labels { display: flex; justify-content: space-between; color: #566; font-size: 10px; margin-top: 2px; }
.image-title { display: flex; align-items: center; justify-content: space-between; }
.image-add { width: 26px; height: 24px; border: 1px solid #4a4d9a; border-radius: 5px; background: #252850; color: #8f8cff; font-size: 18px; line-height: 1; cursor: pointer; }
.image-add:hover { background: #4542a8; color: #fff; }
.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; border: 1px solid #2e2e44; border-radius: 6px; background: #1a1a28; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.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; }
.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 { color: #667; font-size: 11px; margin: 4px 0 2px; }
.resource-divider { height: 1px; margin: 9px 0 7px; background: #2a3549; }
.resource-color-row .blend-select { max-width: 88px; }
.resource-color-picker .hex { margin-left: 0; }
.hex-input { width: 72px; padding: 4px 6px; border: 1px solid #344158; border-radius: 5px; outline: none; background: #101725; color: #aeb8cb; font-size: 11px; font-family: monospace; }
.hex-input:focus { border-color: #7774ff; color: #fff; }
.resource-alpha-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 10px; color: #8a93bb; font-size: 12px; }
.alpha-modes { display: flex; gap: 3px; }
.alpha-modes button { padding: 4px 8px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: #8d98ae; font-size: 11px; cursor: pointer; }
.alpha-modes button:hover { background: #273246; }
.alpha-modes button.on { border-color: #7774ff; background: #605df0; color: #fff; }
.resource-alpha-curve { margin-top: 7px; }
.resource-weight-row { display: flex; align-items: center; gap: 5px; }
.resource-weight-control { flex: 1; min-width: 0; }
.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; }
.file-input { display: none; }
.hex { font-size: 11px; color: #889; margin-left: auto; font-variant-numeric: tabular-nums; }
.inp.tiny { width: 46px; flex: 0 0 46px; text-align: center; }