纯HTML单文件实现玛雅十字帕托利 —— 用52格闭环与5颗豆子还原古玛雅双人棋
手写一个玛雅十字帕托利:680 行纯前端代码,从十字棋盘到 5 颗豆子的胜负手
下载地址:https://download.csdn.net/download/qq616491978/93137182
文章目录
- 手写一个玛雅十字帕托利:680 行纯前端代码,从十字棋盘到 5 颗豆子的胜负手
- 一、我为什么要写这个
- 二、先把规则理清楚
- 2.1 棋盘长什么样
- 2.2 核心要素
- 2.3 特殊机制的作用
- 三、代码怎么组织的
- 四、数据结构怎么设计
- 4.1 棋盘数组
- 4.2 坐标表与常量
- 4.3 坐标换算常量
- 五、CSS / UI 怎么渲染
- 5.1 用网格留白画十字
- 5.2 视觉层级
- 5.3 关键视觉元素
- 5.4 移动端适配
- 六、核心逻辑——走法引擎
- 6.1 先定好状态变量
- 6.2 豆子换算步数
- 6.3 getLegalMoves:一个函数装下经典规则
- 6.4 吃子与安全格落子
- 6.5 坐标换算怎么串起整盘
- 七、渲染和交互怎么配合
- 7.1 全量重绘
- 7.2 点击流程
- 7.3 交互细节
- 八、怎么判胜负
- 8.1 走子收尾触发胜利
- 8.2 胜利遮罩
- 九、规则修正与防呆处理
- 十、几个值得记住的点
- 十一、还可以做什么
- 十二、关于这篇文章
- 适用边界与已知局限
一、我为什么要写这个
前阵子翻到古玛雅的帕托利(Patolli),规则挺有意思:一块十字棋盘,双方各掷豆子决定走几步,谁先把六枚棋子绕完一圈谁赢。可网上能直接玩的单机版少得可怜,要么要联网,要么塞了一堆看不懂的依赖。开发的棋牌游戏只是用来娱乐与开发学习,请勿用于非法用途。
我就想,这不正好是个练手的纯前端小项目吗?一个 HTML 文件就能装下全部逻辑,不联网也能跑,发给朋友双击就能对弈。于是花了一个周末把它写出来,过程中最绕的不是规则,而是怎么把那个「十字」画对、以及怎么把吃子、安全格、精确终点这一堆例外收敛进一个函数里。
写完第一版我自己跑测试,结果核对原始玩法时发现:我最初把「己方可叠子」「2 枚敌子封锁格子」写进去了,那其实是帕奇西(印度十字棋)的路子。经典帕托利里己方不能叠子、落点有对方子直接吃、根本不存在格子封锁。于是我把核心函数推倒重写成现在的样子——这部分在第九章讲。
运行环境:纯原生 HTML + CSS Grid + JavaScript,零依赖、零框架、零 CDN,浏览器直接打开【单机离线版】玛雅十字帕托利.html即可运行;逻辑验证需要 Node.js + Playwright(本实现长期适用,不依赖任何会过期的库版本)。
二、先把规则理清楚
动手前得把帕托利的骨架吃透。它和西洋跳棋不同,路径不是方形的,而是一个十字闭环。
2.1 棋盘长什么样
我把 52 个格子按顺时针手描出来,落在 15×15 的网格上,让「厚 3 格的加号」外轮廓正好串成一圈:
顺时针方向:从玩家1 起点(索引 0,顶臂左上角)出发,沿顶臂右沿下到右侧横臂、绕到底臂、再沿左臂回到起点附近。玩家2 起点在索引 26(底臂正下角),正好差半圈,两人终点分别是索引 51(标「终①」)和索引 25(标「终②」)。
2.2 核心要素
| 要素 | 说明 |
|---|---|
| 玩家 | 玩家1 玉石绿(起点 0),玩家2 黑曜石黑(起点 26) |
| 棋子 | 每方 6 枚,初始全在「待入场」区,绕完一圈移出即完成 |
| 豆子 | 5 颗,掷出后数圆点朝上的数量决定步数 |
| 安全格 | 8 个(金色圆点),不可被吃、对方也能共存 |
| 终点 | 必须正好走满 52 步越过,多走一步该子本回合不能动 |
2.3 特殊机制的作用
安全格是这盘棋的拉锯点。普通格上只要落点有对方棋子(且非安全格)就会被吃,对方退回待入场;安全格上两色可以共存、谁也吃不了谁。于是进攻方常常卡在对方棋子前进退两难,只能等下一次点数绕开。
5 颗豆子全圆点时会走 5 步并额外再掷一次,这一条让节奏忽然变快,是翻盘的关键。经典帕托利里没有任何「格子封锁」机制——你能落的地方就能落,吃子只发生在非安全格的单点对抗上。
三、代码怎么组织的
整个游戏就一个 HTML 文件,分成三块:
玛雅十字帕托利.html ├── <style> → CSS,约 196 行,管十字棋盘与棋子视觉 ├── <body> HTML → 骨架,约 55 行,两侧信息面板 + 中央棋盘 + 掷骰区 └── <script> → 逻辑,约 426 行,这是重点 ├── 常量(COORDS / SAFE / N) ├── 全局状态(board / currentPlayer / diceValue …) ├── 坐标换算(startOf / idxToProgress / progressToIdx) ├── rollDice → 豆子换算步数 ├── getLegalMoves → 走法合法性(不可叠子/吃子/精确终点) ├── movePiece / doMove → 执行与收尾 ├── render → 全量重绘 └── 点击交互(onCellClick / onHomeChip / onFinishChip)最核心的一条思路:棋盘状态只存一个board数组,每回合render()一把全量重绘,逻辑层和视图层不纠缠。
四、数据结构怎么设计
4.1 棋盘数组
letboard;// 长度 52 的数组,board[i] = { p1:数量, p2:数量 }letp1Home,p1Score;// 玩家1:待入场数 / 已完成数letp2Home,p2Score;letcurrentPlayer;// 1 或 2letdiceValue;// 当前骰子换算出的步数(null 表示本回合尚未掷骰)每格只记两色各有多少枚棋子,而不是给每枚棋子单独建对象。经典规则下同格里同色最多 1 枚,所以p1/p2的值通常是 0 或 1;吃子、移出都只是对这两个数做加减,简单到不用想。渲染层仍保留> 1时显示数字的兼容写法,只是正常对局不会再触发。
4.2 坐标表与常量
constCOORDS=[[0,6],[0,7],[0,8],// 0,1,2 顶臂上沿[1,8],[2,8],[3,8],[4,8],[5,8],// 3-7 顶臂右沿[6,9],[6,10],[6,11],[6,12],[6,13],[6,14],// 8-13 右臂上沿[7,14],[8,14],// 14,15 右臂右沿[8,13],[8,12],[8,11],[8,10],[8,9],// 16-20 右臂下沿[9,8],[10,8],[11,8],[12,8],[13,8],[14,8],// 21-26 底臂右沿(26=玩家2起点)[14,7],[14,6],// 27,28 底臂下沿[13,6],[12,6],[11,6],[10,6],[9,6],// 29-33 底臂左沿[8,5],[8,4],[8,3],[8,2],[8,1],[8,0],// 34-39 左臂下沿[7,0],[6,0],// 40,41 左臂左沿[6,1],[6,2],[6,3],[6,4],[6,5],// 42-46 左臂上沿[5,6],[4,6],[3,6],[2,6],[1,6]// 47-51 顶臂左沿(回到起点 0)];constN=52;constSAFE=newSet([1,14,27,40,8,21,34,47]);COORDS是我照着 15×15 网格一个一个格子描出来的顺时针序列。它把「棋盘长相」和「逻辑索引」绑死:逻辑只认 0…51 的顺序,渲染时再查COORDS[i]拿到[行,列]交给 CSS Grid。安全格取「四臂端点」(1,14,27,40) 加「紧邻中心四格」(8,21,34,47),共 8 格。
4.3 坐标换算常量
functionstartOf(p){returnp===1?0:26;}functionidxToProgress(p,idx){consts=startOf(p);return(idx-s+N)%N;// 归一化到 0..51}functionprogressToIdx(p,prog){consts=startOf(p);return(s+prog)%N;// 把进度还原成棋盘索引}能提前算好的就不在运行时反复推导。玩家起点差半圈,所以「自起点走了几步」用取模一归一化就通用了。
五、CSS / UI 怎么渲染
5.1 用网格留白画十字
.board{width:100%;height:100%;display:grid;grid-template-columns:repeat(15,1fr);grid-template-rows:repeat(15,1fr);gap:2px;}.cell{position:relative;display:flex;align-items:center;justify-content:center;}关键不在「画路径」,而在「只给 52 个路径格生成可见的圆槽,其余 15×15 网格位置留空」。CSS Grid 把 225 个格子位排好,我只在COORDS对应的 52 个位置放.cell,其余位置根本没元素——十字形状就自己浮现出来了,不用画一根线。
5.2 视觉层级
.cell.target .slot:合法落点,绿色脉动光晕.cell.sel .slot:已选中的来源,金色描边.cell.safe .slot:安全格,金色圆点角标.piece.p1/.piece.p2:玉石绿与黑曜石黑两种棋子
5.3 关键视觉元素
.piece.p1{background:radial-gradient(circle at 32% 30%,#6ee7b7,var(--p1)70%);}.piece.p2{background:radial-gradient(circle at 32% 30%,#475569,var(--p2)80%);color:var(--p2-edge);border:1px solidvar(--p2-edge);}棋子不用贴图,一个径向渐变加高光就出陶瓷质感。同格多子时margin-left:-30%让它们轻微重叠,一眼能数出几枚。
5.4 移动端适配
@media(max-width:760px){.panel{width:46%;flex:1 1 140px;}.game{gap:10px;}}两侧信息面板在窄屏改成各占 46%,棋盘用min(92vw,540px)自适应,手机上不会挤。
六、核心逻辑——走法引擎
这部分是全篇最花功夫的,也是后来被我推翻重写的地方。
6.1 先定好状态变量
letdiceValue;// 当前步数letbeanDots;// 5 颗豆子的圆点布尔数组letbusy;// 防呆:动画/计算中禁用点击letfinished;// 是否分出胜负letselectedSource;// 当前选中的来源:'home' 或 棋盘索引;null=未选letextraTurn;// 是否处于「全圆点」带来的额外回合letmoves;// source -> [targetIndex | 'done', ...]letlegalSourceSet;// 所有可作为来源的位置集合letlegalTargetSet;// 所有合法落点集合(用于初始提示光晕)6.2 豆子换算步数
beans.forEach(b=>{constd=Math.random()<0.5;// 圆点朝上概率约 50%dots.push(d);if(d)cnt++;b.classList.remove('rolling');b.classList.toggle('dot',d);});beanDots=dots;if(cnt===5){diceValue=5;extraTurn=true;}else{diceValue=(cnt===0?10:cnt);extraTurn=false;}5 颗豆子各自抛一次,圆点朝上记 1。全平(0 点)走 10 步,全圆点(5 点)走 5 步并标记额外回合,中间几颗就是对应步数。换算完立刻computeMoves()算出本回合所有合法走法。
6.3 getLegalMoves:一个函数装下经典规则
functiongetLegalMoves(from){constp=currentPlayer;constopp=p===1?2:1;constownCount=(idx)=>board[idx]['p'+p];// 目标格己方棋子数constoppCount=(idx)=>board[idx]['p'+opp];// 目标格对方棋子数// —— 情况 A:从待入场区放一枚新子到起点 ——if(from==='home'){consts=startOf(p);if(ownCount(s)>=1)return[];// 经典规则:己方不可叠子,起点已有己方棋子则无法入场return[s];}// —— 情况 B:移动场上已有棋子 ——consti=from;constprog=idxToProgress(p,i);// 当前已走步数consttargetProg=prog+diceValue;// 前进后的步数if(targetProg>N)return[];// 必须正好越过终点,多走则非法if(targetProg===N)return['done'];// 正好走满 → 移出棋盘完成constj=progressToIdx(p,targetProg);// 落点棋盘索引// 经典帕托利:己方棋子不可叠放,不能落在己方已占的格(安全格同理)if(ownCount(j)>=1)return[];// 对方棋子(无论几枚)不形成「封锁」:非安全格落在对方子上即吃子;安全格双方共存。均合法。return[j];}这个函数把规则收得极干净:入场先看起点有没有己方棋子(有就不可叠子入场);移动先看是否越界(精确终点);再判落点是否已有己方棋子;安全格用共存豁免,攻防平衡靠「己方不叠子 + 落点吃子」两条铁律,而不是什么封锁。
6.4 吃子与安全格落子
functionmovePiece(from,to){constp=currentPlayer;constopp=p===1?2:1;if(from==='home'){// 从待入场区放子到起点(经典:己方不叠子,直接置 1)consts=startOf(p);if(board[s]['p'+opp]>=1&&!SAFE.has(s)){// 起点有对方棋子(非安全格)→ 入场即吃子board[s]['p'+opp]=0;if(opp===1)p1Home++;elsep2Home++;}board[s]['p'+p]=1;if(p===1)p1Home--;elsep2Home--;return;}consti=from;board[i]['p'+p]=0;// 离开原格(原必为 1)if(to==='done'){// 越过终点,移出棋盘if(p===1)p1Score++;elsep2Score++;return;}constj=to;// —— 经典吃子:落点有对方棋子且非安全格 → 吃子,对方退回待入场区 ——if(board[j]['p'+opp]>=1&&!SAFE.has(j)){board[j]['p'+opp]=0;if(opp===1)p1Home++;elsep2Home++;}// 落入目标格;安全格上可与对方共存(不被吃),非安全格则上方已清掉对方board[j]['p'+p]=1;}吃子判定只认「落点有对方棋子且非安全格」。安全格上即便有对方棋子也照常落入、双方共存——这一处和 6.3 的共存豁免呼应,是安全格机制的两个锚点。己方不叠子则由board[j]['p'+p] = 1强制保证,离开原格时置 0,整盘不会出现同格同色两枚。
6.5 坐标换算怎么串起整盘
玩家点一个棋子,逻辑拿它的索引i,idxToProgress算出已走步数;加上diceValue得到目标步数;progressToIdx再把它变回棋盘索引。整盘移动、入场、终点判定都复用这套换算,不写第二套坐标逻辑。
七、渲染和交互怎么配合
7.1 全量重绘
for(leti=0;i<N;i++){constcell=cellEls[i];cell.classList.remove('target','source','sel');constc=board[i];constslot=cell.querySelector('.slot');lethtml='';if(c.p1>0)html+=`<div class="piece p1">${c.p1>1?c.p1:''}</div>`;if(c.p2>0)html+=`<div class="piece p2">${c.p2>1?c.p2:''}</div>`;slot.innerHTML=html;}每回合把 52 个槽位清空重画。状态简单时这比增量更新省心——不会出现「点击没反应、高亮卡住」这类脏状态。52 格全量重绘在浏览器里感觉不到延迟。
7.2 点击流程
掷豆(rollDice) └─> computeMoves() 算出 legalSourceSet / legalTargetSet 选择来源(onCellClick / onHomeChip) └─> selectedSource = 该格 选择落点(onCellClick / onFinishChip) └─> doMove(from, to) → movePiece → endOfRoll → switchTurn7.3 交互细节
functiononHomeChip(p){if(busy||finished||diceValue===null)return;if(p!==currentPlayer)return;// 仅当前玩家的入场 chip 可响应if(!legalSourceSet.has('home'))return;selectedSource=(selectedSource==='home')?null:'home';render();}入场 chip 和完成 chip 都带玩家编号,点击时先校验p !== currentPlayer直接忽略——这是后面测试抓出来的防呆点。
八、怎么判胜负
8.1 走子收尾触发胜利
functiondoMove(from,to){movePiece(from,to);selectedSource=null;diceValue=null;beanDots=null;if(p1Score>=6||p2Score>=6){finished=true;render();showWin();return;}endOfRoll();}每枚棋子移出棋盘,p1Score/p2Score加一;任一方到 6,立刻finished = true并弹遮罩,不再切换回合。
8.2 胜利遮罩
functionshowWin(){constw=p1Score>=6?1:2;document.getElementById('winText').textContent=`玩家${w}(${w===1?'玉石绿':'黑曜石黑'})获胜!`;document.getElementById('overlay').classList.add('show');}九、规则修正与防呆处理
这一段是写测试逼出来的,也包含了把第一版改成经典帕托利的过程。
第一,最初的getLegalMoves我写的是「己方可叠子、2 枚以上敌子封锁格子」。核对原始玩法后才意识到那是帕奇西的路子。经典帕托利没有格子封锁,于是改成现在这样:己方不可叠子、落点对方子直接吃、无封锁。改动只在三四行,但语义差别很大。重写后computeMoves会把起点已有己方棋子的入场判为非法,把「落点己方已占」判为非法,其余一律可落。
第二,onHomeChip/onFinishChip原来没校验点击者是不是当前玩家。于是玩家2 的回合,点玩家1 的入场 chip 也能放子,回合乱套。加上if (p !== currentPlayer) return;才修好。
第三,rollDice里这层守卫很关键:
functionrollDice(){if(busy||finished||diceValue!==null)return;// 防呆:必须先用完当前点数...}diceValue !== null保证「上一步没走完就不能再掷」,否则步数会被覆盖、合法走法错乱。
写完用 Playwright 接真实浏览器跑回归,覆盖核心规则、精确终点、安全格共存、吃子、己方不可叠子、入场即吃等场景,最终32 项断言全部通过(输出ALL TESTS PASS)。验证命令:
nodetest/verify.js# 预期:==== 验证结果:通过 32,失败 0 === / ALL TESTS PASS十、几个值得记住的点
- 棋盘形状靠「网格留白」而不是「画线」,状态少、视觉自然。
- 把不可叠子/吃子/安全格收进
getLegalMoves一个函数,规则改动只动一处。 - 坐标换算用取模归一化,两个玩家共用一套逻辑。
- 全量重绘比增量更新省心,小棋盘根本感觉不到开销。
- 防呆不是可选项,交互入口一定要校验当前玩家和回合状态。
十一、还可以做什么
当前版本已经能双人对弈、吃子、安全格共存、精确终点、额外回合。想更有意思有几个方向:
加一个简单 AI。把legalSourceSet和moves直接喂给一个评分函数,优先吃子、抢安全格、逼近终点,不用改渲染层。
记走子历史。每步把board快照压栈,加「悔棋」按钮,误操作能退。
音效与动画增强。豆子翻滚、吃子闪红、胜利撒花,纯 CSS/WebAudio 都能做,不引外部资源。
改棋盘主题。把COORDS和SAFE抽成配置,就能快速换别的十字类棋型。
十二、关于这篇文章
完整的 HTML 源码随文附上,保存成.html文件双击就能在浏览器里跑。代码总共约 680 行,函数和关键逻辑都加了中文注释。
下载地址:https://download.csdn.net/download/qq616491978/93137182
适用边界与已知局限
- 本实现仅覆盖代码里写出来那套规则子集:52 格闭环、5 豆步数映射、8 安全格、精确终点、己方不可叠子、无封锁、落点吃子。更繁复的历史变体(如不同安全格布局、赌注规则)未实现。
- 纯前端单文件,状态全部在内存;刷新页面即重置,没有存档。
- 仅双人对战,尚无 AI;棋盘用 CSS Grid 绘制,浏览器需支持 Grid(现代浏览器均支持)。
- 测试基于 Node.js + Playwright 驱动真实页面,逻辑已回归验证;但随机掷豆带来的极端运气局不在自动断言范围内。