feat: 3D blobber dungeon generator (PR 1)

Replaces the 2D-only demo pipeline with a 3D cell-based blobber
generator. Per-cell face walls, per-material mesh emission, and a
GDExtension binding that returns a Dictionary with ArrayMesh surfaces
the demo consumes directly.

- src/blobber/: cell3d_t data model, dungeon container, pipeline that
  wraps the 2D generator per level and materializes into cell3d
- src/mesh/: face-quad emitter with per-material groups + .obj dump
- src/genesis3d_main.c: new CLI driving the blobber + mesh
- godot/: BrogueGen.generate_dungeon(seed, num_levels, depth) binding
  with dungeon_to_dict packing cells + mesh surfaces
- demo/: demo_blobber.tscn + dungeon_builder.gd, func_godot addon for
  the .map export path, point/entity templates, TrenchBroom docs
- Retired: old arcade/FPS demo scenes and their scripts, unused meshlib

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
saarsena@gmail.com 2026-04-18 13:24:27 -04:00
parent 6ee49c3375
commit 7a6ae79d01
160 changed files with 7209 additions and 2072 deletions

View file

@ -18,9 +18,13 @@ extern "C" {
#include "gen/stairs.h"
#include "gen/chokepoints.h"
#include "gen/machines.h"
#include "blobber/pipeline.h"
#include "blobber/dungeon.h"
#include "mesh/face_mesh.h"
}
#include "grid_to_dict.h"
#include "dungeon_to_dict.h"
using namespace godot;
@ -29,6 +33,10 @@ BrogueGen::~BrogueGen() = default;
void BrogueGen::_bind_methods() {
ClassDB::bind_method(D_METHOD("generate", "seed", "depth"), &BrogueGen::generate);
ClassDB::bind_method(D_METHOD("generate_dungeon", "seed", "num_levels", "depth"),
&BrogueGen::generate_dungeon);
ClassDB::bind_method(D_METHOD("generate_2d_slices", "seed", "num_levels", "depth"),
&BrogueGen::generate_2d_slices);
}
Dictionary BrogueGen::generate(int seed, int depth) {
@ -66,3 +74,30 @@ Dictionary BrogueGen::generate(int seed, int depth) {
d["gate_sites"] = gates;
return d;
}
Dictionary BrogueGen::generate_dungeon(int seed, int num_levels, int depth) {
if (num_levels < 1) num_levels = 1;
dungeon_t *dng = blobber_generate((uint64_t)seed, num_levels, depth);
if (!dng) return Dictionary();
mesh_build_t *mb = mesh_build_from_dungeon(dng, /*cell_size=*/3.0f);
if (!mb) {
dungeon_destroy(dng);
return Dictionary();
}
Dictionary d = dungeon_to_dictionary(dng, mb, seed, depth, num_levels);
mesh_build_destroy(mb);
dungeon_destroy(dng);
return d;
}
TypedArray<Dictionary> BrogueGen::generate_2d_slices(int seed, int num_levels, int depth) {
if (num_levels < 1) num_levels = 1;
dungeon_t *dng = blobber_generate((uint64_t)seed, num_levels, depth);
if (!dng) return TypedArray<Dictionary>();
TypedArray<Dictionary> slices = dungeon_to_2d_slices(dng);
dungeon_destroy(dng);
return slices;
}

View file

@ -2,6 +2,7 @@
#include <godot_cpp/classes/node.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/typed_array.hpp>
namespace godot {
@ -20,6 +21,16 @@ public:
~BrogueGen();
Dictionary generate(int seed, int depth);
/* v2 API: 3D blobber dungeon. Returns a Dictionary with raw cell data +
per-material mesh surfaces. See dungeon_to_dict.h for the schema. */
Dictionary generate_dungeon(int seed, int num_levels, int depth);
/* Exporter bridge: returns an Array of per-level Dictionaries shaped like
the 2D generate() output ({width, height, terrain, liquid}), derived
from a blobber 3D dungeon. Lets the .map exporter consume either
generator uniformly. */
TypedArray<Dictionary> generate_2d_slices(int seed, int num_levels, int depth);
};
} // namespace godot

View file

@ -0,0 +1,167 @@
#include "dungeon_to_dict.h"
#include <cstring>
#include <godot_cpp/classes/mesh.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/packed_vector3_array.hpp>
#include <godot_cpp/variant/packed_vector2_array.hpp>
#include <godot_cpp/variant/vector2i.hpp>
#include <godot_cpp/variant/vector3i.hpp>
extern "C" {
#include "blobber/cell3d.h"
}
using namespace godot;
namespace {
/* Match src/gen/grid.h terrain_t / liquid_t values. Kept local so
dungeon_to_dict stays independent of gen/grid.h at compile time. */
constexpr uint8_t T_NOTHING = 0;
constexpr uint8_t T_FLOOR = 1;
constexpr uint8_t T_WALL = 2;
constexpr uint8_t T_DOOR = 3;
constexpr uint8_t T_LIQUID = 5;
constexpr uint8_t T_BRIDGE = 6;
constexpr uint8_t T_STAIRS_UP = 7;
constexpr uint8_t T_STAIRS_DOWN= 8;
constexpr uint8_t L_NONE = 0;
constexpr uint8_t L_WATER = 1;
constexpr uint8_t L_LAVA = 2;
constexpr uint8_t L_CHASM = 3;
/* Translate a cell3d_t's floor type into the brogue exporter's
(terrain, liquid) pair. Walls are represented by FT_VOID here the
blobber grid surrounds rooms with solid voids rather than emitting a
T_WALL-equivalent cell, so VOID is what gives us the wall geometry. */
void floor_to_2d(uint8_t b_floor, uint8_t &terrain, uint8_t &liquid) {
terrain = T_NOTHING;
liquid = L_NONE;
switch (b_floor) {
case FT_VOID: terrain = T_WALL; break;
case FT_STONE: terrain = T_FLOOR; break;
case FT_WATER: terrain = T_LIQUID; liquid = L_WATER; break;
case FT_LAVA: terrain = T_LIQUID; liquid = L_LAVA; break;
case FT_CHASM: terrain = T_LIQUID; liquid = L_CHASM; break;
case FT_STAIR_UP: terrain = T_STAIRS_UP; break;
case FT_STAIR_DOWN: terrain = T_STAIRS_DOWN; break;
case FT_BRIDGE: terrain = T_BRIDGE; break;
case FT_DOOR_SILL: terrain = T_DOOR; break;
default: terrain = T_NOTHING; break;
}
}
} // namespace
static Array pack_group_arrays(const mesh_group_t *g) {
Array arrays;
arrays.resize(Mesh::ARRAY_MAX);
const size_t n = g->vert_count;
PackedVector3Array verts; verts.resize((int)n);
PackedVector3Array normals; normals.resize((int)n);
PackedVector2Array uvs; uvs.resize((int)n);
Vector3 *vp = verts.ptrw();
Vector3 *np = normals.ptrw();
Vector2 *tp = uvs.ptrw();
for (size_t i = 0; i < n; i++) {
const mesh_vertex_t *v = &g->verts[i];
vp[i] = Vector3(v->px, v->py, v->pz);
np[i] = Vector3(v->nx, v->ny, v->nz);
tp[i] = Vector2(v->u, v->v);
}
arrays[Mesh::ARRAY_VERTEX] = verts;
arrays[Mesh::ARRAY_NORMAL] = normals;
arrays[Mesh::ARRAY_TEX_UV] = uvs;
return arrays;
}
Dictionary godot::dungeon_to_dictionary(const dungeon_t *d, const mesh_build_t *mb,
int seed, int depth, int num_levels) {
Dictionary out;
out["seed"] = seed;
out["depth"] = depth;
out["num_levels"] = num_levels;
out["cell_size"] = mb->cell_size;
out["dimensions"] = Vector3i(BL_DCOLS, num_levels, BL_DROWS);
// Raw voxel cell data for future mutation. Order: z-major (level,y,x).
const size_t n_cells = (size_t)num_levels * BL_DROWS * BL_DCOLS;
PackedByteArray cells;
cells.resize((int)(n_cells * sizeof(cell3d_t)));
memcpy(cells.ptrw(), d->cells, n_cells * sizeof(cell3d_t));
out["cells"] = cells;
// Per-material mesh surfaces. Only materials with content are emitted.
Array meshes;
for (int m = 0; m < MAT_COUNT; m++) {
const mesh_group_t *g = &mb->groups[m];
if (g->vert_count == 0) continue;
Dictionary surf;
surf["material"] = m;
surf["tri_count"] = (int)(g->vert_count / 3);
surf["arrays"] = pack_group_arrays(g);
meshes.push_back(surf);
}
out["meshes"] = meshes;
// Per-level metadata.
Array levels;
for (int i = 0; i < d->n_levels; i++) {
const b_level_info_t *info = &d->levels[i];
Dictionary lvl;
lvl["index"] = i;
lvl["stairs_up"] = Vector2i(info->stairs_up_x, info->stairs_up_y);
lvl["stairs_down"] = Vector2i(info->stairs_down_x, info->stairs_down_y);
levels.push_back(lvl);
}
out["levels"] = levels;
// Plan reserved for PR 3+; keep the key present so consumers can rely on it.
Dictionary plan;
plan["stairs"] = Array();
plan["chasms"] = Array();
plan["waterfalls"] = Array();
plan["footprints"] = Array();
out["plan"] = plan;
return out;
}
TypedArray<Dictionary> godot::dungeon_to_2d_slices(const dungeon_t *d) {
TypedArray<Dictionary> slices;
if (!d) return slices;
const int w = BL_DCOLS;
const int h = BL_DROWS;
const int per_level = w * h;
for (int lvl = 0; lvl < d->n_levels; lvl++) {
PackedByteArray terrain; terrain.resize(per_level);
PackedByteArray liquid; liquid.resize(per_level);
uint8_t *tp = terrain.ptrw();
uint8_t *lp = liquid.ptrw();
const cell3d_t *base = &d->cells[lvl * per_level];
for (int i = 0; i < per_level; i++) {
uint8_t t, l;
floor_to_2d(base[i].floor, t, l);
tp[i] = t;
lp[i] = l;
}
Dictionary slice;
slice["width"] = w;
slice["height"] = h;
slice["terrain"] = terrain;
slice["liquid"] = liquid;
slices.push_back(slice);
}
return slices;
}

View file

@ -0,0 +1,28 @@
#pragma once
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/typed_array.hpp>
extern "C" {
#include "blobber/dungeon.h"
#include "mesh/face_mesh.h"
}
namespace godot {
/* Pack dungeon_t + mesh_build_t into a Dictionary suitable for return from
BrogueGen.generate_dungeon(). Keys: seed, num_levels, dimensions,
cell_size, cells (PackedByteArray of raw cell3d_t), meshes
(Array of per-material surface arrays), levels (metadata), plan (empty
for PR 1). */
Dictionary dungeon_to_dictionary(const dungeon_t *d, const mesh_build_t *mb,
int seed, int depth, int num_levels);
/* Flatten a 3D blobber dungeon into an Array of per-level 2D slices shaped
like legacy brogue's grid_to_dictionary() output: each entry is a
Dictionary with {width, height, terrain: PackedByteArray,
liquid: PackedByteArray}. Terrain / liquid values match the T_* / L_*
enums in src/gen/grid.h so the existing .map exporter can consume them. */
TypedArray<Dictionary> dungeon_to_2d_slices(const dungeon_t *d);
} // namespace godot