☰
Cocos2d-x三消游戏:8×8棋盘与连锁结算实现
2026/11/2 2:13:39 网站建设 项目流程

简介:基于 cocos2d-x 2.2.6 的糖果消除游戏工程,面向正在学习三消机制与 2D 游戏开发流程的初学者、独立开发者及课程设计者,可用于对照理解棋盘布局、相邻糖果交换、同色连通判定、消除动画、计分规则和关卡状态管理等模块的组织方式,并借助完整工程在本地编译、运行与调试。压缩包共 43 个文件,约 3.46MB,主体为 cpp 与 h 源码、png 图片、ttf 字体、plist 配置,同时带有 dll、exe 运行依赖和 sln、vcxproj 等 Windows 工程文件,目录覆盖类实现、资源与构建配置,方便查看场景、层和精灵之间的调用关系。已有 210 人学习,说明其作为三消入门参考具有一定认可度;工程把棋盘、糖果元素、消除流程与静态数据配置拆成多个类,读者可据此梳理游戏循环、节点层次和资源加载过程。对希望从可运行源码中理解三消规则落地,并继续尝试特殊糖果、道具系统或关卡编辑器等扩展方向的读者,具有直接参照价值。

1. 三消的实现难点不在糖果,在结算

三消游戏的代码量看着小,真正写起来才知道难的不是画糖果,而是把「一次交换」后面跟着的连锁反应算干净。这套基于 cocos2d-x 的 candy crush 类三消实现,核心就三块:一张 8×8 的纯逻辑棋盘、一套只认方向不认距离的触摸判定、以及一个能把「消除→下落→补充→再检测」串成闭环的结算流程。它解决的是新手拿着引擎不知道从哪下手的问题——场景树、Sprite、触摸事件、Action 回调这些零件,怎么拼成一个能连续玩几千步逻辑都不错位的三消游戏。适合已经能跑通 cocos2d-x 空工程、想照着做出第一个完整玩法原型的开发者,也适合写过一版但总卡在「连锁结算完,棋盘数据和画面上的糖果对不上」的人回头检查结构。

2. 棋盘与坐标:把 8×8 网格落进 Node 树

2.1 逻辑层和表现层必须先把边界划清楚

我见过的三消 demo 挂掉,八成不是算法写错,而是逻辑和节点混在一起:数组里存的是 Sprite,Sprite 的位置又被当成逻辑坐标去反查,最后一旦有动画延迟,两边立刻分叉。动画在跑,数据已经改完,玩家手快再点一下,画面和数据就彻底对不上了。

所以棋盘要做减法:逻辑层只认type和col/row,表现层只负责把col/row换算成坐标并播放动画。任何时刻,节点的真实位置都不参与判断,判断只读数组。这条规则听起来像废话,但它决定了你后面写回滚逻辑时是十分钟还是三天。

// Board.h —— 只保留棋盘的核心字段 #include "cocos2d.h" constexpr int kCols = 8; constexpr int kRows = 8; constexpr int kTypes = 6; // 糖果种类数,直接决定难度曲线 struct Cell { int type = -1; // -1 表示空位,0..kTypes-1 为有效糖果 cocos2d::Sprite* node = nullptr; // 只用于渲染,不参与任何逻辑判断 }; class Board { public: void init(cocos2d::Node* parent, const cocos2d::Size& area); void resetBoard(); // 生成无初始三连的盘面 cocos2d::Vec2 positionOf(int col, int row) const; // 格子 -> 世界坐标 bool cellFromWorld(const cocos2d::Vec2& p, int& col, int& row) const; private: Cell _cells[kCols][kRows]; cocos2d::Node* _root = nullptr; // 所有糖果 Sprite 的父节点 float _cellSize = 0.f; cocos2d::Vec2 _origin; // 棋盘左下角在世界坐标里的位置 };

_cellSize和_origin是这套实现的基准。棋盘尺寸固定为 8×8,但设备分辨率千奇百怪,硬编码像素值必然在某个机型上错位。常见做法是用一块给定的矩形区域,取长宽两个方向能容纳的较小值,再把棋盘整体居中,让不同尺寸的屏幕上糖果仍然保持正方形、不拉伸。

2.2 坐标换算:正着算格子,反着算点击

正向换算(格子到坐标)决定渲染位置,反向换算(坐标到格子)决定点击命中,两者必须是同一套参数的互逆运算,否则就会出现「看得见、点不着」或者「点着了隔壁」。

cocos2d::Vec2 Board::positionOf(int col, int row) const { // 加 0.5 是因为 Sprite 默认锚点是 (0.5, 0.5),返回的应当是格子中心 return _origin + cocos2d::Vec2((col + 0.5f) * _cellSize, (row + 0.5f) * _cellSize); } bool Board::cellFromWorld(const cocos2d::Vec2& p, int& col, int& row) const { const float x = p.x - _origin.x; const float y = p.y - _origin.y; if (x < 0.f || y < 0.f) return false; // 落在棋盘左侧/下方之外 col = static_cast<int>(x / _cellSize); row = static_cast<int>(y / _cellSize); return col >= 0 && col < kCols && row >= 0 && row < kRows; }

参数说明只有两点值得反复提醒:_cellSize一定要是浮点,整数除法带来的累积误差在 8 格上还看不出来,到了 10 格以上就会让最右边一列偏出半个像素;_origin只在初始化时算一次并缓存,不要在每次换算里重算布局,否则镜头稍微一动你就得排查半天为什么点击偏移了一格。

一个容易忽略的坑:触摸事件给到的是 UI 坐标,而positionOf返回的是父节点坐标系里的坐标。如果棋盘节点本身被移动过(比如做了个入场动画往下掉了一段),就必须先convertToNodeSpaceAR转换一次,再去调cellFromWorld。我一般会在cellFromWorld的调用点统一做这次转换,而不是在函数内部猜调用者是谁。

2.3 初始盘面:宁可多摇几次随机,也别让玩家开局白嫖

很多 demo 的初始盘面就是rand() % kTypes一把梭,运气好的时候开局直接一排三个同色,玩家还没操作就已经自动消了一轮——这既掉分也掉帧,还会让第一次连锁的分数异常高,看起来像 bug。正确的做法是在生成时就做前置校验。

// 判断在 (col,row) 放 type 会不会立刻构成三连 // 只检查左侧和下方,因为生成顺序是从左到右、从下到上,这两个方向已经填好了 bool Board::createsMatchAt(int col, int row, int type) const { if (col >= 2 && _cells[col - 1][row].type == type && _cells[col - 2][row].type == type) return true; if (row >= 2 && _cells[col][row - 1].type == type && _cells[col][row - 2].type == type) return true; return false; } void Board::resetBoard() { for (int col = 0; col < kCols; ++col) { for (int row = 0; row < kRows; ++row) { int type = 0; int guard = 0; do { type = rand() % kTypes; } while (createsMatchAt(col, row, type) && ++guard < 32); _cells[col][row].type = type; } } }

注意那个guard计数器。理论上kTypes >= 3时循环必然能退出,但实际调试中如果你把种类数临时改成 2 做测试,createsMatchAt会一直返回 true,代码就在这里原地打转,表现为「点了开始按钮然后画面卡死」。这种偶发的、只在测试参数下出现的卡死最难定位,加个上限然后退化成随机放置,比事后排查便宜得多。

提示:开局生成完之后,立刻调一次findMatches()并断言结果为空。这个断言在开发期会直接告诉你棋盘生成逻辑是否可靠,比上线后玩家反馈「第一关一进来就自动消除」要好处理得多。

3. 触摸与交换:滑动阈值、邻接校验与回弹

3.1 单点监听加绝对方向,别去算角度

三消的输入判定其实是「方向识别」而不是「距离识别」。玩家从一格滑到相邻格,你需要知道的只是他往哪滑,不是滑了多远。用atan2算角度再映射到四个方向,代码又长又容易在 45° 边界上抖,直接比较|dx|和|dy|就够了。

auto listener = cocos2d::EventListenerTouchOneByOne::create(); listener->setSwallowTouches(true); // 必须吞掉,否则底层节点也会收到同一份事件 listener->onTouchBegan = [this](cocos2d::Touch* t, cocos2d::Event*) { if (_locked) return false; // 结算期间不接受新输入 int col = 0, row = 0; if (!cellFromWorld(t->getLocation(), col, row)) return false; _startPoint = t->getLocation(); _selCol = col; _selRow = row; return true; }; listener->onTouchEnded = [this](cocos2d::Touch* t, cocos2d::Event*) { if (_locked) return; const cocos2d::Vec2 delta = t->getLocation() - _startPoint; // 位移小于阈值视为「点选」,走两步点击的选中逻辑 if (delta.length() < _cellSize * 0.35f) { handleTapSelection(_selCol, _selRow); return; } int dc = 0, dr = 0; if (std::fabs(delta.x) > std::fabs(delta.y)) { dc = delta.x > 0.f ? 1 : -1; // 横向滑动 } else { dr = delta.y > 0.f ? 1 : -1; // 纵向滑动 } trySwap(_selCol, _selRow, _selCol + dc, _selRow + dr); }; _eventDispatcher->addEventListenerWithSceneGraphPriority(listener, _root);

这里的0.35f是手感系数,不是随手写的魔法数。太小(比如 0.15)会导致玩家点选时手指轻微抖动就被判成滑动,出现「我只是想点一下,糖果自己换了」;太大(比如 0.7)会让短距离滑动失灵,玩家得划半屏才生效。一格边长的三分之一到四成之间,是手指在这个格子尺寸下的舒适区。setSwallowTouches(true)同样是血泪项,不吞事件的话,棋盘底下的 UI 层会重复收到点击,表现为「点一次触发两个功能」。

3.2 交换的三种结局:成立、回弹、丢弃

一次交换请求到达后,只有三种结果,必须全部显式处理,一种都不能留给「应该不会发生」:

  • 目标格越界(滑到棋盘外)→ 直接丢弃,不播动画。
  • 交换后任意一端形成三连 → 提交交换,进入结算。
  • 交换后没有三连 → 逻辑换回去,并播回弹动画。

第三种是最容易被写漏的:如果只在数据层换回去而节点不跟着动,玩家就会看到糖果停在错误的位置上,而且这个错位会一直累积。正确的顺序是「先换数据 → 播动画 → 判定不成立 → 数据换回 → 再播一次动画」。

void Board::trySwap(int c0, int r0, int c1, int r1) { if (!inBounds(c1, r1)) return; // 越界直接丢弃 _locked = true; // 上锁,防止动画期间重复输入 swapData(c0, r0, c1, r1); // 只交换 type 和 node 指针 playSwapAnim(c0, r0, c1, r1, [this, c0, r0, c1, r1]() { if (!hasMatchAt(c0, r0) && !hasMatchAt(c1, r1)) { swapData(c0, r0, c1, r1); // 数据换回,保证逻辑一致 playSwapAnim(c0, r0, c1, r1, [this]() { _locked = false; // 回弹结束才解锁 }); return; } resolveBoard(); // 进入结算,解锁由结算流程负责 }); }

swapData同时交换type和node指针,这样在用(col,row)索引节点时永远拿得到正确的那一个。而playSwapAnim只需要把两个节点各自MoveTo到对方格子的中心,动画结束后节点的位置就和新的col/row天然对齐了,不需要额外同步。

void Board::playSwapAnim(int c0, int r0, int c1, int r1, const std::function<void()>& done) { auto* a = _cells[c0][r0].node; auto* b = _cells[c1][r1].node; if (!a || !b) { if (done) done(); return; } _animCount += 2; // 计数锁:两个节点都到位才算完成 auto finish = [this, done]() { if (--_animCount == 0 && done) done(); }; const float dur = 0.12f; // 交换时长,见第 6 章的参数表 a->runAction(cocos2d::Sequence::create( cocos2d::MoveTo::create(dur, positionOf(c1, r1)), cocos2d::CallFunc::create(finish), nullptr)); b->runAction(cocos2d::Sequence::create( cocos2d::MoveTo::create(dur, positionOf(c0, r0)), cocos2d::CallFunc::create(finish), nullptr)); }

_animCount这种计数锁是我更推荐的写法。你当然可以只给其中一个节点挂CallFunc然后当作两个都完成了,但只要两个动作的时长因为插值类型不同差了一帧,回调就会先于另一个动作触发,表现为「有时候回弹少了一个糖果」。用计数器把「全部动画完成」这件事显式表达出来,比赌两边的时序要稳。

4. 匹配与结算:一次交换引发的连锁闭环

4.1 三连检测:两次扫描加一次去重

检测本身不难,难在十字交叉。一个格子被横向三连和纵向三连同时命中时,如果你把两次扫描的结果直接拼进一个 vector,这个位置就会被消两次——分数翻倍、节点被removeFromParent两次、后续索引越界。所以扫描结果一定要按(col,row)去重。

std::set<int> Board::findMatches() const { std::set<int> hits; // key = row * kCols + col,天然去重 // 横向扫描 for (int row = 0; row < kRows; ++row) { int run = 1; for (int col = 1; col <= kCols; ++col) { const bool same = (col < kCols) && _cells[col][row].type != -1 && _cells[col][row].type == _cells[col - 1][row].type; if (same) { ++run; continue; } if (run >= 3) { for (int k = col - run; k < col; ++k) hits.insert(row * kCols + k); } run = 1; } } // 纵向扫描,结构与上面完全同构,只把 col/row 互换,此处略 return hits; }

用std::set<int>而不是std::set<std::pair<int,int>>,是因为整数 key 在合并和打印日志时更方便,一次key / kCols和key % kCols就能还原坐标。run的初值必须是 1 而不是 0,循环变量必须能走到kCols(也就是多跑一次空转),否则最右侧那一列的三连永远检测不出来——这个 off-by-one 我第一次写的时候完美踩中,表现是「从右往左数的三连消不掉」,排查了两个小时。

4.2 下落与补充:用写指针压缩,不要整体下移

消除之后让上方糖果落下来,最直观的写法是逐个MoveTo再更新数组,但那是表现层的活。逻辑层只需要一个「写指针」把非空单元格压到底部,剩下的位置填新糖果。这样一次遍历就能同时完成压缩和补位。

bool Board::applyGravity() { bool moved = false; for (int col = 0; col < kCols; ++col) { int write = 0; // 下一个可写入的行号(从底部开始) for (int row = 0; row < kRows; ++row) { if (_cells[col][row].type == -1) continue; // 空位跳过 if (write != row) { _cells[col][write] = _cells[col][row]; _cells[col][row].type = -1; _cells[col][row].node = nullptr; moved = true; } ++write; } for (int row = write; row < kRows; ++row) { // 顶部补新糖 _cells[col][row].type = nextRandomType(); moved = true; } } return moved; }

参数上唯一的决定项是补充的糖果从哪来。直接rand()是最省事的写法,但它会让连锁的期望长度变得不可控;如果你要做关卡难度曲线,常见做法是挂一个「权重队列」:低分关卡只出常见色,高分关卡拉高稀有色的出现概率,让四连、五连更容易凑出来。这套实现里先保留nextRandomType()这个接口,后面要接权重表只改这一处。

4.3 结算循环:递归回调比 while 循环更适合带动画的流程

逻辑上,结算就是个while (有匹配) { 消、落、补 }。但一旦每一步都要等动画播完,while就不好使了——你没法在while里「暂停」。常见做法是把循环写成递归回调:一轮结算结束后,延迟一小段时间再调自己,直到没有匹配为止。

void Board::resolveBoard() { _locked = true; // 整个结算期间锁住输入 resolveStep(1); } void Board::resolveStep(int chain) { const auto hits = findMatches(); if (hits.empty()) { // 终止条件:没有匹配 _locked = false; // 唯一一处解锁 ensureSolvable(); // 顺带检查是否还有可行步 return; } addScore(static_cast<int>(hits.size()), chain); // chain 越大,单颗分值越高 playClearAnim(hits, [this, chain]() { applyGravity(); syncView(); // 数据 -> 节点位置 runAction(cocos2d::Sequence::create( cocos2d::DelayTime::create(0.08f), // 给玩家看清是哪几个消了 cocos2d::CallFunc::create([this, chain]() { resolveStep(chain + 1); // 进入下一轮连锁 }), nullptr)); }); }

这段代码里有三个关键决策。第一,chain从 1 开始计,每轮 +1,直接作为连击倍率的输入,玩家能感知到「刚才一口气消了四轮」的爽感。第二,_locked的解锁位置只有终止分支一处,绝不在中间步骤里解锁,否则玩家在连锁中途再划一下,就会有两套结算同时在跑同一张棋盘——这类 bug 表现为「消着消着多出来一堆糖果然后卡死」,非常难复现。第三,DelayTime不是为了好看,而是给玩家的视觉一个呼吸窗口;没有它,四轮连锁会在 0.4 秒内播完,玩家根本不知道自己得了多少分。

syncView()的作用是把数组状态刷到节点上:遍历所有格子,把node的位置设成positionOf(col,row),空位就把节点移出场景。它是幂等的,调多少次结果都一样,所以可以放心在每个结算步之后无条件调用一次。

void Board::syncView() { for (int col = 0; col < kCols; ++col) { for (int row = 0; row < kRows; ++row) { auto& cell = _cells[col][row]; if (cell.type == -1 || !cell.node) continue; // 已经在目标位置的节点会得到一次无位移的 MoveTo,开销可以忽略 cell.node->setPosition(positionOf(col, row)); cell.node->setLocalZOrder(row); // 越靠上的糖果盖住下面的 } } }

setLocalZOrder(row)这行看着不起眼,但少了它,糖果下落时会互相穿透重叠,视觉上像是「两个糖叠在一起」。用行号当 z 序,天然保证下方糖果在上面糖果之下。

5. 避坑排查:这套代码里最容易翻车的五个点

5.1 触控与坐标相关的三处坑

现象一:点击位置整体偏右偏上一格。原因基本是坐标系搞混了——_origin是用父节点坐标系算的,而触摸事件给的是 UI 世界坐标,中间少了一次convertToNodeSpaceAR。如果棋盘节点本身有位置偏移(比如被放在了一个居中的层里),这个差量会被完整地叠加到点击结果上。解决方式是统一入口:所有cellFromWorld的调用前先转一次坐标系,并且只转一次。我一般会在触摸回调的开头就转好,后面全部用节点坐标,不再混用。

现象二:换了之后偶尔「回不去」,糖果停在错误位置但数据是对的。原因是在回弹路径里只调了swapData没有重新触发动画,或者反过来只播了动画没换数据。这两件事必须成对出现,且顺序固定:换数据 → 播动画 → 换回数据 → 播回弹。我的做法是把「交换 + 动画」封成一个函数,回弹就是再调一次同一个函数,用同一段代码走两遍,避免两条路径的写法不一致。

现象三:快速连划时会出现两个糖果同时消除,但玩家只划了一下。原因是_locked只在trySwap开头置 true,没覆盖到「回弹动画播完」这个时间窗口。回弹期间_locked一旦被提前置回 false,第二次输入就能插进来。解决办法是让_locked的生命周期覆盖整个操作链:置位在trySwap入口,复位只在结算终止分支和回弹完成回调这两处,别的地方一律不碰。

5.2 结算与性能相关的两处坑

现象四:十字交叉位置消除后,分数比预期高出一截。就是第 4 章提到的重复计数问题——横向扫描和纵向扫描各自把交叉点收进了结果集,没有去重。表现上除了分数异常,还可能触发「同一个节点被移除两次」,在部分平台上直接崩溃。用std::set<int>而不是std::vector<Vec2>收结果,这个问题从数据结构层面就不存在了。

现象五:连消到第五六轮时明显掉帧,低端机上甚至会卡住半秒。原因通常有两个:一是每轮补充新糖果时都new Sprite()并加载贴图,频繁创建销毁;二是把findMatches挂在了update里每帧执行。前者用对象池解决——预先创建kCols * kRows * 2个 Sprite 挂在池子里,消除时setVisible(false)回收,补充时取出来复用,只改贴图和位置。后者是设计问题:匹配检测应该是事件驱动的,只在一次交换或一轮消除之后调用一次,绝不能每帧跑。

注意:对象池的回收和取用一定要在同一线程内完成,并且回收时把node从父节点摘掉或隐藏,否则池子里的幽灵节点会继续收到 z 序排序和触摸检测,出现「看不见但能点」的诡异现象。

6. 手感与验证:从能玩推到好玩的两步

6.1 参数不是调出来的,是量出来的

逻辑正确只保证能玩,手感决定愿不愿意继续玩。下面这张表是我在这套实现里反复调过、最后稳定下来的一组值,可以直接拿去当起点,再按自己的美术节奏微调。

参数建议值作用与调整方向
滑动判定阈值0.35 ×_cellSize越低越灵敏,低于 0.2 会误触;高于 0.5 会滑不动
交换动画时长0.10 ~ 0.14 s太快看不清换了什么,太慢拖节奏
消除缩放1.0 → 1.25 → 0.0先放大再缩没,比直接消失更有打击感
消除后下落延迟0.06 ~ 0.10 s让玩家看清是哪几个被消掉的
下落动画缓动EaseIn+ 0.16 s模拟自由落体,匀速会很「塑料」
连锁间隔0.08 s给连击留呼吸,同时保持节奏
连击倍率1 + 0.5 × (chain - 1)四连时单颗分值 2.5 倍,够刺激但不崩平衡

消除动作我用的是Spawn组合:一个ScaleTo放大加一个FadeOut,两个同时跑,比Sequence的串行观感更利落。这里有个小技巧——把ScaleTo的目标值设成 1.25 而不是 2.0,是因为超过 1.5 之后相邻格子的糖果在视觉上会挤在一起,玩家会误以为消除范围变大了。

void Board::playClearAnim(const std::set<int>& hits, const std::function<void()>& done) { if (hits.empty()) { if (done) done(); return; } _animCount += static_cast<int>(hits.size()); auto finish = [this, done]() { if (--_animCount == 0 && done) done(); }; for (int key : hits) { const int col = key % kCols; const int row = key / kCols; auto* node = _cells[col][row].node; if (!node) { finish(); continue; } node->runAction(cocos2d::Sequence::create( cocos2d::Spawn::create( cocos2d::ScaleTo::create(0.10f, 1.25f), cocos2d::FadeOut::create(0.10f), nullptr), cocos2d::CallFunc::create([this, col, row, finish]() { _cells[col][row].node->setVisible(false); // 回收进池,不销毁 _cells[col][row].node = nullptr; finish(); }), nullptr)); } }

注意这里同样用了_animCount计数锁。一批消除可能有 3 个节点,也可能有 15 个,只有全部播完才能进下一轮下落。如果用「随便挑一个节点挂回调」的写法,节点多的时候你会看到糖果在半空中就开始往下掉。

6.2 用固定种子回放,把偶发 bug 变成必现 bug

连锁类 bug 最恶心的地方是不可复现——玩家反馈「消到第三轮之后盘面乱了」,你本地跑一百次都正常。根因几乎都是随机数不可控:初始盘面用了rand(),补充新糖果也用了rand(),两处随机一叠加,出问题的盘面状态你根本摇不出来。

我的做法是把所有随机来源收敛到一个可设种子的生成器里,运行时把种子和玩家的每一步操作(起点格、方向)记成日志。线上出问题时,把种子和操作序列贴回来,本地重放就能精确复现那一刻的棋盘。

class ReplayRng { public: explicit ReplayRng(uint32_t seed) : _s(seed ? seed : 1u) {} int nextType(int n) { _s ^= _s << 13; // xorshift32,够快且不依赖标准库实现 _s ^= _s >> 17; _s ^= _s << 5; return static_cast<int>(_s % static_cast<uint32_t>(n)); } uint32_t seed() const { return _s; } private: uint32_t _s; };

把resetBoard和applyGravity里的rand()全部换成_rng.nextType(kTypes),然后在启动时打印种子。重放时用同一个种子构造ReplayRng,按日志顺序调trySwap,中间不做任何插帧,直接同步驱动结算(把动画时长临时设为 0,或者在无渲染模式下跑)。这样跑一遍,出问题的盘面状态会精确重现,你就能在syncView()前后各打一次全盘 dump,看出到底是哪一格先错位的。

再加一个开发期断言:每次resolveStep进入时,检查数组里有没有type != -1但node == nullptr的格子。正常状态下这种格子只可能出现在消除动画进行中,如果它出现在结算入口,说明有一轮补充漏掉了。这个断言帮我抓出过一次「对象池取用完但没有补新节点」的问题,否则那批糖果会变成彻底透明的空位,玩家点上去毫无反应,只能重开。

这套东西我是吃过亏才补上的。早前有个版本的盘面错位问题,只在特定的连锁顺序下出现,靠人工点了几百局都没抓住,最后是靠固定种子回放,把那次交换的前后状态完整 dump 出来,才发现是去重用了 vector、交叉点被消了两次导致的。从那以后我每次写这类消除逻辑,都会先把种子和操作日志这两件事做进去,再动手写玩法本身——后来的复盘成本,远比一开始多写二十行日志便宜。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询