手写一个塞尼特:600 行纯前端代码,从牛耕式坐标到"结对保护"全流程解析
下载链接:https://download.csdn.net/download/qq616491978/93147293
文章目录
- 手写一个塞尼特:600 行纯前端代码,从牛耕式坐标到"结对保护"全流程解析
- 一、我为什么要写这个
- 二、先把规则理清楚
- 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 掷具:4 根掷棒
- 6.3 结对判定 isProtected
- 6.4 合法落点 getValidMoves
- 6.5 不能跳过敌方结对——这是修过的 bug
- 6.6 执行移动与特殊格 movePiece
- 七、渲染和交互怎么配合
- 7.1 渲染策略:全量重绘
- 7.2 点击事件的处理逻辑
- 7.3 交互细节:高亮谁、什么时候清
- 八、怎么判胜负
- 8.1 先让 5 子全部出局者胜
- 8.2 精确到达终点
- 九、运行环境与逻辑验证
- 十、键盘快捷键
- 十一、几个值得记住的点
- 十二、还可以做什么
- 十三、适用边界 / 已知局限
- 十四、关于这篇文章
一、我为什么要写这个
上个月整理硬盘,翻到一本讲古埃及游戏的旧资料,里面提到塞尼特(Senet)——三千多年前的双人对弈棋。规则不复杂,但有一个我现在都觉得别致的设定:棋子不是吃掉了事,而是"位置互换",被吃的那枚退回你原来的格子。
我手痒想做个能在浏览器里直接玩、断网也能跑的版本。要求是单文件 HTML,不引任何框架、不挂任何 CDN。写出来以后最麻烦的不是渲染,而是棋盘坐标和那条"结对保护"的规则——后者我修过一次 bug,文章里会专门讲。
整份代码约 600 行,纯原生 HTML + CSS + JavaScript,不依赖任何第三方库或框架,双击就能在浏览器里打开运行,原理长期适用。
二、先把规则理清楚
动手之前得把规则吃透,不然代码写着写着就偏了。
2.1 棋盘与路径长什么样
3 行 × 10 列,共 30 格。路径按"牛耕式转行"走:第一行从左到右编号 1–10,第二行从右到左编号 11–20,第三行 再从左到右编号 21–30。
行1 (上) 1 2 3 4 5 6 7 8 9 10 行2 (中) 20 19 18 17 16 15 14 13 12 11 行3 (下) 21 22 23 24 25 26 27 28 29 30注意第二行是反着数的:屏幕左边是第 20 格,右边才是第 11 格。这个反转如果写错,整盘棋的走位就全乱了。
2.2 棋子与掷具
白方先手,双方各 5 枚棋子,开局交错排在首行:
- 白方(P1):第 1、3、5、7、9 格
- 黑方(P2):第 2、4、6、8、10 格
掷具是 4 根"掷棒",正面算 1、反面算 0,正面朝上的数量就是走的步数(1–4);四根全反面则计为 5 步。
| 要素 | 说明 |
|---|---|
| 棋盘 | 3×10 共 30 格,牛耕式路径 |
| 棋子 | 双方各 5 枚,首行交错 |
| 掷具 | 4 根掷棒,步数 1–5 |
| 吃子 | 落点有敌子 → 双方位置互换 |
2.3 特殊格与结对保护
- 第 15 格 重生之屋:普通格,无惩罚。
- 第 26 格 美好之屋:停在上面,额外获得一次掷骰。
- 第 27 格 水之屋:停在上面直接退回第 15 格;若第 15 格被占,则退回第 1 格。
- 第 30 格 终点:必须精确到达才能移出棋盘;步数超过了就动不了。
- 结对保护:相邻两个及以上同色棋子结成"结对",敌方既不能跳过它们,也不能吃掉被保护的敌子。
这条结对规则是后面调试的重点。
三、代码怎么组织的
就一个 HTML 文件,分成三块:
塞尼特.html ├── <style> → CSS,约 140 行,管配色、棋盘网格、棋子与高亮 ├── <body> HTML → 骨架,约 50 行,控制面板 + 棋盘容器 + 胜利遮罩 └── <script> → 逻辑,约 410 行,这是重点 ├── 常量(gridSquare 坐标表 / HOUSE 特殊格) ├── 全局状态(board / pSquares / pieces / currentPlayer ...) ├── 掷具与判定(rollSticks / isProtected / getValidMoves) ├── 移动与特殊格(movePiece) ├── 渲染函数(buildBoardDOM / renderPieces / updateHighlights) ├── 点击交互(handleRoll / handleCellClick / doMove) └── 胜负(win / switchPlayer)最核心的思路就一条:状态是一份数据,画面永远由renderAll()从这份数据全量重绘。点击只改数据,不直接碰 DOM。这样规则和显示彻底解耦,调试时改一处逻辑,所有视图自动跟着对。
四、数据结构怎么设计
4.1 棋盘与棋子坐标
letboard=newArray(30).fill(null);letpSquares={};letpieces=[];letplayers={1:0,2:0};board是长度 30 的数组,索引 0–29 对应格子 1–30,每个元素是null或{ player: 1 }/{ player: 2 }。pSquares用棋子 id 当键,记录它当前在哪一格;出局的棋子记-1。pieces保存每枚棋子的元信息(id 和归属方),渲染时靠它遍历。
把"棋子在哪"和"格子上有谁"拆成两份数据,是刻意的。落子时改pSquares再rebuildBoard()重建board,判定时查board快,渲染时遍历pieces也快。
4.2 特殊格常量
constHOUSE_REBIRTH=14;// 第15格constHOUSE_BEAUTY=25;// 第26格constHOUSE_WATER=26;// 第27格constFINISH=29;// 第30格用"索引"而不是"编号"存,省得每次判断都减一。能提前算好的就不要在运行时算。
4.3 牛耕式坐标表
这是整个棋盘的地基,开局一次性算好:
constgridSquare=[];for(letrow=0;row<3;row++){gridSquare[row]=[];for(letcol=0;col<10;col++){letsq;if(row===0)sq=col;// 0..9elseif(row===1)sq=19-col;// 19..10elsesq=20+col;// 20..29gridSquare[row][col]=sq;}}functionsquareToRC(sq){constrow=Math.floor(sq/10);constpos=sq%10;constcol=(row===1)?(9-pos):pos;return[row,col];}gridSquare[row][col]给出"屏幕上的格子"对应的路径编号,squareToRC是它的反向查询——棋子要摆到哪一格,靠它把路径编号换算回行、列。row===1时col = 9 - pos就是第二行反转的关键。
五、CSS / UI 怎么渲染
5.1 棋盘布局
.board{display:grid;grid-template-columns:repeat(10,var(--cell));grid-template-rows:repeat(3,var(--cell));border-radius:10px;overflow:hidden;box-shadow:0 10px 30pxrgba(0,0,0,.45);}棋盘就是 10 列 3 行的 CSS Grid,每格尺寸用一个--cell变量统一控制。棋子层.pieces绝对定位浮在棋盘之上,靠 CSS 变量--row/--col决定位置。
5.2 视觉层级
.cell.light/.cell.dark:沙金浅、深交替,区分相邻格。.cell.house-beauty:浅绿,标记美好之屋。.cell.house-water:浅红,标记水之屋。.cell.house-rebirth:稍深沙金,标记重生之屋。.piece.white/.piece.black:象牙白 / 黑曜石径向渐变。
5.3 关键视觉元素:高亮与移动
.cell.selected{background:rgba(212,168,87,.55)!important;box-shadow:inset 0 0 0 3px #fff;z-index:2;}.cell.target::after{content:"";position:absolute;width:34%;height:34%;border-radius:50%;background:rgba(20,20,30,.40);pointer-events:none;z-index:2;animation:pulse 1.2s infinite;}选中格用金色底加白边,合法落点用一个会呼吸的深色圆点。棋子移动靠改--row/--col,再用 transition 平滑过去:
.piece{position:absolute;width:calc(var(--cell)- 12px);height:calc(var(--cell)- 12px);left:calc(var(--col)*var(--cell)+ 6px);top:calc(var(--row)*var(--cell)+ 6px);transition:left .45scubic-bezier(.4,0,.2,1),top .45scubic-bezier(.4,0,.2,1),transform .2s;}5.4 移动端适配
--cell用clamp(30px, 10.5vw, 48px),窄屏自动缩到 30px,宽屏最多 48px。控制面板用flex-wrap,屏幕不够宽时棋盘会掉到面板下方,不会因为固定宽度而溢出。
六、核心逻辑——合法落点怎么算
这部分是全文最核心的地方,也是最花功夫调试的。
6.1 先定好状态变量
letboard=newArray(30).fill(null);letpSquares={};letpieces=[];letplayers={1:0,2:0};letcurrentPlayer=1;letdicePoints=null;letselected=null;letlegalTargets=[];letmovable=[];letgameOver=false;letrolling=false;dicePoints是本次掷出的步数(没掷时是null),selected是当前点选的格子,legalTargets是它所有合法落点,movable是"当前回合所有能动的棋子"。这几个变量就是交互的中枢。
6.2 掷具:4 根掷棒
functionrollSticks(){conststicks=[0,0,0,0].map(()=>(Math.random()<0.5?1:0));letpts=sticks.reduce((a,b)=>a+b,0);if(pts===0)pts=5;return{sticks,points:pts};}四根正反随机,求和;和为 0 特判为 5。返回掷棒明细和总点数,UI 用它翻转动画。
6.3 结对判定 isProtected
functionisProtected(i){constp=board[i];if(!p)returnfalse;constleft=(i>0)?board[i-1]:null;constright=(i<29)?board[i+1]:null;if(left&&left.player===p.player)returntrue;if(right&&right.player===p.player)returntrue;returnfalse;}一句话就能说清:第i格有棋子,且它左右邻居里有同色棋子,那它就受保护。注意它只认"有没有同色邻居",不带方向。真正的方向限制放在下面。
6.4 合法落点 getValidMoves
functiongetValidMoves(from,steps){constto=from+steps;if(to>29)return[];// 超过终点,不能移动// 不能跳过"敌方"的结对(受保护的敌子);自己的结对不阻挡自己for(leti=from+1;i<to;i++){constb=board[i];if(b&&b.player!==currentPlayer&&isProtected(i))return[];}consttarget=board[to];if(target&&target.player===currentPlayer)return[];// 自家子挡路if(target&&target.player!==currentPlayer){if(isProtected(to))return[];// 受结对保护的敌子,不能吃}return[to];}逐层过滤:先查是否越过终点;再沿路径查有没有"受保护的敌方结对"挡路——有就整步作废;落点若是自家子,挡路;落点若是受保护的敌子,不能吃。每一层只管自己那条规则。
6.5 不能跳过敌方结对——这是修过的 bug
最早我写路径阻挡时,没有判断b.player !== currentPlayer,也就是说连自己的结对也会挡住自己。这违背规则原意:规则只说"敌方棋子不能跳过它们",没说自己的结对挡自己。
后果很隐蔽:白方两子结成对,另一枚白子想从对子前面跨过去,会被自己人挡住,等于白白丢了一步。改成只拦敌方后,回归测试立刻通过:
// 验证:白方在 0、2、3(2/3 为白方结对),从 0 走 4 步越过自己的结对落到 idx4constmv=getValidMoves(0,4);// 期望 mv.length === 1 && mv[0] === 4if(b&&b.player!==currentPlayer&&isProtected(i))return[];这一行就是全部修复。所谓"边界 bug",往往就是少了一个方向判断。
6.6 执行移动与特殊格 movePiece
functionmovePiece(from,to){constmover=board[from];constplayer=mover.player;// 终点:精确到达即出局if(to===FINISH){pSquares[mover.id]=-1;players[player]++;rebuildBoard();renderPieces();setStatus(`${pname(player)}一枚棋子到达终点!(已出局${players[player]}/5)`);if(players[player]===5){win(player);}returnfalse;}// 吃子:位置互换constenemy=board[to];if(enemy&&enemy.player!==player){pSquares[enemy.id]=from;}pSquares[mover.id]=to;rebuildBoard();// 特殊格效果if(to===HOUSE_BEAUTY){// 第26格:额外回合renderPieces();setStatus(`${pname(player)}停在美好之屋,获得额外回合!`);returntrue;}if(to===HOUSE_WATER){// 第27格:退回第15格,被占则回第1格letdest=board[HOUSE_REBIRTH]?null:HOUSE_REBIRTH;if(dest===null)dest=board[0]?null:0;if(dest===null){for(leti=0;i<30;i++){if(!board[i]){dest=i;break;}}}if(dest!==null){pSquares[mover.id]=dest;rebuildBoard();}renderPieces();setStatus(`${pname(player)}落入水之屋,退回第${dest+1}格!`);returnfalse;}renderPieces();returnfalse;}吃子是"位置互换":敌子退回到from,自己前进到to。返回值true表示触发了美好之屋的额外回合,调用方据此不切换玩家。水之屋先抢第 15 格,被占再抢第 1 格,再不行就找最近的空格——最后用dest + 1把索引换算回"第几格"显示,提示才不会说错话。
七、渲染和交互怎么配合
7.1 渲染策略:全量重绘
functionrenderPieces(){for(constpofpieces){constel=document.getElementById('pc-'+p.id);consts=pSquares[p.id];if(s<0){el.style.display='none';continue;}el.style.display='block';const[row,col]=squareToRC(s);el.style.setProperty('--row',row);el.style.setProperty('--col',col);}}遍历pieces,出局的藏起来,其余的用squareToRC算出行列,写进 CSS 变量。CSS 那边用calc(var(--col) * var(--cell) + 6px)定位,配合 transition 就动起来了。状态变、视图跟着变,中间没有手写的"移动第几格"分支。
7.2 点击事件的处理逻辑
玩家点"掷骰" → rollSticks 出点数 → 遍历 board 算 movable(能动的棋子) → 若 movable 为空:提示并跳过回合 → 否则等玩家点棋子 玩家点己方棋子 → selected = 该格 → legalTargets = getValidMoves(该格, 点数) 玩家点合法落点 → movePiece,处理吃子/特殊格 → 触发额外回合则自己再掷,否则 switchPlayer7.3 交互细节:高亮谁、什么时候清
functionupdateHighlights(){boardEl.querySelectorAll('.cell').forEach(c=>{c.classList.remove('movable','selected','target');});if(dicePoints!==null&&selected===null){for(constmofmovable){constc=boardEl.querySelector(`.cell[data-square="${m}"]`);if(c)c.classList.add('movable');}}if(selected!==null){constsc=boardEl.querySelector(`.cell[data-square="${selected}"]`);if(sc)sc.classList.add('selected');for(consttoflegalTargets){consttc=boardEl.querySelector(`.cell[data-square="${t}"]`);if(tc)tc.classList.add('target');}}}一个容易忽视的细节:没选中具体棋子时,只亮"所有可动子"(.movable);一旦选中,立刻切到"选中格 + 落点"视图。两层用selected === null互斥,避免金框和圆点糊在一起。
八、怎么判胜负
8.1 先让 5 子全部出局者胜
functionwin(player){gameOver=true;winText.textContent=`${pname(player)}获胜!`;overlay.classList.add('show');renderAll();}movePiece里每送出一枚到终点就players[player]++,到 5 直接调win。gameOver置位后,掷骰和点击都被if (gameOver ...) return拦住,盘面锁死,只留遮罩和"再来一局"。
8.2 精确到达终点
to === FINISH才算出局,而getValidMoves里if (to > 29) return []已经把"步数超出"挡在门外。两条加起来保证:多一步不行,少一步也不行,必须正好踩上第 30 格。
九、运行环境与逻辑验证
运行环境很简单:纯原生 HTML + CSS + JavaScript,不依赖任何框架、库或 CDN,浏览器直接打开【单机离线版】塞尼特.html即可游玩。逻辑测试则需要 Node.js 配合 Playwright(本仓库统一用test-common下的env.js与server.js驱动)。
仓库里test/verify.js用真实浏览器跑通全部核心规则,覆盖路径映射、初始布局、掷具范围、吃子互换、越界、结对保护,以及上面那个"自己结对不挡自己"的回归用例。运行命令和预期输出:
cd塞尼特nodetest/verify.js# 预期输出:12/12 全绿,并打印 ALL TESTS PASS我每次改完getValidMoves或movePiece都会先跑这一遍。测试不过不截图、不写文章——这是这套离线小游戏发布流程里的一道闸门。
十、键盘快捷键
这个游戏完全是鼠标 / 触屏点击驱动:掷骰是按钮,走子是点格子,没有键盘快捷键。我当时的考虑是双人同屏对坐,触屏和鼠标比键盘更自然,也就没加按键支持。
十一、几个值得记住的点
- 棋盘坐标这类"固定映射",开局一次性算成表,运行时只查不算是值得养成的习惯。
- "全量重绘"在 30 格这种规模下几乎零成本,却让交互逻辑干净很多。
- 结对这类"带方向的规则",判定函数里一定要带上
player !== currentPlayer这种方向条件,否则自己人也会坑自己。 - 测试门里给每个修过的 bug 留一条回归用例,下次改规则心里才踏实。
十二、还可以做什么
加 AI 对手。现在是人人对战,可以把getValidMoves接一个简单的评分:优先走到特殊格、优先吃 unprotected 的敌子、尽量别把自己的子送进结对缝隙。
做历史回放。每一步把pSquares快照压进一个栈,就能支持"悔棋"和"复盘",对双人游戏很实用。
把掷棒做成可点的真实掷具。现在点击按钮整体掷,可以让 4 根掷棒各自可点、单独翻面,更有古埃及那种掷具的手感。
十三、适用边界 / 已知局限
- 单文件离线实现:整盘棋只有这一个 HTML 文件,断网也能玩,但因此也没有后端、没有存档。
- 只实现了代码里的规则子集:水之屋的退回逻辑(第 15 格 → 第 1 格 → 最近的空格)是我对"被占则退回"这一句的具体落地,并非某本规则书的唯一权威解释;其余特殊格、结对保护同样只覆盖代码实际写出的那部分。
- 浏览器兼容性:
clamp()、grid、CSS 变量都是现代浏览器特性,老旧浏览器(如 IE)无法正确渲染。 - 未覆盖的极端局面:当全盘棋子极密、水之屋退回时第 15 格 / 第 1 格 / 所有空格都被占满,代码会落到"找不到目标就不动"的分支,此时水之屋等于空触发——这种极端拥挤局面没有专门提示。
- 无 AI、无联机:当前是人人对弈,没有电脑对手,也没有网络对战。
十四、关于这篇文章
完整的 HTML 源码随文附上,保存成.html文件双击就能在浏览器里跑。代码总共约 600 行,函数和关键逻辑都加了中文注释。
下载链接:https://download.csdn.net/download/qq616491978/93147293