This commit is contained in:
saarsena@gmail.com 2026-04-16 21:04:50 -04:00
commit e45f121fb9
89 changed files with 336069 additions and 0 deletions

47
.gitignore vendored Normal file
View file

@ -0,0 +1,47 @@
# Compiled genesis binary
/bin/genesis
# C build objects
/obj/
# Godot GDExtension build artifacts
/godot/build/
/demo/addons/brogue_gen/
# godot-cpp build artifacts (keep sources, drop generated/compiled)
/godot/godot-cpp/bin/
/godot/godot-cpp/gen/
# Python cache
__pycache__/
*.pyc
*.pyo
# Object / library files
*.o
*.os
*.obj
*.a
*.so
*.dylib
*.dll
# Godot editor artifacts
.godot/
*.import
# SCons
.sconsign.dblite
.scons_node_count
config.log
# Editors / IDEs
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db

42
Makefile Normal file
View file

@ -0,0 +1,42 @@
CC := cc
CFLAGS := -std=c99 -Wall -Wpedantic -Werror=implicit -Wmissing-prototypes -O2 -g -Isrc
LDFLAGS :=
GEN_SRC := $(wildcard src/gen/*.c)
GEN_OBJ := $(GEN_SRC:src/%.c=obj/%.o)
.PHONY: all clean genesis test stress godot godot-clean
all: genesis
test: bin/genesis
@bash test/run_tests.sh
stress: bin/genesis
@./bin/genesis --stress 5000
godot:
@cd godot && scons -j$$(nproc) platform=linux target=template_debug
godot-clean:
@rm -rf godot/build godot/godot-cpp/bin demo/addons/brogue_gen
genesis: bin/genesis
bin/genesis: $(GEN_OBJ) obj/genesis_main.o obj/json_emit.o
@mkdir -p bin
$(CC) $(LDFLAGS) -o $@ $^ -lm
obj/%.o: src/%.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c $< -o $@
obj/genesis_main.o: src/genesis_main.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c $< -o $@
obj/json_emit.o: src/json_emit.c src/json_emit.h
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -rf obj bin

8
bin/f.fish Executable file
View file

@ -0,0 +1,8 @@
#!/usr/bin/env fish
for i in (seq 1 9999)
echo "```" >>out.md
./genesis --seed $i 2&>>out.md
echo "```" >>out.md
echo "" >>out.md
end

329967
bin/out.md Normal file

File diff suppressed because it is too large Load diff

23
demo/project.godot Normal file
View file

@ -0,0 +1,23 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=5
[animation]
compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application]
config/name="Brogue Genesis Demo"
config/features=PackedStringArray("4.6", "GL Compatibility")
[rendering]
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"

127
demo/scenes/arcade.tscn Normal file
View file

@ -0,0 +1,127 @@
[gd_scene format=3 uid="uid://jysbib77g851"]
[ext_resource type="Script" path="res://scripts/arcade/arcade_scene.gd" id="1_scene"]
[ext_resource type="Script" path="res://scripts/arcade/player_arcade.gd" id="2_player"]
[ext_resource type="Script" path="res://scripts/arcade/hud.gd" id="3_hud"]
[sub_resource type="Environment" id="Env1"]
background_mode = 1
background_color = Color(0.03, 0.03, 0.05, 1)
ambient_light_source = 2
ambient_light_color = Color(0.55, 0.55, 0.65, 1)
ambient_light_energy = 0.45
fog_enabled = false
[sub_resource type="CapsuleShape3D" id="PlayerCapsule"]
radius = 0.3
height = 1.7
[node name="Arcade" type="Node3D"]
script = ExtResource("1_scene")
base_seed = 25
start_depth = 1
total_depths = 10
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Env1")
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
transform = Transform3D(0.707107, -0.5, 0.5, 0, 0.707107, 0.707107, -0.707107, -0.5, 0.5, 0, 10, 0)
light_color = Color(1, 0.95, 0.85, 1)
light_energy = 0.9
shadow_enabled = true
[node name="World" type="Node3D" parent="."]
[node name="Player" type="CharacterBody3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 39, 1.1, 14)
script = ExtResource("2_player")
[node name="CollisionShape3D" type="CollisionShape3D" parent="Player"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.85, 0)
shape = SubResource("PlayerCapsule")
[node name="Camera3D" type="Camera3D" parent="Player"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, 0)
current = true
near = 0.05
far = 500.0
[node name="HUD" type="CanvasLayer" parent="."]
script = ExtResource("3_hud")
[node name="HPBar" type="Control" parent="HUD"]
offset_left = 16.0
offset_top = 16.0
offset_right = 240.0
offset_bottom = 56.0
[node name="BG" type="ColorRect" parent="HUD/HPBar"]
offset_right = 200.0
offset_bottom = 20.0
color = Color(0.1, 0.1, 0.12, 0.75)
[node name="Fill" type="ColorRect" parent="HUD/HPBar"]
offset_right = 200.0
offset_bottom = 20.0
color = Color(0.32, 0.82, 0.36, 1)
[node name="Label" type="Label" parent="HUD/HPBar"]
offset_top = 22.0
offset_right = 240.0
offset_bottom = 40.0
theme_override_colors/font_color = Color(0.95, 0.95, 0.95, 1)
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 3
text = "HP 20 / 20"
[node name="InfoLabel" type="Label" parent="HUD"]
offset_left = 16.0
offset_top = 64.0
offset_right = 500.0
offset_bottom = 88.0
theme_override_colors/font_color = Color(0.95, 0.95, 0.95, 1)
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 3
text = "Depth 1 / 10"
[node name="EndOverlay" type="Control" parent="HUD"]
anchor_right = 1.0
anchor_bottom = 1.0
mouse_filter = 2
[node name="Dim" type="ColorRect" parent="HUD/EndOverlay"]
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0, 0, 0, 0.55)
[node name="Title" type="Label" parent="HUD/EndOverlay"]
anchor_left = 0.5
anchor_top = 0.4
anchor_right = 0.5
anchor_bottom = 0.4
offset_left = -200.0
offset_top = -40.0
offset_right = 200.0
offset_bottom = 20.0
theme_override_colors/font_color = Color(0.92, 0.28, 0.28, 1)
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 4
theme_override_font_sizes/font_size = 56
text = "YOU DIED"
horizontal_alignment = 1
[node name="Hint" type="Label" parent="HUD/EndOverlay"]
anchor_left = 0.5
anchor_top = 0.55
anchor_right = 0.5
anchor_bottom = 0.55
offset_left = -160.0
offset_top = 0.0
offset_right = 160.0
offset_bottom = 30.0
theme_override_colors/font_color = Color(0.9, 0.9, 0.9, 1)
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 3
text = "Press R to restart"
horizontal_alignment = 1

32
demo/scenes/demo_3d.tscn Normal file
View file

@ -0,0 +1,32 @@
[gd_scene format=3 uid="uid://jysbic1s50to"]
[ext_resource type="Script" uid="uid://brsi02a7ei24j" path="res://scripts/demo_3d.gd" id="1_demo3d"]
[ext_resource type="Script" uid="uid://d0srrm35g1m0t" path="res://scripts/fly_camera.gd" id="2_flycam"]
[sub_resource type="Environment" id="Env1"]
background_mode = 1
background_color = Color(0.04, 0.04, 0.05, 1)
ambient_light_source = 2
ambient_light_color = Color(0.6, 0.6, 0.7, 1)
ambient_light_energy = 0.4
[node name="Demo3D" type="Node3D" unique_id=36819859]
script = ExtResource("1_demo3d")
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=1412341693]
environment = SubResource("Env1")
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=236128294]
transform = Transform3D(0.707107, -0.5, 0.5, 0, 0.707107, 0.707107, -0.707107, -0.5, 0.5, 0, 10, 0)
light_color = Color(1, 0.95, 0.85, 1)
light_energy = 0.9
shadow_enabled = true
[node name="FlyCamera" type="Camera3D" parent="." unique_id=658486835]
transform = Transform3D(1, 0, 0, 0, 0.819152, 0.573576, 0, -0.573576, 0.819152, 39, 14, 32)
current = true
near = 0.1
far = 500.0
script = ExtResource("2_flycam")
[node name="Levels" type="Node3D" parent="." unique_id=1476525758]

58
demo/scenes/demo_fps.tscn Normal file
View file

@ -0,0 +1,58 @@
[gd_scene format=3 uid="uid://c0brogue3dfps"]
[ext_resource type="Script" path="res://scripts/demo_fps.gd" id="1_demofps"]
[ext_resource type="Script" path="res://scripts/player.gd" id="2_player"]
[ext_resource type="Script" path="res://scripts/fps_overlay.gd" id="3_fpsov"]
[sub_resource type="Environment" id="Env1"]
background_mode = 1
background_color = Color(0.04, 0.04, 0.05, 1)
ambient_light_source = 2
ambient_light_color = Color(0.6, 0.6, 0.7, 1)
ambient_light_energy = 0.4
fog_enabled = false
[sub_resource type="CapsuleShape3D" id="Capsule1"]
radius = 0.3
height = 1.7
[node name="DemoFPS" type="Node3D"]
script = ExtResource("1_demofps")
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Env1")
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
transform = Transform3D(0.707107, -0.5, 0.5, 0, 0.707107, 0.707107, -0.707107, -0.5, 0.5, 0, 10, 0)
light_color = Color(1, 0.95, 0.85, 1)
light_energy = 0.9
shadow_enabled = true
[node name="Levels" type="Node3D" parent="."]
[node name="Player" type="CharacterBody3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 39, 1.1, 14)
script = ExtResource("2_player")
[node name="CollisionShape3D" type="CollisionShape3D" parent="Player"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.85, 0)
shape = SubResource("Capsule1")
[node name="Camera3D" type="Camera3D" parent="Player"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, 0)
current = true
near = 0.05
far = 500.0
[node name="FPSOverlay" type="CanvasLayer" parent="."]
script = ExtResource("3_fpsov")
[node name="Label" type="Label" parent="FPSOverlay"]
offset_left = 12.0
offset_top = 12.0
offset_right = 800.0
offset_bottom = 80.0
theme_override_colors/font_color = Color(0.95, 0.95, 0.95, 1)
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 3
text = "FPS: --"

View file

@ -0,0 +1,65 @@
[gd_scene format=3 uid="uid://c0broguelarge"]
[ext_resource type="Script" path="res://scripts/demo_fps.gd" id="1_demofps"]
[ext_resource type="Script" path="res://scripts/player.gd" id="2_player"]
[ext_resource type="Script" path="res://scripts/fps_overlay.gd" id="3_fpsov"]
[sub_resource type="Environment" id="Env1"]
background_mode = 1
background_color = Color(0.02, 0.02, 0.03, 1)
ambient_light_source = 2
ambient_light_color = Color(0.6, 0.6, 0.7, 1)
ambient_light_energy = 0.35
fog_enabled = false
[sub_resource type="CapsuleShape3D" id="Capsule1"]
radius = 0.3
height = 1.7
[node name="DemoLarge" type="Node3D"]
script = ExtResource("1_demofps")
base_seed = 2028
depth_start = 20
level_count = 3
level_spacing = 6.0
chunks_x = 5
chunks_y = 5
player_spawn_height = 1.1
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Env1")
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
transform = Transform3D(0.707107, -0.5, 0.5, 0, 0.707107, 0.707107, -0.707107, -0.5, 0.5, 0, 10, 0)
light_color = Color(1, 0.95, 0.85, 1)
light_energy = 0.9
shadow_enabled = true
[node name="Levels" type="Node3D" parent="."]
[node name="Player" type="CharacterBody3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 39, 1.1, 14)
script = ExtResource("2_player")
[node name="CollisionShape3D" type="CollisionShape3D" parent="Player"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.85, 0)
shape = SubResource("Capsule1")
[node name="Camera3D" type="Camera3D" parent="Player"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, 0)
current = true
near = 0.05
far = 2000.0
[node name="FPSOverlay" type="CanvasLayer" parent="."]
script = ExtResource("3_fpsov")
[node name="Label" type="Label" parent="FPSOverlay"]
offset_left = 12.0
offset_top = 12.0
offset_right = 1000.0
offset_bottom = 80.0
theme_override_colors/font_color = Color(0.95, 0.95, 0.95, 1)
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 3
text = "FPS: --"

281
demo/scenes/game_root.tscn Normal file

File diff suppressed because one or more lines are too long

136
demo/scenes/generator.gd Normal file
View file

@ -0,0 +1,136 @@
@tool
extends Node3D
class_name DungeonGenerator
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
const L_NONE := 0
const L_WATER := 1
const L_LAVA := 2
const L_CHASM := 3
const L_BRIMSTONE := 4
# Match the tile IDs exposed by MeshLibraryBuilder.
const TILE_FLOOR := 0
const TILE_WALL := 1
const TILE_DOOR := 2
const TILE_CORRIDOR := 3
const TILE_WATER := 4
const TILE_LAVA := 5
const TILE_BRIMSTONE := 6
const TILE_BRIDGE := 7
const TILE_STAIRS_UP := 8
const TILE_STAIRS_DOWN := 9
@export var base_seed: int = 2321
@export var depth_start: int = 20
@export var level_count: int = 10
@export var level_spacing: float = 6.0
@export var generate: bool = false:
set(value):
if value and Engine.is_editor_hint():
generate_dungeon()
generate = false
@export var clear_levels: bool = false:
set(value):
if value and Engine.is_editor_hint():
_clear_levels()
clear_levels = false
@onready var levels_root: Node3D = %Levels
var _mesh_library: MeshLibrary
# Populate one level's GridMap from the grid dict. Returns the count of
# chasm cells that were deliberately skipped (for reporting).
func _populate_level(grid_map: GridMap, grid: Dictionary) -> int:
var w: int = grid["width"]
var h: int = grid["height"]
var terrain: PackedByteArray = grid["terrain"]
var liquid: PackedByteArray = grid["liquid"]
var chasm_cells := 0
for y in range(h):
for x in range(w):
var idx := y * w + x
var t: int = terrain[idx]
var liq: int = liquid[idx]
# Chasm liquid renders as an actual see-through pit.
if t == T_LIQUID and liq == L_CHASM:
chasm_cells += 1
continue
var tile_id := _tile_for(t, liq)
if tile_id == -1:
continue # T_NOTHING or other empty — leave unrendered
# GridMap coords: (X, Y, Z). We want dungeon x → X, dungeon y → Z.
grid_map.set_cell_item(Vector3i(x, 0, y), tile_id)
return chasm_cells
func _tile_for(terrain: int, liquid: int) -> int:
match terrain:
T_FLOOR: return TILE_FLOOR
T_CORRIDOR: return TILE_CORRIDOR
T_DOOR: return TILE_DOOR
T_WALL: return TILE_WALL
T_BRIDGE: return TILE_BRIDGE
T_STAIRS_UP: return TILE_STAIRS_UP
T_STAIRS_DOWN: return TILE_STAIRS_DOWN
T_LIQUID:
match liquid:
L_WATER: return TILE_WATER
L_LAVA: return TILE_LAVA
L_BRIMSTONE: return TILE_BRIMSTONE
L_CHASM: return -1 # empty cell — see through to level below
_: return TILE_WATER
_: return -1 # T_NOTHING or unknown
func _clear_levels() -> void:
if levels_root == null:
levels_root = %Levels
for child in levels_root.get_children():
levels_root.remove_child(child)
child.queue_free()
func generate_dungeon():
if levels_root == null:
levels_root = %Levels
if levels_root.get_child_count() > 0:
push_warning("Levels already generated — toggle clear_levels first to regenerate.")
return
_mesh_library = MeshLibraryBuilder.build()
var edited_root: Node = get_tree().edited_scene_root if Engine.is_editor_hint() else null
var total_chasm_cells := 0
for level_index in range(level_count):
var level_seed := base_seed + level_index
var depth := depth_start + level_index
var gen := BrogueGen.new()
var grid: Dictionary = gen.generate(level_seed, depth)
gen.free()
var grid_map := GridMap.new()
grid_map.name = "Level%d" % level_index
grid_map.mesh_library = _mesh_library
grid_map.cell_size = Vector3(1, 1, 1)
grid_map.position.y = -level_index * level_spacing
levels_root.add_child(grid_map)
if edited_root:
grid_map.owner = edited_root
var chasm_cells := _populate_level(grid_map, grid)
total_chasm_cells += chasm_cells
print("Level %d: seed=%d depth=%d rooms=%d machines=%d chasms=%d"
% [level_index, level_seed, depth,
(grid["rooms"] as Array).size(),
(grid["machines"] as Array).size(),
chasm_cells])

View file

@ -0,0 +1 @@
uid://ox0s7xjdj3lw

View file

@ -0,0 +1,233 @@
extends Node3D
# Arcade main scene. Owns:
# - current depth
# - map generation + rendering
# - player spawning
# - enemy spawning (proportional to room size)
# - level clear detection (player steps onto stairs_down)
# - death / restart
# Emits signals for the HUD.
signal depth_changed(depth: int, total: int)
signal enemies_changed(remaining: int, total: int)
signal game_over(won: bool)
const T_STAIRS_DOWN := 8
@export var base_seed: int = 25
@export var start_depth: int = 1
@export var total_depths: int = 10 # win after clearing this many
# Enemy count per room scales with depth. At depth 1, dungeons are sparse;
# by depth 10 they're thick.
@export var enemy_density_base: float = 0.04
@export var enemy_density_per_depth: float = 0.015
@export var enemy_min_per_room: int = 0
@export var enemy_max_per_room: int = 3
@onready var world_root: Node3D = $World
@onready var player: CharacterBody3D = $Player
@onready var hud: Node = $HUD
var _current_depth := 1
var _mesh_library: MeshLibrary
var _grid_map: GridMap = null
var _grid: Dictionary
var _enemies: Array[Node3D] = []
var _rng := RandomNumberGenerator.new()
var _transitioning := false
func _ready() -> void:
_rng.randomize()
_mesh_library = MeshLibraryBuilder.build(true)
player.died.connect(_on_player_died)
player.hp_changed.connect(_on_player_hp)
_current_depth = start_depth
_build_level()
func _process(_delta: float) -> void:
if _transitioning:
return
_check_stairs_down()
# --- level lifecycle ---
func _build_level() -> void:
# Clear old level.
if _grid_map:
_grid_map.queue_free()
for e in _enemies:
if is_instance_valid(e):
e.queue_free()
_enemies.clear()
# Generate.
var gen := BrogueGen.new()
_grid = gen.generate(base_seed + _current_depth, _current_depth)
gen.free()
_grid_map = GridMap.new()
_grid_map.mesh_library = _mesh_library
_grid_map.cell_size = Vector3(1, 1, 1)
world_root.add_child(_grid_map)
_populate_grid_map(_grid_map, _grid)
# Spawn player at up-stair.
var up: Vector2i = _grid["stairs_up"] as Vector2i
if up.x >= 0:
player.global_position = GridUtil.grid_to_world(up, 1.1)
player.velocity = Vector3.ZERO
# Reset facing so each level starts neutral.
player.rotation.y = 0.0
# Spawn enemies.
_spawn_enemies()
print("Arcade: built depth %d%d rooms, %d enemies, up=%s down=%s" % [
_current_depth, (_grid["rooms"] as Array).size(), _enemies.size(),
_grid["stairs_up"], _grid["stairs_down"],
])
depth_changed.emit(_current_depth, total_depths)
enemies_changed.emit(_enemies.size(), _enemies.size())
func _populate_grid_map(gm: GridMap, grid: Dictionary) -> void:
var w: int = grid["width"]
var h: int = grid["height"]
var terrain: PackedByteArray = grid["terrain"]
var liquid: PackedByteArray = grid["liquid"]
for y in range(h):
for x in range(w):
var idx := y * w + x
var t: int = terrain[idx]
var liq: int = liquid[idx]
if t == GridUtil.T_LIQUID and liq == GridUtil.L_CHASM:
continue
var tile := _tile_for(t, liq)
if tile < 0:
continue
gm.set_cell_item(Vector3i(x, 0, y), tile)
func _tile_for(terrain: int, liquid: int) -> int:
match terrain:
GridUtil.T_FLOOR: return MeshLibraryBuilder.TILE_FLOOR
GridUtil.T_CORRIDOR: return MeshLibraryBuilder.TILE_CORRIDOR
GridUtil.T_DOOR: return MeshLibraryBuilder.TILE_DOOR
GridUtil.T_WALL: return MeshLibraryBuilder.TILE_WALL
GridUtil.T_BRIDGE: return MeshLibraryBuilder.TILE_BRIDGE
GridUtil.T_STAIRS_UP: return MeshLibraryBuilder.TILE_STAIRS_UP
GridUtil.T_STAIRS_DOWN: return MeshLibraryBuilder.TILE_STAIRS_DOWN
GridUtil.T_LIQUID:
match liquid:
GridUtil.L_WATER: return MeshLibraryBuilder.TILE_WATER
GridUtil.L_LAVA: return MeshLibraryBuilder.TILE_LAVA
GridUtil.L_BRIMSTONE: return MeshLibraryBuilder.TILE_BRIMSTONE
_: return MeshLibraryBuilder.TILE_WATER
_: return -1
# --- enemy spawning ---
func _spawn_enemies() -> void:
var player_grid := GridUtil.world_to_grid(player.global_position)
var rooms: Array = _grid["rooms"]
for room in rooms:
var cells: Array = room["cells"]
if cells.size() < 4:
continue
var density := enemy_density_base + enemy_density_per_depth * (_current_depth - 1)
var target := int(round(cells.size() * density))
target = clamp(target, enemy_min_per_room, enemy_max_per_room)
# Room containing the player gets at most 1 enemy, placed far from them.
var room_has_player := false
for c in cells:
if c == player_grid:
room_has_player = true
break
if room_has_player:
target = min(target, 1)
for i in range(target):
var cell: Vector2i = cells[_rng.randi() % cells.size()]
if room_has_player and cell.distance_to(player_grid) < 4.0:
continue
_spawn_enemy_at(cell, cells)
func _spawn_enemy_at(cell: Vector2i, room_cells: Array) -> void:
var enemy := CharacterBody3D.new()
enemy.set_script(load("res://scripts/arcade/enemy.gd"))
# Visible body: a red box above the floor.
var mesh := MeshInstance3D.new()
var box := BoxMesh.new()
box.size = Vector3(0.6, 1.4, 0.6)
mesh.mesh = box
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(0.80, 0.20, 0.20)
box.material = mat
mesh.position = Vector3(0, 0.7, 0)
enemy.add_child(mesh)
# Collision shape.
var col := CollisionShape3D.new()
var shape := CapsuleShape3D.new()
shape.radius = 0.3
shape.height = 1.4
col.shape = shape
col.position = Vector3(0, 0.7, 0)
enemy.add_child(col)
world_root.add_child(enemy)
enemy.global_position = GridUtil.grid_to_world(cell, 0.1)
enemy.grid_ref = _grid
enemy.set_room(room_cells)
enemy.player_ref = player
enemy.died.connect(_on_enemy_died.bind(enemy))
_enemies.append(enemy)
# --- transitions ---
func _check_stairs_down() -> void:
var pg := GridUtil.world_to_grid(player.global_position)
var w: int = _grid["width"]
var terrain: PackedByteArray = _grid["terrain"]
var idx := pg.y * w + pg.x
if idx < 0 or idx >= terrain.size():
return
if terrain[idx] == T_STAIRS_DOWN:
_advance_depth()
func _advance_depth() -> void:
if _transitioning:
return
_transitioning = true
_current_depth += 1
if _current_depth > start_depth + total_depths - 1:
game_over.emit(true)
return
call_deferred("_build_level")
call_deferred("_clear_transition_flag")
func _clear_transition_flag() -> void:
_transitioning = false
func _on_player_died() -> void:
game_over.emit(false)
func _on_player_hp(_cur: int, _max: int) -> void:
# HUD listens directly; no-op here.
pass
func _on_enemy_died(enemy: Node3D) -> void:
_enemies.erase(enemy)
var total_spawned := _enemies.size()
# We emit remaining = enemies.size; total isn't tracked separately for
# this MVP. HUD shows alive count.
enemies_changed.emit(_enemies.size(), total_spawned)
# Called by HUD restart button.
func restart_from_start() -> void:
_transitioning = true
_current_depth = start_depth
player.hp = player.max_hp
player.hp_changed.emit(player.hp, player.max_hp)
player._alive = true
call_deferred("_build_level")
call_deferred("_clear_transition_flag")

View file

@ -0,0 +1 @@
uid://3hwwo1hwe12

View file

@ -0,0 +1,174 @@
class_name ArcadeEnemy
extends CharacterBody3D
# Three-state FSM:
# WANDER: pick a random passable cell in this enemy's room, walk there,
# occasionally idle.
# CHASE: move toward last-known player grid cell via BFS.
# ATTACK: within melee range — swing, tick cooldown.
#
# Transitions:
# WANDER -> CHASE when player has LOS AND distance ≤ sight_radius
# CHASE -> WANDER when player has been out of sight for lose_sight_turns
# CHASE -> ATTACK when close enough to hit
# ATTACK -> CHASE when out of range
signal died
enum State { WANDER, CHASE, ATTACK }
@export var hp := 8
@export var move_speed := 3.0
@export var sight_radius := 10.0
@export var melee_range := 1.4
@export var melee_damage := 2
@export var melee_cooldown := 0.9
@export var lose_sight_seconds := 3.0
# Set by spawner on instantiate.
var grid_ref: Dictionary = {}
var room_cells: Array = [] # Array[Vector2i]
var player_ref: Node3D = null
var rng := RandomNumberGenerator.new()
var _state := State.WANDER
var _path_target: Vector2i = Vector2i(-1, -1)
var _repath_timer := 0.0
var _attack_timer := 0.0
var _lose_sight_timer := 0.0
var _alive := true
func _ready() -> void:
rng.randomize()
_pick_wander_target()
func set_room(cells: Array) -> void:
room_cells = cells
_pick_wander_target()
func _physics_process(delta: float) -> void:
if not _alive:
return
if _attack_timer > 0.0:
_attack_timer -= delta
_update_state(delta)
match _state:
State.WANDER: _do_wander(delta)
State.CHASE: _do_chase(delta)
State.ATTACK: _do_attack(delta)
# Gravity always.
if not is_on_floor():
velocity.y -= 22.0 * delta
else:
velocity.y = max(velocity.y, 0.0)
move_and_slide()
# --- state machine ---
func _update_state(delta: float) -> void:
if player_ref == null:
return
var my_grid := GridUtil.world_to_grid(global_position)
var p_grid := GridUtil.world_to_grid(player_ref.global_position)
var dist := float(my_grid.distance_to(p_grid))
var can_see := dist <= sight_radius and GridUtil.has_los(grid_ref, my_grid, p_grid)
match _state:
State.WANDER:
if can_see:
_state = State.CHASE
_path_target = p_grid
_repath_timer = 0.0
State.CHASE:
if can_see:
_lose_sight_timer = 0.0
_path_target = p_grid
else:
_lose_sight_timer += delta
if _lose_sight_timer >= lose_sight_seconds:
_state = State.WANDER
_pick_wander_target()
return
# Close enough to swing?
var world_dist := global_position.distance_to(player_ref.global_position)
if world_dist <= melee_range:
_state = State.ATTACK
State.ATTACK:
var world_dist2 := global_position.distance_to(player_ref.global_position)
if world_dist2 > melee_range * 1.3:
_state = State.CHASE
if not can_see:
_lose_sight_timer += delta
if _lose_sight_timer >= lose_sight_seconds:
_state = State.WANDER
_pick_wander_target()
# --- behaviors ---
func _do_wander(delta: float) -> void:
_repath_timer -= delta
if _path_target == Vector2i(-1, -1) or _at_target():
if _repath_timer <= 0.0:
_repath_timer = rng.randf_range(0.8, 2.0)
_pick_wander_target()
_step_toward(_path_target, move_speed * 0.5)
func _do_chase(delta: float) -> void:
_repath_timer -= delta
if _repath_timer <= 0.0:
_repath_timer = 0.25 # repath 4×/sec
_step_toward(_path_target, move_speed)
func _do_attack(_delta: float) -> void:
velocity.x = 0.0
velocity.z = 0.0
if _attack_timer <= 0.0:
_attack_timer = melee_cooldown
if player_ref and player_ref.has_method("take_damage"):
player_ref.take_damage(melee_damage, self)
# --- helpers ---
func _pick_wander_target() -> void:
if room_cells.is_empty():
_path_target = GridUtil.world_to_grid(global_position)
return
_path_target = GridUtil.random_passable(grid_ref, room_cells, rng)
func _at_target() -> bool:
var my_grid := GridUtil.world_to_grid(global_position)
return my_grid == _path_target
func _step_toward(target: Vector2i, speed: float) -> void:
if target == Vector2i(-1, -1):
velocity.x = 0.0
velocity.z = 0.0
return
var my_grid := GridUtil.world_to_grid(global_position)
var nxt := GridUtil.next_step_toward(grid_ref, my_grid, target)
if nxt == my_grid:
velocity.x = 0.0
velocity.z = 0.0
return
var target_world := GridUtil.grid_to_world(nxt, global_position.y)
var dir := (target_world - global_position)
dir.y = 0
dir = dir.normalized()
velocity.x = dir.x * speed
velocity.z = dir.z * speed
# Face movement direction so we look right visually.
if dir.length() > 0.1:
look_at(global_position + dir, Vector3.UP)
func take_damage(amount: int, _source: Node) -> void:
if not _alive:
return
hp -= amount
if hp <= 0:
_alive = false
died.emit()
queue_free()

View file

@ -0,0 +1 @@
uid://dr7jelri306sd

View file

@ -0,0 +1,139 @@
class_name GridUtil
extends RefCounted
# Static helpers for the arcade game. Single 79×29 grid per level; the
# generator dict is the source of truth. World↔grid mapping uses 1-unit
# cells (matches demo_fps.gd GridMap cell_size).
#
# Pathfinding is a plain 4-connected BFS, tiny code, fast enough at this
# scale (<1 ms for a full grid flood on a laptop). For an arcade game we
# recompute per-enemy every ~200ms, not every frame.
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
const L_WATER := 1
const L_LAVA := 2
const L_CHASM := 3
const L_BRIMSTONE := 4
const CELL_SIZE := 1.0
# --- coordinate mapping ---
static func grid_to_world(g: Vector2i, y: float = 0.0) -> Vector3:
return Vector3(float(g.x), y, float(g.y))
static func world_to_grid(w: Vector3) -> Vector2i:
return Vector2i(int(floor(w.x + 0.5)), int(floor(w.z + 0.5)))
# --- passability ---
# A cell is passable if an enemy (or player, for pathfinding intent) can
# step on it. Corridors, floor, doors, bridges, stairs, water count.
# Walls, chasms, lava, brimstone do not.
static func is_passable(grid: Dictionary, pos: Vector2i) -> bool:
var w: int = grid["width"]
var h: int = grid["height"]
if pos.x < 0 or pos.y < 0 or pos.x >= w or pos.y >= h:
return false
var idx := pos.y * w + pos.x
var t: int = (grid["terrain"] as PackedByteArray)[idx]
if t == T_LIQUID:
var liq: int = (grid["liquid"] as PackedByteArray)[idx]
return liq == L_WATER # water walkable, lava/chasm/brimstone not
return t == T_FLOOR or t == T_CORRIDOR or t == T_DOOR \
or t == T_BRIDGE or t == T_STAIRS_UP or t == T_STAIRS_DOWN
# --- BFS pathfinding ---
# Returns the next grid step from `from` toward `to`, or from itself if no
# path exists or we're already there. 4-connected, uniform cost.
static func next_step_toward(grid: Dictionary, from: Vector2i, to: Vector2i) -> Vector2i:
if from == to:
return from
if not is_passable(grid, from) or not is_passable(grid, to):
return from
var w: int = grid["width"]
var h: int = grid["height"]
var came_from := {} # Vector2i → Vector2i
came_from[from] = from
var queue: Array[Vector2i] = [from]
var found := false
var dirs := [Vector2i(1,0), Vector2i(-1,0), Vector2i(0,1), Vector2i(0,-1)]
while queue.size() > 0 and not found:
var cur: Vector2i = queue.pop_front()
for d in dirs:
var nxt: Vector2i = cur + d
if came_from.has(nxt): continue
if not is_passable(grid, nxt): continue
came_from[nxt] = cur
if nxt == to:
found = true
break
queue.push_back(nxt)
if not found:
return from
# Walk back from to → find cell whose came_from is `from`
var cur: Vector2i = to
while came_from[cur] != from:
cur = came_from[cur]
return cur
# True if there's an unobstructed line from a → b for enemy LOS purposes.
# Walks the Bresenham ray; any non-passable cell (wall / chasm / lava)
# blocks sight. Doors are transparent for LOS.
static func has_los(grid: Dictionary, a: Vector2i, b: Vector2i) -> bool:
var dx := absi(b.x - a.x)
var dy := absi(b.y - a.y)
var sx := 1 if a.x < b.x else -1
var sy := 1 if a.y < b.y else -1
var err := dx - dy
var x := a.x
var y := a.y
while true:
if Vector2i(x, y) == b: return true
# Skip blocking check at start cell.
if not (x == a.x and y == a.y):
if not _transparent(grid, Vector2i(x, y)):
return false
var e2 := 2 * err
if e2 > -dy:
err -= dy
x += sx
if e2 < dx:
err += dx
y += sy
return false
static func _transparent(grid: Dictionary, pos: Vector2i) -> bool:
var w: int = grid["width"]
var h: int = grid["height"]
if pos.x < 0 or pos.y < 0 or pos.x >= w or pos.y >= h:
return false
var idx := pos.y * w + pos.x
var t: int = (grid["terrain"] as PackedByteArray)[idx]
# Walls block; everything else is transparent for LOS.
return t != T_WALL and t != 0
# Pick a random passable cell in the given list of cells. Returns (-1,-1)
# if none are passable. Used by enemy wander target selection.
static func random_passable(grid: Dictionary, cells: Array, rng: RandomNumberGenerator) -> Vector2i:
if cells.is_empty():
return Vector2i(-1, -1)
var shuffled := cells.duplicate()
shuffled.shuffle()
for c in shuffled:
if is_passable(grid, c):
return c
return Vector2i(-1, -1)

View file

@ -0,0 +1 @@
uid://buo0sbr01qcm5

View file

@ -0,0 +1,57 @@
extends CanvasLayer
# HP bar, depth label, enemy count, death / win overlay.
@onready var hp_bar: ColorRect = $HPBar/Fill
@onready var hp_label: Label = $HPBar/Label
@onready var depth_label: Label = $InfoLabel
@onready var overlay: Control = $EndOverlay
@onready var overlay_title: Label = $EndOverlay/Title
@onready var overlay_hint: Label = $EndOverlay/Hint
const HP_COLOR_HIGH := Color(0.32, 0.82, 0.36)
const HP_COLOR_LOW := Color(0.88, 0.28, 0.22)
const HP_BAR_MAX_WIDTH := 200.0
var _game_over := false
var _scene_ref: Node = null
func _ready() -> void:
overlay.hide()
_scene_ref = get_parent()
_scene_ref.depth_changed.connect(_on_depth)
_scene_ref.enemies_changed.connect(_on_enemies)
_scene_ref.game_over.connect(_on_game_over)
# Player is a sibling; @onready on parent isn't set yet when we fire.
var p: Node = get_node("../Player")
p.hp_changed.connect(_on_hp)
func _unhandled_input(event: InputEvent) -> void:
if _game_over and event is InputEventKey and event.pressed and event.keycode == KEY_R:
_game_over = false
overlay.hide()
_scene_ref.restart_from_start()
func _on_hp(current: int, maximum: int) -> void:
var frac := 0.0 if maximum <= 0 else float(current) / float(maximum)
hp_bar.size.x = HP_BAR_MAX_WIDTH * frac
hp_bar.color = HP_COLOR_LOW.lerp(HP_COLOR_HIGH, frac)
hp_label.text = "HP %d / %d" % [current, maximum]
func _on_depth(depth: int, total: int) -> void:
depth_label.text = "Depth %d / %d" % [depth, total]
func _on_enemies(remaining: int, _total: int) -> void:
depth_label.text = "%s Enemies: %d" % [depth_label.text.split(" ")[0], remaining]
func _on_game_over(won: bool) -> void:
_game_over = true
overlay.show()
if won:
overlay_title.text = "YOU WIN"
overlay_title.modulate = Color(0.35, 0.90, 0.45)
else:
overlay_title.text = "YOU DIED"
overlay_title.modulate = Color(0.92, 0.28, 0.28)
overlay_hint.text = "Press R to restart"
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE

View file

@ -0,0 +1 @@
uid://dd7ud5jyshij5

View file

@ -0,0 +1,118 @@
class_name PlayerArcade
extends CharacterBody3D
# Arcade player: same FPS controller as the demo, plus HP and a melee
# attack on left-click. Emits hp_changed and died signals; arcade_scene
# listens and updates the HUD / restart logic.
signal hp_changed(current: int, maximum: int)
signal died
signal attacked(hit_point: Vector3, hit_body: Node)
@export var speed := 6.0
@export var run_multiplier := 1.8
@export var jump_velocity := 6.0
@export var gravity := 22.0
@export var mouse_sensitivity := 0.002
@export var max_hp := 20
@export var melee_range := 1.6
@export var melee_damage := 6
@export var melee_cooldown := 0.35
@onready var camera: Camera3D = $Camera3D
var hp := 20
var _pitch := 0.0
var _captured := false
var _melee_timer := 0.0
var _alive := true
func _ready() -> void:
hp = max_hp
hp_changed.emit(hp, max_hp)
_capture()
func _capture() -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
_captured = true
func _release() -> void:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_captured = false
func _unhandled_input(event: InputEvent) -> void:
if not _alive:
return
if event is InputEventMouseMotion and _captured:
var m := event as InputEventMouseMotion
rotation.y -= m.relative.x * mouse_sensitivity
_pitch -= m.relative.y * mouse_sensitivity
_pitch = clamp(_pitch, deg_to_rad(-85.0), deg_to_rad(85.0))
camera.rotation.x = _pitch
elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
_release()
elif event is InputEventMouseButton and event.pressed:
if not _captured:
_capture()
elif event.button_index == MOUSE_BUTTON_LEFT:
_try_attack()
func _physics_process(delta: float) -> void:
if not _alive:
return
if _melee_timer > 0.0:
_melee_timer -= delta
if not is_on_floor():
velocity.y -= gravity * delta
var input := Vector2.ZERO
if Input.is_key_pressed(KEY_W): input.y -= 1.0
if Input.is_key_pressed(KEY_S): input.y += 1.0
if Input.is_key_pressed(KEY_A): input.x -= 1.0
if Input.is_key_pressed(KEY_D): input.x += 1.0
input = input.normalized()
var s := speed
if Input.is_key_pressed(KEY_SHIFT):
s *= run_multiplier
var forward := -transform.basis.z
var right := transform.basis.x
var horiz := (forward * -input.y + right * input.x) * s
velocity.x = horiz.x
velocity.z = horiz.z
if is_on_floor() and Input.is_key_pressed(KEY_SPACE):
velocity.y = jump_velocity
move_and_slide()
# Deal damage to any enemy within melee_range in front of the camera.
func _try_attack() -> void:
if _melee_timer > 0.0:
return
_melee_timer = melee_cooldown
var origin := camera.global_position
var dir := -camera.global_transform.basis.z
var space := get_world_3d().direct_space_state
var query := PhysicsRayQueryParameters3D.create(origin, origin + dir * melee_range)
query.exclude = [self]
var hit := space.intersect_ray(query)
if hit.is_empty():
return
var body := hit.get("collider") as Node
if body and body.has_method("take_damage"):
body.take_damage(melee_damage, self)
attacked.emit(hit.get("position", Vector3.ZERO), body)
# Called by enemies.
func take_damage(amount: int, _source: Node) -> void:
if not _alive:
return
hp = max(0, hp - amount)
hp_changed.emit(hp, max_hp)
if hp <= 0:
_alive = false
died.emit()

View file

@ -0,0 +1 @@
uid://dxyvug2vl2jjq

View file

@ -0,0 +1,135 @@
extends SceneTree
# Usage:
# godot --headless --path demo --script scripts/bake_dungeon.gd -- SEED DEPTH OUT_DIR
#
# Shells out to bin/genesis --emit=json and bakes the result into two sibling
# assets under OUT_DIR:
# - mesh_library.tres (tile catalog, same content MLB.build()
# produces at runtime — shareable across bakes)
# - dungeon_seed<S>_depth<D>.tscn (PackedScene with a GridMap referencing
# the MeshLibrary, populated per the
# generated dungeon)
#
# Override the genesis binary path with the GENESIS_BIN env var; default is
# "../bin/genesis" relative to the Godot project.
const MESH_LIB_NAME := "mesh_library.tres"
const MLB = preload("res://scripts/mesh_library_builder.gd")
func _init() -> void:
var args := OS.get_cmdline_user_args()
if args.size() != 3:
push_error("usage: -- SEED DEPTH OUT_DIR")
quit(1)
return
var seed := int(args[0])
var depth := int(args[1])
var out_dir := args[2]
var bin_path := OS.get_environment("GENESIS_BIN")
if bin_path == "":
bin_path = ProjectSettings.globalize_path("res://") + "../bin/genesis"
bin_path = bin_path.simplify_path()
if not DirAccess.dir_exists_absolute(out_dir):
var err := DirAccess.make_dir_recursive_absolute(out_dir)
if err != OK:
push_error("cannot create %s (err %d)" % [out_dir, err])
quit(1)
return
var stdout: Array = []
var exit_code := OS.execute(bin_path, [
"--seed", str(seed), "--depth", str(depth), "--emit=json",
], stdout, true)
if exit_code != 0:
push_error("genesis exited %d (bin=%s)" % [exit_code, bin_path])
quit(1)
return
var grid: Dictionary = JSON.parse_string(stdout[0])
if grid.is_empty():
push_error("genesis produced invalid JSON")
quit(1)
return
# Step 1 — MeshLibrary.tres.
var lib_path := "%s/%s" % [out_dir, MESH_LIB_NAME]
var save_err := MLB.save_resource(lib_path, true)
if save_err != OK:
push_error("failed to save %s (err %d)" % [lib_path, save_err])
quit(1)
return
# Step 2 — PackedScene with populated GridMap.
var lib: MeshLibrary = load(lib_path)
var root := Node3D.new()
root.name = "Dungeon_seed%d_depth%d" % [seed, depth]
var gm := GridMap.new()
gm.name = "GridMap"
gm.mesh_library = lib
gm.cell_size = Vector3(1, 1, 1)
root.add_child(gm)
gm.owner = root
var cells_set := _populate(gm, grid)
var packed := PackedScene.new()
var pack_err := packed.pack(root)
if pack_err != OK:
push_error("failed to pack scene (err %d)" % pack_err)
quit(1)
return
var scn_path := "%s/dungeon_seed%d_depth%d.tscn" % [out_dir, seed, depth]
var scn_err := ResourceSaver.save(packed, scn_path)
if scn_err != OK:
push_error("failed to save %s (err %d)" % [scn_path, scn_err])
quit(1)
return
print("wrote %s + %s%d cells" % [scn_path, lib_path, cells_set])
quit(0)
# Decode the base64 layers and stamp tile IDs into the GridMap. Mirrors
# arcade_scene.gd _populate_grid_map so the CLI produces the same geometry
# as the in-engine path for the same seed/depth. Chasms stay as empty cells.
func _populate(gm: GridMap, grid: Dictionary) -> int:
var w := int(grid["width"])
var h := int(grid["height"])
var terrain := Marshalls.base64_to_raw(grid["terrain"])
var liquid := Marshalls.base64_to_raw(grid["liquid"])
var count := 0
for y in range(h):
for x in range(w):
var idx := y * w + x
var t := terrain[idx]
var liq := liquid[idx]
if t == 5 and liq == 3: # T_LIQUID + L_CHASM
continue
var tile := _tile_for(t, liq)
if tile < 0:
continue
gm.set_cell_item(Vector3i(x, 0, y), tile)
count += 1
return count
func _tile_for(terrain: int, liquid: int) -> int:
match terrain:
1: return MLB.TILE_FLOOR
4: return MLB.TILE_CORRIDOR
3: return MLB.TILE_DOOR
2: return MLB.TILE_WALL
6: return MLB.TILE_BRIDGE
7: return MLB.TILE_STAIRS_UP
8: return MLB.TILE_STAIRS_DOWN
5:
match liquid:
1: return MLB.TILE_WATER
2: return MLB.TILE_LAVA
4: return MLB.TILE_BRIMSTONE
_: return MLB.TILE_WATER
_: return -1

View file

@ -0,0 +1 @@
uid://y0qm8301m7w6

122
demo/scripts/demo_3d.gd Normal file
View file

@ -0,0 +1,122 @@
extends Node3D
# Demo_3D: renders multiple stacked levels of Brogue dungeons using GridMap.
# Chasm cells are rendered as empty (no tile), so looking down through one
# reveals the floor of the level below.
# Must match src/gen/grid.h
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
const L_NONE := 0
const L_WATER := 1
const L_LAVA := 2
const L_CHASM := 3
const L_BRIMSTONE := 4
# Match the tile IDs exposed by MeshLibraryBuilder.
const TILE_FLOOR := 0
const TILE_WALL := 1
const TILE_DOOR := 2
const TILE_CORRIDOR := 3
const TILE_WATER := 4
const TILE_LAVA := 5
const TILE_BRIMSTONE := 6
const TILE_BRIDGE := 7
const TILE_STAIRS_UP := 8
const TILE_STAIRS_DOWN := 9
@export var base_seed: int = 2321
@export var depth_start: int = 20
@export var level_count: int = 10
@export var level_spacing: float = 6.0
@onready var levels_root: Node3D = $Levels
@onready var camera: Camera3D = $FlyCamera
var _mesh_library: MeshLibrary
func _ready() -> void:
_mesh_library = MeshLibraryBuilder.build()
var total_chasm_cells := 0
for level_index in range(level_count):
var level_seed := base_seed + level_index
var depth := depth_start + level_index
var gen := BrogueGen.new()
var grid: Dictionary = gen.generate(level_seed, depth)
gen.free()
var grid_map := GridMap.new()
grid_map.name = "Level%d" % level_index
grid_map.mesh_library = _mesh_library
grid_map.cell_size = Vector3(1, 1, 1)
grid_map.position.y = -level_index * level_spacing
levels_root.add_child(grid_map)
var chasm_cells := _populate_level(grid_map, grid)
total_chasm_cells += chasm_cells
print("Level %d: seed=%d depth=%d rooms=%d machines=%d chasms=%d"
% [level_index, level_seed, depth,
(grid["rooms"] as Array).size(),
(grid["machines"] as Array).size(),
chasm_cells])
# Position the camera above level 0's grid center, tilted down.
var grid_w := 79
var grid_h := 29
camera.position = Vector3(grid_w * 0.5, 14, grid_h * 0.5 + 18)
camera.rotation_degrees = Vector3(-35, 0, 0)
print("Demo3D ready. %d levels, %d chasm cells total." % [level_count, total_chasm_cells])
# Populate one level's GridMap from the grid dict. Returns the count of
# chasm cells that were deliberately skipped (for reporting).
func _populate_level(grid_map: GridMap, grid: Dictionary) -> int:
var w: int = grid["width"]
var h: int = grid["height"]
var terrain: PackedByteArray = grid["terrain"]
var liquid: PackedByteArray = grid["liquid"]
var chasm_cells := 0
for y in range(h):
for x in range(w):
var idx := y * w + x
var t: int = terrain[idx]
var liq: int = liquid[idx]
# Chasm liquid renders as an actual see-through pit.
if t == T_LIQUID and liq == L_CHASM:
chasm_cells += 1
continue
var tile_id := _tile_for(t, liq)
if tile_id == -1:
continue # T_NOTHING or other empty — leave unrendered
# GridMap coords: (X, Y, Z). We want dungeon x → X, dungeon y → Z.
grid_map.set_cell_item(Vector3i(x, 0, y), tile_id)
return chasm_cells
func _tile_for(terrain: int, liquid: int) -> int:
match terrain:
T_FLOOR: return TILE_FLOOR
T_CORRIDOR: return TILE_CORRIDOR
T_DOOR: return TILE_DOOR
T_WALL: return TILE_WALL
T_BRIDGE: return TILE_BRIDGE
T_STAIRS_UP: return TILE_STAIRS_UP
T_STAIRS_DOWN: return TILE_STAIRS_DOWN
T_LIQUID:
match liquid:
L_WATER: return TILE_WATER
L_LAVA: return TILE_LAVA
L_BRIMSTONE: return TILE_BRIMSTONE
L_CHASM: return -1 # empty cell — see through to level below
_: return TILE_WATER
_: return -1 # T_NOTHING or unknown

View file

@ -0,0 +1 @@
uid://brsi02a7ei24j

143
demo/scripts/demo_fps.gd Normal file
View file

@ -0,0 +1,143 @@
extends Node3D
# First-person demo. Generates a grid of chunks (each a 79×29 dungeon) times
# level_count stacked layers. Default is 1×1 chunks × 3 levels. Bump
# chunks_x / chunks_y to stress-test the renderer with larger worlds.
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
const L_WATER := 1
const L_LAVA := 2
const L_CHASM := 3
const L_BRIMSTONE := 4
const TILE_FLOOR := 0
const TILE_WALL := 1
const TILE_DOOR := 2
const TILE_CORRIDOR := 3
const TILE_WATER := 4
const TILE_LAVA := 5
const TILE_BRIMSTONE := 6
const TILE_BRIDGE := 7
const TILE_STAIRS_UP := 8
const TILE_STAIRS_DOWN := 9
const CHUNK_W := 79
const CHUNK_H := 29
@export var base_seed: int = 2028
@export var depth_start: int = 20
@export var level_count: int = 3
@export var level_spacing: float = 6.0
@export var chunks_x: int = 1
@export var chunks_y: int = 1
@export var player_spawn_height: float = 1.1
@onready var levels_root: Node3D = $Levels
@onready var player: CharacterBody3D = $Player
@onready var fps_overlay: Node = $FPSOverlay if has_node("FPSOverlay") else null
var _mesh_library: MeshLibrary
func _ready() -> void:
var t0 := Time.get_ticks_msec()
_mesh_library = MeshLibraryBuilder.build(true)
var total_cells := 0
var total_chunks := 0
var spawn_world := Vector3.ZERO
var spawn_found := false
for level_index in range(level_count):
for cy in range(chunks_y):
for cx in range(chunks_x):
var seed := base_seed \
+ level_index * 1000 \
+ cy * chunks_x + cx
var depth := depth_start + level_index
var gen := BrogueGen.new()
var grid: Dictionary = gen.generate(seed, depth)
gen.free()
var grid_map := GridMap.new()
grid_map.name = "L%d_C%d_%d" % [level_index, cx, cy]
grid_map.mesh_library = _mesh_library
grid_map.cell_size = Vector3(1, 1, 1)
grid_map.position = Vector3(
cx * CHUNK_W,
-level_index * level_spacing,
cy * CHUNK_H
)
levels_root.add_child(grid_map)
var cells := _populate_level(grid_map, grid)
total_cells += cells
total_chunks += 1
# First up-stair on level 0, chunk (0,0) is the spawn point.
if not spawn_found and level_index == 0 and cx == 0 and cy == 0:
var up: Vector2i = grid["stairs_up"] as Vector2i
if up.x >= 0:
spawn_world = Vector3(up.x, player_spawn_height, up.y)
spawn_found = true
if not spawn_found:
spawn_world = Vector3(CHUNK_W * 0.5, player_spawn_height, CHUNK_H * 0.5)
player.position = spawn_world
var build_ms := Time.get_ticks_msec() - t0
var info := "%d chunks (%dx%d x %d lvls) %d placed cells build=%d ms" % [
total_chunks, chunks_x, chunks_y, level_count, total_cells, build_ms,
]
print(info)
print("Player spawned at %s" % spawn_world)
if fps_overlay and fps_overlay.has_method("set_subtitle"):
fps_overlay.set_subtitle(info)
# Place every non-chasm cell into the GridMap. Returns the number of cells
# actually placed (excludes T_NOTHING and chasms).
func _populate_level(grid_map: GridMap, grid: Dictionary) -> int:
var w: int = grid["width"]
var h: int = grid["height"]
var terrain: PackedByteArray = grid["terrain"]
var liquid: PackedByteArray = grid["liquid"]
var placed := 0
for y in range(h):
for x in range(w):
var idx := y * w + x
var t: int = terrain[idx]
var liq: int = liquid[idx]
if t == T_LIQUID and liq == L_CHASM:
continue
var tile_id := _tile_for(t, liq)
if tile_id == -1:
continue
grid_map.set_cell_item(Vector3i(x, 0, y), tile_id)
placed += 1
return placed
func _tile_for(terrain: int, liquid: int) -> int:
match terrain:
T_FLOOR: return TILE_FLOOR
T_CORRIDOR: return TILE_CORRIDOR
T_DOOR: return TILE_DOOR
T_WALL: return TILE_WALL
T_BRIDGE: return TILE_BRIDGE
T_STAIRS_UP: return TILE_STAIRS_UP
T_STAIRS_DOWN: return TILE_STAIRS_DOWN
T_LIQUID:
match liquid:
L_WATER: return TILE_WATER
L_LAVA: return TILE_LAVA
L_BRIMSTONE: return TILE_BRIMSTONE
_: return TILE_WATER
_: return -1

View file

@ -0,0 +1 @@
uid://dvoatkhtgji2u

261
demo/scripts/export_map.gd Normal file
View file

@ -0,0 +1,261 @@
extends SceneTree
# Usage:
# 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.
#
# 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 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_CHASM := 3
enum Kind { EMPTY, WALL, FLOOR, WATER, 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
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 f := FileAccess.open(out_path, FileAccess.WRITE)
if f == null:
push_error("cannot open %s for write" % out_path)
quit(1)
return
var brush_count := _write_map(f, grids, seed, depth)
f.close()
print("wrote %s%d levels, %d brushes" % [out_path, levels, brush_count])
quit(0)
func _write_map(f: FileAccess, grids: Array, seed: int, depth: int) -> int:
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("{\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)
for k in range(grids.size()):
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.
_propagate_chasms(grid, chasm_above)
f.store_string("}\n")
return count
func _write_level(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, row-merged by kind.
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.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
# 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
# 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.
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
return Kind.FLOOR # lava / brimstone sit at floor level
_: 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).
# 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
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,
])

View file

@ -0,0 +1 @@
uid://braurh5w6hjt2

View file

@ -0,0 +1,61 @@
class_name FlyCamera
extends Camera3D
# WASD + QE + mouse look fly camera. No collision — you fly through walls
# on purpose, so you can inspect the dungeon from any angle.
@export var move_speed := 8.0
@export var boost_multiplier := 3.0
@export var mouse_sensitivity := 0.002
var _yaw := 0.0
var _pitch := 0.0
var _captured := false
func _ready() -> void:
_capture()
_yaw = rotation.y
_pitch = rotation.x
func _capture() -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
_captured = true
func _release() -> void:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_captured = false
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion and _captured:
var m := event as InputEventMouseMotion
_yaw -= m.relative.x * mouse_sensitivity
_pitch -= m.relative.y * mouse_sensitivity
_pitch = clamp(_pitch, deg_to_rad(-85.0), deg_to_rad(85.0))
rotation = Vector3(_pitch, _yaw, 0.0)
elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
_release()
elif event is InputEventMouseButton and event.pressed and not _captured:
_capture()
func _process(delta: float) -> void:
var input := Vector3.ZERO
if Input.is_key_pressed(KEY_W): input.z -= 1.0
if Input.is_key_pressed(KEY_S): input.z += 1.0
if Input.is_key_pressed(KEY_A): input.x -= 1.0
if Input.is_key_pressed(KEY_D): input.x += 1.0
if Input.is_key_pressed(KEY_E): input.y += 1.0
if Input.is_key_pressed(KEY_Q): input.y -= 1.0
var speed := move_speed
if Input.is_key_pressed(KEY_SHIFT):
speed *= boost_multiplier
if input == Vector3.ZERO:
return
# Horizontal movement is yaw-relative; vertical stays in world space.
var yaw_basis := Basis(Vector3.UP, _yaw)
var dir := yaw_basis * Vector3(input.x, 0.0, input.z)
dir.y = input.y
dir = dir.normalized()
position += dir * speed * delta

View file

@ -0,0 +1 @@
uid://d0srrm35g1m0t

123
demo/scripts/fov.gd Normal file
View file

@ -0,0 +1,123 @@
class_name BrogueFOV
extends RefCounted
# Symmetric recursive shadowcasting, tile-accurate FOV for 2D grids.
#
# Port of the standard 8-octant algorithm (Bjoern Bergstrom / Adam Milazzo's
# symmetric variant). In ~100 lines of pure GDScript.
#
# Properties:
# - Tile-accurate: every cell is either fully visible or not visible.
# - Symmetric: if A sees B, B sees A. Critical for fair AI awareness.
# - Circular radius clipping (euclidean distance).
# - Opaque = walls, T_NOTHING, and unbridged liquid (lava/chasm/brimstone).
# Water is transparent (you can see across water).
#
# Usage:
# var vis := BrogueFOV.compute(grid, Vector2i(39, 14), 8)
# for y in grid["height"]:
# for x in grid["width"]:
# if vis[y * grid["width"] + x] == 1:
# draw_cell(x, y)
# Must match src/gen/grid.h: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
const L_WATER := 1
# Octant transforms: each octant maps (row, col) of the shadowcasting loop
# to grid deltas. 8 octants cover all directions symmetrically.
const OCTANTS := [
Vector4i( 1, 0, 0, 1),
Vector4i( 0, 1, 1, 0),
Vector4i( 0, -1, 1, 0),
Vector4i(-1, 0, 0, 1),
Vector4i(-1, 0, 0, -1),
Vector4i( 0, -1, -1, 0),
Vector4i( 0, 1, -1, 0),
Vector4i( 1, 0, 0, -1),
]
# Returns PackedByteArray of width*height, row-major, 1 = visible.
static func compute(grid: Dictionary, origin: Vector2i, radius: int) -> PackedByteArray:
var w: int = grid["width"]
var h: int = grid["height"]
var out := PackedByteArray()
out.resize(w * h)
for i in range(w * h):
out[i] = 0
if origin.x < 0 or origin.y < 0 or origin.x >= w or origin.y >= h:
return out
# Origin is always visible.
out[origin.y * w + origin.x] = 1
var terrain: PackedByteArray = grid["terrain"]
var liquid: PackedByteArray = grid["liquid"]
for oct in OCTANTS:
_cast_octant(out, terrain, liquid, origin, radius, w, h,
1, 1.0, 0.0, oct)
return out
static func _is_opaque(terrain: PackedByteArray, liquid: PackedByteArray,
x: int, y: int, w: int) -> bool:
var idx := y * w + x
var t: int = terrain[idx]
if t == T_WALL or t == T_NOTHING:
return true
if t == T_LIQUID:
# Water is transparent; lava / chasm / brimstone are opaque.
return liquid[idx] != L_WATER
return false
static func _cast_octant(out: PackedByteArray,
terrain: PackedByteArray, liquid: PackedByteArray,
origin: Vector2i, radius: int, w: int, h: int,
row: int, start_slope: float, end_slope: float,
oct: Vector4i) -> void:
if start_slope < end_slope:
return
var r2: int = radius * radius
var new_start := start_slope
var blocked := false
for i in range(row, radius + 1):
if blocked:
break
for dy in range(-i, 1):
var dx := -i
while dx <= 0:
var tx: int = origin.x + dx * oct.x + dy * oct.y
var ty: int = origin.y + dx * oct.z + dy * oct.w
var l_slope := (dx - 0.5) / (dy + 0.5)
var r_slope := (dx + 0.5) / (dy - 0.5)
if start_slope < r_slope:
dx += 1
continue
if end_slope > l_slope:
break
if tx >= 0 and tx < w and ty >= 0 and ty < h:
if dx * dx + dy * dy <= r2:
out[ty * w + tx] = 1
if blocked:
if _is_opaque(terrain, liquid, tx, ty, w):
new_start = r_slope
else:
blocked = false
start_slope = new_start
else:
if _is_opaque(terrain, liquid, tx, ty, w) and i < radius:
blocked = true
_cast_octant(out, terrain, liquid, origin, radius, w, h,
i + 1, start_slope, l_slope, oct)
new_start = r_slope
dx += 1

1
demo/scripts/fov.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://d0ypl6iuo62ox

View file

@ -0,0 +1,18 @@
extends CanvasLayer
# Cheap FPS + stats overlay. Attach as a child of any scene.
# The parent scene can call set_subtitle(text) to add a line below the FPS.
@onready var label: Label = $Label
var _subtitle := ""
func set_subtitle(text: String) -> void:
_subtitle = text
func _process(_delta: float) -> void:
var fps := Engine.get_frames_per_second()
var frame_ms := 0.0 if fps <= 0 else 1000.0 / fps
var txt := "FPS: %d %.2f ms" % [fps, frame_ms]
if _subtitle != "":
txt += "\n" + _subtitle
label.text = txt

View file

@ -0,0 +1 @@
uid://chq0nb6xd2e5w

View file

@ -0,0 +1,3 @@
@warning_ignore("empty_file")
extends Node3D

View file

@ -0,0 +1 @@
uid://yh36kisxu2q3

View file

@ -0,0 +1,199 @@
class_name MeshLibraryBuilder
extends RefCounted
# Builds a MeshLibrary at runtime for the 3D dungeon demo. No art assets —
# BoxMesh / PlaneMesh primitives with tinted StandardMaterial3D. Each tile
# ID here matches a terrain enum the renderer cares about.
# Tile IDs (exposed as constants so demo_3d.gd can pass them to
# GridMap.set_cell_item).
const TILE_FLOOR := 0
const TILE_WALL := 1
const TILE_DOOR := 2
const TILE_CORRIDOR := 3
const TILE_WATER := 4
const TILE_LAVA := 5
const TILE_BRIMSTONE := 6
const TILE_BRIDGE := 7
const TILE_STAIRS_UP := 8
const TILE_STAIRS_DOWN := 9
# Floors and liquid surfaces are thin slabs centered at the cell origin.
# Walls are tall enough that the player (camera ~2.6u) can't see over them.
# Doors are chest-height so they read as entry markers without blocking view
# along corridors — can raise to full height once we have swinging doors.
const CELL_SIZE := 1.0
const WALL_HEIGHT := 3.0
const FLOOR_THICKNESS := 0.05
const LIQUID_THICKNESS := 0.05
const DOOR_HEIGHT := 0.8
const BRIDGE_THICKNESS := 0.1
const STAIRS_HEIGHT := 0.7
static func build(with_collision: bool = true) -> MeshLibrary:
var lib := MeshLibrary.new()
_add_slab(lib, TILE_FLOOR, "Floor", _mat(Color(0.64, 0.54, 0.42)), FLOOR_THICKNESS, 0.0)
_add_cube(lib, TILE_WALL, "Wall", _mat(Color(0.28, 0.28, 0.32)), WALL_HEIGHT, WALL_HEIGHT * 0.5)
_add_cube(lib, TILE_DOOR, "Door", _mat(Color(0.72, 0.36, 0.20)), DOOR_HEIGHT, DOOR_HEIGHT * 0.5)
_add_slab(lib, TILE_CORRIDOR, "Corridor", _mat(Color(0.44, 0.37, 0.29)), FLOOR_THICKNESS, 0.0)
_add_slab(lib, TILE_WATER, "Water", _mat_trans(Color(0.18, 0.42, 0.78, 0.75)), LIQUID_THICKNESS, -0.05)
_add_slab(lib, TILE_LAVA, "Lava", _mat_emissive(Color(0.90, 0.30, 0.12), Color(1.00, 0.45, 0.15), 1.5), LIQUID_THICKNESS, -0.05)
_add_slab(lib, TILE_BRIMSTONE, "Brimstone", _mat_emissive(Color(0.72, 0.50, 0.22), Color(0.95, 0.65, 0.25), 0.8), LIQUID_THICKNESS, -0.05)
_add_slab(lib, TILE_BRIDGE, "Bridge", _mat(Color(0.55, 0.38, 0.22)), BRIDGE_THICKNESS, 0.0)
_add_ramp(lib, TILE_STAIRS_UP, "StairsUp", _mat(Color(0.74, 0.88, 0.94)), true)
_add_ramp(lib, TILE_STAIRS_DOWN, "StairsDown", _mat(Color(0.20, 0.44, 0.80)), false)
if with_collision:
_attach_collision(lib)
return lib
# Bake the library to a .tres so the CLI and runtime can share the same asset
# instead of rebuilding it each time a scene loads.
static func save_resource(path: String, with_collision: bool = true) -> Error:
return ResourceSaver.save(build(with_collision), path)
# --- collision shapes ---
# Every tile except chasm (which has no tile at all) gets a box collider
# sized to match its mesh. Chasms therefore let the player fall through
# cleanly onto the level below. Stairs are treated as solid blocks; walking
# onto them works well enough with gravity + move_and_slide.
static func _attach_collision(lib: MeshLibrary) -> void:
# Shape, y_offset pairs, in the same geometry as the meshes above.
var floor_shape := _box_shape(Vector3(CELL_SIZE, FLOOR_THICKNESS, CELL_SIZE))
var wall_shape := _box_shape(Vector3(CELL_SIZE, WALL_HEIGHT, CELL_SIZE))
var door_shape := _box_shape(Vector3(CELL_SIZE, DOOR_HEIGHT, CELL_SIZE))
var liquid_shape := _box_shape(Vector3(CELL_SIZE, LIQUID_THICKNESS, CELL_SIZE))
var bridge_shape := _box_shape(Vector3(CELL_SIZE, BRIDGE_THICKNESS, CELL_SIZE))
var stairs_shape := _box_shape(Vector3(CELL_SIZE, STAIRS_HEIGHT, CELL_SIZE))
_set_shape(lib, TILE_FLOOR, floor_shape, FLOOR_THICKNESS * 0.5)
_set_shape(lib, TILE_WALL, wall_shape, WALL_HEIGHT * 0.5)
_set_shape(lib, TILE_DOOR, door_shape, DOOR_HEIGHT * 0.5)
_set_shape(lib, TILE_CORRIDOR, floor_shape, FLOOR_THICKNESS * 0.5)
_set_shape(lib, TILE_WATER, liquid_shape, -0.05 + LIQUID_THICKNESS * 0.5)
_set_shape(lib, TILE_LAVA, liquid_shape, -0.05 + LIQUID_THICKNESS * 0.5)
_set_shape(lib, TILE_BRIMSTONE, liquid_shape, -0.05 + LIQUID_THICKNESS * 0.5)
_set_shape(lib, TILE_BRIDGE, bridge_shape, BRIDGE_THICKNESS * 0.5)
_set_shape(lib, TILE_STAIRS_UP, stairs_shape, STAIRS_HEIGHT * 0.5)
_set_shape(lib, TILE_STAIRS_DOWN, stairs_shape, STAIRS_HEIGHT * 0.5)
static func _box_shape(size: Vector3) -> BoxShape3D:
var b := BoxShape3D.new()
b.size = size
return b
static func _set_shape(lib: MeshLibrary, id: int, shape: Shape3D, y: float) -> void:
var t := Transform3D.IDENTITY
t.origin = Vector3(0, y, 0)
lib.set_item_shapes(id, [shape, t])
# --- material helpers ---
static func _mat(base: Color) -> StandardMaterial3D:
var m := StandardMaterial3D.new()
m.albedo_color = base
m.roughness = 0.8
m.metallic = 0.0
return m
static func _mat_trans(base: Color) -> StandardMaterial3D:
var m := _mat(base)
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
return m
static func _mat_emissive(base: Color, emis: Color, strength: float) -> StandardMaterial3D:
var m := _mat(base)
m.emission_enabled = true
m.emission = emis
m.emission_energy_multiplier = strength
return m
# --- mesh builders ---
# A flat slab filling the cell's XZ footprint, with the given thickness and
# base at y_base (the slab extends UP from y_base by `thickness`).
static func _add_slab(lib: MeshLibrary, id: int, name: String,
mat: StandardMaterial3D,
thickness: float, y_base: float) -> void:
var mesh := BoxMesh.new()
mesh.size = Vector3(CELL_SIZE, thickness, CELL_SIZE)
mesh.material = mat
var t := Transform3D.IDENTITY
t.origin = Vector3(0, y_base + thickness * 0.5, 0)
_install(lib, id, name, mesh, t)
# A cube centered on the cell. y_center is the Y of the cube's center.
static func _add_cube(lib: MeshLibrary, id: int, name: String,
mat: StandardMaterial3D,
height: float, y_center: float) -> void:
var mesh := BoxMesh.new()
mesh.size = Vector3(CELL_SIZE, height, CELL_SIZE)
mesh.material = mat
var t := Transform3D.IDENTITY
t.origin = Vector3(0, y_center, 0)
_install(lib, id, name, mesh, t)
# Simple visual ramp using two cubes at different heights. Not geometrically
# correct stairs — just enough to read as "stairs" from flight.
# going_up=true means the tall step is at +Z; false means the tall step is
# at -Z (visually going down).
static func _add_ramp(lib: MeshLibrary, id: int, name: String,
mat: StandardMaterial3D, going_up: bool) -> void:
# GridMap.set_mesh expects a single Mesh. Fake the steps by stacking two
# BoxMeshes into an ArrayMesh via SurfaceTool.
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
st.set_material(mat)
var tall := STAIRS_HEIGHT
var short := STAIRS_HEIGHT * 0.35
var half := CELL_SIZE * 0.25
# Step 1: back (taller going up, shorter going down).
var z_back := half if going_up else -half
_append_box(st, Vector3(0, (tall if going_up else short) * 0.5, z_back),
Vector3(CELL_SIZE, (tall if going_up else short), CELL_SIZE * 0.5))
# Step 2: front.
var z_front := -half if going_up else half
_append_box(st, Vector3(0, (short if going_up else tall) * 0.5, z_front),
Vector3(CELL_SIZE, (short if going_up else tall), CELL_SIZE * 0.5))
var arr := st.commit()
_install(lib, id, name, arr, Transform3D.IDENTITY)
static func _append_box(st: SurfaceTool, center: Vector3, size: Vector3) -> void:
var h := size * 0.5
var c := center
var v := [
c + Vector3(-h.x, -h.y, -h.z), # 0
c + Vector3( h.x, -h.y, -h.z), # 1
c + Vector3( h.x, h.y, -h.z), # 2
c + Vector3(-h.x, h.y, -h.z), # 3
c + Vector3(-h.x, -h.y, h.z), # 4
c + Vector3( h.x, -h.y, h.z), # 5
c + Vector3( h.x, h.y, h.z), # 6
c + Vector3(-h.x, h.y, h.z), # 7
]
# 6 faces, each 2 tris, wound CCW with outward normals.
var faces := [
[0, 1, 2, 3, Vector3(0, 0, -1)],
[5, 4, 7, 6, Vector3(0, 0, 1)],
[1, 5, 6, 2, Vector3( 1, 0, 0)],
[4, 0, 3, 7, Vector3(-1, 0, 0)],
[3, 2, 6, 7, Vector3(0, 1, 0)],
[4, 5, 1, 0, Vector3(0, -1, 0)],
]
for f in faces:
var n: Vector3 = f[4]
for i in [0, 1, 2, 0, 2, 3]:
st.set_normal(n)
st.add_vertex(v[f[i]])
# Register a mesh + transform with the library under the given ID.
static func _install(lib: MeshLibrary, id: int, name: String,
mesh: Mesh, t: Transform3D) -> void:
lib.create_item(id)
lib.set_item_name(id, name)
lib.set_item_mesh(id, mesh)
lib.set_item_mesh_transform(id, t)

View file

@ -0,0 +1 @@
uid://ccjvjlugb7wpu

69
demo/scripts/player.gd Normal file
View file

@ -0,0 +1,69 @@
class_name Player
extends CharacterBody3D
# First-person player controller. WASD relative to body yaw, mouse look
# (yaw on the body, pitch on the child Camera3D). Jump on space. Gravity
# is always on — falling into a chasm drops you onto the level below.
@export var speed := 6.0
@export var run_multiplier := 1.8
@export var jump_velocity := 6.0
@export var gravity := 22.0
@export var mouse_sensitivity := 0.002
@onready var camera: Camera3D = $Camera3D
var _pitch := 0.0
var _captured := false
func _ready() -> void:
_capture()
func _capture() -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
_captured = true
func _release() -> void:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_captured = false
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion and _captured:
var m := event as InputEventMouseMotion
rotation.y -= m.relative.x * mouse_sensitivity
_pitch -= m.relative.y * mouse_sensitivity
_pitch = clamp(_pitch, deg_to_rad(-85.0), deg_to_rad(85.0))
camera.rotation.x = _pitch
elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
_release()
elif event is InputEventMouseButton and event.pressed and not _captured:
_capture()
func _physics_process(delta: float) -> void:
# Gravity.
if not is_on_floor():
velocity.y -= gravity * delta
# Horizontal movement relative to yaw.
var input := Vector2.ZERO
if Input.is_key_pressed(KEY_W): input.y -= 1.0
if Input.is_key_pressed(KEY_S): input.y += 1.0
if Input.is_key_pressed(KEY_A): input.x -= 1.0
if Input.is_key_pressed(KEY_D): input.x += 1.0
input = input.normalized()
var s := speed
if Input.is_key_pressed(KEY_SHIFT):
s *= run_multiplier
var forward := -transform.basis.z
var right := transform.basis.x
var horiz := (forward * -input.y + right * input.x) * s
velocity.x = horiz.x
velocity.z = horiz.z
# Jump.
if is_on_floor() and Input.is_key_pressed(KEY_SPACE):
velocity.y = jump_velocity
move_and_slide()

View file

@ -0,0 +1 @@
uid://bj1fb7syiqys7

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,192 @@
[gd_resource type="MeshLibrary" format=4 uid="uid://u7pnhcouyiss"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_mxq65"]
albedo_color = Color(0.64, 0.54, 0.42, 1)
roughness = 0.8
[sub_resource type="BoxMesh" id="BoxMesh_nfq1i"]
material = SubResource("StandardMaterial3D_mxq65")
size = Vector3(1, 0.05, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_c471q"]
size = Vector3(1, 0.05, 1)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_iuw7j"]
albedo_color = Color(0.28, 0.28, 0.32, 1)
roughness = 0.8
[sub_resource type="BoxMesh" id="BoxMesh_7yf4u"]
material = SubResource("StandardMaterial3D_iuw7j")
size = Vector3(1, 3, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_aewbc"]
size = Vector3(1, 3, 1)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_dpcan"]
albedo_color = Color(0.72, 0.36, 0.2, 1)
roughness = 0.8
[sub_resource type="BoxMesh" id="BoxMesh_ba7m6"]
material = SubResource("StandardMaterial3D_dpcan")
size = Vector3(1, 0.8, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_pqd2j"]
size = Vector3(1, 0.8, 1)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_7bx42"]
albedo_color = Color(0.44, 0.37, 0.29, 1)
roughness = 0.8
[sub_resource type="BoxMesh" id="BoxMesh_q4frx"]
material = SubResource("StandardMaterial3D_7bx42")
size = Vector3(1, 0.05, 1)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_n2rcg"]
transparency = 1
albedo_color = Color(0.18, 0.42, 0.78, 0.75)
roughness = 0.8
[sub_resource type="BoxMesh" id="BoxMesh_qowgg"]
material = SubResource("StandardMaterial3D_n2rcg")
size = Vector3(1, 0.05, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_n4omc"]
size = Vector3(1, 0.05, 1)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_rdtaw"]
albedo_color = Color(0.9, 0.3, 0.12, 1)
roughness = 0.8
emission_enabled = true
emission = Color(1, 0.45, 0.15, 1)
emission_energy_multiplier = 1.5
[sub_resource type="BoxMesh" id="BoxMesh_bbjka"]
material = SubResource("StandardMaterial3D_rdtaw")
size = Vector3(1, 0.05, 1)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_e4bfb"]
albedo_color = Color(0.72, 0.5, 0.22, 1)
roughness = 0.8
emission_enabled = true
emission = Color(0.95, 0.65, 0.25, 1)
emission_energy_multiplier = 0.8
[sub_resource type="BoxMesh" id="BoxMesh_r1428"]
material = SubResource("StandardMaterial3D_e4bfb")
size = Vector3(1, 0.05, 1)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_04bo8"]
albedo_color = Color(0.55, 0.38, 0.22, 1)
roughness = 0.8
[sub_resource type="BoxMesh" id="BoxMesh_qyws6"]
material = SubResource("StandardMaterial3D_04bo8")
size = Vector3(1, 0.1, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_ym5kt"]
size = Vector3(1, 0.1, 1)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_nv4rv"]
albedo_color = Color(0.74, 0.88, 0.94, 1)
roughness = 0.8
[sub_resource type="ArrayMesh" id="ArrayMesh_xoewy"]
_surfaces = [{
"aabb": AABB(-0.5, 0, -0.5, 1, 0.7, 1),
"format": 34359738375,
"material": SubResource("StandardMaterial3D_nv4rv"),
"primitive": 3,
"uv_scale": Vector4(0, 0, 0, 0),
"vertex_count": 72,
"vertex_data": PackedByteArray("AAAAvwAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAPzMzMz8AAAAAAAAAvwAAAAAAAAAAAAAAPzMzMz8AAAAAAAAAvzMzMz8AAAAAAAAAPwAAAAAAAAA/AAAAvwAAAAAAAAA/AAAAvzMzMz8AAAA/AAAAPwAAAAAAAAA/AAAAvzMzMz8AAAA/AAAAPzMzMz8AAAA/AAAAPwAAAAAAAAAAAAAAPwAAAAAAAAA/AAAAPzMzMz8AAAA/AAAAPwAAAAAAAAAAAAAAPzMzMz8AAAA/AAAAPzMzMz8AAAAAAAAAvwAAAAAAAAA/AAAAvwAAAAAAAAAAAAAAvzMzMz8AAAAAAAAAvwAAAAAAAAA/AAAAvzMzMz8AAAAAAAAAvzMzMz8AAAA/AAAAvzMzMz8AAAAAAAAAPzMzMz8AAAAAAAAAPzMzMz8AAAA/AAAAvzMzMz8AAAAAAAAAPzMzMz8AAAA/AAAAvzMzMz8AAAA/AAAAvwAAAAAAAAA/AAAAPwAAAAAAAAA/AAAAPwAAAAAAAAAAAAAAvwAAAAAAAAA/AAAAPwAAAAAAAAAAAAAAvwAAAAAAAAAAAAAAvwAAAAAAAAC/AAAAPwAAAAAAAAC/AAAAP0jhej4AAAC/AAAAvwAAAAAAAAC/AAAAP0jhej4AAAC/AAAAv0jhej4AAAC/AAAAPwAAAAAAAAAAAAAAvwAAAAAAAAAAAAAAv0jhej4AAAAAAAAAPwAAAAAAAAAAAAAAv0jhej4AAAAAAAAAP0jhej4AAAAAAAAAPwAAAAAAAAC/AAAAPwAAAAAAAAAAAAAAP0jhej4AAAAAAAAAPwAAAAAAAAC/AAAAP0jhej4AAAAAAAAAP0jhej4AAAC/AAAAvwAAAAAAAAAAAAAAvwAAAAAAAAC/AAAAv0jhej4AAAC/AAAAvwAAAAAAAAAAAAAAv0jhej4AAAC/AAAAv0jhej4AAAAAAAAAv0jhej4AAAC/AAAAP0jhej4AAAC/AAAAP0jhej4AAAAAAAAAv0jhej4AAAC/AAAAP0jhej4AAAAAAAAAv0jhej4AAAAAAAAAvwAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAPwAAAAAAAAC/AAAAvwAAAAAAAAAAAAAAPwAAAAAAAAC/AAAAvwAAAAAAAAC///////9/AID//////38AgP//////fwCA//////9/AID//////38AgP//////fwCA/3//f/9/AID/f/9//38AgP9//3//fwCA/3//f/9/AID/f/9//38AgP9//3//fwCA////f/9//7////9//3//v////3//f/+/////f/9//7////9//3//v////3//f/+/AAD/f/9//78AAP9//3//vwAA/3//f/+/AAD/f/9//78AAP9//3//vwAA/3//f/+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//38AAAAA/7//fwAAAAD/v/9/AAAAAP+//38AAAAA/7//fwAAAAD/v/9/AAAAAP+///////9/AID//////38AgP//////fwCA//////9/AID//////38AgP//////fwCA/3//f/9/AID/f/9//38AgP9//3//fwCA/3//f/9/AID/f/9//38AgP9//3//fwCA////f/9//7////9//3//v////3//f/+/////f/9//7////9//3//v////3//f/+/AAD/f/9//78AAP9//3//vwAA/3//f/+/AAD/f/9//78AAP9//3//vwAA/3//f/+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//38AAAAA/7//fwAAAAD/v/9/AAAAAP+//38AAAAA/7//fwAAAAD/v/9/AAAAAP+/")
}]
[sub_resource type="BoxShape3D" id="BoxShape3D_ksa0k"]
size = Vector3(1, 0.7, 1)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_wq4fl"]
albedo_color = Color(0.2, 0.44, 0.8, 1)
roughness = 0.8
[sub_resource type="ArrayMesh" id="ArrayMesh_fk0ts"]
_surfaces = [{
"aabb": AABB(-0.5, 0, -0.5, 1, 0.7, 1),
"format": 34359738375,
"material": SubResource("StandardMaterial3D_wq4fl"),
"primitive": 3,
"uv_scale": Vector4(0, 0, 0, 0),
"vertex_count": 72,
"vertex_data": PackedByteArray("AAAAvwAAAAAAAAC/AAAAPwAAAAAAAAC/AAAAP0jhej4AAAC/AAAAvwAAAAAAAAC/AAAAP0jhej4AAAC/AAAAv0jhej4AAAC/AAAAPwAAAAAAAAAAAAAAvwAAAAAAAAAAAAAAv0jhej4AAAAAAAAAPwAAAAAAAAAAAAAAv0jhej4AAAAAAAAAP0jhej4AAAAAAAAAPwAAAAAAAAC/AAAAPwAAAAAAAAAAAAAAP0jhej4AAAAAAAAAPwAAAAAAAAC/AAAAP0jhej4AAAAAAAAAP0jhej4AAAC/AAAAvwAAAAAAAAAAAAAAvwAAAAAAAAC/AAAAv0jhej4AAAC/AAAAvwAAAAAAAAAAAAAAv0jhej4AAAC/AAAAv0jhej4AAAAAAAAAv0jhej4AAAC/AAAAP0jhej4AAAC/AAAAP0jhej4AAAAAAAAAv0jhej4AAAC/AAAAP0jhej4AAAAAAAAAv0jhej4AAAAAAAAAvwAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAPwAAAAAAAAC/AAAAvwAAAAAAAAAAAAAAPwAAAAAAAAC/AAAAvwAAAAAAAAC/AAAAvwAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAPzMzMz8AAAAAAAAAvwAAAAAAAAAAAAAAPzMzMz8AAAAAAAAAvzMzMz8AAAAAAAAAPwAAAAAAAAA/AAAAvwAAAAAAAAA/AAAAvzMzMz8AAAA/AAAAPwAAAAAAAAA/AAAAvzMzMz8AAAA/AAAAPzMzMz8AAAA/AAAAPwAAAAAAAAAAAAAAPwAAAAAAAAA/AAAAPzMzMz8AAAA/AAAAPwAAAAAAAAAAAAAAPzMzMz8AAAA/AAAAPzMzMz8AAAAAAAAAvwAAAAAAAAA/AAAAvwAAAAAAAAAAAAAAvzMzMz8AAAAAAAAAvwAAAAAAAAA/AAAAvzMzMz8AAAAAAAAAvzMzMz8AAAA/AAAAvzMzMz8AAAAAAAAAPzMzMz8AAAAAAAAAPzMzMz8AAAA/AAAAvzMzMz8AAAAAAAAAPzMzMz8AAAA/AAAAvzMzMz8AAAA/AAAAvwAAAAAAAAA/AAAAPwAAAAAAAAA/AAAAPwAAAAAAAAAAAAAAvwAAAAAAAAA/AAAAPwAAAAAAAAAAAAAAvwAAAAAAAAAA//////9/AID//////38AgP//////fwCA//////9/AID//////38AgP//////fwCA/3//f/9/AID/f/9//38AgP9//3//fwCA/3//f/9/AID/f/9//38AgP9//3//fwCA////f/9//7////9//3//v////3//f/+/////f/9//7////9//3//v////3//f/+/AAD/f/9//78AAP9//3//vwAA/3//f/+/AAD/f/9//78AAP9//3//vwAA/3//f/+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//38AAAAA/7//fwAAAAD/v/9/AAAAAP+//38AAAAA/7//fwAAAAD/v/9/AAAAAP+///////9/AID//////38AgP//////fwCA//////9/AID//////38AgP//////fwCA/3//f/9/AID/f/9//38AgP9//3//fwCA/3//f/9/AID/f/9//38AgP9//3//fwCA////f/9//7////9//3//v////3//f/+/////f/9//7////9//3//v////3//f/+/AAD/f/9//78AAP9//3//vwAA/3//f/+/AAD/f/9//78AAP9//3//vwAA/3//f/+//3///wAA/7//f///AAD/v/9///8AAP+//3///wAA/7//f///AAD/v/9///8AAP+//38AAAAA/7//fwAAAAD/v/9/AAAAAP+//38AAAAA/7//fwAAAAD/v/9/AAAAAP+/")
}]
[resource]
item/0/name = "Floor"
item/0/mesh = SubResource("BoxMesh_nfq1i")
item/0/mesh_transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.025, 0)
item/0/mesh_cast_shadow = 1
item/0/shapes = [SubResource("BoxShape3D_c471q"), Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.025, 0)]
item/0/navigation_mesh_transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
item/0/navigation_layers = 1
item/1/name = "Wall"
item/1/mesh = SubResource("BoxMesh_7yf4u")
item/1/mesh_transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, 0)
item/1/mesh_cast_shadow = 1
item/1/shapes = [SubResource("BoxShape3D_aewbc"), Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, 0)]
item/1/navigation_mesh_transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
item/1/navigation_layers = 1
item/2/name = "Door"
item/2/mesh = SubResource("BoxMesh_ba7m6")
item/2/mesh_transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.4, 0)
item/2/mesh_cast_shadow = 1
item/2/shapes = [SubResource("BoxShape3D_pqd2j"), Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.4, 0)]
item/2/navigation_mesh_transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
item/2/navigation_layers = 1
item/3/name = "Corridor"
item/3/mesh = SubResource("BoxMesh_q4frx")
item/3/mesh_transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.025, 0)
item/3/mesh_cast_shadow = 1
item/3/shapes = [SubResource("BoxShape3D_c471q"), Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.025, 0)]
item/3/navigation_mesh_transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
item/3/navigation_layers = 1
item/4/name = "Water"
item/4/mesh = SubResource("BoxMesh_qowgg")
item/4/mesh_transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.025, 0)
item/4/mesh_cast_shadow = 1
item/4/shapes = [SubResource("BoxShape3D_n4omc"), Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.025, 0)]
item/4/navigation_mesh_transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
item/4/navigation_layers = 1
item/5/name = "Lava"
item/5/mesh = SubResource("BoxMesh_bbjka")
item/5/mesh_transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.025, 0)
item/5/mesh_cast_shadow = 1
item/5/shapes = [SubResource("BoxShape3D_n4omc"), Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.025, 0)]
item/5/navigation_mesh_transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
item/5/navigation_layers = 1
item/6/name = "Brimstone"
item/6/mesh = SubResource("BoxMesh_r1428")
item/6/mesh_transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.025, 0)
item/6/mesh_cast_shadow = 1
item/6/shapes = [SubResource("BoxShape3D_n4omc"), Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.025, 0)]
item/6/navigation_mesh_transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
item/6/navigation_layers = 1
item/7/name = "Bridge"
item/7/mesh = SubResource("BoxMesh_qyws6")
item/7/mesh_transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.05, 0)
item/7/mesh_cast_shadow = 1
item/7/shapes = [SubResource("BoxShape3D_ym5kt"), Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.05, 0)]
item/7/navigation_mesh_transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
item/7/navigation_layers = 1
item/8/name = "StairsUp"
item/8/mesh = SubResource("ArrayMesh_xoewy")
item/8/mesh_transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
item/8/mesh_cast_shadow = 1
item/8/shapes = [SubResource("BoxShape3D_ksa0k"), Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.35, 0)]
item/8/navigation_mesh_transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
item/8/navigation_layers = 1
item/9/name = "StairsDown"
item/9/mesh = SubResource("ArrayMesh_fk0ts")
item/9/mesh_transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
item/9/mesh_cast_shadow = 1
item/9/shapes = [SubResource("BoxShape3D_ksa0k"), Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.35, 0)]
item/9/navigation_mesh_transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
item/9/navigation_layers = 1

330
godot/README.md Normal file
View file

@ -0,0 +1,330 @@
# brogue_gen — Godot GDExtension API Reference
Brogue-style dungeon generator for Godot 4.3+. Pure C99 generator under the hood; Godot-native Dictionary interface on top.
**Scope.** Map generation only. One class, one method. Call `BrogueGen.generate(seed, depth)` and get back everything you need to spawn a level: terrain grid, liquids, markers, rooms, machines, chokepoints, stairs. Gameplay is yours to write.
---
## Table of contents
- [Installation](#installation)
- [Project layout](#project-layout)
- [Class `BrogueGen`](#class-broguegen)
- [Returned dictionary schema](#returned-dictionary-schema)
- [Data structures](#data-structures)
- [Enums](#enums)
- [Usage pattern](#usage-pattern)
- [FOV reference (`BrogueFOV`)](#fov-reference)
- [Determinism guarantees](#determinism-guarantees)
- [Build and rebuild](#build-and-rebuild)
- [Troubleshooting](#troubleshooting)
---
## Installation
Drop the `addons/brogue_gen/` directory into any Godot 4.3+ project:
```
your_project/
└── addons/
└── brogue_gen/
├── brogue_gen.gdextension ← registered by Godot scan
└── bin/
└── libbrogue_gen.linux.template_debug.x86_64.so
```
Re-open the project once so Godot scans and registers the extension. `BrogueGen` then appears in **Create New Node** and `class_name` auto-complete.
**Compatibility:** The `.gdextension` manifest declares `compatibility_minimum = "4.3"`. The extension is built against `godot-cpp` 4.5 and loads in any Godot 4.3+, including 4.6.
**Linux x86_64 only for now.** Windows / macOS require building additional targets and adding paths to the `[libraries]` section of the `.gdextension` manifest.
---
## Project layout
```
brogue-genesis/
├── src/ pure C99 generator (no Godot, no SDL)
│ └── gen/ room carving, lakes, machines, chokepoints, ...
├── godot/ GDExtension wrapper
│ ├── godot-cpp/ submodule, branch 4.5
│ ├── src/
│ │ ├── register_types.* module init
│ │ ├── brogue_gen.* BrogueGen class
│ │ └── grid_to_dict.* shared grid → Dictionary serializer
│ ├── SConstruct build script
│ └── brogue_gen.gdextension source-of-truth manifest
└── demo/ reference Godot 4.6 project
├── addons/brogue_gen/ ← build output
│ ├── brogue_gen.gdextension
│ └── bin/libbrogue_gen...so
├── project.godot
├── scenes/
└── scripts/
```
Build with `make godot` from the repo root.
---
## Class `BrogueGen`
`extends Node`
One-shot blocking dungeon generator. Instantiate, call `generate()`, free.
Typical generation time on the reference machine: **under 20 ms per level** including machines. Safe to call synchronously in `_ready()`.
### `generate(seed: int, depth: int) -> Dictionary`
Runs the full pipeline (accretion → loops → lakes → wreaths → bridges → walls → stairs → chokepoints → machines) and returns a Dictionary describing the final dungeon.
Parameters:
| name | type | range | notes |
|---|---|---|---|
| `seed` | int | any 64-bit | same seed + depth always yields identical output |
| `depth` | int | 126 | influences liquid type weights |
```gdscript
var gen := BrogueGen.new()
var grid: Dictionary = gen.generate(2026, 1)
gen.free()
```
---
## Returned dictionary schema
| key | type | notes |
|---|---|---|
| `seed` | `int` | echoed back |
| `depth` | `int` | 1..26 |
| `width` | `int` | always `79` |
| `height` | `int` | always `29` |
| `terrain` | `PackedByteArray` | length `width * height`, row-major. Index = `y * width + x`. Values in [terrain enum](#terrain-enum-terrain-byte). |
| `liquid` | `PackedByteArray` | same layout. Values in [liquid enum](#liquid-enum). Meaningful only when `terrain[i] == T_LIQUID`. |
| `surface` | `PackedByteArray` | semantic marker byte. Values in [marker enum](#marker-enum). `MK_NONE` for un-marked cells. |
| `flags` | `PackedInt32Array` | bitmask of [cell flags](#cell-flags). |
| `room_id` | `PackedByteArray` | 1255 inside a room, `0` elsewhere. |
| `machine_id` | `PackedByteArray` | 1255 inside a machine, `0` elsewhere. |
| `stairs_up` | `Vector2i` | always valid grid coords; `(-1, -1)` only on degenerate seeds. |
| `stairs_down` | `Vector2i` | farthest reachable floor from `stairs_up` (BFS). |
| `rooms` | `Array[Dictionary]` | see [room dictionary](#room-dictionary). |
| `machines` | `Array[Dictionary]` | see [machine dictionary](#machine-dictionary). |
| `chokepoints` | `Array[Vector2i]` | every articulation point in the map graph. |
| `gate_sites` | `Array[Vector2i]` | the "best" chokepoint per pocket — ideal vestibule candidates. Subset of `chokepoints`. |
---
## Data structures
### Room dictionary
Each entry in `grid["rooms"]`:
```gdscript
{
"id": int, # matches room_id in the cell arrays
"cells": Array[Vector2i], # every cell that belongs to this room
}
```
### Machine dictionary
Each entry in `grid["machines"]`:
```gdscript
{
"id": int, # matches machine_id in the cell arrays
"interior_cells": Array[Vector2i],
"gate": Vector2i, # the one F_MACHINE_GATE cell; (-1,-1) if unflagged
"markers": Dictionary, # marker_id (int) -> Array[Vector2i]
}
```
Example:
```gdscript
var m: Dictionary = grid["machines"][0]
for marker_id in m["markers"]:
var positions: Array = m["markers"][marker_id]
match marker_id:
4: # MK_PEDESTAL
for p in positions:
spawn_pedestal(p)
```
---
## Enums
### Terrain enum (`terrain` byte)
| id | constant | meaning |
|---|---|---|
| 0 | `T_NOTHING` | outside the dungeon — not walkable |
| 1 | `T_FLOOR` | room floor |
| 2 | `T_WALL` | wall |
| 3 | `T_DOOR` | door in a room wall |
| 4 | `T_CORRIDOR` | corridor between rooms |
| 5 | `T_LIQUID` | water / lava / chasm / brimstone — see `liquid` byte |
| 6 | `T_BRIDGE` | walkable span across water/chasm |
| 7 | `T_STAIRS_UP` | up-stair |
| 8 | `T_STAIRS_DOWN` | down-stair |
`T_FLOOR`, `T_CORRIDOR`, `T_DOOR`, `T_BRIDGE`, `T_STAIRS_UP`, `T_STAIRS_DOWN` are **passable**.
### Liquid enum (`liquid` byte)
| id | constant | bridges allowed | |
|---|---|---|---|
| 0 | `L_NONE` | — | |
| 1 | `L_WATER` | yes | shallow levels |
| 2 | `L_LAVA` | no | damages walker |
| 3 | `L_CHASM` | yes | gameplay-defined fall behavior |
| 4 | `L_BRIMSTONE` | no | lowest depths |
### Marker enum (`surface` byte)
Semantic placement markers. The generator stamps these; gameplay interprets them.
| id | constant | typical use |
|---|---|---|
| 0 | `MK_NONE` | no marker |
| 1 | `MK_CARPET` | decorative floor tint (paint pass) |
| 2 | `MK_ALTAR` | item pedestal on an altar |
| 3 | `MK_CAGE` | item behind a lock |
| 4 | `MK_PEDESTAL` | item on pedestal |
| 5 | `MK_STATUE` | decorative statue |
| 6 | `MK_TORCH` | point light source |
| 7 | `MK_BRAZIER` | point light source (stronger) |
| 8 | `MK_CHEST` | item container |
| 9 | `MK_SPAWN_MONSTER` | spawn an enemy here |
| 10 | `MK_SPAWN_BOSS` | spawn a boss here |
| 11 | `MK_KEY_ITEM` | the key that unlocks this machine's parent |
### Cell flags
Bitmask in `flags[]`. Test with `(flags[idx] & F_X) != 0`.
| bit | value | constant | meaning |
|---|---|---|---|
| 0 | `0x0001` | `F_IN_ROOM` | cell is room floor |
| 1 | `0x0002` | `F_IN_LOOP` | cell was carved by the loop pass |
| 2 | `0x0004` | `F_CHOKEPT` | reserved |
| 3 | `0x0008` | `F_BRIDGE` | cell is a bridge (redundant with `T_BRIDGE`) |
| 4 | `0x0010` | `F_WREATH` | shoreline cell (adjacent to liquid) |
| 6 | `0x0040` | `F_IN_MACHINE` | cell belongs to a machine |
| 7 | `0x0080` | `F_MACHINE_INTERIOR` | interior (not boundary) |
| 8 | `0x0100` | `F_MACHINE_GATE` | the vestibule / entry |
| 9 | `0x0200` | `F_MACHINE_IMPREGNABLE` | cell is protected from later modification (reserved) |
| 10 | `0x0400` | `F_MACHINE_KEY` | key goes here (gameplay hint) |
---
## Usage pattern
Generate a level, instantiate everything up front, hand off to gameplay.
```gdscript
func build_level(seed: int, depth: int) -> void:
var gen := BrogueGen.new()
var grid: Dictionary = gen.generate(seed, depth)
gen.free()
# Player at the up-stair.
$Player.position = _grid_to_world(grid["stairs_up"])
# Goal marker at the down-stair.
$GoalMarker.position = _grid_to_world(grid["stairs_down"])
# One reward per machine.
for m in grid["machines"]:
for marker_id in m["markers"]:
for cell in m["markers"][marker_id]:
spawn_for_marker(marker_id, cell)
# Monsters proportional to room size.
for room in grid["rooms"]:
var size: int = (room["cells"] as Array).size()
if size > 40 and randf() < 0.6:
spawn_monster(_random_cell(room["cells"]))
```
---
## FOV reference
`demo/scripts/fov.gd` ships a reference `BrogueFOV` class. It is **not** part of the extension — it's pure GDScript you can copy into your project and modify.
```gdscript
# demo/scripts/fov.gd
class_name BrogueFOV extends RefCounted
static func compute(grid: Dictionary, origin: Vector2i, radius: int) -> PackedByteArray
```
Symmetric recursive shadowcasting, tile-accurate, O(visible cells). Opaque cells = walls, nothing, and non-water liquids (lava / chasm / brimstone block sight; water does not).
Example:
```gdscript
var vis := BrogueFOV.compute(grid, player_pos, 8)
for y in grid["height"]:
for x in grid["width"]:
if vis[y * grid["width"] + x] == 1:
mark_visible(x, y)
```
---
## Determinism guarantees
- Same `(seed, depth)` input always produces identical output, across runs, platforms, and future minor versions of the plugin.
- The C generator uses a PCG32 RNG seeded from the `seed` argument only. No platform calls (`time()`, `rand()`) are read.
---
## Build and rebuild
From the repo root:
```bash
make godot # builds the extension
make godot-clean # removes build artifacts
```
This runs `scons -j$(nproc) platform=linux target=template_debug` in `godot/`. The resulting `.so` and manifest land together in `demo/addons/brogue_gen/` per Godot's plugin convention.
For release builds:
```bash
cd godot && scons -j$(nproc) platform=linux target=template_release
```
To add a new platform (Windows/macOS), add the SCons target + append a new line to `brogue_gen.gdextension`:
```
windows.debug.x86_64 = "res://bin/libbrogue_gen.windows.template_debug.x86_64.dll"
```
---
## Troubleshooting
**"Could not find type BrogueGen" / "Cannot get class 'BrogueGen'".**
Godot has not registered the extension. Check:
1. `addons/brogue_gen/brogue_gen.gdextension` exists in the project.
2. `addons/brogue_gen/bin/libbrogue_gen...so` exists and matches the path in the manifest.
3. The project has been opened in the editor at least once (this triggers the extension scan).
4. Delete the `.godot/` cache directory and re-open if in doubt.
**Godot 4.6 crashes on startup (SIGABRT in `WaylandThread`).**
This is a Godot 4.6.1 Wayland bug, not the extension. Launch with X11 instead:
```bash
godot --display-driver x11 --path /path/to/your/project
```

66
godot/SConstruct Normal file
View file

@ -0,0 +1,66 @@
#!/usr/bin/env python
"""
SConstruct for the brogue_gen GDExtension.
Build: cd godot && scons target=template_debug
(or target=template_release for optimized)
Output: ../demo/bin/libbrogue_gen.<platform>.<target>.<arch>.so
"""
import os
import sys
env = SConscript("godot-cpp/SConstruct")
# Where to find our C generator library headers.
env.Append(CPPPATH=["src/", "../src/"])
# Compile all of our C generator sources directly into the extension — the
# generator is a pure C99 library with no Godot deps. This avoids needing a
# separate libbroguegen.a.
c_sources = [
"../src/gen/rng.c",
"../src/gen/grid.c",
"../src/gen/events.c",
"../src/gen/room_types.c",
"../src/gen/accretion.c",
"../src/gen/dijkstra.c",
"../src/gen/loops.c",
"../src/gen/ca.c",
"../src/gen/lakes.c",
"../src/gen/walls.c",
"../src/gen/stairs.c",
"../src/gen/chokepoints.c",
"../src/gen/machines.c",
"../src/gen/blueprints_data.c",
]
# Ensure C files compile with C99; don't inherit godot-cpp's C++ flags verbatim.
c_env = env.Clone()
c_env.Replace(CFLAGS=["-std=c99", "-Wall", "-Wpedantic", "-Werror=implicit",
"-O2", "-g", "-fPIC"])
c_objects = [c_env.SharedObject(target="build/" + os.path.basename(s).replace(".c", ""),
source=s) for s in c_sources]
cpp_sources = Glob("src/*.cpp")
library_name = "libbrogue_gen{}{}".format(env["suffix"], env["SHLIBSUFFIX"])
# Godot plugin layout convention: addons/<plugin_name>/
# brogue_gen.gdextension — manifest, references the .so path relative to res://
# bin/<name>.so — binaries per platform/target
import os as _os
import shutil as _shutil
_ADDON_DIR = "../demo/addons/brogue_gen"
_os.makedirs(_ADDON_DIR + "/bin", exist_ok=True)
library = env.SharedLibrary(
_ADDON_DIR + "/bin/" + library_name,
source=cpp_sources + c_objects,
)
_shutil.copyfile("brogue_gen.gdextension",
_ADDON_DIR + "/brogue_gen.gdextension")
Default(library)

View file

@ -0,0 +1,11 @@
[configuration]
entry_symbol = "brogue_gen_library_init"
compatibility_minimum = "4.3"
[libraries]
linux.debug.x86_64 = "res://addons/brogue_gen/bin/libbrogue_gen.linux.template_debug.x86_64.so"
linux.release.x86_64 = "res://addons/brogue_gen/bin/libbrogue_gen.linux.template_release.x86_64.so"
[dependencies]

1
godot/godot-cpp Submodule

@ -0,0 +1 @@
Subproject commit 60b5a4196de8442b43b32ba68ebe1e79cfcb762f

68
godot/src/brogue_gen.cpp Normal file
View file

@ -0,0 +1,68 @@
#include "brogue_gen.h"
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <godot_cpp/variant/vector2i.hpp>
#include <godot_cpp/variant/typed_array.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
extern "C" {
#include "gen/grid.h"
#include "gen/rng.h"
#include "gen/events.h"
#include "gen/accretion.h"
#include "gen/loops.h"
#include "gen/lakes.h"
#include "gen/walls.h"
#include "gen/stairs.h"
#include "gen/chokepoints.h"
#include "gen/machines.h"
}
#include "grid_to_dict.h"
using namespace godot;
BrogueGen::BrogueGen() = default;
BrogueGen::~BrogueGen() = default;
void BrogueGen::_bind_methods() {
ClassDB::bind_method(D_METHOD("generate", "seed", "depth"), &BrogueGen::generate);
}
Dictionary BrogueGen::generate(int seed, int depth) {
grid_t g;
event_sink_t sink = {nullptr, nullptr, 0};
rng_t rng;
rng_seed(&rng, (uint64_t)seed);
grid_fill(g, T_NOTHING);
event_emit(&sink, EV_GEN_BEGIN, NULL, 0);
accrete_rooms(g, &rng, &sink);
add_loops(g, &sink);
place_lakes(g, &rng, &sink, depth);
apply_wreaths(g, &sink);
place_bridges(g, &sink);
finish_walls(g, &sink);
place_stairs(g, &sink);
static choke_map_t choke;
static choke_flag_t is_choke, is_gate;
compute_chokepoints(g, &sink, choke, is_choke, is_gate);
place_machines(g, &rng, depth, &sink, choke, is_gate);
event_emit(&sink, EV_GEN_END, NULL, 0);
Dictionary d = grid_to_dictionary(g, seed, depth, /*add_metadata=*/true);
TypedArray<Vector2i> chokes, gates;
for (int y = 0; y < DROWS; y++) {
for (int x = 0; x < DCOLS; x++) {
if (is_choke[y][x]) chokes.push_back(Vector2i(x, y));
if (is_gate[y][x]) gates.push_back(Vector2i(x, y));
}
}
d["chokepoints"] = chokes;
d["gate_sites"] = gates;
return d;
}

25
godot/src/brogue_gen.h Normal file
View file

@ -0,0 +1,25 @@
#pragma once
#include <godot_cpp/classes/node.hpp>
#include <godot_cpp/variant/dictionary.hpp>
namespace godot {
/* One-shot facade over the pure C generator. Call generate() with a seed and
depth; receive a Dictionary describing the final dungeon. See the plugin
README for the full dict shape (terrain / liquid / surface / flags arrays,
rooms[], machines[], chokepoints[], stairs). */
class BrogueGen : public Node {
GDCLASS(BrogueGen, Node)
protected:
static void _bind_methods();
public:
BrogueGen();
~BrogueGen();
Dictionary generate(int seed, int depth);
};
} // namespace godot

132
godot/src/grid_to_dict.cpp Normal file
View file

@ -0,0 +1,132 @@
#include "grid_to_dict.h"
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/typed_array.hpp>
using namespace godot;
static void build_rooms_and_machines(grid_t g, Array &rooms, Array &machines) {
/* Bucket cells by room_id and machine_id. room_id and machine_id are 1..255. */
struct Bucket { TypedArray<Vector2i> cells; Vector2i gate = Vector2i(-1, -1); };
Bucket rbuckets[256];
Bucket mbuckets[256];
/* Also collect marker-per-cell for each machine. */
Dictionary mmarkers[256];
for (int y = 0; y < DROWS; y++) {
for (int x = 0; x < DCOLS; x++) {
const cell_t *c = &g[y][x];
Vector2i p(x, y);
if (c->room_id > 0) {
rbuckets[c->room_id].cells.push_back(p);
}
if (c->machine_id > 0) {
mbuckets[c->machine_id].cells.push_back(p);
if (c->flags & F_MACHINE_GATE) {
mbuckets[c->machine_id].gate = p;
}
if (c->surface != MK_NONE) {
Dictionary &mk = mmarkers[c->machine_id];
int key = (int)c->surface;
TypedArray<Vector2i> list;
if (mk.has(key)) {
list = (TypedArray<Vector2i>)(Variant)mk[key];
}
list.push_back(p);
mk[key] = list;
}
}
}
}
for (int i = 1; i < 256; i++) {
if (rbuckets[i].cells.size() == 0) continue;
Dictionary d;
d["id"] = i;
d["cells"] = rbuckets[i].cells;
rooms.push_back(d);
}
for (int i = 1; i < 256; i++) {
if (mbuckets[i].cells.size() == 0) continue;
Dictionary d;
d["id"] = i;
d["interior_cells"] = mbuckets[i].cells;
d["gate"] = mbuckets[i].gate;
d["markers"] = mmarkers[i];
machines.push_back(d);
}
}
Dictionary godot::grid_to_dictionary(grid_t g, int seed, int depth, bool add_metadata) {
const int N = DCOLS * DROWS;
PackedByteArray terrain; terrain.resize(N);
PackedByteArray liquid; liquid.resize(N);
PackedByteArray room_id; room_id.resize(N);
PackedByteArray machine_id; machine_id.resize(N);
PackedByteArray surface; surface.resize(N);
PackedInt32Array flags; flags.resize(N);
uint8_t *t = terrain.ptrw();
uint8_t *lq = liquid.ptrw();
uint8_t *ri = room_id.ptrw();
uint8_t *mi = machine_id.ptrw();
uint8_t *sf = surface.ptrw();
int32_t *fl = flags.ptrw();
Vector2i stairs_up(-1, -1), stairs_down(-1, -1);
int i = 0;
for (int y = 0; y < DROWS; y++) {
for (int x = 0; x < DCOLS; x++, i++) {
const cell_t *c = &g[y][x];
t[i] = c->terrain;
lq[i] = c->liquid;
ri[i] = c->room_id;
mi[i] = c->machine_id;
sf[i] = c->surface;
fl[i] = (int32_t)c->flags;
if (c->terrain == T_STAIRS_UP) stairs_up = Vector2i(x, y);
if (c->terrain == T_STAIRS_DOWN) stairs_down = Vector2i(x, y);
}
}
Dictionary d;
d["seed"] = seed;
d["depth"] = depth;
d["width"] = DCOLS;
d["height"] = DROWS;
d["terrain"] = terrain;
d["liquid"] = liquid;
d["flags"] = flags;
d["room_id"] = room_id;
d["machine_id"] = machine_id;
d["surface"] = surface;
d["stairs_up"] = stairs_up;
d["stairs_down"] = stairs_down;
if (add_metadata) {
Array rooms;
Array machines;
build_rooms_and_machines(g, rooms, machines);
d["rooms"] = rooms;
d["machines"] = machines;
}
return d;
}
Dictionary godot::cell_to_dictionary(grid_t g, int x, int y) {
Dictionary d;
if (!grid_in_bounds(x, y)) return d;
const cell_t *c = &g[y][x];
d["x"] = x;
d["y"] = y;
d["terrain"] = (int)c->terrain;
d["liquid"] = (int)c->liquid;
d["surface"] = (int)c->surface;
d["flags"] = (int)c->flags;
d["room_id"] = (int)c->room_id;
d["machine_id"] = (int)c->machine_id;
return d;
}

22
godot/src/grid_to_dict.h Normal file
View file

@ -0,0 +1,22 @@
#pragma once
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/vector2i.hpp>
extern "C" {
#include "gen/grid.h"
}
namespace godot {
/* Pack a grid_t into a Dictionary with the shape documented in the plugin
README (terrain/liquid/flags/room_id/machine_id/surface arrays). Used by
both BrogueGen.generate() and BrogueReplay.get_grid(). Additional
metadata (rooms, machines, chokepoints, stairs) are computed and added
when add_metadata is true. */
Dictionary grid_to_dictionary(grid_t g, int seed, int depth, bool add_metadata);
/* Pack a single cell as a Dictionary. */
Dictionary cell_to_dictionary(grid_t g, int x, int y);
} // namespace godot

View file

@ -0,0 +1,33 @@
#include "register_types.h"
#include <gdextension_interface.h>
#include <godot_cpp/core/defs.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/godot.hpp>
#include "brogue_gen.h"
using namespace godot;
void initialize_brogue_gen_module(ModuleInitializationLevel p_level) {
if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) return;
ClassDB::register_class<BrogueGen>();
}
void uninitialize_brogue_gen_module(ModuleInitializationLevel p_level) {
if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) return;
}
extern "C" {
GDExtensionBool GDE_EXPORT brogue_gen_library_init(
GDExtensionInterfaceGetProcAddress p_get_proc_address,
const GDExtensionClassLibraryPtr p_library,
GDExtensionInitialization *r_initialization) {
GDExtensionBinding::InitObject init_obj(p_get_proc_address, p_library, r_initialization);
init_obj.register_initializer(initialize_brogue_gen_module);
init_obj.register_terminator(uninitialize_brogue_gen_module);
init_obj.set_minimum_library_initialization_level(MODULE_INITIALIZATION_LEVEL_SCENE);
return init_obj.init();
}
}

View file

@ -0,0 +1,8 @@
#pragma once
#include <godot_cpp/core/class_db.hpp>
using namespace godot;
void initialize_brogue_gen_module(ModuleInitializationLevel p_level);
void uninitialize_brogue_gen_module(ModuleInitializationLevel p_level);

304
src/gen/accretion.c Normal file
View file

@ -0,0 +1,304 @@
#include "accretion.h"
#include "room_types.h"
#include <string.h>
typedef struct {
int x, y;
int dx, dy;
} door_site_t;
#define MAX_SITES 512
#define MAX_ATTEMPTS 30
static int is_open(uint8_t terrain) {
return terrain == T_FLOOR || terrain == T_CORRIDOR || terrain == T_DOOR;
}
/* Find door sites: FLOOR cells with a NOTHING neighbor in one of 4 cardinal
directions. dx,dy is the outward direction (floor -> nothing). */
static int find_main_sites(grid_t g, door_site_t *out, int cap) {
static const int dirs[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};
int n = 0;
for (int y = 1; y < DROWS - 1 && n < cap; y++) {
for (int x = 1; x < DCOLS - 1 && n < cap; x++) {
if (g[y][x].terrain != T_FLOOR) continue;
for (int d = 0; d < 4; d++) {
int nx = x + dirs[d][0];
int ny = y + dirs[d][1];
if (!grid_in_bounds(nx, ny)) continue;
if (g[ny][nx].terrain == T_NOTHING) {
out[n].x = x; out[n].y = y;
out[n].dx = dirs[d][0]; out[n].dy = dirs[d][1];
if (++n >= cap) return n;
}
}
}
}
return n;
}
/* Find scratch sites: FLOOR cells with a NOTHING neighbor outward. */
static int find_scratch_sites(grid_t g, door_site_t *out, int cap) {
return find_main_sites(g, out, cap);
}
/* Corridor path: door is path[0]; subsequent cells are corridor tiles; after
the last path cell comes the entry floor where the scratch room blits. A
straight corridor has a monotonic path; an L-bent one has one inflection. */
#define MAX_PATH 24
typedef struct {
int x[MAX_PATH];
int y[MAX_PATH];
int len;
int entry_x, entry_y;
} corridor_path_t;
/* Check whether placement is valid: all corridor path cells are NOTHING on
main, entry is NOTHING, scratch floor lands on NOTHING with 1-cell buffer
against existing rooms. */
static int fits_placement(grid_t scratch, grid_t main_g,
int tx, int ty, const corridor_path_t *p) {
for (int i = 0; i < p->len; i++) {
if (!grid_in_bounds(p->x[i], p->y[i])) return 0;
if (main_g[p->y[i]][p->x[i]].terrain != T_NOTHING) return 0;
}
if (!grid_in_bounds(p->entry_x, p->entry_y)) return 0;
if (main_g[p->entry_y][p->entry_x].terrain != T_NOTHING) return 0;
for (int sy = 0; sy < DROWS; sy++) {
for (int sx = 0; sx < DCOLS; sx++) {
if (scratch[sy][sx].terrain != T_FLOOR) continue;
int mx = sx + tx;
int my = sy + ty;
if (mx < 1 || my < 1 || mx >= DCOLS - 1 || my >= DROWS - 1) return 0;
if (main_g[my][mx].terrain != T_NOTHING) return 0;
for (int ny = -1; ny <= 1; ny++) {
for (int nx = -1; nx <= 1; nx++) {
if (nx == 0 && ny == 0) continue;
int qx = mx + nx;
int qy = my + ny;
if (!grid_in_bounds(qx, qy)) continue;
/* neighbor is another translated scratch floor? ok */
int ssx = qx - tx, ssy = qy - ty;
if (ssx >= 0 && ssx < DCOLS && ssy >= 0 && ssy < DROWS
&& scratch[ssy][ssx].terrain == T_FLOOR) continue;
/* neighbor is on the corridor path? ok (we're about to
carve it; NOTHING at check time). */
if (is_open(main_g[qy][qx].terrain)) return 0;
}
}
}
}
return 1;
}
/* Keep only the largest 4-connected FLOOR region in scratch; zero others to
NOTHING. Prevents disconnected multi-bump rooms (e.g., chunky with
non-overlapping circles) from creating orphan rooms on main. */
static void scratch_keep_largest(grid_t scratch) {
static int label[DROWS][DCOLS];
memset(label, 0, sizeof(label));
static int sx_stack[DCOLS * DROWS];
static int sy_stack[DCOLS * DROWS];
int best = 0, best_size = 0, next = 0;
static const int dx4[4] = {1, -1, 0, 0};
static const int dy4[4] = {0, 0, 1, -1};
for (int y = 0; y < DROWS; y++) {
for (int x = 0; x < DCOLS; x++) {
if (scratch[y][x].terrain != T_FLOOR || label[y][x]) continue;
next++;
int sp = 0;
sx_stack[sp] = x; sy_stack[sp] = y; sp++;
int size = 0;
while (sp > 0) {
sp--;
int cx = sx_stack[sp], cy = sy_stack[sp];
if (!grid_in_bounds(cx, cy)) continue;
if (label[cy][cx] || scratch[cy][cx].terrain != T_FLOOR) continue;
label[cy][cx] = next;
size++;
for (int i = 0; i < 4; i++) {
sx_stack[sp] = cx + dx4[i];
sy_stack[sp] = cy + dy4[i];
sp++;
}
}
if (size > best_size) { best_size = size; best = next; }
}
}
if (next <= 1) return;
for (int y = 0; y < DROWS; y++)
for (int x = 0; x < DCOLS; x++)
if (scratch[y][x].terrain == T_FLOOR && label[y][x] != best) {
scratch[y][x].terrain = T_NOTHING;
scratch[y][x].flags = 0;
scratch[y][x].room_id = 0;
}
}
static void blit_room(grid_t scratch, grid_t main_g, int tx, int ty, uint8_t room_id) {
for (int sy = 0; sy < DROWS; sy++) {
for (int sx = 0; sx < DCOLS; sx++) {
if (scratch[sy][sx].terrain != T_FLOOR) continue;
int mx = sx + tx, my = sy + ty;
if (!grid_in_bounds(mx, my)) continue;
main_g[my][mx].terrain = T_FLOOR;
main_g[my][mx].room_id = room_id;
main_g[my][mx].flags |= F_IN_ROOM;
}
}
}
static void carve_path(grid_t g, const corridor_path_t *p, uint8_t room_id) {
if (p->len == 0) return;
g[p->y[0]][p->x[0]].terrain = T_DOOR;
g[p->y[0]][p->x[0]].room_id = room_id;
for (int i = 1; i < p->len; i++) {
g[p->y[i]][p->x[i]].terrain = T_CORRIDOR;
g[p->y[i]][p->x[i]].room_id = room_id;
}
}
static void build_straight(corridor_path_t *p,
int door_x, int door_y, int len, int dx, int dy) {
if (len > MAX_PATH) len = MAX_PATH;
p->len = len;
for (int i = 0; i < len; i++) {
p->x[i] = door_x + i * dx;
p->y[i] = door_y + i * dy;
}
p->entry_x = door_x + len * dx;
p->entry_y = door_y + len * dy;
}
/* L-bent corridor: L1 cells in (dx1,dy1), then L2 cells in (dx2,dy2). dx2/dy2
must be perpendicular to dx1/dy1. Total path cells = L1 + L2 ( MAX_PATH).
Entry is one step past the last leg2 cell. */
static void build_lbent(corridor_path_t *p,
int door_x, int door_y,
int L1, int dx1, int dy1,
int L2, int dx2, int dy2) {
int total = L1 + L2;
if (total > MAX_PATH) total = MAX_PATH;
p->len = total;
int i = 0;
for (int k = 0; k < L1 && i < total; k++, i++) {
p->x[i] = door_x + k * dx1;
p->y[i] = door_y + k * dy1;
}
int ex = door_x + (L1 - 1) * dx1;
int ey = door_y + (L1 - 1) * dy1;
for (int k = 1; k <= L2 && i < total; k++, i++) {
p->x[i] = ex + k * dx2;
p->y[i] = ey + k * dy2;
}
p->entry_x = ex + (L2 + 1) * dx2;
p->entry_y = ey + (L2 + 1) * dy2;
}
int accrete_rooms(grid_t g, rng_t *rng, event_sink_t *sink) {
/* First room at center. Cavern is only attempted here (too big for the
accretion buffer check to fit against existing rooms). */
grid_fill(g, T_NOTHING);
uint8_t room_id = 1;
room_type_t t0;
if (rng_percent(rng, 25)) {
t0 = ROOM_CAVERN;
} else {
/* pick any non-cavern type */
t0 = (room_type_t)rng_range(rng, 0, ROOM_COUNT - 2);
if (t0 == ROOM_CAVERN) t0 = ROOM_SMALL_RECT;
}
int tries = 0;
while (!room_carve(g, t0, rng, DCOLS/2, DROWS/2, room_id) && tries++ < 8) {
t0 = (room_type_t)rng_range(rng, 0, ROOM_COUNT - 2);
if (t0 == ROOM_CAVERN) t0 = ROOM_SMALL_RECT;
}
scratch_keep_largest(g);
event_emit(sink, EV_ROOM_PLACED, NULL, 0);
grid_t scratch;
door_site_t main_sites[MAX_SITES];
door_site_t scratch_sites[MAX_SITES];
for (room_id = 2; room_id <= MAX_ROOMS; room_id++) {
int placed = 0;
for (int attempt = 0; attempt < MAX_ATTEMPTS && !placed; attempt++) {
/* Exclude cavern (too big to attach reliably). */
room_type_t rt = (room_type_t)rng_range(rng, 0, ROOM_COUNT - 2);
if (rt == ROOM_CAVERN) rt = ROOM_SMALL_RECT;
event_emit(sink, EV_ROOM_PROPOSED, NULL, 0);
grid_fill(scratch, T_NOTHING);
if (!room_carve(scratch, rt, rng, DCOLS/2, DROWS/2, room_id)) continue;
scratch_keep_largest(scratch);
int n_scratch = find_scratch_sites(scratch, scratch_sites, MAX_SITES);
int n_main = find_main_sites(g, main_sites, MAX_SITES);
if (n_scratch == 0 || n_main == 0) continue;
/* Pick main site, then find scratch site facing opposite. */
door_site_t ms = main_sites[rng_range(rng, 0, n_main - 1)];
int opp_idx[MAX_SITES];
int n_opp = 0;
for (int i = 0; i < n_scratch; i++) {
if (scratch_sites[i].dx == -ms.dx && scratch_sites[i].dy == -ms.dy)
opp_idx[n_opp++] = i;
}
if (n_opp == 0) continue;
door_site_t ss = scratch_sites[opp_idx[rng_range(rng, 0, n_opp - 1)]];
int horiz = (ms.dy == 0);
int L = horiz ? rng_range(rng, 2, 10) : rng_range(rng, 2, 6);
int door_x = ms.x + ms.dx;
int door_y = ms.y + ms.dy;
corridor_path_t path;
int want_bend = L >= 4 && rng_percent(rng, 30);
if (want_bend) {
int L1 = rng_range(rng, 2, L - 2);
int L2 = L - L1;
int sign = rng_percent(rng, 50) ? 1 : -1;
int dx2 = -ms.dy * sign, dy2 = ms.dx * sign;
build_lbent(&path, door_x, door_y,
L1, ms.dx, ms.dy, L2, dx2, dy2);
} else {
build_straight(&path, door_x, door_y, L, ms.dx, ms.dy);
}
/* Scratch room's entry floor must land at path.entry. Its door-
facing scratch cell ss points OUT of the scratch room in its
outward dir; we want scratch to be placed so ss lands at entry,
and the scratch room extends AWAY from the corridor's final
direction. For straight corridors that direction is ms.dir; for
bent it's the leg2 direction. We recover it from path endpoints. */
int final_dx = ms.dx, final_dy = ms.dy;
if (want_bend) {
int last = path.len - 1;
final_dx = path.entry_x - path.x[last];
final_dy = path.entry_y - path.y[last];
/* Scratch site must face opposite to this final direction. */
if (ss.dx != -final_dx || ss.dy != -final_dy) continue;
}
(void)final_dx; (void)final_dy;
int tx = path.entry_x - ss.x;
int ty = path.entry_y - ss.y;
if (!fits_placement(scratch, g, tx, ty, &path)) continue;
event_emit(sink, EV_DOOR_SITE_CHOSEN, NULL, 0);
carve_path(g, &path, room_id);
event_emit(sink, EV_CORRIDOR_CARVED, NULL, 0);
blit_room(scratch, g, tx, ty, room_id);
event_emit(sink, EV_ROOM_PLACED, NULL, 0);
placed = 1;
}
if (!placed) event_emit(sink, EV_ROOM_REJECTED, NULL, 0);
}
return (int)(room_id - 1);
}

13
src/gen/accretion.h Normal file
View file

@ -0,0 +1,13 @@
#ifndef GENESIS_GEN_ACCRETION_H
#define GENESIS_GEN_ACCRETION_H
#include "grid.h"
#include "rng.h"
#include "events.h"
#define MAX_ROOMS 35
/* Place rooms by accretion into g. Returns the number of rooms placed. */
int accrete_rooms(grid_t g, rng_t *rng, event_sink_t *sink);
#endif

66
src/gen/blueprints.h Normal file
View file

@ -0,0 +1,66 @@
#ifndef GENESIS_GEN_BLUEPRINTS_H
#define GENESIS_GEN_BLUEPRINTS_H
#include <stdint.h>
#include "grid.h"
/* Blueprint = a template for a machine (locked vault, reward room, monster
den, etc.). Each blueprint has metadata (depth range, pocket size range,
selection frequency) and a small list of features that describe what to
place inside. Terrain and markers are intentionally generic so the Godot
side can interpret them into game-specific content. */
typedef enum {
BP_ROOM = 1u << 0, /* must occupy a room-sized region */
BP_VESTIBULE = 1u << 1, /* attaches to a chokepoint pocket */
BP_REWARD = 1u << 2, /* counts as a "reward" for pacing */
BP_PURGE_INTERIOR = 1u << 3, /* clear liquid etc before building */
BP_SURROUND_WITH_WALLS = 1u << 4,
BP_OPEN_INTERIOR = 1u << 5, /* re-pave interior to floor */
BP_IMPREGNABLE = 1u << 6, /* walls cannot be later modified */
BP_ADOPT_KEY = 1u << 7, /* receives key from parent machine */
} blueprint_flags_t;
typedef enum {
MF_EVERYWHERE = 1u << 0, /* feature occupies every interior cell */
MF_BUILD_AT_ORIGIN = 1u << 1, /* placed at the blueprint's origin */
MF_BUILD_VESTIBULE = 1u << 2, /* spawns a child vestibule (cascade) */
MF_BUILD_IN_WALLS = 1u << 3,
MF_NEAR_ORIGIN = 1u << 4,
MF_FAR_FROM_ORIGIN = 1u << 5,
MF_PERMIT_BLOCKING = 1u << 6, /* may block a walking path */
MF_TREAT_AS_BLOCKING = 1u << 7, /* others must route around */
MF_IMPREGNABLE = 1u << 8,
MF_ALTERNATIVE = 1u << 9, /* pick one of a group */
MF_GENERATE_KEY = 1u << 10, /* this instance IS the key */
MF_REQUIRE_ADOPTED_KEY = 1u << 11,
} feature_flags_t;
typedef struct {
uint16_t terrain; /* terrain_t to paint, 0 = unchanged */
uint16_t marker; /* marker_t to stamp into cell.surface, MK_NONE = none */
uint8_t min_count; /* minimum instances required for acceptance */
uint8_t max_count; /* upper bound on instances attempted */
uint32_t flags; /* feature_flags_t */
} feature_t;
#define MAX_FEATURES 8
typedef struct {
const char *name;
int16_t depth_min;
int16_t depth_max;
int16_t room_size_min; /* pocket size in cells */
int16_t room_size_max;
uint16_t frequency; /* relative selection weight */
uint32_t flags; /* blueprint_flags_t */
uint8_t feature_count;
feature_t features[MAX_FEATURES];
} blueprint_t;
/* The catalog. Defined in blueprints_data.c. NUM_BLUEPRINTS is the live
length; index 0 is a no-op sentinel ("nothing") to match common usage. */
extern const blueprint_t blueprint_catalog[];
extern const int NUM_BLUEPRINTS;
#endif

36
src/gen/blueprints_data.c Normal file
View file

@ -0,0 +1,36 @@
#include "blueprints.h"
/* One entry for A.6c. More coming in A.6f. The sentinel at index 0 lets
callers loop from i=1 and skip the no-op, matching Brogue's convention. */
const blueprint_t blueprint_catalog[] = {
/* 0: sentinel */
{ .name = "(none)", .feature_count = 0 },
/* 1: Reward Vault
A small pocket behind a chokepoint, carpeted, containing 3-5 pedestals
each bearing an item. The gate cell is marked as a vestibule candidate
(used by the cascade in A.6d; for A.6c it's just an entry marker). */
{
.name = "Reward Vault",
.depth_min = 1,
.depth_max = 26,
.room_size_min = 6,
.room_size_max = 40,
.frequency = 100,
.flags = BP_VESTIBULE | BP_REWARD,
.feature_count = 3,
.features = {
/* Carpet paints every interior floor; purely visual. */
{ .terrain = 0, .marker = MK_CARPET, .min_count = 0, .max_count = 0,
.flags = MF_EVERYWHERE },
/* Pedestals with items — blocking, since you walk around them. */
{ .terrain = 0, .marker = MK_PEDESTAL, .min_count = 3, .max_count = 5,
.flags = MF_TREAT_AS_BLOCKING },
/* Gate cell — will spawn a vestibule child once A.6d lands. */
{ .terrain = 0, .marker = MK_NONE, .min_count = 1, .max_count = 1,
.flags = MF_BUILD_AT_ORIGIN | MF_PERMIT_BLOCKING | MF_BUILD_VESTIBULE },
},
},
};
const int NUM_BLUEPRINTS = (int)(sizeof(blueprint_catalog) / sizeof(blueprint_catalog[0]));

117
src/gen/ca.c Normal file
View file

@ -0,0 +1,117 @@
#include "ca.h"
#include <string.h>
#include <stddef.h>
#define SEED_PERCENT 55
#define ITERATIONS 5
static int count_n8(bitgrid_t g, int cx, int cy, int bx0, int by0, int bx1, int by1) {
int n = 0;
for (int dy = -1; dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++) {
if (dx == 0 && dy == 0) continue;
int x = cx + dx, y = cy + dy;
if (x < bx0 || x >= bx1 || y < by0 || y >= by1) continue;
if (g[y][x]) n++;
}
}
return n;
}
static void step(bitgrid_t in, bitgrid_t out,
int bx0, int by0, int bx1, int by1) {
memset(out, 0, sizeof(bitgrid_t));
for (int y = by0; y < by1; y++) {
for (int x = bx0; x < bx1; x++) {
int n = count_n8(in, x, y, bx0, by0, bx1, by1);
if (in[y][x]) {
/* S45678 */
out[y][x] = (n >= 4) ? 1 : 0;
} else {
/* B678 */
out[y][x] = (n >= 6) ? 1 : 0;
}
}
}
}
static int extract_largest_region(bitgrid_t g, int bx0, int by0, int bx1, int by1) {
static int visited[DROWS][DCOLS];
memset(visited, 0, sizeof(visited));
int best_size = 0;
int best_id = 0;
int label = 0;
static int stack_x[DCOLS * DROWS];
static int stack_y[DCOLS * DROWS];
static const int dx4[4] = {1, -1, 0, 0};
static const int dy4[4] = {0, 0, 1, -1};
for (int y = by0; y < by1; y++) {
for (int x = bx0; x < bx1; x++) {
if (!g[y][x] || visited[y][x]) continue;
label++;
int sp = 0;
stack_x[sp] = x; stack_y[sp] = y; sp++;
int size = 0;
while (sp > 0) {
sp--;
int cx = stack_x[sp], cy = stack_y[sp];
if (cx < bx0 || cx >= bx1 || cy < by0 || cy >= by1) continue;
if (visited[cy][cx] || !g[cy][cx]) continue;
visited[cy][cx] = label;
size++;
for (int i = 0; i < 4; i++) {
stack_x[sp] = cx + dx4[i];
stack_y[sp] = cy + dy4[i];
sp++;
}
}
if (size > best_size) { best_size = size; best_id = label; }
}
}
/* Zero out everything that isn't in best_id. */
for (int y = by0; y < by1; y++) {
for (int x = bx0; x < bx1; x++) {
if (visited[y][x] != best_id) g[y][x] = 0;
}
}
return best_size;
}
void ca_blob(bitgrid_t out, rng_t *rng, event_sink_t *sink,
int x, int y, int w, int h) {
int bx0 = x, by0 = y;
int bx1 = x + w, by1 = y + h;
if (bx0 < 0) bx0 = 0;
if (by0 < 0) by0 = 0;
if (bx1 > DCOLS) bx1 = DCOLS;
if (by1 > DROWS) by1 = DROWS;
memset(out, 0, sizeof(bitgrid_t));
for (int j = by0; j < by1; j++) {
for (int i = bx0; i < bx1; i++) {
out[j][i] = rng_percent(rng, SEED_PERCENT) ? 1 : 0;
}
}
bitgrid_t buf;
for (int k = 0; k < ITERATIONS; k++) {
if (k % 2 == 0) step(out, buf, bx0, by0, bx1, by1);
else step(buf, out, bx0, by0, bx1, by1);
event_emit(sink, EV_CA_ITERATION, NULL, 0);
}
if (ITERATIONS % 2 == 1) memcpy(out, buf, sizeof(bitgrid_t));
extract_largest_region(out, bx0, by0, bx1, by1);
}
int bitgrid_count(bitgrid_t g) {
int n = 0;
for (int y = 0; y < DROWS; y++)
for (int x = 0; x < DCOLS; x++)
if (g[y][x]) n++;
return n;
}

21
src/gen/ca.h Normal file
View file

@ -0,0 +1,21 @@
#ifndef GENESIS_GEN_CA_H
#define GENESIS_GEN_CA_H
#include <stdint.h>
#include "grid.h"
#include "rng.h"
#include "events.h"
typedef uint8_t bitgrid_t[DROWS][DCOLS];
/* Generate a CA blob in a bounding box (x,y,w,h) within bitgrid.
Rule: B678/S45678, 55% seed probability, 5 iterations, then retain only the
largest 4-connected region within the box. Cells outside the box are 0.
Emits EV_CA_ITERATION for each of the 5 steps. */
void ca_blob(bitgrid_t out, rng_t *rng, event_sink_t *sink,
int x, int y, int w, int h);
/* Count filled cells in bitgrid. */
int bitgrid_count(bitgrid_t g);
#endif

134
src/gen/chokepoints.c Normal file
View file

@ -0,0 +1,134 @@
#include "chokepoints.h"
#include <stddef.h>
#include <string.h>
/* Port of Brogue's chokepoint analysis (Architect.c:analyzeMap), simplified:
- Identify chokepoint cells narrow passages where all 4 cardinal arcs
change state and one axis is fully blocked.
- For each chokepoint, flood-fill from its adjacent open neighbors while
pretending the chokepoint is blocked. The flood count is the "pocket
size" that depends on this chokepoint. Every cell in that pocket gets
min(current value, flood count) in the choke map.
- The chokepoint next to the smallest pocket it guards is flagged as the
pocket's gate site. */
static const int CDIRS[8][2] = {
{ 1, 0}, { 1, -1}, { 0, -1}, {-1, -1},
{-1, 0}, {-1, 1}, { 0, 1}, { 1, 1},
};
static int pass(grid_t g, int x, int y) {
if (!grid_in_bounds(x, y)) return 0;
return terrain_is_passable(g[y][x].terrain);
}
/* Rotating around 8 neighbors, count transitions between pass/not-pass. */
static int narrow_passage(grid_t g, int x, int y) {
int arcs = 0;
for (int d = 0; d < 8; d++) {
int old_x = x + CDIRS[(d + 7) % 8][0];
int old_y = y + CDIRS[(d + 7) % 8][1];
int new_x = x + CDIRS[d][0];
int new_y = y + CDIRS[d][1];
int a = pass(g, new_x, new_y);
int b = pass(g, old_x, old_y);
if (a != b) {
if (++arcs > 2) {
int horiz_blocked = !pass(g, x - 1, y) && !pass(g, x + 1, y);
int vert_blocked = !pass(g, x, y - 1) && !pass(g, x, y + 1);
return horiz_blocked || vert_blocked;
}
}
}
return 0;
}
/* 4-connected flood from (sx,sy) through passable cells, but treat
(block_x, block_y) as impassable. Marks visited cells in out[y][x] and
returns the count. */
static int flood_blocked(grid_t g, int sx, int sy,
int block_x, int block_y,
uint8_t out[DROWS][DCOLS]) {
memset(out, 0, (size_t)DROWS * DCOLS);
if (!pass(g, sx, sy)) return 0;
if (sx == block_x && sy == block_y) return 0;
static int qx[DCOLS * DROWS];
static int qy[DCOLS * DROWS];
int head = 0, tail = 0;
out[sy][sx] = 1;
qx[tail] = sx; qy[tail] = sy; tail++;
int count = 0;
static const int d4[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};
while (head < tail) {
int cx = qx[head], cy = qy[head]; head++;
count++;
for (int i = 0; i < 4; i++) {
int nx = cx + d4[i][0];
int ny = cy + d4[i][1];
if (!grid_in_bounds(nx, ny)) continue;
if (out[ny][nx]) continue;
if (nx == block_x && ny == block_y) continue;
if (!pass(g, nx, ny)) continue;
out[ny][nx] = 1;
qx[tail] = nx; qy[tail] = ny; tail++;
}
}
return count;
}
void compute_chokepoints(grid_t g, event_sink_t *sink,
choke_map_t choke_out,
choke_flag_t is_chokepoint,
choke_flag_t is_gate_site) {
/* Init */
for (int y = 0; y < DROWS; y++)
for (int x = 0; x < DCOLS; x++) {
choke_out[y][x] = CHOKE_UNREACHABLE;
is_chokepoint[y][x] = 0;
is_gate_site[y][x] = 0;
}
/* Pass 1: flag chokepoints. */
for (int y = 1; y < DROWS - 1; y++) {
for (int x = 1; x < DCOLS - 1; x++) {
if (!pass(g, x, y)) continue;
if (narrow_passage(g, x, y)) is_chokepoint[y][x] = 1;
}
}
/* Pass 2: for each chokepoint, flood through each open cardinal neighbor
with the chokepoint treated as blocked. Update choke_out for the
flooded pocket. Track gate sites. */
static uint8_t pocket[DROWS][DCOLS];
static const int d4[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};
for (int cy = 0; cy < DROWS; cy++) {
for (int cx = 0; cx < DCOLS; cx++) {
if (!is_chokepoint[cy][cx]) continue;
int best_flood = CHOKE_UNREACHABLE;
for (int i = 0; i < 4; i++) {
int nx = cx + d4[i][0];
int ny = cy + d4[i][1];
if (!pass(g, nx, ny)) continue;
if (is_chokepoint[ny][nx]) continue;
int n = flood_blocked(g, nx, ny, cx, cy, pocket);
if (n < 4) continue; /* too tiny to matter */
for (int py = 0; py < DROWS; py++) {
for (int px = 0; px < DCOLS; px++) {
if (!pocket[py][px]) continue;
if (n < choke_out[py][px]) {
choke_out[py][px] = (int16_t)n;
is_gate_site[py][px] = 0;
}
}
}
if (n < best_flood) best_flood = n;
if (n < choke_out[cy][cx]) {
choke_out[cy][cx] = (int16_t)n;
is_gate_site[cy][cx] = 1;
}
}
if (best_flood < CHOKE_UNREACHABLE)
event_emit(sink, EV_CHOKEPOINT_FOUND, NULL, 0);
}
}
}

29
src/gen/chokepoints.h Normal file
View file

@ -0,0 +1,29 @@
#ifndef GENESIS_GEN_CHOKEPOINTS_H
#define GENESIS_GEN_CHOKEPOINTS_H
#include <stdint.h>
#include "grid.h"
#include "events.h"
/* A cell's choke value is the number of passable cells that would become
unreachable if the nearest chokepoint between that cell and the rest of
the map were blocked. CHOKE_UNREACHABLE is the "untouched" sentinel. */
#define CHOKE_UNREACHABLE 30000
typedef int16_t choke_map_t[DROWS][DCOLS];
typedef uint8_t choke_flag_t[DROWS][DCOLS];
/* Compute:
- choke_out[y][x]: how many cells would be cut off if the relevant nearby
chokepoint were blocked. Lower = more isolated pocket.
- is_chokepoint[y][x]: 1 if cell is a chokepoint (articulation point).
- is_gate_site[y][x]: 1 if cell is the smallest chokepoint seen for its
pocket (ideal vestibule site).
Each emits at most one CHOKEPOINT_FOUND event per gate site. */
void compute_chokepoints(grid_t g, event_sink_t *sink,
choke_map_t choke_out,
choke_flag_t is_chokepoint,
choke_flag_t is_gate_site);
#endif

63
src/gen/dijkstra.c Normal file
View file

@ -0,0 +1,63 @@
#include "dijkstra.h"
#include <stddef.h>
static int passable(uint8_t t) {
return t == T_FLOOR || t == T_CORRIDOR || t == T_DOOR || t == T_BRIDGE
|| t == T_STAIRS_UP || t == T_STAIRS_DOWN;
}
void bfs_from(grid_t g, int sx, int sy, dist_map_t dist,
int extra_passable_x, int extra_passable_y) {
for (int y = 0; y < DROWS; y++)
for (int x = 0; x < DCOLS; x++)
dist[y][x] = DIST_UNREACHABLE;
if (!grid_in_bounds(sx, sy)) return;
static const int dx4[4] = {1, -1, 0, 0};
static const int dy4[4] = {0, 0, 1, -1};
int qx[DCOLS * DROWS];
int qy[DCOLS * DROWS];
int head = 0, tail = 0;
dist[sy][sx] = 0;
qx[tail] = sx; qy[tail] = sy; tail++;
while (head < tail) {
int cx = qx[head], cy = qy[head]; head++;
int16_t d = dist[cy][cx];
for (int i = 0; i < 4; i++) {
int nx = cx + dx4[i], ny = cy + dy4[i];
if (!grid_in_bounds(nx, ny)) continue;
int is_extra = (nx == extra_passable_x && ny == extra_passable_y);
if (!is_extra && !passable(g[ny][nx].terrain)) continue;
if (dist[ny][nx] <= d + 1) continue;
dist[ny][nx] = d + 1;
qx[tail] = nx; qy[tail] = ny; tail++;
}
}
}
int is_connected(grid_t g, int *out_unreached) {
int sx = -1, sy = -1, total = 0;
for (int y = 0; y < DROWS; y++) {
for (int x = 0; x < DCOLS; x++) {
if (passable(g[y][x].terrain)) {
if (sx < 0) { sx = x; sy = y; }
total++;
}
}
}
if (total == 0) { if (out_unreached) *out_unreached = 0; return 1; }
static dist_map_t dist;
bfs_from(g, sx, sy, dist, -1, -1);
int reached = 0;
for (int y = 0; y < DROWS; y++)
for (int x = 0; x < DCOLS; x++)
if (passable(g[y][x].terrain) && dist[y][x] != DIST_UNREACHABLE)
reached++;
if (out_unreached) *out_unreached = total - reached;
return reached == total;
}

22
src/gen/dijkstra.h Normal file
View file

@ -0,0 +1,22 @@
#ifndef GENESIS_GEN_DIJKSTRA_H
#define GENESIS_GEN_DIJKSTRA_H
#include "grid.h"
#define DIST_UNREACHABLE 30000
typedef int16_t dist_map_t[DROWS][DCOLS];
/* 4-connected BFS from (sx,sy) through passable terrain (floor/corridor/door).
dist[][] filled with shortest path length in cells; DIST_UNREACHABLE if no path.
If extra_passable_x/y >= 0, that wall cell is treated as passable for the search. */
void bfs_from(grid_t g, int sx, int sy, dist_map_t dist,
int extra_passable_x, int extra_passable_y);
/* Returns 1 if every passable cell (floor/corridor/door/bridge) in g is
4-connected to every other, 0 otherwise. Trivially true if zero passable
cells. If out_unreached is non-NULL, writes the count of passable cells that
were NOT reached from the first seed. */
int is_connected(grid_t g, int *out_unreached);
#endif

127
src/gen/events.c Normal file
View file

@ -0,0 +1,127 @@
#include "events.h"
#include <stddef.h>
void event_emit(event_sink_t *sink, event_kind_t kind,
const cell_diff_t *diffs, uint32_t diff_count) {
if (!sink || !sink->fn) return;
gen_event_t ev = {0};
ev.step = sink->step++;
ev.kind = (uint16_t)kind;
ev.diffs = (cell_diff_t *)diffs;
ev.diff_count = diff_count;
sink->fn(sink->ctx, &ev);
}
const char *event_kind_name(event_kind_t k) {
switch (k) {
case EV_GEN_BEGIN: return "GEN_BEGIN";
case EV_ROOM_PROPOSED: return "ROOM_PROPOSED";
case EV_ROOM_PLACED: return "ROOM_PLACED";
case EV_ROOM_REJECTED: return "ROOM_REJECTED";
case EV_DOOR_SITE_CHOSEN: return "DOOR_SITE_CHOSEN";
case EV_CORRIDOR_CARVED: return "CORRIDOR_CARVED";
case EV_LOOP_TESTED: return "LOOP_TESTED";
case EV_LOOP_ACCEPTED: return "LOOP_ACCEPTED";
case EV_CA_ITERATION: return "CA_ITERATION";
case EV_LAKE_PROPOSED: return "LAKE_PROPOSED";
case EV_LAKE_REJECTED_IMPASSABLE: return "LAKE_REJECTED_IMPASSABLE";
case EV_LAKE_PLACED: return "LAKE_PLACED";
case EV_WREATH_APPLIED: return "WREATH_APPLIED";
case EV_BRIDGE_PLACED: return "BRIDGE_PLACED";
case EV_WALLS_FINISHED: return "WALLS_FINISHED";
case EV_STAIRS_PLACED_UP: return "STAIRS_PLACED_UP";
case EV_STAIRS_PLACED_DOWN: return "STAIRS_PLACED_DOWN";
case EV_CHOKEPOINT_FOUND: return "CHOKEPOINT_FOUND";
case EV_MACHINE_PROPOSED: return "MACHINE_PROPOSED";
case EV_BLUEPRINT_CASCADE_BEGIN: return "BLUEPRINT_CASCADE_BEGIN";
case EV_FEATURE_BUILT: return "FEATURE_BUILT";
case EV_MACHINE_ACCEPTED: return "MACHINE_ACCEPTED";
case EV_MACHINE_RESTORED_ON_FAIL: return "MACHINE_RESTORED_ON_FAIL";
case EV_GEN_END: return "GEN_END";
default: return "?";
}
}
const char *event_kind_what(event_kind_t k) {
switch (k) {
case EV_GEN_BEGIN: return "Phase 0: empty grid";
case EV_ROOM_PROPOSED: return "Phase 1: trying a room";
case EV_DOOR_SITE_CHOSEN: return "Phase 1: door site picked";
case EV_CORRIDOR_CARVED: return "Phase 1: corridor carved";
case EV_ROOM_PLACED: return "Phase 1: room placed";
case EV_ROOM_REJECTED: return "Phase 1: room gave up";
case EV_LOOP_TESTED: return "Phase 2: testing a loop";
case EV_LOOP_ACCEPTED: return "Phase 2: loop punched";
case EV_CA_ITERATION: return "Phase 3: CA smoothing step";
case EV_LAKE_PROPOSED: return "Phase 3: lake proposed";
case EV_LAKE_REJECTED_IMPASSABLE: return "Phase 3: lake rejected";
case EV_LAKE_PLACED: return "Phase 3: lake placed";
case EV_WREATH_APPLIED: return "Phase 3: shorelines marked";
case EV_BRIDGE_PLACED: return "Phase 3: bridge laid";
case EV_WALLS_FINISHED: return "Phase 4: walls finished";
case EV_STAIRS_PLACED_UP: return "Phase 5: up-stair placed";
case EV_STAIRS_PLACED_DOWN: return "Phase 5: down-stair placed";
case EV_CHOKEPOINT_FOUND: return "Phase 6: chokepoint found";
case EV_MACHINE_PROPOSED: return "Phase 6: machine proposed";
case EV_BLUEPRINT_CASCADE_BEGIN: return "Phase 6: cascade begins";
case EV_FEATURE_BUILT: return "Phase 6: feature built";
case EV_MACHINE_ACCEPTED: return "Phase 6: machine accepted";
case EV_MACHINE_RESTORED_ON_FAIL: return "Phase 6: machine rolled back";
case EV_GEN_END: return "Done";
default: return "";
}
}
const char *event_kind_why(event_kind_t k) {
switch (k) {
case EV_GEN_BEGIN:
return "Bare 79x29 grid. Phases run in order: rooms -> loops -> lakes -> walls.";
case EV_ROOM_PROPOSED:
return "Carved a candidate room in scratch space. Searching for a place to attach it.";
case EV_DOOR_SITE_CHOSEN:
return "An existing room has a perimeter cell facing empty space - that's the doorway.";
case EV_CORRIDOR_CARVED:
return "Vertical corridors are 2-9 cells, horizontal 5-15. Connects the candidate to the existing dungeon.";
case EV_ROOM_PLACED:
return "Buffer + corridor checks passed. Map remains fully connected by construction.";
case EV_ROOM_REJECTED:
return "After 30 attempts, no valid attachment was found. Move on.";
case EV_LOOP_TESTED:
return "Wall with passable cells on opposite sides. If their shortest path is far apart, a shortcut helps.";
case EV_LOOP_ACCEPTED:
return "Graph distance >= 20. Punching the wall creates a useful loop in the dungeon.";
case EV_CA_ITERATION:
return "Cellular automata smoothing the lake blob: B678/S45678 rule, 5 iterations total.";
case EV_LAKE_PROPOSED:
return "Blue overlay = where the lake would go. Red overlay = cells that would become unreachable.";
case EV_LAKE_REJECTED_IMPASSABLE:
return "Lake would orphan part of the map. Reject and try a smaller bounding box.";
case EV_LAKE_PLACED:
return "Passability test passed. Lake painted onto the map; connectivity preserved.";
case EV_WREATH_APPLIED:
return "Cells adjacent (8-conn) to liquid are tagged shoreline. Used later for visual variety.";
case EV_BRIDGE_PLACED:
return "Found a floor-liquid-floor span <= 5 cells. Liquid replaced with a bridge.";
case EV_WALLS_FINISHED:
return "Polish pass: every empty cell next to any open terrain becomes a wall.";
case EV_STAIRS_PLACED_UP:
return "Up-stair placed at the first room's center - the level's entry point.";
case EV_STAIRS_PLACED_DOWN:
return "Down-stair placed at the floor cell with the longest graph-distance from the up-stair.";
case EV_CHOKEPOINT_FOUND:
return "Articulation point: if blocked, part of the map becomes unreachable. Ideal vestibule candidate.";
case EV_MACHINE_PROPOSED:
return "A blueprint is being tried. Looking for a chokepoint or room that matches the constraints.";
case EV_BLUEPRINT_CASCADE_BEGIN:
return "Reward rooms may spawn vestibules, which may spawn key-guard machines - a cascade of nested blueprints.";
case EV_FEATURE_BUILT:
return "A single feature within the current blueprint was placed (terrain / item marker / monster marker).";
case EV_MACHINE_ACCEPTED:
return "All required features fit. Machine committed; any nested cascades ran successfully.";
case EV_MACHINE_RESTORED_ON_FAIL:
return "Placement couldn't satisfy all constraints. Level state is rolled back to before the attempt.";
case EV_GEN_END:
return "Final dungeon. Every passable cell is 4-connected to every other - Brogue's invariant.";
default: return "";
}
}

68
src/gen/events.h Normal file
View file

@ -0,0 +1,68 @@
#ifndef GENESIS_GEN_EVENTS_H
#define GENESIS_GEN_EVENTS_H
#include <stdint.h>
#include "grid.h"
typedef enum {
EV_GEN_BEGIN = 0,
EV_ROOM_PROPOSED,
EV_ROOM_PLACED,
EV_ROOM_REJECTED,
EV_DOOR_SITE_CHOSEN,
EV_CORRIDOR_CARVED,
EV_LOOP_TESTED,
EV_LOOP_ACCEPTED,
EV_CA_ITERATION,
EV_LAKE_PROPOSED,
EV_LAKE_REJECTED_IMPASSABLE,
EV_LAKE_PLACED,
EV_WREATH_APPLIED,
EV_BRIDGE_PLACED,
EV_WALLS_FINISHED,
EV_STAIRS_PLACED_UP,
EV_STAIRS_PLACED_DOWN,
EV_CHOKEPOINT_FOUND,
EV_MACHINE_PROPOSED,
EV_BLUEPRINT_CASCADE_BEGIN,
EV_FEATURE_BUILT,
EV_MACHINE_ACCEPTED,
EV_MACHINE_RESTORED_ON_FAIL,
EV_GEN_END,
EV_KIND_COUNT
} event_kind_t;
typedef struct {
uint16_t x;
uint16_t y;
cell_t before;
cell_t after;
} cell_diff_t;
typedef struct {
uint32_t step;
uint16_t kind;
uint16_t payload_u16[4];
int32_t payload_i32[2];
uint32_t diff_count;
cell_diff_t *diffs;
} gen_event_t;
typedef void (*event_sink_fn)(void *ctx, const gen_event_t *ev);
typedef struct {
event_sink_fn fn;
void *ctx;
uint32_t step;
} event_sink_t;
void event_emit(event_sink_t *sink, event_kind_t kind,
const cell_diff_t *diffs, uint32_t diff_count);
const char *event_kind_name(event_kind_t k);
/* Short human title (~20 chars). */
const char *event_kind_what(event_kind_t k);
/* One-line explanation of why this step matters. May be ~120 chars. */
const char *event_kind_why(event_kind_t k);
#endif

83
src/gen/grid.c Normal file
View file

@ -0,0 +1,83 @@
#include "grid.h"
#include <string.h>
void grid_fill(grid_t g, terrain_t t) {
for (int y = 0; y < DROWS; y++) {
for (int x = 0; x < DCOLS; x++) {
memset(&g[y][x], 0, sizeof(cell_t));
g[y][x].terrain = (uint8_t)t;
}
}
}
int grid_in_bounds(int x, int y) {
return x >= 0 && x < DCOLS && y >= 0 && y < DROWS;
}
void grid_draw_rect(grid_t g, int x, int y, int w, int h, terrain_t t, uint8_t room_id) {
for (int j = y; j < y + h; j++) {
for (int i = x; i < x + w; i++) {
if (!grid_in_bounds(i, j)) continue;
g[j][i].terrain = (uint8_t)t;
if (room_id) {
g[j][i].room_id = room_id;
g[j][i].flags |= F_IN_ROOM;
}
}
}
}
void grid_draw_circle(grid_t g, int cx, int cy, int r, terrain_t t, uint8_t room_id) {
int r2 = r * r;
for (int j = cy - r; j <= cy + r; j++) {
for (int i = cx - r; i <= cx + r; i++) {
if (!grid_in_bounds(i, j)) continue;
int dx = i - cx, dy = j - cy;
if (dx * dx + dy * dy <= r2) {
g[j][i].terrain = (uint8_t)t;
if (room_id) {
g[j][i].room_id = room_id;
g[j][i].flags |= F_IN_ROOM;
}
}
}
}
}
/* Flood fill 4-connected floor cells; writes marker into room_id for visited floor.
Returns count of cells filled. Non-destructive to terrain. */
int grid_flood_fill4(grid_t g, int x, int y, uint8_t marker) {
if (!grid_in_bounds(x, y)) return 0;
if (g[y][x].terrain != T_FLOOR && g[y][x].terrain != T_CORRIDOR && g[y][x].terrain != T_DOOR) return 0;
if (g[y][x].room_id == marker) return 0;
int stack_x[DCOLS * DROWS];
int stack_y[DCOLS * DROWS];
int sp = 0;
stack_x[sp] = x; stack_y[sp] = y; sp++;
int count = 0;
while (sp > 0) {
sp--;
int cx = stack_x[sp], cy = stack_y[sp];
if (!grid_in_bounds(cx, cy)) continue;
cell_t *c = &g[cy][cx];
if (c->terrain != T_FLOOR && c->terrain != T_CORRIDOR && c->terrain != T_DOOR) continue;
if (c->room_id == marker) continue;
c->room_id = marker;
count++;
stack_x[sp] = cx + 1; stack_y[sp] = cy; sp++;
stack_x[sp] = cx - 1; stack_y[sp] = cy; sp++;
stack_x[sp] = cx; stack_y[sp] = cy + 1; sp++;
stack_x[sp] = cx; stack_y[sp] = cy - 1; sp++;
}
return count;
}
int grid_count_floor(grid_t g) {
int n = 0;
for (int y = 0; y < DROWS; y++)
for (int x = 0; x < DCOLS; x++)
if (g[y][x].terrain == T_FLOOR || g[y][x].terrain == T_CORRIDOR || g[y][x].terrain == T_DOOR) n++;
return n;
}

90
src/gen/grid.h Normal file
View file

@ -0,0 +1,90 @@
#ifndef GENESIS_GEN_GRID_H
#define GENESIS_GEN_GRID_H
#include <stdint.h>
#define DCOLS 79
#define DROWS 29
typedef enum {
T_NOTHING = 0,
T_FLOOR = 1,
T_WALL = 2,
T_DOOR = 3,
T_CORRIDOR = 4,
T_LIQUID = 5,
T_BRIDGE = 6,
T_STAIRS_UP = 7,
T_STAIRS_DOWN = 8,
} terrain_t;
typedef enum {
L_NONE = 0,
L_WATER = 1,
L_LAVA = 2,
L_CHASM = 3,
L_BRIMSTONE = 4,
} liquid_t;
typedef enum {
F_IN_ROOM = 1u << 0,
F_IN_LOOP = 1u << 1,
F_CHOKEPT = 1u << 2,
F_BRIDGE = 1u << 3,
F_WREATH = 1u << 4,
/* Set on cells of a lake currently being tested for passability. Cleared
on the next event (rejection or placement). Lets the viewer render the
proposal overlay without any out-of-band metadata. */
F_PROPOSED_LAKE = 1u << 5,
F_IN_MACHINE = 1u << 6, /* cell belongs to a machine instance */
F_MACHINE_INTERIOR = 1u << 7, /* interior cell (not boundary) */
F_MACHINE_GATE = 1u << 8, /* the vestibule / entry point */
F_MACHINE_IMPREGNABLE = 1u << 9, /* later phases must not alter */
F_MACHINE_KEY = 1u << 10, /* key goes here (gameplay hint) */
} cell_flag_t;
/* Semantic placement markers, stored in cell.surface. Brogue uses the surface
byte for atmospherics (gas/fluid splash). We repurpose it for machine
markers, which the Godot RL interprets to spawn concrete items / decor /
monsters. If we later want atmospherics back, cell_t can grow to 12 bytes
and the .gen format bumps version; for MVP 8 bytes is plenty. */
typedef enum {
MK_NONE = 0,
MK_CARPET, /* decorative floor tint */
MK_ALTAR, /* item pedestal on an altar */
MK_CAGE, /* item behind a cage (key-guarded) */
MK_PEDESTAL, /* item on pedestal */
MK_STATUE, /* decorative statue */
MK_TORCH, /* light source */
MK_BRAZIER, /* light source */
MK_CHEST, /* item container */
MK_SPAWN_MONSTER, /* Godot spawns an enemy here */
MK_SPAWN_BOSS,
MK_KEY_ITEM, /* the key that unlocks this machine's parent */
} marker_t;
typedef struct {
uint8_t terrain;
uint8_t liquid;
uint8_t surface; /* marker_t — repurposed; see above */
uint8_t gas;
uint16_t flags;
uint8_t room_id;
uint8_t machine_id; /* 0 = not in any machine, 1..N = machine instance */
} cell_t;
typedef cell_t grid_t[DROWS][DCOLS];
void grid_fill(grid_t g, terrain_t t);
int grid_in_bounds(int x, int y);
/* True if the terrain is walkable (floor, corridor, door, bridge, stairs). */
static inline int terrain_is_passable(uint8_t t) {
return t == T_FLOOR || t == T_CORRIDOR || t == T_DOOR || t == T_BRIDGE
|| t == T_STAIRS_UP || t == T_STAIRS_DOWN;
}
void grid_draw_rect(grid_t g, int x, int y, int w, int h, terrain_t t, uint8_t room_id);
void grid_draw_circle(grid_t g, int cx, int cy, int r, terrain_t t, uint8_t room_id);
int grid_flood_fill4(grid_t g, int x, int y, uint8_t marker);
int grid_count_floor(grid_t g);
#endif

191
src/gen/lakes.c Normal file
View file

@ -0,0 +1,191 @@
#include "lakes.h"
#include "ca.h"
#include "dijkstra.h"
#include <string.h>
#include <stddef.h>
static const int LAKE_BOXES[][2] = {
{30, 15}, {30, 15}, {28, 14}, {24, 12}, {22, 11}, {20, 10},
};
#define LAKE_COUNT (int)(sizeof(LAKE_BOXES) / sizeof(LAKE_BOXES[0]))
static int is_passable(uint8_t t) {
return t == T_FLOOR || t == T_CORRIDOR || t == T_DOOR || t == T_BRIDGE;
}
/* Returns 1 if the proposed lake preserves connectivity of all remaining
passable cells on the map. */
static int passability_test(grid_t g, bitgrid_t lake) {
/* Find first passable cell not in lake. */
int sx = -1, sy = -1;
int total = 0;
for (int y = 0; y < DROWS; y++) {
for (int x = 0; x < DCOLS; x++) {
if (lake[y][x]) continue;
if (is_passable(g[y][x].terrain)) {
if (sx < 0) { sx = x; sy = y; }
total++;
}
}
}
if (sx < 0) return 1; /* no passable cells — trivially fine */
/* BFS, treating lake as impassable. */
static int visited[DROWS][DCOLS];
memset(visited, 0, sizeof(visited));
int qx[DCOLS * DROWS], qy[DCOLS * DROWS];
int head = 0, tail = 0;
qx[tail] = sx; qy[tail] = sy; tail++;
visited[sy][sx] = 1;
int reached = 0;
static const int dx4[4] = {1, -1, 0, 0};
static const int dy4[4] = {0, 0, 1, -1};
while (head < tail) {
int cx = qx[head], cy = qy[head]; head++;
reached++;
for (int i = 0; i < 4; i++) {
int nx = cx + dx4[i], ny = cy + dy4[i];
if (!grid_in_bounds(nx, ny)) continue;
if (visited[ny][nx]) continue;
if (lake[ny][nx]) continue;
if (!is_passable(g[ny][nx].terrain)) continue;
visited[ny][nx] = 1;
qx[tail] = nx; qy[tail] = ny; tail++;
}
}
return reached == total;
}
static void paint_lake(grid_t g, bitgrid_t lake, liquid_t kind) {
for (int y = 0; y < DROWS; y++) {
for (int x = 0; x < DCOLS; x++) {
if (!lake[y][x]) continue;
g[y][x].terrain = T_LIQUID;
g[y][x].liquid = (uint8_t)kind;
g[y][x].flags &= (uint16_t)~F_IN_ROOM;
g[y][x].room_id = 0;
}
}
}
/* Brogue-ish weighted selection by depth. Water dominates shallow; lava,
chasm, brimstone phase in as depth grows. */
static liquid_t pick_liquid(rng_t *rng, int depth) {
int w_water = (depth >= 1 && depth <= 14) ? 20 : 0;
int w_lava = (depth >= 3 && depth <= 17) ? (depth - 2) : 0;
int w_chasm = (depth >= 7 && depth <= 24) ? (depth - 6) : 0;
int w_brimstone = (depth >= 14 && depth <= 26) ? (depth - 13) : 0;
int total = w_water + w_lava + w_chasm + w_brimstone;
if (total <= 0) return L_WATER;
int roll = rng_range(rng, 0, total - 1);
if ((roll -= w_water) < 0) return L_WATER;
if ((roll -= w_lava) < 0) return L_LAVA;
if ((roll -= w_chasm) < 0) return L_CHASM;
return L_BRIMSTONE;
}
static void mark_proposed(grid_t g, bitgrid_t lake, int set) {
for (int y = 0; y < DROWS; y++) {
for (int x = 0; x < DCOLS; x++) {
if (!lake[y][x]) continue;
if (set) g[y][x].flags |= F_PROPOSED_LAKE;
else g[y][x].flags &= (uint16_t)~F_PROPOSED_LAKE;
}
}
}
int place_lakes(grid_t g, rng_t *rng, event_sink_t *sink, int depth) {
(void)depth; /* used in Phase A.2 for liquid type selection */
int placed = 0;
static bitgrid_t lake;
for (int i = 0; i < LAKE_COUNT; i++) {
int bw = LAKE_BOXES[i][0];
int bh = LAKE_BOXES[i][1];
if (bw >= DCOLS - 2) bw = DCOLS - 3;
if (bh >= DROWS - 2) bh = DROWS - 3;
int max_bx = DCOLS - bw - 1;
int max_by = DROWS - bh - 1;
if (max_bx < 1) max_bx = 1;
if (max_by < 1) max_by = 1;
int bx = rng_range(rng, 1, max_bx);
int by = rng_range(rng, 1, max_by);
ca_blob(lake, rng, sink, bx, by, bw, bh);
if (bitgrid_count(lake) < 5) continue;
/* Mark proposal so the recorder + viewer can show what's being
tested. The flag is cleared before the outcome event. */
mark_proposed(g, lake, 1);
event_emit(sink, EV_LAKE_PROPOSED, NULL, 0);
if (!passability_test(g, lake)) {
mark_proposed(g, lake, 0);
event_emit(sink, EV_LAKE_REJECTED_IMPASSABLE, NULL, 0);
continue;
}
mark_proposed(g, lake, 0);
paint_lake(g, lake, pick_liquid(rng, depth));
event_emit(sink, EV_LAKE_PLACED, NULL, 0);
placed++;
}
return placed;
}
void apply_wreaths(grid_t g, event_sink_t *sink) {
for (int y = 0; y < DROWS; y++) {
for (int x = 0; x < DCOLS; x++) {
if (g[y][x].terrain != T_LIQUID) continue;
for (int dy = -1; dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++) {
if (dx == 0 && dy == 0) continue;
int qx = x + dx, qy = y + dy;
if (!grid_in_bounds(qx, qy)) continue;
if (g[qy][qx].terrain == T_LIQUID) continue;
g[qy][qx].flags |= F_WREATH;
}
}
}
}
event_emit(sink, EV_WREATH_APPLIED, NULL, 0);
}
static int try_bridge_run(grid_t g, int x, int y, int dx, int dy, event_sink_t *sink) {
/* (x,y) must be passable floor; the next cells must be liquid for 1..5
steps, terminating at another passable cell. Water and chasms support
bridges; lava and brimstone don't. */
if (!is_passable(g[y][x].terrain)) return 0;
int k = 0;
int nx = x + dx, ny = y + dy;
while (grid_in_bounds(nx, ny) && g[ny][nx].terrain == T_LIQUID && k < 5) {
uint8_t l = g[ny][nx].liquid;
if (l != L_WATER && l != L_CHASM) return 0;
k++;
nx += dx; ny += dy;
}
if (k == 0) return 0;
if (!grid_in_bounds(nx, ny)) return 0;
if (!is_passable(g[ny][nx].terrain)) return 0;
for (int i = 1; i <= k; i++) {
int bx = x + i * dx, by = y + i * dy;
g[by][bx].terrain = T_BRIDGE;
g[by][bx].liquid = L_NONE;
g[by][bx].flags |= F_BRIDGE;
}
event_emit(sink, EV_BRIDGE_PLACED, NULL, 0);
return 1;
}
int place_bridges(grid_t g, event_sink_t *sink) {
int n = 0;
for (int y = 1; y < DROWS - 1; y++) {
for (int x = 1; x < DCOLS - 1; x++) {
if (!is_passable(g[y][x].terrain)) continue;
n += try_bridge_run(g, x, y, 1, 0, sink);
n += try_bridge_run(g, x, y, 0, 1, sink);
}
}
return n;
}

23
src/gen/lakes.h Normal file
View file

@ -0,0 +1,23 @@
#ifndef GENESIS_GEN_LAKES_H
#define GENESIS_GEN_LAKES_H
#include "grid.h"
#include "rng.h"
#include "events.h"
/* Attempt up to 6 lakes at decreasing bounding-box sizes, with passability
flood-fill rejection. Emits LAKE_PROPOSED, LAKE_REJECTED_IMPASSABLE,
LAKE_PLACED. Depth influences liquid type selection (added in Phase A.2).
Returns number of lakes placed. */
int place_lakes(grid_t g, rng_t *rng, event_sink_t *sink, int depth);
/* Mark F_WREATH on every non-liquid cell adjacent (8-neighbor) to a liquid
cell. Emits WREATH_APPLIED once. */
void apply_wreaths(grid_t g, event_sink_t *sink);
/* Bridge scan: for each cardinal axis, find floor…liquid×k…floor spans with
1 k 5 and convert those liquid cells to T_BRIDGE. Emits BRIDGE_PLACED per
bridge. */
int place_bridges(grid_t g, event_sink_t *sink);
#endif

44
src/gen/loops.c Normal file
View file

@ -0,0 +1,44 @@
#include "loops.h"
#include "dijkstra.h"
#include <stddef.h>
static int is_passable(uint8_t t) {
return t == T_FLOOR || t == T_CORRIDOR || t == T_DOOR;
}
int add_loops(grid_t g, event_sink_t *sink) {
dist_map_t dist;
int carved = 0;
/* Iterate in a scan order so output is deterministic. */
for (int y = 1; y < DROWS - 1; y++) {
for (int x = 1; x < DCOLS - 1; x++) {
if (g[y][x].terrain != T_NOTHING && g[y][x].terrain != T_WALL) continue;
/* Two pairings: horizontal (E-W neighbors both passable) and
vertical (N-S both passable). Require the OTHER axis to be
impassable so we don't carve through corners. */
int horiz = is_passable(g[y][x-1].terrain) && is_passable(g[y][x+1].terrain)
&& !is_passable(g[y-1][x].terrain) && !is_passable(g[y+1][x].terrain);
int vert = is_passable(g[y-1][x].terrain) && is_passable(g[y+1][x].terrain)
&& !is_passable(g[y][x-1].terrain) && !is_passable(g[y][x+1].terrain);
if (!horiz && !vert) continue;
int ax = horiz ? x - 1 : x;
int ay = horiz ? y : y - 1;
int bx = horiz ? x + 1 : x;
int by = horiz ? y : y + 1;
bfs_from(g, ax, ay, dist, -1, -1);
int d = dist[by][bx];
event_emit(sink, EV_LOOP_TESTED, NULL, 0);
if (d >= LOOP_THRESHOLD && d < DIST_UNREACHABLE) {
g[y][x].terrain = T_DOOR;
g[y][x].flags |= F_IN_LOOP;
carved++;
event_emit(sink, EV_LOOP_ACCEPTED, NULL, 0);
}
}
}
return carved;
}

13
src/gen/loops.h Normal file
View file

@ -0,0 +1,13 @@
#ifndef GENESIS_GEN_LOOPS_H
#define GENESIS_GEN_LOOPS_H
#include "grid.h"
#include "events.h"
#define LOOP_THRESHOLD 20
/* Punch extra doors where two passable regions are far apart (Dijkstra dist ≥
LOOP_THRESHOLD). Returns number of loops carved. */
int add_loops(grid_t g, event_sink_t *sink);
#endif

206
src/gen/machines.c Normal file
View file

@ -0,0 +1,206 @@
#include "machines.h"
#include "blueprints.h"
#include <stddef.h>
#include <string.h>
static int is_pass(uint8_t t) {
return terrain_is_passable(t);
}
/* Flood 4-connected from (sx,sy) through passable cells, but pretend
(block_x,block_y) is blocked. Fills pocket[][] with 1 for reached cells,
zero otherwise. Returns count. */
static int flood_pocket(grid_t g, int sx, int sy, int block_x, int block_y,
uint8_t pocket[DROWS][DCOLS]) {
memset(pocket, 0, (size_t)DROWS * DCOLS);
if (!grid_in_bounds(sx, sy)) return 0;
if (!is_pass(g[sy][sx].terrain)) return 0;
static int qx[DCOLS * DROWS];
static int qy[DCOLS * DROWS];
int head = 0, tail = 0;
pocket[sy][sx] = 1;
qx[tail] = sx; qy[tail] = sy; tail++;
int count = 1;
static const int d4[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};
while (head < tail) {
int cx = qx[head], cy = qy[head]; head++;
for (int i = 0; i < 4; i++) {
int nx = cx + d4[i][0];
int ny = cy + d4[i][1];
if (!grid_in_bounds(nx, ny)) continue;
if (pocket[ny][nx]) continue;
if (nx == block_x && ny == block_y) continue;
if (!is_pass(g[ny][nx].terrain)) continue;
pocket[ny][nx] = 1;
qx[tail] = nx; qy[tail] = ny; tail++;
count++;
}
}
return count;
}
/* Pick a random blueprint that fits the given depth and pocket size,
weighted by frequency. Returns index into blueprint_catalog, or 0 if no
blueprint matches. */
static int pick_blueprint(rng_t *rng, int depth, int pocket_size) {
int total_weight = 0;
for (int i = 1; i < NUM_BLUEPRINTS; i++) {
const blueprint_t *bp = &blueprint_catalog[i];
if (depth < bp->depth_min || depth > bp->depth_max) continue;
if (pocket_size < bp->room_size_min || pocket_size > bp->room_size_max) continue;
total_weight += bp->frequency;
}
if (total_weight <= 0) return 0;
int roll = rng_range(rng, 0, total_weight - 1);
for (int i = 1; i < NUM_BLUEPRINTS; i++) {
const blueprint_t *bp = &blueprint_catalog[i];
if (depth < bp->depth_min || depth > bp->depth_max) continue;
if (pocket_size < bp->room_size_min || pocket_size > bp->room_size_max) continue;
roll -= bp->frequency;
if (roll < 0) return i;
}
return 0;
}
/* Collect interior cells into a flat list. Returns count. */
static int list_interior(uint8_t pocket[DROWS][DCOLS], int cells_x[], int cells_y[]) {
int n = 0;
for (int y = 0; y < DROWS; y++) {
for (int x = 0; x < DCOLS; x++) {
if (pocket[y][x]) {
cells_x[n] = x;
cells_y[n] = y;
n++;
}
}
}
return n;
}
/* Apply one feature. Returns the number of cells placed (counted against
min_count for acceptance). */
static int apply_feature(grid_t g, rng_t *rng, const feature_t *f,
int origin_x, int origin_y,
int cells_x[], int cells_y[], int cell_count,
uint8_t blocked[DROWS][DCOLS]) {
int placed = 0;
if (f->flags & MF_EVERYWHERE) {
for (int i = 0; i < cell_count; i++) {
int x = cells_x[i], y = cells_y[i];
if (f->terrain) g[y][x].terrain = (uint8_t)f->terrain;
if (f->marker) g[y][x].surface = (uint8_t)f->marker;
placed++;
}
return placed;
}
if (f->flags & MF_BUILD_AT_ORIGIN) {
if (grid_in_bounds(origin_x, origin_y)) {
if (f->terrain) g[origin_y][origin_x].terrain = (uint8_t)f->terrain;
if (f->marker) g[origin_y][origin_x].surface = (uint8_t)f->marker;
g[origin_y][origin_x].flags |= F_MACHINE_GATE;
placed++;
}
return placed;
}
/* Random interior placements, avoiding cells already marked blocking. */
int want = rng_range(rng, f->min_count, f->max_count);
int attempts = 0;
while (placed < want && attempts < cell_count * 4) {
attempts++;
int idx = rng_range(rng, 0, cell_count - 1);
int x = cells_x[idx], y = cells_y[idx];
if (blocked[y][x]) continue;
if (f->terrain) g[y][x].terrain = (uint8_t)f->terrain;
if (f->marker) g[y][x].surface = (uint8_t)f->marker;
if (f->flags & MF_TREAT_AS_BLOCKING) blocked[y][x] = 1;
if (f->flags & MF_GENERATE_KEY) g[y][x].flags |= F_MACHINE_KEY;
placed++;
}
return placed;
}
int place_machines(grid_t g, rng_t *rng, int depth, event_sink_t *sink,
choke_map_t choke, choke_flag_t is_gate) {
static uint8_t pocket[DROWS][DCOLS];
static uint8_t blocked[DROWS][DCOLS];
static int cells_x[DCOLS * DROWS];
static int cells_y[DCOLS * DROWS];
uint8_t next_machine_id = 1;
/* Scan gate sites in ascending pocket size. */
for (int target_size = 4; target_size <= DCOLS * DROWS; target_size++) {
for (int gy = 0; gy < DROWS; gy++) {
for (int gx = 0; gx < DCOLS; gx++) {
if (!is_gate[gy][gx]) continue;
if (choke[gy][gx] != target_size) continue;
/* Find the passable neighbor inside the pocket. */
static const int d4[4][2] = {{1,0},{-1,0},{0,1},{0,-1}};
int px = -1, py = -1;
for (int d = 0; d < 4; d++) {
int nx = gx + d4[d][0];
int ny = gy + d4[d][1];
if (!grid_in_bounds(nx, ny)) continue;
if (!is_pass(g[ny][nx].terrain)) continue;
/* The pocket side is the one where blocking the gate
would strand N cells. Try each neighbor and flood; the
smaller flood is the pocket. */
int n = flood_pocket(g, nx, ny, gx, gy, pocket);
if (n > 0 && n <= target_size + 2) {
px = nx; py = ny;
break;
}
}
if (px < 0) continue;
int pocket_count = flood_pocket(g, px, py, gx, gy, pocket);
if (pocket_count < 4) continue;
int bp_idx = pick_blueprint(rng, depth, pocket_count);
if (bp_idx == 0) continue;
const blueprint_t *bp = &blueprint_catalog[bp_idx];
event_emit(sink, EV_MACHINE_PROPOSED, NULL, 0);
int cell_count = list_interior(pocket, cells_x, cells_y);
memset(blocked, 0, sizeof(blocked));
/* Apply features; track acceptance. */
int all_required_met = 1;
for (int i = 0; i < bp->feature_count; i++) {
const feature_t *f = &bp->features[i];
int placed = apply_feature(g, rng, f, gx, gy,
cells_x, cells_y, cell_count,
blocked);
if (placed < f->min_count) {
all_required_met = 0;
break;
}
event_emit(sink, EV_FEATURE_BUILT, NULL, 0);
}
if (!all_required_met) continue;
/* Commit: tag every interior cell with F_IN_MACHINE + id. */
for (int i = 0; i < cell_count; i++) {
int x = cells_x[i], y = cells_y[i];
g[y][x].flags |= F_IN_MACHINE | F_MACHINE_INTERIOR;
g[y][x].machine_id = next_machine_id;
}
/* The gate itself is part of the machine boundary. */
g[gy][gx].flags |= F_IN_MACHINE | F_MACHINE_GATE;
g[gy][gx].machine_id = next_machine_id;
event_emit(sink, EV_MACHINE_ACCEPTED, NULL, 0);
next_machine_id++;
return 1; /* A.6c: one machine per seed */
}
}
}
return 0;
}

16
src/gen/machines.h Normal file
View file

@ -0,0 +1,16 @@
#ifndef GENESIS_GEN_MACHINES_H
#define GENESIS_GEN_MACHINES_H
#include "grid.h"
#include "rng.h"
#include "events.h"
#include "chokepoints.h"
/* Place up to one machine per call (A.6c milestone — cascade + multi-machine
come in A.6d/f). Requires a precomputed chokepoint map + gate-site flags.
Emits MACHINE_PROPOSED / FEATURE_BUILT / MACHINE_ACCEPTED events.
Returns the number of machines placed. */
int place_machines(grid_t g, rng_t *rng, int depth, event_sink_t *sink,
choke_map_t choke, choke_flag_t is_gate);
#endif

27
src/gen/rng.c Normal file
View file

@ -0,0 +1,27 @@
#include "rng.h"
void rng_seed(rng_t *r, uint64_t seed) {
r->state = 0u;
r->inc = (seed << 1u) | 1u;
rng_u32(r);
r->state += seed;
rng_u32(r);
}
uint32_t rng_u32(rng_t *r) {
uint64_t old = r->state;
r->state = old * 6364136223846793005ULL + r->inc;
uint32_t xorshifted = (uint32_t)(((old >> 18u) ^ old) >> 27u);
uint32_t rot = (uint32_t)(old >> 59u);
return (xorshifted >> rot) | (xorshifted << ((-rot) & 31));
}
int rng_range(rng_t *r, int lo, int hi) {
if (hi <= lo) return lo;
uint32_t span = (uint32_t)(hi - lo + 1);
return lo + (int)(rng_u32(r) % span);
}
int rng_percent(rng_t *r, int pct) {
return (int)(rng_u32(r) % 100) < pct;
}

16
src/gen/rng.h Normal file
View file

@ -0,0 +1,16 @@
#ifndef GENESIS_GEN_RNG_H
#define GENESIS_GEN_RNG_H
#include <stdint.h>
typedef struct {
uint64_t state;
uint64_t inc;
} rng_t;
void rng_seed(rng_t *r, uint64_t seed);
uint32_t rng_u32(rng_t *r);
int rng_range(rng_t *r, int lo, int hi);
int rng_percent(rng_t *r, int pct);
#endif

143
src/gen/room_types.c Normal file
View file

@ -0,0 +1,143 @@
#include "room_types.h"
#include "ca.h"
#include <string.h>
static int carve_small_rect(grid_t g, rng_t *rng, int cx, int cy, uint8_t room_id) {
int w = rng_range(rng, 3, 6);
int h = rng_range(rng, 2, 4);
int x = cx - w / 2;
int y = cy - h / 2;
if (x < 1 || y < 1 || x + w >= DCOLS - 1 || y + h >= DROWS - 1) return 0;
grid_draw_rect(g, x, y, w, h, T_FLOOR, room_id);
return 1;
}
static int carve_cross(grid_t g, rng_t *rng, int cx, int cy, uint8_t room_id) {
int majw = rng_range(rng, 4, 8);
int majh = rng_range(rng, 3, 4);
int minw = rng_range(rng, 3, 5);
int minh = rng_range(rng, 2, 3);
int ax = cx - majw / 2, ay = cy - majh / 2;
int bx = cx - minw / 2, by = cy - minh / 2;
if (ax < 1 || ay < 1 || ax + majw >= DCOLS - 1 || ay + majh >= DROWS - 1) return 0;
if (bx < 1 || by < 1 || bx + minw >= DCOLS - 1 || by + minh >= DROWS - 1) return 0;
grid_draw_rect(g, ax, ay, majw, majh, T_FLOOR, room_id);
grid_draw_rect(g, bx, by, minw, minh, T_FLOOR, room_id);
return 1;
}
static int carve_sym_cross(grid_t g, rng_t *rng, int cx, int cy, uint8_t room_id) {
int majw = rng_range(rng, 4, 8) | 1;
int majh = 3;
int minw = 3;
int minh = rng_range(rng, 3, 5) | 1;
int ax = cx - majw / 2, ay = cy - majh / 2;
int bx = cx - minw / 2, by = cy - minh / 2;
if (ax < 1 || ay < 1 || ax + majw >= DCOLS - 1 || ay + majh >= DROWS - 1) return 0;
if (bx < 1 || by < 1 || bx + minw >= DCOLS - 1 || by + minh >= DROWS - 1) return 0;
grid_draw_rect(g, ax, ay, majw, majh, T_FLOOR, room_id);
grid_draw_rect(g, bx, by, minw, minh, T_FLOOR, room_id);
return 1;
}
static int carve_circular(grid_t g, rng_t *rng, int cx, int cy, uint8_t room_id) {
int r = rng_range(rng, 2, 4);
if (cx - r < 1 || cy - r < 1 || cx + r >= DCOLS - 1 || cy + r >= DROWS - 1) return 0;
grid_draw_circle(g, cx, cy, r, T_FLOOR, room_id);
return 1;
}
static int carve_chunky(grid_t g, rng_t *rng, int cx, int cy, uint8_t room_id) {
int bumps = rng_range(rng, 2, 4);
int placed = 0;
for (int i = 0; i < bumps; i++) {
int ox = rng_range(rng, -2, 2);
int oy = rng_range(rng, -2, 2);
int r = rng_range(rng, 1, 2);
int x = cx + ox, y = cy + oy;
if (x - r < 1 || y - r < 1 || x + r >= DCOLS - 1 || y + r >= DROWS - 1) continue;
grid_draw_circle(g, x, y, r, T_FLOOR, room_id);
placed++;
}
return placed > 0;
}
/* Carve a CA blob into the scratch grid, then transcribe filled cells as
T_FLOOR with the given room_id. The blob is generated centered on (cx,cy)
with the given bounding box (clipped to grid). Returns 1 if any cells got
carved. ca_blob is called with NULL sink to avoid spamming CA_ITERATION
events from inside accretion. */
static int carve_ca_blob(grid_t g, rng_t *rng, int cx, int cy,
int bw, int bh, uint8_t room_id) {
int x0 = cx - bw / 2;
int y0 = cy - bh / 2;
if (x0 < 1) x0 = 1;
if (y0 < 1) y0 = 1;
if (x0 + bw >= DCOLS - 1) x0 = DCOLS - 1 - bw;
if (y0 + bh >= DROWS - 1) y0 = DROWS - 1 - bh;
if (x0 < 1 || y0 < 1) return 0;
static bitgrid_t blob;
ca_blob(blob, rng, NULL, x0, y0, bw, bh);
int count = bitgrid_count(blob);
if (count < 4) return 0;
for (int y = 0; y < DROWS; y++) {
for (int x = 0; x < DCOLS; x++) {
if (!blob[y][x]) continue;
g[y][x].terrain = T_FLOOR;
g[y][x].room_id = room_id;
g[y][x].flags |= F_IN_ROOM;
}
}
return 1;
}
static int carve_cave(grid_t g, rng_t *rng, int cx, int cy, uint8_t room_id) {
int bw = rng_range(rng, 6, 9);
int bh = rng_range(rng, 4, 6);
return carve_ca_blob(g, rng, cx, cy, bw, bh, room_id);
}
static int carve_cavern(grid_t g, rng_t *rng, int cx, int cy, uint8_t room_id) {
int bw = rng_range(rng, 18, 26);
int bh = rng_range(rng, 10, 14);
return carve_ca_blob(g, rng, cx, cy, bw, bh, room_id);
}
static int carve_entrance(grid_t g, rng_t *rng, int cx, int cy, uint8_t room_id) {
(void)rng;
int w = 5, h = 4;
int x = cx - w / 2, y = cy - h / 2;
if (x < 1 || y < 1 || x + w >= DCOLS - 1 || y + h >= DROWS - 1) return 0;
grid_draw_rect(g, x, y, w, h, T_FLOOR, room_id);
return 1;
}
int room_carve(grid_t g, room_type_t type, rng_t *rng, int cx, int cy, uint8_t room_id) {
switch (type) {
case ROOM_SMALL_RECT: return carve_small_rect(g, rng, cx, cy, room_id);
case ROOM_CROSS: return carve_cross(g, rng, cx, cy, room_id);
case ROOM_SYM_CROSS: return carve_sym_cross(g, rng, cx, cy, room_id);
case ROOM_CIRCULAR: return carve_circular(g, rng, cx, cy, room_id);
case ROOM_CHUNKY: return carve_chunky(g, rng, cx, cy, room_id);
case ROOM_CAVE: return carve_cave(g, rng, cx, cy, room_id);
case ROOM_CAVERN: return carve_cavern(g, rng, cx, cy, room_id);
case ROOM_ENTRANCE: return carve_entrance(g, rng, cx, cy, room_id);
default: return 0;
}
}
const char *room_type_name(room_type_t t) {
switch (t) {
case ROOM_SMALL_RECT: return "small_rect";
case ROOM_CROSS: return "cross";
case ROOM_SYM_CROSS: return "sym_cross";
case ROOM_CIRCULAR: return "circular";
case ROOM_CHUNKY: return "chunky";
case ROOM_CAVE: return "cave";
case ROOM_CAVERN: return "cavern";
case ROOM_ENTRANCE: return "entrance";
default: return "?";
}
}

25
src/gen/room_types.h Normal file
View file

@ -0,0 +1,25 @@
#ifndef GENESIS_GEN_ROOM_TYPES_H
#define GENESIS_GEN_ROOM_TYPES_H
#include "grid.h"
#include "rng.h"
typedef enum {
ROOM_SMALL_RECT = 0,
ROOM_CROSS = 1,
ROOM_SYM_CROSS = 2,
ROOM_CIRCULAR = 3,
ROOM_CHUNKY = 4,
ROOM_CAVE = 5,
ROOM_CAVERN = 6,
ROOM_ENTRANCE = 7,
ROOM_COUNT
} room_type_t;
/* Carve a room into the hyperspace scratch grid g, filling floor terrain with
room_id. Returns 1 on success, 0 if the type couldn't fit at (cx, cy). */
int room_carve(grid_t g, room_type_t type, rng_t *rng, int cx, int cy, uint8_t room_id);
const char *room_type_name(room_type_t t);
#endif

47
src/gen/stairs.c Normal file
View file

@ -0,0 +1,47 @@
#include "stairs.h"
#include "dijkstra.h"
#include <stddef.h>
static int is_stair_candidate(uint8_t t) {
return t == T_FLOOR; /* avoid corridors and doors so stairs sit in rooms */
}
/* Find the floor cell nearest to (cx,cy) in Chebyshev distance. */
static int nearest_floor(grid_t g, int cx, int cy, int *ox, int *oy) {
int best = -1, bx = -1, by = -1;
for (int y = 0; y < DROWS; y++) {
for (int x = 0; x < DCOLS; x++) {
if (!is_stair_candidate(g[y][x].terrain)) continue;
int dx = x - cx, dy = y - cy;
int d = (dx < 0 ? -dx : dx) + (dy < 0 ? -dy : dy);
if (best < 0 || d < best) { best = d; bx = x; by = y; }
}
}
if (best < 0) return 0;
*ox = bx; *oy = by;
return 1;
}
void place_stairs(grid_t g, event_sink_t *sink) {
int ux, uy;
if (!nearest_floor(g, DCOLS / 2, DROWS / 2, &ux, &uy)) return;
g[uy][ux].terrain = T_STAIRS_UP;
event_emit(sink, EV_STAIRS_PLACED_UP, NULL, 0);
/* BFS from up-stair; pick farthest passable cell that is still a room
floor candidate. */
static dist_map_t dist;
bfs_from(g, ux, uy, dist, -1, -1);
int best_d = -1, dx_ = -1, dy_ = -1;
for (int y = 0; y < DROWS; y++) {
for (int x = 0; x < DCOLS; x++) {
if (!is_stair_candidate(g[y][x].terrain)) continue;
if (dist[y][x] == DIST_UNREACHABLE) continue;
if (dist[y][x] > best_d) { best_d = dist[y][x]; dx_ = x; dy_ = y; }
}
}
if (best_d < 0) return; /* degenerate: no other floor — leave down-stair unplaced */
g[dy_][dx_].terrain = T_STAIRS_DOWN;
event_emit(sink, EV_STAIRS_PLACED_DOWN, NULL, 0);
}

12
src/gen/stairs.h Normal file
View file

@ -0,0 +1,12 @@
#ifndef GENESIS_GEN_STAIRS_H
#define GENESIS_GEN_STAIRS_H
#include "grid.h"
#include "events.h"
/* Place an up-stair near the map center (first room) and a down-stair at the
floor cell with the greatest graph distance from the up-stair. Called after
wall finishing. Emits EV_STAIRS_PLACED_UP then EV_STAIRS_PLACED_DOWN. */
void place_stairs(grid_t g, event_sink_t *sink);
#endif

24
src/gen/walls.c Normal file
View file

@ -0,0 +1,24 @@
#include "walls.h"
#include <stddef.h>
void finish_walls(grid_t g, event_sink_t *sink) {
for (int y = 0; y < DROWS; y++) {
for (int x = 0; x < DCOLS; x++) {
if (g[y][x].terrain != T_NOTHING) continue;
int adj_open = 0;
for (int ny = -1; ny <= 1 && !adj_open; ny++) {
for (int nx = -1; nx <= 1 && !adj_open; nx++) {
if (nx == 0 && ny == 0) continue;
int qx = x + nx, qy = y + ny;
if (!grid_in_bounds(qx, qy)) continue;
uint8_t tt = g[qy][qx].terrain;
if (tt == T_FLOOR || tt == T_CORRIDOR || tt == T_DOOR) {
adj_open = 1;
}
}
}
if (adj_open) g[y][x].terrain = T_WALL;
}
}
event_emit(sink, EV_WALLS_FINISHED, NULL, 0);
}

10
src/gen/walls.h Normal file
View file

@ -0,0 +1,10 @@
#ifndef GENESIS_GEN_WALLS_H
#define GENESIS_GEN_WALLS_H
#include "grid.h"
#include "events.h"
/* Convert every NOTHING cell adjacent (8-neighbor) to open terrain into WALL. */
void finish_walls(grid_t g, event_sink_t *sink);
#endif

214
src/genesis_main.c Normal file
View file

@ -0,0 +1,214 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "gen/grid.h"
#include "gen/rng.h"
#include "gen/room_types.h"
#include "gen/events.h"
#include "gen/accretion.h"
#include "gen/loops.h"
#include "gen/lakes.h"
#include "gen/walls.h"
#include "gen/stairs.h"
#include "gen/chokepoints.h"
#include "gen/machines.h"
#include "gen/dijkstra.h"
#include "json_emit.h"
static int g_verbose = 0;
static int g_quiet = 0;
static int g_mark_unreached = 0;
static int g_emit_json = 0;
static void print_ascii(grid_t g) {
dist_map_t dist;
if (g_mark_unreached) {
int sx = -1, sy = -1;
for (int y = 0; y < DROWS && sx < 0; y++)
for (int x = 0; x < DCOLS && sx < 0; x++) {
uint8_t t = g[y][x].terrain;
if (t == T_FLOOR || t == T_CORRIDOR || t == T_DOOR || t == T_BRIDGE) {
sx = x; sy = y;
}
}
if (sx >= 0) bfs_from(g, sx, sy, dist, -1, -1);
}
for (int y = 0; y < DROWS; y++) {
for (int x = 0; x < DCOLS; x++) {
char ch;
uint8_t t = g[y][x].terrain;
int passable = (t == T_FLOOR || t == T_CORRIDOR || t == T_DOOR || t == T_BRIDGE);
if (g_mark_unreached && passable && dist[y][x] == DIST_UNREACHABLE) {
ch = '!';
} else switch (t) {
case T_FLOOR: ch = (g[y][x].flags & F_WREATH) ? ';' : '.'; break;
case T_CORRIDOR: ch = ','; break;
case T_DOOR: ch = '+'; break;
case T_WALL: ch = (g[y][x].flags & F_WREATH) ? ':' : '#'; break;
case T_LIQUID:
switch (g[y][x].liquid) {
case L_LAVA: ch = '^'; break;
case L_CHASM: ch = 'v'; break;
case L_BRIMSTONE: ch = '%'; break;
case L_WATER:
default: ch = '~'; break;
}
break;
case T_BRIDGE: ch = '='; break;
case T_STAIRS_UP: ch = '<'; break;
case T_STAIRS_DOWN: ch = '>'; break;
default: ch = ' '; break;
}
putchar(ch);
}
putchar('\n');
}
}
static void stderr_sink(void *ctx, const gen_event_t *ev) {
(void)ctx;
if (!g_verbose) return;
fprintf(stderr, "[ev %4u] %s\n",
ev->step, event_kind_name((event_kind_t)ev->kind));
}
typedef struct {
int counts[EV_KIND_COUNT];
} sink_ctx_t;
static void counting_sink(void *ctx, const gen_event_t *ev) {
sink_ctx_t *s = (sink_ctx_t *)ctx;
s->counts[ev->kind]++;
if (g_verbose) stderr_sink(NULL, ev);
}
typedef struct {
int rooms, loops, lakes_proposed, lakes_rejected, lakes_placed, bridges;
int machines_proposed, machines_placed;
int floor_cells;
int connected;
int unreached;
} run_stats_t;
static void run_once(uint64_t seed, int depth, grid_t *gp, run_stats_t *out, int verify) {
cell_t (*g)[DCOLS] = *gp;
rng_t rng;
rng_seed(&rng, seed);
grid_fill(g, T_NOTHING);
sink_ctx_t sctx = {0};
event_sink_t sink = { .fn = counting_sink, .ctx = &sctx, .step = 0 };
event_emit(&sink, EV_GEN_BEGIN, NULL, 0);
accrete_rooms(g, &rng, &sink);
if (verify && !is_connected(g, NULL))
fprintf(stderr, "!! seed %llu: disconnected after accretion\n", (unsigned long long)seed);
add_loops(g, &sink);
if (verify && !is_connected(g, NULL))
fprintf(stderr, "!! seed %llu: disconnected after loops\n", (unsigned long long)seed);
place_lakes(g, &rng, &sink, depth);
if (verify && !is_connected(g, NULL))
fprintf(stderr, "!! seed %llu: disconnected after lakes\n", (unsigned long long)seed);
apply_wreaths(g, &sink);
place_bridges(g, &sink);
finish_walls(g, &sink);
place_stairs(g, &sink);
/* Chokepoint analysis + machine placement. */
static choke_map_t choke;
static choke_flag_t is_choke, is_gate;
compute_chokepoints(g, &sink, choke, is_choke, is_gate);
place_machines(g, &rng, depth, &sink, choke, is_gate);
event_emit(&sink, EV_GEN_END, NULL, 0);
out->rooms = sctx.counts[EV_ROOM_PLACED];
out->loops = sctx.counts[EV_LOOP_ACCEPTED];
out->lakes_proposed = sctx.counts[EV_LAKE_PROPOSED];
out->lakes_rejected = sctx.counts[EV_LAKE_REJECTED_IMPASSABLE];
out->lakes_placed = sctx.counts[EV_LAKE_PLACED];
out->bridges = sctx.counts[EV_BRIDGE_PLACED];
out->machines_proposed = sctx.counts[EV_MACHINE_PROPOSED];
out->machines_placed = sctx.counts[EV_MACHINE_ACCEPTED];
out->floor_cells = grid_count_floor(g);
out->connected = is_connected(g, &out->unreached);
}
static void print_summary(uint64_t seed, int depth, const run_stats_t *s) {
fprintf(stderr,
"seed=%llu depth=%d rooms=%d loops=%d "
"lakes_proposed=%d lakes_rejected=%d lakes_placed=%d "
"bridges=%d machines=%d/%d floor=%d connected=%d unreached=%d\n",
(unsigned long long)seed, depth,
s->rooms, s->loops,
s->lakes_proposed, s->lakes_rejected, s->lakes_placed,
s->bridges, s->machines_placed, s->machines_proposed,
s->floor_cells, s->connected, s->unreached);
}
static int cmd_stress(int n, uint64_t base_seed, int depth) {
grid_t g;
int failures = 0;
for (int i = 0; i < n; i++) {
uint64_t seed = base_seed + (uint64_t)i;
run_stats_t s = {0};
run_once(seed, depth, &g, &s, 0);
if (!s.connected) {
fprintf(stderr, "FAIL seed=%llu unreached=%d\n",
(unsigned long long)seed, s.unreached);
failures++;
}
}
fprintf(stderr, "stress: %d seeds, %d failures\n", n, failures);
return failures == 0 ? 0 : 1;
}
int main(int argc, char **argv) {
uint64_t seed = 1234;
int depth = 1;
int verify = 0;
int stress_n = 0;
uint64_t stress_base = 1;
for (int i = 1; i < argc; i++) {
if (!strcmp(argv[i], "--seed") && i + 1 < argc) {
seed = strtoull(argv[++i], NULL, 10);
} else if (!strcmp(argv[i], "--depth") && i + 1 < argc) {
depth = atoi(argv[++i]);
if (depth < 1) depth = 1;
if (depth > 26) depth = 26;
} else if (!strcmp(argv[i], "--verbose")) {
g_verbose = 1;
} else if (!strcmp(argv[i], "--quiet")) {
g_quiet = 1;
} else if (!strcmp(argv[i], "--verify")) {
verify = 1;
} else if (!strcmp(argv[i], "--stress") && i + 1 < argc) {
stress_n = atoi(argv[++i]);
} else if (!strcmp(argv[i], "--stress-base") && i + 1 < argc) {
stress_base = strtoull(argv[++i], NULL, 10);
} else if (!strcmp(argv[i], "--mark-unreached")) {
g_mark_unreached = 1;
} else if (!strcmp(argv[i], "--emit=json") || !strcmp(argv[i], "--json")) {
g_emit_json = 1;
}
}
if (stress_n > 0) return cmd_stress(stress_n, stress_base, depth);
grid_t g;
run_stats_t s = {0};
run_once(seed, depth, &g, &s, verify);
if (g_emit_json) {
emit_grid_json(g, seed, depth);
if (verify && !s.connected) return 1;
return 0;
}
print_summary(seed, depth, &s);
if (!g_quiet) print_ascii(g);
if (verify && !s.connected) return 1;
return 0;
}

173
src/json_emit.c Normal file
View file

@ -0,0 +1,173 @@
#include "json_emit.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static const char B64[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
static void b64_write(const uint8_t *src, size_t n) {
size_t i = 0;
while (i + 3 <= n) {
uint32_t v = ((uint32_t)src[i] << 16) | ((uint32_t)src[i+1] << 8) | src[i+2];
putchar(B64[(v >> 18) & 0x3F]);
putchar(B64[(v >> 12) & 0x3F]);
putchar(B64[(v >> 6) & 0x3F]);
putchar(B64[ v & 0x3F]);
i += 3;
}
size_t rem = n - i;
if (rem == 1) {
uint32_t v = (uint32_t)src[i] << 16;
putchar(B64[(v >> 18) & 0x3F]);
putchar(B64[(v >> 12) & 0x3F]);
putchar('=');
putchar('=');
} else if (rem == 2) {
uint32_t v = ((uint32_t)src[i] << 16) | ((uint32_t)src[i+1] << 8);
putchar(B64[(v >> 18) & 0x3F]);
putchar(B64[(v >> 12) & 0x3F]);
putchar(B64[(v >> 6) & 0x3F]);
putchar('=');
}
}
static void write_b64_field(const char *name, const uint8_t *bytes, size_t n) {
printf("\"%s\":\"", name);
b64_write(bytes, n);
putchar('"');
}
static void write_xy(const char *name, int x, int y) {
printf("\"%s\":[%d,%d]", name, x, y);
}
/* Bucket helpers. Room IDs and machine IDs are 1..255. */
typedef struct {
int count;
int cap;
int *xs;
int *ys;
} cellbuf_t;
static void cellbuf_push(cellbuf_t *b, int x, int y) {
if (b->count == b->cap) {
b->cap = b->cap ? b->cap * 2 : 16;
b->xs = (int *)realloc(b->xs, b->cap * sizeof(int));
b->ys = (int *)realloc(b->ys, b->cap * sizeof(int));
}
b->xs[b->count] = x;
b->ys[b->count] = y;
b->count++;
}
static void cellbuf_free(cellbuf_t *b) {
free(b->xs);
free(b->ys);
b->xs = NULL; b->ys = NULL; b->count = 0; b->cap = 0;
}
static void write_cellbuf(const cellbuf_t *b) {
putchar('[');
for (int i = 0; i < b->count; i++) {
if (i) putchar(',');
printf("[%d,%d]", b->xs[i], b->ys[i]);
}
putchar(']');
}
void emit_grid_json(grid_t g, uint64_t seed, int depth) {
const int N = DCOLS * DROWS;
uint8_t *terrain = (uint8_t *)malloc(N);
uint8_t *liquid = (uint8_t *)malloc(N);
uint8_t *surface = (uint8_t *)malloc(N);
uint8_t *room_id = (uint8_t *)malloc(N);
uint8_t *machine_id = (uint8_t *)malloc(N);
uint8_t *flags_le = (uint8_t *)malloc(N * 4);
int stairs_up_x = -1, stairs_up_y = -1;
int stairs_down_x = -1, stairs_down_y = -1;
cellbuf_t rooms[256] = {0};
cellbuf_t mach_interior[256] = {0};
int mach_gate_x[256], mach_gate_y[256];
for (int i = 0; i < 256; i++) { mach_gate_x[i] = -1; mach_gate_y[i] = -1; }
int idx = 0;
for (int y = 0; y < DROWS; y++) {
for (int x = 0; x < DCOLS; x++, idx++) {
const cell_t *c = &g[y][x];
terrain[idx] = c->terrain;
liquid[idx] = c->liquid;
surface[idx] = c->surface;
room_id[idx] = c->room_id;
machine_id[idx] = c->machine_id;
/* Little-endian int32 of the uint16 flags (zero-extended). */
uint32_t f = c->flags;
flags_le[idx*4 + 0] = (uint8_t)( f & 0xFF);
flags_le[idx*4 + 1] = (uint8_t)((f >> 8) & 0xFF);
flags_le[idx*4 + 2] = (uint8_t)((f >> 16) & 0xFF);
flags_le[idx*4 + 3] = (uint8_t)((f >> 24) & 0xFF);
if (c->terrain == T_STAIRS_UP) { stairs_up_x = x; stairs_up_y = y; }
if (c->terrain == T_STAIRS_DOWN) { stairs_down_x = x; stairs_down_y = y; }
if (c->room_id > 0) {
cellbuf_push(&rooms[c->room_id], x, y);
}
if (c->machine_id > 0) {
cellbuf_push(&mach_interior[c->machine_id], x, y);
if (c->flags & F_MACHINE_GATE) {
mach_gate_x[c->machine_id] = x;
mach_gate_y[c->machine_id] = y;
}
}
}
}
putchar('{');
printf("\"seed\":%llu,\"depth\":%d,\"width\":%d,\"height\":%d,",
(unsigned long long)seed, depth, DCOLS, DROWS);
write_b64_field("terrain", terrain, N); putchar(',');
write_b64_field("liquid", liquid, N); putchar(',');
write_b64_field("surface", surface, N); putchar(',');
write_b64_field("room_id", room_id, N); putchar(',');
write_b64_field("machine_id", machine_id, N); putchar(',');
write_b64_field("flags", flags_le, N * 4); putchar(',');
write_xy("stairs_up", stairs_up_x, stairs_up_y); putchar(',');
write_xy("stairs_down", stairs_down_x, stairs_down_y); putchar(',');
fputs("\"rooms\":[", stdout);
int first = 1;
for (int i = 1; i < 256; i++) {
if (rooms[i].count == 0) continue;
if (!first) putchar(',');
first = 0;
printf("{\"id\":%d,\"cells\":", i);
write_cellbuf(&rooms[i]);
putchar('}');
}
fputs("],", stdout);
fputs("\"machines\":[", stdout);
first = 1;
for (int i = 1; i < 256; i++) {
if (mach_interior[i].count == 0) continue;
if (!first) putchar(',');
first = 0;
printf("{\"id\":%d,\"interior_cells\":", i);
write_cellbuf(&mach_interior[i]);
printf(",\"gate\":[%d,%d]}", mach_gate_x[i], mach_gate_y[i]);
}
fputs("]", stdout);
fputs("}\n", stdout);
free(terrain); free(liquid); free(surface);
free(room_id); free(machine_id); free(flags_le);
for (int i = 0; i < 256; i++) { cellbuf_free(&rooms[i]); cellbuf_free(&mach_interior[i]); }
}

15
src/json_emit.h Normal file
View file

@ -0,0 +1,15 @@
#ifndef JSON_EMIT_H
#define JSON_EMIT_H
#include <stdint.h>
#include "gen/grid.h"
/* Emit the grid as a single JSON object to stdout. Layout mirrors the
* Dictionary produced by godot/src/grid_to_dict.cpp so the CLI and the
* GDExtension expose the same schema. Byte layers are base64-encoded;
* the flags layer is emitted as base64 of width*height little-endian
* int32 values.
*/
void emit_grid_json(grid_t g, uint64_t seed, int depth);
#endif

33
test/README Normal file
View file

@ -0,0 +1,33 @@
Tests for the brogue-genesis generator.
Running
-------
make test # goldens + 1000-seed invariant stress
make stress # 5000-seed invariant stress only
Files
-----
run_tests.sh Runs goldens against current binary, then stress.
update_goldens.sh Regenerates goldens.txt. Run this only after an
intentional change to generation logic.
goldens.txt seed|map_sha256|summary_line, one per committed seed.
What's checked
--------------
1. Map hash stability — sha256 of the ASCII map for each committed seed.
2. Summary line stability — room/loop/lake/bridge counts and connectivity.
3. Connectivity invariant — for a rolling sweep of seeds, every passable
cell must be 4-connected to every other. This is the load-bearing
property of Brogue's pipeline.
When to update the goldens
--------------------------
If you intentionally change generation (new room type, tweak CA rule, change
lake sizes, adjust corridor bounds, reorder phases, etc.), the goldens will
break by design. Inspect the diff, confirm it looks right, then:
make && test/update_goldens.sh
make test
If the stress pass ever reports non-zero failures, a connectivity-preserving
invariant has been broken — investigate before committing.

20
test/goldens.txt Normal file
View file

@ -0,0 +1,20 @@
# seed_depth|map_sha256|summary_line
# Generated 2026-04-13T16:05:27Z
1:1|7c7a6b6897f950ffe50cc4617a64ef6dc235c821e8bd0fd2e90be0a91773c0a5|seed=1 depth=1 rooms=31 loops=4 lakes_proposed=6 lakes_rejected=5 lakes_placed=1 bridges=1 machines=1/1 floor=642 connected=1 unreached=0
2:1|7f41699a774c4c23acde501d157f1f5d4561989637b6fd0419688668a162f6ea|seed=2 depth=1 rooms=26 loops=6 lakes_proposed=6 lakes_rejected=5 lakes_placed=1 bridges=2 machines=1/1 floor=534 connected=1 unreached=0
42:1|2d89ffde34b0aa2ce4ffe11dc4feac26b045aef160e8bfa52c85d398e0464ae7|seed=42 depth=1 rooms=27 loops=2 lakes_proposed=6 lakes_rejected=5 lakes_placed=1 bridges=1 machines=1/1 floor=663 connected=1 unreached=0
77:1|af72b9a505777abd87b6d2693b6ee2710f12708ca223b08b0a8a385e110b414a|seed=77 depth=1 rooms=30 loops=4 lakes_proposed=6 lakes_rejected=5 lakes_placed=1 bridges=0 machines=1/1 floor=574 connected=1 unreached=0
1234:1|c62f7b9a68ccaaae6f10621de685b3066d87b098eed9ef9fade351658ca638d9|seed=1234 depth=1 rooms=31 loops=3 lakes_proposed=6 lakes_rejected=4 lakes_placed=2 bridges=0 machines=1/1 floor=646 connected=1 unreached=0
2026:1|bd55089a42166b92daf0814b20aff9b6e0b17d49ed7d53d4f25085bd579e44ea|seed=2026 depth=1 rooms=31 loops=5 lakes_proposed=6 lakes_rejected=6 lakes_placed=0 bridges=0 machines=1/1 floor=664 connected=1 unreached=0
9999:1|e3042b10055bc0ed45667b6804ae05e7a7695a76018d555552c472d8a4d8e159|seed=9999 depth=1 rooms=30 loops=4 lakes_proposed=6 lakes_rejected=4 lakes_placed=2 bridges=1 machines=1/1 floor=652 connected=1 unreached=0
123456:1|cabc305545fbc230c3e4667681bf725d854da3e465f281067077dfc5ddfed435|seed=123456 depth=1 rooms=31 loops=6 lakes_proposed=6 lakes_rejected=5 lakes_placed=1 bridges=8 machines=1/1 floor=680 connected=1 unreached=0
31337:1|f747922a14158050cd871553393d0247becf5f64b4c21a303d36d55e328330c3|seed=31337 depth=1 rooms=29 loops=1 lakes_proposed=6 lakes_rejected=5 lakes_placed=1 bridges=2 machines=1/1 floor=523 connected=1 unreached=0
7:1|e6f748b2690e4ce9fc594f686ee5ddfdfa55c02ca21d428c2eb64c12e042fbdf|seed=7 depth=1 rooms=33 loops=7 lakes_proposed=6 lakes_rejected=6 lakes_placed=0 bridges=0 machines=1/1 floor=673 connected=1 unreached=0
100:1|5291c7836429b2291c2a2e19d31e8503c53b7e6b491bcc3af608eb5bcc99582e|seed=100 depth=1 rooms=28 loops=5 lakes_proposed=6 lakes_rejected=3 lakes_placed=3 bridges=5 machines=1/1 floor=575 connected=1 unreached=0
500:1|11b6eb403ca4d9f5d72d6603097793ad163e6dd33a4840099b1eca86b2c86adc|seed=500 depth=1 rooms=31 loops=5 lakes_proposed=6 lakes_rejected=5 lakes_placed=1 bridges=1 machines=1/1 floor=672 connected=1 unreached=0
2026:10|bd55089a42166b92daf0814b20aff9b6e0b17d49ed7d53d4f25085bd579e44ea|seed=2026 depth=10 rooms=31 loops=5 lakes_proposed=6 lakes_rejected=6 lakes_placed=0 bridges=0 machines=1/1 floor=664 connected=1 unreached=0
2026:15|bd55089a42166b92daf0814b20aff9b6e0b17d49ed7d53d4f25085bd579e44ea|seed=2026 depth=15 rooms=31 loops=5 lakes_proposed=6 lakes_rejected=6 lakes_placed=0 bridges=0 machines=1/1 floor=664 connected=1 unreached=0
2026:20|bd55089a42166b92daf0814b20aff9b6e0b17d49ed7d53d4f25085bd579e44ea|seed=2026 depth=20 rooms=31 loops=5 lakes_proposed=6 lakes_rejected=6 lakes_placed=0 bridges=0 machines=1/1 floor=664 connected=1 unreached=0
2026:25|bd55089a42166b92daf0814b20aff9b6e0b17d49ed7d53d4f25085bd579e44ea|seed=2026 depth=25 rooms=31 loops=5 lakes_proposed=6 lakes_rejected=6 lakes_placed=0 bridges=0 machines=1/1 floor=664 connected=1 unreached=0
1234:18|aa9083fa66adc1fbb8c59540364b12390c3a871091c676cadeaa63c4fd5392fd|seed=1234 depth=18 rooms=31 loops=3 lakes_proposed=6 lakes_rejected=4 lakes_placed=2 bridges=0 machines=1/1 floor=646 connected=1 unreached=0
9999:22|837916e2c833481487443d99cfeddc1c6b6adb01ef4c3f6d790e4cf08c8052d9|seed=9999 depth=22 rooms=30 loops=4 lakes_proposed=6 lakes_rejected=4 lakes_placed=2 bridges=1 machines=1/1 floor=652 connected=1 unreached=0

53
test/run_tests.sh Executable file
View file

@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Golden-seed regression. For each seed in goldens.txt, regenerate the map and
# compare (sha256 of map + summary line). Non-zero exit on any mismatch.
set -u
cd "$(dirname "$0")/.."
BIN=./bin/genesis
GOLDENS=test/goldens.txt
if [[ ! -x "$BIN" ]]; then
echo "error: $BIN not built. Run 'make' first." >&2
exit 2
fi
if [[ ! -f "$GOLDENS" ]]; then
echo "error: $GOLDENS missing. Run test/update_goldens.sh to create." >&2
exit 2
fi
fail=0
total=0
while IFS='|' read -r key expected_hash expected_summary; do
[[ -z "${key:-}" || "${key:0:1}" == "#" ]] && continue
seed="${key%:*}"
depth="${key#*:}"
[[ "$depth" == "$seed" ]] && depth=1
total=$((total + 1))
actual_hash=$("$BIN" --seed "$seed" --depth "$depth" 2>/dev/null | sha256sum | awk '{print $1}')
actual_summary=$("$BIN" --seed "$seed" --depth "$depth" --quiet 2>&1 >/dev/null)
if [[ "$actual_hash" != "$expected_hash" ]]; then
echo "FAIL $key hash mismatch"
echo " expected: $expected_hash"
echo " actual: $actual_hash"
fail=$((fail + 1))
continue
fi
if [[ "$actual_summary" != "$expected_summary" ]]; then
echo "FAIL $key summary mismatch"
echo " expected: $expected_summary"
echo " actual: $actual_summary"
fail=$((fail + 1))
continue
fi
echo "PASS $key"
done < "$GOLDENS"
echo "----"
echo "$((total - fail))/$total passed"
# Invariant sweep: run a wider stress check to catch regressions the goldens
# might miss.
echo "---- stress (connectivity invariant, 1000 seeds) ----"
"$BIN" --stress 1000 2>&1 | tail -1
exit $fail

34
test/update_goldens.sh Executable file
View file

@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Regenerate test/goldens.txt from current binary output. Run only after
# intentional changes to generation logic.
set -eu
cd "$(dirname "$0")/.."
BIN=./bin/genesis
OUT=test/goldens.txt
if [[ ! -x "$BIN" ]]; then
echo "error: $BIN not built. Run 'make' first." >&2
exit 2
fi
# Pairs of seed:depth. Mix shallow (water) and deep (lava/chasm/brimstone) to
# cover the full liquid range.
PAIRS=(
"1:1" "2:1" "42:1" "77:1" "1234:1" "2026:1" "9999:1"
"123456:1" "31337:1" "7:1" "100:1" "500:1"
"2026:10" "2026:15" "2026:20" "2026:25"
"1234:18" "9999:22"
)
{
echo "# seed_depth|map_sha256|summary_line"
echo "# Generated $(date -u +%Y-%m-%dT%H:%M:%SZ)"
for pair in "${PAIRS[@]}"; do
seed="${pair%:*}"
depth="${pair#*:}"
hash=$("$BIN" --seed "$seed" --depth "$depth" 2>/dev/null | sha256sum | awk '{print $1}')
summary=$("$BIN" --seed "$seed" --depth "$depth" --quiet 2>&1 >/dev/null)
printf '%s:%s|%s|%s\n' "$seed" "$depth" "$hash" "$summary"
done
} > "$OUT"
echo "wrote $OUT ($(wc -l < "$OUT") lines)"