Skip to content

Repository files navigation

WARFIRE RISES

WARFIRE RISES

战火升腾

带钢铁雄心4式地图与操作面板的世界模拟游戏,
基于 Tauri 重写。

这是什么

原版《战火升腾》是一个 React + Ink 的终端游戏,你通过文字控制台指挥国家。这一版保留了模拟引擎,把控制台换成了钢铁雄心4式的世界地图与操作面板:在地图上点击国家,用侧边面板处理外交、经济、军事与战争。

每个战役开始时,游戏会抓取真实数据——实时汇率与世界新闻头条(提取为关键词)——然后以连续流动的时间向前推演,11 个 AI 国家各自独立行动。

运行

npm install
npm run tauri:dev      # 桌面应用(Tauri)
npm run dev            # 仅浏览器,http://localhost:1420

打包:

npm run tauri:build    # → src-tauri/target/release/bundle/

推送 v* 标签会触发 GitHub Actions,同时构建 Windows .exe、macOS .dmg、Linux .deb 三种安装包(见 .github/workflows/build.yml)。普通推送也会构建并作为 artifact 上传,不生成 Release。

⚠ 关于安装路径。 下面这点只影响当前这份工作副本。它所在目录名里带冒号(RE: Warfire Rises),而在 Linux 上冒号是 PATH 的分隔符,会破坏两件事:

  1. npm run 找不到 node_modules/.bin。 所以脚本统一写成 node ./node_modules/<包>/<入口>——走显式文件路径,不依赖 PATH 查找。这个写法在 Windows 上也成立(./ 开头的可执行文件在 cmd.exe 里不认),因此同一套脚本三平台通用,CI 跑的就是它。
  2. cargo 完全无法构建 —— 它会用目标目录拼 LD_LIBRARY_PATH 并直接报错: error: failed to join paths from $LD_LIBRARY_PATH together … path segment contains separator ':'。 src-tauri/.cargo/config.toml 把产物重定向到 ~/.cache/warfire-rises-target 绕开它。

干净的做法是把项目移到不含冒号的路径,之后删掉 src-tauri/.cargo/config.toml,脚本也能简化回 vite / tsc / tauri。

这些都不会进仓库。 src-tauri/.cargo/ 被 git 忽略,正是因为里面是只对一台机器有意义的绝对路径——新克隆下来照常构建,CI 也看不到它。

已知限制

  • 世界新闻关键词在浏览器 / webview 里会退回内置列表。 已修复:桌面版的新闻抓取改由 Rust 侧完成(fetch_feed 命令,reqwest + rustls,URL 走白名单),webview 不再直接请求 news.google.com —— 那一侧的 CORS 拦不住 Rust。浏览器版(npm run dev)没有 Rust 可问,仍会退回内置关键词。汇率接口(open.er-api.com)带 CORS 头,一直是从前端直接取的。

操作

操作 说明
点击国家 选中该国,右侧面板切换到该国的上下文
拖动地图 平移
滚轮 以光标为中心缩放
空格 暂停 / 继续时间
1–5 设置游戏速度
`(反引号) 打开 / 关闭控制台——界面上没有按钮
右键气泡 关闭该条通知
顶栏 💾 打开存档面板(3 个存档位:保存 / 载入 / 删除)

选中国家会改变面板的操作对象。未选中任何国家时面板作用于本国;选中后按钮作用于选中的国家。

存档位。 顶栏的 💾 打开存档面板,3 个存档位各自记录国家、天数、难度与保存时间。标题界面的「继续已保存的游戏」打开同一个面板,只是此时只能载入与删除(还没有战役可写)。控制台里 save 2 写入 2 号位、load 2 读取(不带参数时 save 仍像原版一样打印存档串)。升级前的单个存档会被当作 1 号位继续可用。

时间流动

时间是连续的,不是回合制。clock.time 是小数天,state.day 是它的整数部分;一个渲染循环把真实流逝的毫秒乘以速度档位灌进去。

档位 每天秒数 一天耗时
暂停 0 —
1 0.25 4 秒
2 0.5 2 秒
3 1 1 秒
4 2 0.5 秒
5 5 0.2 秒

所有事情都要花时间。 没有任何行动是瞬间完成的——每个行动都是一个带起止时间的任务,显示在「进行中」队列里并有进度条。下令时即扣成本,计时结束时才生效。 取消会返还一半。

行动 天数 行动 天数
谴责 3 渗透 45
操纵汇率 5 投资 60
外交制裁 5 刺激计划 90
精确打击 10 科研 60–180
改善关系 10 建造 120–180
军事演习 20 征兵(每师) 30
防御条约 30 正当化宣战理由 1–180

时代(历史版图)

开始游戏前可以和难度一起选时代,五个时代各自使用当年的真实国界、国力与阵营:

时代 开局 版图
二战前夕 · 梦魇 1938 年 1 月 奥地利尚未并入德国,英国仍持巴勒斯坦与印度,日本据有朝鲜。欧洲是和平的,中国已经打了半年,开局即在战争中
二战战争 · 死斗 1942 年 1 月 轴心国走得最远的一年:日本据有中国沿海与整个南方作战区,德国已吞下奥地利、捷克与波兰。开局即在战争中
二战战争 · 初醒 1945 年 1 月 大战最后一年,德国与日本尚在,满洲已回归中国。开局即在战争中
二十世纪 · 解体 1994 年 1 月 苏联已解体,十二国全部存在
二一世纪 · 复兴 2000 年 1 月 世纪之交的大国格局

边界来自 aourednik/historical-basemaps(GPL-3.0,见 NOTICE.md),由 scripts/build-eras.mjs 生成为 public/eras/*.json(地图按需加载)与 src/map/data/eraRegions.json(每个现代省份在该年归谁所有——模拟层要的是省份,不是多边形)。生成脚本还会把 1938 年数据里被拆开的中国("Chinese warlords"、新疆、西藏)合回一个中国,并把以色列/朝鲜这类当年尚不存在的国家从名册里去掉。

时代同时决定:国家数据(人口、经济、军力、国库、师的数量——1938 年的美国是 1.28 亿人、没有核弹)、初始阵营与关系,以及战役的日历(左栏显示的是 1938 年 1 月 3 日,不是今天)。

国名也随时代变:1938 年的中国是「中华民国」、俄罗斯是「苏维埃社会主义共和国联盟」、日本是「大日本帝国」、德国是「德意志国」、印度是「英属印度」、巴西是「巴西合众国」;1942 年的法国是「自由法国」。这些名字写在语言文件里(era.<era>.<ID>.full / .name),由 Country.nameKey 指向,所以中英文各有一份。标题界面用的也是时代名录,不会出现「1938 年选以色列」这种事。

国旗和简介也都随时代变。1938 年的中国挂的是中华民国国旗、俄罗斯是苏联国旗、美国是 48 星旗、印度是英属印度旗、伊朗是狮日旗、巴西是 1889 年版;德国是德意志国旗(见下)。这些是从维基共享资源直接下载的原图(scripts/fetch-flags.py,无人手绘),脚本把它们套进与现代国旗相同的 36×36 画布,所以游戏里所有国旗都是同一种东西——地图图标、面板、标题界面共用一套尺寸(components/Flag.tsx)。简介则写在 era.<时代>.<ID>.desc 里,没写的国家回落到现代简介。

直播模式下,德意志国的国旗会把万字换成「乐」,并在旗子下面标出灭亡日期(1945.5.8)。见 src/game/flags.ts。

政体也随时代变(era.governments)。民国是威权制度而不是共产主义——不写这一条的话,1938 年的中华民国会挂着现代中国的政体标签。同表还改了:德意志国、巴西(瓦加斯的新国家体制)为威权制度,大日本帝国、英属印度、伊朗帝国为君主制度;1994 年的俄罗斯是民主制度、朝鲜是共产主义。政体只影响显示(国家卡片、status 命令),目前不参与任何机制。

版图之内:谁在管事

1938 年的中华民国是一个国家、也是十几个政府。北伐没有解散军阀,只是让他们换了旗号:抗战是晋系在山西打、桂系在广西打、滇系在云南打、三马在西北打——打着重庆的旗,未必听重庆的话。新疆听盛世才的(背后是莫斯科),西藏自 1912 年起就自己管自己。

这些省在地图上按省加一层暗色覆盖 + 虚线边界,鼠标悬停国家会列出是谁在管,国家面板里也会写。这只是着色:国界不动,领土仍属中国,战争、和平、外交、割让全都照旧——state.autonomous 是一份独立的名单,不碰 regionOwner。

数据在 eras.ts 的 WARLORD_CHINA 表里(省名 + 派系,faction.jin → 晋系)。1945 年新疆已归国民政府(盛世才 1942 年与莫斯科翻脸、1944 年被重庆撤换),所以 WARLORD_CHINA_1945 少一省。本表由人编写,出处是各省在抗战期间的实际控制者——这是全项目唯一一处手工整理的历史数据,其余(国界、国旗、简介)都有外部来源。

国策(decisions/*.warf-decision)

国策是纯 JSON,在代码之外编写。把文件丢进 decisions/,在国策面板点「从磁盘重新载入」即可——桌面版无需重新编译。构建时也会把文件打包进去,所以浏览器版没有文件系统也能用。

开局前可以在「游戏设置」里启用/关闭决策文件——清单会列出每个文件的国策与新闻条数,关掉的文件整局都不会加载(它的国策、新闻都不存在)。all.warf-decision 是基础文件,强制启用、不可关闭;战役一旦开始,这份清单就不再可改。

国策可以限定时代:加 "PlayTime": "ngtm" 就只在 1938 年那一局出现,["dfig","waku"] 则是 1942 与 1945 两局。代码取自时代英文名的首字母:

allt ngtm dfig waku splt resm
所有时代 二战前夕 · 梦魇(1938) 二战战争 · 死斗(1942) 二战战争 · 初醒(1945) 二十世纪 · 解体(1994) 二一世纪 · 复兴(2000)

不写 PlayTime 的国策默认是 resm,也就是只在 21 世纪初那一局出现;写错代码会报错并丢弃该条国策,不会静默当成 resm。

目前 all.warf-decision 里 战时经济动员、对敌宣传攻势、邀请军事指挥家(总参谋部自动指挥)标了 allt,其余国策都是 resm——全国团结号召、china.warf-decision、china-tfr.warf-decision 里的尚未标注。

完整的字段、属性与排错说明见 docs/decisions.md,可以直接抄的模板在 decisions/templates/。

{
  "Country": "CHN",
  "Decisions": [
    {
      "Type": "Decisions",
      "Name": "持续创新",
      "Description": "(持续性国策)\n为了未来科技与国家的进步…",
      "Require": [
        { "Type": "CountryEndurance", "To": "self", "Condition": ">=", "Num": 50 }
      ],
      "Time": "inf",
      "InfinityPhases": [
        {
          "Type": "Phase",
          "Get": [ { "Type": "Get", "Rewards": [["CountryEndurance", "+", 10], ["Nuke", "+", 5]] } ],
          "Content": "我们成功研发出了新一代秘密核导弹。\n科研之路仍在继续。",
          "Time": 60
        }
      ],
      "Result": [
        { "Type": "Result", "Rewards": [ { "Type": "Reward", "Rewards": [["Economy", "+", 2]] } ] }
      ]
    }
  ]
}

上面是去掉注释后的版本,因为解析器用的是严格 JSON.parse:// 注释、尾逗号、单引号都会让整个文件被丢弃。Country 是国家 id 或 "all";Time 是数字(1–180)或 "inf",为 "inf" 时必须提供 InfinityPhases;Result 每次结算都会应用。

条件写作 { Type, To, Condition, Num },Condition 可取 >= <= > < == !=,To 可以是 self、target、enemy 或国家 id。

奖励写作 [属性, 运算符, 数值],运算符可取 + - * / =;成对属性还能带第四项指定国家,例如 ["NukedCountry", "+", 1, "JPN"],省略时用面板上选中的目标国。奖励出现在 InfinityPhases[].Get[].Rewards 与 Result[].Rewards[].Rewards。

可用属性:CountryEndurance ArmyEndurance DiplomaticPoints WorldCollapse Day Economy Military Stability PublicSupport ForceValue Treasury Population Manpower Nuke Divisions DivisionStrength Nuclear AutoArmy,以及五个成对属性:

  • Relation —— 好感度。条件读取执行国对 To 国的好感;奖励双向写入(+ - =)。
  • NukedCountry —— 条件读取「我方对 To 国投放核弹的次数」;作为奖励则向该国投放一枚核弹,效果与游戏内的「核打击」行动完全一致(不检查条件,也不消耗弹头,代价自己写)。
  • GetCasusBelli —— 条件读取「我方是否握有对 To 国的战争理由」(1/0);作为奖励 + 获得、- 撤销。
  • War —— 条件读取「To 国是否正与我方交战」(1/0);作为奖励宣战,+ 是我方宣战、- 是对方宣战(reverse)。剧本化宣战会跳过外交门槛,但仍会消耗进攻方的战争理由。
  • SideCountry —— 条件读取「To 国是否从属于我方」(1/0);作为奖励把 To 国变成我方附属国(- 清除标记)。只要我方占着 To 国的任何一块行政区,条件就成立 —— 这条是算出来的,还地即解除。
  • FullDestroy —— 条件读取「To 国是否已被彻底毁灭」(1/0);作为奖励把 To 国从地图上抹掉:它的地块直接沉入海底,地图上不再绘制国土、省界、城市与国名,军队与战争一并消失。不可逆,且不能对自己使用。

文件顶层还可以写 News 数组声明新闻稿,用 EffectNews 属性发布与查询:

"News": [
  { "Type": "News", "Title": "日本陷落", "Countries": ["CHN", "JPN"],
    "Content": "……", "Effects": [["WorldCollapse", "+", 30]], "NewsId": "chinatfr_japdie" }
]
  • 条件:{ "Type": "EffectNews", "To": "chinatfr_japdie", "Condition": "==", "Num": 1 } —— To 填 NewsId,读「这条新闻发布过没有」。
  • 奖励:["EffectNews", "+", 1, "chinatfr_japdie"] —— 第四项同样是 NewsId。发布会立刻结算 Effects、把新闻推成一张 📰 气泡,并记录进存档;重复发布不会重复结算。

还有一个只能用在奖励里的重定向属性 ToCountry:["ToCountry", "+", 1, "JPN", "Economy", "-", 20] 表示「这 20 点经济从日本身上扣,不是从我省」,后面可以继续跟任意多条 属性 运算符 数值 指令。完整规则见 docs/decisions.md。

CountryEndurance、ArmyEndurance、DiplomaticPoints 存在于玩家国家上。当国策把 To 解析到某个 AI 国家时,它们会退回到该国的稳定度 / 军力,这样为 AI 写的国策读起来仍然合理,而不是悄悄用了玩家的数值。

写坏的文件不会拖垮游戏——每个失败的文件都会连同解析错误列在国策面板里,其余照常加载。

战斗

参照《钢铁雄心4》设计,而不是单次掷骰。核心是这一句:

输掉组织度就输掉战斗,损失兵力才死人。

师会战斗到组织度(士气、凝聚力、补给)耗尽为止,然后脱离接触、由预备队顶上。兵力——真正的人和装备——只会在一个已经崩溃的单位继续挨打时才流失。战斗靠磨掉对方的组织度取胜,不是靠杀伤。

机制 规则
战线宽度 前线只能容纳一定数量的师;每师占 20 宽度,基础战线 80(防守方 100)。装不下的在预备队等待,随部队崩溃轮换上场。
防御 / 突破池 防守方的 Defence(或进攻方薄得多的 Breakthrough)会吸收来袭火力。落在池内的攻击只有约 10% 命中,超出池的部分约 40% 命中。堆出超过对方承受力的火力,才能破线。
工事 固守时每天累积,上限 +30,最高把防守方池子提高 35%。主动进攻会把它烧掉。
姿态 进攻提高 15% 输出,但把防御池换成突破池——除非你兵力或科技占优,否则得不偿失。
科技 攻击随科技的成长速度约为防御的 2.5 倍。若两者同速成长,势均力敌的战斗永远不会分出结果——双方的池子会把对方的全部火力吞掉。

战斗每天自动推进。war attack / war defend 是命令而非攻击:它们设定姿态,然后仗自己打下去。

指挥

师隶属于集团军,每个集团军有指挥官和常设命令。只有设为进攻或固守的集团军会上阵——设为预备的完全不下场,这是让打残的部队休整而不解散的方式。

将领有三项 1–5 的技能,直接乘算进战斗池:攻影响火力,防影响承受力,谋影响构筑工事的速度。没有指挥官的集团军照样能打,只是会拉低平均值——所以放着不管是有代价的。

参谋部

**「邀请军事指挥家」**国策(25 天)会把军队交给参谋部。它就是一个普通的 .warf-decision 文件——通过设置 AutoArmy 属性实现,所以不需要任何专门代码:

"Result": [{ "Type": "Result", "Rewards": [
  { "Type": "Reward", "Rewards": [["AutoArmy", "=", 1], ["ArmyEndurance", "+", 10]] }
]}]

接管之后,参谋部每天工作:把平均组织度低于 45% 的军团撤下来休整、恢复后归队;按任务选派技能对口的指挥官(进攻选攻击最高的,固守选防御最高的);把补充兵员分给最薄的集团军;每 45 个和平日且国库允许时新征一个师。

玩家仍然可以下令——而参谋部会提出异议。 手动改动会弹出一位参谋长的对话,随机从五句话里选一句(「首领,这样的指派不正确。您这样做会消耗我们的耐力的!」)。听从则取消命令;坚持则命令生效,并不可预测地损耗 1–23% 军队耐力。

第一版有个真实缺陷:参谋部第二天早上就把玩家的命令改了回去,让「坚持执行」毫无意义。现在被否决后它会停止干预部署 15 天,让决定真正生效。

战后分地谈判

一个国家的领土不会整块易主——战胜国会坐下来,按它的实际行政区划瓜分。这正是携带省份数据的回报。

战胜国坐主席并拿到最大的索取额度;与其并肩作战的盟国获得席位和较小的额度。每个地区消耗 1 点。AI 代表团会自动花掉自己的份额,无人认领的地区仍归战败国。归属写入 state.regionOwner,地图会把这些省按新主人的颜色重新上色——边界变化在地图上看得到。

科研、核武与军队

任何武器都需要科研。 拥核国家默认解锁 nuclear_weapons,其他国家必须先研究。光有技术还不够——还需要建核设施(180 天、$1500B),之后每 30 天产出一枚弹头。核打击消耗一枚库存弹头。

核打击的授权取决于你是否真的在打仗:

和平时 战时(针对交战国)
库存有弹头 需要 需要
核技术 需要 需要
民众支持 > 70% 需要 免除
迫使值 > 55% 需要 免除
态度为「不死不休」 需要 免除

一旦开战,弹头就是论据——没有国内说服可做了。因此核打击也会直接出现在战争面板上,瞄准你正在交战的那个敌人。对非交战国动手仍要走完整的和平时期检查,即便你正忙别的事。

人力以千为单位,来自人口 1% 的储备池。征募一个师(50K 人力、$80B、30 天)会永久占用这些人力——只有解散该师才会归还。开战下限是 500K 已投入兵力(即十个师)。

关于「至少 500 个人」的理解。 我按 50 万人(src/game/army.ts 里的 WAR_MIN_MANPOWER = 500)实现,也就是十个师。如果你要的是字面意义的 500 人,改这个常量即可。

正当化宣战理由是宣战的前置条件:

天数 = 90 × (1 − |关系| / 100)   关系为负时
天数 = 90 × (1 + 关系 / 100)     关系为正时

两式在 0 处相接,曲线连续:中立时 90 天,关系 −88 时 11 天,+100 时 180 天。

事件与节奏

时间不再是回合制之后,有两件事必须调整。

事件变得稀有。 终端版每天生成 1–3 个世界事件,手动点日期时没问题,但在 5 天/秒下会刷屏。现在 DAILY_EVENT_CHANCE = 0.08(src/game/events.ts),绝大多数日子是平静的。

AI 不再无脑开战。 每个国家带一个 aggression(俄罗斯 1.3、朝鲜 1.6、以色列 1.5;德国 0.4、巴西 0.3),开战要先过一道「立场」筛选:不与盟友或任何一方视为盟友的国家开战、必须双方都处于不死不休(关系 ≤ −50)且要么在彼此的 enemies 名单里、要么关系已经跌破 −70、必须是能打赢的仗(侵略性强的接受接近均势,其余要求至少 1.1 倍优势)、而且已经在打的国家不会再开第二战场。aggression < 0.5 的国家(德国、巴西)永不主动入侵。实测 5 场 400 天的模拟里 300 次宣战全部发生在彼此认定的敌对国家之间,且没有一次打在盟友、弱者或既有战事之外。

崩坏度被重新标定。 沿用原来的每日数值,一个战役在 5× 下约 6 秒就结束了。收益现在以小数累积在 worldCollapseRaw 中(worldCollapse 是显示用的取整值),src/game/state.ts 里的 COLLAPSE_SCALE 是调节战役长度的唯一旋钮。目前一个战役约 140 天,足够让 60–180 天的科研和跨国策产生意义。按影响排序的调节项:DAILY_EVENT_CHANCE、COLLAPSE_SCALE、AI 的 AI_WAR_CHANCE。

事件以气泡呈现,钉在地图右下角:世界事件和 AI 行动都在那里。每条存活 30 秒,右键永久关闭(不存档、不重播)。完整记录保留在日志面板。AI 国家每天行动,所以只有值得注意的(宣战、和平、制裁)会打断你——十一个国家天天动,那是噪音不是新闻。世界新闻限流为每 5 个游戏日一条。

界面没有底栏;地图铺满窗口,控制台只能用反引号打开。

顺带修掉的一个真实 bug。 原版 AI 会每天重复宣战同一个国家——它从不检查是否已在该国交战,于是每天追加一次崩坏度。30 回合的旧战役里看不出来,时间连续后就是雪崩。

地图

渲染引擎:OpenLayers。 地图以前是自己写的 SVG —— 每次 store 通知都要 React 重绘上千条省份 path,浏览器再把它们重新栅格化一遍,平移缩放都卡在这个循环里。现在几何体放在 canvas 里,移动的是相机:平移缩放是一次变换而不是一次协调,投影(geo.ts 里把 d3 的 Natural Earth 投影注册成了 OpenLayers 的坐标参考系)、命中测试、图层顺序、标签避让(declutter)都由引擎负责。观感不变:同一套投影、同一套调色板、同样的图层顺序。 地图是真实地理——通过 world-atlas 使用 Natural Earth 110m 边界,用 d3-geo(Natural Earth I 投影)渲染为 SVG。约 177 个国家全部绘制,其中 12 个可玩国家可交互、有颜色,其余是惰性的灰色地形。

六种地图模式按不同指标重新上色:

  • 政治 —— 与你的关系,从盟友到不死不休(默认)
  • 阵营 —— 你的盟友与敌人
  • 军事 / 经济 / 稳定 —— 强度热力图
  • 战争 —— 高亮交战方

国界

地图数据来自 Natural Earth(world-atlas,美国发布的数据集),按原样使用,上面做了两处修正:

  1. 台湾并入中国。 Natural Earth 把台湾作为独立要素(id 158)与中国(156)分开。不做处理的话,台湾会被画在中国领土之外——而且由于 12 个可玩国家之外都是背景,这会等于把该岛呈现为一个独立国家。src/map/geo.ts 里的 TERRITORY_MERGES 把它的多边形折进中国,因此它按中国的颜色渲染、点击也归属中国。已通过命中测试验证:岛上每个采样点都解析为 PLAYABLE:CHN。

  2. 背景不被描述为一组国家。 灰色填充标注为「其他地区」,而不是「非参战国」。游戏不对它不模拟的任何事物主张国家地位。

TERRITORY_MERGES 就是一张普通的表——加一行就能把任何其他几何并入某个可玩国家:

const TERRITORY_MERGES = [
  { territory: "158", into: "CHN" },   // 台湾 → 中国
];

Natural Earth 还把科索沃、北塞浦路斯、索马里兰、西撒哈拉、巴勒斯坦、波多黎各、福克兰群岛、格陵兰和南极洲等作为独立要素携带。这些一律保留为中性背景:图例不再声称它们是主权国家,游戏也不对它们表态。若要把其中任何一个并入某个可玩国家,在上表加一行即可。

行政区划

每个可玩国家都细分为它的一级行政区——中国的省、美国的州、俄罗斯的州、德国的联邦州等等——以细线绘制。不可玩邻国保持空白,这让十二个国家读起来就是重要的那些。

数据源是 Natural Earth 的 ne_10m_admin_1_states_provinces_lakes(公有领域)。原始文件 39 MB,构建时裁剪:

步骤 结果
源要素 4,596
保留(12 个可玩国家) 676
——去掉内环与碎块后 847 个环
——0.08° Douglas–Peucker 简化后 17,386 个点
src/map/data/admin1.json 278 KB

有两个细节值得知道:

  • 省份以原始经纬度存储,而非预投影坐标。运行时用与国界完全相同的 d3 投影来投影它们,所以两者永远不会错位。
  • 每个国家的省份都被裁切到该国自己的轮廓内(SVG clipPath)。省份是 10m 数据而国界是 110m,不裁切的话更精细的省界会溢出到海里和邻国上。裁切让这个差异不可见。

多渲染 847 条路径一度吃掉了一半的帧预算,直到把这些静态图层 memo 化——省份和背景几何从不改变,现在只渲染一次,随地图变换一起移动,而不必在每次 store 通知时重新 diff。这让 5× 速度下的帧率从 31.6 提升到 54.9 fps。

城市

省会与主要城市来自 Natural Earth 的 ne_10m_populated_places(18.5 MB,裁剪为十二个国家的 899 座城市,发布 68 KB)。它们分三个层级,随缩放逐步显现,让世界视角保持可读:

层级 内容 出现于 数量
3 首都 1.5× 12
2 主要城市——省会与地级市 2.4× 591
1 其余知名城市(30 万以上) 4.4× 296

Natural Earth 的排序字段在美国之外不可用。 对中国的数据明显是错的:淄博排在苏州前面,佛山与遵义同级,河池被记成 383 万人。POP_MAX 和 SCALERANK 都不知道遵义是地级市而兴义只是县级市——而这恰恰是中文读者判断「主要城市」的标准。所以中国城市在构建脚本里带一份显式的重要度表;其余十一国回退到制图等级加人口,表现尚可。

每座城市是一个点加旁边的名字,首都更大更亮。名字随界面语言切换(中文用 NAME_ZH)。

城市标签按文字包围盒碰撞,而不是半径。半径必须按最长的名字取,会一次性抹掉整个密集省份:7× 缩放下 40 单位的半径剔掉了遵义和六盘水,却放过了更远的兴义——完全反了。改成包围盒碰撞后,单屏能放下的标签从 322 条提升到 558 条。

文字光晕始终按字号的比例设置,绝不用固定的 stroke-width。标签的字号随缩放按 1/zoom 缩小以保持屏幕尺寸恒定,而固定的描边宽度会在某个缩放之后变得比字还宽,把文字糊成尖刺。

地图上的名字

国家标注为国旗加全名——🇨🇳 中华人民共和国,而不是 CHN。内部代码是实现细节,只有在控制台(`)输入 debug 时才会追加显示:🇨🇳 中华人民共和国 (CHN)。

由于标签现在是变宽的,它们按文字包围盒碰撞而非圆形半径——按 "United States" 取的半径会把欧洲大部分抹掉。

内嵌字体

游戏自带字体,不依赖玩家机器上装了什么。它们以 WOFF2 放在 public/fonts/,合计约 1.4 MB。

文件 用途 来源 大小
flags-emoji.woff2 国旗与界面图标 Twemoji Color Font 192 KB
body-cjk.woff2 正文(中英) Noto Sans CJK SC 1.16 MB
display.woff2 / display-bold.woff2 窄体标题与按钮 Barlow Condensed 各 18 KB
mono.woff2 数字 JetBrains Mono 26 KB

为什么国旗必须内嵌。 Windows 自带的 Segoe UI Emoji 不含国旗(出于政治原因)——所以 🇺🇸 在每一个 Windows 玩家那里都渲染成字母「US」,顶栏和国家列表里的国旗全部消失。

字体选型试错了四个,前三个都弃用了:网上流传的 seguiemj_*_mod 是微软专有字体的改版,不可再发布,而且它的中国国旗是 1912–1928 年的五色旗而非五星红旗;「Segoe UI Emoji with Twemoji Flags」国旗是对的,但底子仍是微软的字体;Noto Color Emoji 授权正当(OFL)国旗也正确,但字形是 CBDT 位图,一放大就糊。最终采用的 Twemoji Color Font 是矢量(SVG-in-OpenType)、授权正当(MIT + CC-BY 4.0),并且与地图上的国旗 SVG 用的是同一套素材。

国旗字体排在所有字体栈最前面。它不含拉丁字母和汉字,普通文字会直接落到下一个字体,只有 emoji 会被它接管。

每个字体都用 pyftsubset 子集化到游戏能打印的字符,并留了余量:CJK 字体覆盖约 4,500 字,而仓库里当前只用了 1,370 字——因为国策文件是玩家自己写的,按当前文本切分的子集会在他们打出一个新词的瞬间变成豆腐块。字符集通过扫描语言文件、国策文件以及省名/城市数据集得出。

改动中文文案后必须重建 public/fonts/body-cjk.woff2,否则新字会变豆腐块:

python3 -m venv /tmp/fontenv
/tmp/fontenv/bin/pip install fonttools brotli
/tmp/fontenv/bin/python scripts/build-body-font.py

脚本扫描语言文件、国策文件、src/game/*.ts(将领名字在里面)以及省名/城市数据集,并额外并入 GB2312 一级常用字——那 3,755 字就是留白:子集化之后没法再加字,而国策是玩家自己写的,写一个新词就出豆腐块。当前成品约 4,000 字形 / 1.3 MB。WOFF2 需要 brotli,Arch 的系统 Python 装不了(externally managed),所以上面用 venv。

观感

界面风格是1940 年代的作战室而非现代深色 UI:暖调、低饱和的黄铜与牛血红配色;窄体大写标题;带拉丝接缝的冲压金属按钮;每个面板四角的黄铜角标;顶栏的铆钉;整块面板上覆着细纸纹与灯下暗角。

地图配色也随之暖化,唯独 neutral(中立)关系色故意保留蓝灰——在一片黄铜里它一眼就能读出「不结盟」。

游戏系统

从终端版移植,模拟语义保持不变。

态度 —— 关系在 -100…100 之间,映射为四种立场:和平共进(≥50)、中规中矩(≥0)、难以协调(≥-50)、不死不休(<-50)。越过 ±75 会自动把该国加入或移出你的盟友 / 敌人列表。

战争 —— 宣战依次需要:针对目标的正当化宣战理由、至少 500K 已投入兵力的常备师、双方态度为「难以协调」或更差、民众支持 >65%、军队耐力 >79%。战斗掷骰受军力、士气和双方军队规模(上限 +15)修正。每方跟踪士气与损失;组织度归零则战争结束。

世界崩坏度 —— 全球压力表,从 8% 起步,每个事件与侵略行为都会推高它。它封顶 100% 且不再结束战役;超过 60% 后它会拖慢你每日的外交点与军队耐力恢复(100% 时只剩正常的 40%),所以燃烧的世界是慢性绞杀而非倒计时。

战役如何结束

只有一条出路:你的国家被征服。

  • 世界崩坏度不再终结任何东西——它是压力,不是计时器。
  • 战争只有在你是防守方且己方崩溃(士气归零)时,或者你在被侵略时投降,才会终结战役。你发动的战争打输了只是挫折。
  • AI 可以直接入侵你。它这么做时战争会以防御战开局,战争面板会显示红色警告条:输掉这场战争意味着亡国。

不过被征服也不是战役的终点。沦陷国会被洗劫(军队清零、经济与稳定度腰斩、核弹被缴)并移出牌桌;然后你从仍站立的国家中挑选一个继任者。世界原样延续——同一天、同样的崩坏度、同样的关系、同样的 AI 战争——只有你自己的事务重置。只要还有国家站着,你可以输掉一个又一个国家继续下去。

目录结构

src/
  main.tsx                 入口,挂载 App
  App.tsx                  阶段路由:标题 → 游戏 → 结束
  styles.css               钢铁雄心4风格主题
  game/                    模拟层
    types.ts               领域类型(含时钟、任务、师、将领)
    countries.ts           12 个国家 + 地理 id 映射
    state.ts               状态增删改、关系、崩坏度、时钟
    events.ts              24 个加权事件模板
    war.ts                 宣战、战斗结算、士气、撤退
    combat.ts              钢铁雄心4式战斗:组织度、宽度、防御池
    command.ts             集团军、将领、命令
    autoArmy.ts            参谋部 AI
    peace.ts               和平会议与领土瓜分
    ai.ts                  11 个 AI 国家的每日行动
    actions.ts             限时行动注册表(唯一的行动入口)
    scheduler.ts           时间推进与任务完成
    decisions.ts           .warf-decision 解析器与条件/奖励引擎
    attributes.ts          国策文件可用的属性命名空间
    research.ts            科技树、建筑、弹头生产
    army.ts                人力、师、征募
    commands.ts            控制台层(委托给 actions.ts)
    store.ts               响应式 store 与渲染循环
    save.ts                base64 序列化(Tauri 文件系统或 localStorage)
    worldData.ts           实时汇率与新闻关键词提取
  map/
    geo.ts                 TopoJSON → GeoJSON;d3 投影注册为 OpenLayers 的坐标参考系
    provinces.ts           行政区划数据与标注
    cities.ts              城市数据与分层
    colors.ts              地图模式配色与图例
    WorldMap.tsx           OpenLayers 地图:图层、样式、悬停、选中
  panels/                  右侧操作面板
    NationPanel / DecisionPanel / ResearchPanel / ArmyPanel
    CommandPanel / DiplomacyPanel / MilitaryPanel / EconomyPanel
    IntelligencePanel / WarPanel / LogPanel / PanelHost
  components/              TopBar、LeftRail、BottomBar、Console、
                           ConfirmDialog、Toasts、各阶段界面、参谋部异议
  i18n/index.ts            `key: value` 语言文件,zh-cn 与 en-us
  langs/                   翻译数据
decisions/                 *.warf-decision 国策文件
  templates/               可直接复制到上一层的模板(不参与加载)
docs/decisions.md          国策编写指南:字段、属性、模板与排错
src-tauri/                 Rust 外壳:窗口配置与存档读写命令

架构说明

状态。 模拟对象就地修改,靠一个版本计数器触发重渲染——终端版用的就是这个办法,对一棵深度嵌套的游戏对象树来说,它比每帧重建不可变树更合适:

useSyncExternalStore(subscribe, getSnapshot)   // store.ts
// 就地修改 → notify() → version++ → 订阅者重渲染

单一行动路径。 面板按钮和控制台都汇入 runAction(command),它执行一条命令行风格的字符串。原版命令集完整保留,所以从控制台输入 war attack 和点击「进攻」按钮做的事情完全一样。

双重持久化。 同一份产物既跑在 Tauri webview 里,也跑在普通浏览器里。save.ts 检测 window.__TAURI_INTERNALS__ 并使用 Rust 的 save_game / load_game 命令,在浏览器里退回 localStorage。

从原版数据中修复的问题

终端版的 langs/*.txt 是用朴素的 split(",") 从 langs/strings.csv 生成的,凡是英文文本里带逗号的条目都被静默破坏了。这一版按 CSV(未截断的源)修复:

  • 31 条英文被截断在句子中间 —— 例如 cmd.diplomacy.no_points 停在 …(need {cost},丢了 , have {have})。其中八条是事件描述(地震停在「Thousands dead」,网络攻击停在「A massive cyber attack」)。
  • 27 条中文里存的是英文句子片段。 字段错位把英文值的后半截推进了中文列,所以中文界面会显示 event.tech_breakthrough.desc: reshaping global economic competition.。这些已还原为真正的中文翻译。
  • 本地化国名现在真正被使用了。 语言文件里一直带着 country.<ID>.name(美国、中国、俄罗斯……),但原版到处打印英文的 country.name。现在面板 / 地图 / 事件文本都走 src/game/names.ts,中文界面显示「俄罗斯」而不是 "Russia"。

协议

GNU 通用公共许可证 v3.0(GPL-3.0) —— 见 LICENSE。

原先发布在 LGPL-3.0 下。改为 GPL-3.0 是因为加入了历史版图:那些边界来自 aourednik/historical-basemaps(GPL-3.0),它们和游戏编译在同一个二进制里、由同一张地图加载,所以整体按 GPL 分发。授权只收紧、没有放宽:自己写的代码仍可沿用原先更宽松的条款。

第三方素材(字体、地图数据、图标)各自保留其授权,署名见 NOTICE.md。特别是 Twemoji 国旗使用 CC BY 4.0,要求署名——署名就在那个文件里。

Releases

Contributors

Languages