Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
5c49430
Add Fgui-Converter Entry
CursedOctopus Sep 22, 2025
c386335
Add fgui_converter modul.
CursedOctopus Sep 29, 2025
06fc751
Merge branch 'main' into OctopusBranch
CursedOctopus Oct 16, 2025
73b3cc5
Add independency in setup.cfg
CursedOctopus Oct 17, 2025
02a7c36
Modify README.md, add `-o` option to `pyside6-uic` command.
CursedOctopus Oct 17, 2025
0f867ce
Add null "input/output" popup and fix some Converter bug
CursedOctopus Oct 18, 2025
99cc204
Merge branch 'main' into OctopusBranch
CursedOctopus Oct 31, 2025
743a764
Restructured button converter method
CursedOctopus Nov 4, 2025
ac12c0e
Add text style within Button
CursedOctopus Nov 4, 2025
8076bc9
Update Text drop-shadow outline method
CursedOctopus Nov 5, 2025
f615030
Support "Slider" component
CursedOctopus Nov 7, 2025
a8d78a9
Support sliders in screen
CursedOctopus Nov 9, 2025
33634fc
Add special screens (choice, say) converting method.
CursedOctopus Nov 11, 2025
82b2e34
Some component, as button and slider, could get custom data from sour…
CursedOctopus Nov 11, 2025
616a52c
Add special screens (say, save, load) converting method.
CursedOctopus Nov 12, 2025
00f0ac6
Add special screens (history, gallery) converting method.
CursedOctopus Nov 13, 2025
96f9289
Children of screen could be shown or hide by Fgui controllers.
CursedOctopus Nov 13, 2025
1b3e15f
Fix issue of save/load screen lack of tag menu.
CursedOctopus Nov 14, 2025
dc54c12
Implementation hidden(part display) and scrollable with screen level.
CursedOctopus Dec 8, 2025
a24f709
Implementation of screen "gallery".
CursedOctopus Jan 6, 2026
dfe49e2
Add music_room screen implementation
CursedOctopus Jan 10, 2026
0b36b80
Merge branch 'main' into OctopusBranch
CursedOctopus Jan 18, 2026
abed545
Implementation converting of ScrollBar
CursedOctopus Jan 24, 2026
cf7aa3d
Fix issue of bounding-box size with component.
CursedOctopus Feb 2, 2026
ac0375f
Add ScriptFlowEditor module
CursedOctopus Feb 12, 2026
2ea3e12
Merge branch 'main' into OctopusBranch
CursedOctopus Feb 13, 2026
4c8f8fe
Integrated Script Flow Editor with main pipe.
CursedOctopus Feb 14, 2026
34961cb
Update .gitignore
CursedOctopus Feb 16, 2026
903ad25
Update .gitignore
CursedOctopus Feb 16, 2026
d9913e9
Merge branch 'main' into OctopusBranch
CursedOctopus Feb 16, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,4 @@ thumbnails

# version files for pyinstaller build
versionfile_*.txt
/src/ScriptFlowEditor
25 changes: 25 additions & 0 deletions src/ScriptFlowEditor/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
"""
文字 ADV 脚本流程编辑器的数据模型与(后续)视图层入口。

当前仅实现数据模型层:
- 剧情段落(节点)
- 段落间连接条件
- 段落连接条件相关变量(flag)
"""

from ScriptFlowEditor.models import (
FlagType,
FlagVariable,
StorySegment,
SegmentPath,
GameScriptFlow,
)

__all__ = [
"FlagType",
"FlagVariable",
"StorySegment",
"SegmentPath",
"GameScriptFlow",
]
16 changes: 16 additions & 0 deletions src/ScriptFlowEditor/basicflow_deserialization_test.py
Original file line number Diff line number Diff line change
@@ -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}")
37 changes: 37 additions & 0 deletions src/ScriptFlowEditor/basicflow_serialization_test.py
Original file line number Diff line number Diff line change
@@ -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()
17 changes: 17 additions & 0 deletions src/ScriptFlowEditor/models/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
88 changes: 88 additions & 0 deletions src/ScriptFlowEditor/models/flag.py
Original file line number Diff line number Diff line change
@@ -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__}"
)
197 changes: 197 additions & 0 deletions src/ScriptFlowEditor/models/gamescriptflow.py
Original file line number Diff line number Diff line change
@@ -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
Loading