很多人觉得大富翁(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 的每一步选择,极大提高测试覆盖率。
最后一个优化技巧:给棋盘加一个“重置按钮”。每次跑完一局测试,我直接点重置回到初始状态,不需要刷新页面。这个功能在你反复调试购买、破产、入狱等流程时,简直救命。
我个人实际开发中的体会是,写这类游戏项目最容易让人崩溃的从来不是某一个语法问题,而是状态在不同模块间不同步。大富翁这种规则明确、状态集中的项目,恰恰是训练“数据模型先行”思维的最佳练习场。你把棋盘、玩家、卡片这些实体定义清楚,所有逻辑都围绕数据流动来写,界面层再乱也能稳住。后续如果你想扩展,还可以加入房地产拍卖、玩家间交易、更多事件卡,甚至把它改成联机版本。这套基础结构都能托得住,因为它最核心的部分已经稳定了。