feat:初版

This commit is contained in:
2025-12-26 18:10:39 +08:00
commit fd15f9eb8f
17 changed files with 3486 additions and 0 deletions

48
README.md Normal file
View File

@@ -0,0 +1,48 @@
# PZ 配置 WebUI 编辑器server.ini + server_SandboxVars.lua
这个小工具用于在浏览器里逐项编辑《僵尸毁灭工程 / Project Zomboid》的两个配置文件
- `server.ini`
- `server_SandboxVars.lua`
支持:
- 精确到每一项设置的表单编辑(布尔/数字/下拉枚举/文本)
- 每项设置显示中文说明(`server.ini` 直接读取文件内的中文注释;`server_SandboxVars.lua` 使用 `i18n/sandboxvars_zh.json`
- 一键导出修改后的配置(下载 zip或选择“保存覆盖本地”
## 运行
在本目录(包含两个配置文件)打开终端:
```powershell
python -m pip install -r requirements.txt
python .\\pz_webui.py
```
然后浏览器打开:
```
http://127.0.0.1:5050
```
## 导出
- 点击页面底部 `下载配置 ZIP`:不会改动本地文件,会下载一个 zip里面包含修改后的两个文件。
- 点击 `保存覆盖本地`:会直接覆盖写回当前路径下的两个文件(建议先下载 zip 备份)。
## 可选:自定义文件路径/端口
```powershell
python .\\pz_webui.py --ini .\\server.ini --lua .\\server_SandboxVars.lua --host 127.0.0.1 --port 5050
```
## 重新生成 SandboxVars 中文说明(可选)
`i18n/sandboxvars_zh.json` 是离线映射文件。如果你的 `server_SandboxVars.lua` 更新了(新增/变更项),可以重新生成:
```powershell
python .\\tools\\generate_sandboxvars_zh.py --lua .\\server_SandboxVars.lua --out .\\i18n\\sandboxvars_zh.json --resume
```
说明:生成脚本会使用 Google Translate 的公开接口做机翻,仅用于生成本地离线文件。

Binary file not shown.

269
i18n/sandboxvars_zh.json Normal file
View File

@@ -0,0 +1,269 @@
{
"VERSION": "SandboxVars 文件版本号(通常不用改)。",
"StartYear": "开局年份(通常与开局日期/月份一起使用)。",
"Zombies": "更改此设置还会设置“高级僵尸选项”中的“人口倍增器”。默认 = 正常\n1 = 疯狂\n2 = 非常高\n3 = 高\n4 = 正常\n5 = 低\n6 = 无",
"Distribution": "僵尸如何分布在地图上。默认=以城市为中心\n1 = 以城市为中心\n2 = 均匀",
"ZombieVoronoiNoise": "控制是否将某些随机化应用于僵尸分布。",
"ZombieRespawn": "新僵尸添加到世界的频率。默认 = 正常\n1 = 高\n2 = 正常\n3 = 低\n4 = 无",
"ZombieMigrate": "僵尸允许迁移到空的单元格。",
"DayLength": "默认 = 1 小时 30 分钟\n1 = 15 分钟\n2 = 30 分钟\n3 = 1 小时\n4 = 1 小时 30 分钟\n5 = 2 小时\n6 = 3 小时\n7 = 4 小时\n8 = 5 小时\n9 = 6 小时\n10 = 7 小时\n11 = 8 小时\n12 = 9 小时\n13 = 10 小时\n14 = 11 小时\n15 = 12 小时\n16 = 13 小时\n17 = 14 小时\n18 = 15 小时\n19 = 16 小时\n20 = 17 小时\n21 = 18 小时\n22 = 19 小时\n23 = 20 小时\n24 = 21 小时\n25 = 22 小时\n26 = 23 小时\n27 = 实时",
"StartMonth": "比赛开始的月份。默认 = 七月\n1 = 一月\n2 = 二月\n3 = 三月\n4 = 四月\n5 = 五月\n6 = 六月\n7 = 七月\n8 = 八月\n9 = 九月\n10 = 十月\n11 = 十一月\n12 = 十二月",
"StartDay": "比赛开始的月份的哪一天。",
"StartTime": "一天中比赛开始的时间。默认 = 上午 9 点\n1 = 上午 7 点\n2 = 上午 9 点\n3 = 中午 12 点\n4 = 下午 2 点\n5 = 下午 5 点\n6 = 晚上 9 点\n7 = 上午 12 点\n8 = 凌晨 2 点\n9 = 凌晨 5 点",
"DayNightCycle": "一天中的时间是否自然变化,或者始终是白天/黑夜。默认 = 正常\n1 = 正常\n2 = 无尽的一天\n3 = 无尽的夜晚",
"ClimateCycle": "天气是否变化或保持在单一状态。默认 = 正常\n1 = 正常\n2 = 无天气\n3 = 无尽的雨\n4 = 无尽风暴\n5 = 无尽的雪\n6 = 无尽的暴风雪",
"FogCycle": "雾是自然发生、从未发生还是始终存在。默认 = 正常\n1 = 正常\n2 = 无雾\n3 = 无尽的迷雾",
"WaterShut": "在默认开始日期1993 年 7 月 9 日)之后多久,管道装置(例如水槽)不再是无限水源。默认 = 0-30 天\n1 = 即时\n2 = 0-30 天\n3 = 0-2 个月\n4 = 0-6 个月\n5 = 0-1 年\n6 = 0-5 年\n7 = 2-6 个月\n8 = 6-12 个月\n9 = 禁用",
"ElecShut": "默认开始日期1993 年 7 月 9 日)后多久,世界电力将永久关闭。默认 = 0-30 天\n1 = 即时\n2 = 0-30 天\n3 = 0-2 个月\n4 = 0-6 个月\n5 = 0-1 年\n6 = 0-5 年\n7 = 2-6 个月\n8 = 6-12 个月\n9 = 禁用",
"AlarmDecay": "电源关闭后报警电池可以持续多长时间。默认 = 0-30 天\n1 = 即时\n2 = 0-30 天\n3 = 0-2 个月\n4 = 0-6 个月\n5 = 0-1 年\n6 = 0-5 年",
"WaterShutModifier": "在默认开始日期1993 年 7 月 9 日)之后多久,管道装置(例如水槽)不再是无限水源。最小值:-1 最大值2147483647 默认值14",
"ElecShutModifier": "默认开始日期1993 年 7 月 9 日)后多久,世界电力将永久关闭。最小值:-1 最大值2147483647 默认值14",
"AlarmDecayModifier": "电源关闭后报警电池可以持续多长时间。最小值:-1 最大值2147483647 默认值14",
"FoodLootNew": "任何可能腐烂或变质的食物。最小值0.00 最大值4.00 默认值0.60",
"LiteratureLootNew": "所有可读取的项目,包括传单 最小值0.00 最大值4.00 默认值0.60",
"MedicalLootNew": "药品、绷带和急救工具。最小值0.00 最大值4.00 默认值0.60",
"SurvivalGearsLootNew": "钓鱼竿、帐篷、野营装备等 最小值0.00 最大值4.00 默认值0.60",
"CannedFoodLootNew": "罐头和干食品、饮料。最小值0.00 最大值4.00 默认值0.60",
"WeaponLootNew": "不属于其他类别工具的武器。最小值0.00 最大值4.00 默认值0.60",
"RangedWeaponLootNew": "还包括武器附件。最小值0.00 最大值4.00 默认值0.60",
"AmmoLootNew": "散装弹药、盒子和杂志。最小值0.00 最大值4.00 默认值0.60",
"MechanicsLootNew": "车辆零件以及安装它们所需的工具。最小值0.00 最大值4.00 默认值0.60",
"OtherLootNew": "其他一切。也会影响城镇/道路区域中所有物品的觅食。最小值0.00 最大值4.00 默认值0.60",
"ClothingLootNew": "所有不是容器的可穿戴物品。最小值0.00 最大值4.00 默认值0.60",
"ContainerLootNew": "背包和其他可穿戴/可装备的容器例如。案例。最小值0.00 最大值4.00 默认值0.60",
"KeyLootNew": "建筑物/汽车钥匙、钥匙圈和锁。最小值0.00 最大值4.00 默认值0.60",
"MediaLootNew": "VHS 磁带和 CD。最小值0.00 最大值4.00 默认值0.60",
"MementoLootNew": "Spiffo 物品、毛绒玩具和其他可收藏的纪念品例如。照片。最小值0.00 最大值4.00 默认值0.60",
"CookwareLootNew": "用于烹饪的物品包括那些可以作为武器的物品例如刀。不包括食物。包括可用和不可用的物品。最小值0.00 最大值4.00 默认值0.60",
"MaterialLootNew": "用作制作或建筑原料的物品和武器。这是一个一般类别不包括属于其他类别例如炊具或医疗用品的物品。不包括工具。最小值0.00 最大值4.00 默认值0.60",
"FarmingLootNew": "用于动植物农业的物品和武器例如种子、抹刀或铲子。最小值0.00 最大值4.00 默认值0.60",
"ToolLootNew": "属于工具但不属于其他类别例如机械或农业的物品和武器。最小值0.00 最大值4.00 默认值0.60",
"RollsMultiplier": "<BHC> [!] 建议您不要更改此设置。 [!] <RGB:1,1,1> 可用于调整生成战利品时在战利品表上进行的掷骰数。不会将掷骰数减少到 1 以下。如果设置为高值可能会对性能产生负面影响。强烈建议不要更改此设置。最小值0.10 最大值100.00 默认值1.00",
"LootItemRemovalList": "以逗号分隔的物品类型列表,不会作为普通战利品生成。",
"RemoveStoryLoot": "如果启用,战利品移除列表中的物品或稀有度设置为“无”的物品将不会在随机世界故事中生成。",
"RemoveZombieLoot": "如果启用,战利品移除列表中的物品或稀有度设置为“无”的物品将不会生成被僵尸穿戴或附着的物品。",
"ZombiePopLootEffect": "如果大于 0则战利品的生成量会相对于附近僵尸的数量而增加效果会乘以该数字。最小值0 最大值20 默认值10",
"InsaneLootFactor": "最小值0.00 最大值0.20 默认值0.05",
"ExtremeLootFactor": "最小值0.05 最大值0.60 默认值0.20",
"RareLootFactor": "最小值0.20 最大值1.00 默认值0.60",
"NormalLootFactor": "最小值0.60 最大值2.00 默认值1.00",
"CommonLootFactor": "最小值1.00 最大值3.00 默认值2.00",
"AbundantLootFactor": "最小值2.00 最大值4.00 默认值3.00",
"Temperature": "全球温度。默认 = 正常\n1 = 非常冷\n2 = 冷\n3 = 正常\n4 = 热\n5 = 非常热",
"Rain": "多久下一次雨。默认 = 正常\n1 = 非常干燥\n2 = 干\n3 = 正常\n4 = 下雨\n5 = 非常多雨",
"ErosionSpeed": "侵蚀系统(向世界添加藤蔓、长草、新树等)达到 100% 增长所需的天数。默认 = 正常100 天)\n1 = 非常快20 天)\n2 = 快速50 天)\n3 = 正常100 天)\n4 = 慢200 天)\n5 = 非常慢500 天)",
"ErosionDays": "对于自定义侵蚀速度。零表示使用“侵蚀速度”选项。最长为 36,500 天(约 100 年)。最小值:-1 最大值36500 默认值0",
"Farming": "植物生长的速度。默认 = 正常\n1 = 非常快\n2 = 快\n3 = 正常\n4 = 慢\n5 = 非常慢",
"CompostTime": "食物在堆肥器中分解需要多长时间。默认 = 2 周\n1 = 1 周\n2 = 2 周\n3 = 3 周\n4 = 4 周\n5 = 6 周\n6 = 8 周\n7 = 10 周\n8 = 12 周",
"StatsDecrease": "玩家的饥饿、口渴和疲劳减少的速度有多快。默认 = 正常\n1 = 非常快\n2 = 快\n3 = 正常\n4 = 慢\n5 = 非常慢",
"NatureAbundance": "觅食模式中发现的物品丰富。默认 = 正常\n1 = 非常差\n2 = 差\n3 = 正常\n4 = 丰富\n5 = 非常丰富",
"Alarm": "玩家闯入新房子时激活房屋警报的可能性有多大。默认 = 有时\n1 = 从不\n2 = 极其罕见\n3 = 稀有\n4 = 有时\n5 = 经常\n6 = 经常",
"LockedHouses": "住宅和建筑物的门被发现后会被锁上的频率。默认 = 经常\n1 = 从不\n2 = 极其罕见\n3 = 稀有\n4 = 有时\n5 = 经常\n6 = 经常",
"StarterKit": "生成时带有薯片、水瓶、小背包、棒球棒和锤子。",
"Nutrition": "食物的营养价值会影响球员的身体状况。关闭此功能将阻止玩家体重增加或减轻。",
"FoodRotSpeed": "食物在冰箱内部或外部变质的速度有多快。默认 = 正常\n1 = 非常快\n2 = 快\n3 = 正常\n4 = 慢\n5 = 非常慢",
"FridgeFactor": "冰箱在延长食物保鲜方面的效果如何。默认 = 正常\n1 = 非常低\n2 = 低\n3 = 正常\n4 = 高\n5 = 非常高\n6 = 无衰减",
"SeenHoursPreventLootRespawn": "当大于 0 时战利品不会在游戏时间内访问过的区域中重生。最小值0 最大值2147483647 默认值0",
"HoursForLootRespawn": "当大于 0 时X 小时后世界上城镇和拖车公园中的所有集装箱都将重生战利品。要生成战利品容器必须至少被掠夺过一次。战利品重生不受可见性或后续抢劫的影响。最小值0 最大值2147483647 默认值0",
"MaxItemsForLootRespawn": "物品数量大于或等于此设置的容器将不会重生。最小值0 最大值2147483647 默认值5",
"ConstructionPreventsLootRespawn": "物品不会在玩家设置路障或建造的建筑物中重生。",
"WorldItemRemovalList": "将在 HoursForWorldItemRemoval 小时后删除的以逗号分隔的项目类型列表。",
"HoursForWorldItemRemoval": "从物品掉落到地面到被移除之前的小时数。 下次加载该部分地图时,项目将被删除。 零意味着项目没有被删除。最小值0.00 最大值2147483647.00 默认值24.00",
"ItemRemovalListBlacklistToggle": "如果为 true则 WorldItemRemovalList 中*不*的任何项目都将被删除。",
"TimeSinceApo": "世界末日多久之后才开始。这将影响起始世界的侵蚀和食物腐败。不影响开始日期。默认 = 0\n1 = 0\n2 = 1\n3 = 2\n4 = 3\n5 = 4\n6 = 5\n7 = 6\n8 = 7\n9 = 8\n10 = 9\n11 = 10\n12 = 11\n13 = 12",
"PlantResilience": "植物每天会损失多少水,以及它们避免疾病的能力。默认 = 正常\n1 = 非常高\n2 = 高\n3 = 正常\n4 = 低\n5 = 非常低",
"PlantAbundance": "收获时植物的产量。默认 = 正常\n1 = 非常差\n2 = 差\n3 = 正常\n4 = 丰富\n5 = 非常丰富",
"EndRegen": "执行动作后从疲劳中恢复。默认 = 正常\n1 = 非常快\n2 = 快\n3 = 正常\n4 = 慢\n5 = 非常慢",
"Helicopter": "直升机飞越活动区的频率。默认 = 一次\n1 = 从不\n2 = 一次\n3 = 有时\n4 = 经常",
"MetaEvent": "诸如远处枪声等吸引僵尸的元游戏事件发生的频率。默认 = 有时\n1 = 从不\n2 = 有时\n3 = 经常",
"SleepingEvent": "玩家睡眠期间发生的事件(例如噩梦)的频率。默认 = 从不\n1 = 从不\n2 = 有时\n3 = 经常",
"GeneratorFuelConsumption": "游戏中每个小时发电机消耗多少燃料。最小值0.00 最大值100.00 默认值0.10",
"GeneratorSpawning": "发电机在地图上产生的几率。默认=稀有\n1 = 无(不推荐)\n2 = 极其罕见\n3 = 极其罕见\n4 = 稀有\n5 = 正常\n6 = 常见\n7 = 丰富",
"AnnotatedMapChance": "被掠夺的地图上多久会有由已故幸存者写的注释。默认 = 有时\n1 = 从不\n2 = 极其罕见\n3 = 稀有\n4 = 有时\n5 = 经常\n6 = 经常",
"CharacterFreePoints": "在角色创建过程中添加免费积分。最小值:-100 最大值100 默认值0",
"ConstructionBonusPoints": "为玩家建造的建筑提供额外的生命值,使它们更能抵抗僵尸的伤害。默认 = 正常\n1 = 非常低\n2 = 低\n3 = 正常\n4 = 高\n5 = 非常高",
"NightDarkness": "夜间环境照明水平。默认 = 正常\n1 = 漆黑\n2 = 深色\n3 = 正常\n4 = 明亮",
"NightLength": "从黄昏到黎明的时间。默认 = 正常\n1 = 永远是夜晚\n2 = 长\n3 = 正常\n4 = 短\n5 = 永远是一天",
"BoneFracture": "如果幸存者可能因撞击、僵尸伤害、跌倒等而导致四肢骨折。",
"InjurySeverity": "受伤对您身体的影响及其愈合时间。默认 = 正常\n1 = 低\n2 = 正常\n3 = 高",
"HoursForCorpseRemoval": "距离僵尸尸体从世界上消失还有多长时间(以小时为单位)。 如果为 0蛆不会在尸体上生成。最小值-1.00 最大值2147483647.00 默认值216.00",
"DecayingCorpseHealthImpact": "附近腐烂的尸体对玩家的健康和情绪的影响。默认 = 正常\n1 = 无\n2 = 低\n3 = 正常\n4 = 高\n5 = 疯狂",
"ZombieHealthImpact": "附近的“活”僵尸是否对玩家的健康和情绪有同样的影响。",
"BloodLevel": "有多少血因受伤而喷洒在地板和墙壁上。默认 = 正常\n1 = 无\n2 = 低\n3 = 正常\n4 = 高\n5 = 极度血腥",
"ClothingDegradation": "衣服降解、变脏、沾血的速度有多快。默认 = 正常\n1 = 禁用\n2 = 慢\n3 = 正常\n4 = 快",
"FireSpread": "如果火灾在开始时蔓延。",
"DaysForRottenFoodRemoval": "腐烂食物从地图上移除之前的游戏内天数。 -1表示腐烂的食物永远不会被清除。最小值-1 最大值2147483647 默认值:-1",
"AllowExteriorGenerator": "如果启用,发电机将在外部瓷砖上运行。 例如,这将允许为气泵提供动力。",
"MaxFogIntensity": "雾的最大强度。默认 = 正常\n1 = 正常\n2 = 中等\n3 = 低\n4 = 无",
"MaxRainFxIntensity": "最大降雨强度。默认 = 正常\n1 = 正常\n2 = 中等\n3 = 低",
"EnableSnowOnGround": "如果地上会积雪。 如果禁用,雪仍会显示在植被和屋顶上。",
"AttackBlockMovements": "如果近战攻击减慢了你的速度。",
"SurvivorHouseChance": "在地图上找到随机建筑物的机会(例如,烧毁的房屋、藏有战利品或尸体的房屋)。默认=稀有\n1 = 从不\n2 = 极其罕见\n3 = 稀有\n4 = 有时\n5 = 经常\n6 = 经常\n7 = 总是尝试",
"VehicleStoryChance": "道路故事(例如警察路障)产生的机会。默认=稀有\n1 = 从不\n2 = 极其罕见\n3 = 稀有\n4 = 有时\n5 = 经常\n6 = 经常\n7 = 总是尝试",
"ZoneStoryChance": "特定于地图区域(例如森林中的露营地)的故事产生的机会。默认=稀有\n1 = 从不\n2 = 极其罕见\n3 = 稀有\n4 = 有时\n5 = 经常\n6 = 经常\n7 = 总是尝试",
"AllClothesUnlocked": "自定义角色时允许您从游戏中的每件衣服中进行选择",
"EnableTaintedWaterText": "如果水被污染,将会显示警告标记。",
"EnableVehicles": "如果车辆会产卵。",
"CarSpawnRate": "在地图上发现车辆的频率。默认 = 低\n1 = 无\n2 = 非常低\n3 = 低\n4 = 正常\n5 = 高",
"ZombieAttractionMultiplier": "僵尸的一般发动机响度。最小值0.00 最大值100.00 默认值1.00",
"VehicleEasyUse": "发现车辆是否上锁、是否需要钥匙启动等。",
"InitialGas": "已发现车辆的油箱有多满。默认 = 低\n1 = 非常低\n2 = 低\n3 = 正常\n4 = 高\n5 = 非常高\n6 = 满",
"FuelStationGasInfinite": "如果启用,加油泵将永远不会耗尽燃料",
"FuelStationGasMin": "加油泵中可以产生的最小汽油量。选中下面的“高级”框以使用自定义金额。最小值0.00 最大值1.00 默认值0.00",
"FuelStationGasMax": "加油泵中可产生的最大汽油量。选中下面的“高级”框以使用自定义金额。最小值0.00 最大值1.00 默认值0.70",
"FuelStationGasEmptyChance": "单个气泵最初没有燃料的可能性以百分比表示。最小值0 最大值100 默认值20",
"LockedCar": "汽车被锁的可能性有多大 默认 = 罕见\n1 = 从不\n2 = 极其罕见\n3 = 稀有\n4 = 有时\n5 = 经常\n6 = 经常",
"CarGasConsumption": "汽车是多么耗油。最小值0.00 最大值100.00 默认值1.00",
"CarGeneralCondition": "发现车辆将处于一般状况。默认 = 低\n1 = 非常低\n2 = 低\n3 = 正常\n4 = 高\n5 = 非常高",
"CarDamageOnImpact": "碰撞车辆造成的损坏程度。默认 = 正常\n1 = 非常低\n2 = 低\n3 = 正常\n4 = 高\n5 = 非常高",
"DamageToPlayerFromHitByACar": "玩家因碰撞而受到的伤害。默认 = 无\n1 = 无\n2 = 低\n3 = 正常\n4 = 高\n5 = 非常高",
"TrafficJam": "如果主要道路上出现由失事汽车组成的交通堵塞。",
"CarAlarm": "发现车辆发出警报的频率。默认 = 极其罕见\n1 = 从不\n2 = 极其罕见\n3 = 稀有\n4 = 有时\n5 = 经常\n6 = 经常",
"PlayerDamageFromCrash": "如果玩家可能因车祸而受伤。",
"SirenShutoffHours": "游戏进行了多少个小时后警报声就会停止。最小值0.00 最大值168.00 默认值0.00",
"ChanceHasGas": "找到一辆油箱里有汽油的车辆的机会。默认 = 低\n1 = 低\n2 = 正常\n3 = 高",
"RecentlySurvivorVehicles": "玩家是否可以发现诺克斯感染发生后得到保养的汽车。默认 = 低\n1 = 无\n2 = 低\n3 = 正常\n4 = 高",
"MultiHitZombies": "如果某些近战武器能够一击击中多个僵尸。",
"RearVulnerability": "僵尸从背后攻击时被咬伤的概率。默认=高\n1 = 低\n2 = 中\n3 = 高",
"SirenEffectsZombies": "如果僵尸会朝着车辆警报声的方向前进。",
"AnimalStatsModifier": "动物统计数据(饥饿、口渴等)降低的速度。默认 = 正常\n1 = 超快\n2 = 非常快\n3 = 快\n4 = 正常\n5 = 慢\n6 = 非常慢",
"AnimalMetaStatsModifier": "动物在元状态下统计数据(饥饿、口渴等)减少的速度。默认 = 正常\n1 = 超快\n2 = 非常快\n3 = 快\n4 = 正常\n5 = 慢\n6 = 非常慢",
"AnimalPregnancyTime": "动物在分娩前会怀孕多长时间。默认 = 非常快\n1 = 超快\n2 = 非常快\n3 = 快\n4 = 正常\n5 = 慢\n6 = 非常慢",
"AnimalAgeModifier": "动物衰老的速度。默认 = 快速\n1 = 超快\n2 = 非常快\n3 = 快\n4 = 正常\n5 = 慢\n6 = 非常慢",
"AnimalMilkIncModifier": "默认 = 快速\n1 = 超快\n2 = 非常快\n3 = 快\n4 = 正常\n5 = 慢\n6 = 非常慢",
"AnimalWoolIncModifier": "默认 = 快速\n1 = 超快\n2 = 非常快\n3 = 快\n4 = 正常\n5 = 慢\n6 = 非常慢",
"AnimalRanchChance": "在农场找到动物的机会。默认 = 始终\n1 = 从不\n2 = 极其罕见\n3 = 稀有\n4 = 有时\n5 = 经常\n6 = 经常\n7 = 始终",
"AnimalGrassRegrowTime": "草被动物吃掉或被玩家割断后重新生长的小时数。最小值1 最大值9999 默认值240",
"AnimalMetaPredator": "如果夜间鸡舍的门开着,元狐狸(即在游戏中实际上不可见)可能会攻击您的鸡。",
"AnimalMatingSeason": "如果有交配季节的动物会尊重它。 否则它们可以全年繁殖/产卵。",
"AnimalEggHatch": "小动物从蛋中孵化出来需要多长时间。默认 = 快速\n1 = 超快\n2 = 非常快\n3 = 快\n4 = 正常\n5 = 慢\n6 = 非常慢",
"AnimalSoundAttractZombies": "如果属实,动物的叫声会吸引附近的僵尸。",
"AnimalTrackChance": "动物留下足迹的机会。默认 = 有时\n1 = 从不\n2 = 极其罕见\n3 = 稀有\n4 = 有时\n5 = 经常\n6 = 经常",
"AnimalPathChance": "为被猎杀的动物创造一条道路的机会。默认 = 有时\n1 = 从不\n2 = 极其罕见\n3 = 稀有\n4 = 有时\n5 = 经常\n6 = 经常",
"MaximumRatIndex": "例如的频率和强度。老鼠出没的建筑物中。最小值0 最大值50 默认值25",
"DaysUntilMaximumRatIndex": "达到最大害虫指数需要多长时间。最小值0 最大值365 默认值90",
"MetaKnowledge": "如果一段媒体尚未完全查看或读取,此设置将确定它是完全显示、显示为“???”还是完全隐藏。默认=完全隐藏\n1 = 完全显露\n2 = 显示为 ???\n3 = 完全隐藏",
"SeeNotLearntRecipe": "如果属实,您将能够看到可以通过工作站完成的任何食谱,即使您尚未学习它们。",
"MaximumLootedBuildingRooms": "如果建筑物的房间数量超过此数量则不会被抢劫。最小值0 最大值200 默认值50",
"EnablePoisoning": "如果有毒可添加到食物中。默认=真\n1 = 正确\n2 = 假\n3 = 仅禁用漂白剂中毒",
"MaggotSpawn": "如果/当蛆虫可以在尸体中产卵时。默认 = 体内和周围\n1 = 体内和周围\n2 = 仅在体内\n3 = 从不",
"LightBulbLifespan": "该值越高,灯泡在损坏前的使用寿命就越长。 如果为 0灯泡永远不会破裂。 不影响车辆前灯。最小值0.00 最大值1000.00 默认值1.00",
"FishAbundance": "河流、湖泊中鱼类丰富。默认 = 正常\n1 = 非常差\n2 = 差\n3 = 正常\n4 = 丰富\n5 = 非常丰富",
"LevelForMediaXPCutoff": "当某项技能达到此级别或更高级别时,电视/VHS/其他媒体将不会为其提供 XP。最小值0 最大值10 默认值3",
"LevelForDismantleXPCutoff": "当技能达到此级别或更高级别时报废家具不会为相关技能提供经验值。不适用于电气。最小值0 最大值10 默认值0",
"BloodSplatLifespanDays": "旧血迹被清除之前的天数。加载地图块时会发生删除。 0表示它们永远不会消失。最小值0 最大值365 默认值0",
"LiteratureCooldown": "一个人可以从阅读以前阅读过的文献中受益之前的天数。最小值1 最大值365 默认值90",
"NegativeTraitsPenalty": "如果选择多个负面特征所提供的奖励特征点的回报递减。默认 = 无\n1 = 无\n2 = 每选择 3 个负面特征扣 1 分\n3 = 每选择 2 个负面特征扣 1 分\n4 = 第一个之后选择的每个负面特征扣 1 分",
"MinutesPerPage": "阅读一页技能书所需的游戏分钟数。最小值0.00 最大值60.00 默认值2.00",
"KillInsideCrops": "启用后,建筑物内种植的农作物和草药将会死亡。不影响室内植物。",
"PlantGrowingSeasons": "启用后,植物的生长会受到季节的影响。",
"PlaceDirtAboveground": "<BHC> [!] 建议您不要更改此设置。更改此设置可能会导致性能问题。 [!] <RGB:1,1,1> 启用后,可以放置泥土,并可以在地面以外的地方进行耕种。",
"FarmingSpeedNew": "植物生长的速度。最小值0.10 最大值100.00 默认值1.00",
"FarmingAmountNew": "农作物丰收。最小值0.10 最大值10.00 默认值1.00",
"MaximumLooted": "任何建筑物在被发现时已经被洗劫的可能性。选中下面的“高级”框以使用自定义号码。最小值0 最大值200 默认值50",
"DaysUntilMaximumLooted": "达到最大被掠夺建筑几率需要多长时间。最小值0 最大值3650 默认值90",
"RuralLooted": "任何农村建筑被发现时都有可能被洗劫一空。选中下面的“高级”框以使用自定义号码。最小值0.00 最大值2.00 默认值0.50",
"MaximumDiminishedLoot": "当达到最大减少战利品前的天数时不会产生最大战利品。选中下面的“高级”框以使用准确的百分比。最小值0 最大值100 默认值0",
"DaysUntilMaximumDiminishedLoot": "达到最大减少战利品百分比需要多长时间。最小值0 最大值3650 默认值3650",
"MuscleStrainFactor": "当因挥舞武器或搬运重物而造成肌肉拉伤时可起到倍增器的作用。最小值0.00 最大值10.00 默认值1.00",
"DiscomfortFactor": "当因磨损物品而感到不适时可起到乘数作用。最小值0.00 最大值10.00 默认值1.00",
"WoundInfectionFactor": "如果大于零则可以从严重的伤口感染中得到损害。最小值0.00 最大值10.00 默认值0.00",
"NoBlackClothes": "如果具有随机色调的真实服装不会太暗而几乎是黑色的。",
"EasyClimbing": "消除攀爬绳索或翻墙时的失败机会。",
"MaximumFireFuelHours": "可以在篝火、木火炉等中放置燃料的最大小时数。最小值1 最大值168 默认值8",
"FirearmUseDamageChance": "用伤害几率计算取代命中几率机制。 此模式优先考虑玩家瞄准。",
"FirearmNoiseMultiplier": "僵尸可以听到枪声的距离的乘数。最小值0.20 最大值2.00 默认值1.00",
"FirearmJamMultiplier": "枪支干扰机会的乘数。 0 禁用干扰。最小值0.00 最大值10.00 默认值0.00",
"FirearmMoodleMultiplier": "Moodle 效果乘数对命中率的影响。 0 禁用 Moodle 惩罚。最小值0.00 最大值10.00 默认值1.00",
"FirearmWeatherMultiplier": "天气(风、雨和雾)对命中率影响的乘数。 0 禁用天气效果。最小值0.00 最大值10.00 默认值1.00",
"FirearmHeadGearEffect": "允许让焊接面罩等头盔影响命中率",
"ClayLakeChance": "有机会将泥土地板变成粘土地板。适用于湖泊。最小值0.00 最大值1.00 默认值0.05",
"ClayRiverChance": "有机会将泥土地板变成粘土地板。适用于河流。最小值0.00 最大值1.00 默认值0.05",
"GeneratorTileRange": "最小值1 最大值100 默认值20",
"GeneratorVerticalPowerRange": "发电机可以为上方和下方多少层提供电力。最小值1 最大值15 默认值3",
"Basement.SpawnFrequency": "地下室在随机位置生成的频率。默认 = 有时\n1 = 从不\n2 = 极其罕见\n3 = 稀有\n4 = 有时\n5 = 经常\n6 = 经常\n7 = 始终",
"Map.AllowMiniMap": "如果启用,将出现一个迷你地图窗口。",
"Map.AllowWorldMap": "如果启用,则可以访问世界地图。",
"Map.MapAllKnown": "如果启用,世界地图将在游戏开始时被完全填满。",
"Map.MapNeedsLight": "如果启用,除非有可用光源,否则无法读取地图。",
"ZombieLore.Speed": "僵尸移动的速度有多快。默认 = 随机\n1 = 疾跑者\n2 = 快速蹒跚者\n3 = 蹒跚者\n4 = 随机",
"ZombieLore.SprinterPercentage": "如果启用随机速度这将控制僵尸中短跑运动员的百分比。选中下面的“高级”框以使用自定义百分比。最小值0 最大值100 默认值0",
"ZombieLore.Strength": "僵尸每次攻击造成的伤害。默认 = 正常\n1 = 超人\n2 = 正常\n3 = 弱\n4 = 随机",
"ZombieLore.Toughness": "杀死僵尸的难度。默认 = 随机\n1 = 艰难\n2 = 正常\n3 = 脆弱\n4 = 随机",
"ZombieLore.Transmission": "诺克斯病毒如何传播。默认 = 血液和唾液\n1 = 血液和唾液\n2 = 仅唾液\n3 = 每个人都被感染\n4 = 无",
"ZombieLore.Mortality": "感染生效的速度有多快。默认 = 2-3 天\n1 = 即时\n2 = 0-30 秒\n3 = 0-1 分钟\n4 = 0-12 小时\n5 = 2-3 天\n6 = 1-2 周\n7 = 从不",
"ZombieLore.Reanimate": "受感染的尸体变成僵尸的速度有多快。默认 = 0-1 分钟\n1 = 即时\n2 = 0-30 秒\n3 = 0-1 分钟\n4 = 0-12 小时\n5 = 2-3 天\n6 = 1-2 周",
"ZombieLore.Cognition": "僵尸智力。默认 = 基本导航\n1 = 导航和使用门\n2 = 导航\n3 = 基本导航\n4 = 随机",
"ZombieLore.CrawlUnderVehicle": "僵尸在停放的车辆下爬行的频率。默认 = 经常\n1 = 仅爬网程序\n2 = 极其罕见\n3 = 稀有\n4 = 有时\n5 = 经常\n6 = 经常\n7 = 始终",
"ZombieLore.Memory": "僵尸在看到或听到玩家后会记住他们多久。默认 = 正常\n1 = 长\n2 = 正常\n3 = 短\n4 = 无\n5 = 随机\n6 = 正常和无之间随机",
"ZombieLore.Sight": "僵尸视野半径。默认 = 正常和较差之间随机\n1 = 鹰\n2 = 正常\n3 = 差\n4 = 随机\n5 = 正常和较差之间随机",
"ZombieLore.Hearing": "僵尸的听觉半径。默认 = 正常和较差之间随机\n1 = 精确定位\n2 = 正常\n3 = 差\n4 = 随机\n5 = 正常和较差之间随机",
"ZombieLore.SpottedLogic": "激活新的先进隐形机制,让您能够躲避汽车后面的僵尸,考虑特征和天气等等。",
"ZombieLore.ThumpNoChasing": "如果没有看到/听到玩家的僵尸可以在漫游时攻击门和建筑物。",
"ZombieLore.ThumpOnConstruction": "如果僵尸可以摧毁玩家的建筑和防御。",
"ZombieLore.ActiveOnly": "僵尸在白天还是晚上更“活跃”。 “活跃”僵尸将使用“速度”设置中设置的速度。 “不活跃”的僵尸会比较慢,并且不会追赶。默认 = 两者\n1 = 两者\n2 = 夜晚\n3 = 日",
"ZombieLore.TriggerHouseAlarm": "如果僵尸冲破窗户或门时触发房屋警报。",
"ZombieLore.ZombiesDragDown": "如果多个攻击僵尸可以拖垮你并杀死你。 取决于僵尸的实力。",
"ZombieLore.ZombiesCrawlersDragDown": "如果玩家身边有爬行僵尸,就会有被一群僵尸拖下去杀死的几率。",
"ZombieLore.ZombiesFenceLunge": "如果僵尸在爬过栅栏或穿过窗户后有机会向你猛扑(如果你离得太近)。",
"ZombieLore.ZombiesArmorFactor": "在确定僵尸所穿盔甲的有效性时充当乘数。最小值0.00 最大值100.00 默认值2.00",
"ZombieLore.ZombiesMaxDefense": "任何磨损的防护服可以为僵尸提供的最大防御百分比。最小值0 最大值100 默认值85",
"ZombieLore.ChanceOfAttachedWeapon": "拥有随机附加武器的百分比几率。最小值0 最大值100 默认值6",
"ZombieLore.ZombiesFallDamage": "僵尸从高处坠落时会受到多少伤害。最小值0.00 最大值100.00 默认值1.00",
"ZombieLore.DisableFakeDead": "一些看起来已经死了的僵尸是否会复活并攻击玩家。默认=世界僵尸\n1 = 世界僵尸\n2 = 世界和战斗僵尸\n3 = 从不",
"ZombieLore.PlayerSpawnZombieRemoval": "僵尸不会在玩家生成的地方生成。默认 = 建筑物内部及其周围\n1 = 建筑物内部及其周围\n2 = 建筑物内部\n3 = 房间内\n4 = 僵尸可以在任何地方生成",
"ZombieLore.FenceThumpersRequired": "需要多少僵尸才能破坏高高的栅栏。最小值:-1 最大值100 默认值50",
"ZombieLore.FenceDamageMultiplier": "僵尸破坏高栅栏的速度有多快。最小值0.01 最大值100.00 默认值1.00",
"ZombieConfig.PopulationMultiplier": "通过“僵尸计数”人口选项或此处​​的自定义数字进行设置。疯狂 = 2.5,非常高 = 1.6,高 = 1.2,正常 = 0.65,低 = 0.15,无 = 0.0。最小值0.00 最大值4.00 默认值0.65",
"ZombieConfig.PopulationStartMultiplier": "游戏开始时所需僵尸数量的乘数。疯狂 = 3.0,非常高 = 2.0,高 = 1.5,正常 = 1.0,低 = 0.5,无 = 0.0。最小值0.00 最大值4.00 默认值1.00",
"ZombieConfig.PopulationPeakMultiplier": "高峰日所需僵尸数量的乘数。疯狂 = 3.0,非常高 = 2.0,高 = 1.5,正常 = 1.0,低 = 0.5,无 = 0.0。最小值0.00 最大值4.00 默认值1.50",
"ZombieConfig.PopulationPeakDay": "人口达到顶峰的那一天。最小值1 最大值365 默认值28",
"ZombieConfig.RespawnHours": "僵尸在牢房中重生之前必须经过的小时数。如果为 0则禁用生成。最小值0.00 最大值8760.00 默认值72.00",
"ZombieConfig.RespawnUnseenHours": "在僵尸可以在其中重生之前块必须不可见的小时数。最小值0.00 最大值8760.00 默认值16.00",
"ZombieConfig.RespawnMultiplier": "每个 RespawnHours 可能重生的细胞所需数量的比例。最小值0.00 最大值1.00 默认值0.10",
"ZombieConfig.RedistributeHours": "僵尸迁移到同一单元的空部分之前必须经过的小时数。如果为 0则禁用迁移。最小值0.00 最大值8760.00 默认值12.00",
"ZombieConfig.FollowSoundDistance": "僵尸试图走向它最后听到的声音的距离。最小值10 最大值1000 默认值100",
"ZombieConfig.RallyGroupSize": "真正的僵尸闲置时形成的群体规模。 0 表示僵尸不形成群体。群体不会在建筑物或森林区域内形成。最小值0 最大值1000 默认值20",
"ZombieConfig.RallyGroupSizeVariance": "僵尸组的数量(以百分比表示)的大小可以与默认值不同(更大或更小)。 例如,如果默认组大小为 20方差为 50%,则组大小将在 10-30 之间变化。最小值0 最大值100 默认值50",
"ZombieConfig.RallyTravelDistance": "真正的僵尸闲置时形成群体的行进距离。最小值5 最大值50 默认值20",
"ZombieConfig.RallyGroupSeparation": "僵尸群体之间的距离。最小值5 最大值25 默认值15",
"ZombieConfig.RallyGroupRadius": "僵尸群体的成员与该群体的“领导者”的关系有多密切。最小值1 最大值10 默认值3",
"ZombieConfig.ZombiesCountBeforeDelete": "最小值10 最大值500 默认值300",
"MultiplierConfig.Global": "所有技能升级的速度。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.GlobalToggle": "启用后,所有技能都将使用全局乘数。",
"MultiplierConfig.Fitness": "健身技能升级的速率。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Strength": "力量技能升级的速率。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Sprinting": "冲刺技能升级的速率。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Lightfoot": "轻足技能升级的速率。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Nimble": "敏捷技能升级的速度。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Sneak": "潜行技能升级的速率。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Axe": "斧头技能升级的速度。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Blunt": "长钝器技能升级的速率。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.SmallBlunt": "短钝技能的升级速度。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.LongBlade": "长刃技能升级的速度。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.SmallBlade": "短刃技能升级的速度。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Spear": "矛技能升级的速率。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Maintenance": "维护技能升级的速度。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Woodwork": "木工技能升级的速度。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Cooking": "烹饪技能升级的速度。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Farming": "农业技能升级的速度。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Doctor": "急救技能升级的速率。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Electricity": "电气技能升级的速率。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.MetalWelding": "焊接技能升级的速度。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Mechanics": "机械技能升级的速率。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Tailoring": "裁缝技能升级的速度。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Aiming": "瞄准技能升级的速率。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Reloading": "装弹技能升级的速率。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Fishing": "钓鱼技能升级的速度。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Trapping": "陷阱技能升级的速率。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.PlantScavenging": "觅食技能升级的速度。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.FlintKnapping": "敲击技能升级的速率。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Masonry": "石工技能升级的速度。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Pottery": "陶艺技能升级的速度。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Carving": "雕刻技能升级的速度。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Husbandry": "动物护理技能升级的速率。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Tracking": "追踪技能升级的速率。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Blacksmith": "锻造技能升级的速度。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Butchering": "屠宰技能升级的速度。最小值0.00 最大值1000.00 默认值1.00",
"MultiplierConfig.Glassmaking": "玻璃制造技能升级的速度。最小值0.00 最大值1000.00 默认值1.00"
}

2
pz_config/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Project Zomboid config editor helpers (server.ini + SandboxVars lua)."""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

42
pz_config/models.py Normal file
View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal, Mapping
ValueType = Literal["bool", "int", "float", "string"]
@dataclass(frozen=True)
class Setting:
"""
A single editable setting from either server.ini or server_SandboxVars.lua.
- For INI: `path == key`
- For Lua: `path` is dotted (e.g. "ZombieLore.Speed")
"""
source: Literal["ini", "lua"]
path: str
key: str
group: str
value_type: ValueType
value: bool | int | float | str
raw_value: str
line_index: int
description_zh: str
description_en: str | None = None
min_value: int | float | None = None
max_value: int | float | None = None
default_value: str | None = None
choices: Mapping[str, str] | None = None
@dataclass(frozen=True)
class ParsedConfig:
source: Literal["ini", "lua"]
filepath: str
lines: list[str]
settings: list[Setting]

287
pz_config/parsers.py Normal file
View File

@@ -0,0 +1,287 @@
from __future__ import annotations
import json
import re
from dataclasses import replace
from pathlib import Path
from typing import Any
from .models import ParsedConfig, Setting, ValueType
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def _decode_utf8_keep_newlines(path: Path) -> str:
# Keep CRLF exactly as-is by decoding bytes directly (no newline translation).
return path.read_bytes().decode("utf-8-sig")
def _parse_number(text: str) -> int | float | None:
text = text.strip()
if re.fullmatch(r"-?\d+", text):
return int(text)
if re.fullmatch(r"-?\d+\.\d+", text):
return float(text)
return None
def _infer_value_type_and_value(raw: str) -> tuple[ValueType, bool | int | float | str]:
v = raw.strip()
if v.lower() == "true":
return "bool", True
if v.lower() == "false":
return "bool", False
if v.startswith('"') and v.endswith('"') and len(v) >= 2:
inner = v[1:-1]
inner = (
inner.replace("\\\\", "\\")
.replace('\\"', '"')
.replace("\\n", "\n")
.replace("\\r", "\r")
.replace("\\t", "\t")
)
return "string", inner
num = _parse_number(v)
if isinstance(num, int):
return "int", num
if isinstance(num, float):
return "float", num
return "string", v
def _parse_min_max_default(comment_text: str) -> tuple[int | float | None, int | float | None, str | None]:
if not comment_text:
return None, None, None
min_match = re.search(r"\bMin:\s*(-?\d+(?:\.\d+)?)", comment_text)
max_match = re.search(r"\bMax:\s*(-?\d+(?:\.\d+)?)", comment_text)
default_match = re.search(r"\bDefault:\s*([^\s]+)", comment_text)
min_value = _parse_number(min_match.group(1)) if min_match else None
max_value = _parse_number(max_match.group(1)) if max_match else None
default_value = default_match.group(1) if default_match else None
return min_value, max_value, default_value
def _parse_choices(comment_text: str) -> dict[str, str] | None:
"""
Parse enum-like choice lines from comment blocks:
1 = Normal
2 = Very High
"""
if not comment_text:
return None
choices: dict[str, str] = {}
for line in comment_text.splitlines():
m = re.match(r"^\s*(\d+)\s*=\s*(.+?)\s*$", line)
if not m:
continue
choices[m.group(1)] = m.group(2)
return choices or None
def parse_server_ini(filepath: str) -> ParsedConfig:
path = Path(filepath)
text = _decode_utf8_keep_newlines(path)
lines = text.splitlines(keepends=True)
comment_re = re.compile(r"^\s*#\s?(.*)$")
key_re = re.compile(r"^([A-Za-z0-9_]+)=(.*)$")
# Only used when the file itself has no comment block for a key.
fallback_zh: dict[str, str] = {
"ChatStreams": "聊天频道开关/可用频道列表(不同版本可用值可能不同)。",
"ServerImageLoginScreen": "登录界面背景图(通常填图片 URL 或留空)。",
"ServerImageLoadingScreen": "加载界面背景图(通常填图片 URL 或留空)。",
"ServerImageIcon": "服务器图标(通常填图片 URL 或留空)。",
"UsernameDisguises": "是否启用“用户名伪装/易容”相关机制(具体效果受版本影响)。",
"HideDisguisedUserName": "配合 UsernameDisguises是否隐藏伪装后的用户名显示。",
"SwitchZombiesOwnershipEachUpdate": "僵尸网络同步/归属更新策略(高级项,非必要别改)。",
"DenyLoginOnOverloadedServer": "服务器负载过高时是否拒绝新玩家登录。",
"SafehouseDisableDisguises": "安全屋内是否禁用伪装/易容相关效果。",
"SneakModeHideFromOtherPlayers": "潜行模式下是否对其他玩家隐藏(偏玩法/平衡)。",
"UltraSpeedDoesnotAffectToAnimals": "超高速时间流逝是否不影响动物系统B42/动物相关)。",
"LoginQueueEnabled": "是否启用登录排队系统(满服/高峰用)。",
"BanKickGlobalSound": "封禁/踢出时是否播放全服提示音。",
"BackupsOnStart": "服务器启动时是否自动备份存档。",
"BackupsOnVersionChange": "版本变更时是否自动备份存档。",
"AntiCheatMovement": "反作弊:移动相关检查等级(数值含义由游戏定义)。",
"AntiCheatPlayer": "反作弊:玩家相关检查等级(数值含义由游戏定义)。",
"AntiCheatServerCustomization": "反作弊:服务器自定义/一致性检查等级(数值含义由游戏定义)。",
"UsePhysicsHitReaction": "是否启用物理受击硬直反馈(可能影响 PVP/手感)。",
}
settings: list[Setting] = []
pending_comments: list[str] = []
for index, line in enumerate(lines):
line_no_nl = line.rstrip("\r\n")
cm = comment_re.match(line_no_nl)
if cm:
pending_comments.append(cm.group(1).rstrip())
continue
if not line_no_nl.strip():
# Keep blank lines inside comment blocks so we don't merge unrelated comments.
pending_comments.append("")
continue
km = key_re.match(line_no_nl)
if not km:
pending_comments = []
continue
key = km.group(1)
raw_value = km.group(2)
comment_text = "\n".join([c for c in pending_comments if c != ""]).strip()
pending_comments = []
if not comment_text:
comment_text = fallback_zh.get(key, "该项在当前配置文件中没有注释说明。")
min_value, max_value, default_value = _parse_min_max_default(comment_text)
choices = _parse_choices(comment_text)
value_type, value = _infer_value_type_and_value(raw_value)
settings.append(
Setting(
source="ini",
path=key,
key=key,
group="server.ini",
value_type=value_type,
value=value,
raw_value=raw_value,
line_index=index,
description_zh=comment_text,
description_en=None,
min_value=min_value,
max_value=max_value,
default_value=default_value,
choices=choices,
)
)
return ParsedConfig(source="ini", filepath=str(path), lines=lines, settings=settings)
def parse_sandboxvars_lua(filepath: str, translations_json: str | None = None) -> ParsedConfig:
path = Path(filepath)
text = _decode_utf8_keep_newlines(path)
lines = text.splitlines(keepends=True)
comment_re = re.compile(r"^\s*--\s?(.*)$")
open_table_re = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\{\s*$")
close_table_re = re.compile(r"^\s*\},\s*$")
translations: dict[str, str] = {}
if translations_json:
translations = json.loads(Path(translations_json).read_text(encoding="utf-8"))
settings: list[Setting] = []
pending_comments: list[str] = []
table_stack: list[str] = []
for index, line in enumerate(lines):
line_no_nl = line.rstrip("\r\n")
cm = comment_re.match(line_no_nl)
if cm:
pending_comments.append(cm.group(1).rstrip())
continue
if not line_no_nl.strip():
pending_comments = []
continue
om = open_table_re.match(line_no_nl)
if om:
table_stack.append(om.group(1))
pending_comments = []
continue
if close_table_re.match(line_no_nl):
if table_stack:
table_stack.pop()
pending_comments = []
continue
if "=" not in line_no_nl or not line_no_nl.strip().endswith(","):
pending_comments = []
continue
left, right = line_no_nl.split("=", 1)
key = left.strip()
if not _IDENT_RE.fullmatch(key):
pending_comments = []
continue
right = right.strip()
if not right.endswith(","):
pending_comments = []
continue
raw_value = right[:-1].strip()
comment_en = "\n".join([c for c in pending_comments if c != ""]).strip()
pending_comments = []
# Remove the outer-most "SandboxVars" from the dotted path for UI friendliness.
effective_stack = table_stack[:]
if effective_stack[:1] == ["SandboxVars"]:
effective_stack = effective_stack[1:]
setting_path = ".".join(effective_stack + [key]) if effective_stack else key
comment_zh = translations.get(setting_path, "").strip()
if not comment_zh:
# Fallback: translate-free, but still shows something.
comment_zh = "(暂无中文说明)" if not comment_en else f"(暂无中文说明)\n{comment_en}"
min_value, max_value, default_value = _parse_min_max_default(comment_en)
choices_en = _parse_choices(comment_en)
choices_zh = _parse_choices(comment_zh) if comment_zh else None
choices: dict[str, str] | None = None
if choices_en:
choices = {}
for value_key, label_en in choices_en.items():
choices[value_key] = (choices_zh or {}).get(value_key, label_en)
value_type, value = _infer_value_type_and_value(raw_value)
group = effective_stack[0] if effective_stack else "基础"
settings.append(
Setting(
source="lua",
path=setting_path,
key=key,
group=group,
value_type=value_type,
value=value,
raw_value=raw_value,
line_index=index,
description_zh=comment_zh,
description_en=comment_en or None,
min_value=min_value,
max_value=max_value,
default_value=default_value,
choices=choices,
)
)
# Special-case: translate the root-version and any keys that might not have comments.
# We keep them as-is, but still ensure there is some Chinese text.
fixed_settings: list[Setting] = []
for s in settings:
if s.path in {"VERSION", "StartYear"} and (not s.description_zh or "暂无中文说明" in s.description_zh):
zh = {
"VERSION": "SandboxVars 文件版本号(通常不用改)。",
"StartYear": "开局年份(通常与开局日期/月份一起使用)。",
}.get(s.path, s.description_zh)
fixed_settings.append(replace(s, description_zh=zh))
else:
fixed_settings.append(s)
return ParsedConfig(source="lua", filepath=str(path), lines=lines, settings=fixed_settings)

87
pz_config/writers.py Normal file
View File

@@ -0,0 +1,87 @@
from __future__ import annotations
import re
from typing import Mapping
from .models import ParsedConfig, Setting
def _to_ini_value(setting: Setting, new_value: object) -> str:
if setting.value_type == "bool":
return "true" if bool(new_value) else "false"
return str(new_value)
def _lua_quote(value: str) -> str:
# Minimal Lua string escaping for double-quoted strings.
value = (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\r", "\\r")
.replace("\n", "\\n")
.replace("\t", "\\t")
)
return f'"{value}"'
def _to_lua_value(setting: Setting, new_value: object) -> str:
if setting.value_type == "bool":
return "true" if bool(new_value) else "false"
if setting.value_type == "int":
return str(int(new_value))
if setting.value_type == "float":
# Keep a plain, readable float representation.
return str(float(new_value))
return _lua_quote(str(new_value))
def write_server_ini(parsed: ParsedConfig, updates: Mapping[str, object]) -> str:
if parsed.source != "ini":
raise ValueError("write_server_ini expects ParsedConfig(source='ini')")
lines = list(parsed.lines)
key_re = re.compile(r"^([A-Za-z0-9_]+)=(.*)$")
for setting in parsed.settings:
if setting.path not in updates:
continue
new_value = _to_ini_value(setting, updates[setting.path])
original = lines[setting.line_index]
line_no_nl = original.rstrip("\r\n")
newline = original[len(line_no_nl) :]
m = key_re.match(line_no_nl)
if not m:
continue
key = m.group(1)
lines[setting.line_index] = f"{key}={new_value}{newline}"
return "".join(lines)
def write_sandboxvars_lua(parsed: ParsedConfig, updates: Mapping[str, object]) -> str:
if parsed.source != "lua":
raise ValueError("write_sandboxvars_lua expects ParsedConfig(source='lua')")
lines = list(parsed.lines)
for setting in parsed.settings:
if setting.path not in updates:
continue
new_value = _to_lua_value(setting, updates[setting.path])
original = lines[setting.line_index]
line_no_nl = original.rstrip("\r\n")
newline = original[len(line_no_nl) :]
# Replace between '=' and the final comma.
eq_index = line_no_nl.find("=")
comma_index = line_no_nl.rfind(",")
if eq_index == -1 or comma_index == -1 or comma_index <= eq_index:
continue
prefix = line_no_nl[: eq_index + 1]
# Preserve at least one space after '=' for readability.
prefix = prefix.rstrip() + " "
# Keep original indentation + key as-is.
suffix = line_no_nl[comma_index:] + newline
lines[setting.line_index] = f"{prefix}{new_value}{suffix}"
return "".join(lines)

205
pz_webui.py Normal file
View File

@@ -0,0 +1,205 @@
from __future__ import annotations
import argparse
import io
import sys
import zipfile
from datetime import datetime
import hashlib
import re
from pathlib import Path
from typing import Any
from flask import Flask, Response, redirect, render_template, request, url_for
from pz_config.parsers import parse_sandboxvars_lua, parse_server_ini
from pz_config.writers import write_sandboxvars_lua, write_server_ini
def _parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Project Zomboid 配置 WebUI 编辑器")
parser.add_argument("--ini", default="server.ini", help="server.ini 路径(默认:./server.ini")
parser.add_argument(
"--lua",
default="server_SandboxVars.lua",
help="server_SandboxVars.lua 路径(默认:./server_SandboxVars.lua",
)
parser.add_argument(
"--translations",
default=str(Path("i18n") / "sandboxvars_zh.json"),
help="SandboxVars 中文说明 JSON默认./i18n/sandboxvars_zh.json",
)
parser.add_argument("--host", default="127.0.0.1", help="监听地址默认127.0.0.1")
parser.add_argument("--port", type=int, default=5050, help="监听端口默认5050")
parser.add_argument("--debug", action="store_true", help="Flask debug 模式")
return parser.parse_args(argv)
def _coerce_value(value_type: str, raw: str) -> bool | int | float | str:
if value_type == "bool":
return raw.lower() == "true"
if value_type == "int":
return int(raw)
if value_type == "float":
return float(raw)
return raw
def create_app(ini_path: str, lua_path: str, translations_path: str) -> Flask:
app = Flask(__name__)
ini_path = str(Path(ini_path))
lua_path = str(Path(lua_path))
translations_path = str(Path(translations_path))
def _stable_group_id(name: str) -> str:
ascii_slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") or "group"
digest = hashlib.md5(name.encode("utf-8")).hexdigest()[:8]
return f"{ascii_slug}-{digest}"
long_text_keys = {
"ServerWelcomeMessage",
"PublicDescription",
"Mods",
"Map",
"SpawnItems",
"ClientCommandFilter",
"ClientActionLogs",
"BadWordReplacement",
"BadWordListFile",
"GoodWordListFile",
"LootItemRemovalList",
"WorldItemRemovalList",
"Map.MapAllKnown",
}
@app.get("/")
def index() -> str:
translations = translations_path if Path(translations_path).exists() else None
ini_cfg = parse_server_ini(ini_path)
lua_cfg = parse_sandboxvars_lua(lua_path, translations_json=translations)
# Group Lua settings by their top-level table.
lua_groups: dict[str, list[Any]] = {}
for s in lua_cfg.settings:
lua_groups.setdefault(s.group, []).append(s)
for group_settings in lua_groups.values():
group_settings.sort(key=lambda s: s.line_index)
lua_groups_payload: list[dict[str, Any]] = []
for group_name, items in lua_groups.items():
first_line = min(s.line_index for s in items) if items else 0
lua_groups_payload.append(
{
"name": group_name,
"id": _stable_group_id(group_name),
"settings": items,
"count": len(items),
"order": first_line,
}
)
lua_groups_payload.sort(key=lambda g: g["order"])
lua_setting_count = sum(len(items) for items in lua_groups.values())
return render_template(
"index.html",
ini_settings=ini_cfg.settings,
lua_groups=lua_groups_payload,
lua_setting_count=lua_setting_count,
ini_path=ini_path,
lua_path=lua_path,
has_translations=Path(translations_path).exists(),
translations_path=translations_path,
long_text_keys=long_text_keys,
saved=request.args.get("saved") == "1",
)
def _read_updates() -> tuple[dict[str, object], dict[str, object], list[str]]:
translations = translations_path if Path(translations_path).exists() else None
ini_cfg = parse_server_ini(ini_path)
lua_cfg = parse_sandboxvars_lua(lua_path, translations_json=translations)
ini_updates: dict[str, object] = {}
lua_updates: dict[str, object] = {}
errors: list[str] = []
for s in ini_cfg.settings:
field = f"ini__{s.path}"
if s.value_type == "bool":
ini_updates[s.path] = field in request.form
continue
raw = request.form.get(field, "")
try:
ini_updates[s.path] = _coerce_value(s.value_type, raw)
except Exception:
errors.append(f"server.ini: {s.path} 值非法:{raw!r}")
for s in lua_cfg.settings:
field = f"lua__{s.path}"
if s.value_type == "bool":
lua_updates[s.path] = field in request.form
continue
raw = request.form.get(field, "")
try:
lua_updates[s.path] = _coerce_value(s.value_type, raw)
except Exception:
errors.append(f"server_SandboxVars.lua: {s.path} 值非法:{raw!r}")
return ini_updates, lua_updates, errors
@app.post("/download.zip")
def download_zip() -> Response:
translations = translations_path if Path(translations_path).exists() else None
ini_cfg = parse_server_ini(ini_path)
lua_cfg = parse_sandboxvars_lua(lua_path, translations_json=translations)
ini_updates, lua_updates, errors = _read_updates()
if errors:
return Response("\n".join(errors), status=400, mimetype="text/plain; charset=utf-8")
ini_out = write_server_ini(ini_cfg, ini_updates)
lua_out = write_sandboxvars_lua(lua_cfg, lua_updates)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr(Path(ini_path).name, ini_out)
zf.writestr(Path(lua_path).name, lua_out)
buf.seek(0)
filename = f"pz-config-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
return Response(
buf.getvalue(),
mimetype="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@app.post("/save")
def save_to_disk() -> Response:
translations = translations_path if Path(translations_path).exists() else None
ini_cfg = parse_server_ini(ini_path)
lua_cfg = parse_sandboxvars_lua(lua_path, translations_json=translations)
ini_updates, lua_updates, errors = _read_updates()
if errors:
return Response("\n".join(errors), status=400, mimetype="text/plain; charset=utf-8")
ini_out = write_server_ini(ini_cfg, ini_updates)
lua_out = write_sandboxvars_lua(lua_cfg, lua_updates)
Path(ini_path).write_text(ini_out, encoding="utf-8", newline="")
Path(lua_path).write_text(lua_out, encoding="utf-8", newline="")
return redirect(url_for("index", saved="1"))
return app
def main(argv: list[str]) -> int:
args = _parse_args(argv)
app = create_app(args.ini, args.lua, args.translations)
app.run(host=args.host, port=args.port, debug=args.debug)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
flask==3.0.3

475
server.ini Normal file
View File

@@ -0,0 +1,475 @@
# 玩家可以互相伤害、互相杀死
PVP=true
# PVP 行为会记录到管理员聊天频道
PVPLogToolChat=true
# PVP 行为会写入日志文件
PVPLogToolFile=true
# 服务器没人在线时,游戏时间暂停
PauseEmpty=true
# 开关全局聊天true 开false 关)
GlobalChat=true
ChatStreams=s,r,a,w,y,sh,f,all
# 是否允许“没在白名单里的人”直接注册/进服。
# true玩家进服时可以自己填账号密码不用你提前建号
# false必须管理员手动创建账号/密码
Open=true
# 玩家登录后,聊天面板第一时间看到的欢迎语。
# 支持 RGB 颜色:\<RGB:1,0,0> 这种写法会显示红色
# 支持用 <LINE>(中间不要空格)来换行/分段
ServerWelcomeMessage=Welcome to Project Zomboid Multiplayer! <LINE> <LINE> To interact with the Chat panel: press Tab, T, or Enter. <LINE> <LINE> The Tab key will change the target stream of the message. <LINE> <LINE> Global Streams: /all <LINE> Local Streams: /say, /yell <LINE> Special Steams: /whisper, /safehouse, /faction. <LINE> <LINE> Press the Up arrow to cycle through your message history. Click the Gear icon to customize chat. <LINE> <LINE> Happy surviving!
ServerImageLoginScreen=
ServerImageLoadingScreen=
ServerImageIcon=
# (仅对 Open=true 有用)玩家首次加入时,自动把“未知用户名”加进白名单
# 玩家加入时会自己设置用户名/密码
AutoCreateUserInWhiteList=false
# 游戏里在角色头顶显示用户名
DisplayUserName=true
# 头顶显示“名+姓”(角色名的 first/last name
ShowFirstAndLastName=true
UsernameDisguises=false
HideDisguisedUserName=false
SwitchZombiesOwnershipEachUpdate=false
# 强制所有新玩家出生在固定坐标 x,y,z去 map.projectzomboid.com 查坐标)
# 写 0,0,0 表示不强制(忽略)
SpawnPoint=0,0,0
# 安全系统:玩家可以自己开/关 PVP 模式。
# SafetySystem=false 时,只要 PVP=true 就随时互砍
# SafetySystem=true 时:只有至少一方开启了 PVP左侧骷髅图标亮着才会互相伤害
SafetySystem=true
# 对已开启 PVP 模式的玩家,在头顶显示骷髅图标
ShowSafety=true
# 切换进入/退出 PVP 模式需要的时间(分钟/秒?游戏里按这个算)
# Min: 0 Max: 1000 Default: 2
SafetyToggleTimer=2
# 刚切换过 PVP 后,再次切换的冷却时间
# Min: 0 Max: 1000 Default: 3
SafetyCooldownTimer=3
# Min: 0 Max: 60 Default: 60
SafetyDisconnectDelay=60
# 新玩家出生自带物品列表,用英文逗号分隔
# 例Base.Axe,Base.Bag_BigHikingBag
SpawnItems=
# 玩家数据默认端口UDP 的话,这是两个端口中的一个)
# Min: 0 Max: 65535 Default: 16261
DefaultPort=16261
# Min: 0 Max: 65535 Default: 16262
UDPPort=16262
# 重置 ID用来判断服务器是否做过软重置。
# 如果这个数字和客户端的不一样,客户端必须新建角色。
# 会配合 PlayerServerID 一起用。强烈建议把这些 ID 备份好
# Min: 0 Max: 2147483647 Default: 606245551
ResetID=557835860
# 在这里填 Mod 的加载 ID
# 去 \Steam\steamapps\workshop\modID\mods\modName\info.txt 里找
Mods=\B42CNTranslate;\B42Trans_CN;\B42Trans_CN_Simple
# 填地图文件夹名(在 \Steam\steamapps\workshop\modID\mods\modName\media\maps\ 里)
Map=Riverside, KY
# 踢掉“本地游戏文件和服务器不一致”的客户端
DoLuaChecksum=false
DenyLoginOnOverloadedServer=true
# 是否在游戏内服务器列表里公开显示服务器
# (注意:启用 Steam 的服务器总会出现在 Steam 服务器浏览器里)
Public=false
# 服务器在列表里显示的名字(以及 Steam 浏览器显示名)
PublicName=PZ
# 服务器简介(公服列表里看到的描述)
# 输入 <LINE> 可以换行(你这里原注释里那块被吞了,我按常见写法说明)
PublicDescription=几把的服务器有这么难开?
# 最大在线人数(不含管理员)
# 警告:超过 32 人可能会导致地图加载差、不同步/卡顿,请慎重
# Min: 1 Max: 100 Default: 32
MaxPlayers=32
# 延迟ms超过这个值就踢人0=不限制)
# Min: 0 Max: 2147483647 Default: 0
PingLimit=100
# 玩家占领为安全屋的房子里,战利品不再刷新
SafehousePreventsLootRespawn=true
# 玩家死亡后,从白名单移除账号(仅 Open=false 服务器会用到)
# 这样死了就不能直接再建新角色
DropOffWhiteListAfterDeath=false
# 禁用所有火焰(除了营火)
NoFire=false
# 如果启用:玩家死亡会在全服聊天里公告
AnnounceDeath=true
# 世界存档间隔(现实分钟)
# 地图通常只在玩家离开加载区域后才保存,这个设置能强制更频繁保存
# Min: 0 Max: 2147483647 Default: 0
SaveWorldEveryMinutes=0
# 玩家也能占领安全屋
PlayerSafehouse=true
# 只有管理员能占领安全屋
AdminSafehouse=false
# 非成员是否允许进安全屋(不用邀请)
SafehouseAllowTrepass=true
# 火焰是否能烧坏安全屋
SafehouseAllowFire=true
# 非成员是否能从安全屋拿东西
SafehouseAllowLoot=true
# 玩家死亡后,是否允许在加入过的安全屋里复活
SafehouseAllowRespawn=false
# 玩家必须存活达到多少“游戏天数”才允许占领安全屋
# Min: 0 Max: 2147483647 Default: 0
SafehouseDaySurvivedToClaim=0
# 安全屋多久(现实小时)没去就自动把玩家从安全屋里移除
# Min: 0 Max: 2147483647 Default: 144
SafeHouseRemovalTime=144
# 是否允许占领“非住宅建筑”(如仓库、商店)
SafehouseAllowNonResidential=false
SafehouseDisableDisguises=true
# Min: 0 Max: 2147483647 Default: 20000
MaxSafezoneSize=20000
# 是否允许用大锤sledgehammer破坏世界物件
AllowDestructionBySledgehammer=true
# 是否只允许在自己的安全屋里用大锤破坏(前提 AllowDestructionBySledgehammer=true
SledgehammerOnlyInSafehouse=false
# 开战倒计时(秒)
# Min: 60 Max: 2147483647 Default: 600
WarStartDelay=600
# 战争持续时间(秒)
# Min: 60 Max: 2147483647 Default: 3600
WarDuration=3600
# 安全屋在战争中可承受的“被打次数/血量”
# Min: 0 Max: 2147483647 Default: 3
WarSafehouseHitPoints=3
# ServerPlayerID 用来判断角色是来自别的服务器还是单机/本服。
# 软重置可能会改变它。如果这个数字和客户端不一样,客户端必须新建角色。
# 会和 ResetID 一起用。强烈建议备份
ServerPlayerID=1040979181
# RCON远程控制台端口
# Min: 0 Max: 65535 Default: 27015
RCONPort=27015
# RCON 密码(建议设置强密码)
RCONPassword=
# 是否启用 Discord 文字聊天同步
DiscordEnable=false
# Discord 机器人 Token
DiscordToken=
# Discord 频道名(不行就用频道 ID
DiscordChannel=
# Discord 频道 ID频道名不好用时用这个
DiscordChannelID=
# Slack 的 incoming webhook URL
WebhookAddress=
# 进服密码(用 Host 按钮开服时这个设置会被忽略)
Password=80012029
# 限制同一个 Steam 账号最多能在服务器创建多少个不同账户Host 开服会忽略)
# Min: 0 Max: 2147483647 Default: 0
MaxAccountsPerUser=0
# 允许同屏/远程同乐co-op/splitscreen
AllowCoop=true
# 允许玩家睡觉(但不强制必须睡)
SleepAllowed=true
# 需要睡眠会变困不睡不行SleepAllowed=false 时忽略)
SleepNeeded=false
# WIP开启可能造成玩家位置显示不同步
KnockedDownAllowed=false
SneakModeHideFromOtherPlayers=true
UltraSpeedDoesnotAffectToAnimals=false
# 服务器需要下载的创意工坊 Mod ID 列表,用分号分隔
# 例WorkshopItems=514427485;513111049
WorkshopItems=3386702953;3556544454
# 玩家列表里显示 Steam 用户名和头像
SteamScoreboard=true
# 启用 Steam VAC 反作弊
SteamVAC=true
# 尝试用 UPnP 自动配置路由器端口转发
# 如果失败就回退到默认端口
UPnP=true
# 启用语音VOIP
VoiceEnable=true
# 语音最小可听距离(格/瓦片距离)
# Min: 0.00 Max: 100000.00 Default: 10.00
VoiceMinDistance=10.0
# 语音最大可听距离
# Min: 0.00 Max: 100000.00 Default: 100.00
VoiceMaxDistance=100.0
# 语音启用 3D/方向性音频
Voice3D=true
# Min: 10.00 Max: 150.00 Default: 70.00
SpeedLimit=70.0
LoginQueueEnabled=false
# Min: 20 Max: 1200 Default: 60
LoginQueueConnectTimeout=60
# 服务器广播用的 IP多网卡/多 IP 环境用,比如机房服务器)
server_browser_announced_ip=
# 玩家可在死亡地点坐标原地复活
PlayerRespawnWithSelf=true
# 玩家可在同屏/远程同乐玩家的位置复活
PlayerRespawnWithOther=true
# 睡觉时的时间流速倍率
# Min: 1.00 Max: 100.00 Default: 40.00
FastForwardMultiplier=40.0
# 如果安全屋成员有人在线,则安全屋像普通房子一样(成员在线时不再“离线保护”)
DisableSafehouseWhenPlayerConnected=false
# 是否允许创建派系/帮派faction
Faction=true
# 创建派系前必须存活多少“游戏天数”
# Min: 0 Max: 2147483647 Default: 0
FactionDaySurvivedToCreate=0
# 派系拥有者创建“派系标签/前缀”所需的成员人数
# Min: 1 Max: 2147483647 Default: 1
FactionPlayersRequiredForTag=1
# 禁用有权限等级玩家的无线电发言
DisableRadioStaff=false
# 禁用 admin 的无线电发言
DisableRadioAdmin=true
# 禁用 gm 的无线电发言
DisableRadioGM=true
# 禁用 overseer 的无线电发言
DisableRadioOverseer=false
# 禁用 moderator 的无线电发言
DisableRadioModerator=false
# 禁用隐身玩家的无线电发言
DisableRadioInvisible=true
# 不写入 cmd.txt 服务器日志的命令列表(分号分隔)
# 例:-vehicle.* 表示 vehicle 相关都不记;* 表示所有 vehicle 命令都不记
# +vehicle.installPart 表示这个命令要记录
ClientCommandFilter=-vehicle.*;+vehicle.damageWindow;+vehicle.fixPart;+vehicle.installPart;+vehicle.uninstallPart
# 会写入 ClientActionLogs.txt 的客户端行为列表(分号分隔)
ClientActionLogs=ISEnterVehicle;ISExitVehicle;ISTakeEngineParts;
# 记录玩家技能等级变化到 PerkLog.txt
PerkLogs=true
# 容器里允许放的最大“物品数量”0=不限制)
# 注意:小物品也算,比如钉子;设 50 就表示最多放 50 个钉子
# Min: 0 Max: 9000 Default: 0
ItemNumbersLimitPerContainer=0
# 血迹保留多少天后清理(清理发生在地图区块被加载时)
# 0=永不消失
# Min: 0 Max: 365 Default: 0
BloodSplatLifespanDays=0
# 允许用户名使用非 ASCII 字符(如俄文等)
AllowNonAsciiUsername=true
BanKickGlobalSound=true
# 如果开启到达尸体清理时间HoursForCorpseRemoval也会把“玩家尸体”一起清掉
RemovePlayerCorpsesOnCorpseRemoval=false
# 如果开启:玩家可以对垃圾桶使用“全部删除”按钮
TrashDeleteAll=false
# 如果开启:玩家被其他玩家打中进入硬直时,仍然可以继续挥击
PVPMeleeWhileHitReaction=false
# 如果开启:必须把鼠标移到别人身上才会显示对方名字
MouseOverToSeeDisplayName=true
# 如果开启:自动隐藏你看不见的玩家(类似僵尸的遮挡效果)
HidePlayersBehindYou=true
# PVP 近战伤害倍率
# Min: 0.00 Max: 500.00 Default: 30.00
PVPMeleeDamageModifier=30.0
# PVP 枪械伤害倍率
# Min: 0.00 Max: 500.00 Default: 50.00
PVPFirearmDamageModifier=50.0
# 调整车辆吸引僵尸的范围倍率(值越低越不容易引怪,也可能更省性能)
# Min: 0.00 Max: 10.00 Default: 0.50
CarEngineAttractionModifier=0.5
# 玩家跑动穿过别人时,是否会撞开/撞倒对方
PlayerBumpPlayer=false
# 控制“远程玩家”在游戏地图上的显示
# 1=隐藏 2=仅好友 3=所有人
# Min: 1 Max: 3 Default: 1
MapRemotePlayerVisibility=1
# Min: 1 Max: 300 Default: 5
BackupsCount=5
BackupsOnStart=true
BackupsOnVersionChange=true
# 备份周期(分钟?按服务器实现走)
# Min: 0 Max: 1500 Default: 0
BackupsPeriod=60
# 禁用车辆牵引/拖车
DisableVehicleTowing=false
# 禁用拖车(挂斗)牵引
DisableTrailerTowing=false
# 禁用烧毁车辆的牵引
DisableBurntTowing=false
# 被禁词列表文件路径(一行一个词)
BadWordListFile=
# 白名单词列表文件路径:即使包含禁词也允许(一行一个词)
GoodWordListFile=
# 聊天里说禁词怎么处理:
# 1=封禁 2=踢出 3=记录到数据库 4=禁言
BadWordPolicy=3
# 禁词替换成什么文本
BadWordReplacement=[HIDDEN]
# 禁用安全系统相关反作弊保护(数值含义由游戏决定)
AntiCheatSafety=4
AntiCheatMovement=4
# 禁用角色受击相关反作弊保护
AntiCheatHit=4
# 禁用数据包检查相关反作弊保护
AntiCheatPacket=4
# 禁用玩家权限相关反作弊保护
AntiCheatPermission=4
# 禁用玩家经验相关反作弊保护
AntiCheatXP=4
# 禁用火焰检查相关反作弊保护
AntiCheatFire=4
# 禁用安全屋相关反作弊保护
AntiCheatSafeHouse=4
# 禁用配方检查相关反作弊保护
AntiCheatRecipe=4
AntiCheatPlayer=4
# 禁用校验和checksum相关反作弊保护
AntiCheatChecksum=4
# 禁用物品检查相关反作弊保护
AntiCheatItem=4
AntiCheatServerCustomization=4
# 多人同步统计更新周期(秒);设为 0 则禁用统计
# Min: 0 Max: 10 Default: 1
MultiplayerStatisticsPeriod=1
# 禁用计分板/玩家列表
DisableScoreboard=false
# 在玩家列表里隐藏管理员
HideAdminsInPlayerList=false
# 世界生成用的种子
# 想换种子:填新值,并删除存档目录里的 map_worldgen.bin
Seed=NQfVrpzPXocQYSwZ
UsePhysicsHitReaction=false
# 聊天消息最大长度
# Min: 64 Max: 1024 Default: 200
ChatMessageCharacterLimit=200
# 聊天慢速模式间隔(秒):发完一条要等多久才能发下一条
# Min: 1 Max: 30 Default: 3
ChatMessageSlowModeTime=3

1011
server_SandboxVars.lua Normal file

File diff suppressed because it is too large Load Diff

900
templates/index.html Normal file
View File

@@ -0,0 +1,900 @@
<!doctype html>
<html lang="zh-CN" data-bs-theme="light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>PZ Config Studio</title>
<!-- Tabler UI (Bootstrap 5 based) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta21/dist/css/tabler.min.css" />
<!-- Better selects -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.bootstrap5.min.css" />
<style>
:root {
--bg0: #f5f7ff;
--panel: rgba(255, 255, 255, 0.92);
--panel-border: rgba(15, 23, 42, 0.10);
--muted: rgba(15, 23, 42, 0.64);
--accent: rgba(79, 70, 229, 1);
}
body {
min-height: 100vh;
background:
radial-gradient(1200px 900px at 12% 6%,
rgba(79, 70, 229, 0.20) 0%,
rgba(79, 70, 229, 0.10) 36%,
rgba(79, 70, 229, 0.00) 72%),
radial-gradient(1100px 820px at 92% 14%,
rgba(2, 132, 199, 0.18) 0%,
rgba(2, 132, 199, 0.08) 38%,
rgba(2, 132, 199, 0.00) 74%),
radial-gradient(980px 740px at 52% 98%,
rgba(16, 185, 129, 0.14) 0%,
rgba(16, 185, 129, 0.06) 36%,
rgba(16, 185, 129, 0.00) 70%),
linear-gradient(180deg,
#fbfcff 0%,
var(--bg0) 46%,
#f2f7ff 100%);
background-attachment: fixed;
}
.app-shell { max-width: 1480px; }
.glass {
background: var(--panel);
border: 1px solid var(--panel-border);
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.10);
}
.navbar-brand { letter-spacing: 0.2px; }
.muted { color: var(--muted); }
code { color: rgba(29, 78, 216, 1); }
.app-header {
background: rgba(255, 255, 255, 0.86);
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
}
@media (min-width: 992px) {
.sidebar-sticky {
position: sticky;
top: 72px;
height: calc(100vh - 90px);
}
.sidebar-card { height: 100%; display: flex; flex-direction: column; }
.sidebar-scroll { overflow: auto; flex: 1 1 auto; }
}
.setting-row {
border: 1px solid rgba(15, 23, 42, 0.10);
background: rgba(255, 255, 255, 0.88);
border-radius: 14px;
padding: 14px 16px;
transition: background-color .12s ease, border-color .12s ease;
content-visibility: auto;
contain: content;
contain-intrinsic-size: 140px;
}
.setting-row:hover {
background: rgba(255, 255, 255, 0.96);
border-color: rgba(15, 23, 42, 0.14);
}
.setting-row.modified {
border-color: rgba(245, 158, 11, 0.55);
background: rgba(245, 158, 11, 0.10);
}
.setting-key { font-size: 0.92rem; }
.setting-summary { line-height: 1.35; }
.setting-summary { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.form-control, .form-select, textarea {
background: rgba(255, 255, 255, 0.96) !important;
border-color: rgba(15, 23, 42, 0.14) !important;
}
.form-control:focus, .form-select:focus, textarea:focus {
border-color: rgba(79, 70, 229, 0.55) !important;
box-shadow: 0 0 0 .25rem rgba(79, 70, 229, 0.18) !important;
}
.list-group-item { background: transparent; }
.list-group-item.active {
background: rgba(79, 70, 229, 0.10);
border-color: rgba(79, 70, 229, 0.22);
}
.tom-select .ts-control, .ts-wrapper.single .ts-control {
background: rgba(255, 255, 255, 0.96) !important;
border-color: rgba(15, 23, 42, 0.14) !important;
}
.ts-dropdown {
background: rgba(255, 255, 255, 0.98) !important;
border-color: rgba(15, 23, 42, 0.14) !important;
z-index: 2000 !important;
}
.btn-detail {
border-color: rgba(79, 70, 229, 0.30) !important;
color: rgba(67, 56, 202, 1) !important;
background: rgba(79, 70, 229, 0.06) !important;
}
.btn-detail:hover, .btn-detail:focus-visible {
border-color: rgba(79, 70, 229, 0.40) !important;
background: rgba(79, 70, 229, 0.12) !important;
}
</style>
</head>
<body data-default-scope="{{ ini_path }}||{{ lua_path }}">
{% macro sidebar(dismiss_offcanvas=false) -%}
<div class="card glass sidebar-card">
<div class="card-body">
<div class="mb-3">
<label class="form-label">搜索(键名 / 中文说明 / 英文)</label>
<input
type="search"
class="form-control js-search"
placeholder="例如PVP / 端口 / Respawn / 僵尸 / Water..."
autocomplete="off"
spellcheck="false"
/>
</div>
<label class="form-check form-switch m-0">
<input class="form-check-input js-only-modified" type="checkbox" />
<span class="form-check-label">只看已修改(跨全部分组)</span>
</label>
<div class="d-grid gap-2 mt-3">
<button type="button" class="btn btn-outline-secondary js-restore-defaults" {% if dismiss_offcanvas %}data-bs-dismiss="offcanvas"{% endif %}>
恢复默认
</button>
<button type="button" class="btn btn-outline-secondary js-set-defaults" {% if dismiss_offcanvas %}data-bs-dismiss="offcanvas"{% endif %}>
当前设为默认
</button>
</div>
<div class="small muted mt-3">
<div>INI<code>{{ ini_settings | length }}</code></div>
<div>SandboxVars<code>{{ lua_setting_count }}</code></div>
<div class="mt-2">
文件:<code>{{ ini_path }}</code><br />
文件:<code>{{ lua_path }}</code>
</div>
{% if has_translations %}
<div class="mt-2">SandboxVars 中文说明:<code>{{ translations_path }}</code></div>
{% else %}
<div class="mt-2 text-warning">未找到 SandboxVars 中文说明 JSON将显示占位/英文)。</div>
{% endif %}
</div>
</div>
<div class="list-group list-group-flush sidebar-scroll">
<div class="list-group-header text-uppercase muted px-3 py-2 small">配置文件</div>
<a
class="list-group-item list-group-item-action d-flex align-items-center justify-content-between js-nav active"
href="#ini"
data-section="ini"
{% if dismiss_offcanvas %}data-bs-dismiss="offcanvas"{% endif %}
>
<div class="min-w-0">
<div class="fw-semibold text-truncate">server.ini</div>
<div class="small muted text-truncate">服务器核心/多人参数</div>
</div>
<span class="badge bg-azure-lt">{{ ini_settings | length }}</span>
</a>
<div class="list-group-header text-uppercase muted px-3 py-2 small">SandboxVars 分组</div>
{% for g in lua_groups %}
<a
class="list-group-item list-group-item-action d-flex align-items-center justify-content-between js-nav"
href="#lua-{{ g.id }}"
data-section="lua-{{ g.id }}"
{% if dismiss_offcanvas %}data-bs-dismiss="offcanvas"{% endif %}
>
<div class="min-w-0">
<div class="fw-semibold text-truncate">{{ g.name }}</div>
<div class="small muted text-truncate">{{ g.count }} 项</div>
</div>
<span class="badge bg-indigo-lt">{{ g.count }}</span>
</a>
{% endfor %}
</div>
</div>
{%- endmacro %}
<div class="page app-shell mx-auto">
<header class="navbar navbar-expand-md navbar-light sticky-top app-header">
<div class="container-xl">
<button
class="btn btn-outline-secondary d-lg-none me-2"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#sidebarOffcanvas"
aria-controls="sidebarOffcanvas"
>
菜单
</button>
<a class="navbar-brand d-flex align-items-center gap-2" href="/">
<span class="fw-semibold">PZ Config Studio</span>
<span class="badge bg-indigo-lt">WebUI</span>
</a>
<div class="ms-auto d-flex align-items-center gap-2">
<div class="d-none d-md-block small muted">
已修改 <span id="modifiedCount">0</span>
</div>
<button type="button" class="btn btn-outline-secondary d-none d-md-inline-flex" id="btnRestoreDefaults">
恢复默认
</button>
<button type="button" class="btn btn-outline-secondary d-none d-md-inline-flex" id="btnSetDefaults">
当前设为默认
</button>
<button class="btn btn-primary" type="submit" form="configForm" formaction="/download.zip">
下载 ZIP
</button>
<button
class="btn btn-warning"
type="submit"
form="configForm"
formaction="/save"
onclick="return confirm('确认要覆盖写回本地文件吗?建议先下载 ZIP 备份。');"
>
保存覆盖本地
</button>
</div>
</div>
</header>
<div class="offcanvas offcanvas-start" tabindex="-1" id="sidebarOffcanvas" aria-labelledby="sidebarOffcanvasLabel">
<div class="offcanvas-header">
<div class="offcanvas-title fw-semibold" id="sidebarOffcanvasLabel">配置导航</div>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body pt-0">
{{ sidebar(true) }}
</div>
</div>
<div class="page-wrapper">
<div class="page-body">
<div class="container-xl">
{% if saved %}
<div class="alert alert-success glass border-0 mb-3 py-2">
已保存覆盖到本地文件。
</div>
{% endif %}
<div class="row g-3">
<div class="col-12 col-lg-3 d-none d-lg-block">
<div class="sidebar-sticky">
{{ sidebar(false) }}
</div>
</div>
<div class="col-12 col-lg-9">
<form id="configForm" method="post">
<div id="noResults" class="alert alert-warning glass border-0 d-none">
没有匹配项(尝试清空搜索/取消“只看已修改”)。
</div>
<section class="config-section" data-section="ini" id="section-ini">
<div class="d-flex align-items-end justify-content-between mb-2">
<div>
<div class="h2 m-0">server.ini</div>
<div class="muted">按项编辑 · {{ ini_settings | length }} 项</div>
</div>
<div class="d-none d-md-block small muted">点击每项右侧“详情”查看完整中文说明</div>
</div>
<div class="card glass">
<div class="card-body">
<div class="d-flex flex-column gap-3">
{% for s in ini_settings %}
<div
class="setting-row setting-row-ini"
data-search="{{ (s.path ~ ' ' ~ s.description_zh) | lower }}"
data-min="{{ s.min_value if s.min_value is not none else '' }}"
data-max="{{ s.max_value if s.max_value is not none else '' }}"
data-default="{{ s.default_value if s.default_value else '' }}"
data-type="{{ s.value_type }}"
>
<div class="row g-3 align-items-start">
<div class="col-12 col-md-7">
<div class="d-flex align-items-start justify-content-between gap-2">
<div class="min-w-0">
<div class="d-flex align-items-center gap-2 flex-wrap">
<code class="setting-key text-truncate">{{ s.path }}</code>
<span class="badge bg-secondary-lt">{{ s.value_type }}</span>
{% if s.default_value %}<span class="badge bg-azure-lt">默认 {{ s.default_value }}</span>{% endif %}
{% if s.min_value is not none %}<span class="badge bg-indigo-lt">Min {{ s.min_value }}</span>{% endif %}
{% if s.max_value is not none %}<span class="badge bg-indigo-lt">Max {{ s.max_value }}</span>{% endif %}
</div>
<div class="setting-summary small muted mt-1">
{{ s.description_zh.split('\n', 1)[0] }}
</div>
</div>
<button
type="button"
class="btn btn-sm btn-outline-primary js-detail btn-detail"
data-bs-toggle="modal"
data-bs-target="#detailModal"
>
详情
</button>
</div>
<div class="d-none js-desc-zh">{{ s.description_zh }}</div>
<div class="d-none js-desc-en"></div>
</div>
<div class="col-12 col-md-5">
{% set name = 'ini__' ~ s.path %}
{% if s.choices %}
<select class="form-select setting-input js-select" name="{{ name }}" data-original="{{ s.value }}">
{% for v, label in s.choices.items() %}
<option value="{{ v }}" {% if (s.value | string) == v %}selected{% endif %}>{{ v }} · {{ label }}</option>
{% endfor %}
</select>
{% elif s.value_type == 'bool' %}
<label class="form-check form-switch">
<input
class="form-check-input setting-input"
type="checkbox"
name="{{ name }}"
data-original="{{ 'true' if s.value else 'false' }}"
{% if s.value %}checked{% endif %}
/>
<span class="form-check-label muted">开 / 关</span>
</label>
{% elif s.value_type in ['int','float'] %}
<input
class="form-control setting-input"
type="number"
name="{{ name }}"
value="{{ s.value }}"
data-original="{{ s.value }}"
{% if s.min_value is not none %}min="{{ s.min_value }}"{% endif %}
{% if s.max_value is not none %}max="{{ s.max_value }}"{% endif %}
step="{% if s.value_type == 'int' %}1{% else %}0.01{% endif %}"
/>
{% else %}
{% set v = s.value | string %}
{% if v | length > 80 or s.path in long_text_keys %}
<textarea class="form-control setting-input" name="{{ name }}" rows="3" data-original="{{ v }}">{{ v }}</textarea>
{% else %}
<input class="form-control setting-input" type="text" name="{{ name }}" value="{{ v }}" data-original="{{ v }}" />
{% endif %}
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</section>
{% for g in lua_groups %}
<section class="config-section d-none" data-section="lua-{{ g.id }}" id="section-lua-{{ g.id }}">
<div class="d-flex align-items-end justify-content-between mb-2">
<div>
<div class="h2 m-0">SandboxVars · {{ g.name }}</div>
<div class="muted">按项编辑 · {{ g.count }} 项</div>
</div>
<div class="d-none d-md-block small muted">点击每项右侧“详情”查看完整中文/英文说明</div>
</div>
<div class="card glass">
<div class="card-body">
<div class="d-flex flex-column gap-3">
{% for s in g.settings %}
<div
class="setting-row setting-row-lua"
data-search="{{ (s.path ~ ' ' ~ s.description_zh ~ ' ' ~ (s.description_en or '')) | lower }}"
data-min="{{ s.min_value if s.min_value is not none else '' }}"
data-max="{{ s.max_value if s.max_value is not none else '' }}"
data-default="{{ s.default_value if s.default_value else '' }}"
data-type="{{ s.value_type }}"
>
<div class="row g-3 align-items-start">
<div class="col-12 col-md-7">
<div class="d-flex align-items-start justify-content-between gap-2">
<div class="min-w-0">
<div class="d-flex align-items-center gap-2 flex-wrap">
<code class="setting-key text-truncate">{{ s.path }}</code>
<span class="badge bg-secondary-lt">{{ s.value_type }}</span>
{% if s.default_value %}<span class="badge bg-azure-lt">默认 {{ s.default_value }}</span>{% endif %}
{% if s.min_value is not none %}<span class="badge bg-indigo-lt">Min {{ s.min_value }}</span>{% endif %}
{% if s.max_value is not none %}<span class="badge bg-indigo-lt">Max {{ s.max_value }}</span>{% endif %}
{% if s.choices %}<span class="badge bg-teal-lt">枚举</span>{% endif %}
</div>
<div class="setting-summary small muted mt-1">
{{ s.description_zh.split('\n', 1)[0] }}
</div>
</div>
<button
type="button"
class="btn btn-sm btn-outline-primary js-detail btn-detail"
data-bs-toggle="modal"
data-bs-target="#detailModal"
>
详情
</button>
</div>
<div class="d-none js-desc-zh">{{ s.description_zh }}</div>
<div class="d-none js-desc-en">{{ s.description_en or '' }}</div>
</div>
<div class="col-12 col-md-5">
{% set name = 'lua__' ~ s.path %}
{% if s.choices %}
<select class="form-select setting-input js-select" name="{{ name }}" data-original="{{ s.value }}">
{% for v, label in s.choices.items() %}
<option value="{{ v }}" {% if (s.value | string) == v %}selected{% endif %}>{{ v }} · {{ label }}</option>
{% endfor %}
</select>
{% elif s.value_type == 'bool' %}
<label class="form-check form-switch">
<input
class="form-check-input setting-input"
type="checkbox"
name="{{ name }}"
data-original="{{ 'true' if s.value else 'false' }}"
{% if s.value %}checked{% endif %}
/>
<span class="form-check-label muted">true / false</span>
</label>
{% elif s.value_type in ['int','float'] %}
<input
class="form-control setting-input"
type="number"
name="{{ name }}"
value="{{ s.value }}"
data-original="{{ s.value }}"
{% if s.min_value is not none %}min="{{ s.min_value }}"{% endif %}
{% if s.max_value is not none %}max="{{ s.max_value }}"{% endif %}
step="{% if s.value_type == 'int' %}1{% else %}0.01{% endif %}"
/>
{% else %}
{% set v = s.value | string %}
{% if v | length > 80 or s.path in long_text_keys %}
<textarea class="form-control setting-input" name="{{ name }}" rows="3" data-original="{{ v }}">{{ v }}</textarea>
{% else %}
<input class="form-control setting-input" type="text" name="{{ name }}" value="{{ v }}" data-original="{{ v }}" />
{% endif %}
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</section>
{% endfor %}
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Detail modal (single reusable) -->
<div class="modal modal-blur fade" id="detailModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content glass">
<div class="modal-header">
<div>
<div class="modal-title fw-semibold">
<code id="detailKey"></code>
</div>
<div class="small muted mt-1" id="detailMeta"></div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="small muted mb-2">中文说明</div>
<div id="detailZh" class="text-secondary" style="white-space: pre-line;"></div>
<details class="mt-4" id="detailEnWrap">
<summary class="small muted">英文原文</summary>
<div id="detailEn" class="text-secondary mt-2" style="white-space: pre-line;"></div>
</details>
</div>
</div>
</div>
</div>
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 3000;">
<div id="appToast" class="toast glass border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-body" id="appToastBody"></div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/js/tom-select.complete.min.js"></script>
<script>
const DEFAULT_TEMPLATE_STORAGE_KEY = 'pz_default_template_v1';
const STORAGE_KEY = 'pz_active_section';
let activeSection = 'ini';
let searchText = '';
let onlyModified = false;
let modifiedCount = 0;
let applyTimer = null;
let selectInitTimer = null;
let selectInitRaf = null;
function normalizeValue(el) {
if (el.type === 'checkbox') return el.checked ? 'true' : 'false';
return (el.value ?? '').toString();
}
function showToast(text) {
const toastEl = document.getElementById('appToast');
const bodyEl = document.getElementById('appToastBody');
if (!toastEl || !bodyEl) return;
bodyEl.textContent = text;
if (typeof bootstrap === 'undefined' || !bootstrap.Toast) return;
bootstrap.Toast.getOrCreateInstance(toastEl, { delay: 2200 }).show();
}
function getDefaultScope() {
return (document.body?.dataset?.defaultScope || 'global').toString();
}
function loadDefaultTemplate() {
try {
const raw = localStorage.getItem(DEFAULT_TEMPLATE_STORAGE_KEY);
if (!raw) return null;
const map = JSON.parse(raw);
if (!map || typeof map !== 'object') return null;
const scope = getDefaultScope();
const tpl = map[scope];
if (!tpl || typeof tpl !== 'object') return null;
return tpl;
} catch {
return null;
}
}
function saveDefaultTemplate(template) {
try {
const raw = localStorage.getItem(DEFAULT_TEMPLATE_STORAGE_KEY);
const map = raw ? (JSON.parse(raw) || {}) : {};
const scope = getDefaultScope();
map[scope] = template;
localStorage.setItem(DEFAULT_TEMPLATE_STORAGE_KEY, JSON.stringify(map));
} catch {}
}
function collectCurrentValues() {
const values = {};
document.querySelectorAll('.setting-input').forEach((el) => {
const name = el.getAttribute('name');
if (!name) return;
values[name] = normalizeValue(el);
});
return values;
}
function setElementValue(el, value) {
if (el.type === 'checkbox') {
el.checked = value === 'true';
return;
}
if (el.tagName === 'SELECT') {
// Ensure enhanced select when we apply values (better UX).
ensureEnhancedSelect(el);
if (el.tomselect) {
el.tomselect.setValue(value, true);
} else {
el.value = value;
}
return;
}
el.value = value;
}
function restoreDefaults() {
const tpl = loadDefaultTemplate();
const usedCustom = !!tpl;
document.querySelectorAll('.setting-input').forEach((el) => {
const name = el.getAttribute('name');
if (!name) return;
const v = tpl && Object.prototype.hasOwnProperty.call(tpl, name) ? tpl[name] : (el.dataset.original ?? '').toString();
setElementValue(el, (v ?? '').toString());
});
initialModifiedScan();
applyFilters();
scheduleInitSelects();
showToast(usedCustom ? '已恢复到自定义默认配置(未保存到文件)' : '已恢复到文件初始值(未保存到文件)');
}
function setCurrentAsDefaults() {
const values = collectCurrentValues();
saveDefaultTemplate(values);
showToast('已将当前配置保存为默认(保存在浏览器本地)');
}
function scheduleApplyFilters() {
if (applyTimer) clearTimeout(applyTimer);
applyTimer = setTimeout(applyFilters, 90);
}
function scheduleInitSelects() {
if (selectInitTimer) clearTimeout(selectInitTimer);
selectInitTimer = setTimeout(initVisibleSelects, 60);
}
function getSearchInputs() {
return Array.from(document.querySelectorAll('.js-search'));
}
function getOnlyModifiedToggles() {
return Array.from(document.querySelectorAll('.js-only-modified'));
}
function syncSearchInputs(source) {
getSearchInputs().forEach((el) => {
if (el === source) return;
if (el.value !== searchText) el.value = searchText;
});
}
function syncOnlyModifiedToggles(source) {
getOnlyModifiedToggles().forEach((el) => {
if (el === source) return;
if (el.checked !== onlyModified) el.checked = onlyModified;
});
}
function setActiveSection(sectionKey, { updateHash = true, scrollToTop = false } = {}) {
activeSection = sectionKey || 'ini';
try { localStorage.setItem(STORAGE_KEY, activeSection); } catch {}
document.querySelectorAll('.js-nav').forEach((a) => {
a.classList.toggle('active', (a.dataset.section || '') === activeSection);
});
if (updateHash) {
const h = activeSection === 'ini' ? '#ini' : '#' + activeSection;
history.replaceState(null, '', h);
}
applyFilters();
if (scrollToTop) window.scrollTo({ top: 0, behavior: 'auto' });
}
function isModified(el, row) {
const original = (el.dataset.original ?? '').toString();
const current = normalizeValue(el);
const type = (row?.dataset?.type ?? '').toString();
if (type === 'int' || type === 'float') {
const o = Number(original);
const c = Number(current);
if (Number.isFinite(o) && Number.isFinite(c)) return o !== c;
}
return original !== current;
}
function updateRowModifiedFromInput(el) {
const row = el.closest('.setting-row');
if (!row) return;
const was = row.classList.contains('modified');
const now = isModified(el, row);
if (was === now) return;
row.classList.toggle('modified', now);
modifiedCount += now ? 1 : -1;
document.getElementById('modifiedCount').textContent = String(modifiedCount);
}
function initialModifiedScan() {
modifiedCount = 0;
document.querySelectorAll('.setting-row').forEach((row) => {
const el = row.querySelector('.setting-input');
if (!el) return;
const now = isModified(el, row);
row.classList.toggle('modified', now);
if (now) modifiedCount += 1;
});
document.getElementById('modifiedCount').textContent = String(modifiedCount);
}
function applyFilters() {
const q = (searchText || '').toLowerCase().trim();
const globalMode = q.length > 0 || onlyModified;
const noResults = document.getElementById('noResults');
if (!globalMode) {
document.querySelectorAll('.config-section').forEach((section) => {
const key = (section.dataset.section ?? 'ini').toString();
section.classList.toggle('d-none', key !== activeSection);
});
const active = document.querySelector(`.config-section[data-section="${activeSection}"]`);
if (active) active.querySelectorAll('.setting-row').forEach((row) => row.classList.remove('d-none'));
noResults.classList.add('d-none');
scheduleInitSelects();
return;
}
let visibleRows = 0;
document.querySelectorAll('.config-section').forEach((section) => {
let anyVisible = false;
section.querySelectorAll('.setting-row').forEach((row) => {
const hay = (row.dataset.search ?? '').toString();
const matchQ = !q || hay.includes(q);
const matchM = !onlyModified || row.classList.contains('modified');
const visible = matchQ && matchM;
row.classList.toggle('d-none', !visible);
if (visible) { anyVisible = true; visibleRows += 1; }
});
section.classList.toggle('d-none', !anyVisible);
});
noResults.classList.toggle('d-none', visibleRows !== 0);
scheduleInitSelects();
}
function ensureEnhancedSelect(selectEl) {
if (typeof TomSelect === 'undefined') return;
if (!selectEl || selectEl.dataset.enhanced === '1') return;
selectEl.dataset.enhanced = '1';
new TomSelect(selectEl, {
create: false,
allowEmptyOption: true,
maxOptions: 200,
plugins: ['dropdown_input'],
dropdownParent: 'body',
});
}
function initVisibleSelects() {
if (typeof TomSelect === 'undefined') return;
if (selectInitRaf) cancelAnimationFrame(selectInitRaf);
const candidates = Array.from(document.querySelectorAll('select.js-select')).filter((el) => {
if (el.dataset.enhanced === '1') return false;
// Only enhance visible selects to keep things fast.
return el.offsetParent !== null;
});
if (candidates.length === 0) return;
let i = 0;
const BATCH = 6;
const step = () => {
const end = Math.min(i + BATCH, candidates.length);
for (; i < end; i++) ensureEnhancedSelect(candidates[i]);
if (i < candidates.length) {
selectInitRaf = requestAnimationFrame(step);
} else {
selectInitRaf = null;
}
};
selectInitRaf = requestAnimationFrame(step);
}
function init() {
initialModifiedScan();
// Navigation (works for both desktop + offcanvas lists).
document.addEventListener('click', (e) => {
const a = e.target.closest('.js-nav');
if (!a) return;
e.preventDefault();
setActiveSection(a.dataset.section || 'ini', { scrollToTop: true });
});
// Defaults actions (header + sidebar).
document.addEventListener('click', (e) => {
const restoreBtn = e.target.closest('#btnRestoreDefaults, .js-restore-defaults');
if (restoreBtn) {
restoreDefaults();
return;
}
const setBtn = e.target.closest('#btnSetDefaults, .js-set-defaults');
if (setBtn) {
setCurrentAsDefaults();
return;
}
});
// Search + only-modified (supports multiple mirrored controls).
getSearchInputs().forEach((el) => {
el.addEventListener('input', () => {
searchText = el.value || '';
syncSearchInputs(el);
scheduleApplyFilters();
});
});
getOnlyModifiedToggles().forEach((el) => {
el.addEventListener('change', () => {
onlyModified = !!el.checked;
syncOnlyModifiedToggles(el);
scheduleApplyFilters();
});
});
// Change tracking via event delegation (fast).
const form = document.getElementById('configForm');
form.addEventListener('input', (e) => {
const el = e.target;
if (!el || !el.classList || !el.classList.contains('setting-input')) return;
updateRowModifiedFromInput(el);
if (onlyModified) scheduleApplyFilters();
}, true);
form.addEventListener('change', (e) => {
const el = e.target;
if (!el || !el.classList || !el.classList.contains('setting-input')) return;
updateRowModifiedFromInput(el);
if (onlyModified) scheduleApplyFilters();
}, true);
// Lazy-enhance selects only when user interacts.
document.addEventListener('focusin', (e) => {
const sel = e.target.closest && e.target.closest('select.js-select');
if (sel) ensureEnhancedSelect(sel);
}, true);
document.addEventListener('pointerdown', (e) => {
const sel = e.target.closest && e.target.closest('select.js-select');
if (sel) ensureEnhancedSelect(sel);
}, true);
// Detail modal: one delegated handler.
const keyEl = document.getElementById('detailKey');
const metaEl = document.getElementById('detailMeta');
const zhEl = document.getElementById('detailZh');
const enWrap = document.getElementById('detailEnWrap');
const enEl = document.getElementById('detailEn');
document.addEventListener('click', (e) => {
const btn = e.target.closest('.js-detail');
if (!btn) return;
const row = btn.closest('.setting-row');
if (!row) return;
const key = row.querySelector('code')?.textContent ?? '';
const zh = row.querySelector('.js-desc-zh')?.textContent ?? '';
const en = row.querySelector('.js-desc-en')?.textContent ?? '';
const type = (row.dataset.type || '').toString();
const minV = (row.dataset.min || '').toString();
const maxV = (row.dataset.max || '').toString();
const defV = (row.dataset.default || '').toString();
keyEl.textContent = key;
zhEl.textContent = zh;
const metaParts = [];
if (type) metaParts.push(`类型:${type}`);
if (defV) metaParts.push(`默认:${defV}`);
if (minV) metaParts.push(`Min${minV}`);
if (maxV) metaParts.push(`Max${maxV}`);
metaEl.textContent = metaParts.join(' · ');
const hasEn = en.trim().length > 0;
enWrap.classList.toggle('d-none', !hasEn);
enEl.textContent = en;
});
// Restore active section from hash/storage.
const fromHash = (location.hash || '').replace(/^#/, '');
const fromStorage = (() => { try { return localStorage.getItem(STORAGE_KEY) || ''; } catch { return ''; } })();
const candidate = fromHash || fromStorage || 'ini';
const exists = document.querySelector(`.js-nav[data-section="${candidate}"]`);
setActiveSection(exists ? candidate : 'ini', { updateHash: false, scrollToTop: false });
scheduleInitSelects();
}
init();
</script>
</body>
</html>

View File

@@ -0,0 +1,158 @@
from __future__ import annotations
import argparse
import json
import time
import urllib.parse
import urllib.request
from urllib.error import URLError
from pathlib import Path
def _decode_utf8_keep_newlines(path: Path) -> str:
return path.read_bytes().decode("utf-8-sig")
def translate_en_to_zh(
text: str,
*,
timeout_s: int = 45,
retries: int = 6,
backoff_s: float = 1.0,
) -> str:
"""
Uses Google Translate's public endpoint (no API key) to translate English -> zh-CN.
Note: This is a best-effort helper for generating a local offline mapping file.
"""
if not text.strip():
return ""
q = urllib.parse.quote(text)
url = (
"https://translate.googleapis.com/translate_a/single"
"?client=gtx&sl=en&tl=zh-CN&dt=t&q="
+ q
)
headers = {"User-Agent": "pz-config-editor/1.0 (+https://translate.googleapis.com)"}
req = urllib.request.Request(url, headers=headers)
last_err: Exception | None = None
for attempt in range(retries):
try:
with urllib.request.urlopen(req, timeout=timeout_s) as resp:
data = resp.read().decode("utf-8")
payload = json.loads(data)
parts = payload[0] or []
return "".join((p[0] or "") for p in parts)
except (TimeoutError, URLError, json.JSONDecodeError) as e:
last_err = e
time.sleep(backoff_s * (attempt + 1))
raise RuntimeError(f"Translate failed after {retries} retries: {last_err}") from last_err
def extract_sandbox_comment_blocks(lua_path: Path) -> dict[str, str]:
"""
Returns: { "Zombies": "comment lines...", "ZombieLore.Speed": "comment lines..." }
"""
lines = _decode_utf8_keep_newlines(lua_path).splitlines()
def is_ident(s: str) -> bool:
return s.isidentifier()
comments: list[str] = []
table_stack: list[str] = []
out: dict[str, str] = {}
for line in lines:
stripped = line.strip()
if stripped.startswith("--"):
comments.append(stripped[2:].lstrip())
continue
if not stripped:
comments = []
continue
if stripped.endswith("= {"):
name = stripped.split("=", 1)[0].strip()
if is_ident(name):
table_stack.append(name)
comments = []
continue
if stripped == "},":
if table_stack:
table_stack.pop()
comments = []
continue
if "=" in stripped and stripped.endswith(","):
key = stripped.split("=", 1)[0].strip()
if not is_ident(key):
comments = []
continue
effective_stack = table_stack[:]
if effective_stack[:1] == ["SandboxVars"]:
effective_stack = effective_stack[1:]
setting_path = ".".join(effective_stack + [key]) if effective_stack else key
out[setting_path] = "\n".join(comments).strip()
comments = []
continue
comments = []
return out
def main() -> int:
parser = argparse.ArgumentParser(description="Generate SandboxVars English->Chinese comment mapping JSON.")
parser.add_argument("--lua", default="server_SandboxVars.lua", help="Path to server_SandboxVars.lua")
parser.add_argument("--out", default=str(Path("i18n") / "sandboxvars_zh.json"), help="Output JSON path")
parser.add_argument("--sleep", type=float, default=0.12, help="Sleep between requests (seconds)")
parser.add_argument("--resume", action="store_true", help="If output exists, load and continue")
args = parser.parse_args()
lua_path = Path(args.lua)
out_path = Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
blocks = extract_sandbox_comment_blocks(lua_path)
mapping: dict[str, str] = {
"VERSION": "SandboxVars 文件版本号(通常不用改)。",
"StartYear": "开局年份(通常与开局日期/月份一起使用)。",
}
if args.resume and out_path.exists():
try:
mapping.update(json.loads(out_path.read_text(encoding="utf-8")))
except Exception:
pass
cache: dict[str, str] = {}
total = 0
for key, en in blocks.items():
if key in mapping:
continue
total += 1
if en in cache:
mapping[key] = cache[en]
continue
try:
zh = translate_en_to_zh(en)
except Exception as e:
print(f"[WARN] {key}: {e}")
zh = ""
cache[en] = zh
mapping[key] = zh
time.sleep(args.sleep)
out_path.write_text(json.dumps(mapping, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"Wrote {len(mapping)} entries to {out_path} (translated {total} blocks).")
return 0
if __name__ == "__main__":
raise SystemExit(main())