添加spine动画播放器和au3视频格式转换脚本
This commit is contained in:
1 parent
da3087b1d4
commit
d3d91c2539
14 files changed
+40679
No files matched your search
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 it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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