独立开发实录:把「占领地图」做成真实可玩的步行App,我踩了哪些坑

5 阅读5分钟

上线一个月,早期用户量,零差评,但我返工了不止三次。这篇想聊几个让我反复改的技术决策,不是成功学,就是坑的记录。

格子怎么算「被占领了」

整个App的地图逻辑说白了就一个问题:用户走过某个地方,对应的像素格要不要点亮,点多少算「征服」?

地图被切成固定大小的 TileCoordinate 格子,每个格子有独立的访问记录。最开始是「走过即点亮」,但体验太平——用户感知不到「征服」和「路过」的区别,地图就是一堆亮点,没有成就感。

后来定了一套 ZoneConquestRules,把一个 Zone 分成 8×8 的格子网格,点亮 58 个才算「征服」,全部 64 个才算「完美征服」:

enum ZoneConquestRules {
    static func evaluate(
        litTiles: Int,
        conqueredThreshold: Int,
        perfectThreshold: Int
    ) -> ZoneConquestEvaluation {
        let normalizedLitTiles = max(0, litTiles)
        let normalizedConqueredThreshold = max(0, conqueredThreshold)
        let normalizedPerfectThreshold = max(normalizedConqueredThreshold, perfectThreshold)
        return ZoneConquestEvaluation(
            isConquered: normalizedLitTiles >= normalizedConqueredThreshold,
            isPerfect: normalizedLitTiles >= normalizedPerfectThreshold
        )
    }
}

58/64 这个阈值是我拍脑袋定的,然后自己走了一周来验证。城市街道密集的地方基本能到,但公园、操场这种大块空旷地会卡死——你在操场外圈走一圈,永远点不亮中间那片草地。针对这类地形,我把 conqueredThreshold 降到了 48/64,让「征服」在空旷地形里更容易触达,否则公园党直接弃坑。

连击系统:让人「舍不得断掉」

游戏化的关键不是奖励丰厚,而是让人有损失感。RouteScoreRules 里连续走 3-4 天倍率 1.5x,连续 5 天以上 2.0x。早期版本断签一天直接清零,陆续有几个用户出差回来发现连击没了,留言说「算了」,后来就没再打开过。

加了 graceDays = 1 之后,没有精确统计留存数字,但那周断签相关的投诉直接消失了——原来每隔几天必然有人来评论区说「连击清零了太挫败」,加完容错之后这类留言没再出现过:

static func multiplier(for consecutiveDays: Int) -> Double {
    switch max(0, consecutiveDays) {
    case 5...:
        return 2.0
    case 3...4:
        return 1.5
    default:
        return 1.0
    }
}

每日任务分四种类型:解锁格子数(unlock)、行走距离(distance)、活跃时长(duration)、连击维持(momentum)。对应不同行为习惯,总有一个任务是今天顺手能完成的,不会让人觉得「我今天完全没进展」。

热力图衰减:视觉上「告诉」用户该去哪

格子不只有亮和不亮两种状态,热力图有时间衰减。MapHeatRules 定义了几个节点:4天路线光晕开始软衰减,7天明显衰减,14天区域强调效果衰减,30天降为残留透明度 0.12。

这个节奏是试出来的。最开始把衰减设成 3 天开始掉,自己连续用两周,第 10 天打开地图,超过一半的区域已经暗下去了,整张图灰扑扑的——不是「哇这里我没去过」的探索感,而是「怎么到处都消失了」的挫败感。调到 4 天之后,「最近走过」和「很久没去」的对比依然清晰,但不会让人觉得一直在退步。

副作用挺好的:用户打开地图,一眼就能看到哪些区域在变暗,天然产生「我得去那边补一下」的动机,不需要任何推送或提示文字。

历史轨迹导入:一个我还没解决的设计分歧

有用户评论说,历史照片只能导入最近 3 年的位置,希望去掉年份限制。这个限制是我主动加的——如果用户 Photos 里有几万张跨越十年的照片,全量扫描位置信息会让首次导入卡很久,3 年是个粗暴的默认值。

但用户的需求是真实的:很多人十年前去了一次西藏、一次京都,那些照片的经纬度是他们最想点亮的地方,3 年窗口直接把这部分挡死了。

计划是保持 3 年默认,加一个手动选择年份范围的入口。但具体怎么放,我现在卡在两个方案之间:放在导入流程里,会打断新用户的首次体验节奏,很多人可能还没搞清楚这个功能是干什么的就被追问「要导几年」;单独放进设置页,又怕有需求的用户根本找不到这个入口,变成一个藏起来的功能。这个分歧还没想清楚,如果你做过类似的分步导入交互,有想法欢迎在评论里说。

定位兜底的一个小细节

第一次启动、没有 GPS 信号也没有历史数据时,地图要落在哪里?我用 Locale.current.region 做了国家/地区的默认坐标映射:CN 落上海陆家嘴,JP 落东京站,US 落旧金山,TW 落台北 101……至少让用户看到一个有城市的地方,而不是非洲附近的 Null Island(0,0 坐标)。首次启动这个时机用户容忍度很低,这是个很小但必须做的防御性设计。


有个问题想问做过地图类 App 的朋友:你的格子大小最后定的多少米、怎么测出来的?我现在的值是自己走出来的经验值,很想知道别人是怎么定这个参数的。