在 Minecraft 世界里划定自己的行政区划,当作一层矢量色块叠在 Xaero 的世界地图和小地图上。
纯客户端模组:不往服务器发任何东西,也不往存档的区块数据里塞东西——区划是独立的一份矢量数据。
| 依赖 | 版本 |
|---|---|
| Minecraft | 26.2 |
| Fabric Loader | 0.19.3 以上 |
| Java | 25 |
| Fabric API | 26.2 线(0.157.0+26.2 上测过) |
| Xaero's World Map | 1.39.0 以上(世界地图叠层和导出地图要用) |
| Xaero's Minimap | 26.x 线(小地图叠层要用) |
没装 Xaero 也能进游戏,只是地图上看不到区划,编辑模式也打不开。
把 Province-0.2.2+26.2.jar 丢进 .minecraft/mods。文件名里 + 后面是这个 jar 适配的 Minecraft 版本(Fabric 模组的通用命名惯例):以后出了 26.3 的构建,下载页上会同时摆着 Province-0.2.2+26.2.jar 和 Province-0.2.2+26.3.jar,模组自己的版本号可能一样,挑跟自己的游戏版本对得上的那一个装。装错了会在启动时报错退出,不会静默跑成奇怪的行为。
打开 Xaero 的世界地图,按 P 进入编辑模式,再按一次退出。P 可以在原版「选项 → 控制 → 按键绑定」里改,分类叫「方块政区」——编辑模式只走快捷键,地图上没有入口按钮。
同一个界面里按 R 直接把区划图层整个开 / 关,不用绕去设置界面,想单看地形的时候很方便。这个键同样能在按键绑定里改。
编辑器打开后地图照常缩放拖动,左边多一列面板。整个编辑模式只认 P 这一个开关:按一次进来,再按一次出去。
面板最上面两列是工具:
| 工具 | 怎么用 |
|---|---|
| 选区笔 | 按住拖动,划到哪儿就选中哪儿 |
| 直线 | 两点之间拉一条选区(按住 Shift 走横平竖直) |
| 矩形 | 拖出一块实心矩形选区(按住 Shift 是正方形) |
| 圆形 | 拖出一块实心椭圆选区(按住 Shift 是正圆) |
| 选区擦 | 把已经选中的地方擦掉 |
| 魔棒 | 点一下,自动选中被围出来的那一整块 |
选区笔和选区擦的粗细由下面的「笔粗 N px」决定,一格一像素,2 到 48 之间。其它工具不受它影响。
画出来的选区都贴着方块(原理见「边界吸附到方块」):鼠标停在哪一格就从哪一格起笔,笔画沿着格子走阶梯,边界一定落在方块的棱上。所以斜线的边是锯齿状的,圆是一格一格拼出来的。
按住 Shift 拖,直线、矩形、圆形会吸成正形:直线只走横平竖直(往哪边拖得多就走哪边),矩形变成正方形,圆形变成正圆。拖到一半再按或者松开都行,手一松就按当前状态定下来。这一条不占快捷键,纯粹是拖拽时的修饰键。
魔棒把三样东西都当成墙:你已经选中的那一块、父级的边界、同级邻居占掉的地方。它只会选中被这些墙围起来的、包含点击点的那一小块;圈不出来就什么都不做,不会一口气把整个父级吞进来。
画出来的选区是临时的:按「保存」才落到区划上,按「取消」就丢掉。
面板里的「层级 L n」是当前在编辑第几级,L1 到 L5 可以点着切换。
- 第 1 级最上面(省、州这一类),第 5 级最细。
- 第 N 级的范围只能画在第 N-1 级的范围里面,同级之间不能重叠。
- 所以层级选错了会画不出东西——第 1 级没有父级,它只能被同级排开。
在编辑器面板里点「区划树(看结构 / 删区划)」:
- 页脚「+ 新建顶级区划」建一块第 1 级。
- 勾选框(行首)决定这一块在地图上显不显示。默认跟着设置里的「显示第几级」走:那里选的是第 2 级,就所有第 2 级都打勾、其余不打。单独改过之后以单独改的为准,直到下次再动「显示第几级」——那时整批恢复默认。刚新建的块也照这条规矩当场定。
- 小三角(勾选框右边,展开 ▼ / 收起 ▶)只在有下级的块上出现:按一下把它的下级在树状图里收起来,再按一下铺开。这只是树状图的排面,地图上一点都不变。收起的块记进设置文件,下次打开树状图还是收着的。
- 「+」在这块下面建一块下级。
- 「⚙」改名、换色、换级别。
- 「✕」删掉这一块以及它下面所有子块。
- 「≡」按住上下拖,把这一条在同级之间换个位置,连它下面的子块一起挪。只改树状图里的先后,地图上画的东西不受影响;拖到别的父区划底下是不认的。
- 点行本身 = 选中这一块,回到地图上改它的范围。
新建之后会直接弹出命名界面:中文名、英文名、级别,下面还能从 16 种染料色里挑一个代表色。
级别表里国 / 省 / 市 / 县 / 区……一百多条,从古今中外常见的建制名里挑的。表尾那一项是**「(无后缀)」**,选它就只显示区划名本身。页脚右侧有两个小按钮:
- 「+」 加一条自己的级别名。中文名必填(格子里就是靠它认的),英文名留空就跟着中文名走。
- 「-」 只能删自己加的(金色边框那些),内置的一律拒绝。已经用过被删后缀的区划不会跟着改,只是显示时退回默认的「省」。
同名建制各国都有。表里重名的项会在后面括注是哪一套,用最短的称呼:现代国家一律单字简称——「县(中)」「县(日)」「County(美)」「Powiat(波)」,瑞士和阿拉伯世界写全称;中国历代的建制(都、郡、州、道、路、府、军、厅、卫、所、里、邑、乡……)不分朝代统一写**「(古中国)」。日本那一组单独走音译**:都 To、道 Do、府 Fu、县 Ken、郡 Gun、市 Shi、区 Ku、町 Machi、村 Mura。括注只出现在级别格子、以及上面那行「级别:」里,地图上的地名照样是干干净净的「XX县」。
翻页按钮「◀ ▶」左边有一枚小小的**「中 / EN」:按一下,格子里的名字在中文和英文之间来回切,只是看着换,选中的还是同一条;英文名为空的那几条退回显示中文。英文名往往比中文长得多,格子里放不下的平时只显示开头,鼠标停上去才慢慢地往左滚**,滚完一整轮从头再来,鼠标一挪开就重新开始。括注那几条也一起滚。
编辑器面板底部两个按钮:导出当前地图只导屏幕上现在看到的那一块,导出世界全图导整张世界地图。
导出的 PNG 落在存档目录的 province/exports/ 下,导完自动打开那个文件夹;屏幕上先显示「正在导出…」再显示文件名。
导出的图跟 Xaero 自己导的一样,只是多叠了区划:填充、边框、地名全都按导出图的分辨率重画一遍。地名用的是游戏自带那套像素字,所以图里和屏幕上看到的是同一种字形。
编辑器面板里的「设置」:
- 显示:世界地图上显示区划 / 小地图上显示区划 / 显示第几级 / 显示区划名称 / 地名允许超出的宽度(滑条,−12 ~ +12)/ 名称格式 / 填充区划内部 / 填充不透明度 / 智能降低填充精度 / 智能降低边框精度 / 启用平方千格 / 启用序号标记 / 注释位置
- 自动外框:边框样式(实线 / 虚线 / 点划线)/ 边框粗细
- 导出地图:导出时包含区划
- 进入区划提示:启用提示标题 / 启用提示副标题 / 触发级别 / 提示格式 / 淡入时长 / 停留时长 / 淡出时长
「填充不透明度」默认 50%,加减按钮一次走 5%。
「启用平方千格」默认关。打开之后,面积超过 100 万平方格的区划改按平方千格显示、保留三位小数(1234.567 平方千格),不到这个数的照旧按平方格显示。树状图里的面积和编辑面板上的「选区 N」都跟着这条走。
地名只卡横向:包围盒的宽度装不下整行字就跳过这一块,交给底下的序号。到底算不算装得下,由设置里「地名允许超出的宽度」那条滑条说了算——默认 0(字比地块宽就不画),往右拖到 +12 就是允许整行字探出地块 12 个像素,往左拖到 −12 则是反过来要求地块比字还宽出一截。纵向不卡——细长的地块本来就窄,只按高度卡的话它永远没有名字。
名字是最后一遍才落笔的:所有色块和边框都铺完,再统一写字,所以先画的那块的名字不会被后画的邻居色块压住;序号标记还要再晚一层,永远在最上面。
落点是中心点必须落在区划内,然后挑一个「装得下、又离中心近」的位置:先试包围盒中心,中心摆不下(比如正好落在凹口外面)就在整块地上按网格重新找,比较时**「装得下」优先于「离中心近」**。所以形状怪的地块会把名字挪到宽敞的那一头,而不是死盯着正中间;真的哪儿都放不下,才轮到底下的序号。有飞地时每块飞地各算各的。
形状太怪的时候,名字位置可能看着别扭。 凹字形、细长的弯月、中间挖了洞、或者一圈围着一圈的地块,字可能被挤到边上、压到边框、贴在飞地角落上。落点只能在形状允许的位置里挑,遇上这种轮廓挑不出更好看的,就只好退而求其次。想要字摆中间,把形状画规整一点是最直接的办法。
默认关着。打开之后,那些名字怎么也放不下的区划不再是一片空白:图上会在它身上画一个 ①②③……,屏幕角落同时列一张对照表,写清每个序号指的是哪一块。序号就是个带圈的字,跟图上别的地名长得一模一样——不套方框,也不垫影子。
- 判断依据就是上面那套放不下的规则:一块区划的名牌一次都没画出来才轮到序号顶上(有飞地时每块飞地各算各的,只要有一块画出来了就不算)。
- 序号按区划树的顺序发,最多 20 个——序号字符只有 ①②…⑳ 这二十个,再多也就认不过来了。
- 只有落在画面里的地块才发号。 屏幕外那些看不见的块不占号,角落那张对照表也不会被它们塞满。
- 序号和对照表跟地名一样属于「画在图上的东西」:它们只跟着地图缩放走,编辑面板开不开都不影响。
- 「注释位置」选那张对照表贴哪个角(左上 / 右上 / 左下 / 右下);贴左上角时会自动躲开编辑面板。
- 它只在开着「显示区划名称」的时候才有意义:名称整个不显示的时候,给每块地都挂个序号反而是添乱。
设置是即时保存的,存在 config/mc-province/settings.nbt。自己加的级别名单独存在 config/mc-province/levels.nbt——内置的那些写在代码里,不进这个文件,所以删掉这个文件只会丢掉自己加的那几条。
设置里「显示」那一组,两个开关,默认都关。
分开是有道理的:填充的开销随面积涨,边框的开销随轮廓长度涨,想省的东西根本不是一回事。一笔画得很大但形状简单,只该关填充那个;一块轮廓碎得像海岸线但面积不大的区划,只该关边框那个。默认关着是因为精确档看着最舒服;卡了再打开,两个可以单独开。
- 填充:扫描线看情况隔行。轮廓边数越多、图缩得越小,行距拉得越开。色块照样连成一片,小比例尺下边缘略糙一点。
- 边框:把屏幕上短到看不见的边(不足 0.35 像素的那些)并进前一条,省掉采不出来的笔。一块放大到满屏的区划,边界上一半以上的线段都比这还短。
关掉边框那个开关,就是「无论比例尺多大,都精确刻画所有边界」:一条边都不并,每个屏幕像素都采一次。画面最干净,代价是轮廓一复杂就非常卡——一块碎边区的区划能吃掉几十万个落笔点。
在游戏里走进一块新的区划时,屏幕中间会弹一下它的名字(原版的标题样式),默认关着,去设置里打开。淡入 / 停留 / 淡出默认是 10 / 40 / 10 tick。
- 单人存档:
<存档目录>/province/<维度>.nbt,每个维度一个文件,文件名是minecraft_overworld.nbt/minecraft_the_nether.nbt/minecraft_the_end.nbt。 - 连服务器:
config/province/worlds/<服务器地址或 default>/<维度>.nbt。 - 导出图片:
<存档目录>/province/exports/。
数据是纯客户端的:换台机器、换个整合包都还在,但服务器上看不到,别的玩家也看不到。
旧数据会自动迁移。 0.2.0 及以前存下的边界是连续坐标(斜边、曲线),0.2.1 读存档时会把它们重新吸附到方块格线上,落完盘再写回文件(文件头里的 version 从 1 升到 2)。这个过程只对旧文件发生一次,之后就不再动了。重新长出来的形状按上面那条「中心落在里面就算」的规则算,边缘可能差个一格半格;位置、层级、名字、颜色都不变。
想把画好的区划原样搬到另一张地图,直接把 province 文件夹(或里面某个维度的 .nbt)复制过去就行。区划存的是绝对世界坐标和矢量轮廓,跟地形数据无关,文件名里也不含存档名或种子,所以搬过去之后位置、形状、名字、层级、颜色都一样。三件事要注意:
- 先退出游戏再拷。模组每 tick 会比对当前世界,一换世界就把内存里的数据写回去,运行中覆盖会被反写。
- 维度要对上。主世界的文件给主世界用。
- 换种子之后「位置相同」只是坐标相同,原地形可能已经变了。
一块区划的形状是 core/VectorShape,内部包着一个 java.awt.geom.Area:可以求交、求并、求差、判断点在里面、量面积、取包围盒、遍历成折线。
用矢量而不是踩着方块记格子,是因为地图可以缩到很小:一块省缩到几十像素宽的时候,位图会糊,矢量还是那条边。
从 0.2.1 起,形状的边界一定落在方块格线上:顶点全是整数,边只走方块的棱,不会有斜着穿过一格的线。约束由 core/BlockSnap 保证——它把任意形状转成一组方块,规则是「方块 (i, j) 属于新形状,当且仅当它的中心 (i+0.5, j+0.5) 落在原形状里」,扫描线逐行收集,上下完全一样的行并成整条矩形。
编辑侧的配套:
- 鼠标点先
Math.floor到格点,笔画只记格点,相邻两格之间补成阶梯; - 笔宽换算成方块半径(
半径 = (笔宽 - 1) / 2取整),沿笔画盖方块圆盘,最细也留得下一格; - 矩形、椭圆、魔棒选出来的面都过一遍吸附。
所以斜着拖出来的是锯齿状的方块边,圆是一格一格拼的。好处是区划边界和地形天然对齐,放大到一格一像素也不会有半格宽的缝。坐标一律是方块坐标(double),渲染时才乘缩放。
core/ProvinceStore 按 256 方块一格分桶,点查询只翻附近几格。特别大的块(覆盖超过 4096 个格子)不进桶,单独挂在全局表里——不然一块铺满半张地图的省会在每个桶里都出现。
编辑一块第 N 级的区划时,工作形状每改一次都要过一遍蒙版:
结果 = 工作形状 ∩ 父级范围 − 同级其它块
父级范围由 ProvinceStore.parentBound(parentId) 给,同级那些块由 ProvinceStore.siblingUnion(tier, selfId) 并起来。第 1 级没有父级,parentBound 返回 null,也就是不限制。
围栏有三类,缺一类就会漫出去:玩家自己画的那几笔、已经选中的那一块的外轮廓、当前层级允许占用的范围(父级边界与同级邻居)。**前两类都是现成的墙,不用先画一笔就能点。**第 1 级没有父级,旁边又有同级邻居时,会在点击处临时搭一个方框当外边界;一个邻居都没有就什么边都没有,魔棒照旧拒绝出手。
core/FenceFaces 把端点吸附到 1e-4 方块的小格子上,把共点的线段接成平面图,再找面——只有真正连起来的线才算围住了东西,差一点点接不上就不算。喂进去的线段会先按包围盒粗筛、把重合的边去重(两条重合的有向边会凑出一个两条边的假环,把整块面丢掉),两两求交是平方级的,一次点下去最多几千条边,超了按优先级截断。
围栏的墙只压在方块格线上,从不从格子中间穿过去。 已选形状、父级边界、邻居占地的轮廓都是方块拼出来的,顶点全在整数格点上,相邻两个顶点之间必然横平竖直,所以沿边界一条一条接下去就行。你自己画的那条线端点取整对齐到格子,连线保持原样不拆:拿直线工具画的对角线一拆就会正好压在另外两条线上,围出来的三角形整个瘪掉。收口边最多补 32 格,自由端太多就按顺序截断而不是一条都不补。
点下去的位置取的是这一格的中心(gx + 0.5, gz + 0.5),不是格角——墙全在整数格线上,点格角有一半概率正好点在墙上,而 Area 对边界点的判定是说不清的,选中的面往往就变成隔壁那一个。
能不走平面图就不走。 墙本来就只有现成的那几样(已选部分、同级邻居、外面那圈边界),把外边界减掉已选和邻居、再挑出包含点击点的那个连通块就够了(EditSession.quickFill)。平面图是平方级求交,一块顶到父级的大空地光轮廓就上千段,围栏一超预算被截断,面再也合不上——表现就是「点了一大片空闲地,魔棒却毫无反应」。所以近路排在前面;画过线的时候先按你画的线找面(那才圈得出墙本身围不出的形状),找不到照样退回近路兜底。围栏的段数预算也按三份轮廓加你的线分开算,谁都不会把谁挤掉。
找不到面就什么都不做,不再退化成「选中整个父级」——那正是「一点魔棒整片变色」的根源。返回值报的是选区真正变化了多少,点在已经选中的地方会如实报零。
mixin/GuiMapMixin 挂在 Xaero 的世界地图渲染上,把区划按当前地图投影画成色块和边框;同时每帧记一份相机快照,导出时用按下按钮那一帧的视角。
client/render/ProvinceRenderer 负责真正的绘制:填充走扫描线,边框走沿边细分。这是帧率花得最狠的地方,所以:
- 一块区划一帧只投影一遍轮廓给边框用,填充另投一遍(填充不能共用裁到框内那份,见下)。
- 投影时把接在一起、短到看不见的边并进前一条。一块放大到满屏的区划,很多轮廓线段还不到三分之一像素长,并掉它们等于把扫描线的活跃边砍掉一大截。
- 填充的扫描线按轮廓边数和缩放档位隔行扫:边越多、图缩得越小,行距拉得越开。色块照样连成一片,扫描线却少干好几倍的活。设置里的「智能降低填充精度」就是它的开关。
- 同一档位下,边框也按缩放把短边并得更狠;填充和边框是两种不同的开销,两个开关各管各的。
- 描边落墨时,同一行上真正相邻的连续像素攒成长条再交出去。一条八百像素的直边以前是八百次
fill,现在是一次——这是帧率改善的主要来源。合并只认严格相邻,中间隔着空白的两段绝不会被连成一根横条。 - 整块的包围盒完全在屏幕外就直接跳过;扫描线整行在可视区外时不提交任何绘制命令。
- 填充用的那份边表只裁上下、不裁左右。扫描线填充要的是「每条边和水平线的交点 x」,这个 x 落在屏幕外完全没关系,提交命令前夹回来就是;反过来,左右一起裁掉的话屏幕外的边会整条消失,交点少配一对,屏幕上反而多出一条没填到的横缝——区划一大就满屏横条。
描边在智能档下有一份总采样预算,超了就整体把采样间距放大:线会稀一点,但一圈边界始终画得全。这个放大有上限——间距超过几个像素,落墨就断成一串互不相连的点,边界看上去像虚线,甚至像整圈消失了。关掉智能档就把这份预算整个关掉,采样间距恒为 1 像素、并边阈值归零,一条边都不省。
渲染不直接依赖某一个绘制 API,而是收一个 ProvinceRenderer.Sink:
void fill(int x0, int y0, int x1, int y1, int argb);屏幕、世界地图、小地图三套 API 各包一个 lambda 就行。
小地图不能像世界地图那样盖在上面——它是一张圆形的贴图。所以 mixin/MinimapElementRendererHandlerMixin 注入 render(...),只在 location == IN_MINIMAP 时把区划画进小地图自己的 FBO 里;mixin/MinimapElementMapRendererHandlerMixin 注入 prepareRender(DDDF) 拿到这一帧的旋转、缩放和视野半径。
坐标变换和 Xaero 自己的一致:
translateX = ps * offx * zoom - pc * offz * zoom
translateY = pc * offx * zoom + ps * offz * zoom
ps / pc 是朝向的正弦余弦。
裁剪框的反投影取四个角再求包围盒,不能只取两条边——南方为上时那样算出来的 minZ 会大于 maxZ,整张图的区划就全被裁掉了。
client/MapExporter 复用 Xaero 自己的 PNGExporter:先把地图导成 PNG,再用 Java2D 把区划叠上去。导出覆盖的方块范围由 Xaero 的区块选区推出来,所以叠上去的边界和底图是对得齐的。
地名用 client/PixelFont 画。它是纯 CPU 的(java.awt,不碰 OpenGL),字形来自客户端资源包:ASCII 取 textures/font/ascii.png,其它字符取 font/unifont.zip,度量照抄原版 BitmapProvider 和 UnihexProvider。
导出是同步阻塞的(几秒),所以按下按钮那一帧只把「正在导出…」推上屏,下一帧才真开跑。
这个项目跟的是 Minecraft 26.2 + Fabric,有些 API 是最近才改过名或者挪走的,踩过一次记在这里:
- 按键分类的 lang 键是单数:
key.category.mc-province.main。写成复数key.categories.…是 1.21 以前KeyMapping构造函数的写法,26.2 只认单数,写错了界面上会直接显示原始键名。 Screen上没有hasShiftDown()了。要判断修饰键就直接问 GLFW:InputConstants.isKeyDown(Minecraft.getInstance().getWindow(), InputConstants.KEY_LSHIFT),左右 Shift 各判一次。ResourceKey取标识用identifier(),不再是location()。EditSession.onRelease()是无参的,早期版本带一个坐标参数。VectorShape自带contains(double, double),判断点是否落在形状里不用先取Area。CompoundTag没有getIntArrayOr(String, int[])。要往 NBT 里存一组 int,用逗号分隔的String最省事(collapsed就是这么存的)。- 改版本号后要删
build/resources,否则processResources的 expand 不重跑,产物名和fabric.mod.json里的版本会悄悄对不上。 - 别用
Get-Process java | Stop-Process -Force收尾。Gradle 守护进程也是 java,一起杀掉之后下一次构建要重新起,特别慢。只杀命令行里带devlaunch的那几个游戏进程。
改完代码想快速验一遍,用环境变量打开自检:
# Windows
set MCPROVINCE_SELFTEST=1
gradlew.bat runClient
# Linux / macOS
MCPROVINCE_SELFTEST=1 ./gradlew runClient启动后会跑一批不依赖资源包的断言,进世界之后再跑一批依赖资源包的(比如像素字和导出),结果打在日志里。
再加 MCPROVINCE_AUTOWORLD=1 会自动开一个测试世界、造几块区划、截图、退出。
# Windows
gradlew.bat build
# Linux / macOS
./gradlew build产物是 build/libs/Province-0.2.2+26.2.jar。
版本号与 MC 版本都在 gradle.properties 里:mod_version 是模组自己的版本,minecraft_version 是适配的游戏版本,build.gradle 只负责把它们拼成 Province-<mod_version>+<minecraft_version>.jar。要出别的游戏版本的构建,改 minecraft_version(以及 fabric_api_version 那条对应的线)重编即可,产物名会自动跟着变。
src/main/java/com/mcprovince/
McProvince.java 入口
ProvinceSelfTest.java 自检
core/ 形状、吸附、区划、层级名、面积格式、空间索引(不依赖客户端)
data/ NBT 读写与世界隔离
edit/ 编辑会话、工具、历史、魔棒用的平面图
client/ 快捷键、编辑状态、设置、导出、像素字
client/gui/ 区划树、命名、新增级别名、设置四个界面,外加共用的 Ui 与 ScrollViewport
client/render/ 世界地图叠层、小地图叠层、绘制器、投影
mixin/ Xaero 世界地图与小地图的注入点
src/main/resources/
fabric.mod.json
mc-province.mixins.json
mc-province.minimap.mixins.json
assets/mc-province/
icon.png 模组图标(模组菜单里显示的那一张)
lang/ 中英文按键与提示文案