在这里插入图片描述
在这里插入图片描述
在这里插入图片描述在这里插入图片描述

前言

2024 年,华为正式发布了 HarmonyOS NEXT(鸿蒙星河版),这是一个完全剥离 Android 代码、基于 OpenHarmony 深度演进的全场景智能操作系统。它采用全新的鸿蒙内核,不再兼容 Android 应用,标志着中国操作系统进入独立自研的新纪元。作为开发者,掌握 HarmonyOS NEXT 的应用开发技能,正变得前所未有的重要。

本文将带领读者从零开始,使用 HarmonyOS NEXT 6.1.1(API 24)和 ArkTS 语言,完整实现一个经典的"猜拳游戏"(石头剪刀布)应用。文章会涵盖从环境搭建、项目创建、UI 布局、状态管理到业务逻辑实现的全部流程,并附上完整的可运行代码。无论你是刚接触鸿蒙开发的新手,还是想了解 ArkTS 语法的跨平台开发者,本文都能为你提供一个扎实的实战起点。


第一章 认识 HarmonyOS NEXT 与 ArkTS

1.1 HarmonyOS NEXT 的技术定位

HarmonyOS NEXT 是华为面向万物互联时代打造的分布式操作系统。与传统的 Android 或 iOS 不同,它具备以下几个核心特征:

分布式架构:通过分布式软总线,实现多设备之间的硬件能力互助共享。例如,手机上运行的应用可以无缝调用平板的屏幕、手表的传感器或智慧屏的扬声器,开发者无需为每种设备单独适配。

一次开发,多端部署:同一套代码可以运行在手机、平板、手表、车机、智慧屏等多种设备上。ArkTS 提供了自适应布局能力,UI 组件会根据屏幕尺寸自动调整排列方式,真正做到"一套代码,全场景覆盖"。

原生安全:HarmonyOS NEXT 从系统层面对数据访问进行管控。应用必须通过安全访问框架申请权限,系统在授予前会对权限的合理性进行校验。用户的数据默认经过加密存储,应用无法随意读取其他应用的数据。

ArkTS 声明式 UI:ArkTS 借鉴了 SwiftUI 和 Jetpack Compose 的设计理念,使用 TypeScript 语法扩展,提供了声明式、状态驱动的 UI 开发范式。开发者只需描述"UI 应该长什么样",框架自动处理"状态变化时如何更新视图"。

1.2 ArkTS 语言简介

ArkTS 是鸿蒙生态的主力开发语言,它在 TypeScript 的基础上做了针对性的精简和增强:

类型安全:ArkTS 继承 TypeScript 的静态类型检查,所有变量、函数参数和返回值都要求在编译时明确类型,这样可以在编码阶段就发现大量潜在的类型错误,减少运行时崩溃。

装饰器语法:使用 @Component@State@Entry@Builder 等装饰器来定义组件和响应式数据。装饰器是 ArkTS 声明式编程的核心机制,它们标记出哪些数据需要被框架观测、哪些类是 UI 组件。

声明式 UI:通过在 build() 方法中声明界面结构,描述"UI 应该是什么样子",而不是"如何一步步创建 UI"。当状态数据变化时,框架会自动计算出最小的 UI 更新范围并执行重绘,开发者完全不需要手动操作 DOM 或视图节点。

不支持的特性:为了运行性能考虑,ArkTS 不支持以下 TypeScript / JavaScript 特性:

  • any 类型 —— 所有类型必须明确,禁用动态类型
  • eval() 动态代码执行
  • 原型链操作
  • 运行时类型修改
  • 部分 ES6+ 动态语法

这些限制确保了 ArkTS 代码可以在鸿蒙的方舟编译器(ArkCompiler)下获得最优的编译执行性能。

1.3 开发环境要求

在开始编码之前,请确保你的开发机满足以下条件:

项目 要求
操作系统 Windows 10/11(64位)或 macOS Ventura 及以上
DevEco Studio 5.0.3 Release 及以上版本
SDK 版本 HarmonyOS NEXT 6.1.1(API 24)
Node.js 18.x LTS
内存 建议 16GB 以上,最低 8GB
磁盘 至少 30GB 可用空间
分辨率 建议 1920×1080 及以上

第二章 项目创建与目录结构

2.1 创建新项目

打开 DevEco Studio,按以下步骤操作:

  1. 点击欢迎页的 Create Project(或菜单 File > New > Create Project)。
  2. 在模板选择页,选择 Empty Ability(空模板,适合从零开始)。
  3. 填写项目信息:
    • Project Name:MyApplication1
    • Bundle Name:com.example.myapplication1
    • Save Location:选择你希望的磁盘路径
    • Compatible SDK:选择 API 24
    • Language:ArkTS
    • Device:Phone
  4. 点击 Finish,DevEco Studio 会自动初始化项目并通过 ohpm 下载依赖。

初次创建会耗时 1~3 分钟,期间 IDE 底部状态栏会显示 Gradle 和 ohpm 的同步进度。

2.2 项目目录结构解析

创建完成后,项目的目录结构如下:

MyApplication1/
├── AppScope/                          # 应用全局配置
│   ├── app.json5                      # 应用级信息(包名、版本号、图标等)
│   └── resources/
│       └── base/
│           ├── element/               # 颜色、字体等基础资源
│           └── media/                 # 图片资源
├── entry/                             # entry 类型模块(可独立安装的应用)
│   ├── src/
│   │   └── main/
│   │       ├── ets/                   # ArkTS 源码目录
│   │       │   ├── entryability/      # Ability 入口
│   │       │   │   └── EntryAbility.ets
│   │       │   ├── entrybackupability/
│   │       │   │   └── EntryBackupAbility.ets
│   │       │   └── pages/
│   │       │       └── Index.ets      # 主页(核心编辑文件)
│   │       ├── module.json5            # 模块配置
│   │       └── resources/             # 模块级资源
│   ├── build-profile.json5            # 模块构建配置
│   └── oh-package.json5               # 模块依赖声明
├── hvigor/                            # 构建管线配置
├── build-profile.json5                # 项目级构建配置
├── hvigorfile.ts                      # 项目级构建脚本
├── oh-package.json5                   # 项目级依赖
├── oh-package-lock.json5              # 依赖锁定文件
└── local.properties                   # 本地 SDK 路径配置(自动生成)

重点文件说明

  • entry/src/main/ets/pages/Index.ets —— 这是应用的首页,也是我们本次开发的核心文件。猜拳游戏的全部代码都写在这个文件中。
  • AppScope/app.json5 —— 应用级的元信息配置,包括包名、版本号、应用名称等。
  • entry/src/main/module.json5 —— 模块级配置,注册 Ability、配置权限、声明设备类型等。

2.3 关键配置文件详解

app.json5 —— 应用级配置
{
  "app": {
    "bundleName": "com.example.myapplication1",
    "vendor": "example",
    "versionCode": 1000000,
    "versionName": "1.0.0",
    "buildVersion": "1",
    "icon": "$media:layered_image",
    "label": "$string:app_name"
  }
}

各字段含义:

  • bundleName:应用包名,格式通常为反向域名,在华为应用市场上唯一标识一个应用。
  • vendor:应用开发商名称。
  • versionCode:版本号(整型),用于市场版本升级判断,新版本必须大于旧版本。
  • versionName:版本显示名称,如 “1.0.0”,供用户查看。
  • iconlabel:分别引用资源文件中定义的图标和应用名称。
module.json5 —— 模块配置
{
  "module": {
    "name": "entry",
    "type": "entry",
    "description": "$string:module_desc",
    "mainElement": "EntryAbility",
    "deviceTypes": ["phone"],
    "deliveryWithInstall": true,
    "installationFree": false,
    "pages": "$profile:main_pages",
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "description": "$string:EntryAbility_desc",
        "icon": "$media:layered_image",
        "label": "$string:EntryAbility_label",
        "startWindowIcon": "$media:startIcon",
        "startWindowBackground": "$color:start_window_background",
        "exported": true,
        "skills": [
          {
            "entities": ["entity.system.home"],
            "actions": ["ohos.want.action.home"]
          }
        ]
      }
    ],
    "extensionAbilities": [
      {
        "name": "EntryBackupAbility",
        "srcEntry": "./ets/entrybackupability/EntryBackupAbility.ets",
        "type": "backup",
        "exported": false
      }
    ]
  }
}

重点字段:

  • mainElement:应用启动时默认加载的 Ability 名称。
  • abilities:注册应用中所有的 Ability,每个 Ability 代表一个功能入口。
  • abilities[].skills + actions:声明该 Ability 可以响应桌面启动意图,即用户从桌面点击图标时触发。
  • extensionAbilities:注册 Extension Ability,例如这里的 backup 类型用于数据备份恢复。

第三章 ArkTS 核心概念速览

在开始写游戏之前,我们需要快速掌握 ArkTS 中最核心的几个概念。这些知识点将在后续的代码实现中反复用到。

3.1 @Component 装饰器

@Component 将一个自定义类标记为 UI 组件。被装饰的类必须实现 build() 方法,返回组件的 UI 树描述。

@Component
struct MyComponent {
  build() {
    // 在这里声明此组件的 UI 结构
    Text('Hello ArkTS')
      .fontSize(20);
  }
}

注意:ArkTS 中推荐使用 struct(结构体)而非 class 来定义组件。struct 是值类型,分配在栈上,相比 class(引用类型,分配在堆上)具有更好的内存布局和访问性能。在频繁创建销毁 UI 组件的场景下,这种性能优势尤为明显。

3.2 @Entry 装饰器

@Entry 标记一个组件为页面的入口点。它通常与 @Component 配合使用,表示该组件是一个完整的页面,可以被路由导航到。

@Entry
@Component
struct Index {
  build() {
    // 页面代码
  }
}

一个应用中可以有多个 @Entry 组件(多页面应用),每个 @Entry 对应一个可路由的页面。我们猜拳游戏的 Index.ets 就是被 @Entry 修饰的首页。

3.3 @State 装饰器 —— 响应式状态

@State 是 ArkTS 中最常用的状态装饰器。被 @State 修饰的变量发生变化时,框架会自动触发 UI 重新渲染,开发者完全不需要手动编写更新逻辑。

@Component
struct Demo {
  @State count: number = 0;

  build() {
    Column() {
      Text(`计数: ${this.count}`)
        .fontSize(24);
      Button('加一')
        .onClick(() => {
          this.count++; // 修改状态,UI 自动更新
        });
    }
  }
}

关键特性

  • @State 变量必须是组件实例的局部变量,不能是静态变量或全局变量。
  • 支持的类型:numberstringbooleanenumArrayMapSet 以及这些类型的联合类型。
  • 对于嵌套对象的属性变更,@State 只能观测到第一层赋值(this.obj = newObj),无法观测到深层次属性修改(this.obj.prop = value)。如需观测深层变化,需配合使用 @Observed@ObjectLink 装饰器。

3.4 基础 UI 组件

ArkTS 提供了丰富的内置基础组件,我们本次猜拳游戏会用到的有:

组件 说明 常用属性
Column 垂直方向 Flex 布局容器 width, height, padding, backgroundColor, borderRadius, alignItems
Row 水平方向 Flex 布局容器 justifyContent, alignItems, layoutWeight
Text 文本显示组件 fontSize, fontColor, fontWeight, textAlign
Button 按钮组件(可定制子组件) width, height, backgroundColor, borderRadius, onClick
ForEach 循环渲染指令 基于数据源数组动态生成 N 个子组件

3.5 链式属性设置

ArkTS 中,组件的属性设置采用 链式调用(Chained Calls) 风格。每个属性方法都返回组件对象本身,因此可以连续调用,代码非常简洁且可读性强。

Text('Hello')
  .fontSize(20)
  .fontColor('#ff0000')
  .fontWeight(FontWeight.Bold)
  .margin({ top: 10, bottom: 5, left: 16, right: 16 })
  .padding(8)
  .borderRadius(4)
  .onClick(() => {
    console.log('Hello clicked');
  });

这种风格与 SwiftUI 和 Flutter 的组件嵌套写法不同,它更像是"流水线式"的设置过程,每个方法改变组件的某一个属性。开发者可以按任意顺序设置属性,最终效果等价于同时设置所有属性。

3.6 Flex 布局

HarmonyOS NEXT 的 UI 布局系统基于 Flexbox 模型。Column 相当于 flex-direction: column(主轴为垂直方向),Row 相当于 flex-direction: row(主轴为水平方向)。

常用布局属性

Column() {
  // 子组件
}
.alignItems(HorizontalAlign.Center)   // 交叉轴(水平)居中
.justifyContent(FlexAlign.SpaceEvenly); // 主轴(垂直)均匀分布

Row() {
  // 子组件
}
.alignItems(VerticalAlign.Center)     // 交叉轴(垂直)居中
.justifyContent(FlexAlign.Center);    // 主轴(水平)居中

layoutWeight(value: number) 是特别有用的属性,它按照权重分配容器内的剩余空间,相当于 CSS Flex 中的 flex: value。所有设置了 layoutWeight 的子组件会根据权重比例瓜分剩余空间。


第四章 猜拳游戏需求分析与设计

4.1 功能需求

我们要实现的猜拳游戏需要满足以下六项功能:

  1. 三种出拳选项:玩家可以选择石头(Rock)、剪刀(Scissors)或布(Paper)。
  2. 玩家出拳交互:点击对应按钮后,界面立即反馈玩家的选择。
  3. 电脑自动出拳:玩家出拳后,电脑程序随机选择一种出拳。
  4. 即时胜负判定:根据经典猜拳规则判定本局胜负,并显示结果。
  5. 累计比分记录:持续记录玩家和电脑各自获胜的总局数。
  6. 一键重置:任何时候点击重置按钮,清空比分和出拳状态,重新开始。

4.2 猜拳规则

猜拳游戏的经典规则非常简单:

  • 石头剪刀
  • 剪刀
  • 石头
  • 相同出拳则为 平局

我们的代码实现需要将这个规则转化为计算机可以执行的条件判断。虽然可以使用 if-else 枚举所有 3×3 = 9 种组合,但更优雅的方式是利用模运算(Modular Arithmetic)来简化判定。

模运算判定法

将三种出拳分别映射为数字:

  • 石头(Rock)→ 0
  • 剪刀(Scissors)→ 1
  • 布(Paper)→ 2

这 0→1→2→0 构成一个环。在模 3 的体系中,"差值"决定了胜负方向:

diff = (playerValue - computerValue + 3) % 3

diff = 0 → 平局(相同出拳)
diff = 1 → 电脑胜(玩家在环上"后面"一位)
diff = 2 → 玩家胜(玩家在环上"前面"一位)

验证

玩家 电脑 玩家值 电脑值 差值 结果
石头 剪刀 0 1 (0−1+3)%3 = 2 玩家胜 ✓
石头 0 2 (0−2+3)%3 = 1 电脑胜 ✓
剪刀 石头 1 0 (1−0+3)%3 = 1 电脑胜 ✓
剪刀 1 2 (1−2+3)%3 = 2 玩家胜 ✓
石头 2 0 (2−0+3)%3 = 2 玩家胜 ✓
剪刀 2 1 (2−1+3)%3 = 1 电脑胜 ✓

这个判定方法只用一行算术表达式就覆盖了全部 9 种情况,代码简洁且性能极好(没有分支预测失败的开销)。

4.3 UI 设计

考虑到猜拳游戏的交互流程简单明确,我们采用 单页垂直布局 的设计方案。整个界面从上到下依次为:

 ┌─────────────────────────────┐
 │           猜拳游戏            │  ← 标题区
 ├─────────────────────────────┤
 │   玩家: 3  │  VS  │ 电脑: 2  │  ← 比分板
 ├─────────────────────────────┤
 │  你出了 🪨  │  🆚  │ 电脑出 ✂️ │  ← 出拳展示区
 ├─────────────────────────────┤
 │          🎉 你赢了!         │  ← 结果提示
 ├─────────────────────────────┤
 │         请出拳:            │
 │  [🪨石头] [✂️剪刀] [📄布]  │  ← 操作按钮
 ├─────────────────────────────┤
 │        [ 重新开始 ]          │  ← 重置按钮
 └─────────────────────────────┘

设计思路:

  • 采用 Card 式卡片风格,每个区块有浅灰背景和圆角分割,视觉层次清晰。
  • 比分板中玩家分数用蓝色、电脑分数用红色,便于快速区分。
  • 出拳区使用 Emoji 表达,直观且无需额外图片资源。
  • 结果文字颜色随胜负平动态变化(绿色=赢,红色=输,黄色=平)。
  • 底部重置按钮独立存在,避免游戏过程中误触。

第五章 完整代码实现

本章是文章的核心部分。我们将从数据模型定义开始,逐步构建猜拳游戏的完整代码。所有代码都写在 entry/src/main/ets/pages/Index.ets 中。

5.1 定义数据模型

首先,我们定义一个 Choice 接口来描述每种出拳的属性和行为:

interface Choice {
  name: string;    // 中文名称:石头、剪刀、布
  emoji: string;   // Emoji 图标:🪨 ✂️ 📄
  value: number;   // 数值编号:0、1、2
}

选择使用 interface(接口)而不是 class(类),是因为 Choice 仅仅是纯粹的数据载体,不需要任何方法。ArkTS 中的 interface 在编译期会被完全擦除,不产生任何运行时内存占用。

5.2 组件状态设计

@Component struct Index 内部,我们声明以下状态变量:

@State playerChoice: string = '';           // 玩家出拳的 emoji
@State computerChoice: string = '';         // 电脑出拳的 emoji
@State result: string = '点击按钮开始猜拳';   // 结果文本
@State playerScore: number = 0;             // 玩家获胜次数
@State computerScore: number = 0;           // 电脑获胜次数
@State round: number = 0;                   // 总局数计数器

每个变量都被 @State 装饰器修饰,这意味着:

  • 它们属于组件实例,不是共享的全局状态。
  • 当这些变量的值发生变化时,引用了它们的 UI 部分会自动重绘。
  • 例如,this.playerScore++ 执行后,比分板上显示玩家分数的 Text 组件会自动更新。

另外定义一组固定数据(不需要 @State,因为永不变化):

readonly choices: Choice[] = [
  { name: '石头', emoji: '🪨', value: 0 },
  { name: '剪刀', emoji: '✂️', value: 1 },
  { name: '布', emoji: '📄', value: 2 },
];

readonly 关键字确保数组和其元素不会被意外修改。三种出拳的 value 分别是 0、1、2,对应我们在第四章中建立的猜拳判定环。

5.3 build() 方法 —— UI 构建

build() 方法返回组件的界面树。它是组件的"蓝图",ArkTS 框架会根据这个方法生成实际的界面。我们逐层分析每段代码。

5.3.1 根容器
build() {
  Column() {
    // 所有子组件
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#fff');
}

最外层使用 Column 垂直布局容器,宽度和高度都设置为 '100%'(占满整个屏幕),背景色为纯白色。

5.3.2 标题
Text('猜拳游戏')
  .fontSize(28)
  .fontWeight(FontWeight.Bold)
  .margin({ top: 20, bottom: 10 });

使用 FontWeight.Bold 枚举值设置粗体。ArkTS 提供了完整的字体权重枚举:FontWeight.LighterFontWeight.NormalFontWeight.MediumFontWeight.BoldFontWeight.Bolder 等,比直接使用数字更语义化。

5.3.3 比分板

比分板是游戏中最重要的信息展示区,我们采用水平三栏布局:

Row() {
  // 玩家分数区域
  Column() {
    Text('玩家')
      .fontSize(14)
      .fontColor('#666');
    Text(`${this.playerScore}`)
      .fontSize(36)
      .fontWeight(FontWeight.Bold)
      .fontColor('#1890ff');
  }
  .layoutWeight(1)            // 占据 1 份剩余空间

  // VS 分隔
  Text('VS')
    .fontSize(20)
    .fontWeight(FontWeight.Bold)
    .fontColor('#999');

  // 电脑分数区域
  Column() {
    Text('电脑')
      .fontSize(14)
      .fontColor('#666');
    Text(`${this.computerScore}`)
      .fontSize(36)
      .fontWeight(FontWeight.Bold)
      .fontColor('#ff4d4f');
  }
  .layoutWeight(1)            // 占据 1 份剩余空间
}
.margin({ top: 10, bottom: 20 })
.padding(16)
.width('90%')
.backgroundColor('#f5f5f5')
.borderRadius(12);

关键设计:

  • Row 容器内包含三个子元素:左 Column(玩家分)、Text(VS 分隔符)、右 Column(电脑分)。
  • 左右两个 Column 都设置了 layoutWeight(1),它们会等分除去 VS 文本宽度后的所有剩余空间。
  • backgroundColor('#f5f5f5')borderRadius(12) 组合产生浅灰卡片效果。
  • 分数数字使用大号字体(36)和醒目的颜色(玩家蓝 #1890ff,电脑红 #ff4d4f),让比分一目了然。
5.3.4 出拳展示区

出拳展示区在玩家出拳后动态显示双方的选择:

Row() {
  // 玩家出拳
  Column() {
    Text('你出了')
      .fontSize(14)
      .fontColor('#666');
    Text(this.playerChoice || '❓')
      .fontSize(48)
      .margin(8);
  }
  .layoutWeight(1)

  // 对战图标
  Text('🆚')
    .fontSize(28);

  // 电脑出拳
  Column() {
    Text('电脑出了')
      .fontSize(14)
      .fontColor('#666');
    Text(this.computerChoice || '❓')
      .fontSize(48)
      .margin(8);
  }
  .layoutWeight(1)
}
.padding(20)
.width('90%')
.backgroundColor('#fafafa')
.borderRadius(12);

这里使用 || 运算符为初始状态提供默认值:当玩家尚未出拳时,this.playerChoice 为空字符串 '',逻辑或运算符会取后面的 '❓' 作为回退值。同样的逻辑也适用于电脑的出拳展示。

5.3.5 结果文本
Text(this.result)
  .fontSize(22)
  .fontWeight(FontWeight.Bold)
  .fontColor(this.getResultColor())
  .margin(16)
  .height(50);

结果文本的颜色不固定,而是通过 getResultColor() 方法根据当前结果动态计算:

getResultColor(): ResourceColor {
  if (this.result.includes('赢')) {
    return '#52c41a';    // 绿色 —— 赢
  } else if (this.result.includes('输')) {
    return '#ff4d4f';    // 红色 —— 输
  } else if (this.result.includes('平')) {
    return '#faad14';    // 黄色 —— 平局
  }
  return '#999';          // 灰色 —— 初始状态
}

这个方法利用字符串包含判断来区分三种结果,返回对应的颜色值。注意返回值类型声明为 ResourceColor,这是 ArkTS 中表示颜色的内置类型,可以直接接受十六进制颜色字符串。

5.3.6 出拳操作按钮

这是玩家与游戏交互的核心区域。三个按钮分别对应石头、剪刀、布:

Text('请出拳:')
  .fontSize(16)
  .fontColor('#888')
  .margin({ bottom: 12 });

Row() {
  ForEach(this.choices, (item: Choice, index: number) => {
    Button() {
      Column() {
        Text(item.emoji)
          .fontSize(36);
        Text(item.name)
          .fontSize(14)
          .margin({ top: 4 });
      }
    }
    .width(90)
    .height(90)
    .margin({ left: 8, right: 8 })
    .backgroundColor(this.playerChoice === item.name ? '#1890ff' : '#e8e8e8')
    .borderRadius(16)
    .onClick(() => {
      this.playGame(item);
    })
  }, (item: Choice) => item.name)
}
.width('100%')
.justifyContent(FlexAlign.Center);

ForEach 详解

ForEach 是 ArkTS 中的循环渲染指令,它的签名如下:

ForEach(
  dataSource: any[],       // 数据源数组
  itemGenerator: (item, index) => void,  // 生成子组件的闭包
  keyGenerator?: (item) => string        // 可选的键值生成器
)

在代码中:

  • 数据源是 this.choices(三个 Choice 对象)。
  • 闭包接收 item(当前元素)和 index(当前索引),返回 Button 组件。
  • 键值生成器 (item: Choice) => item.name 使用 name 字段作为每个 Button 的稳定标识,帮助 ArkTS 框架在列表更新时准确识别哪些元素发生了变化,从而最小化 DOM 更新范围。

动态背景色

this.playerChoice === item.name ? '#1890ff' : '#e8e8e8' —— 每次 UI 渲染时,遍历三个按钮,如果按钮对应的出拳名称与 playerChoice 匹配(即玩家刚刚点击了这个按钮),则该按钮高亮为蓝色;否则显示浅灰色。这种"选中高亮"的交互反馈让玩家确认自己的选择已被记录。

5.3.7 重置按钮
Button('重新开始')
  .width('50%')
  .margin({ top: 20 })
  .backgroundColor('#ff4d4f')
  .borderRadius(20)
  .onClick(() => {
    this.resetGame();
  });

红色按钮,宽度为父容器的 50%,点击后调用 resetGame() 方法重置所有游戏状态。

5.4 playGame() —— 核心游戏逻辑

playGame 方法是猜拳游戏的大脑,它整合了电脑随机出拳、结果判定和分数更新。完整的代码如下:

playGame(player: Choice): void {
  // 电脑随机出拳 —— 生成 0、1、2 的随机整数
  const computerValue = Math.floor(Math.random() * 3);
  const computer = this.choices[computerValue];

  // 更新出拳展示(触发 UI 自动刷新)
  this.playerChoice = player.emoji;
  this.computerChoice = computer.emoji;
  this.round++;

  // 模运算判定胜负
  // diff = 0 → 平局 | diff = 1 → 电脑胜 | diff = 2 → 玩家胜
  const diff = (player.value - computer.value + 3) % 3;
  if (diff === 0) {
    this.result = '🤝 平局!';
  } else if (diff === 2) {
    this.result = '🎉 你赢了!';
    this.playerScore++;
  } else {
    this.result = '😢 你输了!';
    this.computerScore++;
  }
}

逐行分析

电脑随机出拳

const computerValue = Math.floor(Math.random() * 3);

Math.random() 返回 [0, 1) 的浮点数,乘以 3 后范围是 [0, 3)Math.floor 取整得到 0、1、2 三个整数值之一。这是一种高效且均匀分布的随机数生成方式。

UI 状态更新

this.playerChoice = player.emoji;
this.computerChoice = computer.emoji;

赋值操作的副作用:由于这些变量被 @State 修饰,赋值动作会触发 ArkTS 框架的变更检测,自动找出所有依赖这些变量的 UI 节点并执行最小化重绘。

胜负判定

const diff = (player.value - computer.value + 3) % 3;

这是判定逻辑的核心。我们在第四章已经详细推导过这个公式。这里只给出结论:

  • diff = 0 → 平局
  • diff = 1 → 电脑胜
  • diff = 2 → 玩家胜

注意为什么要 + 3:在 JavaScript/ArkTS 中,% 运算符对负数返回负值(例如 -1 % 3 = -1)。+3 能保证结果始终落在 [0, 2] 的正数范围内,使判定逻辑正确。

5.5 resetGame() —— 重置游戏

resetGame(): void {
  this.playerChoice = '';
  this.computerChoice = '';
  this.result = '点击按钮开始猜拳';
  this.playerScore = 0;
  this.computerScore = 0;
  this.round = 0;
}

将所有状态变量恢复为初始值。所有 @State 变量重置后,UI 会自动回到初始状态——出拳区显示两个问号、比分归零、结果文本恢复为引导文字。

5.6 完整代码总览

将以上所有代码片段整合在一起,得到 Index.ets 的完整内容:

@Entry
@Component
struct Index {
  @State playerChoice: string = '';
  @State computerChoice: string = '';
  @State result: string = '点击按钮开始猜拳';
  @State playerScore: number = 0;
  @State computerScore: number = 0;
  @State round: number = 0;

  readonly choices: Choice[] = [
    { name: '石头', emoji: '🪨', value: 0 },
    { name: '剪刀', emoji: '✂️', value: 1 },
    { name: '布', emoji: '📄', value: 2 },
  ];

  build() {
    Column() {
      // --- 标题 ---
      Text('猜拳游戏')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 20, bottom: 10 });

      // --- 比分板 ---
      Row() {
        Column() {
          Text('玩家').fontSize(14).fontColor('#666');
          Text(`${this.playerScore}`)
            .fontSize(36)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1890ff');
        }.layoutWeight(1)

        Text('VS')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#999');

        Column() {
          Text('电脑').fontSize(14).fontColor('#666');
          Text(`${this.computerScore}`)
            .fontSize(36)
            .fontWeight(FontWeight.Bold)
            .fontColor('#ff4d4f');
        }.layoutWeight(1)
      }
      .margin({ top: 10, bottom: 20 })
      .padding(16)
      .width('90%')
      .backgroundColor('#f5f5f5')
      .borderRadius(12);

      // --- 出拳展示区 ---
      Row() {
        Column() {
          Text('你出了').fontSize(14).fontColor('#666');
          Text(this.playerChoice || '❓')
            .fontSize(48)
            .margin(8);
        }.layoutWeight(1)

        Text('🆚')
          .fontSize(28);

        Column() {
          Text('电脑出了').fontSize(14).fontColor('#666');
          Text(this.computerChoice || '❓')
            .fontSize(48)
            .margin(8);
        }.layoutWeight(1)
      }
      .padding(20)
      .width('90%')
      .backgroundColor('#fafafa')
      .borderRadius(12);

      // --- 结果提示 ---
      Text(this.result)
        .fontSize(22)
        .fontWeight(FontWeight.Bold)
        .fontColor(this.getResultColor())
        .margin(16)
        .height(50);

      // --- 出拳操作 ---
      Text('请出拳:')
        .fontSize(16)
        .fontColor('#888')
        .margin({ bottom: 12 });

      Row() {
        ForEach(this.choices, (item: Choice) => {
          Button() {
            Column() {
              Text(item.emoji).fontSize(36);
              Text(item.name).fontSize(14).margin({ top: 4 });
            }
          }
          .width(90)
          .height(90)
          .margin({ left: 8, right: 8 })
          .backgroundColor(this.playerChoice === item.name ? '#1890ff' : '#e8e8e8')
          .borderRadius(16)
          .onClick(() => {
            this.playGame(item);
          })
        }, (item: Choice) => item.name)
      }
      .width('100%')
      .justifyContent(FlexAlign.Center);

      // --- 重置按钮 ---
      Button('重新开始')
        .width('50%')
        .margin({ top: 20 })
        .backgroundColor('#ff4d4f')
        .borderRadius(20)
        .onClick(() => {
          this.resetGame();
        });
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#fff');
  }

  playGame(player: Choice): void {
    const computerValue = Math.floor(Math.random() * 3);
    const computer = this.choices[computerValue];

    this.playerChoice = player.emoji;
    this.computerChoice = computer.emoji;
    this.round++;

    const diff = (player.value - computer.value + 3) % 3;
    if (diff === 0) {
      this.result = '🤝 平局!';
    } else if (diff === 2) {
      this.result = '🎉 你赢了!';
      this.playerScore++;
    } else {
      this.result = '😢 你输了!';
      this.computerScore++;
    }
  }

  getResultColor(): ResourceColor {
    if (this.result.includes('赢')) {
      return '#52c41a';
    } else if (this.result.includes('输')) {
      return '#ff4d4f';
    } else if (this.result.includes('平')) {
      return '#faad14';
    }
    return '#999';
  }

  resetGame(): void {
    this.playerChoice = '';
    this.computerChoice = '';
    this.result = '点击按钮开始猜拳';
    this.playerScore = 0;
    this.computerScore = 0;
    this.round = 0;
  }
}

interface Choice {
  name: string;
  emoji: string;
  value: number;
}

第六章 构建与运行

6.1 项目构建

代码编写完成后,需要进行构建以验证语法正确性并生成 HAP 安装包。

在 DevEco Studio 中,可以点击菜单栏 Build > Build HAP(s) 进行构建。如果使用命令行,可以在项目根目录执行:

cd E:\MyApplication1
hvigorw assembleHap --mode module -p module=entry --no-daemon

如果一切正常,终端输出末尾会显示:

> hvigor BUILD SUCCESSFUL in 15 s

构建成功后,HAP 安装包生成在 entry/build/default/outputs/ 目录中。

如果构建报错,常见原因及解决方案:

错误类型 可能原因 解决方案
编译报错 XXX is not defined 变量名拼写错误或未声明 检查变量名与 @State 声明是否一致
类型不匹配 @State 变量被赋了错误类型 确保 number 类型变量不赋值字符串
Module not found 文件路径引用错误 检查 module.json5 中的 srcEntry 路径
SDK 版本不兼容 DevEco Studio 或 SDK 版本过低 升级到 API 24 对应的 SDK 版本

6.2 运行到真机或模拟器

运行到模拟器

  1. 在 DevEco Studio 的 Device Manager 中创建一个 Phone 模拟器。
  2. 确保模拟器已启动并处于运行状态。
  3. 点击工具栏的 Run 按钮(绿色三角形),选择目标设备为模拟器。
  4. 等待安装和启动,应用将在模拟器中运行。

运行到真机

  1. 在手机上开启 开发者模式(设置 > 关于手机 > 连续点击版本号 7 次)。
  2. 开启 USB 调试通过 USB 安装应用
  3. 使用 USB 数据线连接手机到电脑。
  4. 在 DevEco Studio 中点击 Run,选择已连接的真机设备。
  5. 如果是首次在真机运行,需要配置签名证书(DevEco Studio 会自动引导生成调试证书)。

6.3 运行效果验证

应用启动后,你应该能看到:

  • 顶部显示"猜拳游戏"标题。
  • 比分板显示"玩家 0 | VS | 电脑 0"。
  • 出拳展示区显示两个问号 ❓ ❓。
  • 中间文字显示"点击按钮开始猜拳"。
  • 三个按钮分别显示 🪨石头、✂️剪刀、📄布。
  • 底部有红色"重新开始"按钮。

点击任意出拳按钮后:

  • 出拳展示区显示玩家的选择和电脑的随机选择(Emoji 形式)。
  • 结果文字变为"🎉 你赢了!"、“😢 你输了!” 或 “🤝 平局!”,颜色对应绿、红、黄。
  • 比分板的数字相应更新。

第七章 扩展与优化方向

我们的猜拳游戏已经是一个完整可用的应用,但这个项目还有很多可以延伸优化的方向。以下是几个值得尝试的扩展思路:

7.1 添加游戏动画

当前版本中,电脑出拳是瞬间显示的,缺少悬念感。可以添加一个"翻牌"动画:玩家出拳后,电脑先显示一个随机问号旋转动画,0.5 秒后再展示真正的出拳结果。ArkTS 提供了 animateTo 方法和过渡动画 API 来实现这类效果:

animateTo({ duration: 500, curve: Curve.EaseOut }, () => {
  // 在闭包中修改状态,框架自动生成动画过渡
  this.computerChoice = computer.emoji;
});

7.2 历史对局记录

可以使用 @State 数组记录最近若干局的历史,并在界面底部添加一个展开式列表,展示每局玩家出了什么、电脑出了什么、结果如何。这有助于玩家复盘自己的出拳策略。

7.3 最佳 N 局决胜模式

添加一个设置入口,让玩家选择"三局两胜"或"五局三胜"模式。先达到胜场数的一方获胜,游戏自动弹窗宣布最终胜利者并重置。

7.4 AI 策略优化

当前的电脑出拳是完全随机的(Math.random() 均匀分布)。可以引入简单的策略:记录玩家的出拳历史,统计玩家最常出的拳种,然后电脑选择能克制该拳种的出拳。例如:

// 简易 AI:如果玩家最近最爱出"石头",电脑就多出"布"
function aiChoice(playerHistory: number[]): number {
  // 统计玩家历史出拳的分布
  const counts = [0, 0, 0];
  playerHistory.forEach(v => counts[v]++);
  const mostFrequent = counts.indexOf(Math.max(...counts));
  // 返回克制玩家最常见出拳的选项
  return (mostFrequent + 2) % 3; // 环上后退一步
}

7.5 多页面与路由

现在所有代码都在一个页面中。如果添加设置页面、历史记录页面或关于页面,需要学习 ArkTS 的路由系统(router API)和页面间传参。


第八章 常见问题解答

Q1: DevEco Studio 提示 SDK 未安装怎么办?

打开 DevEco Studio 的 Settings > SDK Manager,检查 API 24 是否已安装。如果未安装,勾选并点击 Apply 下载。下载完成后重启 IDE。

Q2: 构建时报 “ohpm install failed”?

可能是网络问题导致的依赖下载失败。检查网络连接后,在项目根目录手动执行:

ohpm install

如果持续失败,可以配置 ohpm 镜像源(在 ~/.ohpmrc 中写入 registry=https://repo.harmonyos.com/ohpm/)。

Q3: 真机运行提示 “signing certificate error”?

鸿蒙应用在真机运行时需要签名。在 DevEco Studio 中,按照提示进入 Build > Generate Key and CSR 生成调试证书,或在 Project Structure > Project > Signing Configs 中配置自动签名。

Q4: 界面在不同尺寸设备上显示异常怎么办?

使用 ArkTS 的响应式布局能力替代硬编码的 px 值。例如使用 .width('90%') 而不是 .width(360),使用 .layoutWeight(1) 实现自适应比例分配。对于更复杂的场景,可以监听屏幕尺寸变化并调整布局参数。

Q5: @State 装饰的数组修改后 UI 不更新怎么办?

使用 @State 装饰的数组,如果只是通过索引修改元素(如 this.arr[0] = newValue),ArkTS 无法检测到变化。应当使用不可变方式赋值:

// 错误 —— UI 不会更新
this.choices[0] = newItem;

// 正确 —— 创建新数组触发更新
this.choices = [...this.choices.slice(0, 0), newItem, ...this.choices.slice(1)];

结语

本文从 HarmonyOS NEXT 的技术背景出发,详细介绍了 ArkTS 的核心概念,并带领大家一步步实现了一个完整的猜拳游戏应用。在这个过程中,我们覆盖了:

  • ArkTS 声明式编程@Component@Entry@State 等装饰器的使用
  • UI 构建ColumnRowTextButtonForEach 等组件的组合与布局
  • 状态管理:响应式数据驱动 UI 更新的机制
  • 业务逻辑:猜拳规则的数学建模与代码实现
  • 工程实践:项目构建、真机调试和常见问题排查

猜拳游戏虽然简单,但它涵盖了应用开发中最核心的"UI × 状态 × 逻辑"三角关系。理解了这个基本模式,你就可以将其扩展到更复杂的应用场景中。

HarmonyOS NEXT 作为一个全新的操作系统生态,正在迅速发展。掌握 ArkTS 开发,不仅是掌握一门语言,更是为未来的全场景智能化应用开发做好准备。希望本文能成为你鸿蒙开发旅程中坚实的第一步。

Happy coding!


本文基于 HarmonyOS NEXT 6.1.1(API 24)和 DevEco Studio 5.0.3 编写。源码可在项目目录 entry/src/main/ets/pages/Index.ets 中找到。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐