草庐IT

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

HullQin 2023-03-28 原文
大家好,我是公众号「线下聚会游戏」作者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 噢~我有空了会分享做游戏的相关技术。

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

  1. ruby - 在 Ruby 程序执行时阻止 Windows 7 PC 进入休眠状态 - 2

    我需要在客户计算机上运行Ruby应用程序。通常需要几天才能完成(复制大备份文件)。问题是如果启用sleep,它会中断应用程序。否则,计算机将持续运行数周,直到我下次访问为止。有什么方法可以防止执行期间休眠并让Windows在执行后休眠吗?欢迎任何疯狂的想法;-) 最佳答案 Here建议使用SetThreadExecutionStateWinAPI函数,使应用程序能够通知系统它正在使用中,从而防止系统在应用程序运行时进入休眠状态或关闭显示。像这样的东西:require'Win32API'ES_AWAYMODE_REQUIRED=0x0

  2. ruby - 多次弹出/移动 ruby​​ 数组 - 2

    我的代码目前看起来像这样numbers=[1,2,3,4,5]defpop_threepop=[]3.times{pop有没有办法在一行中完成pop_three方法中的内容?我基本上想做类似numbers.slice(0,3)的事情,但要删除切片中的数组项。嗯...嗯,我想我刚刚意识到我可以试试slice! 最佳答案 是numbers.pop(3)或者numbers.shift(3)如果你想要另一边。 关于ruby-多次弹出/移动ruby​​数组,我们在StackOverflow上找到一

  3. ruby-on-rails - 使用 rails 4 设计而不更新用户 - 2

    我将应用程序升级到Rails4,一切正常。我可以登录并转到我的编辑页面。也更新了观点。使用标准View时,用户会更新。但是当我添加例如字段:name时,它​​不会在表单中更新。使用devise3.1.1和gem'protected_attributes'我需要在设备或数据库上运行某种更新命令吗?我也搜索过这个地方,找到了许多不同的解决方案,但没有一个会更新我的用户字段。我没有添加任何自定义字段。 最佳答案 如果您想允许额外的参数,您可以在ApplicationController中使用beforefilter,因为Rails4将参数

  4. ruby-on-rails - 如何重命名或移动 Rails 的 README_FOR_APP - 2

    当我在我的Rails应用程序根目录中运行rakedoc:app时,API文档是使用/doc/README_FOR_APP作为主页生成的。我想向该文件添加.rdoc扩展名,以便它在GitHub上正确呈现。更好的是,我想将它移动到应用程序根目录(/README.rdoc)。有没有办法通过修改包含的rake/rdoctask任务在我的Rakefile中执行此操作?是否有某个地方可以查找可以修改的主页文件的名称?还是我必须编写一个新的Rake任务?额外的问题:Rails应用程序的两个单独文件/README和/doc/README_FOR_APP背后的逻辑是什么?为什么不只有一个?

  5. ruby-on-rails - 简单的 Ruby on Rails 问题——如何将评论附加到用户和文章? - 2

    我意识到这可能是一个非常基本的问题,但我现在已经花了几天时间回过头来解决这个问题,但出于某种原因,Google就是没有帮助我。(我认为部分问题在于我是一个初学者,我不知道该问什么......)我也看过O'Reilly的RubyCookbook和RailsAPI,但我仍然停留在这个问题上.我找到了一些关于多态关系的信息,但它似乎不是我需要的(尽管如果我错了请告诉我)。我正在尝试调整MichaelHartl'stutorial创建一个包含用户、文章和评论的博客应用程序(不使用脚手架)。我希望评论既属于用户又属于文章。我的主要问题是:我不知道如何将当前文章的ID放入评论Controller。

  6. ruby - RVM "ERROR: Unable to checkout branch ."单用户 - 2

    我在新的Debian6VirtualBoxVM上安装RVM时遇到问题。我已经安装了所有需要的包并使用下载了安装脚本(curl-shttps://rvm.beginrescueend.com/install/rvm)>rvm,但以单个用户身份运行时bashrvm我收到以下错误消息:ERROR:Unabletocheckoutbranch.安装在这里停止,并且(据我所知)没有安装RVM的任何文件。如果我以root身份运行脚本(对于多用户安装),我会收到另一条消息:Successfullycheckedoutbranch''安装程序继续并指示成功,但未添加.rvm目录,甚至在修改我的.bas

  7. ruby-on-rails - rbenv:从 RVM 移动到 rbenv 后,在 Jenkins 执行 shell 中找不到命令 - 2

    我从Ubuntu服务器上的RVM转移到rbenv。当我使用RVM时,使用bundle没有问题。转移到rbenv后,我在Jenkins的执行shell中收到“找不到命令”错误。我内爆并删除了RVM,并从~/.bashrc'中删除了所有与RVM相关的行。使用后我仍然收到此错误:rvmimploderm~/.rvm-rfrm~/.rvmrcgeminstallbundlerecho'exportPATH="$HOME/.rbenv/bin:$PATH"'>>~/.bashrcecho'eval"$(rbenvinit-)"'>>~/.bashrc.~/.bashrcrbenvversions

  8. ruby - 在没有基准或时间的情况下用 Ruby 测量用户时间或系统时间 - 2

    因为我现在正在做一些时间测量,我想知道是否可以在不使用Benchmark类或命令行实用程序time的情况下测量用户时间或系统时间。使用Time类只显示挂钟时间,而不显示系统和用户时间,但是我正在寻找具有相同灵active的解决方案,例如time=TimeUtility.now#somecodeuser,system,real=TimeUtility.now-time原因是我有点不喜欢Benchmark,因为它不能只返回数字(编辑:我错了-它可以。请参阅下面的答案。)。当然,我可以解析输出,但感觉不对。*NIX系统的time实用程序也应该可以解决我的问题,但我想知道是否已经在Ruby中实

  9. ruby - 我需要从 facebook 游戏中抓取数据——使用 ruby - 2

    修改(澄清问题)我已经花了几天时间试图弄清楚如何从Facebook游戏中抓取特定信息;但是,我遇到了一堵又一堵砖墙。据我所知,主要问题如下。我可以使用Chrome的检查元素工具手动查找我需要的html-它似乎位于iframe中。但是,当我尝试抓取该iframe时,它​​是空的(属性除外):如果我使用浏览器的“查看页面源代码”工具,这与我看到的输出相同。我不明白为什么我看不到iframe中的数据。答案不是它是由AJAX之后添加的。(我知道这既是因为“查看页面源代码”可以读取Ajax添加的数据,也是因为我有b/c我一直等到我可以看到数据页面之后才抓取它,但它仍然不存在)。发生这种情况是因为

  10. ruby-on-rails - 使用 javascript 更改数据方法不会更改 ajax 调用用户的什么方法? - 2

    我遇到了一个非常奇怪的问题,我很难解决。在我看来,我有一个与data-remote="true"和data-method="delete"的链接。当我单击该链接时,我可以看到对我的Rails服务器的DELETE请求。返回的JS代码会更改此链接的属性,其中包括href和data-method。再次单击此链接后,我的服务器收到了对新href的请求,但使用的是旧的data-method,即使我已将其从DELETE到POST(它仍然发送一个DELETE请求)。但是,如果我刷新页面,HTML与"new"HTML相同(随返回的JS发生变化),但它实际上发送了正确的请求类型。这就是这个问题令我困惑的

随机推荐