11 次代码提交
作者 SHA1 备注 提交日期
lyizai a0963747b6 v1.2.4: 寻路重试耗尽先往边上走两步再重试原目标,仍失败才回城或切换目标 2026-08-21 16:25:00 +08:00
lyizai 08e15ffc9b v1.2.4: 连续寻路超时超过1分钟自动发送/u return回城,传送后从最高优先级区块重新扩地 2026-08-21 16:10:02 +08:00
lyizai a9c71861f5 v1.2.3: 更新README安装/构建的jar版本引用至1.2.3 2026-08-21 16:02:10 +08:00
lyizai 5193b2e379 v1.2.3: 不在开拓时段点击开始改为登记等待,进入11:00-22:00后自动启动;等待期间可手动取消 2026-08-20 08:55:09 +08:00
lyizai 2309f01291 v1.2.2: 根治寻路秒CALC_FAILED——删除自定义ChunkGoal(含高度isInGoal),改用Baritone原生GoalXZ到区块中心 2026-08-19 21:33:46 +08:00
lyizai 3cddee2abb v1.2.2: 一步之隔目标误判不可达——ChunkGoal.isInGoal回归纯二维(高度约束改为启发式软引导),根治边缘落点被秒判不可达 2026-08-19 21:21:46 +08:00
lyizai 28fe848fb6 v1.2.2: 目标地表高度改取中位数(避免高树孤峰拉高导致一步之隔误判不可达);CALC_FAILED改为先重试再切目标 2026-08-19 21:16:45 +08:00
lyizai 340fb2ddf1 v1.2.2: 寻路不再带玩家下地下矿坑——ChunkGoal增加高度约束,目标为该区块内经排除树叶的地表最高处,停在矿坑不算到达,不搭方块到树上 2026-08-19 21:05:14 +08:00
lyizai fc14e51894 v1.2.2: 修复开启放置权限时搭路原地抽搐放不出方块(配套allowParkourPlace+归零blockPlacementPenalty+允许跳跃搭路) 2026-08-19 20:52:52 +08:00
lyizai 00aa45a551 v1.2.1(重发): 修复刷新误标已占领(缓存击穿+宽限期60s+防误判)、修复GUI/刷新卡顿与内存(视口过滤/后台预计算/零复制校验/内容去重/ChunkPos驻留)、区域扩地重写为严格S形由内而外(起始边离王城最近) 2026-08-19 17:09:20 +08:00
lyizai c55e4eaf8b v1.2.1: 修复飞地检测——按 dynmap even-odd 规则解析领土多边形,内圈孔洞(国中飞地边界)不再被并集填充掩盖 2026-08-19 13:09:42 +08:00
共修改 11 个文件,包含 476 行新增和 251 行删除
+7 -3
查看文件
@@ -8,6 +8,7 @@
- 四种扩地策略:方形外扩、区域扩地、玩家周围、绕国一圈
- 区域扩地采用 S 形扫描:从与己方领土接壤的区块开始,横向/纵向 S 形扫过整个矩形,不沿邦国边界,但保证每个新占领区块都与**己方领土**接壤(仅四个正方向,不把其他邦国领土计入接壤)
- 自动寻路(Baritone)前往目标区块;监听 Baritone 路径事件,目标不可达(CALC_FAILED,如被悬崖/高山/水体隔断)时立即跳过;寻路超时则重新寻路(不立即回城),重试耗尽才跳过当前目标
- 连续寻路超时回城:连续寻路超时累计超过 1 分钟(可配置)时自动发送 `/u return`,约 10 秒后传送到可寻路位置,随后重新规划并从最高优先级区块重新开始扩地
- 运行时接壤校验:寻路/等待阶段每帧校验目标是否仍与己方领土接壤,地图刷新导致边界变化时立即跳过
- 目标有效性校验:目标若已被其他邦国占领则立即跳过
- 现实时间限制:仅 11:00-22:00(本地现实时间)允许开拓,其余时段启动会被拒绝、运行中到点自动停止
@@ -15,13 +16,16 @@
- 实时地图 GUI(通过 Ctrl+R 打开)
- 监听聊天/字幕消息判断开垦结果
- 刷新地图时与最新 `markers.json` 核对已占领区块,丢弃地图未确认的超期假占领,避免高地扩地残留空洞
- 飞地(疏漏区块)检测:自动扫描每个邦国内外接矩形内"四周被同邦国占领但自身未占领"的区块,内置地图用红色标出己方飞地,自动扩地时优先填补这些缺口
- 刷新请求带缓存击穿(时间戳参数 + no-cache 头),修复约半数刷新命中网络缓存拿到旧数据、导致目标被误标"已占领"且长时间不修正的问题;占领成功后立即触发刷新确认,假占领确认宽限期缩短至 60 秒
- 飞地(疏漏区块)检测:按 dynmap 奇偶填充规则解析领土多边形,正确识别国中由边界围出的内圈空腔;内置地图用红色标出己方飞地,自动扩地时优先填补这些缺口
- 区域扩地严格 S 形(蛇形)扫描:起始边为矩形四条边中离王城最近的一条,由内而外推进;即使待开垦区块构成不规则多边形也按 S 形一路扫过去
- 性能优化:地图渲染按视口快速过滤(不再每帧全量遍历 8 万区块)、飞地检测移至后台线程预计算、接壤校验零集合复制、响应内容未变化时跳过重复解析、ChunkPos 跨模型驻留复用——修复打开 GUI 与刷新地图时游戏卡顿、内存占用偏高的问题
- 日志导出:GUI 右上角“导出日志”按钮,或 `/autostore log` 命令,记录寻路、坐标、状态、路径事件等
## 安装
1. 安装 Fabric Loader 0.16.14+ 和 Fabric API
2. 将 `autostore-1.2.0.jar` 放入 `.minecraft/mods/` 目录
2. 将 `autostore-1.2.4.jar` 放入 `.minecraft/mods/` 目录
3. 启动游戏,按 `Ctrl+R` 打开 GUI
## 构建
@@ -31,7 +35,7 @@
./build_mod.sh
```
输出文件:`autostore-1.2.0.jar`
输出文件:`autostore-1.2.4.jar`
## 赞助支持
+1 -1
查看文件
@@ -7,7 +7,7 @@ yarn_mappings=1.21.4+build.8
loader_version=0.19.3
# Mod Properties
mod_version=1.2.0
mod_version=1.2.4
maven_group=autostore
archives_base_name=autostore
@@ -11,6 +11,7 @@ import baritone.api.event.events.PathEvent;
import baritone.api.event.listener.AbstractGameEventListener;
import autostore.client.config.ExpansionConfig;
import autostore.client.territory.ChunkPos;
import baritone.api.pathing.goals.GoalXZ;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import net.fabricmc.api.EnvType;
@@ -55,12 +56,27 @@ public final class BaritoneBridge {
settings.allowSprint.value = config.allowSprint;
settings.allowBreak.value = config.allowBreak;
settings.allowPlace.value = config.allowPlace;
settings.allowParkour.value = config.allowJump;
// 跳跃(跑酷)前进:开启放置时也允许跳跃搭路,避免过河/过缝时必须纯步行
settings.allowParkour.value = config.allowJump || config.allowPlace;
// 关键修复:开启放置时,配套允许“跑酷搭路放方块”。
// 若仅开 allowPlace 而不给 allowParkourPlace,Baritone 在边缘会把
// “下蹲放方块”与“跳跃过去”两条腿计划打架:想放又判定不该放,
// 于是原地反复下蹲+跳跃却始终放不出方块(用户观察到的“原地抽搐”)。
settings.allowParkourPlace.value = config.allowPlace;
// 降低放方块的路径代价:Baritone 默认对搭路惩罚较高,会倾向犹豫/绕行;
// 归零后它更愿意稳定搭路,而不是在缺口边反复空跳。
settings.blockPlacementPenalty.value = 0.0;
settings.avoidance.value = config.avoidMobs;
}
/**
* 让 Baritone 寻路前往指定区块(以“到达区块内”为完成条件)。
* 让 Baritone 寻路前往指定区块中心。
*
* <p>使用 Baritone <b>原生</b>{@link GoalXZ}(目标区块中心单格):原生 GoalXZ 对
* 可达地形几乎不会误判“目标不可达”,是最好的默认选择。早期用自定义
* {@code ChunkGoal}(曾加入高度硬约束)疑似是“平原上一按 W 就能走、Baritone 却秒
* CALC_FAILED”的根因——自定义 Goal 在某些站位/边界下会把可达目标判成不可达。
* 到达判定仍由控制器 {@code playerChunk.equals(target)} 保证,与 Goal 自身容差无关。</p>
*
* @param chunk 目标区块
*/
@@ -69,11 +85,7 @@ public final class BaritoneBridge {
if (baritone == null) {
return;
}
// 使用自定义 ChunkGoal:完成条件为“玩家脚下方块落在目标区块内”,
// 与控制器 playerChunk.equals(target) 的判定一致。避免 Baritone 自带
// GoalXZ(容差 0,精确到区块中心单格)在中心不可达时停在相邻区块,
// 造成目标区块错位与反复寻路。
baritone.getCustomGoalProcess().setGoalAndPath(new ChunkGoal(chunk));
baritone.getCustomGoalProcess().setGoalAndPath(new GoalXZ(chunk.centerBlockX(), chunk.centerBlockZ()));
}
/** 强制取消当前寻路任务。 */
@@ -1,52 +0,0 @@
/*
* 本文件为 AutoStore 模组自定义的 Baritone 寻路目标。
*/
package autostore.client.bridge;
import autostore.client.territory.ChunkPos;
import baritone.api.pathing.goals.Goal;
/**
* 以“到达目标区块内”为完成条件的寻路目标。
*
* <p>修复“目标区块错位”问题:Baritone 自带的 {@code GoalXZ} 容差为 0,
* 会要求玩家精确站在区块中心的单个方块上(如 {@code x*16+8, z*16+8})。
* 一旦该方块不可达(被树、石头、水、建筑等占据),玩家会被停在区块边缘甚至
* 相邻区块,导致玩家所在区块与目标区块错位、反复寻路。</p>
*
* <p>本目标把完成条件放宽为“玩家脚下方块落在目标区块内”,与控制器中
* {@code playerChunk.equals(target)} 的判定完全一致,从而彻底消除
* 中心点不可达带来的错位。</p>
*/
public final class ChunkGoal implements Goal {
/** 目标区块 X 坐标 */
private final int targetX;
/** 目标区块 Z 坐标 */
private final int targetZ;
/** 目标区块中心的方块 X 坐标(用于启发式距离估算) */
private final int centerX;
/** 目标区块中心的方块 Z 坐标(用于启发式距离估算) */
private final int centerZ;
public ChunkGoal(ChunkPos chunk) {
this.targetX = chunk.x();
this.targetZ = chunk.z();
this.centerX = chunk.centerBlockX();
this.centerZ = chunk.centerBlockZ();
}
/** 判断玩家脚下方块是否落在目标区块内 */
@Override
public boolean isInGoal(int x, int y, int z) {
return Math.floorDiv(x, 16) == this.targetX && Math.floorDiv(z, 16) == this.targetZ;
}
/** 启发式:到目标区块中心的水平欧几里得距离(可采纳,保证寻路效率) */
@Override
public double heuristic(int x, int y, int z) {
double dx = x - this.centerX;
double dz = z - this.centerZ;
return Math.sqrt(dx * dx + dz * dz);
}
}
@@ -31,6 +31,12 @@ public class ExpansionConfig {
/** 超时后的最大重试次数 */
public int retries = 2;
/** 连续寻路超时累计达到该秒数后,发送 /u return 回城传送并重新开始(默认 60 秒) */
public int returnThresholdSeconds = 60;
/** 发送 /u return 后等待传送完成的时间(秒);默认 12 秒,覆盖 10 秒传送延迟并留余量 */
public int returnWaitSeconds = 12;
/** 区域扩地策略的中心 X 坐标(区块) */
public int regionCenterX;
@@ -94,6 +100,8 @@ public class ExpansionConfig {
c.targetCount = this.targetCount;
c.waitTimeoutSeconds = this.waitTimeoutSeconds;
c.retries = this.retries;
c.returnThresholdSeconds = this.returnThresholdSeconds;
c.returnWaitSeconds = this.returnWaitSeconds;
c.regionCenterX = this.regionCenterX;
c.regionCenterZ = this.regionCenterZ;
c.regionRadius = this.regionRadius;
@@ -33,6 +33,7 @@ import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.entity.player.PlayerInventory;
import net.minecraft.item.ItemStack;
import net.minecraft.util.math.BlockPos;
import net.minecraft.util.math.Vec3d;
import net.minecraft.client.MinecraftClient;
import net.minecraft.component.DataComponentTypes;
import net.minecraft.text.Text;
@@ -93,6 +94,24 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
private boolean eating;
/** Baritone 最近一次路径计算是否失败(目标不可达);由事件监听设置,寻路阶段消费 */
private volatile boolean pathCalcFailed;
/** 等待开拓时段自动开始:不在 11:00-22:00 时点击开始会暂存配置,进入时段后自动启动(null 表示无等待任务) */
private ExpansionConfig pendingStart;
/** 连续寻路超时累计时长(毫秒):多次寻路超时逐次累加,达到阈值触发 /u return 回城传送 */
private long consecutiveTimeoutMillis;
/** 发送 /u return 回城指令的时间戳(RETURNING 状态使用,用于等待传送完成) */
private long returnStartMillis;
/** 当前目标是否已使用过"往边上走两步"策略(重试耗尽时先用它,避免重复;再次耗尽才切换目标) */
private boolean sideStepAttempted;
/** 是否正在"往边上走两步"阶段(手动向目标连线垂直方向走开约 2.5 格再重试) */
private boolean sideStepping;
/** 边走两步的方向符号(+1 左侧 / -1 右侧),被挡时换另一侧 */
private int sideStepSign = 1;
/** 边走两步的整体超时截止时间戳(毫秒) */
private long sideStepDeadlineMillis;
/** 最近一次换向/开始的时间戳(毫秒,用于判断该方向是否卡住) */
private long sideStepSinceMillis;
/** 边走两步开始时的玩家方块坐标(用于判断是否已走开约 2.5 格) */
private BlockPos sideStepStartPos;
public ExpansionController(TerritoryDataService dataService) {
this.dataService = dataService;
@@ -140,6 +159,11 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
return this.state != State.IDLE && this.state != State.COMPLETE && this.state != State.STOPPED;
}
/** @return 是否处于"等待开拓时段自动开始"状态 */
public boolean isWaitingForClaimTime() {
return this.pendingStart != null;
}
/**
* 判断当前现实时间(本地时区)是否在允许开拓的时间段内。
* 仅 11:00(含)到 22:00(不含)允许开拓,其余时段服务器会拒绝开拓。
@@ -171,14 +195,15 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
this.status = "Baritone \u5c1a\u672a\u521d\u59cb\u5316\uff0c\u8bf7\u7a0d\u540e\u518d\u8bd5";
return false;
}
// 现实时间限制:仅 11:00-22:00 允许开拓,其余时段服务器会拒绝开拓(等待超时)
// 现实时间限制:仅 11:00-22:00 允许开拓,其余时段服务器会拒绝开拓(等待超时)。
// 不在时段时不再直接拒绝,而是登记"等待自动开始":进入时段后由 tick() 自动启动。
if (!ExpansionController.isWithinClaimTime()) {
this.status = "\u4e0d\u5728\u5f00\u62d3\u65f6\u9650\u5185\uff0811:00-22:00\uff09\uff0c\u65e0\u6cd5\u5f00\u62d3";
this.setState(State.IDLE, this.status);
this.log("\u542f\u52a8\u5931\u8d25\uff1a\u4e0d\u5728\u5f00\u62d3\u65f6\u9650\u5185\uff0c\u5f53\u524d\u73b0\u5b9e\u65f6\u95f4 " + LocalTime.now().format(LOG_TIME_FORMAT));
this.pendingStart = requestedConfig.copy();
this.setState(State.IDLE, "\u7b49\u5f85\u5f00\u62d3\u65f6\u6bb5\uff0c11:00 \u81ea\u52a8\u5f00\u59cb");
this.log("\u4e0d\u5728\u5f00\u62d3\u65f6\u6bb5\u5185\uff0811:00-22:00\uff09\uff0c\u5df2\u767b\u8bb0\u81ea\u52a8\u5f00\u59cb\uff0c\u5f53\u524d\u73b0\u5b9e\u65f6\u95f4 " + LocalTime.now().format(LOG_TIME_FORMAT));
// 给玩家发送聊天栏提示,避免玩家未打开 GUI 时看不到原因
client.player.sendMessage(Text.literal("[AutoStore] \u4e0d\u5728\u5f00\u62d3\u65f6\u9650\u5185\uff0811:00-22:00\uff09\uff0c\u65e0\u6cd5\u5f00\u62d3"), false);
return false;
client.player.sendMessage(Text.literal("[AutoStore] \u4e0d\u5728\u5f00\u62d3\u65f6\u6bb5\u5185\uff0811:00-22:00\uff09\uff0c\u5df2\u767b\u8bb0\uff1a\u8fdb\u5165\u65f6\u6bb5\u540e\u81ea\u52a8\u5f00\u59cb"), false);
return true;
}
this.config = requestedConfig.copy();
this.model = this.dataService.model();
@@ -203,6 +228,9 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
}
this.completedClaims = 0;
this.retryCount = 0;
this.consecutiveTimeoutMillis = 0L;
this.sideStepAttempted = false;
this.sideStepping = false;
this.queue.clear();
this.claimedSet.clear();
this.target = null;
@@ -214,12 +242,22 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
return true;
}
/** 手动停止任务:取消 Baritone 寻路并进入 STOPPED 状态 */
/** 手动停止任务:取消 Baritone 寻路并进入 STOPPED 状态;若处于等待自动开始,则取消等待 */
public void stop() {
if (this.pendingStart != null) {
this.pendingStart = null;
this.setState(State.IDLE, "\u5df2\u53d6\u6d88\u81ea\u52a8\u5f00\u59cb");
this.log("\u5df2\u53d6\u6d88\u7b49\u5f85\u81ea\u52a8\u5f00\u59cb");
return;
}
if (this.state == State.IDLE || this.state == State.COMPLETE || this.state == State.STOPPED) {
return;
}
this.baritoneBridge.cancel();
// 若正处于"往边上走两步"阶段,先释放手动按下的前进键
if (this.sideStepping) {
this.stopSideStep(false);
}
this.eating = false;
this.setState(State.STOPPED, "\u5df2\u624b\u52a8\u505c\u6b62");
this.log("\u5df2\u624b\u52a8\u505c\u6b62");
@@ -240,6 +278,13 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
return;
}
this.playerChunk = this.playerChunk(client);
// 等待开拓时段自动开始:进入 11:00-22:00 后自动启动已登记的任务
if (this.pendingStart != null && ExpansionController.isWithinClaimTime()) {
ExpansionConfig pending = this.pendingStart;
this.pendingStart = null;
this.log("\u5df2\u8fdb\u5165\u5f00\u62d3\u65f6\u6bb5\uff0c\u81ea\u52a8\u5f00\u59cb");
this.start(pending);
}
// 现实时间限制:运行中超出 11:00-22:00 则自动停止,避免服务器拒绝开拓导致超时
if (this.isRunning() && !ExpansionController.isWithinClaimTime()) {
this.stop();
@@ -252,6 +297,11 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
return;
}
this.tickAutoSwim(client);
// 处于"往边上走两步"阶段:每帧驱动手动横向行走,走开后自动重试原目标
if (this.sideStepping) {
this.tickSideStep();
return;
}
switch (this.state.ordinal()) {
case 1: {
this.plan();
@@ -277,6 +327,10 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
this.tickNext();
break;
}
case 7: {
this.tickReturning();
break;
}
}
}
@@ -316,6 +370,7 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
/** 让 Baritone 寻路前往当前目标区块,并进入 PATHING 状态 */
private void startPathing() {
this.retryCount = 0;
this.sideStepAttempted = false;
this.stageStartedMillis = System.currentTimeMillis();
this.lastProgress = 0;
this.baritoneBridge.pathTo(this.target);
@@ -325,7 +380,7 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
/**
* 寻路阶段每帧逻辑:到达目标区块后进入等待开垦状态;
* 若寻路超时则重新寻路(而非回城),超过重试次数则跳过该目标。
* 若寻路超时则重新寻路;重试耗尽先"往边上走两步"再重试,仍失败则回城(连续超时达阈值)或跳过该目标。
*/
private void tickPathing() {
if (this.target == null) {
@@ -336,11 +391,24 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
if (this.skipIfTargetClaimed("\u76ee\u6807\u5df2\u88ab\u5360\u9886")) {
return;
}
// Baritone 报告路径计算失败(目标不可达,如被悬崖/高山/水体完全隔断):立即跳过,不再傻等超时
// Baritone 报告路径计算失败(目标不可达,如被悬崖/高山/水体完全隔断)。
// 不立即跳过:CALC_FAILED 常因瞬时能力限制或起点卡位误报,先重新寻路一次
//(相当于“往边上走两步再算一遍”),重试耗尽才跳过当前目标。
if (this.pathCalcFailed) {
this.pathCalcFailed = false;
this.log("\u5bfb\u8def\u5931\u8d25\uff08\u76ee\u6807\u4e0d\u53ef\u8fbe\uff09\uff0c\u8df3\u8fc7 " + this.target.x() + "," + this.target.z());
this.setState(State.FAIL, "\u5bfb\u8def\u5931\u8d25");
++this.retryCount;
if (this.retryCount >= this.config.retries) {
// 重试耗尽:不直接切换目标,先"往边上走两步"再重试一次;仍失败才交给回城/切换处理
if (!this.sideStepAttempted) {
this.startSideStep();
} else {
this.giveUpTarget("\u5bfb\u8def\u5931\u8d25\uff08\u76ee\u6807\u4e0d\u53ef\u8fbe\uff09");
}
} else {
this.log("\u5bfb\u8def\u5931\u8d25\uff08\u76ee\u6807\u4e0d\u53ef\u8fbe\uff09\uff0c\u91cd\u65b0\u5bfb\u8def " + this.retryCount + "/" + this.config.retries);
this.dataService.refreshAsync();
this.retrySameTarget("\u5bfb\u8def\u5931\u8d25\uff0c\u91cd\u65b0\u5bfb\u8def");
}
return;
}
// 目标已不再与己方领土接壤(地图刷新后边界变化):立即跳过,避免走到无效区块
@@ -349,6 +417,8 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
}
if (this.playerChunk.equals(this.target)) {
this.baritoneBridge.cancel();
// 成功到达目标:寻路不再卡死,清零连续超时计数
this.consecutiveTimeoutMillis = 0L;
this.stageStartedMillis = System.currentTimeMillis();
this.setState(State.WAITING, "\u5df2\u5230\u8fbe\uff0c\u7b49\u5f85\u9886\u5730\u5f00\u62d3");
this.log("\u5df2\u5230\u8fbe\u76ee\u6807 " + this.target.x() + "," + this.target.z() + "\uff0c\u5f00\u59cb\u7b49\u5f85\u5f00\u57a6");
@@ -359,18 +429,147 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
++this.retryCount;
// 主动刷新地图,尽快同步最新领土状态
this.dataService.refreshAsync();
if (this.retryCount >= this.config.retries) {
// 重试耗尽:跳过当前目标(不再回城),交给下一个目标
this.log("\u5bfb\u8def\u8d85\u65f6\uff0c\u8df3\u8fc7 " + this.target.x() + "," + this.target.z());
this.setState(State.FAIL, "\u5bfb\u8def\u8d85\u65f6");
} else {
// 重新寻路(而非回城):取消旧路径并重新计算,往往“在边上走走”就能走出困境
// 累计"连续寻路超时"时长:连续多次寻路都超时说明玩家被地形/缝隙卡死走不到目标,
// 达到阈值后作为发送 /u return 回城(约 10 秒后传送到可寻路位置)的最后手段,平时不打断重试/走两步。
this.consecutiveTimeoutMillis += (long)this.config.waitTimeoutSeconds * 1000L;
if (this.retryCount < this.config.retries) {
// 未耗尽:重新寻路(而非回城),取消旧路径并重新计算,往往"在边上走走"就能走出困境
this.log("\u5bfb\u8def\u8d85\u65f6\uff0c\u91cd\u65b0\u5bfb\u8def " + this.retryCount + "/" + this.config.retries);
this.retrySameTarget("\u5bfb\u8def\u8d85\u65f6\uff0c\u91cd\u65b0\u5bfb\u8def");
} else if (!this.sideStepAttempted) {
// 重试耗尽:先不切换目标,用"往边上走两步"再重试一次(走开障碍后重新寻路原目标)
this.startSideStep();
} else {
// 走两步也未能脱困:连续超时达到阈值则回城传送重新开始,否则切换目标
this.giveUpTarget("\u5bfb\u8def\u8d85\u65f6");
}
}
}
/**
* 连续寻路超时达到阈值时触发回城:
* 取消当前寻路,向服务器发送 /u return 指令(约 10 秒后自动传送回可寻路的安全位置),
* 随后进入 RETURNING 状态等待传送完成,传送后由 {@link #tickReturning()} 重新规划并从头开始。
*/
private void sendReturnCommand() {
this.baritoneBridge.cancel();
MinecraftClient client = MinecraftClient.getInstance();
if (client != null && client.player != null) {
this.log("\u8fde\u7eed\u5bfb\u8def\u8d85\u65f6\u8d85\u8fc7 " + this.config.returnThresholdSeconds + " \u79d2\uff0c\u53d1\u9001 /u return \u56de\u57ce\uff0c10 \u79d2\u540e\u81ea\u52a8\u4f20\u9001");
// 通过玩家网络处理器发送聊天指令 /u return(Yarn 映射:sendChatCommand 自动带上前缀斜杠)
client.player.networkHandler.sendChatCommand("u return");
client.player.sendMessage(Text.literal("[AutoStore] \u8fde\u7eed\u5bfb\u8def\u8d85\u65f6\uff0c\u5df2\u53d1\u9001 /u return\uff0c\u7b49\u5f85\u4f20\u9001\u540e\u91cd\u65b0\u6269\u5730"), false);
} else {
this.log("\u8fde\u7eed\u5bfb\u8def\u8d85\u65f6\uff0c\u4f46\u73a9\u5bb6\u4e0d\u5728\u4e16\u754c\uff0c\u8df3\u8fc7");
}
this.returnStartMillis = System.currentTimeMillis();
this.setState(State.RETURNING, "\u7b49\u5f85\u56de\u57ce\u4f20\u9001\uff0810 \u79d2\u540e\u81ea\u52a8\u4f20\u9001\uff09");
}
/**
* 回城传送等待阶段每帧逻辑:
* 发送 /u return 后服务器约 10 秒把玩家传送回安全位置。等待 {@code returnWaitSeconds}
* 秒(覆盖传送延迟并留余量)后,清零连续超时计数并重新规划扩地计划,
* 从最高优先级区块(队首)重新开始寻路。
*/
private void tickReturning() {
long elapsedMillis = System.currentTimeMillis() - this.returnStartMillis;
if (elapsedMillis < (long)this.config.returnWaitSeconds * 1000L) {
return;
}
this.consecutiveTimeoutMillis = 0L;
this.log("\u56de\u57ce\u4f20\u9001\u5b8c\u6210\uff0c\u91cd\u65b0\u89c4\u5212\u5e76\u4ece\u6700\u9ad8\u4f18\u5148\u7ea7\u533a\u5757\u5f00\u59cb");
// 重新规划:清空队列重新生成计划,从队首(最高优先级)区块开始寻路
this.plan();
}
/**
* 目标寻路重试耗尽时的脱困策略入口:
* 取消当前寻路,改为手动朝"目标连线垂直方向"走开约 2.5 格(左右各试一侧),
* 走开后自动重新寻路原目标;走不开/超时则交给 {@link #giveUpTarget} 回城或切换目标。
* 每个目标最多触发一次(sideStepAttempted 标志在 startPathing 时重置)。
*/
private void startSideStep() {
this.baritoneBridge.cancel();
MinecraftClient client = MinecraftClient.getInstance();
this.sideStepping = true;
this.sideStepAttempted = true;
this.sideStepSign = 1; // 默认先朝目标连线左侧走
this.sideStepStartPos = client != null && client.player != null ? client.player.getBlockPos() : null;
this.sideStepDeadlineMillis = System.currentTimeMillis() + 10000L; // 整体最多走 10 秒
this.sideStepSinceMillis = System.currentTimeMillis();
this.log("\u5bfb\u8def\u91cd\u8bd5\u8017\u5c3d\uff0c\u5f00\u59cb\u5f80\u8fb9\u4e0a\u8d70\u4e24\u6b65\u518d\u91cd\u8bd5\u539f\u76ee\u6807");
this.setState(State.PATHING, "\u5f80\u8fb9\u4e0a\u8d70\u4e24\u6b65...");
}
/** 走两步阶段每帧逻辑:朝目标连线垂直方向按住前进键行走,走开约 2.5 格后重试原目标 */
private void tickSideStep() {
MinecraftClient client = MinecraftClient.getInstance();
if (client == null || client.player == null || this.sideStepStartPos == null || this.target == null) {
this.stopSideStep(false);
return;
}
// 整体超时:往边上走两步也未能脱困
if (System.currentTimeMillis() >= this.sideStepDeadlineMillis) {
this.stopSideStep(false);
this.giveUpTarget("\u5f80\u8fb9\u4e0a\u8d70\u4e24\u6b65\u8d85\u65f6");
return;
}
// 目标连线方向(水平),取垂直向量作为横向移动方向(左为 +,右为 -)
Vec3d playerPos = client.player.getPos();
Vec3d dirToTarget = new Vec3d(this.target.centerBlockX() + 0.5, playerPos.y, this.target.centerBlockZ() + 0.5)
.subtract(playerPos).normalize();
Vec3d sideDir = new Vec3d(-dirToTarget.z, 0.0, dirToTarget.x).multiply(this.sideStepSign);
// 面朝横向移动方向并按住前进键
float yaw = (float)Math.toDegrees(Math.atan2(sideDir.z, sideDir.x)) - 90.0f;
client.player.setYaw(yaw);
client.options.forwardKey.setPressed(true);
// 已走开足够距离(约 2.5 格):停止并重试原目标
if (client.player.getBlockPos().getSquaredDistance(this.sideStepStartPos) >= 6.25) {
this.stopSideStep(true);
this.retrySameTarget("\u5f80\u8fb9\u4e0a\u8d70\u4e24\u6b65\u5b8c\u6210\uff0c\u91cd\u65b0\u5bfb\u8def");
return;
}
// 卡住检查:同一方向 5 秒没移动就换另一侧;两侧都试过仍不动则放弃
if (System.currentTimeMillis() - this.sideStepSinceMillis > 5000L) {
if (this.sideStepSign != -1) {
this.sideStepSign = -1;
this.sideStepSinceMillis = System.currentTimeMillis();
this.log("\u5f80\u8fb9\u4e0a\u8d70\u88ab\u6321\uff0c\u53d6\u53cd\u65b9\u5411\u7ee7\u7eed");
} else {
this.stopSideStep(false);
this.giveUpTarget("\u5f80\u8fb9\u4e0a\u8d70\u4e24\u4fa7\u5747\u88ab\u6321");
}
}
}
/** 结束走两步阶段:释放手动按下的前进键;success 表示已走开并准备重试原目标 */
private void stopSideStep(boolean success) {
this.sideStepping = false;
MinecraftClient client = MinecraftClient.getInstance();
if (client != null && client.options != null) {
client.options.forwardKey.setPressed(false);
}
}
/**
* 放弃当前目标(重试与走两步均已尝试)的统一出口:
* 若连续寻路超时累计已达到阈值,则发送 /u return 回城传送并重新规划;
* 否则记录日志并进入 FAIL 状态切换下一个目标。
*/
private void giveUpTarget(String reason) {
if (this.consecutiveTimeoutMillis >= (long)this.config.returnThresholdSeconds * 1000L) {
this.sendReturnCommand();
return;
}
if (this.target != null) {
this.log(reason + "\uff0c\u8df3\u8fc7 " + this.target.x() + "," + this.target.z());
} else {
this.log(reason);
}
this.setState(State.FAIL, reason);
}
/** 等待开垦阶段每帧逻辑:玩家离开目标区块则重新寻路;等待超时则重试或跳过当前目标 */
private void tickWaiting() {
if (this.target == null) {
@@ -451,22 +650,27 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
/**
* 判断区块是否与己方领土(己方邦国地图区块 + 本次会话新占领区块)接壤。
*
* <p>接壤采用四个正方向(上下左右);只对“己方”领土判断,绝不把其他邦国领土计入。</p>
* <p>接壤采用四个正方向(上下左右);只对“己方”领土判断,绝不把其他邦国领土计入。
* 实现上直接查询四个邻居是否命中集合,零集合复制——
* 旧实现每帧把数千个己方区块复制进临时 HashSet,是持续卡顿与内存压力的来源之一。</p>
*/
private boolean isAdjacentToOwn(ChunkPos chunk) {
TerritoryModel model = this.dataService.model();
Set<ChunkPos> own = new HashSet<ChunkPos>();
TerritoryState self = model.selfState();
if (self != null) {
own.addAll(self.chunks());
Set<ChunkPos> ownChunks = self == null ? null : self.chunks();
Set<ChunkPos> sessionClaims = model.sessionClaims();
int x = chunk.x();
int z = chunk.z();
return this.isOwnAt(ownChunks, sessionClaims, x + 1, z)
|| this.isOwnAt(ownChunks, sessionClaims, x - 1, z)
|| this.isOwnAt(ownChunks, sessionClaims, x, z + 1)
|| this.isOwnAt(ownChunks, sessionClaims, x, z - 1);
}
own.addAll(model.sessionClaims());
for (ChunkPos neighbor : this.cardinalNeighbors(chunk)) {
if (own.contains(neighbor)) {
return true;
}
}
return false;
/** 判断指定坐标的区块是否属于己方领土或本次会话新占领 */
private boolean isOwnAt(Set<ChunkPos> ownChunks, Set<ChunkPos> sessionClaims, int x, int z) {
ChunkPos probe = new ChunkPos(x, z);
return ownChunks != null && ownChunks.contains(probe) || sessionClaims.contains(probe);
}
/** 成功后的过渡:进入 NEXT 状态,准备处理下一个目标 */
@@ -583,7 +787,12 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
}
this.model.addSessionClaim(chunk);
this.dataService.addSessionClaim(chunk);
// 占领成功后立即触发一次地图刷新(请求侧已带缓存击穿),尽快让地图数据确认该区块,
// 缩短"会话占领等待确认"窗口,避免假占领长时间残留
this.dataService.refreshAsync();
++this.completedClaims;
// 开垦成功:寻路/等待恢复正常,清零连续超时计数
this.consecutiveTimeoutMillis = 0L;
this.target = chunk;
this.lastProgress = 100;
this.log("\u5360\u9886\u6210\u529f " + chunk.x() + "," + chunk.z() + "\uff08\u533a\u5757 " + chunk.x() + "," + chunk.z() + " / \u4e16\u754c " + blockX + "," + blockZ + "\uff09");
@@ -599,13 +808,23 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
if (this.target == null) {
return;
}
// 开垦只能发生在玩家所在区块:校验玩家确实位于目标区块上。
// 通用成功文案采用模糊匹配,可能被服务器广播的其他玩家开垦消息误触发;
// 不校验会把未占领的目标错误标记为"已占领"并跳过,直到很久之后才被地图数据纠正(v1.2.1 修复)。
if (!this.playerChunk.equals(this.target)) {
return;
}
// 去重:同一区块只计一次
if (!this.claimedSet.add(this.target)) {
return;
}
this.model.addSessionClaim(this.target);
this.dataService.addSessionClaim(this.target);
// 占领成功后立即触发一次地图刷新,尽快确认该区块
this.dataService.refreshAsync();
++this.completedClaims;
// 开垦成功:寻路/等待恢复正常,清零连续超时计数
this.consecutiveTimeoutMillis = 0L;
this.lastProgress = 100;
this.log("\u5360\u9886\u6210\u529f " + this.target.x() + "," + this.target.z());
this.setState(State.SUCCESS, "\u7586\u571f\u5df2\u5f00\u62d3 " + this.target.x() + "," + this.target.z());
@@ -622,11 +841,6 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
return this.dataService.model().allClaimedChunks().contains(chunk);
}
/** 返回区块的四个正方向邻居 */
private List<ChunkPos> cardinalNeighbors(ChunkPos chunk) {
return List.of(new ChunkPos(chunk.x() + 1, chunk.z()), new ChunkPos(chunk.x() - 1, chunk.z()), new ChunkPos(chunk.x(), chunk.z() + 1), new ChunkPos(chunk.x(), chunk.z() - 1));
}
/**
* 自动游泳逻辑(每帧调用):
* 仅在玩家完全没入水中(有溺水风险)时自动跳跃,保持浮在水面。
@@ -707,10 +921,10 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
return -1;
}
/** 进食完成后恢复任务:若仍在寻路阶段则重新发起寻路 */
/** 进食完成后恢复任务:若仍在寻路阶段则重新发起寻路(走两步阶段不抢路) */
private void resumeAfterEating() {
this.eating = false;
if (this.state == State.PATHING && this.target != null) {
if (this.state == State.PATHING && this.target != null && !this.sideStepping) {
this.stageStartedMillis = System.currentTimeMillis();
this.baritoneBridge.pathTo(this.target);
}
@@ -785,6 +999,7 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener {
SUCCESS,
FAIL,
NEXT,
RETURNING,
COMPLETE,
STOPPED;
@@ -351,11 +351,24 @@ extends Screen {
/** 绘制各邦国领土区块(己方高亮、其他半透明),并绘制会话新占领区块与飞地(疏漏区块) */
private void drawTerritories(DrawContext context) {
TerritoryModel model = this.mod.dataService().model();
// 先反推当前视口覆盖的区块范围(含一个区块边距),遍历时用整数比较快速剔除视口外区块。
// 全图约 8 万区块,旧实现每帧对每个区块都调用屏幕坐标换算,是打开 GUI / 刷新后掉帧的主因。
double invScale = 1.0 / this.scale;
int edge = (int)Math.ceil(this.scale) + 2;
int halfW = this.mapW / 2;
int halfH = this.mapH / 2;
int visMinX = (int)Math.floor((this.mapX - edge - this.mapX - halfW) * invScale) + this.viewCenterX;
int visMaxX = (int)Math.ceil((this.mapX + this.mapW + edge - this.mapX - halfW) * invScale) + this.viewCenterX;
int visMinZ = (int)Math.floor((this.mapY - edge - this.mapY - halfH) * invScale) + this.viewCenterZ;
int visMaxZ = (int)Math.ceil((this.mapY + this.mapH + edge - this.mapY - halfH) * invScale) + this.viewCenterZ;
for (TerritoryState state : model.states()) {
boolean self = state.equals(model.selfState());
int color = state.color();
int fill = this.withAlpha(color, self ? 155 : 92);
for (ChunkPos chunk : state.chunks()) {
int cx = chunk.x();
int cz = chunk.z();
if (cx < visMinX || cx > visMaxX || cz < visMinZ || cz > visMaxZ) continue;
this.drawChunkFill(context, chunk, fill);
}
if (!this.isVisible(state.capital())) continue;
@@ -220,13 +220,18 @@ public final class ExpansionPlanner {
}
/**
* 策略 2 矩形区域扩地:在玩家圈选的矩形内,从与现有领土接壤的区块开始,
* 横向或纵向 S 形扫过整个矩形。
* 策略 2 矩形区域扩地:严格 S 形(蛇形)扫描,由内而外推进。
*
* <p>与旧版"沿邦国边界逐层向内 BFS"不同,本算法不再贴着邦国现有边界,
* 而是生成一条贯穿矩形的 S 形路径:从接壤起点开始,逐行/逐列交替反向扫过,
* 保证路径上每个区块都与已占领领土(含己方领土、本次会话新占领)接壤,
* 矩形内已占领的区块自动跳过、不重复占领。
* <p>扫描规则(v1.2.1 重写):
* <ul>
* <li>起始扫描线 = 矩形四条边中<b>离王城直线距离最近</b>的那条边(点到线段距离),
* 从该边向对边单调推进——即"由内而外",避免先跑到区域外侧绕圈;</li>
* <li>扫描行/列内做标准蛇形往返(相邻行方向交替),即使待开垦区块构成不规则多边形,
* 也始终按 S 形顺序一路扫过去,跳过暂时不可开垦的区块而不打乱顺序;</li>
* <li>首条扫描线的行内起始端同样选取离王城更近的一端;</li>
* <li>每个入选区块必须与"己方领土(含本次已选)"四正方向接壤,保证服务器接受开拓。</li>
* </ul>
* 复杂度:O(区域面积) 单遍扫描 + O(4) 接壤检查。</p>
*/
private List<ChunkPos> generateRectangle(TerritoryModel model, ExpansionConfig config, ChunkPos playerChunk) {
Set<ChunkPos> actualOwn = this.ownChunks(model);
@@ -239,139 +244,84 @@ public final class ExpansionPlanner {
int minZ = config.regionMinZ;
int maxZ = config.regionMaxZ;
int limit = config.targetCount > 0 ? config.targetCount : Integer.MAX_VALUE;
// 找出矩形内所有与己方领土接壤的未占领区块(作为 S 形起点候选)
ArrayList<ChunkPos> borderChunks = new ArrayList<ChunkPos>();
for (int x = minX; x <= maxX; ++x) {
for (int z = minZ; z <= maxZ; ++z) {
ChunkPos chunk = new ChunkPos(x, z);
if (allClaimed.contains(chunk)) continue;
if (this.ownNeighbors(chunk, actualOwn) > 0) {
borderChunks.add(chunk);
}
}
}
if (borderChunks.isEmpty()) {
return List.of();
}
// 选择起点:优先矩形角落附近与领土接壤的区块,使 S 形能自然覆盖整个矩形
ChunkPos start = this.chooseStart(borderChunks, minX, maxX, minZ, maxZ);
// 生成 S 形扫描路径(覆盖矩形内所有区块,含已占领的)
List<ChunkPos> sPath = this.buildSPath(start, minX, maxX, minZ, maxZ);
// 己方领土集合(含本次规划已纳入的区块)。
// 注意:这里绝不能把其他邦国的领土(allClaimed)混入接壤判断集合,
// 否则会选中一个“挨着其他邦国、但与己方领土不相邻”的区块,
// 服务器会因不接壤而拒绝开拓,导致玩家反复走到无效目标(历史 bug)。
ChunkPos capital = this.capital(model, playerChunk);
// ===== 1. 四条边到王城的距离(点到线段距离的平方),选最近者为起始边 =====
// 王城坐标投影到边所在线段的横向偏移(王城在区域横向范围外时才有偏移)
int dxEdge = Math.max(0, Math.max(minX - capital.x(), capital.x() - maxX));
int dzEdge = Math.max(0, Math.max(minZ - capital.z(), capital.z() - maxZ));
long distNorth = (long)dxEdge * dxEdge + (long)(minZ - capital.z()) * (minZ - capital.z());
long distSouth = (long)dxEdge * dxEdge + (long)(maxZ - capital.z()) * (maxZ - capital.z());
long distWest = (long)(minX - capital.x()) * (minX - capital.x()) + (long)dzEdge * dzEdge;
long distEast = (long)(maxX - capital.x()) * (maxX - capital.x()) + (long)dzEdge * dzEdge;
// 最近边决定扫描方向:北/南边最近 → 横向逐行扫(行沿 Z 推进);西/东边最近 → 纵向逐列扫
boolean rowScan = Math.min(distNorth, distSouth) <= Math.min(distWest, distEast);
// ===== 2. 生成 S 形扫描序列并按接壤过滤 =====
// 接壤判断集合:己方领土 + 本次已选区块。
// 注意:绝不能把其他邦国领土(allClaimed)混入接壤判断集合,
// 否则会选中"挨着其他邦国、但与己方领土不相邻"的区块,服务器会拒绝开拓(历史 bug)。
HashSet<ChunkPos> claimed = new HashSet<ChunkPos>(actualOwn);
ArrayList<ChunkPos> result = new ArrayList<ChunkPos>();
for (ChunkPos chunk : sPath) {
if (result.size() >= limit) break;
// 已被任何邦国(含己方)占领,或本次规划已纳入:跳过,避免重复/跨邦国开拓
if (allClaimed.contains(chunk) || claimed.contains(chunk)) continue;
// 必须与“己方”领土接壤(含本次已纳入的区块),保证服务器能成功开拓
if (this.ownNeighbors(chunk, claimed) == 0) continue;
result.add(chunk);
claimed.add(chunk);
if (rowScan) {
// 行序:从离王城近的边(z=minZ 或 maxZ)向对边推进;首行行内起点选离王城 x 近的一端
int startRow = distNorth <= distSouth ? minZ : maxZ;
int rowStep = startRow == minZ ? 1 : -1;
boolean ltr = Math.abs(capital.x() - minX) <= Math.abs(capital.x() - maxX);
for (int row = startRow; row >= minZ && row <= maxZ; row += rowStep) {
if (ltr) {
for (int x = minX; x <= maxX; ++x) {
this.tryClaimRect(result, claimed, allClaimed, x, row, limit);
}
} else {
for (int x = maxX; x >= minX; --x) {
this.tryClaimRect(result, claimed, allClaimed, x, row, limit);
}
}
ltr = !ltr;
if (result.size() >= limit) {
return result;
}
}
} else {
// 列序:从离王城近的边(x=minX 或 maxX)向对边推进;首列列内起点选离王城 z 近的一端
int startCol = distWest <= distEast ? minX : maxX;
int colStep = startCol == minX ? 1 : -1;
boolean ttb = Math.abs(capital.z() - minZ) <= Math.abs(capital.z() - maxZ);
for (int col = startCol; col >= minX && col <= maxX; col += colStep) {
if (ttb) {
for (int z = minZ; z <= maxZ; ++z) {
this.tryClaimRect(result, claimed, allClaimed, col, z, limit);
}
} else {
for (int z = maxZ; z >= minZ; --z) {
this.tryClaimRect(result, claimed, allClaimed, col, z, limit);
}
}
ttb = !ttb;
if (result.size() >= limit) {
return result;
}
}
}
return result;
}
/**
* 从接壤候选区块中选择 S 形扫描的起点:
* 优先选择矩形角落上的接壤区块;没有角落接壤时,选择离任一角落最近的接壤区块。
* S 形扫描中的一个格子:若该区块未被任何邦国占领、且与"己方领土(含本次已选)"接壤,
* 则纳入本次扩张计划;否则跳过(保持 S 形顺序一路扫过去,跳过的不打乱后续顺序)。
*/
private ChunkPos chooseStart(List<ChunkPos> borderChunks, int minX, int maxX, int minZ, int maxZ) {
ChunkPos[] corners = new ChunkPos[]{new ChunkPos(minX, minZ), new ChunkPos(maxX, minZ), new ChunkPos(minX, maxZ), new ChunkPos(maxX, maxZ)};
for (ChunkPos corner : corners) {
if (borderChunks.contains(corner)) {
return corner;
private void tryClaimRect(List<ChunkPos> result, Set<ChunkPos> claimed, Set<ChunkPos> allClaimed, int x, int z, int limit) {
if (result.size() >= limit) {
return;
}
ChunkPos chunk = new ChunkPos(x, z);
if (allClaimed.contains(chunk) || claimed.contains(chunk)) {
return;
}
ChunkPos best = null;
int bestDist = Integer.MAX_VALUE;
for (ChunkPos chunk : borderChunks) {
for (ChunkPos corner : corners) {
int dist = Math.abs(chunk.x() - corner.x()) + Math.abs(chunk.z() - corner.z());
if (dist < bestDist) {
bestDist = dist;
best = chunk;
}
}
}
return best;
}
/**
* 生成从起点开始的 S 形扫描路径,覆盖矩形内所有区块。
*
* <p>扫描主轴根据起点所在边缘决定:起点在顶/底行时横向逐行扫描,
* 起点在左/右列时纵向逐列扫描。每行/列内方向交替反向,形成 S 形。
* 起点所在行/列从起点向两侧铺开,保证起点是路径第一个区块。
*/
private List<ChunkPos> buildSPath(ChunkPos start, int minX, int maxX, int minZ, int maxZ) {
ArrayList<ChunkPos> path = new ArrayList<ChunkPos>();
boolean horizontal = start.z() == minZ || start.z() == maxZ;
if (horizontal) {
int startRow = start.z();
// 起点行:从起点向一侧到边缘,再反向到另一侧,完整覆盖该行
boolean leftToRight = start.x() == minX;
if (leftToRight) {
for (int x = start.x(); x <= maxX; ++x) path.add(new ChunkPos(x, startRow));
for (int x = start.x() - 1; x >= minX; --x) path.add(new ChunkPos(x, startRow));
} else {
for (int x = start.x(); x >= minX; --x) path.add(new ChunkPos(x, startRow));
for (int x = start.x() + 1; x <= maxX; ++x) path.add(new ChunkPos(x, startRow));
}
// 其余行:先向下(+z)再向上(-z),每行方向交替,形成 S 形
boolean ltr = leftToRight;
for (int z = startRow + 1; z <= maxZ; ++z) {
this.appendRow(path, z, minX, maxX, ltr);
ltr = !ltr;
}
for (int z = startRow - 1; z >= minZ; --z) {
this.appendRow(path, z, minX, maxX, ltr);
ltr = !ltr;
}
} else {
int startCol = start.x();
// 起点列:从起点向一侧到边缘,再反向到另一侧,完整覆盖该列
boolean topToBottom = start.z() == minZ;
if (topToBottom) {
for (int z = start.z(); z <= maxZ; ++z) path.add(new ChunkPos(startCol, z));
for (int z = start.z() - 1; z >= minZ; --z) path.add(new ChunkPos(startCol, z));
} else {
for (int z = start.z(); z >= minZ; --z) path.add(new ChunkPos(startCol, z));
for (int z = start.z() + 1; z <= maxZ; ++z) path.add(new ChunkPos(startCol, z));
}
// 其余列:先向右(+x)再向左(-x),每列方向交替,形成 S 形
boolean ttb = topToBottom;
for (int x = startCol + 1; x <= maxX; ++x) {
this.appendCol(path, x, minZ, maxZ, ttb);
ttb = !ttb;
}
for (int x = startCol - 1; x >= minX; --x) {
this.appendCol(path, x, minZ, maxZ, ttb);
ttb = !ttb;
}
}
return path;
}
/** 追加一行区块到路径(leftToRight=true 时从左到右,否则从右到左) */
private void appendRow(List<ChunkPos> path, int row, int minX, int maxX, boolean leftToRight) {
if (leftToRight) {
for (int x = minX; x <= maxX; ++x) path.add(new ChunkPos(x, row));
} else {
for (int x = maxX; x >= minX; --x) path.add(new ChunkPos(x, row));
}
}
/** 追加一列区块到路径(topToBottom=true 时从上到下,否则从下到上) */
private void appendCol(List<ChunkPos> path, int col, int minZ, int maxZ, boolean topToBottom) {
if (topToBottom) {
for (int z = minZ; z <= maxZ; ++z) path.add(new ChunkPos(col, z));
} else {
for (int z = maxZ; z >= minZ; --z) path.add(new ChunkPos(col, z));
if (this.ownNeighbors(chunk, claimed) == 0) {
return;
}
result.add(chunk);
claimed.add(chunk);
}
/** 在候选集中找到离当前游标最近的一个区块(曼哈顿距离),作为推进的前沿 */
@@ -74,8 +74,20 @@ public final class TerritoryDataService {
* <p>占领成功后,服务器动态地图(Dynmap/markers.json)可能尚未同步该区块,
* 因此在宽限期内即使地图未确认也先保留"会话占领"标记,避免刚占领的区块被误判为假占领。
* 超过宽限期仍未在地图中确认的,视为未真正占领并丢弃,使空洞区块能重新进入规划。
* (v1.2.1 由 120 秒缩短为 60 秒:配合请求侧缓存击穿,Dynmap 秒级同步,缩短可更快修正假占领)</p>
*/
private static final long CLAIM_CONFIRM_GRACE_MS = 120_000L;
private static final long CLAIM_CONFIRM_GRACE_MS = 60_000L;
/**
* ChunkPos 驻留池:坐标 → 复用实例。
*
* <p>每次解析 markers.json 会创建约 8 万个 ChunkPos,且各次刷新的坐标高度重合。
* 驻留后新旧模型共享同一批实例,刷新时垃圾量从"整份模型"降为"集合外壳",
* 显著降低 GC 压力(缓解刷新时游戏卡顿与内存占用)。</p>
*/
private static final ConcurrentHashMap<Long, ChunkPos> CHUNK_POOL = new ConcurrentHashMap<Long, ChunkPos>();
/** 上次成功响应的正文哈希与长度,用于识别"网络缓存返回了相同旧数据"并跳过重复解析 */
private volatile int lastBodyHash;
private volatile int lastBodyLength = -1;
public TerritoryDataService() {
this.executor = TerritoryDataService.newExecutor();
@@ -129,12 +141,28 @@ public final class TerritoryDataService {
this.executor.execute(() -> {
this.lastAttemptMillis = System.currentTimeMillis();
try {
HttpRequest request = HttpRequest.newBuilder(URI.create(MARKERS_URL)).timeout(Duration.ofSeconds(15L)).header("User-Agent", "Mozilla/5.0 autostore/1.0").GET().build();
// 请求侧缓存击穿:URL 追加时间戳参数 + no-cache 头,确保每次都拿到服务器最新数据。
// 此前约半数刷新命中网络缓存拿到旧数据,导致会话占领迟迟无法被地图确认/纠正。
String url = MARKERS_URL + "?_=" + System.currentTimeMillis();
HttpRequest request = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(15L))
.header("User-Agent", "Mozilla/5.0 autostore/1.2.1")
.header("Cache-Control", "no-cache, no-store")
.header("Pragma", "no-cache").GET().build();
HttpResponse<String> response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() < 200 || response.statusCode() >= 300) {
this.lastError = "HTTP " + response.statusCode();
return;
}
String body = response.body();
// 内容未变化(服务器确实无更新,或缓存仍命中相同旧文):跳过重复解析,
// 避免"每次刷新都重建 8 万区块模型"的 CPU/GC 开销;模型与飞地缓存保持原样。
if (body.length() == this.lastBodyLength && body.hashCode() == this.lastBodyHash) {
this.lastSuccessfulFetchMillis = System.currentTimeMillis();
this.lastError = "";
return;
}
this.lastBodyHash = body.hashCode();
this.lastBodyLength = body.length();
TerritoryModel parsed = TerritoryDataService.parse(response.body());
TerritoryModel previous = this.model;
if (previous != null) {
@@ -160,6 +188,9 @@ public final class TerritoryDataService {
}
}
parsed.setSelfStateName(this.selectedStateName);
// 后台线程预计算飞地缓存:把"各国外接矩形全量扫描"从渲染帧挪到这里,
// 修复"刷新地图/打开 GUI 时游戏卡一下"的问题
parsed.precomputeFlyingChunks();
this.model = parsed;
this.lastSuccessfulFetchMillis = System.currentTimeMillis();
this.lastError = "";
@@ -269,9 +300,9 @@ public final class TerritoryDataService {
return;
}
Set chunks = claims.computeIfAbsent(name, ignored -> new HashSet());
for (List<int[]> polygon : TerritoryDataService.extractPolygons(marker.get("points"))) {
TerritoryDataService.fillPolygon(chunks, polygon);
}
// 一个 marker 的多个多边形遵循 dynmap 的 even-odd 填充规则:
// 外圈为正,内圈多边形(国中飞地的边界)作为孔洞被减去,而非作为独立领土并集填充。
TerritoryDataService.fillPolygonEvenOdd(chunks, TerritoryDataService.extractPolygons(marker.get("points")));
colors.putIfAbsent(name, TerritoryDataService.parseColor(TerritoryDataService.stringOr(marker, "color", "#75bd20")));
}
@@ -337,33 +368,57 @@ public final class TerritoryDataService {
}
/**
* 将多边形覆盖的区块填充进集合:
* 先算出多边形外接矩形(换算为区块坐标),再逐区块用"点在多边形内"算法判断,
* 以区块中心点(cx*16+8, cz*16+8)是否落在多边形内为准。
* 面积超过 600 万区块时直接放弃,防止异常数据导致卡死。
* 将多边形组覆盖的区块按 even-odd 规则填充进集合。
*
* <p>一个 marker 里的多个多边形遵循 dynmap 的奇偶填充:区块落入<b>奇数个</b>多边形内时才视为领土;
* 这样内圈多边形(国中飞地/孔洞的边界)会被正确减去,而不是像并集那样把孔洞也填成领土。</p>
*
* @param output 输出集合(该邦国的区块集合)
* @param polygons 该 marker 的全部多边形,第一个为外圈、可为多个内圈孔洞
*/
private static void fillPolygon(Set<ChunkPos> output, List<int[]> polygon) {
int area;
private static void fillPolygonEvenOdd(Set<ChunkPos> output, List<List<int[]>> polygons) {
if (polygons.isEmpty()) {
return;
}
int minX = Integer.MAX_VALUE;
int maxX = Integer.MIN_VALUE;
int minZ = Integer.MAX_VALUE;
int maxZ = Integer.MIN_VALUE;
for (List<int[]> polygon : polygons) {
for (int[] point : polygon) {
minX = Math.min(minX, Math.floorDiv(point[0], 16));
maxX = Math.max(maxX, Math.floorDiv(point[0], 16));
minZ = Math.min(minZ, Math.floorDiv(point[1], 16));
maxZ = Math.max(maxZ, Math.floorDiv(point[1], 16));
}
int n = area = ((maxX - minX) + 1L) * ((maxZ - minZ) + 1L) > Integer.MAX_VALUE ? Integer.MAX_VALUE : (maxX - minX + 1) * (maxZ - minZ + 1);
if (area > 6000000) {
}
// 面积超过 600 万区块时直接放弃,防止异常数据导致卡死
if ((long)(maxX - minX + 1) * (maxZ - minZ + 1) > 6000000L) {
return;
}
for (int cx = minX; cx <= maxX; ++cx) {
for (int cz = minZ; cz <= maxZ; ++cz) {
if (!TerritoryDataService.pointInPolygon(cx * 16 + 8, cz * 16 + 8, polygon)) continue;
output.add(new ChunkPos(cx, cz));
// 奇偶填充:落在奇数个多边形内则为领土;否则(含恰好落在偶数个内圈孔洞中)忽略
boolean inside = false;
for (List<int[]> polygon : polygons) {
if (TerritoryDataService.pointInPolygon(cx * 16 + 8, cz * 16 + 8, polygon)) {
inside = !inside;
}
}
if (inside) {
output.add(TerritoryDataService.internChunk(cx, cz));
}
}
}
}
/**
* 驻留池获取区块坐标实例:同一坐标跨模型复用同一对象。
* 刷新频率下大幅减少 ChunkPos 临时分配与 GC 压力(见 {@link #CHUNK_POOL})。
*/
private static ChunkPos internChunk(int x, int z) {
long key = (long)x << 32 | (long)z & 0xFFFFFFFFL;
return TerritoryDataService.CHUNK_POOL.computeIfAbsent(key, k -> new ChunkPos(x, z));
}
/** 射线法判断点 (x, z) 是否在多边形内(经典奇偶规则) */
@@ -41,8 +41,12 @@ public final class TerritoryModel {
* <p>飞地指"四周(四个正方向)都被同一邦国占领、但自身未被占领"的区块,
* 通常是自动扩地时漏掉的疏漏。该结果在模型构建后懒计算并缓存,
* 仅在新增会话占领(addSessionClaim)后失效重算。
* 字段为 volatile:后台线程可调用 {@link #precomputeFlyingChunks()} 预热,
* 渲染线程读取时保证可见性,避免把全量扫描留在渲染帧里(卡顿来源)。</p>
*/
private transient Map<String, Set<ChunkPos>> cachedFlyingChunks;
private transient volatile Map<String, Set<ChunkPos>> cachedFlyingChunks;
/** 所有邦国飞地的合并并集缓存(volatile,同上);仅少量区块,供 GUI 直接遍历 */
private transient volatile Set<ChunkPos> cachedAllFlyingChunks;
public TerritoryModel(Map<String, TerritoryState> states) {
this.states = new HashMap<String, TerritoryState>(states);
@@ -103,17 +107,23 @@ public final class TerritoryModel {
}
/**
* 返回所有邦国的飞地(疏漏区块)并集(只读视图)。
* 返回所有邦国的飞地(疏漏区块)并集(只读视图,结果缓存,帧间零分配)。
*
* <p>飞地指“四周(四个正方向)都被同一邦国占领、但自身未被占领”的区块。
* 这些往往是自动扩地过程中漏掉的疏漏,需要在地图上标出并优先填补。</p>
*/
public Set<ChunkPos> flyingChunks() {
Set<ChunkPos> cached = this.cachedAllFlyingChunks;
if (cached != null) {
return cached;
}
HashSet<ChunkPos> result = new HashSet<ChunkPos>();
for (Set<ChunkPos> chunks : this.flyingChunksByState().values()) {
result.addAll(chunks);
}
return result;
Set<ChunkPos> frozen = Collections.unmodifiableSet(result);
this.cachedAllFlyingChunks = frozen;
return frozen;
}
/** 返回己方邦国的飞地(疏漏区块)(只读视图);未选择邦国时为空集 */
@@ -121,6 +131,15 @@ public final class TerritoryModel {
return Collections.unmodifiableSet(this.flyingChunksByState().getOrDefault(this.selfStateName, Set.of()));
}
/**
* 后台预计算飞地缓存(在数据刷新线程调用):
* 把"各国外接矩形全量扫描"提前到后台线程完成,
* 渲染线程读到的永远是现成缓存,避免模型刷新后第一帧卡顿。
*/
public void precomputeFlyingChunks() {
this.flyingChunks();
}
/** 计算并缓存各邦国的飞地区块(懒加载,addSessionClaim 后失效重算) */
private Map<String, Set<ChunkPos>> flyingChunksByState() {
Map<String, Set<ChunkPos>> cached = this.cachedFlyingChunks;
@@ -184,6 +203,7 @@ public final class TerritoryModel {
this.sessionClaims.add(chunk);
this.allClaimed.add(chunk);
this.cachedFlyingChunks = null;
this.cachedAllFlyingChunks = null;
TerritoryState self = this.states.get(this.selfStateName);
if (self != null) {
HashSet<ChunkPos> updated = new HashSet<ChunkPos>(self.chunks());
+1 -1
查看文件
@@ -1 +1 @@
{"schemaVersion":1,"id":"autostore","version":"1.2.0","name":"AutoStore","description":"","authors":[],"contact":{},"license":"All-Rights-Reserved","environment":"client","entrypoints":{"fabric-datagen":["autostore.client.AutoStoreModDataGenerator"],"client":["autostore.client.AutoStoreModClient"],"main":["autostore.AutoStoreMod"]},"mixins":["autostore.mixins.json",{"config":"autostore.client.mixins.json","environment":"client"}],"depends":{"fabricloader":">=0.16.14","fabric-api":"*","minecraft":"1.21.4"},"jars":[{"file":"META-INF/jars/baritone-api-fabric-1.13.1.jar"}]}
{"schemaVersion":1,"id":"autostore","version":"1.2.4","name":"AutoStore","description":"","authors":[],"contact":{},"license":"All-Rights-Reserved","environment":"client","entrypoints":{"fabric-datagen":["autostore.client.AutoStoreModDataGenerator"],"client":["autostore.client.AutoStoreModClient"],"main":["autostore.AutoStoreMod"]},"mixins":["autostore.mixins.json",{"config":"autostore.client.mixins.json","environment":"client"}],"depends":{"fabricloader":">=0.16.14","fabric-api":"*","minecraft":"1.21.4"},"jars":[{"file":"META-INF/jars/baritone-api-fabric-1.13.1.jar"}]}