Ver código fonte

Refine compass and ruler overlay

zhangyan 2 semanas atrás
pai
commit
ce25530938

+ 96 - 0
communication-guidelines.md

@@ -0,0 +1,96 @@
+# 沟通协作建议
+
+这份文档用于约定后续在 UI 微调、交互细改、规则补充时,怎样沟通最有效,减少来回修改。
+
+## 1. 需求描述的推荐格式
+
+后续尽量按下面 4 个点描述需求:
+
+### 改动对象
+明确指出这次只改什么。
+
+例如:
+- 比例尺和指北针顶部数字之间的距离
+- 某个按钮的高亮状态
+- 某条提示文案
+
+### 不要改
+明确指出哪些相邻元素不要动。
+
+例如:
+- 不要改顶部数字和小箭头之间的距离
+- 不要动比例尺刻度算法
+- 不要改地图引擎逻辑
+
+### 目标效果
+说明你想达到什么视觉或交互结果。
+
+例如:
+- 更靠近一点
+- 改成 4 到 5 像素的空隙
+- 只在可点击时高亮
+
+### 验证标准
+说明你用什么标准判断“改对了”。
+
+例如:
+- 看起来贴近,但不要重叠
+- 指北针仍然盖住比例尺
+- 缩放时要实时变化,不要手势结束后再跳
+
+## 2. 推荐的需求表达模板
+
+可以直接按这个模板发:
+
+```text
+改动对象:
+不要改:
+目标:
+验证标准:
+```
+
+例如:
+
+```text
+改动对象:比例尺和顶部角度数字之间的距离
+不要改:顶部角度数字和小箭头之间的距离
+目标:再近一点
+验证标准:看起来大约 4~5px,不重叠
+```
+
+## 3. 为什么这样最有效
+
+很多来回修改,通常不是功能做不了,而是:
+
+- 一次需求里混了两层甚至三层改动
+- 没说清楚“哪块不要动”
+- 验收标准只有感觉,没有边界
+
+一旦把“改什么”和“不要改什么”拆开,误改概率会明显下降。
+
+## 4. 开发执行的约定
+
+后续默认按下面方式执行:
+
+- 先复述这次只改哪一层
+- 真实执行时只改这一层
+- 不顺手带改别的部分
+- 改完明确说明:
+  - 改了什么
+  - 没改什么
+
+## 5. 最适合哪些场景
+
+这套方式尤其适合:
+
+- UI 间距微调
+- 按钮状态和图标切换
+- HUD 显示调整
+- 地图表现修正
+- 规则字段补充
+
+## 6. 一句话原则
+
+后续最有效的协作方式是:
+
+**需求把边界说死,修改一次只动一层。**

+ 19 - 3
miniprogram/engine/map/mapEngine.ts

@@ -23,7 +23,7 @@ const RENDER_MODE = 'Single WebGL Pipeline'
 const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen'
 const MAP_NORTH_OFFSET_DEG = 0
 let MAGNETIC_DECLINATION_DEG = -6.91
-let MAGNETIC_DECLINATION_TEXT = '6.91 W'
+let MAGNETIC_DECLINATION_TEXT = '6.91˚ W'
 const MIN_ZOOM = 15
 const MAX_ZOOM = 20
 const DEFAULT_ZOOM = 17
@@ -129,6 +129,7 @@ export interface MapEngineViewState {
   tileTranslateX: number
   tileTranslateY: number
   tileSizePx: number
+  previewScale: number
   stageWidth: number
   stageHeight: number
   stageLeft: number
@@ -232,6 +233,8 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
   'mapName',
   'configStatusText',
   'zoom',
+  'centerTileX',
+  'centerTileY',
   'rotationDeg',
   'rotationText',
   'rotationMode',
@@ -263,6 +266,11 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
   'networkFetchCount',
   'cacheHitRateText',
   'tileSizePx',
+  'previewScale',
+  'stageWidth',
+  'stageHeight',
+  'stageLeft',
+  'stageTop',
   'statusText',
   'gpsTracking',
   'gpsTrackingText',
@@ -393,12 +401,16 @@ function formatRotationText(rotationDeg: number): string {
   return `${Math.round(normalizeRotationDeg(rotationDeg))}deg`
 }
 
+function normalizeDegreeDisplayText(text: string): string {
+  return text.replace(/[°掳•]/g, '˚')
+}
+
 function formatHeadingText(headingDeg: number | null): string {
   if (headingDeg === null) {
     return '--'
   }
 
-  return `${Math.round(normalizeRotationDeg(headingDeg))}`
+  return `${Math.round(normalizeRotationDeg(headingDeg))}˚`
 }
 
 function formatDevicePoseText(pose: 'upright' | 'tilted' | 'flat'): string {
@@ -1011,6 +1023,7 @@ export class MapEngine {
       tileTranslateX: 0,
       tileTranslateY: 0,
       tileSizePx: 0,
+      previewScale: 1,
       stageWidth: 0,
       stageHeight: 0,
       stageLeft: 0,
@@ -2114,7 +2127,7 @@ export class MapEngine {
 
   applyRemoteMapConfig(config: RemoteMapConfig): void {
     MAGNETIC_DECLINATION_DEG = config.magneticDeclinationDeg
-    MAGNETIC_DECLINATION_TEXT = config.magneticDeclinationText
+    MAGNETIC_DECLINATION_TEXT = normalizeDegreeDisplayText(config.magneticDeclinationText)
     this.minZoom = config.minZoom
     this.maxZoom = config.maxZoom
     this.defaultZoom = config.defaultZoom
@@ -3168,6 +3181,9 @@ export class MapEngine {
     this.previewScale = scale
     this.previewOriginX = originX
     this.previewOriginY = originY
+    this.setState({
+      previewScale: scale,
+    }, true)
   }
 
   resetPreviewState(): void {

+ 291 - 3
miniprogram/pages/map/map.ts

@@ -18,11 +18,24 @@ type CompassLabelData = {
   radius: number
   className: string
 }
+type ScaleRulerMinorTickData = {
+  key: string
+  topPx: number
+  long: boolean
+}
+type ScaleRulerMajorMarkData = {
+  key: string
+  topPx: number
+  label: string
+}
 type SideButtonMode = 'all' | 'left' | 'right' | 'hidden'
 type SideActionButtonState = 'muted' | 'default' | 'active'
+type CenterScaleRulerAnchorMode = 'screen-center' | 'compass-center'
 type MapPageData = MapEngineViewState & {
   showDebugPanel: boolean
   showGameInfoPanel: boolean
+  showCenterScaleRuler: boolean
+  centerScaleRulerAnchorMode: CenterScaleRulerAnchorMode
   statusBarHeight: number
   topInsetHeight: number
   hudPanelIndex: number
@@ -45,12 +58,23 @@ type MapPageData = MapEngineViewState & {
   sideButton2Class: string
   sideButton4Class: string
   sideButton11Class: string
+  sideButton13Class: string
+  sideButton14Class: string
   sideButton16Class: string
+  centerScaleRulerVisible: boolean
+  centerScaleRulerCenterXPx: number
+  centerScaleRulerZeroYPx: number
+  centerScaleRulerHeightPx: number
+  centerScaleRulerAxisBottomPx: number
+  centerScaleRulerZeroVisible: boolean
+  centerScaleRulerZeroLabel: string
+  centerScaleRulerMinorTicks: ScaleRulerMinorTickData[]
+  centerScaleRulerMajorMarks: ScaleRulerMajorMarkData[]
   showLeftButtonGroup: boolean
   showRightButtonGroups: boolean
   showBottomDebugButton: boolean
 }
-const INTERNAL_BUILD_VERSION = 'map-build-232'
+const INTERNAL_BUILD_VERSION = 'map-build-252'
 const CLASSIC_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json'
 const SCORE_O_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json'
 let mapEngine: MapEngine | null = null
@@ -132,7 +156,7 @@ function getSideActionButtonClass(state: SideActionButtonState): string {
   return 'map-side-button map-side-button--default'
 }
 
-function buildSideButtonState(data: Pick<MapPageData, 'sideButtonMode' | 'showGameInfoPanel' | 'skipButtonEnabled' | 'gameSessionStatus' | 'gpsLockEnabled' | 'gpsLockAvailable'>) {
+function buildSideButtonState(data: Pick<MapPageData, 'sideButtonMode' | 'showGameInfoPanel' | 'showCenterScaleRuler' | 'centerScaleRulerAnchorMode' | 'skipButtonEnabled' | 'gameSessionStatus' | 'gpsLockEnabled' | 'gpsLockAvailable'>) {
   const sideButton2State: SideActionButtonState = !data.gpsLockAvailable
     ? 'muted'
     : data.gpsLockEnabled
@@ -140,6 +164,12 @@ function buildSideButtonState(data: Pick<MapPageData, 'sideButtonMode' | 'showGa
       : 'default'
   const sideButton4State: SideActionButtonState = data.gameSessionStatus === 'idle' ? 'default' : 'active'
   const sideButton11State: SideActionButtonState = data.showGameInfoPanel ? 'active' : 'default'
+  const sideButton13State: SideActionButtonState = data.showCenterScaleRuler ? 'active' : 'default'
+  const sideButton14State: SideActionButtonState = !data.showCenterScaleRuler
+    ? 'muted'
+    : data.centerScaleRulerAnchorMode === 'compass-center'
+      ? 'active'
+      : 'default'
   const sideButton16State: SideActionButtonState = data.skipButtonEnabled ? 'default' : 'muted'
 
   return {
@@ -147,10 +177,182 @@ function buildSideButtonState(data: Pick<MapPageData, 'sideButtonMode' | 'showGa
     sideButton2Class: getSideActionButtonClass(sideButton2State),
     sideButton4Class: getSideActionButtonClass(sideButton4State),
     sideButton11Class: getSideActionButtonClass(sideButton11State),
+    sideButton13Class: getSideActionButtonClass(sideButton13State),
+    sideButton14Class: getSideActionButtonClass(sideButton14State),
     sideButton16Class: getSideActionButtonClass(sideButton16State),
   }
 }
 
+function getRpxUnitInPx(): number {
+  const systemInfo = wx.getSystemInfoSync()
+  return systemInfo.windowWidth / 750
+}
+
+function worldTileYToLat(worldTileY: number, zoom: number): number {
+  const scale = Math.pow(2, zoom)
+  const n = Math.PI - (2 * Math.PI * worldTileY) / scale
+  return (180 / Math.PI) * Math.atan(Math.sinh(n))
+}
+
+function getNiceDistanceMeters(rawDistanceMeters: number): number {
+  if (!Number.isFinite(rawDistanceMeters) || rawDistanceMeters <= 0) {
+    return 50
+  }
+
+  const exponent = Math.floor(Math.log10(rawDistanceMeters))
+  const base = Math.pow(10, exponent)
+  const normalized = rawDistanceMeters / base
+
+  if (normalized <= 1) {
+    return base
+  }
+  if (normalized <= 2) {
+    return 2 * base
+  }
+  if (normalized <= 5) {
+    return 5 * base
+  }
+  return 10 * base
+}
+
+function formatScaleDistanceLabel(distanceMeters: number): string {
+  if (distanceMeters >= 1000) {
+    const distanceKm = distanceMeters / 1000
+    const formatted = distanceKm >= 10 ? distanceKm.toFixed(0) : distanceKm.toFixed(1)
+    return `${formatted.replace(/\.0$/, '')} km`
+  }
+
+  return `${Math.round(distanceMeters)} m`
+}
+
+function buildCenterScaleRulerPatch(data: Pick<MapPageData, 'showCenterScaleRuler' | 'centerScaleRulerAnchorMode' | 'stageWidth' | 'stageHeight' | 'topInsetHeight' | 'zoom' | 'centerTileY' | 'tileSizePx' | 'previewScale'>) {
+  if (!data.showCenterScaleRuler) {
+    return {
+      centerScaleRulerVisible: false,
+      centerScaleRulerCenterXPx: 0,
+      centerScaleRulerZeroYPx: 0,
+      centerScaleRulerHeightPx: 0,
+      centerScaleRulerAxisBottomPx: 0,
+      centerScaleRulerZeroVisible: false,
+      centerScaleRulerZeroLabel: '0 m',
+      centerScaleRulerMinorTicks: [] as ScaleRulerMinorTickData[],
+      centerScaleRulerMajorMarks: [] as ScaleRulerMajorMarkData[],
+    }
+  }
+
+  if (!data.stageWidth || !data.stageHeight) {
+    return {
+      centerScaleRulerVisible: false,
+      centerScaleRulerCenterXPx: 0,
+      centerScaleRulerZeroYPx: 0,
+      centerScaleRulerHeightPx: 0,
+      centerScaleRulerAxisBottomPx: 0,
+      centerScaleRulerZeroVisible: false,
+      centerScaleRulerZeroLabel: '0 m',
+      centerScaleRulerMinorTicks: [] as ScaleRulerMinorTickData[],
+      centerScaleRulerMajorMarks: [] as ScaleRulerMajorMarkData[],
+    }
+  }
+
+  const topPadding = 12
+  const rpxUnitPx = getRpxUnitInPx()
+  const compassBottomPaddingPx = 248 * rpxUnitPx
+  const compassDialRadiusPx = (196 * rpxUnitPx) / 2
+  const compassHeadingOverlayHeightPx = 40 * rpxUnitPx
+  const compassOcclusionPaddingPx = 10 * rpxUnitPx
+  const zeroYPx = data.centerScaleRulerAnchorMode === 'compass-center'
+    ? Math.round(data.stageHeight - compassBottomPaddingPx - compassDialRadiusPx)
+    : Math.round(data.stageHeight / 2)
+  const fallbackHeight = Math.max(zeroYPx - topPadding, 160)
+  const coveredBottomPx = data.centerScaleRulerAnchorMode === 'compass-center'
+    ? Math.round(compassDialRadiusPx + compassHeadingOverlayHeightPx + compassOcclusionPaddingPx)
+    : 0
+
+  if (
+    !data.tileSizePx
+    || !Number.isFinite(data.zoom)
+    || !Number.isFinite(data.centerTileY)
+  ) {
+    return {
+      centerScaleRulerVisible: true,
+      centerScaleRulerCenterXPx: Math.round(data.stageWidth / 2),
+      centerScaleRulerZeroYPx: zeroYPx,
+      centerScaleRulerHeightPx: fallbackHeight,
+      centerScaleRulerAxisBottomPx: coveredBottomPx,
+      centerScaleRulerZeroVisible: data.centerScaleRulerAnchorMode !== 'compass-center',
+      centerScaleRulerZeroLabel: '0 m',
+      centerScaleRulerMinorTicks: [] as ScaleRulerMinorTickData[],
+      centerScaleRulerMajorMarks: [] as ScaleRulerMajorMarkData[],
+    }
+  }
+
+  const centerLat = worldTileYToLat(data.centerTileY + 0.5, data.zoom)
+  const metersPerTile = Math.cos(centerLat * Math.PI / 180) * 40075016.686 / Math.pow(2, data.zoom)
+  const metersPerPixel = metersPerTile / data.tileSizePx
+  const effectivePreviewScale = Number.isFinite(data.previewScale) && data.previewScale > 0 ? data.previewScale : 1
+  const effectiveMetersPerPixel = metersPerPixel / effectivePreviewScale
+  const rulerHeight = Math.floor(zeroYPx - topPadding)
+
+  if (!Number.isFinite(effectiveMetersPerPixel) || effectiveMetersPerPixel <= 0 || rulerHeight < 120) {
+    return {
+      centerScaleRulerVisible: true,
+      centerScaleRulerCenterXPx: Math.round(data.stageWidth / 2),
+      centerScaleRulerZeroYPx: zeroYPx,
+      centerScaleRulerHeightPx: Math.max(rulerHeight, fallbackHeight),
+      centerScaleRulerAxisBottomPx: coveredBottomPx,
+      centerScaleRulerZeroVisible: data.centerScaleRulerAnchorMode !== 'compass-center',
+      centerScaleRulerZeroLabel: '0 m',
+      centerScaleRulerMinorTicks: [] as ScaleRulerMinorTickData[],
+      centerScaleRulerMajorMarks: [] as ScaleRulerMajorMarkData[],
+    }
+  }
+
+  const labelDistanceMeters = getNiceDistanceMeters(effectiveMetersPerPixel * 80)
+  const minorDistanceMeters = labelDistanceMeters / 8
+  const minorStepPx = minorDistanceMeters / effectiveMetersPerPixel
+  const visibleTopLimitPx = rulerHeight - coveredBottomPx
+  const minorTicks: ScaleRulerMinorTickData[] = []
+  const majorMarks: ScaleRulerMajorMarkData[] = []
+
+  for (let index = 1; index <= 200; index += 1) {
+    const topPx = Math.round(rulerHeight - index * minorStepPx)
+    if (topPx < 0) {
+      break
+    }
+    if (topPx >= visibleTopLimitPx) {
+      continue
+    }
+
+    const isHalfMajor = index % 4 === 0
+    const isLabelMajor = index % 8 === 0
+    minorTicks.push({
+      key: `minor-${index}`,
+      topPx,
+      long: isHalfMajor,
+    })
+
+    if (isLabelMajor) {
+      majorMarks.push({
+        key: `major-${index}`,
+        topPx,
+        label: formatScaleDistanceLabel((index / 8) * labelDistanceMeters),
+      })
+    }
+  }
+
+  return {
+    centerScaleRulerVisible: true,
+    centerScaleRulerCenterXPx: Math.round(data.stageWidth / 2),
+    centerScaleRulerZeroYPx: zeroYPx,
+    centerScaleRulerHeightPx: rulerHeight,
+    centerScaleRulerAxisBottomPx: coveredBottomPx,
+    centerScaleRulerZeroVisible: data.centerScaleRulerAnchorMode !== 'compass-center',
+    centerScaleRulerZeroLabel: '0 m',
+    centerScaleRulerMinorTicks: minorTicks,
+    centerScaleRulerMajorMarks: majorMarks,
+  }
+}
+
 function buildEmptyGameInfoSnapshot(): MapEngineGameInfoSnapshot {
   return {
     title: '当前游戏',
@@ -170,10 +372,12 @@ Page({
   data: {
     showDebugPanel: false,
     showGameInfoPanel: false,
+    showCenterScaleRuler: false,
     statusBarHeight: 0,
     topInsetHeight: 12,
     hudPanelIndex: 0,
     configSourceText: '顺序赛配置',
+    centerScaleRulerAnchorMode: 'screen-center',
     gameInfoTitle: '当前游戏',
     gameInfoSubtitle: '未开始',
     gameInfoLocalRows: [],
@@ -246,12 +450,23 @@ Page({
     mapPulseFxClass: '',
     stageFxVisible: false,
     stageFxClass: '',
+    centerScaleRulerVisible: false,
+    centerScaleRulerCenterXPx: 0,
+    centerScaleRulerZeroYPx: 0,
+    centerScaleRulerHeightPx: 0,
+    centerScaleRulerAxisBottomPx: 0,
+    centerScaleRulerZeroVisible: false,
+    centerScaleRulerZeroLabel: '0 m',
+    centerScaleRulerMinorTicks: [],
+    centerScaleRulerMajorMarks: [],
     compassTicks: buildCompassTicks(),
     compassLabels: buildCompassLabels(),
     ...buildSideButtonVisibility('left'),
     ...buildSideButtonState({
       sideButtonMode: 'left',
       showGameInfoPanel: false,
+      showCenterScaleRuler: false,
+      centerScaleRulerAnchorMode: 'screen-center',
       skipButtonEnabled: false,
       gameSessionStatus: 'idle',
       gpsLockEnabled: false,
@@ -298,6 +513,7 @@ Page({
 
         this.setData({
           ...nextData,
+          ...buildCenterScaleRulerPatch(mergedData),
           ...buildSideButtonState(mergedData),
         })
 
@@ -391,11 +607,24 @@ Page({
       ...buildSideButtonState({
         sideButtonMode: 'left',
         showGameInfoPanel: false,
+        showCenterScaleRuler: false,
+        centerScaleRulerAnchorMode: 'screen-center',
         skipButtonEnabled: false,
         gameSessionStatus: 'idle',
         gpsLockEnabled: false,
         gpsLockAvailable: false,
       }),
+      ...buildCenterScaleRulerPatch({
+        ...(mapEngine.getInitialData() as MapPageData),
+        showCenterScaleRuler: false,
+        centerScaleRulerAnchorMode: 'screen-center',
+        stageWidth: 0,
+        stageHeight: 0,
+        topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20),
+        zoom: 0,
+        centerTileY: 0,
+        tileSizePx: 0,
+      }),
     })
   },
 
@@ -807,10 +1036,19 @@ Page({
     }
 
     const snapshot = mapEngine.getGameInfoSnapshot()
+    const localRows = snapshot.localRows.concat([
+      { label: '比例尺开关', value: this.data.showCenterScaleRuler ? '开启' : '关闭' },
+      { label: '比例尺锚点', value: this.data.centerScaleRulerAnchorMode === 'compass-center' ? '指北针圆心' : '屏幕中心' },
+      { label: '比例尺可见', value: this.data.centerScaleRulerVisible ? 'true' : 'false' },
+      { label: '比例尺中心X', value: `${this.data.centerScaleRulerCenterXPx}px` },
+      { label: '比例尺零点Y', value: `${this.data.centerScaleRulerZeroYPx}px` },
+      { label: '比例尺高度', value: `${this.data.centerScaleRulerHeightPx}px` },
+      { label: '比例尺主刻度数', value: String(this.data.centerScaleRulerMajorMarks.length) },
+    ])
     this.setData({
       gameInfoTitle: snapshot.title,
       gameInfoSubtitle: snapshot.subtitle,
-      gameInfoLocalRows: snapshot.localRows,
+      gameInfoLocalRows: localRows,
       gameInfoGlobalRows: snapshot.globalRows,
     })
   },
@@ -823,6 +1061,8 @@ Page({
       ...buildSideButtonState({
         sideButtonMode: this.data.sideButtonMode,
         showGameInfoPanel: true,
+        showCenterScaleRuler: this.data.showCenterScaleRuler,
+        centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
         skipButtonEnabled: this.data.skipButtonEnabled,
         gameSessionStatus: this.data.gameSessionStatus,
         gpsLockEnabled: this.data.gpsLockEnabled,
@@ -837,6 +1077,8 @@ Page({
       ...buildSideButtonState({
         sideButtonMode: this.data.sideButtonMode,
         showGameInfoPanel: false,
+        showCenterScaleRuler: this.data.showCenterScaleRuler,
+        centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
         skipButtonEnabled: this.data.skipButtonEnabled,
         gameSessionStatus: this.data.gameSessionStatus,
         gpsLockEnabled: this.data.gpsLockEnabled,
@@ -878,6 +1120,8 @@ Page({
       ...buildSideButtonState({
         sideButtonMode: nextMode,
         showGameInfoPanel: this.data.showGameInfoPanel,
+        showCenterScaleRuler: this.data.showCenterScaleRuler,
+        centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
         skipButtonEnabled: this.data.skipButtonEnabled,
         gameSessionStatus: this.data.gameSessionStatus,
         gpsLockEnabled: this.data.gpsLockEnabled,
@@ -909,6 +1153,8 @@ Page({
       ...buildSideButtonState({
         sideButtonMode: this.data.sideButtonMode,
         showGameInfoPanel: false,
+        showCenterScaleRuler: this.data.showCenterScaleRuler,
+        centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
         skipButtonEnabled: this.data.skipButtonEnabled,
         gameSessionStatus: this.data.gameSessionStatus,
         gpsLockEnabled: this.data.gpsLockEnabled,
@@ -923,6 +1169,8 @@ Page({
       ...buildSideButtonState({
         sideButtonMode: this.data.sideButtonMode,
         showGameInfoPanel: this.data.showGameInfoPanel,
+        showCenterScaleRuler: this.data.showCenterScaleRuler,
+        centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode,
         skipButtonEnabled: this.data.skipButtonEnabled,
         gameSessionStatus: this.data.gameSessionStatus,
         gpsLockEnabled: this.data.gpsLockEnabled,
@@ -931,6 +1179,46 @@ Page({
     })
   },
 
+  handleToggleCenterScaleRuler() {
+    const nextEnabled = !this.data.showCenterScaleRuler
+    this.data.showCenterScaleRuler = nextEnabled
+    const mergedData = {
+      ...this.data,
+      showCenterScaleRuler: nextEnabled,
+    } as MapPageData
+
+    this.setData({
+      showCenterScaleRuler: nextEnabled,
+      ...buildCenterScaleRulerPatch(mergedData),
+      ...buildSideButtonState(mergedData),
+    })
+  },
+
+  handleToggleCenterScaleRulerAnchor() {
+    if (!this.data.showCenterScaleRuler) {
+      return
+    }
+
+    const nextAnchorMode: CenterScaleRulerAnchorMode = this.data.centerScaleRulerAnchorMode === 'screen-center'
+      ? 'compass-center'
+      : 'screen-center'
+    this.data.centerScaleRulerAnchorMode = nextAnchorMode
+    const mergedData = {
+      ...this.data,
+      centerScaleRulerAnchorMode: nextAnchorMode,
+    } as MapPageData
+
+    this.setData({
+      centerScaleRulerAnchorMode: nextAnchorMode,
+      ...buildCenterScaleRulerPatch(mergedData),
+      ...buildSideButtonState(mergedData),
+    })
+
+    if (this.data.showGameInfoPanel) {
+      this.syncGameInfoPanelSnapshot()
+    }
+  },
+
   handleDebugPanelTap() {},
 })
 

+ 18 - 4
miniprogram/pages/map/map.wxml

@@ -37,11 +37,25 @@
     </view>
 
 
+    <view class="map-stage__overlay-center-layer" wx:if="{{!showDebugPanel && !showGameInfoPanel}}">
+      <view class="center-scale-ruler" wx:if="{{centerScaleRulerVisible}}" style="left: {{centerScaleRulerCenterXPx}}px; top: {{centerScaleRulerZeroYPx}}px; height: {{centerScaleRulerHeightPx}}px;">
+        <view class="center-scale-ruler__axis" style="bottom: {{centerScaleRulerAxisBottomPx}}px;"></view>
+        <view class="center-scale-ruler__arrow"></view>
+        <view wx:if="{{centerScaleRulerZeroVisible}}" class="center-scale-ruler__tick center-scale-ruler__tick--major center-scale-ruler__tick--zero" style="top: {{centerScaleRulerHeightPx}}px;"></view>
+        <view wx:if="{{centerScaleRulerZeroVisible}}" class="center-scale-ruler__label center-scale-ruler__label--zero" style="top: {{centerScaleRulerHeightPx}}px;">{{centerScaleRulerZeroLabel}}</view>
+        <view wx:for="{{centerScaleRulerMinorTicks}}" wx:key="key" class="center-scale-ruler__tick {{item.long ? 'center-scale-ruler__tick--major' : ''}}" style="top: {{item.topPx}}px;"></view>
+        <view wx:for="{{centerScaleRulerMajorMarks}}" wx:key="key" class="center-scale-ruler__label" style="top: {{item.topPx}}px;">{{item.label}}</view>
+      </view>
+    </view>
     <view class="map-stage__overlay">
       <view class="map-stage__bottom">
         <view class="compass-widget">
-          <view class="compass-widget__heading">{{sensorHeadingText}}</view>
-          <view class="compass-widget__edge-arrow"></view>
+          <view class="compass-widget__heading-wrap">
+            <view class="compass-widget__heading">{{sensorHeadingText}}</view>
+            <view class="compass-widget__edge-arrow-wrap">
+              <view class="compass-widget__edge-arrow"></view>
+            </view>
+          </view>
           <view class="compass-widget__dial {{orientationMode === 'heading-up' ? 'compass-widget__dial--active' : ''}}">
             <view class="compass-widget__glass"></view>
             <view class="compass-widget__inner-shadow"></view>
@@ -93,8 +107,8 @@
   <cover-view class="map-side-column map-side-column--right-sub" wx:if="{{!showDebugPanel && !showGameInfoPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;">
     <cover-view class="{{sideButton11Class}}" bindtap="handleOpenGameInfoPanel"><cover-image class="map-side-button__action-image" src="../../assets/btn_info.png"></cover-image></cover-view>
     <cover-view class="map-side-button"><cover-view class="map-side-button__text">12</cover-view></cover-view>
-    <cover-view class="map-side-button"><cover-view class="map-side-button__text">13</cover-view></cover-view>
-    <cover-view class="map-side-button"><cover-view class="map-side-button__text">14</cover-view></cover-view>
+    <cover-view class="{{sideButton13Class}}" bindtap="handleToggleCenterScaleRuler"><cover-view class="map-side-button__text">13</cover-view></cover-view>
+    <cover-view class="{{sideButton14Class}}" bindtap="handleToggleCenterScaleRulerAnchor"><cover-view class="map-side-button__text">14</cover-view></cover-view>
     <cover-view class="map-side-button"><cover-view class="map-side-button__text">15</cover-view></cover-view>
     <cover-view class="{{sideButton16Class}}" bindtap="handleSkipAction"><cover-image class="map-side-button__action-image" src="../../assets/btn_skip_cp.png"></cover-image></cover-view>
   </cover-view>

+ 110 - 4
miniprogram/pages/map/map.wxss

@@ -97,6 +97,86 @@
   z-index: 4;
 }
 
+.map-stage__overlay-center-layer {
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+  z-index: 4;
+}
+
+.center-scale-ruler {
+  position: absolute;
+  width: 220rpx;
+  transform: translate(-50%, -100%);
+  pointer-events: none;
+  z-index: 12;
+}
+
+.center-scale-ruler__axis {
+  position: absolute;
+  left: 50%;
+  top: 0;
+  bottom: 0;
+  width: 4rpx;
+  margin-left: -2rpx;
+  border-radius: 999rpx;
+  background: rgba(19, 20, 18, 0.92);
+}
+
+.center-scale-ruler__arrow {
+  position: absolute;
+  left: 50%;
+  top: -34rpx;
+  width: 0;
+  height: 0;
+  margin-left: -14rpx;
+  border-left: 14rpx solid transparent;
+  border-right: 14rpx solid transparent;
+  border-bottom: 34rpx solid rgba(19, 20, 18, 0.96);
+}
+
+.center-scale-ruler__tick {
+  position: absolute;
+  left: 50%;
+  width: 18rpx;
+  height: 4rpx;
+  margin-left: -9rpx;
+  margin-top: -2rpx;
+  border-radius: 999rpx;
+  background: rgba(19, 20, 18, 0.82);
+}
+
+.center-scale-ruler__tick--major {
+  width: 32rpx;
+  margin-left: -16rpx;
+  height: 5rpx;
+  margin-top: -2.5rpx;
+  background: rgba(12, 14, 12, 0.96);
+}
+
+.center-scale-ruler__tick--zero {
+  width: 34rpx;
+  margin-left: -17rpx;
+}
+
+.center-scale-ruler__label {
+  position: absolute;
+  left: 50%;
+  margin-left: 18rpx;
+  transform: translateY(-50%);
+  font-size: 22rpx;
+  line-height: 1;
+  font-weight: 800;
+  color: rgba(19, 20, 18, 0.96);
+  text-shadow: 0 1rpx 0 rgba(255, 255, 255, 0.28);
+  white-space: nowrap;
+}
+
+.center-scale-ruler__label--zero {
+  transform: translateY(-24%);
+  font-size: 24rpx;
+}
+
 .map-stage__topbar {
   display: flex;
   align-items: flex-start;
@@ -329,14 +409,38 @@
   align-items: center;
   gap: 6rpx;
   flex-shrink: 0;
+  position: relative;
+  z-index: 20;
+}
+
+.compass-widget__heading-wrap {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 0;
+  position: relative;
+  z-index: 21;
 }
 
 .compass-widget__heading {
-  font-size: 14rpx;
+  min-width: 96rpx;
+  padding: 6rpx 12rpx;
+  font-size: 20rpx;
   line-height: 1;
   font-weight: 600;
   color: rgba(32, 42, 34, 0.72);
   text-shadow: 0 1rpx 0 rgba(255, 255, 255, 0.35);
+  text-align: center;
+}
+
+.compass-widget__edge-arrow-wrap {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 40rpx;
+  height: 20rpx;
+  margin-top: -2rpx;
+  margin-bottom: -4rpx;
 }
 
 .compass-widget__dial {
@@ -519,8 +623,6 @@
 .compass-widget__edge-arrow {
   width: 0;
   height: 0;
-  margin-top: -2rpx;
-  margin-bottom: -4rpx;
   border-left: 8rpx solid transparent;
   border-right: 8rpx solid transparent;
   border-top: 14rpx solid rgba(58, 49, 37, 0.72);
@@ -530,12 +632,16 @@
 
 .compass-widget__hint {
   max-width: 196rpx;
-  font-size: 14rpx;
+  padding: 4rpx 10rpx;
+  border-radius: 999rpx;
+  background: rgba(219, 238, 212, 0.94);
+  font-size: 16rpx;
   line-height: 1.3;
   color: #d62828;
   text-align: center;
   font-weight: 700;
   text-shadow: 0 1rpx 0 rgba(255, 255, 255, 0.24);
+  box-shadow: 0 2rpx 8rpx rgba(22, 48, 32, 0.08);
 }
 .race-panel-swiper {
   position: absolute;