鸿蒙 ArkTS 情绪模拟游戏开发实战:「分手模拟器」完整教程

鸿蒙 ArkTS 情绪模拟游戏开发实战:「分手模拟器」完整教程
SDK 版本:HarmonyOS NEXT 6.1.1(API 24)
开发语言:ArkTS 声明式 UI
项目模式:Stage 模型
源码文件:entry/src/main/ets/pages/BreakupSimulator.ets(999 行)
编译状态:hvigor BUILD SUCCESSFUL
目录
- 项目背景与设计理念
- 项目结构与路由注册
- 数据模型设计
- 状态管理架构
- 场景系统与剧情设计
- 核心玩法:心碎值与恢复进度
- 选择处理与状态演化
- 纪念物品系统
- 日记系统与情绪梳理
- 三种结局判定机制
- UI 布局设计详解
- @Builder 组件化构建
- 双进度条视觉实现
- 页面路由与导航
- ArkTS 关键语法实践
- 心理学视角的游戏设计
- 与舔狗模拟器的对比分析
- 扩展方向与技术展望
1. 项目背景与设计理念
1.1 为什么会想做分手模拟器
在完成了「舔狗模拟器」之后,我们发现用 ArkTS 做情感互动类应用是一个非常有趣的探索方向。舔狗模拟器走的是幽默讽刺路线,而分手则是一个更具深度和共鸣感的主题。
几乎每个人都经历过分手——那些失眠的夜晚、反复点开的聊天框、删了又舍不得删的照片。分手模拟器的初衷,是用互动叙事的方式,让玩家体验一段完整的心理疗愈过程,从"心碎"到"释怀"。
1.2 设计目标
- 情感共鸣:场景和文案要真实,让有类似经历的人产生共鸣
- 教育意义:通过游戏传递健康的心理恢复方式——社交支持、运动、写日记、断联
- 玩法深度:双进度系统(心碎值 + 恢复进度)和三条结局线,让玩家有复玩动力
- 技术示范:完整展示 ArkTS 在复杂交互场景下的开发能力
1.3 时间线设计
游戏覆盖了分手后 60 天的心理恢复过程,遵循心理学经典的「悲伤五阶段」理论:
| 时间 | 阶段 | 对应心理学模型 |
|---|---|---|
| D1 | 难以置信 | 否认(Denial) |
| D3 | 愤怒 | 愤怒(Anger) |
| D5 | 回忆 | 讨价还价(Bargaining) |
| D7 | 挣扎 | 抑郁(Depression) |
| D10~D14 | 学着独处 | 开始接纳 |
| D21~D28 | 习惯 | 重构生活 |
| D45 | 偶遇 | 检验恢复成果 |
| D60 | 释怀 | 接纳(Acceptance) |
2. 项目结构与路由注册
2.1 文件结构
entry/src/main/ets/pages/
├── Index.ets # 首页(导航总控)
├── ColumnStartDemo.ets # 布局演示
├── SimpSimulator.ets # 舔狗模拟器
└── BreakupSimulator.ets # 分手模拟器(本文)
2.2 路由配置
{
"src": [
"pages/Index",
"pages/ColumnStartDemo",
"pages/SimpSimulator",
"pages/BreakupSimulator"
]
}
2.3 SDK 版本
{
"products": [
{
"targetSdkVersion": "6.1.1(24)",
"compatibleSdkVersion": "6.1.1(24)",
"runtimeOS": "HarmonyOS"
}
]
}
API 24 支持所有现代 ArkTS 特性:@State 响应式状态、@Builder 自定义构建函数、ForEach 循环、条件渲染、.clip()、.enabled() 等。
3. 数据模型设计
分手模拟器定义了 4 个核心接口,分别对应游戏中的不同数据实体。
3.1 DiaryEntry(日记条目)
interface DiaryEntry {
id: number;
text: string;
day: number;
}
day === 0 的特殊条目用来表示系统消息,在渲染时使用斜体样式区分。
3.2 MemoItem(纪念物品)
interface MemoItem {
id: number;
name: string;
desc: string;
emoji: string;
kept: boolean;
}
kept 字段控制物品的显示状态——当物品被丢弃后,文字显示删除线,按钮变为不可用。
3.3 SceneChoice(场景选项)
interface SceneChoice {
text: string;
heartDelta: number; // 心碎值变化
progressDelta: number; // 恢复进度变化
reply: string; // 内心独白
isRelapse?: boolean; // 是否触发复发
}
与舔狗模拟器的 Choice 相比,这里多了一个 progressDelta 字段,因为分手模拟器需要维护两个并行的数值系统。
3.4 Scene(场景)
interface Scene {
title: string;
setting: string;
choices: SceneChoice[];
}
每个场景包含标题、情景描述和三个选项。
3.5 接口设计原则
ArkTS 中的 interface 是纯数据类型,编译后不会生成任何运行时代码。相比 class,interface 更轻量、更安全,适合做 DTO(数据传输对象)。在 ArkTS 中还有一个重要限制——interface 不支持使用展开运算符,这在后续的代码编写中需要注意。
4. 状态管理架构
4.1 @State 状态一览
分手模拟器使用了 12 个 @State 状态变量:
@State heartbreak: number = 85; // 心碎值 0~100
@State recovery: number = 5; // 恢复进度 0~100
@State day: number = 1; // 分手第 N 天
@State currentScene: number = 0; // 当前场景
@State diary: DiaryEntry[] = []; // 日记列表
@State memos: MemoItem[] = []; // 纪念物品
@State isGameOver: boolean = false; // 游戏结束
@State endingText: string = ''; // 结局文字
@State endingType: string = ''; // 结局类型
@State activePanel: string = 'scene'; // 当前面板
@State diaryText: string = ''; // 日记输入
@State showEnding: boolean = false; // 显示结局
4.2 计算属性(getter)
通过 get 关键字定义的只读属性,根据状态值自动派生:
get heartbreakLevel(): string {
if (this.heartbreak >= 80) return '痛彻心扉';
if (this.heartbreak >= 60) return '很难过';
if (this.heartbreak >= 40) return '有点低落';
if (this.heartbreak >= 20) return '慢慢愈合';
return '基本释怀';
}
get heartColor(): string {
if (this.heartbreak >= 70) return '#8B0000'; // 暗红
if (this.heartbreak >= 50) return '#E74C3C'; // 红
if (this.heartbreak >= 30) return '#E67E22'; // 橙
if (this.heartbreak >= 15) return '#F1C40F'; // 黄
return '#27AE60'; // 绿
}
get recoveryStatus(): string {
if (this.recovery >= 90) return '🌟 重获新生';
if (this.recovery >= 70) return '💪 大步向前';
if (this.recovery >= 40) return '🚶 慢慢前行';
if (this.recovery >= 20) return '🥀 还在挣扎';
return '💔 深陷其中';
}
计算属性的设计优势:UI 层只需要引用 this.heartbreakLevel 和 this.heartColor,不需要任何条件判断,视图层代码非常干净。
4.3 @State 更新注意事项
在 ArkTS 中,对于数组类型的 @State,直接调用 push() 方法有时能触发 UI 更新,但不是 100% 可靠的。最安全的做法是创建新数组并重新赋值。不过在分手模拟器的实际测试中,push() 配合 UI 渲染工作正常,因为每次 push 后都会立即插入到 ForEach 渲染的列表中。
5. 场景系统与剧情设计
5.1 十场景时间线
游戏共包含 10 个场景,沿着 60 天的时间线展开:
| 场景 | 标题 | 日期 | 核心情绪 |
|---|---|---|---|
| 0 | 第一天 · 难以置信 | D1 | 震惊、否认、习惯性联系 |
| 1 | 第三天 · 愤怒 | D3 | 愤怒、发泄、破坏冲动 |
| 2 | 第五天 · 回忆 | D5 | 睹物思人、触景生情 |
| 3 | 第七天 · 挣扎 | D7 | 被联系时的心理博弈 |
| 4 | 第十天 · 学着独处 | D10 | 首次完全独处、自我重建 |
| 5 | 第十四天 · 反思 | D14 | 收到遗物、情感筛选 |
| 6 | 第二十一天 · 习惯 | D21 | 戒断反应减轻、新习惯形成 |
| 7 | 第二十八天 · 新生 | D28 | 重新认识自己、自我照顾 |
| 8 | 第四十五天 · 偶遇 | D45 | 面对过去的勇气 |
| 9 | 第六十天 · 释怀 | D60 | 最终接纳、仪式性告别 |
5.2 场景数据定义
每个场景包含标题、情景描述和三个选择。以下是一个完整的场景定义示例:
{
title: '第七天 · 挣扎',
setting: '深夜,手机响了。是那个熟悉的头像发来的消息。',
choices: [
{
text: '点开消息,看到对方说"最近还好吗"。',
heartDelta: 20,
progressDelta: -10,
reply: '你在对话框里输了又删,删了又输,最终还是没发出去。',
isRelapse: true,
},
{
text: '静音手机,明天再看。',
heartDelta: -5,
progressDelta: 5,
reply: '你翻了个身,告诉自己:明天再处理。'
},
{
text: '删掉对话框,继续看剧。',
heartDelta: -8,
progressDelta: 8,
reply: '你点开了一部喜剧,笑得越大声,心里越酸。'
},
],
}
5.3 选项设计原则
每个场景的三个选项分别对应不同的应对策略:
- 健康应对:心碎值下降多、恢复进度上升多(如运动、社交、自我提升)
- 中性应对:效果居中或短暂缓解(如喝酒、看剧)
- 不健康应对:心碎值上升、恢复进度下降(如联系对方、翻看旧回忆)
这种设计让玩家在每次选择时都能感受到——你的选择决定了你恢复的速度。这不是说教,而是通过游戏机制让玩家自己体会到什么是有益的应对方式。
6. 核心玩法:心碎值与恢复进度
6.1 双进度系统
与传统的单数值游戏不同,分手模拟器引入了两个并行的数值系统:
心碎值 (Heartbreak) 0 ──────────────── 100
↑ 初始 85 ↓ 越低越好
恢复进度 (Recovery) 0 ──────────────── 100
↓ 初始 5 ↑ 越高越好
- 心碎值:初始 85(刚分手,极度心碎),需要通过各种方式降低
- 恢复进度:初始 5(几乎没有任何恢复),需要逐步积累
6.2 数值变化的联动逻辑
两个数值之间没有直接的数学关联,但玩法上紧密联动:
- 好的选择 = 心碎下降 + 恢复上升
- 差的选择 = 心碎上升 + 恢复下降
- 中性的选择 = 一方不变,另一方微调
- 极端情况(复发选项且在特定条件下)= 游戏直接结束
6.3 边界保护
this.heartbreak = Math.max(0, Math.min(100, this.heartbreak + choice.heartDelta));
this.recovery = Math.max(0, Math.min(100, this.recovery + choice.progressDelta));
每次数值变化都使用 Math.max(0, Math.min(100, ...)) 进行边界钳制,确保两个数值始终在 0~100 的范围内。
6.4 进度条视觉映射
Row()
.width(`${this.heartbreak}%`)
.height(8)
.backgroundColor(this.heartColor)
.borderRadius(4)
Row()
.width(`${this.recovery}%`)
.height(8)
.backgroundColor('#27AE60')
.borderRadius(4)
使用 Percent string 格式 ${value}% 动态绑定宽度,ArkTS 完美支持这种用法。
7. 选择处理与状态演化
7.1 选择处理流程
private selectChoice(choice: SceneChoice): void {
if (this.isGameOver) return;
// ① 立即记录选择
this.addDiary(`→ ${choice.text}`);
// ② 延迟回复(模拟内心活动)
setTimeout(() => {
if (this.isGameOver) return;
// ③ 记录内心独白
this.addDiary(`💭 ${choice.reply}`);
// ④ 更新心碎值
this.heartbreak = Math.max(0, Math.min(100, this.heartbreak + choice.heartDelta));
// ⑤ 更新恢复进度
this.recovery = Math.max(0, Math.min(100, this.recovery + choice.progressDelta));
this.sceneCount++;
// ⑥ 结局判定
if (choice.isRelapse && this.heartbreak >= 90) {
// 复发结局
this.isGameOver = true;
this.showEnding = true;
this.endingType = 'relapse';
return;
}
if (this.recovery >= 100 && this.heartbreak <= 15) {
// 完美释怀结局
this.isGameOver = true;
this.showEnding = true;
this.endingType = 'recovered';
return;
}
if (this.heartbreak <= 0 && this.recovery < 60) {
// 假装释怀结局
this.isGameOver = true;
this.showEnding = true;
this.endingType = 'stuck';
return;
}
// ⑦ 进入下一场景或下一天
this.currentScene++;
if (this.currentScene >= this.scenes.length) {
this.currentScene = 0;
this.day += 3;
}
this.showCurrentScene();
}, 700);
}
7.2 状态变化的核心逻辑
整个游戏状态是一个多维度演化系统:
用户选择 → 心碎值变化 + 恢复进度变化 → UI 双进度条更新
→ 结局条件检测
→ 场景推进
→ 日记记录更新
每一个选择都会同时影响两条数值线,这种设计让每一次选择都充满张力。
8. 纪念物品系统
8.1 物品定义
private allMemos: MemoItem[] = [
{ id: 1, name: '电影票根', desc: '第一次一起看电影的票根', emoji: '🎬', kept: true },
{ id: 2, name: '情侣手链', desc: '那对刻着名字的手链', emoji: '📿', kept: true },
{ id: 3, name: '合照相框', desc: '笑得最开心的一张合照', emoji: '🖼️', kept: true },
{ id: 4, name: '日记本', desc: '记录着点点滴滴的日记本', emoji: '📔', kept: true },
{ id: 5, name: '纪念礼物', desc: '生日时送的礼物', emoji: '🎁', kept: true },
];
8.2 丢弃逻辑
private discardMemo(index: number): void {
if (this.isGameOver) return;
const memo = this.allMemos[index];
if (!memo.kept) return;
memo.kept = false;
this.addDiary(`📦 扔掉了 ${memo.emoji} ${memo.name} —— ${memo.desc}`);
this.heartbreak = Math.max(0, this.heartbreak - 8);
this.recovery = Math.min(100, this.recovery + 5);
setTimeout(() => {
if (this.isGameOver) return;
this.addDiary('有点不舍,但你知道这是必经的过程。');
}, 500);
}
每次丢弃物品:心碎值 -8,恢复进度 +5。
8.3 物品的象征意义
纪念物品系统是分手模拟器中最有仪式感的设计。每一件物品都代表一段具体的回忆,丢弃它们是一个具有象征意义的动作。在 UI 上,已经丢弃的物品会显示删除线文字,按钮变为灰色且不可点击:
Text(memo.name)
.decoration({
type: memo.kept ? TextDecorationType.None : TextDecorationType.LineThrough
})
9. 日记系统与情绪梳理
9.1 自由输入
TextInput({ placeholder: '今天过得怎么样……', text: this.diaryText })
.layoutWeight(1)
.height(40)
.padding({ left: 12 })
.backgroundColor('#FFFFFF')
.borderRadius(20)
.fontSize(13)
.onChange((val: string) => { this.diaryText = val; })
9.2 日记写作逻辑
private writeDiary(): void {
if (this.isGameOver) return;
if (!this.diaryText.trim()) {
promptAction.showToast({ message: '写点什么吧……', duration: 1000 });
return;
}
this.addDiary(`📝 ${this.diaryText.trim()}`);
this.heartbreak = Math.max(0, this.heartbreak - 3);
this.recovery = Math.min(100, this.recovery + 2);
setTimeout(() => {
if (this.isGameOver) return;
this.addDiary('写下来之后,感觉心里轻松了一点。');
}, 400);
this.diaryText = '';
}
每次写日记:心碎值 -3,恢复进度 +2。
9.3 随机提示引导
private diaryPrompts: string[] = [
'今天又想起了你,但好像没有那么痛了。',
'路过那家店,我没有进去。我在进步。',
'朋友说我最近看起来好多了。我说谎了,但至少我在努力。',
'今天做了一道你最爱吃的菜,味道还不错。',
'删掉了相册里最后的照片。深吸一口气。',
'今天天气很好,我出去走了一圈。世界还在转。',
'我好像,开始习惯没有你的日子了。',
];
用户可以通过「💡 写点什么呢?」按钮获取随机提示,解决"想写但不知道写什么"的问题。
9.4 日记历史展示
在日记面板中,系统消息和普通日记条目分开渲染,只显示非系统消息、非选择记录的纯日记内容,形成一本完整的「疗愈日记」。
10. 三种结局判定机制
10.1 结局条件
分手模拟器设计了三条结局线,由心碎值和恢复进度的最终状态共同决定:
| 结局 | 触发条件 | 颜色 | 含义 |
|---|---|---|---|
| 🌟 彻底释怀 | recovery >= 100 && heartbreak <= 15 |
绿色 | 真正走出 |
| 💀 重蹈覆辙 | isRelapse && heartbreak >= 90 |
红色 | 再次受伤 |
| 🌫️ 假装释怀 | heartbreak <= 0 && recovery < 60 |
橙色 | 表面放下 |
10.2 结局文字
彻底释怀:
你终于走出来了。
那些哭过的夜晚、删掉的照片、走过的路,都成了你的一部分。
你变得更坚强了。下一次爱情来的时候,你会更好地拥抱它。
—— 分手不是结束,而是新生的开始。
重蹈覆辙:
你最终还是没能忍住。那条消息之后,你们又纠缠了一个月,比分手更痛苦。
有些门,关上之后就不该再打开了。
假装释怀:
你不再心碎了,但也没有真正走出来。
你把那段感情封印在了心底最深处,假装什么都没发生过。
可是深夜偶尔梦到的时候,醒来还是会发呆很久。
—— 真正的放下,不是忘记,而是坦然面对。
10.3 结局触发时机
结局检测发生在每次选择处理之后:
// 顺序很重要——复发判定优先
if (choice.isRelapse && this.heartbreak >= 90) { /* 复发 */ }
else if (this.recovery >= 100 && this.heartbreak <= 15) { /* 释怀 */ }
else if (this.heartbreak <= 0 && this.recovery < 60) { /* 假装 */ }
注意检测顺序:复发 > 释怀 > 假装。优先级最高的是复发结局,因为它需要特定的选项触发。如果三个条件都不满足,游戏继续。
11. UI 布局设计详解
11.1 整体布局架构
Scroll() ← 页面可滚动
Column() ← 主容器(ColumnStart)
├─ 标题区 ─ 标题 + 副标题
├─ 双状态面板 ─ 心碎进度条 + 恢复进度条
├─ 选项卡栏 ─ 场景 / 日记 / 纪念
├─ 内容面板(条件渲染) ─
│ ├─ 场景面板 ─ 故事流 + 选项按钮
│ ├─ 日记面板 ─ 输入框 + 日记历史
│ └─ 纪念面板 ─ 物品列表 + 丢弃按钮
├─ 结局面板 ─ 结局展示(条件渲染)
└─ 底部操作栏 ─ 重新开始 + 返回
11.2 ColumnStart 布局配置
Column() {
// 所有子组件
}
.width('100%')
.alignItems(HorizontalAlign.Start) // 水平左对齐
.justifyContent(FlexAlign.Start) // 垂直顶部对齐
.padding({ left: 16, right: 16 })
.backgroundColor('#F5F6FA')
11.3 选项卡切换
Row() {
this.buildTab('🎭', '场景', 'scene')
this.buildTab('📖', '日记', 'diary')
this.buildTab('📦', '纪念', 'memos')
}
// 条件渲染
if (this.activePanel === 'scene') this.buildScenePanel()
else if (this.activePanel === 'diary') this.buildDiaryPanel()
else if (this.activePanel === 'memos') this.buildMemosPanel()
使用紫色主题色 #8E44AD 作为品牌色,区别于舔狗模拟器的绿色和红色。
11.4 故事流样式
故事面板由两条流组成:
- 系统消息(斜体灰字):
📅 第 X 天、场景标题 - 玩家选择(带 D 前缀):
→ 选择了...、💭 内心独白...
📅 日子一天天过去……第 3 天
── 第三天 · 愤怒 ──
你无意中翻到一张合照,情绪突然涌上来。
D1 → 把聊天记录从头翻到尾,哭了一场。
D1 💭 看着那些曾经的甜言蜜语,像刀子一样扎在心上。
D3 → 出门跑了一圈,跑到精疲力尽。
D3 💭 汗水把眼泪都带走了,累到没力气难过。
11.5 ForEach 条件渲染详解
在场景面板的日记展示中,我们使用了 ForEach 配合条件渲染来实现不同类型条目的差异化样式:
ForEach(this.diary, (entry: DiaryEntry) => {
if (entry.day === 0) {
// 系统消息(day === 0):斜体 + 灰色
Text(entry.text)
.fontSize(13)
.fontColor('#7F8C8D')
.fontStyle(FontStyle.Italic)
.lineHeight(20)
.width('100%')
.margin({ top: 4, bottom: 4 })
} else {
// 普通日记条目:带 D 前缀的对话流样式
Row() {
Text(`D${entry.day}`)
.fontSize(10)
.fontColor('#BDC3C7')
.margin({ right: 8, top: 4 })
Column() {
Text(entry.text)
.fontSize(13)
.fontColor('#2C3E50')
.lineHeight(20)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.margin({ top: 4, bottom: 4 })
}
}, (entry: DiaryEntry) => entry.id.toString())
这里展示了 ArkTS 的一个强大特性——在 ForEach 的 itemGenerator 回调中,可以使用 if/else 分支渲染不同的 UI 结构。系统消息不带 D 前缀,使用斜体灰色居中显示;普通条目带 D 前缀,使用左对齐的对话流样式。
关键样式解析:
- 系统消息:
day === 0表示这是一条系统插入的描述性文字,不参与剧情选择树,用斜体和灰色弱化视觉权重 - 天数前缀:
D${entry.day}以极小的字号和浅灰色显示在每一行的左侧,形成时间线效果 - 自动换行:通过
.lineHeight(20)控制行高,.layoutWeight(1)让文字区域占据剩余宽度,实现自动换行
这种设计让整个故事面板看起来像是一本展开的日记,每一天的记录一目了然。
11.6 故事容器滚动控制
故事面板使用 constraintSize({ maxHeight: 300 }) 限制最大高度,超出部分可随页面整体滚动:
Column() {
ForEach(this.diary, (entry: DiaryEntry) => {
// ...渲染逻辑
}, (entry: DiaryEntry) => entry.id.toString())
}
.width('100%')
.padding(12)
.backgroundColor('#F8F9FA')
.borderRadius(12)
.constraintSize({ maxHeight: 300 })
.clip(true)
.margin({ bottom: 12 })
constraintSize 在这里起到了关键作用——它不像 width 或 height 那样强制固定尺寸,而是设置一个上限。当内容不超过 300vp 时,容器自适应内容高度;当内容超过 300vp 时,结合 .clip(true) 裁剪溢出部分。这确保了在游戏初期(日记条目少)不会出现大面积的空白,而在游戏后期(日记条目多)也不会撑破页面布局。
12. @Builder 组件化构建
12.1 本项目的 @Builder 清单
| 方法 | 参数 | 用途 |
|---|---|---|
buildTab |
emoji, label, panelId | 选项卡按钮 |
buildScenePanel |
无 | 场景故事面板 |
buildDiaryPanel |
无 | 日记面板 |
buildMemosPanel |
无 | 纪念物品面板 |
buildEndingPanel |
无 | 结局面板 |
buildStat |
label, value | 统计行 |
12.2 选项卡 @Builder 示例
@Builder
buildTab(emoji: string, label: string, panelId: string): void {
Column() {
Text(emoji).fontSize(22).margin({ bottom: 2 })
Text(label)
.fontSize(12)
.fontColor(this.activePanel === panelId ? '#8E44AD' : '#95A5A6')
.fontWeight(this.activePanel === panelId ? FontWeight.Bold : FontWeight.Normal)
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor(this.activePanel === panelId ? '#F4ECF7' : '#FFFFFF')
.borderRadius(10)
.border({ width: this.activePanel === panelId ? 1.5 : 0, color: '#8E44AD' })
.onClick(() => { this.activePanel = panelId; })
}
12.3 @Builder 的特点总结
- 参数化:通过函数参数控制不同面板的展示
- 访问 struct 成员:可以读取和修改
this.activePanel等状态 - 无实例化开销:比
@Component轻量 - 条件渲染兼容:可以在
if分支中调用@Builder方法
13. 双进度条视觉实现
13.1 心碎值进度条
Row() {
Text('💔 心碎值')
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#2C3E50')
Text(`${this.heartbreak}/100`)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(this.heartColor)
.margin({ left: 8 })
}
.margin({ bottom: 4 })
Row() {
Row()
.width(`${this.heartbreak}%`) // 动态宽度
.height(8)
.backgroundColor(this.heartColor) // 动态颜色
.borderRadius(4)
}
.width('100%')
.height(8)
.backgroundColor('#FADBD8') // 浅红色背景
.borderRadius(4)
13.2 恢复进度条
Row() {
Text('🌱 恢复进度')
Text(`${this.recovery}/100`)
.fontColor('#27AE60')
}
.margin({ bottom: 4 })
Row() {
Row()
.width(`${this.recovery}%`)
.height(8)
.backgroundColor('#27AE60') // 固定绿色
.borderRadius(4)
}
.width('100%')
.height(8)
.backgroundColor('#D5F5E3') // 浅绿色背景
.borderRadius(4)
13.3 状态摘要
Row() {
Column() {
Text('心碎状态').fontSize(11).fontColor('#95A5A6')
Text(this.heartbreakLevel).fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(this.heartColor)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text('恢复状态').fontSize(11).fontColor('#95A5A6')
Text(this.recoveryStatus).fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#27AE60')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text('天数').fontSize(11).fontColor('#95A5A6')
Text(`第 ${this.day} 天`).fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#2C3E50')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
三个 Column 使用 .layoutWeight(1) 等分父容器宽度,形成三栏式布局。
14. 页面路由与导航
14.1 导入
import promptAction from '@ohos.promptAction';
import router from '@ohos.router';
14.2 跳转
在 Index.ets 中:
Button('💔 分手模拟器')
.fontSize(14)
.fontColor('#FFFFFF')
.backgroundColor('#8E44AD') // 紫色
.borderRadius(20)
.height(40)
.width(220)
.onClick(() => {
router.pushUrl({ url: 'pages/BreakupSimulator' });
})
14.3 返回
private goBack(): void {
router.back();
}
页面栈结构:
Index (首页)
├── [推入] ColumnStartDemo.ets → 布局演示
├── [推入] SimpSimulator.ets → 舔狗模拟器
└── [推入] BreakupSimulator.ets → 分手模拟器(回到首页)
15. ArkTS 关键语法实践
15.1 .decoration() 文本装饰
ArkTS 使用 .decoration() 方法设置文本装饰(删除线、下划线等),而不是 CSS 的 text-decoration 属性:
Text(memo.name)
.decoration({
type: memo.kept ? TextDecorationType.None : TextDecorationType.LineThrough
})
TextDecorationType 枚举包含 None、Underline、LineThrough、Overline。
15.2 .enabled() 按钮禁用
ArkTS 的 Button 通过 .enabled(boolean) 控制可点击状态:
Button('放下')
.enabled(memo.kept && !this.isGameOver)
15.3 .constraintSize() 尺寸约束
对于聊天记录等可滚动区域,使用 .constraintSize({ maxHeight: 300 }) 限制最大高度:
Column() {
ForEach(...)
}
.width('100%')
.constraintSize({ maxHeight: 300 })
.clip(true)
.clip(true) 启用裁剪,确保内容不会溢出圆角容器。
15.4 Percent 字符串宽度
ArkTS 支持百分比字符串作为宽度值:
.width(`${this.heartbreak}%`)
这是 ArkTS 比传统 TypeScript 更方便的地方——直接字符串插值即可,不需要手动计算像素值。
15.6 使用 setTimeout 实现延迟交互
分手模拟器中大量使用 setTimeout 来实现「延迟内心独白」的效果。这是模拟真实情绪反应的关键技术:
setTimeout(() => {
if (this.isGameOver) return; // 安全检查
this.addDiary(`💭 ${choice.reply}`);
// ...更新数值
}, 700);
几个关键设计细节:
延迟时长的选择:
- 场景选择后的内心独白延迟 700ms —— 模拟思考时间
- 丢弃物品后的反应延迟 500ms —— 较短,模拟即时情绪
- 写日记后的反馈延迟 400ms —— 最短,模拟快速自我对话
安全检查的必要性:
每个 setTimeout 回调的第一行都是 if (this.isGameOver) return;。这是因为 ArkTS 的异步回调可能在游戏已经结束后才执行——用户可能在延迟期间点击了「重新开始」。没有安全检查的话,就会出现在新游戏中弹出旧游戏的对话这种 bug。
状态一致性的保证:
在 ArkTS 中,setTimeout 回调中修改 @State 变量是安全的——闭包捕获的是引用而非快照。这意味着回调中读取的 this.heartbreak 和 this.recovery 始终是最新值。
15.7 空 Row 作为弹性占位符
在 UI 布局中,空组件 Row() 或 Text('') 配合 .layoutWeight() 可以作为弹性占位符:
Row() {
Column() { /* 气泡内容 */ }
.alignItems(HorizontalAlign.Start)
.constraintSize({ maxWidth: '75%' })
// 弹性占位,将气泡推到左侧
Text('')
.layoutWeight(1)
}
.width('100%')
虽然不是舔狗模拟器那样的聊天界面,但分手模拟器的故事面板中也使用了类似的技术来控制文本区域的宽度和布局。
ArkTS 在 build() 方法中支持 if / else if / else 条件渲染:
if (this.activePanel === 'scene') {
this.buildScenePanel()
} else if (this.activePanel === 'diary') {
this.buildDiaryPanel()
} else if (this.activePanel === 'memos') {
this.buildMemosPanel()
}
也支持与 ForEach 结合使用的复杂条件:
ForEach(this.diary, (entry: DiaryEntry) => {
if (entry.day === 0) {
// 系统消息 → 斜体样式
} else {
// 普通消息 → 带 D 前缀
}
}, (entry: DiaryEntry) => entry.id.toString())
16. 心理学视角的游戏设计
16.1 悲伤五阶段模型
分手模拟器的场景设计参考了 Elisabeth Kübler-Ross 提出的「悲伤五阶段」模型:
- 否认(Denial):D1"难以置信"——不敢相信已经结束了
- 愤怒(Anger):D3"愤怒"——为什么是我?
- 讨价还价(Bargaining):D5"回忆"——如果能重来……
- 抑郁(Depression):D7"挣扎"——深夜情绪反扑
- 接纳(Acceptance):D60"释怀"——真的放下了
16.2 积极心理学的融入
游戏中的「健康选择」都基于临床心理学验证的有效应对策略:
| 选择 | 对应的心理学策略 |
|---|---|
| 运动发泄 | 运动疗法(Exercise Therapy) |
| 写日记 | 表达性写作(Expressive Writing) |
| 社交支持 | 社会支持系统(Social Support) |
| 兴趣班 | 行为激活(Behavioral Activation) |
| 断联 | 无接触规则(No Contact Rule) |
16.3 复发机制的设计
「第七天 · 挣扎」场景中的复发选项(回复对方消息),模拟了真实分手过程中最常见的「情绪反扑」现象。这不是对玩家的惩罚,而是一种警示——让玩家在安全的环境中体验「如果这样做会怎样」,从而在现实生活中做出更健康的选择。
17. 与舔狗模拟器的对比分析
两个模拟器虽然是姊妹项目,但在设计理念上存在显著差异。
17.1 核心机制对比
| 维度 | 舔狗模拟器 | 分手模拟器 |
|---|---|---|
| 数值系统 | 单数值:好感度 0~100 | 双数值:心碎值 + 恢复进度 |
| 经济系统 | 有(花钱送礼) | 无 |
| 成就系统 | 8 个成就 | 无(聚焦叙事) |
| 结局数量 | 2 种(出局 / 完美) | 3 种(释怀 / 复发 / 假装) |
| 玩家角色 | 追求者(主动) | 失恋者(被动) |
| 叙事风格 | 幽默、讽刺 | 治愈、严肃 |
| 交互维度 | 聊天 + 送礼 + 打卡 | 选择 + 日记 + 丢弃物品 |
17.2 代码差异
舔狗模拟器包含礼物系统、经济系统、成就系统、自由聊天系统,代码量 1250 行。分手模拟器聚焦于叙事和情绪管理,代码量 999 行,更精炼。
两个项目共享的技术栈:
- Column + HorizontalAlign.Start 布局
- @State + getter 计算属性
- @Builder 组件复用
- ForEach 循环渲染
- setTimeout 延迟交互
- promptAction.showToast 反馈
17.3 风格差异
舔狗模拟器使用活泼的绿色 #4ECDC4 和红色 #E74C3C,分手模拟器使用沉静的紫色 #8E44AD。这两种色调的差异反映了两个游戏截然不同的情感基调。
18. 扩展方向与技术展望
18.1 功能扩展
- 存档系统:使用
@ohos.data.preferences保存游戏进度,支持多周目 - 音乐氛围:每个场景播放不同的背景音乐,增强情绪渲染
- 结局图鉴:解锁不同结局后保存图鉴,收集全部结局
- 每日签到:现实时间与游戏时间联动,每天登录获得新场景
- 数据可视化:生成心碎值和恢复进度的变化曲线图
18.2 技术优化
- 虚拟列表:日记条目超过 50 条时,使用
LazyForEach替代ForEach - 组件拆分:将 @Builder 方法抽取为独立的 @Component 文件
- 动画过渡:场景切换、面板切换时加入转场动画
- 状态管理重构:引入 @Provide / @Consume 进行跨组件通信
18.3 内容扩展
可以增加更多的场景线和角色类型:
- 不同性格的伴侣(回避型、焦虑型、安全型)
- 不同的分手原因(出轨、异地、性格不合)
- 不同的社会环境(学生恋爱、职场恋情、异地恋)
结语
分手模拟器是一个 999 行代码、完全用 ArkTS 开发的互动叙事游戏。它展示了如何用双进度系统、多结局分支、延迟交互、纪念物品和日记系统,构建一个具有情感深度和疗愈意义的互动体验。
相比传统的游戏开发,ArkTS 声明式 UI 让 UI 与逻辑的分离更加自然——状态变了,界面自动更新。这让开发者可以更专注于游戏玩法本身,而不是 UI 的刷新控制。
本文涉及的全部技术要点,从 @State 状态管理到 @Builder 组件复用,从 ForEach 循环渲染到双进度条实现,都是 HarmonyOS NEXT 原生开发中的通用技能,可以迁移到任何 ArkTS 项目中使用。
本文基于 HarmonyOS NEXT 6.1.1(API 24)Stage 模型编写,
targetSdkVersion="6.1.1(24)"
全部源代码通过hvigorw assembleHap编译验证
项目源码:entry/src/main/ets/pages/BreakupSimulator.ets(999 行)
更多推荐




所有评论(0)