资源与外观调整

This commit is contained in:
tianmo
2026-08-27 19:06:25 +08:00
parent 2ad0b76d11
commit f0f4104b0c
6 changed files with 684 additions and 60 deletions
+164 -8
View File
@@ -12,10 +12,38 @@ export type AttributeMode = 'fixed' | 'random' | 'curve'
export type RotationMode = AttributeMode | 'direction'
export type ScaleAxisMode = 'uniform' | 'separate'
export interface CurvePoint { x: number; y: number }
export interface ColorGradientStop { position: number; color: string }
export interface ParticleImageResource {
id: number
texture: Texture<any> | null
textureName: string
textureFolder: string
previewUrl: string
/** 发射权重,多个资源按权重随机选择 */
weight: number
/** 锁定后,调整其他资源时保持当前占比 */
locked: boolean
scale: number
anchorX: number
anchorY: number
/** 每张贴图独立的颜色、混合与透明度设置 */
colorMode: 'fixed' | 'lifetime'
colorStart: string
colorEnd: string
colorGradient: ColorGradientStop[]
blend: Blend
independentAlpha: 'off' | 'fixed' | 'curve'
alpha: number
alphaCurveMode: '跟随导出' | '线性' | '贝塞尔'
alphaCurve: CurvePoint[]
}
/** 粒子配置(引用保持,面板改字段即实时生效) */
export interface EmitterConfig {
texture: Texture<any>
imageResources: ParticleImageResource[]
imageWeightVersion: number
name: string
// 发射
mode: EmitMode
@@ -151,12 +179,35 @@ export interface ParticleState {
scaleY: number
alpha: number
colorHex: number
resourceId?: number
active: boolean
}
export function defaultConfig(): EmitterConfig {
return {
texture: null as unknown as Texture,
imageResources: [{
id: 1,
texture: null,
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: [{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.9, y: 1 }, { x: 1, y: 0 }],
}],
imageWeightVersion: 1,
name: 'ParticleSystem1',
mode: 'stream',
rate: 20,
@@ -255,12 +306,57 @@ export function defaultConfig(): EmitterConfig {
export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
const defaults = defaultConfig() as unknown as Record<string, unknown>
const target = config as unknown as Record<string, unknown>
const needsImageWeightMigration = target.imageWeightVersion !== 1
for (const [key, value] of Object.entries(defaults)) {
if (target[key] !== undefined && target[key] !== null) continue
target[key] = Array.isArray(value)
? value.map((item) => typeof item === 'object' && item !== null ? { ...item } : item)
: value
}
if (!Array.isArray(config.imageResources) || config.imageResources.length === 0) {
config.imageResources = defaultConfig().imageResources
}
for (const resource of config.imageResources) {
if (!resource.texture && config.texture) resource.texture = config.texture
if (!resource.textureName) resource.textureName = 'star'
if (!resource.previewUrl) resource.previewUrl = resource.textureName === 'star' ? '/particles/star.png' : ''
if (!Number.isFinite(resource.weight)) resource.weight = 100
if (typeof resource.locked !== 'boolean') resource.locked = false
if (!Number.isFinite(resource.scale)) resource.scale = 1
if (!Number.isFinite(resource.anchorX)) resource.anchorX = 0.5
if (!Number.isFinite(resource.anchorY)) resource.anchorY = 0.5
if (resource.colorMode !== 'fixed' && resource.colorMode !== 'lifetime') {
resource.colorMode = config.colorOverLife ? 'lifetime' : 'fixed'
}
if (!resource.colorStart) resource.colorStart = config.colorStart || '#ffffff'
if (!resource.colorEnd) resource.colorEnd = config.colorEnd || resource.colorStart
if (!Array.isArray(resource.colorGradient) || resource.colorGradient.length < 2) {
resource.colorGradient = [
{ position: 0, color: resource.colorStart },
{ position: 1, color: resource.colorEnd },
]
}
if (!Object.prototype.hasOwnProperty.call(BLEND_MAP, resource.blend)) resource.blend = config.blend || 'normal'
if (!['off', 'fixed', 'curve'].includes(resource.independentAlpha)) resource.independentAlpha = config.independentAlpha || 'off'
if (!Number.isFinite(resource.alpha)) resource.alpha = Number.isFinite(config.alphaScale) ? config.alphaScale : 1
if (!['跟随导出', '线性', '贝塞尔'].includes(resource.alphaCurveMode)) resource.alphaCurveMode = config.curveMode || '跟随导出'
if (!Array.isArray(resource.alphaCurve) || resource.alphaCurve.length < 2) {
resource.alphaCurve = (config.alphaCurve || [{ x: 0, y: 1 }, { x: 1, y: 1 }]).map((point) => ({ ...point }))
}
}
if (config.imageResources.length === 1) {
config.imageResources[0].weight = 100
config.imageResources[0].locked = false
} else if (needsImageWeightMigration) {
const equalWeight = 100 / config.imageResources.length
config.imageResources.forEach((resource, index) => {
resource.weight = index === config.imageResources.length - 1
? 100 - equalWeight * (config.imageResources.length - 1)
: equalWeight
resource.locked = false
})
}
config.imageWeightVersion = 1
return config
}
@@ -278,6 +374,7 @@ interface Particle {
speedScale: number
speedScaleTarget: number
scaleOverLifeX: number; scaleOverLifeY: number
resourceId: number
}
const BLEND_MAP: Record<Blend, number> = { normal: 0, add: 1, multiply: 2, screen: 3 }
@@ -327,7 +424,7 @@ export class ParticleEmitter extends Container {
sprite, active: false, boneName: 'p_' + this.pool.length,
life: 0, maxLife: 1, x: 0, y: 0, vx: 0, vy: 0,
scaleStartX: 1, scaleStartY: 1, alphaStart: 1, alphaEnd: 1, rotation: 0, rotationSpeed: 0,
speedScale: 1, speedScaleTarget: 1, scaleOverLifeX: 1, scaleOverLifeY: 1,
speedScale: 1, speedScaleTarget: 1, scaleOverLifeX: 1, scaleOverLifeY: 1, resourceId: 1,
})
}
}
@@ -393,6 +490,7 @@ export class ParticleEmitter extends Container {
p.y += p.vy * dt
const t = 1 - p.life / p.maxLife
const s = p.sprite
const resource = cfg.imageResources.find((item) => item.id === p.resourceId)
if (cfg.speedOverLifeEnabled) {
const target = finite(overLifeValue(cfg.speedOverLifeMode, cfg.speedOverLifeMin, cfg.speedOverLifeMax, cfg.speedOverLifeCurve, t, p.speedScaleTarget), 1)
@@ -414,10 +512,22 @@ export class ParticleEmitter extends Container {
: scaleMulX
}
s.scale.set(finite(p.scaleStartX * scaleMulX, 1), finite(p.scaleStartY * scaleMulY, 1))
s.alpha = cfg.alphaMode === 'curve' ? curveAt(cfg.alphaCurve, t) * cfg.alphaScale : (p.alphaStart + (p.alphaEnd - p.alphaStart) * t) * cfg.alphaScale
const rgb = cfg.colorOverLife ? blendRgb(cfg.colorStart, cfg.colorEnd, t) : hexToNumber(cfg.colorStart)
const baseAlpha = cfg.alphaMode === 'curve'
? curveAt(cfg.alphaCurve, t) * cfg.alphaScale
: cfg.alpha * cfg.alphaScale
// 开启卡片独立透明度后,覆盖系统透明度;固定值不会再受系统曲线影响。
s.alpha = finite(resource?.independentAlpha === 'fixed'
? resource.alpha
: resource?.independentAlpha === 'curve'
? curveAt(resource.alphaCurve, t)
: baseAlpha, 1)
const colorStart = resource?.colorStart || '#ffffff'
const rgb = resource?.colorMode === 'lifetime'
? gradientColorAt(resource.colorGradient, t)
: hexToNumber(colorStart)
s.tint = rgb
states.push({ boneName: p.boneName, x: p.x, y: p.y, rotation: p.rotation, scaleX: s.scale.x, scaleY: s.scale.y, alpha: s.alpha, colorHex: rgb, active: true })
s.blendMode = BLEND_MAP[resource?.blend || 'normal'] as any
states.push({ boneName: p.boneName, x: p.x, y: p.y, rotation: p.rotation, scaleX: s.scale.x, scaleY: s.scale.y, alpha: s.alpha, colorHex: rgb, resourceId: p.resourceId, active: true })
}
this.states = states
return states
@@ -461,13 +571,14 @@ export class ParticleEmitter extends Container {
dirAng = (cfg.direction + lerp(-cfg.spread / 2, cfg.spread / 2, this.rng())) * Math.PI / 180
}
const p = this.pool[idx]
const resource = this.pickImageResource()
p.active = true; p.life = life; p.maxLife = life
p.x = ox; p.y = oy
p.vx = Math.cos(dirAng) * speed
p.vy = (cfg.shape === 'cone' ? -Math.sin(dirAng) : Math.sin(dirAng)) * speed
p.scaleStartX = initialValue(cfg.scaleMode, cfg.scaleMin, cfg.scaleMax, cfg.scaleCurve, this.rng())
p.scaleStartX = initialValue(cfg.scaleMode, cfg.scaleMin, cfg.scaleMax, cfg.scaleCurve, this.rng()) * resource.scale
p.scaleStartY = cfg.scaleAxisMode === 'separate'
? initialValue(cfg.scaleMode, cfg.scaleYMin, cfg.scaleYMax, cfg.scaleYCurve, this.rng())
? initialValue(cfg.scaleMode, cfg.scaleYMin, cfg.scaleYMax, cfg.scaleYCurve, this.rng()) * resource.scale
: p.scaleStartX
p.alphaStart = cfg.alpha; p.alphaEnd = cfg.alphaEnd
p.rotation = cfg.initialRotationMode === 'direction'
@@ -486,10 +597,34 @@ export class ParticleEmitter extends Container {
p.scaleOverLifeY = cfg.scaleOverLifeMode === 'random'
? lerp(cfg.scaleYOverLifeMin, cfg.scaleYOverLifeMax, this.rng())
: cfg.scaleYOverLifeMin
p.sprite.tint = hexToNumber(cfg.colorStart)
p.resourceId = resource.id
p.sprite.texture = resource.texture || cfg.texture
p.sprite.anchor.set(resource.anchorX, resource.anchorY)
p.sprite.blendMode = BLEND_MAP[resource.blend] as any
p.sprite.tint = hexToNumber(resource.colorStart)
p.sprite.visible = true
}
private pickImageResource(): ParticleImageResource {
const resources = this.cfg.imageResources.filter((resource) => resource.texture)
if (!resources.length) return {
id: 1, texture: this.cfg.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', blend: 'normal',
colorGradient: [{ position: 0, color: '#ffffff' }, { position: 1, color: '#ff4d4d' }],
independentAlpha: 'off', alpha: 1, alphaCurveMode: '跟随导出',
alphaCurve: [{ x: 0, y: 0 }, { x: 0.1, y: 1 }, { x: 0.9, y: 1 }, { x: 1, y: 0 }],
}
const total = resources.reduce((sum, resource) => sum + Math.max(0, resource.weight), 0)
if (total <= 0) return resources[Math.floor(this.rng() * resources.length)]
let cursor = this.rng() * total
for (const resource of resources) {
cursor -= Math.max(0, resource.weight)
if (cursor <= 0) return resource
}
return resources[resources.length - 1]
}
private kill(i: number) {
const p = this.pool[i]
p.active = false
@@ -514,7 +649,7 @@ export class ParticleEmitter extends Container {
out.push({
boneName: p.boneName, x: p.x, y: p.y, rotation: p.rotation,
scaleX: p.sprite.scale.x, scaleY: p.sprite.scale.y,
alpha: p.sprite.alpha, colorHex: p.sprite.tint as number, active: true,
alpha: p.sprite.alpha, colorHex: p.sprite.tint as number, resourceId: p.resourceId, active: true,
})
}
return out
@@ -534,6 +669,14 @@ export class ParticleEmitter extends Container {
s.scale.set(st.scaleX, st.scaleY)
s.alpha = st.alpha
s.tint = st.colorHex
if (st.resourceId != null) {
const resource = this.cfg.imageResources.find((item) => item.id === st.resourceId)
if (resource?.texture) {
s.texture = resource.texture
s.anchor.set(resource.anchorX, resource.anchorY)
s.blendMode = BLEND_MAP[resource.blend] as any
}
}
}
}
@@ -595,6 +738,19 @@ function blendRgb(start: string, end: string, t: number): number {
const b = Math.round(s[2] + (e[2] - s[2]) * t)
return (r << 16) | (g << 8) | b
}
function gradientColorAt(stops: ColorGradientStop[] | undefined, t: number): number {
if (!Array.isArray(stops) || stops.length === 0) return 0xffffff
const sorted = [...stops].sort((a, b) => a.position - b.position)
if (t <= sorted[0].position) return hexToNumber(sorted[0].color)
if (t >= sorted[sorted.length - 1].position) return hexToNumber(sorted[sorted.length - 1].color)
for (let index = 0; index < sorted.length - 1; index++) {
const start = sorted[index], end = sorted[index + 1]
if (t < start.position || t > end.position) continue
const amount = (t - start.position) / Math.max(0.0001, end.position - start.position)
return blendRgb(start.color, end.color, amount)
}
return hexToNumber(sorted[sorted.length - 1].color)
}
function hexToRgb(hex: string): [number, number, number] {
let h = hex.replace('#', '')
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
+5
View File
@@ -12,3 +12,8 @@ html, body {
body { position: fixed; inset: 0; }
* { box-sizing: border-box; }
#app { position: fixed; inset: 0; width: auto; height: auto; overflow: clip; }
/* 数值由滑块、键盘或直接输入调整,统一隐藏浏览器自带的上下微调箭头。 */
input[type='number'] { appearance: textfield; -moz-appearance: textfield; }
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button { margin: 0; -webkit-appearance: none; appearance: none; }
+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()