feat: 3D blobber dungeon generator (PR 1)
Replaces the 2D-only demo pipeline with a 3D cell-based blobber generator. Per-cell face walls, per-material mesh emission, and a GDExtension binding that returns a Dictionary with ArrayMesh surfaces the demo consumes directly. - src/blobber/: cell3d_t data model, dungeon container, pipeline that wraps the 2D generator per level and materializes into cell3d - src/mesh/: face-quad emitter with per-material groups + .obj dump - src/genesis3d_main.c: new CLI driving the blobber + mesh - godot/: BrogueGen.generate_dungeon(seed, num_levels, depth) binding with dungeon_to_dict packing cells + mesh surfaces - demo/: demo_blobber.tscn + dungeon_builder.gd, func_godot addon for the .map export path, point/entity templates, TrenchBroom docs - Retired: old arcade/FPS demo scenes and their scripts, unused meshlib Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6ee49c3375
commit
7a6ae79d01
160 changed files with 7209 additions and 2072 deletions
|
|
@ -1,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")
|
||||
|
|
@ -1 +0,0 @@
|
|||
uid://3hwwo1hwe12
|
||||
|
|
@ -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()
|
||||
|
|
@ -1 +0,0 @@
|
|||
uid://dr7jelri306sd
|
||||
|
|
@ -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)
|
||||
|
|
@ -1 +0,0 @@
|
|||
uid://buo0sbr01qcm5
|
||||
|
|
@ -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
|
||||
|
|
@ -1 +0,0 @@
|
|||
uid://dd7ud5jyshij5
|
||||
|
|
@ -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()
|
||||
|
|
@ -1 +0,0 @@
|
|||
uid://dxyvug2vl2jjq
|
||||
|
|
@ -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
|
||||
|
|
@ -1 +0,0 @@
|
|||
uid://y0qm8301m7w6
|
||||
|
|
@ -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
|
||||
|
|
@ -1 +0,0 @@
|
|||
uid://brsi02a7ei24j
|
||||
|
|
@ -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
|
||||
|
|
@ -1 +0,0 @@
|
|||
uid://dvoatkhtgji2u
|
||||
92
demo/scripts/dungeon_builder.gd
Normal file
92
demo/scripts/dungeon_builder.gd
Normal 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)
|
||||
1
demo/scripts/dungeon_builder.gd.uid
Normal file
1
demo/scripts/dungeon_builder.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://b2r6hnyvt7ef7
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
97
demo/scripts/export_tb_config.gd
Normal file
97
demo/scripts/export_tb_config.gd
Normal 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)
|
||||
1
demo/scripts/export_tb_config.gd.uid
Normal file
1
demo/scripts/export_tb_config.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://daojwolkldxxo
|
||||
|
|
@ -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
|
||||
|
|
@ -1 +0,0 @@
|
|||
uid://chq0nb6xd2e5w
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@warning_ignore("empty_file")
|
||||
extends Node3D
|
||||
|
||||
|
|
@ -1 +0,0 @@
|
|||
uid://yh36kisxu2q3
|
||||
Loading…
Add table
Add a link
Reference in a new issue