资源与外观调整

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

+197
View File
@@ -0,0 +1,197 @@
<template>
<div class="gradient-editor">
<div
ref="bar"
class="gradient-bar"
:style="{ background: gradientCss }"
title="双击色带添加色标"
@dblclick="addAtPointer"
>
<button
v-for="(stop, index) in modelValue"
:key="index"
type="button"
class="gradient-stop"
:class="{ selected: index === selectedIndex }"
:style="{ left: `${clamp(stop.position) * 100}%`, '--stop-color': stop.color }"
:title="`${Math.round(clamp(stop.position) * 100)}% · ${stop.color}`"
@click.stop="selectedIndex = index"
@dblclick.stop="removeStop(index)"
@pointerdown.stop="startDrag($event, index)"
></button>
</div>
<div v-if="selectedStop" class="gradient-controls">
<input type="color" class="gradient-color" :value="selectedStop.color" aria-label="色标颜色" @input="setSelectedColor" />
<input
type="text"
class="gradient-hex-input"
:value="selectedStop.color"
aria-label="色标颜色值"
spellcheck="false"
@change="setSelectedColorText"
@keydown.enter="commitColorText"
/>
<label class="gradient-position"><input type="number" min="0" max="100" step="1" :value="Math.round(clamp(selectedStop.position) * 100)" aria-label="色标位置" @input="setSelectedPosition" /><span>%</span></label>
<button type="button" class="gradient-action" title="在中间新增颜色" @click="addMiddle">+</button>
<button type="button" class="gradient-action danger" title="删除当前色标" :disabled="modelValue.length <= 2" @click="removeStop(selectedIndex)">×</button>
</div>
<div class="gradient-hint">双击色带新增 · 拖动色标调整位置 · 双击色标删除</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
export type ColorGradientStop = { position: number; color: string }
const props = defineProps<{ modelValue: ColorGradientStop[] }>()
const emit = defineEmits<{ (event: 'update:modelValue', value: ColorGradientStop[]): void }>()
const bar = ref<HTMLElement | null>(null)
const selectedIndex = ref(0)
const selectedStop = computed(() => props.modelValue[selectedIndex.value] || null)
const sortedStops = computed(() => [...props.modelValue].sort((a, b) => a.position - b.position))
const gradientCss = computed(() => {
const stops = sortedStops.value
if (!stops.length) return '#ffffff'
return `linear-gradient(90deg, ${stops.map((stop) => `${stop.color} ${clamp(stop.position) * 100}%`).join(', ')})`
})
watch(() => props.modelValue.length, (length) => {
if (selectedIndex.value >= length) selectedIndex.value = Math.max(0, length - 1)
})
function clamp(value: number) { return Math.min(1, Math.max(0, Number(value) || 0)) }
function copyStops() { return props.modelValue.map((stop) => ({ position: clamp(stop.position), color: stop.color })) }
function updateStop(index: number, patch: Partial<ColorGradientStop>) {
const next = copyStops()
if (!next[index]) return
next[index] = { ...next[index], ...patch }
emit('update:modelValue', next)
}
function setSelectedColor(event: Event) {
updateStop(selectedIndex.value, { color: (event.target as HTMLInputElement).value })
}
function setSelectedColorText(event: Event) {
const input = event.target as HTMLInputElement
const color = normalizeHex(input.value, selectedStop.value?.color || '#ffffff')
input.value = color
updateStop(selectedIndex.value, { color })
}
function commitColorText(event: KeyboardEvent) {
const input = event.target as HTMLInputElement
setSelectedColorText(event)
input.blur()
}
function setSelectedPosition(event: Event) {
const percent = Number((event.target as HTMLInputElement).value)
if (Number.isFinite(percent)) updateStop(selectedIndex.value, { position: clamp(percent / 100) })
}
function addAt(position: number) {
const next = copyStops()
next.push({ position: clamp(position), color: colorAt(next, position) })
selectedIndex.value = next.length - 1
emit('update:modelValue', next)
}
function addAtPointer(event: MouseEvent) {
if (!bar.value) return
const rect = bar.value.getBoundingClientRect()
addAt((event.clientX - rect.left) / Math.max(1, rect.width))
}
function addMiddle() {
const sorted = sortedStops.value
let position = 0.5
if (sorted.length > 1) {
let widest = -1
for (let index = 0; index < sorted.length - 1; index++) {
const width = sorted[index + 1].position - sorted[index].position
if (width > widest) { widest = width; position = (sorted[index].position + sorted[index + 1].position) / 2 }
}
}
addAt(position)
}
function removeStop(index: number) {
if (props.modelValue.length <= 2 || index < 0) return
const next = copyStops().filter((_, itemIndex) => itemIndex !== index)
selectedIndex.value = Math.min(index, next.length - 1)
emit('update:modelValue', next)
}
function startDrag(event: PointerEvent, index: number) {
if (!bar.value) return
selectedIndex.value = index
const move = (moveEvent: PointerEvent) => {
const rect = bar.value!.getBoundingClientRect()
updateStop(index, { position: clamp((moveEvent.clientX - rect.left) / Math.max(1, rect.width)) })
}
const up = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
window.removeEventListener('pointercancel', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
window.addEventListener('pointercancel', up)
event.preventDefault()
}
function colorAt(stops: ColorGradientStop[], position: number) {
const sorted = [...stops].sort((a, b) => a.position - b.position)
if (!sorted.length) return '#ffffff'
if (position <= sorted[0].position) return sorted[0].color
if (position >= sorted[sorted.length - 1].position) return sorted[sorted.length - 1].color
for (let index = 0; index < sorted.length - 1; index++) {
const start = sorted[index], end = sorted[index + 1]
if (position < start.position || position > end.position) continue
const amount = (position - start.position) / Math.max(0.0001, end.position - start.position)
return mixHex(start.color, end.color, amount)
}
return sorted[sorted.length - 1].color
}
function mixHex(start: string, end: string, amount: number) {
const parse = (hex: string) => {
const value = parseInt(hex.replace('#', '').padEnd(6, '0').slice(0, 6), 16)
return [(value >> 16) & 255, (value >> 8) & 255, value & 255]
}
const a = parse(start), b = parse(end)
const channel = (index: number) => Math.round(a[index] + (b[index] - a[index]) * amount)
return `#${[channel(0), channel(1), channel(2)].map((value) => value.toString(16).padStart(2, '0')).join('')}`
}
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
}
</script>
<style scoped>
.gradient-editor { margin: 8px 0 11px 68px; }
.gradient-bar { position: relative; height: 18px; margin-bottom: 14px; border: 1px solid #465570; border-radius: 6px; cursor: crosshair; }
.gradient-stop { position: absolute; top: 15px; width: 14px; height: 17px; padding: 0; transform: translateX(-50%); border: 2px solid #d8deea; border-radius: 2px 2px 5px 5px; background: var(--stop-color); cursor: grab; box-shadow: 0 1px 3px #0009; }
.gradient-stop::before { content: ''; position: absolute; left: 2px; top: -7px; border-left: 3px solid transparent; border-right: 3px solid transparent; border-bottom: 5px solid #d8deea; }
.gradient-stop.selected { outline: 2px solid #7774ff; outline-offset: 1px; z-index: 2; }
.gradient-stop:active { cursor: grabbing; }
.gradient-controls { display: flex; align-items: center; gap: 5px; margin-top: 30px; }
.gradient-color { width: 28px; height: 24px; padding: 1px; border: 1px solid #465570; border-radius: 4px; background: #151b27; cursor: pointer; }
.gradient-hex-input { width: 66px; padding: 4px 5px; border: 1px solid #344158; border-radius: 4px; outline: none; background: #101725; color: #aeb8cb; font-size: 10px; font-family: monospace; }
.gradient-hex-input:focus { border-color: #7774ff; color: #fff; }
.gradient-position { display: flex; align-items: center; color: #71809a; font-size: 10px; }
.gradient-position input { width: 43px; padding: 3px 4px; border: 1px solid #344158; border-radius: 4px; outline: none; background: #101725; color: #dce3ef; text-align: right; }
.gradient-action { width: 25px; height: 24px; padding: 0; border: 1px solid #3a4860; border-radius: 4px; background: #273246; color: #cbd4e5; cursor: pointer; }
.gradient-action:hover:not(:disabled) { border-color: #7774ff; color: #fff; }
.gradient-action.danger { color: #e3a5aa; }
.gradient-action:disabled { opacity: .35; cursor: not-allowed; }
.gradient-hint { margin-top: 5px; color: #59667b; font-size: 9px; }
</style>
+8 -2
View File
@@ -1,5 +1,5 @@
<template>
<div class="ns">
<div class="ns" :class="{ disabled }">
<span class="ns-label">{{ label }}</span>
<input
type="range"
@@ -8,13 +8,17 @@
:max="max"
:step="step"
:value="modelValue"
:disabled="disabled"
@input="onRange"
/>
<input
type="number"
class="ns-num"
:min="min"
:max="max"
:step="step"
:value="numText"
:disabled="disabled"
@input="onNum"
@blur="onBlur"
/>
@@ -30,6 +34,7 @@ const props = defineProps<{
min: number
max: number
step?: number
disabled?: boolean
}>()
const emit = defineEmits<{ (e: 'update:modelValue', v: number): void }>()
@@ -59,5 +64,6 @@ function onBlur() {
.ns-label { width: 60px; flex-shrink: 0; color: #8a93bb; }
.ns-range { flex: 1; accent-color: #3a7cd6; }
.ns-num { width: 56px; padding: 3px 5px; background: #1a1a28; border: 1px solid #2e2e44; border-radius: 5px; color: #dde; font-size: 12px; text-align: right; }
.ns-num::-webkit-inner-spin-button { opacity: 0.4; }
.ns.disabled { opacity: 0.55; }
.ns.disabled .ns-range, .ns.disabled .ns-num { cursor: not-allowed; }
</style>
+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; }
+15 -3
View File
@@ -63,7 +63,7 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { Application, Container, Graphics, Text, Texture } from 'pixi.js'
import { ParticleEmitter } from '../core/particleEmitter'
import { ParticleEmitter, ensureEmitterConfig } from '../core/particleEmitter'
import { useParticleStore } from '../store/particleStore'
const store = useParticleStore()
@@ -314,6 +314,12 @@ function syncEmitters() {
for (const sys of store.systems) {
// 新增系统若还没贴图(dotTex 已加载后),补上默认点纹理,否则无法渲染
if (dotTex && !sys.config.texture) sys.config.texture = dotTex
const cfg = ensureEmitterConfig(sys.config as any)
if (dotTex) {
for (const resource of cfg.imageResources) {
if (!resource.texture && resource.textureName === 'star') resource.texture = dotTex
}
}
if (emitterMap.has(sys.id)) continue
const em = new ParticleEmitter(sys.config as any)
// 发射原点 = 当前系统的位置X/位置Y(centerX/centerY),改配置实时生效
@@ -576,7 +582,13 @@ onMounted(async () => {
createLayers()
await loadTexture()
const tex = dotTex as Texture<any>
for (const sys of store.systems) sys.config.texture = tex
for (const sys of store.systems) {
sys.config.texture = tex
const cfg = ensureEmitterConfig(sys.config as any)
for (const resource of cfg.imageResources) {
if (!resource.texture && resource.textureName === 'star') resource.texture = tex
}
}
info.value = { systems: store.systems.length, particles: 0 }
// 默认从头播放并循环
store.timeline.recorded = false
@@ -594,7 +606,7 @@ onMounted(async () => {
watch(
() =>
store.systems
.map((s) => JSON.stringify(s.config, (k, v) => (k === 'texture' ? undefined : v)))
.map((s) => JSON.stringify(s.config, (k, v) => (k === 'texture' || k === 'previewUrl' ? undefined : v)))
.join('|'),
() => {
const frames = store.recalcTotalFrames()