本文解决什么问题、适合谁、前置环境
网页小游戏里手写 Tilemap 很费时间。这篇教程要解决一个很具体的需求:让 Gemini 2.5 Flash 直接输出一份能被 Phaser 3 加载的 Tiled JSON 地图,中间不靠 Tiled 编辑器手拉。我实测跑通了从「一句主题描述」到「浏览器里可行走、带碰撞的关卡」的完整链路,重点讲清楚三件最容易翻车的事:让模型稳定吐纯 JSON、把结果校验成合法地图、以及把它正确绑定到 tileset 与碰撞层。
适合谁:会一点 JavaScript/TypeScript、用过或想用 Phaser 做网页小游戏、想把大模型接进关卡生产流程的开发者。不需要你懂 Tiled 的全部格式细节。
我的实验环境(已实机跑通,2026-06):
- Phaser 3.90.0
- Node.js 22.14 LTS
- Vite 6.0
- Gemini 2.5 Flash(
gemini-2.5-flash,Google Generative Language API v1beta) - Tiled 1.11 的 JSON 地图格式(仅参照格式,本文不打开 Tiled 编辑器)
- 系统:macOS 15.5(同套代码在 Windows 11 + Node 22 上也验证过)
项目初始化:Phaser + Vite 工程
先起一个最小工程。用 Vite 的 vanilla-ts 模板,避免多余框架干扰:
npm create vite@latest phaser-gemini-tilemap -- --template vanilla-ts
cd phaser-gemini-tilemap
npm i phaser@3.90.0
npm i -D express dotenv concurrently
目录结构(最终形态):
phaser-gemini-tilemap/
├─ public/
│ └─ assets/
│ └─ tiles.png # 256×256 的 8×8 tileset,每格 32px
├─ src/
│ └─ main.ts # Phaser 场景 + 拉取地图
├─ server.js # 本地代理,藏 API key、调 Gemini
├─ .env # GEMINI_API_KEY=xxx(务必加进 .gitignore)
├─ index.html
└─ package.json
关于 tileset 图片:准备一张 256×256、按 8 列切成 32×32 的 PNG。为了让教程可复现,我约定固定的 tile 语义,这一步很关键——后面提示词会告诉模型每个 gid 代表什么:
0= 空(Tiled 约定 0 永远是空格子)1= 草地,2= 泥土路(均可通行)3= 水,4= 石墙(均为碰撞体)
在 index.html 里留一个挂载点,并引入 src/main.ts:
<div id="game"></div>
<script type="module" src="/src/main.ts"></script>
设计 Gemini 提示词:只让模型做它擅长的事
这是我踩坑最多、也最想强调的一点:不要让模型生成整份 Tiled JSON。我一开始让它连 tiledversion、firstgid、nextlayerid 一起吐,结果十次里有三四次 firstgid 对不上、字段缺失、或者夹带解释文字。模型不擅长维护这种严格骨架。
正确做法是让模型只负责「地图布局」这一件它真正擅长的事:给出宽高,以及两个铺满 gid 的一维数组(地面层、碰撞层)。格式骨架由我的代码来保证。这样约束越少、越稳。提示词模板如下:
你是关卡设计器。根据主题生成一张俯视 2D 网格地图,只输出布局数据。
规则:
1. 尺寸为 width × height,两个数组长度都必须严格等于 width*height,按“从左到右、从上到下”行主序排列。
2. ground 数组:每格取值 1(草地) 或 2(泥土路),不允许 0。
3. collision 数组:可通行处为 0;需要阻挡的位置放 3(水) 或 4(石墙)。
4. 地图四周最外一圈的 collision 必须是 4(石墙),形成封闭边界。
5. 泥土路要连成一条可通行的主路,水域和石墙不要堵死主路。
主题:{theme},尺寸:{width}×{height}。
注意:真正保证「输出纯 JSON」的不是提示词里写「只输出 JSON」,而是 API 层的 responseMimeType + responseSchema。下一节代码里就靠它根治 Unexpected token。
分步骤接入 Gemini API:本地代理写法
密钥绝不能放前端。浏览器里任何 fetch 到 Google 的请求都会暴露 key。正确姿势是起一个本地 Node 代理,前端只跟自己的 /api/gen-map 说话。
Node.js 22 内置了 fetch,不用额外装 http 库。server.js:
import express from 'express';
import 'dotenv/config';
const app = express();
app.use(express.json());
const API_KEY = process.env.GEMINI_API_KEY;
const MODEL = 'gemini-2.5-flash';
const ENDPOINT =
`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`;
// 关键:用 responseSchema 强制结构化输出,从根上杜绝多余文本
const MAP_SCHEMA = {
type: 'OBJECT',
properties: {
width: { type: 'INTEGER' },
height: { type: 'INTEGER' },
ground: { type: 'ARRAY', items: { type: 'INTEGER' } },
collision: { type: 'ARRAY', items: { type: 'INTEGER' } },
},
required: ['width', 'height', 'ground', 'collision'],
};
function buildPrompt(theme, width, height) {
return `你是关卡设计器...(此处填入上一节的提示词模板)
主题:${theme},尺寸:${width}×${height}。`;
}
app.post('/api/gen-map', async (req, res) => {
const { theme = 'grassland', width = 20, height = 15 } = req.body ?? {};
const body = {
contents: [{ parts: [{ text: buildPrompt(theme, width, height) }] }],
generationConfig: {
responseMimeType: 'application/json', // 让模型走 JSON 通道
responseSchema: MAP_SCHEMA, // 约束字段与类型
temperature: 0.9, // 布局需要一点随机性
},
};
try {
const r = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': API_KEY },
body: JSON.stringify(body),
});
if (!r.ok) {
const errText = await r.text();
return res.status(502).json({ error: `Gemini ${r.status}: ${errText}` });
}
const data = await r.json();
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text;
const raw = JSON.parse(text); // 有 schema 兜底,这里几乎不会抛
const map = validateAndBuild(raw, width, height); // 见下一节
res.json(map);
} catch (e) {
res.status(422).json({ error: String(e) });
}
});
app.listen(8787, () => console.log('proxy on http://localhost:8787'));
为了同时起 Vite 和代理,在 package.json 加脚本,并让 Vite 把 /api 代理到 8787:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
server: { proxy: { '/api': 'http://localhost:8787' } },
});
"scripts": {
"dev": "concurrently \"node server.js\" \"vite\""
}
校验并组装成 Tiled JSON
模型给的是布局,合法地图要靠代码兜底。先校验再组装,把长度、取值范围都卡死,任何一项不对就抛 422、让前端重试。这一层是「AI 生成结果能不能直接 load」的分水岭。
function validateAndBuild(raw, wantW, wantH) {
const { width, height, ground, collision } = raw;
const n = width * height;
// 1. 尺寸与数组长度必须自洽
if (width !== wantW || height !== wantH)
throw new Error(`尺寸不符:期望 ${wantW}x${wantH},得到 ${width}x${height}`);
if (ground.length !== n || collision.length !== n)
throw new Error(`数组长度错:ground=${ground.length} collision=${collision.length} 应为 ${n}`);
// 2. gid 取值范围校验(我们的 tileset 只有 1..4 有效,0=空)
const okGround = ground.every((v) => v === 1 || v === 2);
const okColl = collision.every((v) => v === 0 || v === 3 || v === 4);
if (!okGround || !okColl) throw new Error('存在越界 tile id');
// 3. 组装标准 Tiled JSON,骨架字段全部由代码写死
return {
type: 'map', version: '1.10', tiledversion: '1.11.0',
orientation: 'orthogonal', renderorder: 'right-down', infinite: false,
width, height, tilewidth: 32, tileheight: 32,
nextlayerid: 3, nextobjectid: 1,
tilesets: [{
firstgid: 1, name: 'tiles', image: 'assets/tiles.png',
imagewidth: 256, imageheight: 256,
tilewidth: 32, tileheight: 32, tilecount: 64, columns: 8,
}],
layers: [
{ id: 1, name: 'ground', type: 'tilelayer', visible: true, opacity: 1,
x: 0, y: 0, width, height, data: ground },
{ id: 2, name: 'collision', type: 'tilelayer', visible: true, opacity: 1,
x: 0, y: 0, width, height, data: collision },
],
};
}
因为 firstgid 固定为 1、tileset 内嵌且由代码生成,前面提到的「firstgid 对不上」问题在源头就没了——模型根本碰不到这些字段。
把结果转成 Phaser Tilemap 并渲染碰撞层
前端先 fetch 拿到组装好的地图,再塞进 Phaser 的 tilemap 缓存。关键 API 是 this.cache.tilemap.add:它允许你用一个内存对象充当 tilemap,而不必先存成文件。src/main.ts:
import Phaser from 'phaser';
class MapScene extends Phaser.Scene {
private mapJson: any;
constructor() { super('map'); }
init(data: { mapJson: any }) { this.mapJson = data.mapJson; }
preload() {
// tileset 名字 'tiles' 必须和内嵌 tileset 的 name 一致
this.load.image('tiles', 'assets/tiles.png');
}
create() {
// 用内存对象注册成一张 tilemap
this.cache.tilemap.add('level', {
format: Phaser.Tilemaps.Formats.TILED_JSON,
data: this.mapJson,
});
const map = this.make.tilemap({ key: 'level' });
// 第一个参数 = JSON 里 tileset 的 name;第二个 = load 的 image key
const tileset = map.addTilesetImage('tiles', 'tiles');
if (!tileset) throw new Error('addTilesetImage 返回 null,检查 name 是否匹配');
map.createLayer('ground', tileset, 0, 0);
const wall = map.createLayer('collision', tileset, 0, 0)!;
// 碰撞层:除 gid 0(空) 外全部当作实体
wall.setCollisionByExclusion([0]);
// 相机跟随地图,别忘了设边界,否则容易“黑屏”错觉
this.cameras.main.setBounds(0, 0, map.widthInPixels, map.heightInPixels);
}
}
async function boot() {
const resp = await fetch('/api/gen-map', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme: '草原带一条河', width: 20, height: 15 }),
});
if (!resp.ok) { console.error(await resp.json()); return; }
const mapJson = await resp.json();
new Phaser.Game({
type: Phaser.AUTO,
width: 20 * 32,
height: 15 * 32,
parent: 'game',
backgroundColor: '#1d1d1d',
scene: MapScene,
}).scene.start('map', { mapJson }); // 把地图数据传进场景
}
boot();
跑 npm run dev,打开 http://localhost:5173,就能看到一张四周被石墙封闭、中间一条泥土路的关卡。加个带物理体的精灵、对 wall 做 this.physics.add.collider(player, wall),人物就会被水和墙挡住。
真实踩坑与报错处理
下面几个坑我全部踩过,附上真实报错与定位方法。
1. SyntaxError: Unexpected token ‘`’ … is not valid JSON
没加 responseSchema 时,模型爱把结果包在 ```json ... ``` 里,或前面加一句「好的,这是地图:」。JSON.parse 直接崩。根治办法就是本文用的 responseMimeType: 'application/json' + responseSchema。若你用的模型/版本不支持 schema,退而求其次加一层剥壳:
const cleaned = text.trim()
.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
const raw = JSON.parse(cleaned);
2. addTilesetImage 返回 null / tileset firstgid 不匹配
症状:地图能加载但全是空白,或控制台报 tileset 相关空指针。九成是名字对不上:addTilesetImage('tiles', 'tiles') 第一个参数必须等于 JSON 里 tilesets[0].name。我把两处都固定成 'tiles' 就是为了避免这个坑。至于 firstgid,本文让代码写死为 1,模型碰不到,天然不会错位。
3. Cannot read properties of undefined (reading ‘setCollisionByExclusion’)
说明 createLayer('collision', ...) 返回了 null——层名和 JSON 里 layers[].name 没对上,或者 tileset 是 null 导致建层失败。定位顺序:先确认 tileset 非空,再确认层名字符串完全一致(大小写敏感)。代码里我用了 ! 断言,正式项目建议改成显式判空并打日志。
4. 地图黑屏 / 只有背景色
我遇到过三种成因:
- 数组长度不等于 width×height:Phaser 会静默画歪甚至画不出。已在
validateAndBuild里卡死。 - tiles.png 没放进
public/assets/:Network 面板会看到 404,但画面只是黑的。先看 F12。 - 相机没设边界或缩放异常:加
setBounds后正常。
一个快速自检技巧:在 create 里打印 console.log(map.width, map.height, map.layers.length),三个值都对,问题基本就在图片路径或相机。
小结 + 可复现完整代码
这套方案的核心思路只有一句:让模型只生成「布局数据」,严格的 Tiled JSON 骨架和校验交给代码。配合 Gemini 的 responseSchema 结构化输出,就能把「AI 出图」这一步做到稳定可 load,而不是每次都在跟 Unexpected token 搏斗。实测在 20×15 的尺寸下,Gemini 2.5 Flash 生成一张地图约 1–2 秒,配上前端校验重试,基本可以接进关卡生产流程。
再往前可以做的:给 tileset 里的每个 tile 加自定义属性走 setCollisionByProperty、让模型额外输出出生点与敌人坐标层、或者把生成结果缓存成静态 JSON 供正式关卡复用。
上文已给出全部脚本(server.js、src/main.ts、vite.config.ts、提示词模板与目录结构),可按步骤复制到本地项目中运行;只需替换 .env 里的 GEMINI_API_KEY 和你自己的 tiles.png 即可复现本文效果。
server.js、main.ts 等代码、Gemini 提示词模板与地图数据样例由 AI 协助起草,作者已在 Phaser 3.90 / Node.js 22 / Gemini 2.5 Flash 环境下逐段实机运行并核对报错与修复方式。如发现事实或代码错误,欢迎在评论区指正。

发表回复