Files
pgames-manager/backend/app/converter/nani_compiler.py
T

227 lines
14 KiB
Python

"""Compile the supported Naninovel command subset to the shared PGVN player."""
from collections import Counter
import hashlib
import re
from app.converter import nani_expr
from app.converter.compatibility import CompatibilityError, Issue
from app.converter.rpy_parser import ScriptData
def target(path, script: str) -> str:
if isinstance(path, dict):
if "template" in path or set(path) - {"name", "value"}:
raise ValueError("Динамический адрес перехода Naninovel пока не поддерживается")
return "nani:" + str(path.get("name") or script) + ("." + str(path["value"]) if path.get("value") else "")
if not isinstance(path, str) or "{" in path:
raise ValueError("Динамический адрес перехода Naninovel пока не поддерживается")
return "nani:" + (script + path if path.startswith(".") else path)
def compile_package(package) -> tuple[ScriptData, str, dict]:
data, expressions = ScriptData(), {}
counts, locations = Counter(), {}
def issue(message, location):
counts[message] += 1
locations.setdefault(message, location)
resource_sources = {}
for record in package.texts.values():
key = (record.get("collection"), record["key"], record["part"], record.get("occurrence", 0))
if key in resource_sources and resource_sources[key] != record["text"]:
issue(f"Неоднозначный исходный текст Naninovel: {key[0]}/{key[1]}", record["origin"])
resource_sources[key] = record["text"]
def text(value):
value = value.get("template", "") if isinstance(value, dict) else value
if not isinstance(value, str):
raise ValueError("Ожидался текст Naninovel")
for match in re.finditer(r"\{([^{}]+)\}", value):
expressions[match[1]] = nani_expr.parse(match[1])
# Rich text is parsed by the browser. Inline script commands must have
# been decoded as commands, rather than silently displayed as prose.
if re.search(r"\[(?:@|goto\b|set\b|wait\b|char\b|back\b)", value):
raise ValueError("Неподдерживаемая встроенная команда в тексте Naninovel")
for tag in re.findall(r"</?[A-Za-z][^<>]*>", value):
if not re.fullmatch(r"</?(?:b|i|u|s)>|<(?:color|size)=[^<>]+>|</(?:color|size)>|<br\s*/?>", tag, re.I):
raise ValueError(f"Не поддерживается текстовый тег Naninovel: {tag}")
return value
def static(value, name="параметр"):
if isinstance(value, dict) and "template" in value:
raise ValueError(f"Динамический {name} Naninovel пока не поддерживается")
return value
def expression(value):
ast = nani_expr.parse(static(value, "сценарное выражение"))
# PGVN uses Python truthiness; Naninovel also treats strings "false",
# "0" and "null" as false. Convert conditions inside the Nani evaluator.
return {"k": "nani", "ast": ["unary", "!", ["unary", "!", ast]]}
all_targets, all_names = [], set()
for script_name, script in package.scripts.items():
if (not re.fullmatch(r"[\w/.-]+", script_name) or script_name.startswith(("/", ".")) or
".." in script_name.split("/")):
issue("Некорректное имя сценария Naninovel", script_name)
continue
body, extras, stack = [], [], []
current = body
choice_count = 0
for line in script["lines"]:
location = f"{script_name}:{line['line']}"
if line.get("label"):
current.append({"op": "label", "name": f"nani:{script_name}.{line['label']}"})
for number, command in enumerate(line["commands"]):
op, args = command["op"], dict(command["args"])
identity = hashlib.sha256(f"{script_name}:{line['line']}:{number}".encode()).hexdigest()[:24]
try:
condition = args.pop("ConditionalExpression", None)
wait = args.pop("Wait", True)
if wait is not True and wait != 1:
raise ValueError("Асинхронные команды Naninovel (wait:false) пока не поддерживаются")
if op == "BeginIf":
node = {"op": "if", "branches": [{"c": expression(args.pop("Expression")), "body": []}]}
current.append(node)
stack.append((current, node))
current = node["branches"][0]["body"]
nodes = []
elif op in {"Else", "ElseIf"}:
if not stack or stack[-1][1]["branches"][-1]["c"] is None:
raise ValueError("Несогласованные ветки if/else Naninovel")
branch = {"c": expression(args.pop("Expression")) if op == "ElseIf" else None, "body": []}
stack[-1][1]["branches"].append(branch)
current = branch["body"]
nodes = []
elif op == "EndIf":
if not stack:
raise ValueError("EndIf без BeginIf")
current, _ = stack.pop()
nodes = []
elif op in {"PrintText", "AppendText"}:
author = text(args.pop("AuthorId", ""))
reset = args.pop("ResetPrinter", op != "AppendText")
interact = bool(args.pop("WaitForInput", True))
reveal = static(args.pop("RevealSpeed", 1), "RevealSpeed")
if reveal != 1:
raise ValueError("Изменение RevealSpeed Naninovel пока не поддерживается")
nodes = [{"op": "say", "what": text(args.pop("Text", "")), "tid": "nani-" + identity,
**({"whoStr": author} if reset else {"who": "extend"}), "interact": interact}]
elif op == "SetCustomVariable":
nodes = [{"op": "exec", "code": [nani_expr.assignment(static(args.pop("Expression"), "присваивание"))]}]
elif op in {"Goto", "Gosub"}:
destination = target(args.pop("Path"), script_name)
all_targets.append((destination, location))
nodes = [{"op": "jump" if op == "Goto" else "call", "target": destination}]
elif op == "Return":
nodes = [{"op": "return"}]
elif op == "AddChoice":
destination = args.pop("GotoPath", None)
actions = args.pop("OnSelected", [])
if actions:
raise ValueError("Обработчики OnSelected Naninovel пока не поддерживаются")
if not destination:
raise ValueError("Выбор Naninovel без GotoPath пока не поддерживается")
destination = target(destination, script_name)
all_targets.append((destination, location))
node = {"op": "nani_add_choice", "what": text(args.pop("ChoiceSummary", "")),
"tid": "nani-" + identity, "target": destination,
"handler": static(args.pop("HandlerId", ""))}
nodes = [node]
choice_count += 1
elif op == "Stop":
if not choice_count:
raise ValueError("Stop Naninovel требует внешнего интерфейса или механики игры")
nodes, choice_count = [{"op": "nani_menu"}], 0
elif op == "ClearChoiceHandler":
nodes = [{"op": "nani_clear_choice", "handler": static(args.pop("HandlerId", "*"))}]
elif op == "Wait":
mode = str(static(args.pop("WaitMode", "0"), "WaitMode"))
if mode.startswith("i"):
raise ValueError("Прерываемая пауза Naninovel пока не поддерживается")
nodes = [{"op": "pause", "d": {"k": "const", "v": float(mode)}}]
elif op == "WaitForInput":
nodes = [{"op": "pause", "d": None}]
elif op in {"HidePrinter", "ShowPrinter"}:
nodes = [{"op": "window", "mode": "hide" if op == "HidePrinter" else "show"}]
elif op in {"PlayBgm", "PlaySfx", "PlayVoice"}:
key = {"PlayBgm": "BgmPath", "PlaySfx": "SfxPath", "PlayVoice": "VoicePath"}[op]
path = static(args.pop(key), key)
nodes = [{"op": "play", "ch": {"PlayBgm": "music", "PlaySfx": "sound", "PlayVoice": "voice"}[op],
"files": [{"k": "const", "v": path}], "loop": args.pop("Loop", op == "PlayBgm")}]
elif op in {"StopBgm", "StopSfx"}:
nodes = [{"op": "stop", "ch": "music" if op == "StopBgm" else "sound"}]
elif op in {"ModifyBackground", "ModifyCharacter"}:
spec = static(args.pop("AppearanceAndTransition" if op == "ModifyBackground" else "IdAndAppearance"))
if not isinstance(spec, dict) or not spec.get("name"):
raise ValueError(f"Не поддерживается адрес изображения {op}")
actor = str(spec["name"])
appearance = spec.get("value")
if op == "ModifyBackground" and appearance:
raise ValueError("Специальный переход Naninovel пока не поддерживается")
image = actor if op == "ModifyBackground" else actor + "/" + str(appearance or "Default")
key = "nani-bg" if op == "ModifyBackground" else "nani-char-" + actor
name = key + " " + image
data.images[name] = {"file": image}
props = {"xalign": 0.5, "yalign": 1}
if "ScenePosition" in args:
position = static(args.pop("ScenePosition"), "ScenePosition")
if not isinstance(position, list) or len(position) != 2:
raise ValueError("Неподдерживаемая позиция Naninovel")
props = {"xalign": position[0] / 100, "yalign": 1 - position[1] / 100}
duration = float(static(args.pop("Duration", 0), "Duration"))
nodes = [{"op": "show", "as": key, "img": {"name": name.split(" ")},
"at": [{"props": props}], "zorder": {"k": "const", "v": 0 if op == "ModifyBackground" else 10},
"with": {"t": "dissolve", "d": duration} if duration else None}]
elif op == "HideAllCharacters":
duration = float(static(args.pop("Duration", 0), "Duration"))
# The player preserves backgrounds and other image layers.
nodes = [{"op": "nani_hide_characters", "duration": duration}]
elif op == "ExitToTitle":
nodes = [{"op": "nani_exit"}]
else:
raise ValueError(f"Не поддерживается команда Naninovel: {op}")
if args:
raise ValueError(f"Не поддерживаются параметры {op}: {', '.join(sorted(args))}")
if condition is not None:
if not nodes:
raise ValueError("Условие на структурной команде Naninovel")
nodes = [{"op": "if", "branches": [{"c": expression(condition), "body": nodes}]}]
current.extend(nodes)
except (ValueError, KeyError, TypeError) as exc:
issue(str(exc), location)
if stack:
issue("Не закрыт BeginIf Naninovel", script_name)
labels = [{"name": "nani:" + script_name, "body": body}, *extras]
for label in labels:
all_names.add(label["name"])
def names(nodes):
for node in nodes:
if node["op"] == "label":
all_names.add(node["name"])
for branch in node.get("branches", []):
names(branch["body"])
names(label["body"])
data.modules[script_name + ".nani"] = labels
for destination, location in all_targets:
if destination not in all_names:
issue(f"Не найдена цель Naninovel: {destination}", location)
entry = next(("nani:" + name for name in ("Opening", "Main", "Start") if name in package.scripts), None)
if entry is None:
issue("Не определена точка входа Naninovel (Opening/Main/Start)", "")
# Init may include external functions or engine UI setup. Do not discard it.
if "Init" in package.scripts:
data.init_code = []
issue("Инициализация Naninovel требует отдельного адаптера Init", "Init")
issues = package.issues + [Issue("naninovel_command", msg, locations[msg], count)
for msg, count in counts.items()]
if issues:
raise CompatibilityError("naninovel", issues)
resources = []
for tid, record in package.texts.items():
resources.append({"op": "say", "what": record["text"], "tid": tid,
"whoStr": record["origin"], "resource": record})
if resources:
data.modules["resources.nani"] = [{"name": "nani:resources", "body": resources}]
return data, entry, expressions