写点什么

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

作者:HullQin
  • 2022 年 9 月 05 日
    广东
  • 本文字数:6674 字

    阅读完需:约 22 分钟

我是 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 噢~我有空了会分享做游戏的相关技术。

发布于: 刚刚阅读数: 4
用户头像

HullQin

关注

公众号【线下聚会游戏】 2020.10.07 加入

game.hullqin.cn 我做了一些联机桌游网页:支持2-10人联机的UNO、2-4人联机的斗地主、2人联机的五子棋。无需下载,点开即玩!叫上朋友,即刻开局!不看广告,不做任务,享受「纯粹」的游戏!

评论

发布
暂无评论
[教你做小游戏] 滑动选中!PC端+移动端适配!完美用户体验!斗地主手牌交互示范_CSS_HullQin_InfoQ写作社区