/** * Generate 10 Tiled-compatible JSON maps per PRD ยง10. * Also writes .tmx for optional `tiled --export-map` round-trip. */ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const outDir = path.join(__dirname, '../public/assets/maps'); const tmxDir = path.join(__dirname, '../mapsrc'); fs.mkdirSync(outDir, { recursive: true }); fs.mkdirSync(tmxDir, { recursive: true }); const TILE = 16; const TILESET = { firstgid: 1, columns: 16, image: '../tilesets/village.png', imagewidth: 256, imageheight: 128, tilecount: 128, tilewidth: 16, tileheight: 16, name: 'village', margin: 0, spacing: 0, tiles: [ // collidable tiles ...[9, 10, 11, 12, 13, 14, 15, 19, 20, 21, 22, 24, 26, 29, 30, 35, 41, 46].map((id) => ({ id, properties: [{ name: 'collides', type: 'bool', value: true }], })), ], }; function emptyGrid(w, h, fill = 0) { return Array.from({ length: w * h }, () => fill); } function set(data, w, x, y, v) { if (x < 0 || y < 0 || x >= w) return; data[y * w + x] = v; } function fillRect(data, w, x0, y0, x1, y1, v) { for (let y = y0; y <= y1; y++) { for (let x = x0; x <= x1; x++) set(data, w, x, y, v); } } function border(data, w, h, v) { for (let x = 0; x < w; x++) { set(data, w, x, 0, v); set(data, w, x, h - 1, v); } for (let y = 0; y < h; y++) { set(data, w, 0, y, v); set(data, w, w - 1, y, v); } } function makeMap(name, w, h, build) { const ground = emptyGrid(w, h, 1); // grass gid 1 (tile index 0 is empty, firstgid=1 โ†’ tile 0 empty, tile1 grass) // Our tileset: tile index in image 0 = empty transparent; index 1 = grass // firstgid 1 means GID 1 = tile index 0. Let's use firstgid 1 where GID N = image tile (N-1) // Simpler: firstgid=1, and we store image-tile-index+1 as GID. // grass is image tile 1 โ†’ GID 2 for (let i = 0; i < ground.length; i++) ground[i] = 2; // grass const obstacles = emptyGrid(w, h, 0); const objects = []; const api = { w, h, ground, obstacles, objects, // GID 0 = empty; image tile index N โ†’ GID N+1. Passing 0 clears (no collision). setG: (x, y, imgTile) => set(ground, w, x, y, imgTile <= 0 ? 0 : imgTile + 1), setO: (x, y, imgTile) => set(obstacles, w, x, y, imgTile <= 0 ? 0 : imgTile + 1), clearO: (x, y) => set(obstacles, w, x, y, 0), fillG: (x0, y0, x1, y1, imgTile) => fillRect(ground, w, x0, y0, x1, y1, imgTile <= 0 ? 0 : imgTile + 1), fillO: (x0, y0, x1, y1, imgTile) => fillRect(obstacles, w, x0, y0, x1, y1, imgTile <= 0 ? 0 : imgTile + 1), borderO: (imgTile) => { const gid = imgTile <= 0 ? 0 : imgTile + 1; for (let x = 0; x < w; x++) { set(obstacles, w, x, 0, gid); set(obstacles, w, x, h - 1, gid); } for (let y = 0; y < h; y++) { set(obstacles, w, 0, y, gid); set(obstacles, w, w - 1, y, gid); } }, spawn: (x, y) => { objects.push({ id: objects.length + 1, name: 'spawn', type: 'spawn', x: x * TILE, y: y * TILE, width: TILE, height: TILE, }); }, npc: (name, x, y) => { objects.push({ id: objects.length + 1, name, type: 'npc', x: x * TILE, y: y * TILE, width: TILE, height: TILE, properties: [{ name: 'npcId', type: 'string', value: name }], }); }, door: (name, x, y, target, targetSpawn) => { // Door tiles must be walkable (clear obstacle collision) set(obstacles, w, x, y, 0); objects.push({ id: objects.length + 1, name, type: 'door', x: x * TILE, y: y * TILE, width: TILE, height: TILE, properties: [ { name: 'target', type: 'string', value: target }, { name: 'spawn', type: 'string', value: targetSpawn || 'spawn' }, ], }); }, interact: (name, x, y, interactId) => { objects.push({ id: objects.length + 1, name, type: 'interact', x: x * TILE, y: y * TILE, width: TILE, height: TILE, properties: [{ name: 'interactId', type: 'string', value: interactId }], }); }, }; build(api); const map = { compressionlevel: -1, height: h, width: w, infinite: false, layers: [ { id: 1, name: 'ground', type: 'tilelayer', width: w, height: h, data: ground, opacity: 1, visible: true, x: 0, y: 0, }, { id: 2, name: 'obstacles', type: 'tilelayer', width: w, height: h, data: obstacles, opacity: 1, visible: true, x: 0, y: 0, properties: [{ name: 'collides', type: 'bool', value: true }], }, { id: 3, name: 'objects', type: 'objectgroup', objects, opacity: 1, visible: true, x: 0, y: 0, }, ], nextlayerid: 4, nextobjectid: objects.length + 1, orientation: 'orthogonal', renderorder: 'right-down', tiledversion: '1.12.2', tileheight: TILE, tilewidth: TILE, type: 'map', version: '1.10', tilesets: [ { ...TILESET, // embed relative path for Phaser }, ], }; const jsonPath = path.join(outDir, `${name}.json`); fs.writeFileSync(jsonPath, JSON.stringify(map, null, 2)); console.log('wrote', jsonPath); // Minimal TMX for Tiled CLI export verification const tmx = ` ${ground.join(',')} ${obstacles.join(',')} ${objects .map( (o) => ` `, ) .join('\n')} `; fs.writeFileSync(path.join(tmxDir, `${name}.tmx`), tmx); return map; } // TSX tileset for Tiled fs.writeFileSync( path.join(tmxDir, 'village.tsx'), ` `, ); // --- Maps --- // 1. House 12x8 makeMap('house', 12, 8, (m) => { m.fillG(0, 0, 11, 7, 16); // wood floor m.borderO(11); m.setO(2, 2, 18); // bed m.setO(3, 2, 18); m.setO(9, 2, 28); // calendar m.setO(5, 4, 22); // table m.setO(1, 5, 23); // chair // clear door at bottom m.setO(5, 7, 0); m.setO(6, 7, 0); m.setG(5, 7, 17); m.setG(6, 7, 17); m.spawn(6, 5); m.door('to_plaza', 5, 7, 'PlazaScene', 'from_house'); m.door('to_plaza2', 6, 7, 'PlazaScene', 'from_house'); m.interact('bed', 2, 2, 'bed'); m.interact('calendar', 9, 2, 'calendar'); }); // 2. Plaza 30x20 hub makeMap('plaza', 30, 20, (m) => { m.fillG(0, 0, 29, 19, 1); // path cross m.fillG(13, 0, 16, 19, 3); m.fillG(0, 8, 29, 11, 3); // flower beds m.fillO(10, 6, 12, 7, 12 + 0); // use flower tile 7 m.setO(10, 6, 7); m.setO(11, 6, 7); m.setO(12, 6, 7); m.setO(10, 7, 7); m.setO(11, 7, 7); m.setO(12, 7, 7); // board m.setO(15, 5, 29); // tree + birds m.setO(20, 4, 10); m.setO(20, 5, 9); m.setO(21, 4, 10); // mailbox m.setO(4, 10, 30); // houses facades as doors targets m.setO(5, 2, 13); m.setO(6, 2, 13); m.setO(5, 3, 12); m.setO(6, 3, 14); // door visual m.setO(22, 2, 13); m.setO(23, 2, 13); m.setO(22, 3, 12); m.setO(23, 3, 14); m.setO(8, 14, 13); m.setO(9, 14, 13); m.setO(8, 15, 12); m.setO(9, 15, 14); // walls edge soft for (let x = 0; x < 30; x++) { m.setO(x, 0, 35); m.setO(x, 19, 35); } // openings m.setO(14, 0, 0); m.setO(15, 0, 0); m.setO(14, 19, 0); m.setO(15, 19, 0); m.setO(0, 9, 0); m.setO(0, 10, 0); m.setO(29, 9, 0); m.setO(29, 10, 0); m.spawn(15, 12); m.npc('afu', 15, 6); m.npc('dabao', 5, 11); m.npc('twinbirds', 20, 3); m.npc('extra1', 18, 14); m.npc('extra4', 10, 12); m.door('from_house', 6, 4, 'HouseScene', 'spawn'); // visual marker only when entering - actual door below // Fix: doors as interact targets // Re-define doors properly m.objects.length = m.objects.filter((o) => o.type !== 'door').length; // no, just add }); // Fix plaza - rebuild cleanly function rebuildPlaza() { return makeMap('plaza', 30, 20, (m) => { m.fillG(0, 0, 29, 19, 1); m.fillG(13, 0, 16, 19, 3); m.fillG(0, 8, 29, 11, 3); // flower bed tiles for (const [x, y] of [ [10, 6], [11, 6], [12, 6], [10, 7], [11, 7], [12, 7], ]) { m.setO(x, y, 7); } m.setO(15, 5, 29); // board m.setO(20, 4, 10); m.setO(20, 5, 9); m.setO(4, 10, 30); // mailbox // house doors on north m.setO(5, 2, 13); m.setO(6, 2, 13); m.setO(5, 3, 12); m.setO(6, 3, 14); m.setO(22, 2, 13); m.setO(23, 2, 13); m.setO(22, 3, 12); m.setO(23, 3, 14); // clinic south-west m.setO(3, 15, 12); m.setO(4, 15, 14); // bakery east-ish m.setO(25, 8, 12); m.setO(25, 9, 14); // shop west m.setO(2, 8, 12); m.setO(2, 9, 14); // ribbon posts m.setO(12, 10, 33); m.setO(17, 10, 33); m.setO(12, 13, 33); m.setO(17, 13, 33); m.spawn(15, 12); // named entry spawns as objects m.objects.push({ id: 100, name: 'from_house', type: 'spawn', x: 6 * 16, y: 5 * 16, width: 16, height: 16, }); m.objects.push({ id: 101, name: 'from_bakery', type: 'spawn', x: 24 * 16, y: 9 * 16, width: 16, height: 16, }); m.objects.push({ id: 102, name: 'from_shop', type: 'spawn', x: 3 * 16, y: 9 * 16, width: 16, height: 16, }); m.objects.push({ id: 103, name: 'from_clinic', type: 'spawn', x: 4 * 16, y: 14 * 16, width: 16, height: 16, }); m.objects.push({ id: 104, name: 'from_lake', type: 'spawn', x: 15 * 16, y: 17 * 16, width: 16, height: 16, }); m.objects.push({ id: 105, name: 'from_field', type: 'spawn', x: 15 * 16, y: 2 * 16, width: 16, height: 16, }); m.objects.push({ id: 106, name: 'from_well', type: 'spawn', x: 27 * 16, y: 10 * 16, width: 16, height: 16, }); m.objects.push({ id: 107, name: 'from_fog', type: 'spawn', x: 2 * 16, y: 16 * 16, width: 16, height: 16, }); m.npc('afu', 15, 6); m.npc('dabao', 5, 11); m.npc('twinbirds', 20, 3); m.npc('extra1', 18, 14); m.npc('extra4', 10, 12); m.door('house', 6, 3, 'HouseScene', 'spawn'); m.door('bakery', 25, 9, 'BakeryScene', 'spawn'); m.door('shop', 2, 9, 'ShopScene', 'spawn'); m.door('clinic', 4, 15, 'ClinicScene', 'spawn'); m.door('lake', 15, 19, 'LakeScene', 'spawn'); m.door('field', 15, 0, 'FieldScene', 'spawn'); m.door('well', 29, 10, 'WellScene', 'spawn'); m.door('fog', 0, 16, 'FogScene', 'spawn'); m.interact('board', 15, 5, 'board'); m.interact('flowers', 11, 6, 'flowers'); m.interact('mailbox', 4, 10, 'mailbox'); m.interact('ribbon1', 12, 10, 'ribbon'); m.interact('ribbon2', 17, 10, 'ribbon'); m.interact('ribbon3', 12, 13, 'ribbon'); m.interact('ribbon4', 17, 13, 'ribbon'); }); } rebuildPlaza(); // 3. Bakery 15x10 makeMap('bakery', 15, 10, (m) => { m.fillG(0, 0, 14, 9, 16); m.borderO(12); m.fillO(2, 2, 6, 3, 19); // counter m.setO(12, 2, 20); // oven m.setO(10, 2, 42); // recipe paper m.setO(4, 6, 22); m.setO(5, 7, 0); m.setO(6, 9, 0); m.setO(7, 9, 0); m.spawn(7, 7); m.npc('yuanyuan', 4, 4); m.door('to_plaza', 7, 9, 'PlazaScene', 'from_bakery'); m.interact('recipe', 10, 2, 'recipe'); m.interact('counter', 4, 3, 'bread'); m.interact('knead', 3, 3, 'knead'); }); // 4. Shop 15x10 makeMap('shop', 15, 10, (m) => { m.fillG(0, 0, 14, 9, 17); m.borderO(12); for (let x = 2; x <= 12; x++) { m.setO(x, 2, 21); m.setO(x, 4, 21); } m.setO(7, 9, 0); m.setO(8, 9, 0); m.spawn(7, 7); m.npc('mimi', 7, 5); m.door('to_plaza', 7, 9, 'PlazaScene', 'from_shop'); m.interact('shelf', 5, 2, 'shelf'); }); // 5. Clinic 12x8 makeMap('clinic', 12, 8, (m) => { m.fillG(0, 0, 11, 7, 17); m.borderO(12); m.setO(3, 2, 22); m.setO(4, 2, 43); // form m.setO(8, 3, 18); m.setO(5, 7, 0); m.setO(6, 7, 0); m.spawn(6, 5); m.npc('fu', 3, 3); m.door('to_plaza', 6, 7, 'PlazaScene', 'from_clinic'); m.interact('form', 4, 2, 'form'); }); // 6. Lake 24x16 makeMap('lake', 24, 16, (m) => { m.fillG(0, 0, 23, 15, 1); m.fillG(0, 8, 23, 15, 5); // water m.fillG(0, 8, 23, 8, 6); m.fillO(8, 4, 10, 5, 23); // bench area chairs m.setO(9, 4, 23); m.setO(12, 6, 31); // picnic m.setO(13, 6, 31); // path from north m.fillG(11, 0, 12, 7, 3); m.setO(11, 0, 0); m.setO(12, 0, 0); m.spawn(11, 2); m.npc('gui', 9, 5); m.npc('extra3', 18, 4); m.door('to_plaza', 11, 0, 'PlazaScene', 'from_lake'); m.interact('picnic', 12, 6, 'picnic'); m.interact('bench', 9, 4, 'bench'); }); // 7. Field 20x14 makeMap('field', 20, 14, (m) => { m.fillG(0, 0, 19, 13, 1); m.fillG(9, 10, 10, 13, 3); for (const [x, y] of [ [3, 3], [5, 4], [7, 3], [12, 5], [14, 3], [4, 7], [15, 7], [8, 6], ]) { m.setO(x, y, 8); // berry } m.setO(10, 2, 32); // pot m.spawn(10, 11); m.npc('extra2', 6, 8); m.door('to_plaza', 10, 13, 'PlazaScene', 'from_field'); m.interact('berry1', 3, 3, 'berry'); m.interact('berry2', 5, 4, 'berry'); m.interact('berry3', 7, 3, 'berry'); m.interact('berry4', 12, 5, 'berry'); m.interact('berry5', 14, 3, 'berry'); m.interact('pot', 10, 2, 'pot'); }); // 8. Well 14x10 makeMap('well', 14, 10, (m) => { m.fillG(0, 0, 13, 9, 1); m.fillG(0, 4, 3, 5, 3); m.setO(7, 4, 24); // well m.setO(6, 4, 24); m.setO(7, 5, 24); m.setO(6, 5, 24); m.spawn(2, 5); m.npc('extra5', 10, 7); m.door('to_plaza', 0, 5, 'PlazaScene', 'from_well'); m.interact('well', 7, 4, 'well'); }); // 9. Fog 20x8 makeMap('fog', 20, 8, (m) => { m.fillG(0, 0, 19, 7, 1); m.fillG(0, 3, 5, 4, 3); for (let x = 12; x < 20; x++) { for (let y = 0; y < 8; y++) m.setO(x, y, 25); } m.spawn(3, 4); m.door('to_plaza', 0, 4, 'PlazaScene', 'from_fog'); m.interact('fogwall', 12, 4, 'fogwall'); }); // 10. White layer corridor 20x6 + ward via objects makeMap('white', 20, 14, (m) => { m.fillG(0, 0, 19, 13, 27); m.fillO(0, 0, 19, 0, 26); m.fillO(0, 5, 19, 5, 26); m.fillO(0, 0, 0, 5, 26); m.fillO(19, 0, 19, 5, 26); // ward below m.fillG(5, 6, 14, 13, 27); m.fillO(5, 6, 14, 6, 26); m.fillO(5, 13, 14, 13, 26); m.fillO(5, 6, 5, 13, 26); m.fillO(14, 6, 14, 13, 26); m.setO(10, 5, 0); // opening m.setO(9, 9, 18); // bed m.setO(11, 8, 43); m.spawn(2, 3); m.interact('record', 11, 8, 'record'); m.door('to_ward', 10, 5, 'WhiteScene', 'ward'); m.objects.push({ id: 200, name: 'ward', type: 'spawn', x: 10 * 16, y: 8 * 16, width: 16, height: 16, }); }); console.log('All 10 maps generated.');