舍友打架模拟器APP开发实战:基于HarmonyOS API 24的宿舍生活模拟游戏从零到一



随机事件、属性养成、回合对战——一个看似复杂的模拟游戏,如何用ArkUI在单文件中实现?本文从事件系统设计到属性数值平衡,完整记录开发全过程。
一、项目缘起:为什么做"舍友打架模拟器"
1.1 创意来源
宿舍生活是中国大学生最独特的集体记忆之一。在这个十几平米的空间里,每天都在上演着各种"戏剧"——空调温度之争、谁偷用了我的洗发水、深夜打呼噜、外卖被拿错……这些看似琐碎的小事,构成了大学生活最鲜活的底色。
“舍友打架模拟器"的创意正是源于这些真实的宿舍日常。它不是一个提倡暴力的游戏,而是一个用幽默和夸张的方式呈现宿舍生活的模拟器——玩家需要在各种突发事件中做出选择,这些选择影响着角色的6维属性,而属性又决定了在"打架”(矛盾爆发)时的胜负。
1.2 玩法设计
游戏的核心循环:
第1天 → 随机事件 → 选择 → 属性变化
第2天 → 随机事件 → 选择 → 属性变化
第3天 → 🔥 矛盾爆发 → 回合对战 → 胜负影响属性
第4天 → 循环...
三个核心系统:
| 系统 | 说明 | 体验目标 |
|---|---|---|
| 🏠 随机事件 | 7种剧情,每种3个选择 | 做选择时有代入感、纠结感 |
| 📊 属性养成 | 6维属性,选择影响成长方向 | 培养"自己的角色"的养成感 |
| ⚔️ 回合对战 | 4个技能,AI决策 | 检验养成成果的成就感 |
1.3 技术选型
| 维度 | 选择 | 理由 |
|---|---|---|
| 语言 | ArkTS | 类型安全,适合复杂数据模型 |
| UI框架 | ArkUI | 声明式,组件化 |
| 持久化 | 无(单局游戏,不存档) | 游戏设计为"随时开一把" |
| 包体积 | < 100KB | 纯逻辑+UI,无外部依赖 |
二、数据模型设计
2.1 6维属性系统
角色属性是游戏的核心,决定了事件选择的影响范围和战斗中的表现:
| 属性 | 英文 | 范围 | 影响 |
|---|---|---|---|
| ❤️ 生命值 | hp | 1~100 | 战斗中归零即败 |
| 💪 力量 | str | 1~50 | 增加技能伤害 |
| 🛡️ 防御 | def | 1~50 | 减少受到的伤害 |
| ⚡ 速度 | spd | 1~50 | 影响恢复技能效果 |
| 🧹 卫生 | hyg | 0~100 | 纯数值,无战斗影响 |
| 😊 心情 | mood | 0~100 | 影响伤害+治疗效果 |
设计原则:
- 战斗相关属性(hp/str/def/spd):上限50,让玩家可以专注培养特定方向
- 生活属性(hyg/mood):上限100,更细粒度地反映事件影响
- 所有属性都有下限保护:
Math.max(1, ...)/Math.max(0, ...)确保属性不会归零
2.2 角色接口
interface DormCharacter {
name: string // 角色名
emoji: string // 头像表情
hp: number // 当前生命值
maxHp: number // 最大生命值
str: number // 力量
def: number // 防御
spd: number // 速度
hyg: number // 卫生
mood: number // 心情
skills: DormSkill[] // 技能列表(4个)
}
对手生成逻辑:每次打架时重新生成,属性随机波动,保证每局体验不同:
this.enemy = this.newChar('舍友', '🤯');
this.enemy.hp = 80 + Math.floor(Math.random() * 40); // 80-120
this.enemy.str = 8 + Math.floor(Math.random() * 8); // 8-15
this.enemy.def = 8 + Math.floor(Math.random() * 6); // 8-13
2.3 4技能系统
每个角色固定拥有4个技能:
| 技能 | 图标 | 基础伤害 | 效果 | 定位 |
|---|---|---|---|---|
| 平A | 👊 | 8 | 普通攻击 | 稳定输出 |
| 怒吼 | 😤 | 14 | 攻击 | 主力技能 |
| 枕头大战 | 🛏️ | 20 | 攻击 | 大招 |
| 求和 | 🤝 | 0 | 回血25 | 恢复技能 |
伤害计算公式(玩家攻击):
基础伤害 = 技能伤害 + 力量 × 0.5 + 心情 × 0.05
减免后 = 基础伤害 - 敌方防御 × 0.3
实际伤害 = max(3, 减免后) × 随机波动(0.8~1.2)
设计要点:
- 力量主导:
str × 0.5是伤害的主要加成来源,每点力量提升0.5伤害 - 心情微调:
mood × 0.05让心情好的时候打得更有力 - 防御减伤:
def × 0.3每点防御减少0.3伤害 - 最小伤害保护:
max(3, ...)避免因防御太高而打不出伤害 - 随机波动:
0.8~1.2让每次攻击结果不同
恢复技能的效果受速度影响:
恢复量 = 25(基础)+ 速度 × 0.2
2.4 事件系统
事件系统是游戏事件驱动的核心。每个事件包含标题、描述、表情和3个选项:
interface DormEvent {
title: string // 事件标题(如"深夜打呼噜")
desc: string // 事件描述
emoji: string // 事件表情
choices: EventChoice[] // 3个选项
}
interface EventChoice {
text: string // 选项文字(如"忍了,继续睡")
effect: string // 效果描述(如"mood-5")
effAttr: string // 影响的属性
effVal: number // 影响数值
}
7种预设事件:
| 事件 | 选项1(佛系) | 选项2(刚正面) | 选项3(骚操作) |
|---|---|---|---|
| 😴 深夜打呼噜 | mood-5 | mood-3,hyg-2 | str+2,mood-8 |
| 🧴 洗发水被用 | mood-3 | hyg+2 | mood+3,hyg-3 |
| ❄️ 空调之争 | mood+3 | def+2 | spd+3,mood-5 |
| 🍱 外卖被拿 | hp-10 | str+3 | mood+5,hp+20 |
| 🧹 值日冲突 | hyg+5,mood-3 | str+2 | spd+4,mood-8,hyg-5 |
| ⏰ 闹钟之争 | mood-3 | str+3,hp-5 | spd+3,mood+3 |
| 🍿 零食被偷 | mood-3,hp+10 | spd+2 | str+3,mood+3 |
设计原则:
- 没有纯正面选项:每个选项都有代价,让选择有纠结感
- 数值有梯度:-3到-10不等,选择的影响可感知
- 复合效果:部分选项同时影响多个属性,增加决策复杂度
- 路线倾向:佛系路线(减mood)/ 刚正面路线(加str)/ 骚操作路线(加spd)
2.5 效果解析算法
事件选项的效果通过字符串编码,在运行时解析:
const parts = choice.effect.split(','); // 按逗号分割多个效果
for (const part of parts) {
const splitIdx = part.search(/[+-]\d+$/); // 找到数字开始位置
const attr = part.substring(0, splitIdx); // 属性名
const valStr = part.substring(splitIdx); // 数值(如"+3")
const val = Number.parseInt(valStr);
switch (attr) {
case 'hp': p.hp = Math.max(1, Math.min(p.maxHp, p.hp + val)); break;
case 'str': p.str = Math.max(1, Math.min(50, p.str + val)); break;
// ...
}
}
为什么用字符串编码而不是直接传数值?因为ArkTS对复杂数据结构的序列化支持有限,用字符串编码可以保持 DormEvent 的接口简洁,也便于阅读和修改。
三、事件驱动 vs 回合对战
3.1 游戏状态机
游戏在三种"阶段"间切换:
phase: 'event' ──(选择)──→ phase: 'event' (day%3!=0)
│ │
│ day%3==0
│ ↓
│ phase: 'fight'
│ │
│ (一方HP归零)
│ ↓
│ showResult=true
│ │
│ 点击"继续"
│ ↓
└────────────────→ phase: 'event' (day++)
状态变量:
@State phase: string // 'event' | 'fight' | (result通过showResult控制)
@State day: number // 天数,控制事件→战斗的切换
@State showResult: boolean // 战斗结果展示
@State isAnim: boolean // 动画锁,防止技能连点
3.2 事件循环
每次事件触发流程:
nextEvent(): void {
if (this.player.hp <= 0) { this.player.hp = 30; } // 保底
this.curEvent = this.events[Math.floor(Math.random() * this.events.length)];
this.phase = 'event';
}
chooseChoice(idx: number): void {
this.applyEffect(choice); // 应用属性变化
this.day++; // 天数+1
if (this.day % 3 === 0) {
this.startFight(); // 每3天触发战斗
} else {
this.nextEvent(); // 继续事件
}
}
3.3 战斗循环
对战阶段的完整流程:
用户选择技能
→ playerSkill(idx)
→ 动画锁 (isAnim=true)
→ 执行技能效果
→ refreshState() 刷新UI
→ 判定敌方HP是否为0
→ 是:endFight(true)
→ 否:setTimeout 400ms 后 enemyTurn()
敌方回合
→ enemyTurn()
→ AI决策选择技能
→ 执行技能效果
→ refreshState() 刷新UI
→ 判定玩家HP是否为0
→ 是:endFight(false)
→ 否:isAnim=false (解锁)
AI决策逻辑:
// 低血量时有40%概率回血,否则随机攻击
const idx = e.hp < e.maxHp * 0.3 && Math.random() < 0.4 ? 3 : Math.floor(Math.random() * 3);
与"班长大战团支书"中的AI策略一致——简单但有效。
四、UI实现详解
4.1 双视图导航
APP只有两个底部标签页:
🏠 宿舍 (dorm) | 📊 属性 (attr)
宿舍视图内又根据 phase 和 showResult 条件渲染三种子界面:
phase === 'event'→ 事件卡片 + 选项列表phase === 'fight' && !showResult→ 对战界面showResult === true→ 结果界面
属性视图展示角色的完整属性面板。
4.2 事件卡片
事件卡片展示当前随机事件:
Column
├── Text: 事件表情 (48fp)
├── Text: 事件标题 (20fp, 粗体)
└── Text: 事件描述 (14fp, 灰色, 居中)
下方是3个选项按钮,每个展示选项文字和效果预览。
4.3 对战界面
对战界面分为三个区域:
区域①:双方状态条(顶部)
Row
├── Column (玩家)
│ ├── Text: 玩家emoji 48fp
│ ├── Text: 玩家名
│ ├── hpBar: HP血条 (红色)
├── Text: "⚡" 分隔符
└── Column (对手)
├── Text: 对手emoji 48fp
├── Text: 对手名
└── hpBar: HP血条 (蓝色)
区域②:战斗日志(中间,flex:1)
用 List 展示所有战斗记录,根据消息来源不同显示不同颜色:
- 😎 玩家行动 → 红色
- 🤯 敌方行动 → 蓝色
- 💥 伤害 → 亮红
- 💚 恢复 → 绿色
- 回合分隔 → 灰色
区域③:技能栏(底部)
Row
├── Column: 👊 平A (8)
├── Column: 😤 怒吼 (14)
├── Column: 🛏️ 枕头大战 (20)
└── Column: 🤝 求和 (回复)
动画锁激活时技能栏半透明,禁止点击。
4.4 HP血条组件
hpBar 是一个可复用的 @Builder:
@Builder
hpBar(v: number, m: number) {
Row() {
Column() {
Column() { }
.width(`${v / m * 100}%`).height('100%')
.backgroundColor(this.getBarColor(v, m)).borderRadius(6);
}
.width('100%').height(10).backgroundColor('#E0E0E0').borderRadius(6);
Text(`${v}/${m}`).fontSize(10).fontColor('#888').width(44).textAlign(TextAlign.End);
}
}
颜色随HP百分比变化:
-
60%:绿色
#4CAF50 - 30%-60%:橙色
#FF9800 - <30%:红色
#E53935
4.5 属性视图
属性视图展示完整的角色面板:
Column
├── Row: 角色头像 (64fp) + 名称 + 生存天数
│
├── Column: 属性列表
│ ├── attrRow: ❤️ 生命 ████████░ 80/100
│ ├── attrRow: 💪 力量 ██████░░░ 30/50
│ ├── attrRow: 🛡️ 防御 ████░░░░░ 20/50
│ ├── attrRow: ⚡ 速度 █████░░░░ 25/50
│ ├── attrRow: 🧹 卫生 ███████░░ 70/100
│ └── attrRow: 😊 心情 ██████░░░ 60/100
│
└── Column: 技能列表
├── 👊 平A · 普通攻击 · 💥8
├── 😤 怒吼 · 大声咆哮 · 💥14
├── 🛏️ 枕头大战 · 用枕头猛砸 · 💥20
└── 🤝 求和 · 冷静下来恢复HP · 💚+25
每个属性通过百分比宽度展示进度条,让玩家一目了然自己的角色成长方向。
五、@Builder 方法的最佳实践总结
在本次开发中,我们可以总结出 ArkUI @Builder 方法的几个关键规则:
规则1:必须标注 @Builder 装饰器
// ✅ 正确
@Builder
buildFightView() { ... }
// ❌ 错误 —— 缺少 @Builder 会导致包含 UI 语法的代码被当作普通方法解析
buildFightView() { ... }
症状:缺失 @Builder 时,方法内的 ForEach、Column()、if 条件渲染等UI语法全部报错,因为ArkTS编译器不知道这是UI代码。
规则2:不能有 return 提前退出
@Builder
buildEventView() {
// ❌ 错误
if (this.curEvent === null) return;
Column() { ... }
// ✅ 正确
if (this.curEvent != null) {
Column() { ... }
}
}
规则3:不能声明局部变量
@Builder
buildEventView() {
// ❌ 错误
const e = this.curEvent;
// ✅ 正确:直接使用成员变量
Text(this.curEvent.title)
// ✅ 正确:提取到普通方法中计算
Text(this.getEventTitle())
}
规则4:调用 @Builder 方法需要 this 前缀
@Builder
buildDormView() {
Column() {
this.buildEventView(); // ✅ 正确
this.hpBar(v, m); // ✅ 正确
}
}
六、踩坑合集
坑1:@Builder 装饰器缺失 —— 70行代码集体报错
症状:连续70行UI代码全部报错,每个组件都显示"未定义"或"语法错误"。
根因:buildFightView() 方法前缺少 @Builder 装饰器,ArkTS把 ForEach、Column()、if 等UI语法都当作普通JavaScript语法解析,自然全部不通过。
修复:在方法前加上 @Builder。
教训:方法名以 build 开头不代表它就是Builder。必须显式标注 @Builder。
坑2:字符串引号不匹配
症状:.width('100%).margin(...) 报错。
根因:'100%) 的结束引号不对——应该是 '100%',但写成了 '100%)。在 @Builder 的链式调用中,这种引号错误特别容易被忽略,因为连续链式调用太长,眼睛不易聚焦。
修复:改为 '100%'。
教训:长链式调用时注意引号的成对匹配。
坑3:数组解构赋值不可用
症状:const [attr, valStr] = part.split(...) 报错。
根因:ArkTS不支持数组解构赋值(Array Destructuring)。
修复:
// ❌ 不可用
const [attr, valStr] = part.split(/([+-]\d+)$/);
// ✅ 替代方案
const splitIdx = part.search(/[+-]\d+$/);
const attr = part.substring(0, splitIdx);
const valStr = part.substring(splitIdx);
ArkTS中不可用的JS特性列表更新:
| 特性 | 示例 | ArkTS |
|---|---|---|
| 展开运算符 | [...arr] / {...obj} |
❌ |
| 解构赋值 | const [a, b] = arr |
❌ |
| 索引访问类型 | Obj['key'] Type |
❌ |
console.error() |
console.error(msg) |
❌ |
Array.from() |
Array.from(iter) |
❌ |
parseInt() |
parseInt(s) |
❌ (用Number.parseInt) |
return in @Builder |
if (x) return; |
❌ |
| 局部变量 in @Builder | const x = ... |
❌ |
坑4:状态对象属性修改不触发渲染
症状:修改 this.player.hp 后UI没有更新。
根因:@State 对象的属性修改不会触发重新渲染。
修复:每次修改属性后,创建新对象替换:
// 属性修改
this.player.hp -= dmg;
// 触发渲染 —— 创建新对象
const p = this.player;
this.player = {
name: p.name, emoji: p.emoji, hp: p.hp, maxHp: p.maxHp,
str: p.str, def: p.def, spd: p.spd, hyg: p.hyg, mood: p.mood,
skills: p.skills
};
为了方便复用,将这段代码封装为 refreshState() 方法,在每次属性变化后调用。
坑5:@Builder 中的复杂三元表达式
症状:Text(...).fontColor(... ? ... : ... ? ... : ...) 多层嵌套导致代码可读性差。
根因:在 @Builder 中不能声明局部变量,所以复杂的条件逻辑必须内联在组件属性中。
优化方案:将对多个条件的判断提取到普通方法中:
// 普通方法中实现复杂逻辑
getLogColor(log: string): string {
if (log.startsWith('😎')) return '#E53935';
if (log.startsWith('🤯')) return '#2196F3';
// ...
return '#666';
}
// @Builder 中只调用方法
Text(log).fontColor(this.getLogColor(log))
七、数值平衡设计
7.1 为什么需要数值平衡
一个模拟游戏的可玩性很大程度上取决于数值平衡:
- 如果玩家太容易赢,游戏会很快变得无聊
- 如果玩家总是输,游戏会让人沮丧
- 如果事件选择对属性没有实质影响,玩家会觉得"选什么都一样"
7.2 平衡策略
策略1:对手动态难度
敌人的属性根据随机值生成,每次战斗都不同:
this.enemy.hp = 80 + Math.floor(Math.random() * 40); // 80~120
this.enemy.str = 8 + Math.floor(Math.random() * 8); // 8~15
this.enemy.def = 8 + Math.floor(Math.random() * 6); // 8~13
相比玩家的初始属性(hp=100, str=10, def=10),敌人的属性范围在玩家初始值上下波动,保证了:
- 运气好时遇到弱敌,轻松获胜
- 运气差时遇到强敌,艰难作战
- 经过多次养成后属性成长的玩家,即使遇到强敌也有胜算
策略2:属性上限控制
战斗属性上限50,生活属性上限100。玩家不可能"全属性满级",必须做出取舍:
- 想主升力量?力量和防御只能二选一
- 想当血牛?生命和恢复只能二选一
- 心情影响伤害也影响恢复,不能完全放弃
策略3:随机波动
±20%的伤害波动让即使是劣势方也有翻盘的机会,体现了"打架的偶然性"。
7.3 玩家成长曲线预估
| 天数 | 事件次数 | 打架次数 | 预计力量 | 预计生命 |
|---|---|---|---|---|
| 3 | 2 | 1 | 12-16 | 80-100 |
| 6 | 4 | 2 | 14-20 | 80-100 |
| 9 | 6 | 3 | 16-24 | 80-100 |
| 15 | 10 | 5 | 20-30 | 80-100 |
注:生命值在战斗中会大量消耗,每次战斗结束后恢复至30(如果战败)或100左右(如果战胜),因此生命值的提升主要依靠事件中的hp+选项。
八、项目结构与代码统计
8.1 文件结构
Index.ets (525行)
├── 类型定义 (~30行)
│ ├── enum AttrType
│ ├── interface DormSkill / DormCharacter
│ └── interface DormEvent / EventChoice
│
├── 游戏数据 (~60行)
│ ├── 7个随机事件(含3×7=21个选项)
│ └── 4个默认技能
│
├── 游戏逻辑 (~80行)
│ ├── newChar / nextEvent / chooseChoice
│ ├── applyEffect / startFight
│ ├── playerSkill / enemyTurn
│ └── endFight / afterFight / refreshState
│
├── UI辅助方法 (~30行)
│ ├── getStatusEmoji / getBarColor
│ └── statChip / navItem
│
└── @Builder视图 (~320行)
├── build() 入口
├── buildBottomNav / buildDormView
├── buildEventView / buildFightView
├── buildAttrView / buildAttrRow
└── hpBar / statChip
8.2 代码量分布
| 模块 | 行数 | 占比 |
|---|---|---|
| 类型定义 | ~30 | 6% |
| 游戏数据(事件+技能) | ~60 | 11% |
| 游戏逻辑 | ~80 | 15% |
| UI辅助 | ~30 | 6% |
| UI视图 | ~320 | 62% |
与之前几个APP一样,UI代码占总代码量的60%左右,这是ArkUI声明式开发的特征。
九、总结与展望
9.1 项目复盘
| 维度 | 数据 |
|---|---|
| 开发周期 | 约1天 |
| 代码量 | 525行,单文件 |
| 事件数 | 7种 |
| 可选分支 | 21个(7×3) |
| 属性维度 | 6维 |
| 技能数 | 4个/角色 |
| 战斗公式 | 伤害 = (技能+力量×0.5+心情×0.05)×(1-防御×0.3)×随机波动 |
9.2 “舍友打架模拟器” vs “班长大战团支书”
两者同属回合制对战类型,但设计上有所不同:
| 维度 | 班长大战团支书 | 舍友打架模拟器 |
|---|---|---|
| 核心玩法 | 纯对战 | 事件养成+对战 |
| 角色成长 | 固定属性 | 6维属性可养成 |
| 游戏时长 | 单局3分钟 | 可持续多轮 |
| 剧情元素 | 无 | 7种随机事件 |
| AI对手 | 固定属性 | 随机属性动态难度 |
| 技能系统 | 4技能固定 | 4技能(效果受属性影响) |
9.3 可扩展方向
1. 更多事件
目前只有7种事件,可以扩展到20+种,包括正面事件(“舍友请客吃饭”“一起看电影”)和连锁事件(“上次的矛盾升级了”)。
2. 道具系统
引入道具系统,玩家可以通过事件获得道具(“耳塞”“眼罩”“零食储备”),在对战中可以使用道具获得优势。
3. 多舍友
不止一个舍友,每个舍友有不同的性格和属性倾向,玩家的选择会影响与不同舍友的关系值。
4. 结局系统
根据玩家的属性养成方向和打架胜负,触发不同的结局(“和平共处”“一方称霸”"被赶出宿舍"等)。
5. 成就收集
“佛系玩家(连续10次选减mood选项)”“暴脾气(累计怒吼使用10次)”"打不死的小强(残血翻盘)"等成就。
附录:完整API清单
ArkUI组件
| 组件 | 用途 |
|---|---|
Column / Row |
布局容器 |
Text |
文本显示 |
Button |
按钮 |
List / ListItem |
战斗日志 |
ForEach |
循环渲染选项/技能/日志 |
ArkUI属性方法
| 方法 | 用途 |
|---|---|
.fontSize() .fontWeight() .fontColor() |
字体样式 |
.backgroundColor() .borderRadius() |
背景与圆角 |
.layoutWeight() |
弹性布局 |
.alignItems() .justifyContent() |
对齐方式 |
.width() .height() |
尺寸 |
.padding() .margin() |
边距 |
.alignSelf() |
单独对齐 |
.onClick() |
点击事件 |
.opacity() |
透明度(动画锁用) |
.lineHeight() |
行高 |
.textAlign() |
文本对齐 |
.maxLines() .textOverflow() |
文本截断 |
全局API
| API | 用途 | ArkTS |
|---|---|---|
Math.random() |
随机数 | ✅ |
Math.floor() |
向下取整 | ✅ |
Math.max() / Math.min() |
值范围限制 | ✅ |
Number.parseInt() |
字符串转整数 | ✅ |
setTimeout(cb, ms) |
延迟执行 | ✅ |
String.startsWith() |
前缀判断 | ✅ |
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)