feat: 沿线巡检+步行受阻换道续铺+水面悬崖自动搭桥
这个提交包含在:
@@ -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} 格",
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户