feat: 3D blobber dungeon generator (PR 1)

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

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

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

View file

@ -1,233 +0,0 @@
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

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

View file

@ -1,174 +0,0 @@
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

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

View file

@ -1,139 +0,0 @@
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

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

View file

@ -1,57 +0,0 @@
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

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

View file

@ -1,118 +0,0 @@
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

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

View file

@ -1,135 +0,0 @@
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

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

View file

@ -1,122 +0,0 @@
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

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

View file

@ -1,143 +0,0 @@
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

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

View file

@ -0,0 +1,92 @@
extends Node3D
# Blobber dungeon builder. Calls BrogueGen.generate_dungeon() and assembles
# per-material MeshInstance3D children from the returned mesh surface arrays.
# Material ids mirror src/mesh/material_ids.h.
const MAT_STONE_FLOOR := 0
const MAT_STONE_CEILING := 1
const MAT_STONE_WALL := 2
const MAT_DOOR_FLOOR := 3
const MAT_STAIR_UP := 4
const MAT_STAIR_DOWN := 5
const MAT_WATER := 6
const MAT_LAVA := 7
const MAT_BRIDGE := 8
const MAT_CAVE_FLOOR := 9
const MAT_CAVE_CEILING := 10
const MAT_CAVE_WALL := 11
@export var seed_value: int = 42
@export var num_levels: int = 1
@export var depth: int = 1
@export var regenerate_on_ready: bool = true
var _materials: Dictionary = {}
func _ready() -> void:
_materials = _build_materials()
if regenerate_on_ready:
regenerate()
func regenerate() -> void:
for child in get_children():
child.queue_free()
var gen := BrogueGen.new()
var dungeon: Dictionary = gen.generate_dungeon(seed_value, num_levels, depth)
gen.free()
if dungeon.is_empty():
push_error("generate_dungeon returned empty dictionary")
return
var meshes: Array = dungeon.get("meshes", [])
for entry_v in meshes:
var entry: Dictionary = entry_v
var material_id: int = entry.get("material", -1)
var arrays: Array = entry.get("arrays", [])
if material_id < 0 or arrays.is_empty():
continue
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
mesh.surface_set_material(0, _materials.get(material_id, _fallback_material()))
var mi := MeshInstance3D.new()
mi.mesh = mesh
mi.name = "Surface_%d" % material_id
add_child(mi)
var dims: Vector3i = dungeon.get("dimensions", Vector3i(79, 1, 29))
var levels: Array = dungeon.get("levels", [])
print("Dungeon built: seed=%d dims=%s surfaces=%d levels=%d" %
[seed_value, dims, meshes.size(), levels.size()])
# Distinct albedo colors per material for instant visual debugging.
# Later PRs replace these with proper StandardMaterial3D + textures.
func _build_materials() -> Dictionary:
var m := {}
m[MAT_STONE_FLOOR] = _flat(Color(0.45, 0.42, 0.38))
m[MAT_STONE_CEILING] = _flat(Color(0.30, 0.28, 0.26))
m[MAT_STONE_WALL] = _flat(Color(0.55, 0.50, 0.45))
m[MAT_DOOR_FLOOR] = _flat(Color(0.62, 0.38, 0.18))
m[MAT_STAIR_UP] = _flat(Color(0.20, 0.75, 0.40))
m[MAT_STAIR_DOWN] = _flat(Color(0.85, 0.25, 0.20))
m[MAT_WATER] = _flat(Color(0.15, 0.40, 0.75))
m[MAT_LAVA] = _flat(Color(0.95, 0.40, 0.10))
m[MAT_BRIDGE] = _flat(Color(0.50, 0.35, 0.20))
m[MAT_CAVE_FLOOR] = _flat(Color(0.35, 0.32, 0.28))
m[MAT_CAVE_CEILING] = _flat(Color(0.22, 0.20, 0.18))
m[MAT_CAVE_WALL] = _flat(Color(0.42, 0.38, 0.34))
return m
func _flat(c: Color) -> StandardMaterial3D:
var sm := StandardMaterial3D.new()
sm.albedo_color = c
sm.roughness = 0.85
sm.metallic = 0.0
return sm
func _fallback_material() -> StandardMaterial3D:
return _flat(Color.MAGENTA)

View file

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

View file

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

View file

@ -0,0 +1,97 @@
extends SceneTree
# Usage:
# godot --headless --path demo --script scripts/export_tb_config.gd -- OUT_DIR
#
# Writes a complete TrenchBroom game config to OUT_DIR:
# OUT_DIR/GameConfig.cfg — game definition (name, formats, texture dirs)
# OUT_DIR/FuncGodot.fgd — entity definitions, generated from
# res://data/fgd/brogue_fgd.tres via
# FuncGodotFGDFile.build_class_text().
#
# Skips the Inspector's "Export FGD" button entirely. Safe to re-run after
# any change to brogue_fgd.tres or entity resources under data/entities/.
const GAME_CONFIG := """{
\t\"version\": 9,
\t\"name\": \"brogue-genesis\",
\t\"icon\": \"icon.png\",
\t\"fileformats\": [
\t\t{ \"format\": \"Valve\", \"initialmap\": \"initial_valve.map\" },
\t\t{ \"format\": \"Standard\", \"initialmap\": \"initial_standard.map\" }
\t],
\t\"filesystem\": {
\t\t\"searchpath\": \".\",
\t\t\"packageformat\": { \"extension\": \".zip\", \"format\": \"zip\" }
\t},
\t\"materials\": {
\t\t\"root\": \"textures\",
\t\t\"extensions\": [\".bmp\", \".jpeg\", \".jpg\", \".png\", \".tga\", \".webp\"],
\t\t\"excludes\": [ \"*_albedo\", \"*_ao\", \"*_emission\", \"*_height\", \"*_metallic\", \"*_normal\", \"*_orm\", \"*_roughness\", \"*_sss\" ],
\t\t\"palette\": \"textures/palette.lmp\",
\t\t\"attribute\": \"wad\"
\t},
\t\"entities\": {
\t\t\"definitions\": [ \"FuncGodot.fgd\" ],
\t\t\"defaultcolor\": \"0.6 0.6 0.6 1.0\",
\t\t\"scale\": 32
\t},
\t\"tags\": {
\t\t\"brush\": [],
\t\t\"brushface\": [
\t\t\t{ \"name\": \"Clip\", \"attribs\": [ \"transparent\" ], \"match\": \"material\", \"pattern\": \"clip\" },
\t\t\t{ \"name\": \"Skip\", \"attribs\": [ \"transparent\" ], \"match\": \"material\", \"pattern\": \"skip\" },
\t\t\t{ \"name\": \"Origin\", \"attribs\": [ \"transparent\" ], \"match\": \"material\", \"pattern\": \"origin\" }
\t\t]
\t},
\t\"faceattribs\": {
\t\t\"defaults\": { \"scale\": [1.0, 1.0] },
\t\t\"contentflags\": [],
\t\t\"surfaceflags\": []
\t}
}
"""
func _init() -> void:
var args := OS.get_cmdline_user_args()
if args.size() != 1:
push_error("usage: -- OUT_DIR")
quit(1)
return
var out_dir: String = args[0]
if not DirAccess.dir_exists_absolute(out_dir):
if DirAccess.make_dir_recursive_absolute(out_dir) != OK:
push_error("failed to create %s" % out_dir)
quit(1)
return
var cfg_path := out_dir.path_join("GameConfig.cfg")
var cfg_file := FileAccess.open(cfg_path, FileAccess.WRITE)
if cfg_file == null:
push_error("cannot open %s for write" % cfg_path)
quit(1)
return
cfg_file.store_string(GAME_CONFIG)
cfg_file.close()
var fgd: Resource = load("res://data/fgd/brogue_fgd.tres")
if fgd == null:
push_error("failed to load res://data/fgd/brogue_fgd.tres")
quit(1)
return
var fgd_text: String = fgd.build_class_text(1) # 1 == TRENCHBROOM
var fgd_path := out_dir.path_join("FuncGodot.fgd")
var fgd_file := FileAccess.open(fgd_path, FileAccess.WRITE)
if fgd_file == null:
push_error("cannot open %s for write" % fgd_path)
quit(1)
return
fgd_file.store_string(fgd_text)
fgd_file.close()
print("synced TrenchBroom config to %s" % out_dir)
print(" GameConfig.cfg %d bytes" % GAME_CONFIG.length())
print(" FuncGodot.fgd %d bytes" % fgd_text.length())
quit(0)

View file

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

View file

@ -1,18 +0,0 @@
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

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

View file

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

View file

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