AutoStore 模组完整源码
这个提交包含在:
@@ -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)。
|
||||||
|
*
|
||||||
|
* <p>本模组为纯客户端模组,真正的初始化逻辑在 {@link autostore.client.AutoStoreModClient}
|
||||||
|
* 中完成,因此这里的 {@link #onInitialize()} 为空实现。</p>
|
||||||
|
*/
|
||||||
|
public class AutoStoreMod implements ModInitializer {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fabric 加载器调用此方法完成模组初始化(服务端/通用侧)。
|
||||||
|
* 本模组所有功能都位于客户端,故此处无需处理。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void onInitialize() {
|
||||||
|
// 客户端初始化请参见 AutoStoreModClient
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/*
|
||||||
|
* AutoStore 模组客户端主入口(Fabric ClientModInitializer)。
|
||||||
|
* 负责初始化配置、启动领土数据服务、注册 tick 事件与聊天监听、注册命令。
|
||||||
|
*
|
||||||
|
* <p>通过单例 {@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 事件与聊天监听、注册命令。
|
||||||
|
*
|
||||||
|
* <p>通过单例 {@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)。
|
||||||
|
*
|
||||||
|
* <p>用于在开发环境中运行数据生成器(如生成语言文件、模型等)。
|
||||||
|
* 当前模组没有需要生成的数据,因此仅创建了一个空的数据包。</p>
|
||||||
|
*/
|
||||||
|
@Environment(EnvType.CLIENT)
|
||||||
|
public class AutoStoreModDataGenerator implements DataGeneratorEntrypoint {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据生成初始化回调。
|
||||||
|
*
|
||||||
|
* @param fabricDataGenerator Fabric 数据生成器
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void onInitializeDataGenerator(FabricDataGenerator fabricDataGenerator) {
|
||||||
|
// 创建一个空的数据包(当前无自定义数据需要生成)
|
||||||
|
FabricDataGenerator.Pack pack = fabricDataGenerator.createPack();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 封装(桥接层)。
|
||||||
|
*
|
||||||
|
* <p>将模组对 Baritone 寻路机器人的调用集中在此类中,包括:应用寻路行为配置、
|
||||||
|
* 前往目标区块、取消寻路、查询寻路状态等。所有调用都做了空安全保护,
|
||||||
|
* 当 Baritone 未加载时相关操作会安全地静默失败。</p>
|
||||||
|
*/
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 系列命令。
|
||||||
|
*
|
||||||
|
* <p>子命令一览:
|
||||||
|
* <ul>
|
||||||
|
* <li>/autostore gui —— 打开扩地 GUI</li>
|
||||||
|
* <li>/autostore start [数量] —— 开始自动扩地(-1 为持续)</li>
|
||||||
|
* <li>/autostore stop —— 停止扩地</li>
|
||||||
|
* <li>/autostore status —— 查看当前状态</li>
|
||||||
|
* <li>/autostore refresh —— 手动刷新邦国地图</li>
|
||||||
|
* <li>/autostore auto —— 自动识别当前邦国</li>
|
||||||
|
* <li>/autostore state <邦国名> —— 设置目标邦国</li>
|
||||||
|
* <li>/autostore strategy <1-4> —— 设置扩地策略</li>
|
||||||
|
* <li>/autostore rings <层数> —— 设置扩圈上限(0 为持续)</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class AutoStoreCommands {
|
||||||
|
private AutoStoreCommands() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册全部 /autostore 子命令到命令分发器 */
|
||||||
|
public static void register(CommandDispatcher<FabricClientCommandSource> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置文件读写器。
|
||||||
|
*
|
||||||
|
* <p>使用 Gson 将 {@link ExpansionConfig} 序列化为 JSON,
|
||||||
|
* 配置文件位于 Fabric 配置目录下的 {@code autostore.json}。</p>
|
||||||
|
*/
|
||||||
|
@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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动扩地配置数据类。
|
||||||
|
*
|
||||||
|
* <p>保存了扩地策略、目标数量、超时重试、区域限制以及 Baritone 寻路行为等全部可配置项,
|
||||||
|
* 通过 {@link ConfigStore} 持久化为 JSON 文件。</p>
|
||||||
|
*/
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
/**
|
||||||
|
* 自动扩地控制器:扩地任务的核心状态机。
|
||||||
|
*
|
||||||
|
* <p>工作流程:规划(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<ChunkPos> queue = new ArrayDeque<ChunkPos>();
|
||||||
|
/** 运行日志队列(线程安全) */
|
||||||
|
private final Deque<String> logs = new ArrayDeque<String>();
|
||||||
|
/** 当前扩地配置(启动时从请求配置复制) */
|
||||||
|
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<ChunkPos> 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<String> logs() {
|
||||||
|
Deque<String> deque = this.logs;
|
||||||
|
synchronized (deque) {
|
||||||
|
return new ArrayList<String>(this.logs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isRunning() {
|
||||||
|
return this.state != State.IDLE && this.state != State.COMPLETE && this.state != State.STOPPED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动自动扩地任务。
|
||||||
|
*
|
||||||
|
* <p>前置校验:必须在服务器世界中、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<ChunkPos> 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<ChunkPos> 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<String> deque = this.logs;
|
||||||
|
synchronized (deque) {
|
||||||
|
this.logs.addLast(System.currentTimeMillis() + " " + message);
|
||||||
|
while (this.logs.size() > 80) {
|
||||||
|
this.logs.removeFirst();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扩地任务状态机枚举:
|
||||||
|
* <ul>
|
||||||
|
* <li>IDLE:空闲(未启动)</li>
|
||||||
|
* <li>PLANNING:正在生成扩地计划</li>
|
||||||
|
* <li>PATHING:正在寻路前往目标区块</li>
|
||||||
|
* <li>WAITING:已到达,等待开垦完成</li>
|
||||||
|
* <li>SUCCESS:当前区块开垦成功</li>
|
||||||
|
* <li>FAIL:当前区块开垦失败/超时</li>
|
||||||
|
* <li>NEXT:准备处理下一个目标</li>
|
||||||
|
* <li>COMPLETE:全部目标完成</li>
|
||||||
|
* <li>STOPPED:被手动停止</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Environment(value=EnvType.CLIENT)
|
||||||
|
public static enum State {
|
||||||
|
IDLE,
|
||||||
|
PLANNING,
|
||||||
|
PATHING,
|
||||||
|
WAITING,
|
||||||
|
SUCCESS,
|
||||||
|
FAIL,
|
||||||
|
NEXT,
|
||||||
|
COMPLETE,
|
||||||
|
STOPPED;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,660 @@
|
|||||||
|
/*
|
||||||
|
* AutoStore 模组自动扩地 GUI 界面:提供可视化的领土地图与扩地配置面板。
|
||||||
|
*
|
||||||
|
* <p>左侧显示实时领土地图(各邦国区块着色 + 标签 + 玩家位置 + 目标高亮),
|
||||||
|
* 右侧配置面板包含邦国选择、目标数量、策略选择、高级设置(超时/重试/区域/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 界面:提供可视化的领土地图与扩地配置面板。
|
||||||
|
*
|
||||||
|
* <p>左侧显示实时领土地图(各邦国区块着色 + 标签 + 玩家位置 + 目标高亮),
|
||||||
|
* 右侧配置面板包含邦国选择、目标数量、策略选择、高级设置(超时/重试/区域/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<String> 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<Boolean> 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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
/**
|
||||||
|
* 字幕/聊天监听器:解析游戏内消息,识别开垦进度与结果。
|
||||||
|
*
|
||||||
|
* <p>两条输入通道:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #onSubtitle}:来自 HUD 字幕(如"开垦中 50%"),提取百分比进度</li>
|
||||||
|
* <li>{@link #onChat}:来自聊天栏(如"疆土(x,z) [bx,bz] 已为你的邦国开拓"),提取成功结果</li>
|
||||||
|
* </ul>
|
||||||
|
* 消息匹配采用模糊匹配(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
/**
|
||||||
|
* 扩地规划器:根据当前邦国领土、配置与玩家位置,计算下一步要占领的区块列表。
|
||||||
|
*
|
||||||
|
* <p>支持三种扩地策略:
|
||||||
|
* <ul>
|
||||||
|
* <li>策略 1:方形外扩(优先填内部空洞,再向外一圈圈扩展)</li>
|
||||||
|
* <li>策略 4:绕国一圈(沿领土边界按角度均匀向外扩张)</li>
|
||||||
|
* <li>其他(2/3):直线推进(沿一个方向连续向外延伸)</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
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<ChunkPos> 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:方形外扩(优先填洞)。
|
||||||
|
*
|
||||||
|
* <p>以王城为中心,先计算当前领土的覆盖半径,然后从内向外逐圈扫描候选区块。
|
||||||
|
* 候选区块必须:未被任何邦国占领、与已有领土相邻(合法)、且在配置的区域内。
|
||||||
|
* 找到候选后优先排"领土包围圈内部"的空洞,再排外部边界,最后按距离与邻居数排序。
|
||||||
|
*/
|
||||||
|
private List<ChunkPos> generateHolePriority(TerritoryModel model, ExpansionConfig config, ChunkPos playerChunk) {
|
||||||
|
Set<ChunkPos> actualOwn = this.ownChunks(model);
|
||||||
|
Set<ChunkPos> 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<ChunkPos> candidates = new HashSet<ChunkPos>();
|
||||||
|
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<ChunkPos> comparator = Comparator.comparingInt(playerChunk::manhattanDistance).thenComparingInt(chunk -> -this.ownNeighbors(chunk, actualOwn));
|
||||||
|
ArrayList<ChunkPos> inside = new ArrayList<ChunkPos>();
|
||||||
|
ArrayList<ChunkPos> outside = new ArrayList<ChunkPos>();
|
||||||
|
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<ChunkPos> result = new ArrayList<ChunkPos>();
|
||||||
|
result.addAll(inside);
|
||||||
|
result.addAll(outside);
|
||||||
|
return result.stream().limit(limit).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 策略 4:绕国一圈。
|
||||||
|
*
|
||||||
|
* <p>收集所有与己方领土相邻的合法区块,以领土质心(或王城)为中心,
|
||||||
|
* 按方位角(atan2)排序,使扩张点均匀分布在领土四周,形成一圈向外扩散的效果。
|
||||||
|
*/
|
||||||
|
private List<ChunkPos> generatePerimeter(TerritoryModel model, ExpansionConfig config, ChunkPos playerChunk) {
|
||||||
|
Set<ChunkPos> actualOwn = this.ownChunks(model);
|
||||||
|
Set<ChunkPos> allClaimed = model.allClaimedChunks();
|
||||||
|
HashSet<ChunkPos> plannedOwn = new HashSet<ChunkPos>(actualOwn);
|
||||||
|
HashSet<ChunkPos> candidates = new HashSet<ChunkPos>();
|
||||||
|
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<ChunkPos> result = new ArrayList<ChunkPos>();
|
||||||
|
for (int ring = 0; ring < rings && result.size() < limit; ++ring) {
|
||||||
|
this.addNeighbors(plannedOwn, candidates, allClaimed, config);
|
||||||
|
if (candidates.isEmpty()) break;
|
||||||
|
Comparator<ChunkPos> comparator = Comparator.comparingDouble((ChunkPos chunk) -> this.angle(chunk, center)).thenComparingInt(chunk -> chunk.manhattanDistance(playerChunk));
|
||||||
|
List<ChunkPos> ringChunks = candidates.stream().sorted(comparator).limit((long)(limit - result.size())).toList();
|
||||||
|
result.addAll(ringChunks);
|
||||||
|
plannedOwn.addAll(ringChunks);
|
||||||
|
candidates.clear();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 策略 2/3:直线推进。
|
||||||
|
*
|
||||||
|
* <p>从玩家当前位置出发,沿一个方向连续向外延伸占领区块(形成一条"线")。
|
||||||
|
* 每步从候选集中挑选评分最优的区块:距离越近、越贴合当前方向、邻居越多、形状越规整越好。
|
||||||
|
* 当领土形状严重失衡(长宽比超过 2:1)时,会切换方向补短边,避免领土过于细长。
|
||||||
|
*/
|
||||||
|
private List<ChunkPos> generateLine(TerritoryModel model, ExpansionConfig config, ChunkPos playerChunk) {
|
||||||
|
Set<ChunkPos> actualOwn = this.ownChunks(model);
|
||||||
|
Set<ChunkPos> allClaimed = model.allClaimedChunks();
|
||||||
|
HashSet<ChunkPos> plannedOwn = new HashSet<ChunkPos>(actualOwn);
|
||||||
|
HashSet<ChunkPos> candidates = new HashSet<ChunkPos>();
|
||||||
|
// 以己方领土中离玩家最近的区块作为推进起点,保证扩张从领土边缘开始
|
||||||
|
ChunkPos cursor = this.nearestOwnToPlayer(actualOwn, playerChunk);
|
||||||
|
ChunkPos direction = null;
|
||||||
|
int limit = config.targetCount > 0 ? config.targetCount : 128;
|
||||||
|
this.addNeighbors(plannedOwn, candidates, allClaimed, config);
|
||||||
|
ArrayList<ChunkPos> result = new ArrayList<ChunkPos>();
|
||||||
|
// 本次扩张新增区块的边界(用于形状判断,避免被原有领土形状干扰)
|
||||||
|
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 矩形区域扩地:完全按玩家圈选的矩形区域扩地。
|
||||||
|
*
|
||||||
|
* <p>先检查矩形区域是否与己方领土接壤(矩形内至少有一个未占领区块与领土相邻),
|
||||||
|
* 若未接壤则返回空列表(不扩地)。接壤后,从接壤边界开始 BFS 逐层向内,
|
||||||
|
* 将矩形内所有未被占领的区块全部纳入计划,保证每个区块都与已占领领土连通。
|
||||||
|
*/
|
||||||
|
private List<ChunkPos> generateRectangle(TerritoryModel model, ExpansionConfig config, ChunkPos playerChunk) {
|
||||||
|
Set<ChunkPos> actualOwn = this.ownChunks(model);
|
||||||
|
Set<ChunkPos> 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<ChunkPos> plannedOwn = new HashSet<ChunkPos>(actualOwn);
|
||||||
|
ArrayDeque<ChunkPos> frontier = new ArrayDeque<ChunkPos>();
|
||||||
|
ArrayList<ChunkPos> result = new ArrayList<ChunkPos>();
|
||||||
|
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<ChunkPos> candidates, ChunkPos cursor) {
|
||||||
|
return candidates.stream().min(Comparator.comparingInt(cursor::manhattanDistance)).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算推进方向:
|
||||||
|
* 若前沿与游标重合,则优先朝一个未占领的"正方向邻居"走;
|
||||||
|
* 否则返回从游标指向前沿的符号方向(-1/0/1)。
|
||||||
|
*/
|
||||||
|
private ChunkPos chooseDirection(ChunkPos cursor, ChunkPos nearest, Set<ChunkPos> 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<ChunkPos> candidates, Set<ChunkPos> 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<ChunkPos> 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<ChunkPos> 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<ChunkPos> 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<ChunkPos> plannedOwn, Set<ChunkPos> candidates, Set<ChunkPos> 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<ChunkPos> own, Set<ChunkPos> candidates, Set<ChunkPos> allClaimed, ExpansionConfig config) {
|
||||||
|
for (ChunkPos base : own) {
|
||||||
|
this.addNeighbors(base, own, candidates, allClaimed, config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断区块是否"合法"(可被占领):
|
||||||
|
* 必须在世界边界内、未被任何邦国占领、未被本次规划占用,且至少与一个己方区块相邻。
|
||||||
|
*/
|
||||||
|
private boolean isLegal(ChunkPos chunk, Set<ChunkPos> plannedOwn, Set<ChunkPos> 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<ChunkPos> own) {
|
||||||
|
int count = 0;
|
||||||
|
for (ChunkPos neighbor : this.cardinalNeighbors(chunk)) {
|
||||||
|
if (!own.contains(neighbor)) continue;
|
||||||
|
++count;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 形状惩罚分:评估把候选区块纳入后领土的"规整程度"。
|
||||||
|
* 用外接矩形的面积与已有区块数的比值衡量——比值越大说明扩张越"浪费"(形状越散),惩罚越高。
|
||||||
|
*/
|
||||||
|
private double shapePenalty(ChunkPos candidate, Set<ChunkPos> 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<ChunkPos> 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<ChunkPos> ownChunks(TerritoryModel model) {
|
||||||
|
HashSet<ChunkPos> result = new HashSet<ChunkPos>(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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),以“区块”为单位表示二维坐标。
|
||||||
|
*
|
||||||
|
* <p>Minecraft 中 1 个区块为 16×16 方块,本类提供方块坐标与区块坐标的换算,
|
||||||
|
* 以及曼哈顿距离等常用工具方法。</p>
|
||||||
|
*
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 使用。
|
||||||
|
*
|
||||||
|
* <p>数据来源是 AutoStore 服务器自带的动态地图(Dynmap)接口,包含:
|
||||||
|
* <ul>
|
||||||
|
* <li>union_claims:各邦国的领土多边形(区块归属)</li>
|
||||||
|
* <li>union_capitals:各邦国的王城坐标</li>
|
||||||
|
* </ul>
|
||||||
|
* 解析出的领土数据会与本次会话新占领的区块合并,保证扩地过程中地图实时更新。
|
||||||
|
*/
|
||||||
|
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<String> 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 文本为领土模型。
|
||||||
|
*
|
||||||
|
* <p>JSON 结构为数组,每层包含 id 与 markers 列表:
|
||||||
|
* <ul>
|
||||||
|
* <li>union_claims 层:每个 marker 是一个领土多边形,弹窗文本含"疆土归属: 邦国名"</li>
|
||||||
|
* <li>union_capitals 层:每个 marker 是一个王城坐标点,弹窗文本含"「邦国名」的王城"</li>
|
||||||
|
* </ul>
|
||||||
|
* 最终按邦国名聚合为 {@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<String, Set<ChunkPos>> claims = new HashMap<String, Set<ChunkPos>>();
|
||||||
|
HashMap<String, Integer> claimColors = new HashMap<String, Integer>();
|
||||||
|
HashMap<String, ChunkPos> capitals = new HashMap<String, ChunkPos>();
|
||||||
|
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<String, TerritoryState> states = new HashMap<String, TerritoryState>();
|
||||||
|
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<String, Set<ChunkPos>> claims, Map<String, Integer> 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<int[]> 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<String, ChunkPos> 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<List<int[]>>。
|
||||||
|
*/
|
||||||
|
private static List<List<int[]>> extractPolygons(JsonElement points) {
|
||||||
|
ArrayList<List<int[]>> result = new ArrayList<List<int[]>>();
|
||||||
|
if (points == null || !points.isJsonArray()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
JsonArray array = points.getAsJsonArray();
|
||||||
|
if (array.size() == 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (TerritoryDataService.isPointObject(array.get(0))) {
|
||||||
|
List<int[]> 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<int[]> parsePointList(JsonElement element) {
|
||||||
|
ArrayList<int[]> points = new ArrayList<int[]>();
|
||||||
|
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<ChunkPos> output, List<int[]> 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<int[]> 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<ChunkPos> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
/**
|
||||||
|
* 领土模型:聚合所有邦国的领土状态,并提供全局查询能力。
|
||||||
|
*
|
||||||
|
* <p>维护三份数据:
|
||||||
|
* <ul>
|
||||||
|
* <li>states:邦国名 → 邦国领土状态(含区块集合、王城、颜色)</li>
|
||||||
|
* <li>allClaimed:所有邦国已占领区块的并集(用于判断区块是否已被占用)</li>
|
||||||
|
* <li>sessionClaims:本次会话新占领的区块(刷新地图时不会被覆盖)</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class TerritoryModel {
|
||||||
|
/** 邦国名 → 邦国领土状态 */
|
||||||
|
private final Map<String, TerritoryState> states;
|
||||||
|
/** 所有邦国已占领区块的并集 */
|
||||||
|
private final Set<ChunkPos> allClaimed;
|
||||||
|
/** 本次会话新占领的区块 */
|
||||||
|
private final Set<ChunkPos> sessionClaims;
|
||||||
|
/** 当前选中的己方邦国名称 */
|
||||||
|
private String selfStateName = "";
|
||||||
|
|
||||||
|
public TerritoryModel(Map<String, TerritoryState> states) {
|
||||||
|
this.states = new HashMap<String, TerritoryState>(states);
|
||||||
|
this.allClaimed = new HashSet<ChunkPos>();
|
||||||
|
for (TerritoryState state : states.values()) {
|
||||||
|
this.allClaimed.addAll(state.chunks());
|
||||||
|
}
|
||||||
|
this.sessionClaims = new HashSet<ChunkPos>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回一个空的领土模型(无任何邦国数据) */
|
||||||
|
public static TerritoryModel empty() {
|
||||||
|
return new TerritoryModel(Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回所有邦国领土状态(只读视图) */
|
||||||
|
public Collection<TerritoryState> states() {
|
||||||
|
return Collections.unmodifiableCollection(this.states.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按名称查询邦国领土状态,不存在返回 null */
|
||||||
|
public TerritoryState state(String name) {
|
||||||
|
return this.states.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回所有邦国已占领区块的并集(只读视图) */
|
||||||
|
public Set<ChunkPos> allClaimedChunks() {
|
||||||
|
return Collections.unmodifiableSet(this.allClaimed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回本次会话新占领的区块(只读视图) */
|
||||||
|
public Set<ChunkPos> 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<ChunkPos> updated = new HashSet<ChunkPos>(self.chunks());
|
||||||
|
updated.add(chunk);
|
||||||
|
this.states.put(this.selfStateName, new TerritoryState(self.name(), self.capital(), updated, self.color()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<ChunkPos> chunks, int color) {
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"key.autostore.open": "Open Auto Claim GUI (Ctrl+R)",
|
||||||
|
"category.autostore": "AutoStore Auto Claim"
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"key.autostore.open": "打开自动扩地 GUI (Ctrl+R)",
|
||||||
|
"category.autostore": "AutoStore 自动扩地"
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"required": true,
|
||||||
|
"minVersion": "0.8",
|
||||||
|
"package": "autostore.mixin.client",
|
||||||
|
"compatibilityLevel": "JAVA_21",
|
||||||
|
"client": [
|
||||||
|
"InGameHudMixin"
|
||||||
|
],
|
||||||
|
"injectors": {
|
||||||
|
"defaultRequire": 1
|
||||||
|
},
|
||||||
|
"overwrites": {
|
||||||
|
"requireAnnotations": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"required": true,
|
||||||
|
"minVersion": "0.8",
|
||||||
|
"package": "autostore.mixin",
|
||||||
|
"compatibilityLevel": "JAVA_21",
|
||||||
|
"mixins": [
|
||||||
|
],
|
||||||
|
"injectors": {
|
||||||
|
"defaultRequire": 1
|
||||||
|
},
|
||||||
|
"overwrites": {
|
||||||
|
"requireAnnotations": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"}]}
|
||||||
在新工单中引用
屏蔽一个用户