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

在这里插入图片描述

随机事件、属性养成、回合对战——一个看似复杂的模拟游戏,如何用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维属性系统

角色属性是游戏的核心,决定了事件选择的影响范围和战斗中的表现:

属性英文范围影响
❤️ 生命值hp1~100战斗中归零即败
💪 力量str1~50增加技能伤害
🛡️ 防御def1~50减少受到的伤害
⚡ 速度spd1~50影响恢复技能效果
🧹 卫生hyg0~100纯数值,无战斗影响
😊 心情mood0~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-5mood-3,hyg-2str+2,mood-8
🧴 洗发水被用mood-3hyg+2mood+3,hyg-3
❄️ 空调之争mood+3def+2spd+3,mood-5
🍱 外卖被拿hp-10str+3mood+5,hp+20
🧹 值日冲突hyg+5,mood-3str+2spd+4,mood-8,hyg-5
⏰ 闹钟之争mood-3str+3,hp-5spd+3,mood+3
🍿 零食被偷mood-3,hp+10spd+2str+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)

宿舍视图内又根据 phaseshowResult 条件渲染三种子界面:

  • 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 时,方法内的 ForEachColumn()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把 ForEachColumn()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 @Builderif (x) return;
局部变量 in @Builderconst 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 玩家成长曲线预估

天数事件次数打架次数预计力量预计生命
32112-1680-100
64214-2080-100
96316-2480-100
1510520-3080-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 代码量分布

模块行数占比
类型定义~306%
游戏数据(事件+技能)~6011%
游戏逻辑~8015%
UI辅助~306%
UI视图~32062%

与之前几个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()前缀判断
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐