diff --git a/README.md b/README.md index 848fa2d..1849aa0 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,71 @@ -# AutoRail — 自动铺铁轨(Fabric 1.21.4,内置寻路) +# AutoRail — 自动铺铁轨工业模组(Fabric 1.21.4,内置寻路) 按住 **R+T** 打开菜单,输入**起始点**、**目标点**和必经的**途径点**,模组用内置 A* 寻路 -规划一条可铺轨的路线,然后控制玩家边走边铺,直到目标点。**不依赖 Baritone**,寻路与行走全部内置。 +规划一条可铺轨的路线,然后控制玩家自动走到起点、边走边铺,直到目标点。 +**不依赖 Baritone**,寻路与行走全部内置;间隔放置**动力铁轨/探测铁轨/红石火把**, +带**材料不足预案、低血量紧急避险、自动进食**,是完整的自动化铺轨流水线。 ## 使用 1. 安装 [Fabric Loader ≥0.16.9](https://fabricmc.net/use/) 与 [Fabric API](https://modrinth.com/mod/fabric-api),把 `autorail-.jar` 放进 `mods/`。 2. 进世界后按住 **R+T** 打开菜单(先按 T 弹出聊天栏也会被菜单替换掉,正常现象)。 -3. 起始点留空 = 当前坐标;目标点必填;途径点可添加多个(按顺序经过)。 +3. 起始点留空 = 当前坐标;**目标点必填**;途径点可添加多个(按顺序经过)。 坐标格式支持:`100 64 -200`(x y z)、`100 -200`(x z,自动找地面)、`~`、`~ 10 ~`、`~-5` 等相对写法。 -4. 点“开始铺轨”:先分片寻路(菜单/聊天栏显示进度,不卡游戏),完成后自动开始铺设(菜单自动关闭)。 -5. 快捷栏需要**铁轨**;遇到悬空/水面会自动用**圆石**垫脚,没有则提示并停止。 +4. 点“开始铺轨”:先规划铁轨路线;若起点较远,模组会**自动步行走到起点**再开始铺设 + (全程菜单/聊天栏显示进度,不卡游戏)。 +5. 快捷栏需要**铁轨**;启用电气化后还需要**动力铁轨**(和按规则插的**红石火把**/**探测铁轨**); + 遇到悬空/水面会自动用**圆石**垫脚,没有则按预案处理。 6. 铺轨过程中不要打开聊天/背包等界面(行走会暂停);再按 R+T 可查看进度或点“停止”。 +## 放置规则(电气化) + +在菜单里点 **“设置…”** 配置,保存到 `config/autorail.json`: + +| 设置项 | 默认 | 说明 | +| --- | --- | --- | +| 动力铁轨间隔 | 16 格 | 每 N 格放 1 根动力铁轨(0 = 不放置) | +| 探测铁轨间隔 | 0(关) | 每 N 格放 1 个探测铁轨,矿车压过自动激活相邻动力轨 | +| 红石火把间隔 | 12 根动力轨 | 每 N 根动力铁轨旁的支撑方块侧面插 1 支火把持续供电 | +| 材料不足预案 | 等待补充 | **等待**:原地暂停等快捷栏补料(超时可配);**跳过**:缺火把/特殊轨就先铺普通轨保通 | +| 等待超时 | 60 秒 | 等待材料超过该时长自动停止并提示 | +| 低血量避险阈值 | 6 | 生命值低于阈值立即停下后退脱险(0 = 关闭) | +| 自动进食阈值 | 16 | 饥饿度低于阈值自动吃快捷栏里的食物(0 = 关闭) | + +动力铁轨默认由旁边的红石火把持续供电;探测铁轨方案则适合“矿车压过才加速”的线路。 + +## 自动安全系统(铺轨期间后台运行) + +- **低血量紧急避险**:生命值 ≤ 阈值 → 松开前进、后退 + 跳跃拉开距离,恢复后自动回到岗位。 +- **自动进食**:饥饿 ≤ 阈值且血量未满 → 切换到快捷栏食物并长按使用键进食,吃完自动恢复铺轨。 +- **材料不足预案**:铁轨/圆石/火把缺货时按“等待/跳过”预案执行,不会卡死也不会铺出断头路。 +- **卡住自救**:行走受阻先跳跃脱困,持续卡住才报错停止;放置失败自动重试。 + ## 内置寻路(A*) -- 专为铁轨设计:4 向移动、允许 ±1 格坡度、禁止掉头、禁止“转弯+坡度”同格(原版没有斜弯轨)。 -- 每个节点校验:上方两格可通行(玩家沿线行走)、下方有实心支撑或可用圆石垫补、避开水面/岩浆。 -- 转弯有额外代价(尽量走直线);已有铁轨可复用且有折扣,优先延伸既有线路。 -- 分片执行(每 tick 限额展开 4096 节点);搜索范围限制在检查点包围盒外扩 48 格,总展开上限 40 万节点。 +- **铁轨寻路**:4 向移动、±1 格坡度、禁止掉头、“转弯+坡度”同格禁止(原版没有斜弯轨); + 每节点校验头顶两格可通行、下方支撑可垫补,避开水面/岩浆;转弯加价、复用既有铁轨打折。 +- **步行寻路**(去远处起点):4 向 + 跳 1 格 + 落 3 格 + 可涉水,走不到会明确报错。 +- 分片执行(每 tick 限额展开),不卡客户端线程。 ## 铺设(合法交互) - 切换快捷栏 + `processRightClickBlock` 右键放置,**全程合法交互,绝不 `world.setBlockState`**,多人服务器可信任。 -- 放置节奏 200ms 一次;放置前自动看向目标方块;上坡自动跳跃;卡住自动检测并停止报错。 -- 行走只用客户端输入(前进/跳跃键 + 平滑转向),路径按“已铺设节点”推进,不会踩空。 +- 放置节奏 200ms 一次;放置前自动看向目标方块;上坡自动跳跃;卡住自动检测。 ## 代码结构 ```text com.autorail -├─ AutoRailClient # 入口:R+T 组合键 + 每 tick 驱动服务 -├─ gui/AutoRailScreen # 菜单:起点/终点/途径点输入与状态显示 -├─ service/AutoRailService # 状态机:PLANNING → LAYING → DONE/FAILED -├─ service/RailExecutor # 执行器:行走控制 + 合法放置铁轨/圆石 -└─ pathing/ # 内置寻路:RailPathfinder(A*)、RailStep、CoordParser +├─ AutoRailClient # 入口:R+T 组合键 + 每 tick 驱动服务 +├─ gui/AutoRailScreen # 主菜单:起点/终点/途径点 + 设置入口 +├─ gui/AutoRailSettingsScreen # 设置:放置规则 / 预案 / 安全阈值 +├─ config/AutoRailConfig # Gson 持久化配置(config/autorail.json) +├─ 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-.jar ## 已知限制 -- 只铺普通铁轨(`minecraft:rail`),不铺动力铁轨/探测铁轨。 -- 不破坏、不放置除铁轨/圆石外的方块:遇到障碍会绕路而不是挖穿。 -- 起始点必须在玩家附近(水平 6 格、垂直 8 格以内)——本模组不含到远处的自由行走导航。 -- 遇水会垫圆石架桥,但岩浆列直接视为不可通行。 +- 复用既有铁轨不校验类型:老线路上的普通铁轨不会自动升级为动力铁轨。 +- 不破坏、不放置除铁轨/圆石/红石火把外的方块:遇到障碍会绕路而不是挖穿。 +- 遇水会垫圆石架桥,岩浆列视为不可通行。 +- 红石火把在支撑方块侧面放置;若点位被后续地形占用,该处动力轨无供电(线路仍连通)。 +- 自动进食吃的是快捷栏里的食物;食物全靠自带,模组不会杀生取食。 - 基于客户端世界状态判定;服务器延迟时个别放置会失败,会自动重试。 diff --git a/src/main/java/com/autorail/config/AutoRailConfig.java b/src/main/java/com/autorail/config/AutoRailConfig.java new file mode 100644 index 0000000..8207871 --- /dev/null +++ b/src/main/java/com/autorail/config/AutoRailConfig.java @@ -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) { + // 保存失败不影响游戏;下次改动会重试 + } + } +} diff --git a/src/main/java/com/autorail/config/MissingPolicy.java b/src/main/java/com/autorail/config/MissingPolicy.java new file mode 100644 index 0000000..6c6c912 --- /dev/null +++ b/src/main/java/com/autorail/config/MissingPolicy.java @@ -0,0 +1,10 @@ +package com.autorail.config; + +/** + * 材料不足时的预案(用户可配置)。 + * WAIT:原地等待玩家补充材料;SKIP:跳过该特殊件继续铺普通铁轨。 + */ +public enum MissingPolicy { + WAIT, + SKIP +} diff --git a/src/main/java/com/autorail/gui/AutoRailScreen.java b/src/main/java/com/autorail/gui/AutoRailScreen.java index 1be37a3..a947b1d 100644 --- a/src/main/java/com/autorail/gui/AutoRailScreen.java +++ b/src/main/java/com/autorail/gui/AutoRailScreen.java @@ -91,12 +91,15 @@ public class AutoRailScreen extends Screen { btnY = this.height - 52; 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 -> { 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()) - .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; hintY = this.height - 18; @@ -118,10 +121,17 @@ public class AutoRailScreen extends Screen { beginBtn.active = st == AutoRailService.State.IDLE || st == AutoRailService.State.FAILED || 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)); - // 寻路完成进入铺设时自动关闭界面(行走需要关闭界面) - 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); return; } @@ -139,12 +149,6 @@ public class AutoRailScreen extends Screen { var world = this.client.world; try { 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); List cps = new ArrayList<>(); cps.add(start); diff --git a/src/main/java/com/autorail/gui/AutoRailSettingsScreen.java b/src/main/java/com/autorail/gui/AutoRailSettingsScreen.java new file mode 100644 index 0000000..20ba022 --- /dev/null +++ b/src/main/java/com/autorail/gui/AutoRailSettingsScreen.java @@ -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; + } +} diff --git a/src/main/java/com/autorail/pathing/RailPathfinder.java b/src/main/java/com/autorail/pathing/RailPathfinder.java index 94f1fd4..7d764b3 100644 --- a/src/main/java/com/autorail/pathing/RailPathfinder.java +++ b/src/main/java/com/autorail/pathing/RailPathfinder.java @@ -83,6 +83,11 @@ public class RailPathfinder { return opened; } + /** 路径起点(供“自动走近起点”功能作为步行目标);无结果时为 null。 */ + public BlockPos getWalkTarget() { + return result.isEmpty() ? null : result.get(0).railPos(); + } + /** 每帧调用一次,最多展开 budget 个节点。 */ public void step(int budget) { if (state != State.RUNNING) { diff --git a/src/main/java/com/autorail/pathing/WalkPathfinder.java b/src/main/java/com/autorail/pathing/WalkPathfinder.java new file mode 100644 index 0000000..c2c27c2 --- /dev/null +++ b/src/main/java/com/autorail/pathing/WalkPathfinder.java @@ -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 open = new PriorityQueue<>(Comparator.comparingDouble(n -> n.f)); + private final Map best = new HashMap<>(); + private final List 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 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; + } + } +} diff --git a/src/main/java/com/autorail/service/AutoRailService.java b/src/main/java/com/autorail/service/AutoRailService.java index 54f7238..d8ff2f2 100644 --- a/src/main/java/com/autorail/service/AutoRailService.java +++ b/src/main/java/com/autorail/service/AutoRailService.java @@ -1,7 +1,9 @@ package com.autorail.service; +import com.autorail.config.AutoRailConfig; import com.autorail.pathing.RailPathfinder; import com.autorail.pathing.RailStep; +import com.autorail.pathing.WalkPathfinder; import net.minecraft.client.MinecraftClient; import net.minecraft.client.network.ClientPlayerEntity; import net.minecraft.client.world.ClientWorld; @@ -12,19 +14,30 @@ import java.util.List; /** * 铺轨调度服务(单例,GUI 与执行逻辑的桥梁)。 - * 状态机:PLANNING(分片寻路)→ LAYING(行走铺轨)→ DONE / FAILED。 + * 状态机: + * PLANNING(分片铁轨寻路)→ WALK_PLANNING → WALKING(自动走到远处起点) + * → LAYING(行走铺轨)→ DONE / FAILED。 + * LAYING 期间自主行为(低血量避险、饥饿进食)由 {@link AutonomyController} 接管。 */ public class AutoRailService { - public enum State { IDLE, PLANNING, LAYING, DONE, FAILED } + public enum State { IDLE, PLANNING, WALK_PLANNING, WALKING, LAYING, DONE, FAILED } /** 每 tick 寻路展开预算(节点数)。 */ 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 RailPathfinder planner; + private RailPlan plan; private RailExecutor executor; + private WalkPathfinder walkPlanner; + private WalkExecutor walker; + private final AutonomyController autonomy = new AutonomyController(); private String failKey; + private boolean wasWaiting; public State getState() { return state; @@ -32,8 +45,13 @@ public class AutoRailService { public void begin(ClientWorld world, ClientPlayerEntity player, List checkpoints) { planner = new RailPathfinder(world, checkpoints); + plan = null; executor = null; + walkPlanner = null; + walker = null; failKey = null; + wasWaiting = false; + autonomy.releaseKeys(client()); state = State.PLANNING; chat(player, "autorail.msg.plan.start", checkpoints.size()); } @@ -42,17 +60,28 @@ public class AutoRailService { if (executor != null) { 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; + plan = null; executor = null; + walkPlanner = null; + walker = null; state = State.IDLE; if (wasActive && player != null) { chat(player, "autorail.msg.stopped"); } } + private static MinecraftClient client() { + return MinecraftClient.getInstance(); + } + public void tick(MinecraftClient client) { - if (state != State.PLANNING && state != State.LAYING) { + if (state == State.IDLE || state == State.DONE || state == State.FAILED) { return; } ClientPlayerEntity player = client.player; @@ -61,26 +90,101 @@ public class AutoRailService { hardStop(); return; } - if (state == State.PLANNING) { - planner.step(PLAN_BUDGET_PER_TICK); - if (planner.getState() == RailPathfinder.State.FOUND) { - List steps = planner.getResult(); - executor = new RailExecutor(world, steps); - state = State.LAYING; - chat(player, "autorail.msg.plan.done", steps.size()); - } else if (planner.getState() == RailPathfinder.State.FAILED) { - failKey = planner.getFailKey(); - state = State.FAILED; - chat(player, failKey); + AutoRailConfig cfg = AutoRailConfig.get(); + + // 自主行为(紧急避险/自动进食)优先:接管本 tick,铺轨暂停 + String act = autonomy.tick(client, player, cfg); + if (act != null) { + if (executor != null) { + executor.releaseKeys(); + } + if (walker != null) { + walker.releaseKeys(); + } + if ("flee".equals(act) && wasActiveState()) { + chat(player, "autorail.msg.flee"); + } else if ("eat".equals(act) && wasActiveState()) { + chatThrottled(player, "autorail.msg.eat"); } 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 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); + // 材料等待状态提示 + 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.getFailKey() != null) { - failKey = executor.getFailKey(); - state = State.FAILED; - chat(player, failKey); + fail(player, executor.getFailKey()); } else { state = State.DONE; 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() { if (executor != null) { executor.releaseKeys(); } + if (walker != null) { + walker.releaseKeys(); + } + autonomy.releaseKeys(client()); planner = null; + plan = null; executor = null; + walkPlanner = null; + walker = null; 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 底部状态行。 */ public Text statusText() { return switch (state) { case IDLE -> Text.translatable("autorail.gui.status.idle"); case PLANNING -> Text.translatable("autorail.gui.status.planning", planner == null ? 0 : planner.getOpened()); - case LAYING -> executor == null ? Text.translatable("autorail.gui.status.idle") - : Text.translatable("autorail.gui.status.laying", executor.getPlaced(), executor.getTotal()); + case WALK_PLANNING -> Text.translatable("autorail.gui.status.walkPlanning", + 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") : Text.translatable("autorail.gui.status.done", executor.getTotal()); 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) { 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; } diff --git a/src/main/java/com/autorail/service/AutonomyController.java b/src/main/java/com/autorail/service/AutonomyController.java new file mode 100644 index 0000000..083ee9a --- /dev/null +++ b/src/main/java/com/autorail/service/AutonomyController.java @@ -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 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; + } +} diff --git a/src/main/java/com/autorail/service/RailExecutor.java b/src/main/java/com/autorail/service/RailExecutor.java index d2f7abd..c5c86f5 100644 --- a/src/main/java/com/autorail/service/RailExecutor.java +++ b/src/main/java/com/autorail/service/RailExecutor.java @@ -1,8 +1,9 @@ package com.autorail.service; +import com.autorail.config.AutoRailConfig; +import com.autorail.config.MissingPolicy; import com.autorail.pathing.RailStep; import net.minecraft.block.BlockState; -import net.minecraft.block.Blocks; import net.minecraft.client.MinecraftClient; import net.minecraft.client.network.ClientPlayerEntity; import net.minecraft.client.world.ClientWorld; @@ -18,17 +19,23 @@ import net.minecraft.util.math.Vec3d; import java.util.List; /** - * 铺轨执行器:沿已规划好的路径控制玩家行走,并合法放置铁轨/圆石。 + * 铺轨执行器:沿已规划好的路径控制玩家行走,并合法放置铁轨/圆石/红石火把。 * * 行走:平滑转向 + 按下前进/跳跃键(客户端输入,服务端正常结算); * 放置:切换快捷栏 + {@code processRightClickBlock} 右键交互, * 绝不 world.setBlockState,多人服务器可信任。 * 玩家只会走在“已完成”的节点上,放置窗口始终领先于行走目标。 + * + * 放置计划({@link RailPlan}):动力铁轨/探测铁轨按用户间隔混铺; + * 计划中的火把节点先放火把再放铁轨。 + * 材料不足按用户预案处理:WAIT 原地等待(超时停止)/ SKIP 跳过火把继续铺普通轨。 */ public class RailExecutor { /** 放置动作间隔(tick),4 tick = 200ms,符合多人服务器节奏。 */ private static final int ACTION_INTERVAL = 4; + /** 等待材料时的重试间隔(tick)。 */ + private static final int WAIT_RETRY_INTERVAL = 20; /** 放置距离的平方(略保守于 4.5 格交互距离)。 */ private static final double REACH_SQ = 4.2 * 4.2; /** 一次尝试放置的路径窗口(节点数)。 */ @@ -44,6 +51,8 @@ public class RailExecutor { private final ClientWorld world; private final List path; + private final RailPlan plan; + private final AutoRailConfig cfg; private int cursor; private int standIndex; private long tickCounter; @@ -59,9 +68,23 @@ public class RailExecutor { private String failKey; private boolean finished; - public RailExecutor(ClientWorld world, List path) { + // 等待材料状态 + private boolean waiting; + private long waitTicks; + private String waitMsgKey; + + // 火把放置状态:0=未处理 1=已放置 2=已跳过(找不到点位/材料) + private byte[] torchState; + + // 避险暂停(由服务层控制,暂停时行走与放置都停) + private boolean paused; + + public RailExecutor(ClientWorld world, List path, RailPlan plan, AutoRailConfig cfg) { this.world = world; this.path = path; + this.plan = plan; + this.cfg = cfg; + this.torchState = new byte[path.size()]; } public boolean isFinished() { @@ -80,6 +103,27 @@ public class RailExecutor { 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) { if (finished || failKey != null) { return; @@ -90,7 +134,25 @@ public class RailExecutor { } 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))) { cursor++; } @@ -100,27 +162,16 @@ public class RailExecutor { return; } - // 2) 行走(放置阶段会再覆盖朝向到交互点) + // 3) 行走(放置阶段会再覆盖朝向到交互点) standIndex = findStandIndex(player); move(client, player); - // 3) 放置:窗口内第一个“可达且未完成”的节点 + // 4) 放置:窗口内第一个“可达且未完成”的节点 if (tickCounter - lastActionTick >= ACTION_INTERVAL) { - 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; - } + tryPlaceWindow(client, player); } - // 4) 卡住 / 停滞检测 + // 5) 卡住 / 停滞检测 if (lastNeedMove && player.isOnGround() && speedSq(player) < 2.0e-4) { if (stuckSince < 0) { 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) { int frontier = cursor - 1; // 最高已完成节点 @@ -176,17 +242,46 @@ public class RailExecutor { RailStep s = path.get(i); BlockPos rail = s.railPos(); 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"); 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(); BlockState sup = world.getBlockState(support); if (!sup.isSolidBlock(world, support) || !sup.getFluidState().isEmpty()) { // 支撑缺失(悬空/水中):先垫圆石 - int slot = findSlot(player, Items.COBBLESTONE); - if (slot < 0) { + int cobbleSlot = findSlot(player, Items.COBBLESTONE); + if (cobbleSlot < 0) { fail("autorail.err.noCobble"); return; } @@ -195,18 +290,13 @@ public class RailExecutor { fail("autorail.err.noHit"); return; } - selectSlot(player, slot); + selectSlot(player, cobbleSlot); aimAt(player, hit.getPos()); client.interactionManager.interactBlock(player, Hand.MAIN_HAND, hit); player.swingHand(Hand.MAIN_HAND); return; } - int slot = findSlot(player, Items.RAIL); - if (slot < 0) { - fail("autorail.err.noRail"); - return; - } BlockHitResult hit = new BlockHitResult( new Vec3d(support.getX() + 0.5, support.getY() + 1.0, support.getZ() + 0.5), Direction.UP, support, false); @@ -216,6 +306,64 @@ public class RailExecutor { 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 位置。优先用上一节点的支撑侧面。 */ private BlockHitResult findSupportHit(ClientPlayerEntity player, int i, BlockPos support) { if (i > 0) { @@ -246,7 +394,7 @@ public class RailExecutor { return bestHit; } - /** 点击 block 的 face 面(新方块会出现在 support 一侧)。 */ + /** 点击 block 的 face 面(新方块会出现在 face 一侧)。 */ private static BlockHitResult faceHit(BlockPos block, Direction face) { Vec3d p = new Vec3d( block.getX() + 0.5 + face.getOffsetX() * 0.5, @@ -289,7 +437,7 @@ public class RailExecutor { } 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) { @@ -348,6 +496,7 @@ public class RailExecutor { private void fail(String key) { failKey = key; + waiting = false; releaseKeys(); } diff --git a/src/main/java/com/autorail/service/RailPlan.java b/src/main/java/com/autorail/service/RailPlan.java new file mode 100644 index 0000000..7b8b709 --- /dev/null +++ b/src/main/java/com/autorail/service/RailPlan.java @@ -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 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 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 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); + } +} diff --git a/src/main/java/com/autorail/service/WalkExecutor.java b/src/main/java/com/autorail/service/WalkExecutor.java new file mode 100644 index 0000000..138e7c9 --- /dev/null +++ b/src/main/java/com/autorail/service/WalkExecutor.java @@ -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 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 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); + } +} diff --git a/src/main/resources/assets/autorail/lang/en_us.json b/src/main/resources/assets/autorail/lang/en_us.json index f63cbec..1bf9a28 100644 --- a/src/main/resources/assets/autorail/lang/en_us.json +++ b/src/main/resources/assets/autorail/lang/en_us.json @@ -13,9 +13,28 @@ "autorail.gui.begin": "Start laying", "autorail.gui.stop": "Stop", "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.planning": "Pathfinding… {0} nodes opened", + "autorail.gui.status.walkPlanning": "Planning walk route… {0} nodes", + "autorail.gui.status.walking": "Walking to start… {0} / {1}", "autorail.gui.status.laying": "Laying rails… {0} / {1}", + "autorail.gui.status.waiting": "Waiting for materials… {0}s left", "autorail.gui.status.done": "Done! {0} rails placed", "autorail.gui.status.failed": "Failed: {0}", "autorail.err.needWorld": "Enter a world first", @@ -29,11 +48,22 @@ "autorail.err.tooFar": "Too far or too complex for the pathfinder", "autorail.err.noRail": "No rails left 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.blocked": "Path blocked (cannot place a rail there)", "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.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.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…" } diff --git a/src/main/resources/assets/autorail/lang/zh_cn.json b/src/main/resources/assets/autorail/lang/zh_cn.json index 27ee53d..f49f4ea 100644 --- a/src/main/resources/assets/autorail/lang/zh_cn.json +++ b/src/main/resources/assets/autorail/lang/zh_cn.json @@ -13,9 +13,28 @@ "autorail.gui.begin": "开始铺轨", "autorail.gui.stop": "停止", "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.planning": "寻路中… 已展开 {0} 个节点", + "autorail.gui.status.walkPlanning": "规划步行路线… {0} 个节点", + "autorail.gui.status.walking": "自动走向起点… {0} / {1}", "autorail.gui.status.laying": "铺轨中… {0} / {1}", + "autorail.gui.status.waiting": "等待材料中… 剩余 {0} 秒,请补充到快捷栏", "autorail.gui.status.done": "完成!共铺设 {0} 格铁轨", "autorail.gui.status.failed": "失败:{0}", "autorail.err.needWorld": "请先进入世界", @@ -29,11 +48,22 @@ "autorail.err.tooFar": "距离太远或地形太复杂,超出寻路上限", "autorail.err.noRail": "快捷栏里没有铁轨了", "autorail.err.noCobble": "前方需要垫脚,但快捷栏里没有圆石", + "autorail.err.noTorch": "快捷栏里没有红石火把了", "autorail.err.noHit": "找不到可交互的方块来放置", "autorail.err.blocked": "路径被阻挡(该位置无法放置铁轨)", "autorail.err.stuck": "玩家卡住了,请清理障碍后重试", + "autorail.err.walkFail": "无法自动走到起点(地形过远或过险),请手动接近后重试", + "autorail.err.walkStuck": "前往起点途中卡住,请清理障碍后重试", + "autorail.err.waitTimeout": "等待材料超时,已自动停止", "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.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": "饥饿值过低,自动进食中…" } diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index f6d95c6..d069b35 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -3,7 +3,7 @@ "id": "autorail", "version": "${version}", "name": "AutoRail", - "description": "自动铺铁轨模组:内置 A* 寻路规划可铺轨路线,控制玩家边走边合法右键放置铁轨/圆石,不依赖 Baritone。", + "description": "自动铺铁轨工业模组:内置 A* 寻路与自动行走,按用户间隔放置动力铁轨/探测铁轨/红石火把,带材料不足预案、低血量避险与自动进食,不依赖 Baritone。", "authors": [ "AutoRail" ],