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,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()
|