在这里插入图片描述

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

在英雄联盟的选人阶段,了解英雄之间的克制关系非常重要。选到克制对手的英雄,可以在对线阶段占据优势;反之,被克制则可能整局都很难受。

这篇文章我们来实现克制关系页面,展示热门英雄的克制信息。这是一个典型的静态数据展示页面,我们会讨论数据结构的设计、卡片列表的渲染、以及如何用视觉设计传达"克制"和"被克制"的语义。

克制关系的游戏背景

在深入代码之前,先聊聊什么是"克制关系"。

英雄联盟中,某些英雄的技能机制天然克制另一些英雄。比如:

  • 亚索潘森克制:潘森的点击技能无法被亚索的风墙挡住,而且潘森的爆发伤害很高,亚索很难换血
  • 丽桑卓克制:丽桑卓的大招可以在劫切入时自我冰封,完美躲避劫的爆发
  • 阿狸卡萨丁克制:卡萨丁的被动减少魔法伤害,Q 技能还能打断阿狸的魅惑

了解这些克制关系,可以帮助玩家在选人阶段做出更好的决策。

数据结构设计

const counterData = [
  {champion: '亚索', counters: ['潘森', '雷恩加尔', '马尔扎哈'], countered: ['阿卡丽', '劫', '卡特琳娜']},
  {champion: '劫', counters: ['丽桑卓', '马尔扎哈', '卡萨丁'], countered: ['阿卡丽', '卡特琳娜', '菲兹']},
  {champion: '阿狸', counters: ['卡萨丁', '加里奥', '马尔扎哈'], countered: ['泽拉斯', '辛德拉', '维克托']},
];

每个英雄的克制数据包含三个字段

字段 类型 含义
champion string 英雄名称
counters string[] 克制该英雄的英雄列表(对该英雄不利)
countered string[] 被该英雄克制的英雄列表(对该英雄有利)

命名的语义

  • counters:谁克制我?(我被谁克制)
  • countered:我克制谁?(谁被我克制)

这个命名可能有点绕,但从当前英雄的视角来看是合理的:counters 是"克制我的人",countered 是"被我克制的人"。

为什么用数组存储?

每个英雄通常有多个克制/被克制的对象,用数组可以存储任意数量。显示时用 join('、') 连接成字符串。

数据来源的考虑

当前数据是硬编码在代码中的。在实际项目中,这些数据可能来自:

  1. 后端 API:根据大数据分析得出的克制关系
  2. 配置文件:JSON 文件,方便非开发人员维护
  3. 社区贡献:玩家投票或编辑的克制信息

硬编码的好处是简单、无需网络请求;缺点是更新需要发版。

组件导入与结构

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

export function CounterPickPage() {
  return (
    <ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
      <Text style={styles.title}>克制关系</Text>
      <Text style={styles.subtitle}>了解英雄之间的克制关系</Text>

这个组件非常简洁,没有任何状态(useState)和副作用(useEffect)。这是因为:

  1. 数据是静态的,不需要从 API 获取
  2. 没有用户交互需要处理
  3. 不需要响应任何外部变化

这种纯展示组件是最简单的组件类型,也是最容易测试和维护的。

页面标题的设计

标题和副标题的组合是一种常见的页面头部设计:

  • 标题:简短有力,告诉用户这是什么页面
  • 副标题:补充说明,解释页面的用途

这种设计在整个 App 中保持一致,形成统一的视觉语言。

克制卡片的渲染

      {counterData.map((data, index) => (
        <View key={index} style={styles.card}>
          <Text style={styles.championName}>{data.champion}</Text>
          <View style={styles.section}>
            <Text style={styles.sectionLabel}>🔴 被克制</Text>
            <Text style={styles.sectionValue}>{data.counters.join('、')}</Text>
          </View>
          <View style={styles.section}>
            <Text style={styles.sectionLabel}>🟢 克制</Text>
            <Text style={styles.sectionValue}>{data.countered.join('、')}</Text>
          </View>
        </View>
      ))}

map 遍历渲染

counterData.map() 遍历数据数组,为每个英雄生成一张卡片。这是 React 中渲染列表的标准方式。

key 的选择

这里用 index 作为 key。虽然通常不推荐用 index,但在这个场景下是安全的,因为:

  1. 数据是静态的,不会动态增删
  2. 列表顺序不会改变
  3. 没有基于 key 的动画或状态

如果数据会动态变化,应该用英雄名称或唯一 ID 作为 key:

<View key={data.champion} style={styles.card}>

卡片内容结构

每张卡片包含三部分:

  1. 英雄名称:金色大字,是卡片的标题
  2. 被克制区域:红色标记,显示克制该英雄的英雄
  3. 克制区域:绿色标记,显示被该英雄克制的英雄

Emoji 图标的语义设计

<Text style={styles.sectionLabel}>🔴 被克制</Text>
<Text style={styles.sectionLabel}>🟢 克制</Text>

这里用 Emoji 圆点来区分两种关系:

  • 🔴 红色:表示危险、不利,用于"被克制"
  • 🟢 绿色:表示安全、有利,用于"克制"

这种颜色语义是通用的:

  • 红色 = 危险、停止、负面
  • 绿色 = 安全、通行、正面

用户不需要阅读文字,看颜色就能理解哪个是好的、哪个是坏的。

为什么用 Emoji 而不是自定义图标?

  1. 简单:不需要额外的图片资源
  2. 跨平台:在所有设备上都能正常显示
  3. 语义清晰:红绿圆点的含义一目了然
  4. 易于修改:直接改字符串,不需要换图片

数组转字符串的处理

{data.counters.join('、')}

join('、') 方法把数组元素用中文顿号连接成字符串:

['潘森', '雷恩加尔', '马尔扎哈'].join('、')
// → '潘森、雷恩加尔、马尔扎哈'

为什么用中文顿号?

因为这是中文环境,用中文顿号(、)比英文逗号(,)更符合阅读习惯。这是一个本地化的细节。

空数组的处理

如果某个英雄没有克制关系,join 会返回空字符串:

[].join('、')  // → ''

可以添加空值处理:

<Text style={styles.sectionValue}>
  {data.counters.length > 0 ? data.counters.join('、') : '暂无数据'}
</Text>

提示信息卡片

      <View style={styles.infoCard}>
        <Text style={styles.infoText}>💡 克制关系仅供参考,实际对局还需考虑玩家操作水平</Text>
      </View>
    </ScrollView>
  );
}

页面底部有一个提示信息卡片,这是一个重要的设计决策。

为什么需要这个提示?

克制关系是统计意义上的,不是绝对的。一个高手用被克制的英雄,可能照样能打赢低手用克制英雄。这个提示:

  1. 管理用户预期:告诉用户这只是参考,不是必胜法则
  2. 避免误导:防止用户过度依赖克制关系
  3. 体现专业性:说明我们理解游戏的复杂性

💡 图标的选择

灯泡 Emoji 表示"提示"或"小贴士",是一种通用的视觉语言。用户看到灯泡就知道这是一条建议或提示。

样式设计详解

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
  },
  card: {
    backgroundColor: colors.backgroundCard, 
    borderRadius: 12, 
    padding: 16, 
    marginBottom: 12, 
    borderWidth: 1, 
    borderColor: colors.border
  },
  championName: {
    fontSize: 18, 
    fontWeight: '600', 
    color: colors.textGold, 
    marginBottom: 12
  },
  section: {
    marginBottom: 8
  },
  sectionLabel: {
    fontSize: 14, 
    color: colors.textSecondary, 
    marginBottom: 4
  },
  sectionValue: {
    fontSize: 14, 
    color: colors.textPrimary
  },
  infoCard: {
    backgroundColor: colors.backgroundCard, 
    borderRadius: 8, 
    padding: 16, 
    marginTop: 8
  },
  infoText: {
    fontSize: 13, 
    color: colors.textSecondary, 
    textAlign: 'center'
  },
});

卡片样式的一致性

克制卡片和提示卡片使用相似的样式(背景色、圆角、内边距),但有细微差别:

属性 克制卡片 提示卡片
borderRadius 12px 8px
marginBottom 12px 0
marginTop 0 8px
边框

提示卡片的圆角更小、没有边框,视觉上更轻量,不会抢克制卡片的风头。

英雄名称的样式

championName: {
  fontSize: 18, 
  fontWeight: '600', 
  color: colors.textGold, 
  marginBottom: 12
}
  • fontSize: 18:比正文大,突出显示
  • fontWeight: ‘600’:半粗体,强调但不过分
  • color: colors.textGold:金色,和游戏风格一致
  • marginBottom: 12:和下面的内容保持距离

信息层级的设计

整个卡片的信息层级是:

  1. 英雄名称(最重要):18px,金色,加粗
  2. 区域标签(次要):14px,灰色
  3. 克制英雄(重要):14px,白色

这种层级让用户可以快速扫描:先看英雄名称确定是不是自己关心的,再看具体的克制关系。

扩展:添加搜索功能

当英雄数量增多时,用户可能需要搜索特定英雄:

const [searchText, setSearchText] = useState('');

const filteredData = counterData.filter(data => 
  data.champion.includes(searchText) ||
  data.counters.some(c => c.includes(searchText)) ||
  data.countered.some(c => c.includes(searchText))
);

这个搜索逻辑会匹配:

  1. 英雄名称包含搜索词
  2. 克制列表中有英雄包含搜索词
  3. 被克制列表中有英雄包含搜索词

比如搜索"潘森",会显示所有和潘森有克制关系的英雄。

扩展:添加详细说明

可以为每个克制关系添加原因说明

interface CounterRelation {
  champion: string;
  reason: string;  // 克制原因
}

const counterData = [
  {
    champion: '亚索',
    counters: [
      {champion: '潘森', reason: '点击技能无法被风墙挡住'},
      {champion: '雷恩加尔', reason: '草丛跳跃难以预判'},
      {champion: '马尔扎哈', reason: '大招压制无法闪避'},
    ],
    countered: [
      {champion: '阿卡丽', reason: '风墙可挡 Q 技能'},
      {champion: '劫', reason: '风墙可挡手里剑'},
      {champion: '卡特琳娜', reason: '风墙可挡飞刀'},
    ],
  },
];

显示时可以添加展开/收起功能,点击查看详细原因。

扩展:添加胜率数据

可以显示对线胜率,让数据更有说服力:

const counterData = [
  {
    champion: '亚索',
    counters: [
      {champion: '潘森', winRate: 42.3},  // 亚索对潘森的胜率
      {champion: '雷恩加尔', winRate: 44.1},
      {champion: '马尔扎哈', winRate: 45.2},
    ],
    countered: [
      {champion: '阿卡丽', winRate: 54.8},
      {champion: '劫', winRate: 53.2},
      {champion: '卡特琳娜', winRate: 55.1},
    ],
  },
];

胜率低于 50% 表示被克制,高于 50% 表示克制。这种数据驱动的方式比主观判断更客观。

扩展:双向查询

当前设计是"以英雄为中心",用户需要找到特定英雄的卡片才能看到克制关系。可以添加双向查询功能:

const [selectedChampion, setSelectedChampion] = useState<string | null>(null);

// 查找所有和选中英雄有关系的数据
const getRelatedData = (champion: string) => {
  const asMain = counterData.find(d => d.champion === champion);
  
  const countersMe = counterData.filter(d => 
    d.countered.includes(champion)
  ).map(d => d.champion);
  
  const counteredByMe = counterData.filter(d => 
    d.counters.includes(champion)
  ).map(d => d.champion);
  
  return {
    direct: asMain,
    countersMe,      // 克制我的英雄
    counteredByMe,   // 被我克制的英雄
  };
};

这样用户选择一个英雄后,可以看到完整的克制网络

扩展:可视化克制图谱

更进一步,可以用图形化的方式展示克制关系:

// 简化的克制关系图
const CounterGraph = ({champion}: {champion: string}) => {
  const data = counterData.find(d => d.champion === champion);
  if (!data) return null;
  
  return (
    <View style={styles.graph}>
      {/* 克制我的英雄(上方) */}
      <View style={styles.countersRow}>
        {data.counters.map(c => (
          <View key={c} style={styles.counterNode}>
            <Text>{c}</Text>
            <Text style={styles.arrow}>↓</Text>
          </View>
        ))}
      </View>
      
      {/* 当前英雄(中间) */}
      <View style={styles.centerNode}>
        <Text style={styles.centerText}>{champion}</Text>
      </View>
      
      {/* 被我克制的英雄(下方) */}
      <View style={styles.counteredRow}>
        {data.countered.map(c => (
          <View key={c} style={styles.counteredNode}>
            <Text style={styles.arrow}>↓</Text>
            <Text>{c}</Text>
          </View>
        ))}
      </View>
    </View>
  );
};

这种可视化方式更直观,用户一眼就能看出谁克制谁。

性能考虑

这个页面的性能非常好,因为:

  1. 无网络请求:数据是静态的
  2. 无状态管理:组件是纯展示的
  3. 列表很短:只有几个英雄,不需要虚拟化

如果数据量增大(比如包含所有 160+ 英雄),需要考虑:

使用 FlatList

<FlatList
  data={counterData}
  keyExtractor={item => item.champion}
  renderItem={({item}) => <CounterCard data={item} />}
/>

FlatList 只渲染可见区域的项目,对长列表性能更好。

数据分页

const [page, setPage] = useState(1);
const pageSize = 10;
const displayData = counterData.slice(0, page * pageSize);

// 滚动到底部时加载更多
const loadMore = () => setPage(p => p + 1);

小结

克制关系页面展示了静态数据展示的典型实现:

  1. 数据结构设计:用对象数组存储结构化数据,字段命名清晰
  2. 语义化设计:用颜色(红/绿)传达"不利/有利"的含义
  3. 列表渲染:用 map 遍历数据生成卡片
  4. 用户提示:在底部添加免责声明,管理用户预期

这种模式可以复用到其他静态数据展示场景,比如英雄定位说明、装备分类介绍、游戏术语解释等。

下一篇我们来实现设置页面,让用户可以自定义 App 的行为。


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

Logo

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

更多推荐