bgen/demo/scripts/arcade/arcade_scene.gd

234 lines
6.9 KiB
GDScript3
Raw Normal View History

2026-04-16 21:04:50 -04:00
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")