网页小游戏开发

03复刻 FF2 开场:瓦片地图、对话队列和剧情脚本

约 30 分钟

先玩一遍:最终幻想 · 第一章 · 完整源码

复刻的是 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 瓦片是唯一一个“走到就触发剧情”的格子:任务没接会被拦回去,接了任务出城就进结局画面。

练习

  1. 给小镇加一张室内地图(武器店),用门 D 瓦片做切换
  2. 必败之战里让“盖”最后才倒下,加一句他的台词
  3. 给每个 NPC 记一个“是否已对话”标记,第二次对话说不同的话