wayfarers_wanderer/autoload/arena_manager.gd
Jonas Reith 9ea573d681 Add Diablo-style combat: player health/attack/click-move, enemies, tile-based arenas
Player (player/player.gd, shared by both island.tscn's real Player and
the player/player.tscn debug prototype via the same script):
- Health (max_health/health, take_damage, heal_full, death via
  change_scene_to_file to a new Game Over screen).
- Left-click move-to-point layered onto existing WASD (WASD cancels an
  active click-target and takes priority; click-move re-derives the
  existing screen-relative animation logic from the world-space
  direction instead of duplicating it). New "attack" input action
  (Space) does a simple range check against everything in the
  "enemies" group. Runs via _unhandled_input so an open interaction
  menu naturally takes priority (button consumes the event first).

New systems:
- world/enemy/enemy.gd + .tscn: direct-chase CharacterBody3D (no
  pathfinding), contact damage with its own cooldown, reports death to
  ArenaManager rather than freeing itself (which owns the live count).
- world/arena/tiles/: 6 placeholder tile scenes (4 plain floor colors,
  2 with a simple obstacle pillar) — pure static geometry, same
  unshaded-flat-color convention as terrain.gd.
- autoload/arena_manager.gd (new singleton): assembles a 3x3 grid of
  random tiles (center forced to plain floor) under a persistent
  ArenaRoot positioned far below the island (0,-300,0, group
  "arena_root"), rebuilding it fresh on every trigger since only one
  arena is ever active. Adds 4 axis-aligned invisible walls (same idea
  as terrain.gd's _build_edge_barrier, no rotation needed for a
  square). Spawns 3 enemies, teleports the player in. On the last
  enemy's death: calls the POI's mark_visited(), reuses
  InteractionManager.open_message() (existing, previously-unused code)
  for a "Sieg!" message, then teleports the player back after a timer.
- world/poi/poi_arena.gd + .tscn: same Area3D/sprite shell as poi.gd,
  but interacting starts an arena directly instead of opening the
  generic menu (same divergence pattern poi_gate.gd already
  established). Exposes the same duck-typed `visited` field, so
  poi_gate.gd's region-gate group check needed zero changes.
- boot/game_over_screen.gd + .tscn: same Control/Panel/Button
  structure as main_menu.tscn, one button back to the main menu.
- world/ui/player_hud.gd + .tscn: minimal CanvasLayer HP label.

Content pass: poi.gd gained `event_text` (falls back to the old flat
placeholder if unset); interaction_manager.gd's "explore" case uses it
and "rest" now actually calls player.heal_full(). Applied to region 1:
Ort1/Ort4/Ort6 became arena POIs, Ort2/Ort3/Ort5 got distinct event
text — first concrete proof of the "mix of events and arenas" pattern,
replicating to regions 2-6 is a fast follow-up.

Verified headless via direct method calls (confirmed limitation:
synthetic clicks/keypresses don't reliably drive _unhandled_input in
this harness): start_arena produces 9 tiles + a wall body + 3 enemies
and teleports the player near y=-300; killing all 3 enemies flips the
POI's visited flag and opens the win message; completing the win flow
returns the player next to the original POI; marking the other 5
region-1 POIs visited then correctly unlocks GateR1toR2 (proving the
arena-POI/gate integration without touching poi_gate.gd); player
take_damage(max_health) triggers is_dead and a scene change to
game_over_screen.tscn (confirmed after allowing deferred frames to
process). Also verified an enemy actually closes distance on the
player and lands contact damage over a live physics simulation, and
that the player's attack respects both damage and cooldown.

Not verifiable headlessly, needs in-editor testing: real click-to-move
and Space-bar attack end-to-end, combat feel/balance, arena tile
visuals.
2026-08-27 22:31:47 +02:00

112 lines
3.8 KiB
GDScript

extends Node
const TILE_SIZE := 8.0
const GRID_SIZE := 3
const ENEMY_COUNT := 3
const WIN_MESSAGE_SECONDS := 1.5
const WALL_HEIGHT := 6.0
const WALL_THICKNESS := 1.0
const PLAIN_TILES := [
preload("res://world/arena/tiles/arena_tile_plain_a.tscn"),
preload("res://world/arena/tiles/arena_tile_plain_b.tscn"),
preload("res://world/arena/tiles/arena_tile_plain_c.tscn"),
preload("res://world/arena/tiles/arena_tile_plain_d.tscn"),
]
const OBSTACLE_TILES := [
preload("res://world/arena/tiles/arena_tile_obstacle_a.tscn"),
preload("res://world/arena/tiles/arena_tile_obstacle_b.tscn"),
]
const ENEMY_SCENE := preload("res://world/enemy/enemy.tscn")
var _current_poi: Node = null
var _live_enemies: int = 0
# Baut die Arena bei jedem Aufruf komplett neu (nur eine gleichzeitig aktiv) und
# teleportiert den Spieler hinein. 3x3-Raster aus den Kacheln, Mittelfeld immer
# ein Boden-Stück (damit niemand auf einem Hindernis landet).
func start_arena(poi: Node) -> void:
_current_poi = poi
var arena_root := get_tree().get_first_node_in_group("arena_root")
if arena_root == null:
push_error("ArenaRoot nicht gefunden! Gruppe 'arena_root' gesetzt?")
return
for child in arena_root.get_children():
child.queue_free()
_build_walls(arena_root)
var half := float(GRID_SIZE) * TILE_SIZE * 0.5
var center_index := GRID_SIZE / 2
var spawn_cells: Array[Vector3] = []
for gx in range(GRID_SIZE):
for gz in range(GRID_SIZE):
var is_center := gx == center_index and gz == center_index
var palette := PLAIN_TILES if is_center else (PLAIN_TILES + OBSTACLE_TILES)
var tile_scene: PackedScene = palette[randi() % palette.size()]
var tile := tile_scene.instantiate()
var local_pos := Vector3(
gx * TILE_SIZE - half + TILE_SIZE * 0.5,
0.0,
gz * TILE_SIZE - half + TILE_SIZE * 0.5
)
tile.position = local_pos
arena_root.add_child(tile)
if not is_center:
spawn_cells.append(local_pos)
spawn_cells.shuffle()
_live_enemies = 0
for i in range(min(ENEMY_COUNT, spawn_cells.size())):
var enemy := ENEMY_SCENE.instantiate()
arena_root.add_child(enemy)
enemy.global_position = arena_root.global_position + spawn_cells[i] + Vector3(0, 1.0, 0)
_live_enemies += 1
var player := get_tree().get_first_node_in_group("player")
if player:
player.global_position = arena_root.global_position + Vector3(0, 1.0, 0)
func on_enemy_died(enemy: Node) -> void:
enemy.queue_free()
_live_enemies -= 1
if _live_enemies <= 0:
_on_win()
func _on_win() -> void:
var poi := _current_poi
if poi and poi.has_method("mark_visited"):
poi.mark_visited()
InteractionManager.open_message(poi, "Sieg!", "Die Gegner wurden besiegt.")
var timer := get_tree().create_timer(WIN_MESSAGE_SECONDS)
timer.timeout.connect(_on_win_message_done)
func _on_win_message_done() -> void:
InteractionManager.close()
var player := get_tree().get_first_node_in_group("player")
if player and _current_poi:
player.global_position = _current_poi.global_position + Vector3(0, 0.5, 0.6)
_current_poi = null
func _build_walls(arena_root: Node3D) -> void:
var half := float(GRID_SIZE) * TILE_SIZE * 0.5
var barrier := StaticBody3D.new()
barrier.name = "ArenaWalls"
arena_root.add_child(barrier)
var configs := [
{"size": Vector3(WALL_THICKNESS, WALL_HEIGHT, half * 2.0), "pos": Vector3(half, WALL_HEIGHT * 0.5, 0)},
{"size": Vector3(WALL_THICKNESS, WALL_HEIGHT, half * 2.0), "pos": Vector3(-half, WALL_HEIGHT * 0.5, 0)},
{"size": Vector3(half * 2.0, WALL_HEIGHT, WALL_THICKNESS), "pos": Vector3(0, WALL_HEIGHT * 0.5, half)},
{"size": Vector3(half * 2.0, WALL_HEIGHT, WALL_THICKNESS), "pos": Vector3(0, WALL_HEIGHT * 0.5, -half)},
]
for cfg in configs:
var box := BoxShape3D.new()
box.size = cfg["size"]
var cs := CollisionShape3D.new()
cs.shape = box
cs.position = cfg["pos"]
barrier.add_child(cs)