03复刻 FF2 开场:瓦片地图、对话队列和剧情脚本
先玩一遍:最终幻想 · 第一章 · 完整源码
复刻的是 FC 版最终幻想 2 的开场:主角四人被帝国追兵击败(一场打不赢的仗)→ 在反抗军基地醒来 → 见到公主接任务 → 走出小镇。素材和台词全是自己写的,只复刻结构。
一、地图就是字符串数组
瓦片地图不需要任何库,二维字符数组就够了:
grid: [
'##########',
'#BBBBBBBB#',
'####DD####',
]
每个字符一种地形:# 墙、B 地板、D 门、~ 水、G 城门。渲染时逐格画图,行走时查 solid 字符串里有没有这个字符:
function tileAt(x, y) {
if (x < 0 || y < 0 || x >= cur.w || y >= cur.h) return '#'; // 越界算墙
return cur.grid[y][x];
}
写地图最容易踩的坑:每行长度必须一致,否则某行短一截,玩家能走到地图外的虚无里。这个版本里我提交前专门跑了一段脚本校验所有行等宽——值得抄进自己的流程。
二、摄像机:不是移动玩家,是移动世界
视口固定 10×9 格,地图比视口大时,摄像机以玩家为中心、夹在地图边界内:
camX = Math.max(0, Math.min(player.x - (viewW >> 1), cur.w - viewW));
渲染时所有东西都减去 camX * T 再画。玩家始终画在屏幕中央附近,世界在他脚下移动。
三、对话是一个队列,不是一个弹窗
RPG 的对话会排队:剧情里常常四五句连着来。所以结构是“队列 + 逐条弹出”:
let dlgQueue = [];
function say(name, lines, onDone) {
dlgQueue = lines.map(t => ({ name, text: t }));
dlgQueue.onDone = onDone; // 整段播完后的回调
nextDlg();
}
关键是那个 onDone——剧情脚本的推进全靠它:开场战斗的“围上来了”播完才打开指令菜单;全灭后的“世界暗了下去”播完才切场景到基地。
对话打开时要锁输入,不然玩家会一边看台词一边走出屏幕。
四、“必败之战”是一个反向的战斗系统
FF2 开场最妙的设计:第一场战斗注定失败,逃跑也无效。这在代码上反而是最省事的战斗——不需要平衡,只需要演出:
- 玩家回合正常出刀(伤害个位数,打在 80 血的士兵身上像挠痒)
- 敌人回合每个士兵一次“秒杀式”反击,直接把某个队友 HP 清零
逃跑选项存在,但永远返回“被包围了,逃不掉”
玩家能操作、能挣扎、然后还是输——比直接播放画外音“你们被打败了”冲击力强得多。让玩家亲手输掉,是叙事类游戏最常用的小花招。
实现上这里有个细节:逃跑也不能白逃,每次失败的逃跑都触发一轮敌方反击,否则玩家可以无限选择逃跑卡死不推进剧情。
五、三个场景其实是一台状态机
整个游戏只有 battle / walk / end 三个顶层场景,渲染函数各管各的:
battle(开场战)→ fade → walk(基地+小镇)→ end(第一章完)
场景切换全靠 say() 的 onDone 回调串起来,不需要专门的事件系统。城门 G 瓦片是唯一一个“走到就触发剧情”的格子:任务没接会被拦回去,接了任务出城就进结局画面。
练习
- 给小镇加一张室内地图(武器店),用门
D瓦片做切换 - 必败之战里让“盖”最后才倒下,加一句他的台词
- 给每个 NPC 记一个“是否已对话”标记,第二次对话说不同的话