feat: 沿线巡检+步行受阻换道续铺+水面悬崖自动搭桥

这个提交包含在:
lyizai
2026-09-03 22:47:11 +08:00
父节点 3bd9a41d63
当前提交 c38404f0f4
共修改 9 个文件,包含 576 行新增和 53 行删除
+32 -8
查看文件
@@ -15,7 +15,8 @@
(全程菜单/聊天栏显示进度,不卡游戏)。 (全程菜单/聊天栏显示进度,不卡游戏)。
5. 快捷栏需要**铁轨**;启用电气化后还需要**动力铁轨**(和按规则插的**红石火把**/**探测铁轨**); 5. 快捷栏需要**铁轨**;启用电气化后还需要**动力铁轨**(和按规则插的**红石火把**/**探测铁轨**);
遇到悬空/水面会自动用**圆石**垫脚,没有则按预案处理。 遇到悬空/水面会自动用**圆石**垫脚,没有则按预案处理。
6. 铺轨过程中不要打开聊天/背包等界面(行走会暂停);再按 R+T 可查看进度或点“停止”。 6. 铺完可点 **“沿线巡检”**:沿路走回逐格检查断轨/顶部受堵/支撑流失,问题坐标逐条播报。
7. 铺轨过程中不要打开聊天/背包等界面(行走会暂停);再按 R+T 可查看进度或点“停止”。
## 放置规则(电气化) ## 放置规则(电气化)
@@ -40,11 +41,31 @@
- **材料不足预案**:铁轨/圆石/火把缺货时按“等待/跳过”预案执行,不会卡死也不会铺出断头路。 - **材料不足预案**:铁轨/圆石/火把缺货时按“等待/跳过”预案执行,不会卡死也不会铺出断头路。
- **卡住自救**:行走受阻先跳跃脱困,持续卡住才报错停止;放置失败自动重试。 - **卡住自救**:行走受阻先跳跃脱困,持续卡住才报错停止;放置失败自动重试。
## 全程不弃单(换道续铺 + 自动搭桥)
- **换道续铺**:步行到起点途中被卡住/缺圆石/路线不通时,**不报错放弃**——
自动放弃步行路线,从玩家当前位置重新规划线路,直接铺到目标。
- **自动搭桥**:步行寻路允许“悬空格”(水面之上、悬崖边缘),先垫圆石成型再踩上去;
搭桥代价高于绕路,能绕就绕、绕不过才搭。岩浆上不搭桥。
## 沿线巡检
铺轨完成后点 **“沿线巡检”**,玩家沿已铺线路走回一圈,逐格检查:
| 问题 | 含义 |
| --- | --- |
| 断轨 | 该格没有铁轨(被破坏/未铺上) |
| 上方被堵 | 头顶两格被实体方块占用,矿车/玩家过不去 |
| 支撑流失 | 铁轨下方支撑消失(悬空/泡水),可能脱落 |
| 无法到达 | 巡检者被地形卡住,跳过该格继续检查后续线路 |
结果在聊天栏汇总播报(问题坐标 + 类型,最多显示 8 条),状态栏实时显示进度与问题数。
## 内置寻路(A*) ## 内置寻路(A*)
- **铁轨寻路**:4 向移动、±1 格坡度、禁止掉头、“转弯+坡度”同格禁止(原版没有斜弯轨); - **铁轨寻路**:4 向移动、±1 格坡度、禁止掉头、“转弯+坡度”同格禁止(原版没有斜弯轨);
每节点校验头顶两格可通行、下方支撑可垫补,避开水面/岩浆;转弯加价、复用既有铁轨打折。 每节点校验头顶两格可通行、下方支撑可垫补,避开水面/岩浆;转弯加价、复用既有铁轨打折。
- **步行寻路**(去远处起点):4 向 + 跳 1 格 + 落 3 格 + 可涉水,走不到会明确报错。 - **步行寻路**(去远处起点):4 向 + 跳 1 格 + 落 3 格 + 可涉水 + 悬空格可搭桥(垫圆石)。
- 分片执行(每 tick 限额展开),不卡客户端线程。 - 分片执行(每 tick 限额展开),不卡客户端线程。
## 铺设(合法交互) ## 铺设(合法交互)
@@ -57,15 +78,16 @@
```text ```text
com.autorail com.autorail
├─ AutoRailClient # 入口:R+T 组合键 + 每 tick 驱动服务 ├─ AutoRailClient # 入口:R+T 组合键 + 每 tick 驱动服务
├─ gui/AutoRailScreen # 主菜单:起点/终点/途径点 + 设置入口 ├─ gui/AutoRailScreen # 主菜单:起点/终点/途径点 + 巡检/设置入口
├─ gui/AutoRailSettingsScreen # 设置:放置规则 / 预案 / 安全阈值 ├─ gui/AutoRailSettingsScreen # 设置:放置规则 / 预案 / 安全阈值
├─ config/AutoRailConfig # Gson 持久化配置(config/autorail.json) ├─ config/AutoRailConfig # Gson 持久化配置(config/autorail.json)
├─ service/AutoRailService # 状态机:PLANNING → WALKING → LAYING → DONE/FAILED ├─ service/AutoRailService # 状态机:PLANNING → WALKING → LAYING → PATROLLING → DONE/FAILED
├─ service/RailExecutor # 执行器:行走控制 + 合法放置铁轨/圆石/火把 ├─ service/RailExecutor # 执行器:行走控制 + 合法放置铁轨/圆石/火把
├─ service/WalkExecutor # 步行执行器:把玩家带到远处起点 ├─ service/WalkExecutor # 步行执行器:走到远处起点(含垫圆石搭桥)
├─ service/PatrolExecutor # 巡检执行器:沿线逐格检查断轨/堵顶/支撑流失
├─ service/AutonomyController # 自主行为:自动进食 / 低血量避险 ├─ service/AutonomyController # 自主行为:自动进食 / 低血量避险
├─ service/RailPlan # 把间隔规则展开成逐节点放置计划 ├─ service/RailPlan # 把间隔规则展开成逐节点放置计划
└─ pathing/ # 内置寻路:RailPathfinder、WalkPathfinder、RailStep、CoordParser └─ pathing/ # 内置寻路:RailPathfinder、WalkPathfinder、RailStep、WalkCell、CoordParser
``` ```
## 开发 ## 开发
@@ -76,9 +98,11 @@ gradlew build # 产物在 build/libs/autorail-<version>.jar
## 已知限制 ## 已知限制
- 复用既有铁轨不校验类型:老线路上的普通铁轨不会自动升级为动力铁轨。 - 复用既有铁轨不校验类型:老线路上的普通铁轨不会自动升级为动力铁轨(巡检会标出问题格)。
- 巡检只报告不修复:发现断轨后可对同线路再跑一次铺轨,复用折扣会让模组优先补齐缺口。
- 不破坏、不放置除铁轨/圆石/红石火把外的方块:遇到障碍会绕路而不是挖穿。 - 不破坏、不放置除铁轨/圆石/红石火把外的方块:遇到障碍会绕路而不是挖穿。
- 遇水会垫圆石架桥,岩浆列视为不可通行。 - 遇水会垫圆石架桥,岩浆列视为不可通行;步行搭桥同理(岩浆上不搭桥)。
- 红石火把在支撑方块侧面放置;若点位被后续地形占用,该处动力轨无供电(线路仍连通)。 - 红石火把在支撑方块侧面放置;若点位被后续地形占用,该处动力轨无供电(线路仍连通)。
- 自动进食吃的是快捷栏里的食物;食物全靠自带,模组不会杀生取食。 - 自动进食吃的是快捷栏里的食物;食物全靠自带,模组不会杀生取食。
- 换道续铺要求目标点仍在寻路范围内(检查点包围盒外扩 48 格);超远距离请加途径点分段。
- 基于客户端世界状态判定;服务器延迟时个别放置会失败,会自动重试。 - 基于客户端世界状态判定;服务器延迟时个别放置会失败,会自动重试。
@@ -30,6 +30,7 @@ public class AutoRailScreen extends Screen {
private ButtonWidget beginBtn; private ButtonWidget beginBtn;
private ButtonWidget stopBtn; private ButtonWidget stopBtn;
private ButtonWidget addBtn; private ButtonWidget addBtn;
private ButtonWidget patrolBtn;
private String startText = ""; private String startText = "";
private String destText = ""; private String destText = "";
@@ -101,6 +102,11 @@ public class AutoRailScreen extends Screen {
this.client.setScreen(new AutoRailSettingsScreen(this)); this.client.setScreen(new AutoRailSettingsScreen(this));
}).dimensions(cx + 101, btnY, 90, 20).build()); }).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; statusY = this.height - 30;
hintY = this.height - 18; hintY = this.height - 18;
} }
@@ -118,20 +124,24 @@ public class AutoRailScreen extends Screen {
saveTexts(); saveTexts();
AutoRailService s = AutoRailClient.SERVICE; AutoRailService s = AutoRailClient.SERVICE;
AutoRailService.State st = s.getState(); AutoRailService.State st = s.getState();
beginBtn.active = st == AutoRailService.State.IDLE boolean busy = st == AutoRailService.State.PLANNING
|| st == AutoRailService.State.FAILED
|| st == AutoRailService.State.DONE;
stopBtn.active = st == AutoRailService.State.PLANNING
|| st == AutoRailService.State.WALK_PLANNING || st == AutoRailService.State.WALK_PLANNING
|| st == AutoRailService.State.WALKING || 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)); addBtn.active = waypointTexts.size() < Math.min(MAX_WAYPOINTS, Math.max(1, maxVisible));
// 规划/步行完成进入铺设时自动关闭界面(行走需要关闭界面) // 作业进行中(铺设/巡检需要行走)时自动关闭界面
boolean busy = st == AutoRailService.State.LAYING; if (busy && prevState != AutoRailService.State.IDLE
boolean wasBusy = prevState == AutoRailService.State.LAYING;
if (!wasBusy && busy && prevState != AutoRailService.State.IDLE
&& prevState != AutoRailService.State.DONE && prevState != AutoRailService.State.DONE
&& prevState != AutoRailService.State.FAILED) { && prevState != AutoRailService.State.FAILED
&& (st == AutoRailService.State.LAYING
|| st == AutoRailService.State.PATROLLING)) {
this.client.setScreen(null); this.client.setScreen(null);
return; return;
} }
@@ -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) {
}
@@ -15,9 +15,11 @@ import java.util.Map;
import java.util.PriorityQueue; import java.util.PriorityQueue;
/** /**
* 自动走到远处的起始点:内置 A* 步行寻路(4 向 + 跳 1 格 + 落 3 格 + 可涉水)。 * 自动走到远处的起始点:内置 A* 步行寻路(4 向 + 跳 1 格 + 落 3 格 + 可涉水 + 悬空搭桥)。
* *
* 与铺轨寻路解耦:先走过去,再从起点开始铺轨;走不到则明确报错让用户手动接近。 * 与铺轨寻路解耦:先走过去,再从起点开始铺轨。
* 水面/悬崖边的悬空格允许“搭桥”通过(垫圆石,代价高于绕路);
* 起点列没有合法落脚点时自动在附近找同类落脚点,尽量不报错。
*/ */
public class WalkPathfinder { public class WalkPathfinder {
@@ -27,6 +29,8 @@ public class WalkPathfinder {
private static final long MAX_EXPANSIONS = 200_000L; private static final long MAX_EXPANSIONS = 200_000L;
private static final float COST_JUMP = 1.7f; private static final float COST_JUMP = 1.7f;
private static final float COST_WATER = 0.8f; private static final float COST_WATER = 0.8f;
/** 搭桥(垫圆石)的额外代价:优先绕实路,绕不过去才搭。 */
private static final float COST_BRIDGE = 3.0f;
private final ClientWorld world; private final ClientWorld world;
private final BlockPos start; private final BlockPos start;
@@ -34,7 +38,7 @@ public class WalkPathfinder {
private final PriorityQueue<Node> open = new PriorityQueue<>(Comparator.comparingDouble(n -> n.f)); private final PriorityQueue<Node> open = new PriorityQueue<>(Comparator.comparingDouble(n -> n.f));
private final Map<Long, Node> best = new HashMap<>(); private final Map<Long, Node> best = new HashMap<>();
private final List<BlockPos> result = new ArrayList<>(); private final List<WalkCell> result = new ArrayList<>();
private State state = State.RUNNING; private State state = State.RUNNING;
private String failKey; private String failKey;
@@ -48,7 +52,7 @@ public class WalkPathfinder {
this.start = start.toImmutable(); this.start = start.toImmutable();
this.goal = goal.toImmutable(); this.goal = goal.toImmutable();
computeRegion(); 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); best.put(this.start.asLong(), s);
open.add(s); open.add(s);
opened++; opened++;
@@ -62,7 +66,7 @@ public class WalkPathfinder {
return failKey; return failKey;
} }
public List<BlockPos> getResult() { public List<WalkCell> getResult() {
return result; return result;
} }
@@ -128,14 +132,17 @@ public class WalkPathfinder {
} }
BlockState sup = world.getBlockState(target.down()); BlockState sup = world.getBlockState(target.down());
boolean solid = sup.isSolidBlock(world, 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; return;
} }
if (jump && !solid) {
return; // 跳跃必须落在实心方块上
}
float cost = baseCost; float cost = baseCost;
if (bridged) {
cost += COST_BRIDGE;
}
if (world.getBlockState(target).getFluidState().isIn(FluidTags.WATER)) { if (world.getBlockState(target).getFluidState().isIn(FluidTags.WATER)) {
cost += COST_WATER; cost += COST_WATER;
} }
@@ -143,7 +150,7 @@ public class WalkPathfinder {
long key = target.asLong(); long key = target.asLong();
Node old = best.get(key); Node old = best.get(key);
if (old == null || g < old.g - 1.0e-4f) { 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); best.put(key, n);
open.add(n); open.add(n);
opened++; opened++;
@@ -153,7 +160,7 @@ public class WalkPathfinder {
private void rebuild(Node goalNode) { private void rebuild(Node goalNode) {
result.clear(); result.clear();
for (Node n = goalNode; n != null; n = n.parent) { 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); java.util.Collections.reverse(result);
} }
@@ -204,12 +211,15 @@ public class WalkPathfinder {
final Node parent; final Node parent;
final float g; final float g;
final float f; 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.pos = pos;
this.parent = parent; this.parent = parent;
this.g = g; this.g = g;
this.f = f; this.f = f;
this.bridge = bridge;
} }
} }
} }
@@ -16,12 +16,16 @@ import java.util.List;
* 铺轨调度服务(单例,GUI 与执行逻辑的桥梁)。 * 铺轨调度服务(单例,GUI 与执行逻辑的桥梁)。
* 状态机: * 状态机:
* PLANNING(分片铁轨寻路)→ WALK_PLANNING → WALKING(自动走到远处起点) * PLANNING(分片铁轨寻路)→ WALK_PLANNING → WALKING(自动走到远处起点)
* → LAYING(行走铺轨)→ DONE / FAILED。 * → LAYING(行走铺轨)→ PATROLLING(沿线巡检)→ DONE / FAILED。
* 步行走不到起点时不报错放弃:自动“换道”——放弃原步行路线,
* 从玩家当前位置直接重新规划线路铺到目标(就近落地,等效换道)。
* LAYING 期间自主行为(低血量避险、饥饿进食)由 {@link AutonomyController} 接管。 * LAYING 期间自主行为(低血量避险、饥饿进食)由 {@link AutonomyController} 接管。
*/ */
public class AutoRailService { 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 寻路展开预算(节点数)。 */ /** 每 tick 寻路展开预算(节点数)。 */
private static final int PLAN_BUDGET_PER_TICK = 4096; private static final int PLAN_BUDGET_PER_TICK = 4096;
@@ -35,20 +39,35 @@ public class AutoRailService {
private RailExecutor executor; private RailExecutor executor;
private WalkPathfinder walkPlanner; private WalkPathfinder walkPlanner;
private WalkExecutor walker; private WalkExecutor walker;
private PatrolExecutor patrol;
private final AutonomyController autonomy = new AutonomyController(); private final AutonomyController autonomy = new AutonomyController();
private String failKey; private String failKey;
private boolean wasWaiting; private boolean wasWaiting;
/** 最近一次铺设的路径(巡检用)。 */
private List<RailStep> lastPath;
public State getState() { public State getState() {
return state; 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<BlockPos> checkpoints) { public void begin(ClientWorld world, ClientPlayerEntity player, List<BlockPos> checkpoints) {
planner = new RailPathfinder(world, checkpoints); planner = new RailPathfinder(world, checkpoints);
plan = null; plan = null;
executor = null; executor = null;
walkPlanner = null; walkPlanner = null;
walker = null; walker = null;
patrol = null;
lastPath = null;
failKey = null; failKey = null;
wasWaiting = false; wasWaiting = false;
autonomy.releaseKeys(client()); autonomy.releaseKeys(client());
@@ -56,6 +75,20 @@ public class AutoRailService {
chat(player, "autorail.msg.plan.start", checkpoints.size()); 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) { public void stop(ClientPlayerEntity player) {
if (executor != null) { if (executor != null) {
executor.releaseKeys(); executor.releaseKeys();
@@ -63,6 +96,9 @@ public class AutoRailService {
if (walker != null) { if (walker != null) {
walker.releaseKeys(); walker.releaseKeys();
} }
if (patrol != null) {
patrol.releaseKeys();
}
autonomy.releaseKeys(client()); autonomy.releaseKeys(client());
boolean wasActive = state != State.IDLE && state != State.DONE && state != State.FAILED; boolean wasActive = state != State.IDLE && state != State.DONE && state != State.FAILED;
planner = null; planner = null;
@@ -70,6 +106,7 @@ public class AutoRailService {
executor = null; executor = null;
walkPlanner = null; walkPlanner = null;
walker = null; walker = null;
patrol = null;
state = State.IDLE; state = State.IDLE;
if (wasActive && player != null) { if (wasActive && player != null) {
chat(player, "autorail.msg.stopped"); chat(player, "autorail.msg.stopped");
@@ -92,7 +129,7 @@ public class AutoRailService {
} }
AutoRailConfig cfg = AutoRailConfig.get(); AutoRailConfig cfg = AutoRailConfig.get();
// 自主行为(紧急避险/自动进食)优先:接管本 tick,铺轨暂停 // 自主行为(紧急避险/自动进食)优先:接管本 tick,作业暂停
String act = autonomy.tick(client, player, cfg); String act = autonomy.tick(client, player, cfg);
if (act != null) { if (act != null) {
if (executor != null) { if (executor != null) {
@@ -101,6 +138,9 @@ public class AutoRailService {
if (walker != null) { if (walker != null) {
walker.releaseKeys(); walker.releaseKeys();
} }
if (patrol != null) {
patrol.releaseKeys();
}
if ("flee".equals(act) && wasActiveState()) { if ("flee".equals(act) && wasActiveState()) {
chat(player, "autorail.msg.flee"); chat(player, "autorail.msg.flee");
} else if ("eat".equals(act) && wasActiveState()) { } else if ("eat".equals(act) && wasActiveState()) {
@@ -114,64 +154,85 @@ public class AutoRailService {
switch (state) { switch (state) {
case PLANNING -> tickPlanning(client, player); case PLANNING -> tickPlanning(client, player);
case WALK_PLANNING -> tickWalkPlanning(client); case WALK_PLANNING -> tickWalkPlanning(client, player);
case WALKING -> tickWalking(client); case WALKING -> tickWalking(client, player);
case LAYING -> tickLaying(client, player); case LAYING -> tickLaying(client, player);
case PATROLLING -> tickPatrol(client, player);
default -> { } default -> { }
} }
} }
private boolean wasActiveState() { private boolean wasActiveState() {
return state == State.PLANNING || state == State.WALK_PLANNING 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) { private void tickPlanning(MinecraftClient client, ClientPlayerEntity player) {
planner.step(PLAN_BUDGET_PER_TICK); planner.step(PLAN_BUDGET_PER_TICK);
if (planner.getState() == RailPathfinder.State.FOUND) { if (planner.getState() == RailPathfinder.State.FOUND) {
List<RailStep> steps = planner.getResult(); List<RailStep> steps = planner.getResult();
lastPath = List.copyOf(steps);
plan = RailPlan.plan(steps, client.world, AutoRailConfig.get()); plan = RailPlan.plan(steps, client.world, AutoRailConfig.get());
executor = new RailExecutor(client.world, steps, plan, AutoRailConfig.get()); executor = new RailExecutor(client.world, steps, plan, AutoRailConfig.get());
BlockPos walkTarget = planner.getWalkTarget(); BlockPos walkTarget = planner.getWalkTarget();
if (walkTarget != null && isFar(player, walkTarget)) { if (walkTarget != null && isFar(player, walkTarget)) {
// 起点较远:先自动走过去 // 起点较远:先自动走过去(走不到会换道,见 tickWalkPlanning)
walkPlanner = new WalkPathfinder(client.world, player.getBlockPos(), walkTarget); walkPlanner = new WalkPathfinder(client.world, player.getBlockPos(), walkTarget);
state = State.WALK_PLANNING; state = State.WALK_PLANNING;
chat(player, "autorail.msg.walk.start"); chat(player, "autorail.msg.walk.start");
} else { } else {
state = State.LAYING; 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) { } else if (planner.getState() == RailPathfinder.State.FAILED) {
fail(client.player, planner.getFailKey()); fail(client.player, planner.getFailKey());
} }
} }
private void tickWalkPlanning(MinecraftClient client) { private void tickWalkPlanning(MinecraftClient client, ClientPlayerEntity player) {
walkPlanner.step(WALK_BUDGET_PER_TICK); walkPlanner.step(WALK_BUDGET_PER_TICK);
if (walkPlanner.getState() == WalkPathfinder.State.FOUND) { if (walkPlanner.getState() == WalkPathfinder.State.FOUND) {
walker = new WalkExecutor(client.world, walkPlanner.getResult()); walker = new WalkExecutor(client.world, walkPlanner.getResult());
walkPlanner = null; walkPlanner = null;
state = State.WALKING; state = State.WALKING;
} else if (walkPlanner.getState() == WalkPathfinder.State.FAILED) { } 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); walker.tick(client);
if (walker.isArrived()) { if (walker.isArrived()) {
walker.releaseKeys(); walker.releaseKeys();
walker = null; walker = null;
state = State.LAYING; state = State.LAYING;
chat(client.player, "autorail.msg.walk.done"); chat(player, "autorail.msg.walk.done");
} else if (walker.isFailed()) { } 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) { private void tickLaying(MinecraftClient client, ClientPlayerEntity player) {
executor.tick(client); 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) { private static boolean isFar(ClientPlayerEntity player, BlockPos target) {
double dx = target.getX() + 0.5 - player.getX(); double dx = target.getX() + 0.5 - player.getX();
double dz = target.getZ() + 0.5 - player.getZ(); double dz = target.getZ() + 0.5 - player.getZ();
@@ -206,12 +305,16 @@ public class AutoRailService {
if (walker != null) { if (walker != null) {
walker.releaseKeys(); walker.releaseKeys();
} }
if (patrol != null) {
patrol.releaseKeys();
}
autonomy.releaseKeys(client()); autonomy.releaseKeys(client());
planner = null; planner = null;
plan = null; plan = null;
executor = null; executor = null;
walkPlanner = null; walkPlanner = null;
walker = null; walker = null;
patrol = null;
state = State.IDLE; state = State.IDLE;
} }
@@ -224,6 +327,9 @@ public class AutoRailService {
if (walker != null) { if (walker != null) {
walker.releaseKeys(); walker.releaseKeys();
} }
if (patrol != null) {
patrol.releaseKeys();
}
autonomy.releaseKeys(client()); autonomy.releaseKeys(client());
if (player != null) { if (player != null) {
chat(player, key); chat(player, key);
@@ -245,12 +351,14 @@ public class AutoRailService {
yield Text.translatable("autorail.gui.status.idle"); yield Text.translatable("autorail.gui.status.idle");
} }
if (executor.isWaiting()) { if (executor.isWaiting()) {
long sec = executor.getWaitSeconds(); yield Text.translatable("autorail.gui.status.waiting", executor.getWaitSeconds());
yield Text.translatable("autorail.gui.status.waiting", sec);
} }
yield Text.translatable("autorail.gui.status.laying", yield Text.translatable("autorail.gui.status.laying",
executor.getPlaced(), executor.getTotal()); 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") case DONE -> executor == null ? Text.translatable("autorail.gui.status.idle")
: Text.translatable("autorail.gui.status.done", executor.getTotal()); : Text.translatable("autorail.gui.status.done", executor.getTotal());
case FAILED -> Text.translatable("autorail.gui.status.failed", case FAILED -> Text.translatable("autorail.gui.status.failed",
@@ -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<RailStep> 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<CellReport> reports = new java.util.ArrayList<>();
public PatrolExecutor(ClientWorld world, List<RailStep> 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<CellReport> 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);
}
}
@@ -1,9 +1,16 @@
package com.autorail.service; package com.autorail.service;
import com.autorail.pathing.WalkCell;
import net.minecraft.block.BlockState;
import net.minecraft.client.MinecraftClient; import net.minecraft.client.MinecraftClient;
import net.minecraft.client.network.ClientPlayerEntity; import net.minecraft.client.network.ClientPlayerEntity;
import net.minecraft.client.world.ClientWorld; 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.BlockPos;
import net.minecraft.util.math.Direction;
import net.minecraft.util.math.MathHelper; import net.minecraft.util.math.MathHelper;
import net.minecraft.util.math.Vec3d; import net.minecraft.util.math.Vec3d;
@@ -11,26 +18,31 @@ import java.util.List;
/** /**
* 步行执行器:沿 WalkPathfinder 的格点路径控制玩家行走(转向/前进/跳跃), * 步行执行器:沿 WalkPathfinder 的格点路径控制玩家行走(转向/前进/跳跃),
* 用于把玩家自动带到远处的铺轨起点。只行走、不放置。 * 用于把玩家自动带到远处的铺轨起点。
* 遇到标记为“搭桥”的格子(水面/悬空)会先垫圆石成型再前进;
* 圆石耗尽或无支点可垫时失败,由服务层换道续铺兜底。
*/ */
public class WalkExecutor { public class WalkExecutor {
private static final int WALK_AHEAD = 2; 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 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_JUMP_TICKS = 15;
private static final int STUCK_FAIL_TICKS = 160; private static final int STUCK_FAIL_TICKS = 160;
private static final float MAX_TURN_PER_TICK = 60.0f; private static final float MAX_TURN_PER_TICK = 60.0f;
private final List<BlockPos> cells; private final List<WalkCell> cells;
private int idx = 1; private int idx = 1;
private long tickCounter; private long tickCounter;
private long lastActionTick = -ACTION_INTERVAL - 1L;
private long stuckSince = -1; private long stuckSince = -1;
private int jumpTicks; private int jumpTicks;
private int lastIdx; private int lastIdx;
private boolean arrived; private boolean arrived;
private String failKey; private String failKey;
public WalkExecutor(ClientWorld world, List<BlockPos> cells) { public WalkExecutor(ClientWorld world, List<WalkCell> cells) {
this.cells = cells; this.cells = cells;
} }
@@ -69,7 +81,7 @@ public class WalkExecutor {
return; return;
} }
BlockPos t = cells.get(idx); BlockPos t = cells.get(idx).pos();
double dx = t.getX() + 0.5 - player.getX(); double dx = t.getX() + 0.5 - player.getX();
double dz = t.getZ() + 0.5 - player.getZ(); double dz = t.getZ() + 0.5 - player.getZ();
double hSq = dx * dx + dz * dz; double hSq = dx * dx + dz * dz;
@@ -85,6 +97,16 @@ public class WalkExecutor {
return; 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); Vec3d center = new Vec3d(t.getX() + 0.5, t.getY() + 0.4, t.getZ() + 0.5);
aimAt(player, center); aimAt(player, center);
@@ -101,7 +123,7 @@ public class WalkExecutor {
} }
client.options.forwardKey.setPressed(true); client.options.forwardKey.setPressed(true);
// 卡住检测:先跳跃自救,再报错 // 卡住检测:先跳跃自救,再报错(由服务层换道兜底)
if (idx == lastIdx && player.isOnGround() && speedSq(player) < 2.0e-4 && !needJump) { if (idx == lastIdx && player.isOnGround() && speedSq(player) < 2.0e-4 && !needJump) {
if (stuckSince < 0) { if (stuckSince < 0) {
stuckSince = tickCounter; stuckSince = tickCounter;
@@ -119,6 +141,57 @@ public class WalkExecutor {
lastIdx = idx; 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() { public void releaseKeys() {
MinecraftClient mc = MinecraftClient.getInstance(); MinecraftClient mc = MinecraftClient.getInstance();
if (mc == null || mc.options == null) { if (mc == null || mc.options == null) {
@@ -128,6 +201,17 @@ public class WalkExecutor {
mc.options.jumpKey.setPressed(false); 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) { private static double speedSq(ClientPlayerEntity player) {
Vec3d v = player.getVelocity(); Vec3d v = player.getVelocity();
return v.x * v.x + v.z * v.z; return v.x * v.x + v.z * v.z;
@@ -154,4 +238,31 @@ public class WalkExecutor {
} }
return MathHelper.wrapDegrees(cur + d); 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);
}
} }
@@ -29,12 +29,14 @@
"autorail.gui.settings.reset": "Defaults", "autorail.gui.settings.reset": "Defaults",
"autorail.gui.settings.badNumber": "Bad number, or torch interval exceeds powered interval", "autorail.gui.settings.badNumber": "Bad number, or torch interval exceeds powered interval",
"autorail.gui.settings.saved": "Saved to config/autorail.json", "autorail.gui.settings.saved": "Saved to config/autorail.json",
"autorail.gui.patrol": "Patrol line",
"autorail.gui.status.idle": "Idle", "autorail.gui.status.idle": "Idle",
"autorail.gui.status.planning": "Pathfinding… {0} nodes opened", "autorail.gui.status.planning": "Pathfinding… {0} nodes opened",
"autorail.gui.status.walkPlanning": "Planning walk route… {0} nodes", "autorail.gui.status.walkPlanning": "Planning walk route… {0} nodes",
"autorail.gui.status.walking": "Walking to start… {0} / {1}", "autorail.gui.status.walking": "Walking to start… {0} / {1}",
"autorail.gui.status.laying": "Laying rails… {0} / {1}", "autorail.gui.status.laying": "Laying rails… {0} / {1}",
"autorail.gui.status.waiting": "Waiting for materials… {0}s left", "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.done": "Done! {0} rails placed",
"autorail.gui.status.failed": "Failed: {0}", "autorail.gui.status.failed": "Failed: {0}",
"autorail.err.needWorld": "Enter a world first", "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.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.walkStuck": "Stuck on the way to the start - clear the obstacle and retry",
"autorail.err.waitTimeout": "Material wait timed out - stopped", "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.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.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", "autorail.msg.lay.done": "Done! {0} rails placed",
@@ -29,12 +29,14 @@
"autorail.gui.settings.reset": "恢复默认", "autorail.gui.settings.reset": "恢复默认",
"autorail.gui.settings.badNumber": "数值格式错误或火把间隔大于动力轨间隔", "autorail.gui.settings.badNumber": "数值格式错误或火把间隔大于动力轨间隔",
"autorail.gui.settings.saved": "设置已保存到 config/autorail.json", "autorail.gui.settings.saved": "设置已保存到 config/autorail.json",
"autorail.gui.patrol": "沿线巡检",
"autorail.gui.status.idle": "待机中", "autorail.gui.status.idle": "待机中",
"autorail.gui.status.planning": "寻路中… 已展开 {0} 个节点", "autorail.gui.status.planning": "寻路中… 已展开 {0} 个节点",
"autorail.gui.status.walkPlanning": "规划步行路线… {0} 个节点", "autorail.gui.status.walkPlanning": "规划步行路线… {0} 个节点",
"autorail.gui.status.walking": "自动走向起点… {0} / {1}", "autorail.gui.status.walking": "自动走向起点… {0} / {1}",
"autorail.gui.status.laying": "铺轨中… {0} / {1}", "autorail.gui.status.laying": "铺轨中… {0} / {1}",
"autorail.gui.status.waiting": "等待材料中… 剩余 {0} 秒,请补充到快捷栏", "autorail.gui.status.waiting": "等待材料中… 剩余 {0} 秒,请补充到快捷栏",
"autorail.gui.status.patrol": "巡检中… {0} / {1},发现问题 {2} 处",
"autorail.gui.status.done": "完成!共铺设 {0} 格铁轨", "autorail.gui.status.done": "完成!共铺设 {0} 格铁轨",
"autorail.gui.status.failed": "失败:{0}", "autorail.gui.status.failed": "失败:{0}",
"autorail.err.needWorld": "请先进入世界", "autorail.err.needWorld": "请先进入世界",
@@ -55,6 +57,17 @@
"autorail.err.walkFail": "无法自动走到起点(地形过远或过险),请手动接近后重试", "autorail.err.walkFail": "无法自动走到起点(地形过远或过险),请手动接近后重试",
"autorail.err.walkStuck": "前往起点途中卡住,请清理障碍后重试", "autorail.err.walkStuck": "前往起点途中卡住,请清理障碍后重试",
"autorail.err.waitTimeout": "等待材料超时,已自动停止", "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.start": "开始寻路({0} 个检查点)…",
"autorail.msg.plan.done": "路径规划完成:{0} 格(动力轨 {1}、探测轨 {2}、火把 {3}),开始铺轨(请勿打开其他界面)", "autorail.msg.plan.done": "路径规划完成:{0} 格(动力轨 {1}、探测轨 {2}、火把 {3}),开始铺轨(请勿打开其他界面)",
"autorail.msg.lay.done": "铺轨完成!共 {0} 格", "autorail.msg.lay.done": "铺轨完成!共 {0} 格",