优化碰撞识别,加入暖场帧数
This commit is contained in:
1 parent
b8652acce0
commit
fed1015849
21 files changed
+195
-23
No files matched your search
@@ -125,6 +125,7 @@ export function defaultConfig(): EmitterConfig {
|
||||
gravityCurve: [{ x: 0, y: 1 }, { x: 1, y: 1 }],
|
||||
damping: 0,
|
||||
seed: 0,
|
||||
warmupFrames: 0,
|
||||
colorStart: '#ffffff',
|
||||
colorEnd: '#ff4d4d',
|
||||
colorOverLife: false,
|
||||
@@ -358,6 +359,7 @@ export function ensureEmitterConfig(config: EmitterConfig): EmitterConfig {
|
||||
if (config.streamBehavior !== 'normal' && config.streamBehavior !== 'loop') config.streamBehavior = 'normal'
|
||||
if (needsLoopDurationMigration || !Number.isFinite(config.loopDurationFrames)) config.loopDurationFrames = Math.ceil(Math.max(1 / 30, Number(config.duration) || 1) * 30)
|
||||
config.loopDurationFrames = Math.min(1800, Math.max(1, Math.ceil(config.loopDurationFrames)))
|
||||
config.warmupFrames = Math.max(0, Math.floor(Number(config.warmupFrames) || 0))
|
||||
if (typeof config.generateLoopStartAnimation !== 'boolean') config.generateLoopStartAnimation = false
|
||||
if (typeof config.generateLoopEndAnimation !== 'boolean') config.generateLoopEndAnimation = false
|
||||
if (typeof config.loopStartUseCustomDuration !== 'boolean') config.loopStartUseCustomDuration = false
|
||||
|
||||
@@ -862,4 +862,10 @@ export class ParticleEmitter extends Container {
|
||||
this._lastSeed = this.cfg.seed
|
||||
this.rng = mulberry32(this.cfg.seed >>> 0)
|
||||
}
|
||||
|
||||
/** 从当前重置状态向前模拟,用于时间轴第 0 帧的暖场。 */
|
||||
warmup(frames: number, frameDuration: number) {
|
||||
const count = Math.max(0, Math.floor(Number(frames) || 0))
|
||||
for (let frame = 0; frame < count; frame++) this.update(frameDuration)
|
||||
}
|
||||
}
|
||||
@@ -119,8 +119,8 @@ export function resolveColliderPoint(collider: SceneCollider, worldX: number, wo
|
||||
const halfWidth = Math.max(0.001, collider.width * 0.5)
|
||||
const halfHeight = Math.max(0.001, collider.height * 0.5)
|
||||
if (Math.abs(localX) >= halfWidth || Math.abs(localY) >= halfHeight) return null
|
||||
const penetrationX = halfWidth - Math.abs(localX)
|
||||
const penetrationY = halfHeight - Math.abs(localY)
|
||||
const penetrationX = (halfWidth - Math.abs(localX)) * scaleX
|
||||
const penetrationY = (halfHeight - Math.abs(localY)) * scaleY
|
||||
if (penetrationX < penetrationY) {
|
||||
const nx = localX >= 0 ? 1 : -1
|
||||
return toWorld(nx * halfWidth, localY, nx, 0)
|
||||
|
||||
@@ -265,6 +265,8 @@ export interface EmitterConfig {
|
||||
damping: number
|
||||
/** 全局随机种子;包括 0 在内的任意整数都可确定性复现 */
|
||||
seed: number
|
||||
/** 时间轴开始前预先模拟的帧数。 */
|
||||
warmupFrames: number
|
||||
// 颜色
|
||||
colorStart: string
|
||||
colorEnd: string
|
||||
|
||||
@@ -487,6 +487,7 @@
|
||||
"rotation": "Drehung",
|
||||
"scaleX": "X-Skalierung",
|
||||
"scaleY": "Y-Skalierung",
|
||||
"warmupFrames": "Vorlauf in Frames",
|
||||
"seed": "Zufallsseed",
|
||||
"seedTitle": "Steuern Sie alle Zufallsattribute, die Ressourcenauswahl und die Zufallsstichprobe von Modifikatoren im Partikelsystem",
|
||||
"randomizeSeed": "Generieren Sie einen neuen globalen Zufallsstartwert",
|
||||
@@ -811,7 +812,7 @@
|
||||
"differenceDesc": "„Konfiguration speichern“ speichert alle editierbaren Objekte und Parameter; „Export Spine Json“ exportiert nur das angezeigte Partikelsystem und backt die Kollisions-, Folge-, Nachlauf- und Sub-Emitter-Ergebnisse in unabhängige Animationsdaten."
|
||||
},
|
||||
"errors": {
|
||||
"confirmLoopFollowEnter": "Nach dem Aufrufen des kontinuierlichen Schleifenmodus werden die Partikelverfolgungseinstellungen ungültig und auf den Standardwert zurückgesetzt. Weitermachen?",
|
||||
"confirmLoopFollowEnter": "Nach dem Aufrufen des kontinuierlichen Schleifenmodus werden die Partikelverfolgungseinstellungen ungültig, auf den Standardwert zurückgesetzt und der Vorlauf in Frames ignoriert. Weitermachen?",
|
||||
"confirmLoopFollowExit": "Nach dem Verlassen des Schleifenpersistenzmodus werden die Partikelverfolgungseinstellungen ungültig und auf ihre Standardwerte zurückgesetzt. Weitermachen?",
|
||||
"invalidJson": "Die Konfigurationsdatei ist ungültig JSON",
|
||||
"invalidConfigFormat": "Nicht das Editorprofil SpineParticle",
|
||||
|
||||
@@ -499,6 +499,7 @@
|
||||
"rotation": "Rotation",
|
||||
"scaleX": "Scale X",
|
||||
"scaleY": "Scale Y",
|
||||
"warmupFrames": "Warm-up frames",
|
||||
"seed": "Random seed",
|
||||
"seedTitle": "Controls random attributes, resource selection, and modifier sampling",
|
||||
"randomizeSeed": "Generate a new global random seed",
|
||||
@@ -834,7 +835,7 @@
|
||||
"differenceDesc": "Save Config preserves editable objects and parameters. Export Spine JSON includes only visible particle systems and bakes collisions, following, trails, and sub-emitters into editable animation data."
|
||||
},
|
||||
"errors": {
|
||||
"confirmLoopFollowEnter": "Enabling loop stream disables particle following and resets its settings. Continue?",
|
||||
"confirmLoopFollowEnter": "Enabling loop stream disables particle following, resets its settings, and disables warm-up frames. Continue?",
|
||||
"confirmLoopFollowExit": "Leaving loop stream disables particle following and resets its settings. Continue?",
|
||||
"invalidJson": "The configuration file is not valid JSON",
|
||||
"invalidConfigFormat": "This is not a SpineParticle editor configuration",
|
||||
|
||||
@@ -487,6 +487,7 @@
|
||||
"rotation": "Rotación",
|
||||
"scaleX": "Escala X",
|
||||
"scaleY": "Escala Y",
|
||||
"warmupFrames": "Fotogramas de calentamiento",
|
||||
"seed": "Semilla aleatoria",
|
||||
"seedTitle": "Controle todos los atributos aleatorios, la selección de recursos y el muestreo aleatorio de modificadores en el sistema de partículas.",
|
||||
"randomizeSeed": "Generar una nueva semilla aleatoria global",
|
||||
@@ -811,7 +812,7 @@
|
||||
"differenceDesc": "\"Guardar configuración\" guarda todos los objetos y parámetros que se pueden editar; \"Exportar Spine Json\" solo exporta el sistema de partículas mostrado y convierte los resultados de colisión, seguimiento, seguimiento y subemisor en datos de animación independientes."
|
||||
},
|
||||
"errors": {
|
||||
"confirmLoopFollowEnter": "Después de ingresar al modo de bucle continuo, la siguiente configuración de partículas no será válida y se restaurará al valor predeterminado. ¿Continuar?",
|
||||
"confirmLoopFollowEnter": "Al activar el flujo en bucle, el seguimiento de partículas se desactivará y se restablecerá, y los fotogramas de calentamiento se ignorarán. ¿Continuar?",
|
||||
"confirmLoopFollowExit": "Después de salir del modo de persistencia del bucle, la siguiente configuración de partículas no será válida y se restaurará a sus valores predeterminados. ¿Continuar?",
|
||||
"invalidJson": "El archivo de configuración no es válido JSON",
|
||||
"invalidConfigFormat": "No es el perfil del editor SpineParticle",
|
||||
|
||||
@@ -487,6 +487,7 @@
|
||||
"rotation": "Rotation",
|
||||
"scaleX": "Échelle X",
|
||||
"scaleY": "Échelle Y",
|
||||
"warmupFrames": "Images de préchauffage",
|
||||
"seed": "Graine aléatoire",
|
||||
"seedTitle": "Contrôlez tous les attributs aléatoires, la sélection des ressources et l'échantillonnage aléatoire du modificateur dans le système de particules",
|
||||
"randomizeSeed": "Générer une nouvelle graine aléatoire globale",
|
||||
@@ -811,7 +812,7 @@
|
||||
"differenceDesc": "« Enregistrer la configuration » enregistre tous les objets et paramètres pouvant être modifiés ; \"Exporter Spine Json\" exporte uniquement le système de particules affiché et intègre les résultats de collision, de suivi, de fuite et de sous-émetteur dans des données d'animation indépendantes."
|
||||
},
|
||||
"errors": {
|
||||
"confirmLoopFollowEnter": "Après être entré en mode continu de boucle, les paramètres de suivi des particules seront invalides et restaurés à leur valeur par défaut. Continuer?",
|
||||
"confirmLoopFollowEnter": "Après être entré en mode continu de boucle, les paramètres de suivi des particules seront invalides et restaurés à leur valeur par défaut, et les images de préchauffage seront ignorées. Continuer?",
|
||||
"confirmLoopFollowExit": "Après avoir quitté le mode de persistance de boucle, les paramètres de suivi des particules seront invalides et restaurés à leurs valeurs par défaut. Continuer?",
|
||||
"invalidJson": "Le fichier de configuration n'est pas valide JSON",
|
||||
"invalidConfigFormat": "Pas le profil de l'éditeur SpineParticle",
|
||||
|
||||
@@ -487,6 +487,7 @@
|
||||
"rotation": "回転",
|
||||
"scaleX": "X スケール",
|
||||
"scaleY": "Y スケール",
|
||||
"warmupFrames": "ウォームアップフレーム",
|
||||
"seed": "ランダムシード",
|
||||
"seedTitle": "パーティクル システム内のすべてのランダム アトリビュート、リソースの選択、モディファイアのランダム サンプリングを制御します。",
|
||||
"randomizeSeed": "新しいグローバルランダムシードを生成する",
|
||||
@@ -811,7 +812,7 @@
|
||||
"differenceDesc": "「設定の保存」では、編集可能なすべてのオブジェクトとパラメータが保存されます。 「Spine Json のエクスポート」は、表示されているパーティクル システムのみをエクスポートし、コリジョン、フォロー、トレーリング、サブエミッターの結果を独立したアニメーション データにベイクします。"
|
||||
},
|
||||
"errors": {
|
||||
"confirmLoopFollowEnter": "ループ連続モードに入ると、パーティクル追従設定は無効になり、デフォルト値に戻ります。続く?",
|
||||
"confirmLoopFollowEnter": "ループ連続モードに入ると、パーティクル追従設定は無効になり、デフォルト値に戻り、ウォームアップフレームも無効になります。続く?",
|
||||
"confirmLoopFollowExit": "ループ永続モードを終了すると、パーティクル追従設定は無効になり、デフォルト値に戻ります。続く?",
|
||||
"invalidJson": "構成ファイルが有効ではありません JSON",
|
||||
"invalidConfigFormat": "SpineParticle 編集者プロフィールではありません",
|
||||
|
||||
@@ -487,6 +487,7 @@
|
||||
"rotation": "회전",
|
||||
"scaleX": "X 크기",
|
||||
"scaleY": "Y 크기",
|
||||
"warmupFrames": "웜업 프레임",
|
||||
"seed": "랜덤 시드",
|
||||
"seedTitle": "입자 시스템의 모든 무작위 속성, 리소스 선택 및 수정자 무작위 샘플링을 제어합니다.",
|
||||
"randomizeSeed": "새로운 전역 무작위 시드 생성",
|
||||
@@ -811,7 +812,7 @@
|
||||
"differenceDesc": "\"구성 저장\"은 편집할 수 있는 모든 개체와 매개변수를 저장합니다. \"Spine Json 내보내기\"는 표시된 입자 시스템만 내보내고 충돌, 추적, 추적 및 하위 방사체 결과를 독립적인 애니메이션 데이터로 굽습니다."
|
||||
},
|
||||
"errors": {
|
||||
"confirmLoopFollowEnter": "루프 연속 모드에 들어가면 입자 추적 설정이 무효화되고 기본값으로 복원됩니다. 계속하다?",
|
||||
"confirmLoopFollowEnter": "루프 연속 모드에 들어가면 입자 추적 설정이 무효화되어 기본값으로 복원되고 웜업 프레임도 적용되지 않습니다. 계속할까요?",
|
||||
"confirmLoopFollowExit": "루프 지속 모드를 종료한 후 입자 추적 설정이 무효화되고 기본값으로 복원됩니다. 계속하다?",
|
||||
"invalidJson": "구성 파일이 유효하지 않습니다. JSON",
|
||||
"invalidConfigFormat": "SpineParticle 편집자 프로필이 아닙니다.",
|
||||
|
||||
@@ -487,6 +487,7 @@
|
||||
"rotation": "Rotação",
|
||||
"scaleX": "Escala X",
|
||||
"scaleY": "Escala Y",
|
||||
"warmupFrames": "Quadros de aquecimento",
|
||||
"seed": "Semente aleatória",
|
||||
"seedTitle": "Controle todos os atributos aleatórios, seleção de recursos e modificador de amostragem aleatória no sistema de partículas",
|
||||
"randomizeSeed": "Gere uma nova semente aleatória global",
|
||||
@@ -811,7 +812,7 @@
|
||||
"differenceDesc": "“Salvar configuração” salva todos os objetos e parâmetros que podem ser editados; \"Exportar Spine Json\" exporta apenas o sistema de partículas exibido e transforma os resultados de colisão, seguimento, rastreamento e subemissor em dados de animação independentes."
|
||||
},
|
||||
"errors": {
|
||||
"confirmLoopFollowEnter": "Depois de entrar no modo de loop contínuo, as configurações seguintes de partículas serão inválidas e restauradas para o valor padrão. Continuar?",
|
||||
"confirmLoopFollowEnter": "Depois de entrar no modo de loop contínuo, o rastreamento de partículas será desativado, suas configurações serão restauradas e os quadros de aquecimento serão ignorados. Continuar?",
|
||||
"confirmLoopFollowExit": "Depois de sair do modo de persistência de loop, as configurações a seguir serão inválidas e restauradas para seus valores padrão. Continuar?",
|
||||
"invalidJson": "O arquivo de configuração não é válido JSON",
|
||||
"invalidConfigFormat": "Não é o perfil do editor SpineParticle",
|
||||
|
||||
@@ -499,6 +499,7 @@
|
||||
"rotation": "旋转",
|
||||
"scaleX": "缩放X",
|
||||
"scaleY": "缩放Y",
|
||||
"warmupFrames": "暖场帧数",
|
||||
"seed": "随机种子",
|
||||
"seedTitle": "控制粒子系统内全部随机属性、资源选择与修改器随机采样",
|
||||
"randomizeSeed": "生成新的全局随机种子",
|
||||
@@ -834,7 +835,7 @@
|
||||
"differenceDesc": "“保存配置”保存全部可继续编辑的对象和参数;“导出 Spine Json”只导出显示中的粒子系统,并把碰撞、跟随、拖尾和子发射器结果烘焙为独立动画数据。"
|
||||
},
|
||||
"errors": {
|
||||
"confirmLoopFollowEnter": "进入循环持续模式后,粒子跟随设置会失效并恢复默认值。是否继续?",
|
||||
"confirmLoopFollowEnter": "进入循环持续模式后,粒子跟随设置会失效并恢复默认值,且暖场帧数失效。是否继续?",
|
||||
"confirmLoopFollowExit": "退出循环持续模式后,粒子跟随设置会失效并恢复默认值。是否继续?",
|
||||
"invalidJson": "配置文件不是有效的 JSON",
|
||||
"invalidConfigFormat": "不是 SpineParticle 编辑器配置文件",
|
||||
|
||||
@@ -487,6 +487,7 @@
|
||||
"rotation": "旋轉",
|
||||
"scaleX": "X 縮放",
|
||||
"scaleY": "Y 縮放",
|
||||
"warmupFrames": "暖場幀數",
|
||||
"seed": "隨機種子",
|
||||
"seedTitle": "控製粒子系統內全部隨機屬性、資源選擇與修改器隨機取樣",
|
||||
"randomizeSeed": "產生新的全域隨機種子",
|
||||
@@ -811,7 +812,7 @@
|
||||
"differenceDesc": "「儲存配置」儲存全部可繼續編輯的物件和參數;「匯出 Spine Json」只匯出顯示中的粒子系統,並把碰撞、跟隨、拖尾和子發射器結果烘焙為獨立動畫資料。"
|
||||
},
|
||||
"errors": {
|
||||
"confirmLoopFollowEnter": "進入循環持續模式後,粒子跟隨設定會失效並恢復預設值。是否繼續?",
|
||||
"confirmLoopFollowEnter": "進入循環持續模式後,粒子跟隨設定會失效並恢復預設值,且暖場幀數失效。是否繼續?",
|
||||
"confirmLoopFollowExit": "退出循環持續模式後,粒子跟隨設定會失效並恢復預設值。是否繼續?",
|
||||
"invalidJson": "設定檔不是有效的 JSON",
|
||||
"invalidConfigFormat": "不是 SpineParticle 編輯器設定檔",
|
||||
|
||||
+63
-6
@@ -204,12 +204,47 @@ function syncEmitters() {
|
||||
emitterSync.sync(store, world, dotTex, trailTex, spineRuntime)
|
||||
}
|
||||
|
||||
/** 事件互相连接的粒子系统共享最长暖场帧数,保证子发射器也能从父事件产生。 */
|
||||
function warmupFramesBySystem() {
|
||||
const byId = new Map(store.systems.map((system) => [system.id, system]))
|
||||
const subEmitterIds = collectSubEmitterIds(store.systems)
|
||||
const neighbors = new Map(store.systems.map((system) => [system.id, new Set<number>()]))
|
||||
for (const system of store.systems) {
|
||||
if (system.config.eventEnabled === false) continue
|
||||
for (const rule of system.config.events || []) {
|
||||
if (!byId.has(rule.targetSystemId) || rule.targetSystemId === system.id) continue
|
||||
neighbors.get(system.id)!.add(rule.targetSystemId)
|
||||
neighbors.get(rule.targetSystemId)!.add(system.id)
|
||||
}
|
||||
}
|
||||
const result = new Map<number, number>()
|
||||
for (const system of store.systems) {
|
||||
if (result.has(system.id)) continue
|
||||
const component: number[] = []
|
||||
const pending = [system.id]
|
||||
while (pending.length) {
|
||||
const id = pending.pop()!
|
||||
if (component.includes(id)) continue
|
||||
component.push(id)
|
||||
pending.push(...neighbors.get(id)!)
|
||||
}
|
||||
const frames = Math.max(0, ...component.map((id) => {
|
||||
if (subEmitterIds.has(id)) return 0
|
||||
const config = byId.get(id)?.config
|
||||
if (config?.mode === 'stream' && config.streamBehavior === 'loop') return 0
|
||||
return Math.floor(Number(config?.warmupFrames) || 0)
|
||||
}))
|
||||
for (const id of component) result.set(id, frames)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步烘焙一个系统的整段帧缓存。
|
||||
* 与逐帧录制结果一致(同样的固定步长、同样的随机序列),因此不会改变预览内容,
|
||||
* 只是把"参数变动时就该生成好的烘焙结果"补齐。
|
||||
*/
|
||||
function bakeSystemFrames(system: ParticleSystem, emitter: ParticleEmitter, fps: number, totalFrames: number) {
|
||||
function bakeSystemFrames(system: ParticleSystem, emitter: ParticleEmitter, fps: number, totalFrames: number, warmupFrames: number) {
|
||||
const frameCount = Math.max(2, Math.floor(totalFrames) || 2)
|
||||
// 循环持续本来就是一次性烘焙,直接复用同一套逻辑。
|
||||
if (system.config.mode === 'stream' && system.config.streamBehavior === 'loop') {
|
||||
@@ -218,10 +253,12 @@ function bakeSystemFrames(system: ParticleSystem, emitter: ParticleEmitter, fps:
|
||||
}
|
||||
emitter.reset()
|
||||
const frameDuration = 1 / Math.max(1, fps)
|
||||
emitter.warmup(warmupFrames, frameDuration)
|
||||
const frames: ParticleState[][] = []
|
||||
for (let frame = 0; frame < frameCount; frame++) {
|
||||
// 与逐帧录制保持一致:第 0 帧保持空白,避免正式播放前提前发射。
|
||||
if (frame === 0) { emitter.apply([]); frames.push([]) } else frames.push(emitter.update(frameDuration))
|
||||
// 第 0 帧使用暖场后的状态;未设置暖场时仍保持从空白开始。
|
||||
if (frame === 0) frames.push(warmupFrames > 0 ? emitter.capture() : [])
|
||||
else frames.push(emitter.update(frameDuration))
|
||||
}
|
||||
system.frames = frames
|
||||
}
|
||||
@@ -233,6 +270,7 @@ function bakeSystemFrames(system: ParticleSystem, emitter: ParticleEmitter, fps:
|
||||
function ensureFramesBaked(fps: number) {
|
||||
const totalFrames = Math.max(2, Math.floor(store.timeline.totalFrames) || 2)
|
||||
const subEmitterIds = collectSubEmitterIds(store.systems)
|
||||
const warmupFrames = warmupFramesBySystem()
|
||||
// 烘焙预热会大量出生/死亡粒子,期间暂停跨系统事件,避免注入预热粒子。
|
||||
emitterSync.suspendEvents(true)
|
||||
try {
|
||||
@@ -242,7 +280,7 @@ function ensureFramesBaked(fps: number) {
|
||||
if ((system.frames?.length || 0) >= totalFrames) continue
|
||||
const emitter = emitterSync.emitters.get(system.id)
|
||||
if (!emitter) continue
|
||||
bakeSystemFrames(system as unknown as ParticleSystem, emitter, fps, totalFrames)
|
||||
bakeSystemFrames(system as unknown as ParticleSystem, emitter, fps, totalFrames, warmupFrames.get(system.id) || 0)
|
||||
}
|
||||
} finally {
|
||||
emitterSync.suspendEvents(false)
|
||||
@@ -398,6 +436,7 @@ function loop() {
|
||||
displayFrame = tl.frame
|
||||
collected.length = 0
|
||||
total = 0
|
||||
const warmedSystems = new Set<number>()
|
||||
if (!tl.recorded) {
|
||||
// 录制阶段:固定步长模拟并记录当前帧。
|
||||
emitterSync.suspendEvents(true)
|
||||
@@ -421,14 +460,32 @@ function loop() {
|
||||
} finally {
|
||||
emitterSync.suspendEvents(false)
|
||||
}
|
||||
if (tl.frame === 0) {
|
||||
const warmupFrames = warmupFramesBySystem()
|
||||
const targets = store.systems.flatMap((system) => {
|
||||
const emitter = emitterSync.emitters.get(system.id)
|
||||
const isLoopStream = system.config.mode === 'stream' && system.config.streamBehavior === 'loop'
|
||||
const frames = warmupFrames.get(system.id) || 0
|
||||
return emitter && !isLoopStream && frames > 0 ? [{ system, emitter, frames }] : []
|
||||
})
|
||||
const lastFrame = Math.max(0, ...targets.map(({ frames }) => frames))
|
||||
for (let warmupFrame = 0; warmupFrame < lastFrame; warmupFrame++) {
|
||||
for (const target of targets) {
|
||||
if (warmupFrame < target.frames) target.emitter.update(frameDuration)
|
||||
}
|
||||
}
|
||||
for (const { system } of targets) warmedSystems.add(system.id)
|
||||
}
|
||||
for (const [systemId, em] of emitterSync.emitters) {
|
||||
const system = store.systems.find((item) => item.id === systemId)
|
||||
const isLoopStream = system?.config.mode === 'stream' && system.config.streamBehavior === 'loop'
|
||||
const loopFrame = isLoopStream
|
||||
? system.frames?.[tl.frame]
|
||||
: undefined
|
||||
const forceEmptyInitialFrame = tl.frame === 0 && !isLoopStream
|
||||
const states = forceEmptyInitialFrame ? [] : (loopFrame || em.update(frameDuration))
|
||||
const forceEmptyInitialFrame = tl.frame === 0 && !isLoopStream && !warmedSystems.has(systemId)
|
||||
const states = forceEmptyInitialFrame
|
||||
? []
|
||||
: loopFrame || (warmedSystems.has(systemId) ? em.capture() : em.update(frameDuration))
|
||||
if (forceEmptyInitialFrame) em.apply([])
|
||||
else if (loopFrame) em.apply(loopFrame)
|
||||
if (system?.visible !== false) {
|
||||
|
||||
@@ -86,6 +86,24 @@
|
||||
<NumSlider :label="$t('scene.rotation')" :min="-360" :max="360" :step="1" v-model="sys.config.rootRotation" />
|
||||
<NumSlider :label="$t('scene.scaleX')" :min="0.05" :max="10" :step="0.05" v-model="sys.config.rootScaleX" />
|
||||
<NumSlider :label="$t('scene.scaleY')" :min="0.05" :max="10" :step="0.05" v-model="sys.config.rootScaleY" />
|
||||
<div class="seed-row scene-warmup">
|
||||
<label for="system-warmup">{{ $t('scene.warmupFrames') }}</label>
|
||||
<div class="scene-number-unit" :class="{ disabled: warmupDisabled }">
|
||||
<input
|
||||
id="system-warmup"
|
||||
class="seed-input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
:disabled="warmupDisabled"
|
||||
:value="warmupFramesText"
|
||||
@input="warmupFramesText = ($event.target as HTMLInputElement).value"
|
||||
@change="commitWarmupFrames"
|
||||
@blur="commitWarmupFrames"
|
||||
/>
|
||||
<span>f</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="seed-row scene-seed">
|
||||
<label for="system-seed">{{ $t('scene.seed') }}</label>
|
||||
<input id="system-seed" class="seed-input" type="number" min="0" step="1" v-model.number="sys.config.seed" :title="$t('scene.seedTitle')" />
|
||||
@@ -184,18 +202,37 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import NumSlider from '../NumSlider.vue'
|
||||
import CurveEditor from '../CurveEditor.vue'
|
||||
import ParticleAttributeControl from '../ParticleAttributeControl.vue'
|
||||
import ColorGradientEditor from '../ColorGradientEditor.vue'
|
||||
import ImageResourceCard from '../ImageResourceCard.vue'
|
||||
import SpineObjectPanel from '../../spine/SpineObjectPanel.vue'
|
||||
import { collectSubEmitterIds } from '../../core/subEmitterIds'
|
||||
import { useParticlePanelContext } from './particlePanelContext'
|
||||
const { store, resolvedCurveEditorMode, sys, activeObjectType, activeCollider, activePath, activeSpine, canCopyObject, collapsed, toggle, sectionOpen, addSys, addCollider, addPath, addSpine, copySelectedObject, applyPathPreset, clearPathAnchors, randomizeSeed, LINEAR_CURVE } = useParticlePanelContext()
|
||||
|
||||
type DisplaySystem = { system: (typeof store.systems)[number]; depth: number }
|
||||
|
||||
const warmupDisabled = computed(() => !!sys.value && (
|
||||
collectSubEmitterIds(store.systems).has(sys.value.id)
|
||||
|| (sys.value.config.mode === 'stream' && sys.value.config.streamBehavior === 'loop')
|
||||
))
|
||||
const warmupFramesText = ref('0')
|
||||
watch(
|
||||
() => [sys.value?.id, sys.value?.config.warmupFrames] as const,
|
||||
() => { warmupFramesText.value = String(sys.value?.config.warmupFrames ?? 0) },
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function commitWarmupFrames() {
|
||||
if (!sys.value || warmupDisabled.value) return
|
||||
const frames = Math.max(0, Math.floor(Number(warmupFramesText.value) || 0))
|
||||
warmupFramesText.value = String(frames)
|
||||
sys.value.config.warmupFrames = frames
|
||||
}
|
||||
|
||||
/** 子发射器嵌在父系统下方显示,方便直接选中;移除引用后自动回到普通行。 */
|
||||
const displaySystems = computed<DisplaySystem[]>(() => {
|
||||
const byId = new Map(store.systems.map((system) => [system.id, system]))
|
||||
@@ -314,5 +351,11 @@ const displaySystems = computed<DisplaySystem[]>(() => {
|
||||
.seed-random { width: 34px; height: 30px; border: 1px solid #3a4860; border-radius: 6px; background: #2c3950; color: #d7def0; cursor: pointer; }
|
||||
.seed-random:hover { background: #394964; }
|
||||
.scene-seed { margin-top: 10px; }
|
||||
.scene-warmup { margin-top: 10px; }
|
||||
.scene-number-unit { min-width: 0; flex: 1; display: flex; align-items: center; border: 1px solid #344158; border-radius: 6px; background: #0f1625; }
|
||||
.scene-number-unit:focus-within { border-color: #625ff1; }
|
||||
.scene-number-unit .seed-input { border: 0; background: transparent; }
|
||||
.scene-number-unit > span { padding-right: 8px; color: #7e8aa3; font-size: 12px; }
|
||||
.scene-number-unit.disabled { opacity: .5; }
|
||||
.seed-hint { margin: 5px 0 1px 72px; color: #657188; font-size: 9px; line-height: 1.45; }
|
||||
</style>
|
||||
Reference in new issue
Block a user