背包斗斗棋 - 技术架构文档分享

05/2970 浏览开发心得
虽然小破游凉了,还是搞个文档记录下项目架构。
本文档记录项目核心技术设计决策与实现模式,用于技术分享和日后项目参考。项目类型:回合制棋盘对战游戏(UrhoX 引擎 + Lua + NanoVG 全 2D 渲染)
网络模式:PVP 常驻服 persistent_world + PVE 纯客户端 AI
代码规模:~65 个 Lua 源文件,~25,000 行
一、整体架构
1.1 运行模式三路由
main.lua:487-506 — 游戏启动时根据 GetRuntimeMode() 返回值分流到三种运行模式:lua
复制local runtimeMode = GetRuntimeMode and GetRuntimeMode() or "standalone"
if runtimeMode == "server" then
    Module = require("network.Server")
    Module.Start()
elseif runtimeMode == "client" then
    Module = require("network.Client")
    -- ... 初始化完整 UI 管线
else
    Module = require("network.Standalone")
    -- ... 初始化完整 UI 管线
end
模式加载模块特点servernetwork/Server.lua无 UI、无渲染,纯逻辑+通信clientnetwork/Client.lua完整 UI + 远程事件通信standalonenetwork/Standalone.lua完整 UI + 本地 AI,无网络
设计意图:服务端极简启动,不加载任何 UI/渲染代码,减少内存占用和潜在崩溃点。客户端和 Standalone 共享完整 UI 管线,通过统一 Module 接口屏蔽差异。
1.2 Module 接口模式Client.lua 和 Standalone.lua 暴露相同的 API 表面,UI 层通过 Module 变量调用而无需感知当前是 PVP 还是 PVE:lua
复制-- UI 层统一调用方式(不关心当前模式)
Module.PlayerAttack(col, row)
Module.EndTurn()
Module.CastPieceSkill(pieceIdx, targetCol, targetRow)
local game = Module.GetGame()
好处
  • 新增 UI 功能时不需要写 if isPvp then ... else ... end 分支
  • 切换模式只需替换 Module 引用,所有回调链自动重新绑定
  • 测试时可以单独运行 Standalone 模式验证 UI,无需联网
  • config 不依赖任何其他层
  • game 只依赖 config
  • network 依赖 config + game
  • ui 可依赖所有层,但通过回调/接口注入减少硬依赖
二、多人对战服务器
2.1 常驻服 persistent_world 模式
不同于房间制(match_info)每局创建/销毁实例,本项目使用 persistent_world 模式:
  • 服务器长期运行,玩家随进随出
  • 单个实例支持最多 60 个并发连接(max_players: 60)
  • 多对局在同一实例内通过 Room 对象隔离
  • 服务器长期运行,玩家随进随出
  • 单个实例支持最多 60 个并发连接(max_players: 60)
  • 多对局在同一实例内通过 Room 对象隔离
  • 棋盘对战游戏对局时间短(3-10 分钟),频繁创建/销毁实例开销大
  • 需要维护在线人数统计、匹配队列等跨对局状态
  • 服务端需要持续运行 serverCloud 异步回调
function Room.new(globalSlotA, globalSlotB)
    local self = setmetatable({}, Room)
    self.id         = roomIdCounter
    self.phase      = "IDLE"      -- IDLE → WAITING_PLACEMENT → DICE → BATTLE → GAME_OVER
    self.game       = nil         -- Rules 实例
    self.connSlots  = { globalSlotA, globalSlotB }  -- roomSlot → globalSlot 映射
    self.phaseTimer = 0
    self.destroyed  = false
    return self
end
network/Server.lua:36-52 — 双映射实现 O(1) 查找:lua
复制local S = {
    players_    = {},   -- [1..60] 全局连接池(固定槽位)
    connToIdx_  = {},   -- connKey → 槽位索引
    matchQueue_ = {},   -- connKey → true(等待匹配)
    rooms_      = {},   -- roomId → Room 对象
    connToRoom_ = {},   -- connKey → roomId(玩家→所在房间)
}
事件路由:所有对局事件通过 GetRoomContext(eventData) 辅助函数定位到正确的 Room + 槽位,避免全局状态混乱。
2.3 60 连接池
S.players_[1..60] 为固定大小数组,每个槽位存储玩家信息:lua
复制S.players_[slot] = {
    conn       = connection,      -- 网络连接对象
    connKey    = connKey,         -- 连接唯一标识
    userId     = userId,          -- TapTap 用户 ID
    nickname   = nickname,        -- 昵称
    lastHeartbeat = os.clock(),   -- 最后心跳时间
    masterDefIndex = 1,           -- 棋手配置
    masterLevel = 0,
    pieceSlots = {},              -- 棋子配置
}
新连接到达时分配空闲槽位,断开时释放。S.connToIdx_[connKey] 提供 O(1) 反向查找。
2.4 幽灵连接检测
防止客户端异常退出(崩溃/网络断开)导致槽位永久占用:lua
复制-- 客户端:每 5 秒发送心跳
local HEARTBEAT_INTERVAL = 5.0  -- Client.lua:88
-- 服务端:每 5 秒扫描一次,超过 15 秒无心跳判定为幽灵
local HEARTBEAT_TIMEOUT = 15    -- Server.lua:584-639
S.staleCheckTimer_ = S.staleCheckTimer_ + dt
if S.staleCheckTimer_ >= 5.0 then
    for idx = 1, MAX_CONN_SLOTS do
        local p = S.players_[idx]
        if p and (now - p.lastHeartbeat) > HEARTBEAT_TIMEOUT then
            -- 按房间阶段分级处理:
            -- BATTLE → 强制投降
            -- WAITING_PLACEMENT / DICE → 销毁房间
            -- 无房间 → 仅清理槽位
        end
    end
end
2.5 同账号顶号重连
同一 userId 二次连接时,踢除旧连接释放槽位:lua
复制-- KickOldConnection 逻辑:
-- 1. 遍历 S.players_ 查找同 userId 的旧连接
-- 2. 发送 KICK 事件通知旧客户端
-- 3. 清理旧连接的 connToIdx_ / matchQueue_ / connToRoom_
-- 4. kickedConns_[oldConnKey] = true,标记已踢
-- 5. HandleClientDisconnected 检测到 kickedConns_ 时跳过重复处理
为什么需要 kickedConns_:踢除旧连接后,引擎仍会触发 ClientDisconnected 事件。不标记的话会导致重复清理房间状态。
2.6 协议版本校验防止客户端/服务端代码版本不一致导致通信异常:lua
复制-- Settings.lua
PROTOCOL_VERSION = 1  -- 不兼容协议变更时递增
-- Client.lua:196-202 — 连接后立即发送
local vm = VariantMap()
vm["ProtocolVersion"] = Variant(Settings.PROTOCOL_VERSION)
network.serverConnection:SendRemoteEvent(EVENTS.VERSION_CHECK, true, vm)
-- ServerEventHandlers.lua — 校验不通过时:
-- 1. 发送 VERSION_MISMATCH 事件(携带服务端版本号)
-- 2. 清理槽位
-- 3. 延迟 1 秒断开(确保客户端收到提示)
延迟断开的必要性:如果立即断开,客户端可能还未处理 VERSION_MISMATCH 事件,用户看不到"请更新版本"的提示。
2.7 在线人数统计Server.lua:569-580 — 使用覆写模式而非增减计数:lua
复制-- 每 10 秒覆写真实在线人数
local ONLINE_SYNC_INTERVAL = 10
S.onlineSyncTimer_ = S.onlineSyncTimer_ + dt
if S.onlineSyncTimer_ >= ONLINE_SYNC_INTERVAL then
    S.onlineSyncTimer_ = 0
    local localCount = GetLocalPlayerCount()
    serverCloud:Set(0, Settings.CloudKeys.PVP_ONLINE_COUNT, localCount, {...})
end
为什么不用 Add(+1/-1)
  • 服务端崩溃时 -1 操作不会执行,导致计数永久漂移
  • 覆写模式(Set)保证最终一致性——即使崩溃重启,下一次 Set 会修正
三、网络通信协议
3.1 纯远程事件通信
本项目不使用 Urho3D 的 REPLICATED 节点同步机制,所有同步走 VariantMap 远程事件:lua
复制-- 发送
local vm = VariantMap()
vm["MyBoard"] = Variant(jsonData)
conn:SendRemoteEvent(EVENTS.BOARD_UPDATE, true, vm)
-- 接收
SubscribeToEvent(EVENTS.BOARD_UPDATE, HandleBoardUpdate)
function HandleBoardUpdate(eventType, eventData)
    local data = Shared.JsonDecode(eventData["Data"]:GetString())
end
选择原因
  • 棋盘游戏状态离散(格子状态、回合数),非连续物理模拟,不需要帧同步
  • 远程事件更灵活,可以精确控制发送内容和时机
  • 避免 Scene Replication 的复杂性(节点所有权、插值等)
复制-- Server 端 SendBoardUpdate:
-- 对玩家 A:MyBoard = A 的完整视图(含棋子位置),EnemyBoard = B 的隐藏视图(仅命中/未命中标记)
-- 对玩家 B:MyBoard = B 的完整视图,EnemyBoard = A 的隐藏视图
关键安全原则:客户端永远看不到对手棋子的实际位置,直到命中或击沉。
3.3 ServerToLocal / LocalToServer 视角映射
服务端使用绝对槽位(1 和 2),客户端始终视自己为"玩家 1":lua
复制-- 服务端内部:slot=1 是先匹配的玩家,slot=2 是后匹配的
-- 客户端 Client.lua:
-- C.mySlot = 从 GAME_START 事件获取的 1 或 2
-- 但 UI 层始终把自己当作"下方玩家"渲染
3.4 投降超时兜底
lua
复制-- Client.lua 发送投降后启动 5s 定时器:
C.surrenderTimeout_ = 5.0
-- 如果 5s 内未收到服务端 GAME_OVER 确认,客户端强制本地结算
-- 防止网络延迟或服务端异常导致客户端卡死在对战界面
四、云存储体系
4.1 三路径存储模式
Growth(养成数据)支持三种存储路径,按优先级自动选择:lua
复制-- Growth.lua:536-556
function Growth.Save()
    lastSaveTime_ = os.clock()
    data_._version = (data_._version or 0) + 1
    -- 路径 3(始终执行):本地文件兜底
    SaveLocal()
    if remoteInterface_ and remoteInterface_.save then
        -- 路径 1(最高优先级):通过 remoteInterface 中继到 serverCloud
        pendingSaves_[data_._version] = true
        remoteInterface_.save(data_)
    elseif clientCloud then
        -- 路径 2:直接写 clientCloud(Standalone 模式)
        clientCloud:Set(cloudKey, data_, {...})
    end
end
路径适用场景存储位置特点remoteInterfacePVP 常驻服模式serverCloud(服务端)可做服务端校验clientCloudStandalone PVE引擎云存储简单直接,无服务端本地 File所有模式设备文件系统离线兜底,防数据丢失
4.2 remoteInterface 注入模式
main.lua:744-795 — 在常驻服模式下为各模块注入 {load, save} 接口:lua
复制Growth.SetRemoteInterface({
    load = function(onDone) ClientModule_.LoadGrowth(onDone) end,
    save = function(data)   ClientModule_.SaveGrowth(data)   end,
})
UserPrefs.SetRemoteInterface({...})
PlacementStorage.SetRemoteInterface({...})
LeaderboardPopup.SetRemoteInterface(function(key, count, onDone)
    ClientModule_.LoadLeaderboard(key, count, onDone)
end)
设计优点
  • 模块本身不感知网络细节,只知道"有个地方能存/取数据"
  • 切换存储后端(serverCloud / clientCloud / mock)只需替换注入的接口
  • 单元测试时可以注入 mock 接口验证逻辑
复制-- ApplyCloudData 被调用时(云端数据到达):
local localVersion = data_._version or 0
local cloudVersion = cloudData._version or 0
if lastSaveTime_ > 0 then
    -- 本地有未同步的保存操作
    if localVersion > cloudVersion then
        -- 本地数据更新 → 保留本地,回传云端
        remoteInterface_.save(data_)
        return  -- 不覆盖本地
    end
end
-- 否则用云端数据覆盖本地
典型场景
  1. 用户看广告 → Growth.Save() → _version 递增为 5
  2. 网络断开 → 重连后云端推送旧数据(_version = 4)
  3. 检测到 localVersion(5) > cloudVersion(4) → 保留本地,回传云端
复制local lastSaveTime_ = 0  -- Growth.lua:54
-- Save 时标记:
lastSaveTime_ = os.clock()
-- ApplyCloudData 检查:
if lastSaveTime_ > 0 then
    -- 用户刚保存过,跳过云端覆盖
end
防止以下竞态:Save 请求发出 → 云端异步回调旧数据到达 → 错误覆盖刚保存的数据。
4.5 Save ACK + 自动重试
lua
复制-- pendingSaves_[version] = true(Save 时标记)
-- 收到服务端 ACK 后移除对应 version
-- 如果超时未收到 ACK → 自动重试最多 2 次
-- 重试耗尽 → 通知 UI 显示保存失败提示
五、服务端反作弊
5.1 ValidateTransition
9 规则ServerCloudHandlers.lua:396-425 — 保存养成数据前对比旧值与新值:lua
复制local function ValidateTransition(oldData, newData)
    -- ① 金币增量上限(防止篡改加币)
    if goldDelta > GOLD_MAX_INCREASE_PER_SAVE then return false end
    -- ② 碎片增量上限(每种碎片独立检查)
    if shardDelta > SHARD_MAX_INCREASE_PER_SAVE then return false end
    -- ③ 等级不回退(不能降级)
    if newLevel < oldLevel then return false end
    -- ④ 等级不跳级(单次最多 +1)
    if newLevel - oldLevel > 1 then return false end
    -- ⑤ 解锁列表单调递增(不能反解锁)
    -- ⑥ 体力恢复时间校验(恢复速率不超过设计值)
    -- ⑦ 时间戳不超未来(lastStaminaTime 不能在未来)
    -- ⑧ 签到天数单调递增
    -- ⑨ _version 单调递增(防回滚攻击)
    return true
end
设计哲学:“宽松边界校验”——不追求绝对精确,而是拦截明显异常。正常游玩永远不会触发这些限制,但篡改数据包时必然超出阈值。
5.2 Read-Before-Write
lua
复制-- ServerCloudHandlers.lua HandleSaveGrowth:
-- 1. serverCloud:Get(userId, "growth_data")  -- 读旧数据
-- 2. ValidateTransition(oldData, newData)     -- 对比校验
-- 3. 通过后才 serverCloud:Set(...)           -- 写入新数据
5.3 兑换码原子性
lua
复制-- HandleRedeemCode:
-- 1. 读取已兑换列表 → serverCloud:Get(userId, "redeemed_codes")
-- 2. 检查是否已兑换过此码
-- 3. 验证码是否有效(服务端硬编码码表,客户端不可见)
-- 4. 追加到已兑换列表 → serverCloud:Set(...)
-- 5. 返回奖励数据给客户端
兑换码表仅存在于服务端代码中,客户端无法枚举。
5.4 调试工具白名单
lua
复制-- ServerCloudHandlers.lua:
local DEBUG_ALLOWED_USERS = { [xxxxxxxx] = true, [xxxxxxxx] = true }
function HandleDebugTool(eventType, eventData)
    local userId = ...
    if not DEBUG_ALLOWED_USERS[userId] then
        return  -- 静默拒绝
    end
    -- pcall 包裹执行,防止调试命令崩溃服务端
    local ok, err = pcall(executeDebugCommand, ...)
end
双层安全:客户端 SHOW_GM 控制面板可见性(防误触),服务端白名单校验权限(防破解客户端)。
六、排行榜系统
6.1 3 路并行异步合并
ServerCloudHandlers.lua:722-810 — 同时发起三个独立请求,用计数器合并:lua
复制local doneCount = 0
local totalCalls = 3
local function trySendResult(
)
    doneCount = doneCount + 1
    if doneCount < totalCalls then return end
    -- 全部完成,发送合并结果给客户端
    conn:SendRemoteEvent(EVENTS.LEADERBOARD_DATA, true, vm)
end
-- 请求 1:排行榜列表
serverCloud:GetRankList(key, count, {
    ok = function(list) result.list = list; trySendResult(
) end
})
-- 请求 2:我的排名
serverCloud:GetRank(key, userId, {
    ok = function(rank) result.myRank = rank; trySendResult(
) end
})
-- 请求 3:我的积分 + 总人数
serverCloud:GetScore(key, userId, {
    ok = function(score, total) result.myScore = score; result.total = total; trySendResult(
) end
})
模式优点:三个请求无依赖关系,并行执行总耗时 = max(单次) 而非 sum(三次)。
6.2 BatchGet 昵称
排行榜返回 userId 列表后,用 serverCloud:BatchGet 批量查询昵称:lua
复制-- 排行榜列表返回后:
local userIds = {}
for _, entry in ipairs(list) do table.insert(userIds, entry.userId) end
serverCloud:BatchGet(userIds, "player_nickname", {
    ok = function(nickMap)
        for _, entry in ipairs(list) do
            entry.nickname = nickMap[entry.userId] or "未知"
        end
        trySendResult()
    end
})
为什么不在排行榜接口中直接返回昵称:persistent_world 模式下 GetUserNickname 仅限当前服务器在线玩家,离线玩家查不到。通过 serverCloud 持久化昵称解决。
6.3 双积分架构
积分类型存储位置写入方安全性PVE 积分serverCloud客户端可写ValidateTransition delta 校验(0~30)PVP 积分serverCloud服务端原子操作serverCloud:Add 仅服务端可调用PVP 积分由服务端在对局结算时直接 Add,客户端无法篡改。PVE 积分允许客户端写入但有范围校验。
七、养成系统架构
7.1 Growth + GrowthActivities 拆分game/Growth.lua          (~920 行) — 核心状态管理(货币/等级/存储/加载/校验)
game/GrowthActivities.lua (~400 行) — 活动操作(体力/签到/每日任务/商店/广告/里程碑)
通过 Init() 注入内部引用,避免循环依赖:lua
复制-- GrowthActivities.lua:
function GrowthActivities.Init(growthRef)
    growth_ = growthRef  -- 保存 Growth 模块引用
end
-- Growth.lua:
GrowthActivities.Init(Growth)
7.2 每日重置机制lua
复制-- Growth 内部:
local function checkDailyReset()
    local today = os.date("%Y-%m-%d")
    if data_.dailyDate ~= today then
        -- 重置每日任务进度
        data_.dailyPveWins = 0
        data_.dailyPvpWins = 0
        data_.dailyPveRewards = {}
        data_.dailyPvpRewards = {}
        data_.dailyDate = today
    end
    if data_.shopDate ~= today then
        -- 重置商店已购状态
        data_.shopDate = today
    end
    if data_.adDate ~= today then
        -- 重置广告次数
        data_.adCount = 0
        data_.adDate = today
    end
end
在每次 Growth.LoadFromCloud 完成和 Growth.Update 调用时检查。
7.3 SyncLevelsToSettingsGrowth 作为等级数据的权威来源,变更后同步到 Settings 表确保全局一致:lua
复制-- Growth 内部保存/加载后:
function syncLevelsToSettings()
    for i, master in ipairs(Settings.PlayerMasters) do
        master.level = data_.masterLevels[i] or 0
    end
    for i, piece in ipairs(Settings.PlayerPieces) do
        piece.level = data_.pieceLevels[i] or 0
    end
end
7.4 onChange 事件驱动lua
复制-- Growth.lua:
local onChange_ = nil
function Growth.SetOnChange(fn) onChange_ = fn end
-- 数据变更后触发:
local function notifyChange()
    if onChange_ then onChange_() end
end
-- main.lua 注册监听:
Growth.SetOnChange(function()
    UserPrefs.ValidateUnlocks()  -- 重校验装备解锁
    RedDot.MarkDirty()           -- 红点系统刷新
    MainMenu.RefreshCurrency()   -- 货币栏刷新
end)
八、渲染与适配
8.1 NanoVG 全 2D 渲染管线
整个游戏画面(棋盘、UI、动画、特效)全部使用 NanoVG 矢量渲染,不使用引擎 UI 组件或 3D 场景:lua
复制function HandleNanoVGRender(eventType, eventData)
    nvgBeginFrame(ctx, logicalW, logicalH, dpr)
    nvgScale(ctx, scale_, scale_)
    -- 绘制背景 → 棋盘 → 棋子 → 动画 → UI 覆盖层 → 过渡遮罩
    DrawBackground()
    currentScreen_.Draw(ctx)    -- 当前页面渲染
    DrawToast(ctx)              -- 全局 Toast
    DrawFadeOverlay(ctx)        -- 过渡动画遮罩
    nvgEndFrame(ctx)
end
选择原因
  • 棋盘游戏图形简单,不需要 3D/粒子系统
  • NanoVG 矢量绘制在各分辨率下清晰锐利
  • 完全掌控渲染顺序,无层级冲突问题
  • 水墨淡彩美术风格适合矢量 + 纹理混合渲染
复制local DESIGN_W = 1080  -- 设计基准宽度
function RecalcLayout()
    local physW, physH = graphics:GetWidth(), graphics:GetHeight()
    local dpr = graphics:GetDPR()
    logicalW = physW / dpr
    logicalH = physH / dpr
    scale_ = logicalW / DESIGN_W
    designH_ = logicalH / scale_  -- 当前设备的"设计高度"(可变)
end
设备逻辑分辨率scale_设计空间iPhone 15393×8520.3641080×2341iPad Pro1024×13660.9481080×1441PC 1920×10801920×10801.7781080×607所有 UI 元素按 1080 宽度设计坐标绘制,nvgScale(ctx, scale_, scale_) 自动缩放到实际屏幕。
8.3 FadeToScreen 过渡系统main.lua:100-170 — 页面切换时的纸张暖白遮罩动画:lua
复制local FADE_DURATION  = 0.35      -- 单程时间
local FADE_COLOR     = {242, 238, 232}  -- 纸张暖白
local fadeState_     = "none"    -- "none" | "out" | "in"
local fadeAlpha_     = 0         -- 0–255
function FadeToScreen(switchFn)
    if fadeState_ ~= "none" then switchFn(); return end
    fadePendingFn_ = switchFn
    fadeState_ = "out"
    fadeTimer_ = 0
end
-- 状态机:
-- "out" → alpha 从 0 渐增到 255(Hermite 缓动 t²(3-2t))
-- 到达 255 → 执行 switchFn()(切换页面)→ 进入 "in"
-- "in" → alpha 从 255 渐减到 0
-- 到达 0 → fadeState_ = "none"
8.4 nvgBeginFrame 缓存尺寸main.lua:413-422 — 防止键盘弹出时闪烁:lua
复制-- 不实时读取视口:
-- ❌ nvgBeginFrame(ctx, graphics:GetWidth()/dpr, graphics:GetHeight()/dpr, dpr)
-- 使用缓存值(仅 RecalcLayout 时更新):
-- ✅ nvgBeginFrame(ctx, logicalW, logicalH, dpr)
问题根因:系统软键盘弹出时,视口高度瞬间变化。如果每帧实时读取,连续两帧的 nvgBeginFrame 参数不同,导致 NanoVG 内部状态混乱产生黑屏闪烁。使用缓存值保证帧间一致。
九、其他值得记录的模式
9.1 PlacementStorage 数据校验lua
复制-- 加载布置缓存后验证一致性:
function PlacementStorage.IsDataValid(data)
    if not data or not data.pieceIds then return false end
    -- 检查缓存的棋子 ID 列表是否匹配当前出战配置
    local activeIds = Settings.ActivePieceIndices
    if #data.pieceIds ~= #activeIds then return false end
    for i, id in ipairs(data.pieceIds) do
        if id ~= activeIds[i] then return false end
    end
    return true
end
-- 不匹配(用户换了出战棋子)→ 重新生成布置
9.2 PVP 布置倒计时竞态处理
角色超时时间行为客户端25 秒调用 DoConfirm()(保存布置+标记已确认)服务端30 秒随机布置 + 强制进入掷骰阶段5 秒安全缓冲确保客户端先于服务端超时。如果服务端先超时(极端网络延迟),客户端在 onDiceResult 中检测到未确认布置时,用 PlacementStorage.BuildRandom() 随机布置兜底。
9.3 延迟弹出结算lua
复制-- 收到 GAME_OVER 时不立即弹窗:
pendingGameOver_ = { timer = 0.5, data = resultData }
-- 每帧检查:
if pendingGameOver_ then
    pendingGameOver_.timer = pendingGameOver_.timer - dt
    if pendingGameOver_.timer <= 0 and not BattleView.IsNotifyBusy() then
        -- 所有播报/通知播完,安全弹出结算弹窗
        GameOverPopup.Show(pendingGameOver_.data)
        pendingGameOver_ = nil
    end
end
BattleView.IsNotifyBusy() 三层检查:
  1. 通知队列是否有待播放项
  2. 击沉播报是否正在播放
  3. 技能播报是否正在播放
6
4
2