创建碰撞与路径
This commit is contained in:
1 parent
f2fe653c04
commit
f27167621c
4 files changed
+783
-56
No files matched your search
+288
-14
@@ -1,13 +1,10 @@
|
||||
<template>
|
||||
<div class="stage-wrap">
|
||||
<div ref="holder" class="stage-holder" :class="{ grid: showGrid }" @wheel.prevent="onWheel" @pointerdown="onPanDown"></div>
|
||||
<div ref="holder" class="stage-holder" :class="{ grid: showGrid }" @wheel.prevent="onWheel" @pointerdown="onPanDown" @dblclick.prevent="onStageDoubleClick"></div>
|
||||
|
||||
<!-- 顶栏 -->
|
||||
<div class="topbar">
|
||||
<label class="brand">粒子发射编辑器</label>
|
||||
<label class="chk"><input type="checkbox" v-model="showEmitter" />发射点</label>
|
||||
<label class="chk"><input type="checkbox" v-model="showBones" />骨骼连线</label>
|
||||
<label class="chk"><input type="checkbox" v-model="showSkinMesh" />蒙皮网格</label>
|
||||
<button class="settings-btn" @click="showSettings = !showSettings" title="系统设置">系统设置</button>
|
||||
|
||||
<!-- 根变换工具:拖动(默认) / 位移 / 旋转 / 缩放 -->
|
||||
<div class="tool-group">
|
||||
@@ -29,13 +26,16 @@
|
||||
<span class="zoom-pct">{{ Math.round(store.editor.viewScale * 100) }}%</span>
|
||||
<span class="fps">t={{ time.toFixed(2) }}s</span>
|
||||
|
||||
<!-- 系统设置入口 -->
|
||||
<button class="tool-btn settings-btn" @click="showSettings = !showSettings" title="系统设置">⚙</button>
|
||||
</div>
|
||||
|
||||
<!-- 系统设置面板(右上角弹出) -->
|
||||
<!-- 系统设置面板(左上角弹出) -->
|
||||
<div class="settings-panel" v-if="showSettings">
|
||||
<div class="sp-head">系统设置 <button class="sp-close" @click="showSettings = false">✕</button></div>
|
||||
<div class="sp-section-title">画布辅助显示</div>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="showEmitter" />发射点</label>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="showBones" />骨骼连线</label>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="showSkinMesh" />蒙皮网格</label>
|
||||
<div class="sp-divider"></div>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="store.settings.tickEnabled" />启用刻度</label>
|
||||
<template v-if="store.settings.tickEnabled">
|
||||
<label class="sp-row"><span>刻度文字大小</span>
|
||||
@@ -65,7 +65,7 @@
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Application, Container, Graphics, Text, Texture } from 'pixi.js'
|
||||
import { ParticleEmitter, ensureEmitterConfig, type ParticleState } from '../core/particleEmitter'
|
||||
import { useParticleStore } from '../store/particleStore'
|
||||
import { useParticleStore, type ScenePath } from '../store/particleStore'
|
||||
|
||||
const store = useParticleStore()
|
||||
const holder = ref<HTMLElement | null>(null)
|
||||
@@ -87,6 +87,8 @@ let boneGfx: Graphics | null = null
|
||||
let shapeGfx: Graphics | null = null
|
||||
let forceFieldGfx: Graphics | null = null
|
||||
let attractionGfx: Graphics | null = null
|
||||
let collisionGfx: Graphics | null = null
|
||||
let pathGfx: Graphics | null = null
|
||||
let gizmoGfx: Graphics | null = null
|
||||
let axisGfx: Graphics | null = null
|
||||
let axisLabelLayer: Container | null = null // 文字图层,不随 world 缩放,避免放大变糊
|
||||
@@ -97,6 +99,9 @@ let dotTex: Texture<any> | null = null
|
||||
let trailTex: Texture<any> | null = null
|
||||
|
||||
const emitterMap = new Map<number, ParticleEmitter>()
|
||||
const selectedPathNodes = new Set<number>()
|
||||
let suppressNextPathDoubleClick = false
|
||||
watch(() => [store.activeObjectType, store.activeObjectId], () => selectedPathNodes.clear())
|
||||
|
||||
async function loadTexture() {
|
||||
if (dotTex && trailTex) return dotTex
|
||||
@@ -124,6 +129,10 @@ function createLayers() {
|
||||
world.addChild(forceFieldGfx)
|
||||
attractionGfx = new Graphics() // 选中系统的全局吸附范围
|
||||
world.addChild(attractionGfx)
|
||||
collisionGfx = new Graphics()
|
||||
world.addChild(collisionGfx)
|
||||
pathGfx = new Graphics()
|
||||
world.addChild(pathGfx)
|
||||
gizmoGfx = new Graphics() // root 变换 gizmo 手柄
|
||||
world.addChild(gizmoGfx)
|
||||
boneGfx = new Graphics()
|
||||
@@ -248,6 +257,8 @@ function onWheel(e: WheelEvent) {
|
||||
// 鼠标左键长按拖动画布(平移)
|
||||
function onPanDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
if (onPathPointerDown(e)) return
|
||||
if (onColliderPointerDown(e)) return
|
||||
// 吸附点优先于力场;重叠时拖动红色吸附圆。
|
||||
if (onAttractionDown(e)) return
|
||||
// 力场使用全局坐标,按住圆内任意位置时优先拖动力场中心。
|
||||
@@ -282,6 +293,168 @@ function pointerToWorldMath(clientX: number, clientY: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function pathLocalToWorld(path: ScenePath, x: number, y: number) {
|
||||
const angle = path.rotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
return { x: path.x + cos * x - sin * y, y: path.y + sin * x + cos * y }
|
||||
}
|
||||
|
||||
function worldToPathLocal(path: ScenePath, x: number, y: number) {
|
||||
const angle = path.rotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const dx = x - path.x, dy = y - path.y
|
||||
return { x: cos * dx + sin * dy, y: -sin * dx + cos * dy }
|
||||
}
|
||||
|
||||
function activePath() {
|
||||
if (store.activeObjectType !== 'path') return null
|
||||
return store.paths.find((item) => item.id === store.activeObjectId) || null
|
||||
}
|
||||
|
||||
function onStageDoubleClick(e: MouseEvent) {
|
||||
const path = activePath()
|
||||
if (!path || !path.enabled) return
|
||||
if (suppressNextPathDoubleClick) { suppressNextPathDoubleClick = false; return }
|
||||
const pointer = pointerToWorldMath(e.clientX, e.clientY)
|
||||
const local = worldToPathLocal(path, pointer.x, pointer.y)
|
||||
const threshold = 9 / Math.max(0.01, store.editor.viewScale)
|
||||
const anchorIndex = path.points.findIndex((point) => Math.hypot(point.x - local.x, point.y - local.y) <= threshold)
|
||||
if (anchorIndex >= 0) {
|
||||
path.points.splice(anchorIndex, 1)
|
||||
selectedPathNodes.clear()
|
||||
return
|
||||
}
|
||||
path.points.push({ x: local.x, y: local.y, inX: 0, inY: 0, outX: 0, outY: 0 })
|
||||
selectedPathNodes.clear()
|
||||
selectedPathNodes.add(path.points.length - 1)
|
||||
}
|
||||
|
||||
function onPathPointerDown(e: PointerEvent) {
|
||||
const path = activePath()
|
||||
if (!path || !path.enabled) return false
|
||||
const pointer = pointerToWorldMath(e.clientX, e.clientY)
|
||||
const local = worldToPathLocal(path, pointer.x, pointer.y)
|
||||
const threshold = 9 / Math.max(0.01, store.editor.viewScale)
|
||||
if (e.detail >= 2) {
|
||||
const existing = path.points.findIndex((point) => Math.hypot(point.x - local.x, point.y - local.y) <= threshold)
|
||||
if (existing >= 0) return true
|
||||
const anchor = { x: local.x, y: local.y, inX: 0, inY: 0, outX: 0, outY: 0 }
|
||||
path.points.push(anchor)
|
||||
selectedPathNodes.clear()
|
||||
selectedPathNodes.add(path.points.length - 1)
|
||||
suppressNextPathDoubleClick = true
|
||||
installPointerDrag((event) => {
|
||||
const worldPoint = pointerToWorldMath(event.clientX, event.clientY)
|
||||
const next = worldToPathLocal(path, worldPoint.x, worldPoint.y)
|
||||
anchor.outX = next.x - anchor.x
|
||||
anchor.outY = next.y - anchor.y
|
||||
anchor.inX = -anchor.outX
|
||||
anchor.inY = -anchor.outY
|
||||
})
|
||||
return true
|
||||
}
|
||||
let handleHit: { index: number; side: 'in' | 'out' } | null = null
|
||||
for (const index of selectedPathNodes) {
|
||||
const point = path.points[index]
|
||||
if (!point) continue
|
||||
if (Math.hypot(point.x + point.inX - local.x, point.y + point.inY - local.y) <= threshold) handleHit = { index, side: 'in' }
|
||||
if (Math.hypot(point.x + point.outX - local.x, point.y + point.outY - local.y) <= threshold) handleHit = { index, side: 'out' }
|
||||
}
|
||||
if (handleHit) {
|
||||
const point = path.points[handleHit.index]
|
||||
const oppositeLength = handleHit.side === 'in' ? Math.hypot(point.outX, point.outY) : Math.hypot(point.inX, point.inY)
|
||||
const move = (event: PointerEvent) => {
|
||||
const worldPoint = pointerToWorldMath(event.clientX, event.clientY)
|
||||
const next = worldToPathLocal(path, worldPoint.x, worldPoint.y)
|
||||
const hx = next.x - point.x, hy = next.y - point.y
|
||||
if (handleHit!.side === 'in') { point.inX = hx; point.inY = hy } else { point.outX = hx; point.outY = hy }
|
||||
if (!event.altKey) {
|
||||
const length = Math.max(0.0001, Math.hypot(hx, hy))
|
||||
const mirrorLength = event.shiftKey ? length : oppositeLength
|
||||
if (handleHit!.side === 'in') { point.outX = -hx / length * mirrorLength; point.outY = -hy / length * mirrorLength }
|
||||
else { point.inX = -hx / length * mirrorLength; point.inY = -hy / length * mirrorLength }
|
||||
}
|
||||
}
|
||||
installPointerDrag(move)
|
||||
return true
|
||||
}
|
||||
const anchorIndex = path.points.findIndex((point) => Math.hypot(point.x - local.x, point.y - local.y) <= threshold)
|
||||
if (anchorIndex >= 0) {
|
||||
if (e.altKey) { selectedPathNodes.delete(anchorIndex); return true }
|
||||
if (e.ctrlKey || e.metaKey) selectedPathNodes.add(anchorIndex)
|
||||
else if (!selectedPathNodes.has(anchorIndex)) { selectedPathNodes.clear(); selectedPathNodes.add(anchorIndex) }
|
||||
const start = local
|
||||
const origins = [...selectedPathNodes].map((index) => ({ index, x: path.points[index].x, y: path.points[index].y }))
|
||||
const move = (event: PointerEvent) => {
|
||||
const worldPoint = pointerToWorldMath(event.clientX, event.clientY)
|
||||
const next = worldToPathLocal(path, worldPoint.x, worldPoint.y)
|
||||
for (const origin of origins) {
|
||||
path.points[origin.index].x = origin.x + next.x - start.x
|
||||
path.points[origin.index].y = origin.y + next.y - start.y
|
||||
}
|
||||
}
|
||||
installPointerDrag(move)
|
||||
return true
|
||||
}
|
||||
if (e.altKey && !e.ctrlKey && !e.metaKey) { selectedPathNodes.clear(); return true }
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) {
|
||||
const start = local
|
||||
const subtract = e.altKey
|
||||
const up = (event: PointerEvent) => {
|
||||
const endWorld = pointerToWorldMath(event.clientX, event.clientY)
|
||||
const end = worldToPathLocal(path, endWorld.x, endWorld.y)
|
||||
const minX = Math.min(start.x, end.x), maxX = Math.max(start.x, end.x)
|
||||
const minY = Math.min(start.y, end.y), maxY = Math.max(start.y, end.y)
|
||||
path.points.forEach((point, index) => {
|
||||
if (point.x >= minX && point.x <= maxX && point.y >= minY && point.y <= maxY) {
|
||||
if (subtract) selectedPathNodes.delete(index); else selectedPathNodes.add(index)
|
||||
}
|
||||
})
|
||||
window.removeEventListener('pointerup', up)
|
||||
window.removeEventListener('pointercancel', up)
|
||||
}
|
||||
window.addEventListener('pointerup', up)
|
||||
window.addEventListener('pointercancel', up)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function installPointerDrag(move: (event: PointerEvent) => void) {
|
||||
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)
|
||||
}
|
||||
|
||||
function onColliderPointerDown(e: PointerEvent) {
|
||||
if (store.activeObjectType !== 'collision') return false
|
||||
const collider = store.colliders.find((item) => item.id === store.activeObjectId)
|
||||
if (!collider?.enabled) return false
|
||||
const pointer = pointerToWorldMath(e.clientX, e.clientY)
|
||||
const angle = collider.rotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const dx = pointer.x - collider.x, dy = pointer.y - collider.y
|
||||
const localX = cos * dx + sin * dy, localY = -sin * dx + cos * dy
|
||||
const hit = collider.shape === 'circle'
|
||||
? Math.hypot(localX, localY) <= collider.radius
|
||||
: collider.shape === 'ellipse'
|
||||
? Math.hypot(localX / Math.max(1, collider.radiusX), localY / Math.max(1, collider.radiusY)) <= 1
|
||||
: Math.abs(localX) <= collider.width * 0.5 && Math.abs(localY) <= collider.height * 0.5
|
||||
if (!hit) return false
|
||||
const startX = collider.x, startY = collider.y, start = pointer
|
||||
installPointerDrag((event) => {
|
||||
const next = pointerToWorldMath(event.clientX, event.clientY)
|
||||
collider.x = startX + next.x - start.x
|
||||
collider.y = startY + next.y - start.y
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
/** 命中当前选中系统的力场圆时开始拖拽;返回 true 表示事件已被力场接管。 */
|
||||
function onForceFieldDown(e: PointerEvent) {
|
||||
const sys = store.systems.find((item) => item.id === store.activeId)
|
||||
@@ -652,6 +825,100 @@ function drawBones(bones: BonePreview[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function colorNumber(value: string, fallback = 0xffffff) {
|
||||
const parsed = Number.parseInt(value.replace('#', ''), 16)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
function drawCollisionBodies() {
|
||||
if (!collisionGfx) return
|
||||
const g = collisionGfx
|
||||
g.clear()
|
||||
for (const collider of store.colliders) {
|
||||
const selected = store.activeObjectType === 'collision' && store.activeObjectId === collider.id
|
||||
const color = collider.enabled ? (selected ? 0xff5e72 : 0xd4475b) : 0x586174
|
||||
const angle = collider.rotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const point = (x: number, y: number) => ({
|
||||
x: collider.x + cos * x - sin * y,
|
||||
y: -(collider.y + sin * x + cos * y),
|
||||
})
|
||||
let points: Array<{ x: number; y: number }> = []
|
||||
if (collider.shape === 'circle' || collider.shape === 'ellipse') {
|
||||
const radiusX = collider.shape === 'circle' ? collider.radius : collider.radiusX
|
||||
const radiusY = collider.shape === 'circle' ? collider.radius : collider.radiusY
|
||||
points = Array.from({ length: 49 }, (_, index) => {
|
||||
const theta = index / 48 * Math.PI * 2
|
||||
return point(Math.cos(theta) * radiusX, Math.sin(theta) * radiusY)
|
||||
})
|
||||
} else if (collider.shape === 'rect') {
|
||||
const hw = collider.width * 0.5, hh = collider.height * 0.5
|
||||
points = [point(-hw, -hh), point(hw, -hh), point(hw, hh), point(-hw, hh), point(-hw, -hh)]
|
||||
} else {
|
||||
const sides = Math.max(3, Math.min(16, Math.round(collider.sides)))
|
||||
points = Array.from({ length: sides + 1 }, (_, index) => {
|
||||
const theta = Math.PI * 0.5 + (index % sides) / sides * Math.PI * 2
|
||||
return point(Math.cos(theta) * collider.width * 0.5, Math.sin(theta) * collider.height * 0.5)
|
||||
})
|
||||
}
|
||||
if (!points.length) continue
|
||||
g.lineStyle(selected ? 2.5 : 1.5, color, collider.enabled ? 0.95 : 0.45)
|
||||
g.beginFill(color, selected ? 0.12 : 0.055)
|
||||
g.moveTo(points[0].x, points[0].y)
|
||||
for (let i = 1; i < points.length; i++) g.lineTo(points[i].x, points[i].y)
|
||||
g.endFill()
|
||||
const center = point(0, 0)
|
||||
g.lineStyle(1, color, 0.8)
|
||||
g.moveTo(center.x - 7, center.y); g.lineTo(center.x + 7, center.y)
|
||||
g.moveTo(center.x, center.y - 7); g.lineTo(center.x, center.y + 7)
|
||||
}
|
||||
}
|
||||
|
||||
function drawPaths() {
|
||||
if (!pathGfx) return
|
||||
const g = pathGfx
|
||||
g.clear()
|
||||
for (const path of store.paths) {
|
||||
if (!path.enabled || !path.points.length) continue
|
||||
const selected = store.activeObjectType === 'path' && store.activeObjectId === path.id
|
||||
const color = colorNumber(path.color, 0xff933e)
|
||||
const screenPoint = (x: number, y: number) => {
|
||||
const worldPoint = pathLocalToWorld(path, x, y)
|
||||
return { x: worldPoint.x, y: -worldPoint.y }
|
||||
}
|
||||
const first = path.points[0]
|
||||
const start = screenPoint(first.x, first.y)
|
||||
g.lineStyle(selected ? 2.5 : 1.8, color, selected ? 1 : 0.75)
|
||||
g.moveTo(start.x, start.y)
|
||||
const segmentCount = path.closed ? path.points.length : path.points.length - 1
|
||||
for (let i = 0; i < segmentCount; i++) {
|
||||
const a = path.points[i]
|
||||
const b = path.points[(i + 1) % path.points.length]
|
||||
const controlA = screenPoint(a.x + a.outX, a.y + a.outY)
|
||||
const controlB = screenPoint(b.x + b.inX, b.y + b.inY)
|
||||
const end = screenPoint(b.x, b.y)
|
||||
g.bezierCurveTo(controlA.x, controlA.y, controlB.x, controlB.y, end.x, end.y)
|
||||
}
|
||||
if (!selected) continue
|
||||
path.points.forEach((anchor, index) => {
|
||||
const point = screenPoint(anchor.x, anchor.y)
|
||||
const input = screenPoint(anchor.x + anchor.inX, anchor.y + anchor.inY)
|
||||
const output = screenPoint(anchor.x + anchor.outX, anchor.y + anchor.outY)
|
||||
const hasInput = Math.hypot(anchor.inX, anchor.inY) > 0.01
|
||||
const hasOutput = Math.hypot(anchor.outX, anchor.outY) > 0.01
|
||||
g.lineStyle(1, 0xa9b4cb, 0.65)
|
||||
if (hasInput) { g.moveTo(point.x, point.y); g.lineTo(input.x, input.y) }
|
||||
if (hasOutput) { g.moveTo(point.x, point.y); g.lineTo(output.x, output.y) }
|
||||
if (hasInput) { g.beginFill(0xd5dcec, 0.95); g.drawCircle(input.x, input.y, 3); g.endFill() }
|
||||
if (hasOutput) { g.beginFill(0xd5dcec, 0.95); g.drawCircle(output.x, output.y, 3); g.endFill() }
|
||||
g.beginFill(selectedPathNodes.has(index) ? 0xffffff : color, 1)
|
||||
g.lineStyle(1.5, selectedPathNodes.has(index) ? 0x5d7cff : 0xffffff, 0.95)
|
||||
g.drawCircle(point.x, point.y, selectedPathNodes.has(index) ? 5 : 4)
|
||||
g.endFill()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function loop() {
|
||||
raf = requestAnimationFrame(loop)
|
||||
const now = performance.now() / 1000
|
||||
@@ -673,6 +940,7 @@ function loop() {
|
||||
em.position.set(c.centerX, -c.centerY) // y 数学上正 → 屏幕取负
|
||||
em.rotation = -c.rootRotation * Math.PI / 180 // 数学上正(顺时针为负)
|
||||
em.scale.set(c.rootScaleX, c.rootScaleY)
|
||||
em.setColliders(store.colliders)
|
||||
}
|
||||
let total = 0
|
||||
const collected: BonePreview[] = []
|
||||
@@ -722,6 +990,8 @@ function loop() {
|
||||
}
|
||||
}
|
||||
for (const [, emitter] of emitterMap) emitter.drawTrailMeshDebug(showSkinMesh.value)
|
||||
drawCollisionBodies()
|
||||
drawPaths()
|
||||
drawOrigin()
|
||||
drawShapeRange()
|
||||
drawForceField()
|
||||
@@ -769,7 +1039,7 @@ onMounted(async () => {
|
||||
() =>
|
||||
store.systems
|
||||
.map((s) => JSON.stringify(s.config, (k, v) => (k === 'texture' || k === 'trailTexture' || k === 'previewUrl' ? undefined : v)))
|
||||
.join('|'),
|
||||
.join('|') + `|colliders:${JSON.stringify(store.colliders)}`,
|
||||
() => {
|
||||
const frames = store.recalcTotalFrames()
|
||||
store.timeline.recorded = false
|
||||
@@ -804,9 +1074,7 @@ onUnmounted(() => {
|
||||
padding: 8px 12px; background: rgba(20,20,32,0.85); border-radius: 10px; border: 1px solid rgba(255,255,255,0.08);
|
||||
font-size: 12px; color: #aab; z-index: 5; flex-wrap: wrap;
|
||||
}
|
||||
.brand { font-weight: 700; color: #cdf; }
|
||||
.topbar input[type=range] { background: #1a1a28; border: 1px solid #2e2e44; color: #dde; border-radius: 5px; }
|
||||
.chk { display: flex; align-items: center; gap: 3px; }
|
||||
/* 工具按钮组 */
|
||||
.tool-group { display: flex; gap: 6px; margin-left: 6px; padding-left: 12px; border-left: 1px solid #2a2a3c; }
|
||||
.tool-btn {
|
||||
@@ -820,13 +1088,19 @@ onUnmounted(() => {
|
||||
.hud { position: absolute; bottom: 12px; left: 12px; padding: 8px 12px; background: rgba(20,20,32,0.75); border-radius: 8px; font-size: 12px; color: #a0a8cc; }
|
||||
.hud-tip { margin-left: 12px; color: #5f6a8a; font-size: 11px; }
|
||||
b { color: #ffcc33; }
|
||||
.settings-btn { margin-left: 6px; }
|
||||
.settings-btn {
|
||||
height: 28px; padding: 0 12px; flex: 0 0 auto; border: 1px solid #3a4963; border-radius: 6px;
|
||||
background: #202a3b; color: #cdf; font-size: 12px; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.settings-btn:hover { border-color: #7774ff; background: #2b3a5c; color: #fff; }
|
||||
.settings-panel {
|
||||
position: absolute; top: 52px; right: 12px; width: 240px; z-index: 10;
|
||||
position: absolute; top: 52px; left: 10px; width: 240px; z-index: 10;
|
||||
background: rgba(20,20,32,0.97); border: 1px solid #2a2a3c; border-radius: 10px; padding: 10px 12px;
|
||||
font-size: 12px; color: #aab; box-shadow: 0 6px 20px rgba(0,0,0,0.4);
|
||||
}
|
||||
.sp-head { display: flex; justify-content: space-between; align-items: center; font-weight: 700; color: #cdf; margin-bottom: 10px; }
|
||||
.sp-section-title { margin: 2px 0 8px; color: #8a93bb; font-size: 11px; }
|
||||
.sp-divider { height: 1px; margin: 10px 0; background: #2a2a3c; }
|
||||
.sp-close { background: #2e3a55; border: none; color: #cdf; border-radius: 4px; width: 18px; height: 18px; cursor: pointer; font-size: 11px; }
|
||||
.sp-row { display: flex; align-items: center; gap: 8px; margin: 8px 0; }
|
||||
.sp-row > span:first-child { width: 76px; flex-shrink: 0; color: #8a93bb; }
|
||||
|
||||
Reference in new issue
Block a user