├── test ├── app.py ├── .gitignore ├── nbs2save ├── gui │ ├── animations.py │ ├── coordinate_picker.py │ ├── widgets.py │ └── window.py └── core │ ├── config.py │ ├── constants.py │ ├── mcfunction.py │ ├── schematic.py │ ├── staircase_schematic.py │ └── core.py ├── cli.py ├── README.md ├── gui_functionality_test.py └── LICENSE /test: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | GUI入口 5 | ---------- 6 | 通过图形界面方式调用NBS转换工具,适用于普通用户操作 7 | """ 8 | 9 | import sys 10 | from PyQt6.QtWidgets import QApplication 11 | 12 | from nbs2save.gui.window import MainWindow 13 | 14 | if __name__ == "__main__": 15 | app = QApplication(sys.argv) 16 | window = MainWindow() 17 | window.show() 18 | sys.exit(app.exec()) 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | *$py.class 4 | 5 | *.so 6 | 7 | dist/ 8 | build/ 9 | *.egg-info/ 10 | *.egg 11 | 12 | *.manifest 13 | *.spec 14 | 15 | htmlcov/ 16 | .tox/ 17 | .nox/ 18 | .coverage 19 | .coverage.* 20 | .cache 21 | nosetests.xml 22 | coverage.xml 23 | *.cover 24 | .hypothesis/ 25 | .pytest_cache/ 26 | 27 | venv/ 28 | env/ 29 | ENV/ 30 | .env 31 | .venv 32 | env.bak/ 33 | venv.bak/ 34 | 35 | .idea/ 36 | .vscode/ 37 | *.swp 38 | *.swo 39 | *~ 40 | .spyderproject 41 | .spyproject 42 | .ropeproject 43 | 44 | .ipynb_checkpoints 45 | 46 | instance/ 47 | .webassets-cache 48 | local_settings.py 49 | 50 | *.log 51 | logs/ 52 | 53 | *.db 54 | *.sqlite3 55 | 56 | .DS_Store 57 | Thumbs.db 58 | ehthumbs.db 59 | Desktop.ini 60 | 61 | output/ 62 | temp/ 63 | *.nbs 64 | *.schem 65 | *.mcfunction 66 | last_config.json -------------------------------------------------------------------------------- /nbs2save/gui/animations.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtCore import ( 2 | QPropertyAnimation, QEasingCurve, QPoint, QObject, pyqtProperty, 3 | QPointF, QParallelAnimationGroup, QAbstractAnimation 4 | ) 5 | from PyQt6.QtWidgets import QWidget, QGraphicsItem, QGraphicsOpacityEffect 6 | from PyQt6.QtGui import QColor 7 | 8 | class AnimationUtils: 9 | """通用动画工具类""" 10 | 11 | @staticmethod 12 | def fade_in_entry(widget: QWidget, duration=500, scale=True): 13 | """ 14 | 窗口进入动画: 15 | 1. 透明度 0 -> 1 16 | 2. (可选) 缩放 0.95 -> 1.0 (模拟 Windows 11 弹窗效果) 17 | """ 18 | widget.setWindowOpacity(0) 19 | 20 | group = QParallelAnimationGroup(widget) 21 | 22 | # 透明度动画 23 | anim_opacity = QPropertyAnimation(widget, b"windowOpacity") 24 | anim_opacity.setStartValue(0) 25 | anim_opacity.setEndValue(1) 26 | anim_opacity.setDuration(duration) 27 | anim_opacity.setEasingCurve(QEasingCurve.Type.OutCubic) 28 | group.addAnimation(anim_opacity) 29 | 30 | if scale: 31 | # 需要在 resizeEvent 中处理 geometry,这里简化处理 32 | # 对于顶层窗口,直接做透明度通常最稳健,geometry 动画可能导致闪烁 33 | # 这里的 scale 预留给子控件使用 34 | pass 35 | 36 | group.start() 37 | 38 | class GraphicsItemAnimWrapper(QObject): 39 | """QGraphicsItem 的动画包装器""" 40 | def __init__(self, item: QGraphicsItem): 41 | super().__init__() 42 | self.item = item 43 | 44 | @pyqtProperty(QPointF) 45 | def pos(self): 46 | return self.item.pos() 47 | 48 | @pos.setter 49 | def pos(self, value): 50 | self.item.setPos(value) 51 | 52 | class ColorAnimWrapper(QObject): 53 | """ 54 | 用于为普通 QWidget 或 QPushButton 的背景色/前景色制作动画 55 | 需要配合 paintEvent 使用 56 | """ 57 | def __init__(self, parent): 58 | super().__init__(parent) 59 | self._color = QColor(0, 0, 0, 0) 60 | self.widget = parent 61 | 62 | @pyqtProperty(QColor) 63 | def color(self): 64 | return self._color 65 | 66 | @color.setter 67 | def color(self, value): 68 | self._color = value 69 | self.widget.update() # 触发重绘 -------------------------------------------------------------------------------- /cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 命令行入口 4 | ---------- 5 | 通过命令行方式调用NBS转换工具,适用于自动化处理场景 6 | """ 7 | 8 | import pynbs 9 | 10 | from nbs2save.core.config import GENERATE_CONFIG, GROUP_CONFIG 11 | from nbs2save.core.core import GroupProcessor 12 | from nbs2save.core.mcfunction import McFunctionOutputStrategy 13 | from nbs2save.core.schematic import SchematicOutputStrategy 14 | from nbs2save.core.staircase_schematic import StaircaseSchematicOutputStrategy # 新增导入 15 | 16 | # -------------------------- 17 | # 工具函数 18 | # -------------------------- 19 | def log(message: str): 20 | """简单的日志输出函数""" 21 | print(message) 22 | 23 | def progress(value: int): 24 | """进度显示函数""" 25 | print(f"进度: {value}%") 26 | 27 | # -------------------------- 28 | # 主处理类 29 | # -------------------------- 30 | class CLIProcessor(GroupProcessor): 31 | """CLI版本的轨道组处理器""" 32 | 33 | def __init__(self): 34 | # 读取NBS文件 35 | nbs = pynbs.read(GENERATE_CONFIG['input_file']) 36 | all_notes = nbs.notes 37 | global_max_tick = nbs.header.song_length 38 | 39 | # 调用父类初始化 40 | super().__init__(all_notes, global_max_tick, GENERATE_CONFIG, GROUP_CONFIG) 41 | 42 | # 注册回调 43 | self.set_log_callback(log) 44 | self.set_progress_callback(progress) 45 | 46 | # -------------------------- 47 | # 程序入口 48 | # -------------------------- 49 | if __name__ == "__main__": 50 | processor = CLIProcessor() 51 | 52 | # 根据配置选择输出策略 53 | output_type = GENERATE_CONFIG['type'] 54 | if output_type == 'mcfunction': 55 | processor.set_output_strategy(McFunctionOutputStrategy()) 56 | elif output_type == 'schematic': 57 | # 检查是否有轨道组使用阶梯模式 58 | use_staircase = any(config.get('generation_mode') == 'staircase' 59 | for config in GROUP_CONFIG.values()) 60 | if use_staircase: 61 | processor.set_output_strategy(StaircaseSchematicOutputStrategy()) 62 | else: 63 | processor.set_output_strategy(SchematicOutputStrategy()) 64 | else: 65 | raise ValueError(f"不支持的输出类型: {output_type}") 66 | 67 | # 执行处理 68 | processor.process() 69 | print("处理完成!") -------------------------------------------------------------------------------- /nbs2save/core/config.py: -------------------------------------------------------------------------------- 1 | from mcschematic import Version 2 | 3 | # ============================================================================== 4 | # NBS到Minecraft结构转换工具配置文件 5 | # ============================================================================== 6 | # 该文件定义了程序运行所需的基本配置参数,包括输入输出设置和轨道组配置 7 | # 用户可以根据需要修改这些参数来定制转换过程 8 | 9 | # -------------------------- 10 | # 全局生成配置 (字典格式) 11 | # -------------------------- 12 | # GENERATE_CONFIG 定义了程序的基本运行参数,控制整个转换过程的行为 13 | # 包括输入文件路径、输出格式、输出文件名等设置 14 | GENERATE_CONFIG = { 15 | # data_version: 指定生成的schematic文件的Minecraft版本 16 | # 这个参数只在输出格式为schematic时生效 17 | # 可选值参考 mcschematic.Version 枚举 18 | 'data_version': Version.JE_1_21_4, 19 | 20 | # input_file: 指定要转换的NBS文件路径 21 | # 程序将读取该文件并解析其中的音符信息 22 | 'input_file': 'test.nbs', 23 | 24 | # type: 指定输出格式类型 25 | # 可选值: 26 | # 'schematic' -> 生成WorldEdit格式的.schem文件 27 | # 'mcfunction' -> 生成Minecraft原版函数文件(.mcfunction) 28 | 'type': 'schematic', 29 | 30 | # output_file: 指定输出文件的名称(不包含扩展名) 31 | # 程序会根据type参数自动添加相应的扩展名 32 | # 例如: 如果type为'schematic'且output_file为'test',则生成'test.schem' 33 | 'output_file': 'test' 34 | } 35 | 36 | # -------------------------- 37 | # 轨道组配置 (字典格式) 38 | # -------------------------- 39 | # GROUP_CONFIG 定义了如何将NBS文件中的音符轨道分组以及每组的生成参数 40 | # 每个轨道组可以有不同的基准坐标、包含的轨道列表和方块配置 41 | # 键为轨道组ID(整数),值为该组的配置参数 42 | GROUP_CONFIG = { 43 | # 轨道组0的配置 44 | 0: { 45 | # base_coords: 轨道组的基准坐标 (x, y, z) 46 | # 这是该轨道组生成结构的起始坐标位置 47 | # 所有该组的音符都将基于这个坐标进行定位 48 | # 注意: 坐标值需要是字符串类型 49 | 'base_coords': ("0", "0", "0"), 50 | 51 | # layers: 该轨道组包含的轨道ID列表 52 | # NBS文件中的每个音符轨道都有一个唯一的ID 53 | # 通过这个列表可以指定哪些轨道属于当前轨道组 54 | 'layers': [0, 1,2,3,4,5,6], 55 | 56 | # block: 轨道组的方块配置 57 | # 定义生成结构时使用的方块类型 58 | 'block': { 59 | # base: 基础平台方块 60 | # 用于构建音符播放平台的基础方块 61 | 'base': 'minecraft:iron_block', 62 | 63 | # cover: 顶部覆盖方块 64 | # 用于覆盖在基础平台上方的方块,通常是红石相关结构 65 | 'cover': 'minecraft:iron_block' 66 | }, 67 | 68 | # generation_mode: 生成模式 69 | # 可选值: 70 | # 'default' -> 默认生成模式(当前schematic.py的实现) 71 | # 'staircase' -> 阶梯向下生成模式(偏移>=3时启用阶梯效果) 72 | 'generation_mode': 'default' 73 | }, 74 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NBS-to-minecraftsave 2 | 3 | 一个将NBS(Note Block Studio)音乐文件转换为Minecraft可用格式的工具,支持生成WorldEdit schematic文件和Minecraft数据包函数。 4 | ## 严禁使用本程序用于生成与《花之舞》(Flower Dance)有关的nbs!!!! 如需生成,请联系我获取授权。 5 | ## 严禁将本程序用于商业用途(如有需要需授权) 6 | 7 | ## 功能特点 8 | 9 | ### 已实现功能 10 | - 将NBS文件转换为**Minecraft数据包(.mcfunction)** 11 | - 将NBS文件转换为**WorldEdit schematic(.schem)文件** 12 | - 提供直观的GUI操作界面 13 | - 支持左右声相偏移设置 14 | - 支持多轨道组管理 15 | - 可设置偏移位置音符判断规则 16 | - 自由配置每个轨道组的基础方块、覆盖方块及坐标位置 17 | - 支持两种生成模式:默认模式和阶梯向下模式 18 | 19 | ### 待实现功能 20 | - 更完善的GUI选点功能 21 | 22 | ## 安装说明 23 | 24 | 1. 确保已安装Python 3.8及以上版本 25 | 2. 安装依赖库: 26 | ```bash 27 | pip install pynbs mcschematic PyQt6 28 | ``` 29 | 30 | ## 使用方法 31 | 32 | ### 基本流程 33 | 1. 运行程序: 34 | - GUI模式:`python app.py` 35 | - 命令行模式:`python cli.py`(需修改配置文件后使用) 36 | 37 | 2. GUI版本直接在GUI里按提示操作即可 38 | 命令行请修改config.py 39 | nbs里单个音符的左右声道偏移为音符相对于轨道组单直轨的左右偏移 40 | 举个例子我进入声道模式,对某个**音符**进行了声道设置,我设置的为左声道,20 41 | 那么程序生成结果如下(空方块为音符盒) 42 | ``` 43 | │ ■ 44 | │ ■ 45 | │ □ ■ ■ 46 | │ ■ ↑↑↑(中继器朝向) 47 | ``` 48 | 也就是会相对于主干结构的左边2格,生成音符盒 49 | 如果声道设置为30 那么会为左边三格,若为31,则会四舍五入为30,以此类推 50 | ⚠ nbs左边设置的,整个轨道的声像偏移,本程序无法识别 51 | 52 | 4. 输出文件使用: 53 | - **Schematic文件**:通过WorldEdit导入到游戏中 54 | - **mcfunction文件**: 55 | 1. 创建一个空的数据包 56 | 2. 将数据包解压到你的存档文件夹`save/存档名/datapack`下 57 | 3. 将生成的`.mcfunction`文件放入`functions`文件夹 58 | 4. 在游戏中使用`/function <命名空间>:<文件名>`命令执行(如`/function test:test`) 59 | 60 | ## 配置说明 61 | 62 | 配置文件位于`nbs2save/core/config.py`,可手动修改以下参数: 63 | - `GENERATE_CONFIG`:全局生成配置 64 | - `data_version`:Minecraft版本(如`Version.JE_1_21_4`) 65 | - `input_file`:输入NBS文件路径 66 | - `type`:输出类型(`schematic`或`mcfunction`) 67 | - `output_file`:输出文件名(不含扩展名) 68 | 69 | - `GROUP_CONFIG`:轨道组配置 70 | - `base_coords`:基准坐标(x, y, z) 71 | - `layers`:包含的轨道ID列表 72 | - `block`:方块配置(`base`基础方块,`cover`覆盖方块) 73 | - `generation_mode`:生成模式(`default`默认模式或`staircase`阶梯向下模式) 74 | 75 | ## 生成模式说明 76 | 77 | ### 默认模式(default) 78 | 在默认模式下,所有音符平台和红石线都保持在同一水平高度上,适用于大多数情况。 79 | 80 | ### 阶梯向下模式(staircase) 81 | 阶梯向下模式是一种增强的生成方式,当左右偏移大于等于3时,会启用阶梯效果: 82 | - 左右偏移>=3的音符平台将逐级向下阶梯式生成 83 | - 左右偏移为1、2的音符平台保持与默认模式一致 84 | 85 | 这种模式可以创建更立体的视觉效果,特别适用于大型音乐作品,能更好地展示音符的声像分布。 86 | 87 | ## 核心变量说明 88 | 89 | 在本项目中,有几个关键变量用于确定音符在Minecraft世界中的位置: 90 | 91 | ### tick_x 变量 92 | `tick_x` 是用于计算音符在Minecraft世界中X坐标位置的变量。它的计算公式是: 93 | ```python 94 | tick_x = self.base_x + tick * 2 95 | ``` 96 | 97 | 这个变量的含义是: 98 | - `self.base_x`:轨道组的基准X坐标,即整个轨道组在Minecraft世界中的起始X位置 99 | - `tick`:当前音符的时间点(以游戏刻度为单位) 100 | - `tick * 2`:每个时间点(tick)在X轴上占用2格空间,这样设计是为了给每个音符留出足够的空间放置红石中继器等组件 101 | 102 | 因此,`tick_x` 表示在特定时间点(tick)上,音符应该放置的X坐标位置。 103 | 104 | ### 其他相关变量 105 | - `base_x, base_y, base_z`:轨道组的基准坐标,定义了整个轨道组在Minecraft世界中的起始位置 106 | - `pan_offset`:声像偏移量,表示音符在Z轴上的偏移,用于实现立体声效果 107 | - `z_pos`:音符在Z轴上的最终位置,计算公式为 `z_pos = self.base_z + pan_offset` 108 | - `cover_block`:覆盖方块,通常用于隐藏红石线路 109 | - `base_block`:基础方块,构成平台的主体部分 110 | 111 | ## 工作原理 112 | 113 | 在Minecraft中播放音乐时,需要按照时间顺序激活音符盒。这个系统通过以下方式工作: 114 | 115 | 1. 每个tick(游戏刻)在X轴方向上占据2格空间 116 | 2. 在每个tick位置上,构建一个基础结构,包括: 117 | - 红石中继器(用于时钟信号) 118 | - 覆盖方块和基础方块(构成平台) 119 | 3. 根据音符的声像偏移(panning)在Z轴方向上进行偏移 120 | 4. 当多个音符在同一tick但不同声像位置时,会生成延伸的平台 121 | 122 | 这种设计使得音乐可以在Minecraft中以可视化的方式播放,每个音符在正确的时间和位置被激活。 123 | 124 | ## 许可证 125 | 126 | 本项目采用Apache License 2.0开源许可证,详情见[LICENSE](LICENSE)文件了解详情。 127 | 128 | ## 注意事项 129 | 130 | - 小白小白版用户建议等待后续简化版本(已经有了,GUI,但是不太好用) 131 | - 生成大型音乐文件可能需要较长时间(毕竟是一个一个音符来生成,大型推荐使用schem) 132 | - 使用前请确保已备份你的Minecraft存档 133 | 134 | ## 反馈与贡献 135 | 136 | 欢迎提交issue和PR来帮助改进这个项目!由于作者是新人,代码可能存在不足,敬请谅解。 137 | -------------------------------------------------------------------------------- /nbs2save/core/constants.py: -------------------------------------------------------------------------------- 1 | from mcschematic import Version 2 | 3 | # ============================================================================== 4 | # NBS到Minecraft结构转换工具常量定义文件 5 | # ============================================================================== 6 | # 该文件定义了程序运行所需的各种常量映射表和配置参数 7 | # 包括乐器映射、音高映射、支持的Minecraft版本等 8 | # 通常情况下这些参数无需修改,除非需要添加自定义音色或支持新版本 9 | 10 | # -------------------------- 11 | # 常量映射表 (通常无需修改) 12 | # -------------------------- 13 | # 除非你要加一些自定义音色,那就在下面的INSTRUMENT_MAPPING和INSTRUMENT_BLOCK_MAPPING中添加上你需要的音色和对应方块 14 | 15 | # -------------------------- 16 | # 乐器到音符盒音色映射 17 | # -------------------------- 18 | # INSTRUMENT_MAPPING 定义了NBS中的乐器ID到Minecraft音符盒instrument值的映射关系 19 | # NBS文件中的每个音符都有一个instrument属性,表示该音符使用的乐器类型 20 | # Minecraft中的音符盒方块也有对应的instrument属性,用于指定播放的音色 21 | # 该映射表确保NBS中的乐器能够正确转换为Minecraft中的对应音色 22 | # 23 | # 映射说明: 24 | # 0 -> "harp" 钢琴(竖琴)音色,Minecraft默认音色 25 | # 1 -> "bass" 贝斯音色 26 | # 2 -> "basedrum" 底鼓音色 27 | # 3 -> "snare" 小军鼓音色 28 | # 4 -> "hat" 铜钹音色 29 | # 5 -> "guitar" 吉他音色 30 | # 6 -> "flute" 长笛音色 31 | # 7 -> "bell" 钟琴音色 32 | # 8 -> "chime" 风铃音色 33 | # 9 -> "xylophone" 木琴音色 34 | # 10 -> "iron_xylophone" 铁木琴音色 35 | # 11 -> "cow_bell" 牛铃音色 36 | # 12 -> "didgeridoo" 迪吉里杜管音色 37 | # 13 -> "bit" 比特音色 38 | # 14 -> "banjo" 班卓琴音色 39 | # 15 -> "pling" 电钢琴音色 40 | INSTRUMENT_MAPPING = { 41 | 0: "harp", 1: "bass", 2: "basedrum", 3: "snare", 4: "hat", 42 | 5: "guitar", 6: "flute", 7: "bell", 8: "chime", 9: "xylophone", 43 | 10: "iron_xylophone", 11: "cow_bell", 12: "didgeridoo", 13: "bit", 44 | 14: "banjo", 15: "pling" 45 | } 46 | 47 | # -------------------------- 48 | # 乐器对应下方块类型映射 49 | # -------------------------- 50 | # INSTRUMENT_BLOCK_MAPPING 定义了不同乐器需要放置在什么方块上才能发出对应音色 51 | # 在Minecraft中,音符盒播放的声音会根据其下方方块的类型而改变音色 52 | # 该映射表确保每种乐器都能放置在正确的方块上以产生预期的音色效果 53 | # 54 | # 映射说明: 55 | # 0 -> "minecraft:dirt" 钢琴(竖琴)音色对应泥土方块 56 | # 1 -> "minecraft:oak_planks" 贝斯音色对应橡木木板 57 | # 2 -> "minecraft:stone" 底鼓音色对应石头方块 58 | # 3 -> "minecraft:sand" 小军鼓音色对应沙子方块 59 | # 4 -> "minecraft:glass" 铜钹音色对应玻璃方块 60 | # 5 -> "minecraft:white_wool" 吉他音色对应白色羊毛方块 61 | # 6 -> "minecraft:clay" 长笛音色对应粘土方块 62 | # 7 -> "minecraft:gold_block" 钟琴音色对应金块 63 | # 8 -> "minecraft:packed_ice" 风铃音色对应浮冰方块 64 | # 9 -> "minecraft:bone_block" 木琴音色对应骨块 65 | # 10 -> "minecraft:iron_block" 铁木琴音色对应铁块 66 | # 11 -> "minecraft:soul_sand" 牛铃音色对应灵魂沙方块 67 | # 12 -> "minecraft:pumpkin" 迪吉里杜管音色对应南瓜方块 68 | # 13 -> "minecraft:emerald_block" 比特音色对应绿宝石块 69 | # 14 -> "minecraft:hay_block" 班卓琴音色对应干草块 70 | # 15 -> "minecraft:glowstone" 电钢琴音色对应荧石方块 71 | INSTRUMENT_BLOCK_MAPPING = { 72 | 0: "minecraft:dirt", 1: "minecraft:oak_planks", 2: "minecraft:stone", 3: "minecraft:sand", 73 | 4: "minecraft:glass", 5: "minecraft:white_wool", 6: "minecraft:clay", 7: "minecraft:gold_block", 74 | 8: "minecraft:packed_ice", 9: "minecraft:bone_block", 10: "minecraft:iron_block", 11: "minecraft:soul_sand", 75 | 12: "minecraft:pumpkin", 13: "minecraft:emerald_block", 14: "minecraft:hay_block", 15: "minecraft:glowstone" 76 | } 77 | 78 | # -------------------------- 79 | # 音高映射表 (MIDI键到游戏音高) 80 | # -------------------------- 81 | # NOTEPITCH_MAPPING 定义了NBS中的MIDI键值到Minecraft音符盒音高值的映射关系 82 | # NBS文件中的每个音符都有一个key属性,表示该音符的音高(MIDI键值) 83 | # Minecraft中的音符盒方块有note属性,用于指定播放的音高(0-24) 84 | # 该映射表将MIDI键值(33-57)映射到Minecraft音高值(0-24) 85 | # 86 | # MIDI键值范围说明: 87 | # - MIDI标准中,中央C的键值为60 88 | # - NBS文件中常用的键值范围大约在33-57之间 89 | # - Minecraft音符盒支持的音高范围为0-24(对应F#3到F#5) 90 | # 91 | # 映射逻辑: 92 | # 将MIDI键值33-57映射到Minecraft音高值0-24 93 | # 即: 音高值 = MIDI键值 - 33 94 | NOTEPITCH_MAPPING = {k: str(v) for v, k in enumerate(range(33, 58))} 95 | 96 | 97 | # -------------------------- 98 | # Minecraft版本列表 99 | # -------------------------- 100 | # MINECRAFT_VERSIONS 定义了程序支持的Minecraft Java Edition版本列表 101 | # 用于生成schematic文件时指定目标Minecraft版本 102 | # 版本按从新到旧的顺序排列,确保兼容性 103 | # 当生成schematic文件时,程序会使用配置中指定的版本 104 | MINECRAFT_VERSIONS = [ 105 | Version.JE_1_21_5, 106 | Version.JE_1_21_4, 107 | Version.JE_1_21_3, 108 | Version.JE_1_21_2, 109 | Version.JE_1_21_1, 110 | Version.JE_1_21, 111 | Version.JE_1_20_6, 112 | Version.JE_1_20_5, 113 | Version.JE_1_20_4, 114 | Version.JE_1_20_3, 115 | Version.JE_1_20_2, 116 | Version.JE_1_20_1, 117 | Version.JE_1_20, 118 | Version.JE_1_19_4, 119 | Version.JE_1_19_3, 120 | Version.JE_1_19_2, 121 | Version.JE_1_19_1, 122 | Version.JE_1_19, 123 | Version.JE_1_18_2, 124 | Version.JE_1_18_1, 125 | Version.JE_1_18, 126 | Version.JE_1_17_1, 127 | Version.JE_1_17, 128 | Version.JE_1_16_5, 129 | Version.JE_1_16_4, 130 | Version.JE_1_16_3, 131 | Version.JE_1_16_2, 132 | Version.JE_1_16_1, 133 | Version.JE_1_16, 134 | Version.JE_1_15_2, 135 | Version.JE_1_15_1, 136 | Version.JE_1_15, 137 | Version.JE_1_14_4, 138 | Version.JE_1_14_3, 139 | Version.JE_1_14_2, 140 | Version.JE_1_14_1, 141 | Version.JE_1_14, 142 | Version.JE_1_13_2 143 | ] -------------------------------------------------------------------------------- /nbs2save/core/mcfunction.py: -------------------------------------------------------------------------------- 1 | """ 2 | Minecraft函数文件生成器 3 | ---------------------- 4 | 负责将Note列表转换成Minecraft .mcfunction命令文件。 5 | 6 | 主要流程 7 | 1. 根据group_config把Note划分到不同轨道组。 8 | 2. 每个轨道组内部: 9 | 2.1 生成tick级基础结构(时钟、走线)。 10 | 2.2 根据panning生成左右声像平台。 11 | 2.3 在准确坐标生成音符方块及其基座。 12 | 3. 输出为.mcfunction命令文件 13 | """ 14 | 15 | from __future__ import annotations 16 | 17 | from typing import Dict, List 18 | 19 | from pynbs import Note 20 | 21 | from .core import GroupProcessor, OutputFormatStrategy 22 | from .constants import INSTRUMENT_MAPPING, INSTRUMENT_BLOCK_MAPPING, NOTEPITCH_MAPPING 23 | 24 | # -------------------------- 25 | # 命令文件生成策略 26 | # -------------------------- 27 | class McFunctionOutputStrategy(OutputFormatStrategy): 28 | """输出为 .mcfunction 命令文件的策略实现。""" 29 | 30 | def __init__(self): 31 | self.commands = [] # 存储生成的命令 32 | 33 | def initialize(self, processor: GroupProcessor): 34 | """ 35 | 初始化输出格式,清空命令列表 36 | 37 | 参数: 38 | processor: GroupProcessor实例 39 | """ 40 | self.commands = [] 41 | # 清空输出文件 42 | output_file = processor.config["output_file"] + ".mcfunction" 43 | with open(output_file, "w", encoding="utf-8") as f: 44 | f.write("") # 创建空文件或清空已有文件 45 | 46 | def write_base_structures(self, processor: GroupProcessor, tick: int): 47 | """ 48 | 写入基础结构 49 | 50 | 参数: 51 | processor: GroupProcessor实例 52 | tick: 当前tick 53 | """ 54 | # 计算当前tick在X轴上的位置(每个tick占2格) 55 | tick_x = processor.base_x + tick * 2 56 | commands = [ 57 | f"setblock {tick_x} {processor.base_y} {processor.base_z} {processor.cover_block}", 58 | f"setblock {tick_x} {processor.base_y - 1} {processor.base_z} {processor.base_block}", 59 | f"setblock {tick_x - 1} {processor.base_y} {processor.base_z} minecraft:repeater[delay=1,facing=west]", 60 | f"setblock {tick_x - 1} {processor.base_y - 1} {processor.base_z} {processor.base_block}", 61 | ] 62 | self._write_commands(processor, commands) 63 | 64 | def write_pan_platform(self, processor: GroupProcessor, tick: int, direction: int): 65 | """ 66 | 写入声像平台 67 | 68 | 参数: 69 | processor: GroupProcessor实例 70 | tick: 当前tick 71 | direction: 方向(1=右,-1=左) 72 | """ 73 | # 检查该tick该方向的平台是否已生成 74 | if processor.tick_status[tick]["right" if direction == 1 else "left"]: 75 | return # 已生成 76 | 77 | # 获取该方向上的最大偏移量 78 | max_pan_offset = processor._get_max_pan(processor.notes, tick, direction) 79 | if max_pan_offset == 0: 80 | return 81 | 82 | # 计算平台的起始和结束坐标 83 | tick_x = processor.base_x + tick * 2 84 | platform_start_z = processor.get_platform_start_z() # 平台起始Z坐标(主干道) 85 | platform_end_z = processor.calculate_platform_end_z(max_pan_offset, direction) 86 | 87 | # 生成平台基础结构命令 88 | platform_commands = [ 89 | f"fill {tick_x} {processor.base_y - 1} {platform_start_z} {tick_x} {processor.base_y - 1} {platform_end_z} {processor.base_block}", 90 | f"setblock {tick_x} {processor.base_y} {platform_start_z} {processor.cover_block}", 91 | ] 92 | 93 | # 如果偏移量大于1,需要铺设红石线连接 94 | if abs(max_pan_offset) > 1: 95 | # 红石线的起始位置应该是从主干道旁边开始 96 | wire_start_z = processor.get_wire_start_z(direction) 97 | wire_end_z = platform_end_z 98 | platform_commands.append( 99 | f"fill {tick_x} {processor.base_y} {wire_start_z} {tick_x} {processor.base_y} {wire_end_z} " 100 | "minecraft:redstone_wire[north=side,south=side]" 101 | ) 102 | 103 | self._write_commands(processor, platform_commands) 104 | processor.tick_status[tick]["right" if direction == 1 else "left"] = True 105 | 106 | def write_note(self, processor: GroupProcessor, note: Note): 107 | """ 108 | 写入音符 109 | 110 | 参数: 111 | processor: GroupProcessor实例 112 | note: 要写入的音符 113 | """ 114 | # 计算音符的位置坐标 115 | tick_x, y, z_pos = processor.get_note_position(note) 116 | # 获取音符方块的信息 117 | instrument, base_block, note_pitch = self.get_note_block_info(note) 118 | 119 | # 生成音符方块和基座方块的命令 120 | commands = [ 121 | f"setblock {tick_x} {y} {z_pos} note_block[note={note_pitch},instrument={instrument}]", 122 | f"setblock {tick_x} {y - 1} {z_pos} {base_block}", 123 | ] 124 | 125 | # 如果基座是沙子类方块,需要在下方添加屏障防止掉落 126 | if self.is_sand_block(base_block): 127 | commands.append(f"setblock {tick_x} {y - 2} {z_pos} barrier") 128 | 129 | self._write_commands(processor, commands) 130 | 131 | def finalize(self, processor: GroupProcessor): 132 | """ 133 | 完成输出,将所有命令写入文件 134 | 135 | 参数: 136 | processor: GroupProcessor实例 137 | """ 138 | output_file = processor.config["output_file"] + ".mcfunction" 139 | with open(output_file, "a", encoding="utf-8") as f: 140 | f.write("\n".join(self.commands) + "\n\n") 141 | 142 | def _write_commands(self, processor: GroupProcessor, commands: List[str]): 143 | """ 144 | 将命令添加到命令列表中 145 | 146 | 参数: 147 | processor: GroupProcessor实例 148 | commands: 要添加的命令列表 149 | """ 150 | self.commands.extend(commands) 151 | 152 | # ---------------------- 153 | # 工具方法 154 | # ---------------------- 155 | @staticmethod 156 | def get_note_block_info(note: Note): 157 | """根据 instrument 获取音符方块属性。""" 158 | instrument = INSTRUMENT_MAPPING.get(note.instrument, "harp") 159 | base_block = INSTRUMENT_BLOCK_MAPPING.get(note.instrument, "minecraft:stone") 160 | note_pitch = NOTEPITCH_MAPPING.get(note.key, "0") 161 | return instrument, base_block, note_pitch 162 | 163 | @staticmethod 164 | def is_sand_block(block: str) -> bool: 165 | """简单规则:以 'sand' 结尾即视为沙子类方块。""" 166 | return block.endswith("sand") 167 | 168 | 169 | # -------------------------- 170 | # 兼容性类(为了保持向后兼容) 171 | # -------------------------- 172 | class McFunctionProcessor(GroupProcessor): 173 | """向后兼容的 McFunctionProcessor 类。""" 174 | 175 | def __init__(self, all_notes, global_max_tick, config, group_config): 176 | super().__init__(all_notes, global_max_tick, config, group_config) 177 | self.set_output_strategy(McFunctionOutputStrategy()) 178 | 179 | def _generate_base_structures(self, tick: int): 180 | """向后兼容的方法。""" 181 | pass 182 | 183 | def _generate_pan_platform(self, tick: int, direction: int): 184 | """向后兼容的方法。""" 185 | pass 186 | 187 | def _generate_note(self, note: Note): 188 | """向后兼容的方法。""" 189 | pass 190 | 191 | def _write(self, commands: List[str]): 192 | """向后兼容的方法。""" 193 | pass -------------------------------------------------------------------------------- /nbs2save/core/schematic.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Minecraft结构文件生成器 4 | ---------------------- 5 | 负责将Note列表转换成Minecraft .schem结构文件。 6 | 7 | 主要流程 8 | 1. 根据group_config把Note划分到不同轨道组。 9 | 2. 每个轨道组内部: 10 | 2.1 生成tick级基础结构(时钟、走线)。 11 | 2.2 根据panning生成左右声像平台。 12 | 2.3 在准确坐标生成音符方块及其基座。 13 | 3. 输出为.schem结构文件 14 | """ 15 | 16 | from __future__ import annotations 17 | 18 | from typing import Dict, List 19 | 20 | from mcschematic import MCSchematic 21 | from collections import defaultdict 22 | from pynbs import Note 23 | 24 | from .constants import INSTRUMENT_MAPPING, INSTRUMENT_BLOCK_MAPPING, NOTEPITCH_MAPPING 25 | from .config import GENERATE_CONFIG, GROUP_CONFIG 26 | from .core import GroupProcessor, OutputFormatStrategy 27 | 28 | # -------------------------- 29 | # 结构文件生成策略 30 | # -------------------------- 31 | class SchematicOutputStrategy(OutputFormatStrategy): 32 | """输出为 .schem 结构文件的策略实现。""" 33 | 34 | def __init__(self): 35 | self.schem: MCSchematic = None # 内存中的结构对象 36 | 37 | def initialize(self, processor: GroupProcessor): 38 | """ 39 | 初始化输出格式 40 | 41 | 参数: 42 | processor: GroupProcessor实例 43 | """ 44 | self.schem = MCSchematic() # 重新初始化 45 | # 验证配置 46 | self.validate_config(processor) 47 | 48 | def write_base_structures(self, processor: GroupProcessor, tick: int): 49 | """ 50 | 写入基础结构 51 | 52 | 参数: 53 | processor: GroupProcessor实例 54 | tick: 当前tick 55 | """ 56 | # 计算当前tick在X轴上的位置(每个tick占2格) 57 | tick_x = processor.base_x + tick * 2 58 | # 设置基础平台方块 59 | self.schem.setBlock((tick_x, processor.base_y, processor.base_z), processor.cover_block) 60 | self.schem.setBlock((tick_x, processor.base_y - 1, processor.base_z), processor.base_block) 61 | # 设置红石中继器(用于时钟信号) 62 | self.schem.setBlock((tick_x - 1, processor.base_y, processor.base_z), "minecraft:repeater[delay=1,facing=west]") 63 | self.schem.setBlock((tick_x - 1, processor.base_y - 1, processor.base_z), processor.base_block) 64 | 65 | def write_pan_platform(self, processor: GroupProcessor, tick: int, direction: int): 66 | """ 67 | 写入声像平台 68 | 69 | 参数: 70 | processor: GroupProcessor实例 71 | tick: 当前tick 72 | direction: 方向(1=右,-1=左) 73 | """ 74 | # 检查该tick该方向的平台是否已生成 75 | if processor.tick_status[tick]["right" if direction == 1 else "left"]: 76 | return 77 | 78 | # 获取该方向上的最大偏移量 79 | max_pan_offset = processor._get_max_pan(processor.notes, tick, direction) 80 | if max_pan_offset == 0: 81 | return 82 | 83 | # 计算平台的起始和结束坐标 84 | tick_x = processor.base_x + tick * 2 85 | platform_start_z = processor.get_platform_start_z() # 平台起始Z坐标(主干道) 86 | platform_end_z = processor.calculate_platform_end_z(max_pan_offset, direction) 87 | step = 1 if direction == 1 else -1 88 | 89 | # 生成平台基座方块 90 | for z in range(platform_start_z, platform_end_z + step, step): 91 | self.schem.setBlock((tick_x, processor.base_y - 1, z), processor.base_block) 92 | 93 | # 在主干道位置放置覆盖方块 94 | self.schem.setBlock((tick_x, processor.base_y, platform_start_z), processor.cover_block) 95 | 96 | # 如果偏移量大于1,需要铺设红石线连接 97 | if abs(max_pan_offset) > 1: 98 | # 红石线的起始位置应该是从主干道旁边开始 99 | wire_start_z = processor.get_wire_start_z(direction) 100 | wire_end_z = platform_end_z 101 | for z in range(wire_start_z, wire_end_z + step, step): 102 | self.schem.setBlock( 103 | (tick_x, processor.base_y, z), 104 | "minecraft:redstone_wire[north=side,south=side]" 105 | ) 106 | 107 | processor.tick_status[tick]["right" if direction == 1 else "left"] = True 108 | 109 | def write_note(self, processor: GroupProcessor, note: Note): 110 | """ 111 | 写入音符 112 | 113 | 参数: 114 | processor: GroupProcessor实例 115 | note: 要写入的音符 116 | """ 117 | # 计算音符的位置坐标 118 | tick_x, y, z_pos = processor.get_note_position(note) 119 | # 获取音符方块的信息 120 | instrument, base_block, note_pitch = self.get_note_block_info(note) 121 | 122 | # 设置音符方块 123 | self.schem.setBlock( 124 | (tick_x, y, z_pos), 125 | f"minecraft:note_block[note={note_pitch},instrument={instrument}]" 126 | ) 127 | # 设置基座方块 128 | self.schem.setBlock((tick_x, y - 1, z_pos), base_block) 129 | 130 | # 如果基座是沙子类方块,需要在下方添加屏障防止掉落 131 | if self.is_sand_block(base_block): 132 | self.schem.setBlock((tick_x, y - 2, z_pos), "minecraft:barrier") 133 | 134 | def finalize(self, processor: GroupProcessor): 135 | """ 136 | 完成输出,保存结构文件 137 | 138 | 参数: 139 | processor: GroupProcessor实例 140 | """ 141 | path = processor.config["output_file"] 142 | # 保存到本地 .schem 143 | self.schem.save(".", path.rsplit("/", 1)[-1], processor.config["data_version"]) 144 | 145 | # ---------------------- 146 | # 工具方法 147 | # ---------------------- 148 | @staticmethod 149 | def get_note_block_info(note: Note): 150 | """根据 instrument 获取音符方块属性。""" 151 | instrument = INSTRUMENT_MAPPING.get(note.instrument, "harp") 152 | base_block = INSTRUMENT_BLOCK_MAPPING.get(note.instrument, "minecraft:stone") 153 | note_pitch = NOTEPITCH_MAPPING.get(note.key, "0") 154 | return instrument, base_block, note_pitch 155 | 156 | @staticmethod 157 | def is_sand_block(block: str) -> bool: 158 | """简单规则:以 'sand' 结尾即视为沙子类方块。""" 159 | return block.endswith("sand") 160 | 161 | @staticmethod 162 | def validate_config(processor: GroupProcessor): 163 | """确保 config 包含必需的键。""" 164 | required_keys = ["output_file", "data_version"] 165 | for key in required_keys: 166 | if key not in processor.config: 167 | raise ValueError(f"配置缺失: {key}") 168 | 169 | 170 | # -------------------------- 171 | # 兼容性类(为了保持向后兼容) 172 | # -------------------------- 173 | class SchematicProcessor(GroupProcessor): 174 | """向后兼容的 SchematicProcessor 类。""" 175 | 176 | def __init__(self, all_notes, global_max_tick, config, group_config): 177 | super().__init__(all_notes, global_max_tick, config, group_config) 178 | self.set_output_strategy(SchematicOutputStrategy()) 179 | 180 | def _generate_base_structures(self, tick: int): 181 | """向后兼容的方法。""" 182 | pass 183 | 184 | def _generate_pan_platform(self, tick: int, direction: int): 185 | """向后兼容的方法。""" 186 | pass 187 | 188 | def _generate_note(self, note: Note): 189 | """向后兼容的方法。""" 190 | pass 191 | 192 | def _write(self, commands: List[str]): 193 | """向后兼容的方法。""" 194 | pass 195 | 196 | def process(self): 197 | """前置校验 + 父类流程 + 最终保存。""" 198 | # 验证配置 199 | SchematicOutputStrategy.validate_config(self) 200 | super().process() -------------------------------------------------------------------------------- /gui_functionality_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | GUI功能完整性测试脚本 4 | 测试GUI的各项核心功能是否正常工作 5 | 该脚本使用ai辅助编写,测试窗口初始化、基本输入字段、轨道组设置、生成模式选择、输出目录选择、转换按钮点击等核心功能。 6 | """ 7 | 8 | import os 9 | import sys 10 | import unittest 11 | from unittest.mock import Mock, patch 12 | import tempfile 13 | import time 14 | 15 | # 添加项目路径 16 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) 17 | 18 | from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton, QLineEdit, QComboBox, QTableWidget, QFileDialog 19 | from PyQt6.QtCore import Qt 20 | from PyQt6.QtTest import QTest 21 | 22 | from nbs2save.gui.window import MainWindow 23 | 24 | 25 | class GUI_Functionality_Test(unittest.TestCase): 26 | """GUI功能测试类""" 27 | 28 | @classmethod 29 | def setUpClass(cls): 30 | """测试类初始化""" 31 | if not QApplication.instance(): 32 | cls.app = QApplication([]) 33 | else: 34 | cls.app = QApplication.instance() 35 | cls.window = MainWindow() 36 | cls.window.show() 37 | QApplication.processEvents() 38 | time.sleep(0.1) # 确保窗口完全加载 39 | 40 | @classmethod 41 | def tearDownClass(cls): 42 | """测试类清理""" 43 | cls.window.close() 44 | QApplication.processEvents() 45 | 46 | def test_01_window_initialization(self): 47 | """测试窗口初始化""" 48 | print("🧪 测试窗口初始化...") 49 | 50 | # 检查窗口标题 51 | self.assertIn("NBS", self.window.windowTitle()) 52 | 53 | # 检查主要控件是否存在(通过类型检查) 54 | self.assertGreater(len(self.window.findChildren(QLineEdit)), 0, "应该存在输入框控件") 55 | self.assertGreater(len(self.window.findChildren(QComboBox)), 0, "应该存在下拉框控件") 56 | self.assertGreater(len(self.window.findChildren(QTableWidget)), 0, "应该存在表格控件") 57 | 58 | print("✅ 窗口初始化测试通过") 59 | 60 | def test_02_basic_input_fields(self): 61 | """测试基本输入字段""" 62 | print("🧪 测试基本输入字段...") 63 | 64 | # 测试输入文件编辑框 65 | input_edit = self.window.findChild(QLineEdit) 66 | if input_edit: 67 | input_edit.setText("test_nbs_file.nbs") 68 | self.assertEqual(input_edit.text(), "test_nbs_file.nbs") 69 | 70 | # 测试基础方块输入框(现在在轨道组表格中) 71 | groups_table = getattr(self.window, 'groups_table', None) 72 | if groups_table and groups_table.rowCount() > 0: 73 | # 在表格中设置基础方块 74 | base_block_item = groups_table.item(0, 5) # 基础方块列 75 | if base_block_item: 76 | base_block_item.setText("minecraft:diamond_block") 77 | self.assertEqual(base_block_item.text(), "minecraft:diamond_block") 78 | 79 | print("✅ 基本输入字段测试通过") 80 | 81 | def test_03_combobox_functionality(self): 82 | """测试下拉框功能""" 83 | print("🧪 测试下拉框功能...") 84 | 85 | # 测试版本下拉框 86 | version_combo = getattr(self.window, 'version_combo', None) 87 | if version_combo: 88 | original_index = version_combo.currentIndex() 89 | version_combo.setCurrentIndex(1) 90 | self.assertNotEqual(version_combo.currentIndex(), original_index) 91 | 92 | # 测试输出类型下拉框 93 | type_combo = getattr(self.window, 'type_combo', None) 94 | if type_combo: 95 | self.assertTrue(type_combo.count() >= 1) 96 | 97 | print("✅ 下拉框功能测试通过") 98 | 99 | def test_04_table_operations(self): 100 | """测试表格操作""" 101 | print("🧪 测试表格操作...") 102 | 103 | # 获取轨道组表格 104 | groups_table = getattr(self.window, 'groups_table', None) 105 | if groups_table: 106 | # 检查表格行数 107 | initial_rows = groups_table.rowCount() 108 | print(f" 初始表格行数: {initial_rows}") 109 | 110 | # 检查表格列数 111 | self.assertEqual(groups_table.columnCount(), 8) 112 | 113 | # 检查表头 114 | headers = [groups_table.horizontalHeaderItem(i).text() 115 | for i in range(groups_table.columnCount())] 116 | expected_headers = ["ID", "基准X", "基准Y", "基准Z", "轨道ID", "基础方块", "覆盖方块", "生成模式"] 117 | self.assertEqual(headers, expected_headers) 118 | 119 | print("✅ 表格操作测试通过") 120 | 121 | def test_05_button_functionality(self): 122 | """测试按钮功能""" 123 | print("🧪 测试按钮功能...") 124 | 125 | # 测试各种按钮对象是否存在 126 | buttons = { 127 | 'runButton': self.window.findChild(QPushButton, "runButton"), 128 | 'saveButton': self.window.findChild(QPushButton, "saveButton"), 129 | 'loadButton': self.window.findChild(QPushButton, "loadButton"), 130 | 'exitButton': self.window.findChild(QPushButton, "exitButton") 131 | } 132 | 133 | for btn_name, btn in buttons.items(): 134 | if btn: 135 | print(f" ✅ 找到按钮: {btn_name}") 136 | else: 137 | print(f" ⚠️ 按钮未找到: {btn_name}") 138 | 139 | print("✅ 按钮功能测试通过") 140 | 141 | def test_06_file_browsing_simulation(self): 142 | """模拟测试文件浏览功能""" 143 | print("🧪 模拟文件浏览功能...") 144 | 145 | # 模拟文件对话框 146 | with patch('PyQt6.QtWidgets.QFileDialog.getOpenFileName') as mock_open: 147 | mock_open.return_value = ("test.nbs", "NBS Files (*.nbs)") 148 | 149 | # 测试输入文件浏览 150 | input_edit = self.window.findChild(QLineEdit) 151 | if input_edit: 152 | self.window.browse_input_file() 153 | mock_open.assert_called_once() 154 | 155 | print("✅ 文件浏览功能测试通过") 156 | 157 | def test_07_status_bar_functionality(self): 158 | """测试状态栏功能""" 159 | print("🧪 测试状态栏功能...") 160 | 161 | # 检查状态栏是否存在 162 | status_bar = self.window.statusBar() 163 | self.assertIsNotNone(status_bar) 164 | 165 | # 测试状态栏消息显示 166 | test_message = "测试状态消息" 167 | status_bar.showMessage(test_message) 168 | QApplication.processEvents() 169 | 170 | print("✅ 状态栏功能测试通过") 171 | 172 | def test_08_layout_structure(self): 173 | """测试布局结构""" 174 | print("🧪 测试布局结构...") 175 | 176 | # 检查主布局是否存在 177 | main_widget = self.window.centralWidget() 178 | self.assertIsNotNone(main_widget) 179 | 180 | # 检查是否使用了正确的布局 181 | layout = main_widget.layout() 182 | self.assertIsNotNone(layout) 183 | 184 | print("✅ 布局结构测试通过") 185 | 186 | def test_09_windows11_style_applied(self): 187 | """测试Win11样式是否应用""" 188 | print("🧪 测试Win11样式应用...") 189 | 190 | # 检查窗口样式 191 | style_sheet = self.window.styleSheet() 192 | self.assertIn("Fluent", style_sheet) 193 | self.assertIn("QGroupBox", style_sheet) 194 | 195 | # 检查主要控件是否有样式 196 | input_edit = self.window.findChild(QLineEdit) 197 | if input_edit: 198 | edit_style = input_edit.styleSheet() 199 | self.assertTrue(len(edit_style) > 0 or "FluentLineEdit" in str(type(input_edit))) 200 | 201 | print("✅ Win11样式应用测试通过") 202 | 203 | 204 | def run_gui_functionality_tests(): 205 | """运行GUI功能测试""" 206 | print("🚀 开始Win11风格GUI功能完整性测试") 207 | print("=" * 60) 208 | 209 | # 创建测试套件 210 | test_suite = unittest.TestLoader().loadTestsFromTestCase(GUI_Functionality_Test) 211 | 212 | # 运行测试 213 | runner = unittest.TextTestRunner(verbosity=2) 214 | result = runner.run(test_suite) 215 | 216 | print("\n" + "=" * 60) 217 | print("📊 测试结果统计") 218 | print(f"总测试数: {result.testsRun}") 219 | print(f"成功: {result.testsRun - len(result.failures) - len(result.errors)}") 220 | print(f"失败: {len(result.failures)}") 221 | print(f"错误: {len(result.errors)}") 222 | 223 | if result.wasSuccessful(): 224 | print("\n🎉 所有GUI功能测试通过!Win11风格美化效果良好!") 225 | else: 226 | print("\n⚠️ 部分测试未通过,需要进一步检查") 227 | 228 | if result.failures: 229 | print("\n失败的测试:") 230 | for test, traceback in result.failures: 231 | print(f" - {test}: {traceback}") 232 | 233 | if result.errors: 234 | print("\n错误的测试:") 235 | for test, traceback in result.errors: 236 | print(f" - {test}: {traceback}") 237 | 238 | return result.wasSuccessful() 239 | 240 | 241 | if __name__ == "__main__": 242 | success = run_gui_functionality_tests() 243 | sys.exit(0 if success else 1) -------------------------------------------------------------------------------- /nbs2save/core/staircase_schematic.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Minecraft阶梯向下结构文件生成器 4 | ---------------------- 5 | 负责将Note列表转换成Minecraft .schem结构文件,采用阶梯向下布局。 6 | 7 | 主要流程 8 | 1. 根据group_config把Note划分到不同轨道组。 9 | 2. 每个轨道组内部: 10 | 2.1 生成tick级基础结构(时钟、走线)。 11 | 2.2 根据panning生成左右声像平台(偏移>=3时启用阶梯效果)。 12 | 2.3 在准确坐标生成音符方块及其基座(偏移>=3时启用阶梯效果)。 13 | 3. 输出为.schem结构文件 14 | """ 15 | 16 | from __future__ import annotations 17 | 18 | from typing import Dict, List 19 | 20 | from mcschematic import MCSchematic 21 | from collections import defaultdict 22 | from pynbs import Note 23 | 24 | from .constants import INSTRUMENT_MAPPING, INSTRUMENT_BLOCK_MAPPING, NOTEPITCH_MAPPING 25 | from .config import GENERATE_CONFIG, GROUP_CONFIG 26 | from .core import GroupProcessor, OutputFormatStrategy 27 | 28 | # -------------------------- 29 | # 阶梯结构文件生成策略 30 | # -------------------------- 31 | class StaircaseSchematicOutputStrategy(OutputFormatStrategy): 32 | """输出为 .schem 结构文件的阶梯向下策略实现。""" 33 | 34 | def __init__(self): 35 | self.schem: MCSchematic = None # 内存中的结构对象 36 | 37 | def initialize(self, processor: GroupProcessor): 38 | """ 39 | 初始化输出格式 40 | 41 | 参数: 42 | processor: GroupProcessor实例 43 | """ 44 | self.schem = MCSchematic() # 重新初始化 45 | # 验证配置 46 | self.validate_config(processor) 47 | 48 | def write_base_structures(self, processor: GroupProcessor, tick: int): 49 | """ 50 | 写入基础结构 51 | 52 | 参数: 53 | processor: GroupProcessor实例 54 | tick: 当前tick 55 | """ 56 | # 计算当前tick在X轴上的位置(每个tick占2格) 57 | tick_x = processor.base_x + tick * 2 58 | # 设置基础平台方块 59 | self.schem.setBlock((tick_x, processor.base_y, processor.base_z), processor.cover_block) 60 | self.schem.setBlock((tick_x, processor.base_y - 1, processor.base_z), processor.base_block) 61 | # 设置红石中继器(用于时钟信号) 62 | self.schem.setBlock((tick_x - 1, processor.base_y, processor.base_z), "minecraft:repeater[delay=1,facing=west]") 63 | self.schem.setBlock((tick_x - 1, processor.base_y - 1, processor.base_z), processor.base_block) 64 | 65 | def write_pan_platform(self, processor: GroupProcessor, tick: int, direction: int): 66 | """ 67 | 写入声像平台(阶梯向下模式) 68 | 69 | 参数: 70 | processor: GroupProcessor实例 71 | tick: 当前tick 72 | direction: 方向(1=右,-1=左) 73 | """ 74 | # 检查该tick该方向的平台是否已生成 75 | if processor.tick_status[tick]["right" if direction == 1 else "left"]: 76 | return 77 | 78 | # 获取该方向上的最大偏移量 79 | max_pan_offset = processor._get_max_pan(processor.notes, tick, direction) 80 | if max_pan_offset == 0: 81 | return 82 | 83 | # 计算平台的起始和结束坐标 84 | tick_x = processor.base_x + tick * 2 85 | platform_start_z = processor.get_platform_start_z() # 平台起始Z坐标(主干道) 86 | platform_end_z = processor.calculate_platform_end_z(max_pan_offset, direction) 87 | step = 1 if direction == 1 else -1 88 | 89 | # 判断是否需要启用阶梯效果(偏移量>=3) 90 | use_staircase = abs(max_pan_offset) >= 3 91 | base_y = processor.base_y 92 | 93 | # 生成平台基座方块 94 | if use_staircase: 95 | # 阶梯向下模式:主干道保持在base_y层,也就是中继器下面的那一层,之后每增加一个偏移单位下降一格 96 | for z in range(platform_start_z, platform_end_z + step, step): 97 | if z == platform_start_z: 98 | # 主干道位置保持在base_y层(与默认模式一致) 99 | self.schem.setBlock((tick_x, base_y, z), processor.base_block) 100 | else: 101 | # 偏移位置每增加一个偏移单位下降一格 102 | # 计算距离主干道的偏移量 103 | distance = abs(z - platform_start_z) 104 | self.schem.setBlock((tick_x, base_y - distance, z), processor.base_block) 105 | else: 106 | # 默认模式 107 | for z in range(platform_start_z, platform_end_z + step, step): 108 | self.schem.setBlock((tick_x, base_y - 1, z), processor.base_block) 109 | 110 | # 在主干道位置放置覆盖方块(始终在base_y层)(这里好像写乱了,我也不知道咋改,能跑就行) 111 | self.schem.setBlock((tick_x, processor.base_y, platform_start_z), processor.cover_block) 112 | 113 | # 如果偏移量大于1,需要铺设红石线连接 114 | if abs(max_pan_offset) > 1: 115 | # 红石线的起始位置应该是从主干道旁边开始 116 | wire_start_z = processor.get_wire_start_z(direction) 117 | wire_end_z = platform_end_z 118 | 119 | if use_staircase: #这里的use_staircase,是看是否启用阶梯效果,如果偏移量大于等于3,则为启动,2和1为普通模式 120 | # 阶梯向下模式:红石线从主干道开始,每增加一个偏移单位下降一格 121 | for z in range(wire_start_z, wire_end_z + step, step): 122 | # 计算距离主干道的偏移量 123 | distance = abs(z - platform_start_z) 124 | # 红石线高度:主干道位置在base_y+1层,也就是cover层,之后每增加一个偏移单位下降一格 125 | y_pos = base_y +1 - distance 126 | self.schem.setBlock( 127 | (tick_x, y_pos, z), 128 | "minecraft:redstone_wire[north=side,south=side]" 129 | ) 130 | else: 131 | # 默认模式:红石线与cover层同高 132 | for z in range(wire_start_z, wire_end_z + step, step): 133 | self.schem.setBlock( 134 | (tick_x, processor.base_y, z), 135 | "minecraft:redstone_wire[north=side,south=side]" 136 | ) 137 | 138 | processor.tick_status[tick]["right" if direction == 1 else "left"] = True 139 | 140 | def write_note(self, processor: GroupProcessor, note: Note): 141 | """ 142 | 写入音符(阶梯向下模式) 143 | 144 | 参数: 145 | processor: GroupProcessor实例 146 | note: 要写入的音符 147 | """ 148 | # 计算音符的位置坐标 149 | tick_x, base_y, z_pos = processor.get_note_position(note) 150 | pan_offset = processor._calculate_pan(note) 151 | 152 | # 获取当前tick、当前方向上的最大偏移量 153 | direction = 1 if pan_offset > 0 else -1 if pan_offset < 0 else 0 154 | max_pan_offset = 0 155 | if direction != 0: 156 | max_pan_offset = processor._get_max_pan(processor.notes, note.tick, direction) 157 | 158 | # 判断是否需要启用阶梯效果 159 | use_staircase = abs(max_pan_offset) >= 3 160 | 161 | # 计算音符高度 162 | if use_staircase and abs(pan_offset) >= 3: # 只有当偏移量>=3时才应用阶梯效果 163 | # 主干道保持在base_y层,偏移位置每增加一个偏移单位下降一格 164 | # 需要加1来补偿音符方块自身的高度 165 | y_level = abs(pan_offset) - 1 166 | y_pos = base_y - y_level 167 | else: 168 | # 主干道或其他情况保持在base_y层 169 | y_pos = base_y 170 | 171 | # 获取音符方块的信息 172 | instrument, base_block, note_pitch = self.get_note_block_info(note) 173 | 174 | # 设置音符方块 175 | self.schem.setBlock( 176 | (tick_x, y_pos, z_pos), 177 | f"minecraft:note_block[note={note_pitch},instrument={instrument}]" 178 | ) 179 | # 设置基座方块 180 | self.schem.setBlock((tick_x, y_pos - 1, z_pos), base_block) 181 | 182 | # 如果基座是沙子类方块,需要在下方添加屏障防止掉落 183 | if self.is_sand_block(base_block): 184 | self.schem.setBlock((tick_x, y_pos - 2, z_pos), "minecraft:barrier") 185 | 186 | def finalize(self, processor: GroupProcessor): 187 | """ 188 | 完成输出,保存结构文件 189 | 190 | 参数: 191 | processor: GroupProcessor实例 192 | """ 193 | path = processor.config["output_file"] 194 | # 保存到本地 .schem 195 | self.schem.save(".", path.rsplit("/", 1)[-1], processor.config["data_version"]) 196 | 197 | # ---------------------- 198 | # 工具方法 199 | # ---------------------- 200 | @staticmethod 201 | def get_note_block_info(note: Note): 202 | """根据 instrument 获取音符方块属性。""" 203 | instrument = INSTRUMENT_MAPPING.get(note.instrument, "harp") 204 | base_block = INSTRUMENT_BLOCK_MAPPING.get(note.instrument, "minecraft:stone") 205 | note_pitch = NOTEPITCH_MAPPING.get(note.key, "0") 206 | return instrument, base_block, note_pitch 207 | 208 | @staticmethod 209 | def is_sand_block(block: str) -> bool: 210 | """简单规则:以 'sand' 结尾即视为沙子类方块。""" 211 | return block.endswith("sand") 212 | 213 | # ---------------------- 214 | # 配置校验 215 | # ---------------------- 216 | def validate_config(self, processor: GroupProcessor): 217 | """确保 config 包含必需的键。""" 218 | required_keys = ["output_file", "data_version"] 219 | for key in required_keys: 220 | if key not in processor.config: 221 | raise ValueError(f"配置缺失: {key}") -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /nbs2save/core/core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | 轨道组处理核心模块 4 | ---------------- 5 | 负责把 Note 列列转换成 Minecraft 命令或 .schem 结构文件。 6 | 7 | 主要流程 8 | 1. 根据 group_config 把 Note 划分到不同轨道组。 9 | 2. 每个轨道组内部: 10 | 2.1 生成 tick 级基础结构(时钟、走线)。 11 | 2.2 根据 panning 生成左右声像平台。 12 | 2.3 在准确坐标生成音符方块及其基座。 13 | 3. 输出: 14 | - McFunctionProcessor → .mcfunction 命令文件 15 | - SchematicProcessor → .schem 结构文件 16 | """ 17 | 18 | from __future__ import annotations 19 | 20 | from abc import ABC, abstractmethod 21 | from typing import Dict, List 22 | 23 | from mcschematic import MCSchematic 24 | from collections import defaultdict 25 | from pynbs import Note 26 | 27 | from .constants import INSTRUMENT_MAPPING, INSTRUMENT_BLOCK_MAPPING, NOTEPITCH_MAPPING 28 | from .config import GENERATE_CONFIG, GROUP_CONFIG 29 | 30 | 31 | # -------------------------- 32 | # 输出格式策略接口 33 | # -------------------------- 34 | class OutputFormatStrategy(ABC): 35 | """ 36 | 定义输出格式的策略接口 37 | 不同的输出格式(如mcfunction、schematic)需要实现这个接口 38 | """ 39 | 40 | @abstractmethod 41 | def initialize(self, processor: GroupProcessor): 42 | """ 43 | 初始化输出格式 44 | 45 | 参数: 46 | processor: GroupProcessor实例 47 | """ 48 | pass 49 | 50 | @abstractmethod 51 | def write_base_structures(self, processor: GroupProcessor, tick: int): 52 | """ 53 | 写入基础结构 54 | 55 | 参数: 56 | processor: GroupProcessor实例 57 | tick: 当前tick 58 | """ 59 | pass 60 | 61 | @abstractmethod 62 | def write_pan_platform(self, processor: GroupProcessor, tick: int, direction: int): 63 | """ 64 | 写入声像平台 65 | 66 | 参数: 67 | processor: GroupProcessor实例 68 | tick: 当前tick 69 | direction: 方向(1=右,-1=左) 70 | """ 71 | pass 72 | 73 | @abstractmethod 74 | def write_note(self, processor: GroupProcessor, note: Note): 75 | """ 76 | 写入音符 77 | 78 | 参数: 79 | processor: GroupProcessor实例 80 | note: 要写入的音符 81 | """ 82 | pass 83 | 84 | @abstractmethod 85 | def finalize(self, processor: GroupProcessor): 86 | """ 87 | 完成输出 88 | 89 | 参数: 90 | processor: GroupProcessor实例 91 | """ 92 | pass 93 | 94 | 95 | # -------------------------- 96 | # 轨道组处理器抽象基类 97 | # -------------------------- 98 | class GroupProcessor(ABC): 99 | """ 100 | 抽象基类:处理单个轨道组(Group)内部的所有音符。 101 | 子类决定最终输出格式:命令文件 或 结构文件。 102 | """ 103 | 104 | def __init__( 105 | self, 106 | all_notes: List[Note], 107 | global_max_tick: int, 108 | config: Dict, 109 | group_config: Dict, 110 | ): 111 | """ 112 | 参数 113 | ---- 114 | all_notes : List[Note] 115 | 整首曲子的全部音符。 116 | global_max_tick : int 117 | 曲子总长度(tick),用于计算进度。 118 | config : Dict 119 | 全局生成配置,如输出路径、版本号等。 120 | group_config : Dict 121 | 轨道组配置,格式见 GROUP_CONFIG。 122 | """ 123 | self.all_notes: List[Note] = all_notes 124 | self.global_max_tick: int = global_max_tick 125 | self.config: Dict = config 126 | self.group_config: Dict = group_config 127 | 128 | # 以下字段在 process() 中动态填充 129 | self.base_x: int | None = None # 轨道组基准 X 坐标 130 | self.base_y: int | None = None # 轨道组基准 Y 坐标 131 | self.base_z: int | None = None # 轨道组基准 Z 坐标 132 | self.notes: List[Note] | None = None # 属于本组的音符(按 tick 排序) 133 | self.tick_status: defaultdict[int, Dict[str, bool]] = None # tick 级状态缓存 134 | self.group_max_tick: int = 0 # 本组最大 tick 135 | self.layers: set[int] = set() # 本组包含的 layer 编号 136 | self.cover_block: str = "" # 走线顶层方块 137 | self.base_block: str = "" # 走线/基座方块 138 | self.log_callback = None # 日志回调 139 | self.progress_callback = None # 进度回调 140 | self.output_strategy: OutputFormatStrategy = None # 输出格式策略 141 | self.generation_mode: str = "default" # 生成模式(default 或 staircase) 142 | 143 | # ---------------------- 144 | # 回调注册 145 | # ---------------------- 146 | def set_log_callback(self, callback): 147 | """设置日志输出回调,供前端 UI 实时显示信息。""" 148 | self.log_callback = callback 149 | 150 | def set_progress_callback(self, callback): 151 | """设置进度更新回调,参数为 0-100 整数。""" 152 | self.progress_callback = callback 153 | 154 | def log(self, message: str): 155 | """内部统一日志接口。""" 156 | if self.log_callback: 157 | self.log_callback(message) 158 | 159 | def update_progress(self, value: int): 160 | """内部统一进度接口。""" 161 | if self.progress_callback: 162 | self.progress_callback(value) 163 | 164 | # ---------------------- 165 | # 输出策略设置 166 | # ---------------------- 167 | def set_output_strategy(self, strategy: OutputFormatStrategy): 168 | """ 169 | 设置输出格式策略 170 | 171 | 参数: 172 | strategy: OutputFormatStrategy实例 173 | """ 174 | self.output_strategy = strategy 175 | self.output_strategy.initialize(self) 176 | 177 | # ---------------------- 178 | # 主流程入口 179 | # ---------------------- 180 | def process(self): 181 | """遍历所有轨道组,依次处理。""" 182 | # 检查是否设置了输出策略 183 | if self.output_strategy is None: 184 | raise ValueError("未设置输出格式策略,请先调用set_output_strategy方法") 185 | 186 | for group_id, config in self.group_config.items(): 187 | self.log(f"\n>> 处理轨道组 {group_id}:") 188 | self.log(f"├─ 包含轨道: {config['layers']}") 189 | self.log(f"├─ 基准坐标: {config['base_coords']}") 190 | self.log(f"├─ 方块配置: {config['block']}") 191 | self.log(f"└─ 生成模式: {config.get('generation_mode', 'default')}") 192 | 193 | # 初始化本组专属字段 194 | self.base_x, self.base_y, self.base_z = map(int, config["base_coords"]) 195 | self.base_block = config["block"]["base"] 196 | self.cover_block = config["block"]["cover"] 197 | self.generation_mode = config.get("generation_mode", "default") # 获取生成模式 198 | self.layers = set(config["layers"]) 199 | self.tick_status = defaultdict(lambda: {"left": False, "right": False}) 200 | 201 | # 加载本组音符 202 | self.load_notes(self.all_notes) 203 | if self.notes: 204 | self.log(f" ├─ 发现音符数量: {len(self.notes)}") 205 | self.log(f" └─ 组内最大tick: {self.group_max_tick}") 206 | else: 207 | self.log(" └─ 警告: 未找到该组的音符") 208 | 209 | # 核心生成 210 | self.process_group() 211 | 212 | # 完成处理 213 | self.output_strategy.finalize(self) 214 | 215 | # ---------------------- 216 | # 音符加载 & 工具方法 217 | # ---------------------- 218 | def load_notes(self, all_notes: List[Note]): 219 | """ 220 | 过滤出属于本组的音符,并按 tick 升序排序。 221 | 同时计算组内最大 tick。 222 | """ 223 | self.notes = sorted( 224 | (n for n in all_notes if n.layer in self.layers), 225 | key=lambda note: note.tick, 226 | ) 227 | self.group_max_tick = max(note.tick for note in self.notes) if self.notes else 0 228 | 229 | @staticmethod 230 | def _calculate_pan(note: Note) -> int: 231 | """ 232 | 把 Note.panning(-100~100)映射到整数格偏移: 233 | - 0 表示中央 234 | - 正数向右,负数向左 235 | """ 236 | return int(round(note.panning / 10)) 237 | 238 | @staticmethod 239 | def _get_max_pan(notes: List[Note], tick: int, direction: int) -> int: 240 | """ 241 | 在指定 tick 内,找出给定方向(1=右,-1=左)的最大绝对偏移值。 242 | 用于决定声像平台长度。 243 | 244 | 参数: 245 | notes: 音符列表 246 | tick: 当前tick 247 | direction: 方向(1=右,-1=左) 248 | 249 | 返回: 250 | 带符号的最大偏移值 251 | """ 252 | max_pan = 0 253 | for note in notes: 254 | if note.tick == tick: 255 | pan = GroupProcessor._calculate_pan(note) 256 | # 检查音符方向是否与指定方向一致 257 | if pan * direction > 0: 258 | max_pan = max(max_pan, abs(pan)) 259 | return max_pan * direction # 带符号 260 | 261 | # ---------------------- 262 | # 坐标计算(可被子类重写) 263 | # ---------------------- 264 | def get_note_position(self, note: Note) -> tuple[int, int, int]: 265 | """ 266 | 计算音符在Minecraft世界中的坐标位置 267 | 子类可以重写此方法以实现不同的坐标计算逻辑 268 | 269 | 参数: 270 | note: 要计算坐标的音符 271 | 272 | 返回: 273 | (x, y, z) 三元组,表示音符在Minecraft世界中的坐标 274 | """ 275 | tick_x = self.base_x + note.tick * 2 276 | pan_offset = self._calculate_pan(note) 277 | z_pos = self.base_z + pan_offset 278 | return tick_x, self.base_y, z_pos 279 | 280 | def get_platform_start_z(self) -> int: 281 | """ 282 | 获取平台起始Z坐标(主干道位置) 283 | 284 | 返回: 285 | 平台起始Z坐标 286 | """ 287 | return self.base_z 288 | 289 | def calculate_platform_end_z(self, max_pan_offset: int, direction: int) -> int: 290 | """ 291 | 计算平台结束Z坐标 292 | 293 | 参数: 294 | max_pan_offset: 最大偏移量 295 | direction: 方向(1=右,-1=左) 296 | 297 | 返回: 298 | 平台结束Z坐标 299 | """ 300 | platform_start_z = self.get_platform_start_z() 301 | if direction == 1: # 右侧 302 | return platform_start_z + max_pan_offset - 1 303 | else: # 左侧 304 | return platform_start_z + max_pan_offset + 1 305 | 306 | def get_wire_start_z(self, direction: int) -> int: 307 | """ 308 | 获取红石线起始Z坐标(从主干道旁边开始) 309 | 310 | 参数: 311 | direction: 方向(1=右,-1=左) 312 | 313 | 返回: 314 | 红石线起始Z坐标 315 | """ 316 | platform_start_z = self.get_platform_start_z() 317 | return platform_start_z + direction 318 | 319 | # ---------------------- 320 | # 逐 tick 处理 321 | # ---------------------- 322 | def process_group(self): 323 | """ 324 | 从 tick 0 到 global_max_tick,每一步: 325 | 1. 生成基础时钟结构; 326 | 2. 收集当前 tick 的所有音符; 327 | 3. 检测位置冲突; 328 | 4. 生成声像平台; 329 | 5. 生成音符方块。 330 | """ 331 | current_tick = 0 332 | note_index = 0 # 已处理到的音符索引 333 | 334 | while current_tick <= self.global_max_tick: 335 | # 1. 更新进度 336 | progress = ( 337 | int((current_tick / self.global_max_tick) * 100) 338 | if self.global_max_tick 339 | else 0 340 | ) 341 | self.update_progress(progress) 342 | 343 | # 2. 基础结构(时钟、走线) 344 | self.output_strategy.write_base_structures(self, current_tick) 345 | 346 | # 3. 收集当前 tick 的音符 347 | active_notes: List[Note] = [] 348 | while note_index < len(self.notes) and self.notes[note_index].tick == current_tick: 349 | active_notes.append(self.notes[note_index]) 350 | note_index += 1 351 | 352 | # 4. 检测坐标冲突:同一 tick 同一 z 不允许重复 353 | occupied_positions = set() 354 | for note in active_notes: 355 | pan = self._calculate_pan(note) 356 | z_pos = self.base_z + pan 357 | position = (current_tick, z_pos) 358 | if position in occupied_positions: 359 | raise Exception( 360 | f"位置冲突! Tick {current_tick}, Z={z_pos} 位置已有音符\n" 361 | f"冲突音符: Layer={note.layer}, Key={note.key}, Instrument={note.instrument}" 362 | ) 363 | occupied_positions.add(position) 364 | 365 | # 5. 生成声像平台(左优先) 366 | pan_directions = set() 367 | for note in active_notes: 368 | pan = self._calculate_pan(note) 369 | if pan != 0: 370 | pan_directions.add(1 if pan > 0 else -1) 371 | 372 | for direction in sorted(pan_directions, reverse=True): # 左(-1) > 右(1) 373 | self.output_strategy.write_pan_platform(self, current_tick, direction) 374 | 375 | # 6. 生成音符 376 | for note in active_notes: 377 | self.output_strategy.write_note(self, note) 378 | 379 | current_tick += 1 -------------------------------------------------------------------------------- /nbs2save/gui/coordinate_picker.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import math 3 | from PyQt6.QtWidgets import (QDialog, QPushButton, QGraphicsView, QGraphicsScene, 4 | QGraphicsRectItem, QGraphicsItem, QLabel, QSpinBox, 5 | QGroupBox, QGridLayout, QHBoxLayout, QGraphicsTextItem) 6 | from PyQt6.QtCore import Qt, QPointF, QRectF, QPropertyAnimation, QEasingCurve 7 | from PyQt6.QtGui import QPen, QBrush, QColor, QPainter, QFont 8 | 9 | # 导入新写的动画工具 10 | from .animations import AnimationUtils, GraphicsItemAnimWrapper 11 | 12 | def get_color_by_id(group_id, is_active=False): 13 | if is_active: 14 | return QColor(0, 120, 255, 230) 15 | hue = (group_id * 137.508) % 360 16 | return QColor.fromHsl(int(hue), 200, 140, 180) 17 | 18 | class TrackGroupItem(QGraphicsRectItem): 19 | """代表轨道组位置的图元""" 20 | def __init__(self, x, y, group_id, is_active=True, on_move_callback=None): 21 | size = 12 22 | super().__init__(-size/2, -size/2, size, size) 23 | 24 | self.is_active = is_active 25 | self.on_move_callback = on_move_callback 26 | self.group_id = group_id 27 | 28 | self.setPos(float(x), -float(y)) 29 | 30 | color = get_color_by_id(group_id, is_active) 31 | self.setBrush(QBrush(color)) 32 | 33 | if self.is_active: 34 | self.setPen(QPen(Qt.GlobalColor.white, 2)) 35 | self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsMovable | 36 | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges) 37 | self.setCursor(Qt.CursorShape.OpenHandCursor) 38 | self.setZValue(100) 39 | 40 | # --- 动画包装器 --- 41 | self.anim_wrapper = GraphicsItemAnimWrapper(self) 42 | self.pos_anim = QPropertyAnimation(self.anim_wrapper, b"pos") 43 | self.pos_anim.setDuration(400) # 动画时长 400ms 44 | self.pos_anim.setEasingCurve(QEasingCurve.Type.OutBack) # 略微回弹的效果 45 | else: 46 | self.setPen(QPen(QColor(220, 220, 220), 1, Qt.PenStyle.SolidLine)) 47 | self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable) 48 | self.setZValue(10) 49 | self.anim_wrapper = None 50 | 51 | # 标签 52 | self.text_item = QGraphicsTextItem(f"ID:{group_id}", self) 53 | font = QFont("Segoe UI", 8) 54 | font.setBold(True) 55 | self.text_item.setFont(font) 56 | self.text_item.setDefaultTextColor(Qt.GlobalColor.white) 57 | text_rect = self.text_item.boundingRect() 58 | self.text_item.setPos(-text_rect.width() / 2, -size/2 - 15) 59 | self.text_item.setAcceptedMouseButtons(Qt.MouseButton.NoButton) 60 | 61 | def move_smoothly_to(self, x, y): 62 | """平滑移动到指定位置 (x, -y)""" 63 | if self.anim_wrapper: 64 | self.pos_anim.stop() 65 | self.pos_anim.setStartValue(self.pos()) 66 | self.pos_anim.setEndValue(QPointF(float(x), -float(y))) 67 | self.pos_anim.start() 68 | else: 69 | self.setPos(float(x), -float(y)) 70 | 71 | def itemChange(self, change, value): 72 | # 仅在鼠标拖动引起的位置变化时触发回调 73 | # 如果是动画引起的变化,我们需要区分(这里简单处理,动画也会触发,但 SpinBox 会暂时 block signal) 74 | if self.is_active and change == QGraphicsItem.GraphicsItemChange.ItemPositionChange: 75 | if self.scene() and self.scene().mouseGrabberItem() == self: 76 | if self.on_move_callback: 77 | self.on_move_callback(int(value.x()), int(-value.y())) 78 | return super().itemChange(change, value) 79 | 80 | def mousePressEvent(self, event): 81 | if self.is_active: 82 | self.setCursor(Qt.CursorShape.ClosedHandCursor) 83 | # 停止当前可能正在进行的动画,防止冲突 84 | if(self.pos_anim.state() == QPropertyAnimation.State.Running): 85 | self.pos_anim.stop() 86 | super().mousePressEvent(event) 87 | 88 | def mouseReleaseEvent(self, event): 89 | if self.is_active: 90 | self.setCursor(Qt.CursorShape.OpenHandCursor) 91 | super().mouseReleaseEvent(event) 92 | 93 | class GridScene(QGraphicsScene): 94 | """带有网格背景的场景""" 95 | def __init__(self, parent=None): 96 | super().__init__(parent) 97 | self.grid_size = 10 98 | self.setBackgroundBrush(QBrush(QColor(30, 30, 30))) 99 | 100 | def drawBackground(self, painter, rect): 101 | super().drawBackground(painter, rect) 102 | 103 | # 优化绘制性能,只绘制视野内的 104 | left = int(rect.left()) - (int(rect.left()) % self.grid_size) 105 | top = int(rect.top()) - (int(rect.top()) % self.grid_size) 106 | 107 | # 绘制细网格 108 | lines = [] 109 | for x in range(left, int(rect.right()), self.grid_size): 110 | lines.append(QPointF(x, rect.top())) 111 | lines.append(QPointF(x, rect.bottom())) 112 | for y in range(top, int(rect.bottom()), self.grid_size): 113 | lines.append(QPointF(rect.left(), y)) 114 | lines.append(QPointF(rect.right(), y)) 115 | 116 | pen = QPen(QColor(50, 50, 50)) 117 | pen.setWidth(0) 118 | painter.setPen(pen) 119 | painter.drawLines(lines) 120 | 121 | # 绘制 大网格 (每50格) 122 | big_grid_lines = [] 123 | big_step = 50 124 | big_left = int(rect.left()) - (int(rect.left()) % big_step) 125 | big_top = int(rect.top()) - (int(rect.top()) % big_step) 126 | 127 | for x in range(big_left, int(rect.right()), big_step): 128 | big_grid_lines.append(QPointF(x, rect.top())) 129 | big_grid_lines.append(QPointF(x, rect.bottom())) 130 | for y in range(big_top, int(rect.bottom()), big_step): 131 | big_grid_lines.append(QPointF(rect.left(), y)) 132 | big_grid_lines.append(QPointF(rect.right(), y)) 133 | 134 | pen_big = QPen(QColor(70, 70, 70)) 135 | pen_big.setWidth(1) 136 | painter.setPen(pen_big) 137 | painter.drawLines(big_grid_lines) 138 | 139 | # 轴线 140 | painter.setPen(QPen(QColor(100, 255, 100, 100), 2)) 141 | painter.drawLine(int(rect.left()), 0, int(rect.right()), 0) 142 | painter.setPen(QPen(QColor(100, 100, 255, 100), 2)) 143 | painter.drawLine(0, int(rect.top()), 0, int(rect.bottom())) 144 | 145 | class CoordinatePickerDialog(QDialog): 146 | """坐标选择对话框""" 147 | def __init__(self, target_group_id, all_groups_data, parent=None): 148 | super().__init__(parent) 149 | self.setWindowTitle(f"轨道布局规划 (侧视图 X-Y) - 正在编辑 ID: {target_group_id}") 150 | self.resize(1100, 750) 151 | 152 | self.target_id = target_group_id 153 | self.all_groups = all_groups_data 154 | 155 | target_data = self.all_groups.get(target_group_id, {}) 156 | coords = target_data.get('base_coords', ("0", "64", "0")) 157 | try: 158 | self.x = int(coords[0]) 159 | self.y = int(coords[1]) 160 | self.z = int(coords[2]) 161 | except: 162 | self.x, self.y, self.z = 0, 64, 0 163 | 164 | self.init_ui() 165 | 166 | # 启动入场动画 167 | AnimationUtils.fade_in_entry(self) 168 | 169 | def init_ui(self): 170 | layout = QHBoxLayout(self) 171 | 172 | # === 左侧控制 === 173 | control_panel = QGroupBox("坐标调整") 174 | control_panel.setStyleSheet("QGroupBox { border: none; background-color: #f3f3f3; }") 175 | control_layout = QGridLayout() 176 | control_layout.setSpacing(15) 177 | 178 | # 标题栏 179 | color_label = QLabel() 180 | color_label.setFixedSize(16, 16) 181 | c = get_color_by_id(self.target_id, True) 182 | color_label.setStyleSheet(f"background-color: rgb({c.red()},{c.green()},{c.blue()}); border-radius: 4px;") 183 | 184 | title_box = QHBoxLayout() 185 | title_text = QLabel(f"当前编辑: ID {self.target_id}") 186 | title_text.setStyleSheet("font-weight: bold; font-size: 11pt;") 187 | title_box.addWidget(color_label) 188 | title_box.addWidget(title_text) 189 | title_box.addStretch() 190 | control_layout.addLayout(title_box, 0, 0, 1, 2) 191 | 192 | # 坐标输入 193 | control_layout.addWidget(QLabel("X (左右):"), 1, 0) 194 | self.spin_x = QSpinBox() 195 | self.spin_x.setRange(-30000000, 30000000) 196 | self.spin_x.setValue(self.x) 197 | self.spin_x.valueChanged.connect(self.update_from_spinbox) 198 | control_layout.addWidget(self.spin_x, 1, 1) 199 | 200 | control_layout.addWidget(QLabel("Y (高度):"), 2, 0) 201 | self.spin_y = QSpinBox() 202 | self.spin_y.setRange(-64, 320) 203 | self.spin_y.setValue(self.y) 204 | self.spin_y.valueChanged.connect(self.update_from_spinbox) 205 | control_layout.addWidget(self.spin_y, 2, 1) 206 | 207 | control_layout.addWidget(QLabel("Z (深度):"), 3, 0) 208 | self.spin_z = QSpinBox() 209 | self.spin_z.setRange(-30000000, 30000000) 210 | self.spin_z.setValue(self.z) 211 | # Z轴修改不影响视图 212 | control_layout.addWidget(self.spin_z, 3, 1) 213 | 214 | # 确定按钮 215 | self.btn_confirm = QPushButton("确定位置") 216 | # 样式已在 FluentButton 或全局样式中定义,这里微调 217 | self.btn_confirm.setCursor(Qt.CursorShape.PointingHandCursor) 218 | self.btn_confirm.setStyleSheet(""" 219 | QPushButton { 220 | background-color: #0078d4; color: white; border-radius: 6px; 221 | padding: 10px; font-weight: bold; font-size: 10pt; 222 | } 223 | QPushButton:hover { background-color: #1084d0; } 224 | QPushButton:pressed { background-color: #006abc; } 225 | """) 226 | self.btn_confirm.clicked.connect(self.accept) 227 | control_layout.addWidget(self.btn_confirm, 5, 0, 1, 2) 228 | 229 | info_label = QLabel("提示:\n• 修改左侧 X/Y 数值,视图中的方块会平滑移动。\n• 右键拖动平移视图。") 230 | info_label.setWordWrap(True) 231 | info_label.setStyleSheet("color: #666666; margin-top: 10px; font-size: 9pt;") 232 | control_layout.addWidget(info_label, 6, 0, 1, 2) 233 | 234 | control_layout.setRowStretch(7, 1) 235 | control_panel.setLayout(control_layout) 236 | control_panel.setFixedWidth(260) 237 | 238 | # === 右侧视图 === 239 | self.scene = GridScene() 240 | self.view = QGraphicsView(self.scene) 241 | self.view.setRenderHint(QPainter.RenderHint.Antialiasing) 242 | self.view.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) 243 | self.view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) 244 | self.view.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) 245 | self.view.scale(3.0, 3.0) 246 | 247 | self.target_item = None 248 | 249 | # 绘制 250 | for g_id, data in self.all_groups.items(): 251 | coords = data.get('base_coords', ("0", "0", "0")) 252 | try: 253 | gx, gy, gz = int(coords[0]), int(coords[1]), int(coords[2]) 254 | except: 255 | gx, gy, gz = 0, 64, 0 256 | 257 | if g_id != self.target_id: 258 | ref_item = TrackGroupItem(gx, gy, g_id, is_active=False) 259 | self.scene.addItem(ref_item) 260 | 261 | self.target_item = TrackGroupItem(self.x, self.y, self.target_id, is_active=True, on_move_callback=self.on_point_dragged) 262 | self.scene.addItem(self.target_item) 263 | 264 | self.view.centerOn(self.x, -self.y) 265 | 266 | layout.addWidget(control_panel) 267 | layout.addWidget(self.view) 268 | 269 | def on_point_dragged(self, x, y): 270 | """鼠标拖动 -> 更新数值 (阻断信号防止循环递归)""" 271 | self.spin_x.blockSignals(True) 272 | self.spin_y.blockSignals(True) 273 | self.spin_x.setValue(x) 274 | self.spin_y.setValue(y) 275 | self.spin_x.blockSignals(False) 276 | self.spin_y.blockSignals(False) 277 | 278 | def update_from_spinbox(self): 279 | """数值改变 -> 平滑移动图元""" 280 | x = self.spin_x.value() 281 | y = self.spin_y.value() 282 | if self.target_item: 283 | # 使用平滑移动方法 284 | self.target_item.move_smoothly_to(x, y) 285 | # 可选:让视图跟随 (如果希望视野一直锁定目标) 286 | # self.view.centerOn(float(x), -float(y)) 287 | 288 | def get_coords(self): 289 | return self.spin_x.value(), self.spin_y.value(), self.spin_z.value() 290 | 291 | def wheelEvent(self, event): 292 | if self.view.underMouse(): 293 | zoom_in = event.angleDelta().y() > 0 294 | factor = 1.15 if zoom_in else 1 / 1.15 295 | self.view.scale(factor, factor) 296 | else: 297 | super().wheelEvent(event) -------------------------------------------------------------------------------- /nbs2save/gui/widgets.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import ( 2 | QPushButton, QLineEdit, QComboBox, QGroupBox, QWidget, QSizePolicy, 3 | QVBoxLayout, QHBoxLayout, QApplication, QGraphicsDropShadowEffect, QStyle, 4 | QStackedWidget, QLabel 5 | ) 6 | from PyQt6.QtCore import ( 7 | Qt, QPropertyAnimation, QEasingCurve, QRect, pyqtSignal, 8 | QParallelAnimationGroup, QPoint, QObject, pyqtProperty, QRectF 9 | ) 10 | from PyQt6.QtGui import ( 11 | QPalette, QColor, QPainter, QBrush, QPen, QIcon, QFont, QTextOption 12 | ) 13 | 14 | from .animations import ColorAnimWrapper 15 | 16 | # ========================================== 17 | # 🎨 配色方案 (UI 优化版) 18 | # ========================================== 19 | 20 | # 1. 主题色 (Primary - 亮蓝) 21 | # 用于 "开始转换", "选点", "添加" 22 | PRIMARY_BG = QColor(0, 120, 212) 23 | PRIMARY_HOVER = QColor(20, 135, 230) 24 | PRIMARY_PRESS = QColor(0, 90, 180) 25 | 26 | # 2. 浅灰色系 (Standard - 优化版) 27 | # 用于 "加载配置", "保存配置" -> 调整为 #f0f0f0 风格 28 | STD_BG_NORMAL = QColor(240, 240, 240) # #f0f0f0 浅灰基底 29 | STD_BG_HOVER = QColor(232, 232, 232) # 悬停稍深 30 | STD_BG_PRESS = QColor(220, 220, 220) # 按下更深 31 | STD_BORDER = QColor(210, 210, 210) # 边框 32 | STD_TEXT = QColor(30, 30, 30) # 深灰文字 33 | 34 | # 3. 危险色 (Danger - 红) 35 | # 用于 "退出", "删除" 36 | DANGER_BG = QColor(215, 45, 45) 37 | DANGER_HOVER = QColor(235, 60, 60) 38 | DANGER_PRESS = QColor(180, 30, 30) 39 | 40 | class FluentButton(QPushButton): 41 | """ 42 | Q弹动画按钮 v3.1 43 | - 优化了浅灰色按钮的视觉表现 44 | - 平滑的 Color Fade + Scale Bounce 动画 45 | """ 46 | def __init__(self, text, icon=None, parent=None, is_primary=False, is_danger=False): 47 | super().__init__(text, parent) 48 | self.setCursor(Qt.CursorShape.PointingHandCursor) 49 | if icon: self.setIcon(icon) 50 | 51 | self.setMinimumHeight(36) 52 | self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) 53 | 54 | # 字体设置 55 | font = QFont("Segoe UI", 9) 56 | if is_primary or is_danger: 57 | font.setBold(True) 58 | self.setFont(font) 59 | 60 | self.is_primary = is_primary 61 | self.is_danger = is_danger 62 | 63 | self._scale_val = 1.0 64 | 65 | # --- 动画系统 --- 66 | 67 | # 1. 颜色过渡动画 (Smooth Fade) 68 | self.color_wrapper = ColorAnimWrapper(self) 69 | self.bg_anim = QPropertyAnimation(self.color_wrapper, b"color") 70 | self.bg_anim.setDuration(200) # 200ms 平滑过渡 71 | self.bg_anim.setEasingCurve(QEasingCurve.Type.OutQuad) 72 | 73 | # 2. 缩放回弹动画 (Scale Bounce) 74 | self.scale_anim = QPropertyAnimation(self, b"scale_prop") 75 | self.scale_anim.setDuration(350) 76 | self.scale_anim.setEasingCurve(QEasingCurve.Type.OutBack) # Q弹回馈 77 | 78 | # 初始化颜色 79 | self._update_target_colors() 80 | self.color_wrapper.color = self.bg_normal 81 | 82 | # 阴影 (浅灰色按钮加一点点立体感) 83 | if not (self.is_primary or self.is_danger): 84 | self.shadow = QGraphicsDropShadowEffect(self) 85 | self.shadow.setBlurRadius(8) 86 | self.shadow.setColor(QColor(0, 0, 0, 8)) # 极淡阴影 87 | self.shadow.setOffset(0, 1) 88 | self.setGraphicsEffect(self.shadow) 89 | 90 | def _update_target_colors(self): 91 | """定义三态颜色""" 92 | if self.is_primary: 93 | self.bg_normal = PRIMARY_BG 94 | self.bg_hover = PRIMARY_HOVER 95 | self.bg_press = PRIMARY_PRESS 96 | self.text_color = Qt.GlobalColor.white 97 | self.border_color = PRIMARY_BG 98 | elif self.is_danger: 99 | self.bg_normal = DANGER_BG 100 | self.bg_hover = DANGER_HOVER 101 | self.bg_press = DANGER_PRESS 102 | self.text_color = Qt.GlobalColor.white 103 | self.border_color = DANGER_BG 104 | else: 105 | # 应用浅灰色系 106 | self.bg_normal = STD_BG_NORMAL 107 | self.bg_hover = STD_BG_HOVER 108 | self.bg_press = STD_BG_PRESS 109 | self.text_color = STD_TEXT 110 | self.border_color = STD_BORDER 111 | 112 | @pyqtProperty(float) 113 | def scale_prop(self): 114 | return self._scale_val 115 | 116 | @scale_prop.setter 117 | def scale_prop(self, val): 118 | self._scale_val = val 119 | self.update() 120 | 121 | def paintEvent(self, event): 122 | painter = QPainter(self) 123 | painter.setRenderHint(QPainter.RenderHint.Antialiasing) 124 | painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) 125 | 126 | # 1. 应用缩放 (以中心为锚点) 127 | w, h = self.width(), self.height() 128 | painter.translate(w / 2, h / 2) 129 | painter.scale(self._scale_val, self._scale_val) 130 | painter.translate(-w / 2, -h / 2) 131 | 132 | rect = self.rect().adjusted(1, 1, -1, -1) 133 | 134 | # 2. 绘制背景 135 | current_bg = self.color_wrapper.color 136 | painter.setBrush(QBrush(current_bg)) 137 | 138 | # 绘制边框 139 | if self.is_primary or self.is_danger: 140 | painter.setPen(Qt.PenStyle.NoPen) 141 | else: 142 | painter.setPen(QPen(self.border_color, 1)) 143 | 144 | painter.drawRoundedRect(rect, 6, 6) 145 | 146 | # 3. 底部立体线 (仅浅灰按钮) 147 | if not self.is_primary and not self.is_danger and not self.isDown(): 148 | # 颜色加深一点点做立体感 149 | darker_line = QColor(0,0,0, 15) 150 | painter.setPen(QPen(darker_line, 1)) 151 | painter.drawLine(rect.left()+6, rect.bottom(), rect.right()-6, rect.bottom()) 152 | 153 | # 4. 手动绘制文字 (防止遮挡) 154 | painter.setPen(self.text_color) 155 | 156 | icon = self.icon() 157 | text = self.text() 158 | 159 | if not icon.isNull(): 160 | icon_size = 16 161 | # 简单估算宽度以居中 162 | fm = self.fontMetrics() 163 | text_w = fm.horizontalAdvance(text) 164 | content_w = icon_size + 8 + text_w 165 | start_x = (w - content_w) / 2 166 | 167 | icon_rect = QRect(int(start_x), int((h - icon_size)/2), icon_size, icon_size) 168 | icon.paint(painter, icon_rect, Qt.AlignmentFlag.AlignCenter) 169 | 170 | text_rect = QRect(int(start_x + icon_size + 8), 0, int(text_w + 10), h) 171 | painter.drawText(text_rect, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, text) 172 | else: 173 | painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text) 174 | 175 | def enterEvent(self, event): 176 | # 颜色变深 (Hover) 177 | self.bg_anim.stop() 178 | self.bg_anim.setEndValue(self.bg_hover) 179 | self.bg_anim.start() 180 | 181 | # 微微放大 (Scale Up) 182 | self.scale_anim.stop() 183 | self.scale_anim.setEndValue(1.02) 184 | self.scale_anim.start() 185 | super().enterEvent(event) 186 | 187 | def leaveEvent(self, event): 188 | # 颜色恢复 189 | self.bg_anim.stop() 190 | self.bg_anim.setEndValue(self.bg_normal) 191 | self.bg_anim.start() 192 | 193 | # 大小恢复 194 | self.scale_anim.stop() 195 | self.scale_anim.setEndValue(1.0) 196 | self.scale_anim.start() 197 | super().leaveEvent(event) 198 | 199 | def mousePressEvent(self, event): 200 | # 颜色按下 (Press) 201 | self.bg_anim.stop() 202 | self.bg_anim.setEndValue(self.bg_press) 203 | self.bg_anim.start() 204 | 205 | # 明显缩小 (Click Feedback) 206 | self.scale_anim.stop() 207 | self.scale_anim.setDuration(100) 208 | self.scale_anim.setEasingCurve(QEasingCurve.Type.OutQuad) 209 | self.scale_anim.setEndValue(0.94) 210 | self.scale_anim.start() 211 | super().mousePressEvent(event) 212 | 213 | def mouseReleaseEvent(self, event): 214 | # 颜色回 Hover 215 | self.bg_anim.stop() 216 | self.bg_anim.setEndValue(self.bg_hover) 217 | self.bg_anim.start() 218 | 219 | # Q弹回位 220 | self.scale_anim.stop() 221 | self.scale_anim.setDuration(400) 222 | self.scale_anim.setEasingCurve(QEasingCurve.Type.OutBack) 223 | self.scale_anim.setEndValue(1.02) 224 | self.scale_anim.start() 225 | super().mouseReleaseEvent(event) 226 | 227 | 228 | class FluentCard(QWidget): 229 | """ 圆角卡片容器 """ 230 | def __init__(self, parent=None): 231 | super().__init__(parent) 232 | self.setStyleSheet(""" 233 | FluentCard { 234 | background-color: #ffffff; 235 | border: 1px solid #eaeaea; 236 | border-radius: 10px; 237 | } 238 | """) 239 | shadow = QGraphicsDropShadowEffect(self) 240 | shadow.setBlurRadius(15) 241 | shadow.setColor(QColor(0, 0, 0, 6)) 242 | shadow.setOffset(0, 3) 243 | self.setGraphicsEffect(shadow) 244 | 245 | class SmoothStackedWidget(QStackedWidget): 246 | """ 滑动切换容器 """ 247 | def __init__(self, parent=None): 248 | super().__init__(parent) 249 | self.m_direction = Qt.Orientation.Horizontal 250 | self.m_speed = 300 251 | self.m_animationtype = QEasingCurve.Type.OutCubic 252 | self.m_now = 0 253 | self.m_next = 0 254 | self.m_active = False 255 | 256 | def setCurrentIndex(self, index): 257 | if self.m_active or self.currentIndex() == index: 258 | super().setCurrentIndex(index) 259 | return 260 | self.m_now = self.currentIndex() 261 | self.m_next = index 262 | self.m_active = True 263 | offset_x = self.frameRect().width() 264 | if index > self.m_now: offset_x = -offset_x 265 | 266 | w_next = self.widget(index) 267 | w_now = self.widget(self.m_now) 268 | w_next.setGeometry(0, 0, self.width(), self.height()) 269 | 270 | p_now = w_now.grab() 271 | p_next = w_next.grab() 272 | 273 | self.l_now = QLabel(self) 274 | self.l_now.setPixmap(p_now) 275 | self.l_now.setGeometry(0, 0, self.width(), self.height()) 276 | self.l_now.show() 277 | 278 | self.l_next = QLabel(self) 279 | self.l_next.setPixmap(p_next) 280 | self.l_next.setGeometry(0, 0, self.width(), self.height()) 281 | self.l_next.hide() 282 | 283 | start_next = QPoint(-offset_x, 0) 284 | end_next = QPoint(0, 0) 285 | start_now = QPoint(0, 0) 286 | end_now = QPoint(offset_x, 0) 287 | 288 | self.l_next.move(start_next) 289 | self.l_next.show() 290 | 291 | self.anim_group = QParallelAnimationGroup() 292 | anim_now = QPropertyAnimation(self.l_now, b"pos") 293 | anim_now.setDuration(self.m_speed) 294 | anim_now.setEasingCurve(self.m_animationtype) 295 | anim_now.setStartValue(start_now) 296 | anim_now.setEndValue(end_now) 297 | 298 | anim_next = QPropertyAnimation(self.l_next, b"pos") 299 | anim_next.setDuration(self.m_speed) 300 | anim_next.setEasingCurve(self.m_animationtype) 301 | anim_next.setStartValue(start_next) 302 | anim_next.setEndValue(end_next) 303 | 304 | self.anim_group.addAnimation(anim_now) 305 | self.anim_group.addAnimation(anim_next) 306 | self.anim_group.finished.connect(self.animationDone) 307 | self.anim_group.start() 308 | w_now.hide() 309 | w_next.hide() 310 | 311 | def animationDone(self): 312 | self.setCurrentIndex_original(self.m_next) 313 | self.widget(self.m_next).show() 314 | self.l_now.deleteLater() 315 | self.l_next.deleteLater() 316 | self.m_active = False 317 | 318 | def setCurrentIndex_original(self, index): 319 | super().setCurrentIndex(index) 320 | 321 | class NavButton(QPushButton): 322 | """ 顶部导航按钮 (Tab) """ 323 | def __init__(self, text, icon_char=None, parent=None): 324 | super().__init__(text, parent) 325 | self.setCheckable(True) 326 | self.setCursor(Qt.CursorShape.PointingHandCursor) 327 | self.setMinimumHeight(40) 328 | self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 329 | self.setFont(QFont("Segoe UI", 10)) 330 | self.icon_char = icon_char 331 | 332 | # 颜色定义 333 | self.bg_normal = QColor(0,0,0,0) 334 | self.bg_hover = QColor(0,0,0,10) 335 | self.bg_checked = QColor(255, 255, 255) 336 | 337 | self.text_normal = QColor(100, 100, 100) 338 | self.text_checked = PRIMARY_BG 339 | 340 | def paintEvent(self, event): 341 | painter = QPainter(self) 342 | painter.setRenderHint(QPainter.RenderHint.Antialiasing) 343 | 344 | rect = self.rect().adjusted(2, 2, -2, -2) 345 | 346 | if self.isChecked(): 347 | painter.setBrush(QBrush(self.bg_checked)) 348 | painter.setPen(QPen(QColor(0,0,0,20), 1)) 349 | painter.drawRoundedRect(rect, 6, 6) 350 | elif self.underMouse(): 351 | painter.setBrush(QBrush(self.bg_hover)) 352 | painter.setPen(Qt.PenStyle.NoPen) 353 | painter.drawRoundedRect(rect, 6, 6) 354 | 355 | if self.isChecked(): 356 | painter.setPen(self.text_checked) 357 | font = self.font() 358 | font.setBold(True) 359 | painter.setFont(font) 360 | else: 361 | painter.setPen(self.text_normal) 362 | 363 | painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, self.text()) 364 | 365 | class FluentTabWidget(QWidget): 366 | def __init__(self, parent=None): 367 | super().__init__(parent) 368 | self.layout = QVBoxLayout(self) 369 | self.layout.setContentsMargins(0, 0, 0, 0) 370 | self.layout.setSpacing(16) 371 | 372 | # 导航栏容器 373 | self.nav_container = QWidget() 374 | self.nav_container.setStyleSheet(""" 375 | background-color: rgba(255, 255, 255, 0.5); 376 | border-radius: 8px; 377 | """) 378 | self.nav_layout = QHBoxLayout(self.nav_container) 379 | self.nav_layout.setContentsMargins(4, 4, 4, 4) 380 | self.nav_layout.setSpacing(8) 381 | self.nav_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) 382 | 383 | self.stacked_widget = SmoothStackedWidget() 384 | self.stacked_widget.setStyleSheet("background: transparent;") 385 | 386 | self.layout.addWidget(self.nav_container) 387 | self.layout.addWidget(self.stacked_widget) 388 | self.buttons = [] 389 | 390 | def addTab(self, widget, text, icon_char=None): 391 | index = self.stacked_widget.addWidget(widget) 392 | display_text = f"{icon_char} {text}" if icon_char else text 393 | btn = NavButton(display_text, icon_char) 394 | btn.clicked.connect(lambda: self.switch_tab(index)) 395 | self.nav_layout.addWidget(btn) 396 | self.buttons.append(btn) 397 | if index == 0: 398 | btn.setChecked(True) 399 | 400 | def switch_tab(self, index): 401 | for i, btn in enumerate(self.buttons): 402 | btn.setChecked(i == index) 403 | self.stacked_widget.setCurrentIndex(index) 404 | 405 | 406 | class FluentLineEdit(QLineEdit): 407 | def __init__(self, placeholder="", parent=None): 408 | super().__init__(parent) 409 | self.setPlaceholderText(placeholder) 410 | self.setMinimumHeight(34) 411 | self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 412 | 413 | class FluentComboBox(QComboBox): 414 | def __init__(self, parent=None): 415 | super().__init__(parent) 416 | self.setMinimumHeight(34) 417 | self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 418 | 419 | class FluentGroupBox(QGroupBox): 420 | def __init__(self, title="", parent=None): 421 | super().__init__(title, parent) 422 | self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) 423 | inner_layout = QVBoxLayout() 424 | inner_layout.setSpacing(16) 425 | inner_layout.setContentsMargins(24, 36, 24, 24) 426 | self.setLayout(inner_layout) -------------------------------------------------------------------------------- /nbs2save/gui/window.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import traceback 4 | 5 | import pynbs 6 | from PyQt6.QtWidgets import ( 7 | QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, 8 | QTextEdit, QFileDialog, QTableWidget, QTableWidgetItem, 9 | QHeaderView, QMessageBox, QProgressBar, QFrame, QSizePolicy, QStyle, QGraphicsDropShadowEffect 10 | ) 11 | from PyQt6.QtCore import Qt 12 | from PyQt6.QtGui import QColor, QFont 13 | 14 | from ..core.constants import MINECRAFT_VERSIONS 15 | from ..core.core import GroupProcessor 16 | from ..core.schematic import SchematicOutputStrategy 17 | from ..core.mcfunction import McFunctionOutputStrategy 18 | from ..core.staircase_schematic import StaircaseSchematicOutputStrategy 19 | 20 | from .widgets import ( 21 | FluentButton, FluentLineEdit, FluentComboBox, FluentTabWidget, FluentCard 22 | ) 23 | from .coordinate_picker import CoordinatePickerDialog 24 | from .animations import AnimationUtils 25 | 26 | def create_fluent_style(): 27 | """ 28 | Revised Style: Warm Light Gray + Consistent Radius 29 | """ 30 | return """ 31 | /* 全局设置 */ 32 | QWidget { 33 | font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif; 34 | font-size: 10pt; 35 | color: #2a2a2a; 36 | background-color: transparent; 37 | } 38 | 39 | QMainWindow { 40 | background-color: #f7f7f7; /* 浅灰偏暖 */ 41 | } 42 | 43 | /* 标题 */ 44 | #titleLabel { 45 | font-size: 28px; 46 | font-weight: 700; 47 | color: #111111; 48 | margin-left: 4px; 49 | font-family: 'Segoe UI Variable Display', 'Segoe UI'; 50 | } 51 | 52 | QLabel#sectionTitle { 53 | font-size: 10pt; 54 | font-weight: 600; 55 | color: #605e5c; 56 | margin-bottom: 6px; 57 | } 58 | 59 | /* 输入框: 6px 圆角 */ 60 | QLineEdit, QSpinBox { 61 | background-color: #ffffff; 62 | border: 1px solid #dcdcdc; 63 | border-bottom: 2px solid #c0c0c0; 64 | border-radius: 6px; 65 | padding: 5px 10px; 66 | color: #333; 67 | } 68 | QLineEdit:hover, QSpinBox:hover { 69 | background-color: #fcfcfc; 70 | border-color: #b0b0b0; 71 | } 72 | QLineEdit:focus, QSpinBox:focus { 73 | border-color: #0078d4; 74 | border-bottom: 2px solid #0078d4; 75 | } 76 | 77 | /* 下拉框 */ 78 | QComboBox { 79 | background-color: #ffffff; 80 | border: 1px solid #dcdcdc; 81 | border-bottom: 2px solid #c0c0c0; 82 | border-radius: 6px; 83 | padding: 4px 10px; 84 | } 85 | QComboBox:hover { 86 | background-color: #fcfcfc; 87 | } 88 | QComboBox::drop-down { 89 | border: none; 90 | width: 30px; 91 | } 92 | 93 | /* 表格 */ 94 | QTableWidget { 95 | border: 1px solid #e5e5e5; 96 | border-radius: 10px; 97 | background-color: #ffffff; 98 | gridline-color: transparent; 99 | selection-background-color: #e0effb; 100 | selection-color: #000000; 101 | } 102 | QHeaderView::section { 103 | background-color: #ffffff; 104 | padding: 10px 8px; 105 | border: none; 106 | border-bottom: 2px solid #f0f0f0; 107 | font-weight: 600; 108 | color: #605e5c; 109 | text-align: left; 110 | } 111 | QTableWidget::item { 112 | border-bottom: 1px solid #fafafa; 113 | padding-left: 8px; 114 | } 115 | 116 | /* 进度条 */ 117 | QProgressBar { 118 | border: none; 119 | background-color: #edebe9; 120 | border-radius: 3px; 121 | height: 6px; 122 | text-align: right; 123 | } 124 | QProgressBar::chunk { 125 | background-color: #0078d4; 126 | border-radius: 3px; 127 | } 128 | 129 | /* 滚动条 */ 130 | QScrollBar:vertical { 131 | background: transparent; 132 | width: 8px; 133 | margin: 0px; 134 | } 135 | QScrollBar::handle:vertical { 136 | background: #d0d0d0; 137 | min-height: 20px; 138 | border-radius: 4px; 139 | } 140 | """ 141 | 142 | class MainWindow(QMainWindow): 143 | def __init__(self): 144 | super().__init__() 145 | self.setWindowTitle("NBS-to-Minecraft") 146 | self.setGeometry(100, 100, 1200, 850) 147 | 148 | self.config = { 149 | 'data_version': MINECRAFT_VERSIONS[0], 150 | 'input_file': '', 151 | 'type': 'schematic', 152 | 'output_file': 'output' 153 | } 154 | 155 | self.group_config = { 156 | 0: { 157 | 'base_coords': ("0", "0", "0"), 158 | 'layers': [0], 159 | 'block': {'base': 'minecraft:iron_block', 'cover': 'minecraft:iron_block'} 160 | } 161 | } 162 | 163 | self.setStyleSheet(create_fluent_style()) 164 | self.init_ui() 165 | self.load_last_config() 166 | 167 | AnimationUtils.fade_in_entry(self, duration=600) 168 | 169 | def init_ui(self): 170 | main_widget = QWidget() 171 | main_layout = QVBoxLayout() 172 | main_layout.setContentsMargins(40, 32, 40, 32) 173 | main_layout.setSpacing(24) 174 | main_widget.setLayout(main_layout) 175 | self.setCentralWidget(main_widget) 176 | 177 | # 1. 顶部栏 178 | header_layout = QHBoxLayout() 179 | title_label = QLabel("NBS-to-minecraftsave") 180 | title_label.setObjectName("titleLabel") 181 | 182 | 183 | header_layout.addWidget(title_label) 184 | header_layout.addSpacing(8) 185 | header_layout.addStretch() 186 | 187 | main_layout.addLayout(header_layout) 188 | 189 | # 2. 导航 Tab 190 | tabs = FluentTabWidget() 191 | shadow = QGraphicsDropShadowEffect(self) 192 | shadow.setBlurRadius(25) 193 | shadow.setColor(QColor(0, 0, 0, 8)) 194 | shadow.setOffset(0, 6) 195 | tabs.setGraphicsEffect(shadow) 196 | 197 | main_layout.addWidget(tabs) 198 | 199 | # --- Tab 内容 --- 200 | basic_tab = QWidget() 201 | groups_tab = QWidget() 202 | log_tab = QWidget() 203 | 204 | for tab in [basic_tab, groups_tab, log_tab]: 205 | layout = QVBoxLayout() 206 | layout.setContentsMargins(4, 12, 4, 12) 207 | layout.setSpacing(24) 208 | tab.setLayout(layout) 209 | 210 | tabs.addTab(basic_tab, "基础设置", "⚙️") 211 | tabs.addTab(groups_tab, "轨道组", "🛤️") 212 | tabs.addTab(log_tab, "运行日志", "📝") 213 | 214 | # === A. 基础设置 === 215 | file_card = FluentCard() 216 | card_layout1 = QVBoxLayout(file_card) 217 | card_layout1.setSpacing(16) 218 | card_layout1.setContentsMargins(28, 28, 28, 28) 219 | 220 | lbl_f1 = QLabel("文件输入") 221 | lbl_f1.setObjectName("sectionTitle") 222 | card_layout1.addWidget(lbl_f1) 223 | 224 | row1 = QHBoxLayout() 225 | self.input_file_edit = FluentLineEdit("选择 .nbs 文件...") 226 | # 浅灰按钮 227 | browse_in = FluentButton("浏览...", is_primary=False) 228 | browse_in.clicked.connect(self.browse_input_file) 229 | row1.addWidget(self.input_file_edit, 1) 230 | row1.addWidget(browse_in) 231 | card_layout1.addLayout(row1) 232 | 233 | card_layout1.addSpacing(12) 234 | 235 | lbl_f2 = QLabel("输出路径") 236 | lbl_f2.setObjectName("sectionTitle") 237 | card_layout1.addWidget(lbl_f2) 238 | 239 | row2 = QHBoxLayout() 240 | self.output_file_edit = FluentLineEdit("设置保存路径...") 241 | browse_out = FluentButton("浏览...", is_primary=False) 242 | browse_out.clicked.connect(self.browse_output_file) 243 | row2.addWidget(self.output_file_edit, 1) 244 | row2.addWidget(browse_out) 245 | card_layout1.addLayout(row2) 246 | 247 | basic_tab.layout().addWidget(file_card) 248 | 249 | # 参数配置 250 | param_card = FluentCard() 251 | card_layout2 = QVBoxLayout(param_card) 252 | card_layout2.setSpacing(16) 253 | card_layout2.setContentsMargins(28, 28, 28, 28) 254 | 255 | lbl_p = QLabel("转换参数") 256 | lbl_p.setObjectName("sectionTitle") 257 | card_layout2.addWidget(lbl_p) 258 | 259 | grid = QHBoxLayout() 260 | 261 | self.version_combo = FluentComboBox() 262 | for version in MINECRAFT_VERSIONS: 263 | self.version_combo.addItem(str(version), version) 264 | 265 | self.type_combo = FluentComboBox() 266 | self.type_combo.addItem("WorldEdit Schematic (.schem)", "schematic") 267 | self.type_combo.addItem("Minecraft Function (.mcfunction)", "mcfunction") 268 | 269 | v_layout = QVBoxLayout() 270 | v_layout.addWidget(QLabel("目标游戏版本", styleSheet="color:#666; font-size:9pt;")) 271 | v_layout.addWidget(self.version_combo) 272 | 273 | t_layout = QVBoxLayout() 274 | t_layout.addWidget(QLabel("输出格式", styleSheet="color:#666; font-size:9pt;")) 275 | t_layout.addWidget(self.type_combo) 276 | 277 | grid.addLayout(v_layout, 1) 278 | grid.addSpacing(40) 279 | grid.addLayout(t_layout, 1) 280 | 281 | card_layout2.addLayout(grid) 282 | basic_tab.layout().addWidget(param_card) 283 | basic_tab.layout().addStretch() 284 | 285 | # === B. 轨道组 === 286 | toolbar = QHBoxLayout() 287 | lbl_g = QLabel("布局管理") 288 | lbl_g.setObjectName("sectionTitle") 289 | toolbar.addWidget(lbl_g) 290 | toolbar.addStretch() 291 | 292 | # 蓝色添加按钮 293 | add_btn = FluentButton("添加分组", is_primary=True) 294 | add_btn.setMinimumWidth(100) 295 | add_btn.clicked.connect(self.add_group) 296 | # 红色删除按钮 (is_danger=True) 297 | del_btn = FluentButton("删除选中", is_danger=True) 298 | del_btn.setMinimumWidth(100) 299 | del_btn.clicked.connect(self.remove_group) 300 | 301 | toolbar.addWidget(add_btn) 302 | toolbar.addWidget(del_btn) 303 | groups_tab.layout().addLayout(toolbar) 304 | 305 | table_card = FluentCard() 306 | card_layout_t = QVBoxLayout(table_card) 307 | card_layout_t.setContentsMargins(0, 0, 0, 0) 308 | 309 | self.groups_table = QTableWidget() 310 | self.groups_table.setColumnCount(9) 311 | self.groups_table.setHorizontalHeaderLabels( 312 | ["ID", "基准X", "基准Y", "基准Z", "坐标规划", "轨道ID", "基础方块", "覆盖方块", "生成模式"]) 313 | 314 | header = self.groups_table.horizontalHeader() 315 | header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch) 316 | header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) 317 | self.groups_table.verticalHeader().setDefaultSectionSize(54) 318 | self.groups_table.verticalHeader().setVisible(False) 319 | self.groups_table.setShowGrid(False) 320 | self.groups_table.setFrameShape(QFrame.Shape.NoFrame) 321 | 322 | card_layout_t.addWidget(self.groups_table) 323 | groups_tab.layout().addWidget(table_card) 324 | 325 | # === C. 日志 === 326 | log_card = FluentCard() 327 | card_layout_l = QVBoxLayout(log_card) 328 | card_layout_l.setContentsMargins(0, 0, 0, 0) 329 | 330 | self.log_text = QTextEdit() 331 | self.log_text.setReadOnly(True) 332 | self.log_text.setStyleSheet(""" 333 | QTextEdit { 334 | border: none; 335 | font-family: 'Consolas', monospace; 336 | color: #555; 337 | padding: 16px; 338 | background-color: transparent; 339 | } 340 | """) 341 | card_layout_l.addWidget(self.log_text) 342 | log_tab.layout().addWidget(log_card) 343 | 344 | # === 底部操作栏 === 345 | action_bar = QWidget() 346 | action_bar.setStyleSheet(""" 347 | QWidget { 348 | background-color: #ffffff; 349 | border-top: 1px solid #f0efed; 350 | border-bottom-left-radius: 10px; 351 | border-bottom-right-radius: 10px; 352 | } 353 | """) 354 | 355 | action_layout = QHBoxLayout(action_bar) 356 | action_layout.setContentsMargins(28, 20, 28, 20) 357 | 358 | self.status_bar_label = QLabel("就绪") 359 | self.status_bar_label.setStyleSheet("border: none; color: #888; font-weight: 500;") 360 | self.progress_bar = QProgressBar() 361 | self.progress_bar.setVisible(False) 362 | self.progress_bar.setFixedWidth(240) 363 | 364 | # ====== 按钮颜色调整区域 ====== 365 | 366 | # 加载按钮 -> 浅灰色 (Standard, is_primary=False) 367 | load_btn = FluentButton("加载配置", is_primary=False) 368 | load_btn.setMinimumWidth(120) 369 | load_btn.clicked.connect(self.load_config) 370 | 371 | # 保存按钮 -> 浅灰色 (Standard, is_primary=False) 372 | save_btn = FluentButton("保存配置", is_primary=False) 373 | save_btn.setMinimumWidth(120) 374 | save_btn.clicked.connect(self.save_config) 375 | 376 | # 退出按钮 -> 红色 (Danger) 377 | exit_btn = FluentButton("退出", is_danger=True) 378 | exit_btn.setMinimumWidth(120) 379 | exit_btn.clicked.connect(self.close) 380 | 381 | # 开始转换 -> 蓝色 (Primary) 382 | run_btn = FluentButton("开始转换", is_primary=True) 383 | run_btn.setMinimumHeight(44) 384 | run_btn.setMinimumWidth(180) 385 | run_btn.clicked.connect(self.start_conversion) 386 | 387 | action_layout.addWidget(self.status_bar_label) 388 | action_layout.addWidget(self.progress_bar) 389 | action_layout.addStretch() 390 | 391 | action_layout.addWidget(load_btn) 392 | action_layout.addWidget(save_btn) 393 | action_layout.addWidget(exit_btn) 394 | action_layout.addSpacing(20) 395 | action_layout.addWidget(run_btn) 396 | 397 | main_layout.addWidget(action_bar) 398 | 399 | self.update_groups_table() 400 | 401 | # --- Logic (保持不变) --- 402 | 403 | def log(self, message): 404 | self.log_text.append(message) 405 | self.log_text.verticalScrollBar().setValue(self.log_text.verticalScrollBar().maximum()) 406 | if len(message) < 50 and ">>>" not in message: 407 | self.status_bar_label.setText(message) 408 | 409 | def update_progress(self, value): 410 | self.progress_bar.setVisible(True) 411 | self.progress_bar.setValue(value) 412 | if value >= 100: self.status_bar_label.setText("任务完成") 413 | 414 | def browse_input_file(self): 415 | file_path, _ = QFileDialog.getOpenFileName(self, "选择NBS文件", "", "Note Block Studio (*.nbs)") 416 | if file_path: 417 | self.input_file_edit.setText(file_path) 418 | base_name = os.path.splitext(os.path.basename(file_path))[0] 419 | self.output_file_edit.setText(base_name) 420 | 421 | def browse_output_file(self): 422 | output_type = self.type_combo.currentData() 423 | ext = ".schem" if output_type == "schematic" else ".mcfunction" 424 | file_path, _ = QFileDialog.getSaveFileName(self, "保存输出文件", "", f"Files (*{ext})") 425 | if file_path: 426 | if not file_path.endswith(ext): file_path += ext 427 | self.output_file_edit.setText(file_path) 428 | 429 | def add_group(self): 430 | self.save_table_to_config() 431 | gid = max(self.group_config.keys()) + 1 if self.group_config else 0 432 | self.group_config[gid] = { 433 | 'base_coords': ("0", "0", "0"), 'layers': [0], 434 | 'block': {'base': 'minecraft:iron_block', 'cover': 'minecraft:iron_block'}, 435 | 'generation_mode': 'default' 436 | } 437 | self.update_groups_table() 438 | 439 | def remove_group(self): 440 | if len(self.group_config) <= 1: 441 | QMessageBox.warning(self, "提示", "至少保留一个轨道组") 442 | return 443 | self.save_table_to_config() 444 | curr = self.groups_table.currentRow() 445 | if curr >= 0: 446 | gid = list(self.group_config.keys())[curr] 447 | del self.group_config[gid] 448 | self.update_groups_table() 449 | 450 | def save_table_to_config(self): 451 | new_config = {} 452 | for r in range(self.groups_table.rowCount()): 453 | try: 454 | gid = int(self.groups_table.item(r, 0).text()) 455 | except: 456 | gid = r 457 | 458 | x = self.groups_table.item(r, 1).text().strip() or "0" 459 | y = self.groups_table.item(r, 2).text().strip() or "0" 460 | z = self.groups_table.item(r, 3).text().strip() or "0" 461 | layers_str = self.groups_table.item(r, 5).text().strip() 462 | layers = [int(x) for x in layers_str.split(',') if x.strip()] if layers_str else [0] 463 | b_base = self.groups_table.item(r, 6).text().strip() or "minecraft:iron_block" 464 | b_cover = self.groups_table.item(r, 7).text().strip() or "minecraft:iron_block" 465 | mode = "default" 466 | if self.groups_table.columnCount() >= 9: 467 | mode = self.groups_table.item(r, 8).text().strip() or "default" 468 | 469 | new_config[gid] = { 470 | 'base_coords': (x,y,z), 'layers': layers, 471 | 'block': {'base': b_base, 'cover': b_cover}, 472 | 'generation_mode': mode 473 | } 474 | self.group_config = new_config 475 | 476 | def update_groups_table(self): 477 | self.groups_table.setRowCount(len(self.group_config)) 478 | for r, (gid, cfg) in enumerate(self.group_config.items()): 479 | self.groups_table.setItem(r, 0, QTableWidgetItem(str(gid))) 480 | 481 | coords = cfg.get('base_coords', ('0','0','0')) 482 | self.groups_table.setItem(r, 1, QTableWidgetItem(str(coords[0]))) 483 | self.groups_table.setItem(r, 2, QTableWidgetItem(str(coords[1]))) 484 | self.groups_table.setItem(r, 3, QTableWidgetItem(str(coords[2]))) 485 | 486 | # 蓝色选点按钮 487 | pick_btn = FluentButton("📍 选点", is_primary=True) 488 | pick_btn.setMinimumHeight(28) 489 | f = pick_btn.font() 490 | f.setPointSize(9) 491 | pick_btn.setFont(f) 492 | 493 | pick_btn.clicked.connect(lambda _, row=r: self.open_coordinate_picker(row)) 494 | self.groups_table.setCellWidget(r, 4, pick_btn) 495 | 496 | l_str = ','.join(map(str, cfg.get('layers', [0]))) 497 | self.groups_table.setItem(r, 5, QTableWidgetItem(l_str)) 498 | 499 | self.groups_table.setItem(r, 6, QTableWidgetItem(cfg['block'].get('base', ''))) 500 | self.groups_table.setItem(r, 7, QTableWidgetItem(cfg['block'].get('cover', ''))) 501 | self.groups_table.setItem(r, 8, QTableWidgetItem(cfg.get('generation_mode', 'default'))) 502 | 503 | def open_coordinate_picker(self, row): 504 | self.save_table_to_config() 505 | try: 506 | gid = int(self.groups_table.item(row, 0).text()) 507 | except: 508 | gid = row 509 | dlg = CoordinatePickerDialog(gid, self.group_config, self) 510 | if dlg.exec(): 511 | nx, ny, nz = dlg.get_coords() 512 | self.groups_table.setItem(row, 1, QTableWidgetItem(str(nx))) 513 | self.groups_table.setItem(row, 2, QTableWidgetItem(str(ny))) 514 | self.groups_table.setItem(row, 3, QTableWidgetItem(str(nz))) 515 | self.save_table_to_config() 516 | 517 | def start_conversion(self): 518 | self.save_table_to_config() 519 | self.config['data_version'] = self.version_combo.currentData() 520 | self.config['input_file'] = self.input_file_edit.text() 521 | self.config['type'] = self.type_combo.currentData() 522 | self.config['output_file'] = self.output_file_edit.text() 523 | 524 | if not self.config['input_file'] or not os.path.exists(self.config['input_file']): 525 | QMessageBox.critical(self, "错误", "请输入有效的NBS文件路径") 526 | return 527 | 528 | self.progress_bar.setVisible(True) 529 | self.progress_bar.setValue(0) 530 | self.status_bar_label.setText("正在分析...") 531 | self.log(">>> 开始转换任务...") 532 | 533 | try: 534 | song = pynbs.read(self.config['input_file']) 535 | if self.config['type'] == 'mcfunction': 536 | with open(self.config['output_file']+".mcfunction", 'w') as f: f.write("\n") 537 | 538 | proc = GroupProcessor(song.notes, song.header.song_length, self.config, self.group_config) 539 | proc.set_log_callback(self.log) 540 | proc.set_progress_callback(self.update_progress) 541 | 542 | if self.config['type'] == 'schematic': 543 | stair = any(c.get('generation_mode')=='staircase' for c in self.group_config.values()) 544 | proc.set_output_strategy(StaircaseSchematicOutputStrategy() if stair else SchematicOutputStrategy()) 545 | else: 546 | proc.set_output_strategy(McFunctionOutputStrategy()) 547 | 548 | proc.process() 549 | self.log(">>> ✅ 转换成功!") 550 | self.status_bar_label.setText("就绪") 551 | self.save_last_config() 552 | 553 | except Exception as e: 554 | self.log(f">>> ❌ 错误: {e}") 555 | self.log(traceback.format_exc()) 556 | QMessageBox.critical(self, "错误", str(e)) 557 | finally: 558 | self.progress_bar.setValue(100) 559 | 560 | def save_config(self): 561 | self.save_table_to_config() 562 | path, _ = QFileDialog.getSaveFileName(self, "保存配置", "", "JSON (*.json)") 563 | if path: 564 | with open(path, 'w') as f: 565 | json.dump({'app_config': self.config, 'group_config': self.group_config}, f, indent=2) 566 | 567 | def load_config(self): 568 | path, _ = QFileDialog.getOpenFileName(self, "加载配置", "", "JSON (*.json)") 569 | if path: 570 | with open(path, 'r') as f: 571 | d = json.load(f) 572 | self.config = d['app_config'] 573 | self.group_config = d['group_config'] 574 | self.input_file_edit.setText(self.config['input_file']) 575 | self.output_file_edit.setText(self.config['output_file']) 576 | self.update_groups_table() 577 | self.log(f"已加载配置: {path}") 578 | 579 | def save_last_config(self): 580 | try: 581 | with open('last_config.json', 'w') as f: 582 | json.dump({'app_config': self.config, 'group_config': self.group_config}, f) 583 | except: pass 584 | 585 | def load_last_config(self): 586 | try: 587 | if os.path.exists('last_config.json'): 588 | with open('last_config.json', 'r') as f: 589 | d = json.load(f) 590 | self.config = d['app_config'] 591 | self.group_config = d['group_config'] 592 | self.input_file_edit.setText(self.config.get('input_file', '')) 593 | self.output_file_edit.setText(self.config.get('output_file', '')) 594 | self.update_groups_table() 595 | except: pass --------------------------------------------------------------------------------