Phaser接入Gemini生成Tilemap教程

Phaser接入Gemini生成Tilemap教程

本文解决什么问题、适合谁、前置环境

网页小游戏里手写 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。我一开始让它连 tiledversionfirstgidnextlayerid 一起吐,结果十次里有三四次 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,就能看到一张四周被石墙封闭、中间一条泥土路的关卡。加个带物理体的精灵、对 wallthis.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.jssrc/main.tsvite.config.ts、提示词模板与目录结构),可按步骤复制到本地项目中运行;只需替换 .env 里的 GEMINI_API_KEY 和你自己的 tiles.png 即可复现本文效果。

🤖 本文部分内容由 AI 辅助生成,已经作者人工审核、实测与校订。文中 server.jsmain.ts 等代码、Gemini 提示词模板与地图数据样例由 AI 协助起草,作者已在 Phaser 3.90 / Node.js 22 / Gemini 2.5 Flash 环境下逐段实机运行并核对报错与修复方式。如发现事实或代码错误,欢迎在评论区指正。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注