代码拆分
This commit is contained in:
1 parent
8bf16fe09f
commit
ee72d09b23
20 files changed
+2760
-2450
No files matched your search
+88
-808
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { useParticleStore } from '../store/particleStore'
|
||||
import { setUndoLimit } from '../editor/editorHistory'
|
||||
|
||||
defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
const store = useParticleStore()
|
||||
|
||||
function onUndoLimitChange(event: Event) {
|
||||
setUndoLimit(store, Number((event.target as HTMLInputElement).value))
|
||||
}
|
||||
|
||||
function normalizeBoneDebugSizes() {
|
||||
store.settings.bonePointDrawSize = Math.min(10, Math.max(0.1, Number(store.settings.bonePointDrawSize) || 1))
|
||||
store.settings.boneAxisDrawSize = Math.min(10, Math.max(0.1, Number(store.settings.boneAxisDrawSize) || 2))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="show" class="settings-panel">
|
||||
<div class="sp-head">系统设置 <button class="sp-close" @click="emit('close')">✕</button></div>
|
||||
<div class="sp-section-title">界面布局</div>
|
||||
<label class="sp-switch-row">
|
||||
<span>参数面板位置</span>
|
||||
<span class="sp-switch-control">
|
||||
<input type="checkbox" v-model="store.settings.parameterPanelOnRight" aria-label="参数面板显示在右侧" />
|
||||
<span class="sp-switch-ui" aria-hidden="true"></span>
|
||||
<b>{{ store.settings.parameterPanelOnRight ? '右侧' : '左侧' }}</b>
|
||||
</span>
|
||||
</label>
|
||||
<div class="sp-divider"></div>
|
||||
<div class="sp-section-title">操作历史</div>
|
||||
<label class="sp-row"><span>最大撤回步数</span>
|
||||
<input
|
||||
class="sp-num sp-undo-limit"
|
||||
type="number"
|
||||
min="1"
|
||||
max="200"
|
||||
v-model.number="store.settings.undoLimit"
|
||||
aria-label="最大撤回步数"
|
||||
@change="onUndoLimitChange"
|
||||
/>
|
||||
</label>
|
||||
<div class="sp-divider"></div>
|
||||
<div class="sp-section-title">画布辅助显示</div>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="store.settings.showEmitter" />发射点</label>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="store.settings.showBones" />骨骼连线</label>
|
||||
<template v-if="store.settings.showBones">
|
||||
<label class="sp-row"><span>骨骼点绘制大小</span>
|
||||
<input class="sp-num" type="number" min="0.1" max="10" step="0.1" v-model.number="store.settings.bonePointDrawSize" aria-label="骨骼点绘制大小" @change="normalizeBoneDebugSizes" />
|
||||
</label>
|
||||
<label class="sp-row"><span>坐标轴绘制大小</span>
|
||||
<input class="sp-num" type="number" min="0.1" max="10" step="0.1" v-model.number="store.settings.boneAxisDrawSize" aria-label="坐标轴绘制大小" @change="normalizeBoneDebugSizes" />
|
||||
</label>
|
||||
</template>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="store.settings.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>
|
||||
<input type="range" min="8" max="40" step="1" v-model.number="store.settings.axisFontSize" />
|
||||
<input type="number" min="8" max="40" v-model.number="store.settings.axisFontSize" class="sp-num" />
|
||||
</label>
|
||||
<label class="sp-row"><span>刻度线粗细</span>
|
||||
<input type="range" min="1" max="5" step="0.5" v-model.number="store.settings.tickWidth" />
|
||||
<input type="number" min="1" max="5" v-model.number="store.settings.tickWidth" class="sp-num" />
|
||||
</label>
|
||||
<label class="sp-row"><span>坐标轴颜色</span>
|
||||
<input type="color" v-model="store.settings.axisColor" class="sp-colr" />
|
||||
<span class="sp-hex">{{ store.settings.axisColor }}</span>
|
||||
</label>
|
||||
<label class="sp-chk"><input type="checkbox" v-model="store.settings.showAxisLabels" />显示刻度数值</label>
|
||||
</template>
|
||||
<div class="sp-cache-note">整个系统设置会自动保存到当前浏览器</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-panel {
|
||||
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-switch-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: #aab; }
|
||||
.sp-switch-control { display: inline-flex; align-items: center; gap: 7px; }
|
||||
.sp-switch-control input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.sp-switch-ui { position: relative; width: 34px; height: 18px; border-radius: 999px; background: #39445a; cursor: pointer; transition: background .16s ease; }
|
||||
.sp-switch-ui::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: #f4f6fb; transition: transform .16s ease; }
|
||||
.sp-switch-control input:checked + .sp-switch-ui { background: #655df0; }
|
||||
.sp-switch-control input:checked + .sp-switch-ui::after { transform: translateX(16px); }
|
||||
.sp-switch-control input:focus-visible + .sp-switch-ui { outline: 2px solid #8b86ff; outline-offset: 2px; }
|
||||
.sp-switch-control b { min-width: 24px; color: #8a93bb; font-size: 11px; font-weight: 500; }
|
||||
.sp-cache-note { margin-top: 10px; padding-top: 9px; border-top: 1px solid #2a2a3c; color: #647089; font-size: 10px; text-align: center; }
|
||||
.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; }
|
||||
.sp-row input[type=range] { flex: 1; background: #1a1a28; accent-color: #3a5a8c; }
|
||||
.sp-num { width: 52px; background: #1a1a28; border: 1px solid #2e2e44; border-radius: 5px; color: #dde; font-size: 12px; padding: 2px 4px; }
|
||||
.sp-undo-limit { margin-left: auto; width: 64px; height: 26px; box-sizing: border-box; }
|
||||
.sp-colr { width: 26px; height: 22px; border: none; background: none; cursor: pointer; }
|
||||
.sp-hex { font-size: 11px; color: #889; font-variant-numeric: tabular-nums; }
|
||||
.sp-chk { display: flex; align-items: center; gap: 6px; margin: 8px 0; color: #aab; }
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useParticleStore } from '../store/particleStore'
|
||||
import { zoomIn, zoomOut, fitView, resetView, toggleGrid, type StageContext } from './stage/viewControls'
|
||||
import { undoLastOperation, editorHistoryState } from '../editor/editorHistory'
|
||||
|
||||
const props = defineProps<{
|
||||
ctx: StageContext
|
||||
activeTool: '' | 'translate' | 'rotate' | 'scale'
|
||||
showSettings: boolean
|
||||
showHelp: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:activeTool', v: '' | 'translate' | 'rotate' | 'scale'): void
|
||||
(e: 'update:showSettings', v: boolean): void
|
||||
(e: 'update:showHelp', v: boolean): void
|
||||
(e: 'clear-background', resetControls: boolean): void
|
||||
(e: 'background-file', event: Event): void
|
||||
}>()
|
||||
|
||||
const store = useParticleStore()
|
||||
const showBackgroundMenu = ref(false)
|
||||
const backgroundFileInput = ref<HTMLInputElement | null>(null)
|
||||
const backgroundFileName = computed(() => store.settings.backgroundImageName)
|
||||
|
||||
function toggleTool(t: '' | 'translate' | 'rotate' | 'scale') {
|
||||
emit('update:activeTool', props.activeTool === t ? '' : t)
|
||||
}
|
||||
function toggleBackgroundMenu() {
|
||||
showBackgroundMenu.value = !showBackgroundMenu.value
|
||||
if (showBackgroundMenu.value) emit('update:showSettings', false)
|
||||
}
|
||||
function toggleSettings() {
|
||||
const next = !props.showSettings
|
||||
emit('update:showSettings', next)
|
||||
// 打开系统设置时关闭背景图面板,保持两个弹出层互斥(与原 Stage.vue 行为一致)。
|
||||
if (next) showBackgroundMenu.value = false
|
||||
}
|
||||
function openHelp() {
|
||||
emit('update:showHelp', true)
|
||||
showBackgroundMenu.value = false
|
||||
emit('update:showSettings', false)
|
||||
}
|
||||
function onBackgroundFileChange(event: Event) {
|
||||
// 上传处理委托给父组件(需要访问 BackgroundLayer 同步纹理)
|
||||
emit('background-file', event)
|
||||
}
|
||||
|
||||
function clearBackground(resetControls = true) {
|
||||
// 文件输入框在本组件内,清空其值以便用户能再次选择同一张图片触发 change。
|
||||
if (backgroundFileInput.value) backgroundFileInput.value.value = ''
|
||||
emit('clear-background', resetControls)
|
||||
}
|
||||
|
||||
function undo() {
|
||||
undoLastOperation()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="topbar">
|
||||
<button class="settings-btn" @click="toggleSettings" title="系统设置">系统设置</button>
|
||||
<button class="undo-btn" :disabled="editorHistoryState.undoCount === 0" @click="undo" title="撤回上一步 (Ctrl+Z)">↶</button>
|
||||
|
||||
<!-- 根变换工具:拖动(默认) / 位移 / 旋转 / 缩放 -->
|
||||
<div class="tool-group">
|
||||
<button class="tool-btn" :class="{ on: activeTool === '' }" @click="toggleTool('')" title="拖动画布 (Q)">✋</button>
|
||||
<button class="tool-btn" :class="{ on: activeTool === 'translate' }" @click="toggleTool('translate')" title="位移 (W)">✥</button>
|
||||
<button class="tool-btn" :class="{ on: activeTool === 'rotate' }" @click="toggleTool('rotate')" title="旋转 (E)">⟳</button>
|
||||
<button class="tool-btn" :class="{ on: activeTool === 'scale' }" @click="toggleTool('scale')" title="缩放 (R)">⤢</button>
|
||||
</div>
|
||||
|
||||
<!-- 工具按钮组(参考画布工具栏) -->
|
||||
<div class="tool-group">
|
||||
<button class="tool-btn" @click="zoomIn(ctx)" title="放大">+</button>
|
||||
<button class="tool-btn" @click="zoomOut(ctx)" title="缩小">-</button>
|
||||
<button class="tool-btn" @click="fitView(ctx)" title="适配画布">⤢</button>
|
||||
<button class="tool-btn" :class="{ on: store.settings.showGrid }" @click="toggleGrid(ctx)" title="切换网格">▦</button>
|
||||
<button class="tool-btn" @click="resetView(ctx)" 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="store.settings.backgroundX" type="number" step="1" aria-label="背景图 X 偏移" /></label>
|
||||
<label><span>Y</span><input v-model.number="store.settings.backgroundY" type="number" step="1" aria-label="背景图 Y 偏移" /></label>
|
||||
</div>
|
||||
<label class="background-opacity-row">
|
||||
<span>透明度</span>
|
||||
<input v-model.number="store.settings.backgroundOpacity" type="range" min="0" max="1" step="0.01" aria-label="背景图透明度" />
|
||||
<b>{{ Math.round(store.settings.backgroundOpacity * 100) }}%</b>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="help-btn" :class="{ on: showHelp }" title="查看使用说明" @click="openHelp">使用说明</button>
|
||||
<span class="zoom-pct">{{ Math.round(store.editor.viewScale * 100) }}%</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topbar {
|
||||
position: absolute; top: 10px; left: 10px; right: 10px; display: flex; align-items: center; gap: 10px;
|
||||
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;
|
||||
}
|
||||
.topbar input[type=range] { background: #1a1a28; border: 1px solid #2e2e44; color: #dde; border-radius: 5px; }
|
||||
/* 工具按钮组 */
|
||||
.tool-group { display: flex; gap: 6px; margin-left: 6px; padding-left: 12px; border-left: 1px solid #2a2a3c; }
|
||||
.tool-btn {
|
||||
width: 30px; height: 26px; background: #1e1e2e; border: 1px solid #2e2e44; border-radius: 6px;
|
||||
color: #aab; font-size: 14px; cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.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; }
|
||||
.help-btn {
|
||||
height: 28px; margin-left: auto; padding: 0 11px; flex: 0 0 auto; border: 1px solid #3a4963; border-radius: 6px;
|
||||
background: #202a3b; color: #cbd7ed; font-size: 12px; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.help-btn:hover, .help-btn.on { border-color: #7774ff; background: #343b63; color: #fff; }
|
||||
.zoom-pct { margin-left: 0; font-variant-numeric: tabular-nums; color: #9aa; min-width: 44px; text-align: center; }
|
||||
.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; }
|
||||
.undo-btn {
|
||||
width: 30px; height: 28px; flex: 0 0 30px; border: 1px solid #3a4963; border-radius: 6px;
|
||||
background: #202a3b; color: #cdd8ed; font-size: 18px; line-height: 1; cursor: pointer;
|
||||
}
|
||||
.undo-btn:hover:not(:disabled) { border-color: #7774ff; background: #2b3a5c; color: #fff; }
|
||||
.undo-btn:disabled { opacity: .38; cursor: not-allowed; }
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Sprite, Texture } from 'pixi.js'
|
||||
import type { Container } from 'pixi.js'
|
||||
import { fileToDataUrl } from '../../spine/spineSourceBundle'
|
||||
|
||||
/**
|
||||
* 背景图层:精灵创建、纹理异步加载(带版本号防竞态)、清除与上传处理。
|
||||
* 纯 store 绑定 + 精灵操作,无模拟逻辑。
|
||||
*/
|
||||
export class BackgroundLayer {
|
||||
private sprite: Sprite | null = null
|
||||
private texture: Texture | null = null
|
||||
private loadVersion = 0
|
||||
|
||||
/** 在 world 容器内创建背景精灵(最底层)。 */
|
||||
createSprite(world: Container) {
|
||||
const sprite = new Sprite(Texture.EMPTY)
|
||||
sprite.anchor.set(0.5)
|
||||
sprite.visible = false
|
||||
world.addChild(sprite)
|
||||
this.sprite = sprite
|
||||
return sprite
|
||||
}
|
||||
|
||||
/** 每帧应用位置与透明度设置。 */
|
||||
applyTransform(backgroundX: number, backgroundY: number, backgroundOpacity: number) {
|
||||
if (!this.sprite) return
|
||||
this.sprite.position.set(Number(backgroundX) || 0, -(Number(backgroundY) || 0))
|
||||
this.sprite.alpha = Math.min(1, Math.max(0, Number(backgroundOpacity) || 0))
|
||||
}
|
||||
|
||||
/** 异步把 dataUrl 加载为纹理;旧请求返回时作废。 */
|
||||
async syncTexture(dataUrl: string) {
|
||||
const version = ++this.loadVersion
|
||||
if (!dataUrl) {
|
||||
if (this.sprite) {
|
||||
this.sprite.texture = Texture.EMPTY
|
||||
this.sprite.visible = false
|
||||
}
|
||||
if (this.texture) this.texture.destroy(true)
|
||||
this.texture = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const texture = await Texture.fromURL(dataUrl)
|
||||
if (version !== this.loadVersion || !this.sprite) {
|
||||
texture.destroy(true)
|
||||
return
|
||||
}
|
||||
const previous = this.texture
|
||||
this.texture = texture
|
||||
this.sprite.texture = texture
|
||||
this.sprite.visible = true
|
||||
if (previous) previous.destroy(true)
|
||||
} catch (error) {
|
||||
console.error('背景图恢复失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除背景纹理与偏移/透明度设置(供工具栏"清除背景"按钮使用)。 */
|
||||
static clearStoreBackground(store: { settings: { backgroundImageData: string; backgroundImageName: string; backgroundX: number; backgroundY: number; backgroundOpacity: number } }, resetControls: boolean) {
|
||||
store.settings.backgroundImageData = ''
|
||||
store.settings.backgroundImageName = ''
|
||||
if (resetControls) {
|
||||
store.settings.backgroundX = 0
|
||||
store.settings.backgroundY = 0
|
||||
store.settings.backgroundOpacity = 1
|
||||
}
|
||||
}
|
||||
|
||||
/** 把上传文件写进 store(dataUrl + 文件名)。 */
|
||||
static async handleFileChange(store: { settings: { backgroundImageData: string; backgroundImageName: string } }, event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
store.settings.backgroundImageData = await fileToDataUrl(file)
|
||||
store.settings.backgroundImageName = file.name
|
||||
} catch (error) {
|
||||
console.error('背景图加载失败', error)
|
||||
} finally {
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 卸载时释放纹理。 */
|
||||
destroy() {
|
||||
this.loadVersion++
|
||||
if (this.texture) this.texture.destroy(true)
|
||||
this.texture = null
|
||||
this.sprite = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Texture, type Container } from 'pixi.js'
|
||||
import { ParticleEmitter, ensureEmitterConfig } from '../../core/particleEmitter'
|
||||
import { sampleScenePath } from '../../core/scenePathSampler'
|
||||
import type { EmitterConfig } from '../../core/particleTypes'
|
||||
import { followCurveValue } from './stageGeometry'
|
||||
import type { useParticleStore, CollisionBody } from '../../store/particleStore'
|
||||
import type { ScenePath } from '../../store/particleStore'
|
||||
|
||||
type EditorStore = ReturnType<typeof useParticleStore>
|
||||
|
||||
/** 发射器容器世界点 → 发射器局部坐标(数学坐标系)。 */
|
||||
export function worldPointToEmitterLocal(
|
||||
config: { centerX: number; centerY: number; rootRotation: number; rootScaleX: number; rootScaleY: number },
|
||||
worldX: number,
|
||||
worldY: number,
|
||||
scaleX = Math.max(0.0001, Math.abs(config.rootScaleX)),
|
||||
scaleY = Math.max(0.0001, Math.abs(config.rootScaleY)),
|
||||
) {
|
||||
const angle = -config.rootRotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const screenX = worldX - config.centerX
|
||||
const screenY = -worldY + config.centerY
|
||||
return { x: (cos * screenX + sin * screenY) / scaleX, y: (-sin * screenX + cos * screenY) / scaleY }
|
||||
}
|
||||
|
||||
/** 采样出生路径并把世界点/切线角转换为发射器局部坐标。 */
|
||||
export function sampleSpawnPathForEmitter(
|
||||
config: { centerX: number; centerY: number; rootRotation: number; rootScaleX: number; rootScaleY: number },
|
||||
path: ScenePath,
|
||||
progress: number,
|
||||
) {
|
||||
const sample = sampleScenePath(path, progress)
|
||||
if (!sample) return null
|
||||
const local = worldPointToEmitterLocal(config, sample.x, sample.y)
|
||||
const radians = sample.angle * Math.PI / 180
|
||||
const tangentWorld = worldPointToEmitterLocal(config, sample.x + Math.cos(radians), sample.y + Math.sin(radians))
|
||||
const angle = Math.atan2(tangentWorld.y - local.y, tangentWorld.x - local.x) * 180 / Math.PI
|
||||
return { ...local, angle }
|
||||
}
|
||||
|
||||
/** 每帧预览碰撞体(含路径跟随),供粒子碰撞解算与绘制使用。 */
|
||||
export function buildPreviewColliders(store: EditorStore, timeSeconds: number): CollisionBody[] {
|
||||
return store.colliders.map((collider) => {
|
||||
if (!collider.followPath || !collider.followPathId) return collider
|
||||
const path = store.paths.find((item) => item.id === collider.followPathId && item.enabled)
|
||||
if (!path?.points.length) return collider
|
||||
const duration = Math.max(0.05, Number(collider.followDuration) || 2)
|
||||
const normalized = Math.min(1, Math.max(0, timeSeconds / duration))
|
||||
const curve = Array.isArray(collider.followCurve) ? collider.followCurve : [{ x: 0, y: 0 }, { x: 1, y: 1 }]
|
||||
const curveMode = collider.followCurveMode === '跟随导出'
|
||||
? (store.settings.exportSettings.keyframeCurve === 'bezier' ? 'bezier' : 'linear')
|
||||
: collider.followCurveMode === '贝塞尔' ? 'bezier' : 'linear'
|
||||
const progress = Math.min(1, Math.max(0, followCurveValue(curve, normalized, curveMode)))
|
||||
const sample = sampleScenePath(path, progress)
|
||||
return sample
|
||||
? {
|
||||
...collider,
|
||||
x: sample.x,
|
||||
y: sample.y,
|
||||
rotation: collider.rotation + (collider.followPathRotation ? sample.angle : 0),
|
||||
}
|
||||
: collider
|
||||
})
|
||||
}
|
||||
|
||||
/** 发射器集合:按 store.systems 增删 ParticleEmitter,并在纹理就绪后补挂默认贴图。 */
|
||||
export class EmitterSync {
|
||||
readonly emitters = new Map<number, ParticleEmitter>()
|
||||
private configs = new Map<number, object>()
|
||||
|
||||
sync(
|
||||
store: EditorStore,
|
||||
world: Container,
|
||||
dotTex: Texture<any> | null,
|
||||
trailTex: Texture<any> | null,
|
||||
) {
|
||||
for (const [id, em] of this.emitters) {
|
||||
const system = store.systems.find((item) => item.id === id)
|
||||
if (!system || this.configs.get(id) !== system.config) {
|
||||
world.removeChild(em)
|
||||
em.destroy({ children: true })
|
||||
this.emitters.delete(id)
|
||||
this.configs.delete(id)
|
||||
}
|
||||
}
|
||||
for (const sys of store.systems) {
|
||||
// 新增系统若还没贴图(dotTex 已加载后),补上默认点纹理,否则无法渲染
|
||||
if (dotTex && !sys.config.texture) sys.config.texture = dotTex
|
||||
const cfg = ensureEmitterConfig(sys.config as any)
|
||||
if (trailTex) {
|
||||
for (const resource of cfg.trailResources) {
|
||||
if (!resource.texture && (resource.previewUrl === '/images/trail.png' || resource.previewUrl === '/trails/trail.png')) resource.texture = trailTex
|
||||
}
|
||||
}
|
||||
if (dotTex) {
|
||||
for (const resource of cfg.imageResources) {
|
||||
if (!resource.texture && resource.textureName === 'star') resource.texture = dotTex
|
||||
}
|
||||
}
|
||||
if (this.emitters.has(sys.id)) continue
|
||||
const em = new ParticleEmitter(sys.config as any)
|
||||
world.addChild(em)
|
||||
this.emitters.set(sys.id, em)
|
||||
this.configs.set(sys.id, sys.config)
|
||||
}
|
||||
}
|
||||
|
||||
/** 卸载时销毁全部发射器。 */
|
||||
destroy(world: Container) {
|
||||
for (const [id, em] of this.emitters) {
|
||||
world.removeChild(em)
|
||||
em.destroy({ children: true })
|
||||
this.emitters.delete(id)
|
||||
this.configs.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { SceneTransformTarget } from './stageTypes'
|
||||
import { installPointerDrag } from './pointerDrag'
|
||||
import { pointerToWorldMath, type StageContext } from './viewControls'
|
||||
|
||||
/** 场景对象命中交互:碰撞体拖拽、力场/吸附点拖拽、根变换 gizmo 三工具手势。 */
|
||||
export class ObjectInteractions {
|
||||
constructor(private ctx: StageContext, private getTool: () => '' | 'translate' | 'rotate' | 'scale') {}
|
||||
|
||||
/** 命中当前选中碰撞体时拖拽其中心;返回 true 表示事件已被接管。 */
|
||||
onColliderPointerDown(e: PointerEvent): boolean {
|
||||
const store = this.ctx.store
|
||||
if (store.activeObjectType !== 'collision') return false
|
||||
const collider = store.colliders.find((item) => item.id === store.activeObjectId)
|
||||
if (!collider?.enabled || collider.visible === false) return false
|
||||
const pointer = pointerToWorldMath(this.ctx, e.clientX, e.clientY)
|
||||
const angle = collider.rotation * Math.PI / 180
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle)
|
||||
const scaleX = Math.max(0.0001, Math.abs(collider.scaleX ?? 1))
|
||||
const scaleY = Math.max(0.0001, Math.abs(collider.scaleY ?? 1))
|
||||
const dx = pointer.x - collider.x, dy = pointer.y - collider.y
|
||||
const localX = (cos * dx + sin * dy) / scaleX, localY = (-sin * dx + cos * dy) / scaleY
|
||||
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(this.ctx, event.clientX, event.clientY)
|
||||
collider.x = startX + next.x - start.x
|
||||
collider.y = startY + next.y - start.y
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
/** 命中当前选中系统的力场圆时开始拖拽;返回 true 表示事件已被力场接管。 */
|
||||
onForceFieldDown(e: PointerEvent): boolean {
|
||||
const sys = this.ctx.store.systems.find((item) => item.id === this.ctx.store.activeId)
|
||||
if (sys?.visible === false) return false
|
||||
const cfg = sys?.config
|
||||
if (!cfg?.forceField || cfg.forceRadius <= 0) return false
|
||||
const startPointer = pointerToWorldMath(this.ctx, e.clientX, e.clientY)
|
||||
const distance = Math.hypot(startPointer.x - cfg.forceCenterX, startPointer.y - cfg.forceCenterY)
|
||||
if (distance > cfg.forceRadius) return false
|
||||
const startX = cfg.forceCenterX
|
||||
const startY = cfg.forceCenterY
|
||||
if (this.ctx.holder.value) this.ctx.holder.value.style.cursor = 'grabbing'
|
||||
const move = (event: PointerEvent) => {
|
||||
const pointer = pointerToWorldMath(this.ctx, event.clientX, event.clientY)
|
||||
cfg.forceCenterX = startX + pointer.x - startPointer.x
|
||||
cfg.forceCenterY = startY + pointer.y - startPointer.y
|
||||
}
|
||||
const up = () => {
|
||||
if (this.ctx.holder.value) this.ctx.holder.value.style.cursor = ''
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
window.removeEventListener('pointercancel', up)
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
window.addEventListener('pointercancel', up)
|
||||
e.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
/** 命中吸附圆(半径为 0 时使用屏幕 12px 热区)时拖动全局吸附点。 */
|
||||
onAttractionDown(e: PointerEvent): boolean {
|
||||
const sys = this.ctx.store.systems.find((item) => item.id === this.ctx.store.activeId)
|
||||
if (sys?.visible === false) return false
|
||||
const cfg = sys?.config
|
||||
if (!cfg?.attraction) return false
|
||||
const startPointer = pointerToWorldMath(this.ctx, e.clientX, e.clientY)
|
||||
const hitRadius = Math.max(cfg.attractionRadius, 12 / Math.max(0.0001, this.ctx.store.editor.viewScale))
|
||||
const distance = Math.hypot(startPointer.x - cfg.attractionCenterX, startPointer.y - cfg.attractionCenterY)
|
||||
if (distance > hitRadius) return false
|
||||
const startX = cfg.attractionCenterX
|
||||
const startY = cfg.attractionCenterY
|
||||
if (this.ctx.holder.value) this.ctx.holder.value.style.cursor = 'grabbing'
|
||||
const move = (event: PointerEvent) => {
|
||||
const pointer = pointerToWorldMath(this.ctx, event.clientX, event.clientY)
|
||||
cfg.attractionCenterX = startX + pointer.x - startPointer.x
|
||||
cfg.attractionCenterY = startY + pointer.y - startPointer.y
|
||||
}
|
||||
const up = () => {
|
||||
if (this.ctx.holder.value) this.ctx.holder.value.style.cursor = ''
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
window.removeEventListener('pointercancel', up)
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
window.addEventListener('pointercancel', up)
|
||||
e.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
/** 依据层级树当前选中对象生成 gizmo 目标(读写其 x/y/rotation/scale)。 */
|
||||
selectedTransformTarget(): SceneTransformTarget | null {
|
||||
const store = this.ctx.store
|
||||
const id = store.activeObjectId
|
||||
if (store.activeObjectType === 'particle') {
|
||||
const system = store.systems.find((item) => item.id === id)
|
||||
if (!system || system.visible === false) return null
|
||||
const config = system.config
|
||||
return {
|
||||
x: config.centerX, y: config.centerY, rotation: config.rootRotation,
|
||||
scaleX: config.rootScaleX ?? 1, scaleY: config.rootScaleY ?? 1,
|
||||
setPosition: (x, y) => { config.centerX = x; config.centerY = y },
|
||||
setRotation: (rotation) => { config.rootRotation = rotation },
|
||||
setScale: (scaleX, scaleY) => { config.rootScaleX = scaleX; config.rootScaleY = scaleY },
|
||||
}
|
||||
}
|
||||
const collection = store.activeObjectType === 'collision'
|
||||
? store.colliders
|
||||
: store.activeObjectType === 'path'
|
||||
? store.paths
|
||||
: store.spines
|
||||
const object = collection.find((item) => item.id === id) as any
|
||||
if (!object || object.visible === false) return null
|
||||
return {
|
||||
x: Number(object.x) || 0, y: Number(object.y) || 0, rotation: Number(object.rotation) || 0,
|
||||
scaleX: Math.max(0.05, Number(object.scaleX) || 1), scaleY: Math.max(0.05, Number(object.scaleY) || 1),
|
||||
setPosition: (x, y) => { object.x = x; object.y = y },
|
||||
setRotation: (rotation) => { object.rotation = rotation },
|
||||
setScale: (scaleX, scaleY) => { object.scaleX = scaleX; object.scaleY = scaleY },
|
||||
}
|
||||
}
|
||||
|
||||
/** 场景对象 gizmo 拖拽:作用于场景树当前选中对象。 */
|
||||
onGizmoDown(e: PointerEvent) {
|
||||
const target = this.selectedTransformTarget()
|
||||
if (!target) return
|
||||
const app = this.ctx.app()!
|
||||
const store = this.ctx.store
|
||||
const sc = store.editor.viewScale
|
||||
const rect = this.ctx.holder.value!.getBoundingClientRect()
|
||||
const cx = rect.left + app.screen.width / 2 + store.editor.viewX + target.x * sc
|
||||
const cy = rect.top + app.screen.height * 0.5 + store.editor.viewY + (-target.y) * sc
|
||||
const startX = e.clientX, startY = e.clientY
|
||||
const tool = this.getTool()
|
||||
// 初始基准(按下时快照,后续基于快照 + 相对按下点的总增量,避免累积误差/抖动)
|
||||
const sCx = target.x, sCy = target.y
|
||||
const sRot = target.rotation
|
||||
const sSx = target.scaleX, sSy = target.scaleY
|
||||
const startDist = Math.hypot(startX - cx, startY - cy) || 1
|
||||
const startAngle = Math.atan2(startY - cy, startX - cx)
|
||||
|
||||
const move = (ev: PointerEvent) => {
|
||||
const dx = (ev.clientX - startX) / sc
|
||||
const dy = (ev.clientY - startY) / sc
|
||||
if (tool === 'translate') {
|
||||
// 位移:按下基准 + 总位移(离手快照,不累积)
|
||||
target.setPosition(sCx + dx, sCy - dy)
|
||||
} else if (tool === 'rotate') {
|
||||
const a = Math.atan2(ev.clientY - cy, ev.clientX - cx)
|
||||
// 跟手:拖拽环动方向 = 旋转方向(逆时针环动→逆时针,顺时针环动→顺时针)。
|
||||
// 渲染层 em.rotation=-rootRotation(屏幕坐标),故这里对屏幕 atan2 增量取反,使 rootRotation 在数学坐标下与拖拽环动同向。
|
||||
target.setRotation(sRot - (a - startAngle) * 180 / Math.PI)
|
||||
} else if (tool === 'scale') {
|
||||
const nd = Math.hypot(ev.clientX - cx, ev.clientY - cy)
|
||||
const f = nd / startDist
|
||||
target.setScale(Math.max(0.05, sSx * f), Math.max(0.05, sSy * f))
|
||||
}
|
||||
}
|
||||
installPointerDrag(move)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { worldToPathLocal } from './stageGeometry'
|
||||
import { installPointerDrag } from './pointerDrag'
|
||||
import { pointerToWorldMath, type StageContext } from './viewControls'
|
||||
|
||||
/**
|
||||
* 路径节点编辑:双击增删锚点、单击选中/多选/反选、框选、锚点与贝塞尔手柄拖拽。
|
||||
* 与原 Stage.vue 实现逐行为等价,仅把函数移到模块并显式传入共享状态。
|
||||
*/
|
||||
export class PathEditing {
|
||||
/** 多选节点集合(选中层级树路径对象时启用)。 */
|
||||
readonly selectedNodes = new Set<number>()
|
||||
/** 双击后抑制紧随其后的又一次双击(单击建锚已处理过)。 */
|
||||
private suppressNextDoubleClick = false
|
||||
|
||||
constructor(private ctx: StageContext) {}
|
||||
|
||||
resetSelection() {
|
||||
this.selectedNodes.clear()
|
||||
}
|
||||
|
||||
activePath() {
|
||||
const store = this.ctx.store
|
||||
if (store.activeObjectType !== 'path') return null
|
||||
const path = store.paths.find((item) => item.id === store.activeObjectId) || null
|
||||
return path?.visible === false ? null : path
|
||||
}
|
||||
|
||||
private pathInsertIndex(path: NonNullable<ReturnType<PathEditing['activePath']>>, x: number, y: number) {
|
||||
if (path.points.length < 2) return path.points.length
|
||||
const segments = path.closed ? path.points.length : path.points.length - 1
|
||||
let bestIndex = path.points.length
|
||||
let bestDistance = Infinity
|
||||
for (let index = 0; index < segments; index++) {
|
||||
const a = path.points[index], b = path.points[(index + 1) % path.points.length]
|
||||
const dx = b.x - a.x, dy = b.y - a.y
|
||||
const lengthSq = Math.max(0.0001, dx * dx + dy * dy)
|
||||
const amount = Math.min(1, Math.max(0, ((x - a.x) * dx + (y - a.y) * dy) / lengthSq))
|
||||
const px = a.x + dx * amount, py = a.y + dy * amount
|
||||
const distance = Math.hypot(x - px, y - py)
|
||||
if (distance < bestDistance) { bestDistance = distance; bestIndex = index + 1 }
|
||||
}
|
||||
return bestIndex
|
||||
}
|
||||
|
||||
/** 双击:删除命中的锚点,否则在最近线段处插入新锚点。 */
|
||||
onStageDoubleClick(e: MouseEvent) {
|
||||
const path = this.activePath()
|
||||
if (!path || !path.enabled) return
|
||||
if (this.suppressNextDoubleClick) { this.suppressNextDoubleClick = false; return }
|
||||
const pointer = pointerToWorldMath(this.ctx, e.clientX, e.clientY)
|
||||
const local = worldToPathLocal(path, pointer.x, pointer.y)
|
||||
const threshold = 9 / Math.max(0.01, this.ctx.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)
|
||||
this.selectedNodes.clear()
|
||||
return
|
||||
}
|
||||
const insertIndex = this.pathInsertIndex(path, local.x, local.y)
|
||||
path.points.splice(insertIndex, 0, { x: local.x, y: local.y, inX: 0, inY: 0, outX: 0, outY: 0 })
|
||||
this.selectedNodes.clear()
|
||||
this.selectedNodes.add(insertIndex)
|
||||
}
|
||||
|
||||
/** 单击路径:返回 true 表示事件已被路径编辑接管。 */
|
||||
onPointerDown(e: PointerEvent): boolean {
|
||||
const path = this.activePath()
|
||||
if (!path || !path.enabled) return false
|
||||
const pointer = pointerToWorldMath(this.ctx, e.clientX, e.clientY)
|
||||
const local = worldToPathLocal(path, pointer.x, pointer.y)
|
||||
const threshold = 9 / Math.max(0.01, this.ctx.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 }
|
||||
const insertIndex = this.pathInsertIndex(path, local.x, local.y)
|
||||
path.points.splice(insertIndex, 0, anchor)
|
||||
this.selectedNodes.clear()
|
||||
this.selectedNodes.add(insertIndex)
|
||||
this.suppressNextDoubleClick = true
|
||||
installPointerDrag((event) => {
|
||||
const worldPoint = pointerToWorldMath(this.ctx, 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 this.selectedNodes) {
|
||||
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(this.ctx, 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) { this.selectedNodes.delete(anchorIndex); return true }
|
||||
if (e.ctrlKey || e.metaKey) this.selectedNodes.add(anchorIndex)
|
||||
else if (!this.selectedNodes.has(anchorIndex)) { this.selectedNodes.clear(); this.selectedNodes.add(anchorIndex) }
|
||||
const start = local
|
||||
const origins = [...this.selectedNodes].map((index) => ({ index, x: path.points[index].x, y: path.points[index].y }))
|
||||
const move = (event: PointerEvent) => {
|
||||
const worldPoint = pointerToWorldMath(this.ctx, 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) { this.selectedNodes.clear(); return true }
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) {
|
||||
const start = local
|
||||
const subtract = e.altKey
|
||||
const up = (event: PointerEvent) => {
|
||||
const endWorld = pointerToWorldMath(this.ctx, 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) this.selectedNodes.delete(index); else this.selectedNodes.add(index)
|
||||
}
|
||||
})
|
||||
window.removeEventListener('pointerup', up)
|
||||
window.removeEventListener('pointercancel', up)
|
||||
}
|
||||
window.addEventListener('pointerup', up)
|
||||
window.addEventListener('pointercancel', up)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 统一的指针拖拽安装器:move 挂到 pointermove,pointerup/pointercancel 时自动清理。
|
||||
* 原先散落在 Stage.vue 各拖拽手势中的重复样板收拢于此。
|
||||
*/
|
||||
export 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)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Texture } from 'pixi.js'
|
||||
import { ensureEmitterConfig } from '../../core/particleEmitter'
|
||||
import type { useParticleStore } from '../../store/particleStore'
|
||||
|
||||
type EditorStore = ReturnType<typeof useParticleStore>
|
||||
|
||||
/** 加载内置粒子点与拖尾纹理(仅加载一次)。 */
|
||||
export async function loadDefaultTextures() {
|
||||
const [particleTexture, trailTexture] = await Promise.all([
|
||||
Texture.fromURL('/images/star.png'),
|
||||
Texture.fromURL('/images/trail.png'),
|
||||
])
|
||||
return { dotTex: particleTexture, trailTex: trailTexture }
|
||||
}
|
||||
|
||||
/** 把默认贴图补挂到尚未绑定纹理的系统与资源上(挂载时执行一次)。 */
|
||||
export function attachDefaultTextures(store: EditorStore, dotTex: Texture<any>, trailTex: Texture<any> | null) {
|
||||
for (const sys of store.systems) {
|
||||
sys.config.texture = dotTex
|
||||
const cfg = ensureEmitterConfig(sys.config as any)
|
||||
if (trailTex) {
|
||||
for (const resource of cfg.trailResources) {
|
||||
if (!resource.texture && (resource.previewUrl === '/images/trail.png' || resource.previewUrl === '/trails/trail.png')) resource.texture = trailTex
|
||||
}
|
||||
}
|
||||
for (const resource of cfg.imageResources) {
|
||||
if (!resource.texture && resource.textureName === 'star') resource.texture = dotTex
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Application } from 'pixi.js'
|
||||
import type { Ref } from 'vue'
|
||||
import type { useParticleStore } from '../../store/particleStore'
|
||||
import { installPointerDrag } from './pointerDrag'
|
||||
|
||||
type EditorStore = ReturnType<typeof useParticleStore>
|
||||
|
||||
/** Stage 各交互/渲染模块共享的运行时引用。 */
|
||||
export interface StageContext {
|
||||
store: EditorStore
|
||||
/** 画布容器 DOM(Pixi canvas 的父元素) */
|
||||
holder: Ref<HTMLElement | null>
|
||||
/** Pixi Application(初始化前为 null) */
|
||||
app: () => Application | null
|
||||
}
|
||||
|
||||
/** 屏幕像素坐标 → 世界数学坐标(Y 向上)。 */
|
||||
export function pointerToWorldMath(ctx: StageContext, clientX: number, clientY: number) {
|
||||
const holder = ctx.holder.value!
|
||||
const app = ctx.app()!
|
||||
const rect = holder.getBoundingClientRect()
|
||||
const scale = Math.max(0.0001, ctx.store.editor.viewScale)
|
||||
const screenX = clientX - rect.left
|
||||
const screenY = clientY - rect.top
|
||||
return {
|
||||
x: (screenX - app.screen.width / 2 - ctx.store.editor.viewX) / scale,
|
||||
y: -(screenY - app.screen.height * 0.5 - ctx.store.editor.viewY) / scale,
|
||||
}
|
||||
}
|
||||
|
||||
/** 滚轮缩放画布(viewScale),基于鼠标所在位置缩放更平滑。 */
|
||||
export function onWheel(ctx: StageContext, e: WheelEvent) {
|
||||
const holder = ctx.holder.value!
|
||||
const app = ctx.app()!
|
||||
const store = ctx.store
|
||||
const rect = holder.getBoundingClientRect()
|
||||
const mx = e.clientX - rect.left
|
||||
const my = e.clientY - rect.top
|
||||
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12
|
||||
const old = store.editor.viewScale
|
||||
const nn = Math.max(0.01, old * factor)
|
||||
// 保持鼠标下的世界点不动:调整 viewX/viewY
|
||||
const cx = app.screen.width / 2
|
||||
const cy = app.screen.height * 0.5
|
||||
const oldVX = store.editor.viewX
|
||||
const oldVY = store.editor.viewY
|
||||
// 鼠标处 world 坐标(之前),需扣除当前平移偏移
|
||||
const wx = (mx - cx - oldVX) / old
|
||||
const wy = (my - cy - oldVY) / old
|
||||
store.editor.viewX = mx - cx - wx * nn
|
||||
store.editor.viewY = my - cy - wy * nn
|
||||
store.editor.viewScale = nn
|
||||
}
|
||||
|
||||
/** 鼠标左键长按拖动画布(平移)。 */
|
||||
export function panView(ctx: StageContext, startX: number, startY: number) {
|
||||
const store = ctx.store
|
||||
const vx0 = store.editor.viewX
|
||||
const vy0 = store.editor.viewY
|
||||
installPointerDrag((ev) => {
|
||||
store.editor.viewX = vx0 + (ev.clientX - startX)
|
||||
store.editor.viewY = vy0 + (ev.clientY - startY)
|
||||
})
|
||||
}
|
||||
|
||||
export function viewScaleBy(ctx: StageContext, factor: number) {
|
||||
const nn = Math.max(0.01, ctx.store.editor.viewScale * factor)
|
||||
ctx.store.editor.viewScale = nn
|
||||
}
|
||||
|
||||
export function zoomIn(ctx: StageContext) { viewScaleBy(ctx, 1.2) }
|
||||
export function zoomOut(ctx: StageContext) { viewScaleBy(ctx, 1 / 1.2) }
|
||||
|
||||
export function fitView(ctx: StageContext) {
|
||||
ctx.store.editor.viewScale = 1.0
|
||||
ctx.store.editor.viewX = 0
|
||||
ctx.store.editor.viewY = 0
|
||||
}
|
||||
|
||||
export function resetView(ctx: StageContext) {
|
||||
ctx.store.editor.viewScale = 1.0
|
||||
ctx.store.editor.viewX = 0
|
||||
ctx.store.editor.viewY = 0
|
||||
}
|
||||
|
||||
export function toggleGrid(ctx: StageContext) {
|
||||
ctx.store.settings.showGrid = !ctx.store.settings.showGrid
|
||||
}
|
||||
Reference in new issue
Block a user