extends SceneTree # 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 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 # stays open all the way down until it hits a non-chasm floor. Chasms on # the bottom level get a local pit floor at CHASM_TOP so you don't fall # into the void. const TILE_SIZE := 64 const HEIGHT := 128 const WALL_THICKNESS := 64 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 # Per-level Z offset. Each level's brushes get translated by # -level_index * LEVEL_SPACING. Sized so level N+1's ceiling top sits at # or below level N's PIT_BOTTOM — no overlap, no z-fighting. const LEVEL_SPACING := 384 # terrain_t const T_NOTHING := 0 const T_FLOOR := 1 const T_WALL := 2 const T_DOOR := 3 const T_CORRIDOR := 4 const T_LIQUID := 5 const T_BRIDGE := 6 const T_STAIRS_UP := 7 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, LAVA, CHASM } func _init() -> void: 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 = _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: push_error("cannot open %s for write" % out_path) quit(1) return var stats := _write_map(f, grids, seed_value, depth, generator) f.close() print("wrote %s — generator=%s levels=%d brushes=%d entities=%d" % [ out_path, generator, levels, stats["brushes"], stats["entities"], ]) quit(0) 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 — 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 w: int = grids[0]["width"] var h: int = grids[0]["height"] var chasm_above := PackedByteArray() chasm_above.resize(w * h) for k in range(grids.size()): var grid: Dictionary = grids[k] var is_bottom := (k == grids.size() - 1) var z_offset := -k * LEVEL_SPACING stats["brushes"] += _write_level_worldspawn(f, grid, z_offset, chasm_above, is_bottom) _propagate_chasms(grid, chasm_above) f.store_string("}\n") 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"] var terrain: PackedByteArray = grid["terrain"] var liquid: PackedByteArray = grid["liquid"] var count := 0 var ts := TILE_SIZE # 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) 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.WALL: count += _emit_box(f, x0, y0, z_off + PIT_BOTTOM, x1, y1, z_off + WALL_TOP) Kind.FLOOR: count += _emit_box(f, x0, y0, z_off + PIT_BOTTOM, x1, y1, z_off + FLOOR_TOP) Kind.CHASM: # Non-bottom chasms are real holes — no floor. Bottom # chasms get a local pit floor so the player doesn't # fall into empty void. if is_bottom: count += _emit_box(f, x0, y0, z_off + PIT_BOTTOM, x1, y1, z_off + CHASM_TOP) _: pass run_start = gx run_kind = cur # Pass 2 — ceilings, row-merged by "is ceiling present here?". A # ceiling is present if the cell has floor-like terrain AND no level # above has drilled a chasm through it. for gy in range(h): var run_start := 0 var run_has := _has_ceiling(terrain, chasm_above, w, 0, gy) for gx in range(1, w + 1): var cur := false if gx < w: cur = _has_ceiling(terrain, chasm_above, w, gx, gy) if cur == run_has and gx < w: continue if run_has: var x0 := run_start * ts var x1 := gx * ts var y0 := gy * ts var y1 := (gy + 1) * ts count += _emit_box(f, x0, y0, z_off + HEIGHT, x1, y1, z_off + WALL_TOP) run_start = gx run_has = cur 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: var w: int = grid["width"] var h: int = grid["height"] var terrain: PackedByteArray = grid["terrain"] var liquid: PackedByteArray = grid["liquid"] for gy in range(h): for gx in range(w): var idx := gy * w + gx if terrain[idx] == T_LIQUID and liquid[idx] == L_CHASM: chasm_above[idx] = 1 # 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 var t: int = terrain[idx] match t: T_NOTHING: return Kind.EMPTY T_WALL: return Kind.WALL T_LIQUID: var liq: int = liquid[idx] if liq == L_CHASM: return Kind.CHASM if liq == L_WATER: return Kind.WATER 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. # Walls and empty cells don't get ceilings either (wall brush already # reaches WALL_TOP; empty is empty). func _has_ceiling(terrain: PackedByteArray, chasm_above: PackedByteArray, w: int, x: int, y: int) -> bool: var idx := y * w + x if chasm_above[idx] != 0: return false var t: int = terrain[idx] return t != T_NOTHING and t != T_WALL # Standard Quake brush: 6 axis-aligned planes, 3 points per plane, inward # normals per TrenchBroom convention. Mirrors libd's emit_solid_box. func _emit_box(f: FileAccess, 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") # Valve 220 texture axes per face normal (Quake convention). 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 ]" _face(f, x0, y0, z0, x0, y1, z0, x0, y0, z1, ax_x) _face(f, x1, y0, z0, x1, y0, z1, x1, y1, z0, ax_x) _face(f, x0, y0, z1, x1, y0, z1, x0, y0, z0, ax_y) _face(f, x0, y1, z0, x1, y1, z0, x0, y1, z1, ax_y) _face(f, x0, y0, z0, x1, y0, z0, x0, y1, z0, ax_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: f.store_string("( %d %d %d ) ( %d %d %d ) ( %d %d %d ) %s %s 0 1 1\n" % [ x1, y1, z1, x2, y2, z2, x3, y3, z3, TEXTURE, axes, ])