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.
- package/.agents/skills/galbook/SKILL.md +44 -0
- package/LICENSE +21 -0
- package/README.en.md +114 -0
- package/README.md +133 -3
- package/bin/galbook.js +53 -0
- package/cordis.patch.yml +30 -0
- package/dsh-plugin/README.md +49 -0
- package/dsh-plugin/install.py +190 -0
- package/galbook/__init__.py +15 -0
- package/galbook/__main__.py +138 -0
- package/galbook/build.py +89 -0
- package/galbook/cli.py +29 -0
- package/galbook/demo/fog-harbor/assets/cg_dawn_together.svg +1 -0
- package/galbook/demo/fog-harbor/assets/cg_old_letter.svg +1 -0
- package/galbook/demo/fog-harbor/assets/cg_ship.svg +1 -0
- package/galbook/demo/fog-harbor/assets/harbor_night.svg +1 -0
- package/galbook/demo/fog-harbor/assets/lamp_room.svg +1 -0
- package/galbook/demo/fog-harbor/assets/lamp_room_dawn.svg +1 -0
- package/galbook/demo/fog-harbor/assets/path_tide.svg +1 -0
- package/galbook/demo/fog-harbor/assets/shio_normal.svg +1 -0
- package/galbook/demo/fog-harbor/assets/shio_smile.svg +1 -0
- package/galbook/demo/fog-harbor/assets/shio_surprised.svg +1 -0
- package/galbook/demo/fog-harbor/assets/warehouse.svg +1 -0
- package/galbook/demo/fog-harbor/story.gal +97 -0
- package/galbook/demo/fog-harbor-en/assets/cg_dawn_together.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/cg_old_letter.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/cg_ship.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/harbor_night.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/lamp_room.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/lamp_room_dawn.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/path_tide.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/shio_normal.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/shio_smile.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/shio_surprised.svg +1 -0
- package/galbook/demo/fog-harbor-en/assets/warehouse.svg +1 -0
- package/galbook/demo/fog-harbor-en/story.gal +97 -0
- package/galbook/demo_boot.py +63 -0
- package/galbook/engine.py +325 -0
- package/galbook/expr.py +107 -0
- package/galbook/i18n.py +85 -0
- package/galbook/importers/__init__.py +106 -0
- package/galbook/importers/common.py +94 -0
- package/galbook/importers/galscript.py +218 -0
- package/galbook/importers/kag.py +326 -0
- package/galbook/importers/legacy.py +122 -0
- package/galbook/importers/nscripter.py +314 -0
- package/galbook/importers/qlie.py +498 -0
- package/galbook/importers/renpy.py +333 -0
- package/galbook/importers/textdump.py +128 -0
- package/galbook/ir.py +266 -0
- package/galbook/mcp_server.py +102 -0
- package/galbook/play.py +374 -0
- package/galbook/server.py +199 -0
- package/galbook/static/play.html +151 -0
- package/galbook/store.py +160 -0
- package/galbook/tools.py +99 -0
- package/galbook/web.py +316 -0
- package/icon.svg +21 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- 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()
|