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:
parent
6ee49c3375
commit
7a6ae79d01
160 changed files with 7209 additions and 2072 deletions
|
|
@ -1,10 +1,16 @@
|
|||
extends SceneTree
|
||||
|
||||
# Usage:
|
||||
# Usage (preferred):
|
||||
# godot --headless --path demo --script scripts/export_map.gd -- \
|
||||
# --generator brogue|blobber --seed N --depth N [--levels N] --out PATH.map
|
||||
#
|
||||
# Legacy positional form (brogue only):
|
||||
# godot --headless --path demo --script scripts/export_map.gd -- SEED DEPTH [LEVELS] OUT.map
|
||||
#
|
||||
# Generates N dungeons (seeds SEED..SEED+N-1, depths DEPTH..DEPTH+N-1),
|
||||
# stacks them vertically, and writes one Standard Quake .map file.
|
||||
# Generates N dungeons (seeds SEED..SEED+N-1, depths DEPTH..DEPTH+N-1 for
|
||||
# brogue; single seed multi-level for blobber), stacks them vertically, and
|
||||
# writes one Standard Quake .map file with typed FuncGodot entities for
|
||||
# water, lava, stairs, doors, and a player spawn.
|
||||
#
|
||||
# Chasms on non-bottom levels are real holes — no floor brush, and the
|
||||
# level below has its ceiling drilled out at the same (x,y) so the shaft
|
||||
|
|
@ -19,6 +25,7 @@ const TEXTURE := "__TB_empty"
|
|||
|
||||
const FLOOR_TOP := 0
|
||||
const WATER_TOP := -32
|
||||
const LAVA_TOP := -32
|
||||
const CHASM_TOP := -128
|
||||
const PIT_BOTTOM := CHASM_TOP - WALL_THICKNESS # = -192
|
||||
const WALL_TOP := HEIGHT + WALL_THICKNESS # = 192
|
||||
|
|
@ -41,42 +48,32 @@ const T_STAIRS_DOWN := 8
|
|||
|
||||
# liquid_t
|
||||
const L_WATER := 1
|
||||
const L_LAVA := 2
|
||||
const L_CHASM := 3
|
||||
|
||||
enum Kind { EMPTY, WALL, FLOOR, WATER, CHASM }
|
||||
enum Kind { EMPTY, WALL, FLOOR, WATER, LAVA, CHASM }
|
||||
|
||||
func _init() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
var seed: int
|
||||
var depth: int
|
||||
var levels: int
|
||||
var out_path: String
|
||||
match args.size():
|
||||
3:
|
||||
seed = int(args[0])
|
||||
depth = int(args[1])
|
||||
levels = 1
|
||||
out_path = args[2]
|
||||
4:
|
||||
seed = int(args[0])
|
||||
depth = int(args[1])
|
||||
levels = int(args[2])
|
||||
out_path = args[3]
|
||||
_:
|
||||
push_error("usage: -- SEED DEPTH [LEVELS] OUT.map")
|
||||
quit(1)
|
||||
return
|
||||
var parsed := _parse_args(OS.get_cmdline_user_args())
|
||||
if parsed.is_empty():
|
||||
quit(1)
|
||||
return
|
||||
var generator: String = parsed["generator"]
|
||||
var seed_value: int = parsed["seed"]
|
||||
var depth: int = parsed["depth"]
|
||||
var levels: int = parsed["levels"]
|
||||
var out_path: String = parsed["out"]
|
||||
|
||||
if levels < 1:
|
||||
push_error("levels must be >= 1")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var grids: Array = []
|
||||
for k in range(levels):
|
||||
var gen := BrogueGen.new()
|
||||
grids.append(gen.generate(seed + k, depth + k))
|
||||
gen.free()
|
||||
var grids: Array = _generate_grids(generator, seed_value, depth, levels)
|
||||
if grids.is_empty():
|
||||
push_error("generator '%s' returned no grids" % generator)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var f := FileAccess.open(out_path, FileAccess.WRITE)
|
||||
if f == null:
|
||||
|
|
@ -84,25 +81,126 @@ func _init() -> void:
|
|||
quit(1)
|
||||
return
|
||||
|
||||
var brush_count := _write_map(f, grids, seed, depth)
|
||||
var stats := _write_map(f, grids, seed_value, depth, generator)
|
||||
f.close()
|
||||
print("wrote %s — %d levels, %d brushes" % [out_path, levels, brush_count])
|
||||
print("wrote %s — generator=%s levels=%d brushes=%d entities=%d" % [
|
||||
out_path, generator, levels, stats["brushes"], stats["entities"],
|
||||
])
|
||||
quit(0)
|
||||
|
||||
func _write_map(f: FileAccess, grids: Array, seed: int, depth: int) -> int:
|
||||
func _parse_args(args: PackedStringArray) -> Dictionary:
|
||||
# Accept either --flag form or legacy positional form.
|
||||
if args.size() >= 1 and args[0].begins_with("--"):
|
||||
return _parse_flag_args(args)
|
||||
return _parse_positional_args(args)
|
||||
|
||||
func _parse_flag_args(args: PackedStringArray) -> Dictionary:
|
||||
var out := {
|
||||
"generator": "brogue",
|
||||
"seed": 0,
|
||||
"depth": 1,
|
||||
"levels": 1,
|
||||
"out": "",
|
||||
}
|
||||
var seen_seed := false
|
||||
var seen_depth := false
|
||||
var seen_out := false
|
||||
var i := 0
|
||||
while i < args.size():
|
||||
var a: String = args[i]
|
||||
match a:
|
||||
"--generator":
|
||||
i += 1
|
||||
out["generator"] = args[i] if i < args.size() else ""
|
||||
"--seed":
|
||||
i += 1
|
||||
if i < args.size():
|
||||
out["seed"] = int(args[i]); seen_seed = true
|
||||
"--depth":
|
||||
i += 1
|
||||
if i < args.size():
|
||||
out["depth"] = int(args[i]); seen_depth = true
|
||||
"--levels":
|
||||
i += 1
|
||||
if i < args.size():
|
||||
out["levels"] = int(args[i])
|
||||
"--out":
|
||||
i += 1
|
||||
if i < args.size():
|
||||
out["out"] = args[i]; seen_out = true
|
||||
_:
|
||||
push_error("unknown arg: %s" % a)
|
||||
return {}
|
||||
i += 1
|
||||
if not seen_seed or not seen_depth or not seen_out:
|
||||
push_error("usage: --generator brogue|blobber --seed N --depth N [--levels N] --out PATH")
|
||||
return {}
|
||||
if out["generator"] != "brogue" and out["generator"] != "blobber":
|
||||
push_error("--generator must be 'brogue' or 'blobber'")
|
||||
return {}
|
||||
return out
|
||||
|
||||
func _parse_positional_args(args: PackedStringArray) -> Dictionary:
|
||||
match args.size():
|
||||
3:
|
||||
return {
|
||||
"generator": "brogue",
|
||||
"seed": int(args[0]),
|
||||
"depth": int(args[1]),
|
||||
"levels": 1,
|
||||
"out": args[2],
|
||||
}
|
||||
4:
|
||||
return {
|
||||
"generator": "brogue",
|
||||
"seed": int(args[0]),
|
||||
"depth": int(args[1]),
|
||||
"levels": int(args[2]),
|
||||
"out": args[3],
|
||||
}
|
||||
_:
|
||||
push_error("usage: --generator brogue|blobber --seed N --depth N [--levels N] --out PATH (or: SEED DEPTH [LEVELS] OUT.map)")
|
||||
return {}
|
||||
|
||||
func _generate_grids(generator: String, seed_value: int, depth: int, levels: int) -> Array:
|
||||
match generator:
|
||||
"brogue":
|
||||
var out: Array = []
|
||||
for k in range(levels):
|
||||
var gen := BrogueGen.new()
|
||||
out.append(gen.generate(seed_value + k, depth + k))
|
||||
gen.free()
|
||||
return out
|
||||
"blobber":
|
||||
var gen := BrogueGen.new()
|
||||
var slices_v: Variant = gen.generate_2d_slices(seed_value, levels, depth)
|
||||
gen.free()
|
||||
if slices_v == null:
|
||||
return []
|
||||
var slices: Array = slices_v
|
||||
return slices
|
||||
return []
|
||||
|
||||
func _write_map(f: FileAccess, grids: Array, seed_value: int, depth: int, generator: String) -> Dictionary:
|
||||
f.store_string("// Game: FuncGodot\n")
|
||||
f.store_string("// Format: Valve\n")
|
||||
f.store_string("// Generated by brogue-genesis — seed: %d, depth: %d, levels: %d\n" % [
|
||||
seed, depth, grids.size(),
|
||||
f.store_string("// Generated by brogue-genesis — generator: %s, seed: %d, depth: %d, levels: %d\n" % [
|
||||
generator, seed_value, depth, grids.size(),
|
||||
])
|
||||
|
||||
var stats := {"brushes": 0, "entities": 0}
|
||||
_emit_worldspawn(f, grids, stats)
|
||||
_emit_liquid_entities(f, grids, stats)
|
||||
_emit_point_entities(f, grids, stats)
|
||||
return stats
|
||||
|
||||
# --- Worldspawn: static geometry only (floors/walls/ceilings/bridges/stairs floors). ---
|
||||
func _emit_worldspawn(f: FileAccess, grids: Array, stats: Dictionary) -> void:
|
||||
f.store_string("{\n")
|
||||
f.store_string("\"classname\" \"worldspawn\"\n")
|
||||
|
||||
var count := 0
|
||||
var w: int = grids[0]["width"]
|
||||
var h: int = grids[0]["height"]
|
||||
# Per-(x,y) flag: "some level above has a chasm here, so our ceiling
|
||||
# is drilled out so the shaft stays open". Accumulates top-down.
|
||||
var chasm_above := PackedByteArray()
|
||||
chasm_above.resize(w * h)
|
||||
|
||||
|
|
@ -110,14 +208,12 @@ func _write_map(f: FileAccess, grids: Array, seed: int, depth: int) -> int:
|
|||
var grid: Dictionary = grids[k]
|
||||
var is_bottom := (k == grids.size() - 1)
|
||||
var z_offset := -k * LEVEL_SPACING
|
||||
count += _write_level(f, grid, z_offset, chasm_above, is_bottom)
|
||||
# Update chasm_above for the next level using THIS level's chasms.
|
||||
stats["brushes"] += _write_level_worldspawn(f, grid, z_offset, chasm_above, is_bottom)
|
||||
_propagate_chasms(grid, chasm_above)
|
||||
|
||||
f.store_string("}\n")
|
||||
return count
|
||||
|
||||
func _write_level(f: FileAccess, grid: Dictionary, z_off: int,
|
||||
func _write_level_worldspawn(f: FileAccess, grid: Dictionary, z_off: int,
|
||||
chasm_above: PackedByteArray, is_bottom: bool) -> int:
|
||||
var w: int = grid["width"]
|
||||
var h: int = grid["height"]
|
||||
|
|
@ -127,7 +223,8 @@ func _write_level(f: FileAccess, grid: Dictionary, z_off: int,
|
|||
var count := 0
|
||||
var ts := TILE_SIZE
|
||||
|
||||
# Pass 1 — floors / walls, row-merged by kind.
|
||||
# Pass 1 — floors / walls / chasm pit-floors, row-merged.
|
||||
# Water & lava are skipped here; they are emitted as entities.
|
||||
for gy in range(h):
|
||||
var run_start := 0
|
||||
var run_kind := _kind(terrain, liquid, w, 0, gy)
|
||||
|
|
@ -148,9 +245,6 @@ func _write_level(f: FileAccess, grid: Dictionary, z_off: int,
|
|||
Kind.FLOOR:
|
||||
count += _emit_box(f, x0, y0, z_off + PIT_BOTTOM,
|
||||
x1, y1, z_off + FLOOR_TOP)
|
||||
Kind.WATER:
|
||||
count += _emit_box(f, x0, y0, z_off + PIT_BOTTOM,
|
||||
x1, y1, z_off + WATER_TOP)
|
||||
Kind.CHASM:
|
||||
# Non-bottom chasms are real holes — no floor. Bottom
|
||||
# chasms get a local pit floor so the player doesn't
|
||||
|
|
@ -187,6 +281,124 @@ func _write_level(f: FileAccess, grid: Dictionary, z_off: int,
|
|||
|
||||
return count
|
||||
|
||||
# --- Liquid entities: one func_water / func_lava per row-run, per level. ---
|
||||
func _emit_liquid_entities(f: FileAccess, grids: Array, stats: Dictionary) -> void:
|
||||
for k in range(grids.size()):
|
||||
var grid: Dictionary = grids[k]
|
||||
var z_off := -k * LEVEL_SPACING
|
||||
var counts := _write_level_liquid_entities(f, grid, z_off)
|
||||
stats["brushes"] += counts["brushes"]
|
||||
stats["entities"] += counts["entities"]
|
||||
|
||||
func _write_level_liquid_entities(f: FileAccess, grid: Dictionary, z_off: int) -> Dictionary:
|
||||
var w: int = grid["width"]
|
||||
var h: int = grid["height"]
|
||||
var terrain: PackedByteArray = grid["terrain"]
|
||||
var liquid: PackedByteArray = grid["liquid"]
|
||||
|
||||
var brushes := 0
|
||||
var entities := 0
|
||||
var ts := TILE_SIZE
|
||||
|
||||
for gy in range(h):
|
||||
var run_start := 0
|
||||
var run_kind := _kind(terrain, liquid, w, 0, gy)
|
||||
for gx in range(1, w + 1):
|
||||
var cur: int = Kind.EMPTY
|
||||
if gx < w:
|
||||
cur = _kind(terrain, liquid, w, gx, gy)
|
||||
if cur == run_kind and gx < w:
|
||||
continue
|
||||
var x0 := run_start * ts
|
||||
var x1 := gx * ts
|
||||
var y0 := gy * ts
|
||||
var y1 := (gy + 1) * ts
|
||||
match run_kind:
|
||||
Kind.WATER:
|
||||
brushes += _emit_solid_entity_box(f, "func_water",
|
||||
x0, y0, z_off + PIT_BOTTOM, x1, y1, z_off + WATER_TOP)
|
||||
entities += 1
|
||||
Kind.LAVA:
|
||||
brushes += _emit_solid_entity_box(f, "func_lava",
|
||||
x0, y0, z_off + PIT_BOTTOM, x1, y1, z_off + LAVA_TOP)
|
||||
entities += 1
|
||||
_:
|
||||
pass
|
||||
run_start = gx
|
||||
run_kind = cur
|
||||
|
||||
return {"brushes": brushes, "entities": entities}
|
||||
|
||||
# --- Point entities: stairs, doors, and a single player spawn. ---
|
||||
func _emit_point_entities(f: FileAccess, grids: Array, stats: Dictionary) -> void:
|
||||
var player_start_origin: Vector3i = Vector3i(-1, -1, -1)
|
||||
|
||||
for k in range(grids.size()):
|
||||
var grid: Dictionary = grids[k]
|
||||
var z_off := -k * LEVEL_SPACING
|
||||
var w: int = grid["width"]
|
||||
var h: int = grid["height"]
|
||||
var terrain: PackedByteArray = grid["terrain"]
|
||||
var ts := TILE_SIZE
|
||||
for gy in range(h):
|
||||
for gx in range(w):
|
||||
var idx := gy * w + gx
|
||||
var t: int = terrain[idx]
|
||||
var ox := gx * ts + ts / 2
|
||||
var oy := gy * ts + ts / 2
|
||||
var oz := z_off + FLOOR_TOP
|
||||
match t:
|
||||
T_STAIRS_UP:
|
||||
_emit_point_entity(f, "point_stair_up", Vector3i(ox, oy, oz))
|
||||
stats["entities"] += 1
|
||||
if k == 0 and player_start_origin.x < 0:
|
||||
player_start_origin = Vector3i(ox, oy, oz + 32)
|
||||
T_STAIRS_DOWN:
|
||||
_emit_point_entity(f, "point_stair_down", Vector3i(ox, oy, oz))
|
||||
stats["entities"] += 1
|
||||
T_DOOR:
|
||||
var angle := _door_angle(terrain, w, h, gx, gy)
|
||||
_emit_point_entity(f, "point_door", Vector3i(ox, oy, oz),
|
||||
{"angle": str(angle)})
|
||||
stats["entities"] += 1
|
||||
_:
|
||||
pass
|
||||
|
||||
# Fallback: no stairs-up on level 0 — pick first floor cell.
|
||||
if player_start_origin.x < 0 and grids.size() > 0:
|
||||
var grid: Dictionary = grids[0]
|
||||
var w: int = grid["width"]
|
||||
var h: int = grid["height"]
|
||||
var terrain: PackedByteArray = grid["terrain"]
|
||||
var ts := TILE_SIZE
|
||||
for gy in range(h):
|
||||
for gx in range(w):
|
||||
var idx := gy * w + gx
|
||||
var t: int = terrain[idx]
|
||||
if t == T_FLOOR or t == T_CORRIDOR or t == T_BRIDGE:
|
||||
player_start_origin = Vector3i(
|
||||
gx * ts + ts / 2, gy * ts + ts / 2, FLOOR_TOP + 32)
|
||||
break
|
||||
if player_start_origin.x >= 0:
|
||||
break
|
||||
|
||||
if player_start_origin.x >= 0:
|
||||
_emit_point_entity(f, "point_player_start", player_start_origin)
|
||||
stats["entities"] += 1
|
||||
|
||||
# Angle (0/90/180/270, Quake convention: 0=east) derived from which
|
||||
# adjacent cell is open — door faces into the open corridor/room.
|
||||
func _door_angle(terrain: PackedByteArray, w: int, _h: int, x: int, y: int) -> int:
|
||||
var east_open := x + 1 < w and _is_passable(terrain[y * w + (x + 1)])
|
||||
var west_open := x - 1 >= 0 and _is_passable(terrain[y * w + (x - 1)])
|
||||
if east_open or west_open:
|
||||
return 0 # door axis along E/W; value doesn't matter much for now
|
||||
return 90 # default to N/S axis
|
||||
|
||||
func _is_passable(t: int) -> bool:
|
||||
return t == T_FLOOR or t == T_CORRIDOR or t == T_BRIDGE \
|
||||
or t == T_DOOR or t == T_STAIRS_UP or t == T_STAIRS_DOWN
|
||||
|
||||
# Record any chasm cells at this level into the running mask so the NEXT
|
||||
# level knows to drill its ceiling there.
|
||||
func _propagate_chasms(grid: Dictionary, chasm_above: PackedByteArray) -> void:
|
||||
|
|
@ -200,7 +412,8 @@ func _propagate_chasms(grid: Dictionary, chasm_above: PackedByteArray) -> void:
|
|||
if terrain[idx] == T_LIQUID and liquid[idx] == L_CHASM:
|
||||
chasm_above[idx] = 1
|
||||
|
||||
# Draw kind for floor/wall emission.
|
||||
# Draw kind for floor/wall emission. Water and lava are distinct so the
|
||||
# main switch can route them to separate entity emitters.
|
||||
func _kind(terrain: PackedByteArray, liquid: PackedByteArray,
|
||||
w: int, x: int, y: int) -> int:
|
||||
var idx := y * w + x
|
||||
|
|
@ -212,7 +425,8 @@ func _kind(terrain: PackedByteArray, liquid: PackedByteArray,
|
|||
var liq: int = liquid[idx]
|
||||
if liq == L_CHASM: return Kind.CHASM
|
||||
if liq == L_WATER: return Kind.WATER
|
||||
return Kind.FLOOR # lava / brimstone sit at floor level
|
||||
if liq == L_LAVA: return Kind.LAVA
|
||||
return Kind.FLOOR
|
||||
_: return Kind.FLOOR
|
||||
|
||||
# Ceiling emitted when the cell is floor-like AND no chasm sits above it.
|
||||
|
|
@ -234,25 +448,38 @@ func _emit_box(f: FileAccess, x0: int, y0: int, z0: int,
|
|||
return 0
|
||||
f.store_string("{\n")
|
||||
# Valve 220 texture axes per face normal (Quake convention).
|
||||
# X-facing walls: U=Y, V=-Z. Y-facing walls: U=X, V=-Z. Z-facing: U=X, V=-Y.
|
||||
var ax_x := "[ 0 1 0 0 ] [ 0 0 -1 0 ]"
|
||||
var ax_y := "[ 1 0 0 0 ] [ 0 0 -1 0 ]"
|
||||
var ax_z := "[ 1 0 0 0 ] [ 0 -1 0 0 ]"
|
||||
# -X
|
||||
_face(f, x0, y0, z0, x0, y1, z0, x0, y0, z1, ax_x)
|
||||
# +X
|
||||
_face(f, x1, y0, z0, x1, y0, z1, x1, y1, z0, ax_x)
|
||||
# -Y
|
||||
_face(f, x0, y0, z1, x1, y0, z1, x0, y0, z0, ax_y)
|
||||
# +Y
|
||||
_face(f, x0, y1, z0, x1, y1, z0, x0, y1, z1, ax_y)
|
||||
# -Z
|
||||
_face(f, x0, y0, z0, x1, y0, z0, x0, y1, z0, ax_z)
|
||||
# +Z
|
||||
_face(f, x0, y0, z1, x0, y1, z1, x1, y0, z1, ax_z)
|
||||
f.store_string("}\n")
|
||||
return 1
|
||||
|
||||
# Solid entity wrapping a single axis-aligned box (one brush per entity).
|
||||
func _emit_solid_entity_box(f: FileAccess, classname: String,
|
||||
x0: int, y0: int, z0: int, x1: int, y1: int, z1: int) -> int:
|
||||
if x0 >= x1 or y0 >= y1 or z0 >= z1:
|
||||
return 0
|
||||
f.store_string("{\n")
|
||||
f.store_string("\"classname\" \"%s\"\n" % classname)
|
||||
var emitted := _emit_box(f, x0, y0, z0, x1, y1, z1)
|
||||
f.store_string("}\n")
|
||||
return emitted
|
||||
|
||||
func _emit_point_entity(f: FileAccess, classname: String, origin: Vector3i,
|
||||
extra: Dictionary = {}) -> void:
|
||||
f.store_string("{\n")
|
||||
f.store_string("\"classname\" \"%s\"\n" % classname)
|
||||
f.store_string("\"origin\" \"%d %d %d\"\n" % [origin.x, origin.y, origin.z])
|
||||
for key in extra.keys():
|
||||
f.store_string("\"%s\" \"%s\"\n" % [key, extra[key]])
|
||||
f.store_string("}\n")
|
||||
|
||||
func _face(f: FileAccess, x1: int, y1: int, z1: int,
|
||||
x2: int, y2: int, z2: int,
|
||||
x3: int, y3: int, z3: int, axes: String) -> void:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue