Skip to content

Commit b186aec

Browse files
basic renpy integration (#26)
* basic renpy integration * fix issue
1 parent df4e070 commit b186aec

File tree

8 files changed

+243
-23
lines changed

8 files changed

+243
-23
lines changed

src/preppipe/appdir.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# SPDX-FileCopyrightText: 2022 PrepPipe's Contributors
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""可执行/基础目录的解析与覆盖,供设置文件、Ren'Py SDK 等路径统一使用。"""
5+
6+
import os
7+
import sys
8+
9+
10+
def _compute_executable_base_dir() -> str:
11+
"""打包运行时为可执行文件所在目录,否则为 preppipe 包所在目录。"""
12+
if getattr(sys, 'frozen', False):
13+
return os.path.dirname(sys.executable)
14+
return os.path.dirname(os.path.abspath(__file__))
15+
16+
17+
_executable_base_dir: str = _compute_executable_base_dir()
18+
19+
20+
def get_executable_base_dir() -> str:
21+
"""返回当前认定的「可执行/基础」目录。"""
22+
return _executable_base_dir
23+
24+
25+
def set_executable_base_dir(path: str) -> None:
26+
"""在未打包环境下覆盖基础目录(如 GUI 启动时指定设置目录)。打包后调用无效。"""
27+
global _executable_base_dir
28+
if getattr(sys, 'frozen', False):
29+
return
30+
_executable_base_dir = os.path.abspath(path)
31+
if not os.path.isdir(_executable_base_dir):
32+
raise FileNotFoundError(f"Path '{_executable_base_dir}' does not exist")

src/preppipe/renpy/passes.py

Lines changed: 111 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,118 @@
11
# SPDX-FileCopyrightText: 2023 PrepPipe's Contributors
22
# SPDX-License-Identifier: Apache-2.0
33

4+
import os
5+
import subprocess
6+
import sys
7+
import shutil
48
from preppipe.irbase import Operation, typing
9+
from preppipe.appdir import get_executable_base_dir
510
from .ast import *
611
from ..pipeline import *
712
from ..irbase import *
13+
from ..exceptions import PPInternalError
814
from .export import export_renpy
915
from .codegen import codegen_renpy
1016
from ..vnmodel import VNModel
11-
import shutil
17+
18+
19+
def _find_renpy_sdk() -> str | None:
20+
"""查找内嵌的 Ren'Py SDK 目录(含 renpy.py 的 renpy-sdk 目录)。"""
21+
env_path = os.environ.get('PREPPIPE_RENPY_SDK')
22+
if env_path and os.path.isdir(env_path) and os.path.isfile(os.path.join(env_path, 'renpy.py')):
23+
return os.path.abspath(env_path)
24+
base = get_executable_base_dir()
25+
for dir_ in (base, os.path.dirname(base), os.path.dirname(os.path.dirname(base)), '.'):
26+
candidate = os.path.join(dir_, 'renpy-sdk') if dir_ != '.' else os.path.abspath('renpy-sdk')
27+
if dir_ != '.':
28+
candidate = os.path.abspath(candidate)
29+
if os.path.isdir(candidate) and os.path.isfile(os.path.join(candidate, 'renpy.py')):
30+
return candidate
31+
return None
32+
33+
34+
def _get_renpy_python_exe(sdk_dir: str) -> str:
35+
"""返回 Ren'Py SDK 内嵌的 Python 解释器路径。"""
36+
system = sys.platform
37+
if system == 'win32':
38+
lib_python = os.path.join(sdk_dir, 'lib', 'py3-windows-x86_64', 'python.exe')
39+
elif system == 'darwin':
40+
lib_python = os.path.join(sdk_dir, 'lib', 'py3-darwin-x86_64', 'python')
41+
if not os.path.isfile(lib_python):
42+
lib_python = os.path.join(sdk_dir, 'lib', 'py3-darwin-arm64', 'python')
43+
else:
44+
lib_python = os.path.join(sdk_dir, 'lib', 'py3-linux-x86_64', 'python')
45+
if not os.path.isfile(lib_python):
46+
raise PPInternalError('Ren\'Py SDK 中未找到对应平台的 Python: ' + lib_python)
47+
return lib_python
48+
49+
50+
def _renpy_launcher_language_from_env() -> str | None:
51+
"""根据 PREPPIPE_LANGUAGE 返回 Ren'Py launcher 的 --language 值。仅处理简中/繁中,其他不传(默认英语)。"""
52+
lang = (os.environ.get('PREPPIPE_LANGUAGE') or '').strip().lower()
53+
if lang in ('zh_cn', 'schinese'):
54+
return 'schinese'
55+
if lang in ('zh_hk', 'tchinese', 'zh-tw'):
56+
return 'tchinese'
57+
return None
58+
59+
60+
def _ensure_renpy_project_generated(game_dir: str, language: str | None = None) -> None:
61+
"""
62+
若 game_dir 下尚无完整 Ren'Py 工程(无 gui.rpy),则使用内嵌 SDK 生成空工程并生成 GUI 图片。
63+
game_dir 为工程下的 game 目录(即输出目录);其父目录为工程根。
64+
language 为 None 时不传 --language,由 Ren'Py 使用默认(英语)。
65+
"""
66+
gui_rpy = os.path.join(game_dir, 'gui.rpy')
67+
if os.path.isfile(gui_rpy):
68+
return
69+
sdk_dir = _find_renpy_sdk()
70+
if not sdk_dir:
71+
raise PPInternalError(
72+
'输出目录下未检测到 Ren\'Py 工程(无 gui.rpy),且未找到 Ren\'Py SDK。'
73+
'请设置环境变量 PREPPIPE_RENPY_SDK 或将 SDK 解压到 renpy-sdk 目录。'
74+
)
75+
project_root = os.path.dirname(game_dir)
76+
os.makedirs(game_dir, exist_ok=True)
77+
python_exe = _get_renpy_python_exe(sdk_dir)
78+
renpy_py = os.path.join(sdk_dir, 'renpy.py')
79+
cmd_generate = [
80+
python_exe, renpy_py, 'launcher', 'generate_gui',
81+
os.path.abspath(project_root), '--start',
82+
]
83+
if language is not None:
84+
cmd_generate.extend(('--language', language))
85+
subprocess.run(cmd_generate, cwd=sdk_dir, check=True)
86+
cmd_gui_images = [python_exe, renpy_py, os.path.abspath(project_root), 'gui_images']
87+
subprocess.run(cmd_gui_images, cwd=sdk_dir, check=True)
88+
89+
90+
def run_renpy_project(project_root: str, sdk_dir: str | None = None) -> None:
91+
"""
92+
使用内嵌 Ren'Py SDK 运行指定工程(不等待进程结束)。
93+
project_root 为工程根目录,其下应包含 game 目录。
94+
sdk_dir 若提供则优先使用(如 GUI 设置中的默认路径),否则按环境变量与默认目录查找。
95+
供 GUI「运行项目」等调用。
96+
"""
97+
if sdk_dir and os.path.isdir(sdk_dir) and os.path.isfile(os.path.join(sdk_dir, 'renpy.py')):
98+
pass
99+
else:
100+
sdk_dir = _find_renpy_sdk()
101+
if not sdk_dir:
102+
raise PPInternalError(
103+
'未找到 Ren\'Py SDK,无法运行项目。'
104+
'请设置环境变量 PREPPIPE_RENPY_SDK 或将 SDK 解压到 renpy-sdk 目录。'
105+
)
106+
python_exe = _get_renpy_python_exe(sdk_dir)
107+
renpy_py = os.path.join(sdk_dir, 'renpy.py')
108+
abs_root = os.path.abspath(project_root)
109+
subprocess.Popen(
110+
[python_exe, renpy_py, abs_root],
111+
cwd=sdk_dir,
112+
stdin=subprocess.DEVNULL,
113+
stdout=subprocess.DEVNULL,
114+
stderr=subprocess.DEVNULL,
115+
)
12116

13117
@FrontendDecl('test-renpy-build', input_decl=IODecl(description='<No Input>', nargs=0), output_decl=RenPyModel)
14118
class _TestVNModelBuild(TransformBase):
@@ -76,17 +180,19 @@ def handle_arguments(args : argparse.Namespace):
76180
_RenPyExport._template_dir = _RenPyExport._template_dir[0]
77181
assert isinstance(_RenPyExport._template_dir, str)
78182
if len(_RenPyExport._template_dir) > 0 and not os.path.isdir(_RenPyExport._template_dir):
79-
raise RuntimeError('--renpy-export-templatedir: input "' + _RenPyExport._template_dir + '" is not a valid path')
183+
raise PPInternalError('--renpy-export-templatedir: input "' + _RenPyExport._template_dir + '" is not a valid path')
80184

81185
def run(self) -> None:
82186
if len(self._inputs) == 0:
83187
return None
84188
if len(self._inputs) > 1:
85-
raise RuntimeError("renpy-export: exporting multiple input IR is not supported")
189+
raise PPInternalError("renpy-export: exporting multiple input IR is not supported")
86190
out_path = self.output
87191
if os.path.exists(out_path):
88192
if not os.path.isdir(out_path):
89-
raise RuntimeError("renpy-export: exporting to non-directory path: " + out_path)
193+
raise PPInternalError("renpy-export: exporting to non-directory path: " + out_path)
194+
# 若输出目录尚无完整 Ren'Py 工程(无 gui.rpy),则用内嵌 SDK 先生成空工程与 GUI 图片
195+
_ensure_renpy_project_generated(out_path, _renpy_launcher_language_from_env())
90196
return export_renpy(self.inputs[0], out_path, _RenPyExport._template_dir)
91197

92198
@MiddleEndDecl('renpy-codegen', input_decl=VNModel, output_decl=RenPyModel)
@@ -95,6 +201,6 @@ def run(self) -> RenPyModel | None:
95201
if len(self._inputs) == 0:
96202
return None
97203
if len(self._inputs) > 1:
98-
raise RuntimeError("renpy-codegen: exporting multiple input IR is not supported")
204+
raise PPInternalError("renpy-codegen: exporting multiple input IR is not supported")
99205
return codegen_renpy(self._inputs[0])
100206
pass

src/preppipe_gui_pyside6/execution.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class ExecutionInfo:
3333
unspecified_paths: dict[int, UnspecifiedPathInfo] = dataclasses.field(default_factory=dict)
3434
specified_outputs: list[SpecifiedOutputInfo] = dataclasses.field(default_factory=list)
3535
enable_debug_dump: bool = False
36+
is_renpy_export: bool = False # 为 True 时,主输出为 Ren'Py 工程 game 目录,执行界面显示「运行项目」
3637

3738
def add_output_specified(self, field_name : Translatable | str, path : str, auxiliary : bool = False):
3839
argindex = len(self.args)
@@ -165,6 +166,9 @@ def __init__(self, parent: QObject, info : ExecutionInfo) -> None:
165166
# 准备执行环境
166167
self.composed_envs = os.environ.copy()
167168
self.composed_envs.update(self.info.envs)
169+
# 设置中配置的默认 Ren'Py SDK 路径优先传入管线进程
170+
if sdk_path := SettingsDict.get_renpy_sdk_path():
171+
self.composed_envs['PREPPIPE_RENPY_SDK'] = sdk_path
168172
# 总是使用 UTF-8 编码
169173
self.composed_envs.update({
170174
'PYTHONIOENCODING': 'utf-8',

src/preppipe_gui_pyside6/forms/settingwidget.ui

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,26 @@
7373
</layout>
7474
</item>
7575
<item row="1" column="0" colspan="2">
76+
<layout class="QHBoxLayout" name="renpySdkPathLayout">
77+
<item>
78+
<widget class="QLabel" name="renpySdkPathLabel">
79+
<property name="sizePolicy">
80+
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
81+
<horstretch>0</horstretch>
82+
<verstretch>0</verstretch>
83+
</sizepolicy>
84+
</property>
85+
<property name="text">
86+
<string>Default Ren'Py SDK Path</string>
87+
</property>
88+
</widget>
89+
</item>
90+
<item>
91+
<widget class="FileSelectionWidget" name="renpySdkPathWidget" native="true"></widget>
92+
</item>
93+
</layout>
94+
</item>
95+
<item row="2" column="0" colspan="2">
7696
<widget class="QCheckBox" name="debugModeCheckBox">
7797
<property name="text">
7898
<string>Generate Debug Outputs</string>

src/preppipe_gui_pyside6/settingsdict.py

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,9 @@
1010
import collections.abc
1111
import tempfile
1212
from preppipe.language import *
13-
14-
def _get_executable_base_dir() -> str:
15-
if getattr(sys, 'frozen', False):
16-
return os.path.dirname(sys.executable)
17-
return os.path.dirname(os.path.abspath(__file__))
13+
from preppipe.appdir import get_executable_base_dir, set_executable_base_dir
1814

1915
class SettingsDict(collections.abc.MutableMapping):
20-
_executable_base_dir : typing.ClassVar[str] = _get_executable_base_dir()
2116
_settings_instance : typing.ClassVar['SettingsDict | None'] = None
2217

2318
lock : threading.Lock
@@ -26,7 +21,7 @@ class SettingsDict(collections.abc.MutableMapping):
2621
@staticmethod
2722
def instance() -> 'SettingsDict':
2823
if SettingsDict._settings_instance is None:
29-
SettingsDict._settings_instance = SettingsDict(os.path.join(SettingsDict._executable_base_dir, "preppipe_gui.settings.db"))
24+
SettingsDict._settings_instance = SettingsDict(os.path.join(get_executable_base_dir(), "preppipe_gui.settings.db"))
3025
if SettingsDict._settings_instance is None:
3126
raise RuntimeError("SettingsDict instance failed to initialize")
3227
return SettingsDict._settings_instance
@@ -41,18 +36,12 @@ def finalize() -> None:
4136
def try_set_settings_dir(path : str) -> None:
4237
if SettingsDict._settings_instance is not None:
4338
raise RuntimeError("SettingsDict instance already exists. Cannot change settings directory after initialization")
44-
if getattr(sys, 'frozen', False):
45-
return
46-
SettingsDict._executable_base_dir = os.path.abspath(path)
47-
if not os.path.isdir(SettingsDict._executable_base_dir):
48-
raise FileNotFoundError(f"Path '{SettingsDict._executable_base_dir}' does not exist")
39+
set_executable_base_dir(path)
4940

5041
@staticmethod
5142
def get_executable_base_dir():
52-
# 提供给其他模块使用
53-
# 没打包成可执行文件时,只用 _get_executable_base_dir() 取路径的话,
54-
# __file__ 取得的路径可能会不一致,因此将结果保存下来反复使用
55-
return SettingsDict._executable_base_dir
43+
"""提供给其他模块使用,与 preppipe.appdir.get_executable_base_dir() 一致。"""
44+
return get_executable_base_dir()
5645

5746
def __init__(self, filename='settings.db'):
5847
self.filename = filename
@@ -149,6 +138,15 @@ def get_current_temp_dir() -> str:
149138
return tempdir
150139
return tempfile.gettempdir()
151140

141+
@staticmethod
142+
def get_renpy_sdk_path() -> str | None:
143+
"""返回设置中配置的默认 Ren'Py SDK 路径;未配置或无效时返回 None。优先于默认目录查找。"""
144+
if inst := SettingsDict.instance():
145+
if path := inst.get("renpy/sdk_path"):
146+
if isinstance(path, str) and path.strip():
147+
return path.strip()
148+
return None
149+
152150
@staticmethod
153151
def get_user_asset_directories() -> list[str]:
154152
"""Get the list of user-specified asset directories from settings.

src/preppipe_gui_pyside6/toolwidgets/execute.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
from preppipe.language import *
1818
from ..toolwidgetinterface import *
1919
from ..componentwidgets.outputentrywidget import OutputEntryWidget
20+
from ..settingsdict import SettingsDict
21+
from preppipe.renpy.passes import run_renpy_project
2022

2123
TR_gui_executewidget = TranslationDomain("gui_executewidget")
2224

@@ -71,6 +73,16 @@ class ExecuteWidget(QWidget, ToolWidgetInterface):
7173
zh_cn="临时目录(及其下所有文件)会在本页关闭时删除: {path}",
7274
zh_hk="臨時目錄(及其下所有文件)會在本頁關閉時刪除: {path}",
7375
)
76+
_tr_run_renpy_project = TR_gui_executewidget.tr("run_renpy_project",
77+
en="Run project",
78+
zh_cn="运行项目",
79+
zh_hk="運行項目",
80+
)
81+
_tr_run_renpy_project_failed = TR_gui_executewidget.tr("run_renpy_project_failed",
82+
en="Failed to run Ren'Py project: {error}",
83+
zh_cn="无法运行 Ren'Py 项目:{error}",
84+
zh_hk="無法運行 Ren'Py 項目:{error}",
85+
)
7486

7587
@classmethod
7688
def getToolInfo(cls) -> ToolWidgetInfo:
@@ -83,6 +95,7 @@ def getToolInfo(cls) -> ToolWidgetInfo:
8395

8496
ui : Ui_ExecuteWidget
8597
exec : ExecutionObject | None
98+
_renpy_project_root : str | None # 若为 Ren'Py 导出,则为工程根目录,用于「运行项目」
8699

87100
def __init__(self, parent: QWidget):
88101
super(ExecuteWidget, self).__init__(parent)
@@ -93,6 +106,7 @@ def __init__(self, parent: QWidget):
93106
self.bind_text(self.ui.outputGroupBox.setTitle, self._tr_outputs)
94107
self.ui.killButton.clicked.connect(self.kill_process)
95108
self.exec = None
109+
self._renpy_project_root = None
96110

97111
def setData(self, execinfo : ExecutionInfo):
98112
self.exec = ExecutionObject(self, execinfo)
@@ -111,6 +125,13 @@ def setData(self, execinfo : ExecutionInfo):
111125
w.setData(out.field_name, value)
112126
self.ui.outputGroupBox.layout().addWidget(w)
113127

128+
if execinfo.is_renpy_export and main_outputs:
129+
self._renpy_project_root = os.path.dirname(self.exec.composed_args[main_outputs[0].argindex])
130+
run_renpy_btn = QPushButton()
131+
self.bind_text(run_renpy_btn.setText, self._tr_run_renpy_project)
132+
run_renpy_btn.clicked.connect(self.run_renpy_project_clicked)
133+
self.ui.outputGroupBox.layout().addWidget(run_renpy_btn)
134+
114135
self.exec.executionFinished.connect(self.handle_process_finished)
115136
self.exec.launch()
116137

@@ -162,3 +183,17 @@ def kill_process(self):
162183
if self.exec:
163184
self.exec.kill()
164185
self.appendPlainText(self._tr_process_killed.get())
186+
187+
@Slot()
188+
def run_renpy_project_clicked(self):
189+
if not self._renpy_project_root:
190+
return
191+
try:
192+
sdk_dir = SettingsDict.get_renpy_sdk_path() or None
193+
run_renpy_project(self._renpy_project_root, sdk_dir=sdk_dir)
194+
except Exception as e:
195+
QMessageBox.critical(
196+
self,
197+
self._tr_run_renpy_project.get(),
198+
self._tr_run_renpy_project_failed.format(error=str(e)),
199+
)

src/preppipe_gui_pyside6/toolwidgets/maininput.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import dataclasses
2+
import os
23
from PySide6.QtWidgets import *
34
from ..toolwidgetinterface import *
45
from ..mainwindowinterface import *
@@ -118,7 +119,7 @@ def build_operation_groupbox(self, group : QGroupBox, entry_list : list):
118119
def request_analysis(self):
119120
self.showNotImplementedMessageBox()
120121

121-
def request_export_common(self,target:str):
122+
def request_export_common(self, target: str):
122123
filelist = self.filelist.getCurrentList()
123124
if not filelist:
124125
QMessageBox.critical(self, self._tr_unable_to_execute.get(), self._tr_input_required.get())
@@ -130,7 +131,15 @@ def request_export_common(self,target:str):
130131
if exportPath := self.ui.exportPathWidget.getCurrentPath():
131132
info.add_output_specified(self._tr_export_path, exportPath)
132133
else:
133-
info.add_output_unspecified(self._tr_export_path, "game", is_dir=True)
134+
# 未指定输出时:Ren'Py 使用「第一个剧本文件名/game」作为输出,便于作为工程名
135+
if target == 'renpy':
136+
first_name = os.path.splitext(os.path.basename(filelist[0]))[0]
137+
default_output = f"{first_name}/game" if first_name else "game"
138+
else:
139+
default_output = "game"
140+
info.add_output_unspecified(self._tr_export_path, default_output, is_dir=True)
141+
if target == 'renpy':
142+
info.is_renpy_export = True
134143
MainWindowInterface.getHandle(self).requestExecution(info)
135144

136145
@Slot()

0 commit comments

Comments
 (0)