海龟汤游戏 App 开发实录:用 HarmonyOS ArkTS 构建推理互动平台
—目录海龟汤是什么项目背景与设计目标产品需求分析技术架构概览数据层15 个谜题的建模实践UI 架构从列表到游戏的页面流转游戏交互设计主持人与玩家的角色分工Builder 的语法陷阱与避坑指南状态管理策略视觉设计暗黑哥特风格编译构建与部署踩坑全记录优化方向与后续迭代总结一、海龟汤是什么海龟汤英文原名是 Lateral Thinking Puzzle水平思考谜题是一种在东亚地区广泛流行的推理游戏。它的核心规则极其简单主持人知道整个故事的来龙去脉汤底玩家只知道故事的表面现象汤面互动方式玩家问「是 / 否」类问题主持人作答目标玩家通过问题拼凑出完整的真相这个游戏的名字来源于一个经典谜题一个人去餐馆吃了一道叫海龟汤的菜吃完后问服务员这真的是海龟汤吗“服务员回答不是”。然后这个人就自杀了——为什么这个谜题的答案出人意料又令人唏嘘也奠定了这类游戏的基调表面平静暗流汹涌。海龟汤的魅力在于门槛极低不需要任何道具一张嘴就能玩脑洞大开答案往往颠覆常识需要打破思维定式社交属性强一群人围在一起七嘴八舌地提问气氛热烈短小精悍一个谜题玩 5-15 分钟适合聚会的碎片时间作为一个开发者我意识到这个游戏天然适合做成 App——主持人需要偷偷看答案、控制提示的节奏、记录玩家问了多少问题这些都可以用数字化方式做得比纸牌更好。二、项目背景与设计目标2.1 为什么要做这个 App市面上虽然有一些海龟汤 App但存在几个普遍的痛点广告太多看答案前先看 30 秒广告体验割裂谜题质量参差不齐很多谜题的答案是强行反转逻辑不严谨缺少主持模式大部分 App 只提供单人推理AI 作为主持人但海龟汤的真正乐趣在于一群人一起玩UI 过于复杂动画花哨、按钮分散实际游戏时反而不方便操作基于这些痛点我设定了以下设计目标目标优先级说明零广告P0纯粹的游戏体验不插入任何商业广告优质谜题P0收录经过验证的经典谜题每个都经过逻辑审查主持人优先P0App 的第一角色是「主持人的工具」其次是「玩家的题库」极简操作P1游戏过程中所有操作不超过两次点击本地存储P1所有数据本地存储无需联网2.2 目标用户画像这个 App 的核心用户是“聚会上负责活跃气氛的那个人。”他们通常在朋友聚会、家庭聚餐、公司团建等场景中担任气氛组。他们需要的是快速找到高质量的谜题不需要自己现编偷偷看答案不被发现UI 设计要保证主持人的隐私方便地记录已回答的问题数防止玩家抵赖在适当时机给出提示控制游戏节奏三、产品需求分析3.1 用户故事作为用户我希望能作为主持人我能在 10 秒内选好一个谜题并开始游戏。 作为主持人我能安全地看到答案而不被玩家发现。 作为主持人我能在玩家提问时快速给出是/否/无关的回答。 作为主持人我能在适当时机给出提示。 作为主持人我能记录玩家问了多少问题。 作为玩家我能看到清晰的谜面从中寻找线索。 作为玩家我能在猜出答案后立即看到完整的真相。3.2 页面流转设计整个 App 只有两个主要的视图状态首页谜题列表 │ ├── 点击「开始游戏」──→ 游戏页未看答案 │ ├── 偷看汤底 → 确认弹窗 → 显示汤底 │ ├── 揭晓提示 → 逐条增加 │ ├── 回答问题 → 计数器 1 │ └── 猜中 → 自动展示答案 │ └── 点击「浏览」─────→ 游戏页直接显示答案预览模式 │ 任意页面 ───→ 点击返回 → 首页3.3 状态定义每个游戏会话需要维护以下状态currentSoup:TurtleSoup|null// 当前谜题showAnswer:boolean// 是否已看/展示汤底currentHintIndex:number// 当前已揭晓的提示索引-1未揭晓questionCount:number// 玩家已问问题数showAnswerWarning:boolean// 是否显示确认偷看弹窗isSolved:boolean// 玩家是否已猜中四、技术架构概览4.1 整体架构App 采用单页面 状态驱动的架构模式┌──────────────────────────────────────┐ │ Index.ets │ │ ┌────────────────────────────────┐ │ │ │ State currentPage: list │ │ │ │ │ game │ │ │ │ ▼ │ │ │ │ ┌─────────┐ ┌──────────────┐ │ │ │ │ │ 列表页 │ │ 游戏页 │ │ │ │ │ │ - 卡片列表│ │ - 汤面 │ │ │ │ │ │ - 难度标签│ │ - 主持区 │ │ │ │ │ │ - 预览 │ │ - 底部按钮 │ │ │ │ │ └─────────┘ └──────────────┘ │ │ │ └────────────────────────────────┘ │ │ ↑ │ │ ┌────────┴────────┐ │ │ │ TurtleSoupData │ │ │ │ (15 个谜题) │ │ │ └─────────────────┘ │ └──────────────────────────────────────┘4.2 为什么选择单页面而非路由这个 App 只有两个页面——列表和游戏。如果使用 router 路由// router 方案不采用import{router}fromkit.ArkUI;router.pushUrl({url:pages/GamePage});需要额外注册页面、处理参数传递、管理页面栈。对于只有两个页面的 App这完全是不必要的复杂度。使用状态驱动方案StatecurrentPage:stringlist;// 在 build() 中根据状态切换if(this.currentPagelist){this.buildListPage();}else{this.buildGamePage();}优点零额外文件所有代码在一个 Component 中状态共享游戏状态直接是 State 成员变量无需传参无页面栈管理返回就是改个字符串缺点如果页面超过 5 个代码会膨胀到不可维护但 2 个页面完全在可接受范围内五、数据层15 个谜题的建模实践5.1 数据结构设计每个海龟汤谜题包含五个字段exportinterfaceTurtleSoup{id:number;// 唯一编号title:string;// 谜题标题如企鹅肉difficulty:简单|中等|困难|烧脑;// 难度等级surface:string;// 汤面 — 展示给玩家的故事answer:string;// 汤底 — 完整的真相hints:string[];// 逐步提示3 条}5.2 难度分级标准在设计谜题集合时我对每个谜题进行了难度评级难度评判标准示例数量简单答案在 5 个问题内可猜中思路直接电梯里的男人2中等需要 8-12 个问题有点小转折潜水艇、盲人与狗4困难需要 15 个问题答案令人意想不到企鹅肉、音乐家之死5烧脑需要打破固有思维定式答案反直觉电梯游戏、山顶木屋45.3 谜题筛选标准从几十个候选谜题中精选出 15 个遵循以下标准逻辑自洽答案不能有逻辑漏洞所有细节必须能闭环适度反转反转是必须的但不能为了反转而强行不合理文化适配选择中国玩家熟悉的场景和设定多样性涵盖情感、悬疑、科幻、日常等多种类型例如电梯里的男人这个谜题虽然简单但它完美体现了海龟汤的核心趣味——答案出人意料但合情合理。而山顶木屋更复杂涉及到雪地追踪、心理绝望等元素。5.4 数据存储考量当前所有数据硬编码在TurtleSoupData.ets中文件大小约 16KB。对于 15 个谜题来说这是合理的。但如果扩展到 50 个谜题建议迁移到 JSON 资源文件// resources/rawfile/puzzles.json{puzzles:[{id:1,title:企鹅肉,surface:...,answer:...,hints:[...,...,...]}]}然后用getContext().resourceManager.getRawFileContent()读取。这样做的好处是数据与代码分离修改谜题不需要重新编译方便后续添加云端同步功能数据文件可以单独测试和验证六、UI 架构从列表到游戏的页面流转6.1 首页谜题卡片列表首页使用卡片式布局展示所有谜题。每张卡片包含┌─────────────────────────────────────┐ │ [#困难] #1 │ │ │ │ 企鹅肉 │ │ 一个人在南极旅行去餐馆吃饭时点 │ │ 了一道南极烤企鹅肉…… │ │ │ │ [ 开始游戏] [ 浏览] │ └─────────────────────────────────────┘实现要点BuilderbuildSoupCard(soup:TurtleSoup){Column(){// 难度标签带颜色Text(soup.difficulty).fontColor(this.getDiffColor(soup.difficulty)).backgroundColor(this.getDiffBg(soup.difficulty))// 标题Text(soup.title).fontSize(20).fontWeight(FontWeight.Bold)// 预览摘要截取前 60 个字Text(soup.surface.substring(0,60)……).maxLines(2).textOverflow({overflow:TextOverflow.Ellipsis})// 两个操作按钮Button( 开始游戏)// → 进入游戏答案隐藏Button( 浏览)// → 进入游戏直接展示答案}}两个按钮的设计区分了两种使用场景开始游戏用户是主持人需要先偷偷看答案再主持浏览用户是学习者直接看答案和解析6.2 游戏页主持人的控制台游戏页是 App 的核心界面分为三个区域┌─────────────────────────────────────┐ │ ← 返回 #1 企鹅肉 困难 │ ← 顶部导航 ├─────────────────────────────────────┤ │ │ │ 汤面展示给玩家 │ │ ┌─────────────────────────────┐ │ │ │ 一个人在南极旅行…… │ │ │ └─────────────────────────────┘ │ │ │ │ 主持区仅主持人可见 │ │ ┌─────────────────────────────┐ │ │ │ [️ 偷看汤底] [ 提示] │ │ │ └─────────────────────────────┘ │ │ │ │ 问题数3 提示1/3 │ ← 状态栏 │ │ ├─────────────────────────────────────┤ │ [✅是] [❌否] [➖无关] [猜中] │ ← 底部操作栏 └─────────────────────────────────────┘这个布局遵循一个核心设计原则信息层级从上到下递减。顶部最重要的信息——汤面所有玩家和主持人共同关注的焦点中部主持人专属操作区需要一定的隐私保护底部快速操作按钮游戏过程中最常点击的区域七、游戏交互设计主持人与玩家的角色分工7.1 主持人的工作流一个典型的主持流程选谜题在首页浏览卡片选择一个难度合适的谜题偷看汤底进入游戏页点击偷看汤底弹出确认框读汤面将汤面内容读给所有玩家听或传阅手机回答问题玩家提问主持人点击是/否/无关给予提示玩家卡壳时点击提示揭晓一条线索判定胜负有人猜中时点击猜中展示完整答案7.2 安全偷看机制这是整个 App 最关键的细节设计。如果主持人偷看答案时被玩家瞥见游戏就直接毁了。解决方案是一个双层确认机制// 第一层按钮文案明确提示Button(️ 偷看汤底)// 第二层点击后弹出确认弹窗if(this.showAnswerWarning){Column(){Text(⚠️ 确认偷看汤底)Text(汤底是整个谜题的答案。偷看后你将无法再以未知的身份参与游戏。)Button(取消)// 关闭弹窗Button(确认偷看)// 展示答案}.border({width:1,color:#E8C87A})// 金色边框强调}这个设计有几个巧妙的点弹窗的文案提醒主持人你正在做什么防止误触金色边框让弹窗在视觉上与其他内容区分弹窗本身也起到了遮挡作用——即使玩家瞥到屏幕看到的也只是弹窗而不是答案7.3 问题计数器的社交价值问题计数器看似是一个简单的数字但在实际游戏中有重要的社交功能Text(问题数 this.questionCount)它的价值在于制造压力玩家知道自己的问题数被记录会更谨慎地提问成就系统猜中时问题数越少成就感越强复盘参考游戏结束后可以回顾「用了 XX 个问题猜中的」在一些海龟汤的变体规则中甚至有限制问题数的玩法如「20 个问题之内猜中」计数器为这种玩法提供了技术支持。7.4 提示的分级解锁每个谜题预设了 3 条提示从模糊到明确// 以企鹅肉为例hints:[重点在于味道不同——为什么南极的企鹅肉和城市里的味道不一样,想想他吃到的真的是企鹅吗当时只有他和朋友两个人。,朋友为什么一直没回来那只企鹅是怎么出现的,]第一条提示给出思考方向第二条缩小范围第三条几乎直接点破真相。主持人根据玩家的进展决定是否解锁下一条保持了游戏的节奏控制权。八、Builder 的语法陷阱与避坑指南8.1 问题背景在开发过程中我遇到了 ArkTS Builder 装饰器的若干语法限制。这是 ArkTS 与标准 TypeScript 差异最大的地方也最容易踩坑。8.2 Builder 中的禁止操作以下操作在 Builder 方法是不允许的BuilderbuildGameContent(){// ❌ 错误 1不能使用 constconstsoupthis.currentSoup;// ❌ 错误 2不能使用 letletcountthis.questionCount;// ❌ 错误 3不能使用 returnif(this.currentSoupnull){return;}// ❌ 错误 4不能直接调用非 Builder 方法并赋值consthintthis.getHintText();// 错误// ✅ 正确可以在组件属性中调用方法Text(this.getHintText())// 正确// ✅ 正确可以调用其他 Builder 方法this.buildOtherComponent()}8.3 解决方案有三种解决策略策略一内联表达式最简单不使用变量直接在使用处写完整表达式BuilderbuildGameContent(){// 不声明变量直接使用 this.currentSoup!Text(#(this.currentSoupasTurtleSoup).id)}缺点是重复代码多可读性差。策略二抽取为普通方法将计算逻辑提取到普通方法中然后在 Builder 中调用// 普通方法非 BuildergetHintText(soup:TurtleSoup):string{returnsoup.hints.slice(0,this.currentHintIndex1).join(\n\n);}// Builder 中调用BuilderbuildHintCard(soup:TurtleSoup){this.buildContentCard(this.getHintText(soup),#8B7E66)}这是最推荐的方案——保持 Builder 干净逻辑在普通方法中。策略三条件判断放在外层不要试图在 Builder 内部做复杂的条件分支而是在调用处判断// ✅ 推荐在 build() 中判断if(this.currentSoupnull){Text(加载失败)}else{this.buildGameContent()}// ❌ 不推荐在 Builder 内部 return8.4 原因分析为什么 ArkTS 要对 Builder 做这些限制根本原因是方舟编译器的编译模型。Builder 方法会被编译为独立的渲染函数它们的执行环境与普通方法不同。编译器需要对 Builder 的代码做额外的静态分析以优化渲染性能。允许const、let、return等操作会使这种分析复杂化。这是一种为了性能而牺牲语法灵活性的设计取舍。在最新的 API 版本中部分限制已经有所放宽如支持if/else但let/const/return仍然被禁止。九、状态管理策略9.1 状态设计原则这个 App 的状态管理遵循一个简单原则每个 State 变量对应一个独立的用户操作。用户操作对应的 State变化范围切换页面currentPage整个内容区域重建翻看卡片—无列表是静态的点击开始游戏currentSoup currentPage从列表切换到游戏偷看答案showAnswer showAnswerWarning答案区域显示回答是/否questionCount计数器数字更新揭晓提示currentHintIndex提示区域追加猜中isSolved showAnswer展示答案9.2 跨方法的状态共享所有State变量都在Component struct Index中声明Builder方法和普通方法共享同一份this上下文Componentstruct Index{StatecurrentPage:stringlist;StateshowAnswer:booleanfalse;StatequestionCount:number0;// Builder 可以直接访问 StateBuilderbuildGameStatus(){Text(问题数this.questionCount)// 直接读取}// 普通方法也可以读写resetGameState():void{this.questionCount0;// 直接修改}}这是单页面架构的最大优势——不需要通过参数传递状态没有 Prop drilling 的问题。9.3 状态重置的时机每次开始新游戏或返回列表时都需要重置游戏状态resetGameState():void{this.showAnswerfalse;this.currentHintIndex-1;this.questionCount0;this.showAnswerWarningfalse;this.isSolvedfalse;}这个方法的调用位置需要特别注意点击开始游戏时 → 重置确保新游戏是干净状态点击返回时 → 重置防止下次进入时残留旧状态偷看确认弹窗的取消 → 不重置只是关闭弹窗十、视觉设计暗黑哥特风格10.1 设计理念海龟汤的故事通常带有悬疑、恐怖或悲伤的色彩企鹅肉、音乐家之死、黑夜敲门声……。所以视觉设计采用了暗黑哥特风格——深色背景、暖色文字、低饱和度。这与上一个 App「孔雀东南飞」的古风主题形成鲜明对比但深色系的思路是一脉相承的。10.2 色彩系统色值用途设计意图#0D0D1A主背景极深的蓝黑像深夜#1A1520卡片背景比主背景略浅形成层级#2A2018边框深棕色细边框不抢眼#F0E6D3主文字米白色温暖不刺眼#C9A87C金色强调汤底、重要按钮#8B7E66次级文字提示、标签、说明#6B5E4A弱化文字统计信息、辅助内容10.3 难度标签的颜色编码四种难度使用不同的颜色让用户一目了然getDiffColor(difficulty:string):ResourceColor{switch(difficulty){case简单:return#7BCF8C;// 绿色case中等:return#E8C87A;// 黄色case困难:return#E87A7A;// 红色case烧脑:return#C97BE8;// 紫色}}这种颜色映射借鉴了游戏行业的难度分级惯例——绿色代表简单红色代表困难紫色代表高难度/稀有。10.4 卡片设计列表页的每张卡片都有 1px 的深色边框和 16px 的圆角.backgroundColor(#1A1520).borderRadius(16).border({width:1,color:#2A2018})这种设计在浅色背景上可能显得脏但在深色背景上反而呈现出一种精致的质感——像一张被烛光照亮的羊皮纸。十一、编译构建与部署11.1 构建配置项目使用 hvigor 6.1.1 构建工具API 24 (SDK 6.1.1)。核心配置在build-profile.json5中{ products: [{ name: default, signingConfig: default, targetSdkVersion: 6.1.1(24), compatibleSdkVersion: 6.1.1(24), runtimeOS: HarmonyOS }] }entry 模块的build-profile.json5中apiType设置为stageMode这是 API 24 强制要求的模型。11.2 构建命令hvigorw assembleHap--modemodule-pproductdefault --no-daemon完整构建流程PreBuild → CreateModuleInfo → MergeProfile → ProcessResource → CompileResource → CompileArkTS → PackageHap → SignHap → BUILD SUCCESSFUL其中 CompileArkTS 阶段耗时最长约 3-4 秒主要完成语法检查类型检查、Builder 规则检查方舟字节码编译摇树优化Tree Shaking11.3 构建产物构建成功的产物位于entry/build/default/outputs/default/entry-default-unsigned.hapHAP 文件大小约 1.8MB未签名。这个体积对于包含 15 个文本谜题的 App 来说是合理的——纯文本数据占用的空间极小主要体积来自 ArkUI 运行时库的引用。十二、踩坑全记录12.1 坑一字符串引号不匹配现象编译错误 “Unterminated string literal”位置TurtleSoupData.ets 第 59 行原因字符串以单引号开头以双引号结尾// ❌ 错误音乐会中发生了什么特别的事// 单引号开头双引号结尾 —— 不匹配// ✅ 正确音乐会中发生了什么特别的事教训在写大量中文文本时引号匹配是最容易犯的低级错误。建议在写完所有字符串后做一次全局的引号匹配检查。12.2 坑二Builder 中的 let/const 限制现象编译错误 “Only UI component syntax can be written here”位置Index.ets 中所有 Builder 方法原因在 Builder 方法中使用了let或const声明变量。解决方案将计算逻辑抽取到普通方法中。教训Builder 不是普通的函数。它的编译规则更接近 JSX——你只能在视图构建的语境中写代码。任何非 UI 的逻辑都应该放到普通方法中。12.3 坑三数组元素间缺少逗号现象编译错误 “‘,’ expected”位置TurtleSoupData.ets 第 60 行原因数组的两个元素之间缺少逗号// ❌ 错误hints:[第一条第二条,// 第一行末尾缺少逗号]// ✅ 正确hints:[第一条,第二条,]教训在 TypeScript/ArkTS 中数组元素之间的逗号不能省略不像 JavaScript 中在某些情况下可以省略。修改多个相邻字符串时要特别注意。12.4 坑四错误信息定位不准ArkTS 编译器的错误信息有时会指向错误行号之后的若干行。这是因为编译器在发现错误后需要继续解析才能确定错误的精确范围。例如缺少逗号的错误可能被报告在下一行而不是实际缺少逗号的那一行。应对策略当错误行号看起来不对时向上查找 2-3 行通常能发现真正的问题。12.5 坑五Builder 中的条件判断在最初的版本中我尝试在 Builder 方法中这样写BuilderbuildSomething(){if(this.x){// 一些 UI}}这其实是允许的——Builder 中可以使用if/else进行条件渲染。但不允许的是在if/else之外写return、let、const等。12.6 踩坑总结表#错误类型表现根因修复方式1引号不匹配Unterminated string开头结尾统一引号2Builder 限制Only UI component syntaxlet/const 在 Builder 中抽取到普通方法3缺少逗号‘,’ expected数组元素间无逗号加逗号4错误偏移行号不准确编译器错误传递向上查 2-3 行5可为空引用编译警告currentSoup 可能为 null用as TurtleSoup断言6颜色字符串无错误但无效颜色值拼写错误统一使用 7 位 Hex十三、优化方向与后续迭代13.1 短期优化MVP 之后谜题收藏功能用户可以收藏喜欢的谜题方便复玩计时器记录每局游戏的用时增加竞速玩法已玩标记已玩过的谜题在列表上显示标记避免重复搜索/筛选按难度、类型、关键词筛选谜题13.2 中期迭代自定义谜题用户自己编写海龟汤谜题分享给朋友多人联机通过华为近距离通信Nearby实现同房间的联机游戏语音支持集成语音识别玩家可以直接语音提问主持人不用打字云端题库从云端下载新谜题保持内容更新13.3 长期愿景将 App 发展为一个推理游戏平台不仅支持海龟汤还支持剧本杀短篇密室逃脱文字版逻辑推理谜题每个游戏类型共享同一套基础架构数据模型 状态管理 主持模式只需要替换数据内容和 UI 文案。13.4 技术债务当前版本遗留的技术债务硬编码数据15 个谜题硬编码在 .ets 文件中。如果扩展到 50应迁移到 JSON 资源文件单文件膨胀Index.ets 约 530 行。如果继续增加功能应考虑拆分为多个 Component缺少测试当前没有写单元测试。hypium 测试框架已经集成但测试用例还未补上访问性没有添加 Accessibility 支持。Text 组件的 accessibilityText 属性未设置十四、总结14.1 项目数据指标数值开发总工时约 2.5 小时源代码文件2 个数据 页面总代码行数~700 行谜题数量15 个构建时间~10 秒HAP 体积~1.8 MB14.2 技术收获Builder 的语法限制这是 ArkTS 与标准 TypeScript 最大的差异点。理解了这些限制背后的编译器设计考量后编码时就能避免犯错。单页面架构的适用场景对于 2-3 个页面的小型 App状态驱动的单页面模式比 router 路由更简洁。但对于 5 个页面以上的 App这种模式会变得难以维护。数据与UI的分离将 15 个谜题放在独立的TurtleSoupData.ets中不仅在架构上是好的实践也让 UI 层的修改不会影响数据反之亦然。14.3 写给 ArkTS 初学者的建议如果你正在学习 ArkTS这里有几个实用的建议不要把 ArkTS 当成 TypeScript 写。虽然语法相似但 Builder 的规则是独特的。先读懂编译器的错误信息比看十篇教程都管用。善用 Builder 的参数传递。Builder 方法可以接收参数这是拆分 UI 的正确方式。状态变量越少越好。每个 State 都会增加框架的跟踪开销。能用计算属性普通 getter代替的就不要用 State。从简单的 App 开始。海龟汤和孔雀东南飞这类单页面 App 是学习 ArkTS 的最佳起点——不涉及复杂的路由、网络、动画可以专注于理解声明式 UI 的核心概念。附录A谜题难度分布难度数量占比 简单213% 中等427% 困难533% 烧脑427%附录B关键编译配置// build-profile.json5 (项目根目录) { app: { products: [{ name: default, targetSdkVersion: 6.1.1(24), compatibleSdkVersion: 6.1.1(24), runtimeOS: HarmonyOS }] } }// entry/build-profile.json5 { apiType: stageMode, buildOption: { resOptions: { copyCodeResource: { enable: false } } } }附录C项目文件结构entry/src/main/ets/ ├── models/ │ └── TurtleSoupData.ets # 15 个谜题数据 类型定义 └── pages/ └── Index.ets # 主页面列表 游戏本文涉及的完整源码可在 DevEco Studio 项目中查看models/TurtleSoupData.etspages/Index.ets。构建环境HarmonyOS API 24 (SDK 6.1.1)DevEco Studio 6.1.xhvigor 6.1.1。