diff --git a/.gitignore b/.gitignore index bacbd88..1f7b077 100644 --- a/.gitignore +++ b/.gitignore @@ -180,3 +180,4 @@ thumbnails # version files for pyinstaller build versionfile_*.txt +/src/ScriptFlowEditor diff --git a/src/ScriptFlowEditor/__init__.py b/src/ScriptFlowEditor/__init__.py new file mode 100644 index 0000000..460ddf3 --- /dev/null +++ b/src/ScriptFlowEditor/__init__.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +""" +文字 ADV 脚本流程编辑器的数据模型与(后续)视图层入口。 + +当前仅实现数据模型层: +- 剧情段落(节点) +- 段落间连接条件 +- 段落连接条件相关变量(flag) +""" + +from ScriptFlowEditor.models import ( + FlagType, + FlagVariable, + StorySegment, + SegmentPath, + GameScriptFlow, +) + +__all__ = [ + "FlagType", + "FlagVariable", + "StorySegment", + "SegmentPath", + "GameScriptFlow", +] diff --git a/src/ScriptFlowEditor/basicflow_deserialization_test.py b/src/ScriptFlowEditor/basicflow_deserialization_test.py new file mode 100644 index 0000000..442b758 --- /dev/null +++ b/src/ScriptFlowEditor/basicflow_deserialization_test.py @@ -0,0 +1,16 @@ +from pathlib import Path +from ScriptFlowEditor import GameScriptFlow + +# 项目根目录(本文件在 src/ScriptFlowEditor/basicflow_deserialization_test.py) +base = Path(__file__).resolve().parent.parent.parent +path = base / "src" / "ScriptFlowEditor" / "generated" / "Demo Flow.json" +flow = GameScriptFlow.load_from_json(path) + +print("已加载流程") +print(f"名称: {flow.name}") +print(f"id: {flow.id}") +print(f"标题: {flow.title}") +print(f"注释: {flow.comment}") +print(f"各段落: {flow.segments}") +print(f"各路径: {flow.paths}") +print(f"flag变量: {flow.flags}") \ No newline at end of file diff --git a/src/ScriptFlowEditor/basicflow_serialization_test.py b/src/ScriptFlowEditor/basicflow_serialization_test.py new file mode 100644 index 0000000..d9ae53d --- /dev/null +++ b/src/ScriptFlowEditor/basicflow_serialization_test.py @@ -0,0 +1,37 @@ +# 基础流程及序列化保存测试 + +from ScriptFlowEditor import * + +# flag变量 +first_choice = FlagVariable(name='choice_1', flag_type=FlagType.BOOL, initial_value=True, comment='The 1st choice flag.') +second_choice = FlagVariable(name='choice_2', flag_type=FlagType.BOOL, initial_value=False, comment='The 2nd choice flag.') + +# start,整个流程入口 +start_node = StorySegment(name='start', content='This is start label.', is_ending_segment=False, id=None) + +# 分支段落1 +branch_seg_1 = StorySegment(name='branch_seg_1', content='This is 1st branch segment label.', is_ending_segment=False, id=None) +# 分支段落2 +branch_seg_2 = StorySegment(name='branch_seg_2', content='This is 2nd branch segment label.', is_ending_segment=False, id=None) + +# 结局1。从branch_seg_1跳转到该结局。 +ending_1 = StorySegment(name='ending_1', content='This is ending 1.', is_ending_segment=True) +# 结局2。在second_choice为True时,从branch_seg_2跳转到该结局。 +ending_2 = StorySegment(name='ending_2', content='This is ending 2.', is_ending_segment=True) +# 结局3。在first_choice为False时,从branch_seg_2跳转到该结局。 +ending_3 = StorySegment(name='ending_3', content='This is ending 3.', is_ending_segment=True) + +# 路径1。在start_node结尾,若first_choice为True则进入branch_seg_1。 +path_1 = SegmentPath(prev_segment_id=start_node.id, next_segment_id=branch_seg_1.id, condition_expression="choice_1 == True") +# 路径2。在start_node结尾,若first_choice为False则进入branch_seg_2。 +path_2 = SegmentPath(prev_segment_id=start_node.id, next_segment_id=branch_seg_2.id, condition_expression="choice_1 == False") +# 路径3。在branch_seg_1结尾,无条件进入ending_1。 +path_3 = SegmentPath(prev_segment_id=branch_seg_1.id, next_segment_id=ending_1.id, condition_expression=None) +# 路径4。在branch_seg_2结尾,若second_choice为True则进入ending_2。 +path_4 = SegmentPath(prev_segment_id=branch_seg_2.id, next_segment_id=ending_2.id, condition_expression="choice_2 == True") +# 路径5。在branch_seg_2结尾,若second_choice为False则进入ending_3。 +path_5 = SegmentPath(prev_segment_id=branch_seg_2.id, next_segment_id=ending_3.id, condition_expression="choice_2 == False") + +flow = GameScriptFlow(name="Demo Flow", segments=[start_node, branch_seg_1, branch_seg_2, ending_1, ending_2, ending_3], paths=[path_1, path_2, path_3, path_4, path_5], flags=[first_choice, second_choice]) + +flow.save_as_json() diff --git a/src/ScriptFlowEditor/models/__init__.py b/src/ScriptFlowEditor/models/__init__.py new file mode 100644 index 0000000..9de18ac --- /dev/null +++ b/src/ScriptFlowEditor/models/__init__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +"""脚本流程编辑器数据模型。""" + +from ScriptFlowEditor.models.flag import FlagType +from ScriptFlowEditor.models.flag import FlagVariable +from ScriptFlowEditor.models.segment import StorySegment +from ScriptFlowEditor.models.path import SegmentPath +from ScriptFlowEditor.models.gamescriptflow import GameScriptFlow + + +__all__ = [ + "FlagType", + "FlagVariable", + "StorySegment", + "SegmentPath", + "GameScriptFlow", +] diff --git a/src/ScriptFlowEditor/models/flag.py b/src/ScriptFlowEditor/models/flag.py new file mode 100644 index 0000000..b4942de --- /dev/null +++ b/src/ScriptFlowEditor/models/flag.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +"""段落连接条件相关变量(flag)模型。""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass, field + +import enum + + +class FlagType(enum.Enum): + + """ + flag变量类型。 + + bool: 布尔值 + int: 整数 + float: 浮点数 + """ + BOOL = "bool" + INT = "int" + FLOAT = "float" + + +@dataclass +class FlagVariable: + """ + 控制分支走向相关变量(flag)。 + 用于在段落间连接条件中引用,具备类型与初始值。 + """ + + # 变量名称,全局范围内应唯一。 + name: str + + # 变量类型:bool / int / float。入参可为 str(如 "bool")或 FlagType,字符串会转换为枚举。 + flag_type: str | FlagType + + # 初始值,类型需与 flag_type 一致。 + initial_value: bool | int | float + + # 注释。 + comment: str | None = None + + # 唯一标识,由 name 哈希生成 8 位;不传则自动生成。 + id: str | None = None + + def __post_init__(self) -> None: + if self.id is None: + self.id = hashlib.sha256(self.name.encode()).hexdigest()[:8] + self._normalize_flag_type() + self._validate_initial_value() + + # 将入参转换为 FlagType,无效则抛错。 + def _normalize_flag_type(self) -> None: + if isinstance(self.flag_type, str): + try: + self.flag_type = FlagType(self.flag_type) + except ValueError: + valid = [e.value for e in FlagType] + raise ValueError( + f"FlagVariable {self.name!r}: flag_type value is invalid {self.flag_type!r}, " + f"it should be one of {valid}" + ) from None + elif not isinstance(self.flag_type, FlagType): + raise TypeError( + f"FlagVariable {self.name!r}: flag_type should be str or FlagType, " + f"got {type(self.flag_type).__name__}" + ) + + # 校验初始值类型是否与 flag_type 一致。 + def _validate_initial_value(self) -> None: + + if self.flag_type == FlagType.BOOL and not isinstance(self.initial_value, bool): + raise TypeError( + f"FlagVariable {self.name!r}: flag_type is bool, " + f"initial_value should be bool, got {type(self.initial_value).__name__}" + ) + if self.flag_type == FlagType.INT and not isinstance(self.initial_value, int): + raise TypeError( + f"FlagVariable {self.name!r}: flag_type is int, " + f"initial_value should be int, got {type(self.initial_value).__name__}" + ) + if self.flag_type == FlagType.FLOAT and not isinstance(self.initial_value, (int, float)): + raise TypeError( + f"FlagVariable {self.name!r}: flag_type is float, " + f"initial_value should be int or float, got {type(self.initial_value).__name__}" + ) diff --git a/src/ScriptFlowEditor/models/gamescriptflow.py b/src/ScriptFlowEditor/models/gamescriptflow.py new file mode 100644 index 0000000..dca4e37 --- /dev/null +++ b/src/ScriptFlowEditor/models/gamescriptflow.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- +"""脚本文档聚合模型:段落、连接、变量。""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from dataclasses import asdict, dataclass, field +from ScriptFlowEditor.models.flag import FlagType, FlagVariable +from ScriptFlowEditor.models.segment import StorySegment +from ScriptFlowEditor.models.path import SegmentPath + + +@dataclass +class GameScriptFlow: + """ + 游戏脚本流程。 + + 聚合所有剧情段落、段落间分支路径、以及分支路径条件相关变量(flag), + 构成一份完整的游戏脚本流程数据。 + """ + # 脚本名称,用于在编辑器中显示与引用。 + name: str = "" + + # 剧情段落(节点)列表。 + segments: list[StorySegment] = field(default_factory=list) + + # 段落间分支路径列表。 + paths: list[SegmentPath] = field(default_factory=list) + + # 分支路径条件相关变量列表。 + flags: list[FlagVariable] = field(default_factory=list) + + # 脚本流程标题(可选,用于显示)。 + title: str = "" + + # 注释。 + comment: str | None = None + + # 唯一标识,由 name 哈希生成 8 位;不传则自动生成。 + id: str | None = None + + def __post_init__(self) -> None: + if self.id is None: + self.id = hashlib.sha256(self.name.encode()).hexdigest()[:8] + + # 按 id 查找剧情段落。 + def get_segment_by_id(self, segment_id: str) -> StorySegment | None: + for s in self.segments: + if s.id == segment_id: + return s + return None + + # 按 id 查找变量。 + def get_flag_by_id(self, flag_id: str) -> FlagVariable | None: + for f in self.flags: + if f.id == flag_id: + return f + return None + + # 按名称查找变量。 + def get_flag_by_name(self, name: str) -> FlagVariable | None: + for f in self.flags: + if f.name == name: + return f + return None + + # 按前置段落 id 查找分支路径。 + def get_path_prev(self, prev_segment_id: str) -> list[SegmentPath]: + return [c for c in self.paths if c.prev_segment_id == prev_segment_id] + + # 按后续段落 id 查找分支路径。 + def get_path_next(self, next_segment_id: str) -> list[SegmentPath]: + return [c for c in self.paths if c.next_segment_id == next_segment_id] + + # 序列化为可 JSON 序列化的字典(JSON 对象)。 + def to_dict(self) -> dict: + return { + "name": self.name, + "id": self.id, + "title": self.title, + "comment": self.comment, + "segments": [asdict(s) for s in self.segments], + "paths": [asdict(p) for p in self.paths], + "flags": [self._flag_to_dict(f) for f in self.flags], + } + + # 将 FlagVariable 转为 dict,flag_type 枚举转为字符串。 + @staticmethod + def _flag_to_dict(f: FlagVariable) -> dict: + d = asdict(f) + if hasattr(f.flag_type, "value"): + d["flag_type"] = f.flag_type.value + return d + + # 序列化为 JSON 字符串。 + def to_json(self, *, indent: int | None = None, ensure_ascii: bool = False) -> str: + return json.dumps(self.to_dict(), indent=indent, ensure_ascii=ensure_ascii) + + def _get_generated_dir(self) -> Path: + """返回 ScriptFlowEditor/generated 目录路径,不存在则创建。""" + # 本文件位于 ScriptFlowEditor/models/gamescriptflow.py + generated = Path(__file__).resolve().parent.parent / "generated" + generated.mkdir(parents=True, exist_ok=True) + return generated + + @classmethod + def load_from_json(cls, path: str | Path) -> GameScriptFlow: + """ + 从 JSON 文件反序列化为 GameScriptFlow 对象。 + + :param path: JSON 文件路径,例如 src/ScriptFlowEditor/generated/Demo Flow.json + :return: 反序列化得到的 GameScriptFlow 实例 + """ + path = Path(path) + data = json.loads(path.read_text(encoding="utf-8")) + return cls.load_from_dict(data) + + @classmethod + def load_from_dict(cls, data: dict) -> GameScriptFlow: + """ + 从字典反序列化为 GameScriptFlow 对象(可含额外键如 node_positions,会被忽略)。 + + :param data: 含 segments、paths、flags 等键的字典 + :return: 反序列化得到的 GameScriptFlow 实例 + """ + segments = [cls._segment_from_dict(d) for d in data.get("segments", [])] + paths = [cls._path_from_dict(d) for d in data.get("paths", [])] + flags = [cls._flag_from_dict(d) for d in data.get("flags", [])] + return cls( + name=data.get("name", ""), + id=data.get("id"), + title=data.get("title", ""), + comment=data.get("comment"), + segments=segments, + paths=paths, + flags=flags, + ) + + @staticmethod + def _segment_from_dict(d: dict) -> StorySegment: + return StorySegment( + name=d["name"], + content=d["content"], + is_ending_segment=d.get("is_ending_segment", True), + id=d.get("id"), + comment=d.get("comment", ""), + paths_segment_ids=d.get("paths_segment_ids", {}), + ) + + @staticmethod + def _path_from_dict(d: dict) -> SegmentPath: + return SegmentPath( + prev_segment_id=d["prev_segment_id"], + next_segment_id=d["next_segment_id"], + condition_expression=d.get("condition_expression"), + name=d.get("name"), + comment=d.get("comment"), + id=d.get("id"), + ) + + @staticmethod + def _flag_from_dict(d: dict) -> FlagVariable: + return FlagVariable( + name=d["name"], + flag_type=d["flag_type"], + initial_value=d["initial_value"], + comment=d.get("comment"), + id=d.get("id"), + ) + + # 序列化为 JSON 并保存到 ScriptFlowEditor/generated 目录下的 .json 文件。 + def save_as_json( + self, + filename: str | None = None, + *, + indent: int | None = 2, + ensure_ascii: bool = False, + ) -> Path: + """ + 将当前对象序列化为 JSON 并保存到 src/ScriptFlowEditor/generated 目录。 + + :param filename: 文件名(可含 .json 后缀);为 None 时用 name 或 id,不含则自动加 .json。 + :param indent: 传给 json.dumps 的缩进,默认 2。 + :param ensure_ascii: 是否转义非 ASCII,默认 False 以保留中文。 + :return: 保存后的文件路径。 + """ + out_dir = self._get_generated_dir() + if not filename: + base = (self.name or self.id or "gamescriptflow").replace("/", "_").replace("\\", "_").strip(". ") or "flow" + filename = f"{base}.json" if not base.lower().endswith(".json") else base + elif not filename.lower().endswith(".json"): + filename = f"{filename}.json" + path = out_dir / filename + path.write_text(self.to_json(indent=indent, ensure_ascii=ensure_ascii), encoding="utf-8") + return path diff --git a/src/ScriptFlowEditor/models/path.py b/src/ScriptFlowEditor/models/path.py new file mode 100644 index 0000000..b5e9d05 --- /dev/null +++ b/src/ScriptFlowEditor/models/path.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +"""段落间连接路径模型。""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass, field + +@dataclass +class SegmentPath: + """ + 段落间分支路径。 + 满足条件表达式时,可以通往目标段落。 + """ + + # 前置剧情段落id。 + prev_segment_id: str + + # 后续剧情段落id。 + next_segment_id: str + + # 条件表达式或模式字符串,为空表示无条件。 + condition_expression: str|None = None + + # 路径名称,用于在编辑器中显示与引用。为空时按源与目标段落id拼接。 + name: str | None = None + + # 注释。 + comment: str | None = None + + # 唯一标识,由 name 哈希生成 8 位;不传则自动生成。 + id: str | None = None + + def __post_init__(self) -> None: + if self.name is None or self.name == "": + self.name = f"{self.prev_segment_id}_to_{self.next_segment_id}_path" + if self.id is None or self.id == "": + self.id = hashlib.sha256(self.name.encode()).hexdigest()[:8] + diff --git a/src/ScriptFlowEditor/models/segment.py b/src/ScriptFlowEditor/models/segment.py new file mode 100644 index 0000000..abca548 --- /dev/null +++ b/src/ScriptFlowEditor/models/segment.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +"""剧情段落(节点)模型。""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass, field + + +@dataclass +class StorySegment: + """ + 剧情段落(节点)。 + 表示一段可播放的剧情内容,具备名称、正文以及是否为终点段落的属性。 + """ + + # 段落名称,用于在编辑器中显示。 + name: str + + # 段落正文内容(脚本文本)。 + content: str + + # 是否为终点段落;默认为 True。如果为 False,则有后继连接。 如果为 True,则没有后继连接。 + is_ending_segment: bool = True + + # 唯一标识,由 name 哈希生成 8 位;不传则自动生成。 + id: str | None = None + + # 注释。 + comment: str = "" + + # 分支路径id与后续段落id字典。 + paths_segment_ids: dict[str, str] = field(default_factory=dict) + + def __post_init__(self) -> None: + if self.id is None: + self.id = hashlib.sha256(self.name.encode()).hexdigest()[:8] + + def add_path_segment_id(self, path_id: str, segment_id: str) -> None: + self.paths_segment_ids[path_id] = segment_id diff --git a/src/fgui_converter/FguiAssetsParseLib.py b/src/fgui_converter/FguiAssetsParseLib.py index 248d12a..7257155 100644 --- a/src/fgui_converter/FguiAssetsParseLib.py +++ b/src/fgui_converter/FguiAssetsParseLib.py @@ -522,7 +522,7 @@ def __init__(self, display_item_tree, package_desc=None): # 轴心。默认值为(0.0, 0.0)。 pivot = self.display_item_tree.get("pivot", "0.0,0.0") self.pivot = tuple(map(float, pivot.split(","))) - # 是否将轴心作为锚点。否认为False。 + # 是否将轴心作为锚点。默认为False。 self.pivot_is_anchor = (self.display_item_tree.get("anchor") == "true") # 不透明度。默认为1.0。 alpha = self.display_item_tree.get("alpha", "1.0") diff --git a/src/fgui_converter/utils/renpy/Fgui2RenpyConverter.py b/src/fgui_converter/utils/renpy/Fgui2RenpyConverter.py index 8be067f..831dceb 100644 --- a/src/fgui_converter/utils/renpy/Fgui2RenpyConverter.py +++ b/src/fgui_converter/utils/renpy/Fgui2RenpyConverter.py @@ -25,6 +25,81 @@ class BarOrientationType(IntEnum): HORIZONTAL = 0 VERTICAL = 1 + +class MainMenuTitle: + """主菜单标题:由 --main-menu-title 的键值对解析得到。""" + __slots__ = ('text_str', 'text_color') + + def __init__(self, text_str: str | None = None, text_color: str | None = None): + self.text_str = text_str + self.text_color = text_color + + +# 颜色名称到十六进制值的映射,用于 --main-menu-title text_color=名称 +COLOR_NAME_TO_HEX = { + 'red': '#ff0000', + 'green': '#00ff00', + 'blue': '#0000ff', + 'white': '#ffffff', + 'black': '#000000', + 'yellow': '#ffff00', + 'cyan': '#00ffff', + 'magenta': '#ff00ff', + 'orange': '#ffa500', + 'gray': '#808080', + 'grey': '#808080', + 'silver': '#c0c0c0', + 'maroon': '#800000', + 'lime': '#00ff00', + 'olive': '#808000', + 'navy': '#000080', + 'purple': '#800080', + 'teal': '#008080', +} + + +def is_valid_hex_color(color: str | None) -> bool: + """ + 校验颜色值是否合法:支持以下两种形式 + 1) 以 # 开头,后跟 3 位或 6 位十六进制数字,如 #f00、#ff0000; + 2) 预定义颜色名称,如 red、green、blue(见 COLOR_NAME_TO_HEX)。 + 空字符串或 None 视为不合法。 + """ + if not color or not isinstance(color, str): + return False + color = color.strip() + if re.fullmatch(r'#[0-9A-Fa-f]{3}(?:[0-9A-Fa-f]{3})?', color): + return True + return color.lower() in COLOR_NAME_TO_HEX + + +def _parse_main_menu_title_kv(pairs): + """ + 将 --main-menu-title key1=val1 key2=val2 解析为 MainMenuTitle。 + pairs: 由 argparse 解析得到的字符串列表,如 ['text_str=标题', 'text_color=#ff0000']。 + 返回 MainMenuTitle 或 None(当 pairs 为空或 None 时)。 + """ + if not pairs: + return None + d = {} + for s in pairs: + if '=' in s: + k, _, v = s.partition('=') + d[k.strip()] = v.strip() + if not d: + return None + # 未提供的键为 None,赋值时保持 displayable 原值不变 + text_str = d.get('text_str') if 'text_str' in d else d.get('text') + text_color = d.get('text_color') if 'text_color' in d else d.get('color') + if text_color is not None and not is_valid_hex_color(text_color): + print(f"警告: text_color 格式无效(需 # 加 3/6 位十六进制或预定义颜色名),已忽略: {text_color!r}") + text_color = None + elif text_color is not None: + # 颜色名解析为十六进制,便于后续使用 + text_color = COLOR_NAME_TO_HEX.get(text_color.strip().lower(), text_color) + return MainMenuTitle(text_str=text_str, text_color=text_color) + + # 添加当前目录到Python路径,以便导入FguiAssetsParseLib sys.path.append(os.path.dirname(os.path.abspath(__file__))) @@ -76,6 +151,10 @@ class FguiToRenpyConverter: # 主管线提供的gallery数据文件 ui_helper_file_name = '01_ui_helper.rpy' + # 自定义参数,用于替换Fgui发布资源中的值。 + main_menu_title = None + main_menu_logo = None + def __init__(self, fgui_assets): self.fgui_assets = fgui_assets self.renpy_code = [] @@ -135,11 +214,50 @@ def __init__(self, fgui_assets): # 一些样式预设,用于覆盖Ren'Py默认样式。 self.style_code.append(self.style_preset) - def set_game_global_variables(self, variable_name, variable_value): + def set_game_global_variables(self, variable_name : str, variable_value : str | int | float | bool | None): + """ + 设置游戏全局变量。 + variable_name: 变量名。 + variable_value: 变量值。 + """ + if variable_value is None: + variable_value = 'None' + elif isinstance(variable_value, bool): + variable_value = 'True' if variable_value else 'False' + elif isinstance(variable_value, int): + variable_value = str(variable_value) + elif isinstance(variable_value, float): + variable_value = str(variable_value) + elif isinstance(variable_value, str): + variable_value = f"'{variable_value}'" + else: + variable_value = str(variable_value) variable_str = f"define {variable_name} = {variable_value}" self.game_global_variables_code.append(variable_str) self.game_global_variables_code.append('') + def add_game_global_variables(self, variable_name : str, variable_value : str | int | float | bool | None): + """ + 添加游戏全局变量。 + variable_name: 变量名。 + variable_value: 变量值。 + """ + if variable_value is None: + variable_value = 'None' + elif isinstance(variable_value, bool): + variable_value = 'True' if variable_value else 'False' + elif isinstance(variable_value, int): + variable_value = str(variable_value) + elif isinstance(variable_value, float): + variable_value = str(variable_value) + elif isinstance(variable_value, str): + variable_value = f"'{variable_value}'" + else: + variable_value = str(variable_value) + variable_str = f"default {variable_name} = {variable_value}" + self.game_global_variables_code.append(variable_str) + self.game_global_variables_code.append('') + def calculate_indent(self): self.indent_str = self.indent_unit * self.root_indent_level return self.indent_str @@ -512,6 +630,10 @@ def generate_scroll_bar_style(self, fgui_scrollbar : FguiScrollBar) -> None: self.style_code.extend(scrollbar_style_code) + @staticmethod + def is_main_menu_screen(screen_name : str): + return screen_name == 'main_menu' + @staticmethod def is_menu_screen(screen_name : str): return screen_name in FguiToRenpyConverter.menu_screen_name_list @@ -666,7 +788,6 @@ def generate_screen(self, component : FguiComponent): self.screen_ui_code.clear() self.screen_has_dismiss = False self.dismiss_action_list.clear() - choice_screen_code = [] self.screen_definition_head.append("# 界面定义") self.screen_definition_head.append(f"# 从FairyGUI组件{component.name}转换而来") @@ -678,6 +799,11 @@ def generate_screen(self, component : FguiComponent): # 界面入参列表 screen_params = '' + # main_menu界面的特殊处理 + if self.is_main_menu_screen(screen_name): + self.generate_main_menu_screen(component) + return + # choice界面的特殊处理 if self.is_choice_screen(screen_name): self.generate_choice_screen(component) @@ -770,6 +896,82 @@ def generate_screen(self, component : FguiComponent): self.screen_ui_code.append("") self.screen_code.extend(self.screen_ui_code) + def generate_main_menu_screen(self, component : FguiComponent): + print("This is main menu screen.") + self.screen_definition_head.clear() + self.screen_variable_code.clear() + self.screen_function_code.clear() + self.screen_ui_code.clear() + self.screen_has_dismiss = False + self.dismiss_action_list.clear() + + self.reset_indent_level() + self.screen_definition_head.append(f"screen main_menu():") + self.indent_level_up() + self.screen_ui_code.append(f"{self.indent_str}tag menu\n") + + # 自定义的组件 + # 标题文本。若main_menu_title不为空,则先遍历一遍所有组件并修改标题文本内容。 + if self.main_menu_title: + for displayable in component.display_list.displayable_list: + if isinstance(displayable, FguiText) and displayable.name == 'title': + if self.main_menu_title.text_str: + displayable.text = self.main_menu_title.text_str + if self.main_menu_title.text_color: + displayable.text_color = self.main_menu_title.text_color + + # 游戏logo图片。若main_menu_logo不为空,则先遍历一遍所有组件并修改logo图片内容。 + # if self.main_menu_logo: + # for displayable in component.display_list.displayable_list: + # if isinstance(displayable, FguiImage) and displayable.name == 'logo': + # displayable.image = self.main_menu_logo + + # 根据控制器列表定义界面内变量 + if component.controller_list: + self.screen_variable_code.append(f"{self.indent_str}# 由组件控制器生成的界面内控制变量:") + for controller in component.controller_list: + if not isinstance(controller, FguiController): + print("Component controller object type is wrong.") + break + self.screen_variable_code.append(f"{self.indent_str}default {controller.name} = {controller.selected}") + + # 根据组件的可见区域性质,决定是否加一层viewport。 + if component.overflow == "hidden": + self.screen_ui_code.append(f"{self.indent_str}viewport:") + self.indent_level_up() + self.screen_ui_code.append(f"{self.indent_str}xysize {component.size}") + elif component.overflow == "scroll": + self.screen_ui_code.append(f"{self.indent_str}viewport:") + self.indent_level_up() + self.screen_ui_code.append(f"{self.indent_str}xysize {component.size}") + self.screen_ui_code.append(f"{self.indent_str}draggable True") + # 在Ren'Py中实际可能无法滚动。 + # 需要添加一个fixed组件,并设置一个合适的xysize。该xysize应为容纳所有子组件的包围框。 + self.screen_ui_code.append(f"{self.indent_str}fixed:") + self.indent_level_up() + self.screen_ui_code.append(f"{self.indent_str}xysize ({component.bbox_width}, {component.bbox_height})") + + self.screen_ui_code.extend(self.convert_component_display_list(component)) + + self.screen_code.extend(self.screen_definition_head) + if self.screen_variable_code: + self.screen_code.extend(self.screen_variable_code) + self.screen_code.append("") + if self.screen_function_code: + self.screen_code.extend(self.screen_function_code) + self.screen_code.append("") + # 添加只有1个生效的dismiss + if self.screen_has_dismiss: + self.screen_ui_code.append(f"{self.indent_str}dismiss:") + self.indent_level_up() + self.screen_ui_code.append(f"{self.indent_str}modal False") + dismiss_action_list = ', '.join(self.dismiss_action_list) + self.screen_ui_code.append(f"{self.indent_str}action [{dismiss_action_list}]") + + self.screen_ui_code.append("") + self.screen_code.extend(self.screen_ui_code) + + def generate_choice_screen(self, component : FguiComponent): print("This is choice screen.") self.screen_definition_head.clear() @@ -2102,7 +2304,6 @@ def generate_text_displayable(self, fgui_text): print("It is not a text displayable.") return text_code - end_indent_level = 1 # 根据显示控制器gearDisplay设置显示条件 @@ -2114,15 +2315,24 @@ def generate_text_displayable(self, fgui_text): # 直接定义text组件。 # 处理换行符 - text_str = fgui_text.text.replace("\n", "\\n").replace("\r", "\\n") + if fgui_text.single_line: + # 设置为单行,删除所有换行符 + text_str = fgui_text.text.replace("\n", "").replace("\r", "") + else: + # 默认转义换行符 + text_str = fgui_text.text.replace("\n", "\\n").replace("\r", "\\n") # 需要根据is_input区分文本组件与输入框 if fgui_text.is_input: # Ren'Py中的直接使用input组件无法在多个输入框的情况下切换焦点,也无法点击空白区域让所有输入框失去焦点。 # 需要使用button作为父组件,与InputValue关联。整个界面添加一个dismiss,空白区域点击事件让输入框失去焦点。 - # pass + # 添加InputValue变量。 - self.screen_variable_code.append(f"{self.indent_str}default {fgui_text.name} = '{fgui_text.text}'") - self.screen_variable_code.append(f"{self.indent_str}default {fgui_text.name}_input_value = ScreenVariableInputValue('{fgui_text.name}', default=False)") + if fgui_text.custom_data: + self.add_game_global_variables(fgui_text.custom_data, fgui_text.text) + self.screen_variable_code.append(f"{self.indent_str}default {fgui_text.name}_input_value = VariableInputValue('{fgui_text.custom_data}', default=False)") + else: + self.screen_variable_code.append(f"{self.indent_str}default {fgui_text.name} = '{fgui_text.text}'") + self.screen_variable_code.append(f"{self.indent_str}default {fgui_text.name}_input_value = ScreenVariableInputValue('{fgui_text.name}', default=False)") if self.screen_has_dismiss == False: self.screen_has_dismiss = True # 若prompt不为空,需要在screen中添加一个输入检测函数 @@ -2227,8 +2437,8 @@ def generate_text_displayable(self, fgui_text): xalign, yalign = self.trans_text_align(fgui_text.align, fgui_text.v_align) text_code.append(f"{self.indent_str}textalign {xalign}") # 不自动换行 - text_code.append(f"{self.indent_str}layout 'nobreak'") - + if fgui_text.single_line: + text_code.append(f"{self.indent_str}layout 'nobreak'") # 粗体、斜体、下划线、删除线 if fgui_text.bold: @@ -2249,7 +2459,11 @@ def generate_text_displayable(self, fgui_text): if fgui_text.is_password: text_code.append(f"{self.indent_str}mask '*'") # FGUI的输入限制使用正则表达式。在Ren'Py中使用字符串。此处仅为占位,无效果。 - text_code.append(f"{self.indent_str}allow {{}}") + # text_code.append(f"{self.indent_str}allow {{}}") + if fgui_text.restrict: + text_code.append(f"{self.indent_str}allow '{fgui_text.restrict}'") + else: + text_code.append(f"{self.indent_str}allow {{}}") text_code.append(f"{self.indent_str}exclude {{}}") if fgui_text.is_input: self.indent_level_down() @@ -2605,10 +2819,18 @@ def convert(argv): help='输入FairyGUI资源文件所在目录名 (目录中需存在同名 .bytes 文件)') parser.add_argument('-o', '--output', type=str, help='输出Ren\'Py项目基目录路径 (即Ren\'Py项目根目录)') - + parser.add_argument('--main-menu-title', type=str, metavar='KEY=VALUE', + help='主菜单标题,键值对用空格分隔写在一个字符串里,如: "text_str=标题 text_color=#ff0000"') + parser.add_argument('--main-menu-logo', type=str, + help='主菜单logo(可选)') # 解析命令行参数 args = parser.parse_args(argv[1:] if argv and len(argv) > 1 else []) + # 将 --main-menu-title 的单个字符串按空格拆成键值对列表后解析为 MainMenuTitle + raw = getattr(args, 'main_menu_title', None) + pairs = raw.strip().split() if raw else None + main_menu_title = _parse_main_menu_title_kv(pairs) + # 检查必需的参数 if not args.input: print("错误: 必须指定输入目录 (-i 或 --input)") @@ -2671,6 +2893,9 @@ def convert(argv): converter.game_dir = game_dir converter.scripts_dir = scripts_dir converter.images_dir = images_dir + # 设置自定义参数,用于替换Fgui发布资源中的值。 + converter.main_menu_title = main_menu_title + converter.main_menu_logo = args.main_menu_logo print("转换器创建完成") # 生成Ren'Py代码 diff --git a/src/preppipe_gui_pyside6/toolwidgets/home.py b/src/preppipe_gui_pyside6/toolwidgets/home.py index 72215fe..4f3a00e 100644 --- a/src/preppipe_gui_pyside6/toolwidgets/home.py +++ b/src/preppipe_gui_pyside6/toolwidgets/home.py @@ -8,6 +8,7 @@ from .maininput import MainInputWidget from .setting import SettingWidget from .assetbrowser import AssetBrowserWidget +from .scriptfloweditor import ScriptFlowEditorWidget class HomeWidget(QWidget, ToolWidgetInterface): _tr_toolname_home = TR_gui_mainwindow.tr("toolname_home", @@ -39,6 +40,7 @@ class HomeWidget(QWidget, ToolWidgetInterface): MainInputWidget, SettingWidget, AssetBrowserWidget, + ScriptFlowEditorWidget, ] @classmethod def getToolInfo(cls) -> ToolWidgetInfo: diff --git a/src/preppipe_gui_pyside6/toolwidgets/scriptfloweditor.py b/src/preppipe_gui_pyside6/toolwidgets/scriptfloweditor.py new file mode 100644 index 0000000..27670f2 --- /dev/null +++ b/src/preppipe_gui_pyside6/toolwidgets/scriptfloweditor.py @@ -0,0 +1,953 @@ +# -*- coding: utf-8 -*- +"""脚本流程编辑器工具:嵌入 NodeGraphQt-PySide6,与 ScriptFlowEditor 数据模型双向同步。""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +from PySide6.QtCore import Qt, QPointF +from PySide6.QtGui import QAction, QKeySequence, QShortcut +from PySide6.QtWidgets import ( + QButtonGroup, + QCheckBox, + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFileDialog, + QFormLayout, + QGraphicsProxyWidget, + QHBoxLayout, + QMenu, + QPlainTextEdit, + QPushButton, + QRadioButton, + QSpinBox, + QVBoxLayout, + QWidget, +) +from NodeGraphQt import BaseNode, NodeGraph +from NodeGraphQt.qgraphics.pipe import PipeItem + +from ScriptFlowEditor.models import ( + FlagType, + FlagVariable, + GameScriptFlow, + SegmentPath, + StorySegment, +) + +from preppipe.language import TranslationDomain + +from ..mainwindowinterface import MainWindowInterface +from ..toolwidgetinterface import ToolWidgetInterface, ToolWidgetInfo, ToolWidgetUniqueLevel + +# 节点类型标识 +_SEGMENT_NODE_TYPE = "preppipe.scriptflow.SegmentNode" +_FLAG_NODE_TYPE = "preppipe.scriptflow.FlagNode" + +TR_gui_scriptfloweditor = TranslationDomain("gui_scriptfloweditor") + + +class SegmentContentEditDialog(QDialog): + """编辑剧情段落正文的对话框,关闭时若接受则把文本写入对应 StorySegment.content。""" + + def __init__( + self, + parent: QWidget | None, + segment: StorySegment, + *, + title: str = "", + placeholder: str = "", + ): + super().__init__(parent) + self._segment = segment + self.setWindowTitle(title or ("Edit Segment Content" if not parent else parent.tr("编辑剧情文本"))) + layout = QVBoxLayout(self) + self._text_edit = QPlainTextEdit(self) + self._text_edit.setPlaceholderText(placeholder or ("Enter segment content…" if not parent else parent.tr("在此输入该段落的剧情文本…"))) + self._text_edit.setPlainText(segment.content or "") + layout.addWidget(self._text_edit) + self._buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + self._buttons.accepted.connect(self.accept) + self._buttons.rejected.connect(self.reject) + layout.addWidget(self._buttons) + + def accept(self) -> None: + self._segment.content = self._text_edit.toPlainText() + super().accept() + + +class SegmentCommentEditDialog(QDialog): + """编辑剧情段落注释的对话框,关闭时若接受则把文本写入对应 StorySegment.comment。""" + + def __init__( + self, + parent: QWidget | None, + segment: StorySegment, + *, + title: str = "", + placeholder: str = "", + ): + super().__init__(parent) + self._segment = segment + self.setWindowTitle(title or "Edit Comment") + layout = QVBoxLayout(self) + self._text_edit = QPlainTextEdit(self) + self._text_edit.setPlaceholderText(placeholder or "Enter comment…") + self._text_edit.setPlainText(segment.comment or "") + layout.addWidget(self._text_edit) + self._buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + self._buttons.accepted.connect(self.accept) + self._buttons.rejected.connect(self.reject) + layout.addWidget(self._buttons) + + def accept(self) -> None: + self._segment.comment = self._text_edit.toPlainText() + super().accept() + + +class PathConditionEditDialog(QDialog): + """编辑连线分支条件的对话框,关闭时若接受则把文本写入对应 SegmentPath.condition_expression。""" + + def __init__( + self, + parent: QWidget | None, + segment_path: SegmentPath, + *, + title: str = "", + placeholder: str = "", + ): + super().__init__(parent) + self._path = segment_path + self.setWindowTitle(title or "Edit Branch Condition") + layout = QVBoxLayout(self) + self._text_edit = QPlainTextEdit(self) + self._text_edit.setPlaceholderText(placeholder or "Enter condition expression…") + self._text_edit.setPlainText(segment_path.condition_expression or "") + layout.addWidget(self._text_edit) + self._buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + self._buttons.accepted.connect(self.accept) + self._buttons.rejected.connect(self.reject) + layout.addWidget(self._buttons) + + def accept(self) -> None: + self._path.condition_expression = self._text_edit.toPlainText() + super().accept() + + +class FlagCommentEditDialog(QDialog): + """编辑 Flag 变量注释的对话框,关闭时若接受则把文本写入对应 FlagVariable.comment。""" + + def __init__( + self, + parent: QWidget | None, + flag_var: FlagVariable, + *, + title: str = "", + placeholder: str = "", + ): + super().__init__(parent) + self._flag_var = flag_var + self.setWindowTitle(title or "Edit Comment") + layout = QVBoxLayout(self) + self._text_edit = QPlainTextEdit(self) + self._text_edit.setPlaceholderText(placeholder or "Enter comment…") + self._text_edit.setPlainText(flag_var.comment or "") + layout.addWidget(self._text_edit) + self._buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + self._buttons.accepted.connect(self.accept) + self._buttons.rejected.connect(self.reject) + layout.addWidget(self._buttons) + + def accept(self) -> None: + self._flag_var.comment = self._text_edit.toPlainText() + super().accept() + + +class FlagInitialValueEditDialog(QDialog): + """编辑 Flag 变量初始值的对话框,根据 flag_type 显示布尔单选/整数/浮点数输入。""" + + def __init__( + self, + parent: QWidget | None, + flag_var: FlagVariable, + *, + title: str = "", + ): + super().__init__(parent) + self._flag_var = flag_var + self.setWindowTitle(title or "Edit Flag Initial Value") + layout = QFormLayout(self) + if flag_var.flag_type == FlagType.BOOL: + group = QButtonGroup(self) + self._radio_true = QRadioButton("True", self) + self._radio_false = QRadioButton("False", self) + group.addButton(self._radio_true) + group.addButton(self._radio_false) + self._radio_true.setChecked(bool(flag_var.initial_value)) + self._radio_false.setChecked(not bool(flag_var.initial_value)) + bool_row = QWidget(self) + bool_layout = QHBoxLayout(bool_row) + bool_layout.setContentsMargins(0, 0, 0, 0) + bool_layout.addWidget(self._radio_true) + bool_layout.addWidget(self._radio_false) + self._widget = bool_row + elif flag_var.flag_type == FlagType.INT: + self._widget = QSpinBox(self) + self._widget.setRange(-(2**31), 2**31 - 1) + self._widget.setValue(int(flag_var.initial_value)) + else: + self._widget = QDoubleSpinBox(self) + self._widget.setDecimals(6) + self._widget.setRange(-1e308, 1e308) + self._widget.setValue(float(flag_var.initial_value)) + layout.addRow(self._widget) + self._buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + self._buttons.accepted.connect(self.accept) + self._buttons.rejected.connect(self.reject) + layout.addRow(self._buttons) + + def accept(self) -> None: + w = self._widget + if self._flag_var.flag_type == FlagType.BOOL: + self._flag_var.initial_value = self._radio_true.isChecked() + elif self._flag_var.flag_type == FlagType.INT: + self._flag_var.initial_value = w.value() + else: + self._flag_var.initial_value = w.value() + super().accept() + + +class SegmentNode(BaseNode): + """剧情段落节点:一个输入、一个输出,对应数据层 StorySegment。""" + __identifier__ = "preppipe.scriptflow" + NODE_NAME = "Segment" + + def __init__(self): + super().__init__() + self.add_input("in", multi_input=True, display_name=True) + self.add_output("out", display_name=True) + + +class FlagNode(BaseNode): + """Flag 节点:无端口、不允许连线,对应数据层 FlagVariable;中间显示 initial_value。""" + __identifier__ = "preppipe.scriptflow" + NODE_NAME = "Flag" + + def __init__(self): + super().__init__() + # 不添加 input/output,节点不可连线 + self.add_text_input("initial_value_display", label="", text="—") + w = self.get_widget("initial_value_display") + le = w.get_custom_widget() + le.setReadOnly(True) + le.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) # 避免关闭对话框后弹出 QLineEdit 默认菜单(Select All 等) + QGraphicsProxyWidget.setToolTip(w, "") # 禁用显示初始值的控件的 tooltip(避免库默认加属性名) + + +class ScriptFlowEditorWidget(QWidget, ToolWidgetInterface): + """脚本流程编辑器 Tab:内嵌节点图,与 GameScriptFlow 数据层双向同步。""" + + # 多语言:与 setting、home 等一致,使用 TranslationDomain + bind_text,语言切换时由 update_text 更新 + # _tr_add_node = TR_gui_scriptfloweditor.tr("add_node", en="Add Node", zh_cn="添加节点", zh_hk="添加節點") + _tr_add_script_node = TR_gui_scriptfloweditor.tr("add_script_node", en="Add Script Node", zh_cn="增加剧情节点", zh_hk="增加劇情節點") + _tr_add_flag = TR_gui_scriptfloweditor.tr("add_flag_variable", en="Add Flag Variable", zh_cn="添加Flag变量", zh_hk="添加Flag變量") + # _tr_btn_add_flag = TR_gui_scriptfloweditor.tr("btn_add_flag", en="Add Flag", zh_cn="添加Flag", zh_hk="添加Flag") + _tr_btn_add_flag_tooltip = TR_gui_scriptfloweditor.tr("btn_add_flag_tooltip", en="Add a flag variable node.", zh_cn="添加一个 Flag 变量节点", zh_hk="添加一個 Flag 變量節點") + _tr_edit_segment_content = TR_gui_scriptfloweditor.tr("edit_segment_content", en="Edit Segment Content", zh_cn="编辑剧情文本", zh_hk="編輯劇情文本") + _tr_edit_comment = TR_gui_scriptfloweditor.tr("edit_comment", en="Edit Comment", zh_cn="编辑注释", zh_hk="編輯註釋") + _tr_edit_dialog_title = TR_gui_scriptfloweditor.tr("edit_dialog_title", en="Edit Segment Content", zh_cn="编辑剧情文本", zh_hk="編輯劇情文本") + _tr_edit_comment_dialog_title = TR_gui_scriptfloweditor.tr("edit_comment_dialog_title", en="Edit Comment", zh_cn="编辑注释", zh_hk="編輯註釋") + _tr_edit_comment_dialog_placeholder = TR_gui_scriptfloweditor.tr("edit_comment_dialog_placeholder", en="Enter comment for this node…", zh_cn="在此输入该节点的注释…", zh_hk="在此輸入該節點的註釋…") + _tr_edit_dialog_placeholder = TR_gui_scriptfloweditor.tr("edit_dialog_placeholder", en="Enter the segment story text here…", zh_cn="在此输入该段落的剧情文本…", zh_hk="在此輸入該段落的劇情文本…") + # _tr_btn_add = TR_gui_scriptfloweditor.tr("btn_add", en="Add Segment Node", zh_cn="增加剧情节点", zh_hk="增加劇情節點") + _tr_btn_add_tooltip = TR_gui_scriptfloweditor.tr("btn_add_tooltip", en="Add a new segment node at the top-left of the view.", zh_cn="当前视图左上角新增一个剧情节点", zh_hk="當前視圖左上角新增一個劇情節點") + _tr_btn_del = TR_gui_scriptfloweditor.tr("btn_del", en="Delete Selected", zh_cn="删除选中节点", zh_hk="刪除選中節點") + _tr_btn_del_tooltip = TR_gui_scriptfloweditor.tr("btn_del_tooltip", en="Delete the selected nodes.", zh_cn="删除当前选中节点", zh_hk="刪除當前選中節點") + _tr_chk_acyclic = TR_gui_scriptfloweditor.tr("chk_acyclic", en="Acyclic", zh_cn="无环模式", zh_hk="無環模式") + _tr_chk_acyclic_tooltip = TR_gui_scriptfloweditor.tr("chk_acyclic_tooltip", en="When checked, cycles are not allowed.", zh_cn="勾选后,禁止创建闭环连接", zh_hk="勾選後,禁止創建閉環連接") + _tr_save_flow = TR_gui_scriptfloweditor.tr("save_flow", en="Save Flow", zh_cn="保存流程", zh_hk="保存流程") + _tr_save_tooltip = TR_gui_scriptfloweditor.tr("save_tooltip", en="Save current flow and node positions to a JSON file.", zh_cn="将当前流程与节点位置保存为 JSON 文件", zh_hk="將當前流程與節點位置保存為 JSON 文件") + _tr_load_flow = TR_gui_scriptfloweditor.tr("load_flow", en="Load Flow", zh_cn="打开流程", zh_hk="打開流程") + _tr_open_tooltip = TR_gui_scriptfloweditor.tr("open_tooltip", en="Load flow and node positions from a JSON file.", zh_cn="从 JSON 文件加载流程与节点位置", zh_hk="從 JSON 文件加載流程與節點位置") + _tr_save_dialog_title = TR_gui_scriptfloweditor.tr("save_dialog_title", en="Save Flow", zh_cn="保存流程", zh_hk="保存流程") + _tr_open_dialog_title = TR_gui_scriptfloweditor.tr("open_dialog_title", en="Open Flow", zh_cn="打开流程", zh_hk="打開流程") + _tr_json_filter = TR_gui_scriptfloweditor.tr("json_filter", en="JSON files (*.json);;All files (*)", zh_cn="JSON 文件 (*.json);;所有文件 (*)", zh_hk="JSON 文件 (*.json);;所有文件 (*)") + + _tr_node_tooltip_ending = TR_gui_scriptfloweditor.tr("node_tooltip_ending", en="Ending segment.", zh_cn="剧本结局节点", zh_hk="劇本結局節點") + _tr_node_tooltip_start = TR_gui_scriptfloweditor.tr("node_tooltip_start", en="Start segment.", zh_cn="剧本起始节点", zh_hk="劇本起始節點") + _tr_node_tooltip_default = TR_gui_scriptfloweditor.tr("node_tooltip_default", en="Segment node.", zh_cn="新剧情段落节点", zh_hk="新劇情段落節點") + _tr_node_tooltip_flag_default = TR_gui_scriptfloweditor.tr("node_tooltip_flag_default", en="Flag variable node.", zh_cn="Flag 变量节点", zh_hk="Flag 變量節點") + _tr_node_name_edit_tooltip = TR_gui_scriptfloweditor.tr("node_name_edit_tooltip", en="Double-click to edit node name.", zh_cn="双击编辑节点名称", zh_hk="雙擊編輯節點名稱") + + _tr_flag_change_type = TR_gui_scriptfloweditor.tr("flag_change_type", en="Change Flag Type", zh_cn="修改flag变量类型", zh_hk="修改flag變量類型") + _tr_flag_type_bool = TR_gui_scriptfloweditor.tr("flag_type_bool", en="Boolean", zh_cn="布尔型", zh_hk="布爾型") + _tr_flag_type_int = TR_gui_scriptfloweditor.tr("flag_type_int", en="Integer", zh_cn="整型", zh_hk="整型") + _tr_flag_type_float = TR_gui_scriptfloweditor.tr("flag_type_float", en="Float", zh_cn="浮点型", zh_hk="浮點型") + _tr_flag_change_initial = TR_gui_scriptfloweditor.tr("flag_change_initial", en="Change Flag Initial Value", zh_cn="修改flag初始值", zh_hk="修改flag初始值") + _tr_flag_initial_dialog_title = TR_gui_scriptfloweditor.tr("flag_initial_dialog_title", en="Edit Flag Initial Value", zh_cn="修改flag初始值", zh_hk="修改flag初始值") + + _tr_edit_branch_condition = TR_gui_scriptfloweditor.tr("edit_branch_condition", en="Edit Branch Condition", zh_cn="编辑分支条件", zh_hk="編輯分支條件") + _tr_edit_branch_condition_dialog_title = TR_gui_scriptfloweditor.tr("edit_branch_condition_dialog_title", en="Edit Branch Condition", zh_cn="编辑分支条件", zh_hk="編輯分支條件") + _tr_edit_branch_condition_placeholder = TR_gui_scriptfloweditor.tr("edit_branch_condition_placeholder", en="Enter condition expression for this path…", zh_cn="在此输入该连线的条件表达式…", zh_hk="在此輸入該連線的條件表達式…") + + _tr_toolname = MainWindowInterface.tr_toolname_scriptflow_editor + + @classmethod + def getToolInfo(cls) -> ToolWidgetInfo: + return ToolWidgetInfo( + idstr="scriptflow_editor", + name=ScriptFlowEditorWidget._tr_toolname, + widget=cls, + uniquelevel=ToolWidgetUniqueLevel.SINGLE_INSTANCE, + ) + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + + self._graph = NodeGraph(parent=self) + self._graph.register_node(SegmentNode) + self._graph.register_node(FlagNode) + self._graph.set_acyclic(True) + + # 数据层:与节点视图一一对应 + self._flow = GameScriptFlow(name="ScriptFlow") + self._node_id_to_segment_id: dict[str, str] = {} + self._node_id_to_flag_id: dict[str, str] = {} + # 为 True 时表示正在根据 flow 构建图,不把连线/断开同步回 flow,避免重复路径与误判闭环 + self._building_from_flow = False + + # 视图 → 数据:连线、删除、改名 + self._graph.port_connected.connect(self._on_port_connected) + self._graph.port_disconnected.connect(self._on_port_disconnected) + self._graph.nodes_deleted.connect(self._on_nodes_deleted) + self._graph.viewer().node_name_changed.connect(self._on_node_name_changed) + + # 在节点图右键菜单中增加「Add Node」;右键空白处时在点击位置创建节点;右键节点时记录节点 id 供 Flag 子菜单使用 + self._last_context_menu_scene_pos = None + self._last_context_menu_node_id: str | None = None + # 右键前缓存的选中连线(右键后 selection 可能被清空,用于「编辑分支条件」) + self._context_menu_pipe_selection: list = [] + self._graph.context_menu_prompt.connect(self._on_context_menu_prompt) + graph_menu = self._graph.viewer().context_menus()["graph"] + self._ctx_action_add_node = QAction(self._tr_add_script_node.get(), self) + self._ctx_action_add_node.triggered.connect(self._on_add_node) + graph_menu.addAction(self._ctx_action_add_node) + self.bind_text(self._ctx_action_add_node.setText, self._tr_add_script_node) + self._ctx_action_add_flag = QAction(self._tr_add_flag.get(), self) + self._ctx_action_add_flag.triggered.connect(self._on_add_flag) + graph_menu.addAction(self._ctx_action_add_flag) + self.bind_text(self._ctx_action_add_flag.setText, self._tr_add_flag) + self._ctx_action_edit_path_condition = QAction(self._tr_edit_branch_condition.get(), self) + self._ctx_action_edit_path_condition.triggered.connect(self._on_edit_path_condition) + graph_menu.addAction(self._ctx_action_edit_path_condition) + self.bind_text(self._ctx_action_edit_path_condition.setText, self._tr_edit_branch_condition) + + # 节点右键菜单:编辑剧情文本(保留 command 引用以便 update_text 时更新文案) + nodes_menu = self._graph.get_context_menu("nodes") + self._ctx_command_edit_content = nodes_menu.add_command( + self._tr_edit_segment_content.get(), + func=self._on_edit_segment_content, + node_class=SegmentNode, + ) + self._ctx_command_edit_comment = nodes_menu.add_command( + self._tr_edit_comment.get(), + func=self._on_edit_segment_comment, + node_class=SegmentNode, + ) + # FlagNode 右键菜单:「修改flag变量类型」二级菜单(布尔型/整型/浮点型)、「修改flag初始值」 + self._ctx_command_edit_flag_initial = nodes_menu.add_command( + self._tr_flag_change_initial.get(), + func=self._on_edit_flag_initial_value, + node_class=FlagNode, + ) + self._ctx_command_edit_flag_comment = nodes_menu.add_command( + self._tr_edit_comment.get(), + func=self._on_edit_flag_comment, + node_class=FlagNode, + ) + # 在 FlagNode 子菜单前插入「修改flag变量类型」二级菜单(库只对直接 action 设置 node_id,子菜单项用 _last_context_menu_node_id) + nodes_qmenu = nodes_menu.qmenu + for action in nodes_qmenu.actions(): + sub = action.menu() + if sub is not None and getattr(sub, "node_class", None) is FlagNode: + type_submenu = QMenu(self._tr_flag_change_type.get(), self) + for label_tr, flag_type in [ + (self._tr_flag_type_bool, FlagType.BOOL), + (self._tr_flag_type_int, FlagType.INT), + (self._tr_flag_type_float, FlagType.FLOAT), + ]: + a = QAction(label_tr.get(), self) + a.triggered.connect(lambda checked=False, ft=flag_type: self._on_set_flag_type(ft)) + type_submenu.addAction(a) + sub.insertMenu(sub.actions()[0], type_submenu) + self._ctx_menu_flag_type = type_submenu + break + else: + self._ctx_menu_flag_type = None + + # 工具栏:所有文案用 bind_text 绑定,语言切换时由 update_text 更新 + toolbar = QWidget() + bar_layout = QHBoxLayout(toolbar) + bar_layout.setContentsMargins(4, 2, 4, 2) + self._btn_add = QPushButton(self._tr_add_script_node.get()) + self._btn_add.clicked.connect(self._on_add_node) + self.bind_text(self._btn_add.setText, self._tr_add_script_node) + self.bind_text(self._btn_add.setToolTip, self._tr_btn_add_tooltip) + self._btn_del = QPushButton(self._tr_btn_del.get()) + self._btn_del.clicked.connect(self._on_delete_selected) + self.bind_text(self._btn_del.setText, self._tr_btn_del) + self.bind_text(self._btn_del.setToolTip, self._tr_btn_del_tooltip) + self._chk_acyclic = QCheckBox(self._tr_chk_acyclic.get()) + self._chk_acyclic.setChecked(True) + self._chk_acyclic.toggled.connect(self._on_acyclic_toggled) + self.bind_text(self._chk_acyclic.setText, self._tr_chk_acyclic) + self.bind_text(self._chk_acyclic.setToolTip, self._tr_chk_acyclic_tooltip) + self._btn_save = QPushButton(self._tr_save_flow.get()) + self._btn_save.clicked.connect(self._on_save) + self.bind_text(self._btn_save.setText, self._tr_save_flow) + self.bind_text(self._btn_save.setToolTip, self._tr_save_tooltip) + self._btn_load = QPushButton(self._tr_load_flow.get()) + self._btn_load.clicked.connect(self._on_load) + self.bind_text(self._btn_load.setText, self._tr_load_flow) + self.bind_text(self._btn_load.setToolTip, self._tr_open_tooltip) + self._btn_add_flag = QPushButton(self._tr_add_flag.get()) + self._btn_add_flag.clicked.connect(self._on_add_flag) + self.bind_text(self._btn_add_flag.setText, self._tr_add_flag) + self.bind_text(self._btn_add_flag.setToolTip, self._tr_btn_add_flag_tooltip) + bar_layout.addWidget(self._btn_add) + bar_layout.addWidget(self._btn_add_flag) + bar_layout.addWidget(self._btn_del) + bar_layout.addWidget(self._chk_acyclic) + bar_layout.addWidget(self._btn_save) + bar_layout.addWidget(self._btn_load) + bar_layout.addStretch() + main_layout.addWidget(toolbar) + + # 从数据层构建初始图:Start → Ending + self._build_initial_flow() + self._build_graph_from_flow() + + main_layout.addWidget(self._graph.widget) + for key in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace): + shortcut = QShortcut(QKeySequence(key), self, context=Qt.ShortcutContext.WidgetWithChildrenShortcut) + shortcut.activated.connect(self._on_delete_key) + + def update_text(self) -> None: + super().update_text() + # 图右键菜单项需随语言更新 + self._ctx_action_add_node.setText(self._tr_add_script_node.get()) + self._ctx_action_add_flag.setText(self._tr_add_flag.get()) + self._ctx_action_edit_path_condition.setText(self._tr_edit_branch_condition.get()) + # 节点右键菜单由库管理,需手动更新 + self._ctx_command_edit_content.qaction.setText(self._tr_edit_segment_content.get()) + self._ctx_command_edit_comment.qaction.setText(self._tr_edit_comment.get()) + self._ctx_command_edit_flag_initial.qaction.setText(self._tr_flag_change_initial.get()) + self._ctx_command_edit_flag_comment.qaction.setText(self._tr_edit_comment.get()) + if hasattr(self, "_ctx_menu_flag_type") and self._ctx_menu_flag_type is not None: + self._ctx_menu_flag_type.setTitle(self._tr_flag_change_type.get()) + for action, label_tr in zip( + self._ctx_menu_flag_type.actions(), + (self._tr_flag_type_bool, self._tr_flag_type_int, self._tr_flag_type_float), + ): + action.setText(label_tr.get()) + # 所有节点及名称区的 tooltip 随语言更新 + for node in self._graph.all_nodes(): + seg_id = self._node_id_to_segment_id.get(node.id) + if seg_id is not None: + seg = self._flow.get_segment_by_id(seg_id) + if seg is not None: + self._set_node_tooltips(node, seg) + continue + flag_id = self._node_id_to_flag_id.get(node.id) + if flag_id is not None: + flag_var = self._flow.get_flag_by_id(flag_id) + if flag_var is not None: + self._set_flag_node_tooltips(node, flag_var) + + def _build_initial_flow(self) -> None: + """数据层初始状态:Start 段落、Ending 段落、一条路径。""" + seg_start = StorySegment(name="Start", content="\"剧情从这里开始。\"", is_ending_segment=False) + seg_ending = StorySegment(name="Ending", content="\"剧情在这里结束。\"", is_ending_segment=True) + self._flow.segments = [seg_start, seg_ending] + path = SegmentPath(prev_segment_id=seg_start.id, next_segment_id=seg_ending.id) + self._flow.paths = [path] + seg_start.add_path_segment_id(path.id, seg_ending.id) + + def _build_graph_from_flow( + self, + segment_positions: dict[str, list[float]] | None = None, + flag_positions: dict[str, list[float]] | None = None, + ) -> None: + """根据当前 flow 在图中创建节点与连线;segment_positions / flag_positions 用于恢复位置。""" + self._building_from_flow = True + try: + self._build_graph_from_flow_impl( + segment_positions=segment_positions, + flag_positions=flag_positions, + ) + finally: + self._building_from_flow = False + + def _build_graph_from_flow_impl( + self, + segment_positions: dict[str, list[float]] | None = None, + flag_positions: dict[str, list[float]] | None = None, + ) -> None: + segment_positions = segment_positions or {} + flag_positions = flag_positions or {} + default_positions = [(0, 0), (400, 0)] + for i, seg in enumerate(self._flow.segments): + pos = segment_positions.get(seg.id) + if pos is not None and len(pos) >= 2: + pos = (float(pos[0]), float(pos[1])) + else: + pos = default_positions[i] if i < len(default_positions) else (0, 0) + node = self._graph.create_node( + _SEGMENT_NODE_TYPE, + name=seg.name, + pos=pos, + ) + self._node_id_to_segment_id[node.id] = seg.id + self._set_node_tooltips(node, seg) + for f in self._flow.flags: + pos = flag_positions.get(f.id) + if pos is not None and len(pos) >= 2: + pos = (float(pos[0]), float(pos[1])) + else: + pos = (0, 0) + node = self._graph.create_node( + _FLAG_NODE_TYPE, + name=f.name, + pos=pos, + ) + self._node_id_to_flag_id[node.id] = f.id + self._update_flag_node_display(node) + self._set_flag_node_tooltips(node, f) + seg_id_to_node = { + self._node_id_to_segment_id[n.id]: n + for n in self._graph.all_nodes() + if n.id in self._node_id_to_segment_id + } + for path in self._flow.paths: + prev_node = seg_id_to_node.get(path.prev_segment_id) + next_node = seg_id_to_node.get(path.next_segment_id) + if prev_node is not None and next_node is not None: + prev_node.set_output(0, next_node.input(0)) + self._update_pipe_tooltips() + + def _update_pipe_tooltips(self) -> None: + """根据 SegmentPath.condition_expression 刷新场景中所有连线的 tooltip。""" + scene = self._graph.viewer().scene() + for item in scene.items(): + if not isinstance(item, PipeItem): + continue + if not item.input_port or not item.output_port: + continue + prev_seg_id = self._node_id_to_segment_id.get(item.output_port.node.id) + next_seg_id = self._node_id_to_segment_id.get(item.input_port.node.id) + if prev_seg_id is None or next_seg_id is None: + item.setToolTip("") + continue + path = next( + (p for p in self._flow.paths if p.prev_segment_id == prev_seg_id and p.next_segment_id == next_seg_id), + None, + ) + text = (path.condition_expression or "").strip() if path is not None else "" + item.setToolTip(text) + + def _set_node_tooltips(self, node, segment: StorySegment) -> None: + """根据段落类型或注释设置节点与名称区的 tooltip(使用当前语言)。有注释时节点 tooltip 显示注释。""" + if segment.comment and segment.comment.strip(): + node.view.setToolTip(segment.comment.strip()) + elif segment.is_ending_segment: + node.view.setToolTip(self._tr_node_tooltip_ending.get()) + elif segment.name == "Start": + node.view.setToolTip(self._tr_node_tooltip_start.get()) + else: + node.view.setToolTip(self._tr_node_tooltip_default.get()) + node.view.text_item.setToolTip(self._tr_node_name_edit_tooltip.get()) + + def _set_flag_node_tooltips(self, node, flag_var: FlagVariable) -> None: + """根据 FlagVariable.comment 或当前语言默认文案设置 Flag 节点及名称区的 tooltip。""" + if flag_var.comment and flag_var.comment.strip(): + node.view.setToolTip(flag_var.comment.strip()) + else: + node.view.setToolTip(self._tr_node_tooltip_flag_default.get()) + node.view.text_item.setToolTip(self._tr_node_name_edit_tooltip.get()) + + def _format_flag_initial_value(self, flag_var: FlagVariable) -> str: + """将 FlagVariable.initial_value 格式化为节点中部显示的字符串。""" + if flag_var.flag_type == FlagType.BOOL: + return "True" if flag_var.initial_value else "False" + if flag_var.flag_type == FlagType.INT: + return str(int(flag_var.initial_value)) + return str(float(flag_var.initial_value)) + + def _update_flag_node_display(self, node) -> None: + """根据节点对应的 FlagVariable 更新节点中部的 initial_value 显示。""" + flag_id = self._node_id_to_flag_id.get(node.id) + if flag_id is None: + return + flag_var = self._flow.get_flag_by_id(flag_id) + if flag_var is None: + return + if node.view.has_widget("initial_value_display"): + node.get_widget("initial_value_display").set_value( + self._format_flag_initial_value(flag_var) + ) + + def _next_segment_name(self) -> str: + """生成下一个 segment_ 名称,保证不与已有节点重名。""" + pattern = re.compile(r"^segment_(\d+)$") + max_index = -1 + for node in self._graph.all_nodes(): + m = pattern.match(node.name()) + if m: + max_index = max(max_index, int(m.group(1))) + return "segment_{}".format(max_index + 1) + + def _next_flag_name(self) -> str: + """生成下一个 flag_ 名称,保证不与已有节点重名。""" + pattern = re.compile(r"^flag_(\d+)$") + max_index = -1 + for node in self._graph.all_nodes(): + m = pattern.match(node.name()) + if m: + max_index = max(max_index, int(m.group(1))) + return "flag_{}".format(max_index + 1) + + def _on_context_menu_prompt(self, _menu: object, node: object) -> None: + """右键菜单即将弹出时记录场景坐标,供「Add Node」在点击位置创建节点;记录节点 id 供 Flag 子菜单使用;图菜单时根据光标下或已选中的单条连线启用「编辑分支条件」。""" + if node is None: + pos = self._graph.viewer().scene_cursor_pos() + self._last_context_menu_scene_pos = (pos.x(), pos.y()) + self._last_context_menu_node_id = None + # 优先用当前选中连线;若无则取光标下的连线(右键后 selection 常被清空) + self._context_menu_pipe_selection = list(self._graph.selected_pipes()) + if len(self._context_menu_pipe_selection) != 1: + scene = self._graph.viewer().scene() + pt = QPointF(pos.x(), pos.y()) + items_at = scene.items(pt) + pipes_at = [i for i in items_at if isinstance(i, PipeItem)] + if len(pipes_at) == 1: + old_sel = list(scene.selectedItems()) + scene.clearSelection() + pipes_at[0].setSelected(True) + self._context_menu_pipe_selection = list(self._graph.selected_pipes()) + scene.clearSelection() + for item in old_sel: + item.setSelected(True) + self._ctx_action_edit_path_condition.setEnabled(len(self._context_menu_pipe_selection) == 1) + else: + self._last_context_menu_scene_pos = None + self._last_context_menu_node_id = node.id + + def _on_add_node(self) -> None: + if self._last_context_menu_scene_pos is not None: + pos = self._last_context_menu_scene_pos + self._last_context_menu_scene_pos = None + else: + pos = (0, 0) + name = self._next_segment_name() + segment = StorySegment(name=name, content="", is_ending_segment=False) + self._flow.segments.append(segment) + node = self._graph.create_node(_SEGMENT_NODE_TYPE, name=name, pos=pos) + self._node_id_to_segment_id[node.id] = segment.id + self._set_node_tooltips(node, segment) + + def _on_add_flag(self) -> None: + """在图中添加一个 FlagNode,并在 flow.flags 中新增对应 FlagVariable。""" + if self._last_context_menu_scene_pos is not None: + pos = self._last_context_menu_scene_pos + self._last_context_menu_scene_pos = None + else: + pos = (0, 0) + name = self._next_flag_name() + flag_var = FlagVariable(name=name, flag_type=FlagType.BOOL, initial_value=False) + self._flow.flags.append(flag_var) + node = self._graph.create_node(_FLAG_NODE_TYPE, name=name, pos=pos) + self._node_id_to_flag_id[node.id] = flag_var.id + self._update_flag_node_display(node) + self._set_flag_node_tooltips(node, flag_var) + + def _on_delete_selected(self) -> None: + self._delete_selected_pipes() + self._graph.delete_nodes(self._graph.selected_nodes()) + + def _on_acyclic_toggled(self, checked: bool) -> None: + self._graph.set_acyclic(checked) + + def _on_delete_key(self) -> None: + self._on_delete_selected() + + def _on_edit_path_condition(self) -> None: + """图右键「编辑分支条件」:对当前选中的单条连线,编辑对应 SegmentPath.condition_expression。""" + pipes = self._context_menu_pipe_selection if len(self._context_menu_pipe_selection) == 1 else self._graph.selected_pipes() + if len(pipes) != 1: + return + in_port, out_port = pipes[0] + prev_seg_id = self._node_id_to_segment_id.get(out_port.node().id) + next_seg_id = self._node_id_to_segment_id.get(in_port.node().id) + if prev_seg_id is None or next_seg_id is None: + return + path = next( + (p for p in self._flow.paths if p.prev_segment_id == prev_seg_id and p.next_segment_id == next_seg_id), + None, + ) + if path is None: + return + dialog = PathConditionEditDialog( + self, + segment_path=path, + title=self._tr_edit_branch_condition_dialog_title.get(), + placeholder=self._tr_edit_branch_condition_placeholder.get(), + ) + if dialog.exec() == QDialog.DialogCode.Accepted: + self._update_pipe_tooltips() + + def _on_port_connected(self, in_port, out_port) -> None: + """连线建立:在数据层添加 SegmentPath。NodeGraphQt 信号参数顺序为 (input_port, output_port)。同一条连线只添加一次。""" + if self._building_from_flow: + return + out_node = out_port.node() + in_node = in_port.node() + prev_seg_id = self._node_id_to_segment_id.get(out_node.id) + next_seg_id = self._node_id_to_segment_id.get(in_node.id) + if prev_seg_id is None or next_seg_id is None: + return + already = any( + p.prev_segment_id == prev_seg_id and p.next_segment_id == next_seg_id + for p in self._flow.paths + ) + if already: + return + path = SegmentPath(prev_segment_id=prev_seg_id, next_segment_id=next_seg_id) + self._flow.paths.append(path) + prev_seg = self._flow.get_segment_by_id(prev_seg_id) + if prev_seg is not None: + prev_seg.add_path_segment_id(path.id, next_seg_id) + self._update_pipe_tooltips() + + def _on_port_disconnected(self, in_port, out_port) -> None: + """连线断开:从数据层移除对应 SegmentPath。NodeGraphQt 信号参数顺序为 (input_port, output_port)。""" + if self._building_from_flow: + return + out_node = out_port.node() + in_node = in_port.node() + prev_seg_id = self._node_id_to_segment_id.get(out_node.id) + next_seg_id = self._node_id_to_segment_id.get(in_node.id) + if prev_seg_id is None or next_seg_id is None: + return + to_remove = [ + p for p in self._flow.paths + if p.prev_segment_id == prev_seg_id and p.next_segment_id == next_seg_id + ] + for p in to_remove: + self._flow.paths.remove(p) + prev_seg = self._flow.get_segment_by_id(prev_seg_id) + if prev_seg is not None and p.id in prev_seg.paths_segment_ids: + del prev_seg.paths_segment_ids[p.id] + + def _on_nodes_deleted(self, node_ids: list) -> None: + """节点删除:从数据层移除对应段落/Flag 及关联路径,并清理映射。""" + for nid in node_ids: + seg_id = self._node_id_to_segment_id.pop(nid, None) + if seg_id is not None: + self._flow.segments = [s for s in self._flow.segments if s.id != seg_id] + self._flow.paths = [ + p for p in self._flow.paths + if p.prev_segment_id != seg_id and p.next_segment_id != seg_id + ] + for s in self._flow.segments: + for path_id in list(s.paths_segment_ids.keys()): + if s.paths_segment_ids[path_id] == seg_id: + del s.paths_segment_ids[path_id] + continue + flag_id = self._node_id_to_flag_id.pop(nid, None) + if flag_id is not None: + self._flow.flags = [f for f in self._flow.flags if f.id != flag_id] + + def _on_node_name_changed(self, node_id: str, name: str) -> None: + """节点改名:同步到数据层 StorySegment.name 或 FlagVariable.name(id 保持不变),Segment 依 comment 重设 tooltip。""" + seg_id = self._node_id_to_segment_id.get(node_id) + if seg_id is not None: + seg = self._flow.get_segment_by_id(seg_id) + if seg is not None: + seg.name = name + node = self._graph.get_node_by_id(node_id) + if node is not None: + self._set_node_tooltips(node, seg) + return + flag_id = self._node_id_to_flag_id.get(node_id) + if flag_id is not None: + flag_var = self._flow.get_flag_by_id(flag_id) + if flag_var is not None: + flag_var.name = name + node = self._graph.get_node_by_id(node_id) + if node is not None: + self._set_flag_node_tooltips(node, flag_var) + + def _on_edit_segment_content(self, _graph, node) -> None: + """节点右键「编辑剧情文本」:弹出对话框编辑对应 StorySegment.content,关闭时保存。""" + seg_id = self._node_id_to_segment_id.get(node.id) + if seg_id is None: + return + seg = self._flow.get_segment_by_id(seg_id) + if seg is None: + return + dialog = SegmentContentEditDialog( + self, + segment=seg, + title=self._tr_edit_dialog_title.get(), + placeholder=self._tr_edit_dialog_placeholder.get(), + ) + dialog.exec() + + def _on_edit_segment_comment(self, _graph, node) -> None: + """节点右键「编辑注释」:弹出对话框编辑对应 StorySegment.comment,关闭时保存并设为节点 tooltip。""" + seg_id = self._node_id_to_segment_id.get(node.id) + if seg_id is None: + return + seg = self._flow.get_segment_by_id(seg_id) + if seg is None: + return + dialog = SegmentCommentEditDialog( + self, + segment=seg, + title=self._tr_edit_comment_dialog_title.get(), + placeholder=self._tr_edit_comment_dialog_placeholder.get(), + ) + if dialog.exec() == QDialog.DialogCode.Accepted: + self._set_node_tooltips(node, seg) + + def _on_set_flag_type(self, new_type: FlagType) -> None: + """将当前右键的 Flag 节点对应的 FlagVariable 的 flag_type 设为 new_type;类型未变则不改 initial_value,类型改变则 bool→False、int/float→0。""" + node_id = self._last_context_menu_node_id + if node_id is None: + return + flag_id = self._node_id_to_flag_id.get(node_id) + if flag_id is None: + return + flag_var = self._flow.get_flag_by_id(flag_id) + if flag_var is None: + return + old_type = flag_var.flag_type + flag_var.flag_type = new_type + if old_type != new_type: + flag_var.initial_value = False if new_type == FlagType.BOOL else 0 + graph_node = self._graph.get_node_by_id(node_id) + if graph_node is not None: + self._update_flag_node_display(graph_node) + + def _on_edit_flag_initial_value(self, _graph, node) -> None: + """节点右键「修改flag初始值」:弹出对话框编辑对应 FlagVariable.initial_value。""" + flag_id = self._node_id_to_flag_id.get(node.id) + if flag_id is None: + return + flag_var = self._flow.get_flag_by_id(flag_id) + if flag_var is None: + return + dialog = FlagInitialValueEditDialog( + self, + flag_var=flag_var, + title=self._tr_flag_initial_dialog_title.get(), + ) + if dialog.exec() == QDialog.DialogCode.Accepted: + self._update_flag_node_display(node) + self._set_flag_node_tooltips(node, flag_var) + + def _on_edit_flag_comment(self, _graph, node) -> None: + """节点右键「编辑注释」:弹出对话框编辑对应 FlagVariable.comment,关闭时同步到节点 tooltip。""" + flag_id = self._node_id_to_flag_id.get(node.id) + if flag_id is None: + return + flag_var = self._flow.get_flag_by_id(flag_id) + if flag_var is None: + return + dialog = FlagCommentEditDialog( + self, + flag_var=flag_var, + title=self._tr_edit_comment_dialog_title.get(), + placeholder=self._tr_edit_comment_dialog_placeholder.get(), + ) + if dialog.exec() == QDialog.DialogCode.Accepted: + self._set_flag_node_tooltips(node, flag_var) + + def _on_save(self) -> None: + """将当前流程数据与节点位置保存为 JSON 文件。""" + path, _ = QFileDialog.getSaveFileName( + self, + self._tr_save_dialog_title.get(), + "", + self._tr_json_filter.get(), + ) + if not path: + return + path = Path(path) + if path.suffix.lower() != ".json": + path = path.with_suffix(".json") + flow_dict = self._flow.to_dict() + node_positions = {} + flag_positions = {} + for node in self._graph.all_nodes(): + pos = getattr(node.model, "pos", [0.0, 0.0]) + pos_list = list(pos) if isinstance(pos, (list, tuple)) else [0.0, 0.0] + seg_id = self._node_id_to_segment_id.get(node.id) + if seg_id is not None: + node_positions[seg_id] = pos_list + else: + flag_id = self._node_id_to_flag_id.get(node.id) + if flag_id is not None: + flag_positions[flag_id] = pos_list + flow_dict["node_positions"] = node_positions + flow_dict["flag_positions"] = flag_positions + path.write_text( + json.dumps(flow_dict, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + def _on_load(self) -> None: + """从 JSON 文件加载流程数据与节点位置,重建视图。""" + path, _ = QFileDialog.getOpenFileName( + self, + self._tr_open_dialog_title.get(), + "", + self._tr_json_filter.get(), + ) + if not path: + return + path = Path(path) + if not path.is_file(): + return + data = json.loads(path.read_text(encoding="utf-8")) + node_positions = data.pop("node_positions", {}) + flag_positions = data.pop("flag_positions", {}) + self._node_id_to_segment_id.clear() + self._node_id_to_flag_id.clear() + for node in list(self._graph.all_nodes()): + self._graph.delete_nodes([node]) + self._flow = GameScriptFlow.load_from_dict(data) + self._build_graph_from_flow( + segment_positions=node_positions, + flag_positions=flag_positions, + ) + + def _delete_selected_pipes(self) -> None: + """删除当前选中的连线。selected_pipes() 返回 (Port, Port) 列表。""" + for port1, port2 in self._graph.selected_pipes(): + port1.disconnect_from(port2)