我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

用C# WinForms开发马年猜成语小游戏:从玩法到打包发布全记录

用C# WinForms开发马年猜成语小游戏:从玩法到打包发布全记录 老家客厅里的春联还带着墨香几个孩子围着电视抢遥控器长辈们坐在沙发上各自刷手机眼看过年又要变成“一人一台手机各玩各的”的局面。我放假前就盘算着带一样能让全家凑到一块儿玩的东西回去最后决定自己写一个小程序主题定在马上到来的马年玩法是猜成语。这里说的“小程序”在C#技术栈的语境下通常指原生WinForms写出来的轻量级桌面小应用不是微信小程序那套。C#做这类东西非常顺手——拖一个窗体、写几句逻辑、调试两三晚就能拿出一版可以玩的成品。我选择WinForms而不是WPF理由很直接WinForms部署简单、对系统资源要求低家里那些配置不高的旧电脑也能跑得动而且对UTF-8中文的支持和窗体布局的调试都省心。这篇文章就把整个项目的完整拆解记录下来玩法怎么定、成语库怎么筛、猜题判定逻辑怎么写、界面交互有哪些排不完的坑、最后怎么打包给全家用。适合刚学C#想找个真实项目练手的朋友也适合想在节假日场合做点小工具给身边人用的开发者参考。1. 先把玩法定死猜成语不是随便猜做游戏最怕的是边写代码边想功能。我一开始就坐下来把玩法、流程、计分规则全部写在一张纸上想清楚了再动手。1.1 核心交互流程整个游戏的循环其实很简单就五步程序从题库里抽取一个成语作为当前题目界面展示谜面默认只显示拼音首字母比如“m d c g”玩家在输入框里输入四个汉字并提交系统对每个字做比对标出正确、同音、错误三种状态全对则得分并进入下一题否则提示玩家继续尝试。这个流程看起来简单但每一步都有细节。比如“同音”这个状态很多人会觉得多此一举——答案明明写在那儿打同音字就该判错。但实际使用场景里用户真的会把“骏”打成“俊”或者把“厉”打成“利”。如果直接判错挫败感很强标注成“音对字错”玩家反而会觉得系统挺智能。所以第四步的判定逻辑我用了一下午来打磨后面会专门讲。1.2 三种模式闯关、限时、马年主题只做一个无限随机出题的版本很容易玩腻。我把模式拆成三种共用同一个题库只在出题范围和计分规则上做区分模式题库范围计分规则适用场景闯关模式按难度从1星到3星顺序出题基础分100提示扣20连击加分家人轮流玩一人一题比成绩限时模式随机抽取全部题目每题30秒倒计时剩余秒数乘0.5计分年轻人之间比拼反应速度马年主题只出与马相关的成语同闯关计分但提示次数无限制应景过年讨个好彩头三种模式其实只改了两个参数出题筛选条件Where条件和计分公式。我当时特意没有把计分规则硬编码进模式枚举里而是用策略模式把计分函数作为参数传给游戏引擎。这样后面想加“妈妈模式”提示不扣分或者“儿童模式”只需答出三个字就算对加一个函数就行。1.3 功能清单与优先级我习惯把需求分成P0和P1两级先保证P0全部落地再评估P1能不能做。P0必须要有的出题、输入答案、判定反馈、提示功能、基础计分、下一题切换、模式选择。这些缺任何一项游戏都没法完整玩下去。P1加分项连击动画、得分飘字、音效、成语释义展示、自定义题库导入、答题历史记录。这些决定体验的上限但不会影响核心闭环。一个容易被忽视的点是成语释义展示。猜完成语之后如果界面上能同步显示这个成语的出处和含义长辈们会很感兴趣——他们不只是在玩游戏还在做文化输出。我给每个成语都加了Tip字段答对后显示在谜面下方答错三次也会显示顺便引导玩家记住答案。2. 成语库马年题库的筛选与数据结构设计整个项目的灵魂在题库。如果题库只有二十个成语玩一轮就没意思了。我翻了一下午成语词典整理出一份四十多条的马年主题题库然后按难度做了分级。2.1 什么成语算“马年成语”这里我定了三条筛选标准第一类直接含“马”字的这是主力。例如马到成功、一马当先、万马奔腾、马不停蹄、龙马精神、千军万马、快马加鞭、天马行空、走马观花、老马识途、单枪匹马、蛛丝马迹、塞翁失马、马革裹尸、青梅竹马、心猿意马、马首是瞻、招兵买马、马放南山、马耳东风、马齿徒增。第二类成语里没有“马”字但包含与马相关的汉字典型的是“驹”“骥”“骅”“骝”这类马字旁的字。比如白驹过隙形容时间过得飞快、按图索骥比喻拘泥成法办事、骐骥一跃出自《荀子》“骐骥一跃不能十步”。这类成语作为进阶题非常合适玩家猜的时候会觉得“有意思居然还有这种操作”。第三类是生肖祝福类比如“马到成功”“龙马精神”虽然按第一类已经收了但我会单独打上一个IsFestival标签供马年主题模式直接调用。我按难度做了三级分类1星是日常高频使用的比如“马到成功”“一马当先”2星是常用但需要想一下的比如“天马行空”“塞翁失马”3星是相对生僻但字面很有趣的比如“马耳东风”“马齿徒增”。闯关模式就按这个顺序出题从易到难节奏很舒服。2.2 成语实体与JSON存储方案C#里我用一个实体类来承载成语数据public class ChengyuItem { public int Id { get; set; } public string Text { get; set; } // 成语本身例如马到成功 public string Pinyin { get; set; } // 完整拼音例如ma dao cheng gong public int Difficulty { get; set; } // 难度等级 1-3 public string Tip { get; set; } // 释义答对或多次失败后展示 public bool IsFestival { get; set; } // 是否属于马年祝福类 }Pinyin字段是整个判定逻辑的关键。它既用于生成提示首字母提取也用于同音字容错判断。所以我在录入数据时就要求拼音必须准确并且统一用空格分隔每个汉字比如ma dao cheng gong。这里有个细节拼音不要带声调。虽然带声调理论上更精确但玩家打字时几乎不会考虑声调判断同音时反正也得忽略那干脆存的时候就存不带声调的版本省得每次比对还要做标准化。题库的存储我选择了JSON而不是SQLite或Access。原因有三一是成语数据量小几十条内容用数据库属于杀鸡用牛刀二是JSON对人类可读后面想手动加成语直接用记事本改三是System.Text.Json在.NET 8里解析性能足够好而且没有第三方依赖。2.3 加载与编码UTF-8的坑加载JSON看着简单实际上我在这里踩了一个非常典型的编码坑。最初我从网上复制了一批成语数据粘贴到记事本里保存程序运行时发现中文全部变成了乱码。问题出在记事本默认的编码可能是ANSIGBK而System.Text.Json默认按UTF-8解析。正确的做法是把数据文件明确保存为UTF-8编码并且在读取时显式指定var json File.ReadAllText(chengyu.json, Encoding.UTF8); var items JsonSerializer.DeserializeListChengyuItem(json);但这里还有一个更隐蔽的坑如果UTF-8文件带BOM字节顺序标记ReadAllText能正常处理如果不带BOM在某些旧版.NET下用File.ReadAllText默认也可能解析成乱码。最稳妥的方式是不要依赖默认编码任何地方读文本都显式传Encoding.UTF8。我在项目里把所有文件读写操作都封装成了一个DataLoader类统一处理编码后面再没出过乱码问题。另外题库文件建议放在exe同目录而不是嵌入程序集资源。嵌入资源虽然省事但用户想自己加几条成语就得重新编译。外部文件的方式配合“默认值兜底”逻辑最合理启动时检测chengyu.json是否存在不存在就从程序集内嵌的一份默认题库加载并且自动生成一份JSON文件到exe目录方便用户查看和修改。3. 猜题判定的核心逻辑逐字比对与拼音容错判定逻辑是整个项目最核心的部分我花的时间最多重写了两轮才满意。3.1 用户输入的预处理链路玩家在输入框里可能输入各种内容带空格、带标点、全角字符、字母甚至误输回车。直接拿这些脏数据跟答案比对结果会莫名其妙。所以每次提交都要走一条预处理管线Trim()去掉首尾空白全角转半角——中文输入法经常打出全角空格和全角符号需要统一转换用正则[\u4e00-\u9fa5]只提取汉字过滤掉字母、数字、标点和表情符号再次Trim()保证最终只剩一串连续的汉字。全角转半角的实现就是一个字符遍历private static string ToHalfWidth(string input) { var sb new StringBuilder(input.Length); foreach (var c in input) { if (c \uFF01 c \uFF5E) { sb.Append((char)(c - 0xFEE0)); } else if (c \u3000) { sb.Append( ); } else { sb.Append(c); } } return sb.ToString(); }顺手说一下这里用到了C#里的StringBuilder。很多人拼接字符串喜欢用但循环里这样做会产生大量临时对象。StringBuilder在这种逐字符处理的场景下是更合理的选型。预处理之后如果提取出来的汉字数量不等于成语长度直接返回“请检查字数”的提示不做后续比对。这个判断放在比对之前能避免很多无效计算。3.2 逐字状态标记与结果判定比较两个字符串是否相等最简单的是运算符。但猜成语游戏需要的是“每个字”对错的反馈而不是一个笼统的判对判错。所以我把答案和输入都转成字符数组逐位比对输出一个状态数组public enum CharStatus { Correct, // 完全正确 SimilarPinyin, // 同音但字不对 Wrong // 完全错误 } public (bool IsCorrect, ListCharStatus Statuses, int CorrectCount) Evaluate(string answerText, string inputText) { var answerChars answerText.ToCharArray(); var inputChars inputText.ToCharArray(); var statuses new ListCharStatus(); int correct 0; for (int i 0; i answerChars.Length; i) { if (answerChars[i] inputChars[i]) { statuses.Add(CharStatus.Correct); correct; } else if (IsSamePinyin(answerChars[i], inputChars[i])) { statuses.Add(CharStatus.SimilarPinyin); } else { statuses.Add(CharStatus.Wrong); } } return (correct answerChars.Length, statuses, correct); }这里我直接返回了一个C#值元组(bool, ListCharStatus, int)调用方用解构语法接住var (isCorrect, statuses, correctCount) _evaluator.Evaluate(answer, input);值元组是C#里一个很实用的特性不用为一次返回值专门定义一个类代码看起来也更紧凑。很多教程会用out参数或者定义一个Result类在这类小工具里元组明显更顺手。需要强调的是这个比对是位置敏感的。玩家输入“马到成功”答案也是“马到成功”四个位置全对。但如果输入“成功马到”虽然包含的字完全一样四个位置全错不能判对。这符合猜成语的直觉——成语是有固定语序的不是填字游戏。3.3 拼音编码不用拼音库也能做的方案IsSamePinyin是同音容错的核心。有人可能会想到引入NPinyin这类第三方拼音库但我没有用。原因很简单判断题库里的汉字总共就两百多个为这两百多个字引入一个拼音库完全没必要。我维护了一个静态字典只收录题库中出现的汉字对应的拼音private static readonly Dictionarychar, string PinyinMap new() { { 马, ma }, { 到, dao }, { 成, cheng }, { 功, gong }, { 一, yi }, { 当, dang }, { 先, xian }, // 题库里每个字都要有缺了就在调试时补上 };然后写一个校验方法private static bool IsSamePinyin(char a, char b) { if (!PinyinMap.TryGetValue(a, out var pa)) return false; if (!PinyinMap.TryGetValue(b, out var pb)) return false; return pa pb; }这个方案的好处是零依赖、离线可用、行为完全可预测。代价是我必须保证题库里的每一个汉字都在字典里否则同音判断会静默失效。为了解决这个问题我写了一个调试用的自检方法程序启动时遍历题库所有汉字检查PinyinMap是否存在缺哪个就在调试输出里打出来。这样每次加新成语跑一遍就能发现漏网的拼音。“同音”的比较准则是忽略声调的完整拼音一致。也就是说“骏”jun和“俊”jun会判为同音而“马”ma和“吗”ma也会判为同音。后一种情况其实算得上“误判”——字义完全不同只是读音碰巧一样。但从游戏体验角度宽容一点比苛刻一点好反正最终正确字没对上状态栏照样标黄玩家还是得继续想。这个度要把握好只做同音容错不做形近字容错否则游戏就失去考量的意义了。3.4 提示系统的委托设计提示功能我用了C#里的Func委托来设计这样提示策略可以做到可替换。public enum HintLevel { PinyinFirstLetters, // 显示拼音首字母比如 m d c g RevealOneChar, // 揭开展示一个汉字比如 马口成口 } FuncChengyuItem, HintLevel, string BuildHint (item, level) { if (level HintLevel.PinyinFirstLetters) { return string.Join( , item.Text.Select(c PinyinMap.TryGetValue(c, out var p) ? p[0].ToString() : ?)); } var chars item.Text.ToCharArray(); var revealed chars.Select((c, i) (c, i)) .Where(x x.i % 2 0) // 简单策略揭开偶数位置的字 .ToArray(); foreach (var (c, i) in revealed) chars[i] c; return new string(chars); };默认首次点击提示按钮显示拼音首字母第二次点击再揭开一个字的字形第三次答案基本就摆在眼前了。用Func的好处是提示策略可以作为一个参数传进游戏引擎以后想加“根据释义猜成语”的提示模式不用改引擎代码传一个新的函数进来就行。顺便说一下这里我用到了LINQ的Select和元组。C#开发者如果还没用过值元组我真的建议在随手写的工具项目里大胆尝试它能让代码表达意图更直接。4. 界面与交互体验WinForms也可以很“游戏”很多教程教WinForms就只是往窗体上拖按钮。但实际做一个要给别人玩的程序“体验”这两个字能拉开很大的差距。4.1 界面布局一道题一个输入框我最终采用的布局是一个经典的上下分区结构顶部信息栏左侧显示“第 12 题”中间显示当前模式右侧显示得分中部谜面卡一个大的Label字体用楷体28号默认显示拼音首字母提示上面用卡片背景色框起来视觉上充当“题目牌”下部操作区一个TextBox加两个按钮分别是“提交”和“提示”底部状态栏显示连击数、当前用时、最近一次判定结果的反馈。谜面卡用Label而不是PictureBox原因很简单汉字文本渲染用Label最清晰而且动态改Text非常方便。字体我选楷体因为成语题配上书法风格字体在观感上最搭。需要注意的是楷体在精简版Windows系统上不一定存在如果Font指定了“楷体”但系统里没有会回退到默认字体。想要稳定的话直接用“微软雅黑”最靠谱我在最终版本里默认用的是“微软雅黑”把“楷体”作为一个可选项留在了设置里。4.2 中文输入法下的输入框设计一个真实的坑最开始我把输入区做成了四个单字输入格就是四个TextBox排成一排每个MaxLength1填完一个自动跳到下一个。界面效果确实好看我还加了焦点自动跳转的逻辑private void Slot_TextChanged(object? sender, EventArgs e) { var box sender as TextBox; if (box ! null box.Text.Length 1) { int index Array.IndexOf(_slots, box); if (index _slots.Length - 1) { _slots[index 1].Focus(); } } }结果真实测试的时候翻车了。在微软拼音输入法下MaxLength1并不能阻止拼音组合态上屏用户敲完“madao”的拼音组合串会直接涌入第一个格子并被截断之后根本无法选字。四个单字格的思路在中文输入法面前几乎是废的。我把方案回退成了单个输入框宽度足以容纳四个大字MaxLength设为10玩家一次性输入完整成语。这个方案的输入法兼容性最好——拼音组合串可以完整显示用户选完字上屏提交时再做预处理拆分。虽然少了一点“逐格输入”的仪式感但胜在稳定可靠。如果你真的想要那种逐字输入的体验可以尝试一个折中方案输入框不可见玩家通过输入法直接往一个隐藏的TextBox里打字每上屏一个字就在界面上显示一个汉字卡片同时把已输入的字符清空。这样既兼容输入法又有仪式感。但复杂度会增加不少考虑到春节场景下“能用”优先我就没有继续折腾。4.3 DPI缩放与界面模糊第一次在同事的2K高分屏笔记本上跑这个程序窗体整个是糊的文字边缘全是毛刺。这就是WinForms老生常谈的DPI缩放问题。解决方法分两步。第一步在Program.cs里显式声明高DPI模式ApplicationConfiguration.Initialize(); Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); Application.Run(new MainForm());第二步在窗体设计器里把AutoScaleMode设为Dpi这样窗体上的控件会跟着缩放比例调整。如果你的程序用.NET 6以上的模板ApplicationConfiguration.Initialize()里已经帮你做了不少初始化但显式写清楚SetHighDpiMode更安心。需要提醒的是PerMonitorV2模式下如果在系统显示设置里改动缩放比例已经启动的窗体不会自动重新布局只有重新启动程序才会生效。所以测试时改完DPI一定要重启程序不要以为改了没反应就是代码没写对。4.4 得分、连击与飘字动画计分逻辑本身很简单我给每个按钮的事件挂上逻辑就行。关键是“反馈爽感”。猜对了弹一个“100”的飘字比屏幕下方安静地加一行数字体验好得多。飘字动画我用了System.Windows.Forms.Timer每16毫秒触发一次Tick事件让一个Label从初始位置向上移动3个像素同时透明度逐步降低大约500毫秒后从窗体上移除。这个方案比用Task.Delay配合Invoke要省心许多——Timer的事件天然跑在UI线程上直接改控件属性不会触发跨线程异常。private void ShowScoreFlyout(int score, int x, int y) { var label new Label { Text ${score}, Font new Font(微软雅黑, 14f, FontStyle.Bold), ForeColor Color.Goldenrod, AutoSize true, Location new Point(x, y) }; Controls.Add(label); label.BringToFront(); var timer new Timer { Interval 16 }; int steps 0; timer.Tick (_, _) { steps; label.Top - 3; if (steps 30) { timer.Stop(); Controls.Remove(label); label.Dispose(); timer.Dispose(); } }; timer.Start(); }类似的小反馈还有答错时输入框边框变成浅红色底部状态栏显示“第2个字同音再试一次”。这些细节单个看起来不起眼合在一起玩家就会觉得“这程序做得挺用心”。5. 打包分发与后续扩展做这个项目的直接目标是要在过年期间给家里人用那就必须解决一个现实问题怎么把程序变成亲戚朋友双击就能玩的东西。5.1 单文件发布让亲戚不用装.NET默认情况下dotnet build出来的程序依赖本机安装.NET运行时这在家用电脑上基本等于不可用。我选择的方案是.NET 8的单文件自包含发布。在csproj里加上这几行PropertyGroup PublishSingleFiletrue/PublishSingleFile SelfContainedtrue/SelfContained RuntimeIdentifierwin-x64/RuntimeIdentifier IncludeNativeLibrariesForSelfExtracttrue/IncludeNativeLibrariesForSelfExtract /PropertyGroup然后执行dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFiletrue这样会生成一个几十MB的独立exe目标机器不需要安装任何额外组件。IncludeNativeLibrariesForSelfExtract的作用是把原生库也打包进exe运行时会自动解压到临时目录避免用户看到一坨DLL文件。有两个实际经验要分享一是单文件exe偶尔会被杀毒软件误报。因为自包含程序体积大、带运行时、还可能自解压行为特征偶尔会触发误报。解决办法是在发布说明里注明并建议用户添加信任。我在打包时顺手写了一个使用必读.txt里面写清楚“本程序不含任何恶意代码如被杀毒软件拦截请添加信任”。二是不要开启PublishTrimmedtrue。WinForms程序大量使用反射裁剪后很容易在运行时出现“类型或方法找不到”的异常。为了省那十兆体积把自己搭进去不值当。5.2 让成语库变成外部文件用户也能参与我把题库放在了exe目录下一份chengyu.json里。启动时DataLoader会先看这个文件在不在不在就从程序集内嵌的默认题库加载并生成一份到本地在就直接读外部文件。这样用户加成语只改JSON不需要重新编译。我还在窗体上留了一个“导入CSV”的按钮支持一种最简单的格式每行一个成语用逗号分隔成语、拼音、难度、释义。导入逻辑就是按行拆分再转换成ChengyuItem列表合并进现有题库。这个功能本来是给自己加题库用的后来发现亲戚里的语文老师真的会用它把孩子课本上的成语加进去算是意外收获。外部化的设计会让程序的边界清晰很多核心逻辑在exe里数据在文件里后续改题库不用动代码。5.3 如果要把玩法搬到手机上C#在前后端的分工我上一个项目交付的是微信小程序做完这个C#桌面版最大的感触是两类工程的思维完全不同小程序前端主要做展示和交互真正的判定逻辑如果都堆在小程序里代码会混乱而且难测试。C#这套核心逻辑非常适合沉淀成一个独立的类库。做法很直白把ChengyuEvaluator、DataLoader、ChengyuItem这些和数据与判定相关的代码抽成一个Chengyu.Core类库项目然后另起一个ASP.NET Core Minimal API项目引用它对外暴露几个接口GET /api/chengyu/random获取随机题目POST /api/chengyu/guess提交答案返回逐字状态GET /api/chengyu/list获取全部成语列表。小程序前端只负责画界面、收输入、展示判定结果。这样C#负责重度逻辑和数据小程序负责轻量交互各司其职。这个话题真要展开又是一篇文章但对眼前这个项目来说先把核心逻辑抽出来、把外部题库做好就是为将来多端复用铺好了路。说实话这个项目本身的难度不算高真正花时间的反而都是些细枝末节中文字体选哪个、输入法为什么吃回车、高DPI下窗体为什么会糊、JSON文件为什么读出来是乱码。把这些细节一个个排干净一个小工具才真正算做完。我做完之后最大的收获不是那几十条成语而是明白了一个道理给身边人做工具稳定、直接、能用是最重要的功能炫不炫倒在其次。如果你也想做一个春节家族同乐的小程序不妨把题库换成本地方言、老物件甚至歇后语玩法骨架完全不用大改。
返回列表