1
0
forked from Rowland/EG
EG/project/project_manager.py
2025-07-10 09:19:51 +08:00

769 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
项目管理器 - 负责项目的生命周期管理
处理项目创建、打开、保存、打包等功能
"""
import os
import sys
import json
import re
import datetime
import subprocess
import shutil
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QLineEdit, QPushButton,
QLabel, QDialogButtonBox, QFileDialog, QMessageBox, QMainWindow
)
from PyQt5.QtCore import Qt
# 导入自定义对话框
from ui.widgets import NewProjectDialog
class ProjectManager:
"""项目管理器 - 统一管理项目的生命周期"""
def __init__(self, world):
"""初始化项目管理器
Args:
world: 主程序world对象引用
"""
self.world = world
self.current_project_path = None
self.project_config = None
print("✓ 项目管理系统初始化完成")
# ==================== 项目生命周期管理 ====================
def createNewProject(self, parent_window):
"""创建新项目"""
dialog = NewProjectDialog(parent_window)
if dialog.exec_() != QDialog.Accepted:
return False
project_path = dialog.projectPath
project_name = dialog.projectName
full_project_path = os.path.join(project_path, project_name)
try:
# 创建项目文件夹结构
os.makedirs(full_project_path)
os.makedirs(os.path.join(full_project_path, "models")) # 模型文件夹
os.makedirs(os.path.join(full_project_path, "textures")) # 贴图文件夹
scenes_path = os.path.join(full_project_path, "scenes") # 场景文件夹
os.makedirs(scenes_path)
# 创建项目配置文件
project_config = {
"name": project_name,
"path": full_project_path,
"created_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"last_modified": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"version": "1.0.0",
"engine_version": "1.0.0"
}
config_file = os.path.join(full_project_path, "project.json")
with open(config_file, "w", encoding="utf-8") as f:
json.dump(project_config, f, ensure_ascii=False, indent=4)
print(f"项目配置文件已创建: {config_file}")
# 清空当前场景
self._clearCurrentScene()
# 自动保存初始场景
scene_file = os.path.join(scenes_path, "scene.bam")
if self.world.scene_manager.saveScene(scene_file):
# 更新配置文件中的场景路径
project_config["scene_file"] = os.path.relpath(scene_file, full_project_path)
with open(config_file, "w", encoding="utf-8") as f:
json.dump(project_config, f, ensure_ascii=False, indent=4)
print(f"初始场景已保存到: {scene_file}")
else:
print("初始场景保存失败")
# 更新项目状态
self.current_project_path = full_project_path
self.project_config = project_config
# 更新文件浏览器到新项目路径
if hasattr(parent_window, 'fileView') and hasattr(parent_window, 'fileModel'):
parent_window.fileView.setRootIndex(parent_window.fileModel.index(full_project_path))
# 更新窗口标题
self.updateWindowTitle(parent_window, project_name)
# 保存当前项目路径到主窗口
parent_window.current_project_path = full_project_path
# 显示成功消息
QMessageBox.information(parent_window, "成功", f"项目 '{project_name}' 创建成功!")
return True
except Exception as e:
QMessageBox.critical(parent_window, "错误", f"创建项目失败: {str(e)}")
return False
def openProject(self, parent_window):
"""打开项目"""
try:
# 获取项目路径
project_path = QFileDialog.getExistingDirectory(
parent_window,
"选择项目文件夹",
"",
QFileDialog.ShowDirsOnly
)
if not project_path:
return False
# 检查是否是有效的项目文件夹
config_file = os.path.join(project_path, "project.json")
if not os.path.exists(config_file):
QMessageBox.warning(parent_window, "警告", "选择的不是有效的项目文件夹!")
return False
# 读取项目配置
with open(config_file, "r", encoding="utf-8") as f:
project_config = json.load(f)
# 检查场景文件
scene_file = os.path.join(project_path, "scenes", "scene.bam")
if not os.path.exists(scene_file):
QMessageBox.warning(parent_window, "警告", "没有找到场景文件!")
return False
# 加载场景
if self.world.scene_manager.loadScene(scene_file):
# 更新项目配置
project_config["last_modified"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
project_config["scene_file"] = os.path.relpath(scene_file, project_path)
with open(config_file, "w", encoding="utf-8") as f:
json.dump(project_config, f, ensure_ascii=False, indent=4)
# 更新项目状态
self.current_project_path = project_path
self.project_config = project_config
# 保存当前项目路径到主窗口
parent_window.current_project_path = project_path
# 更新窗口标题
project_name = os.path.basename(project_path)
self.updateWindowTitle(parent_window, project_name)
# 更新文件浏览器
if hasattr(parent_window, 'fileView') and hasattr(parent_window, 'fileModel'):
parent_window.fileView.setRootIndex(parent_window.fileModel.index(project_path))
QMessageBox.information(parent_window, "成功", "项目加载成功!")
return True
else:
QMessageBox.warning(parent_window, "错误", "加载场景失败!")
return False
except Exception as e:
QMessageBox.critical(parent_window, "错误", f"加载项目时发生错误:{str(e)}")
return False
def saveProject(self, parent_window):
"""保存项目"""
try:
# 检查是否有当前项目路径
if not self.current_project_path:
# 尝试从主窗口获取
if hasattr(parent_window, 'current_project_path'):
self.current_project_path = parent_window.current_project_path
else:
QMessageBox.warning(parent_window, "警告", "请先创建或打开一个项目!")
return False
project_path = self.current_project_path
scenes_path = os.path.join(project_path, "scenes")
# 固定的场景文件名
scene_file = os.path.join(scenes_path, "scene.bam")
# 如果存在旧文件,先删除
if os.path.exists(scene_file):
try:
os.remove(scene_file)
print(f"已删除旧场景文件: {scene_file}")
except Exception as e:
print(f"删除旧场景文件失败: {str(e)}")
return False
# 保存场景
if self.world.scene_manager.saveScene(scene_file):
# 更新项目配置文件
config_file = os.path.join(project_path, "project.json")
if os.path.exists(config_file):
with open(config_file, "r", encoding="utf-8") as f:
project_config = json.load(f)
# 更新最后修改时间
project_config["last_modified"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 记录场景文件路径
project_config["scene_file"] = os.path.relpath(scene_file, project_path)
with open(config_file, "w", encoding="utf-8") as f:
json.dump(project_config, f, ensure_ascii=False, indent=4)
# 更新项目配置
self.project_config = project_config
# 更新窗口标题
project_name = os.path.basename(project_path)
self.updateWindowTitle(parent_window, project_name)
QMessageBox.information(parent_window, "成功", "项目保存成功!")
return True
else:
QMessageBox.warning(parent_window, "错误", "保存场景失败!")
return False
except Exception as e:
QMessageBox.critical(parent_window, "错误", f"保存项目时发生错误:{str(e)}")
return False
# ==================== 项目打包功能 ====================
def buildPackage(self, parent_window):
"""打包项目为可执行文件 - 按照Panda3D官方标准方法"""
try:
# 检查是否有当前项目路径
if not self.current_project_path:
if hasattr(parent_window, 'current_project_path'):
self.current_project_path = parent_window.current_project_path
else:
QMessageBox.warning(parent_window, "警告", "请先创建或打开一个项目!")
return False
project_path = self.current_project_path
scenes_path = os.path.join(project_path, "scenes")
scene_file = os.path.join(scenes_path, "scene.bam")
# 检查场景文件是否存在
if not os.path.exists(scene_file):
QMessageBox.warning(parent_window, "警告", "请先保存场景!")
return False
# 创建构建目录
build_dir = os.path.join(project_path, "build")
if not os.path.exists(build_dir):
os.makedirs(build_dir)
# 创建标准的打包文件
self._createStandardBuildFiles(build_dir, project_path, scene_file)
# 执行打包命令
success = self._executeStandardBuild(build_dir, parent_window)
if success:
QMessageBox.information(parent_window, "成功",
"打包完成!\n可执行文件在 build/dist/ 目录中。\n"
"支持的格式:\n"
"- Windows: .exe 安装程序\n"
"- Linux: .tar.gz 压缩包\n"
"- 通用: .zip 压缩包")
return True
else:
return False
except Exception as e:
QMessageBox.critical(parent_window, "错误", f"打包过程出错:{str(e)}")
return False
def _createStandardBuildFiles(self, build_dir, project_path, scene_file):
"""创建标准的Panda3D打包文件"""
project_name = os.path.basename(project_path)
# 确保构建目录存在
if not os.path.exists(build_dir):
os.makedirs(build_dir)
# 复制场景文件到构建目录
shutil.copy2(scene_file, os.path.join(build_dir, "scene.bam"))
# 创建标准的应用程序入口文件
self._createAppFile(build_dir, project_name)
# 创建标准的setup.py文件
self._createStandardSetupFile(build_dir, project_name)
def _createAppFile(self, build_dir, project_name):
"""创建应用程序主文件"""
app_code = f'''#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
{project_name} - Panda3D应用程序
使用Panda3D引擎编辑器创建
"""
import sys
import os
from direct.showbase.ShowBase import ShowBase
from panda3d.core import (loadPrcFileData, WindowProperties, AmbientLight,
DirectionalLight, Point3, Vec3)
# 配置Panda3D
loadPrcFileData("", """
win-size 1280 720
window-title {project_name}
show-frame-rate-meter 1
sync-video 1
want-directtools #f
want-tk #f
audio-library-name p3openal_audio
""")
class {project_name.replace(' ', '').replace('-', '')}App(ShowBase):
"""应用程序主类"""
def __init__(self):
ShowBase.__init__(self)
print(f"启动 {project_name}...")
# 设置窗口属性
self.setupWindow()
# 设置光照
self.setupLighting()
# 加载场景
self.loadScene()
# 设置相机控制
self.setupControls()
print("✓ 应用程序初始化完成")
def setupWindow(self):
"""设置窗口"""
# 设置背景色
self.setBackgroundColor(0.2, 0.2, 0.2)
# 设置窗口属性
props = WindowProperties()
props.setTitle("{project_name}")
self.win.requestProperties(props)
def setupLighting(self):
"""设置光照系统"""
# 环境光
alight = AmbientLight('alight')
alight.setColor((0.3, 0.3, 0.3, 1))
alnp = self.render.attachNewNode(alight)
self.render.setLight(alnp)
# 定向光(模拟太阳光)
dlight = DirectionalLight('dlight')
dlight.setColor((0.8, 0.8, 0.8, 1))
dlight.setDirection(Vec3(-1, -1, -1))
dlnp = self.render.attachNewNode(dlight)
self.render.setLight(dlnp)
def loadScene(self):
"""加载场景"""
try:
# 查找场景文件
scene_file = "scene.bam"
if not os.path.exists(scene_file):
print("警告: 没有找到场景文件,创建默认场景")
self.createDefaultScene()
return
# 加载场景
scene = self.loader.loadModel(scene_file)
if scene:
scene.reparentTo(self.render)
print("✓ 场景加载成功")
# 自动调整相机位置
self.adjustCamera()
else:
print("警告: 场景加载失败,创建默认场景")
self.createDefaultScene()
except Exception as e:
print(f"加载场景时出错: {{str(e)}}")
self.createDefaultScene()
def createDefaultScene(self):
"""创建默认场景"""
# 加载默认的环境模型
env = self.loader.loadModel("models/environment")
if env:
env.reparentTo(self.render)
env.setScale(0.25)
env.setPos(-8, 42, 0)
# 创建一个简单的立方体作为示例
from panda3d.core import CardMaker
cm = CardMaker("ground")
cm.setFrame(-10, 10, -10, 10)
ground = self.render.attachNewNode(cm.generate())
ground.setP(-90)
ground.setColor(0.5, 0.8, 0.5, 1)
def adjustCamera(self):
"""调整相机位置以查看场景"""
# 计算场景边界
bounds = self.render.getBounds()
if bounds and not bounds.isEmpty():
center = bounds.getCenter()
radius = bounds.getRadius()
# 设置相机位置
distance = radius * 3
self.cam.setPos(center.x, center.y - distance, center.z + radius)
self.cam.lookAt(center)
else:
# 默认相机位置
self.cam.setPos(0, -20, 5)
self.cam.lookAt(0, 0, 0)
def setupControls(self):
"""设置相机控制"""
# 启用鼠标控制
self.accept("wheel_up", self.zoomIn)
self.accept("wheel_down", self.zoomOut)
# 键盘控制说明
print("\\n=== 控制说明 ===")
print("鼠标滚轮: 缩放")
print("ESC: 退出")
print("================\\n")
# ESC键退出
self.accept("escape", sys.exit)
def zoomIn(self):
"""放大"""
pos = self.cam.getPos()
lookAt = Point3(0, 0, 0) # 假设看向原点
direction = (lookAt - pos).normalized()
newPos = pos + direction * 2
self.cam.setPos(newPos)
def zoomOut(self):
"""缩小"""
pos = self.cam.getPos()
lookAt = Point3(0, 0, 0) # 假设看向原点
direction = (lookAt - pos).normalized()
newPos = pos - direction * 2
self.cam.setPos(newPos)
def main():
"""主函数"""
try:
app = {project_name.replace(' ', '').replace('-', '')}App()
app.run()
except Exception as e:
print(f"应用程序启动失败: {{str(e)}}")
import traceback
traceback.print_exc()
input("按Enter键退出...")
if __name__ == "__main__":
main()
'''
app_path = os.path.join(build_dir, "main.py")
with open(app_path, "w", encoding="utf-8") as f:
f.write(app_code)
def _createStandardSetupFile(self, build_dir, project_name):
"""创建标准的setup.py文件 - 按照Panda3D官方文档"""
setup_code = f'''#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
{project_name} 打包配置文件
使用 Panda3D 标准打包工具
"""
from setuptools import setup
# 应用程序配置
APP_NAME = "{project_name}"
APP_VERSION = "1.0.0"
MAIN_SCRIPT = "main.py"
setup(
name=APP_NAME,
version=APP_VERSION,
# Panda3D 打包选项
options={{
'build_apps': {{
# GUI应用程序
'gui_apps': {{
APP_NAME: MAIN_SCRIPT,
}},
# 包含的文件模式
'include_patterns': [
'*.bam', # 场景文件
'*.egg', # 模型文件
'*.jpg', '*.png', # 纹理文件
'*.wav', '*.ogg', # 音频文件
'*.ttf', '*.otf', # 字体文件
],
# 排除的文件模式
'exclude_patterns': [
'*.pyc',
'__pycache__/**',
'.git/**',
'.vscode/**',
'*.log',
],
# Panda3D 插件
'plugins': [
'pandagl', # OpenGL渲染器
'pandaegg', # Egg文件支持
'p3openal_audio', # OpenAL音频
],
# 包含的Python模块
'include_modules': {{
'*': [
'direct.showbase.ShowBase',
'direct.task',
'direct.actor',
'direct.interval',
'panda3d.core',
'panda3d.direct',
],
}},
# 排除的Python模块减小体积
'exclude_modules': {{
'*': [
'tkinter', # Tkinter GUI
'matplotlib', # 绘图库
'numpy', # 数值计算(如果不需要)
'scipy', # 科学计算(如果不需要)
'PIL', # 图像处理(如果不需要)
'wx', # wxPython
'PyQt5', # Qt界面库
'setuptools', # 安装工具
'distutils', # 分发工具
],
}},
# 平台设置
'platforms': [
'win_amd64', # Windows 64位
'linux_x86_64', # Linux 64位
# 'macosx_10_9_x86_64', # macOS如果需要
],
# 优化设置
'strip_docstrings': True, # 移除文档字符串
}},
}},
# 标准setuptools选项
author="Panda3D 引擎编辑器",
author_email="user@example.com",
description=f"{{APP_NAME}} - 使用Panda3D创建的3D应用程序",
long_description="这是一个使用Panda3D引擎编辑器创建的3D应用程序。",
# 依赖项
install_requires=[
'panda3d>=1.10.13',
],
# Python版本要求
python_requires='>=3.7',
# 分类信息
classifiers=[
'Development Status :: 4 - Beta',
'Intended Audience :: End Users/Desktop',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Topic :: Games/Entertainment',
'Topic :: Multimedia :: Graphics :: 3D Rendering',
],
# 使用现代的许可证表达式
license='MIT',
)
'''
setup_path = os.path.join(build_dir, "setup.py")
with open(setup_path, "w", encoding="utf-8") as f:
f.write(setup_code)
def _executeStandardBuild(self, build_dir, parent_window):
"""执行标准的Panda3D打包命令"""
try:
print(f"开始打包,工作目录: {build_dir}")
# 首先尝试 bdist_apps推荐方式
print("执行标准打包命令: python setup.py bdist_apps")
process = subprocess.Popen(
[sys.executable, "setup.py", "bdist_apps"],
cwd=build_dir,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
encoding='utf-8'
)
# 实时显示输出
stdout_lines = []
stderr_lines = []
while True:
output = process.stdout.readline()
if output == '' and process.poll() is not None:
break
if output:
print(output.strip())
stdout_lines.append(output.strip())
# 获取错误输出
stderr = process.stderr.read()
if stderr:
print("错误输出:", stderr)
stderr_lines.append(stderr)
# 检查返回码
if process.returncode == 0:
print("✓ 打包成功完成")
return True
else:
# 如果bdist_apps失败尝试build_apps
print(f"bdist_apps 失败 (返回码: {process.returncode}),尝试 build_apps...")
return self._tryBuildApps(build_dir, parent_window)
except Exception as e:
print(f"执行打包命令时出错: {str(e)}")
QMessageBox.critical(parent_window, "错误", f"打包失败:{str(e)}")
return False
def _tryBuildApps(self, build_dir, parent_window):
"""尝试使用 build_apps 命令"""
try:
print("执行备用打包命令: python setup.py build_apps")
process = subprocess.Popen(
[sys.executable, "setup.py", "build_apps"],
cwd=build_dir,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
encoding='utf-8'
)
# 实时显示输出
while True:
output = process.stdout.readline()
if output == '' and process.poll() is not None:
break
if output:
print(output.strip())
stderr = process.stderr.read()
if stderr:
print("错误输出:", stderr)
if process.returncode == 0:
print("✓ build_apps 成功完成")
return True
else:
error_msg = f"打包失败,返回码:{process.returncode}"
if stderr:
error_msg += f"\\n错误信息{stderr}"
QMessageBox.critical(parent_window, "错误", error_msg)
return False
except Exception as e:
QMessageBox.critical(parent_window, "错误", f"执行 build_apps 失败:{str(e)}")
return False
# ==================== 工具方法 ====================
def updateWindowTitle(self, window, project_name=None):
"""更新窗口标题"""
base_title = "引擎编辑器"
if project_name:
window.setWindowTitle(f"{base_title} - {project_name}")
else:
window.setWindowTitle(base_title)
def _clearCurrentScene(self):
"""清空当前场景"""
# 移除所有模型
for model in self.world.models:
model.removeNode()
self.world.models.clear()
# 清空GUI元素
for gui_element in self.world.gui_elements:
gui_element.removeNode()
self.world.gui_elements.clear()
# 更新场景树
if hasattr(self.world, 'updateSceneTree'):
self.world.updateSceneTree()
def getCurrentProjectPath(self):
"""获取当前项目路径"""
return self.current_project_path
def getCurrentProjectConfig(self):
"""获取当前项目配置"""
return self.project_config
def isProjectOpen(self):
"""检查是否有项目打开"""
return self.current_project_path is not None
def getProjectName(self):
"""获取当前项目名称"""
if self.current_project_path:
return os.path.basename(self.current_project_path)
return None
def getProjectInfo(self):
"""获取项目信息"""
if not self.project_config:
return None
return {
"name": self.project_config.get("name", "未知项目"),
"path": self.project_config.get("path", ""),
"created_at": self.project_config.get("created_at", ""),
"last_modified": self.project_config.get("last_modified", ""),
"version": self.project_config.get("version", "1.0.0"),
"engine_version": self.project_config.get("engine_version", "1.0.0")
}
# ==================== 便利函数 ====================
def updateWindowTitle(window, project_name=None):
"""更新窗口标题 - 独立便利函数"""
base_title = "引擎编辑器"
if project_name:
window.setWindowTitle(f"{base_title} - {project_name}")
else:
window.setWindowTitle(base_title)