添加spine动画播放器和au3视频格式转换脚本
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,283 @@
|
|||||||
|
// ==========================================
|
||||||
|
// Spine 3.8 渲染逻辑 (Timeline + DebugDraw)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
var canvas, gl, shader, batcher, mvp, skeletonRenderer;
|
||||||
|
var skeleton, animationState;
|
||||||
|
var lastTime = Date.now();
|
||||||
|
var camX=0, camY=0, camZoom=1.0;
|
||||||
|
// var rendererRequestId = 0; // 移除局部变量
|
||||||
|
|
||||||
|
function startSpine38(canvasElement, files) {
|
||||||
|
// 强制停止之前的循环 (双重保险,防止 index.html 没有清理干净)
|
||||||
|
if(window.viewerConfig.animRequestId) cancelAnimationFrame(window.viewerConfig.animRequestId);
|
||||||
|
|
||||||
|
canvas = canvasElement;
|
||||||
|
// 关键修改:开启 alpha 通道,并设置 premultipliedAlpha: false
|
||||||
|
// 这告诉浏览器:Canvas 的内容是 Straight Alpha 的(未预乘)。
|
||||||
|
// 即使我们画的是 PMA 数据,如果浏览器认为它是 PMA 并在合成时不再处理,就会导致透明边缘黑边。
|
||||||
|
// 这里设为 false,是为了让浏览器在合成到网页背景时,帮我们处理好边缘混合。
|
||||||
|
gl = canvas.getContext("webgl", { alpha: true, preserveDrawingBuffer: true, premultipliedAlpha: false });
|
||||||
|
|
||||||
|
// 如果 Context 丢失 (因为 Canvas 被移除),直接返回
|
||||||
|
if(!gl) return;
|
||||||
|
|
||||||
|
// === 关键:全局禁用 WebGL 自动预乘 ===
|
||||||
|
// 我们的纹理已经是 PMA 的了,所以不能让 WebGL 再乘一次。
|
||||||
|
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
|
||||||
|
|
||||||
|
// 移除之前的 gl.blendFunc Hack,回归标准流程
|
||||||
|
|
||||||
|
shader = spine.webgl.Shader.newTwoColoredTextured(gl);
|
||||||
|
batcher = new spine.webgl.PolygonBatcher(gl);
|
||||||
|
mvp = new spine.webgl.Matrix4();
|
||||||
|
skeletonRenderer = new spine.webgl.SkeletonRenderer(gl);
|
||||||
|
|
||||||
|
// 恢复 PMA 模式 (Normal 混合无黑边)
|
||||||
|
skeletonRenderer.premultipliedAlpha = true;
|
||||||
|
addPmaToggle38();
|
||||||
|
loadFiles38(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPmaToggle38() {
|
||||||
|
const chk = document.getElementById('chk-pma');
|
||||||
|
if (chk) {
|
||||||
|
// 恢复默认勾选
|
||||||
|
chk.checked = true;
|
||||||
|
window.viewerConfig.pmaEnabled = true;
|
||||||
|
|
||||||
|
chk.onchange = (e) => {
|
||||||
|
window.viewerConfig.pmaEnabled = e.target.checked;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles38(files) {
|
||||||
|
let map = { atlas: null, png: null, main: null, type: 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.main = f; map.type = 'binary'; }
|
||||||
|
else if(name.endsWith('.json') || name.endsWith('.json.txt')) { map.main = f; map.type = 'json'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const atlasText = await readFileAsText(map.atlas);
|
||||||
|
const imgUrl = await readFileAsDataURL(map.png);
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async () => {
|
||||||
|
// 确保不预乘 (防止黑边)
|
||||||
|
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
|
||||||
|
|
||||||
|
const texture = new spine.webgl.GLTexture(gl, img);
|
||||||
|
const spineAtlas = new spine.TextureAtlas(atlasText, (path) => texture);
|
||||||
|
const atlasLoader = new spine.AtlasAttachmentLoader(spineAtlas);
|
||||||
|
|
||||||
|
let skeletonData;
|
||||||
|
if(map.type === 'binary') {
|
||||||
|
const buffer = await readFileAsArrayBuffer(map.main);
|
||||||
|
const skeletonBinary = new spine.SkeletonBinary(atlasLoader);
|
||||||
|
skeletonBinary.scale = 1.0;
|
||||||
|
skeletonData = skeletonBinary.readSkeletonData(new Uint8Array(buffer));
|
||||||
|
} else {
|
||||||
|
const text = await readFileAsText(map.main);
|
||||||
|
const skeletonJson = new spine.SkeletonJson(atlasLoader);
|
||||||
|
skeletonJson.scale = 1.0;
|
||||||
|
skeletonData = skeletonJson.readSkeletonData(JSON.parse(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSkeleton38(skeletonData);
|
||||||
|
|
||||||
|
document.getElementById('ui').style.display = 'block';
|
||||||
|
document.getElementById('controls').style.display = 'flex';
|
||||||
|
document.getElementById('version-label').innerText = "Spine 3.8 (Binary/JSON)";
|
||||||
|
document.getElementById('version-label').style.color = "#ff0055";
|
||||||
|
|
||||||
|
if(window.viewerConfig.animRequestId) cancelAnimationFrame(window.viewerConfig.animRequestId);
|
||||||
|
render38();
|
||||||
|
};
|
||||||
|
img.src = imgUrl;
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
alert("3.8 加载错误: " + e);
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSkeleton38(skeletonData) {
|
||||||
|
skeleton = new spine.Skeleton(skeletonData);
|
||||||
|
skeleton.setToSetupPose();
|
||||||
|
skeleton.updateWorldTransform();
|
||||||
|
skeleton.x = 0; skeleton.y = 0;
|
||||||
|
|
||||||
|
// 暴露实例供导出使用
|
||||||
|
window.spineInstance = { skeleton: skeleton, state: animationState, gl: gl };
|
||||||
|
|
||||||
|
var stateData = new spine.AnimationStateData(skeleton.data);
|
||||||
|
animationState = new spine.AnimationState(stateData);
|
||||||
|
|
||||||
|
const animSelect = document.getElementById('anim-select');
|
||||||
|
animSelect.innerHTML = "";
|
||||||
|
skeleton.data.animations.forEach(a => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = a.name; opt.text = a.name; animSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
if(skeleton.data.animations.length > 0) animationState.setAnimation(0, skeleton.data.animations[0].name, true);
|
||||||
|
|
||||||
|
const skinSelect = document.getElementById('skin-select');
|
||||||
|
skinSelect.innerHTML = "";
|
||||||
|
skeleton.data.skins.forEach(s => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = s.name; opt.text = s.name; skinSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
if(skeleton.data.defaultSkin) skinSelect.value = skeleton.data.defaultSkin.name;
|
||||||
|
|
||||||
|
animSelect.onchange = () => animationState.setAnimation(0, animSelect.value, true);
|
||||||
|
skinSelect.onchange = () => { skeleton.setSkinByName(skinSelect.value); skeleton.setSlotsToSetupPose(); };
|
||||||
|
|
||||||
|
bindControls38();
|
||||||
|
updateControls38();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render38() {
|
||||||
|
// 将 requestId 存储到全局配置中,以便其他地方可以取消
|
||||||
|
window.viewerConfig.animRequestId = requestAnimationFrame(render38);
|
||||||
|
|
||||||
|
// 检查 canvas 是否还存在于 DOM 中 (防止僵尸循环)
|
||||||
|
if(!canvas.parentElement) {
|
||||||
|
cancelAnimationFrame(window.viewerConfig.animRequestId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Date.now();
|
||||||
|
const targetFps = window.viewerConfig.targetFps || 30;
|
||||||
|
const interval = 1000 / targetFps;
|
||||||
|
const elapsed = now - lastTime;
|
||||||
|
|
||||||
|
if (elapsed < interval) return;
|
||||||
|
|
||||||
|
lastTime = now - (elapsed % interval);
|
||||||
|
let delta = elapsed / 1000;
|
||||||
|
|
||||||
|
// 应用倍速
|
||||||
|
delta *= (window.viewerConfig.speed || 1.0);
|
||||||
|
|
||||||
|
// === 导出模式判断 ===
|
||||||
|
const isExporting = window.viewerConfig.isExporting;
|
||||||
|
|
||||||
|
if (!isExporting) {
|
||||||
|
// 只有非导出模式下才跟随窗口大小
|
||||||
|
if(canvas.width !== window.innerWidth) canvas.width = window.innerWidth;
|
||||||
|
if(canvas.height !== window.innerHeight) canvas.height = window.innerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
let w = canvas.width;
|
||||||
|
let h = canvas.height;
|
||||||
|
gl.viewport(0, 0, w, h);
|
||||||
|
|
||||||
|
if (isExporting) {
|
||||||
|
gl.clearColor(0, 0, 0, 0); // 强制全透明
|
||||||
|
} else {
|
||||||
|
const bg = window.viewerConfig ? window.viewerConfig.bgColor : [0.2, 0.2, 0.2, 1];
|
||||||
|
gl.clearColor(bg[0], bg[1], bg[2], bg[3]);
|
||||||
|
}
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
// === 关键修复:同步 PMA 设置 ===
|
||||||
|
// 确保每一帧都从全局配置读取 PMA 状态,并应用到 skeletonRenderer
|
||||||
|
if (window.viewerConfig) {
|
||||||
|
skeletonRenderer.premultipliedAlpha = window.viewerConfig.pmaEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skeleton && animationState) {
|
||||||
|
const ctrl = window.animControl;
|
||||||
|
let track = animationState.tracks[0];
|
||||||
|
|
||||||
|
if (track) {
|
||||||
|
if (ctrl) ctrl.updateUI(track.trackTime, track.animation.duration);
|
||||||
|
if (ctrl && ctrl.isScrubbing && ctrl.targetTime >= 0) {
|
||||||
|
track.trackTime = ctrl.targetTime;
|
||||||
|
}
|
||||||
|
else if (ctrl && !ctrl.isPlaying) {
|
||||||
|
animationState.update(0);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
animationState.update(delta);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
animationState.update(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
animationState.apply(skeleton);
|
||||||
|
skeleton.updateWorldTransform();
|
||||||
|
|
||||||
|
shader.bind();
|
||||||
|
mvp.ortho2d(camX - w/2/camZoom, camY - h/2/camZoom, w/camZoom, h/camZoom);
|
||||||
|
shader.setUniform4x4f(spine.webgl.Shader.MVP_MATRIX, mvp.values);
|
||||||
|
shader.setUniformi(spine.webgl.Shader.SAMPLER, 0);
|
||||||
|
|
||||||
|
// 关键修复:不要手动调用 gl.blendFunc,因为 skeletonRenderer.draw 会根据 Slot 模式自己设置
|
||||||
|
// 我们只需要确保 skeletonRenderer.premultipliedAlpha 是对的
|
||||||
|
if (window.viewerConfig) {
|
||||||
|
skeletonRenderer.premultipliedAlpha = window.viewerConfig.pmaEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
batcher.begin(shader);
|
||||||
|
skeletonRenderer.draw(batcher, skeleton);
|
||||||
|
batcher.end();
|
||||||
|
shader.unbind();
|
||||||
|
|
||||||
|
// === 绘制骨骼调试线 (导出时不绘制) ===
|
||||||
|
if(!isExporting) {
|
||||||
|
if(window.debugRenderer && window.viewerConfig.showBones) {
|
||||||
|
window.debugRenderer.drawBones(skeleton, camX, camY, camZoom, h);
|
||||||
|
} else if(window.debugRenderer) {
|
||||||
|
window.debugRenderer.drawBones(null, 0,0,1,0); // 清空
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateControls38() {
|
||||||
|
document.getElementById('posX').value = skeleton.x;
|
||||||
|
document.getElementById('posY').value = skeleton.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindControls38() {
|
||||||
|
const inputX = document.getElementById('posX');
|
||||||
|
const inputY = document.getElementById('posY');
|
||||||
|
inputX.oninput = () => { if(skeleton) skeleton.x = parseFloat(inputX.value); };
|
||||||
|
inputY.oninput = () => { if(skeleton) skeleton.y = parseFloat(inputY.value); };
|
||||||
|
|
||||||
|
window.resetView = function() {
|
||||||
|
skeleton.x = 0; skeleton.y = 0;
|
||||||
|
camX=0; camY=0; camZoom=1.0;
|
||||||
|
updateControls38();
|
||||||
|
};
|
||||||
|
|
||||||
|
let isDragging=false, startX, startY, isRightDrag=false;
|
||||||
|
canvas.oncontextmenu = e => e.preventDefault();
|
||||||
|
canvas.onmousedown = e => {
|
||||||
|
if(e.button === 2) isRightDrag = true; else isDragging = true;
|
||||||
|
startX = e.clientX; startY = e.clientY;
|
||||||
|
};
|
||||||
|
window.onmouseup = () => { isDragging=false; isRightDrag=false; };
|
||||||
|
window.onmousemove = e => {
|
||||||
|
if(isDragging) {
|
||||||
|
camX -= (e.clientX - startX) / camZoom;
|
||||||
|
camY += (e.clientY - startY) / camZoom;
|
||||||
|
startX = e.clientX; startY = e.clientY;
|
||||||
|
} else if(isRightDrag) {
|
||||||
|
let dy = e.clientY - startY;
|
||||||
|
let newZoom = camZoom * (1 + dy * 0.01);
|
||||||
|
if(newZoom > 0.1 && newZoom < 10) camZoom = newZoom;
|
||||||
|
startY = e.clientY;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
canvas.onwheel = e => {
|
||||||
|
let newZoom = camZoom * (e.deltaY > 0 ? 0.9 : 1.1);
|
||||||
|
if(newZoom > 0.1 && newZoom < 10) camZoom = newZoom;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
// ==========================================
|
||||||
|
// Spine 4.1 渲染逻辑 (Timeline + DebugDraw)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
var canvas, gl, renderer, assetManager, skeleton, animationState;
|
||||||
|
var lastTime = Date.now();
|
||||||
|
var rendererRequestId = 0;
|
||||||
|
|
||||||
|
function startSpine41(canvasElement, files) {
|
||||||
|
if(window.viewerConfig.animRequestId) cancelAnimationFrame(window.viewerConfig.animRequestId);
|
||||||
|
|
||||||
|
canvas = canvasElement;
|
||||||
|
// 关键修复:添加 premultipliedAlpha: false,解决半透明黑边问题
|
||||||
|
gl = canvas.getContext("webgl", { alpha: true, preserveDrawingBuffer: true, premultipliedAlpha: false });
|
||||||
|
|
||||||
|
if(!gl) return;
|
||||||
|
|
||||||
|
// 确保 WebGL 不自动预乘 (因为 PMA 图片本身已经是预乘过的)
|
||||||
|
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
|
||||||
|
|
||||||
|
renderer = new spine.SceneRenderer(canvas, gl);
|
||||||
|
assetManager = new spine.AssetManager(gl);
|
||||||
|
|
||||||
|
renderer.premultipliedAlpha = true;
|
||||||
|
addPmaToggle41();
|
||||||
|
loadFiles41(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPmaToggle41() {
|
||||||
|
// 适配新版 UI:直接复用 index.html 的 chk-pma,不再重复创建
|
||||||
|
const chk = document.getElementById('chk-pma');
|
||||||
|
if (chk) {
|
||||||
|
// 绑定事件
|
||||||
|
chk.onchange = (e) => {
|
||||||
|
window.viewerConfig.pmaEnabled = e.target.checked;
|
||||||
|
if(renderer) {
|
||||||
|
renderer.premultipliedAlpha = e.target.checked;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 初始化
|
||||||
|
window.viewerConfig.pmaEnabled = chk.checked;
|
||||||
|
if(renderer) renderer.premultipliedAlpha = chk.checked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles41(files) {
|
||||||
|
let map = { atlas: null, png: null, main: null, type: 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.main = f; map.type = 'binary'; }
|
||||||
|
else if(name.endsWith('.json') || name.endsWith('.json.txt')) { map.main = f; map.type = 'json'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const atlasText = await readFileAsText(map.atlas);
|
||||||
|
const imgUrl = await readFileAsDataURL(map.png);
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async () => {
|
||||||
|
const texture = new spine.GLTexture(gl, img);
|
||||||
|
const spineAtlas = new spine.TextureAtlas(atlasText, (path) => texture);
|
||||||
|
|
||||||
|
for (let i = 0; i < spineAtlas.pages.length; i++) {
|
||||||
|
spineAtlas.pages[i].texture = texture;
|
||||||
|
spineAtlas.pages[i].width = img.width;
|
||||||
|
spineAtlas.pages[i].height = img.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
const atlasLoader = new spine.AtlasAttachmentLoader(spineAtlas);
|
||||||
|
let skeletonData;
|
||||||
|
|
||||||
|
if(map.type === 'binary') {
|
||||||
|
const buffer = await readFileAsArrayBuffer(map.main);
|
||||||
|
const skeletonBinary = new spine.SkeletonBinary(atlasLoader);
|
||||||
|
skeletonBinary.scale = 1.0;
|
||||||
|
skeletonData = skeletonBinary.readSkeletonData(new Uint8Array(buffer));
|
||||||
|
} else {
|
||||||
|
const text = await readFileAsText(map.main);
|
||||||
|
const skeletonJson = new spine.SkeletonJson(atlasLoader);
|
||||||
|
skeletonJson.scale = 1.0;
|
||||||
|
const jsonObj = JSON.parse(text);
|
||||||
|
if(jsonObj.skeleton) jsonObj.skeleton.images = "";
|
||||||
|
skeletonData = skeletonJson.readSkeletonData(jsonObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSkeleton41(skeletonData);
|
||||||
|
|
||||||
|
document.getElementById('ui').style.display = 'block';
|
||||||
|
document.getElementById('controls').style.display = 'flex';
|
||||||
|
document.getElementById('version-label').innerText = "Spine 4.1 (Binary/JSON)";
|
||||||
|
document.getElementById('version-label').style.color = "#00e5ff";
|
||||||
|
|
||||||
|
if(rendererRequestId) cancelAnimationFrame(rendererRequestId);
|
||||||
|
requestAnimationFrame(render41);
|
||||||
|
};
|
||||||
|
img.src = imgUrl;
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
alert("4.1 加载错误: " + e);
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSkeleton41(skeletonData) {
|
||||||
|
skeleton = new spine.Skeleton(skeletonData);
|
||||||
|
skeleton.setToSetupPose();
|
||||||
|
skeleton.updateWorldTransform();
|
||||||
|
skeleton.x = 0; skeleton.y = -500;
|
||||||
|
|
||||||
|
// 暴露实例供导出使用
|
||||||
|
window.spineInstance = { skeleton: skeleton, state: animationState, gl: gl, renderer: renderer };
|
||||||
|
|
||||||
|
renderer.camera.position.x = 0;
|
||||||
|
renderer.camera.position.y = 0;
|
||||||
|
renderer.camera.zoom = 1.0;
|
||||||
|
|
||||||
|
var stateData = new spine.AnimationStateData(skeleton.data);
|
||||||
|
animationState = new spine.AnimationState(stateData);
|
||||||
|
|
||||||
|
const animSelect = document.getElementById('anim-select');
|
||||||
|
animSelect.innerHTML = "";
|
||||||
|
skeleton.data.animations.forEach(a => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = a.name; opt.text = a.name; animSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
if(skeleton.data.animations.length > 0) animationState.setAnimation(0, skeleton.data.animations[0].name, true);
|
||||||
|
|
||||||
|
const skinSelect = document.getElementById('skin-select');
|
||||||
|
skinSelect.innerHTML = "";
|
||||||
|
skeleton.data.skins.forEach(s => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = s.name; opt.text = s.name; skinSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
if(skeleton.data.defaultSkin) skinSelect.value = skeleton.data.defaultSkin.name;
|
||||||
|
|
||||||
|
animSelect.onchange = () => animationState.setAnimation(0, animSelect.value, true);
|
||||||
|
skinSelect.onchange = () => { skeleton.setSkinByName(skinSelect.value); skeleton.setSlotsToSetupPose(); };
|
||||||
|
|
||||||
|
bindControls41();
|
||||||
|
updateControls41();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render41() {
|
||||||
|
// 将 requestId 存储到全局配置中
|
||||||
|
window.viewerConfig.animRequestId = requestAnimationFrame(render41);
|
||||||
|
|
||||||
|
// 检查 canvas 是否还存在于 DOM 中
|
||||||
|
if(!canvas.parentElement) {
|
||||||
|
cancelAnimationFrame(window.viewerConfig.animRequestId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Date.now();
|
||||||
|
const targetFps = window.viewerConfig.targetFps || 30;
|
||||||
|
const interval = 1000 / targetFps;
|
||||||
|
const elapsed = now - lastTime;
|
||||||
|
|
||||||
|
if (elapsed < interval) return;
|
||||||
|
|
||||||
|
lastTime = now - (elapsed % interval);
|
||||||
|
let delta = elapsed / 1000;
|
||||||
|
|
||||||
|
// 应用倍速
|
||||||
|
delta *= (window.viewerConfig.speed || 1.0);
|
||||||
|
|
||||||
|
// === 导出模式判断 ===
|
||||||
|
const isExporting = window.viewerConfig.isExporting;
|
||||||
|
|
||||||
|
if (!isExporting) {
|
||||||
|
let w = window.innerWidth;
|
||||||
|
let h = window.innerHeight;
|
||||||
|
if(canvas.width !== w || canvas.height !== h) {
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let w = canvas.width;
|
||||||
|
let h = canvas.height;
|
||||||
|
gl.viewport(0, 0, w, h);
|
||||||
|
|
||||||
|
if(renderer.camera.viewportWidth !== w || renderer.camera.viewportHeight !== h) {
|
||||||
|
renderer.camera.viewportWidth = w;
|
||||||
|
renderer.camera.viewportHeight = h;
|
||||||
|
renderer.camera.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExporting) {
|
||||||
|
gl.clearColor(0, 0, 0, 0); // 强制全透明
|
||||||
|
} else {
|
||||||
|
const bg = window.viewerConfig ? window.viewerConfig.bgColor : [0.2, 0.2, 0.2, 1];
|
||||||
|
gl.clearColor(bg[0], bg[1], bg[2], bg[3]);
|
||||||
|
}
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
// === 关键修复:同步 PMA 设置 ===
|
||||||
|
// 确保每一帧都从全局配置读取 PMA 状态,并应用到 renderer
|
||||||
|
if (renderer && window.viewerConfig) {
|
||||||
|
renderer.premultipliedAlpha = window.viewerConfig.pmaEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skeleton && animationState) {
|
||||||
|
const ctrl = window.animControl;
|
||||||
|
let track = animationState.getCurrent(0);
|
||||||
|
|
||||||
|
if (track) {
|
||||||
|
if (ctrl) ctrl.updateUI(track.trackTime, track.animation.duration);
|
||||||
|
if (ctrl && ctrl.isScrubbing && ctrl.targetTime >= 0) {
|
||||||
|
track.trackTime = ctrl.targetTime;
|
||||||
|
}
|
||||||
|
else if (ctrl && !ctrl.isPlaying) {
|
||||||
|
animationState.update(0);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
animationState.update(delta);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
animationState.update(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
animationState.apply(skeleton);
|
||||||
|
skeleton.updateWorldTransform();
|
||||||
|
renderer.begin();
|
||||||
|
renderer.drawSkeleton(skeleton, true);
|
||||||
|
renderer.end();
|
||||||
|
|
||||||
|
// === 绘制骨骼调试线 (导出时不绘制) ===
|
||||||
|
if(!isExporting) {
|
||||||
|
if(window.debugRenderer && window.viewerConfig.showBones) {
|
||||||
|
window.debugRenderer.drawBones(
|
||||||
|
skeleton,
|
||||||
|
renderer.camera.position.x,
|
||||||
|
renderer.camera.position.y,
|
||||||
|
renderer.camera.zoom,
|
||||||
|
h
|
||||||
|
);
|
||||||
|
} else if(window.debugRenderer) {
|
||||||
|
window.debugRenderer.drawBones(null, 0,0,1,0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateControls41() {
|
||||||
|
document.getElementById('posX').value = skeleton.x;
|
||||||
|
document.getElementById('posY').value = skeleton.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindControls41() {
|
||||||
|
const inputX = document.getElementById('posX');
|
||||||
|
const inputY = document.getElementById('posY');
|
||||||
|
inputX.oninput = () => { if(skeleton) skeleton.x = parseFloat(inputX.value); };
|
||||||
|
inputY.oninput = () => { if(skeleton) skeleton.y = parseFloat(inputY.value); };
|
||||||
|
|
||||||
|
window.resetView = function() {
|
||||||
|
skeleton.x = 0; skeleton.y = -500;
|
||||||
|
renderer.camera.position.x = 0; renderer.camera.position.y = 0; renderer.camera.zoom = 1.0;
|
||||||
|
updateControls41();
|
||||||
|
};
|
||||||
|
|
||||||
|
let isDragging=false, startX, startY, isRightDrag=false;
|
||||||
|
canvas.oncontextmenu = e => e.preventDefault();
|
||||||
|
canvas.onmousedown = e => {
|
||||||
|
if(e.button === 2) isRightDrag = true; else isDragging = true;
|
||||||
|
startX = e.clientX; startY = e.clientY;
|
||||||
|
};
|
||||||
|
window.onmouseup = () => { isDragging=false; isRightDrag=false; };
|
||||||
|
window.onmousemove = e => {
|
||||||
|
if(isDragging) {
|
||||||
|
renderer.camera.position.x -= (e.clientX - startX) * renderer.camera.zoom;
|
||||||
|
renderer.camera.position.y += (e.clientY - startY) * renderer.camera.zoom;
|
||||||
|
startX = e.clientX; startY = e.clientY;
|
||||||
|
} else if(isRightDrag) {
|
||||||
|
let dy = e.clientY - startY;
|
||||||
|
let newZoom = renderer.camera.zoom * (1 + dy * 0.01);
|
||||||
|
if(newZoom > 0.1 && newZoom < 10) renderer.camera.zoom = newZoom;
|
||||||
|
startY = e.clientY;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
canvas.onwheel = e => {
|
||||||
|
let newZoom = renderer.camera.zoom * (e.deltaY > 0 ? 1.1 : 0.9);
|
||||||
|
if(newZoom > 0.1 && newZoom < 10) renderer.camera.zoom = newZoom;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
// Spine 4.2 逻辑适配器 (基于 V30 + 全局配置 + Timeline + DebugDraw)
|
||||||
|
window.startSpine42 = async function(canvas, files) {
|
||||||
|
console.log("启动 Spine 4.2 引擎...");
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 动态增强 UI
|
||||||
|
// =============================================================
|
||||||
|
const uiPanel = document.getElementById('ui');
|
||||||
|
|
||||||
|
// 适配新版 UI:绑定现有的 PMA checkbox
|
||||||
|
const chkPma = document.getElementById('chk-pma');
|
||||||
|
if (chkPma) {
|
||||||
|
chkPma.onchange = (e) => {
|
||||||
|
// 4.2 可能需要特定的处理,或者只是重绘
|
||||||
|
// 这里假设 render loop 会读取 global pma setting
|
||||||
|
window.viewerConfig.pmaEnabled = e.target.checked;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 物理重置按钮保留,但样式微调
|
||||||
|
if (!document.getElementById('btn-reset-phy')) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = 'btn-reset-phy';
|
||||||
|
btn.innerText = "💥 重置物理";
|
||||||
|
btn.className = 'btn-export'; // 复用导出按钮的样式
|
||||||
|
btn.style.marginTop = '15px';
|
||||||
|
btn.style.background = '#880000'; // 特殊颜色
|
||||||
|
btn.onclick = () => resetPhysics();
|
||||||
|
uiPanel.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 核心变量
|
||||||
|
// =============================================================
|
||||||
|
const ui = {
|
||||||
|
log: (msg, type) => {
|
||||||
|
console.log(msg);
|
||||||
|
const box = document.getElementById('log');
|
||||||
|
if(box) box.innerText = msg;
|
||||||
|
},
|
||||||
|
updateSliders: () => {
|
||||||
|
const sx = document.getElementById('posX');
|
||||||
|
const sy = document.getElementById('posY');
|
||||||
|
if(sx) sx.value = cam.x;
|
||||||
|
if(sy) sy.value = cam.y;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Mat4 = {
|
||||||
|
create: () => new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]),
|
||||||
|
ortho: (out, l, r, b, t, n, f) => {
|
||||||
|
const lr = 1/(l-r), bt = 1/(b-t), nf = 1/(n-f);
|
||||||
|
out[0]=-2*lr; out[5]=-2*bt; out[10]=2*nf; out[12]=(l+r)*lr; out[13]=(t+b)*bt; out[14]=(f+n)*nf; out[15]=1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let gl, shader, batcher, renderer;
|
||||||
|
let skeleton, state, mvp = Mat4.create();
|
||||||
|
let lastTime = Date.now();
|
||||||
|
let cam = {x:0, y:0, zoom:1.0};
|
||||||
|
window.cam42 = cam; // 暴露给导出脚本使用
|
||||||
|
let globalTexture = null;
|
||||||
|
const Physics = { None: 0, Update: 1, Reset: 2 };
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 初始化 WebGL
|
||||||
|
// =============================================================
|
||||||
|
try {
|
||||||
|
// 关键修复:添加 premultipliedAlpha: false,解决半透明黑边问题
|
||||||
|
gl = canvas.getContext('webgl', { alpha: true, preserveDrawingBuffer: true, premultipliedAlpha: false });
|
||||||
|
if(!gl) throw "WebGL 不可用";
|
||||||
|
|
||||||
|
// 确保 WebGL 不自动预乘 (因为 PMA 图片本身已经是预乘过的)
|
||||||
|
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
|
||||||
|
|
||||||
|
shader = spine.Shader.newTwoColoredTextured(gl);
|
||||||
|
batcher = new spine.PolygonBatcher(gl, true);
|
||||||
|
renderer = new spine.SkeletonRenderer(gl);
|
||||||
|
renderer.twoColor = true;
|
||||||
|
renderer.premultipliedAlpha = true;
|
||||||
|
|
||||||
|
bindInput();
|
||||||
|
|
||||||
|
// 开始处理文件
|
||||||
|
handleFiles(files);
|
||||||
|
|
||||||
|
// 启动循环
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
|
||||||
|
} catch(e) { ui.log("初始化失败: " + e, 'err'); }
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 输入绑定
|
||||||
|
// =============================================================
|
||||||
|
function bindInput() {
|
||||||
|
// 鼠标/滚轮控制
|
||||||
|
canvas.onwheel = e => {
|
||||||
|
cam.zoom *= (e.deltaY>0 ? 0.9 : 1.1);
|
||||||
|
if(cam.zoom < 0.1) cam.zoom = 0.1;
|
||||||
|
if(cam.zoom > 10) cam.zoom = 10;
|
||||||
|
};
|
||||||
|
|
||||||
|
let drag=false, lx, ly, isRight=false;
|
||||||
|
|
||||||
|
canvas.oncontextmenu = e => e.preventDefault();
|
||||||
|
canvas.onmousedown = e => {
|
||||||
|
drag=true;
|
||||||
|
isRight = (e.button === 2);
|
||||||
|
lx=e.clientX; ly=e.clientY;
|
||||||
|
};
|
||||||
|
window.onmouseup = () => drag=false;
|
||||||
|
|
||||||
|
window.onmousemove = e => {
|
||||||
|
if(drag) {
|
||||||
|
let dx = e.clientX - lx;
|
||||||
|
let dy = e.clientY - ly;
|
||||||
|
|
||||||
|
if(isRight) {
|
||||||
|
let newZoom = cam.zoom * (1 + dy * 0.005);
|
||||||
|
if(newZoom > 0.1 && newZoom < 10) cam.zoom = newZoom;
|
||||||
|
} else {
|
||||||
|
cam.x -= dx / cam.zoom;
|
||||||
|
cam.y += dy / cam.zoom;
|
||||||
|
|
||||||
|
// 物理惯性
|
||||||
|
if (skeleton) {
|
||||||
|
skeleton.x += dx / cam.zoom;
|
||||||
|
skeleton.y -= dy / cam.zoom;
|
||||||
|
}
|
||||||
|
ui.updateSliders();
|
||||||
|
}
|
||||||
|
lx = e.clientX; ly = e.clientY;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sx = document.getElementById('posX');
|
||||||
|
const sy = document.getElementById('posY');
|
||||||
|
if(sx) sx.oninput = () => cam.x = parseFloat(sx.value);
|
||||||
|
if(sy) sy.oninput = () => cam.y = parseFloat(sy.value);
|
||||||
|
|
||||||
|
// 绑定全局归位函数
|
||||||
|
window.resetView = function() {
|
||||||
|
if(skeleton) {
|
||||||
|
// 4.2 需要同时重置摄像机和骨骼位置(如果有物理惯性)
|
||||||
|
cam.x = 0;
|
||||||
|
cam.y = -200; // 4.2 默认稍微向下偏移一点
|
||||||
|
cam.zoom = 1.0;
|
||||||
|
|
||||||
|
// 重置滑块
|
||||||
|
if(sx) sx.value = 0;
|
||||||
|
if(sy) sy.value = -200;
|
||||||
|
|
||||||
|
ui.log("视图已重置");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPhysics() {
|
||||||
|
if(skeleton && spine.Physics) {
|
||||||
|
skeleton.updateWorldTransform(spine.Physics.reset);
|
||||||
|
ui.log("物理已重置");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 文件处理
|
||||||
|
// =============================================================
|
||||||
|
async function handleFiles(fileList) {
|
||||||
|
let map = {};
|
||||||
|
for(let f of fileList) {
|
||||||
|
let n = f.name.toLowerCase();
|
||||||
|
if(n.includes('.atlas')) map.atlas = f;
|
||||||
|
else if(n.includes('.png') || n.includes('.jpg') || n.includes('.webp')) map.png = f;
|
||||||
|
else if(n.includes('.skel') || n.includes('.json')) map.main = f;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!map.atlas || !map.png || !map.main) {
|
||||||
|
ui.log("文件缺失: 需要 .json/.skel + .atlas + .png", 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imgBlob = URL.createObjectURL(map.png);
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => processSpine(map, img);
|
||||||
|
img.src = imgBlob;
|
||||||
|
} catch(e) { ui.log(e, 'err'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processSpine(map, img) {
|
||||||
|
try {
|
||||||
|
ui.log("正在加载 (Spine 4.2)...");
|
||||||
|
const atlasText = await readFile(map.atlas, 'text');
|
||||||
|
|
||||||
|
globalTexture = new spine.GLTexture(gl, img, false);
|
||||||
|
globalTexture.setFilters(gl.LINEAR, gl.LINEAR);
|
||||||
|
globalTexture.setWraps(gl.CLAMP_TO_EDGE, gl.CLAMP_TO_EDGE);
|
||||||
|
|
||||||
|
const atlas = new spine.TextureAtlas(atlasText, path => globalTexture);
|
||||||
|
// 强制修正 Atlas 尺寸
|
||||||
|
for(let page of atlas.pages) { page.width = img.width; page.height = img.height; }
|
||||||
|
|
||||||
|
const loader = new spine.AtlasAttachmentLoader(atlas);
|
||||||
|
let skelData;
|
||||||
|
|
||||||
|
if(map.main.name.includes('.skel')) {
|
||||||
|
ui.log("解析二进制文件...");
|
||||||
|
const buffer = await readFile(map.main, 'array');
|
||||||
|
skelData = new spine.SkeletonBinary(loader).readSkeletonData(new Uint8Array(buffer));
|
||||||
|
} else {
|
||||||
|
ui.log("解析 JSON 文件...");
|
||||||
|
const text = await readFile(map.main, 'text');
|
||||||
|
skelData = new spine.SkeletonJson(loader).readSkeletonData(JSON.parse(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.log(`版本: ${skelData.version}`);
|
||||||
|
|
||||||
|
skeleton = new spine.Skeleton(skelData);
|
||||||
|
window.skeleton = skeleton; // 暴露给导出脚本
|
||||||
|
|
||||||
|
// 自动注入纹理
|
||||||
|
let fixCount = 0;
|
||||||
|
for(let skin of skeleton.data.skins) {
|
||||||
|
let entries = skin.getAttachments ? skin.getAttachments() : skin.attachments;
|
||||||
|
for(let entry of entries) {
|
||||||
|
let att = entry ? entry.attachment : null;
|
||||||
|
if(att && att.region) {
|
||||||
|
att.region.texture = globalTexture;
|
||||||
|
att.region.renderObject = att.region;
|
||||||
|
fixCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
skeleton.setToSetupPose();
|
||||||
|
if(spine.Physics) skeleton.updateWorldTransform(spine.Physics.reset);
|
||||||
|
|
||||||
|
state = new spine.AnimationState(new spine.AnimationStateData(skelData));
|
||||||
|
|
||||||
|
setupUI(skelData);
|
||||||
|
|
||||||
|
// 预热两帧
|
||||||
|
state.update(0); state.apply(skeleton); skeleton.updateWorldTransform(spine.Physics.update);
|
||||||
|
state.update(0); state.apply(skeleton); skeleton.updateWorldTransform(spine.Physics.update);
|
||||||
|
|
||||||
|
// 自动对焦
|
||||||
|
const offset = new spine.Vector2(), size = new spine.Vector2();
|
||||||
|
skeleton.getBounds(offset, size, []);
|
||||||
|
if(size.x > 0) {
|
||||||
|
cam.x = offset.x + size.x/2;
|
||||||
|
cam.y = offset.y + size.y/2;
|
||||||
|
cam.zoom = Math.min(canvas.width/size.x, canvas.height/size.y) * 0.8;
|
||||||
|
} else {
|
||||||
|
cam.x = 0; cam.y = -200;
|
||||||
|
}
|
||||||
|
ui.updateSliders();
|
||||||
|
|
||||||
|
// 显示UI
|
||||||
|
document.getElementById('ui').style.display = 'block';
|
||||||
|
document.getElementById('controls').style.display = 'flex';
|
||||||
|
|
||||||
|
} catch(e) { ui.log(e.message, 'err'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupUI(data) {
|
||||||
|
const animSelect = document.getElementById('anim-select');
|
||||||
|
const skinSelect = document.getElementById('skin-select');
|
||||||
|
if(!animSelect || !skinSelect) return;
|
||||||
|
|
||||||
|
animSelect.innerHTML = "";
|
||||||
|
skinSelect.innerHTML = "";
|
||||||
|
|
||||||
|
data.animations.forEach(a => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = a.name; opt.text = a.name; animSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
if(data.animations.length > 0) {
|
||||||
|
state.setAnimation(0, data.animations[0].name, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
data.skins.forEach(s => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = s.name; opt.text = s.name; skinSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
if(data.defaultSkin) skinSelect.value = data.defaultSkin.name;
|
||||||
|
|
||||||
|
animSelect.onchange = () => state.setAnimation(0, animSelect.value, true);
|
||||||
|
skinSelect.onchange = () => {
|
||||||
|
skeleton.setSkinByName(skinSelect.value);
|
||||||
|
skeleton.setSlotsToSetupPose();
|
||||||
|
skeleton.setToSetupPose();
|
||||||
|
if(spine.Physics) skeleton.updateWorldTransform(spine.Physics.reset);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFile(f, type) {
|
||||||
|
return new Promise(r => {
|
||||||
|
const fr = new FileReader();
|
||||||
|
fr.onload = () => r(fr.result);
|
||||||
|
if(type==='text') fr.readAsText(f); else fr.readAsArrayBuffer(f);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 渲染循环
|
||||||
|
// =============================================================
|
||||||
|
function loop() {
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const targetFps = window.viewerConfig.targetFps || 30;
|
||||||
|
const interval = 1000 / targetFps;
|
||||||
|
const elapsed = now - lastTime;
|
||||||
|
|
||||||
|
if (elapsed < interval) return;
|
||||||
|
|
||||||
|
lastTime = now - (elapsed % interval);
|
||||||
|
let dt = elapsed / 1000;
|
||||||
|
|
||||||
|
// 应用倍速
|
||||||
|
const speed = window.viewerConfig.speed || 1.0;
|
||||||
|
dt *= speed;
|
||||||
|
|
||||||
|
// === 导出模式判断 ===
|
||||||
|
const isExporting = window.viewerConfig.isExporting;
|
||||||
|
|
||||||
|
if (!isExporting) {
|
||||||
|
if(canvas.width !== window.innerWidth) { canvas.width = window.innerWidth; canvas.height = window.innerHeight; }
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
if (isExporting) {
|
||||||
|
gl.clearColor(0, 0, 0, 0); // 强制全透明
|
||||||
|
} else {
|
||||||
|
const bg = window.viewerConfig ? window.viewerConfig.bgColor : [0.2, 0.2, 0.2, 1];
|
||||||
|
gl.clearColor(bg[0], bg[1], bg[2], bg[3]);
|
||||||
|
}
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
if(!skeleton || !state) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// === 时间轴核心控制 ===
|
||||||
|
const ctrl = window.animControl;
|
||||||
|
let track = state.getCurrent(0);
|
||||||
|
|
||||||
|
if (track) {
|
||||||
|
if (ctrl) ctrl.updateUI(track.trackTime, track.animation.duration);
|
||||||
|
if (ctrl && ctrl.isScrubbing && ctrl.targetTime >= 0) {
|
||||||
|
track.trackTime = ctrl.targetTime;
|
||||||
|
}
|
||||||
|
else if (ctrl && !ctrl.isPlaying) {
|
||||||
|
if (skeleton.update) skeleton.update(0);
|
||||||
|
state.update(0);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
state.update(dt);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.update(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.apply(skeleton);
|
||||||
|
|
||||||
|
// 物理更新逻辑
|
||||||
|
if(skeleton.update && (!ctrl || (ctrl.isPlaying && !ctrl.isScrubbing))) {
|
||||||
|
skeleton.update(dt);
|
||||||
|
}
|
||||||
|
if(spine.Physics) skeleton.updateWorldTransform(spine.Physics.update);
|
||||||
|
else skeleton.updateWorldTransform(1);
|
||||||
|
|
||||||
|
if(!Number.isFinite(cam.zoom)) cam.zoom=0.1;
|
||||||
|
const w = canvas.width, h = canvas.height;
|
||||||
|
Mat4.ortho(mvp, cam.x - w/cam.zoom/2, cam.x + w/cam.zoom/2, cam.y - h/cam.zoom/2, cam.y + h/cam.zoom/2, -2000, 2000);
|
||||||
|
|
||||||
|
shader.bind();
|
||||||
|
shader.setUniform4x4f(spine.Shader.MVP_MATRIX, mvp);
|
||||||
|
shader.setUniformi(spine.Shader.SAMPLER, 0);
|
||||||
|
|
||||||
|
// 从全局配置读取 PMA
|
||||||
|
const pma = window.viewerConfig ? window.viewerConfig.pmaEnabled : true;
|
||||||
|
|
||||||
|
gl.enable(gl.BLEND);
|
||||||
|
gl.blendFunc(pma ? gl.ONE : gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||||
|
|
||||||
|
batcher.begin(shader);
|
||||||
|
renderer.draw(batcher, skeleton);
|
||||||
|
batcher.end();
|
||||||
|
shader.unbind();
|
||||||
|
|
||||||
|
// === 绘制骨骼调试线 (导出时不绘制) ===
|
||||||
|
if(!isExporting) {
|
||||||
|
if(window.debugRenderer && window.viewerConfig.showBones) {
|
||||||
|
window.debugRenderer.drawBones(
|
||||||
|
skeleton,
|
||||||
|
cam.x,
|
||||||
|
cam.y,
|
||||||
|
cam.zoom,
|
||||||
|
h
|
||||||
|
);
|
||||||
|
} else if(window.debugRenderer) {
|
||||||
|
window.debugRenderer.drawBones(null, 0,0,1,0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch(e) { }
|
||||||
|
}
|
||||||
|
};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,302 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Spine 3.8 Core</title>
|
||||||
|
<script src="spine-webgl-3.8.js"></script>
|
||||||
|
<style>
|
||||||
|
body, html { margin: 0; padding: 0; width: 100%; height: 100%; background: transparent; overflow: hidden; color:#eee; font-family: sans-serif;}
|
||||||
|
canvas { display: block; width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
#ui { position: absolute; top: 20px; left: 20px; background: rgba(0,0,0,0.7); padding: 20px; border-radius: 8px; display: none; min-width: 200px; z-index: 10;}
|
||||||
|
#controls { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.8); padding: 15px; border-radius: 10px; color: white; display: none; gap: 30px; z-index: 10; align-items: center;}
|
||||||
|
.control-group { display: flex; flex-direction: column; align-items: center; gap: 5px; }
|
||||||
|
input[type=range] { width: 200px; cursor: pointer; }
|
||||||
|
select { background: #444; color: white; border: 1px solid #666; padding: 5px; border-radius: 4px; width: 100%; margin-top: 5px;}
|
||||||
|
button { cursor: pointer; border:none; border-radius:4px; padding:5px 10px;}
|
||||||
|
|
||||||
|
/* 加载提示 */
|
||||||
|
#loading { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 24px; color: #00e5ff; background: rgba(0,0,0,0.8); padding: 20px 40px; border-radius: 10px; z-index: 100; pointer-events: none; text-align: center; }
|
||||||
|
#error-box { position: fixed; top: 10%; left: 10%; right: 10%; bottom: 10%; background: rgba(50,0,0,0.95); color: #ff5555; padding: 20px; border: 2px solid red; overflow: auto; display: none; z-index: 200; font-family: monospace; white-space: pre-wrap; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="loading">⏳ 初始化内核...</div>
|
||||||
|
<div id="error-box"></div>
|
||||||
|
|
||||||
|
<div id="ui">
|
||||||
|
<div style="font-weight:bold; color:#ff0055; margin-bottom:10px;">SPINE 3.8 DETECTED</div>
|
||||||
|
<div style="margin-bottom:10px;">
|
||||||
|
<label>动画:</label> <select id="anim-select"></select>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:10px;">
|
||||||
|
<label>皮肤:</label> <select id="skin-select"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="controls">
|
||||||
|
<div class="control-group">
|
||||||
|
<label>X</label> <input type="range" id="posX" min="-2000" max="2000" value="0" step="10">
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label>Y</label> <input type="range" id="posY" min="-2000" max="2000" value="0" step="10">
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<button onclick="resetView()" style="background:#555; color:white;">重置</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<canvas id="canvas"></canvas>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 全局错误捕捉
|
||||||
|
window.onerror = function(msg, url, line, col, error) {
|
||||||
|
showError(`运行错误:\n${msg}\nLine: ${line}`);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
function showStatus(msg) {
|
||||||
|
document.getElementById('loading').innerText = msg;
|
||||||
|
document.getElementById('loading').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideStatus() {
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
const box = document.getElementById('error-box');
|
||||||
|
box.style.display = 'block';
|
||||||
|
box.innerText += msg + "\n----------------\n";
|
||||||
|
hideStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
var canvas, gl, shader, batcher, mvp, skeletonRenderer;
|
||||||
|
var skeleton, animationState;
|
||||||
|
var lastTime = Date.now();
|
||||||
|
var camX=0, camY=0, camZoom=1.0;
|
||||||
|
|
||||||
|
window.onload = function() {
|
||||||
|
try {
|
||||||
|
if (typeof spine === 'undefined') {
|
||||||
|
throw new Error("spine-webgl-3.8.js 未加载!\n请检查 Spine_Universal 文件夹下是否存在该文件。");
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
showStatus("正在连接主页面...");
|
||||||
|
|
||||||
|
if (window.parent && window.parent.spineSharedData) {
|
||||||
|
loadFromSharedData(window.parent.spineSharedData);
|
||||||
|
} else {
|
||||||
|
// 稍微延迟一下再次尝试,防止加载时序问题
|
||||||
|
setTimeout(() => {
|
||||||
|
if (window.parent && window.parent.spineSharedData) {
|
||||||
|
loadFromSharedData(window.parent.spineSharedData);
|
||||||
|
} else {
|
||||||
|
showError("无法获取文件数据!\n原因可能是浏览器限制了本地 iframe 访问。\n请尝试使用 Chrome 并在启动参数添加 --allow-file-access-from-files (不推荐),或者这是第一次加载太慢。");
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
showError(e.toString());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
canvas = document.getElementById("canvas");
|
||||||
|
gl = canvas.getContext("webgl", { alpha: false });
|
||||||
|
|
||||||
|
shader = spine.webgl.Shader.newTwoColoredTextured(gl);
|
||||||
|
batcher = new spine.webgl.PolygonBatcher(gl);
|
||||||
|
mvp = new spine.webgl.Matrix4();
|
||||||
|
skeletonRenderer = new spine.webgl.SkeletonRenderer(gl);
|
||||||
|
|
||||||
|
requestAnimationFrame(render);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 辅助函数
|
||||||
|
function readFileAsText(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const fr = new FileReader();
|
||||||
|
fr.onload = () => resolve(fr.result);
|
||||||
|
fr.onerror = () => reject("读取文本失败");
|
||||||
|
fr.readAsText(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function readFileAsArrayBuffer(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const fr = new FileReader();
|
||||||
|
fr.onload = () => resolve(fr.result);
|
||||||
|
fr.onerror = () => reject("读取二进制失败");
|
||||||
|
fr.readAsArrayBuffer(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function readFileAsDataURL(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const fr = new FileReader();
|
||||||
|
fr.onload = () => resolve(fr.result);
|
||||||
|
fr.onerror = () => reject("读取图片失败");
|
||||||
|
fr.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFromSharedData(data) {
|
||||||
|
try {
|
||||||
|
const { atlas, png, main, type } = data;
|
||||||
|
|
||||||
|
showStatus("正在读取 Atlas...");
|
||||||
|
const atlasText = await readFileAsText(atlas);
|
||||||
|
|
||||||
|
showStatus("正在读取图片...");
|
||||||
|
const imgUrl = await readFileAsDataURL(png);
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async () => {
|
||||||
|
try {
|
||||||
|
showStatus("构建纹理...");
|
||||||
|
const texture = new spine.webgl.GLTexture(gl, img);
|
||||||
|
const spineAtlas = new spine.TextureAtlas(atlasText, (path) => texture);
|
||||||
|
const atlasLoader = new spine.AtlasAttachmentLoader(spineAtlas);
|
||||||
|
|
||||||
|
let skeletonData;
|
||||||
|
|
||||||
|
if(type === 'binary') {
|
||||||
|
showStatus("解析二进制骨架...");
|
||||||
|
const buffer = await readFileAsArrayBuffer(main);
|
||||||
|
const skeletonBinary = new spine.SkeletonBinary(atlasLoader);
|
||||||
|
skeletonBinary.scale = 1.0;
|
||||||
|
skeletonData = skeletonBinary.readSkeletonData(new Uint8Array(buffer));
|
||||||
|
} else {
|
||||||
|
showStatus("解析 JSON 骨架...");
|
||||||
|
const text = await readFileAsText(main);
|
||||||
|
const skeletonJson = new spine.SkeletonJson(atlasLoader);
|
||||||
|
skeletonJson.scale = 1.0;
|
||||||
|
skeletonData = skeletonJson.readSkeletonData(JSON.parse(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
showStatus("初始化渲染器...");
|
||||||
|
setupSkeleton(skeletonData);
|
||||||
|
hideStatus();
|
||||||
|
|
||||||
|
document.getElementById('ui').style.display = 'block';
|
||||||
|
document.getElementById('controls').style.display = 'flex';
|
||||||
|
} catch(err) {
|
||||||
|
showError("解析过程错误:\n" + err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onerror = () => showError("图片加载失败!");
|
||||||
|
img.src = imgUrl;
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
showError("数据加载错误:\n" + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSkeleton(skeletonData) {
|
||||||
|
skeleton = new spine.Skeleton(skeletonData);
|
||||||
|
skeleton.setToSetupPose();
|
||||||
|
skeleton.updateWorldTransform();
|
||||||
|
skeleton.x = 0; skeleton.y = 0;
|
||||||
|
|
||||||
|
var stateData = new spine.AnimationStateData(skeleton.data);
|
||||||
|
animationState = new spine.AnimationState(stateData);
|
||||||
|
|
||||||
|
const animSelect = document.getElementById('anim-select');
|
||||||
|
animSelect.innerHTML = "";
|
||||||
|
skeleton.data.animations.forEach(a => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = a.name; opt.text = a.name; animSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
if(skeleton.data.animations.length > 0) animationState.setAnimation(0, skeleton.data.animations[0].name, true);
|
||||||
|
|
||||||
|
const skinSelect = document.getElementById('skin-select');
|
||||||
|
skinSelect.innerHTML = "";
|
||||||
|
skeleton.data.skins.forEach(s => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = s.name; opt.text = s.name; skinSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
if(skeleton.data.defaultSkin) skinSelect.value = skeleton.data.defaultSkin.name;
|
||||||
|
|
||||||
|
animSelect.onchange = () => animationState.setAnimation(0, animSelect.value, true);
|
||||||
|
skinSelect.onchange = () => { skeleton.setSkinByName(skinSelect.value); skeleton.setSlotsToSetupPose(); };
|
||||||
|
|
||||||
|
updateControls();
|
||||||
|
bindControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateControls() {
|
||||||
|
if(!skeleton) return;
|
||||||
|
document.getElementById('posX').value = skeleton.x;
|
||||||
|
document.getElementById('posY').value = skeleton.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetView() {
|
||||||
|
if(!skeleton) return;
|
||||||
|
skeleton.x = 0; skeleton.y = 0;
|
||||||
|
camX = 0; camY = 0; camZoom = 1.0;
|
||||||
|
updateControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindControls() {
|
||||||
|
const inputX = document.getElementById('posX');
|
||||||
|
const inputY = document.getElementById('posY');
|
||||||
|
inputX.oninput = () => { if(skeleton) skeleton.x = parseFloat(inputX.value); };
|
||||||
|
inputY.oninput = () => { if(skeleton) skeleton.y = parseFloat(inputY.value); };
|
||||||
|
|
||||||
|
let isDragging=false, startX, startY, isRightDrag=false;
|
||||||
|
canvas.oncontextmenu = e => e.preventDefault();
|
||||||
|
canvas.onmousedown = e => {
|
||||||
|
if(e.button === 2) isRightDrag = true; else isDragging = true;
|
||||||
|
startX = e.clientX; startY = e.clientY;
|
||||||
|
};
|
||||||
|
window.onmouseup = () => { isDragging=false; isRightDrag=false; };
|
||||||
|
window.onmousemove = e => {
|
||||||
|
if(isDragging) {
|
||||||
|
camX -= (e.clientX - startX) / camZoom;
|
||||||
|
camY += (e.clientY - startY) / camZoom;
|
||||||
|
startX = e.clientX; startY = e.clientY;
|
||||||
|
} else if(isRightDrag) {
|
||||||
|
let dy = e.clientY - startY;
|
||||||
|
let newZoom = camZoom * (1 + dy * 0.01);
|
||||||
|
if(newZoom > 0.1 && newZoom < 10) camZoom = newZoom;
|
||||||
|
startY = e.clientY;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
canvas.onwheel = e => {
|
||||||
|
let newZoom = camZoom * (e.deltaY > 0 ? 0.9 : 1.1);
|
||||||
|
if(newZoom > 0.1 && newZoom < 10) camZoom = newZoom;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
let now = Date.now();
|
||||||
|
let delta = (now - lastTime) / 1000;
|
||||||
|
lastTime = now;
|
||||||
|
let w = window.innerWidth, h = window.innerHeight;
|
||||||
|
if (canvas.width != w || canvas.height != h) { canvas.width = w; canvas.height = h; }
|
||||||
|
gl.viewport(0, 0, w, h);
|
||||||
|
gl.clearColor(0.2, 0.2, 0.2, 1);
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
if (skeleton && animationState) {
|
||||||
|
animationState.update(delta);
|
||||||
|
animationState.apply(skeleton);
|
||||||
|
skeleton.updateWorldTransform();
|
||||||
|
|
||||||
|
shader.bind();
|
||||||
|
mvp.ortho2d(camX - w/2/camZoom, camY - h/2/camZoom, w/camZoom, h/camZoom);
|
||||||
|
shader.setUniform4x4f(spine.webgl.Shader.MVP_MATRIX, mvp.values);
|
||||||
|
shader.setUniformi(spine.webgl.Shader.SAMPLER, 0);
|
||||||
|
gl.enable(gl.BLEND);
|
||||||
|
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
||||||
|
batcher.begin(shader);
|
||||||
|
skeletonRenderer.draw(batcher, skeleton);
|
||||||
|
batcher.end();
|
||||||
|
shader.unbind();
|
||||||
|
}
|
||||||
|
requestAnimationFrame(render);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Spine 4.1 Core</title>
|
||||||
|
<script src="spine-webgl.js"></script>
|
||||||
|
<style>
|
||||||
|
body, html { margin: 0; padding: 0; width: 100%; height: 100%; background: transparent; overflow: hidden; color:#eee; font-family: sans-serif;}
|
||||||
|
canvas { display: block; width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
#ui { position: absolute; top: 20px; right: 20px; background: rgba(0,0,0,0.7); padding: 20px; border-radius: 8px; display: none; min-width: 200px; z-index: 10;}
|
||||||
|
#controls { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.8); padding: 15px; border-radius: 10px; color: white; display: none; gap: 30px; z-index: 10; align-items: center;}
|
||||||
|
.control-group { display: flex; flex-direction: column; align-items: center; gap: 5px; }
|
||||||
|
input[type=range] { width: 200px; cursor: pointer; }
|
||||||
|
select { background: #444; color: white; border: 1px solid #666; padding: 5px; border-radius: 4px; width: 100%; margin-top: 5px;}
|
||||||
|
button { cursor: pointer; border:none; border-radius:4px; padding:5px 10px;}
|
||||||
|
|
||||||
|
/* 加载提示 */
|
||||||
|
#loading { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 24px; color: #00e5ff; background: rgba(0,0,0,0.8); padding: 20px 40px; border-radius: 10px; z-index: 100; pointer-events: none; text-align: center; }
|
||||||
|
#error-box { position: fixed; top: 10%; left: 10%; right: 10%; bottom: 10%; background: rgba(50,0,0,0.95); color: #ff5555; padding: 20px; border: 2px solid red; overflow: auto; display: none; z-index: 200; font-family: monospace; white-space: pre-wrap; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="loading">⏳ 初始化内核...</div>
|
||||||
|
<div id="error-box"></div>
|
||||||
|
|
||||||
|
<div id="ui">
|
||||||
|
<div style="font-weight:bold; color:#00e5ff; margin-bottom:10px;">SPINE 4.1 DETECTED</div>
|
||||||
|
<div style="margin-bottom:10px;">
|
||||||
|
<label>动画:</label> <select id="anim-select"></select>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:10px;">
|
||||||
|
<label>皮肤:</label> <select id="skin-select"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="controls">
|
||||||
|
<div class="control-group">
|
||||||
|
<label>X</label> <input type="range" id="posX" min="-2000" max="2000" value="0" step="10">
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label>Y</label> <input type="range" id="posY" min="-2000" max="2000" value="-500" step="10">
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<button onclick="resetView()" style="background:#555; color:white;">重置</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<canvas id="canvas"></canvas>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.onerror = function(msg, url, line, col, error) {
|
||||||
|
showError(`运行错误:\n${msg}\nLine: ${line}`);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
function showStatus(msg) {
|
||||||
|
document.getElementById('loading').innerText = msg;
|
||||||
|
document.getElementById('loading').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideStatus() {
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
const box = document.getElementById('error-box');
|
||||||
|
box.style.display = 'block';
|
||||||
|
box.innerText += msg + "\n----------------\n";
|
||||||
|
hideStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
var canvas, gl, renderer, assetManager, skeleton, animationState;
|
||||||
|
var lastTime = Date.now();
|
||||||
|
|
||||||
|
window.onload = function() {
|
||||||
|
try {
|
||||||
|
if (typeof spine === 'undefined') {
|
||||||
|
throw new Error("spine-webgl.js (4.1) 未加载!\n请检查 Spine_Universal 文件夹下是否存在该文件。");
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
showStatus("正在连接主页面...");
|
||||||
|
|
||||||
|
if (window.parent && window.parent.spineSharedData) {
|
||||||
|
loadFromSharedData(window.parent.spineSharedData);
|
||||||
|
} else {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (window.parent && window.parent.spineSharedData) {
|
||||||
|
loadFromSharedData(window.parent.spineSharedData);
|
||||||
|
} else {
|
||||||
|
showError("无法获取文件数据!\n原因可能是浏览器限制了本地 iframe 访问。\n请尝试使用 Chrome 并在启动参数添加 --allow-file-access-from-files (不推荐),或者这是第一次加载太慢。");
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
showError(e.toString());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
canvas = document.getElementById("canvas");
|
||||||
|
gl = canvas.getContext("webgl", { alpha: false });
|
||||||
|
renderer = new spine.SceneRenderer(canvas, gl);
|
||||||
|
assetManager = new spine.AssetManager(gl);
|
||||||
|
requestAnimationFrame(render);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileAsText(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const fr = new FileReader();
|
||||||
|
fr.onload = () => resolve(fr.result);
|
||||||
|
fr.onerror = () => reject("读取文本失败");
|
||||||
|
fr.readAsText(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function readFileAsArrayBuffer(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const fr = new FileReader();
|
||||||
|
fr.onload = () => resolve(fr.result);
|
||||||
|
fr.onerror = () => reject("读取二进制失败");
|
||||||
|
fr.readAsArrayBuffer(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function readFileAsDataURL(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const fr = new FileReader();
|
||||||
|
fr.onload = () => resolve(fr.result);
|
||||||
|
fr.onerror = () => reject("读取图片失败");
|
||||||
|
fr.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFromSharedData(data) {
|
||||||
|
try {
|
||||||
|
const { atlas, png, main, type } = data;
|
||||||
|
|
||||||
|
showStatus("正在读取 Atlas...");
|
||||||
|
const atlasText = await readFileAsText(atlas);
|
||||||
|
|
||||||
|
showStatus("正在读取图片...");
|
||||||
|
const imgUrl = await readFileAsDataURL(png);
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async () => {
|
||||||
|
try {
|
||||||
|
showStatus("构建纹理...");
|
||||||
|
const texture = new spine.GLTexture(gl, img);
|
||||||
|
const spineAtlas = new spine.TextureAtlas(atlasText, (path) => texture);
|
||||||
|
|
||||||
|
for (let i = 0; i < spineAtlas.pages.length; i++) {
|
||||||
|
spineAtlas.pages[i].texture = texture;
|
||||||
|
spineAtlas.pages[i].width = img.width;
|
||||||
|
spineAtlas.pages[i].height = img.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
const atlasLoader = new spine.AtlasAttachmentLoader(spineAtlas);
|
||||||
|
let skeletonData;
|
||||||
|
|
||||||
|
if(type === 'binary') {
|
||||||
|
showStatus("解析二进制骨架...");
|
||||||
|
const buffer = await readFileAsArrayBuffer(main);
|
||||||
|
const skeletonBinary = new spine.SkeletonBinary(atlasLoader);
|
||||||
|
skeletonBinary.scale = 1.0;
|
||||||
|
skeletonData = skeletonBinary.readSkeletonData(new Uint8Array(buffer));
|
||||||
|
} else {
|
||||||
|
showStatus("解析 JSON 骨架...");
|
||||||
|
const text = await readFileAsText(main);
|
||||||
|
const skeletonJson = new spine.SkeletonJson(atlasLoader);
|
||||||
|
skeletonJson.scale = 1.0;
|
||||||
|
const jsonObj = JSON.parse(text);
|
||||||
|
if(jsonObj.skeleton) jsonObj.skeleton.images = "";
|
||||||
|
skeletonData = skeletonJson.readSkeletonData(jsonObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
showStatus("初始化渲染器...");
|
||||||
|
setupSkeleton(skeletonData);
|
||||||
|
hideStatus();
|
||||||
|
|
||||||
|
document.getElementById('ui').style.display = 'block';
|
||||||
|
document.getElementById('controls').style.display = 'flex';
|
||||||
|
} catch(err) {
|
||||||
|
showError("解析过程错误:\n" + err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onerror = () => showError("图片加载失败!");
|
||||||
|
img.src = imgUrl;
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
showError("数据加载错误:\n" + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSkeleton(skeletonData) {
|
||||||
|
skeleton = new spine.Skeleton(skeletonData);
|
||||||
|
skeleton.setToSetupPose();
|
||||||
|
skeleton.updateWorldTransform();
|
||||||
|
skeleton.x = 0; skeleton.y = -500;
|
||||||
|
|
||||||
|
renderer.camera.position.x = 0;
|
||||||
|
renderer.camera.position.y = 0;
|
||||||
|
renderer.camera.zoom = 1.0;
|
||||||
|
|
||||||
|
var stateData = new spine.AnimationStateData(skeleton.data);
|
||||||
|
animationState = new spine.AnimationState(stateData);
|
||||||
|
|
||||||
|
const animSelect = document.getElementById('anim-select');
|
||||||
|
animSelect.innerHTML = "";
|
||||||
|
skeleton.data.animations.forEach(a => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = a.name; opt.text = a.name; animSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
if(skeleton.data.animations.length > 0) animationState.setAnimation(0, skeleton.data.animations[0].name, true);
|
||||||
|
|
||||||
|
const skinSelect = document.getElementById('skin-select');
|
||||||
|
skinSelect.innerHTML = "";
|
||||||
|
skeleton.data.skins.forEach(s => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = s.name; opt.text = s.name; skinSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
if(skeleton.data.defaultSkin) skinSelect.value = skeleton.data.defaultSkin.name;
|
||||||
|
|
||||||
|
animSelect.onchange = () => animationState.setAnimation(0, animSelect.value, true);
|
||||||
|
skinSelect.onchange = () => { skeleton.setSkinByName(skinSelect.value); skeleton.setSlotsToSetupPose(); };
|
||||||
|
|
||||||
|
updateControls();
|
||||||
|
bindControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateControls() {
|
||||||
|
if(!skeleton) return;
|
||||||
|
document.getElementById('posX').value = skeleton.x;
|
||||||
|
document.getElementById('posY').value = skeleton.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetView() {
|
||||||
|
if(!skeleton) return;
|
||||||
|
skeleton.x = 0; skeleton.y = -500;
|
||||||
|
renderer.camera.position.x = 0; renderer.camera.position.y = 0; renderer.camera.zoom = 1.0;
|
||||||
|
updateControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindControls() {
|
||||||
|
const inputX = document.getElementById('posX');
|
||||||
|
const inputY = document.getElementById('posY');
|
||||||
|
inputX.oninput = () => { if(skeleton) skeleton.x = parseFloat(inputX.value); };
|
||||||
|
inputY.oninput = () => { if(skeleton) skeleton.y = parseFloat(inputY.value); };
|
||||||
|
|
||||||
|
let isDragging=false, startX, startY, isRightDrag=false;
|
||||||
|
canvas.oncontextmenu = e => e.preventDefault();
|
||||||
|
canvas.onmousedown = e => {
|
||||||
|
if(e.button === 2) isRightDrag = true; else isDragging = true;
|
||||||
|
startX = e.clientX; startY = e.clientY;
|
||||||
|
};
|
||||||
|
window.onmouseup = () => { isDragging=false; isRightDrag=false; };
|
||||||
|
window.onmousemove = e => {
|
||||||
|
if(isDragging) {
|
||||||
|
renderer.camera.position.x -= (e.clientX - startX) * renderer.camera.zoom;
|
||||||
|
renderer.camera.position.y += (e.clientY - startY) * renderer.camera.zoom;
|
||||||
|
startX = e.clientX; startY = e.clientY;
|
||||||
|
} else if(isRightDrag) {
|
||||||
|
let dy = e.clientY - startY;
|
||||||
|
let newZoom = renderer.camera.zoom * (1 + dy * 0.01);
|
||||||
|
if(newZoom > 0.1 && newZoom < 10) renderer.camera.zoom = newZoom;
|
||||||
|
startY = e.clientY;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
canvas.onwheel = e => {
|
||||||
|
let newZoom = renderer.camera.zoom * (e.deltaY > 0 ? 1.1 : 0.9);
|
||||||
|
if(newZoom > 0.1 && newZoom < 10) renderer.camera.zoom = newZoom;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
let now = Date.now();
|
||||||
|
let delta = (now - lastTime) / 1000;
|
||||||
|
lastTime = now;
|
||||||
|
let w = window.innerWidth, h = window.innerHeight;
|
||||||
|
if (canvas.width != w || canvas.height != h) { canvas.width = w; canvas.height = h; }
|
||||||
|
gl.viewport(0, 0, w, h);
|
||||||
|
if(renderer && renderer.camera) {
|
||||||
|
if(renderer.camera.viewportWidth !== w || renderer.camera.viewportHeight !== h) {
|
||||||
|
renderer.camera.viewportWidth = w; renderer.camera.viewportHeight = h; renderer.camera.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gl.clearColor(0.2, 0.2, 0.2, 1);
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
if (skeleton && animationState) {
|
||||||
|
animationState.update(delta);
|
||||||
|
animationState.apply(skeleton);
|
||||||
|
skeleton.updateWorldTransform();
|
||||||
|
renderer.begin();
|
||||||
|
renderer.drawSkeleton(skeleton, true);
|
||||||
|
renderer.end();
|
||||||
|
}
|
||||||
|
requestAnimationFrame(render);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Spine 4.2 独立播放器 (V30)</title>
|
||||||
|
<script src="spine-webgl-4.2.js"></script>
|
||||||
|
<style>
|
||||||
|
body, html { margin: 0; padding: 0; width: 100%; height: 100%; background: #222; font-family: "Microsoft YaHei", sans-serif; overflow: hidden; color:#eee;}
|
||||||
|
|
||||||
|
#canvas-container { position: absolute; top:0; left:0; width: 100%; height: 100%; z-index: 0; background: radial-gradient(circle, #444 0%, #222 100%); }
|
||||||
|
canvas { display: block; width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
#drop-zone { position: fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.9); z-index:99; display:flex; flex-direction:column; justify-content:center; align-items:center; transition: opacity 0.5s;}
|
||||||
|
|
||||||
|
#ui { position: absolute; top: 20px; left: 20px; background: rgba(0,0,0,0.7); padding: 20px; border-radius: 8px; display: none; min-width: 220px; z-index: 10; border: 1px solid #555; box-shadow: 0 4px 10px rgba(0,0,0,0.5); }
|
||||||
|
#controls { position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.8); padding: 15px 30px; border-radius: 30px; color: white; display: none; gap: 30px; z-index: 10; align-items: center; border: 1px solid #555; box-shadow: 0 4px 15px rgba(0,0,0,0.5); }
|
||||||
|
|
||||||
|
#log { position: fixed; top: 10px; right: 10px; color: #00ff9d; font-family: monospace; font-size: 12px; pointer-events: none; background:rgba(0,0,0,0.8); padding:10px; border-radius:4px; max-width: 400px; max-height: 50%; overflow-y: auto; text-align: left; z-index: 100;}
|
||||||
|
|
||||||
|
.control-group { display: flex; flex-direction: column; align-items: center; gap: 5px; }
|
||||||
|
input[type=range] { width: 150px; cursor: pointer; }
|
||||||
|
select { background: #444; color: white; border: 1px solid #666; padding: 8px; border-radius: 4px; width: 100%; margin-top: 5px; outline: none; }
|
||||||
|
select:hover { background: #555; }
|
||||||
|
|
||||||
|
button { transition: transform 0.1s; cursor: pointer; }
|
||||||
|
button:active { transform: scale(0.95); }
|
||||||
|
|
||||||
|
.highlight { color: #00e5ff; font-weight: bold; }
|
||||||
|
.highlight-bin { color: #ff0055; font-weight: bold; }
|
||||||
|
|
||||||
|
.divider { height: 1px; background: #555; margin: 15px 0; }
|
||||||
|
.toggle-row { display: flex; justify-content: space-between; align-items: center; margin-top: 5px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="drop-zone">
|
||||||
|
<div style="font-size: 60px; margin-bottom: 20px;">🔥</div>
|
||||||
|
<h2>Spine 4.2 独立播放器</h2>
|
||||||
|
<p>支持格式:<span class="highlight">.json</span> / <span class="highlight-bin">.skel</span> (4.2+)</p>
|
||||||
|
<p>请选择包含 <b>骨架文件 + .atlas + .png</b> 的文件夹</p>
|
||||||
|
|
||||||
|
<input type="file" id="file-input" multiple style="display:none">
|
||||||
|
<input type="file" id="folder-input" webkitdirectory directory style="display:none">
|
||||||
|
|
||||||
|
<div style="margin-top:40px; display:flex; gap:20px; flex-direction:column; align-items:center;">
|
||||||
|
<button onclick="document.getElementById('folder-input').click()" style="padding:15px 40px; font-size:20px; background: linear-gradient(45deg, #ff9900, #ff0055); border:none; color:white; border-radius:8px; font-weight:bold; box-shadow: 0 4px 15px rgba(0,0,0,0.5);">
|
||||||
|
📂 选择文件夹 (自动识别)
|
||||||
|
</button>
|
||||||
|
<button onclick="document.getElementById('file-input').click()" style="padding:10px 20px; font-size:14px; background:transparent; border:1px solid #666; color:#aaa; border-radius:8px;">
|
||||||
|
或者点击选择文件...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="canvas-container"><canvas id="canvas"></canvas></div>
|
||||||
|
|
||||||
|
<div id="ui">
|
||||||
|
<div style="font-weight:bold; color:#ff9900; margin-bottom:15px; font-size: 16px;">SPINE 4.2 PRO</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:15px;">
|
||||||
|
<label style="font-size:12px; color:#aaa;">动画 (Animation)</label>
|
||||||
|
<select id="anim-select"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:15px;">
|
||||||
|
<label style="font-size:12px; color:#aaa;">皮肤 (Skin)</label>
|
||||||
|
<select id="skin-select"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="toggle-row">
|
||||||
|
<label style="font-size:14px;">PMA (预乘Alpha)</label>
|
||||||
|
<input type="checkbox" id="chk-pma" checked>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onclick="resetPhysics()" style="width:100%; margin-top:15px; padding:10px; background:#880000; color:#fff; border:none; border-radius:4px; font-weight:bold;">💥 重置物理 (Reset Physics)</button>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<button onclick="location.reload()" style="width:100%; padding:8px; background:#444; color:#fff; border:1px solid #666; border-radius:4px;">🔄 打开新文件</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="controls">
|
||||||
|
<div class="control-group">
|
||||||
|
<label style="font-size:12px; color:#aaa;">水平位置 (X)</label>
|
||||||
|
<input type="range" id="posX" min="-2000" max="2000" value="0" step="10">
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<label style="font-size:12px; color:#aaa;">垂直位置 (Y)</label>
|
||||||
|
<input type="range" id="posY" min="-2000" max="2000" value="0" step="10">
|
||||||
|
</div>
|
||||||
|
<div class="control-group" style="margin-left: 20px;">
|
||||||
|
<button onclick="resetView()" style="padding: 8px 20px; background:#444; color:white; border:1px solid #666; border-radius:4px;">重置视图</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="log">系统就绪 (V30 独立版)...</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// =============================================================
|
||||||
|
// 工具函数 & 变量
|
||||||
|
// =============================================================
|
||||||
|
const ui = {
|
||||||
|
log: (msg, type) => {
|
||||||
|
console.log(msg);
|
||||||
|
const box = document.getElementById('log');
|
||||||
|
const color = type==='err'?'#ff5555':(type==='ok'?'#55ff55':'#00ff9d');
|
||||||
|
box.innerHTML += `<div style="color:${color}; margin-bottom:4px;">> ${msg}</div>`;
|
||||||
|
box.scrollTop = box.scrollHeight;
|
||||||
|
},
|
||||||
|
updateSliders: () => {
|
||||||
|
document.getElementById('posX').value = cam.x;
|
||||||
|
document.getElementById('posY').value = cam.y;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Mat4 = {
|
||||||
|
create: () => new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]),
|
||||||
|
ortho: (out, l, r, b, t, n, f) => {
|
||||||
|
const lr = 1/(l-r), bt = 1/(b-t), nf = 1/(n-f);
|
||||||
|
out[0]=-2*lr; out[5]=-2*bt; out[10]=2*nf; out[12]=(l+r)*lr; out[13]=(t+b)*bt; out[14]=(f+n)*nf; out[15]=1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let canvas, gl, shader, batcher, renderer;
|
||||||
|
let skeleton, state, mvp = Mat4.create();
|
||||||
|
let lastTime = Date.now();
|
||||||
|
let cam = {x:0, y:0, zoom:1.0};
|
||||||
|
let globalTexture = null;
|
||||||
|
const Physics = { None: 0, Update: 1, Reset: 2 };
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 初始化 & 输入绑定
|
||||||
|
// =============================================================
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
canvas = document.getElementById('canvas');
|
||||||
|
gl = canvas.getContext('webgl', { alpha: false });
|
||||||
|
if(!gl) throw "WebGL 不可用";
|
||||||
|
|
||||||
|
shader = spine.Shader.newTwoColoredTextured(gl);
|
||||||
|
batcher = new spine.PolygonBatcher(gl, true);
|
||||||
|
renderer = new spine.SkeletonRenderer(gl);
|
||||||
|
renderer.twoColor = true;
|
||||||
|
renderer.premultipliedAlpha = true;
|
||||||
|
|
||||||
|
bindInput();
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
|
||||||
|
// 绑定输入文件事件
|
||||||
|
document.getElementById('file-input').onchange = e => handleFiles(e.target.files);
|
||||||
|
document.getElementById('folder-input').onchange = e => handleFiles(e.target.files);
|
||||||
|
|
||||||
|
// 拖拽支持
|
||||||
|
const dz = document.getElementById('drop-zone');
|
||||||
|
document.body.addEventListener('dragover', e => e.preventDefault());
|
||||||
|
document.body.addEventListener('drop', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleFiles(e.dataTransfer.files);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch(e) { ui.log(e, 'err'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindInput() {
|
||||||
|
// 鼠标/滚轮控制
|
||||||
|
canvas.onwheel = e => {
|
||||||
|
cam.zoom *= (e.deltaY>0 ? 0.9 : 1.1);
|
||||||
|
// 限制缩放范围
|
||||||
|
if(cam.zoom < 0.1) cam.zoom = 0.1;
|
||||||
|
if(cam.zoom > 10) cam.zoom = 10;
|
||||||
|
};
|
||||||
|
|
||||||
|
let drag=false, lx, ly, isRight=false;
|
||||||
|
|
||||||
|
canvas.oncontextmenu = e => e.preventDefault();
|
||||||
|
canvas.onmousedown = e => {
|
||||||
|
drag=true;
|
||||||
|
isRight = (e.button === 2);
|
||||||
|
lx=e.clientX; ly=e.clientY;
|
||||||
|
};
|
||||||
|
window.onmouseup = () => drag=false;
|
||||||
|
|
||||||
|
window.onmousemove = e => {
|
||||||
|
if(drag) {
|
||||||
|
let dx = e.clientX - lx;
|
||||||
|
let dy = e.clientY - ly;
|
||||||
|
|
||||||
|
if(isRight) {
|
||||||
|
// 右键缩放
|
||||||
|
let newZoom = cam.zoom * (1 + dy * 0.005);
|
||||||
|
if(newZoom > 0.1 && newZoom < 10) cam.zoom = newZoom;
|
||||||
|
} else {
|
||||||
|
// 左键拖拽 + 物理惯性
|
||||||
|
cam.x -= dx / cam.zoom;
|
||||||
|
cam.y += dy / cam.zoom;
|
||||||
|
|
||||||
|
// 物理惯性:反向作用于骨架
|
||||||
|
if (skeleton) {
|
||||||
|
skeleton.x += dx / cam.zoom;
|
||||||
|
skeleton.y -= dy / cam.zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.updateSliders();
|
||||||
|
}
|
||||||
|
lx = e.clientX; ly = e.clientY;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// UI 滑块控制
|
||||||
|
const sx = document.getElementById('posX');
|
||||||
|
const sy = document.getElementById('posY');
|
||||||
|
sx.oninput = () => cam.x = parseFloat(sx.value);
|
||||||
|
sy.oninput = () => cam.y = parseFloat(sy.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetView() {
|
||||||
|
cam.x = 0; cam.y = 0; cam.zoom = 1.0;
|
||||||
|
ui.updateSliders();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPhysics() {
|
||||||
|
if(skeleton && spine.Physics) {
|
||||||
|
skeleton.updateWorldTransform(spine.Physics.reset);
|
||||||
|
ui.log("物理已重置", 'ok');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 文件处理 & 核心加载
|
||||||
|
// =============================================================
|
||||||
|
async function handleFiles(files) {
|
||||||
|
if(files.length === 0) return;
|
||||||
|
|
||||||
|
let map = {};
|
||||||
|
for(let f of files) {
|
||||||
|
let n = f.name.toLowerCase();
|
||||||
|
if(n.includes('.atlas')) map.atlas = f;
|
||||||
|
else if(n.includes('.png') || n.includes('.jpg') || n.includes('.webp')) map.png = f;
|
||||||
|
else if(n.includes('.skel') || n.includes('.json')) map.main = f;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!map.atlas || !map.png || !map.main) {
|
||||||
|
ui.log("文件缺失: 需要 .json/.skel + .atlas + .png", 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imgBlob = URL.createObjectURL(map.png);
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => processSpine(map, img);
|
||||||
|
img.src = imgBlob;
|
||||||
|
} catch(e) { ui.log(e, 'err'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processSpine(map, img) {
|
||||||
|
try {
|
||||||
|
ui.log("正在加载...", 'ok');
|
||||||
|
const atlasText = await readFile(map.atlas, 'text');
|
||||||
|
|
||||||
|
globalTexture = new spine.GLTexture(gl, img, false);
|
||||||
|
globalTexture.setFilters(gl.LINEAR, gl.LINEAR);
|
||||||
|
globalTexture.setWraps(gl.CLAMP_TO_EDGE, gl.CLAMP_TO_EDGE);
|
||||||
|
|
||||||
|
const atlas = new spine.TextureAtlas(atlasText, path => globalTexture);
|
||||||
|
// 强制修正 Atlas 尺寸
|
||||||
|
for(let page of atlas.pages) { page.width = img.width; page.height = img.height; }
|
||||||
|
|
||||||
|
const loader = new spine.AtlasAttachmentLoader(atlas);
|
||||||
|
let skelData;
|
||||||
|
|
||||||
|
if(map.main.name.includes('.skel')) {
|
||||||
|
ui.log("解析二进制文件...");
|
||||||
|
const buffer = await readFile(map.main, 'array');
|
||||||
|
skelData = new spine.SkeletonBinary(loader).readSkeletonData(new Uint8Array(buffer));
|
||||||
|
} else {
|
||||||
|
ui.log("解析 JSON 文件...");
|
||||||
|
const text = await readFile(map.main, 'text');
|
||||||
|
skelData = new spine.SkeletonJson(loader).readSkeletonData(JSON.parse(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.log(`版本: ${skelData.version}`, 'ok');
|
||||||
|
|
||||||
|
skeleton = new spine.Skeleton(skelData);
|
||||||
|
|
||||||
|
// 自动注入纹理
|
||||||
|
let fixCount = 0;
|
||||||
|
for(let skin of skeleton.data.skins) {
|
||||||
|
let entries = skin.getAttachments ? skin.getAttachments() : skin.attachments;
|
||||||
|
for(let entry of entries) {
|
||||||
|
let att = entry ? entry.attachment : null;
|
||||||
|
if(att && att.region) {
|
||||||
|
att.region.texture = globalTexture;
|
||||||
|
att.region.renderObject = att.region;
|
||||||
|
fixCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.log(`纹理修复: ${fixCount} 个附件`, 'ok');
|
||||||
|
|
||||||
|
skeleton.setToSetupPose();
|
||||||
|
if(spine.Physics) skeleton.updateWorldTransform(spine.Physics.reset);
|
||||||
|
|
||||||
|
state = new spine.AnimationState(new spine.AnimationStateData(skelData));
|
||||||
|
|
||||||
|
setupUI(skelData); // 初始化 UI 选择器
|
||||||
|
|
||||||
|
// 预热两帧
|
||||||
|
state.update(0); state.apply(skeleton); skeleton.updateWorldTransform(spine.Physics.update);
|
||||||
|
state.update(0); state.apply(skeleton); skeleton.updateWorldTransform(spine.Physics.update);
|
||||||
|
|
||||||
|
// 自动对焦
|
||||||
|
const offset = new spine.Vector2(), size = new spine.Vector2();
|
||||||
|
skeleton.getBounds(offset, size, []);
|
||||||
|
if(size.x > 0) {
|
||||||
|
cam.x = offset.x + size.x/2;
|
||||||
|
cam.y = offset.y + size.y/2;
|
||||||
|
cam.zoom = Math.min(canvas.width/size.x, canvas.height/size.y) * 0.8;
|
||||||
|
} else {
|
||||||
|
cam.x = 0; cam.y = -200; // 默认位置
|
||||||
|
}
|
||||||
|
ui.updateSliders();
|
||||||
|
|
||||||
|
// 切换界面
|
||||||
|
document.getElementById('drop-zone').style.opacity = 0;
|
||||||
|
setTimeout(() => document.getElementById('drop-zone').style.display = 'none', 500);
|
||||||
|
document.getElementById('ui').style.display = 'block';
|
||||||
|
document.getElementById('controls').style.display = 'flex';
|
||||||
|
|
||||||
|
} catch(e) { ui.log(e.message, 'err'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupUI(data) {
|
||||||
|
const animSelect = document.getElementById('anim-select');
|
||||||
|
const skinSelect = document.getElementById('skin-select');
|
||||||
|
animSelect.innerHTML = "";
|
||||||
|
skinSelect.innerHTML = "";
|
||||||
|
|
||||||
|
data.animations.forEach(a => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = a.name; opt.text = a.name; animSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
if(data.animations.length > 0) {
|
||||||
|
state.setAnimation(0, data.animations[0].name, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
data.skins.forEach(s => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = s.name; opt.text = s.name; skinSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
if(data.defaultSkin) skinSelect.value = data.defaultSkin.name;
|
||||||
|
|
||||||
|
animSelect.onchange = () => state.setAnimation(0, animSelect.value, true);
|
||||||
|
skinSelect.onchange = () => {
|
||||||
|
skeleton.setSkinByName(skinSelect.value);
|
||||||
|
skeleton.setSlotsToSetupPose();
|
||||||
|
skeleton.setToSetupPose(); // 重置姿势以应用皮肤
|
||||||
|
if(spine.Physics) skeleton.updateWorldTransform(spine.Physics.reset); // 皮肤切换可能需要重置物理
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFile(f, type) {
|
||||||
|
return new Promise(r => {
|
||||||
|
const fr = new FileReader();
|
||||||
|
fr.onload = () => r(fr.result);
|
||||||
|
if(type==='text') fr.readAsText(f); else fr.readAsArrayBuffer(f);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 渲染循环
|
||||||
|
// =============================================================
|
||||||
|
function loop() {
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
|
||||||
|
if(canvas.width !== window.innerWidth) { canvas.width = window.innerWidth; canvas.height = window.innerHeight; }
|
||||||
|
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||||
|
gl.clearColor(0.2, 0.2, 0.2, 1);
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
if(!skeleton || !state) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
const dt = (now - lastTime) / 1000;
|
||||||
|
lastTime = now;
|
||||||
|
|
||||||
|
state.update(dt);
|
||||||
|
state.apply(skeleton);
|
||||||
|
|
||||||
|
if(skeleton.update) skeleton.update(dt);
|
||||||
|
if(spine.Physics) skeleton.updateWorldTransform(spine.Physics.update);
|
||||||
|
else skeleton.updateWorldTransform(1);
|
||||||
|
|
||||||
|
if(!Number.isFinite(cam.zoom)) cam.zoom=0.1;
|
||||||
|
const w = canvas.width, h = canvas.height;
|
||||||
|
Mat4.ortho(mvp, cam.x - w/cam.zoom/2, cam.x + w/cam.zoom/2, cam.y - h/cam.zoom/2, cam.y + h/cam.zoom/2, -2000, 2000);
|
||||||
|
|
||||||
|
shader.bind();
|
||||||
|
shader.setUniform4x4f(spine.Shader.MVP_MATRIX, mvp);
|
||||||
|
shader.setUniformi(spine.Shader.SAMPLER, 0);
|
||||||
|
|
||||||
|
const pma = document.getElementById('chk-pma').checked;
|
||||||
|
gl.enable(gl.BLEND);
|
||||||
|
gl.blendFunc(pma ? gl.ONE : gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||||
|
|
||||||
|
batcher.begin(shader);
|
||||||
|
renderer.draw(batcher, skeleton);
|
||||||
|
batcher.end();
|
||||||
|
shader.unbind();
|
||||||
|
|
||||||
|
} catch(e) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
介绍:https://www.bilibili.com/video/BV1vgmQBWEfa
|
||||||
|
================================================================
|
||||||
|
Spine 播放器 (Spine Universal Viewer) 使用说明书
|
||||||
|
================================================================
|
||||||
|
|
||||||
|
感谢您使用本软件!这是一款支持全版本 (3.8 / 4.1 / 4.2) 的 Spine 动画查看与导出工具。
|
||||||
|
无需安装,解压即用。
|
||||||
|
|
||||||
|
【快速开始】
|
||||||
|
|
||||||
|
1. 启动软件:
|
||||||
|
直接双击文件夹里的 "index.html" 文件。
|
||||||
|
(推荐使用 Chrome 谷歌浏览器 或 Edge 浏览器打开,体验最好)
|
||||||
|
|
||||||
|
2. 加载动画:
|
||||||
|
点击屏幕中间蓝色的 "📂 打开动画文件夹" 按钮。
|
||||||
|
选中包含 Spine 文件的文件夹即可。
|
||||||
|
|
||||||
|
★ 注意:您的文件夹里必须包含以下三个文件才能成功加载:
|
||||||
|
- xxx.json 或 xxx.skel (骨骼数据)
|
||||||
|
- xxx.atlas (图集数据)
|
||||||
|
- xxx.png (图片纹理)
|
||||||
|
|
||||||
|
================================================================
|
||||||
|
【功能详解】
|
||||||
|
|
||||||
|
[1] 左上角面板:导出与重置
|
||||||
|
------------------------------------------------
|
||||||
|
● 🔄 重置 / 打开新文件
|
||||||
|
点击此按钮返回启动页,加载新的动画。
|
||||||
|
|
||||||
|
● 📂 导出序列帧
|
||||||
|
可以将当前的动画动作,导出为透明背景的 PNG 图片序列。
|
||||||
|
- 前缀:设置导出文件名的开头(如 "atk",导出就是 atk_000.png)。
|
||||||
|
- 编号:设置序号的位数。
|
||||||
|
- 缩放:设置图片大小。输入 "1.0" 是原大,输入 "2.0" 是两倍高清大图。
|
||||||
|
- 点击导出按钮后,会弹出一个窗口让您选择保存位置,然后自动开始录制。
|
||||||
|
|
||||||
|
[2] 右上角面板:外观设置
|
||||||
|
------------------------------------------------
|
||||||
|
● 选择动画:切换角色的动作(如待机、攻击、跑步)。
|
||||||
|
● 选择皮肤:切换角色的皮肤(如果有)。
|
||||||
|
● 显示骨骼:勾选后会显示绿色的骨骼线条,方便看结构。
|
||||||
|
● PMA (预乘Alpha):★重要功能★
|
||||||
|
如果发现角色边缘有奇怪的黑边或白边,请尝试勾选或取消勾选这个框。
|
||||||
|
不同版本的 Spine 导出设置不同,切换这个选项通常能解决边缘显示问题。
|
||||||
|
|
||||||
|
[3] 底部面板:播放控制
|
||||||
|
------------------------------------------------
|
||||||
|
● 播放/暂停:控制动画播放。
|
||||||
|
● 进度条:可以手动拖拽查看每一帧。
|
||||||
|
● 倍速:从 0.5x (慢动作) 到 2.0x (快进) 只有选择。
|
||||||
|
● 帧率:建议选择 60 FPS 以获得最流畅的体验。
|
||||||
|
● 归位:如果把角色拖出屏幕找不到了,点一下这个按钮,角色会回到画面中心。
|
||||||
|
|
||||||
|
================================================================
|
||||||
|
【常见问题】
|
||||||
|
|
||||||
|
Q:为什么打开是黑屏?
|
||||||
|
A:请检查是否使用了极速浏览器或旧版IE,请务必使用 Chrome 或 Edge。
|
||||||
|
另外,请确认文件夹里是否齐备了 .json/.skel + .atlas + .png 三个文件。
|
||||||
|
|
||||||
|
Q:怎么换成我自己的 Logo?
|
||||||
|
A:很简单!把您设计好的图片重命名为 "icon.png",放入本软件的文件夹里。
|
||||||
|
刷新一下网页,启动页的钻石图标就会变成您的图片了。
|
||||||
|
|
||||||
|
Q:Spine 4.2 的物理效果好像卡住了?
|
||||||
|
A:加载 4.2 模型时,右上角会出现红色的 "重置物理" 按钮,点一下它即可复位。
|
||||||
|
|
||||||
|
================================================================
|
||||||
|
祝您使用愉快!
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
; HLS_Batch_Converter_Multi.au3
|
||||||
|
; 支持最多 8 个并发转码 + 实时显示 ffmpeg 日志
|
||||||
|
|
||||||
|
#include <GUIConstantsEx.au3>
|
||||||
|
#include <EditConstants.au3>
|
||||||
|
#include <WindowsConstants.au3>
|
||||||
|
#include <File.au3>
|
||||||
|
#include <Array.au3>
|
||||||
|
|
||||||
|
; ========== 配置 ==========
|
||||||
|
Global Const $MAX_CONCURRENT = 8
|
||||||
|
|
||||||
|
; ========== 全局变量 ==========
|
||||||
|
Global $g_sSourceDir = ""
|
||||||
|
Global $g_hLogEdit, $g_hBtnStart, $g_hBtnSelect, $g_hLabelStatus
|
||||||
|
|
||||||
|
; 任务队列和运行中任务
|
||||||
|
Global $g_aPendingFiles[0] ; 待处理文件列表 (完整路径)
|
||||||
|
Global $g_aRunningTasks[0][4] ; [PID, FileName, OutputDir, LogHandle]
|
||||||
|
Global $g_iSuccess = 0
|
||||||
|
Global $g_bConverting = False
|
||||||
|
|
||||||
|
; ========== 创建主窗口 ==========
|
||||||
|
GUICreate("HLS 批量转码工具 - 多线程版", 700, 550)
|
||||||
|
GUICtrlCreateLabel("1. 请选择包含视频文件的源目录:", 10, 10, 400, 20)
|
||||||
|
|
||||||
|
$g_hBtnSelect = GUICtrlCreateButton("选择目录...", 10, 35, 100, 30)
|
||||||
|
$g_hLabelStatus = GUICtrlCreateLabel("", 120, 40, 570, 20)
|
||||||
|
GUICtrlSetColor(-1, 0x0000FF)
|
||||||
|
|
||||||
|
GUICtrlCreateLabel("2. 点击“开始转码”(最多 " & $MAX_CONCURRENT & " 个并发):", 10, 75, 400, 20)
|
||||||
|
$g_hBtnStart = GUICtrlCreateButton("开始转码", 10, 100, 100, 30)
|
||||||
|
GUICtrlSetState($g_hBtnStart, $GUI_DISABLE)
|
||||||
|
|
||||||
|
GUICtrlCreateLabel("实时日志(格式: [视频名] 日志内容):", 10, 140, 300, 20)
|
||||||
|
$g_hLogEdit = GUICtrlCreateEdit("", 10, 165, 680, 370, BitOR($ES_AUTOVSCROLL, $ES_READONLY, $WS_VSCROLL))
|
||||||
|
GUICtrlSetFont($g_hLogEdit, 9, 400, 0, "Consolas")
|
||||||
|
|
||||||
|
GUISetState(@SW_SHOW)
|
||||||
|
|
||||||
|
; ========== 消息循环 ==========
|
||||||
|
While 1
|
||||||
|
Switch GUIGetMsg()
|
||||||
|
Case $GUI_EVENT_CLOSE
|
||||||
|
; 清理子进程
|
||||||
|
_KillAllRunningTasks()
|
||||||
|
Exit
|
||||||
|
Case $g_hBtnSelect
|
||||||
|
_SelectSourceDir()
|
||||||
|
Case $g_hBtnStart
|
||||||
|
_StartConversion()
|
||||||
|
EndSwitch
|
||||||
|
|
||||||
|
; 如果正在转码,定期检查任务状态(模拟异步)
|
||||||
|
If $g_bConverting Then
|
||||||
|
_PollRunningTasks()
|
||||||
|
Sleep(50) ; 减轻 CPU 占用
|
||||||
|
EndIf
|
||||||
|
WEnd
|
||||||
|
|
||||||
|
; ========== 函数定义 ==========
|
||||||
|
Func _SelectSourceDir()
|
||||||
|
Local $sDir = FileSelectFolder("请选择视频所在的源目录", "")
|
||||||
|
If Not @error And $sDir <> "" Then
|
||||||
|
$g_sSourceDir = $sDir
|
||||||
|
GUICtrlSetData($g_hLabelStatus, "已选择: " & $g_sSourceDir)
|
||||||
|
GUICtrlSetState($g_hBtnStart, $GUI_ENABLE)
|
||||||
|
_Log("✅ 目录已选择:" & $g_sSourceDir)
|
||||||
|
Else
|
||||||
|
$g_sSourceDir = ""
|
||||||
|
GUICtrlSetData($g_hLabelStatus, "未选择目录")
|
||||||
|
GUICtrlSetState($g_hBtnStart, $GUI_DISABLE)
|
||||||
|
EndIf
|
||||||
|
EndFunc
|
||||||
|
|
||||||
|
Func _StartConversion()
|
||||||
|
If $g_sSourceDir = "" Or $g_bConverting Then Return
|
||||||
|
|
||||||
|
$g_bConverting = True
|
||||||
|
GUICtrlSetState($g_hBtnStart, $GUI_DISABLE)
|
||||||
|
GUICtrlSetState($g_hBtnSelect, $GUI_DISABLE)
|
||||||
|
_Log(@CRLF & "🚀 开始批量转码(最多 " & $MAX_CONCURRENT & " 个并发)..." & @CRLF)
|
||||||
|
|
||||||
|
; 初始化
|
||||||
|
$g_iSuccess = 0
|
||||||
|
Dim $g_aPendingFiles[0]
|
||||||
|
Dim $g_aRunningTasks[0][4]
|
||||||
|
|
||||||
|
; 获取视频文件列表
|
||||||
|
Local $aExtensions = [".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv", ".mpeg", ".mpg", ".webm"]
|
||||||
|
Local $aFiles = _FileListToArray($g_sSourceDir, "*", $FLTA_FILES)
|
||||||
|
If @error Then
|
||||||
|
_Log("❌ 目录中没有找到任何文件。")
|
||||||
|
_FinishConversion()
|
||||||
|
Return
|
||||||
|
EndIf
|
||||||
|
|
||||||
|
; 填充待处理队列
|
||||||
|
For $i = 1 To $aFiles[0]
|
||||||
|
Local $sFile = $g_sSourceDir & "\" & $aFiles[$i]
|
||||||
|
Local $sExt = StringLower(StringRight($sFile, 4))
|
||||||
|
If _IsInArray($aExtensions, $sExt) Then
|
||||||
|
_ArrayAdd($g_aPendingFiles, $sFile)
|
||||||
|
EndIf
|
||||||
|
Next
|
||||||
|
|
||||||
|
If UBound($g_aPendingFiles) = 0 Then
|
||||||
|
_Log("❌ 未找到支持的视频文件。")
|
||||||
|
_FinishConversion()
|
||||||
|
Return
|
||||||
|
EndIf
|
||||||
|
|
||||||
|
; 启动第一批任务(最多 MAX_CONCURRENT 个)
|
||||||
|
_LaunchNextTasks()
|
||||||
|
EndFunc
|
||||||
|
|
||||||
|
Func _LaunchNextTasks()
|
||||||
|
While UBound($g_aRunningTasks, 1) < $MAX_CONCURRENT And UBound($g_aPendingFiles) > 0
|
||||||
|
Local $sFilePath = $g_aPendingFiles[0]
|
||||||
|
_ArrayDelete($g_aPendingFiles, 0)
|
||||||
|
|
||||||
|
Local $sFileName = StringRegExpReplace($sFilePath, "^.*\\", "") ; 仅文件名
|
||||||
|
Local $sBaseName = StringRegExpReplace($sFileName, "\.[^.\\]+$", "")
|
||||||
|
Local $sOutputDir = $g_sSourceDir & "\" & $sBaseName
|
||||||
|
|
||||||
|
If DirCreate($sOutputDir) = 0 Then
|
||||||
|
_Log("[系统] ⚠️ 无法创建目录: " & $sOutputDir)
|
||||||
|
ContinueLoop
|
||||||
|
EndIf
|
||||||
|
|
||||||
|
Local $sM3U8 = $sOutputDir & "\playlist.m3u8"
|
||||||
|
Local $sTS = $sOutputDir & "\segment_%05d.ts"
|
||||||
|
|
||||||
|
Local $sCmd = 'ffmpeg -i "' & $sFilePath & '" ' & _
|
||||||
|
'-c:v libx264 -crf 23 -preset fast -profile:v baseline -level 3.0 ' & _
|
||||||
|
'-c:a aac -b:a 128k ' & _
|
||||||
|
'-hls_time 2 ' & _
|
||||||
|
'-hls_playlist_type vod ' & _
|
||||||
|
'-hls_segment_filename "' & $sTS & '" ' & _
|
||||||
|
'"' & $sM3U8 & '"'
|
||||||
|
|
||||||
|
; 启动进程并捕获合并输出
|
||||||
|
Local $iPID = Run($sCmd, "", @SW_HIDE, $STDERR_MERGED)
|
||||||
|
If @error Then
|
||||||
|
_Log("[" & $sBaseName & "] ❌ 启动失败")
|
||||||
|
ContinueLoop
|
||||||
|
EndIf
|
||||||
|
|
||||||
|
; 记录任务
|
||||||
|
Local $iIndex = UBound($g_aRunningTasks, 1)
|
||||||
|
ReDim $g_aRunningTasks[$iIndex + 1][4]
|
||||||
|
$g_aRunningTasks[$iIndex][0] = $iPID
|
||||||
|
$g_aRunningTasks[$iIndex][1] = $sBaseName
|
||||||
|
$g_aRunningTasks[$iIndex][2] = $sOutputDir
|
||||||
|
$g_aRunningTasks[$iIndex][3] = ""
|
||||||
|
|
||||||
|
_Log("[" & $sBaseName & "] 🔄 开始转码...")
|
||||||
|
WEnd
|
||||||
|
EndFunc
|
||||||
|
|
||||||
|
Func _PollRunningTasks()
|
||||||
|
If UBound($g_aRunningTasks, 1) = 0 Then
|
||||||
|
If UBound($g_aPendingFiles) = 0 Then
|
||||||
|
_FinishConversion()
|
||||||
|
EndIf
|
||||||
|
Return
|
||||||
|
EndIf
|
||||||
|
|
||||||
|
Local $aToRemove[0]
|
||||||
|
|
||||||
|
For $i = 0 To UBound($g_aRunningTasks, 1) - 1
|
||||||
|
Local $iPID = $g_aRunningTasks[$i][0]
|
||||||
|
Local $sName = $g_aRunningTasks[$i][1]
|
||||||
|
|
||||||
|
; 读取新日志
|
||||||
|
Local $sNewOutput = StdoutRead($iPID)
|
||||||
|
If $sNewOutput <> "" Then
|
||||||
|
; 按行分割并加前缀
|
||||||
|
Local $aLines = StringSplit(StringStripCR($sNewOutput), @LF, $STR_NOCOUNT)
|
||||||
|
For $j = 0 To UBound($aLines) - 1
|
||||||
|
If $aLines[$j] <> "" Then
|
||||||
|
_Log("[" & $sName & "] " & $aLines[$j])
|
||||||
|
EndIf
|
||||||
|
Next
|
||||||
|
EndIf
|
||||||
|
|
||||||
|
; 检查是否结束
|
||||||
|
If Not ProcessExists($iPID) Then
|
||||||
|
Local $iExitCode = @extended ; 注意:Run 返回的 PID 在 ProcessExists 失败后,@extended 是退出码!
|
||||||
|
If $iExitCode = 0 Then
|
||||||
|
$g_iSuccess += 1
|
||||||
|
_Log("[" & $sName & "] ✅ 成功完成")
|
||||||
|
Else
|
||||||
|
_Log("[" & $sName & "] ❌ 失败 (退出码: " & $iExitCode & ")")
|
||||||
|
EndIf
|
||||||
|
_ArrayAdd($aToRemove, $i)
|
||||||
|
EndIf
|
||||||
|
Next
|
||||||
|
|
||||||
|
; 移除已完成的任务(从后往前删)
|
||||||
|
For $i = UBound($aToRemove) - 1 To 0 Step -1
|
||||||
|
Local $iIndex = $aToRemove[$i]
|
||||||
|
_ArrayDelete($g_aRunningTasks, $iIndex)
|
||||||
|
Next
|
||||||
|
|
||||||
|
; 启动新任务填补空缺
|
||||||
|
If UBound($aToRemove) > 0 Then
|
||||||
|
_LaunchNextTasks()
|
||||||
|
EndIf
|
||||||
|
EndFunc
|
||||||
|
|
||||||
|
Func _FinishConversion()
|
||||||
|
$g_bConverting = False
|
||||||
|
_Log(@CRLF & "🎉 所有转码任务完成!成功处理 " & $g_iSuccess & " 个视频。")
|
||||||
|
GUICtrlSetState($g_hBtnStart, $GUI_ENABLE)
|
||||||
|
GUICtrlSetState($g_hBtnSelect, $GUI_ENABLE)
|
||||||
|
EndFunc
|
||||||
|
|
||||||
|
Func _KillAllRunningTasks()
|
||||||
|
If Not IsArray($g_aRunningTasks) Then Return
|
||||||
|
For $i = 0 To UBound($g_aRunningTasks, 1) - 1
|
||||||
|
ProcessClose($g_aRunningTasks[$i][0])
|
||||||
|
Next
|
||||||
|
EndFunc
|
||||||
|
|
||||||
|
Func _Log($sText)
|
||||||
|
Local $sCurrent = GUICtrlRead($g_hLogEdit)
|
||||||
|
Local $sNew = $sCurrent & $sText & @CRLF
|
||||||
|
If StringLen($sNew) > 2000 Then
|
||||||
|
$sNew = StringRight($sNew, 2000)
|
||||||
|
EndIf
|
||||||
|
GUICtrlSetData($g_hLogEdit, $sNew)
|
||||||
|
GUICtrlSendMsg($g_hLogEdit, $EM_LINESCROLL, 0, GUICtrlSendMsg($g_hLogEdit, $EM_GETLINECOUNT, 0, 0))
|
||||||
|
EndFunc
|
||||||
|
|
||||||
|
Func _IsInArray($aArray, $sValue)
|
||||||
|
For $i = 0 To UBound($aArray) - 1
|
||||||
|
If $aArray[$i] = $sValue Then Return True
|
||||||
|
Next
|
||||||
|
Return False
|
||||||
|
EndFunc
|
||||||
Binary file not shown.
Reference in New Issue
Block a user