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,333 @@
|
|
|
1
|
+
"""Ren'Py(.rpy 源码)→ galbook/1。新游戏最常见的引擎之一;只吃 .rpy 源码(.rpyc 先用 unrpyc 反编译)。
|
|
2
|
+
|
|
3
|
+
认的写法:
|
|
4
|
+
define e = Character("名字", …) 说话人
|
|
5
|
+
image eileen happy = "eileen_happy.png" 图片名 → 文件
|
|
6
|
+
default x = 0 / define persistent… 初始变量(persistent.* = 系统变量 → s.*)
|
|
7
|
+
label name: 标签(全局,跨文件)
|
|
8
|
+
"旁白" / e "台词" / e happy "台词" / "名字" "台词"
|
|
9
|
+
menu: / menu name: (可选一句提问)+ "选项" [if 条件]: 块
|
|
10
|
+
jump x / call x / return / pass
|
|
11
|
+
$ x = 1 / $ persistent.y = True
|
|
12
|
+
if 条件: / elif 条件: / else:
|
|
13
|
+
scene bg room / show eileen happy [at left] / hide eileen / play music "x"
|
|
14
|
+
其它块(python:/init:/screen/transform/style/ATL)整块跳过。
|
|
15
|
+
"""
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
from .common import SceneB, Assets, read_text, is_h_title
|
|
20
|
+
|
|
21
|
+
STR = r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\''
|
|
22
|
+
SAY = re.compile(r"^(?:(%s)|([A-Za-z_]\w*)(?:\s+[A-Za-z_][\w\s]*?)?)\s+(%s)(?:\s+with\s+\w+)?\s*$" % (STR, STR))
|
|
23
|
+
NARR = re.compile(r"^(%s)(?:\s+with\s+\w+)?\s*$" % STR)
|
|
24
|
+
SKIP_BLOCK = re.compile(r"^(init\b.*|python\b.*|screen\b.*|transform\b.*|style\b.*|translate\b.*|image\s+[^=]+:\s*$|"
|
|
25
|
+
r"layeredimage\b.*|testcase\b.*)$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _unq(s):
|
|
29
|
+
s = s.strip()
|
|
30
|
+
if len(s) >= 2 and s[0] in "\"'" and s[-1] == s[0]:
|
|
31
|
+
s = s[1:-1]
|
|
32
|
+
return s.replace('\\"', '"').replace("\\'", "'").replace("\\n", "\n")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def conv_expr(e):
|
|
36
|
+
e = e.strip()
|
|
37
|
+
parts = re.split(r"(%s)" % STR, e)
|
|
38
|
+
out = []
|
|
39
|
+
for i, p in enumerate(parts):
|
|
40
|
+
if i % 2:
|
|
41
|
+
out.append(p)
|
|
42
|
+
continue
|
|
43
|
+
p = re.sub(r"\bpersistent\.(\w+)", r"s.\1", p)
|
|
44
|
+
p = re.sub(r"\bTrue\b", "true", p)
|
|
45
|
+
p = re.sub(r"\bFalse\b", "false", p)
|
|
46
|
+
p = re.sub(r"\bNone\b", "0", p)
|
|
47
|
+
|
|
48
|
+
def rep(m):
|
|
49
|
+
w = m.group(0)
|
|
50
|
+
if w in ("and", "or", "not", "true", "false", "s", "f"):
|
|
51
|
+
return w
|
|
52
|
+
return "f." + w
|
|
53
|
+
p = re.sub(r"(?<![\w.])(?!s\.)([A-Za-z_]\w*)(?![\w(])", rep, p)
|
|
54
|
+
p = p.replace("f.s.", "s.")
|
|
55
|
+
out.append(p)
|
|
56
|
+
return "".join(out)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _Line(object):
|
|
60
|
+
__slots__ = ("ind", "text", "no")
|
|
61
|
+
|
|
62
|
+
def __init__(self, ind, text, no):
|
|
63
|
+
self.ind, self.text, self.no = ind, text, no
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _lines(src):
|
|
67
|
+
out = []
|
|
68
|
+
for no, raw in enumerate(src.splitlines(), 1):
|
|
69
|
+
if not raw.strip():
|
|
70
|
+
continue
|
|
71
|
+
# 去掉行尾注释(不在字符串里的 #)
|
|
72
|
+
s, q, buf = raw.rstrip(), None, []
|
|
73
|
+
for ch in s:
|
|
74
|
+
if q:
|
|
75
|
+
buf.append(ch)
|
|
76
|
+
if ch == q:
|
|
77
|
+
q = None
|
|
78
|
+
elif ch in "\"'":
|
|
79
|
+
q = ch
|
|
80
|
+
buf.append(ch)
|
|
81
|
+
elif ch == "#":
|
|
82
|
+
break
|
|
83
|
+
else:
|
|
84
|
+
buf.append(ch)
|
|
85
|
+
t = "".join(buf).rstrip()
|
|
86
|
+
if not t.strip():
|
|
87
|
+
continue
|
|
88
|
+
ind = len(t) - len(t.lstrip(" \t").replace("\t", " ")) if "\t" in t else len(t) - len(t.lstrip())
|
|
89
|
+
out.append(_Line(ind, t.strip(), no))
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Importer(object):
|
|
94
|
+
def __init__(self, src, assets=None, entry="start"):
|
|
95
|
+
self.src, self.assets, self.entry = src, assets or Assets(), entry
|
|
96
|
+
self.chars, self.images, self.defaults = {}, {}, []
|
|
97
|
+
self.label_scene = {}
|
|
98
|
+
self.scenes = {}
|
|
99
|
+
self.warnings = []
|
|
100
|
+
self.unknown = {}
|
|
101
|
+
self.stats = {"files": 0, "says": 0, "choices": 0, "options": 0, "jumps": 0, "calls": 0, "sets": 0, "ifs": 0}
|
|
102
|
+
self.system_vars = set()
|
|
103
|
+
self.n = 0
|
|
104
|
+
|
|
105
|
+
def build(self):
|
|
106
|
+
files = []
|
|
107
|
+
for dp, _, fns in os.walk(self.src):
|
|
108
|
+
for fn in sorted(fns):
|
|
109
|
+
if fn.endswith(".rpy"):
|
|
110
|
+
files.append(os.path.join(dp, fn))
|
|
111
|
+
if not files:
|
|
112
|
+
raise ValueError("没找到 .rpy")
|
|
113
|
+
parsed = [(os.path.splitext(os.path.relpath(p, self.src))[0].replace(os.sep, "/"), _lines(read_text(p))) for p in files]
|
|
114
|
+
for sid, ls in parsed: # 第一遍:角色、图片、默认值、标签归属
|
|
115
|
+
for ln in ls:
|
|
116
|
+
if ln.ind == 0:
|
|
117
|
+
self._decl(ln.text, sid)
|
|
118
|
+
for sid, ls in parsed:
|
|
119
|
+
self.stats["files"] += 1
|
|
120
|
+
b = SceneB(sid, sid, tags=["h"] if is_h_title(sid) else [])
|
|
121
|
+
self._block(b, ls, 0, len(ls), top=True)
|
|
122
|
+
b.add("end", kind="title", name="%s 走到文件末尾(Ren'Py 回到主菜单)" % sid)
|
|
123
|
+
self.scenes[sid] = b.data()
|
|
124
|
+
if self.entry not in self.label_scene:
|
|
125
|
+
raise ValueError("没有入口标签 label %s" % self.entry)
|
|
126
|
+
st = SceneB("__start__", "开始", tags=["system"])
|
|
127
|
+
st.add("label", name="top")
|
|
128
|
+
for name, val in self.defaults:
|
|
129
|
+
st.add("set", expr="%s = %s" % (name, val))
|
|
130
|
+
st.add("jump", scene=self.label_scene[self.entry], label=self.entry)
|
|
131
|
+
self.scenes["__start__"] = st.data()
|
|
132
|
+
meta = {"format": "galbook/1", "title": "", "engine": "renpy", "importer": "galbook-renpy-1",
|
|
133
|
+
"entry": {"scene": "__start__", "label": "top"},
|
|
134
|
+
"scenes": {k: {"title": v["title"], "tags": v["tags"]} for k, v in self.scenes.items()},
|
|
135
|
+
"routes": [], "system_vars": sorted(self.system_vars),
|
|
136
|
+
"stats": dict(self.stats, unknown=sorted(self.unknown.items(), key=lambda x: -x[1])[:20],
|
|
137
|
+
assets=self.assets.report()),
|
|
138
|
+
"warnings": self.warnings[:200]}
|
|
139
|
+
return meta, self.scenes
|
|
140
|
+
|
|
141
|
+
def _decl(self, t, sid):
|
|
142
|
+
m = re.match(r"^define\s+([A-Za-z_]\w*)\s*=\s*(?:Character|DynamicCharacter)\s*\(\s*(?:_\()?\s*(%s|None)" % STR, t)
|
|
143
|
+
if m:
|
|
144
|
+
self.chars[m.group(1)] = "" if m.group(2) == "None" else _unq(m.group(2))
|
|
145
|
+
return
|
|
146
|
+
m = re.match(r"^image\s+([^=]+?)\s*=\s*(%s)" % STR, t)
|
|
147
|
+
if m:
|
|
148
|
+
self.images[m.group(1).strip()] = _unq(m.group(2))
|
|
149
|
+
return
|
|
150
|
+
m = re.match(r"^(?:default|define)\s+(persistent\.)?([A-Za-z_]\w*)\s*=\s*(.+)$", t)
|
|
151
|
+
if m and "Character" not in t:
|
|
152
|
+
scope = "s." if m.group(1) else "f."
|
|
153
|
+
try:
|
|
154
|
+
val = conv_expr(m.group(3))
|
|
155
|
+
if scope == "s.":
|
|
156
|
+
self.system_vars.add(m.group(2))
|
|
157
|
+
val = "%s%s == 0 && %s || %s%s" % (scope, m.group(2), val, scope, m.group(2)) if False else val
|
|
158
|
+
if scope == "f.":
|
|
159
|
+
self.defaults.append((scope + m.group(2), val))
|
|
160
|
+
except Exception: # noqa
|
|
161
|
+
pass
|
|
162
|
+
return
|
|
163
|
+
m = re.match(r"^label\s+([A-Za-z_][\w.]*)\s*(\(.*\))?\s*:", t)
|
|
164
|
+
if m:
|
|
165
|
+
self.label_scene.setdefault(m.group(1), sid)
|
|
166
|
+
|
|
167
|
+
def _img(self, name):
|
|
168
|
+
name = name.strip()
|
|
169
|
+
f = self.images.get(name)
|
|
170
|
+
if f:
|
|
171
|
+
return self.assets.find(f) or f
|
|
172
|
+
return self.assets.find(name.replace(" ", "_")) or self.assets.find(name) or ("image:" + name)
|
|
173
|
+
|
|
174
|
+
def _end_of(self, ls, i, j):
|
|
175
|
+
ind = ls[i].ind
|
|
176
|
+
k = i + 1
|
|
177
|
+
while k < j and ls[k].ind > ind:
|
|
178
|
+
k += 1
|
|
179
|
+
return k
|
|
180
|
+
|
|
181
|
+
def _lab(self, kind):
|
|
182
|
+
self.n += 1
|
|
183
|
+
return "__%s%d" % (kind, self.n)
|
|
184
|
+
|
|
185
|
+
def _block(self, b, ls, i, j, top=False):
|
|
186
|
+
while i < j:
|
|
187
|
+
ln = ls[i]
|
|
188
|
+
t = ln.text
|
|
189
|
+
end = self._end_of(ls, i, j)
|
|
190
|
+
if top and ln.ind == 0 and not t.startswith("label "):
|
|
191
|
+
i = end
|
|
192
|
+
continue
|
|
193
|
+
m = re.match(r"^label\s+([A-Za-z_][\w.]*)", t)
|
|
194
|
+
if m:
|
|
195
|
+
b.add("label", name=m.group(1))
|
|
196
|
+
self._block(b, ls, i + 1, end)
|
|
197
|
+
i = end
|
|
198
|
+
continue
|
|
199
|
+
if SKIP_BLOCK.match(t) or t.startswith("python:") or t.startswith("$ python"):
|
|
200
|
+
i = end
|
|
201
|
+
continue
|
|
202
|
+
if re.match(r"^menu(\s+[A-Za-z_]\w*)?\s*:", t):
|
|
203
|
+
self._menu(b, ls, i, end)
|
|
204
|
+
i = end
|
|
205
|
+
continue
|
|
206
|
+
m = re.match(r"^(if|elif|else)\b\s*(.*?)\s*:$", t)
|
|
207
|
+
if m and m.group(1) == "if":
|
|
208
|
+
k = i
|
|
209
|
+
done = self._lab("ifend")
|
|
210
|
+
while k < j:
|
|
211
|
+
mm = re.match(r"^(if|elif|else)\b\s*(.*?)\s*:$", ls[k].text)
|
|
212
|
+
if not mm or ls[k].ind != ln.ind or (k != i and mm.group(1) == "if"):
|
|
213
|
+
break
|
|
214
|
+
e2 = self._end_of(ls, k, j)
|
|
215
|
+
nxt = self._lab("ifnext")
|
|
216
|
+
if mm.group(1) != "else":
|
|
217
|
+
b.add("jump", label=nxt, **{"if": "!(%s)" % conv_expr(mm.group(2))})
|
|
218
|
+
self.stats["ifs"] += 1
|
|
219
|
+
self._block(b, ls, k + 1, e2)
|
|
220
|
+
b.add("jump", label=done)
|
|
221
|
+
b.add("label", name=nxt)
|
|
222
|
+
k = e2
|
|
223
|
+
b.add("label", name=done)
|
|
224
|
+
i = k
|
|
225
|
+
continue
|
|
226
|
+
self._stmt(b, t)
|
|
227
|
+
i = end if end > i + 1 and t.endswith(":") else i + 1
|
|
228
|
+
|
|
229
|
+
def _menu(self, b, ls, i, end):
|
|
230
|
+
prompt = ""
|
|
231
|
+
opts = []
|
|
232
|
+
done = self._lab("menuend")
|
|
233
|
+
k = i + 1
|
|
234
|
+
bodies = []
|
|
235
|
+
while k < end:
|
|
236
|
+
t = ls[k].text
|
|
237
|
+
e2 = self._end_of(ls, k, end)
|
|
238
|
+
m = re.match(r"^(%s)\s*(?:if\s+(.+?))?\s*:$" % STR, t)
|
|
239
|
+
if m:
|
|
240
|
+
lb = self._lab("opt")
|
|
241
|
+
o = {"text": _unq(m.group(1)), "goto": {"label": lb}}
|
|
242
|
+
if m.group(2):
|
|
243
|
+
o["if"] = conv_expr(m.group(2))
|
|
244
|
+
opts.append(o)
|
|
245
|
+
bodies.append((lb, k + 1, e2))
|
|
246
|
+
elif SAY.match(t) or NARR.match(t):
|
|
247
|
+
self._stmt(b, t) # 菜单的提问句照常显示
|
|
248
|
+
prompt = _unq((SAY.match(t) or NARR.match(t)).group(3 if SAY.match(t) else 1))
|
|
249
|
+
elif t.startswith("set "):
|
|
250
|
+
pass
|
|
251
|
+
else:
|
|
252
|
+
self.unknown["menu:" + t.split()[0]] = self.unknown.get("menu:" + t.split()[0], 0) + 1
|
|
253
|
+
k = e2
|
|
254
|
+
b.add("choice", id=self._lab("menu"), prompt=prompt, options=opts)
|
|
255
|
+
self.stats["choices"] += 1
|
|
256
|
+
self.stats["options"] += len(opts)
|
|
257
|
+
for lb, s, e in bodies:
|
|
258
|
+
b.add("label", name=lb)
|
|
259
|
+
self._block(b, ls, s, e)
|
|
260
|
+
b.add("jump", label=done)
|
|
261
|
+
b.add("label", name=done)
|
|
262
|
+
|
|
263
|
+
def _stmt(self, b, t):
|
|
264
|
+
m = NARR.match(t)
|
|
265
|
+
if m:
|
|
266
|
+
b.add("say", who="", text=_unq(m.group(1)))
|
|
267
|
+
self.stats["says"] += 1
|
|
268
|
+
return
|
|
269
|
+
m = SAY.match(t)
|
|
270
|
+
if m and (m.group(1) or m.group(2) in self.chars or m.group(2) not in
|
|
271
|
+
("scene", "show", "hide", "play", "stop", "queue", "voice", "jump", "call", "return", "with", "window", "pause", "nvl")):
|
|
272
|
+
who = _unq(m.group(1)) if m.group(1) else self.chars.get(m.group(2), m.group(2))
|
|
273
|
+
b.add("say", who=who, text=_unq(m.group(3)))
|
|
274
|
+
self.stats["says"] += 1
|
|
275
|
+
return
|
|
276
|
+
w = t.split()
|
|
277
|
+
k = w[0] if w else ""
|
|
278
|
+
if k == "jump":
|
|
279
|
+
if w[1] == "expression":
|
|
280
|
+
self.warnings.append("jump expression 动态跳转没法静态解析:%s" % t)
|
|
281
|
+
return
|
|
282
|
+
sc = self.label_scene.get(w[1])
|
|
283
|
+
if sc is None:
|
|
284
|
+
self.warnings.append("跳到不存在的标签 %s" % w[1])
|
|
285
|
+
return
|
|
286
|
+
b.add("jump", scene=None if sc == b.id else sc, label=w[1])
|
|
287
|
+
self.stats["jumps"] += 1
|
|
288
|
+
elif k == "call":
|
|
289
|
+
if len(w) > 1 and w[1] != "screen" and w[1] in self.label_scene:
|
|
290
|
+
sc = self.label_scene[w[1]]
|
|
291
|
+
b.add("call", scene=None if sc == b.id else sc, label=w[1])
|
|
292
|
+
self.stats["calls"] += 1
|
|
293
|
+
elif k == "return":
|
|
294
|
+
b.add("ret")
|
|
295
|
+
b.add("end", kind="title", how="return(回到主菜单)")
|
|
296
|
+
elif k == "$":
|
|
297
|
+
e = t[1:].strip()
|
|
298
|
+
m2 = re.match(r"^(persistent\.)?([A-Za-z_]\w*)\s*(\+=|-=|=)\s*(.+)$", e)
|
|
299
|
+
if m2:
|
|
300
|
+
scope = "s." if m2.group(1) else "f."
|
|
301
|
+
if scope == "s.":
|
|
302
|
+
self.system_vars.add(m2.group(2))
|
|
303
|
+
b.add("set", expr="%s%s %s %s" % (scope, m2.group(2), m2.group(3), conv_expr(m2.group(4))))
|
|
304
|
+
self.stats["sets"] += 1
|
|
305
|
+
else:
|
|
306
|
+
self.unknown["$"] = self.unknown.get("$", 0) + 1
|
|
307
|
+
elif k == "scene":
|
|
308
|
+
nm = re.sub(r"\s+(with|at|onlayer|behind|as)\b.*$", "", t[5:].strip())
|
|
309
|
+
b.add("sprites_clear")
|
|
310
|
+
b.add("bg", img=self._img(nm) if nm else None)
|
|
311
|
+
elif k == "show":
|
|
312
|
+
nm = re.sub(r"\s+(with|at|onlayer|behind|zorder|as)\b.*$", "", t[4:].strip())
|
|
313
|
+
pos = re.search(r"\bat\s+(\w+)", t)
|
|
314
|
+
if nm.startswith("bg "):
|
|
315
|
+
b.add("bg", img=self._img(nm))
|
|
316
|
+
elif nm and not nm.startswith("screen"):
|
|
317
|
+
b.add("sprite", name=nm.split()[0], img=self._img(nm), pos=pos.group(1) if pos else "center")
|
|
318
|
+
elif k == "hide":
|
|
319
|
+
if len(w) > 1:
|
|
320
|
+
b.add("sprite", name=w[1], img=None)
|
|
321
|
+
elif k == "play" and len(w) > 2 and w[1] == "music":
|
|
322
|
+
b.add("bgm", name=_unq(" ".join(w[2:]).split(" fade")[0]))
|
|
323
|
+
elif k == "stop" and len(w) > 1 and w[1] == "music":
|
|
324
|
+
b.add("bgm", name=None)
|
|
325
|
+
elif k in ("pass", "with", "pause", "window", "voice", "play", "stop", "queue", "nvl", "centered", "extend",
|
|
326
|
+
"define", "default", "image", "init", "python", "camera", "renpy", "show_screen"):
|
|
327
|
+
pass
|
|
328
|
+
else:
|
|
329
|
+
self.unknown[k] = self.unknown.get(k, 0) + 1
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def build(src, assets=None, entry="start", **_):
|
|
333
|
+
return Importer(src, assets, entry or "start").build()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""文本导出(线性)→ galbook/1:给「暂时没有专用导入器」的引擎兜底。
|
|
2
|
+
|
|
3
|
+
很多引擎已经有成熟的文本抽取工具,抽出来的东西能直接喂进来,先「能读」:
|
|
4
|
+
* VNTextPatch(arcusmaximus,MIT)`extractlocal <游戏脚本目录> <输出目录>` 产出的 .json
|
|
5
|
+
格式:[{"name": "灯", "message": "「…」"}, {"names": ["A","B"], "message": "…"}, {"message": "旁白"}]
|
|
6
|
+
它支持 AdvHD / Artemis / BGI(Ethornell) / CatSystem2 / Majiro / Musica / RealLive / Softpal /
|
|
7
|
+
SystemNNN / YU-RIS / Silky's 等 20 多种引擎。
|
|
8
|
+
* 任意 .csv / .tsv:表头里有 name/speaker/character/who/名前/名字 之一 + text/message/line/original/原文/台词 之一
|
|
9
|
+
* .txt:一行一句,「名字:台词」「名字「台词」」或旁白(要 --engine textdump 显式指定,避免和 NScripter 0.txt 混)
|
|
10
|
+
|
|
11
|
+
限制(如实说):这些工具只抽文字,**不抽选项的跳转关系**(选项文字会混在台词里),所以导出来是一条直线,mode=linear。
|
|
12
|
+
要真分支,需要该引擎的专用导入器(见 docs/IMPORTERS.md,欢迎贡献)。
|
|
13
|
+
文件顺序:目录里有 order.txt 就按它(一行一个文件名),否则按文件名自然排序。"""
|
|
14
|
+
import csv
|
|
15
|
+
import io
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
from .common import SceneB, Assets, read_text
|
|
21
|
+
|
|
22
|
+
NAME = "textdump"
|
|
23
|
+
NAME_KEYS = ("name", "speaker", "character", "chara", "who", "名前", "名字", "角色", "说话人")
|
|
24
|
+
TEXT_KEYS = ("message", "text", "line", "original", "source", "原文", "台词", "本文", "セリフ")
|
|
25
|
+
SAY = re.compile(r"^([^\s::「『。,、!?!?,…—]{1,12})(?::|:\s)\s*(.+)$")
|
|
26
|
+
SAY_Q = re.compile(r"^([^\s::「『((。,、!?!?,…—]{1,12})([「『].*)$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _natkey(s):
|
|
30
|
+
return [int(x) if x.isdigit() else x.lower() for x in re.split(r"(\d+)", s)]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_vntp_json(path):
|
|
34
|
+
try:
|
|
35
|
+
with open(path, encoding="utf-8-sig") as f:
|
|
36
|
+
d = json.load(f)
|
|
37
|
+
return isinstance(d, list) and (not d or (isinstance(d[0], dict) and "message" in d[0]))
|
|
38
|
+
except Exception: # noqa
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def detect(src):
|
|
43
|
+
js = []
|
|
44
|
+
for dp, _, fn in os.walk(src):
|
|
45
|
+
js += [os.path.join(dp, f) for f in fn if f.lower().endswith(".json")]
|
|
46
|
+
for f in fn:
|
|
47
|
+
if f.lower().endswith((".csv", ".tsv")):
|
|
48
|
+
head = read_text(os.path.join(dp, f)).splitlines()[:1]
|
|
49
|
+
if head and any(k in head[0].lower() for k in TEXT_KEYS):
|
|
50
|
+
return True, "有带表头的台词表(.csv/.tsv):按线性文本导入"
|
|
51
|
+
if js and any(_is_vntp_json(p) for p in js[:5]):
|
|
52
|
+
return True, "有 VNTextPatch 格式的 .json 文本导出:按线性文本导入(没有分支)"
|
|
53
|
+
return False, ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _rows(path):
|
|
57
|
+
low = path.lower()
|
|
58
|
+
if low.endswith(".json"):
|
|
59
|
+
with open(path, encoding="utf-8-sig") as f:
|
|
60
|
+
d = json.load(f)
|
|
61
|
+
if not isinstance(d, list):
|
|
62
|
+
raise ValueError("%s 不是 JSON 数组" % path)
|
|
63
|
+
for e in d:
|
|
64
|
+
if isinstance(e, dict):
|
|
65
|
+
who = e.get("name") or "・".join(e.get("names") or []) or ""
|
|
66
|
+
yield who, e.get("message") or ""
|
|
67
|
+
elif isinstance(e, str):
|
|
68
|
+
yield "", e
|
|
69
|
+
elif low.endswith((".csv", ".tsv")):
|
|
70
|
+
txt = read_text(path)
|
|
71
|
+
rd = csv.reader(io.StringIO(txt), delimiter="\t" if low.endswith(".tsv") else ",")
|
|
72
|
+
head = [h.strip().lower() for h in next(rd, [])]
|
|
73
|
+
ni = next((head.index(k) for k in NAME_KEYS if k in head), None)
|
|
74
|
+
ti = next((head.index(k) for k in TEXT_KEYS if k in head), None)
|
|
75
|
+
if ti is None:
|
|
76
|
+
raise ValueError("%s 表头里找不到台词列(认:%s)" % (path, "/".join(TEXT_KEYS)))
|
|
77
|
+
for row in rd:
|
|
78
|
+
if len(row) > ti:
|
|
79
|
+
yield (row[ni].strip() if ni is not None and len(row) > ni else ""), row[ti]
|
|
80
|
+
else:
|
|
81
|
+
for line in read_text(path).splitlines():
|
|
82
|
+
s = line.strip()
|
|
83
|
+
if not s:
|
|
84
|
+
continue
|
|
85
|
+
m = SAY.match(s) or SAY_Q.match(s)
|
|
86
|
+
yield (m.group(1), m.group(2)) if m else ("", s)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build(src, assets=None, **_):
|
|
90
|
+
assets = assets or Assets()
|
|
91
|
+
exts = (".json", ".csv", ".tsv", ".txt")
|
|
92
|
+
files = []
|
|
93
|
+
for dp, _, fn in os.walk(src):
|
|
94
|
+
files += [os.path.relpath(os.path.join(dp, f), src) for f in fn if f.lower().endswith(exts) and f != "order.txt"]
|
|
95
|
+
op = os.path.join(src, "order.txt")
|
|
96
|
+
if os.path.isfile(op):
|
|
97
|
+
want = [l.strip() for l in read_text(op).splitlines() if l.strip()]
|
|
98
|
+
missing = [w for w in want if w not in files]
|
|
99
|
+
if missing:
|
|
100
|
+
raise ValueError("order.txt 里列的文件不存在:%s" % "、".join(missing[:5]))
|
|
101
|
+
files = want
|
|
102
|
+
else:
|
|
103
|
+
files.sort(key=_natkey)
|
|
104
|
+
if not files:
|
|
105
|
+
raise ValueError("%s 里没有 .json/.csv/.tsv/.txt 文本" % src)
|
|
106
|
+
scenes, order, stats = {}, [], {"files": len(files), "says": 0, "choices": 0, "options": 0, "jumps": 0}
|
|
107
|
+
for rel in files:
|
|
108
|
+
sid = re.sub(r"[\\/]", "__", os.path.splitext(rel)[0])
|
|
109
|
+
b = SceneB(sid, os.path.splitext(os.path.basename(rel))[0])
|
|
110
|
+
b.add("label", name="top")
|
|
111
|
+
for who, text in _rows(os.path.join(src, rel)):
|
|
112
|
+
text = (text or "").replace("\r\n", "\n").strip()
|
|
113
|
+
if text:
|
|
114
|
+
b.add("say", who=who.strip(), text=text)
|
|
115
|
+
stats["says"] += 1
|
|
116
|
+
scenes[sid] = b
|
|
117
|
+
order.append(sid)
|
|
118
|
+
for a, c in zip(order, order[1:]):
|
|
119
|
+
scenes[a].add("jump", scene=c, label="top")
|
|
120
|
+
stats["jumps"] += 1
|
|
121
|
+
scenes[order[-1]].add("end", kind="ending", name="全文完")
|
|
122
|
+
data = {k: v.data() for k, v in scenes.items()}
|
|
123
|
+
meta = {"format": "galbook/1", "title": "", "engine": "textdump", "importer": "galbook-textdump-1", "mode": "linear",
|
|
124
|
+
"entry": {"scene": order[0], "label": "top"},
|
|
125
|
+
"scenes": {k: {"title": v["title"], "tags": v["tags"]} for k, v in data.items()},
|
|
126
|
+
"routes": [], "system_vars": [], "stats": dict(stats, unknown=[], assets=assets.report()),
|
|
127
|
+
"warnings": ["线性文本:原作的选项文字(如果有)混在台词里,没有分支"]}
|
|
128
|
+
return meta, data
|