简介一份基于C的轻量级视觉小说/Galgame框架设计源码面向希望快速搭建互动叙事游戏的原生开发者、独立游戏作者及C学习者旨在简化场景管理、用户输入、对话分支与界面渲染等基础开发工作。压缩包共52个文件包含25个cpp与23个hpp源码覆盖场景控制、输入框、菜单、列表视图、文本工具、核心流程等模块另有txt说明、license与gitignore配置整体仅269KB结构紧凑适合阅读与二次改造。已有323人学习下载。通过这套源码开发者可以掌握完整游戏框架的模块划分与实现思路直接复用场景切换、选项分支、UI绘制等关键组件也能参考其轻量级设计在资源占用与开发效率之间取得平衡适用于从独立小项目到小型商业作品的快速原型开发。1. 基于 C 的 libVN 轻量级视觉小说/galgame框架设计源码把剧情从代码里解放出来做过 galgame 的人都有过这种经历立绘还没定稿剧情却在 C 源码里写成了硬编码美工每换一版表情你就得重编译一次。更痛的是台词、选项、分支跳转和渲染逻辑纠缠在同一个类里改对白像拆炸弹。标题里这种“基于 C 的 libVN 轻量级视觉小说/galgame框架设计源码”核心思路就是把“剧情怎么走”和“画面怎么画”彻底拆开用一套轻量脚本描述演出用 C 引擎执行渲染、音频和输入。它适合独立游戏开发者、想做二次开发的 C 学习者也适合被 Unity/Web 方案逼疯、想亲手控制每帧逻辑的人。这套框架不追求 3D 粒子只求稳定、可改、跑得顺。2. libVN 框架分层C 核心模块与资源管理的取舍2.1 最小可运行工程的代码骨架常见做法是先把目录拆成四块main 入口、engine 核心、script 脚本解释、res 资源目录。这里给一个最精简的入口它只负责组装配置、构造引擎、跑主循环。// Filename: main.cpp #include VNEngine.h int main(int argc, char* argv[]) { VNConfig cfg; cfg.windowTitle libVN Demo; cfg.windowW 1280; cfg.windowH 720; cfg.targetFps 60; cfg.scriptPath ./res/script/start.vns; cfg.fontPath ./res/fonts/NotoSansSC-Regular.ttf; cfg.fontSize 32; VNEngine engine(cfg); return engine.Run(); }这段代码里最关键的是 VNConfig 这个结构体。windowW 和 windowH 决定画布尺寸targetFps 会在后面驱动固定步长主循环fontPath 和 fontSize 则直接决定所有文本框的默认排版。写 galgame 时我一般把 cfg 设计成引擎内部的公共参数块而不是散落在各个类里否则后期调 DPI 和字体大小会改到崩溃。2.2 从 VNEngine 到 Layer 的对象划分libVN 这类轻量级框架不应引入完整 ECS一个 VNEngine 组合几个核心对象就够了。我习惯这样划分职责// Filename: VNEngine.h class VNEngine { public: explicit VNEngine(const VNConfig cfg); int Run(); private: void Update(float dt); void Render(); void HandleEvent(); bool running_; uint32_t lastTick_; VNConfig cfg_; std::unique_ptrVNScreen screen_; std::unique_ptrResourceManager res_; std::unique_ptrScriptRunner script_; std::unique_ptrTextLayer textLayer_; };screen_ 负责底层窗口和图层res_ 统一加载纹理音频script_ 是剧本执行器textLayer_ 单独管理文本框和选项。这么拆的好处是改剧情只碰 script_调画画只碰 screen_互相不串。VNScreen 内部再维护一个有序图层数组立绘、背景、文本框各占一层绘制时按下标从后往前输出。2.3 资源管理器纹理、音频、字体的统一入口资源管理最容易翻车的地方是“同一张立绘被加载了两遍”。轻量级框架不需要做太重的引用计数一个哈希表缓存就够用SDL_Texture* ResourceManager::LoadTexture(const std::string path) { auto it textures_.find(path); if (it ! textures_.end()) { return it-second; } SDL_Texture* tex LoadFromDisk(path); // 内部调用 SDL_LoadBMP/SDL_IMG textures_[path] tex; return tex; }提示这张缓存的 Key 要用统一格式化后的路径比如全部转小写、替换反斜杠。否则./res/CHARA.png和./res/chara.png会被当成两个资源显存悄悄翻一倍。做小游戏时这条规则依然适用C 工程里资源路径是血泪教训高发区字符串数组初始化、路径拼错、大小写不一致都会在这里暴露。3. 渲染与帧循环galgame 特效稳定跑 60FPS 的最小实现3.1 固定步长主循环为什么 galgame 更需要稳定节奏网页游戏可以用 requestAnimationFrame 随缘更新但 C 写 galgame 时不把逻辑更新和渲染分开会遇到一个典型问题立绘切换有时快有时慢文本框内文字偶尔卡顿。原因就是窗口拖动、后台切回时帧间隔忽大忽小而剧情推进直接挂在渲染帧上。固定步长主循环是这类框架的标准答案while (running_) { uint32_t now SDL_GetTicks(); uint32_t frameTime now - lastTick_; lastTick_ now; accumulator frameTime; const uint32_t stepMs 1000 / cfg_.targetFps; if (accumulator 200) accumulator 200; // 防止窗口拖动时累加爆炸 while (accumulator stepMs) { HandleEvent(); Update(stepMs / 1000.0f); accumulator - stepMs; } Render(); }这里 stepMs 在 60FPS 下是 16.67ms。Update 永远用 1/60 秒的固定增量推进脚本和动画Render 则每帧调用一次。accumulator 钳位到 200ms 是个容易被忽略的细节debug 时打断点回来如果让累加器无限制膨胀游戏会瞬间跳完一整个章节。3.2 精灵渲染从一张立绘到透明图层叠放galgame 的立绘通常带 alpha 通道需要支持半透明叠放。最小实现里Spirit 结构体存 Transform 和透明度struct VNSprite { std::string textureId; float x 0; // 中心点 X 坐标 float y 0; // 中心点 Y 坐标 float scale 1.0f; float alpha 1.0f; // 0.0 ~ 1.0 int layer 10; // 越小越底层 };绘制时按照 layer 排序然后把 alpha 设置到 SDL 的调制颜色上SDL_SetTextureAlphaMod(tex, static_castUint8(alpha * 255))。注意 alpha 是乘在纹理上的如果一张立绘本身已经半透明最终透明度是两者相乘不是相加。3.3 文字打字机效果与 C 随机数打字机效果看着简单但“每个字固定间隔”读起来非常机械。这里给一个带随机停顿的更新逻辑也是 C 随机数在框架里的典型用法void TextLayer::Update(float dt) { timer_ dt; float wait 0.035f; // 基础每字间隔 35ms if (engine_-GetRandom().Next() % 100 15) { wait 0.05f; // 15% 概率额外慢半拍 } if (timer_ wait shownChars_ fullText_.size()) { shownChars_; timer_ 0.0f; } }随机停顿不需要用 mt19937 做复杂分布一个线性同余生成器就够。这里真正重要的是打字机只推进“已显示字符数”不修改文本内容渲染时用fullText_.substr(0, shownChars_)取子串。这样如果玩家按快进或点击跳过只需把 shownChars_ 设成 fullText_.size()立刻显示完整文本不会中文断字。4. 脚本到执行流给 galgame 框架自定义剧本 DSL 与解析器4.1 为什么不用 JSON 而自研轻量标签现有很多 C 项目一上来就想塞 Lua但对轻量级 galgame 框架Lua 绑定成本和不熟悉脚本的编剧门槛都是问题。直接用 JSON 写台词更是灾难每句对白都要包一层引号和键名编剧改起来很痛苦。自研一种纯文本标签其实就是在 class 里做一层字符串解析几十行代码就能得到很高的可读性比较项JSON 剧本标签文本剧本对白标记{speaker:A,text:你好}[say A]你好换行\n转义直接换行非技术人员可读性低高解析复杂度需要完整 JSON 库一个 Split 就够4.2 指令集与基本语法设计脚本 DSL 时不用追求表达力够用就行。我通常会先定义一个最小指令表[say 角色]文本显示台词[bg 背景图]切换背景[show 立绘图 layer10 alpha1]显示立绘[hide 立绘图]隐藏立绘[music 音频 looptrue]播放 BGM[choice 选项1|选项2]进入分支[jmp 标签名]跳转到指定标签[end]结束演出指令用方括号包住参数之间用空格分隔。这种格式的好处是可以直接用 std::istringstream 拆词不用写词法分析器。4.3 解析循环与状态机真正跑起来解析器需要维护当前脚本位置和跳转表inline void ScriptRunner::ExecuteLine(const std::string line) { std::string t Trim(line); if (t.empty() || t[0] # || t[0] ;) return; if (t[0] ! [) { textLayer_-SetFullText(t); // 裸文本当对话处理 return; } auto args SplitArgs(t.substr(1, t.size() - 2)); const std::string cmd args[0]; auto it handlers_.find(cmd); if (it handlers_.end()) { SDL_Log(libVN warn: unknown command %s, cmd.c_str()); return; } it-second(args); }handlers_ 是一个std::mapstd::string, std::functionvoid(const Args)命令注册表。[jmp label]的实现则依赖 label 表启动时先扫一遍脚本把所有[label 名字]的位置记录下来jmp 时把 currentLine_ 指过去。整套状态机只有三种等待输入文本显示中、等待选择选项分支中、正常推进。加上一个 WaitClick() 阻塞调用就能用顺序风格写剧本而不是靠回调嵌套。5. libVN 落地避坑从编译崩溃到中文乱码的 5 个翻车现场5.1 换台机器就报“找不到 MSVCP140.dll”或“libstdc-6.dll”现象代码在自己电脑上跑得好好的打包发给别人一启动就提示缺少运行库。原因默认情况下 Visual C 程序动态链接到 MSVC 运行库MinGW 程序则动态链接 libstdc-6.dll。很多从 VS Code 配置 C/C 环境起步的新手用 MinGW 编译的 exe 直接发给从没装过运行库的机器就翻车。解决发布前到“项目属性 - C/C - 代码生成 - 运行库”改成/MT静态链接。或者跟着 C 游戏发布习惯走把对应版本的 Microsoft Visual C Redistributable 安装包放进附目录。前者体积大一点后者省心但要求用户装两分钟。5.2 C# 调用 C 核心出现 Access Violation (0xC0000005)现象框架单独跑没问题写成 DLL 后由 C# 侧的 UI 调用ShowText()方法一传参就崩溃错误码 0xC0000005。原因C# 的string默认封送成 BSTR而 C 接口接收的是const char*。两边对内存布局的理解不一致C 内部一访问字符串内容直接打到无效内存地址。解决DLL 导出用固定的int __stdcall ShowText(const char* text)C# 侧显式指定入口点并改成 UTF-8 编码封送。如果框架内部用std::string一定在 DLL 边界转成const char*再跨语言传递不要直接把std::string地址传出去。5.3 高 DPI 屏幕下窗口要么模糊要么坐标错位现象在 4K 屏上跑场景背景被拉伸成一片糊或者鼠标点击立绘位置回应却偏了半个屏幕。原因SDL2 默认不处理 DPI 缩放。Windows 上 150% 缩放时窗口逻辑坐标和物理坐标不一致。模糊是渲染时直接把 1280x720 内容拉到 1920x1080 上面错位则是事件坐标用的是物理像素脚本坐标用的是逻辑像素。解决初始化时加SDL_SetHint(SDL_HINT_VIDEO_HIGHDPI_DISABLED, 0)启用高 DPI然后用SDL_GL_GetDrawableSize拿真实尺寸做比例换算。更省事的办法是把所有逻辑坐标放在统一的 VNScreen 坐标系里事件和渲染共用同一套缩放只在最后一步发生。5.4 中文字体显示成“口口口”或发虚现象文本框里全是豆腐块或者字边缘异常油腻。原因SDL_ttf 加载字体时如果文件路径不对表面上是字体缺失实际上是字体尺寸太小、Hinting 开启导致的字形渲染异常。另一种情况是脚本文件是 UTF-8 带 BOM第一行指令前缀多了三个字节解析器把“ef bb bf [say”当成未知命令文本直接漏掉。解决字体文件用一个大号 TTF推荐至少 24px 以上渲染脚本文件统一存成“UTF-8 无 BOM 格式”加载后先做 BOM 剥离然后 trim 掉空行和注释行。字幕稍微发虚优先检查的却往往不是字体而是纹理的放缩设置把纹理过滤从线性改回最近邻就能锐利很多。5.5 帧循环累加器爆炸切后台回来剧情直接跳完现象运行中把窗口拖到后台十分钟切回来角色已经把所有台词说完跳到了结局。原因Update 调用量等于“补帧”而补帧数量和主循环所在线程被挂起的时间成正比。解决固定步长主循环里必须做 accumulator 钳位。上面 3.1 节代码里的if (accumulator 200)就是干这个的。另一个治本方案是检测到窗口失焦时暂停累计器SDL_GetWindowFlags里没有焦点标记就重置 lastTick_。6. 进阶与自测把 libVN 扩展成自己的 C 游戏引擎脚手架一个真正的 galgame 框架不会停在文本和立绘还应该支持一点“氛围演出”。这里给一个不依赖第三方音频库的 BGM 淡入淡出实现用 C 标准库的时钟控制增益class BGMPlayer { public: void StartFadeIn(std::string path, float durationSec) { channel_.Play(path); timer_ 0.0f; duration_ durationSec; fadingIn_ true; } void Update(float dt) { if (!fadingIn_) return; timer_ dt; float gain timer_ / duration_; if (gain 1.0f) { gain 1.0f; fadingIn_ false; } mixer_SetChannelVolume(channel_, static_castint(gain * 255)); } };调参数时先记住一个经验值淡入 1.5 秒、淡出 1.2 秒大部分 galgame 场景都不违和。再长就会让玩家觉得“是不是卡了”。验证框架是否真正可用最靠谱的方式不是玩一个 Demo而是写一个自动化回归测试。用 assert 检查脚本跳转是否正确void TestScriptJump() { ScriptRunner runner; runner.LoadFile(./res/script/test.vns); runner.ExecuteLine([jmp bad_end]); assert(runner.GetCurrentLabel() bad_end); runner.ExecuteLine([jmp good_end]); assert(runner.GetCurrentLabel() good_end); }最后再提一个我自己的习惯所有指令集和资源路径都写进框架自带的自检脚本每加一个特性先跑一遍这个脚本再提交代码。标题里这种轻量级框架最容易失控的地方就是“新功能越加越重”把测试脚本固定住才能长期保持轻量。希望帮到你有空可以把这个方向做深。本文还有配套的精品资源点击获取