Files
story-edit-web/samples/yuye_koumen.html

248 lines
15 KiB
HTML
Raw 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.

<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<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 = {"id": "QY_YYKM", "title": "雨夜叩门", "theme": "正统武侠·道德抉择", "scale": "标准奇遇", "roles": [{"slot": "P1", "name": "值夜弟子", "archetype": "玩家", "camp": 1}, {"slot": "NP1", "name": "神秘剑客", "archetype": "负伤外门高手", "camp": 0}, {"slot": "NP2", "name": "掌门", "archetype": "本门掌门", "camp": 0}], "stage": {"type": "门派入口·夜", "reuse_hint": "K3_A"}, "nodes": [{"id": "n1", "kind": "narration", "text": "暴雨倾盆,山门外的灯笼在风里摇晃。一阵急促的叩门声,盖过了雷声。", "next": "n2"}, {"id": "n2", "kind": "dialogue", "speaker": "NP1", "camera": "NP1", "text": "在下途经贵派,身负旧伤,恳请借宿一晚,天明即走。", "next": "n3"}, {"id": "n3", "kind": "dialogue", "speaker": "P1", "text": "(这位侠客腰间的铁牌……分明是近日劫掠商队的黑风寨样式。)", "next": "n4"}, {"id": "n4", "kind": "choice", "options": [{"text": "江湖救急,先收留他", "goto": "end_ally"}, {"text": "不动声色,擒下他交予掌门", "goto": "fight1"}, {"text": "赠些盘缠,请他即刻离去", "condition": {"kind": "银两", "op": ">=", "value": 500}, "reward": {"grants": [{"kind": "银两", "value": -500}]}, "goto": "end_pay"}]}, {"id": "fight1", "kind": "fight", "fight_type": 1, "camp1": ["P1"], "camp2": ["NP1"], "win": "end_subdue", "lose": "end_lose"}], "endings": [{"id": "end_ally", "summary": "结义同盟", "grants": [{"kind": "友好度", "target": "NP1", "value": 30}, {"kind": "入门", "target": "NP1"}]}, {"id": "end_pay", "summary": "破财消灾", "grants": [{"kind": "友好度", "target": "NP1", "value": 10}]}, {"id": "end_subdue", "summary": "擒贼献掌门", "grants": [{"kind": "银两", "value": 200}, {"kind": "道具", "item": "P6", "value": 1}]}, {"id": "end_lose", "summary": "技不如人", "grants": []}]};
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>