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")