# -*- coding: utf-8 -*- """ 通用游戏文字脚本文档生成工具 功能:自动扫描项目目录,识别标准结构,生成游戏文字脚本Word文档。 无需手动配置路径,只需提供项目根目录即可自动完成全部流程。 配置表直接读取 xlsx(优先)或 csv,无需预先转换。 标准目录结构约定: / ├── *_config/ ← 配置表目录 (xlsx/csv) ├── *_game/ ← 游戏客户端目录 │ └── unity/Assets/Resources/.../atlas_ui/ ← 图标资源 │ └── unity/Assets/Scripts/.../*Const*.cs ← 系统提示常量 └── work/ ← 工作目录 (输出等) 正文编号使用 Word 自动编号列表,在 Word 中增删条目后编号会自动重排。 用法: python universal_tool.py [项目路径] [--output 输出路径] 依赖:pip install openpyxl python-docx """ import csv import os import re import sys import argparse from docx import Document from docx.shared import Pt, Cm from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.enum.table import WD_TABLE_ALIGNMENT from docx.oxml.ns import qn from docx.oxml import OxmlElement # ============ 常量 ============ CN_NUMBERS = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一"] # 11章节数据源定义(字段名取自表格英文头行第3行;优先读 xlsx,其次 csv) SECTION_DEFS = { "quest_texts": {"csv": "quest", "field": "desc", "filter": {"category": ["1", "2"]}}, "achievements": {"csv": "quest", "field": "desc", "filter": {"category": ["9"]}}, "guide_texts": {"csv": "guide", "field": "desc"}, "props": {"csv": "prop", "fields": ["name", "icon", "tips"], "icon_key": "atlas_product_icons"}, "skills": {"csv": "skill", "fields": ["name", "icon", "desc"], "icon_key": "atlas_skill"}, "runes": {"csv": "rune", "fields": ["name", "icon", "desc"], "icon_key": "atlas_rune_icons"}, "talents": {"csv": "playerlevel", "fields": ["name", "icon", "desc"], "icon_key": "atlas_talent_icons", "dedup": "name"}, "equips": {"csv": "equip", "fields": ["name", "icon"], "icon_key": "atlas_equip_icons", "dedup": "name"}, "level_names": {"csv": "fight_sample", "field": "name"}, } # ================================================================ # 一、项目结构自动扫描 # ================================================================ class ProjectScanner: """自动扫描并识别项目目录结构""" def __init__(self, project_root): self.root = os.path.abspath(project_root) self.config_dir = None self.game_dir = None self.csv_dir = None self.work_dir = None self.resources_dir = None self.icon_dirs = {} self.scripts_dir = None self.game_text_const = None self.project_name = "" self._scan() def _scan(self): # 1. 查找 _config / _game / work 目录 for entry in os.listdir(self.root): p = os.path.join(self.root, entry) if not os.path.isdir(p): continue if entry.endswith("_config"): self.config_dir = p elif entry.endswith("_game"): self.game_dir = p elif entry == "work": self.work_dir = p # 1b. 配置目录兜底:部分项目(如 HM6)把配置表放在 P-XXX_game/config # 而非独立的 *_config 目录。优先顶层 config 目录,其次游戏目录下的 config 子目录, # 取其中确实含 xlsx/csv 的那个作为 config_dir。 if not self.config_dir: candidates = [] top_config = os.path.join(self.root, "config") if os.path.isdir(top_config): candidates.append(top_config) if self.game_dir: gc = os.path.join(self.game_dir, "config") if os.path.isdir(gc): candidates.append(gc) for c in candidates: try: has_table = any(f.endswith((".xlsx", ".csv")) for f in os.listdir(c)) except OSError: has_table = False if has_table: self.config_dir = c break # 2. 项目名:优先从 *_config 目录名提取;否则从 *_game 目录名提取;再否则用 config 目录名 if self.config_dir and os.path.basename(self.config_dir).endswith("_config"): self.project_name = os.path.basename(self.config_dir).replace("_config", "") elif self.game_dir and os.path.basename(self.game_dir).endswith("_game"): self.project_name = os.path.basename(self.game_dir).replace("_game", "") elif self.config_dir: self.project_name = os.path.basename(self.config_dir) # 3. 数据表目录(csv_dir): work/cvs 优先(若存在),否则用 config_dir 本身(含xlsx) # find_file 优先查找 .xlsx,其次 .csv,两种格式都直接读取 if self.work_dir: cvs = os.path.join(self.work_dir, "cvs") if os.path.isdir(cvs) and os.listdir(cvs): self.csv_dir = cvs if not self.csv_dir: self.csv_dir = self.config_dir # 4&5. 图标目录: 在 game_dir 下递归搜索 atlas_ui 目录, # 在其中找 atlas_* 子目录(只保留含图片的),resources_dir 记为 atlas_ui 的父目录。 # 直接按 atlas_ui 定位可兼容 P-L12(unity/Assets/...) 与 L17(Assets/...) 任意层级, # 避免误匹配同名但错误的 Resources_moved 目录。 if self.game_dir: for root, dirs, _ in os.walk(self.game_dir): if os.path.basename(root) == "atlas_ui": self.resources_dir = os.path.dirname(root) for d in dirs: if d.startswith("atlas_"): dp = os.path.join(root, d) if any(f.lower().endswith((".png", ".jpg", ".jpeg")) for f in os.listdir(dp)): self.icon_dirs[d] = dp break # 找到第一个 atlas_ui 即止 # 6. Scripts 目录: 递归收集所有名为 Scripts 的目录,挑选"真实游戏脚本"目录。 # 规则:优先"直接位于 Assets 目录下"的 Scripts(Unity 游戏源码根目录约定, # 如 unity/Assets/Scripts 或 Assets/Scripts),排除 TutorialInfo/示例等非源码目录; # 在其中再优先含游戏常量文件(GameError/GameText/Error/Text/Event/Msg Const)者; # 无 Assets/Scripts 时回退到全部候选中"含游戏常量"或".cs 最多"的目录。 # 同时跳过 Library(Unity 包缓存, 文件巨多且非游戏源码),避免误选并加速。 if self.game_dir: all_scripts = [] for root, dirs, _ in os.walk(self.game_dir): if "Library" in dirs: dirs.remove("Library") # 不进入 Unity 包缓存 if os.path.basename(root) == "Scripts": all_scripts.append(root) # 直接位于 Assets 下的 Scripts 目录(游戏源码根) assets_scripts = [p for p in all_scripts if os.path.basename(os.path.dirname(p)) == "Assets"] candidates = assets_scripts if assets_scripts else all_scripts def _has_game_const(p): # 递归查找游戏常量文件(常量常嵌套在子目录, 不能只看直接子文件) try: for r, _, files in os.walk(p): for f in files: if f.endswith(".cs") and "Const" in f and any( k in f for k in ("GameError", "GameText", "Error", "Text", "Event", "Msg") ): return True except OSError: pass return False def _score(p): return 1000 if _has_game_const(p) else 0 if candidates: self.scripts_dir = max(candidates, key=_score) # 7. 系统提示常量文件: 收集所有 *Const*.cs, # 优先 ErrorConst/GameErrorConst/GameTextConst/TextConst,否则取第一个。 if self.scripts_dir: const_files = [] for root, _, files in os.walk(self.scripts_dir): for f in files: if f.endswith(".cs") and "Const" in f: const_files.append(os.path.join(root, f)) if const_files: def _pick(sub): for p in const_files: if sub in os.path.basename(p): return p return None self.game_text_const = ( _pick("GameErrorConst") or _pick("ErrorConst") or _pick("GameTextConst") or _pick("TextConst") or const_files[0] ) def find_file(self, name): """在 csv_dir 或 config_dir 中查找文件(优先 .xlsx,其次 .csv)""" for d in [self.csv_dir, self.config_dir]: if not d: continue for ext in [".xlsx", ".csv"]: p = os.path.join(d, name + ext) if os.path.isfile(p): return p return None def summary(self): """打印扫描结果""" lines = [ f" 项目名称: {self.project_name}", f" 配置目录: {self.config_dir or '未找到'}", f" 游戏目录: {self.game_dir or '未找到'}", f" 数据表目录: {self.csv_dir or '未找到'}", f" 图标目录: {len(self.icon_dirs)}个 {list(self.icon_dirs.keys())}", f" 脚本目录: {self.scripts_dir or '未找到'}", f" 常量文件: {os.path.basename(self.game_text_const) if self.game_text_const else '未找到'}", ] return "\n".join(lines) # ================================================================ # 二、表格数据读取(xlsx/csv 自动识别 + 动态字段名解析) # ================================================================ def read_table(filepath): """ 统一表格读取(支持 .xlsx / .csv),返回 (headers, rows): headers: 第3行英文字段名列表 rows: 第4行起的数据行列表 (list of list) xlsx 直接读取,无需先转 csv。 """ ext = os.path.splitext(filepath)[1].lower() if ext == ".xlsx": return _read_xlsx(filepath) return _read_csv(filepath) def _read_csv(filepath): """读取CSV文件,自动检测编码 (utf-8-sig/gbk/gb2312/utf-8)。""" for enc in ["utf-8-sig", "gbk", "gb2312", "utf-8"]: try: with open(filepath, "r", encoding=enc) as f: all_rows = list(csv.reader(f)) if len(all_rows) < 4: return all_rows[-1] if all_rows else [], [] return all_rows[2], all_rows[3:] except (UnicodeDecodeError, UnicodeError): continue return [], [] def _read_xlsx(filepath): """直接读取 xlsx(无需先转csv),表头结构与csv一致:第3行字段名,第4行起数据。""" from openpyxl import load_workbook wb = load_workbook(filepath, read_only=True, data_only=True) ws = wb.active all_rows = [] for row in ws.iter_rows(values_only=True): all_rows.append([str(c) if c is not None else "" for c in row]) wb.close() if len(all_rows) < 4: return all_rows[-1] if all_rows else [], [] return all_rows[2], all_rows[3:] def field_map(headers): """将英文头行转为 {字段名: 列索引} 映射""" return {h.strip(): i for i, h in enumerate(headers) if h.strip()} def field_val(row, fm, name, default=""): """从数据行中按字段名取值""" idx = fm.get(name) if idx is not None and idx < len(row): return row[idx].strip() return default # ================================================================ # 三、图片资源查找 # ================================================================ def find_image(icon_name, icon_dirs, icon_key): """根据 icon 名称在指定目录中查找图片文件""" icon_dir = icon_dirs.get(icon_key) if not icon_dir or not os.path.isdir(icon_dir): return None # 直接匹配 for ext in [".png", ".jpg"]: p = os.path.join(icon_dir, icon_name + ext) if os.path.isfile(p): return p # 补零匹配 (如 icon_product_1 -> icon_product_01) m = re.match(r"^(.+?)(\d+)$", icon_name) if m: prefix, num = m.group(1), m.group(2) for pad in [2, 3]: padded = prefix + num.zfill(pad) for ext in [".png", ".jpg"]: p = os.path.join(icon_dir, padded + ext) if os.path.isfile(p): return p return None # ================================================================ # 四、系统提示常量 / 事件文本解析 # ================================================================ def parse_game_text_const(cs_path, scripts_dir): """ 自动解析 GameTextConst.cs 中所有 public const string 定义, 扫描项目中其他 .cs 文件确认哪些常量被引用, 返回去重后的文本列表。 """ if not cs_path or not os.path.isfile(cs_path): return [] # 读取文件 content = None for enc in ["utf-8-sig", "utf-8", "gbk", "gb2312"]: try: with open(cs_path, "r", encoding=enc) as f: content = f.read() break except (UnicodeDecodeError, UnicodeError): continue if content is None: return [] # 提取常量:public const string Name = "value"; pattern = r'public\s+const\s+string\s+(\w+)\s*=\s*"((?:[^"\\]|\\.)*)"\s*;' all_consts = {} for m in re.finditer(pattern, content): name, value = m.group(1), m.group(2) # 还原转义 value = value.replace("\\n", "\n").replace("\\t", "\t").replace('\\"', '"') all_consts[name] = value if not all_consts: return [] # 扫描引用 const_names = set(all_consts.keys()) referenced = set() cs_file = os.path.basename(cs_path) if scripts_dir and os.path.isdir(scripts_dir): for root, _, files in os.walk(scripts_dir): for fname in files: if not fname.endswith(".cs") or fname == cs_file: continue try: with open(os.path.join(root, fname), "r", encoding="utf-8", errors="ignore") as f: src = f.read() for cn in const_names: if cn in src: referenced.add(cn) except Exception: continue # 去重文本值 # 优先保留"被其他源码引用"的常量;若引用扫描未命中(项目引用方式不同,如 L17 把错误码集中管理), # 则回退为使用常量文件中的全部 public const string,避免整章变空。 seen = set() result = [] src_items = ((n, t) for n, t in all_consts.items() if n in referenced) if referenced else all_consts.items() for name, text in src_items: if text not in seen: seen.add(text) result.append(text) return result def parse_event_system_tips(scripts_dir): """ 扫描 scripts_dir 下所有 .cs 文件,提取通过 event_system_tip 事件触发显示的 系统提示文本 —— 即形如 TriggerEventGlobal(GameEventConst.event_system_tip, "文本") 的第二个字符串参数(真正的弹窗提示文案)。 跳过 // 注释行。返回去重后的文本列表。 说明:不少项目(如 L17)并未把系统提示集中写在 GameTextConst/GameErrorConst 里, 而是散落在成百上千个 .cs 文件中的 event_system_tip 事件调用里, GameEventConst 本身只继承 FGameEventConst 定义事件名常量, 因此必须扫描调用现场才能拿到真实文案。 """ if not scripts_dir or not os.path.isdir(scripts_dir): return [] # event_system_tip 之后(允许跨空白/换行)紧跟逗号与字符串字面量 pattern = re.compile(r'event_system_tip\b[^;{]*?,\s*"((?:[^"\\]|\\.)*)"', re.DOTALL) seen = set() result = [] for root, _, files in os.walk(scripts_dir): for fname in files: if not fname.endswith(".cs"): continue try: with open(os.path.join(root, fname), "r", encoding="utf-8", errors="ignore") as f: lines = f.readlines() except Exception: continue # 先逐行剔除 // 注释,再拼接扫描,避免误抓注释里的写法 clean = [] for line in lines: idx = line.find("//") if idx != -1: line = line[:idx] clean.append(line) src = "\n".join(clean) for m in pattern.finditer(src): text = m.group(1) # 还原转义 text = text.replace("\\n", "\n").replace("\\t", "\t").replace('\\"', '"') if text and text not in seen: seen.add(text) result.append(text) return result def get_system_prompts(scanner): """ 系统提示统一数据源:合并两类来源并去重: 1) GameErrorConst / GameTextConst 等常量文件中的 public const string 值 (parse_game_text_const, 含回退到全部常量)。 2) 散落在各 .cs 文件中的 event_system_tip 事件触发文本 (parse_event_system_tips, 真正弹窗提示文案)。 """ const_prompts = parse_game_text_const(scanner.game_text_const, scanner.scripts_dir) event_tips = parse_event_system_tips(scanner.scripts_dir) seen = set() combined = [] for t in const_prompts + event_tips: if t and t not in seen: seen.add(t) combined.append(t) return combined # ================================================================ # 五、文档格式化辅助 # ================================================================ def _run(para, text, font="仿宋", size=14, bold=False, underline=None): """创建带格式的 run""" r = para.add_run(text) r.font.name = font r._element.rPr.rFonts.set(qn("w:eastAsia"), font) r.font.size = Pt(size) r.font.bold = bold if underline is not None: r.font.underline = underline return r def add_title(doc, text): """标题:宋体 二号(22pt) 加粗 居中""" p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER _run(p, text, font="宋体", size=22, bold=True, underline=False) def add_section_heading(doc, cn_num, title): """小标题:仿宋 四号(14pt) 加粗 中文编号""" p = doc.add_paragraph() _run(p, f"{cn_num}、{title}", font="仿宋", size=14, bold=True) # ---- Word 自动编号辅助 ---- # 用 Word 的 numbering 机制生成 1. 2. 3. ... 列表, # 这样在 Word 中增删条目后编号会自动重排,无需手改数字。 def _get_numbering_element(doc): """获取文档的 根元素(默认模板已含 numbering part)。""" try: return doc.part.numbering_part.element except KeyError: raise RuntimeError("当前文档模板缺少 numbering part,无法使用自动编号。") def _create_decimal_numbering(doc): """ 新建一个十进制自动编号定义 (1. 2. 3. ...),返回新的 numId。 每次调用返回独立的 numId,编号从 1 开始, 因此每个章节调用一次即可获得独立计数。 """ numbering = _get_numbering_element(doc) abs_ids = [int(an.get(qn('w:abstractNumId'))) for an in numbering.findall(qn('w:abstractNum'))] num_ids = [int(n.get(qn('w:numId'))) for n in numbering.findall(qn('w:num'))] next_abs = (max(abs_ids) + 1) if abs_ids else 0 next_num = (max(num_ids) + 1) if num_ids else 1 # abstract_num = OxmlElement('w:abstractNum') abstract_num.set(qn('w:abstractNumId'), str(next_abs)) multi_level = OxmlElement('w:multiLevelType') multi_level.set(qn('w:val'), 'singleLevel') abstract_num.append(multi_level) lvl = OxmlElement('w:lvl') lvl.set(qn('w:ilvl'), '0') start = OxmlElement('w:start') start.set(qn('w:val'), '1') lvl.append(start) num_fmt = OxmlElement('w:numFmt') num_fmt.set(qn('w:val'), 'decimal') lvl.append(num_fmt) lvl_text = OxmlElement('w:lvlText') lvl_text.set(qn('w:val'), '%1.') lvl.append(lvl_text) lvl_jc = OxmlElement('w:lvlJc') lvl_jc.set(qn('w:val'), 'left') lvl.append(lvl_jc) p_pr = OxmlElement('w:pPr') ind = OxmlElement('w:ind') ind.set(qn('w:left'), '420') ind.set(qn('w:hanging'), '420') p_pr.append(ind) lvl.append(p_pr) abstract_num.append(lvl) # num = OxmlElement('w:num') num.set(qn('w:numId'), str(next_num)) abs_ref = OxmlElement('w:abstractNumId') abs_ref.set(qn('w:val'), str(next_abs)) num.append(abs_ref) # abstractNum 必须排在所有 num 之前 last_abs = numbering.findall(qn('w:abstractNum')) if last_abs: last_abs[-1].addnext(abstract_num) else: numbering.insert(0, abstract_num) numbering.append(num) return next_num def _apply_numbering(paragraph, num_id, ilvl=0): """给段落应用自动编号 (设置 numPr)。""" p_pr = paragraph._p.get_or_add_pPr() for existing in p_pr.findall(qn('w:numPr')): p_pr.remove(existing) num_pr = OxmlElement('w:numPr') ilvl_el = OxmlElement('w:ilvl') ilvl_el.set(qn('w:val'), str(ilvl)) num_pr.append(ilvl_el) num_id_el = OxmlElement('w:numId') num_id_el.set(qn('w:val'), str(num_id)) num_pr.append(num_id_el) p_pr.append(num_pr) def add_numbered_items(doc, items): """ 编号列表:仿宋 四号 不加粗,使用 Word 自动编号。 每次调用创建独立编号序列(从 1 开始), 在 Word 中增删条目后编号会自动重排。 """ if not items: return num_id = _create_decimal_numbering(doc) for text in items: p = doc.add_paragraph() _apply_numbering(p, num_id) _run(p, str(text), font="仿宋", size=14, bold=False) def _set_cell(cell, text, bold=False, size=14): """设置表格单元格文本""" cell.text = "" p = cell.paragraphs[0] p.alignment = WD_ALIGN_PARAGRAPH.CENTER _run(p, str(text), font="仿宋", size=size, bold=bold) def _insert_img(cell, img_path): """在单元格中插入图片(高度3cm 锁定纵横比)""" p = cell.paragraphs[0] p.alignment = WD_ALIGN_PARAGRAPH.CENTER p.add_run().add_picture(img_path, height=Cm(3)) def add_data_table(doc, headers, rows, icon_dirs=None, icon_key=None): """通用数据表格(支持图片列)""" table = doc.add_table(rows=1, cols=len(headers), style="Table Grid") table.alignment = WD_TABLE_ALIGNMENT.CENTER for i, h in enumerate(headers): _set_cell(table.rows[0].cells[i], h, bold=True, size=10) for rd in rows: row = table.add_row() _set_cell(row.cells[0], rd[0]) if len(rd) > 1: icon_name = rd[1] img = find_image(icon_name, icon_dirs, icon_key) if icon_name and icon_dirs else None if img: _insert_img(row.cells[1], img) else: _set_cell(row.cells[1], "", size=10) if len(rd) > 2: _set_cell(row.cells[2], rd[2], size=10) def add_name_table(doc, names): """玩家姓名表格(姓/名 两列)""" table = doc.add_table(rows=1, cols=2, style="Table Grid") table.alignment = WD_TABLE_ALIGNMENT.CENTER for i, h in enumerate(["姓", "名"]): _set_cell(table.rows[0].cells[i], h, bold=True) for surname, given in names: row = table.add_row() _set_cell(row.cells[0], surname) _set_cell(row.cells[1], given) # ================================================================ # 六、数据提取函数 # ================================================================ def get_player_names(scanner): """获取玩家随机名称(优先 name.xlsx/csv,否则使用默认列表)""" fp = scanner.find_file("name") if fp: headers, rows = read_table(fp) fm = field_map(headers) if "first" in fm and "last" in fm: seen = set() result = [] for r in rows: first = field_val(r, fm, "first") last = field_val(r, fm, "last") if first and last: key = (first, last) if key not in seen: seen.add(key) result.append(key) if result: return result # 默认名称(100对) return [ ("李", "子轩"), ("王", "梓涵"), ("张", "浩然"), ("刘", "欣怡"), ("陈", "宇轩"), ("杨", "晨曦"), ("赵", "俊杰"), ("黄", "雅静"), ("周", "天佑"), ("吴", "梦琪"), ("徐", "博文"), ("孙", "思彤"), ("胡", "睿智"), ("朱", "静怡"), ("高", "昊天"), ("林", "雨桐"), ("何", "泽宇"), ("郭", "婉清"), ("马", "俊熙"), ("罗", "雪柔"), ("梁", "子豪"), ("宋", "依琳"), ("郑", "宇航"), ("谢", "诗涵"), ("韩", "志强"), ("唐", "梦瑶"), ("冯", "文博"), ("于", "雅楠"), ("董", "天宇"), ("萧", "欣妍"), ("程", "子睿"), ("曹", "静雯"), ("袁", "俊豪"), ("邓", "雨欣"), ("许", "浩宇"), ("傅", "语嫣"), ("沈", "子涵"), ("曾", "思雨"), ("彭", "博涛"), ("吕", "雅婷"), ("苏", "天翔"), ("卢", "梦洁"), ("蒋", "文轩"), ("蔡", "雪梅"), ("贾", "志远"), ("丁", "依娜"), ("魏", "子安"), ("薛", "静雅"), ("叶", "俊峰"), ("阎", "雨婷"), ("余", "浩轩"), ("潘", "诗琪"), ("杜", "子恒"), ("戴", "思雅"), ("夏", "博超"), ("钟", "雅琪"), ("汪", "天瑞"), ("田", "梦菲"), ("任", "文昊"), ("姜", "雪莲"), ("范", "志鹏"), ("方", "依婷"), ("石", "子健"), ("姚", "静淑"), ("谭", "俊凯"), ("廖", "雨菲"), ("邹", "浩南"), ("熊", "诗瑶"), ("金", "子宁"), ("陆", "思琪"), ("郝", "博远"), ("孔", "雅静"), ("白", "天浩"), ("崔", "梦婷"), ("康", "文杰"), ("毛", "雪琪"), ("邱", "志伟"), ("秦", "依静"), ("江", "子文"), ("史", "静怡"), ("顾", "俊杰"), ("侯", "雨萱"), ("邵", "浩天"), ("孟", "诗涵"), ("龙", "子辰"), ("万", "思妍"), ("段", "博文"), ("雷", "雅欣"), ("钱", "天佑"), ("汤", "梦瑶"), ("尹", "文豪"), ("黎", "雪莹"), ("易", "志强"), ("常", "依琳"), ("武", "子轩"), ("乔", "静雯"), ("贺", "俊熙"), ("赖", "雨桐"), ("龚", "浩宇"), ("文", "诗琪"), ] def get_csv_list_data(scanner, csv_name, field_name, filter_dict=None): """通用:从表格(xlsx/csv)中按字段名提取去重文本列表""" fp = scanner.find_file(csv_name) if not fp: return [] headers, rows = read_table(fp) fm = field_map(headers) seen = set() result = [] for r in rows: # 过滤条件 if filter_dict: ok = True for fk, fvs in filter_dict.items(): if field_val(r, fm, fk) not in fvs: ok = False break if not ok: continue val = field_val(r, fm, field_name) if val and val not in seen: seen.add(val) result.append(val) return result def get_csv_table_data(scanner, csv_name, field_names, dedup_field=None): """通用:从表格(xlsx/csv)中提取多字段表格数据 [(f1, f2, f3, ...), ...]""" fp = scanner.find_file(csv_name) if not fp: return [] headers, rows = read_table(fp) fm = field_map(headers) seen = set() result = [] for r in rows: vals = tuple(field_val(r, fm, fn) for fn in field_names) if not vals[0]: continue if dedup_field: key = vals[0] if key in seen: continue seen.add(key) result.append(vals) return result # ================================================================ # 七、文档构建主流程 # ================================================================ def build_document(scanner, output_path): """根据扫描结果构建完整文档""" print("\n[1/4] 准备数据源...") # 直接从 config_dir / csv_dir 读取 xlsx(优先) 或 csv,不再转换。 data_dir = scanner.csv_dir or scanner.config_dir if data_dir: files = [f for f in os.listdir(data_dir) if not f.startswith("~$")] xlsx_n = sum(1 for f in files if f.endswith(".xlsx")) csv_n = sum(1 for f in files if f.endswith(".csv")) print(f" 数据目录: {data_dir} (xlsx {xlsx_n} 个 / csv {csv_n} 个)") # 预加载数据 # 系统提示 = GameErrorConst/GameTextConst 常量值 合并 event_system_tip 触发文本 system_prompts = get_system_prompts(scanner) player_names = get_player_names(scanner) print(f" 系统提示: {len(system_prompts)}条") print(f" 玩家名称: {len(player_names)}对") print("\n[2/4] 创建文档...") doc = Document() style = doc.styles["Normal"] style.font.name = "仿宋" style.font.size = Pt(14) style._element.rPr.rFonts.set(qn("w:eastAsia"), "仿宋") doc_title = f"《{scanner.project_name}》文字脚本" if scanner.project_name else "游戏文字脚本" add_title(doc, doc_title) print("\n[3/4] 写入章节...") icon_dirs = scanner.icon_dirs sections_done = 0 # --- 一、系统提示 --- add_section_heading(doc, CN_NUMBERS[0], "系统提示") add_numbered_items(doc, system_prompts) print(f" 一、系统提示: {len(system_prompts)}条") sections_done += 1 # --- 二、任务文本 --- quest_def = SECTION_DEFS["quest_texts"] quest_texts = get_csv_list_data(scanner, quest_def["csv"], quest_def["field"], quest_def.get("filter")) add_section_heading(doc, CN_NUMBERS[1], "任务文本") add_numbered_items(doc, quest_texts) print(f" 二、任务文本: {len(quest_texts)}条") sections_done += 1 # --- 三、成就文本 --- ach_def = SECTION_DEFS["achievements"] ach_texts = get_csv_list_data(scanner, ach_def["csv"], ach_def["field"], ach_def.get("filter")) add_section_heading(doc, CN_NUMBERS[2], "成就文本") add_numbered_items(doc, ach_texts) print(f" 三、成就文本: {len(ach_texts)}条") sections_done += 1 # --- 四、玩家随机名称文本 --- add_section_heading(doc, CN_NUMBERS[3], "玩家随机名称文本") add_name_table(doc, player_names) print(f" 四、玩家随机名称文本: {len(player_names)}对") sections_done += 1 # --- 五、新手指引文本 --- guide_def = SECTION_DEFS["guide_texts"] guide_texts = get_csv_list_data(scanner, guide_def["csv"], guide_def["field"]) add_section_heading(doc, CN_NUMBERS[4], "新手指引文本") add_numbered_items(doc, guide_texts) print(f" 五、新手指引文本: {len(guide_texts)}条") sections_done += 1 # --- 六、道具说明 --- prop_def = SECTION_DEFS["props"] prop_data = get_csv_table_data(scanner, prop_def["csv"], prop_def["fields"]) add_section_heading(doc, CN_NUMBERS[5], "道具说明") add_data_table(doc, ["道具名称", "图标", "道具描述"], prop_data, icon_dirs, prop_def["icon_key"]) print(f" 六、道具说明: {len(prop_data)}条") sections_done += 1 # --- 七、技能 --- skill_def = SECTION_DEFS["skills"] skill_data = get_csv_table_data(scanner, skill_def["csv"], skill_def["fields"]) add_section_heading(doc, CN_NUMBERS[6], "技能") add_data_table(doc, ["技能名称", "技能图标", "技能描述"], skill_data, icon_dirs, skill_def["icon_key"]) print(f" 七、技能: {len(skill_data)}条") sections_done += 1 # --- 八、灵石 --- rune_def = SECTION_DEFS["runes"] rune_data = get_csv_table_data(scanner, rune_def["csv"], rune_def["fields"]) add_section_heading(doc, CN_NUMBERS[7], "灵石") add_data_table(doc, ["灵石名称", "灵石图标", "灵石介绍"], rune_data, icon_dirs, rune_def["icon_key"]) print(f" 八、灵石: {len(rune_data)}条") sections_done += 1 # --- 九、天赋 --- talent_def = SECTION_DEFS["talents"] talent_data = get_csv_table_data(scanner, talent_def["csv"], talent_def["fields"], talent_def.get("dedup")) add_section_heading(doc, CN_NUMBERS[8], "天赋") add_data_table(doc, ["天赋名称", "天赋图标", "天赋介绍"], talent_data, icon_dirs, talent_def["icon_key"]) print(f" 九、天赋: {len(talent_data)}条") sections_done += 1 # --- 十、装备 --- equip_def = SECTION_DEFS["equips"] equip_data = get_csv_table_data(scanner, equip_def["csv"], equip_def["fields"], equip_def.get("dedup")) add_section_heading(doc, CN_NUMBERS[9], "装备") add_data_table(doc, ["装备名称", "装备图标"], equip_data, icon_dirs, equip_def["icon_key"]) print(f" 十、装备: {len(equip_data)}条") sections_done += 1 # --- 十一、关卡名称 --- level_def = SECTION_DEFS["level_names"] level_names = get_csv_list_data(scanner, level_def["csv"], level_def["field"]) add_section_heading(doc, CN_NUMBERS[10], "关卡名称") add_numbered_items(doc, level_names) print(f" 十一、关卡名称: {len(level_names)}条") sections_done += 1 print(f"\n[4/4] 保存文档...") doc.save(output_path) print(f" 文档已生成: {output_path}") print(f" 共 {sections_done} 个章节") # ================================================================ # 主入口 # ================================================================ def main(): parser = argparse.ArgumentParser(description="通用游戏文字脚本文档生成工具") parser.add_argument("project_path", nargs="?", help="项目根目录路径 (默认: 当前目录)") parser.add_argument("--output", "-o", help="输出文档路径 (默认: 自动生成)") args = parser.parse_args() project_path = args.project_path or os.getcwd() if not os.path.isdir(project_path): print(f"错误: 目录不存在 - {project_path}") sys.exit(1) print(f"{'='*50}") print(f" 通用游戏文字脚本生成工具") print(f"{'='*50}") print(f"\n扫描项目: {project_path}") scanner = ProjectScanner(project_path) print(f"\n{scanner.summary()}") if not scanner.csv_dir and not scanner.config_dir: print("\n错误: 未找到配置目录 (*_config) 或CSV目录") sys.exit(1) if args.output: output_path = args.output else: work_dir = scanner.work_dir or os.path.join(project_path, "work") os.makedirs(work_dir, exist_ok=True) title = scanner.project_name or "游戏" output_path = os.path.join(work_dir, f"《{title}》游戏文字脚本.docx") build_document(scanner, output_path) print(f"\n{'='*50}") print(f" 全部完成!") print(f"{'='*50}") if __name__ == "__main__": main()