简介:这是一份面向C++游戏开发学习者的复古生存恐怖游戏完整源码,基于raylib定制引擎实现,既可用于课程设计,也可作为个人游戏项目的起点。项目围绕“黑暗不适”主题,串联起场景管理、实体组件、资源加载、资产目录等多个模块,结构清晰,适合想了解raylib引擎集成方式或独立游戏小型架构的开发者。压缩包共60个文件,以22个h头文件和20个cpp源文件为核心,另含4个png图片、4个scene场景文件、3个yml配置、obj/blend模型以及Makefile构建脚本,整体仅170KB,轻量但五脏俱全。目前已有628人学习下载。源码附带跨平台构建说明:macOS/Linux下依次运行make setup和make即可编译,Windows使用mingw32-make完成相同操作;同时支持使用--editor参数启动编辑器模式,方便调整场景内容。工程目录按assets、scenes、systems、utils等划分,并引入vendor/raylib-cpp作为引擎封装层,便于逐文件阅读和二次开发,是学习C++游戏编程、raylib定制渲染管线与复古生存恐怖游戏设计的优质参考。 《黑暗不适》这个项目,我从立项到做出可玩原型用了大概两个多月。玩法就是老式生存恐怖游戏那一套:固定视角、坦克式操作、手电筒永远照不完整间屋子,美术走低多边形风格。不一样的是,我全程没有用 Unity、Godot 这类通用引擎,而是把 raylib 作为唯一底层依赖,自己搭了一套面向这个品类的定制引擎。当时这个选择被不少人质疑,因为 raylib 在国内讨论度不算高,能参考的完整项目也不多。但实际做完之后,我觉得这套组合恰恰最适合复古生存恐怖题材。这篇文章会把我整个选型逻辑、引擎架构、核心手感实现,以及开发过程中踩过的坑完整讲一遍。
如果你正在做独立游戏,尤其是恐怖、步行模拟、固定视角解密这类慢节奏小体量项目,这篇应该能帮你少走不少弯路。
1. 为什么选 raylib 自造引擎而不是直接用现成引擎
1.1 raylib 恰好够用:不做重引擎,也能做重氛围
先说结论:对于固定视角、封闭场景、慢节奏玩法的游戏来说,通用引擎里 90% 的功能其实都是负担。Unity 的物理系统、动画系统、完整光照烘焙、UI 框架,在后台持续消耗你的心智带宽,但复古生存恐怖恰恰只需要几样东西:稳定的渲染循环、精确的输入采样、简洁的 3D 模型加载,以及基本的音频播放。
raylib 是一个用 C 语言编写的基础图形音频库,它只提供工具函数,不强制任何架构。你可以自由决定游戏循环怎么写、场景怎么组织、对象怎么管理。对于追求老派游戏手感的项目来说,这种自由度非常关键,因为“手感”往往就藏在很多大引擎封装好的细节里,比如输入采样精度、碰撞处理的微调、声音衰减曲线。用大引擎默认方案,经常要绕过好几层才能改到核心。
另外,raylib 的体量真的很轻。编译出来的可执行文件很小,启动速度快,加载场景基本没有等待感。对于需要频繁调试场景、反复进入游戏测试手感的工作流来说,这个优势会被放大得非常明显。我在开发过程中反复重启游戏至少几百次,每一次都能快速回到场景里,体验开销几乎为零。
1.2 定制引擎到底定制了哪些模块
我所谓的“定制引擎”绝对不是从零写一个 OpenGL 渲染器,那是一条没必要走的路。真正的定制,是在 raylib 之上搭建一套专门为生存恐怖游戏服务的框架,把游戏逻辑和底层细节解耦。我的引擎里主要有这几个模块:
- 场景管理:负责切换不同的房间、区域、固定视角相机,维护每个视角的加载和卸载
- 实体系统:管理玩家、敌人、可交互物品、开关门、拾取物等对象的生命周期
- 资源管理:封装模型、纹理、音效、音乐资源的加载、缓存与释放
- 输入映射:把键盘、手柄输入统一转换成适合坦克式操作的移动与交互信号
- 存档系统:保存玩家位置、当前视角、物品状态、门锁状态、敌人存活情况
这样做最大的好处是,游戏玩法的修改不需要触碰底层渲染和输入代码。比如后期要加一个新的谜题系统,只需要在实体系统里挂一个新的交互逻辑,不需要关心相机怎么运作、贴图怎么加载。引擎层始终是稳定的,变化都集中在玩法层。
2. 引擎架构设计:把场景、输入、资产都收编进来
2.1 固定视角驱动一切:ViewZone 场景切块
复古生存恐怖最核心的视觉特征就是固定视角。玩家进入一个场景,看到的是一个经过设计的摄像机机位,角色在里面移动时相机不动。玩家走到某些边界位置,视角才会跳切到下一个预设机位。
我在引擎里用 ViewZone 结构体表达一个视角区域。它包含一块 2D 平面上的触发范围、该区域的固定相机参数、当前视角能交互的物体列表,以及可生成的敌人出生点。玩家位置进入触发范围后,引擎会根据新 ViewZone 的相机设置重新调整投影,并隐藏上一区域的非全局物体。
typedef struct ViewZone { Rectangle bounds; // 玩家进入/离开的2D触发区域 Camera3D camera; // 当前视角的固定3D相机 int enemySpawns[8]; // 敌人出生点ID列表 int interactables[16]; // 可交互物体ID列表 bool isActive; // 当前视角是否激活 } ViewZone;这个设计让我可以用 Tiled 编辑器规划场景布局,导出 CSV 后由引擎加载。代码里不再硬编码坐标,策划调整相机角度、挖空房间、移动互动点都变得非常直观。整个场景本质上是一长串 ViewZone 的集合,玩家在其中穿行,像看一部由自己控制移动的定格动画。
2.2 碰撞与交互:不做物理引擎也能有手感
生存恐怖游戏对碰撞的需求并不复杂,不需要完整的刚体物理、关节约束、车辆动力学。我采用的是胶囊体碰撞体 + 静态 AABB 检测,玩家角色拥有一颗简化胶囊体,场景里的墙面、家具、门框都是静态 AABB。每一帧检测玩家移动后在 XZ 平面上的碰撞,再单独处理 Y 轴位置,避免倾斜地面带来的不稳定。
这里最值得细说的是“滑墙处理”。坦克式操作中玩家常会推着摇杆朝墙面硬撞,如果速度直接清零,手感会非常僵硬。正确做法是把移动速度向量拆成平行于墙面和垂直于墙面两个分量,保留平行分量,让角色沿墙自然滑过去。这在我试过的所有方案里最接近老式游戏的顺滑感,代码也极短:
Vector3 SlideMove(Vector3 vel, Vector3 normal) { float dot = vel.x * normal.x + vel.z * normal.z; Vector3 result = { vel.x - dot * normal.x, vel.y, vel.z - dot * normal.z }; return result; }交互系统同样走极简路线。玩家面前一定范围内检测可交互物体,按一个按键触发,触发结果可能是开门、拾取、检查、推箱子。没有复杂的上下文菜单,只有“最近的交互目标”这一个概念。对于复古恐怖这种慢节奏玩法,反而更自然,因为操作越少,玩家越容易沉浸在氛围里。
2.3 资源缓存与存档:别让 IO 拖垮恐惧体验
raylib 提供了 LoadTexture、LoadModel 这类加载函数,但如果每个区域都重新加载一次模型,玩家在长走廊来回走几次,就能明显感到卡顿和资源暴涨。我在引擎里做了一个极简的带引用计数的缓存层:所有资源加载前都查一下哈希表,已有资源就让引用计数加一,没有才真正调用 raylib 的 Load。卸载时引用计数归零才真正释放 GPU 内存,这样既避免了重复加载,也避免了一不小心把还在用的贴图释放掉的崩溃。
存档系统用 JSON 格式,记录当前视角 ID、玩家坐标、物品状态、已解锁门锁、敌人存活列表。加载时按存档恢复整个场景状态。这里最容易被忽略的是存档时机,如果在视角切换动画播放到一半时存档,可能会把相机参数存成过渡状态,读档后角色出现在错误的机位。我的解决方法是存档前强制完成当前视角切换,保证所有关键状态处于稳定值。
3. 复古生存恐怖的核心手感是怎么调出来的
3.1 坦克式移动:转向阻尼比速度值更重要
复古生存恐怖最令人印象深刻的操作方式就是坦克式移动:前后键控制前进后退,左右键控制原地转向,角色不会横向平移。实现起来确实不复杂,维护一个朝向角 yaw,左右输入改变角度,前后输入按角度计算位移方向:
yaw += turnInput * turnSpeed * dt; if (moveInput != 0.0f) { move.x = sinf(yaw) * moveSpeed * moveInput * dt; move.z = cosf(yaw) * moveSpeed * moveInput * dt; }代码简单,但手感差异巨大的是转向阻尼。直接给 yaw 加固定角速度,旋转会非常机械,像玩具车原地打转。我加了一个阻尼过程:快速拨动转向时,角度变化先慢后快再慢,停止输入时也保留一点惯性回正。实际实现是让每帧转向量 = 当前转向速度 * 0.85 + 目标输入 * 0.15,这样会得到一个平滑的转向过渡。调完这个细节之后,测试的朋友第一次试玩就说“有内味儿了”。
另外一个细节是移动起停的加速度。角色从静止到最大速度不是瞬时的,而是有一个很短的加速过程。这个加速时长大概控制在 0.2 到 0.4 秒之间,太长显得拖沓,太短又不够复古。反复调下来,0.25 秒的加速是我最舒服的节奏。
3.2 声音距离与脚步循环:恐怖感至少一半来自音频
生存恐怖游戏的音频承担着叙事和张力营造双重作用。我用 raylib 的 Audio 模块加载脚步声、开锁声、敌人嘶吼和环境风声,但最核心的是“距离衰减的敌人音效”。raylib 的 PlaySound 没有原生 3D 衰减,需要手动根据距离计算音量:
float dist = Vector3Distance(playerPos, enemyPos); float vol = 1.0f - (dist - 2.0f) / 23.0f; vol = Clamp(vol, 0.0f, 1.0f); SetSoundVolume(enemyLoop, vol);耳边音量会随着敌人接近逐步变大,玩家能明显感受到“有什么东西在靠近”,但无法透过墙壁判断准确方位。对恐怖游戏来说,这种信息的不对称非常重要,它是焦虑感的来源。
脚步系统我也做了随机化。每次播放脚步时,音高在正负 10% 的范围内随机偏移,间隔也根据角色移动速度动态调整。连续播放同一种音色,哪怕音量一致,玩家也会在两分钟内习惯并忽略;加入随机偏移之后,听觉系统会持续保持注意力。恐怖游戏里,玩家注意力本身就是最宝贵的资源。
3.3 黑暗光照与手电筒:让玩家主动选择恐惧
复古生存恐怖不需要真实物理光照,黑暗本身就是氛围道具。我在场景里只放置少量点光源,通常是一个跟随玩家的近身光加一个很弱的环境光。手电筒是核心道具,它跟随相机方向,但为了让移动时光束不那么机械,我加了朝向延迟:手电光方向每帧向相机方向做插值,人转快了灯会跟不上,营造出一种“举着手电在黑暗中扫视”的真实感。
渲染这个效果并没有走自定义 shader,而是用了一个很讨巧的做法:场景的光照维持在中等偏暗水平,手电照射范围用专用的光照贴图叠加在相机朝向位置,周围再叠一层径向渐暗的遮罩。这样玩家永远能看到自己面前两米左右的范围,而周围环境始终藏在模糊的黑暗中。性能开销几乎为零,效果却非常接近老式游戏想营造的压迫感。
4. 从场景到敌人:玩法闭环的落地记录
4.1 Blender 建模到 raylib 加载的几步流程
美术方面我用 Blender 做低模场景,导出 glTF 后由 raylib 的 LoadModel 加载。第一版导入就遇到了坐标轴不一致的问题:Blender 默认 Z 轴向上,而 raylib 的 3D 世界是 Y 轴向上。模型加载进来旋转全部错乱,碰撞检测也完全对不上。解决方法是导出前在 Blender 里统一把场景应用为 Y 轴向上,检查模型的前方向是否与 -Z 方向一致,随后再测试导入结果。
为了让碰撞体不额外维护一套数据文件,我在模型命名上做了约定:静态场景中所有需要碰撞的物体,名称前缀加 col_。程序加载模型后遍历子节点,凡是带这个前缀的就自动生成 AABB 加入碰撞列表,并且不参与渲染。这样美工在 Blender 里摆放碰撞盒,游戏运行时会自动识别,省掉了导出一份独立碰撞数据的环节。
4.2 敌人 AI:巡逻、警觉、追踪、攻击
敌人 AI 我用了最经典的四状态有限状态机:巡逻、警觉、追踪、攻击。巡逻时敌人在预设路径点之间慢速移动;当玩家进入感知半径并且视线没被阻挡时,切换到警觉状态,停止移动并播放一声短促的嘶吼;随后进入追踪状态,朝玩家所在位置移动。
追踪状态的追击路线并不是直线,而是每隔 0.5 秒重新计算一次朝向,再叠加少量随机扰动。这个扰动让敌人移动轨迹看起来更“有机”,而不是机械地锁定玩家坐标。攻击状态触发条件是距离小于 1.5 米,播放攻击动作并扣除玩家生命值。整套 AI 代码量不大,但每个状态之间的切换时机和动画配合,是制造压迫感的关键。巡逻阶段如果太短,玩家来不及探路就觉得被追得很紧;太长又会无聊。我调了一段时间,最终让敌人在每个路径点停留 2 到 4 秒,给玩家留出观察节奏的空隙。
4.3 固定时间步长:为什么复古游戏不能纯靠渲染帧
在开发早期,我把游戏逻辑直接写在渲染循环里,问题很快浮现:144 Hz 显示器上玩家移动速度几乎是 60 Hz 显示器的两倍,敌人追踪速度飘忽不定,门动画时快时慢。这是因为逻辑更新次数和渲染帧率绑定在一起。
解决办法是固定时间步长。每帧取真实经过的时间,累加到一个 accumulator 里,当累加值超过固定步长(我用 1/60 秒)就执行一次物理与逻辑更新,剩余未消耗的时间累计到下一帧。这样不管渲染帧率是 60 还是 165,游戏内所有逻辑都按照 60 Hz 稳定推进,手感完全一致。
float accumulator = 0.0f; const float dt = 1.0f / 60.0f; while (!WindowShouldClose()) { float frameTime = GetFrameTime(); accumulator += frameTime; while (accumulator >= dt) { UpdateGame(dt); accumulator -= dt; } DrawGame(); }这一步是我在定制引擎里做过最值得的投资。没做之前,所有涉及时间的系统都在碰运气;做完之后,光照闪烁、敌人移动、开关门动画、按键判定全部变得可控可预测。
5. 一路上踩过的坑与性能取舍
5.1 窗口失焦后角色还在自动走路
开发过程中我遇到一个特别恼人的问题:游戏运行中切到其他窗口查资料,再切回来时角色还在朝某个方向自动走。原因是 raylib 的按键状态并不会在窗口失去焦点时自动清空,如果此时玩家正按着 W 键切走,回来时 W 依然处于按下状态。
解决方法是每帧开头检查窗口焦点状态,如果窗口不在前台,就强制重置所有输入映射和按键状态。这个修复很关键,它避免了无数次的“角色跑到墙里”和“敌人追到玩家出生点”的诡异 Bug。
if (!IsWindowFocused()) { ResetInputState(); }5.2 点光源数量:8 个是性能分水岭
在场景里放置点光源非常容易,但数量一多,渲染性能会急剧下降。我的场景里同时激活的点光源一般不超过 8 个,超过这个数后帧率会明显波动。为解决这个问题,引擎里加入了光源优先级:距离玩家最近的前 8 个光源正常渲染,更远的自动降级为低强度环境光,或者直接隐藏。
黑暗环境本来就天然遮罩了远处细节,玩家除非刻意观察,几乎不会注意到远处光源消失。把性能留给玩家眼前可见的光影变化,才是恐怖游戏最合理的资源分配方式。
5.3 存档写了一半崩溃:原文件名覆盖的问题
JSON 存档听起来很安全,但在实际运行中我踩过一次大坑:游戏在写档过程中如果被强制退出或断电,存档文件会停留在半个 JSON 的状态,再次读取时直接解析失败,玩家一整晚的进度全部丢失。
后来我把写档流程改成两步:先写入临时文件,完全写完后用 rename 覆盖正式存档文件。在主流文件系统里 rename 是原子操作,所以旧存档要么完整存在,要么被新存档完整替换,不会出现中间状态。这个习惯我现在做所有需要落盘的数据都会使用,是存储安全里成本最低收益最高的设计。
6. 写在最后:做复古恐怖最大的收获
《黑暗不适》这个项目还在持续打磨,我最近在考虑加入更多可交互物品和多结局分支。如果你也在做独立游戏,尤其想做固定视角恐怖题材,我最大的建议是别急着写一堆复杂系统,先把视角切换、坦克移动、距离衰减音效这三件事调顺,游戏的骨架就立住了。这三种机制的组合在 raylib 里实现起来非常直接,不需要庞大的框架,只需要你愿意一层层抠细节。
我用 raylib 之前也学过一阵子通用引擎,总觉得功能丰富就代表上限更高。做完这个项目后我的判断变了,小而精的定制引擎在特定品类里拥有非常恐怖的上限,因为它把所有无关的复杂度全部剥掉,留下的每一个做决策的点都在为游戏体验服务。如果你也在做复古恐怖之类的强氛围项目,不妨试试给 raylib 一个机会。
本文还有配套的精品资源,点击获取