galbook 0.0.0-stage → 1.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.agents/skills/galbook/SKILL.md +44 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +114 -0
  4. package/README.md +133 -3
  5. package/bin/galbook.js +53 -0
  6. package/cordis.patch.yml +30 -0
  7. package/dsh-plugin/README.md +49 -0
  8. package/dsh-plugin/install.py +190 -0
  9. package/galbook/__init__.py +15 -0
  10. package/galbook/__main__.py +138 -0
  11. package/galbook/build.py +89 -0
  12. package/galbook/cli.py +29 -0
  13. package/galbook/demo/fog-harbor/assets/cg_dawn_together.svg +1 -0
  14. package/galbook/demo/fog-harbor/assets/cg_old_letter.svg +1 -0
  15. package/galbook/demo/fog-harbor/assets/cg_ship.svg +1 -0
  16. package/galbook/demo/fog-harbor/assets/harbor_night.svg +1 -0
  17. package/galbook/demo/fog-harbor/assets/lamp_room.svg +1 -0
  18. package/galbook/demo/fog-harbor/assets/lamp_room_dawn.svg +1 -0
  19. package/galbook/demo/fog-harbor/assets/path_tide.svg +1 -0
  20. package/galbook/demo/fog-harbor/assets/shio_normal.svg +1 -0
  21. package/galbook/demo/fog-harbor/assets/shio_smile.svg +1 -0
  22. package/galbook/demo/fog-harbor/assets/shio_surprised.svg +1 -0
  23. package/galbook/demo/fog-harbor/assets/warehouse.svg +1 -0
  24. package/galbook/demo/fog-harbor/story.gal +97 -0
  25. package/galbook/demo/fog-harbor-en/assets/cg_dawn_together.svg +1 -0
  26. package/galbook/demo/fog-harbor-en/assets/cg_old_letter.svg +1 -0
  27. package/galbook/demo/fog-harbor-en/assets/cg_ship.svg +1 -0
  28. package/galbook/demo/fog-harbor-en/assets/harbor_night.svg +1 -0
  29. package/galbook/demo/fog-harbor-en/assets/lamp_room.svg +1 -0
  30. package/galbook/demo/fog-harbor-en/assets/lamp_room_dawn.svg +1 -0
  31. package/galbook/demo/fog-harbor-en/assets/path_tide.svg +1 -0
  32. package/galbook/demo/fog-harbor-en/assets/shio_normal.svg +1 -0
  33. package/galbook/demo/fog-harbor-en/assets/shio_smile.svg +1 -0
  34. package/galbook/demo/fog-harbor-en/assets/shio_surprised.svg +1 -0
  35. package/galbook/demo/fog-harbor-en/assets/warehouse.svg +1 -0
  36. package/galbook/demo/fog-harbor-en/story.gal +97 -0
  37. package/galbook/demo_boot.py +63 -0
  38. package/galbook/engine.py +325 -0
  39. package/galbook/expr.py +107 -0
  40. package/galbook/i18n.py +85 -0
  41. package/galbook/importers/__init__.py +106 -0
  42. package/galbook/importers/common.py +94 -0
  43. package/galbook/importers/galscript.py +218 -0
  44. package/galbook/importers/kag.py +326 -0
  45. package/galbook/importers/legacy.py +122 -0
  46. package/galbook/importers/nscripter.py +314 -0
  47. package/galbook/importers/qlie.py +498 -0
  48. package/galbook/importers/renpy.py +333 -0
  49. package/galbook/importers/textdump.py +128 -0
  50. package/galbook/ir.py +266 -0
  51. package/galbook/mcp_server.py +102 -0
  52. package/galbook/play.py +374 -0
  53. package/galbook/server.py +199 -0
  54. package/galbook/static/play.html +151 -0
  55. package/galbook/store.py +160 -0
  56. package/galbook/tools.py +99 -0
  57. package/galbook/web.py +316 -0
  58. package/icon.svg +21 -0
  59. package/locale/en.json +6 -0
  60. package/locale/zh.json +6 -0
  61. package/package.json +58 -6
@@ -0,0 +1,94 @@
1
+ """导入器共用:建场景、资源解析、统计。"""
2
+ import os
3
+ import re
4
+
5
+
6
+ class SceneB(object):
7
+ def __init__(self, sid, title, tags=None, route=""):
8
+ self.id, self.title = sid, title
9
+ self.ops = []
10
+ self.tags = list(tags or [])
11
+ self.route = route
12
+
13
+ def add(self, op, **kw):
14
+ d = {"op": op}
15
+ d.update({k: v for k, v in kw.items() if v is not None})
16
+ self.ops.append(d)
17
+ return d
18
+
19
+ def data(self):
20
+ return {"id": self.id, "title": self.title, "ops": self.ops, "tags": self.tags, "route": self.route}
21
+
22
+
23
+ class Assets(object):
24
+ """资源索引:大小写不敏感、可省扩展名。paths 是相对书目录的路径(如 assets/画像/bg_001.bmp)。"""
25
+ EXTS = (".png", ".webp", ".jpg", ".jpeg", ".bmp", ".gif", ".svg")
26
+
27
+ def __init__(self, paths=(), prefix=""):
28
+ self.by_stem = {}
29
+ self.by_name = {}
30
+ for p in paths:
31
+ p = p.replace("\\", "/")
32
+ full = prefix + p
33
+ base = p.rsplit("/", 1)[-1].lower()
34
+ stem, ext = os.path.splitext(base)
35
+ if ext in self.EXTS:
36
+ self.by_name.setdefault(base, full)
37
+ self.by_stem.setdefault(stem, full)
38
+ # 带子目录的 stem(立ち絵/中/灯_中_01_01_02)
39
+ sub = os.path.splitext(p.lower())[0]
40
+ self.by_stem.setdefault(sub, full)
41
+ self.hit = 0
42
+ self.miss = {}
43
+
44
+ @classmethod
45
+ def from_dir(cls, book_dir, sub="assets"):
46
+ root = os.path.join(book_dir, sub)
47
+ paths = []
48
+ for dp, _, fns in os.walk(root):
49
+ for fn in fns:
50
+ paths.append(os.path.relpath(os.path.join(dp, fn), book_dir).replace(os.sep, "/"))
51
+ return cls(paths)
52
+
53
+ @classmethod
54
+ def from_list(cls, list_file, prefix="assets/"):
55
+ with open(list_file, encoding="utf-8") as f:
56
+ return cls([ln.strip() for ln in f if ln.strip()], prefix)
57
+
58
+ def find(self, name, kind="img"):
59
+ if not name:
60
+ return None
61
+ k = name.replace("\\", "/").lower()
62
+ r = self.by_name.get(k.rsplit("/", 1)[-1]) or self.by_stem.get(k) or \
63
+ self.by_stem.get(os.path.splitext(k)[0]) or self.by_stem.get(k.rsplit("/", 1)[-1])
64
+ if r:
65
+ self.hit += 1
66
+ else:
67
+ self.miss[name] = self.miss.get(name, 0) + 1
68
+ return r
69
+
70
+ def report(self):
71
+ tot = self.hit + sum(self.miss.values())
72
+ top = sorted(self.miss.items(), key=lambda x: -x[1])[:15]
73
+ return {"resolved": self.hit, "total": tot, "rate": round(self.hit / tot, 3) if tot else None,
74
+ "missing_top": top}
75
+
76
+
77
+ def read_text(path, encs=("utf-8-sig", "cp932", "gbk", "utf-16")):
78
+ with open(path, "rb") as fh:
79
+ raw = fh.read()
80
+ if raw[:2] in (b"\xff\xfe", b"\xfe\xff"):
81
+ return raw.decode("utf-16")
82
+ for e in encs:
83
+ try:
84
+ return raw.decode(e)
85
+ except UnicodeDecodeError:
86
+ continue
87
+ return raw.decode("cp932", "replace")
88
+
89
+
90
+ H_RE = re.compile(r"[HH](?:シーン|[0-90-9]+回目|$)|エッチ|エッチ")
91
+
92
+
93
+ def is_h_title(t):
94
+ return bool(H_RE.search(t or ""))
@@ -0,0 +1,218 @@
1
+ """galscript(.gal):一种人手能写、程序好生成的剧本格式 → galbook/1。
2
+
3
+ 用途:①自己写互动故事给 agent 玩;②给没有现成导入器的引擎写转换脚本时,先转成 .gal 再导入(最省事)。
4
+
5
+ # 注释 title: 书名 (第一个场景前的 key: value 是书的信息)
6
+ === 场景id 标题 新场景(一个文件可多个;不写就是一个场景 main)
7
+ @route 路线名 @tag h 给当前场景标路线 / 标签(h = H 场景,默认折叠)
8
+ * 标签 跳转目标
9
+ 灯:「台词」 或 灯: 台词 台词(名字 ≤12 字、不含空格和句读;用全角「:」或「: 」)
10
+ 其它行 旁白
11
+ @bg 文件 @cg 文件 @show 名字 文件 [位置] @hide 名字 @clear @bgm 名字|none @event 名 参数
12
+ $ f.trust += 1; s.clear_a = 1 改变量(f. 本周目 / s. 跨周目)
13
+ ? 提示语 选项开始(可省提示语,直接写 - 行也行)
14
+ - 选项文字 -> 目标 [if 条件] [do 赋值]
15
+ -> 目标 [if 条件] 跳转;目标 = 标签 / 场景id / 场景id#标签
16
+ (先找本场景标签,再找同名场景,再找全局唯一标签;找不到 build 校验会报出来)
17
+ call 目标 return
18
+ @end ending 结局名 @end title @end stop
19
+ 文件编码 UTF-8(也认 cp932/GBK)。多个 .gal 文件按文件名排序依次读入,第一个文件的第一个场景是入口。"""
20
+ import os
21
+ import re
22
+
23
+ from .common import SceneB, Assets, read_text
24
+
25
+ NAME = "galscript"
26
+ SAY = re.compile(r"^([^\s::「『。,、!?!?,…—]{1,12})(?::|:\s)\s*(.+)$")
27
+ SAY_Q = re.compile(r"^([^\s::「『((。,、!?!?,…—]{1,12})([「『].*)$")
28
+ OPT = re.compile(r"^-\s*(.+?)\s*->\s*(\S+)(?:\s+if\s+(.+?))?(?:\s+do\s+(.+))?\s*$")
29
+ JMP = re.compile(r"^->\s*(\S+)(?:\s+if\s+(.+))?\s*$")
30
+
31
+
32
+ def detect(src):
33
+ for dp, _, fn in os.walk(src):
34
+ if any(f.lower().endswith(".gal") for f in fn):
35
+ return True, "有 galscript 剧本(.gal)"
36
+ return False, ""
37
+
38
+
39
+ def _target(t, here):
40
+ if "#" in t:
41
+ sc, _, lb = t.partition("#")
42
+ return {"scene": sc or None, "label": lb or None}
43
+ return {"scene": None, "label": t}
44
+
45
+
46
+ def build(src, assets=None, **_):
47
+ assets = assets or Assets()
48
+ files = []
49
+ for dp, _, fn in os.walk(src):
50
+ files += [os.path.join(dp, f) for f in fn if f.lower().endswith(".gal")]
51
+ files.sort()
52
+ if not files:
53
+ raise ValueError("%s 里没有 .gal 文件" % src)
54
+ scenes, order, meta_kv, warnings = {}, [], {}, []
55
+ stats = {"files": len(files), "says": 0, "choices": 0, "options": 0, "jumps": 0, "calls": 0, "sets": 0}
56
+ sysv = set()
57
+ cur, pend = None, None # pend = 正在攒的 choice op
58
+
59
+ def new_scene(sid, title):
60
+ nonlocal cur
61
+ if sid in scenes:
62
+ raise ValueError("场景 id 重复:%s" % sid)
63
+ cur = SceneB(sid, title or sid)
64
+ scenes[sid] = cur
65
+ order.append(sid)
66
+
67
+ def fix_targets(op, here):
68
+ g = op.get("goto") if op.get("op") == "choice" else None
69
+ return g
70
+
71
+ for path in files:
72
+ rel = os.path.relpath(path, src)
73
+ for no, raw in enumerate(read_text(path).splitlines(), 1):
74
+ line = raw.strip()
75
+ where = "%s:%d" % (rel, no)
76
+ if not line or line.startswith("#"):
77
+ continue
78
+ if pend is not None and not (line.startswith("- ") or line == "-" or line.startswith("-\t")):
79
+ pend = None
80
+ if cur is None and not line.startswith("===") and re.match(r"^[A-Za-z_]+:\s", line) and not scenes:
81
+ k, _, v = line.partition(":")
82
+ meta_kv[k.strip()] = v.strip()
83
+ continue
84
+ if line.startswith("==="):
85
+ bits = line[3:].strip().split(None, 1)
86
+ if not bits:
87
+ warnings.append("%s: === 后面要写场景 id" % where)
88
+ continue
89
+ new_scene(bits[0], bits[1] if len(bits) > 1 else bits[0])
90
+ continue
91
+ if cur is None:
92
+ new_scene("main", meta_kv.get("title", "main"))
93
+ for m in re.findall(r"\b(s\.[A-Za-z_]\w*)", line):
94
+ sysv.add(m[2:])
95
+ if line.startswith("* "):
96
+ cur.add("label", name=line[2:].strip())
97
+ elif line.startswith("@"):
98
+ w = line[1:].split()
99
+ c, a = w[0].lower(), w[1:]
100
+ if c == "route":
101
+ cur.route = " ".join(a)
102
+ elif c == "tag":
103
+ cur.tags += a
104
+ elif c in ("bg", "cg"):
105
+ img = None if not a or a[0].lower() == "none" else (assets.find(a[0]) or a[0])
106
+ cur.add(c, img=img)
107
+ elif c == "show" and len(a) >= 2:
108
+ cur.add("sprite", name=a[0], img=assets.find(a[1]) or a[1], pos=a[2] if len(a) > 2 else "center")
109
+ elif c == "hide" and a:
110
+ cur.add("sprite", name=a[0], img=None)
111
+ elif c == "clear":
112
+ cur.add("sprites_clear")
113
+ elif c == "bgm":
114
+ cur.add("bgm", name=None if not a or a[0].lower() == "none" else a[0])
115
+ elif c == "event" and a:
116
+ cur.add("event", name=a[0], arg=" ".join(a[1:]) or None)
117
+ elif c == "end":
118
+ kind = a[0] if a and a[0] in ("ending", "title", "stop") else "ending"
119
+ cur.add("end", kind=kind, name=" ".join(a[1:]) or None)
120
+ else:
121
+ warnings.append("%s: 不认识的指令 @%s" % (where, c))
122
+ elif line.startswith("$"):
123
+ cur.add("set", expr=line[1:].strip())
124
+ stats["sets"] += 1
125
+ elif line.startswith("?") or line.startswith("- ") or line == "-":
126
+ if pend is None:
127
+ pend = cur.add("choice", id="c%d" % len(cur.ops), prompt=line[1:].strip() if line.startswith("?") else "",
128
+ options=[])
129
+ stats["choices"] += 1
130
+ if line.startswith("-"):
131
+ m = OPT.match(line)
132
+ if not m:
133
+ warnings.append("%s: 选项要写成「- 文字 -> 目标」" % where)
134
+ continue
135
+ o = {"text": m.group(1), "goto": _target(m.group(2), cur.id)}
136
+ if m.group(3):
137
+ o["if"] = m.group(3)
138
+ if m.group(4):
139
+ o["set"] = m.group(4)
140
+ pend["options"].append(o)
141
+ stats["options"] += 1
142
+ elif line.startswith("->"):
143
+ m = JMP.match(line)
144
+ if not m:
145
+ warnings.append("%s: 跳转要写成「-> 目标」" % where)
146
+ continue
147
+ g = _target(m.group(1), cur.id)
148
+ cur.add("jump", scene=g["scene"], label=g["label"], **({"if": m.group(2)} if m.group(2) else {}))
149
+ stats["jumps"] += 1
150
+ elif line.startswith("if ") and "->" in line:
151
+ cond, _, tg = line[3:].rpartition("->")
152
+ g = _target(tg.strip(), cur.id)
153
+ cur.add("jump", scene=g["scene"], label=g["label"], **{"if": cond.strip()})
154
+ stats["jumps"] += 1
155
+ elif line.startswith("call "):
156
+ g = _target(line[5:].strip(), cur.id)
157
+ cur.add("call", scene=g["scene"], label=g["label"])
158
+ stats["calls"] += 1
159
+ elif line == "return":
160
+ cur.add("ret")
161
+ else:
162
+ m = SAY.match(line) or SAY_Q.match(line)
163
+ if m:
164
+ cur.add("say", who=m.group(1), text=m.group(2).strip())
165
+ else:
166
+ cur.add("say", who="", text=line)
167
+ stats["says"] += 1
168
+ # 场景之间默认按顺序接上(没写结尾的场景走到下一个)
169
+ for a, b in zip(order, order[1:]):
170
+ ops = scenes[a].ops
171
+ if not ops or ops[-1]["op"] not in ("jump", "end", "ret"):
172
+ scenes[a].add("jump", scene=b, label=None)
173
+ last = scenes[order[-1]].ops
174
+ if not last or last[-1]["op"] not in ("jump", "end", "ret"):
175
+ scenes[order[-1]].add("end", kind="title", how="走到最后")
176
+ # 目标解析:先找本场景的标签,再找同名场景,再找全局唯一的标签;都没有就原样留给校验报错
177
+ labels = {sid: {op["name"] for op in b.ops if op["op"] == "label"} for sid, b in scenes.items()}
178
+
179
+ def resolve(g, sid):
180
+ if g.get("scene") is None and g.get("label") is not None and g["label"] not in labels[sid]:
181
+ lb = g["label"]
182
+ if lb in scenes:
183
+ g["scene"], g["label"] = lb, None
184
+ else:
185
+ owners = [s2 for s2, ls in labels.items() if lb in ls]
186
+ if len(owners) == 1:
187
+ g["scene"] = owners[0]
188
+ for sid, b in scenes.items():
189
+ for op in b.ops:
190
+ if op["op"] == "choice":
191
+ for o in op["options"]:
192
+ resolve(o["goto"], sid)
193
+ o["goto"]["scene"] = o["goto"]["scene"] or sid
194
+ if o["goto"].get("label") is None:
195
+ o["goto"].pop("label")
196
+ elif op["op"] in ("jump", "call") and "scene" not in op:
197
+ g = {"scene": None, "label": op.get("label")}
198
+ resolve(g, sid)
199
+ if g["scene"]:
200
+ op["scene"] = g["scene"]
201
+ if g["label"] is None:
202
+ op.pop("label", None)
203
+ routes, seen_r = [], set()
204
+ for sid in order:
205
+ r = scenes[sid].route
206
+ if r and r not in seen_r:
207
+ seen_r.add(r)
208
+ routes.append({"id": r, "title": r, "start": {"scene": sid, "label": None}})
209
+ data = {k: v.data() for k, v in scenes.items()}
210
+ meta = {"format": "galbook/1", "title": meta_kv.get("title", ""), "engine": "galscript", "importer": "galbook-galscript-1",
211
+ "entry": {"scene": order[0], "label": None},
212
+ "scenes": {k: {"title": v["title"], "tags": v["tags"], "route": v["route"]} for k, v in data.items()},
213
+ "routes": routes, "system_vars": sorted(sysv),
214
+ "stats": dict(stats, unknown=[], assets=assets.report()), "warnings": warnings[:200]}
215
+ for k in ("author", "license", "lang"):
216
+ if k in meta_kv:
217
+ meta[k] = meta_kv[k]
218
+ return meta, data
@@ -0,0 +1,326 @@
1
+ """吉里吉里 KAG / TyranoScript(.ks)→ galbook/1。老游戏(KAG2/KAG3/KAGEX)和新的网页游戏(Tyrano)共用。
2
+
3
+ 认的写法:
4
+ *label|标题 标签
5
+ ; 注释 @tag a=b 整行指令 [tag a=b] 行内指令
6
+ 台词 【名字】「…」 / #名字(Tyrano,到下一个 # 为止) / [nm t="名字"] / @nm t=名字(KAGEX)
7
+ [l] [p] [r] [cm] 断句;没写等待的行按一行一句
8
+ 选项 [link target=*x storage=y.ks exp="f.a=1"]文字[endlink] … [s]
9
+ [glink text="文字" target=*x storage=… exp=…] … [s](Tyrano) [button graphic=… target=…]
10
+ 跳转 [jump storage= target= cond=] [call …] [return] [s](无选项时 = 停住)
11
+ 变量 [eval exp="f.x=1"] sf.* = 系统变量(跨周目)→ s.* tf.* → f.tf_*
12
+ [if exp=…] [elsif exp=…] [else] [endif] [ignore exp=…] [endignore]
13
+ 画面 [bg storage=] @image storage= layer=base|0..n [chara_show name= face=] [chara_hide]
14
+ [chara_mod] [freeimage layer=] [playbgm storage=] / @bgm [cm] / [ct]
15
+ """
16
+ import os
17
+ import re
18
+
19
+ from .common import SceneB, Assets, read_text, is_h_title
20
+
21
+ ATTR = re.compile(r'([A-Za-z_][\w\-]*)\s*=\s*("(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'|[^\s\]]+)|([A-Za-z_][\w\-]*)')
22
+ TOK = re.compile(r"\[(?!\[)([^\]]*)\]|([^\[]+)|\[\[")
23
+
24
+
25
+ def parse_attrs(s):
26
+ d = {}
27
+ for m in ATTR.finditer(s):
28
+ if m.group(1):
29
+ v = m.group(2)
30
+ if v[:1] in "\"'" and v[-1:] == v[:1]:
31
+ v = v[1:-1]
32
+ d[m.group(1).lower()] = v
33
+ elif m.group(3):
34
+ d[m.group(3).lower()] = True
35
+ return d
36
+
37
+
38
+ def conv_expr(e):
39
+ if e is None:
40
+ return None
41
+ e = str(e)
42
+ e = re.sub(r"\bsf\.", "s.", e)
43
+ e = re.sub(r"\btf\.", "f.tf_", e)
44
+ e = re.sub(r"\bmp\.", "f.mp_", e)
45
+ e = e.replace("===", "==").replace("!==", "!=")
46
+ return e
47
+
48
+
49
+ def _target(at, cur):
50
+ st = at.get("storage")
51
+ sid = os.path.splitext(os.path.basename(st))[0] if st and st is not True else None
52
+ tg = at.get("target")
53
+ lb = tg[1:] if isinstance(tg, str) and tg.startswith("*") else (tg if isinstance(tg, str) else None)
54
+ return sid, lb
55
+
56
+
57
+ class _Ctx(object):
58
+ def __init__(self, sid):
59
+ self.b = SceneB(sid, sid, tags=["h"] if is_h_title(sid) else [])
60
+ self.buf = []
61
+ self.who = ""
62
+ self.hash_who = None
63
+ self.links = []
64
+ self.link_open = None
65
+ self.ifs = []
66
+ self.nif = 0
67
+ self.sprites = {}
68
+
69
+
70
+ class Importer(object):
71
+ def __init__(self, src, assets=None, entry=None):
72
+ self.src, self.assets, self.entry = src, assets or Assets(), entry
73
+ self.files = {}
74
+ self.scenes = {}
75
+ self.warnings = []
76
+ self.unknown = {}
77
+ self.stats = {"files": 0, "says": 0, "choices": 0, "options": 0, "jumps": 0, "calls": 0, "sets": 0, "ifs": 0}
78
+ self.system_vars = set()
79
+
80
+ def build(self):
81
+ for dp, _, fns in os.walk(self.src):
82
+ for fn in sorted(fns):
83
+ if fn.lower().endswith(".ks"):
84
+ self.files[os.path.splitext(fn)[0]] = os.path.join(dp, fn)
85
+ if not self.files:
86
+ raise ValueError("没找到 .ks")
87
+ for sid in sorted(self.files):
88
+ self._parse(sid)
89
+ ent = self.entry or next((x for x in ("first", "start", "main", "scenario") if x in self.files), sorted(self.files)[0])
90
+ start = SceneB("__start__", "开始", tags=["system"])
91
+ start.add("label", name="top")
92
+ start.add("jump", scene=ent)
93
+ self.scenes["__start__"] = start.data()
94
+ meta = {"format": "galbook/1", "title": "", "engine": "kag", "importer": "galbook-kag-1",
95
+ "entry": {"scene": "__start__", "label": "top"},
96
+ "scenes": {k: {"title": v["title"], "tags": v["tags"]} for k, v in self.scenes.items()},
97
+ "routes": [], "system_vars": sorted(self.system_vars),
98
+ "stats": dict(self.stats, unknown=sorted(self.unknown.items(), key=lambda x: -x[1])[:20],
99
+ assets=self.assets.report()),
100
+ "warnings": self.warnings[:200]}
101
+ return meta, self.scenes
102
+
103
+ # ---- 文本缓冲 ----
104
+ def _flush(self, cx):
105
+ t = "".join(cx.buf).strip()
106
+ cx.buf = []
107
+ if not t:
108
+ return
109
+ who = cx.who or cx.hash_who or ""
110
+ m = re.match(r"^【([^】]{1,16})】\s*(.*)$", t)
111
+ if m:
112
+ who, t = m.group(1), m.group(2)
113
+ cx.b.add("say", who=who, text=t)
114
+ cx.who = ""
115
+ self.stats["says"] += 1
116
+
117
+ def _parse(self, sid):
118
+ cx = _Ctx(sid)
119
+ self.stats["files"] += 1
120
+ text = read_text(self.files[sid])
121
+ in_script = False
122
+ for raw in text.splitlines():
123
+ line = raw.rstrip("\r\n")
124
+ s = line.strip()
125
+ if in_script:
126
+ if s.lower().startswith("[endscript") or s.lower().startswith("@endscript"):
127
+ in_script = False
128
+ continue
129
+ if not s or s.startswith(";") or s.startswith("//"):
130
+ continue
131
+ if s.startswith("*"):
132
+ self._flush(cx)
133
+ cx.b.add("label", name=s[1:].split("|")[0].strip())
134
+ continue
135
+ if s.startswith("#"):
136
+ self._flush(cx)
137
+ nm = s[1:].strip().split(":")[0]
138
+ cx.hash_who = nm or None
139
+ continue
140
+ if s.startswith("@"):
141
+ if s[1:].lower().startswith("iscript"):
142
+ in_script = True
143
+ continue
144
+ name, _, rest = s[1:].partition(" ")
145
+ self._tag(cx, name.lower(), parse_attrs(rest))
146
+ continue
147
+ if s.lower().startswith("[iscript"):
148
+ in_script = True
149
+ continue
150
+ had_wait = False
151
+ for m in TOK.finditer(s):
152
+ if m.group(0) == "[[":
153
+ cx.buf.append("[")
154
+ elif m.group(2) is not None:
155
+ if cx.link_open is not None:
156
+ cx.link_open["text"] += m.group(2)
157
+ else:
158
+ cx.buf.append(m.group(2))
159
+ else:
160
+ inner = m.group(1).strip()
161
+ name, _, rest = inner.partition(" ")
162
+ name = name.lower()
163
+ if name in ("l", "p", "cm", "er", "ct"):
164
+ had_wait = True
165
+ self._tag(cx, name, parse_attrs(rest))
166
+ if not had_wait and cx.buf and cx.link_open is None:
167
+ self._flush(cx) # 没写 [l]/[p] 的行:一行一句
168
+ self._flush(cx)
169
+ for lb in cx.ifs:
170
+ cx.b.add("label", name=lb["end"])
171
+ if cx.links:
172
+ self._emit_choice(cx)
173
+ self.scenes[sid] = cx.b.data()
174
+
175
+ def _emit_choice(self, cx):
176
+ opts = []
177
+ for l in cx.links:
178
+ o = {"text": l["text"].strip() or "(选项)", "goto": {"scene": l["sid"], "label": l["lb"]}}
179
+ if l.get("exp"):
180
+ o["set"] = conv_expr(l["exp"])
181
+ if l.get("cond"):
182
+ o["if"] = l["cond"]
183
+ opts.append(o)
184
+ cx.b.add("choice", id="c%d" % len(cx.b.ops), options=opts)
185
+ self.stats["choices"] += 1
186
+ self.stats["options"] += len(opts)
187
+ cx.links = []
188
+
189
+ @staticmethod
190
+ def _link_cond(cx, at):
191
+ """选项是攒到 [s] 才一起出的,所以要把外层 [if] 的条件记到每个选项上。"""
192
+ cs = [c for lb in cx.ifs for c in [lb.get("cur")] if c]
193
+ own = conv_expr(at.get("cond")) if isinstance(at.get("cond"), str) else None
194
+ if own:
195
+ cs.append(own)
196
+ return " && ".join("(%s)" % c for c in cs) if cs else None
197
+
198
+ def _tag(self, cx, name, at):
199
+ cond = conv_expr(at.get("cond")) if isinstance(at.get("cond"), str) else None
200
+ if name in ("l", "p"):
201
+ self._flush(cx)
202
+ elif name in ("r", "br"):
203
+ cx.buf.append("\n") if cx.link_open is None else None
204
+ elif name in ("cm", "er", "ct"):
205
+ self._flush(cx)
206
+ elif name in ("nm", "name", "ns"):
207
+ t = at.get("t") or at.get("text") or at.get("name")
208
+ if isinstance(t, str):
209
+ cx.who = t
210
+ elif name == "link":
211
+ sid, lb = _target(at, cx.b.id)
212
+ cx.link_open = {"text": "", "sid": sid, "lb": lb, "exp": at.get("exp"), "cond": self._link_cond(cx, at)}
213
+ elif name == "endlink":
214
+ if cx.link_open is not None:
215
+ cx.links.append(cx.link_open)
216
+ cx.link_open = None
217
+ elif name in ("glink", "button"):
218
+ sid, lb = _target(at, cx.b.id)
219
+ txt = at.get("text") or at.get("graphic") or "(按钮)"
220
+ if lb or sid:
221
+ cx.links.append({"text": str(txt), "sid": sid, "lb": lb, "exp": at.get("exp"), "cond": self._link_cond(cx, at)})
222
+ elif name == "s":
223
+ self._flush(cx)
224
+ if cx.links:
225
+ self._emit_choice(cx)
226
+ else:
227
+ cx.b.add("end", kind="stop", name="[s] 停住(等待外部操作)")
228
+ elif name in ("jump", "call"):
229
+ self._flush(cx)
230
+ sid, lb = _target(at, cx.b.id)
231
+ op = cx.b.add(name, scene=sid if sid != cx.b.id else None, label=lb)
232
+ if cond:
233
+ op["if"] = cond
234
+ self.stats["calls" if name == "call" else "jumps"] += 1
235
+ elif name == "return":
236
+ self._flush(cx)
237
+ cx.b.add("ret")
238
+ elif name == "eval":
239
+ e = conv_expr(at.get("exp"))
240
+ if e:
241
+ op = cx.b.add("set", expr=e)
242
+ if cond:
243
+ op["if"] = cond
244
+ self.stats["sets"] += 1
245
+ for m in re.finditer(r"\bs\.(\w+)\s*(?:=|\+=|-=)(?!=)", e):
246
+ self.system_vars.add(m.group(1))
247
+ elif name in ("if", "ignore"):
248
+ self._flush(cx)
249
+ cx.nif += 1
250
+ lb = {"next": "__if%d_n1" % cx.nif, "end": "__if%d_end" % cx.nif, "k": 1, "n": cx.nif}
251
+ cx.ifs.append(lb)
252
+ e = conv_expr(at.get("exp")) or "false"
253
+ neg = e if name == "ignore" else "!(%s)" % e
254
+ lb["cur"] = ("!(%s)" % e) if name == "ignore" else e
255
+ lb["prev"] = [lb["cur"]]
256
+ cx.b.add("jump", label=lb["next"], **{"if": neg})
257
+ self.stats["ifs"] += 1
258
+ elif name in ("elsif", "else"):
259
+ self._flush(cx)
260
+ if not cx.ifs:
261
+ return
262
+ lb = cx.ifs[-1]
263
+ cx.b.add("jump", label=lb["end"])
264
+ cx.b.add("label", name=lb["next"])
265
+ lb["k"] += 1
266
+ lb["next"] = "__if%d_n%d" % (lb["n"], lb["k"])
267
+ notprev = " && ".join("!(%s)" % c for c in lb.get("prev", []))
268
+ if name == "elsif":
269
+ e = conv_expr(at.get("exp")) or "false"
270
+ cx.b.add("jump", label=lb["next"], **{"if": "!(%s)" % e})
271
+ lb["cur"] = "(%s) && (%s)" % (notprev, e) if notprev else e
272
+ lb.setdefault("prev", []).append(e)
273
+ else:
274
+ lb["cur"] = notprev or None
275
+ elif name in ("endif", "endignore"):
276
+ self._flush(cx)
277
+ if cx.ifs:
278
+ lb = cx.ifs.pop()
279
+ cx.b.add("label", name=lb["next"])
280
+ cx.b.add("label", name=lb["end"])
281
+ elif name == "bg" or (name == "image" and str(at.get("layer", "")).lower() in ("base", "background")):
282
+ self._flush(cx)
283
+ cx.b.add("bg", img=self.assets.find(at.get("storage")) if at.get("storage") else None)
284
+ elif name == "image":
285
+ self._flush(cx)
286
+ layer = str(at.get("layer", "0"))
287
+ cx.b.add("sprite", name="layer" + layer, img=self.assets.find(at.get("storage")), pos=at.get("pos") or "center")
288
+ elif name in ("freeimage", "freelayer"):
289
+ self._flush(cx)
290
+ layer = str(at.get("layer", "0"))
291
+ if layer in ("base", "background"):
292
+ cx.b.add("bg", img=None)
293
+ else:
294
+ cx.b.add("sprite", name="layer" + layer, img=None)
295
+ elif name in ("chara_show", "chara_mod"):
296
+ self._flush(cx)
297
+ nm = at.get("name", "?")
298
+ face = at.get("face") or at.get("storage") or ""
299
+ img = self.assets.find(at.get("storage")) if at.get("storage") else self.assets.find("%s_%s" % (nm, face) if face else str(nm))
300
+ cx.b.add("sprite", name=str(nm), img=img or ("chara:%s%s" % (nm, (":" + face) if face else "")), pos=at.get("pos") or "center")
301
+ elif name == "chara_hide":
302
+ cx.b.add("sprite", name=str(at.get("name", "?")), img=None)
303
+ elif name in ("chara_hide_all", "freeimage_all"):
304
+ cx.b.add("sprites_clear")
305
+ elif name in ("playbgm", "bgm", "fadeinbgm"):
306
+ cx.b.add("bgm", name=at.get("storage") if isinstance(at.get("storage"), str) else None)
307
+ elif name in ("stopbgm", "fadeoutbgm"):
308
+ cx.b.add("bgm", name=None)
309
+ elif name in ("gotostart", "close", "end"):
310
+ self._flush(cx)
311
+ cx.b.add("end", kind="title", how="[%s]" % name)
312
+ elif name in ("macro", "endmacro", "wait", "wt", "trans", "wq", "quake", "move", "wm", "playse", "se", "ws",
313
+ "position", "font", "resetfont", "delay", "nowait", "endnowait", "locate", "current", "layopt",
314
+ "backlay", "style", "deffont", "defstyle", "history", "clearsysvar", "clearvar", "seopt",
315
+ "anim", "kanim", "wa", "vo", "voice", "playvoice", "mask", "mask_off", "chara_new", "chara_face",
316
+ "chara_config", "showmenubutton", "hidemenubutton", "title", "loadjs", "plugin", "skipstop",
317
+ "autostop", "cancelskip", "fadeoutse", "stopse", "filter", "free_filter", "layermode", "camera",
318
+ "reset_camera", "ptext", "free", "wait_cancel", "hidemessage", "showlog", "clickable", "dialog",
319
+ "image_tag", "emb", "ruby", "graph", "ch", "hch", "add", "rclick", "button_cancel", "cancel"):
320
+ pass
321
+ else:
322
+ self.unknown[name] = self.unknown.get(name, 0) + 1
323
+
324
+
325
+ def build(src, assets=None, entry=None, **_):
326
+ return Importer(src, assets, entry).build()