Honey Village: six model builds + hub frontend, Dockerized for Dokploy

This commit is contained in:
2026-07-15 10:50:12 -04:00
commit 67253bded5
523 changed files with 58879 additions and 0 deletions
+647
View File
@@ -0,0 +1,647 @@
/**
* 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 = `<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.2" orientation="orthogonal" renderorder="right-down" width="${w}" height="${h}" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="${objects.length + 1}">
<tileset firstgid="1" source="village.tsx"/>
<layer id="1" name="ground" width="${w}" height="${h}">
<data encoding="csv">
${ground.join(',')}
</data>
</layer>
<layer id="2" name="obstacles" width="${w}" height="${h}">
<properties>
<property name="collides" type="bool" value="true"/>
</properties>
<data encoding="csv">
${obstacles.join(',')}
</data>
</layer>
<objectgroup id="3" name="objects">
${objects
.map(
(o) =>
` <object id="${o.id}" name="${o.name}" type="${o.type}" x="${o.x}" y="${o.y}" width="${o.width}" height="${o.height}"/>`,
)
.join('\n')}
</objectgroup>
</map>
`;
fs.writeFileSync(path.join(tmxDir, `${name}.tmx`), tmx);
return map;
}
// TSX tileset for Tiled
fs.writeFileSync(
path.join(tmxDir, 'village.tsx'),
`<?xml version="1.0" encoding="UTF-8"?>
<tileset version="1.10" tiledversion="1.12.2" name="village" tilewidth="16" tileheight="16" tilecount="128" columns="16">
<image source="../public/assets/tilesets/village.png" width="256" height="128"/>
</tileset>
`,
);
// --- 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.');