news 2026/7/19 3:02:24

手写一个塞尼特:600 行纯前端代码,从牛耕式坐标到“结对保护“全流程解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写一个塞尼特:600 行纯前端代码,从牛耕式坐标到“结对保护“全流程解析

手写一个塞尼特: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 当键,记录它当前在哪一格;出局的棋子记-1pieces保存每枚棋子的元信息(id 和归属方),渲染时靠它遍历。

把"棋子在哪"和"格子上有谁"拆成两份数据,是刻意的。落子时改pSquaresrebuildBoard()重建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===1col = 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 移动端适配

--cellclamp(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] === 4
if(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,处理吃子/特殊格 → 触发额外回合则自己再掷,否则 switchPlayer

7.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 直接调wingameOver置位后,掷骰和点击都被if (gameOver ...) return拦住,盘面锁死,只留遮罩和"再来一局"。

8.2 精确到达终点

to === FINISH才算出局,而getValidMovesif (to > 29) return []已经把"步数超出"挡在门外。两条加起来保证:多一步不行,少一步也不行,必须正好踩上第 30 格。


九、运行环境与逻辑验证

运行环境很简单:纯原生 HTML + CSS + JavaScript,不依赖任何框架、库或 CDN,浏览器直接打开【单机离线版】塞尼特.html即可游玩。逻辑测试则需要 Node.js 配合 Playwright(本仓库统一用test-common下的env.jsserver.js驱动)。

仓库里test/verify.js用真实浏览器跑通全部核心规则,覆盖路径映射、初始布局、掷具范围、吃子互换、越界、结对保护,以及上面那个"自己结对不挡自己"的回归用例。运行命令和预期输出:

cd塞尼特nodetest/verify.js# 预期输出:12/12 全绿,并打印 ALL TESTS PASS

我每次改完getValidMovesmovePiece都会先跑这一遍。测试不过不截图、不写文章——这是这套离线小游戏发布流程里的一道闸门。


十、键盘快捷键

这个游戏完全是鼠标 / 触屏点击驱动:掷骰是按钮,走子是点格子,没有键盘快捷键。我当时的考虑是双人同屏对坐,触屏和鼠标比键盘更自然,也就没加按键支持。


十一、几个值得记住的点

  • 棋盘坐标这类"固定映射",开局一次性算成表,运行时只查不算是值得养成的习惯。
  • "全量重绘"在 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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/19 2:59:03

DEV C++ 新手入门指南:从安装配置到调试实战全解析

1. 项目概述&#xff1a;为什么是DEV C&#xff1f;如果你刚开始接触C或C语言编程&#xff0c;或者正在寻找一个轻量级、不折腾的集成开发环境&#xff08;IDE&#xff09;&#xff0c;DEV C大概率会出现在你的备选清单里。它不像Visual Studio那样庞大&#xff0c;也不像VSCod…

作者头像 李华
网站建设 2026/7/19 2:58:19

Spring AI Alibaba 2.0集成MCP协议:构建智能工具调用系统实战

在实际 Java AI 应用开发中&#xff0c;最麻烦的不是调用大模型 API&#xff0c;而是如何让 AI 系统安全、可控地访问外部工具和数据源。传统方案需要为每个工具编写特定的适配器&#xff0c;维护复杂的认证和错误处理逻辑&#xff0c;而 Model Context Protocol (MCP) 的出现彻…

作者头像 李华
网站建设 2026/7/19 2:58:05

雪茄沙发常见问题解答(2026最新专家版)

美式复古雪茄沙发工厂常见问题解析 Q1&#xff1a;靠谱的美式复古雪茄沙发工厂怎么选&#xff1f; 看场景专注度&#xff1a;优先选择聚焦雪茄吧、威士忌吧细分场景的工厂&#xff0c;这类工厂更懂场景的真实使用需求&#xff0c;产品设计更贴合使用习惯。 看产业集群优势&…

作者头像 李华
网站建设 2026/7/19 2:57:03

WTM框架快速搭建博客后台系统实战

1. WTM框架与博客系统后台搭建WTM&#xff08;WalkingTec MVVM&#xff09;是一个基于.NET Core的全栈快速开发框架&#xff0c;它特别适合快速构建管理后台类应用。我在多个企业级项目中采用WTM框架开发后台系统&#xff0c;其最大优势在于通过代码生成器可以快速完成80%的基础…

作者头像 李华
网站建设 2026/7/19 2:56:53

ROS2 Humble下MoveIt2实操指南:从URDF配置到轨迹执行

1. 这不是一本“教材”&#xff0c;而是一张机器人运动规划的实操地图如果你刚在ROS工作空间里敲下rosrun moveit_setup_assistant moveit_setup_assistant&#xff0c;看着那个带3D模型预览的GUI界面发呆&#xff1b;或者你已经把UR5机械臂的URDF拖进MoveIt Setup Assistant&a…

作者头像 李华
网站建设 2026/7/19 2:56:32

数据科学家的四重身份:业务翻译、技术权衡、组织协同与伦理守门

1. 这不是岗位说明书&#xff0c;而是一份数据科学家的生存手记“数据科学家”这五个字&#xff0c;从2012年《哈佛商业评论》那篇《The Sexiest Job of the 21st Century》开始&#xff0c;就裹着金粉上了神坛。但十年过去&#xff0c;我带过27个数据项目、面试过156位候选人、…

作者头像 李华