Files
docxmaker/wenzijiaoben/py/universal_tool.py
T
2026-09-03 15:37:54 +08:00

895 lines
35 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.
# -*- coding: utf-8 -*-
"""
通用游戏文字脚本文档生成工具
功能:自动扫描项目目录,识别标准结构,生成游戏文字脚本Word文档。
无需手动配置路径,只需提供项目根目录即可自动完成全部流程。
配置表直接读取 xlsx(优先)或 csv,无需预先转换。
标准目录结构约定:
<project_root>/
├── *_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):
"""获取文档的 <w:numbering> 根元素(默认模板已含 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
# <w:abstractNum>
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)
# <w:num>
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()