做 FNF 模组开发时,最容易被卡住的往往不是美术,而是角色接入和谱面调通这两步。尤其当你做的还是“换主角”这种玩法变体,比如把原版按 Pico 的战斗视角重新设计一遍,就会发现角色 XML、Lua 逻辑、动画帧名、谱面 JSON 这四块必须同时对齐,任何一环脱节都会直接黑屏或者爆 Null。这篇教程就以“fnf vs 享利(但是Pico)”作为实战项目,从环境搭建、角色配置、脚本逻辑到谱面编排,完整拆一套可复现的模组制作流程。新手可以按步骤攒出第一个可玩 Mod,有基础的开发者也能从配置规范和排错清单里找到可复用的经验。
1. 背景与核心概念
1.1 什么是 FNF 模组
Friday Night Funkin'(简称 FNF)是一款基于节奏对战玩法的开源游戏,玩家控制的角色需要在节拍内按下对应方向的音符,和对手轮流演唱,更像一场带演出效果的音游 PK。游戏本体使用 HaxeFlixel 引擎开发,底层是 Haxe 语言加 OpenFL 渲染,天然具备比较好的跨平台能力和社区扩展性。
“模组”(Mod)就是在这个基础上做二次创作。它可以是新增一首歌、一个新角色、一张新背景,也可以是完全替换整套玩法。FNF 的模组生态非常活跃,大量社区创作者通过替换美术资源、谱面数据和脚本逻辑,做出了各种“如果某某角色出场会怎样”的平行世界对战。
本文要做的“fnf vs 享利(但是Pico)”,本质属于“换主角 + 新对战角色”的复合型 Mod。玩家将使用 Pico 的立绘、动画和技能动作,去迎战自定义角色“享利”。这种做法的核心不是写游戏引擎,而是把 FNF 的角色资源规范、动画配置和谱面文件理解透。
1.2 Pico 角色定位与原版差异
Pico 是 FNF 宇宙里的经典角色,也是 Pico's School 系列的代表人物。在 FNF 主线里,Pico 以朋友和助战者的身份登场,有自己的主题曲“Pico”,形象特征是红色贝雷帽、严肃表情和比较利落的攻击动作。战斗中他的出牌节奏和待机动画都有自己的节奏感,改造成玩家操作角色时,需要注意几个细节:
- 待机动画和唱歌动画的帧率要匹配 BPM。
- 他的音符攻击动作不适合直接套用男友的默认帧,否则会显得“换皮不换骨”。
- 作为常驻对战角色时,需要给他设计完整的四方向音符反馈,不能只做上下左右某一个方向。
“享利”是这次 Mod 里的对战对象,可以理解成一个自定义 NPC 角色。它的立绘、动画、语音都不来自 FNF 原版,需要创作者自行绘制或者二创整理。所以在制作时,我们要把他的角色包按照 FNF 的规范组织好,并让 Pico 的脚本逻辑能够正确读取和响应。
1.3 模组开发的基本流程
一次完整的 FNF 模组制作通常分成下面几个阶段:
- 确定玩法:是原曲换角色,还是全新曲目加全新对手。
- 素材准备:立绘、待机图、动画帧、背景图、精灵表(Sprite Sheet)。
- 角色接入:把素材裁剪、命名,并让游戏引擎能识别角色动画。
- 谱面制作:编辑歌曲的音符落点,形成 JSON 谱面文件。
- 脚本逻辑:如果需要自定义对话、阶段动画或特殊机制,用 Lua 脚本实现。
- 本地测试:反复调整节奏、动画帧和难度曲线。
- 发布打包:整理成规范的 Mod 目录,方便玩家一键安装。
整个流程里,环境搭建是第一道门槛,也是很多人一开始就放弃的地方。下面先看环境怎么准备。
2. 环境准备与版本说明
2.1 搭建 HaxeFlixel 开发环境
FNF 本体基于 HaxeFlixel,所以做模组需要对 Haxe 工具链有基本了解。Haxe 是一门可编译为多平台代码的编程语言,FNF 主要使用它的 JavaScript 和 C++ 导出能力。
安装顺序建议如下:
- 安装 Haxe。
- 安装 OpenFL。
- 安装 HaxeFlixel。
- 安装 Visual Studio Code,并装好 Haxe 扩展和 Lua 扩展。
- 安装 Git,方便拉取引擎源码和后续版本管理。
命令行安装方式是:
# 安装 Haxe 后,在终端执行 haxelib install openfl haxelib install flixel haxelib install flixel-addons haxelib install flixel-ui需要注意的是,Haxe 库的版本会随着 FNF 引擎版本变化而不同。官方仓库的project.xml或haxelib.json会写明具体依赖版本,直接按它来装最稳妥。不同版本的 FNF 源码对 Haxe 版本的要求也有差异,建议优先使用引擎作者锁定的版本,不要盲目装最新版。
2.2 获取引擎源码
FNF 的模组开发通常有两条路线:
- 直接基于原版 FNF 源码改。
- 基于社区增强引擎 Psych Engine 开发。
原版源码的优点是“原汁原味”,但开发和测试体验对新手不够友好;社区引擎通常已经内置了角色配置读取、Lua 脚本支持、谱面自动加载等功能,非常适合做“新角色 + 新歌曲”类 Mod。
本文示例优先使用 Psych Engine 这类社区引擎。选择原因是:
- 角色可直接通过
characters/目录下的 XML 配置。 - 谱面 JSON 放在
songs/目录后会自动识别。 - Lua 脚本支持自定义多数对战事件。
- 内置 Debug 菜单,方便测试角色动画和谱面。
获取方式:
git clone https://github.com/your-engine-source/psych-engine.git cd psych-engine把仓库拉下来之后,用 Visual Studio Code 打开工程,执行编译命令前先确认依赖已经安装完成。具体编译命令在引擎文档里会写,本文演示以常见命令为例。
2.3 素材与工具准备
做角色素材时,最常用的是 Aseprite 或 Photoshop。Aseprite 更适合做像素风精灵表,Photoshop 更擅长处理大图和逐帧拆分。本文不限定工具,但建议满足以下条件:
- 输出图片为透明背景 PNG。
- 单帧尺寸统一,比如 150×150 像素。
- 动画帧按行排列,生成一张完整的精灵表。
- XML 中的帧名和图片中的裁切区域一一对应。
谱面制作可以使用 FNF 社区常见的谱面编辑器,也可以直接用文本编辑 JSON。后面对着 JSON 结构慢慢改也能用,只是效率低一些。如果歌曲节奏比较复杂,建议先用编辑器可视化排布,再导出 JSON。
3. 模组核心文件与配置解析
3.1 目录结构说明
一个典型的 FNF 模组目录长这样:
your-mod/ ├── data/ │ └── songs/ │ └── henry-pico/ │ ├── henry-pico.json │ └── henry-pico.easy.json ├── images/ │ ├── characters/ │ │ ├── pico/ │ │ │ ├── pico.xml │ │ │ └── pico.png │ │ └── henry/ │ │ ├── henry.xml │ │ └── henry.png │ └── ui/ ├── songs/ │ └── henry-pico/ │ ├── Inst.ogg │ └── Voices.ogg ├── scripts/ │ └── henry-pico.lua └── mods.json不同引擎的目录命名可能有差异,比如 Psych Engine 会把音乐放在songs/下,角色放在images/characters/下。模块化的好处是,资源加载时引擎能根据名字自动匹配文件。
3.2 角色 XML 配置解析
FNF 的角色动画系统依赖 XML 文件来描述精灵表的切分方式。一个角色 XML 通常长这样:
<?xml version="1.0" encoding="UTF-8"?> <TextureAtlas imagePath="pico.png"> <SubTexture name="pico idle0000" x="0" y="0" width="150" height="150"/> <SubTexture name="pico idle0001" x="150" y="0" width="150" height="150"/> <SubTexture name="pico singLEFT0000" x="300" y="0" width="150" height="150"/> <SubTexture name="pico singDOWN0000" x="450" y="0" width="150" height="150"/> <SubTexture name="pico singUP0000" x="600" y="0" width="150" height="150"/> <SubTexture name="pico singRIGHT0000" x="750" y="0" width="150" height="150"/> </TextureAtlas>重点在于:
name是动画帧的唯一标识,引擎根据它加载对应动画。x、y、width、height表示这张小图在大图中的位置。imagePath指向同目录下的 PNG 文件。
常见的坑是 XML 里帧名和代码中调用的动画名不一致。比如你在 Lua 里写playAnim('henry left'),但 XML 里是henry singLEFT0000,就必然触发找不到动画的报错。
3.3 动画帧与命中框
FNF 角色的每个动画是由若干连续帧组成。XML 只定义了资源边界,动画节奏是由代码或脚本控制的。引擎通常默认每 24 帧切一次动画,也就是每秒播放 24 帧。
做“vs 享利”这种对战角色时,要保证待机动画的节奏和歌曲 BPM 对齐。比如歌曲 BPM 是 120,那么每拍 0.5 秒,如果待机动画只有 4 帧,每帧停留 1/8 秒就会显得特别跳。一般处理方式是让待机动画有呼吸感,而不是一直高频抖动。
命中框(hitbox)参数决定音符落到角色身上时,攻击动画是否有效。这部分通常不写在 XML 里,而是在角色初始化代码或 Lua 脚本中配置。对新手来说,先保证动画能正常播放,再去调命中框手感。
3.4 Lua 脚本实现对战逻辑
Lua 是 FNF 模组中做自定义逻辑的主要手段。Psych Engine 对 Lua 的支持是:只要在scripts/目录下放同名.lua文件,就会在加载歌曲时自动执行。
最简单的脚本结构:
-- 文件路径:scripts/henry-pico.lua function onCreate() -- 初始化逻辑,比如修改角色位置 debugPrint('Mod loaded!') end function onBeatHit() -- 每个节拍触发一次 -- 可以在这里做闪白、换表情等效果 end function onStepHit() -- 每个步进触发一次 end function onSongStart() -- 歌曲开始时触发 end function onUpdate(elapsed) -- 每帧更新,适合做平滑过渡 end function opponentNoteHit(id, direction, noteType, isSustainNote) -- 对手命中音符时触发 end这些回调函数在不同引擎版本中可能存在命名差异。比如有的引擎用onNoteMiss,有的用onPlayerMiss。最稳妥的做法是打开引擎源码里的 Lua 文档,确认当前版本支持哪些回调。
3.5 谱面 JSON 格式
FNF 的谱面 JSON 很长,但核心结构稳定。一个简化示例:
{ "song": { "notes": [ { "sectionNumber": 0, "mustHitSection": true, "bpm": 120, "sectionNotes": [ [0, 1, 0, 500], [0, 2, 1, 400], [1, 0, 0, 300] ] } ], "player1": "pico", "player2": "henry", "song": "Henry Pico", "bpm": 120, "speed": 2.0 } }关键字段含义:
sectionNotes数组内每个元素:第一个数字是节拍步数,第二个是方向(0 左,1 下,2 上,3 右),第三个是长度(0 表示普通音符,大于 0 表示长条),第四个是秒数偏移。player1和player2分别对应玩家角色和对手角色,值必须和characters/下配置的角色名一致。speed是谱面滚动速度,数值越大音符下落越快。
谱面 JSON 最容易出错的地方是sectionNumber的切分与mustHitSection的状态切换。如果某个小节没标对,音符会在玩家侧和对手侧之间乱跳。
4. 实战:制作“vs 享利(但是Pico)”
4.1 创建项目结构
打开引擎源码目录后,找到mods/或assets/的对应位置,先创建本次 Mod 的独立文件夹。推荐结构如下:
mods/henry-pico/ ├── data/songs/henry-pico/ ├── images/characters/henry/ ├── images/characters/pico/ ├── songs/henry-pico/ └── scripts/使用命令行创建:
mkdir -p mods/henry-pico/{data/songs/henry-pico,images/characters/henry,images/characters/pico,songs/henry-pico,scripts}创建完成之后,再把已经画好的角色素材放进去。实际工作中,我建议不要直接在源码目录里改,先做好独立文件夹,再通过引擎的 Mod 加载机制来测试,这样不会污染主工程,也方便整体打包发布。
4.2 添加角色配置
把 Pico 和享利的角色包分别放入images/characters/。目录里至少包含:
pico.pngpico.xmlhenry.pnghenry.xml
以享利的 XML 为例,如果他的动画帧是 6 组,每组 2 帧,那 XML 里就要写 12 条 SubTexture。一条正确的帧定义如下:
<SubTexture name="henry idle0001" x="300" y="0" width="150" height="150"/>接着在谱面 JSON 中把player1写成pico,player2写成henry。这样引擎启动战斗时,就会自动加载两个角色的素材。
有一个细节比较重要:角色的 JSON 配套文件。部分引擎会额外读取characters/角色名.json,里面配置角色缩放、偏移、初始位置、颜色等参数。如果缺少这个文件,角色可能会显示在舞台正中间,或者大小明显不对。下面是一个极简版:
{ "animations": [ { "anim": "idle", "prefix": "henry idle", "fps": 24, "loop": true }, { "anim": "singLEFT", "prefix": "henry singLEFT", "fps": 24, "loop": false } ], "position": [500, 350], "scale": 1.0, "healthbar_colors": [160, 80, 80] }这个文件的字段名在不同引擎里有差异,但prefix与动画名的对应关系是通用的。前缀要和 XML 的name字段匹配,比如henry idle能匹配到henry idle0000和henry idle0001。
4.3 编写对战脚本
本次 Mod 想实现的效果是:开战前有一段自定义对话,战斗中当享利血量低于一半时,背景闪红一次并播放特殊音效。
把这些效果全部写进 Lua 脚本。
-- 文件路径:scripts/henry-pico.lua function onCreate() makeLuaText('introText', 'Henry vs Pico', 500, 200, 300) screenCenter('introText', 'x') addLuaText('introText') end function onBeatHit() if curBeat == 0 then removeLuaText('introText', true) end end function onUpdate(elapsed) local hp = getProperty('health') if curBeat > 96 and hp < 0.4 then setProperty('defaultCamZoom', 1.1) setProperty('bgFlash.alpha', 0.6) end end function opponentNoteHit(id, direction, noteType, isSustainNote) if noteType == 'Henry Special' then triggerEvent('Play Sound', 'henry-special', 0) end end脚本中用到getProperty、setProperty、makeLuaText、triggerEvent等函数名,在 Psych Engine 的 Lua API 中属于高频常用函数。具体参数名可能因版本不同稍有变化,如果运行报错,优先查看引擎自带的 Lua 文档或现有案例脚本。
4.4 配置歌曲与谱面
歌曲文件需要放到songs/henry-pico/下。音频一般包含两个文件:
Inst.ogg:纯伴奏。Voices.ogg:人声。
如果本模组使用 Pico 原版素材,人声部分需要自行处理或者只保留伴奏。当Voices.ogg缺失时,对战会变成没有演唱感的纯乐器模式,代码一般不报错,但体验打折。
谱面 JSON 中的小节数要能和歌曲长度对上。假设歌曲是 4 分钟、BPM 120,那大致有 192 个小节。如果你只写了 10 个小节的谱面,游戏会在 10 个小节之后出现无音符可打的情况,只能干等歌曲结束。因此谱面编辑最好在可视化工具中完成,再导入 JSON。
4.5 进入测试与验证
保存所有文件后,回到 Visual Studio Code 终端,执行编译命令:
lime test html5如果没有接外设,HTML5 模式最方便。编译完成后浏览器会启动游戏,然后在自由游玩菜单里选中Henry Pico这首歌。如果顺利,应该看到:
- Pico 出现在玩家侧,待机动画正常循环。
- 享利出现在对手侧,开场表情正常。
- 音符按谱面下落,命中时角色有对应的唱歌动画。
- 血量条正常扣减,胜负判定可以正常结束。
如果只看到黑屏或者字符乱窜,不要慌,大部分情况都能通过日志或控制台输出定位。比如 Haxe 编译时的报错会直接指出第几行调用了不存在的变量,Lua 脚本的debugPrint输出也能帮助定位执行顺序。
5. 常见问题与排查思路
5.1 角色显示异常,图片拉伸或透明
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 角色变成大块色斑 | PNG 非透明背景 | 检查素材导出时是否裁剪正确 |
| 角色太小或太大 | 角色 JSON 中 scale 配置异常 | 调整 scale 为合理值,通常 0.7~1.2 |
| 角色位置偏离舞台 | position 设置超出舞台范围 | 基于 1280×720 分辨率调试 |
| 角色全身发黑 | XML 的 SubTexture 坐标错误 | 重新核对大图的切图坐标 |
5.2 谱面加载失败或闪退
常见原因有三个:音频文件格式不兼容、JSON 里字段拼写错误、player1角色名不存在。
排查顺序建议:
- 先单独播放
Inst.ogg和Voices.ogg,确认音频本身有效。 - 用在线 JSON 校验工具检查谱面文件格式。
- 打开源码日志,查看是角色加载失败还是谱面数据缺失。
一个容易被忽视的坑是路径大小写。Linux 环境下,Henry与henry可能会被当成不同路径,但 Windows 不区分。发布前统一小写命名,能省掉大量跨平台问题。
5.3 动画不播放,角色一直保持第一帧
这通常是因为 XML 帧名前缀和动画配置前缀不匹配。比如 XML 里写的是:
<SubTexture name="henry idle0000" .../>但角色 JSON 里写的是:
{"anim": "idle", "prefix": "henry idle "}注意prefix末尾有无空格,以及引擎是否自动补零。稳妥做法是让 XML 里的帧名严格按角色名 动画名0000这一规律命名,不要夹带多余字符。
5.4 Lua 脚本报错
Lua 报错在控制台通常以Lua Error开头。高频问题包括:
- 使用了当前引擎不存在的回调函数名。
- 调用了
getProperty时传入的路径写错。 - 在
onUpdate里每帧创建新的文本对象,导致内存暴涨。
快速定位方法:在关键回调前加debugPrint('here'),逐步缩小范围。脚本类问题大多不是语法难,而是函数名和属性路径没对齐。
6. 最佳实践与工程建议
6.1 素材命名规范
角色动画帧的命名直接决定开发效率。建议统一为:
- 待机:
角色名 idle0000开始 - 左唱:
角色名 singLEFT0000开始 - 下唱:
角色名 singDOWN0000开始 - 上唱:
角色名 singUP0000开始 - 右唱:
角色名 singRIGHT0000开始
这样写角色 JSON 时,前缀规则一目了然,后续换角色时也只需要整体替换。
6.2 谱面与歌曲节奏对齐策略
做谱面时不要把注意力全放在音符密度上。先定 BPM,再定小节数,最后填音符。比较稳妥的流程是:
- 用 DAW 确认歌曲 BPM。
- 在谱面编辑器中设定 BPM 和偏移量。
- 先用四拍一个音的方式完成整首歌骨架。
- 再逐步细化,加入长条和组合键。
如果歌曲中途有变速或停顿,需要拆成多个 BPM 区间。FNF 谱面 JSON 允许不同小节指定不同 BPM,但写起来很容易乱,建议用可视化编辑器处理。
6.3 模组兼容性
社区引擎更新很快,一个 Mod 可能今天能跑,明天引擎升级后角色 JSON 的字段名变了就报错。外部依赖较少的 Mod 更容易长期维护。尽量把逻辑写在 Lua 脚本而非改引擎源码,这样即使引擎升级,只要 Lua API 没大改,脚本还能继续用。
6.4 发布与版权
发布到 GameBanana 或 GitHub 时,注意素材来源。如果用了别人现成的 Pico 素材,需要在简介中标注原作者或确认二创许可。自制素材则建议附上简单的创作声明。FNF 模组社区整体比较开放,但不能因此忽视基本版权规范。
7. 总结与学习路线
7.1 本文要点回顾
通过“fnf vs 享利(但是Pico)”这个实例,核心掌握了四件事:
- FNF 模组的目录结构和资源加载逻辑。
- 角色 XML、角色 JSON、精灵表三者的配合方式。
- Lua 脚本处理自定义战斗事件。
- 谱面 JSON 的简易结构和对齐思路。
这些内容不只适用于本文角色,换成任何新角色、新歌曲,都能按同样流程推进。
7.2 下一步可以学什么
如果你打算继续深入 FNF 模组开发,可以往这几个方向扩展:
- 学习 Haxe 语言基础,直接修改引擎源码。
- 学习事件系统,比如镜头抖动、屏幕滤镜、弹窗对话、动画事件触发器。
- 研究 FNF 原版歌曲的谱面结构,分析它们如何设计难度曲线。
- 尝试把音游逻辑迁移到其他 HaxeFlixel 项目。
平时遇到问题,先看引擎自带的示例、官方文档和社区源码,比到处问人更高效。模组开发本质上是一个“素材管道 + 脚本控制”的过程,只要把资源规范吃透,后面做再复杂的角色都会顺手很多。希望这篇教程能帮你少踩几个坑,早日做出自己的第一个可玩 Mod。