Files
2025-09-15 17:47:43 +08:00

986 lines
35 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

class TreeSelector {
constructor(app) {
this.app = app;
this.treeCollapseState = this.loadCollapseCache();
// Try desktop container first, fallback to drawer container (mobile), else create one
this.controlsContentDiv = document.getElementById("controls-content") || document.getElementById('drawer-controls-content');
if (!this.controlsContentDiv) {
const controlsRoot = document.getElementById('controls');
if (controlsRoot) {
const div = document.createElement('div');
div.id = 'controls-content';
controlsRoot.insertBefore(div, controlsRoot.firstChild);
this.controlsContentDiv = div;
} else if (document.body) {
const div = document.createElement('div');
div.id = 'controls-content';
document.body.appendChild(div);
this.controlsContentDiv = div;
}
}
this.__treeSelectorRendered = false;
}
getCollapseCacheKey() {
return `tree_collapse_${this.app.currentCharacter}`;
}
saveCollapseCache() {
try {
localStorage.setItem(this.getCollapseCacheKey(), JSON.stringify(this.treeCollapseState));
} catch (e) {}
}
loadCollapseCache() {
let cache = localStorage.getItem(this.getCollapseCacheKey());
if (!cache) return {};
try { return JSON.parse(cache); } catch (e) { return {}; }
}
getSelectionCacheKey() {
return `character_selection_${this.app.currentCharacter}`;
}
saveSelectionCache(rootNode) {
function collect(node) {
let result = {};
if (node.Children) {
for (let key in node.Children) result[key] = collect(node.Children[key]);
}
if (node.SpriteRenderer && node.SpriteRenderer.Sprite) result.__selected = !!node.__selected;
return result;
}
try {
localStorage.setItem(this.getSelectionCacheKey(), JSON.stringify(collect(rootNode)));
} catch (e) {}
}
loadSelectionCache(rootNode) {
let cache = localStorage.getItem(this.getSelectionCacheKey());
if (!cache) return;
try { cache = JSON.parse(cache); } catch (e) { return; }
function restore(node, cacheObj) {
if (!cacheObj) return;
if (node.Children) {
for (let key in node.Children) restore(node.Children[key], cacheObj[key]);
}
if (node.SpriteRenderer && node.SpriteRenderer.Sprite && typeof cacheObj.__selected !== "undefined") {
node.__selected = !!cacheObj.__selected;
}
}
restore(rootNode, cache);
}
clearSelectionCache() {
localStorage.removeItem(this.getSelectionCacheKey());
}
// 判断节点或其子节点是否含有 SpriteRenderer.Sprite
static hasLeafSprite(node) {
if (node.SpriteRenderer && node.SpriteRenderer.Sprite) return true;
if (node.Children) {
for (let key in node.Children) {
if (TreeSelector.hasLeafSprite(node.Children[key])) return true;
}
}
return false;
}
// 新增:返回按 Name 排序的子节点键数组(避免重复排序逻辑)
static sortedChildKeys(children) {
if (!children) return [];
return Object.keys(children).sort((a, b) => {
const nameA = (children[a] && children[a].Name) || "";
const nameB = (children[b] && children[b].Name) || "";
return nameA.localeCompare(nameB, 'zh-CN');
});
}
// 收集所有选中的叶节点(多选)
getSelectedLeafNodes(node) {
let result = [];
let isLeaf = !node.Children || Object.keys(node.Children).length === 0;
if (isLeaf && node.SpriteRenderer && node.SpriteRenderer.Sprite && node.__selected) {
result.push(node);
}
if (node.Children) {
for (let key in node.Children) {
result = result.concat(this.getSelectedLeafNodes(node.Children[key]));
}
}
return result;
}
// 同名连锁同步
syncLinkedLeafsByName(name, selected) {
// 收集所有匹配节点(保持原来包含匹配逻辑)
let matches = [];
function traverseCollect(node) {
if (node.SpriteRenderer && node.SpriteRenderer.Sprite && node.Name && node.Name !== name && node.Name.includes(name)) {
matches.push(node);
}
if (node.Children) {
for (let key in node.Children) traverseCollect(node.Children[key]);
}
}
traverseCollect(this.app.rootNode);
if (!matches.length) return;
// 正则检测后缀形如 _01、_02、_3 等
const suffixRe = /^(.*)_(\d+)$/;
// 分组:带后缀按前缀分组,不带后缀单独处理
let groups = new Map(); // key: prefix, value: array of {node, idx}
for (let node of matches) {
let m = node.Name.match(suffixRe);
if (m) {
let prefix = m[1];
let idx = parseInt(m[2], 10);
if (!groups.has(prefix)) groups.set(prefix, []);
groups.get(prefix).push({ node, idx });
} else {
// 非后缀直接设置(和之前行为一致)
node.__selected = selected;
}
}
// 对每个带后缀的组,只选择 idx 最小的一个,其余取消选择
for (let [prefix, arr] of groups.entries()) {
// 找到最小 idx 的节点(如果相同 idx 多个,选第一个遇到的)
arr.sort((a, b) => a.idx - b.idx);
let first = arr[0];
// 设定选中状态:第一个为 selected其余为 false
first.node.__selected = selected;
for (let i = 1; i < arr.length; i++) {
arr[i].node.__selected = false;
}
}
}
setLeafSelected(node, selected) {
node.__selected = selected;
if (node.Name) this.syncLinkedLeafsByName(node.Name, selected);
}
selectEnabledIfNoneSelected(otherLeafs) {
let hasSelected = otherLeafs.some(n => n.__selected);
if (!hasSelected && otherLeafs.length > 0) {
let enabledIdx = otherLeafs.findIndex(n => n.SpriteRenderer.Enabled);
otherLeafs.forEach((n, i) => this.setLeafSelected(n, i === enabledIdx && enabledIdx !== -1));
}
}
selectEnabledIfNoneSelectedByNode(node) {
if (node && node.Children) {
let otherLeafs = Object.values(node.Children).filter(n => n.SpriteRenderer && n.SpriteRenderer.Sprite);
this.selectEnabledIfNoneSelected(otherLeafs);
}
}
// 渲染整个树(与原 renderTreeSelector 行为对应)
render(rootNode) {
// Ensure we have a container to render into. Try to resolve again in case DOM changed.
if (!this.controlsContentDiv) {
this.controlsContentDiv = document.getElementById("controls-content") || document.getElementById('drawer-controls-content');
}
if (!this.controlsContentDiv) return; // nothing to render into yet
this.controlsContentDiv.innerHTML = "";
// 回读折叠状态(保持)
this.treeCollapseState = this.loadCollapseCache();
const isFirstRender = !window.__treeSelectorRendered;
window.__treeSelectorRendered = true;
let hasCache = false;
if (isFirstRender) {
hasCache = !!localStorage.getItem(this.getSelectionCacheKey());
if (hasCache) this.loadSelectionCache(rootNode);
}
// 寻找特殊节点引用
let armsNode = null, armLNode = null, armRNode = null;
function findSpecialNodes(node) {
if (node.Name === "Arms") armsNode = node;
if (node.Name === "ArmL") armLNode = node;
if (node.Name === "ArmR") armRNode = node;
if (node.Children) for (let k in node.Children) findSpecialNodes(node.Children[k]);
}
findSpecialNodes(rootNode);
// 递归渲染
const singleSelectGroups = ["ArmL", "ArmR", "Arms", "Eyes", "Mouth", "Eyes01", "Mouth01"];
const that = this;
function renderNode(node, path = []) {
if (!TreeSelector.hasLeafSprite(node)) return null;
const nodeDiv = document.createElement("div");
nodeDiv.className = "tree-parent-group";
const headerDiv = document.createElement("div");
headerDiv.className = "tree-header";
headerDiv.style.cursor = "pointer";
const nodeKey = path.concat(node.Name || "").join("/");
let collapsed = !!that.treeCollapseState[nodeKey];
const toggleBtn = document.createElement("span");
toggleBtn.className = "tree-toggle";
toggleBtn.textContent = collapsed ? "▶" : "▼";
headerDiv.appendChild(toggleBtn);
const titleSpan = document.createElement("span");
titleSpan.className = "tree-title";
titleSpan.textContent = node.Name || "(未命名)";
headerDiv.appendChild(titleSpan);
nodeDiv.appendChild(headerDiv);
const gridDiv = document.createElement("div");
gridDiv.className = "thumb-grid";
gridDiv.style.display = collapsed ? "none" : "grid";
// 收集直接叶节点子节点并排序
let leafNodes = [];
if (node.Children) {
const sortedKeys = TreeSelector.sortedChildKeys(node.Children);
for (let key of sortedKeys) {
let child = node.Children[key];
if (child.SpriteRenderer && child.SpriteRenderer.Sprite) leafNodes.push(child);
}
}
const isSingleSelect = singleSelectGroups.includes(node.Name);
// 首次渲染初始化选择状态(无缓存)
if (isFirstRender && leafNodes.length > 0 && !hasCache) {
let hasEnabled = leafNodes.some(n => n.SpriteRenderer.Enabled);
if (isSingleSelect) {
let idx = leafNodes.findIndex(n => n.SpriteRenderer.Enabled);
leafNodes.forEach((n, i) => n.__selected = (i === idx && hasEnabled ? true : false));
} else {
leafNodes.forEach(n => n.__selected = !!n.SpriteRenderer.Enabled && hasEnabled);
}
}
leafNodes.forEach((childNode, idx) => {
const thumbDiv = document.createElement("div");
thumbDiv.className = "thumb-node";
if (childNode.__selected) thumbDiv.classList.add("selected");
const img = document.createElement("img");
img.className = "thumb-img";
img.src = `/images/character/${that.app.currentCharacter}/${childNode.SpriteRenderer.Sprite.Name}.webp`;
img.alt = childNode.Name || "";
thumbDiv.appendChild(img);
const nameDiv = document.createElement("div");
nameDiv.className = "thumb-name";
nameDiv.textContent = childNode.Name || "(未命名)";
thumbDiv.appendChild(nameDiv);
thumbDiv.onclick = function() {
// Arms 与 ArmL/ArmR 互斥逻辑,同原实现
if (node.Name === "Arms" && childNode.__selected === false) {
if (armLNode && armLNode.Children) Object.values(armLNode.Children).forEach(n => that.setLeafSelected(n, false));
if (armRNode && armRNode.Children) Object.values(armRNode.Children).forEach(n => that.setLeafSelected(n, false));
} else if (node.Name === "Arms" && childNode.__selected === true) {
that.selectEnabledIfNoneSelectedByNode(armLNode);
that.selectEnabledIfNoneSelectedByNode(armRNode);
}
if ((node.Name === "ArmL" || node.Name === "ArmR") && !childNode.__selected) {
if (armsNode && armsNode.Children) {
Object.values(armsNode.Children)
.filter(n => n.SpriteRenderer && n.SpriteRenderer.Sprite)
.forEach(n => that.setLeafSelected(n, false));
}
let otherNode = node.Name === "ArmL" ? armRNode : armLNode;
that.selectEnabledIfNoneSelectedByNode(otherNode);
}
if (isSingleSelect) {
// 新增:如果是 ArmL / ArmR 分组,点击已选项时直接忽略(禁止手动取消)
if ((node.Name === "ArmL" || node.Name === "ArmR") && childNode.__selected) {
return;
}
if (childNode.__selected) {
that.setLeafSelected(childNode, false);
} else {
leafNodes.forEach(n => { if (n.__selected) that.setLeafSelected(n, false); });
that.setLeafSelected(childNode, true);
}
} else {
that.setLeafSelected(childNode, !childNode.__selected);
}
that.saveSelectionCache(rootNode);
that.render(rootNode);
that.app.renderer.composeAndDraw();
};
gridDiv.appendChild(thumbDiv);
});
nodeDiv.appendChild(gridDiv);
const childrenDiv = document.createElement("div");
childrenDiv.className = "tree-children";
childrenDiv.style.display = collapsed ? "none" : "block";
if (node.Children) {
const sortedKeys = TreeSelector.sortedChildKeys(node.Children);
for (let key of sortedKeys) {
let child = node.Children[key];
if (child.Children && Object.keys(child.Children).length > 0) {
const childTree = renderNode(child, path.concat(node.Name || ""));
if (childTree) childrenDiv.appendChild(childTree);
}
}
}
nodeDiv.appendChild(childrenDiv);
headerDiv.onclick = function() {
collapsed = !collapsed;
gridDiv.style.display = collapsed ? "none" : "grid";
childrenDiv.style.display = collapsed ? "none" : "block";
toggleBtn.textContent = collapsed ? "▶" : "▼";
that.treeCollapseState[nodeKey] = collapsed;
that.saveCollapseCache();
};
return nodeDiv;
}
const tree = renderNode(rootNode);
if (tree) this.controlsContentDiv.appendChild(tree);
}
}
class Renderer {
constructor(app) {
this.app = app;
this.offscreenCanvas = null;
this.offscreenCtx = null;
this.offscreenWidth = 0;
this.offscreenHeight = 0;
this.maskAlphaCache = {}; // 缓存 mask
this.firstDraw = true;
}
// 主画布绘制(使用 app.view 与 offscreenCanvas
drawToMainCanvas() {
const canvas = this.app.canvas;
const ctx = this.app.ctx;
if (!canvas || !ctx) return;
// Clear using display size aware clearing
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!this.offscreenCanvas) return;
// Ensure backing store matches display size
let dpr = window.devicePixelRatio || 1;
const displayWidth = Math.round(canvas.clientWidth * dpr);
const displayHeight = Math.round(canvas.clientHeight * dpr);
if (canvas.width !== displayWidth || canvas.height !== displayHeight) {
canvas.width = displayWidth;
canvas.height = displayHeight;
}
ctx.save();
if (this.app.panzoom && this.app.panzoom.isActive) {
// When panzoom is active the visual transform is handled by CSS transform
// so just draw the offscreen canvas to fit the physical canvas
try {
ctx.drawImage(this.offscreenCanvas, 0, 0, canvas.width, canvas.height);
} catch (e) {
// fallback if drawImage with sizing fails
ctx.drawImage(this.offscreenCanvas, 0, 0);
}
} else {
let scale = this.app.view.scale || 1;
let offsetX = (this.app.view.offsetX || 0) * dpr;
let offsetY = (this.app.view.offsetY || 0) * dpr;
if (this.firstDraw && this.offscreenWidth > 0 && this.offscreenHeight > 0) {
let scaleX = canvas.width / this.offscreenWidth;
let scaleY = canvas.height / this.offscreenHeight;
scale = Math.min(scaleX, scaleY, 1);
offsetX = (canvas.width - this.offscreenWidth * scale) / 2;
offsetY = (canvas.height - this.offscreenHeight * scale) / 2;
this.app.view.scale = scale;
this.app.view.offsetX = offsetX / dpr;
this.app.view.offsetY = offsetY / dpr;
this.firstDraw = false;
}
ctx.translate(offsetX, offsetY);
ctx.scale(scale, scale);
ctx.drawImage(this.offscreenCanvas, 0, 0);
}
ctx.restore();
}
// 合成并绘制(核心函数,参考原 composeAndDraw
composeAndDraw() {
const t0 = performance.now();
let selectedNodes = this.app.selector.getSelectedLeafNodes(this.app.rootNode);
selectedNodes.sort((a, b) => {
let sa = a.SpriteRenderer.SortingOrder || 0;
let sb = b.SpriteRenderer.SortingOrder || 0;
return sa - sb;
});
const t1 = performance.now();
console.log(`[composeAndDraw] 选中节点收集与排序耗时: ${(t1-t0).toFixed(2)}ms`);
let loadPromises = selectedNodes.map(node => {
let sprite = node.SpriteRenderer.Sprite;
if (!sprite || !sprite.Name) return Promise.resolve(null);
let img = new Image();
img.src = `/images/character/${this.app.currentCharacter}/${sprite.Name}.webp`;
return new Promise(resolve => {
img.onload = () => resolve({img, node});
img.onerror = () => resolve(null);
});
});
loadPromises.length === 0 && console.log("[composeAndDraw] 无需加载图片");
Promise.all(loadPromises).then(results => {
const t2 = performance.now();
console.log(`[composeAndDraw] 图片加载耗时: ${(t2-t1).toFixed(2)}ms`);
let imagesInfo = [];
let bboxes = [];
results.forEach(res => {
if (!res) return;
let {img, node} = res;
let trans = node.Transform || {};
let pos = trans.Position || {x:0, y:0, z:0};
let scale = trans.Scale || {x:1, y:1};
let pixels_to_units = (node.SpriteRenderer.Sprite.PixelsToUnits) || 100.0;
let pivot = node.SpriteRenderer.Sprite.Pivot || {x:0.5, y:0.5};
let scaled_w = img.width * scale.x;
let scaled_h = img.height * scale.y;
let color = node.SpriteRenderer.Color || {r:1,g:1,b:1,a:1};
let px = pos.x * pixels_to_units;
let py = pos.y * pixels_to_units;
let pivot_offset_x = pivot.x * scaled_w;
let pivot_offset_y = pivot.y * scaled_h;
let left = px - pivot_offset_x;
let right = left + scaled_w;
let top = py + (scaled_h - pivot_offset_y);
let bottom = top - scaled_h;
bboxes.push([left, top, right, bottom]);
imagesInfo.push({
img, px, py, pivot_offset_x, pivot_offset_y, scaled_w, scaled_h, color, left, top, spriteName: node.SpriteRenderer.Sprite.Name, scale, node
});
});
if (!imagesInfo.length) return;
let min_x = Math.min(...bboxes.map(b=>b[0]));
let max_x = Math.max(...bboxes.map(b=>b[2]));
let min_y = Math.min(...bboxes.map(b=>b[3]));
let max_y = Math.max(...bboxes.map(b=>b[1]));
this.offscreenWidth = Math.ceil(max_x - min_x);
this.offscreenHeight = Math.ceil(max_y - min_y);
this.offscreenCanvas = document.createElement("canvas");
this.offscreenCanvas.width = this.offscreenWidth;
this.offscreenCanvas.height = this.offscreenHeight;
this.offscreenCtx = this.offscreenCanvas.getContext("2d");
this.offscreenCtx.clearRect(0, 0, this.offscreenWidth, this.offscreenHeight);
// 清空 mask 缓存(每次合成新建或保留?保留可以重用;这里清空以避免孤立缓存)
this.maskAlphaCache = {};
const t3 = performance.now();
console.log(`[composeAndDraw] 图片信息与bbox计算耗时: ${(t3-t2).toFixed(2)}ms`);
const t4_start = performance.now();
imagesInfo.forEach((info, idx) => {
const t_layer_start = performance.now();
let {img, px, py, pivot_offset_x, pivot_offset_y, scaled_w, scaled_h, color, left, top, scale, node} = info;
// 临时 canvas 处理并缩放
const t_canvas_start = performance.now();
let tempCanvas = document.createElement("canvas");
tempCanvas.width = Math.max(1, Math.ceil(scaled_w));
tempCanvas.height = Math.max(1, Math.ceil(scaled_h));
let tempCtx = tempCanvas.getContext("2d");
tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height);
tempCtx.save();
// 为了避免二次放缩模糊,先 drawImage 原图到缩放后的大小
tempCtx.drawImage(img, 0, 0, tempCanvas.width, tempCanvas.height);
tempCtx.restore();
const t_canvas_end = performance.now();
// 颜色应用(替代 getImageData使用 source-in 技巧)
const t_color_start = performance.now();
if (!(color.r === 1 && color.g === 1 && color.b === 1 && color.a === 1)) {
let colorCanvas = document.createElement("canvas");
colorCanvas.width = tempCanvas.width;
colorCanvas.height = tempCanvas.height;
let colorCtx = colorCanvas.getContext("2d");
colorCtx.clearRect(0, 0, colorCanvas.width, colorCanvas.height);
colorCtx.fillStyle = `rgba(${Math.round(color.r*255)}, ${Math.round(color.g*255)}, ${Math.round(color.b*255)}, ${color.a})`;
colorCtx.fillRect(0, 0, colorCanvas.width, colorCanvas.height);
colorCtx.globalCompositeOperation = "source-in";
colorCtx.drawImage(tempCanvas, 0, 0);
tempCanvas = colorCanvas;
}
const t_color_end = performance.now();
let paste_x = Math.round(px - pivot_offset_x - min_x);
let paste_y = Math.round(max_y - py - (scaled_h - pivot_offset_y));
// mask 处理
const t_mask_start = performance.now();
let floats = null;
if (node.SpriteRenderer.Materials && node.SpriteRenderer.Materials.length > 0) {
floats = node.SpriteRenderer.Materials[0].Floats || {};
}
let stencilRef = floats && floats._StencilRef ? floats._StencilRef : 0;
let stencilComp = floats && floats._StencilComp ? floats._StencilComp : 0;
if (stencilRef !== 0 && stencilComp === 8) {
// 被 mask 图层:记录到 mask 缓存并同时绘制到最终结果
let maskObj = this.ensureMaskCanvas(stencilRef);
let maskCtx = maskObj.ctx;
maskCtx.globalCompositeOperation = "source-over";
maskCtx.drawImage(tempCanvas, paste_x, paste_y);
let materialName = (node.SpriteRenderer.Materials && node.SpriteRenderer.Materials[0].Name) || "";
this.offscreenCtx.globalCompositeOperation = this.getCompositeOp(materialName);
this.offscreenCtx.globalAlpha = 1;
this.offscreenCtx.drawImage(tempCanvas, paste_x, paste_y);
this.offscreenCtx.globalCompositeOperation = "source-over";
}
else if (stencilRef !== 0 && stencilComp === 4) {
// 使用之前的 mask 缓存对本图层裁剪
let maskObj = this.maskAlphaCache[stencilRef];
if (maskObj) {
let maskedCanvas = document.createElement("canvas");
maskedCanvas.width = tempCanvas.width;
maskedCanvas.height = tempCanvas.height;
let maskedCtx = maskedCanvas.getContext("2d");
// 把 mask 的对应区域绘制到 maskedCanvas
maskedCtx.drawImage(
maskObj.canvas,
paste_x, paste_y, tempCanvas.width, tempCanvas.height,
0, 0, tempCanvas.width, tempCanvas.height
);
// 用 source-in 将 tempCanvas 裁剪到 mask
maskedCtx.globalCompositeOperation = "source-in";
maskedCtx.drawImage(tempCanvas, 0, 0);
let materialName = (node.SpriteRenderer.Materials && node.SpriteRenderer.Materials[0].Name) || "";
this.offscreenCtx.globalCompositeOperation = this.getCompositeOp(materialName);
this.offscreenCtx.globalAlpha = 1;
this.offscreenCtx.drawImage(maskedCanvas, paste_x, paste_y);
this.offscreenCtx.globalCompositeOperation = "source-over";
}
} else {
// 普通图层
let materialName = (node.SpriteRenderer.Materials && node.SpriteRenderer.Materials[0].Name) || "";
this.offscreenCtx.globalCompositeOperation = this.getCompositeOp(materialName);
this.offscreenCtx.globalAlpha = 1;
this.offscreenCtx.drawImage(tempCanvas, paste_x, paste_y);
this.offscreenCtx.globalCompositeOperation = "source-over";
}
const t_mask_end = performance.now();
const t_layer_end = performance.now();
console.log(`[composeAndDraw] 图层${idx}(${node.Name || ''})耗时: 总${(t_layer_end-t_layer_start).toFixed(2)}ms, ` +
`canvas+缩放${(t_canvas_end-t_canvas_start).toFixed(2)}ms, ` +
`颜色${(t_color_end-t_color_start).toFixed(2)}ms, ` +
`mask/绘制${(t_mask_end-t_mask_start).toFixed(2)}ms`);
});
const t4_end = performance.now();
console.log(`[composeAndDraw] 图层合成耗时: ${(t4_end-t4_start).toFixed(2)}ms`);
// 合成完成后绘制到主画布(居中缩放)
const t6_start = performance.now();
this.drawToMainCanvas();
const t6_end = performance.now();
console.log(`[composeAndDraw] 主画布绘制耗时: ${(t6_end-t6_start).toFixed(2)}ms`);
console.log(`[composeAndDraw] 总耗时: ${(t6_end-t0).toFixed(2)}ms`);
});
}
saveImage() {
if (!this.offscreenCanvas) return;
let dataURL = this.offscreenCanvas.toDataURL("image/webp", 1.0);
let a = document.createElement("a");
a.href = dataURL;
a.download = `${this.app.currentCharacter}.webp`;
a.click();
}
// 新增:根据材质名返回合成模式
getCompositeOp(materialName) {
if (!materialName) return "source-over";
if (materialName.startsWith("Naninovel_Multiply")) return "multiply";
if (materialName.startsWith("Naninovel_Softlight")) return "luminosity";
return "source-over";
}
// 新增:确保 mask canvas 存在并返回对象
ensureMaskCanvas(stencilRef) {
if (!this.maskAlphaCache[stencilRef]) {
let maskCanvas = document.createElement("canvas");
maskCanvas.width = this.offscreenWidth;
maskCanvas.height = this.offscreenHeight;
let maskCtx = maskCanvas.getContext("2d");
maskCtx.clearRect(0, 0, this.offscreenWidth, this.offscreenHeight);
this.maskAlphaCache[stencilRef] = {canvas: maskCanvas, ctx: maskCtx};
}
return this.maskAlphaCache[stencilRef];
}
}
class App {
constructor(rootNode, currentCharacter) {
this.rootNode = rootNode;
this.currentCharacter = currentCharacter || window.currentCharacter || "unknown";
// DOM
this.parentSelect = document.getElementById("parentSelect");
this.childSelect = document.getElementById("childSelect");
this.canvas = document.getElementById("canvas");
this.ctx = this.canvas.getContext("2d");
this.saveBtn = document.getElementById("saveBtn");
// 视图状态(拖拽 & 缩放)
this.view = {
scale: 1,
offsetX: 0,
offsetY: 0,
dragging: false,
dragStartX: 0,
dragStartY: 0,
lastOffsetX: 0,
lastOffsetY: 0
};
this.isSplitterDragging = false;
// 子系统
this.selector = new TreeSelector(this);
this.renderer = new Renderer(this);
// Panzoom manager will enable touch-friendly pan/zoom using @panzoom/panzoom
this.panzoom = null;
// 事件与初始化
this.initEvents();
this.selector.render(this.rootNode);
this.renderer.composeAndDraw();
// Initialize Panzoom if available
if (window.Panzoom) {
this.panzoom = new PanzoomManager(this);
this.panzoom.init();
}
// 初始化移动抽屉引用
this.mobileDrawer = document.getElementById('mobile-drawer');
this.drawerHandle = document.getElementById('drawer-handle');
this.drawerContent = document.getElementById('drawer-controls-content');
// 保证移动抽屉内按钮运行:绑定移动版按钮到现有功能
const saveMobile = document.getElementById('saveBtn_mobile');
if (saveMobile) saveMobile.onclick = () => this.renderer.saveImage();
const backMobile = document.getElementById('backBtn_mobile');
if (backMobile) backMobile.onclick = () => { window.location.href = '/'; };
const resetMobile = document.getElementById('resetBtn_mobile');
if (resetMobile) resetMobile.onclick = () => {
this.selector.clearSelectionCache();
localStorage.removeItem(this.selector.getCollapseCacheKey());
window.__treeSelectorRendered = false;
this.selector.render(this.rootNode);
this.renderer.composeAndDraw();
};
// 绑定抽屉事件
this.setupDrawer();
// 根据视口决定 selector 渲染目标:移动端渲染到抽屉内,桌面渲染到 controls
const isMobile = window.innerWidth <= 800 && this.drawerContent;
if (isMobile) {
// 替换 selector 的容器引用并渲染
this.selector.controlsContentDiv = this.drawerContent;
this.selector.render(this.rootNode);
// 自动展开抽屉(移动端直接展示选择器)
if (this.openDrawer) this.openDrawer();
} else {
this.selector.controlsContentDiv = document.getElementById('controls-content');
this.selector.render(this.rootNode);
}
// 暴露给外部(主脚本)
window.app = this;
}
// 抽屉相关的初始化与事件绑定
setupDrawer() {
if (!this.mobileDrawer || !this.drawerHandle) return;
const drawer = this.mobileDrawer;
const handle = this.drawerHandle;
let startY = 0, startHeight = 0, dragging = false, touchMoved = false;
// 将 open/close 方法暴露到实例上,便于外部调用(比如备用按钮)
this.openDrawer = () => {
drawer.style.display = 'block'; // 强制显示(覆盖 media query 仅用于调试/降级)
drawer.classList.remove('drawer-closed');
drawer.classList.add('drawer-open');
drawer.setAttribute('aria-hidden', 'false');
};
this.closeDrawer = () => {
drawer.classList.remove('drawer-open');
drawer.classList.add('drawer-closed');
drawer.setAttribute('aria-hidden', 'true');
// 恢复 display如果媒体查询会隐藏的话
if (window.innerWidth > 800) drawer.style.display = '';
};
handle.addEventListener('click', (e) => {
if (drawer.classList.contains('drawer-open')) this.closeDrawer();
else this.openDrawer();
});
// touch drag
handle.addEventListener('touchstart', (e) => {
dragging = true;
touchMoved = false;
startY = e.touches[0].clientY;
startHeight = drawer.getBoundingClientRect().height;
e.preventDefault();
}, { passive: false });
handle.addEventListener('touchmove', (e) => {
if (!dragging) return;
let dy = startY - e.touches[0].clientY; // 向上为正
// 如果移动超过阈值,视为拖动
if (Math.abs(dy) > 6) touchMoved = true;
let newHeight = Math.max(48, Math.min(window.innerHeight * 0.9, startHeight + dy));
drawer.style.height = newHeight + 'px';
e.preventDefault();
}, { passive: false });
handle.addEventListener('touchend', (e) => {
dragging = false;
// 如果是轻触(无明显移动),则视为 tap切换抽屉
if (!touchMoved) {
if (drawer.classList.contains('drawer-open')) this.closeDrawer(); else this.openDrawer();
} else {
let rect = drawer.getBoundingClientRect();
// 如果高度超过屏幕的一半则打开,否则关闭
if (rect.height > window.innerHeight * 0.4) this.openDrawer(); else this.closeDrawer();
}
drawer.style.height = '';
e.preventDefault();
}, { passive: false });
// 不使用按钮打开抽屉(遵循用户要求),保留 openDrawer/closeDrawer 方法供内部调用
}
// 新增:从容器设置 canvas 尺寸
setCanvasSizeFromContainer() {
const canvasContainer = document.getElementById("canvas-container");
if (!canvasContainer) return;
const rect = canvasContainer.getBoundingClientRect();
this.canvas.width = rect.width;
this.canvas.height = rect.height;
}
// 新增:统一绑定按钮
attachButton(id, handler) {
const el = document.getElementById(id);
if (el) el.onclick = handler;
}
initEvents() {
// 拖拽
this.canvas.addEventListener("mousedown", (e) => {
this.view.dragging = true;
this.view.dragStartX = e.clientX;
this.view.dragStartY = e.clientY;
this.view.lastOffsetX = this.view.offsetX;
this.view.lastOffsetY = this.view.offsetY;
});
window.addEventListener("mousemove", (e) => {
if (this.view.dragging) {
this.view.offsetX = this.view.lastOffsetX + (e.clientX - this.view.dragStartX);
this.view.offsetY = this.view.lastOffsetY + (e.clientY - this.view.dragStartY);
this.renderer.drawToMainCanvas();
}
});
window.addEventListener("mouseup", () => {
this.view.dragging = false;
this.renderer.drawToMainCanvas();
});
// 滚轮缩放
this.canvas.addEventListener("wheel", (e) => {
// If panzoom is active, let it handle wheel events
if (this.panzoom && this.panzoom.isActive) return;
e.preventDefault();
let oldScale = this.view.scale;
if (e.deltaY < 0) this.view.scale *= 1.1;
else this.view.scale /= 1.1;
let rect = this.canvas.getBoundingClientRect();
let mx = e.clientX - rect.left;
let my = e.clientY - rect.top;
this.view.offsetX = mx - (mx - this.view.offsetX) * (this.view.scale / oldScale);
this.view.offsetY = my - (my - this.view.offsetY) * (this.view.scale / oldScale);
this.renderer.drawToMainCanvas();
}, { passive: false });
// 窗口大小自适应(非分割线拖动时)
window.addEventListener("resize", () => {
if (this.isSplitterDragging) return;
this.setCanvasSizeFromContainer();
this.renderer.drawToMainCanvas();
});
// 立即触发一次大小调整
this.setCanvasSizeFromContainer();
// 分割线拖动(保留原有优化)
(function(that){
const splitter = document.getElementById("splitter");
const controls = document.getElementById("controls");
if (!splitter || !controls) return;
let dragging = false;
let lastWidth = null;
let animationFrameId = null;
function updateWidth(e) {
const layoutRect = document.getElementById("main-layout").getBoundingClientRect();
let newWidth = e.clientX - layoutRect.left;
newWidth = Math.max(200, Math.min(newWidth, 800));
if (lastWidth !== newWidth) {
controls.style.flexBasis = newWidth + "px";
lastWidth = newWidth;
that.isSplitterDragging = true;
that.setCanvasSizeFromContainer();
that.renderer.drawToMainCanvas();
}
}
splitter.addEventListener("mousedown", function(e) {
dragging = true;
that.isSplitterDragging = true;
document.body.style.cursor = "ew-resize";
document.body.style.userSelect = "none";
});
window.addEventListener("mousemove", function(e) {
if (!dragging) return;
if (animationFrameId) return;
animationFrameId = requestAnimationFrame(() => {
updateWidth(e);
animationFrameId = null;
});
});
window.addEventListener("mouseup", function() {
if (dragging) {
dragging = false;
that.isSplitterDragging = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
animationFrameId = null;
}
});
})(this);
// 按钮事件:保存/返回/重置
this.attachButton("saveBtn", () => this.renderer.saveImage());
this.attachButton("backBtn", () => { window.location.href = '/'; });
this.attachButton("resetBtn", () => {
this.selector.clearSelectionCache();
localStorage.removeItem(this.selector.getCollapseCacheKey());
window.__treeSelectorRendered = false;
this.selector.render(this.rootNode);
this.renderer.composeAndDraw();
});
// Drawer 交互:手柄点击/拖拽打开/关闭抽屉(仅在移动端)
// 抽屉事件在构造函数中 setupDrawer() 里绑定(确保 DOM 已存在)
// 触摸事件支持:单指平移,双指捏合缩放
let lastTouchDist = 0;
let lastTouchCenter = null;
let gestureMode = null; // "pan" 或 "zoom"
this.canvas.addEventListener('touchstart', (e) => {
if (this.panzoom && this.panzoom.isActive) return;
if (e.touches.length === 1) {
gestureMode = "pan";
const t = e.touches[0];
this.view.dragging = true;
this.view.dragStartX = t.clientX;
this.view.dragStartY = t.clientY;
this.view.lastOffsetX = this.view.offsetX;
this.view.lastOffsetY = this.view.offsetY;
} else if (e.touches.length === 2) {
gestureMode = "zoom";
// 初始化捏合
const t0 = e.touches[0], t1 = e.touches[1];
lastTouchDist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY);
lastTouchCenter = { x: (t0.clientX + t1.clientX) / 2, y: (t0.clientY + t1.clientY) / 2 };
}
// 阻止页面滚动以获得更好的交互
e.preventDefault();
}, { passive: false });
this.canvas.addEventListener('touchmove', (e) => {
if (this.panzoom && this.panzoom.isActive) return;
if (gestureMode === "pan" && e.touches.length === 1 && this.view.dragging) {
const t = e.touches[0];
this.view.offsetX = this.view.lastOffsetX + (t.clientX - this.view.dragStartX);
this.view.offsetY = this.view.lastOffsetY + (t.clientY - this.view.dragStartY);
this.renderer.drawToMainCanvas();
} else if (gestureMode === "zoom" && e.touches.length === 2) {
const t0 = e.touches[0], t1 = e.touches[1];
const dist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY);
const center = { x: (t0.clientX + t1.clientX) / 2, y: (t0.clientY + t1.clientY) / 2 };
if (lastTouchDist > 0) {
let oldScale = this.view.scale;
let factor = dist / lastTouchDist;
this.view.scale = Math.max(0.1, Math.min(5, this.view.scale * factor));
// 根据中心点调整偏移以实现以双指中心为缩放锚点
let rect = this.canvas.getBoundingClientRect();
let mx = center.x - rect.left;
let my = center.y - rect.top;
this.view.offsetX = mx - (mx - this.view.offsetX) * (this.view.scale / oldScale);
this.view.offsetY = my - (my - this.view.offsetY) * (this.view.scale / oldScale);
this.renderer.drawToMainCanvas();
}
lastTouchDist = dist;
lastTouchCenter = center;
}
e.preventDefault();
}, { passive: false });
this.canvas.addEventListener('touchend', (e) => {
if (e.touches.length === 0) {
this.view.dragging = false;
lastTouchDist = 0;
lastTouchCenter = null;
gestureMode = null; // 重置模式
}
e.preventDefault();
});
}
}
const app = new App(rootNode, currentCharacter);