Files
story-edit-web/ir_to_html.py

290 lines
15 KiB
Python
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.

# -*- coding: utf-8 -*-
"""Story IR -> 单文件可视化 HTML 面板生成器。
用法:
python ir_to_html.py samples/yuye_koumen.ir.json
python ir_to_html.py samples/yuye_koumen.ir.json -o out.html
产出一个自包含、离线可打开的 .html分层分支树 + 点节点看台词/角色/奖励 +
奖励总览 + 导出 IR。所有渲染逻辑在前端IR 以 JSON 内联进页面。
"""
import argparse
import json
import os
import sys
TEMPLATE = r"""<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<title>__TITLE__ · Story 面板</title>
<style>
* { box-sizing: border-box; }
body { margin:0; font-family:"Microsoft YaHei","PingFang SC",sans-serif;
background:#161310; color:#e8e0d4; }
header { padding:14px 22px; background:#1f1a15; border-bottom:1px solid #3a322a; }
header h1 { margin:0; font-size:20px; color:#e6c878; }
header .meta { margin-top:4px; font-size:13px; color:#9a8f7e; }
header .roles { margin-top:8px; display:flex; flex-wrap:wrap; gap:8px; }
.role { font-size:12px; padding:3px 9px; border-radius:11px;
background:#2a241d; border:1px solid #4a4030; }
.role b { color:#e6c878; }
#wrap { display:flex; height:calc(100vh - 86px); }
#graph { position:relative; flex:1; overflow:auto; padding:30px; }
#svg { position:absolute; top:0; left:0; pointer-events:none; }
#layers { position:relative; z-index:2; margin:0 auto; }
.node { background:#262019; border:1.5px solid #4a4030; border-radius:9px;
padding:9px 12px; width:188px; cursor:pointer; transition:.15s;
box-shadow:0 2px 6px rgba(0,0,0,.4); }
.node:hover { border-color:#e6c878; transform:translateY(-2px); }
.node.sel { border-color:#e6c878; box-shadow:0 0 0 2px rgba(230,200,120,.4); }
.node .k { font-size:11px; color:#b89a5a; font-weight:bold; }
.node .t { font-size:13px; margin-top:3px; line-height:1.4; color:#ddd3c2; }
.node.kind-ending { background:#3a2a17; border-color:#e0a850;
box-shadow:0 0 0 1px rgba(224,168,80,.35), 0 2px 9px rgba(0,0,0,.45); }
.node.kind-ending .k { color:#f2c463; }
.node.kind-ending .t { color:#eccf95; }
.node.kind-ending .rw { font-size:11.5px; color:#c9a86a; margin-top:4px;
border-top:1px dashed #6a5630; padding-top:4px; }
.node.kind-fight { border-color:#7a4a4a; background:#2a1c1c; }
.node.kind-fight .k { color:#d87878; }
/* 选择节点:流程图判定框,蓝色横六边形(菱形感) */
.node.kind-choice, .node.kind-choice_once {
background:#1d2840; border:none; padding-left:26px; padding-right:26px;
clip-path: polygon(16px 0, calc(100% - 16px) 0, 100% 50%, calc(100% - 16px) 100%, 16px 100%, 0 50%); }
.node.kind-choice .k, .node.kind-choice_once .k { color:#9ec0f0; }
.node.kind-choice .t, .node.kind-choice_once .t { color:#cdddf0; }
#side { width:340px; background:#1c1813; border-left:1px solid #3a322a;
overflow:auto; padding:0; }
#detail { padding:16px; }
#detail h3 { margin:0 0 8px; color:#e6c878; font-size:16px; }
#detail .row { margin:7px 0; font-size:13px; line-height:1.6; }
#detail .lab { color:#9a8f7e; }
#detail .quote { background:#241f18; border-left:3px solid #b89a5a;
padding:8px 10px; border-radius:4px; color:#e8dfce; }
#detail .opt { background:#221d16; border:1px solid #3a322a; border-radius:6px;
padding:7px 9px; margin:6px 0; font-size:12.5px; }
#detail .opt .arrow { color:#7aa0d8; }
.sec-title { padding:12px 16px; font-size:13px; color:#9a8f7e;
border-bottom:1px solid #3a322a; background:#19150f; letter-spacing:1px; }
#rewards { padding:12px 16px; font-size:12.5px; }
#rewards .rw { margin:6px 0; padding:6px 9px; background:#221d16; border-radius:5px; }
#rewards .rw b { color:#e6c878; }
.toolbar { padding:10px 16px; border-top:1px solid #3a322a; }
.toolbar button { background:#3a3024; color:#e6c878; border:1px solid #5a4a32;
padding:6px 14px; border-radius:5px; cursor:pointer; font-size:13px; }
.toolbar button:hover { background:#4a3d2c; }
.empty { color:#6a6256; font-size:13px; padding:20px 16px; }
</style>
</head>
<body>
<header>
<h1 id="h-title"></h1>
<div class="meta" id="h-meta"></div>
<div class="roles" id="h-roles"></div>
</header>
<div id="wrap">
<div id="graph"><svg id="svg"></svg><div id="layers"></div></div>
<div id="side">
<div class="sec-title">节点详情</div>
<div id="detail"><div class="empty">点击左侧任意节点查看台词 / 角色 / 奖励</div></div>
<div class="sec-title">奖励总览</div>
<div id="rewards"></div>
<div class="toolbar"><button onclick="exportIR()">导出 IR (JSON)</button></div>
</div>
</div>
<script>
const IR = __IR_JSON__;
const roleName = {};
(IR.roles||[]).forEach(r => roleName[r.slot] = r.name + (r.archetype?(""+r.archetype+""):""));
const nameOf = s => (s==="P1"?"玩家":"") || roleName[s] || s;
// ---- 收集节点(含结局)与边 ----
const nodes = {};
(IR.nodes||[]).forEach(n => nodes[n.id] = Object.assign({_end:false}, n));
(IR.endings||[]).forEach(e => nodes[e.id] = Object.assign({_end:true, kind:"ending"}, e));
const edges = [];
function addEdge(u,v,type,label){ if(v && nodes[v]) edges.push({u,v,type,label:label||""}); }
(IR.nodes||[]).forEach(n => {
if(n.next) addEdge(n.id,n.next,"next");
(n.options||[]).forEach(o => addEdge(n.id,o.goto,"option",o.text));
(n.branches||[]).forEach(b => addEdge(n.id,b.goto,"random","权重"+(b.weight!=null?b.weight:"")));
if(n.kind==="fight"){ addEdge(n.id,n.win,"win",""); addEdge(n.id,n.lose,"lose",""); }
});
// ---- 最长路径分层 ----
const layer = {}; Object.keys(nodes).forEach(id => layer[id]=0);
let changed=true, guard=0;
while(changed && guard++<999){ changed=false;
edges.forEach(e => { if(layer[e.v] < layer[e.u]+1){ layer[e.v]=layer[e.u]+1; changed=true; } });
}
const byLayer={};
Object.keys(nodes).forEach(id => { (byLayer[layer[id]]=byLayer[layer[id]]||[]).push(id); });
// ---- 节点摘要 ----
function summary(n){
if(n.kind==="narration") return ["旁白", (n.text||"").slice(0,28)];
if(n.kind==="dialogue") return ["对话 · "+nameOf(n.speaker), (n.text||"").slice(0,24)];
if(n.kind==="choice") return ["选择 ("+(n.options||[]).length+"项)", (n.options||[]).map(o=>o.text).join(" / ").slice(0,30)];
if(n.kind==="choice_once")return ["一次性选择", (n.options||[]).map(o=>o.text).join(" / ").slice(0,30)];
if(n.kind==="random") return ["随机分支", (n.branches||[]).length+""];
if(n.kind==="fight") return ["战斗", "vs "+(n.camp2||[]).map(nameOf).join("")];
if(n.kind==="move") return ["走位 · "+nameOf(n.actor), ""+(n.to||"")];
if(n.kind==="anim") return ["动画 · "+nameOf(n.actor), n.ani||""];
if(n.kind==="reward") return ["奖励结算", ""];
if(n.kind==="ending") return ["★ 结局", n.summary||""];
return [n.kind, ""];
}
// ---- 树布局x 由子树决定(父居中于子)y 由最长路径层级决定 ----
const layersDiv=document.getElementById("layers");
const ROW=150, SP=232, NW=188;
const childMap={}; Object.keys(nodes).forEach(id=>childMap[id]=[]);
const indeg={}; Object.keys(nodes).forEach(id=>indeg[id]=0);
const seenE=new Set();
edges.forEach(e=>{ const k=e.u+">"+e.v; if(!seenE.has(k)){ seenE.add(k); childMap[e.u].push(e.v); indeg[e.v]++; } });
let roots=Object.keys(nodes).filter(id=>indeg[id]===0);
if(!roots.length) roots=[Object.keys(nodes)[0]];
const xpos={}; let nextX=0; const vis=new Set();
function assignX(id){
if(vis.has(id)) return; vis.add(id);
if(childMap[id].length===0){ xpos[id]=nextX; nextX+=SP; return; } // 叶子(结局)从左到右排开
childMap[id].forEach(assignX); // 已访问的会直接 return但 xpos 已在
const placed=childMap[id].map(c=>xpos[c]).filter(v=>v!==undefined); // 父居中于其全部子节点
xpos[id]= placed.length ? (Math.min(...placed)+Math.max(...placed))/2 : (nextX+=SP, nextX-SP);
}
roots.forEach(assignX);
Object.keys(nodes).forEach(id=>{ if(xpos[id]===undefined){ xpos[id]=nextX; nextX+=SP; } });
let maxX=0, maxL=0;
Object.keys(nodes).forEach(id=>{
const n=nodes[id], [k,t]=summary(n);
const d=document.createElement("div");
d.className="node kind-"+n.kind; d.id="node-"+id;
let inner='<div class="k">'+k+'</div><div class="t">'+(t||id)+'</div>';
if(n.kind==="ending"){ const g=(n.grants&&n.grants.length)?n.grants.map(grantStr).join(""):"无奖励"; inner+='<div class="rw">'+g+'</div>'; }
d.innerHTML=inner;
d.style.position="absolute"; d.style.left=xpos[id]+"px"; d.style.top=(layer[id]*ROW)+"px";
d.onclick=()=>select(id);
layersDiv.appendChild(d);
maxX=Math.max(maxX,xpos[id]); maxL=Math.max(maxL,layer[id]);
});
layersDiv.style.width=(maxX+NW+40)+"px";
layersDiv.style.height=(maxL*ROW+180)+"px";
// ---- 画连线 ----
const svg=document.getElementById("svg");
const COLOR={next:"#6a6256",option:"#7aa0d8",random:"#a07ad8",win:"#7ac88a",lose:"#d87878"};
function draw(){
const g=document.getElementById("graph"), gb=g.getBoundingClientRect();
svg.setAttribute("width", g.scrollWidth); svg.setAttribute("height", g.scrollHeight);
let h='<defs>';
Object.entries(COLOR).forEach(([k,c])=>{ h+='<marker id="ar-'+k+'" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="'+c+'"/></marker>'; });
h+='</defs>';
edges.forEach(e=>{
const a=document.getElementById("node-"+e.u), b=document.getElementById("node-"+e.v);
if(!a||!b) return;
const ra=a.getBoundingClientRect(), rb=b.getBoundingClientRect();
const x1=ra.left-gb.left+g.scrollLeft+ra.width/2, y1=ra.bottom-gb.top+g.scrollTop;
const x2=rb.left-gb.left+g.scrollLeft+rb.width/2, y2=rb.top-gb.top+g.scrollTop;
const c=COLOR[e.type]||"#6a6256", my=(y1+y2)/2;
h+='<path d="M'+x1+','+y1+' C'+x1+','+my+' '+x2+','+my+' '+x2+','+y2+'" stroke="'+c+'" stroke-width="2.2" fill="none" opacity="0.92" marker-end="url(#ar-'+e.type+')"'+(e.type==="option"?' stroke-dasharray="7,4"':'')+'/>';
if(e.label){
// 标签放在靠近目标节点处(t=0.8),多条同源边自然分散到各自目标上方,避免重叠
const t=0.8, mt=1-t;
const lx=mt*mt*mt*x1 + 3*mt*mt*t*x1 + 3*mt*t*t*x2 + t*t*t*x2;
const ly=mt*mt*mt*y1 + 3*mt*mt*t*my + 3*mt*t*t*my + t*t*t*y2;
h+='<text x="'+lx+'" y="'+ly+'" fill="'+c+'" font-size="11.5" text-anchor="middle" stroke="#161310" stroke-width="3.5" paint-order="stroke" style="font-weight:bold">'+e.label.slice(0,12)+'</text>';
}
});
svg.innerHTML=h;
}
// ---- 详情 ----
function grantStr(gr){
if(gr.kind==="银两") return "银两 "+(gr.value>0?"+":"")+gr.value;
if(gr.kind==="道具") return "道具 "+gr.item+" ×"+gr.value;
if(gr.kind==="友好度") return nameOf(gr.target)+" 友好度+"+gr.value;
if(gr.kind==="入门") return nameOf(gr.target)+" 加入门派";
return JSON.stringify(gr);
}
function condStr(c){ return c ? (c.kind+c.op+c.value) : ""; }
function select(id){
document.querySelectorAll(".node").forEach(x=>x.classList.remove("sel"));
document.getElementById("node-"+id).classList.add("sel");
const n=nodes[id]; let h='<h3>'+(summary(n)[0])+' <span style="color:#6a6256;font-size:12px">#'+id+'</span></h3>';
if(n.speaker) h+='<div class="row"><span class="lab">角色:</span>'+nameOf(n.speaker)+'</div>';
if(n.actor) h+='<div class="row"><span class="lab">角色:</span>'+nameOf(n.actor)+'</div>';
if(n.text) h+='<div class="row quote">'+n.text+'</div>';
if(n.kind==="fight"){
h+='<div class="row"><span class="lab">类型:</span>'+(n.fight_type===1?"击倒":"死斗")+'</div>';
h+='<div class="row"><span class="lab">敌方:</span>'+(n.camp2||[]).map(nameOf).join("")+'</div>';
h+='<div class="row"><span class="lab">胜→</span>'+(n.win||"")+' <span class="lab">败→</span>'+(n.lose||"")+'</div>';
}
(n.options||[]).forEach(o=>{
h+='<div class="opt">'+o.text;
if(o.condition) h+=' <span class="lab">[条件:'+condStr(o.condition)+']</span>';
if(o.reward&&o.reward.grants) h+=' <span class="lab">{'+o.reward.grants.map(grantStr).join("")+'}</span>';
h+=' <span class="arrow">→ '+o.goto+'</span></div>';
});
(n.branches||[]).forEach(b=>{ h+='<div class="opt">权重 '+b.weight+' <span class="arrow">→ '+b.goto+'</span></div>'; });
if(n.grants&&n.grants.length) h+='<div class="row"><span class="lab">奖励:</span>'+n.grants.map(grantStr).join("")+'</div>';
if(n.grants&&!n.grants.length) h+='<div class="row"><span class="lab">奖励:</span>无</div>';
if(n.next) h+='<div class="row"><span class="lab">下一步 →</span> '+n.next+'</div>';
document.getElementById("detail").innerHTML=h;
}
// ---- 奖励总览 ----
(function(){
let h=""; const collect=[];
(IR.nodes||[]).forEach(n=>(n.options||[]).forEach(o=>{ if(o.reward&&o.reward.grants) collect.push(["选项「"+o.text+"", o.reward.grants]); }));
(IR.endings||[]).forEach(e=>collect.push(["结局「"+(e.summary||e.id)+"", e.grants||[]]));
collect.forEach(([k,gr])=>{ h+='<div class="rw"><b>'+k+'</b><br>'+(gr.length?gr.map(grantStr).join(""):"")+'</div>'; });
document.getElementById("rewards").innerHTML=h||'<div class="empty">无奖励配置</div>';
})();
// ---- 头部 ----
document.getElementById("h-title").textContent=IR.title+" "+(IR.id?(""+IR.id+""):"");
document.getElementById("h-meta").textContent=[IR.theme,IR.scale,(IR.stage&&("舞台:"+IR.stage.type+(IR.stage.reuse_hint?(" / 复用 "+IR.stage.reuse_hint):"")))].filter(Boolean).join(" · ");
document.getElementById("h-roles").innerHTML=(IR.roles||[]).map(r=>'<span class="role"><b>'+r.slot+'</b> '+r.name+' '+r.archetype+'</span>').join("");
function exportIR(){
const blob=new Blob([JSON.stringify(IR,null,2)],{type:"application/json"});
const a=document.createElement("a"); a.href=URL.createObjectURL(blob);
a.download=(IR.id||"story")+".ir.json"; a.click();
}
window.addEventListener("load", ()=>{ draw(); });
window.addEventListener("resize", draw);
</script>
</body>
</html>"""
def main():
ap = argparse.ArgumentParser()
ap.add_argument("ir", help="Story IR JSON 文件路径")
ap.add_argument("-o", "--out", help="输出 HTML 路径(默认与输入同名 .html")
args = ap.parse_args()
with open(args.ir, encoding="utf-8") as f:
ir = json.load(f)
base = args.ir
for ext in (".json", ".ir"):
if base.endswith(ext):
base = base[: -len(ext)]
out = args.out or base + ".html"
html = (TEMPLATE
.replace("__IR_JSON__", json.dumps(ir, ensure_ascii=False))
.replace("__TITLE__", ir.get("title", "Story")))
with open(out, "w", encoding="utf-8") as f:
f.write(html)
print("OK ->", out)
if __name__ == "__main__":
main()