Account System

This commit is contained in:
torsten 2026-03-03 09:22:18 +01:00
parent 10a4b1a754
commit 5060770c4c
11 changed files with 347 additions and 85 deletions

View file

@ -0,0 +1,19 @@
class_name AccountData extends Resource
var username: String = ""
var password_hash: String = ""
var characters: Array[CharacterData] = [] # Characters associated with this account
func _init(p_username: String = "", p_password_hash: String = ""):
username = p_username
password_hash = p_password_hash
func to_dict() -> Dictionary:
return {
"username": username,
"password_hash": password_hash
}
func from_dict(data: Dictionary) -> void:
username = data.get("username", username)
password_hash = data.get("password_hash", password_hash)

View file

@ -0,0 +1 @@
uid://8bnlnlh750b4

View file

@ -1,16 +1,28 @@
class_name CharacterData
class_name CharacterData extends Resource
var id: String = "" # UUID des Charakters
var name: String = "Neuer Held"
var character_name: String = "Neuer Held"
var level: int = 1
var area: String = "keuwin_grasland"
var position: Vector3 = Vector3.ZERO
func _init():
if id == "":
generate_id()
func generate_id() -> void:
# Generate a new UUID and assign it to the 'id' property.
# Godot 4 uses Crypto for UUID generation.
var crypto = Crypto.new()
var bytes = crypto.generate_random_bytes(16) # 16 bytes for UUID
id = bytes.hex_encode()
# Erstellt ein Dictionary – Vector3 bleibt ein Vector3!
func to_dict() -> Dictionary:
return {
"id": id,
"name": name,
"character_name": character_name,
"level": level,
"area": area,
"position": position
@ -18,7 +30,7 @@ func to_dict() -> Dictionary:
func from_dict(data: Dictionary) -> void:
id = data.get("id", id)
name = data.get("name", name)
character_name = data.get("character_name", character_name)
level = data.get("level", level)
area = data.get("area", area)
position = data.get("position", position)

View file

@ -11,3 +11,4 @@ signal player_area_join_requested(area_key: String)
signal network_show_characters(characters:Array)
signal network_new_character_received(character: CharacterData)
signal network_login_response(success: bool) # Renamed to avoid confusion with internal RPC, emits only success/failure

View file

@ -3,6 +3,11 @@ extends Node
const PORT = 7777
const MAX_CLIENTS = 32
# AccountData is a global class_name, no need to preload.
# Utils is a global class_name, no need to preload.
# const AccountData = preload("res://01_core/base_classes/account_data.gd")
# const Utils = preload("res://01_core/systems/utils.gd")
# Kamera (wird nur auf dem Client gebraucht)
var local_camera: Camera3D = null
@ -15,10 +20,16 @@ var area_paths = {
# Server-seitiges Tracking: peer_id -> SubViewportContainer
var player_worlds = {}
# Server-seitiges Tracking: peer_id -> username
var _player_account_map: Dictionary[int, String] = {}
# Struktur: "username" -> { "password_hash": String, "chars": Array[CharacterData] }
var _accounts: Dictionary = {}
# Struktur: "username" -> AccountData
var _accounts: Dictionary[String, AccountData] = {}
# Temporäre Speicherung für Client-Login-Daten
var _temp_username: String = ""
var _temp_password_hash: String = ""
# Der Pfad, wo die Benutzerdaten liegen
const SAVE_PATH = "user://accounts/"
@ -30,6 +41,8 @@ func _ready():
GameEvents.ui_join_requested.connect(join_game)
GameEvents.ui_character_created.connect(request_character_store)
multiplayer.connected_to_server.connect(_on_connected_to_server)
if DisplayServer.get_name() == "headless" or OS.get_cmdline_args().has("--server"):
host_game()
@ -48,17 +61,39 @@ func host_game():
_load_all_accounts_from_disk()
func save_character(username: String, char_data: Resource) -> void: # Die Resouce ist CharacterData
print("NetworkManager: save_character called for username: ", username, " char_data: ", char_data)
if not is_instance_valid(char_data):
print("ERROR: save_character: char_data is not a valid instance. Cannot save.")
return
if char_data.id.is_empty():
print("ERROR: save_character: char_data.id is empty. Cannot save.")
return
var account_path = SAVE_PATH + username + "/chars/"
if not DirAccess.dir_exists_absolute(account_path):
DirAccess.make_dir_recursive_absolute(account_path)
# Dateiname ist die UUID des Charakters
var file_path = account_path + char_data.guid + ".tres"
var error = ResourceSaver.save(char_data as Resource, file_path)
var file_path = account_path + char_data.id + ".tres"
var error = ResourceSaver.save(char_data as Resource, file_path) # Cast again just in case
if error == OK:
print("Server: Charakter %s gespeichert." % char_data.character_name)
print("Server: Charakter %s (ID: %s) gespeichert." % [char_data.character_name, char_data.id])
else:
print("Server: Fehler beim Speichern von Charakter %s (ID: %s): %s" % [char_data.character_name, char_data.id, error])
func save_account(account_data: AccountData) -> void:
var user_path = SAVE_PATH + account_data.username + "/"
if not DirAccess.dir_exists_absolute(user_path):
DirAccess.make_dir_recursive_absolute(user_path)
var file_path = user_path + "account_info.tres"
var error = ResourceSaver.save(account_data as Resource, file_path)
if error == OK:
print("Server: Account %s gespeichert." % account_data.username)
else:
print("Server: Fehler beim Speichern von Account %s: %s" % [account_data.username, error])
func _load_all_accounts_from_disk():
var dir = DirAccess.open(SAVE_PATH)
@ -66,29 +101,51 @@ func _load_all_accounts_from_disk():
dir.list_dir_begin()
var user_dir = dir.get_next()
while user_dir != "":
if dir.current_is_dir() and not user_dir.begin_with("."):
if dir.current_is_dir() and not user_dir.begins_with("."):
_load_account(user_dir)
user_dir = dir.get_next()
func _load_account(username: String):
var char_path = SAVE_PATH + username + "/chars/"
var characters: Array[CharacterData] = []
func _load_account(username: String) -> void:
var user_path = SAVE_PATH + username + "/"
var account_file_path = user_path + "account_info.tres"
var account_data: AccountData = null
if DirAccess.dir_exists_absolute(char_path):
var dir = DirAccess.open(char_path)
dir.list_dir_begin()
var file_name = dir.get_next()
while file_name != "":
if file_name.ends_with(".tres"):
var res = ResourceLoader.load(char_path + file_name)
if res is CharacterData:
characters.append(res)
file_name = dir.get_next()
if ResourceLoader.exists(account_file_path):
var res = ResourceLoader.load(account_file_path)
if res is AccountData:
account_data = res
# If account_data loaded from disk has no password, this indicates old/corrupted data.
# For security, we should not assume a blank password. It should be set on first login.
# If it's empty, a new login with proper hash should overwrite it.
if account_data.password_hash.is_empty():
print("WARNING: Account %s loaded with empty password hash. Will require fresh login to set password." % username)
else:
print("ERROR: Loaded resource for account %s is not AccountData. Skipping account load." % username)
return # Do not load this account if it's corrupted
else:
# If the account info file does not exist, do not create a new AccountData here with a blank password.
# A new account will be created by srv_handle_login_request when the client first attempts to log in.
print("INFO: Account info file not found for %s. Will be created on first login attempt." % username)
return # Skip loading if file doesn't exist yet.
_accounts[username] = {
"password_hash": "...", # Hier würde man eine account_info.json laden
"characters": characters
}
# At this point, account_data should be a valid AccountData instance loaded from disk.
if account_data:
# Load characters for this account
var char_path = user_path + "chars/"
if DirAccess.dir_exists_absolute(char_path):
var dir = DirAccess.open(char_path)
dir.list_dir_begin()
var file_name = dir.get_next()
while file_name != "":
if file_name.ends_with(".tres"):
var res = ResourceLoader.load(char_path + file_name)
if res is CharacterData:
account_data.characters.append(res)
file_name = dir.get_next()
_accounts[username] = account_data # Store AccountData instance
else:
print("ERROR: AccountData is null for user %s after attempted load. This should not happen." % username)
func join_game(ip: String, username: String, password: String):
@ -97,16 +154,40 @@ func join_game(ip: String, username: String, password: String):
peer.create_client(ip, PORT)
multiplayer.multiplayer_peer = peer
func _on_player_connected(id):
# Temporarily store credentials for when the client connects to the server
_temp_username = username
# The password needs to be hashed before storing if it's not already.
# It's better to hash it once here before storing.
_temp_password_hash = load("res://01_core/systems/utils.gd").hash_password(password) # Hash here once
# The RPC call will be sent after successful connection in _on_connected_to_server.
func _on_connected_to_server():
print("Client: Successfully connected to server.")
# Now that connection is established, send the login request to the server
if not multiplayer.is_server(): # Ensure this logic only runs on the client
print("Client: About to send srv_handle_login_request RPC for user: ", _temp_username)
# Use the stored temporary credentials for the RPC call
get_node("/root/NetworkManager").rpc_id(1, "srv_handle_login_request", _temp_username, _temp_password_hash)
_temp_username = "" # Clear temporary storage
_temp_password_hash = "" # Clear temporary storage
func _on_player_connected(id): # This is for server-side peer_connected
print("Player connected with id:", id)
player_worlds[id] = null # SubViewport wird erst connected, wenn area geladen wurde.
cl_show_characters.rpc_id(id, [])
# Characters are sent after successful login via cl_login_response.
# No need to send an empty list here anymore.
@rpc("authority", "call_remote", "reliable")
func cl_show_characters(characters):
func cl_show_characters(characters: Array):
print("Got client_show_characters")
GameEvents.network_show_characters.emit(characters)
# Convert dictionaries back to CharacterData instances before emitting
var character_instances: Array[CharacterData] = []
for char_dict in characters:
var new_character = CharacterData.new()
new_character.from_dict(char_dict)
character_instances.append(new_character)
GameEvents.network_show_characters.emit(character_instances)
func _on_player_disconnected(id):
@ -120,7 +201,39 @@ func _on_player_disconnected(id):
print("Removing player ", id, " from internal tracking.")
player_worlds.erase(id)
func change_player_area(id: int, area_key: String):
@rpc("any_peer", "call_remote", "reliable")
func srv_handle_area_change_request(area_key: String, character_id: String):
print("Server received srv_handle_area_change_request for char_id: ", character_id)
var peer_id = multiplayer.get_remote_sender_id()
if not player_worlds.has(peer_id):
print("ERROR: Server srv_handle_area_change_request could not be handled because client peer_id is not in player_worlds list")
return
if not _player_account_map.has(peer_id):
print("ERROR: Server srv_handle_area_change_request could not be handled because peer_id %d is not logged in." % peer_id)
return
var username = _player_account_map[peer_id]
if not _accounts.has(username):
print("ERROR: Server srv_handle_area_change_request could not be handled because account %s does not exist." % username)
return
var selected_character: CharacterData = null
for char in _accounts[username].characters:
if char.id == character_id:
selected_character = char
break
if not selected_character:
print("ERROR: Server srv_handle_area_change_request: Character with ID %s not found for user %s." % [character_id, username])
return
print("Server: Player %d (%s) requests area change to %s with character %s (ID: %s)" % [peer_id, username, area_key, selected_character.character_name, selected_character.id])
# Pass peer_id and selected_character to change_player_area for further processing
change_player_area(peer_id, area_key, selected_character)
func change_player_area(id: int, area_key: String, character_data: CharacterData):
if not multiplayer.is_server(): return
print("Server change_player_area: Server is loading ", area_key)
var path = area_paths[area_key]
@ -152,23 +265,30 @@ func change_player_area(id: int, area_key: String):
# --- ALTEN ZUSTAND AUFRÄUMEN ---
if player_worlds.has(id):
# Get the old container node to remove the player from the old area
var old_container = player_worlds[id]
if old_container:
var old_viewport = old_container.get_child(0)
var player_node = old_viewport.get_child(0).get_node("SpawnPoints").get_node(str(id))
if player_node:
player_node.queue_free()
player_worlds.erase(id)
# --- NEUEN ZUSTAND REGISTRIEREN ---
player_worlds[id] = container_node
# Client anweisen, Szene zu laden
cl_load_area.rpc_id(id, area_key)
cl_load_area.rpc_id(id, area_key, character_data.id) # Pass character_data.id to client
# Avatar auf Server erstellen erfolgt erst wenn Client Vollzug meldet (client_ready_to_spawn)
@rpc("any_peer", "call_local", "reliable")
func client_ready_to_spawn(area_key:String):
var id = multiplayer.get_remote_sender_id()
func client_ready_to_spawn(area_key:String, character_id: String): # Added character_id
var peer_id = multiplayer.get_remote_sender_id()
if not multiplayer.is_server(): return
if player_worlds.has(id):
var container_node = player_worlds[id]
if player_worlds.has(peer_id):
var container_node = player_worlds[peer_id]
var viewport_node = container_node.get_node(area_key+"_viewport")
var spawn_target_node = viewport_node.get_node(area_key).get_node("SpawnPoints")
@ -176,12 +296,24 @@ func client_ready_to_spawn(area_key:String):
print("Viewport Node:", viewport_node)
print("spawn target Node:", spawn_target_node)
print("Client ", id, " bereits für Spawning.")
_spawn_player_in_world(id, spawn_target_node)
print("Client ", peer_id, " bereits für Spawning.")
var username = _player_account_map[peer_id]
var account_data: AccountData = _accounts[username]
var selected_character: CharacterData = null
for char in account_data.characters:
if char.id == character_id:
selected_character = char
break
if selected_character:
_spawn_player_in_world(peer_id, spawn_target_node, selected_character) # Pass selected_character
else:
print("ERROR: client_ready_to_spawn: Character with ID %s not found for user %s." % [character_id, username])
@rpc("authority", "call_remote", "reliable")
func cl_load_area(area_key):
func cl_load_area(area_key: String, character_id: String): # Added character_id
print("Client loads area")
# Client löscht alte Map, lädt neue lokal in das Root
for child in get_children():
@ -214,7 +346,8 @@ func cl_load_area(area_key):
setup_client_camera(viewport_node)
# Jetzt können wir dem Server ok fürs Spawnen geben
client_ready_to_spawn.rpc_id(1, area_key) # Server ist immer id=1
client_ready_to_spawn.rpc_id(1, area_key, character_id) # Pass character_id
func setup_client_camera(node):
local_camera = Camera3D.new()
@ -226,11 +359,11 @@ func setup_client_camera(node):
local_camera.add_child(AudioListener3D.new())
func _spawn_player_in_world(id, spawn_target_node):
func _spawn_player_in_world(id: int, spawn_target_node: Node, character_data: CharacterData): # Added character_data
var player_scene = load("res://04_entities/player/player.tscn")
var player = player_scene.instantiate()
player.name = str(id)
player.get_node("PlayerNameLabel").text = str(id)
player.get_node("PlayerNameLabel").text = character_data.character_name # Use character name
#player.set_multiplayer_authority(id)
player.set_multiplayer_authority(1)
@ -266,30 +399,6 @@ func cl_assign_camera_target(node_name: String):
# var camera = get_viewport().get_camera_3d()
@rpc("any_peer", "call_remote", "reliable")
func srv_handle_area_change_request(area_key: String):
print("Server received srv_handle_area_change_request")
var id = multiplayer.get_remote_sender_id()
if not player_worlds.has(id):
print("ERROR: Server srv_handle_area_change_request could not be handled because client peer_id is not in player_worlds list")
return
print("Server: Player ", id, " requests area change to ", area_key)
# 1. Alten Spieler finden und entfernen falls vorhanden
var old_container = player_worlds[id]
if old_container:
print("container name=", old_container)
var old_viewport = old_container.get_child(0)
print("Viewport name=", old_viewport)
var player_node = old_viewport.get_child(0).get_node("SpawnPoints").get_node(str(id))
if player_node:
player_node.queue_free()
# 2. Bestehende Funktion für den Wechsel aufrufen
change_player_area(id, area_key)
func request_character_store(new_character: CharacterData):
var character_dict = new_character.to_dict()
srv_store_new_character.rpc_id(1,character_dict)
@ -298,12 +407,85 @@ func request_character_store(new_character: CharacterData):
func srv_store_new_character(character_dict: Dictionary):
var new_character = CharacterData.new()
new_character.from_dict(character_dict)
if not is_instance_valid(new_character) or new_character.id.is_empty():
print("ERROR: srv_store_new_character: new_character is invalid or has no ID after from_dict. Dict: ", character_dict)
return # Prevent further errors
# TODO: In Zukunft erhalten neue Charaktere einen area_key und position gesetzt
var client_id = multiplayer.get_remote_sender_id()
cl_receive_new_character.rpc_id(client_id, new_character.to_dict())
# Add the character to the account
if _player_account_map.has(client_id):
var username = _player_account_map[client_id]
if _accounts.has(username):
var account_data: AccountData = _accounts[username]
account_data.characters.append(new_character)
save_account(account_data) # Save the updated account
# Explicitly cast new_character to Resource to satisfy type hint.
save_character(username, new_character as Resource) # Save the new character as a separate .tres
cl_receive_new_character.rpc_id(client_id, new_character.to_dict()) # Pass dictionary
@rpc("authority", "call_remote", "reliable")
func cl_receive_new_character(char_dict: Dictionary):
var new_character = CharacterData.new()
new_character.from_dict(char_dict)
GameEvents.network_new_character_received.emit(new_character)
@rpc("any_peer", "call_remote", "reliable")
func srv_handle_login_request(username: String, password_hash: String):
var peer_id = multiplayer.get_remote_sender_id()
var account_data: AccountData = null
if _accounts.has(username):
account_data = _accounts[username]
if account_data.password_hash.is_empty():
# Account exists but has no password hash set (e.g., loaded from old/initial save).
# Set the password hash from the client's first login attempt.
account_data.password_hash = password_hash
save_account(account_data) # Persist the new password hash
print("Server: Player %d logged in as existing user %s. Password hash updated." % [peer_id, username])
_player_account_map[peer_id] = username
var char_dicts: Array = []
for char in account_data.characters:
char_dicts.append(char.to_dict())
cl_login_response.rpc_id(peer_id, true, char_dicts)
elif account_data.password_hash == password_hash:
print("Server: Player %d logged in as existing user %s" % [peer_id, username])
_player_account_map[peer_id] = username
# Send characters to client
var char_dicts: Array = []
for char in account_data.characters:
char_dicts.append(char.to_dict())
cl_login_response.rpc_id(peer_id, true, char_dicts)
else:
print("Server: Player %d failed login for existing user %s (wrong password)")
print("Server: Stored hash: ", account_data.password_hash, " Received hash: ", password_hash)
cl_login_response.rpc_id(peer_id, false, [])
else:
# New account
account_data = AccountData.new(username, password_hash)
_accounts[username] = account_data
save_account(account_data)
print("Server: Player %d created new account %s and logged in" % [peer_id, username])
_player_account_map[peer_id] = username
cl_login_response.rpc_id(peer_id, true, []) # No characters yet for a new account
@rpc("authority", "call_remote", "reliable")
func cl_login_response(success: bool, characters_dicts: Array):
print("Client: Received login response. Success: ", success)
GameEvents.network_login_response.emit(success) # Emit success/failure
if success:
var character_instances: Array[CharacterData] = []
for char_dict in characters_dicts:
var new_character = CharacterData.new()
new_character.from_dict(char_dict)
character_instances.append(new_character)
print("Client: Emitting network_show_characters with ", character_instances.size(), " characters.")
GameEvents.network_show_characters.emit(character_instances) # Show characters
else:
print("Client: Login failed.")
# The login.gd script will handle showing the error message.

View file

@ -16,3 +16,10 @@ func generate_uuid_v4_formatted() -> String:
s.substr(16, 4),
s.substr(20, 12)
]
## Hashes a password using SHA256. For production, consider a stronger KDF.
static func hash_password(password: String) -> String:
# Fallback to String.sha256_text() for basic hashing,
# as HashingContext.init seems to be problematic in this environment.
# For production, a more robust password hashing algorithm should be used.
return password.sha256_text()

View file

@ -1,6 +1,26 @@
extends Control
# Utils and NetworkManager are global class_names/singletons, no need to preload.
# const Utils = preload("res://01_core/systems/utils.gd")
# const NetworkManager = preload("res://01_core/systems/network_manager.gd")
@onready var ip_input: TextEdit = $IPInput
@onready var username_line_edit: LineEdit = $UsernameLineEdit
@onready var password_line_edit: LineEdit = $PasswordLineEdit
@onready var error_label: Label = $ErrorLabel # Assuming an ErrorLabel exists in the scene
func _ready():
GameEvents.network_login_response.connect(_on_network_login_response) # Connect to the new login response signal
error_label.hide() # Hide error label by default
func _on_network_login_response(success: bool):
if success:
error_label.hide()
# Client will receive characters via network_show_characters, and this screen will be hidden by char select.
else:
error_label.text = "Login failed. Check username and password."
error_label.show()
show() # Show login screen again for retry
func _on_host_button_pressed() -> void:
print("host pressed")
@ -10,7 +30,18 @@ func _on_host_button_pressed() -> void:
func _on_join_button_pressed():
var username = $UsernameLineEdit.text
var password = $PasswordLineEdit.text # TODO: Handle Password
var password = $PasswordLineEdit.text
GameEvents.ui_join_requested.emit(ip_input.text, username, password)
# Hash the password before sending it to the server
var hashed_password = load("res://01_core/systems/utils.gd").hash_password(password)
# Store credentials temporarily and initiate client connection
# NetworkManager.join_game will now handle sending the login request after connection is established.
get_node("/root/NetworkManager")._temp_username = username
get_node("/root/NetworkManager")._temp_password_hash = hashed_password
# This initiates the client connection process.
get_node("/root/NetworkManager").join_game(ip_input.text, username, password)
# Hide the login screen; character select or an error will appear later.
hide()

View file

@ -77,6 +77,14 @@ offset_right = 230.0
offset_bottom = -7.0
text = "test_pw"
[node name="ErrorLabel" type="Label" parent="." unique_id=638509728]
visible = false
layout_mode = 0
offset_left = 29.0
offset_top = 55.0
offset_right = 534.0
offset_bottom = 78.0
[connection signal="pressed" from="Join_Button" to="." method="_on_join_button_pressed"]
[connection signal="button_up" from="Host_Button" to="." method="_on_host_pressed"]
[connection signal="pressed" from="Host_Button" to="." method="_on_host_button_pressed"]

View file

@ -11,8 +11,8 @@ func show_creation_screen():
func _on_create_button_pressed() -> void:
var new_character = CharacterData.new()
new_character.name = name_line_edit.text
print("Create Button pressed")
new_character.character_name = name_line_edit.text
print("CharacterCreate: Created new character: ", new_character.character_name, " ID: ", new_character.id)
GameEvents.ui_character_created.emit(new_character)
hide()

View file

@ -10,15 +10,17 @@ func _ready():
func receive_new_character(character: CharacterData):
characters.append(character)
character_item_list.add_item(character.name)
character_item_list.add_item(character.character_name)
show()
func show_list(characters: Array):
print("show_list triggered")
func show_list(p_characters: Array[CharacterData]): # Renamed parameter to avoid conflict with member variable
print("CharacterSelect: show_list triggered.")
characters = p_characters # Store the characters
character_item_list.clear()
for character: CharacterData in characters:
character_item_list.add_item(character.name)
show()
character_item_list.add_item(character.character_name)
show() # Make the CharacterSelect UI visible
print("CharacterSelect: Setting visible property to true.")
func _on_create_character_button_pressed() -> void:
GameEvents.ui_create_character_screen.emit()
@ -27,12 +29,9 @@ func _on_create_character_button_pressed() -> void:
# Doubleclick on Character should start area transition
func _on_character_item_list_item_activated(index: int) -> void:
print("Doubleclick for index=", index)
var item = character_item_list.get_selected_items()[0]
print("=>",character_item_list.get_item_text(item))
var selected_character: CharacterData = characters[index]
print("Selected character: ", selected_character.character_name, " ID: ", selected_character.id)
#for name in character_item_list.get_selected_items():
# #character_item_list.add_item(character.name)
# print(name)
print("sending to server: srv_handle_area_change_request")
NetworkManager.srv_handle_area_change_request.rpc_id(1, 'keuwin_grasland')
NetworkManager.srv_handle_area_change_request.rpc_id(1, 'keuwin_grasland', selected_character.id)
hide()

View file

@ -183,3 +183,5 @@ Variablen:
löst aus: GameEvents.create_character_screen.emit() , der von CharacterCreate abonniert ist, und dort die
Methode show_creation_screen auslöst.
** Wird das Formular ausgefüllt und Create angeklickt, löst dies die an den Server gerichtete rpc store_player_charakter aus.
Next step: Name des Charakters persistieren. Aktuell heissen die nach deserialisierung immer "Neuer Held" :-)