1093 lines
54 KiB
JavaScript
1093 lines
54 KiB
JavaScript
import {
|
||
buildGraph,
|
||
cloneBook,
|
||
makeIndex,
|
||
primaryRatePerMinute,
|
||
summarizeBook,
|
||
traceIngredientClosure,
|
||
validateBook,
|
||
} from "./core.mjs";
|
||
|
||
const $ = (selector) => document.querySelector(selector);
|
||
const elements = {
|
||
app: $("#app"),
|
||
statRecipes: $("#statRecipes"),
|
||
statItems: $("#statItems"),
|
||
statMachines: $("#statMachines"),
|
||
tabRecipes: $("#tabRecipes"),
|
||
tabItems: $("#tabItems"),
|
||
tabMachines: $("#tabMachines"),
|
||
saveState: $("#saveState"),
|
||
undoButton: $("#undoButton"),
|
||
redoButton: $("#redoButton"),
|
||
importButton: $("#importButton"),
|
||
exportButton: $("#exportButton"),
|
||
issuesButton: $("#issuesButton"),
|
||
issueCount: $("#issueCount"),
|
||
saveButton: $("#saveButton"),
|
||
addEntityButton: $("#addEntityButton"),
|
||
typeTabs: $("#typeTabs"),
|
||
catalogSearch: $("#catalogSearch"),
|
||
stageFilter: $("#stageFilter"),
|
||
biomeFilter: $("#biomeFilter"),
|
||
catalogSummary: $("#catalogSummary"),
|
||
entityList: $("#entityList"),
|
||
graphTitle: $("#graphTitle"),
|
||
graphSubtitle: $("#graphSubtitle"),
|
||
graphMode: $("#graphMode"),
|
||
depthControl: $("#depthControl"),
|
||
graphDepth: $("#graphDepth"),
|
||
fitGraphButton: $("#fitGraphButton"),
|
||
graphStage: $("#graphStage"),
|
||
graph: $("#recipeGraph"),
|
||
graphViewport: $("#graphViewport"),
|
||
graphBackdrop: $("#graphBackdrop"),
|
||
graphEdges: $("#graphEdges"),
|
||
graphNodes: $("#graphNodes"),
|
||
graphEmpty: $("#graphEmpty"),
|
||
zoomOutButton: $("#zoomOutButton"),
|
||
zoomInButton: $("#zoomInButton"),
|
||
zoomOutput: $("#zoomOutput"),
|
||
routeSummary: $("#routeSummary"),
|
||
inspectorTitle: $("#inspectorTitle"),
|
||
inspectorKind: $("#inspectorKind"),
|
||
inspectorContent: $("#inspectorContent"),
|
||
issuesDialog: $("#issuesDialog"),
|
||
issuesSummary: $("#issuesSummary"),
|
||
issueFilter: $("#issueFilter"),
|
||
issueList: $("#issueList"),
|
||
helpDialog: $("#helpDialog"),
|
||
importFile: $("#importFile"),
|
||
toastStack: $("#toastStack"),
|
||
};
|
||
|
||
const TYPE_META = {
|
||
recipes: { label: "配方", glyph: "✦", singular: "recipe" },
|
||
items: { label: "物品", glyph: "◆", singular: "item" },
|
||
machines: { label: "设施", glyph: "▣", singular: "machine" },
|
||
};
|
||
|
||
const RECIPE_KINDS = [
|
||
["craft", "制造"], ["inscription", "刻印"], ["growth", "生长"],
|
||
["decay", "腐朽"], ["state", "状态转化"], ["separation", "分离"],
|
||
["ritual", "仪式"], ["result_bag", "结果袋"], ["recovery", "回收"],
|
||
];
|
||
const INPUT_MODES = [["consumed", "消耗"], ["catalyst", "催化"], ["fluid", "流体"], ["state", "状态"]];
|
||
const ITEM_CATEGORIES = ["raw", "material", "component", "fluid", "proof", "state", "anchor", "offering", "construct", "record", "relic", "waste"];
|
||
const DRAFT_KEY = "magic-foundry-recipe-book-draft-v1";
|
||
const MAX_HISTORY = 100;
|
||
|
||
const state = {
|
||
book: null,
|
||
selected: { type: "recipes", id: "perform_first_warp" },
|
||
activeType: "recipes",
|
||
search: "",
|
||
stage: "all",
|
||
biome: "all",
|
||
graphMode: "focus",
|
||
graphDepth: 1,
|
||
graphTransform: { x: 0, y: 0, scale: 1 },
|
||
graphBounds: null,
|
||
graphNeedsFit: true,
|
||
issues: [],
|
||
issueSeverity: "all",
|
||
history: [],
|
||
future: [],
|
||
dirty: false,
|
||
serverOnline: false,
|
||
saving: false,
|
||
savedBookJson: "",
|
||
technologyIds: null,
|
||
draftTimer: null,
|
||
pendingEdit: null,
|
||
};
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function short(value, length = 22) {
|
||
const text = String(value ?? "");
|
||
return text.length > length ? `${text.slice(0, length - 1)}…` : text;
|
||
}
|
||
|
||
function slugTime() {
|
||
return new Date().toISOString().replaceAll(":", "-").replace("T", "_").slice(0, 19);
|
||
}
|
||
|
||
function parseLines(value) {
|
||
return String(value || "").split(/[\n,,]/).map((entry) => entry.trim()).filter(Boolean);
|
||
}
|
||
|
||
function numberFromInput(input, nullable = false) {
|
||
if (nullable && input.value.trim() === "") return null;
|
||
const value = Number(input.value);
|
||
return Number.isFinite(value) ? value : 0;
|
||
}
|
||
|
||
function selectedEntity() {
|
||
return state.book?.[state.selected.type]?.find((entry) => entry.id === state.selected.id) || null;
|
||
}
|
||
|
||
function ensureSelection() {
|
||
if (selectedEntity()) return;
|
||
const list = state.book?.[state.activeType] || [];
|
||
if (list.length) state.selected = { type: state.activeType, id: list[0].id };
|
||
}
|
||
|
||
function optionHtml(entries, selected, getId = (entry) => entry.id, getLabel = (entry) => entry.name) {
|
||
return entries.map((entry) => {
|
||
const id = getId(entry);
|
||
return `<option value="${escapeHtml(id)}"${id === selected ? " selected" : ""}>${escapeHtml(getLabel(entry))}</option>`;
|
||
}).join("");
|
||
}
|
||
|
||
function toast(message, severity = "success", timeout = 2800) {
|
||
const node = document.createElement("div");
|
||
node.className = `toast ${severity}`;
|
||
node.textContent = message;
|
||
elements.toastStack.append(node);
|
||
window.setTimeout(() => node.remove(), timeout);
|
||
}
|
||
|
||
function scheduleDraft() {
|
||
window.clearTimeout(state.draftTimer);
|
||
state.draftTimer = window.setTimeout(() => {
|
||
try {
|
||
localStorage.setItem(DRAFT_KEY, JSON.stringify({ savedAt: new Date().toISOString(), book: state.book }));
|
||
} catch {
|
||
toast("浏览器草稿空间不足,请尽快保存或导出。", "warning", 5000);
|
||
}
|
||
}, 500);
|
||
}
|
||
|
||
function updateDirty() {
|
||
state.dirty = JSON.stringify(state.book) !== state.savedBookJson;
|
||
if (state.dirty) scheduleDraft();
|
||
else localStorage.removeItem(DRAFT_KEY);
|
||
}
|
||
|
||
function mutate(label, operation, { fitGraph = false } = {}) {
|
||
state.pendingEdit = null;
|
||
state.history.push({ label, book: cloneBook(state.book), selected: { ...state.selected } });
|
||
if (state.history.length > MAX_HISTORY) state.history.shift();
|
||
state.future.length = 0;
|
||
operation(state.book);
|
||
state.book.metadata ||= {};
|
||
state.book.metadata.updatedAt = new Date().toISOString();
|
||
updateDirty();
|
||
state.graphNeedsFit ||= fitGraph;
|
||
refreshAll();
|
||
}
|
||
|
||
function undo() {
|
||
state.pendingEdit = null;
|
||
const snapshot = state.history.pop();
|
||
if (!snapshot) return;
|
||
state.future.push({ label: snapshot.label, book: cloneBook(state.book), selected: { ...state.selected } });
|
||
state.book = snapshot.book;
|
||
state.selected = snapshot.selected;
|
||
updateDirty();
|
||
state.graphNeedsFit = true;
|
||
refreshAll();
|
||
toast(`已撤销:${snapshot.label}`);
|
||
}
|
||
|
||
function redo() {
|
||
state.pendingEdit = null;
|
||
const snapshot = state.future.pop();
|
||
if (!snapshot) return;
|
||
state.history.push({ label: snapshot.label, book: cloneBook(state.book), selected: { ...state.selected } });
|
||
state.book = snapshot.book;
|
||
state.selected = snapshot.selected;
|
||
updateDirty();
|
||
state.graphNeedsFit = true;
|
||
refreshAll();
|
||
toast(`已重做:${snapshot.label}`);
|
||
}
|
||
|
||
function validate() {
|
||
state.issues = validateBook(state.book, { technologyIds: state.technologyIds });
|
||
return state.issues;
|
||
}
|
||
|
||
function renderStatus() {
|
||
const summary = summarizeBook(state.book, state.issues);
|
||
elements.statRecipes.textContent = summary.recipes;
|
||
elements.statItems.textContent = summary.items;
|
||
elements.statMachines.textContent = summary.machines;
|
||
elements.tabRecipes.textContent = summary.recipes;
|
||
elements.tabItems.textContent = summary.items;
|
||
elements.tabMachines.textContent = summary.machines;
|
||
elements.undoButton.disabled = state.history.length === 0;
|
||
elements.undoButton.dataset.history = String(state.history.length);
|
||
elements.undoButton.dataset.pendingEdit = state.pendingEdit?.key || "";
|
||
elements.redoButton.disabled = state.future.length === 0;
|
||
elements.saveButton.disabled = state.saving;
|
||
elements.saveButton.textContent = state.saving ? "保存中…" : "保存数据";
|
||
|
||
const errors = state.issues.filter((entry) => entry.severity === "error").length;
|
||
const warnings = state.issues.filter((entry) => entry.severity === "warning").length;
|
||
elements.issueCount.textContent = errors ? `${errors} 个错误` : warnings ? `${warnings} 个警告` : "校验通过";
|
||
elements.issuesButton.classList.toggle("has-errors", errors > 0);
|
||
elements.issuesButton.classList.toggle("has-warnings", warnings > 0);
|
||
|
||
let className = "save-state ";
|
||
let text;
|
||
if (state.saving) {
|
||
className += "loading";
|
||
text = "正在写入";
|
||
} else if (state.dirty) {
|
||
className += state.serverOnline ? "dirty" : "offline";
|
||
text = state.serverOnline ? "有未保存修改" : "草稿尚未写回";
|
||
} else if (state.serverOnline) {
|
||
className += "saved";
|
||
text = "已保存到项目";
|
||
} else {
|
||
className += "offline";
|
||
text = "仅导入导出模式";
|
||
}
|
||
elements.saveState.className = className;
|
||
elements.saveState.querySelector("span").textContent = text;
|
||
}
|
||
|
||
function renderFilters() {
|
||
const currentStage = state.stage;
|
||
const currentBiome = state.biome;
|
||
elements.stageFilter.innerHTML = `<option value="all">全部阶段</option>${optionHtml(state.book.stages, currentStage)}`;
|
||
elements.biomeFilter.innerHTML = `<option value="all">全部生态区</option>${optionHtml(state.book.biomes, currentBiome)}`;
|
||
elements.stageFilter.value = state.book.stages.some((entry) => entry.id === currentStage) ? currentStage : "all";
|
||
elements.biomeFilter.value = state.book.biomes.some((entry) => entry.id === currentBiome) ? currentBiome : "all";
|
||
}
|
||
|
||
function renderCatalog() {
|
||
const query = state.search.trim().toLocaleLowerCase("zh-CN");
|
||
const stageOrder = new Map(state.book.stages.map((entry) => [entry.id, entry.order]));
|
||
const list = (state.book[state.activeType] || []).filter((entity) => {
|
||
if (state.stage !== "all" && entity.stage !== state.stage) return false;
|
||
if (state.biome !== "all" && entity.biome !== state.biome) return false;
|
||
if (!query) return true;
|
||
const haystack = [entity.name, entity.id, entity.category, entity.kind, entity.description, entity.notes, ...(entity.tags || [])].join(" ").toLocaleLowerCase("zh-CN");
|
||
return haystack.includes(query);
|
||
}).sort((a, b) => (stageOrder.get(a.stage) ?? 99) - (stageOrder.get(b.stage) ?? 99) || a.name.localeCompare(b.name, "zh-CN"));
|
||
|
||
elements.typeTabs.querySelectorAll("button").forEach((button) => button.classList.toggle("active", button.dataset.type === state.activeType));
|
||
elements.catalogSummary.textContent = `显示 ${list.length} / ${state.book[state.activeType].length} 个${TYPE_META[state.activeType].label}`;
|
||
if (!list.length) {
|
||
elements.entityList.innerHTML = `<div class="list-empty">没有符合条件的条目。<br>可以清除搜索或筛选。</div>`;
|
||
return;
|
||
}
|
||
|
||
let lastStage = null;
|
||
const stageNames = new Map(state.book.stages.map((entry) => [entry.id, entry.name]));
|
||
elements.entityList.innerHTML = list.map((entity) => {
|
||
const stageLabel = entity.stage !== lastStage ? `<div class="stage-group-label">${escapeHtml(stageNames.get(entity.stage) || entity.stage)}</div>` : "";
|
||
lastStage = entity.stage;
|
||
const meta = state.activeType === "recipes" ? `${entity.duration}s · ${entity.kind}` : state.activeType === "items" ? entity.category : `速度 ×${entity.baseSpeed}`;
|
||
return `${stageLabel}<button class="entity-row${state.selected.type === state.activeType && state.selected.id === entity.id ? " active" : ""}" type="button" data-type="${state.activeType}" data-id="${escapeHtml(entity.id)}">
|
||
<span class="entity-icon">${TYPE_META[state.activeType].glyph}</span>
|
||
<span class="entity-copy"><strong>${escapeHtml(entity.name)}</strong><small>${escapeHtml(entity.id)}</small></span>
|
||
<span class="entity-meta">${escapeHtml(meta)}</span>
|
||
</button>`;
|
||
}).join("");
|
||
}
|
||
|
||
function commonFields(entity) {
|
||
return `<div class="inspector-section">
|
||
<h3 class="section-title">基本信息</h3>
|
||
<div class="form-grid">
|
||
<label class="form-field full"><span>显示名称</span><input type="text" data-field="name" value="${escapeHtml(entity.name)}"></label>
|
||
<label class="form-field full"><span>稳定 ID</span><input class="id-field" type="text" data-action="rename-id" value="${escapeHtml(entity.id)}"><small class="field-help">改名会同步更新所有引用;运行时接入后不建议频繁修改。</small></label>
|
||
<label class="form-field"><span>阶段</span><select data-field="stage">${optionHtml(state.book.stages, entity.stage)}</select></label>
|
||
<label class="form-field"><span>生态区</span><select data-field="biome">${optionHtml(state.book.biomes, entity.biome)}</select></label>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function linkButtons(type, entities) {
|
||
if (!entities.length) return `<span class="field-help">暂无</span>`;
|
||
return `<div class="dependency-links">${entities.map((entry) => `<button type="button" data-action="navigate" data-type="${type}" data-id="${escapeHtml(entry.id)}">${escapeHtml(entry.name)}</button>`).join("")}</div>`;
|
||
}
|
||
|
||
function renderItemInspector(item) {
|
||
const index = makeIndex(state.book);
|
||
const producers = index.producers.get(item.id) || [];
|
||
const consumers = index.consumers.get(item.id) || [];
|
||
const categories = [...new Set([...ITEM_CATEGORIES, ...state.book.items.map((entry) => entry.category).filter(Boolean)])];
|
||
return `${commonFields(item)}
|
||
<div class="inspector-section">
|
||
<h3 class="section-title">物料属性</h3>
|
||
<div class="form-grid">
|
||
<label class="form-field"><span>类别</span><select data-field="category">${optionHtml(categories, item.category, (entry) => entry, (entry) => entry)}</select></label>
|
||
<label class="checkbox-field"><input type="checkbox" data-field="isRaw"${item.isRaw ? " checked" : ""}> 世界原料</label>
|
||
<label class="form-field"><span>单件质量</span><input type="number" min="0.001" step="0.1" data-field="mass" value="${escapeHtml(item.mass)}"></label>
|
||
<label class="form-field"><span>堆叠上限</span><input type="number" min="1" step="1" data-field="stackSize" value="${escapeHtml(item.stackSize)}"></label>
|
||
<label class="form-field full"><span>法则标签(逗号分隔)</span><input type="text" data-field="tags" data-array="comma" value="${escapeHtml((item.tags || []).join(", "))}"></label>
|
||
<label class="form-field full"><span>策划备注</span><textarea data-field="notes">${escapeHtml(item.notes || "")}</textarea></label>
|
||
</div>
|
||
</div>
|
||
<div class="inspector-section">
|
||
<h3 class="section-title">依赖关系</h3>
|
||
<div class="inline-stats"><div><strong>${producers.length}</strong><span>生产配方</span></div><div><strong>${consumers.length}</strong><span>消费配方</span></div><div><strong>${item.mass}</strong><span>质量</span></div></div>
|
||
<div class="form-field" style="margin-top:10px"><span>由这些配方产出</span>${linkButtons("recipes", producers)}</div>
|
||
<div class="form-field" style="margin-top:10px"><span>用于这些配方</span>${linkButtons("recipes", consumers)}</div>
|
||
</div>
|
||
${entityActions("items")}`;
|
||
}
|
||
|
||
function renderInputRows(recipe) {
|
||
const itemOptions = [...state.book.items].sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
|
||
return (recipe.inputs || []).map((entry, index) => `<div class="io-row input">
|
||
<div class="io-main">
|
||
<select aria-label="输入物品" data-io="inputs" data-index="${index}" data-key="itemId">${optionHtml(itemOptions, entry.itemId)}</select>
|
||
<input aria-label="输入数量" type="number" min="0.001" step="0.1" data-io="inputs" data-index="${index}" data-key="amount" value="${escapeHtml(entry.amount)}">
|
||
<select aria-label="输入方式" data-io="inputs" data-index="${index}" data-key="mode">${optionHtml(INPUT_MODES, entry.mode || "consumed", (item) => item[0], (item) => item[1])}</select>
|
||
<button class="row-delete" type="button" aria-label="删除输入" data-action="delete-io" data-side="inputs" data-index="${index}">×</button>
|
||
</div>
|
||
</div>`).join("") || `<p class="field-help">没有物品输入;这通常表示世界采集或环境生成。</p>`;
|
||
}
|
||
|
||
function renderOutputRows(recipe) {
|
||
const itemOptions = [...state.book.items].sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
|
||
return (recipe.outputs || []).map((entry, index) => `<div class="io-row output">
|
||
<div class="io-main">
|
||
<select aria-label="产出物品" data-io="outputs" data-index="${index}" data-key="itemId">${optionHtml(itemOptions, entry.itemId)}</select>
|
||
<input aria-label="期望数量" type="number" min="0" step="0.1" data-io="outputs" data-index="${index}" data-key="amount" value="${escapeHtml(entry.amount)}">
|
||
<button class="row-delete" type="button" aria-label="删除产出" data-action="delete-io" data-side="outputs" data-index="${index}">×</button>
|
||
</div>
|
||
<div class="io-extra">
|
||
<label>最小<input type="number" min="0" step="0.1" placeholder="—" data-nullable data-io="outputs" data-index="${index}" data-key="min" value="${entry.min ?? ""}"></label>
|
||
<label>最大<input type="number" min="0" step="0.1" placeholder="—" data-nullable data-io="outputs" data-index="${index}" data-key="max" value="${entry.max ?? ""}"></label>
|
||
<label>概率<input type="number" min="0" max="1" step="0.01" placeholder="—" data-nullable data-io="outputs" data-index="${index}" data-key="chance" value="${entry.chance ?? ""}"></label>
|
||
<label>权重<input type="number" min="0" step="1" placeholder="—" data-nullable data-io="outputs" data-index="${index}" data-key="weight" value="${entry.weight ?? ""}"></label>
|
||
</div>
|
||
</div>`).join("") || `<p class="field-help">至少需要一项产出,校验会阻止空产出配方被保存。</p>`;
|
||
}
|
||
|
||
function renderRecipeInspector(recipe) {
|
||
const machine = state.book.machines.find((entry) => entry.id === recipe.machineId);
|
||
const rate = primaryRatePerMinute(recipe, machine);
|
||
const consumed = (recipe.inputs || []).filter((entry) => entry.mode !== "catalyst").reduce((sum, entry) => sum + Number(entry.amount || 0), 0);
|
||
return `${commonFields(recipe)}
|
||
<div class="inspector-section">
|
||
<h3 class="section-title">制作规则</h3>
|
||
<div class="form-grid">
|
||
<label class="form-field"><span>制作设施</span><select data-field="machineId">${optionHtml(state.book.machines, recipe.machineId)}</select></label>
|
||
<label class="form-field"><span>法则类型</span><select data-field="kind">${optionHtml(RECIPE_KINDS, recipe.kind, (item) => item[0], (item) => item[1])}</select></label>
|
||
<label class="form-field"><span>单次耗时(秒)</span><input type="number" min="0.01" step="0.1" data-field="duration" value="${escapeHtml(recipe.duration)}"></label>
|
||
<label class="form-field"><span>解锁科技 ID</span><input class="id-field" type="text" data-field="unlockedBy" value="${escapeHtml(recipe.unlockedBy || "")}"></label>
|
||
<label class="form-field"><span>魔力 / 秒</span><input type="number" min="0" step="0.1" data-field="manaPerSecond" value="${escapeHtml(recipe.manaPerSecond || 0)}"></label>
|
||
<label class="form-field"><span>单次熵增</span><input type="number" min="0" step="0.1" data-field="entropy" value="${escapeHtml(recipe.entropy || 0)}"></label>
|
||
<label class="checkbox-field full"><input type="checkbox" data-field="enabled"${recipe.enabled !== false ? " checked" : ""}> 启用此配方</label>
|
||
</div>
|
||
</div>
|
||
<div class="inspector-section">
|
||
<h3 class="section-title"><span>输入 <small>(${(recipe.inputs || []).length})</small></span><button type="button" data-action="add-io" data-side="inputs">+ 添加输入</button></h3>
|
||
${renderInputRows(recipe)}
|
||
</div>
|
||
<div class="inspector-section">
|
||
<h3 class="section-title"><span>产出 <small>(${(recipe.outputs || []).length})</small></span><button type="button" data-action="add-io" data-side="outputs">+ 添加产出</button></h3>
|
||
${renderOutputRows(recipe)}
|
||
</div>
|
||
<div class="inspector-section">
|
||
<h3 class="section-title">仪式与策划说明</h3>
|
||
<div class="form-grid">
|
||
<label class="form-field full"><span>环境 / 同步条件(每行一项)</span><textarea data-field="conditions" data-array="lines">${escapeHtml((recipe.conditions || []).join("\n"))}</textarea></label>
|
||
<label class="form-field full"><span>法则标签(逗号分隔)</span><input type="text" data-field="tags" data-array="comma" value="${escapeHtml((recipe.tags || []).join(", "))}"></label>
|
||
<label class="form-field full"><span>策划备注</span><textarea data-field="notes">${escapeHtml(recipe.notes || "")}</textarea></label>
|
||
</div>
|
||
</div>
|
||
<div class="inspector-section">
|
||
<h3 class="section-title">即时估算</h3>
|
||
<div class="inline-stats"><div><strong>${rate.toFixed(1)}</strong><span>主产物 / 分</span></div><div><strong>${consumed.toFixed(1)}</strong><span>每批消耗量</span></div><div><strong>${Number(recipe.entropy || 0).toFixed(1)}</strong><span>每批熵增</span></div></div>
|
||
</div>
|
||
<button type="button" class="help-link" data-action="help">催化、状态、权重和概率分别是什么意思?</button>
|
||
${entityActions("recipes", true)}`;
|
||
}
|
||
|
||
function renderMachineInspector(machine) {
|
||
const recipes = state.book.recipes.filter((entry) => entry.machineId === machine.id);
|
||
return `${commonFields(machine)}
|
||
<div class="inspector-section">
|
||
<h3 class="section-title">设施属性</h3>
|
||
<div class="form-grid">
|
||
<label class="form-field"><span>基础速度倍率</span><input type="number" min="0.01" step="0.05" data-field="baseSpeed" value="${escapeHtml(machine.baseSpeed)}"></label>
|
||
<label class="form-field full"><span>法则标签(逗号分隔)</span><input type="text" data-field="tags" data-array="comma" value="${escapeHtml((machine.tags || []).join(", "))}"></label>
|
||
<label class="form-field full"><span>设施说明</span><textarea data-field="description">${escapeHtml(machine.description || "")}</textarea></label>
|
||
</div>
|
||
</div>
|
||
<div class="inspector-section">
|
||
<h3 class="section-title">承载配方 <small>(${recipes.length})</small></h3>
|
||
${linkButtons("recipes", recipes)}
|
||
</div>
|
||
${entityActions("machines")}`;
|
||
}
|
||
|
||
function entityActions(type, canDuplicate = false) {
|
||
return `<div class="inspector-actions">
|
||
${canDuplicate ? `<button class="button ghost" type="button" data-action="duplicate">复制配方</button>` : ""}
|
||
<button class="button ghost danger" type="button" data-action="delete-entity">删除${TYPE_META[type].label}</button>
|
||
</div>`;
|
||
}
|
||
|
||
function renderInspector() {
|
||
const entity = selectedEntity();
|
||
if (!entity) {
|
||
elements.inspectorTitle.textContent = "选择一个条目";
|
||
elements.inspectorKind.textContent = "—";
|
||
elements.inspectorContent.innerHTML = `<div class="empty-inspector"><span>✦</span><p>从左侧目录或中央配方树选择一个条目。</p></div>`;
|
||
return;
|
||
}
|
||
elements.inspectorTitle.textContent = entity.name;
|
||
elements.inspectorKind.textContent = TYPE_META[state.selected.type].label;
|
||
if (state.selected.type === "items") elements.inspectorContent.innerHTML = renderItemInspector(entity);
|
||
else if (state.selected.type === "recipes") elements.inspectorContent.innerHTML = renderRecipeInspector(entity);
|
||
else elements.inspectorContent.innerHTML = renderMachineInspector(entity);
|
||
}
|
||
|
||
function focusGraphLayout(graph) {
|
||
const nodeMap = new Map(graph.nodes.map((node) => [node.key, node]));
|
||
const adjacency = new Map(graph.nodes.map((node) => [node.key, []]));
|
||
for (const edge of graph.edges) {
|
||
adjacency.get(edge.source)?.push({ key: edge.target, delta: 1 });
|
||
adjacency.get(edge.target)?.push({ key: edge.source, delta: -1 });
|
||
}
|
||
const selectedKey = `${state.selected.type}:${state.selected.id}`;
|
||
const ranks = new Map([[selectedKey, 0]]);
|
||
const queue = [selectedKey];
|
||
while (queue.length) {
|
||
const key = queue.shift();
|
||
for (const neighbor of adjacency.get(key) || []) {
|
||
if (!ranks.has(neighbor.key)) {
|
||
ranks.set(neighbor.key, ranks.get(key) + neighbor.delta);
|
||
queue.push(neighbor.key);
|
||
}
|
||
}
|
||
}
|
||
for (const node of graph.nodes) if (!ranks.has(node.key)) ranks.set(node.key, 0);
|
||
const rankValues = [...new Set(ranks.values())].sort((a, b) => a - b);
|
||
const columns = new Map(rankValues.map((rank) => [rank, graph.nodes.filter((node) => ranks.get(node.key) === rank)]));
|
||
const stageOrder = new Map(state.book.stages.map((entry) => [entry.id, entry.order]));
|
||
for (const column of columns.values()) column.sort((a, b) => (stageOrder.get(a.stage) ?? 99) - (stageOrder.get(b.stage) ?? 99) || a.label.localeCompare(b.label, "zh-CN"));
|
||
const maxRows = Math.max(1, ...[...columns.values()].map((column) => column.length));
|
||
const columnWidth = 236;
|
||
const rowHeight = 76;
|
||
const positions = new Map();
|
||
const bands = [];
|
||
rankValues.forEach((rank, columnIndex) => {
|
||
const column = columns.get(rank);
|
||
const x = columnIndex * columnWidth;
|
||
const yOffset = (maxRows - column.length) * rowHeight / 2;
|
||
column.forEach((node, rowIndex) => {
|
||
const isRecipe = node.type === "recipes";
|
||
positions.set(node.key, { x: x + 22, y: 54 + yOffset + rowIndex * rowHeight, width: isRecipe ? 188 : 174, height: isRecipe ? 62 : 54 });
|
||
});
|
||
bands.push({
|
||
x,
|
||
width: columnWidth - 10,
|
||
height: maxRows * rowHeight + 100,
|
||
label: rank < 0 ? `上游 ${Math.abs(rank)}` : rank > 0 ? `下游 ${rank}` : "当前选择",
|
||
});
|
||
});
|
||
return { positions, bands, width: Math.max(rankValues.length * columnWidth, 400), height: maxRows * rowHeight + 100 };
|
||
}
|
||
|
||
function stagedGraphLayout(graph) {
|
||
const stages = new Map(state.book.stages.map((entry) => [entry.id, entry]));
|
||
const stageIds = [...new Set(graph.nodes.map((node) => node.stage))].sort((a, b) => (stages.get(a)?.order ?? 99) - (stages.get(b)?.order ?? 99));
|
||
const positions = new Map();
|
||
const bands = [];
|
||
const bandWidth = 670;
|
||
let maxHeight = 280;
|
||
|
||
stageIds.forEach((stageId, stageIndex) => {
|
||
const stageNodes = graph.nodes.filter((node) => node.stage === stageId);
|
||
const recipes = stageNodes.filter((node) => node.type === "recipes");
|
||
const machines = stageNodes.filter((node) => node.type === "machines");
|
||
const items = stageNodes.filter((node) => node.type === "items");
|
||
const itemConnections = new Map(items.map((item) => [item.key, { in: 0, out: 0 }]));
|
||
graph.edges.forEach((edge) => {
|
||
if (itemConnections.has(edge.source)) itemConnections.get(edge.source).out += 1;
|
||
if (itemConnections.has(edge.target)) itemConnections.get(edge.target).in += 1;
|
||
});
|
||
const leftItems = items.filter((item) => itemConnections.get(item.key)?.out > 0);
|
||
const rightItems = items.filter((item) => !leftItems.includes(item));
|
||
const x = stageIndex * bandWidth;
|
||
leftItems.forEach((node, index) => positions.set(node.key, { x: x + 28, y: 60 + index * 72, width: 174, height: 54 }));
|
||
recipes.forEach((node, index) => positions.set(node.key, { x: x + 240, y: 60 + index * 82, width: 188, height: 62 }));
|
||
rightItems.forEach((node, index) => positions.set(node.key, { x: x + 466, y: 60 + index * 72, width: 174, height: 54 }));
|
||
machines.forEach((node, index) => positions.set(node.key, { x: x + 240, y: 74 + recipes.length * 82 + index * 70, width: 188, height: 54 }));
|
||
const rows = Math.max(leftItems.length * 72, recipes.length * 82 + machines.length * 70, rightItems.length * 72, 190);
|
||
const height = rows + 110;
|
||
maxHeight = Math.max(maxHeight, height);
|
||
bands.push({ x, width: bandWidth - 16, height, label: stages.get(stageId)?.name || stageId });
|
||
});
|
||
return { positions, bands, width: Math.max(stageIds.length * bandWidth, 400), height: maxHeight };
|
||
}
|
||
|
||
function graphLayout(graph) {
|
||
return state.graphMode === "focus" ? focusGraphLayout(graph) : stagedGraphLayout(graph);
|
||
}
|
||
|
||
function edgePath(source, target) {
|
||
const sourceCenter = source.x + source.width / 2;
|
||
const targetCenter = target.x + target.width / 2;
|
||
const forward = sourceCenter <= targetCenter;
|
||
const sx = forward ? source.x + source.width : source.x;
|
||
const tx = forward ? target.x : target.x + target.width;
|
||
const sy = source.y + source.height / 2;
|
||
const ty = target.y + target.height / 2;
|
||
const bend = Math.max(55, Math.abs(tx - sx) * .44);
|
||
const c1 = forward ? sx + bend : sx - bend;
|
||
const c2 = forward ? tx - bend : tx + bend;
|
||
return { d: `M ${sx} ${sy} C ${c1} ${sy}, ${c2} ${ty}, ${tx} ${ty}`, mx: (sx + tx) / 2, my: (sy + ty) / 2 };
|
||
}
|
||
|
||
function renderGraph() {
|
||
const entity = selectedEntity();
|
||
const graph = buildGraph(state.book, {
|
||
mode: state.graphMode,
|
||
depth: state.graphDepth,
|
||
selection: state.selected,
|
||
stage: state.stage,
|
||
biome: state.biome,
|
||
});
|
||
const layout = graphLayout(graph);
|
||
state.graphBounds = { x: 0, y: 0, width: layout.width, height: layout.height };
|
||
elements.graphEmpty.hidden = graph.nodes.length > 0;
|
||
elements.depthControl.classList.toggle("is-disabled", state.graphMode === "full");
|
||
elements.graphMode.querySelectorAll("button").forEach((button) => button.classList.toggle("active", button.dataset.mode === state.graphMode));
|
||
elements.graphTitle.textContent = entity ? entity.name : "配方树";
|
||
elements.graphSubtitle.textContent = state.graphMode === "focus" ? `显示选中条目前后 ${state.graphDepth} 层关系` : `显示当前筛选下的全部关系(${graph.nodes.length} 个节点)`;
|
||
|
||
elements.graphBackdrop.innerHTML = layout.bands.map((band) => `<g class="stage-band"><rect x="${band.x + 4}" y="8" width="${band.width}" height="${Math.max(layout.height - 16, band.height)}" rx="16"></rect><text x="${band.x + 24}" y="35">${escapeHtml(band.label)}</text></g>`).join("");
|
||
elements.graphEdges.innerHTML = graph.edges.map((edge) => {
|
||
const source = layout.positions.get(edge.source);
|
||
const target = layout.positions.get(edge.target);
|
||
if (!source || !target) return "";
|
||
const path = edgePath(source, target);
|
||
const amount = Number(edge.amount);
|
||
return `<g><path class="graph-edge ${escapeHtml(edge.kind)}" d="${path.d}"></path>${Number.isFinite(amount) ? `<text class="edge-label" x="${path.mx}" y="${path.my - 4}">×${amount}</text>` : ""}</g>`;
|
||
}).join("");
|
||
elements.graphNodes.innerHTML = graph.nodes.map((node) => {
|
||
const position = layout.positions.get(node.key);
|
||
if (!position) return "";
|
||
const singular = TYPE_META[node.type]?.singular || "item";
|
||
const selected = state.selected.type === node.type && state.selected.id === node.id;
|
||
return `<g class="graph-node ${singular}${selected ? " selected" : ""}" data-type="${node.type}" data-id="${escapeHtml(node.id)}" transform="translate(${position.x} ${position.y})">
|
||
<rect width="${position.width}" height="${position.height}" rx="10"></rect>
|
||
<text class="node-glyph" x="13" y="22">${TYPE_META[node.type]?.glyph || "◆"}</text>
|
||
<text class="node-label" x="31" y="22">${escapeHtml(short(node.label, 20))}</text>
|
||
<text class="node-subtitle" x="13" y="${position.height - 12}">${escapeHtml(short(node.subtitle, 29))}</text>
|
||
</g>`;
|
||
}).join("");
|
||
|
||
const critical = traceIngredientClosure(state.book, "first_warp_record");
|
||
const selectedSummary = entity ? `${TYPE_META[state.selected.type].label}“${entity.name}”` : "未选择条目";
|
||
elements.routeSummary.textContent = `${selectedSummary} · 首次折跃主线 ${critical.items.length} 种物品 / ${critical.recipes.length} 道配方 / ${critical.missing.length} 处断点`;
|
||
updateGraphTransform();
|
||
if (state.graphNeedsFit) {
|
||
state.graphNeedsFit = false;
|
||
requestAnimationFrame(fitGraph);
|
||
}
|
||
}
|
||
|
||
function updateGraphTransform() {
|
||
const { x, y, scale } = state.graphTransform;
|
||
elements.graphViewport.setAttribute("transform", `translate(${x} ${y}) scale(${scale})`);
|
||
elements.zoomOutput.value = `${Math.round(scale * 100)}%`;
|
||
elements.zoomOutput.textContent = `${Math.round(scale * 100)}%`;
|
||
}
|
||
|
||
function fitGraph() {
|
||
const bounds = state.graphBounds;
|
||
const rect = elements.graph.getBoundingClientRect();
|
||
if (!bounds || rect.width < 20 || rect.height < 20) return;
|
||
const padding = 44;
|
||
const scale = Math.min(1.1, Math.max(.12, Math.min((rect.width - padding * 2) / bounds.width, (rect.height - padding * 2) / bounds.height)));
|
||
state.graphTransform = {
|
||
scale,
|
||
x: (rect.width - bounds.width * scale) / 2 - bounds.x * scale,
|
||
y: (rect.height - bounds.height * scale) / 2 - bounds.y * scale,
|
||
};
|
||
updateGraphTransform();
|
||
}
|
||
|
||
function zoomGraph(factor, clientX, clientY) {
|
||
const rect = elements.graph.getBoundingClientRect();
|
||
const px = clientX == null ? rect.width / 2 : clientX - rect.left;
|
||
const py = clientY == null ? rect.height / 2 : clientY - rect.top;
|
||
const previous = state.graphTransform.scale;
|
||
const next = Math.min(2.2, Math.max(.12, previous * factor));
|
||
const worldX = (px - state.graphTransform.x) / previous;
|
||
const worldY = (py - state.graphTransform.y) / previous;
|
||
state.graphTransform.x = px - worldX * next;
|
||
state.graphTransform.y = py - worldY * next;
|
||
state.graphTransform.scale = next;
|
||
updateGraphTransform();
|
||
}
|
||
|
||
function renderIssues() {
|
||
const errors = state.issues.filter((entry) => entry.severity === "error").length;
|
||
const warnings = state.issues.filter((entry) => entry.severity === "warning").length;
|
||
const info = state.issues.filter((entry) => entry.severity === "info").length;
|
||
elements.issuesSummary.textContent = `${errors} 个错误 · ${warnings} 个警告 · ${info} 条提示`;
|
||
elements.issueFilter.querySelectorAll("button").forEach((button) => button.classList.toggle("active", button.dataset.severity === state.issueSeverity));
|
||
const issues = state.issues.filter((entry) => state.issueSeverity === "all" || entry.severity === state.issueSeverity);
|
||
elements.issueList.innerHTML = issues.length ? issues.map((entry) => `<button class="issue-row ${entry.severity}" type="button" data-type="${escapeHtml(entry.entityType)}" data-id="${escapeHtml(entry.entityId)}">
|
||
<span class="issue-dot"></span><span class="issue-copy"><strong>${escapeHtml(entry.message)}</strong><small>${escapeHtml(entry.entityType)}${entry.entityId ? ` / ${escapeHtml(entry.entityId)}` : ""}</small></span><span class="issue-code">${escapeHtml(entry.code)}</span>
|
||
</button>`).join("") : `<div class="no-issues">✓ 当前筛选下没有问题</div>`;
|
||
}
|
||
|
||
function refreshAll() {
|
||
ensureSelection();
|
||
validate();
|
||
renderStatus();
|
||
renderCatalog();
|
||
renderInspector();
|
||
renderGraph();
|
||
renderIssues();
|
||
}
|
||
|
||
function selectEntity(type, id, { switchTab = false, fitGraph = true } = {}) {
|
||
if (!state.book?.[type]?.some((entry) => entry.id === id)) return;
|
||
state.selected = { type, id };
|
||
if (switchTab) state.activeType = type;
|
||
state.graphNeedsFit ||= fitGraph;
|
||
renderCatalog();
|
||
renderInspector();
|
||
renderGraph();
|
||
}
|
||
|
||
function uniqueId(prefix, collection) {
|
||
let suffix = 1;
|
||
let candidate = `${prefix}_${suffix}`;
|
||
const ids = new Set(collection.map((entry) => entry.id));
|
||
while (ids.has(candidate)) candidate = `${prefix}_${++suffix}`;
|
||
return candidate;
|
||
}
|
||
|
||
function addEntity() {
|
||
const type = state.activeType;
|
||
const stage = state.stage !== "all" ? state.stage : state.book.stages[0]?.id;
|
||
const biome = state.biome !== "all" ? state.biome : state.book.biomes[0]?.id;
|
||
let entity;
|
||
if (type === "items") {
|
||
entity = { id: uniqueId("new_item", state.book.items), name: "新物品", category: "material", stage, biome, mass: 1, stackSize: 100, isRaw: false, tags: [], notes: "" };
|
||
} else if (type === "machines") {
|
||
entity = { id: uniqueId("new_machine", state.book.machines), name: "新设施", stage, biome, baseSpeed: 1, tags: [], description: "" };
|
||
} else {
|
||
entity = { id: uniqueId("new_recipe", state.book.recipes), name: "新配方", stage, biome, machineId: state.book.machines[0]?.id || "", kind: "craft", duration: 1, unlockedBy: "", inputs: [], outputs: state.book.items[0] ? [{ itemId: state.book.items[0].id, amount: 1 }] : [], manaPerSecond: 0, entropy: 0, conditions: [], tags: [], notes: "", enabled: true };
|
||
}
|
||
mutate(`新增${TYPE_META[type].label}`, (book) => book[type].push(entity), { fitGraph: true });
|
||
selectEntity(type, entity.id, { switchTab: true });
|
||
toast(`已新增${TYPE_META[type].label},请填写右侧属性。`);
|
||
}
|
||
|
||
function renameSelected(nextId) {
|
||
const entity = selectedEntity();
|
||
const type = state.selected.type;
|
||
const previousId = entity.id;
|
||
const clean = nextId.trim();
|
||
if (clean === previousId) return;
|
||
if (!/^[a-z][a-z0-9_]*$/.test(clean)) {
|
||
toast("ID 只能使用小写字母、数字和下划线,并以字母开头。", "error", 4500);
|
||
renderInspector();
|
||
return;
|
||
}
|
||
if (state.book[type].some((entry) => entry.id === clean)) {
|
||
toast(`ID “${clean}” 已存在。`, "error");
|
||
renderInspector();
|
||
return;
|
||
}
|
||
const affected = type === "items"
|
||
? state.book.recipes.reduce((count, recipe) => count + [...(recipe.inputs || []), ...(recipe.outputs || [])].filter((entry) => entry.itemId === previousId).length, 0)
|
||
: type === "machines" ? state.book.recipes.filter((recipe) => recipe.machineId === previousId).length : 0;
|
||
if (affected && !window.confirm(`这会同步修改 ${affected} 处配方引用。继续将 ${previousId} 改为 ${clean}?`)) {
|
||
renderInspector();
|
||
return;
|
||
}
|
||
mutate(`重命名 ${previousId}`, (book) => {
|
||
book[type].find((entry) => entry.id === previousId).id = clean;
|
||
if (type === "items") {
|
||
for (const recipe of book.recipes) for (const entry of [...(recipe.inputs || []), ...(recipe.outputs || [])]) if (entry.itemId === previousId) entry.itemId = clean;
|
||
}
|
||
if (type === "machines") for (const recipe of book.recipes) if (recipe.machineId === previousId) recipe.machineId = clean;
|
||
state.selected.id = clean;
|
||
}, { fitGraph: true });
|
||
toast(`已更新 ID,并同步 ${affected} 处引用。`);
|
||
}
|
||
|
||
function duplicateRecipe() {
|
||
const recipe = selectedEntity();
|
||
if (!recipe || state.selected.type !== "recipes") return;
|
||
const copy = cloneBook(recipe);
|
||
copy.id = uniqueId(`${recipe.id}_copy`, state.book.recipes);
|
||
copy.name = `${recipe.name}(副本)`;
|
||
mutate(`复制配方 ${recipe.name}`, (book) => book.recipes.push(copy), { fitGraph: true });
|
||
selectEntity("recipes", copy.id, { switchTab: true });
|
||
}
|
||
|
||
function deleteSelected() {
|
||
const entity = selectedEntity();
|
||
const type = state.selected.type;
|
||
if (!entity) return;
|
||
const index = makeIndex(state.book);
|
||
if (type === "items") {
|
||
const references = (index.producers.get(entity.id)?.length || 0) + (index.consumers.get(entity.id)?.length || 0);
|
||
if (references) return toast(`不能删除:仍有 ${references} 道配方引用这个物品。请先改接配方。`, "error", 5000);
|
||
}
|
||
if (type === "machines") {
|
||
const references = state.book.recipes.filter((recipe) => recipe.machineId === entity.id).length;
|
||
if (references) return toast(`不能删除:仍有 ${references} 道配方使用这个设施。请先更换设施。`, "error", 5000);
|
||
}
|
||
if (!window.confirm(`确认删除${TYPE_META[type].label}“${entity.name}”?可以用撤销恢复。`)) return;
|
||
mutate(`删除${TYPE_META[type].label} ${entity.name}`, (book) => {
|
||
const position = book[type].findIndex((entry) => entry.id === entity.id);
|
||
book[type].splice(position, 1);
|
||
const fallback = book[type][Math.min(position, book[type].length - 1)];
|
||
state.selected = fallback ? { type, id: fallback.id } : { type: "recipes", id: book.recipes[0]?.id || "" };
|
||
}, { fitGraph: true });
|
||
}
|
||
|
||
function changeEntityField(target) {
|
||
const entity = selectedEntity();
|
||
if (!entity) return;
|
||
const field = target.dataset.field;
|
||
let value;
|
||
if (target.type === "checkbox") value = target.checked;
|
||
else if (target.dataset.array) value = parseLines(target.value);
|
||
else if (target.type === "number") value = numberFromInput(target);
|
||
else value = target.value;
|
||
if (JSON.stringify(entity[field]) === JSON.stringify(value)) return;
|
||
mutate(`修改 ${entity.name} / ${field}`, () => { selectedEntity()[field] = value; });
|
||
}
|
||
|
||
function changeIoField(target) {
|
||
const recipe = selectedEntity();
|
||
if (!recipe || state.selected.type !== "recipes") return;
|
||
const side = target.dataset.io;
|
||
const index = Number(target.dataset.index);
|
||
const key = target.dataset.key;
|
||
const entry = recipe[side]?.[index];
|
||
if (!entry) return;
|
||
let value = target.type === "number" ? numberFromInput(target, target.hasAttribute("data-nullable")) : target.value;
|
||
mutate(`修改 ${recipe.name} / ${side}`, () => {
|
||
const current = selectedEntity()[side][index];
|
||
if (value == null) delete current[key];
|
||
else current[key] = value;
|
||
});
|
||
}
|
||
|
||
function isLiveInput(target) {
|
||
return target.matches('input[type="text"], input[type="number"], textarea') && !target.matches('[data-action="rename-id"]');
|
||
}
|
||
|
||
function beginLiveEdit(target) {
|
||
if (!isLiveInput(target)) return;
|
||
const editKey = [state.selected.type, state.selected.id, target.dataset.field, target.dataset.io, target.dataset.index, target.dataset.key].join(":");
|
||
if (state.pendingEdit?.key === editKey) return;
|
||
state.pendingEdit = null;
|
||
const entity = selectedEntity();
|
||
if (!entity) return;
|
||
const label = `修改 ${entity.name} / ${target.dataset.field || target.dataset.io || "字段"}`;
|
||
state.history.push({ label, book: cloneBook(state.book), selected: { ...state.selected } });
|
||
if (state.history.length > MAX_HISTORY) state.history.shift();
|
||
state.future.length = 0;
|
||
state.pendingEdit = { key: editKey, label };
|
||
}
|
||
|
||
function applyLiveInput(target) {
|
||
if (!isLiveInput(target)) return;
|
||
const entity = selectedEntity();
|
||
if (!entity) return;
|
||
if (target.dataset.io) {
|
||
const entry = entity[target.dataset.io]?.[Number(target.dataset.index)];
|
||
if (!entry) return;
|
||
const value = target.type === "number" ? numberFromInput(target, target.hasAttribute("data-nullable")) : target.value;
|
||
if ((value == null && entry[target.dataset.key] == null) || JSON.stringify(entry[target.dataset.key]) === JSON.stringify(value)) return;
|
||
beginLiveEdit(target);
|
||
if (value == null) delete entry[target.dataset.key];
|
||
else entry[target.dataset.key] = value;
|
||
} else if (target.dataset.field) {
|
||
let value;
|
||
if (target.dataset.array) value = parseLines(target.value);
|
||
else if (target.type === "number") value = numberFromInput(target);
|
||
else value = target.value;
|
||
if (JSON.stringify(entity[target.dataset.field]) === JSON.stringify(value)) return;
|
||
beginLiveEdit(target);
|
||
entity[target.dataset.field] = value;
|
||
}
|
||
state.book.metadata ||= {};
|
||
state.book.metadata.updatedAt = new Date().toISOString();
|
||
updateDirty();
|
||
validate();
|
||
renderStatus();
|
||
if (["name", "tags"].includes(target.dataset.field)) renderCatalog();
|
||
renderGraph();
|
||
renderIssues();
|
||
}
|
||
|
||
function finalizeLiveEdit() {
|
||
if (!state.pendingEdit) return;
|
||
state.pendingEdit = null;
|
||
renderStatus();
|
||
}
|
||
|
||
function addIo(side) {
|
||
const recipe = selectedEntity();
|
||
const firstItem = state.book.items[0];
|
||
if (!recipe || state.selected.type !== "recipes" || !firstItem) return;
|
||
mutate(`为 ${recipe.name} 添加${side === "inputs" ? "输入" : "产出"}`, () => {
|
||
if (side === "inputs") selectedEntity().inputs.push({ itemId: firstItem.id, amount: 1, mode: "consumed" });
|
||
else selectedEntity().outputs.push({ itemId: firstItem.id, amount: 1 });
|
||
}, { fitGraph: true });
|
||
}
|
||
|
||
function deleteIo(side, index) {
|
||
const recipe = selectedEntity();
|
||
if (!recipe || state.selected.type !== "recipes") return;
|
||
mutate(`删除 ${recipe.name} 的${side === "inputs" ? "输入" : "产出"}`, () => selectedEntity()[side].splice(index, 1), { fitGraph: true });
|
||
}
|
||
|
||
function downloadBook() {
|
||
const blob = new Blob([`${JSON.stringify(state.book, null, 2)}\n`], { type: "application/json" });
|
||
const url = URL.createObjectURL(blob);
|
||
const anchor = document.createElement("a");
|
||
anchor.href = url;
|
||
anchor.download = `recipe-book_${slugTime()}.json`;
|
||
anchor.click();
|
||
URL.revokeObjectURL(url);
|
||
toast("已导出 JSON;导出不会清除“未保存”状态。", "success", 4000);
|
||
}
|
||
|
||
async function saveBook() {
|
||
finalizeLiveEdit();
|
||
const issues = validate();
|
||
const errors = issues.filter((entry) => entry.severity === "error");
|
||
if (errors.length) {
|
||
renderIssues();
|
||
elements.issuesDialog.showModal();
|
||
return toast(`有 ${errors.length} 个错误,修复后才能保存。`, "error", 5000);
|
||
}
|
||
if (!state.serverOnline) {
|
||
downloadBook();
|
||
return toast("本地保存服务未连接,已改为导出。请用启动器打开工具以直接写回项目。", "warning", 6500);
|
||
}
|
||
state.saving = true;
|
||
renderStatus();
|
||
try {
|
||
const response = await fetch("/api/book", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(state.book) });
|
||
const result = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(result.message || `保存失败(HTTP ${response.status})`);
|
||
state.savedBookJson = JSON.stringify(state.book);
|
||
state.dirty = false;
|
||
state.history.length = 0;
|
||
state.future.length = 0;
|
||
localStorage.removeItem(DRAFT_KEY);
|
||
toast(`已写回项目,并生成备份 ${result.backup || ""}`.trim(), "success", 4500);
|
||
} catch (error) {
|
||
state.serverOnline = false;
|
||
toast(`${error.message}。数据仍保留在浏览器草稿中。`, "error", 6000);
|
||
} finally {
|
||
state.saving = false;
|
||
renderStatus();
|
||
}
|
||
}
|
||
|
||
async function importBook(file) {
|
||
try {
|
||
const imported = JSON.parse(await file.text());
|
||
const issues = validateBook(imported, { technologyIds: state.technologyIds });
|
||
const errors = issues.filter((entry) => entry.severity === "error");
|
||
if (errors.length) throw new Error(`文件有 ${errors.length} 个结构错误:${errors[0].message}`);
|
||
state.history.push({ label: "导入数据", book: cloneBook(state.book), selected: { ...state.selected } });
|
||
state.future.length = 0;
|
||
state.book = imported;
|
||
updateDirty();
|
||
state.selected = imported.recipes.some((entry) => entry.id === "perform_first_warp") ? { type: "recipes", id: "perform_first_warp" } : { type: "recipes", id: imported.recipes[0]?.id || "" };
|
||
state.activeType = state.selected.type;
|
||
state.graphNeedsFit = true;
|
||
renderFilters();
|
||
refreshAll();
|
||
toast(`已导入 ${file.name},请确认后点击“保存数据”。`, "success", 4500);
|
||
} catch (error) {
|
||
toast(`导入失败:${error.message}`, "error", 6500);
|
||
} finally {
|
||
elements.importFile.value = "";
|
||
}
|
||
}
|
||
|
||
async function loadTechnologyIds() {
|
||
try {
|
||
const response = await fetch("../../docs/tech-tree-nodes-v0.2.csv", { cache: "no-store" });
|
||
if (!response.ok) return;
|
||
const text = await response.text();
|
||
state.technologyIds = new Set(text.split(/\r?\n/).slice(1).map((line) => line.split(",", 1)[0].trim()).filter(Boolean));
|
||
} catch {
|
||
state.technologyIds = null;
|
||
}
|
||
}
|
||
|
||
async function loadInitialBook() {
|
||
let book;
|
||
try {
|
||
const response = await fetch("/api/book", { cache: "no-store" });
|
||
if (!response.ok) throw new Error("API unavailable");
|
||
book = await response.json();
|
||
state.serverOnline = true;
|
||
} catch {
|
||
try {
|
||
const response = await fetch("../../data/recipe-book.json", { cache: "no-store" });
|
||
if (!response.ok) throw new Error("file unavailable");
|
||
book = await response.json();
|
||
} catch {
|
||
elements.app.setAttribute("aria-busy", "false");
|
||
elements.saveState.className = "save-state offline";
|
||
elements.saveState.querySelector("span").textContent = "等待导入数据";
|
||
elements.importFile.click();
|
||
toast("无法自动读取配方书。请选择 data/recipe-book.json。", "warning", 8000);
|
||
return;
|
||
}
|
||
}
|
||
|
||
await loadTechnologyIds();
|
||
state.book = book;
|
||
state.savedBookJson = JSON.stringify(book);
|
||
try {
|
||
const draft = JSON.parse(localStorage.getItem(DRAFT_KEY) || "null");
|
||
const draftTime = Date.parse(draft?.book?.metadata?.updatedAt || draft?.savedAt || 0);
|
||
const bookTime = Date.parse(book?.metadata?.updatedAt || 0);
|
||
if (draft?.book && draftTime > bookTime && window.confirm("检测到比项目文件更新的浏览器草稿。要恢复尚未保存的修改吗?")) {
|
||
state.book = draft.book;
|
||
updateDirty();
|
||
toast("已恢复浏览器草稿;确认无误后请保存到项目。", "warning", 5500);
|
||
}
|
||
} catch {
|
||
localStorage.removeItem(DRAFT_KEY);
|
||
}
|
||
renderFilters();
|
||
refreshAll();
|
||
elements.app.setAttribute("aria-busy", "false");
|
||
}
|
||
|
||
elements.typeTabs.addEventListener("click", (event) => {
|
||
const button = event.target.closest("button[data-type]");
|
||
if (!button) return;
|
||
state.activeType = button.dataset.type;
|
||
const current = state.book[state.activeType].find((entry) => entry.id === state.selected.id);
|
||
if (!current) {
|
||
const first = state.book[state.activeType][0];
|
||
if (first) state.selected = { type: state.activeType, id: first.id };
|
||
}
|
||
state.graphNeedsFit = true;
|
||
refreshAll();
|
||
});
|
||
|
||
elements.entityList.addEventListener("click", (event) => {
|
||
const row = event.target.closest(".entity-row");
|
||
if (row) selectEntity(row.dataset.type, row.dataset.id);
|
||
});
|
||
|
||
elements.catalogSearch.addEventListener("input", (event) => { state.search = event.target.value; renderCatalog(); });
|
||
elements.stageFilter.addEventListener("change", (event) => { state.stage = event.target.value; state.graphNeedsFit = true; renderCatalog(); renderGraph(); });
|
||
elements.biomeFilter.addEventListener("change", (event) => { state.biome = event.target.value; state.graphNeedsFit = true; renderCatalog(); renderGraph(); });
|
||
elements.addEntityButton.addEventListener("click", addEntity);
|
||
|
||
elements.inspectorContent.addEventListener("change", (event) => {
|
||
const target = event.target;
|
||
if (target.matches('[data-action="rename-id"]')) renameSelected(target.value);
|
||
else if (isLiveInput(target)) finalizeLiveEdit();
|
||
else if (target.dataset.io) changeIoField(target);
|
||
else if (target.dataset.field) changeEntityField(target);
|
||
});
|
||
elements.inspectorContent.addEventListener("input", (event) => applyLiveInput(event.target));
|
||
elements.inspectorContent.addEventListener("focusout", (event) => { if (isLiveInput(event.target)) finalizeLiveEdit(); });
|
||
|
||
elements.inspectorContent.addEventListener("click", (event) => {
|
||
const button = event.target.closest("button[data-action]");
|
||
if (!button) return;
|
||
const action = button.dataset.action;
|
||
if (action === "navigate") selectEntity(button.dataset.type, button.dataset.id, { switchTab: true });
|
||
else if (action === "add-io") addIo(button.dataset.side);
|
||
else if (action === "delete-io") deleteIo(button.dataset.side, Number(button.dataset.index));
|
||
else if (action === "duplicate") duplicateRecipe();
|
||
else if (action === "delete-entity") deleteSelected();
|
||
else if (action === "help") elements.helpDialog.showModal();
|
||
});
|
||
|
||
elements.graphMode.addEventListener("click", (event) => {
|
||
const button = event.target.closest("button[data-mode]");
|
||
if (!button) return;
|
||
state.graphMode = button.dataset.mode;
|
||
state.graphNeedsFit = true;
|
||
renderGraph();
|
||
});
|
||
elements.graphDepth.addEventListener("change", (event) => { state.graphDepth = Number(event.target.value); state.graphNeedsFit = true; renderGraph(); });
|
||
elements.fitGraphButton.addEventListener("click", fitGraph);
|
||
elements.zoomInButton.addEventListener("click", () => zoomGraph(1.2));
|
||
elements.zoomOutButton.addEventListener("click", () => zoomGraph(1 / 1.2));
|
||
|
||
let drag = null;
|
||
elements.graph.addEventListener("pointerdown", (event) => {
|
||
if (event.button !== 0) return;
|
||
drag = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, originX: state.graphTransform.x, originY: state.graphTransform.y, moved: false, target: event.target.closest(".graph-node") };
|
||
elements.graph.setPointerCapture(event.pointerId);
|
||
elements.graph.classList.add("dragging");
|
||
});
|
||
elements.graph.addEventListener("pointermove", (event) => {
|
||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||
const dx = event.clientX - drag.startX;
|
||
const dy = event.clientY - drag.startY;
|
||
if (Math.abs(dx) + Math.abs(dy) > 5) drag.moved = true;
|
||
if (drag.moved) {
|
||
state.graphTransform.x = drag.originX + dx;
|
||
state.graphTransform.y = drag.originY + dy;
|
||
updateGraphTransform();
|
||
}
|
||
});
|
||
elements.graph.addEventListener("pointerup", (event) => {
|
||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||
if (!drag.moved && drag.target) selectEntity(drag.target.dataset.type, drag.target.dataset.id, { switchTab: true, fitGraph: false });
|
||
drag = null;
|
||
elements.graph.classList.remove("dragging");
|
||
});
|
||
elements.graph.addEventListener("wheel", (event) => { event.preventDefault(); zoomGraph(event.deltaY < 0 ? 1.12 : 1 / 1.12, event.clientX, event.clientY); }, { passive: false });
|
||
|
||
elements.undoButton.addEventListener("click", () => { finalizeLiveEdit(); undo(); });
|
||
elements.redoButton.addEventListener("click", () => { finalizeLiveEdit(); redo(); });
|
||
elements.importButton.addEventListener("click", () => elements.importFile.click());
|
||
elements.importFile.addEventListener("change", () => { if (elements.importFile.files[0]) importBook(elements.importFile.files[0]); });
|
||
elements.exportButton.addEventListener("click", downloadBook);
|
||
elements.saveButton.addEventListener("click", saveBook);
|
||
elements.issuesButton.addEventListener("click", () => { renderIssues(); elements.issuesDialog.showModal(); });
|
||
elements.issueFilter.addEventListener("click", (event) => {
|
||
const button = event.target.closest("button[data-severity]");
|
||
if (!button) return;
|
||
state.issueSeverity = button.dataset.severity;
|
||
renderIssues();
|
||
});
|
||
elements.issueList.addEventListener("click", (event) => {
|
||
const row = event.target.closest(".issue-row");
|
||
if (!row || !TYPE_META[row.dataset.type] || !row.dataset.id) return;
|
||
elements.issuesDialog.close();
|
||
selectEntity(row.dataset.type, row.dataset.id, { switchTab: true });
|
||
});
|
||
|
||
window.addEventListener("keydown", (event) => {
|
||
const modifier = event.ctrlKey || event.metaKey;
|
||
if (modifier && event.key.toLowerCase() === "s") { event.preventDefault(); finalizeLiveEdit(); saveBook(); }
|
||
else if (modifier && event.key.toLowerCase() === "z" && !event.shiftKey) { event.preventDefault(); finalizeLiveEdit(); undo(); }
|
||
else if (modifier && (event.key.toLowerCase() === "y" || (event.key.toLowerCase() === "z" && event.shiftKey))) { event.preventDefault(); finalizeLiveEdit(); redo(); }
|
||
else if (!modifier && event.key === "/" && !["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement?.tagName)) { event.preventDefault(); elements.catalogSearch.focus(); }
|
||
});
|
||
window.addEventListener("beforeunload", (event) => { if (state.dirty) { event.preventDefault(); event.returnValue = ""; } });
|
||
window.addEventListener("resize", () => { if (state.graphBounds) fitGraph(); });
|
||
|
||
loadInitialBook();
|