461 lines
32 KiB
Vue
461 lines
32 KiB
Vue
<template>
|
||
<!-- 1. 场景对象:创建入口始终可用,即使当前没有粒子系统。 -->
|
||
<div class="group" data-matomo-section="scene">
|
||
<div class="group-head scene-head" @click="toggle('scene')">
|
||
<span class="caret">{{ collapsed.scene ? '▸' : '▾' }}</span><span>{{ $t('scene.title') }}</span>
|
||
<div class="scene-actions">
|
||
<button class="scene-add" data-matomo-event="scene.add-particle" :title="$t('scene.addParticle')" @click.stop="addSys">{{ $t('scene.addParticleShort') }}</button>
|
||
<button class="scene-add" data-matomo-event="scene.add-collider" :title="$t('scene.addCollider')" @click.stop="addCollider">{{ $t('scene.addColliderShort') }}</button>
|
||
<button class="scene-add" data-matomo-event="scene.add-path" :title="$t('scene.addPath')" @click.stop="addPath">{{ $t('scene.addPathShort') }}</button>
|
||
<button class="scene-add" data-matomo-event="scene.add-spine" :title="$t('scene.addSpine')" @click.stop="addSpine">+Spine</button>
|
||
<button class="scene-add" data-matomo-event="scene.copy" :disabled="!canCopyObject" :title="$t('scene.copyTitle')" @click.stop="copySelectedObject">+Copy</button>
|
||
</div>
|
||
</div>
|
||
<div class="group-body" v-show="sectionOpen('scene')">
|
||
<div class="tree scene-tree">
|
||
<div
|
||
v-for="entry in displaySystems"
|
||
:key="`particle-${entry.system.id}`"
|
||
class="tree-item scene-object-item"
|
||
:class="{ on: activeObjectType === 'particle' && entry.system.id === store.activeObjectId, 'scene-object-child': entry.depth > 0 }"
|
||
:style="entry.depth > 0 ? { paddingLeft: `${entry.depth * 18}px` } : undefined"
|
||
@click="store.selectSystem(entry.system.id)"
|
||
@contextmenu.prevent.stop="openSceneObjectMenu($event, 'particle', entry.system.id)"
|
||
>
|
||
<span v-if="entry.depth > 0" class="sub-branch">└</span>
|
||
<span v-else class="tdot">●</span>
|
||
<span class="tlabel">{{ entry.system.config.name || 'ParticleSystem' }}</span>
|
||
<button class="scene-object-eye" data-matomo-event="scene.toggle-particle" :class="{ off: entry.system.visible === false }" :title="entry.system.visible === false ? $t('scene.showParticle') : $t('scene.hideParticle')" :aria-label="entry.system.visible === false ? $t('scene.showParticle') : $t('scene.hideParticle')" @click.stop="entry.system.visible = entry.system.visible === false">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/><path v-if="entry.system.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
|
||
</button>
|
||
<span class="scene-object-kind" :class="{ 'sub-emitter-kind': entry.depth > 0 }">{{ entry.depth > 0 ? $t('scene.subEmitter') : $t('scene.particle') }}</span>
|
||
<button class="scene-object-delete" data-matomo-event="scene.delete-particle" :title="$t('scene.deleteParticle')" @click.stop="store.removeSystem(entry.system.id)">✕</button>
|
||
</div>
|
||
<div
|
||
v-for="collider in store.colliders"
|
||
:key="`collision-${collider.id}`"
|
||
class="tree-item scene-object-item"
|
||
:class="{ on: activeObjectType === 'collision' && collider.id === store.activeObjectId }"
|
||
@click="store.selectSceneObject('collision', collider.id)"
|
||
@contextmenu.prevent.stop="openSceneObjectMenu($event, 'collision', collider.id)"
|
||
>
|
||
<span class="tdot collision-dot">■</span>
|
||
<span class="tlabel">{{ collider.name }}</span>
|
||
<button class="scene-object-eye" data-matomo-event="scene.toggle-collider" :class="{ off: collider.visible === false }" :title="collider.visible === false ? $t('scene.showCollider') : $t('scene.hideCollider')" :aria-label="collider.visible === false ? $t('scene.showCollider') : $t('scene.hideCollider')" @click.stop="collider.visible = collider.visible === false">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/><path v-if="collider.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
|
||
</button>
|
||
<span class="scene-object-kind">{{ $t('scene.collision') }}</span>
|
||
<button class="scene-object-delete" data-matomo-event="scene.delete-collider" :title="$t('scene.deleteCollider')" @click.stop="store.removeCollider(collider.id)">✕</button>
|
||
</div>
|
||
<div
|
||
v-for="path in store.paths"
|
||
:key="`path-${path.id}`"
|
||
class="tree-item scene-object-item"
|
||
:class="{ on: activeObjectType === 'path' && path.id === store.activeObjectId }"
|
||
@click="store.selectSceneObject('path', path.id)"
|
||
@contextmenu.prevent.stop="openSceneObjectMenu($event, 'path', path.id)"
|
||
>
|
||
<span class="tdot path-dot">◇</span>
|
||
<span class="tlabel">{{ path.name }}</span>
|
||
<button class="scene-object-eye" data-matomo-event="scene.toggle-path" :class="{ off: path.visible === false }" :title="path.visible === false ? $t('scene.showPath') : $t('scene.hidePath')" :aria-label="path.visible === false ? $t('scene.showPath') : $t('scene.hidePath')" @click.stop="path.visible = path.visible === false">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/><path v-if="path.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
|
||
</button>
|
||
<span class="scene-object-kind">{{ $t('scene.path') }}</span>
|
||
<button class="scene-object-delete" data-matomo-event="scene.delete-path" :title="$t('scene.deletePath')" @click.stop="store.removePath(path.id)">✕</button>
|
||
</div>
|
||
<div
|
||
v-for="spine in store.spines"
|
||
:key="`spine-${spine.id}`"
|
||
class="tree-item scene-object-item"
|
||
:class="{ on: activeObjectType === 'spine' && spine.id === store.activeObjectId }"
|
||
@click="store.selectSceneObject('spine', spine.id)"
|
||
@contextmenu.prevent.stop="openSceneObjectMenu($event, 'spine', spine.id)"
|
||
>
|
||
<span class="tdot spine-dot">◆</span>
|
||
<span class="tlabel">{{ spine.name }}</span>
|
||
<button class="scene-object-eye" data-matomo-event="scene.toggle-spine" :class="{ off: spine.visible === false }" :title="spine.visible === false ? $t('scene.showSpine') : $t('scene.hideSpine')" :aria-label="spine.visible === false ? $t('scene.showSpine') : $t('scene.hideSpine')" @click.stop="spine.visible = spine.visible === false">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"/><circle cx="12" cy="12" r="2.8"/><path v-if="spine.visible === false" class="eye-slash" d="M4 4l16 16"/></svg>
|
||
</button>
|
||
<span class="scene-object-kind">Spine</span>
|
||
<button class="scene-object-delete" data-matomo-event="scene.delete-spine" :title="$t('scene.deleteSpine')" @click.stop="store.removeSpine(spine.id)">✕</button>
|
||
</div>
|
||
<div v-if="!store.systems.length && !store.colliders.length && !store.paths.length && !store.spines.length" class="empty">{{ $t('scene.empty') }}</div>
|
||
</div>
|
||
<template v-if="activeObjectType === 'particle' && sys">
|
||
<label class="row"><span>{{ $t('scene.name') }}</span><input v-model="sys.config.name" class="inp" /></label>
|
||
<NumSlider :label="$t('scene.positionX')" :min="-2000" :max="2000" :step="1" v-model="sys.config.centerX" />
|
||
<NumSlider :label="$t('scene.positionY')" :min="-2000" :max="2000" :step="1" v-model="sys.config.centerY" />
|
||
<NumSlider :label="$t('scene.offsetX')" :min="-2000" :max="2000" :step="1" v-model="sys.config.offsetX" />
|
||
<NumSlider :label="$t('scene.offsetY')" :min="-2000" :max="2000" :step="1" v-model="sys.config.offsetY" />
|
||
<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')" />
|
||
<button class="seed-random" data-matomo-event="scene.randomize-seed" :title="$t('scene.randomizeSeed')" @click="randomizeSeed">⚄</button>
|
||
</div>
|
||
<div class="seed-hint">{{ $t('scene.seedHint') }}</div>
|
||
</template>
|
||
<template v-else-if="activeCollider">
|
||
<div class="object-editor-title">{{ $t('scene.colliderTitle') }}</div>
|
||
<label class="field-block"><span>{{ $t('scene.colliderName') }}</span><input v-model="activeCollider.name" class="inp" /></label>
|
||
<div class="field-grid">
|
||
<label class="field-block"><span>{{ $t('scene.type') }}</span><select v-model="activeCollider.shape" class="inp">
|
||
<option value="circle">{{ $t('scene.circle') }}</option><option value="ellipse">{{ $t('scene.ellipse') }}</option><option value="rect">{{ $t('scene.rect') }}</option><option value="polygon">{{ $t('scene.polygon') }}</option><option value="custom">{{ $t('scene.custom') }}</option>
|
||
</select></label>
|
||
<label class="field-block check-field"><span>{{ $t('scene.enabled') }}</span><input v-model="activeCollider.enabled" type="checkbox" /></label>
|
||
</div>
|
||
<div class="field-grid">
|
||
<label class="compact-field"><span>{{ $t('scene.xPosition') }}</span><input v-model.number="activeCollider.x" type="number" class="inp" /></label>
|
||
<label class="compact-field"><span>{{ $t('scene.yPosition') }}</span><input v-model.number="activeCollider.y" type="number" class="inp" /></label>
|
||
</div>
|
||
<NumSlider :label="$t('scene.rotation')" :min="-360" :max="360" :step="1" v-model="activeCollider.rotation" />
|
||
<div class="field-grid">
|
||
<label class="compact-field"><span>{{ $t('scene.scaleXShort') }}</span><input v-model.number="activeCollider.scaleX" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||
<label class="compact-field"><span>{{ $t('scene.scaleYShort') }}</span><input v-model.number="activeCollider.scaleY" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||
</div>
|
||
<NumSlider v-if="activeCollider.shape === 'circle'" :label="$t('scene.radius')" :min="1" :max="1000" :step="1" v-model="activeCollider.radius" />
|
||
<div v-else-if="activeCollider.shape === 'ellipse'" class="field-grid">
|
||
<label class="compact-field"><span>{{ $t('scene.radiusX') }}</span><input v-model.number="activeCollider.radiusX" type="number" min="1" class="inp" /></label>
|
||
<label class="compact-field"><span>{{ $t('scene.radiusY') }}</span><input v-model.number="activeCollider.radiusY" type="number" min="1" class="inp" /></label>
|
||
</div>
|
||
<div v-else class="field-grid">
|
||
<label class="compact-field"><span>{{ $t('scene.width') }}</span><input v-model.number="activeCollider.width" type="number" min="1" class="inp" /></label>
|
||
<label class="compact-field"><span>{{ $t('scene.height') }}</span><input v-model.number="activeCollider.height" type="number" min="1" class="inp" /></label>
|
||
</div>
|
||
<NumSlider v-if="activeCollider.shape === 'polygon' || activeCollider.shape === 'custom'" :label="$t('scene.sides')" :min="3" :max="16" :step="1" v-model="activeCollider.sides" />
|
||
<label class="field-block"><span>{{ $t('scene.collisionResponse') }}</span><select v-model="activeCollider.response" class="inp">
|
||
<option value="physics">{{ $t('scene.physics') }}</option><option value="pause">{{ $t('scene.pause') }}</option><option value="destroy">{{ $t('scene.destroy') }}</option>
|
||
</select></label>
|
||
<div v-if="activeCollider.response === 'physics'" class="field-grid">
|
||
<label class="compact-field"><span>{{ $t('scene.elasticity') }}</span><input v-model.number="activeCollider.elasticity" type="number" min="0" max="1" step="0.01" class="inp" /></label>
|
||
<label class="compact-field"><span>{{ $t('scene.friction') }}</span><input v-model.number="activeCollider.friction" type="number" min="0" max="1" step="0.01" class="inp" /></label>
|
||
</div>
|
||
<div class="collider-follow-section">
|
||
<label class="follow-path"><input v-model="activeCollider.followPath" type="checkbox" />{{ $t('scene.followPath') }}</label>
|
||
<template v-if="activeCollider.followPath">
|
||
<label class="field-block"><span>{{ $t('scene.targetPath') }}</span><select v-model.number="activeCollider.followPathId" class="inp">
|
||
<option :value="0">{{ $t('scene.notSelected') }}</option>
|
||
<option v-for="path in store.paths" :key="path.id" :value="path.id">{{ path.name }}</option>
|
||
</select></label>
|
||
<div class="field-grid">
|
||
<label class="compact-field"><span>{{ $t('scene.moveDuration') }}</span><input v-model.number="activeCollider.followDuration" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||
<label class="compact-field"><span>{{ $t('appearance.curveMode') }}</span><select v-model="activeCollider.followCurveMode" class="inp">
|
||
<option value="跟随导出">{{ $t('common.followExport') }}</option><option value="线性">{{ $t('common.linear') }}</option><option value="贝塞尔">{{ $t('common.bezier') }}</option>
|
||
</select></label>
|
||
</div>
|
||
<label class="follow-path"><input v-model="activeCollider.followPathRotation" type="checkbox" />{{ $t('scene.followDirection') }}</label>
|
||
<div class="collider-curve-title">{{ $t('scene.moveProgressCurve') }} <small>{{ $t('scene.moveProgressHelp') }}</small></div>
|
||
<CurveEditor
|
||
:model-value="activeCollider.followCurve || LINEAR_CURVE"
|
||
:default-value="LINEAR_CURVE"
|
||
:mode="resolvedCurveEditorMode(activeCollider.followCurveMode)"
|
||
@update:model-value="activeCollider.followCurve = $event"
|
||
/>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="activePath">
|
||
<div class="object-editor-title">{{ $t('scene.pathTitle') }}</div>
|
||
<label class="field-block"><span>{{ $t('scene.pathName') }}</span><input v-model="activePath.name" class="inp" /></label>
|
||
<div class="field-grid">
|
||
<label class="compact-field"><span>{{ $t('scene.xPosition') }}</span><input v-model.number="activePath.x" type="number" class="inp" /></label>
|
||
<label class="compact-field"><span>{{ $t('scene.yPosition') }}</span><input v-model.number="activePath.y" type="number" class="inp" /></label>
|
||
</div>
|
||
<NumSlider :label="$t('scene.rotation')" :min="-360" :max="360" :step="1" v-model="activePath.rotation" />
|
||
<div class="field-grid">
|
||
<label class="compact-field"><span>{{ $t('scene.scaleXShort') }}</span><input v-model.number="activePath.scaleX" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||
<label class="compact-field"><span>{{ $t('scene.scaleYShort') }}</span><input v-model.number="activePath.scaleY" type="number" min="0.05" step="0.05" class="inp" /></label>
|
||
</div>
|
||
<label class="setting-line"><span>{{ $t('scene.closedPath') }}</span><input v-model="activePath.closed" type="checkbox" /></label>
|
||
<label class="setting-line"><span>{{ $t('scene.pathColor') }}</span><input v-model="activePath.color" type="color" class="path-color" /></label>
|
||
<div class="path-help"><span>{{ $t('scene.pathHelp') }}</span><ul>
|
||
<li>{{ $t('scene.pathHelpItems.0') }}</li><li>{{ $t('scene.pathHelpItems.1') }}</li><li>{{ $t('scene.pathHelpItems.2') }}</li>
|
||
<li>{{ $t('scene.pathHelpItems.3') }}</li><li>{{ $t('scene.pathHelpItems.4') }}</li><li>{{ $t('scene.pathHelpItems.5') }}</li>
|
||
<li>{{ $t('scene.pathHelpItems.6') }}</li><li>{{ $t('scene.pathHelpItems.7') }}</li><li>{{ $t('scene.pathHelpItems.8') }}</li><li>{{ $t('scene.pathHelpItems.9') }}</li>
|
||
</ul></div>
|
||
<label class="field-block"><span>{{ $t('scene.applyPresetShape') }}</span><select v-model="activePath.preset" class="inp">
|
||
<option value="">{{ $t('scene.choosePreset') }}</option><option value="circle">{{ $t('scene.circle') }}</option><option value="square">{{ $t('scene.square') }}</option><option value="polygon">{{ $t('scene.polygon') }}</option><option value="star">{{ $t('scene.star') }}</option>
|
||
</select></label>
|
||
<button class="path-action primary" @click="applyPathPreset">{{ $t('scene.applyPreset') }}</button>
|
||
<button class="path-action" @click="clearPathAnchors">{{ $t('scene.clearAnchors') }}</button>
|
||
</template>
|
||
<SpineObjectPanel v-else-if="activeSpine" :spine-object="activeSpine" />
|
||
<div v-else class="panel-hint">{{ $t('scene.panelHint') }}</div>
|
||
</div>
|
||
</div>
|
||
<Teleport to="body">
|
||
<div
|
||
v-if="sceneContextMenu.open"
|
||
ref="sceneContextMenuElement"
|
||
class="scene-context-menu"
|
||
role="menu"
|
||
:style="{ left: sceneContextMenu.x + 'px', top: sceneContextMenu.y + 'px' }"
|
||
@pointerdown.stop
|
||
@contextmenu.prevent
|
||
>
|
||
<button type="button" role="menuitem" @click="copyFromSceneContextMenu">{{ $t('scene.contextCopy') }}</button>
|
||
<button type="button" role="menuitem" @click="pasteFromSceneContextMenu">{{ $t('scene.contextPaste') }}</button>
|
||
</div>
|
||
</Teleport>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, 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 { copySceneObjectToClipboard, pasteSceneObjectFromClipboard } from '../../editor/sceneObjectClipboard'
|
||
import type { SceneObjectType } from '../../store/particleStore'
|
||
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 sceneContextMenuElement = ref<HTMLElement | null>(null)
|
||
const sceneContextMenu = reactive({
|
||
open: false,
|
||
x: 0,
|
||
y: 0,
|
||
type: 'particle' as SceneObjectType,
|
||
id: 0,
|
||
})
|
||
|
||
function closeSceneContextMenu() {
|
||
sceneContextMenu.open = false
|
||
}
|
||
|
||
async function openSceneObjectMenu(event: MouseEvent, type: SceneObjectType, id: number) {
|
||
store.selectSceneObject(type, id)
|
||
Object.assign(sceneContextMenu, { open: true, x: event.clientX, y: event.clientY, type, id })
|
||
await nextTick()
|
||
const menu = sceneContextMenuElement.value
|
||
if (!menu) return
|
||
const bounds = menu.getBoundingClientRect()
|
||
sceneContextMenu.x = Math.max(8, Math.min(event.clientX, window.innerWidth - bounds.width - 8))
|
||
sceneContextMenu.y = Math.max(8, Math.min(event.clientY, window.innerHeight - bounds.height - 8))
|
||
}
|
||
|
||
async function runSceneContextAction(action: 'copy' | 'paste') {
|
||
const { type, id } = sceneContextMenu
|
||
closeSceneContextMenu()
|
||
try {
|
||
if (action === 'copy') await copySceneObjectToClipboard(store, type, id)
|
||
else await pasteSceneObjectFromClipboard(store, type, id)
|
||
} catch (error) {
|
||
window.alert(error instanceof Error ? error.message : String(error))
|
||
}
|
||
}
|
||
|
||
function copyFromSceneContextMenu() {
|
||
void runSceneContextAction('copy')
|
||
}
|
||
|
||
function pasteFromSceneContextMenu() {
|
||
void runSceneContextAction('paste')
|
||
}
|
||
|
||
function onSceneMenuPointerDown(event: PointerEvent) {
|
||
if (sceneContextMenu.open && !sceneContextMenuElement.value?.contains(event.target as Node)) {
|
||
closeSceneContextMenu()
|
||
}
|
||
}
|
||
|
||
function onSceneMenuKeyDown(event: KeyboardEvent) {
|
||
if (event.key === 'Escape') closeSceneContextMenu()
|
||
}
|
||
|
||
onMounted(() => {
|
||
window.addEventListener('pointerdown', onSceneMenuPointerDown, true)
|
||
window.addEventListener('keydown', onSceneMenuKeyDown, true)
|
||
window.addEventListener('scroll', closeSceneContextMenu, true)
|
||
window.addEventListener('blur', closeSceneContextMenu)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('pointerdown', onSceneMenuPointerDown, true)
|
||
window.removeEventListener('keydown', onSceneMenuKeyDown, true)
|
||
window.removeEventListener('scroll', closeSceneContextMenu, true)
|
||
window.removeEventListener('blur', closeSceneContextMenu)
|
||
})
|
||
|
||
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]))
|
||
const parentOf = new Map<number, number>()
|
||
for (const system of store.systems) {
|
||
if (system.config.eventEnabled === false) continue
|
||
for (const rule of system.config.events || []) {
|
||
if (rule.targetSystemId <= 0 || rule.targetSystemId === system.id) continue
|
||
if (!byId.has(rule.targetSystemId) || parentOf.has(rule.targetSystemId)) continue
|
||
parentOf.set(rule.targetSystemId, system.id)
|
||
}
|
||
}
|
||
const children = new Map<number, number[]>()
|
||
for (const [child, parent] of parentOf) {
|
||
const list = children.get(parent)
|
||
if (list) list.push(child)
|
||
else children.set(parent, [child])
|
||
}
|
||
const result: DisplaySystem[] = []
|
||
const visited = new Set<number>()
|
||
const walk = (system: (typeof store.systems)[number], depth: number) => {
|
||
if (visited.has(system.id)) return
|
||
visited.add(system.id)
|
||
result.push({ system, depth })
|
||
for (const childId of children.get(system.id) || []) {
|
||
const child = byId.get(childId)
|
||
if (child) walk(child, depth + 1)
|
||
}
|
||
}
|
||
for (const system of store.systems) if (!parentOf.has(system.id)) walk(system, 0)
|
||
// 环形引用兜底:保证所有系统仍然可见、可选中。
|
||
for (const system of store.systems) if (!visited.has(system.id)) walk(system, 0)
|
||
return result
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.scene-context-menu {
|
||
position: fixed; z-index: 10000; min-width: 142px; padding: 4px;
|
||
border: 1px solid var(--ui-border-strong); border-radius: 7px; background: var(--ui-surface);
|
||
box-shadow: 0 10px 28px rgba(0, 0, 0, .48);
|
||
}
|
||
.scene-context-menu button {
|
||
display: block; width: 100%; height: 30px; padding: 0 10px; border: 0; border-radius: 5px;
|
||
background: transparent; color: var(--ui-text-strong); font: inherit; font-size: 12px; text-align: left; cursor: pointer;
|
||
}
|
||
.scene-context-menu button:hover, .scene-context-menu button:focus-visible {
|
||
outline: none; background: var(--ui-control-hover); color: #fff;
|
||
}
|
||
.panel { display: flex; width: 100%; height: 100%; flex-direction: column; overflow: hidden; background: var(--ui-panel-bg); border-left: 1px solid var(--ui-border); padding: 12px; box-sizing: border-box; }
|
||
.empty, .panel-hint { color: var(--ui-text-muted); font-size: 12px; padding: 8px 0; }
|
||
.group { border: 1px solid var(--ui-border); border-radius: 8px; margin-bottom: 10px; overflow: hidden; }
|
||
.group-head {
|
||
display: flex; align-items: center; gap: 6px; padding: 7px 10px; cursor: pointer;
|
||
font-size: 12px; font-weight: 600; color: var(--ui-text-soft); background: var(--ui-surface-muted); user-select: none;
|
||
}
|
||
.group-head:hover { background: var(--ui-hover-surface); }
|
||
.scene-head { padding-right: 6px; }
|
||
.scene-actions { margin-left: auto; display: flex; align-items: center; gap: 4px; }
|
||
.scene-add {
|
||
height: 24px; padding: 0 6px; border: 1px solid var(--ui-border-strong); border-radius: 5px;
|
||
background: var(--ui-border-strong); color: var(--ui-accent-text-muted); font-size: 10px; white-space: nowrap; cursor: pointer;
|
||
}
|
||
.scene-add:hover { border-color: var(--ui-accent-strong); background: var(--ui-accent-soft); color: #fff; }
|
||
.scene-add:disabled { opacity: 0.42; cursor: not-allowed; border-color: var(--ui-border-control); background: var(--ui-surface); color: var(--ui-text-faint); }
|
||
.group-body { padding: 8px 10px 12px; }
|
||
.caret { display: inline-block; width: 12px; color: var(--ui-text-muted); font-size: 11px; }
|
||
.tree { margin-bottom: 8px; }
|
||
.tree-item { display: flex; align-items: center; gap: 6px; padding: 5px 6px; font-size: 12px; color: var(--ui-accent-text-muted); border-radius: 6px; margin-bottom: 2px; }
|
||
.tree-item.on { background: var(--ui-surface-raised); color: var(--ui-text-strong); }
|
||
.tree-item .tdot { color: #ffcc33; font-size: 10px; }
|
||
.tree-item .tdot.dim { color: var(--ui-text-faint); }
|
||
.tree-item .tdot.collision-dot { color: #ff7070; }
|
||
.tree-item .tdot.path-dot { color: #ff933e; font-size: 14px; }
|
||
.tree-item .tdot.spine-dot { color: #c08cff; font-size: 11px; }
|
||
.tree-item .sub-branch { color: var(--ui-text-faint); font-size: 12px; }
|
||
.tree-item .tlabel { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.scene-tree { padding-bottom: 8px; border-bottom: 1px solid var(--ui-border); }
|
||
.scene-object-item { min-height: 28px; cursor: pointer; }
|
||
.scene-object-item:hover { background: var(--ui-surface-raised); }
|
||
.scene-object-child { background: var(--ui-hover-translucent); }
|
||
.scene-object-kind { color: var(--ui-text-faint); font-size: 10px; }
|
||
.scene-object-kind.sub-emitter-kind { color: var(--ui-accent-text-soft); }
|
||
.scene-object-eye {
|
||
width: 24px; height: 22px; padding: 3px; flex: 0 0 24px; border: 1px solid transparent; border-radius: 5px;
|
||
background: transparent; color: var(--ui-text-body); cursor: pointer;
|
||
}
|
||
.scene-object-eye:hover { border-color: var(--ui-border-strong); background: var(--ui-control-hover); color: var(--ui-text-strong); }
|
||
.scene-object-eye.off { color: var(--ui-text-faint); }
|
||
.scene-object-eye svg { display: block; width: 100%; height: 100%; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||
.scene-object-eye .eye-slash { stroke: #d47b88; stroke-width: 2; }
|
||
.scene-object-delete {
|
||
width: 24px; height: 22px; padding: 0; border: 1px solid #59414a; border-radius: 5px;
|
||
background: #3a272f; color: #efb4bd; font-size: 11px; cursor: pointer;
|
||
}
|
||
.scene-object-delete:hover { border-color: #a45c69; background: #57303a; color: #fff; }
|
||
.object-editor-title { margin: 10px 0 8px; padding-top: 8px; border-top: 1px solid var(--ui-border); color: var(--ui-text-body); font-weight: 700; }
|
||
.field-block { display: flex; flex-direction: column; gap: 5px; margin: 10px 0; color: var(--ui-text-faint); font-size: 11px; }
|
||
.field-block > .inp { width: 100%; box-sizing: border-box; min-height: 30px; }
|
||
.field-block small { color: var(--ui-text-faint); line-height: 1.5; }
|
||
.field-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; align-items: end; }
|
||
.compact-field { display: flex; min-width: 0; flex-direction: column; gap: 5px; margin: 5px 0; color: var(--ui-text-faint); font-size: 11px; }
|
||
.compact-field .inp { width: 100%; min-height: 30px; box-sizing: border-box; }
|
||
.check-field { align-items: flex-start; }
|
||
.check-field input { width: 18px; height: 18px; margin: 4px 0; accent-color: var(--ui-accent-strong); }
|
||
.follow-path, .setting-line { display: flex; align-items: center; gap: 8px; margin: 12px 0 4px; color: var(--ui-text-body); font-size: 12px; }
|
||
.setting-line { justify-content: space-between; }
|
||
.follow-path input, .setting-line input[type='checkbox'] { width: 17px; height: 17px; accent-color: var(--ui-accent-strong); }
|
||
.collider-follow-section { margin-top: 12px; padding-top: 4px; border-top: 1px solid var(--ui-border-strong); }
|
||
.collider-curve-title { display: flex; flex-direction: column; gap: 3px; margin: 12px 0 6px; color: var(--ui-text-body); font-size: 12px; }
|
||
.collider-curve-title small { color: var(--ui-text-faint); font-size: 10px; line-height: 1.45; }
|
||
.path-color { width: 48px; height: 28px; padding: 0; border: 1px solid var(--ui-border-button); background: transparent; cursor: pointer; }
|
||
.path-help { margin: 12px 0; color: var(--ui-text-faint); font-size: 11px; line-height: 1.55; }
|
||
.path-help > span { color: var(--ui-text-body); }
|
||
.path-help ul { margin: 6px 0 0; padding-left: 20px; }
|
||
.path-help li { margin: 3px 0; }
|
||
.path-action { width: 100%; height: 31px; margin-top: 7px; border: 0; border-radius: 5px; background: var(--ui-border-strong); color: var(--ui-text-body); cursor: pointer; }
|
||
.path-action:hover { filter: brightness(1.12); }
|
||
.path-action.primary { background: #f05408; color: #fff; }
|
||
.row { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 12px; color: var(--ui-text); }
|
||
.row > span:first-child { width: 60px; flex-shrink: 0; color: var(--ui-text-secondary); }
|
||
.inp { flex: 1; min-width: 0; padding: 3px 5px; background: var(--ui-control-bg); border: 1px solid var(--ui-border-control); border-radius: 5px; color: var(--ui-text-strong); font-size: 12px; }
|
||
.inp.tiny { width: 46px; flex: 0 0 46px; text-align: center; }
|
||
.seed-row { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--ui-border-strong); }
|
||
.seed-row label { width: 64px; color: var(--ui-text-faint); font-size: 12px; }
|
||
.seed-input { min-width: 0; flex: 1; padding: 6px 8px; border: 1px solid var(--ui-border-strong); border-radius: 6px; outline: none; background: var(--ui-control-deep); color: var(--ui-text-strong); font-size: 12px; }
|
||
.seed-input:focus { border-color: var(--ui-accent); }
|
||
.seed-random { width: 34px; height: 30px; border: 1px solid var(--ui-border-strong); border-radius: 6px; background: var(--ui-border-strong); color: var(--ui-text-strong); cursor: pointer; }
|
||
.seed-random:hover { background: var(--ui-border-strong); }
|
||
.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 var(--ui-border-strong); border-radius: 6px; background: var(--ui-control-deep); }
|
||
.scene-number-unit:focus-within { border-color: var(--ui-accent); }
|
||
.scene-number-unit .seed-input { border: 0; background: transparent; }
|
||
.scene-number-unit > span { padding-right: 8px; color: var(--ui-text-faint); font-size: 12px; }
|
||
.scene-number-unit.disabled { opacity: .5; }
|
||
.seed-hint { margin: 5px 0 1px 72px; color: var(--ui-text-faint); font-size: 9px; line-height: 1.45; }
|
||
</style>
|