From b63d103b0eb994c8f53dd260b234c5fdc91bd210 Mon Sep 17 00:00:00 2001 From: Shiroha7z Date: Tue, 27 Oct 2026 10:14:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(mp-gateway,miniprogram,sdk):=20=E5=B0=8F?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=9C=B0=E5=9B=BE=E6=98=BE=E7=A4=BA=E9=A2=86?= =?UTF-8?q?=E5=9C=B0=E4=B8=8E=E5=8C=BA=E5=9D=97=E7=BD=91=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 网关新增 GET …/map/lands,只读转发地图服务 /api/lands,去掉领主玩家 id 与非法条目 - 小程序地图按区块画 land 模块领地(外轮廓、子区域虚线框、点击信息卡),新增「区块」网格开关 - SDK service.get 的 HTTP 回退不再使用 SAPI 中不存在的 URLSearchParams --- .changeset/mp-map-lands.md | 8 + .../sdk/@sfmc-sdk/src/sapi/service/client.ts | 3 +- .../miniprogram/miniprogram/core/map-api.ts | 32 ++- .../miniprogram/miniprogram/core/map-lands.ts | 193 ++++++++++++++++++ .../miniprogram/miniprogram/core/map-view.ts | 28 ++- .../miniprogram/miniprogram/pages/map/map.ts | 165 +++++++++++++-- .../miniprogram/pages/map/map.wxml | 3 + packages/mp-gateway/src/map-client.ts | 78 ++++++- packages/mp-gateway/src/map-routes.ts | 10 +- packages/mp-gateway/test/gateway.test.ts | 41 +++- 10 files changed, 539 insertions(+), 22 deletions(-) create mode 100644 .changeset/mp-map-lands.md create mode 100644 packages/miniprogram/miniprogram/core/map-lands.ts diff --git a/.changeset/mp-map-lands.md b/.changeset/mp-map-lands.md new file mode 100644 index 00000000..cb08f05e --- /dev/null +++ b/.changeset/mp-map-lands.md @@ -0,0 +1,8 @@ +--- +"@sfmc-bds/mp-gateway": minor +"@sfmc-bds/sdk": patch +--- + +小程序地图显示 land 模块的领地与区块网格:网关新增 `GET …/map/lands`(需要玩家会话,只读转发地图服务 `/api/lands`,只透出名称、领主名、状态、等级、按维度分组的区块与子区域的水平范围,不转发领主玩家 id)。此前小程序只读 `…/map/regions`,那里只有 area 模块的矩形区域,land 模块的领地不会出现。 + +SDK 的 `service.get` / `service.call` 在服务不在本进程注册、走 HTTP 回退时不再使用 `URLSearchParams`(SAPI 脚本引擎没有该全局对象,此前会抛 `ReferenceError`,例如 bluemap 在 land 重载期间读取 `land.map`)。 diff --git a/modules/sdk/@sfmc-sdk/src/sapi/service/client.ts b/modules/sdk/@sfmc-sdk/src/sapi/service/client.ts index 2f8dbc16..8efb62e1 100644 --- a/modules/sdk/@sfmc-sdk/src/sapi/service/client.ts +++ b/modules/sdk/@sfmc-sdk/src/sapi/service/client.ts @@ -163,7 +163,8 @@ export function createServiceClient( const local = await tryLocal(name, input); if (local !== undefined) return local as never; - const qs = new URLSearchParams({ input: JSON.stringify(input) }).toString(); + // SAPI 脚本引擎没有 URLSearchParams,手动编码;服务端按标准查询串解码,结果一致 + const qs = `input=${encodeURIComponent(JSON.stringify(input))}`; const res = await HttpDB.typedRequest<{ ok: true; result: unknown }>( SafeHttpMethod.Get, withModuleId(`/api/sfmc/services/${encodeURIComponent(name)}?${qs}`), diff --git a/packages/miniprogram/miniprogram/core/map-api.ts b/packages/miniprogram/miniprogram/core/map-api.ts index 30884bac..9c74f7f7 100644 --- a/packages/miniprogram/miniprogram/core/map-api.ts +++ b/packages/miniprogram/miniprogram/core/map-api.ts @@ -74,7 +74,7 @@ export interface MapPlayers { stale: boolean; } -/** 区域(area)或领地(land)的矩形范围 */ +/** area 模块的区域(area),或旧版 area 里带 land_core 的领地(land)的矩形范围 */ export interface MapRegion { id: string; kind: "area" | "land"; @@ -86,12 +86,41 @@ export interface MapRegion { maxZ: number; } +/** 领地里的一个子区域(水平投影,坐标含两端方块) */ +export interface MapLandArea { + id: string; + name: string; + dimensionId: string; + minX: number; + minZ: number; + maxX: number; + maxZ: number; +} + +/** + * land 模块的一块领地(GET /map/lands)。 + * 使用场景:地图页按区块画领地、点击时显示领主与所在子区域。与 bluemap 网页的 MapLand 同形,网关已去掉领主玩家 id。 + */ +export interface MapLand { + id: string; + name: string; + ownerName: string; + /** active / delinquent / frozen;未知值原样显示 */ + status: string; + level: number; + /** 按维度分组的已占领区块,chunks 为 [chunkX, chunkZ] */ + claims: Array<{ dimensionId: string; chunks: Array<[number, number]> }>; + areas: MapLandArea[]; +} + /** 地图接口 */ export interface MapApi { readonly endpoint: string; meta(): Promise; players(): Promise; regions(): Promise; + /** land 模块的领地;旧版网关没有该接口时请求失败,由调用方按没有领地处理 */ + lands(): Promise; /** 一张瓦片的完整地址(公开,不带会话) */ tileUrl(zoom: number, tileX: number, tileZ: number, imageFormat: string): string; } @@ -126,6 +155,7 @@ export function createMapApi(endpoint: string): MapApi { meta: () => call("GET", "/map/meta"), players: () => call("GET", "/map/players"), regions: async () => (await call<{ regions: MapRegion[] }>("GET", "/map/regions")).regions, + lands: async () => (await call<{ lands: MapLand[] }>("GET", "/map/lands")).lands, tileUrl: (zoom, tileX, tileZ, imageFormat) => `${endpoint}/map/tiles/${zoom}/${tileX}/${tileZ}.${imageFormat === "jpg" ? "jpg" : "png"}`, }; } diff --git a/packages/miniprogram/miniprogram/core/map-lands.ts b/packages/miniprogram/miniprogram/core/map-lands.ts new file mode 100644 index 00000000..188282a5 --- /dev/null +++ b/packages/miniprogram/miniprogram/core/map-lands.ts @@ -0,0 +1,193 @@ +/** + * map-lands.ts — land 模块领地在地图页上的几何与配色(纯函数,不依赖 wx) + * + * 使用场景:pages/map 的「区域与领地」图层按区块画领地填充、外轮廓与子区域虚线框,点击时找出所在领地和子区域。 + * 规则与 bluemap 网页 web/src/lib/lands.ts 一致(配色哈希、只画外轮廓、标签落在最靠近质心的区块),两边改动需同步。 + * 所有坐标都是方块坐标,与视口无关;只在领地数据变化时重算,平移缩放只做坐标换算。瓦片只覆盖主世界,这里只取主世界部分。 + */ +import type { MapLand, MapLandArea } from "./map-api"; +import { CHUNK_BLOCKS, isOverworld } from "./map-view"; + +/** 一块领地的配色:fill 半透明填充,stroke 轮廓与子区域虚线 */ +export interface LandColor { + fill: string; + stroke: string; +} + +/** 一条外轮廓线段(方块坐标),水平或竖直 */ +export interface LandEdge { + x1: number; + z1: number; + x2: number; + z2: number; +} + +/** 方块坐标下的外接矩形,max 不含(区块右下角的下一格) */ +export interface LandBounds { + minX: number; + minZ: number; + maxX: number; + maxZ: number; +} + +/** 一块领地在主世界的绘制几何 */ +export interface LandShape { + land: MapLand; + color: LandColor; + /** 已占领区块的西北角方块坐标 [x, z],每个画一个 CHUNK_BLOCKS 见方的格子 */ + chunks: Array<[number, number]>; + /** 只含外轮廓的线段:相邻两格都属于本领地时,它们之间的边不画;同一直线上首尾相接的已合并 */ + edges: LandEdge[]; + /** 主世界的子区域 */ + areas: MapLandArea[]; + bounds: LandBounds; + /** 名称标签的位置:离区块质心最近的那个区块的中心,保证落在领地内 */ + labelAt: { x: number; z: number }; + /** 已占领区块键 "chunkX,chunkZ",用于点击查询 */ + chunkKeys: ReadonlySet; +} + +/** 区块键。使用场景:chunkKeys 的存取 */ +function chunkKey(chunkX: number, chunkZ: number): string { + return `${chunkX},${chunkZ}`; +} + +/** + * 按领地 id 生成固定的颜色(FNV-1a 哈希取色相)。 + * 使用场景:每块领地一种色相,相邻领地容易分清,刷新数据时颜色不跳;与网页版同一 id 得到同一色相。 + */ +export function landColor(id: string): LandColor { + let hash = 0x811c9dc5; + for (let i = 0; i < id.length; i += 1) { + hash ^= id.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + const hue = (hash >>> 0) % 360; + return { fill: `hsla(${hue}, 75%, 50%, 0.28)`, stroke: `hsl(${hue}, 80%, 40%)` }; +} + +/** + * 把同一条直线上首尾相接的单区块线段合并成长线段。 + * lines 的键是线所在的坐标(水平线为 z,竖直线为 x),值是该线上每段的起点(长度均为一个区块)。 + */ +function mergeEdges(lines: Map, horizontal: boolean, out: LandEdge[]): void { + for (const [line, starts] of lines) { + starts.sort((a, b) => a - b); + let runStart = starts[0]!; + let runEnd = runStart + CHUNK_BLOCKS; + const flush = () => out.push(horizontal ? { x1: runStart, z1: line, x2: runEnd, z2: line } : { x1: line, z1: runStart, x2: line, z2: runEnd }); + for (let i = 1; i < starts.length; i += 1) { + if (starts[i] === runEnd) { + runEnd += CHUNK_BLOCKS; + continue; + } + flush(); + runStart = starts[i]!; + runEnd = runStart + CHUNK_BLOCKS; + } + flush(); + } +} + +/** 往某条线上记一段单区块线段 */ +function pushSegment(lines: Map, line: number, start: number): void { + const list = lines.get(line); + if (list) list.push(start); + else lines.set(line, [start]); +} + +/** 一块领地在主世界的绘制几何;主世界既没有区块也没有子区域时返回 null */ +function buildLandShape(land: MapLand): LandShape | null { + const chunks: Array<[number, number]> = []; + for (const group of land.claims) { + if (isOverworld(group.dimensionId)) chunks.push(...group.chunks); + } + const areas = land.areas.filter((area) => isOverworld(area.dimensionId)); + if (chunks.length === 0 && areas.length === 0) return null; + + const chunkKeys = new Set(chunks.map(([x, z]) => chunkKey(x, z))); + const horizontal = new Map(); + const vertical = new Map(); + const bounds: LandBounds = { minX: Infinity, minZ: Infinity, maxX: -Infinity, maxZ: -Infinity }; + const origins: Array<[number, number]> = []; + let sumX = 0; + let sumZ = 0; + for (const [cx, cz] of chunks) { + const x = cx * CHUNK_BLOCKS; + const z = cz * CHUNK_BLOCKS; + origins.push([x, z]); + if (!chunkKeys.has(chunkKey(cx, cz - 1))) pushSegment(horizontal, z, x); + if (!chunkKeys.has(chunkKey(cx, cz + 1))) pushSegment(horizontal, z + CHUNK_BLOCKS, x); + if (!chunkKeys.has(chunkKey(cx - 1, cz))) pushSegment(vertical, x, z); + if (!chunkKeys.has(chunkKey(cx + 1, cz))) pushSegment(vertical, x + CHUNK_BLOCKS, z); + bounds.minX = Math.min(bounds.minX, x); + bounds.minZ = Math.min(bounds.minZ, z); + bounds.maxX = Math.max(bounds.maxX, x + CHUNK_BLOCKS); + bounds.maxZ = Math.max(bounds.maxZ, z + CHUNK_BLOCKS); + sumX += x + CHUNK_BLOCKS / 2; + sumZ += z + CHUNK_BLOCKS / 2; + } + for (const area of areas) { + bounds.minX = Math.min(bounds.minX, area.minX); + bounds.minZ = Math.min(bounds.minZ, area.minZ); + bounds.maxX = Math.max(bounds.maxX, area.maxX + 1); + bounds.maxZ = Math.max(bounds.maxZ, area.maxZ + 1); + } + + let labelAt = { x: (bounds.minX + bounds.maxX) / 2, z: (bounds.minZ + bounds.maxZ) / 2 }; + if (origins.length > 0) { + const meanX = sumX / origins.length; + const meanZ = sumZ / origins.length; + let best = Infinity; + for (const [ox, oz] of origins) { + const x = ox + CHUNK_BLOCKS / 2; + const z = oz + CHUNK_BLOCKS / 2; + const distance = (x - meanX) ** 2 + (z - meanZ) ** 2; + if (distance < best) { + best = distance; + labelAt = { x, z }; + } + } + } + + const edges: LandEdge[] = []; + mergeEdges(horizontal, true, edges); + mergeEdges(vertical, false, edges); + return { land, color: landColor(land.id), chunks: origins, edges, areas, bounds, labelAt, chunkKeys }; +} + +/** 所有领地在主世界的绘制几何。使用场景:地图页拉到领地数据后重算一次 */ +export function buildLandShapes(lands: readonly MapLand[]): LandShape[] { + const out: LandShape[] = []; + for (const land of lands) { + const shape = buildLandShape(land); + if (shape) out.push(shape); + } + return out; +} + +/** 子区域是否包含这一格(坐标含两端) */ +function areaContains(area: MapLandArea, bx: number, bz: number): boolean { + return bx >= area.minX && bx <= area.maxX && bz >= area.minZ && bz <= area.maxZ; +} + +/** + * 某个方块所在的领地,以及包含它的子区域;没有领地时返回 null。 + * 使用场景:点击地图时的信息卡。先按区块找;区块未被占领但落在某个子区域里时也算该领地(与网页版 landAt 一致)。 + */ +export function landAt(shapes: readonly LandShape[], x: number, z: number): { shape: LandShape; areas: MapLandArea[] } | null { + const bx = Math.floor(x); + const bz = Math.floor(z); + const key = chunkKey(Math.floor(bx / CHUNK_BLOCKS), Math.floor(bz / CHUNK_BLOCKS)); + const shape = shapes.find((item) => item.chunkKeys.has(key)) ?? shapes.find((item) => item.areas.some((area) => areaContains(area, bx, bz))); + if (!shape) return null; + return { shape, areas: shape.areas.filter((area) => areaContains(area, bx, bz)) }; +} + +/** 领地状态的中文名;未知状态原样返回。使用场景:信息卡 */ +export function landStatusLabel(status: string): string { + if (status === "active") return "正常"; + if (status === "delinquent") return "欠费"; + if (status === "frozen") return "冻结"; + return status; +} diff --git a/packages/miniprogram/miniprogram/core/map-view.ts b/packages/miniprogram/miniprogram/core/map-view.ts index 1e39a8fb..1d9019a0 100644 --- a/packages/miniprogram/miniprogram/core/map-view.ts +++ b/packages/miniprogram/miniprogram/core/map-view.ts @@ -2,7 +2,7 @@ * map-view.ts — 地图页的视口与瓦片计算(纯函数,不依赖 wx) * * 使用场景:pages/map 的 canvas 渲染与手势处理:方块坐标与屏幕坐标互换、平移、以手指为中心缩放、 - * 选择瓦片层级、枚举视口内的瓦片、找低一级瓦片占位。 + * 选择瓦片层级、枚举视口内的瓦片、找低一级瓦片占位、区块网格的线距。 * 瓦片规则移植自 bluemap 网页 web/src/lib/unmined-meta.ts 与 satellite-map.tsx(两个项目暂时各有一份,后续可抽成共享包)。 * 坐标约定:x 向东增大、z 向南增大;屏幕 x 向右、y 向下,因此方块 z 直接对应屏幕 y。 */ @@ -62,6 +62,18 @@ const ZOOM_IN_EXTRA_LEVELS = 2; /** 首次进入时整图适配屏幕后再留出的边距比例(地图占屏幕短边的 90%)。使用场景:fitViewport。 */ const FIT_FILL_RATIO = 0.9; +/** 一个区块的边长(方块)。使用场景:区块网格的线距、领地按区块换算方块坐标(core/map-lands.ts)。 */ +export const CHUNK_BLOCKS = 16; + +/** + * 区块网格的疏密阈值(CSS 像素),与 bluemap 网页 web/src/lib/map-layers.ts 的 chunkGridStyle 一致: + * 格子小于 GRID_MIN_CELL_PX 时改为每隔 2、4、8… 个区块画一条,最多合并 GRID_MAX_MERGE 个;合并后仍小于 GRID_HIDE_PX 就不画。 + * 使用场景:chunkGridSpacing。 + */ +const GRID_MIN_CELL_PX = 12; +const GRID_MAX_MERGE = 64; +const GRID_HIDE_PX = 8; + /** 某一层级上,一张瓦片覆盖的方块边长 */ export function tileBlockSize(zoom: number, tileSize: number): number { return tileSize / 2 ** zoom; @@ -124,6 +136,20 @@ export function viewBlockRect(view: Viewport): MapBounds { return { minX: view.centerX - halfW, minZ: view.centerZ - halfH, maxX: view.centerX + halfW, maxZ: view.centerZ + halfH }; } +/** + * 当前缩放下区块网格线的间距(方块,总是 CHUNK_BLOCKS 的 2 的幂倍);缩得太小不必画时返回 null。 + * 使用场景:地图页「区块」图层,缩小时自动稀疏,避免网格糊成一片。 + */ +export function chunkGridSpacing(scale: number): number | null { + let cells = 1; + let cellPx = CHUNK_BLOCKS * scale; + while (cellPx < GRID_MIN_CELL_PX && cells < GRID_MAX_MERGE) { + cells *= 2; + cellPx *= 2; + } + return cellPx < GRID_HIDE_PX ? null : cells * CHUNK_BLOCKS; +} + /** * 枚举视口盖住、且落在地图边界内的瓦片,按离屏幕中心由近到远排序(先加载中间的)。 * 使用场景:每帧绘制与按需加载瓦片图片。 diff --git a/packages/miniprogram/miniprogram/pages/map/map.ts b/packages/miniprogram/miniprogram/pages/map/map.ts index e08590e7..aac7d439 100644 --- a/packages/miniprogram/miniprogram/pages/map/map.ts +++ b/packages/miniprogram/miniprogram/pages/map/map.ts @@ -4,18 +4,23 @@ * 使用场景:查看服务器主世界的俯视地图(bluemap / uNmINeD 渲染的瓦片)、所有在线玩家的位置,以及区域与领地范围。 * - canvas 2d 原生绘制:单指拖动、双指缩放,用 canvas.requestAnimationFrame 按需重绘; * - 瓦片走 core/tile-cache.ts 的 LRU 缓存,目标层级还没加载完时用已缓存的低一级瓦片放大占位; - * - 玩家与区域 / 领地两个图层可开关;点击玩家显示名字与坐标,点击空白处显示所在区域; + * - 玩家、区域与领地、区块网格三个图层可开关;点击玩家显示名字与坐标,点击空白处显示所在领地(含子区域)或区域; + * - 领地来自 land 模块(…/map/lands,按区块画填充、外轮廓与子区域虚线框,几何在 core/map-lands.ts), + * 区域来自 area 模块(…/map/regions,矩形); * - 不在主世界的玩家列在底部,不画在图上(瓦片只覆盖主世界); * - 已绑定游戏账号时显示「定位到我」,按游戏角色名匹配自己的位置; - * - 页面显示时每 5 秒刷新玩家位置,隐藏时停止;元数据与区域在每次进入页面时拉取。 + * - 页面显示时每 5 秒刷新玩家位置,隐藏时停止;元数据、区域与领地在每次进入页面时拉取。 * 数据来自 core/map-api.ts(mp-gateway 只读转发),视口与瓦片计算在 core/map-view.ts。 */ import { errorText } from "../../core/format"; -import { homeMapApi, renderedMeta, type MapPlayer, type MapRegion, type RenderedMapMeta } from "../../core/map-api"; +import { homeMapApi, renderedMeta, type MapLand, type MapPlayer, type MapRegion, type RenderedMapMeta } from "../../core/map-api"; +import { buildLandShapes, landAt, landStatusLabel, type LandShape } from "../../core/map-lands"; import { ancestorTile, blockToScreen, + CHUNK_BLOCKS, chooseTileZoom, + chunkGridSpacing, clampCenter, dimensionLabel, hitNearest, @@ -28,6 +33,7 @@ import { tileKeyString, touchDistance, touchMidpoint, + viewBlockRect, visibleTiles, zoomAt, type ScreenPoint, @@ -60,21 +66,28 @@ const ZOOM_BUTTON_FACTOR = 2; const LOCATE_MIN_SCALE = 2; /** 区域在屏幕上至少这么宽(CSS 像素)才写名称,避免小领地的字糊成一团 */ const REGION_LABEL_MIN_PX = 48; +/** 领地子区域虚线框的线段 / 间隔长度(CSS 像素)。使用场景:drawLands */ +const LAND_AREA_DASH = [4, 3]; /** * 画布配色。canvas 读不到 CSS 变量,按系统主题取两套。 * bg 与 theme.json 的 pageBg 一致(地图边界外的底色);玩家与区域颜色叠在地形上,需要描边保证对比度。 + * grid / gridHighlight 是区块网格的暗线与错开 1 像素的亮线,深浅地形上都能看清(与 bluemap 网页 chunkGridStyle 同色)。 + * land 模块的领地按 id 取色(core/map-lands.ts 的 landColor),不在这里。 */ const MAP_COLORS = { - light: { bg: "#f5f5f6", player: "#2563eb", self: "#ea580c", outline: "#ffffff", label: "#1c1c1c", labelHalo: "rgba(255,255,255,0.85)", area: "rgba(37,99,235,0.95)", areaFill: "rgba(37,99,235,0.10)", land: "rgba(22,163,74,0.95)", landFill: "rgba(22,163,74,0.12)", selected: "#facc15" }, - dark: { bg: "#1d1d1d", player: "#60a5fa", self: "#fb923c", outline: "#111111", label: "#f2f2f2", labelHalo: "rgba(0,0,0,0.75)", area: "rgba(96,165,250,0.95)", areaFill: "rgba(96,165,250,0.12)", land: "rgba(74,222,128,0.95)", landFill: "rgba(74,222,128,0.14)", selected: "#facc15" }, + light: { bg: "#f5f5f6", player: "#2563eb", self: "#ea580c", outline: "#ffffff", label: "#1c1c1c", labelHalo: "rgba(255,255,255,0.85)", area: "rgba(37,99,235,0.95)", areaFill: "rgba(37,99,235,0.10)", land: "rgba(22,163,74,0.95)", landFill: "rgba(22,163,74,0.12)", selected: "#facc15", grid: "rgba(0,0,0,0.55)", gridHighlight: "rgba(255,255,255,0.65)" }, + dark: { bg: "#1d1d1d", player: "#60a5fa", self: "#fb923c", outline: "#111111", label: "#f2f2f2", labelHalo: "rgba(0,0,0,0.75)", area: "rgba(96,165,250,0.95)", areaFill: "rgba(96,165,250,0.12)", land: "rgba(74,222,128,0.95)", landFill: "rgba(74,222,128,0.14)", selected: "#facc15", grid: "rgba(0,0,0,0.55)", gridHighlight: "rgba(255,255,255,0.65)" }, }; /** 地图页用到的 canvas 2d 上下文 */ type Context2D = WechatMiniprogram.CanvasRenderingContext.CanvasRenderingContext2D; -/** 当前选中的对象:玩家按名字跟踪(位置会刷新),区域按 id */ -type Selection = { kind: "player"; name: string } | { kind: "region"; id: string } | null; +/** + * 当前选中的对象:玩家按名字跟踪(位置会刷新),区域按 id; + * 领地按 id,并记下点击处所在的子区域 id(信息卡列出子区域名) + */ +type Selection = { kind: "player"; name: string } | { kind: "region"; id: string } | { kind: "land"; id: string; areaIds: string[] } | null; /** 一次手势的跟踪状态 */ interface Gesture { @@ -98,6 +111,8 @@ const rt = { meta: null as RenderedMapMeta | null, players: [] as MapPlayer[], regions: [] as MapRegion[], + /** land 模块领地的主世界绘制几何(每次进入页面拉取后重算) */ + landShapes: [] as LandShape[], tiles: null as TileCache | null, tilesCreatedAt: 0, /** 已排队的动画帧;非 null 时不重复请求 */ @@ -107,9 +122,11 @@ const rt = { /** 当前微信绑定的游戏角色名;未绑定为空 */ myName: "", selection: null as Selection, - /** 图层开关(与 data.showPlayers / showRegions 同步,绘制时读这里,免得每帧读页面实例) */ + /** 图层开关(与 data.showPlayers / showRegions / showChunks 同步,绘制时读这里,免得每帧读页面实例) */ showPlayers: true, showRegions: true, + /** 区块网格默认关闭(与网页版一致),放大看领地边界时再打开 */ + showChunks: false, /** 丢弃过期的加载结果(快速切换标签页时旧请求晚到) */ loadTicket: 0, }; @@ -251,6 +268,88 @@ function drawRegions(ctx: Context2D, palette: Palette, view: Viewport): void { } } +/** + * 画 land 模块的领地:按区块半透明填充、只描外轮廓,子区域画虚线框; + * 领地在屏幕上足够宽时在标签点写领地名,子区域足够宽时在左上角写子区域名。 + * 使用场景:「区域与领地」图层打开时,画在 area 矩形之上。 + */ +function drawLands(ctx: Context2D, palette: Palette, view: Viewport): void { + const rect = viewBlockRect(view); + const cell = CHUNK_BLOCKS * view.scale; + for (const shape of rt.landShapes) { + const { bounds } = shape; + if (bounds.maxX < rect.minX || bounds.minX > rect.maxX || bounds.maxZ < rect.minZ || bounds.minZ > rect.maxZ) continue; + const selected = rt.selection?.kind === "land" && rt.selection.id === shape.land.id; + // 同一路径内的格子同向,按非零规则合并填充,相邻区块之间不会出现深色缝 + ctx.beginPath(); + for (const [x, z] of shape.chunks) { + const origin = blockToScreen(view, x, z); + ctx.rect(origin.x, origin.y, cell, cell); + } + ctx.fillStyle = shape.color.fill; + ctx.fill(); + ctx.beginPath(); + for (const edge of shape.edges) { + const from = blockToScreen(view, edge.x1, edge.z1); + const to = blockToScreen(view, edge.x2, edge.z2); + ctx.moveTo(from.x, from.y); + ctx.lineTo(to.x, to.y); + } + ctx.strokeStyle = selected ? palette.selected : shape.color.stroke; + ctx.lineWidth = selected ? 3 : 1.5; + ctx.lineCap = "square"; + ctx.stroke(); + if (shape.areas.length) { + ctx.setLineDash(LAND_AREA_DASH); + ctx.lineWidth = 1.5; + ctx.strokeStyle = shape.color.stroke; + for (const area of shape.areas) { + const topLeft = blockToScreen(view, area.minX, area.minZ); + const bottomRight = blockToScreen(view, area.maxX + 1, area.maxZ + 1); + const width = bottomRight.x - topLeft.x; + ctx.strokeRect(topLeft.x, topLeft.y, width, bottomRight.y - topLeft.y); + if (width >= REGION_LABEL_MIN_PX && bottomRight.y - topLeft.y >= 20) drawLabel(ctx, palette, area.name, topLeft.x + 4, topLeft.y + 10, "left"); + } + ctx.setLineDash([]); + } + if ((bounds.maxX - bounds.minX) * view.scale >= REGION_LABEL_MIN_PX) { + const at = blockToScreen(view, shape.labelAt.x, shape.labelAt.z); + drawLabel(ctx, palette, shape.land.name, at.x, at.y, "center"); + } + } +} + +/** + * 画区块网格:暗线加错开 1 像素的亮线;缩小时按 chunkGridSpacing 自动稀疏,太小就不画。 + * 使用场景:「区块」图层打开时,画在瓦片之上、区域与领地之下。 + */ +function drawChunkGrid(ctx: Context2D, palette: Palette, view: Viewport): void { + const spacing = chunkGridSpacing(view.scale); + if (spacing === null) return; + const rect = viewBlockRect(view); + const xs: number[] = []; + const ys: number[] = []; + // 取整到像素中线,1 像素线不发虚 + for (let x = Math.ceil(rect.minX / spacing) * spacing; x <= rect.maxX; x += spacing) xs.push(Math.round(blockToScreen(view, x, 0).x) + 0.5); + for (let z = Math.ceil(rect.minZ / spacing) * spacing; z <= rect.maxZ; z += spacing) ys.push(Math.round(blockToScreen(view, 0, z).y) + 0.5); + const strokeGrid = (offset: number, color: string) => { + ctx.beginPath(); + for (const x of xs) { + ctx.moveTo(x + offset, 0); + ctx.lineTo(x + offset, view.height); + } + for (const y of ys) { + ctx.moveTo(0, y + offset); + ctx.lineTo(view.width, y + offset); + } + ctx.strokeStyle = color; + ctx.lineWidth = 1; + ctx.stroke(); + }; + strokeGrid(0, palette.grid); + strokeGrid(1, palette.gridHighlight); +} + /** 重绘整张画布 */ function draw(): void { const { ctx, view, meta } = rt; @@ -260,7 +359,11 @@ function draw(): void { ctx.fillStyle = palette.bg; ctx.fillRect(0, 0, view.width, view.height); drawTiles(ctx, view, meta); - if (rt.showRegions) drawRegions(ctx, palette, view); + if (rt.showChunks) drawChunkGrid(ctx, palette, view); + if (rt.showRegions) { + drawRegions(ctx, palette, view); + drawLands(ctx, palette, view); + } if (rt.showPlayers) { for (const player of rt.players) { if (!isOverworld(player.dimension)) continue; @@ -285,6 +388,8 @@ interface MapData { renderMessage: string; showPlayers: boolean; showRegions: boolean; + /** 「区块」网格图层开关 */ + showChunks: boolean; /** 游戏服务器的位置上报已中断 */ stale: boolean; onlineCount: number; @@ -304,6 +409,7 @@ Page({ renderMessage: "", showPlayers: true, showRegions: true, + showChunks: false, stale: false, onlineCount: 0, otherDimensions: [], @@ -329,15 +435,20 @@ Page({ this.stopPolling(); }, - /** 拉取元数据、区域与自己的绑定信息;有地图时初始化画布 */ + /** 拉取元数据、区域、领地与自己的绑定信息;有地图时初始化画布 */ async load() { const ticket = ++rt.loadTicket; const api = homeMapApi(); try { - // 区域读取失败不影响看地图,按没有区域处理 - const [meta, regions] = await Promise.all([api.meta(), api.regions().catch(() => [] as MapRegion[])]); + // 区域 / 领地读取失败(含旧版网关没有 /map/lands)不影响看地图,按没有区域 / 领地处理 + const [meta, regions, lands] = await Promise.all([ + api.meta(), + api.regions().catch(() => [] as MapRegion[]), + api.lands().catch(() => [] as MapLand[]), + ]); if (ticket !== rt.loadTicket) return; rt.regions = regions; + rt.landShapes = buildLandShapes(lands); const rendered = renderedMeta(meta); if (!rendered) { rt.meta = null; @@ -489,7 +600,7 @@ Page({ if (wasTap && tapAt) this.handleTap(tapAt); }, - /** 点击:优先选中附近的玩家,其次选中所在的区域 / 领地,否则清除选中 */ + /** 点击:优先选中附近的玩家,其次选中所在的领地(land 模块),再次选中所在的区域(area 模块),否则清除选中 */ handleTap(point: ScreenPoint) { const view = rt.view; if (!view) return; @@ -505,6 +616,13 @@ Page({ } if (rt.showRegions) { const block = screenToBlock(view, point); + const land = landAt(rt.landShapes, block.x, block.z); + if (land) { + rt.selection = { kind: "land", id: land.shape.land.id, areaIds: land.areas.map((area) => area.id) }; + this.refreshSelection(); + requestDraw(); + return; + } const overworld = rt.regions.filter((region) => isOverworld(region.dimension)); const index = smallestRegionAt(overworld, block.x, block.z); if (index >= 0) { @@ -532,6 +650,19 @@ Page({ this.setData({ selectedTitle: region.name, selectedDesc: `${regionKindLabel(region.kind)} · ${region.minX}, ${region.minZ} → ${region.maxX}, ${region.maxZ}` }); return; } + if (selection?.kind === "land") { + const shape = rt.landShapes.find((item) => item.land.id === selection.id); + if (!shape) return this.clearSelection(); + const { land } = shape; + const areaNames = shape.areas.filter((area) => selection.areaIds.includes(area.id)).map((area) => area.name); + const parts = ["领地"]; + if (land.ownerName) parts.push(`领主 ${land.ownerName}`); + // 正常状态不占信息卡位置,只提示欠费 / 冻结等异常 + if (land.status && land.status !== "active") parts.push(landStatusLabel(land.status)); + if (areaNames.length) parts.push(`区域 ${areaNames.join("、")}`); + this.setData({ selectedTitle: land.name, selectedDesc: parts.join(" · ") }); + return; + } if (this.data.selectedTitle) this.setData({ selectedTitle: "", selectedDesc: "" }); }, clearSelection() { @@ -549,7 +680,13 @@ Page({ toggleRegions() { rt.showRegions = !rt.showRegions; this.setData({ showRegions: rt.showRegions }); - if (!rt.showRegions && rt.selection?.kind === "region") this.clearSelection(); + if (!rt.showRegions && (rt.selection?.kind === "region" || rt.selection?.kind === "land")) this.clearSelection(); + requestDraw(); + }, + /** 开关区块网格 */ + toggleChunks() { + rt.showChunks = !rt.showChunks; + this.setData({ showChunks: rt.showChunks }); requestDraw(); }, diff --git a/packages/miniprogram/miniprogram/pages/map/map.wxml b/packages/miniprogram/miniprogram/pages/map/map.wxml index c91fa409..a7333e4b 100644 --- a/packages/miniprogram/miniprogram/pages/map/map.wxml +++ b/packages/miniprogram/miniprogram/pages/map/map.wxml @@ -29,6 +29,9 @@ 区域与领地 + + 区块 + 玩家位置已停止更新 diff --git a/packages/mp-gateway/src/map-client.ts b/packages/mp-gateway/src/map-client.ts index e631ea06..3e9bb883 100644 --- a/packages/mp-gateway/src/map-client.ts +++ b/packages/mp-gateway/src/map-client.ts @@ -1,9 +1,9 @@ /** * map-client.ts — 只读访问本机 bluemap 地图服务(map-server,默认 127.0.0.1:5120) * - * 使用场景:map-routes.ts 的 …/map/meta、…/map/players、…/map/regions 与瓦片转发(小程序「地图」页)。 + * 使用场景:map-routes.ts 的 …/map/meta、…/map/players、…/map/regions、…/map/lands 与瓦片转发(小程序「地图」页)。 * 以 MapSource 接口提供(DIP),路由不依赖地图服务的 HTTP 路径与文件布局;单测可换成指向假地图服务的实现。 - * 只调用地图服务的 GET 接口,绝不转发它没有鉴权的写接口(POST /api/players、/api/regions、/api/render)。 + * 只调用地图服务的 GET 接口,绝不转发它没有鉴权的写接口(POST /api/players、/api/regions、/api/lands、/api/render)。 */ import { Readable } from "node:stream"; import { HttpError } from "./routes.js"; @@ -93,6 +93,36 @@ export interface MapRegion { maxZ: number; } +/** + * 领地里的一个子区域(land 模块的三维盒子,这里只保留水平投影)。坐标含两端方块。 + * 使用场景:MapLand.areas,小程序地图在领地内画虚线框、点击时列出所在子区域。 + */ +export interface MapLandArea { + id: string; + name: string; + dimensionId: string; + minX: number; + minZ: number; + maxX: number; + maxZ: number; +} + +/** + * land 模块的一块领地(地图服务 GET /api/lands,数据由 bluemap 脚本从 land.map 服务推送)。 + * 使用场景:…/map/lands,小程序地图按区块画领地。只透出地图需要的字段,不转发领主的玩家 id。 + */ +export interface MapLand { + id: string; + name: string; + ownerName: string; + /** active / delinquent / frozen;未知值原样透传 */ + status: string; + level: number; + /** 按维度分组的已占领区块,chunks 为 [chunkX, chunkZ] */ + claims: Array<{ dimensionId: string; chunks: Array<[number, number]> }>; + areas: MapLandArea[]; +} + /** 一张瓦片的转发结果;status 为 404 时 body 为 null */ export interface MapTile { status: 200 | 404; @@ -115,6 +145,7 @@ export interface MapSource { meta(): Promise; players(): Promise; regions(): Promise; + lands(): Promise; tile(key: MapTileKey): Promise; } @@ -216,6 +247,44 @@ function sanitizeRegions(raw: unknown): MapRegion[] { return result; } +/** 是否为 [chunkX, chunkZ] 整数对 */ +function isChunk(value: unknown): value is [number, number] { + return Array.isArray(value) && value.length === 2 && Number.isInteger(value[0]) && Number.isInteger(value[1]); +} + +/** + * 只保留合法的领地条目,并去掉地图服务可能附带的其他字段(含领主玩家 id)。 + * 使用场景:lands()。单个区块组 / 子区域不合格时只丢弃该项,领地本身缺字段时整块丢弃。 + */ +function sanitizeLands(raw: unknown): MapLand[] { + if (!Array.isArray(raw)) return []; + const result: MapLand[] = []; + for (const row of raw as Array>) { + if (!row || typeof row.id !== "string" || typeof row.name !== "string") continue; + const claims: MapLand["claims"] = []; + for (const group of Array.isArray(row.claims) ? (row.claims as Array>) : []) { + if (!group || typeof group.dimensionId !== "string" || !Array.isArray(group.chunks)) continue; + claims.push({ dimensionId: group.dimensionId, chunks: group.chunks.filter(isChunk).map(([x, z]) => [x, z]) }); + } + const areas: MapLandArea[] = []; + for (const area of Array.isArray(row.areas) ? (row.areas as Array>) : []) { + if (!area || typeof area.id !== "string" || typeof area.name !== "string" || typeof area.dimensionId !== "string") continue; + if (!isNum(area.minX) || !isNum(area.minZ) || !isNum(area.maxX) || !isNum(area.maxZ)) continue; + areas.push({ id: area.id, name: area.name, dimensionId: area.dimensionId, minX: area.minX, minZ: area.minZ, maxX: area.maxX, maxZ: area.maxZ }); + } + result.push({ + id: row.id, + name: row.name, + ownerName: typeof row.ownerName === "string" ? row.ownerName : "", + status: typeof row.status === "string" ? row.status : "", + level: isNum(row.level) ? row.level : 0, + claims, + areas, + }); + } + return result; +} + /** * 基于地图服务 HTTP 的实现。 * @@ -280,6 +349,11 @@ export function createHttpMapSource(baseUrl: string, fetchImpl: typeof fetch = f return sanitizeRegions(data.regions); }, + async lands() { + const data = await getJson("/api/lands"); + return sanitizeLands(data.lands); + }, + async tile(key) { const response = await get(tilePath(key), TILE_TIMEOUT_MS); if (response.status === 404) { diff --git a/packages/mp-gateway/src/map-routes.ts b/packages/mp-gateway/src/map-routes.ts index 2c150217..38b5a37d 100644 --- a/packages/mp-gateway/src/map-routes.ts +++ b/packages/mp-gateway/src/map-routes.ts @@ -3,7 +3,8 @@ * * GET …/map/meta → MapMeta(需要玩家会话) * GET …/map/players → { players, updatedAt, stale }(需要玩家会话;所有在线玩家) - * GET …/map/regions → { regions }(需要玩家会话) + * GET …/map/regions → { regions }(需要玩家会话;area 模块的矩形区域) + * GET …/map/lands → { lands }(需要玩家会话;land 模块按区块占领的领地及其子区域) * GET …/map/tiles/{zoom}/{x}/{z}.{ext} → 瓦片图片(公开;canvas 加载网络图片无法携带 Authorization 头) * * 使用场景:小程序 pages/map。按 player-routes.ts 约定的方式新增路由工厂并在 server.ts 注册(OCP)。 @@ -89,6 +90,13 @@ export function createMapRoutes(deps: MapRouteDeps): Route[] { sendJson(context.response, 200, { regions: await sessionMap(context).regions() }); }, }, + { + method: "GET", + suffix: "/map/lands", + async handle(context) { + sendJson(context.response, 200, { lands: await sessionMap(context).lands() }); + }, + }, { method: "GET", suffix: "/map/tiles", diff --git a/packages/mp-gateway/test/gateway.test.ts b/packages/mp-gateway/test/gateway.test.ts index 7738329a..89c320d2 100644 --- a/packages/mp-gateway/test/gateway.test.ts +++ b/packages/mp-gateway/test/gateway.test.ts @@ -370,7 +370,7 @@ const FAKE_TILE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]); /** * 启动一个假的 bluemap 地图服务,记录收到的每个请求(方法 + 路径)。 - * 只认 /api/status、/api/players、/api/regions、属性脚本与一张瓦片 tile.-3.5(目录 -1/0),其余 404。 + * 只认 /api/status、/api/players、/api/regions、/api/lands、属性脚本与一张瓦片 tile.-3.5(目录 -1/0),其余 404。 */ async function launchFakeMap() { const requests: string[] = []; @@ -387,6 +387,26 @@ async function launchFakeMap() { if (request.url === "/api/regions") { return json({ regions: [{ id: "a1", kind: "land", name: "家", dimension: "minecraft:overworld", minX: 0, minZ: 0, maxX: 15, maxZ: 15 }, { id: "x", kind: "other" }] }); } + if (request.url === "/api/lands") { + return json({ + lands: [ + { + id: "l1", + name: "小镇", + ownerId: "xuid-secret", + ownerName: "Steve", + status: "active", + level: 2, + claims: [{ dimensionId: "minecraft:overworld", chunks: [[0, 0], [1, 0], [1.5, 2], "bad"] }, { dimensionId: 3 }], + areas: [ + { id: "s1", name: "商店", dimensionId: "minecraft:overworld", minX: 1, minY: 60, minZ: 2, maxX: 8, maxY: 70, maxZ: 9 }, + { id: "bad", name: "缺坐标" }, + ], + }, + { name: "缺 id" }, + ], + }); + } if (request.url === "/unmined/unmined.map.properties.js") { response.writeHead(200, { "content-type": "text/javascript" }); return response.end(FAKE_PROPERTIES); @@ -404,13 +424,14 @@ async function launchFakeMap() { return { requests, url: `http://127.0.0.1:${address.port}`, close: () => new Promise((done) => server.close(() => done())) }; } -test("HTTP 地图:meta / players / regions 需要会话并只透出白名单字段;瓦片公开转发;只向地图服务发 GET", async () => { +test("HTTP 地图:meta / players / regions / lands 需要会话并只透出白名单字段;瓦片公开转发;只向地图服务发 GET", async () => { const fakeMap = await launchFakeMap(); const { gateway, base } = await launch({ map: createHttpMapSource(fakeMap.url) }); try { assert.equal((await call(`${base}/map/meta`)).status, 401); assert.equal((await call(`${base}/map/players`)).status, 401); assert.equal((await call(`${base}/map/regions`)).status, 401); + assert.equal((await call(`${base}/map/lands`)).status, 401); const token = await login(base, "a"); const meta = await call(`${base}/map/meta`, { token }); @@ -438,6 +459,22 @@ test("HTTP 地图:meta / players / regions 需要会话并只透出白名单 const regions = await call(`${base}/map/regions`, { token }); assert.deepEqual(regions.data, { regions: [{ id: "a1", kind: "land", name: "家", dimension: "minecraft:overworld", minX: 0, minZ: 0, maxX: 15, maxZ: 15 }] }); + // 领地:不透出领主玩家 id 与子区域的高度;非整数区块、缺字段的区块组 / 子区域 / 领地被丢弃 + const lands = await call(`${base}/map/lands`, { token }); + assert.deepEqual(lands.data, { + lands: [ + { + id: "l1", + name: "小镇", + ownerName: "Steve", + status: "active", + level: 2, + claims: [{ dimensionId: "minecraft:overworld", chunks: [[0, 0], [1, 0]] }], + areas: [{ id: "s1", name: "商店", dimensionId: "minecraft:overworld", minX: 1, minZ: 2, maxX: 8, maxZ: 9 }], + }, + ], + }); + // 瓦片不带会话也能取;负坐标的目录分组向下取整(-3 → -1) const tile = await fetch(`${base}/map/tiles/0/-3/5.png`); assert.equal(tile.status, 200);