简介这是一份H5独立版英文塔罗牌占卜系统源码主要面向前端开发者与H5应用学习者系统同时包含前端展示与部分后端逻辑可用于研究英文界面下的交互流程、随机抽牌算法、占卜结果生成逻辑以及移动端适配思路具有一定完整度。压缩包共259个文件总大小约8.6MB以199个PHP文件为主配合HTML/TPL模板、JSON配置、SQL脚本以及ini、yml等环境配置PHP负责核心业务与数据处理模板决定页面呈现SQL可用于初始化数据结构整体目录结构清晰便于按模块定位和学习。目前已有269人学习下载资源虽标注“仅供学习”但提供完整前端资源结构、后端接口参考和部署配置样例适合想快速了解塔罗牌占卜类H5产品实现的读者作为练手项目并结合自身技术栈进行二次改造与功能扩展。1. 轻量级H5塔罗占卜系统一次随机抽牌背后的前端工作量某团队接了个海外演示需求一个英文版、独立部署、能在手机浏览器直接打开的塔罗牌占卜H5。需求方最开始觉得“这就是一个随机抽牌页”但真正落地才发现光把78张牌的数据结构、三种牌阵的抽牌位置、洗牌手感和3D翻牌动效做好就已经足够让前端团队忙上整整一周。这份源码的价值正在于此它把“洗牌—抽牌—翻牌—展示英文牌义”的完整主链路跑通了同时附带轻量级后端接口骨架。适合想研究移动端交互动效的人也适合想看清小工具型产品前后端边界的新手和熟手。整体定位是学习参考不是开箱即用的商业成品这一点在后文很多细节里都能感受到。2. 牌库与牌阵的数据结构78张牌在代码里怎么建模2.1 大阿卡纳与小阿卡纳英文牌义表为什么推荐常量配置塔罗牌的标准组成是78张22张大阿卡纳Major Arcana加56张小阿卡纳Minor Arcana。小阿卡纳又分成四组花色每组从Ace到十号牌外加四张宫廷牌Page、Knight、Queen、King。这套体系很固定所以用一组常量来描述牌库是完全够用的不需要后端数据库参与。const DECK_META { arcana: [ { type: major, count: 22 }, { type: minor, suits: [wands, cups, swords, pentacles], ranks: [ace, two, three, four, five, six, seven, eight, nine, ten, page, knight, queen, king] } ], suitLabels: { wands: Wands, cups: Cups, swords: Swords, pentacles: Pentacles } };逻辑说明上面这段元数据的作用是“生成牌库”不是直接写死78个对象。代码先定义花色和等级再通过两重循环去生成小阿卡纳避免手工维护78条记录时出现复制粘贴错误。这个设计我最喜欢的一点是——如果要加第四套牌或者自定义牌组只需要改这个元数据就可以渲染层根本不用动。参数说明count是大阿卡纳的固定数量suits和ranks决定小阿卡纳的遍历范围。实际生成时每张牌会拿到一个唯一编号id、正位牌义uprightMeaning、逆位牌义reversedMeaning以及一张牌面图片路径。英文文案在源码里以字符串常量形式存放不混在模板里。这样做的好处是后面想加德语、法语版本时只需要替换文本配置不需要动页面结构。2.2 三种牌阵抽牌逻辑单张、三张、凯尔特十字的差异牌阵决定了“抽几张牌、放在什么位置、每个位置代表什么含义”。最常用的是单张One Card、三张Past/Present/Future和凯尔特十字Celtic Cross十张。三者的差异不只是张数还有“是否区分位置语义”。const SPREADS { one-card: { positions: [ { key: now, label: Now, x: 50, y: 50, rotate: 0 } ] }, three-card: { positions: [ { key: past, label: The Past, x: 20, y: 50, rotate: -8 }, { key: present, label: The Present, x: 50, y: 50, rotate: 0 }, { key: future, label: The Future, x: 80, y: 50, rotate: 8 } ] } }; function buildPositions(spreadId) { return SPREADS[spreadId].positions.map(pos ({ ...pos, // 百分比坐标转像素坐标由渲染层负责 style: left: ${pos.x}%; top: ${pos.y}%; transform: rotate(${pos.rotate}deg); })); }逻辑说明把牌阵抽象成“一组带坐标和角度的位置”抽牌逻辑就变得很纯粹。无论是一个位置还是十个位置代码都只需要遍历positions数组从洗好的牌堆里按顺序取牌再赋值给对应key。这种数据驱动的方式让牌阵扩展成本极低。参数说明x和y用的是百分比这样在不同屏幕尺寸下都能保持相对位置。rotate是牌位的初始倾斜角度营造手排牌时“随手一放”的随意感。取值上我一般建议单张牌用0度三张牌用-8到8度凯尔特十字里交叉的牌用-18度这样视觉层次会清晰很多。凯尔特十字的十个位置分别是现状、挑战、过去、近未来、目标、近因、自我认知、环境、希望与恐惧、最终结果。中间两张牌要对齐交叉放置外围八张环绕排列。源码里给这十个位置写了一套完整的坐标系坐标值经过移动端适配在小屏幕上的间距还能维持可读性。2.3 洗牌算法Fisher-Yates与随机分布的坑现代浏览器上洗牌最简单的方式是arr.sort(() Math.random() - 0.5)但这条路会牺牲随机分布性。用 sort 洗牌的问题在于比较器不满足一致性要求最终的排列结果并不是等概率分布出现“某张牌总落在边缘”的偏斜是常态。塔罗牌讲究信任随机性至少代码层面要让分布均匀。function shuffleDeck(deck) { const result deck.slice(); for (let i result.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [result[i], result[j]] [result[j], result[i]]; } return result; } function drawCards(deck, spreadId) { const positions buildPositions(spreadId); const shuffled shuffleDeck(deck); return positions.map((pos, index) ({ positionKey: pos.key, positionLabel: pos.label, card: shuffled[index] })); }逻辑说明Fisher-Yates 洗牌从数组末尾开始每次从“还未处理的牌”里随机选一张换到当前位置。这个算法时间复杂度是 O(n)而且每一张牌出现在任意位置的概率一致。抽牌流程把它放在buildPositions之后执行先确定牌位再洗牌最后用下标顺序填充保证“每个牌位拿到的牌都来自同一轮洗牌”。参数说明deck是包含78张牌的完整数组spreadId是牌阵标识。函数返回的是带位置语义的抽牌结果前端拿到它就可以进入渲染流程。有一点需要注意抽完的牌要立即存起来因为后续翻牌动效需要知道“哪个牌位对应哪张牌”如果在这里丢了映射关系后面就得重新随机体验就崩了。3. 洗牌与翻牌的移动端交互手感、动效与触摸事件3.1 牌堆渲染DOM重复占位与牌背纹理塔罗牌H5的视觉核心在牌背。用户第一眼看到的是牌堆牌背纹理的质感直接决定整个产品是精致还是廉价。这份源码的牌堆用的是重复DOM方案78张卡片在洗牌开始前全部渲染成绝对定位的元素牌背朝上等待洗牌动画。div classcard-stack idcardStack !-- 由 JS 动态生成结构如下 -- div classcard>keyframes shuffle-move { 0% { transform: translate(0, 0) rotate(0deg); } 20% { transform: translate(8px, -12px) rotate(4deg); } 40% { transform: translate(-10px, 6px) rotate(-6deg); } 60% { transform: translate(12px, 10px) rotate(3deg); } 80% { transform: translate(-6px, -8px) rotate(-4deg); } 100% { transform: translate(0, 0) rotate(0deg); } } .card-stack .card { animation: shuffle-move 0.9s ease-in-out both; } .card-stack .card:nth-child(3n) { animation-delay: 0.1s; } .card-stack .card:nth-child(3n1) { animation-delay: 0.2s; }逻辑说明nth-child(3n)系列选择器把78张牌分成三组每组延迟差0.1秒动画整体在视觉上形成流动感。这里的关键是写动画时不要直接引用animation-name而是通过延迟和方向组合出差异。归拢的感觉来自最后一个关键帧所有牌的transform统一回到原点而且动画填充模式both确保结束后状态停留牌堆恢复整齐。参数说明延迟0.1到0.2秒是个人经验值。太短看不出层次太长会让用户觉得卡顿。低端机上如果掉帧可以把关键帧减少到三个0%、50%、100%减少重绘工作量。另外一个实用细节洗牌动画期间要给卡片容器加pointer-events: none防止用户在动画中途就点牌导致翻牌时牌的坐标还是动画前的值出现牌从错误位置旋转出来的情况。3.3 翻牌3D细节preserve-3d与backface-visibility翻牌是塔罗牌H5的“交付时刻”。这张动画如果做得拖沓用户会觉得系统反应慢做得太生硬又少了仪式感。源码里用的是一个非常标准的3D翻牌结构核心是父元素开启preserve-3d、正反面元素关闭背面渲染。.card { perspective: 800px; } .card-inner { position: relative; width: 100%; height: 100%; transform-style: preserve-3d; transition: transform 0.6s cubic-bezier(0.4, 0.2, 0.2, 1); } .card-inner.flipped { transform: rotateY(180deg); } .card-face { position: absolute; width: 100%; height: 100%; backface-visibility: hidden; -webkit-backface-visibility: hidden; } .card-front { transform: rotateY(180deg); }逻辑说明.card-front先旋转180度藏到背面当父元素.card-inner也旋转180度时前表面正好转到正面视觉上就是“这张牌翻了过来”。perspective: 800px写在父级.card上值越小透视畸变越强800px 在手机屏幕上的立体感比较舒适。cubic-bezier(0.4, 0.2, 0.2, 1)是一个“先慢后快再缓收”的曲线模拟人手翻牌时手腕发力、末段减速的节奏比默认的ease更有手感。参数说明backface-visibility: hidden必须同时写带-webkit-前缀的版本否则iOS Safari上翻牌过程中会出现背面文字透视穿透的怪影。transform-style: preserve-3d有一个很隐蔽的坑一旦父元素加overflow: hidden这个属性就会被浏览器强制降级为flat3D效果瞬间失效。这份源码在牌堆和卡片容器上都避开了overflow改用的clip-path或直接铺满全屏来裁剪视觉边界。3.4 触摸点选300ms延迟与快速连点的状态锁移动端H5最容易踩的一个老坑是click事件的300毫秒延迟。现代浏览器在设置了viewport后基本消除了这个延迟但如果源码里的页面没有正确配置用户点一张牌会明显感觉“顿了半拍”。塔罗牌这个场景对点击手感极其敏感所以直接用pointerdown替代click。card.addEventListener(pointerdown, function (e) { if (turnLock.active) return; turnLock.active true; const cardId this.dataset.cardId; const flipResult flipCard(this, cardId); flipResult.onfinish function () { showCardMeaning(cardId); turnLock.active false; }; }); function flipCard(cardEl, cardId) { cardEl.querySelector(.card-inner).classList.add(flipped); return cardEl.querySelector(.card-inner).getAnimations()[0] || { onfinish: null }; }逻辑说明pointerdown在手指接触屏幕的瞬间触发省去click的延迟和判定过程。但pointerdown的问题是触发时机太早用户手指可能只是误触所以要用turnLock做状态锁翻牌动画结束前其余所有点击直接忽略。这样既避免了连点导致两张牌同时翻开也避免动画未完成时状态错乱。参数说明turnLock.active是一个模块级布尔变量。注意锚点语义不能直接放在全局window上否则一个页面多个牌阵实例时会互相干扰。flipCard里通过getAnimations()拿动画结束回调比监听transitionend更可靠因为transitionend在快速切换样式时偶尔会被浏览器吞掉。4. 前后端源码的组织方式Express接口与一次占卜的数据流4.1 技术栈选型前后端同一语言的好处“部分前后端源码”这几个字说明了一个现实前端主链路是完整的后端是骨架级的。源码里的后端选择了Node.js加Express和前端共用JavaScript好处是开发者不需要维护两套心智模型。Express在这个场景的核心价值不是性能而是“几行代码就能把静态资源和API路由都服务起来”。server/ app.js routes/ deck.js reading.js data/ cards.js package.json public/ index.html css/ style.css js/ main.js tarot.js assets/ cards/ major/ wands/ cups/ swords/ pentacles/目录结构说明server管数据源和记录存储public管页面与交互。cards.js是完整的78张牌数据文件作为数据源被两个地方引用前端启动时会拉取一份副本之后所有抽牌逻辑都在客户端完成。后端不再重复提供随机接口只提供“牌库拉取”和“占卜记录保存”两个能力。参数说明这个结构对学习型项目来说正好。如果你一开始就把所有抽牌逻辑放到后端API里每个动效阶段的牌状态都要请求服务器网络抖动会让体验变得不可用。实践证明抽牌结果在客户端算后端只做数据分发和结果存档是这类工具型产品最合理的边界。4.2 接口设计GET /api/cards 与 POST /api/reading后端接口的数量被刻意控制在两个这是“部分源码”的另一个体现——接口骨架演示了数据的输入输出但没有做权限、限流、多语言服务等生产级能力。const express require(express); const router express.Router(); const deckData require(../data/cards); // 返回完整牌库前端启动时拉取一次 router.get(/cards, (req, res) { res.json({ code: 0, data: deckData, ts: Date.now() }); }); // 保存一次占卜结果返回记录 id router.post(/reading, (req, res) { const { spreadId, result, deviceId } req.body; if (!spreadId || !Array.isArray(result) || result.length 0) { return res.status(400).json({ code: 1, message: invalid params }); } const recordId saveReading({ spreadId, result, deviceId, createdAt: Date.now() }); res.json({ code: 0, data: { recordId } }); });逻辑说明GET /api/cards目的是让前端拿到一份带最新修正的牌库数据避免每次发布都要重新打包前端文件。POST /api/reading是一次完整的业务闭环前端把牌阵类型、抽牌结果、设备标识传到后端后端存下来并返回记录编号。这里的saveReading在源码里是写入一个本地JSON文件方便演示数据流。参数说明spreadId对应前端SPREADS里的键值result是数组元素结构为{ positionKey, cardId, reversed }deviceId是前端在用户首次访问时生成的随机字符串用来区分同一设备的历史记录。请求体里面result必须做空数组校验否则一次异常的前端调用会把脏数据写进记录文件排查起来会让人头疼。4.3 刷新恢复localStorage版本化存储用户最反感的一件事占卜到一半退出浏览器回来发现结果没了。塔罗牌H5属于典型的一次性工具产品用户愿意重来第二次的比例很低所以记录“最近一次占卜”是必须做的。源码用的是 localStorage而不是后端记录原因是刷新恢复要的是即时读取不依赖网络。const STORAGE_KEY tarot_last_reading_v1; function persistLastReading(spreadId, result) { const payload { version: 1, ts: Date.now(), spreadId, result }; localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); } function restoreLastReading() { try { const raw localStorage.getItem(STORAGE_KEY); if (!raw) return null; const parsed JSON.parse(raw); if (parsed.version ! 1 || !Array.isArray(parsed.result)) { localStorage.removeItem(STORAGE_KEY); return null; } return parsed; } catch (err) { localStorage.removeItem(STORAGE_KEY); return null; } }逻辑说明STORAGE_KEY里带上了_v1版本后缀。这是为后期数据结构升级留的后悔药一旦result数组里的对象结构发生变化旧数据解析后理论上还能兼容但字段缺失会让前端渲染出错。所以恢复数据时要校验version和result的数组类型不合法就直接清掉不污染新一轮占卜。参数说明ts时间戳在恢复后可以用于判断“这份结果是不是一个月前的旧数据”。如果想做得更细可以把一天前的记录视为失效恢复时提示用户“重新开始”。源码里没有做这个过期逻辑但我在实际复现时加了30分钟失效策略让每次打开都更像一次完整的仪式。5. 避坑与排查H5塔罗牌最容易翻车的五个地方5.1 iPhone上翻牌出现黑屏闪烁现象用iPhone打开页面点击翻牌后牌面先闪一下黑块然后才显示正常图片有时干脆一直黑屏。原因是iOS Safari对backface-visibility和transform-style: preserve-3d的渲染存在层级问题当卡片图片是一个跨域或未缓存的img时GPU合成会短暂失败。解决办法是给牌面图片设置固定尺寸和背景色同时把翻牌动画触发前的图片预加载一遍确保翻转时图片已经在内存里。按照这个思路处理之后闪烁问题基本消失。5.2 长英文单词撑破卡片布局现象牌义文本如果写的是 “Unconsciousness” 或 “Transformation” 这类长单词在窄屏幕小卡片上会横向溢出文字顶到卡片边缘甚至把整张卡片撑宽。原因是英文单词默认不换行当单词长度超过容器宽度就会溢出。解决方案是把.card-front的文字容器加上overflow-wrap: break-word和hyphens: auto同时给卡片一个min-width: 0的约束防止 flex 子项自动撑开。.card-front .card-meaning { overflow-wrap: break-word; word-break: break-word; hyphens: auto; line-height: 1.5; padding: 8px 10px; }逻辑说明overflow-wrap: break-word允许长单词在到达容器边界时强行折行hyphens: auto会让浏览器根据英语连字符规则自动加连字符排版更接近书籍效果。桌面端 Chrome 上不加这两个属性也能看但在iOS Safari上表现截然不同移动端适配时一定要显式声明。参数说明line-height和padding数值需要根据卡片尺寸微调。塔罗牌占卜结果界面的卡片通常是 280px 宽、420px 高的竖卡牌义文本最多显示三行即可收起再长就用line-clamp截断不要试图在卡片里展示全部解释。5.3 快速连点导致多张牌同时翻转现象用户手速较快时连续点击两张牌结果两张牌同时翻开且其中一张的位置状态错乱。原因是点击事件没有加锁每一次点击都会创建一个动画任务浏览器并发处理导致状态互相覆盖。解决方式是用一个简单的模块级状态锁turnLock.active在动画结束后再释放。这里有一个微妙的点释放锁的动作不是监听click而是监听动画结束否则锁会在动画播放中提前解开等于没加。let turnLock { active: false }; function handlePick(e) { if (turnLock.active) return; turnLock.active true; const cardEl e.currentTarget; const inner cardEl.querySelector(.card-inner); inner.classList.add(flipped); inner.addEventListener(transitionend, function handler() { inner.removeEventListener(transitionend, handler); turnLock.active false; }, { once: true }); }逻辑说明{ once: true }确保回调执行一次后自动移除监听省去手动removeEventListener的冗余代码。注意这里用的是transitionend和前面代码里的getAnimations().onfinish是两条不同路线实际项目中任选其一即可不要两套混用否则同一个动画完成会触发两次解锁锁就失去作用了。参数说明只开一个锁不区分“正在翻开的牌”用户仍然可以翻牌只是要等上一张动画完全结束。如果想做得更顺手可以把锁改成队列但塔罗牌占卜流程本身是逐张翻开的节奏锁一次一张反而是最贴合交互的体验。5.4 洗牌动画低端机掉帧卡顿现象在千元级安卓机上打开页面洗牌动画一启动整个页面掉到十几帧牌堆像幻灯片一样跳动。原因是78张卡片同时执行CSS位移动画每一帧都要进行大量重绘再加上3D变换打开时GPU显存占用过高。解决思路是分两层处理先给动画中的卡片加上will-change: transform和translateZ(0)提示浏览器走合成层再在动画结束后去掉这些属性避免长期占用GPU资源。.card-stack.card-animating .card { will-change: transform; transform: translateZ(0); animation: shuffle-move 0.9s ease-in-out both; }逻辑说明will-change是提前通知浏览器该元素即将变化让浏览器预先分配合成层。这个提示用的时候要克制它只该出现在动画进行中动画结束就该移除否则每个牌位都占一层合成层内存反而更吃紧。源码里在洗牌动画类名添加时同时加card-animating动画结束后用监听移除就是为这个目的。参数说明如果做了上述优化仍然卡顿说明问题在图片解码。78张牌背如果是同一张PNG纹理只需要一次解码这是最理想的情况。如果每张牌背都换成不同纹理解码成本会直接乘以78低端机必然扛不住。确认牌背纹理是否复用了同一张图片是排查性能问题时要放在前面的步骤。5.5 localStorage数据结构升级后解析失败现象改版后result数组的元素从原来两个字段增加到三个字段旧版本用户刷新页面时报错白屏控制台提示Cannot read properties of undefined。原因是旧数据里没有新字段恢复逻辑运行时读不到数据导致渲染崩溃。解决方式就是前面提到的版本化存储。每次结构变更就把STORAGE_KEY里的_v1改成_v2解析时校验版本号不匹配直接清空并不做恢复。function restoreLastReading() { const CURRENT_VERSION 2; const raw localStorage.getItem(tarot_last_reading_v CURRENT_VERSION); if (!raw) return null; try { const parsed JSON.parse(raw); if (parsed.version ! CURRENT_VERSION) return null; return parsed; } catch (e) { return null; } }逻辑说明restoreLastReading只认当前版本的键名旧版本键值留在localStorage里没人读取也无所谓。这种做法牺牲了跨版本迁移能力换取的是绝对的解析安全性。对于一个学习型项目这个取舍是合理的你不需要为了兼容旧数据写一套映射逻辑。参数说明version字段和键名后缀要保持一致两者是同一份数据的身份证。实际操作中如果迭代里新增了一个字段且旧字段含义不变可以尝试写一个最多两层结构的迁移函数但如果字段含义变了或结构嵌套变深直接换版本号反而省心。6. 进阶把固定牌阵改成配置驱动的自定义占卜前面几个固定牌阵的坐标都是写死在SPREADS里的新增一个牌阵等于改配置、加坐标还要确保渲染层的循环逻辑覆盖所有位置。这一步可以再往前走深一点把牌阵定义抽成一份独立的 JSON 配置让前端通过读配置来动态生成牌位完全不改渲染层的代码。const CUSTOM_SPREAD [ { key: foundation, label: The Root, x: 30, y: 35, rotate: -6, layer: 1 }, { key: obstacle, label: The Block, x: 50, y: 50, rotate: 0, layer: 2 }, { key: outcome, label: The Peak, x: 70, y: 65, rotate: 6, layer: 1 }, { key: advice, label: The Support, x: 50, y: 80, rotate: 0, layer: 3 } ];逻辑说明每个牌位对象增加一个layer字段渲染时按layer从低到高依次堆叠。这样设计的价值很直接牌位之间有重叠时layer高的牌会浮在低层牌上面点击命中区域不会被遮挡。以前写固定牌阵时这个层级关系是隐含在数组顺序里的但现在配置化之后显式的layer字段反而更不容易出错。渲染逻辑可以保持positions.map(...)不变只需要在生成DOM时按layer做一次排序。参数说明x、y控制位置rotate控制牌位倾斜角layer控制叠加顺序这三个字段已经足够覆盖绝大部分自定义牌阵的视觉需求。抽牌逻辑也不需要改drawCards(deck, spreadConfig)完全可以接受一个自定义数组。我实际复现的时候把原本写死的凯尔特十字也换成了这个格式最终抽牌和翻牌过程完全一致。从那以后我每次给这类工具型H5设计牌阵都会把配置和渲染逻辑分开。前端只负责“读配置、发牌、翻转、展示”所有的牌位语义都躺在配置里。这样无论是加一张“今日指引”还是做一个十张的大型占卜都只是数据和坐标的事。这份源码帮我打通的不只是塔罗牌的流程更是“数据驱动交互”的一个具体切片。好的组装逻辑之后换牌阵就像换一张菜单表渲染层基本不需要改动。希望这份拆解帮你在复现时少走几步弯路。本文还有配套的精品资源点击获取