Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/preppipe/appdir.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# SPDX-FileCopyrightText: 2022 PrepPipe's Contributors
# SPDX-License-Identifier: Apache-2.0

"""可执行/基础目录的解析与覆盖,供设置文件、Ren'Py SDK 等路径统一使用。"""

import os
import sys


def _compute_executable_base_dir() -> str:
"""打包运行时为可执行文件所在目录,否则为 preppipe 包所在目录。"""
if getattr(sys, 'frozen', False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))


_executable_base_dir: str = _compute_executable_base_dir()


def get_executable_base_dir() -> str:
"""返回当前认定的「可执行/基础」目录。"""
return _executable_base_dir


def set_executable_base_dir(path: str) -> None:
"""在未打包环境下覆盖基础目录(如 GUI 启动时指定设置目录)。打包后调用无效。"""
global _executable_base_dir
if getattr(sys, 'frozen', False):
return
_executable_base_dir = os.path.abspath(path)
if not os.path.isdir(_executable_base_dir):
raise FileNotFoundError(f"Path '{_executable_base_dir}' does not exist")
116 changes: 111 additions & 5 deletions src/preppipe/renpy/passes.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,118 @@
# SPDX-FileCopyrightText: 2023 PrepPipe's Contributors
# SPDX-License-Identifier: Apache-2.0

import os
import subprocess
import sys
import shutil
from preppipe.irbase import Operation, typing
from preppipe.appdir import get_executable_base_dir
from .ast import *
from ..pipeline import *
from ..irbase import *
from ..exceptions import PPInternalError
from .export import export_renpy
from .codegen import codegen_renpy
from ..vnmodel import VNModel
import shutil


def _find_renpy_sdk() -> str | None:
"""查找内嵌的 Ren'Py SDK 目录(含 renpy.py 的 renpy-sdk 目录)。"""
env_path = os.environ.get('PREPPIPE_RENPY_SDK')
if env_path and os.path.isdir(env_path) and os.path.isfile(os.path.join(env_path, 'renpy.py')):
return os.path.abspath(env_path)
base = get_executable_base_dir()
for dir_ in (base, os.path.dirname(base), os.path.dirname(os.path.dirname(base)), '.'):
candidate = os.path.join(dir_, 'renpy-sdk') if dir_ != '.' else os.path.abspath('renpy-sdk')
if dir_ != '.':
candidate = os.path.abspath(candidate)
if os.path.isdir(candidate) and os.path.isfile(os.path.join(candidate, 'renpy.py')):
return candidate
return None


def _get_renpy_python_exe(sdk_dir: str) -> str:
"""返回 Ren'Py SDK 内嵌的 Python 解释器路径。"""
system = sys.platform
if system == 'win32':
lib_python = os.path.join(sdk_dir, 'lib', 'py3-windows-x86_64', 'python.exe')
elif system == 'darwin':
lib_python = os.path.join(sdk_dir, 'lib', 'py3-darwin-x86_64', 'python')
if not os.path.isfile(lib_python):
lib_python = os.path.join(sdk_dir, 'lib', 'py3-darwin-arm64', 'python')
else:
lib_python = os.path.join(sdk_dir, 'lib', 'py3-linux-x86_64', 'python')
if not os.path.isfile(lib_python):
raise PPInternalError('Ren\'Py SDK 中未找到对应平台的 Python: ' + lib_python)
return lib_python


def _renpy_launcher_language_from_env() -> str | None:
"""根据 PREPPIPE_LANGUAGE 返回 Ren'Py launcher 的 --language 值。仅处理简中/繁中,其他不传(默认英语)。"""
lang = (os.environ.get('PREPPIPE_LANGUAGE') or '').strip().lower()
if lang in ('zh_cn', 'schinese'):
return 'schinese'
if lang in ('zh_hk', 'tchinese', 'zh-tw'):
Comment thread
xushengj marked this conversation as resolved.
return 'tchinese'
return None


def _ensure_renpy_project_generated(game_dir: str, language: str | None = None) -> None:
"""
若 game_dir 下尚无完整 Ren'Py 工程(无 gui.rpy),则使用内嵌 SDK 生成空工程并生成 GUI 图片。
game_dir 为工程下的 game 目录(即输出目录);其父目录为工程根。
language 为 None 时不传 --language,由 Ren'Py 使用默认(英语)。
"""
gui_rpy = os.path.join(game_dir, 'gui.rpy')
if os.path.isfile(gui_rpy):
return
sdk_dir = _find_renpy_sdk()
if not sdk_dir:
raise PPInternalError(
'输出目录下未检测到 Ren\'Py 工程(无 gui.rpy),且未找到 Ren\'Py SDK。'
'请设置环境变量 PREPPIPE_RENPY_SDK 或将 SDK 解压到 renpy-sdk 目录。'
)
project_root = os.path.dirname(game_dir)
os.makedirs(game_dir, exist_ok=True)
python_exe = _get_renpy_python_exe(sdk_dir)
renpy_py = os.path.join(sdk_dir, 'renpy.py')
cmd_generate = [
python_exe, renpy_py, 'launcher', 'generate_gui',
os.path.abspath(project_root), '--start',
]
if language is not None:
cmd_generate.extend(('--language', language))
subprocess.run(cmd_generate, cwd=sdk_dir, check=True)
cmd_gui_images = [python_exe, renpy_py, os.path.abspath(project_root), 'gui_images']
subprocess.run(cmd_gui_images, cwd=sdk_dir, check=True)


def run_renpy_project(project_root: str, sdk_dir: str | None = None) -> None:
"""
使用内嵌 Ren'Py SDK 运行指定工程(不等待进程结束)。
project_root 为工程根目录,其下应包含 game 目录。
sdk_dir 若提供则优先使用(如 GUI 设置中的默认路径),否则按环境变量与默认目录查找。
供 GUI「运行项目」等调用。
"""
if sdk_dir and os.path.isdir(sdk_dir) and os.path.isfile(os.path.join(sdk_dir, 'renpy.py')):
pass
else:
sdk_dir = _find_renpy_sdk()
if not sdk_dir:
raise PPInternalError(
'未找到 Ren\'Py SDK,无法运行项目。'
'请设置环境变量 PREPPIPE_RENPY_SDK 或将 SDK 解压到 renpy-sdk 目录。'
)
python_exe = _get_renpy_python_exe(sdk_dir)
renpy_py = os.path.join(sdk_dir, 'renpy.py')
abs_root = os.path.abspath(project_root)
subprocess.Popen(
[python_exe, renpy_py, abs_root],
cwd=sdk_dir,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)

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

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

@MiddleEndDecl('renpy-codegen', input_decl=VNModel, output_decl=RenPyModel)
Expand All @@ -95,6 +201,6 @@ def run(self) -> RenPyModel | None:
if len(self._inputs) == 0:
return None
if len(self._inputs) > 1:
raise RuntimeError("renpy-codegen: exporting multiple input IR is not supported")
raise PPInternalError("renpy-codegen: exporting multiple input IR is not supported")
return codegen_renpy(self._inputs[0])
pass
4 changes: 4 additions & 0 deletions src/preppipe_gui_pyside6/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class ExecutionInfo:
unspecified_paths: dict[int, UnspecifiedPathInfo] = dataclasses.field(default_factory=dict)
specified_outputs: list[SpecifiedOutputInfo] = dataclasses.field(default_factory=list)
enable_debug_dump: bool = False
is_renpy_export: bool = False # 为 True 时,主输出为 Ren'Py 工程 game 目录,执行界面显示「运行项目」

def add_output_specified(self, field_name : Translatable | str, path : str, auxiliary : bool = False):
argindex = len(self.args)
Expand Down Expand Up @@ -165,6 +166,9 @@ def __init__(self, parent: QObject, info : ExecutionInfo) -> None:
# 准备执行环境
self.composed_envs = os.environ.copy()
self.composed_envs.update(self.info.envs)
# 设置中配置的默认 Ren'Py SDK 路径优先传入管线进程
if sdk_path := SettingsDict.get_renpy_sdk_path():
self.composed_envs['PREPPIPE_RENPY_SDK'] = sdk_path
# 总是使用 UTF-8 编码
self.composed_envs.update({
'PYTHONIOENCODING': 'utf-8',
Expand Down
20 changes: 20 additions & 0 deletions src/preppipe_gui_pyside6/forms/settingwidget.ui
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,26 @@
</layout>
</item>
<item row="1" column="0" colspan="2">
<layout class="QHBoxLayout" name="renpySdkPathLayout">
<item>
<widget class="QLabel" name="renpySdkPathLabel">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Default Ren'Py SDK Path</string>
</property>
</widget>
</item>
<item>
<widget class="FileSelectionWidget" name="renpySdkPathWidget" native="true"></widget>
</item>
</layout>
</item>
<item row="2" column="0" colspan="2">
<widget class="QCheckBox" name="debugModeCheckBox">
<property name="text">
<string>Generate Debug Outputs</string>
Expand Down
30 changes: 14 additions & 16 deletions src/preppipe_gui_pyside6/settingsdict.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,9 @@
import collections.abc
import tempfile
from preppipe.language import *

def _get_executable_base_dir() -> str:
if getattr(sys, 'frozen', False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
from preppipe.appdir import get_executable_base_dir, set_executable_base_dir

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

lock : threading.Lock
Expand All @@ -26,7 +21,7 @@ class SettingsDict(collections.abc.MutableMapping):
@staticmethod
def instance() -> 'SettingsDict':
if SettingsDict._settings_instance is None:
SettingsDict._settings_instance = SettingsDict(os.path.join(SettingsDict._executable_base_dir, "preppipe_gui.settings.db"))
SettingsDict._settings_instance = SettingsDict(os.path.join(get_executable_base_dir(), "preppipe_gui.settings.db"))
if SettingsDict._settings_instance is None:
raise RuntimeError("SettingsDict instance failed to initialize")
return SettingsDict._settings_instance
Expand All @@ -41,18 +36,12 @@ def finalize() -> None:
def try_set_settings_dir(path : str) -> None:
if SettingsDict._settings_instance is not None:
raise RuntimeError("SettingsDict instance already exists. Cannot change settings directory after initialization")
if getattr(sys, 'frozen', False):
return
SettingsDict._executable_base_dir = os.path.abspath(path)
if not os.path.isdir(SettingsDict._executable_base_dir):
raise FileNotFoundError(f"Path '{SettingsDict._executable_base_dir}' does not exist")
set_executable_base_dir(path)

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

def __init__(self, filename='settings.db'):
self.filename = filename
Expand Down Expand Up @@ -149,6 +138,15 @@ def get_current_temp_dir() -> str:
return tempdir
return tempfile.gettempdir()

@staticmethod
def get_renpy_sdk_path() -> str | None:
"""返回设置中配置的默认 Ren'Py SDK 路径;未配置或无效时返回 None。优先于默认目录查找。"""
if inst := SettingsDict.instance():
if path := inst.get("renpy/sdk_path"):
if isinstance(path, str) and path.strip():
return path.strip()
return None

@staticmethod
def get_user_asset_directories() -> list[str]:
"""Get the list of user-specified asset directories from settings.
Expand Down
35 changes: 35 additions & 0 deletions src/preppipe_gui_pyside6/toolwidgets/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
from preppipe.language import *
from ..toolwidgetinterface import *
from ..componentwidgets.outputentrywidget import OutputEntryWidget
from ..settingsdict import SettingsDict
from preppipe.renpy.passes import run_renpy_project

TR_gui_executewidget = TranslationDomain("gui_executewidget")

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

@classmethod
def getToolInfo(cls) -> ToolWidgetInfo:
Expand All @@ -83,6 +95,7 @@ def getToolInfo(cls) -> ToolWidgetInfo:

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

def __init__(self, parent: QWidget):
super(ExecuteWidget, self).__init__(parent)
Expand All @@ -93,6 +106,7 @@ def __init__(self, parent: QWidget):
self.bind_text(self.ui.outputGroupBox.setTitle, self._tr_outputs)
self.ui.killButton.clicked.connect(self.kill_process)
self.exec = None
self._renpy_project_root = None

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

if execinfo.is_renpy_export and main_outputs:
self._renpy_project_root = os.path.dirname(self.exec.composed_args[main_outputs[0].argindex])
run_renpy_btn = QPushButton()
self.bind_text(run_renpy_btn.setText, self._tr_run_renpy_project)
run_renpy_btn.clicked.connect(self.run_renpy_project_clicked)
self.ui.outputGroupBox.layout().addWidget(run_renpy_btn)

self.exec.executionFinished.connect(self.handle_process_finished)
self.exec.launch()

Expand Down Expand Up @@ -162,3 +183,17 @@ def kill_process(self):
if self.exec:
self.exec.kill()
self.appendPlainText(self._tr_process_killed.get())

@Slot()
def run_renpy_project_clicked(self):
if not self._renpy_project_root:
return
try:
sdk_dir = SettingsDict.get_renpy_sdk_path() or None
run_renpy_project(self._renpy_project_root, sdk_dir=sdk_dir)
except Exception as e:
QMessageBox.critical(
self,
self._tr_run_renpy_project.get(),
self._tr_run_renpy_project_failed.format(error=str(e)),
)
13 changes: 11 additions & 2 deletions src/preppipe_gui_pyside6/toolwidgets/maininput.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import dataclasses
import os
from PySide6.QtWidgets import *
from ..toolwidgetinterface import *
from ..mainwindowinterface import *
Expand Down Expand Up @@ -118,7 +119,7 @@ def build_operation_groupbox(self, group : QGroupBox, entry_list : list):
def request_analysis(self):
self.showNotImplementedMessageBox()

def request_export_common(self,target:str):
def request_export_common(self, target: str):
filelist = self.filelist.getCurrentList()
if not filelist:
QMessageBox.critical(self, self._tr_unable_to_execute.get(), self._tr_input_required.get())
Expand All @@ -130,7 +131,15 @@ def request_export_common(self,target:str):
if exportPath := self.ui.exportPathWidget.getCurrentPath():
info.add_output_specified(self._tr_export_path, exportPath)
else:
info.add_output_unspecified(self._tr_export_path, "game", is_dir=True)
# 未指定输出时:Ren'Py 使用「第一个剧本文件名/game」作为输出,便于作为工程名
if target == 'renpy':
first_name = os.path.splitext(os.path.basename(filelist[0]))[0]
default_output = f"{first_name}/game" if first_name else "game"
else:
default_output = "game"
info.add_output_unspecified(self._tr_export_path, default_output, is_dir=True)
if target == 'renpy':
info.is_renpy_export = True
MainWindowInterface.getHandle(self).requestExecution(info)

@Slot()
Expand Down
Loading