在这里插入图片描述

案例开源地址:https://atomgit.com/nutpi/rn_openharmony_lol

在英雄联盟中,了解技能的伤害计算方式对于提升游戏水平很有帮助。一个技能的伤害通常由基础伤害加上属性加成组成,比如"100 + 60% AP"表示基础伤害 100,加上法术强度的 60%。

这篇文章我们来实现一个伤害计算器,让用户可以输入各项参数,实时计算出最终伤害。这是一个典型的表单输入 + 实时计算场景,我们会重点讨论受控组件、数值输入处理、以及实时计算的实现方式。

伤害计算的基本公式

在实现之前,先了解一下英雄联盟中技能伤害的计算方式:

总伤害 = 基础伤害 + (AP × AP加成比例) + (AD × AD加成比例)

举个例子,阿狸的 Q 技能"欺诈宝珠":

  • 基础伤害:80/115/150/185/220(根据技能等级)
  • AP 加成:70%

如果阿狸有 300 点法术强度,Q 技能 5 级时的伤害是:

220 + 300 × 0.7 = 220 + 210 = 430

当然实际游戏中还要考虑敌人的魔法抗性、穿透等因素,但基础的伤害计算就是这个公式。理解这个公式对于选择出装、评估技能强度都很有帮助。

状态设计与初始化

import React, {useState} from 'react';
import {View, Text, ScrollView, TextInput, StyleSheet} from 'react-native';
import {colors} from '../../styles/colors';

export function DamageCalcPage() {
  const [baseDamage, setBaseDamage] = useState('100');
  const [ap, setAp] = useState('0');
  const [ad, setAd] = useState('0');
  const [apRatio, setApRatio] = useState('0.6');
  const [adRatio, setAdRatio] = useState('0');

这个组件定义了五个状态变量,分别对应伤害计算公式中的五个参数:

状态变量 含义 默认值 说明
baseDamage 基础伤害 ‘100’ 技能的固定伤害部分
ap 法术强度 ‘0’ 英雄的 AP 属性
apRatio AP 加成比例 ‘0.6’ 技能描述中的 AP 系数
ad 攻击力 ‘0’ 英雄的 AD 属性
adRatio AD 加成比例 ‘0’ 技能描述中的 AD 系数

为什么用字符串类型而不是数字类型?

这是一个很重要的设计决策。TextInput 组件的 value 属性必须是字符串,如果用数字类型存储,会遇到几个问题:

  1. 类型转换麻烦:显示时要 String(num),计算时要 Number(str),来回转换容易出错
  2. 空值处理复杂:用户清空输入框时,空字符串转数字会变成 NaN 或 0,行为不可预测
  3. 格式丢失:用户输入 “0.60”,转成数字再转回来会变成 “0.6”

用字符串存储可以保留用户输入的原始格式,只在需要计算时才转换成数字。

默认值的选择逻辑

默认值不是随便选的,而是经过考虑的:

  • 基础伤害 100:一个常见的技能基础伤害值,让用户看到有意义的初始结果
  • AP 加成 0.6:很多法师技能的 AP 加成在 50%-80% 之间,0.6 是一个典型值
  • AD 和 AD 加成默认 0:法师技能通常没有 AD 加成,默认为 0 符合大多数场景

这些默认值让用户打开页面就能看到一个合理的计算结果(100 + 0 × 0.6 + 0 × 0 = 100),而不是全是 0 或者 NaN。

实时计算的实现

  const totalDamage = parseFloat(baseDamage || '0') 
    + parseFloat(ap || '0') * parseFloat(apRatio || '0') 
    + parseFloat(ad || '0') * parseFloat(adRatio || '0');

这行代码是整个计算器的核心逻辑,它实现了伤害公式:基础伤害 + AP × AP比例 + AD × AD比例

parseFloat 函数的行为

parseFloat 是 JavaScript 内置函数,用于把字符串转换成浮点数。它的行为比 Number() 更宽容:

parseFloat('100')      // → 100
parseFloat('100.5')    // → 100.5
parseFloat('100abc')   // → 100(忽略后面的非数字字符)
parseFloat('')         // → NaN
parseFloat('abc')      // → NaN

空值保护机制

baseDamage || '0' 这个写法是空值保护。当用户清空输入框时,状态会变成空字符串 '',而 parseFloat('') 返回 NaN,会导致整个计算结果变成 NaN。

|| 运算符可以在值为空字符串(falsy 值)时返回 ‘0’,确保计算不会出错:

'' || '0'      // → '0'
'100' || '0'   // → '100'

为什么不用 useMemo 优化?

有些开发者可能会想用 useMemo 来缓存计算结果:

const totalDamage = useMemo(() => {
  return parseFloat(baseDamage || '0') + ...;
}, [baseDamage, ap, apRatio, ad, adRatio]);

但在这个场景下没有必要。原因是:

  1. 计算非常简单,只是几个数字的加减乘除,性能开销可以忽略
  2. 每次输入变化都需要重新计算,useMemo 的缓存几乎不会命中
  3. 增加 useMemo 反而会增加代码复杂度和内存开销

只有当计算逻辑复杂、或者依赖的数据量大时,才需要用 useMemo 优化。

页面结构与布局

  return (
    <ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
      <Text style={styles.title}>伤害计算器</Text>
      <Text style={styles.subtitle}>计算技能伤害</Text>
      <View style={styles.inputGroup}>
        <Text style={styles.label}>基础伤害</Text>
        <TextInput 
          style={styles.input} 
          value={baseDamage} 
          onChangeText={setBaseDamage} 
          keyboardType="numeric" 
          placeholderTextColor={colors.textMuted} 
        />
      </View>

为什么用 ScrollView 包裹?

虽然当前内容不多,但用 ScrollView 包裹有几个好处:

  1. 小屏幕适配:在屏幕较小的设备上,内容可能超出一屏,需要滚动查看
  2. 键盘适配:键盘弹出时,ScrollView 可以自动调整,确保当前输入框可见
  3. 一致性:和其他页面保持相同的结构,便于维护

输入组的结构设计

每个输入项由标签 + 输入框组成,用 inputGroup 容器包裹。这种结构清晰明了,用户一眼就能看出每个输入框的用途。

受控组件模式详解

<TextInput 
  style={styles.input} 
  value={baseDamage} 
  onChangeText={setBaseDamage} 
  keyboardType="numeric" 
  placeholderTextColor={colors.textMuted} 
/>

这是 React 中典型的受控组件(Controlled Component)模式。

什么是受控组件?

受控组件是指表单元素的值完全由 React 状态控制。数据流是单向的:

用户输入 → onChangeText 触发 → setBaseDamage 更新状态 → 组件重新渲染 → 输入框显示新值

受控组件 vs 非受控组件

特性 受控组件 非受控组件
值的存储位置 React state DOM 元素内部
获取值的方式 直接读取 state 通过 ref 获取
实时验证 ✓ 容易实现 ✗ 需要额外处理
实时计算 ✓ 自动触发 ✗ 需要手动触发

在这个场景下,受控组件是最佳选择,因为我们需要实时计算——每次输入变化都要重新计算伤害值。

keyboardType=“numeric” 的作用

这个属性告诉系统弹出数字键盘而不是全键盘:

  • iOS:显示纯数字键盘
  • Android:显示带小数点的数字键盘

这样用户输入数字更方便,也减少了输入错误的可能。

placeholderTextColor 的必要性

在深色主题下,默认的占位符颜色可能看不清。显式设置 placeholderTextColor={colors.textMuted} 确保占位符在任何主题下都清晰可见。

完整的输入表单

      <View style={styles.inputGroup}>
        <Text style={styles.label}>法术强度 (AP)</Text>
        <TextInput 
          style={styles.input} 
          value={ap} 
          onChangeText={setAp} 
          keyboardType="numeric" 
          placeholderTextColor={colors.textMuted} 
        />
      </View>
      <View style={styles.inputGroup}>
        <Text style={styles.label}>AP 加成比例</Text>
        <TextInput 
          style={styles.input} 
          value={apRatio} 
          onChangeText={setApRatio} 
          keyboardType="numeric" 
          placeholderTextColor={colors.textMuted} 
        />
      </View>
      <View style={styles.inputGroup}>
        <Text style={styles.label}>攻击力 (AD)</Text>
        <TextInput 
          style={styles.input} 
          value={ad} 
          onChangeText={setAd} 
          keyboardType="numeric" 
          placeholderTextColor={colors.textMuted} 
        />
      </View>
      <View style={styles.inputGroup}>
        <Text style={styles.label}>AD 加成比例</Text>
        <TextInput 
          style={styles.input} 
          value={adRatio} 
          onChangeText={setAdRatio} 
          keyboardType="numeric" 
          placeholderTextColor={colors.textMuted} 
        />
      </View>

输入项的排列顺序

输入项按逻辑分组排列,而不是随意堆放:

  1. 基础伤害(独立项,最重要)
  2. AP 相关(法术强度、AP 加成比例)
  3. AD 相关(攻击力、AD 加成比例)

这种顺序符合用户的思维模式:先输入基础值,再输入各种加成。相关的输入项放在一起,用户不需要来回跳跃。

标签的命名规范

标签使用中文名称 + 英文缩写的格式,比如"法术强度 (AP)"。这样做的好处是:

  • 照顾新手:不熟悉英文缩写的用户也能理解
  • 方便老手:熟悉游戏的用户能快速识别 AP、AD 等术语
  • 节省空间:比纯中文"法术强度"或纯英文"Ability Power"都更简洁

结果展示区域

      <View style={styles.resultCard}>
        <Text style={styles.resultLabel}>总伤害</Text>
        <Text style={styles.resultValue}>{totalDamage.toFixed(0)}</Text>
      </View>
    </ScrollView>
  );
}

toFixed(0) 的作用

toFixed(n) 方法把数字格式化为指定小数位数的字符串,会进行四舍五入

123.456.toFixed(0)   // → "123"
123.567.toFixed(0)   // → "124"
123.456.toFixed(2)   // → "123.46"

游戏中的伤害都是整数,所以用 toFixed(0) 去掉小数部分。

结果卡片的视觉设计

结果卡片和输入区域有明显的视觉区分,让用户一眼就能找到计算结果:

设计元素 输入区域 结果卡片
内边距 12px 24px
边框宽度 1px 2px
边框颜色 普通边框色 金色
对齐方式 左对齐 居中
字号 16px 48px

这些差异让结果成为页面的视觉焦点,用户扫一眼就能看到计算结果。

样式设计详解

const styles = StyleSheet.create({
  container: {
    flex: 1, 
    backgroundColor: colors.background, 
    padding: 16
  },
  title: {
    fontSize: 20, 
    fontWeight: 'bold', 
    color: colors.textPrimary, 
    marginBottom: 4
  },
  subtitle: {
    fontSize: 14, 
    color: colors.textSecondary, 
    marginBottom: 20
  },
  inputGroup: {
    marginBottom: 16
  },
  label: {
    fontSize: 14, 
    color: colors.textSecondary, 
    marginBottom: 6
  },
  input: {
    backgroundColor: colors.backgroundCard, 
    borderRadius: 8, 
    padding: 12, 
    fontSize: 16, 
    color: colors.textPrimary, 
    borderWidth: 1, 
    borderColor: colors.border
  },
  resultCard: {
    backgroundColor: colors.backgroundCard, 
    borderRadius: 12, 
    padding: 24, 
    alignItems: 'center', 
    marginTop: 20, 
    borderWidth: 2, 
    borderColor: colors.borderGold
  },
  resultLabel: {
    fontSize: 16, 
    color: colors.textSecondary, 
    marginBottom: 8
  },
  resultValue: {
    fontSize: 48, 
    fontWeight: 'bold', 
    color: colors.textGold
  },
});

输入框样式的设计考量

  • backgroundColor: colors.backgroundCard:和页面背景色有区分,让用户明确知道这是可输入区域
  • borderRadius: 8:圆角让输入框更柔和,符合现代 UI 设计趋势
  • padding: 12:足够的内边距,让文字不会贴着边缘,提升可读性
  • fontSize: 16:适中的字号,在移动端方便阅读和输入
  • borderWidth: 1:细边框增加层次感,但不会太抢眼

结果卡片样式的设计考量

  • borderWidth: 2:比输入框更粗的边框,突出重要性
  • borderColor: colors.borderGold:金色边框,和游戏风格一致,也暗示"这是重要信息"
  • fontSize: 48:超大字号,让结果一目了然
  • alignItems: 'center':居中对齐,让结果成为视觉中心

输入验证的实现思路

当前实现没有做输入验证,用户可以输入任何内容。在生产环境中,可能需要添加验证逻辑。

限制输入范围

const handleApChange = (text: string) => {
  // 允许空字符串(用户正在清空输入)
  if (text === '') {
    setAp(text);
    return;
  }
  
  const num = parseFloat(text);
  // 只接受 0-2000 之间的有效数字
  if (!isNaN(num) && num >= 0 && num <= 2000) {
    setAp(text);
  }
  // 超出范围的输入会被忽略,输入框保持原值
};

这个函数只允许输入 0-2000 之间的数字,超出范围的输入会被静默忽略。2000 是一个合理的上限,正常游戏中很难达到这么高的 AP。

格式化输入(只允许数字和小数点)

const handleRatioChange = (text: string) => {
  // 只保留数字和小数点
  const cleaned = text.replace(/[^0-9.]/g, '');
  
  // 处理多个小数点的情况:只保留第一个
  const parts = cleaned.split('.');
  const formatted = parts.length > 2 
    ? parts[0] + '.' + parts.slice(1).join('') 
    : cleaned;
  
  setApRatio(formatted);
};

这个函数确保用户只能输入有效的数字格式,自动过滤掉字母和特殊字符。

显示错误提示

const [error, setError] = useState('');

const handleApChange = (text: string) => {
  setAp(text);
  
  const num = parseFloat(text);
  if (text !== '' && isNaN(num)) {
    setError('请输入有效的数字');
  } else if (num < 0) {
    setError('数值不能为负');
  } else if (num > 2000) {
    setError('数值不能超过 2000');
  } else {
    setError('');
  }
};

// 在 UI 中显示错误
{error !== '' && (
  <Text style={styles.errorText}>{error}</Text>
)}

这种方式允许用户输入任何内容,但会显示错误提示。用户体验比静默忽略更好,因为用户知道发生了什么。

扩展:添加护甲/魔抗计算

实际伤害还要考虑敌人的防御属性。英雄联盟的减伤公式是:

减伤比例 = 护甲 / (100 + 护甲)
实际伤害 = 原始伤害 × (1 - 减伤比例)

比如敌人有 100 护甲:

减伤比例 = 100 / (100 + 100) = 50%
实际伤害 = 原始伤害 × 50%

实现代码:

const [armor, setArmor] = useState('0');
const [magicResist, setMagicResist] = useState('0');
const [damageType, setDamageType] = useState<'physical' | 'magic'>('magic');

// 计算减伤比例
const getReduction = (resist: number) => {
  if (resist >= 0) {
    return resist / (100 + resist);
  } else {
    // 负抗性会增加伤害(穿透过多时可能出现)
    return resist / (100 - resist);
  }
};

// 根据伤害类型选择对应的抗性
const resistance = damageType === 'physical' 
  ? parseFloat(armor || '0') 
  : parseFloat(magicResist || '0');

const reduction = getReduction(resistance);
const actualDamage = totalDamage * (1 - reduction);

扩展:添加穿透计算

穿透可以减少敌人的有效护甲/魔抗。穿透分为固定穿透百分比穿透,计算顺序是:先百分比,后固定

const [flatPen, setFlatPen] = useState('0');      // 固定穿透
const [percentPen, setPercentPen] = useState('0'); // 百分比穿透

// 计算有效抗性
const calculateEffectiveResist = (baseResist: number) => {
  const percentPenValue = parseFloat(percentPen || '0') / 100;
  const flatPenValue = parseFloat(flatPen || '0');
  
  // 先计算百分比穿透
  const afterPercent = baseResist * (1 - percentPenValue);
  // 再计算固定穿透,最低为 0
  const afterFlat = Math.max(0, afterPercent - flatPenValue);
  
  return afterFlat;
};

const effectiveResist = calculateEffectiveResist(resistance);
const actualDamage = totalDamage * (1 - getReduction(effectiveResist));

扩展:预设技能数据

可以添加一些常用技能的预设,让用户快速填充参数:

const skillPresets = [
  {name: '阿狸 Q', baseDamage: '220', apRatio: '0.7', adRatio: '0'},
  {name: '劫 Q', baseDamage: '230', apRatio: '0', adRatio: '1.0'},
  {name: '拉克丝 R', baseDamage: '500', apRatio: '1.2', adRatio: '0'},
  {name: '盖伦 E', baseDamage: '180', apRatio: '0', adRatio: '0.36'},
];

const applyPreset = (preset: typeof skillPresets[0]) => {
  setBaseDamage(preset.baseDamage);
  setApRatio(preset.apRatio);
  setAdRatio(preset.adRatio);
};

用户点击预设按钮,就能一键填充对应技能的参数,省去手动输入的麻烦。

扩展:计算历史记录

保存用户的计算历史,方便对比不同配置的伤害:

interface HistoryRecord {
  params: {
    baseDamage: string;
    ap: string;
    apRatio: string;
    ad: string;
    adRatio: string;
  };
  result: number;
  timestamp: number;
}

const [history, setHistory] = useState<HistoryRecord[]>([]);

const saveToHistory = () => {
  const record: HistoryRecord = {
    params: {baseDamage, ap, apRatio, ad, adRatio},
    result: totalDamage,
    timestamp: Date.now(),
  };
  // 新记录放在最前面,最多保留 10 条
  setHistory(prev => [record, ...prev].slice(0, 10));
};

用户可以点击历史记录恢复之前的参数,方便对比不同出装方案的伤害差异。

键盘处理优化

在移动端,键盘弹出可能会遮挡输入框。可以用 KeyboardAvoidingView 处理:

import {KeyboardAvoidingView, Platform} from 'react-native';

<KeyboardAvoidingView 
  behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
  style={{flex: 1}}
>
  <ScrollView>
    {/* 内容 */}
  </ScrollView>
</KeyboardAvoidingView>

KeyboardAvoidingView 会在键盘弹出时自动调整布局,确保当前输入框始终可见。behavior 属性在 iOS 和 Android 上需要不同的值才能获得最佳效果。

小结

伤害计算器展示了表单输入页面的典型实现模式:

  1. 受控组件:用 state 管理输入值,通过 value 和 onChangeText 保持同步
  2. 实时计算:每次渲染时根据当前状态计算结果,无需手动触发"计算"按钮
  3. 数值处理:用 parseFloat 转换字符串,用 || '0' 处理空值
  4. 视觉层次:输入区域和结果区域有明显的视觉区分,结果是页面焦点

这种模式可以复用到其他计算工具,比如经验计算器、金币效率计算器、冷却缩减计算器等。

下一篇我们来实现克制关系页面,展示英雄之间的克制信息。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐