233 lines
6.9 KiB
GDScript
233 lines
6.9 KiB
GDScript
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")
|