import { assetManager, JsonAsset, sys } from 'cc'; import { MiniGamePf } from '../../mx/tools/MiniGameHostInfo'; import { GBundle } from '../game/ConfigRes'; type ChinaProvincePack = { /** 坐标放大倍数(lng/lat * s 后为整数) */ s: number; p: Array<{ n: string; /** [minLng, minLat, maxLng, maxLat],已乘 s */ b: number[]; /** 外环,扁平 [lng,lat,lng,lat...],已乘 s */ r: number[][]; }>; }; type CachePayload = { n: string }; const CACHE_KEY = 'province_locator_v1'; const DATA_PATH = 'config/china_provinces'; const LOCATE_TIMEOUT_MS = 10000; /** * 一次定位 + 本地省界判断。命中后写入本地缓存,之后不再调定位。 */ export class ProvinceLocator { private static _cacheLoaded = false; private static _cached: string | null = null; private static _inflight: Promise | null = null; private static _pack: ChinaProvincePack | null = null; /** 空缓存本进程内允许静默重试一次 */ private static _emptyRetryDone = false; /** * 返回短省名(广东 / 北京);未授权、失败或未命中返回空串。 * @param options.allowEmptyCacheRetry 上次失败空缓存时,本进程内是否允许再定位一次 * @param options.force 清掉空失败缓存后强制重新定位(授权成功后用) */ static getProvince(options?: { allowEmptyCacheRetry?: boolean; force?: boolean }): Promise { this._readCache(); if (options?.force && this._cached === '') { this._cached = null; this._emptyRetryDone = false; } if (this._cached != null && this._cached !== '') return Promise.resolve(this._cached); if (this._cached === '') { if (!options?.allowEmptyCacheRetry || this._emptyRetryDone) { return Promise.resolve(''); } this._emptyRetryDone = true; } if (this._inflight) return this._inflight; this._inflight = this._resolve().finally(() => { this._inflight = null; }); return this._inflight; } private static async _resolve(): Promise { const loc = await this._getLatLng(); if (!loc) return ''; if ('denied' in loc) { this._writeCache(''); return ''; } const pack = await this._loadPack(); if (!pack) return ''; const name = this._hitProvince(loc.lng, loc.lat, pack); this._writeCache(name); return name; } private static _readCache(): void { if (this._cacheLoaded) return; this._cacheLoaded = true; try { const raw = sys.localStorage.getItem(CACHE_KEY); if (!raw) return; const o = JSON.parse(raw) as CachePayload; if (typeof o?.n === 'string') this._cached = o.n; } catch { /* ignore */ } } private static _writeCache(name: string): void { this._cached = name; try { const payload: CachePayload = { n: name }; sys.localStorage.setItem(CACHE_KEY, JSON.stringify(payload)); } catch { /* ignore */ } } private static _loadPack(): Promise { if (this._pack) return Promise.resolve(this._pack); return new Promise((resolve) => { const take = (bundle: ReturnType) => { if (!bundle) { resolve(null); return; } const cached = bundle.get(DATA_PATH, JsonAsset); if (cached?.json) { this._pack = cached.json as ChinaProvincePack; resolve(this._pack); return; } bundle.load(DATA_PATH, JsonAsset, (err, asset) => { if (err || !asset?.json) { resolve(null); return; } this._pack = asset.json as ChinaProvincePack; resolve(this._pack); }); }; const existed = assetManager.getBundle(GBundle.First); if (existed) { take(existed); return; } assetManager.loadBundle(GBundle.First, (err, bundle) => take(err ? null : bundle)); }); } private static _getLatLng(): Promise<{ lat: number; lng: number } | { denied: true } | null> { const host = MiniGamePf.api(); const wx = (window as Record)['wx'] as MiniLocationApi | undefined; const api = (host?.getLocation ? host : wx) as MiniLocationApi | undefined; if (!api?.getLocation) return Promise.resolve(null); return new Promise((resolve) => { let done = false; const finish = (v: { lat: number; lng: number } | { denied: true } | null) => { if (done) return; done = true; resolve(v); }; const timer = setTimeout(() => finish(null), LOCATE_TIMEOUT_MS); const opts: Parameters[0] = { success: (res) => { clearTimeout(timer); const lat = Number(res?.latitude); const lng = Number(res?.longitude); if (!Number.isFinite(lat) || !Number.isFinite(lng)) finish(null); else finish({ lat, lng }); }, fail: (res) => { clearTimeout(timer); const msg = String(res?.errMsg ?? ''); if (/auth deny|authorize|permission|privacy/i.test(msg)) finish({ denied: true }); else finish(null); }, }; // B 站无 type;抖音/微信传 gcj02 对齐国内省界 if (!MiniGamePf.isBl()) opts.type = 'gcj02'; api.getLocation(opts); }); } private static _hitProvince(lng: number, lat: number, pack: ChinaProvincePack): string { const s = pack.s || 10000; const x = lng * s; const y = lat * s; const list = pack.p; for (let i = 0; i < list.length; i++) { const item = list[i]; const b = item.b; if (x < b[0] || y < b[1] || x > b[2] || y > b[3]) continue; const rings = item.r; for (let k = 0; k < rings.length; k++) { if (this._pointInRing(x, y, rings[k])) return item.n; } } return ''; } /** 射线法:点是否在多边形内(ring 为已乘 s 的扁平坐标) */ private static _pointInRing(x: number, y: number, ring: number[]): boolean { const n = (ring.length / 2) | 0; let inside = false; let j = n - 1; for (let i = 0; i < n; i++) { const xi = ring[i * 2]; const yi = ring[i * 2 + 1]; const xj = ring[j * 2]; const yj = ring[j * 2 + 1]; const dy = yj - yi; if (dy !== 0 && (yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / dy + xi) { inside = !inside; } j = i; } return inside; } } type MiniLocationApi = { getLocation: (opts: { type?: string; success?: (res: { latitude?: number; longitude?: number }) => void; fail?: (res: { errMsg?: string }) => void; }) => void; };