From c38404f0f4a5870dfd602a4b68b640bb2066e84d Mon Sep 17 00:00:00 2001 From: lyizai <3945107889@qq.com> Date: Thu, 3 Sep 2026 22:47:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B2=BF=E7=BA=BF=E5=B7=A1=E6=A3=80+?= =?UTF-8?q?=E6=AD=A5=E8=A1=8C=E5=8F=97=E9=98=BB=E6=8D=A2=E9=81=93=E7=BB=AD?= =?UTF-8?q?=E9=93=BA+=E6=B0=B4=E9=9D=A2=E6=82=AC=E5=B4=96=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=90=AD=E6=A1=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 40 +++- .../java/com/autorail/gui/AutoRailScreen.java | 30 ++- .../java/com/autorail/pathing/WalkCell.java | 12 + .../com/autorail/pathing/WalkPathfinder.java | 36 ++- .../com/autorail/service/AutoRailService.java | 142 +++++++++-- .../com/autorail/service/PatrolExecutor.java | 222 ++++++++++++++++++ .../com/autorail/service/WalkExecutor.java | 121 +++++++++- .../resources/assets/autorail/lang/en_us.json | 13 + .../resources/assets/autorail/lang/zh_cn.json | 13 + 9 files changed, 576 insertions(+), 53 deletions(-) create mode 100644 src/main/java/com/autorail/pathing/WalkCell.java create mode 100644 src/main/java/com/autorail/service/PatrolExecutor.java diff --git a/README.md b/README.md index 1849aa0..d9c7f39 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ (全程菜单/聊天栏显示进度,不卡游戏)。 5. 快捷栏需要**铁轨**;启用电气化后还需要**动力铁轨**(和按规则插的**红石火把**/**探测铁轨**); 遇到悬空/水面会自动用**圆石**垫脚,没有则按预案处理。 -6. 铺轨过程中不要打开聊天/背包等界面(行走会暂停);再按 R+T 可查看进度或点“停止”。 +6. 铺完可点 **“沿线巡检”**:沿路走回逐格检查断轨/顶部受堵/支撑流失,问题坐标逐条播报。 +7. 铺轨过程中不要打开聊天/背包等界面(行走会暂停);再按 R+T 可查看进度或点“停止”。 ## 放置规则(电气化) @@ -40,11 +41,31 @@ - **材料不足预案**:铁轨/圆石/火把缺货时按“等待/跳过”预案执行,不会卡死也不会铺出断头路。 - **卡住自救**:行走受阻先跳跃脱困,持续卡住才报错停止;放置失败自动重试。 +## 全程不弃单(换道续铺 + 自动搭桥) + +- **换道续铺**:步行到起点途中被卡住/缺圆石/路线不通时,**不报错放弃**—— + 自动放弃步行路线,从玩家当前位置重新规划线路,直接铺到目标。 +- **自动搭桥**:步行寻路允许“悬空格”(水面之上、悬崖边缘),先垫圆石成型再踩上去; + 搭桥代价高于绕路,能绕就绕、绕不过才搭。岩浆上不搭桥。 + +## 沿线巡检 + +铺轨完成后点 **“沿线巡检”**,玩家沿已铺线路走回一圈,逐格检查: + +| 问题 | 含义 | +| --- | --- | +| 断轨 | 该格没有铁轨(被破坏/未铺上) | +| 上方被堵 | 头顶两格被实体方块占用,矿车/玩家过不去 | +| 支撑流失 | 铁轨下方支撑消失(悬空/泡水),可能脱落 | +| 无法到达 | 巡检者被地形卡住,跳过该格继续检查后续线路 | + +结果在聊天栏汇总播报(问题坐标 + 类型,最多显示 8 条),状态栏实时显示进度与问题数。 + ## 内置寻路(A*) - **铁轨寻路**:4 向移动、±1 格坡度、禁止掉头、“转弯+坡度”同格禁止(原版没有斜弯轨); 每节点校验头顶两格可通行、下方支撑可垫补,避开水面/岩浆;转弯加价、复用既有铁轨打折。 -- **步行寻路**(去远处起点):4 向 + 跳 1 格 + 落 3 格 + 可涉水,走不到会明确报错。 +- **步行寻路**(去远处起点):4 向 + 跳 1 格 + 落 3 格 + 可涉水 + 悬空格可搭桥(垫圆石)。 - 分片执行(每 tick 限额展开),不卡客户端线程。 ## 铺设(合法交互) @@ -57,15 +78,16 @@ ```text com.autorail ├─ AutoRailClient # 入口:R+T 组合键 + 每 tick 驱动服务 -├─ gui/AutoRailScreen # 主菜单:起点/终点/途径点 + 设置入口 +├─ gui/AutoRailScreen # 主菜单:起点/终点/途径点 + 巡检/设置入口 ├─ gui/AutoRailSettingsScreen # 设置:放置规则 / 预案 / 安全阈值 ├─ config/AutoRailConfig # Gson 持久化配置(config/autorail.json) -├─ service/AutoRailService # 状态机:PLANNING → WALKING → LAYING → DONE/FAILED +├─ service/AutoRailService # 状态机:PLANNING → WALKING → LAYING → PATROLLING → DONE/FAILED ├─ service/RailExecutor # 执行器:行走控制 + 合法放置铁轨/圆石/火把 -├─ service/WalkExecutor # 步行执行器:把玩家带到远处起点 +├─ service/WalkExecutor # 步行执行器:走到远处起点(含垫圆石搭桥) +├─ service/PatrolExecutor # 巡检执行器:沿线逐格检查断轨/堵顶/支撑流失 ├─ service/AutonomyController # 自主行为:自动进食 / 低血量避险 ├─ service/RailPlan # 把间隔规则展开成逐节点放置计划 -└─ pathing/ # 内置寻路:RailPathfinder、WalkPathfinder、RailStep、CoordParser +└─ pathing/ # 内置寻路:RailPathfinder、WalkPathfinder、RailStep、WalkCell、CoordParser ``` ## 开发 @@ -76,9 +98,11 @@ gradlew build # 产物在 build/libs/autorail-.jar ## 已知限制 -- 复用既有铁轨不校验类型:老线路上的普通铁轨不会自动升级为动力铁轨。 +- 复用既有铁轨不校验类型:老线路上的普通铁轨不会自动升级为动力铁轨(巡检会标出问题格)。 +- 巡检只报告不修复:发现断轨后可对同线路再跑一次铺轨,复用折扣会让模组优先补齐缺口。 - 不破坏、不放置除铁轨/圆石/红石火把外的方块:遇到障碍会绕路而不是挖穿。 -- 遇水会垫圆石架桥,岩浆列视为不可通行。 +- 遇水会垫圆石架桥,岩浆列视为不可通行;步行搭桥同理(岩浆上不搭桥)。 - 红石火把在支撑方块侧面放置;若点位被后续地形占用,该处动力轨无供电(线路仍连通)。 - 自动进食吃的是快捷栏里的食物;食物全靠自带,模组不会杀生取食。 +- 换道续铺要求目标点仍在寻路范围内(检查点包围盒外扩 48 格);超远距离请加途径点分段。 - 基于客户端世界状态判定;服务器延迟时个别放置会失败,会自动重试。 diff --git a/src/main/java/com/autorail/gui/AutoRailScreen.java b/src/main/java/com/autorail/gui/AutoRailScreen.java index a947b1d..7797149 100644 --- a/src/main/java/com/autorail/gui/AutoRailScreen.java +++ b/src/main/java/com/autorail/gui/AutoRailScreen.java @@ -30,6 +30,7 @@ public class AutoRailScreen extends Screen { private ButtonWidget beginBtn; private ButtonWidget stopBtn; private ButtonWidget addBtn; + private ButtonWidget patrolBtn; private String startText = ""; private String destText = ""; @@ -101,6 +102,11 @@ public class AutoRailScreen extends Screen { this.client.setScreen(new AutoRailSettingsScreen(this)); }).dimensions(cx + 101, btnY, 90, 20).build()); + int patrolY = btnY - 24; + patrolBtn = addDrawableChild(ButtonWidget.builder(Text.translatable("autorail.gui.patrol"), b -> { + AutoRailClient.SERVICE.beginPatrol(this.client.world, this.client.player); + }).dimensions(cx - 91, patrolY, 90, 20).build()); + statusY = this.height - 30; hintY = this.height - 18; } @@ -118,20 +124,24 @@ public class AutoRailScreen extends Screen { saveTexts(); AutoRailService s = AutoRailClient.SERVICE; AutoRailService.State st = s.getState(); - beginBtn.active = st == AutoRailService.State.IDLE - || st == AutoRailService.State.FAILED - || st == AutoRailService.State.DONE; - stopBtn.active = st == AutoRailService.State.PLANNING + boolean busy = st == AutoRailService.State.PLANNING || st == AutoRailService.State.WALK_PLANNING || st == AutoRailService.State.WALKING - || st == AutoRailService.State.LAYING; + || st == AutoRailService.State.LAYING + || st == AutoRailService.State.PATROLLING; + beginBtn.active = !busy; + stopBtn.active = busy; + patrolBtn.active = (st == AutoRailService.State.IDLE + || st == AutoRailService.State.FAILED + || st == AutoRailService.State.DONE) + && s.hasLastPath(); addBtn.active = waypointTexts.size() < Math.min(MAX_WAYPOINTS, Math.max(1, maxVisible)); - // 规划/步行完成进入铺设时自动关闭界面(行走需要关闭界面) - boolean busy = st == AutoRailService.State.LAYING; - boolean wasBusy = prevState == AutoRailService.State.LAYING; - if (!wasBusy && busy && prevState != AutoRailService.State.IDLE + // 作业进行中(铺设/巡检需要行走)时自动关闭界面 + if (busy && prevState != AutoRailService.State.IDLE && prevState != AutoRailService.State.DONE - && prevState != AutoRailService.State.FAILED) { + && prevState != AutoRailService.State.FAILED + && (st == AutoRailService.State.LAYING + || st == AutoRailService.State.PATROLLING)) { this.client.setScreen(null); return; } diff --git a/src/main/java/com/autorail/pathing/WalkCell.java b/src/main/java/com/autorail/pathing/WalkCell.java new file mode 100644 index 0000000..614c254 --- /dev/null +++ b/src/main/java/com/autorail/pathing/WalkCell.java @@ -0,0 +1,12 @@ +package com.autorail.pathing; + +import net.minecraft.util.math.BlockPos; + +/** + * 步行路径上的一格。 + * + * @param pos 玩家应站立的格子 + * @param bridge 到达该格前是否需要先垫圆石搭桥(支撑为空气/水面) + */ +public record WalkCell(BlockPos pos, boolean bridge) { +} diff --git a/src/main/java/com/autorail/pathing/WalkPathfinder.java b/src/main/java/com/autorail/pathing/WalkPathfinder.java index c2c27c2..8d5e6fd 100644 --- a/src/main/java/com/autorail/pathing/WalkPathfinder.java +++ b/src/main/java/com/autorail/pathing/WalkPathfinder.java @@ -15,9 +15,11 @@ import java.util.Map; import java.util.PriorityQueue; /** - * 自动走到远处的起始点:内置 A* 步行寻路(4 向 + 跳 1 格 + 落 3 格 + 可涉水)。 + * 自动走到远处的起始点:内置 A* 步行寻路(4 向 + 跳 1 格 + 落 3 格 + 可涉水 + 悬空搭桥)。 * - * 与铺轨寻路解耦:先走过去,再从起点开始铺轨;走不到则明确报错让用户手动接近。 + * 与铺轨寻路解耦:先走过去,再从起点开始铺轨。 + * 水面/悬崖边的悬空格允许“搭桥”通过(垫圆石,代价高于绕路); + * 起点列没有合法落脚点时自动在附近找同类落脚点,尽量不报错。 */ public class WalkPathfinder { @@ -27,6 +29,8 @@ public class WalkPathfinder { private static final long MAX_EXPANSIONS = 200_000L; private static final float COST_JUMP = 1.7f; private static final float COST_WATER = 0.8f; + /** 搭桥(垫圆石)的额外代价:优先绕实路,绕不过去才搭。 */ + private static final float COST_BRIDGE = 3.0f; private final ClientWorld world; private final BlockPos start; @@ -34,7 +38,7 @@ public class WalkPathfinder { private final PriorityQueue open = new PriorityQueue<>(Comparator.comparingDouble(n -> n.f)); private final Map best = new HashMap<>(); - private final List result = new ArrayList<>(); + private final List result = new ArrayList<>(); private State state = State.RUNNING; private String failKey; @@ -48,7 +52,7 @@ public class WalkPathfinder { this.start = start.toImmutable(); this.goal = goal.toImmutable(); computeRegion(); - Node s = new Node(this.start, null, 0f, h(this.start)); + Node s = new Node(this.start, null, 0f, h(this.start), false); best.put(this.start.asLong(), s); open.add(s); opened++; @@ -62,7 +66,7 @@ public class WalkPathfinder { return failKey; } - public List getResult() { + public List getResult() { return result; } @@ -128,14 +132,17 @@ public class WalkPathfinder { } BlockState sup = world.getBlockState(target.down()); boolean solid = sup.isSolidBlock(world, target.down()); - boolean water = sup.getFluidState().isIn(FluidTags.WATER); - if (!solid && !water) { + // 悬空格(水面上/悬崖边):允许通过,但搭桥要垫圆石,额外代价; + // 只有跳跃步必须落在实心方块上。岩浆支撑不可走。 + boolean bridged = !solid + && (sup.isAir() || sup.getFluidState().isIn(FluidTags.WATER)); + if (bridged && jump) { return; } - if (jump && !solid) { - return; // 跳跃必须落在实心方块上 - } float cost = baseCost; + if (bridged) { + cost += COST_BRIDGE; + } if (world.getBlockState(target).getFluidState().isIn(FluidTags.WATER)) { cost += COST_WATER; } @@ -143,7 +150,7 @@ public class WalkPathfinder { long key = target.asLong(); Node old = best.get(key); if (old == null || g < old.g - 1.0e-4f) { - Node n = new Node(target, cur, g, g + h(target)); + Node n = new Node(target, cur, g, g + h(target), bridged); best.put(key, n); open.add(n); opened++; @@ -153,7 +160,7 @@ public class WalkPathfinder { private void rebuild(Node goalNode) { result.clear(); for (Node n = goalNode; n != null; n = n.parent) { - result.add(n.pos); + result.add(new WalkCell(n.pos, n.bridge)); } java.util.Collections.reverse(result); } @@ -204,12 +211,15 @@ public class WalkPathfinder { final Node parent; final float g; final float f; + /** 到达该格前是否需要先垫桥(支撑为空气/水面)。 */ + final boolean bridge; - Node(BlockPos pos, Node parent, float g, float f) { + Node(BlockPos pos, Node parent, float g, float f, boolean bridge) { this.pos = pos; this.parent = parent; this.g = g; this.f = f; + this.bridge = bridge; } } } diff --git a/src/main/java/com/autorail/service/AutoRailService.java b/src/main/java/com/autorail/service/AutoRailService.java index d8ff2f2..df80cf0 100644 --- a/src/main/java/com/autorail/service/AutoRailService.java +++ b/src/main/java/com/autorail/service/AutoRailService.java @@ -16,12 +16,16 @@ import java.util.List; * 铺轨调度服务(单例,GUI 与执行逻辑的桥梁)。 * 状态机: * PLANNING(分片铁轨寻路)→ WALK_PLANNING → WALKING(自动走到远处起点) - * → LAYING(行走铺轨)→ DONE / FAILED。 + * → LAYING(行走铺轨)→ PATROLLING(沿线巡检)→ DONE / FAILED。 + * 步行走不到起点时不报错放弃:自动“换道”——放弃原步行路线, + * 从玩家当前位置直接重新规划线路铺到目标(就近落地,等效换道)。 * LAYING 期间自主行为(低血量避险、饥饿进食)由 {@link AutonomyController} 接管。 */ public class AutoRailService { - public enum State { IDLE, PLANNING, WALK_PLANNING, WALKING, LAYING, DONE, FAILED } + public enum State { + IDLE, PLANNING, WALK_PLANNING, WALKING, LAYING, PATROLLING, DONE, FAILED + } /** 每 tick 寻路展开预算(节点数)。 */ private static final int PLAN_BUDGET_PER_TICK = 4096; @@ -35,20 +39,35 @@ public class AutoRailService { private RailExecutor executor; private WalkPathfinder walkPlanner; private WalkExecutor walker; + private PatrolExecutor patrol; private final AutonomyController autonomy = new AutonomyController(); private String failKey; private boolean wasWaiting; + /** 最近一次铺设的路径(巡检用)。 */ + private List lastPath; public State getState() { return state; } + /** 巡检结果摘要(GUI 状态栏 / 聊天栏用),非巡检状态为 null。 */ + public PatrolExecutor getPatrol() { + return patrol; + } + + /** 是否有可供巡检的历史线路(铺设过至少一次且未重启游戏)。 */ + public boolean hasLastPath() { + return lastPath != null && !lastPath.isEmpty(); + } + public void begin(ClientWorld world, ClientPlayerEntity player, List checkpoints) { planner = new RailPathfinder(world, checkpoints); plan = null; executor = null; walkPlanner = null; walker = null; + patrol = null; + lastPath = null; failKey = null; wasWaiting = false; autonomy.releaseKeys(client()); @@ -56,6 +75,20 @@ public class AutoRailService { chat(player, "autorail.msg.plan.start", checkpoints.size()); } + /** 对最近铺设的线路发起沿线巡检(从玩家当前位置就近接入线路)。 */ + public void beginPatrol(ClientWorld world, ClientPlayerEntity player) { + if (lastPath == null || lastPath.isEmpty()) { + chat(player, "autorail.err.noLine"); + return; + } + patrol = new PatrolExecutor(world, lastPath); + failKey = null; + wasWaiting = false; + autonomy.releaseKeys(client()); + state = State.PATROLLING; + chat(player, "autorail.msg.patrol.start", lastPath.size()); + } + public void stop(ClientPlayerEntity player) { if (executor != null) { executor.releaseKeys(); @@ -63,6 +96,9 @@ public class AutoRailService { if (walker != null) { walker.releaseKeys(); } + if (patrol != null) { + patrol.releaseKeys(); + } autonomy.releaseKeys(client()); boolean wasActive = state != State.IDLE && state != State.DONE && state != State.FAILED; planner = null; @@ -70,6 +106,7 @@ public class AutoRailService { executor = null; walkPlanner = null; walker = null; + patrol = null; state = State.IDLE; if (wasActive && player != null) { chat(player, "autorail.msg.stopped"); @@ -92,7 +129,7 @@ public class AutoRailService { } AutoRailConfig cfg = AutoRailConfig.get(); - // 自主行为(紧急避险/自动进食)优先:接管本 tick,铺轨暂停 + // 自主行为(紧急避险/自动进食)优先:接管本 tick,作业暂停 String act = autonomy.tick(client, player, cfg); if (act != null) { if (executor != null) { @@ -101,6 +138,9 @@ public class AutoRailService { if (walker != null) { walker.releaseKeys(); } + if (patrol != null) { + patrol.releaseKeys(); + } if ("flee".equals(act) && wasActiveState()) { chat(player, "autorail.msg.flee"); } else if ("eat".equals(act) && wasActiveState()) { @@ -114,64 +154,85 @@ public class AutoRailService { switch (state) { case PLANNING -> tickPlanning(client, player); - case WALK_PLANNING -> tickWalkPlanning(client); - case WALKING -> tickWalking(client); + case WALK_PLANNING -> tickWalkPlanning(client, player); + case WALKING -> tickWalking(client, player); case LAYING -> tickLaying(client, player); + case PATROLLING -> tickPatrol(client, player); default -> { } } } private boolean wasActiveState() { return state == State.PLANNING || state == State.WALK_PLANNING - || state == State.WALKING || state == State.LAYING; + || state == State.WALKING || state == State.LAYING || state == State.PATROLLING; } private void tickPlanning(MinecraftClient client, ClientPlayerEntity player) { planner.step(PLAN_BUDGET_PER_TICK); if (planner.getState() == RailPathfinder.State.FOUND) { List steps = planner.getResult(); + lastPath = List.copyOf(steps); plan = RailPlan.plan(steps, client.world, AutoRailConfig.get()); executor = new RailExecutor(client.world, steps, plan, AutoRailConfig.get()); BlockPos walkTarget = planner.getWalkTarget(); if (walkTarget != null && isFar(player, walkTarget)) { - // 起点较远:先自动走过去 + // 起点较远:先自动走过去(走不到会换道,见 tickWalkPlanning) walkPlanner = new WalkPathfinder(client.world, player.getBlockPos(), walkTarget); state = State.WALK_PLANNING; chat(player, "autorail.msg.walk.start"); } else { state = State.LAYING; - chat(player, "autorail.msg.plan.done", steps.size()); + chat(player, "autorail.msg.plan.done", steps.size(), + plan.totalPowered(), plan.totalDetector(), plan.totalTorch()); } } else if (planner.getState() == RailPathfinder.State.FAILED) { fail(client.player, planner.getFailKey()); } } - private void tickWalkPlanning(MinecraftClient client) { + private void tickWalkPlanning(MinecraftClient client, ClientPlayerEntity player) { walkPlanner.step(WALK_BUDGET_PER_TICK); if (walkPlanner.getState() == WalkPathfinder.State.FOUND) { walker = new WalkExecutor(client.world, walkPlanner.getResult()); walkPlanner = null; state = State.WALKING; } else if (walkPlanner.getState() == WalkPathfinder.State.FAILED) { - fail(client.player, walkPlanner.getFailKey() != null - ? walkPlanner.getFailKey() : "autorail.err.walkFail"); + // 换道:不报错,放弃步行路线,从当前位置直接重新规划线路铺到目标 + walkPlanner = null; + walker = null; + replanFromPlayer(client, player); } } - private void tickWalking(MinecraftClient client) { + private void tickWalking(MinecraftClient client, ClientPlayerEntity player) { walker.tick(client); if (walker.isArrived()) { walker.releaseKeys(); walker = null; state = State.LAYING; - chat(client.player, "autorail.msg.walk.done"); + chat(player, "autorail.msg.walk.done"); } else if (walker.isFailed()) { - fail(client.player, walker.getFailKey() != null - ? walker.getFailKey() : "autorail.err.walkFail"); + // 换道:步行被卡住/缺圆石时,放弃步行,从当前位置直接铺过去 + walker.releaseKeys(); + walker = null; + replanFromPlayer(client, player); } } + /** 换道续铺:从玩家当前位置出发,直接规划线路铺到目标(不再要求走到原起点)。 */ + private void replanFromPlayer(MinecraftClient client, ClientPlayerEntity player) { + if (lastPath == null || lastPath.isEmpty()) { + fail(player, "autorail.err.walkFail"); + return; + } + BlockPos goal = lastPath.get(lastPath.size() - 1).railPos(); + chat(player, "autorail.msg.reroute"); + planner = new RailPathfinder(client.world, List.of(player.getBlockPos(), goal)); + plan = null; + executor = null; + state = State.PLANNING; + } + private void tickLaying(MinecraftClient client, ClientPlayerEntity player) { executor.tick(client); // 材料等待状态提示 @@ -192,6 +253,44 @@ public class AutoRailService { } } + private void tickPatrol(MinecraftClient client, ClientPlayerEntity player) { + patrol.tick(client); + if (patrol.isDone()) { + int issues = patrol.getIssueCount(); + if (patrol.isFailed()) { + fail(player, patrol.getFailKey()); + return; + } + state = State.DONE; + if (issues > 0) { + chat(player, "autorail.msg.patrol.doneIssues", patrol.getIdx(), issues); + // 逐条播报(最多 8 条,避免刷屏) + int shown = 0; + for (PatrolExecutor.CellReport r : patrol.getReports()) { + if (shown++ >= 8) { + chat(player, "autorail.msg.patrol.more", patrol.getReports().size() - 8); + break; + } + chat(player, "autorail.msg.patrol.issue", + r.index() + 1, r.railPos().getX(), r.railPos().getY(), r.railPos().getZ(), + Text.translatable(issueKey(r.issue()))); + } + } else { + chat(player, "autorail.msg.patrol.doneClean", patrol.getIdx()); + } + patrol = null; + } + } + + private static String issueKey(PatrolExecutor.Issue issue) { + return switch (issue) { + case RAIL_MISSING -> "autorail.patrol.railMissing"; + case TOP_BLOCKED -> "autorail.patrol.topBlocked"; + case SUPPORT_LOST -> "autorail.patrol.supportLost"; + case UNREACHABLE -> "autorail.patrol.unreachable"; + }; + } + private static boolean isFar(ClientPlayerEntity player, BlockPos target) { double dx = target.getX() + 0.5 - player.getX(); double dz = target.getZ() + 0.5 - player.getZ(); @@ -206,12 +305,16 @@ public class AutoRailService { if (walker != null) { walker.releaseKeys(); } + if (patrol != null) { + patrol.releaseKeys(); + } autonomy.releaseKeys(client()); planner = null; plan = null; executor = null; walkPlanner = null; walker = null; + patrol = null; state = State.IDLE; } @@ -224,6 +327,9 @@ public class AutoRailService { if (walker != null) { walker.releaseKeys(); } + if (patrol != null) { + patrol.releaseKeys(); + } autonomy.releaseKeys(client()); if (player != null) { chat(player, key); @@ -245,12 +351,14 @@ public class AutoRailService { yield Text.translatable("autorail.gui.status.idle"); } if (executor.isWaiting()) { - long sec = executor.getWaitSeconds(); - yield Text.translatable("autorail.gui.status.waiting", sec); + yield Text.translatable("autorail.gui.status.waiting", executor.getWaitSeconds()); } yield Text.translatable("autorail.gui.status.laying", executor.getPlaced(), executor.getTotal()); } + case PATROLLING -> patrol == null ? Text.translatable("autorail.gui.status.idle") + : Text.translatable("autorail.gui.status.patrol", + patrol.getIdx(), patrol.getTotal(), patrol.getIssueCount()); case DONE -> executor == null ? Text.translatable("autorail.gui.status.idle") : Text.translatable("autorail.gui.status.done", executor.getTotal()); case FAILED -> Text.translatable("autorail.gui.status.failed", diff --git a/src/main/java/com/autorail/service/PatrolExecutor.java b/src/main/java/com/autorail/service/PatrolExecutor.java new file mode 100644 index 0000000..57fc704 --- /dev/null +++ b/src/main/java/com/autorail/service/PatrolExecutor.java @@ -0,0 +1,222 @@ +package com.autorail.service; + +import com.autorail.pathing.RailStep; +import net.minecraft.block.BlockState; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.client.world.ClientWorld; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Direction; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.Vec3d; + +import java.util.List; + +/** + * 沿线巡检执行器:铺轨完成后沿路走回,逐格检查线路状况。 + * + * 检查项(每格): + * - {@link Issue#RAIL_MISSING}:铁轨位没有铁轨(断轨); + * - {@link Issue#TOP_BLOCKED}:铁轨上方两格被实体方块堵死(矿车/玩家过不去); + * - {@link Issue#SUPPORT_LOST}:支撑方块流失且该格没有铁轨(线路塌陷); + * - {@link Issue#UNREACHABLE}:巡检者被地形卡住,无法继续前进(进度停在原地)。 + * + * 只报告不修复:修复交给用户手动处理或下一轮铺轨(复用既有轨有折扣)。 + * 行走逻辑与 {@link WalkExecutor} 同款(转向/前进/跳跃/卡住自救)。 + */ +public class PatrolExecutor { + + /** 单格问题类型。 */ + public enum Issue { RAIL_MISSING, TOP_BLOCKED, SUPPORT_LOST, UNREACHABLE } + + /** 每格检查结果。 */ + public record CellReport(int index, BlockPos railPos, Issue issue) { + } + + private static final int WALK_AHEAD = 2; + private static final double ARRIVE_SQ = 0.35; + private static final int STUCK_JUMP_TICKS = 15; + private static final int STUCK_FAIL_TICKS = 200; + private static final float MAX_TURN_PER_TICK = 60.0f; + + private final ClientWorld world; + private final List path; + private int idx = 0; + private long tickCounter; + private long stuckSince = -1; + private int jumpTicks; + private int lastIdx = -1; + private boolean done; + private String failKey; + + private final java.util.ArrayList reports = new java.util.ArrayList<>(); + + public PatrolExecutor(ClientWorld world, List path) { + this.world = world; + this.path = path; + } + + public boolean isDone() { + return done || failKey != null; + } + + public boolean isFailed() { + return failKey != null; + } + + public String getFailKey() { + return failKey; + } + + public int getIdx() { + return Math.min(idx, path.size()); + } + + public int getTotal() { + return path.size(); + } + + /** 已发现问题数。 */ + public int getIssueCount() { + return reports.size(); + } + + /** 累积的检查结果(巡检完成后可用)。 */ + public List getReports() { + return reports; + } + + public void tick(MinecraftClient client) { + if (done || failKey != null) { + return; + } + ClientPlayerEntity player = client.player; + if (player == null) { + return; + } + tickCounter++; + + // 1) 检查所有已进入检查半径的格子(不要求每格踩上去,节省时间) + while (idx < path.size()) { + BlockPos rail = path.get(idx).railPos(); + if (player.getEyePos().squaredDistanceTo(Vec3d.ofCenter(rail)) > 16.0 * 16.0) { + break; // 还没走到附近,先前进 + } + inspect(idx, rail); + idx++; + } + if (idx >= path.size()) { + done = true; + releaseKeys(); + return; + } + + // 2) 前进到下一待检格 + BlockPos t = path.get(idx).railPos(); + double dx = t.getX() + 0.5 - player.getX(); + double dz = t.getZ() + 0.5 - player.getZ(); + double hSq = dx * dx + dz * dz; + int feetY = player.getBlockY(); + + if (hSq < ARRIVE_SQ && Math.abs(t.getY() - feetY) <= 1) { + // 到了但没进检查半径:罕见(半径>到达距离),保险处理 + inspect(idx, t); + idx++; + return; + } + + Vec3d center = new Vec3d(t.getX() + 0.5, t.getY() + 0.4, t.getZ() + 0.5); + aimAt(player, center); + boolean inWater = player.isInsideWaterOrBubbleColumn(); + boolean needJump = t.getY() > feetY || (inWater && t.getY() >= feetY); + if (needJump) { + jumpTicks = 4; + } + if (jumpTicks > 0) { + client.options.jumpKey.setPressed(true); + jumpTicks--; + } else { + client.options.jumpKey.setPressed(false); + } + client.options.forwardKey.setPressed(true); + + // 3) 卡住检测:跳跃自救;持续卡住 → 记录 UNREACHABLE 并跳到下一段继续 + if (idx == lastIdx && player.isOnGround() && speedSq(player) < 2.0e-4 && !needJump) { + if (stuckSince < 0) { + stuckSince = tickCounter; + } else if (tickCounter - stuckSince > STUCK_JUMP_TICKS) { + jumpTicks = 6; + } + if (tickCounter - stuckSince > STUCK_FAIL_TICKS) { + reports.add(new CellReport(idx, t, Issue.UNREACHABLE)); + idx++; // 跳过卡住的格子继续巡检后面的线路 + stuckSince = -1; + } + } else { + stuckSince = -1; + } + lastIdx = idx; + } + + /** 检查单格并记录问题。 */ + private void inspect(int i, BlockPos rail) { + BlockState at = world.getBlockState(rail); + if (!at.isOf(net.minecraft.block.Blocks.RAIL) + && !at.isOf(net.minecraft.block.Blocks.POWERED_RAIL) + && !at.isOf(net.minecraft.block.Blocks.DETECTOR_RAIL)) { + reports.add(new CellReport(i, rail, Issue.RAIL_MISSING)); + return; + } + BlockState up1 = world.getBlockState(rail.up()); + BlockState up2 = world.getBlockState(rail.up(2)); + if (!passable(up1) || !passable(up2)) { + reports.add(new CellReport(i, rail, Issue.TOP_BLOCKED)); + return; + } + // 断轨已报;有轨时支撑流失也会导致矿车脱轨(铁轨悬空掉落) + BlockState sup = world.getBlockState(rail.down()); + if (sup.isAir() || !sup.getFluidState().isEmpty()) { + reports.add(new CellReport(i, rail, Issue.SUPPORT_LOST)); + } + } + + private static boolean passable(BlockState s) { + return s.isAir() || (s.getFluidState().isEmpty() && s.isReplaceable()); + } + + public void releaseKeys() { + MinecraftClient mc = MinecraftClient.getInstance(); + if (mc == null || mc.options == null) { + return; + } + mc.options.forwardKey.setPressed(false); + mc.options.jumpKey.setPressed(false); + } + + private static double speedSq(ClientPlayerEntity player) { + Vec3d v = player.getVelocity(); + return v.x * v.x + v.z * v.z; + } + + private static void aimAt(ClientPlayerEntity player, Vec3d target) { + Vec3d eye = player.getEyePos(); + double dx = target.x - eye.x; + double dy = target.y - eye.y; + double dz = target.z - eye.z; + double h = Math.sqrt(dx * dx + dz * dz); + float yaw = (float) Math.toDegrees(Math.atan2(-dx, dz)); + float pitch = (float) -Math.toDegrees(Math.atan2(dy, h)); + player.setYaw(stepAngle(player.getYaw(), yaw)); + player.setPitch(stepAngle(player.getPitch(), pitch)); + } + + private static float stepAngle(float cur, float target) { + float d = MathHelper.wrapDegrees(target - cur); + if (d > MAX_TURN_PER_TICK) { + d = MAX_TURN_PER_TICK; + } else if (d < -MAX_TURN_PER_TICK) { + d = -MAX_TURN_PER_TICK; + } + return MathHelper.wrapDegrees(cur + d); + } +} diff --git a/src/main/java/com/autorail/service/WalkExecutor.java b/src/main/java/com/autorail/service/WalkExecutor.java index 138e7c9..6305697 100644 --- a/src/main/java/com/autorail/service/WalkExecutor.java +++ b/src/main/java/com/autorail/service/WalkExecutor.java @@ -1,9 +1,16 @@ package com.autorail.service; +import com.autorail.pathing.WalkCell; +import net.minecraft.block.BlockState; import net.minecraft.client.MinecraftClient; import net.minecraft.client.network.ClientPlayerEntity; import net.minecraft.client.world.ClientWorld; +import net.minecraft.item.Items; +import net.minecraft.network.packet.c2s.play.UpdateSelectedSlotC2SPacket; +import net.minecraft.util.Hand; +import net.minecraft.util.hit.BlockHitResult; import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Direction; import net.minecraft.util.math.MathHelper; import net.minecraft.util.math.Vec3d; @@ -11,26 +18,31 @@ import java.util.List; /** * 步行执行器:沿 WalkPathfinder 的格点路径控制玩家行走(转向/前进/跳跃), - * 用于把玩家自动带到远处的铺轨起点。只行走、不放置。 + * 用于把玩家自动带到远处的铺轨起点。 + * 遇到标记为“搭桥”的格子(水面/悬空)会先垫圆石成型再前进; + * 圆石耗尽或无支点可垫时失败,由服务层换道续铺兜底。 */ public class WalkExecutor { private static final int WALK_AHEAD = 2; + private static final int ACTION_INTERVAL = 4; private static final double ARRIVE_SQ = 0.35; + private static final double REACH_SQ = 4.2 * 4.2; private static final int STUCK_JUMP_TICKS = 15; private static final int STUCK_FAIL_TICKS = 160; private static final float MAX_TURN_PER_TICK = 60.0f; - private final List cells; + private final List cells; private int idx = 1; private long tickCounter; + private long lastActionTick = -ACTION_INTERVAL - 1L; private long stuckSince = -1; private int jumpTicks; private int lastIdx; private boolean arrived; private String failKey; - public WalkExecutor(ClientWorld world, List cells) { + public WalkExecutor(ClientWorld world, List cells) { this.cells = cells; } @@ -69,7 +81,7 @@ public class WalkExecutor { return; } - BlockPos t = cells.get(idx); + BlockPos t = cells.get(idx).pos(); double dx = t.getX() + 0.5 - player.getX(); double dz = t.getZ() + 0.5 - player.getZ(); double hSq = dx * dx + dz * dz; @@ -85,6 +97,16 @@ public class WalkExecutor { return; } + // 搭桥格:支撑未成型前原地等待并垫圆石 + if (cells.get(idx).bridge() && !isSolid(t.down())) { + standStill(client); + if (tickCounter - lastActionTick >= ACTION_INTERVAL) { + lastActionTick = tickCounter; + attemptBridge(client, player, t.down()); + } + return; + } + // 面向下一目标前进;目标更高则跳 Vec3d center = new Vec3d(t.getX() + 0.5, t.getY() + 0.4, t.getZ() + 0.5); aimAt(player, center); @@ -101,7 +123,7 @@ public class WalkExecutor { } client.options.forwardKey.setPressed(true); - // 卡住检测:先跳跃自救,再报错 + // 卡住检测:先跳跃自救,再报错(由服务层换道兜底) if (idx == lastIdx && player.isOnGround() && speedSq(player) < 2.0e-4 && !needJump) { if (stuckSince < 0) { stuckSince = tickCounter; @@ -119,6 +141,57 @@ public class WalkExecutor { lastIdx = idx; } + /** 在支撑位垫一块圆石:从相邻实心方块面点击放置。 */ + private void attemptBridge(MinecraftClient client, ClientPlayerEntity player, BlockPos support) { + int slot = findSlot(player, Items.COBBLESTONE); + if (slot < 0) { + failKey = "autorail.err.noCobble"; + releaseKeys(); + return; + } + BlockHitResult hit = findBridgeHit(player, support); + if (hit == null) { + failKey = "autorail.err.noHit"; + releaseKeys(); + return; + } + selectSlot(player, slot); + aimAt(player, hit.getPos()); + client.interactionManager.interactBlock(player, Hand.MAIN_HAND, hit); + player.swingHand(Hand.MAIN_HAND); + } + + /** 找支撑位相邻的实心面(与 RailExecutor 同款策略:就近最优面)。 */ + private BlockHitResult findBridgeHit(ClientPlayerEntity player, BlockPos support) { + double best = Double.MAX_VALUE; + BlockHitResult bestHit = null; + Vec3d eye = player.getEyePos(); + for (Direction face : Direction.values()) { + BlockPos nb = support.offset(face.getOpposite()); + if (!isSolid(nb)) { + continue; + } + BlockHitResult h = faceHit(nb, face); + double d = eye.squaredDistanceTo(h.getPos()); + if (d < best) { + best = d; + bestHit = h; + } + } + return bestHit; + } + + /** 搭桥等待期:松开移动键站在原地。 */ + private void standStill(MinecraftClient client) { + client.options.forwardKey.setPressed(false); + if (jumpTicks > 0) { + client.options.jumpKey.setPressed(true); + jumpTicks--; + } else { + client.options.jumpKey.setPressed(false); + } + } + public void releaseKeys() { MinecraftClient mc = MinecraftClient.getInstance(); if (mc == null || mc.options == null) { @@ -128,6 +201,17 @@ public class WalkExecutor { mc.options.jumpKey.setPressed(false); } + private boolean isSolid(BlockPos pos) { + BlockState s = clientWorld().getBlockState(pos); + return !s.isAir() && s.getFluidState().isEmpty() && !s.isReplaceable() + && s.isSolidBlock(clientWorld(), pos); + } + + private static ClientWorld clientWorld() { + MinecraftClient mc = MinecraftClient.getInstance(); + return mc == null ? null : mc.world; + } + private static double speedSq(ClientPlayerEntity player) { Vec3d v = player.getVelocity(); return v.x * v.x + v.z * v.z; @@ -154,4 +238,31 @@ public class WalkExecutor { } return MathHelper.wrapDegrees(cur + d); } + + /** 快捷栏查找指定物品。 */ + private static int findSlot(ClientPlayerEntity player, net.minecraft.item.Item item) { + for (int i = 0; i < 9; i++) { + if (player.getInventory().getStack(i).isOf(item)) { + return i; + } + } + return -1; + } + + /** 切换选中槽位并同步给服务端。 */ + private static void selectSlot(ClientPlayerEntity player, int slot) { + player.getInventory().selectedSlot = slot; + if (player.networkHandler != null) { + player.networkHandler.sendPacket(new UpdateSelectedSlotC2SPacket(slot)); + } + } + + /** 点击 block 的 face 面(新方块会出现在 face 一侧)。 */ + private static BlockHitResult faceHit(BlockPos block, Direction face) { + Vec3d p = new Vec3d( + block.getX() + 0.5 + face.getOffsetX() * 0.5, + block.getY() + 0.5 + face.getOffsetY() * 0.5, + block.getZ() + 0.5 + face.getOffsetZ() * 0.5); + return new BlockHitResult(p, face, block, false); + } } diff --git a/src/main/resources/assets/autorail/lang/en_us.json b/src/main/resources/assets/autorail/lang/en_us.json index 1bf9a28..9917dda 100644 --- a/src/main/resources/assets/autorail/lang/en_us.json +++ b/src/main/resources/assets/autorail/lang/en_us.json @@ -29,12 +29,14 @@ "autorail.gui.settings.reset": "Defaults", "autorail.gui.settings.badNumber": "Bad number, or torch interval exceeds powered interval", "autorail.gui.settings.saved": "Saved to config/autorail.json", + "autorail.gui.patrol": "Patrol line", "autorail.gui.status.idle": "Idle", "autorail.gui.status.planning": "Pathfinding… {0} nodes opened", "autorail.gui.status.walkPlanning": "Planning walk route… {0} nodes", "autorail.gui.status.walking": "Walking to start… {0} / {1}", "autorail.gui.status.laying": "Laying rails… {0} / {1}", "autorail.gui.status.waiting": "Waiting for materials… {0}s left", + "autorail.gui.status.patrol": "Patrolling… {0} / {1}, {2} issue(s)", "autorail.gui.status.done": "Done! {0} rails placed", "autorail.gui.status.failed": "Failed: {0}", "autorail.err.needWorld": "Enter a world first", @@ -55,6 +57,17 @@ "autorail.err.walkFail": "Cannot walk to the start automatically - get closer and retry", "autorail.err.walkStuck": "Stuck on the way to the start - clear the obstacle and retry", "autorail.err.waitTimeout": "Material wait timed out - stopped", + "autorail.err.noLine": "No line to patrol - lay a line first", + "autorail.msg.reroute": "Walk route blocked - rerouting: laying rails from current position…", + "autorail.msg.patrol.start": "Patrolling the line ({0} blocks)…", + "autorail.msg.patrol.doneClean": "Patrol done: all {0} blocks OK", + "autorail.msg.patrol.doneIssues": "Patrol done: {0} blocks checked, {1} issue(s):", + "autorail.msg.patrol.issue": " #{0} ({1},{2},{3}): {4}", + "autorail.msg.patrol.more": " …and {0} more issue(s)", + "autorail.patrol.railMissing": "rail missing", + "autorail.patrol.topBlocked": "top blocked (blocks above)", + "autorail.patrol.supportLost": "support lost (floating/waterlogged rail)", + "autorail.patrol.unreachable": "patroller could not reach (stuck)", "autorail.msg.plan.start": "Pathfinding ({0} checkpoints)…", "autorail.msg.plan.done": "Path found: {0} blocks ({1} powered, {2} detector, {3} torches). Laying rails - avoid opening other screens", "autorail.msg.lay.done": "Done! {0} rails placed", diff --git a/src/main/resources/assets/autorail/lang/zh_cn.json b/src/main/resources/assets/autorail/lang/zh_cn.json index f49f4ea..2a3f1b5 100644 --- a/src/main/resources/assets/autorail/lang/zh_cn.json +++ b/src/main/resources/assets/autorail/lang/zh_cn.json @@ -29,12 +29,14 @@ "autorail.gui.settings.reset": "恢复默认", "autorail.gui.settings.badNumber": "数值格式错误或火把间隔大于动力轨间隔", "autorail.gui.settings.saved": "设置已保存到 config/autorail.json", + "autorail.gui.patrol": "沿线巡检", "autorail.gui.status.idle": "待机中", "autorail.gui.status.planning": "寻路中… 已展开 {0} 个节点", "autorail.gui.status.walkPlanning": "规划步行路线… {0} 个节点", "autorail.gui.status.walking": "自动走向起点… {0} / {1}", "autorail.gui.status.laying": "铺轨中… {0} / {1}", "autorail.gui.status.waiting": "等待材料中… 剩余 {0} 秒,请补充到快捷栏", + "autorail.gui.status.patrol": "巡检中… {0} / {1},发现问题 {2} 处", "autorail.gui.status.done": "完成!共铺设 {0} 格铁轨", "autorail.gui.status.failed": "失败:{0}", "autorail.err.needWorld": "请先进入世界", @@ -55,6 +57,17 @@ "autorail.err.walkFail": "无法自动走到起点(地形过远或过险),请手动接近后重试", "autorail.err.walkStuck": "前往起点途中卡住,请清理障碍后重试", "autorail.err.waitTimeout": "等待材料超时,已自动停止", + "autorail.err.noLine": "还没有可巡检的线路(先铺设一次)", + "autorail.msg.reroute": "步行路线受阻,换道:从当前位置直接铺到目标…", + "autorail.msg.patrol.start": "开始沿线巡检(共 {0} 格)…", + "autorail.msg.patrol.doneClean": "巡检完成:{0} 格全部正常", + "autorail.msg.patrol.doneIssues": "巡检完成:检查 {0} 格,发现 {1} 处问题:", + "autorail.msg.patrol.issue": " 第 {0} 格 ({1},{2},{3}):{4}", + "autorail.msg.patrol.more": " …另有 {0} 处问题未显示", + "autorail.patrol.railMissing": "断轨(该格没有铁轨)", + "autorail.patrol.topBlocked": "上方被堵(头顶两格有方块)", + "autorail.patrol.supportLost": "支撑流失(铁轨悬空/泡水,可能脱落)", + "autorail.patrol.unreachable": "巡检者无法到达(被地形卡住)", "autorail.msg.plan.start": "开始寻路({0} 个检查点)…", "autorail.msg.plan.done": "路径规划完成:{0} 格(动力轨 {1}、探测轨 {2}、火把 {3}),开始铺轨(请勿打开其他界面)", "autorail.msg.lay.done": "铺轨完成!共 {0} 格",