feat: 电气化间隔放置+自动走近起点+材料预案+避险进食

这个提交包含在:
lyizai
2026-09-03 21:42:17 +08:00
父节点 d8d7723110
当前提交 3bd9a41d63
共修改 15 个文件,包含 1443 行新增和 87 行删除
+52 -20
查看文件
@@ -1,40 +1,71 @@
# AutoRail — 自动铺铁轨(Fabric 1.21.4,内置寻路) # AutoRail — 自动铺铁轨工业模组(Fabric 1.21.4,内置寻路)
按住 **R+T** 打开菜单,输入**起始点**、**目标点**和必经的**途径点**,模组用内置 A* 寻路 按住 **R+T** 打开菜单,输入**起始点**、**目标点**和必经的**途径点**,模组用内置 A* 寻路
规划一条可铺轨的路线,然后控制玩家边走边铺,直到目标点。**不依赖 Baritone**,寻路与行走全部内置。 规划一条可铺轨的路线,然后控制玩家自动走到起点、边走边铺,直到目标点。
**不依赖 Baritone**,寻路与行走全部内置;间隔放置**动力铁轨/探测铁轨/红石火把**,
带**材料不足预案、低血量紧急避险、自动进食**,是完整的自动化铺轨流水线。
## 使用 ## 使用
1. 安装 [Fabric Loader ≥0.16.9](https://fabricmc.net/use/) 与 [Fabric API](https://modrinth.com/mod/fabric-api),把 `autorail-<version>.jar` 放进 `mods/`。 1. 安装 [Fabric Loader ≥0.16.9](https://fabricmc.net/use/) 与 [Fabric API](https://modrinth.com/mod/fabric-api),把 `autorail-<version>.jar` 放进 `mods/`。
2. 进世界后按住 **R+T** 打开菜单(先按 T 弹出聊天栏也会被菜单替换掉,正常现象)。 2. 进世界后按住 **R+T** 打开菜单(先按 T 弹出聊天栏也会被菜单替换掉,正常现象)。
3. 起始点留空 = 当前坐标;目标点必填;途径点可添加多个(按顺序经过)。 3. 起始点留空 = 当前坐标;**目标点必填**;途径点可添加多个(按顺序经过)。
坐标格式支持:`100 64 -200`(x y z)、`100 -200`(x z,自动找地面)、`~`、`~ 10 ~`、`~-5` 等相对写法。 坐标格式支持:`100 64 -200`(x y z)、`100 -200`(x z,自动找地面)、`~`、`~ 10 ~`、`~-5` 等相对写法。
4. 点“开始铺轨”:先分片寻路(菜单/聊天栏显示进度,不卡游戏),完成后自动开始铺设(菜单自动关闭)。 4. 点“开始铺轨”:先规划铁轨路线;若起点较远,模组会**自动步行走到起点**再开始铺设
5. 快捷栏需要**铁轨**;遇到悬空/水面会自动用**圆石**垫脚,没有则提示并停止。 (全程菜单/聊天栏显示进度,不卡游戏)。
5. 快捷栏需要**铁轨**;启用电气化后还需要**动力铁轨**(和按规则插的**红石火把**/**探测铁轨**);
遇到悬空/水面会自动用**圆石**垫脚,没有则按预案处理。
6. 铺轨过程中不要打开聊天/背包等界面(行走会暂停);再按 R+T 可查看进度或点“停止”。 6. 铺轨过程中不要打开聊天/背包等界面(行走会暂停);再按 R+T 可查看进度或点“停止”。
## 放置规则(电气化)
在菜单里点 **“设置…”** 配置,保存到 `config/autorail.json`:
| 设置项 | 默认 | 说明 |
| --- | --- | --- |
| 动力铁轨间隔 | 16 格 | 每 N 格放 1 根动力铁轨(0 = 不放置) |
| 探测铁轨间隔 | 0(关) | 每 N 格放 1 个探测铁轨,矿车压过自动激活相邻动力轨 |
| 红石火把间隔 | 12 根动力轨 | 每 N 根动力铁轨旁的支撑方块侧面插 1 支火把持续供电 |
| 材料不足预案 | 等待补充 | **等待**:原地暂停等快捷栏补料(超时可配);**跳过**:缺火把/特殊轨就先铺普通轨保通 |
| 等待超时 | 60 秒 | 等待材料超过该时长自动停止并提示 |
| 低血量避险阈值 | 6 | 生命值低于阈值立即停下后退脱险(0 = 关闭) |
| 自动进食阈值 | 16 | 饥饿度低于阈值自动吃快捷栏里的食物(0 = 关闭) |
动力铁轨默认由旁边的红石火把持续供电;探测铁轨方案则适合“矿车压过才加速”的线路。
## 自动安全系统(铺轨期间后台运行)
- **低血量紧急避险**:生命值 ≤ 阈值 → 松开前进、后退 + 跳跃拉开距离,恢复后自动回到岗位。
- **自动进食**:饥饿 ≤ 阈值且血量未满 → 切换到快捷栏食物并长按使用键进食,吃完自动恢复铺轨。
- **材料不足预案**:铁轨/圆石/火把缺货时按“等待/跳过”预案执行,不会卡死也不会铺出断头路。
- **卡住自救**:行走受阻先跳跃脱困,持续卡住才报错停止;放置失败自动重试。
## 内置寻路(A*) ## 内置寻路(A*)
- 专为铁轨设计:4 向移动、允许 ±1 格坡度、禁止掉头、禁止“转弯+坡度”同格(原版没有斜弯轨)。 - **铁轨寻路**:4 向移动、±1 格坡度、禁止掉头、“转弯+坡度”同格禁止(原版没有斜弯轨);
- 每个节点校验:上方两格可通行(玩家沿线行走)、下方有实心支撑或可用圆石垫补、避开水面/岩浆。 每节点校验头顶两格可通行、下方支撑可垫补,避开水面/岩浆;转弯加价、复用既有铁轨打折。
- 转弯有额外代价(尽量走直线);已有铁轨可复用且有折扣,优先延伸既有线路。 - **步行寻路**(去远处起点):4 向 + 跳 1 格 + 落 3 格 + 可涉水,走不到会明确报错。
- 分片执行(每 tick 限额展开 4096 节点);搜索范围限制在检查点包围盒外扩 48 格,总展开上限 40 万节点。 - 分片执行(每 tick 限额展开),不卡客户端线程。
## 铺设(合法交互) ## 铺设(合法交互)
- 切换快捷栏 + `processRightClickBlock` 右键放置,**全程合法交互,绝不 `world.setBlockState`**,多人服务器可信任。 - 切换快捷栏 + `processRightClickBlock` 右键放置,**全程合法交互,绝不 `world.setBlockState`**,多人服务器可信任。
- 放置节奏 200ms 一次;放置前自动看向目标方块;上坡自动跳跃;卡住自动检测并停止报错。 - 放置节奏 200ms 一次;放置前自动看向目标方块;上坡自动跳跃;卡住自动检测。
- 行走只用客户端输入(前进/跳跃键 + 平滑转向),路径按“已铺设节点”推进,不会踩空。
## 代码结构 ## 代码结构
```text ```text
com.autorail com.autorail
├─ AutoRailClient # 入口:R+T 组合键 + 每 tick 驱动服务 ├─ AutoRailClient # 入口:R+T 组合键 + 每 tick 驱动服务
├─ gui/AutoRailScreen # 菜单:起点/终点/途径点输入与状态显示 ├─ gui/AutoRailScreen # 主菜单:起点/终点/途径点 + 设置入口
├─ service/AutoRailService # 状态机:PLANNING → LAYING → DONE/FAILED ├─ gui/AutoRailSettingsScreen # 设置:放置规则 / 预案 / 安全阈值
├─ service/RailExecutor # 执行器:行走控制 + 合法放置铁轨/圆石 ├─ config/AutoRailConfig # Gson 持久化配置(config/autorail.json)
└─ pathing/ # 内置寻路:RailPathfinder(A*)、RailStep、CoordParser ├─ service/AutoRailService # 状态机:PLANNING → WALKING → LAYING → DONE/FAILED
├─ service/RailExecutor # 执行器:行走控制 + 合法放置铁轨/圆石/火把
├─ service/WalkExecutor # 步行执行器:把玩家带到远处起点
├─ service/AutonomyController # 自主行为:自动进食 / 低血量避险
├─ service/RailPlan # 把间隔规则展开成逐节点放置计划
└─ pathing/ # 内置寻路:RailPathfinder、WalkPathfinder、RailStep、CoordParser
``` ```
## 开发 ## 开发
@@ -45,8 +76,9 @@ gradlew build # 产物在 build/libs/autorail-<version>.jar
## 已知限制 ## 已知限制
- 只铺普通铁轨(`minecraft:rail`),不铺动力铁轨/探测铁轨。 - 复用既有铁轨不校验类型:老线路上的普通铁轨不会自动升级为动力铁轨。
- 不破坏、不放置除铁轨/圆石外的方块:遇到障碍会绕路而不是挖穿。 - 不破坏、不放置除铁轨/圆石/红石火把外的方块:遇到障碍会绕路而不是挖穿。
- 起始点必须在玩家附近(水平 6 格、垂直 8 格以内)——本模组不含到远处的自由行走导航。 - 遇水会垫圆石架桥,岩浆列视为不可通行。
- 遇水会垫圆石架桥,但岩浆列直接视为不可通行。 - 红石火把在支撑方块侧面放置;若点位被后续地形占用,该处动力轨无供电(线路仍连通)。
- 自动进食吃的是快捷栏里的食物;食物全靠自带,模组不会杀生取食。
- 基于客户端世界状态判定;服务器延迟时个别放置会失败,会自动重试。 - 基于客户端世界状态判定;服务器延迟时个别放置会失败,会自动重试。
@@ -0,0 +1,85 @@
package com.autorail.config;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import net.fabricmc.loader.api.FabricLoader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* 模组配置(Gson 持久化到 config/autorail.json)。
*
* 放置规则(动力铁轨/探测铁轨/红石火把)由用户在 GUI 里调整后保存。
* 用户属性可随时覆盖加载后的配置(不要直接改字段)。
*/
public class AutoRailConfig {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final String FILE_NAME = "autorail.json";
// ===== 放置规则 =====
/** 每 N 格普通铁轨放 1 根动力铁轨(1 = 每 2 格 1 根,即标准满速线),<=0 表示不放。 */
public int poweredEvery = 16;
/** 每 N 格普通铁轨放 1 个探测铁轨(<=0 表示不放)。 */
public int detectorEvery = 0;
/** 每 N 格动力铁轨旁放 1 支红石火把(<=0 表示不放)。 */
public int torchEvery = 12;
// ===== 材料不足预案 =====
/** 材料不足时:WAIT(原地等待补充)或 SKIP(跳过特殊件继续铺)。 */
public MissingPolicy missingPolicy = MissingPolicy.WAIT;
/** WAIT 策略下最长等待秒数,超时自动停止。 */
public int maxWaitSeconds = 60;
// ===== 安全 =====
/** 生命值低于该值(0-20)进入紧急避险,<=0 关闭。 */
public float lowHealthThreshold = 6.0f;
/** 饥饿值低于该值(0-20)开始进食,<=0 关闭。 */
public int eatBelow = 16;
// ===== 保存 / 加载 =====
private static AutoRailConfig instance;
public static AutoRailConfig get() {
if (instance == null) {
instance = load();
}
return instance;
}
/** 测试与覆盖加载用。 */
public static void setInstance(AutoRailConfig cfg) {
instance = cfg;
}
private static Path file() {
return FabricLoader.getInstance().getConfigDir().resolve(FILE_NAME);
}
public static AutoRailConfig load() {
Path f = file();
if (Files.exists(f)) {
try {
String json = Files.readString(f, StandardCharsets.UTF_8);
return GSON.fromJson(json, AutoRailConfig.class);
} catch (Exception e) {
// 配置文件损坏时退回默认值,不让模组崩掉
}
}
return new AutoRailConfig();
}
public void save() {
try {
Path f = file();
Files.createDirectories(f.getParent());
Files.writeString(f, GSON.toJson(this), StandardCharsets.UTF_8);
} catch (IOException ignored) {
// 保存失败不影响游戏;下次改动会重试
}
}
}
@@ -0,0 +1,10 @@
package com.autorail.config;
/**
* 材料不足时的预案(用户可配置)。
* WAIT:原地等待玩家补充材料;SKIP:跳过该特殊件继续铺普通铁轨。
*/
public enum MissingPolicy {
WAIT,
SKIP
}
@@ -91,12 +91,15 @@ public class AutoRailScreen extends Screen {
btnY = this.height - 52; btnY = this.height - 52;
beginBtn = addDrawableChild(ButtonWidget.builder(Text.translatable("autorail.gui.begin"), b -> onBegin()) beginBtn = addDrawableChild(ButtonWidget.builder(Text.translatable("autorail.gui.begin"), b -> onBegin())
.dimensions(cx - 141, btnY, 90, 20).build()); .dimensions(cx - 187, btnY, 90, 20).build());
stopBtn = addDrawableChild(ButtonWidget.builder(Text.translatable("autorail.gui.stop"), b -> { stopBtn = addDrawableChild(ButtonWidget.builder(Text.translatable("autorail.gui.stop"), b -> {
AutoRailClient.SERVICE.stop(this.client == null ? null : this.client.player); AutoRailClient.SERVICE.stop(this.client == null ? null : this.client.player);
}).dimensions(cx - 45, btnY, 90, 20).build()); }).dimensions(cx - 91, btnY, 90, 20).build());
addDrawableChild(ButtonWidget.builder(Text.translatable("autorail.gui.close"), b -> close()) addDrawableChild(ButtonWidget.builder(Text.translatable("autorail.gui.close"), b -> close())
.dimensions(cx + 51, btnY, 90, 20).build()); .dimensions(cx + 5, btnY, 90, 20).build());
addDrawableChild(ButtonWidget.builder(Text.translatable("autorail.gui.settings"), b -> {
this.client.setScreen(new AutoRailSettingsScreen(this));
}).dimensions(cx + 101, btnY, 90, 20).build());
statusY = this.height - 30; statusY = this.height - 30;
hintY = this.height - 18; hintY = this.height - 18;
@@ -118,10 +121,17 @@ public class AutoRailScreen extends Screen {
beginBtn.active = st == AutoRailService.State.IDLE beginBtn.active = st == AutoRailService.State.IDLE
|| st == AutoRailService.State.FAILED || st == AutoRailService.State.FAILED
|| st == AutoRailService.State.DONE; || st == AutoRailService.State.DONE;
stopBtn.active = st == AutoRailService.State.PLANNING || st == AutoRailService.State.LAYING; stopBtn.active = st == AutoRailService.State.PLANNING
|| st == AutoRailService.State.WALK_PLANNING
|| st == AutoRailService.State.WALKING
|| st == AutoRailService.State.LAYING;
addBtn.active = waypointTexts.size() < Math.min(MAX_WAYPOINTS, Math.max(1, maxVisible)); addBtn.active = waypointTexts.size() < Math.min(MAX_WAYPOINTS, Math.max(1, maxVisible));
// 寻路完成进入铺设时自动关闭界面(行走需要关闭界面) // 规划/步行完成进入铺设时自动关闭界面(行走需要关闭界面)
if (prevState == AutoRailService.State.PLANNING && st == AutoRailService.State.LAYING) { boolean busy = st == AutoRailService.State.LAYING;
boolean wasBusy = prevState == AutoRailService.State.LAYING;
if (!wasBusy && busy && prevState != AutoRailService.State.IDLE
&& prevState != AutoRailService.State.DONE
&& prevState != AutoRailService.State.FAILED) {
this.client.setScreen(null); this.client.setScreen(null);
return; return;
} }
@@ -139,12 +149,6 @@ public class AutoRailScreen extends Screen {
var world = this.client.world; var world = this.client.world;
try { try {
BlockPos start = CoordParser.parse(startText.isBlank() ? "~" : startText, player, world); BlockPos start = CoordParser.parse(startText.isBlank() ? "~" : startText, player, world);
double dx = start.getX() - player.getBlockX();
double dz = start.getZ() - player.getBlockZ();
if (dx * dx + dz * dz > 36 || Math.abs(start.getY() - player.getBlockY()) > 8) {
error = Text.translatable("autorail.err.startFar");
return;
}
BlockPos dest = CoordParser.parse(destText, player, world); BlockPos dest = CoordParser.parse(destText, player, world);
List<BlockPos> cps = new ArrayList<>(); List<BlockPos> cps = new ArrayList<>();
cps.add(start); cps.add(start);
@@ -0,0 +1,170 @@
package com.autorail.gui;
import com.autorail.config.AutoRailConfig;
import com.autorail.config.MissingPolicy;
import net.minecraft.client.gui.DrawContext;
import net.minecraft.client.gui.screen.Screen;
import net.minecraft.client.gui.widget.ButtonWidget;
import net.minecraft.client.gui.widget.TextFieldWidget;
import net.minecraft.text.Text;
/**
* 设置界面:放置规则(动力轨/探测轨/火把间隔)、材料不足预案、安全阈值。
* 点“保存”写入 config/autorail.json 并返回主菜单。
*/
public class AutoRailSettingsScreen extends Screen {
private static final int FIELD_W = 150;
private static final int FIELD_H = 18;
private final Screen parent;
private Text error;
private TextFieldWidget poweredField;
private TextFieldWidget detectorField;
private TextFieldWidget torchField;
private TextFieldWidget lowHealthField;
private TextFieldWidget eatBelowField;
private TextFieldWidget maxWaitField;
private ButtonWidget policyBtn;
private MissingPolicy policySel;
private AutoRailConfig cfg;
private int rowY(int r) {
return 56 + r * 26;
}
public AutoRailSettingsScreen(Screen parent) {
super(Text.translatable("autorail.gui.settings.title"));
this.parent = parent;
}
@Override
protected void init() {
cfg = AutoRailConfig.get();
policySel = cfg.missingPolicy;
int cx = this.width / 2;
int lx = cx - FIELD_W - 12;
int rx = cx + 12;
poweredField = addNum(lx, rowY(0), "autorail.gui.settings.powered", cfg.poweredEvery);
detectorField = addNum(rx, rowY(0), "autorail.gui.settings.detector", cfg.detectorEvery);
torchField = addNum(lx, rowY(1), "autorail.gui.settings.torch", cfg.torchEvery);
lowHealthField = addNum(lx, rowY(2), "autorail.gui.settings.lowHealth", Math.round(cfg.lowHealthThreshold));
eatBelowField = addNum(rx, rowY(1), "autorail.gui.settings.eatBelow", cfg.eatBelow);
maxWaitField = addNum(rx, rowY(2), "autorail.gui.settings.maxWait", cfg.maxWaitSeconds);
policyBtn = addDrawableChild(ButtonWidget.builder(policyLabel(), b -> {
policySel = policySel == MissingPolicy.WAIT ? MissingPolicy.SKIP : MissingPolicy.WAIT;
policyBtn.setMessage(policyLabel());
}).dimensions(lx, rowY(3), FIELD_W, FIELD_H).build());
int btnY = this.height - 46;
addDrawableChild(ButtonWidget.builder(Text.translatable("autorail.gui.settings.save"), b -> onSave())
.dimensions(cx - 141, btnY, 90, 20).build());
addDrawableChild(ButtonWidget.builder(Text.translatable("autorail.gui.close"), b -> close())
.dimensions(cx - 45, btnY, 90, 20).build());
addDrawableChild(ButtonWidget.builder(Text.translatable("autorail.gui.settings.reset"), b -> onReset())
.dimensions(cx + 51, btnY, 90, 20).build());
}
private TextFieldWidget addNum(int x, int y, String labelKey, int value) {
TextFieldWidget f = new TextFieldWidget(this.textRenderer, x, y, FIELD_W, FIELD_H,
Text.translatable(labelKey));
f.setText(String.valueOf(value));
return addDrawableChild(f);
}
private Text policyLabel() {
return Text.translatable(policySel == MissingPolicy.WAIT
? "autorail.gui.settings.policyWait" : "autorail.gui.settings.policySkip");
}
private void onSave() {
error = null;
try {
int powered = clamp(parse(poweredField), 0, 64);
int detector = clamp(parse(detectorField), 0, 64);
int torch = clamp(parse(torchField), 0, 64);
int lowHealth = clamp(parse(lowHealthField), 0, 20);
int eatBelow = clamp(parse(eatBelowField), 0, 19);
int maxWait = clamp(parse(maxWaitField), 10, 600);
if (powered > 0 && torch > 0 && torch > powered) {
throw err();
}
cfg.poweredEvery = powered;
cfg.detectorEvery = detector;
cfg.torchEvery = powered > 0 ? torch : 0;
cfg.lowHealthThreshold = lowHealth;
cfg.eatBelow = eatBelow;
cfg.maxWaitSeconds = maxWait;
cfg.missingPolicy = policySel;
cfg.save();
close();
} catch (NumberFormatException e) {
error = Text.translatable("autorail.gui.settings.badNumber");
}
}
private void onReset() {
AutoRailConfig def = new AutoRailConfig();
poweredField.setText(String.valueOf(def.poweredEvery));
detectorField.setText(String.valueOf(def.detectorEvery));
torchField.setText(String.valueOf(def.torchEvery));
lowHealthField.setText(String.valueOf(Math.round(def.lowHealthThreshold)));
eatBelowField.setText(String.valueOf(def.eatBelow));
maxWaitField.setText(String.valueOf(def.maxWaitSeconds));
policySel = def.missingPolicy;
policyBtn.setMessage(policyLabel());
}
private static NumberFormatException err() {
return new NumberFormatException("torch > powered");
}
private static int parse(TextFieldWidget f) throws NumberFormatException {
return Integer.parseInt(f.getText().trim());
}
private static int clamp(int v, int min, int max) {
return Math.max(min, Math.min(max, v));
}
@Override
public void close() {
if (this.client != null) {
this.client.setScreen(parent);
}
}
@Override
public void render(DrawContext context, int mouseX, int mouseY, float delta) {
super.render(context, mouseX, mouseY, delta);
int cx = this.width / 2;
context.drawCenteredTextWithShadow(this.textRenderer, this.title, cx, 16, 0xFFFFFF);
context.drawCenteredTextWithShadow(this.textRenderer,
Text.translatable("autorail.gui.settings.hint"), cx, 30, 0xA0A0A0);
int lx = cx - FIELD_W - 12;
int rx = cx + 12;
drawLabel(context, "autorail.gui.settings.powered", lx, rowY(0));
drawLabel(context, "autorail.gui.settings.detector", rx, rowY(0));
drawLabel(context, "autorail.gui.settings.torch", lx, rowY(1));
drawLabel(context, "autorail.gui.settings.eatBelow", rx, rowY(1));
drawLabel(context, "autorail.gui.settings.lowHealth", lx, rowY(2));
drawLabel(context, "autorail.gui.settings.maxWait", rx, rowY(2));
drawLabel(context, "autorail.gui.settings.missingPolicy", lx, rowY(3));
context.drawTextWithShadow(this.textRenderer, policyLabel(), lx, rowY(3) + FIELD_H + 3, 0x808080);
Text st = error != null ? error : Text.translatable("autorail.gui.settings.saved");
context.drawCenteredTextWithShadow(this.textRenderer, st, cx, this.height - 58,
error != null ? 0xFF5555 : 0x808080);
}
private void drawLabel(DrawContext context, String key, int x, int y) {
context.drawTextWithShadow(this.textRenderer, Text.translatable(key), x, y - 11, 0xE0E0E0);
}
@Override
public boolean shouldPause() {
return false;
}
}
@@ -83,6 +83,11 @@ public class RailPathfinder {
return opened; return opened;
} }
/** 路径起点(供“自动走近起点”功能作为步行目标);无结果时为 null。 */
public BlockPos getWalkTarget() {
return result.isEmpty() ? null : result.get(0).railPos();
}
/** 每帧调用一次,最多展开 budget 个节点。 */ /** 每帧调用一次,最多展开 budget 个节点。 */
public void step(int budget) { public void step(int budget) {
if (state != State.RUNNING) { if (state != State.RUNNING) {
@@ -0,0 +1,215 @@
package com.autorail.pathing;
import net.minecraft.block.BlockState;
import net.minecraft.block.Blocks;
import net.minecraft.client.world.ClientWorld;
import net.minecraft.registry.tag.FluidTags;
import net.minecraft.util.math.BlockPos;
import net.minecraft.util.math.Direction;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.PriorityQueue;
/**
* 自动走到远处的起始点:内置 A* 步行寻路(4 向 + 跳 1 格 + 落 3 格 + 可涉水)。
*
* 与铺轨寻路解耦:先走过去,再从起点开始铺轨;走不到则明确报错让用户手动接近。
*/
public class WalkPathfinder {
public enum State { RUNNING, FOUND, FAILED }
private static final int MARGIN = 32;
private static final long MAX_EXPANSIONS = 200_000L;
private static final float COST_JUMP = 1.7f;
private static final float COST_WATER = 0.8f;
private final ClientWorld world;
private final BlockPos start;
private final BlockPos goal;
private final PriorityQueue<Node> open = new PriorityQueue<>(Comparator.comparingDouble(n -> n.f));
private final Map<Long, Node> best = new HashMap<>();
private final List<BlockPos> result = new ArrayList<>();
private State state = State.RUNNING;
private String failKey;
private long expansions;
private int opened;
private int minX, minY, minZ, maxX, maxY, maxZ;
public WalkPathfinder(ClientWorld world, BlockPos start, BlockPos goal) {
this.world = world;
this.start = start.toImmutable();
this.goal = goal.toImmutable();
computeRegion();
Node s = new Node(this.start, null, 0f, h(this.start));
best.put(this.start.asLong(), s);
open.add(s);
opened++;
}
public State getState() {
return state;
}
public String getFailKey() {
return failKey;
}
public List<BlockPos> getResult() {
return result;
}
public int getOpened() {
return opened;
}
/** 每帧分片执行,最多展开 budget 个节点,不卡客户端线程。 */
public void step(int budget) {
if (state != State.RUNNING) {
return;
}
for (int b = 0; b < budget; b++) {
Node cur = poll();
if (cur == null) {
fail("autorail.err.walkFail");
return;
}
if (cur.pos.equals(goal)) {
rebuild(cur);
state = State.FOUND;
return;
}
expand(cur);
if (++expansions > MAX_EXPANSIONS) {
fail("autorail.err.walkFail");
return;
}
}
}
private Node poll() {
while (true) {
Node n = open.poll();
if (n == null) {
return null;
}
if (best.get(n.pos.asLong()) == n) {
return n;
}
}
}
private void expand(Node cur) {
for (Direction d : Direction.Type.HORIZONTAL) {
BlockPos base = cur.pos.offset(d);
// 跳上 1 格
if (passHead(cur.pos.up(2))) {
tryMove(cur, base.up(), COST_JUMP, true);
}
// 平走 / 涉水
tryMove(cur, base, 1.0f, false);
// 落下 1-3 格
for (int f = 1; f <= 3; f++) {
tryMove(cur, base.down(f), 1.0f + 0.25f * f, false);
}
}
}
private void tryMove(Node cur, BlockPos target, float baseCost, boolean jump) {
if (!inRegion(target) || !walkable(target) || !passHead(target.up())) {
return;
}
BlockState sup = world.getBlockState(target.down());
boolean solid = sup.isSolidBlock(world, target.down());
boolean water = sup.getFluidState().isIn(FluidTags.WATER);
if (!solid && !water) {
return;
}
if (jump && !solid) {
return; // 跳跃必须落在实心方块上
}
float cost = baseCost;
if (world.getBlockState(target).getFluidState().isIn(FluidTags.WATER)) {
cost += COST_WATER;
}
float g = cur.g + cost;
long key = target.asLong();
Node old = best.get(key);
if (old == null || g < old.g - 1.0e-4f) {
Node n = new Node(target, cur, g, g + h(target));
best.put(key, n);
open.add(n);
opened++;
}
}
private void rebuild(Node goalNode) {
result.clear();
for (Node n = goalNode; n != null; n = n.parent) {
result.add(n.pos);
}
java.util.Collections.reverse(result);
}
private void computeRegion() {
minX = Math.min(start.getX(), goal.getX()) - MARGIN;
maxX = Math.max(start.getX(), goal.getX()) + MARGIN;
minZ = Math.min(start.getZ(), goal.getZ()) - MARGIN;
maxZ = Math.max(start.getZ(), goal.getZ()) + MARGIN;
minY = Math.max(world.getBottomY() + 1, Math.min(start.getY(), goal.getY()) - MARGIN);
maxY = Math.min(world.getTopYInclusive() - 2, Math.max(start.getY(), goal.getY()) + MARGIN);
}
private boolean inRegion(BlockPos p) {
return p.getX() >= minX && p.getX() <= maxX
&& p.getY() >= minY && p.getY() <= maxY
&& p.getZ() >= minZ && p.getZ() <= maxZ;
}
private float h(BlockPos p) {
return 1.0f * (Math.abs(p.getX() - goal.getX())
+ Math.abs(p.getY() - goal.getY())
+ Math.abs(p.getZ() - goal.getZ()));
}
private void fail(String key) {
failKey = key;
state = State.FAILED;
}
/** 站立格:空气 / 可替换 / 铁轨(无碰撞)/ 水。岩浆不可走。 */
private boolean walkable(BlockPos pos) {
BlockState s = world.getBlockState(pos);
return s.isAir()
|| (s.getFluidState().isEmpty() && s.isReplaceable())
|| s.isOf(Blocks.RAIL)
|| s.isOf(Blocks.POWERED_RAIL)
|| s.isOf(Blocks.DETECTOR_RAIL)
|| s.getFluidState().isIn(FluidTags.WATER);
}
private boolean passHead(BlockPos pos) {
return walkable(pos);
}
private static final class Node {
final BlockPos pos;
final Node parent;
final float g;
final float f;
Node(BlockPos pos, Node parent, float g, float f) {
this.pos = pos;
this.parent = parent;
this.g = g;
this.f = f;
}
}
}
@@ -1,7 +1,9 @@
package com.autorail.service; package com.autorail.service;
import com.autorail.config.AutoRailConfig;
import com.autorail.pathing.RailPathfinder; import com.autorail.pathing.RailPathfinder;
import com.autorail.pathing.RailStep; import com.autorail.pathing.RailStep;
import com.autorail.pathing.WalkPathfinder;
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;
@@ -12,19 +14,30 @@ import java.util.List;
/** /**
* 铺轨调度服务(单例,GUI 与执行逻辑的桥梁)。 * 铺轨调度服务(单例,GUI 与执行逻辑的桥梁)。
* 状态机:PLANNING(分片寻路)→ LAYING(行走铺轨)→ DONE / FAILED。 * 状态机:
* PLANNING(分片铁轨寻路)→ WALK_PLANNING → WALKING(自动走到远处起点)
* → LAYING(行走铺轨)→ DONE / FAILED。
* LAYING 期间自主行为(低血量避险、饥饿进食)由 {@link AutonomyController} 接管。
*/ */
public class AutoRailService { public class AutoRailService {
public enum State { IDLE, PLANNING, LAYING, DONE, FAILED } public enum State { IDLE, PLANNING, WALK_PLANNING, WALKING, LAYING, DONE, FAILED }
/** 每 tick 寻路展开预算(节点数)。 */ /** 每 tick 寻路展开预算(节点数)。 */
private static final int PLAN_BUDGET_PER_TICK = 4096; private static final int PLAN_BUDGET_PER_TICK = 4096;
private static final int WALK_BUDGET_PER_TICK = 2048;
/** 起点离玩家超过该水平距离时,先自动走过去(平方)。 */
private static final double WALK_TRIGGER_SQ = 16;
private State state = State.IDLE; private State state = State.IDLE;
private RailPathfinder planner; private RailPathfinder planner;
private RailPlan plan;
private RailExecutor executor; private RailExecutor executor;
private WalkPathfinder walkPlanner;
private WalkExecutor walker;
private final AutonomyController autonomy = new AutonomyController();
private String failKey; private String failKey;
private boolean wasWaiting;
public State getState() { public State getState() {
return state; return state;
@@ -32,8 +45,13 @@ public class AutoRailService {
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;
executor = null; executor = null;
walkPlanner = null;
walker = null;
failKey = null; failKey = null;
wasWaiting = false;
autonomy.releaseKeys(client());
state = State.PLANNING; state = State.PLANNING;
chat(player, "autorail.msg.plan.start", checkpoints.size()); chat(player, "autorail.msg.plan.start", checkpoints.size());
} }
@@ -42,17 +60,28 @@ public class AutoRailService {
if (executor != null) { if (executor != null) {
executor.releaseKeys(); executor.releaseKeys();
} }
boolean wasActive = state == State.PLANNING || state == State.LAYING; if (walker != null) {
walker.releaseKeys();
}
autonomy.releaseKeys(client());
boolean wasActive = state != State.IDLE && state != State.DONE && state != State.FAILED;
planner = null; planner = null;
plan = null;
executor = null; executor = null;
walkPlanner = null;
walker = 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");
} }
} }
private static MinecraftClient client() {
return MinecraftClient.getInstance();
}
public void tick(MinecraftClient client) { public void tick(MinecraftClient client) {
if (state != State.PLANNING && state != State.LAYING) { if (state == State.IDLE || state == State.DONE || state == State.FAILED) {
return; return;
} }
ClientPlayerEntity player = client.player; ClientPlayerEntity player = client.player;
@@ -61,26 +90,101 @@ public class AutoRailService {
hardStop(); hardStop();
return; return;
} }
if (state == State.PLANNING) { AutoRailConfig cfg = AutoRailConfig.get();
planner.step(PLAN_BUDGET_PER_TICK);
if (planner.getState() == RailPathfinder.State.FOUND) { // 自主行为(紧急避险/自动进食)优先:接管本 tick,铺轨暂停
List<RailStep> steps = planner.getResult(); String act = autonomy.tick(client, player, cfg);
executor = new RailExecutor(world, steps); if (act != null) {
state = State.LAYING; if (executor != null) {
chat(player, "autorail.msg.plan.done", steps.size()); executor.releaseKeys();
} else if (planner.getState() == RailPathfinder.State.FAILED) { }
failKey = planner.getFailKey(); if (walker != null) {
state = State.FAILED; walker.releaseKeys();
chat(player, failKey); }
if ("flee".equals(act) && wasActiveState()) {
chat(player, "autorail.msg.flee");
} else if ("eat".equals(act) && wasActiveState()) {
chatThrottled(player, "autorail.msg.eat");
} }
return; return;
} }
if (autonomy.wasFleeing()) {
chat(player, "autorail.msg.fleeDone");
}
switch (state) {
case PLANNING -> tickPlanning(client, player);
case WALK_PLANNING -> tickWalkPlanning(client);
case WALKING -> tickWalking(client);
case LAYING -> tickLaying(client, player);
default -> { }
}
}
private boolean wasActiveState() {
return state == State.PLANNING || state == State.WALK_PLANNING
|| state == State.WALKING || state == State.LAYING;
}
private void tickPlanning(MinecraftClient client, ClientPlayerEntity player) {
planner.step(PLAN_BUDGET_PER_TICK);
if (planner.getState() == RailPathfinder.State.FOUND) {
List<RailStep> steps = planner.getResult();
plan = RailPlan.plan(steps, client.world, AutoRailConfig.get());
executor = new RailExecutor(client.world, steps, plan, AutoRailConfig.get());
BlockPos walkTarget = planner.getWalkTarget();
if (walkTarget != null && isFar(player, walkTarget)) {
// 起点较远:先自动走过去
walkPlanner = new WalkPathfinder(client.world, player.getBlockPos(), walkTarget);
state = State.WALK_PLANNING;
chat(player, "autorail.msg.walk.start");
} else {
state = State.LAYING;
chat(player, "autorail.msg.plan.done", steps.size());
}
} else if (planner.getState() == RailPathfinder.State.FAILED) {
fail(client.player, planner.getFailKey());
}
}
private void tickWalkPlanning(MinecraftClient client) {
walkPlanner.step(WALK_BUDGET_PER_TICK);
if (walkPlanner.getState() == WalkPathfinder.State.FOUND) {
walker = new WalkExecutor(client.world, walkPlanner.getResult());
walkPlanner = null;
state = State.WALKING;
} else if (walkPlanner.getState() == WalkPathfinder.State.FAILED) {
fail(client.player, walkPlanner.getFailKey() != null
? walkPlanner.getFailKey() : "autorail.err.walkFail");
}
}
private void tickWalking(MinecraftClient client) {
walker.tick(client);
if (walker.isArrived()) {
walker.releaseKeys();
walker = null;
state = State.LAYING;
chat(client.player, "autorail.msg.walk.done");
} else if (walker.isFailed()) {
fail(client.player, walker.getFailKey() != null
? walker.getFailKey() : "autorail.err.walkFail");
}
}
private void tickLaying(MinecraftClient client, ClientPlayerEntity player) {
executor.tick(client); executor.tick(client);
// 材料等待状态提示
boolean waiting = executor.isWaiting();
if (waiting && !wasWaiting) {
chat(player, "autorail.msg.waiting");
} else if (!waiting && wasWaiting) {
chat(player, "autorail.msg.waitingDone");
}
wasWaiting = waiting;
if (executor.isFinished()) { if (executor.isFinished()) {
if (executor.getFailKey() != null) { if (executor.getFailKey() != null) {
failKey = executor.getFailKey(); fail(player, executor.getFailKey());
state = State.FAILED;
chat(player, failKey);
} else { } else {
state = State.DONE; state = State.DONE;
chat(player, "autorail.msg.lay.done", executor.getTotal()); chat(player, "autorail.msg.lay.done", executor.getTotal());
@@ -88,23 +192,65 @@ public class AutoRailService {
} }
} }
private static boolean isFar(ClientPlayerEntity player, BlockPos target) {
double dx = target.getX() + 0.5 - player.getX();
double dz = target.getZ() + 0.5 - player.getZ();
return dx * dx + dz * dz > WALK_TRIGGER_SQ
|| Math.abs(target.getY() - player.getBlockY()) > 6;
}
private void hardStop() { private void hardStop() {
if (executor != null) { if (executor != null) {
executor.releaseKeys(); executor.releaseKeys();
} }
if (walker != null) {
walker.releaseKeys();
}
autonomy.releaseKeys(client());
planner = null; planner = null;
plan = null;
executor = null; executor = null;
walkPlanner = null;
walker = null;
state = State.IDLE; state = State.IDLE;
} }
private void fail(ClientPlayerEntity player, String key) {
failKey = key;
state = State.FAILED;
if (executor != null) {
executor.releaseKeys();
}
if (walker != null) {
walker.releaseKeys();
}
autonomy.releaseKeys(client());
if (player != null) {
chat(player, key);
}
}
/** GUI 底部状态行。 */ /** GUI 底部状态行。 */
public Text statusText() { public Text statusText() {
return switch (state) { return switch (state) {
case IDLE -> Text.translatable("autorail.gui.status.idle"); case IDLE -> Text.translatable("autorail.gui.status.idle");
case PLANNING -> Text.translatable("autorail.gui.status.planning", case PLANNING -> Text.translatable("autorail.gui.status.planning",
planner == null ? 0 : planner.getOpened()); planner == null ? 0 : planner.getOpened());
case LAYING -> executor == null ? Text.translatable("autorail.gui.status.idle") case WALK_PLANNING -> Text.translatable("autorail.gui.status.walkPlanning",
: Text.translatable("autorail.gui.status.laying", executor.getPlaced(), executor.getTotal()); walkPlanner == null ? 0 : walkPlanner.getOpened());
case WALKING -> walker == null ? Text.translatable("autorail.gui.status.idle")
: Text.translatable("autorail.gui.status.walking", walker.getIdx(), walker.getTotal());
case LAYING -> {
if (executor == null) {
yield Text.translatable("autorail.gui.status.idle");
}
if (executor.isWaiting()) {
long sec = executor.getWaitSeconds();
yield Text.translatable("autorail.gui.status.waiting", sec);
}
yield Text.translatable("autorail.gui.status.laying",
executor.getPlaced(), executor.getTotal());
}
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",
@@ -115,4 +261,15 @@ public class AutoRailService {
private static void chat(ClientPlayerEntity player, String key, Object... args) { private static void chat(ClientPlayerEntity player, String key, Object... args) {
player.sendMessage(Text.literal("[AutoRail] ").append(Text.translatable(key, args)), false); player.sendMessage(Text.literal("[AutoRail] ").append(Text.translatable(key, args)), false);
} }
private static void chatThrottled(ClientPlayerEntity player, String key) {
// 进食提示最多每 10 秒一次,避免刷屏
long now = System.currentTimeMillis() / 1000L;
if (now - lastEatMsg >= 10) {
lastEatMsg = now;
chat(player, key);
}
}
private static long lastEatMsg;
} }
@@ -0,0 +1,176 @@
package com.autorail.service;
import com.autorail.config.AutoRailConfig;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.network.ClientPlayerEntity;
import net.minecraft.component.DataComponentTypes;
import net.minecraft.item.Item;
import net.minecraft.item.ItemStack;
import net.minecraft.network.packet.c2s.play.UpdateSelectedSlotC2SPacket;
import net.minecraft.registry.Registries;
import net.minecraft.util.Identifier;
import java.util.List;
/**
* 玩家自主行为:饥饿自动进食 + 危险时的原地避险(后退 + 跳跃脱离)。
* 只做“自救动作”,不做世界修改;所有交互仍是合法右键/键位输入。
*/
public final class AutonomyController {
/** 已知可安全生食/熟食(按优先级;金苹果留到最后,不放最前)。 */
private static final List<String> FOOD_PRIORITY = List.of(
"minecraft:cooked_beef", "minecraft:cooked_porkchop", "minecraft:cooked_mutton",
"minecraft:cooked_chicken", "minecraft:cooked_salmon", "minecraft:cooked_cod",
"minecraft:bread", "minecraft:baked_potato", "minecraft:potato", "minecraft:carrot",
"minecraft:beetroot", "minecraft:beetroot_soup", "minecraft:mushroom_stew",
"minecraft:rabbit_stew", "minecraft:suspicious_stew", "minecraft:apple",
"minecraft:golden_carrot", "minecraft:golden_apple", "minecraft:porkchop",
"minecraft:beef", "minecraft:mutton", "minecraft:chicken", "minecraft:salmon",
"minecraft:cod", "minecraft:tropical_fish", "minecraft:pufferfish", "minecraft:melon_slice",
"minecraft:sweet_berries", "minecraft:glow_berries", "minecraft:honey_bottle",
"minecraft:cookie", "minecraft:pumpkin_pie", "minecraft:rotten_flesh",
"minecraft:spider_eye", "minecraft:dried_kelp"
);
private int eatTicks = -1;
private int fleeTicks;
private boolean fleeWasActive;
/**
* 每 tick 检查。返回当前执行的自主动作(供状态机显示/决策):
* "eat" / "flee" / null。
*/
public String tick(MinecraftClient client, ClientPlayerEntity player, AutoRailConfig cfg) {
fleeWasActive = fleeTicks > 0;
// 1) 低血量:紧急避险(松开前进、后退跳跃脱险)
if (cfg.lowHealthThreshold > 0 && player.getHealth() <= cfg.lowHealthThreshold) {
flee(client);
if (eatTicks >= 0) {
stopEating(client, player);
}
return "flee";
}
// 2) 饥饿:自动进食
if (cfg.eatBelow > 0 && player.getHealth() < player.getMaxHealth()
&& player.getHungerManager().getFoodLevel() <= cfg.eatBelow) {
if (eatTicks < 0) {
int slot = findFood(player);
if (slot >= 0) {
startEating(client, player, slot);
}
}
if (eatTicks >= 0) {
continueEating(client, player);
return "eat";
}
return null;
}
// 血量健康且不饿:如果还在吃就停下
if (eatTicks >= 0) {
stopEating(client, player);
}
return null;
}
/** 正在进食时返回 true(铺轨行走需要暂停转向干扰)。 */
public boolean isEating() {
return eatTicks >= 0;
}
public boolean isFleeing() {
return fleeTicks > 0;
}
/** 上一 tick 是否在避险(用于服务层播报“避险结束”)。 */
public boolean wasFleeing() {
return fleeWasActive;
}
private void startEating(MinecraftClient client, ClientPlayerEntity player, int slot) {
player.getInventory().selectedSlot = slot;
if (player.networkHandler != null) {
player.networkHandler.sendPacket(new UpdateSelectedSlotC2SPacket(slot));
}
client.options.useKey.setPressed(true);
eatTicks = 0;
}
private void continueEating(MinecraftClient client, ClientPlayerEntity player) {
eatTicks++;
ItemStack stack = player.getMainHandStack();
boolean stillFood = stack.contains(DataComponentTypes.FOOD);
// 吃完了 / 换手了:重置,下一 tick 重新找
if (!stillFood || player.getHungerManager().getFoodLevel() > 20) {
stopEating(client, player);
return;
}
// 长按右键吃 1.6s+;期间保持 useKey 按下,玩家速度会自然放缓
client.options.useKey.setPressed(true);
if (eatTicks > 60) {
stopEating(client, player); // 超时保护,避免卡死
}
}
private void stopEating(MinecraftClient client, ClientPlayerEntity player) {
client.options.useKey.setPressed(false);
eatTicks = -1;
}
/** 避险:后撤 + 小跳,持续约 1.5s;期间调用方应暂停铺轨放置。 */
private void flee(MinecraftClient client) {
ClientPlayerEntity player = client.player;
if (player == null) {
return;
}
if (fleeTicks <= 0) {
fleeTicks = 30;
}
fleeTicks--;
client.options.forwardKey.setPressed(false);
client.options.backKey.setPressed(true);
if (player.horizontalCollision || (fleeTicks % 10 == 0 && player.isOnGround())) {
client.options.jumpKey.setPressed(true);
} else {
client.options.jumpKey.setPressed(false);
}
if (fleeTicks <= 0) {
client.options.backKey.setPressed(false);
client.options.jumpKey.setPressed(false);
}
}
/** 释放避险可能残留的按键。 */
public void releaseKeys(MinecraftClient client) {
if (client != null && client.options != null) {
client.options.backKey.setPressed(false);
client.options.useKey.setPressed(false);
}
eatTicks = -1;
fleeTicks = 0;
fleeWasActive = false;
}
/** 按优先级在快捷栏找食物,返回槽位;找不到返回 -1。 */
private static int findFood(ClientPlayerEntity player) {
int fallback = -1;
int fallbackRank = Integer.MAX_VALUE;
for (int i = 0; i < 9; i++) {
ItemStack s = player.getInventory().getStack(i);
if (s.isEmpty() || !s.contains(DataComponentTypes.FOOD)) {
continue;
}
Item item = s.getItem();
Identifier id = Registries.ITEM.getId(item);
int rank = FOOD_PRIORITY.indexOf(id.toString());
if (rank < 0) {
rank = Integer.MAX_VALUE - 1; // 未知食物兜底(仍可食用)
}
if (rank < fallbackRank) {
fallbackRank = rank;
fallback = i;
}
}
return fallback;
}
}
@@ -1,8 +1,9 @@
package com.autorail.service; package com.autorail.service;
import com.autorail.config.AutoRailConfig;
import com.autorail.config.MissingPolicy;
import com.autorail.pathing.RailStep; import com.autorail.pathing.RailStep;
import net.minecraft.block.BlockState; import net.minecraft.block.BlockState;
import net.minecraft.block.Blocks;
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;
@@ -18,17 +19,23 @@ import net.minecraft.util.math.Vec3d;
import java.util.List; import java.util.List;
/** /**
* 铺轨执行器:沿已规划好的路径控制玩家行走,并合法放置铁轨/圆石。 * 铺轨执行器:沿已规划好的路径控制玩家行走,并合法放置铁轨/圆石/红石火把。
* *
* 行走:平滑转向 + 按下前进/跳跃键(客户端输入,服务端正常结算); * 行走:平滑转向 + 按下前进/跳跃键(客户端输入,服务端正常结算);
* 放置:切换快捷栏 + {@code processRightClickBlock} 右键交互, * 放置:切换快捷栏 + {@code processRightClickBlock} 右键交互,
* 绝不 world.setBlockState,多人服务器可信任。 * 绝不 world.setBlockState,多人服务器可信任。
* 玩家只会走在“已完成”的节点上,放置窗口始终领先于行走目标。 * 玩家只会走在“已完成”的节点上,放置窗口始终领先于行走目标。
*
* 放置计划({@link RailPlan}):动力铁轨/探测铁轨按用户间隔混铺;
* 计划中的火把节点先放火把再放铁轨。
* 材料不足按用户预案处理:WAIT 原地等待(超时停止)/ SKIP 跳过火把继续铺普通轨。
*/ */
public class RailExecutor { public class RailExecutor {
/** 放置动作间隔(tick),4 tick = 200ms,符合多人服务器节奏。 */ /** 放置动作间隔(tick),4 tick = 200ms,符合多人服务器节奏。 */
private static final int ACTION_INTERVAL = 4; private static final int ACTION_INTERVAL = 4;
/** 等待材料时的重试间隔(tick)。 */
private static final int WAIT_RETRY_INTERVAL = 20;
/** 放置距离的平方(略保守于 4.5 格交互距离)。 */ /** 放置距离的平方(略保守于 4.5 格交互距离)。 */
private static final double REACH_SQ = 4.2 * 4.2; private static final double REACH_SQ = 4.2 * 4.2;
/** 一次尝试放置的路径窗口(节点数)。 */ /** 一次尝试放置的路径窗口(节点数)。 */
@@ -44,6 +51,8 @@ public class RailExecutor {
private final ClientWorld world; private final ClientWorld world;
private final List<RailStep> path; private final List<RailStep> path;
private final RailPlan plan;
private final AutoRailConfig cfg;
private int cursor; private int cursor;
private int standIndex; private int standIndex;
private long tickCounter; private long tickCounter;
@@ -59,9 +68,23 @@ public class RailExecutor {
private String failKey; private String failKey;
private boolean finished; private boolean finished;
public RailExecutor(ClientWorld world, List<RailStep> path) { // 等待材料状态
private boolean waiting;
private long waitTicks;
private String waitMsgKey;
// 火把放置状态:0=未处理 1=已放置 2=已跳过(找不到点位/材料)
private byte[] torchState;
// 避险暂停(由服务层控制,暂停时行走与放置都停)
private boolean paused;
public RailExecutor(ClientWorld world, List<RailStep> path, RailPlan plan, AutoRailConfig cfg) {
this.world = world; this.world = world;
this.path = path; this.path = path;
this.plan = plan;
this.cfg = cfg;
this.torchState = new byte[path.size()];
} }
public boolean isFinished() { public boolean isFinished() {
@@ -80,6 +103,27 @@ public class RailExecutor {
return cursor; return cursor;
} }
public boolean isWaiting() {
return waiting;
}
/** 剩余等待秒数(超时后会失败停止)。 */
public long getWaitSeconds() {
return Math.max(0L, cfg.maxWaitSeconds - waitTicks / 20L);
}
/** 当前等待提示的语言 key(供 GUI 状态栏显示)。 */
public String getWaitMsgKey() {
return waitMsgKey;
}
public void setPaused(boolean paused) {
this.paused = paused;
if (paused) {
releaseKeys();
}
}
public void tick(MinecraftClient client) { public void tick(MinecraftClient client) {
if (finished || failKey != null) { if (finished || failKey != null) {
return; return;
@@ -90,7 +134,25 @@ public class RailExecutor {
} }
tickCounter++; tickCounter++;
// 1) 推进已完成(已有铁轨)的节点 if (paused) {
return;
}
// 1) 等待材料:站住不动,定期重试
if (waiting) {
waitTicks++;
if (waitTicks > cfg.maxWaitSeconds * 20L) {
fail("autorail.err.waitTimeout");
return;
}
if (tickCounter - lastActionTick >= WAIT_RETRY_INTERVAL) {
lastActionTick = tickCounter;
tryPlaceWindow(client, player);
}
return;
}
// 2) 推进已完成(已有铁轨)的节点
while (cursor < path.size() && isDone(path.get(cursor))) { while (cursor < path.size() && isDone(path.get(cursor))) {
cursor++; cursor++;
} }
@@ -100,27 +162,16 @@ public class RailExecutor {
return; return;
} }
// 2) 行走(放置阶段会再覆盖朝向到交互点) // 3) 行走(放置阶段会再覆盖朝向到交互点)
standIndex = findStandIndex(player); standIndex = findStandIndex(player);
move(client, player); move(client, player);
// 3) 放置:窗口内第一个“可达且未完成”的节点 // 4) 放置:窗口内第一个“可达且未完成”的节点
if (tickCounter - lastActionTick >= ACTION_INTERVAL) { if (tickCounter - lastActionTick >= ACTION_INTERVAL) {
for (int i = cursor; i < Math.min(cursor + PLACE_WINDOW, path.size()); i++) { tryPlaceWindow(client, player);
RailStep s = path.get(i);
if (isDone(s)) {
continue;
}
if (player.getEyePos().squaredDistanceTo(Vec3d.ofCenter(s.railPos())) > REACH_SQ) {
continue;
}
attemptPlace(client, player, i);
lastActionTick = tickCounter;
break;
}
} }
// 4) 卡住 / 停滞检测 // 5) 卡住 / 停滞检测
if (lastNeedMove && player.isOnGround() && speedSq(player) < 2.0e-4) { if (lastNeedMove && player.isOnGround() && speedSq(player) < 2.0e-4) {
if (stuckSince < 0) { if (stuckSince < 0) {
stuckSince = tickCounter; stuckSince = tickCounter;
@@ -145,6 +196,21 @@ public class RailExecutor {
} }
} }
private void tryPlaceWindow(MinecraftClient client, ClientPlayerEntity player) {
for (int i = cursor; i < Math.min(cursor + PLACE_WINDOW, path.size()); i++) {
RailStep s = path.get(i);
if (isDone(s)) {
continue;
}
if (player.getEyePos().squaredDistanceTo(Vec3d.ofCenter(s.railPos())) > REACH_SQ) {
continue;
}
attemptPlace(client, player, i);
lastActionTick = tickCounter;
break;
}
}
/** 行走控制:朝已铺设前方的节点平滑转向并前进,需要上坡时自动跳跃。 */ /** 行走控制:朝已铺设前方的节点平滑转向并前进,需要上坡时自动跳跃。 */
private void move(MinecraftClient client, ClientPlayerEntity player) { private void move(MinecraftClient client, ClientPlayerEntity player) {
int frontier = cursor - 1; // 最高已完成节点 int frontier = cursor - 1; // 最高已完成节点
@@ -176,17 +242,46 @@ public class RailExecutor {
RailStep s = path.get(i); RailStep s = path.get(i);
BlockPos rail = s.railPos(); BlockPos rail = s.railPos();
BlockState at = world.getBlockState(rail); BlockState at = world.getBlockState(rail);
if (!at.isAir() && !at.isReplaceable() && !at.isOf(Blocks.RAIL)) { if (!at.isAir() && !at.isReplaceable() && !RailPlan.isRailDone(at)) {
fail("autorail.err.blocked"); fail("autorail.err.blocked");
return; return;
} }
// 1) 火把节点:先放火把(挂在支撑方块侧面),再放动力轨
if (plan.needsTorch(i) && torchState[i] == 0) {
if (!attemptTorch(client, player, i)) {
return;
}
}
// 2) 铁轨本体
RailPlan.Kind kind = plan.kindAt(i);
net.minecraft.item.Item item = switch (kind) {
case POWERED_RAIL -> Items.POWERED_RAIL;
case DETECTOR_RAIL -> Items.DETECTOR_RAIL;
case RAIL -> Items.RAIL;
};
int slot = findSlot(player, item);
if (slot < 0) {
// 特殊轨缺失且策略为 SKIP:降级为普通轨;普通轨/其他情况:按预案等待
if (handleMissing(item, "autorail.err.noRail", kind != RailPlan.Kind.RAIL)) {
slot = findSlot(player, Items.RAIL);
if (slot < 0) {
handleMissing(Items.RAIL, "autorail.err.noRail", false);
return;
}
item = Items.RAIL;
} else {
return;
}
}
BlockPos support = rail.down(); BlockPos support = rail.down();
BlockState sup = world.getBlockState(support); BlockState sup = world.getBlockState(support);
if (!sup.isSolidBlock(world, support) || !sup.getFluidState().isEmpty()) { if (!sup.isSolidBlock(world, support) || !sup.getFluidState().isEmpty()) {
// 支撑缺失(悬空/水中):先垫圆石 // 支撑缺失(悬空/水中):先垫圆石
int slot = findSlot(player, Items.COBBLESTONE); int cobbleSlot = findSlot(player, Items.COBBLESTONE);
if (slot < 0) { if (cobbleSlot < 0) {
fail("autorail.err.noCobble"); fail("autorail.err.noCobble");
return; return;
} }
@@ -195,18 +290,13 @@ public class RailExecutor {
fail("autorail.err.noHit"); fail("autorail.err.noHit");
return; return;
} }
selectSlot(player, slot); selectSlot(player, cobbleSlot);
aimAt(player, hit.getPos()); aimAt(player, hit.getPos());
client.interactionManager.interactBlock(player, Hand.MAIN_HAND, hit); client.interactionManager.interactBlock(player, Hand.MAIN_HAND, hit);
player.swingHand(Hand.MAIN_HAND); player.swingHand(Hand.MAIN_HAND);
return; return;
} }
int slot = findSlot(player, Items.RAIL);
if (slot < 0) {
fail("autorail.err.noRail");
return;
}
BlockHitResult hit = new BlockHitResult( BlockHitResult hit = new BlockHitResult(
new Vec3d(support.getX() + 0.5, support.getY() + 1.0, support.getZ() + 0.5), new Vec3d(support.getX() + 0.5, support.getY() + 1.0, support.getZ() + 0.5),
Direction.UP, support, false); Direction.UP, support, false);
@@ -216,6 +306,64 @@ public class RailExecutor {
player.swingHand(Hand.MAIN_HAND); player.swingHand(Hand.MAIN_HAND);
} }
/**
* 在节点 i 的支撑方块侧面放置红石火把(激活相邻的动力铁轨)。
*
* @return true = 火把已处理(放置成功或标记跳过),可继续放铁轨;false = 本 tick 动作已消耗
*/
private boolean attemptTorch(MinecraftClient client, ClientPlayerEntity player, int i) {
RailStep s = path.get(i);
BlockPos rail = s.railPos();
BlockPos support = rail.down();
BlockPos slot = plan.torchSlot(i);
BlockState at = world.getBlockState(slot);
boolean free = at.isAir() || (at.getFluidState().isEmpty() && at.isReplaceable());
BlockState sup = world.getBlockState(support);
if (!free || !sup.isSolidBlock(world, support)) {
torchState[i] = 2; // 点位被占/支撑变化:跳过火把,继续铺动力轨
return true;
}
int itemSlot = findSlot(player, Items.REDSTONE_TORCH);
if (itemSlot < 0) {
if (handleMissing(Items.REDSTONE_TORCH, "autorail.err.noTorch", true)) {
torchState[i] = 2; // SKIP 策略:跳过火把,继续铺动力轨(未供电)
return true;
}
return false; // WAIT 策略:已进入等待状态
}
Direction face = horizontalDirection(support, slot);
if (face == null) {
torchState[i] = 2;
return true;
}
BlockHitResult hit = faceHit(support, face);
selectSlot(player, itemSlot);
aimAt(player, hit.getPos());
client.interactionManager.interactBlock(player, Hand.MAIN_HAND, hit);
player.swingHand(Hand.MAIN_HAND);
torchState[i] = 1;
return true;
}
/**
* 材料不足预案。
*
* @param skippable 该材料是否可以在 SKIP 策略下跳过(火把可以,铁轨/圆石不行)
* @return true = 按 SKIP 跳过了该材料;false = 已进入等待状态,本 tick 放置流程终止
*/
private boolean handleMissing(net.minecraft.item.Item item, String missingKey, boolean skippable) {
if (cfg.missingPolicy == MissingPolicy.SKIP && skippable) {
return true;
}
if (!waiting || !missingKey.equals(waitMsgKey)) {
waitMsgKey = missingKey;
waitTicks = 0; // 换了材料种类,重置等待计时
}
waiting = true;
releaseKeys();
return false;
}
/** 找一个可点击的实心面,把圆石垫进 support 位置。优先用上一节点的支撑侧面。 */ /** 找一个可点击的实心面,把圆石垫进 support 位置。优先用上一节点的支撑侧面。 */
private BlockHitResult findSupportHit(ClientPlayerEntity player, int i, BlockPos support) { private BlockHitResult findSupportHit(ClientPlayerEntity player, int i, BlockPos support) {
if (i > 0) { if (i > 0) {
@@ -246,7 +394,7 @@ public class RailExecutor {
return bestHit; return bestHit;
} }
/** 点击 block 的 face 面(新方块会出现在 support 一侧)。 */ /** 点击 block 的 face 面(新方块会出现在 face 一侧)。 */
private static BlockHitResult faceHit(BlockPos block, Direction face) { private static BlockHitResult faceHit(BlockPos block, Direction face) {
Vec3d p = new Vec3d( Vec3d p = new Vec3d(
block.getX() + 0.5 + face.getOffsetX() * 0.5, block.getX() + 0.5 + face.getOffsetX() * 0.5,
@@ -289,7 +437,7 @@ public class RailExecutor {
} }
private boolean isDone(RailStep s) { private boolean isDone(RailStep s) {
return world.getBlockState(s.railPos()).isOf(Blocks.RAIL); return RailPlan.isRailDone(world.getBlockState(s.railPos()));
} }
private boolean isSolid(BlockPos pos) { private boolean isSolid(BlockPos pos) {
@@ -348,6 +496,7 @@ public class RailExecutor {
private void fail(String key) { private void fail(String key) {
failKey = key; failKey = key;
waiting = false;
releaseKeys(); releaseKeys();
} }
@@ -0,0 +1,136 @@
package com.autorail.service;
import com.autorail.config.AutoRailConfig;
import com.autorail.pathing.RailStep;
import net.minecraft.block.BlockState;
import net.minecraft.block.Blocks;
import net.minecraft.client.world.ClientWorld;
import net.minecraft.util.math.BlockPos;
import java.util.List;
/**
* 铺设计划:把“间隔放置规则”应用到具体路径上。
*
* 规则(用户可在设置中调整):
* - 每 poweredEvery 格放 1 根动力铁轨(加速段);
* - 每 detectorEvery 格放 1 个探测铁轨(矿车压过自动激活相邻动力轨);
* - 每 torchEvery 根动力铁轨下方插 1 支红石火把(持续供电)。
* 间隔从 0 开始计数,即索引 i 满足 i % N == 0 时命中。
*/
public final class RailPlan {
/** 节点应放置的铁轨类型(火把是动力轨节点的前置动作,见 {@link #needsTorch})。 */
public enum Kind { RAIL, POWERED_RAIL, DETECTOR_RAIL }
private final Kind[] kinds;
/** 火把放置格(通常在动力铁轨下方的支撑方块侧面)。 */
private final BlockPos[] torchSlots;
private int torchCount;
private int poweredCount;
private int detectorCount;
private RailPlan(int n) {
this.kinds = new Kind[n];
this.torchSlots = new BlockPos[n];
java.util.Arrays.fill(kinds, Kind.RAIL);
}
public Kind kindAt(int i) {
return kinds[i];
}
/** 该节点旁是否需要放置红石火把。 */
public boolean needsTorch(int i) {
return torchSlots[i] != null;
}
/** 火把放置格(铁轨水平相邻、挂在支撑侧面)。 */
public BlockPos torchSlot(int i) {
return torchSlots[i];
}
public int totalTorch() {
return torchCount;
}
public int totalPowered() {
return poweredCount;
}
public int totalDetector() {
return detectorCount;
}
public static RailPlan plan(List<RailStep> path, ClientWorld world, AutoRailConfig cfg) {
RailPlan p = new RailPlan(path.size());
// 顺序:先标记探测轨与动力轨,再给动力轨配火把
if (cfg.poweredEvery > 0) {
for (int i = 0; i < path.size(); i += Math.max(1, cfg.poweredEvery)) {
p.kinds[i] = Kind.POWERED_RAIL;
p.poweredCount++;
}
}
if (cfg.detectorEvery > 0) {
for (int i = 0; i < path.size(); i += Math.max(1, cfg.detectorEvery)) {
p.kinds[i] = Kind.DETECTOR_RAIL;
p.detectorCount++;
}
}
// 火把:每 torchEvery 根动力轨在“下一根动力轨”处插火把;挂在支撑方块侧面
int sinceTorch = cfg.torchEvery; // 让第一根动力轨就带火把
for (int i = 0; i < path.size(); i++) {
if (p.kinds[i] != Kind.POWERED_RAIL) {
continue;
}
if (sinceTorch >= cfg.torchEvery) {
BlockPos slot = findTorchSlot(path, world, i);
if (slot != null) {
p.torchSlots[i] = slot;
p.torchCount++;
}
sinceTorch = 0;
}
sinceTorch++;
}
return p;
}
/**
* 火把放置格:铁轨支撑方块的水平相邻格(挂在支撑侧面)。
* 要求:目标格可替换、支撑为实心。找不到返回 null(该处动力轨未供电,照常铺设)。
*/
private static BlockPos findTorchSlot(List<RailStep> path, ClientWorld world, int i) {
BlockPos rail = path.get(i).railPos();
BlockPos support = rail.down();
BlockState sup = world.getBlockState(support);
if (!sup.isSolidBlock(world, support)) {
return null;
}
for (net.minecraft.util.math.Direction d : net.minecraft.util.math.Direction.Type.HORIZONTAL) {
BlockPos c = support.offset(d);
BlockState cs = world.getBlockState(c);
boolean placeable = cs.isAir() || (cs.getFluidState().isEmpty() && cs.isReplaceable());
if (placeable && !occupiedByPath(path, c)) {
return c;
}
}
return null;
}
private static boolean occupiedByPath(List<RailStep> path, BlockPos pos) {
for (RailStep s : path) {
if (s.railPos().equals(pos)) {
return true;
}
}
return false;
}
/** 该节点是否已经是某种铁轨(任何铁轨都算完成,允许复用)。 */
public static boolean isRailDone(BlockState state) {
return state.isOf(Blocks.RAIL)
|| state.isOf(Blocks.POWERED_RAIL)
|| state.isOf(Blocks.DETECTOR_RAIL);
}
}
@@ -0,0 +1,157 @@
package com.autorail.service;
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.MathHelper;
import net.minecraft.util.math.Vec3d;
import java.util.List;
/**
* 步行执行器:沿 WalkPathfinder 的格点路径控制玩家行走(转向/前进/跳跃),
* 用于把玩家自动带到远处的铺轨起点。只行走、不放置。
*/
public class WalkExecutor {
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 = 160;
private static final float MAX_TURN_PER_TICK = 60.0f;
private final List<BlockPos> cells;
private int idx = 1;
private long tickCounter;
private long stuckSince = -1;
private int jumpTicks;
private int lastIdx;
private boolean arrived;
private String failKey;
public WalkExecutor(ClientWorld world, List<BlockPos> cells) {
this.cells = cells;
}
public boolean isArrived() {
return arrived;
}
public boolean isFailed() {
return failKey != null;
}
public String getFailKey() {
return failKey;
}
public int getIdx() {
return Math.min(idx, cells.size());
}
public int getTotal() {
return cells.size();
}
public void tick(MinecraftClient client) {
if (arrived || failKey != null) {
return;
}
ClientPlayerEntity player = client.player;
if (player == null) {
return;
}
tickCounter++;
if (idx >= cells.size()) {
arrived = true;
releaseKeys();
return;
}
BlockPos t = cells.get(idx);
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) {
idx++;
stuckSince = -1;
if (idx >= cells.size()) {
arrived = true;
releaseKeys();
}
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);
// 卡住检测:先跳跃自救,再报错
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) {
failKey = "autorail.err.walkStuck";
releaseKeys();
return;
}
} else {
stuckSince = -1;
}
lastIdx = idx;
}
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);
}
}
@@ -13,9 +13,28 @@
"autorail.gui.begin": "Start laying", "autorail.gui.begin": "Start laying",
"autorail.gui.stop": "Stop", "autorail.gui.stop": "Stop",
"autorail.gui.close": "Close", "autorail.gui.close": "Close",
"autorail.gui.settings": "Settings…",
"autorail.gui.settings.title": "AutoRail Settings",
"autorail.gui.settings.hint": "Intervals count from 1; 0 disables the item",
"autorail.gui.settings.powered": "Powered rail every (blocks)",
"autorail.gui.settings.detector": "Detector rail every (blocks)",
"autorail.gui.settings.torch": "Torch every (powered rails)",
"autorail.gui.settings.lowHealth": "Flee below health (0-20)",
"autorail.gui.settings.eatBelow": "Eat below hunger (0-19)",
"autorail.gui.settings.maxWait": "Material wait timeout (s)",
"autorail.gui.settings.missingPolicy": "Missing material policy",
"autorail.gui.settings.policyWait": "Wait for restock",
"autorail.gui.settings.policySkip": "Skip special pieces",
"autorail.gui.settings.save": "Save",
"autorail.gui.settings.reset": "Defaults",
"autorail.gui.settings.badNumber": "Bad number, or torch interval exceeds powered interval",
"autorail.gui.settings.saved": "Saved to config/autorail.json",
"autorail.gui.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.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.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",
@@ -29,11 +48,22 @@
"autorail.err.tooFar": "Too far or too complex for the pathfinder", "autorail.err.tooFar": "Too far or too complex for the pathfinder",
"autorail.err.noRail": "No rails left in the hotbar", "autorail.err.noRail": "No rails left in the hotbar",
"autorail.err.noCobble": "Bridging needs cobblestone in the hotbar", "autorail.err.noCobble": "Bridging needs cobblestone in the hotbar",
"autorail.err.noTorch": "No redstone torches left in the hotbar",
"autorail.err.noHit": "No clickable block to place against", "autorail.err.noHit": "No clickable block to place against",
"autorail.err.blocked": "Path blocked (cannot place a rail there)", "autorail.err.blocked": "Path blocked (cannot place a rail there)",
"autorail.err.stuck": "Player stuck - clear the obstacle and retry", "autorail.err.stuck": "Player stuck - clear the obstacle 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.waitTimeout": "Material wait timed out - stopped",
"autorail.msg.plan.start": "Pathfinding ({0} checkpoints)…", "autorail.msg.plan.start": "Pathfinding ({0} checkpoints)…",
"autorail.msg.plan.done": "Path found: {0} blocks. 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",
"autorail.msg.stopped": "Stopped" "autorail.msg.stopped": "Stopped",
"autorail.msg.walk.start": "Start is far away - walking there…",
"autorail.msg.walk.done": "Arrived at the start. Laying rails - avoid opening other screens",
"autorail.msg.waiting": "Out of materials - waiting for restock (auto-stops on timeout)",
"autorail.msg.waitingDone": "Materials restocked - resuming",
"autorail.msg.flee": "Low health! Emergency retreat…",
"autorail.msg.fleeDone": "Retreat over - resuming work",
"autorail.msg.eat": "Hunger low - eating…"
} }
@@ -13,9 +13,28 @@
"autorail.gui.begin": "开始铺轨", "autorail.gui.begin": "开始铺轨",
"autorail.gui.stop": "停止", "autorail.gui.stop": "停止",
"autorail.gui.close": "关闭", "autorail.gui.close": "关闭",
"autorail.gui.settings": "设置…",
"autorail.gui.settings.title": "AutoRail 设置",
"autorail.gui.settings.hint": "间隔从 1 开始计数;0 表示不放置该项",
"autorail.gui.settings.powered": "动力铁轨间隔(格)",
"autorail.gui.settings.detector": "探测铁轨间隔(格)",
"autorail.gui.settings.torch": "红石火把间隔(根动力轨)",
"autorail.gui.settings.lowHealth": "低血量避险阈值(0-20)",
"autorail.gui.settings.eatBelow": "自动进食饥饿阈值(0-19)",
"autorail.gui.settings.maxWait": "等待材料超时(秒)",
"autorail.gui.settings.missingPolicy": "材料不足预案",
"autorail.gui.settings.policyWait": "原地等待补充",
"autorail.gui.settings.policySkip": "跳过特殊件继续铺",
"autorail.gui.settings.save": "保存",
"autorail.gui.settings.reset": "恢复默认",
"autorail.gui.settings.badNumber": "数值格式错误或火把间隔大于动力轨间隔",
"autorail.gui.settings.saved": "设置已保存到 config/autorail.json",
"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.walking": "自动走向起点… {0} / {1}",
"autorail.gui.status.laying": "铺轨中… {0} / {1}", "autorail.gui.status.laying": "铺轨中… {0} / {1}",
"autorail.gui.status.waiting": "等待材料中… 剩余 {0} 秒,请补充到快捷栏",
"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": "请先进入世界",
@@ -29,11 +48,22 @@
"autorail.err.tooFar": "距离太远或地形太复杂,超出寻路上限", "autorail.err.tooFar": "距离太远或地形太复杂,超出寻路上限",
"autorail.err.noRail": "快捷栏里没有铁轨了", "autorail.err.noRail": "快捷栏里没有铁轨了",
"autorail.err.noCobble": "前方需要垫脚,但快捷栏里没有圆石", "autorail.err.noCobble": "前方需要垫脚,但快捷栏里没有圆石",
"autorail.err.noTorch": "快捷栏里没有红石火把了",
"autorail.err.noHit": "找不到可交互的方块来放置", "autorail.err.noHit": "找不到可交互的方块来放置",
"autorail.err.blocked": "路径被阻挡(该位置无法放置铁轨)", "autorail.err.blocked": "路径被阻挡(该位置无法放置铁轨)",
"autorail.err.stuck": "玩家卡住了,请清理障碍后重试", "autorail.err.stuck": "玩家卡住了,请清理障碍后重试",
"autorail.err.walkFail": "无法自动走到起点(地形过远或过险),请手动接近后重试",
"autorail.err.walkStuck": "前往起点途中卡住,请清理障碍后重试",
"autorail.err.waitTimeout": "等待材料超时,已自动停止",
"autorail.msg.plan.start": "开始寻路({0} 个检查点)…", "autorail.msg.plan.start": "开始寻路({0} 个检查点)…",
"autorail.msg.plan.done": "路径规划完成:{0} 格,开始铺轨(请勿打开其他界面)", "autorail.msg.plan.done": "路径规划完成:{0} 格(动力轨 {1}、探测轨 {2}、火把 {3}),开始铺轨(请勿打开其他界面)",
"autorail.msg.lay.done": "铺轨完成!共 {0} 格", "autorail.msg.lay.done": "铺轨完成!共 {0} 格",
"autorail.msg.stopped": "已停止" "autorail.msg.stopped": "已停止",
"autorail.msg.walk.start": "起点较远,自动前往起点…",
"autorail.msg.walk.done": "已到达起点,开始铺轨(请勿打开其他界面)",
"autorail.msg.waiting": "材料不足,原地等待补充(超时将自动停止)",
"autorail.msg.waitingDone": "材料已补充,继续铺轨",
"autorail.msg.flee": "生命值过低!紧急避险中…",
"autorail.msg.fleeDone": "避险结束,恢复作业",
"autorail.msg.eat": "饥饿值过低,自动进食中…"
} }
+1 -1
查看文件
@@ -3,7 +3,7 @@
"id": "autorail", "id": "autorail",
"version": "${version}", "version": "${version}",
"name": "AutoRail", "name": "AutoRail",
"description": "自动铺铁轨模组:内置 A* 寻路规划可铺轨路线,控制玩家边走边合法右键放置铁轨/圆石,不依赖 Baritone。", "description": "自动铺铁轨工业模组:内置 A* 寻路与自动行走,按用户间隔放置动力铁轨/探测铁轨/红石火把,带材料不足预案、低血量避险与自动进食,不依赖 Baritone。",
"authors": [ "authors": [
"AutoRail" "AutoRail"
], ],