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,498 @@
1
+ """QLIE(.s 剧本)→ galbook/1。
2
+
3
+ 在一部商业 QLIE 作品上逐条核对过的「AVGシステム」标准模板写法(同模板的作品很多):
4
+ 台词 voice,名字@别名,「台词」 (voice=none 表示无语音;名字@后是真名,显示用@前)
5
+ 旁白 不以 \\ ^ @ // 开头的行
6
+ 画面 ^bg0,名 ^mbg0,效果,名 ^chara,名字,表情,姿势,距离,位置,服装 ^clear ^music
7
+ 选项 ^select,N,,选项1,选项2… ^selectpre,编号 ^selectjmp
8
+ → 跳到 select.s 的 @@<本文件名>_sel<编号>_<0起序号>,那里再 \\jmp 到 Route.s 同名标签
9
+ 流程 \\jmp,@@标签[,"文件"] \\go 同 jmp \\sub,@@标签,"文件"(调用)
10
+ 文件末尾 \\sub,@@!FilejumpName,self,SelfFileName + \\jmp,ResultStr[0],"シナリオ\\Route.s"
11
+ = 跳到 Route.s 的 @@<本文件名>_end,再顺着往下走到下一个场景
12
+ 变量 \\cal,#X=1(# 开头是系统变量 = 跨周目,记到 s.X) \\if,条件\\then … \\else … \\end
13
+ 开局 スクリプト\\RouteSelect\\main.s:常见写法是若干 #XXClearflag 都为 1 才出现隐藏线入口(条件照原文翻译,不写死)
14
+ 认不出的指令不猜:计数后写进 stats.unknown,选项/跳转认不出写进 warnings。
15
+ """
16
+ import os
17
+ import re
18
+
19
+ from .common import SceneB, Assets, read_text, is_h_title
20
+
21
+ INTERNAL = re.compile(r"^(Result\w*|Param\w*|Self\w*|_\w*)$")
22
+ IDENT = re.compile(r"(#?)\b([A-Za-z_][A-Za-z0-9_]*)\b(?!\s*\()")
23
+ IGNORED_CARET = {"se", "se0", "se1", "se2", "effect", "voicedir", "layoutchange", "d", "enchara", "mchara", "achara",
24
+ "evbgdef", "action", "action0", "action1", "caction0", "delaychara", "alphachara", "savetext", "date",
25
+ "bg1", "bg2", "bg3", "mbg1", "mbg2", "mbg3", "music1", "voice", "wait", "quake", "flash"}
26
+ IGNORED_BS = {"clk", "scp", "wait", "ret_", "l", "p", "a", "e", "var", "gvar", "svar", "del", "zero", "d", "us",
27
+ "ui", "o", "play", "m", "v", "i", "ts", "tab", "sp", "vtn", "srct", "thrd", "stop", "lk", "mv", "ck",
28
+ "dc", "t", "ans", "fexs", "vexs", "ddlg", "div", "fmt", "pos", "li", "ps", "vt", "vtv", "cc", "brk",
29
+ "for", "next", "case", "default", "endswitch", "switch"}
30
+
31
+
32
+ def _split_args(s):
33
+ out, cur, q = [], [], False
34
+ for ch in s:
35
+ if ch == '"':
36
+ q = not q
37
+ cur.append(ch)
38
+ elif ch == "," and not q:
39
+ out.append("".join(cur))
40
+ cur = []
41
+ else:
42
+ cur.append(ch)
43
+ out.append("".join(cur))
44
+ return [x.strip() for x in out]
45
+
46
+
47
+ def _unq(s):
48
+ s = (s or "").strip()
49
+ return s[1:-1] if len(s) >= 2 and s[0] == s[-1] == '"' else s
50
+
51
+
52
+ def _file_stem(ref):
53
+ """'シナリオ\\Route.s' → 'route'(小写,不带扩展名)"""
54
+ ref = _unq(ref).replace("/", "\\")
55
+ base = ref.rsplit("\\", 1)[-1]
56
+ return os.path.splitext(base)[0].lower()
57
+
58
+
59
+ def translate_cond(c):
60
+ """QLIE 条件 → galbook 表达式;含引擎内部变量(ResultStr 等)返回 None。"""
61
+ c = c.strip()
62
+ bad = []
63
+
64
+ def rep(m):
65
+ sharp, name = m.group(1), m.group(2)
66
+ if name in ("and", "or", "not", "true", "false"):
67
+ return m.group(0)
68
+ if INTERNAL.match(name):
69
+ bad.append(name)
70
+ return name
71
+ return ("s." if sharp else "f.") + name
72
+ out = IDENT.sub(rep, c)
73
+ out = re.sub(r"(?<![=!<>])=(?!=)", "==", out) if "==" not in out and "!=" not in out and "<=" not in out and ">=" not in out and "=" in out else out
74
+ out = out.replace("&", " && ").replace("|", " || ").replace("&& &&", "&&").replace("|| ||", "||")
75
+ out = re.sub(r"&&\s*&&", "&&", out)
76
+ out = re.sub(r"\|\|\s*\|\|", "||", out)
77
+ return None if bad else out
78
+
79
+
80
+ def translate_assign(c):
81
+ m = re.match(r"^\s*(#?)([A-Za-z_]\w*)\s*(\+\+|--|\+=|-=|=)\s*(.*)$", c)
82
+ if not m:
83
+ return None
84
+ sharp, name, op, rhs = m.groups()
85
+ if INTERNAL.match(name):
86
+ return None
87
+ tgt = ("s." if sharp else "f.") + name
88
+ if op == "++":
89
+ return tgt + " += 1"
90
+ if op == "--":
91
+ return tgt + " -= 1"
92
+ r = translate_cond(rhs) if rhs else None
93
+ if r is None:
94
+ return None
95
+ return "%s %s %s" % (tgt, op, r)
96
+
97
+
98
+ class _FileCtx(object):
99
+ def __init__(self, imp, path, sid):
100
+ self.imp, self.path, self.sid = imp, path, sid
101
+ self.title = sid
102
+ self.b = SceneB(sid, sid, tags=["h"] if is_h_title(sid) else [], route=imp.route_of(sid))
103
+ self.chara = {}
104
+ self.sel_opts = None
105
+ self.sel_no = None
106
+ self.filejump = False
107
+ self.ifs = []
108
+ self.nif = 0
109
+ self.cur_label = None
110
+ self.last_scn_label = None
111
+
112
+
113
+ class Importer(object):
114
+ def __init__(self, src, assets=None):
115
+ self.src = src
116
+ self.assets = assets or Assets()
117
+ self.files = {} # stem(lower) -> path
118
+ self.sid_of = {} # stem(lower) -> scene id(原文件名)
119
+ self.scenes = {}
120
+ self.warnings = []
121
+ self.unknown = {}
122
+ self.ignored = 0
123
+ self.stats = {"files": 0, "says": 0, "dialogue": 0, "narration": 0, "choices": 0, "options": 0,
124
+ "jumps": 0, "calls": 0, "sets": 0, "ifs": 0, "untranslated_if": 0}
125
+ self.title_labels = set()
126
+ self.system_vars = set()
127
+ self.scn_dir = None
128
+ self.lib_dir = None
129
+
130
+ # ---------- 找文件 ----------
131
+ def scan(self):
132
+ for dp, dn, fns in os.walk(self.src):
133
+ for fn in fns:
134
+ if fn.lower().endswith(".s"):
135
+ full = os.path.join(dp, fn)
136
+ if os.path.basename(dp).lower() in ("シナリオ", "scenario", "scn"):
137
+ stem = os.path.splitext(fn)[0]
138
+ self.files[stem.lower()] = full
139
+ self.sid_of[stem.lower()] = stem
140
+ self.scn_dir = dp
141
+ if not self.files:
142
+ raise ValueError("在 %s 下没找到 シナリオ/*.s" % self.src)
143
+
144
+ def find_script(self, *parts):
145
+ want = [p.lower() for p in parts]
146
+ for dp, dn, fns in os.walk(self.src):
147
+ for fn in fns:
148
+ full = os.path.join(dp, fn)
149
+ low = full.lower().replace("\\", "/")
150
+ if all(w in low for w in want):
151
+ return full
152
+ return None
153
+
154
+ def route_of(self, sid):
155
+ m = re.match(r"^(.+?ルート)", sid)
156
+ if m:
157
+ return m.group(1)
158
+ return "" if sid.lower() not in ("route", "select") else "system"
159
+
160
+ # ---------- 主流程 ----------
161
+ def build(self):
162
+ self.scan()
163
+ rp = self.files.get("route")
164
+ if rp:
165
+ self._prescan_title_labels(rp)
166
+ for stem in sorted(self.files):
167
+ self._parse_file(stem)
168
+ entry = self._build_start()
169
+ routes = self._routes()
170
+ meta = {"format": "galbook/1", "title": "", "engine": "qlie", "importer": "galbook-qlie-1",
171
+ "entry": entry, "scenes": {sid: {"title": d["title"], "tags": d["tags"], "route": d["route"]}
172
+ for sid, d in self.scenes.items()},
173
+ "routes": routes, "system_vars": sorted(self.system_vars),
174
+ "stats": dict(self.stats, ignored_cmds=self.ignored,
175
+ unknown=sorted(self.unknown.items(), key=lambda x: -x[1])[:20],
176
+ assets=self.assets.report()),
177
+ "warnings": self.warnings[:200]}
178
+ return meta, self.scenes
179
+
180
+ def _prescan_title_labels(self, path):
181
+ lines = [ln.strip() for ln in read_text(path).splitlines()]
182
+ for i, ln in enumerate(lines):
183
+ if ln.startswith("@@") and not ln.startswith("@@@"):
184
+ nxt = [x for x in lines[i + 1:i + 4] if x and not x.startswith("//")]
185
+ if nxt and re.match(r"^\\scp,sys\s+\\r\b", nxt[0]):
186
+ self.title_labels.add(ln[2:])
187
+
188
+ def _parse_file(self, stem):
189
+ path = self.files[stem]
190
+ sid = self.sid_of[stem]
191
+ cx = _FileCtx(self, path, sid)
192
+ self.stats["files"] += 1
193
+ text = read_text(path)
194
+ for raw in text.splitlines():
195
+ s = raw.strip()
196
+ if not s or s.startswith("//") or s.startswith(";"):
197
+ continue
198
+ if s.startswith("@@@"):
199
+ continue
200
+ if s.startswith("@@"):
201
+ name = s[2:].split()[0]
202
+ cx.b.add("label", name=name)
203
+ cx.cur_label = name
204
+ if stem == "route" and not name.endswith("_end") and self.sid_of.get(name.lower()):
205
+ cx.last_scn_label = name
206
+ continue
207
+ if s.startswith("^"):
208
+ self._caret(cx, s)
209
+ continue
210
+ if s.startswith("\\"):
211
+ for part in re.split(r"\t+(?=\\)", s):
212
+ self._bs(cx, part.strip())
213
+ continue
214
+ self._text(cx, s)
215
+ if cx.ifs:
216
+ self.warnings.append("%s: 有 %d 个 \\if 没闭合" % (sid, len(cx.ifs)))
217
+ for lb in cx.ifs:
218
+ cx.b.add("label", name=lb["end"])
219
+ self.scenes[sid] = cx.b.data()
220
+
221
+ # ---------- 台词 / 旁白 ----------
222
+ def _text(self, cx, s):
223
+ parts = s.split(",", 2)
224
+ if len(parts) == 3 and (parts[2][:1] in "「『((" or parts[0] == "none" or re.match(r"^[A-Za-z]{1,6}H?_?\d+", parts[0])):
225
+ voice, who, txt = parts
226
+ who = who.split("@")[0].strip()
227
+ cx.b.add("say", who=who, text=txt.strip(), voice=None if voice == "none" else voice)
228
+ self.stats["dialogue"] += 1
229
+ else:
230
+ cx.b.add("say", who="", text=s)
231
+ self.stats["narration"] += 1
232
+ self.stats["says"] += 1
233
+
234
+ # ---------- ^ 指令 ----------
235
+ def _caret(self, cx, s):
236
+ head, _, rest = s[1:].partition(",")
237
+ cmd = head.strip().lower()
238
+ a = _split_args(rest) if rest else []
239
+ if cmd in ("bg0", "bg"):
240
+ self._bg(cx, a[0] if a else "")
241
+ elif cmd == "mbg0":
242
+ if len(a) >= 2 and a[1]:
243
+ self._bg(cx, a[1])
244
+ elif cmd == "chara":
245
+ self._chara(cx, a)
246
+ elif cmd == "clear":
247
+ tgt = (a[0] if a else "").lower()
248
+ if tgt in ("", "chara", "all"):
249
+ cx.b.add("sprites_clear")
250
+ cx.chara = {k: dict(v, shown=False) for k, v in cx.chara.items()}
251
+ if tgt in ("all", "画像"):
252
+ cx.b.add("cg", img=None)
253
+ elif cmd == "music":
254
+ cx.b.add("bgm", name=a[0] if a else None)
255
+ elif cmd == "music0":
256
+ cx.b.add("bgm", name=None)
257
+ elif cmd == "select":
258
+ cx.sel_opts = [x for x in a[2:] if x != ""]
259
+ elif cmd == "selectpre":
260
+ cx.sel_no = a[0] if a else "0"
261
+ elif cmd == "selectjmp":
262
+ self._selectjmp(cx)
263
+ elif cmd in ("branchpre", "branchjmp", "routejmp"):
264
+ self.warnings.append("%s: ^%s 这类按 flag 自动分支的写法还没接(本作没用到)" % (cx.sid, cmd))
265
+ elif cmd in IGNORED_CARET:
266
+ self.ignored += 1
267
+ else:
268
+ self.unknown["^" + cmd] = self.unknown.get("^" + cmd, 0) + 1
269
+
270
+ def _bg(self, cx, name):
271
+ name = _unq(name)
272
+ if not name or name.lower() in ("bg_bl", "bg_wh", "黒", "白", "black", "white"):
273
+ cx.b.add("bg", img=None)
274
+ return
275
+ img = self.assets.find(name)
276
+ if img and not os.path.basename(img).lower().startswith("bg"):
277
+ cx.b.add("cg", img=img) # 事件 CG(KH01 / HN04 / SZ05e …)
278
+ else:
279
+ cx.b.add("bg", img=img)
280
+
281
+ def _chara(self, cx, a):
282
+ if not a or not a[0]:
283
+ cx.b.add("sprites_clear")
284
+ cx.chara = {}
285
+ return
286
+ name = a[0]
287
+ if len(a) == 1:
288
+ cx.b.add("sprite", name=name, img=None)
289
+ cx.chara.pop(name, None)
290
+ return
291
+ st = cx.chara.setdefault(name, {"feel": "01", "pose": "01", "dist": "中", "pos": "center", "clothes": "01"})
292
+ for k, i in (("feel", 1), ("pose", 2), ("dist", 3), ("pos", 4), ("clothes", 5)):
293
+ if len(a) > i and a[i] and not a[i].startswith('"'):
294
+ st[k] = a[i]
295
+ stem = "立ち絵/%s/%s_%s_%s_%s_%s" % (st["dist"], name, st["dist"], st["clothes"], st["pose"], st["feel"])
296
+ img = self.assets.find(stem)
297
+ cx.b.add("sprite", name=name, img=img or None, pos=st["pos"])
298
+
299
+ def _selectjmp(self, cx):
300
+ opts = cx.sel_opts or []
301
+ if not opts:
302
+ self.warnings.append("%s: ^selectjmp 前没有 ^select" % cx.sid)
303
+ return
304
+ no = cx.sel_no if cx.sel_no is not None else "0"
305
+ sel_sid = self.sid_of.get("select")
306
+ options = []
307
+ for i, t in enumerate(opts):
308
+ lb = "%s_sel%s_%d" % (cx.sid, no, i)
309
+ tgt = sel_sid if sel_sid and self._has_label(sel_sid, lb) else self.sid_of.get("route")
310
+ options.append({"text": t, "goto": {"scene": tgt, "label": lb}})
311
+ cx.b.add("choice", id="%s_sel%s" % (cx.sid, no), options=options)
312
+ self.stats["choices"] += 1
313
+ self.stats["options"] += len(options)
314
+ cx.sel_opts, cx.sel_no = None, None
315
+
316
+ def _has_label(self, sid, lb):
317
+ path = self.files.get(sid.lower())
318
+ if not path:
319
+ return False
320
+ return ("@@" + lb) in read_text(path)
321
+
322
+ # ---------- \ 指令 ----------
323
+ def _bs(self, cx, s):
324
+ m = re.match(r"^\\([A-Za-z?]+)(?:,(.*))?$", s)
325
+ if not m:
326
+ self.unknown[s[:12]] = self.unknown.get(s[:12], 0) + 1
327
+ return
328
+ cmd, rest = m.group(1).lower(), m.group(2) or ""
329
+ if cmd.startswith("if"):
330
+ return self._if(cx, s)
331
+ if cmd == "else":
332
+ if not cx.ifs:
333
+ self.warnings.append("%s: 多余的 \\else" % cx.sid)
334
+ return
335
+ top = cx.ifs[-1]
336
+ cx.b.add("jump", label=top["end"])
337
+ cx.b.add("label", name=top["else"])
338
+ top["has_else"] = True
339
+ return
340
+ if cmd == "end":
341
+ if not cx.ifs:
342
+ return
343
+ top = cx.ifs.pop()
344
+ if not top.get("has_else"):
345
+ cx.b.add("label", name=top["else"])
346
+ cx.b.add("label", name=top["end"])
347
+ return
348
+ a = _split_args(rest) if rest else []
349
+ if cmd in ("jmp", "go", "g"):
350
+ return self._jmp(cx, a, call=False)
351
+ if cmd == "sub":
352
+ if a and a[0] == "@@!FilejumpName":
353
+ cx.filejump = True
354
+ return
355
+ if a and a[0] == "@@!EyeCatch":
356
+ arg = "・".join(x for x in a[2:] if x)
357
+ cx.b.add("event", name="eyecatch", arg=arg or None)
358
+ return
359
+ if a and a[0] == "@@!MovieExcute":
360
+ cx.b.add("event", name="movie", arg=a[2] if len(a) > 2 else None)
361
+ return
362
+ if len(a) >= 2 and "endroll" in a[1].lower():
363
+ cx.b.add("event", name="endroll", arg=_unq(a[2]) if len(a) > 2 else None)
364
+ return
365
+ if len(a) >= 2 and _file_stem(a[1]) in self.files:
366
+ return self._jmp(cx, a, call=True)
367
+ self.ignored += 1
368
+ return
369
+ if cmd == "ret":
370
+ cx.b.add("ret")
371
+ return
372
+ if cmd == "cal":
373
+ expr = translate_assign(rest)
374
+ if expr:
375
+ cx.b.add("set", expr=expr)
376
+ self.stats["sets"] += 1
377
+ if expr.startswith("s."):
378
+ self.system_vars.add(expr[2:].split()[0])
379
+ if "clear" in expr.lower():
380
+ cx.b.add("event", name="clear", arg=expr[2:].split()[0])
381
+ else:
382
+ self.ignored += 1
383
+ return
384
+ if cx.sid.lower() == "route" and cmd in ("scp", "r"):
385
+ return
386
+ if cmd in IGNORED_BS or cmd.startswith("?"):
387
+ self.ignored += 1
388
+ return
389
+ self.unknown["\\" + cmd] = self.unknown.get("\\" + cmd, 0) + 1
390
+
391
+ def _if(self, cx, s):
392
+ m = re.match(r"^\\if,(.*?)\\then\s*$", s)
393
+ cond = m.group(1) if m else s[4:]
394
+ self.stats["ifs"] += 1
395
+ cx.nif += 1
396
+ lb = {"else": "__if%d_else" % cx.nif, "end": "__if%d_end" % cx.nif}
397
+ cx.ifs.append(lb)
398
+ g = translate_cond(cond)
399
+ if g is None:
400
+ self.stats["untranslated_if"] += 1
401
+ cx.b.add("jump", label=lb["else"]) # 看不懂的条件(引擎内部状态)一律当不成立
402
+ else:
403
+ cx.b.add("jump", label=lb["else"], **{"if": "!(%s)" % g})
404
+
405
+ def _jmp(self, cx, a, call=False):
406
+ if not a:
407
+ return
408
+ tgt = a[0]
409
+ fstem = _file_stem(a[1]) if len(a) > 1 and a[1] not in ("self", "") else None
410
+ sid = self.sid_of.get(fstem) if fstem else cx.sid
411
+ if fstem and not sid:
412
+ self.warnings.append("%s: 跳到未知文件 %s" % (cx.sid, a[1]))
413
+ return
414
+ if tgt.startswith("@@") and re.match(r"^@@[^+\s]+$", tgt):
415
+ label = tgt[2:]
416
+ if label.upper() == "TOP" and sid != cx.sid and not self._has_label(sid, label):
417
+ label = None # @@TOP = 文件开头
418
+ if sid == cx.sid and label in self.title_labels:
419
+ name = (cx.last_scn_label or "").replace("_end", "")
420
+ cx.b.add("end", kind="ending", name=self.route_of(name) or name or "通关")
421
+ return
422
+ cx.b.add("call" if call else "jump", scene=None if sid == cx.sid else sid, label=label)
423
+ self.stats["calls" if call else "jumps"] += 1
424
+ return
425
+ if tgt == "ResultStr[0]" and cx.filejump:
426
+ if not self._has_label(sid, "%s_end" % cx.sid):
427
+ # 例:demo.s(标题画面的自动演示)在 Route.s 里没有 _end 标签 → 回标题
428
+ cx.b.add("end", kind="title", name="%s 结束(Route.s 里没有 %s_end)" % (cx.sid, cx.sid))
429
+ return
430
+ cx.b.add("jump", scene=sid, label="%s_end" % cx.sid)
431
+ self.stats["jumps"] += 1
432
+ return
433
+ if tgt.replace(" ", "") == '_SelectLabel+"_"+ResultBtnInt' and cx.cur_label:
434
+ cx.b.add("jump", scene=sid, label=cx.cur_label)
435
+ self.stats["jumps"] += 1
436
+ return
437
+ self.warnings.append("%s: 看不懂的跳转目标 %s(标签 %s 之后)" % (cx.sid, tgt, cx.cur_label))
438
+
439
+ # ---------- 开局 / 路线 ----------
440
+ def _build_start(self):
441
+ route_sid = self.sid_of.get("route")
442
+ b = SceneB("__start__", "开始", tags=["system"], route="system")
443
+ b.add("label", name="top")
444
+ rs = self.find_script("routeselect", "main.s")
445
+ targets, lock = [], None
446
+ if rs and route_sid:
447
+ t = read_text(rs)
448
+ for m in re.finditer(r'\\go,@@(\w+),"([^"]+)"', t):
449
+ if _file_stem(m.group(2)) == "route":
450
+ targets.append(m.group(1))
451
+ m = re.search(r"\\if,([^\n]*?)\\then\s*\n(?:[^\n]*\n){0,4}?[^\n]*ResultStr\[0\]=\"Normal\"", t)
452
+ if m:
453
+ lock = translate_cond(m.group(1))
454
+ if route_sid and targets:
455
+ opts = []
456
+ for lb in dict.fromkeys(targets):
457
+ if lb.lower().endswith("normal"):
458
+ opts.insert(0, {"text": "从头开始(共通线)", "goto": {"scene": route_sid, "label": lb}})
459
+ else:
460
+ nm = self._first_route_after(lb) or lb.split("_", 1)[-1]
461
+ o = {"text": "%s(通关其它线后解锁)" % nm, "goto": {"scene": route_sid, "label": lb}}
462
+ if lock:
463
+ o["if"] = "!(%s)" % lock
464
+ opts.append(o)
465
+ b.add("choice", id="routeselect", prompt="选择开始的路线", options=opts, auto_single=True)
466
+ self.stats["choices"] += 1 # 开局路线选择也是脚本(routeselect.s)里来的选择点,计数要对上 build 的校验
467
+ elif route_sid:
468
+ b.add("jump", scene=route_sid, label=None)
469
+ else:
470
+ first = sorted(self.sid_of.values())[0]
471
+ b.add("jump", scene=first)
472
+ self.warnings.append("没有 route.s,入口取了按名字排第一的剧本 %s" % first)
473
+ self.scenes["__start__"] = b.data()
474
+ return {"scene": "__start__", "label": "top"}
475
+
476
+ def _first_route_after(self, label):
477
+ ops = self.scenes.get(self.sid_of.get("route"), {}).get("ops", [])
478
+ hit = False
479
+ for op in ops:
480
+ if op.get("op") == "label" and op.get("name") == label:
481
+ hit = True
482
+ elif hit and op.get("op") == "label" and self.route_of(op.get("name", "")) not in ("", "system"):
483
+ return self.route_of(op["name"])
484
+ return None
485
+
486
+ def _routes(self):
487
+ out, seen = [], set()
488
+ for sid in self.scenes:
489
+ r = self.route_of(sid)
490
+ if r and r != "system" and r not in seen:
491
+ seen.add(r)
492
+ out.append({"id": r, "title": r})
493
+ return out
494
+
495
+
496
+ def build(src, assets=None, **_):
497
+ imp = Importer(src, assets)
498
+ return imp.build()