news 2026/10/22 19:45:49

用原生JavaScript实现大富翁游戏:从规则建模到DOM渲染的完整教程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用原生JavaScript实现大富翁游戏:从规则建模到DOM渲染的完整教程

很多人觉得大富翁(Monopoly)不就是掷骰子、走格子、买地交租吗?等我真的动手用 JavaScript 写了一遍,才发现这套规则的复杂度被严重低估了。买地交租只是最外面一层,后面还躲着同色集团加成、房屋升级、抵押贷款、机会卡、入狱出狱、破产判定、甚至“经过起点要不要发工资”这类容易吵起来的细节。这篇文章我就把自己从零实现 Monopoly 游戏 JavaScript 实现教程过程中的完整思路、核心代码骨架和踩坑记录整理出来,适合已经掌握 JS 基础语法、想通过一个完整小项目把面向对象、状态管理和 DOM 操作串起来的开发者。不需要框架,不需要构建工具,一个浏览器加一个文本编辑器就能跟着跑起来。

我先说个结论:如果你只是想做一个“能玩”的单机版大富翁,原生 JavaScript 完全够用。它不涉及高并发、不涉及多人对时实同步,核心是几十个格子的数据结构、几个玩家的状态流转,以及一套能看得懂的界面。正因为它规模不大,反而特别适合用来训练“先把规则抽象成数据,再把数据渲染成界面”的能力。这篇文章会围绕项目设计、棋盘建模、交易机制、渲染交互和问题排查五块展开,每一块我都会给出我自己在开发时真正用到的方案,而不是网上那种只贴两段代码就结束的伪教程。

首先,我只用原生 JS,是因为这类教学项目最大的目标不是炫技,而是让你能一口气完成。引入 React、Vue 或任何工程化脚手架当然也可以,但对一个棋盘渲染功能来说,这些框架带来的复杂度和收益不成比例。原生 DOM 操作完全能覆盖这个场景,而且少了构建步骤之后,你能把注意力全部放在游戏逻辑本身。等棋盘、玩家、交易系统都稳定了,再迁移框架,那时候你对需求的理解已经足够支撑你做架构取舍。

1. 项目设计与核心难点拆解

1.1 先把规则边界划清楚:我们要做“哪个版本”的大富翁

写代码之前第一件事不是开编辑器,而是决定你到底要实现哪些规则。大富翁的规则有很多变体,光一个“落到别人地盘要不要强制支付租金”就可能因为版本不同产生完全不同的玩法。我自己做的版本明确了这样一个规则集:

  • 2 到 4 名玩家,轮流行动,不能操控其他角色,其余玩家也可以由简单 AI 代替。
  • 棋盘一共 40 个格子,包含起始点、普通地产、铁路、公共事业、税款、机会卡、监狱、免费停车、前往监狱这些类型。
  • 玩家掷两颗六面骰子,移动对应步数,经过或落在起始点可领取薪水。
  • 落到无人地产时可以选择购买;落到他人地产时需要支付租金。
  • 持有同色地块全部地产时,租金翻倍;可以在自己地产上盖房子提升租金。
  • 机会卡随机触发奖励或惩罚事件。
  • 落到“前往监狱”则直接入狱,入狱后需要掷出对子或支付罚款才能出来。
  • 金钱不足以支付任何款项时宣告破产,退出游戏。

把规则用文字写出来,看起来不多,但每一条背后都藏着独立的状态管理逻辑。比如“持有同色地块全部地产时租金翻倍”这条,就要求你的数据结构能快速回答“某个颜色组还有没有未拥有的地块”。所以第一步不是写渲染函数,而是把这一整条规则链给拆成可验证的独立模块。这也是我在教程里最想强调的思路:先文档化规则,再写代码。

1.2 为什么选择原生 JavaScript + DOM,而不是直接上框架

我见到很多人一上手就把项目初始化成 React 工程,装了十几个依赖,结果一行游戏逻辑还没写就卡在环境配置上。对于这类单页小游戏,原生 JavaScript 有其不可替代的优势:你可以在浏览器开发者工具里直接调试变量,不需要编译映射,也不会遇到打包器带来的版本问题。

那框架到底用不用?我的建议是,可以等核心逻辑写完后再决定。如果你将来想把这个棋盘拆成组件,把每个格子作为独立组件管理,这时候再迁移到框架也会很轻松。因为你要抽离的核心数据模型和渲染层是分开的,框架只是换了渲染层。原生 JS 写完的数据层,挪到任何框架下都能继续用。这就是我坚持“逻辑和渲染分离”的原因。

界面方面,我选了两种方案:一套最简单的是控制台版本,纯拿来验证回合逻辑;另一套是 DOM 版本,用来给人实际点击操作。控制台版的好处是可以很快跑通,DOM 版则是用来完善交互体验。两套共用同一个核心逻辑模块,只是渲染出口不同。这种分层方式在项目演进时特别有价值。

2. 棋盘数据建模:整个游戏的地基

2.1 用数组表达棋盘,用对象表达格子

大富翁棋盘是一个固定环形结构,玩家位置用一个数字表示即可。所以棋盘最自然的数据结构就是一个长度为 40 的数组,数组下标就是棋盘索引。我用了一个常量来保存棋盘大小,这样以后想改成 32 格或者 48 格,只需要调整数组内容和常量即可,其他代码几乎不用动。

每个格子用一个对象来表达。对象里保存了类型、名称、价格、租金、所属颜色组、所有者索引、房屋等级这些字段。不同的格子类型字段会有所差异,但统一放在对象里也无妨,只要访问前判断类型就行。我见过有人给每种类型分别建数组,最后查询起来反而很麻烦。统一数组加类型判断,是小型项目里最直接高效的做法。

const BOARD_SIZE = 40; const TILE_TYPES = { GO: 'go', PROPERTY: 'property', RAILROAD: 'railroad', UTILITY: 'utility', TAX: 'tax', CHANCE: 'chance', JAIL: 'jail', FREE_PARKING: 'free_parking', GO_TO_JAIL: 'go_to_jail' }; function createProperty(id, name, price, group, rents, mortgage) { return { id, name, type: TILE_TYPES.PROPERTY, price, group, rents, mortgage, owner: -1, houseLevel: 0 }; } const board = []; // 初始化时逐个 push,也可以写一个配置表然后循环生成 board.push({ id: 0, name: '起点', type: TILE_TYPES.GO }); board.push(createProperty(1, '某某大道', 60, 'brown', [2, 10, 30, 90, 160, 250], 30)); // 其余格子按同样方式初始化

使用owner: -1表示暂无玩家拥有,这样判断归属时只需要比较owner >= 0。房屋等级从 0 开始,0 表示空地,1 到 5 分别代表一栋房子到酒店级别。这样做的好处是租金计算可以转成查表,不用写一堆 if 嵌套。

2.2 玩家对象与掷骰回合的状态机

玩家状态我用一个数组保存,每个玩家拥有姓名、位置、金钱、是否入狱、已连续掷出对子的次数、所属地产列表这些字段。我特别保留了一个doublesCount字段,用来记录连续掷出对子的次数,连续三次对子要按规则进监狱。这个字段一开始没设计,后来在调试时发现很容易遗漏,最好从开头就纳入状态设计。

function createPlayer(name, index) { return { name, index, position: 0, money: 1500, inJail: false, jailTurns: 0, doublesCount: 0, properties: [], bankrupt: false }; }

回合循环可以说是整个游戏的中枢。一次完整回合分为:掷骰子、移动、触发格子效果、处理对子规则、切换到下一个玩家。这里有一个很典型的认知偏差:很多人把“掷骰子”和“移动”合并成一个函数,但实际开发时最好拆开。因为在入狱状态下,玩家不需要正常移动,而是走另一套“掷出对子才能出狱”的规则。

function rollDice() { const d1 = Math.floor(Math.random() * 6) + 1; const d2 = Math.floor(Math.random() * 6) + 1; return { total: d1 + d2, isDouble: d1 === d2 }; } function takeTurn(playerIndex) { const player = players[playerIndex]; if (player.bankrupt) { nextPlayer(); return; } if (player.inJail) { handleJailTurn(player); nextPlayer(); return; } const dice = rollDice(); if (dice.isDouble) { player.doublesCount += 1; if (player.doublesCount >= 3) { sendToJail(player); nextPlayer(); return; } } else { player.doublesCount = 0; } movePlayer(player, dice.total); handleTile(player); nextPlayer(); }

movePlayer里需要特别注意“经过起点”的判断。如果玩家位置移动后小于移动前的值,说明跨过了起点,必须发放工资。这里有一个经典陷阱:如果玩家恰好停在起点,是否需要发工资?不同版本规则不一样。我采用的方案是判定“经过或停在起点”都发放,简单且没有争议。

3. 核心交易机制实现:买地、收租、事件卡

3.1 购买逻辑与租金计算的几个关键点

落到无主地产时,游戏要弹出购买确认。如果玩家资金不足,则直接跳过;如果资金足够但玩家选择不买,这块地在我的版本里会保持无主状态,不做拍卖(拍卖会增加不少复杂度,但未来完全可以扩展)。购买时,玩家扣减金钱、地块记录所有者、玩家地产列表推入地块引用。

这里有一个细节值得展开:玩家地产列表里存的是地块引用还是地块索引?我建议存索引。因为 JavaScript 对象引用虽然好用,但当渲染层需要深拷贝棋盘状态做对比时,存索引更方便。你完全可以通过board[propertyIndex]获取完整对象,又避免了复制时产生循环引用的问题。

function tryBuyProperty(player, tile) { if (tile.owner !== -1) return; // 已经有主 if (player.money < tile.price) { showMessage('资金不足,无法购买'); return; } if (confirm(`是否购买 ${tile.name},价格 ${tile.price}?`)) { player.money -= tile.price; tile.owner = player.index; player.properties.push(tile.id); renderBoard(); } }

租金计算要比购买稍微绕一点。普通地产的租金受“同色集团加成”和“房屋等级”影响。我设计了一个getRent(tile)函数,它先看格子类型:如果是铁路或公用事业,走另一套计算逻辑;如果是普通地产,再按房屋等级查租金表。

同色集团加成怎么判断?每次计算租金时扫描一次所有同组地块,检查是否全都被同一玩家拥有。这个扫描成本不高,因为棋盘总共只有几十个格子,不需要为了性能做复杂缓存。这也是小项目的特点:很多时候,最简单的方案就是最佳方案。

function getGroupSize(group) { return board.filter(tile => tile.group === group).length; } function countGroupOwned(group, playerIndex) { return board.filter(tile => tile.group === group && tile.owner === playerIndex).length; } function getRent(tile) { if (tile.type === TILE_TYPES.RAILROAD) { const ownedRailroads = board.filter(t => t.type === TILE_TYPES.RAILROAD && t.owner === tile.owner).length; return [50, 100, 200, 400][ownedRailroads - 1] || 0; } if (tile.type === TILE_TYPES.UTILITY) { const ownedUtilities = board.filter(t => t.type === TILE_TYPES.UTILITY && t.owner === tile.owner).length; return ownedUtilities === 2 ? 10 * lastDiceTotal : 4 * lastDiceTotal; } if (tile.houseLevel === 0) { const groupComplete = countGroupOwned(tile.group, tile.owner) === getGroupSize(tile.group); return groupComplete ? tile.rents[0] * 2 : tile.rents[0]; } return tile.rents[tile.houseLevel]; }

公用事业的租金计算需要依赖“最近一次掷骰子的点数”,所以我在全局保存了一个lastDiceTotal变量,每次掷骰后更新。交叉依赖是这类项目里最需要警惕的部分,建议把这种临时数据封装在回合状态对象里,而不是到处定义全局变量。

3.2 机会卡与事件卡:用数组加函数解决变化

机会卡的实现并不复杂,难在让每张卡的效果可维护。我最开始用的是 switch-case 字符串匹配,后来发现每加一张卡就要改一次 switch,很快变得混乱。更好的方案是:每张卡片由两个字段组成,一个是展示文案,另一个是效果函数,游戏每次从卡片数组里随机抽一张,直接调用对应函数。这样新增卡片就是往数组里 push 一个新对象,完全不用改动核心逻辑。

const chanceCards = [ { text: '资本市场回暖,你获得 200', execute(player) { player.money += 200; } }, { text: '家具焕新支出,扣 150', execute(player) { player.money -= 150; } }, { text: '回到起点并领取薪水', execute(player) { moveToTile(player, 0); player.money += SALARY; } }, { text: '立刻前往监狱', execute(player) { sendToJail(player); } } ]; function drawChanceCard(player) { const card = chanceCards[Math.floor(Math.random() * chanceCards.length)]; showMessage(card.text); card.execute(player); checkBankrupt(player); }

使用函数做效果的另一个好处是,你可以在卡片效果里调用其他系统函数,比如入狱、移动、收钱,不需要额外设计一套事件语法。这种“数据驱动函数引用”的思路,同样适用于未来的更多扩展,比如节日活动卡、任务卡。

3.3 入狱、税收、免费停车这些边界机制怎么处理

大富翁里最容易产生理解分歧的格子是“免费停车”和“入狱”。我的规则是:免费停车格子不产生任何收益,只是单纯的停留。税收格子执行一次性扣款。入狱这块,我实现了三种出狱方式:掷出对子、支付罚款、在监狱中停留一定回合数后强制支付罚款出狱。

function handleJailTurn(player) { const dice = rollDice(); if (dice.isDouble) { player.inJail = false; player.doublesCount = 0; movePlayer(player, dice.total); handleTile(player); } else { player.jailTurns += 1; if (player.jailTurns >= 3) { player.money -= JAIL_FINE; player.inJail = false; player.jailTurns = 0; } } }

这个函数特别容易出 bug 的点在于:支付罚款出狱后,本次掷骰点数是否要再移动?很多版本规定不需要。我采用的规则是,只有通过掷出对子出狱时,才移动骰子点数;支付罚款出狱的回合不再移动。逻辑不复杂,但如果一开始没想清楚,代码会写得很别扭。

4. 渲染与交互落地:把逻辑变成界面

4.1 DOM 版棋盘渲染:如何把一维数组画成二维棋盘

大富翁棋盘是一个环形,最常见的布局是四周各 10 格或 11 格,四个角分别是起点、监狱、免费停车、前往监狱。DOM 渲染的关键在于把长度为 40 的数组映射到二维网格坐标。我用的方法是:先确定每个格子的x和y,然后通过定位left和top属性把格子元素放到对应位置。

不一定要用 Canvas,纯 DOM 的 div 就够用。每个格子内部可以放格子名称,最上面用一条色带代表颜色组;拥有者的标识用一个小的圆形棋子或者文字标签来表示。这里我建议不要试图用一个巨大函数完成所有渲染,而是把渲染拆成两个层面:一个是静态棋盘层,只创建一次;另一个是玩家层,每次位置变化后只更新小棋子节点。

function renderBoard() { boardContainer.innerHTML = ''; board.forEach((tile, index) => { const tileEl = document.createElement('div'); tileEl.className = 'tile'; tileEl.dataset.index = index; tileEl.style.left = getTileX(index) + 'px'; tileEl.style.top = getTileY(index) + 'px'; const colorBar = document.createElement('div'); colorBar.className = 'color-bar'; colorBar.style.backgroundColor = tile.group ? groupColors[tile.group] : 'transparent'; tileEl.appendChild(colorBar); const nameEl = document.createElement('span'); nameEl.className = 'tile-name'; nameEl.textContent = tile.name; tileEl.appendChild(nameEl); boardContainer.appendChild(tileEl); }); }

每次更新棋盘,一个最简单的做法是整体重新渲染,但对于这种几十个节点的界面,不会产生可感知的性能问题。只有在玩家棋子频繁移动时,建议单独维护棋子节点,避免整个棋盘闪烁。我实际开发中就用一个playerMarkers对象保存每个玩家的 DOM 节点,移动时只更新transform属性。

4.2 按钮事件与操作弹窗的衔接

界面交互要处理的其实只有三个主要操作:掷骰子、购买确认、回合结束。购买确认我用的是原生的confirm弹窗,方便但有点丑。如果你想让界面更精致,可以把tryBuyProperty里的confirm替换成自己设计的模态框,核心逻辑不变。这里最需要注意的是异步交互和状态机的关系:玩家点击按钮后,必须保证状态已经正确更新,再重新渲染界面。

rollBtn.addEventListener('click', () => { if (isProcessing) return; isProcessing = true; takeTurn(currentPlayerIndex); renderBoard(); renderPlayerPanel(); isProcessing = false; });

我用一个isProcessing标志来防止玩家在动画或弹窗期间连续点击骰子按钮,这是很多小游戏项目容易忽略的细节。没有这个标志,玩家快速连点两次骰子,会导致同一回合内连续执行两次行动,整个局面彻底乱套。

5. 调试笔记:那些必须避开的坑

5.1 最容易写错的四个逻辑位置

第一个坑:数组越界。棋盘 40 格,玩家位置 39,再掷出 6 点,位置变成 45,一定要用取模运算% BOARD_SIZE然回到 5。但取模之后,“是否经过起点”的判断就丢了,所以必须在取模之前先判断。先判断再取模,顺序不能反。

第二个坑:连续对子进监狱。doublesCount必须在每次非对子掷骰时清零。我一度忘记清零,结果玩家连续两次对子后被强制入狱,程序把第二次的移动也执行了,导致位置和状态不匹配。

第三个坑:负数资金。在扣除各种费用时,如果玩家资金跌破 0,不能直接允许继续玩,要走破产流程。破产流程里还要处理玩家名下的地产释放,把owner重置为 -1,否则棋盘上会残留已破产玩家的标记。

第四个坑:多玩家共用出口引用。我最早把properties数组存放了对象引用,结果深层拷贝棋盘状态时,出现循环引用导致JSON.stringify直接报错。改成存索引后这个问题就消失了,后续调试省了很多时间。

5.2 体验优化小技巧:让调试过程更愉快

我常用的一个调试技巧:提供一个“强制执行下一步”的快捷键,比如键盘空格键触发掷骰子。这样在开发者工具里可以连续操作,不需要鼠标反复点击按钮。另一个技巧是日志面板,把每一步动作,包括玩家、位置、骰子点数、资金变化,以文字形式追加到页面上。这比打断点查看变量更直观,尤其是在复现随机 bug 时,可以快速定位是哪一步出现了异常状态。

如果你计划加入 AI 玩家,我的建议是不要在 AI 回合里调用任何confirm弹窗。AI 决策应该走一个独立的自动购买函数,由简单的策略决定,比如资金足够且该地未拥有时自动购买。这样在测试时,你可以把人类玩家和 AI 玩家混合,一边开着日志面板,一边观察 AI 的每一步选择,极大提高测试覆盖率。

最后一个优化技巧:给棋盘加一个“重置按钮”。每次跑完一局测试,我直接点重置回到初始状态,不需要刷新页面。这个功能在你反复调试购买、破产、入狱等流程时,简直救命。

我个人实际开发中的体会是,写这类游戏项目最容易让人崩溃的从来不是某一个语法问题,而是状态在不同模块间不同步。大富翁这种规则明确、状态集中的项目,恰恰是训练“数据模型先行”思维的最佳练习场。你把棋盘、玩家、卡片这些实体定义清楚,所有逻辑都围绕数据流动来写,界面层再乱也能稳住。后续如果你想扩展,还可以加入房地产拍卖、玩家间交易、更多事件卡,甚至把它改成联机版本。这套基础结构都能托得住,因为它最核心的部分已经稳定了。

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

高效搜索API集成实践:查询参数调优、缓存降级与故障排查

很多做搜索功能的朋友&#xff0c;一开始最容易踩的坑就是“先抓数据再排序”&#xff0c;结果代码写了几百行&#xff0c;搜索框还是慢得像在爬。真正的搜索核心不是数据量&#xff0c;而是API怎么选、查询怎么构、结果怎么用。这篇博文我从需求拆解、API选型、参数调优、缓存…

作者头像 李华
网站建设 2026/10/22 19:42:15

MySQL LIKE查询加权排序实战:从权重设计到全文索引优化

1. 这个需求背后的真实场景1.1 什么时候需要给 LIKE 查询加权排序先说我自己的一个项目经历。某次给一个内容管理系统做搜索功能&#xff0c;表里存了几万条内容&#xff0c;每个内容有标题、摘要、正文、标签四个字段。业务方最开始只提了一个需求&#xff1a;用户在搜索框输入…

作者头像 李华
网站建设 2026/10/22 19:39:07

前端转Agent开发,先放下模型训练,这些工程技能才是核心

第一次被问到“Agent 开发是不是要训练模型”的时候&#xff0c;我愣了好几秒。问的人是个写了好几年前端的朋友&#xff0c;技术底子不差&#xff0c;React、Node.js 都玩得挺溜&#xff0c;突然想转来做 Agent&#xff0c;结果第一反应是“那我是不是得先去补深度学习”。我说…

作者头像 李华
网站建设 2026/10/22 19:38:14

大模型应用开发三层架构:输入层、处理层与输出层全解析

1. 为什么“三层架构”是理解大模型应用的万能钥匙 很多人第一次接触大模型应用开发时&#xff0c;会被各种新名词砸晕&#xff1a;RAG、Agent、Function Calling、思维链、多模态、工作流编排……每隔几周就冒出一个新概念&#xff0c;感觉永远追不完。但如果你把这些花样全部…

作者头像 李华
网站建设 2026/10/22 19:33:30

大数据查询优化:从字典到pandas DataFrame的实战迁移指南

1. 从字典到DataFrame&#xff1a;一次查询优化的真实触发场景 前阵子帮一个做电商数据分析的朋友处理订单数据&#xff0c;原始数据量大概在800万行左右&#xff0c;字段包括订单号、用户ID、商品SKU、下单时间、支付金额、收货城市等十几个维度。他最初的代码逻辑很直接&…

作者头像 李华
网站建设 2026/10/22 19:32:03

Unity中型项目工程化框架:资源管理、热更新回滚与线程安全实践

简介&#xff1a;这是一套面向Unity中高级开发者的完整框架工具集&#xff0c;聚焦游戏项目工业化开发中的核心痛点&#xff0c;涵盖UI系统扩展、热更新方案、资源生命周期管理、多线程安全调度及通用数据处理模块&#xff0c;显著提升团队协作效率与项目可维护性。压缩包共436…

作者头像 李华