[Python] 纯文本查看 复制代码
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, simpledialog
import threading
import os
import sys
import json
from typing import Dict, List
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.story_parser import parse_story_file, split_into_chapters
from core.template_generator import generate_template, get_genre_list, save_template, load_template, delete_template
from core.entity_extractor import extract_entities, format_extraction_result
from core.ai_client import DeepSeekClient
from core.mud_generator import refine_with_ai, generate_scenes_from_structure, build_html
from core.cache_manager import get_cached, set_cached, clear_cache, get_cache_size
from core.logger import get_logger
log = get_logger("app")
CONFIG_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json")
class Story2MUDApp:
def __init__(self, root):
self.root = root
self.root.title("Story2MUD - 小说转MUD游戏工具")
self.root.geometry("750x700")
self.root.resizable(True, True)
self.story_text = ""
self.html_output = ""
self.last_output_path = ""
self.converting = False
self._build_ui()
self._load_config()
def _build_ui(self):
main = ttk.Frame(self.root, padding=15)
main.pack(fill=tk.BOTH, expand=True)
# Title
ttk.Label(main, text="Story2MUD", font=("Helvetica", 16, "bold")).pack(anchor=tk.W)
ttk.Label(main, text="将小说文本转换为MUD文字冒险游戏(四阶段流程)", foreground="gray").pack(anchor=tk.W)
ttk.Separator(main, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=10)
# File import
file_frame = ttk.LabelFrame(main, text="小说文件", padding=8)
file_frame.pack(fill=tk.X, pady=(0, 10))
file_btn_frame = ttk.Frame(file_frame)
file_btn_frame.pack(fill=tk.X)
ttk.Button(file_btn_frame, text="导入TXT文件", command=self._import_file).pack(side=tk.LEFT)
self.file_label = ttk.Label(file_btn_frame, text="未选择文件", foreground="gray")
self.file_label.pack(side=tk.LEFT, padx=10)
self.preview = tk.Text(file_frame, height=6, wrap=tk.WORD, state=tk.DISABLED,
font=("Consolas", 9), bg="#f5f5f5")
self.preview.pack(fill=tk.X, pady=(8, 0))
scrollbar = ttk.Scrollbar(self.preview, command=self.preview.yview)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.preview.config(yscrollcommand=scrollbar.set)
# Config
config_frame = ttk.LabelFrame(main, text="配置", padding=8)
config_frame.pack(fill=tk.X, pady=(0, 10))
# API Key
api_frame = ttk.Frame(config_frame)
api_frame.pack(fill=tk.X, pady=2)
ttk.Label(api_frame, text="DeepSeek API Key:", width=16).pack(side=tk.LEFT)
self.api_key_var = tk.StringVar()
self.api_entry = ttk.Entry(api_frame, textvariable=self.api_key_var, show="*", width=35)
self.api_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
ttk.Button(api_frame, text="保存Key", command=self._save_config).pack(side=tk.LEFT, padx=(5, 0))
# Title
title_frame = ttk.Frame(config_frame)
title_frame.pack(fill=tk.X, pady=2)
ttk.Label(title_frame, text="游戏标题:", width=16).pack(side=tk.LEFT)
self.title_var = tk.StringVar(value="文字冒险")
ttk.Entry(title_frame, textvariable=self.title_var, width=45).pack(side=tk.LEFT, fill=tk.X, expand=True)
# Genre + Density row
row_frame = ttk.Frame(config_frame)
row_frame.pack(fill=tk.X, pady=2)
ttk.Label(row_frame, text="小说类别:", width=16).pack(side=tk.LEFT)
self.genre_var = tk.StringVar(value="通用")
self.genre_combo = ttk.Combobox(row_frame, textvariable=self.genre_var, state="readonly", width=10)
self.genre_combo["values"] = get_genre_list()
self.genre_combo.pack(side=tk.LEFT)
self.genre_combo.bind("<<ComboboxSelected>>", self._on_genre_change)
ttk.Button(row_frame, text="保存模板", command=self._save_template).pack(side=tk.LEFT, padx=(5, 0))
ttk.Button(row_frame, text="删除模板", command=self._delete_template).pack(side=tk.LEFT, padx=(3, 0))
ttk.Label(row_frame, text=" 分支密度:").pack(side=tk.LEFT)
self.density_var = tk.StringVar(value="medium")
density_combo = ttk.Combobox(row_frame, textvariable=self.density_var, state="readonly", width=8)
density_combo["values"] = ["low", "medium", "high"]
density_combo.pack(side=tk.LEFT)
ttk.Label(row_frame, text="(类别影响模板,密度影响分支数量)", foreground="gray").pack(side=tk.LEFT, padx=8)
# Convert button & progress
action_frame = ttk.Frame(main)
action_frame.pack(fill=tk.X, pady=(0, 5))
self.convert_btn = ttk.Button(action_frame, text="开始转换", command=self._start_convert)
self.convert_btn.pack(side=tk.LEFT)
self.progress_var = tk.DoubleVar()
self.progress_bar = ttk.Progressbar(action_frame, variable=self.progress_var,
maximum=100, length=300)
self.progress_bar.pack(side=tk.LEFT, padx=10, fill=tk.X, expand=True)
# Stage labels
stage_frame = ttk.Frame(main)
stage_frame.pack(fill=tk.X, pady=(0, 10))
self.stage_labels = []
stages = ["①模板", "②粗筛", "③精滤", "④生成"]
for i, stage in enumerate(stages):
lbl = ttk.Label(stage_frame, text=stage, foreground="gray", font=("Helvetica", 9))
lbl.pack(side=tk.LEFT, padx=(0, 15))
self.stage_labels.append(lbl)
self.status_label = ttk.Label(stage_frame, text="就绪", foreground="gray")
self.status_label.pack(side=tk.RIGHT)
# Output buttons
output_frame = ttk.Frame(main)
output_frame.pack(fill=tk.X)
self.preview_btn = ttk.Button(output_frame, text="浏览器预览", command=self._preview_html, state=tk.DISABLED)
self.preview_btn.pack(side=tk.LEFT)
self.save_btn = ttk.Button(output_frame, text="保存HTML", command=self._save_html, state=tk.DISABLED)
self.save_btn.pack(side=tk.LEFT, padx=10)
ttk.Button(output_frame, text="清空缓存", command=self._clear_cache).pack(side=tk.RIGHT)
def _import_file(self):
filetypes = [("文本文件", "*.txt"), ("所有文件", "*.*")]
path = filedialog.askopenfilename(filetypes=filetypes)
if not path:
return
try:
self.story_text = parse_story_file(path)
self.file_label.config(text=os.path.basename(path))
self.preview.config(state=tk.NORMAL)
self.preview.delete("1.0", tk.END)
self.preview.insert("1.0", self.story_text[:5000])
if len(self.story_text) > 5000:
self.preview.insert(tk.END, f"\n\n... (共 {len(self.story_text)} 字)")
self.preview.config(state=tk.DISABLED)
except Exception as e:
messagebox.showerror("错误", f"读取文件失败: {e}")
def _set_stage(self, index, active):
color = "#00aa00" if active else "gray"
font = ("Helvetica", 9, "bold") if active else ("Helvetica", 9)
self.stage_labels[index].config(foreground=color, font=font)
def _start_convert(self):
if self.converting:
return
if not self.story_text:
messagebox.showwarning("提示", "请先导入小说文件")
return
api_key = self.api_key_var.get().strip()
if not api_key:
messagebox.showwarning("提示", "请输入DeepSeek API Key")
return
self.converting = True
self.convert_btn.config(state=tk.DISABLED)
self.preview_btn.config(state=tk.DISABLED)
self.save_btn.config(state=tk.DISABLED)
self.progress_var.set(0)
for lbl in self.stage_labels:
lbl.config(foreground="gray", font=("Helvetica", 9))
thread = threading.Thread(target=self._convert_worker, args=(api_key,), daemon=True)
thread.start()
def _convert_worker(self, api_key):
log.info("=" * 50)
log.info("开始转换流程")
try:
client = DeepSeekClient(api_key)
genre = self.genre_var.get()
density = self.density_var.get()
title = self.title_var.get()
log.info(f"配置: genre={genre}, density={density}, title={title}")
log.info(f"文本长度: {len(self.story_text)}字")
# ---- 阶段1:模板生成(本地,无需缓存)----
log.info(">>> 阶段1:模板生成")
self.root.after(0, lambda: self._set_stage(0, True))
self._update_status("阶段1:生成转换模板...")
template = generate_template(genre)
self.root.after(0, lambda: self.progress_var.set(10))
self._update_status(f"阶段1完成:{genre}模板已生成")
self.root.after(0, lambda: self._set_stage(0, False))
log.info(f"阶段1完成: genre={template.get('genre')}")
# ---- 阶段2:粗筛提取(检查缓存)----
log.info(">>> 阶段2:粗筛提取")
self.root.after(0, lambda: self._set_stage(1, True))
cached_entities = get_cached("stage2", self.story_text, genre, density, title)
if cached_entities:
entities = cached_entities
self._update_status("阶段2:使用缓存结果")
log.info("阶段2使用缓存")
else:
self._update_status("阶段2:提取游戏要素...")
entities = extract_entities(self.story_text)
set_cached("stage2", self.story_text, genre, density, title, entities)
log.info("阶段2结果已缓存")
self.root.after(0, lambda: self.progress_var.set(25))
self._update_status(f"阶段2完成:人物{len(entities['characters'])}个, "
f"地点{len(entities['locations'])}个, "
f"物品{len(entities['items'])}个")
self.root.after(0, lambda: self._set_stage(1, False))
# ---- 阶段3:AI精滤(检查缓存)----
log.info(">>> 阶段3:AI精滤")
self.root.after(0, lambda: self._set_stage(2, True))
cached_structure = get_cached("stage3", self.story_text, genre, density, title)
if cached_structure:
game_structure = cached_structure
self._update_status("阶段3:使用缓存结果")
log.info("阶段3使用缓存")
else:
self._update_status("阶段3:AI精滤设计游戏结构...")
game_structure = refine_with_ai(client, template, entities, self.story_text, title)
if not game_structure.get("rooms"):
log.error("阶段3结果无rooms字段")
raise RuntimeError("AI未能生成有效的游戏结构")
set_cached("stage3", self.story_text, genre, density, title, game_structure)
log.info("阶段3结果已缓存")
self.root.after(0, lambda: self.progress_var.set(50))
room_count = len(game_structure.get("rooms", []))
self._update_status(f"阶段3完成:{room_count}个房间, "
f"{len(game_structure.get('npcs', []))}个NPC")
self.root.after(0, lambda: self._set_stage(2, False))
# ---- 阶段4:生成HTML场景(检查缓存)----
log.info(">>> 阶段4:生成HTML场景")
self.root.after(0, lambda: self._set_stage(3, True))
cached_scenes = get_cached("stage4", self.story_text, genre, density, title)
if cached_scenes:
mud_data = cached_scenes
self._update_status("阶段4:使用缓存结果")
log.info("阶段4使用缓存")
else:
self._update_status("阶段4:生成互动场景和选择分支...")
mud_data = generate_scenes_from_structure(client, game_structure, self.story_text)
if not mud_data.get("scenes"):
log.warning("阶段4返回无scenes,使用降级方案")
self._update_status("降级:使用游戏结构直接构建场景...")
mud_data = {
"title": title,
"scenes": _fallback_scenes(game_structure)
}
if not mud_data.get("title"):
mud_data["title"] = title
set_cached("stage4", self.story_text, genre, density, title, mud_data)
log.info("阶段4结果已缓存")
self.root.after(0, lambda: self.progress_var.set(85))
self._update_status("正在生成HTML文件...")
template_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"templates", "mud_template.html")
self.html_output = build_html(mud_data, template_path)
# 自动保存到output目录
output_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "output")
os.makedirs(output_dir, exist_ok=True)
safe_title = "".join(c for c in title if c not in r'\/:*?"<>|').strip() or "mud_game"
output_path = os.path.join(output_dir, f"{safe_title}.html")
with open(output_path, "w", encoding="utf-8") as f:
f.write(self.html_output)
self.last_output_path = output_path
log.info(f"HTML已保存: {output_path}")
self.root.after(0, lambda: self.progress_var.set(100))
scene_count = len(mud_data.get("scenes", []))
self._update_status(f"完成! 已保存到 output/{safe_title}.html")
self.root.after(0, lambda: self._set_stage(3, False))
self.root.after(0, lambda: self.preview_btn.config(state=tk.NORMAL))
self.root.after(0, lambda: self.save_btn.config(state=tk.NORMAL))
except Exception as e:
import traceback
err_msg = str(e)
log.error(f"转换失败: {err_msg}")
log.error(traceback.format_exc())
self._update_status(f"错误: {err_msg}")
self.root.after(0, lambda msg=err_msg: messagebox.showerror("转换失败", msg))
finally:
self.converting = False
self.root.after(0, lambda: self.convert_btn.config(state=tk.NORMAL))
for i in range(4):
self.root.after(0, lambda idx=i: self._set_stage(idx, False))
def _update_status(self, text):
self.root.after(0, lambda: self.status_label.config(text=text))
def _preview_html(self):
if not self.html_output:
return
import webbrowser
if self.last_output_path and os.path.exists(self.last_output_path):
webbrowser.open(f"file://{self.last_output_path}")
else:
import tempfile
tmp = tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode="w", encoding="utf-8")
tmp.write(self.html_output)
tmp.close()
webbrowser.open(f"file://{tmp.name}")
def _clear_cache(self):
count = get_cache_size()
if count == 0:
messagebox.showinfo("提示", "缓存为空")
return
if messagebox.askyesno("确认", f"确定清空 {count} 个缓存文件?"):
cleared = clear_cache()
messagebox.showinfo("已清空", f"已清除 {cleared} 个缓存文件")
def _save_html(self):
if not self.html_output:
return
title = self.title_var.get() or "mud_game"
default_name = f"{title}.html"
path = filedialog.asksaveasfilename(
defaultextension=".html",
filetypes=[("HTML文件", "*.html")],
initialfile=default_name
)
if not path:
return
try:
with open(path, "w", encoding="utf-8") as f:
f.write(self.html_output)
messagebox.showinfo("保存成功", f"HTML游戏已保存到:\n{path}")
except Exception as e:
messagebox.showerror("保存失败", str(e))
def _refresh_genre_list(self):
self.genre_combo["values"] = get_genre_list()
def _on_genre_change(self, event=None):
pass
def _save_template(self):
genre = self.genre_var.get()
# 只有内置模板可以作为基础保存为自定义
template = generate_template(genre)
name = tk.simpledialog.askstring("保存模板", "输入模板名称:", initialvalue=f"{genre}_自定义")
if not name:
return
try:
path = save_template(name, template)
self._refresh_genre_list()
self.genre_var.set(name)
messagebox.showinfo("保存成功", f"模板已保存:\n{path}")
except Exception as e:
messagebox.showerror("保存失败", str(e))
def _delete_template(self):
genre = self.genre_var.get()
if genre in ["仙侠", "玄幻", "武侠", "悬疑", "科幻", "通用"]:
messagebox.showinfo("提示", "内置模板不可删除")
return
if not messagebox.askyesno("确认", f"确定删除模板 '{genre}' ?"):
return
if delete_template(genre):
self._refresh_genre_list()
self.genre_var.set("通用")
messagebox.showinfo("已删除", f"模板 '{genre}' 已删除")
else:
messagebox.showerror("错误", "删除失败")
def _load_config(self):
if not os.path.exists(CONFIG_PATH):
return
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
config = json.load(f)
if config.get("api_key"):
self.api_key_var.set(config["api_key"])
except Exception:
pass
def _save_config(self):
config = {}
if os.path.exists(CONFIG_PATH):
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
config = json.load(f)
except Exception:
pass
config["api_key"] = self.api_key_var.get().strip()
try:
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
messagebox.showinfo("保存成功", "API Key 已保存")
except Exception as e:
messagebox.showerror("保存失败", str(e))
def _fallback_scenes(game_structure: Dict) -> List[Dict]:
"""降级方案:从game_structure直接构建简单场景"""
scenes = []
rooms = game_structure.get("rooms", [])
for i, room in enumerate(rooms):
scene_id = "scene_start" if i == 0 else f"scene_{room.get('id', i)}"
choices = []
exits = room.get("exits", {})
for direction, target_room in exits.items():
target_id = f"scene_{target_room}" if target_room != rooms[0].get("id") else "scene_start"
choices.append({
"text": f"向{direction}走去",
"next_scene": target_id,
"effect": ""
})
if not choices and i < len(rooms) - 1:
choices.append({
"text": "继续前行",
"next_scene": f"scene_{rooms[i+1].get('id', i+1)}",
"effect": ""
})
scenes.append({
"id": scene_id,
"title": room.get("name", f"场景{i+1}"),
"description": room.get("description", "你来到了一个新的地方。"),
"choices": choices
})
return scenes