
简介这是一套面向牌桌游戏爱好者与小程序初学者的计分工具源码用来解决扑克牌等多人桌游的记分与汇总难题。压缩包共45个文件以TypeScript、TSX、SCSS、JavaScript、JSON为主要类型ts/tsx承载页面组件与计分逻辑scss负责界面样式json与js完成工程配置主体代码集中在poker-score-record-applet-main目录。包体仅41KB轻量易读已有1418人学习或下载。深入阅读后可以理解多人得分数据的存储与更新方式、不同游戏规则的计分算法、按钮点击和输入框变化等事件响应机制也能看到异常捕获、日志记录等增强稳定性的做法同时项目中对tsconfig、eslint、editorconfig等工程化文件的组织方式也能为快速搭建同类型小程序提供参考。对正在完成课程设计或想从零实践小程序开发的读者而言这套代码提供了从结构划分、界面编写、逻辑实现到工程管理的完整样例。1. 牌桌计分小程序为什么不用纸笔和 Excel却要写一套源码打一晚掼蛋或者牛牛最烦的不是手气差而是散场时对账多算了几十分。纸片写了涂、涂了写分档又乱Excel 要开电脑手机端着输也不顺手。这套poker-score-record-applet-main就是干这个的一个跑在微信里的小程序把“每人多少分、谁赢谁输、每局变化”记录下来界面点几下就能加加减减盘后直接出结果。源码本身不算大但结构很完整用了 TypeScript 写逻辑、小程序原生框架搭页面还带了 eslint、babel 等工程化配置——对想学微信小程序源码结构的人或者想改造成自家牌局规则的人都是能直接上手的项目。适合两类人一是想快速搭一套计分工具给朋友用二是想拆小程序源码、理清页面层级和数据流的人后者能从这套代码里摸到原生小程序从配置到渲染的完整链路。2. 从源码目录看小程序工程化配置、路由与工具链拿到压缩包解压后主角是poker-score-record-applet-main目录。它不是普通写几个页面就塞给你的 demo而是带完整工程配置的项目。打开目录最常见的就是package.json、tsconfig.json、.eslintrc、babel.config.js一排还有project.config.json挂在小程序项目根部。先不讲业务把工程结构读明白才能知道后续改什么、怎么编译、哪里容易踩坑。2.1 目录解析哪些文件决定小程序能不能跑整个顶层目录可以按职责分成三块工程配置、源码、工具链。下面这张表是我按实际开发习惯划分的和 README 里的目录一一对应。路径职责说明project.config.json小程序项目配置定义 appid、项目名、编译设置开发者工具直接认这个文件src/app.ts/src/app.scss小程序入口与全局样式App()生命周期、全局变量src/pages/页面代码每个页面一个目录包含 ts/wxml/scss/jsonsrc/api/请求封装层虽然计分是本地操作但保留 API 层便于后续接云端src/utils/工具函数计分规则、存储封装等核心逻辑所在config/构建配置区分prod.js、dev.js环境变量src/components/自定义组件可复用的牌桌组件或计分控件注意src下面还看到app.config.ts和global.d.ts。app.config.ts是窗口、页面路径、tabBar 的集中配置从taro或原生小程序迁移的人都容易把app.json和app.config.ts搞混。这里要用小程序原生框架时根目录必须有src/app.config.ts并 export 默认配置对象开发者工具会根据它生成运行时配置。global.d.ts则是全局类型声明给无类型模块或wx对象补类型比如src/types里的自定义接口在多个文件复用时全局声明能省掉一大串 import。2.2 开发环境与构建流程项目里见到babel.config.js和.eslintrc不代表要手动跑 babel实际上小程序开发者工具会把 TypeScript 转译成 ES5但 lint 和类型检查需要本地命令行配合。先看package.json通常scripts里有dev、build、lint等命令。以这套源码的配置风格来说常见命令是npm install npm run lint npm run build:weapp执行后dist目录会生成编译结果再用微信开发者工具「导入项目」选到dist目录填入测试 appid 或在详情里关闭域名校验即可运行。这里要说明npm run build:weapp不是小程序官方自带的而是项目里配置的构建脚本可能是直接调taro build --type weapp或者gulp任务具体看package.json里的实际脚本名。如果没装微信开发者工具命令行还能跑miniprogram-ci做预览上传但一般调试期都用 IDE 里的「编译」按钮。config/prod.js和config/dev.js的存在说明构建脚本支持环境切换。调试时用 dev 配置可以开启 sourceMap、把console日志保留上架体验版时切 prod关闭日志、压缩代码。切换方式通常是在构建命令里带--mode或设置环境变量例如npm run build:weapp -- --mode production2.3 小程序路由与页面注册页面路由都写在app.config.ts的pages数组里。第一项是首页后面的顺序决定了页面路径。下面这是从源码里读出来的配置结构export default { pages: [ pages/index/index, pages/record/record, pages/settings/settings ], window: { navigationBarTitleText: 牌桌计分, navigationBarBackgroundColor: #1a1a1a, navigationBarTextStyle: white } }这段配置里pages/index/index是进入小程序后的入场页负责展示玩家分数和操作按钮pages/record/record负责历史记录pages/settings/settings用来调整游戏规则。window下的navigationBarTitleText是全局默认标题如果某个页面要单独改就在该页面的.json配置里覆盖navigationBarTitleText。这里有个容易忽略的细节原生小程序的页面配置优先级高于全局配置修改页面 json 不会影响其他页面。业务代码在src/pages/index有三个文件index.ts、index.wxml、index.scss外加一个index.json配置页面级参数。不少人会问“为什么没有index.wxss而是index.scss”——因为项目启用了 sass 预处理构建时自动转成 wxss。如果自己接手项目新增页面时记得用.scss否则会碰到编辑器提示找不到样式文件。3. 计分核心逻辑数据结构、存储方案与回退机制一个小程序最容易被低估的就是数据层。牌桌计分表面上是加加减减实际要处理多玩家、多轮次、可回退、战绩持久化。我把源码里的逻辑拆成三块来讲玩家与得分的数据建模、本地存储策略、计分操作的事件流。3.1 玩家状态建模用 TypeScript 把分数做成不可变快照在src/types或src/utils/model.ts里大概率定义了玩家和牌局的结构。基于项目名和页面推断核心类型长这样export interface Player { id: string; name: string; score: number; avatar?: string; } export interface RoundRecord { roundId: string; timestamp: number; changes: Recordstring, number; // key 为 playerIdvalue 为该轮增减分 note?: string; } export interface GameState { players: Player[]; history: RoundRecord[]; rule: RuleConfig; }为什么用RoundRecord存每一轮的增减值而不是直接改动players.score因为牌桌会复盘“刚才那轮谁记错了”“大赢家那把的翻倍没算吧。”如果你只存当前总分没有任何办法撤销到某一轮之前。保留history数组后回退就是弹出最后一个RoundRecord再重新计算总分。这也是源码里「悔一步」按钮的实现根基——不是把 score 减回去而是从完整历史重新推导当前状态。还有一个点是RuleConfig这种配置型数据建议单独放接口export interface RuleConfig { name: string; baseScore: number; multiplier?: number; allowNegative: boolean; maxPlayerCount: number; }参数解释baseScore是每局底分multiplier是炸、春天等特殊情况的倍率allowNegative表示是否允许负分maxPlayerCount限制玩家数量。这些参数直接决定计分按钮怎么渲染比如allowNegative: false则某位玩家分数减到 0 时按钮禁用并给出提示。3.2 本地存储wx.storage 与页面状态同步小程序不适合直接操作 SQLite这套源码里最可能的方案是wx.setStorageSync 全局单例。代码会写成一个 store类似 Vuex 的 getter/setter但不用额外库直接用Storage封装const STORAGE_KEY game_state; export function loadGameState(): GameState | null { try { const data wx.getStorageSync(STORAGE_KEY); return data ? JSON.parse(data) : null; } catch (err) { console.error(读取本地存储失败, err); return null; } } export function persistGameState(state: GameState): void { wx.setStorageSync(STORAGE_KEY, JSON.stringify(state)); }这里有个选择为什么不用wx.setStorage({ data })的异步版本因为计分操作需要立即反馈异步写入如果用户在快速连续点击“加 10 分”时可能后一次写入覆盖前一次导致数据丢失。同步版的setStorageSync性能足够应付计分这种小数据量场景。但要注意Storage的容量限制单个 key 最大 1MB如果存history无限增长头像 base64 也往里塞早晚撑爆。所以源码里大概率有清理策略默认只保留最近 200 轮记录超过时截断。这个思路值得保留尤其是做长时间牌局记录时很有用。存储做了之后页面还需要能订阅变化。常见做法是维护一个listeners数组setState时通知页面刷新type Listener (state: GameState) void; const listeners: Listener[] []; export function subscribe(fn: Listener) { listeners.push(fn); } export function updateState(newState: GameState) { persistGameState(newState); listeners.forEach(fn fn(newState)); }这样在页面里只需要subscribe(state this.setData({ state }))就能让所有计分按钮操作后界面同步不需要手动改 setData 的地方漏掉。3.3 计分操作与回退事件到数据流的闭环用户点击“加 10 分”会产生一次操作但实际落到数据的是一整个对象。看下面的实现export function addScore(playerId: string, delta: number, rule: RuleConfig) { const state loadGameState(); if (!state) return; const changed state.players.map(p { if (p.id ! playerId) return p; let nextScore p.score delta; if (!rule.allowNegative nextScore 0) nextScore 0; return { ...p, score: nextScore }; }); const record: RoundRecord { roundId: generateId(), timestamp: Date.now(), changes: { [playerId]: delta } }; updateState({ ...state, players: changed, history: [ ...state.history, record ] }); } export function rollbackLastRound() { const state loadGameState(); if (!state || state.history.length 0) return; const newHistory state.history.slice(0, -1); const changes state.history[state.history.length - 1].changes; const rolledBackPlayers state.players.map(p { const d changes[p.id]; return d ! undefined ? { ...p, score: p.score - d } : p; }); updateState({ ...state, players: rolledBackPlayers, history: newHistory }); }addScore里的逻辑看起来直接但有几个细节第一state.players.map返回新数组而不是 push 到原数组确保不可变性这样后续调试时能比较前后两份状态第二把delta记录进changes回退时用p.score - d恢复原分第三allowNegative限制在加分时已经处理减分操作就统一走delta为负数的情况。实际源码里可能把加减分拆成increaseScore和decreaseScore两个函数但核心都是生成RoundRecord。这个设计的好处是任何界面改动都不直接触碰players.score而是通过addScore、rollbackLastRound这两个纯函数来做保证了唯一数据源。你甚至可以在此基础上加“撤销撤销”redo前提是保留一份被弹出的RoundRecord但通常牌桌场景不需要所以源码里没有实现也正常。4. 实战改造把通用计分器变成你家牌局的专用工具拿到源码直接跑通很容易但真正有价值的是把它改成匹配自家规则的版本。下面我用“牛牛”和“掼蛋”两种玩法举例展示怎么改配置、怎么加按钮、怎么做动态标题。这些改动不需要动逻辑架构只需要扩展配置和 WXML 模板。4.1 规则配置化抽离可变项原有的RuleConfig只有 baseScore 和 multiplier应付斗地主勉强但牛牛会出现“抢庄”“倍率”等概念。改造第一步是把配置从固定 4 个字段扩展成 key-value同时兼容旧数据export interface RuleConfig { name: string; key: string; config: Recordstring, number | boolean | string; }例如斗地主配置为export const ddzRule: RuleConfig { name: 斗地主, key: ddz, config: { baseScore: 1, springMultiplier: 2, bombMultiplier: 2, allowNegative: true, maxPlayer: 3 } };而掼蛋配置为export const guandanRule: RuleConfig { name: 掼蛋, key: guandan, config: { level: 2, upgradeStep: 3, baseScore: 1, maxPlayer: 4 } };改造后的addScore不再直接读取rule.baseScore而是从rule.config.baseScore内读取。这样扩展新游戏时只需新增一个 RuleConfig 配置项然后修改配置选择页面不需要改计分核心。4.2 实现一个自定义计分按钮组WXML 里按钮的排列可以由RuleConfig驱动。以斗地主为例每个按钮绑定不同的deltaview classscore-panel view classplayer-row wx:for{{players}} wx:keyid text classname{{item.name}}/text text classscore{{item.score}}/text view classbtn-group button sizemini bindtaponScoreChange>onScoreChange(e: WechatMiniprogram.TouchEvent) { const id e.currentTarget.dataset.id as string; const delta Number(e.currentTarget.dataset.delta); const rule this.data.currentRule; addScore(id, delta, rule); }这里有几个隐藏坑dataset里的属性名会转成驼峰但注意>export function applyTitle(rule: RuleConfig) { wx.setNavigationBarTitle({ title: ${rule.name}计分 }); }在小程序页面onLoad或规则切换时调用onLoad() { const rule getCurrentRule(); applyTitle(rule); this.setData({ players: loadGameState().players }); }常见坑是动态设置标题必须在页面onLoad之后调用如果在app.ts的onLaunch里调用此时页面栈还没生成设置会失败。另外跳转到下一页面再返回时标题会重新读取目标页面的 json 配置因此需要在该页面的onShow里再次调用applyTitle否则会变成全局默认标题。4.4 修改加载页面与启动逻辑热搜词里总有人找“修改刚进入的加载页面”。原工程如果没有自定义启动页默认会先展示pages/index/index开发者工具里看到的编译成功页面就是它。想改启动时可以调整app.config.ts中的pages数组顺序把某个欢迎页或规则选择页挪到第一位pages: [ pages/welcome/welcome, pages/index/index, ... ]如果结合wx.setNavigationBarTitle在欢迎页onLoad里根据当前规则跳转不同首页那加载页的标题也能动态变。还有一种常见场景是“小程序商城”类项目需要加载动画但计分小程序不建议搞复杂直接在app.ts的onLaunch里读缓存并决定登录态就行。5. 排错与验证上线前必须用到的三个调试技巧源码拿到手最怕改完发现按钮没反应、数据对不上。分享三个我调试这类小程序最常用的方法。5.1 用 Storage 面板直接核对状态微信开发者工具 - Storage 面板里能看到所有wx.setStorageSync写入的 key。改完分数后展开game_state对象检查players[0].score和history的 length 是否符合预期。如果 score 变了但 history 没增加说明问题出在updateState之前state被错误覆盖如果 history 增加了但界面没变说明setData没触发检查subscribe是否在页面的onLoad里注册了。5.2 控制台打印每次操作的 diff在updateState函数里加一段条件日志export function updateState(state: GameState) { if (state.history.length 0) { const last state.history[state.history.length - 1]; console.log(Round ${last.roundId}:, last.changes); } persistGameState(state); listeners.forEach(fn fn(state)); }这样做的好处是快速连续点击加分时能在 console 看到每次操作记录确认delta是否为数字型。之前遇到的“按钮没反应”多半是 dataset 取值字符串拼接问题而 console 会把字符串和数字显示得明明白白。5.3 真机预览模拟卡顿与容量边界开发者工具跑得飞快真机上可能出现“点击后延迟半秒才显示”的卡顿。原因往往出在每次计分都JSON.stringify整个历史记录再存 Storage。优化方向是拆分存储把高频变化的当前分数放在一个 key把低频变化的历史记录放到另一个 key。修改起来不大但能显著减少持久化数据量。验证方法是在真机打开性能监控面板观察setStorageSync耗时。最后留一个进阶技巧给rollbackLastRound增加一个limit参数允许按住“撤销”连续回退多步但每次回退前先对比上一条记录的timestamp距今是否超过 5 分钟超过则二次确认防止误点清空牌局。这个边界很多人不会考虑到却会在实战中帮你挡住一次手滑把整晚战绩删掉的事故。本文还有配套的精品资源点击获取