乐趣区

关于前端:教你做小游戏-滑动选中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;
};

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

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

计算 left 间隔

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

先计算牌大小:

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

其中 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;

其中 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 张 8 后,变为:

计算 z -index

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

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

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})`,
  }}
/>

left top zIndex 下面曾经形容过。此外还用了 filter 给扑克牌减少彩色半透明遮罩层,用了 transform 给扑克牌放缩。

DragEvent

还记得文章结尾提到的吗?

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

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

const cardFlag = useRef<boolean>(false);

随后,给每个 \<Poker /> 增加事件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. 如果要拖拽 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[]>([]);

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,所以须要给 <Poker> 减少 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);
  }));
};

给 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);
  }}
/>

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);
    }
  });
}}

组件 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;

注:

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

写在最初

我是 HullQin,公众号 线下团聚游戏 的作者(欢送关注公众号,发送加微信,交个敌人),转发本文前需取得作者 HullQin 受权。我独立开发了《联机桌游合集》,是个网页,能够很不便的跟敌人联机玩斗地主、五子棋等游戏,不免费没广告。还开发了《Dice Crush》加入 Game Jam 2022。喜爱能够关注我 HullQin 噢~我有空了会分享做游戏的相干技术。

退出移动版