Files
tools/Spine插件/Spine_动画播放器_2.0/index.html
T

1492 lines
61 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Spine 万能查看器 (3.8 / 4.1 / 4.2)</title>
<style>
/* ================= UI 美化版 ================= */
body { margin: 0; overflow: hidden; background-color: #111; font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; color: #eee; }
/* 布局容器 */
#canvas-wrapper { position: relative; width: 100vw; height: 100vh; background-color: #333; }
canvas { display: block; width: 100%; height: 100%; position: absolute; top: 0; left: 0; }
#debug-canvas { pointer-events: none; z-index: 10; }
#canvas { z-index: 5; }
/* 背景图层 */
#bg-image-container {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
z-index: 1; /* 在背景色之上,Canvas 之下 */
pointer-events: none;
overflow: hidden;
display: none; /* 默认隐藏 */
}
/* 通用面板样式 */
.panel {
background: rgba(30, 30, 30, 0.85);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
padding: 20px;
transition: all 0.3s ease;
}
/* 控件行:统一高度和对齐 */
.control-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
height: 28px; /* 强制统一高度 */
}
.control-row label {
font-size: 13px;
color: #bbb;
font-weight: 500;
}
/* 按钮通用样式 */
button {
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
display: flex; justify-content: center; align-items: center;
}
button:active { transform: scale(0.98); }
/* 特大按钮(重置/打开) */
.btn-large {
width: 100%;
height: 48px; /* 加大高度 */
font-size: 16px;
font-weight: 600;
background: linear-gradient(135deg, #4a90e2, #357abd);
color: white;
margin-bottom: 15px;
box-shadow: 0 4px 12px rgba(74, 144, 226, 0.3);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-large:hover { background: linear-gradient(135deg, #5aa0f2, #458acd); box-shadow: 0 6px 16px rgba(74, 144, 226, 0.4); }
/* 导出按钮 */
.btn-export {
width: 100%;
padding: 10px;
background: linear-gradient(135deg, #f5a623, #d08816);
color: #222;
font-weight: bold;
box-shadow: 0 4px 12px rgba(245, 166, 35, 0.3);
margin-top: 10px;
}
.btn-export:hover { background: linear-gradient(135deg, #ffb733, #e09826); color: #000; }
/* 输入框和下拉单 */
select, input[type="text"] {
background: #222;
border: 1px solid #444;
color: #eee;
padding: 6px 8px;
border-radius: 6px;
font-size: 13px;
outline: none;
width: 100%;
box-sizing: border-box;
transition: border 0.2s;
}
select:focus, input[type="text"]:focus { border-color: #666; background: #2a2a2a; }
/* 颜色预设方块 */
.color-group { display: flex; gap: 6px; align-items: center; }
.color-preset {
width: 20px; height: 20px;
border-radius: 4px;
cursor: pointer;
border: 1px solid rgba(255,255,255,0.2);
transition: transform 0.2s;
}
.color-preset:hover { transform: scale(1.15); border-color: white; }
input[type="color"] {
-webkit-appearance: none;
border: none; width: 24px; height: 24px; padding: 0; background: none; cursor: pointer;
}
input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; }
input[type="color"]::-webkit-color-swatch { border: 1px solid #666; border-radius: 4px; }
/* 滑块美化 */
input[type=range] {
-webkit-appearance: none; width: 100px; height: 4px; background: #444; border-radius: 2px; outline: none;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; width: 14px; height: 14px; background: #888; border-radius: 50%; cursor: pointer; transition: background 0.2s;
}
input[type=range]::-webkit-slider-thumb:hover { background: #fff; }
/* Checkbox 美化 */
input[type=checkbox] {
width: 18px; height: 18px; accent-color: #00e5ff; cursor: pointer;
}
/* 左上角文件面板 */
#file-panel {
position: absolute; top: 20px; left: 20px;
width: 320px; /* 加宽一点以容纳缩略图 */
z-index: 10;
display: none; /* JS控制显示 */
flex-direction: column;
max-height: 80vh; /* 防止太长 */
}
/* 自定义文件列表容器 */
#custom-file-list {
background: #222;
border: 1px solid #444;
border-radius: 6px;
max-height: 300px; /* 限制高度,内部滚动 */
overflow-y: auto;
margin-top: 5px;
}
/* 列表模式:紧凑 */
#custom-file-list.view-list .file-item {
padding: 4px 8px;
}
#custom-file-list.view-list .file-thumb {
display: none; /* 列表模式隐藏图片 */
}
#custom-file-list.view-list .file-name {
font-size: 13px;
}
/* 预览模式:大图 */
#custom-file-list.view-grid .file-item {
padding: 8px;
}
#custom-file-list.view-grid .file-thumb {
display: block;
width: 48px; height: 48px;
}
/* 列表项 */
.file-item {
display: flex;
align-items: center;
cursor: pointer;
border-bottom: 1px solid #333;
transition: background 0.2s;
}
.file-item:last-child { border-bottom: none; }
.file-item:hover { background: #333; }
.file-item.active { background: #004466; border-left: 3px solid #00e5ff; }
.file-thumb {
background: #111;
border-radius: 4px;
margin-right: 10px;
object-fit: contain;
border: 1px solid #444;
flex-shrink: 0;
}
.file-info {
flex: 1;
overflow: hidden;
}
.file-name {
color: #eee;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.file-path {
font-size: 11px;
color: #888;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 右上角控制面板 */
#ui {
position: absolute; top: 20px; right: 20px;
width: 280px;
z-index: 10;
display: none; /* JS控制显示 */
}
/* 底部播放条 */
#controls {
position: absolute; bottom: 30px; left: 50%;
transform: translateX(-50%);
width: 540px;
z-index: 10;
display: none;
flex-direction: column;
gap: 12px;
align-items: center;
}
/* 播放控制行 */
.play-row { display: flex; align-items: center; gap: 15px; width: 100%; }
#timeline { flex: 1; height: 6px; background: #444; border-radius: 3px; }
#timeline::-webkit-slider-thumb { width: 16px; height: 16px; background: #00e5ff; }
#btn-play { width: 40px; background: #00e5ff; color: #000; font-weight: bold; }
/* 右下角信息 */
#log { position: fixed; bottom: 50px; right: 20px; pointer-events:none; z-index:200; font-family:'Consolas', monospace; color:#00ff9d; font-size:16px; text-align: right; text-shadow: 1px 1px 2px black; font-weight: bold; }
#version-label { position: fixed; bottom: 15px; right: 20px; font-size:18px; color:#888; z-index: 100; pointer-events: none; font-family: sans-serif; font-weight: bold; opacity: 0.8; }
/* 隐藏滚动条 */
::-webkit-scrollbar { width: 0; }
/* 分隔线 */
.divider { border-top: 1px solid rgba(255,255,255,0.1); margin: 15px 0; padding-top: 15px; }
/* 拖拽区域 */
#drop-zone {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
display: flex; justify-content: center; align-items: center;
background: radial-gradient(circle at center, #333 0%, #000 100%); /* 更深邃的背景 */
color: white; font-size: 24px;
z-index: 5; pointer-events: none; opacity: 1; transition: opacity 0.5s;
flex-direction: column; gap: 20px;
}
.logo-icon {
width: 280px; height: auto; object-fit: contain; /* 加大图片,自适应高度 */
margin-bottom: 10px; /* 减小底部间距,因为图片本身可能有留白 */
filter: drop-shadow(0 0 25px rgba(0, 229, 255, 0.4)); /* 增强光晕 */
animation: float 4s ease-in-out infinite; /* 调慢浮动速度,更优雅 */
}
@keyframes float {
0% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
100% { transform: translateY(0px); }
}
h1 {
font-size: 56px; /* 加大标题 */
font-weight: 800;
margin: 0 0 10px 0;
background: linear-gradient(135deg, #fff 0%, #ccc 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 10px 40px rgba(0,0,0,0.6);
letter-spacing: 8px; /* 增加字间距,更有设计感 */
text-transform: uppercase;
}
.btn-folder {
margin-top: 20px; /* 增加上方间距 */
padding: 18px 50px; font-size: 20px;
background: linear-gradient(135deg, #00e5ff, #0099cc);
color: #fff; border: none; border-radius: 50px;
cursor: pointer; font-weight: bold;
pointer-events: auto;
box-shadow: 0 10px 30px rgba(0, 229, 255, 0.3);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
letter-spacing: 2px;
}
.btn-folder:hover {
transform: translateY(-2px) scale(1.02);
box-shadow: 0 15px 40px rgba(0, 229, 255, 0.5);
}
.btn-folder:active {
transform: translateY(1px) scale(0.98);
}
</style>
</head>
<body>
<div id="drop-zone">
<!-- 支持用户自定义 Logo:请将您的图片命名为 icon.png 放入同级目录 -->
<img src="icon.png" class="logo-icon" onerror="this.style.display='none'; document.getElementById('default-logo').style.display='block';" alt="Logo">
<div id="default-logo" class="logo-icon" style="display:none; font-size:120px; line-height: 1;">💎</div>
<h1>播放器</h1>
<p style="color:#666; margin-bottom:30px; font-size: 14px; letter-spacing: 4px; text-transform: uppercase; font-weight: 300;">Universal Spine Viewer</p>
<div id="web-input">
<input type="file" id="folder-input" webkitdirectory directory style="display:none">
<button class="btn-folder" onclick="document.getElementById('folder-input').click()">📂 打开动画文件夹</button>
</div>
<div id="native-input" style="display:none;">
<button class="btn-folder" onclick="openNativeFolder()">📂 打开本地文件夹</button>
</div>
</div>
<div id="log">等待文件...</div>
<div id="canvas-wrapper">
<!-- 背景参考图层 -->
<div id="bg-image-container" style="position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; overflow:hidden; z-index:0; display:none;">
<img id="bg-reference" src="" style="position:absolute; top:50%; left:50%; transform:translate(-50%, -50%); opacity:1.0;">
</div>
<canvas id="canvas" style="position:relative; z-index:1;"></canvas>
<canvas id="debug-canvas" style="z-index:2;"></canvas>
</div>
<!-- 左上角:文件与导出 -->
<div id="file-panel" class="panel">
<button onclick="location.reload()" class="btn-large">🔄 重置 / 打开新文件</button>
<div id="file-list-container" style="display:none; margin-bottom:15px;">
<label style="color:#aaa; font-size:12px; margin-bottom:5px; display:block;">切换骨架文件:</label>
<!-- 搜索框与视图切换 -->
<div style="display:flex; align-items:center; gap:5px; margin-bottom: 5px;">
<div style="position:relative; flex:1;">
<input type="text" id="spine-file-search" placeholder="🔍 搜索文件名..." oninput="filterSpineFiles(this.value)" style="padding-right: 30px;">
<span id="search-count" style="position:absolute; right:8px; top:50%; transform:translateY(-50%); font-size:11px; color:#666; pointer-events:none;"></span>
</div>
<button onclick="toggleFileView()" id="btn-view-mode" style="width:30px; height:30px; background:#333; color:#aaa; font-size:16px; padding:0;" title="切换视图模式 (列表/预览)"></button>
</div>
<!-- 自定义带缩略图的列表 (默认列表模式) -->
<div id="custom-file-list" class="view-list"></div>
<!-- 隐藏原来的 Select,仅保留作为数据源的兼容性参考(如果不删的话) -->
<select id="spine-file-select" style="display:none;"></select>
</div>
<div class="divider"></div>
<div style="margin-top:5px;">
<label style="color:#aaa; font-size:12px; margin-bottom:5px; display:block;">导出序列帧:</label>
<div style="display:flex; gap:8px;">
<input type="text" id="export-prefix" value="anim" placeholder="前缀">
<select id="export-digits" style="width:70px;">
<option value="2">00</option>
<option value="3" selected>000</option>
<option value="4">0000</option>
</select>
</div>
<div style="display:flex; align-items:center; gap:8px; margin-top:8px;">
<label style="color:#aaa; font-size:12px; white-space:nowrap;">缩放:</label>
<input type="number" id="export-scale" value="1.0" step="0.1" min="0.1" max="5.0" style="background:#222; border:1px solid #444; color:#fff; padding:4px; border-radius:4px; width:100%;">
</div>
<button onclick="exportSequence()" class="btn-export">📂 选择文件夹并导出</button>
</div>
</div>
<div id="ui" class="panel">
<div style="margin-bottom:15px;">
<label style="display:block; margin-bottom:5px; color:#bbb; font-size:13px;">选择动画</label>
<select id="anim-select"></select>
</div>
<div style="margin-bottom:15px;">
<label style="display:block; margin-bottom:5px; color:#bbb; font-size:13px;">选择皮肤</label>
<select id="skin-select"></select>
</div>
<div class="divider"></div>
<div class="control-row">
<label>显示骨骼</label>
<input type="checkbox" id="chk-bones" onchange="window.viewerConfig.showBones = this.checked">
</div>
<div class="control-row">
<label>骨骼颜色</label>
<div class="color-group">
<div class="color-preset" onclick="setBoneColor('#00ff00')" style="background:#00ff00;"></div>
<div class="color-preset" onclick="setBoneColor('#00ccff')" style="background:#00ccff;"></div>
<div class="color-preset" onclick="setBoneColor('#ff0055')" style="background:#ff0055;"></div>
<div class="color-preset" onclick="setBoneColor('#ffff00')" style="background:#ffff00;"></div>
<input type="color" id="bone-color" value="#00ff00">
</div>
</div>
<div class="control-row">
<label>骨骼粗细</label>
<input type="range" id="bone-scale" min="0.5" max="3.0" step="0.1" value="1.0" style="width:120px;">
</div>
<div class="control-row">
<label>背景颜色</label>
<div class="color-group">
<div class="color-preset" onclick="setBgColor('#333333')" style="background:#333333;"></div>
<div class="color-preset" onclick="setBgColor('#555555')" style="background:#555555;"></div>
<div class="color-preset" onclick="setBgColor('#2b303b')" style="background:#2b303b;"></div>
<div class="color-preset" onclick="setBgColor('#2b332b')" style="background:#2b332b;"></div>
<input type="color" id="bg-color" value="#333333">
</div>
</div>
<div class="divider"></div>
<!-- 背景图片控制 -->
<div style="margin-bottom:10px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:5px;">
<label style="color:#bbb; font-size:13px;">参考背景图</label>
<label style="font-size:12px; display:flex; align-items:center; gap:5px; cursor:pointer;">
<input type="checkbox" id="bg-visible" checked onchange="toggleBgVisibility(this.checked)"> 显示
</label>
</div>
<div style="display:flex; gap:5px; margin-bottom:8px;">
<button onclick="document.getElementById('bg-upload').click()" style="flex:1; background:#444; color:#fff; padding:4px; font-size:12px;">📂 选择图片</button>
<input type="file" id="bg-upload" accept="image/*" style="display:none" onchange="loadBgImage(this)">
<button onclick="resetBgParams()" style="width:30px; background:#4466aa; color:#fff;" title="重置参数"></button>
<button onclick="clearBgImage()" style="width:30px; background:#633; color:#fff;" title="移除图片">×</button>
</div>
<div id="bg-controls" style="display:none; flex-direction:column; gap:5px;">
<div class="control-row">
<label>X 位移</label>
<div style="display:flex; align-items:center; gap:5px;">
<input type="range" id="bg-x" min="-1000" max="1000" value="0" step="1" oninput="updateBgFromSlider('bg-x')" style="width:70px;">
<input type="number" id="val-bg-x" value="0" step="1" oninput="updateBgFromInput('bg-x')" style="width:50px; background:#222; border:1px solid #444; color:#fff; padding:2px; border-radius:4px; text-align:right;">
</div>
</div>
<div class="control-row">
<label>Y 位移</label>
<div style="display:flex; align-items:center; gap:5px;">
<input type="range" id="bg-y" min="-1000" max="1000" value="0" step="1" oninput="updateBgFromSlider('bg-y')" style="width:70px;">
<input type="number" id="val-bg-y" value="0" step="1" oninput="updateBgFromInput('bg-y')" style="width:50px; background:#222; border:1px solid #444; color:#fff; padding:2px; border-radius:4px; text-align:right;">
</div>
</div>
<div class="control-row">
<label>大小</label>
<div style="display:flex; align-items:center; gap:5px;">
<input type="range" id="bg-scale" min="0.1" max="5.0" value="1.0" step="0.01" oninput="updateBgFromSlider('bg-scale')" style="width:70px;">
<input type="number" id="val-bg-scale" value="1.0" step="0.1" oninput="updateBgFromInput('bg-scale')" style="width:50px; background:#222; border:1px solid #444; color:#fff; padding:2px; border-radius:4px; text-align:right;">
</div>
</div>
<div class="control-row">
<label>透明度</label>
<div style="display:flex; align-items:center; gap:5px;">
<input type="range" id="bg-opacity" min="0.1" max="1.0" value="1.0" step="0.01" oninput="updateBgFromSlider('bg-opacity')" style="width:70px;">
<input type="number" id="val-bg-opacity" value="1.0" step="0.1" max="1.0" min="0" oninput="updateBgFromInput('bg-opacity')" style="width:50px; background:#222; border:1px solid #444; color:#fff; padding:2px; border-radius:4px; text-align:right;">
</div>
</div>
</div>
</div>
<div class="divider"></div>
<div class="control-row">
<label>PMA (预乘Alpha)</label>
<input type="checkbox" id="chk-pma" checked onchange="togglePMA(this.checked)">
</div>
</div>
<div id="export-modal">
<h2 style="color:white;">正在导出序列帧...</h2>
<div id="export-progress-bar"><div id="export-progress-fill"></div></div>
<p id="export-status" style="color:#aaa; margin-top:10px;">0 / 0</p>
</div>
<div id="controls" class="panel">
<div class="play-row">
<button id="btn-play" onclick="animControl.togglePlay()">❚❚</button>
<input type="range" id="timeline" min="0" max="1000" value="0" step="1" style="flex:1;">
<div style="display:flex; align-items:center; gap:5px; margin-left:10px;">
<select id="time-unit-select" style="width:40px;">
<option value="seconds">s</option>
<option value="frames">f</option>
</select>
<span id="time-text" style="font-family:monospace; font-size:12px; min-width:80px; text-align:right;">0.00 / 0.00</span>
</div>
</div>
<div class="play-row" style="justify-content: center; gap: 20px;">
<div class="control-group">
<label style="font-size:12px; color:#aaa;">倍速</label>
<select id="speed-select" style="width:60px;">
<option value="0.5">0.5x</option>
<option value="0.75">0.75x</option>
<option value="1.0" selected>1.0x</option>
<option value="1.25">1.25x</option>
<option value="1.5">1.5x</option>
<option value="2.0">2.0x</option>
</select>
</div>
<div class="control-group">
<label style="font-size:12px; color:#aaa;">帧率</label>
<select id="fps-select" style="width:50px;">
<option value="24">24</option>
<option value="30">30</option>
<option value="60" selected>60</option>
</select>
</div>
<button onclick="if(window.resetView) window.resetView()">归位</button>
</div>
<div class="play-row" style="margin-top:5px; border-top:1px solid #555; padding-top:10px; opacity:0.6;">
<span style="font-size:12px;">X</span> <input type="range" id="posX" min="-2000" max="2000" value="0" step="10" style="width:100px;">
<span style="font-size:12px;">Y</span> <input type="range" id="posY" min="-2000" max="2000" value="0" step="10" style="width:100px;">
</div>
</div>
<div id="version-label">Waiting...</div>
<script>
function setBoneColor(c) {
document.getElementById('bone-color').value = c;
window.viewerConfig.boneColor = c;
}
function setBgColor(c) {
document.getElementById('bg-color').value = c;
document.getElementById('canvas-wrapper').style.backgroundColor = c;
const r = parseInt(c.slice(1, 3), 16) / 255;
const g = parseInt(c.slice(3, 5), 16) / 255;
const b = parseInt(c.slice(5, 7), 16) / 255;
window.viewerConfig.bgColor = [r, g, b, 0.0]; // Alpha = 0 让 WebGL 透明
}
function log(msg) { document.getElementById('log').innerText = msg; }
// ==========================================
// 全局配置 (指挥部)
// ==========================================
window.viewerConfig = {
bgColor: [0.2, 0.2, 0.2, 0.0],
showBones: false,
boneColor: '#00ff00',
boneScale: 1.0,
speed: 1.0,
targetFps: 60,
timeMode: 'seconds', // 'seconds' or 'frames'
debugCtx: null,
debugCanvas: null
};
// ==========================================
// 骨骼调试渲染器 (Spine 样式还原版)
// ==========================================
window.debugRenderer = {
init: function() {
const c = document.getElementById('debug-canvas');
window.viewerConfig.debugCanvas = c;
window.viewerConfig.debugCtx = c.getContext('2d');
// 绑定骨骼UI事件
document.getElementById('bone-color').addEventListener('input', (e) => window.viewerConfig.boneColor = e.target.value);
document.getElementById('bone-scale').addEventListener('input', (e) => window.viewerConfig.boneScale = parseFloat(e.target.value));
// 绑定播放控制事件
document.getElementById('time-unit-select').addEventListener('change', (e) => {
window.viewerConfig.timeMode = e.target.value;
window.animControl.refresh();
});
document.getElementById('speed-select').addEventListener('change', (e) => window.viewerConfig.speed = parseFloat(e.target.value));
document.getElementById('fps-select').addEventListener('change', (e) => {
window.viewerConfig.targetFps = parseInt(e.target.value);
window.animControl.refresh(); // FPS变了,总帧数也会变,需要立即刷新
});
this.resize();
},
resize: function() {
const c = window.viewerConfig.debugCanvas;
if(c) {
c.width = window.innerWidth;
c.height = window.innerHeight;
}
},
hexToRgba: function(hex, alpha) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
},
drawBones: function(skeleton, camX, camY, zoom, canvasHeight) {
const ctx = window.viewerConfig.debugCtx;
const c = window.viewerConfig.debugCanvas;
if(!ctx || !c || !window.viewerConfig.showBones || !skeleton) {
if(ctx) ctx.clearRect(0, 0, c.width, c.height);
return;
}
ctx.clearRect(0, 0, c.width, c.height);
ctx.save();
// 坐标系转换
ctx.translate(c.width/2, c.height/2);
ctx.scale(zoom, zoom);
ctx.scale(1, -1);
ctx.translate(-camX, -camY);
// 获取当前配置
const baseColor = window.viewerConfig.boneColor;
const scale = window.viewerConfig.boneScale;
// 动态计算颜色
const BONE_FILL = this.hexToRgba(baseColor, 0.15);
const BONE_STROKE = this.hexToRgba(baseColor, 0.6);
const JOINT_COLOR = baseColor;
// 设置线条粗细 (受 scale 影响)
ctx.lineWidth = (1.5 * scale) / zoom;
// 遍历骨骼
for (let i = 0; i < skeleton.bones.length; i++) {
let bone = skeleton.bones[i];
let x = bone.worldX;
let y = bone.worldY;
const rad = bone.worldRotation * Math.PI / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
if (bone.data.length > 0) {
const len = bone.data.length;
// 宽度受 scale 影响
const width = Math.min(Math.max(len * 0.1, 5 * scale), 25 * scale);
const endX = x + cos * len;
const endY = y + sin * len;
const shoulderLen = Math.min(len * 0.2, 20 * scale);
const shoulderX = x + cos * shoulderLen;
const shoulderY = y + sin * shoulderLen;
const perpX = -sin * (width / 2);
const perpY = cos * (width / 2);
const leftX = shoulderX + perpX;
const leftY = shoulderY + perpY;
const rightX = shoulderX - perpX;
const rightY = shoulderY - perpY;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(leftX, leftY);
ctx.lineTo(endX, endY);
ctx.lineTo(rightX, rightY);
ctx.closePath();
// 优先使用骨骼自带颜色(如果有),否则使用全局配置
ctx.fillStyle = bone.data.color ? '#' + bone.data.color : BONE_FILL;
ctx.fill();
ctx.strokeStyle = BONE_STROKE;
ctx.stroke();
// 关节圆点大小受 scale 影响
ctx.beginPath();
ctx.arc(x, y, (3 * scale)/zoom, 0, Math.PI*2);
ctx.fillStyle = JOINT_COLOR;
ctx.fill();
}
else {
// 十字大小受 scale 影响
const size = (8 * scale) / zoom;
ctx.lineWidth = (1.5 * scale) / zoom;
ctx.strokeStyle = JOINT_COLOR;
ctx.beginPath();
ctx.moveTo(x - size, y); ctx.lineTo(x + size, y);
ctx.moveTo(x, y - size); ctx.lineTo(x, y + size);
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, (1.5 * scale)/zoom, 0, Math.PI*2);
ctx.fillStyle = JOINT_COLOR;
ctx.fill();
}
}
ctx.restore();
}
};
// 初始化调试画布
window.addEventListener('load', () => window.debugRenderer.init());
window.addEventListener('resize', () => window.debugRenderer.resize());
// 动画控制器
window.animControl = {
isPlaying: true,
isScrubbing: false,
targetTime: -1,
duration: 0,
currentTime: 0,
togglePlay: function() {
this.isPlaying = !this.isPlaying;
document.getElementById('btn-play').innerText = this.isPlaying ? "⏸" : "▶";
},
updateUI: function(current, total) {
this.duration = total;
this.currentTime = current;
const el = document.getElementById('timeline');
const txt = document.getElementById('time-text');
if (!this.isScrubbing && total > 0) {
const progress = (current % total) / total;
el.value = progress * 1000;
}
if(window.viewerConfig.timeMode === 'frames') {
const fps = window.viewerConfig.targetFps || 30;
const curFrame = Math.floor((current % total) * fps);
const totalFrame = Math.floor(total * fps);
txt.innerText = `${curFrame} / ${totalFrame}`;
txt.style.color = '#00e5ff';
} else {
txt.innerText = `${(current % total).toFixed(2)} / ${total.toFixed(2)}`;
txt.style.color = '#ffffff';
}
},
// 强制刷新(当FPS改变时调用)
refresh: function() {
this.updateUI(this.currentTime, this.duration);
}
};
// 绑定背景颜色
document.getElementById('bg-color').addEventListener('input', (e) => {
const hex = e.target.value;
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
window.viewerConfig.bgColor = [r, g, b, 1.0];
});
// 绑定时间滑块
const timeline = document.getElementById('timeline');
timeline.addEventListener('mousedown', () => { window.animControl.isScrubbing = true; });
timeline.addEventListener('mouseup', () => {
window.animControl.isScrubbing = false;
window.animControl.targetTime = -1;
});
timeline.addEventListener('input', (e) => {
if (window.animControl.duration > 0) {
const percent = e.target.value / 1000;
const time = percent * window.animControl.duration;
window.animControl.targetTime = time;
window.animControl.updateUI(time, window.animControl.duration);
}
});
window.readFileAsText = async function(file) {
if(file.url) return await (await fetch(file.url)).text();
return new Promise((r,j) => { const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.onerror=j; fr.readAsText(file); });
};
window.readFileAsArrayBuffer = async function(file) {
if(file.url) return await (await fetch(file.url)).arrayBuffer();
return new Promise((r,j) => { const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.onerror=j; fr.readAsArrayBuffer(file); });
};
window.readFileAsDataURL = async function(file) {
if(file.url) return file.url;
return new Promise((r,j) => { const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.onerror=j; fr.readAsDataURL(file); });
};
const isNative = window.chrome && window.chrome.webview;
if (isNative) {
document.getElementById('web-input').style.display = 'none';
document.getElementById('native-input').style.display = 'block';
log("已连接到本地引擎");
}
async function openNativeFolder() {
try {
const jsonStr = await window.chrome.webview.hostObjects.native.SelectFolder();
const data = JSON.parse(jsonStr);
if(data.files && data.files.length > 0) {
handleFiles(data.files);
}
} catch(e) {
alert("打开失败: " + e);
}
}
const dropZone = document.getElementById('drop-zone');
document.body.addEventListener('dragover', e => e.preventDefault());
document.body.addEventListener('drop', e => { e.preventDefault(); handleFiles(e.dataTransfer.files); });
document.getElementById('folder-input').addEventListener('change', e => handleFiles(e.target.files));
async function handleFiles(files) {
if(files.length === 0) return;
// 1. 自动分组逻辑 (支持子文件夹)
// 结构: { "folder_path/base_name": { name: "base_name", path: "folder_path", files: [] } }
const groups = {};
for(let i=0; i<files.length; i++) {
let f = files[i];
let name = f.name; // 文件名 (如 "a.png")
let path = f.webkitRelativePath || ""; // 相对路径 (如 "root/sub/a.png")
// 如果是拖拽进入的,webkitRelativePath 可能是空的,我们需要处理这种情况
// 但通常拖拽文件夹时,我们只能拿到 File 对象,拿不到目录结构(除非用 FileSystem API)。
// 不过对于 input 选择的文件夹,webkitRelativePath 是有的。
// 对于简单的文件拖拽,path 为空,我们默认它们在同一层级。
let folderPath = "";
if (path.indexOf('/') !== -1) {
folderPath = path.substring(0, path.lastIndexOf('/'));
}
let lowerName = name.toLowerCase();
if(lowerName.startsWith('.')) continue;
// 提取主文件名 (移除扩展名)
let baseName = name.substring(0, name.lastIndexOf('.'));
if(lowerName.endsWith('.atlas.txt')) baseName = name.substring(0, name.toLowerCase().indexOf('.atlas.txt'));
else if(lowerName.endsWith('.json.txt')) baseName = name.substring(0, name.toLowerCase().indexOf('.json.txt'));
else if(lowerName.endsWith('.skel.bytes')) baseName = name.substring(0, name.toLowerCase().indexOf('.skel.bytes'));
// 唯一标识符:目录路径 + 主文件名 (解决不同目录下同名文件冲突)
let uniqueKey = folderPath + "/" + baseName;
if(!groups[uniqueKey]) groups[uniqueKey] = { name: baseName, folder: folderPath, files: [] };
groups[uniqueKey].files.push(f);
}
// 2. 筛选有效组 (必须包含 .atlas 和 .png 和 .skel/.json)
const validGroups = [];
for(let key in groups) {
const g = groups[key];
let hasAtlas = false, hasPng = false, hasData = false;
for(let f of g.files) {
let n = f.name.toLowerCase();
if(n.endsWith('.atlas') || n.endsWith('.atlas.txt')) hasAtlas = true;
else if(n.endsWith('.png') || n.endsWith('.jpg') || n.endsWith('.webp')) hasPng = true;
else if(n.endsWith('.skel') || n.endsWith('.json') || n.endsWith('.skel.bytes') || n.endsWith('.json.txt')) hasData = true;
}
if(hasAtlas && hasPng && hasData) {
// 优化显示名称:如果有多层目录,显示 "目录/文件名"
if(g.folder) g.displayName = g.folder + "/" + g.name;
else g.displayName = g.name;
validGroups.push(g);
}
}
if(validGroups.length === 0) {
alert("❌ 未找到完整的 Spine 文件组!\n请确保每个子文件夹内包含完整的: .skel/.json + .atlas + .png");
return;
}
// 按路径排序,方便查看
validGroups.sort((a, b) => a.displayName.localeCompare(b.displayName));
// 3. 填充自定义列表
window.spineFileGroups = validGroups;
const listContainer = document.getElementById('custom-file-list');
listContainer.innerHTML = "";
validGroups.forEach(async (g, index) => {
const item = document.createElement('div');
item.className = 'file-item';
if(index === 0) item.classList.add('active'); // 默认选中第一个
item.dataset.index = index;
item.onclick = () => {
// 移除其他激活状态
document.querySelectorAll('.file-item').forEach(d => d.classList.remove('active'));
item.classList.add('active');
switchSpineFile(index);
};
// 尝试获取缩略图 (使用 .png 纹理)
let thumbUrl = "icon.png"; // 默认图
let pngFile = null;
// 优先找 preview.png,其次找 .png 纹理
// 这里的 files 是 File 对象数组
for(let f of g.files) {
if(f.name.toLowerCase() === 'preview.png') { pngFile = f; break; }
}
if(!pngFile) {
for(let f of g.files) {
if(f.name.toLowerCase().endsWith('.png') || f.name.toLowerCase().endsWith('.jpg')) { pngFile = f; break; }
}
}
if(pngFile) {
// 异步生成 Blob URL
thumbUrl = URL.createObjectURL(pngFile);
}
const img = document.createElement('img');
img.className = 'file-thumb';
img.src = thumbUrl;
const info = document.createElement('div');
info.className = 'file-info';
const name = document.createElement('div');
name.className = 'file-name';
name.innerText = g.name;
const path = document.createElement('div');
path.className = 'file-path';
path.innerText = g.folder || "/";
info.appendChild(name);
info.appendChild(path);
item.appendChild(img);
item.appendChild(info);
listContainer.appendChild(item);
// 存入 displayName 方便搜索
item.dataset.searchKey = g.displayName.toLowerCase();
});
// 显示切换面板
if(validGroups.length > 1) {
document.getElementById('file-list-container').style.display = 'block';
} else {
document.getElementById('file-list-container').style.display = 'none';
}
// 4. 加载第一组
loadSpineGroup(validGroups[0].files);
}
// 切换文件组
window.switchSpineFile = function(index) {
if(!window.spineFileGroups) return;
const group = window.spineFileGroups[index];
if(!group) return;
const files = group.files;
loadSpineGroup(files);
// 更新高亮 (如果是从搜索框回车触发的,可能需要同步 UI)
const items = document.querySelectorAll('.file-item');
items.forEach(it => {
if(parseInt(it.dataset.index) === parseInt(index)) it.classList.add('active');
else it.classList.remove('active');
});
};
// 搜索过滤逻辑
window.filterSpineFiles = function(keyword) {
const listContainer = document.getElementById('custom-file-list');
const items = listContainer.querySelectorAll('.file-item');
const countLabel = document.getElementById('search-count');
let matchCount = 0;
keyword = keyword.toLowerCase();
items.forEach(item => {
const key = item.dataset.searchKey;
if(key.includes(keyword)) {
item.style.display = 'flex';
matchCount++;
} else {
item.style.display = 'none';
}
});
// 更新计数
if (keyword) {
countLabel.innerText = matchCount > 0 ? `${matchCount}个` : '无';
countLabel.style.color = matchCount > 0 ? '#888' : '#f44';
} else {
countLabel.innerText = "";
}
};
// 绑定回车事件
document.getElementById('spine-file-search').addEventListener('keydown', (e) => {
if(e.key === 'Enter') {
// 找到第一个可见的 item
const visibleItem = document.querySelector('.file-item:not([style*="display: none"])');
if(visibleItem) {
visibleItem.click(); // 模拟点击
visibleItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
e.target.blur();
}
}
});
// 切换文件列表视图模式
window.toggleFileView = function() {
const list = document.getElementById('custom-file-list');
const btn = document.getElementById('btn-view-mode');
if (list.classList.contains('view-list')) {
// 切换到预览图模式
list.classList.remove('view-list');
list.classList.add('view-grid');
btn.innerText = "▦"; // 图标变为网格
// 保存偏好
localStorage.setItem('spine_view_mode', 'grid');
} else {
// 切换到列表模式
list.classList.remove('view-grid');
list.classList.add('view-list');
btn.innerText = "≣"; // 图标变为列表
localStorage.setItem('spine_view_mode', 'list');
}
};
// 初始化视图模式
window.addEventListener('DOMContentLoaded', () => {
const mode = localStorage.getItem('spine_view_mode') || 'list'; // 默认列表
const list = document.getElementById('custom-file-list');
const btn = document.getElementById('btn-view-mode');
if (list && btn) {
if (mode === 'grid') {
list.classList.remove('view-list');
list.classList.add('view-grid');
btn.innerText = "▦";
} else {
list.classList.remove('view-grid');
list.classList.add('view-list');
btn.innerText = "≣";
}
}
});
async function loadSpineGroup(files) {
// 1. 停止旧的渲染循环
if (window.rendererRequestId) {
cancelAnimationFrame(window.rendererRequestId);
window.rendererRequestId = 0;
}
// 4.2 可能用了不同的变量名,尝试清理
if (window.viewerConfig && window.viewerConfig.animRequestId) {
cancelAnimationFrame(window.viewerConfig.animRequestId);
window.viewerConfig.animRequestId = 0;
}
// 2. 彻底销毁旧 Canvas 并重建
const wrapper = document.getElementById('canvas-wrapper');
const oldCanvas = document.getElementById('canvas');
if (oldCanvas) {
// 尝试失去上下文
const gl = oldCanvas.getContext('webgl');
if(gl) gl.getExtension('WEBGL_lose_context')?.loseContext();
wrapper.removeChild(oldCanvas);
}
const newCanvas = document.createElement('canvas');
newCanvas.id = 'canvas';
newCanvas.style.position = 'absolute';
newCanvas.style.top = '0';
newCanvas.style.left = '0';
newCanvas.style.width = '100%';
newCanvas.style.height = '100%';
newCanvas.style.zIndex = '5';
const debugCanvas = document.getElementById('debug-canvas');
wrapper.insertBefore(newCanvas, debugCanvas);
// 3. 关键修复:清理全局对象,防止版本冲突
window.skeleton = null;
window.animationState = null;
window.spine = undefined; // 强制清除 spine 对象,迫使新脚本重新初始化它
if(window.debugRenderer) window.debugRenderer.drawBones(null, 0,0,1,0);
// 4. 开始文件分析
let map = { skel: null, json: null, atlas: null, png: null };
for(let f of files) {
let name = f.name.toLowerCase();
if(name.endsWith('.atlas') || name.endsWith('.atlas.txt')) map.atlas = f;
else if(name.endsWith('.png') || name.endsWith('.jpg')) map.png = f;
else if(name.endsWith('.skel') || name.endsWith('.skel.bytes')) map.skel = f;
else if(name.endsWith('.json') || name.endsWith('.json.txt')) map.json = f;
}
let version = "4.1";
if (map.skel) version = await detectBinaryVersion(map.skel);
else version = await detectJsonVersion(map.json);
if(!version) version = "4.1";
log(`加载: ${version} ...`);
document.getElementById('version-label').innerText = `SPINE ${version}`;
document.getElementById('file-panel').style.display = 'flex';
dropZone.style.opacity = 0;
setTimeout(() => dropZone.style.display = 'none', 500);
// 重置 UI
document.getElementById('ui').style.display = 'none';
document.getElementById('controls').style.display = 'none';
// 5. 强制重新加载脚本 (添加时间戳防止缓存,确保重新执行)
const ts = Date.now();
if (version.startsWith("3.8")) {
loadScript(`spine-webgl-3.8.js?t=${ts}`, () => {
// 此时 window.spine 应该是 3.8 版本
loadScript(`logic_3.8.js?t=${ts}`, () => {
if(window.startSpine38) window.startSpine38(newCanvas, files);
});
});
} else if (version.startsWith("4.2")) {
loadScript(`spine-webgl-4.2.js?t=${ts}`, () => {
loadScript(`logic_4.2.js?t=${ts}`, () => {
if(window.startSpine42) window.startSpine42(newCanvas, files);
});
});
} else {
loadScript(`spine-webgl.js?t=${ts}`, () => {
loadScript(`logic_4.1.js?t=${ts}`, () => {
if(window.startSpine41) window.startSpine41(newCanvas, files);
});
});
}
}
function loadScript(src, callback) {
let script = document.createElement('script');
script.src = src;
script.onload = callback;
script.onerror = () => alert(`无法加载脚本: ${src}`);
document.head.appendChild(script);
}
function detectJsonVersion(file) {
return new Promise(async r => {
try {
const text = await window.readFileAsText(file);
const o = JSON.parse(text);
r(o.skeleton ? o.skeleton.spine : null);
} catch { r(null); }
});
}
function detectBinaryVersion(file) {
return new Promise(async r => {
const buffer = await window.readFileAsArrayBuffer(file);
const header = buffer.slice(0, 100);
const v = new DataView(header);
let s = "";
for(let i=0; i<v.byteLength; i++) {
let c = v.getUint8(i);
if(c>=32 && c<=126) s+=String.fromCharCode(c); else s+=" ";
}
let m = s.match(/(\d+\.\d+)(\.\d+)?/);
if(m) {
let ver = m[1];
if(ver === "3.8") r(m[0]);
else if(ver === "4.2") r(m[0]);
else r("4.1");
} else r(null);
});
}
// ==========================================
// 背景图片控制逻辑
// ==========================================
window.bgConfig = { x: 0, y: 0, scale: 1.0, opacity: 1.0 };
function loadBgImage(input) {
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
const img = document.getElementById('bg-reference');
img.src = e.target.result;
document.getElementById('bg-image-container').style.display = 'block';
document.getElementById('bg-controls').style.display = 'flex';
document.getElementById('bg-visible').checked = true; // 自动勾选显示
resetBgParams();
}
reader.readAsDataURL(input.files[0]);
}
}
function clearBgImage() {
document.getElementById('bg-reference').src = '';
document.getElementById('bg-image-container').style.display = 'none';
document.getElementById('bg-controls').style.display = 'none';
document.getElementById('bg-upload').value = '';
}
function toggleBgVisibility(show) {
const container = document.getElementById('bg-image-container');
// 使用 visibility 而不是 display,保持占位(虽然绝对定位没占位),主要是保持加载状态
container.style.visibility = show ? 'visible' : 'hidden';
}
function resetBgParams() {
updateBgFromInput('bg-x', 0);
updateBgFromInput('bg-y', 0);
updateBgFromInput('bg-scale', 1.0);
updateBgFromInput('bg-opacity', 1.0);
}
// 从滑块更新 (同步到输入框)
function updateBgFromSlider(id) {
const val = parseFloat(document.getElementById(id).value);
document.getElementById('val-' + id).value = val; // 更新输入框
applyBgTransform();
}
// 从输入框更新 (同步到滑块,支持传入特定值)
function updateBgFromInput(id, val) {
if (val === undefined) {
val = parseFloat(document.getElementById('val-' + id).value);
} else {
document.getElementById('val-' + id).value = val;
}
document.getElementById(id).value = val; // 更新滑块
applyBgTransform();
}
function applyBgTransform() {
window.bgConfig.x = parseFloat(document.getElementById('val-bg-x').value);
window.bgConfig.y = parseFloat(document.getElementById('val-bg-y').value);
window.bgConfig.scale = parseFloat(document.getElementById('val-bg-scale').value);
window.bgConfig.opacity = parseFloat(document.getElementById('val-bg-opacity').value);
const img = document.getElementById('bg-reference');
img.style.transform = `translate(-50%, -50%) translate(${window.bgConfig.x}px, ${window.bgConfig.y}px) scale(${window.bgConfig.scale})`;
img.style.opacity = window.bgConfig.opacity;
}
// ==========================================
// 序列帧导出逻辑 (自动裁剪 + 透明通道)
// ==========================================
async function exportSequence() {
if(!window.animControl || window.animControl.duration <= 0) {
alert("没有可导出的动画!");
return;
}
const prefix = document.getElementById('export-prefix').value || "anim";
const digits = parseInt(document.getElementById('export-digits').value);
// 1. 选择文件夹
let dirHandle;
try {
dirHandle = await window.showDirectoryPicker();
} catch(e) { return; } // 用户取消
// UI 显示
const modal = document.getElementById('export-modal');
const fill = document.getElementById('export-progress-fill');
const status = document.getElementById('export-status');
modal.style.display = 'flex';
status.innerText = "正在计算裁剪范围...";
// 2. 暂停播放 & 准备环境
const wasPlaying = window.animControl.isPlaying;
window.animControl.isPlaying = false;
window.animControl.isScrubbing = true;
window.viewerConfig.isExporting = true;
// 3. 计算最佳包围盒 (Auto Crop)
// 我们需要遍历整个动画,找到最大和最小的边界
const fps = 30;
const duration = window.animControl.duration;
const totalFrames = Math.ceil(duration * fps);
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
// 获取当前的骨架对象 (需要各个 logic.js 把 skeleton 暴露给 window,或者我们去猜测)
// 目前 logic_3.8/4.1/4.2 并没有把 skeleton 挂载到 window.skeleton
// 但是 render 函数里用了全局变量 skeleton。
// 我们在 index.html 里无法直接访问 iframe 里的闭包变量(如果是闭包的话),
// 但幸运的是我们的 logic.js 是直接加载到当前 window 的,所以 skeleton 是全局变量!
if(!window.skeleton) {
alert("无法获取骨架数据");
modal.style.display = 'none';
return;
}
// 模拟运行一圈
const tempOffset = {x:0, y:0};
const tempSize = {x:0, y:0};
// 临时覆盖 updateUI 防止刷新界面拖慢速度
const originalUpdateUI = window.animControl.updateUI;
window.animControl.updateUI = function(){};
for(let i=0; i<totalFrames; i++) {
const time = i / fps;
// 强制设置时间并更新骨架 (不渲染)
// 注意:这里需要调用对应的 update 逻辑,比较麻烦,我们直接利用 render 循环的机制
// 但为了快,最好是手动调用。
// 由于不同版本 API 不同,我们这里采用一种"笨办法":
// 还是走渲染循环,但是不截图,只计算。
// 鉴于逻辑复杂,我们采用"渲染时计算"的策略:
// 先不要 resize,先跑一轮拿到 bounds,然后再跑一轮导出。
}
// 由于 updateWorldTransform 依赖 logic.js 的内部逻辑,我们在 index.html 里很难完美模拟。
// 所以我们简化策略:
// 第一遍循环:渲染每一帧,调用 skeleton.getBounds() 收集数据。
// 第二遍循环:调整 canvas 大小,渲染,导出。
try {
const canvas = document.getElementById('canvas');
const originalWidth = canvas.width;
const originalHeight = canvas.height;
const originalCam = { ... (window.cam || {}), ... (window.renderer?.camera?.position || {}) };
// 备份原始摄像机状态比较难,因为各版本变量不同。
// 我们采取导出后 reload 的策略,或者只修改全局变量 camX/Y
// === 第一阶段:扫描包围盒 ===
for(let i=0; i<totalFrames; i++) {
window.animControl.targetTime = i / fps;
// 等待一帧让骨骼更新
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
if(window.skeleton.getBounds) {
// 构造兼容的 Vector2 对象 (带 set 方法)
let offset = { x:0, y:0, set: function(x,y){this.x=x; this.y=y;} };
let size = { x:0, y:0, set: function(x,y){this.x=x; this.y=y;} };
// 传入 temp 数组以兼容 4.0+
window.skeleton.getBounds(offset, size, []);
if(size.x > 0 && size.y > 0) {
minX = Math.min(minX, offset.x);
minY = Math.min(minY, offset.y);
maxX = Math.max(maxX, offset.x + size.x);
maxY = Math.max(maxY, offset.y + size.y);
}
}
// 更新进度
fill.style.width = (i / totalFrames * 50) + '%';
}
// 增加一点 Padding
const padding = 10;
minX -= padding; minY -= padding;
maxX += padding; maxY += padding;
// 读取用户缩放比例
const scaleInput = document.getElementById('export-scale');
const scale = scaleInput ? parseFloat(scaleInput.value) || 1.0 : 1.0;
const baseW = maxX - minX;
const baseH = maxY - minY;
const targetW = Math.ceil(baseW * scale);
const targetH = Math.ceil(baseH * scale);
if(targetW <= 0 || targetH <= 0 || !isFinite(targetW)) {
throw "无法计算包围盒 (minX=" + minX + ", maxX=" + maxX + ")";
}
// 调试:显示计算结果
console.log(`[Export] Bounds: ${minX},${minY} -> ${maxX},${maxY} Size: ${targetW}x${targetH} (Scale: ${scale})`);
// === 调整画布和摄像机 ===
// 强制修改 Canvas 大小
canvas.width = targetW;
canvas.height = targetH;
// 居中摄像机
const centerX = minX + baseW/2;
const centerY = minY + baseH/2;
// 适配不同版本的摄像机控制
if(typeof window.camX !== 'undefined') { // 3.8
window.camX = centerX;
window.camY = centerY;
window.camZoom = scale; // 关键:缩放摄像机
}
else if(window.renderer && window.renderer.camera) { // 4.1
window.renderer.camera.position.x = centerX;
window.renderer.camera.position.y = centerY;
window.renderer.camera.zoom = scale; // 关键:缩放摄像机
window.renderer.camera.viewportWidth = targetW;
window.renderer.camera.viewportHeight = targetH;
window.renderer.camera.update();
}
else if(window.cam42) { // 4.2
window.cam42.x = centerX;
window.cam42.y = centerY;
window.cam42.zoom = scale; // 关键:缩放摄像机
// 4.2 还需要重置物理,否则之前的惯性会导致画面抖动
if(window.skeleton && window.spine && window.spine.Physics) {
window.skeleton.updateWorldTransform(window.spine.Physics.reset);
}
}
status.innerText = "开始导出图片...";
// === 第二阶段:渲染并保存 ===
for(let i=0; i<totalFrames; i++) {
window.animControl.targetTime = i / fps;
// 等待渲染
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
// 截图 (PNG 自动带 Alpha)
const blob = await new Promise(r => canvas.toBlob(r, 'image/png'));
const numStr = String(i).padStart(digits, '0');
const fileName = `${prefix}_${numStr}.png`;
const fileHandle = await dirHandle.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
fill.style.width = (50 + i / totalFrames * 50) + '%';
status.innerText = `${i+1} / ${totalFrames}`;
}
status.innerText = "导出完成!";
setTimeout(() => modal.style.display = 'none', 1000);
} catch(e) {
alert("导出出错: " + e);
console.error(e);
modal.style.display = 'none';
} finally {
// 5. 恢复现场 (不刷新页面)
window.viewerConfig.isExporting = false;
window.animControl.isScrubbing = false;
window.animControl.targetTime = -1;
window.animControl.updateUI = originalUpdateUI; // 恢复 UI 更新逻辑
// 恢复画布大小
canvas.width = originalWidth;
canvas.height = originalHeight;
// 恢复摄像机位置
if(typeof window.camX !== 'undefined') { // 3.8
window.camX = 0; window.camY = 0; window.camZoom = 1.0; // 简单重置
// 如果想完美恢复,需要在上面记录 originalCamX 等
}
else if(window.renderer && window.renderer.camera) { // 4.1
window.renderer.camera.position.x = 0;
window.renderer.camera.position.y = 0;
window.renderer.camera.zoom = 1.0;
window.renderer.camera.viewportWidth = originalWidth;
window.renderer.camera.viewportHeight = originalHeight;
window.renderer.camera.update();
}
else if(window.cam42) { // 4.2
window.cam42.x = 0;
window.cam42.y = -200;
window.cam42.zoom = 1.0;
if(window.skeleton && window.spine && window.spine.Physics) {
window.skeleton.updateWorldTransform(window.spine.Physics.reset);
}
}
// 恢复播放状态
if(wasPlaying) window.animControl.togglePlay();
// 强制刷新一帧以消除残影
if(window.requestAnimationFrame) requestAnimationFrame(() => {});
}
}
</script>
</body>
</html>