1492 lines
61 KiB
HTML
1492 lines
61 KiB
HTML
<!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>
|