十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

纯前端实现交互式塔罗牌抽牌应用:洗牌算法与3D翻转动画

纯前端实现交互式塔罗牌抽牌应用:洗牌算法与3D翻转动画 做这个交互式塔罗牌抽牌应用其实是有一天下班后突发奇想塔罗牌实体卡太大、翻起来麻烦手机上的小程序又总是广告弹窗不如自己花一个晚上写个干净的网页版。核心想法很简单——页面上铺开盘成一排的牌背点“洗牌”后牌序被打乱点任意一张牌它以 3D 翻转的动画翻开展示牌名、正逆位和一句通俗解释同时把抽过的牌记录在浏览器本地下次打开还能看到抽牌痕迹。整个项目不依赖后端没有数据库就是一个纯前端的单页面应用。虽然规模不大但该有的交互全都有洗牌动画、翻牌动效、随机算法、状态持久化。如果你正在学前端想找一个不太复杂又有点仪式感的小项目练手或者单纯想自己做一个随时随地能玩的抽牌工具这篇笔记里的设计思路和踩坑记录都能直接抄作业。1. 设计思路与功能拆解写代码之前我先把需求想清楚了不是做一个“看起来很酷但不好用”的演示页而是要让用户真的愿意每天打开抽一次。所以核心并不在塔罗占卜本身而是交互能不能给人“抽牌的感觉”。1.1 塔罗牌应用的核心交互模型实体塔罗牌的使用流程是洗牌、切牌、抽牌、翻牌、解读。放到网页上这五个动作必须全部保留否则用户会觉得“这是数据库查询不是抽牌”。我拆解了一下洗牌对应页面上的随机打乱操作必须让用户有“牌在动”的视觉反馈而不是直接刷新结果这是仪式感的重要来源。切牌实体操作里是从牌堆中分出一叠网页上我用“让用户在桌面上选择一张牌”替代。所有牌背面朝上平铺用户可以自由选择既还原了“切牌”里人为选择的部分又降低了实现难度。抽牌点击任意一张牌触发翻牌动画。翻牌用 CSS 的 3D 翻转实现牌背朝上时是一张图案翻过来后显示牌名和正逆位。解读每张牌预置含义文案正位和逆位展示不同内容保证解读不会千篇一律。这个交互模型是规划的核心也是后面所有代码的骨架。功能上我分为两个页面区块一个是抽牌区一个是历史记录区。抽牌区负责动画和结果展示历史记录区负责把每次抽牌的结果按时间倒序存在浏览器本地最多保留最近 50 条避免 localStorage 无限膨胀。1.2 技术方案选型为什么坚持纯前端选择纯前端方案最初是因为我不想搭服务器但做着做着发现这个选择在体验上也有明显优势页面加载后所有数据都在本地断网也能打开点“洗牌”后光影和牌背翻转的动画是即时的没有任何网络延迟。我用了原生 JavaScript 加少量现代浏览器特性没有引入框架。原因有两个一是这个项目的交互规模有限用 Vue 或 React 需要多一次构建配置反而绕远路二是纯原生代码对前端初学者更友好打开 HTML 文件就能跑理解起来没有黑盒。模块化方面我只用了 ES Module把卡组数据、洗牌算法、历史记录逻辑拆成三个独立文件。如果后续想扩展成多用户在线对抽再考虑引入 Vue 或 React 加 WebSocket 也不迟。单机场景下原生方案完全够用。2. 核心实现细节与交互设计这个项目的难点不在“随机抽一张牌”而在“如何让抽牌过程有仪式感”。这里展开聊聊数据结构、随机算法和翻牌动效三个核心细节。2.1 塔罗牌数据结构设计与正逆位处理塔罗牌标准牌组有 78 张牌分大阿卡纳22 张和小阿卡纳56 张。但很多轻量应用只使用大阿卡纳 22 张一是文案量小二是对非专业用户来说大阿卡纳的牌意更容易理解。我这个版本默认使用完整 78 张但数据结构从一开始就设计成了写 1 张和写 78 张没有区别。每张牌的数据结构我定义成这样{ id: 0, name: 愚者, arcana: major, // major 大阿卡纳minor 小阿卡纳 suit: null, // 小阿卡纳的权杖/圣杯/宝剑/星币大阿卡纳为 null number: 0, upright: 新的开始、冒险、天真, reversed: 鲁莽、缺乏方向、犹豫, image: cards/00.jpg }upright和reversed是两个固定字段翻牌后由随机结果决定显示哪个。这里有个细节正逆位的判断我用的是随机数而不是像实体洗牌那样由牌的物理方向决定。对纯前端应用来说这是最合理的方案因为一张图片随机旋转 180 度在视觉上容易显得歪斜体验不好。我在历史记录里也会存下每次是正位还是逆位方便回溯。2.2 洗牌算法从“看起来随机”到“验证随机”洗牌的常规做法是 Fisher-Yates 洗牌算法比sort(() Math.random() - 0.5)靠谱得多。sort那个方案的问题在于比较函数不符合排序的传递性不同浏览器可能产生不同的乱序效果而且分布不均匀——简单说就是有些牌更容易出现在某几个位置。Fisher-Yates 则是从后往前遍历每次从未处理的元素中随机取一个放到当前位置时间复杂度 O(n)洗完后每个排列出现的概率理论上是均等的。我实现的洗牌函数function shuffle(deck) { const arr [...deck]; for (let i arr.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [arr[i], arr[j]] [arr[j], arr[i]]; } return arr; }抽牌时并不是把这张牌从牌堆里永久移除而是从洗好的数组中取第一张。为什么不是“抽到哪张就移除哪张”因为这样会导致一局牌越抽越少最后只剩几张体验不完整。我的做法是每次“洗牌”时生成一份新的随机排列用户抽完一张后如果想继续抽可以再次点击“重新洗牌”把 78 张牌重新打乱。每局牌之间相互独立既避免了重复抽到同一张牌的尴尬又保留了“每次抽牌都是一个完整抽取过程”的仪式感。2.3 3D 翻牌动效与视觉反馈的实现翻牌是交互中最有仪式感的动作。实现用了 CSStransform-style: preserve-3d配合rotateY(180deg)做翻转牌分正面和背面两层背面的backface-visibility设为hidden。要点是正反两面的层级关系。我给每张牌设置了一个容器.card里面有两个子元素.card-back和.card-front。默认状态下面朝用户的是牌背点击后容器旋转 180 度牌面才转过来。同时我会在翻牌结束后延迟显示牌名和解释文案动画时长控制在 600 毫秒太短显得仓促太长用户会着急。.card { transform-style: preserve-3d; transition: transform 0.6s cubic-bezier(0.4, 2, 0.6, 1); } .card.flipped { transform: rotateY(180deg); } .card-back, .card-front { position: absolute; inset: 0; backface-visibility: hidden; border-radius: 10px; } .card-front { transform: rotateY(180deg); }一个小细节过渡曲线用了cubic-bezier(0.4, 2, 0.6, 1)hmm等等我在实测时发现这个曲线在翻牌时会轻微回弹看起来像实体牌翻过去之后抖了一下效果很不错。但要注意回弹幅度不要太大否则会有廉价感如果你不喜欢回弹效果换回ease-in-out就好。3. 实操过程与核心环节实现这个部分我把最关键的实现代码贴出来从页面结构到逻辑一条线说清楚。3.1 页面结构与基础布局页面结构分三块顶部的标题和操作按钮中间的抽牌区底部是历史记录。基础布局我直接用 Flexbox牌桌区域宽度超过一定值后自动换行。div idapp div classheader h1赛博塔罗/h1 button idshuffleBtn重新洗牌/button /div div idtable classtable/div div idresult classresult-panel/div div idhistory classhistory-panel/div /div渲染 78 张牌时我用一个for循环生成.card结构然后把每张牌的位置设定为按顺序排布。实体塔罗牌是叠成一摞的网页上叠在一起不现实因此平铺显示。这里有个体验上的取舍78 张牌全平铺在桌面端会排得非常长。解决办法是加载时只显示一部分牌剩下的隐藏在屏幕边缘附近点击“洗牌”后再重新排列为了不煞风景我直接限定牌桌区域为两行每行最多显示 13 张多出部分横向滚动。3.2 抽牌逻辑与状态管理我在state.js里维护了几个核心状态const state { deck: [], currentPick: null, // 当前抽取的牌对象 history: [], locked: false // 动画期间禁止重复点击 };抽牌函数的核心有三步调用shuffle()生成乱序排列。点击某张牌时从该牌在 DOM 上的>function pickCard(index) { if (state.locked) return; state.locked true; const cardEl document.querySelectorAll(.card)[index]; const card state.deck[index]; cardEl.classList.add(flipped); setTimeout(() { // 正逆位判定 const isReversed Math.random() 0.5; const meaning isReversed ? card.reversed : card.upright; state.currentPick { ...card, isReversed, meaning, time: Date.now() }; renderResult(state.currentPick); saveToHistory(state.currentPick); state.locked false; }, 700); }这里有个小坑querySelectorAll(.card)[index]的方案依赖 DOM 顺序和state.deck顺序一致。如果列表被重新渲染过索引就会对不上。更稳妥的做法是在生成牌时直接用闭包绑定索引cardEl.addEventListener(click, () pickCard(cardIndex));闭包方案从根本上规避了“DOM 顺序和数据顺序不一致”的问题。我在第一版用了>function saveToHistory(record) { const history JSON.parse(localStorage.getItem(tarot_history) || []); history.unshift(record); if (history.length 50) history.length 50; localStorage.setItem(tarot_history, JSON.stringify(history)); }读取时直接在页面初始化时执行一次渲染。要注意 JSON 序列化和解析过程中的容错——localStorage 里的数据可能因为用户手滑改坏或者旧版本结构不兼容而解析失败所以解析要放在 try-catch 里解析失败就放弃这堆旧数据重新开始。4. 常见问题与排查技巧实录开发中踩了一些坑有些是逻辑问题有些是浏览器特性问题整理成速查表供参考。问题现象根因解决方案抽牌结果反复出现同一张随机范围不均或洗牌算法不当改用 Fisher-Yates 洗牌翻牌后文案还没渲染就消失动画期间状态被重置用locked锁状态localStorage 读不出记录JSON 格式被破坏try-catch 包裹解析牌背图片在部署后不显示路径是相对路径部署环境变更使用相对基路径/配置 publicPath手机端点击无反应移除了click事件兼容性使用pointerdown或保留click4.1 为什么牌越抽越像“每次都一样”这是随机应用常见的心理效应塔罗牌 78 张但大阿卡纳的牌在文案上给人印象更深抽到 5 张小阿卡纳可能完全没记忆抽到 2 次“愚者”就记住了。所以我为了防止用户觉得“算法有问题”在历史记录里增加了统计标签显示“最近 20 次中这张牌出现过几次”。用户看到统计后就会明白概率本身是这样不是应用在作弊情绪会平稳很多。这个设计建议所有做“随机结果展示”类应用的人都考虑一下在随机结果旁边直接展示一句“每次抽取相互独立”或者提供概率可视化从体验层面消除误会比用户质疑后解释要有效得多。4.2 翻牌动画与数据不同步的问题我的第一版代码在点击牌的瞬间就渲染了文案结果动画还没翻转完成文字已经在牌背下面透出来了非常出戏。后来把文案渲染放进setTimeout的 700ms 回调里也就是等翻转动画结束后再渲染。但这里也要注意setTimeout的延迟时间必须大于等于 CSS 的transition时长。如果 CSS 是transition: transform 0.6s那就至少等 600ms 再操作。用transitionend事件监听可以更精确但事件在一些极端情况下可能不触发比如标签页切到后台所以我最终用setTimeout保底给一个比动画时长略长的确定值。4.3 localStorage 数据被清空或被污染用户开了隐私模式、浏览器清理缓存、或者页面在 iframe 中被限制了存储都可能让 localStorage 读不到数据。这不是代码 bug但我需要让应用在这种环境下也能优雅降级——读不到就用空数组写入失败就静默跳过不阻塞抽牌功能本身。历史记录有最好没有也不影响核心流程。4.4 随机数生成不能重试浏览器里的Math.random()不支持“返回上一次的随机结果”所以像“重新洗牌后上次抽到的牌是什么”这种问题依赖随机数是永远无法回溯的。解决方案就是上面说的把每次抽取结果立刻写入 localStorage。这里补充一个实操建议记录里除了牌名还要把完整的时间戳存下来后续如果做“每日一张”“每周运势”这种功能直接按时间戳筛选即可不用额外迁移数据。写在最后塔罗牌应用本身是小众需求但做完这个项目我更看重它在前端基本功上的性价比数组洗牌、闭包绑定、CSS 3D 动画、本地存储四个前端核心知识点全部覆盖项目体量又控制在一个晚上能完成的范围。我实际使用中最大的感受是“仪式感”这个看不见摸不着的东西真的要靠在交互细节里一点点抠出来——洗牌按钮的微光、翻牌后的轻微回弹、文案出现的延迟时机每一样都在影响用户愿不愿意养成“每天抽一张”的习惯。如果你想在这个基础上继续玩可以扩展的方向也挺多加入韦特塔罗的完整牌面图片、增加“每日一张”的定时提醒、把结果页生成可分享的卡片图。核心代码都在剩下的就是你自己的想象力了。
返回列表