我是HullQin,公众号线下团聚游戏的作者(欢送关注公众号,发送加微信,交个敌人),转发本文前需取得作者HullQin受权。我独立开发了《联机桌游合集》,是个网页,能够很不便的跟敌人联机玩斗地主、五子棋等游戏,不免费没广告。还开发了《Dice Crush》加入Game Jam 2022。喜爱能够关注我 HullQin 噢~我有空了会分享做游戏的相干技术。
背景
之前咱们提到了斗地主的最优良的交互计划:《斗地主的手牌,如何布局?看25万粉游戏区UP主怎么说》。
具体交互如下:
PC端:
- 未选中的牌,是默认状态;选中的牌,加一层半透明的彩色遮罩层。
- 鼠标单击牌,能够选中牌。
- 鼠标单击已选中的牌,能够勾销选中。
- 鼠标点击某个未选中的牌,并且开始拖拽,所滑过的牌,都会被选中。 (不是反选那么简略!)
- 鼠标点击某个已选中的牌,并且开始拖拽,所滑过的牌,都会被勾销选中。 (不是反选那么简略!)
挪动端:
- 未选中的牌,是默认状态;选中的牌,加一层半透明的彩色遮罩层。
- 轻触一张牌,能够选中牌。
- 轻触已选中的一张牌,能够勾销选中。
- 手指从某个未选中的牌开始滑动,所滑过的牌,都会被选中。 (不是反选那么简略!)
- 手指从某个已选中的牌开始滑动,所滑过的牌,都会被勾销选中。 (不是反选那么简略!)
明天,咱们聊一下,如何用JS开发实现这种对用户体验敌对的交互。
背景常识
DragEvent和TouchEvent
为什么下面2个交互,看起来截然不同,我却要说两遍呢?
其实,用鼠标(或触摸板),这种带有光标的交互设施,拖拽触发的是Drag事件。而触摸屏幕这种交互,滑动触发的是Touch事件。两种事件是不一样的,他们有实质上的区别:光标同一时间只能处于一个地位,然而触摸屏幕容许多点同时触摸。因而Web API在设计时,就把这两种事件辨别了:DragEvent
和TouchEvent
。
咱们在开发时,也要特地留神这点——这个交互要开发2次,同时反对DragEvent
和TouchEvent
。
对于滑动/拖动与click
在触摸屏设施上,轻触屏幕时,会同时触发TouchEvent(包含touchmove、touchstart等)和click。也就是说:click和TouchEvent可能会同时触发。
然而在光标交互时,点击一下鼠标只会触发click,不会触发DragEvent(dragstart、dragenter等)。然而如果你点击鼠标并挪动,则只会触发DragEvent不会触发click。也就是说:click和DragEvent不会同时触发。
所以有个注意事项:当你要同时实现TouchEvent解决逻辑和click解决逻辑时,要通过代码逻辑保障,2个逻辑不同时触发。(否则,如果你的代码逻辑是反选某个牌,轻触屏幕后,你会发现没反馈,起因是2次反选等于没变。)
根底组件
咱们上次有文章曾经介绍了,如何开发展现扑克牌的组件:《展现斗地主扑克牌,反对按出牌规定排序!反对按大小排序!》。
定义组件的输出参数
咱们这次要实现的是一个手牌列表,能够取名为PokerListSSQ
,(其中SSQ是时少权的首字母,以他的名字做组件名,示意对创意提出者的尊重)。
- 咱们必定是须要一个扑克牌id列表的。
- 为了动静调整牌的大小,也容许传入height。
- 这是一个交互控件,有一个最重要的状态:选中牌的列表,这个状态须要裸露给父组件,不便点击「出牌」时,其它兄弟组件能够获取到这些选中牌。所以咱们间接把
selected
和setSelected
这两个货色保护在父组件中(可参考React文档:状态晋升)。因而,这就多了2个参数:selected
和setSelected
。
参考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
还记得文章结尾提到的吗?
- 鼠标点击某个未选中的牌,并且开始拖拽,所滑过的牌,都会被选中。 (不是反选那么简略!)
- 鼠标点击某个已选中的牌,并且开始拖拽,所滑过的牌,都会被勾销选中。 (不是反选那么简略!)
所以咱们要用一个cardFlag
,记录一开始点的牌,状态是什么。
const cardFlag = useRef<boolean>(false);
随后,给每个\<Poker />增加事件onDragStart
、onDragEnter
:
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); }));}}
注意事项
- 如果要拖拽
div
,须要给div
设置draggable
属性。如果你拖拽img
、a
这种人造反对拖拽的元素,就能够不必加。 - 拖拽时,会有个拖拽图片,如何暗藏掉呢?用
event.dataTransfer.setDragImage
函数即可,设置了一个通明的拖拽图片。下面img.src是用base64结构了一个1*1的通明的gif。 - 这里应用了
use-immer
,所以setSelected
的逻辑内能够间接批改oldSelected
,而不用return newSelected。
const [selectedCards, setSelectedCards] = useImmer<number[]>([]);
TouchEvent
先定义一个onTouch
函数,它会被用2次,别离在onTouchStart
、onTouchMove
上。
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 噢~我有空了会分享做游戏的相干技术。