init: 剧情事件协作 Web 编辑器独立仓(从 SGame/tools/event_authoring 拆出)

This commit is contained in:
bia
2026-06-08 16:50:27 +08:00
commit f5669dc01d
32 changed files with 3497 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
__pycache__/
*.pyc
web/data/
web/story_events.db

157
IR_SCHEMA.md Normal file
View File

@ -0,0 +1,157 @@
# Story IR Schemav0.2M4 扩展)
> v0.2 变更M4新增 `sequences`/`out_ref`子序列复用、Option `skip`(押注跳过)、
> `stage.point_set`(点位集引用 + 坐标校验);条件/奖励词典外置到 `ir_dictionary.json`。
> 校验+编译内核收敛进 `ir_core/` 包CLI 与 M5 Web 后端共用)。
Story IR 是"讲故事"与"编译成配置"两段之间的唯一交接棒。它只描述**语义层**
(语义角色 / 语义场景 / 叙事节点),把真实 ID、prefab 点位、`step` 号、`nextStepId`
前缀拼接全部留给编译器。
配套:
- 样张:`tools/event_authoring/samples/yuye_koumen.ir.json`
- 降级目标:`Assets/Scripts/Base/Config/Data/GameEventData.cs`
- 设计依据:`docs/plans/2026-06-03-event-config-automation-design.md`
---
## 1. 顶层结构
```jsonc
{
"id": "QY_YYKM", // group 名(唯一,不与现有内置 group 冲突)
"title": "雨夜叩门",
"theme": "正统武侠·道德抉择",
"scale": "标准奇遇", // 仅元信息,不参与编译
"roles": [ Role, ... ],
"stage": Stage,
"sequences": [ Sequence, ... ], // 可选;可复用子序列(被 out_ref 引用)
"nodes": [ Node, ... ],
"endings": [ Ending, ... ]
}
```
## 2. Role语义角色
```jsonc
{ "slot": "NP1", "name": "神秘剑客", "archetype": "负伤外门高手", "camp": 0 }
```
- `slot`:编译后写进 `points`/`camp2Fighters` 的语义点位名。`P1`=玩家固定。
- `archetype`:原型标签,编译期→真实 NPC ID匹配 `npc_data`)。
- `camp`0 中立 / 1 玩家方 / 2 敌方(战斗时用,可省略)。
## 3. Stage语义场景
```jsonc
{ "type": "门派入口·夜", "reuse_hint": "K3_A", "point_set": "QY_YYKM" }
```
- `type`:场景语义;编译器据此 + 所需角色数从点位库挑结构匹配的 prefab。
- `reuse_hint`:可选,显式指定复用哪个现有 prefab仅作 BDTree host
- `point_set`:可选,引用的点位集名(默认 = `id`)。对应 `Assets/StreamingAssets/Story/PointSets/{point_set}.points.json`M1 取点工具产出。编译期校验IR 里实际站位的点位名speaker/actor/move.to/fight 阵营/camera必须都在该点位集中存在点位集文件不存在则降级为警告`--strict-points` 下视为错)。
## 4. Node叙事节点
通用字段:`id`(组内唯一)、`kind`。各 `kind` 字段如下:
| kind | 字段 | → GameEventData |
|---|---|---|
| `narration` | `speaker?`, `text`, `next` | type0挂角色(默认 P1)用气泡实景呈现,**不走全屏黑幕**`content`=text |
| `dialogue` | `speaker`, `text`, `camera?`, `next` | type0`points`=[speaker]`content``cameraPoint` |
| `move` | `actor`, `to`, `mode?`(walk/teleport/remove), `speed?`, `ani?`, `next` | type0`points`/`movePoint`/`moveStatus` |
| `anim` | `actor`, `ani`, `angle?`, `next` | type0`anis`=[ani,angle] |
| `choice` | `options`:[Option] | type1每 option 一行 |
| `choice_once` | `options`:[Option] | type2编译器保证有恒满足兜底项 |
| `random` | `branches`:[{`weight`,`goto`}] | type3`weight`+`nextStepId` |
| `fight` | `fight_type`(1 击倒/2 死斗), `camp2`:[slot], `camp1?`:[slot], `win`, `lose` | type0`camp2Fighters`,`fightStatus`=[type,win,lose] |
| `reward` | `grants`:[Grant], `next` | type0`resultRewardIds``roleActionCode` |
| `out_ref` | `ref`(子序列 id), `next` | 编译前预展开成普通节点(见 §4.1);本节点不直接产行 |
### 4.1 Sequence / out_ref子序列复用对应 `OutRefStoryNodeData`
```jsonc
// 顶层 sequences可复用子序列
{ "id": "seq_cheer", "nodes": [ {Node}, ... ] }
// 引用它的节点
{ "id": "ref_a", "kind": "out_ref", "ref": "seq_cheer", "next": "end_win" }
```
- **语义**:把一段公共流程(庆功/过场/结算)抽成子序列,多处 `out_ref` 复用,避免重复手写。
- **编译**(对齐 `StorylineMgr.ReplaceStoryEventData` 的展开):编译前把每个 `out_ref` 节点 `r` 摊平——
克隆 `ref` 子序列的节点id 加 `{r.id}__` 前缀,内部跳转同步加前缀;子序列出口(`next` 为空的尾节点)
`next` 接到 `r.next`;指向 `r` 的跳转改写为子序列入口 `{r.id}__{首节点}`
- **多处复用不撞 id**:不同 `out_ref` 节点前缀不同 → 各自一份独立行。
- **限制**M4 仅支持**一层**(子序列内不得再含 `out_ref`),违者校验报错;子序列内跳转目标须落在子序列内或共享结局。
### Option选项
```jsonc
{ "text": "收留他", "condition": Condition?, "reward": {"grants":[Grant]}?,
"skip": { "node": "end_bet", "reward": {"grants":[Grant]} }?, "goto": "end_ally" }
```
- `text``choose``condition``condition``reward``resultRewardIds`(选项被选后自身发的奖励/扣费),`goto``nextStepId`
- **`skip`(押注跳过,对应 `skipNodeId`+`skipReward`**:选该项时**把 `skip.reward` 注入 `skip.node` 那行的奖励**,常配合 `goto` 越过中间战斗直达结算行(押对了→不打了直接领彩头)。
- 编译:选项行写 `skipNodeId = skip.node`(裸节点 id运行时 `ChooseTaskUI` 自动拼 `{group}_` 查行)、`skipReward = compile(skip.reward)`
- 注意:运行时是**覆盖** `skip.node` 行的 `resultRewardIds`(非追加)→ 让 `skip.node` 的自带 grants 为空、彩头全由 `skip.reward` 给。
- 校验:`skip.node` 须可解析;`skip.reward.grants` 非空且合法。
## 5. Ending结局
```jsonc
{ "id": "end_ally", "summary": "结义同盟", "grants": [Grant], "result": "success" }
```
- `result`:可选,`success`(默认,→`gameTaskStatus`1) / `fail`(→2) / `end`(→3)。决定收尾分支。
- 编译为**两行**:① 结算行(`missionText`=summary + `resultRewardIds`/`roleActionCode``gameTaskStatus`=0
`nextStepId`→终结行);② 裸终结行(仅 `gameTaskStatus`)。
- **为何拆两行**`EventRefreshStepAction``RefreshStep()` 返回 false即下一步 `gameTaskStatus>0`)时
返回 `Failure`,会中断该步 Sequence——若把奖励与 `gameTaskStatus` 放同一行,发奖励/入门的 Action 被跳过。
终结行触发 `DirectStoryEnd`(复位镜头 `otherTarget=null` / 恢复玩家控制 / 结束剧情)。与可视化编辑器
及内置奇遇 `Qiyu/QY10_A.json`(末节点 `taskStatus:1`、内容为空、奖励在前置节点)一致。
## 6. Condition / Grant 词典(外置 `ir_dictionary.json`
> M4 起词典外置到 `tools/event_authoring/ir_dictionary.json`,编译器(`ir_core`)与 M5 Web 前端共读。
> 新增**查表类** kind 只改该 JSON、零代码需引擎特例的 kind 标 `"engine": true`。
> 编译器对未登记 kind **报错不静默**。下表为当前登记项(随推进扩充)。
### Condition→ `condition` 字段)
```jsonc
{ "kind": "银两", "op": ">=", "value": 500 } // → "V19,2,500" (op: >=→2)
```
| kind | 真实ID | 说明 |
|---|---|---|
| 银两 | V19 | 门派金钱(`RewardItems` 特判 schoolMoney |
### Grant→ `resultRewardIds` 或行为码)
```jsonc
{ "kind": "银两", "value": 200 } // → "V19,200"(正=给,负=扣)
{ "kind": "道具", "item": "P6", "value": 3 } // → "P6,3"
{ "kind": "友好度", "target": "NP1", "value": 30 } // → "NP1,V15,30"
{ "kind": "入门", "target": "NP1" } // → roleActionCode "JoinToPlayerSch=NP1"
```
| kind | 真实ID/机制 |
|---|---|
| 银两 | V19 |
| 道具 | 由 `item` 指定drop_item_data ID |
| 友好度 | V15`target` NPC slot |
| 入门 | roleActionCode `JoinToPlayerSch`NPC 加入玩家门派) |
> 词典随 MVP 推进扩充;编译器对未登记的 kind 报错,不静默产出。
## 7. 编译器降级不变量(与 StorylineMgr 现有逻辑一致)
- 自动生成 `step`(按节点拓扑序)。
- 自动给 `id`/`nextStepId`/`fightStatus[1..2]``{group}_` 前缀。
- 所有跳转目标(`next`/`goto`/`win`/`lose`/`branches[].goto`)必须可解析到某节点或结局,否则编译失败。
- 每个 `choice`/`choice_once` 至少一个无 `condition`(或恒满足)的兜底项,否则编译失败。
## 8. 多语言 / 本地化
游戏本地化走 `SGameText`(重写 `Text.text` setter。剧情对话气泡 `TaskDialogUI.contentText`
已是 `SGameText`,链路可用。规则:
- **文本一律用简体中文撰写**`content`/`choose`/`summary`),作为 canonical key
- **简体↔繁体**`SGameText` 字符级机械转换,自动覆盖,无需额外工作。
- **韩文(及未来语言)**`SGameText` 用中文整句作 key 查 `StreamingAssets/i18n/ko.tsv`
- 编译器附带产出 `{group}.i18n.tsv`(中文 key 列 + 空韩文列),翻译填好后合并进 `ko.tsv`
新剧情文本即纳入本地化,不会成为漏网。
- **占位符**:需动态插入角色名时用游戏既有约定(如 `{NP1}``{A5,NP1}`),编译器原样保留。
注意:含占位的整句**韩文查表会 miss**(拿到的是替换后的完整串)→ 简繁不受影响,韩文 fallback 中文。
这是项目级已知限制(待 `SGameText.SetFormat` 模板方案解决),故重要台词尽量少用占位。
- **运行时前提**:剧情文本的显示组件必须是 `SGameText`。对话气泡已满足;黑屏旁白/选项 UI
若发现是原生 `Text`,需在游戏侧补挂 `SGameText`(属游戏侧改造,不在编译器范围)。

93
ir_compile.py Normal file
View File

@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
"""Story IR -> 可注入的 GameEventData 配置编译器 + 校验器CLI 壳)。
用法:
python ir_compile.py samples/yuye_koumen.ir.json
python ir_compile.py samples/yuye_koumen.ir.json -o out.events.json
python ir_compile.py samples/yuye_koumen.ir.json --deploy [--qiyu] [--strict-points]
校验/编译/词典内核在 ir_core 包(与 M5 Web 后端共用)。本文件只做参数解析、
文件读写、报告打印、部署拷贝。校验不过则报错退出、不产出任何配置。
points/movePoint/camp2Fighters 里的 slot(P1/NP1) 是点位名,坐标存同名
{group}.points.jsonM1 点位集),运行时直接读;编译器只校验点位名存在。
"""
import argparse
import json
import os
import shutil
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import ir_core
def main():
ap = argparse.ArgumentParser()
ap.add_argument("ir")
ap.add_argument("-o", "--out")
ap.add_argument("--deploy", action="store_true",
help="编译后部署到 Assets/StreamingAssets/Story/Config/{group}.events.json")
ap.add_argument("--qiyu", action="store_true", help="配合 --deploy部署到 Qiyu/ 子目录")
ap.add_argument("--strict-points", action="store_true",
help="点位集文件缺失也视为错(部署前严格门)")
args = ap.parse_args()
with open(args.ir, encoding="utf-8") as f:
ir = json.load(f)
dic = ir_core.load_dictionary()
errs, warns = ir_core.validate(ir, dic, strict_points=args.strict_points)
for w in warns:
print("[警告] " + w)
if errs:
print("[校验失败] 共 %d 项,未产出配置:" % len(errs))
for e in errs:
print(" - " + e)
sys.exit(1)
try:
rows = ir_core.compile_ir(ir, dic)
except ir_core.CompileError as e:
print("[编译错误] %s" % e)
sys.exit(1)
base = args.ir
for ext in (".json", ".ir"):
if base.endswith(ext):
base = base[: -len(ext)]
out = args.out or base + ".events.json"
with open(out, "w", encoding="utf-8") as f:
json.dump(rows, f, ensure_ascii=False, indent=2)
print("[校验通过]")
print("[编译完成] %s -> %s" % (args.ir, out))
print(" group=%s%d" % (ir["id"], len(rows)))
stage = ir.get("stage", {})
print(" 舞台: %s 点位集: %s" % (stage.get("type", "?"),
stage.get("point_set") or ir["id"]))
print(" 角色->点位(原型,真实坐标见点位集):")
for r in ir.get("roles", []):
print(" %-4s = %s %s" % (r["slot"], r["name"], r.get("archetype", "")))
texts = ir_core.extract_texts(ir)
i18n_out = base + ".i18n.tsv"
with open(i18n_out, "w", encoding="utf-8") as f:
f.write("# 简体中文(key,勿改)\t韩文(待译繁体无需填SGameText 自动转换)\n")
for t in texts:
f.write(t + "\t\n")
print("[i18n] 抽取 %d 条可见文本 -> %s" % (len(texts), i18n_out))
print(" 简繁自动覆盖;韩文翻译填好后合并进 Assets/StreamingAssets/i18n/ko.tsv")
if args.deploy:
proj = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sub = "Qiyu" if args.qiyu else ""
dst_dir = os.path.join(proj, "Assets", "StreamingAssets", "Story", "Config", sub)
os.makedirs(dst_dir, exist_ok=True)
dst = os.path.join(dst_dir, ir["id"] + ".events.json")
shutil.copy(out, dst)
print("[部署] -> %s" % dst)
if __name__ == "__main__":
main()

14
ir_core/__init__.py Normal file
View File

@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
"""ir_coreStory IR 校验 + 编译 + 词典的共享内核。
编译器 CLIir_compile.pyM5Web 后端共用此包,保证试走/校验/导出口径一致。
"""
from .dictionary import CompileError, Dictionary, load_dictionary
from .compile import compile_ir, extract_texts, expand_out_refs
from .validate import validate, collect_point_refs
__all__ = [
"CompileError", "Dictionary", "load_dictionary",
"compile_ir", "extract_texts", "expand_out_refs",
"validate", "collect_point_refs",
]

269
ir_core/compile.py Normal file
View File

@ -0,0 +1,269 @@
# -*- coding: utf-8 -*-
"""Story IR -> 扁平 List[GameEventData] 编译器。
产出 events.json绕过 StorylineMgr.ReplaceStoryEventData自己做 step/前缀/展开)。
M4 新增out_ref 子序列复用(编译前预展开)、选项 skip押注跳过→直接结算
"""
from .dictionary import CompileError
OUT_REF_SEP = "__" # out_ref 展开前缀分隔符(与节点 id 内的下划线区分)
# ============ out_ref 预展开 ============
def expand_out_refs(ir):
"""把 out_ref 节点摊平成普通节点,返回新的 nodes 列表(不改原 ir
对齐 StorylineMgr.ReplaceStoryEventData 的语义:
- 子序列每个节点 id 加 {refId}__ 前缀;内部跳转同步加前缀;
- 子序列出口next 为空的尾节点)的 next 接到 out_ref 节点的 next
- 引用该 out_ref 节点的跳转改写为子序列入口 {refId}__{entry}
多个 out_ref 引用同一子序列 → 前缀不同 → id 天然不撞(复用本意)。
M4 仅支持一层(子序列内不得再含 out_ref
"""
seqs = {s["id"]: s for s in ir.get("sequences", [])}
src_nodes = ir.get("nodes", [])
out_refs = [n for n in src_nodes if n.get("kind") == "out_ref"]
if not out_refs:
return list(src_nodes)
expanded = [] # 展开出来的子序列节点
ref_entry = {} # refId -> 子序列入口节点 id含前缀
for r in out_refs:
rid, ref = r["id"], r.get("ref")
seq = seqs.get(ref)
if seq is None:
raise CompileError("out_ref 节点 %s 引用了不存在的子序列 %r" % (rid, ref))
sub = seq.get("nodes", [])
if not sub:
raise CompileError("子序列 %r 为空(被 %s 引用)" % (ref, rid))
sub_ids = {n["id"] for n in sub}
prefix = rid + OUT_REF_SEP
ref_entry[rid] = prefix + sub[0]["id"]
def remap(t, is_next):
"""重写子序列内部跳转目标。"""
if t in sub_ids:
return prefix + t
if not t: # 空 next = 子序列出口 -> 接回 caller
return r.get("next", "") if is_next else t
return t # 指向子序列外(共享结局等)保持原样
for n in sub:
if n.get("kind") == "out_ref":
raise CompileError("子序列 %r 含嵌套 out_ref节点 %sM4 暂不支持" % (ref, n["id"]))
c = dict(n)
c["id"] = prefix + n["id"]
if "next" in c:
c["next"] = remap(c.get("next"), True)
elif n.get("kind") not in ("choice", "choice_once", "random", "fight"):
# 链尾叶子(无 next 字段)-> 出口接回 caller.next
c["next"] = r.get("next", "")
if "options" in c:
c["options"] = [_remap_opt(o, remap) for o in c["options"]]
if "branches" in c:
c["branches"] = [dict(b, goto=remap(b.get("goto"), False)) for b in c["branches"]]
if c.get("kind") == "fight":
c["win"] = remap(c.get("win"), False)
c["lose"] = remap(c.get("lose"), False)
expanded.append(c)
# 主图节点:去掉 out_ref 占位,并把指向它们的跳转改写到子序列入口
def deref(t):
return ref_entry.get(t, t)
result = []
for n in src_nodes:
if n.get("kind") == "out_ref":
continue
c = dict(n)
if "next" in c:
c["next"] = deref(c.get("next"))
if "options" in c:
c["options"] = [dict(o, goto=deref(o.get("goto"))) for o in c["options"]]
if "branches" in c:
c["branches"] = [dict(b, goto=deref(b.get("goto"))) for b in c["branches"]]
if c.get("kind") == "fight":
c["win"] = deref(c.get("win"))
c["lose"] = deref(c.get("lose"))
result.append(c)
return result + expanded
def _remap_opt(o, remap):
c = dict(o)
c["goto"] = remap(o.get("goto"), False)
if o.get("skip"):
sk = dict(o["skip"])
sk["node"] = remap(o["skip"].get("node"), False)
c["skip"] = sk
return c
# ============ 编译 ============
def compile_ir(ir, dic):
group = ir["id"]
src_nodes = expand_out_refs(ir)
nodes = {n["id"]: n for n in src_nodes}
endings = {e["id"]: e for e in ir.get("endings", [])}
def entry(tid):
"""目标节点的入口行 id含 group 前缀)。"""
n = nodes.get(tid)
if n and n.get("kind") in ("choice", "choice_once", "random"):
return "%s_%s_o0" % (group, tid)
return "%s_%s" % (group, tid)
def children(tid):
n = nodes.get(tid)
if not n:
return []
res = []
if n.get("next"):
res.append(n["next"])
res += [o["goto"] for o in n.get("options", [])]
res += [b["goto"] for b in n.get("branches", [])]
if n.get("kind") == "fight":
res += [n.get("win"), n.get("lose")]
return [r for r in res if r]
indeg = {i: 0 for i in list(nodes) + list(endings)}
for nid in nodes:
for c in children(nid):
if c in indeg:
indeg[c] += 1
roots = [i for i in nodes if indeg[i] == 0] or list(nodes)[:1]
order, seen = [], set()
def dfs(tid):
if tid in seen or tid not in indeg:
return
seen.add(tid)
order.append(tid)
for c in children(tid):
dfs(c)
for r in roots:
dfs(r)
for i in list(nodes) + list(endings):
if i not in seen:
order.append(i); seen.add(i)
rows, step = [], 0
def newrow(**kw):
nonlocal step
step += 1
row = {"group": group, "step": step, "type": 0}
row.update(kw)
rows.append(row)
return row
for tid in order:
if tid in endings:
e = endings[tid]
rid_str, rac = dic.compile_grants(e.get("grants"))
status = {"success": 1, "fail": 2, "end": 3}.get(e.get("result", "success"))
if status is None:
raise CompileError("结局 %s 的 result 非法: %r(仅 success/fail/end"
% (tid, e.get("result")))
end_id = "%s_%s__end" % (group, tid)
r = newrow(id="%s_%s" % (group, tid), missionText=e.get("summary", ""),
nextStepId=end_id)
if rid_str:
r["resultRewardIds"] = rid_str
if rac:
r["roleActionCode"] = rac
newrow(id=end_id, gameTaskStatus=status)
continue
n = nodes[tid]
kind = n["kind"]
if kind == "narration":
spk = n.get("speaker", "P1")
newrow(id="%s_%s" % (group, tid), points=[spk], content=n.get("text", ""),
nextStepId=entry(n["next"]) if n.get("next") else "")
elif kind == "dialogue":
newrow(id="%s_%s" % (group, tid), points=[n["speaker"]],
content=n.get("text", ""),
nextStepId=entry(n["next"]) if n.get("next") else "")
elif kind in ("choice", "choice_once"):
opt_type = 1 if kind == "choice" else 2
for i, o in enumerate(n.get("options", [])):
rid_str, rac = dic.compile_grants((o.get("reward") or {}).get("grants"))
r = newrow(id="%s_%s_o%d" % (group, tid, i), type=opt_type,
choose=o.get("text", ""), points=["P1"],
nextStepId=entry(o["goto"]))
if o.get("condition"):
r["condition"] = dic.compile_cond(o["condition"])
if rid_str:
r["resultRewardIds"] = rid_str
if rac:
r["roleActionCode"] = rac
# skip押注跳过——选该项时把彩头注入 skip.node 行runtime 自拼 {group}_
if o.get("skip"):
sk = o["skip"]
sk_str, _ = dic.compile_grants((sk.get("reward") or {}).get("grants"))
r["skipNodeId"] = sk["node"]
r["skipReward"] = sk_str
elif kind == "random":
for i, b in enumerate(n.get("branches", [])):
newrow(id="%s_%s_o%d" % (group, tid, i), type=3,
weight=b.get("weight", 1), nextStepId=entry(b["goto"]))
elif kind == "fight":
newrow(id="%s_%s" % (group, tid),
points=n.get("camp1") or ["P1"],
camp2Fighters=n.get("camp2", []),
fightStatus=[str(n["fight_type"]), entry(n["win"]), entry(n["lose"])])
elif kind == "move":
if n.get("mode") == "teleport":
ms = ["1"]
elif n.get("mode") == "remove":
ms = ["2"]
else:
ms = [str(n.get("speed", 6)), n.get("ani", "")]
newrow(id="%s_%s" % (group, tid), points=[n["actor"]],
movePoint=[n["to"]], moveStatus=ms,
nextStepId=entry(n["next"]) if n.get("next") else "")
elif kind == "anim":
anis = [n["ani"]] + ([str(n["angle"])] if n.get("angle") is not None else [])
newrow(id="%s_%s" % (group, tid), points=[n["actor"]], anis=anis,
nextStepId=entry(n["next"]) if n.get("next") else "")
elif kind == "reward":
rid_str, rac = dic.compile_grants(n.get("grants"))
r = newrow(id="%s_%s" % (group, tid),
nextStepId=entry(n["next"]) if n.get("next") else "")
if rid_str:
r["resultRewardIds"] = rid_str
if rac:
r["roleActionCode"] = rac
else:
raise CompileError("未知节点 kind: %r (节点 %s)" % (kind, tid))
ids = [r["id"] for r in rows]
dup = {x for x in ids if ids.count(x) > 1}
if dup:
raise CompileError("行 id 重复: %s" % dup)
return rows
def extract_texts(ir):
"""抽取所有玩家可见文本(去重保序),供本地化翻译。
含 sequences 子序列内文本。"""
texts, seen = [], set()
def add(t):
if t and t not in seen:
seen.add(t); texts.append(t)
def walk_nodes(node_list):
for n in node_list:
add(n.get("text"))
for o in n.get("options", []):
add(o.get("text"))
walk_nodes(ir.get("nodes", []))
for s in ir.get("sequences", []):
walk_nodes(s.get("nodes", []))
for e in ir.get("endings", []):
add(e.get("summary"))
return texts

93
ir_core/dictionary.py Normal file
View File

@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
"""语义词典condition/grant kind -> 真实 ID/形态。外置 ir_dictionary.json
编译器与M5Web 后端共读。新增查表类 kind 只改 JSON零代码改动。
form 决定 grant 编译串形态:
money -> "{id},{value}" (银两;正给负扣)
item -> "{item},{value}" 道具item 由 grant 指定 drop_item_data ID
friend -> "{target},{id},{value}" (友好度,需 target NPC slot
join -> roleActionCode "{code}={target}"(入门,需 target
"""
import json
import os
_DICT_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"ir_dictionary.json")
class CompileError(Exception):
pass
class Dictionary(object):
def __init__(self, data):
self.conditions = data.get("conditions", {})
self.grants = data.get("grants", {})
# ---- 查询 ----
def is_known_cond(self, kind):
return kind in self.conditions
def is_known_grant(self, kind):
return kind in self.grants
def cond_spec(self, kind):
return self.conditions.get(kind)
def grant_spec(self, kind):
return self.grants.get(kind)
def grant_needs_target(self, kind):
spec = self.grants.get(kind) or {}
return bool(spec.get("needs_target"))
# ---- 编译 ----
def compile_cond(self, c):
"""condition -> condition 字段字符串。"""
if not c:
return ""
kind = c.get("kind")
spec = self.conditions.get(kind)
if not spec:
raise CompileError("不支持的 condition kind: %r" % kind)
op = c.get("op")
ops = spec.get("ops", {})
if op not in ops:
raise CompileError("condition %r 不支持比较符 %r" % (kind, op))
return "%s,%s,%s" % (spec["id"], ops[op], c["value"])
def compile_grants(self, grants):
"""grants -> (resultRewardIds 串, roleActionCode 串)。"""
rewards, rac = [], ""
for g in grants or []:
k = g.get("kind")
spec = self.grants.get(k)
if not spec:
raise CompileError("不支持的 grant kind: %r" % k)
form = spec.get("form")
if form == "money":
rewards.append("%s,%s" % (spec["id"], g["value"]))
elif form == "item":
rewards.append("%s,%s" % (g["item"], g["value"]))
elif form == "friend":
rewards.append("%s,%s,%s" % (g["target"], spec["id"], g["value"]))
elif form == "join":
rac = "%s=%s" % (spec["code"], g["target"])
else:
raise CompileError("grant kind %r 的 form %r 未实现" % (k, form))
return ";".join(rewards), rac
_cached = None
def load_dictionary(path=None):
"""加载词典(默认缓存同目录 ir_dictionary.json"""
global _cached
if path is None:
if _cached is None:
with open(_DICT_PATH, encoding="utf-8") as f:
_cached = Dictionary(json.load(f))
return _cached
with open(path, encoding="utf-8") as f:
return Dictionary(json.load(f))

185
ir_core/validate.py Normal file
View File

@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
"""Story IR 配置期校验。挡掉调研发现的卡流程 bug校验不过不产出任何配置。
返回 (errors, warnings)errors 非空即拒绝编译warnings 仅提示(如点位集缺失)。
M4 新增out_ref 结构校验、选项 skip 校验、点位集坐标引用校验。
"""
import os
import re
from .compile import expand_out_refs
from .dictionary import CompileError
ITEM_RE = re.compile(r"^[VP]\d+$")
# 点位集位置:本文件在 SGame/tools/event_authoring/ir_core/ -> 上溯 4 级到项目根
_POINTS_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))),
"Assets", "StreamingAssets", "Story", "PointSets")
def validate(ir, dic, points_dir=None, strict_points=False):
errs, warns = [], []
# ---- out_ref 结构预检(在展开前;展开依赖此处通过)----
seqs = {s["id"]: s for s in ir.get("sequences", [])}
for n in ir.get("nodes", []):
if n.get("kind") == "out_ref":
ref = n.get("ref")
if ref not in seqs:
errs.append("[out_ref失效] 节点 %s 引用了不存在的子序列 %r" % (n["id"], ref))
elif not seqs[ref].get("nodes"):
errs.append("[空子序列] 子序列 %r 没有任何节点(被 %s 引用)" % (ref, n["id"]))
for sn in seqs.get(ref, {}).get("nodes", []):
if sn.get("kind") == "out_ref":
errs.append("[嵌套out_ref] 子序列 %r 节点 %s 含嵌套 out_refM4 暂不支持"
% (ref, sn["id"]))
if errs:
return errs, warns # 结构坏了就别展开了,避免 KeyError 噪声
# ---- 展开 out_ref 后做节点级校验goto/win/lose/skip 目标含子序列内 id----
try:
nodes_list = expand_out_refs(ir)
except CompileError as e:
return ["[展开失败] %s" % e], warns
nodes = {n["id"]: n for n in nodes_list}
endings = {e["id"]: e for e in ir.get("endings", [])}
all_ids = set(nodes) | set(endings)
slots = {r["slot"] for r in ir.get("roles", [])}
def check_target(src, tid, field):
if tid not in all_ids:
errs.append("[跳转失效] 节点 %s%s 指向不存在的目标 %r" % (src, field, tid))
def check_slot(src, s, field):
if s and s not in slots:
errs.append("[未声明角色] 节点 %s%s 引用了未在 roles 声明的 slot %r" % (src, field, s))
def check_grants(src, grants):
for g in grants or []:
k = g.get("kind")
if not dic.is_known_grant(k):
errs.append("[未知奖励] 节点 %s 出现未登记的 grant kind %r" % (src, k)); continue
if k == "道具" and not ITEM_RE.match(str(g.get("item", ""))):
errs.append("[非法道具ID] 节点 %s 的道具 %r 不是合法 ID" % (src, g.get("item")))
spec = dic.grant_spec(k)
if spec.get("form") in ("money", "item", "friend") and \
not isinstance(g.get("value"), (int, float)):
errs.append("[非法数值] 节点 %s 的奖励数值 %r 不是数字" % (src, g.get("value")))
if dic.grant_needs_target(k):
check_slot(src, g.get("target"), "奖励target")
def check_cond(src, c):
if not c:
return
if not dic.is_known_cond(c.get("kind")):
errs.append("[非法条件] 节点 %s 的 condition %r 无法编译" % (src, c)); return
spec = dic.cond_spec(c.get("kind"))
if c.get("op") not in spec.get("ops", {}):
errs.append("[非法条件] 节点 %s 的 condition %r 比较符不支持" % (src, c))
elif not isinstance(c.get("value"), (int, float)):
errs.append("[非法条件数值] 节点 %s 的 condition 阈值 %r 不是数字" % (src, c.get("value")))
for nid, n in nodes.items():
kind = n.get("kind")
if n.get("next"):
check_target(nid, n["next"], "next")
if n.get("speaker"):
check_slot(nid, n["speaker"], "speaker")
if n.get("actor"):
check_slot(nid, n["actor"], "actor")
if kind in ("choice", "choice_once"):
opts = n.get("options", [])
if not opts:
errs.append("[空选择] 节点 %s 没有任何选项" % nid)
if not any("condition" not in o for o in opts):
errs.append("[选项无兜底] 节点 %s 的所有选项都带条件,可能全部不满足导致空面板卡死" % nid)
for o in opts:
check_target(nid, o.get("goto"), "option.goto")
check_cond(nid, o.get("condition"))
if o.get("reward"):
check_grants(nid, o["reward"].get("grants"))
if o.get("skip"):
sk = o["skip"]
check_target(nid, sk.get("node"), "option.skip.node")
grants = (sk.get("reward") or {}).get("grants")
if not grants:
errs.append("[空skip奖励] 节点 %s 选项 skip 缺 reward.grants押注无彩头" % nid)
check_grants(nid, grants)
if kind == "random":
for b in n.get("branches", []):
check_target(nid, b.get("goto"), "branch.goto")
if not isinstance(b.get("weight"), (int, float)):
errs.append("[非法权重] 节点 %s 的随机权重 %r 不是数字" % (nid, b.get("weight")))
if kind == "fight":
if n.get("fight_type") not in (1, 2):
errs.append("[非法战斗类型] 节点 %s 的 fight_type 必须是 1(击倒)/2(死斗)" % nid)
if not n.get("camp2"):
errs.append("[战斗缺敌方] 节点 %s 的 camp2 为空" % nid)
for s in (n.get("camp2") or []) + (n.get("camp1") or []):
check_slot(nid, s, "fight阵营")
check_target(nid, n.get("win"), "fight.win")
check_target(nid, n.get("lose"), "fight.lose")
if kind == "move":
check_slot(nid, n.get("actor"), "move.actor")
if kind == "reward":
check_grants(nid, n.get("grants"))
for e in ir.get("endings", []):
check_grants(e["id"], e.get("grants"))
# ---- 点位集坐标引用校验 ----
_check_point_set(ir, errs, warns, points_dir, strict_points)
return errs, warns
def collect_point_refs(ir):
"""收集 IR 里**实际需要物理站位**的点位名(角色出生点/移动目标/镜头点/战斗阵营)。
仅声明、从未在节点出现的 role如未登场的旁系角色不计——运行时不为其摆人。
"""
refs = set()
all_nodes = list(ir.get("nodes", []))
for s in ir.get("sequences", []):
all_nodes += s.get("nodes", [])
for n in all_nodes:
for f in ("speaker", "actor"):
v = n.get(f)
if v and v != "P1":
refs.add(v)
if n.get("kind") == "move" and n.get("to") and n["to"] != "P1":
refs.add(n["to"])
if n.get("camera"):
refs.add(n["camera"])
if n.get("kind") == "fight":
for s in (n.get("camp1") or []) + (n.get("camp2") or []):
if s and s != "P1":
refs.add(s)
return refs
def _check_point_set(ir, errs, warns, points_dir, strict_points):
import json
name = (ir.get("stage") or {}).get("point_set") or ir["id"]
base = points_dir or _POINTS_DIR
path = os.path.join(base, name + ".points.json")
refs = collect_point_refs(ir)
if not os.path.exists(path):
msg = "[点位集缺失] 未找到 %s.points.json%s);坐标校验跳过" % (name, path)
if strict_points:
errs.append(msg + "--strict-points 下视为错)")
else:
warns.append(msg)
return
try:
with open(path, encoding="utf-8") as f:
ps = json.load(f)
except Exception as e:
errs.append("[点位集损坏] %s 解析失败: %s" % (path, e)); return
have = {p.get("name") for p in ps.get("points", [])}
for r in sorted(refs):
if r not in have:
errs.append("[点位缺失] 点位 %r 在点位集 %s.points.json 中不存在(需取点工具补录)"
% (r, name))

12
ir_dictionary.json Normal file
View File

@ -0,0 +1,12 @@
{
"_note": "语义词典(条件/奖励 kind -> 真实 ID/形态)。编译器与 Web 前端共读。新增查表类 kind 只改本文件;需引擎特例的标 engine:true 并在报告提示。",
"conditions": {
"银两": { "id": "V19", "ops": { ">=": "2" } }
},
"grants": {
"银两": { "form": "money", "id": "V19" },
"道具": { "form": "item" },
"友好度": { "form": "friend", "id": "V15", "needs_target": true },
"入门": { "form": "join", "code": "JoinToPlayerSch", "needs_target": true }
}
}

289
ir_to_html.py Normal file
View File

@ -0,0 +1,289 @@
# -*- 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()

View File

@ -0,0 +1,159 @@
[
{
"group": "QY_BSYZ",
"step": 1,
"type": 0,
"id": "QY_BSYZ_n1",
"points": [
"P1"
],
"content": "市集擂台前人声鼎沸,一名精壮武师立于台上,无人敢应战。",
"nextStepId": "QY_BSYZ_n2"
},
{
"group": "QY_BSYZ",
"step": 2,
"type": 0,
"id": "QY_BSYZ_n2",
"points": [
"NP2"
],
"content": "押注了押注了!是亲自下场,还是押这位高手赢?",
"nextStepId": "QY_BSYZ_n3_o0"
},
{
"group": "QY_BSYZ",
"step": 3,
"type": 1,
"id": "QY_BSYZ_n3_o0",
"choose": "亲自下场会会他",
"points": [
"P1"
],
"nextStepId": "QY_BSYZ_fight1"
},
{
"group": "QY_BSYZ",
"step": 4,
"type": 1,
"id": "QY_BSYZ_n3_o1",
"choose": "押这位高手赢押注50两",
"points": [
"P1"
],
"nextStepId": "QY_BSYZ_ref_cheer_b__c1",
"skipNodeId": "end_bet_win",
"skipReward": "V19,300"
},
{
"group": "QY_BSYZ",
"step": 5,
"type": 1,
"id": "QY_BSYZ_n3_o2",
"choose": "看个热闹就走",
"points": [
"P1"
],
"nextStepId": "QY_BSYZ_end_leave"
},
{
"group": "QY_BSYZ",
"step": 6,
"type": 0,
"id": "QY_BSYZ_fight1",
"points": [
"P1"
],
"camp2Fighters": [
"NP1"
],
"fightStatus": [
"1",
"QY_BSYZ_ref_cheer_a__c1",
"QY_BSYZ_end_fight_lose"
]
},
{
"group": "QY_BSYZ",
"step": 7,
"type": 0,
"id": "QY_BSYZ_ref_cheer_a__c1",
"points": [
"NP2"
],
"content": "好身手!满堂喝彩!",
"nextStepId": "QY_BSYZ_end_fight_win"
},
{
"group": "QY_BSYZ",
"step": 8,
"type": 0,
"id": "QY_BSYZ_end_fight_win",
"missionText": "技压群雄",
"nextStepId": "QY_BSYZ_end_fight_win__end",
"resultRewardIds": "V19,200;NP1,V15,20"
},
{
"group": "QY_BSYZ",
"step": 9,
"type": 0,
"id": "QY_BSYZ_end_fight_win__end",
"gameTaskStatus": 1
},
{
"group": "QY_BSYZ",
"step": 10,
"type": 0,
"id": "QY_BSYZ_end_fight_lose",
"missionText": "败下阵来",
"nextStepId": "QY_BSYZ_end_fight_lose__end"
},
{
"group": "QY_BSYZ",
"step": 11,
"type": 0,
"id": "QY_BSYZ_end_fight_lose__end",
"gameTaskStatus": 2
},
{
"group": "QY_BSYZ",
"step": 12,
"type": 0,
"id": "QY_BSYZ_ref_cheer_b__c1",
"points": [
"NP2"
],
"content": "好身手!满堂喝彩!",
"nextStepId": "QY_BSYZ_end_bet_win"
},
{
"group": "QY_BSYZ",
"step": 13,
"type": 0,
"id": "QY_BSYZ_end_bet_win",
"missionText": "押对了,坐收彩头",
"nextStepId": "QY_BSYZ_end_bet_win__end"
},
{
"group": "QY_BSYZ",
"step": 14,
"type": 0,
"id": "QY_BSYZ_end_bet_win__end",
"gameTaskStatus": 1
},
{
"group": "QY_BSYZ",
"step": 15,
"type": 0,
"id": "QY_BSYZ_end_leave",
"missionText": "看罢热闹离场",
"nextStepId": "QY_BSYZ_end_leave__end"
},
{
"group": "QY_BSYZ",
"step": 16,
"type": 0,
"id": "QY_BSYZ_end_leave__end",
"gameTaskStatus": 1
}
]

View File

@ -0,0 +1,11 @@
# 简体中文(key,勿改) 韩文(待译繁体无需填SGameText 自动转换)
市集擂台前人声鼎沸,一名精壮武师立于台上,无人敢应战。
押注了押注了!是亲自下场,还是押这位高手赢?
亲自下场会会他
押这位高手赢押注50两
看个热闹就走
好身手!满堂喝彩!
技压群雄
败下阵来
押对了,坐收彩头
看罢热闹离场
1 # 简体中文(key,勿改) 韩文(待译;繁体无需填,SGameText 自动转换)
2 市集擂台前人声鼎沸,一名精壮武师立于台上,无人敢应战。
3 押注了押注了!是亲自下场,还是押这位高手赢?
4 亲自下场会会他
5 押这位高手赢(押注50两)
6 看个热闹就走
7 好身手!满堂喝彩!
8 技压群雄
9 败下阵来
10 押对了,坐收彩头
11 看罢热闹离场

View File

@ -0,0 +1,67 @@
{
"id": "QY_BSYZ",
"title": "比武押注",
"theme": "市井江湖·赌性博弈",
"scale": "标准奇遇",
"roles": [
{ "slot": "P1", "name": "路过的你", "archetype": "玩家", "camp": 1 },
{ "slot": "NP1", "name": "擂台高手", "archetype": "市井武师", "camp": 2 },
{ "slot": "NP2", "name": "赌坊庄家", "archetype": "市井掮客", "camp": 0 }
],
"stage": { "type": "市集擂台·昼", "point_set": "QY_BSYZ", "reuse_hint": "K3_A" },
"sequences": [
{
"id": "seq_cheer",
"nodes": [
{ "id": "c1", "kind": "dialogue", "speaker": "NP2",
"text": "好身手!满堂喝彩!" }
]
}
],
"nodes": [
{
"id": "n1", "kind": "narration",
"text": "市集擂台前人声鼎沸,一名精壮武师立于台上,无人敢应战。",
"next": "n2"
},
{
"id": "n2", "kind": "dialogue", "speaker": "NP2",
"text": "押注了押注了!是亲自下场,还是押这位高手赢?",
"next": "n3"
},
{
"id": "n3", "kind": "choice",
"options": [
{ "text": "亲自下场会会他", "goto": "fight1" },
{
"text": "押这位高手赢押注50两",
"skip": { "node": "end_bet_win", "reward": { "grants": [ { "kind": "银两", "value": 300 } ] } },
"goto": "ref_cheer_b"
},
{ "text": "看个热闹就走", "goto": "end_leave" }
]
},
{
"id": "fight1", "kind": "fight",
"fight_type": 1,
"camp1": [ "P1" ],
"camp2": [ "NP1" ],
"win": "ref_cheer_a",
"lose": "end_fight_lose"
},
{ "id": "ref_cheer_a", "kind": "out_ref", "ref": "seq_cheer", "next": "end_fight_win" },
{ "id": "ref_cheer_b", "kind": "out_ref", "ref": "seq_cheer", "next": "end_bet_win" }
],
"endings": [
{
"id": "end_fight_win", "summary": "技压群雄",
"grants": [
{ "kind": "银两", "value": 200 },
{ "kind": "友好度", "target": "NP1", "value": 20 }
]
},
{ "id": "end_fight_lose", "summary": "败下阵来", "grants": [], "result": "fail" },
{ "id": "end_bet_win", "summary": "押对了,坐收彩头", "grants": [] },
{ "id": "end_leave", "summary": "看罢热闹离场", "grants": [] }
]
}

View File

@ -0,0 +1,151 @@
[
{
"group": "QY_YYKM",
"step": 1,
"type": 0,
"id": "QY_YYKM_n1",
"points": [
"P1"
],
"content": "暴雨倾盆,山门外的灯笼在风里摇晃。一阵急促的叩门声,盖过了雷声。",
"nextStepId": "QY_YYKM_n2"
},
{
"group": "QY_YYKM",
"step": 2,
"type": 0,
"id": "QY_YYKM_n2",
"points": [
"NP1"
],
"content": "在下途经贵派,身负旧伤,恳请借宿一晚,天明即走。",
"nextStepId": "QY_YYKM_n3"
},
{
"group": "QY_YYKM",
"step": 3,
"type": 0,
"id": "QY_YYKM_n3",
"points": [
"P1"
],
"content": "(这位侠客腰间的铁牌……分明是近日劫掠商队的黑风寨样式。)",
"nextStepId": "QY_YYKM_n4_o0"
},
{
"group": "QY_YYKM",
"step": 4,
"type": 1,
"id": "QY_YYKM_n4_o0",
"choose": "江湖救急,先收留他",
"points": [
"P1"
],
"nextStepId": "QY_YYKM_end_ally"
},
{
"group": "QY_YYKM",
"step": 5,
"type": 1,
"id": "QY_YYKM_n4_o1",
"choose": "不动声色,擒下他交予掌门",
"points": [
"P1"
],
"nextStepId": "QY_YYKM_fight1"
},
{
"group": "QY_YYKM",
"step": 6,
"type": 1,
"id": "QY_YYKM_n4_o2",
"choose": "赠些盘缠,请他即刻离去",
"points": [
"P1"
],
"nextStepId": "QY_YYKM_end_pay",
"condition": "V19,2,500",
"resultRewardIds": "V19,-500"
},
{
"group": "QY_YYKM",
"step": 7,
"type": 0,
"id": "QY_YYKM_end_ally",
"missionText": "结义同盟",
"nextStepId": "QY_YYKM_end_ally__end",
"resultRewardIds": "NP1,V15,30",
"roleActionCode": "JoinToPlayerSch=NP1"
},
{
"group": "QY_YYKM",
"step": 8,
"type": 0,
"id": "QY_YYKM_end_ally__end",
"gameTaskStatus": 1
},
{
"group": "QY_YYKM",
"step": 9,
"type": 0,
"id": "QY_YYKM_fight1",
"points": [
"P1"
],
"camp2Fighters": [
"NP1"
],
"fightStatus": [
"1",
"QY_YYKM_end_subdue",
"QY_YYKM_end_lose"
]
},
{
"group": "QY_YYKM",
"step": 10,
"type": 0,
"id": "QY_YYKM_end_subdue",
"missionText": "擒贼献掌门",
"nextStepId": "QY_YYKM_end_subdue__end",
"resultRewardIds": "V19,200;P6,1"
},
{
"group": "QY_YYKM",
"step": 11,
"type": 0,
"id": "QY_YYKM_end_subdue__end",
"gameTaskStatus": 1
},
{
"group": "QY_YYKM",
"step": 12,
"type": 0,
"id": "QY_YYKM_end_lose",
"missionText": "技不如人",
"nextStepId": "QY_YYKM_end_lose__end"
},
{
"group": "QY_YYKM",
"step": 13,
"type": 0,
"id": "QY_YYKM_end_lose__end",
"gameTaskStatus": 2
},
{
"group": "QY_YYKM",
"step": 14,
"type": 0,
"id": "QY_YYKM_end_pay",
"missionText": "破财消灾",
"nextStepId": "QY_YYKM_end_pay__end",
"resultRewardIds": "NP1,V15,10"
},
{
"group": "QY_YYKM",
"step": 15,
"type": 0,
"id": "QY_YYKM_end_pay__end",
"gameTaskStatus": 1
}
]

248
samples/yuye_koumen.html Normal file
View 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>

View File

@ -0,0 +1,11 @@
# 简体中文(key,勿改) 韩文(待译繁体无需填SGameText 自动转换)
暴雨倾盆,山门外的灯笼在风里摇晃。一阵急促的叩门声,盖过了雷声。
在下途经贵派,身负旧伤,恳请借宿一晚,天明即走。
(这位侠客腰间的铁牌……分明是近日劫掠商队的黑风寨样式。)
江湖救急,先收留他
不动声色,擒下他交予掌门
赠些盘缠,请他即刻离去
结义同盟
破财消灾
擒贼献掌门
技不如人
1 # 简体中文(key,勿改) 韩文(待译;繁体无需填,SGameText 自动转换)
2 暴雨倾盆,山门外的灯笼在风里摇晃。一阵急促的叩门声,盖过了雷声。
3 在下途经贵派,身负旧伤,恳请借宿一晚,天明即走。
4 (这位侠客腰间的铁牌……分明是近日劫掠商队的黑风寨样式。)
5 江湖救急,先收留他
6 不动声色,擒下他交予掌门
7 赠些盘缠,请他即刻离去
8 结义同盟
9 破财消灾
10 擒贼献掌门
11 技不如人

View File

@ -0,0 +1,75 @@
{
"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": [],
"result": "fail"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

11
web/.dockerignore Normal file
View File

@ -0,0 +1,11 @@
# 构建上下文 = tools/event_authoring只需 ir_core / ir_dictionary.json / web。
web/data
web/story_events.db
web/__pycache__
**/__pycache__
*.pyc
samples
*.html
ir_to_html.py
ir_compile.py
IR_SCHEMA.md

4
web/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
story_events.db
data/
__pycache__/
*.pyc

33
web/Dockerfile Normal file
View File

@ -0,0 +1,33 @@
# Story 事件协作 Web 编辑器M6 部署)。
# 构建上下文 = tools/event_authoring (需含 ir_core / ir_dictionary.json / web
# docker build -f web/Dockerfile -t story-event-web .
# 或用 web/docker-compose.yml 一键起。
FROM python:3.12-slim
WORKDIR /app
# 仅装后端运行依赖(编译器是纯 Python 标准库,无需额外包)
# PIP_INDEX_URL 默认官方源(可移植);国内构建可 --build-arg 指向清华等镜像加速。
ARG PIP_INDEX_URL=https://pypi.org/simple
COPY web/requirements.txt ./web/requirements.txt
RUN pip install --no-cache-dir --index-url ${PIP_INDEX_URL} -r web/requirements.txt
# 编译/校验内核 + 外置词典 + 后端 + 前端
COPY ir_core ./ir_core
COPY ir_dictionary.json ./ir_dictionary.json
COPY web ./web
# SQLite 持久化目录(挂卷到此);点位集挂载到 /pointsets只读
ENV STORY_DB_PATH=/data/story_events.db \
STORY_POINTSETS_DIR=/pointsets \
STORY_WEB_PASSWORD=story
RUN mkdir -p /data /pointsets
EXPOSE 8787
WORKDIR /app/web
# 探测首页(静态,恒 200)/api/* 无凭证会 401 且 urlopen 会抛异常,故改打 / 。
HEALTHCHECK --interval=30s --timeout=4s --start-period=8s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8787/',timeout=3).status==200 else 1)"
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8787"]

70
web/README.md Normal file
View File

@ -0,0 +1,70 @@
# Story 事件协作 Web 编辑器M5
设计:`docs/plans/2026-06-06-story-event-pipeline-design.md`§5.1/§6, D1D4/D8
计划:`docs/plans/2026-06-08-story-event-M5-web-editor-plan.md`
少数人凭共享口令在网页里审校/编辑剧情事件 → 静态校验 + 剧本试走(零引擎)→ 一键编译
所有 confirmed 事件成 `.events.json` + `.i18n.tsv` 打包下载。校验/编译走 `ir_core`,与 CLI
`ir_compile.py`)逐字节同口径。
## 起服务
```bash
cd tools/event_authoring/web
pip install -r requirements.txt
# Windows PowerShell: $env:STORY_WEB_PASSWORD="your-pass"
set STORY_WEB_PASSWORD=your-pass # 默认 story
uvicorn app:app --host 0.0.0.0 --port 8787
```
浏览器打开 `http://<host>:8787`,输入共享口令进入。
## 用法
1. **导入 IR**:右上「导入 IR」粘贴单个或数组形式的 IR JSON`samples/*.ir.json`)。
2. **审校/编辑**:左栏选事件 → 中栏分支树 → 点节点在右栏改文案/增删节点/下拉改分支/角色表/点位下拉。
3. **校验**:与 CLI 同口径(断链、选项无兜底、未登记 kind、未声明角色、点位缺失、out_ref 失效…)。
4. **试走**:从首节点走,点选项/掷随机/手选战斗胜负,实时累计银两/道具/友好度账面与结局。
5. **确认/丢弃**改事件状态pending/confirmed/discarded
6. **导出 confirmed**:编译所有 confirmed → `story_export.zip`;任一 confirmed 校验不过则整体拒绝。
导出后把 `{group}.events.json` 放进 `Assets/StreamingAssets/Story/Config/`(或 `Qiyu/` 子目录),
`{group}.i18n.tsv` 的韩文翻译合并进 `Assets/StreamingAssets/i18n/ko.tsv`
## 数据
- 事件存 SQLite `story_events.db`(本目录,已 gitignore末次写入生效不做锁
- 词典 `../ir_dictionary.json`、点位集 `Assets/StreamingAssets/Story/PointSets/*.points.json` 只读引用。
## Docker 部署M6
单容器FastAPI + 静态前端 + SQLite + 纯 Python 编译器)。构建上下文是
`tools/event_authoring`(需含 `ir_core`/`ir_dictionary.json`/`web`)。
```bash
cd tools/event_authoring/web
STORY_WEB_PASSWORD=your-pass docker compose up -d --build
# 或不用 compose
# docker build -f web/Dockerfile -t story-event-web ..
# docker run -d -p 8787:8787 -e STORY_WEB_PASSWORD=your-pass \
# -v "$PWD/web/data:/data" \
# -v "$PWD/Assets/StreamingAssets/Story/PointSets:/pointsets:ro" story-event-web
```
- **卷**`./data:/data`SQLite 持久化,容器重建不丢事件,**勿删**
`…/PointSets:/pointsets:ro`(开发侧点位集只读;缺失时坐标校验降级为警告)。
- **环境变量**`STORY_WEB_PASSWORD`(口令)、`STORY_WEB_PORT`(宿主端口,默认 8787
`STORY_DB_PATH`(默认 `/data/story_events.db`)、`STORY_POINTSETS_DIR`(默认 `/pointsets`)、
可选 `TZ=Asia/Shanghai`(否则 `updated_at` 按 UTC 显示)。
- **NAS + VPS**NAS 跑容器VPS 用反代/frp/Cloudflare Tunnel 把 8787 映射出去。点位集更新只需
同步文件到 NAS 的 `/pointsets` 卷路径,**无需重建镜像**。备份=拷 `data/story_events.db`
## API鉴权后
`POST /api/login` · `GET /api/dictionary` · `GET /api/pointsets` · `POST /api/import` ·
`GET /api/events?status=` · `GET /api/events/{group}` · `PUT /api/events/{group}` ·
`POST /api/events/{group}/status` · `POST /api/validate` · `POST /api/export`
## 非目标(见设计)
容器化部署M6、多人账号/并发锁、手拖画布、引擎侧新语义、把坐标嵌进 events.json。

209
web/app.py Normal file
View File

@ -0,0 +1,209 @@
# -*- coding: utf-8 -*-
"""M5 协作 Web 编辑器后端FastAPI 单文件)。
少数人 + 共享口令;事件存 SQLite校验/编译走 ir_core与 CLI 同口径)。
起服务:
pip install -r requirements.txt
set STORY_WEB_PASSWORD=your-pass (默认 story)
uvicorn app:app --host 0.0.0.0 --port 8787
浏览器打开 http://<host>:8787 。
"""
import datetime
import io
import json
import os
import sys
import zipfile
from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse, FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
_HERE = os.path.dirname(os.path.abspath(__file__))
_AUTHORING = os.path.dirname(_HERE) # tools/event_authoring
_PROJ = os.path.dirname(os.path.dirname(_AUTHORING)) # 项目根
sys.path.insert(0, _AUTHORING)
import ir_core # noqa: E402 tools/event_authoring/ir_core
import db # noqa: E402
_DICT_PATH = os.path.join(_AUTHORING, "ir_dictionary.json")
# 点位集目录:容器内用 STORY_POINTSETS_DIR 指向挂载卷;本地默认指向项目 Assets。
_POINTSETS_DIR = os.environ.get("STORY_POINTSETS_DIR") or \
os.path.join(_PROJ, "Assets", "StreamingAssets", "Story", "PointSets")
_STATIC_DIR = os.path.join(_HERE, "static")
PASSWORD = os.environ.get("STORY_WEB_PASSWORD", "story")
COOKIE = "story_auth"
db.init_db()
app = FastAPI(title="Story Event Web Editor (M5)")
def _now():
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# ---------- 鉴权中间件 ----------
@app.middleware("http")
async def auth_guard(request: Request, call_next):
path = request.url.path
# 放行登录、静态资源、根
if path.startswith("/api/") and path != "/api/login":
if request.cookies.get(COOKIE) != PASSWORD:
return JSONResponse({"error": "未授权"}, status_code=401)
return await call_next(request)
# ---------- 鉴权 ----------
@app.post("/api/login")
async def login(request: Request):
body = await request.json()
if body.get("password") != PASSWORD:
return JSONResponse({"error": "口令错误"}, status_code=403)
resp = JSONResponse({"ok": True})
resp.set_cookie(COOKIE, PASSWORD, httponly=True, samesite="lax", max_age=30 * 86400)
return resp
@app.post("/api/logout")
async def logout():
resp = JSONResponse({"ok": True})
resp.delete_cookie(COOKIE)
return resp
# ---------- 词典 / 点位集(驱动前端下拉与试走) ----------
@app.get("/api/dictionary")
async def dictionary():
with open(_DICT_PATH, encoding="utf-8") as f:
return json.load(f)
@app.get("/api/pointsets")
async def pointsets():
out = {}
if os.path.isdir(_POINTSETS_DIR):
for fn in os.listdir(_POINTSETS_DIR):
if fn.endswith(".points.json"):
name = fn[: -len(".points.json")]
try:
with open(os.path.join(_POINTSETS_DIR, fn), encoding="utf-8") as f:
ps = json.load(f)
out[name] = {
"mapId": ps.get("mapId", ""),
"points": [p.get("name") for p in ps.get("points", [])],
}
except Exception as e:
out[name] = {"error": str(e)}
return out
# ---------- 事件 CRUD ----------
@app.get("/api/events")
async def events(status: str = "all"):
return db.list_events(status)
@app.get("/api/events/{group}")
async def event_detail(group: str):
d = db.get_event(group)
if not d:
return JSONResponse({"error": "不存在"}, status_code=404)
return d
@app.post("/api/import")
async def import_events(request: Request):
body = await request.json()
by = body.get("by", "匿名")
items = body.get("events", [])
if isinstance(items, dict): # 容错:单个 IR
items = [items]
saved, errors = [], []
for ir in items:
if not isinstance(ir, dict) or "id" not in ir:
errors.append("缺少 id 字段的条目已跳过")
continue
db.upsert_event(ir, by, _now())
saved.append(ir["id"])
return {"saved": saved, "errors": errors}
@app.put("/api/events/{group}")
async def update_event(group: str, request: Request):
body = await request.json()
ir = body.get("ir")
if not ir or ir.get("id") != group:
return JSONResponse({"error": "ir.id 与 group 不一致"}, status_code=400)
db.upsert_event(ir, body.get("by", "匿名"), _now(), notes=body.get("notes"))
return {"ok": True, "updated_at": _now()}
@app.post("/api/events/{group}/status")
async def change_status(group: str, request: Request):
body = await request.json()
ok = db.set_status(group, body.get("status"), body.get("by", "匿名"), _now())
if not ok:
return JSONResponse({"error": "事件不存在"}, status_code=404)
return {"ok": True}
# ---------- 校验 ----------
@app.post("/api/validate")
async def validate(request: Request):
body = await request.json()
ir = body.get("ir")
if not ir:
return JSONResponse({"error": "缺少 ir"}, status_code=400)
dic = ir_core.load_dictionary(_DICT_PATH)
try:
errs, warns = ir_core.validate(ir, dic, points_dir=_POINTSETS_DIR)
except Exception as e:
return {"errors": ["[校验异常] %s" % e], "warnings": []}
return {"errors": errs, "warnings": warns}
# ---------- 导出(编译所有 confirmed -> zip ----------
@app.post("/api/export")
async def export_zip():
dic = ir_core.load_dictionary(_DICT_PATH)
confirmed = db.confirmed_events()
if not confirmed:
return JSONResponse({"error": "没有 confirmed 事件可导出"}, status_code=422)
# 校验门:任一 confirmed 有 error 即整体拒绝
report = {}
blocked = False
for group, ir in confirmed:
errs, warns = ir_core.validate(ir, dic, points_dir=_POINTSETS_DIR)
report[group] = {"errors": errs, "warnings": warns}
if errs:
blocked = True
if blocked:
return JSONResponse({"error": "存在校验失败的 confirmed 事件,已拒绝导出",
"report": report}, status_code=422)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
for group, ir in confirmed:
rows = ir_core.compile_ir(ir, dic)
z.writestr(group + ".events.json",
json.dumps(rows, ensure_ascii=False, indent=2))
texts = ir_core.extract_texts(ir)
tsv = ["# 简体中文(key,勿改)\t韩文(待译繁体无需填SGameText 自动转换)"]
tsv += ["%s\t" % t for t in texts]
z.writestr(group + ".i18n.tsv", "\n".join(tsv))
buf.seek(0)
return StreamingResponse(
buf, media_type="application/zip",
headers={"Content-Disposition": 'attachment; filename="story_export.zip"'})
# ---------- 静态前端 ----------
@app.get("/")
async def index():
return FileResponse(os.path.join(_STATIC_DIR, "index.html"))
app.mount("/", StaticFiles(directory=_STATIC_DIR), name="static")

108
web/db.py Normal file
View File

@ -0,0 +1,108 @@
# -*- coding: utf-8 -*-
"""M5 Web 编辑器的 SQLite 存储层。
单表 eventsgroup(PK)/title/theme/status/ir_json/updated_at/updated_by/notes。
末次写入生效(设计接受),不做锁。
"""
import json
import os
import sqlite3
# DB 路径:容器内用 STORY_DB_PATH 指向挂载卷(持久化);本地默认同目录。
_DB_PATH = os.environ.get("STORY_DB_PATH") or \
os.path.join(os.path.dirname(os.path.abspath(__file__)), "story_events.db")
STATUSES = ("pending", "confirmed", "discarded")
def _conn(path=None):
c = sqlite3.connect(path or _DB_PATH)
c.row_factory = sqlite3.Row
return c
def init_db(path=None):
d = os.path.dirname(path or _DB_PATH)
if d and not os.path.isdir(d):
os.makedirs(d, exist_ok=True)
with _conn(path) as c:
c.execute(
"""CREATE TABLE IF NOT EXISTS events (
"group" TEXT PRIMARY KEY,
title TEXT,
theme TEXT,
status TEXT NOT NULL DEFAULT 'pending',
ir_json TEXT NOT NULL,
updated_at TEXT,
updated_by TEXT,
notes TEXT
)"""
)
def list_events(status=None, path=None):
"""列表(不含 ir_json轻量"""
sql = ('SELECT "group", title, theme, status, updated_at, updated_by, notes '
"FROM events")
args = []
if status and status != "all":
sql += " WHERE status = ?"
args.append(status)
sql += " ORDER BY updated_at DESC"
with _conn(path) as c:
return [dict(r) for r in c.execute(sql, args).fetchall()]
def get_event(group, path=None):
with _conn(path) as c:
r = c.execute('SELECT * FROM events WHERE "group" = ?', (group,)).fetchone()
if not r:
return None
d = dict(r)
d["ir"] = json.loads(d.pop("ir_json"))
return d
def upsert_event(ir, by, now, notes=None, keep_status=True, path=None):
"""插入或更新。已存在时默认保留状态(仅刷新 ir/title/theme/元信息)。"""
group = ir["id"]
title = ir.get("title", "")
theme = ir.get("theme", "")
ir_str = json.dumps(ir, ensure_ascii=False)
with _conn(path) as c:
exists = c.execute('SELECT status FROM events WHERE "group" = ?',
(group,)).fetchone()
if exists:
c.execute(
'UPDATE events SET title=?, theme=?, ir_json=?, updated_at=?, '
'updated_by=?, notes=COALESCE(?, notes) WHERE "group"=?',
(title, theme, ir_str, now, by, notes, group),
)
else:
c.execute(
'INSERT INTO events ("group", title, theme, status, ir_json, '
"updated_at, updated_by, notes) VALUES (?,?,?,?,?,?,?,?)",
(group, title, theme, "pending", ir_str, now, by, notes or ""),
)
return group
def set_status(group, status, by, now, path=None):
if status not in STATUSES:
raise ValueError("非法状态: %r" % status)
with _conn(path) as c:
cur = c.execute(
'UPDATE events SET status=?, updated_at=?, updated_by=? WHERE "group"=?',
(status, now, by, group),
)
return cur.rowcount > 0
def confirmed_events(path=None):
"""所有 confirmed 事件的 (group, ir) 列表,供导出编译。"""
with _conn(path) as c:
rows = c.execute(
'SELECT "group", ir_json FROM events WHERE status=? ORDER BY "group"',
("confirmed",),
).fetchall()
return [(r["group"], json.loads(r["ir_json"])) for r in rows]

View File

@ -0,0 +1,21 @@
# 极空间(x86/amd64) 部署用:导入 story-event-web.tar 后直接引用镜像起服务(不构建)。
# 在极空间 Docker 的「Compose」里新建项目粘贴本文件内容即可。
# 改两处STORY_WEB_PASSWORD口令、按需放开点位集卷。
services:
story-web:
image: story-event-web:latest # 由 docker save 导出的 tar 导入而来
container_name: story-event-web
ports:
- "8787:8787" # 宿主8787 -> 容器8787frpc/反代再对外
environment:
STORY_WEB_PASSWORD: "change-me" # ← 改成你的共享口令
TZ: "Asia/Shanghai" # 否则 updated_at 按 UTC 显示
volumes:
- ./data:/data # SQLite 持久化(事件数据,勿删)
# 前端静态文件热挂载(可选但推荐):把仓库 tools/event_authoring/web/static
# 同步到 NAS 某目录后取消下一行注释并改成实际路径,之后改前端只需同步+刷新,无需重新导入镜像:
# - /vol1/docker/story/static:/app/web/static:ro
# 点位集(可选):先不挂 = 坐标校验降级为警告,能正常用。
# 把 Assets/StreamingAssets/Story/PointSets 同步到 NAS 某目录后取消下一行注释并改成实际路径:
# - /vol1/docker/story/pointsets:/pointsets:ro
restart: unless-stopped

26
web/docker-compose.yml Normal file
View File

@ -0,0 +1,26 @@
# Story 事件协作 Web 编辑器M6。NAS 跑容器VPS 端口映射到此。
# cd tools/event_authoring/web
# STORY_WEB_PASSWORD=your-pass docker compose up -d --build
services:
story-web:
build:
context: .. # = tools/event_authoring
dockerfile: web/Dockerfile
args:
# 默认官方源;国内构建用环境变量覆盖,如
# PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple docker compose build
PIP_INDEX_URL: "${PIP_INDEX_URL:-https://pypi.org/simple}"
image: story-event-web:latest
container_name: story-event-web
ports:
- "${STORY_WEB_PORT:-8787}:8787"
environment:
STORY_WEB_PASSWORD: "${STORY_WEB_PASSWORD:-story}"
volumes:
# SQLite 持久化(事件数据;勿删)
- ./data:/data
# 点位集(开发侧产出,只读引用;缺失时坐标校验降级为警告)
- ../../../Assets/StreamingAssets/Story/PointSets:/pointsets:ro
# 前端静态文件热挂载:改 static/* 后刷新浏览器即生效,无需重建镜像
- ./static:/app/web/static:ro
restart: unless-stopped

2
web/requirements.txt Normal file
View File

@ -0,0 +1,2 @@
fastapi>=0.110
uvicorn[standard]>=0.27

252
web/static/app.js Normal file
View File

@ -0,0 +1,252 @@
// 主控:鉴权 / 事件列表 / 加载保存 / 校验 / 状态 / 导入导出 / 试走。
(function () {
const App = {
dict: { conditions: {}, grants: {} },
pointsets: {}, // name -> {mapId, points:[]}
events: [],
current: null, // 当前 group
ir: null, // 工作副本
status: null,
selectedNode: null,
dirty: false,
by: localStorage.getItem("story_by") || "匿名",
};
window.App = App;
const $ = id => document.getElementById(id);
async function api(path, opts) {
const r = await fetch(path, Object.assign({ headers: { "Content-Type": "application/json" } }, opts));
if (r.status === 401) { showLogin(); throw new Error("未授权"); }
return r;
}
// ---------- 鉴权 ----------
function showLogin() { $("login").classList.remove("hidden"); $("login").style.display = "flex"; }
function hideLogin() { $("login").style.display = "none"; }
$("login-btn").onclick = async () => {
const pass = $("login-pass").value, name = $("login-name").value.trim();
const r = await fetch("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: pass }) });
if (!r.ok) { $("login-err").textContent = "口令错误"; return; }
if (name) { App.by = name; localStorage.setItem("story_by", name); }
hideLogin(); init();
};
$("login-pass").onkeydown = e => { if (e.key === "Enter") $("login-btn").click(); };
// ---------- 初始化 ----------
async function init() {
$("who").textContent = "你:" + App.by;
try {
App.dict = await (await api("/api/dictionary")).json();
App.pointsets = await (await api("/api/pointsets")).json();
} catch (e) { return; }
await loadList();
}
// ---------- 列表 ----------
async function loadList() {
const status = $("filter-status").value;
const r = await api("/api/events?status=" + encodeURIComponent(status));
App.events = await r.json();
renderList();
}
function renderList() {
const q = $("search").value.trim().toLowerCase();
const host = $("event-list"); host.innerHTML = "";
const badge = { pending: ["b-pending", "待审"], confirmed: ["b-confirmed", "已确认"], discarded: ["b-discarded", "已丢弃"] };
App.events
.filter(e => !q || (e.title || "").toLowerCase().includes(q) || (e.group || "").toLowerCase().includes(q))
.forEach(e => {
const b = badge[e.status] || ["b-pending", e.status];
const d = document.createElement("div");
d.className = "ev" + (e.group === App.current ? " sel" : "");
d.innerHTML = '<span class="badge ' + b[0] + '">' + b[1] + '</span>'
+ '<div class="t">' + esc(e.title || e.group) + '</div>'
+ '<div class="g">' + esc(e.group) + ' · ' + esc(e.updated_by || "") + ' ' + esc((e.updated_at || "").slice(5, 16)) + '</div>';
d.onclick = () => selectEvent(e.group);
host.appendChild(d);
});
if (!host.children.length) host.innerHTML = '<div class="empty" style="padding:14px">无事件,点「导入 IR」</div>';
}
$("filter-status").onchange = loadList;
$("search").oninput = renderList;
// ---------- 选中事件 ----------
async function selectEvent(group) {
if (App.dirty && !confirm("当前事件有未保存修改,放弃并切换?")) return;
const r = await api("/api/events/" + encodeURIComponent(group));
const d = await r.json();
App.current = group; App.ir = JSON.parse(JSON.stringify(d.ir));
App.status = d.status; App.selectedNode = null; App.dirty = false;
$("graph-empty").style.display = "none";
["btn-save", "btn-validate", "btn-playtest", "btn-confirm", "btn-discard", "btn-addnode"].forEach(b => $(b).disabled = false);
renderAll(true);
renderList();
updateDirty();
}
const ctx = () => ({
dict: App.dict,
pointNames: (App.pointsets[(App.ir.stage || {}).point_set || App.ir.id] || {}).points || [],
onChange: structural => { App.dirty = true; updateDirty(); drawTree(); if (structural) { FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, App.selectedNode, ctx()); } },
selectNode: id => { App.selectedNode = id; },
});
function drawTree() {
if (App.ir) renderTree(App.ir, {
selected: App.selectedNode, onSelect: selectNode,
onAddNext: addSuccessor, onDelete: deleteNode,
});
}
function selectNode(id) { App.selectedNode = id; drawTree(); FormUI.renderNode(App.ir, id, ctx()); }
// 节点快捷按钮:加后继 / 删除
function addSuccessor(id) {
const r = FormUI.addSuccessor(App.ir, id);
if (!r) return;
if (!r.linked) alert("该战斗节点的「胜→win」「败→lose」出口都已占用\n新节点已创建但未自动接线——请在右栏把胜或败指向它id: " + r.id + ")。");
App.dirty = true; selectNode(r.id); FormUI.renderMeta(App.ir, ctx()); updateDirty();
}
function deleteNode(id) {
const isEnding = (App.ir.endings || []).some(e => e.id === id);
if (!confirm("删除" + (isEnding ? "结局" : "节点") + " " + id + "?指向它的跳转需手动修复(校验会提示)。")) return;
if (isEnding) App.ir.endings = (App.ir.endings || []).filter(e => e.id !== id);
else App.ir.nodes = (App.ir.nodes || []).filter(n => n.id !== id);
if (App.selectedNode === id) App.selectedNode = null;
App.dirty = true; drawTree(); FormUI.renderMeta(App.ir, ctx());
FormUI.renderNode(App.ir, App.selectedNode, ctx()); updateDirty();
}
function renderAll() { drawTree(); FormUI.renderMeta(App.ir, ctx()); FormUI.renderNode(App.ir, App.selectedNode, ctx()); }
function updateDirty() {
$("btn-save").textContent = App.dirty ? "保存 *" : "保存";
$("who").textContent = "你:" + App.by + (App.current ? (" | " + App.current + "" + (App.status || "") + "") : "");
}
// ---------- 增节点 ----------
$("btn-addnode").onclick = () => {
if (!App.ir) return;
const id = FormUI.newNode(App.ir);
App.dirty = true; selectNode(id); FormUI.renderMeta(App.ir, ctx()); updateDirty();
};
// ---------- 保存 ----------
$("btn-save").onclick = async () => {
if (!App.ir) return;
const r = await api("/api/events/" + encodeURIComponent(App.current), { method: "PUT", body: JSON.stringify({ ir: App.ir, by: App.by }) });
if (r.ok) { App.dirty = false; updateDirty(); await loadList(); }
else alert("保存失败:" + (await r.text()));
};
// ---------- 校验 ----------
$("btn-validate").onclick = async () => {
const r = await api("/api/validate", { method: "POST", body: JSON.stringify({ ir: App.ir }) });
const d = await r.json();
showValidate(d.errors || [], d.warnings || []);
};
function showValidate(errs, warns) {
let h = "";
if (!errs.length && !warns.length) h = '<div class="v-ok">✓ 校验通过,无错误无警告</div>';
errs.forEach(e => h += '<div class="v-err">✗ ' + esc(e) + '</div>');
warns.forEach(w => h += '<div class="v-warn">⚠ ' + esc(w) + '</div>');
$("validate-body").innerHTML = h;
$("validate-modal").classList.remove("hidden");
}
// ---------- 状态 ----------
async function setStatus(s) {
const r = await api("/api/events/" + encodeURIComponent(App.current) + "/status", { method: "POST", body: JSON.stringify({ status: s, by: App.by }) });
if (r.ok) { App.status = s; updateDirty(); await loadList(); }
}
$("btn-confirm").onclick = () => setStatus("confirmed");
$("btn-discard").onclick = () => setStatus("discarded");
// ---------- 试走 ----------
$("btn-playtest").onclick = () => Playtest.open(App.ir, App.dict);
// ---------- 导入 ----------
let importFiles = []; // 当前已选文件
function renderImportFiles() {
const host = $("import-files"); host.innerHTML = "";
importFiles.forEach((f, i) => {
const d = document.createElement("div");
d.className = "fileitem";
d.innerHTML = '<span class="fn">' + esc(f.name) + '</span><span class="fsz">' + (f.size > 1024 ? (f.size / 1024).toFixed(1) + " KB" : f.size + " B") + '</span><button class="rm" title="移除">✕</button>';
d.querySelector(".rm").onclick = () => { importFiles.splice(i, 1); renderImportFiles(); };
host.appendChild(d);
});
}
function addImportFiles(fileList) {
for (const f of fileList) if (!importFiles.some(x => x.name === f.name && x.size === f.size)) importFiles.push(f);
renderImportFiles();
}
$("btn-import").onclick = () => {
importFiles = []; renderImportFiles();
$("import-text").value = ""; $("import-result").textContent = "";
$("import-modal").classList.remove("hidden");
};
$("import-drop").onclick = () => $("import-file").click();
$("import-file").onchange = e => { addImportFiles(e.target.files); e.target.value = ""; };
const drop = $("import-drop");
["dragenter", "dragover"].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.add("over"); }));
["dragleave", "drop"].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.remove("over"); }));
drop.addEventListener("drop", e => { addImportFiles(e.dataTransfer.files); });
async function collectImportEvents() {
const events = [];
for (const f of importFiles) {
let text;
try { text = await f.text(); } catch (e) { throw new Error(f.name + ":读取失败"); }
let data;
try { data = JSON.parse(text); } catch (e) { throw new Error(f.name + "JSON 解析失败 " + e.message); }
(Array.isArray(data) ? data : [data]).forEach(x => events.push(x));
}
const pasted = $("import-text").value.trim();
if (pasted) {
let data;
try { data = JSON.parse(pasted); } catch (e) { throw new Error("粘贴文本JSON 解析失败 " + e.message); }
(Array.isArray(data) ? data : [data]).forEach(x => events.push(x));
}
return events;
}
$("import-do").onclick = async () => {
$("import-result").classList.add("err"); $("import-result").style.color = "";
let events;
try { events = await collectImportEvents(); } catch (e) { $("import-result").textContent = e.message; return; }
if (!events.length) { $("import-result").textContent = "请先选择文件或粘贴 JSON"; return; }
const r = await api("/api/import", { method: "POST", body: JSON.stringify({ events, by: App.by }) });
const d = await r.json();
$("import-result").textContent = "已导入 " + (d.saved || []).length + " 个" + ((d.errors || []).length ? "" + d.errors.join("") : "");
importFiles = []; renderImportFiles(); $("import-text").value = "";
await loadList();
};
// ---------- 导出 ----------
$("btn-export").onclick = async () => {
const r = await api("/api/export", { method: "POST", body: JSON.stringify({}) });
if (r.ok) {
const blob = await r.blob();
const a = document.createElement("a"); a.href = URL.createObjectURL(blob);
a.download = "story_export.zip"; a.click();
} else {
const d = await r.json().catch(() => ({}));
let msg = d.error || "导出失败";
if (d.report) { for (const g in d.report) { const e = d.report[g].errors; if (e.length) msg += "\n[" + g + "] " + e.join(""); } }
alert(msg);
}
};
// ---------- 遮罩关闭 ----------
document.querySelectorAll(".modal-close").forEach(b => b.onclick = () => b.closest(".overlay").classList.add("hidden"));
// ---------- 工具 ----------
function esc(s) { return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
window.addEventListener("resize", drawTree);
// ---------- 启动 ----------
(async function () {
try { const r = await fetch("/api/events?status=all"); if (r.status === 401) { showLogin(); return; } hideLogin(); init(); }
catch (e) { showLogin(); }
})();
})();

324
web/static/form.js Normal file
View File

@ -0,0 +1,324 @@
// 右栏表单编辑:元信息 + 角色表 + 按 kind 的节点表单 + grant/condition 编辑器。
// ctx = { dict, pointNames:[], onChange(structural), selectNode(id) }
// onChange(true) -> 结构变化(增删节点/选项、改 kind调用方重画树 + 重渲表单
// onChange(false) -> 仅字段值变化:调用方重画树(更新标签)
(function () {
const NODE_KINDS = ["narration", "dialogue", "choice", "choice_once", "random",
"fight", "move", "anim", "reward", "out_ref"];
// ---- DOM 小工具 ----
function el(tag, attrs, kids) {
const e = document.createElement(tag);
if (attrs) for (const k in attrs) {
if (k === "class") e.className = attrs[k];
else if (k.startsWith("on")) e[k] = attrs[k];
else if (attrs[k] != null) e.setAttribute(k, attrs[k]);
}
(kids || []).forEach(c => e.appendChild(typeof c === "string" ? document.createTextNode(c) : c));
return e;
}
function field(label, input) { return el("div", { class: "fld" }, [el("label", {}, [label]), input]); }
function txt(val, oninput) { const i = el("input", { type: "text", value: val == null ? "" : val }); i.oninput = () => oninput(i.value); return i; }
function area(val, oninput) { const t = el("textarea", {}, [val || ""]); t.oninput = () => oninput(t.value); return t; }
function num(val, oninput) { const i = el("input", { type: "number", value: val == null ? "" : val }); i.oninput = () => oninput(i.value === "" ? null : Number(i.value)); return i; }
function sel(val, opts, onchange) {
const s = el("select");
opts.forEach(o => { const op = el("option", { value: o.value }, [o.label]); if (String(o.value) === String(val)) op.selected = true; s.appendChild(op); });
s.onchange = () => onchange(s.value);
return s;
}
// ---- 选项来源 ----
function targets(ir) {
const ids = (ir.nodes || []).map(n => n.id).concat((ir.endings || []).map(e => e.id));
return [{ value: "", label: "(无 / 留空)" }].concat(ids.map(i => ({ value: i, label: i })));
}
function slots(ir, withPlayer) {
const list = (ir.roles || []).map(r => ({ value: r.slot, label: r.slot + " " + r.name }));
return (withPlayer ? [] : []).concat(list);
}
function pointOpts(ir, ctx, cur) {
const set = new Set((ctx.pointNames || []).concat((ir.roles || []).map(r => r.slot)));
if (cur) set.add(cur);
return [{ value: "", label: "(无)" }].concat([...set].map(p => ({ value: p, label: p })));
}
function grantKinds(ctx) { return Object.keys((ctx.dict || {}).grants || {}); }
function condKinds(ctx) { return Object.keys((ctx.dict || {}).conditions || {}); }
// ---- grant 编辑器 ----
function grantsEditor(ir, ctx, grants, onMut) {
grants = grants || [];
const box = el("div", { class: "subbox" });
box.appendChild(el("div", { class: "hd" }, [
el("span", {}, ["奖励 grants"]),
el("button", { class: "mini", onclick: () => { grants.push({ kind: grantKinds(ctx)[0], value: 0 }); onMut(grants); } }, [""]),
]));
grants.forEach((g, i) => {
const row = el("div", { class: "fld" });
const head = el("div", { class: "row2" }, [
sel(g.kind, grantKinds(ctx).map(k => ({ value: k, label: k })), v => { grants[i] = { kind: v, value: 0 }; onMut(grants); }),
el("button", { class: "mini", onclick: () => { grants.splice(i, 1); onMut(grants); } }, ["删"]),
]);
row.appendChild(head);
const form = ((ctx.dict.grants[g.kind]) || {}).form;
const fields = el("div", { class: "row2" });
if (form === "money") fields.appendChild(field("数值(±)", num(g.value, v => { g.value = v; onMut(grants, true); })));
else if (form === "item") {
fields.appendChild(field("道具ID", txt(g.item, v => { g.item = v; onMut(grants, true); })));
fields.appendChild(field("数量", num(g.value, v => { g.value = v; onMut(grants, true); })));
} else if (form === "friend") {
fields.appendChild(field("对象", sel(g.target, slots(ir), v => { g.target = v; onMut(grants, true); })));
fields.appendChild(field("数值", num(g.value, v => { g.value = v; onMut(grants, true); })));
} else if (form === "join") {
fields.appendChild(field("门派(对象)", sel(g.target, slots(ir), v => { g.target = v; onMut(grants, true); })));
}
if (fields.children.length) row.appendChild(fields);
box.appendChild(row);
});
if (!grants.length) box.appendChild(el("div", { class: "empty" }, ["无"]));
return box;
}
// ---- condition 编辑器 ----
function condEditor(ir, ctx, cond, setCond) {
const box = el("div", { class: "subbox" });
box.appendChild(el("div", { class: "hd" }, [
el("span", {}, ["条件 condition"]),
el("button", { class: "mini", onclick: () => setCond(cond ? null : { kind: condKinds(ctx)[0], op: ">=", value: 0 }) }, [cond ? "移除" : ""]),
]));
if (cond) {
const ops = Object.keys((ctx.dict.conditions[cond.kind] || {}).ops || { ">=": 1 });
box.appendChild(el("div", { class: "row2" }, [
sel(cond.kind, condKinds(ctx).map(k => ({ value: k, label: k })), v => { cond.kind = v; setCond(cond, true); }),
sel(cond.op, ops.map(o => ({ value: o, label: o })), v => { cond.op = v; setCond(cond, true); }),
num(cond.value, v => { cond.value = v; setCond(cond, true); }),
]));
}
return box;
}
// ========== 元信息 + 角色表 ==========
window.FormUI = window.FormUI || {};
FormUI.renderMeta = function (ir, ctx) {
const host = document.getElementById("meta-edit");
host.innerHTML = "";
const psHint = (ctx.pointNames && ctx.pointNames.length)
? ("点位集: " + ctx.pointNames.length + " 点") : "(无点位集,坐标校验降级警告)";
host.appendChild(field("标题", txt(ir.title, v => { ir.title = v; ctx.onChange(false); })));
host.appendChild(el("div", { class: "row2" }, [
field("主题", txt(ir.theme, v => { ir.theme = v; ctx.onChange(false); })),
field("规模", txt(ir.scale, v => { ir.scale = v; ctx.onChange(false); })),
]));
const stage = ir.stage || (ir.stage = {});
host.appendChild(field("舞台 / " + psHint, txt(stage.type, v => { stage.type = v; ctx.onChange(false); })));
// 角色表
const box = el("div", { class: "subbox" });
box.appendChild(el("div", { class: "hd" }, [
el("span", {}, ["角色表 roles"]),
el("button", { class: "mini", onclick: () => {
const n = (ir.roles || []).length;
(ir.roles = ir.roles || []).push({ slot: "NP" + n, name: "新角色", archetype: "", camp: 0 });
ctx.onChange(true);
} }, ["+角色"]),
]));
(ir.roles || []).forEach((r, i) => {
const row = el("div", { class: "fld" }, [
el("div", { class: "row2" }, [
txt(r.slot, v => { r.slot = v; ctx.onChange(false); }),
txt(r.name, v => { r.name = v; ctx.onChange(false); }),
]),
el("div", { class: "row2" }, [
txt(r.archetype, v => { r.archetype = v; ctx.onChange(false); }),
sel(r.camp, [0, 1, 2].map(c => ({ value: c, label: "阵营" + c })), v => { r.camp = Number(v); ctx.onChange(false); }),
el("button", { class: "mini", onclick: () => { ir.roles.splice(i, 1); ctx.onChange(true); } }, ["删"]),
]),
]);
box.appendChild(row);
});
host.appendChild(box);
};
// ========== 节点表单 ==========
FormUI.renderNode = function (ir, id, ctx) {
const host = document.getElementById("node-edit");
host.innerHTML = "";
if (!id) { host.appendChild(el("div", { class: "empty" }, ["点击中间任意节点进行编辑"])); return; }
const isEnding = (ir.endings || []).some(e => e.id === id);
const node = isEnding ? (ir.endings.find(e => e.id === id))
: (ir.nodes.find(n => n.id === id));
if (!node) { host.appendChild(el("div", { class: "empty" }, ["节点已删除"])); return; }
const head = el("div", { class: "fld inline" }, [
el("span", { class: "node-id" }, ["#" + id]),
]);
host.appendChild(head);
if (isEnding) { renderEnding(host, ir, node, ctx); return; }
// kind 切换
host.appendChild(field("类型 kind", sel(node.kind, NODE_KINDS.map(k => ({ value: k, label: k })), v => {
node.kind = v; ctx.onChange(true);
})));
const mut = (s) => ctx.onChange(!!s ? false : true); // mut(true)=值改, 默认结构改
const tgt = targets(ir);
if (node.kind === "narration") {
host.appendChild(field("说话者(可选)", sel(node.speaker || "P1", [{ value: "P1", label: "P1 玩家" }].concat(slots(ir)), v => { node.speaker = v; mut(1); })));
host.appendChild(field("文本", area(node.text, v => { node.text = v; mut(1); })));
host.appendChild(field("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(false); })));
} else if (node.kind === "dialogue") {
host.appendChild(field("说话者 speaker", sel(node.speaker, [{ value: "P1", label: "P1 玩家" }].concat(slots(ir)), v => { node.speaker = v; mut(1); })));
host.appendChild(field("镜头 camera(点位,可选)", sel(node.camera, pointOpts(ir, ctx, node.camera), v => { node.camera = v || undefined; mut(1); })));
host.appendChild(field("文本", area(node.text, v => { node.text = v; mut(1); })));
host.appendChild(field("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(false); })));
} else if (node.kind === "choice" || node.kind === "choice_once") {
const box = el("div", { class: "subbox" });
box.appendChild(el("div", { class: "hd" }, [
el("span", {}, ["选项 options"]),
el("button", { class: "mini", onclick: () => { (node.options = node.options || []).push({ text: "新选项", goto: "" }); ctx.onChange(true); } }, ["+选项"]),
]));
(node.options || []).forEach((o, i) => {
const ob = el("div", { class: "subbox" });
ob.appendChild(el("div", { class: "hd" }, [
el("span", {}, ["选项 " + (i + 1)]),
el("button", { class: "mini", onclick: () => { node.options.splice(i, 1); ctx.onChange(true); } }, ["删"]),
]));
ob.appendChild(field("文本", txt(o.text, v => { o.text = v; ctx.onChange(false); })));
ob.appendChild(field("跳转 goto", sel(o.goto, tgt, v => { o.goto = v; ctx.onChange(false); })));
ob.appendChild(condEditor(ir, ctx, o.condition, (c, valOnly) => { if (c) o.condition = c; else delete o.condition; ctx.onChange(!valOnly); }));
ob.appendChild(grantsEditor(ir, ctx, (o.reward || {}).grants, (gr, valOnly) => { o.reward = { grants: gr }; ctx.onChange(!valOnly); }));
// skip
const skBox = el("div", { class: "subbox" });
skBox.appendChild(el("div", { class: "hd" }, [
el("span", {}, ["押注跳过 skip"]),
el("button", { class: "mini", onclick: () => { if (o.skip) delete o.skip; else o.skip = { node: "", reward: { grants: [] } }; ctx.onChange(true); } }, [o.skip ? "移除" : ""]),
]));
if (o.skip) {
skBox.appendChild(field("结算目标节点", sel(o.skip.node, tgt, v => { o.skip.node = v; ctx.onChange(false); })));
skBox.appendChild(grantsEditor(ir, ctx, (o.skip.reward || {}).grants, (gr, valOnly) => { o.skip.reward = { grants: gr }; ctx.onChange(!valOnly); }));
}
ob.appendChild(skBox);
box.appendChild(ob);
});
host.appendChild(box);
} else if (node.kind === "random") {
const box = el("div", { class: "subbox" });
box.appendChild(el("div", { class: "hd" }, [
el("span", {}, ["随机分支 branches"]),
el("button", { class: "mini", onclick: () => { (node.branches = node.branches || []).push({ weight: 1, goto: "" }); ctx.onChange(true); } }, ["+分支"]),
]));
(node.branches || []).forEach((b, i) => {
box.appendChild(el("div", { class: "row2" }, [
field("权重", num(b.weight, v => { b.weight = v; ctx.onChange(false); })),
field("跳转", sel(b.goto, tgt, v => { b.goto = v; ctx.onChange(false); })),
el("button", { class: "mini", onclick: () => { node.branches.splice(i, 1); ctx.onChange(true); } }, ["删"]),
]));
});
host.appendChild(box);
} else if (node.kind === "fight") {
host.appendChild(field("战斗类型", sel(node.fight_type, [{ value: 1, label: "1 击倒" }, { value: 2, label: "2 死斗" }], v => { node.fight_type = Number(v); ctx.onChange(false); })));
host.appendChild(campPicker("我方 camp1", ir, node, "camp1", ctx, true));
host.appendChild(campPicker("敌方 camp2", ir, node, "camp2", ctx, false));
host.appendChild(el("div", { class: "row2" }, [
field("胜 → win", sel(node.win, tgt, v => { node.win = v; ctx.onChange(false); })),
field("败 → lose", sel(node.lose, tgt, v => { node.lose = v; ctx.onChange(false); })),
]));
} else if (node.kind === "move") {
host.appendChild(field("移动者 actor", sel(node.actor, [{ value: "P1", label: "P1 玩家" }].concat(slots(ir)), v => { node.actor = v; mut(1); })));
host.appendChild(field("目标点 to", sel(node.to, pointOpts(ir, ctx, node.to), v => { node.to = v; ctx.onChange(false); })));
host.appendChild(field("模式 mode", sel(node.mode || "walk", [{ value: "walk", label: "walk 行走" }, { value: "teleport", label: "teleport 瞬移" }, { value: "remove", label: "remove 移除" }], v => { node.mode = v; ctx.onChange(true); })));
if ((node.mode || "walk") === "walk") {
host.appendChild(el("div", { class: "row2" }, [
field("速度 speed", num(node.speed == null ? 6 : node.speed, v => { node.speed = v; ctx.onChange(false); })),
field("动作 ani", txt(node.ani, v => { node.ani = v; ctx.onChange(false); })),
]));
}
host.appendChild(field("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(false); })));
} else if (node.kind === "anim") {
host.appendChild(field("角色 actor", sel(node.actor, [{ value: "P1", label: "P1 玩家" }].concat(slots(ir)), v => { node.actor = v; mut(1); })));
host.appendChild(el("div", { class: "row2" }, [
field("动画 ani", txt(node.ani, v => { node.ani = v; ctx.onChange(false); })),
field("朝向 angle(可选)", num(node.angle, v => { if (v == null) delete node.angle; else node.angle = v; ctx.onChange(false); })),
]));
host.appendChild(field("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(false); })));
} else if (node.kind === "reward") {
host.appendChild(grantsEditor(ir, ctx, node.grants, (gr, valOnly) => { node.grants = gr; ctx.onChange(!valOnly); }));
host.appendChild(field("下一步 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(false); })));
} else if (node.kind === "out_ref") {
const seqs = (ir.sequences || []).map(s => ({ value: s.id, label: s.id }));
host.appendChild(field("引用子序列 ref", sel(node.ref, [{ value: "", label: "(无)" }].concat(seqs), v => { node.ref = v; ctx.onChange(false); })));
host.appendChild(field("出口接回 next", sel(node.next, tgt, v => { node.next = v; ctx.onChange(false); })));
}
// 删除节点
host.appendChild(el("div", { class: "fld" }, [
el("button", { class: "mini", onclick: () => {
if (!confirm("删除节点 " + id + "?指向它的跳转需手动修复(校验会提示)。")) return;
ir.nodes = ir.nodes.filter(n => n.id !== id);
ctx.selectNode(null); ctx.onChange(true);
} }, ["删除此节点"]),
]));
};
function renderEnding(host, ir, e, ctx) {
host.appendChild(field("结局摘要 summary", txt(e.summary, v => { e.summary = v; ctx.onChange(false); })));
host.appendChild(field("结果 result", sel(e.result || "success", [
{ value: "success", label: "success 成功" }, { value: "fail", label: "fail 失败" }, { value: "end", label: "end 中性" }], v => { e.result = v; ctx.onChange(false); })));
host.appendChild(grantsEditor(ir, ctx, e.grants, (gr, valOnly) => { e.grants = gr; ctx.onChange(!valOnly); }));
}
function campPicker(label, ir, node, key, ctx, withPlayer) {
const cur = new Set(node[key] || []);
const wrap = el("div", { class: "fld" }, [el("label", {}, [label])]);
const pick = el("div", { class: "tag-pick" });
const all = (withPlayer ? [{ slot: "P1", name: "玩家" }] : []).concat(ir.roles || []);
all.forEach(r => {
const cb = el("input", { type: "checkbox" }); cb.checked = cur.has(r.slot);
cb.onchange = () => {
const arr = new Set(node[key] || []);
if (cb.checked) arr.add(r.slot); else arr.delete(r.slot);
node[key] = [...arr]; ctx.onChange(false);
};
pick.appendChild(el("label", {}, [cb, r.slot]));
});
wrap.appendChild(pick);
return wrap;
}
// 新建节点:分配唯一 id
FormUI.newNode = function (ir) {
let i = (ir.nodes || []).length + 1, id;
do { id = "n" + i++; } while ((ir.nodes || []).some(n => n.id === id) || (ir.endings || []).some(e => e.id === id));
(ir.nodes = ir.nodes || []).push({ id, kind: "dialogue", speaker: "P1", text: "新对话", next: "" });
return id;
};
// 在指定节点后追加一个新节点,并按其类型自动接线;返回新节点 id结局/不存在返回 null
// 线性类型(next)插入到当前与原后继之间choice/random追加一个选项/分支fight填空缺的胜/败出口。
FormUI.addSuccessor = function (ir, id) {
const node = (ir.nodes || []).find(n => n.id === id);
if (!node) return null; // 结局节点等无后继
const nid = FormUI.newNode(ir);
const kind = node.kind;
let linked = true;
if (kind === "choice" || kind === "choice_once") {
(node.options = node.options || []).push({ text: "新选项", goto: nid });
} else if (kind === "random") {
(node.branches = node.branches || []).push({ weight: 1, goto: nid });
} else if (kind === "fight") {
if (!node.win) node.win = nid;
else if (!node.lose) node.lose = nid;
else linked = false; // 胜败出口都已占用:新节点孤立
} else {
// 线性 next 类型:插入到当前与原后继之间,不破坏原有链路
const fresh = ir.nodes.find(n => n.id === nid);
fresh.next = node.next || "";
node.next = nid;
}
return { id: nid, linked: linked };
};
})();

115
web/static/index.html Normal file
View File

@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Story 协作编辑器 · M5</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- 登录遮罩 -->
<div id="login" class="overlay">
<div class="login-box">
<h2>剧情事件协作编辑器</h2>
<p class="hint">输入共享口令进入</p>
<input id="login-pass" type="password" placeholder="共享口令" autocomplete="off">
<input id="login-name" type="text" placeholder="你的昵称(用于记录改动者)">
<button id="login-btn">进入</button>
<div id="login-err" class="err"></div>
</div>
</div>
<header>
<h1>剧情事件协作编辑器 <span class="ver">M5</span></h1>
<div class="toolbar">
<button id="btn-save" class="primary" disabled>保存</button>
<button id="btn-validate" disabled>校验</button>
<button id="btn-playtest" disabled>试走</button>
<span class="sep"></span>
<button id="btn-confirm" disabled>确认</button>
<button id="btn-discard" disabled>丢弃</button>
<span class="sep"></span>
<button id="btn-import">导入 IR</button>
<button id="btn-export">导出 confirmed</button>
<span class="who" id="who"></span>
</div>
</header>
<div id="wrap">
<!-- 左:事件列表 -->
<aside id="list-pane">
<div class="filters">
<select id="filter-status">
<option value="all">全部</option>
<option value="pending">待审</option>
<option value="confirmed">已确认</option>
<option value="discarded">已丢弃</option>
</select>
<input id="search" type="text" placeholder="搜索标题/group">
</div>
<div id="event-list"></div>
</aside>
<!-- 中:分支树 -->
<main id="graph-pane">
<div id="graph"><svg id="svg"></svg><div id="layers"></div></div>
<div id="graph-empty" class="empty-center">从左侧选择一个事件</div>
</main>
<!-- 右:表单编辑 -->
<section id="edit-pane">
<div id="meta-edit"></div>
<div class="sec-title">节点编辑 <button id="btn-addnode" class="mini" disabled>+节点</button></div>
<div id="node-edit"><div class="empty">点击中间任意节点进行编辑</div></div>
</section>
</div>
<!-- 校验结果遮罩 -->
<div id="validate-modal" class="overlay hidden">
<div class="modal">
<h3>校验结果</h3>
<div id="validate-body"></div>
<button class="modal-close">关闭</button>
</div>
</div>
<!-- 导入遮罩 -->
<div id="import-modal" class="overlay hidden">
<div class="modal">
<h3>导入 IR选择一个或多个 JSON 文件)</h3>
<div id="import-drop" class="drop">
<p>点击选择文件,或把 .json 文件拖到这里</p>
<p class="hint">每个文件可为单个事件对象,或事件数组;支持多选</p>
<input id="import-file" type="file" accept=".json,application/json" multiple hidden>
</div>
<div id="import-files" class="filelist"></div>
<details class="import-paste">
<summary>或粘贴 JSON 文本</summary>
<textarea id="import-text" placeholder='{"id":"QY_XXX", ...} 或 [ {...}, {...} ]'></textarea>
</details>
<div class="modal-actions">
<button id="import-do" class="primary">导入</button>
<button class="modal-close">取消</button>
</div>
<div id="import-result" class="err"></div>
</div>
</div>
<!-- 试走遮罩 -->
<div id="playtest-modal" class="overlay hidden">
<div class="modal wide">
<h3>剧本试走 <button class="modal-close" style="float:right">关闭</button></h3>
<div id="pt-layout">
<div id="pt-flow"></div>
<div id="pt-ledger"></div>
</div>
</div>
</div>
<script src="tree.js"></script>
<script src="form.js"></script>
<script src="playtest.js"></script>
<script src="app.js"></script>
</body>
</html>

171
web/static/playtest.js Normal file
View 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
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 });
}
};
})();

156
web/static/style.css Normal file
View File

@ -0,0 +1,156 @@
* { box-sizing: border-box; }
body { margin:0; font-family:"Microsoft YaHei","PingFang SC",sans-serif;
background:#161310; color:#e8e0d4; height:100vh; overflow:hidden;
display:flex; flex-direction:column; }
/* ---- header / toolbar ---- */
header { padding:10px 18px; background:#1f1a15; border-bottom:1px solid #3a322a;
display:flex; align-items:center; gap:18px; flex:none; }
header h1 { margin:0; font-size:18px; color:#e6c878; white-space:nowrap; }
header h1 .ver { font-size:12px; color:#9a8f7e; }
.toolbar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.toolbar .sep { width:1px; height:20px; background:#3a322a; margin:0 4px; }
.toolbar .who { margin-left:10px; font-size:12px; color:#9a8f7e; }
button { background:#3a3024; color:#e6c878; border:1px solid #5a4a32;
padding:6px 13px; border-radius:5px; cursor:pointer; font-size:13px; }
button:hover:not(:disabled) { background:#4a3d2c; }
button:disabled { opacity:.4; cursor:not-allowed; }
button.primary { background:#5a4a26; border-color:#8a7038; color:#f3dca0; }
button.mini { padding:2px 8px; font-size:12px; }
/* ---- layout ---- */
#wrap { display:flex; flex:1; min-height:0; }
#list-pane { width:250px; background:#19150f; border-right:1px solid #3a322a;
display:flex; flex-direction:column; flex:none; }
.filters { padding:10px; display:flex; gap:6px; border-bottom:1px solid #3a322a; }
.filters select, .filters input, #login input, #import-text {
background:#241f18; color:#e8e0d4; border:1px solid #4a4030; border-radius:4px;
padding:6px 8px; font-size:13px; }
.filters select { flex:none; }
.filters input { flex:1; min-width:0; }
#event-list { overflow:auto; flex:1; }
.ev { padding:9px 12px; border-bottom:1px solid #241f18; cursor:pointer; }
.ev:hover { background:#221d16; }
.ev.sel { background:#2a2316; border-left:3px solid #e6c878; padding-left:9px; }
.ev .t { font-size:13.5px; color:#ddd3c2; }
.ev .g { font-size:11px; color:#7a7264; margin-top:2px; }
.ev .badge { float:right; font-size:11px; padding:1px 7px; border-radius:9px; }
.b-pending { background:#3a3320; color:#d8c060; }
.b-confirmed{ background:#1f3a24; color:#7ad88a; }
.b-discarded{ background:#3a2020; color:#d88; }
#graph-pane { flex:1; position:relative; min-width:0; }
#graph { position:absolute; inset:0; overflow:auto; padding:30px; }
#svg { position:absolute; top:0; left:0; pointer-events:none; }
#layers { position:relative; z-index:2; }
.empty-center { position:absolute; inset:0; display:flex; align-items:center;
justify-content:center; color:#6a6256; font-size:15px; }
#edit-pane { width:370px; background:#1c1813; border-left:1px solid #3a322a;
overflow:auto; flex:none; }
.sec-title { padding:10px 14px; font-size:13px; color:#9a8f7e;
border-bottom:1px solid #3a322a; background:#19150f; letter-spacing:1px;
display:flex; justify-content:space-between; align-items:center; }
#meta-edit, #node-edit { padding:12px 14px; }
.empty { color:#6a6256; font-size:13px; padding:8px 0; }
/* ---- nodes (tree) ---- */
.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); position:absolute; }
.node:hover { border-color:#e6c878; transform:translateY(-2px); }
.node.sel { border-color:#e6c878; box-shadow:0 0 0 2px rgba(230,200,120,.4); z-index:20; }
.node-acts { position:absolute; display:flex; gap:4px; z-index:30; transform:translateX(-100%); }
.nact { border:none; border-radius:5px; font-size:11px; line-height:1; padding:4px 6px;
cursor:pointer; color:#161310; font-weight:bold; box-shadow:0 1px 3px rgba(0,0,0,.5); }
.nact.add { background:#e6c878; }
.nact.add:hover { background:#f0d68a; }
.nact.del { background:#d87878; color:#fff; }
.nact.del:hover { background:#e89090; }
.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; }
.node.kind-ending .k { color:#f2c463; }
.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-out_ref { border-style:dashed; border-color:#7a7ad8; background:#1d1d2a; }
.node.kind-out_ref .k { color:#9e9ef0; }
.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; }
/* ---- form ---- */
.fld { margin:9px 0; }
.fld > label { display:block; font-size:12px; color:#9a8f7e; margin-bottom:3px; }
.fld input, .fld select, .fld textarea {
width:100%; background:#241f18; color:#e8e0d4; border:1px solid #4a4030;
border-radius:4px; padding:6px 8px; font-size:13px; }
.fld textarea { min-height:60px; resize:vertical; font-family:inherit; }
.fld.inline { display:flex; gap:6px; align-items:center; }
.fld.inline > label { margin:0; flex:none; }
.subbox { border:1px solid #3a322a; border-radius:6px; padding:8px; margin:8px 0;
background:#19150f; }
.subbox .hd { display:flex; justify-content:space-between; align-items:center;
font-size:12px; color:#b89a5a; margin-bottom:6px; }
.row2 { display:flex; gap:6px; }
.row2 > * { flex:1; }
.tag-pick { display:flex; flex-wrap:wrap; gap:5px; }
.tag-pick label { font-size:12px; background:#241f18; border:1px solid #4a4030;
padding:3px 8px; border-radius:11px; cursor:pointer; }
.tag-pick input { margin-right:4px; }
.node-id { color:#7a7264; font-size:11px; }
/* ---- overlays / modals ---- */
.overlay { position:fixed; inset:0; background:rgba(0,0,0,.72); z-index:100;
display:flex; align-items:center; justify-content:center; }
.overlay.hidden { display:none; }
.login-box, .modal { background:#221d16; border:1px solid #4a4030; border-radius:10px;
padding:24px; width:380px; box-shadow:0 8px 30px rgba(0,0,0,.6); }
.modal { width:520px; max-height:82vh; overflow:auto; }
.modal.wide { width:760px; }
.login-box h2, .modal h3 { margin:0 0 12px; color:#e6c878; }
.login-box .hint { color:#9a8f7e; font-size:13px; margin:0 0 14px; }
.login-box input { width:100%; margin-bottom:10px; }
.login-box button { width:100%; }
.err { color:#e08a7a; font-size:13px; margin-top:8px; min-height:18px; }
.modal textarea { width:100%; min-height:160px; background:#19150f; color:#e8e0d4;
border:1px solid #4a4030; border-radius:5px; padding:8px;
font-family:monospace; font-size:12px; }
.modal-actions { margin-top:10px; display:flex; gap:8px; }
.drop { border:1.5px dashed #4a4030; border-radius:8px; padding:22px 12px; text-align:center;
cursor:pointer; color:#bdb29c; transition:border-color .15s, background .15s; }
.drop:hover { border-color:#6a5d40; background:#1c1810; }
.drop.over { border-color:#e6c878; background:#241d10; color:#e6c878; }
.drop p { margin:0; }
.drop .hint { margin-top:6px; font-size:12px; color:#8a8068; }
.filelist { margin-top:8px; }
.fileitem { display:flex; align-items:center; gap:8px; padding:5px 8px; margin-top:5px;
background:#19150f; border:1px solid #3a3326; border-radius:5px; font-size:13px; }
.fileitem .fn { flex:1; color:#e8e0d4; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.fileitem .fsz { color:#8a8068; font-size:12px; }
.fileitem .rm { background:none; border:none; color:#e08a7a; cursor:pointer; font-size:13px; padding:0 4px; }
.import-paste { margin-top:10px; }
.import-paste summary { cursor:pointer; color:#bdb29c; font-size:13px; user-select:none; }
.import-paste textarea { margin-top:8px; min-height:110px; }
.v-err { color:#e08a7a; margin:4px 0; font-size:13px; }
.v-warn { color:#d8c060; margin:4px 0; font-size:13px; }
.v-ok { color:#7ad88a; font-size:14px; }
/* ---- playtest ---- */
#pt-layout { display:flex; gap:14px; }
#pt-flow { flex:1; max-height:62vh; overflow:auto; }
#pt-ledger { width:230px; flex:none; background:#19150f; border:1px solid #3a322a;
border-radius:6px; padding:10px; font-size:13px; align-self:flex-start; }
#pt-ledger h4 { margin:0 0 8px; color:#e6c878; font-size:13px; }
#pt-ledger .lg { margin:4px 0; }
.pt-step { background:#241f18; border-left:3px solid #4a4030; padding:7px 10px;
margin:7px 0; border-radius:4px; font-size:13px; }
.pt-step.spk { color:#cdddf0; }
.pt-step.sys { color:#9a8f7e; font-style:italic; }
.pt-step.end { border-left-color:#e0a850; color:#eccf95; }
.pt-choices button { display:block; width:100%; text-align:left; margin:5px 0; }
.pt-choices button.locked { opacity:.55; }
.pt-q { color:#b89a5a; font-size:12px; margin:8px 0 4px; }

157
web/static/tree.js Normal file
View File

@ -0,0 +1,157 @@
// 分支树渲染(从 ir_to_html.py 的 TEMPLATE 抽出,加 onSelect 回调供编辑)。
// 用法: renderTree(ir, { onSelect:id=>{}, selected:'n1' })
(function () {
const ROW = 150, SP = 232, NW = 188;
const COLOR = { next:"#6a6256", option:"#7aa0d8", random:"#a07ad8",
win:"#7ac88a", lose:"#d87878", ref:"#9e9ef0" };
function roleNames(ir) {
const m = {};
(ir.roles || []).forEach(r => m[r.slot] = r.name + (r.archetype ? ("" + r.archetype + "") : ""));
return m;
}
function nameOf(ir, names, s) { return (s === "P1" ? "玩家" : "") || names[s] || s; }
function grantStr(ir, names, gr) {
if (gr.kind === "银两") return "银两 " + (gr.value > 0 ? "+" : "") + gr.value;
if (gr.kind === "道具") return "道具 " + gr.item + " ×" + gr.value;
if (gr.kind === "友好度") return nameOf(ir, names, gr.target) + " 友好度+" + gr.value;
if (gr.kind === "入门") return nameOf(ir, names, gr.target) + " 加入门派";
return JSON.stringify(gr);
}
function summary(ir, names, n) {
if (n.kind === "narration") return ["旁白", (n.text || "").slice(0, 28)];
if (n.kind === "dialogue") return ["对话 · " + nameOf(ir, names, 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(s => nameOf(ir, names, s)).join("、")];
if (n.kind === "move") return ["走位 · " + nameOf(ir, names, n.actor), "→ " + (n.to || "")];
if (n.kind === "anim") return ["动画 · " + nameOf(ir, names, n.actor), n.ani || ""];
if (n.kind === "reward") return ["奖励结算", ""];
if (n.kind === "out_ref") return ["引用子序列", "→ " + (n.ref || "")];
if (n.kind === "ending") return ["★ 结局", n.summary || ""];
return [n.kind, ""];
}
window.renderTree = function (ir, opts) {
opts = opts || {};
const names = roleNames(ir);
const layersDiv = document.getElementById("layers");
const svg = document.getElementById("svg");
layersDiv.innerHTML = ""; svg.innerHTML = "";
// 节点 (含结局)
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 = [];
const add = (u, v, type, label) => { if (v && nodes[v]) edges.push({ u, v, type, label: label || "" }); };
(ir.nodes || []).forEach(n => {
if (n.next) add(n.id, n.next, n.kind === "out_ref" ? "ref" : "next");
(n.options || []).forEach(o => add(n.id, o.goto, "option", o.text));
(n.branches || []).forEach(b => add(n.id, b.goto, "random", "权重" + (b.weight != null ? b.weight : "")));
if (n.kind === "fight") { add(n.id, n.win, "win", "胜"); add(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 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 (!id || vis.has(id)) return; vis.add(id);
if (childMap[id].length === 0) { xpos[id] = nextX; nextX += SP; return; }
childMap[id].forEach(assignX);
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(ir, names, n);
const d = document.createElement("div");
d.className = "node kind-" + n.kind + (id === opts.selected ? " sel" : "");
d.id = "node-" + id;
let inner = '<div class="k">' + k + '</div><div class="t">' + esc(t || id) + '</div>';
if (n.kind === "ending") {
const g = (n.grants && n.grants.length) ? n.grants.map(gr => grantStr(ir, names, gr)).join("") : "无奖励";
inner += '<div class="rw">' + esc(g) + '</div>';
}
d.innerHTML = inner;
d.style.left = xpos[id] + "px"; d.style.top = (layer[id] * ROW) + "px";
d.onclick = () => opts.onSelect && opts.onSelect(id);
layersDiv.appendChild(d);
// 选中节点:浮出快捷按钮。作为画布独立元素按坐标定位,避免被 choice 等节点的 clip-path 裁切。
if (id === opts.selected) {
const bar = document.createElement("div");
bar.className = "node-acts";
bar.style.left = (xpos[id] + NW + 2) + "px"; // 右缘对齐节点右上角
bar.style.top = (layer[id] * ROW - 13) + "px";
const mk = (cls, label, title, fn) => {
const b = document.createElement("button");
b.className = "nact " + cls; b.textContent = label; b.title = title;
b.onclick = e => { e.stopPropagation(); fn(id); };
return b;
};
if (opts.onAddNext && !n._end) bar.appendChild(mk("add", "+后继", "新建一个节点并自动接到这里", opts.onAddNext));
if (opts.onDelete) bar.appendChild(mk("del", "✕", n._end ? "删除此结局" : "删除此节点", opts.onDelete));
layersDiv.appendChild(bar);
}
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";
drawEdges(edges);
};
function drawEdges(edges) {
const g = document.getElementById("graph"), svg = document.getElementById("svg");
const 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) {
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">' + esc(e.label.slice(0, 12)) + '</text>';
}
});
svg.innerHTML = h;
}
function esc(s) { return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
window._treeGrantStr = grantStr;
window._treeNameOf = nameOf;
window._treeRoleNames = roleNames;
})();