diff --git a/src/preppipe/exceptions.py b/src/preppipe/exceptions.py index b5e8ae2..7f927dd 100644 --- a/src/preppipe/exceptions.py +++ b/src/preppipe/exceptions.py @@ -23,3 +23,10 @@ def __init__(self, msg : str = '') -> None: class PPInvalidOperationError(RuntimeError): def __init__(self, msg : str = '') -> None: super().__init__(TR_preppipe.invalid_operation.get_with_msg(msg)) + + +class PPCommandHandlerSignatureError(RuntimeError): + """注册到 resolve_call 的回调函数签名不符合约定(例如多个可按位的业务形参)。""" + + def __init__(self, msg: str = "") -> None: + super().__init__(msg) diff --git a/src/preppipe/frontend/commanddocs.py b/src/preppipe/frontend/commanddocs.py index 763a813..2e859d5 100644 --- a/src/preppipe/frontend/commanddocs.py +++ b/src/preppipe/frontend/commanddocs.py @@ -218,7 +218,7 @@ def get_type_annotation_str_impl(self, a, enumtr_list : list) -> str: return self._tr_vtype_callexpr.get() if issubclass(a, (StringLiteral, str)): return self._tr_vtype_str.get() - if issubclass(a, (FloatLiteral, decimal.Decimal, float)): + if issubclass(a, (FloatLiteral, decimal.Decimal)): return self._tr_vtype_float.get() if issubclass(a, (IntLiteral, int)): return self._tr_vtype_int.get() diff --git a/src/preppipe/frontend/commandsemantics.py b/src/preppipe/frontend/commandsemantics.py index 7903d1a..4f2c7b6 100644 --- a/src/preppipe/frontend/commandsemantics.py +++ b/src/preppipe/frontend/commandsemantics.py @@ -4,12 +4,14 @@ from __future__ import annotations +import decimal import inspect import types import typing import collections import enum import re +import unicodedata from ..irbase import * from ..inputmodel import * @@ -289,6 +291,7 @@ def add_command_handler(self, command_info : FrontendCommandInfo, func : typing. # 如果在这里报错(无法解析类型名)的话,请确保: # 1. 定义命令的源文件可以在没有 from __future__ import annotations 的情况下顺利使用类型标注 # 2. 所有在回调函数参数类型的标注中的类全都在 imports 中(不过 imports 也可以为空) + validate_frontend_command_handler_semantics(func, imports) sig = inspect.signature(func, globals=imports, eval_str=True) command_info.handler_list.append((func, sig)) @@ -406,6 +409,120 @@ def get_root_node(self) -> FrontendCommandNamespace: _frontend_command_registry = FrontendCommandRegistry() # ------------------------------------------------------------------------------ +# 前端命令处理函数签名的语言约束(与 handle_command_invocation / resolve_call 一致) + +def _flatten_union_parts(ann: typing.Any) -> list[typing.Any]: + if ann is None: + return [] + if isinstance(ann, types.UnionType): + out: list[typing.Any] = [] + for a in ann.__args__: + out.extend(_flatten_union_parts(a)) + return out + origin = typing.get_origin(ann) + if origin is typing.Union: + out = [] + for a in typing.get_args(ann): + out.extend(_flatten_union_parts(a)) + return out + return [ann] + + +def _annotation_contains_builtin_float(ann: typing.Any) -> bool: + return any(part is float for part in _flatten_union_parts(ann)) + + +def _is_parser_injection_annotation(ann: typing.Any) -> bool: + return isinstance(ann, type) and issubclass(ann, FrontendParserBase) + + +def _is_state_injection_annotation(ann: typing.Any) -> bool: + # 与 handle_command_invocation 中 param.annotation == get_state_type() 的用法一致;注册阶段无解析器实例,按类型名约定识别。 + return isinstance(ann, type) and ann.__name__.endswith('ParsingState') + + +def _is_extend_data_only_param_annotation(ann: typing.Any) -> bool: + parts = [p for p in _flatten_union_parts(ann) if p not in (type(None), types.NoneType)] + if not parts: + return False + return all(isinstance(t, type) and issubclass(t, ExtendDataExprBase) for t in parts) + + +def _is_list_positional_param_annotation(ann: typing.Any) -> bool: + return typing.get_origin(ann) is list and len(typing.get_args(ann)) == 1 + + +def validate_frontend_command_handler_semantics(func: typing.Callable, globalns: dict[str, typing.Any]) -> None: + """约束:禁止内置 float 标注;按位业务参数至多一个(可为 list[…] 一次吸收多项)。""" + sig = inspect.signature(func, globals=globalns, eval_str=True) + try: + hints = typing.get_type_hints(func, globalns=globalns, localns=globalns, include_extras=False) + except Exception as ex: + raise PPCommandHandlerSignatureError( + f"注册命令回调时,无法解析「{func.__name__}」的类型标注({ex})。请检查各参数注解是否完整、前向引用是否写成字符串形式。" + ) from ex + for pname, param in sig.parameters.items(): + ann = hints.get(pname, param.annotation) + if ann is inspect.Parameter.empty: + raise PPCommandHandlerSignatureError( + f"注册命令回调「{func.__name__}」时:参数「{pname}」缺少类型标注。解析器依赖注解做参数转换,请为每个参数写上类型。" + ) + if _annotation_contains_builtin_float(ann): + raise PPCommandHandlerSignatureError( + f"注册命令回调「{func.__name__}」时:参数「{pname}」勿使用内置 float,请改为 decimal.Decimal(剧本里的数字仍会按 Decimal 解析)。", + ) + + slots: list[tuple[str, typing.Any]] = [] + for pname, param in sig.parameters.items(): + if param.kind != inspect.Parameter.POSITIONAL_OR_KEYWORD: + continue + ann = hints.get(pname, param.annotation) + if _is_parser_injection_annotation(ann) or ann is Context or ann is GeneralCommandOp or _is_state_injection_annotation(ann): + continue + if _is_extend_data_only_param_annotation(ann): + continue + slots.append((pname, ann)) + + list_slots = [s for s in slots if _is_list_positional_param_annotation(s[1])] + scalar_slots = [s for s in slots if not _is_list_positional_param_annotation(s[1])] + _pos_rule_hint = ( + "说明:文档里括号内不带名字的「按位参数」会按顺序填入回调里「可同时按位置与关键字」的形参,这类形参在整条调用链上至多只能有一个;" + "其余参数必须在 Python 里写成仅关键字(单独一行 `*` 再写参数名),由剧本里的参数名(如「时长」「颜色」)传入;" + "若确实需要连续多个按位项,请改用一个 `list[元素类型]` 形参一次性接住列表。" + ) + if len(list_slots) > 1: + raise PPCommandHandlerSignatureError( + f"注册命令回调「{func.__name__}」时:list[…] 形参至多一个,当前有 {len(list_slots)} 个:{', '.join(n for n, _ in list_slots)}。{_pos_rule_hint}", + ) + if len(list_slots) == 1 and len(scalar_slots) > 0: + raise PPCommandHandlerSignatureError( + f"注册命令回调「{func.__name__}」时:已有按位列表形参 {list_slots[0][0]},不能再声明其它可按位的业务形参(" + + ", ".join(n for n, _ in scalar_slots) + + f")。请把这些参数挪到 `*` 之后改为仅关键字。{_pos_rule_hint}", + ) + if len(scalar_slots) > 1: + raise PPCommandHandlerSignatureError( + f"注册命令回调「{func.__name__}」时:可按位的业务形参至多一个,当前有 {len(scalar_slots)} 个:" + + ", ".join(n for n, _ in scalar_slots) + + f"。{_pos_rule_hint}", + ) + + +def _strip_wrapping_quotes_for_numeric(s: str) -> str: + """剥掉 Word/表格中数字外层引号(可多层),含 ASCII 与弯引号;先 NFKC 再剥。""" + s = unicodedata.normalize("NFKC", s).strip() + for _ in range(8): + if len(s) < 2: + break + if s[0] == s[-1] and s[0] in "'\"": + s = s[1:-1].strip() + continue + if s[0] in "\u201c\u2018" and s[-1] in "\u201d\u2019": + s = s[1:-1].strip() + continue + break + return s + ParserStateType = typing.TypeVar('ParserStateType') @@ -733,6 +850,11 @@ def check_is_extend_data(annotation : typing.Any) -> list[type]: zh_cn="参数未使用:{name}", zh_hk="參數未使用:{name}", ) + _tr_cmd_too_many_positionals = TR_parser.tr("cmd_too_many_positionals", + en="Too many positional arguments; they do not match the command parameters in order.", + zh_cn="按位参数过多,无法与命令参数按顺序一一对应。", + zh_hk="按位參數過多,無法與命令參數按順序一一對應。", + ) _tr_positional_arg = TR_parser.tr("positional_arg", en="", zh_cn="<按位参数>", @@ -775,10 +897,9 @@ def handle_command_invocation(self, state : ParserStateType, commandop : General is_unconstrained_param_found = False is_op_param_found = False is_extenddata_param_found = False - is_first_param_for_positional_args = True + positional_arg_index = 0 first_fatal_error : tuple[str, typing.Any] = None used_args : set[str] = set() - is_positional_arg_used = len(positional_args) == 0 is_extend_data_used = extend_data_value is None for name, param in sig.parameters.items(): @@ -858,30 +979,34 @@ def handle_command_invocation(self, state : ParserStateType, commandop : General # 该命令不是一个特殊参数,尝试赋值 # 如果 kwargs (关键字参数)有现成的值的话用这个,优先级最高 - # 其次如果这是第一个参数的话,用 positional_args (位置参数)也可以 + # 其次用按位参数:按声明顺序依次绑定到连续的 POSITIONAL_OR_KEYWORD(类型为 list[…] 的参数一次性吃掉剩余按位项) # 如果都没有的话如果有默认值的话用默认值 # 都没有的话就算错误,不能用这个回调函数 - # 另外如果提供了 positional_args 但是在 kwargs 里有值的话同样报错,我们只接受对第一个参数使用 positional args + # 若尚未消费任何按位参数时第一个业务参数却来自关键字,则不接受同时出现按位参数(与原行为一致) if name in kwargs: - if is_first_param_for_positional_args and len(positional_args) > 0: + if positional_arg_index == 0 and len(positional_args) > 0: first_fatal_error = ('cmdparser-unused-positional-args', self._tr_unused_positional_args.format(name=name)) break used_args.add(name) - is_first_param_for_positional_args = False first_fatal_error = cur_match.try_add_parameter(param, kwargs[name]) if first_fatal_error is not None: break continue - if is_first_param_for_positional_args and len(positional_args) > 0: - # 如果该参数只能以 kwargs 出现的话也报错 + if positional_arg_index < len(positional_args): if param.kind == inspect.Parameter.KEYWORD_ONLY: first_fatal_error = ('cmdparser-kwarg-using-positional-value', self._tr_kwarg_using_positional_value.format(name=name)) break - is_positional_arg_used = True - is_first_param_for_positional_args = False - first_fatal_error = cur_match.try_add_parameter(param, positional_args) + if typing.get_origin(param.annotation) is list: + rest = positional_args[positional_arg_index:] + first_fatal_error = cur_match.try_add_parameter(param, rest) + if first_fatal_error is not None: + break + positional_arg_index = len(positional_args) + continue + first_fatal_error = cur_match.try_add_parameter(param, positional_args[positional_arg_index]) if first_fatal_error is not None: break + positional_arg_index += 1 continue if param.default != inspect.Parameter.empty: cur_match.add_parameter(param, param.default) @@ -892,6 +1017,7 @@ def handle_command_invocation(self, state : ParserStateType, commandop : General if first_fatal_error is not None: unmatched_results.append((cb, first_fatal_error)) continue + is_positional_arg_used = positional_arg_index == len(positional_args) # 检查是否有参数没用上,有的话同样报错(除非有 UnconstrainedValue) if not is_unconstrained_param_found: if not is_positional_arg_used: @@ -1006,6 +1132,11 @@ def to_tuple(self) -> tuple[int, int]: zh_cn="浮点数", zh_hk="浮點數", ) + tr_typename_decimal = TR_parser.tr(code='cmdparser-typename-decimal', + en="Decimal", + zh_cn="Decimal(decimal.Decimal)", + zh_hk="Decimal(decimal.Decimal)", + ) tr_unrecognized_exprname = TR_parser.tr(code='cmdparser-unrecognized-exprname', en="Unrecognized expression name: \"{exprname}\", expecting: {exprnamelist}", zh_cn="无法识别的表达式名:\"{exprname}\",支持的表达式如下:{exprnamelist}", @@ -1021,16 +1152,28 @@ def _populate_validargs_str(paramdict : dict[str, Translatable]) -> str: # 用于在一系列可选的调用中选择一个回调函数并执行 @staticmethod - def resolve_call(callexpr : CallExprOperand, candidate_list : list[tuple[Translatable | list[Translatable], typing.Callable, dict[str, Translatable]]], warnings : list[tuple[str, str]]) -> typing.Any: + def resolve_call( + callexpr : CallExprOperand, + candidate_list : list[tuple[Translatable | list[Translatable], typing.Callable, dict[str, Translatable]]], + warnings : list[tuple[str, str]], + *, + strict : bool = False, + ) -> typing.Any: for name, cb, paramdict in candidate_list: # name 可以是单个 Translatable 也可以是一个列表,为了方便一个名称有多个别名的情况 # (比如预设背景和预设角色在没有歧义的情况下都可以使用“预设”作为简称) if (callexpr.name in name.get_all_candidates() if isinstance(name, Translatable) else any(callexpr.name in n.get_all_candidates() for n in name)): + validate_frontend_command_handler_semantics(cb, getattr(cb, "__globals__", {})) sig = inspect.signature(cb) + try: + cb_hints = typing.get_type_hints(cb, globalns=cb.__globals__, localns=None, include_extras=False) + except Exception: + cb_hints = {} valid_args = None # 首先,我们得把按位参数和关键字参数都处理好 # 关键字参数直接用名字匹配,按位参数按顺序匹配 parsed_params = {} + unknown_kw = False for k, v in callexpr.kwargs.items(): is_current_param_found = False for pname, tr in paramdict.items(): @@ -1042,8 +1185,8 @@ def resolve_call(callexpr : CallExprOperand, candidate_list : list[tuple[Transla if valid_args is None: valid_args = FrontendParserBase._populate_validargs_str(paramdict) warnings.append(('cmdparser-unexpected-params', FrontendParserBase.tr_unexpected_params.format(exprname=callexpr.name, paramname=k, args=valid_args))) - # 接下来处理按位参数 - # 我们先查看回调函数支持多少个按位参数,然后再根据这个数量处理 callexpr 中的按位参数 + unknown_kw = True + # 接下来处理按位参数(与 handle_command_invocation 一致:至多一个 list[…] 吸收剩余按位项) num_consumed_positional_params = 0 for pname, p in sig.parameters.items(): if p.kind == inspect.Parameter.POSITIONAL_ONLY: @@ -1054,20 +1197,26 @@ def resolve_call(callexpr : CallExprOperand, candidate_list : list[tuple[Transla # 我们忽略 *args 和 **kwargs continue if p.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: - # 先尝试进行读取 if len(callexpr.args) == num_consumed_positional_params: break - cur_index = num_consumed_positional_params - num_consumed_positional_params += 1 + ann = cb_hints.get(p.name, p.annotation) if p.name in parsed_params: - # 这个参数已经被赋值了,我们不再处理 warnings.append(('cmdparser-param-override', FrontendParserBase.tr_param_override.format(exprname=callexpr.name, paramname=paramdict[p.name].get()))) + if strict: + return None continue - parsed_params[p.name] = callexpr.args[cur_index] + if typing.get_origin(ann) is list and len(typing.get_args(ann)) == 1: + rest = callexpr.args[num_consumed_positional_params:] + parsed_params[p.name] = list(rest) + num_consumed_positional_params = len(callexpr.args) + continue + parsed_params[p.name] = callexpr.args[num_consumed_positional_params] + num_consumed_positional_params += 1 continue raise PPInternalError('Unexpected parameter kind') # 如果还有剩的按位参数,我们也不处理 - if num_consumed_positional_params < len(callexpr.args): + extra_positional = num_consumed_positional_params < len(callexpr.args) + if extra_positional: # 我们整理一下所有按位参数的名字 valid_positional_args_list : list[str] = [] for pname, p in sig.parameters.items(): @@ -1078,6 +1227,8 @@ def resolve_call(callexpr : CallExprOperand, candidate_list : list[tuple[Transla else: valid_positional_args = '' warnings.append(('cmdparser-extra-positional-args', FrontendParserBase.tr_extra_positonalargs.format(exprname=callexpr.name, numposarg=str(len(valid_positional_args_list)), posargs=valid_positional_args, numgiven=str(len(callexpr.args))))) + if strict and (unknown_kw or extra_positional): + return None # 解析完成,开始准备转换参数类型 # 如果有某个参数无法转换的话,我们就把 final_params 设为 None 来表示出错 final_params = {} @@ -1085,37 +1236,48 @@ def resolve_call(callexpr : CallExprOperand, candidate_list : list[tuple[Transla if pname not in parsed_params: # 这个参数没有被赋值,我们用默认值 if p.default == inspect.Parameter.empty: + conv_ann = cb_hints.get(pname, p.annotation) + ann_origin = typing.get_origin(conv_ann) + if ann_origin is list and len(typing.get_args(conv_ann)) > 0: + # 允许「仅关键字」补全 list 形参:无按位参数时视为空列表(由回调自行校验是否合法) + final_params[pname] = [] + continue warnings.append(('cmdparser-missing-param', FrontendParserBase.tr_missing_param.format(exprname=callexpr.name, paramname=paramdict[pname].get()))) final_params = None break final_params[pname] = p.default continue rawvalue = parsed_params[pname] - if converted := FrontendParserBase.try_convert_parameter(p.annotation, rawvalue): + conv_ann = cb_hints.get(pname, p.annotation) + converted = FrontendParserBase.try_convert_parameter(conv_ann, rawvalue) + if converted is not None: final_params[pname] = converted else: # 给一些有特殊报错消息的类型单独处理 - if p.annotation == Color: + if conv_ann == Color: warnings.append(('cmdparser-invalid-color-expr', FrontendParserBase._tr_invalid_color_expr.format(paramname=paramdict[pname].get(), exprname=callexpr.name, expr=str(rawvalue)))) - elif p.annotation == FrontendParserBase.Resolution: + elif conv_ann == FrontendParserBase.Resolution: warnings.append(('cmdparser-invalid-resolution-expr', FrontendParserBase._tr_invalid_resolution_expr.format(paramname=paramdict[pname].get(), exprname=callexpr.name, expr=str(rawvalue)))) - elif p.annotation == FrontendParserBase.Coordinate2D: + elif conv_ann == FrontendParserBase.Coordinate2D: warnings.append(('cmdparser-invalid-coordinate-expr', FrontendParserBase._tr_invalid_coordinate_expr.format(paramname=paramdict[pname].get(), exprname=callexpr.name, expr=str(rawvalue)))) else: - if p.annotation == int: + if conv_ann == int: typename = FrontendParserBase.tr_typename_int.get() - elif p.annotation == float: - typename = FrontendParserBase.tr_typename_float.get() + elif conv_ann == decimal.Decimal: + typename = FrontendParserBase.tr_typename_decimal.get() else: - typename = str(p.annotation) + typename = str(conv_ann) warnings.append(('cmdparser-invalid-expr-general', FrontendParserBase._tr_invalid_expr_general.format(paramname=paramdict[pname].get(), exprname=callexpr.name, expr=str(rawvalue), typename=typename))) - # 再尝试使用默认值,如果有的话 + # 再尝试使用默认值,如果有的话(strict 下禁止静默回退,须在 IR 阶段报错) if p.default == inspect.Parameter.empty: final_params = None break + if strict: + final_params = None + break final_params[pname] = p.default if final_params is None: - continue + return None return cb(**final_params) # 没有找到匹配的回调函数 expr_name_list = [] @@ -1230,6 +1392,18 @@ def try_convert_parameter(ty : type | types.UnionType | typing._GenericAlias, va return None value = value[0] + # Word/表格常把 0、0.5 落成 IntLiteral/FloatLiteral,须能参与 Decimal 形参解析 + if ty == decimal.Decimal: + if isinstance(value, FloatLiteral): + return value.value + if isinstance(value, IntLiteral): + return decimal.Decimal(int(value.value)) + if ty == int: + if isinstance(value, IntLiteral): + return int(value.value) + if isinstance(value, FloatLiteral): + return int(value.value) + if isinstance(value, ty): return value @@ -1278,20 +1452,17 @@ def try_convert_parameter(ty : type | types.UnionType | typing._GenericAlias, va if ty == str: return value_str if ty == int: + s = _strip_wrapping_quotes_for_numeric(value_str) try: - return int(value_str) + return int(s) except ValueError: return None if ty == decimal.Decimal: + s = _strip_wrapping_quotes_for_numeric(value_str) try: - return decimal.Decimal(value_str) + return decimal.Decimal(s) except decimal.InvalidOperation: return None - if ty == float: - try: - return float(value_str) - except ValueError: - return None if ty == FrontendParserBase.Resolution: if res := FrontendParserBase.parse_pixel_resolution_str(value_str): return FrontendParserBase.Resolution(res) diff --git a/src/preppipe/frontend/vnmodel/vnast.py b/src/preppipe/frontend/vnmodel/vnast.py index cf6cc4c..dcc8f17 100644 --- a/src/preppipe/frontend/vnmodel/vnast.py +++ b/src/preppipe/frontend/vnmodel/vnast.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import dataclasses +import decimal import enum import typing @@ -12,6 +13,7 @@ from ..commandsyntaxparser import * from ...vnmodel import * + @IROperationDataclass class VNASTNodeBase(Operation): # 该结点是否只能出现在函数内 @@ -81,6 +83,25 @@ def create(context : Context, nodetype : VNASTSayNodeType, content : typing.Iter # 交替模式 | 旁白 | 按次序排列的人 |指定角色,修正说话者 # ---------------------------------------------------------- +@IRWrappedStatelessClassJsonName("vnast_instant_effect_kind_e") +class VNInstantEffectKind(str, enum.Enum): + """即时特效种类(与 ``parse_instant_effect``、``parse_instant_effect_command_duration`` 等 API 一致)。 + + 管线内须使用本枚举成员,勿以裸字符串代替;值即规范小写英文键(与剧本命令名一致)。""" + + SHAKE = "shake" + FLASH = "flash" + BOUNCE = "bounce" + TREMBLE = "tremble" + GRAYSCALE = "grayscale" + OPACITY = "opacity" + TINT = "tint" + BLUR = "blur" + # 与 ``parse_instant_effect`` 中天气分支一致(场景特效解析元组首项;AST 上仍用 ``VNASTWeatherEffectNode``) + SNOW = "snow" + RAIN = "rain" + + @IRWrappedStatelessClassJsonName("vnast_say_mode_e") class VNASTSayMode(enum.Enum): MODE_DEFAULT = 0 @@ -187,7 +208,6 @@ class VNASTAssetIntendedOperation(enum.Enum): class VNASTAssetKind(enum.Enum): KIND_IMAGE = 0 KIND_AUDIO = enum.auto() - KIND_EFFECT = enum.auto() KIND_VIDEO = enum.auto() @IROperationDataclass @@ -379,6 +399,281 @@ def get_short_str(self, indent : int = 0) -> str: def create(context : Context, character : StringLiteral | str, name : str = '', loc : Location | None = None): return VNASTCharacterExitNode(init_mode=IRObjectInitMode.CONSTRUCT, context=context, character=character, name=name, loc=loc) +@IROperationDataclass +class VNASTInstantEffectNode(VNASTNodeBase): + """场景/角色「瞬时」类特效 AST 结点(非转场)。 + + 数值在 IR 中一律为 ``FloatLiteral``,其底层为 ``decimal.Decimal``(秒、像素、比例等按字段说明)。 + ``create()`` 的标量参数请传 ``decimal.Decimal``,勿传内置 ``float``。 + """ + + # True:场景级全屏;False:绑定角色立绘(须配合 character_name / character_states_csv)。 + scene_wide : OpOperand[BoolLiteral] + # 角色在符号表中的规范名(来自特效 CallExpr 的角色部分);仅 scene_wide=False 时有效,否则可为空串。 + character_name : OpOperand[StringLiteral] + # 该角色当前立绘状态列表的逗号分隔文本(与后端槽位解析一致);无多状态时可为空串。 + character_states_csv : OpOperand[StringLiteral] + effect_kind : OpOperand[EnumLiteral[VNInstantEffectKind]] + # 整体持续或过渡到目标状态的时长(秒)。<0 表示持续到用户执行「结束特效」;闪烁 flash 在命令层通常固定为 0(由内层切入/停留/恢复控制形态)。 + duration : OpOperand[FloatLiteral] + # 依 effect_kind:shake 为像素级幅度;bounce 为跳起高度占屏高比例(约 0~1);tremble 为像素幅度;滤镜类(grayscale/opacity/tint/blur)为 0~1 强度或半径等(与后端约定一致)。 + amplitude : OpOperand[FloatLiteral] + # 依 effect_kind:shake 为衰减系数;bounce 存重复次数(Decimal 字面值);tremble 存往复周期(秒);其余情况多为 0。 + decay : OpOperand[FloatLiteral] + # 仅 ``effect_kind==shake`` 时有效;其余种类填 ``VNShakeAxisKind.NONE``。 + shake_axis : OpOperand[EnumLiteral[VNShakeAxisKind]] + # 仅 ``effect_kind==bounce`` 时有效;其余种类填 ``VNMotionStyleKind.LINEAR``。 + motion_style : OpOperand[EnumLiteral[VNMotionStyleKind]] + # #RRGGBB;tint 为叠加色;非色调类滤镜时常为占位 ``#ffffff``。 + flash_color : OpOperand[StringLiteral] + # 以下为闪烁(flash)专用三段时长(秒);其它 effect_kind 下多为占位默认(如 0.06/0.1/0.18)。 + flash_in : OpOperand[FloatLiteral] + flash_hold : OpOperand[FloatLiteral] + flash_out : OpOperand[FloatLiteral] + + def get_short_str(self, indent : int = 0) -> str: + sc = "scene" if self.scene_wide.get().value else ("char:" + self.character_name.get().get_string()) + return f"InstantEffect<{self.effect_kind.get().value.value}> {sc}" + + @staticmethod + def create( + context : Context, + *, + scene_wide : bool, + character_name : str = "", + character_states_csv : str = "", + effect_kind : VNInstantEffectKind, + duration : decimal.Decimal, + amplitude : decimal.Decimal, + decay : decimal.Decimal, + flash_color : str, + flash_in : decimal.Decimal, + flash_hold : decimal.Decimal, + flash_out : decimal.Decimal, + shake_axis : VNShakeAxisKind = VNShakeAxisKind.NONE, + motion_style : VNMotionStyleKind = VNMotionStyleKind.LINEAR, + name : str = "", + loc : Location | None = None, + ) -> VNASTInstantEffectNode: + return VNASTInstantEffectNode( + init_mode=IRObjectInitMode.CONSTRUCT, + context=context, + scene_wide=BoolLiteral.get(scene_wide, context), + character_name=StringLiteral.get(character_name, context), + character_states_csv=StringLiteral.get(character_states_csv, context), + effect_kind=EnumLiteral.get(context, effect_kind), + duration=FloatLiteral.get(duration, context), + amplitude=FloatLiteral.get(amplitude, context), + decay=FloatLiteral.get(decay, context), + shake_axis=EnumLiteral.get(context, shake_axis), + motion_style=EnumLiteral.get(context, motion_style), + flash_color=StringLiteral.get(flash_color, context), + flash_in=FloatLiteral.get(flash_in, context), + flash_hold=FloatLiteral.get(flash_hold, context), + flash_out=FloatLiteral.get(flash_out, context), + name=name, + loc=loc, + ) + +@IROperationDataclass +class VNASTWeatherEffectNode(VNASTNodeBase): + """全屏粒子天气(雪/雨),独立 screen 层;sustain<0 表示持续至「结束特效·场景」。""" + + weather_kind : OpOperand[EnumLiteral[VNWeatherEffectKind]] + intensity : OpOperand[FloatLiteral] + inner_fade_in : OpOperand[FloatLiteral] + inner_fade_out : OpOperand[FloatLiteral] + overlay_fade_in : OpOperand[FloatLiteral] + sustain : OpOperand[FloatLiteral] + vx : OpOperand[FloatLiteral] + vy : OpOperand[FloatLiteral] + + def get_short_str(self, indent : int = 0) -> str: + return f"Weather<{self.weather_kind.get().value.value}>" + + @staticmethod + def create( + context : Context, + *, + weather_kind : VNWeatherEffectKind, + intensity : decimal.Decimal, + inner_fade_in : decimal.Decimal, + inner_fade_out : decimal.Decimal, + overlay_fade_in : decimal.Decimal, + sustain : decimal.Decimal, + vx : decimal.Decimal, + vy : decimal.Decimal, + name : str = "", + loc : Location | None = None, + ) -> VNASTWeatherEffectNode: + return VNASTWeatherEffectNode( + init_mode=IRObjectInitMode.CONSTRUCT, + context=context, + weather_kind=EnumLiteral.get(context, weather_kind), + intensity=FloatLiteral.get(intensity, context), + inner_fade_in=FloatLiteral.get(inner_fade_in, context), + inner_fade_out=FloatLiteral.get(inner_fade_out, context), + overlay_fade_in=FloatLiteral.get(overlay_fade_in, context), + sustain=FloatLiteral.get(sustain, context), + vx=FloatLiteral.get(vx, context), + vy=FloatLiteral.get(vy, context), + name=name, + loc=loc, + ) + +@IROperationDataclass +class VNASTCharacterMoveTweenNode(VNASTNodeBase): + """立绘平移补间:目标须已上屏且具备 screen2d 占位。 + + ``target_screen_x_ratio`` / ``target_screen_y_ratio`` 为相对游戏分辨率的比例(约 0~1)。 + 时长与缓动见 ``duration``、``style``;codegen 中换算为像素并下发 ``VNCharacterSpriteMoveInst``(move_kind=``move``)。 + """ + + character_name : OpOperand[StringLiteral] + character_states_csv : OpOperand[StringLiteral] + duration : OpOperand[FloatLiteral] + target_screen_x_ratio : OpOperand[FloatLiteral] + target_screen_y_ratio : OpOperand[FloatLiteral] + style : OpOperand[EnumLiteral[VNMotionStyleKind]] + + def get_short_str(self, indent : int = 0) -> str: + return f"CharacterMove {self.character_name.get().get_string()}" + + @staticmethod + def create( + context : Context, + *, + character_name : str, + character_states_csv : str, + duration : decimal.Decimal, + target_screen_x_ratio : decimal.Decimal, + target_screen_y_ratio : decimal.Decimal, + style : VNMotionStyleKind, + name : str = "", + loc : Location | None = None, + ) -> VNASTCharacterMoveTweenNode: + return VNASTCharacterMoveTweenNode( + init_mode=IRObjectInitMode.CONSTRUCT, + context=context, + character_name=StringLiteral.get(character_name, context), + character_states_csv=StringLiteral.get(character_states_csv, context), + duration=FloatLiteral.get(duration, context), + target_screen_x_ratio=FloatLiteral.get(target_screen_x_ratio, context), + target_screen_y_ratio=FloatLiteral.get(target_screen_y_ratio, context), + style=EnumLiteral.get(context, style), + name=name, + loc=loc, + ) + + +@IROperationDataclass +class VNASTCharacterScaleTweenNode(VNASTNodeBase): + """立绘缩放补间(相对 1.0 的目标缩放系数,中心固定为立绘中心)。""" + + character_name : OpOperand[StringLiteral] + character_states_csv : OpOperand[StringLiteral] + duration : OpOperand[FloatLiteral] + target_scale : OpOperand[FloatLiteral] + style : OpOperand[EnumLiteral[VNMotionStyleKind]] + + def get_short_str(self, indent : int = 0) -> str: + return f"CharacterMove {self.character_name.get().get_string()}" + + @staticmethod + def create( + context : Context, + *, + character_name : str, + character_states_csv : str, + duration : decimal.Decimal, + target_scale : decimal.Decimal, + style : VNMotionStyleKind, + name : str = "", + loc : Location | None = None, + ) -> VNASTCharacterScaleTweenNode: + return VNASTCharacterScaleTweenNode( + init_mode=IRObjectInitMode.CONSTRUCT, + context=context, + character_name=StringLiteral.get(character_name, context), + character_states_csv=StringLiteral.get(character_states_csv, context), + duration=FloatLiteral.get(duration, context), + target_scale=FloatLiteral.get(target_scale, context), + style=EnumLiteral.get(context, style), + name=name, + loc=loc, + ) + + +@IROperationDataclass +class VNASTCharacterRotateTweenNode(VNASTNodeBase): + """立绘旋转补间(绕立绘中心,角度单位为度)。""" + + character_name : OpOperand[StringLiteral] + character_states_csv : OpOperand[StringLiteral] + duration : OpOperand[FloatLiteral] + angle_degrees : OpOperand[FloatLiteral] + style : OpOperand[EnumLiteral[VNMotionStyleKind]] + + def get_short_str(self, indent : int = 0) -> str: + return f"CharacterMove {self.character_name.get().get_string()}" + + @staticmethod + def create( + context : Context, + *, + character_name : str, + character_states_csv : str, + duration : decimal.Decimal, + angle_degrees : decimal.Decimal, + style : VNMotionStyleKind, + name : str = "", + loc : Location | None = None, + ) -> VNASTCharacterRotateTweenNode: + return VNASTCharacterRotateTweenNode( + init_mode=IRObjectInitMode.CONSTRUCT, + context=context, + character_name=StringLiteral.get(character_name, context), + character_states_csv=StringLiteral.get(character_states_csv, context), + duration=FloatLiteral.get(duration, context), + angle_degrees=FloatLiteral.get(angle_degrees, context), + style=EnumLiteral.get(context, style), + name=name, + loc=loc, + ) + +@IROperationDataclass +class VNASTEndEffectNode(VNASTNodeBase): + """结束持续类特效:目标为场景或指定角色立绘。""" + + scene_wide : OpOperand[BoolLiteral] + character_name : OpOperand[StringLiteral] + character_states_csv : OpOperand[StringLiteral] + + def get_short_str(self, indent : int = 0) -> str: + if self.scene_wide.get().value: + return "EndEffect" + return "EndEffect" + + @staticmethod + def create( + context : Context, + *, + scene_wide : bool, + character_name : str = "", + character_states_csv : str = "", + name : str = "", + loc : Location | None = None, + ) -> VNASTEndEffectNode: + return VNASTEndEffectNode( + init_mode=IRObjectInitMode.CONSTRUCT, + context=context, + scene_wide=BoolLiteral.get(scene_wide, context), + character_name=StringLiteral.get(character_name, context), + character_states_csv=StringLiteral.get(character_states_csv, context), + name=name, + loc=loc, + ) + @IROperationDataclass class VNASTCodegenRegion(VNASTNodeBase): body : Block @@ -725,6 +1020,54 @@ class VNASTImagePresetPlaceSymbol(Symbol): def create(context : Context, kind : VNASTImagePlacerKind, parameters : typing.Iterable[Literal], name : str, loc : Location | None = None): return VNASTImagePresetPlaceSymbol(init_mode=IRObjectInitMode.CONSTRUCT, context=context, kind=kind, parameters=parameters, name=name, loc=loc) +# ------------------------------------------------------------------------------ +# EffectDecl 预设效果集:用于多语言解析 left/右/左 等固定参数(转场方向等) +# ------------------------------------------------------------------------------ + +@IROperationDataclass +class VNASTEffectPresetEntrySymbol(Symbol): + """预设效果集(``VNASTEffectPresetSetSymbol``)中的一条取值。 + + ``name`` / ``canonical_value`` 均为该条目的 **规范键**(小写英文蛇形,与转场/特效 codegen 约定一致)。 + ``aliases`` 为指向同一规范键的多语言或别称(0..N 个 ``StringLiteral``)。 + + **canonical_value 的可能取值** 取决于该条目所属预设集的 **名称**(``VNASTEffectPresetSetSymbol`` 的 ``name``,如 ``SlideDirection``): + + - **内置**时,各集允许出现的规范键 **全集** 见 ``preppipe.frontend.vnmodel.vnutil`` 中的 + ``BUILTIN_EFFECT_PRESET_CANONICAL_BY_SET``(键为预设集名,值为 ``frozenset[str]``), + 与同文件中的 ``CANONICAL_SLIDE_DIRECTIONS``、``CANONICAL_ZOOM_DIRECTIONS``、 + ``CANONICAL_ZOOM_POINTS``、``CANONICAL_SHAKE_DIRECTIONS`` 及注入函数 + ``ensure_builtin_effect_presets`` 中的表格一致。 + - **用户**可在文档 ``EffectDecl`` 中为同名集 **追加** 条目,因此运行时也可能出现上述全集中未列出的 + ``canonical_value``(无法用单一 Enum 封闭)。 + """ + # 该条目对应的规范键字符串;含义由父级预设集 name 决定,取值范围见类文档。 + canonical_value : OpOperand[StringLiteral] + aliases : OpOperand[StringLiteral] # 0 到 N 个别名(多语言等) + + @staticmethod + def create(context : Context, canonical_value : str, aliases : typing.Iterable[str | StringLiteral], name : str | None = None, loc : Location | None = None): + # Symbol 的 name 用于符号表查找,与 canonical_value 一致;aliases 逐个 add_operand 以支持多值 + key = canonical_value if isinstance(canonical_value, str) else canonical_value.get_string() + cv = StringLiteral.get(key, context) if isinstance(canonical_value, str) else canonical_value + alias_list = [StringLiteral.get(a, context) if isinstance(a, str) else a for a in aliases] + first = alias_list[0] if alias_list else StringLiteral.get("", context) + entry = VNASTEffectPresetEntrySymbol(init_mode=IRObjectInitMode.CONSTRUCT, context=context, canonical_value=cv, aliases=first, name=name or key, loc=loc) + for a in alias_list[1:]: + entry.aliases.add_operand(a) + return entry + + +@IROperationDataclass +class VNASTEffectPresetSetSymbol(Symbol): + """EffectDecl 预设效果集:一组命名取值(如 SlideDirection: left/右/左, right/右, ...)。""" + entries : SymbolTableRegion[VNASTEffectPresetEntrySymbol] # 每个 entry 的 name 为规范键 + + @staticmethod + def create(context : Context, name : str, loc : Location | None = None): + return VNASTEffectPresetSetSymbol(init_mode=IRObjectInitMode.CONSTRUCT, context=context, name=name, loc=loc) + + @IROperationDataclass class VNASTCharacterSymbol(Symbol): aliases : OpOperand[StringLiteral] @@ -846,6 +1189,7 @@ def create(name : str, loc : Location, namespace : StringLiteral | str | None = class VNAST(Operation): screen_resolution : OpOperand[IntTuple2DLiteral] files : Block # VNASTFileInfo + effect_presets : SymbolTableRegion[VNASTEffectPresetSetSymbol] # EffectDecl 预设效果集(如 SlideDirection, ZoomDirection) def get_short_str(self, indent : int = 0) -> str: width, height = self.screen_resolution.get().value @@ -930,6 +1274,18 @@ def visitVNASTCharacterStateChangeNode(self, node : VNASTCharacterStateChangeNod return self.visit_default_handler(node) def visitVNASTCharacterExitNode(self, node : VNASTCharacterExitNode): return self.visit_default_handler(node) + def visitVNASTInstantEffectNode(self, node : VNASTInstantEffectNode): + return self.visit_default_handler(node) + def visitVNASTWeatherEffectNode(self, node : VNASTWeatherEffectNode): + return self.visit_default_handler(node) + def visitVNASTCharacterMoveTweenNode(self, node : VNASTCharacterMoveTweenNode): + return self.visit_default_handler(node) + def visitVNASTCharacterScaleTweenNode(self, node : VNASTCharacterScaleTweenNode): + return self.visit_default_handler(node) + def visitVNASTCharacterRotateTweenNode(self, node : VNASTCharacterRotateTweenNode): + return self.visit_default_handler(node) + def visitVNASTEndEffectNode(self, node : VNASTEndEffectNode): + return self.visit_default_handler(node) def visitVNASTCodegenRegion(self, node : VNASTCodegenRegion): return self.visit_default_handler(node) def visitVNASTConditionalExecutionNode(self, node : VNASTConditionalExecutionNode): diff --git a/src/preppipe/frontend/vnmodel/vncodegen.py b/src/preppipe/frontend/vnmodel/vncodegen.py index 7280ec2..14cb51c 100644 --- a/src/preppipe/frontend/vnmodel/vncodegen.py +++ b/src/preppipe/frontend/vnmodel/vncodegen.py @@ -4,6 +4,7 @@ from __future__ import annotations import dataclasses +import decimal import enum import typing from typing import Any @@ -876,7 +877,7 @@ class _FunctionCodegenHelper(_NonlocalCodegenHelper): is_in_transition : bool = False # 如果是 Value, 这是一个通用的转场 # 如果是 tuple, 这是某个后端独有的转场 - # 如果是 None, 则父转场结点没有提供转场 + # None:Transition 结点未写转场名(子指令仍用各自默认淡入/淡出);显式「无」解析为 DT_NO_TRANSITION,由 parse_transition 得到 parent_transition : Value | tuple[StringLiteral, StringLiteral] | None = None transition_child_finishtimes : list[Value] = dataclasses.field(default_factory=list) @@ -1099,7 +1100,7 @@ def remove_handle(handle : Value, transition : Value | None = None): self.destblock.push_back(rm) rmfinishtimes.append(rm.get_finish_time()) - chhide = VNDefaultTransitionType.DT_SPRITE_HIDE.get_enum_literal(self.context) + chhide = default_scene_dissolve_lit(self.context) for ch, d in character_sprites.items(): for creation, handle in d.items(): remove_handle(handle, chhide) @@ -1390,7 +1391,11 @@ def handle_transition_and_finishtime(self, node : VNCreateInst | VNPutInst | VNM else: if self.parent_transition is not None: if isinstance(self.parent_transition, Value): - node.transition.set_operand(0, self.parent_transition) + # 人物与场景共用同一套通用转场;退场须把入场类(淡入等)映射为淡出等,见 map_sprite_transition_entry_to_exit + applied : Value = self.parent_transition + if isinstance(node, VNRemoveInst): + applied = map_sprite_transition_entry_to_exit(self.context, applied) + node.transition.set_operand(0, applied) elif isinstance(self.parent_transition, tuple): curfallback = node.transition.try_get_value() if isinstance(curfallback, EnumLiteral) and isinstance(curfallback.value, VNDefaultTransitionType): @@ -1399,11 +1404,25 @@ def handle_transition_and_finishtime(self, node : VNCreateInst | VNPutInst | VNM curfallback = VNDefaultTransitionType.DT_NO_TRANSITION.get_enum_literal(self.context) backend, expr = self.parent_transition final_transition = VNBackendDisplayableTransitionExpr.get(context=self.context, backend=backend, expression=expr, fallback=curfallback) + if isinstance(node, VNRemoveInst): + final_transition = map_sprite_transition_entry_to_exit(self.context, final_transition) node.transition.set_operand(0, final_transition) else: raise PPInternalError("Unexpected parent_transition type") self.transition_child_finishtimes.append(node.get_finish_time()) + def _scene_switch_parent_transition_as_value(self) -> Value | None: + """场景切换时把 parent_transition 规范为单一 Value(与 handle_transition_and_finishtime 的 tuple 分支一致)。""" + if not self.is_in_transition or self.parent_transition is None: + return None + if isinstance(self.parent_transition, Value): + return self.parent_transition + if isinstance(self.parent_transition, tuple): + backend, expr = self.parent_transition + fb = VNDefaultTransitionType.DT_NO_TRANSITION.get_enum_literal(self.context) + return VNBackendDisplayableTransitionExpr.get(context=self.context, backend=backend, expression=expr, fallback=fb) + raise PPInternalError("Unexpected parent_transition type") + _tr_character_entry_character_notfound = TR_vn_codegen.tr("character_entry_character_notfound", en="Character {sayname} not found and the entry with state {change} is ignored. Please declare the character with sprite info if you want to use sprites. Please check the spelling if you already have the declaration.", zh_cn="角色 {sayname} 未找到,以该状态 {change} 的入场操作将被忽略。如果想使用立绘,请声明角色并提供立绘信息。如果您已经声明该角色,请检查拼写、输入错误。", @@ -1441,7 +1460,7 @@ def visitVNASTCharacterEntryNode(self, node : VNASTCharacterEntryNode) -> VNTerm raise PPAssertionError("We only support image types for now") cnode = VNCreateInst.create(context=self.context, start_time=self.starttime, content=sprite, ty=VNHandleType.get(sprite.valuetype), device=self.parsecontext.dev_foreground, loc=node.location) - cnode.transition.set_operand(0, VNDefaultTransitionType.DT_SPRITE_SHOW.get_enum_literal(self.context)) + cnode.transition.set_operand(0, default_scene_dissolve_lit(self.context)) self.handle_transition_and_finishtime(cnode) # 在更新 info.sprite_handle 之前就把事件加上去,这样可以使调用 placer 时能记录事件发生前的状态 self.placer.add_character_event(info, self.codegen.get_character_astnode(info.identity), sprite=sprite, instr=cnode, destexpr=node) @@ -1475,7 +1494,8 @@ def visitVNASTCharacterExitNode(self, node : VNASTCharacterExitNode) -> VNTermin if info := self.scenecontext.try_get_character_state(sayname, ch): if info.sprite_handle: rm = VNRemoveInst.create(self.context, start_time=self.starttime, handlein=info.sprite_handle, loc=node.location) - rm.transition.set_operand(0, VNDefaultTransitionType.DT_SPRITE_HIDE.get_enum_literal(self.context)) + self._copy_sprite_screen2d_to_remove(rm, info.sprite_handle) + rm.transition.set_operand(0, default_scene_dissolve_lit(self.context)) self.handle_transition_and_finishtime(rm) self.destblock.push_back(rm) self.placer.add_character_event(info, self.codegen.get_character_astnode(info.identity), sprite=None, instr=rm, destexpr=node) @@ -1498,11 +1518,428 @@ def _create_image_expr(self, v : ImageAssetData) -> ImageAssetLiteralExpr: zh_cn="该资源并不正在被使用,所以停用操作将被忽略: {asset}", zh_hk="該資源並不正在被使用,所以停用操作將被忽略: {asset}", ) - _tr_assetref_se_not_supported = TR_vn_codegen.tr("assetref_se_not_supporte", - en="Special effect is not supported yet and the reference is ignored. Please contact the developer if you want this feature.", - zh_cn="特效暂不支持,对特效的引用将被忽略。如果您需要该功能,请联系开发者。", - zh_hk="特效暫不支持,對特效的引用將被忽略。如果您需要該功能,請聯系開發者。", + _tr_instant_effect_char_no_sprite = TR_vn_codegen.tr("instant_effect_char_no_sprite", + en="Character {n} has no sprite on stage; character effect ignored.", + zh_cn="角色 {n} 无在场立绘,角色特效已忽略。", + zh_hk="角色 {n} 無在場立繪,角色特效已忽略。", + ) + _tr_instant_effect_need_screen2d = TR_vn_codegen.tr("instant_effect_need_screen2d", + en="Character sprite must use absolute screen position (screen2d) for character effects.", + zh_cn="角色立绘须使用绝对屏幕位置(screen2d) 才能使用角色特效。", + zh_hk="角色立繪須使用絕對屏幕位置(screen2d) 才能使用角色特效。", + ) + _tr_instant_effect_scene_bounce_tremble = TR_vn_codegen.tr( + "instant_effect_scene_bounce_tremble", + en="Scene-wide instant effects do not support Bounce or Tremble.", + zh_cn="场景特效不支持跳动或发抖。", + zh_hk="場景特效不支援跳動或發抖。", + ) + _tr_sprite_handle_internal = TR_vn_codegen.tr( + "sprite_handle_internal", + en="Internal error while resolving the sprite handle.", + zh_cn="解析立绘句柄时发生内部错误。", + zh_hk="解析立繪句柄時發生內部錯誤。", ) + # AST 滤镜种类 → IR 滤镜种类(不落回字符串) + _FILTER_KIND_FROM_INSTANT_EFFECT : typing.ClassVar[dict[VNInstantEffectKind, VNFilterEffectKind]] = { + VNInstantEffectKind.GRAYSCALE: VNFilterEffectKind.GRAYSCALE, + VNInstantEffectKind.OPACITY: VNFilterEffectKind.OPACITY, + VNInstantEffectKind.TINT: VNFilterEffectKind.TINT, + VNInstantEffectKind.BLUR: VNFilterEffectKind.BLUR, + } + + def _sprite_content_and_screen2d(self, handle : Value) -> tuple[Value, tuple[int, int, int, int] | None]: + h : Any = handle + place : tuple[int, int, int, int] | None = None + while isinstance(h, VNModifyInst): + if position := h.placeat.get(VNPositionSymbol.NAME_SCREEN2D): + pos = position.position.get() + if isinstance(pos, VNScreen2DPositionLiteralExpr): + place = (int(pos.x_abs.value), int(pos.y_abs.value), int(pos.width.value), int(pos.height.value)) + h = h.handlein.get() + if isinstance(h, VNCreateInst): + if position := h.placeat.get(VNPositionSymbol.NAME_SCREEN2D): + pos = position.position.get() + if isinstance(pos, VNScreen2DPositionLiteralExpr): + place = (int(pos.x_abs.value), int(pos.y_abs.value), int(pos.width.value), int(pos.height.value)) + return h.content.get(), place + raise PPInternalError("sprite handle root must be VNCreateInst") + + def _copy_sprite_screen2d_to_remove(self, rm : VNRemoveInst, sprite_handle : Value) -> None: + h : typing.Any = sprite_handle + while isinstance(h, VNModifyInst): + if sym := h.placeat.get(VNPositionSymbol.NAME_SCREEN2D): + rm.placeat.add(sym.clone()) + return + h = h.handlein.get() + if isinstance(h, VNCreateInst): + if sym := h.placeat.get(VNPositionSymbol.NAME_SCREEN2D): + rm.placeat.add(sym.clone()) + + def visitVNASTInstantEffectNode(self, node : VNASTInstantEffectNode) -> VNTerminatorInstBase | None: + if self.check_blocklocal_cond(node): + return None + kind_e : VNInstantEffectKind = node.effect_kind.get().value + scene = bool(node.scene_wide.get().value) + + d = node.duration.get().value + amp = node.amplitude.get().value + dec = node.decay.get().value + fc = node.flash_color.get().get_string() + fi = node.flash_in.get().value + fh = node.flash_hold.get().value + fo = node.flash_out.get().value + st = self.starttime + + filt_kinds = frozenset(self._FILTER_KIND_FROM_INSTANT_EFFECT.keys()) + + if scene: + if kind_e in (VNInstantEffectKind.BOUNCE, VNInstantEffectKind.TREMBLE): + self.codegen.emit_error( + "vncodegen-instant-effect", + self._tr_instant_effect_scene_bounce_tremble.get(), + node.location, + self.destblock, + ) + return None + if kind_e == VNInstantEffectKind.SHAKE: + inst = VNShakeEffectInst.create( + self.context, st, scene_wide=True, sprite=None, has_place=False, place_xywh=None, + duration=d, amplitude=amp, decay=dec, direction=node.shake_axis.get().value, name=node.name, loc=node.location, + ) + elif kind_e in filt_kinds: + inst = VNFilterEffectInst.create( + self.context, + st, + scene_wide=True, + sprite=None, + has_place=False, + place_xywh=None, + filter_kind=self._FILTER_KIND_FROM_INSTANT_EFFECT[kind_e], + strength=amp, + duration=d, + color=fc, + name=node.name, + loc=node.location, + ) + else: + inst = VNFlashEffectInst.create( + self.context, st, scene_wide=True, sprite=None, has_place=False, place_xywh=None, + color=fc, fade_in=fi, hold=fh, fade_out=fo, name=node.name, loc=node.location, + ) + self.destblock.push_back(inst) + return None + raw = node.character_name.get().get_string() + sayname = self.parsecontext.resolve_alias(raw) + ch = self.codegen.resolve_character(sayname=sayname, from_namespace=self.namespace_tuple, parsecontext=self.parsecontext) + if ch is None: + msg = self._tr_character_entry_character_notfound.format(sayname=raw, change="") + self.codegen.emit_error("vncodegen-instant-effect", msg, node.location, self.destblock) + return None + info = self.scenecontext.try_get_character_state(sayname, ch) + if info is None or info.sprite_handle is None: + self.codegen.emit_error( + "vncodegen-instant-effect", + self._tr_instant_effect_char_no_sprite.format(n=raw), + node.location, + self.destblock, + ) + return None + try: + sp, place = self._sprite_content_and_screen2d(info.sprite_handle) + except PPInternalError: + self.codegen.emit_error( + "vncodegen-instant-effect", + self._tr_sprite_handle_internal.get(), + node.location, + self.destblock, + ) + return None + if place is None: + self.codegen.emit_error( + "vncodegen-instant-effect", + self._tr_instant_effect_need_screen2d.get(), + node.location, + self.destblock, + ) + return None + if kind_e == VNInstantEffectKind.SHAKE: + inst = VNShakeEffectInst.create( + self.context, st, scene_wide=False, sprite=sp, has_place=True, place_xywh=place, + duration=d, amplitude=amp, decay=dec, direction=node.shake_axis.get().value, name=node.name, loc=node.location, + ) + elif kind_e == VNInstantEffectKind.BOUNCE: + cnt = max(1, int(dec.to_integral_value(rounding=decimal.ROUND_HALF_UP))) + inst = VNCharacterSpriteMoveInst.create( + self.context, + st, + sprite=sp, + has_place=True, + place_xywh=place, + move_kind=VNCharacterSpriteMoveKind.BOUNCE, + duration=d, + n1=amp, + n2=decimal.Decimal(cnt), + n3=decimal.Decimal(0), + n4=decimal.Decimal(0), + style=node.motion_style.get().value, + name=node.name, + loc=node.location, + ) + elif kind_e == VNInstantEffectKind.TREMBLE: + period = max(decimal.Decimal("0.04"), dec) + inst = VNCharTrembleEffectInst.create( + self.context, + st, + sprite=sp, + has_place=True, + place_xywh=place, + amplitude=amp, + period=period, + duration=d, + name=node.name, + loc=node.location, + ) + elif kind_e in filt_kinds: + inst = VNFilterEffectInst.create( + self.context, + st, + scene_wide=False, + sprite=sp, + has_place=True, + place_xywh=place, + filter_kind=self._FILTER_KIND_FROM_INSTANT_EFFECT[kind_e], + strength=amp, + duration=d, + color=fc, + name=node.name, + loc=node.location, + ) + else: + inst = VNFlashEffectInst.create( + self.context, st, scene_wide=False, sprite=sp, has_place=True, place_xywh=place, + color=fc, fade_in=fi, hold=fh, fade_out=fo, name=node.name, loc=node.location, + ) + self.destblock.push_back(inst) + return None + + def visitVNASTWeatherEffectNode(self, node : VNASTWeatherEffectNode) -> VNTerminatorInstBase | None: + if self.check_blocklocal_cond(node): + return None + st = self.starttime + inst = VNWeatherEffectInst.create( + self.context, + st, + weather_kind=node.weather_kind.get().value, + intensity=node.intensity.get().value, + inner_fade_in=node.inner_fade_in.get().value, + inner_fade_out=node.inner_fade_out.get().value, + overlay_fade_in=node.overlay_fade_in.get().value, + sustain=node.sustain.get().value, + vx=node.vx.get().value, + vy=node.vy.get().value, + name=node.name, + loc=node.location, + ) + self.destblock.push_back(inst) + return None + + def visitVNASTEndEffectNode(self, node : VNASTEndEffectNode) -> VNTerminatorInstBase | None: + if self.check_blocklocal_cond(node): + return None + st = self.starttime + if bool(node.scene_wide.get().value): + inst = VNEndEffectInst.create( + self.context, + st, + scene_wide=True, + sprite=None, + has_place=False, + place_xywh=None, + name=node.name, + loc=node.location, + ) + self.destblock.push_back(inst) + return None + raw = node.character_name.get().get_string() + sayname = self.parsecontext.resolve_alias(raw) + ch = self.codegen.resolve_character(sayname=sayname, from_namespace=self.namespace_tuple, parsecontext=self.parsecontext) + if ch is None: + msg = self._tr_character_entry_character_notfound.format(sayname=raw, change="") + self.codegen.emit_error("vncodegen-end-effect", msg, node.location, self.destblock) + return None + info = self.scenecontext.try_get_character_state(sayname, ch) + if info is None or info.sprite_handle is None: + self.codegen.emit_error( + "vncodegen-end-effect", + self._tr_instant_effect_char_no_sprite.format(n=raw), + node.location, + self.destblock, + ) + return None + try: + sp, place = self._sprite_content_and_screen2d(info.sprite_handle) + except PPInternalError: + self.codegen.emit_error("vncodegen-end-effect", self._tr_sprite_handle_internal.get(), node.location, self.destblock) + return None + if place is None: + self.codegen.emit_error( + "vncodegen-end-effect", + self._tr_instant_effect_need_screen2d.get(), + node.location, + self.destblock, + ) + return None + inst = VNEndEffectInst.create( + self.context, + st, + scene_wide=False, + sprite=sp, + has_place=True, + place_xywh=place, + name=node.name, + loc=node.location, + ) + self.destblock.push_back(inst) + return None + + def _emit_character_sprite_tween( + self, + node : VNASTNodeBase, + *, + move_kind : VNCharacterSpriteMoveKind, + duration : decimal.Decimal, + n1 : decimal.Decimal, + n2 : decimal.Decimal, + n3 : decimal.Decimal, + n4 : decimal.Decimal, + style : VNMotionStyleKind, + sync_screen2d_from_xy_ratios : bool, + screen_x_ratio : decimal.Decimal, + screen_y_ratio : decimal.Decimal, + ) -> VNTerminatorInstBase | None: + if self.check_blocklocal_cond(node): + return None + st = self.starttime + raw = node.character_name.get().get_string() + sayname = self.parsecontext.resolve_alias(raw) + ch = self.codegen.resolve_character(sayname=sayname, from_namespace=self.namespace_tuple, parsecontext=self.parsecontext) + if ch is None: + msg = self._tr_character_entry_character_notfound.format(sayname=raw, change="") + self.codegen.emit_error("vncodegen-character-move", msg, node.location, self.destblock) + return None + info = self.scenecontext.try_get_character_state(sayname, ch) + if info is None or info.sprite_handle is None: + self.codegen.emit_error( + "vncodegen-character-move", + self._tr_instant_effect_char_no_sprite.format(n=raw), + node.location, + self.destblock, + ) + return None + try: + sp, place = self._sprite_content_and_screen2d(info.sprite_handle) + except PPInternalError: + self.codegen.emit_error("vncodegen-character-move", self._tr_sprite_handle_internal.get(), node.location, self.destblock) + return None + if place is None: + self.codegen.emit_error( + "vncodegen-character-move", + self._tr_instant_effect_need_screen2d.get(), + node.location, + self.destblock, + ) + return None + _sx, _sy, w, h = place + inst = VNCharacterSpriteMoveInst.create( + self.context, + st, + sprite=sp, + has_place=True, + place_xywh=place, + move_kind=move_kind, + duration=duration, + n1=n1, + n2=n2, + n3=n3, + n4=n4, + style=style, + name=node.name, + loc=node.location, + ) + self.destblock.push_back(inst) + if sync_screen2d_from_xy_ratios: + sw, sh = self.codegen.ast.screen_resolution.get().value + swd = decimal.Decimal(int(sw)) + shd = decimal.Decimal(int(sh)) + ex = int((screen_x_ratio * swd).to_integral_value(rounding=decimal.ROUND_HALF_UP)) + ey = int((screen_y_ratio * shd).to_integral_value(rounding=decimal.ROUND_HALF_UP)) + prev_content, prev_device = self._get_info_from_handle(info.sprite_handle) + mod = VNModifyInst.create( + context=self.context, + start_time=st, + handlein=info.sprite_handle, + content=prev_content, + device=prev_device, + loc=node.location, + ) + pos_lit = VNScreen2DPositionLiteralExpr.get(self.context, ex, ey, w, h) + mod.placeat.add(VNPositionSymbol.create(self.context, name=VNPositionSymbol.NAME_SCREEN2D, position=pos_lit)) + info.sprite_handle = mod + self.destblock.push_back(mod) + if not self.is_in_transition: + self.starttime = mod.get_finish_time() + return None + + def visitVNASTCharacterMoveTweenNode(self, node : VNASTCharacterMoveTweenNode) -> VNTerminatorInstBase | None: + rx = node.target_screen_x_ratio.get().value + ry = node.target_screen_y_ratio.get().value + return self._emit_character_sprite_tween( + node, + move_kind=VNCharacterSpriteMoveKind.MOVE, + duration=node.duration.get().value, + n1=rx, + n2=ry, + n3=decimal.Decimal(0), + n4=decimal.Decimal(0), + style=node.style.get().value, + sync_screen2d_from_xy_ratios=True, + screen_x_ratio=rx, + screen_y_ratio=ry, + ) + + def visitVNASTCharacterScaleTweenNode(self, node : VNASTCharacterScaleTweenNode) -> VNTerminatorInstBase | None: + sc = node.target_scale.get().value + return self._emit_character_sprite_tween( + node, + move_kind=VNCharacterSpriteMoveKind.SCALE, + duration=node.duration.get().value, + n1=sc, + n2=decimal.Decimal(0), + n3=decimal.Decimal(0), + n4=decimal.Decimal(0), + style=node.style.get().value, + sync_screen2d_from_xy_ratios=False, + screen_x_ratio=decimal.Decimal(0), + screen_y_ratio=decimal.Decimal(0), + ) + + def visitVNASTCharacterRotateTweenNode(self, node : VNASTCharacterRotateTweenNode) -> VNTerminatorInstBase | None: + ang = node.angle_degrees.get().value + return self._emit_character_sprite_tween( + node, + move_kind=VNCharacterSpriteMoveKind.ROTATE, + duration=node.duration.get().value, + n1=ang, + n2=decimal.Decimal(0), + n3=decimal.Decimal(0), + n4=decimal.Decimal(0), + style=node.style.get().value, + sync_screen2d_from_xy_ratios=False, + screen_x_ratio=decimal.Decimal(0), + screen_y_ratio=decimal.Decimal(0), + ) + _tr_assetref_video_not_supported = TR_vn_codegen.tr("assetref_video_not_supported", en="Video playing is not supported yet and the reference is ignored. Please contact the developer if you want this feature.", zh_cn="视频播放暂不支持,对视频的引用将被忽略。如果您需要该功能,请联系开发者。", @@ -1541,6 +1978,8 @@ def visitVNASTAssetReference(self, node : VNASTAssetReference) -> VNTerminatorIn cnode = VNCreateInst.create(context=self.context, start_time=self.starttime, content=content, ty=VNHandleType.get(assetdata.valuetype), device=self.parsecontext.dev_foreground, name=node.name, loc=node.location) if transition := node.transition.try_get_value(): cnode.transition.set_operand(0, transition) + else: + cnode.transition.set_operand(0, default_scene_dissolve_lit(self.context)) self.destblock.push_back(cnode) self.handle_transition_and_finishtime(cnode) info = VNCodeGen.SceneContext.AssetState(dev=self.parsecontext.dev_foreground, search_names=description, data=content, output_handle=cnode) @@ -1568,6 +2007,8 @@ def visitVNASTAssetReference(self, node : VNASTAssetReference) -> VNTerminatorIn rnode = VNRemoveInst.create(context=self.context, start_time=self.starttime, handlein=info.output_handle, name=node.name, loc=node.location) if transition := node.transition.try_get_value(): rnode.transition.set_operand(0, transition) + else: + rnode.transition.set_operand(0, default_scene_dissolve_lit(self.context)) self.handle_transition_and_finishtime(rnode) self.destblock.push_back(rnode) self.placer.add_image_event(handle=info, content=info.output_handle, instr=rnode, destexpr=node) @@ -1589,10 +2030,6 @@ def visitVNASTAssetReference(self, node : VNASTAssetReference) -> VNTerminatorIn # 当前不应该出现这种情况 raise NotImplementedError() return None - case VNASTAssetKind.KIND_EFFECT: - msg = self._tr_assetref_se_not_supported.get() - self.codegen.emit_error(code='vncodegen-not-implemented', msg=msg, loc=node.location, dest=self.destblock) - return None case VNASTAssetKind.KIND_VIDEO: msg = self._tr_assetref_video_not_supported.get() self.codegen.emit_error(code='vncodegen-not-implemented', msg=msg, loc=node.location, dest=self.destblock) @@ -1607,7 +2044,11 @@ def visitVNASTTransitionNode(self, node : VNASTTransitionNode) -> VNTerminatorIn transition_kwargs = {} transition_name = node.populate_argdicts(transition_args, transition_kwargs) warnings = [] - transition = parse_transition(self.context, transition_name=transition_name, transition_args=transition_args, transition_kwargs=transition_kwargs, warnings=warnings) + try: + transition = parse_transition(self.context, transition_name=transition_name, transition_args=transition_args, transition_kwargs=transition_kwargs, warnings=warnings, ast=self.codegen.ast) + except PPInvalidOperationError as e: + self.codegen.emit_error(code='transition-param-invalid', msg=str(e), loc=node.location, dest=self.destblock) + return None for code, msg in warnings: self.codegen.emit_error(code, msg, node.location, dest=self.destblock) if self.is_in_transition: @@ -1788,8 +2229,8 @@ def visitVNASTSceneSwitchNode(self, node : VNASTSceneSwitchNode) -> VNTerminator if info.output_handle is None: raise PPAssertionError rm = VNRemoveInst.create(context=self.context, start_time=self.starttime, handlein=info.output_handle, loc=node.location) - if self.is_in_transition and self.parent_transition is not None: - rm.transition.set_operand(0, self.parent_transition) + if (pv := self._scene_switch_parent_transition_as_value()) is not None: + rm.transition.set_operand(0, map_sprite_transition_entry_to_exit(self.context, pv)) switchnode.body.push_back(rm) rmtimes.append(rm.get_finish_time()) self.scenecontext.asset_info.clear() @@ -1797,10 +2238,10 @@ def visitVNASTSceneSwitchNode(self, node : VNASTSceneSwitchNode) -> VNTerminator for characterinfo in self.scenecontext.character_states.values(): if characterinfo.sprite_handle: rm = VNRemoveInst.create(context=self.context, start_time=self.starttime, handlein=characterinfo.sprite_handle, loc=node.location) - if self.is_in_transition and self.parent_transition is not None: - rm.transition.set_operand(0, self.parent_transition) + if (pv := self._scene_switch_parent_transition_as_value()) is not None: + rm.transition.set_operand(0, map_sprite_transition_entry_to_exit(self.context, pv)) else: - rm.transition.set_operand(0, VNDefaultTransitionType.DT_SPRITE_HIDE.get_enum_literal(self.context)) + rm.transition.set_operand(0, default_scene_dissolve_lit(self.context)) switchnode.body.push_back(rm) rmtimes.append(rm.get_finish_time()) characterinfo.sprite_handle = None @@ -1814,10 +2255,10 @@ def visitVNASTSceneSwitchNode(self, node : VNASTSceneSwitchNode) -> VNTerminator if self.scenecontext.scene_bg and self.scenecontext.scene_bg.output_handle: # 现在已有背景 rm = VNRemoveInst.create(context=self.context, start_time=self.starttime, handlein=self.scenecontext.scene_bg.output_handle, loc=node.location) - if self.is_in_transition and self.parent_transition is not None: - rm.transition.set_operand(0, self.parent_transition) + if (pv := self._scene_switch_parent_transition_as_value()) is not None: + rm.transition.set_operand(0, map_sprite_transition_entry_to_exit(self.context, pv)) else: - rm.transition.set_operand(0, VNDefaultTransitionType.DT_BACKGROUND_HIDE.get_enum_literal(self.context)) + rm.transition.set_operand(0, default_scene_dissolve_lit(self.context)) switchnode.body.push_back(rm) rmtimes.append(rm.get_finish_time()) self.scenecontext.scene_bg.output_handle = None @@ -1828,10 +2269,10 @@ def visitVNASTSceneSwitchNode(self, node : VNASTSceneSwitchNode) -> VNTerminator best_finish_time = rmtimes[-1] if len(rmtimes) > 0 else self.starttime if scene_background is not None: cnode = VNCreateInst.create(context=self.context, start_time=best_finish_time, content=scene_background, ty=VNHandleType.get(scene_background.valuetype), device=self.parsecontext.dev_background, loc=node.location) - if self.is_in_transition and self.parent_transition is not None: - cnode.transition.set_operand(0, self.parent_transition) + if (pv := self._scene_switch_parent_transition_as_value()) is not None: + cnode.transition.set_operand(0, pv) else: - cnode.transition.set_operand(0, VNDefaultTransitionType.DT_BACKGROUND_SHOW.get_enum_literal(self.context)) + cnode.transition.set_operand(0, default_scene_dissolve_lit(self.context)) self.scenecontext.scene_bg = VNCodeGen.SceneContext.AssetState(dev=self.parsecontext.dev_background, search_names=[], data=scene_background, output_handle=cnode) best_finish_time = cnode.get_finish_time() switchnode.body.push_back(cnode) diff --git a/src/preppipe/frontend/vnmodel/vnparser.py b/src/preppipe/frontend/vnmodel/vnparser.py index d6bb8c5..54f8857 100644 --- a/src/preppipe/frontend/vnmodel/vnparser.py +++ b/src/preppipe/frontend/vnmodel/vnparser.py @@ -3,8 +3,11 @@ from __future__ import annotations +import collections import copy +import decimal import re +import typing import pathvalidate @@ -22,6 +25,7 @@ from ...language import TranslationDomain from ...util.antlr4util import TextStringParsingUtil +from ...exceptions import PPInvalidOperationError # ------------------------------------------------------------------------------ # 内容声明命令 @@ -43,6 +47,25 @@ zh_cn="场景", zh_hk="場景", ) +_tr_end_effect_scene_symbol = TR_vnparse.tr("end_effect_scene_symbol", en="Scene", zh_cn="场景", zh_hk="場景") +_tr_cmd_instant_effect_duration_on_flash = TR_vnparse.tr( + "instant_effect_cmd_duration_on_flash", + en="Command parameter \"duration\" does not apply to Flash; use fade_in, hold, and fade_out inside the flash call.", + zh_cn="闪烁(Flash) 的时间形态由内层「切入时长」「停留时长」「恢复时长」决定,请勿在「场景特效/角色特效」命令上使用「时长」参数。", + zh_hk="閃爍(Flash) 的時間形態由內層「切入時長」「停留時長」「恢復時長」決定,請勿在「場景特效/角色特效」命令上使用「時長」參數。", +) +_tr_scene_effect_no_bounce_tremble = TR_vnparse.tr( + "scene_effect_no_bounce_tremble", + en="Scene effects do not support Bounce or Tremble; use Shake, Flash, filters, or place these effects under Character Instant Effect.", + zh_cn="场景特效不支持跳动(Bounce)、发抖(Tremble);请使用震动、闪烁、滤镜类,或将上述效果写在「角色特效」中。", + zh_hk="場景特效不支援跳動(Bounce)、發抖(Tremble);請使用震動、閃爍、濾鏡類,或將上述效果寫在「角色特效」中。", +) +_tr_character_effect_no_weather = TR_vnparse.tr( + "character_effect_no_weather", + en="Rain and snow are full-screen weather effects; use Scene Instant Effect, not Character Instant Effect.", + zh_cn="雨、雪为全屏场景天气,请使用「场景特效」,不能使用「角色特效」。", + zh_hk="雨、雪為全屏場景天氣,請使用「場景特效」,不能使用「角色特效」。", +) _tr_category_image = TR_vnparse.tr("category_image", en="Image", zh_cn="图片", @@ -149,6 +172,7 @@ class VNParser(FrontendParserBase[VNASTParsingState]): def __init__(self, ctx: Context, command_ns: FrontendCommandNamespace, screen_resolution : tuple[int, int], name : str = '') -> None: super().__init__(ctx, command_ns) self.ast = VNAST.create(name=name, screen_resolution=IntTuple2DLiteral.get(screen_resolution, ctx), context=ctx) + ensure_builtin_effect_presets(self.ast) self.resolution = screen_resolution @classmethod @@ -246,6 +270,22 @@ def emit_transition_node(self, state : VNASTParsingState, backingop : Operation, result.add_kwarg(name, v) else: state.emit_error('vnparser-unexpected-argument-in-transition', self._tr_unexpected_argument_in_transition.format(arg=str(v)) + " (" + k + " @ " + transition.name + ")", loc=backingop.location) + # 与 codegen 使用同一套 parse_transition,在**剧本解析阶段**报错,避免导出后 Ren'Py 才炸 + tname = (transition.name or "").strip() + if tname: + w_tr : list[tuple[str, str]] = [] + args_lit : list[Literal] = [a for a in transition.args if isinstance(a, Literal)] + # 与 VNASTTransitionNode.add_kwarg(StringLiteral.get(k,…)) / populate_argdicts 的键一致,避免校验与 codegen 关键字集合不一致 + kwo : collections.OrderedDict[str, Literal] = collections.OrderedDict() + for kk, vv in transition.kwargs.items(): + if isinstance(vv, Literal): + kwo[StringLiteral.get(kk, state.context).get_string()] = vv + try: + parse_transition(state.context, tname, args_lit, kwo, w_tr, ast=self.ast) + except PPInvalidOperationError as e: + state.emit_error("vnparse-transition-invalid", str(e), loc=backingop.location) + for code, msg in w_tr: + state.emit_error(code, msg, loc=backingop.location) state.emit_node(result) return result @@ -510,7 +550,7 @@ def handle_command_unique_invocation(self, state: VNASTParsingState, @CmdCategory(_tr_category_image) @CommandDecl(vn_command_ns, _imports, 'DeclImage') -def cmd_image_decl_path(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, name : str, path : str): +def cmd_image_decl_path(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, name : str, *, path : str): warnings : list[tuple[str,str]] = [] img = emit_image_expr_from_path(context=state.context, pathexpr=path, basepath=state.input_file_path, warnings=warnings) for code, msg in warnings: @@ -527,7 +567,7 @@ def cmd_image_decl_path(parser : VNParser, state : VNASTParsingState, commandop return @CommandDecl(vn_command_ns, _imports, 'DeclImage') -def cmd_image_decl_src(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, name : str, source : CallExprOperand): +def cmd_image_decl_src(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, name : str, *, source : CallExprOperand): wlist : list[tuple[str, str]] = [] img = emit_image_expr_from_callexpr(context=state.context, call=source, basepath=state.input_file_path, placeholderdest=ImageExprPlaceholderDest.DEST_UNKNOWN, placeholderdesc=name, warnings=wlist) if img is None: @@ -578,7 +618,7 @@ def cmd_image_decl_src(parser : VNParser, state : VNASTParsingState, commandop : }) @CommandDecl(vn_command_ns, _imports, 'DeclVariable') # pylint: disable=redefined-builtin -def cmd_variable_decl(parser: VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, name : str, type : str, initializer : str): +def cmd_variable_decl(parser: VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, name : str, *, type : str, initializer : str): if existing := state.output_current_file.variables.get(name): state.emit_error('vnparse-nameclash-variabledecl', 'Variable "' + name + '" already exist: type=' + existing.vtype.get().get_string() + ' initializer=' + existing.initializer.get().get_string(), commandop.location) return @@ -830,7 +870,7 @@ class _SceneSpecialKindEnum(enum.Enum): (_tr_scenedecl_background, [tr_vnutil_vtype_imageexprtree]) ]) @CommandDecl(vn_command_ns, _imports, 'DeclScene') -def cmd_scene_decl(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, name : str, kind: _SceneSpecialKindEnum = _SceneSpecialKindEnum.NORMAL, ext : ListExprOperand | None = None): +def cmd_scene_decl(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, name : str, *, kind: _SceneSpecialKindEnum = _SceneSpecialKindEnum.NORMAL, ext : ListExprOperand | None = None): if existing := state.output_current_file.scenes.get(name): state.emit_error('vnparse-nameclash-scenedecl', 'Scene "' + name + '" already exist', loc=commandop.location) return @@ -977,7 +1017,7 @@ def visit_node(statestack : list[str], node : ListExprTreeNode): }, }) @CommandDecl(vn_command_ns, _imports, 'DeclAlias') -def cmd_alias_decl(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, alias_name : str, target : str): +def cmd_alias_decl(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, alias_name : str, *, target : str): # (仅在解析时用到,不会在IR中) # 给目标添加别名(比如在剧本中用‘我’指代某个人) # 别名都是文件内有效,出文件就失效。(实在需要可以复制黏贴) @@ -990,7 +1030,7 @@ def cmd_alias_decl(parser : VNParser, state : VNASTParsingState, commandop : Gen @CmdCategory(_tr_category_special) @CommandDecl(vn_command_ns, _imports, 'ASM') -def cmd_asm_1(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, content : str, backend : str): +def cmd_asm_1(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, content : str, *, backend : str): # 单行内嵌后端命令 if isinstance(backend, str): backend_l = StringLiteral.get(backend, state.context) @@ -1000,7 +1040,7 @@ def cmd_asm_1(parser : VNParser, state : VNASTParsingState, commandop : GeneralC parser.emit_asm_node(state, commandop, code=[StringLiteral.get(content, state.context)], backend=backend_l) @CommandDecl(vn_command_ns, _imports, 'ASM') -def cmd_asm_2(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, ext : SpecialBlockOperand, backend : str): +def cmd_asm_2(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, ext : SpecialBlockOperand, *, backend : str): # 使用特殊块的后端命令 code = [] block : IMSpecialBlockOp = ext.original_op @@ -1043,7 +1083,7 @@ def cmd_asm_2(parser : VNParser, state : VNASTParsingState, commandop : GeneralC }, }) @CommandDecl(vn_command_ns, _imports, 'CharacterEnter') -def cmd_character_entry(parser : VNParser, state: VNASTParsingState, commandop : GeneralCommandOp, characters : list[CallExprOperand], transition : CallExprOperand = None): +def cmd_character_entry(parser : VNParser, state: VNASTParsingState, commandop : GeneralCommandOp, characters : list[CallExprOperand], *, transition : CallExprOperand = None): transition = parser.emit_transition_node(state, commandop, transition) for chexpr in characters: charname = chexpr.name @@ -1076,7 +1116,7 @@ def cmd_character_entry(parser : VNParser, state: VNASTParsingState, commandop : }, }) @CommandDecl(vn_command_ns, _imports, 'CharacterExit') -def cmd_character_exit(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, characters : list[CallExprOperand], transition : CallExprOperand = None): +def cmd_character_exit(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, characters : list[CallExprOperand], *, transition : CallExprOperand = None): # 如果退场时角色带有状态,我们先把角色的状态切换成目标状态,然后再创建一个 Transition 结点存放真正的退场 characters_list : list[StringLiteral] = [] for ch in characters: @@ -1088,24 +1128,424 @@ def cmd_character_exit(parser : VNParser, state : VNASTParsingState, commandop : node = VNASTCharacterExitNode.create(state.context, chname, name='', loc=commandop.location) transition.push_back(node) -@CmdHideInDoc +def _helper_make_filter_instant_effect_node( + ctx : Context, + loc : Location | None, + commandop : GeneralCommandOp, + *, + scene_wide : bool, + r : tuple, + duration : typing.Any, + character_name : str = "", + character_states_csv : str = "", +) -> VNASTInstantEffectNode: + fk = r[0] + d = parse_instant_effect_command_duration(fk, duration) + if fk == VNInstantEffectKind.TINT: + fc, amp = r[1], r[2] + else: + fc, amp = "#ffffff", r[1] + return VNASTInstantEffectNode.create( + ctx, + scene_wide=scene_wide, + character_name=character_name, + character_states_csv=character_states_csv, + effect_kind=fk, + duration=d, + amplitude=amp, + decay=decimal.Decimal(0), + shake_axis=VNShakeAxisKind.NONE, + motion_style=VNMotionStyleKind.LINEAR, + flash_color=fc, + flash_in=decimal.Decimal("0.06"), + flash_hold=decimal.Decimal("0.1"), + flash_out=decimal.Decimal("0.18"), + name=commandop.name, + loc=loc, + ) + +@CmdCategory(_tr_category_scene) @CmdAliasDecl(TR_vnparse, { - "zh_cn": "特效", - "zh_hk": "特效", -},{ + "zh_cn": "场景特效", + "zh_hk": "場景特效", + "en": ["SceneEffect", "SceneFx"], +}, { "effect": { "zh_cn": "特效", "zh_hk": "特效", }, + "duration": { + "zh_cn": "时长", + "zh_hk": "時長", + "en": "duration", + }, }) -@CommandDecl(vn_command_ns, _imports, 'SpecialEffect') -def cmd_special_effect(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, effect : CallExprOperand): - ref = parser.emit_pending_assetref(state, commandop, effect) - result = VNASTAssetReference.create(context=state.context, kind=VNASTAssetKind.KIND_EFFECT, operation=VNASTAssetIntendedOperation.OP_PUT, asset=ref, transition=None, name=commandop.name, loc=commandop.location) - state.emit_node(result) +@CommandDecl(vn_command_ns, _imports, 'SceneInstantEffect') +def cmd_scene_instant_effect(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, effect : CallExprOperand, *, duration : typing.Any = None): + # effect 必选;时长可选,默认可写在特效类型后的下一个按位参数,也可用关键字「时长」/ duration + w : list[tuple[str, str]] = [] + try: + r = parse_instant_effect(state.context, effect, parser.ast, w) + except PPInvalidOperationError as e: + state.emit_error("vnparse-instant-effect-invalid", str(e), loc=commandop.location) + return + for code, msg in w: + state.emit_error(code, msg, loc=commandop.location) + if r[0] in (VNInstantEffectKind.BOUNCE, VNInstantEffectKind.TREMBLE): + state.emit_error( + "vnparse-scene-effect-no-char-only-fx", + _tr_scene_effect_no_bounce_tremble.get(), + loc=commandop.location, + ) + return + loc = commandop.location + ctx = state.context + if r[0] == VNInstantEffectKind.SHAKE: + node = VNASTInstantEffectNode.create( + ctx, + scene_wide=True, + effect_kind=VNInstantEffectKind.SHAKE, + duration=parse_instant_effect_command_duration(VNInstantEffectKind.SHAKE, duration), + amplitude=r[1], + decay=r[2], + shake_axis=vn_shake_axis_from_parsed_str(r[3]), + flash_color="#ffffff", + flash_in=decimal.Decimal("0.06"), + flash_hold=decimal.Decimal("0.1"), + flash_out=decimal.Decimal("0.18"), + name=commandop.name, + loc=loc, + ) + elif r[0] in (VNInstantEffectKind.GRAYSCALE, VNInstantEffectKind.OPACITY, VNInstantEffectKind.TINT, VNInstantEffectKind.BLUR): + node = _helper_make_filter_instant_effect_node(ctx, loc, commandop, scene_wide=True, r=r, duration=duration) + elif r[0] == VNInstantEffectKind.SNOW: + node = VNASTWeatherEffectNode.create( + ctx, + weather_kind=VNWeatherEffectKind.SNOW, + intensity=r[1], + inner_fade_in=r[2], + inner_fade_out=r[3], + overlay_fade_in=parse_weather_effect_overlay_fade_in(VNInstantEffectKind.SNOW, duration), + sustain=decimal.Decimal("-1"), + vx=decimal.Decimal(0), + vy=decimal.Decimal(0), + name=commandop.name, + loc=loc, + ) + elif r[0] == VNInstantEffectKind.RAIN: + node = VNASTWeatherEffectNode.create( + ctx, + weather_kind=VNWeatherEffectKind.RAIN, + intensity=r[1], + inner_fade_in=r[2], + inner_fade_out=r[3], + overlay_fade_in=parse_weather_effect_overlay_fade_in(VNInstantEffectKind.RAIN, duration), + sustain=decimal.Decimal("-1"), + vx=r[4], + vy=r[5], + name=commandop.name, + loc=loc, + ) + else: + if duration is not None: + state.emit_error("vnparse-instant-effect-flash-duration", _tr_cmd_instant_effect_duration_on_flash.get(), loc=commandop.location) + return + node = VNASTInstantEffectNode.create( + ctx, + scene_wide=True, + effect_kind=VNInstantEffectKind.FLASH, + duration=decimal.Decimal(0), + amplitude=decimal.Decimal(0), + decay=decimal.Decimal(0), + flash_color=r[1], + flash_in=r[2], + flash_hold=r[3], + flash_out=r[4], + name=commandop.name, + loc=loc, + ) + state.emit_node(node) + +@CmdCategory(_tr_category_scene) +@CmdAliasDecl(TR_vnparse, { + "zh_cn": "结束特效", + "zh_hk": "結束特效", + "en": ["EndEffect", "EndFx"], +}, { + "target": { + "zh_cn": "目标", + "zh_hk": "目標", + }, +}) +@CommandDecl(vn_command_ns, _imports, 'EndEffect') +def cmd_end_effect(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, target : CallExprOperand): + loc = commandop.location + ctx = state.context + if _helper_subject_display_name(target) in _tr_end_effect_scene_symbol.get_all_candidates(): + node = VNASTEndEffectNode.create(ctx, scene_wide=True, name=commandop.name, loc=loc) + state.emit_node(node) + return + states = _helper_collect_character_expr(parser, state, commandop, target) + csv = ",".join(s.get_string() for s in states) + node = VNASTEndEffectNode.create( + ctx, + scene_wide=False, + character_name=_helper_subject_display_name(target), + character_states_csv=csv, + name=commandop.name, + loc=loc, + ) + state.emit_node(node) + +@CmdCategory(_tr_category_character) +@CmdAliasDecl(TR_vnparse, { + "zh_cn": "角色特效", + "zh_hk": "角色特效", + "en": ["CharacterEffect", "CharacterFx"], +}, { + "targets": { + "zh_cn": "角色与特效(按位两项:角色表达式、特效调用)", + "zh_hk": "角色與特效(按位兩項:角色表達式、特效調用)", + }, + "duration": { + "zh_cn": "时长", + "zh_hk": "時長", + "en": "duration", + }, +}) +@CommandDecl(vn_command_ns, _imports, 'CharacterInstantEffect') +def cmd_character_instant_effect( + parser : VNParser, + state : VNASTParsingState, + commandop : GeneralCommandOp, + targets : list[typing.Any], + *, + duration : typing.Any = None, +): + # character、effect 必选(同一按位 list 中前两项);时长可选,可用关键字「时长」/ duration,或第三项按位 + if len(targets) < 2: + state.emit_error( + "vnparse-character-instant-effect-args", + "「角色特效」至少需要两个按位参数:角色表达式与特效调用(例如:角色特效(苏语涵, 闪烁()))。", + loc=commandop.location, + ) + return + character, effect = targets[0], targets[1] + if len(targets) >= 3 and duration is None: + duration = targets[2] + w : list[tuple[str, str]] = [] + try: + r = parse_instant_effect(state.context, effect, parser.ast, w) + except PPInvalidOperationError as e: + state.emit_error("vnparse-instant-effect-invalid", str(e), loc=commandop.location) + return + for code, msg in w: + state.emit_error(code, msg, loc=commandop.location) + if r[0] in (VNInstantEffectKind.SNOW, VNInstantEffectKind.RAIN): + state.emit_error( + "vnparse-char-weather-effect", + _tr_character_effect_no_weather.get(), + loc=commandop.location, + ) + return + states = _helper_collect_character_expr(parser, state, commandop, character) + csv = ",".join(s.get_string() for s in states) + loc = commandop.location + ctx = state.context + cn = _helper_subject_display_name(character) + if r[0] == VNInstantEffectKind.SHAKE: + node = VNASTInstantEffectNode.create( + ctx, + scene_wide=False, + character_name=cn, + character_states_csv=csv, + effect_kind=VNInstantEffectKind.SHAKE, + duration=parse_instant_effect_command_duration(VNInstantEffectKind.SHAKE, duration), + amplitude=r[1], + decay=r[2], + shake_axis=vn_shake_axis_from_parsed_str(r[3]), + flash_color="#ffffff", + flash_in=decimal.Decimal("0.06"), + flash_hold=decimal.Decimal("0.1"), + flash_out=decimal.Decimal("0.18"), + name=commandop.name, + loc=loc, + ) + elif r[0] == VNInstantEffectKind.BOUNCE: + node = VNASTInstantEffectNode.create( + ctx, + scene_wide=False, + character_name=cn, + character_states_csv=csv, + effect_kind=VNInstantEffectKind.BOUNCE, + duration=parse_instant_effect_command_duration(VNInstantEffectKind.BOUNCE, duration), + amplitude=r[1], + decay=r[2], + motion_style=r[3], + flash_color="#ffffff", + flash_in=decimal.Decimal("0.06"), + flash_hold=decimal.Decimal("0.1"), + flash_out=decimal.Decimal("0.18"), + name=commandop.name, + loc=loc, + ) + elif r[0] == VNInstantEffectKind.TREMBLE: + node = VNASTInstantEffectNode.create( + ctx, + scene_wide=False, + character_name=cn, + character_states_csv=csv, + effect_kind=VNInstantEffectKind.TREMBLE, + duration=parse_instant_effect_command_duration(VNInstantEffectKind.TREMBLE, duration), + amplitude=r[1], + decay=r[2], + flash_color="#ffffff", + flash_in=decimal.Decimal("0.06"), + flash_hold=decimal.Decimal("0.1"), + flash_out=decimal.Decimal("0.18"), + name=commandop.name, + loc=loc, + ) + elif r[0] in (VNInstantEffectKind.GRAYSCALE, VNInstantEffectKind.OPACITY, VNInstantEffectKind.TINT, VNInstantEffectKind.BLUR): + node = _helper_make_filter_instant_effect_node( + ctx, + loc, + commandop, + scene_wide=False, + r=r, + duration=duration, + character_name=cn, + character_states_csv=csv, + ) + else: + if duration is not None: + state.emit_error("vnparse-instant-effect-flash-duration", _tr_cmd_instant_effect_duration_on_flash.get(), loc=commandop.location) + return + node = VNASTInstantEffectNode.create( + ctx, + scene_wide=False, + character_name=cn, + character_states_csv=csv, + effect_kind=VNInstantEffectKind.FLASH, + duration=decimal.Decimal(0), + amplitude=decimal.Decimal(0), + decay=decimal.Decimal(0), + flash_color=r[1], + flash_in=r[2], + flash_hold=r[3], + flash_out=r[4], + name=commandop.name, + loc=loc, + ) + state.emit_node(node) + +@CmdCategory(_tr_category_character) +@CmdAliasDecl(TR_vnparse, { + "zh_cn": "角色移动", + "zh_hk": "角色移動", + "en": ["CharacterMove", "SpriteMove"], +}, { + "targets": { + "zh_cn": "角色与补间(按位两项:角色表达式、补间调用)", + "zh_hk": "角色與補間(按位兩項:角色表達式、補間調用)", + }, +}) +@CommandDecl(vn_command_ns, _imports, 'CharacterMove') +def cmd_character_move( + parser : VNParser, + state : VNASTParsingState, + commandop : GeneralCommandOp, + targets : list[CallExprOperand], +): + # 角色与补间调用各一项,合并为唯一按位 list + if len(targets) < 2: + state.emit_error( + "vnparse-character-move-args", + "「角色移动」至少需要两个按位参数:角色表达式与补间调用(例如:角色移动(苏语涵, 移动(...)))。", + loc=commandop.location, + ) + return + character, move = targets[0], targets[1] + w : list[tuple[str, str]] = [] + try: + mk, dur, n1, n2, n3, n4, sty = parse_character_sprite_move(state.context, move, parser.ast, w) + except PPInvalidOperationError as e: + state.emit_error("vnparse-character-move-invalid", str(e), loc=commandop.location) + return + for code, msg in w: + state.emit_error(code, msg, loc=commandop.location) + states = _helper_collect_character_expr(parser, state, commandop, character) + csv = ",".join(s.get_string() for s in states) + loc = commandop.location + ctx = state.context + cn = _helper_subject_display_name(character) + if mk == VNCharacterSpriteMoveKind.MOVE: + node = VNASTCharacterMoveTweenNode.create( + ctx, + character_name=cn, + character_states_csv=csv, + duration=dur, + target_screen_x_ratio=n1, + target_screen_y_ratio=n2, + style=sty, + name=commandop.name, + loc=loc, + ) + elif mk == VNCharacterSpriteMoveKind.SCALE: + node = VNASTCharacterScaleTweenNode.create( + ctx, + character_name=cn, + character_states_csv=csv, + duration=dur, + target_scale=n1, + style=sty, + name=commandop.name, + loc=loc, + ) + elif mk == VNCharacterSpriteMoveKind.ROTATE: + node = VNASTCharacterRotateTweenNode.create( + ctx, + character_name=cn, + character_states_csv=csv, + duration=dur, + angle_degrees=n1, + style=sty, + name=commandop.name, + loc=loc, + ) + else: + state.emit_error( + "vnparse-character-move-invalid", + "不支持的立绘补间类型:%s" % mk, + loc=commandop.location, + ) + return + state.emit_node(node) -def _helper_collect_character_expr(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, expr : CallExprOperand) -> list[StringLiteral]: - # 将描述角色状态的 CallExprOperand 转化为字符串数组 +def _helper_subject_display_name(expr : CallExprOperand | StringLiteral | TextFragmentLiteral) -> str: + """按位参数可能是「调用」角色名(状态…) 或纯文本角色名 / 场景名。""" + if isinstance(expr, CallExprOperand): + return expr.name + if isinstance(expr, (StringLiteral, TextFragmentLiteral)): + return expr.get_string() + return "" + +def _helper_collect_character_expr( + parser : VNParser, + state : VNASTParsingState, + commandop : GeneralCommandOp, + expr : CallExprOperand | StringLiteral | TextFragmentLiteral, +) -> list[StringLiteral]: + # 将描述角色状态的 CallExprOperand 转化为字符串数组;纯字面量无括号状态 + if isinstance(expr, (StringLiteral, TextFragmentLiteral)): + return [] + if not isinstance(expr, CallExprOperand): + state.emit_error( + 'vnparser-unexpected-argument-in-character-expr', + '角色表达式应为「角色名」或「角色名(状态…)」调用形式,当前为:' + type(expr).__name__, + loc=commandop.location, + ) + return [] deststate : list[StringLiteral] = [] for arg in expr.args: if isinstance(arg, StringLiteral): @@ -1117,7 +1557,12 @@ def _helper_collect_character_expr(parser : VNParser, state : VNASTParsingState, state.emit_error('vnparser-unexpected-argument-in-character-expr', expr.name + ': "' + k + '"=' + str(v), loc=commandop.location) return deststate -def _helper_collect_scene_expr(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, expr : CallExprOperand) -> list[StringLiteral]: +def _helper_collect_scene_expr( + parser : VNParser, + state : VNASTParsingState, + commandop : GeneralCommandOp, + expr : CallExprOperand | StringLiteral | TextFragmentLiteral, +) -> list[StringLiteral]: # 将描述场景状态的 CallExprOperand 转化为字符串数组 # 这个和角色的一样,所以直接引用了 return _helper_collect_character_expr(parser, state, commandop, expr) @@ -1168,10 +1613,10 @@ def cmd_switch_character_state(parser : VNParser, state : VNASTParsingState, com }, }) @CommandDecl(vn_command_ns, _imports, 'SwitchScene') -def cmd_switch_scene(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, scene: CallExprOperand, transition : CallExprOperand = None): +def cmd_switch_scene(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, scene: CallExprOperand, *, transition : CallExprOperand = None): transition = parser.emit_transition_node(state, commandop, transition) scenestates = _helper_collect_scene_expr(parser, state, commandop, scene) - node = VNASTSceneSwitchNode.create(context=state.context, destscene=scene.name, states=scenestates) + node = VNASTSceneSwitchNode.create(context=state.context, destscene=_helper_subject_display_name(scene), states=scenestates) transition.push_back(node) @CmdCategory(_tr_category_image) @@ -1189,7 +1634,7 @@ def cmd_switch_scene(parser : VNParser, state : VNASTParsingState, commandop : G }, }) @CommandDecl(vn_command_ns, _imports, 'HideImage') -def cmd_hide_image(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, image_name : str, transition : CallExprOperand = None): +def cmd_hide_image(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, image_name : str, *, transition : CallExprOperand = None): transition = parser.emit_transition_node(state, commandop, transition) ref = VNASTPendingAssetReference.get(value=image_name, args=None, kwargs=None, context=state.context) node = VNASTAssetReference.create(context=state.context, kind=VNASTAssetKind.KIND_IMAGE, operation=VNASTAssetIntendedOperation.OP_REMOVE, asset=ref, transition=VNDefaultTransitionType.DT_IMAGE_HIDE.get_enum_literal(state.context), name=commandop.name, loc=commandop.location) @@ -1365,7 +1810,7 @@ class _SelectFinishActionEnum(enum.Enum): }, }) @CommandDecl(vn_command_ns, _imports, 'Select') -def cmd_select(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, ext : ListExprOperand, name : str, finish_action : _SelectFinishActionEnum = _SelectFinishActionEnum.CONTINUE): +def cmd_select(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, ext : ListExprOperand, *, name : str, finish_action : _SelectFinishActionEnum = _SelectFinishActionEnum.CONTINUE): # 该命令应该这样使用: # 【选项 名称=。。。】 # * <选项1文本> @@ -1629,7 +2074,7 @@ def _helper_collect_arguments(ctx : Context, operand : OpOperand) -> list[Value] } }) @CommandDecl(vn_command_ns, _imports, 'ExpandTable') -def cmd_expand_table(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, cmdname : str, table : TableExprOperand): +def cmd_expand_table(parser : VNParser, state : VNASTParsingState, commandop : GeneralCommandOp, cmdname : str, *, table : TableExprOperand): # 表格第一行是参数名 # 如果第一列没有参数名,这列代表按位参数(positional argument) # 从第二行起,如果某一格是空白,则代表没有该参数 diff --git a/src/preppipe/frontend/vnmodel/vnutil.py b/src/preppipe/frontend/vnmodel/vnutil.py index 9a21983..7ae91e2 100644 --- a/src/preppipe/frontend/vnmodel/vnutil.py +++ b/src/preppipe/frontend/vnmodel/vnutil.py @@ -4,17 +4,37 @@ # 该文件存放一些帮助代码生成的函数 # 有些逻辑在解析与生成时都会用到,比如查找图片表达式等,我们把这些实现放在这 +import collections +import decimal import re import traceback +import typing +import unicodedata from ...irbase import * +from ...commontypes import Color from ...imageexpr import * from ..commandsemantics import * +from ..commandsemantics import _strip_wrapping_quotes_for_numeric # import * 不导入以下划线开头的名称 from ..commandsyntaxparser import * from .vnast import * from ...util.message import MessageHandler from ...exceptions import * from ...language import TranslationDomain +from ...vnmodel import ( + VNCharacterSpriteMoveKind, + VNMotionStyleKind, + VNShakeAxisKind, + VNDefaultTransitionType, + VNDissolveSceneTransitionLit, + VNFadeInSceneTransitionLit, + VNFadeOutSceneTransitionLit, + VNFadeToColorSceneTransitionLit, + VNPushSceneTransitionLit, + VNSlideInSceneTransitionLit, + VNSlideOutSceneTransitionLit, + VNZoomSceneTransitionLit, +) _TR_vn_util = TranslationDomain("vn_util") @@ -393,29 +413,848 @@ def _try_open_audio(p : str) -> str | None: zh_hk="後端", ) -def parse_transition(context : Context, transition_name : str, transition_args : list[Literal], transition_kwargs : dict[str, Literal], warnings : list[tuple[str, str]]) -> Value | tuple[StringLiteral, StringLiteral] | None: - # 如果没有提供转场,就返回 None - # 如果提供了后端专有的转场,则返回 tuple[StringLiteral, StringLiteral] 表示后端名和表达式 - # 如果提供了通用的转场,则返回对应的值 (Value) +# 通用场景过渡(与后端无关的固定符号),对应 effect_doc 第一节 +_tr_transition_fade_in = _TR_vn_util.tr("fade_in", + en="FadeIn", + zh_cn=["淡入", "淡入时长"], + zh_hk=["淡入", "淡入時長"], +) +_tr_transition_fade_out = _TR_vn_util.tr("fade_out", + en="FadeOut", + zh_cn=["淡出", "淡出时长"], + zh_hk=["淡出", "淡出時長"], +) +_tr_transition_dissolve = _TR_vn_util.tr("dissolve", + en="Dissolve", + zh_cn="溶解", + zh_hk="溶解", +) +_tr_transition_slide_in = _TR_vn_util.tr("slide_in", + en="SlideIn", + zh_cn="滑入", + zh_hk="滑入", +) +_tr_transition_slide_out = _TR_vn_util.tr("slide_out", + en="SlideOut", + zh_cn="滑出", + zh_hk="滑出", +) +_tr_transition_push = _TR_vn_util.tr("push", + en="Push", + zh_cn="推移", + zh_hk="推移", +) +_tr_transition_fade_to_color = _TR_vn_util.tr("fade_to_color", + en="FadeToColor", + zh_cn="黑白酒", + zh_hk="黑白酒", +) +_tr_transition_zoom = _TR_vn_util.tr("zoom", + en="Zoom", + zh_cn="缩放", + zh_hk="縮放", +) +_tr_effect_shake = _TR_vn_util.tr("instant_shake", en="Shake", zh_cn="震动", zh_hk="震動") +_tr_effect_flash = _TR_vn_util.tr("instant_flash", en="Flash", zh_cn="闪烁", zh_hk="閃爍") +_tr_effect_amplitude = _TR_vn_util.tr("instant_amplitude", en="amplitude", zh_cn="幅度", zh_hk="幅度") +_tr_effect_decay = _TR_vn_util.tr("instant_decay", en="decay", zh_cn="衰减", zh_hk="衰減") +_tr_flash_fade_in = _TR_vn_util.tr("flash_fade_in", en="fade_in", zh_cn="切入时长", zh_hk="切入時長") +_tr_flash_fade_out = _TR_vn_util.tr("flash_fade_out", en="fade_out", zh_cn="恢复时长", zh_hk="恢復時長") +_tr_duration = _TR_vn_util.tr("duration", + en="duration", + zh_cn="时长", + zh_hk="時長", +) +_tr_direction = _TR_vn_util.tr("direction", + en="direction", + zh_cn="方向", + zh_hk="方向", +) +_tr_hold = _TR_vn_util.tr("hold", + en="hold", + zh_cn=["停留时长", "停留"], + zh_hk=["停留時長", "停留"], +) +_tr_start_point = _TR_vn_util.tr("start_point", + en="start_point", + zh_cn="起始点", + zh_hk="起始點", +) +_tr_end_point = _TR_vn_util.tr("end_point", + en="end_point", + zh_cn="结束点", + zh_hk="結束點", +) +def _transition_decimal_to_value(context: Context, v: decimal.Decimal | int) -> Value: + if isinstance(v, decimal.Decimal): + return FloatLiteral.get(v, context) + return FloatLiteral.get(decimal.Decimal(int(v)), context) + +# 预设效果集名称(与后端无关的固定键,用于 AST effect_presets 查找) +EFFECT_PRESET_SLIDE_DIRECTION = "SlideDirection" +EFFECT_PRESET_ZOOM_DIRECTION = "ZoomDirection" +EFFECT_PRESET_ZOOM_POINT = "ZoomPoint" +EFFECT_PRESET_SHAKE_DIRECTION = "ShakeDirection" + +# --------------------------------------------------------------------------- +# 场景转场 LiteralExpr(如 VNZoomSceneTransitionLit.direction / .point)在 parse 时已规范为英文键: +# 须在构造 IR 时即为规范英文蛇形键;codegen 只做「规范键 → 引擎参数」,不再做自然语言匹配。 +# --------------------------------------------------------------------------- + +CANONICAL_SLIDE_DIRECTIONS : typing.Final[frozenset[str]] = frozenset({"left", "right", "up", "down"}) +CANONICAL_ZOOM_DIRECTIONS : typing.Final[frozenset[str]] = frozenset({"in", "out"}) +CANONICAL_ZOOM_POINTS : typing.Final[frozenset[str]] = frozenset({ + "top_left", "top_center", "top_right", + "center_left", "center", "center_right", + "bottom_left", "bottom_center", "bottom_right", +}) +CANONICAL_SHAKE_DIRECTIONS : typing.Final[frozenset[str]] = frozenset({"horizontal", "vertical"}) + +# 内置各预设效果集可能出现的 canonical_value(规范英文蛇形键)全集;与 ensure_builtin_effect_presets 注入的条目一致。 +# 用户尚可在 EffectDecl 中为同名集追加条目,故运行时可能出现此处未列出的 canonical_value。 +BUILTIN_EFFECT_PRESET_CANONICAL_BY_SET : typing.Final[dict[str, frozenset[str]]] = { + EFFECT_PRESET_SLIDE_DIRECTION: CANONICAL_SLIDE_DIRECTIONS, + EFFECT_PRESET_ZOOM_DIRECTION: CANONICAL_ZOOM_DIRECTIONS, + EFFECT_PRESET_ZOOM_POINT: CANONICAL_ZOOM_POINTS, + EFFECT_PRESET_SHAKE_DIRECTION: CANONICAL_SHAKE_DIRECTIONS, +} + +# 未命中 EffectDecl 预设时,允许的英文别名 → 规范九点点键 +_TRANSITION_ZOOM_POINT_ALIASES : typing.Final[dict[str, str]] = { + "top": "top_center", + "bottom": "bottom_center", + "left": "center_left", + "right": "center_right", + "middle": "center", + "centre": "center", +} + + +def canonicalize_transition_zoom_point(raw : str) -> str: + if raw is None or not str(raw).strip(): + raise PPInvalidOperationError( + "转场缩放点不能为空;请使用规范键(如 center、top_center)或在 EffectDecl 的 ZoomPoint 集中声明别名。", + ) + key = str(raw).strip().replace(" ", "_").replace("-", "_").lower() + if key in CANONICAL_ZOOM_POINTS: + return key + if key in _TRANSITION_ZOOM_POINT_ALIASES: + return _TRANSITION_ZOOM_POINT_ALIASES[key] + allowed = ", ".join(sorted(CANONICAL_ZOOM_POINTS)) + raise PPInvalidOperationError( + f"不支持的转场缩放点 {raw!r}。请使用:{allowed};或英文别名 top/bottom/left/right/middle;或在 EffectDecl 中为 ZoomPoint 声明别名。", + ) + + +def canonicalize_transition_slide_direction(raw : str) -> str: + if raw is None or not str(raw).strip(): + raise PPInvalidOperationError( + "滑移/推移方向不能为空;请使用 left、right、up、down(或在 EffectDecl 的 SlideDirection 集中声明别名)。", + ) + s = str(raw).strip().lower() + if s == "top": + s = "up" + if s == "bottom": + s = "down" + if s in CANONICAL_SLIDE_DIRECTIONS: + return s + raise PPInvalidOperationError( + f"不支持的滑移/推移方向 {raw!r}。请使用:left、right、up、down。", + ) + + +def canonicalize_transition_zoom_direction(raw : str) -> str: + if raw is None or not str(raw).strip(): + raise PPInvalidOperationError( + "缩放转场方向不能为空;请使用 in 或 out(或在 EffectDecl 的 ZoomDirection 集中声明别名)。", + ) + s = str(raw).strip().lower() + if s in ("入",): + s = "in" + if s in ("出",): + s = "out" + if s in CANONICAL_ZOOM_DIRECTIONS: + return s + raise PPInvalidOperationError( + f"不支持的缩放转场方向 {raw!r}。请使用:in、out。", + ) + + +def resolve_effect_preset_value(ast : VNAST, preset_set_name : str, user_input : str) -> StringLiteral | None: + """ + 根据 AST 中的 EffectDecl 预设效果集解析用户输入为规范键(StringLiteral)。 + 若 ast 为 None 或未找到匹配则返回 None。 + """ + if ast is None: + return None + preset_set = ast.effect_presets.get(preset_set_name) + if preset_set is None: + return None + raw = user_input.strip() + raw_lower = raw.lower() + for entry in preset_set.entries: + if not isinstance(entry, VNASTEffectPresetEntrySymbol): + continue + canonical_str = entry.canonical_value.get().get_string() + if raw == canonical_str or raw_lower == canonical_str.lower(): + return entry.canonical_value.get() + for use in entry.aliases.operanduses(): + a = use.value.get_string() + if a and (raw == a or raw_lower == a.lower()): + return entry.canonical_value.get() + return None + + +def ensure_builtin_effect_presets(ast : VNAST) -> None: + """向 AST 注入内置预设效果集(SlideDirection、ZoomDirection),若尚不存在则创建。""" + ctx = ast.context + if ast.effect_presets.get(EFFECT_PRESET_SLIDE_DIRECTION) is None: + slide = VNASTEffectPresetSetSymbol.create(ctx, EFFECT_PRESET_SLIDE_DIRECTION) + for canonical, aliases in [ + ("left", ["左", "Left"]), + ("right", ["右", "Right"]), + ("up", ["上", "Up"]), + ("down", ["下", "Down"]), + ]: + slide.entries.add(VNASTEffectPresetEntrySymbol.create(ctx, canonical, aliases)) + ast.effect_presets.add(slide) + if ast.effect_presets.get(EFFECT_PRESET_ZOOM_DIRECTION) is None: + zoom = VNASTEffectPresetSetSymbol.create(ctx, EFFECT_PRESET_ZOOM_DIRECTION) + for canonical, aliases in [ + ("in", ["入", "In"]), + ("out", ["出", "Out"]), + ]: + zoom.entries.add(VNASTEffectPresetEntrySymbol.create(ctx, canonical, aliases)) + ast.effect_presets.add(zoom) + if ast.effect_presets.get(EFFECT_PRESET_ZOOM_POINT) is None: + zoom_pt = VNASTEffectPresetSetSymbol.create(ctx, EFFECT_PRESET_ZOOM_POINT) + for canonical, aliases in [ + ("top_left", ["左上"]), + ("top_center", ["中上", "上中", "上"]), + ("top_right", ["右上"]), + ("center_left", ["左中", "左"]), + ("center", ["中心", "中"]), + ("center_right", ["右中", "右"]), + ("bottom_left", ["左下"]), + ("bottom_center", ["中下", "下中", "下"]), + ("bottom_right", ["右下"]), + ]: + zoom_pt.entries.add(VNASTEffectPresetEntrySymbol.create(ctx, canonical, aliases)) + ast.effect_presets.add(zoom_pt) + if ast.effect_presets.get(EFFECT_PRESET_SHAKE_DIRECTION) is None: + sd = VNASTEffectPresetSetSymbol.create(ctx, EFFECT_PRESET_SHAKE_DIRECTION) + for canonical, aliases in [ + ("horizontal", ["水平", "H", "X"]), + ("vertical", ["垂直", "V", "Y"]), + ]: + sd.entries.add(VNASTEffectPresetEntrySymbol.create(ctx, canonical, aliases)) + ast.effect_presets.add(sd) + + +def parse_transition(context : Context, transition_name : str, transition_args : list[Literal], transition_kwargs : dict[str, Literal], warnings : list[tuple[str, str]], ast : VNAST | None = None) -> Value | tuple[StringLiteral, StringLiteral] | None: + # - 未写转场名(空字符串):返回 None,表示「本条 Transition 结点未指定效果」;外层 codegen 对立绘等仍会套用默认淡入/淡出。 + # - 显式「无」等:返回 DT_NO_TRANSITION,表示用户要求立刻切换、不要渐变。 + # - 后端专有:tuple[backend, expr];通用场景转场:对应 LiteralExpr / 等价值。 + # ast 用于从 EffectDecl 预设效果集解析 direction 等多语言参数;为 None 时退化为仅接受英文规范键。 + + transition_name = (transition_name or "").strip() if len(transition_name) == 0: return None - # 目前我们支持以下转场: - # 1. 无:立即发生,无转场 - # 2. 后端转场<表达式,后端>:后端专有的表达式 - # 等以后做通用的再加 + # 1. 无:立即发生,无转场(与「未写转场名」不同,后者为 None) if transition_name in _tr_transition_none.get_all_candidates(): return VNDefaultTransitionType.DT_NO_TRANSITION.get_enum_literal(context) + # 黑白酒:表格常写「时长」,与溶解/淡入等单段转场混淆;须在 resolve_call 前给出明确错误(否则易落到 Ren'Py 运行期) + if transition_name in _tr_transition_fade_to_color.get_all_candidates(): + for _kw in transition_kwargs: + if unicodedata.normalize("NFKC", _kw).strip() == "时长": + raise PPInvalidOperationError( + "转场「黑白酒」不能使用参数「时长」。请使用「淡出」「淡出时长」「停留时长」「淡入」「淡入时长」与「颜色」。", + ) + def handle_backend_specific_transition(expr : str, *, backend : str) -> tuple[StringLiteral, StringLiteral]: return (StringLiteral.get(backend, context), StringLiteral.get(expr, context)) + def _resolve_direction(preset_name: str, direction_str: str) -> StringLiteral: + resolved = resolve_effect_preset_value(ast, preset_name, direction_str) if ast else None + if resolved is not None: + return resolved + if preset_name == EFFECT_PRESET_SLIDE_DIRECTION: + canon = canonicalize_transition_slide_direction(direction_str) + elif preset_name == EFFECT_PRESET_ZOOM_DIRECTION: + canon = canonicalize_transition_zoom_direction(direction_str) + else: + raise PPInternalError("未知的方向预设集: " + preset_name) + return StringLiteral.get(canon, context) + + # 2. 通用场景过渡(与后端无关的固定符号) + def handle_fade_in(duration: decimal.Decimal = decimal.Decimal("0.5")): + return VNFadeInSceneTransitionLit.get(context, _transition_decimal_to_value(context, duration)) + + def handle_fade_out(duration: decimal.Decimal = decimal.Decimal("0.5")): + return VNFadeOutSceneTransitionLit.get(context, _transition_decimal_to_value(context, duration)) + + def handle_dissolve(duration: decimal.Decimal = decimal.Decimal("0.5")): + return VNDissolveSceneTransitionLit.get(context, _transition_decimal_to_value(context, duration)) + + def handle_slide_in(duration: decimal.Decimal = decimal.Decimal("0.5"), *, direction: str): + dir_lit = _resolve_direction(EFFECT_PRESET_SLIDE_DIRECTION, direction) + return VNSlideInSceneTransitionLit.get(context, _transition_decimal_to_value(context, duration), dir_lit) + + def handle_slide_out(duration: decimal.Decimal = decimal.Decimal("0.5"), *, direction: str): + dir_lit = _resolve_direction(EFFECT_PRESET_SLIDE_DIRECTION, direction) + return VNSlideOutSceneTransitionLit.get(context, _transition_decimal_to_value(context, duration), dir_lit) + + def handle_push(duration: decimal.Decimal = decimal.Decimal("0.5"), *, direction: str): + dir_lit = _resolve_direction(EFFECT_PRESET_SLIDE_DIRECTION, direction) + return VNPushSceneTransitionLit.get(context, _transition_decimal_to_value(context, duration), dir_lit) + + def handle_fade_to_color( + *, + fade_out: decimal.Decimal = decimal.Decimal("0.5"), + hold: decimal.Decimal = decimal.Decimal("0.2"), + fade_in: decimal.Decimal = decimal.Decimal("0.5"), + color: Color, + ): + color_lit = ColorLiteral.get(color, context) + return VNFadeToColorSceneTransitionLit.get( + context, + _transition_decimal_to_value(context, fade_out), + _transition_decimal_to_value(context, hold), + _transition_decimal_to_value(context, fade_in), + color_lit, + ) + + def _resolve_zoom_point(preset_name: str, point_str: str) -> StringLiteral: + resolved = resolve_effect_preset_value(ast, preset_name, point_str) if ast else None + if resolved is not None: + return resolved + canon = canonicalize_transition_zoom_point(point_str) + return StringLiteral.get(canon, context) + + def handle_zoom( + duration: decimal.Decimal = decimal.Decimal("0.5"), + *, + direction: str, + start_point: str = "center", + end_point: str = "center", + ): + dir_lit = _resolve_direction(EFFECT_PRESET_ZOOM_DIRECTION, direction) + d_val = _transition_decimal_to_value(context, duration) + dir_s = dir_lit.get_string().strip().lower() + point_str = start_point if dir_s == "in" else end_point + point_lit = _resolve_zoom_point(EFFECT_PRESET_ZOOM_POINT, point_str) + return VNZoomSceneTransitionLit.get(context, dir_lit, d_val, point_lit) + callexpr = CallExprOperand(transition_name, transition_args, collections.OrderedDict(transition_kwargs)) transition_expr = FrontendParserBase.resolve_call(callexpr, [ (_tr_transition_backend_transition, handle_backend_specific_transition, {'expr': _tr_expr, 'backend': _tr_backend}), - ], warnings) - return transition_expr + (_tr_transition_fade_in, handle_fade_in, {'duration': _tr_duration}), + (_tr_transition_fade_out, handle_fade_out, {'duration': _tr_duration}), + (_tr_transition_dissolve, handle_dissolve, {'duration': _tr_duration}), + (_tr_transition_slide_in, handle_slide_in, {'duration': _tr_duration, 'direction': _tr_direction}), + (_tr_transition_slide_out, handle_slide_out, {'duration': _tr_duration, 'direction': _tr_direction}), + (_tr_transition_push, handle_push, {'duration': _tr_duration, 'direction': _tr_direction}), + (_tr_transition_fade_to_color, handle_fade_to_color, {'fade_out': _tr_transition_fade_out, 'hold': _tr_hold, 'fade_in': _tr_transition_fade_in, 'color': _tr_color}), + (_tr_transition_zoom, handle_zoom, {'duration': _tr_duration, 'direction': _tr_direction, 'start_point': _tr_start_point, 'end_point': _tr_end_point}), + ], warnings, strict=True) + if transition_expr is not None: + return transition_expr + msgs = [m for _, m in warnings] + raise PPInvalidOperationError( + "转场「%s」无法用当前参数解析:%s" + % ( + transition_name, + ";".join(msgs) + if msgs + else "请检查参数名与类型(「黑白酒」须用:淡出/淡出时长、停留时长、淡入/淡入时长、颜色;勿使用单独的「时长」)。", + ), + ) + + +_DEFAULT_TRANSITION_DURATION = decimal.Decimal("0.5") + + +def map_sprite_transition_entry_to_exit(context : Context, entry : Value) -> Value: + """立绘退场:若包裹块给出的是「入场类」通用转场,映射为对应的退场类,避免把淡入等绑在 hide 上。 + + DT_NO_TRANSITION 与已是退场类 / 未知值保持原样;DT_SPRITE_SHOW 视为默认入场,映射为默认溶解退场(与 codegen 默认一致)。""" + if isinstance(entry, VNFadeInSceneTransitionLit): + return VNFadeOutSceneTransitionLit.get(context, entry.duration) + if isinstance(entry, VNDissolveSceneTransitionLit): + return VNDissolveSceneTransitionLit.get(context, entry.duration) + if isinstance(entry, VNSlideInSceneTransitionLit): + return VNSlideOutSceneTransitionLit.get(context, entry.duration, entry.direction) + if isinstance(entry, VNPushSceneTransitionLit): + return VNPushSceneTransitionLit.get(context, entry.duration, entry.direction) + if isinstance(entry, VNFadeToColorSceneTransitionLit): + return VNFadeOutSceneTransitionLit.get(context, entry.fade_out) + if isinstance(entry, VNZoomSceneTransitionLit): + if entry.direction.get_string().strip().lower() == "in": + return VNZoomSceneTransitionLit.get( + context, + StringLiteral.get("out", context), + entry.duration, + entry.point, + ) + return entry + dt = VNDefaultTransitionType.get_default_transition_type(entry) + if dt == VNDefaultTransitionType.DT_NO_TRANSITION: + return entry + if dt == VNDefaultTransitionType.DT_SPRITE_SHOW: + return VNDissolveSceneTransitionLit.get(context, FloatLiteral.get(_DEFAULT_TRANSITION_DURATION, context)) + return entry + + +def default_scene_fade_in_lit(context : Context) -> VNFadeInSceneTransitionLit: + """显式淡入(FadeIn);未写转场的立绘/前景/背景默认路径用 default_scene_dissolve_lit。""" + return VNFadeInSceneTransitionLit.get(context, FloatLiteral.get(_DEFAULT_TRANSITION_DURATION, context)) + + +def default_scene_fade_out_lit(context : Context) -> VNFadeOutSceneTransitionLit: + """显式淡出(FadeOut);未写转场的立绘/前景/背景默认路径用 default_scene_dissolve_lit。""" + return VNFadeOutSceneTransitionLit.get(context, FloatLiteral.get(_DEFAULT_TRANSITION_DURATION, context)) + + +def default_scene_dissolve_lit(context : Context) -> VNDissolveSceneTransitionLit: + """用户未写转场时,场景、立绘、前景图等采用的默认溶解(与 Ren'Py dissolve 一致)。""" + return VNDissolveSceneTransitionLit.get(context, FloatLiteral.get(_DEFAULT_TRANSITION_DURATION, context)) + + +_tr_effect_bounce = _TR_vn_util.tr("instant_bounce", en="Bounce", zh_cn="跳动", zh_hk="跳動") +_tr_motion_style = _TR_vn_util.tr("motion_style", en="style", zh_cn="方式", zh_hk="方式") +_tr_bounce_height_ratio = _TR_vn_util.tr("bounce_height_ratio", en="height", zh_cn="高度", zh_hk="高度") +_tr_bounce_count = _TR_vn_util.tr("instant_bounce_count", en="count", zh_cn="次数", zh_hk="次數") +_tr_effect_tremble = _TR_vn_util.tr("instant_tremble", en=["Tremble", "Shiver"], zh_cn="发抖", zh_hk="發抖") +_tr_tremble_period = _TR_vn_util.tr("tremble_period", en="period", zh_cn="周期", zh_hk="週期") +_tr_effect_grayscale = _TR_vn_util.tr("instant_grayscale", en="Grayscale", zh_cn="灰化", zh_hk="灰化") +_tr_effect_opacity = _TR_vn_util.tr("instant_opacity", en="Opacity", zh_cn="半透明", zh_hk="半透明") +_tr_effect_tint = _TR_vn_util.tr("instant_tint", en=["Tint", "ColorOverlay"], zh_cn="色调叠加", zh_hk="色調疊加") +_tr_effect_blur = _TR_vn_util.tr("instant_blur", en="Blur", zh_cn="模糊", zh_hk="模糊") +_tr_filter_strength = _TR_vn_util.tr("filter_strength", en="strength", zh_cn="强度", zh_hk="強度") +_tr_filter_alpha = _TR_vn_util.tr("filter_alpha", en="alpha", zh_cn="透明度", zh_hk="透明度") +_tr_effect_snow = _TR_vn_util.tr("instant_snow", en="Snow", zh_cn="雪", zh_hk="雪") +_tr_effect_rain = _TR_vn_util.tr("instant_rain", en="Rain", zh_cn="雨", zh_hk="雨") +_tr_weather_intensity = _TR_vn_util.tr("weather_intensity", en="intensity", zh_cn="强度", zh_hk="強度") +_tr_weather_fade_in = _TR_vn_util.tr("weather_fade_in", en="fade_in", zh_cn="淡入时长", zh_hk="淡入時長") +_tr_weather_fade_out = _TR_vn_util.tr("weather_fade_out", en="fade_out", zh_cn="淡出时长", zh_hk="淡出時長") +_tr_weather_vx = _TR_vn_util.tr("weather_horizontal_speed", en="horizontal_speed", zh_cn="水平速度", zh_hk="水平速度") +_tr_weather_vy = _TR_vn_util.tr("weather_vertical_speed", en="vertical_speed", zh_cn="垂直速度", zh_hk="垂直速度") + + +def _vnutil_clamp_dec(lo : decimal.Decimal, hi : decimal.Decimal, x : decimal.Decimal) -> decimal.Decimal: + return max(lo, min(hi, x)) + + +def _parse_effect_duration_sustain(v : typing.Any) -> decimal.Decimal: + """时长:数值为秒;字符串「持续」等表示无限(IR 用 -1);需配合「结束特效」。 + Word 格子里常为 TextFragmentLiteral 或带引号的数值串,须走 Decimal 路径而非内置 float。""" + D = decimal.Decimal + if isinstance(v, TextFragmentLiteral): + raw = v.get_string() + elif isinstance(v, StringLiteral): + raw = v.get_string() + elif isinstance(v, str): + raw = v + elif isinstance(v, FloatLiteral): + return v.value + elif isinstance(v, IntLiteral): + return D(int(v.value)) + else: + try: + return D(str(v)) + except decimal.InvalidOperation: + return D("0.8") + t = _strip_wrapping_quotes_for_numeric(raw).strip().lower() + if t in ("持续", "永续", "无限", "sustain", "infinite", "loop", "forever"): + return D("-1") + try: + return D(t) + except decimal.InvalidOperation: + return D("-1") if t else D("0.8") + + +def _normalize_motion_style(v : typing.Any) -> str: + if isinstance(v, StringLiteral): + s = v.get_string() + else: + s = str(v or "") + t = s.strip().lower().replace("-", "").replace("_", "") + if t in ("", "linear", "线性", "匀速"): + return "linear" + if t in ("ease", "缓动"): + return "ease" + if t in ("easein", "缓入"): + return "easein" + if t in ("easeout", "缓出"): + return "easeout" + return t + + +_MOTION_STYLE_STR_TO_ENUM : dict[str, VNMotionStyleKind] = { + VNMotionStyleKind.LINEAR.value: VNMotionStyleKind.LINEAR, + VNMotionStyleKind.EASE.value: VNMotionStyleKind.EASE, + VNMotionStyleKind.EASEIN.value: VNMotionStyleKind.EASEIN, + VNMotionStyleKind.EASEOUT.value: VNMotionStyleKind.EASEOUT, +} + + +def vn_motion_style_normalize_to_enum(v : typing.Any) -> VNMotionStyleKind: + raw = _normalize_motion_style(v) + return _MOTION_STYLE_STR_TO_ENUM.get(raw, VNMotionStyleKind.LINEAR) + + +def vn_shake_axis_from_parsed_str(s : str) -> VNShakeAxisKind: + t = (s or "").strip().lower() + if t == "": + return VNShakeAxisKind.NONE + if t == "horizontal": + return VNShakeAxisKind.HORIZONTAL + if t == "vertical": + return VNShakeAxisKind.VERTICAL + return VNShakeAxisKind.HORIZONTAL + + +def parse_weather_effect_overlay_fade_in(effect_kind : VNInstantEffectKind, cmd_duration : typing.Any) -> decimal.Decimal: + """天气(雨/雪)命令「时长」:正数为全屏层 alpha 淡入秒数;「持续」或省略时用该效果类型的默认淡入(如雪/雨 0.8)。 + + ``effect_kind`` 须为 ``VNInstantEffectKind``(与 ``parse_instant_effect`` 首项及 AST 一致),勿传裸字符串。""" + if cmd_duration is None: + return parse_instant_effect_command_duration(effect_kind, None) + raw = _parse_effect_duration_sustain(cmd_duration) + if raw < 0: + return parse_instant_effect_command_duration(effect_kind, None) + return raw + + +def parse_instant_effect_command_duration(effect_kind : VNInstantEffectKind, cmd_duration : typing.Any) -> decimal.Decimal: + """场景/角色特效命令上的「时长」:秒;None 时按效果类型给默认;「持续」等同义词见 _parse_effect_duration_sustain。 + + ``effect_kind`` 须为 ``VNInstantEffectKind``(与 ``parse_instant_effect`` 首项及 AST 一致),勿传裸字符串。""" + D = decimal.Decimal + if cmd_duration is None: + match effect_kind: + case VNInstantEffectKind.SHAKE: + return D("0.5") + case VNInstantEffectKind.BOUNCE: + return D("0.4") + case VNInstantEffectKind.TREMBLE: + return D("0.8") + case VNInstantEffectKind.GRAYSCALE | VNInstantEffectKind.OPACITY | VNInstantEffectKind.TINT | VNInstantEffectKind.BLUR: + return D("0.35") + case VNInstantEffectKind.SNOW | VNInstantEffectKind.RAIN: + return D("0.8") + case _: + return D(0) + return _parse_effect_duration_sustain(cmd_duration) + + +def parse_instant_effect( + context : Context, + call : CallExprOperand, + ast : VNAST | None, + warnings : list[tuple[str, str]], +) -> tuple[VNInstantEffectKind, ...]: + """ + 解析场景/角色即时特效(震动、闪烁、跳动、发抖及滤镜类)。**整体过渡/持续时长**由外层命令 **时长** 提供(滤镜为过渡到目标状态的秒数;持续见 _parse_effect_duration_sustain)。 + 首项恒为 ``VNInstantEffectKind``;其余标量数值均为 ``decimal.Decimal``(bounce 次数等亦以 Decimal 表示以便写入 IR): + (SHAKE, amplitude, decay, direction_str) 或 + (FLASH, color_hex, fade_in, hold, fade_out) 或 + (BOUNCE, height_ratio, count, style_enum) 或 + (TREMBLE, amplitude, period) 或 + (GRAYSCALE, strength) 或 (OPACITY, alpha) 或 (TINT, color_hex, strength) 或 (BLUR, radius) 或 + (SNOW, intensity, inner_fade_in, inner_fade_out) 或 + (RAIN, intensity, inner_fade_in, inner_fade_out, horizontal_speed, vertical_speed) + """ + def _shake_dir(s : str) -> str: + s = (s or "").strip() + if not s: + return "" + r = resolve_effect_preset_value(ast, EFFECT_PRESET_SHAKE_DIRECTION, s) if ast else None + if r is not None: + low = r.get_string().strip().lower() + if low in ("horizontal", "h", "x", "水平"): + return "horizontal" + if low in ("vertical", "v", "y", "垂直"): + return "vertical" + return "horizontal" + low = s.lower() + if low in ("horizontal", "h", "x", "水平"): + return "horizontal" + if low in ("vertical", "v", "y", "垂直"): + return "vertical" + return "horizontal" + + def _color_str(v) -> str: + if isinstance(v, ColorLiteral): + c = v.value + return f"#{c.r:02x}{c.g:02x}{c.b:02x}" + if isinstance(v, Color): + return f"#{v.r:02x}{v.g:02x}{v.b:02x}" + if isinstance(v, StringLiteral): + t = v.get_string().strip().lower() + if t in ("白", "white", "#fff", "#ffffff"): + return "#ffffff" + if t in ("黑", "black", "#000", "#000000"): + return "#000000" + if t.startswith("#") and len(t) >= 4: + return t + return "#ffffff" + + def handle_shake( + *, + amplitude: decimal.Decimal = decimal.Decimal("12"), + decay: decimal.Decimal = decimal.Decimal(0), + direction: typing.Any = "", + ): + dstr = direction.get_string() if isinstance(direction, StringLiteral) else str(direction or "") + return (VNInstantEffectKind.SHAKE, amplitude, decay, _shake_dir(dstr)) + + def handle_flash( + *, + color: typing.Any = "#ffffff", + fade_in: decimal.Decimal = decimal.Decimal("0.06"), + hold: decimal.Decimal = decimal.Decimal("0.1"), + fade_out: decimal.Decimal = decimal.Decimal("0.18"), + ): + if isinstance(color, str): + co = _color_str(StringLiteral.get(color, context)) + else: + co = _color_str(color) + return (VNInstantEffectKind.FLASH, co, fade_in, hold, fade_out) + + def handle_bounce( + *, + height: decimal.Decimal = decimal.Decimal("0.04"), + count: decimal.Decimal = decimal.Decimal(1), + style: typing.Any = "linear", + ): + cnt = max(decimal.Decimal(1), count.to_integral_value(rounding=decimal.ROUND_HALF_UP)) + return (VNInstantEffectKind.BOUNCE, height, cnt, vn_motion_style_normalize_to_enum(style)) + + def handle_tremble( + *, + amplitude: decimal.Decimal = decimal.Decimal(6), + period: decimal.Decimal = decimal.Decimal("0.14"), + ): + p = max(decimal.Decimal("0.04"), period) + return (VNInstantEffectKind.TREMBLE, amplitude, p) + + D0 = decimal.Decimal(0) + D1 = decimal.Decimal(1) + + def handle_grayscale(strength: decimal.Decimal = decimal.Decimal(1)): + st = _vnutil_clamp_dec(D0, D1, strength) + return (VNInstantEffectKind.GRAYSCALE, st) + + def handle_opacity(alpha: decimal.Decimal = decimal.Decimal("0.7")): + a = _vnutil_clamp_dec(D0, D1, alpha) + return (VNInstantEffectKind.OPACITY, a) + + def handle_tint( + *, + color: typing.Any = "#ffffff", + strength: decimal.Decimal = decimal.Decimal("0.6"), + ): + if isinstance(color, str): + co = _color_str(StringLiteral.get(color, context)) + else: + co = _color_str(color) + st = _vnutil_clamp_dec(D0, D1, strength) + return (VNInstantEffectKind.TINT, co, st) + + def handle_blur(strength: decimal.Decimal = decimal.Decimal(8)): + b = _vnutil_clamp_dec(decimal.Decimal(0), decimal.Decimal(48), strength) + return (VNInstantEffectKind.BLUR, b) + + def handle_snow( + *, + intensity: decimal.Decimal = decimal.Decimal(100), + fade_in: decimal.Decimal = decimal.Decimal("0.25"), + fade_out: decimal.Decimal = decimal.Decimal("0.45"), + ): + n = _vnutil_clamp_dec(decimal.Decimal(10), decimal.Decimal(400), intensity) + fi = max(D0, fade_in) + fo = max(decimal.Decimal("0.05"), fade_out) + return (VNInstantEffectKind.SNOW, n, fi, fo) + + def handle_rain( + *, + intensity: decimal.Decimal = decimal.Decimal(140), + fade_in: decimal.Decimal = decimal.Decimal("0.2"), + fade_out: decimal.Decimal = decimal.Decimal("0.4"), + horizontal_speed: decimal.Decimal = decimal.Decimal("-40"), + vertical_speed: decimal.Decimal = decimal.Decimal(520), + ): + n = _vnutil_clamp_dec(decimal.Decimal(20), decimal.Decimal(500), intensity) + vx = horizontal_speed + vy = _vnutil_clamp_dec(decimal.Decimal(80), decimal.Decimal(1200), vertical_speed) + fi = max(D0, fade_in) + fo = max(decimal.Decimal("0.05"), fade_out) + return (VNInstantEffectKind.RAIN, n, fi, fo, vx, vy) + + callexpr = CallExprOperand(call.name, call.args, collections.OrderedDict(call.kwargs)) + out = FrontendParserBase.resolve_call( + callexpr, + [ + (_tr_effect_shake, handle_shake, {"amplitude": _tr_effect_amplitude, "decay": _tr_effect_decay, "direction": _tr_direction}), + (_tr_effect_flash, handle_flash, {"color": _tr_color, "fade_in": _tr_flash_fade_in, "hold": _tr_hold, "fade_out": _tr_flash_fade_out}), + (_tr_effect_bounce, handle_bounce, {"height": _tr_bounce_height_ratio, "count": _tr_bounce_count, "style": _tr_motion_style}), + (_tr_effect_tremble, handle_tremble, {"amplitude": _tr_effect_amplitude, "period": _tr_tremble_period}), + (_tr_effect_grayscale, handle_grayscale, {"strength": _tr_filter_strength}), + (_tr_effect_opacity, handle_opacity, {"alpha": _tr_filter_alpha}), + (_tr_effect_tint, handle_tint, {"color": _tr_color, "strength": _tr_filter_strength}), + (_tr_effect_blur, handle_blur, {"strength": _tr_filter_strength}), + (_tr_effect_snow, handle_snow, {"intensity": _tr_weather_intensity, "fade_in": _tr_weather_fade_in, "fade_out": _tr_weather_fade_out}), + (_tr_effect_rain, handle_rain, {"intensity": _tr_weather_intensity, "fade_in": _tr_weather_fade_in, "fade_out": _tr_weather_fade_out, "horizontal_speed": _tr_weather_vx, "vertical_speed": _tr_weather_vy}), + ], + warnings, + strict=True, + ) + if out is None: + msgs = [m for _, m in warnings] + raise PPInvalidOperationError( + "即时特效「%s」无法用当前参数解析:%s" % (call.name, ";".join(msgs) if msgs else "未知特效名或参数不匹配。"), + ) + return out + + +_tr_sprite_move = _TR_vn_util.tr("sprite_anim_move", en="Move", zh_cn="移动", zh_hk="移動") +_tr_sprite_scale = _TR_vn_util.tr("sprite_anim_scale", en="Scale", zh_cn="缩放", zh_hk="縮放") +_tr_sprite_rotate = _TR_vn_util.tr("sprite_anim_rotate", en="Rotate", zh_cn="旋转", zh_hk="旋轉") +_tr_sprite_scale_ratio = _TR_vn_util.tr("sprite_scale_ratio", en="scale", zh_cn="比例", zh_hk="比例") +_tr_rotate_angle = _TR_vn_util.tr("sprite_rotate_angle", en="angle", zh_cn="角度", zh_hk="角度") +_tr_ratio_x = _TR_vn_util.tr("sprite_ratio_x", en="x", zh_cn="横向比例", zh_hk="橫向比例") +_tr_ratio_y = _TR_vn_util.tr("sprite_ratio_y", en="y", zh_cn="纵向比例", zh_hk="縱向比例") +# spec 形参的 Translatable:首条为报错/文档中的可读说明;末条为内部占位,避免与用户关键字冲突(get_all_candidates 才会收录) +_tr_sprite_move_spec = _TR_vn_util.tr( + "sprite_move_spec", + en=["positional: horizontal ratio, vertical ratio [, duration]", "__pp_sprite_move_spec__"], + zh_cn=["按位:横向比例、纵向比例(可选第三项时长)", "__pp_sprite_move_spec__"], + zh_hk=["按位:橫向比例、縱向比例(可選第三項時長)", "__pp_sprite_move_spec__"], +) +_tr_sprite_scale_spec = _TR_vn_util.tr( + "sprite_scale_spec", + en=["positional: scale [, duration]", "__pp_sprite_scale_spec__"], + zh_cn=["按位:比例(可选第二项时长)", "__pp_sprite_scale_spec__"], + zh_hk=["按位:比例(可選第二項時長)", "__pp_sprite_scale_spec__"], +) +_tr_sprite_rotate_spec = _TR_vn_util.tr( + "sprite_rotate_spec", + en=["positional: degrees [, duration]", "__pp_sprite_rotate_spec__"], + zh_cn=["按位:角度(度)(可选第二项时长)", "__pp_sprite_rotate_spec__"], + zh_hk=["按位:角度(度)(可選第二項時長)", "__pp_sprite_rotate_spec__"], +) + + +def parse_character_sprite_move( + context : Context, + call : CallExprOperand, + ast : VNAST | None, + warnings : list[tuple[str, str]], +) -> tuple[VNCharacterSpriteMoveKind, decimal.Decimal, decimal.Decimal, decimal.Decimal, decimal.Decimal, decimal.Decimal, VNMotionStyleKind]: + """ + 解析角色立绘补间(移动/缩放/旋转)。返回: + (move_kind, duration, n1, n2, n3, n4, style);move_kind 为 ``VNCharacterSpriteMoveKind``,标量均为 ``decimal.Decimal``,style 为 ``VNMotionStyleKind``。 + move: n1=横向位置比例(0~1), n2=纵向位置比例, n3=n4=0 + scale: n1=目标缩放(相对1.0), n2=n3=n4=0(缩放中心固定为立绘中心) + rotate: n1=角度(度), n2=n3=n4=0(绕中心旋转) + """ + z = decimal.Decimal(0) + def handle_move( + spec: list[decimal.Decimal], + *, + ratio_x: decimal.Decimal | None = None, + ratio_y: decimal.Decimal | None = None, + duration: decimal.Decimal = decimal.Decimal("0.5"), + style: typing.Any = "linear", + ): + if len(spec) >= 2: + x, y = spec[0], spec[1] + dur = spec[2] if len(spec) >= 3 else duration + elif len(spec) == 0 and ratio_x is not None and ratio_y is not None: + x, y = ratio_x, ratio_y + dur = duration + else: + raise PPInvalidOperationError( + "立绘移动需要:两个按位参数(横向比例、纵向比例,可选第三项时长),或同时使用关键字「横向比例」「纵向比例」(时长、方式仍可用关键字)。", + ) + return (VNCharacterSpriteMoveKind.MOVE, dur, x, y, z, z, vn_motion_style_normalize_to_enum(style)) + + def handle_scale( + spec: list[decimal.Decimal], + *, + target_scale: decimal.Decimal | None = None, + duration: decimal.Decimal = decimal.Decimal("0.5"), + style: typing.Any = "linear", + ): + if len(spec) >= 1: + sc = spec[0] + dur = spec[1] if len(spec) >= 2 else duration + elif target_scale is not None: + sc = target_scale + dur = duration + else: + raise PPInvalidOperationError( + "立绘缩放需要:一个按位参数(目标比例,可选第二项时长),或使用关键字「比例」指定比例(时长、方式仍可用关键字)。", + ) + return (VNCharacterSpriteMoveKind.SCALE, dur, sc, z, z, z, vn_motion_style_normalize_to_enum(style)) + + def handle_rotate( + spec: list[decimal.Decimal], + *, + angle: decimal.Decimal | None = None, + duration: decimal.Decimal = decimal.Decimal("0.5"), + style: typing.Any = "linear", + ): + if len(spec) >= 1: + ang = spec[0] + dur = spec[1] if len(spec) >= 2 else duration + elif angle is not None: + ang = angle + dur = duration + else: + raise PPInvalidOperationError( + "立绘旋转需要:一个按位参数(角度,单位度;可选第二项时长),或使用关键字「角度」或 angle(时长、方式仍可用关键字)。", + ) + return (VNCharacterSpriteMoveKind.ROTATE, dur, ang, z, z, z, vn_motion_style_normalize_to_enum(style)) + + callexpr = CallExprOperand(call.name, call.args, collections.OrderedDict(call.kwargs)) + out = FrontendParserBase.resolve_call( + callexpr, + [ + (_tr_sprite_move, handle_move, { + "spec": _tr_sprite_move_spec, + "ratio_x": _tr_ratio_x, + "ratio_y": _tr_ratio_y, + "duration": _tr_duration, + "style": _tr_motion_style, + }), + (_tr_sprite_scale, handle_scale, { + "spec": _tr_sprite_scale_spec, + "target_scale": _tr_sprite_scale_ratio, + "duration": _tr_duration, + "style": _tr_motion_style, + }), + (_tr_sprite_rotate, handle_rotate, { + "spec": _tr_sprite_rotate_spec, + "angle": _tr_rotate_angle, + "duration": _tr_duration, + "style": _tr_motion_style, + }), + ], + warnings, + strict=True, + ) + if out is None: + msgs = [m for _, m in warnings] + raise PPInvalidOperationError( + "立绘补间「%s」无法用当前参数解析:%s" % (call.name, ";".join(msgs) if msgs else "未知补间名或参数不匹配。"), + ) + return out + _tr_vnutil_placer_absolute = _TR_vn_util.tr("placer_absolute", en="AbsolutePosition", @@ -480,9 +1319,12 @@ def handle_backend_specific_transition(expr : str, *, backend : str) -> tuple[St ) def resolve_placer_callexpr(context : Context, placer : CallExprOperand, defaultconf : VNASTImagePlacerParameterSymbol | None, warnings : list[tuple[str, str]], is_fillall : bool, callback : typing.Callable[[VNASTImagePlacerKind, list[Literal]], typing.Any]) -> typing.Any | None: - def handle_placer_absolute(anchor : FrontendParserBase.Coordinate2D | None = None, - scale : decimal.Decimal | None = None, - anchorcoord : FrontendParserBase.Coordinate2D | None = None) -> typing.Any: + def handle_placer_absolute( + *, + anchor : FrontendParserBase.Coordinate2D | None = None, + scale : decimal.Decimal | None = None, + anchorcoord : FrontendParserBase.Coordinate2D | None = None, + ) -> typing.Any: if anchor is not None: result_anchor = anchor.to_tuple() elif defaultconf is not None: @@ -506,10 +1348,13 @@ def handle_placer_absolute(anchor : FrontendParserBase.Coordinate2D | None = Non params.append(IntTuple2DLiteral.get(result_anchorcoord, context)) return callback(VNASTImagePlacerKind.ABSOLUTE, params) - def handle_placer_sprite(baseheight : decimal.Decimal | None = None, - topheight : decimal.Decimal | None = None, - xoffset : decimal.Decimal | None = None, - xpos : decimal.Decimal | None = None) -> typing.Any: + def handle_placer_sprite( + *, + baseheight : decimal.Decimal | None = None, + topheight : decimal.Decimal | None = None, + xoffset : decimal.Decimal | None = None, + xpos : decimal.Decimal | None = None, + ) -> typing.Any: if baseheight is not None: result_baseheight = baseheight elif defaultconf is not None: @@ -544,7 +1389,7 @@ def handle_placer_sprite(baseheight : decimal.Decimal | None = None, return FrontendParserBase.resolve_call(placer, [ (_tr_vnutil_placer_absolute, handle_placer_absolute, {'anchor': _tr_vnutil_placer_absolute_anchor, 'scale': _tr_vnutil_placer_absolute_scale, 'anchorcoord': _tr_vnutil_placer_absolute_anchorcoord}), (_tr_vnutil_placer_sprite, handle_placer_sprite, {'baseheight': _tr_vnutil_placer_sprite_baseheight, 'topheight': _tr_vnutil_placer_sprite_topheight, 'xoffset': _tr_vnutil_placer_sprite_xoffset, 'xpos': _tr_vnutil_placer_sprite_xpos}), - ], warnings) + ], warnings, strict=True) def resolve_placer_expr(context : Context, expr : ListExprTreeNode, defaultconf : VNASTImagePlacerParameterSymbol | None, presetplace : SymbolTableRegion[VNASTImagePresetPlaceSymbol], warnings : list[tuple[str, str]]) -> tuple[VNASTImagePlacerKind, list[Literal]] | None: # 尝试根据当前的配置解析一个完整的位置表达式 diff --git a/src/preppipe/irbase.py b/src/preppipe/irbase.py index e55790b..70bfe6e 100644 --- a/src/preppipe/irbase.py +++ b/src/preppipe/irbase.py @@ -2309,7 +2309,7 @@ def get_value_tuple(self) -> tuple[Value]: def _get_impl(cexpr_cls, ty : ValueType, values : typing.Iterable[Value]): key_tuple = (cexpr_cls, *values) return ty.context.get_constexpr_uniquing_dict(cexpr_cls).get_or_create(key_tuple, - lambda: cexpr_cls(init_mode = IRObjectInitMode.CONSTRUCT, context = ty.context, values = values)) + lambda: cexpr_cls(init_mode = IRObjectInitMode.CONSTRUCT, context = ty.context, ty = ty, values = values)) class LiteralUniquingDict: _ty : type @@ -2710,6 +2710,7 @@ def export(self, dest_path : str) -> None: if data := self._data: data.save(dest_path) return + # 源与目标扩展名一致时直接拷贝字节流,避免无谓的解码重编码改变文件内容(除非目标格式要求转换)。 # we do file copy iff the source and dest format matches # otherwise, we open the source file and save it in the destination _srcname, srcext = os.path.splitext(self.backing_store_path) diff --git a/src/preppipe/renpy/codegen.py b/src/preppipe/renpy/codegen.py index bf7d71e..64f229a 100644 --- a/src/preppipe/renpy/codegen.py +++ b/src/preppipe/renpy/codegen.py @@ -3,6 +3,7 @@ import re import dataclasses +import decimal import typing from .ast import * @@ -11,6 +12,13 @@ from ..util.imagepackexportop import * from ..util import nameconvert from ..enginecommon.codegen import BackendCodeGenHelperBase +from ..exceptions import PPNotImplementedError +from ..frontend.vnmodel.vnutil import map_sprite_transition_entry_to_exit + +# Ren'Py 导出脚本中与 IR 一致的默认转场时长(decimal,避免经 float 丢精度) +_DEFAULT_TRANSITION_SEC = decimal.Decimal("0.5") +_DEFAULT_TRANSITION_HOLD = decimal.Decimal("0.2") +_TRANSITION_DURATION_EPS = decimal.Decimal("1e-6") @dataclasses.dataclass class _RenPyScriptFileWrapper: @@ -137,6 +145,8 @@ def __init__(self, model : VNModel) -> None: self.imspec_dict = collections.OrderedDict() self.audiospec_dict = collections.OrderedDict() self.numeric_image_index = 0 + # 场景 master 层滤镜:Ren'Py 的 scene 会清掉 show_layer_at,切换场景后在同一条脚本链上补一行以维持到「结束特效:场景」 + self._pending_scene_master_filter : tuple[str, decimal.Decimal, str] | None = None if not self.is_matchtree_installed(): _RenPyCodeGenHelper.init_matchtable() @@ -442,6 +452,13 @@ def init_matchtable(): VNRemoveInst : _RenPyCodeGenHelper.gen_remove, VNCallInst : _RenPyCodeGenHelper.gen_call, VNBackendInstructionGroup : _RenPyCodeGenHelper.gen_renpy_asm, + VNShakeEffectInst : _RenPyCodeGenHelper.gen_shake_effect, + VNFlashEffectInst : _RenPyCodeGenHelper.gen_flash_effect, + VNCharacterSpriteMoveInst : _RenPyCodeGenHelper.gen_character_sprite_move, + VNCharTrembleEffectInst : _RenPyCodeGenHelper.gen_char_tremble, + VNFilterEffectInst : _RenPyCodeGenHelper.gen_filter_effect, + VNWeatherEffectInst : _RenPyCodeGenHelper.gen_weather_effect, + VNEndEffectInst : _RenPyCodeGenHelper.gen_end_effect, }) _RenPyCodeGenHelper.install_asset_basedir_matchtree({ AudioAssetData : { @@ -461,6 +478,21 @@ def init_matchtable(): def get_result(self) -> RenPyModel: return self.result + @staticmethod + def _decimal_from_transition_duration_operand(v : Value | None, *, default : decimal.Decimal) -> decimal.Decimal: + """转场时长操作数须为数值字面值(FloatLiteral 底层为 Decimal);导出为脚本字面量时不经 float。""" + if v is None: + return default + if isinstance(v, FloatLiteral): + return v.value + if isinstance(v, IntLiteral): + return decimal.Decimal(int(v.value)) + raise PPInternalError( + "转场时长 IR 应为 FloatLiteral 或 IntLiteral,实际为 " + + type(v).__name__ + + ";请检查剧本转场是否在解析阶段通过 parse_transition 校验。", + ) + def collect_say_text(self, src : OpOperand) -> list[Value]: result = [] for u in src.operanduses(): @@ -663,9 +695,14 @@ def gen_sceneswitch(self, instrs : list[VNInstruction], insert_before : RenPyNod withnode = RenPyWithNode.create(self.context, expr=img_transition) result.with_.set_operand(0, withnode) result.body.push_back(withnode) - if top_insert_place is None: - top_insert_place = insert_before - result.insert_before(top_insert_place) + anchor = top_insert_place if top_insert_place is not None else insert_before + if self._pending_scene_master_filter is not None: + fk, s, col = self._pending_scene_master_filter + rline = f'$ preppipe_scene_filter("{fk}", {s}, 0, "{col}")' + reapply = RenPyASMNode.create(self.context, asm=StringLiteral.get(rline, self.context)) + reapply.insert_before(anchor) + result.insert_before(reapply) + result.insert_before(anchor) return result def _resolve_transition(self, transition : Value) -> tuple[str|None, tuple[decimal.Decimal, decimal.Decimal] | None]: @@ -696,6 +733,8 @@ def _resolve_transition(self, transition : Value) -> tuple[str|None, tuple[decim renpy_displayable_transition = transition.expression.get_string() else: return self._resolve_transition(transition.fallback) + elif isinstance(transition, VN_SCENE_TRANSITION_LIT_TYPES): + renpy_displayable_transition = self._scene_transition_lit_to_renpy(transition) elif isinstance(transition, VNAudioFadeTransitionExpr): renpy_audio_transition = (transition.fadein.value, transition.fadeout.value) else: @@ -703,6 +742,73 @@ def _resolve_transition(self, transition : Value) -> tuple[str|None, tuple[decim pass return (renpy_displayable_transition, renpy_audio_transition) + def _scene_transition_lit_to_renpy(self, t) -> str: + """将场景转场 LiteralExpr 映射为 Ren'Py with 表达式字符串。""" + if isinstance(t, VNFadeInSceneTransitionLit): + d = t.duration + sec = _RenPyCodeGenHelper._decimal_from_transition_duration_operand(d, default=_DEFAULT_TRANSITION_SEC) + # 须用脚本命名空间中的 Fade(见 renpy.common);勿写 renpy.display.transition.Fade,8.5+ 上该名可能已是 MultipleTransition 实例,不可再调用 + return f"Fade(0, 0, {sec})" # 新画面淡入 + if isinstance(t, VNFadeOutSceneTransitionLit): + d = t.duration + sec = _RenPyCodeGenHelper._decimal_from_transition_duration_operand(d, default=_DEFAULT_TRANSITION_SEC) + return f"Fade({sec}, 0, 0)" # 旧画面淡出 + if isinstance(t, VNDissolveSceneTransitionLit): + d = t.duration + sec = _RenPyCodeGenHelper._decimal_from_transition_duration_operand(d, default=_DEFAULT_TRANSITION_SEC) + return f"Dissolve({sec})" if sec != _DEFAULT_TRANSITION_SEC else "dissolve" + if isinstance(t, VNSlideInSceneTransitionLit): + d = t.duration + sec = _RenPyCodeGenHelper._decimal_from_transition_duration_operand(d, default=_DEFAULT_TRANSITION_SEC) + direction = t.direction.get_string().strip().lower() + if direction not in ("left", "right", "up", "down"): + raise PPInternalError(f"IR 滑移方向非规范键: {direction!r}") + mode = "slide" + direction # slideleft, slideright, slideup, slidedown + if sec != _DEFAULT_TRANSITION_SEC: + return f'CropMove({sec}, "{mode}")' + return mode + if isinstance(t, VNSlideOutSceneTransitionLit): + d = t.duration + sec = _RenPyCodeGenHelper._decimal_from_transition_duration_operand(d, default=_DEFAULT_TRANSITION_SEC) + direction = t.direction.get_string().strip().lower() + if direction not in ("left", "right", "up", "down"): + raise PPInternalError(f"IR 滑移方向非规范键: {direction!r}") + mode = "slideaway" + direction # slideawayleft, slideawayright, ... + if sec != _DEFAULT_TRANSITION_SEC: + return f'CropMove({sec}, "{mode}")' + return mode + if isinstance(t, VNPushSceneTransitionLit): + d = t.duration + sec = _RenPyCodeGenHelper._decimal_from_transition_duration_operand(d, default=_DEFAULT_TRANSITION_SEC) + direction = t.direction.get_string().strip().lower() + if direction not in ("left", "right", "up", "down"): + raise PPInternalError(f"IR 推移方向非规范键: {direction!r}") + mode = "push" + direction # pushright, pushleft, pushup, pushdown + if sec != _DEFAULT_TRANSITION_SEC: + return f'PushMove({sec}, "{mode}")' + return mode + if isinstance(t, VNFadeToColorSceneTransitionLit): + out_s = _RenPyCodeGenHelper._decimal_from_transition_duration_operand(t.fade_out, default=_DEFAULT_TRANSITION_SEC) + hold_s = _RenPyCodeGenHelper._decimal_from_transition_duration_operand(t.hold, default=_DEFAULT_TRANSITION_HOLD) + in_s = _RenPyCodeGenHelper._decimal_from_transition_duration_operand(t.fade_in, default=_DEFAULT_TRANSITION_SEC) + col = self._fade_to_color_hex_lit(t) + return f'Fade({out_s}, {hold_s}, {in_s}, color="{col}")' + if isinstance(t, VNZoomSceneTransitionLit): + direction = t.direction.get_string().strip().lower() + if direction not in ("in", "out"): + raise PPInternalError(f"IR 缩放方向非规范键: {direction!r}") + sec = _RenPyCodeGenHelper._decimal_from_transition_duration_operand(t.duration, default=_DEFAULT_TRANSITION_SEC) + pt = t.point.get_string() + if not pt.strip(): + raise PPInternalError("IR 缺少 Zoom 缩放点(应由 parse_transition 填充)") + pt_key = pt.strip().replace(" ", "_").replace("-", "_").lower() + if pt_key not in self._ZOOM_POINT_XY: + raise PPInternalError(f"IR 缩放点非规范键: {pt_key!r}") + pt_escaped = pt.replace("\\", "\\\\").replace("'", "\\'") + name = "preppipe_zoomout" if direction == "out" else "preppipe_zoomin" + return f"{name}({sec}, '{pt_escaped}')" + raise NotImplementedError(f"Unhandled scene transition literal: {type(t).__name__}") + def resolve_displayable_transition(self, transition : Value) -> str | None: renpy_displayable_transition, renpy_audio_transition = self._resolve_transition(transition) return renpy_displayable_transition @@ -717,6 +823,223 @@ def _add_audio_transition(self, transition : Value, play : RenPyPlayNode): play.fadein.set_operand(0, FloatLiteral.get(fadein, self.context)) play.fadeout.set_operand(0, FloatLiteral.get(fadeout, self.context)) + # 与 vnutil.CANONICAL_ZOOM_POINTS 一致;别名须在 parse_transition 时已规范化为下列键 + _ZOOM_POINT_XY: typing.ClassVar[dict[str, tuple[float, float]]] = { + "top_left": (0.0, 0.0), "top_center": (0.5, 0.0), "top_right": (1.0, 0.0), + "center_left": (0.0, 0.5), "center": (0.5, 0.5), "center_right": (1.0, 0.5), + "bottom_left": (0.0, 1.0), "bottom_center": (0.5, 1.0), "bottom_right": (1.0, 1.0), + } + + _SLIDE_DIST: typing.ClassVar[float] = 2500.0 + + def _fade_to_color_hex_lit(self, t: VNFadeToColorSceneTransitionLit) -> str: + c = t.color.value + return f"#{c.r:02x}{c.g:02x}{c.b:02x}" + + def _vn_duration_sec(self, d) -> decimal.Decimal: + sec = _RenPyCodeGenHelper._decimal_from_transition_duration_operand(d, default=_DEFAULT_TRANSITION_SEC) + # 时长为 0 时 Ren'Py 中线性 ATL 与 pause 均为零,退场会「一闪而过」 + return sec if sec > _TRANSITION_DURATION_EPS else _DEFAULT_TRANSITION_SEC + + def _chain_show_at(self, showat : RenPyASMExpr | None, at_suffix : str) -> RenPyASMExpr: + if showat is not None: + return RenPyASMExpr.create(self.context, showat.get_string() + ", " + at_suffix) + return RenPyASMExpr.create(self.context, StringLiteral.get(at_suffix, self.context)) + + def _direction_slide_offset(self, direction : str) -> tuple[float, float]: + d = (direction or "").strip().lower() + s = self._SLIDE_DIST + if d == "left": + return (-s, 0.0) + if d == "right": + return (s, 0.0) + if d == "up": + return (0.0, -s) + if d == "down": + return (0.0, s) + raise PPInternalError(f"IR 滑移方向非规范键: {direction!r}(应由 parse_transition 规范化)") + + def _sprite_zoom_align(self, t : VNZoomSceneTransitionLit) -> tuple[decimal.Decimal, float, float]: + d = t.duration + sec = self._vn_duration_sec(d) + pt = t.point.get_string() or "center" + key = pt.strip().replace(" ", "_").replace("-", "_").lower() + if key not in self._ZOOM_POINT_XY: + raise PPInternalError(f"IR 缩放点非规范键: {key!r}(应由 parse_transition 规范化)") + xa, ya = self._ZOOM_POINT_XY[key] + return sec, xa, ya + + def _generic_to_sprite_show_at(self, t) -> str | None: + """仅入场语义:show 上不处理 FadeOut / SlideOut / ZoomOut。""" + if isinstance(t, VNZoomSceneTransitionLit): + if t.direction.get_string().lower() == "out": + return None + s, xa, ya = self._sprite_zoom_align(t) + return f"preppipe_sprite_zoom_in({s}, {xa}, {ya})" + if isinstance(t, VNFadeInSceneTransitionLit): + sec = self._vn_duration_sec(t.duration) + return f"preppipe_sprite_fade_in({sec})" + if isinstance(t, VNDissolveSceneTransitionLit): + sec = self._vn_duration_sec(t.duration) + return f"preppipe_sprite_dissolve_in({sec})" + if isinstance(t, VNSlideInSceneTransitionLit): + dr = t.direction.get_string().strip().lower() + sec = self._vn_duration_sec(t.duration) + ox, oy = self._direction_slide_offset(dr) + return f"preppipe_sprite_slide_in({sec}, {ox}, {oy})" + if isinstance(t, VNPushSceneTransitionLit): + dr = t.direction.get_string().strip().lower() + sec = self._vn_duration_sec(t.duration) + ox, oy = self._direction_slide_offset(dr) + return f"preppipe_sprite_slide_in({sec}, {ox}, {oy})" + if isinstance(t, VNFadeToColorSceneTransitionLit): + total = self._vn_duration_sec(t.fade_out) + self._vn_duration_sec(t.hold) + self._vn_duration_sec(t.fade_in) + return f"preppipe_sprite_fade_in({total})" + return None + + def _generic_is_exit_only_on_show(self, t) -> bool: + if isinstance(t, VNFadeOutSceneTransitionLit): + return True + if isinstance(t, VNSlideOutSceneTransitionLit): + return True + if isinstance(t, VNZoomSceneTransitionLit): + return t.direction.get_string().lower() == "out" + return False + + def _transition_has_visual_effect(self, transition : Value | None) -> bool: + """转场值是否存在可生成的画面效果(「无转场」枚举在 Python 中仍为真值,须单独排除)。""" + if transition is None: + return False + dt = VNDefaultTransitionType.get_default_transition_type(transition) + return dt != VNDefaultTransitionType.DT_NO_TRANSITION + + def _require_foreground_show_at(self, transition : Value) -> str | None: + """立绘入场转场:不支持的类型或参数直接报错。""" + if isinstance(transition, VN_SCENE_TRANSITION_LIT_TYPES): + if self._generic_is_exit_only_on_show(transition): + raise PPNotImplementedError( + "立绘入场不支持退场类转场(淡出、滑出、缩小离场等);请使用淡入、滑入等入场类转场," + "退场类请写在角色退场上。", + ) + at_s = self._generic_to_sprite_show_at(transition) + if at_s is None: + raise PPNotImplementedError( + "立绘入场不支持该转场种类,请改用淡入、滑入、溶解、黑白酒等入场效果。", + ) + return at_s + dt = VNDefaultTransitionType.get_default_transition_type(transition) + if dt == VNDefaultTransitionType.DT_NO_TRANSITION: + return None + if dt in (VNDefaultTransitionType.DT_SPRITE_SHOW, VNDefaultTransitionType.DT_IMAGE_SHOW): + return f"preppipe_sprite_dissolve_in({_DEFAULT_TRANSITION_SEC})" + if isinstance(transition, VNBackendDisplayableTransitionExpr): + if transition.backend.get_string().lower() != "renpy": + raise PPNotImplementedError( + "立绘入场不支持该显示层转场写法,请使用剧本中的通用入场转场(淡入、滑入、溶解等)。", + ) + e = transition.expression.get_string().strip().lower() + if e == "dissolve" or e.startswith("dissolve(") or e.startswith("dissolve "): + return f"preppipe_sprite_dissolve_in({_DEFAULT_TRANSITION_SEC})" + raise PPNotImplementedError( + "立绘入场仅支持「溶解」形式的显示层转场,请改用溶解或通用入场转场。", + ) + if isinstance(transition, VNAudioFadeTransitionExpr): + raise PPNotImplementedError("立绘显示不支持音频淡入淡出转场。") + if dt is not None: + raise PPNotImplementedError( + "立绘入场不支持当前默认转场设置,请使用立绘显示默认效果或通用入场转场(淡入、滑入等)。", + ) + raise PPNotImplementedError("立绘入场不支持该转场值,请核对转场类型或改用通用入场转场。") + + def _push_direction_to_sprite_exit_offset(self, direction : str) -> tuple[float, float]: + """推移退场:与场景「推移」时旧画面被推开方向一致,映射为单张立绘的滑出 offset。""" + d = (direction or "").strip().lower() + if d == "left": + return self._direction_slide_offset("right") + if d == "right": + return self._direction_slide_offset("left") + if d == "up": + return self._direction_slide_offset("down") + if d == "down": + return self._direction_slide_offset("up") + raise PPInternalError(f"IR 推移方向非规范键: {direction!r}") + + def _foreground_hide_sprite_at_pause( + self, + imspec : tuple, + insert_before : RenPyNode, + at_expr : str, + pause_sec : decimal.Decimal, + base_showat : RenPyASMExpr | None = None, + ) -> RenPyShowNode: + """立绘退场:只动该层,不用 hide … with 整屏转场。 + + 返回 **show**(时间上最先执行的一节):codegen_block 从块尾向前插桩, + 若误返回 hide,上一条指令会插在 pause 与 hide 之间,打断 ATL,表现为退场无动画。 + + base_showat:与入场一致的 screen2d_abs,须与退场 ATL 链式拼接,否则 offset/缩放锚点会丢。 + """ + if base_showat is not None: + showat = self._chain_show_at(base_showat, at_expr) + else: + showat = RenPyASMExpr.create(self.context, StringLiteral.get(at_expr, self.context)) + show = RenPyShowNode.create( + context=self.context, + imspec=imspec, + showat=showat, + ) + hide = RenPyHideNode.create(context=self.context, imspec=imspec) + pause_asm = StringLiteral.get(f"pause {pause_sec}", self.context) + pause = RenPyASMNode.create(self.context, asm=pause_asm) + hide.insert_before(insert_before) + pause.insert_before(hide) + show.insert_before(pause) + return show + + def _foreground_hide_fade_sequence( + self, imspec : tuple, duration : decimal.Decimal, insert_before : RenPyNode, base_showat : RenPyASMExpr | None = None + ) -> RenPyShowNode: + return self._foreground_hide_sprite_at_pause( + imspec, insert_before, f"preppipe_sprite_fade_out({duration})", duration, base_showat=base_showat + ) + + def _emit_foreground_hide( + self, imspec : tuple, transition : Value, insert_before : RenPyNode, instr : VNRemoveInst + ) -> RenPyShowNode | None: + base_showat = None + if position := instr.placeat.get(VNPositionSymbol.NAME_SCREEN2D): + base_showat = self._get_screen2d_position(position) + if isinstance(transition, VN_SCENE_TRANSITION_LIT_TYPES): + if isinstance(transition, VNZoomSceneTransitionLit): + s, _xa, _ya = self._sprite_zoom_align(transition) + at_expr = f"preppipe_sprite_zoom_out({s})" + return self._foreground_hide_sprite_at_pause(imspec, insert_before, at_expr, s, base_showat=base_showat) + if isinstance(transition, VNSlideOutSceneTransitionLit): + sec = self._vn_duration_sec(transition.duration) + dr = transition.direction.get_string().strip().lower() + ox, oy = self._direction_slide_offset(dr) + at_expr = f"preppipe_sprite_slide_out({sec}, {ox}, {oy})" + return self._foreground_hide_sprite_at_pause(imspec, insert_before, at_expr, sec, base_showat=base_showat) + if isinstance(transition, VNPushSceneTransitionLit): + sec = self._vn_duration_sec(transition.duration) + dr = transition.direction.get_string().strip().lower() + ox, oy = self._push_direction_to_sprite_exit_offset(dr) + at_expr = f"preppipe_sprite_slide_out({sec}, {ox}, {oy})" + return self._foreground_hide_sprite_at_pause(imspec, insert_before, at_expr, sec, base_showat=base_showat) + if isinstance(transition, (VNFadeOutSceneTransitionLit, VNDissolveSceneTransitionLit, VNFadeInSceneTransitionLit)): + # 淡入误绑在退场时按同时长淡出(与 map_sprite_transition_entry_to_exit 一致) + sec = self._vn_duration_sec(transition.duration) + return self._foreground_hide_fade_sequence(imspec, sec, insert_before, base_showat=base_showat) + dt = VNDefaultTransitionType.get_default_transition_type(transition) + if dt in (VNDefaultTransitionType.DT_SPRITE_HIDE, VNDefaultTransitionType.DT_IMAGE_HIDE): + return self._foreground_hide_fade_sequence(imspec, _DEFAULT_TRANSITION_SEC, insert_before, base_showat=base_showat) + if isinstance(transition, VNBackendDisplayableTransitionExpr): + if transition.backend.get_string().lower() == "renpy": + e = transition.expression.get_string().strip().lower() + if e == "dissolve" or e.startswith("dissolve"): + return self._foreground_hide_fade_sequence(imspec, _DEFAULT_TRANSITION_SEC, insert_before, base_showat=base_showat) + return None + def _add_image_transition(self, transition : Value, node : RenPyShowNode | RenPyHideNode): if img_transition := self.resolve_displayable_transition(transition): withnode = RenPyWithNode.create(self.context, img_transition) @@ -755,9 +1078,11 @@ def gen_create_put(self, instrs : list[VNInstruction], insert_before : RenPyNode showat = None if position := instr.placeat.get(VNPositionSymbol.NAME_SCREEN2D): showat = self._get_screen2d_position(position) + transition = instr.transition.try_get_value() + at_sp = self._require_foreground_show_at(transition) if transition else None + if at_sp: + showat = self._chain_show_at(showat, at_sp) show = RenPyShowNode.create(context=self.context, imspec=imspec, showat=showat) - if transition := instr.transition.try_get_value(): - self._add_image_transition(transition, show) show.insert_before(insert_before) return show case VNStandardDeviceKind.O_SE_AUDIO: @@ -799,9 +1124,17 @@ def gen_modify(self, instrs : list[VNInstruction], insert_before : RenPyNode) -> showat = None if position := instr.placeat.get(VNPositionSymbol.NAME_SCREEN2D): showat = self._get_screen2d_position(position) + transition = instr.transition.try_get_value() + at_sp = ( + self._require_foreground_show_at(transition) + if transition and devkind == VNStandardDeviceKind.O_FOREGROUND_DISPLAY + else None + ) + if at_sp: + showat = self._chain_show_at(showat, at_sp) show = RenPyShowNode.create(context=self.context, imspec=new_imspec, showat=showat) show.insert_before(insert_before) - if transition := instr.transition.try_get_value(): + if self._transition_has_visual_effect(transition) and devkind == VNStandardDeviceKind.O_BACKGROUND_DISPLAY: self._add_image_transition(transition, show) new_insert_point = show if remove_imspec[0] != new_imspec[0]: @@ -824,9 +1157,19 @@ def gen_remove(self, instrs : list[VNInstruction], insert_before : RenPyNode) -> match kind: case VNStandardDeviceKind.O_BACKGROUND_DISPLAY | VNStandardDeviceKind.O_FOREGROUND_DISPLAY: imspec = self.get_impsec(removevalue, kind) + transition = instr.transition.try_get_value() + if kind == VNStandardDeviceKind.O_FOREGROUND_DISPLAY and transition is not None: + transition = map_sprite_transition_entry_to_exit(self.context, transition) + if self._transition_has_visual_effect(transition) and kind == VNStandardDeviceKind.O_FOREGROUND_DISPLAY: + if hid := self._emit_foreground_hide(imspec, transition, insert_before, instr): + return hid + raise PPNotImplementedError( + "立绘退场不支持该转场。退场请使用:淡出、溶解、滑出、推移、缩放(含缩小离场);" + "入场类(淡入、滑入、黑白酒等)请写在角色入场,不要写在退场。", + ) hide = RenPyHideNode.create(context=self.context, imspec=imspec) hide.insert_before(insert_before) - if transition := instr.transition.try_get_value(): + if self._transition_has_visual_effect(transition): self._add_image_transition(transition, hide) return hide case VNStandardDeviceKind.O_BGM_AUDIO: @@ -849,6 +1192,144 @@ def gen_call(self, instrs : list[VNInstruction], insert_before : RenPyNode) -> R result.insert_before(insert_before) return result + def gen_shake_effect(self, instrs : list[VNInstruction], insert_before : RenPyNode) -> RenPyNode: + inst = instrs[0] + assert isinstance(inst, VNShakeEffectInst) + d = inst.duration.get().value + amp = inst.amplitude.get().value + dec = inst.decay.get().value + dr = inst.direction.get().value.value + if inst.scene_wide.get().value: + line = f"$ preppipe_scene_shake({d}, {amp}, {dec}, {repr(dr)})" + else: + spec = self.get_impsec(inst.sprite.get(), user_hint=VNStandardDeviceKind.O_FOREGROUND_DISPLAY) + parts = ", ".join(repr(x.get_string()) for x in spec) + x = int(inst.place_x.get().value) + y = int(inst.place_y.get().value) + w = int(inst.place_w.get().value) + h = int(inst.place_h.get().value) + line = f"$ preppipe_char_shake(({parts}), {x}, {y}, {w}, {h}, {d}, {amp}, {dec}, {repr(dr)})" + node = RenPyASMNode.create(self.context, StringLiteral.get(line, self.context)) + node.insert_before(insert_before) + return node + + def gen_flash_effect(self, instrs : list[VNInstruction], insert_before : RenPyNode) -> RenPyNode: + inst = instrs[0] + assert isinstance(inst, VNFlashEffectInst) + fc = inst.color.get().get_string().replace("\\", "\\\\").replace('"', '\\"') + fi = inst.fade_in.get().value + fh = inst.hold.get().value + fo = inst.fade_out.get().value + if inst.scene_wide.get().value: + line = f'$ preppipe_scene_flash("{fc}", {fi}, {fh}, {fo})' + else: + spec = self.get_impsec(inst.sprite.get(), user_hint=VNStandardDeviceKind.O_FOREGROUND_DISPLAY) + parts = ", ".join(repr(x.get_string()) for x in spec) + x = int(inst.place_x.get().value) + y = int(inst.place_y.get().value) + w = int(inst.place_w.get().value) + h = int(inst.place_h.get().value) + line = f'$ preppipe_char_flash(({parts}), {x}, {y}, {w}, {h}, "{fc}", {fi}, {fh}, {fo})' + node = RenPyASMNode.create(self.context, StringLiteral.get(line, self.context)) + node.insert_before(insert_before) + return node + + def gen_character_sprite_move(self, instrs : list[VNInstruction], insert_before : RenPyNode) -> RenPyNode: + inst = instrs[0] + assert isinstance(inst, VNCharacterSpriteMoveInst) + spec = self.get_impsec(inst.sprite.get(), user_hint=VNStandardDeviceKind.O_FOREGROUND_DISPLAY) + parts = ", ".join(repr(x.get_string()) for x in spec) + x = int(inst.place_x.get().value) + y = int(inst.place_y.get().value) + w = int(inst.place_w.get().value) + h = int(inst.place_h.get().value) + mk = inst.move_kind.get().value.value.replace("\\", "\\\\").replace('"', '\\"') + es = inst.style.get().value.value.replace("\\", "\\\\").replace('"', '\\"') + d = inst.duration.get().value + f1 = inst.n1.get().value + f2 = inst.n2.get().value + f3 = inst.n3.get().value + f4 = inst.n4.get().value + line = ( + f'$ preppipe_char_sprite_anim(({parts}), {x}, {y}, {w}, {h}, "{mk}", {d}, {f1}, {f2}, {f3}, {f4}, "{es}")' + ) + node = RenPyASMNode.create(self.context, StringLiteral.get(line, self.context)) + node.insert_before(insert_before) + return node + + def gen_char_tremble(self, instrs : list[VNInstruction], insert_before : RenPyNode) -> RenPyNode: + inst = instrs[0] + assert isinstance(inst, VNCharTrembleEffectInst) + spec = self.get_impsec(inst.sprite.get(), user_hint=VNStandardDeviceKind.O_FOREGROUND_DISPLAY) + parts = ", ".join(repr(x.get_string()) for x in spec) + x = int(inst.place_x.get().value) + y = int(inst.place_y.get().value) + w = int(inst.place_w.get().value) + h = int(inst.place_h.get().value) + amp = inst.amplitude.get().value + per = inst.period.get().value + dur = inst.duration.get().value + line = f"$ preppipe_char_tremble(({parts}), {x}, {y}, {w}, {h}, {amp}, {per}, {dur})" + node = RenPyASMNode.create(self.context, StringLiteral.get(line, self.context)) + node.insert_before(insert_before) + return node + + def gen_filter_effect(self, instrs : list[VNInstruction], insert_before : RenPyNode) -> RenPyNode: + inst = instrs[0] + assert isinstance(inst, VNFilterEffectInst) + fk = inst.filter_kind.get().value.value.replace("\\", "\\\\").replace('"', '\\"') + s = inst.strength.get().value + d = inst.duration.get().value + col = inst.color.get().get_string().replace("\\", "\\\\").replace('"', '\\"') + if inst.scene_wide.get().value: + line = f'$ preppipe_scene_filter("{fk}", {s}, {d}, "{col}")' + self._pending_scene_master_filter = (fk, s, col) + else: + spec = self.get_impsec(inst.sprite.get(), user_hint=VNStandardDeviceKind.O_FOREGROUND_DISPLAY) + parts = ", ".join(repr(x.get_string()) for x in spec) + x = int(inst.place_x.get().value) + y = int(inst.place_y.get().value) + w = int(inst.place_w.get().value) + h = int(inst.place_h.get().value) + line = f'$ preppipe_char_filter(({parts}), {x}, {y}, {w}, {h}, "{fk}", {s}, {d}, "{col}")' + node = RenPyASMNode.create(self.context, StringLiteral.get(line, self.context)) + node.insert_before(insert_before) + return node + + def gen_weather_effect(self, instrs : list[VNInstruction], insert_before : RenPyNode) -> RenPyNode: + inst = instrs[0] + assert isinstance(inst, VNWeatherEffectInst) + k = inst.weather_kind.get().value.value.replace("\\", "\\\\").replace('"', '\\"') + intensity = inst.intensity.get().value + ifi = inst.inner_fade_in.get().value + ifo = inst.inner_fade_out.get().value + ov = inst.overlay_fade_in.get().value + sus = inst.sustain.get().value + vx = inst.vx.get().value + vy = inst.vy.get().value + line = f'$ preppipe_weather_start("{k}", {intensity}, {ifi}, {ifo}, {ov}, {vx}, {vy}, {sus})' + node = RenPyASMNode.create(self.context, StringLiteral.get(line, self.context)) + node.insert_before(insert_before) + return node + + def gen_end_effect(self, instrs : list[VNInstruction], insert_before : RenPyNode) -> RenPyNode: + inst = instrs[0] + assert isinstance(inst, VNEndEffectInst) + if inst.scene_wide.get().value: + self._pending_scene_master_filter = None + line = "$ preppipe_end_scene_effect()" + else: + spec = self.get_impsec(inst.sprite.get(), user_hint=VNStandardDeviceKind.O_FOREGROUND_DISPLAY) + parts = ", ".join(repr(x.get_string()) for x in spec) + x = int(inst.place_x.get().value) + y = int(inst.place_y.get().value) + w = int(inst.place_w.get().value) + h = int(inst.place_h.get().value) + line = f"$ preppipe_end_char_effect(({parts}), {x}, {y}, {w}, {h})" + node = RenPyASMNode.create(self.context, StringLiteral.get(line, self.context)) + node.insert_before(insert_before) + return node + def gen_renpy_asm(self, instrs : list[VNInstruction], insert_before : RenPyNode) -> RenPyNode: group = instrs[0] assert isinstance(group, VNBackendInstructionGroup) diff --git a/src/preppipe/renpy/preppipert.rpy b/src/preppipe/renpy/preppipert.rpy index 3548e4a..de0c8af 100644 --- a/src/preppipe/renpy/preppipert.rpy +++ b/src/preppipe/renpy/preppipert.rpy @@ -1,4 +1,646 @@ # This is the runtime library for Ren'Py engine. +# 立绘入场/离场 ATL:只作用当前 show 的那张图(避免 show/hide with 整屏转场) + +transform preppipe_sprite_fade_in(duration=0.5): + alpha 0.0 + linear duration alpha 1.0 + +transform preppipe_sprite_dissolve_in(duration=0.5): + alpha 0.0 + linear duration alpha 1.0 + +transform preppipe_sprite_slide_in(duration=0.5, ox=0.0, oy=0.0): + offset (ox, oy) + linear duration offset (0, 0) + +transform preppipe_sprite_slide_out(duration=0.5, ox=0.0, oy=0.0): + offset (0, 0) + linear duration offset (ox, oy) + +# 缩放枢轴用 xanchor/yanchor(相对立绘自身 0~1);勿用 xalign/yalign(会按整屏对齐) +transform preppipe_sprite_zoom_in(duration=0.5, xa=0.5, ya=0.5): + subpixel True + xanchor xa + yanchor ya + zoom 0.0 + linear duration zoom 1.0 + +# 退场只改 zoom,不改 anchor/pos,避免重置位置;枢轴保持当前 Transform 状态 +transform preppipe_sprite_zoom_out(duration=0.5): + subpixel True + linear duration zoom 0.01 + +transform preppipe_sprite_fade_out(duration=0.5): + alpha 1.0 + linear duration alpha 0.0 + +# 角色立绘震动:Ren'Py 8.5.2 下 ATL repeat + xoffset/yoffset 在 pause 渲染时易触发 atl.py「infinite loop」; +# 实现放在 init python 的 _preppipe_char_shake_transform(Transform function),波形与原 4×0.05s/周期一致。 + +transform preppipe_at_char_flash(fi, hld, fo, col): + matrixcolor IdentityMatrix() + linear fi matrixcolor TintMatrix(col) * BrightnessMatrix(0.35) + pause hld + linear fo matrixcolor IdentityMatrix() + +# 跳动:与角色震动相同,Ren'Py 8.5.2 下 ATL repeat+yoffset 易 infinite loop,见 _preppipe_bounce_transform。 + +transform preppipe_move_lin(sx, sy, ex, ey, d): + subpixel True + pos (int(sx), int(sy)) + linear d pos (int(ex), int(ey)) + +transform preppipe_move_ease(sx, sy, ex, ey, d): + subpixel True + pos (int(sx), int(sy)) + ease d pos (int(ex), int(ey)) + +transform preppipe_move_easein(sx, sy, ex, ey, d): + subpixel True + pos (int(sx), int(sy)) + easein d pos (int(ex), int(ey)) + +transform preppipe_move_easeout(sx, sy, ex, ey, d): + subpixel True + pos (int(sx), int(sy)) + easeout d pos (int(ex), int(ey)) + +transform preppipe_at_scale_to(z1, d, xa, ya): + subpixel True + zoom 1.0 + xalign xa + yalign ya + linear d zoom z1 + +transform preppipe_rotate_to(a, d, xa, ya): + subpixel True + rotate 0.0 + xalign xa + yalign ya + linear d rotate a + +transform preppipe_at_char_tremble_loop(amp, period): + subpixel True + block: + linear (period / 2.0) xoffset amp + linear (period / 2.0) xoffset (-amp) + repeat + +# halfp 为半周期秒数(Python 已 max(0.001, period/2));n 为循环次数 +transform preppipe_at_char_tremble_cycles(amp, halfp, n): + subpixel True + block: + repeat n + linear halfp xoffset amp + linear halfp xoffset (-amp) + # 从末帧 -amp 回到 0,须带时长;勿写零耗时 xoffset 0 + linear 0.02 xoffset 0 + +# 滤镜(场景/立绘):命令「时长」为过渡到目标状态的秒数;SaturationMatrix 参数 0=全灰、1=原饱和度 +transform preppipe_at_fx_grayscale(s, dur): + matrixcolor IdentityMatrix() + linear max(0.001, dur) matrixcolor SaturationMatrix(1.0 - s) + +transform preppipe_at_fx_grayscale_snap(s): + matrixcolor SaturationMatrix(1.0 - s) + +transform preppipe_at_fx_opacity(target_a, dur): + alpha 1.0 + linear max(0.001, dur) alpha target_a + +transform preppipe_at_fx_opacity_snap(a): + alpha a + +transform preppipe_at_fx_tint(col, s, dur): + matrixcolor IdentityMatrix() + linear max(0.001, dur) matrixcolor (TintMatrix(col) * SaturationMatrix(max(0.05, 1.0 - 0.4 * s))) + +transform preppipe_at_fx_tint_snap(col, s): + matrixcolor (TintMatrix(col) * SaturationMatrix(max(0.05, 1.0 - 0.4 * s))) + +transform preppipe_at_fx_blur(b, dur): + blur 0.0 + linear max(0.001, dur) blur b + +transform preppipe_at_fx_blur_snap(b): + blur b + +# 结束场景层滤镜:show_layer_at 后须用显式复位覆盖,单靠 hide_layer_at 在部分版本下仍会残留灰化/模糊 +transform preppipe_master_layer_reset: + matrixcolor IdentityMatrix() + alpha 1.0 + blur 0.0 + +transform preppipe_weather_overlay_fade(overlay_fi): + alpha 0.0 + linear overlay_fi alpha 1.0 + +screen preppipe_weather_screen(): + modal False + zorder 100 + fixed: + xfill True + yfill True + add renpy.store._preppipe_weather_displayable at preppipe_weather_overlay_fade(renpy.store._preppipe_weather_overlay_fi) + +screen preppipe_flash_screen(color, fi, h, fo): + modal True + zorder 9998 + add Solid(color): + at transform: + alpha 0.0 + linear fi alpha 1.0 + pause h + linear fo alpha 0.0 + timer (fi + h + fo) action Return() + +# 全屏缩放转场(scene / 整层) +init offset = -2 + +init python: + import re + import unicodedata + + from renpy.display.motion import Transform as _PreppipeShakeMotionTransform + + _preppipe_float_num_re = re.compile(r"[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?") + _preppipe_shake_seg_s = 0.05 + _preppipe_shake_cycle_s = 0.2 + + def _preppipe_coerce_float(x): + """任意来源(表格引号、Revertable、Ren'Py 包装类型)统一为 float;永不抛错,避免引擎报「无法解析为浮点数」。""" + try: + if isinstance(x, bool): + return float(x) + if isinstance(x, (int, float)): + r = float(x) + if r != r: # NaN + return 0.0 + return r + if x is None: + return 0.0 + if isinstance(x, str): + raw = x + else: + try: + r = float(x) + if r != r: + return 0.0 + return r + except Exception: + try: + raw = str(x) + except Exception: + return 0.0 + s = unicodedata.normalize("NFKC", raw).strip().replace("\ufeff", "") + for _ in range(8): + if len(s) < 2: + break + if s[0] == s[-1] and s[0] in "'\"": + s = s[1:-1].strip() + continue + if s[0] in "\u201c\u2018" and s[-1] in "\u201d\u2019": + s = s[1:-1].strip() + continue + break + if s == "" or s in ("-", "+"): + return 0.0 + try: + r = float(s) + if r != r: + return 0.0 + return r + except Exception: + m = _preppipe_float_num_re.search(s) + if m: + try: + r = float(m.group(0)) + if r != r: + return 0.0 + return r + except Exception: + pass + return 0.0 + except Exception: + return 0.0 + + def _preppipe_shake_offset_in_cycle(t_local, amp): + """单周期 [0,0.2) 内与旧 ATL 一致:0→a→-a→0.45a→0,每段 0.05s。""" + if abs(amp) < 1e-12: + return 0.0 + c = _preppipe_shake_cycle_s + s = _preppipe_shake_seg_s + t_local = max(0.0, min(float(t_local), c - 1e-12)) + si = min(3, int(t_local // s)) + frac = (t_local - si * s) / s + a = float(amp) + if si == 0: + return frac * a + if si == 1: + return a + frac * (-2.0 * a) + if si == 2: + return -a + frac * (a * 0.45 + a) + return a * 0.45 * (1.0 - frac) + + def _preppipe_char_shake_transform(duration, ax, ay): + d = max(0.0, float(duration)) + ax = float(ax) + ay = float(ay) + + def shake_fn(trans, st, at): + if st >= d: + trans.xoffset = 0 + trans.yoffset = 0 + return None + t_eff = min(float(st), d - 1e-12) + tl = t_eff % _preppipe_shake_cycle_s + trans.xoffset = _preppipe_shake_offset_in_cycle(tl, ax) + trans.yoffset = _preppipe_shake_offset_in_cycle(tl, ay) + return 1.0 / 120.0 + + return _PreppipeShakeMotionTransform(function=shake_fn, subpixel=True) + + def _preppipe_bounce_transform(duration, hpx, cnt): + """cnt 次弹跳在 duration 内均匀分配;每跳前半程 0→-hpx、后半程 -hpx→0(等价于 seg=d/(2*cnt) 且 seg≥0.001 时的 ATL)。""" + d = max(0.0, float(duration)) + h = float(hpx) + n = max(1, int(cnt)) + + def bounce_fn(trans, st, at): + if d <= 0 or st >= d: + trans.yoffset = 0 + return None + t = min(float(st), d - 1e-12) + fb = ((t / d) * n) % 1.0 + if fb < 0.5: + trans.yoffset = -h * (fb * 2.0) + else: + trans.yoffset = -h * (2.0 - 2.0 * fb) + return 1.0 / 120.0 + + return _PreppipeShakeMotionTransform(function=bounce_fn, subpixel=True) + + try: + from renpy.ui import Hide as _preppipe_screen_Hide + except Exception: + _preppipe_screen_Hide = None + + renpy.store.preppipe_char_sustained_fx = set() + renpy.store.preppipe_scene_layer_filter_active = False + renpy.store._preppipe_scene_filter_snapshot = None + renpy.store.preppipe_weather_active = False + renpy.store._preppipe_weather_displayable = None + renpy.store._preppipe_weather_overlay_fi = 0.8 + renpy.store._preppipe_weather_fo = 0.45 + + # Avoid polluting store with 'layout'/'transform' (Ren'Py 00compat expects store.layout). + # 9 points: top_left, top_center, top_right, center_left, center, center_right, bottom_left, bottom_center, bottom_right + # 缩放支点 (xalign, yalign),0=左/上 0.5=中 1=右/下 + _ZOOM_POINT_ALIGN = { + "top_left": (0.0, 0.0), "top_center": (0.5, 0.0), "top_right": (1.0, 0.0), + "center_left": (0.0, 0.5), "center": (0.5, 0.5), "center_right": (1.0, 0.5), + "bottom_left": (0.0, 1.0), "bottom_center": (0.5, 1.0), "bottom_right": (1.0, 1.0), + } + + class _PreppipeZoomTransition(renpy.display.displayable.Displayable): + def __init__(self, old_widget, new_widget, duration, zoom_in, point="center", **kwargs): + super(_PreppipeZoomTransition, self).__init__(**kwargs) + self.old_widget = old_widget + self.new_widget = new_widget + self.duration = duration + self.zoom_in = zoom_in + self.point = (point or "center").strip().lower() + self.delay = duration + + def _align(self): + key = self.point.replace(" ", "_") if self.point else "center" + xa, ya = _ZOOM_POINT_ALIGN.get(key, (0.5, 0.5)) + return float(xa), float(ya) + + def render(self, width, height, st, at): + progress = min(1.0, st / self.duration) if self.duration > 0 else 1.0 + if progress < 1.0: + renpy.redraw(self, 0) + xa, ya = self._align() + Transform = renpy.display.transform.Transform + # Ren'Py 的 zoom 以显示区左上 (0,0) 为缩放中心;要让 (xa,ya) 固定,需把缩放后图像放在 + # 像素位置 (xa*width*(1-zoom), ya*height*(1-zoom)),并设 anchor=(0,0) + def make_zoom_transform(child, zoom_val): + if zoom_val <= 0: + zoom_val = 1e-6 + return Transform(child=child, zoom=zoom_val, xanchor=0, yanchor=0) + if self.zoom_in: + bottom = self.old_widget + top = make_zoom_transform(self.new_widget, progress) + zoom_val = progress + else: + zoom_val = 1.0 - progress + bottom = self.new_widget + top = make_zoom_transform(self.old_widget, zoom_val) + if zoom_val <= 0: + zoom_val = 1e-6 + px = int(round(xa * width * (1.0 - zoom_val))) + py = int(round(ya * height * (1.0 - zoom_val))) + bottom_render = renpy.render(bottom, width, height, st, at) + top_render = renpy.render(top, width, height, st, at) + render = renpy.Render(width, height) + render.blit(bottom_render, (0, 0)) + # Transform 的 render 可能是缩放后内容的尺寸,需在 (px,py) 处 blit 才能使支点 (xa,ya) 固定 + render.blit(top_render, (px, py)) + return render + + def preppipe_zoomin(duration=0.5, point="center"): + duration = _preppipe_coerce_float(duration) + + def _transition(old_widget=None, new_widget=None): + return _PreppipeZoomTransition(old_widget, new_widget, duration, zoom_in=True, point=point) + _transition.delay = duration + return _transition + + def preppipe_zoomout(duration=0.5, point="center"): + duration = _preppipe_coerce_float(duration) + + def _transition(old_widget=None, new_widget=None): + return _PreppipeZoomTransition(old_widget, new_widget, duration, zoom_in=False, point=point) + _transition.delay = duration + return _transition + + def preppipe_scene_shake(duration=0.5, amplitude=12.0, decay=0.0, direction=""): + duration = _preppipe_coerce_float(duration) + amplitude = _preppipe_coerce_float(amplitude) + decay = _preppipe_coerce_float(decay) + steps = max(2, min(20, int(duration * 7))) + per = duration / float(steps) if steps else 0.05 + for _ in range(steps): + if direction == "horizontal": + renpy.with_statement(renpy.store.hpunch) + elif direction == "vertical": + renpy.with_statement(renpy.store.vpunch) + else: + renpy.with_statement(renpy.store.hpunch) + renpy.with_statement(renpy.store.vpunch) + renpy.pause(per * 0.45) + + def preppipe_scene_flash(color, fi, h, fo): + fi = _preppipe_coerce_float(fi) + h = _preppipe_coerce_float(h) + fo = _preppipe_coerce_float(fo) + renpy.call_screen("preppipe_flash_screen", color=color, fi=fi, h=h, fo=fo) + + def preppipe_weather_start(kind, intensity, inner_fi, inner_fo, overlay_fi, vx, vy, sustain=-1.0): + """全屏天气:SnowBlossom + 独立 screen;sustain 预留(当前均持续至 preppipe_weather_stop / 结束场景特效)。""" + from renpy.display.particle import SnowBlossom + + kind = (kind or "").strip().lower() + intensity = _preppipe_coerce_float(intensity) + inner_fi = max(0.0, _preppipe_coerce_float(inner_fi)) + inner_fo = max(0.05, _preppipe_coerce_float(inner_fo)) + overlay_fi = max(0.001, _preppipe_coerce_float(overlay_fi)) + vx = _preppipe_coerce_float(vx) + vy = _preppipe_coerce_float(vy) + + if renpy.store.preppipe_weather_active: + renpy.hide_screen("preppipe_weather_screen") + renpy.store.preppipe_weather_active = False + + cnt = max(6, min(220, int(intensity / 2))) + renpy.store._preppipe_weather_fo = inner_fo + renpy.store._preppipe_weather_overlay_fi = overlay_fi + + if kind == "snow": + flake = Solid("#ffffff", xsize=3, ysize=3) + d = SnowBlossom( + flake, + count=cnt, + border=100, + xspeed=(-35, 35), + yspeed=(70, 150), + start=inner_fi, + fast=False, + animation=True, + ) + elif kind == "rain": + drop = Solid("#aabdd8", xsize=2, ysize=18) + ax = sorted((vx * 0.88, vx * 1.12)) + ay = sorted((vy * 0.92, vy * 1.08)) + d = SnowBlossom( + drop, + count=cnt, + border=140, + xspeed=(ax[0], ax[1]), + yspeed=(ay[0], ay[1]), + start=inner_fi, + fast=False, + animation=True, + ) + else: + return + + renpy.store._preppipe_weather_displayable = d + renpy.store.preppipe_weather_active = True + renpy.show_screen("preppipe_weather_screen") + _preppipe_reapply_master_scene_filter_if_active() + + def preppipe_weather_stop(): + if not renpy.store.preppipe_weather_active: + return + fo = max(0.001, _preppipe_coerce_float(renpy.store._preppipe_weather_fo)) + tr = renpy.store.Dissolve(fo) + # renpy.store.Hide 在部分工程未注入;用 renpy.ui.Hide + renpy.run 与「hide screen … with」一致 + if _preppipe_screen_Hide is not None: + renpy.run(_preppipe_screen_Hide("preppipe_weather_screen", transition=tr)) + else: + renpy.hide_screen("preppipe_weather_screen") + renpy.store.preppipe_weather_active = False + renpy.store._preppipe_weather_displayable = None + + def preppipe_char_shake(imspec, x, y, w, h, duration, amplitude, decay, direction): + duration = _preppipe_coerce_float(duration) + amplitude = _preppipe_coerce_float(amplitude) + decay = _preppipe_coerce_float(decay) + dkey = str(direction or "").strip().lower() + xh = 0 if dkey in ("vertical", "v", "y", "垂直") else 1 + yh = 0 if dkey in ("horizontal", "h", "x", "水平") else 1 + ax = amplitude * xh + ay = amplitude * yh + st = renpy.store + if abs(ax) < 1e-9 and abs(ay) < 1e-9: + tr = None + else: + tr = _preppipe_char_shake_transform(duration, ax, ay) + base = st.screen2d_abs(x, y, w, h) + if tr is None: + renpy.show(imspec, at_list=[base]) + else: + renpy.show(imspec, at_list=[base, tr]) + renpy.pause(duration) + renpy.show(imspec, at_list=[base]) + + def preppipe_char_flash(imspec, x, y, w, h, color, fi, hld, fo): + fi = _preppipe_coerce_float(fi) + hld = _preppipe_coerce_float(hld) + fo = _preppipe_coerce_float(fo) + renpy.show(imspec, at_list=[renpy.store.screen2d_abs(x, y, w, h), renpy.store.preppipe_at_char_flash(fi, hld, fo, color)]) + renpy.pause(fi + hld + fo) + + def _preppipe_fx_pick_tr(fk, s, dur, col): + fk = (fk or "").strip().lower() + s = _preppipe_coerce_float(s) + dur = _preppipe_coerce_float(dur) + col = col or "#ffffff" + snap = dur <= 0 + d = max(0.001, dur) if not snap else 0.001 + st = renpy.store + if fk == "grayscale": + return st.preppipe_at_fx_grayscale_snap(s) if snap else st.preppipe_at_fx_grayscale(s, d) + if fk == "opacity": + return st.preppipe_at_fx_opacity_snap(s) if snap else st.preppipe_at_fx_opacity(s, d) + if fk == "tint": + return st.preppipe_at_fx_tint_snap(col, s) if snap else st.preppipe_at_fx_tint(col, s, d) + if fk == "blur": + return st.preppipe_at_fx_blur_snap(s) if snap else st.preppipe_at_fx_blur(s, d) + return None + + def _preppipe_reapply_master_scene_filter_if_active(): + """Ren'Py 在 show_screen 等之后常会丢掉 master 的 show_layer_at;若仍有场景滤镜标记则按快照补回(如先模糊再下雪)。""" + if not getattr(renpy.store, "preppipe_scene_layer_filter_active", False): + return + snap = getattr(renpy.store, "_preppipe_scene_filter_snapshot", None) + if snap is None: + return + fk, s, col = snap + tr = _preppipe_fx_pick_tr(fk, s, 0.0, col) + if tr is None: + return + sla = getattr(renpy, "show_layer_at", None) + if sla is not None: + sla([tr], layer="master") + + def preppipe_char_filter(imspec, x, y, w, h, fk, strength, dur, col): + x, y, w, h = int(x), int(y), int(w), int(h) + base = renpy.store.screen2d_abs(x, y, w, h) + fk = (fk or "").strip().lower() + dur = _preppipe_coerce_float(dur) + k = (_preppipe_char_fx_key(imspec), x, y, w, h) + tr = _preppipe_fx_pick_tr(fk, strength, dur, col) + if tr is None: + return + if dur < 0: + renpy.show(imspec, at_list=[base, tr]) + renpy.store.preppipe_char_sustained_fx.add(k) + return + renpy.show(imspec, at_list=[base, tr]) + if dur > 0: + renpy.pause(dur) + + def preppipe_scene_filter(fk, strength, dur, col): + fk = (fk or "").strip().lower() + dur = _preppipe_coerce_float(dur) + tr = _preppipe_fx_pick_tr(fk, strength, dur, col) + if tr is None: + return + sla = getattr(renpy, "show_layer_at", None) + if sla is None: + return + sla([tr], layer="master") + renpy.store.preppipe_scene_layer_filter_active = True + renpy.store._preppipe_scene_filter_snapshot = (fk, _preppipe_coerce_float(strength), col) + if dur > 0: + renpy.pause(dur) + + def _preppipe_char_fx_key(imspec): + if isinstance(imspec, tuple): + return imspec + return (imspec,) + + def preppipe_char_tremble(imspec, x, y, w, h, amp, period, duration): + x, y, w, h = int(x), int(y), int(w), int(h) + base = renpy.store.screen2d_abs(x, y, w, h) + amp = _preppipe_coerce_float(amp) + period = max(0.04, _preppipe_coerce_float(period)) + duration = _preppipe_coerce_float(duration) + k = (_preppipe_char_fx_key(imspec), x, y, w, h) + if duration < 0: + tr = renpy.store.preppipe_at_char_tremble_loop(amp, period) + renpy.show(imspec, at_list=[base, tr]) + renpy.store.preppipe_char_sustained_fx.add(k) + else: + dur = max(period, duration) + n = max(1, int(dur / period)) + halfp = max(0.001, period / 2.0) + tr = renpy.store.preppipe_at_char_tremble_cycles(amp, halfp, n) + renpy.show(imspec, at_list=[base, tr]) + renpy.pause(float(n * period)) + renpy.show(imspec, at_list=[base]) + renpy.store.preppipe_char_sustained_fx.discard(k) + + def preppipe_end_char_effect(imspec, x, y, w, h): + x, y, w, h = int(x), int(y), int(w), int(h) + base = renpy.store.screen2d_abs(x, y, w, h) + k = (_preppipe_char_fx_key(imspec), x, y, w, h) + renpy.store.preppipe_char_sustained_fx.discard(k) + renpy.show(imspec, at_list=[base]) + + def preppipe_end_scene_effect(): + """结束挂在 master 层上的场景滤镜(show_layer_at)、全屏天气 screen 及预留的持续特效。""" + preppipe_weather_stop() + if getattr(renpy.store, "preppipe_scene_layer_filter_active", False): + hla = getattr(renpy, "hide_layer_at", None) + if hla is not None: + hla(layer="master") + sla = getattr(renpy, "show_layer_at", None) + if sla is not None: + sla([renpy.store.preppipe_master_layer_reset], layer="master") + renpy.store.preppipe_scene_layer_filter_active = False + renpy.store._preppipe_scene_filter_snapshot = None + + def _preppipe_move_tr(sx, sy, ex, ey, d, sty): + sx, sy, ex, ey = int(sx), int(sy), int(ex), int(ey) + d = _preppipe_coerce_float(d) + e = (sty or "linear").strip().lower() + if e == "ease": + return renpy.store.preppipe_move_ease(sx, sy, ex, ey, d) + if e == "easein": + return renpy.store.preppipe_move_easein(sx, sy, ex, ey, d) + if e == "easeout": + return renpy.store.preppipe_move_easeout(sx, sy, ex, ey, d) + return renpy.store.preppipe_move_lin(sx, sy, ex, ey, d) + + def preppipe_char_sprite_anim(imspec, x, y, w, h, kind, duration, n1, n2, n3, n4, style): + x, y, w, h = int(x), int(y), int(w), int(h) + base = renpy.store.screen2d_abs(x, y, w, h) + k = (kind or "").strip().lower() + d = _preppipe_coerce_float(duration) + sty = (style or "linear").strip().lower() + n1 = _preppipe_coerce_float(n1) + n2 = _preppipe_coerce_float(n2) + n3 = _preppipe_coerce_float(n3) + n4 = _preppipe_coerce_float(n4) + sw = float(renpy.config.screen_width) + sh = float(renpy.config.screen_height) + if k == "bounce": + cnt = int(max(1, n2)) + hpx = n1 * sh + tr = _preppipe_bounce_transform(d, hpx, cnt) + renpy.show(imspec, at_list=[base, tr]) + renpy.pause(d) + renpy.show(imspec, at_list=[base]) + elif k == "move": + ex = int(round(n1 * sw)) + ey = int(round(n2 * sh)) + tr = _preppipe_move_tr(x, y, ex, ey, d, sty) + renpy.show(imspec, at_list=[base, tr]) + renpy.pause(d) + elif k == "scale": + tr = renpy.store.preppipe_at_scale_to(n1, d, 0.5, 0.5) + renpy.show(imspec, at_list=[base, tr]) + renpy.pause(d) + renpy.show(imspec, at_list=[base]) + elif k == "rotate": + tr = renpy.store.preppipe_rotate_to(n1, d, 0.5, 0.5) + renpy.show(imspec, at_list=[base, tr]) + renpy.pause(d) + renpy.show(imspec, at_list=[base]) init offset = -1 diff --git a/src/preppipe/util/imageexprexportop.py b/src/preppipe/util/imageexprexportop.py index 3927ef1..627824d 100644 --- a/src/preppipe/util/imageexprexportop.py +++ b/src/preppipe/util/imageexprexportop.py @@ -7,15 +7,12 @@ import PIL import PIL.Image import PIL.ImageDraw -import PIL.ImageFont from ..irbase import * from ..exportcache import CacheableOperationSymbol from ..imageexpr import * from ..commontypes import * from ..irdataop import * -from ..assets.assetmanager import AssetManager - ImageExprType = typing.TypeVar('ImageExprType', bound=BaseImageLiteralExpr) @IROperationDataclass @@ -34,7 +31,8 @@ def run_export(self, output_rootdir : str) -> None: relpath = self.export_path.get().value fullpath = os.path.join(output_rootdir, relpath) os.makedirs(os.path.dirname(fullpath), exist_ok=True) - img.save(fullpath, format='PNG') + # 固定 PNG 编码参数,便于同一输入在各平台得到一致字节(便于校验与用户资源不应被无端改写的原则) + img.save(fullpath, format="PNG", compress_level=9, optimize=False) @classmethod def get_image(cls, v : ImageExprType) -> PIL.Image.Image: @@ -52,12 +50,7 @@ def create(cls, context : Context, v : ImageExprType, path : StringLiteral | str class PlaceholderImageExportOpSymbol(ImageExprExportOpSymbolBase[PlaceholderImageLiteralExpr]): @classmethod def cls_prepare_export(cls, tp : concurrent.futures.ThreadPoolExecutor) -> None: - # 尝试载入一下字体 - AssetManager.get_font() - - @staticmethod - def get_starting_font_point_size(width : int, height : int) -> int: - return int(width*0.75) + pass @classmethod def get_image(cls, v : PlaceholderImageLiteralExpr) -> PIL.Image.Image: @@ -66,7 +59,6 @@ def get_image(cls, v : PlaceholderImageLiteralExpr) -> PIL.Image.Image: fg_color = (255, 255, 255, 255) # 白色前景 stroke_color = (0, 0, 0, 255) # 黑色描边 linewidth = 3 - strokewidth = 3 width, height = v.size.value img = PIL.Image.new('RGBA', (width, height), (0, 0, 0, 0)) @@ -80,21 +72,8 @@ def get_image(cls, v : PlaceholderImageLiteralExpr) -> PIL.Image.Image: draw.line((left, top, right, bottom), fill=fg_color, width=linewidth) draw.line((left, bottom, right, top), fill=fg_color, width=linewidth) - # 准备把描述文本画上去 - text = v.description.get_string() - curfontsize = PlaceholderImageExportOpSymbol.get_starting_font_point_size(width, height) - font = AssetManager.get_font(curfontsize) - center = ((left+right)/2, (top+bottom)/2) - while True: - bbox = draw.textbbox(center, text=text, font=font, align='center', anchor="mm", stroke_width=strokewidth) - if bbox[0] > 0 and bbox[1] > 0 and bbox[2] < width and bbox[3] < height: - break - next_size = int(curfontsize * 0.9) - if next_size == curfontsize or next_size == 0: - break - curfontsize = next_size - font = AssetManager.get_font(curfontsize) - draw.text(center, text=text, font=font, fill=fg_color, align='center', anchor="mm", stroke_width=strokewidth, stroke_fill=stroke_color) + # 不在位图中绘制描述文字:字体与 FreeType 版本会导致同一占位图在不同机器上 PNG 字节不一致。 + # 描述仍保留在 PlaceholderImageLiteralExpr / Ren'Py 脚本侧;导出像素仅由尺寸与 bbox 决定,跨平台稳定。 return img @IROperationDataclass diff --git a/src/preppipe/vnmodel.py b/src/preppipe/vnmodel.py index 46283e7..4f5d03e 100644 --- a/src/preppipe/vnmodel.py +++ b/src/preppipe/vnmodel.py @@ -4,6 +4,7 @@ import os import io import pathlib +import decimal import typing import pydub import pathlib @@ -22,6 +23,7 @@ TR_vnmodel = TranslationDomain("vnmodel") + # VNModel 只保留核心的、基于文本的IR信息,其他内容(如图片显示位置等)大部分都会放在抽象接口后 # 像图片位置(2D屏幕坐标)、设备外观(对话框贴图)等信息,在后端生成时会用到,所以需要在IR中记录 # 但是他们的具体内容、格式等可能每个引擎都不一样,强求统一的话一定会影响可拓展性 @@ -814,11 +816,270 @@ def get(context : Context, backend : StringLiteral, expression : StringLiteral, raise RuntimeError("Expecting VNDefaultTransitionType for fallback element: " + type(fallback.value).__name__) return VNBackendDisplayableTransitionExpr._get_literalexpr_impl((backend, expression, fallback), context) + +# ------------------------------------------------------------------------------ +# 通用场景/背景切换过渡(LiteralExpr 子类;参数按类型打包,与 imageexpr 中图片 LiteralExpr 一致) +# 对应 effect_doc 第一节:淡入、淡出、溶解、滑入、滑出、推移、黑/白场、缩放过渡 +# ------------------------------------------------------------------------------ + +@IRObjectJsonTypeName("vn_tr_fade_in_le") +class VNFadeInSceneTransitionLit(LiteralExpr): + def construct_init(self, *, context: Context, value_tuple: tuple[FloatLiteral], **kwargs) -> None: # 绑定唯一操作数为时长字面值并挂到特效函数类型 + assert len(value_tuple) == 1 and isinstance(value_tuple[0], FloatLiteral) + ty = VNEffectFunctionType.get(context) # 转场在类型系统中与「特效函数值」共用占位类型 + super().construct_init(ty=ty, value_tuple=value_tuple, **kwargs) + + @staticmethod + def get_fixed_value_type(): # LiteralExpr 元数据:该表达式对外值的 IR 类型 + return VNEffectFunctionType + + @property + def value(self) -> tuple[FloatLiteral]: # 字面值元组视图(与操作数顺序一致) + return super().value + + @property + def duration(self) -> FloatLiteral: # 新画面淡入持续时间(秒) + return self.get_operand(0) + + @staticmethod + def get(context: Context, duration: FloatLiteral) -> VNFadeInSceneTransitionLit: # 工厂:按 context 去重得到单例 LiteralExpr + return VNFadeInSceneTransitionLit._get_literalexpr_impl((duration,), context) + + +@IRObjectJsonTypeName("vn_tr_fade_out_le") +class VNFadeOutSceneTransitionLit(LiteralExpr): + def construct_init(self, *, context: Context, value_tuple: tuple[FloatLiteral], **kwargs) -> None: # 绑定唯一操作数为时长字面值并挂到特效函数类型 + assert len(value_tuple) == 1 and isinstance(value_tuple[0], FloatLiteral) + ty = VNEffectFunctionType.get(context) # 转场在类型系统中与「特效函数值」共用占位类型 + super().construct_init(ty=ty, value_tuple=value_tuple, **kwargs) + + @staticmethod + def get_fixed_value_type(): # LiteralExpr 元数据:该表达式对外值的 IR 类型 + return VNEffectFunctionType + + @property + def value(self) -> tuple[FloatLiteral]: # 字面值元组视图(与操作数顺序一致) + return super().value + + @property + def duration(self) -> FloatLiteral: # 旧画面淡出持续时间(秒) + return self.get_operand(0) + + @staticmethod + def get(context: Context, duration: FloatLiteral) -> VNFadeOutSceneTransitionLit: # 工厂:按 context 去重得到单例 LiteralExpr + return VNFadeOutSceneTransitionLit._get_literalexpr_impl((duration,), context) + + +@IRObjectJsonTypeName("vn_tr_dissolve_le") +class VNDissolveSceneTransitionLit(LiteralExpr): + def construct_init(self, *, context: Context, value_tuple: tuple[FloatLiteral], **kwargs) -> None: # 绑定唯一操作数为时长字面值并挂到特效函数类型 + assert len(value_tuple) == 1 and isinstance(value_tuple[0], FloatLiteral) + ty = VNEffectFunctionType.get(context) # 转场在类型系统中与「特效函数值」共用占位类型 + super().construct_init(ty=ty, value_tuple=value_tuple, **kwargs) + + @staticmethod + def get_fixed_value_type(): # LiteralExpr 元数据:该表达式对外值的 IR 类型 + return VNEffectFunctionType + + @property + def value(self) -> tuple[FloatLiteral]: # 字面值元组视图(与操作数顺序一致) + return super().value + + @property + def duration(self) -> FloatLiteral: # 溶解交叉淡化持续时间(秒) + return self.get_operand(0) + + @staticmethod + def get(context: Context, duration: FloatLiteral) -> VNDissolveSceneTransitionLit: # 工厂:按 context 去重得到单例 LiteralExpr + return VNDissolveSceneTransitionLit._get_literalexpr_impl((duration,), context) + + +@IRObjectJsonTypeName("vn_tr_slide_in_le") +class VNSlideInSceneTransitionLit(LiteralExpr): + def construct_init(self, *, context: Context, value_tuple: tuple[FloatLiteral, StringLiteral], **kwargs) -> None: # 时长 + 方向键字面值,挂到特效函数类型 + assert len(value_tuple) == 2 and isinstance(value_tuple[0], FloatLiteral) and isinstance(value_tuple[1], StringLiteral) + ty = VNEffectFunctionType.get(context) # 转场与特效函数值共用占位类型 + super().construct_init(ty=ty, value_tuple=value_tuple, **kwargs) + + @staticmethod + def get_fixed_value_type(): # LiteralExpr 对外 IR 类型 + return VNEffectFunctionType + + @property + def value(self) -> tuple[FloatLiteral, StringLiteral]: # 字面值元组(时长、方向) + return super().value + + @property + def duration(self) -> FloatLiteral: # 滑入动画时长(秒) + return self.get_operand(0) + + @property + def direction(self) -> StringLiteral: # 规范方向键(如 left/right/up/down) + return self.get_operand(1) + + @staticmethod + def get(context: Context, duration: FloatLiteral, direction: StringLiteral) -> VNSlideInSceneTransitionLit: # 工厂:context 下去重 + return VNSlideInSceneTransitionLit._get_literalexpr_impl((duration, direction), context) + + +@IRObjectJsonTypeName("vn_tr_slide_out_le") +class VNSlideOutSceneTransitionLit(LiteralExpr): + def construct_init(self, *, context: Context, value_tuple: tuple[FloatLiteral, StringLiteral], **kwargs) -> None: # 时长 + 方向键字面值,挂到特效函数类型 + assert len(value_tuple) == 2 and isinstance(value_tuple[0], FloatLiteral) and isinstance(value_tuple[1], StringLiteral) + ty = VNEffectFunctionType.get(context) # 转场与特效函数值共用占位类型 + super().construct_init(ty=ty, value_tuple=value_tuple, **kwargs) + + @staticmethod + def get_fixed_value_type(): # LiteralExpr 对外 IR 类型 + return VNEffectFunctionType + + @property + def value(self) -> tuple[FloatLiteral, StringLiteral]: # 字面值元组(时长、方向) + return super().value + + @property + def duration(self) -> FloatLiteral: # 滑出动画时长(秒) + return self.get_operand(0) + + @property + def direction(self) -> StringLiteral: # 规范方向键(如 left/right/up/down) + return self.get_operand(1) + + @staticmethod + def get(context: Context, duration: FloatLiteral, direction: StringLiteral) -> VNSlideOutSceneTransitionLit: # 工厂:context 下去重 + return VNSlideOutSceneTransitionLit._get_literalexpr_impl((duration, direction), context) + + +@IRObjectJsonTypeName("vn_tr_push_le") +class VNPushSceneTransitionLit(LiteralExpr): + def construct_init(self, *, context: Context, value_tuple: tuple[FloatLiteral, StringLiteral], **kwargs) -> None: # 时长 + 推移方向键字面值,挂到特效函数类型 + assert len(value_tuple) == 2 and isinstance(value_tuple[0], FloatLiteral) and isinstance(value_tuple[1], StringLiteral) + ty = VNEffectFunctionType.get(context) # 转场与特效函数值共用占位类型 + super().construct_init(ty=ty, value_tuple=value_tuple, **kwargs) + + @staticmethod + def get_fixed_value_type(): # LiteralExpr 对外 IR 类型 + return VNEffectFunctionType + + @property + def value(self) -> tuple[FloatLiteral, StringLiteral]: # 字面值元组(时长、方向) + return super().value + + @property + def duration(self) -> FloatLiteral: # 整屏推移时长(秒) + return self.get_operand(0) + + @property + def direction(self) -> StringLiteral: # 推移方向规范键 + return self.get_operand(1) + + @staticmethod + def get(context: Context, duration: FloatLiteral, direction: StringLiteral) -> VNPushSceneTransitionLit: # 工厂:context 下去重 + return VNPushSceneTransitionLit._get_literalexpr_impl((duration, direction), context) + + +@IRObjectJsonTypeName("vn_tr_fade_to_color_le") +class VNFadeToColorSceneTransitionLit(LiteralExpr): + def construct_init( + self, # IR 对象自身 + *, # 仅关键字参数 + context: Context, # IR 上下文(类型注册与去重) + value_tuple: tuple[FloatLiteral, FloatLiteral, FloatLiteral, ColorLiteral], # 淡出时长、全黑停留、淡入时长、中间色 + **kwargs, + ) -> None: # 校验四元组并注册为场景转场字面值 + assert len(value_tuple) == 4 + assert all(isinstance(value_tuple[i], FloatLiteral) for i in range(3)) + assert isinstance(value_tuple[3], ColorLiteral) + ty = VNEffectFunctionType.get(context) # 转场与特效函数值共用占位类型 + super().construct_init(ty=ty, value_tuple=value_tuple, **kwargs) + + @staticmethod + def get_fixed_value_type(): # LiteralExpr 对外 IR 类型 + return VNEffectFunctionType + + @property + def value(self) -> tuple[FloatLiteral, FloatLiteral, FloatLiteral, ColorLiteral]: # 四段时序与颜色的字面值元组 + return super().value + + @property + def fade_out(self) -> FloatLiteral: # 画面淡出至纯色段时长(秒) + return self.get_operand(0) + + @property + def hold(self) -> FloatLiteral: # 保持纯色全屏时长(秒) + return self.get_operand(1) + + @property + def fade_in(self) -> FloatLiteral: # 从纯色淡回画面时长(秒) + return self.get_operand(2) + + @property + def color(self) -> ColorLiteral: # 中间过渡使用的 RGBA 色 + return self.get_operand(3) + + @staticmethod + def get( + context: Context, # IR 上下文 + fade_out: FloatLiteral, # 淡出段时长 + hold: FloatLiteral, # 纯色停留时长 + fade_in: FloatLiteral, # 淡入段时长 + color: ColorLiteral, # 中间色 + ) -> VNFadeToColorSceneTransitionLit: # 工厂:context 下去重 + return VNFadeToColorSceneTransitionLit._get_literalexpr_impl((fade_out, hold, fade_in, color), context) + + +@IRObjectJsonTypeName("vn_tr_zoom_le") +class VNZoomSceneTransitionLit(LiteralExpr): + """缩放过渡:direction 为 in/out;point 为九格锚点键(进入=起始点,退出=结束点)。""" + + def construct_init(self, *, context: Context, value_tuple: tuple[StringLiteral, FloatLiteral, StringLiteral], **kwargs) -> None: # 缩放方向、时长、锚点键字面值 + assert len(value_tuple) == 3 + assert isinstance(value_tuple[0], StringLiteral) and isinstance(value_tuple[1], FloatLiteral) and isinstance(value_tuple[2], StringLiteral) + ty = VNEffectFunctionType.get(context) # 转场与特效函数值共用占位类型 + super().construct_init(ty=ty, value_tuple=value_tuple, **kwargs) + + @staticmethod + def get_fixed_value_type(): # LiteralExpr 对外 IR 类型 + return VNEffectFunctionType + + @property + def value(self) -> tuple[StringLiteral, FloatLiteral, StringLiteral]: # 字面值元组(方向、时长、锚点) + return super().value + + @property + def direction(self) -> StringLiteral: # 缩放方向 in(进入)/out(退出) + return self.get_operand(0) + + @property + def duration(self) -> FloatLiteral: # 缩放过渡时长(秒) + return self.get_operand(1) + + @property + def point(self) -> StringLiteral: # 九格锚点规范键(缩放中心/终点) + return self.get_operand(2) + + @staticmethod + def get(context: Context, direction: StringLiteral, duration: FloatLiteral, point: StringLiteral) -> VNZoomSceneTransitionLit: # 工厂:context 下去重 + return VNZoomSceneTransitionLit._get_literalexpr_impl((direction, duration, point), context) + + +VN_SCENE_TRANSITION_LIT_TYPES: typing.Tuple[type, ...] = ( # 所有场景转场 LiteralExpr 子类,供 isinstance/遍历 + VNFadeInSceneTransitionLit, # 新画面淡入 + VNFadeOutSceneTransitionLit, # 旧画面淡出 + VNDissolveSceneTransitionLit, # 溶解交叉 + VNSlideInSceneTransitionLit, # 新画面滑入 + VNSlideOutSceneTransitionLit, # 旧画面滑出 + VNPushSceneTransitionLit, # 整屏推移 + VNFadeToColorSceneTransitionLit, # 经纯色全屏的淡变 + VNZoomSceneTransitionLit, # 缩放进出 +) + + # 目前音频仅支持淡入淡出渐变 # TODO 加入前端支持 class VNAudioFadeTransitionExpr(LiteralExpr): - DEFAULT_FADEIN : typing.ClassVar[decimal.Decimal] = decimal.Decimal(0.5) - DEFAULT_FADEOUT : typing.ClassVar[decimal.Decimal] = decimal.Decimal(0.5) + DEFAULT_FADEIN : typing.ClassVar[decimal.Decimal] = decimal.Decimal("0.5") + DEFAULT_FADEOUT : typing.ClassVar[decimal.Decimal] = decimal.Decimal("0.5") def construct_init(self, *, context : Context, value_tuple : tuple[FloatLiteral, FloatLiteral], **kwargs) -> None: assert len(value_tuple) == 2 @@ -972,6 +1233,446 @@ class VNBackendInstructionGroup(VNInstructionGroup): def create(context : Context, start_time : Value | None = None, name: str = '', loc: Location | None = None): return VNBackendInstructionGroup(init_mode=IRObjectInitMode.CONSTRUCT, context=context, start_time=start_time, name=name, loc=loc) + +@IRWrappedStatelessClassJsonName("vn_filter_effect_kind_e") +class VNFilterEffectKind(str, enum.Enum): + """立绘/场景滤镜在 IR 中的规范种类(与即时特效 AST 中的键一致)。""" + + GRAYSCALE = "grayscale" + OPACITY = "opacity" + TINT = "tint" + BLUR = "blur" + + +@IRWrappedStatelessClassJsonName("vn_character_sprite_move_kind_e") +class VNCharacterSpriteMoveKind(str, enum.Enum): + """立绘补间种类(平移/缩放/旋转/跳动)。""" + + MOVE = "move" + SCALE = "scale" + ROTATE = "rotate" + BOUNCE = "bounce" + + +@IRWrappedStatelessClassJsonName("vn_weather_effect_kind_e") +class VNWeatherEffectKind(str, enum.Enum): + """全屏粒子天气种类(与即时特效解析中的键一致)。""" + + SNOW = "snow" + RAIN = "rain" + + +@IRWrappedStatelessClassJsonName("vn_shake_axis_kind_e") +class VNShakeAxisKind(str, enum.Enum): + """震动轴向(解析层规范为 horizontal / vertical / 空)。""" + + NONE = "" + HORIZONTAL = "horizontal" + VERTICAL = "vertical" + + +@IRWrappedStatelessClassJsonName("vn_motion_style_kind_e") +class VNMotionStyleKind(str, enum.Enum): + """立绘补间/跳动缓动键(与 ``_normalize_motion_style`` 输出一致)。""" + + LINEAR = "linear" + EASE = "ease" + EASEIN = "easein" + EASEOUT = "easeout" + + +class VNVisualEffectInst(VNInstruction): + """舞台视觉效果指令的抽象基类(震动、闪烁、天气、立绘补间/发抖、滤镜、结束持续效果等)。 + + 仅用于类型归并与分析(如资源/槽位使用频率);不参与单独 IR 序列化,仍由各具体 ``vn_*_op`` 表示。 + + 字段重叠概要:Shake / Flash / Filter / End 共享 ``scene_wide``、``sprite``、``has_place``、``place_*``; + CharacterMove / Tremble 共享 ``sprite``、``has_place``、``place_*``(无 ``scene_wide``); + Weather 自成一组(``weather_kind`` 与粒子/淡入淡出等),与立绘矩形目标无关。 + """ + + +@IROperationDataclass +@IRObjectJsonTypeName("vn_shake_effect_op") +class VNShakeEffectInst(VNVisualEffectInst): + """场景/立绘震动(各后端运行时实现)。""" + + scene_wide : OpOperand[BoolLiteral] # True:全屏场景震动;False:针对立绘/局部矩形 + sprite : OpOperand[Value] # 目标立绘句柄;无目标时用占位整数字面值 + has_place : OpOperand[BoolLiteral] # 是否使用下方矩形(相对/绝对区域由后端约定) + place_x : OpOperand[IntLiteral] # 目标区域左上角 X(像素) + place_y : OpOperand[IntLiteral] # 目标区域左上角 Y(像素) + place_w : OpOperand[IntLiteral] # 目标区域宽度(像素) + place_h : OpOperand[IntLiteral] # 目标区域高度(像素) + duration : OpOperand[FloatLiteral] # 震动持续时长(秒) + amplitude : OpOperand[FloatLiteral] # 初始振幅(像素或归一化,由后端解释) + decay : OpOperand[FloatLiteral] # 振幅衰减系数(由后端解释) + direction : OpOperand[EnumLiteral[VNShakeAxisKind]] + + @staticmethod + def create( + context : Context, # IR 上下文 + start_time : Value, # 本指令开始时间序值 + *, + scene_wide : bool, # 是否全屏场景震动 + sprite : Value | None, # 立绘句柄;None 表示无立绘目标 + has_place : bool, # 是否提供 place_xywh + place_xywh : tuple[int, int, int, int] | None, # 可选矩形 x,y,w,h + duration : decimal.Decimal, # 持续秒数 + amplitude : decimal.Decimal, # 振幅 + decay : decimal.Decimal, # 衰减 + direction : VNShakeAxisKind, + name : str = "", # IR 操作名 + loc : Location | None = None, # 源码位置 + ) -> VNShakeEffectInst: + x, y, w, h = place_xywh if place_xywh else (0, 0, 0, 0) + sp = sprite if sprite is not None else IntLiteral.get(0, context) + return VNShakeEffectInst( + init_mode=IRObjectInitMode.CONSTRUCT, + context=context, + start_time=start_time, + scene_wide=BoolLiteral.get(scene_wide, context), + sprite=sp, + has_place=BoolLiteral.get(has_place, context), + place_x=IntLiteral.get(x, context), + place_y=IntLiteral.get(y, context), + place_w=IntLiteral.get(w, context), + place_h=IntLiteral.get(h, context), + duration=FloatLiteral.get(duration, context), + amplitude=FloatLiteral.get(amplitude, context), + decay=FloatLiteral.get(decay, context), + direction=EnumLiteral.get(context, direction), + name=name, + loc=loc, + ) + + +@IROperationDataclass +@IRObjectJsonTypeName("vn_flash_effect_op") +class VNFlashEffectInst(VNVisualEffectInst): + """场景/立绘闪烁(非转场)。""" + + scene_wide : OpOperand[BoolLiteral] # True:全屏闪色;False:立绘/局部 + sprite : OpOperand[Value] # 目标立绘句柄或占位 + has_place : OpOperand[BoolLiteral] # 是否使用矩形区域 + place_x : OpOperand[IntLiteral] # 区域左上角 X + place_y : OpOperand[IntLiteral] # 区域左上角 Y + place_w : OpOperand[IntLiteral] # 区域宽 + place_h : OpOperand[IntLiteral] # 区域高 + color : OpOperand[StringLiteral] # 闪烁叠加色规范串(如 #RRGGBB 或命名色) + fade_in : OpOperand[FloatLiteral] # 淡入段时长(秒) + hold : OpOperand[FloatLiteral] # 峰值保持时长(秒) + fade_out : OpOperand[FloatLiteral] # 淡出段时长(秒) + + @staticmethod + def create( + context : Context, # IR 上下文 + start_time : Value, # 开始时间序值 + *, + scene_wide : bool, # 是否全屏 + sprite : Value | None, # 立绘句柄;None 无立绘 + has_place : bool, # 是否带矩形 + place_xywh : tuple[int, int, int, int] | None, # 可选 x,y,w,h + color : str, # 颜色键/串 + fade_in : decimal.Decimal, # 淡入秒数 + hold : decimal.Decimal, # 保持秒数 + fade_out : decimal.Decimal, # 淡出秒数 + name : str = "", # IR 操作名 + loc : Location | None = None, # 源码位置 + ) -> VNFlashEffectInst: + x, y, w, h = place_xywh if place_xywh else (0, 0, 0, 0) + sp = sprite if sprite is not None else IntLiteral.get(0, context) + return VNFlashEffectInst( + init_mode=IRObjectInitMode.CONSTRUCT, + context=context, + start_time=start_time, + scene_wide=BoolLiteral.get(scene_wide, context), + sprite=sp, + has_place=BoolLiteral.get(has_place, context), + place_x=IntLiteral.get(x, context), + place_y=IntLiteral.get(y, context), + place_w=IntLiteral.get(w, context), + place_h=IntLiteral.get(h, context), + color=StringLiteral.get(color, context), + fade_in=FloatLiteral.get(fade_in, context), + hold=FloatLiteral.get(hold, context), + fade_out=FloatLiteral.get(fade_out, context), + name=name, + loc=loc, + ) + + +@IROperationDataclass +@IRObjectJsonTypeName("vn_weather_effect_op") +class VNWeatherEffectInst(VNVisualEffectInst): + """全屏粒子天气(雪/雨),各后端实现。""" + + weather_kind : OpOperand[EnumLiteral[VNWeatherEffectKind]] + intensity : OpOperand[FloatLiteral] # 粒子密度/强度 + inner_fade_in : OpOperand[FloatLiteral] # 粒子层自身淡入(秒) + inner_fade_out : OpOperand[FloatLiteral] # 粒子层自身淡出(秒) + overlay_fade_in : OpOperand[FloatLiteral] # 全屏遮罩/叠层淡入(秒) + sustain : OpOperand[FloatLiteral] # 稳定持续时长(秒) + vx : OpOperand[FloatLiteral] # 粒子水平漂移速度分量 + vy : OpOperand[FloatLiteral] # 粒子垂直漂移速度分量 + + @staticmethod + def create( + context : Context, # IR 上下文 + start_time : Value, # 开始时间序值 + *, + weather_kind : VNWeatherEffectKind, + intensity : decimal.Decimal, # 强度 + inner_fade_in : decimal.Decimal, # 内层淡入 + inner_fade_out : decimal.Decimal, # 内层淡出 + overlay_fade_in : decimal.Decimal, # 叠层淡入 + sustain : decimal.Decimal, # 持续 + vx : decimal.Decimal, # 水平速度 + vy : decimal.Decimal, # 垂直速度 + name : str = "", # IR 操作名 + loc : Location | None = None, # 源码位置 + ) -> VNWeatherEffectInst: + return VNWeatherEffectInst( + init_mode=IRObjectInitMode.CONSTRUCT, + context=context, + start_time=start_time, + weather_kind=EnumLiteral.get(context, weather_kind), + intensity=FloatLiteral.get(intensity, context), + inner_fade_in=FloatLiteral.get(inner_fade_in, context), + inner_fade_out=FloatLiteral.get(inner_fade_out, context), + overlay_fade_in=FloatLiteral.get(overlay_fade_in, context), + sustain=FloatLiteral.get(sustain, context), + vx=FloatLiteral.get(vx, context), + vy=FloatLiteral.get(vy, context), + name=name, + loc=loc, + ) + + +@IROperationDataclass +@IRObjectJsonTypeName("vn_character_sprite_move_op") +class VNCharacterSpriteMoveInst(VNVisualEffectInst): + """立绘补间(跳动/平移/缩放/旋转),各后端实现。""" + + sprite : OpOperand[Value] # 目标立绘句柄或占位 + has_place : OpOperand[BoolLiteral] # 是否使用矩形(定位/裁剪视后端) + place_x : OpOperand[IntLiteral] # 区域 X + place_y : OpOperand[IntLiteral] # 区域 Y + place_w : OpOperand[IntLiteral] # 区域宽 + place_h : OpOperand[IntLiteral] # 区域高 + move_kind : OpOperand[EnumLiteral[VNCharacterSpriteMoveKind]] + duration : OpOperand[FloatLiteral] # 补间时长(秒) + n1 : OpOperand[FloatLiteral] # 数值参数 1(位移/角度等,语义依 move_kind) + n2 : OpOperand[FloatLiteral] # 数值参数 2 + n3 : OpOperand[FloatLiteral] # 数值参数 3 + n4 : OpOperand[FloatLiteral] # 数值参数 4 + style : OpOperand[EnumLiteral[VNMotionStyleKind]] + + @staticmethod + def create( + context : Context, # IR 上下文 + start_time : Value, # 开始时间序值 + *, + sprite : Value | None, # 立绘句柄;None 占位 + has_place : bool, # 是否带矩形 + place_xywh : tuple[int, int, int, int] | None, # 可选 x,y,w,h + move_kind : VNCharacterSpriteMoveKind, + duration : decimal.Decimal, # 秒 + n1 : decimal.Decimal, # 参数 1 + n2 : decimal.Decimal, # 参数 2 + n3 : decimal.Decimal, # 参数 3 + n4 : decimal.Decimal, # 参数 4 + style : VNMotionStyleKind, + name : str = "", # IR 操作名 + loc : Location | None = None, # 源码位置 + ) -> VNCharacterSpriteMoveInst: + x, y, w, h = place_xywh if place_xywh else (0, 0, 0, 0) + sp = sprite if sprite is not None else IntLiteral.get(0, context) + return VNCharacterSpriteMoveInst( + init_mode=IRObjectInitMode.CONSTRUCT, + context=context, + start_time=start_time, + sprite=sp, + has_place=BoolLiteral.get(has_place, context), + place_x=IntLiteral.get(x, context), + place_y=IntLiteral.get(y, context), + place_w=IntLiteral.get(w, context), + place_h=IntLiteral.get(h, context), + move_kind=EnumLiteral.get(context, move_kind), + duration=FloatLiteral.get(duration, context), + n1=FloatLiteral.get(n1, context), + n2=FloatLiteral.get(n2, context), + n3=FloatLiteral.get(n3, context), + n4=FloatLiteral.get(n4, context), + style=EnumLiteral.get(context, style), + name=name, + loc=loc, + ) + + +@IROperationDataclass +@IRObjectJsonTypeName("vn_char_tremble_effect_op") +class VNCharTrembleEffectInst(VNVisualEffectInst): + """立绘发抖(左右往复);duration<0 表示持续至结束特效。""" + + sprite : OpOperand[Value] # 目标立绘句柄或占位 + has_place : OpOperand[BoolLiteral] # 是否使用矩形区域 + place_x : OpOperand[IntLiteral] # 区域 X + place_y : OpOperand[IntLiteral] # 区域 Y + place_w : OpOperand[IntLiteral] # 区域宽 + place_h : OpOperand[IntLiteral] # 区域高 + amplitude : OpOperand[FloatLiteral] # 单次摆动幅度(像素,由后端解释) + period : OpOperand[FloatLiteral] # 往复周期(秒) + duration : OpOperand[FloatLiteral] # 总时长(秒);负值表示持续到 End 指令 + + @staticmethod + def create( + context : Context, # IR 上下文 + start_time : Value, # 开始时间序值 + *, + sprite : Value | None, # 立绘句柄;None 占位 + has_place : bool, # 是否带矩形 + place_xywh : tuple[int, int, int, int] | None, # 可选 x,y,w,h + amplitude : decimal.Decimal, # 幅度 + period : decimal.Decimal, # 周期秒 + duration : decimal.Decimal, # 总时长秒(可负表示持续) + name : str = "", # IR 操作名 + loc : Location | None = None, # 源码位置 + ) -> VNCharTrembleEffectInst: + x, y, w, h = place_xywh if place_xywh else (0, 0, 0, 0) + sp = sprite if sprite is not None else IntLiteral.get(0, context) + return VNCharTrembleEffectInst( + init_mode=IRObjectInitMode.CONSTRUCT, + context=context, + start_time=start_time, + sprite=sp, + has_place=BoolLiteral.get(has_place, context), + place_x=IntLiteral.get(x, context), + place_y=IntLiteral.get(y, context), + place_w=IntLiteral.get(w, context), + place_h=IntLiteral.get(h, context), + amplitude=FloatLiteral.get(amplitude, context), + period=FloatLiteral.get(period, context), + duration=FloatLiteral.get(duration, context), + name=name, + loc=loc, + ) + + +@IROperationDataclass +@IRObjectJsonTypeName("vn_filter_effect_op") +class VNFilterEffectInst(VNVisualEffectInst): + """场景/立绘滤镜:灰化、半透明、色调叠加、模糊。strength 语义依 filter_kind;color 仅 tint 使用。""" + + scene_wide : OpOperand[BoolLiteral] # True:全屏滤镜;False:立绘/局部 + sprite : OpOperand[Value] # 目标立绘句柄或占位 + has_place : OpOperand[BoolLiteral] # 是否使用矩形 + place_x : OpOperand[IntLiteral] # 区域 X + place_y : OpOperand[IntLiteral] # 区域 Y + place_w : OpOperand[IntLiteral] # 区域宽 + place_h : OpOperand[IntLiteral] # 区域高 + filter_kind : OpOperand[EnumLiteral[VNFilterEffectKind]] + strength : OpOperand[FloatLiteral] # 强度(0–1 或后端约定,依 filter_kind) + duration : OpOperand[FloatLiteral] # 过渡到目标强度的时长(秒) + color : OpOperand[StringLiteral] # tint 等需要的颜色串;非 tint 可为占位 + + @staticmethod + def create( + context : Context, # IR 上下文 + start_time : Value, # 开始时间序值 + *, + scene_wide : bool, # 是否全屏 + sprite : Value | None, # 立绘句柄;None 占位 + has_place : bool, # 是否带矩形 + place_xywh : tuple[int, int, int, int] | None, # 可选 x,y,w,h + filter_kind : VNFilterEffectKind, + strength : decimal.Decimal, # 强度 + duration : decimal.Decimal, # 过渡秒数 + color : str, # 颜色串 + name : str = "", # IR 操作名 + loc : Location | None = None, # 源码位置 + ) -> VNFilterEffectInst: + x, y, w, h = place_xywh if place_xywh else (0, 0, 0, 0) + sp = sprite if sprite is not None else IntLiteral.get(0, context) + return VNFilterEffectInst( + init_mode=IRObjectInitMode.CONSTRUCT, + context=context, + start_time=start_time, + scene_wide=BoolLiteral.get(scene_wide, context), + sprite=sp, + has_place=BoolLiteral.get(has_place, context), + place_x=IntLiteral.get(x, context), + place_y=IntLiteral.get(y, context), + place_w=IntLiteral.get(w, context), + place_h=IntLiteral.get(h, context), + filter_kind=EnumLiteral.get(context, filter_kind), + strength=FloatLiteral.get(strength, context), + duration=FloatLiteral.get(duration, context), + color=StringLiteral.get(color, context), + name=name, + loc=loc, + ) + + +@IROperationDataclass +@IRObjectJsonTypeName("vn_end_effect_op") +class VNEndEffectInst(VNVisualEffectInst): + """结束持续类特效(角色立绘 / 场景占位)。""" + + scene_wide : OpOperand[BoolLiteral] # True:结束场景级持续效果;False:结束立绘/局部 + sprite : OpOperand[Value] # 目标立绘句柄或占位 + has_place : OpOperand[BoolLiteral] # 是否与某矩形区域绑定的持续效果一并结束 + place_x : OpOperand[IntLiteral] # 区域 X + place_y : OpOperand[IntLiteral] # 区域 Y + place_w : OpOperand[IntLiteral] # 区域宽 + place_h : OpOperand[IntLiteral] # 区域高 + + @staticmethod + def create( + context : Context, # IR 上下文 + start_time : Value, # 开始时间序值 + *, + scene_wide : bool, # 是否场景范围结束 + sprite : Value | None = None, # 立绘句柄;默认 None 占位 + has_place : bool = False, # 是否带矩形 + place_xywh : tuple[int, int, int, int] | None = None, # 可选 x,y,w,h + name : str = "", # IR 操作名 + loc : Location | None = None, # 源码位置 + ) -> VNEndEffectInst: + x, y, w, h = place_xywh if place_xywh else (0, 0, 0, 0) + sp = sprite if sprite is not None else IntLiteral.get(0, context) + return VNEndEffectInst( + init_mode=IRObjectInitMode.CONSTRUCT, + context=context, + start_time=start_time, + scene_wide=BoolLiteral.get(scene_wide, context), + sprite=sp, + has_place=BoolLiteral.get(has_place, context), + place_x=IntLiteral.get(x, context), + place_y=IntLiteral.get(y, context), + place_w=IntLiteral.get(w, context), + place_h=IntLiteral.get(h, context), + name=name, + loc=loc, + ) + + +def is_vn_visual_effect_instruction(inst: VNInstruction) -> bool: + """是否为上述七大视觉效果指令之一(含立绘补间与天气)。""" + return isinstance(inst, VNVisualEffectInst) + + +VN_VISUAL_EFFECT_INST_TYPES: tuple[type[VNVisualEffectInst], ...] = ( # 所有视觉效果指令类,供遍历/类型判断 + VNShakeEffectInst, # 震动 + VNFlashEffectInst, # 闪烁 + VNWeatherEffectInst, # 天气粒子 + VNCharacterSpriteMoveInst, # 立绘补间 + VNCharTrembleEffectInst, # 立绘发抖 + VNFilterEffectInst, # 滤镜 + VNEndEffectInst, # 结束持续特效 +) + + @IROperationDataclass @IRObjectJsonTypeName("vn_say_instrgroup_op") class VNSayInstructionGroup(VNInstructionGroup): @@ -1076,7 +1777,7 @@ class VNPlacementInstBase(VNInstruction): content : OpOperand[Value] device : OpOperand[VNDeviceSymbol] placeat : SymbolTableRegion[VNPositionSymbol] - transition : OpOperand[Value] + transition : OpOperand[Value] # 未绑定/None:后端无转场;剧本未写转场时由 codegen 写入默认淡入等 @IROperationDataclass class VNPutInst(VNPlacementInstBase): @@ -1130,8 +1831,9 @@ def create(context : Context, start_time: Value, handlein : Value, content : Val @IROperationDataclass class VNRemoveInst(VNInstruction): # 去除某对象句柄所用的指令,只能指定渐变效果 - handlein : OpOperand - transition : OpOperand[Value] + handlein : OpOperand # 要移除的资源/显示句柄(操作数) + transition : OpOperand[Value] # 退场转场;未绑定或 try_get_value 为 None 时后端按「无转场」直接切换;codegen 会为未写命令转场填入默认淡出 + placeat : SymbolTableRegion # 可选符号区:退场 show 链式位置(如 Ren'Py screen2d_abs,与入场一致) @staticmethod def create(context : Context, start_time: Value, handlein : Value, name: str = '', loc: Location | None = None) -> VNRemoveInst: diff --git a/src/preppipe/webgal/animation_ir.py b/src/preppipe/webgal/animation_ir.py new file mode 100644 index 0000000..e122687 --- /dev/null +++ b/src/preppipe/webgal/animation_ir.py @@ -0,0 +1,298 @@ +# SPDX-FileCopyrightText: 2024 PrepPipe's Contributors +# SPDX-License-Identifier: Apache-2.0 + +"""由通用特效 IR 推导 WebGAL game/animation JSON 关键帧(与 Ren'Py preppipert 语义对齐的采样)。 + +TODO(WebGAL): 当前 `webgal/codegen` 对上述 IR 一律 `PPInternalError`,本模块保留供后续恢复导出时使用。 +""" + +from __future__ import annotations + +import hashlib +import json +import math +import re + +_SHAKE_SEG_S = 0.05 +_SHAKE_CYCLE_S = 0.2 + + +def _shake_offset_in_cycle(t_local: float, amp: float) -> float: + if abs(amp) < 1e-12: + return 0.0 + c = _SHAKE_CYCLE_S + s = _SHAKE_SEG_S + t_local = max(0.0, min(float(t_local), c - 1e-12)) + si = min(3, int(t_local // s)) + frac = (t_local - si * s) / s + a = float(amp) + if si == 0: + return frac * a + if si == 1: + return a + frac * (-2.0 * a) + if si == 2: + return -a + frac * (a * 0.45 + a) + return a * 0.45 * (1.0 - frac) + + +def shake_xy_keyframes(duration_s: float, amplitude: float, direction: str) -> list[dict]: + """position.x/y 为像素偏移;与 preppipert._preppipe_char_shake_transform 同周期波形。""" + d = max(0.0, float(duration_s)) + dkey = str(direction or "").strip().lower() + xh = 0 if dkey in ("vertical", "v", "y", "垂直") else 1 + yh = 0 if dkey in ("horizontal", "h", "x", "水平") else 1 + ax = float(amplitude) * xh + ay = float(amplitude) * yh + if d <= 1e-9: + return [{"position": {"x": 0, "y": 0}, "duration": 0}] + step_s = 1.0 / 60.0 + frames: list[dict] = [] + t = 0.0 + prev_x = prev_y = 0.0 + while t < d: + tl = t % _SHAKE_CYCLE_S + ox = _shake_offset_in_cycle(tl, ax) + oy = _shake_offset_in_cycle(tl, ay) + seg = min(step_s, d - t) + dur_ms = max(1, int(round(seg * 1000))) + if not frames: + frames.append({"position": {"x": ox, "y": oy}, "duration": 0}) + else: + frames.append({"position": {"x": ox, "y": oy}, "duration": dur_ms}) + prev_x, prev_y = ox, oy + t += seg + ox = oy = 0.0 + frames.append({"position": {"x": ox, "y": oy}, "duration": max(1, int(round(0.02 * 1000)))}) + return frames + + +def bounce_y_keyframes(duration_s: float, height_px: float, count: int) -> list[dict]: + d = max(0.0, float(duration_s)) + h = float(height_px) + n = max(1, int(count)) + if d <= 1e-9: + return [{"position": {"x": 0, "y": 0}, "duration": 0}] + + def y_at(t: float) -> float: + if t >= d: + return 0.0 + t = min(float(t), d - 1e-12) + fb = ((t / d) * n) % 1.0 + if fb < 0.5: + return -h * (fb * 2.0) + return -h * (2.0 - 2.0 * fb) + + step_s = 1.0 / 60.0 + frames: list[dict] = [] + t = 0.0 + while t < d: + yy = y_at(t) + seg = min(step_s, d - t) + dur_ms = max(1, int(round(seg * 1000))) + if not frames: + frames.append({"position": {"x": 0, "y": yy}, "duration": 0}) + else: + frames.append({"position": {"x": 0, "y": yy}, "duration": dur_ms}) + t += seg + frames.append({"position": {"x": 0, "y": 0}, "duration": max(1, int(round(0.02 * 1000)))}) + return frames + + +def _ease_linear(t: float) -> float: + return t + + +def _ease_ease(t: float) -> float: + return t * t * (3.0 - 2.0 * t) + + +def _ease_in(t: float) -> float: + return t * t + + +def _ease_out(t: float) -> float: + return 1.0 - (1.0 - t) * (1.0 - t) + + +def _ease_fn(style: str): + s = (style or "linear").strip().lower() + if s == "ease": + return _ease_ease + if s == "easein": + return _ease_in + if s == "easeout": + return _ease_out + return _ease_linear + + +def move_xy_keyframes( + sx: float, sy: float, ex: float, ey: float, duration_s: float, style: str +) -> list[dict]: + d = max(0.0, float(duration_s)) + ease = _ease_fn(style) + if d <= 1e-9: + return [{"position": {"x": 0, "y": 0}, "duration": 0}] + dx_total = float(ex) - float(sx) + dy_total = float(ey) - float(sy) + n = max(2, min(32, int(d * 30) + 1)) + frames: list[dict] = [] + for i in range(n): + t = i / (n - 1) if n > 1 else 1.0 + u = ease(t) + px = dx_total * u + py = dy_total * u + if i == 0: + frames.append({"position": {"x": px, "y": py}, "duration": 0}) + else: + prev_u = ease((i - 1) / (n - 1) if n > 1 else 0.0) + seg_t = (t - ((i - 1) / (n - 1) if n > 1 else 0.0)) * d + dur_ms = max(1, int(round(seg_t * 1000))) + frames.append({"position": {"x": px, "y": py}, "duration": dur_ms}) + return frames + + +def scale_keyframes(z_end: float, duration_s: float) -> list[dict]: + d = max(0.0, float(duration_s)) + z1 = float(z_end) + if d <= 1e-9: + return [{"scale": {"x": z1, "y": z1}, "duration": 0}] + n = max(2, min(24, int(d * 24) + 1)) + frames: list[dict] = [] + for i in range(n): + t = i / (n - 1) if n > 1 else 1.0 + z = 1.0 + (z1 - 1.0) * t + if i == 0: + frames.append({"scale": {"x": z, "y": z}, "duration": 0}) + else: + seg_t = d / (n - 1) + dur_ms = max(1, int(round(seg_t * 1000))) + frames.append({"scale": {"x": z, "y": z}, "duration": dur_ms}) + return frames + + +def rotate_keyframes(angle_rad: float, duration_s: float) -> list[dict]: + d = max(0.0, float(duration_s)) + a = float(angle_rad) + if d <= 1e-9: + return [{"rotation": a, "duration": 0}] + n = max(2, min(24, int(d * 24) + 1)) + frames: list[dict] = [] + for i in range(n): + t = i / (n - 1) if n > 1 else 1.0 + ang = a * t + if i == 0: + frames.append({"rotation": ang, "duration": 0}) + else: + seg_t = d / (n - 1) + dur_ms = max(1, int(round(seg_t * 1000))) + frames.append({"rotation": ang, "duration": dur_ms}) + return frames + + +def tremble_x_keyframes(amplitude: float, half_period_s: float, n_cycles: int) -> list[dict]: + amp = float(amplitude) + hp = max(0.001, float(half_period_s)) + n = max(1, int(n_cycles)) + dur_ms = max(1, int(round(hp * 1000))) + frames: list[dict] = [{"position": {"x": 0, "y": 0}, "duration": 0}] + for _ in range(n): + frames.append({"position": {"x": amp, "y": 0}, "duration": dur_ms}) + frames.append({"position": {"x": -amp, "y": 0}, "duration": dur_ms}) + frames.append({"position": {"x": 0, "y": 0}, "duration": max(1, int(round(0.02 * 1000)))}) + return frames + + +def tremble_loop_x_keyframes(amplitude: float, period_s: float, duration_s: float) -> list[dict]: + """有限时长内循环往复(与 repeat 近似)。""" + amp = float(amplitude) + per = max(0.04, float(period_s)) + d = float(duration_s) + halfp = max(0.001, per / 2.0) + n = max(1, int(d / per)) + return tremble_x_keyframes(amp, halfp, n) + + +_HEX_RE = re.compile(r"^#?([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$") + + +def parse_hex_rgb(color: str) -> tuple[int, int, int]: + s = (color or "#ffffff").strip() + m = _HEX_RE.match(s) + if not m: + return 255, 255, 255 + h = m.group(1) + if len(h) == 3: + h = "".join(c * 2 for c in h) + r = int(h[0:2], 16) + g = int(h[2:4], 16) + b = int(h[4:6], 16) + return r, g, b + + +def flash_keyframes(fade_in_s: float, hold_s: float, fade_out_s: float, color: str) -> list[dict]: + fi = max(0.0, float(fade_in_s)) + hld = max(0.0, float(hold_s)) + fo = max(0.0, float(fade_out_s)) + r, g, b = parse_hex_rgb(color) + frames: list[dict] = [{"alpha": 1.0, "brightness": 1.0, "duration": 0}] + if fi > 1e-9: + n = max(2, int(fi * 30) + 1) + for i in range(1, n + 1): + t = i / n + frames.append( + { + "alpha": 1.0 - t * 0.82, + "brightness": 1.0 + 0.4 * t, + "colorRed": r, + "colorGreen": g, + "colorBlue": b, + "duration": max(1, int(round(fi * 1000 / n))), + } + ) + if hld > 1e-9: + frames.append( + { + "alpha": 0.18, + "brightness": 1.4, + "colorRed": r, + "colorGreen": g, + "colorBlue": b, + "duration": max(1, int(round(hld * 1000))), + } + ) + if fo > 1e-9: + n = max(2, int(fo * 30) + 1) + for i in range(1, n + 1): + t = i / n + frames.append( + { + "alpha": 0.18 + t * 0.82, + "brightness": 1.4 - t * 0.4, + "colorRed": int(round(r + (255 - r) * t)), + "colorGreen": int(round(g + (255 - g) * t)), + "colorBlue": int(round(b + (255 - b) * t)), + "duration": max(1, int(round(fo * 1000 / n))), + } + ) + frames.append( + { + "alpha": 1.0, + "brightness": 1.0, + "colorRed": 255, + "colorGreen": 255, + "colorBlue": 255, + "duration": max(1, int(round(0.02 * 1000))), + } + ) + return frames + + +def stable_animation_name(prefix: str, payload: dict) -> str: + raw = json.dumps(payload, sort_keys=True, separators=(",", ":")) + h = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] + safe_p = re.sub(r"[^a-zA-Z0-9_]", "_", prefix)[:24] + return f"pp_{safe_p}_{h}" + + +def animation_json_dumps(frames: list[dict]) -> str: + return json.dumps(frames, ensure_ascii=False, separators=(",", ":")) diff --git a/src/preppipe/webgal/ast.py b/src/preppipe/webgal/ast.py index 24fe200..5ac8a1c 100644 --- a/src/preppipe/webgal/ast.py +++ b/src/preppipe/webgal/ast.py @@ -1,6 +1,8 @@ # SPDX-FileCopyrightText: 2024 PrepPipe's Contributors # SPDX-License-Identifier: Apache-2.0 +import typing + from ..irbase import * from .. import irdataop from ..language import TranslationDomain, Translatable @@ -216,12 +218,27 @@ def create(context : Context, label : StringLiteral | str, loc : Location | None label = StringLiteral.get(label, context=context) return WebGalJumpLabelNode(init_mode=IRObjectInitMode.CONSTRUCT, context=context, label=label, loc=loc) -irdataop.IROperationDataclass +@irdataop.IROperationDataclass class WebGalSetVarNode(WebGalNode): varname : OpOperand[StringLiteral] expr : OpOperand[StringLiteral] flag_global : OpOperand[BoolLiteral] + @staticmethod + def create( + context : Context, + varname : str, + expr : str, + *, + global_var : bool = False, + loc : Location | None = None, + ): + n = WebGalSetVarNode(init_mode=IRObjectInitMode.CONSTRUCT, context=context, loc=loc) + n.varname.set_operand(0, StringLiteral.get(varname, context)) + n.expr.set_operand(0, StringLiteral.get(expr, context)) + n.flag_global.set_operand(0, BoolLiteral.get(global_var, context)) + return n + @irdataop.IROperationDataclass class WebGalGetUserInputNode(WebGalNode): varname : OpOperand[StringLiteral] @@ -233,6 +250,42 @@ class WebGalSetAnimationNode(WebGalNode): animation : OpOperand[StringLiteral] target : OpOperand[StringLiteral] + @staticmethod + def create( + context : Context, + animation : str, + target : str, + loc : Location | None = None, + ): + n = WebGalSetAnimationNode(init_mode=IRObjectInitMode.CONSTRUCT, context=context, loc=loc) + n.animation.set_operand(0, StringLiteral.get(animation, context)) + n.target.set_operand(0, StringLiteral.get(target, context)) + return n + +@irdataop.IROperationDataclass +class WebGalSetTransformNode(WebGalNode): + transform_json : OpOperand[StringLiteral] + target : OpOperand[StringLiteral] + duration_ms : OpOperand[IntLiteral] + flag_write_default : OpOperand[BoolLiteral] + + @staticmethod + def create( + context : Context, + transform_json : str, + target : str, + duration_ms : int, + *, + write_default : bool = False, + loc : Location | None = None, + ): + n = WebGalSetTransformNode(init_mode=IRObjectInitMode.CONSTRUCT, context=context, loc=loc) + n.transform_json.set_operand(0, StringLiteral.get(transform_json, context)) + n.target.set_operand(0, StringLiteral.get(target, context)) + n.duration_ms.set_operand(0, IntLiteral.get(duration_ms, context)) + n.flag_write_default.set_operand(0, BoolLiteral.get(write_default, context)) + return n + @irdataop.IROperationDataclass class WebGalSetTransitionNode(WebGalNode): target : OpOperand[StringLiteral] @@ -241,12 +294,35 @@ class WebGalSetTransitionNode(WebGalNode): @irdataop.IROperationDataclass class WebGalPixiInitNode(WebGalNode): - pass + @staticmethod + def create(context : Context, loc : Location | None = None): + return WebGalPixiInitNode(init_mode=IRObjectInitMode.CONSTRUCT, context=context, loc=loc) @irdataop.IROperationDataclass class WebGalPixiPerformNode(WebGalNode): effect : OpOperand[StringLiteral] + @staticmethod + def create(context : Context, effect : str, loc : Location | None = None): + n = WebGalPixiPerformNode(init_mode=IRObjectInitMode.CONSTRUCT, context=context, loc=loc) + n.effect.set_operand(0, StringLiteral.get(effect, context)) + return n + +@irdataop.IROperationDataclass +class WebGalTextDataFileOp(Symbol): + """导出到工程内的 UTF-8 文本文件(如 game/animation/*.json)。""" + text_body : OpOperand[StringLiteral] + + @staticmethod + def create(context : Context, relpath : str, text : str, loc : Location | None = None): + return WebGalTextDataFileOp( + init_mode=IRObjectInitMode.CONSTRUCT, + context=context, + name=relpath, + text_body=StringLiteral.get(text, context), + loc=loc, + ) + @irdataop.IROperationDataclass class WebGalScriptFileOp(Symbol): relpath : OpOperand[StringLiteral] @@ -263,7 +339,17 @@ def start_visit(self, v : WebGalScriptFileOp): @irdataop.IROperationDataclass class WebGalModel(BackendProjectModelBase[WebGalScriptFileOp]): + _data_file_region : SymbolTableRegion = irdataop.symtable_field(lookup_name="data_file") @staticmethod def create(context : Context): return WebGalModel(init_mode=IRObjectInitMode.CONSTRUCT, context=context) + + def add_data_file(self, op : WebGalTextDataFileOp) -> None: + self._data_file_region.add(op) + + def get_data_file(self, relpath : str) -> WebGalTextDataFileOp | None: + return self._data_file_region.get(relpath) + + def data_files(self) -> typing.Iterable[WebGalTextDataFileOp]: + return self._data_file_region diff --git a/src/preppipe/webgal/codegen.py b/src/preppipe/webgal/codegen.py index 6c3a337..ec709f9 100644 --- a/src/preppipe/webgal/codegen.py +++ b/src/preppipe/webgal/codegen.py @@ -1,8 +1,11 @@ # SPDX-FileCopyrightText: 2024 PrepPipe's Contributors # SPDX-License-Identifier: Apache-2.0 +import dataclasses + from .ast import * from ..vnmodel import * +from ..exceptions import PPInternalError from ..util.imagepackexportop import ImagePackExportDataBuilder from ..util.imagepack import * from ..util import nameconvert @@ -66,9 +69,18 @@ def __init__(self, model : VNModel) -> None: @staticmethod def init_matchtable(): + # TODO(WebGAL): 待引擎支持工程内开箱扩展(或 PrepPipe 分发定制构建)后,恢复 VN*EffectInst 的 + # setAnimation / setTransform / Pixi 等价生成;当前凡遇此类 IR 一律 _webgal_unsupported_vn_effect。 _WebGalCodeGenHelper.install_codegen_matchtree({ VNWaitInstruction : { VNSayInstructionGroup: _WebGalCodeGenHelper.gen_say_wait, + VNShakeEffectInst: _WebGalCodeGenHelper.gen_shake_effect_wait, + VNFlashEffectInst: _WebGalCodeGenHelper.gen_flash_effect_wait, + VNWeatherEffectInst: _WebGalCodeGenHelper.gen_weather_effect_wait, + VNCharacterSpriteMoveInst: _WebGalCodeGenHelper.gen_character_sprite_move_wait, + VNCharTrembleEffectInst: _WebGalCodeGenHelper.gen_char_tremble_effect_wait, + VNFilterEffectInst: _WebGalCodeGenHelper.gen_filter_effect_wait, + VNEndEffectInst: _WebGalCodeGenHelper.gen_end_effect_wait, None: _WebGalCodeGenHelper.gen_wait, }, VNSayInstructionGroup: _WebGalCodeGenHelper.gen_say_nowait, @@ -79,6 +91,13 @@ def init_matchtable(): VNRemoveInst : _WebGalCodeGenHelper.gen_remove, VNCallInst : _WebGalCodeGenHelper.gen_call, VNBackendInstructionGroup : _WebGalCodeGenHelper.gen_asm, + VNShakeEffectInst : _WebGalCodeGenHelper.gen_shake_effect_inst, + VNFlashEffectInst : _WebGalCodeGenHelper.gen_flash_effect_inst, + VNWeatherEffectInst : _WebGalCodeGenHelper.gen_weather_effect_inst, + VNCharacterSpriteMoveInst : _WebGalCodeGenHelper.gen_character_sprite_move_inst, + VNCharTrembleEffectInst : _WebGalCodeGenHelper.gen_char_tremble_effect_inst, + VNFilterEffectInst : _WebGalCodeGenHelper.gen_filter_effect_inst, + VNEndEffectInst : _WebGalCodeGenHelper.gen_end_effect_inst, }) _WebGalCodeGenHelper.install_asset_basedir_matchtree({ AudioAssetData : { @@ -535,6 +554,73 @@ def gen_wait(self, instrs: list[VNInstruction], insert_before: WebGalNode) -> We wait.insert_before(insert_before) return wait + def _webgal_unsupported_vn_effect(self, inst : VNInstruction) -> None: + """TODO(WebGAL): Open WebGAL 若支持工程内运行时扩展 Pixi / 或 PrepPipe 固定分发定制引擎,再实现等价导出。""" + cls = type(inst).__name__ + raise PPInternalError( + "WebGAL 后端暂不支持通用特效 IR(" + + cls + + "):官方引擎无法在工程目录内像 Ren'Py 一样开箱加载自定义脚本;" + "语义等价导出待 WebGAL 侧能力或 PrepPipe 定制构建就绪后恢复(见 webgal/codegen.py init_matchtable TODO)。" + "请改用 Ren'Py 导出,或从工程中移除此类特效指令。", + ) + + def gen_shake_effect_wait(self, instrs: list[VNInstruction], insert_before: WebGalNode) -> WebGalNode: + assert len(instrs) == 2 and isinstance(instrs[1], VNShakeEffectInst) + self._webgal_unsupported_vn_effect(instrs[1]) + + def gen_shake_effect_inst(self, instrs: list[VNInstruction], insert_before: WebGalNode) -> WebGalNode: + assert len(instrs) == 1 and isinstance(instrs[0], VNShakeEffectInst) + self._webgal_unsupported_vn_effect(instrs[0]) + + def gen_flash_effect_wait(self, instrs: list[VNInstruction], insert_before: WebGalNode) -> WebGalNode: + assert len(instrs) == 2 and isinstance(instrs[1], VNFlashEffectInst) + self._webgal_unsupported_vn_effect(instrs[1]) + + def gen_flash_effect_inst(self, instrs: list[VNInstruction], insert_before: WebGalNode) -> WebGalNode: + assert len(instrs) == 1 and isinstance(instrs[0], VNFlashEffectInst) + self._webgal_unsupported_vn_effect(instrs[0]) + + def gen_weather_effect_wait(self, instrs: list[VNInstruction], insert_before: WebGalNode) -> WebGalNode: + assert len(instrs) == 2 and isinstance(instrs[1], VNWeatherEffectInst) + self._webgal_unsupported_vn_effect(instrs[1]) + + def gen_weather_effect_inst(self, instrs: list[VNInstruction], insert_before: WebGalNode) -> WebGalNode: + assert len(instrs) == 1 and isinstance(instrs[0], VNWeatherEffectInst) + self._webgal_unsupported_vn_effect(instrs[0]) + + def gen_character_sprite_move_wait(self, instrs: list[VNInstruction], insert_before: WebGalNode) -> WebGalNode: + assert len(instrs) == 2 and isinstance(instrs[1], VNCharacterSpriteMoveInst) + self._webgal_unsupported_vn_effect(instrs[1]) + + def gen_character_sprite_move_inst(self, instrs: list[VNInstruction], insert_before: WebGalNode) -> WebGalNode: + assert len(instrs) == 1 and isinstance(instrs[0], VNCharacterSpriteMoveInst) + self._webgal_unsupported_vn_effect(instrs[0]) + + def gen_char_tremble_effect_wait(self, instrs: list[VNInstruction], insert_before: WebGalNode) -> WebGalNode: + assert len(instrs) == 2 and isinstance(instrs[1], VNCharTrembleEffectInst) + self._webgal_unsupported_vn_effect(instrs[1]) + + def gen_char_tremble_effect_inst(self, instrs: list[VNInstruction], insert_before: WebGalNode) -> WebGalNode: + assert len(instrs) == 1 and isinstance(instrs[0], VNCharTrembleEffectInst) + self._webgal_unsupported_vn_effect(instrs[0]) + + def gen_filter_effect_wait(self, instrs: list[VNInstruction], insert_before: WebGalNode) -> WebGalNode: + assert len(instrs) == 2 and isinstance(instrs[1], VNFilterEffectInst) + self._webgal_unsupported_vn_effect(instrs[1]) + + def gen_filter_effect_inst(self, instrs: list[VNInstruction], insert_before: WebGalNode) -> WebGalNode: + assert len(instrs) == 1 and isinstance(instrs[0], VNFilterEffectInst) + self._webgal_unsupported_vn_effect(instrs[0]) + + def gen_end_effect_wait(self, instrs: list[VNInstruction], insert_before: WebGalNode) -> WebGalNode: + assert len(instrs) == 2 and isinstance(instrs[1], VNEndEffectInst) + self._webgal_unsupported_vn_effect(instrs[1]) + + def gen_end_effect_inst(self, instrs: list[VNInstruction], insert_before: WebGalNode) -> WebGalNode: + assert len(instrs) == 1 and isinstance(instrs[0], VNEndEffectInst) + self._webgal_unsupported_vn_effect(instrs[0]) + def _assign_block_labels(self, f : VNFunction) -> dict[Block | None, str]: # 为每个基本块分配一个标签,入口除外 # 由于 WebGal 没有 return 指令,为了实现返回,我们需要给返回指令预留一个标签,放在脚本的最后 diff --git a/src/preppipe/webgal/export.py b/src/preppipe/webgal/export.py index 3f81bb1..b440a2d 100644 --- a/src/preppipe/webgal/export.py +++ b/src/preppipe/webgal/export.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import io +import json from .ast import * from ..enginecommon.export import * @@ -243,6 +244,17 @@ def visitWebGalSetAnimationNode(self, node : WebGalSetAnimationNode): self.add_common_flags(result, node) self.dest.write(' '.join(result) + ';\n') + def visitWebGalSetTransformNode(self, node : WebGalSetTransformNode): + result = [ + 'setTransform:' + node.transform_json.get().get_string(), + '-target=' + node.target.get().get_string(), + '-duration=' + str(node.duration_ms.get().value), + ] + if self.test(node.flag_write_default): + result.append('-writeDefault') + self.add_common_flags(result, node) + self.dest.write(' '.join(result) + ';\n') + def visitWebGalSetTransitionNode(self, node : WebGalSetTransitionNode): result = ['setTransition:'] if target := node.target.try_get_value(): @@ -267,6 +279,41 @@ def visitWebGalPixiPerformNode(self, node : WebGalPixiPerformNode): def start_visit(self, file : WebGalScriptFileOp): self.walk_body(file.body) +def _merge_animation_table(out_path : str, new_names : list[str]) -> None: + table_path = os.path.join(out_path, 'game', 'animationTable.json') + existing : list[str] = [] + if os.path.isfile(table_path): + try: + with open(table_path, 'r', encoding='utf-8') as tf: + data = json.load(tf) + if isinstance(data, list): + existing = [str(x) for x in data] + except (json.JSONDecodeError, OSError): + existing = [] + merged = sorted(set(existing) | set(new_names)) + os.makedirs(os.path.dirname(table_path), exist_ok=True) + with open(table_path, 'w', encoding='utf-8') as tf: + json.dump(merged, tf, ensure_ascii=False) + +def _copy_preppipe_runtime(out_path : str) -> None: + """TODO(WebGAL): 与 `webgal/codegen.py` 中特效 IR 导出一并恢复;将包内 runtime 递归复制到 `game/prepipe/`。""" + pkg_rt = os.path.join(os.path.dirname(__file__), 'runtime') + if not os.path.isdir(pkg_rt): + return + dest_root = os.path.join(out_path, 'game', 'prepipe') + skip_dir_names = {'__pycache__'} + for root, dirnames, filenames in os.walk(pkg_rt): + dirnames[:] = [d for d in dirnames if d not in skip_dir_names] + rel = os.path.relpath(root, pkg_rt) + dest_dir = dest_root if rel == '.' else os.path.join(dest_root, rel) + os.makedirs(dest_dir, exist_ok=True) + for fname in filenames: + if fname.endswith('.pyc'): + continue + src_f = os.path.join(root, fname) + if os.path.isfile(src_f): + shutil.copy2(src_f, os.path.join(dest_dir, fname)) + def export_webgal(m : WebGalModel, out_path : str, template_dir : str = '') -> None: assert isinstance(m, WebGalModel) os.makedirs(out_path, exist_ok=True) @@ -274,7 +321,23 @@ def export_webgal(m : WebGalModel, out_path : str, template_dir : str = '') -> N if len(template_dir) > 0: shutil.copytree(template_dir, out_path, dirs_exist_ok=True) - # step 2: start walking all script files + # TODO(WebGAL): 特效等价导出启用后取消注释,随导出附带 game/prepipe/ 自定义脚本与说明。 + # _copy_preppipe_runtime(out_path) + + # step 2: data files (animation JSON 等) + generated_anim_names : list[str] = [] + for df in m.data_files(): + fp = os.path.join(out_path, df.name) + parentdir = os.path.dirname(fp) + os.makedirs(parentdir, exist_ok=True) + body = df.text_body.get().get_string() + with open(fp, 'w', encoding='utf-8') as wf: + wf.write(body) + norm = df.name.replace('\\', '/') + if norm.startswith('game/animation/') and norm.endswith('.json'): + generated_anim_names.append(os.path.splitext(os.path.basename(norm))[0]) + + # step 3: start walking all script files # apply the default transform to make output nicer for script in m.scripts(): scriptpath = os.path.join(out_path, script.name) @@ -284,5 +347,8 @@ def export_webgal(m : WebGalModel, out_path : str, template_dir : str = '') -> N exporter = WebGalExportVisitor(f) exporter.start_visit(script) + if generated_anim_names: + _merge_animation_table(out_path, generated_anim_names) + export_assets_and_cacheable(m, out_path=out_path) # done for now diff --git a/src/preppipe/webgal/runtime/prepipePerform.ts b/src/preppipe/webgal/runtime/prepipePerform.ts new file mode 100644 index 0000000..87cd3d7 --- /dev/null +++ b/src/preppipe/webgal/runtime/prepipePerform.ts @@ -0,0 +1,72 @@ +/** + * PrepPipe WebGAL 自定义 Pixi perform(骨架实现) + * + * TODO(WebGAL): 当前 `codegen_webgal` 对特效 IR 一律报错,导出不会自动复制本文件;待后端恢复后再启用 `export_webgal` 的 `_copy_preppipe_runtime`。 + * + * - 目标态:`export_webgal` 将本文件复制到输出工程的 `game/prepipe/prepipePerform.ts`。 + * - 官方 WebGAL 不会自动加载此路径;请把本文件合并进 WebGAL 源码 + * `Core/gameScripts/pixiPerformScripts/prepipePerform.ts`,并在 `index.ts` 中 `import './prepipePerform'`, + * 再执行 `yarn run build`。 + * + * 以下为类型占位;真实构建请使用 WebGAL 源码中的 registerPerform、PIXI、WebGAL 类型定义。 + */ +// import * as PIXI from 'pixi.js'; +// import { registerPerform } from '../pixiPerformManager'; + +declare const WebGAL: { + game: { + userData: Record; + }; +}; + +function readNum(key: string, def: number): number { + const v = WebGAL.game.userData[key]; + if (v === undefined || v === '') return def; + const n = Number(v); + return Number.isFinite(n) ? n : def; +} + +function readStr(key: string, def: string): string { + const v = WebGAL.game.userData[key]; + return v === undefined || v === '' ? def : String(v); +} + +/* +registerPerform('prepipeWeather', { + fg: () => { + const kind = readStr('prepipe_weather_kind', 'snow').toLowerCase(); + const intensity = readNum('prepipe_weather_intensity', 40); + void intensity; + void kind; + // TODO: 使用 PIXI 粒子系统实现 snow/rain,读取 vx/vy、fade 参数 + }, +}); + +registerPerform('prepipeCharTrembleLoop', { + fg: () => { + void readStr('prepipe_tremble_target', ''); + void readNum('prepipe_tremble_amp', 4); + void readNum('prepipe_tremble_period', 0.1); + // TODO: 按 target 在立绘容器上挂接往复位移,直至 pixiInit 或显式停止 + }, +}); + +registerPerform('prepipeCharTrembleStop', { + fg: () => { + void readStr('prepipe_tremble_target', ''); + // TODO: 移除该 target 上由 prepipeCharTrembleLoop 挂接的位移(无操作则安全返回) + }, +}); + +registerPerform('prepipeFlash', { + fg: () => { + void readStr('prepipe_flash_color', '#ffffff'); + void readNum('prepipe_flash_fi', 0.1); + void readNum('prepipe_flash_hold', 0.2); + void readNum('prepipe_flash_fo', 0.1); + // TODO: 全屏色块 alpha 动画,与 Ren'Py preppipe_flash_screen 时长一致 + }, +}); +*/ + +export {}; diff --git a/src/test.docx b/src/test.docx new file mode 100644 index 0000000..d203079 Binary files /dev/null and b/src/test.docx differ