From 9ea573d68149ad6cdde5ce90024a24791a82e12c Mon Sep 17 00:00:00 2001 From: Jonas Reith Date: Thu, 27 Aug 2026 22:31:47 +0200 Subject: [PATCH] Add Diablo-style combat: player health/attack/click-move, enemies, tile-based arenas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- autoload/arena_manager.gd | 111 +++++++++++++++++++ autoload/arena_manager.gd.uid | 1 + autoload/interaction_manager.gd | 9 +- boot/game_over_screen.gd | 6 + boot/game_over_screen.gd.uid | 1 + boot/game_over_screen.tscn | 42 +++++++ player/player.gd | 94 +++++++++++++++- project.godot | 7 ++ world/arena/tiles/arena_tile_obstacle_a.tscn | 41 +++++++ world/arena/tiles/arena_tile_obstacle_b.tscn | 41 +++++++ world/arena/tiles/arena_tile_plain_a.tscn | 22 ++++ world/arena/tiles/arena_tile_plain_b.tscn | 22 ++++ world/arena/tiles/arena_tile_plain_c.tscn | 22 ++++ world/arena/tiles/arena_tile_plain_d.tscn | 22 ++++ world/enemy/enemy.gd | 55 +++++++++ world/enemy/enemy.gd.uid | 1 + world/enemy/enemy.tscn | 31 ++++++ world/island.tscn | 22 +++- world/poi/poi.gd | 4 + world/poi/poi_arena.gd | 60 ++++++++++ world/poi/poi_arena.gd.uid | 1 + world/poi/poi_arena.tscn | 53 +++++++++ world/ui/player_hud.gd | 12 ++ world/ui/player_hud.gd.uid | 1 + world/ui/player_hud.tscn | 13 +++ 25 files changed, 685 insertions(+), 9 deletions(-) create mode 100644 autoload/arena_manager.gd create mode 100644 autoload/arena_manager.gd.uid create mode 100644 boot/game_over_screen.gd create mode 100644 boot/game_over_screen.gd.uid create mode 100644 boot/game_over_screen.tscn create mode 100644 world/arena/tiles/arena_tile_obstacle_a.tscn create mode 100644 world/arena/tiles/arena_tile_obstacle_b.tscn create mode 100644 world/arena/tiles/arena_tile_plain_a.tscn create mode 100644 world/arena/tiles/arena_tile_plain_b.tscn create mode 100644 world/arena/tiles/arena_tile_plain_c.tscn create mode 100644 world/arena/tiles/arena_tile_plain_d.tscn create mode 100644 world/enemy/enemy.gd create mode 100644 world/enemy/enemy.gd.uid create mode 100644 world/enemy/enemy.tscn create mode 100644 world/poi/poi_arena.gd create mode 100644 world/poi/poi_arena.gd.uid create mode 100644 world/poi/poi_arena.tscn create mode 100644 world/ui/player_hud.gd create mode 100644 world/ui/player_hud.gd.uid create mode 100644 world/ui/player_hud.tscn diff --git a/autoload/arena_manager.gd b/autoload/arena_manager.gd new file mode 100644 index 0000000..a376f3e --- /dev/null +++ b/autoload/arena_manager.gd @@ -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) diff --git a/autoload/arena_manager.gd.uid b/autoload/arena_manager.gd.uid new file mode 100644 index 0000000..9951d0f --- /dev/null +++ b/autoload/arena_manager.gd.uid @@ -0,0 +1 @@ +uid://dmo00p6ex24u8 diff --git a/autoload/interaction_manager.gd b/autoload/interaction_manager.gd index 04bbf20..e9f5886 100755 --- a/autoload/interaction_manager.gd +++ b/autoload/interaction_manager.gd @@ -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() diff --git a/boot/game_over_screen.gd b/boot/game_over_screen.gd new file mode 100644 index 0000000..b099238 --- /dev/null +++ b/boot/game_over_screen.gd @@ -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")) diff --git a/boot/game_over_screen.gd.uid b/boot/game_over_screen.gd.uid new file mode 100644 index 0000000..c5b24da --- /dev/null +++ b/boot/game_over_screen.gd.uid @@ -0,0 +1 @@ +uid://gjcsth4d4ey7 diff --git a/boot/game_over_screen.tscn b/boot/game_over_screen.tscn new file mode 100644 index 0000000..1a75c4a --- /dev/null +++ b/boot/game_over_screen.tscn @@ -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ü" diff --git a/player/player.gd b/player/player.gd index 35e8f89..09a313d 100755 --- a/player/player.gd +++ b/player/player.gd @@ -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) diff --git a/project.godot b/project.godot index 42efbc5..97f935a 100755 --- a/project.godot +++ b/project.godot @@ -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) diff --git a/world/arena/tiles/arena_tile_obstacle_a.tscn b/world/arena/tiles/arena_tile_obstacle_a.tscn new file mode 100644 index 0000000..742ee8e --- /dev/null +++ b/world/arena/tiles/arena_tile_obstacle_a.tscn @@ -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") diff --git a/world/arena/tiles/arena_tile_obstacle_b.tscn b/world/arena/tiles/arena_tile_obstacle_b.tscn new file mode 100644 index 0000000..fd4fa21 --- /dev/null +++ b/world/arena/tiles/arena_tile_obstacle_b.tscn @@ -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") diff --git a/world/arena/tiles/arena_tile_plain_a.tscn b/world/arena/tiles/arena_tile_plain_a.tscn new file mode 100644 index 0000000..3696c45 --- /dev/null +++ b/world/arena/tiles/arena_tile_plain_a.tscn @@ -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") diff --git a/world/arena/tiles/arena_tile_plain_b.tscn b/world/arena/tiles/arena_tile_plain_b.tscn new file mode 100644 index 0000000..b9f48bd --- /dev/null +++ b/world/arena/tiles/arena_tile_plain_b.tscn @@ -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") diff --git a/world/arena/tiles/arena_tile_plain_c.tscn b/world/arena/tiles/arena_tile_plain_c.tscn new file mode 100644 index 0000000..172883f --- /dev/null +++ b/world/arena/tiles/arena_tile_plain_c.tscn @@ -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") diff --git a/world/arena/tiles/arena_tile_plain_d.tscn b/world/arena/tiles/arena_tile_plain_d.tscn new file mode 100644 index 0000000..a34e95c --- /dev/null +++ b/world/arena/tiles/arena_tile_plain_d.tscn @@ -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") diff --git a/world/enemy/enemy.gd b/world/enemy/enemy.gd new file mode 100644 index 0000000..2b9a69d --- /dev/null +++ b/world/enemy/enemy.gd @@ -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) diff --git a/world/enemy/enemy.gd.uid b/world/enemy/enemy.gd.uid new file mode 100644 index 0000000..1b7173a --- /dev/null +++ b/world/enemy/enemy.gd.uid @@ -0,0 +1 @@ +uid://dc3e4i0qsn8kp diff --git a/world/enemy/enemy.tscn b/world/enemy/enemy.tscn new file mode 100644 index 0000000..77067be --- /dev/null +++ b/world/enemy/enemy.tscn @@ -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") diff --git a/world/island.tscn b/world/island.tscn index 033badd..9e03dca 100755 --- a/world/island.tscn +++ b/world/island.tscn @@ -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")] diff --git a/world/poi/poi.gd b/world/poi/poi.gd index 2184001..8811b5e 100755 --- a/world/poi/poi.gd +++ b/world/poi/poi.gd @@ -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 diff --git a/world/poi/poi_arena.gd b/world/poi/poi_arena.gd new file mode 100644 index 0000000..4171bb2 --- /dev/null +++ b/world/poi/poi_arena.gd @@ -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.") diff --git a/world/poi/poi_arena.gd.uid b/world/poi/poi_arena.gd.uid new file mode 100644 index 0000000..7a8bab9 --- /dev/null +++ b/world/poi/poi_arena.gd.uid @@ -0,0 +1 @@ +uid://bltg23248s2y7 diff --git a/world/poi/poi_arena.tscn b/world/poi/poi_arena.tscn new file mode 100644 index 0000000..87e3922 --- /dev/null +++ b/world/poi/poi_arena.tscn @@ -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 diff --git a/world/ui/player_hud.gd b/world/ui/player_hud.gd new file mode 100644 index 0000000..bd67a48 --- /dev/null +++ b/world/ui/player_hud.gd @@ -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] diff --git a/world/ui/player_hud.gd.uid b/world/ui/player_hud.gd.uid new file mode 100644 index 0000000..29e4b30 --- /dev/null +++ b/world/ui/player_hud.gd.uid @@ -0,0 +1 @@ +uid://capiuygufek2t diff --git a/world/ui/player_hud.tscn b/world/ui/player_hud.tscn new file mode 100644 index 0000000..5ed68ad --- /dev/null +++ b/world/ui/player_hud.tscn @@ -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"