• [教你做小游戏] 滑动选中!PC端+移动端适配!完美用户体验!斗地主手牌交互示范


    我是HullQin,公众号线下聚会游戏的作者(欢迎关注公众号,发送加微信,交个朋友),转发本文前需获得作者HullQin授权。我独立开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏,不收费没广告。还开发了《Dice Crush》参加Game Jam 2022。喜欢可以关注我 HullQin 噢~我有空了会分享做游戏的相关技术。

    背景

    之前我们提到了斗地主的最优秀的交互方案:《斗地主的手牌,如何布局?看25万粉游戏区UP主怎么说》。

    具体交互如下:

    PC端:

    1. 未选中的牌,是默认状态;选中的牌,加一层半透明的黑色遮罩层。
    2. 鼠标单击牌,可以选中牌。
    3. 鼠标单击已选中的牌,可以取消选中。
    4. 鼠标点击某个未选中的牌,并且开始拖拽,所滑过的牌,都会被选中。 (不是反选那么简单!)
    5. 鼠标点击某个已选中的牌,并且开始拖拽,所滑过的牌,都会被取消选中。 (不是反选那么简单!)

    移动端:

    1. 未选中的牌,是默认状态;选中的牌,加一层半透明的黑色遮罩层。
    2. 轻触一张牌,可以选中牌。
    3. 轻触已选中的一张牌,可以取消选中。
    4. 手指从某个未选中的牌开始滑动,所滑过的牌,都会被选中。 (不是反选那么简单!)
    5. 手指从某个已选中的牌开始滑动,所滑过的牌,都会被取消选中。 (不是反选那么简单!)

    今天,我们聊一下,如何用JS开发实现这种对用户体验友好的交互。

    背景知识

    DragEvent和TouchEvent

    为什么上面2个交互,看起来一模一样,我却要说两遍呢?

    其实,用鼠标(或触摸板),这种带有光标的交互设备,拖拽触发的是Drag事件。而触摸屏幕这种交互,滑动触发的是Touch事件。两种事件是不一样的,他们有本质上的区别:光标同一时间只能处于一个位置,但是触摸屏幕允许多点同时触摸。因此Web API在设计时,就把这两种事件区分了:DragEventTouchEvent

    我们在开发时,也要特别注意这点——这个交互要开发2次,同时支持DragEventTouchEvent

    关于滑动/拖动与click

    在触摸屏设备上,轻触屏幕时,会同时触发TouchEvent(包括touchmove、touchstart等)和click。也就是说:click和TouchEvent可能会同时触发

    但是在光标交互时,点击一下鼠标只会触发click,不会触发DragEvent(dragstart、dragenter等)。但是如果你点击鼠标并移动,则只会触发DragEvent不会触发click。也就是说:click和DragEvent不会同时触发

    所以有个注意事项:当你要同时实现TouchEvent处理逻辑和click处理逻辑时,要通过代码逻辑保证,2个逻辑不同时触发。(否则,如果你的代码逻辑是反选某个牌,轻触屏幕后,你会发现没反应,原因是2次反选等于没变。)

    基础组件

    我们上次有文章已经介绍了,如何开发展示扑克牌的组件:《展示斗地主扑克牌,支持按出牌规则排序!支持按大小排序!》。

    定义组件的输入参数

    我们这次要实现的是一个手牌列表,可以取名为PokerListSSQ,(其中SSQ是时少权的首字母,以他的名字做组件名,表示对创意提出者的尊重)。

    • 我们肯定是需要一个扑克牌id列表的。
    • 为了动态调整牌的大小,也允许传入height。
    • 这是一个交互控件,有一个最重要的状态:选中牌的列表,这个状态需要暴露给父组件,方便点击「出牌」时,其它兄弟组件可以获取到这些选中牌。所以我们直接把selectedsetSelected这两个东西维护在父组件中(可参考React文档:状态提升)。因此,这就多了2个参数:selectedsetSelected

    参考props的类型定义:

    type PokerListProps = {
      ids: number[];
      height?: number;
      className?: string;
      selected: number[];
      setSelected: number[] | (selected: number[]) => void;
      style?: CSSProperties;
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    难点:扑克牌如何摆布局?

    输入参数有ids,有一个难点:如何把扑克牌按照预期摆放?

    计算left距离

    首先,有一点可以确定:扑克牌的left一定跟它的数字有关,比如大王,left=0,扑克牌的大小越小,那么left就越大,这是一个线性函数的映射。比较容易得出。

    先计算牌大小:

    let cardNumber = getCardNumber(id);
    cardNumber = cardNumber > 50 ? 50 : cardNumber;
    
    • 1
    • 2

    其中getCardNumber会把扑克牌ID映射到扑克牌的一个值(代表它的大小)。3-13映射到3-13本身,A和2对应14、15,大王小王映射到54、53。

    这里为了让大小王能够放在同一列展示,所以又做了一次转换,统一为50。

    那么每个扑克牌的left距离计算如下:

    let left;
    if (cardNumber >= 50) left = 0;
    else left = (16 - cardNumber) * gap;
    
    • 1
    • 2
    • 3

    其中gap就是相邻扑克牌的间距,可动态调整,本代码采用的是const gap = height * 48 / 159

    计算top距离

    如果你有最多8个相同的牌(假如你有8个K),那么这一列K的top是比较好计算的,也是等差数列,从0一直到7*padding(其中padding是垂直方向,两张相邻牌的间距,跟gap一个意思,只是一个横轴一个纵轴)。

    但如果此时,如果你出了一张K,只有7个K了,而且其他牌不足8张。那么此时,所有牌的top都应该减去1个padding,保证上方没有太大空白。如果你的牌出到最后,中间留下7个padding的空白,是很丑的。

    所以每张扑克牌的top不仅跟当前扑克牌是同数字牌中的第几张count有关,还跟最大相同牌数maxCount有关,公式如下:

    const top = (maxCount - count) * padding;
    
    • 1

    效果如下:

    1.png

    出了1张8后,变为:

    2.png

    计算z-index

    这就够了吗?还不够,为了让扑克牌展示正确的遮挡关系,我们还需要计算一下zIndex:

    const zIndex = (left << 5) - count + 10;
    
    • 1

    left << 5就是乘了个很大的数字,也就是说,优先以left判断,left越小,表明位置越靠左,zIndex就小,应该被遮住。

    对于同样大小的扑克牌,按照count计算,count越大,表明位置越靠上,zIndex越小,会被遮住。

    给Poker定义style样式

    <Poker
      style={{
        left, top, zIndex, filter: selected.includes(id - 1) ? 'brightness(0.8)' : 'brightness(1)', transform: `scale(${height / 159})`,
      }}
    />
    
    • 1
    • 2
    • 3
    • 4
    • 5

    left top zIndex上面已经描述过。此外还用了filter给扑克牌增加黑色半透明遮罩层,用了transform给扑克牌放缩。

    DragEvent

    还记得文章开头提到的吗?

    1. 鼠标点击某个未选中的牌,并且开始拖拽,所滑过的牌,都会被选中。 (不是反选那么简单!)
    2. 鼠标点击某个已选中的牌,并且开始拖拽,所滑过的牌,都会被取消选中。 (不是反选那么简单!)

    所以我们要用一个cardFlag,记录一开始点的牌,状态是什么。

    const cardFlag = useRef<boolean>(false);
    
    • 1

    随后,给每个添加事件onDragStartonDragEnter

    onDragStart={(event: DragEvent) => {
      if (event.dataTransfer) {
        const img = new Image();
        img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
        event.dataTransfer.setDragImage(img, 0, 0);
      }
      cardFlag.current = selected.includes(id - 1);
      setSelected(((oldSelected: number[]) => {
        const index2 = oldSelected.indexOf(id - 1);
        if (index2 === -1) {
          if (!cardFlag.current) oldSelected.push(id - 1);
        } else if (cardFlag.current) oldSelected.splice(index2, 1);
      }));
    }}
    onDragEnter={() => {
      setSelected(((oldSelected: number[]) => {
        const index2 = oldSelected.indexOf(id - 1);
        if (index2 === -1) {
          if (!cardFlag.current) oldSelected.push(id - 1);
        } else if (cardFlag.current) oldSelected.splice(index2, 1);
      }));
    }}
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    注意事项

    1. 如果要拖拽div,需要给div设置draggable属性。如果你拖拽imga这种天然支持拖拽的元素,就可以不用加。
    2. 拖拽时,会有个拖拽图片,如何隐藏掉呢?用event.dataTransfer.setDragImage函数即可,设置了一个透明的拖拽图片。上面img.src是用base64构造了一个1*1的透明的gif。
    3. 这里使用了use-immer,所以setSelected的逻辑内可以直接修改oldSelected,而不必return newSelected。
    const [selectedCards, setSelectedCards] = useImmer<number[]>([]);
    
    • 1

    TouchEvent

    先定义一个onTouch函数,它会被用2次,分别在onTouchStartonTouchMove上。

    const onTouch = (ev : TouchEvent) => {
      const { clientX, clientY } = ev.changedTouches[0];
      let topEl: HTMLElement | undefined;
      let topZIndex = -999;
      // TODO: 这里可以改用React ref引用,从而获取元素。调用dom API并不合理,但这看起来会容易懂。
      Array.from(document.getElementsByClassName('my-poker-list')).forEach((el: any) => {
        const {
          x, y, width, height,
        } = el.getBoundingClientRect();
        if (clientX >= x && clientX <= x + width && clientY >= y && clientY <= y + height) {
          const z = Number(el.style.zIndex);
          if (z > topZIndex) {
            topZIndex = z;
            topEl = el;
          }
        }
      });
      // 上面计算到了当前触摸的扑克牌是哪张(topEl)
      if (!topEl) return;
      // 下面依赖dom元素的id属性获取扑克牌ID,所以需要给增加id字段。
      const currentId = Number(topEl.getAttribute('id')) - 1;
      setSelected(((oldSelected: number[]) => {
        const index2 = oldSelected.indexOf(currentId);
        if (index2 === -1) {
          if (!cardFlag.current) oldSelected.push(currentId);
        } else if (cardFlag.current) oldSelected.splice(index2, 1);
      }));
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    给Poker赋值以下字段:

    <Poker
      key={id}
      id={id}
      className="my-poker-list"
      onTouchStart={(ev: TouchEvent) => {
        cardFlag.current = selected.includes(id - 1);
        onTouch(ev);
      }}
      onTouchMove={(ev: TouchEvent) => {
        onTouch(ev);
      }}
    />
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    onClick

    我们需要给Poker增加onClick的处理器,这里注意,当是触摸屏时,禁止触发该事件。

    怎么判断?用if ('ontouchstart' in window)即可。

    onClick={() => {
      if ('ontouchstart' in window) return;
      setSelected((oldSelected: number[]) => {
        const index2 = oldSelected.indexOf(id - 1);
        if (index2 === -1) {
          oldSelected.push(id - 1);
        } else {
          oldSelected.splice(index2, 1);
        }
      });
    }}
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    组件PokerListSSQ的完整代码

    import React, {
      CSSProperties, useEffect, useMemo, useRef,
    } from 'react';
    import Poker from './Poker';
    import { getCardNumber, sortPokersById } from '../utils/ddz';
    
    type PokerListProps = {
      ids: number[];
      height?: number;
      className?: string;
      selected: number[];
      setSelected: any;
      style?: CSSProperties;
    };
    
    function PokerListSSQ(props: PokerListProps) {
      const {
        ids: pids, height = 159, className, selected, setSelected, style,
      } = props;
      const ids = pids.map((i) => i + 1);
      const sortedIds = useMemo(() => sortPokersById([...ids]), [ids]);
      const cardFlag = useRef<boolean>(false);
      useEffect(() => {
        setSelected([]);
      }, [sortedIds.length]);
      const padding = height * 58 / 159;
      const gap = height * 48 / 159;
      let maxCount = 1;
      let count = 0;
      let lastCardNumber = 0;
      sortedIds.forEach((id) => {
        let cardNumber = getCardNumber(id);
        cardNumber = cardNumber > 50 ? 50 : cardNumber;
        if (cardNumber === lastCardNumber) {
          count += 1;
          if (count > maxCount) maxCount = count;
        } else {
          lastCardNumber = cardNumber;
          count = 0;
        }
      });
      count = 0;
      lastCardNumber = 0;
      const cards = sortedIds.map((id) => {
        let cardNumber = getCardNumber(id);
        cardNumber = cardNumber > 50 ? 50 : cardNumber;
        if (cardNumber === lastCardNumber) {
          count += 1;
        } else {
          lastCardNumber = cardNumber;
          count = 0;
        }
        let left;
        if (cardNumber >= 50) left = 0;
        else left = (16 - cardNumber) * gap;
        const onTouch = (ev : TouchEvent) => {
          const { clientX, clientY } = ev.changedTouches[0];
          let topEl: HTMLElement | undefined;
          let topZIndex = -999;
          Array.from(document.getElementsByClassName('my-poker-list')).forEach((el: any) => {
            const {
              x, y, width, height,
            } = el.getBoundingClientRect();
            if (clientX >= x && clientX <= x + width && clientY >= y && clientY <= y + height) {
              const z = Number(el.style.zIndex);
              if (z > topZIndex) {
                topZIndex = z;
                topEl = el;
              }
            }
          });
          if (!topEl) return;
          const currentId = Number(topEl.getAttribute('id')) - 1;
          setSelected(((oldSelected: number[]) => {
            const index2 = oldSelected.indexOf(currentId);
            if (index2 === -1) {
              if (!cardFlag.current) oldSelected.push(currentId);
            } else if (cardFlag.current) oldSelected.splice(index2, 1);
          }));
        };
        return (
          <Poker
            key={id}
            id={id}
            className="my-poker-list"
            style={{
              left, top: (maxCount - count) * padding, zIndex: (left << 5) - count + 10, filter: selected.includes(id - 1) ? 'brightness(0.8)' : 'brightness(1)', transform: `scale(${height / 159})`,
            }}
            onClick={() => {
              if ('ontouchstart' in window) return;
              setSelected((oldSelected: number[]) => {
                const index2 = oldSelected.indexOf(id - 1);
                if (index2 === -1) {
                  oldSelected.push(id - 1);
                } else {
                  oldSelected.splice(index2, 1);
                }
              });
            }}
            onDragStart={(event: DragEvent) => {
              if (event.dataTransfer) {
                const img = new Image();
                img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
                event.dataTransfer.setDragImage(img, 0, 0);
              }
              cardFlag.current = selected.includes(id - 1);
              setSelected(((oldSelected: number[]) => {
                const index2 = oldSelected.indexOf(id - 1);
                if (index2 === -1) {
                  if (!cardFlag.current) oldSelected.push(id - 1);
                } else if (cardFlag.current) oldSelected.splice(index2, 1);
              }));
            }}
            onDragEnter={() => {
              setSelected(((oldSelected: number[]) => {
                const index2 = oldSelected.indexOf(id - 1);
                if (index2 === -1) {
                  if (!cardFlag.current) oldSelected.push(id - 1);
                } else if (cardFlag.current) oldSelected.splice(index2, 1);
              }));
            }}
            onTouchStart={(ev: TouchEvent) => {
              cardFlag.current = selected.includes(id - 1);
              onTouch(ev);
            }}
            onTouchMove={(ev: TouchEvent) => {
              onTouch(ev);
            }}
          />
        );
      });
    
      return (
        <div
          className={`poker-list${className ? ` ${className}` : ''}`}
          style={{ height: height + padding * maxCount, ...style }}
        >
          {cards}
        </div>
      );
    }
    
    PokerListSSQ.defaultProps = {
      height: 159,
    };
    
    export default PokerListSSQ;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147

    注:

    • import Poker from './Poker';import { getCardNumber, sortPokersById } from '../utils/ddz';的代码都在《展示斗地主扑克牌,支持按出牌规则排序!支持按大小排序!》。

    写在最后

    我是HullQin,公众号线下聚会游戏的作者(欢迎关注公众号,发送加微信,交个朋友),转发本文前需获得作者HullQin授权。我独立开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏,不收费没广告。还开发了《Dice Crush》参加Game Jam 2022。喜欢可以关注我 HullQin 噢~我有空了会分享做游戏的相关技术。

  • 相关阅读:
    K8s安装乐维5.0应用部署文档
    玩转Mybatis高级特性:让你的数据操作更上一层楼
    源码分析:设备活跃和心跳分析
    XSS、CSRF、sql注入
    IntelliJ IDEA 2023.2.1 (Ultimate Edition) 版本 Git 如何合并多次的本地提交进行 Push
    C#获取声音信号并通过FFT得到声音频谱
    美国DDoS服务器:如何保护你的网站免遭攻击?
    this is biaoti
    解决线上服务器CPU占用过高问题
    Java中使用JTS实现WKT字符串读取转换线、查找LineString的list中距离最近的线、LineString做缓冲区扩展并计算点在缓冲区内的方位角
  • 原文地址:https://blog.csdn.net/kd_2015/article/details/126882958