鸿蒙 ArkTS 实战:从零开发「舔狗模拟器」交互游戏
SDK 版本:HarmonyOS NEXT 6.1.1(API 24)
开发语言:ArkTS(声明式 UI)
项目模式:Stage 模型
源码文件:entry/src/main/ets/pages/SimpSimulator.ets(1250 行)
目录
- 项目背景与创意来源
- 项目结构与页面注册
- 数据模型设计
- 状态管理架构
- 核心玩法逻辑
- 送礼与经济系统
- 每日签到机制
- 自由聊天系统
- 成就系统实现
- UI 布局设计
- @Builder 组件复用
- 聊天气泡实现
- 结局判定与重置
- 页面路由导航
- ArkTS 避坑总结
- 扩展与优化方向
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 枚举,而不是 ItemAlign。ItemAlign 是 Row 容器的对齐属性。这是 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 的三大优势
- 参数化:通过函数参数控制颜色、文字、数据
- 访问 struct 成员:可以直接使用
this.activePanel等状态变量 - 轻量:不需要创建独立的 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(最小可行产品),可以在此基础上做大量扩展:
- 更多剧情:增加 20+ 场景,不同的性格线(温柔型、高冷型、搞笑型)
- 多角色:可以切换追求不同的目标对象,每个对象有不同的反应模式
- 道具系统:增加更多可购买道具,如电影票、旅行券等
- 天气系统:天气影响对方的心情和回复
- 朋友圈系统:模拟刷朋友圈、点赞、评论的互动
- 成就通知:解锁成就时显示更精美的弹窗动画
16.2 性能优化
- 虚拟列表:聊天记录过长时,使用
LazyForEach替代ForEach,只渲染可见区域 - 状态拆分:将一个大组件拆分为多个
@Component子组件,减少不必要的渲染 - 动画过渡:切换面板时加入转场动画,提升用户体验
16.3 代码重构
- 将场景数据抽离到独立的 JSON 文件中
- 使用
@Component替代部分@Builder,提高复用性 - 引入状态管理模式(如
@Provide/@Consume)处理跨组件通信 - 添加单元测试,覆盖核心玩法的边界条件
结语
本文通过 舔狗模拟器 这个完整的 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 的独特优势
- 原生中文支持:变量名、注释、字符串都可以用中文,对中文开发者友好
- Stage 模型:清晰的能力生命周期管理
- 编译时优化:通过限制某些 JS 特性(如对象展开),换取更好的运行时性能
- 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 开发的全部核心知识:
- 声明式 UI 构建:使用 Column、Row、Text、Button、TextInput、ForEach 等基础组件
- 状态管理:@State 装饰器 + getter 计算属性
- 组件复用:@Builder 自定义构建函数
- 业务逻辑:完整的游戏循环(场景、选择、判定、结局)
- 用户体验:延迟回复、Toast 反馈、色彩映射
- 编译规范:遵守 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
更多推荐





所有评论(0)