init: 剧情事件协作 Web 编辑器独立仓(从 SGame/tools/event_authoring 拆出)
This commit is contained in:
171
web/static/playtest.js
Normal file
171
web/static/playtest.js
Normal file
@ -0,0 +1,171 @@
|
||||
// 剧本试走:从首节点走,点选项/掷随机/手选战斗胜负,模拟银两/道具/友好度账面。
|
||||
// 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, "<").replace(/>/g, ">"); }
|
||||
|
||||
function renderLedger() {
|
||||
let h = "<h4>账面</h4>";
|
||||
h += '<div class="lg">银两:<b>' + state.银两 + "</b></div>";
|
||||
const items = Object.entries(state.道具).filter(([, v]) => v);
|
||||
h += '<div class="lg">道具:' + (items.length ? items.map(([k, v]) => k + "×" + v).join(",") : "—") + "</div>";
|
||||
const fr = Object.entries(state.友好度).filter(([, v]) => v);
|
||||
h += '<div class="lg">友好度:' + (fr.length ? fr.map(([k, v]) => nameOf(k) + "+" + v).join(",") : "—") + "</div>";
|
||||
if (state.入门.length) h += '<div class="lg">入门:' + state.入门.join(",") + "</div>";
|
||||
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", "<b>" + esc(nameOf(n.speaker || "P1")) + "</b>:" + esc(n.text)); loc = advance(loc); }
|
||||
else if (k === "dialogue") { add("spk", "<b>" + esc(nameOf(n.speaker)) + "</b>:" + esc(n.text)); loc = advance(loc); }
|
||||
else if (k === "move") { add("sys", "〔走位〕" + nameOf(n.actor) + " → " + (n.to || "")); loc = advance(loc); }
|
||||
else if (k === "anim") { add("sys", "〔动画〕" + nameOf(n.actor) + " " + (n.ani || "")); loc = advance(loc); }
|
||||
else if (k === "reward") { applyGrants(n.grants, "结算"); add("sys", "〔奖励结算〕"); loc = advance(loc); }
|
||||
else if (k === "out_ref") { add("sys", "〔进入子序列 " + 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 });
|
||||
}
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user