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