commit f5669dc01dd9aaa40078146dbba0040877ce8406 Author: bia Date: Mon Jun 8 16:50:27 2026 +0800 init: 剧情事件协作 Web 编辑器独立仓(从 SGame/tools/event_authoring 拆出) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..60c1865 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +web/data/ +web/story_events.db diff --git a/IR_SCHEMA.md b/IR_SCHEMA.md new file mode 100644 index 0000000..1f60c02 --- /dev/null +++ b/IR_SCHEMA.md @@ -0,0 +1,157 @@ +# Story IR Schema(v0.2,M4 扩展) + +> 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`(属游戏侧改造,不在编译器范围)。 diff --git a/ir_compile.py b/ir_compile.py new file mode 100644 index 0000000..d647475 --- /dev/null +++ b/ir_compile.py @@ -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.json(M1 点位集),运行时直接读;编译器只校验点位名存在。 +""" +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() diff --git a/ir_core/__init__.py b/ir_core/__init__.py new file mode 100644 index 0000000..37d7810 --- /dev/null +++ b/ir_core/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +"""ir_core:Story IR 校验 + 编译 + 词典的共享内核。 + +编译器 CLI(ir_compile.py)与(M5)Web 后端共用此包,保证试走/校验/导出口径一致。 +""" +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", +] diff --git a/ir_core/compile.py b/ir_core/compile.py new file mode 100644 index 0000000..1bdb681 --- /dev/null +++ b/ir_core/compile.py @@ -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(节点 %s),M4 暂不支持" % (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 diff --git a/ir_core/dictionary.py b/ir_core/dictionary.py new file mode 100644 index 0000000..b47ee5e --- /dev/null +++ b/ir_core/dictionary.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +"""语义词典:condition/grant kind -> 真实 ID/形态。外置 ir_dictionary.json, +编译器与(M5)Web 后端共读。新增查表类 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)) diff --git a/ir_core/validate.py b/ir_core/validate.py new file mode 100644 index 0000000..9f75c65 --- /dev/null +++ b/ir_core/validate.py @@ -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_ref,M4 暂不支持" + % (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)) diff --git a/ir_dictionary.json b/ir_dictionary.json new file mode 100644 index 0000000..adf82f2 --- /dev/null +++ b/ir_dictionary.json @@ -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 } + } +} diff --git a/ir_to_html.py b/ir_to_html.py new file mode 100644 index 0000000..b5082b5 --- /dev/null +++ b/ir_to_html.py @@ -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""" + + + +__TITLE__ · Story 面板 + + + +
+

+
+
+
+
+
+
+
节点详情
+
点击左侧任意节点查看台词 / 角色 / 奖励
+
奖励总览
+
+
+
+
+ + +""" + + +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() diff --git a/samples/bishi_yazhu.events.json b/samples/bishi_yazhu.events.json new file mode 100644 index 0000000..3f5769d --- /dev/null +++ b/samples/bishi_yazhu.events.json @@ -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 + } +] \ No newline at end of file diff --git a/samples/bishi_yazhu.i18n.tsv b/samples/bishi_yazhu.i18n.tsv new file mode 100644 index 0000000..8753f25 --- /dev/null +++ b/samples/bishi_yazhu.i18n.tsv @@ -0,0 +1,11 @@ +# 简体中文(key,勿改) 韩文(待译;繁体无需填,SGameText 自动转换) +市集擂台前人声鼎沸,一名精壮武师立于台上,无人敢应战。 +押注了押注了!是亲自下场,还是押这位高手赢? +亲自下场会会他 +押这位高手赢(押注50两) +看个热闹就走 +好身手!满堂喝彩! +技压群雄 +败下阵来 +押对了,坐收彩头 +看罢热闹离场 diff --git a/samples/bishi_yazhu.ir.json b/samples/bishi_yazhu.ir.json new file mode 100644 index 0000000..7f1fdb5 --- /dev/null +++ b/samples/bishi_yazhu.ir.json @@ -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": [] } + ] +} diff --git a/samples/yuye_koumen.events.json b/samples/yuye_koumen.events.json new file mode 100644 index 0000000..b39ba71 --- /dev/null +++ b/samples/yuye_koumen.events.json @@ -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 + } +] \ No newline at end of file diff --git a/samples/yuye_koumen.html b/samples/yuye_koumen.html new file mode 100644 index 0000000..57a43aa --- /dev/null +++ b/samples/yuye_koumen.html @@ -0,0 +1,248 @@ + + + + +雨夜叩门 · Story 面板 + + + +
+

+
+
+
+
+
+
+
节点详情
+
点击左侧任意节点查看台词 / 角色 / 奖励
+
奖励总览
+
+
+
+
+ + + \ No newline at end of file diff --git a/samples/yuye_koumen.i18n.tsv b/samples/yuye_koumen.i18n.tsv new file mode 100644 index 0000000..2740722 --- /dev/null +++ b/samples/yuye_koumen.i18n.tsv @@ -0,0 +1,11 @@ +# 简体中文(key,勿改) 韩文(待译;繁体无需填,SGameText 自动转换) +暴雨倾盆,山门外的灯笼在风里摇晃。一阵急促的叩门声,盖过了雷声。 +在下途经贵派,身负旧伤,恳请借宿一晚,天明即走。 +(这位侠客腰间的铁牌……分明是近日劫掠商队的黑风寨样式。) +江湖救急,先收留他 +不动声色,擒下他交予掌门 +赠些盘缠,请他即刻离去 +结义同盟 +破财消灾 +擒贼献掌门 +技不如人 diff --git a/samples/yuye_koumen.ir.json b/samples/yuye_koumen.ir.json new file mode 100644 index 0000000..237410c --- /dev/null +++ b/samples/yuye_koumen.ir.json @@ -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" + } + ] +} diff --git a/samples/yuye_koumen.preview.png b/samples/yuye_koumen.preview.png new file mode 100644 index 0000000..3640b11 Binary files /dev/null and b/samples/yuye_koumen.preview.png differ diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..5b611e7 --- /dev/null +++ b/web/.dockerignore @@ -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 diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..24392ea --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,4 @@ +story_events.db +data/ +__pycache__/ +*.pyc diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..ea4e2ff --- /dev/null +++ b/web/Dockerfile @@ -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"] diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..d75c116 --- /dev/null +++ b/web/README.md @@ -0,0 +1,70 @@ +# Story 事件协作 Web 编辑器(M5) + +设计:`docs/plans/2026-06-06-story-event-pipeline-design.md`(§5.1/§6, D1–D4/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://: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。 diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..21e7309 --- /dev/null +++ b/web/app.py @@ -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://: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") diff --git a/web/db.py b/web/db.py new file mode 100644 index 0000000..8889753 --- /dev/null +++ b/web/db.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +"""M5 Web 编辑器的 SQLite 存储层。 + +单表 events:group(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] diff --git a/web/docker-compose.nas.yml b/web/docker-compose.nas.yml new file mode 100644 index 0000000..e3688d4 --- /dev/null +++ b/web/docker-compose.nas.yml @@ -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 -> 容器8787;frpc/反代再对外 + 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 diff --git a/web/docker-compose.yml b/web/docker-compose.yml new file mode 100644 index 0000000..14acdff --- /dev/null +++ b/web/docker-compose.yml @@ -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 diff --git a/web/requirements.txt b/web/requirements.txt new file mode 100644 index 0000000..4178b85 --- /dev/null +++ b/web/requirements.txt @@ -0,0 +1,2 @@ +fastapi>=0.110 +uvicorn[standard]>=0.27 diff --git a/web/static/app.js b/web/static/app.js new file mode 100644 index 0000000..a521f80 --- /dev/null +++ b/web/static/app.js @@ -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 = '' + b[1] + '' + + '
' + esc(e.title || e.group) + '
' + + '
' + esc(e.group) + ' · ' + esc(e.updated_by || "") + ' ' + esc((e.updated_at || "").slice(5, 16)) + '
'; + d.onclick = () => selectEvent(e.group); + host.appendChild(d); + }); + if (!host.children.length) host.innerHTML = '
无事件,点「导入 IR」
'; + } + $("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 = '
✓ 校验通过,无错误无警告
'; + errs.forEach(e => h += '
✗ ' + esc(e) + '
'); + warns.forEach(w => h += '
⚠ ' + esc(w) + '
'); + $("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 = '' + esc(f.name) + '' + (f.size > 1024 ? (f.size / 1024).toFixed(1) + " KB" : f.size + " B") + ''; + 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, "&").replace(//g, ">"); } + 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(); } + })(); +})(); diff --git a/web/static/form.js b/web/static/form.js new file mode 100644 index 0000000..e189f34 --- /dev/null +++ b/web/static/form.js @@ -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 }; + }; +})(); diff --git a/web/static/index.html b/web/static/index.html new file mode 100644 index 0000000..ffdc2b4 --- /dev/null +++ b/web/static/index.html @@ -0,0 +1,115 @@ + + + + + +Story 协作编辑器 · M5 + + + + + +
+ +
+ +
+

剧情事件协作编辑器 M5

+
+ + + + + + + + + + +
+
+ +
+ + + + +
+
+
从左侧选择一个事件
+
+ + +
+
+
节点编辑
+
点击中间任意节点进行编辑
+
+
+ + + + + + + + + + + + + + + + diff --git a/web/static/playtest.js b/web/static/playtest.js new file mode 100644 index 0000000..f0a4159 --- /dev/null +++ b/web/static/playtest.js @@ -0,0 +1,171 @@ +// 剧本试走:从首节点走,点选项/掷随机/手选战斗胜负,模拟银两/道具/友好度账面。 +// out_ref 用返回栈进出子序列;skip 选项命中目标结局时按 skipReward 覆盖结算(对齐运行时)。 + +(function () { + let IR, DICT, flow, ledger, state, retStack, pendingSkip; + + function nameOf(s) { const r = (IR.roles || []).find(x => x.slot === s); return s === "P1" ? "玩家" : (r ? r.name : s); } + function grantForm(kind) { return ((DICT.grants || {})[kind] || {}).form; } + + function seqMap() { const m = {}; (IR.sequences || []).forEach(s => m[s.id] = s); return m; } + function mainNode(id) { return (IR.nodes || []).find(n => n.id === id); } + function ending(id) { return (IR.endings || []).find(e => e.id === id); } + function nodeAt(loc) { + if (!loc) return null; + if (loc.seq) { const s = seqMap()[loc.seq]; return s && s.nodes.find(n => n.id === loc.id); } + return mainNode(loc.id) || ending(loc.id); + } + function seqHas(seqId, id) { const s = seqMap()[seqId]; return s && s.nodes.some(n => n.id === id); } + + function advance(loc) { + const n = nodeAt(loc); const nx = n && n.next; + if (nx) { + if (loc.seq && seqHas(loc.seq, nx)) return { seq: loc.seq, id: nx }; + return { seq: null, id: nx }; + } + if (loc.seq) { const ex = retStack.pop(); return ex ? { seq: null, id: ex } : null; } + return null; + } + function enterSeq(seqId, exit) { + retStack.push(exit || ""); + const s = seqMap()[seqId]; + if (!s || !(s.nodes || []).length) return null; + return { seq: seqId, id: s.nodes[0].id }; + } + + function applyGrants(grants, srcLabel) { + (grants || []).forEach(g => { + const f = grantForm(g.kind); + if (f === "money") state.银两 += (g.value || 0); + else if (f === "item") state.道具[g.item] = (state.道具[g.item] || 0) + (g.value || 0); + else if (f === "friend") state.友好度[g.target] = (state.友好度[g.target] || 0) + (g.value || 0); + else if (f === "join") state.入门.push(nameOf(g.target)); + }); + if (grants && grants.length) renderLedger(); + } + + function condMet(c) { + if (!c) return true; + if (c.kind === "银两") { + if (c.op === ">=") return state.银两 >= c.value; + if (c.op === ">") return state.银两 > c.value; + if (c.op === "<=") return state.银两 <= c.value; + if (c.op === "<") return state.银两 < c.value; + if (c.op === "==") return state.银两 === c.value; + } + return true; // 未知条件不拦 + } + + // ---- 渲染 ---- + function add(cls, html) { const d = document.createElement("div"); d.className = "pt-step " + cls; d.innerHTML = html; flow.appendChild(d); flow.scrollTop = flow.scrollHeight; return d; } + function esc(s) { return String(s == null ? "" : s).replace(/&/g, "&").replace(//g, ">"); } + + function renderLedger() { + let h = "

账面

"; + h += '
银两:' + state.银两 + "
"; + const items = Object.entries(state.道具).filter(([, v]) => v); + h += '
道具:' + (items.length ? items.map(([k, v]) => k + "×" + v).join(",") : "—") + "
"; + const fr = Object.entries(state.友好度).filter(([, v]) => v); + h += '
友好度:' + (fr.length ? fr.map(([k, v]) => nameOf(k) + "+" + v).join(",") : "—") + "
"; + if (state.入门.length) h += '
入门:' + state.入门.join(",") + "
"; + 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", "" + esc(nameOf(n.speaker || "P1")) + ":" + esc(n.text)); loc = advance(loc); } + else if (k === "dialogue") { add("spk", "" + esc(nameOf(n.speaker)) + ":" + 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 }); + } + }; +})(); diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..0458c61 --- /dev/null +++ b/web/static/style.css @@ -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; } diff --git a/web/static/tree.js b/web/static/tree.js new file mode 100644 index 0000000..fd5e4cd --- /dev/null +++ b/web/static/tree.js @@ -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 = '
' + k + '
' + esc(t || id) + '
'; + if (n.kind === "ending") { + const g = (n.grants && n.grants.length) ? n.grants.map(gr => grantStr(ir, names, gr)).join(",") : "无奖励"; + inner += '
' + esc(g) + '
'; + } + 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 = ''; + Object.entries(COLOR).forEach(([k, c]) => { + h += ''; + }); + h += ''; + 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 += ''; + 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 += '' + esc(e.label.slice(0, 12)) + ''; + } + }); + svg.innerHTML = h; + } + + function esc(s) { return String(s).replace(/&/g, "&").replace(//g, ">"); } + window._treeGrantStr = grantStr; + window._treeNameOf = nameOf; + window._treeRoleNames = roleNames; +})();