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

70 lines
2.3 KiB
GDScript
Executable File

extends Area3D
@export var poi_name: String = "Ruinen"
@export var marker_color: Color = Color.WHITE
@export var event_text: String = ""
@onready var sprite: AnimatedSprite3D = $AnimatedSprite3D
@onready var name_label: Label3D = $Billboard/NameLabel
@onready var prompt: Label3D = $Billboard/InteractionPrompt
var player_nearby: bool = false
var visited: bool = false
func _ready() -> void:
prompt.visible = false
prompt.text = "[F] Interact"
_update_visuals()
body_entered.connect(_on_body_entered)
body_exited.connect(_on_body_exited)
# Terrain-Kollision braucht mind. einen Physik-Tick um registriert zu sein
await get_tree().physics_frame
_snap_to_terrain()
# Handplatzierte Y-Werte treffen die geneigten Facetten nicht exakt (die Höhe
# ist keine reine Funktion des Radius, siehe terrain.gd). Statt jede Position
# neu von Hand zu berechnen: per Raycast auf die echte Terrain-Oberfläche
# schnappen, damit Sprite/Hitbox garantiert am Boden sitzen.
func _snap_to_terrain() -> void:
var space_state := get_world_3d().direct_space_state
var from := global_position + Vector3(0, 20, 0)
var to := global_position + Vector3(0, -20, 0)
var query := PhysicsRayQueryParameters3D.create(from, to)
query.exclude = [get_rid(), $StaticBody3D.get_rid()]
var result := space_state.intersect_ray(query)
if result:
global_position.y = result.position.y + 0.5
func _update_visuals() -> void:
if visited:
name_label.text = poi_name
sprite.modulate = marker_color
name_label.modulate = Color(1.0, 1.0, 1.0, 1.0)
else:
name_label.text = "???"
# Ausgegraut, behält aber die Kennfarbe
sprite.modulate = marker_color * Color(0.1, 0.1, 0.1, 1.0)
name_label.modulate = Color(0.6, 0.6, 0.6, 1.0)
func get_event_text() -> String:
return event_text if event_text != "" else "Das wurde besucht."
func _on_body_entered(body: Node3D) -> void:
if body.is_in_group("player"):
player_nearby = true
prompt.text = "[F] Interact"
prompt.visible = true
func _on_body_exited(body: Node3D) -> void:
if body.is_in_group("player"):
player_nearby = false
prompt.visible = false
func _input(event: InputEvent) -> void:
if player_nearby and Input.is_action_just_pressed("interact"):
# Beim ersten Besuch entdecken
if not visited:
visited = true
_update_visuals()
InteractionManager.open_poi(self)