阶段8落盘
This commit is contained in:
1 parent
b5ac9552a1
commit
a2de23a789
10 files changed
+419
-20
No files matched your search
@@ -13,6 +13,7 @@
|
||||
<button class="scene-add" title="添加碰撞体" @click.stop="addCollider">+碰撞</button>
|
||||
<button class="scene-add" title="添加路径" @click.stop="addPath">+路径</button>
|
||||
<button class="scene-add" title="添加 Spine" @click.stop="addSpine">+Spine</button>
|
||||
<button class="scene-add" :disabled="!canCopyObject" title="复制当前选中的场景对象" @click.stop="copySelectedObject">+Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="group-body" v-show="!collapsed.scene">
|
||||
@@ -797,6 +798,12 @@ const activePath = computed(() => store.activeObjectType === 'path'
|
||||
const activeSpine = computed(() => store.activeObjectType === 'spine'
|
||||
? store.spines.find((item) => item.id === store.activeObjectId) || null
|
||||
: null)
|
||||
const canCopyObject = computed(() => {
|
||||
if (store.activeObjectType === 'particle') return store.systems.some((item) => item.id === store.activeObjectId)
|
||||
if (store.activeObjectType === 'collision') return store.colliders.some((item) => item.id === store.activeObjectId)
|
||||
if (store.activeObjectType === 'path') return store.paths.some((item) => item.id === store.activeObjectId)
|
||||
return store.spines.some((item) => item.id === store.activeObjectId)
|
||||
})
|
||||
const selectedSpineBone = computed(() => {
|
||||
const spine = store.spines.find((item) => item.selectedBone && item.bones.some((bone) => bone.name === item.selectedBone))
|
||||
return spine ? { spine, boneName: spine.selectedBone } : null
|
||||
@@ -972,6 +979,7 @@ function addSys() { store.addSystem() }
|
||||
function addCollider() { store.addCollider() }
|
||||
function addPath() { store.addPath() }
|
||||
function addSpine() { store.addSpine() }
|
||||
function copySelectedObject() { store.duplicateSelectedObject() }
|
||||
function addColliderTag() {
|
||||
const collider = activeCollider.value
|
||||
if (!collider) return
|
||||
@@ -1445,6 +1453,7 @@ function toggleResourceLock(resourceId: number) {
|
||||
background: #2e3a55; color: #cdf; font-size: 10px; white-space: nowrap; cursor: pointer;
|
||||
}
|
||||
.scene-add:hover { border-color: #7774ff; background: #45426f; color: #fff; }
|
||||
.scene-add:disabled { opacity: 0.42; cursor: not-allowed; border-color: #343a4b; background: #252a38; color: #778095; }
|
||||
.group-body { padding: 8px 10px 12px; }
|
||||
.caret { display: inline-block; width: 12px; color: #667; font-size: 11px; }
|
||||
.divider { height: 1px; background: #2a2a3c; margin: 8px 0; }
|
||||
|
||||
+143
-5
@@ -4,7 +4,7 @@
|
||||
|
||||
<!-- 顶栏 -->
|
||||
<div class="topbar">
|
||||
<button class="settings-btn" @click="showSettings = !showSettings" title="系统设置">系统设置</button>
|
||||
<button class="settings-btn" @click="toggleSettings" title="系统设置">系统设置</button>
|
||||
|
||||
<!-- 根变换工具:拖动(默认) / 位移 / 旋转 / 缩放 -->
|
||||
<div class="tool-group">
|
||||
@@ -23,6 +23,38 @@
|
||||
<button class="tool-btn" @click="resetView" title="重置视图">⟳</button>
|
||||
</div>
|
||||
|
||||
<div class="background-menu" @click.stop>
|
||||
<button
|
||||
class="background-btn"
|
||||
:class="{ on: showBackgroundMenu }"
|
||||
title="背景图显示设置"
|
||||
@click="toggleBackgroundMenu"
|
||||
>背景图</button>
|
||||
<div v-if="showBackgroundMenu" class="background-panel">
|
||||
<button class="background-upload" @click="backgroundFileInput?.click()">
|
||||
{{ backgroundFileName ? '更换图像' : '上传图像' }}
|
||||
</button>
|
||||
<button class="background-clear" :disabled="!backgroundFileName" @click="clearBackground()">清除背景</button>
|
||||
<input
|
||||
ref="backgroundFileInput"
|
||||
class="background-file-input"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
@change="onBackgroundFileChange"
|
||||
/>
|
||||
<div class="background-file-name" :title="backgroundFileName">{{ backgroundFileName || '未上传图像' }}</div>
|
||||
<div class="background-position-row">
|
||||
<label><span>X</span><input v-model.number="backgroundX" type="number" step="1" aria-label="背景图 X 偏移" /></label>
|
||||
<label><span>Y</span><input v-model.number="backgroundY" type="number" step="1" aria-label="背景图 Y 偏移" /></label>
|
||||
</div>
|
||||
<label class="background-opacity-row">
|
||||
<span>透明度</span>
|
||||
<input v-model.number="backgroundOpacity" type="range" min="0" max="1" step="0.01" aria-label="背景图透明度" />
|
||||
<b>{{ Math.round(backgroundOpacity * 100) }}%</b>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="zoom-pct">{{ Math.round(store.editor.viewScale * 100) }}%</span>
|
||||
|
||||
</div>
|
||||
@@ -62,7 +94,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Application, Container, Graphics, Text, Texture } from 'pixi.js'
|
||||
import { Application, Container, Graphics, Sprite, Text, Texture } from 'pixi.js'
|
||||
import { ParticleEmitter, ensureEmitterConfig, type ParticleState } from '../core/particleEmitter'
|
||||
import { useParticleStore, type CollisionBody, type ScenePath } from '../store/particleStore'
|
||||
import { SpineRuntimeLayer } from '../spine/SpineRuntimeLayer'
|
||||
@@ -75,12 +107,21 @@ const showSkinMesh = ref(false)
|
||||
const info = ref<{ systems: number; particles: number } | null>(null)
|
||||
const showGrid = ref(true)
|
||||
const showSettings = ref(false)
|
||||
const showBackgroundMenu = ref(false)
|
||||
const backgroundFileInput = ref<HTMLInputElement | null>(null)
|
||||
const backgroundFileName = ref('')
|
||||
const backgroundX = ref(0)
|
||||
const backgroundY = ref(0)
|
||||
const backgroundOpacity = ref(1)
|
||||
// 根变换 gizmo 工具:'translate' | 'rotate' | 'scale' | ''(无)
|
||||
const activeTool = ref<'' | 'translate' | 'rotate' | 'scale'>('')
|
||||
function setTool(t: '' | 'translate' | 'rotate' | 'scale') { activeTool.value = activeTool.value === t ? '' : t }
|
||||
|
||||
let app: Application | null = null
|
||||
let world: Container | null = null
|
||||
let backgroundSprite: Sprite | null = null
|
||||
let backgroundTexture: Texture | null = null
|
||||
let backgroundObjectUrl = ''
|
||||
let originGfx: Graphics | null = null
|
||||
let boneFollowTargetGfx: Graphics | null = null
|
||||
let boneGfx: Graphics | null = null
|
||||
@@ -124,6 +165,10 @@ function createLayers() {
|
||||
app!.stage.addChild(world)
|
||||
axisLabelLayer = new Container() // 独立顶层,不随 world 缩放
|
||||
app!.stage.addChild(axisLabelLayer)
|
||||
backgroundSprite = new Sprite(Texture.EMPTY)
|
||||
backgroundSprite.anchor.set(0.5)
|
||||
backgroundSprite.visible = false
|
||||
world.addChild(backgroundSprite)
|
||||
axisGfx = new Graphics()
|
||||
world.addChild(axisGfx)
|
||||
spineRuntime = new SpineRuntimeLayer()
|
||||
@@ -148,6 +193,58 @@ function createLayers() {
|
||||
world.addChild(boneGfx)
|
||||
}
|
||||
|
||||
function toggleBackgroundMenu() {
|
||||
showBackgroundMenu.value = !showBackgroundMenu.value
|
||||
if (showBackgroundMenu.value) showSettings.value = false
|
||||
}
|
||||
|
||||
function toggleSettings() {
|
||||
showSettings.value = !showSettings.value
|
||||
if (showSettings.value) showBackgroundMenu.value = false
|
||||
}
|
||||
|
||||
async function onBackgroundFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file || !backgroundSprite) return
|
||||
const nextUrl = URL.createObjectURL(file)
|
||||
try {
|
||||
const nextTexture = await Texture.fromURL(nextUrl)
|
||||
const previousTexture = backgroundTexture
|
||||
const previousUrl = backgroundObjectUrl
|
||||
backgroundTexture = nextTexture
|
||||
backgroundObjectUrl = nextUrl
|
||||
backgroundSprite.texture = nextTexture
|
||||
backgroundSprite.visible = true
|
||||
backgroundFileName.value = file.name
|
||||
if (previousTexture) previousTexture.destroy(true)
|
||||
if (previousUrl) URL.revokeObjectURL(previousUrl)
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(nextUrl)
|
||||
console.error('背景图加载失败', error)
|
||||
} finally {
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function clearBackground(resetControls = true) {
|
||||
if (backgroundSprite) {
|
||||
backgroundSprite.texture = Texture.EMPTY
|
||||
backgroundSprite.visible = false
|
||||
}
|
||||
if (backgroundTexture) backgroundTexture.destroy(true)
|
||||
if (backgroundObjectUrl) URL.revokeObjectURL(backgroundObjectUrl)
|
||||
backgroundTexture = null
|
||||
backgroundObjectUrl = ''
|
||||
backgroundFileName.value = ''
|
||||
if (backgroundFileInput.value) backgroundFileInput.value.value = ''
|
||||
if (resetControls) {
|
||||
backgroundX.value = 0
|
||||
backgroundY.value = 0
|
||||
backgroundOpacity.value = 1
|
||||
}
|
||||
}
|
||||
|
||||
// 自适应刻度步进(仿时间轴):返回 1/2/5 序列里的最合适步长,保证屏幕间距合理
|
||||
function niceStep(target: number, minStep = 0): number {
|
||||
if (target <= 0) return 1
|
||||
@@ -1139,6 +1236,10 @@ function loop() {
|
||||
if (app && world) {
|
||||
world.scale.set(store.editor.viewScale)
|
||||
world.position.set(app.screen.width / 2 + store.editor.viewX, app.screen.height * 0.5 + store.editor.viewY)
|
||||
if (backgroundSprite) {
|
||||
backgroundSprite.position.set(Number(backgroundX.value) || 0, -(Number(backgroundY.value) || 0))
|
||||
backgroundSprite.alpha = Math.min(1, Math.max(0, Number(backgroundOpacity.value) || 0))
|
||||
}
|
||||
|
||||
drawAxis()
|
||||
syncEmitters()
|
||||
@@ -1291,9 +1392,11 @@ function loop() {
|
||||
if (!tl.playing) displayFrame = tl.frame
|
||||
for (const sys of store.systems) {
|
||||
const emitter = emitterMap.get(sys.id)
|
||||
const frame = sys.frames?.[tl.frame]
|
||||
if (!tl.playing && tl.recorded && emitter && frame) {
|
||||
// 暂停、逐帧或手动拖动播放头时立即显示指定的录制帧。
|
||||
// 播放中使用最近一次真正推进的显示帧;暂停/逐帧时读取播放头指定帧。
|
||||
// 回放缓存存在时不再调用 update(0),避免用首次模拟结束后的旧活动状态覆盖数量。
|
||||
const replayFrameIndex = tl.playing ? timelineDisplayFrame : tl.frame
|
||||
const frame = sys.frames?.[replayFrameIndex]
|
||||
if (tl.recorded && emitter && frame) {
|
||||
emitter.apply(frame)
|
||||
for (const state of frame) if (state.active) {
|
||||
if (sys.visible !== false) appendStateBones(collected, state)
|
||||
@@ -1377,6 +1480,8 @@ onUnmounted(() => {
|
||||
cancelAnimationFrame(raf)
|
||||
spineRuntime?.destroy()
|
||||
spineRuntime = null
|
||||
clearBackground(false)
|
||||
backgroundSprite = null
|
||||
if (app) { app.destroy(true, { children: true }); app = null }
|
||||
})
|
||||
</script>
|
||||
@@ -1407,6 +1512,39 @@ onUnmounted(() => {
|
||||
}
|
||||
.tool-btn:hover { background: #2b3a5c; color: #fff; }
|
||||
.tool-btn.on { background: #3a5a8c; color: #fff; border-color: #5a7ab8; }
|
||||
.background-menu { position: relative; flex: 0 0 auto; }
|
||||
.background-btn {
|
||||
height: 28px; padding: 0 11px; border: 1px solid #3a4963; border-radius: 6px;
|
||||
background: #202a3b; color: #cbd7ed; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.background-btn:hover, .background-btn.on { border-color: #7774ff; background: #343b63; color: #fff; }
|
||||
.background-panel {
|
||||
position: absolute; top: calc(100% + 9px); right: 0; width: 248px; box-sizing: border-box; padding: 10px;
|
||||
border: 1px solid #343b50; border-radius: 9px; background: rgba(20,20,32,.98); color: #aeb8cc;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.48); z-index: 12;
|
||||
}
|
||||
.background-upload {
|
||||
width: 100%; height: 30px; border: 1px solid #5652b5; border-radius: 6px;
|
||||
background: #4540a0; color: #fff; cursor: pointer;
|
||||
}
|
||||
.background-upload:hover { border-color: #8982ff; background: #554dc2; }
|
||||
.background-clear {
|
||||
width: 100%; height: 28px; margin-top: 6px; border: 1px solid #70404a; border-radius: 6px;
|
||||
background: #512a33; color: #efb5bd; cursor: pointer;
|
||||
}
|
||||
.background-clear:hover:not(:disabled) { border-color: #b65b6a; background: #6a303b; color: #fff; }
|
||||
.background-clear:disabled { opacity: .42; cursor: not-allowed; }
|
||||
.background-file-input { display: none; }
|
||||
.background-file-name { margin: 7px 2px 9px; overflow: hidden; color: #737f98; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.background-position-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.background-position-row label { display: flex; align-items: center; gap: 5px; color: #8995ad; font-size: 11px; }
|
||||
.background-position-row input {
|
||||
width: 0; min-width: 0; flex: 1; box-sizing: border-box; height: 28px; padding: 0 6px;
|
||||
border: 1px solid #343e55; border-radius: 5px; background: #101522; color: #e1e6f2;
|
||||
}
|
||||
.background-opacity-row { display: grid; grid-template-columns: 46px 1fr 34px; align-items: center; gap: 7px; margin-top: 10px; font-size: 11px; }
|
||||
.background-opacity-row input { min-width: 0; accent-color: #675de0; }
|
||||
.background-opacity-row b { color: #aeb8cc; font-size: 10px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.zoom-pct { margin-left: auto; font-variant-numeric: tabular-nums; color: #9aa; min-width: 44px; text-align: center; }
|
||||
.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-number { display: inline-block; box-sizing: border-box; text-align: right; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum' 1; }
|
||||
|
||||
@@ -414,7 +414,7 @@ function onSpineBarDown(e: PointerEvent, spine: SpineSceneObject) {
|
||||
.tl-animation-trigger > i { flex: 0 0 auto; color: #8c96ad; font-style: normal; }
|
||||
.tl-animation-trigger:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.tl-animation-menu {
|
||||
position: absolute; z-index: 30; top: calc(100% + 6px); right: 0; width: 226px; padding: 7px;
|
||||
position: absolute; z-index: 30; right: 0; bottom: calc(100% + 6px); width: 226px; padding: 7px;
|
||||
border: 1px solid #3c4760; border-radius: 8px; background: #171b29; box-shadow: 0 10px 24px rgba(0,0,0,.45);
|
||||
}
|
||||
.tl-animation-menu-title { padding: 2px 4px 6px; color: #77839c; font-size: 10px; }
|
||||
|
||||
Reference in new issue
Block a user