init: 剧情事件协作 Web 编辑器独立仓(从 SGame/tools/event_authoring 拆出)
This commit is contained in:
248
samples/yuye_koumen.html
Normal file
248
samples/yuye_koumen.html
Normal file
@ -0,0 +1,248 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user