// 剧本试走:从首节点走,点选项/掷随机/手选战斗胜负,模拟银两/道具/友好度账面。 // out_ref 用返回栈进出子序列;skip 选项命中目标结局时按 skipReward 覆盖结算(对齐运行时)。 (function () { let IR, DICT, flow, ledger, state, retStack, pendingSkip; function nameOf(s) { const r = (IR.roles || []).find(x => x.slot === s); return s === "P1" ? "玩家" : (r ? r.name : s); } function grantForm(kind) { return ((DICT.grants || {})[kind] || {}).form; } function seqMap() { const m = {}; (IR.sequences || []).forEach(s => m[s.id] = s); return m; } function mainNode(id) { return (IR.nodes || []).find(n => n.id === id); } function ending(id) { return (IR.endings || []).find(e => e.id === id); } function nodeAt(loc) { if (!loc) return null; if (loc.seq) { const s = seqMap()[loc.seq]; return s && s.nodes.find(n => n.id === loc.id); } return mainNode(loc.id) || ending(loc.id); } function seqHas(seqId, id) { const s = seqMap()[seqId]; return s && s.nodes.some(n => n.id === id); } function advance(loc) { const n = nodeAt(loc); const nx = n && n.next; if (nx) { if (loc.seq && seqHas(loc.seq, nx)) return { seq: loc.seq, id: nx }; return { seq: null, id: nx }; } if (loc.seq) { const ex = retStack.pop(); return ex ? { seq: null, id: ex } : null; } return null; } function enterSeq(seqId, exit) { retStack.push(exit || ""); const s = seqMap()[seqId]; if (!s || !(s.nodes || []).length) return null; return { seq: seqId, id: s.nodes[0].id }; } function applyGrants(grants, srcLabel) { (grants || []).forEach(g => { const f = grantForm(g.kind); if (f === "money") state.银两 += (g.value || 0); else if (f === "item") state.道具[g.item] = (state.道具[g.item] || 0) + (g.value || 0); else if (f === "friend") state.友好度[g.target] = (state.友好度[g.target] || 0) + (g.value || 0); else if (f === "join") state.入门.push(nameOf(g.target)); }); if (grants && grants.length) renderLedger(); } function condMet(c) { if (!c) return true; if (c.kind === "银两") { if (c.op === ">=") return state.银两 >= c.value; if (c.op === ">") return state.银两 > c.value; if (c.op === "<=") return state.银两 <= c.value; if (c.op === "<") return state.银两 < c.value; if (c.op === "==") return state.银两 === c.value; } return true; // 未知条件不拦 } // ---- 渲染 ---- function add(cls, html) { const d = document.createElement("div"); d.className = "pt-step " + cls; d.innerHTML = html; flow.appendChild(d); flow.scrollTop = flow.scrollHeight; return d; } function esc(s) { return String(s == null ? "" : s).replace(/&/g, "&").replace(//g, ">"); } function renderLedger() { let h = "

账面

"; h += '
银两:' + state.银两 + "
"; const items = Object.entries(state.道具).filter(([, v]) => v); h += '
道具:' + (items.length ? items.map(([k, v]) => k + "×" + v).join(",") : "—") + "
"; const fr = Object.entries(state.友好度).filter(([, v]) => v); h += '
友好度:' + (fr.length ? fr.map(([k, v]) => nameOf(k) + "+" + v).join(",") : "—") + "
"; if (state.入门.length) h += '
入门:' + state.入门.join(",") + "
"; ledger.innerHTML = h; } function walk(loc) { let guard = 0; while (loc && guard++ < 500) { const n = nodeAt(loc); if (!n) { add("sys", "⚠ 断链:目标节点不存在,流程中断"); return; } const isEnd = !loc.seq && ending(loc.id); if (isEnd) { const e = n; let grants = e.grants; if (pendingSkip && pendingSkip.node === loc.id) { grants = pendingSkip.grants; add("sys", "(押注命中:结算改用 skip 彩头)"); } applyGrants(grants, "结局"); const res = { success: "成功", fail: "失败", end: "中性" }[e.result || "success"]; add("end", "★ 结局:" + esc(e.summary || loc.id) + "(" + res + ")"); add("sys", "—— 试走结束 ——"); return; } const k = n.kind; if (k === "narration") { add("spk", "" + esc(nameOf(n.speaker || "P1")) + ":" + esc(n.text)); loc = advance(loc); } else if (k === "dialogue") { add("spk", "" + esc(nameOf(n.speaker)) + ":" + esc(n.text)); loc = advance(loc); } else if (k === "move") { add("sys", "〔走位〕" + esc(nameOf(n.actor)) + " → " + esc(n.to || "")); loc = advance(loc); } else if (k === "anim") { add("sys", "〔动画〕" + esc(nameOf(n.actor)) + " " + esc(n.ani || "")); loc = advance(loc); } else if (k === "reward") { applyGrants(n.grants, "结算"); add("sys", "〔奖励结算〕"); loc = advance(loc); } else if (k === "out_ref") { add("sys", "〔进入子序列 " + esc(n.ref) + "〕"); loc = enterSeq(n.ref, n.next); } else if (k === "choice" || k === "choice_once") { renderChoice(n); return; } else if (k === "random") { renderRandom(n); return; } else if (k === "fight") { renderFight(n); return; } else { add("sys", "未知节点类型 " + k); loc = advance(loc); } } if (guard >= 500) add("sys", "⚠ 步数超限(疑似循环),中断"); } function choices(prompt, list) { const box = document.createElement("div"); box.className = "pt-step pt-choices"; box.appendChild(Object.assign(document.createElement("div"), { className: "pt-q", textContent: prompt })); list.forEach(it => { const b = document.createElement("button"); b.textContent = it.label; if (it.locked) b.className = "locked"; b.onclick = () => { box.querySelectorAll("button").forEach(x => x.disabled = true); b.style.borderColor = "#e6c878"; it.act(); }; box.appendChild(b); }); flow.appendChild(box); flow.scrollTop = flow.scrollHeight; } function renderChoice(n) { choices("请选择:", (n.options || []).map(o => { const ok = condMet(o.condition); let label = o.text; if (o.condition) label += " [需 " + o.condition.kind + o.condition.op + o.condition.value + (ok ? " ✓" : " ✗") + "]"; if (o.skip) label += " 〔押注跳过〕"; return { label, locked: !ok, act: () => { applyGrants((o.reward || {}).grants, "选项"); if (o.skip) pendingSkip = { node: o.skip.node, grants: (o.skip.reward || {}).grants || [] }; walk({ seq: null, id: o.goto }); } }; })); } function renderRandom(n) { const total = (n.branches || []).reduce((s, b) => s + (b.weight || 0), 0) || 1; choices("随机分支(手选):", (n.branches || []).map((b, i) => ({ label: "分支" + (i + 1) + "(权重 " + b.weight + ",约 " + Math.round(b.weight / total * 100) + "%)→ " + b.goto, act: () => walk({ seq: null, id: b.goto }) }))); } function renderFight(n) { choices("战斗 vs " + (n.camp2 || []).map(nameOf).join("、") + " —— 手选结果:", [ { label: "胜 → " + n.win, act: () => walk({ seq: null, id: n.win }) }, { label: "败 → " + n.lose, act: () => walk({ seq: null, id: n.lose }) }, ]); } function firstNode() { const indeg = {}; (IR.nodes || []).forEach(n => indeg[n.id] = 0); (IR.nodes || []).forEach(n => { const outs = [n.next].concat((n.options || []).map(o => o.goto), (n.branches || []).map(b => b.goto), n.kind === "fight" ? [n.win, n.lose] : []); outs.forEach(t => { if (t in indeg) indeg[t]++; }); }); const roots = (IR.nodes || []).filter(n => indeg[n.id] === 0); return (roots[0] || (IR.nodes || [])[0] || {}).id; } window.Playtest = { open(ir, dict) { IR = ir; DICT = dict; flow = document.getElementById("pt-flow"); ledger = document.getElementById("pt-ledger"); flow.innerHTML = ""; state = { 银两: 0, 道具: {}, 友好度: {}, 入门: [] }; retStack = []; pendingSkip = null; renderLedger(); document.getElementById("playtest-modal").classList.remove("hidden"); const start = firstNode(); if (!start) { add("sys", "没有可走的节点"); return; } walk({ seq: null, id: start }); } }; })();