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

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