在这里插入图片描述

SDK 版本:HarmonyOS NEXT 6.1.1(API 24)
开发语言:ArkTS(声明式 UI)
项目模式:Stage 模型
源码文件:entry/src/main/ets/pages/SimpSimulator.ets(1250 行)


目录

  1. 项目背景与创意来源
  2. 项目结构与页面注册
  3. 数据模型设计
  4. 状态管理架构
  5. 核心玩法逻辑
  6. 送礼与经济系统
  7. 每日签到机制
  8. 自由聊天系统
  9. 成就系统实现
  10. UI 布局设计
  11. @Builder 组件复用
  12. 聊天气泡实现
  13. 结局判定与重置
  14. 页面路由导航
  15. ArkTS 避坑总结
  16. 扩展与优化方向

1. 项目背景与创意来源

1.1 为什么要做「舔狗模拟器」

在移动互联网时代,社交互动类应用始终占据着用户的大量时间。然而,市面上的大多数社交模拟游戏要么过于复杂,要么缺乏本地化的幽默感。「舔狗模拟器」这个创意来源于中文互联网文化中一个经久不衰的梗——“舔狗舔狗,舔到最后应有尽有”。

这个应用本质上是一个 剧情驱动的选择互动游戏,用户扮演一名追求者,通过聊天、送礼、日常打卡等方式,提升目标对象的好感度。不同的选择会导致不同的结局,既有"舔到出局"的悲惨结局,也有"修成正果"的完美结局。

1.2 技术选型

既然是 HarmonyOS NEXT 原生应用,自然选择 ArkTS 声明式 UI 作为开发语言。整个应用仅需 一个 .ets 文件、1250 行代码,就实现了完整的游戏循环,包括:

  • 10 个剧情场景,每个场景 3 种选择
  • 好感度系统(0~100 动态变化)
  • 经济系统(赚钱、消费)
  • 礼物系统(5 种礼物)
  • 每日签到(连续签到加成)
  • 自由聊天(随机回复)
  • 成就系统(8 个成就)
  • 双结局(出局 / 完美)

2. 项目结构与页面注册

2.1 文件目录

entry/src/main/ets/pages/
├── Index.ets              # 首页(番茄钟 + 导航)
├── ColumnStartDemo.ets    # ColumnStart 布局演示
└── SimpSimulator.ets      # 舔狗模拟器(本文主角)

2.2 页面路由注册

每个页面必须在 main_pages.json 中注册才能被路由跳转访问:

{
  "src": [
    "pages/Index",
    "pages/ColumnStartDemo",
    "pages/SimpSimulator"
  ]
}

2.3 SDK 版本配置

在根目录 build-profile.json5 中定义:

{
  "app": {
    "products": [
      {
        "name": "default",
        "targetSdkVersion": "6.1.1(24)",
        "compatibleSdkVersion": "6.1.1(24)",
        "runtimeOS": "HarmonyOS"
      }
    ]
  }
}

API 24 是 HarmonyOS NEXT 6.1.1 对应的 SDK 版本,支持所有最新的 ArkTS 语法特性:条件渲染、ForEach 循环、@Builder 自定义构建函数、@State 响应式状态管理等。

2.4 首页导航

Index.ets 通过 router.pushUrl() 跳转到舔狗模拟器:

Button('🐶 舔狗模拟器')
  .fontSize(14)
  .fontColor('#FFFFFF')
  .backgroundColor('#E74C3C')
  .borderRadius(20)
  .height(40)
  .width(220)
  .onClick(() => {
    router.pushUrl({ url: 'pages/SimpSimulator' });
  })

3. 数据模型设计

在 ArkTS 中,使用 interface 定义数据结构。舔狗模拟器定义了 5 个接口,各自承担不同的职责。

3.1 消息模型(ChatMessage)

interface ChatMessage {
  id: number;
  sender: 'me' | 'crush';
  text: string;
  time: string;
}
  • id:消息唯一标识,用于 ForEach 的 key 生成
  • sender:联合类型 'me' | 'crush',区分左右气泡
  • text:消息正文
  • time:发送时间,格式 HH:mm

3.2 成就模型(Achievement)

interface Achievement {
  id: string;
  name: string;
  desc: string;
  unlocked: boolean;
  icon: string;
}

3.3 选择模型(Choice)

interface Choice {
  text: string;
  affectionDelta: number;
  reply: string;
  isDeathFlag?: boolean;
}
  • text:用户看到的选项文字
  • affectionDelta:选择后的好感度变化量(可正可负)
  • reply:对方回复的内容
  • isDeathFlag:可选字段,标记为「死亡选项」

3.4 场景模型(Scene)

interface Scene {
  question: string;
  choices: Choice[];
}

每个场景由一个问题和三个选择组成。

3.5 礼物模型(GiftItem)

interface GiftItem {
  name: string;
  cost: number;
  affection: number;
  emoji: string;
}

3.6 设计要点

为什么选择 interface 而不是 class?

在 ArkTS 中,纯数据对象推荐使用 interface,它更轻量、不支持继承、编译时擦除。class 用于需要方法的复杂对象,而这里的数据结构只需要存储属性,interface 是最优选择。


4. 状态管理架构

4.1 @State 装饰器

ArkTS 的 @State 装饰器用于声明组件的内部状态。当状态变量发生变化时,UI 会自动重新渲染。

舔狗模拟器中使用了 9 个 @State 状态变量:

@State affection: number = 30;          // 好感度 0~100
@State money: number = 100;             // 零花钱
@State day: number = 1;                 // 当前天数
@State currentScene: number = 0;        // 当前场景索引
@State messages: ChatMessage[] = [];    // 聊天记录
@State isGameOver: boolean = false;     // 是否出局
@State endingText: string = '';         // 结局文字
@State isWin: boolean = false;          // 是否通关
@State achievements: Achievement[] = [];// 已解锁成就
@State freeChatText: string = '';       // 输入框内容
@State activePanel: string = 'chat';    // 当前面板
@State dailyDone: boolean = false;      // 每日签到
@State streakDays: number = 0;          // 连续签到天数

4.2 计算属性(getter)

使用 get 关键字定义计算属性,根据状态值动态返回结果:

get affectionLevel(): string {
  if (this.affection <= 10) return '路人';
  if (this.affection <= 30) return '认识的人';
  if (this.affection <= 50) return '普通朋友';
  if (this.affection <= 70) return '好朋友';
  if (this.affection <= 90) return '暧昧对象';
  return '心动伴侣';
}

get affectionColor(): string {
  if (this.affection <= 20) return '#E74C3C';   // 红色
  if (this.affection <= 40) return '#E67E22';   // 橙色
  if (this.affection <= 60) return '#F1C40F';   // 黄色
  if (this.affection <= 80) return '#2ECC71';   // 绿色
  return '#27AE60';                               // 翠绿
}

get affectionEmoji(): string {
  if (this.affection <= 10) return '😐';
  if (this.affection <= 30) return '🙂';
  if (this.affection <= 50) return '😊';
  if (this.affection <= 70) return '😄';
  if (this.affection <= 90) return '🥰';
  return '💖';
}

这种设计的优势:UI 中只需引用 this.affectionColor,不需要任何额外的条件判断代码,视图层非常干净。

4.3 私有数据(非 @State)

场景数据、礼物列表、每日问候文案等不需要触发 UI 刷新的数据,使用普通的 private 声明:

private scenes: Scene[] = [ /* 10 个场景 */ ];
private giftList: GiftItem[] = [ /* 5 种礼物 */ ];
private dailyGreetings: string[] = [ /* 7 条问候 */ ];
private allAchievements: Achievement[] = [ /* 8 个成就 */ ];
private giftCount: number = 0;
private sceneCount: number = 0;

重要区分:只有需要驱动 UI 刷新的数据才用 @State,不需要的一律用普通属性,避免不必要的渲染开销。


5. 核心玩法逻辑

5.1 剧情场景

游戏内置了 10 个场景,涵盖从"初次搭讪"到"最终表白"的完整追求历程:

场景 主题 死亡选项
1 鼓起勇气发消息
2 对方想喝奶茶 评论"少喝奶茶"
3 对方感冒了
4 周末约会 去她家楼下
5 逛街看到包包
6 半夜睡不着
7 生日礼物
8 怀疑她与别人走太近
9 工作挫折 说"我养你"
10 最终表白 摆蜡烛

5.2 选择处理流程

private selectChoice(choice: Choice): void {
  if (this.isGameOver || this.isWin) return;

  // 1. 记录用户的选择
  this.addMessage('me', choice.text);

  // 2. 延迟 800ms 模拟对方回复
  setTimeout(() => {
    this.addMessage('crush', choice.reply);

    // 3. 计算好感度变化(穷酸效应)
    let delta = choice.affectionDelta;
    if (this.money <= 10 && delta > 0) {
      delta = Math.floor(delta / 2);  // 没钱了,加成减半
    }
    this.affection = Math.max(0, Math.min(100, this.affection + delta));

    // 4. 每次互动扣 2 元
    this.money = Math.max(0, this.money - 2);

    // 5. 检查是否出局或通关
    if (choice.isDeathFlag || this.affection <= 0) { /* 出局 */ }
    if (this.affection >= 100) { /* 通关 */ }

    // 6. 进入下一场景
    this.currentScene++;
    if (this.currentScene >= this.scenes.length) {
      this.currentScene = 0;
      this.day++;
    }
  }, 800);
}

关键设计思路

  • 穷酸效应:当零花钱低于 10 元时,好感度正面加成减半——这是对现实的幽默映射
  • 延迟回复:使用 setTimeout 模拟对方思考和打字的时间,增强沉浸感
  • 边界钳制Math.max(0, Math.min(100, ...)) 确保好感度始终在 0~100 范围内
  • 场景循环:10 个场景走完自动进入下一天,形成"天"的循环概念

5.3 延迟调用与状态安全

setTimeout(() => {
  if (this.isGameOver || this.isWin) return;  // 安全检查
  // ...更新状态
}, 800);

每个 setTimeout 回调的开头都进行状态检查,防止在游戏已经结束后仍然执行逻辑。


6. 送礼与经济系统

6.1 礼物定义

private giftList: GiftItem[] = [
  { name: '一杯奶茶', cost: 15, affection: 3, emoji: '🧋' },
  { name: '一束鲜花', cost: 30, affection: 6, emoji: '💐' },
  { name: '巧克力礼盒', cost: 25, affection: 5, emoji: '🍫' },
  { name: '精致手链', cost: 50, affection: 10, emoji: '📿' },
  { name: '名牌香水', cost: 80, affection: 15, emoji: '🧴' },
];

6.2 送礼逻辑

private sendGift(index: number): void {
  if (this.isGameOver || this.isWin) return;
  const gift = this.giftList[index];
  if (this.money < gift.cost) {
    promptAction.showToast({ message: '余额不足,先去做每日任务赚钱吧!', duration: 2000 });
    return;
  }

  this.money -= gift.cost;
  this.affection = Math.min(100, this.affection + gift.affection);

  this.addMessage('me', `💝 送出了 ${gift.emoji} ${gift.name}`);
  setTimeout(() => {
    this.addMessage('crush', `你送我的${gift.name}收到了!好喜欢😊`);
    this.giftCount++;
    this.checkAchievements();

    if (gift.cost >= 50) {
      setTimeout(() => {
        this.addMessage('crush', '不过……下次别花这么多钱了,我会心疼的 💕');
      }, 600);
    }
  }, 600);
}

设计亮点:送礼金额超过 50 元会触发额外剧情对话,模拟"贵重礼物带来更复杂的心理反应"。

6.3 经济平衡

  • 初始资金:100 元
  • 每次互动扣 2 元
  • 每日签到奖励 20 元
  • 最便宜的礼物 15 元,最贵的 80 元
  • 没钱时好感加成减半

这种设计让玩家必须在"攒钱送礼"和"日常互动"之间做出权衡,增加了策略性。


7. 每日签到机制

7.1 签到文案

private dailyGreetings: string[] = [
  '"早安,今天也是元气满满的一天!☀️"',
  '"今天天气转凉,记得多穿件衣服哦~"',
  '"你昨天的朋友圈照片好好看!"',
  '"我今天路过一家店,觉得你一定会喜欢~"',
  '"晚安,好梦~🌙"',
  '"今天工作辛苦啦,好好休息!"',
  '"给你分享一首歌,超好听!🎵"',
];

7.2 签到逻辑

private doDailyGreeting(): void {
  if (this.dailyDone) {
    promptAction.showToast({ message: '今天已经打过卡啦,明天再来吧!', duration: 1500 });
    return;
  }

  this.dailyDone = true;
  this.streakDays++;

  const greeting = this.dailyGreetings[Math.floor(Math.random() * this.dailyGreetings.length)];
  this.addMessage('me', greeting);
  const affectionGain = 3 + Math.min(this.streakDays, 7); // 连续签到加成
  this.affection = Math.min(100, this.affection + affectionGain);
  this.money += 20;

  setTimeout(() => {
    this.addMessage('crush', '早安呀~你也是!☀️');
    this.addSystemMessage(`💬 好感度 +${affectionGain},零花钱 +¥20`);
  }, 600);
}

连续签到加成公式

好感度获得 = 3 + min(连续天数, 7)

这意味着:

  • 第 1 天:+4
  • 第 3 天:+6
  • 第 7 天及以上:+10

这种递增机制鼓励玩家每天登录,增加粘性。


8. 自由聊天系统

8.1 随机回复

private sendFreeChat(): void {
  const text = this.freeChatText.trim();
  if (!text) {
    promptAction.showToast({ message: '输入点什么吧……', duration: 1000 });
    return;
  }

  this.addMessage('me', text);
  this.money = Math.max(0, this.money - 1);

  const replies = [
    '"哈哈,你说得对 😄"',
    '"嗯嗯,我在听。"',
    '"你今天是话痨模式吗?😂"',
    '"🤔 有意思,继续说。"',
    '"好!就这么办!"',
    '"😅 不知道说什么好了。"',
    '"你开心就好~"',
  ];
  const randomReply = replies[Math.floor(Math.random() * replies.length)];
  const randomDelta = Math.floor(Math.random() * 5) - 1; // -1 ~ 3

  setTimeout(() => {
    this.addMessage('crush', randomReply);
    this.affection = Math.max(0, Math.min(100, this.affection + randomDelta));
    if (randomDelta > 0) {
      this.addSystemMessage(`💬 聊得不错,好感度 +${randomDelta}`);
    } else if (randomDelta < 0) {
      this.addSystemMessage('😅 好像有点冷场……');
    }
  }, 600);

  this.freeChatText = '';
}

这个系统的趣味在于"不确定性"——即使你认真输入了一段文字,对方可能回复热情也可能冷淡,模拟了现实中聊天的随机性。


9. 成就系统实现

9.1 成就定义

private allAchievements: Achievement[] = [
  { id: 'first_chat',     name: '初次搭讪',    desc: '发送第一条消息',     icon: '💬' },
  { id: 'gift_giver',     name: '送礼达人',    desc: '送出 3 次礼物',      icon: '🎁' },
  { id: 'streak_3',       name: '坚持不懈',    desc: '连续签到 3 天',      icon: '🔥' },
  { id: 'affection_50',   name: '有点意思',    desc: '好感度达到 50',      icon: '😊' },
  { id: 'affection_80',   name: '好感爆棚',    desc: '好感度达到 80',      icon: '💕' },
  { id: 'survive_5',      name: '撑过五关',    desc: '存活 5 个场景',      icon: '🛡️' },
  { id: 'rich_gift',      name: '挥金如土',    desc: '单次送礼超过 50 元', icon: '💰' },
  { id: 'perfect',        name: '完美结局',    desc: '达成美满结局',       icon: '👑' },
];

9.2 成就检测

private checkAchievements(): void {
  const unlockAchievement = (idx: number): void => {
    if (this.allAchievements[idx].unlocked) return;
    this.allAchievements[idx].unlocked = true;
    this.achievements.push({
      id: this.allAchievements[idx].id,
      name: this.allAchievements[idx].name,
      desc: this.allAchievements[idx].desc,
      unlocked: true,
      icon: this.allAchievements[idx].icon
    });
  };

  if (this.messages.length >= 2)    unlockAchievement(0);
  if (this.giftCount >= 3)           unlockAchievement(1);
  if (this.streakDays >= 3)         unlockAchievement(2);
  if (this.affection >= 50)         unlockAchievement(3);
  if (this.affection >= 80)         unlockAchievement(4);
  if (this.sceneCount >= 5)         unlockAchievement(5);
  if (this.isWin)                    unlockAchievement(7);
}

9.3 贵重礼物成就

private checkRichGift(): void {
  if (this.allAchievements[6].unlocked) return;
  this.allAchievements[6].unlocked = true;
  this.achievements.push({
    id: this.allAchievements[6].id,
    name: this.allAchievements[6].name,
    desc: this.allAchievements[6].desc,
    unlocked: true,
    icon: this.allAchievements[6].icon
  });
  promptAction.showToast({ message: '🏆 解锁成就:挥金如土', duration: 2000 });
}

10. UI 布局设计

10.1 整体布局结构

整个页面采用 Column + HorizontalAlign.Start 布局,所有内容左对齐、顶部排列:

Scroll()                          ← 可滚动
  Column()                        ← 主容器
    ├─ 标题区                     ─ Text("🐶 舔狗模拟器")
    ├─ 状态仪表板                 ─ 好感度进度条 + 等级 + 金钱 + 天数
    ├─ 功能选项卡                 ─ 聊天 / 送礼 / 打卡 / 成就
    ├─ 内容面板                   ─ 根据选项卡切换
    │   ├─ 聊天面板               ─ 聊天记录 + 选项 + 输入框
    │   ├─ 送礼面板               ─ 礼物列表
    │   ├─ 打卡面板               ─ 签到日历 + 按钮
    │   └─ 成就面板               ─ 成就列表
    ├─ 结局面板                   ─ 出局/通关时显示
    └─ 底部操作                   ─ 重新开始 + 返回首页

10.2 ColumnStart 配置

Column() {
  // ...所有子组件
}
.width('100%')
.alignItems(HorizontalAlign.Start)      // 水平左对齐
.justifyContent(FlexAlign.Start)        // 垂直顶部对齐
.padding({ left: 16, right: 16 })
.backgroundColor('#F5F6FA')

为什么用 HorizontalAlign 而不是 ItemAlign

在 HarmonyOS NEXT API 24 中,Column 容器的 alignItems() 方法接受 HorizontalAlign 枚举,而不是 ItemAlignItemAlignRow 容器的对齐属性。这是 ArkTS 与 CSS Flexbox 的一个重要区别:

容器 主轴 交叉轴 alignItems 参数类型
Column 垂直 水平 HorizontalAlign
Row 水平 垂直 VerticalAlign
Flex 自定义 自定义 ItemAlign

10.3 状态仪表板

Column() {
  // 好感度进度条
  Row() {
    Text('❤️ 好感度')
    Text(`${this.affection}/100`)
      .fontColor(this.affectionColor)
    Text(this.affectionEmoji)
  }

  // 进度条
  Row() {
    Row()
      .width(`${this.affection}%`)
      .height(8)
      .backgroundColor(this.affectionColor)
      .borderRadius(4)
  }
  .width('100%')
  .height(8)
  .backgroundColor('#ECF0F1')
  .borderRadius(4)
}

进度条使用两个嵌套的 Row 实现:外层作为背景(灰色),内层作为填充(动态颜色),宽度由 ${this.affection}% 动态绑定。

10.4 选项卡切换

Row() {
  this.buildTabButton('💬', '聊天', 'chat')
  this.buildTabButton('🎁', '送礼', 'gift')
  this.buildTabButton('📅', '打卡', 'daily')
  this.buildTabButton('🏆', '成就', 'achievement')
}

使用 @State activePanel 控制当前显示的面板,在 build() 中通过 if/else if 条件渲染:

if (this.activePanel === 'chat') {
  this.buildChatPanel()
} else if (this.activePanel === 'gift') {
  this.buildGiftPanel()
} else if (this.activePanel === 'daily') {
  this.buildDailyPanel()
} else if (this.activePanel === 'achievement') {
  this.buildAchievementPanel()
}

11. @Builder 组件复用

11.1 定义 @Builder

@Builder 是 ArkTS 用于创建可复用 UI 片段的装饰器:

@Builder
buildTabButton(emoji: string, label: string, panelId: string): void {
  Column() {
    Text(emoji).fontSize(22)
    Text(label).fontSize(12)
      .fontColor(this.activePanel === panelId ? '#4ECDC4' : '#95A5A6')
  }
  .alignItems(HorizontalAlign.Center)
  .layoutWeight(1)
  .backgroundColor(this.activePanel === panelId ? '#F0FFFA' : '#FFFFFF')
  .borderRadius(10)
  .border({ width: this.activePanel === panelId ? 1.5 : 0, color: '#4ECDC4' })
  .onClick(() => { this.activePanel = panelId; })
}

11.2 本项目中定义的 @Builder

方法名 参数 用途
buildTabButton emoji, label, panelId 选项卡按钮
buildChatPanel 聊天面板
buildGiftPanel 送礼面板
buildDailyPanel 打卡面板
buildAchievementPanel 成就面板
buildEndingPanel 结局面板
buildStatRow label, value 统计行

11.3 @Builder 的三大优势

  1. 参数化:通过函数参数控制颜色、文字、数据
  2. 访问 struct 成员:可以直接使用 this.activePanel 等状态变量
  3. 轻量:不需要创建独立的 Component 结构体

12. 聊天气泡实现

12.1 系统消息

if (msg.time === '系统') {
  Text(msg.text)
    .fontSize(13)
    .fontColor('#7F8C8D')
    .fontStyle(FontStyle.Italic)
}

12.2 我的消息(右对齐)

Row() {
  Text('').layoutWeight(1)                      // 左侧弹性占位
  Column() {
    Text(msg.text)
      .padding({ left: 12, right: 12, top: 8, bottom: 8 })
      .backgroundColor('#4ECDC4')               // 绿色气泡
      .borderRadius(12)
    Text(msg.time).fontSize(10)
      .textAlign(TextAlign.End)
  }
  .alignItems(HorizontalAlign.End)
  .constraintSize({ maxWidth: '75%' })          // 最大宽度 75%
  .margin({ right: 4 })
}
.width('100%')

12.3 对方消息(左对齐)

Row() {
  Column() {
    Text(msg.text)
      .padding({ left: 12, right: 12, top: 8, bottom: 8 })
      .backgroundColor('#FFFFFF')               // 白色气泡
      .borderRadius(12)
    Text(msg.time).fontSize(10)
  }
  .alignItems(HorizontalAlign.Start)
  .constraintSize({ maxWidth: '75%' })
  .margin({ left: 4 })

  Text('').layoutWeight(1)                      // 右侧弹性占位
}
.width('100%')

气泡布局要点

  • 使用 Text('').layoutWeight(1) 作为弹性占位,将气泡推到右侧/左侧
  • constraintSize({ maxWidth: '75%' }) 限制气泡最大宽度为容器的 75%
  • 聊天容器使用 .clip(true) 裁剪溢出内容,确保圆角效果
  • 外层容器设置 constraintSize({ maxHeight: 360 }) 限制最大高度,超出可滚动

13. 结局判定与重置

13.1 出局结局

if (choice.isDeathFlag || this.affection <= 0) {
  this.isGameOver = true;
  this.endingText = this.affection <= 0
    ? '💔 对方彻底把你拉黑了。\n舔狗不得好死 —— 但下一个更乖。'
    : '🚫 你踩到了对方的雷区。\n有些错误,犯一次就再也没有机会了。';
}

13.2 完美结局

if (this.affection >= 100) {
  this.isWin = true;
  this.endingText = '🎉 恭喜!对方被你打动了!\n你们幸福地走在了一起。\n\n舔狗舔狗,舔到最后应有尽有!';
}

13.3 重置游戏

private resetGame(): void {
  this.affection = 30;
  this.money = 100;
  this.day = 1;
  this.currentScene = 0;
  this.messages = [];
  this.isGameOver = false;
  this.isWin = false;
  this.endingText = '';
  this.freeChatText = '';
  this.activePanel = 'chat';
  this.dailyDone = false;
  this.streakDays = 0;
  this.giftCount = 0;
  this.sceneCount = 0;
  this.achievements = [];
  this.allAchievements.forEach(a => a.unlocked = false);

  this.aboutToAppear();     // 重新初始化
  this.showCurrentScene();
}

重置方法将所有状态恢复初始值,并重新调用 aboutToAppear() 显示欢迎消息。

13.4 结局面板 UI

@Builder
buildEndingPanel(): void {
  Column() {
    Text(this.isWin ? '💖 完美结局!' : '💔 游戏结束')
      .fontColor(this.isWin ? '#27AE60' : '#E74C3C')

    Text(this.endingText)

    Column() {
      this.buildStatRow('📅 存活天数', `${this.day}`)
      this.buildStatRow('❤️ 最终好感度', `${this.affection}/100`)
      this.buildStatRow('💰 剩余零花钱', this.moneyDisplay)
      this.buildStatRow('💬 发送消息', `${this.messages.length}`)
      this.buildStatRow('🎁 送礼次数', `${this.giftCount}`)
      this.buildStatRow('🏆 解锁成就', `${this.achievements.length}`)
    }

    Button('🔄 再来一次')
      .onClick(() => { this.resetGame(); })
  }
}

14. 页面路由导航

14.1 模块导入

import promptAction from '@ohos.promptAction';  // Toast 提示
import router from '@ohos.router';               // 页面路由

14.2 跳转与返回

// 跳转到舔狗模拟器
router.pushUrl({ url: 'pages/SimpSimulator' });

// 返回首页
private goBack(): void {
  router.back();
}

pushUrl 将页面压入路由栈,保留当前页面状态;back 弹出当前页面,返回上一页。


15. ArkTS 避坑总结

在开发过程中,我们遇到了以下 ArkTS 特有的规则限制,这是从 TypeScript 或其它框架转过来的开发者最容易踩的坑。

15.1 禁止对象展开运算符(Spread Operator)

错误写法

// ❌ arkts-no-spread
this.achievements.push({ ...this.allAchievements[0], unlocked: true });

正确写法

// ✅ 逐字段赋值
this.achievements.push({
  id: this.allAchievements[0].id,
  name: this.allAchievements[0].name,
  desc: this.allAchievements[0].desc,
  unlocked: true,
  icon: this.allAchievements[0].icon
});

ArkTS 出于性能考虑,禁止了对对象的展开操作(...),数组展开是允许的。这个限制在官方文档的 arkts-no-spread 规则中有明确说明。

15.2 Column 和 Row 的对齐属性类型不同

容器 alignItems 参数类型 示例
Column HorizontalAlign .alignItems(HorizontalAlign.Start)
Row VerticalAlign .alignItems(VerticalAlign.Center)
Flex ItemAlign .alignItems(ItemAlign.Center)

新手最常见的错误是在 Column 上使用 ItemAlign.Start,这会导致编译错误:

Argument of type 'ItemAlign' is not assignable to parameter of type 'HorizontalAlign'

15.3 @Builder 的方法声明

@Builder 方法不需要 : void 返回类型注解:

// ✅ 推荐
@Builder
buildMyComponent() {
  Column() { /* ... */ }
}

// ✅ 也可以,但不必要
@Builder
buildMyComponent(): void {
  Column() { /* ... */ }
}

15.4 ForEach 的类型注解

在 ForEach 的回调中,推荐显式标注参数类型:

ForEach(this.giftList, (gift: GiftItem, index: number) => {
  // ...
}, (gift: GiftItem, index: number) => index.toString())

15.5 @State 的不可变更新

对于数组类型的 @State,ArkTS 无法检测到 push() 方法对原数组的修改。虽然在实际测试中 @State messages: ChatMessage[] 配合 push() 似乎能触发渲染,但最安全的方式始终是创建新数组:

// 方式一:push(部分场景有效)
this.messages.push(newMsg);

// 方式二:重新赋值(100% 可靠)
this.messages = [...this.messages, newMsg];  // 数组展开允许

15.6 条件渲染与 @Builder

build() 方法中,可以使用 if/else if/else 进行条件渲染,并在分支中调用 @Builder 方法:

if (this.activePanel === 'chat') {
  this.buildChatPanel()
} else if (this.activePanel === 'gift') {
  this.buildGiftPanel()
}

这在 ArkTS API 11+ 中完全支持。


16. 扩展与优化方向

16.1 功能扩展

当前版本是舔狗模拟器的 MVP(最小可行产品),可以在此基础上做大量扩展:

  1. 更多剧情:增加 20+ 场景,不同的性格线(温柔型、高冷型、搞笑型)
  2. 多角色:可以切换追求不同的目标对象,每个对象有不同的反应模式
  3. 道具系统:增加更多可购买道具,如电影票、旅行券等
  4. 天气系统:天气影响对方的心情和回复
  5. 朋友圈系统:模拟刷朋友圈、点赞、评论的互动
  6. 成就通知:解锁成就时显示更精美的弹窗动画

16.2 性能优化

  1. 虚拟列表:聊天记录过长时,使用 LazyForEach 替代 ForEach,只渲染可见区域
  2. 状态拆分:将一个大组件拆分为多个 @Component 子组件,减少不必要的渲染
  3. 动画过渡:切换面板时加入转场动画,提升用户体验

16.3 代码重构

  1. 将场景数据抽离到独立的 JSON 文件中
  2. 使用 @Component 替代部分 @Builder,提高复用性
  3. 引入状态管理模式(如 @Provide / @Consume)处理跨组件通信
  4. 添加单元测试,覆盖核心玩法的边界条件

结语

本文通过 舔狗模拟器 这个完整的 ArkTS 项目,逐步讲解了以下核心技术:

技术领域 核心知识点
数据建模 interface 定义、联合类型、可选属性
状态管理 @State 装饰器、getter 计算属性
布局系统 Column + HorizontalAlign + justifyContent
组件复用 @Builder 自定义构建函数
条件渲染 if/else if 分支、ForEach 循环
事件处理 onClick、setTimeout 延迟调用
路由导航 router.pushUrl / router.back
交互反馈 promptAction.showToast

整个应用只用了一个文件、1250 行代码,就实现了一个完整的互动游戏。这充分展示了 ArkTS 声明式 UI 在快速开发中的生产力优势。

项目中的所有代码均可在以下路径找到:

entry/src/main/ets/pages/SimpSimulator.ets   — 游戏主逻辑
entry/src/main/ets/pages/Index.ets           — 首页导航
entry/src/main/resources/base/profile/       — 页面路由配置

将项目导入 DevEco Studio,连接 HarmonyOS NEXT 6.1.1 以上版本的真机或模拟器即可运行体验。

最后提醒:本文中的"舔狗"概念来源于网络文化,仅作为技术演示的趣味主题。在真实社交中,平等、真诚、互相尊重才是健康关系的基础。


17. UI 交互细节与用户体验设计

17.1 颜色与情感映射

舔狗模拟器的 UI 色彩设计遵循「情感映射」原则,好感度不同阶段使用不同的颜色:

好感度区间 颜色 十六进制 情感映射
0 ~ 20 红色 #E74C3C 危险、被讨厌
21 ~ 40 橙色 #E67E22 陌生、冷淡
41 ~ 60 黄色 #F1C40F 中立、观望
61 ~ 80 绿色 #2ECC71 友好、舒适
81 ~ 100 翠绿 #27AE60 亲密、心动

这个色彩渐变让玩家仅凭颜色就能直观感知当前的关系状态,不需要阅读具体数值。配合表情符号(😐 → 🙂 → 😊 → 😄 → 🥰 → 💖),形成了多层次的反馈体系。

17.2 Toast 提示的使用

游戏在多个关键节点使用 promptAction.showToast() 提供即时反馈:

// 成功反馈
promptAction.showToast({ message: '🏆 解锁成就:挥金如土', duration: 2000 });

// 错误提示
promptAction.showToast({ message: '余额不足,先去做每日任务赚钱吧!', duration: 2000 });

// 状态提示
promptAction.showToast({ message: '今天已经打过卡啦,明天再来吧!', duration: 1500 });

duration 参数控制 Toast 显示时长(毫秒),成功消息给 2 秒,普通提示给 1.5 秒。

17.3 延迟响应的沉浸感

游戏中所有对方回复都使用 setTimeout 延迟 600~800ms 后出现,这是为了模拟「真人打字需要时间」的体验。如果回复瞬间出现,反而会显得不真实。延迟时间的选择:

  • 剧情选择回复:800ms —— 较长,表示对方在思考
  • 送礼回复:600ms —— 较短,表示收到礼物后的即时反应
  • 每日签到回复:600ms —— 自然的早安回复节奏

17.4 面板切换的视觉反馈

选项卡使用边框高亮和背景色变化来标识当前激活的面板:

.backgroundColor(this.activePanel === panelId ? '#F0FFFA' : '#FFFFFF')
.border({
  width: this.activePanel === panelId ? 1.5 : 0,
  color: '#4ECDC4'
})

激活状态:淡绿色背景 + 绿色边框
非激活状态:白色背景 + 无边框

这种设计让玩家清晰地知道当前所处哪个功能模块。


18. 游戏场景设计思路

18.1 场景设计原则

10 个场景的设计遵循「三幕式结构」:

第一幕(场景 1~3):建立关系

  • 初次搭讪:测试玩家的基本社交能力
  • 奶茶互动:引入送礼概念
  • 关心健康:观察玩家的同理心

第二幕(场景 4~7):加深羁绊

  • 约会邀请:测试邀约技巧
  • 逛街消费:经济意识考验
  • 深夜聊天:建立情感连接
  • 生日惊喜:用心程度检验

第三幕(场景 8~10):情感考验

  • 吃醋场景:信任与猜疑的选择
  • 挫折支持:情绪价值的提供
  • 最终表白:决定结局的时刻

18.2 好感度变化量的设计

每个选项的好感度变化量不是随意设定的,而是遵循「风险与回报」原则:

选择类型 风险 回报 示例
稳妥型 低(+1 ~ +5) “多喝热水”
用心型 中(+8 ~ +15) 送粥和药、安静倾听
浪漫型 高(+20 ~ +30) 亲手做相册、真诚表白
踩雷型 极高 负(-10 ~ -30) 摆蜡烛表白、说"我养你"

这种设计让玩家在每次选择时都需要权衡——是选择安全的选项稳步推进,还是冒险选择高回报选项快速升温。

18.3 死亡选项的节奏控制

10 个场景中有 3 个直接死亡选项(场景 2、4、9、10),分布在游戏的前、中、后期:

  • 场景 2(早期):评论"少喝奶茶"——测试玩家是否懂分寸
  • 场景 4(中期):去对方家楼下——测试玩家是否尊重边界
  • 场景 9(后期):说"我养你"——测试玩家是否理解情绪支持
  • 场景 10(最终):摆蜡烛表白——测试玩家是否理解对方感受

死亡选项不是随机惩罚,而是有教育意义的"反例教学"。


19. 从编译错误中学到的 ArkTS 规则

在开发过程中,我们遇到了 23 个编译错误。这些错误不仅是障碍,更是学习 ArkTS 规则的最佳教材。

19.1 错误全景图

错误类型 数量 规则编号 根因
对象展开禁止 8 arkts-no-spread { ...obj } 不被允许
类型不匹配 15 ItemAlign 误用于 Column

19.2 arkts-no-spread 规则详解

ArkTS 出于编译优化考虑,仅允许在数组字面量中使用展开运算符,对象字面量中的展开被禁止:

// ✅ 允许:数组展开
const arr = [1, 2, 3];
const newArr = [...arr, 4];

// ❌ 禁止:对象展开
const obj = { a: 1, b: 2 };
const newObj = { ...obj, c: 3 };  // arkts-no-spread

// ✅ 正确做法:逐字段赋值
const newObj = { a: obj.a, b: obj.b, c: 3 };

这个限制的底层原因:ArkTS 的编译器和运行时需要对对象结构进行精确的静态分析,而展开运算符会引入动态属性,破坏这种分析的确定性。

19.3 HorizontalAlign 与 ItemAlign 的区分

这是 ArkTS 中新手最容易混淆的概念。关键在于理解组件的主轴方向:

Column(垂直容器)

主轴:垂直方向(从上到下)
交叉轴:水平方向(从左到右)
alignItems() 控制:交叉轴 → 水平方向 → HorizontalAlign

Row(水平容器)

主轴:水平方向(从左到右)
交叉轴:垂直方向(从上到下)
alignItems() 控制:交叉轴 → 垂直方向 → VerticalAlign

Flex(弹性容器)

主轴:可自定义(通过 direction 属性)
alignItems() 控制:交叉轴 → ItemAlign(通用)

记忆方法:Column 管水平,Row 管垂直,Flex 最灵活。


20. 与同类框架的对比

20.1 ArkTS vs SwiftUI

特性 ArkTS SwiftUI
容器 Column / Row VStack / HStack
对齐 HorizontalAlign / VerticalAlign alignment: .leading
状态 @State @State
复用 @Builder @ViewBuilder
循环 ForEach ForEach

两者在概念上高度相似,ArkTS 显然是借鉴了 SwiftUI 的声明式 UI 理念。

20.2 ArkTS vs Jetpack Compose

特性 ArkTS Jetpack Compose
布局 Column / Row Column / Row
对齐 .alignItems() .align()
状态 @State remember { mutableStateOf() }
条件 if 语句 if 语句
构建函数 @Builder @Composable

20.3 ArkTS 的独特优势

  1. 原生中文支持:变量名、注释、字符串都可以用中文,对中文开发者友好
  2. Stage 模型:清晰的能力生命周期管理
  3. 编译时优化:通过限制某些 JS 特性(如对象展开),换取更好的运行时性能
  4. HarmonyOS 生态集成:可直接调用系统能力(振动、通知、NFC 等)

21. 数据流与响应式渲染分析

21.1 数据流闭环

舔狗模拟器的数据流遵循「单向数据流」模式:

用户操作(点击/输入)
    ↓
事件回调(onClick/onChange)
    ↓
状态更新(修改 @State 变量)
    ↓
UI 自动重新渲染(build() 重新执行)
    ↓
用户看到新界面
    ↑
    └─────────────── 循环 ───────────────┘

21.2 状态变更触发链

以「选择对话选项」为例,一次操作触发的状态变更链:

selectChoice(choice)
  ├── this.messages.push()          → 聊天记录更新
  ├── setTimeout 800ms
  │     ├── this.messages.push()    → 对方回复显示
  │     ├── this.affection += delta → 好感度变化(进度条、等级、颜色同步更新)
  │     ├── this.money -= 2         → 零花钱更新
  │     ├── this.currentScene++     → 场景切换
  │     └── this.checkAchievements()→ 成就解锁检测
  └── UI 重渲染

每个 @State 变量的变化都会触发 UI 重渲染,但 ArkTS 的编译器会做最小化 diff,只更新发生变化的部分。

21.3 渲染性能考虑

在 1250 行的代码中,最可能影响性能的是聊天记录 ForEach 的重渲染。每次新消息加入,ForEach 都会重新执行所有消息的渲染。当消息数量超过 100 条时,可能出现卡顿。

优化方案:使用 LazyForEach 替代 ForEach,它只渲染可见区域的列表项:

// 性能优化方向
LazyForEach(this.dataSource, (msg: ChatMessage) => {
  this.buildMessageBubble(msg)
}, (msg: ChatMessage) => msg.id.toString())

但在本应用的典型使用场景中,消息数量通常不超过 50 条,ForEach 的性能完全足够。


22. 完整代码架构图

为了帮助读者更好地理解文件结构,以下是用 ASCII 艺术图表示的代码架构:

┌─────────────────────────────────────────────────────────────┐
│                    SimpSimulator.ets                         │
│                      1,250 行                                │
├───────────────┬─────────────────────────────────────────────┤
│  数据层        │  interface ChatMessage                      │
│  (接口定义)    │  interface Achievement                      │
│               │  interface Choice                            │
│               │  interface Scene                             │
│               │  interface GiftItem                          │
├───────────────┼─────────────────────────────────────────────┤
│  状态层        │  @State affection: number                   │
│  (@State)     │  @State money: number                        │
│               │  @State messages: ChatMessage[]              │
│               │  @State ... (共 13 个状态变量)               │
├───────────────┼─────────────────────────────────────────────┤
│  数据层        │  private scenes: Scene[] (10 个场景)       │
│  (私有属性)    │  private giftList: GiftItem[] (5 个礼物)    │
│               │  private allAchievements (8 个成就)          │
├───────────────┼─────────────────────────────────────────────┤
│  计算属性      │  get affectionLevel(): string               │
│  (getter)     │  get affectionColor(): string                │
│               │  get affectionEmoji(): string                │
│               │  get moneyDisplay(): string                  │
├───────────────┼─────────────────────────────────────────────┤
│  业务逻辑      │  selectChoice()     — 选择处理              │
│  (方法)       │  sendGift()          — 送礼逻辑              │
│               │  doDailyGreeting()   — 签到逻辑              │
│               │  sendFreeChat()      — 自由聊天              │
│               │  checkAchievements() — 成就检测              │
│               │  resetGame()         — 重置游戏              │
├───────────────┼─────────────────────────────────────────────┤
│  UI 层        │  build()              — 入口构建             │
│  (build +     │  buildTabButton()     — 选项卡               │
│   @Builder)   │  buildChatPanel()     — 聊天面板             │
│               │  buildGiftPanel()     — 送礼面板             │
│               │  buildDailyPanel()    — 打卡面板             │
│               │  buildAchievementPanel() — 成就面板          │
│               │  buildEndingPanel()   — 结局面板             │
│               │  buildStatRow()       — 统计行               │
└───────────────┴─────────────────────────────────────────────┘

23. 从项目中学到的关键经验

23.1 声明式 UI 的思维方式转变

从传统的命令式 UI(如 Java Swing、Android XML)转向声明式 UI(如 ArkTS),需要思维方式的转变:

命令式思维

获取文本框 → 读取值 → 验证 → 设置按钮颜色 → 显示提示

声明式思维

绑定状态到属性:按钮颜色 = 余额充足 ? 绿色 : 灰色
当余额变化时,按钮颜色自动更新

在舔狗模拟器中,好感度进度条的颜色就是声明式思维的典型例子——UI 自动跟随 this.affection 的值变化,不需要手动更新。

23.2 单一文件 vs 多文件结构

本项目将全部代码放在一个 .ets 文件中,优点是:

  • 方便阅读和理解整个应用的逻辑
  • 减少文件之间的跳转
  • 适合小型项目

缺点是:

  • 1250 行的文件可读性下降
  • 团队协作时容易产生冲突
  • 不利于代码复用

对于实际项目,建议在代码量超过 500 行时进行拆分。

23.3 测试与调试策略

在开发过程中,promptAction.showToast() 不仅用于用户提示,也可以作为调试工具:

// 调试用途
this.addSystemMessage(`💬 好感度 +${affectionGain},零花钱 +¥20`);

在 API 24 中,还可以使用 console.info() 输出日志到 DevEco Studio 的 Log 面板。


24. 总结与展望

舔狗模拟器虽然是一个趣味性项目,但它涵盖了 ArkTS 开发的全部核心知识:

  1. 声明式 UI 构建:使用 Column、Row、Text、Button、TextInput、ForEach 等基础组件
  2. 状态管理:@State 装饰器 + getter 计算属性
  3. 组件复用:@Builder 自定义构建函数
  4. 业务逻辑:完整的游戏循环(场景、选择、判定、结局)
  5. 用户体验:延迟回复、Toast 反馈、色彩映射
  6. 编译规范:遵守 ArkTS 的语法限制(无对象展开、正确的对齐类型)

这个项目证明了使用 ArkTS 进行快速原型开发的可行性——一个功能完整的交互游戏,仅需 1250 行代码、一个源文件即可实现。

在后续的鸿蒙原生开发学习中,你可以从这个项目出发,尝试:

  • 将单一组件拆分为多组件架构
  • 引入数据持久化(@Storage / Preferences)
  • 添加动画和转场效果
  • 集成系统能力(振动、通知、分享)

本文基于 HarmonyOS NEXT 6.1.1(API 24)Stage 模型编写,SDK 版本 targetSdkVersion = "6.1.1(24)"
全部源代码均通过 hvigorw assembleHap 编译验证,BUILD SUCCESSFUL
示例项目见 entry/src/main/ets/pages/SimpSimulator.ets

Logo

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

更多推荐