Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/mp-map-lands.md
Original file line number Diff line number Diff line change
@@ -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`)。
3 changes: 2 additions & 1 deletion modules/sdk/@sfmc-sdk/src/sapi/service/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`),
Expand Down
32 changes: 31 additions & 1 deletion packages/miniprogram/miniprogram/core/map-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<MapMeta>;
players(): Promise<MapPlayers>;
regions(): Promise<MapRegion[]>;
/** land 模块的领地;旧版网关没有该接口时请求失败,由调用方按没有领地处理 */
lands(): Promise<MapLand[]>;
/** 一张瓦片的完整地址(公开,不带会话) */
tileUrl(zoom: number, tileX: number, tileZ: number, imageFormat: string): string;
}
Expand Down Expand Up @@ -126,6 +155,7 @@ export function createMapApi(endpoint: string): MapApi {
meta: () => call<MapMeta>("GET", "/map/meta"),
players: () => call<MapPlayers>("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"}`,
};
}
Expand Down
193 changes: 193 additions & 0 deletions packages/miniprogram/miniprogram/core/map-lands.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
}

/** 区块键。使用场景: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<number, number[]>, 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<number, number[]>, 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<number, number[]>();
const vertical = new Map<number, number[]>();
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;
}
28 changes: 27 additions & 1 deletion packages/miniprogram/miniprogram/core/map-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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。
*/
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

/**
* 枚举视口盖住、且落在地图边界内的瓦片,按离屏幕中心由近到远排序(先加载中间的)。
* 使用场景:每帧绘制与按需加载瓦片图片。
Expand Down
Loading
Loading