init
This commit is contained in:
commit
e45f121fb9
89 changed files with 336069 additions and 0 deletions
233
demo/scripts/arcade/arcade_scene.gd
Normal file
233
demo/scripts/arcade/arcade_scene.gd
Normal 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")
|
||||
1
demo/scripts/arcade/arcade_scene.gd.uid
Normal file
1
demo/scripts/arcade/arcade_scene.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://3hwwo1hwe12
|
||||
174
demo/scripts/arcade/enemy.gd
Normal file
174
demo/scripts/arcade/enemy.gd
Normal 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()
|
||||
1
demo/scripts/arcade/enemy.gd.uid
Normal file
1
demo/scripts/arcade/enemy.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dr7jelri306sd
|
||||
139
demo/scripts/arcade/grid_util.gd
Normal file
139
demo/scripts/arcade/grid_util.gd
Normal 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)
|
||||
1
demo/scripts/arcade/grid_util.gd.uid
Normal file
1
demo/scripts/arcade/grid_util.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://buo0sbr01qcm5
|
||||
57
demo/scripts/arcade/hud.gd
Normal file
57
demo/scripts/arcade/hud.gd
Normal 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
|
||||
1
demo/scripts/arcade/hud.gd.uid
Normal file
1
demo/scripts/arcade/hud.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dd7ud5jyshij5
|
||||
118
demo/scripts/arcade/player_arcade.gd
Normal file
118
demo/scripts/arcade/player_arcade.gd
Normal 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()
|
||||
1
demo/scripts/arcade/player_arcade.gd.uid
Normal file
1
demo/scripts/arcade/player_arcade.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dxyvug2vl2jjq
|
||||
Loading…
Add table
Add a link
Reference in a new issue