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.
157 lines
4.7 KiB
GDScript
Executable File
157 lines
4.7 KiB
GDScript
Executable File
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
|
|
@onready var equipment: Equipment = get_node_or_null("Equipment")
|
|
|
|
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
|
|
|
|
# Eingabe
|
|
var input_dir = Vector2(
|
|
Input.get_axis("move_left", "move_right"),
|
|
Input.get_axis("move_forward", "move_backward")
|
|
)
|
|
|
|
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
|
|
forward.y = 0
|
|
right.y = 0
|
|
forward = forward.normalized()
|
|
right = right.normalized()
|
|
|
|
var move_dir = (forward * -input_dir.y + right * input_dir.x).normalized()
|
|
velocity.x = move_dir.x * move_speed
|
|
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)
|
|
sprite.play("idle")
|
|
|
|
move_and_slide()
|
|
|
|
func _update_animation(input_dir: Vector2) -> void:
|
|
|
|
if input_dir.y > 0.1:
|
|
sprite.play("move_down")
|
|
elif input_dir.y < -0.1:
|
|
sprite.play("move_up")
|
|
elif input_dir.x > 0.1:
|
|
sprite.play("move_right")
|
|
elif input_dir.x < -0.1:
|
|
sprite.play("move_left")
|
|
else:
|
|
sprite.play("idle")
|