便签整体链路
07/055 浏览
截图左上角的便签不是单独页面元素,而是排行榜弹窗打开时,作为背景层叠在遮罩上的“战绩便签墙”。
链路是:
- 服务端在真人对局结束后生成玩家便签
- 服务端从 serverCloud 拉取便签数据
- 客户端收到排行榜数据时一起收到 wallNotes
- main.lua 合并到本地缓存
- LobbyScreens.lua 把便签渲染到排行榜遮罩背景上


1. 客户端便签缓存
scripts/main.lua:48
lualocal wallCache_ = { version = 1, lastWallTime = 0, notes = {} } local wallNotes_ = {}
这里有两个层次:
- wallCache_:本地持久缓存,按 userId 存每个玩家最新便签
- wallNotes_:用于 UI 展示的数组,会按时间排序


2. 本地缓存文件路径
scripts/main.lua:55
lualocal WALL_CACHE_PATH = "bagua_wall_cache.json"
便签会缓存在本地 bagua_wall_cache.json,避免每次打开都完全空白。


3. 重建 UI 用的便签数组
scripts/main.lua:284
lualocal function RebuildWallNotes() wallNotes_ = {} for _, note in pairs(wallCache_.notes or {}) do wallNotes_[#wallNotes_ + 1] = note end table.sort(wallNotes_, function(a, b) return (a.time or 0) > (b.time or 0) end) end
作用:
- 从 wallCache_.notes 里取出所有便签
- 转成数组
- 按时间倒序排列


4. 读取本地便签缓存
scripts/main.lua:291
lualocal function LoadWallCache() if not fileSystem or not fileSystem:FileExists(WALL_CACHE_PATH) then RebuildWallNotes() return end local file = File(WALL_CACHE_PATH, FILE_READ) if not file or not file:IsOpen() then return end local text = file:ReadString() file:Close() local ok, data = pcall(function() return cjson.decode(text or "") end) if ok and type(data) == "table" then wallCache_.version = data.version or 1 wallCache_.lastWallTime = data.lastWallTime or 0 wallCache_.notes = type(data.notes) == "table" and data.notes or {} end RebuildWallNotes() end
游戏启动时会调用它,先把之前缓存的便签读回来。
调用位置在 scripts/main.lua:1698:
luaLoadWallCache()


5. 保存本地便签缓存
scripts/main.lua:311
lualocal function SaveWallCache() wallCache_.version = 1 wallCache_.notes = wallCache_.notes or {} local ok, text = pcall(function() return cjson.encode(wallCache_) end) if not ok or not text then return end local file = File(WALL_CACHE_PATH, FILE_WRITE) if file and file:IsOpen() then file:WriteString(text) file:Close() end end
只要收到新的便签,就会写回本地。


6. 合并服务器下发的便签
scripts/main.lua:326
lualocal function MergeWallNotes(notes, syncedTo) wallCache_.notes = wallCache_.notes or {} local changed = false for _, note in ipairs(notes or {}) do local userId = tostring(note.userId or "") if userId ~= "" and note.message and note.message ~= "" then local old = wallCache_.notes[userId] if old == nil or (note.time or 0) >= (old.time or 0) then wallCache_.notes[userId] = note changed = true elseif IsMeaningfulPlayerName(note.nickname) and not IsMeaningfulPlayerName(old.nickname) then old.nickname = note.nickname changed = true end if (note.time or 0) > (wallCache_.lastWallTime or 0) then wallCache_.lastWallTime = note.time changed = true end end end if syncedTo and syncedTo > (wallCache_.lastWallTime or 0) then wallCache_.lastWallTime = syncedTo changed = true end if changed then SaveWallCache() end RebuildWallNotes() end
关键逻辑:
- 用 userId 去重
- 只保留每个玩家最新一条便签
- message 为空的不展示
- 收到新数据后保存本地缓存
- 最后重建 wallNotes_


7. 排行榜更新时合并便签
scripts/main.lua:443
lualocal function OnLeaderboardUpdated(leaderboard) leaderboard_ = leaderboard or { entries = {}, myRank = 0, myWins = 0, myGames = 0 } MergeWallNotes(leaderboard_.wallNotes, leaderboard_.wallSyncedTo) if leaderboard_.wallNextStart and leaderboard_.wallNextStart > 0 then LobbyClient.RequestLeaderboard(wallSyncBaseTime_, leaderboard_.wallNextStart) return end leaderboardLoading_ = false if game_.screen == "menu" then CreateUI() end LobbyScreens.SetStatus("排行榜已更新。") end
排行榜返回时,便签数据也会一起返回。
如果 wallNextStart > 0,说明服务端还有下一页便签,客户端继续请求。


8. 把便签传给大厅 UI
scripts/main.lua:1634
luauiRoot_ = LobbyScreens.Main({ rooms = roomList_, connected = lobbyConnected_, playerName = GetPlayerName(), setPlayerName = SetPlayerName, startAI = StartAIBattle, createRoom = RequestCreateRoom, showJoin = ShowJoinRoomScreen, joinRoom = JoinRoomFromCard, refreshRooms = RequestRoomList, showLeaderboard = ShowLeaderboard, closeLeaderboard = CloseLeaderboard, leaderboard = leaderboard_, wallNotes = wallNotes_, leaderboardOpen = leaderboardOpen_, leaderboardLoading = leaderboardLoading_, exitGame = ExitGame, })
这里的关键字段是:
luawallNotes = wallNotes_,
也就是说,便签不是排行榜面板自己拉数据,而是 main.lua 把整理好的 wallNotes_ 传进去。


UI 便签渲染关键代码
9. 便签槽位
scripts/ui/LobbyScreens.lua:145
lualocal NOTE_SLOTS = { { left = "3%", top = "5%", w = 138, h = 94 }, { left = "21%", top = "7%", w = 118, h = 82 }, { left = "4%", top = "29%", w = 128, h = 86 }, { left = "9%", top = "53%", w = 170, h = 88 }, { left = "5%", top = "73%", w = 145, h = 96 }, { left = "23%", top = "77%", w = 96, h = 72 }, { right = "8%", top = "8%", w = 168, h = 82 }, { right = "3%", top = "32%", w = 154, h = 88 }, { right = "4%", top = "56%", w = 166, h = 82 }, { right = "10%", top = "72%", w = 134, h = 90 }, }
截图左上角红框里的便签,大概率就是第一个槽位:
lua{ left = "3%", top = "5%", w = 138, h = 94 }
所以它出现在屏幕左上侧。


10. 便签颜色池
scripts/ui/LobbyScreens.lua:134
lualocal NOTE_COLORS = { { 255, 244, 177, 235 }, { 255, 221, 177, 235 }, { 222, 244, 190, 235 }, { 255, 214, 221, 235 }, { 213, 235, 255, 235 }, }
截图里的浅蓝便签,对应这里的:
lua{ 213, 235, 255, 235 }


11. 稳定随机 hash
scripts/ui/LobbyScreens.lua:160
lualocal function NoteHash(note, salt) local text = tostring(note.userId or "") .. tostring(note.time or 0) .. tostring(salt or "") local value = 0 for i = 1, #text do value = (value * 33 + string.byte(text, i)) % 2147483647 end return value end
这个函数用于让每张便签的:
- 颜色
- 偏移
- 旋转角度
- 胶带旋转角度
- 排列顺序
看起来随机,但对同一条便签保持稳定。


12. 选择便签展示顺序
scripts/ui/LobbyScreens.lua:167
lualocal function PickNotesForWall(notes) local picked = {} for _, note in ipairs(notes or {}) do picked[#picked + 1] = note end table.sort(picked, function(a, b) return NoteHash(a, "slot") < NoteHash(b, "slot") end) return picked end
这里没有按时间展示,而是按 hash 排列。
效果是:便签墙的位置更稳定,不会每次刷新都大幅跳动。


13. 创建单张便签
scripts/ui/LobbyScreens.lua:175
lualocal function CreateWallNote(note, slot, index) local color = NOTE_COLORS[(NoteHash(note, "color") % #NOTE_COLORS) + 1] local jitterX = (NoteHash(note, "x") % 45) - 22 local jitterY = (NoteHash(note, "y") % 35) - 17 local rotate = (NoteHash(note, "r") % 25) - 12 local tapeRotate = (NoteHash(note, "tape") % 11) - 5 return UI.Panel { position = "absolute", left = slot.left, right = slot.right, top = slot.top, width = slot.w, height = slot.h, padding = { 12, 10, 8, 10 }, gap = 4, translateX = jitterX, translateY = jitterY, rotate = rotate, transformOrigin = "center", backgroundColor = color, borderRadius = 8, borderWidth = 1, borderColor = { 112, 78, 45, 120 }, boxShadow = { { x = 4, y = 5, blur = 0, color = { 58, 37, 20, 95 } }, { x = -1, y = -1, blur = 0, color = { 255, 255, 230, 85 } }, }, pointerEvents = "none", children = { ... }, } end
这一段决定了便签的视觉效果:
- position = "absolute":绝对定位
- translateX / translateY:随机抖动
- rotate:轻微旋转
- backgroundColor:随机颜色
- boxShadow:纸片阴影
- pointerEvents = "none":不拦截点击


14. 便签上方胶带
scripts/ui/LobbyScreens.lua:201
luaUI.Panel { position = "absolute", left = "34%", top = -8, width = 44, height = 15, rotate = tapeRotate, transformOrigin = "center", backgroundColor = { 245, 222, 169, 165 }, borderRadius = 4, borderWidth = 1, borderColor = { 116, 84, 48, 80 }, pointerEvents = "none", },
截图里便签顶部那条浅黄色胶带,就是这段代码画出来的。


15. 便签文字内容
scripts/ui/LobbyScreens.lua:216
luaH.Text(note.nickname or "玩家", 11, C.ink, { width = "100%", textAlign = "left" }), H.Text(note.message or "再来一题", 12, C.ink, { width = "100%", flexGrow = 1, flexShrink = 1, textAlign = "center" }), H.Text(note.timeText or "", 9, C.inkSoft, { width = "100%", textAlign = "right" }),
截图里的三行:
text耳内有灰 新手上桌 07-05 13:00
对应:
luanote.nickname note.message note.timeText


16. 创建便签墙层
scripts/ui/LobbyScreens.lua:226
lualocal function CreateWallNotesLayer(callbacks) local notes = PickNotesForWall(callbacks.wallNotes or {}) local children = {} for i, note in ipairs(notes) do local slot = NOTE_SLOTS[((i - 1) % #NOTE_SLOTS) + 1] children[#children + 1] = CreateWallNote(note, slot, i) end return UI.Panel { position = "absolute", left = 0, right = 0, top = 0, bottom = 0, pointerEvents = "none", children = children, } end
这一层铺满屏幕,然后把每张便签放到预设槽位中。
注意:
lualocal slot = NOTE_SLOTS[((i - 1) % #NOTE_SLOTS) + 1]
如果便签超过 10 张,会循环使用槽位。


17. 便签层挂在排行榜遮罩里
scripts/ui/LobbyScreens.lua:296
luareturn UI.Panel { visible = callbacks.leaderboardOpen == true, position = "absolute", left = 0, right = 0, top = 0, bottom = 0, justifyContent = "center", alignItems = "center", backgroundColor = { 0, 0, 0, 140 }, pointerEvents = "auto", children = { CreateWallNotesLayer(callbacks), H.Frame { width = "86%", maxWidth = 460, padding = { 20, 18, 18, 18 }, gap = 9, alignItems = "center", backgroundColor = C.bgTop, children = rows, }, }, }
重点是这里:
luachildren = { CreateWallNotesLayer(callbacks), H.Frame { ... }, }
便签层在前,排行榜面板在后。
因此视觉上是:
- 黑色半透明遮罩
- 背景便签
- 中央排行榜面板
这正是截图里的效果。


服务端生成便签关键代码
18. 便签云端 key
scripts/network/Server.lua:21
lualocal CLOUD_KEY_WALL_TIME = "bagua_wall_time" local CLOUD_KEY_WALL_MSG = "bagua_wall_msg" local CLOUD_KEY_WALL_TIME_TEXT = "bagua_wall_time_text"
服务端把便签拆成三个云端字段:
- 更新时间
- 文案
- 显示时间文本


19. 根据战绩挑一句便签文案
scripts/network/Server.lua:700
lualocal function PickWallMessage(wins, games) wins = wins or 0 games = games or 0 local pool if games <= 0 then pool = { "第一题还没开", "等一个对手", "刚进大厅", "先观望一下", "新手上桌" } elseif wins <= 0 then pool = { "这题有点绕", "差点就答对", "还在找思路", "今天题不顺", "再给我一局" } elseif wins >= 5 and wins * 2 >= games * 3 then pool = { "稳中带猜", "对手不好骗", "答题很稳", "题路清晰", "有点东西" } elseif wins >= 3 then pool = { "破题小能手", "今天有点准", "题感在线", "越猜越顺", "已赢" .. tostring(wins) .. "局" } elseif games >= 8 then pool = { "老玩家了", "久坐大厅", "再来一题", "题海老手", "继续上桌" } else pool = { "小胜一局", "题感来了", "刚破一题", "手感还行", "这题我熟" } end return pool[math.random(1, #pool)] end
截图里的:
text新手上桌
就来自这一组:
luapool = { "第一题还没开", "等一个对手", "刚进大厅", "先观望一下", "新手上桌" }


20. 保存便签到 serverCloud
scripts/network/Server.lua:720
lualocal function SaveWallNote(uid, nickname, wins, games) if uid == nil or serverCloud == nil then return end local now = os.time() local message = PickWallMessage(wins, games) local timeText = FormatWallTime(now) local ok, err = pcall(function() serverCloud:BatchSet(uid) :SetInt(CLOUD_KEY_WALL_TIME, now) :Set(CLOUD_KEY_WALL_MSG, message) :Set(CLOUD_KEY_WALL_TIME_TEXT, timeText) :Save("更新猜卦便签", { ok = function() print("[LobbyServer] Wall note saved") end, error = function(code, reason) print("[LobbyServer] Wall note save failed: " .. tostring(code) .. " " .. tostring(reason)) end, }) end) if not ok then print("[LobbyServer] Wall note save exception: " .. tostring(err)) end end
这里写入:
luabagua_wall_time bagua_wall_msg bagua_wall_time_text


21. 真人对局结束时更新便签
scripts/network/Server.lua:748
lualocal function RecordMatchStats(room, winnerSeat) for _, player in ipairs(room.players or {}) do if not player.isAI then AddCloudStat(player.userId, "bagua_games", 1) if player.seat == winnerSeat then AddCloudStat(player.userId, "bagua_wins", 1) else AddCloudStat(player.userId, "bagua_losses", 1) end if room.mode ~= "ai" then AddCloudStat(player.userId, CLOUD_KEY_PVP_GAMES, 1) if player.seat == winnerSeat then AddCloudStat(player.userId, CLOUD_KEY_PVP_WINS, 1) end SaveWallNoteWithDelta(player.userId, player.name, player.seat == winnerSeat and 1 or 0, 1) end end end end
这说明便签主要来自真人 PVP 战绩,不是 AI 对局。


22. 服务端拉取便签
scripts/network/Server.lua:771
lualocal function FetchWallNotes(lastWallTime, wallStart, callback) if serverCloud == nil then callback({}, 0, 0); return end wallStart = math.max(1, wallStart or 1) local threshold = math.max(0, (lastWallTime or 0) - WALL_OVERLAP_SECONDS) serverCloud:GetRankList(CLOUD_KEY_WALL_TIME, wallStart, WALL_FETCH_LIMIT, { ok = function(rankList) local notes = {} local userIds = {} local stoppedByThreshold = false for _, item in ipairs(rankList or {}) do local uid = item.userId local wallTime = item.iscore and item.iscore[CLOUD_KEY_WALL_TIME] or 0 if wallTime > threshold then notes[#notes + 1] = { userId = uid, nickname = "玩家", message = "", time = wallTime, timeText = "", wins = 0, games = 0 } userIds[#userIds + 1] = uid else stoppedByThreshold = true end end ... end, }) end
服务端用 CLOUD_KEY_WALL_TIME 排名拉取最近更新过便签的玩家。


23. 服务端补全便签详情
scripts/network/Server.lua:800
luaserverCloud:BatchGet(note.userId) :Key(CLOUD_KEY_NICKNAME) :Key(CLOUD_KEY_WALL_MSG) :Key(CLOUD_KEY_WALL_TIME_TEXT) :Key(CLOUD_KEY_PVP_WINS) :Key(CLOUD_KEY_PVP_GAMES) :Fetch({ ok = function(scores, iscores) local customName = scores and scores[CLOUD_KEY_NICKNAME] or nil note.nickname = PreferName(customName, note.nickname) note.message = scores and scores[CLOUD_KEY_WALL_MSG] or "" note.timeText = scores and scores[CLOUD_KEY_WALL_TIME_TEXT] or FormatWallTime(note.time) note.wins = iscores and iscores[CLOUD_KEY_PVP_WINS] or 0 note.games = iscores and iscores[CLOUD_KEY_PVP_GAMES] or 0 finishOne() end, error = finishOne, })
这一步把便签真正需要显示的字段补齐:
luanote.nickname note.message note.timeText


24. 服务端下发便签
scripts/network/Server.lua:230
lualocal function SendLeaderboard(connection, entries, myRank, myWins, myGames, wallNotes, wallSyncedTo, wallNextStart) local data = VariantMap() local count = math.min(LEADERBOARD_LIMIT, #(entries or {})) Shared.WriteMessage(data, Shared.MSG.LEADERBOARD, { Count = count, MyRank = myRank or 0, MyWins = myWins or 0, MyGames = myGames or 0, WallCount = #(wallNotes or {}), WallSyncedTo = wallSyncedTo or 0, WallNextStart = wallNextStart or 0, }) ... end
关键字段:
luaWallCount WallSyncedTo WallNextStart


25. 逐条写入便签字段
scripts/network/Server.lua:251
luafor i, note in ipairs(wallNotes or {}) do data["WallUserId" .. i] = Variant(tostring(note.userId or "")) data["WallNickname" .. i] = Variant(NormalizeName(note.nickname)) data["WallMessage" .. i] = Variant(note.message or "") data["WallTime" .. i] = Variant(note.time or 0) data["WallTimeText" .. i] = Variant(note.timeText or "") data["WallWins" .. i] = Variant(note.wins or 0) data["WallGames" .. i] = Variant(note.games or 0) end
这里是网络传输层,把数组形式的便签拆成:
textWallUserId1 WallNickname1 WallMessage1 WallTime1 WallTimeText1 ...


客户端接收便签关键代码
26. 请求排行榜时带便签同步参数
scripts/network/Client.lua:83
luafunction Client.RequestLeaderboard(lastWallTime, wallStart) return SendToServer(Shared.MSG.REQUEST_LEADERBOARD, { LastWallTime = lastWallTime or 0, WallStart = wallStart or 1 }) end
客户端请求排行榜时,也告诉服务端:
- 我已经同步到哪个时间
- 我从第几条便签开始拉


27. 客户端解析便签
scripts/network/Client.lua:219
lualocal wallNotes = {} local wallCount = Shared.ReadInt(eventData, "WallCount", 0) for i = 1, wallCount do wallNotes[#wallNotes + 1] = { userId = Shared.ReadString(eventData, "WallUserId" .. i, ""), nickname = Shared.ReadString(eventData, "WallNickname" .. i, "玩家"), message = Shared.ReadString(eventData, "WallMessage" .. i, ""), time = Shared.ReadInt(eventData, "WallTime" .. i, 0), timeText = Shared.ReadString(eventData, "WallTimeText" .. i, ""), wins = Shared.ReadInt(eventData, "WallWins" .. i, 0), games = Shared.ReadInt(eventData, "WallGames" .. i, 0), } end
这里还原成客户端可用的 wallNotes 数组。


28. 客户端返回排行榜对象
scripts/network/Client.lua:233
luareturn { entries = entries, myRank = Shared.ReadInt(eventData, "MyRank", 0), myWins = Shared.ReadInt(eventData, "MyWins", 0), myGames = Shared.ReadInt(eventData, "MyGames", 0), wallNotes = wallNotes, wallSyncedTo = Shared.ReadInt(eventData, "WallSyncedTo", 0), wallNextStart = Shared.ReadInt(eventData, "WallNextStart", 0), }
这就是 main.lua 里 OnLeaderboardUpdated() 收到的数据来源。


这张截图里的便签对应关系
截图红框里的便签:
text耳内有灰 新手上桌 07-05 13:00
对应代码字段是:
luanote.nickname = "耳内有灰" note.message = "新手上桌" note.timeText = "07-05 13:00"
来源分别是:
- nickname:玩家昵称,来自 TapTap 昵称或自定义昵称
- message:服务端根据战绩随机生成
- timeText:服务端用 os.date("%m-%d %H:%M", timestamp) 格式化
视觉位置来自:
luaNOTE_SLOTS
视觉随机性来自:
luaNoteHash(note, "color") NoteHash(note, "x") NoteHash(note, "y") NoteHash(note, "r") NoteHash(note, "tape")


关键结论
当前便签系统不是静态装饰,而是“排行榜附带的玩家动态墙”:
- 数据来自真人 PVP 战绩
- 每个玩家保留最新一条
- 打开排行榜时更新
- 本地有缓存
- UI 上以便签墙形式铺在排行榜遮罩背景上
- 红框中的便签是 CreateWallNote() 渲染出来的单张 UI.Panel


