feat:初版
This commit is contained in:
48
README.md
Normal file
48
README.md
Normal 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 的公开接口做机翻,仅用于生成本地离线文件。
|
||||
BIN
__pycache__/pz_webui.cpython-311.pyc
Normal file
BIN
__pycache__/pz_webui.cpython-311.pyc
Normal file
Binary file not shown.
269
i18n/sandboxvars_zh.json
Normal file
269
i18n/sandboxvars_zh.json
Normal 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
2
pz_config/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Project Zomboid config editor helpers (server.ini + SandboxVars lua)."""
|
||||
|
||||
BIN
pz_config/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
pz_config/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
pz_config/__pycache__/models.cpython-311.pyc
Normal file
BIN
pz_config/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
BIN
pz_config/__pycache__/parsers.cpython-311.pyc
Normal file
BIN
pz_config/__pycache__/parsers.cpython-311.pyc
Normal file
Binary file not shown.
BIN
pz_config/__pycache__/writers.cpython-311.pyc
Normal file
BIN
pz_config/__pycache__/writers.cpython-311.pyc
Normal file
Binary file not shown.
42
pz_config/models.py
Normal file
42
pz_config/models.py
Normal 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
287
pz_config/parsers.py
Normal 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
87
pz_config/writers.py
Normal 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
205
pz_webui.py
Normal 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
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
flask==3.0.3
|
||||
|
||||
475
server.ini
Normal file
475
server.ini
Normal 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
1011
server_SandboxVars.lua
Normal file
File diff suppressed because it is too large
Load Diff
900
templates/index.html
Normal file
900
templates/index.html
Normal 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>
|
||||
158
tools/generate_sandboxvars_zh.py
Normal file
158
tools/generate_sandboxvars_zh.py
Normal 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())
|
||||
Reference in New Issue
Block a user