Files
SpineParticlesWeb/src/views/particle-panel/SceneObjectsSection.vue
T
2026-11-09 18:11:53 +08:00

461 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>