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.
This commit is contained in:
Jonas Reith 2026-08-27 22:31:47 +02:00
parent 6eb6758216
commit 9ea573d681
25 changed files with 685 additions and 9 deletions

111
autoload/arena_manager.gd Normal file
View File

@ -0,0 +1,111 @@
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)

View File

@ -0,0 +1 @@
uid://dmo00p6ex24u8

View File

@ -21,9 +21,14 @@ func _get_main_options() -> Array:
func handle_selection(action: String) -> void:
match action:
"explore":
# Platzhalter, bis echte Erkunden-Events pro POI existieren
InteractionUI.show_text("Das wurde besucht.")
var text := "Das wurde besucht."
if current_poi and current_poi.has_method("get_event_text"):
text = current_poi.get_event_text()
InteractionUI.show_text(text)
"rest":
var player := get_tree().get_first_node_in_group("player")
if player and player.has_method("heal_full"):
player.heal_full()
InteractionUI.show_text("Du rastest eine Weile...\nDu fühlst dich erholt.")
"close":
close()

6
boot/game_over_screen.gd Normal file
View File

@ -0,0 +1,6 @@
extends Control
@onready var menu_button: Button = $Panel/ButtonList/MenuButton
func _ready() -> void:
menu_button.pressed.connect(func(): get_tree().change_scene_to_file("res://boot/main_menu.tscn"))

View File

@ -0,0 +1 @@
uid://gjcsth4d4ey7

View File

@ -0,0 +1,42 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://boot/game_over_screen.gd" id="1_gameover"]
[node name="GameOverScreen" type="Control"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_gameover")
[node name="Panel" type="Panel" parent="."]
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -160.0
offset_top = -110.0
offset_right = 160.0
offset_bottom = 110.0
[node name="TitleLabel" type="Label" parent="Panel"]
anchor_right = 1.0
offset_top = 10.0
offset_bottom = 60.0
text = "Du bist gestorben"
horizontal_alignment = 1
vertical_alignment = 1
[node name="ButtonList" type="VBoxContainer" parent="Panel"]
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 20.0
offset_top = 70.0
offset_right = -20.0
offset_bottom = -20.0
theme_override_constants/separation = 10
[node name="MenuButton" type="Button" parent="Panel/ButtonList"]
layout_mode = 2
text = "Zurück zum Hauptmenü"

View File

@ -2,8 +2,20 @@ extends CharacterBody3D
@export var move_speed: float = 5.0
@export var gravity: float = 9.8
@export var max_health: int = 10
@export var attack_damage: int = 1
@export var attack_range: float = 1.0
@export var attack_cooldown: float = 0.5
signal health_changed(current: int, max: int)
var camera_rig: Node3D
var health: int
var is_dead: bool = false
var _attack_cooldown_left: float = 0.0
var _click_move_target: Vector3 = Vector3.ZERO
var _click_move_active: bool = false
@onready var sprite: AnimatedSprite3D = $AnimatedSprite3D
# Nur in world/island.tscn vorhanden, nicht in diesem Debug-Prototyp -> optional
@ -13,12 +25,68 @@ func _ready() -> void:
camera_rig = get_tree().get_first_node_in_group("camera_rig")
if camera_rig == null:
push_error("CameraRig nicht gefunden! Gruppe 'camera_rig' gesetzt?")
health = max_health
# Diablo-artig: Linksklick bewegt zusätzlich zu WASD, Leertaste greift an.
# Läuft über _unhandled_input, damit ein offenes Interaktions-Menü (Buttons
# verbrauchen den Klick/die Leertaste zuerst) automatisch Vorrang hat.
func _unhandled_input(event: InputEvent) -> void:
if InteractionManager.active:
return
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
_try_click_move(event.position)
elif event.is_action_pressed("attack"):
_try_attack()
func _try_click_move(screen_pos: Vector2) -> void:
if camera_rig == null:
return
var cam: Camera3D = camera_rig.camera
var from := cam.project_ray_origin(screen_pos)
var dir := cam.project_ray_normal(screen_pos)
var space_state := get_world_3d().direct_space_state
var query := PhysicsRayQueryParameters3D.create(from, from + dir * 1000.0)
query.exclude = [get_rid()]
var result := space_state.intersect_ray(query)
if result:
_click_move_target = result.position
_click_move_active = true
func _try_attack() -> void:
if _attack_cooldown_left > 0.0:
return
_attack_cooldown_left = attack_cooldown
for enemy in get_tree().get_nodes_in_group("enemies"):
if global_position.distance_to(enemy.global_position) <= attack_range:
enemy.take_damage(attack_damage)
func take_damage(amount: int) -> void:
if is_dead:
return
health = max(0, health - amount)
health_changed.emit(health, max_health)
if health <= 0:
_die()
func heal_full() -> void:
health = max_health
health_changed.emit(health, max_health)
func _die() -> void:
is_dead = true
get_tree().change_scene_to_file("res://boot/game_over_screen.tscn")
func _physics_process(delta: float) -> void:
# Bewegung sperren während Interaction
if InteractionManager.active:
velocity = Vector3.ZERO
return
if is_dead:
return
if _attack_cooldown_left > 0.0:
_attack_cooldown_left -= delta
# Schwerkraft
if not is_on_floor():
velocity.y -= gravity * delta
@ -30,6 +98,7 @@ func _physics_process(delta: float) -> void:
)
if input_dir != Vector2.ZERO:
_click_move_active = false # WASD hat Vorrang vor einem laufenden Klick-Ziel
var cam_basis = camera_rig.global_transform.basis
var forward = -cam_basis.z
var right = cam_basis.x
@ -43,6 +112,29 @@ func _physics_process(delta: float) -> void:
velocity.z = move_dir.z * move_speed
_update_animation(input_dir)
elif _click_move_active:
var to_target = _click_move_target - global_position
to_target.y = 0
if to_target.length() < 0.15:
_click_move_active = false
velocity.x = move_toward(velocity.x, 0, move_speed)
velocity.z = move_toward(velocity.z, 0, move_speed)
sprite.play("idle")
else:
var move_dir = to_target.normalized()
velocity.x = move_dir.x * move_speed
velocity.z = move_dir.z * move_speed
# Weltrichtung zurück in die bestehende bildschirmrelative
# Animationslogik übersetzen, statt eine zweite Logik zu bauen.
var cam_basis = camera_rig.global_transform.basis
var forward = -cam_basis.z
var right = cam_basis.x
forward.y = 0
right.y = 0
forward = forward.normalized()
right = right.normalized()
var screen_input := Vector2(move_dir.dot(right), -move_dir.dot(forward))
_update_animation(screen_input)
else:
velocity.x = move_toward(velocity.x, 0, move_speed)
velocity.z = move_toward(velocity.z, 0, move_speed)

View File

@ -19,6 +19,7 @@ config/features=PackedStringArray("4.6", "GL Compatibility")
InteractionManager="*uid://cbg1abspfwgxo"
InteractionUI="*uid://cmg2008xfel2v"
SaveManager="*res://autoload/save_manager.gd"
ArenaManager="*res://autoload/arena_manager.gd"
[display]
@ -87,6 +88,12 @@ interact={
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null)
]
}
attack={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":2,"pressure":0.0,"pressed":false,"script":null)
]
}
cancel={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)

View File

@ -0,0 +1,41 @@
[gd_scene format=3]
[sub_resource type="BoxMesh" id="BoxMesh_floor"]
size = Vector3(8, 0.5, 8)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_floor"]
shading_mode = 0
albedo_color = Color(0.45, 0.42, 0.38, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_floor"]
size = Vector3(8, 0.5, 8)
[sub_resource type="BoxMesh" id="BoxMesh_pillar"]
size = Vector3(1.5, 2, 1.5)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_pillar"]
shading_mode = 0
albedo_color = Color(0.3, 0.24, 0.2, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_pillar"]
size = Vector3(1.5, 2, 1.5)
[node name="ArenaTileObstacleA" type="StaticBody3D"]
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.25, 0)
mesh = SubResource("BoxMesh_floor")
surface_material_override/0 = SubResource("StandardMaterial3D_floor")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.25, 0)
shape = SubResource("BoxShape3D_floor")
[node name="Pillar" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.0, 0)
mesh = SubResource("BoxMesh_pillar")
surface_material_override/0 = SubResource("StandardMaterial3D_pillar")
[node name="PillarCollision" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.0, 0)
shape = SubResource("BoxShape3D_pillar")

View File

@ -0,0 +1,41 @@
[gd_scene format=3]
[sub_resource type="BoxMesh" id="BoxMesh_floor"]
size = Vector3(8, 0.5, 8)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_floor"]
shading_mode = 0
albedo_color = Color(0.4, 0.38, 0.42, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_floor"]
size = Vector3(8, 0.5, 8)
[sub_resource type="BoxMesh" id="BoxMesh_pillar"]
size = Vector3(1.5, 2, 1.5)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_pillar"]
shading_mode = 0
albedo_color = Color(0.22, 0.22, 0.26, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_pillar"]
size = Vector3(1.5, 2, 1.5)
[node name="ArenaTileObstacleB" type="StaticBody3D"]
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.25, 0)
mesh = SubResource("BoxMesh_floor")
surface_material_override/0 = SubResource("StandardMaterial3D_floor")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.25, 0)
shape = SubResource("BoxShape3D_floor")
[node name="Pillar" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.0, 0)
mesh = SubResource("BoxMesh_pillar")
surface_material_override/0 = SubResource("StandardMaterial3D_pillar")
[node name="PillarCollision" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.0, 0)
shape = SubResource("BoxShape3D_pillar")

View File

@ -0,0 +1,22 @@
[gd_scene format=3]
[sub_resource type="BoxMesh" id="BoxMesh_floor"]
size = Vector3(8, 0.5, 8)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_floor"]
shading_mode = 0
albedo_color = Color(0.45, 0.42, 0.38, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_floor"]
size = Vector3(8, 0.5, 8)
[node name="ArenaTilePlainA" type="StaticBody3D"]
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.25, 0)
mesh = SubResource("BoxMesh_floor")
surface_material_override/0 = SubResource("StandardMaterial3D_floor")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.25, 0)
shape = SubResource("BoxShape3D_floor")

View File

@ -0,0 +1,22 @@
[gd_scene format=3]
[sub_resource type="BoxMesh" id="BoxMesh_floor"]
size = Vector3(8, 0.5, 8)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_floor"]
shading_mode = 0
albedo_color = Color(0.4, 0.38, 0.42, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_floor"]
size = Vector3(8, 0.5, 8)
[node name="ArenaTilePlainB" type="StaticBody3D"]
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.25, 0)
mesh = SubResource("BoxMesh_floor")
surface_material_override/0 = SubResource("StandardMaterial3D_floor")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.25, 0)
shape = SubResource("BoxShape3D_floor")

View File

@ -0,0 +1,22 @@
[gd_scene format=3]
[sub_resource type="BoxMesh" id="BoxMesh_floor"]
size = Vector3(8, 0.5, 8)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_floor"]
shading_mode = 0
albedo_color = Color(0.42, 0.4, 0.32, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_floor"]
size = Vector3(8, 0.5, 8)
[node name="ArenaTilePlainC" type="StaticBody3D"]
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.25, 0)
mesh = SubResource("BoxMesh_floor")
surface_material_override/0 = SubResource("StandardMaterial3D_floor")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.25, 0)
shape = SubResource("BoxShape3D_floor")

View File

@ -0,0 +1,22 @@
[gd_scene format=3]
[sub_resource type="BoxMesh" id="BoxMesh_floor"]
size = Vector3(8, 0.5, 8)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_floor"]
shading_mode = 0
albedo_color = Color(0.38, 0.36, 0.36, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_floor"]
size = Vector3(8, 0.5, 8)
[node name="ArenaTilePlainD" type="StaticBody3D"]
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.25, 0)
mesh = SubResource("BoxMesh_floor")
surface_material_override/0 = SubResource("StandardMaterial3D_floor")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.25, 0)
shape = SubResource("BoxShape3D_floor")

55
world/enemy/enemy.gd Normal file
View File

@ -0,0 +1,55 @@
extends CharacterBody3D
@export var max_health: int = 3
@export var move_speed: float = 3.5
@export var aggro_range: float = 30.0
@export var contact_damage: int = 1
@export var contact_range: float = 0.7
@export var contact_cooldown: float = 1.0
@export var gravity: float = 9.8
var health: int
var _contact_timer: float = 0.0
func _ready() -> void:
health = max_health
add_to_group("enemies")
func _physics_process(delta: float) -> void:
if not is_on_floor():
velocity.y -= gravity * delta
else:
velocity.y = 0.0
if _contact_timer > 0.0:
_contact_timer -= delta
var player := get_tree().get_first_node_in_group("player")
if player:
var to_player: Vector3 = player.global_position - global_position
to_player.y = 0.0
var dist := to_player.length()
if dist <= aggro_range and dist > 0.01:
var dir := to_player.normalized()
velocity.x = dir.x * move_speed
velocity.z = dir.z * move_speed
else:
velocity.x = 0.0
velocity.z = 0.0
if dist <= contact_range and _contact_timer <= 0.0 and player.has_method("take_damage"):
player.take_damage(contact_damage)
_contact_timer = contact_cooldown
else:
velocity.x = 0.0
velocity.z = 0.0
move_and_slide()
func take_damage(amount: int) -> void:
if health <= 0:
return
health -= amount
if health <= 0:
health = 0
ArenaManager.on_enemy_died(self)

1
world/enemy/enemy.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://dc3e4i0qsn8kp

31
world/enemy/enemy.tscn Normal file
View File

@ -0,0 +1,31 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://world/enemy/enemy.gd" id="1_enemy"]
[ext_resource type="Texture2D" uid="uid://c3ajv7vb8c4fb" path="res://assets/debug/checkerboard.png" id="2_enemy"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_enemy"]
radius = 0.25
height = 0.6
[sub_resource type="SpriteFrames" id="SpriteFrames_enemy"]
animations = [{
"frames": [{
"duration": 1.0,
"texture": ExtResource("2_enemy")
}],
"loop": true,
"name": &"default",
"speed": 5.0
}]
[node name="Enemy" type="CharacterBody3D"]
script = ExtResource("1_enemy")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("CapsuleShape3D_enemy")
[node name="AnimatedSprite3D" type="AnimatedSprite3D" parent="."]
billboard = 2
pixel_size = 0.003
modulate = Color(1, 0.2, 0.2, 1)
sprite_frames = SubResource("SpriteFrames_enemy")

View File

@ -7,6 +7,8 @@
[ext_resource type="Script" path="res://player/equipment/equipment.gd" id="4_equip"]
[ext_resource type="PackedScene" uid="uid://be6ufrc3dsjge" path="res://poi.tscn" id="5_lquwl"]
[ext_resource type="PackedScene" path="res://world/poi/poi_gate.tscn" id="5_gate"]
[ext_resource type="PackedScene" path="res://world/poi/poi_arena.tscn" id="5_arena"]
[ext_resource type="PackedScene" path="res://world/ui/player_hud.tscn" id="9_hud"]
[ext_resource type="PackedScene" uid="uid://dmhs1oy1kh6hi" path="res://assets/models/mountain.glb" id="6_7mycd"]
[ext_resource type="PackedScene" uid="uid://gl4qpk4wto1e" path="res://assets/models/mountain2v5.glb" id="7_272bh"]
[ext_resource type="PackedScene" uid="uid://be2uq7ixlbv6b" path="res://assets/models/forest.glb" id="8_5vw27"]
@ -210,34 +212,37 @@ far = 500.0
[node name="POI" parent="." unique_id=1770000468 instance=ExtResource("5_lquwl")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2, 0.5, 2)
[node name="Ort1" parent="." instance=ExtResource("5_lquwl") groups=["region1_pois"]]
[node name="Ort1" parent="." instance=ExtResource("5_arena") groups=["region1_pois"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 24.15, -1.214, 6.47)
poi_name = "Ort 1"
poi_name = "Verlassenes Schlachtfeld"
marker_color = Color(1, 0.2, 0.2, 1)
[node name="Ort2" parent="." instance=ExtResource("5_lquwl") groups=["region1_pois"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 17.68, -1.214, 17.68)
poi_name = "Ort 2"
marker_color = Color(1, 0.6, 0.1, 1)
event_text = "Alte Fußspuren führen ins Nichts. Wer auch immer hier war, ist längst weitergezogen."
[node name="Ort3" parent="." instance=ExtResource("5_lquwl") groups=["region1_pois"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 37.17, -3.443, 7.9)
poi_name = "Ort 3"
marker_color = Color(1, 0.95, 0.2, 1)
event_text = "Ein verwittertes Wegzeichen weist auf ein Gebiet, das es nicht mehr gibt."
[node name="Ort4" parent="." instance=ExtResource("5_lquwl") groups=["region1_pois"]]
[node name="Ort4" parent="." instance=ExtResource("5_arena") groups=["region1_pois"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 25.43, -3.443, 28.24)
poi_name = "Ort 4"
poi_name = "Wachpfostenruine"
marker_color = Color(0.2, 0.9, 0.3, 1)
[node name="Ort5" parent="." instance=ExtResource("5_lquwl") groups=["region1_pois"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 19.05, -0.7, 11.0)
poi_name = "Ort 5"
marker_color = Color(0.2, 0.5, 1, 1)
event_text = "Der Wind trägt eine Melodie herbei, die du nicht greifen kannst."
[node name="Ort6" parent="." instance=ExtResource("5_lquwl") groups=["region1_pois"]]
[node name="Ort6" parent="." instance=ExtResource("5_arena") groups=["region1_pois"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 28.37, -3.1, 22.17)
poi_name = "Ort 6"
poi_name = "Vergessener Hinterhalt"
marker_color = Color(0.7, 0.3, 0.9, 1)
[node name="GateR1toR2" parent="." instance=ExtResource("5_gate")]
@ -517,3 +522,8 @@ shape = SubResource("CylinderShape3D_kek77")
[node name="CollisionShape3D3" type="CollisionShape3D" parent="Node3D3/forest2" unique_id=1876581014]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.084690094, -0.5371094, 0.40002155)
shape = SubResource("CylinderShape3D_kek77")
[node name="ArenaRoot" type="Node3D" parent="." groups=["arena_root"]]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -300, 0)
[node name="PlayerHUD" parent="." instance=ExtResource("9_hud")]

View File

@ -2,6 +2,7 @@ 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
@ -45,6 +46,9 @@ func _update_visuals() -> void:
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

60
world/poi/poi_arena.gd Normal file
View File

@ -0,0 +1,60 @@
extends Area3D
@export var poi_name: String = "Kampfarena"
@export var marker_color: Color = Color.WHITE
@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()
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
else:
name_label.text = "???"
sprite.modulate = marker_color * Color(0.4, 0.4, 0.4, 1.0)
func mark_visited() -> void:
visited = true
_update_visuals()
func _on_body_entered(body: Node3D) -> void:
if body.is_in_group("player"):
player_nearby = true
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"):
if not visited:
ArenaManager.start_arena(self)
else:
InteractionManager.open_message(self, poi_name, "Bereits bezwungen.")

View File

@ -0,0 +1 @@
uid://bltg23248s2y7

53
world/poi/poi_arena.tscn Normal file
View File

@ -0,0 +1,53 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://world/poi/poi_arena.gd" id="1_arena"]
[ext_resource type="Texture2D" uid="uid://c3ajv7vb8c4fb" path="res://assets/debug/checkerboard.png" id="2_arena"]
[sub_resource type="SphereShape3D" id="SphereShape3D_arena"]
radius = 0.6
[sub_resource type="SpriteFrames" id="SpriteFrames_arena"]
animations = [{
"frames": [{
"duration": 1.0,
"texture": ExtResource("2_arena")
}],
"loop": true,
"name": &"default",
"speed": 5.0
}]
[sub_resource type="CylinderShape3D" id="CylinderShape3D_arena"]
radius = 0.4
height = 1.0
[node name="POIArena" type="Area3D"]
script = ExtResource("1_arena")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("SphereShape3D_arena")
[node name="AnimatedSprite3D" type="AnimatedSprite3D" parent="."]
billboard = 2
pixel_size = 0.004
sprite_frames = SubResource("SpriteFrames_arena")
[node name="StaticBody3D" type="StaticBody3D" parent="."]
[node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D"]
shape = SubResource("CylinderShape3D_arena")
[node name="Billboard" type="Node3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
[node name="NameLabel" type="Label3D" parent="Billboard"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.7, 0)
billboard = 1
text = "???"
font_size = 16
[node name="InteractionPrompt" type="Label3D" parent="Billboard"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.55, 0)
visible = false
billboard = 1
font_size = 12

12
world/ui/player_hud.gd Normal file
View File

@ -0,0 +1,12 @@
extends CanvasLayer
@onready var label: Label = $Label
func _ready() -> void:
var player := get_tree().get_first_node_in_group("player")
if player and player.has_signal("health_changed"):
player.health_changed.connect(_on_health_changed)
_on_health_changed(player.health, player.max_health)
func _on_health_changed(current: int, max_hp: int) -> void:
label.text = "HP: %d / %d" % [current, max_hp]

View File

@ -0,0 +1 @@
uid://capiuygufek2t

13
world/ui/player_hud.tscn Normal file
View File

@ -0,0 +1,13 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://world/ui/player_hud.gd" id="1_hud"]
[node name="PlayerHUD" type="CanvasLayer"]
script = ExtResource("1_hud")
[node name="Label" type="Label" parent="."]
offset_left = 16.0
offset_top = 16.0
offset_right = 200.0
offset_bottom = 46.0
text = "HP: 10 / 10"