本文基于 HarmonyOS ArkTS 声明式 UI 框架实战项目「听歌识曲 ECHO」撰写,从设计理念、色彩系统、项目架构、数据模型、六大 Tab 页面、弹窗交互、动画效果、核心业务系统到开发环境搭建,进行全方位深度解析,并附完整源码。

引言:把一首歌的前奏,变成一场全民狂欢

在音乐流媒体高度发达的今天,"听歌识曲"早已从单纯的工具属性演变为一种社交游戏。Shazam 让"这是什么歌"有了答案,而「听歌识曲 ECHO」则把这个答案变成了一场全民参与的猜歌狂欢——听 1 秒前奏,在 15 秒内从四个选项中选出正确歌名,与全国玩家实时对战,在 12 个主题关卡中一路闯关,用耳朵争夺"王者金耳"的称号。

听歌识曲 ECHO 闯关 Tab 首页

「听歌识曲 ECHO」是一款基于 HarmonyOS ArkTS 声明式 UI 框架开发的猜歌闯关与实时对战 App,单文件约 2500 行代码,无外部依赖。项目采用"糖果游戏风"视觉设计——奶白底(#F5F7FF)营造轻松愉悦的游戏氛围,糖果粉(#FF5A79)如同草莓味的气泡糖,薄荷绿(#00C2A8)如同夏日的薄荷汽水,亮黄(#FFB800)如同金灿灿的金币。四种高饱和度糖果色的碰撞,让每一次猜歌都像拆开一颗水果糖——充满期待与惊喜。

应用包含 6 大 Tab 页面:闯关(12 个主题关卡 + 答题弹窗 + 结果弹窗)、对战(8 种对战房间 + 匹配弹窗 + 房间详情)、题库(14 个主题题库 + 刷题详情 + 刷题榜规则)、排行(全国猜歌榜 + 颁奖台 + 段位说明)、商城(12 种道具/装扮/礼包 + 购买确认 + 购买成功)、我的(每日签到 + 本周答题统计 + 背包/称号墙/歌友/设置)。每个 Tab 都有完整的弹窗交互体系,总计约 15 套弹窗,形成完整的猜歌游戏业务闭环。

本文将从设计理念出发,逐层拆解「听歌识曲 ECHO」的技术实现与设计思考,希望为 HarmonyOS 开发者和游戏化产品从业者提供有价值的参考。


第一章 项目概述与设计理念

1.1 项目定位

「听歌识曲 ECHO」定位为一款轻量级音乐猜歌游戏 App,核心玩法是"听前奏猜歌名"。与传统音乐 App 不同,ECHO 不提供完整的音乐播放功能,而是将音乐拆解为 1-5 秒的前奏片段,转化为四选一的答题游戏,通过闯关、对战、刷题、排行等游戏化机制,让"听歌识曲"从工具属性升级为社交娱乐属性。

项目的核心设计目标包括:

  • 极低的上手门槛:听前奏、四选一,3 秒即可理解玩法
  • 强游戏化反馈:答对得分、连对加成、段位晋升、金币奖励,每一次操作都有即时反馈
  • 丰富的内容纵深:12 个主题关卡 + 14 个题库分类,覆盖热歌、华语、欧美、日韩、影视、民谣、电音、古风、摇滚、爵士等全曲库
  • 社交对战体验:1v1 实时对战、4 人生存赛、3v3 团战、8 人连对狂欢,多种对战模式满足不同社交需求
  • 糖果游戏风视觉:高饱和度糖果色 + 圆润卡片 + 弹跳动画,营造轻松愉悦的游戏氛围

1.2 设计理念

「听歌识曲 ECHO」的设计理念可以概括为三个关键词:糖果感、游戏感、音乐感

糖果感是视觉层面的核心表达。奶白底 + 糖果粉 + 薄荷绿 + 亮黄的四色体系,如同糖果店的橱窗——每一颗糖都有不同的颜色和味道。卡片采用大圆角(14-22px),按钮采用胶囊形(borderRadius 20-26px),图标使用 Emoji 表情(🎤⚔️📚🏆🛍👤),整体视觉圆润、可爱、充满童趣。糖果粉 #FF5A79 是主操作色(按钮、选中态、高亮),薄荷绿 #00C2A8 是成功/进入色(答对、进入房间、胜率),亮黄 #FFB800 是奖励/金币色(金币、星级、得分),三色各司其职,在奶白底上形成鲜明的视觉层次。

游戏感是交互层面的核心表达。ECHO 将猜歌过程完全游戏化:闯关模式有 12 个主题关卡,每关 10-25 题,通关获得星级评价(★★★/★★☆/★☆☆/☆☆☆)和金币奖励;对战模式有 8 种房间,从免费场到金币场 ×500,从 1v1 到 8 人连对狂欢;题库模式有 14 个主题分类,每个题库记录已刷题数和正确率;排行模式有全国猜歌榜和四段段位体系(青铜耳塞/黄金唱针/钻石音叉/王者金耳)。每一个系统都围绕"成长感"和"成就感"设计,让用户在猜歌过程中不断获得正向反馈。

音乐感是内容层面的核心表达。虽然 ECHO 不播放完整音乐,但整个应用处处充满音乐元素:头部的 🎵 音符图标、答题弹窗中的声波柱状图(12 根高低错落的柱子,模拟音频波形)、选项中的 A/B/C/D 字母编号、连对加成的 🔥 火焰图标、段位名称中的"唱针"“音叉”“金耳"等音乐术语。音乐感不是通过播放音乐来体现,而是通过视觉符号和文案设计,让用户在每一次操作中都感受到"这是一个关于音乐的游戏”。

1.3 核心功能矩阵

功能模块 核心能力 数据规模 弹窗数量
闯关 12 个主题关卡 + 答题 + 结果反馈 12 关 / 约 200 题 3 套(关卡详情/答题/结果)
对战 8 种房间 + 实时匹配 + 房间详情 8 房间 / 4 种模式 3 套(房间详情/匹配/邀请)
题库 14 个主题题库 + 刷题 + 正确率 14 题库 / 约 4000 题 2 套(题库详情/刷题榜规则)
排行 全国榜 + 颁奖台 + 段位说明 12 玩家 / 4 段位 1 套(段位说明)
商城 12 种商品 + 分类筛选 + 购买 12 商品 / 4 分类 2 套(商品详情/购买成功)
我的 签到 + 统计 + 背包 + 设置 7 天签到 / 4 项设置 2 套(签到/设置)

1.4 技术栈

  • ArkTS 声明式 UI:采用 @Component / @State / @Builder 等装饰器构建声明式 UI,代码简洁直观
  • 单文件架构:所有代码集中在一个 2132.ets 文件中,约 2500 行,包含 8 个 @Component 组件
  • 纯静态数据驱动:7 组静态数据数组(QUEST_LEVELS / BATTLE_ROOMS / TOPIC_LIST / RANK_PLAYERS / SHOP_GOODS / DAILY_STATS / SIGN_DAYS),无网络请求
  • Stack 叠加弹窗:使用 Stack 容器 + if 条件渲染实现半透明遮罩弹窗,无需自定义弹窗组件
  • 属性动画:使用 .animation() 实现按钮缩放、进度条过渡、弹窗弹出等流畅动效

第二章 色彩系统与视觉风格

2.1 糖果游戏风色彩体系

「听歌识曲 ECHO」的色彩体系是典型的"糖果游戏风"——以奶白色为基底,以糖果粉、薄荷绿、亮黄三色为核心强调色,形成高饱和度、高对比度、充满活力的视觉效果。

色彩角色 色值 用途 视觉感受
奶白底 #F5F7FF 应用背景色 柔和、干净、不刺眼
卡片白 #FFFFFF 卡片/弹窗背景 明亮、通透、有层次
糖果粉 #FF5A79 主操作色(按钮/选中/高亮/主Tab) 热情、活泼、草莓糖
薄荷绿 #00C2A8 成功色(答对/进入/胜率/进度) 清新、冷静、薄荷汽水
亮黄 #FFB800 奖励色(金币/星级/得分/连对) 闪耀、珍贵、金币
深灰文字 #1D2129 主标题/正文 清晰、易读
中灰文字 #9AA0B4 副标题/辅助文字 柔和、不抢戏
浅灰分割 #EDE7F8 分割线/边框/浅背景 轻盈、有层次
遮罩黑 rgba(29,33,41,0.5-0.65) 弹窗半透明遮罩 聚焦、不压抑

2.2 色彩语义分析

糖果粉 #FF5A79 是 ECHO 的灵魂色。它不是传统的正红或玫红,而是偏粉偏暖的"草莓粉"——RGB 中红色 255、绿色 90、蓝色 121,绿色和蓝色的占比让它不会过于刺眼,反而有一种柔和的甜美感。糖果粉在应用中承担"主操作"角色:所有主要按钮(开始闯关、开始匹配、签到领奖励、确认购买)都是糖果粉填充+白色文字;底部 Tab 选中态是糖果粉;答题选项选中态是糖果粉填充;进度条主色是糖果粉;连对加成提示是糖果粉。糖果粉的高频使用让整个应用充满"可点击"的游戏感,用户一眼就能识别出哪些元素是可交互的。

薄荷绿 #00C2A8 是 ECHO 的"成功色"。它是一种偏蓝的青绿——RGB 中红色 0、绿色 194、蓝色 168,蓝色的占比让它不会过于鲜艳,反而有一种清凉的薄荷感。薄荷绿在应用中承担"成功/正向"角色:答对结果弹窗的 ✓ 图标是薄荷绿;对战房间的"进入"按钮是薄荷绿;玩家胜率文字是薄荷绿;答题进度条(已答部分)是薄荷绿;签到已完成天数的 ✓ 是薄荷绿;设置开关"开"状态是薄荷绿。薄荷绿与糖果粉形成冷暖对比——糖果粉代表"行动",薄荷绿代表"成功",用户在点击糖果粉按钮后获得薄荷绿反馈,形成完整的"行动→成功"心理闭环。

亮黄 #FFB800 是 ECHO 的"奖励色"。它是一种偏橙的金黄——RGB 中红色 255、绿色 184、蓝色 0,红色的占比让它不会过于刺眼,反而有一种温暖的金属感。亮黄在应用中承担"奖励/珍贵"角色:金币图标和数量是亮黄;关卡星级评价是亮黄(★★★);排行榜玩家得分是亮黄;商城商品价格是亮黄;连对加成的 🔥 火焰图标背景是亮黄;签到奖励中的金币是亮黄。亮黄的使用让"奖励"在视觉上脱颖而出——用户在猜歌过程中不断看到亮黄的金币和星级,强化"玩游戏=获得奖励"的心理联想。

奶白底 #F5F7FF 是 ECHO 的基底色。它不是纯白,而是偏蓝偏紫的极浅灰——RGB 中红色 245、绿色 247、蓝色 255,蓝色的微量占比让它有一种"牛奶加了一点蓝莓"的柔和感。奶白底与卡片白 #FFFFFF 形成微妙的层次差异——卡片在奶白底上通过白色填充和浅灰边框(#EDE7F8)自然浮现,无需阴影即可区分层级。这种"白底+白卡"的设计在游戏类 App 中并不常见(大多数游戏 App 使用深色背景),但 ECHO 选择奶白底正是为了营造"轻松、不压抑"的糖果游戏感——它不是一款紧张刺激的硬核游戏,而是一款休闲愉悦的猜歌小游戏。

2.3 视觉风格要素

除了色彩体系,ECHO 的视觉风格还包含以下要素:

大圆角卡片:所有卡片采用 12-22px 的大圆角,弹窗采用 18-22px 圆角,按钮采用 16-26px 胶囊形圆角。大圆角让整个应用的视觉语言统一为"圆润、可爱、无攻击性",与糖果游戏风的定位高度契合。

Emoji 图标系统:ECHO 完全使用 Emoji 表情作为图标——🎵(音符/Logo)、🎤(麦克风/闯关)、⚔️(交叉剑/对战)、📚(书本/题库)、🏆(奖杯/排行)、🛍(购物袋/商城)、👤(人像/我的)、🔥(火焰/热门/连对)、💰(金币袋)、🛡(盾牌/复活盾)、⏳(沙漏/加时)、🎁(礼物/礼包)。Emoji 图标的使用让应用无需额外的图标资源文件,同时 Emoji 本身的多彩属性进一步强化了糖果游戏风。

声波可视化:答题弹窗中使用 12 根高低错落的柱状图模拟音频波形,柱子高度在 20-46px 之间随机分布,高于 36px 的柱子为糖果粉,低于等于 36px 的为薄荷绿。声波可视化是 ECHO 中最具"音乐感"的视觉元素——它在用户听前奏猜歌时动态展示,让"听"这个动作有了视觉反馈。

星级评价系统:闯关模式使用三星评价体系(★★★/★★☆/★☆☆/☆☆☆),星级为亮黄色,通过 starRow() 工具函数根据得分生成。星级评价是游戏化设计的经典元素,它让每一关的通关结果不再是简单的"通过/未通过",而是有了"完美/优秀/良好/及格"的层次区分,激励用户反复挑战以获得三星。

段位命名体系:排行模式的四段段位命名充满音乐元素——青铜耳塞(0-999分)、黄金唱针(1000-1799分)、钻石音叉(1800-2499分)、王者金耳(2500+分)。“耳塞”“唱针”“音叉”"金耳"都是与音乐/音频相关的术语,让段位晋升不仅是分数的增长,更是"音乐耳朵"的成长。


第三章 项目架构与文件组织

3.1 单文件架构

「听歌识曲 ECHO」采用单文件架构,所有代码集中在一个 2132.ets 文件中,约 2500 行。这种架构选择基于以下考虑:

  • 演示项目定位:ECHO 是一个 UI 演示项目,核心目标是展示 ArkTS 声明式 UI 的开发能力,而非生产级应用架构
  • 无网络依赖:所有数据为静态常量,无需数据层/网络层/存储层的分层架构
  • 组件内聚:8 个 @Component 组件各自管理自己的状态和弹窗,组件间通过 @State 隔离,无需跨组件通信
  • 阅读友好:单文件架构让读者可以从头到尾顺序阅读,无需在多个文件间跳转

单文件架构按照以下顺序组织:

2132.ets
├── 文件头注释(项目名/风格/规范声明)
├── 类型定义(8 个 interface)
├── 静态数据(9 组 const 数组)
├── 工具函数(3 个 function + 1 个 enum)
├── 主入口组件(@Entry @Component EchoQuizApp)
├── 头部组件(@Component EchoHeader)
├── 闯关 Tab 组件(@Component QuestContent)
├── 对战 Tab 组件(@Component BattleContent)
├── 题库 Tab 组件(@Component TopicContent)
├── 排行 Tab 组件(@Component RankContent)
├── 商城 Tab 组件(@Component ShopContent)
└── 我的 Tab 组件(@Component MeContent)

3.2 组件架构

ECHO 共包含 8 个 @Component 组件,按照职责可以分为三类:

1. 根入口组件

组件名 装饰器 职责 状态数
EchoQuizApp @Entry @Component 应用根入口,管理底部 Tab 切换,路由六大内容区 1(activeTab)

EchoQuizApp 是应用的唯一 @Entry 组件,负责:

  • 维护 activeTab 状态(EchoTab 枚举:QUEST/BATTLE/TOPIC/RANK/SHOP/ME)
  • 通过 contentArea() @Builder 根据 activeTab 渲染对应 Tab 内容
  • 通过 bottomTabItem() @Builder 渲染底部 6 个 Tab 按钮
  • 整体布局:Column(头部 + 内容区 + 分割线 + 底部 Tab)

2. 通用头部组件

组件名 装饰器 职责 状态数
EchoHeader @Component 应用顶部头部,包含 Logo、搜索栏、金币/通知图标、活动横幅 0(纯静态)

EchoHeader 是一个纯静态组件(无 @State),负责:

  • 左侧 🎵 音符圆形图标(糖果粉浅背景)
  • 中间"听歌识曲 ECHO"标题 + "GUESS THE SONG · 音乐答题对战"副标题
  • 搜索栏(搜歌曲/题库/玩家)
  • 右侧 💰 金币图标 + 🔔 通知图标
  • 底部活动横幅(第三届城市猜歌大赛报名中 + 今日在线人数)

3. 六大 Tab 内容组件

组件名 装饰器 职责 状态数 弹窗数
QuestContent @Component 闯关 Tab:关卡列表 + 答题 + 结果 6 3
BattleContent @Component 对战 Tab:房间列表 + 匹配 + 房间详情 3 2
TopicContent @Component 题库 Tab:题库列表 + 详情 + 刷题榜 3 2
RankContent @Component 排行 Tab:颁奖台 + 榜单 + 段位说明 1 1
ShopContent @Component 商城 Tab:商品网格 + 分类 + 购买 5 2
MeContent @Component 我的 Tab:个人资料 + 签到 + 统计 + 设置 4 2

每个 Tab 组件都是一个独立的、自包含的单元——它管理自己的 @State 状态、自己的 @Builder 弹窗、自己的 build() 布局。组件之间没有任何耦合,通过 EchoQuizApp 的 activeTab 状态进行切换。

3.3 状态管理架构

ECHO 的状态管理采用组件局部状态模式,所有状态都是 @State 装饰的组件内部变量,没有使用 @Provide/@Consume、@Link、@Observed 等跨组件状态管理机制。这种选择基于以下原因:

  • 组件间无共享状态:六大 Tab 之间没有需要共享的数据,每个 Tab 的数据都是独立的静态数组
  • 弹窗状态局部化:每个弹窗的显示/隐藏状态都由所属 Tab 组件管理,不需要跨组件传递
  • 简化心智模型:局部状态让每个组件的逻辑完全内聚,阅读某个组件时不需要关心其他组件的状态

状态类型统计:

状态类型 数量 典型用途
boolean 约 20 弹窗显示/隐藏(showLevelModal/showQuizModal/showResult 等)
number 约 8 选中索引/答题选项/签到天数/分类索引(choiceIndex/signIndex/typeIndex)
对象引用 约 6 选中的数据项(selectedLevel/selectedRoom/selectedTopic/selectedGoods)
string 1 结果提示文本(resultText)
boolean(设置开关) 1 音效开关(soundOn)

3.4 弹窗架构

ECHO 的弹窗采用Stack 叠加 + if 条件渲染模式,这是 ArkTS 中实现弹窗的经典方式:

build() {
  Stack({ alignContent: Alignment.Center }) {
    Scroll() { /* 主内容 */ }
    .layoutWeight(1)

    if (this.showLevelModal) {
      Column() {
        Column()  // 半透明遮罩(点击关闭)
          .layoutWeight(1)
          .onClick(() => { this.showLevelModal = false; })
        this.levelModal()  // 弹窗内容
      }
      .width('100%')
      .height('100%')
      .backgroundColor('rgba(29,33,41,0.5)')
    }
  }
}

这种模式的核心要点:

  1. Stack 容器:使用 Stack 作为根容器,让主内容和弹窗可以叠加显示
  2. if 条件渲染:通过 showXxx 布尔状态控制弹窗是否渲染
  3. Column 双层结构:弹窗外层 Column 包含一个 layoutWeight(1) 的透明遮罩(点击关闭)和一个弹窗内容 @Builder
  4. 半透明背景:外层 Column 设置 backgroundColor 为 rgba(29,33,41,0.5-0.65),实现半透明遮罩效果
  5. @Builder 封装:每个弹窗内容封装为独立的 @Builder 方法,保持 build() 方法的简洁

ECHO 总计约 15 套弹窗,分布在 6 个 Tab 中:

Tab 弹窗名称 类型 用途
闯关 levelModal 详情弹窗 关卡详情(主题/题数/限时/奖励/进度)
闯关 quizModal 答题弹窗 答题界面(倒计时/声波/四选项)
闯关 resultModal 结果弹窗 答对结果(得分/金币/用时/连对加成)
对战 roomModal 详情弹窗 房间详情(模式/赌注/玩家/奖励)
对战 matchModal 匹配弹窗 实时匹配中(进度/等待时间)
题库 topicModal 详情弹窗 题库详情(题数/进度/正确率/规则)
题库 rankRule 规则弹窗 刷题榜规则说明
排行 tierModal 说明弹窗 段位说明(四段段位/分数区间)
商城 goodsModal 详情弹窗 商品详情(描述/价格/余额/购买)
商城 buyToast 成功提示 购买成功提示
我的 signModal 签到弹窗 每日签到(7天奖励/连签天数)
我的 settingModal 设置弹窗 游戏设置(音效/自动下一题/邀请)

第四章 数据模型与全局状态管理

4.1 类型定义体系

ECHO 定义了 8 个 interface 类型,覆盖应用的所有核心数据实体:

1. QuestLevel(关卡)

interface QuestLevel {
  id: number;        // 关卡唯一标识
  name: string;      // 关卡名称(如"热歌闯关·入门")
  theme: string;     // 主题(如"本周热歌""00年代")
  total: number;     // 题目总数
  stars: number;     // 获得星级(0-3)
  unlocked: boolean; // 是否解锁
  emoji: string;     // 关卡图标 Emoji
}

QuestLevel 是闯关模式的核心数据结构。12 个关卡按照主题分类:热歌入门、华语金曲(上/下)、欧美流行、日韩精选、影视原声、民谣角落、电音现场、古风词韵、摇滚编年史(锁定)、爵士深夜档(锁定)、终极混合大师(锁定)。每个关卡有独立的题数(10-25题)、星级评价(0-3星)和解锁状态。unlocked 字段控制关卡是否可点击——未解锁关卡显示 🔒 图标且不可点击,营造"逐步解锁"的游戏成长感。

2. BattleRoom(对战房间)

interface BattleRoom {
  id: number;         // 房间唯一标识
  name: string;       // 房间名称(如"极速猜歌 1v1")
  mode: string;       // 模式描述(如"1v1 · 15秒一题")
  players: number;    // 当前玩家数
  maxPlayers: number; // 最大玩家数
  stake: string;      // 赌注/门票(如"金币场 ×100""免费")
  hot: boolean;       // 是否热门(显示 HOT 标签)
}

BattleRoom 是对战模式的核心数据结构。8 个房间覆盖多种对战模式:1v1 极速猜歌、4 人车轮生存赛、1v1 新手免费场、1v1 巅峰高分段、3v3 团战、8 人连对狂欢、1v1 残局挑战(1秒前奏)、4 人午夜慢节奏。每个房间有独立的玩家容量(2/4/6/8人)、赌注类型(免费/金币场/门票)和热门标记。players/maxPlayers 字段实时显示房间人数,让用户在进入前就能判断房间是否快满。

3. TopicEntry(题库)

interface TopicEntry {
  id: number;       // 题库唯一标识
  name: string;     // 题库名称(如"抖音热歌 500 首")
  icon: string;     // 图标 Emoji
  count: number;    // 题目总数
  played: number;   // 已刷题数
  accuracy: string; // 正确率(如"78%")
}

TopicEntry 是题库模式的核心数据结构。14 个题库覆盖广泛的音乐分类:抖音热歌(500首)、周杰伦全专辑(342首)、林夕词选(280首)、K-POP女团十年(360首)、动漫OP/ED(400首)、粤语老歌(320首)、民谣歌词接龙(150首)、欧美Diva(260首)、国风音乐(230首)、游戏BGM(310首)、影视金曲(290首)、乐队合辑(270首)、儿童歌谣(120首)、红白歌会(240首)。played/count 字段计算刷题进度,accuracy 字段记录正确率,让用户清晰了解自己在每个题库的掌握程度。

4. RankPlayer(排行玩家)

interface RankPlayer {
  rank: number;    // 排名(1-12)
  name: string;    // 玩家昵称
  avatar: string;  // 头像 Emoji
  score: number;   // 积分
  wins: string;    // 胜率(如"92%")
  streak: number;  // 连胜场数
}

RankPlayer 是排行榜的核心数据结构。12 个玩家展示全国猜歌榜的前 12 名,前三名在颁奖台特殊展示(放大头像+🥇🥈🥉奖牌),4-12 名在列表中展示。每个玩家有独立的昵称(如"人形听歌机"“修琴的猫”“野生DJ”)、Emoji 头像、积分、胜率和连胜数。昵称和头像的设计充满趣味——"人形听歌机"暗示听曲能力极强,"歌词本成精"暗示歌词记忆超强,"CD机爷爷"暗示老歌库丰富,让排行榜不仅是分数的排名,更是一群有趣玩家的群像。

5. ShopGoods(商城商品)

interface ShopGoods {
  id: number;     // 商品唯一标识
  name: string;   // 商品名称(如"双倍金币卡·3天")
  icon: string;   // 图标 Emoji
  desc: string;   // 商品描述
  price: number;  // 价格(金币)
  type: string;   // 类型(道具/装扮/特效/货币/礼包)
  owned: boolean; // 是否已拥有
}

ShopGoods 是商城的核心数据结构。12 种商品覆盖 4 大分类:道具(双倍金币卡、复活盾、加时沙漏、改名卡、段位保护卡、猜歌小助手)、装扮(唱针黑胶头像框、霓虹音符气泡、音波进阶背景)、特效(冠军战袍特效)、货币(水晶音符×50)、礼包(金币礼包·中)。每个商品有独立的价格(150-1200金币)、类型分类和拥有状态。owned 字段控制商品是否显示"已拥有"标签——已拥有商品按钮变为灰色不可点击,避免重复购买。

6. DailyStat(每日答题统计)

interface DailyStat {
  day: string;     // 星期(一/二/.../日)
  count: number;   // 答题数
}

DailyStat 是"我的"Tab 中本周答题统计柱状图的数据结构。7 天数据(周一42题/周二65题/周三28题/周四78题/周五96题/周六120题/周日88题)通过柱状图可视化展示,柱子高度由 barHeightCount() 工具函数根据 count 计算(count × 1.2 vp),高于 80 题的柱子为糖果粉,低于等于 80 题的为亮黄。柱状图让用户直观看到自己一周的答题节奏——周末答题量明显高于工作日,符合休闲游戏的使用规律。

7. 辅助常量类型

除了上述 6 个 interface,ECHO 还定义了 2 组 string 数组常量作为辅助数据:

  • OPTION_DEMO: string[]:答题弹窗的四个选项演示数据([‘晴天’,‘七里香’,‘稻香’,‘夜曲’]),均为周杰伦经典歌曲
  • SIGN_DAYS: string[]:每日签到 7 天奖励([‘+20’,‘+40’,‘+60’,‘💎’,‘+80’,‘+120’,‘🎁’]),第4天和第7天为特殊奖励(水晶音符/礼包)

4.2 静态数据规模

ECHO 的 9 组静态数据总计约 80 条数据记录:

数据数组 类型 条数 用途
QUEST_LEVELS QuestLevel[] 12 闯关模式关卡列表
BATTLE_ROOMS BattleRoom[] 8 对战模式房间列表
TOPIC_LIST TopicEntry[] 14 题库模式题库列表
RANK_PLAYERS RankPlayer[] 12 排行榜玩家列表
SHOP_GOODS ShopGoods[] 12 商城商品列表
DAILY_STATS DailyStat[] 7 本周答题统计
OPTION_DEMO string[] 4 答题选项演示
SIGN_DAYS string[] 7 签到奖励
VOICE_PARTS(无) - - ECHO 无此数据

4.3 工具函数

ECHO 定义了 3 个工具函数和 1 个枚举:

starRow(n: number): string — 星级评价生成

function starRow(n: number): string {
  if (n >= 3) return '★★★';
  if (n === 2) return '★★☆';
  if (n === 1) return '★☆☆';
  return '☆☆☆';
}

根据关卡获得的星数(0-3)生成对应的星级字符串,用于关卡列表和关卡详情弹窗的星级展示。

barHeightCount(c: number): string — 柱状图高度计算

function barHeightCount(c: number): string {
  return (c * 1.2).toFixed(0) + 'vp';
}

根据每日答题数计算柱状图的高度(答题数 × 1.2 vp),用于"我的"Tab 本周答题统计柱状图。

rankMedal(r: number): string — 排名奖牌生成

function rankMedal(r: number): string {
  if (r === 1) return '🥇';
  if (r === 2) return '🥈';
  if (r === 3) return '🥉';
  return '';
}

根据排名生成对应的奖牌 Emoji(1名🥇/2名🥈/3名🥉/其他空),用于排行榜颁奖台展示。

enum EchoTab — 底部 Tab 枚举

enum EchoTab {
  QUEST,    // 闯关
  BATTLE,   // 对战
  TOPIC,    // 题库
  RANK,     // 排行
  SHOP,     // 商城
  ME        // 我的
}

定义底部 6 个 Tab 的枚举值,用于 EchoQuizApp 的 activeTab 状态管理和 Tab 切换逻辑。

4.4 全局状态管理

如 3.3 节所述,ECHO 采用纯局部状态管理模式,没有任何全局状态。所有状态都是 @State 装饰的组件内部变量,组件之间通过 EchoQuizApp 的 activeTab 枚举进行切换,不共享任何数据。

这种设计的优势:

  • 零耦合:每个组件完全独立,可以单独修改/替换而不影响其他组件
  • 易调试:状态问题只可能出现在当前组件内,不需要跨组件追踪
  • 易理解:阅读某个组件时不需要关心全局状态的变化

这种设计的局限(在演示项目中可接受):

  • 数据不共享:如果"我的"Tab 需要展示闯关进度,需要重新计算而非共享状态
  • 刷新不联动:在商城购买道具后,"我的"Tab 的背包数量不会自动更新(因为是两个独立组件)
  • 无持久化:所有状态在应用重启后重置(演示项目不涉及持久化)

在生产级应用中,可以通过 @Provide/@Consume 或 AppStorage 实现跨组件状态共享,但 ECHO 作为演示项目,纯局部状态已经足够。


第五章 六大 Tab 页面深度解析

「听歌识曲 ECHO」的主界面由底部 6 个 Tab 组成:闯关、对战、题库、排行、商城、我的。每个 Tab 都是一个独立的 @Component 组件,管理自己的状态和弹窗。本章逐一解析六大 Tab 的页面结构、交互逻辑和设计亮点。

5.1 闯关 Tab:12 关主题地图 + 答题弹窗

闯关 Tab 首页

闯关 Tab 是 ECHO 的默认首页,也是核心玩法入口。页面结构从上到下分为三个区域:

1. 今日闯关进度卡片
页面顶部是一个醒目的进度卡片,展示"今日闯关进度"——已通过 5 题,距离日目标还差 5 题,进度 50%。卡片左侧是 🔥 火焰图标,中间是进度文字,右侧是大号"50%“百分比,底部是一条糖果粉的进度条(宽度 50%)。这个卡片的设计目的是给用户一个明确的"每日目标”——每天答 10 题,通过进度条和百分比让用户直观感受到距离目标还有多远,激发"再答几题就能完成目标"的游戏心理。

2. 关卡地图标题栏
进度卡片下方是"🗺 关卡地图"标题,右侧显示"总星数 14/36"——12 关每关最多 3 星,总计 36 星,当前已获得 14 星。总星数是闯关模式的核心成长指标,它让用户不仅关注"通过了多少关",更关注"每关获得了几颗星",激励用户反复挑战以获得三星评价。

3. 关卡列表
标题栏下方是 12 个关卡的纵向列表,每个关卡是一个 levelCard @Builder 封装的卡片:

  • 左侧:圆形图标(54×54px,圆角 18px),显示关卡 Emoji(🎤🎼🎹🌍🎌🎬🌿🎧🏮🎸🎷👑),已解锁关卡背景为亮黄浅底(rgba(255,184,0,0.12)),未解锁关卡背景为浅灰(#EDE7F8)且图标缩小(scale 0.9)
  • 中间:关卡名称(粗体 14px)+ 主题和题数(10px 中灰)+ 星级评价(亮黄 ★★★)
  • 右侧:圆形按钮(34×34px,圆角 17px),已解锁显示 ▶ 播放图标(糖果粉填充+白色图标),未解锁显示 🔒 锁图标(浅灰填充+中灰图标)

12 个关卡按照难度递增排列:热歌闯关入门(15题,三星)→ 华语金曲上(20题,三星)→ 华语金曲下(20题,两星)→ 欧美流行(18题,三星)→ 日韩精选(16题,一星)→ 影视原声(14题,两星)→ 民谣角落(12题,零星)→ 电音现场(15题,零星)→ 古风词韵(12题,零星)→ 摇滚编年史(18题,锁定)→ 爵士深夜档(10题,锁定)→ 终极混合大师(25题,锁定)。前 9 关已解锁,后 3 关锁定,营造"逐步解锁"的成长感。

点击已解锁关卡弹出 levelModal 关卡详情弹窗(详见第六章),点击"开始闯关"进入 quizModal 答题弹窗,答对后显示 resultModal 结果弹窗,形成"关卡选择→答题→结果→下一题"的完整闯关流程。

5.2 对战 Tab:8 种房间 + 实时匹配

对战 Tab 首页

对战 Tab 是 ECHO 的社交核心,让用户与全国玩家实时猜歌对战。页面结构从上到下分为三个区域:

1. 实时对战卡片
页面顶部是一个大卡片,左侧是 ⚔️ 交叉剑圆形图标(54×54px,糖果粉浅底),中间是"实时对战"标题 + "今日战绩 6胜2负 · 连胜 +3"副标题,右侧是一个醒目的"开始匹配"胶囊按钮(糖果粉填充+白色文字,带 scale 1.04 缩放动画)。这个卡片既是对战模式的入口,也是用户今日战绩的展示面板——6胜2负的战绩和 +3 连胜让用户直观感受到自己的对战状态。

2. 模式筛选栏
对战卡片下方是一行模式筛选标签:全部(糖果粉填充+白色文字,选中态)、1v1、多人、免费场(白色填充+中灰文字,未选中态)。筛选栏让用户快速过滤不同类型的对战房间——想单挑选"1v1",想群战选"多人",不想花金币选"免费场"。

3. 房间列表
筛选栏下方是 8 个对战房间的纵向列表,每个房间卡片包含:

  • 左侧:圆形图标(46×46px,圆角 14px),1v1 模式显示 ⚔️,多人模式显示 👥,热门房间背景为糖果粉浅底,非热门为薄荷绿浅底
  • 中间:房间名称(粗体 13px)+ 模式描述(10px 中灰)+ 玩家人数和赌注(9px,👥 人数为薄荷绿,💰 赌注为亮黄)
  • 右侧:"进入"胶囊按钮(薄荷绿填充+白色文字)

8 个房间覆盖丰富的对战模式:

  • 极速猜歌 1v1(1v1·15秒一题,金币场×100,热门)
  • 车轮战·房间(4人·生存赛,门票200,热门)
  • 新手上路·免费(1v1·无惩罚,免费)
  • 巅峰对决·高分段(1v1·1600分以上,金币场×500,热门)
  • 听歌识曲·团战(3v3·组队,门票150)
  • 连对狂欢(8人·连对加倍,金币场×200,热门)
  • 残局挑战(1s前奏极限,门票300)
  • 午夜歌友会(4人·慢节奏,免费)

每个房间有独立的玩家人数(1/3/0/1/4/6/1/2)和最大容量(2/4/2/2/6/8/2/4),让用户在进入前就能判断房间的拥挤程度。点击房间弹出 roomModal 房间详情弹窗,点击"快速加入"进入 matchModal 匹配弹窗,形成"房间选择→匹配→对战"的完整对战流程。

5.3 题库 Tab:14 个主题题库 + 刷题详情

题库 Tab 首页

题库 Tab 是 ECHO 的内容库,让用户按照主题分类进行专项刷题。页面结构从上到下分为两个区域:

1. 标题栏
页面顶部是"📚 全部题库 14 个"标题,右侧是"刷题榜 ›"链接(点击弹出刷题榜规则弹窗)。标题栏简洁明了,直接告诉用户题库的总数量。

2. 题库列表
标题栏下方是 14 个题库的纵向列表,每个题库卡片包含:

  • 左侧:圆形图标(48×48px,圆角 14px,薄荷绿浅底),显示题库 Emoji(🔥🎹✍️🎀📺🏙🌿👑🏮🎮🎬🎸🧸🎌)
  • 中间:题库名称(粗体 13px)+ 题数可选(10px 中灰)+ 已刷题数和正确率(9px,已刷为薄荷绿,正确率为亮黄)
  • 右侧:"刷题 ›"胶囊按钮(糖果粉浅底+糖果粉文字)

14 个题库覆盖广泛的音乐分类,从大众流行到小众细分:

  • 抖音热歌 500 首(已刷210,正确率78%)
  • 周杰伦全专辑(已刷342,正确率95%——全题库最高)
  • 林夕词选(已刷96,正确率71%)
  • K-POP 女团十年(已刷120,正确率66%)
  • 动漫 OP/ED(已刷88,正确率82%)
  • 粤语老歌(已刷176,正确率74%)
  • 民谣歌词接龙(已刷150,正确率91%)
  • 欧美 Diva(已刷64,正确率69%)
  • 国风音乐(已刷132,正确率80%)
  • 游戏 BGM(已刷45,正确率77%)
  • 影视金曲(已刷108,正确率73%)
  • 乐队合辑(已刷90,正确率67%)
  • 儿童歌谣(已刷30,正确率88%)
  • 红白歌会(已刷52,正确率61%——全题库最低)

每个题库的已刷题数和正确率让用户清晰了解自己在每个分类的掌握程度——周杰伦全专辑 95% 正确率说明是周杰伦铁粉,红白歌会 61% 正确率说明对日音不太熟悉。这种"专项刷题+正确率反馈"的设计让用户可以针对性地补强自己的薄弱分类。点击题库弹出 topicModal 题库详情弹窗,展示更详细的刷题进度和题库规则。

5.4 排行 Tab:全国猜歌榜 + 颁奖台 + 段位

排行 Tab 首页

排行 Tab 是 ECHO 的竞争核心,让用户看到全国玩家的排名并激励自己提升段位。页面结构从上到下分为三个区域:

1. 标题栏
页面顶部是"🏆 全国猜歌榜"标题,右侧是"周榜"标签(糖果粉浅底+糖果粉文字)。标题栏简洁,直接点明这是全国范围的周榜排名。

2. 颁奖台(前三名)
标题栏下方是一个三列颁奖台,展示前三名玩家:

  • 中间(第一名):🥇 奖牌在顶部,🤖 圆形头像(62×62px,亮黄浅底,scale 1.1 放大),"人形听歌机"昵称(粗体 11px),28410 积分(亮黄粗体 11px)
  • 左侧(第二名):🐱 圆形头像(52×52px,薄荷绿浅底),"修琴的猫"昵称(10px),26890 积分(中灰粗体 10px)
  • 右侧(第三名):🎧 圆形头像(52×52px,糖果粉浅底),"野生DJ"昵称(10px),25320 积分(中灰粗体 10px)

颁奖台的设计采用经典的"中间高两侧低"布局——第一名头像更大(62px vs 52px)、有 🥇 奖牌、积分用亮黄色,视觉上明显高于两侧。这种设计让"成为第一名"的目标更加具象化——用户一眼就能看到第一名是谁、多少分,从而产生"我要超过他"的竞争心理。

3. 排行榜列表(4-12名)
颁奖台下方是 4-12 名玩家的纵向列表,每个玩家行包含:

  • 左侧:排名数字(粗体 13px 中灰,26px 宽)
  • 中间左:圆形头像(36×36px,圆角 18px,浅灰底),显示玩家 Emoji
  • 中间右:玩家昵称(12px 中灰)+ 胜率和连胜(9px,胜率为薄荷绿,🔥连胜为糖果粉)
  • 右侧:积分(粗体 13px 亮黄)

9 个玩家(4-12名)展示了排行榜的中后段:歌词本成精(23960分,胜率85%,连胜9)、一秒识曲(22880分,胜率84%,连胜12)、夜市点歌王(21450分,胜率82%,连胜7)、耳机不摘星人(20910分,胜率81%,连胜5)、CD机爷爷(19870分,胜率79%,连胜3)、音准小雷达(19020分,胜率78%,连胜4)、歌荒急救员(18440分,胜率77%,连胜2)、弹幕点歌姬(17930分,胜率75%,连胜1)、我的波点裙(17260分,胜率74%,连胜6)。

每个玩家的昵称都充满趣味——"歌词本成精"暗示歌词记忆超强,"一秒识曲"暗示听曲速度极快,"CD机爷爷"暗示老歌库丰富,"歌荒急救员"暗示推荐歌曲能力强。这些有趣的昵称让排行榜不仅是冰冷的分数排名,更是一群有趣玩家的群像,增加了排行榜的人情味和社交感。

页面底部是"🎖 段位说明"按钮(糖果粉浅底+糖果粉文字),点击弹出 tierModal 段位说明弹窗,展示四段段位体系(青铜耳塞/黄金唱针/钻石音叉/王者金耳)的分数区间和特权。

5.5 商城 Tab:12 种商品 + 分类筛选 + 购买

商城 Tab 首页

商城 Tab 是 ECHO 的商业化核心,让用户用金币购买道具、装扮和礼包。页面结构从上到下分为两个区域:

1. 分类筛选栏
页面顶部是一行分类筛选标签:全部(糖果粉填充+白色文字,选中态)、道具、装扮、礼包(白色填充+中灰文字,未选中态)。筛选栏让用户快速切换不同类型的商品——想买功能性道具选"道具",想买外观装扮选"装扮",想买优惠礼包选"礼包"。

2. 商品网格
筛选栏下方是商品的双列网格布局(Flex wrap,每行 2 个),每个商品卡片包含:

  • 顶部:图标区域(64px 高,圆角 14px,亮黄浅底),显示商品 Emoji(💰🛡⏳🖼💭✨💎🎫🧿🌌🤝🎁)
  • 中间:商品名称(粗体 11px)+ 商品描述(9px 中灰)
  • 底部:价格(亮黄粗体 12px,💰 图标+价格数字)+ 已拥有标签(薄荷绿,仅 owned=true 的商品显示)

12 种商品覆盖 4 大分类:

  • 道具类:双倍金币卡·3天(300金币)、复活盾×5(250金币,已拥有)、加时沙漏×3(180金币)、改名卡(150金币)、段位保护卡×3(400金币)、猜歌小助手·7天(500金币)
  • 装扮类:唱针·黑胶头像框(880金币)、霓虹音符气泡(460金币,已拥有)、音波·进阶背景(720金币)
  • 特效类:冠军战袍特效(1200金币)
  • 货币/礼包类:水晶音符×50(600金币)、金币礼包·中(30金币)

商品价格从 30 金币到 1200 金币不等,覆盖不同消费层级——30 金币的金币礼包是入门级消费,1200 金币的冠军战袍特效是高端炫耀性消费。已拥有商品(复活盾、霓虹音符气泡)显示"已拥有"标签且按钮变为灰色,避免重复购买。点击商品弹出 goodsModal 商品详情弹窗,展示更详细的商品信息、价格对比和购买确认。

5.6 我的 Tab:个人资料 + 签到 + 统计 + 设置

我的 Tab 首页

我的 Tab 是 ECHO 的个人中心,展示用户的游戏数据、签到奖励和设置选项。页面结构从上到下分为五个区域:

1. 个人资料卡片
页面顶部是个人资料行:

  • 左侧:🎧 圆形头像(60×60px,圆角 30px,糖果粉浅底)
  • 中间:"野生DJ"昵称(粗体 17px)+ "黄金唱针 II · 积分 1,720"段位和积分(10px 中灰)
  • 右侧:"签到"胶囊按钮(糖果粉填充+白色文字,带 scale 1.05 缩放动画)

个人资料卡片让用户一眼看到自己的昵称、段位和积分——"黄金唱针 II"是当前段位,"积分 1,720"是距离下一段位(钻石音叉 1800分)还差 80 分,这种"差一点就升级"的设计激励用户继续游戏。

2. 资产三栏
个人资料下方是三栏资产展示:

  • 金币:2,860(亮黄粗体 15px)
  • 水晶音符:32(薄荷绿粗体 15px)
  • 背包道具:18(糖果粉粗体 15px)

三栏资产让用户清晰了解自己的游戏财富——金币是普通货币(可通过对战和闯关获得),水晶音符是高级货币(可通过签到和充值获得),背包道具是已拥有的道具数量。三栏均为白色卡片+圆角 14px,视觉统一。

3. 本周答题数柱状图
资产下方是"📊 本周答题数"标题 + 7 天柱状图。柱状图展示周一到周日的答题数:周一42、周二65、周三28、周四78、周五96、周六120、周日88。柱子高度由 barHeightCount() 计算(count × 1.2 vp),高于 80 题的柱子为糖果粉(周五/周六/周日),低于等于 80 题的为亮黄(周一/周二/周三/周四)。柱状图区域高度 160px,柱子底部对齐,顶部显示答题数数字,底部显示星期。

柱状图让用户直观看到自己一周的答题节奏——周六答题最多(120题),周三最少(28题),周末答题量明显高于工作日,符合休闲游戏的使用规律。这种数据可视化不仅是展示,更是一种"自我量化"的游戏化设计——用户会不自觉地想"明天要多答几题,让柱子更高"。

4. 功能列表
柱状图下方是一个白色圆角卡片,包含 4 个功能行(每行之间有浅灰分割线):

  • 🎒 我的背包(18件 ›)
  • 🏅 称号墙(6枚 ›)
  • 👥 我的歌友(36人 ›)
  • ⚙️ 游戏设置(›,点击弹出 settingModal 设置弹窗)

每个功能行左侧是 Emoji 图标,中间是功能名称,右侧是数量/箭头。功能列表采用经典的"设置页"布局,简洁清晰,用户可以快速找到需要的功能。

5. 版本号
页面最底部是"v1.2.0 · 听歌识曲 ECHO"版本号(10px 浅灰),作为页面的收尾。


第六章 弹窗交互体系深度解析

「听歌识曲 ECHO」总计约 15 套弹窗,分布在 6 个 Tab 中,形成完整的游戏交互闭环。本章深度解析核心弹窗的设计逻辑和技术实现。

6.1 每日签到弹窗:7 天连签奖励

每日签到弹窗

签到弹窗是"我的"Tab 中点击"签到"按钮后弹出的弹窗,也是 ECHO 中最具"游戏感"的弹窗之一。弹窗结构:

  • 顶部:"📅 每日签到"标题(粗体 16px)+ "已连签 4 天 · 今天是第 5 天"副标题(11px 中灰)
  • 中间:7 天签到奖励横向排列,每天一个小卡片:
    • 第1-4天(已签到):✓ 薄荷绿图标 + 奖励文字(+20/+40/+60/💎),薄荷绿浅底
    • 第5天(今天):🎁 礼物图标(糖果粉)+ "+80"奖励文字(糖果粉),糖果粉浅底,scale 1.1 放大高亮
    • 第6-7天(未签到):奖励文字(+120/🎁),亮黄色,浅灰底
  • 底部:"签到领奖励"胶囊按钮(糖果粉填充+白色文字)

签到弹窗的设计核心是"连签激励"——7 天奖励递增(+20→+40→+60→💎→+80→+120→🎁),第4天和第7天是特殊奖励(水晶音符/礼包),今天的卡片放大高亮(scale 1.1),已签到的天数显示 ✓ 薄荷绿。这种设计让用户产生"每天都要来签一下,不然连签就断了"的心理,是游戏化产品中经典的"日活留存"手段。

技术实现上,签到弹窗使用 signIndex 状态(number,默认 4)记录已签到天数,通过 ForEach 遍历 SIGN_DAYS 数组渲染 7 天卡片,根据 i < signIndex(已签到)、i === signIndex(今天)、i > signIndex(未签到)三种状态渲染不同的样式。点击"签到领奖励"按钮后 signIndex += 1,模拟签到成功。

6.2 关卡详情弹窗:主题/题数/限时/奖励/进度

关卡详情弹窗

关卡详情弹窗是闯关 Tab 中点击已解锁关卡后弹出的弹窗,展示关卡的详细信息并提供"开始闯关"入口。弹窗结构:

  • 顶部:🎤 麦克风大图标(42px)
  • 关卡名称:“热歌闯关 · 入门”(粗体 18px)
  • 主题:“本周热歌”(11px 中灰)
  • 星级:★★★(亮黄 18px)
  • 详情列表:题目数量 15题 / 每题限时 15秒 / 通关奖励 金币×200·唱针头像框
  • 进度条:薄荷绿进度条(宽度 60%)+ "我的进度 9/15"文字
  • 底部:"▶ 开始闯关"大按钮(糖果粉填充+白色文字,圆角 26px,scale 1.05 缩放动画)

关卡详情弹窗的设计核心是"信息透明+行动召唤"——在开始闯关前,用户需要知道这一关有多少题、每题多久、通关奖励是什么、当前进度多少,这些信息全部在弹窗中清晰展示。底部的"开始闯关"大按钮是明确的行动召唤(CTA),糖果粉填充+缩放动画让按钮在视觉上非常突出,用户一眼就知道"点这里开始"。

技术实现上,关卡详情弹窗使用 selectedLevel 状态(QuestLevel | null)记录选中的关卡,通过 levelModal() @Builder 渲染。弹窗中的所有数据都来自 selectedLevel 对象——名称、主题、星级、题数、进度。进度条宽度硬编码为 60%(演示项目),在生产项目中应根据 selectedLevel 的实际进度计算。

6.3 答题弹窗:倒计时 + 声波 + 四选项

答题弹窗是闯关模式的核心交互,点击关卡详情的"开始闯关"后弹出。弹窗结构:

  • 顶部:“第 3 / 15 题”(11px 中灰)+ "⏱ 12s"倒计时(粗体 13px 糖果粉,scale 1.1 动画)
  • 进度条:糖果粉进度条(宽度 72%,模拟答题进度)
  • 声波可视化:12 根高低错落的柱状图(高度 20-46px 随机),高于 36px 为糖果粉,低于等于 36px 为薄荷绿,底部对齐
  • 提示文字:“🔊 听前奏,猜歌名”(粗体 14px)
  • 四选项列表:A/B/C/D 四个选项,每个选项包含字母编号(圆形,选中态为糖果粉填充+白色,未选中为浅灰填充+中灰)+ 选项文字(选中态为白色,未选中为深灰)+ 选中圆点(仅选中态显示)
    • A. 晴天
    • B. 七里香
    • C. 稻香
    • D. 夜曲

答题弹窗的设计核心是"紧张感+专注度"——倒计时数字(12s)用糖果粉粗体+缩放动画,营造"时间在流逝"的紧张感;声波可视化让"听"这个动作有了视觉反馈;四选项大按钮(padding 12px,圆角 14px)方便快速点击;选中态用糖果粉填充+白色文字,视觉反馈明确。点击选项后 choiceIndex 状态更新,同时 showResult = true 弹出结果弹窗。

技术实现上,答题弹窗使用 choiceIndex 状态(number,默认 -1)记录选中的选项,通过 ForEach 遍历 OPTION_DEMO 数组渲染四个选项。选项的选中态通过 this.choiceIndex === i 判断,动态切换背景色和文字色。倒计时和进度条在演示项目中为静态值,生产项目中应使用定时器动态更新。

6.4 结果弹窗:得分 + 金币 + 用时 + 连对加成

结果弹窗是答对题目后弹出的反馈弹窗,展示答题结果和奖励。弹窗结构:

  • 顶部:✓ 大对勾(52px 薄荷绿,圆形浅底,scale 1.15 动画)
  • “回答正确!”(粗体 19px)
  • “《晴天》 · 周杰伦”(12px 中灰)
  • 三栏奖励:本题得分 +120(亮黄)/ 金币 +15(糖果粉)/ 用时 2.4s(薄荷绿)
  • 连对加成提示:“🔥 连对 5 题 · 火焰加成 ×1.5”(糖果粉,浅底)
  • 底部:"下一题 ›"按钮(糖果粉填充+白色文字)

结果弹窗的设计核心是"正向反馈+奖励激励"——答对后用薄荷绿 ✓ 大对勾+缩放动画给予强烈的正向视觉反馈;三栏奖励(得分/金币/用时)让用户清晰知道这一题获得了什么;连对加成提示(🔥连对5题×1.5)激励用户保持连对状态以获得更高奖励。"下一题"按钮让用户快速进入下一题,保持游戏节奏的流畅性。

6.5 其他弹窗概览

除上述核心弹窗外,ECHO 还包含以下弹窗:

弹窗 所属 Tab 触发方式 核心内容
roomModal 房间详情 对战 点击房间 模式/赌注/玩家列表/奖励/邀请好友/快速加入
matchModal 匹配中 对战 点击开始匹配/快速加入 匹配进度条/段位区间/等待时间/取消匹配
topicModal 题库详情 题库 点击题库 题数/进度条/正确率/题库规则/收藏题库/开始刷题
rankRule 刷题榜规则 题库 点击刷题榜 积分规则/重复答题限制/清榜时间
tierModal 段位说明 排行 点击段位说明 四段段位(青铜/黄金/钻石/王者)分数区间和特权
goodsModal 商品详情 商城 点击商品 商品描述/类型/单价/我的金币/购买后余额/确认购买
buyToast 购买成功 商城 确认购买 ✓购买成功/道具已放入背包
settingModal 游戏设置 我的 点击游戏设置 音效开关/自动下一题/好友对战邀请

每个弹窗都遵循统一的设计规范:白色背景+大圆角(18-22px)+半透明遮罩(rgba(29,33,41,0.5-0.65))+点击遮罩关闭+主操作按钮为糖果粉胶囊形。统一的设计规范让用户在不同弹窗间切换时不会产生认知负担。


第七章 动画与过渡效果

「听歌识曲 ECHO」虽然是一个 UI 演示项目,但在动画和过渡效果上做了精心设计,让整个应用充满"活"的游戏感。本章解析 ECHO 中的核心动画效果。

7.1 属性动画体系

ECHO 使用 ArkTS 的属性动画(.animation())实现所有过渡效果,这是 ArkTS 中最简洁的动画方式——在需要动画的属性后添加 .animation({ duration: 毫秒, curve: 曲线 }),当属性值变化时自动产生过渡动画。

ECHO 中使用的动画参数统一为:

  • duration:200-600ms(大多数为 220-300ms,匹配弹窗为 600ms)
  • curve:Curve.EaseOut(缓出曲线,开始快结束慢,适合 UI 交互反馈)

7.2 核心动画场景

1. 底部 Tab 切换缩放
底部 Tab 图标在选中时 scale 从 {x:1,y:1} 变为 {x:1.18,y:1.18},配合 220ms EaseOut 动画,产生"选中的图标弹一下"的效果。这个动画虽然微小,但给底部 Tab 切换带来了明确的触觉反馈感。

2. 按钮悬停/点击缩放
多个主要按钮使用 scale 动画:

  • 闯关"开始闯关"按钮:scale {x:1.05,y:1.05}
  • 对战"开始匹配"按钮:scale {x:1.04,y:1.04}
  • 我的"签到"按钮:scale {x:1.05,y:1.05}
  • 签到弹窗"签到领奖励"按钮:scale {x:1.05,y:1.05}

按钮的 scale 动画让按钮在视觉上"鼓起来",产生"可点击"的暗示。虽然 ArkUI 没有 hover 态,但通过默认 scale 1.05+动画让按钮始终有"凸起感"。

3. 进度条过渡
所有进度条(闯关进度、答题进度、匹配进度、刷题进度、关卡进度)都使用 .animation({ duration: 300, curve: Curve.EaseOut }),当宽度变化时产生平滑的填充动画。进度条动画是游戏化 UI 中最经典的反馈——用户能"看到"进度在增长,产生"在进步"的心理感受。

4. 选项选中切换
答题弹窗的四选项在选中时背景色从浅灰变为糖果粉、文字色从深灰变为白色,配合动画产生平滑的颜色过渡。虽然代码中没有显式添加 .animation(),但 ArkUI 的属性变化默认会产生过渡效果。

5. 签到卡片高亮
签到弹窗中"今天"的卡片使用 scale {x:1.1,y:1.1} + 200ms 动画,在 7 天卡片中突出显示。放大高亮让用户一眼就看到"今天可以签到"。

6. 匹配弹窗图标动画
匹配弹窗的 ⚡ 闪电图标使用 scale {x:1.2,y:1.2} + 600ms 动画,比其他动画更长更慢,产生"正在搜索中"的呼吸感。600ms 的慢动画配合匹配进度条,让用户感受到"系统正在努力为你匹配对手"。

7. 结果弹窗对勾动画
结果弹窗的 ✓ 对勾使用 scale {x:1.15,y:1.15} + 400ms 动画,答对后对勾"弹一下",给予强烈的正向反馈。400ms 的时长比普通按钮动画更长,让"答对"这个正向事件更加突出。

8. 颁奖台第一名放大
排行 Tab 颁奖台的第一名头像使用 scale {x:1.1,y:1.1} + 300ms 动画,在三人中视觉上最大最高。这个动画是静态的(页面加载时播放一次),但它让第一名在视觉上"脱颖而出"。

7.3 动画设计原则

ECHO 的动画设计遵循以下原则:

1. 快而不躁:大多数动画时长在 200-300ms,快速但不急促。游戏类 App 的动画应该"跟手"——用户操作后立即响应,200-300ms 是最佳区间。

2. 缓出曲线:所有动画使用 Curve.EaseOut(开始快结束慢),这是 UI 交互最自然的曲线——用户的注意力在操作开始时最集中,快速响应满足期待,缓慢结束让动画看起来"优雅"。

3. 语义化时长:不同场景使用不同时长——普通交互 220ms、进度条 300ms、结果反馈 400ms、匹配等待 600ms。时长与事件的"重要性"和"等待感"正相关——越重要的反馈动画越长,越需要"等待感"的场景动画越长。

4. 克制使用:动画只用于关键交互点(Tab 切换、按钮、进度条、选中态、结果反馈),不在每个元素上都加动画。过度动画会让界面显得"花哨"和"卡顿",克制使用才能让动画有"点睛"效果。


第八章 猜歌闯关系统

猜歌闯关是 ECHO 的核心玩法之一,也是用户进入应用后看到的第一个 Tab。本章深度解析闯关系统的设计逻辑和技术实现。

8.1 关卡设计

ECHO 的闯关模式包含 12 个主题关卡,按照"从易到难、从大众到小众"的原则排列:

序号 关卡名称 主题 题数 星级 解锁
1 热歌闯关·入门 本周热歌 15 ★★★
2 华语金曲·上 00年代 20 ★★★
3 华语金曲·下 10年代 20 ★★☆
4 欧美流行 Billboard 18 ★★★
5 日韩精选 J-POP/K-POP 16 ★☆☆
6 影视原声 OST 14 ★★☆
7 民谣角落 独立民谣 12 ☆☆☆
8 电音现场 EDM 15 ☆☆☆
9 古风词韵 国风 12 ☆☆☆
10 摇滚编年史 Rock 18 - 🔒
11 爵士深夜档 Jazz 10 - 🔒
12 终极混合·大师 全曲库 25 - 🔒

关卡设计的几个关键点:

1. 主题分类覆盖广:12 关覆盖热歌、华语、欧美、日韩、影视、民谣、电音、古风、摇滚、爵士、混合 11 种音乐分类,几乎涵盖所有主流音乐类型,让不同音乐偏好的用户都能找到自己擅长的关卡。

2. 题数递增:题数从 10 题到 25 题不等,入门关 15 题,终极关 25 题。题数递增让关卡难度不仅体现在音乐类型上,也体现在"耐力"上——终极关需要连续答 25 题,对注意力和耐力都是考验。

3. 星级评价激励重玩:每关有 0-3 星评价,星级根据答题正确率和速度计算(演示项目中为静态值)。星级评价让用户不仅关注"通过",更关注"完美通过"——获得一星的用户会想"再玩一次拿两星",获得两星的会想"再玩一次拿三星",三星用户则获得"完美通关"的成就感。

4. 锁定关卡制造期待:后 3 关(摇滚/爵士/终极混合)锁定,显示 🔒 图标且不可点击。锁定关卡制造"还有更多内容等待解锁"的期待感,激励用户通过前 9 关以解锁后续内容。在生产项目中,锁定关卡应根据前关星级或用户等级动态解锁。

8.2 答题流程

闯关模式的答题流程是一个经典的"选择→答题→反馈→继续"循环:

点击关卡 → 关卡详情弹窗 → 开始闯关 → 答题弹窗
    ↑                                          ↓
下一题 ← 结果弹窗(答对/答错) ← 选择答案
  1. 关卡选择:用户在关卡列表中点击已解锁关卡
  2. 关卡详情:弹出 levelModal,展示关卡信息和进度,用户点击"开始闯关"
  3. 答题:弹出 quizModal,播放前奏(演示项目为静态声波),用户在 15 秒内从四选项中选择答案
  4. 结果反馈:选择后弹出 resultModal,展示答对/答错、得分、金币、用时、连对加成
  5. 继续/结束:点击"下一题"进入下一题,答完所有题后显示关卡结算(演示项目未实现结算页)

这个流程的设计核心是"快节奏+强反馈"——每题 15 秒限时,选择后立即显示结果,结果弹窗只展示关键信息(得分/金币/用时),"下一题"按钮让用户快速继续。整个流程没有冗余的等待和确认,保持游戏节奏的流畅性。

8.3 连对加成机制

连对加成是闯关模式的核心激励机制——用户连续答对题目时,得分和金币奖励会乘以连对倍数。在结果弹窗中展示为"🔥 连对 5 题 · 火焰加成 ×1.5"。

连对加成的设计目的:

  • 激励专注:连对倍数让用户更加专注,避免因粗心答错而"断连"
  • 制造高潮:连对到 5 题、10 题时,倍数提升带来"越来越刺激"的高潮感
  • 区分高手:连对能力是区分"普通玩家"和"高手"的关键指标——高手能保持长连对获得高倍数奖励

在生产项目中,连对加成通常设计为阶梯式:连对3题×1.2、连对5题×1.5、连对10题×2.0、连对20题×3.0,倍数随连对数递增,制造"越连越刺激"的游戏感。


第九章 实时对战系统

实时对战是 ECHO 的社交核心,让用户与全国玩家进行猜歌 PK。本章深度解析对战系统的设计逻辑。

9.1 对战模式设计

ECHO 提供 8 种对战房间,覆盖 4 种对战模式:

1. 1v1 单挑模式

  • 极速猜歌 1v1(15秒一题,金币场×100)
  • 新手上路·免费(无惩罚,免费)
  • 巅峰对决·高分段(1600分以上,金币场×500)
  • 残局挑战(1s前奏极限,门票300)

1v1 是最经典的对战模式,两人 PK,胜者获得金币/积分,败者扣除。不同房间针对不同水平的玩家——新手免费场适合入门,巅峰高分段适合高手,残局挑战适合追求极限刺激的玩家。

2. 多人生存模式

  • 车轮战·房间(4人生存赛,门票200)
  • 连对狂欢(8人连对加倍,金币场×200)
  • 午夜歌友会(4人慢节奏,免费)

多人生存模式让多名玩家同场竞技,车轮战是"答错淘汰"的生存赛,连对狂欢是"连对越多奖励越高"的积分赛,午夜歌友会是轻松休闲的慢节奏场。多人模式比 1v1 更热闹,适合社交。

3. 组队团战模式

  • 听歌识曲·团战(3v3组队,门票150)

团战模式让玩家组队对抗,3v3 的团队配合比单人 PK 更有策略性——可以安排不同擅长领域的玩家组队(如一人擅长华语、一人擅长欧美、一人擅长日韩),增加社交深度。

9.2 匹配机制

对战系统的匹配流程:

点击"开始匹配"或房间"快速加入" → matchModal 匹配弹窗
    → 显示匹配进度条和等待时间
    → 匹配成功后进入对战界面(演示项目未实现)
    → 点击"取消匹配"返回

匹配弹窗的设计:

  • ⚡ 闪电大图标(scale 1.2 + 600ms 动画,制造"搜索中"的呼吸感)
  • "正在匹配对手…"标题
  • "当前段位:黄金唱针 · 匹配区间 ±100 分"段位信息
  • 薄荷绿进度条(宽度 70%,模拟匹配进度)
  • "已用时 3.2 秒 · 平均等待 8 秒"等待时间
  • "取消匹配"按钮

匹配机制的核心设计原则:

  • 段位匹配:根据用户段位(积分)匹配相近水平的对手,匹配区间 ±100 分,避免"高手虐新手"
  • 等待时间透明:显示已用时间和平均等待时间,让用户对等待有预期
  • 可取消:随时可以取消匹配,避免"等太久"的焦虑

在生产项目中,匹配机制还应包含:匹配超时自动扩大段位区间、机器人填充(等待超过 30 秒时用 AI 对手填充)、匹配成功倒计时确认等。

9.3 赌注与奖励

对战房间的赌注/门票分为三种类型:

  • 免费场:无赌注,纯娱乐(新手上路、午夜歌友会)
  • 金币场:入场消耗金币,胜者获得奖池金币(极速猜歌×100、巅峰对决×500、连对狂欢×200)
  • 门票场:入场消耗门票(可通过金币购买),胜者获得奖励(车轮战200、团战150、残局挑战300)

赌注/门票的设计让对战有"代价感"——免费场轻松无压力,金币场有"赢了赚金币、输了亏金币"的刺激感,门票场门槛更高奖励也更丰厚。不同赌注类型满足不同风险偏好的玩家。


第十章 题库与刷题系统

题库系统是 ECHO 的内容库和练习场,让用户按照主题分类进行专项刷题。本章深度解析题库系统的设计逻辑。

10.1 题库分类设计

ECHO 提供 14 个主题题库,覆盖广泛的音乐分类:

题库名称 题数 已刷 正确率 分类
抖音热歌 500 首 500 210 78% 流行
周杰伦全专辑 342 342 95% 华语歌手
林夕词选 280 96 71% 词作
K-POP 女团十年 360 120 66% 日韩
动漫 OP/ED 400 88 82% 二次元
粤语老歌 320 176 74% 华语地域
民谣歌词接龙 150 150 91% 民谣
欧美 Diva 260 64 69% 欧美歌手
国风音乐 230 132 80% 国风
游戏 BGM 310 45 77% 游戏
影视金曲 290 108 73% 影视
乐队合辑 270 90 67% 乐队
儿童歌谣 120 30 88% 儿童
红白歌会 240 52 61% 日本综艺

题库分类的设计特点:

  • 按维度交叉分类:不仅按音乐类型(流行/民谣/国风/摇滚),还按歌手(周杰伦/欧美Diva)、词作(林夕)、地域(粤语)、场景(动漫/影视/游戏)、人群(儿童)等多维度交叉分类,让用户能精准找到自己感兴趣的细分领域
  • 题数差异大:从 120 题(儿童歌谣)到 500 题(抖音热歌),热门分类题数多,小众分类题数少,符合内容供给的实际情况
  • 正确率反馈:每个题库显示已刷题数和正确率,让用户清晰了解自己在每个分类的掌握程度

10.2 刷题流程

题库模式的刷题流程与闯关模式类似,但有几个关键区别:

  1. 无时间限制:题库刷题没有 15 秒限时,用户可以慢慢听、慢慢想,适合"学习"而非"竞技"
  2. 无星级评价:题库刷题不评星级,只记录正确率和已刷题数
  3. 四种题型轮换:题库详情中提到"四种题型轮换:歌名/歌手/歌词/专辑",比闯关模式(仅猜歌名)更丰富
  4. 隐藏称号:全部答对可解锁隐藏称号,激励用户"刷完整个题库"

题库模式的定位是"练习场"——闯关模式是"考试"(限时、评分、排名),题库模式是"复习"(不限时、多题型、按专项)。两种模式互补,满足用户不同场景的需求。

10.3 刷题榜规则

题库 Tab 的"刷题榜"链接弹出刷题榜规则弹窗,规则包括:

  1. 每答对一题积 1 分,答错不扣
  2. 同一题库当日重复答题仅计一次
  3. 每周一 0 点清榜并发放奖励

刷题榜的设计让题库刷题也有了"竞争性"——不仅是自己练习,还能与其他用户比拼刷题量和正确率。"当日重复答题仅计一次"的规则防止了"反复刷同一题刷分"的作弊行为,"每周清榜"让榜单保持新鲜感和持续参与度。


第十一章 排行榜与段位系统

排行榜和段位系统是 ECHO 的竞争核心,让用户有明确的成长目标和竞争动力。本章深度解析排行和段位系统的设计。

11.1 全国猜歌榜

排行榜展示全国玩家的周榜排名,前 12 名可见:

排名 玩家 积分 胜率 连胜
🥇1 人形听歌机 28410 92% 36
🥈2 修琴的猫 26890 89% 21
🥉3 野生DJ 25320 87% 15
4 歌词本成精 23960 85% 9
5 一秒识曲 22880 84% 12
6 夜市点歌王 21450 82% 7
7 耳机不摘星人 20910 81% 5
8 CD机爷爷 19870 79% 3
9 音准小雷达 19020 78% 4
10 歌荒急救员 18440 77% 2
11 弹幕点歌姬 17930 75% 1
12 我的波点裙 17260 74% 6

排行榜的设计特点:

  • 颁奖台+列表:前三名在颁奖台特殊展示(放大头像+奖牌),4-12 名在列表中展示,经典的排行榜布局
  • 多维度数据:不仅展示积分,还展示胜率和连胜,让排名不仅是"分数高低",更是"实力强弱"的综合体现
  • 趣味昵称:每个玩家的昵称都充满个性和趣味,让排行榜有人情味
  • 周榜机制:标注"周榜",每周清榜,保持竞争的新鲜感

11.2 四段段位体系

ECHO 的段位系统分为四段,每段有明确的分数区间和特权:

段位 分数区间 图标 状态 特权
青铜耳塞 0-999 🥉 已通过 入场即达,试试手感
黄金唱针 1000-1799 🥈 进行中 当前段位,胜率55%可晋升
钻石音叉 1800-2499 🥇 未达成 前10%的耳朵在这里
王者金耳 2500+ 👑 未达成 全服不超过500人

段位命名的设计充满音乐元素:

  • 青铜耳塞:耳塞是最基础的听音设备,对应入门段位
  • 黄金唱针:唱针是黑胶唱片的核心部件,对应中段位,“唱针"也暗示"精准”
  • 钻石音叉:音叉是调音工具,钻石象征珍贵,对应高段位,“音叉"暗示"音准极高”
  • 王者金耳:金耳是"金色的耳朵",象征"听曲能力登峰造极",对应最高段位

段位体系的设计目的:

  • 明确成长路径:四段段位让用户清晰知道"我现在在哪、下一步要去哪"
  • 分段激励:每段有不同的特权和称号,激励用户不断晋升
  • 稀缺性设计:王者金耳"全服不超过500人",制造稀缺性和荣誉感
  • 音乐主题命名:段位名称都与音乐/音频相关,强化产品的音乐属性

11.3 积分获取机制

积分是段位晋升的核心货币,主要通过对战获得:

  • 对战胜利:获得积分(根据对手段位和赌注计算)
  • 对战失败:扣除积分(段位保护卡可免扣)
  • 连对加成:对战中连对可获得额外积分
  • 周榜奖励:排行榜周榜前列可获得积分奖励

在生产项目中,积分机制还应包含:新手保护期(前10场不扣积分)、段位保护赛(即将掉段时触发保护赛)、连胜加分等,让积分系统更加完善和公平。


第十二章 个人中心与游戏数据系统

个人中心是 ECHO 的用户数据聚合页,展示用户的游戏资产、统计数据和设置选项。本章深度解析个人中心的设计。

12.1 个人资料与资产

个人中心顶部展示用户的核心资料和资产:

个人资料

  • 头像:🎧 Emoji(60×60px 圆形,糖果粉浅底)
  • 昵称:野生DJ
  • 段位:黄金唱针 II
  • 积分:1,720(距离钻石音叉 1800 分还差 80 分)

资产三栏

  • 💰 金币:2,860(亮黄色)
  • 💎 水晶音符:32(薄荷绿色)
  • 🎒 背包道具:18(糖果粉色)

资产设计的特点:

  • 双货币体系:金币(普通货币,通过游戏获得)+ 水晶音符(高级货币,通过签到/充值获得),双货币是游戏化产品的经典设计
  • 三栏并列:金币/水晶音符/背包道具三栏并列展示,让用户一眼看到自己的财富状况
  • 颜色区分:三种资产用三种颜色区分(亮黄/薄荷绿/糖果粉),视觉清晰

12.2 本周答题统计

个人中心的核心数据可视化是"本周答题数"柱状图,展示周一到周日的答题量:

星期 答题数 柱子颜色
42 亮黄
65 亮黄
28 亮黄
78 亮黄
96 糖果粉
120 糖果粉
88 糖果粉

柱状图的设计特点:

  • 颜色阈值:高于 80 题为糖果粉(“高产日”),低于等于 80 题为亮黄(“普通日”),颜色阈值让用户一眼看出哪些天是"高产日"
  • 底部对齐:柱子底部对齐,顶部显示答题数数字,经典的柱状图布局
  • 高度映射:柱子高度 = 答题数 × 1.2 vp,答题数越高柱子越高,直观展示数据差异
  • 周末效应:周六(120题)和周五(96题)答题量最高,周三(28题)最低,符合休闲游戏"周末玩得多、工作日玩得少"的使用规律

数据可视化的目的不仅是展示,更是"自我量化激励"——用户看到自己的答题数据后,会不自觉地产生"明天要多答几题"的动力,这是游戏化设计中"数据驱动行为"的经典应用。

12.3 功能列表

个人中心的功能列表包含 4 个入口:

  1. 🎒 我的背包(18件):展示已拥有的道具、装扮、特效,可使用/装备
  2. 🏅 称号墙(6枚):展示已获得的称号(如"周杰伦铁粉"“连对达人”“周榜前十”),称号是荣誉的象征
  3. 👥 我的歌友(36人):展示好友列表,可查看好友战绩、发起对战、聊天
  4. ⚙️ 游戏设置:音效开关、自动下一题、好友对战邀请等设置

功能列表采用经典的"设置页"布局,每行左侧是 Emoji 图标+功能名称,右侧是数量/箭头,行之间有浅灰分割线。这种布局简洁清晰,用户可以快速找到需要的功能。

12.4 每日签到系统

每日签到是个人中心的核心留存功能,点击"签到"按钮弹出签到弹窗(详见 6.1 节)。

签到系统的设计:

  • 7天循环:签到以 7 天为一个周期,第 1-7 天奖励递增
  • 递增奖励:+20→+40→+60→💎→+80→+120→🎁,奖励随天数递增
  • 特殊奖励日:第4天(💎水晶音符)和第7天(🎁礼包)是特殊奖励,比普通金币奖励更珍贵
  • 连签激励:显示"已连签 4 天",断签后重新计数,激励用户每天签到

签到系统是游戏化产品中最经典的"日活留存"手段——通过递增奖励和连签机制,让用户养成"每天打开 App 签个到"的习惯,从而提升日活和留存率。

12.5 游戏设置

游戏设置弹窗包含 3 个开关:

  1. 🔊 答题音效:开/关(可点击切换,soundOn 状态)
  2. ⏱ 自动下一题:关(演示项目为静态,生产项目可配置)
  3. 🤝 好友对战邀请:开(是否接收好友的对战邀请)

设置项的设计简洁实用,只包含最核心的游戏设置——音效(影响体验)、自动下一题(影响节奏)、好友邀请(影响社交)。不包含冗余的设置项,保持设置页的简洁性。


十三、开发环境搭建

13.1 环境要求

DevEco Studio 5.0+(支持HarmonyOS 6.1.1)、HarmonyOS SDK API 24、Node.js 18+、至少8GB内存。

13.2 安装DevEco Studio

DevEco Studio 安装界面

从华为开发者官网下载最新版本,运行安装包,选择安装路径,勾选创建桌面快捷方式,点击安装。

13.3 配置SDK

SDK 配置界面

首次启动后进入File > Settings > HarmonyOS SDK,勾选API 24及以上版本,点击Apply等待下载完成。

13.4 创建项目

创建项目界面

选择Create Project > Empty Ability,配置项目名称、包名、保存路径,选择Compile SDK为API 24,点击Finish。

13.5 运行应用

运行应用界面

将1316.ets代码替换到entry/src/main/ets/pages/Index.ets,连接HarmonyOS设备或启动模拟器,点击运行按钮预览。

13.6 预览调试

预览调试界面

DevEco Studio提供实时预览功能,编辑器右侧打开Previewer面板,支持多设备尺寸预览、深色模式切换、组件树inspect等调试能力。

总结与展望

项目总结

听歌识曲 ECHO 是一款以"听前奏猜歌名"为核心玩法的轻量级音乐猜歌游戏 App,基于 HarmonyOS ArkTS 声明式 UI 框架开发,单文件约 2500 行代码,无外部依赖。项目实现了 6 大 Tab 页面(闯关/对战/题库/排行/商城/我的)和约 15 套弹窗组件,完整覆盖猜歌闯关、实时对战、题库刷题、排行榜、商城购买、每日签到等核心游戏业务场景。

视觉设计上采用"糖果游戏风"——奶白底(#F5F7FF)营造轻松愉悦的游戏氛围,糖果粉(#FF5A79)如同草莓味的气泡糖,薄荷绿(#00C2A8)如同夏日的薄荷汽水,亮黄(#FFB800)如同金灿灿的金币。四种高饱和度糖果色的碰撞,让每一次猜歌都像拆开一颗水果糖——充满期待与惊喜。色彩语义明确——糖果粉代表主操作/选中/高亮,薄荷绿代表成功/进入/胜率,亮黄代表奖励/金币/星级/得分。

技术实现上严格遵循 ArkTS 规范:无 Blank 组件、无 UI 内变量声明、constraintSize 限高、单子组件 Scroll、接口约束全部对象字面量。采用单文件组件化架构,通过 @Component 划分为 8 个独立组件,@State 管理局部状态,@Builder 封装可复用 UI 和弹窗,Stack 叠加实现半透明遮罩弹窗,属性动画实现交互动效。

技术亮点

  1. 糖果游戏风视觉体系:奶白底+糖果粉+薄荷绿+亮黄的四色体系,大圆角卡片+胶囊形按钮+Emoji 图标,整体视觉圆润可爱充满童趣,让每一次猜歌都像游戏闯关
  2. 12 关主题闯关地图:从热歌入门到终极混合大师,12 关覆盖 11 种音乐分类,三星评价激励重玩,锁定关卡制造期待,形成完整的"逐步解锁"成长体验
  3. 8 种对战房间模式:从 1v1 极速猜歌到 8 人连对狂欢,覆盖单挑/生存/团战/极限等多种对战模式,免费场/金币场/门票场三种赌注类型满足不同风险偏好
  4. 14 个主题题库专项刷题:按歌手/词作/地域/场景/人群多维度交叉分类,每个题库记录已刷题数和正确率,四种题型轮换(歌名/歌手/歌词/专辑),让刷题不仅是练习更是学习
  5. 四段音乐主题段位体系:青铜耳塞→黄金唱针→钻石音叉→王者金耳,段位名称充满音乐元素,王者金耳"全服不超过500人"制造稀缺性和荣誉感
  6. 答题弹窗声波可视化:12 根高低错落的柱状图模拟音频波形,高于阈值为糖果粉低于阈值为薄荷绿,让"听前奏"这个动作有了视觉反馈
  7. 连对加成火焰机制:连对 5 题获得 ×1.5 火焰加成,连对越多奖励越高,激励用户保持专注,制造"越连越刺激"的游戏高潮
  8. 每日签到 7 天递增奖励:+20→+40→+60→💎→+80→+120→🎁,第4天和第7天为特殊奖励,连签激励提升日活留存
  9. 约 15 套完整弹窗交互体系:从关卡详情、答题、结果、匹配、房间详情、题库详情、段位说明、商品详情到签到、设置,每个游戏场景都有对应的弹窗交互,形成完整的猜歌游戏业务闭环
  10. 本周答题数数据可视化:7 天柱状图展示答题节奏,高于 80 题为糖果粉"高产日",数据可视化不仅是展示更是"自我量化激励"

扩展方向

听歌识曲 ECHO 作为演示项目,仍有多个可扩展方向:

  • 真实音频播放与识别:当前答题为静态演示,可接入 HarmonyOS 音频播放 API 实现真实的前奏播放,支持音频流式播放、音量控制、播放进度等
  • 实时对战网络同步:当前对战为本地演示,可接入 WebSocket 实现真实的实时对战,支持对手状态同步、答题计时同步、结果判定同步等
  • 题库动态扩展:当前题库为静态数据,可接入后端 API 实现题库动态扩展,支持每日更新、用户贡献题目、题目审核等
  • AI 难度匹配:可集成 AI 算法根据用户的历史答题数据动态调整题目难度,实现"千人千面"的个性化猜歌体验
  • 语音识别答题:可接入语音识别 API,支持用户用语音说出歌名而非点击选项,增加答题的趣味性和挑战性
  • 社交功能深化:可扩展好友系统、聊天、战队、公会等社交功能,让猜歌从"单人游戏"升级为"社交游戏"
  • AR 猜歌:可结合 HarmonyOS 的 AR 能力,实现 AR 猜歌——在真实场景中识别歌曲相关元素,增加沉浸式体验
  • 多端协同:可扩展为手机/平板/手表/车机多端协同,手机用于答题,手表用于震动反馈,车机用于车载猜歌娱乐

听歌识曲 ECHO 展示了 HarmonyOS ArkTS 在游戏化 App 开发中的强大能力——声明式 UI 的简洁语法、@State 的高效状态管理、Stack 叠加弹窗的灵活实现、属性动画的流畅动效、Previewer 的实时预览,让开发者可以用较少的代码实现丰富的游戏数据展示与交互体验。希望本文能为 HarmonyOS 开发者和游戏化产品从业者提供有价值的参考。

听一秒前奏,猜一首好歌。愿 ECHO 让每一双耳朵都成为金耳。

本文基于 HarmonyOS ArkTS 声明式 UI 框架实战项目撰写,所有代码和图片均来自听歌识曲 ECHO 项目。
—附录开始—


附录:完整源码(2132.ets)

以下为听歌识曲 ECHO 应用的完整 ArkTS 源码(2132.ets),可直接复制到 DevEco Studio 的 Index.ets 文件中运行。

// 2132.ets 听歌识曲 ECHO —— 猜歌闯关 / 实时对战 / 曲库题库
// 糖果游戏风:奶白底 + 糖果粉 + 薄荷绿 + 亮黄
// 遵循要求.md:无 Blank、无 UI 内变量声明、constraintSize 限高、单子组件 Scroll、接口约束全部对象字面量

// ============ 类型定义 ============
interface QuestLevel {
  id: number;
  name: string;
  theme: string;
  total: number;
  stars: number;
  unlocked: boolean;
  emoji: string;
}

interface BattleRoom {
  id: number;
  name: string;
  mode: string;
  players: number;
  maxPlayers: number;
  stake: string;
  hot: boolean;
}

interface TopicEntry {
  id: number;
  name: string;
  icon: string;
  count: number;
  played: number;
  accuracy: string;
}

interface RankPlayer {
  rank: number;
  name: string;
  avatar: string;
  score: number;
  wins: string;
  streak: number;
}

interface ShopGoods {
  id: number;
  name: string;
  icon: string;
  desc: string;
  price: number;
  type: string;
  owned: boolean;
}

interface DailyStat {
  day: string;
  count: number;
}

// ============ 静态数据 ============
const QUEST_LEVELS: QuestLevel[] = [
  { id: 1, name: '热歌闯关 · 入门', theme: '本周热歌', total: 15, stars: 3, unlocked: true, emoji: '🎤' },
  { id: 2, name: '华语金曲 · 上', theme: '00年代', total: 20, stars: 3, unlocked: true, emoji: '🎼' },
  { id: 3, name: '华语金曲 · 下', theme: '10年代', total: 20, stars: 2, unlocked: true, emoji: '🎹' },
  { id: 4, name: '欧美流行', theme: 'Billboard', total: 18, stars: 3, unlocked: true, emoji: '🌍' },
  { id: 5, name: '日韩精选', theme: 'J-POP / K-POP', total: 16, stars: 1, unlocked: true, emoji: '🎌' },
  { id: 6, name: '影视原声', theme: 'OST', total: 14, stars: 2, unlocked: true, emoji: '🎬' },
  { id: 7, name: '民谣角落', theme: '独立民谣', total: 12, stars: 0, unlocked: true, emoji: '🌿' },
  { id: 8, name: '电音现场', theme: 'EDM', total: 15, stars: 0, unlocked: true, emoji: '🎧' },
  { id: 9, name: '古风词韵', theme: '国风', total: 12, stars: 0, unlocked: true, emoji: '🏮' },
  { id: 10, name: '摇滚编年史', theme: 'Rock', total: 18, stars: 0, unlocked: false, emoji: '🎸' },
  { id: 11, name: '爵士深夜档', theme: 'Jazz', total: 10, stars: 0, unlocked: false, emoji: '🎷' },
  { id: 12, name: '终极混合 · 大师', theme: '全曲库', total: 25, stars: 0, unlocked: false, emoji: '👑' }
];

const BATTLE_ROOMS: BattleRoom[] = [
  { id: 1, name: '极速猜歌 1v1', mode: '1v1 · 15秒一题', players: 1, maxPlayers: 2, stake: '金币场 ×100', hot: true },
  { id: 2, name: '车轮战 · 房间', mode: '4人 · 生存赛', players: 3, maxPlayers: 4, stake: '门票 200', hot: true },
  { id: 3, name: '新手上路 · 免费', mode: '1v1 · 无惩罚', players: 0, maxPlayers: 2, stake: '免费', hot: false },
  { id: 4, name: '巅峰对决 · 高分段', mode: '1v1 · 1600分以上', players: 1, maxPlayers: 2, stake: '金币场 ×500', hot: true },
  { id: 5, name: '听歌识曲 · 团战', mode: '3v3 · 组队', players: 4, maxPlayers: 6, stake: '门票 150', hot: false },
  { id: 6, name: '连对狂欢', mode: '8人 · 连对加倍', players: 6, maxPlayers: 8, stake: '金币场 ×200', hot: true },
  { id: 7, name: '残局挑战', mode: '1s 前奏极限', players: 1, maxPlayers: 2, stake: '门票 300', hot: false },
  { id: 8, name: '午夜歌友会', mode: '4人 · 慢节奏', players: 2, maxPlayers: 4, stake: '免费', hot: false }
];

const TOPIC_LIST: TopicEntry[] = [
  { id: 1, name: '抖音热歌 500 首', icon: '🔥', count: 500, played: 210, accuracy: '78%' },
  { id: 2, name: '周杰伦全专辑', icon: '🎹', count: 342, played: 342, accuracy: '95%' },
  { id: 3, name: '林夕词选', icon: '✍️', count: 280, played: 96, accuracy: '71%' },
  { id: 4, name: 'K-POP 女团十年', icon: '🎀', count: 360, played: 120, accuracy: '66%' },
  { id: 5, name: '动漫 OP/ED', icon: '📺', count: 400, played: 88, accuracy: '82%' },
  { id: 6, name: '粤语老歌', icon: '🏙', count: 320, played: 176, accuracy: '74%' },
  { id: 7, name: '民谣歌词接龙', icon: '🌿', count: 150, played: 150, accuracy: '91%' },
  { id: 8, name: '欧美 Diva', icon: '👑', count: 260, played: 64, accuracy: '69%' },
  { id: 9, name: '国风音乐', icon: '🏮', count: 230, played: 132, accuracy: '80%' },
  { id: 10, name: '游戏 BGM', icon: '🎮', count: 310, played: 45, accuracy: '77%' },
  { id: 11, name: '影视金曲', icon: '🎬', count: 290, played: 108, accuracy: '73%' },
  { id: 12, name: '乐队合辑', icon: '🎸', count: 270, played: 90, accuracy: '67%' },
  { id: 13, name: '儿童歌谣', icon: '🧸', count: 120, played: 30, accuracy: '88%' },
  { id: 14, name: '红白歌会', icon: '🎌', count: 240, played: 52, accuracy: '61%' }
];

const RANK_PLAYERS: RankPlayer[] = [
  { rank: 1, name: '人形听歌机', avatar: '🤖', score: 28410, wins: '92%', streak: 36 },
  { rank: 2, name: '修琴的猫', avatar: '🐱', score: 26890, wins: '89%', streak: 21 },
  { rank: 3, name: '野生DJ', avatar: '🎧', score: 25320, wins: '87%', streak: 15 },
  { rank: 4, name: '歌词本成精', avatar: '📖', score: 23960, wins: '85%', streak: 9 },
  { rank: 5, name: '一秒识曲', avatar: '⚡', score: 22880, wins: '84%', streak: 12 },
  { rank: 6, name: '夜市点歌王', avatar: '🌙', score: 21450, wins: '82%', streak: 7 },
  { rank: 7, name: '耳机不摘星人', avatar: '👽', score: 20910, wins: '81%', streak: 5 },
  { rank: 8, name: 'CD机爷爷', avatar: '💿', score: 19870, wins: '79%', streak: 3 },
  { rank: 9, name: '音准小雷达', avatar: '📡', score: 19020, wins: '78%', streak: 4 },
  { rank: 10, name: '歌荒急救员', avatar: '🚑', score: 18440, wins: '77%', streak: 2 },
  { rank: 11, name: '弹幕点歌姬', avatar: '🎀', score: 17930, wins: '75%', streak: 1 },
  { rank: 12, name: '我的波点裙', avatar: '👗', score: 17260, wins: '74%', streak: 6 }
];

const SHOP_GOODS: ShopGoods[] = [
  { id: 1, name: '双倍金币卡 · 3天', icon: '💰', desc: '对战奖励翻倍', price: 300, type: '道具', owned: false },
  { id: 2, name: '复活盾 ×5', icon: '🛡', desc: '答错一次免淘汰', price: 250, type: '道具', owned: true },
  { id: 3, name: '加时沙漏 ×3', icon: '⏳', desc: '每题延长 5 秒', price: 180, type: '道具', owned: false },
  { id: 4, name: '唱针 · 黑胶头像框', icon: '🖼', desc: '限定头像框', price: 880, type: '装扮', owned: false },
  { id: 5, name: '霓虹音符气泡', icon: '💭', desc: '聊天气泡皮肤', price: 460, type: '装扮', owned: true },
  { id: 6, name: '冠军战袍特效', icon: '✨', desc: '胜利出场动画', price: 1200, type: '特效', owned: false },
  { id: 7, name: '水晶音符 ×50', icon: '💎', desc: '高级货币', price: 600, type: '货币', owned: false },
  { id: 8, name: '改名卡', icon: '🎫', desc: '修改昵称一次', price: 150, type: '道具', owned: false },
  { id: 9, name: '段位保护卡 ×3', icon: '🧿', desc: '失败不掉分', price: 400, type: '道具', owned: false },
  { id: 10, name: '音波·进阶背景', icon: '🌌', desc: '个人主页背景', price: 720, type: '装扮', owned: false },
  { id: 11, name: '猜歌小助手 · 7天', icon: '🤝', desc: '每题排除2个错误答案', price: 500, type: '道具', owned: false },
  { id: 12, name: '金币礼包 · 中', icon: '🎁', desc: '金币 ×3000', price: 30, type: '礼包', owned: false }
];

const DAILY_STATS: DailyStat[] = [
  { day: '一', count: 42 },
  { day: '二', count: 65 },
  { day: '三', count: 28 },
  { day: '四', count: 78 },
  { day: '五', count: 96 },
  { day: '六', count: 120 },
  { day: '日', count: 88 }
];

const OPTION_DEMO: string[] = ['晴天', '七里香', '稻香', '夜曲'];
const SIGN_DAYS: string[] = ['+20', '+40', '+60', '💎', '+80', '+120', '🎁'];

// ============ 工具函数 ============
function starRow(n: number): string {
  if (n >= 3) {
    return '★★★';
  }
  if (n === 2) {
    return '★★☆';
  }
  if (n === 1) {
    return '★☆☆';
  }
  return '☆☆☆';
}

function barHeightCount(c: number): string {
  return (c * 1.2).toFixed(0) + 'vp';
}

function rankMedal(r: number): string {
  if (r === 1) {
    return '🥇';
  }
  if (r === 2) {
    return '🥈';
  }
  if (r === 3) {
    return '🥉';
  }
  return '';
}

enum EchoTab {
  QUEST,
  BATTLE,
  TOPIC,
  RANK,
  SHOP,
  ME
}
// ============ 主入口 ============
@Entry
@Component
struct EchoQuizApp {
  @State activeTab: EchoTab = EchoTab.QUEST

  @Builder contentArea() {
    if (this.activeTab === EchoTab.QUEST) {
      QuestContent()
    } else if (this.activeTab === EchoTab.BATTLE) {
      BattleContent()
    } else if (this.activeTab === EchoTab.TOPIC) {
      TopicContent()
    } else if (this.activeTab === EchoTab.RANK) {
      RankContent()
    } else if (this.activeTab === EchoTab.SHOP) {
      ShopContent()
    } else {
      MeContent()
    }
  }

  @Builder bottomTabItem(icon: string, label: string, tab: EchoTab) {
    Column({ space: 3 }) {
      Text(icon)
        .fontSize(21)
        .scale(this.activeTab === tab ? { x: 1.18, y: 1.18 } : { x: 1, y: 1 })
        .animation({ duration: 220, curve: Curve.EaseOut })
      Text(label)
        .fontSize(10)
        .fontColor(this.activeTab === tab ? '#FF5A79' : '#9AA0B4')
    }
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 8, bottom: 6 })
    .width('16.6%')
    .onClick(() => {
      this.activeTab = tab;
    })
  }

  build() {
    Column() {
      EchoHeader()
      Column() {
        this.contentArea()
      }
      .layoutWeight(1)
      .width('100%')

      Divider()
        .color('#EDE7F8')
        .strokeWidth(1)

      Row() {
        this.bottomTabItem('🎮', '闯关', EchoTab.QUEST)
        this.bottomTabItem('⚔️', '对战', EchoTab.BATTLE)
        this.bottomTabItem('📚', '题库', EchoTab.TOPIC)
        this.bottomTabItem('🏆', '排行', EchoTab.RANK)
        this.bottomTabItem('🛍', '商城', EchoTab.SHOP)
        this.bottomTabItem('👤', '我的', EchoTab.ME)
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .padding({ bottom: 6 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F7FF')
  }
}

// ============ 头部(电商风·静态) ============
@Component
struct EchoHeader {
  onSearchTap: () => void = () => {};

  build() {
    Column() {
      Row({ space: 10 }) {
        Column() {
          Text('🎵')
            .fontSize(20)
        }
        .width(42)
        .height(42)
        .borderRadius(21)
        .backgroundColor('rgba(255,90,121,0.12)')
        .justifyContent(FlexAlign.Center)

        Column({ space: 2 }) {
          Text('听歌识曲 ECHO')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1D2129')
          Text('GUESS THE SONG · 音乐答题对战')
            .fontSize(8)
            .fontColor('#9AA0B4')
            .letterSpacing(1)
        }
        .alignItems(HorizontalAlign.Start)

        Row({ space: 6 }) {
          Text('🔎')
            .fontSize(13)
          Text('搜歌曲 / 题库 / 玩家')
            .fontSize(11)
            .fontColor('#9AA0B4')
        }
        .padding({ left: 12, right: 12, top: 8, bottom: 8 })
        .backgroundColor('#FFFFFF')
        .borderRadius(22)
        .border({ width: 1, color: '#EDE7F8' })
        .layoutWeight(1)
        .onClick(() => {
          this.onSearchTap();
        })

        Text('💰')
          .fontSize(18)
        Text('🔔')
          .fontSize(18)
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12, bottom: 12 })

      Row({ space: 8 }) {
        Text('🏆 第三届城市猜歌大赛 · 报名中')
          .fontSize(11)
          .fontColor('#FF5A79')
          .padding({ left: 10, right: 10, top: 4, bottom: 4 })
          .backgroundColor('rgba(255,90,121,0.08)')
          .borderRadius(4)
        Text('今日在线 2.4 万人正在猜歌')
          .fontSize(10)
          .fontColor('#9AA0B4')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .layoutWeight(1)
      }
      .width('100%')
      .padding({ left: 16, right: 16, bottom: 12 })
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
  }
}
// ============ 闯关 Tab(关卡列表 + 答题弹窗) ============
@Component
struct QuestContent {
  @State showLevelModal: boolean = false
  @State selectedLevel: QuestLevel | null = null
  @State showQuizModal: boolean = false
  @State choiceIndex: number = -1
  @State showResult: boolean = false

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Column()
        .width('100%')
        .layoutWeight(1)
        .onClick(() => {
          onClose();
        })
      Column() {
        Text('🎮')
          .fontSize(30)
          .margin({ bottom: 8 })
        Text('闯关演示')
          .fontSize(12)
          .fontColor('#9AA0B4')
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }

  @Builder levelModal() {
    Column({ space: 12 }) {
      Text(this.selectedLevel!.emoji)
        .fontSize(42)
        .margin({ top: 20 })

      Text(this.selectedLevel!.name)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1D2129')
      Text(this.selectedLevel!.theme)
        .fontSize(11)
        .fontColor('#9AA0B4')
      Text(starRow(this.selectedLevel!.stars))
        .fontSize(18)
        .fontColor('#FFB800')
      Column({ space: 6 }) {
        Row() {
          Text('题目数量')
            .fontSize(11)
            .fontColor('#9AA0B4')
          Text(this.selectedLevel!.total.toString() + ' 题')
            .fontSize(11)
            .fontColor('#1D2129')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('每题限时')
            .fontSize(11)
            .fontColor('#9AA0B4')
          Text('15 秒')
            .fontSize(11)
            .fontColor('#1D2129')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('通关奖励')
            .fontSize(11)
            .fontColor('#9AA0B4')
          Text('金币 ×200 · 唱针头像框')
            .fontSize(11)
            .fontColor('#FF5A79')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .width('100%')

      Column() {
        Column()
          .width('60%')
          .height(8)
          .borderRadius(4)
          .backgroundColor('#00C2A8')
      }
      .width('84%')
      .height(8)
      .borderRadius(4)
      .backgroundColor('#EDE7F8')

      Text('我的进度 9 / ' + this.selectedLevel!.total)
        .fontSize(10)
        .fontColor('#9AA0B4')

      Button() {
        Text('▶ 开始闯关')
          .fontSize(15)
          .fontColor('#FFFFFF')
      }
      .padding({ left: 44, right: 44, top: 12, bottom: 12 })
      .backgroundColor('#FF5A79')
      .borderRadius(26)
      .scale({ x: 1.05, y: 1.05 })
      .animation({ duration: 300, curve: Curve.EaseOut })
      .margin({ bottom: 22 })
      .onClick(() => {
        this.showLevelModal = false;
        this.showQuizModal = true;
      })
    }
    .width('88%')
    .backgroundColor('#FFFFFF')
    .borderRadius(20)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#EDE7F8' })
  }

  @Builder quizModal() {
    Column({ space: 16 }) {
      Row({ space: 8 }) {
        Text('第 3 / 15 题')
          .fontSize(11)
          .fontColor('#9AA0B4')
          .layoutWeight(1)
        Text('⏱ 12s')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FF5A79')
          .scale({ x: 1.1, y: 1.1 })
          .animation({ duration: 500, curve: Curve.EaseOut })
      }
      .width('100%')
      .padding({ left: 20, right: 20, top: 18 })

      Column() {
        Column()
          .width('72%')
          .height(8)
          .borderRadius(4)
          .backgroundColor('#FF5A79')
      }
      .width('84%')
      .height(8)
      .borderRadius(4)
      .backgroundColor('#EDE7F8')

      Column({ space: 10 }) {
        Row({ space: 0 }) {
          ForEach([22, 40, 30, 46, 26, 38, 20, 44, 28, 34, 24, 42], (h: number) => {
            Column()
              .width(9)
              .height(h)
              .borderRadius(3)
              .backgroundColor(h > 36 ? '#FF5A79' : '#00C2A8')
              .margin({ left: 3, right: 3 })
          }, (h: number) => h.toString())
        }
        .height(50)
        .alignItems(VerticalAlign.Bottom)
        .justifyContent(FlexAlign.Center)

        Text('🔊 听前奏,猜歌名')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1D2129')
      }
      .width('100%')

      Column({ space: 10 }) {
        ForEach(OPTION_DEMO, (opt: string, i: number) => {
          Row({ space: 10 }) {
            Text(String.fromCharCode(65 + i))
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor(this.choiceIndex === i ? '#FFFFFF' : '#9AA0B4')
              .width(24)
              .height(24)
              .borderRadius(12)
              .backgroundColor(this.choiceIndex === i ? '#FF5A79' : '#EDE7F8')
              .textAlign(TextAlign.Center)
            Text(opt)
              .fontSize(14)
              .fontColor(this.choiceIndex === i ? '#FFFFFF' : '#1D2129')
              .layoutWeight(1)
            if (this.choiceIndex === i) {
              Text('●')
                .fontSize(12)
                .fontColor('#FFFFFF')
            }
          }
          .width('100%')
          .padding({ left: 14, right: 14, top: 12, bottom: 12 })
          .backgroundColor(this.choiceIndex === i ? '#FF5A79' : '#F5F7FF')
          .borderRadius(14)
          .onClick(() => {
            this.choiceIndex = i;
            this.showResult = true;
          })
        }, (opt: string) => opt)
      }
      .width('86%')
      .margin({ bottom: 22 })
    }
    .width('92%')
    .backgroundColor('#FFFFFF')
    .borderRadius(22)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#EDE7F8' })
  }

  @Builder resultModal() {
    Column({ space: 12 }) {
      Text('✓')
        .fontSize(52)
        .fontColor('#00C2A8')
        .width(90)
        .height(90)
        .borderRadius(45)
        .backgroundColor('rgba(0,194,168,0.1)')
        .textAlign(TextAlign.Center)
        .scale({ x: 1.15, y: 1.15 })
        .animation({ duration: 400, curve: Curve.EaseOut })
        .margin({ top: 24 })

      Text('回答正确!')
        .fontSize(19)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1D2129')
      Text('《晴天》 · 周杰伦')
        .fontSize(12)
        .fontColor('#9AA0B4')
      Row({ space: 20 }) {
        Column({ space: 2 }) {
          Text('+120')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFB800')
          Text('本题得分')
            .fontSize(10)
            .fontColor('#9AA0B4')
        }
        .alignItems(HorizontalAlign.Center)

        Column({ space: 2 }) {
          Text('+15')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FF5A79')
          Text('金币')
            .fontSize(10)
            .fontColor('#9AA0B4')
        }
        .alignItems(HorizontalAlign.Center)

        Column({ space: 2 }) {
          Text('2.4s')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#00C2A8')
          Text('用时')
            .fontSize(10)
            .fontColor('#9AA0B4')
        }
        .alignItems(HorizontalAlign.Center)
      }

      Text('🔥 连对 5 题 · 火焰加成 ×1.5')
        .fontSize(11)
        .fontColor('#FF5A79')
        .padding({ left: 12, right: 12, top: 4, bottom: 4 })
        .backgroundColor('rgba(255,90,121,0.08)')
        .borderRadius(10)

      Button() {
        Text('下一题 ›')
          .fontSize(15)
          .fontColor('#FFFFFF')
      }
      .padding({ left: 44, right: 44, top: 11, bottom: 11 })
      .backgroundColor('#FF5A79')
      .borderRadius(26)
      .margin({ top: 6, bottom: 24 })
      .onClick(() => {
        this.showResult = false;
        this.showQuizModal = false;
        this.choiceIndex = -1;
      })
    }
    .width('86%')
    .backgroundColor('#FFFFFF')
    .borderRadius(20)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#EDE7F8' })
  }

  @Builder levelCard(l: QuestLevel) {
    Row({ space: 12 }) {
      Column() {
        Text(l.emoji)
          .fontSize(24)
          .scale(l.unlocked ? { x: 1, y: 1 } : { x: 0.9, y: 0.9 })
      }
      .width(54)
      .height(54)
      .borderRadius(18)
      .backgroundColor(l.unlocked ? 'rgba(255,184,0,0.12)' : '#EDE7F8')
      .justifyContent(FlexAlign.Center)

      Column({ space: 4 }) {
        Row({ space: 8 }) {
          Text(l.name)
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(l.unlocked ? '#1D2129' : '#9AA0B4')
          Text(starRow(l.stars))
            .fontSize(12)
            .fontColor('#FFB800')
        }
        Text(l.theme + ' · ' + l.total + ' 题')
          .fontSize(10)
          .fontColor('#9AA0B4')
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)

      Text(l.unlocked ? '▶' : '🔒')
        .fontSize(15)
        .fontColor(l.unlocked ? '#FFFFFF' : '#9AA0B4')
        .width(34)
        .height(34)
        .borderRadius(17)
        .backgroundColor(l.unlocked ? '#FF5A79' : '#EDE7F8')
        .textAlign(TextAlign.Center)
    }
    .width('100%')
    .padding(14)
    .backgroundColor('#FFFFFF')
    .borderRadius(16)
    .margin({ bottom: 10 })
    .onClick(() => {
      if (l.unlocked) {
        this.selectedLevel = l;
        this.showLevelModal = true;
      }
    })
  }

  build() {
    Stack({ alignContent: Alignment.Center }) {
      Scroll() {
        Column() {
          Column({ space: 8 }) {
            Row({ space: 10 }) {
              Text('🔥')
                .fontSize(24)
              Column({ space: 2 }) {
                Text('今日闯关进度')
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1D2129')
                Text('已通过 5 题 · 距离日目标还差 5 题')
                  .fontSize(10)
                  .fontColor('#9AA0B4')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              Text('50%')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FF5A79')
            }
            .width('100%')

            Column() {
              Column()
                .width('50%')
                .height(10)
                .borderRadius(5)
                .backgroundColor('#FF5A79')
            }
            .width('100%')
            .height(10)
            .borderRadius(5)
            .backgroundColor('#EDE7F8')
          }
          .width('100%')
          .padding(16)
          .backgroundColor('#FFFFFF')
          .borderRadius(16)
          .margin({ left: 16, right: 16, top: 14 })

          Row({ space: 10 }) {
            Text('🗺 关卡地图')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1D2129')
              .layoutWeight(1)
            Text('总星数 14/36')
              .fontSize(11)
              .fontColor('#FFB800')
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 18, bottom: 10 })

          Column() {
            ForEach(QUEST_LEVELS, (l: QuestLevel) => {
              this.levelCard(l)
            }, (l: QuestLevel) => l.id.toString())
          }
          .width('100%')
          .padding({ left: 16, right: 16, bottom: 20 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .width('100%')

      if (this.showLevelModal) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showLevelModal = false;
            })
          this.levelModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(29,33,41,0.5)')
      }

      if (this.showQuizModal) {
        Column() {
          this.quizModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(29,33,41,0.6)')
        .justifyContent(FlexAlign.Center)
      }

      if (this.showResult) {
        Column() {
          this.resultModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(29,33,41,0.65)')
        .justifyContent(FlexAlign.Center)
      }
    }
    .width('100%')
    .height('100%')
  }
}
// ============ 对战 Tab(房间列表 + 邀请/匹配) ============
@Component
struct BattleContent {
  @State showRoomModal: boolean = false
  @State selectedRoom: BattleRoom | null = null
  @State showMatchModal: boolean = false

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Column()
        .width('100%')
        .layoutWeight(1)
        .onClick(() => {
          onClose();
        })
      Column() {
        Text('⚔️')
          .fontSize(30)
          .margin({ bottom: 8 })
        Text('对战演示')
          .fontSize(12)
          .fontColor('#9AA0B4')
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }

  @Builder roomModal() {
    Column({ space: 12 }) {
      Text(this.selectedRoom!.name)
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1D2129')
        .margin({ top: 20 })

      Text(this.selectedRoom!.mode)
        .fontSize(11)
        .fontColor('#9AA0B4')
      Row({ space: 10 }) {
        Text(this.selectedRoom!.stake)
          .fontSize(10)
          .fontColor('#FFB800')
          .padding({ left: 10, right: 10, top: 3, bottom: 3 })
          .backgroundColor('rgba(255,184,0,0.12)')
          .borderRadius(4)
        if (this.selectedRoom!.hot) {
          Text('热门')
            .fontSize(10)
            .fontColor('#FF5A79')
            .padding({ left: 10, right: 10, top: 3, bottom: 3 })
            .backgroundColor('rgba(255,90,121,0.08)')
            .borderRadius(4)
        }

        Text(this.selectedRoom!.players + '/' + this.selectedRoom!.maxPlayers + ' 人')
          .fontSize(10)
          .fontColor('#9AA0B4')
          .padding({ left: 10, right: 10, top: 3, bottom: 3 })
          .backgroundColor('#F5F7FF')
          .borderRadius(4)
      }

      Column({ space: 8 }) {
        Row({ space: 10 }) {
          Column() {
            Text('🧑‍🎤')
              .fontSize(18)
          }
          .width(40)
          .height(40)
          .borderRadius(20)
          .backgroundColor('rgba(0,194,168,0.1)')
          .justifyContent(FlexAlign.Center)
          Text('修琴的猫')
            .fontSize(12)
            .fontColor('#1D2129')
            .layoutWeight(1)
          Text('胜率 89%')
            .fontSize(10)
            .fontColor('#00C2A8')
        }
        .width('100%')

        Row({ space: 10 }) {
          Column() {
            Text('👩‍🎤')
              .fontSize(18)
          }
          .width(40)
          .height(40)
          .borderRadius(20)
          .backgroundColor('rgba(255,90,121,0.1)')
          .justifyContent(FlexAlign.Center)
          Text('(等待加入)')
            .fontSize(12)
            .fontColor('#9AA0B4')
            .layoutWeight(1)
        }
        .width('100%')
      }
      .width('86%')
      .padding(12)
      .backgroundColor('#F5F7FF')
      .borderRadius(14)

      Text('奖励:胜者金币 ×200 · 败者金币 ×50')
        .fontSize(10)
        .fontColor('#9AA0B4')

      Row({ space: 12 }) {
        Button() {
          Text('邀请好友')
            .fontSize(13)
            .fontColor('#FF5A79')
        }
        .padding({ left: 18, right: 18, top: 9, bottom: 9 })
        .backgroundColor('rgba(255,90,121,0.08)')
        .borderRadius(20)

        Button() {
          Text('快速加入')
            .fontSize(13)
            .fontColor('#FFFFFF')
        }
        .padding({ left: 18, right: 18, top: 9, bottom: 9 })
        .backgroundColor('#FF5A79')
        .borderRadius(20)
        .onClick(() => {
          this.showRoomModal = false;
          this.showMatchModal = true;
        })
      }
      .margin({ bottom: 20 })
    }
    .width('88%')
    .backgroundColor('#FFFFFF')
    .borderRadius(20)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#EDE7F8' })
  }

  @Builder matchModal() {
    Column({ space: 14 }) {
      Text('⚡')
        .fontSize(46)
        .scale({ x: 1.2, y: 1.2 })
        .animation({ duration: 600, curve: Curve.EaseOut })
        .margin({ top: 26 })

      Text('正在匹配对手…')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1D2129')
      Text('当前段位:黄金唱针 · 匹配区间 ±100 分')
        .fontSize(11)
        .fontColor('#9AA0B4')
      Column() {
        Column()
          .width('70%')
          .height(8)
          .borderRadius(4)
          .backgroundColor('#00C2A8')
      }
      .width('70%')
      .height(8)
      .borderRadius(4)
      .backgroundColor('#EDE7F8')
      .margin({ top: 4 })

      Text('已用时 3.2 秒 · 平均等待 8 秒')
        .fontSize(10)
        .fontColor('#9AA0B4')

      Button() {
        Text('取消匹配')
          .fontSize(13)
          .fontColor('#9AA0B4')
      }
      .padding({ left: 26, right: 26, top: 9, bottom: 9 })
      .backgroundColor('#F5F7FF')
      .borderRadius(20)
      .margin({ bottom: 22 })
      .onClick(() => {
        this.showMatchModal = false;
      })
    }
    .width('80%')
    .backgroundColor('#FFFFFF')
    .borderRadius(20)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#EDE7F8' })
  }

  build() {
    Stack({ alignContent: Alignment.Center }) {
      Scroll() {
        Column() {
          Row({ space: 10 }) {
            Column() {
              Text('⚔️')
                .fontSize(26)
            }
            .width(54)
            .height(54)
            .borderRadius(18)
            .backgroundColor('rgba(255,90,121,0.1)')
            .justifyContent(FlexAlign.Center)

            Column({ space: 3 }) {
              Text('实时对战')
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1D2129')
              Text('今日战绩 6胜2负 · 连胜 +3')
                .fontSize(10)
                .fontColor('#9AA0B4')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text('开始匹配')
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
              .padding({ left: 16, right: 16, top: 8, bottom: 8 })
              .backgroundColor('#FF5A79')
              .borderRadius(18)
              .scale({ x: 1.04, y: 1.04 })
              .animation({ duration: 300, curve: Curve.EaseOut })
              .onClick(() => {
                this.showMatchModal = true;
              })
          }
          .width('100%')
          .padding(14)
          .backgroundColor('#FFFFFF')
          .borderRadius(16)
          .margin({ left: 16, right: 16, top: 14 })

          Row({ space: 8 }) {
            Text('全部')
              .fontSize(11)
              .fontColor('#FFFFFF')
              .fontWeight(FontWeight.Bold)
              .padding({ left: 14, right: 14, top: 6, bottom: 6 })
              .backgroundColor('#FF5A79')
              .borderRadius(16)
            Text('1v1')
              .fontSize(11)
              .fontColor('#9AA0B4')
              .padding({ left: 14, right: 14, top: 6, bottom: 6 })
              .backgroundColor('#FFFFFF')
              .borderRadius(16)
            Text('多人')
              .fontSize(11)
              .fontColor('#9AA0B4')
              .padding({ left: 14, right: 14, top: 6, bottom: 6 })
              .backgroundColor('#FFFFFF')
              .borderRadius(16)
            Text('免费场')
              .fontSize(11)
              .fontColor('#9AA0B4')
              .padding({ left: 14, right: 14, top: 6, bottom: 6 })
              .backgroundColor('#FFFFFF')
              .borderRadius(16)
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 14 })

          Column({ space: 8 }) {
            ForEach(BATTLE_ROOMS, (r: BattleRoom) => {
              Row({ space: 12 }) {
                Column() {
                  Text(r.mode.split(' · ')[0] === '1v1' ? '⚔️' : '👥')
                    .fontSize(20)
                }
                .width(46)
                .height(46)
                .borderRadius(14)
                .backgroundColor(r.hot ? 'rgba(255,90,121,0.1)' : 'rgba(0,194,168,0.1)')
                .justifyContent(FlexAlign.Center)

                Column({ space: 4 }) {
                  Row({ space: 6 }) {
                    Text(r.name)
                      .fontSize(13)
                      .fontWeight(FontWeight.Bold)
                      .fontColor('#1D2129')
                    if (r.hot) {
                      Text('HOT')
                        .fontSize(8)
                        .fontColor('#FF5A79')
                        .padding({ left: 5, right: 5, top: 1, bottom: 1 })
                        .backgroundColor('rgba(255,90,121,0.1)')
                        .borderRadius(3)
                    }
                  }
                  Text(r.mode)
                    .fontSize(10)
                    .fontColor('#9AA0B4')
                  Row({ space: 8 }) {
                    Text('👥 ' + r.players + '/' + r.maxPlayers)
                      .fontSize(9)
                      .fontColor('#00C2A8')
                    Text(r.stake)
                      .fontSize(9)
                      .fontColor('#FFB800')
                  }
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)

                Text('进入')
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#FFFFFF')
                  .padding({ left: 14, right: 14, top: 6, bottom: 6 })
                  .backgroundColor('#00C2A8')
                  .borderRadius(14)
              }
              .width('100%')
              .padding(12)
              .backgroundColor('#FFFFFF')
              .borderRadius(14)
              .onClick(() => {
                this.selectedRoom = r;
                this.showRoomModal = true;
              })
            }, (r: BattleRoom) => r.id.toString())
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 10, bottom: 20 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .width('100%')

      if (this.showRoomModal) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showRoomModal = false;
            })
          this.roomModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(29,33,41,0.55)')
      }

      if (this.showMatchModal) {
        Column() {
          this.matchModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(29,33,41,0.6)')
        .justifyContent(FlexAlign.Center)
      }
    }
    .width('100%')
    .height('100%')
  }
}
// ============ 题库 Tab(分类列表 + 详情) ============
@Component
struct TopicContent {
  @State showTopicModal: boolean = false
  @State selectedTopic: TopicEntry | null = null
  @State showRankRule: boolean = false

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Column()
        .width('100%')
        .layoutWeight(1)
        .onClick(() => {
          onClose();
        })
      Column() {
        Text('📚')
          .fontSize(30)
          .margin({ bottom: 8 })
        Text('题库演示')
          .fontSize(12)
          .fontColor('#9AA0B4')
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }

  @Builder topicModal() {
    Column({ space: 12 }) {
      Row({ space: 12 }) {
        Column() {
          Text(this.selectedTopic!.icon)
            .fontSize(24)
        }
        .width(56)
        .height(56)
        .borderRadius(18)
        .backgroundColor('rgba(255,184,0,0.12)')
        .justifyContent(FlexAlign.Center)

        Column({ space: 3 }) {
          Text(this.selectedTopic!.name)
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1D2129')
          Text(this.selectedTopic!.count + ' 首题目可选')
            .fontSize(10)
            .fontColor('#9AA0B4')
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
      }
      .width('100%')
      .padding({ left: 20, right: 20, top: 20 })

      Column() {
        Column()
          .width((this.selectedTopic!.played / this.selectedTopic!.count * 100).toFixed(0) + '%')
          .height(10)
          .borderRadius(5)
          .backgroundColor('#00C2A8')
      }
      .width('84%')
      .height(10)
      .borderRadius(5)
      .backgroundColor('#EDE7F8')

      Text('已刷 ' + this.selectedTopic!.played + '/' + this.selectedTopic!.count + ' · 正确率 ' + this.selectedTopic!.accuracy)
        .fontSize(10)
        .fontColor('#9AA0B4')

      Column({ space: 8 }) {
        Text('题库规则')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1D2129')
        Row({ space: 8 }) {
          Text('·')
            .fontSize(11)
            .fontColor('#FF5A79')
          Text('随机抽题,前奏 1-5 秒起播')
            .fontSize(11)
            .fontColor('#9AA0B4')
        }
        .width('84%')

        Row({ space: 8 }) {
          Text('·')
            .fontSize(11)
            .fontColor('#FF5A79')
          Text('四种题型轮换:歌名 / 歌手 / 歌词 / 专辑')
            .fontSize(11)
            .fontColor('#9AA0B4')
        }
        .width('84%')

        Row({ space: 8 }) {
          Text('·')
            .fontSize(11)
            .fontColor('#FF5A79')
          Text('全部答对可解锁隐藏称号')
            .fontSize(11)
            .fontColor('#9AA0B4')
        }
        .width('84%')
      }
      .width('100%')
      .alignItems(HorizontalAlign.Center)

      Row({ space: 12 }) {
        Button() {
          Text('收藏题库')
            .fontSize(13)
            .fontColor('#FFB800')
        }
        .padding({ left: 18, right: 18, top: 9, bottom: 9 })
        .backgroundColor('rgba(255,184,0,0.1)')
        .borderRadius(20)

        Button() {
          Text('开始刷题')
            .fontSize(13)
            .fontColor('#FFFFFF')
        }
        .padding({ left: 18, right: 18, top: 9, bottom: 9 })
        .backgroundColor('#00C2A8')
        .borderRadius(20)
        .onClick(() => {
          this.showTopicModal = false;
        })
      }
      .margin({ bottom: 20 })
    }
    .width('90%')
    .backgroundColor('#FFFFFF')
    .borderRadius(20)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#EDE7F8' })
  }

  build() {
    Stack({ alignContent: Alignment.Center }) {
      Scroll() {
        Column() {
          Row({ space: 10 }) {
            Text('📚 全部题库 ' + TOPIC_LIST.length + ' 个')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1D2129')
              .layoutWeight(1)
            Text('刷题榜 ›')
              .fontSize(11)
              .fontColor('#9AA0B4')
              .onClick(() => {
                this.showRankRule = true;
              })
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 14, bottom: 10 })

          Column({ space: 8 }) {
            ForEach(TOPIC_LIST, (t: TopicEntry) => {
              Row({ space: 12 }) {
                Column() {
                  Text(t.icon)
                    .fontSize(20)
                }
                .width(48)
                .height(48)
                .borderRadius(14)
                .backgroundColor('rgba(0,194,168,0.08)')
                .justifyContent(FlexAlign.Center)

                Column({ space: 4 }) {
                  Text(t.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#1D2129')
                  Text(t.count + ' 题可选')
                    .fontSize(10)
                    .fontColor('#9AA0B4')
                  Row({ space: 8 }) {
                    Text('已刷 ' + t.played)
                      .fontSize(9)
                      .fontColor('#00C2A8')
                    Text('正确率 ' + t.accuracy)
                      .fontSize(9)
                      .fontColor('#FFB800')
                  }
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)

                Text('刷题 ›')
                  .fontSize(10)
                  .fontColor('#FF5A79')
                  .padding({ left: 10, right: 10, top: 5, bottom: 5 })
                  .backgroundColor('rgba(255,90,121,0.08)')
                  .borderRadius(12)
              }
              .width('100%')
              .padding(12)
              .backgroundColor('#FFFFFF')
              .borderRadius(14)
              .onClick(() => {
                this.selectedTopic = t;
                this.showTopicModal = true;
              })
            }, (t: TopicEntry) => t.id.toString())
          }
          .width('100%')
          .padding({ left: 16, right: 16, bottom: 20 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .width('100%')

      if (this.showTopicModal) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showTopicModal = false;
            })
          this.topicModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(29,33,41,0.5)')
      }

      if (this.showRankRule) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showRankRule = false;
            })
          Column({ space: 10 }) {
            Text('🏅 刷题榜规则')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1D2129')
              .margin({ top: 20 })

            Column({ space: 8 }) {
              Row({ space: 8 }) {
                Text('1.')
                  .fontSize(11)
                  .fontColor('#FF5A79')
                Text('每答对一题积 1 分,答错不扣')
                  .fontSize(11)
                  .fontColor('#9AA0B4')
              }
              .width('84%')

              Row({ space: 8 }) {
                Text('2.')
                  .fontSize(11)
                  .fontColor('#FF5A79')
                Text('同一题库当日重复答题仅计一次')
                  .fontSize(11)
                  .fontColor('#9AA0B4')
              }
              .width('84%')

              Row({ space: 8 }) {
                Text('3.')
                  .fontSize(11)
                  .fontColor('#FF5A79')
                Text('每周一 0 点清榜并发放奖励')
                  .fontSize(11)
                  .fontColor('#9AA0B4')
              }
              .width('84%')
            }

            Button() {
              Text('知道了')
                .fontSize(13)
                .fontColor('#FFFFFF')
            }
            .padding({ left: 30, right: 30, top: 9, bottom: 9 })
            .backgroundColor('#FF5A79')
            .borderRadius(20)
            .margin({ bottom: 20 })
            .onClick(() => {
              this.showRankRule = false;
            })
          }
          .width('84%')
          .backgroundColor('#FFFFFF')
          .borderRadius(20)
          .alignItems(HorizontalAlign.Center)
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(29,33,41,0.5)')
      }
    }
    .width('100%')
    .height('100%')
  }
}

// ============ 排行 Tab(颁奖台 + 榜单) ============
@Component
struct RankContent {
  @State showTierModal: boolean = false

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Column()
        .width('100%')
        .layoutWeight(1)
        .onClick(() => {
          onClose();
        })
      Column() {
        Text('🏆')
          .fontSize(30)
          .margin({ bottom: 8 })
        Text('排行演示')
          .fontSize(12)
          .fontColor('#9AA0B4')
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }

  @Builder tierModal() {
    Column({ space: 12 }) {
      Text('🎖 段位说明')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1D2129')
        .margin({ top: 20 })

      Column({ space: 10 }) {
        Row({ space: 10 }) {
          Text('🥉')
            .fontSize(18)
          Column({ space: 2 }) {
            Text('青铜耳塞 · 0-999')
              .fontSize(12)
              .fontColor('#1D2129')
            Text('入场即达,试试手感')
              .fontSize(9)
              .fontColor('#9AA0B4')
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Text('已通过')
            .fontSize(9)
            .fontColor('#00C2A8')
        }
        .width('100%')

        Row({ space: 10 }) {
          Text('🥈')
            .fontSize(18)
          Column({ space: 2 }) {
            Text('黄金唱针 · 1000-1799')
              .fontSize(12)
              .fontColor('#1D2129')
            Text('当前段位,胜率 55% 可晋升')
              .fontSize(9)
              .fontColor('#9AA0B4')
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Text('进行中')
            .fontSize(9)
            .fontColor('#FFB800')
        }
        .width('100%')

        Row({ space: 10 }) {
          Text('🥇')
            .fontSize(18)
          Column({ space: 2 }) {
            Text('钻石音叉 · 1800-2499')
              .fontSize(12)
              .fontColor('#1D2129')
            Text('前 10% 的耳朵在这里')
              .fontSize(9)
              .fontColor('#9AA0B4')
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Text('未达成')
            .fontSize(9)
            .fontColor('#9AA0B4')
        }
        .width('100%')

        Row({ space: 10 }) {
          Text('👑')
            .fontSize(18)
          Column({ space: 2 }) {
            Text('王者金耳 · 2500+')
              .fontSize(12)
              .fontColor('#1D2129')
            Text('全服不超过 500 人')
              .fontSize(9)
              .fontColor('#9AA0B4')
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Text('未达成')
            .fontSize(9)
            .fontColor('#9AA0B4')
        }
        .width('100%')
      }
      .width('86%')

      Button() {
        Text('继续加油')
          .fontSize(13)
          .fontColor('#FFFFFF')
      }
      .padding({ left: 30, right: 30, top: 9, bottom: 9 })
      .backgroundColor('#FF5A79')
      .borderRadius(20)
      .margin({ bottom: 20 })
      .onClick(() => {
        this.showTierModal = false;
      })
    }
    .width('88%')
    .backgroundColor('#FFFFFF')
    .borderRadius(20)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#EDE7F8' })
  }

  build() {
    Stack({ alignContent: Alignment.Center }) {
      Scroll() {
        Column() {
          Row({ space: 10 }) {
            Text('🏆 全国猜歌榜')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1D2129')
              .layoutWeight(1)
            Text('周榜')
              .fontSize(11)
              .fontColor('#FF5A79')
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .backgroundColor('rgba(255,90,121,0.08)')
              .borderRadius(12)
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 14 })

          Row({  }) {
            Column({ space: 6 }) {
              Column() {
                Text('🐱')
                  .fontSize(22)
              }
              .width(52)
              .height(52)
              .borderRadius(26)
              .backgroundColor('rgba(0,194,168,0.1)')
              .justifyContent(FlexAlign.Center)
              Text('修琴的猫')
                .fontSize(10)
                .fontColor('#1D2129')
              Text('26890')
                .fontSize(10)
                .fontWeight(FontWeight.Bold)
                .fontColor('#9AA0B4')
            }
            .alignItems(HorizontalAlign.Center)
            .layoutWeight(1)

            Column({ space: 6 }) {
              Text('🥇')
                .fontSize(22)
              Column() {
                Text('🤖')
                  .fontSize(26)
              }
              .width(62)
              .height(62)
              .borderRadius(31)
              .backgroundColor('rgba(255,184,0,0.15)')
              .justifyContent(FlexAlign.Center)
              .scale({ x: 1.1, y: 1.1 })
              .animation({ duration: 300, curve: Curve.EaseOut })
              Text('人形听歌机')
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1D2129')
              Text('28410')
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFB800')
            }
            .alignItems(HorizontalAlign.Center)
            .layoutWeight(1)

            Column({ space: 6 }) {
              Column() {
                Text('🎧')
                  .fontSize(22)
              }
              .width(52)
              .height(52)
              .borderRadius(26)
              .backgroundColor('rgba(255,90,121,0.1)')
              .justifyContent(FlexAlign.Center)
              Text('野生DJ')
                .fontSize(10)
                .fontColor('#1D2129')
              Text('25320')
                .fontSize(10)
                .fontWeight(FontWeight.Bold)
                .fontColor('#9AA0B4')
            }
            .alignItems(HorizontalAlign.Center)
            .layoutWeight(1)
          }
          .width('100%')
          .padding({ top: 20, bottom: 16 })

          Column() {
            ForEach(RANK_PLAYERS, (p: RankPlayer) => {
              if (p.rank > 3) {
                Row({ space: 12 }) {
                  Text(p.rank.toString())
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#9AA0B4')
                    .width(26)
                  Column() {
                    Text(p.avatar)
                      .fontSize(16)
                  }
                  .width(36)
                  .height(36)
                  .borderRadius(18)
                  .backgroundColor('#F5F7FF')
                  .justifyContent(FlexAlign.Center)

                  Column({ space: 2 }) {
                    Text(p.name)
                      .fontSize(12)
                      .fontWeight(FontWeight.Medium)
                      .fontColor('#1D2129')
                    Row({ space: 8 }) {
                      Text('胜率 ' + p.wins)
                        .fontSize(9)
                        .fontColor('#00C2A8')
                      Text('🔥 连胜 ' + p.streak)
                        .fontSize(9)
                        .fontColor('#FF5A79')
                    }
                  }
                  .alignItems(HorizontalAlign.Start)
                  .layoutWeight(1)

                  Text(p.score.toString())
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#FFB800')
                }
                .width('100%')
                .padding({ left: 14, right: 14, top: 9, bottom: 9 })
                .backgroundColor('#FFFFFF')
                .borderRadius(12)
                .margin({ bottom: 6 })
              }
            }, (p: RankPlayer) => p.rank.toString())
          }
          .width('100%')
          .padding({ left: 16, right: 16 })

          Button() {
            Text('🎖 段位说明')
              .fontSize(12)
              .fontColor('#FF5A79')
          }
          .padding({ left: 22, right: 22, top: 9, bottom: 9 })
          .backgroundColor('rgba(255,90,121,0.08)')
          .borderRadius(20)
          .margin({ top: 10, bottom: 24 })
          .onClick(() => {
            this.showTierModal = true;
          })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .width('100%')

      if (this.showTierModal) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showTierModal = false;
            })
          this.tierModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(29,33,41,0.5)')
      }
    }
    .width('100%')
    .height('100%')
  }
}
// ============ 商城 Tab(道具网格 + 购买确认) ============
@Component
struct ShopContent {
  @State typeIndex: number = 0
  @State showGoodsModal: boolean = false
  @State selectedGoods: ShopGoods | null = null
  @State showBuyToast: boolean = false

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Column()
        .width('100%')
        .layoutWeight(1)
        .onClick(() => {
          onClose();
        })
      Column() {
        Text('🛍')
          .fontSize(30)
          .margin({ bottom: 8 })
        Text('商城演示')
          .fontSize(12)
          .fontColor('#9AA0B4')
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }

  @Builder goodsModal() {
    Column({ space: 12 }) {
      Text(this.selectedGoods!.icon)
        .fontSize(46)
        .margin({ top: 22 })

      Text(this.selectedGoods!.name)
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1D2129')
      Text(this.selectedGoods!.desc)
        .fontSize(11)
        .fontColor('#9AA0B4')
      Row({ space: 10 }) {
        Text(this.selectedGoods!.type)
          .fontSize(10)
          .fontColor('#00C2A8')
          .padding({ left: 10, right: 10, top: 3, bottom: 3 })
          .backgroundColor('rgba(0,194,168,0.1)')
          .borderRadius(4)
        if (this.selectedGoods!.owned) {
          Text('已拥有')
            .fontSize(10)
            .fontColor('#FFB800')
            .padding({ left: 10, right: 10, top: 3, bottom: 3 })
            .backgroundColor('rgba(255,184,0,0.12)')
            .borderRadius(4)
        }
      }

      Column({ space: 6 }) {
        Row() {
          Text('单价')
            .fontSize(11)
            .fontColor('#9AA0B4')
          Text('💰 ' + this.selectedGoods!.price)
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFB800')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('我的金币')
            .fontSize(11)
            .fontColor('#9AA0B4')
          Text('💰 2,860')
            .fontSize(13)
            .fontColor('#1D2129')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)

        Row() {
          Text('购买后余额')
            .fontSize(11)
            .fontColor('#9AA0B4')
          Text('💰 ' + (2860 - this.selectedGoods!.price))
            .fontSize(13)
            .fontColor('#FF5A79')
        }
        .width('84%')
        .justifyContent(FlexAlign.SpaceBetween)
      }

      Row({ space: 12 }) {
        Button() {
          Text('取消')
            .fontSize(13)
            .fontColor('#9AA0B4')
        }
        .padding({ left: 20, right: 20, top: 9, bottom: 9 })
        .backgroundColor('#F5F7FF')
        .borderRadius(20)
        .onClick(() => {
          this.showGoodsModal = false;
        })

        Button() {
          Text(this.selectedGoods!.owned ? '已拥有' : '确认购买')
            .fontSize(13)
            .fontColor('#FFFFFF')
        }
        .padding({ left: 20, right: 20, top: 9, bottom: 9 })
        .backgroundColor(this.selectedGoods!.owned ? '#9AA0B4' : '#FF5A79')
        .borderRadius(20)
        .onClick(() => {
          if (!this.selectedGoods!.owned) {
            this.showGoodsModal = false;
            this.showBuyToast = true;
          }
        })
      }
      .margin({ bottom: 22 })
    }
    .width('86%')
    .backgroundColor('#FFFFFF')
    .borderRadius(20)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#EDE7F8' })
  }

  @Builder buyToast() {
    Column({ space: 8 }) {
      Text('✓')
        .fontSize(34)
        .fontColor('#00C2A8')
        .scale({ x: 1.25, y: 1.25 })
        .animation({ duration: 400, curve: Curve.EaseOut })
      Text('购买成功')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1D2129')
      Text('道具已放入背包,可在「我的-背包」查看')
        .fontSize(10)
        .fontColor('#9AA0B4')
    }
    .width('62%')
    .padding({ top: 22, bottom: 22 })
    .backgroundColor('#FFFFFF')
    .borderRadius(18)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#EDE7F8' })
  }

  build() {
    Stack({ alignContent: Alignment.Center }) {
      Column() {
        Row({ space: 8 }) {
          Text('全部')
            .fontSize(11)
            .fontColor(this.typeIndex === 0 ? '#FFFFFF' : '#9AA0B4')
            .fontWeight(FontWeight.Bold)
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .backgroundColor(this.typeIndex === 0 ? '#FF5A79' : '#FFFFFF')
            .borderRadius(16)
            .onClick(() => {
              this.typeIndex = 0;
            })
          Text('道具')
            .fontSize(11)
            .fontColor(this.typeIndex === 1 ? '#FFFFFF' : '#9AA0B4')
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .backgroundColor(this.typeIndex === 1 ? '#FF5A79' : '#FFFFFF')
            .borderRadius(16)
            .onClick(() => {
              this.typeIndex = 1;
            })
          Text('装扮')
            .fontSize(11)
            .fontColor(this.typeIndex === 2 ? '#FFFFFF' : '#9AA0B4')
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .backgroundColor(this.typeIndex === 2 ? '#FF5A79' : '#FFFFFF')
            .borderRadius(16)
            .onClick(() => {
              this.typeIndex = 2;
            })
          Text('礼包')
            .fontSize(11)
            .fontColor(this.typeIndex === 3 ? '#FFFFFF' : '#9AA0B4')
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .backgroundColor(this.typeIndex === 3 ? '#FF5A79' : '#FFFFFF')
            .borderRadius(16)
            .onClick(() => {
              this.typeIndex = 3;
            })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12 })

        Scroll() {
          Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
            ForEach(SHOP_GOODS, (g: ShopGoods) => {
              if (this.typeIndex === 0 || g.type === ['道具', '装扮', '礼包'][this.typeIndex - 1]) {
                Column({ space: 8 }) {
                  Column() {
                    Text(g.icon)
                      .fontSize(28)
                  }
                  .width('100%')
                  .height(64)
                  .borderRadius(14)
                  .backgroundColor('rgba(255,184,0,0.1)')
                  .justifyContent(FlexAlign.Center)

                  Text(g.name)
                    .fontSize(11)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#1D2129')
                    .maxLines(1)
                  Text(g.desc)
                    .fontSize(9)
                    .fontColor('#9AA0B4')
                    .maxLines(1)
                  Row({ space: 6 }) {
                    Text('💰 ' + g.price)
                      .fontSize(12)
                      .fontWeight(FontWeight.Bold)
                      .fontColor('#FFB800')
                    if (g.owned) {
                      Text('已拥有')
                        .fontSize(8)
                        .fontColor('#00C2A8')
                        .padding({ left: 5, right: 5, top: 1, bottom: 1 })
                        .backgroundColor('rgba(0,194,168,0.1)')
                        .borderRadius(3)
                    }
                  }
                }
                .width('48%')
                .padding(10)
                .backgroundColor('#FFFFFF')
                .borderRadius(16)
                .margin({ bottom: 10 })
                .onClick(() => {
                  this.selectedGoods = g;
                  this.showGoodsModal = true;
                })
              }
            }, (g: ShopGoods) => g.id.toString() + this.typeIndex.toString())
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 20 })
        }
        .scrollBar(BarState.Off)
        .layoutWeight(1)
        .width('100%')
      }
      .width('100%')
      .height('100%')

      if (this.showGoodsModal) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showGoodsModal = false;
            })
          this.goodsModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(29,33,41,0.55)')
      }

      if (this.showBuyToast) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showBuyToast = false;
            })
          this.buyToast()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(29,33,41,0.5)')
      }
    }
    .width('100%')
    .height('100%')
  }
}

// ============ 我的 Tab(签到 + 统计 + 设置) ============
@Component
struct MeContent {
  @State showSignModal: boolean = false
  @State signIndex: number = 4
  @State showSettingModal: boolean = false
  @State soundOn: boolean = true

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Column()
        .width('100%')
        .layoutWeight(1)
        .onClick(() => {
          onClose();
        })
      Column() {
        Text('👤')
          .fontSize(30)
          .margin({ bottom: 8 })
        Text('我的演示')
          .fontSize(12)
          .fontColor('#9AA0B4')
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }

  @Builder signModal() {
    Column({ space: 14 }) {
      Text('📅 每日签到')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1D2129')
        .margin({ top: 20 })

      Text('已连签 4 天 · 今天是第 5 天')
        .fontSize(11)
        .fontColor('#9AA0B4')
      Row({ space: 8 }) {
        ForEach(SIGN_DAYS, (s: string, i: number) => {
          Column({ space: 5 }) {
            Text('第' + (i + 1) + '天')
              .fontSize(8)
              .fontColor('#9AA0B4')
            Text(i < this.signIndex ? '✓' : (i === this.signIndex ? '🎁' : s))
              .fontSize(13)
              .fontColor(i < this.signIndex ? '#00C2A8' : (i === this.signIndex ? '#FF5A79' : '#FFB800'))
            Text(s)
              .fontSize(8)
              .fontColor(i <= this.signIndex ? '#FF5A79' : '#9AA0B4')
          }
          .padding({ left: 6, right: 6, top: 8, bottom: 8 })
          .backgroundColor(i < this.signIndex ? 'rgba(0,194,168,0.08)' : (i === this.signIndex ? 'rgba(255,90,121,0.1)' : '#F5F7FF'))
          .borderRadius(10)
          .scale(i === this.signIndex ? { x: 1.1, y: 1.1 } : { x: 1, y: 1 })
          .animation({ duration: 200, curve: Curve.EaseOut })
        }, (s: string) => s)
      }

      Button() {
        Text('签到领奖励')
          .fontSize(14)
          .fontColor('#FFFFFF')
      }
      .padding({ left: 36, right: 36, top: 11, bottom: 11 })
      .backgroundColor('#FF5A79')
      .borderRadius(24)
      .margin({ bottom: 20 })
      .onClick(() => {
        this.signIndex += 1;
      })
    }
    .width('90%')
    .backgroundColor('#FFFFFF')
    .borderRadius(20)
    .alignItems(HorizontalAlign.Center)
    .border({ width: 1, color: '#EDE7F8' })
  }

  @Builder settingModal() {
    Column({ space: 4 }) {
      Text('⚙️ 游戏设置')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1D2129')
        .margin({ top: 20, bottom: 8 })

      Row({ space: 12 }) {
        Text('🔊')
          .fontSize(16)
        Text('答题音效')
          .fontSize(12)
          .fontColor('#1D2129')
          .layoutWeight(1)
        Text(this.soundOn ? '开' : '关')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.soundOn ? '#00C2A8' : '#9AA0B4')
          .padding({ left: 16, right: 16, top: 6, bottom: 6 })
          .backgroundColor('#F5F7FF')
          .borderRadius(14)
          .onClick(() => {
            this.soundOn = !this.soundOn;
          })
      }
      .width('86%')
      .padding({ top: 10, bottom: 10 })

      Row({ space: 12 }) {
        Text('⏱')
          .fontSize(16)
        Text('自动下一题')
          .fontSize(12)
          .fontColor('#1D2129')
          .layoutWeight(1)
        Text('关')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#9AA0B4')
          .padding({ left: 16, right: 16, top: 6, bottom: 6 })
          .backgroundColor('#F5F7FF')
          .borderRadius(14)
      }
      .width('86%')
      .padding({ top: 10, bottom: 10 })

      Row({ space: 12 }) {
        Text('🤝')
          .fontSize(16)
        Text('好友对战邀请')
          .fontSize(12)
          .fontColor('#1D2129')
          .layoutWeight(1)
        Text('开')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#00C2A8')
          .padding({ left: 16, right: 16, top: 6, bottom: 6 })
          .backgroundColor('#F5F7FF')
          .borderRadius(14)
      }
      .width('86%')
      .padding({ top: 10, bottom: 10 })

      Button() {
        Text('完成')
          .fontSize(13)
          .fontColor('#FFFFFF')
      }
      .padding({ left: 30, right: 30, top: 9, bottom: 9 })
      .backgroundColor('#FF5A79')
      .borderRadius(20)
      .margin({ top: 10, bottom: 20 })
      .onClick(() => {
        this.showSettingModal = false;
      })
    }
    .width('86%')
    .backgroundColor('#FFFFFF')
    .borderRadius(20)
    .alignItems(HorizontalAlign.Center)
  }

  build() {
    Stack({ alignContent: Alignment.Center }) {
      Scroll() {
        Column() {
          Row({ space: 14 }) {
            Column() {
              Text('🎧')
                .fontSize(26)
            }
            .width(60)
            .height(60)
            .borderRadius(30)
            .backgroundColor('rgba(255,90,121,0.1)')
            .justifyContent(FlexAlign.Center)

            Column({ space: 4 }) {
              Text('野生DJ')
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1D2129')
              Text('黄金唱针 II · 积分 1,720')
                .fontSize(10)
                .fontColor('#9AA0B4')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text('签到')
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
              .padding({ left: 14, right: 14, top: 7, bottom: 7 })
              .backgroundColor('#FF5A79')
              .borderRadius(16)
              .scale({ x: 1.05, y: 1.05 })
              .animation({ duration: 300, curve: Curve.EaseOut })
              .onClick(() => {
                this.showSignModal = true;
              })
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 18 })

          Row({ space: 10 }) {
            Column({ space: 3 }) {
              Text('2,860')
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFB800')
              Text('金币')
                .fontSize(10)
                .fontColor('#9AA0B4')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            .padding({ top: 12, bottom: 12 })
            .backgroundColor('#FFFFFF')
            .borderRadius(14)

            Column({ space: 3 }) {
              Text('32')
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#00C2A8')
              Text('水晶音符')
                .fontSize(10)
                .fontColor('#9AA0B4')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            .padding({ top: 12, bottom: 12 })
            .backgroundColor('#FFFFFF')
            .borderRadius(14)

            Column({ space: 3 }) {
              Text('18')
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FF5A79')
              Text('背包道具')
                .fontSize(10)
                .fontColor('#9AA0B4')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            .padding({ top: 12, bottom: 12 })
            .backgroundColor('#FFFFFF')
            .borderRadius(14)
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 14 })

          Text('📊 本周答题数')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1D2129')
            .width('100%')
            .padding({ left: 16, top: 18, bottom: 10 })

          Row({ space: 12 }) {
            ForEach(DAILY_STATS, (d: DailyStat) => {
              Column({ space: 6 }) {
                Text(d.count.toString())
                  .fontSize(9)
                  .fontColor('#FF5A79')
                Column()
                  .width(16)
                  .height(barHeightCount(d.count))
                  .borderRadius(4)
                  .backgroundColor(d.count > 80 ? '#FF5A79' : '#FFB800')
                Text(d.day)
                  .fontSize(10)
                  .fontColor('#9AA0B4')
              }
              .alignItems(HorizontalAlign.Center)
              .justifyContent(FlexAlign.End)
            }, (d: DailyStat) => d.day)
          }
          .height(160)
          .alignItems(VerticalAlign.Bottom)
          .width('100%')
          .padding({ left: 16, right: 16 })

          Column({ space: 0 }) {
            Row({ space: 12 }) {
              Text('🎒')
                .fontSize(16)
              Text('我的背包')
                .fontSize(12)
                .fontColor('#1D2129')
                .layoutWeight(1)
              Text('18 件 ›')
                .fontSize(10)
                .fontColor('#9AA0B4')
            }
            .width('100%')
            .padding({ left: 16, right: 16, top: 13, bottom: 13 })

            Divider()
              .color('#F5F7FF')
              .margin({ left: 44 })

            Row({ space: 12 }) {
              Text('🏅')
                .fontSize(16)
              Text('称号墙')
                .fontSize(12)
                .fontColor('#1D2129')
                .layoutWeight(1)
              Text('6 枚 ›')
                .fontSize(10)
                .fontColor('#9AA0B4')
            }
            .width('100%')
            .padding({ left: 16, right: 16, top: 13, bottom: 13 })

            Divider()
              .color('#F5F7FF')
              .margin({ left: 44 })

            Row({ space: 12 }) {
              Text('👥')
                .fontSize(16)
              Text('我的歌友')
                .fontSize(12)
                .fontColor('#1D2129')
                .layoutWeight(1)
              Text('36 人 ›')
                .fontSize(10)
                .fontColor('#9AA0B4')
            }
            .width('100%')
            .padding({ left: 16, right: 16, top: 13, bottom: 13 })

            Divider()
              .color('#F5F7FF')
              .margin({ left: 44 })

            Row({ space: 12 }) {
              Text('⚙️')
                .fontSize(16)
              Text('游戏设置')
                .fontSize(12)
                .fontColor('#1D2129')
                .layoutWeight(1)
              Text('›')
                .fontSize(10)
                .fontColor('#9AA0B4')
            }
            .width('100%')
            .padding({ left: 16, right: 16, top: 13, bottom: 13 })
            .onClick(() => {
              this.showSettingModal = true;
            })
          }
          .width('100%')
          .padding({ left: 16, right: 16 })
          .backgroundColor('#FFFFFF')
          .borderRadius(16)
          .clip(true)
          .margin({ top: 18 })

          Text('v1.2.0 · 听歌识曲 ECHO')
            .fontSize(10)
            .fontColor('#C4C9D9')
            .margin({ top: 20, bottom: 24 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
      .width('100%')

      if (this.showSignModal) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showSignModal = false;
            })
          this.signModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(29,33,41,0.5)')
      }

      if (this.showSettingModal) {
        Column() {
          Column()
            .width('100%')
            .layoutWeight(1)
            .onClick(() => {
              this.showSettingModal = false;
            })
          this.settingModal()
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(29,33,41,0.5)')
      }
    }
    .width('100%')
    .height('100%')
  }
}
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐