diff --git a/src/main/java/autostore/AutoStoreMod.java b/src/main/java/autostore/AutoStoreMod.java new file mode 100644 index 0000000..64628cf --- /dev/null +++ b/src/main/java/autostore/AutoStoreMod.java @@ -0,0 +1,25 @@ +/* + * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152), + * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。 + */ +package autostore; + +import net.fabricmc.api.ModInitializer; + +/** + * 模组主入口(ModInitializer)。 + * + *

本模组为纯客户端模组,真正的初始化逻辑在 {@link autostore.client.AutoStoreModClient} + * 中完成,因此这里的 {@link #onInitialize()} 为空实现。

+ */ +public class AutoStoreMod implements ModInitializer { + + /** + * Fabric 加载器调用此方法完成模组初始化(服务端/通用侧)。 + * 本模组所有功能都位于客户端,故此处无需处理。 + */ + @Override + public void onInitialize() { + // 客户端初始化请参见 AutoStoreModClient + } +} diff --git a/src/main/java/autostore/client/AutoStoreModClient.java b/src/main/java/autostore/client/AutoStoreModClient.java new file mode 100644 index 0000000..c30c904 --- /dev/null +++ b/src/main/java/autostore/client/AutoStoreModClient.java @@ -0,0 +1,145 @@ +/* + * AutoStore 模组客户端主入口(Fabric ClientModInitializer)。 + * 负责初始化配置、启动领土数据服务、注册 tick 事件与聊天监听、注册命令。 + * + *

通过单例 {@link #instance()} 向其他模块提供访问入口。 + */ +package autostore.client; + +import autostore.client.command.AutoStoreCommands; +import autostore.client.config.ConfigStore; +import autostore.client.config.ExpansionConfig; +import autostore.client.controller.ExpansionController; +import autostore.client.gui.AutoStoreGui; +import autostore.client.monitor.SubtitleChatMonitor; +import autostore.client.territory.TerritoryDataService; +import com.mojang.brigadier.CommandDispatcher; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; +import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; +import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents; +import net.minecraft.text.Text; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.option.KeyBinding; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.util.InputUtil; + +@Environment(value=EnvType.CLIENT) +/** + * 客户端主入口(Fabric ClientModInitializer): + * 负责初始化配置、启动领土数据服务、注册 tick 事件与聊天监听、注册命令。 + * + *

通过单例 {@link #instance()} 向其他模块提供访问入口。 + */ +public class AutoStoreModClient +implements ClientModInitializer { + /** 全局单例实例 */ + private static AutoStoreModClient instance; + /** 配置存储(读写 JSON 配置文件) */ + private final ConfigStore configStore = new ConfigStore(); + /** 领土数据服务(定时拉取邦国地图) */ + private final TerritoryDataService dataService = new TerritoryDataService(); + /** 自动扩地控制器 */ + private final ExpansionController controller = new ExpansionController(this.dataService); + /** 当前生效的扩地配置 */ + private ExpansionConfig config; + /** GUI 打开请求标记(在下一帧安全地打开界面) */ + private boolean openGuiRequested; + /** 一键唤出 GUI 的快捷键(默认 Ctrl+R,可在控制设置中修改) */ + private KeyBinding guiKey; + + /** GLFW 键码:R 键 */ + private static final int KEY_R = 82; + /** GLFW 键码:左/右 Ctrl */ + private static final int KEY_LEFT_CTRL = 341; + private static final int KEY_RIGHT_CTRL = 345; + + /** + * 客户端初始化入口: + * 加载配置、以 60 秒间隔启动领土数据刷新、 + * 注册每帧 tick(驱动控制器 + 延迟打开 GUI)、 + * 注册聊天/游戏消息监听(转发给扩地监听器)、注册 /autostore 命令。 + */ + public void onInitializeClient() { + instance = this; + this.config = this.configStore.load(); + this.dataService.start(Math.max(10L, this.config.refreshIntervalSeconds)); + // 注册一键唤出 GUI 的快捷键(默认 Ctrl+R,可在"控制"设置中重新绑定) + this.guiKey = KeyBindingHelper.registerKeyBinding(new KeyBinding("key.autostore.open", KEY_R, "category.autostore")); + ClientTickEvents.END_CLIENT_TICK.register(client -> { + block3: { + this.controller.tick(); + // Ctrl+R 组合键按下时请求打开 GUI(与 /autostore gui 命令走同一安全路径) + while (this.guiKey.wasPressed()) { + if (this.isCtrlDown(client)) { + this.openGui(); + } + } + if (this.openGuiRequested) { + this.openGuiRequested = false; + try { + client.setScreenAndRender(new AutoStoreGui(this)); + } + catch (Throwable throwable) { + if (client.player == null) break block3; + client.player.sendMessage(Text.literal(("AutoStore GUI \u6253\u5f00\u5931\u8d25: " + String.valueOf(throwable))), false); + } + } + } + }); + ClientReceiveMessageEvents.GAME.register((message, overlay) -> this.controller.monitor().onChat(message.getString())); + ClientReceiveMessageEvents.CHAT.register((message, signed, profile, params, instant) -> this.controller.monitor().onChat(message.getString())); + ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> AutoStoreCommands.register(dispatcher, this)); + } + + /** 返回全局单例实例 */ + public static AutoStoreModClient instance() { + return instance; + } + + /** 返回自动扩地控制器 */ + public ExpansionController controller() { + return this.controller; + } + + /** 返回领土数据服务 */ + public TerritoryDataService dataService() { + return this.dataService; + } + + /** 返回配置存储 */ + public ConfigStore configStore() { + return this.configStore; + } + + /** 返回当前生效的扩地配置 */ + public ExpansionConfig config() { + return this.config; + } + + /** 保存当前配置到磁盘 */ + public void saveConfig() { + this.configStore.save(this.config); + } + + /** 返回扩地消息监听器 */ + public SubtitleChatMonitor monitor() { + return this.controller.monitor(); + } + + /** 请求打开扩地 GUI(下一帧安全执行) */ + public void openGui() { + this.openGuiRequested = true; + } + + /** 检查是否按下了 Ctrl 键(左或右) */ + private boolean isCtrlDown(MinecraftClient client) { + long handle = client.getWindow().getHandle(); + return InputUtil.isKeyPressed(handle, KEY_LEFT_CTRL) + || InputUtil.isKeyPressed(handle, KEY_RIGHT_CTRL); + } +} diff --git a/src/main/java/autostore/client/AutoStoreModDataGenerator.java b/src/main/java/autostore/client/AutoStoreModDataGenerator.java new file mode 100644 index 0000000..4304e48 --- /dev/null +++ b/src/main/java/autostore/client/AutoStoreModDataGenerator.java @@ -0,0 +1,31 @@ +/* + * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152), + * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。 + */ +package autostore.client; + +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.fabricmc.fabric.api.datagen.v1.DataGeneratorEntrypoint; +import net.fabricmc.fabric.api.datagen.v1.FabricDataGenerator; + +/** + * Fabric 数据生成入口(DataGeneratorEntrypoint)。 + * + *

用于在开发环境中运行数据生成器(如生成语言文件、模型等)。 + * 当前模组没有需要生成的数据,因此仅创建了一个空的数据包。

+ */ +@Environment(EnvType.CLIENT) +public class AutoStoreModDataGenerator implements DataGeneratorEntrypoint { + + /** + * 数据生成初始化回调。 + * + * @param fabricDataGenerator Fabric 数据生成器 + */ + @Override + public void onInitializeDataGenerator(FabricDataGenerator fabricDataGenerator) { + // 创建一个空的数据包(当前无自定义数据需要生成) + FabricDataGenerator.Pack pack = fabricDataGenerator.createPack(); + } +} diff --git a/src/main/java/autostore/client/bridge/BaritoneBridge.java b/src/main/java/autostore/client/bridge/BaritoneBridge.java new file mode 100644 index 0000000..2cc690f --- /dev/null +++ b/src/main/java/autostore/client/bridge/BaritoneBridge.java @@ -0,0 +1,88 @@ +/* + * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152), + * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。 + */ +package autostore.client.bridge; + +import baritone.api.BaritoneAPI; +import baritone.api.IBaritone; +import baritone.api.Settings; +import baritone.api.pathing.goals.Goal; +import baritone.api.pathing.goals.GoalXZ; +import autostore.client.config.ExpansionConfig; +import autostore.client.territory.ChunkPos; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; + +/** + * Baritone API 封装(桥接层)。 + * + *

将模组对 Baritone 寻路机器人的调用集中在此类中,包括:应用寻路行为配置、 + * 前往目标区块、取消寻路、查询寻路状态等。所有调用都做了空安全保护, + * 当 Baritone 未加载时相关操作会安全地静默失败。

+ */ +@Environment(EnvType.CLIENT) +public final class BaritoneBridge { + + /** + * 将模组配置同步到 Baritone 的全局设置。 + * + * @param config 扩地配置 + */ + public void applyConfig(ExpansionConfig config) { + Settings settings = BaritoneAPI.getSettings(); + settings.allowSprint.value = config.allowSprint; + settings.allowBreak.value = config.allowBreak; + settings.allowPlace.value = config.allowPlace; + settings.allowParkour.value = config.allowJump; + settings.avoidance.value = config.avoidMobs; + } + + /** + * 让 Baritone 寻路前往指定区块的中心。 + * + * @param chunk 目标区块 + */ + public void pathTo(ChunkPos chunk) { + IBaritone baritone = this.primary(); + if (baritone == null) { + return; + } + // 使用 GoalXZ 前往区块中心:Baritone 的 GoalXZ 自带约 10 格容差, + // 只需到达区块中心附近即可,避免中心点不可达导致寻路卡死 + baritone.getCustomGoalProcess().setGoalAndPath(new GoalXZ(chunk.centerBlockX(), chunk.centerBlockZ())); + } + + /** 强制取消当前寻路任务。 */ + public void cancel() { + IBaritone baritone = this.primary(); + if (baritone == null) { + return; + } + baritone.getPathingBehavior().forceCancel(); + } + + /** @return 当前是否正在寻路 */ + public boolean isPathing() { + IBaritone baritone = this.primary(); + return baritone != null && baritone.getPathingBehavior().isPathing(); + } + + /** @return Baritone 是否可用(已加载并存在主实例) */ + public boolean isAvailable() { + return this.primary() != null; + } + + /** + * 获取 Baritone 主实例;加载失败或异常时返回 null。 + * + * @return 主 Baritone 实例,或 null + */ + private IBaritone primary() { + try { + return BaritoneAPI.getProvider().getPrimaryBaritone(); + } catch (Throwable ignored) { + return null; + } + } +} diff --git a/src/main/java/autostore/client/command/AutoStoreCommands.java b/src/main/java/autostore/client/command/AutoStoreCommands.java new file mode 100644 index 0000000..3967799 --- /dev/null +++ b/src/main/java/autostore/client/command/AutoStoreCommands.java @@ -0,0 +1,121 @@ +/* + * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152), + * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。 + */ +package autostore.client.command; + +import autostore.client.AutoStoreModClient; +import autostore.client.config.ExpansionConfig; +import autostore.client.controller.ExpansionController; +import autostore.client.territory.ChunkPos; +import autostore.client.territory.TerritoryState; +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.ArgumentType; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager; +import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource; +import net.minecraft.util.math.BlockPos; +import net.minecraft.text.Text; + +@Environment(value=EnvType.CLIENT) +/** + * 客户端命令注册:向游戏注册 /autostore 系列命令。 + * + *

子命令一览: + *

+ */ +public final class AutoStoreCommands { + private AutoStoreCommands() { + } + + /** 注册全部 /autostore 子命令到命令分发器 */ + public static void register(CommandDispatcher dispatcher, AutoStoreModClient mod) { + dispatcher.register((((((((((ClientCommandManager.literal((String)"autostore").then(ClientCommandManager.literal((String)"gui").executes(ctx -> { + (ctx.getSource()).sendFeedback(Text.literal((String)"\u5df2\u6253\u5f00 AutoStore GUI")); + mod.openGui(); + return 1; + }))).then((ClientCommandManager.literal((String)"start").executes(ctx -> AutoStoreCommands.start(ctx.getSource(), mod, mod.config().targetCount, mod.config().strategy))).then(ClientCommandManager.argument((String)"count", IntegerArgumentType.integer()).executes(ctx -> AutoStoreCommands.start(ctx.getSource(), mod, IntegerArgumentType.getInteger(ctx, (String)"count"), mod.config().strategy))))).then(ClientCommandManager.literal((String)"stop").executes(ctx -> { + mod.controller().stop(); + (ctx.getSource()).sendFeedback(Text.literal((String)"\u5df2\u505c\u6b62\u81ea\u52a8\u6269\u5730")); + return 1; + }))).then(ClientCommandManager.literal((String)"status").executes(ctx -> AutoStoreCommands.status(ctx.getSource(), mod)))).then(ClientCommandManager.literal((String)"refresh").executes(ctx -> { + mod.dataService().refreshAsync(); + (ctx.getSource()).sendFeedback(Text.literal((String)"\u6b63\u5728\u5237\u65b0\u90a6\u56fd\u5730\u56fe")); + return 1; + }))).then(ClientCommandManager.literal((String)"auto").executes(ctx -> { + TerritoryState state = mod.dataService().model().findContainingState(AutoStoreCommands.playerChunk(ctx.getSource())); + if (state == null) { + (ctx.getSource()).sendError(Text.literal((String)"\u65e0\u6cd5\u8bc6\u522b\u5f53\u524d\u90a6\u56fd")); + } else { + mod.config().stateName = state.name(); + mod.saveConfig(); + (ctx.getSource()).sendFeedback(Text.literal(("\u5df2\u81ea\u52a8\u8bc6\u522b\u90a6\u56fd\uff1a" + state.name()))); + } + return 1; + }))).then(ClientCommandManager.literal((String)"state").then(ClientCommandManager.argument((String)"name", StringArgumentType.greedyString()).executes(ctx -> { + String name; + mod.config().stateName = name = StringArgumentType.getString(ctx, (String)"name"); + mod.saveConfig(); + (ctx.getSource()).sendFeedback(Text.literal(("\u5df2\u8bbe\u7f6e\u90a6\u56fd\uff1a" + name))); + return 1; + })))).then(ClientCommandManager.literal((String)"strategy").then(ClientCommandManager.argument((String)"strategy", IntegerArgumentType.integer((int)1, (int)4)).executes(ctx -> { + int strategy; + mod.config().strategy = strategy = IntegerArgumentType.getInteger(ctx, (String)"strategy"); + mod.saveConfig(); + (ctx.getSource()).sendFeedback(Text.literal(("\u5df2\u8bbe\u7f6e\u6269\u5f20\u7b56\u7565\uff1a" + strategy))); + return 1; + })))).then(ClientCommandManager.literal((String)"rings").then(ClientCommandManager.argument((String)"rings", IntegerArgumentType.integer((int)0)).executes(ctx -> { + int rings; + mod.config().maxRings = rings = IntegerArgumentType.getInteger(ctx, (String)"rings"); + mod.saveConfig(); + (ctx.getSource()).sendFeedback(Text.literal((rings <= 0 ? "\u5df2\u8bbe\u7f6e\u6301\u7eed\u6269\u5730" : "\u5df2\u8bbe\u7f6e\u6269\u5708\u4e0a\u9650\uff1a" + rings))); + return 1; + })))).then(ClientCommandManager.literal((String)"help").executes(ctx -> { + (ctx.getSource()).sendFeedback(Text.literal((String)"/autostore help \u663e\u793a\u5e2e\u52a9\n/autostore gui \u6253\u5f00 GUI\uff08\u6216\u6309 F6 \u5feb\u6377\u952e\uff09\n/autostore start \u5f00\u59cb\u6269\u5730\n/autostore start <\u6570\u91cf> \u6307\u5b9a\u76ee\u6807\u6570\u91cf\uff0c-1 \u4e3a\u6301\u7eed\n/autostore stop \u505c\u6b62\n/autostore status \u67e5\u770b\u72b6\u6001\n/autostore refresh \u5237\u65b0\u5730\u56fe\n/autostore auto \u81ea\u52a8\u8bc6\u522b\u90a6\u56fd\n/autostore state <\u90a6\u56fd\u540d> \u8bbe\u7f6e\u90a6\u56fd\n/autostore strategy <1|2|3|4> \u8bbe\u7f6e\u7b56\u7565\n/autostore rings <\u5c42\u6570> \u8bbe\u7f6e\u5708\u6570\u4e0a\u9650")); + return 1; + }))); + } + + /** /autostore start 命令处理:复制当前配置并覆盖目标数量与策略,然后启动扩地 */ + private static int start(FabricClientCommandSource source, AutoStoreModClient mod, int targetCount, int strategy) { + ExpansionConfig config = mod.config().copy(); + config.targetCount = targetCount; + config.strategy = strategy; + mod.config().targetCount = targetCount; + mod.config().strategy = strategy; + mod.saveConfig(); + boolean started = mod.controller().start(config); + source.sendFeedback(Text.literal((started ? "\u5f00\u59cb\u81ea\u52a8\u6269\u5730" : "\u542f\u52a8\u5931\u8d25\uff1a" + mod.controller().status()))); + return started ? 1 : 0; + } + + /** /autostore status 命令处理:汇总状态、进度、队列、策略与邦国信息并反馈 */ + private static int status(FabricClientCommandSource source, AutoStoreModClient mod) { + ExpansionController controller = mod.controller(); + String text = "\u72b6\u6001\uff1a" + controller.status() + " | \u8fdb\u5ea6\uff1a" + controller.completedClaims() + (mod.config().targetCount > 0 ? "/" + mod.config().targetCount : "/\u6301\u7eed") + " | \u961f\u5217\uff1a" + controller.queuedTargets() + " | \u7b56\u7565\uff1a" + mod.config().strategy + " | \u90a6\u56fd\uff1a" + (mod.config().stateName.isEmpty() ? "\u81ea\u52a8" : mod.config().stateName); + source.sendFeedback(Text.literal((String)text)); + return 1; + } + + /** 获取命令执行者(玩家)当前所在区块 */ + private static ChunkPos playerChunk(FabricClientCommandSource source) { + BlockPos pos = source.getPlayer().getBlockPos(); + return ChunkPos.fromBlock(pos.getX(), pos.getZ()); + } +} + diff --git a/src/main/java/autostore/client/config/ConfigStore.java b/src/main/java/autostore/client/config/ConfigStore.java new file mode 100644 index 0000000..441f557 --- /dev/null +++ b/src/main/java/autostore/client/config/ConfigStore.java @@ -0,0 +1,80 @@ +/* + * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152), + * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。 + */ +package autostore.client.config; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.nio.file.attribute.FileAttribute; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.fabricmc.loader.api.FabricLoader; + +/** + * 配置文件读写器。 + * + *

使用 Gson 将 {@link ExpansionConfig} 序列化为 JSON, + * 配置文件位于 Fabric 配置目录下的 {@code autostore.json}。

+ */ +@Environment(EnvType.CLIENT) +public final class ConfigStore { + + /** 用于格式化的 Gson 实例(美化输出) */ + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + /** + * 从磁盘加载配置;文件不存在或解析失败时返回默认配置。 + * + * @return 加载到的配置(不会为 null) + */ + public ExpansionConfig load() { + Path path = this.path(); + if (!Files.exists(path, new LinkOption[0])) { + return new ExpansionConfig(); + } + try { + String json = Files.readString(path, StandardCharsets.UTF_8); + ExpansionConfig loaded = GSON.fromJson(json, ExpansionConfig.class); + if (loaded == null) { + return new ExpansionConfig(); + } + // 以下字段为固定默认值,防止旧配置文件中的异常值影响运行 + loaded.autoEat = true; + loaded.eatThreshold = 14; + loaded.maxRings = 0; + return loaded; + } catch (Exception e) { + // 解析失败时回退到默认配置 + return new ExpansionConfig(); + } + } + + /** + * 将配置保存到磁盘(自动创建父目录)。 + * + * @param config 要保存的配置 + */ + public void save(ExpansionConfig config) { + Path path = this.path(); + try { + if (path.getParent() != null) { + Files.createDirectories(path.getParent(), new FileAttribute[0]); + } + Files.writeString(path, GSON.toJson(config), StandardCharsets.UTF_8, new OpenOption[0]); + } catch (IOException iOException) { + // 保存失败时静默忽略(不阻塞游戏运行) + } + } + + /** @return 配置文件路径(Fabric 配置目录 / autostore.json) */ + private Path path() { + return FabricLoader.getInstance().getConfigDir().resolve("autostore.json"); + } +} diff --git a/src/main/java/autostore/client/config/ExpansionConfig.java b/src/main/java/autostore/client/config/ExpansionConfig.java new file mode 100644 index 0000000..0b5f88b --- /dev/null +++ b/src/main/java/autostore/client/config/ExpansionConfig.java @@ -0,0 +1,116 @@ +/* + * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152), + * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。 + */ +package autostore.client.config; + +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; + +/** + * 自动扩地配置数据类。 + * + *

保存了扩地策略、目标数量、超时重试、区域限制以及 Baritone 寻路行为等全部可配置项, + * 通过 {@link ConfigStore} 持久化为 JSON 文件。

+ */ +@Environment(EnvType.CLIENT) +public class ExpansionConfig { + + /** 扩地策略:1=方形外扩,2=区域扩地,3=玩家周围,4=绕国一圈 */ + public int strategy = 1; + + /** 目标邦国名称;为空时表示自动识别玩家当前所在邦国 */ + public String stateName = ""; + + /** 目标扩地数量;-1 表示持续扩地直到无可用区块 */ + public int targetCount = -1; + + /** 等待/寻路超时时间(秒) */ + public int waitTimeoutSeconds = 60; + + /** 超时后的最大重试次数 */ + public int retries = 2; + + /** 区域扩地策略的中心 X 坐标(区块) */ + public int regionCenterX; + + /** 区域扩地策略的中心 Z 坐标(区块) */ + public int regionCenterZ; + + /** 区域扩地策略的半径(区块) */ + public int regionRadius = 5; + + /** 区域扩地策略的矩形边界(区块坐标);minX > maxX 表示未圈选,使用中心+半径 */ + public int regionMinX = 0; + + /** 区域扩地策略的矩形边界(区块坐标);未圈选时为 -1 */ + public int regionMaxX = -1; + + /** 区域扩地策略的矩形边界(区块坐标);minZ > maxZ 表示未圈选,使用中心+半径 */ + public int regionMinZ = 0; + + /** 区域扩地策略的矩形边界(区块坐标);未圈选时为 -1 */ + public int regionMaxZ = -1; + + /** 是否允许 Baritone 疾跑 */ + public boolean allowSprint = true; + + /** 是否允许 Baritone 游泳 */ + public boolean allowSwim = true; + + /** 是否允许 Baritone 跳跃 */ + public boolean allowJump = true; + + /** 是否允许 Baritone 破坏方块 */ + public boolean allowBreak; + + /** 是否允许 Baritone 放置方块 */ + public boolean allowPlace; + + /** 是否启用避开怪物 */ + public boolean avoidMobs = true; + + /** 是否启用自动进食(饥饿时自动吃背包食物) */ + public boolean autoEat = true; + + /** 触发自动进食的饥饿值阈值(0-20) */ + public int eatThreshold = 14; + + /** 扩圈上限(0 表示不限制) */ + public int maxRings; + + /** 地图自动刷新间隔(秒),最小 10 秒 */ + public int refreshIntervalSeconds = 30; + + /** + * 深拷贝当前配置,返回一个独立的副本。 + * + * @return 与当前配置完全相同的副本 + */ + public ExpansionConfig copy() { + ExpansionConfig c = new ExpansionConfig(); + c.strategy = this.strategy; + c.stateName = this.stateName; + c.targetCount = this.targetCount; + c.waitTimeoutSeconds = this.waitTimeoutSeconds; + c.retries = this.retries; + c.regionCenterX = this.regionCenterX; + c.regionCenterZ = this.regionCenterZ; + c.regionRadius = this.regionRadius; + c.regionMinX = this.regionMinX; + c.regionMaxX = this.regionMaxX; + c.regionMinZ = this.regionMinZ; + c.regionMaxZ = this.regionMaxZ; + c.allowSprint = this.allowSprint; + c.allowSwim = this.allowSwim; + c.allowJump = this.allowJump; + c.allowBreak = this.allowBreak; + c.allowPlace = this.allowPlace; + c.avoidMobs = this.avoidMobs; + c.autoEat = this.autoEat; + c.eatThreshold = this.eatThreshold; + c.maxRings = this.maxRings; + c.refreshIntervalSeconds = this.refreshIntervalSeconds; + return c; + } +} diff --git a/src/main/java/autostore/client/controller/ExpansionController.java b/src/main/java/autostore/client/controller/ExpansionController.java new file mode 100644 index 0000000..2664774 --- /dev/null +++ b/src/main/java/autostore/client/controller/ExpansionController.java @@ -0,0 +1,587 @@ +/* + * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152), + * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。 + */ +package autostore.client.controller; + +import autostore.client.bridge.BaritoneBridge; +import autostore.client.config.ExpansionConfig; +import autostore.client.monitor.SubtitleChatMonitor; +import autostore.client.planner.ExpansionPlanner; +import autostore.client.territory.ChunkPos; +import autostore.client.territory.TerritoryDataService; +import autostore.client.territory.TerritoryModel; +import autostore.client.territory.TerritoryState; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.minecraft.util.Hand; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.entity.player.PlayerInventory; +import net.minecraft.item.ItemStack; +import net.minecraft.util.math.BlockPos; +import net.minecraft.client.MinecraftClient; +import net.minecraft.component.DataComponentTypes; + +@Environment(value=EnvType.CLIENT) +/** + * 自动扩地控制器:扩地任务的核心状态机。 + * + *

工作流程:规划(PLANNING)→ 寻路(PATHING)→ 等待开垦(WAITING)→ 成功/失败(SUCCESS/FAIL)→ 下一个目标(NEXT)→ 完成(COMPLETE)。 + * 每帧由 {@link #tick()} 驱动,通过 Baritone 自动寻路到目标区块, + * 并监听聊天/字幕消息判断开垦是否成功。支持自动进食、超时重试、手动停止等功能。 + */ +public final class ExpansionController +implements SubtitleChatMonitor.Listener { + /** 日志环形缓冲区的最大条数 */ + private static final int MAX_LOGS = 80; + /** Baritone 寻路桥接(负责自动移动) */ + private final BaritoneBridge baritoneBridge = new BaritoneBridge(); + /** 扩地规划器(决定下一个目标区块) */ + private final ExpansionPlanner planner = new ExpansionPlanner(); + /** 聊天/字幕监听器(识别开垦进度与结果) */ + private final SubtitleChatMonitor monitor = new SubtitleChatMonitor(this); + /** 领土数据服务(提供实时领土模型) */ + private final TerritoryDataService dataService; + /** 待占领区块队列 */ + private final Deque queue = new ArrayDeque(); + /** 运行日志队列(线程安全) */ + private final Deque logs = new ArrayDeque(); + /** 当前扩地配置(启动时从请求配置复制) */ + private ExpansionConfig config = new ExpansionConfig(); + /** 当前领土模型 */ + private TerritoryModel model = TerritoryModel.empty(); + /** 当前状态机状态 */ + private State state = State.IDLE; + /** 当前状态描述文本(显示在 GUI/命令中) */ + private String status = "\u672a\u542f\u52a8"; + /** 当前目标区块 */ + private ChunkPos target; + /** 玩家当前所在区块(每帧更新) */ + private ChunkPos playerChunk = new ChunkPos(0, 0); + /** 已成功开垦的区块数量 */ + private int completedClaims; + /** 当前目标的连续重试次数 */ + private int retryCount; + /** 当前阶段(寻路/等待)的开始时间戳 */ + private long stageStartedMillis; + /** 最近一次开垦进度(0-100) */ + private int lastProgress; + /** 当前目标区块的哈希集合,用于去重(防止重复计数) */ + private final Set claimedSet = new HashSet<>(); + /** 是否正在自动进食 */ + private boolean eating; + + public ExpansionController(TerritoryDataService dataService) { + this.dataService = dataService; + } + + public SubtitleChatMonitor monitor() { + return this.monitor; + } + + public State state() { + return this.state; + } + + public String status() { + return this.status; + } + + public int completedClaims() { + return this.completedClaims; + } + + public int queuedTargets() { + return this.queue.size(); + } + + public ChunkPos target() { + return this.target; + } + + public int lastProgress() { + return this.lastProgress; + } + + /** 返回运行日志的副本(线程安全) */ + public List logs() { + Deque deque = this.logs; + synchronized (deque) { + return new ArrayList(this.logs); + } + } + + public boolean isRunning() { + return this.state != State.IDLE && this.state != State.COMPLETE && this.state != State.STOPPED; + } + + /** + * 启动自动扩地任务。 + * + *

前置校验:必须在服务器世界中、Baritone 已就绪; + * 邦国名称为空时自动识别玩家所在邦国,否则校验指定邦国是否存在。 + * 校验通过后清空队列、生成扩地计划并进入 PLANNING 状态。 + * + * @param requestedConfig 本次任务的扩地配置 + * @return 是否成功启动 + */ + public boolean start(ExpansionConfig requestedConfig) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null || client.player == null || client.world == null) { + this.status = "\u8bf7\u5148\u8fdb\u5165\u670d\u52a1\u5668\u4e16\u754c"; + return false; + } + if (!this.baritoneBridge.isAvailable()) { + this.status = "Baritone \u5c1a\u672a\u521d\u59cb\u5316\uff0c\u8bf7\u7a0d\u540e\u518d\u8bd5"; + return false; + } + this.config = requestedConfig.copy(); + this.model = this.dataService.model(); + this.playerChunk = this.playerChunk(client); + if (this.config.stateName.isEmpty()) { + TerritoryState auto = this.model.findContainingState(this.playerChunk); + if (auto == null) { + this.status = "\u65e0\u6cd5\u81ea\u52a8\u8bc6\u522b\u90a6\u56fd\uff0c\u8bf7\u5728 GUI \u4e2d\u624b\u52a8\u9009\u62e9"; + this.state = State.IDLE; + return false; + } + this.model.setSelfStateName(auto.name()); + this.dataService.setSelectedStateName(auto.name()); + } else { + if (this.model.state(this.config.stateName) == null) { + this.status = "\u6240\u9009\u90a6\u56fd\u4e0d\u5b58\u5728\uff1a" + this.config.stateName; + this.state = State.IDLE; + return false; + } + this.model.setSelfStateName(this.config.stateName); + this.dataService.setSelectedStateName(this.config.stateName); + } + this.completedClaims = 0; + this.retryCount = 0; + this.queue.clear(); + this.claimedSet.clear(); + this.target = null; + this.eating = false; + this.baritoneBridge.applyConfig(this.config); + this.setState(State.PLANNING, "\u6b63\u5728\u751f\u6210\u6269\u5f20\u8ba1\u5212"); + this.log("\u5f00\u59cb\u81ea\u52a8\u6269\u5730\uff0c\u90a6\u56fd\uff1a" + this.model.selfStateName()); + this.plan(); + return true; + } + + /** 手动停止任务:取消 Baritone 寻路并进入 STOPPED 状态 */ + public void stop() { + if (this.state == State.IDLE || this.state == State.COMPLETE || this.state == State.STOPPED) { + return; + } + this.baritoneBridge.cancel(); + this.eating = false; + this.setState(State.STOPPED, "\u5df2\u624b\u52a8\u505c\u6b62"); + this.log("\u5df2\u624b\u52a8\u505c\u6b62"); + } + + /** + * 每帧驱动逻辑(由客户端 tick 事件调用): + * 更新玩家区块,优先处理自动进食,再按当前状态分发到对应的 tick 方法。 + * 若已退出世界则自动停止任务。 + */ + public void tick() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null || client.player == null || client.world == null) { + if (this.isRunning()) { + this.stop(); + this.status = "\u4e16\u754c\u5df2\u9000\u51fa\uff0c\u4efb\u52a1\u505c\u6b62"; + } + return; + } + this.playerChunk = this.playerChunk(client); + if (this.tickAutoFood(client)) { + return; + } + switch (this.state.ordinal()) { + case 1: { + this.plan(); + break; + } + case 2: { + this.tickPathing(); + break; + } + case 3: { + this.tickWaiting(); + break; + } + case 4: { + this.tickSuccess(); + break; + } + case 5: { + this.tickFail(); + break; + } + case 6: { + this.tickNext(); + break; + } + } + } + + /** + * 生成扩地计划:调用规划器计算候选区块,按配置过滤圈数上限后入队。 + * 若已达成目标数量或没有可用区块则直接完成。 + */ + private void plan() { + this.model = this.dataService.model(); + if (this.config.targetCount > 0 && this.completedClaims >= this.config.targetCount) { + this.finish(); + return; + } + this.queue.clear(); + List planned = this.planner.generate(this.model, this.config, this.playerChunk); + // 过滤掉规划后已被他人占领的区块,避免寻路到无效目标 + for (ChunkPos chunk : planned) { + if (!this.isClaimedByOthers(chunk)) { + this.queue.add(chunk); + } + } + // 矩形区域扩地时不受扩圈上限限制(玩家圈选的矩形即为明确边界,需完整填充) + boolean rectangleMode = this.config.strategy == 2 && this.config.regionMaxX >= this.config.regionMinX; + if (this.config.maxRings > 0 && !rectangleMode) { + this.queue.removeIf(chunk -> this.ringDistance(chunk, this.model) > this.config.maxRings); + } + this.log("\u89c4\u5212\u5b8c\u6210\uff0c\u5171 " + this.queue.size() + " \u4e2a\u5019\u9009\u533a\u5757"); + if (this.queue.isEmpty()) { + this.setState(State.COMPLETE, "\u6ca1\u6709\u53ef\u7528\u7684\u76f8\u90bb\u65e0\u4e3b\u533a\u5757"); + this.log("\u6ca1\u6709\u53ef\u7528\u7684\u76f8\u90bb\u65e0\u4e3b\u533a\u5757"); + return; + } + this.target = this.queue.removeFirst(); + this.startPathing(); + } + + /** 让 Baritone 寻路前往当前目标区块,并进入 PATHING 状态 */ + private void startPathing() { + this.retryCount = 0; + this.stageStartedMillis = System.currentTimeMillis(); + this.lastProgress = 0; + this.baritoneBridge.pathTo(this.target); + this.setState(State.PATHING, "\u6b63\u5728\u524d\u5f80\u76ee\u6807\u533a\u5757 " + this.target.x() + "," + this.target.z()); + } + + /** + * 寻路阶段每帧逻辑:到达目标区块后进入等待开垦状态; + * 若寻路超时则重试,超过重试次数则跳过该目标。 + */ + private void tickPathing() { + if (this.target == null) { + this.setState(State.NEXT, "\u76ee\u6807\u4e3a\u7a7a"); + return; + } + if (this.playerChunk.equals(this.target)) { + this.baritoneBridge.cancel(); + this.stageStartedMillis = System.currentTimeMillis(); + this.setState(State.WAITING, "\u5df2\u5230\u8fbe\uff0c\u7b49\u5f85\u9886\u5730\u5f00\u62d3"); + return; + } + long elapsedSeconds = (System.currentTimeMillis() - this.stageStartedMillis) / 1000L; + if (elapsedSeconds >= this.config.waitTimeoutSeconds) { + ++this.retryCount; + if (this.retryCount >= this.config.retries) { + this.log("\u5bfb\u8def\u8d85\u65f6\uff0c\u8df3\u8fc7 " + this.target.x() + "," + this.target.z()); + this.setState(State.FAIL, "\u5bfb\u8def\u8d85\u65f6"); + } else { + this.log("\u5bfb\u8def\u8d85\u65f6\uff0c\u91cd\u8bd5 " + this.retryCount + "/" + this.config.retries); + this.retrySameTarget("\u91cd\u8bd5\u524d\u5f80 " + this.target.x() + "," + this.target.z()); + } + } + } + + /** 等待开垦阶段每帧逻辑:玩家离开目标区块则重新寻路;等待超时则重试或跳过当前目标 */ + private void tickWaiting() { + if (this.target == null) { + this.setState(State.NEXT, "\u76ee\u6807\u4e3a\u7a7a"); + return; + } + // 玩家离开了目标区块(被击退/寻路偏差),重新寻路回去 + if (!this.playerChunk.equals(this.target)) { + this.retrySameTarget("\u79bb\u5f00\u76ee\u6807\u533a\u5757\uff0c\u91cd\u65b0\u524d\u5f80 " + this.target.x() + "," + this.target.z()); + return; + } + long elapsedSeconds = (System.currentTimeMillis() - this.stageStartedMillis) / 1000L; + if (elapsedSeconds >= this.config.waitTimeoutSeconds) { + ++this.retryCount; + if (this.retryCount >= this.config.retries) { + this.log("\u7b49\u5f85\u8d85\u65f6\uff0c\u8df3\u8fc7 " + this.target.x() + "," + this.target.z()); + this.setState(State.FAIL, "\u7b49\u5f85\u9886\u5730\u5f00\u62d3\u8d85\u65f6"); + } else { + this.log("\u7b49\u5f85\u8d85\u65f6\uff0c\u91cd\u8bd5 " + this.retryCount + "/" + this.config.retries); + this.retrySameTarget("\u7b49\u5f85\u8d85\u65f6\uff0c\u91cd\u65b0\u524d\u5f80 " + this.target.x() + "," + this.target.z()); + } + } + } + + /** 重试当前目标:取消旧寻路,重新让 Baritone 前往同一目标区块 */ + private void retrySameTarget(String statusText) { + this.baritoneBridge.cancel(); + this.stageStartedMillis = System.currentTimeMillis(); + this.baritoneBridge.pathTo(this.target); + this.setState(State.PATHING, statusText); + } + + /** 成功后的过渡:进入 NEXT 状态,准备处理下一个目标 */ + private void tickSuccess() { + this.setState(State.NEXT, "\u5df2\u5b8c\u6210 " + this.completedClaims + (this.config.targetCount > 0 ? "/" + this.config.targetCount : "/\u6301\u7eed")); + } + + /** 失败后的过渡:进入 NEXT 状态,跳过当前目标 */ + private void tickFail() { + this.setState(State.NEXT, "\u51c6\u5907\u4e0b\u4e00\u4e2a\u76ee\u6807"); + } + + /** + * 处理下一个目标:若已达成目标数量则完成; + * 队列为空时重新规划,否则取出队首区块开始寻路。 + */ + private void tickNext() { + this.target = null; + if (this.config.targetCount > 0 && this.completedClaims >= this.config.targetCount) { + this.finish(); + return; + } + if (this.queue.isEmpty()) { + this.plan(); + return; + } + this.target = this.queue.removeFirst(); + // 目标已被他人占领时跳过,避免无效寻路 + if (this.isClaimedByOthers(this.target)) { + this.log("\u76ee\u6807\u5df2\u88ab\u5360\u9886\uff0c\u8df3\u8fc7 " + this.target.x() + "," + this.target.z()); + this.setState(State.NEXT, "\u8df3\u8fc7\u5df2\u5360\u9886\u533a\u5757"); + return; + } + this.startPathing(); + } + + /** 完成任务:取消寻路、停止进食,进入 COMPLETE 状态 */ + private void finish() { + this.baritoneBridge.cancel(); + this.eating = false; + this.setState(State.COMPLETE, this.config.targetCount > 0 ? "\u5df2\u5b8c\u6210\u76ee\u6807\u6570\u91cf " + this.completedClaims + "/" + this.config.targetCount : "\u5df2\u65e0\u53ef\u6269\u533a\u5757\uff0c\u7d2f\u8ba1\u6269\u5730 " + this.completedClaims); + this.log("\u81ea\u52a8\u6269\u5730\u5b8c\u6210"); + } + + /** 计算区块到己方领土的"圈数"(切比雪夫距离的最小值),用于过滤扩圈上限 */ + private int ringDistance(ChunkPos chunk, TerritoryModel model) { + int distance; + int min = Integer.MAX_VALUE; + TerritoryState self = model.selfState(); + if (self != null) { + for (ChunkPos own : self.chunks()) { + distance = Math.max(Math.abs(chunk.x() - own.x()), Math.abs(chunk.z() - own.z())); + min = Math.min(min, distance); + } + } + for (ChunkPos own : model.sessionClaims()) { + distance = Math.max(Math.abs(chunk.x() - own.x()), Math.abs(chunk.z() - own.z())); + min = Math.min(min, distance); + } + return min == Integer.MAX_VALUE ? 0 : min; + } + + /** 监听器回调:开垦进度更新(0-100%) */ + @Override + public void onProgress(int percent) { + if (!this.isRunning()) { + return; + } + this.lastProgress = percent; + this.status = "\u5f00\u62d3\u4e2d " + percent + "%"; + } + + /** + * 监听器回调:开垦成功(带具体坐标)。 + * 将新占领区块加入模型与数据服务,累计完成数并进入 SUCCESS 状态。 + */ + @Override + public void onSuccess(ChunkPos chunk, int blockX, int blockZ) { + if (!this.isRunning()) { + return; + } + if (chunk == null) { + chunk = this.target; + } + if (chunk == null) { + return; + } + // 去重:同一区块只计一次 + if (!this.claimedSet.add(chunk)) { + return; + } + this.model.addSessionClaim(chunk); + this.dataService.addSessionClaim(chunk); + ++this.completedClaims; + this.target = chunk; + this.lastProgress = 100; + this.log("\u5360\u9886\u6210\u529f " + chunk.x() + "," + chunk.z() + "\uff08\u533a\u5757 " + chunk.x() + "," + chunk.z() + " / \u4e16\u754c " + blockX + "," + blockZ + "\uff09"); + this.setState(State.SUCCESS, "\u7586\u571f\u5df2\u5f00\u62d3 " + chunk.x() + "," + chunk.z()); + } + + /** 监听器回调:开垦成功(无具体坐标的通用消息),按当前目标区块记录 */ + @Override + public void onGenericSuccess() { + if (!this.isRunning()) { + return; + } + if (this.target == null) { + return; + } + // 去重:同一区块只计一次 + if (!this.claimedSet.add(this.target)) { + return; + } + this.model.addSessionClaim(this.target); + this.dataService.addSessionClaim(this.target); + ++this.completedClaims; + this.lastProgress = 100; + this.log("\u5360\u9886\u6210\u529f " + this.target.x() + "," + this.target.z()); + this.setState(State.SUCCESS, "\u7586\u571f\u5df2\u5f00\u62d3 " + this.target.x() + "," + this.target.z()); + } + + /** 获取玩家当前所在区块(由方块坐标换算) */ + private ChunkPos playerChunk(MinecraftClient client) { + BlockPos pos = client.player.getBlockPos(); + return ChunkPos.fromBlock(pos.getX(), pos.getZ()); + } + + /** 判断区块是否已被任何邦国占领(含己方与本次会话) */ + private boolean isClaimedByOthers(ChunkPos chunk) { + return this.dataService.model().allClaimedChunks().contains(chunk); + } + + /** 返回区块的四个正方向邻居 */ + private List cardinalNeighbors(ChunkPos chunk) { + return List.of(new ChunkPos(chunk.x() + 1, chunk.z()), new ChunkPos(chunk.x() - 1, chunk.z()), new ChunkPos(chunk.x(), chunk.z() + 1), new ChunkPos(chunk.x(), chunk.z() - 1)); + } + + /** + * 自动进食逻辑(每帧调用): + * 开启 autoEat 且饥饿值低于阈值时暂停寻路并自动吃背包里的食物, + * 吃饱后恢复任务。返回 true 表示本帧被进食占用。 + */ + private boolean tickAutoFood(MinecraftClient client) { + if (!this.config.autoEat || !this.isRunning()) { + this.eating = false; + return false; + } + int foodLevel = client.player.getHungerManager().getFoodLevel(); + if (this.eating) { + if (client.player.isUsingItem()) { + this.status = "\u81ea\u52a8\u8fdb\u98df\u4e2d"; + return true; + } + if (foodLevel >= 20 || foodLevel > this.config.eatThreshold) { + this.eating = false; + this.resumeAfterEating(); + this.log("\u81ea\u52a8\u8fdb\u98df\u5b8c\u6210"); + return false; + } + return this.beginEating(client, foodLevel); + } + if (foodLevel > this.config.eatThreshold) { + return false; + } + return this.beginEating(client, foodLevel); + } + + /** 开始进食:在背包中找食物并切换主手使用;找不到食物则提示并返回 false */ + private boolean beginEating(MinecraftClient client, int foodLevel) { + if (client.player.isUsingItem()) { + this.eating = true; + this.status = "\u81ea\u52a8\u8fdb\u98df\u4e2d"; + return true; + } + int slot = this.findFoodSlot(client.player.getInventory()); + if (slot < 0) { + this.status = "\u9965\u997f\u4f46\u80cc\u5305\u91cc\u6ca1\u6709\u98df\u7269"; + return false; + } + this.baritoneBridge.cancel(); + client.player.getInventory().selectedSlot = slot; + client.interactionManager.interactItem(client.player, Hand.field_5808); + this.eating = true; + this.status = "\u81ea\u52a8\u8fdb\u98df\u4e2d"; + this.log("\u81ea\u52a8\u8fdb\u98df\uff0c\u9965\u997f\u503c " + foodLevel); + return true; + } + + /** 在背包前 36 格中查找第一个可食用的物品槽位(含 FOOD 组件),找不到返回 -1 */ + private int findFoodSlot(PlayerInventory inventory) { + for (int slot = 0; slot < 36; ++slot) { + ItemStack stack = inventory.getStack(slot); + if (stack.isEmpty() || stack.getComponents().get(DataComponentTypes.field_50075) == null) continue; + return slot; + } + return -1; + } + + /** 进食完成后恢复任务:若仍在寻路阶段则重新发起寻路 */ + private void resumeAfterEating() { + this.eating = false; + if (this.state == State.PATHING && this.target != null) { + this.stageStartedMillis = System.currentTimeMillis(); + this.baritoneBridge.pathTo(this.target); + } + } + + /** 更新状态与状态描述文本 */ + private void setState(State newState, String newStatus) { + this.state = newState; + this.status = newStatus; + } + + /** 追加一条带时间戳的日志,超出上限时移除最旧记录 */ + private void log(String message) { + Deque deque = this.logs; + synchronized (deque) { + this.logs.addLast(System.currentTimeMillis() + " " + message); + while (this.logs.size() > 80) { + this.logs.removeFirst(); + } + } + } + + /** + * 扩地任务状态机枚举: + *

+ */ + @Environment(value=EnvType.CLIENT) + public static enum State { + IDLE, + PLANNING, + PATHING, + WAITING, + SUCCESS, + FAIL, + NEXT, + COMPLETE, + STOPPED; + + } +} + diff --git a/src/main/java/autostore/client/gui/AutoStoreGui.java b/src/main/java/autostore/client/gui/AutoStoreGui.java new file mode 100644 index 0000000..8360a3f --- /dev/null +++ b/src/main/java/autostore/client/gui/AutoStoreGui.java @@ -0,0 +1,660 @@ +/* + * AutoStore 模组自动扩地 GUI 界面:提供可视化的领土地图与扩地配置面板。 + * + *

左侧显示实时领土地图(各邦国区块着色 + 标签 + 玩家位置 + 目标高亮), + * 右侧配置面板包含邦国选择、目标数量、策略选择、高级设置(超时/重试/区域/Baritone 行为)。 + * 支持地图缩放(滚轮)与拖动(鼠标拖拽)。 + */ +package autostore.client.gui; + +import autostore.client.AutoStoreModClient; +import autostore.client.config.ExpansionConfig; +import autostore.client.controller.ExpansionController; +import autostore.client.territory.ChunkPos; +import autostore.client.territory.TerritoryModel; +import autostore.client.territory.TerritoryState; +import java.util.List; +import java.util.function.Consumer; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.minecraft.util.math.BlockPos; +import net.minecraft.text.Text; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.widget.TextFieldWidget; +import net.minecraft.client.gui.Element; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.CheckboxWidget; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.text.StringVisitable; + +@Environment(value=EnvType.CLIENT) +/** + * 自动扩地 GUI 界面:提供可视化的领土地图与扩地配置面板。 + * + *

左侧显示实时领土地图(各邦国区块着色 + 标签 + 玩家位置 + 目标高亮), + * 右侧配置面板包含邦国选择、目标数量、策略选择、高级设置(超时/重试/区域/Baritone 行为)。 + * 支持地图缩放(滚轮)与拖动(鼠标拖拽)。 + */ +public final class AutoStoreGui +extends Screen { + private final AutoStoreModClient mod; + private final ExpansionConfig config; + private final ExpansionController controller; + // ---- 输入控件 ---- + private TextFieldWidget stateField; + private TextFieldWidget targetCountField; + private TextFieldWidget waitTimeoutField; + private TextFieldWidget retriesField; + private TextFieldWidget regionXField; + private TextFieldWidget regionZField; + private TextFieldWidget regionRadiusField; + private TextFieldWidget ringsField; + private TextFieldWidget refreshIntervalField; + private CheckboxWidget sprintBox; + private CheckboxWidget jumpBox; + private CheckboxWidget avoidBox; + private CheckboxWidget breakBox; + private CheckboxWidget placeBox; + private ButtonWidget refreshButton; + // ---- 区域圈选状态 ---- + private boolean selectingRegion; + private boolean draggingRect; + private int rectStartX; + private int rectStartZ; + private int rectEndX; + private int rectEndZ; + private boolean updatingFields; + // ---- 布局尺寸 ---- + private int mapX = 12; + private int mapY = 40; + private int mapW = 420; + private int mapH = 260; + private int panelX = 460; + private int advancedY = 208; + private int stateFieldY = 84; + private int targetFieldY = 116; + private int advRowGap = 24; + // ---- 地图视图 ---- + private double scale = 5.0; + private int viewCenterX; + private int viewCenterZ; + + public AutoStoreGui(AutoStoreModClient mod) { + super(Text.literal((String)"AutoStore \u81ea\u52a8\u6269\u5730")); + this.mod = mod; + this.config = mod.config().copy(); + this.controller = mod.controller(); + } + + /** 界面初始化:根据窗口尺寸自适应布局,创建所有控件并设置初始可见性 */ + protected void init() { + // ===== 自适应布局计算 ===== + // 右侧配置面板:宽度取窗口 1/3(230~260),右对齐并留 8px 边距 + int panelWidth = Math.max(230, Math.min(260, this.width / 3)); + this.panelX = Math.max(12, this.width - panelWidth - 8); + // 左侧地图:填满面板左侧的剩余空间(窄屏时允许更小,避免与面板重叠) + this.mapX = 12; + this.mapY = 40; + this.mapW = Math.max(120, this.panelX - this.mapX - 8); + // 底部信息区高度(日志 + 状态文本),地图填满其上方空间 + int infoHeight = 78; + this.mapH = Math.max(140, this.height - this.mapY - infoHeight - 8); + + // 屏幕较矮时压缩垂直间距,保证右侧面板内容不被截断 + boolean compact = this.height < 480; + int rowGap = compact ? 24 : 28; // 主字段行间距 + this.advRowGap = compact ? 20 : 24; // 高级面板字段行间距 + int advCheckGap = compact ? 16 : 18; // 高级面板复选框行间距 + + // ===== 顶部控制按钮 ===== + int topX = this.panelX; + ButtonWidget startButton = ButtonWidget.builder(Text.literal((String)"\u5f00\u59cb\u81ea\u52a8\u6269\u5730"), button -> this.start()).dimensions(topX, 34, 154, 30).build(); + ButtonWidget stopButton = ButtonWidget.builder(Text.literal((String)"\u505c\u6b62"), button -> this.controller.stop()).dimensions(topX + 160, 34, 74, 30).build(); + this.addDrawableChild(startButton); + this.addDrawableChild(stopButton); + // 地图手动刷新按钮(位于地图右上角,点击立即重新拉取领土数据) + this.refreshButton = ButtonWidget.builder(Text.literal("\u5237\u65b0\u5730\u56fe"), button -> this.mod.dataService().refreshAsync()).dimensions(this.mapX + this.mapW - 64, this.mapY - 16, 60, 16).build(); + this.addDrawableChild(this.refreshButton); + + // ===== 右侧配置字段 ===== + int left = this.panelX + 4; + int fieldX = this.panelX + 82; + int fieldWidth = Math.max(64, panelWidth - 82 - 64 - 6); + int y = 84; + this.stateFieldY = y; + this.stateField = this.textField(this.config.stateName, fieldX, y, fieldWidth, 16); + this.addDrawableChild(this.stateField); + ButtonWidget autoButton = ButtonWidget.builder(Text.literal((String)"\u81ea\u52a8\u8bc6\u522b"), button -> this.autoState()).dimensions(fieldX + fieldWidth + 6, y, 64, 20).build(); + this.addDrawableChild(autoButton); + this.targetFieldY = y += rowGap; + this.targetCountField = this.numberField(this.config.targetCount, fieldX, this.targetFieldY, fieldWidth); + this.addDrawableChild(this.targetCountField); + this.addDrawableChild(ButtonWidget.builder(Text.literal((String)"\u6301\u7eed"), button -> { + this.targetCountField.setText("-1"); + this.config.targetCount = -1; + }).dimensions(fieldX + fieldWidth + 6, this.targetFieldY, 64, 20).build()); + int strategyY = y += rowGap; + this.addDrawableChild(this.strategyButton("\u65b9\u5f62\u5916\u6269", 1, left, strategyY, 90)); + this.addDrawableChild(this.strategyButton("\u533a\u57df\u6269\u5730", 2, left + 96, strategyY, 90)); + this.addDrawableChild(this.strategyButton("\u73a9\u5bb6\u5468\u56f4", 3, left, strategyY + 24, 90)); + this.addDrawableChild(this.strategyButton("\u7ed5\u56fd\u4e00\u5708", 4, left + 96, strategyY + 24, 90)); + + // ===== 高级设置面板:直接显示(无展开/收起按钮),紧贴策略按钮下方 ===== + this.updateRegionButtons(); + // 策略按钮第二行底部 + 8px 间距,作为高级面板起始位置 + this.advancedY = strategyY + 24 + 8; + int advFieldX1 = this.panelX + 62; + int advFieldX2 = this.panelX + 178; + int advFieldWidth = Math.max(46, panelWidth - 184); + this.waitTimeoutField = this.numberField(this.config.waitTimeoutSeconds, advFieldX1, this.advancedY, advFieldWidth); + this.retriesField = this.numberField(this.config.retries, advFieldX2, this.advancedY, advFieldWidth); + this.regionXField = this.numberField(this.config.regionCenterX, advFieldX1, this.advancedY + this.advRowGap, advFieldWidth); + this.regionZField = this.numberField(this.config.regionCenterZ, advFieldX2, this.advancedY + this.advRowGap, advFieldWidth); + this.regionRadiusField = this.numberField(this.config.regionRadius, advFieldX1, this.advancedY + this.advRowGap * 2, advFieldWidth); + this.ringsField = this.numberField(this.config.maxRings, advFieldX2, this.advancedY + this.advRowGap * 2, advFieldWidth); + this.refreshIntervalField = this.numberField(this.config.refreshIntervalSeconds, advFieldX1, this.advancedY + this.advRowGap * 3, advFieldWidth); + this.addDrawableChild(this.waitTimeoutField); + this.addDrawableChild(this.retriesField); + this.addDrawableChild(this.regionXField); + this.addDrawableChild(this.regionZField); + this.addDrawableChild(this.regionRadiusField); + this.addDrawableChild(this.ringsField); + this.addDrawableChild(this.refreshIntervalField); + // 用户手动编辑中心/半径时,清除圈选矩形(避免边界与输入框不一致) + Consumer clearRectOnEdit = s -> { + if (!this.updatingFields) { + this.config.regionMinX = 0; + this.config.regionMaxX = -1; + this.config.regionMinZ = 0; + this.config.regionMaxZ = -1; + this.updateRegionButtons(); + } + }; + this.regionXField.setChangedListener(clearRectOnEdit); + this.regionZField.setChangedListener(clearRectOnEdit); + this.regionRadiusField.setChangedListener(clearRectOnEdit); + int checkY1 = this.advancedY + this.advRowGap * 3 + 20 + 8; + int checkY2 = checkY1 + advCheckGap; + int checkX = left; + this.sprintBox = this.checkbox("\u75be\u8dd1", checkX, checkY1, this.config.allowSprint, value -> { + this.config.allowSprint = value; + }); + this.jumpBox = this.checkbox("\u8df3\u8dc3", checkX + 88, checkY1, this.config.allowJump, value -> { + this.config.allowJump = value; + }); + this.avoidBox = this.checkbox("\u907f\u602a", checkX + 160, checkY1, this.config.avoidMobs, value -> { + this.config.avoidMobs = value; + }); + this.breakBox = this.checkbox("\u7834\u574f", checkX, checkY2, this.config.allowBreak, value -> { + this.config.allowBreak = value; + }); + this.placeBox = this.checkbox("\u653e\u7f6e", checkX + 88, checkY2, this.config.allowPlace, value -> { + this.config.allowPlace = value; + }); + this.addDrawableChild(this.sprintBox); + this.addDrawableChild(this.jumpBox); + this.addDrawableChild(this.avoidBox); + this.addDrawableChild(this.breakBox); + this.addDrawableChild(this.placeBox); + this.initializeMapView(); + } + + /** 创建整数输入框(初始值为 value,最多 12 位) */ + private TextFieldWidget numberField(int value, int x, int y, int width) { + TextFieldWidget field = new TextFieldWidget(this.textRenderer, x, y, width, 20, Text.literal((String)"")); + field.setText(Integer.toString(value)); + field.setMaxLength(12); + return field; + } + + /** 创建文本输入框(初始值为 value,限制最大长度) */ + private TextFieldWidget textField(String value, int x, int y, int width, int maxLength) { + TextFieldWidget field = new TextFieldWidget(this.textRenderer, x, y, width, 20, Text.literal((String)"")); + field.setText(value); + field.setMaxLength(maxLength); + return field; + } + + /** 创建策略选择按钮(点击后设置对应策略编号;区域扩地时自动进入圈选模式,切换其他模式时清除圈选) */ + private ButtonWidget strategyButton(String label, int strategy, int x, int y, int width) { + return ButtonWidget.builder(Text.literal((String)label), button -> { + this.config.strategy = strategy; + if (strategy == 2) { + // 区域扩地:点击后自动进入圈选模式,提示用户在地图上拖拽圈选 + this.selectingRegion = true; + this.draggingRect = false; + } else { + // 切换其他模式:退出圈选并清除已圈选的矩形区域 + this.selectingRegion = false; + this.draggingRect = false; + this.clearRegion(); + } + this.updateRegionButtons(); + }).dimensions(x, y, width, 20).build(); + } + + /** 创建复选框(带回调设置器) */ + private CheckboxWidget checkbox(String label, int x, int y, boolean checked, Consumer setter) { + return CheckboxWidget.builder(Text.literal((String)label), this.textRenderer).pos(x, y).checked(checked).callback((box, value) -> setter.accept(value)).build(); + } + + // ========== 区域圈选方法 ========== + + /** 清除已圈选的矩形区域 */ + private void clearRegion() { + this.config.regionMinX = 0; + this.config.regionMaxX = -1; + this.config.regionMinZ = 0; + this.config.regionMaxZ = -1; + this.selectingRegion = false; + this.draggingRect = false; + this.updateRegionButtons(); + } + + /** 更新圈选状态:非区域扩地模式下自动退出圈选 */ + private void updateRegionButtons() { + if (this.config.strategy != 2) { + this.selectingRegion = false; + this.draggingRect = false; + } + } + + /** 将当前拖拽的矩形提交到配置,并更新高级面板的输入框 */ + private void finalizeRegion() { + if (this.rectStartX == this.rectEndX && this.rectStartZ == this.rectEndZ) { + // 单点无效,清空 + this.clearRegion(); + return; + } + this.config.regionMinX = Math.min(this.rectStartX, this.rectEndX); + this.config.regionMaxX = Math.max(this.rectStartX, this.rectEndX); + this.config.regionMinZ = Math.min(this.rectStartZ, this.rectEndZ); + this.config.regionMaxZ = Math.max(this.rectStartZ, this.rectEndZ); + this.selectingRegion = false; + this.draggingRect = false; + this.updateRegionButtons(); + // 同步更新中心+半径输入框,使高级面板保持一致 + int centerX = (this.config.regionMinX + this.config.regionMaxX) / 2; + int centerZ = (this.config.regionMinZ + this.config.regionMaxZ) / 2; + int radius = Math.max( + this.config.regionMaxX - this.config.regionMinX, + this.config.regionMaxZ - this.config.regionMinZ + ) / 2 + 1; + this.updatingFields = true; + this.regionXField.setText(Integer.toString(centerX)); + this.regionZField.setText(Integer.toString(centerZ)); + this.regionRadiusField.setText(Integer.toString(radius)); + this.updatingFields = false; + } + + /** 绘制区域圈选矩形(覆盖层 + 边框);仅区域扩地模式下绘制 */ + private void drawSelectionRect(DrawContext context) { + if (this.config.strategy != 2) { + return; + } + if (this.config.regionMaxX < this.config.regionMinX && !this.draggingRect) { + return; + } + int minX, maxX, minZ, maxZ; + if (this.draggingRect) { + // 正在拖拽中,使用实时拖拽坐标 + minX = Math.min(this.rectStartX, this.rectEndX); + maxX = Math.max(this.rectStartX, this.rectEndX); + minZ = Math.min(this.rectStartZ, this.rectEndZ); + maxZ = Math.max(this.rectStartZ, this.rectEndZ); + } else { + // 使用已保存的矩形边界 + minX = this.config.regionMinX; + maxX = this.config.regionMaxX; + minZ = this.config.regionMinZ; + maxZ = this.config.regionMaxZ; + } + // 转换四个角到屏幕坐标 + int sx1 = this.chunkScreenX(minX); + int sz1 = this.chunkScreenZ(minZ); + int sx2 = this.chunkScreenX(maxX); + int sz2 = this.chunkScreenZ(maxZ); + int size = Math.max(1, (int)Math.ceil(this.scale)); + int right = Math.max(sx1, sx2) + size; + int bottom = Math.max(sz1, sz2) + size; + int left = Math.min(sx1, sx2); + int top = Math.min(sz1, sz2); + // 限定在地图范围内 + left = Math.max(left, this.mapX); + top = Math.max(top, this.mapY); + right = Math.min(right, this.mapX + this.mapW); + bottom = Math.min(bottom, this.mapY + this.mapH); + if (left >= right || top >= bottom) return; + // 半透明绿色填充 + context.fill(left, top, right, bottom, 0x4022DD22); + // 绿色边框 + context.drawBorder(left, top, right - left, bottom - top, 0xFF22DD22); + } + + /** 初始化地图视图:以玩家所在区块(或己方王城)为地图中心 */ + private void initializeMapView() { + TerritoryModel model = this.mod.dataService().model(); + ChunkPos center = this.playerChunk(); + TerritoryState self = model.selfState(); + if (self != null) { + center = self.capital(); + } + this.viewCenterX = center.x(); + this.viewCenterZ = center.z(); + this.scale = 5.0; + } + + /** 每帧渲染:先渲染父类控件,再绘制地图与信息面板 */ + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + super.render(context, mouseX, mouseY, delta); + this.drawMap(context); + this.drawInfo(context); + } + + /** 绘制地图背景、领土着色、玩家/目标标记与边框标题 */ + private void drawMap(DrawContext context) { + context.fill(this.mapX, this.mapY, this.mapX + this.mapW, this.mapY + this.mapH, -16052716); + this.drawTerritories(context); + this.drawPlayerAndTarget(context); + this.drawSelectionRect(context); + // 区域扩地模式下显示圈选引导提示 + if (this.config.strategy == 2) { + if (this.selectingRegion) { + context.drawCenteredTextWithShadow(this.textRenderer, "\u8bf7\u5728\u5730\u56fe\u4e0a\u62d6\u62fd\u5708\u9009\u9700\u8981\u5360\u9886\u7684\u533a\u57df\uff0c\u677e\u5f00\u9f20\u6807\u786e\u8ba4", this.mapX + this.mapW / 2, this.mapY + this.mapH - 14, -10163); + } else if (this.config.regionMaxX < this.config.regionMinX) { + context.drawCenteredTextWithShadow(this.textRenderer, "\u5c1a\u672a\u5708\u9009\u533a\u57df\uff0c\u8bf7\u5728\u5730\u56fe\u4e0a\u62d6\u62fd\u9009\u62e9\u9700\u8981\u5360\u9886\u7684\u533a\u57df", this.mapX + this.mapW / 2, this.mapY + this.mapH - 14, -65536); + } + } + context.drawBorder(this.mapX, this.mapY, this.mapW, this.mapH, -12761518); + context.drawTextWithShadow(this.textRenderer, "\u9886\u571f\u5730\u56fe", this.mapX + 2, this.mapY - 12, -1513240); + } + + /** 绘制各邦国领土区块(己方高亮、其他半透明),并绘制会话新占领区块 */ + private void drawTerritories(DrawContext context) { + TerritoryModel model = this.mod.dataService().model(); + for (TerritoryState state : model.states()) { + boolean self = state.equals(model.selfState()); + int color = state.color(); + int fill = this.withAlpha(color, self ? 155 : 92); + for (ChunkPos chunk : state.chunks()) { + this.drawChunkFill(context, chunk, fill); + } + if (!this.isVisible(state.capital())) continue; + this.drawStateLabel(context, state, self); + } + for (ChunkPos chunk : model.sessionClaims()) { + this.drawChunkFill(context, chunk, -1938440928); + } + } + + /** 绘制玩家位置(白色方块)与当前目标区块(橙色描边) */ + private void drawPlayerAndTarget(DrawContext context) { + ChunkPos target; + ChunkPos player = this.playerChunk(); + if (this.isVisible(player)) { + int sx = this.chunkScreenX(player.x()); + int sz = this.chunkScreenZ(player.z()); + context.fill(sx - 2, sz - 2, sx + 4, sz + 4, -1); + context.drawBorder(sx - 2, sz - 2, 7, 7, -16777216); + } + if ((target = this.controller.target()) != null && this.isVisible(target)) { + this.drawChunkOutline(context, target, -10163); + } + } + + /** 在邦国首都位置绘制邦国名称标签(缩放太小时不显示) */ + private void drawStateLabel(DrawContext context, TerritoryState state, boolean self) { + if (this.scale < 2.5) { + return; + } + int sx = this.chunkScreenX(state.capital().x()); + int sz = this.chunkScreenZ(state.capital().z()); + int color = self ? -10163 : -1; + context.drawCenteredTextWithShadow(this.textRenderer, state.name(), sx, sz - 10, color); + } + + /** 填充绘制一个区块(超出地图范围时跳过) */ + private void drawChunkFill(DrawContext context, ChunkPos chunk, int color) { + int sx = this.chunkScreenX(chunk.x()); + int sz = this.chunkScreenZ(chunk.z()); + int size = Math.max(1, (int)Math.ceil(this.scale)); + int right = sx + size; + int bottom = sz + size; + if (right < this.mapX || sx > this.mapX + this.mapW || bottom < this.mapY || sz > this.mapY + this.mapH) { + return; + } + context.fill(sx, sz, right, bottom, color); + } + + /** 描边绘制一个区块(用于高亮目标,超出地图范围时跳过) */ + private void drawChunkOutline(DrawContext context, ChunkPos chunk, int color) { + int sx = this.chunkScreenX(chunk.x()); + int sz = this.chunkScreenZ(chunk.z()); + int size = Math.max(1, (int)Math.ceil(this.scale)); + int right = sx + size; + int bottom = sz + size; + if (right < this.mapX || sx > this.mapX + this.mapW || bottom < this.mapY || sz > this.mapY + this.mapH) { + return; + } + context.fill(sx, sz, right, bottom, color); + context.drawBorder(sx, sz, size, size, -16777216); + } + + /** 区块 X 坐标 → 屏幕 X 坐标(以视图中心为基准,按缩放比例换算) */ + private int chunkScreenX(int chunkX) { + return (int)Math.round(this.mapX + this.mapW / 2.0 + (chunkX - this.viewCenterX) * this.scale); + } + + /** 区块 Z 坐标 → 屏幕 Y 坐标 */ + private int chunkScreenZ(int chunkZ) { + return (int)Math.round(this.mapY + this.mapH / 2.0 + (chunkZ - this.viewCenterZ) * this.scale); + } + + /** 屏幕 X 坐标 → 区块 X 坐标(圈选矩形用) */ + private int screenToChunkX(double screenX) { + return (int)Math.round((screenX - this.mapX - this.mapW / 2.0) / this.scale + this.viewCenterX); + } + + /** 屏幕 Y 坐标 → 区块 Z 坐标(圈选矩形用) */ + private int screenToChunkZ(double screenY) { + return (int)Math.round((screenY - this.mapY - this.mapH / 2.0) / this.scale + this.viewCenterZ); + } + + /** 判断区块是否在地图可视范围内(含少量边距) */ + private boolean isVisible(ChunkPos chunk) { + int sx = this.chunkScreenX(chunk.x()); + int sz = this.chunkScreenZ(chunk.z()); + int margin = (int)Math.ceil(this.scale) + 2; + return sx >= this.mapX - margin && sx <= this.mapX + this.mapW + margin && sz >= this.mapY - margin && sz <= this.mapY + this.mapH + margin; + } + + /** 为颜色设置透明度(保留 RGB,替换 alpha 通道) */ + private int withAlpha(int color, int alpha) { + return alpha << 24 | color & 0xFFFFFF; + } + + /** 绘制右侧信息面板:字段标签、运行日志与状态/进度信息 */ + private void drawInfo(DrawContext context) { + int shown; + context.drawTextWithShadow(this.textRenderer, "\u90a6\u56fd", this.panelX + 4, this.stateFieldY + 4, -5592406); + context.drawTextWithShadow(this.textRenderer, "\u76ee\u6807\u6570\u91cf", this.panelX + 4, this.targetFieldY + 4, -5592406); + context.drawTextWithShadow(this.textRenderer, "\u7b49\u5f85\u8d85\u65f6", this.panelX + 4, this.advancedY + 4, -5592406); + context.drawTextWithShadow(this.textRenderer, "\u91cd\u8bd5\u6b21\u6570", this.panelX + 116, this.advancedY + 4, -5592406); + context.drawTextWithShadow(this.textRenderer, "\u533a\u57df\u4e2d\u5fc3X", this.panelX + 4, this.advancedY + this.advRowGap + 4, -5592406); + context.drawTextWithShadow(this.textRenderer, "\u533a\u57df\u4e2d\u5fc3Z", this.panelX + 116, this.advancedY + this.advRowGap + 4, -5592406); + context.drawTextWithShadow(this.textRenderer, "\u533a\u57df\u534a\u5f84", this.panelX + 4, this.advancedY + this.advRowGap * 2 + 4, -5592406); + context.drawTextWithShadow(this.textRenderer, "\u6269\u5708\u4e0a\u9650", this.panelX + 116, this.advancedY + this.advRowGap * 2 + 4, -5592406); + context.drawTextWithShadow(this.textRenderer, "\u5237\u65b0\u95f4\u9694", this.panelX + 4, this.advancedY + this.advRowGap * 3 + 4, -5592406); + int belowY = this.mapY + this.mapH + 12; + int logWidth = Math.max(120, (int)(this.mapW * 0.52)); + int statusX = this.mapX + logWidth + 20; + int statusWidth = this.mapW - logWidth - 20; + List logs = this.controller.logs(); + for (int i = shown = Math.min(4, logs.size()); i > 0; --i) { + String trimmed = this.textRenderer.trimToWidth(logs.get(logs.size() - i), logWidth); + context.drawTextWithShadow(this.textRenderer, trimmed, this.mapX, belowY + (shown - i) * 12, -6577233); + } + int target = this.config.targetCount; + String targetText = target > 0 ? Integer.toString(target) : "\u6301\u7eed"; + context.drawWrappedTextWithShadow(this.textRenderer, Text.literal(("\u72b6\u6001\uff1a" + this.controller.status())), statusX, belowY, statusWidth, -1); + context.drawWrappedTextWithShadow(this.textRenderer, Text.literal(("\u8fdb\u5ea6\uff1a" + this.controller.completedClaims() + " / " + targetText + "\uff0c\u7b49\u5f85\uff1a" + this.controller.lastProgress() + "%")), statusX, belowY + 14, statusWidth, -1); + context.drawWrappedTextWithShadow(this.textRenderer, Text.literal(("\u961f\u5217\uff1a" + this.controller.queuedTargets() + "\uff0c\u6570\u636e\u9519\u8bef\uff1a" + (this.mod.dataService().lastError().isEmpty() ? "\u65e0" : this.mod.dataService().lastError()))), statusX, belowY + 30, statusWidth, -1); + context.drawWrappedTextWithShadow(this.textRenderer, Text.literal(this.refreshStatusText()), statusX, belowY + 46, statusWidth, -5592406); + context.drawWrappedTextWithShadow(this.textRenderer, Text.literal((String)"\u5f00\u59cb\u524d\u8bf7\u786e\u8ba4\u5df2\u624b\u52a8\u6267\u884c /u claimmode"), statusX, belowY + 62, statusWidth, -13210); + } + + /** 生成地图刷新状态文本(显示上次成功刷新距今的时间) */ + private String refreshStatusText() { + long last = this.mod.dataService().lastSuccessfulFetchMillis(); + if (last <= 0) { + return "\u5730\u56fe\u672a\u5237\u65b0"; + } + long seconds = (System.currentTimeMillis() - last) / 1000L; + if (seconds < 60) { + return "\u5730\u56fe\u5df2\u5237\u65b0\uff1a" + seconds + "\u79d2\u524d"; + } + return "\u5730\u56fe\u5df2\u5237\u65b0\uff1a" + (seconds / 60) + "\u5206\u949f\u524d"; + } + + /** 自动识别玩家当前所在邦国并填入邦国输入框 */ + private void autoState() { + TerritoryState auto = this.mod.dataService().model().findContainingState(this.playerChunk()); + if (auto == null) { + this.config.stateName = ""; + this.stateField.setText(""); + } else { + this.config.stateName = auto.name(); + this.stateField.setText(auto.name()); + } + } + + /** 点击"开始"按钮:解析输入到配置,保存到 mod 配置并启动扩地 */ + private void start() { + this.parseToConfig(); + this.saveToModConfig(); + // 应用新的刷新间隔 + this.mod.dataService().reschedule(this.config.refreshIntervalSeconds); + this.controller.start(this.mod.config()); + } + + /** 将各输入框/复选框的值解析到本地配置副本 */ + private void parseToConfig() { + this.config.stateName = this.stateField.getText().trim(); + this.config.targetCount = this.parseInt(this.targetCountField.getText(), 20); + this.config.waitTimeoutSeconds = this.parseInt(this.waitTimeoutField.getText(), 60); + this.config.retries = this.parseInt(this.retriesField.getText(), 2); + this.config.regionCenterX = this.parseInt(this.regionXField.getText(), this.playerChunk().x()); + this.config.regionCenterZ = this.parseInt(this.regionZField.getText(), this.playerChunk().z()); + this.config.regionRadius = this.parseInt(this.regionRadiusField.getText(), 5); + this.config.maxRings = this.parseInt(this.ringsField.getText(), 0); + this.config.refreshIntervalSeconds = this.parseInt(this.refreshIntervalField.getText(), 30); + this.config.allowSprint = this.sprintBox.isChecked(); + this.config.allowJump = this.jumpBox.isChecked(); + this.config.avoidMobs = this.avoidBox.isChecked(); + this.config.allowBreak = this.breakBox.isChecked(); + this.config.allowPlace = this.placeBox.isChecked(); + } + + /** 将本地配置副本同步到 mod 全局配置并保存到磁盘 */ + private void saveToModConfig() { + ExpansionConfig target = this.mod.config(); + target.strategy = this.config.strategy; + target.stateName = this.config.stateName; + target.targetCount = this.config.targetCount; + target.waitTimeoutSeconds = this.config.waitTimeoutSeconds; + target.retries = this.config.retries; + target.regionCenterX = this.config.regionCenterX; + target.regionCenterZ = this.config.regionCenterZ; + target.regionRadius = this.config.regionRadius; + target.regionMinX = this.config.regionMinX; + target.regionMaxX = this.config.regionMaxX; + target.regionMinZ = this.config.regionMinZ; + target.regionMaxZ = this.config.regionMaxZ; + target.maxRings = this.config.maxRings; + target.refreshIntervalSeconds = this.config.refreshIntervalSeconds; + target.allowSprint = this.config.allowSprint; + target.allowJump = this.config.allowJump; + target.avoidMobs = this.config.avoidMobs; + target.allowBreak = this.config.allowBreak; + target.allowPlace = this.config.allowPlace; + this.mod.saveConfig(); + } + + /** 安全解析整数:解析失败时返回默认值 */ + private int parseInt(String value, int fallback) { + try { + return Integer.parseInt(value.trim()); + } + catch (NumberFormatException ignored) { + return fallback; + } + } + + /** 获取玩家当前所在区块(未进入世界时返回 (0,0)) */ + private ChunkPos playerChunk() { + if (this.client.player == null) { + return new ChunkPos(0, 0); + } + BlockPos pos = this.client.player.getBlockPos(); + return ChunkPos.fromBlock(pos.getX(), pos.getZ()); + } + + /** 关闭界面:先保存配置再关闭 */ + public void close() { + this.parseToConfig(); + this.saveToModConfig(); + super.close(); + } + + /** 鼠标滚轮:在地图区域内缩放视图(范围 0.5x - 40x) */ + public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) { + if (this.inMap(mouseX, mouseY)) { + this.scale = Math.max(0.5, Math.min(40.0, this.scale + verticalAmount * 1.2)); + return true; + } + return super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount); + } + + /** 鼠标按下:圈选模式下在地图内开始绘制矩形 */ + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (this.selectingRegion && button == 0 && this.inMap(mouseX, mouseY)) { + this.draggingRect = true; + this.rectStartX = this.screenToChunkX(mouseX); + this.rectStartZ = this.screenToChunkZ(mouseY); + this.rectEndX = this.rectStartX; + this.rectEndZ = this.rectStartZ; + return true; + } + return super.mouseClicked(mouseX, mouseY, button); + } + + /** 鼠标拖拽:圈选模式下更新矩形终点;否则在地图区域内平移视图 */ + public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { + if (this.selectingRegion && this.draggingRect) { + this.rectEndX = this.screenToChunkX(mouseX); + this.rectEndZ = this.screenToChunkZ(mouseY); + return true; + } + if (button == 0 && this.inMap(mouseX, mouseY)) { + this.viewCenterX -= (int)Math.round(deltaX / this.scale); + this.viewCenterZ -= (int)Math.round(deltaY / this.scale); + return true; + } + return super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY); + } + + /** 鼠标释放:圈选模式下提交矩形到配置 */ + public boolean mouseReleased(double mouseX, double mouseY, int button) { + if (this.selectingRegion && this.draggingRect && button == 0) { + this.finalizeRegion(); + return true; + } + return super.mouseReleased(mouseX, mouseY, button); + } + + /** 判断坐标是否在地图区域内 */ + private boolean inMap(double x, double y) { + return x >= this.mapX && x <= (this.mapX + this.mapW) && y >= this.mapY && y <= (this.mapY + this.mapH); + } +} diff --git a/src/main/java/autostore/client/monitor/SubtitleChatMonitor.java b/src/main/java/autostore/client/monitor/SubtitleChatMonitor.java new file mode 100644 index 0000000..0dc48d2 --- /dev/null +++ b/src/main/java/autostore/client/monitor/SubtitleChatMonitor.java @@ -0,0 +1,163 @@ +/* + * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152), + * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。 + */ +package autostore.client.monitor; + +import autostore.client.territory.ChunkPos; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; + +@Environment(value=EnvType.CLIENT) +/** + * 字幕/聊天监听器:解析游戏内消息,识别开垦进度与结果。 + * + *

两条输入通道: + *

+ * 消息匹配采用模糊匹配(Levenshtein 距离),以兼容服务器端文案的细微差异。 + */ +public final class SubtitleChatMonitor { + /** 匹配字幕中的百分比:如 "开垦中 50%" */ + private static final Pattern PERCENT_PATTERN = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*%"); + /** 匹配开垦成功的聊天消息:形如 "疆土(区块X,区块Z) [方块X,方块Z] 已为你的邦国开拓" */ + private static final Pattern CLAIM_MESSAGE_PATTERN = Pattern.compile("\u7586\u571f[\uff08(]\\s*(-?\\d+)\\s*[\uff0c,]\\s*(-?\\d+)\\s*[)\uff09]\\s*\\[\\s*(-?\\d+)\\s*[\uff0c,]\\s*(-?\\d+)\\s*]\\s*\u5df2\u4e3a\u4f60\u7684\u90a6\u56fd\u5f00\u62d3"); + /** 结果回调监听器 */ + private final Listener listener; + + public SubtitleChatMonitor(Listener listener) { + this.listener = listener; + } + + /** + * 处理字幕消息:若包含"开垦"相关文本,则提取其中的百分比并回调进度。 + * 使用模糊匹配容忍服务器文案的细微差异。 + */ + public void onSubtitle(String text) { + if (text == null || text.isEmpty()) { + return; + } + if (!SubtitleChatMonitor.containsFuzzy(text, "\u5f00\u62d3\u4e2d", 2) && !SubtitleChatMonitor.containsFuzzy(text, "\u5f00\u62d3", 1)) { + return; + } + Matcher matcher = PERCENT_PATTERN.matcher(text); + if (matcher.find()) { + try { + int percent = (int)Math.round(Double.parseDouble(matcher.group(1))); + this.listener.onProgress(Math.max(0, Math.min(100, percent))); + } + catch (NumberFormatException numberFormatException) { + // 百分比解析失败时忽略 + } + } + } + + /** + * 处理聊天消息:优先匹配带坐标的开垦成功消息(回调 onSuccess), + * 否则模糊匹配"疆土已开拓/已为你的邦国开拓"等通用成功文案(回调 onGenericSuccess)。 + */ + public void onChat(String text) { + if (text == null || text.isEmpty()) { + return; + } + Matcher matcher = CLAIM_MESSAGE_PATTERN.matcher(text); + if (matcher.find()) { + try { + ChunkPos chunk = new ChunkPos(Integer.parseInt(matcher.group(1)), Integer.parseInt(matcher.group(2))); + int blockX = Integer.parseInt(matcher.group(3)); + int blockZ = Integer.parseInt(matcher.group(4)); + this.listener.onSuccess(chunk, blockX, blockZ); + return; + } + catch (NumberFormatException numberFormatException) { + // 坐标解析失败时忽略,尝试匹配通用消息 + } + } + if (SubtitleChatMonitor.containsFuzzy(text, "\u7586\u571f\u5df2\u5f00\u62d3", 3) || SubtitleChatMonitor.containsFuzzy(text, "\u5df2\u4e3a\u4f60\u7684\u90a6\u56fd\u5f00\u62d3", 4)) { + this.listener.onGenericSuccess(); + } + } + + /** + * 模糊包含匹配:判断 text 中是否存在与 needle 编辑距离 ≤ maxEdits 的子串。 + * 采用滑动窗口 + 有界 Levenshtein 距离,用于容忍服务器文案的细微差异。 + */ + public static boolean containsFuzzy(String text, String needle, int maxEdits) { + if (needle == null || needle.isEmpty()) { + return true; + } + if (text == null) { + return false; + } + int n = text.length(); + int m = needle.length(); + if (m > n + maxEdits) { + return false; + } + boolean start = false; + int end = Math.min(n, m + maxEdits); + int best = maxEdits + 1; + for (int i = 0; i <= n; ++i) { + int from = Math.max(0, i - maxEdits); + int to = Math.min(n, i + m + maxEdits); + if (to - from < m) continue; + int j = from; + while (j + m <= to) { + int distance = SubtitleChatMonitor.boundedLevenshtein(text, j, needle, maxEdits + 1); + if (distance < best) { + best = distance; + } + if (best == 0) { + return true; + } + ++j; + } + } + return best <= maxEdits; + } + + /** + * 有界 Levenshtein 距离:计算 text 从 textOffset 起的子串与 needle 的编辑距离。 + * 当某行最小距离超过 limit 时提前返回(剪枝优化),避免全量计算。 + */ + private static int boundedLevenshtein(String text, int textOffset, String needle, int limit) { + int m = needle.length(); + int[] previous = new int[m + 1]; + int[] current = new int[m + 1]; + for (int j = 0; j <= m; ++j) { + previous[j] = j; + } + int maxI = Math.min(m + limit, text.length() - textOffset); + for (int i = 1; i <= maxI; ++i) { + current[0] = i; + int rowMin = current[0]; + for (int j = 1; j <= m; ++j) { + int cost = text.charAt(textOffset + i - 1) == needle.charAt(j - 1) ? 0 : 1; + current[j] = Math.min(Math.min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost); + rowMin = Math.min(rowMin, current[j]); + } + if (rowMin > limit) { + return limit + 1; + } + int[] swap = previous; + previous = current; + current = swap; + } + return previous[m]; + } + + /** 扩地结果回调接口:进度更新、开垦成功(带坐标)、开垦成功(通用) */ + @Environment(value=EnvType.CLIENT) + public static interface Listener { + public void onProgress(int var1); + + public void onSuccess(ChunkPos var1, int var2, int var3); + + public void onGenericSuccess(); + } +} + diff --git a/src/main/java/autostore/client/planner/ExpansionPlanner.java b/src/main/java/autostore/client/planner/ExpansionPlanner.java new file mode 100644 index 0000000..cf885d4 --- /dev/null +++ b/src/main/java/autostore/client/planner/ExpansionPlanner.java @@ -0,0 +1,467 @@ +/* + * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152), + * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。 + */ +package autostore.client.planner; + +import autostore.client.config.ExpansionConfig; +import autostore.client.territory.ChunkPos; +import autostore.client.territory.TerritoryModel; +import autostore.client.territory.TerritoryState; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; + +@Environment(value=EnvType.CLIENT) +/** + * 扩地规划器:根据当前邦国领土、配置与玩家位置,计算下一步要占领的区块列表。 + * + *

支持三种扩地策略: + *

+ */ +public final class ExpansionPlanner { + /** 世界边界半径(区块数):Minecraft 1.21.4 默认世界边界为 ±2048 区块,此处取 1280 作为安全上限 */ + private static final int WORLD_BORDER_CHUNK_RADIUS = 1280; + + /** + * 根据配置的策略分发到对应的扩地算法。 + * + * @param model 当前邦国领土模型(含所有邦国已占领区块) + * @param config 扩地配置(策略、目标数量、区域范围等) + * @param playerChunk 玩家当前所在区块 + * @return 建议占领的区块列表(按优先级排序) + */ + public List generate(TerritoryModel model, ExpansionConfig config, ChunkPos playerChunk) { + if (config.strategy == 1) { + return this.generateHolePriority(model, config, playerChunk); + } + if (config.strategy == 4) { + return this.generatePerimeter(model, config, playerChunk); + } + // 策略 2 区域扩地:玩家圈选了矩形时,完全按矩形区域扩地 + if (config.strategy == 2 && config.regionMaxX >= config.regionMinX) { + return this.generateRectangle(model, config, playerChunk); + } + return this.generateLine(model, config, playerChunk); + } + + /** + * 策略 1:方形外扩(优先填洞)。 + * + *

以王城为中心,先计算当前领土的覆盖半径,然后从内向外逐圈扫描候选区块。 + * 候选区块必须:未被任何邦国占领、与已有领土相邻(合法)、且在配置的区域内。 + * 找到候选后优先排"领土包围圈内部"的空洞,再排外部边界,最后按距离与邻居数排序。 + */ + private List generateHolePriority(TerritoryModel model, ExpansionConfig config, ChunkPos playerChunk) { + Set actualOwn = this.ownChunks(model); + Set allClaimed = model.allClaimedChunks(); + if (actualOwn.isEmpty()) { + return List.of(); + } + int limit = config.targetCount > 0 ? config.targetCount : 256; + // 以己方领土的外接矩形为基准,逐圈向外扩展搜索,保证候选区块与领土相邻且覆盖完整 + int[] ownBounds = this.bounds(actualOwn); + int maxExpansions = config.maxRings > 0 ? config.maxRings + 1 : 16; + for (int expansion = 0; expansion < maxExpansions; ++expansion) { + HashSet candidates = new HashSet(); + int minX = ownBounds[0] - expansion; + int maxX = ownBounds[1] + expansion; + int minZ = ownBounds[2] - expansion; + int maxZ = ownBounds[3] + expansion; + for (int x = minX; x <= maxX; ++x) { + for (int z = minZ; z <= maxZ; ++z) { + ChunkPos chunk = new ChunkPos(x, z); + if (actualOwn.contains(chunk) || !this.isLegal(chunk, actualOwn, allClaimed) || !this.isInRegion(chunk, config)) continue; + candidates.add(chunk); + } + } + if (!candidates.isEmpty()) { + Comparator comparator = Comparator.comparingInt(playerChunk::manhattanDistance).thenComparingInt(chunk -> -this.ownNeighbors(chunk, actualOwn)); + ArrayList inside = new ArrayList(); + ArrayList outside = new ArrayList(); + for (ChunkPos chunk : candidates) { + if (chunk.x() >= ownBounds[0] && chunk.x() <= ownBounds[1] && chunk.z() >= ownBounds[2] && chunk.z() <= ownBounds[3]) { + inside.add(chunk); + continue; + } + outside.add(chunk); + } + inside.sort(comparator); + outside.sort(comparator); + ArrayList result = new ArrayList(); + result.addAll(inside); + result.addAll(outside); + return result.stream().limit(limit).toList(); + } + } + return List.of(); + } + + /** + * 策略 4:绕国一圈。 + * + *

收集所有与己方领土相邻的合法区块,以领土质心(或王城)为中心, + * 按方位角(atan2)排序,使扩张点均匀分布在领土四周,形成一圈向外扩散的效果。 + */ + private List generatePerimeter(TerritoryModel model, ExpansionConfig config, ChunkPos playerChunk) { + Set actualOwn = this.ownChunks(model); + Set allClaimed = model.allClaimedChunks(); + HashSet plannedOwn = new HashSet(actualOwn); + HashSet candidates = new HashSet(); + ChunkPos center = this.capital(model, this.centroid(actualOwn, playerChunk)); + int limit = config.targetCount > 0 ? config.targetCount : 128; + // 支持多圈扩张:每圈取当前领土的合法邻居,按方位角均匀排序后入队 + int rings = config.maxRings > 0 ? config.maxRings : 1; + ArrayList result = new ArrayList(); + for (int ring = 0; ring < rings && result.size() < limit; ++ring) { + this.addNeighbors(plannedOwn, candidates, allClaimed, config); + if (candidates.isEmpty()) break; + Comparator comparator = Comparator.comparingDouble((ChunkPos chunk) -> this.angle(chunk, center)).thenComparingInt(chunk -> chunk.manhattanDistance(playerChunk)); + List ringChunks = candidates.stream().sorted(comparator).limit((long)(limit - result.size())).toList(); + result.addAll(ringChunks); + plannedOwn.addAll(ringChunks); + candidates.clear(); + } + return result; + } + + /** + * 策略 2/3:直线推进。 + * + *

从玩家当前位置出发,沿一个方向连续向外延伸占领区块(形成一条"线")。 + * 每步从候选集中挑选评分最优的区块:距离越近、越贴合当前方向、邻居越多、形状越规整越好。 + * 当领土形状严重失衡(长宽比超过 2:1)时,会切换方向补短边,避免领土过于细长。 + */ + private List generateLine(TerritoryModel model, ExpansionConfig config, ChunkPos playerChunk) { + Set actualOwn = this.ownChunks(model); + Set allClaimed = model.allClaimedChunks(); + HashSet plannedOwn = new HashSet(actualOwn); + HashSet candidates = new HashSet(); + // 以己方领土中离玩家最近的区块作为推进起点,保证扩张从领土边缘开始 + ChunkPos cursor = this.nearestOwnToPlayer(actualOwn, playerChunk); + ChunkPos direction = null; + int limit = config.targetCount > 0 ? config.targetCount : 128; + this.addNeighbors(plannedOwn, candidates, allClaimed, config); + ArrayList result = new ArrayList(); + // 本次扩张新增区块的边界(用于形状判断,避免被原有领土形状干扰) + int expMinX = Integer.MAX_VALUE; + int expMaxX = Integer.MIN_VALUE; + int expMinZ = Integer.MAX_VALUE; + int expMaxZ = Integer.MIN_VALUE; + while (result.size() < limit) { + ChunkPos nearest; + ChunkPos best; + if (candidates.isEmpty()) { + this.addNeighbors(plannedOwn, candidates, allClaimed, config); + if (candidates.isEmpty()) break; + } + nearest = this.nearestFrontier(candidates, cursor); + // 首次迭代确定推进方向;之后保持方向稳定,避免来回摆动 + if (direction == null) { + direction = this.chooseDirection(cursor, nearest, plannedOwn); + } + best = this.chooseLineCandidate(candidates, plannedOwn, cursor, direction); + if (best == null) { + best = nearest; + } + candidates.remove(best); + result.add(best); + plannedOwn.add(best); + cursor = best; + expMinX = Math.min(expMinX, best.x()); + expMaxX = Math.max(expMaxX, best.x()); + expMinZ = Math.min(expMinZ, best.z()); + expMaxZ = Math.max(expMaxZ, best.z()); + this.addNeighbors(best, plannedOwn, candidates, allClaimed, config); + // 本次扩张形状失衡时转向补短边,并保持该方向 + if (this.expansionImbalanced(expMinX, expMaxX, expMinZ, expMaxZ)) { + direction = this.shortSideDirection(expMinX, expMaxX, expMinZ, expMaxZ); + } + } + return result; + } + + /** + * 策略 2 矩形区域扩地:完全按玩家圈选的矩形区域扩地。 + * + *

先检查矩形区域是否与己方领土接壤(矩形内至少有一个未占领区块与领土相邻), + * 若未接壤则返回空列表(不扩地)。接壤后,从接壤边界开始 BFS 逐层向内, + * 将矩形内所有未被占领的区块全部纳入计划,保证每个区块都与已占领领土连通。 + */ + private List generateRectangle(TerritoryModel model, ExpansionConfig config, ChunkPos playerChunk) { + Set actualOwn = this.ownChunks(model); + Set allClaimed = model.allClaimedChunks(); + if (actualOwn.isEmpty()) { + return List.of(); + } + int minX = config.regionMinX; + int maxX = config.regionMaxX; + int minZ = config.regionMinZ; + int maxZ = config.regionMaxZ; + int limit = config.targetCount > 0 ? config.targetCount : Integer.MAX_VALUE; + // 检查矩形是否与己方领土接壤:矩形内至少有一个未占领区块与领土相邻 + boolean bordersOwn = false; + for (int x = minX; x <= maxX; ++x) { + for (int z = minZ; z <= maxZ; ++z) { + ChunkPos chunk = new ChunkPos(x, z); + if (allClaimed.contains(chunk)) continue; + if (this.ownNeighbors(chunk, actualOwn) > 0) { + bordersOwn = true; + break; + } + } + if (bordersOwn) break; + } + if (!bordersOwn) { + return List.of(); + } + // 初始边界:矩形内与己方领土(原始领土)直接相邻的未占领区块 + HashSet plannedOwn = new HashSet(actualOwn); + ArrayDeque frontier = new ArrayDeque(); + ArrayList result = new ArrayList(); + for (int x = minX; x <= maxX; ++x) { + for (int z = minZ; z <= maxZ; ++z) { + ChunkPos chunk = new ChunkPos(x, z); + if (allClaimed.contains(chunk) || plannedOwn.contains(chunk)) continue; + if (this.ownNeighbors(chunk, actualOwn) > 0) { + plannedOwn.add(chunk); + frontier.add(chunk); + } + } + } + // BFS 逐层向内填满整个矩形 + while (!frontier.isEmpty() && result.size() < limit) { + ChunkPos chunk = frontier.removeFirst(); + result.add(chunk); + for (ChunkPos neighbor : this.cardinalNeighbors(chunk)) { + if (neighbor.x() < minX || neighbor.x() > maxX || neighbor.z() < minZ || neighbor.z() > maxZ) continue; + if (allClaimed.contains(neighbor) || plannedOwn.contains(neighbor)) continue; + plannedOwn.add(neighbor); + frontier.add(neighbor); + } + } + return result; + } + + /** 在候选集中找到离当前游标最近的一个区块(曼哈顿距离),作为推进的前沿 */ + private ChunkPos nearestFrontier(Set candidates, ChunkPos cursor) { + return candidates.stream().min(Comparator.comparingInt(cursor::manhattanDistance)).orElse(null); + } + + /** + * 计算推进方向: + * 若前沿与游标重合,则优先朝一个未占领的"正方向邻居"走; + * 否则返回从游标指向前沿的符号方向(-1/0/1)。 + */ + private ChunkPos chooseDirection(ChunkPos cursor, ChunkPos nearest, Set own) { + if (nearest == null) { + return new ChunkPos(0, 0); + } + if (cursor.equals(nearest)) { + for (ChunkPos candidate : this.cardinalNeighbors(nearest)) { + if (own.contains(candidate)) continue; + return new ChunkPos(candidate.x() - nearest.x(), candidate.z() - nearest.z()); + } + } + return new ChunkPos(Integer.signum(nearest.x() - cursor.x()), Integer.signum(nearest.z() - cursor.z())); + } + + /** + * 直线推进的候选评分:分数越低越优先。 + * 距离近、方向一致(方向点积大)、邻居多、形状影响小 的区块得分更优。 + */ + private ChunkPos chooseLineCandidate(Set candidates, Set plannedOwn, ChunkPos cursor, ChunkPos direction) { + if (direction == null || direction.x() == 0 && direction.z() == 0) { + return null; + } + ChunkPos best = null; + double bestScore = Double.MAX_VALUE; + for (ChunkPos candidate : candidates) { + int dx = candidate.x() - cursor.x(); + int dz = candidate.z() - cursor.z(); + double directionBonus = dx * direction.x() + dz * direction.z(); + double distance = cursor.manhattanDistance(candidate); + double shape = this.shapePenalty(candidate, plannedOwn); + double score = distance * 800.0 - directionBonus * 600.0 - this.ownNeighbors(candidate, plannedOwn) * 260.0 + shape * 200.0; + if (!(score < bestScore)) continue; + bestScore = score; + best = candidate; + } + return best; + } + + /** 判断本次扩张的形状是否失衡:长边超过短边的 2 倍时视为"过瘦",需要转向补短边 */ + private boolean expansionImbalanced(int minX, int maxX, int minZ, int maxZ) { + if (minX == Integer.MAX_VALUE) { + return false; + } + int width = maxX - minX + 1; + int height = maxZ - minZ + 1; + return Math.max(width, height) > Math.min(width, height) * 2; + } + + /** 返回本次扩张短边对应的推进方向:宽 < 高 时向 X 方向补,否则向 Z 方向补 */ + private ChunkPos shortSideDirection(int minX, int maxX, int minZ, int maxZ) { + int width = maxX - minX + 1; + int height = maxZ - minZ + 1; + return width < height ? new ChunkPos(1, 0) : new ChunkPos(0, 1); + } + + /** 返回己方领土中离玩家最近的区块;领土为空时返回玩家所在区块 */ + private ChunkPos nearestOwnToPlayer(Set own, ChunkPos playerChunk) { + if (own.isEmpty()) { + return playerChunk; + } + return own.stream().min(Comparator.comparingInt(playerChunk::manhattanDistance)).orElse(playerChunk); + } + + /** 计算一组区块的外接矩形边界:[minX, maxX, minZ, maxZ] */ + private int[] bounds(Set own) { + int minX = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE; + int minZ = Integer.MAX_VALUE; + int maxZ = Integer.MIN_VALUE; + for (ChunkPos chunk : own) { + minX = Math.min(minX, chunk.x()); + maxX = Math.max(maxX, chunk.x()); + minZ = Math.min(minZ, chunk.z()); + maxZ = Math.max(maxZ, chunk.z()); + } + return new int[]{minX, maxX, minZ, maxZ}; + } + + /** 返回指定区块的四个正方向邻居(上下左右) */ + private List cardinalNeighbors(ChunkPos chunk) { + return List.of(new ChunkPos(chunk.x() + 1, chunk.z()), new ChunkPos(chunk.x() - 1, chunk.z()), new ChunkPos(chunk.x(), chunk.z() + 1), new ChunkPos(chunk.x(), chunk.z() - 1)); + } + + /** 将某区块的合法邻居加入候选集(单个区块版本) */ + private void addNeighbors(ChunkPos chunk, Set plannedOwn, Set candidates, Set allClaimed, ExpansionConfig config) { + for (ChunkPos candidate : this.cardinalNeighbors(chunk)) { + if (!this.isLegal(candidate, plannedOwn, allClaimed) || !this.isInRegion(candidate, config)) continue; + candidates.add(candidate); + } + } + + /** 将一组区块的所有合法邻居加入候选集(批量版本) */ + private void addNeighbors(Set own, Set candidates, Set allClaimed, ExpansionConfig config) { + for (ChunkPos base : own) { + this.addNeighbors(base, own, candidates, allClaimed, config); + } + } + + /** + * 判断区块是否"合法"(可被占领): + * 必须在世界边界内、未被任何邦国占领、未被本次规划占用,且至少与一个己方区块相邻。 + */ + private boolean isLegal(ChunkPos chunk, Set plannedOwn, Set allClaimed) { + if (chunk.x() < -1280 || chunk.x() >= 1280 || chunk.z() < -1280 || chunk.z() >= 1280) { + return false; + } + if (allClaimed.contains(chunk) || plannedOwn.contains(chunk)) { + return false; + } + return this.ownNeighbors(chunk, plannedOwn) > 0; + } + + /** 判断区块是否在配置的扩地区域内(仅策略 2 区域扩地有限制,其他策略恒为 true) */ + private boolean isInRegion(ChunkPos chunk, ExpansionConfig config) { + int dz; + if (config.strategy != 2) { + return true; + } + // 优先使用矩形边界(圈选功能) + if (config.regionMaxX >= config.regionMinX) { + return chunk.x() >= config.regionMinX && chunk.x() <= config.regionMaxX + && chunk.z() >= config.regionMinZ && chunk.z() <= config.regionMaxZ; + } + // 回退到圆形区域(中心+半径) + int dx = Math.abs(chunk.x() - config.regionCenterX); + return Math.max(dx, dz = Math.abs(chunk.z() - config.regionCenterZ)) <= Math.max(1, config.regionRadius); + } + + /** 统计某区块周围(上下左右)属于给定集合的邻居数量 */ + private int ownNeighbors(ChunkPos chunk, Set own) { + int count = 0; + for (ChunkPos neighbor : this.cardinalNeighbors(chunk)) { + if (!own.contains(neighbor)) continue; + ++count; + } + return count; + } + + /** + * 形状惩罚分:评估把候选区块纳入后领土的"规整程度"。 + * 用外接矩形的面积与已有区块数的比值衡量——比值越大说明扩张越"浪费"(形状越散),惩罚越高。 + */ + private double shapePenalty(ChunkPos candidate, Set ownChunks) { + if (ownChunks.isEmpty()) { + return 1.0; + } + int minX = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE; + int minZ = Integer.MAX_VALUE; + int maxZ = Integer.MIN_VALUE; + for (ChunkPos chunk : ownChunks) { + minX = Math.min(minX, chunk.x()); + maxX = Math.max(maxX, chunk.x()); + minZ = Math.min(minZ, chunk.z()); + maxZ = Math.max(maxZ, chunk.z()); + } + minX = Math.min(minX, candidate.x()); + maxX = Math.max(maxX, candidate.x()); + minZ = Math.min(minZ, candidate.z()); + maxZ = Math.max(maxZ, candidate.z()); + long width = (maxX - minX) + 1L; + long height = (maxZ - minZ) + 1L; + long longSide = Math.max(width, height); + double shortSide = Math.max(Math.min(width, height), longSide / 2.0); + double normalizedArea = longSide * shortSide; + return normalizedArea / (ownChunks.size() + 1.0); + } + + /** 计算区块相对中心点的方位角(弧度),用于绕国一圈策略的均匀排序 */ + private double angle(ChunkPos chunk, ChunkPos center) { + return Math.atan2(chunk.z() - center.z(), chunk.x() - center.x()); + } + + /** 计算一组区块的质心(坐标平均值);集合为空时回退到 fallback */ + private ChunkPos centroid(Set chunks, ChunkPos fallback) { + if (chunks.isEmpty()) { + return fallback; + } + long x = 0L; + long z = 0L; + for (ChunkPos chunk : chunks) { + x += chunk.x(); + z += chunk.z(); + } + return new ChunkPos((int)(x / chunks.size()), (int)(z / chunks.size())); + } + + /** 汇总"己方"区块:本次会话新占领的区块 + 己方邦国已拥有的区块 */ + private Set ownChunks(TerritoryModel model) { + HashSet result = new HashSet(model.sessionClaims()); + TerritoryState self = model.selfState(); + if (self != null) { + result.addAll(self.chunks()); + } + return result; + } + + /** 获取己方邦国的王城位置;未识别邦国时回退到 fallback */ + private ChunkPos capital(TerritoryModel model, ChunkPos fallback) { + TerritoryState self = model.selfState(); + return self == null ? fallback : self.capital(); + } +} + diff --git a/src/main/java/autostore/client/territory/ChunkPos.java b/src/main/java/autostore/client/territory/ChunkPos.java new file mode 100644 index 0000000..c74a88d --- /dev/null +++ b/src/main/java/autostore/client/territory/ChunkPos.java @@ -0,0 +1,52 @@ +/* + * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152), + * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。 + */ +package autostore.client.territory; + +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; + +/** + * 区块坐标(record),以“区块”为单位表示二维坐标。 + * + *

Minecraft 中 1 个区块为 16×16 方块,本类提供方块坐标与区块坐标的换算, + * 以及曼哈顿距离等常用工具方法。

+ * + * @param x 区块 X 坐标(= 方块 X / 16) + * @param z 区块 Z 坐标(= 方块 Z / 16) + */ +@Environment(EnvType.CLIENT) +public record ChunkPos(int x, int z) { + + /** + * 由方块坐标换算为区块坐标(向下取整,兼容负数坐标)。 + * + * @param blockX 方块 X 坐标 + * @param blockZ 方块 Z 坐标 + * @return 对应的区块坐标 + */ + public static ChunkPos fromBlock(int blockX, int blockZ) { + return new ChunkPos(Math.floorDiv(blockX, 16), Math.floorDiv(blockZ, 16)); + } + + /** @return 本区块中心的方块 X 坐标 */ + public int centerBlockX() { + return this.x * 16 + 8; + } + + /** @return 本区块中心的方块 Z 坐标 */ + public int centerBlockZ() { + return this.z * 16 + 8; + } + + /** + * 计算与另一区块的曼哈顿距离(|dx| + |dz|)。 + * + * @param other 另一个区块坐标 + * @return 曼哈顿距离 + */ + public int manhattanDistance(ChunkPos other) { + return Math.abs(this.x - other.x) + Math.abs(this.z - other.z); + } +} diff --git a/src/main/java/autostore/client/territory/TerritoryDataService.java b/src/main/java/autostore/client/territory/TerritoryDataService.java new file mode 100644 index 0000000..294105e --- /dev/null +++ b/src/main/java/autostore/client/territory/TerritoryDataService.java @@ -0,0 +1,399 @@ +/* + * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152), + * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。 + */ +package autostore.client.territory; + +import autostore.client.territory.ChunkPos; +import autostore.client.territory.TerritoryModel; +import autostore.client.territory.TerritoryState; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; + +@Environment(value=EnvType.CLIENT) +/** + * 领土数据服务:定时从远程地图服务拉取邦国领土数据(markers.json), + * 解析为 {@link TerritoryModel} 供规划器与 GUI 使用。 + * + *

数据来源是 AutoStore 服务器自带的动态地图(Dynmap)接口,包含: + *

+ * 解析出的领土数据会与本次会话新占领的区块合并,保证扩地过程中地图实时更新。 + */ +public final class TerritoryDataService { + /** 动态地图的 markers.json 接口地址(AutoStore 服务器) */ + private static final String MARKERS_URL = "http://autostore.simpfun.cn/tiles/minecraft_overworld/markers.json"; + /** 从 marker 弹窗文本中提取"疆土归属: xxx"的邦国名 */ + private static final Pattern CLAIM_PATTERN = Pattern.compile("\u7586\u571f\u5f52\u5c5e:\\s*([^<\\r\\n]+)"); + /** 从 marker 弹窗文本中提取"「xxx」的王城"的王城名 */ + private static final Pattern CAPITAL_PATTERN = Pattern.compile("\u300c([^\u300d]+)\u300d\u7684\u738b\u57ce"); + /** HTTP 客户端:10 秒连接超时,自动跟随重定向 */ + private final HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10L)).followRedirects(HttpClient.Redirect.NORMAL).build(); + /** 单线程调度器,用于周期刷新领土数据(守护线程,不阻塞游戏退出) */ + private volatile ScheduledExecutorService executor; + /** 当前解析出的领土模型(volatile 保证多线程可见性) */ + private volatile TerritoryModel model = TerritoryModel.empty(); + /** 当前选中的己方邦国名称 */ + private volatile String selectedStateName = ""; + /** 最近一次拉取失败的错误信息(空串表示无错误) */ + private volatile String lastError = ""; + /** 最近一次成功拉取的时间戳(毫秒) */ + private volatile long lastSuccessfulFetchMillis; + /** 最近一次尝试拉取的时间戳(毫秒) */ + private volatile long lastAttemptMillis; + + public TerritoryDataService() { + this.executor = TerritoryDataService.newExecutor(); + } + + /** 创建单线程调度器(守护线程,不阻塞游戏退出) */ + private static ScheduledExecutorService newExecutor() { + return Executors.newSingleThreadScheduledExecutor(r -> { + Thread thread = new Thread(r, "AutoStore-TerritoryData"); + thread.setDaemon(true); + return thread; + }); + } + + /** + * 启动定时刷新:立即刷新一次,之后按固定间隔(至少 10 秒)循环拉取。 + * + * @param refreshIntervalSeconds 刷新间隔(秒) + */ + public void start(long refreshIntervalSeconds) { + this.refreshAsync(); + this.reschedule(refreshIntervalSeconds); + } + + /** + * 按新间隔重新调度定时刷新(立即生效)。 + * 会关闭旧的调度线程并创建一个新线程,用于 GUI 中修改刷新间隔后即时应用。 + * + * @param refreshIntervalSeconds 新的刷新间隔(秒),最小 10 秒 + */ + public void reschedule(long refreshIntervalSeconds) { + ScheduledExecutorService fresh = TerritoryDataService.newExecutor(); + ScheduledExecutorService old = this.executor; + this.executor = fresh; + long interval = Math.max(10L, refreshIntervalSeconds); + fresh.scheduleWithFixedDelay(this::refreshAsync, interval, interval, TimeUnit.SECONDS); + old.shutdownNow(); + } + + /** 停止定时刷新,关闭调度线程 */ + public void stop() { + this.executor.shutdownNow(); + } + + /** + * 异步刷新领土数据(在调度线程中执行)。 + * 拉取成功后解析 JSON,并把上一份模型中的"会话新占领区块"合并进新模型, + * 避免刷新覆盖掉本次会话的占领记录。 + */ + public void refreshAsync() { + this.executor.execute(() -> { + this.lastAttemptMillis = System.currentTimeMillis(); + try { + HttpRequest request = HttpRequest.newBuilder(URI.create(MARKERS_URL)).timeout(Duration.ofSeconds(15L)).header("User-Agent", "Mozilla/5.0 autostore/1.0").GET().build(); + HttpResponse response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + this.lastError = "HTTP " + response.statusCode(); + return; + } + TerritoryModel parsed = TerritoryDataService.parse(response.body()); + TerritoryModel previous = this.model; + if (previous != null) { + for (ChunkPos sessionClaim : previous.sessionClaims()) { + parsed.addSessionClaim(sessionClaim); + } + } + parsed.setSelfStateName(this.selectedStateName); + this.model = parsed; + this.lastSuccessfulFetchMillis = System.currentTimeMillis(); + this.lastError = ""; + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + this.lastError = "\u62c9\u53d6\u88ab\u4e2d\u65ad"; + } + catch (Exception e) { + this.lastError = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); + } + }); + } + + public TerritoryModel model() { + return this.model; + } + + public void setSelectedStateName(String name) { + this.selectedStateName = name == null ? "" : name; + TerritoryModel current = this.model; + if (current != null) { + current.setSelfStateName(this.selectedStateName); + } + } + + public String selectedStateName() { + return this.selectedStateName; + } + + public void addSessionClaim(ChunkPos chunk) { + TerritoryModel current = this.model; + if (current != null) { + current.addSessionClaim(chunk); + } + } + + public String lastError() { + return this.lastError; + } + + public long lastSuccessfulFetchMillis() { + return this.lastSuccessfulFetchMillis; + } + + public long lastAttemptMillis() { + return this.lastAttemptMillis; + } + + /** + * 解析 markers.json 文本为领土模型。 + * + *

JSON 结构为数组,每层包含 id 与 markers 列表: + *

    + *
  • union_claims 层:每个 marker 是一个领土多边形,弹窗文本含"疆土归属: 邦国名"
  • + *
  • union_capitals 层:每个 marker 是一个王城坐标点,弹窗文本含"「邦国名」的王城"
  • + *
+ * 最终按邦国名聚合为 {@link TerritoryState} 列表。 + */ + public static TerritoryModel parse(String json) { + JsonElement root = JsonParser.parseString((String)json); + if (!root.isJsonArray()) { + throw new IllegalArgumentException("markers.json must be an array"); + } + HashMap> claims = new HashMap>(); + HashMap claimColors = new HashMap(); + HashMap capitals = new HashMap(); + for (JsonElement layerElement : root.getAsJsonArray()) { + JsonObject layer = layerElement.getAsJsonObject(); + String id = TerritoryDataService.stringOr(layer, "id", ""); + JsonArray markers = layer.getAsJsonArray("markers"); + if (markers == null) continue; + if ("union_claims".equals(id)) { + for (JsonElement markerElement : markers) { + TerritoryDataService.parseClaimMarker(markerElement.getAsJsonObject(), claims, claimColors); + } + continue; + } + if (!"union_capitals".equals(id)) continue; + for (JsonElement markerElement : markers) { + TerritoryDataService.parseCapitalMarker(markerElement.getAsJsonObject(), capitals); + } + } + HashMap states = new HashMap(); + for (Map.Entry entry : claims.entrySet()) { + String name = (String)entry.getKey(); + ChunkPos capital = capitals.getOrDefault(name, TerritoryDataService.firstChunk((Set)entry.getValue())); + int color = claimColors.getOrDefault(name, -9061088); + states.put(name, new TerritoryState(name, capital, Set.copyOf((Collection)entry.getValue()), color)); + } + return new TerritoryModel(states); + } + + /** + * 解析一个领土多边形 marker:从弹窗文本提取邦国名, + * 将多边形覆盖的区块加入该邦国的区块集合,并记录颜色。 + */ + private static void parseClaimMarker(JsonObject marker, Map> claims, Map colors) { + String popup = TerritoryDataService.stringOr(marker, "popup", ""); + Matcher matcher = CLAIM_PATTERN.matcher(popup); + if (!matcher.find()) { + return; + } + String name = matcher.group(1).trim(); + if (name.isEmpty()) { + return; + } + Set chunks = claims.computeIfAbsent(name, ignored -> new HashSet()); + for (List polygon : TerritoryDataService.extractPolygons(marker.get("points"))) { + TerritoryDataService.fillPolygon(chunks, polygon); + } + colors.putIfAbsent(name, TerritoryDataService.parseColor(TerritoryDataService.stringOr(marker, "color", "#75bd20"))); + } + + /** 解析一个王城 marker:从弹窗文本提取邦国名,记录王城的方块坐标(换算为区块坐标) */ + private static void parseCapitalMarker(JsonObject marker, Map capitals) { + String popup = TerritoryDataService.stringOr(marker, "popup", ""); + Matcher matcher = CAPITAL_PATTERN.matcher(popup); + if (!matcher.find()) { + return; + } + JsonObject point = marker.getAsJsonObject("point"); + if (point == null) { + return; + } + int x = TerritoryDataService.intOr(point, "x", 0); + int z = TerritoryDataService.intOr(point, "z", 0); + capitals.put(matcher.group(1).trim(), ChunkPos.fromBlock(x, z)); + } + + /** + * 递归提取多边形列表:points 可能是单个多边形(点数组), + * 也可能是多边形数组(多层嵌套),统一展平为 List>。 + */ + private static List> extractPolygons(JsonElement points) { + ArrayList> result = new ArrayList>(); + if (points == null || !points.isJsonArray()) { + return result; + } + JsonArray array = points.getAsJsonArray(); + if (array.size() == 0) { + return result; + } + if (TerritoryDataService.isPointObject(array.get(0))) { + List polygon = TerritoryDataService.parsePointList((JsonElement)array); + if (polygon.size() >= 3) { + result.add(polygon); + } + } else { + for (JsonElement element : array) { + result.addAll(TerritoryDataService.extractPolygons(element)); + } + } + return result; + } + + /** 判断 JSON 元素是否是一个"点对象"(同时含 x 与 z 字段) */ + private static boolean isPointObject(JsonElement element) { + return element != null && element.isJsonObject() && element.getAsJsonObject().has("x") && element.getAsJsonObject().has("z"); + } + + /** 解析一个点数组为 int[] 列表(每个点取 x、z 两个坐标) */ + private static List parsePointList(JsonElement element) { + ArrayList points = new ArrayList(); + if (!element.isJsonArray()) { + return points; + } + for (JsonElement pointElement : element.getAsJsonArray()) { + JsonObject point = pointElement.getAsJsonObject(); + if (point == null) continue; + points.add(new int[]{TerritoryDataService.intOr(point, "x", 0), TerritoryDataService.intOr(point, "z", 0)}); + } + return points; + } + + /** + * 将多边形覆盖的区块填充进集合: + * 先算出多边形外接矩形(换算为区块坐标),再逐区块用"点在多边形内"算法判断, + * 以区块中心点(cx*16+8, cz*16+8)是否落在多边形内为准。 + * 面积超过 600 万区块时直接放弃,防止异常数据导致卡死。 + */ + private static void fillPolygon(Set output, List polygon) { + int area; + int minX = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE; + int minZ = Integer.MAX_VALUE; + int maxZ = Integer.MIN_VALUE; + for (int[] point : polygon) { + minX = Math.min(minX, Math.floorDiv(point[0], 16)); + maxX = Math.max(maxX, Math.floorDiv(point[0], 16)); + minZ = Math.min(minZ, Math.floorDiv(point[1], 16)); + maxZ = Math.max(maxZ, Math.floorDiv(point[1], 16)); + } + int n = area = ((maxX - minX) + 1L) * ((maxZ - minZ) + 1L) > Integer.MAX_VALUE ? Integer.MAX_VALUE : (maxX - minX + 1) * (maxZ - minZ + 1); + if (area > 6000000) { + return; + } + for (int cx = minX; cx <= maxX; ++cx) { + for (int cz = minZ; cz <= maxZ; ++cz) { + if (!TerritoryDataService.pointInPolygon(cx * 16 + 8, cz * 16 + 8, polygon)) continue; + output.add(new ChunkPos(cx, cz)); + } + } + } + + /** 射线法判断点 (x, z) 是否在多边形内(经典奇偶规则) */ + private static boolean pointInPolygon(int x, int z, List polygon) { + boolean inside = false; + int i = 0; + int j = polygon.size() - 1; + while (i < polygon.size()) { + boolean intersects; + int xi = polygon.get(i)[0]; + int zi = polygon.get(i)[1]; + int xj = polygon.get(j)[0]; + int zj = polygon.get(j)[1]; + boolean bl = intersects = zi > z != zj > z && x < (xj - xi) * (z - zi) / (zj - zi) + xi; + if (intersects) { + inside = !inside; + } + j = i++; + } + return inside; + } + + /** 取集合中的第一个区块作为默认王城(当该邦国没有标记王城时) */ + private static ChunkPos firstChunk(Set chunks) { + return chunks.stream().findFirst().orElse(new ChunkPos(0, 0)); + } + + /** 安全读取 JSON 字符串字段,缺失或为 null 时返回默认值 */ + private static String stringOr(JsonObject object, String key, String fallback) { + JsonElement element = object.get(key); + return element == null || element.isJsonNull() ? fallback : element.getAsString(); + } + + /** 安全读取 JSON 整数字段,缺失或为 null 时返回默认值 */ + private static int intOr(JsonObject object, String key, int fallback) { + JsonElement element = object.get(key); + return element == null || element.isJsonNull() ? fallback : element.getAsInt(); + } + + /** + * 解析十六进制颜色字符串为 ARGB 整数: + * 支持 #RRGGBB(自动补全不透明 alpha)与 #AARRGGBB 两种格式; + * 解析失败时返回默认的邦国绿(#75bd20)。 + */ + private static int parseColor(String hex) { + try { + String value; + String string = value = hex.startsWith("#") ? hex.substring(1) : hex; + if (value.length() == 8) { + return (int)Long.parseLong(value, 16); + } + if (value.length() == 6) { + return 0xFF000000 | Integer.parseInt(value, 16); + } + } + catch (NumberFormatException numberFormatException) { + // 颜色格式非法时忽略,使用默认颜色 + } + return -9061088; + } +} + diff --git a/src/main/java/autostore/client/territory/TerritoryModel.java b/src/main/java/autostore/client/territory/TerritoryModel.java new file mode 100644 index 0000000..081ca77 --- /dev/null +++ b/src/main/java/autostore/client/territory/TerritoryModel.java @@ -0,0 +1,112 @@ +/* + * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152), + * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。 + */ +package autostore.client.territory; + +import autostore.client.territory.ChunkPos; +import autostore.client.territory.TerritoryState; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; + +@Environment(value=EnvType.CLIENT) +/** + * 领土模型:聚合所有邦国的领土状态,并提供全局查询能力。 + * + *

维护三份数据: + *

    + *
  • states:邦国名 → 邦国领土状态(含区块集合、王城、颜色)
  • + *
  • allClaimed:所有邦国已占领区块的并集(用于判断区块是否已被占用)
  • + *
  • sessionClaims:本次会话新占领的区块(刷新地图时不会被覆盖)
  • + *
+ */ +public final class TerritoryModel { + /** 邦国名 → 邦国领土状态 */ + private final Map states; + /** 所有邦国已占领区块的并集 */ + private final Set allClaimed; + /** 本次会话新占领的区块 */ + private final Set sessionClaims; + /** 当前选中的己方邦国名称 */ + private String selfStateName = ""; + + public TerritoryModel(Map states) { + this.states = new HashMap(states); + this.allClaimed = new HashSet(); + for (TerritoryState state : states.values()) { + this.allClaimed.addAll(state.chunks()); + } + this.sessionClaims = new HashSet(); + } + + /** 返回一个空的领土模型(无任何邦国数据) */ + public static TerritoryModel empty() { + return new TerritoryModel(Map.of()); + } + + /** 返回所有邦国领土状态(只读视图) */ + public Collection states() { + return Collections.unmodifiableCollection(this.states.values()); + } + + /** 按名称查询邦国领土状态,不存在返回 null */ + public TerritoryState state(String name) { + return this.states.get(name); + } + + /** 返回所有邦国已占领区块的并集(只读视图) */ + public Set allClaimedChunks() { + return Collections.unmodifiableSet(this.allClaimed); + } + + /** 返回本次会话新占领的区块(只读视图) */ + public Set sessionClaims() { + return Collections.unmodifiableSet(this.sessionClaims); + } + + /** 返回当前选中的己方邦国名称 */ + public String selfStateName() { + return this.selfStateName; + } + + /** 设置当前选中的己方邦国名称(null 视为空串) */ + public void setSelfStateName(String name) { + this.selfStateName = name == null ? "" : name; + } + + /** 返回己方邦国的领土状态;未设置或不存在时返回 null */ + public TerritoryState selfState() { + return this.states.get(this.selfStateName); + } + + /** 查找包含指定区块的邦国;没有邦国覆盖该区块时返回 null */ + public TerritoryState findContainingState(ChunkPos chunk) { + for (TerritoryState state : this.states.values()) { + if (!state.chunks().contains(chunk)) continue; + return state; + } + return null; + } + + /** + * 记录一次会话内新占领的区块: + * 加入会话集合与全局已占领集合,并同步更新己方邦国的区块集合。 + */ + public void addSessionClaim(ChunkPos chunk) { + this.sessionClaims.add(chunk); + this.allClaimed.add(chunk); + TerritoryState self = this.states.get(this.selfStateName); + if (self != null) { + HashSet updated = new HashSet(self.chunks()); + updated.add(chunk); + this.states.put(this.selfStateName, new TerritoryState(self.name(), self.capital(), updated, self.color())); + } + } +} + diff --git a/src/main/java/autostore/client/territory/TerritoryState.java b/src/main/java/autostore/client/territory/TerritoryState.java new file mode 100644 index 0000000..01b70fb --- /dev/null +++ b/src/main/java/autostore/client/territory/TerritoryState.java @@ -0,0 +1,21 @@ +/* + * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152), + * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。 + */ +package autostore.client.territory; + +import java.util.Set; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; + +/** + * 一个国家(邦国)的领土状态(record)。 + * + * @param name 国家名称 + * @param capital 首都所在的区块坐标 + * @param chunks 该国占有的全部区块坐标集合 + * @param color 地图上用于绘制该国的 ARGB 颜色值 + */ +@Environment(EnvType.CLIENT) +public record TerritoryState(String name, ChunkPos capital, Set chunks, int color) { +} diff --git a/src/main/java/autostore/mixin/client/InGameHudMixin.java b/src/main/java/autostore/mixin/client/InGameHudMixin.java new file mode 100644 index 0000000..00c125a --- /dev/null +++ b/src/main/java/autostore/mixin/client/InGameHudMixin.java @@ -0,0 +1,40 @@ +/* + * 本文件由 autostore-1.0-SNAPSHOT.jar 反编译并整理而来(CFR 0.152), + * 混淆名已替换为 Minecraft 1.21.4 Yarn 可读映射,并补充中文注释。 + */ +package autostore.mixin.client; + +import autostore.client.AutoStoreModClient; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.minecraft.text.Text; +import net.minecraft.client.gui.hud.InGameHud; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Environment(value=EnvType.CLIENT) +/** + * 游戏内 HUD 的 Mixin:拦截字幕(subtitle)与标题(title)的显示, + * 转发给扩地监听器,用于识别开垦进度消息(例如"开垦中 50%")。 + */ +@Mixin(value={InGameHud.class}) +public class InGameHudMixin { + /** 拦截字幕显示(HEAD 注入):把字幕文本转发给扩地监听器 */ + @Inject(method={"method_34002"}, at={@At(value="HEAD")}) + private void autostore$onSubtitle(Text subtitle, CallbackInfo ci) { + if (AutoStoreModClient.instance() != null) { + AutoStoreModClient.instance().monitor().onSubtitle(subtitle.getString()); + } + } + + /** 拦截标题显示(HEAD 注入):把标题文本转发给扩地监听器 */ + @Inject(method={"method_34004"}, at={@At(value="HEAD")}) + private void autostore$onTitle(Text title, CallbackInfo ci) { + if (AutoStoreModClient.instance() != null) { + AutoStoreModClient.instance().monitor().onSubtitle(title.getString()); + } + } +} + diff --git a/src/main/resources/assets/autostore/lang/en_us.json b/src/main/resources/assets/autostore/lang/en_us.json new file mode 100644 index 0000000..eb3a0db --- /dev/null +++ b/src/main/resources/assets/autostore/lang/en_us.json @@ -0,0 +1,4 @@ +{ + "key.autostore.open": "Open Auto Claim GUI (Ctrl+R)", + "category.autostore": "AutoStore Auto Claim" +} diff --git a/src/main/resources/assets/autostore/lang/zh_cn.json b/src/main/resources/assets/autostore/lang/zh_cn.json new file mode 100644 index 0000000..114730e --- /dev/null +++ b/src/main/resources/assets/autostore/lang/zh_cn.json @@ -0,0 +1,4 @@ +{ + "key.autostore.open": "打开自动扩地 GUI (Ctrl+R)", + "category.autostore": "AutoStore 自动扩地" +} diff --git a/src/main/resources/autostore.client.mixins.json b/src/main/resources/autostore.client.mixins.json new file mode 100644 index 0000000..9caf54c --- /dev/null +++ b/src/main/resources/autostore.client.mixins.json @@ -0,0 +1,15 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "autostore.mixin.client", + "compatibilityLevel": "JAVA_21", + "client": [ + "InGameHudMixin" + ], + "injectors": { + "defaultRequire": 1 + }, + "overwrites": { + "requireAnnotations": true + } +} diff --git a/src/main/resources/autostore.mixins.json b/src/main/resources/autostore.mixins.json new file mode 100644 index 0000000..d5fafd0 --- /dev/null +++ b/src/main/resources/autostore.mixins.json @@ -0,0 +1,14 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "autostore.mixin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + ], + "injectors": { + "defaultRequire": 1 + }, + "overwrites": { + "requireAnnotations": true + } +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..7026154 --- /dev/null +++ b/src/main/resources/fabric.mod.json @@ -0,0 +1 @@ +{"schemaVersion":1,"id":"autostore","version":"1.0-SNAPSHOT","name":"AutoStore","description":"","authors":[],"contact":{},"license":"All-Rights-Reserved","environment":"client","entrypoints":{"fabric-datagen":["autostore.client.AutoStoreModDataGenerator"],"client":["autostore.client.AutoStoreModClient"],"main":["autostore.AutoStoreMod"]},"mixins":["autostore.mixins.json",{"config":"autostore.client.mixins.json","environment":"client"}],"depends":{"fabricloader":">=0.16.14","fabric-api":"*","minecraft":"1.21.4"},"jars":[{"file":"META-INF/jars/baritone-api-fabric-1.13.1.jar"}]} \ No newline at end of file