diff --git a/README.md b/README.md index e8795da..9ea316d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,10 @@ - 实时地图 GUI(通过 Ctrl+R 打开) - 监听聊天/字幕消息判断开垦结果 - 刷新地图时与最新 `markers.json` 核对已占领区块,丢弃地图未确认的超期假占领,避免高地扩地残留空洞 +- 刷新请求带缓存击穿(时间戳参数 + no-cache 头),修复约半数刷新命中网络缓存拿到旧数据、导致目标被误标"已占领"且长时间不修正的问题;占领成功后立即触发刷新确认,假占领确认宽限期缩短至 60 秒 - 飞地(疏漏区块)检测:按 dynmap 奇偶填充规则解析领土多边形,正确识别国中由边界围出的内圈空腔;内置地图用红色标出己方飞地,自动扩地时优先填补这些缺口 +- 区域扩地严格 S 形(蛇形)扫描:起始边为矩形四条边中离王城最近的一条,由内而外推进;即使待开垦区块构成不规则多边形也按 S 形一路扫过去 +- 性能优化:地图渲染按视口快速过滤(不再每帧全量遍历 8 万区块)、飞地检测移至后台线程预计算、接壤校验零集合复制、响应内容未变化时跳过重复解析、ChunkPos 跨模型驻留复用——修复打开 GUI 与刷新地图时游戏卡顿、内存占用偏高的问题 - 日志导出:GUI 右上角“导出日志”按钮,或 `/autostore log` 命令,记录寻路、坐标、状态、路径事件等 ## 安装 diff --git a/src/main/java/autostore/client/controller/ExpansionController.java b/src/main/java/autostore/client/controller/ExpansionController.java index cd880b6..4bdd1d7 100644 --- a/src/main/java/autostore/client/controller/ExpansionController.java +++ b/src/main/java/autostore/client/controller/ExpansionController.java @@ -451,22 +451,27 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener { /** * 判断区块是否与己方领土(己方邦国地图区块 + 本次会话新占领区块)接壤。 * - *

接壤采用四个正方向(上下左右);只对“己方”领土判断,绝不把其他邦国领土计入。

+ *

接壤采用四个正方向(上下左右);只对“己方”领土判断,绝不把其他邦国领土计入。 + * 实现上直接查询四个邻居是否命中集合,零集合复制—— + * 旧实现每帧把数千个己方区块复制进临时 HashSet,是持续卡顿与内存压力的来源之一。

*/ private boolean isAdjacentToOwn(ChunkPos chunk) { TerritoryModel model = this.dataService.model(); - Set own = new HashSet(); TerritoryState self = model.selfState(); - if (self != null) { - own.addAll(self.chunks()); - } - own.addAll(model.sessionClaims()); - for (ChunkPos neighbor : this.cardinalNeighbors(chunk)) { - if (own.contains(neighbor)) { - return true; - } - } - return false; + Set ownChunks = self == null ? null : self.chunks(); + Set sessionClaims = model.sessionClaims(); + int x = chunk.x(); + int z = chunk.z(); + return this.isOwnAt(ownChunks, sessionClaims, x + 1, z) + || this.isOwnAt(ownChunks, sessionClaims, x - 1, z) + || this.isOwnAt(ownChunks, sessionClaims, x, z + 1) + || this.isOwnAt(ownChunks, sessionClaims, x, z - 1); + } + + /** 判断指定坐标的区块是否属于己方领土或本次会话新占领 */ + private boolean isOwnAt(Set ownChunks, Set sessionClaims, int x, int z) { + ChunkPos probe = new ChunkPos(x, z); + return ownChunks != null && ownChunks.contains(probe) || sessionClaims.contains(probe); } /** 成功后的过渡:进入 NEXT 状态,准备处理下一个目标 */ @@ -583,6 +588,9 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener { } this.model.addSessionClaim(chunk); this.dataService.addSessionClaim(chunk); + // 占领成功后立即触发一次地图刷新(请求侧已带缓存击穿),尽快让地图数据确认该区块, + // 缩短"会话占领等待确认"窗口,避免假占领长时间残留 + this.dataService.refreshAsync(); ++this.completedClaims; this.target = chunk; this.lastProgress = 100; @@ -599,12 +607,20 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener { if (this.target == null) { return; } + // 开垦只能发生在玩家所在区块:校验玩家确实位于目标区块上。 + // 通用成功文案采用模糊匹配,可能被服务器广播的其他玩家开垦消息误触发; + // 不校验会把未占领的目标错误标记为"已占领"并跳过,直到很久之后才被地图数据纠正(v1.2.1 修复)。 + if (!this.playerChunk.equals(this.target)) { + return; + } // 去重:同一区块只计一次 if (!this.claimedSet.add(this.target)) { return; } this.model.addSessionClaim(this.target); this.dataService.addSessionClaim(this.target); + // 占领成功后立即触发一次地图刷新,尽快确认该区块 + this.dataService.refreshAsync(); ++this.completedClaims; this.lastProgress = 100; this.log("\u5360\u9886\u6210\u529f " + this.target.x() + "," + this.target.z()); @@ -622,11 +638,6 @@ implements SubtitleChatMonitor.Listener, BaritoneBridge.PathListener { return this.dataService.model().allClaimedChunks().contains(chunk); } - /** 返回区块的四个正方向邻居 */ - private List cardinalNeighbors(ChunkPos chunk) { - return List.of(new ChunkPos(chunk.x() + 1, chunk.z()), new ChunkPos(chunk.x() - 1, chunk.z()), new ChunkPos(chunk.x(), chunk.z() + 1), new ChunkPos(chunk.x(), chunk.z() - 1)); - } - /** * 自动游泳逻辑(每帧调用): * 仅在玩家完全没入水中(有溺水风险)时自动跳跃,保持浮在水面。 diff --git a/src/main/java/autostore/client/gui/AutoStoreGui.java b/src/main/java/autostore/client/gui/AutoStoreGui.java index 8010267..d66d740 100644 --- a/src/main/java/autostore/client/gui/AutoStoreGui.java +++ b/src/main/java/autostore/client/gui/AutoStoreGui.java @@ -351,11 +351,24 @@ extends Screen { /** 绘制各邦国领土区块(己方高亮、其他半透明),并绘制会话新占领区块与飞地(疏漏区块) */ private void drawTerritories(DrawContext context) { TerritoryModel model = this.mod.dataService().model(); + // 先反推当前视口覆盖的区块范围(含一个区块边距),遍历时用整数比较快速剔除视口外区块。 + // 全图约 8 万区块,旧实现每帧对每个区块都调用屏幕坐标换算,是打开 GUI / 刷新后掉帧的主因。 + double invScale = 1.0 / this.scale; + int edge = (int)Math.ceil(this.scale) + 2; + int halfW = this.mapW / 2; + int halfH = this.mapH / 2; + int visMinX = (int)Math.floor((this.mapX - edge - this.mapX - halfW) * invScale) + this.viewCenterX; + int visMaxX = (int)Math.ceil((this.mapX + this.mapW + edge - this.mapX - halfW) * invScale) + this.viewCenterX; + int visMinZ = (int)Math.floor((this.mapY - edge - this.mapY - halfH) * invScale) + this.viewCenterZ; + int visMaxZ = (int)Math.ceil((this.mapY + this.mapH + edge - this.mapY - halfH) * invScale) + this.viewCenterZ; 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()) { + int cx = chunk.x(); + int cz = chunk.z(); + if (cx < visMinX || cx > visMaxX || cz < visMinZ || cz > visMaxZ) continue; this.drawChunkFill(context, chunk, fill); } if (!this.isVisible(state.capital())) continue; diff --git a/src/main/java/autostore/client/planner/ExpansionPlanner.java b/src/main/java/autostore/client/planner/ExpansionPlanner.java index 5e203e2..c9d3c5c 100644 --- a/src/main/java/autostore/client/planner/ExpansionPlanner.java +++ b/src/main/java/autostore/client/planner/ExpansionPlanner.java @@ -220,13 +220,18 @@ public final class ExpansionPlanner { } /** - * 策略 2 矩形区域扩地:在玩家圈选的矩形内,从与现有领土接壤的区块开始, - * 横向或纵向 S 形扫过整个矩形。 + * 策略 2 矩形区域扩地:严格 S 形(蛇形)扫描,由内而外推进。 * - *

与旧版"沿邦国边界逐层向内 BFS"不同,本算法不再贴着邦国现有边界, - * 而是生成一条贯穿矩形的 S 形路径:从接壤起点开始,逐行/逐列交替反向扫过, - * 保证路径上每个区块都与已占领领土(含己方领土、本次会话新占领)接壤, - * 矩形内已占领的区块自动跳过、不重复占领。 + *

扫描规则(v1.2.1 重写): + *

    + *
  • 起始扫描线 = 矩形四条边中离王城直线距离最近的那条边(点到线段距离), + * 从该边向对边单调推进——即"由内而外",避免先跑到区域外侧绕圈;
  • + *
  • 扫描行/列内做标准蛇形往返(相邻行方向交替),即使待开垦区块构成不规则多边形, + * 也始终按 S 形顺序一路扫过去,跳过暂时不可开垦的区块而不打乱顺序;
  • + *
  • 首条扫描线的行内起始端同样选取离王城更近的一端;
  • + *
  • 每个入选区块必须与"己方领土(含本次已选)"四正方向接壤,保证服务器接受开拓。
  • + *
+ * 复杂度:O(区域面积) 单遍扫描 + O(4) 接壤检查。

*/ private List generateRectangle(TerritoryModel model, ExpansionConfig config, ChunkPos playerChunk) { Set actualOwn = this.ownChunks(model); @@ -239,139 +244,84 @@ public final class ExpansionPlanner { int minZ = config.regionMinZ; int maxZ = config.regionMaxZ; int limit = config.targetCount > 0 ? config.targetCount : Integer.MAX_VALUE; - // 找出矩形内所有与己方领土接壤的未占领区块(作为 S 形起点候选) - ArrayList borderChunks = new ArrayList(); - for (int x = minX; x <= maxX; ++x) { - for (int z = minZ; z <= maxZ; ++z) { - ChunkPos chunk = new ChunkPos(x, z); - if (allClaimed.contains(chunk)) continue; - if (this.ownNeighbors(chunk, actualOwn) > 0) { - borderChunks.add(chunk); - } - } - } - if (borderChunks.isEmpty()) { - return List.of(); - } - // 选择起点:优先矩形角落附近与领土接壤的区块,使 S 形能自然覆盖整个矩形 - ChunkPos start = this.chooseStart(borderChunks, minX, maxX, minZ, maxZ); - // 生成 S 形扫描路径(覆盖矩形内所有区块,含已占领的) - List sPath = this.buildSPath(start, minX, maxX, minZ, maxZ); - // 己方领土集合(含本次规划已纳入的区块)。 - // 注意:这里绝不能把其他邦国的领土(allClaimed)混入接壤判断集合, - // 否则会选中一个“挨着其他邦国、但与己方领土不相邻”的区块, - // 服务器会因不接壤而拒绝开拓,导致玩家反复走到无效目标(历史 bug)。 + ChunkPos capital = this.capital(model, playerChunk); + // ===== 1. 四条边到王城的距离(点到线段距离的平方),选最近者为起始边 ===== + // 王城坐标投影到边所在线段的横向偏移(王城在区域横向范围外时才有偏移) + int dxEdge = Math.max(0, Math.max(minX - capital.x(), capital.x() - maxX)); + int dzEdge = Math.max(0, Math.max(minZ - capital.z(), capital.z() - maxZ)); + long distNorth = (long)dxEdge * dxEdge + (long)(minZ - capital.z()) * (minZ - capital.z()); + long distSouth = (long)dxEdge * dxEdge + (long)(maxZ - capital.z()) * (maxZ - capital.z()); + long distWest = (long)(minX - capital.x()) * (minX - capital.x()) + (long)dzEdge * dzEdge; + long distEast = (long)(maxX - capital.x()) * (maxX - capital.x()) + (long)dzEdge * dzEdge; + // 最近边决定扫描方向:北/南边最近 → 横向逐行扫(行沿 Z 推进);西/东边最近 → 纵向逐列扫 + boolean rowScan = Math.min(distNorth, distSouth) <= Math.min(distWest, distEast); + // ===== 2. 生成 S 形扫描序列并按接壤过滤 ===== + // 接壤判断集合:己方领土 + 本次已选区块。 + // 注意:绝不能把其他邦国领土(allClaimed)混入接壤判断集合, + // 否则会选中"挨着其他邦国、但与己方领土不相邻"的区块,服务器会拒绝开拓(历史 bug)。 HashSet claimed = new HashSet(actualOwn); ArrayList result = new ArrayList(); - for (ChunkPos chunk : sPath) { - if (result.size() >= limit) break; - // 已被任何邦国(含己方)占领,或本次规划已纳入:跳过,避免重复/跨邦国开拓 - if (allClaimed.contains(chunk) || claimed.contains(chunk)) continue; - // 必须与“己方”领土接壤(含本次已纳入的区块),保证服务器能成功开拓 - if (this.ownNeighbors(chunk, claimed) == 0) continue; - result.add(chunk); - claimed.add(chunk); + if (rowScan) { + // 行序:从离王城近的边(z=minZ 或 maxZ)向对边推进;首行行内起点选离王城 x 近的一端 + int startRow = distNorth <= distSouth ? minZ : maxZ; + int rowStep = startRow == minZ ? 1 : -1; + boolean ltr = Math.abs(capital.x() - minX) <= Math.abs(capital.x() - maxX); + for (int row = startRow; row >= minZ && row <= maxZ; row += rowStep) { + if (ltr) { + for (int x = minX; x <= maxX; ++x) { + this.tryClaimRect(result, claimed, allClaimed, x, row, limit); + } + } else { + for (int x = maxX; x >= minX; --x) { + this.tryClaimRect(result, claimed, allClaimed, x, row, limit); + } + } + ltr = !ltr; + if (result.size() >= limit) { + return result; + } + } + } else { + // 列序:从离王城近的边(x=minX 或 maxX)向对边推进;首列列内起点选离王城 z 近的一端 + int startCol = distWest <= distEast ? minX : maxX; + int colStep = startCol == minX ? 1 : -1; + boolean ttb = Math.abs(capital.z() - minZ) <= Math.abs(capital.z() - maxZ); + for (int col = startCol; col >= minX && col <= maxX; col += colStep) { + if (ttb) { + for (int z = minZ; z <= maxZ; ++z) { + this.tryClaimRect(result, claimed, allClaimed, col, z, limit); + } + } else { + for (int z = maxZ; z >= minZ; --z) { + this.tryClaimRect(result, claimed, allClaimed, col, z, limit); + } + } + ttb = !ttb; + if (result.size() >= limit) { + return result; + } + } } return result; } /** - * 从接壤候选区块中选择 S 形扫描的起点: - * 优先选择矩形角落上的接壤区块;没有角落接壤时,选择离任一角落最近的接壤区块。 + * S 形扫描中的一个格子:若该区块未被任何邦国占领、且与"己方领土(含本次已选)"接壤, + * 则纳入本次扩张计划;否则跳过(保持 S 形顺序一路扫过去,跳过的不打乱后续顺序)。 */ - private ChunkPos chooseStart(List borderChunks, int minX, int maxX, int minZ, int maxZ) { - ChunkPos[] corners = new ChunkPos[]{new ChunkPos(minX, minZ), new ChunkPos(maxX, minZ), new ChunkPos(minX, maxZ), new ChunkPos(maxX, maxZ)}; - for (ChunkPos corner : corners) { - if (borderChunks.contains(corner)) { - return corner; - } + private void tryClaimRect(List result, Set claimed, Set allClaimed, int x, int z, int limit) { + if (result.size() >= limit) { + return; } - ChunkPos best = null; - int bestDist = Integer.MAX_VALUE; - for (ChunkPos chunk : borderChunks) { - for (ChunkPos corner : corners) { - int dist = Math.abs(chunk.x() - corner.x()) + Math.abs(chunk.z() - corner.z()); - if (dist < bestDist) { - bestDist = dist; - best = chunk; - } - } + ChunkPos chunk = new ChunkPos(x, z); + if (allClaimed.contains(chunk) || claimed.contains(chunk)) { + return; } - return best; - } - - /** - * 生成从起点开始的 S 形扫描路径,覆盖矩形内所有区块。 - * - *

扫描主轴根据起点所在边缘决定:起点在顶/底行时横向逐行扫描, - * 起点在左/右列时纵向逐列扫描。每行/列内方向交替反向,形成 S 形。 - * 起点所在行/列从起点向两侧铺开,保证起点是路径第一个区块。 - */ - private List buildSPath(ChunkPos start, int minX, int maxX, int minZ, int maxZ) { - ArrayList path = new ArrayList(); - boolean horizontal = start.z() == minZ || start.z() == maxZ; - if (horizontal) { - int startRow = start.z(); - // 起点行:从起点向一侧到边缘,再反向到另一侧,完整覆盖该行 - boolean leftToRight = start.x() == minX; - if (leftToRight) { - for (int x = start.x(); x <= maxX; ++x) path.add(new ChunkPos(x, startRow)); - for (int x = start.x() - 1; x >= minX; --x) path.add(new ChunkPos(x, startRow)); - } else { - for (int x = start.x(); x >= minX; --x) path.add(new ChunkPos(x, startRow)); - for (int x = start.x() + 1; x <= maxX; ++x) path.add(new ChunkPos(x, startRow)); - } - // 其余行:先向下(+z)再向上(-z),每行方向交替,形成 S 形 - boolean ltr = leftToRight; - for (int z = startRow + 1; z <= maxZ; ++z) { - this.appendRow(path, z, minX, maxX, ltr); - ltr = !ltr; - } - for (int z = startRow - 1; z >= minZ; --z) { - this.appendRow(path, z, minX, maxX, ltr); - ltr = !ltr; - } - } else { - int startCol = start.x(); - // 起点列:从起点向一侧到边缘,再反向到另一侧,完整覆盖该列 - boolean topToBottom = start.z() == minZ; - if (topToBottom) { - for (int z = start.z(); z <= maxZ; ++z) path.add(new ChunkPos(startCol, z)); - for (int z = start.z() - 1; z >= minZ; --z) path.add(new ChunkPos(startCol, z)); - } else { - for (int z = start.z(); z >= minZ; --z) path.add(new ChunkPos(startCol, z)); - for (int z = start.z() + 1; z <= maxZ; ++z) path.add(new ChunkPos(startCol, z)); - } - // 其余列:先向右(+x)再向左(-x),每列方向交替,形成 S 形 - boolean ttb = topToBottom; - for (int x = startCol + 1; x <= maxX; ++x) { - this.appendCol(path, x, minZ, maxZ, ttb); - ttb = !ttb; - } - for (int x = startCol - 1; x >= minX; --x) { - this.appendCol(path, x, minZ, maxZ, ttb); - ttb = !ttb; - } - } - return path; - } - - /** 追加一行区块到路径(leftToRight=true 时从左到右,否则从右到左) */ - private void appendRow(List path, int row, int minX, int maxX, boolean leftToRight) { - if (leftToRight) { - for (int x = minX; x <= maxX; ++x) path.add(new ChunkPos(x, row)); - } else { - for (int x = maxX; x >= minX; --x) path.add(new ChunkPos(x, row)); - } - } - - /** 追加一列区块到路径(topToBottom=true 时从上到下,否则从下到上) */ - private void appendCol(List path, int col, int minZ, int maxZ, boolean topToBottom) { - if (topToBottom) { - for (int z = minZ; z <= maxZ; ++z) path.add(new ChunkPos(col, z)); - } else { - for (int z = maxZ; z >= minZ; --z) path.add(new ChunkPos(col, z)); + if (this.ownNeighbors(chunk, claimed) == 0) { + return; } + result.add(chunk); + claimed.add(chunk); } /** 在候选集中找到离当前游标最近的一个区块(曼哈顿距离),作为推进的前沿 */ diff --git a/src/main/java/autostore/client/territory/TerritoryDataService.java b/src/main/java/autostore/client/territory/TerritoryDataService.java index 2d3cd24..e1dac21 100644 --- a/src/main/java/autostore/client/territory/TerritoryDataService.java +++ b/src/main/java/autostore/client/territory/TerritoryDataService.java @@ -74,8 +74,20 @@ public final class TerritoryDataService { *

占领成功后,服务器动态地图(Dynmap/markers.json)可能尚未同步该区块, * 因此在宽限期内即使地图未确认也先保留"会话占领"标记,避免刚占领的区块被误判为假占领。 * 超过宽限期仍未在地图中确认的,视为未真正占领并丢弃,使空洞区块能重新进入规划。 + * (v1.2.1 由 120 秒缩短为 60 秒:配合请求侧缓存击穿,Dynmap 秒级同步,缩短可更快修正假占领)

*/ - private static final long CLAIM_CONFIRM_GRACE_MS = 120_000L; + private static final long CLAIM_CONFIRM_GRACE_MS = 60_000L; + /** + * ChunkPos 驻留池:坐标 → 复用实例。 + * + *

每次解析 markers.json 会创建约 8 万个 ChunkPos,且各次刷新的坐标高度重合。 + * 驻留后新旧模型共享同一批实例,刷新时垃圾量从"整份模型"降为"集合外壳", + * 显著降低 GC 压力(缓解刷新时游戏卡顿与内存占用)。

+ */ + private static final ConcurrentHashMap CHUNK_POOL = new ConcurrentHashMap(); + /** 上次成功响应的正文哈希与长度,用于识别"网络缓存返回了相同旧数据"并跳过重复解析 */ + private volatile int lastBodyHash; + private volatile int lastBodyLength = -1; public TerritoryDataService() { this.executor = TerritoryDataService.newExecutor(); @@ -129,12 +141,28 @@ public final class TerritoryDataService { 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(); + // 请求侧缓存击穿:URL 追加时间戳参数 + no-cache 头,确保每次都拿到服务器最新数据。 + // 此前约半数刷新命中网络缓存拿到旧数据,导致会话占领迟迟无法被地图确认/纠正。 + String url = MARKERS_URL + "?_=" + System.currentTimeMillis(); + HttpRequest request = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(15L)) + .header("User-Agent", "Mozilla/5.0 autostore/1.2.1") + .header("Cache-Control", "no-cache, no-store") + .header("Pragma", "no-cache").GET().build(); HttpResponse response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); if (response.statusCode() < 200 || response.statusCode() >= 300) { this.lastError = "HTTP " + response.statusCode(); return; } + String body = response.body(); + // 内容未变化(服务器确实无更新,或缓存仍命中相同旧文):跳过重复解析, + // 避免"每次刷新都重建 8 万区块模型"的 CPU/GC 开销;模型与飞地缓存保持原样。 + if (body.length() == this.lastBodyLength && body.hashCode() == this.lastBodyHash) { + this.lastSuccessfulFetchMillis = System.currentTimeMillis(); + this.lastError = ""; + return; + } + this.lastBodyHash = body.hashCode(); + this.lastBodyLength = body.length(); TerritoryModel parsed = TerritoryDataService.parse(response.body()); TerritoryModel previous = this.model; if (previous != null) { @@ -160,6 +188,9 @@ public final class TerritoryDataService { } } parsed.setSelfStateName(this.selectedStateName); + // 后台线程预计算飞地缓存:把"各国外接矩形全量扫描"从渲染帧挪到这里, + // 修复"刷新地图/打开 GUI 时游戏卡一下"的问题 + parsed.precomputeFlyingChunks(); this.model = parsed; this.lastSuccessfulFetchMillis = System.currentTimeMillis(); this.lastError = ""; @@ -375,12 +406,21 @@ public final class TerritoryDataService { } } if (inside) { - output.add(new ChunkPos(cx, cz)); + output.add(TerritoryDataService.internChunk(cx, cz)); } } } } + /** + * 驻留池获取区块坐标实例:同一坐标跨模型复用同一对象。 + * 刷新频率下大幅减少 ChunkPos 临时分配与 GC 压力(见 {@link #CHUNK_POOL})。 + */ + private static ChunkPos internChunk(int x, int z) { + long key = (long)x << 32 | (long)z & 0xFFFFFFFFL; + return TerritoryDataService.CHUNK_POOL.computeIfAbsent(key, k -> new ChunkPos(x, z)); + } + /** 射线法判断点 (x, z) 是否在多边形内(经典奇偶规则) */ private static boolean pointInPolygon(int x, int z, List polygon) { boolean inside = false; diff --git a/src/main/java/autostore/client/territory/TerritoryModel.java b/src/main/java/autostore/client/territory/TerritoryModel.java index d725892..523caf4 100644 --- a/src/main/java/autostore/client/territory/TerritoryModel.java +++ b/src/main/java/autostore/client/territory/TerritoryModel.java @@ -41,8 +41,12 @@ public final class TerritoryModel { *

飞地指"四周(四个正方向)都被同一邦国占领、但自身未被占领"的区块, * 通常是自动扩地时漏掉的疏漏。该结果在模型构建后懒计算并缓存, * 仅在新增会话占领(addSessionClaim)后失效重算。 + * 字段为 volatile:后台线程可调用 {@link #precomputeFlyingChunks()} 预热, + * 渲染线程读取时保证可见性,避免把全量扫描留在渲染帧里(卡顿来源)。

*/ - private transient Map> cachedFlyingChunks; + private transient volatile Map> cachedFlyingChunks; + /** 所有邦国飞地的合并并集缓存(volatile,同上);仅少量区块,供 GUI 直接遍历 */ + private transient volatile Set cachedAllFlyingChunks; public TerritoryModel(Map states) { this.states = new HashMap(states); @@ -103,17 +107,23 @@ public final class TerritoryModel { } /** - * 返回所有邦国的飞地(疏漏区块)并集(只读视图)。 + * 返回所有邦国的飞地(疏漏区块)并集(只读视图,结果缓存,帧间零分配)。 * *

飞地指“四周(四个正方向)都被同一邦国占领、但自身未被占领”的区块。 * 这些往往是自动扩地过程中漏掉的疏漏,需要在地图上标出并优先填补。

*/ public Set flyingChunks() { + Set cached = this.cachedAllFlyingChunks; + if (cached != null) { + return cached; + } HashSet result = new HashSet(); for (Set chunks : this.flyingChunksByState().values()) { result.addAll(chunks); } - return result; + Set frozen = Collections.unmodifiableSet(result); + this.cachedAllFlyingChunks = frozen; + return frozen; } /** 返回己方邦国的飞地(疏漏区块)(只读视图);未选择邦国时为空集 */ @@ -121,6 +131,15 @@ public final class TerritoryModel { return Collections.unmodifiableSet(this.flyingChunksByState().getOrDefault(this.selfStateName, Set.of())); } + /** + * 后台预计算飞地缓存(在数据刷新线程调用): + * 把"各国外接矩形全量扫描"提前到后台线程完成, + * 渲染线程读到的永远是现成缓存,避免模型刷新后第一帧卡顿。 + */ + public void precomputeFlyingChunks() { + this.flyingChunks(); + } + /** 计算并缓存各邦国的飞地区块(懒加载,addSessionClaim 后失效重算) */ private Map> flyingChunksByState() { Map> cached = this.cachedFlyingChunks; @@ -184,6 +203,7 @@ public final class TerritoryModel { this.sessionClaims.add(chunk); this.allClaimed.add(chunk); this.cachedFlyingChunks = null; + this.cachedAllFlyingChunks = null; TerritoryState self = this.states.get(this.selfStateName); if (self != null) { HashSet updated = new HashSet(self.chunks());