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

前言

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 Studio5.0.3 Release 及以上版本
SDK 版本HarmonyOS NEXT 6.1.1(API 24)
Node.js18.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”,供用户查看。
  • icon 和 label:分别引用资源文件中定义的图标和应用名称。
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 变量必须是组件实例的局部变量,不能是静态变量或全局变量。
  • 支持的类型:number、string、boolean、enum、Array、Map、Set 以及这些类型的联合类型。
  • 对于嵌套对象的属性变更,@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 → 玩家胜(玩家在环上"前面"一位)

验证:

玩家电脑玩家值电脑值差值结果
石头剪刀01(0−1+3)%3 = 2玩家胜 ✓
石头布02(0−2+3)%3 = 1电脑胜 ✓
剪刀石头10(1−0+3)%3 = 1电脑胜 ✓
剪刀布12(1−2+3)%3 = 2玩家胜 ✓
布石头20(2−0+3)%3 = 2玩家胜 ✓
布剪刀21(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.Lighter、FontWeight.Normal、FontWeight.Medium、FontWeight.Bold、FontWeight.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 构建:Column、Row、Text、Button、ForEach 等组件的组合与布局
  • 状态管理:响应式数据驱动 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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐