新闻详情

新闻详情

首页 / 资讯中心 / 详情

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

发布时间:2026/7/19 3:02:31
手写一个塞尼特: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 结对判定 isProtected6.4 合法落点 getValidMoves6.5 不能跳过敌方结对——这是修过的 bug6.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 棋盘与棋子坐标letboardnewArray(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_REBIRTH14;// 第15格constHOUSE_BEAUTY25;// 第26格constHOUSE_WATER26;// 第27格constFINISH29;// 第30格用索引而不是编号存省得每次判断都减一。能提前算好的就不要在运行时算。4.3 牛耕式坐标表这是整个棋盘的地基开局一次性算好constgridSquare[];for(letrow0;row3;row){gridSquare[row][];for(letcol0;col10;col){letsq;if(row0)sqcol;// 0..9elseif(row1)sq19-col;// 19..10elsesq20col;// 20..29gridSquare[row][col]sq;}}functionsquareToRC(sq){constrowMath.floor(sq/10);constpossq%10;constcol(row1)?(9-pos):pos;return[row,col];}gridSquare[row][col]给出屏幕上的格子对应的路径编号squareToRC是它的反向查询——棋子要摆到哪一格靠它把路径编号换算回行、列。row1时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 先定好状态变量letboardnewArray(30).fill(null);letpSquares{};letpieces[];letplayers{1:0,2:0};letcurrentPlayer1;letdicePointsnull;letselectednull;letlegalTargets[];letmovable[];letgameOverfalse;letrollingfalse;dicePoints是本次掷出的步数没掷时是nullselected是当前点选的格子legalTargets是它所有合法落点movable是当前回合所有能动的棋子。这几个变量就是交互的中枢。6.2 掷具4 根掷棒functionrollSticks(){conststicks[0,0,0,0].map(()(Math.random()0.5?1:0));letptssticks.reduce((a,b)ab,0);if(pts0)pts5;return{sticks,points:pts};}四根正反随机求和和为 0 特判为 5。返回掷棒明细和总点数UI 用它翻转动画。6.3 结对判定 isProtectedfunctionisProtected(i){constpboard[i];if(!p)returnfalse;constleft(i0)?board[i-1]:null;constright(i29)?board[i1]:null;if(leftleft.playerp.player)returntrue;if(rightright.playerp.player)returntrue;returnfalse;}一句话就能说清第i格有棋子且它左右邻居里有同色棋子那它就受保护。注意它只认有没有同色邻居不带方向。真正的方向限制放在下面。6.4 合法落点 getValidMovesfunctiongetValidMoves(from,steps){consttofromsteps;if(to29)return[];// 超过终点不能移动// 不能跳过敌方的结对受保护的敌子自己的结对不阻挡自己for(letifrom1;ito;i){constbboard[i];if(bb.player!currentPlayerisProtected(i))return[];}consttargetboard[to];if(targettarget.playercurrentPlayer)return[];// 自家子挡路if(targettarget.player!currentPlayer){if(isProtected(to))return[];// 受结对保护的敌子不能吃}return[to];}逐层过滤先查是否越过终点再沿路径查有没有受保护的敌方结对挡路——有就整步作废落点若是自家子挡路落点若是受保护的敌子不能吃。每一层只管自己那条规则。6.5 不能跳过敌方结对——这是修过的 bug最早我写路径阻挡时没有判断b.player ! currentPlayer也就是说连自己的结对也会挡住自己。这违背规则原意规则只说敌方棋子不能跳过它们没说自己的结对挡自己。后果很隐蔽白方两子结成对另一枚白子想从对子前面跨过去会被自己人挡住等于白白丢了一步。改成只拦敌方后回归测试立刻通过// 验证白方在 0、2、32/3 为白方结对从 0 走 4 步越过自己的结对落到 idx4constmvgetValidMoves(0,4);// 期望 mv.length 1 mv[0] 4if(bb.player!currentPlayerisProtected(i))return[];这一行就是全部修复。所谓边界 bug往往就是少了一个方向判断。6.6 执行移动与特殊格 movePiecefunctionmovePiece(from,to){constmoverboard[from];constplayermover.player;// 终点精确到达即出局if(toFINISH){pSquares[mover.id]-1;players[player];rebuildBoard();renderPieces();setStatus(${pname(player)}一枚棋子到达终点已出局${players[player]}/5);if(players[player]5){win(player);}returnfalse;}// 吃子位置互换constenemyboard[to];if(enemyenemy.player!player){pSquares[enemy.id]from;}pSquares[mover.id]to;rebuildBoard();// 特殊格效果if(toHOUSE_BEAUTY){// 第26格额外回合renderPieces();setStatus(${pname(player)}停在美好之屋获得额外回合);returntrue;}if(toHOUSE_WATER){// 第27格退回第15格被占则回第1格letdestboard[HOUSE_REBIRTH]?null:HOUSE_REBIRTH;if(destnull)destboard[0]?null:0;if(destnull){for(leti0;i30;i){if(!board[i]){desti;break;}}}if(dest!null){pSquares[mover.id]dest;rebuildBoard();}renderPieces();setStatus(${pname(player)}落入水之屋退回第${dest1}格);returnfalse;}renderPieces();returnfalse;}吃子是位置互换敌子退回到from自己前进到to。返回值true表示触发了美好之屋的额外回合调用方据此不切换玩家。水之屋先抢第 15 格被占再抢第 1 格再不行就找最近的空格——最后用dest 1把索引换算回第几格显示提示才不会说错话。七、渲染和交互怎么配合7.1 渲染策略全量重绘functionrenderPieces(){for(constpofpieces){consteldocument.getElementById(pc-p.id);constspSquares[p.id];if(s0){el.style.displaynone;continue;}el.style.displayblock;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!nullselectednull){for(constmofmovable){constcboardEl.querySelector(.cell[data-square${m}]);if(c)c.classList.add(movable);}}if(selected!null){constscboardEl.querySelector(.cell[data-square${selected}]);if(sc)sc.classList.add(selected);for(consttoflegalTargets){consttcboardEl.querySelector(.cell[data-square${t}]);if(tc)tc.classList.add(target);}}}一个容易忽视的细节没选中具体棋子时只亮所有可动子.movable一旦选中立刻切到选中格 落点视图。两层用selected null互斥避免金框和圆点糊在一起。八、怎么判胜负8.1 先让 5 子全部出局者胜functionwin(player){gameOvertrue;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
网站建设 高端定制 企业官网