From f5669dc01dd9aaa40078146dbba0040877ce8406 Mon Sep 17 00:00:00 2001 From: bia Date: Mon, 8 Jun 2026 16:50:27 +0800 Subject: [PATCH] =?UTF-8?q?init:=20=E5=89=A7=E6=83=85=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E5=8D=8F=E4=BD=9C=20Web=20=E7=BC=96=E8=BE=91=E5=99=A8=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E4=BB=93=EF=BC=88=E4=BB=8E=20SGame/tools/event=5Fauth?= =?UTF-8?q?oring=20=E6=8B=86=E5=87=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + IR_SCHEMA.md | 157 ++++++++++++++++ ir_compile.py | 93 +++++++++ ir_core/__init__.py | 14 ++ ir_core/compile.py | 269 ++++++++++++++++++++++++++ ir_core/dictionary.py | 93 +++++++++ ir_core/validate.py | 185 ++++++++++++++++++ ir_dictionary.json | 12 ++ ir_to_html.py | 289 ++++++++++++++++++++++++++++ samples/bishi_yazhu.events.json | 159 ++++++++++++++++ samples/bishi_yazhu.i18n.tsv | 11 ++ samples/bishi_yazhu.ir.json | 67 +++++++ samples/yuye_koumen.events.json | 151 +++++++++++++++ samples/yuye_koumen.html | 248 ++++++++++++++++++++++++ samples/yuye_koumen.i18n.tsv | 11 ++ samples/yuye_koumen.ir.json | 75 ++++++++ samples/yuye_koumen.preview.png | Bin 0 -> 103723 bytes web/.dockerignore | 11 ++ web/.gitignore | 4 + web/Dockerfile | 33 ++++ web/README.md | 70 +++++++ web/app.py | 209 ++++++++++++++++++++ web/db.py | 108 +++++++++++ web/docker-compose.nas.yml | 21 +++ web/docker-compose.yml | 26 +++ web/requirements.txt | 2 + web/static/app.js | 252 +++++++++++++++++++++++++ web/static/form.js | 324 ++++++++++++++++++++++++++++++++ web/static/index.html | 115 ++++++++++++ web/static/playtest.js | 171 +++++++++++++++++ web/static/style.css | 156 +++++++++++++++ web/static/tree.js | 157 ++++++++++++++++ 32 files changed, 3497 insertions(+) create mode 100644 .gitignore create mode 100644 IR_SCHEMA.md create mode 100644 ir_compile.py create mode 100644 ir_core/__init__.py create mode 100644 ir_core/compile.py create mode 100644 ir_core/dictionary.py create mode 100644 ir_core/validate.py create mode 100644 ir_dictionary.json create mode 100644 ir_to_html.py create mode 100644 samples/bishi_yazhu.events.json create mode 100644 samples/bishi_yazhu.i18n.tsv create mode 100644 samples/bishi_yazhu.ir.json create mode 100644 samples/yuye_koumen.events.json create mode 100644 samples/yuye_koumen.html create mode 100644 samples/yuye_koumen.i18n.tsv create mode 100644 samples/yuye_koumen.ir.json create mode 100644 samples/yuye_koumen.preview.png create mode 100644 web/.dockerignore create mode 100644 web/.gitignore create mode 100644 web/Dockerfile create mode 100644 web/README.md create mode 100644 web/app.py create mode 100644 web/db.py create mode 100644 web/docker-compose.nas.yml create mode 100644 web/docker-compose.yml create mode 100644 web/requirements.txt create mode 100644 web/static/app.js create mode 100644 web/static/form.js create mode 100644 web/static/index.html create mode 100644 web/static/playtest.js create mode 100644 web/static/style.css create mode 100644 web/static/tree.js 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 0000000000000000000000000000000000000000..3640b11fc99c4f977694f8afb65530c1e5f810e1 GIT binary patch literal 103723 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
  • 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: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; +})();