阶段8落盘

This commit is contained in:
tianmo committed 2026-08-29 21:36:04 +08:00
1 parent b5ac9552a1
commit a2de23a789
10 files changed
+419 -20

No files matched your search

+9
View File
@@ -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
View File
@@ -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; }
+1 -1
View File
@@ -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; }