基于Cocos2d-x的大富翁游戏开发:状态机与地图数据建模实践
简介基于Cocos2d的大富翁小游戏完整工程适合游戏开发学习者、课程设计使用者参考可解决回合制桌面游戏从界面搭建到玩法交互的完整实现需求。项目功能非常丰富包含开始/选择/设置界面、背景音乐、回合制回调嵌套、人物行走动画与沿路移动、地图拖拽/选点/视角跟随/小地图定位并内置AI玩家、旅店/街道/特殊房产、实体公司、命运与机会随机事件、29种道具、股市、房屋建造与升级、银行/医院及神灵附身等系统。压缩包共4102个文件以C头文件与源文件h/cpp为主配合png图集、plist动画配置、mp3音频以及Android/iOS构建相关文件整体约115.82MB目录结构完整便于按模块学习游戏架构与逻辑分层。已有1193人学习下载适合作为课程设计或游戏开发入门进阶的完整工程样例。1. 基于Cocos2d的大富翁小游戏不止是掷骰子和走格子大富翁Monopoly类游戏看着简单轮到你、掷骰子、走几步、买地、交租。但一旦动手用 Cocos2d 去实现你会发现“走格子”反而是最不重要的部分棋盘地图的数据结构、玩家回合的状态流转、地皮买卖与升级的数值体系、破产清算的判定顺序每一个模块都能让代码库膨胀到难以维护。本文要讲的不是美术怎么画而是把 Cocos2d-x 当作一个真正的游戏框架来用——场景管理、事件分发、动作系统、UI 层级——把一套多人回合制大富翁的骨架搭起来。适合已经写过几百行 Demo、想挑战完整小游戏闭环的客户端开发者如果你只关心引擎 API 怎么调可以直接跳到第 3 章和第 4 章看核心玩法循环与买卖缴租的实现。2. 选型和工程骨架为什么是 Cocos2d-x以及地图数据怎么建模2.1 为什么不用 Cocos Creator而是选择 Cocos2d-x现在的社区风气是“新项目一律 Creator”但 Cocos2d-x 在回合制棋盘游戏上依然有其价值纯 C/Lua 的代码逻辑便于做深度定制内存和渲染开销可控也不依赖编辑器导出的场景文件。我一般会选择 Cocos2d-x 3.17 以上版本配合 Lua 脚本把战斗和经济逻辑写在 Lua 里热更新和调参都方便。如果你更熟悉 C可以直接把下面所有的 Lua 代码等价改写成 C类的设计看成形即可。Cocos2d-x 提供的关键能力恰好覆盖大富翁的需求Scene管理场景切换Layer做 UI 分层Menu和Sprite负责棋盘绘制Action系统驱动棋子移动EventDispatcher处理点击与自定义事件。这套组合比单纯用 Canvas 手绘要省力得多也比 Creator 那种组件化拖拽更容易把控代码走向。2.2 棋盘地图的两种建模方式大富翁地图在 Cocos2d 里常见的做法有两种表格列举一下方案数据结构优点缺点网格数组用二维数组存储下标即格子坐标实现简单逻辑直观不规则地图表现力差路径点数组用有序数组存储格子中心点坐标贴合实际行走路线需要额外维护格子类型大富翁的棋盘通常是一个环形路径所以路径点数组是更自然的选择。每个格子只需要记录 WorldPosition世界坐标、GridType土地/机会/起点/监狱和 Owner当前归属就能满足所有逻辑。开局时把每个格子的中心坐标依次填入数组棋子的移动就变成了“在当前路径数组上做下标加减”。2.3 搭建最小 Cocos2d-x 工程假设你已经有 Cocos2d-x 3.17 的环境新建一个 Lua 工程后main.lua 里只需要把场景跑起来-- main.lua local function main() cc.Director:getInstance():getOpenGLView():setDesignResolutionSize(1280, 720, cc.ResolutionPolicy.SHOW_ALL) local scene require(GameScene):create() cc.Director:getInstance():runWithScene(scene) end local status, err xpcall(main, __G__TRACKBACK__) if not status then print(err) end这段代码先把屏幕设计分辨率固定为 1280x720保证在不同比例下棋盘不拉伸变形然后创建并运行 GameScene。SHOW_ALL策略会在屏幕宽高比不一致时留黑边对于棋盘类游戏完全够用。如果你的地图元件是美术出图建议把设计分辨率定成切图的基准尺寸避免后续坐标换算。GameScene.lua 里初始化棋盘-- GameScene.lua local GameScene class(GameScene, function() return cc.Scene:create() end) function GameScene:create() local scene GameScene.new() scene:init() return scene end function GameScene:init() self.mapSteps {} -- 路径点数组 self.players {} -- 玩家数据 self.currentPlayer 1 -- 加载棋盘地图数据 self:loadMapData(map_01.json) endloadMapData从 JSON 读取格子的坐标位置和类型依次创建 Sprite 并存入mapSteps。这里有一个容易踩的坑JSON 里的坐标是像素值而 Cocos2d 的坐标系原点在左下角如果你的切图在 Photoshop 里从左上角量坐标记得做 y 轴翻转y designHeight - y。3. 核心玩法循环摇骰子、按路径移动与回合状态机3.1 回合状态机的设计与状态枚举大富翁的每个回合可以拆成四个状态ROLL等待掷骰子、MOVE棋子移动中、SETTLE结算格子效果、WAIT回合结束等待。玩家必须在这四个状态间流转不能跳步。我一般用枚举加一个状态管理器来做避免在 UI 回调里到处写if判断-- TurnState.lua TurnState { ROLL 1, -- 等待玩家掷骰 MOVE 2, -- 棋子移动 SETTLE 3, -- 格子效果结算 WAIT 4 -- 回合结束等待下一玩家 }状态机的好处是当玩家连续点击骰子按钮时处于MOVE或SETTLE状态下直接忽略事件不会出现“一个人连续走两次”的脏逻辑。这也解决了新手最容易犯的错——在cc.Menu的回调里直接改玩家位置结果点击速度一快逻辑就乱套。3.2 摇骰子与步数判定掷骰子的实现本身没有难度难点在“摇出点数后如何表现”。我采用的做法是先做一次 0.5 秒的数值抖动动画再显示最终点数这样玩家有等待的预期。核心代码如下-- DiceManager.lua function DiceManager:roll(callback) local times 0 local maxTimes 5 local finalValue math.random(1, 6) local scheduler cc.Director:getInstance():getScheduler() local function updateDice() times times 1 local displayValue math.random(1, 6) self.diceLabel:setString(骰子: .. displayValue) if times maxTimes then scheduler:unscheduleScriptEntry(scheduleId) self.diceLabel:setString(骰子: .. finalValue) if callback then callback(finalValue) end end end local scheduleId scheduler:scheduleScriptFunc(updateDice, 0.1, false) endscheduler:scheduleScriptFunc每 0.1 秒执行一次 updateDice连续显示 5 个随机数后再定格到最终值。false表示不重复执行由内部手动 unschedule 掉。这里的关键参数是间隔时间和抖动次数间隔太短看不出来太长会让人烦躁0.1 秒加 5~6 次是稳妥的组合。3.3 棋子在地图上的移动实现拿到骰子点数后棋子的移动要平滑。如果你直接把坐标 setPosition玩家会看到瞬移彻底破坏体验。要使用cc.MoveTo配合数组来实现步进式移动-- PlayerUnit.lua function PlayerUnit:moveSteps(stepCount, callback) local map self.mapSteps local targetIndex self.currentIndex self.actions {} for i 1, stepCount do targetIndex targetIndex 1 if targetIndex #map then targetIndex 1 -- 超过终点回到起点大富翁的环形规则 end local targetPos map[targetIndex].position local move cc.MoveTo:create(0.3, targetPos) table.insert(self.actions, move) end local seq cc.Sequence:create(self.actions) local callFunc cc.CallFunc:create(function() self.currentIndex targetIndex if callback then callback(targetIndex) end end) self.node:runAction(cc.Sequence:create(seq, callFunc)) end每次移动一个格子0.3 秒的 MoveTo 连起来形成一个序列动作。targetIndex从当前格子往后加超过数组长度就回绕到 1。注意cc.Sequence的数组参数是可变参数如果你在 actions 里插入过多动作导致栈溢出改用cc.ArrayToCreate或者把移动步长加大都是可行的解法。移动结束后必须有回调通知状态机进入结算阶段否则操作会僵在MOVE状态玩家就无法进行下一轮操作。3.4 完整的回合流程控制回合流程的主控代码放在 GameScene 里类似这样-- GameScene 内部 function GameScene:onDiceClicked() if self.turnState ~ TurnState.ROLL then return end self.turnState TurnState.MOVE self.diceManager:roll(function(value) local player self.players[self.currentPlayer] player:moveSteps(value, function(targetIndex) self:settleGrid(targetIndex) end) end) end function GameScene:settleGrid(targetIndex) self.turnState TurnState.SETTLE local grid self.mapSteps[targetIndex] if grid.type GridType.PROPERTY then self:handleProperty(grid) -- 进入买卖或缴租逻辑 elseif grid.type GridType.TAX then self:handleTax(grid) elseif grid.type GridType.GO then self:handleBonus(grid) end -- 结算完切到 WAIT等待玩家点击“结束回合” self.turnState TurnState.WAIT self:enableEndTurnButton(true) endsettleGrid是整局游戏的中枢无论是买地、缴租还是触发随机事件都在这里根据格子类型分发。把状态机流转和业务逻辑分开后新增一种格子类型时不需要动主流程代码只需要在settleGrid里添加一个分支可维护性立刻上来了。4. 买卖与缴租地块资产系统的数据结构和数值校验4.1 地块属性数据结构大富翁的核心爽点在于买地、升级、收租。每个地块格子需要记录以下信息-- PropertyData.lua PropertyData { gridId 1, -- 格子编号 name 长安街, -- 地块名 price 2000, -- 购买价格 level 0, -- 等级 0~3 baseRent 200, -- 基础租金 ownerId 0, -- 拥有者玩家ID0为无主 upgradeCost 1000, -- 每次升级费用 posIndex 1 -- 在路径数组中的下标 }这里建议把“格子编号 gridId”和“路径下标 posIndex”分开。gridId 是稳定不变的唯一标识posIndex 可能因为地图改版而变化。如果后续要支持地图编辑器、存档恢复用 gridId 做引用更安全用 posIndex 可能出现读档后指向错误格子的数据错乱。4.2 购买与升级的完整判定流程当棋子停留在一个无主地块上时给出购买或弃购选项-- SettlementManager.lua function SettlementManager:handlePurchase(player, property, onComplete) if player.money property.price then self:showToast(资金不足无法购买) onComplete(false) return end local confirm cc.MessageBox:create(是否购买 .. property.name .. 价格 .. property.price, 购买提示) -- 由于 MessageBox 是阻塞式实际开发建议使用弹出层 UI这里用回调模拟 self:showPurchaseDialog(player, property, function(isBuy) if isBuy then player.money player.money - property.price property.ownerId player.id player.properties[#player.properties 1] property.gridId self:updatePlayerMoneyUI(player) self:updateGridColorUI(property) end onComplete(isBuy) end) end注意购买成功后必须做三件事扣钱、改归属、更新 UI。很多新手只扣钱不改 ownerId或者改了数据不刷新地皮颜色导致玩家看到自己站着的格子颜色没变困惑不已。updateGridColorUI用玩家对应的颜色给格子染色是最直观的归属反馈。升级逻辑同理校验玩家财富与现有等级function SettlementManager:handleUpgrade(player, property) if property.ownerId ~ player.id then self:showToast(只能升级自己的地块) return end if property.level 3 then self:showToast(地块已满级) return end local cost property.upgradeCost * (property.level 1) if player.money cost then self:showToast(资金不足) return end player.money player.money - cost property.level property.level 1 property.upgradeCost cost -- 下次升级费用重新计算 -- 刷新地块 UI例如加一颗星或升高建筑 self:updateGridLevelUI(property) end升级费用采用cost * (level 1)的递增公式保证后期资金有消耗出口。如果你希望经济节奏更紧把系数从 1 提到 1.5 或 2 即可这个参数建议放到配置文件里方便策划调优。4.3 缴租与破产清算的边界情况停留在他人的地块上时按等级计算租金function SettlementManager:handlePayRent(player, property) if property.ownerId 0 or property.ownerId player.id then return end local rent property.baseRent * (property.level 1) player.money player.money - rent local owner self.players[property.ownerId] owner.money owner.money rent if player.money 0 then self:handleBankruptcy(player) end self:updatePlayerMoneyUI(player) self:updatePlayerMoneyUI(owner) end破产清算有个常见的边界情况如果缴租后玩家资金变为负数直接判负会把局面变得太残酷。我会在handleBankruptcy里先检查该玩家是否有可抵押的地块如果有自动出售一块换钱这样游戏节奏更友好。只有所有资产都卖光仍为负数才真正淘汰。4.4 金钱变动用事件而非直接调用在整个经济系统里最忌讳的就是到处直接修改player.money。耦合会迅速失控尤其当多个系统UI、音效、成就、AI都要响应金钱变化时直接赋值就是灾难。建议做一个简易的事件分发-- EventBus.lua EventBus { listeners {} } function EventBus:on(event, callback) if not self.listeners[event] then self.listeners[event] {} end table.insert(self.listeners[event], callback) end function EventBus:emit(event, ...) if not self.listeners[event] then return end for _, cb in ipairs(self.listeners[event]) do cb(...) end end所有玩家金钱变动的地方统一调用EventBus:emit(PLAYER_MONEY_CHANGED, playerId, newMoney)UI 层、任务系统、音效各自监听这个事件。新增需求时不再需要去代码里找所有改钱的地方。5. 破产处理、AI托管与界面表现层的优化5.1 破产判定后的资产转移当玩家破产时除了移除该玩家还需要处理其所有地块的归属。简单方案是地块全部置为无主但更常见的是按比例拍卖或直接归银行。如果地块直接归银行棋盘很快就会变成“无主之地”互动性下降。更好的做法是让剩余玩家可以低价收购这里抛出一个回调function SettlementManager:handleBankruptcy(player) self:removePlayerFromMap(player) -- 遍历该玩家的所有地块 for _, gridId in ipairs(player.properties) do local property self.propertyMap[gridId] property.ownerId 0 self:showToast(property.name .. 已变为无主地块) end -- 通知 UI 层移除玩家的棋子与角色面板 EventBus:emit(PLAYER_BANKRUPT, player.id) end要注意循环遍历player.properties时不要原地删除表元素可以先浅拷贝数组再遍历或者采用标记删除。Cocos2d-x 的 Lua 表在做table.remove时会移动后续元素容易导致漏处理。5.2 支持 AI 托管回合单人通关或调试时其他人可以交给 AI 托管。AI 的决策逻辑不一定要多么智能但必须有可配置的参数。最简单的做法是写一个策略函数根据资金水平判断买不买地-- AIPlayer.lua function AIPlayer:decidePurchase(property) -- 简单策略资金充足且有超过两个未开发地块时购买 if self.money property.price * 2 then return true end if self.money property.price and self.propertyCount 2 then return true end return false endAI 决策加一个人工延迟模拟思考过程避免一瞬间完成所有操作。我一般加 0.8 秒的延迟再执行购买或弃购配合棋子的移动动画观感会比较自然。延迟用performWithDelay实现-- GameScene.lua function GameScene:scheduleAIHander(player) local delay cc.DelayTime:create(0.8) local call cc.CallFunc:create(function() self:aiDoNextAction(player) end) self:runAction(cc.Sequence:create(delay, call)) end5.3 UI 表现层需要隐藏的状态信息大富翁这类游戏的信息密集度很高每个玩家的余额、地块数量、当前位置、持有的道具、当前回合的骰子点数。直接在棋盘中堆 UI 会显得杂乱。我推荐采用“底栏信息面板 格子信息卡片”的双层结构底栏展示四个玩家的当前余额和地块总数点击任意一位玩家弹出详情面板玩家踩到地块时从屏幕右下角弹出该地块的购买/升级/缴租提示卡片。这里cc.LayerColor是一个被低估的组件给卡片加一层半透明底能有效提升文字可读性成本比加载一张九宫格背景图低很多。用代码实现-- UIFactory.lua local bg cc.LayerColor:create(cc.c4b(0, 0, 0, 180)) bg:setContentSize(cc.size(320, 180)) bg:setPosition(cc.p(30, 130)) self:addChild(bg)半透明黑色背景配合白色文字在任何棋盘色块上都不会产生阅读障碍。5.4 动画与动作的叠加顺序走格子的移动动画和骰子动画之间有一个隐藏的坑如果你用runAction让节点播放移动动画又在同一节点立即runAction另一个缩放动画Cocos2d-x 默认规则是会让多个动作并行执行但cc.MoveTo和cc.ScaleTo由不同的属性控制并行没问题如果是两个cc.MoveTo并行后者会覆盖前者的位置目标造成棋子“瞬移”。因此移动相关的动作必须放在同一个cc.Sequence里或者用cc.StopAllActionsByTag清理掉上一个动作。给每个动作打标签是很实用的习惯local move cc.MoveTo:create(0.3, pos) move:setTag(1001) node:runAction(move)清理时调用node:stopActionByTag(1001)即可。尤其在玩家快速点击“跳过动画”时这种清理能避免多个移动动作叠加。6. 存档断点续玩与多局测试的场景复用大富翁的地图状态变化非常多玩家在进行到中段时退出游戏再做一次cc.Director:getInstance():endToLua()或 app 切后台每次都要重新开局会让人失去耐心。存档机制其实不复杂关键在存哪些数据必须保存地图上每个格子的 ownerId 和 level、哪个玩家持有多少现金、当前轮到谁、当前状态机处于哪个阶段。这三类数据直接决定游戏的还原程度。-- SaveManager.lua function SaveManager:saveGame(filename) local data { mapState self:dumpMapState(), players self:dumpPlayers(), turnState self.turnState, currentPlayer self.currentPlayer, roundCount self.roundCount } local path cc.FileUtils:getInstance():getWritablePath() .. filename local jsonStr json.encode(data) local file io.open(path, w) if file then file:write(jsonStr) file:close() end end function SaveManager:dumpMapState() local mapState {} for _, property in ipairs(self.propertyMap) do mapState[property.gridId] { ownerId property.ownerId, level property.level } end return mapState end读档时反过来把 JSON 数据逐个回填到对应属性再根据turnState恢复按钮的可用性。一个容易出的问题如果存档发生在SETTLE状态下而结算逻辑已经被跳过读档后玩家就会被卡住。我建议只在WAIT状态允许存档或者在读档时强行把状态重置为ROLL并让当前玩家重掷这样能规避逻辑漏洞。测试阶段我有一个实用技巧在调试模式下按住“胜利”按钮直接加钱或者用命令设置步数固定值这样能快速验证某个地段的购买流程而不用反复掷骰子赌运气。用调试标记在代码里做个分支即可发布时通过宏开关关掉。-- GameScene.lua 调试辅助 if G_DEBUG then self.diceManager:forceValue(6) endforceValue会把随机结果强制为 6方便测试“走满一圈回到起点”的环形逻辑。这样做的收益非常明显大富翁的核心循环从开局到第一次破产可能要 20 分钟固化了测试变量后针对某个格子的验证只需要几秒。最终整个项目在 Cocos2d-x 上跑通时你会意识到大富翁小游戏的复杂度并不在引擎的某个难点上而在“如何用清晰的状态机把分散的地图数据、玩家数据、经济系统串起来”。模块之间靠事件通讯而非直接调用的约束、存档时对回合状态机的精确捕获决定了这个项目是从工程角度可维护的成品而不是一个能跑却一改就崩的教学玩具。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →