标签: 关卡生成

  • 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 环境下逐段实机运行并核对报错与修复方式。如发现事实或代码错误,欢迎在评论区指正。