feat:实现登录系统和用户认证功能

- 添加登录场景(login_scene.tscn)和主菜单场景(main_menu_scene.tscn)
- 实现认证管理器(AuthManager)用于用户登录和会话管理
- 添加核心服务:加密服务、存储服务、网络服务
- 配置项目主场景为登录场景
- 添加自动加载服务到项目配置
- 添加开发环境配置(VSCode、Claude)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-23 01:15:33 +08:00
parent 8ed260b413
commit d623c705b6
28 changed files with 1455 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"WebFetch(domain:whaletownend.xinghangee.icu)",
"mcp__ide__getDiagnostics",
"Bash(dir /s /b *.gd)",
"Bash(findstr:*)",
"Bash(del nul)",
"Bash(git checkout:*)",
"Bash(git add:*)"
]
}
}

15
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "GDScript Godot",
"type": "godot",
"request": "launch",
"project": "${workspaceFolder}",
"port": 6007,
"address": "127.0.0.1",
"launch_game_instance": true,
"launch_scene": false
}
]
}

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"godotTools.editorPath.godot4": "d:\\software\\godot\\Godot_v4.5.1-stable_win64.exe"
}

BIN
assets/login/enter.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cswuqnhiiypb2"
path="res://.godot/imported/enter.png-ed3d67e95e66053ad0fb61054985bfb8.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/login/enter.png"
dest_files=["res://.godot/imported/enter.png-ed3d67e95e66053ad0fb61054985bfb8.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

BIN
assets/login/login-bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://byw73r2dt6xb8"
path="res://.godot/imported/login-bg.png-3f4c0c8160ab08ab0791dc1de267dd7a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/login/login-bg.png"
dest_files=["res://.godot/imported/login-bg.png-3f4c0c8160ab08ab0791dc1de267dd7a.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

BIN
assets/login/whaletown.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bqvel7n6dfo1d"
path="res://.godot/imported/whaletown.png-c44c9408e8b817d0db57fd8eec9de194.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/login/whaletown.png"
dest_files=["res://.godot/imported/whaletown.png-c44c9408e8b817d0db57fd8eec9de194.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@@ -11,5 +11,13 @@ config_version=5
[application]
config/name="whaleTown"
run/main_scene="res://scenes/login_scene.tscn"
config/features=PackedStringArray("4.5", "Forward Plus")
config/icon="res://icon.svg"
[autoload]
EncryptionService="*res://scripts/managers/EncryptionService.gd"
StorageService="*res://scripts/managers/StorageService.gd"
NetworkService="*res://scripts/managers/NetworkService.gd"
AuthManager="*res://scripts/managers/AuthManager.gd"

196
scenes/login_scene.tscn Normal file
View File

@@ -0,0 +1,196 @@
[gd_scene load_steps=5 format=3 uid="uid://bqj1ykrxo0x8s"]
[ext_resource type="Script" uid="uid://c6f1awwaw1nim" path="res://scripts/ui/UI_Login.gd" id="1_p2w8r"]
[ext_resource type="Texture2D" uid="uid://byw73r2dt6xb8" path="res://assets/login/login-bg.png" id="2_login_bg"]
[ext_resource type="Texture2D" uid="uid://cswuqnhiiypb2" path="res://assets/login/enter.png" id="3_enter_btn"]
[ext_resource type="Texture2D" uid="uid://bqvel7n6dfo1d" path="res://assets/login/whaletown.png" id="4_whaletown"]
[node name="loginScene" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_p2w8r")
[node name="Background" type="ColorRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
color = Color(0.85098, 0.929412, 0.964706, 1)
[node name="LoginPanel" type="TextureRect" parent="."]
layout_mode = 0
offset_left = 250.0
offset_top = 8.0
offset_right = 1274.0
offset_bottom = 1032.0
scale = Vector2(0.6, 0.6)
texture = ExtResource("2_login_bg")
stretch_mode = 1
[node name="MainTitle" type="TextureRect" parent="LoginPanel"]
layout_mode = 0
offset_left = 325.00003
offset_top = 243.33331
offset_right = 713.3334
offset_bottom = 393.3333
texture = ExtResource("4_whaletown")
expand_mode = 1
stretch_mode = 5
[node name="SubTitle" type="Label" parent="LoginPanel"]
layout_mode = 0
offset_left = 351.66666
offset_top = 408.3333
offset_right = 651.6666
offset_bottom = 431.3333
theme_override_colors/font_color = Color(0, 0, 0, 1)
text = "开始你的小镇之旅!"
horizontal_alignment = 1
vertical_alignment = 1
[node name="PasswordLoginTab" type="Button" parent="LoginPanel"]
layout_mode = 0
offset_left = 355.0
offset_top = 468.3333
offset_right = 500.0
offset_bottom = 503.3333
toggle_mode = true
button_pressed = true
text = "密码登录"
[node name="CodeLoginTab" type="Button" parent="LoginPanel"]
layout_mode = 0
offset_left = 510.0
offset_top = 468.3333
offset_right = 655.0
offset_bottom = 503.3333
toggle_mode = true
text = "验证码登录"
[node name="UsernameInput" type="LineEdit" parent="LoginPanel"]
layout_mode = 0
offset_left = 355.0
offset_top = 523.3333
offset_right = 655.0
offset_bottom = 563.3333
placeholder_text = "用户名/手机/邮箱"
caret_blink = true
[node name="PasswordInput" type="LineEdit" parent="LoginPanel"]
layout_mode = 0
offset_left = 355.0
offset_top = 578.3333
offset_right = 615.0
offset_bottom = 618.3333
placeholder_text = "输入密码"
caret_blink = true
secret = true
[node name="TogglePasswordButton" type="Button" parent="LoginPanel"]
layout_mode = 0
offset_left = 615.0
offset_top = 578.3333
offset_right = 655.0
offset_bottom = 618.3333
text = "👁"
[node name="RememberPasswordCheckBox" type="CheckBox" parent="LoginPanel"]
layout_mode = 0
offset_left = 325.0
offset_top = 703.3333
offset_right = 425.0
offset_bottom = 734.3333
theme_override_colors/font_color = Color(0, 0, 0, 1)
text = "记住密码"
[node name="AutoLoginCheckBox" type="CheckBox" parent="LoginPanel"]
layout_mode = 0
offset_left = 575.0
offset_top = 688.3333
offset_right = 675.0
offset_bottom = 719.3333
theme_override_colors/font_color = Color(0, 0, 0, 1)
text = "自动登录"
[node name="EnterButton" type="TextureButton" parent="LoginPanel"]
layout_mode = 0
offset_left = 286.66666
offset_top = 774.99994
offset_right = 1243.6666
offset_bottom = 958.99994
scale = Vector2(0.5, 0.5)
texture_normal = ExtResource("3_enter_btn")
stretch_mode = 0
[node name="EnterButtonLabel" type="Label" parent="LoginPanel/EnterButton"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_font_sizes/font_size = 64
text = "进入小镇"
horizontal_alignment = 1
vertical_alignment = 1
[node name="ForgotPasswordButton" type="LinkButton" parent="LoginPanel"]
layout_mode = 0
offset_left = 328.3333
offset_top = 893.3333
offset_right = 428.3333
offset_bottom = 918.3333
theme_override_colors/font_color = Color(0, 0, 0, 1)
text = "忘记密码?"
[node name="RegisterButton" type="LinkButton" parent="LoginPanel"]
layout_mode = 0
offset_left = 595.0
offset_top = 890.0
offset_right = 695.0
offset_bottom = 915.0
theme_override_colors/font_color = Color(0, 0, 0, 1)
text = "注册居民身份"
[node name="StatusLabel" type="Label" parent="LoginPanel"]
layout_mode = 0
offset_left = 50.0
offset_top = 480.0
offset_right = 350.0
offset_bottom = 510.0
text = " "
horizontal_alignment = 1
vertical_alignment = 1
[node name="LoadingOverlay" type="ColorRect" parent="."]
visible = false
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
color = Color(0, 0, 0, 0.5)
[node name="LoadingLabel" type="Label" parent="LoadingOverlay"]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -50.0
offset_top = -11.5
offset_right = 50.0
offset_bottom = 11.5
grow_horizontal = 2
grow_vertical = 2
text = "登录中..."
horizontal_alignment = 1
vertical_alignment = 1

View File

@@ -0,0 +1,40 @@
[gd_scene load_steps=2 format=3 uid="uid://c3mxv7d1qw7ba"]
[ext_resource type="Script" path="res://scripts/ui/UI_MainMenu.gd" id="1_main_menu"]
[node name="mainMenuScene" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_main_menu")
[node name="centerContainer" type="CenterContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="vboxContainer" type="VBoxContainer" parent="centerContainer"]
layout_mode = 2
theme_override_constants/separation = 20
[node name="welcomeLabel" type="Label" parent="centerContainer/vboxContainer"]
layout_mode = 2
text = "欢迎来到鲸鱼小镇!"
horizontal_alignment = 1
[node name="userInfoLabel" type="Label" parent="centerContainer/vboxContainer"]
layout_mode = 2
text = "用户名:--
等级:--"
horizontal_alignment = 1
[node name="logoutButton" type="Button" parent="centerContainer/vboxContainer"]
custom_minimum_size = Vector2(200, 40)
layout_mode = 2
text = "退出登录"

45
scripts/data/ErrorCode.gd Normal file
View File

@@ -0,0 +1,45 @@
class_name ErrorCode
## 错误码定义
## 用于统一管理登录系统中的所有错误码和错误消息
# 成功
const SUCCESS: int = 0
# 网络相关错误 (1000-1999)
const NETWORK_ERROR: int = 1001
const TIMEOUT: int = 1002
const SERVER_ERROR: int = 1003
# 输入验证错误 (2000-2999)
const INVALID_INPUT: int = 2001
const INVALID_USERNAME: int = 2002
const INVALID_PASSWORD: int = 2003
const PASSWORDS_NOT_MATCH: int = 2004
# 认证相关错误 (3000-3999)
const WRONG_CREDENTIALS: int = 3001
const USER_NOT_FOUND: int = 3002
const ACCOUNT_DISABLED: int = 3003
const TOKEN_EXPIRED: int = 3004
const TOKEN_INVALID: int = 3005
# 错误消息映射
const MESSAGES: Dictionary = {
NETWORK_ERROR: "网络连接失败,请检查网络设置",
TIMEOUT: "请求超时,请稍后重试",
SERVER_ERROR: "服务器错误,请稍后重试",
INVALID_INPUT: "用户名或密码不能为空",
INVALID_USERNAME: "用户名格式不正确3-16个字符",
INVALID_PASSWORD: "密码格式不正确6-20个字符",
PASSWORDS_NOT_MATCH: "两次输入的密码不一致",
WRONG_CREDENTIALS: "用户名或密码错误",
USER_NOT_FOUND: "用户不存在",
ACCOUNT_DISABLED: "账号已被禁用",
TOKEN_EXPIRED: "登录已过期,请重新登录",
TOKEN_INVALID: "登录状态无效,请重新登录"
}
## 获取错误消息
static func getMessage(errorCode: int) -> String:
return MESSAGES.get(errorCode, "未知错误 (代码: %d)" % errorCode)

View File

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

66
scripts/data/UserData.gd Normal file
View File

@@ -0,0 +1,66 @@
class_name UserData
## 用户数据类
## 用于存储用户的基本信息
var id: String = ""
var username: String = ""
var nickname: String = ""
var email: String = ""
var phone: String = ""
var avatarUrl: String = ""
var role: int = 1
var createdAt: String = ""
# 兼容旧字段
var level: int = 1
var exp: int = 0
var createTime: int = 0
## 从字典创建 UserData 对象
static func fromDict(data: Dictionary) -> UserData:
var userData = UserData.new()
# 新字段(处理可能为 null 的值)
userData.id = str(data.get("id", ""))
userData.username = _getStringOrEmpty(data, "username")
userData.nickname = _getStringOrEmpty(data, "nickname")
userData.email = _getStringOrEmpty(data, "email")
userData.phone = _getStringOrEmpty(data, "phone")
userData.avatarUrl = _getStringOrEmpty(data, "avatar_url", _getStringOrEmpty(data, "avatarUrl"))
userData.role = int(data.get("role", 1))
userData.createdAt = _getStringOrEmpty(data, "created_at", _getStringOrEmpty(data, "createdAt"))
# 兼容旧字段
userData.level = data.get("level", 1)
userData.exp = data.get("exp", 0)
userData.createTime = data.get("create_time", data.get("createTime", 0))
return userData
## 辅助方法:从字典中获取字符串,如果为 null 则返回空字符串
static func _getStringOrEmpty(data: Dictionary, key: String, defaultValue: String = "") -> String:
var value = data.get(key, defaultValue)
if value == null:
return ""
return str(value)
## 转换为字典
func toDict() -> Dictionary:
return {
"id": id,
"username": username,
"nickname": nickname,
"email": email,
"phone": phone,
"avatar_url": avatarUrl,
"role": role,
"created_at": createdAt,
"level": level,
"exp": exp,
"create_time": createTime
}
## 打印用户信息(用于调试)
func toString() -> String:
return "UserData(id=%s, username=%s, nickname=%s, role=%d)" % [id, username, nickname, role]

View File

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

View File

@@ -0,0 +1,183 @@
extends Node
## 认证管理器
## 统一管理用户登录、登出、Token等认证相关功能
# 登录状态枚举
enum LoginState {
NOT_LOGGED_IN, # 未登录
LOGGING_IN, # 登录中
LOGGED_IN, # 已登录
GUEST # 游客模式
}
# 信号
signal login_success(userData: UserData)
signal login_failed(errorCode: int, errorMessage: String)
signal logout_success()
# 当前登录状态
var currentState: LoginState = LoginState.NOT_LOGGED_IN
# Token
var accessToken: String = ""
var tokenExpireTime: int = 0
# 当前用户信息
var currentUser: UserData = null
# 是否正在登录(防止重复请求)
var _isLoggingIn: bool = false
func _ready():
# 游戏启动时尝试从本地加载 Token 和用户信息
_tryLoadLocalAuth()
## 登录
func login(username: String, password: String, rememberPassword: bool) -> Dictionary:
# 防止重复登录
if _isLoggingIn:
return {
"success": false,
"error_code": ErrorCode.INVALID_INPUT,
"message": "登录中,请稍候..."
}
_isLoggingIn = true
currentState = LoginState.LOGGING_IN
# 构建登录请求数据
var requestData = {
"identifier": username,
"password": password
}
# 发送登录请求
var response = await NetworkService.post("/api/auth/login", requestData)
_isLoggingIn = false
# 处理响应
if response.success:
# 解析用户数据和 Token
var data = response.get("data", {})
var token = data.get("access_token", "")
var userData = data.get("user", {})
if token.is_empty():
var errorMsg = "服务器返回数据格式错误"
login_failed.emit(ErrorCode.SERVER_ERROR, errorMsg)
currentState = LoginState.NOT_LOGGED_IN
return {
"success": false,
"error_code": ErrorCode.SERVER_ERROR,
"message": errorMsg
}
# 保存 Token
accessToken = token
tokenExpireTime = int(Time.get_unix_time_from_system()) + 3600 * 24 # 假设24小时过期
StorageService.saveToken(accessToken, tokenExpireTime)
# 保存用户信息
currentUser = UserData.fromDict(userData)
StorageService.saveUserData(currentUser)
# 如果勾选记住密码,保存账号密码
if rememberPassword:
StorageService.saveAccount(username, password, true)
else:
# 只保存用户名,不保存密码
StorageService.save("account", "last_username", username)
StorageService.clearSavedPassword()
# 更新状态
currentState = LoginState.LOGGED_IN
# 发送登录成功信号
login_success.emit(currentUser)
return {
"success": true,
"message": "登录成功",
"user": currentUser
}
else:
# 登录失败
var errorCode = response.get("error_code", ErrorCode.WRONG_CREDENTIALS)
var errorMsg = response.get("message", "登录失败")
currentState = LoginState.NOT_LOGGED_IN
login_failed.emit(errorCode, errorMsg)
return {
"success": false,
"error_code": errorCode,
"message": errorMsg
}
## 登出
func logout() -> void:
# 清除 Token 和用户信息
accessToken = ""
tokenExpireTime = 0
currentUser = null
# 清除本地存储(保留用户名和记住密码设置)
StorageService.clearToken()
# 更新状态
currentState = LoginState.NOT_LOGGED_IN
# 发送登出成功信号
logout_success.emit()
print("用户已登出")
## 检查是否已登录
func isLoggedIn() -> bool:
return currentState == LoginState.LOGGED_IN and currentUser != null
## 获取当前用户
func getCurrentUser() -> UserData:
return currentUser
## 获取当前 Token
func getToken() -> String:
return accessToken
## 尝试从本地加载认证信息
func _tryLoadLocalAuth() -> void:
# 加载 Token
var tokenData = StorageService.loadToken()
var token = tokenData.get("token", "")
var expireTime = tokenData.get("expire_time", 0)
# 检查 Token 是否有效
var currentTime = int(Time.get_unix_time_from_system())
if token.is_empty() or expireTime <= currentTime:
print("本地 Token 无效或已过期")
return
# Token 有效,加载用户信息
var userData = StorageService.loadUserData()
if userData == null:
print("本地用户信息不存在")
return
# 恢复登录状态
accessToken = token
tokenExpireTime = expireTime
currentUser = userData
currentState = LoginState.LOGGED_IN
print("从本地恢复登录状态: ", currentUser.username)
# 可选:静默刷新 Token如果接近过期
# _refreshTokenIfNeeded()
## 检查 Token 是否即将过期
func isTokenExpiring() -> bool:
var currentTime = int(Time.get_unix_time_from_system())
var timeLeft = tokenExpireTime - currentTime
return timeLeft < 300 # 少于5分钟

View File

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

View File

@@ -0,0 +1,98 @@
extends Node
## 加密服务
## 提供密码加密、解密等安全功能
# 固定的 Salt用于生成设备密钥
const FIXED_SALT: String = "whaleTown_2024_secret_key"
# AES 加密密钥基于设备ID生成
var _encryptionKey: PackedByteArray
func _ready():
_initEncryptionKey()
## 初始化加密密钥
func _initEncryptionKey() -> void:
# 使用设备唯一ID + 固定Salt 生成加密密钥
var deviceId = OS.get_unique_id()
var keyString = deviceId + FIXED_SALT
# 使用 SHA-256 生成 32 字节密钥
var crypto = Crypto.new()
var hash = crypto.generate_random_bytes(32) # 临时方案,实际应该用 hash
# 简单实现:取字符串的 MD5 哈希的前32字节
_encryptionKey = keyString.md5_buffer()
# 如果需要32字节重复一次
_encryptionKey.append_array(keyString.md5_buffer())
## 加密字符串(用于本地存储密码)
func encrypt(plainText: String) -> String:
if plainText.is_empty():
return ""
var crypto = Crypto.new()
var aes = AESContext.new()
# 生成随机 IV初始化向量
var iv = crypto.generate_random_bytes(16)
# 转换明文为字节
var plainBytes = plainText.to_utf8_buffer()
# 使用 AES-256-CBC 加密
aes.start(AESContext.MODE_CBC_ENCRYPT, _encryptionKey.slice(0, 32), iv)
var encryptedBytes = aes.update(plainBytes)
aes.finish()
# 将 IV 和加密数据组合IV + encrypted_data
var combined = PackedByteArray()
combined.append_array(iv)
combined.append_array(encryptedBytes)
# 转换为 Base64 字符串
return Marshalls.raw_to_base64(combined)
## 解密字符串
func decrypt(encryptedText: String) -> String:
if encryptedText.is_empty():
return ""
# Base64 解码
var combined = Marshalls.base64_to_raw(encryptedText)
if combined.size() < 16:
push_error("加密数据格式错误")
return ""
# 分离 IV 和加密数据
var iv = combined.slice(0, 16)
var encryptedBytes = combined.slice(16)
# 解密
var aes = AESContext.new()
aes.start(AESContext.MODE_CBC_DECRYPT, _encryptionKey.slice(0, 32), iv)
var decryptedBytes = aes.update(encryptedBytes)
aes.finish()
# 转换为字符串
return decryptedBytes.get_string_from_utf8()
## 生成密码哈希(用于网络传输)
## 注意:这是简单实现,实际应该使用更安全的算法(如 bcrypt, scrypt
func hashPassword(password: String, salt: String = "") -> String:
if password.is_empty():
return ""
var combined = password + salt
return combined.sha256_text()
## 验证密码哈希
func verifyPassword(password: String, hashedPassword: String, salt: String = "") -> bool:
return hashPassword(password, salt) == hashedPassword
## 生成随机 Salt
func generateSalt(length: int = 16) -> String:
var crypto = Crypto.new()
var randomBytes = crypto.generate_random_bytes(length)
return Marshalls.raw_to_base64(randomBytes)

View File

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

View File

@@ -0,0 +1,282 @@
extends Node
## 网络服务
## 封装 HTTP 请求,提供统一的网络通信接口
# API 基础 URL
const API_BASE_URL: String = "https://whaletownui.angforever.top"
# 请求超时时间(秒)
const REQUEST_TIMEOUT: float = 30.0
# 最大重试次数
const MAX_RETRY: int = 3
# 是否开启调试日志(显示请求和响应详情)
const DEBUG_MODE: bool = true
## 发送 POST 请求
func post(endpoint: String, data: Dictionary, headers: Array = []) -> Dictionary:
var url = API_BASE_URL + endpoint
return await _request(url, HTTPClient.METHOD_POST, data, headers)
## 发送 GET 请求
func sendGet(endpoint: String, headers: Array = []) -> Dictionary:
var url = API_BASE_URL + endpoint
return await _request(url, HTTPClient.METHOD_GET, {}, headers)
## 发送带 Token 的 POST 请求
func authenticatedPost(endpoint: String, data: Dictionary) -> Dictionary:
var tokenData = StorageService.loadToken()
var token = tokenData.get("token", "")
if token.is_empty():
return {
"success": false,
"error_code": ErrorCode.TOKEN_INVALID,
"message": ErrorCode.getMessage(ErrorCode.TOKEN_INVALID)
}
var headers = [
"Authorization: Bearer " + token
]
return await post(endpoint, data, headers)
## 核心请求方法
func _request(url: String, method: HTTPClient.Method, data: Dictionary, customHeaders: Array) -> Dictionary:
# 创建 HTTPRequest 节点
var httpRequest = HTTPRequest.new()
add_child(httpRequest)
# 设置超时
httpRequest.timeout = REQUEST_TIMEOUT
# 构建请求头
var headers = [
"Content-Type: application/json",
"Accept: application/json"
]
headers.append_array(customHeaders)
# 准备请求体
var body = ""
if method == HTTPClient.METHOD_POST or method == HTTPClient.METHOD_PUT:
body = JSON.stringify(data)
# 调试日志:请求信息
if DEBUG_MODE:
_logRequest(url, method, headers, body)
# 发送请求
var error = httpRequest.request(url, headers, method, body)
if error != OK:
httpRequest.queue_free()
return _createErrorResponse(ErrorCode.NETWORK_ERROR, "发送请求失败")
# 等待响应
var result = await httpRequest.request_completed
# 调试日志:响应信息
if DEBUG_MODE:
_logResponse(result)
# 清理
httpRequest.queue_free()
# 解析响应
return _parseResponse(result)
## 解析 HTTP 响应
func _parseResponse(result: Array) -> Dictionary:
var httpResult: int = result[0]
var responseCode: int = result[1]
var _headers: PackedStringArray = result[2]
var body: PackedByteArray = result[3]
# 检查 HTTP 请求错误
if httpResult != HTTPRequest.RESULT_SUCCESS:
return _handleHttpError(httpResult)
# 检查 HTTP 状态码
if responseCode < 200 or responseCode >= 300:
return _handleStatusCodeError(responseCode, body)
# 解析 JSON 响应体
var bodyString = body.get_string_from_utf8()
if bodyString.is_empty():
return _createErrorResponse(ErrorCode.SERVER_ERROR, "服务器返回空响应")
var json = JSON.new()
var parseError = json.parse(bodyString)
if parseError != OK:
push_error("JSON 解析失败: " + bodyString)
return _createErrorResponse(ErrorCode.SERVER_ERROR, "响应数据格式错误")
var responseData = json.data
# 根据后端API格式解析通用格式后续可能需要调整
if typeof(responseData) == TYPE_DICTIONARY:
# 检查是否有 success 字段(新版 API 格式)
if responseData.has("success"):
var success = responseData.get("success", false)
if success:
return {
"success": true,
"data": responseData.get("data", {}),
"message": responseData.get("message", "操作成功")
}
else:
# 业务错误
return {
"success": false,
"error_code": responseData.get("error_code", ErrorCode.SERVER_ERROR),
"message": responseData.get("message", "操作失败")
}
# 检查是否有 code 字段(旧版 API 格式)
elif responseData.has("code"):
var code = responseData.get("code", -1)
if code == 0 or code == 200: # 成功
return {
"success": true,
"data": responseData.get("data", {}),
"message": responseData.get("message", "操作成功")
}
else: # 业务错误
return {
"success": false,
"error_code": code,
"message": responseData.get("message", "操作失败")
}
else:
# 假设整个响应就是数据
return {
"success": true,
"data": responseData,
"message": "操作成功"
}
return _createErrorResponse(ErrorCode.SERVER_ERROR, "响应格式不正确")
## 处理 HTTP 请求错误
func _handleHttpError(httpResult: int) -> Dictionary:
match httpResult:
HTTPRequest.RESULT_TIMEOUT:
return _createErrorResponse(ErrorCode.TIMEOUT, "请求超时")
HTTPRequest.RESULT_CONNECTION_ERROR:
return _createErrorResponse(ErrorCode.NETWORK_ERROR, "网络连接失败")
HTTPRequest.RESULT_CANT_CONNECT:
return _createErrorResponse(ErrorCode.NETWORK_ERROR, "无法连接到服务器")
_:
return _createErrorResponse(ErrorCode.NETWORK_ERROR, "网络请求失败: " + str(httpResult))
## 处理 HTTP 状态码错误
func _handleStatusCodeError(statusCode: int, body: PackedByteArray) -> Dictionary:
var bodyString = body.get_string_from_utf8()
# 尝试解析错误信息
var json = JSON.new()
if json.parse(bodyString) == OK:
var data = json.data
if typeof(data) == TYPE_DICTIONARY and data.has("message"):
var message = data.get("message", "")
# 确保 message 是字符串类型
if typeof(message) == TYPE_ARRAY:
message = ", ".join(message)
elif typeof(message) != TYPE_STRING:
message = str(message)
return _createErrorResponse(statusCode, message)
# 通用状态码错误处理
match statusCode:
400:
return _createErrorResponse(statusCode, "请求参数错误")
401:
return _createErrorResponse(statusCode, "未授权,请重新登录")
403:
return _createErrorResponse(statusCode, "没有权限访问")
404:
return _createErrorResponse(statusCode, "请求的资源不存在")
500:
return _createErrorResponse(statusCode, "服务器内部错误")
502:
return _createErrorResponse(statusCode, "网关错误")
503:
return _createErrorResponse(statusCode, "服务暂时不可用")
_:
return _createErrorResponse(statusCode, "HTTP 错误: " + str(statusCode))
## 创建错误响应
func _createErrorResponse(errorCode: int, message: String) -> Dictionary:
return {
"success": false,
"error_code": errorCode,
"message": message
}
## 记录请求日志
func _logRequest(url: String, method: HTTPClient.Method, headers: Array, body: String) -> void:
print("\n========== HTTP 请求 ==========")
print("URL: ", url)
print("方法: ", _getMethodName(method))
print("请求头:")
for header in headers:
print(" ", header)
if not body.is_empty():
print("请求体:")
# 尝试格式化 JSON
var json = JSON.new()
if json.parse(body) == OK:
print(" ", JSON.stringify(json.data, " "))
else:
print(" ", body)
print("==============================\n")
## 记录响应日志
func _logResponse(result: Array) -> void:
var httpResult: int = result[0]
var responseCode: int = result[1]
var responseHeaders: PackedStringArray = result[2]
var body: PackedByteArray = result[3]
print("\n========== HTTP 响应 ==========")
print("请求结果: ", _getHttpResultName(httpResult))
print("状态码: ", responseCode)
print("响应头:")
for header in responseHeaders:
print(" ", header)
var bodyString = body.get_string_from_utf8()
if not bodyString.is_empty():
print("响应体:")
# 尝试格式化 JSON
var json = JSON.new()
if json.parse(bodyString) == OK:
print(" ", JSON.stringify(json.data, " "))
else:
print(" ", bodyString)
else:
print("响应体: (空)")
print("==============================\n")
## 获取 HTTP 方法名称
func _getMethodName(method: HTTPClient.Method) -> String:
match method:
HTTPClient.METHOD_GET: return "GET"
HTTPClient.METHOD_POST: return "POST"
HTTPClient.METHOD_PUT: return "PUT"
HTTPClient.METHOD_DELETE: return "DELETE"
HTTPClient.METHOD_PATCH: return "PATCH"
_: return "UNKNOWN"
## 获取 HTTP 请求结果名称
func _getHttpResultName(result: int) -> String:
match result:
HTTPRequest.RESULT_SUCCESS: return "成功"
HTTPRequest.RESULT_TIMEOUT: return "超时"
HTTPRequest.RESULT_CONNECTION_ERROR: return "连接错误"
HTTPRequest.RESULT_CANT_CONNECT: return "无法连接"
HTTPRequest.RESULT_NO_RESPONSE: return "无响应"
_: return "错误 (" + str(result) + ")"

View File

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

View File

@@ -0,0 +1,124 @@
extends Node
## 存储服务
## 负责本地配置的读写包括用户账号、密码、Token 等
# 配置文件路径
const CONFIG_PATH: String = "user://user_config.cfg"
# ConfigFile 实例
var _config: ConfigFile
func _ready():
_config = ConfigFile.new()
_loadConfig()
## 加载配置文件
func _loadConfig() -> void:
var error = _config.load(CONFIG_PATH)
if error != OK and error != ERR_FILE_NOT_FOUND:
push_error("加载配置文件失败: " + str(error))
## 保存配置文件
func _saveConfig() -> void:
var error = _config.save(CONFIG_PATH)
if error != OK:
push_error("保存配置文件失败: " + str(error))
## 保存键值对
func save(section: String, key: String, value: Variant) -> void:
_config.set_value(section, key, value)
_saveConfig()
## 读取值
func getValue(section: String, key: String, defaultValue: Variant = null) -> Variant:
return _config.get_value(section, key, defaultValue)
## 保存账号信息(包括加密的密码)
func saveAccount(username: String, password: String, remember: bool) -> void:
save("account", "last_username", username)
if remember and not password.is_empty():
# 加密密码后保存
var encryptedPassword = EncryptionService.encrypt(password)
save("account", "saved_password", encryptedPassword)
save("account", "remember_password", true)
else:
# 不记住密码,清除保存的密码
clearSavedPassword()
## 读取保存的账号信息
func loadAccount() -> Dictionary:
var username = getValue("account", "last_username", "")
var rememberPassword = getValue("account", "remember_password", false)
var encryptedPassword = getValue("account", "saved_password", "")
var result = {
"username": username,
"remember_password": rememberPassword
}
# 如果记住密码,解密密码
if rememberPassword and not encryptedPassword.is_empty():
var decryptedPassword = EncryptionService.decrypt(encryptedPassword)
result["password"] = decryptedPassword
else:
result["password"] = ""
return result
## 清除保存的密码
func clearSavedPassword() -> void:
save("account", "saved_password", "")
save("account", "remember_password", false)
## 保存 Token
func saveToken(token: String, expireTime: int = 0) -> void:
save("auth", "token", token)
save("auth", "expire_time", expireTime)
## 读取 Token
func loadToken() -> Dictionary:
var token = getValue("auth", "token", "")
var expireTime = getValue("auth", "expire_time", 0)
return {
"token": token,
"expire_time": expireTime
}
## 清除 Token
func clearToken() -> void:
save("auth", "token", "")
save("auth", "expire_time", 0)
## 保存用户信息
func saveUserData(userData: UserData) -> void:
if userData == null:
return
save("user", "id", userData.id)
save("user", "username", userData.username)
save("user", "email", userData.email)
save("user", "level", userData.level)
save("user", "exp", userData.exp)
save("user", "avatar_url", userData.avatarUrl)
## 读取用户信息
func loadUserData() -> UserData:
var userData = UserData.new()
userData.id = str(getValue("user", "id", ""))
userData.username = getValue("user", "username", "")
userData.email = getValue("user", "email", "")
userData.level = getValue("user", "level", 1)
userData.exp = getValue("user", "exp", 0)
userData.avatarUrl = getValue("user", "avatar_url", "")
return userData if not userData.id.is_empty() else null
## 清除所有用户数据
func clearAll() -> void:
clearSavedPassword()
clearToken()
_config.clear()
_saveConfig()

View File

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

217
scripts/ui/UI_Login.gd Normal file
View File

@@ -0,0 +1,217 @@
extends Control
## 登录界面控制器
## 处理登录界面的用户交互、输入验证、错误提示等
# UI 节点引用
@onready var passwordLoginTab: Button = $LoginPanel/PasswordLoginTab
@onready var codeLoginTab: Button = $LoginPanel/CodeLoginTab
@onready var usernameInput: LineEdit = $LoginPanel/UsernameInput
@onready var passwordInput: LineEdit = $LoginPanel/PasswordInput
@onready var togglePasswordButton: Button = $LoginPanel/TogglePasswordButton
@onready var rememberPasswordCheckBox: CheckBox = $LoginPanel/RememberPasswordCheckBox
@onready var autoLoginCheckBox: CheckBox = $LoginPanel/AutoLoginCheckBox
@onready var enterButton: TextureButton = $LoginPanel/EnterButton
@onready var forgotPasswordButton: LinkButton = $LoginPanel/ForgotPasswordButton
@onready var registerButton: LinkButton = $LoginPanel/RegisterButton
@onready var statusLabel: Label = $LoginPanel/StatusLabel
@onready var loadingOverlay: ColorRect = $LoadingOverlay
@onready var loadingLabel: Label = $LoadingOverlay/LoadingLabel
# 当前登录模式
enum LoginMode {
PASSWORD, # 密码登录
CODE # 验证码登录
}
var currentMode: LoginMode = LoginMode.PASSWORD
func _ready():
# 连接Tab按钮信号
passwordLoginTab.pressed.connect(_onPasswordTabPressed)
codeLoginTab.pressed.connect(_onCodeTabPressed)
# 连接主要按钮信号
enterButton.pressed.connect(_onEnterButtonPressed)
togglePasswordButton.pressed.connect(_onTogglePasswordPressed)
# 连接底部链接按钮
forgotPasswordButton.pressed.connect(_onForgotPasswordPressed)
registerButton.pressed.connect(_onRegisterPressed)
# 连接 AuthManager 信号
AuthManager.login_success.connect(_onLoginSuccess)
AuthManager.login_failed.connect(_onLoginFailed)
# 连接输入框回车事件
usernameInput.text_submitted.connect(_onUsernameSubmitted)
passwordInput.text_submitted.connect(_onPasswordSubmitted)
# 自动填充记住的账号密码
_loadSavedAccount()
# 初始化状态
_hideLoading()
_clearStatus()
_updateLoginMode()
## Tab切换 - 密码登录
func _onPasswordTabPressed() -> void:
if currentMode != LoginMode.PASSWORD:
currentMode = LoginMode.PASSWORD
codeLoginTab.button_pressed = false
passwordLoginTab.button_pressed = true
_updateLoginMode()
## Tab切换 - 验证码登录
func _onCodeTabPressed() -> void:
if currentMode != LoginMode.CODE:
currentMode = LoginMode.CODE
passwordLoginTab.button_pressed = false
codeLoginTab.button_pressed = true
_updateLoginMode()
## 更新登录模式UI
func _updateLoginMode() -> void:
match currentMode:
LoginMode.PASSWORD:
passwordInput.placeholder_text = "输入密码"
passwordInput.secret = true
togglePasswordButton.visible = true
LoginMode.CODE:
passwordInput.placeholder_text = "输入验证码"
passwordInput.secret = false
togglePasswordButton.visible = false
## 切换密码显示/隐藏
func _onTogglePasswordPressed() -> void:
passwordInput.secret = !passwordInput.secret
togglePasswordButton.text = "👁" if passwordInput.secret else "🔒"
## 加载保存的账号密码
func _loadSavedAccount() -> void:
var savedAccount = StorageService.loadAccount()
var username = savedAccount.get("username", "")
var password = savedAccount.get("password", "")
var rememberPassword = savedAccount.get("remember_password", false)
# 设置用户名
usernameInput.text = username
if rememberPassword and not password.is_empty():
passwordInput.text = password
rememberPasswordCheckBox.button_pressed = true
else:
rememberPasswordCheckBox.button_pressed = false
## 进入小镇按钮点击事件
func _onEnterButtonPressed() -> void:
match currentMode:
LoginMode.PASSWORD:
_handlePasswordLogin()
LoginMode.CODE:
_handleCodeLogin()
## 处理密码登录
func _handlePasswordLogin() -> void:
# 验证输入
var validation = _validateInput()
if not validation.valid:
_showError(validation.error_code)
return
# 显示加载状态
_showLoading("登录中...")
# 获取输入
var username = usernameInput.text.strip_edges()
var password = passwordInput.text
var remember = rememberPasswordCheckBox.button_pressed
# 调用 AuthManager 登录
var result = await AuthManager.login(username, password, remember)
# 隐藏加载状态
_hideLoading()
## 处理验证码登录
func _handleCodeLogin() -> void:
# TODO: 实现验证码登录逻辑
_showError(ErrorCode.SERVER_ERROR, "验证码登录功能暂未开放")
## 用户名输入框回车事件
func _onUsernameSubmitted(_text: String) -> void:
passwordInput.grab_focus()
## 密码输入框回车事件
func _onPasswordSubmitted(_text: String) -> void:
_onEnterButtonPressed()
## 忘记密码按钮点击
func _onForgotPasswordPressed() -> void:
# TODO: 跳转到忘记密码页面
_showError(ErrorCode.SERVER_ERROR, "忘记密码功能暂未开放")
## 注册按钮点击
func _onRegisterPressed() -> void:
# TODO: 跳转到注册页面
_showError(ErrorCode.SERVER_ERROR, "注册功能暂未开放")
## 登录成功回调
func _onLoginSuccess(userData: UserData) -> void:
_hideLoading()
_showSuccess("登录成功!")
# 延迟0.5秒后跳转到主菜单
await get_tree().create_timer(0.5).timeout
get_tree().change_scene_to_file("res://scenes/main_menu_scene.tscn")
## 登录失败回调
func _onLoginFailed(errorCode: int, errorMessage: String) -> void:
_hideLoading()
_showError(errorCode, errorMessage)
## 验证输入
func _validateInput() -> Dictionary:
var username = usernameInput.text.strip_edges()
var password = passwordInput.text
# 检查是否为空
if username.is_empty() or password.is_empty():
return {
"valid": false,
"error_code": ErrorCode.INVALID_INPUT
}
return {"valid": true}
## 显示加载状态
func _showLoading(message: String = "加载中...") -> void:
loadingLabel.text = message
loadingOverlay.visible = true
enterButton.disabled = true
## 隐藏加载状态
func _hideLoading() -> void:
loadingOverlay.visible = false
enterButton.disabled = false
## 显示错误提示
func _showError(errorCode: int, customMessage: String = "") -> void:
var message = customMessage if not customMessage.is_empty() else ErrorCode.getMessage(errorCode)
statusLabel.text = message
statusLabel.modulate = Color.RED
# 3秒后自动清除
await get_tree().create_timer(3.0).timeout
_clearStatus()
## 显示成功提示
func _showSuccess(message: String) -> void:
statusLabel.text = message
statusLabel.modulate = Color.GREEN
## 清除状态提示
func _clearStatus() -> void:
statusLabel.text = " "
statusLabel.modulate = Color.WHITE

View File

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

37
scripts/ui/UI_MainMenu.gd Normal file
View File

@@ -0,0 +1,37 @@
extends Control
## 主菜单界面控制器
## 显示用户信息并提供退出登录功能
@onready var welcomeLabel: Label = $centerContainer/vboxContainer/welcomeLabel
@onready var userInfoLabel: Label = $centerContainer/vboxContainer/userInfoLabel
@onready var logoutButton: Button = $centerContainer/vboxContainer/logoutButton
func _ready():
# 连接退出登录按钮
logoutButton.pressed.connect(_onLogoutPressed)
# 连接 AuthManager 信号
AuthManager.logout_success.connect(_onLogoutSuccess)
# 显示用户信息
_displayUserInfo()
## 显示用户信息
func _displayUserInfo() -> void:
var currentUser = AuthManager.getCurrentUser()
if currentUser != null:
userInfoLabel.text = "用户名:%s\n等级:%d" % [currentUser.username, currentUser.level]
else:
userInfoLabel.text = "未登录"
## 退出登录按钮点击事件
func _onLogoutPressed() -> void:
# 调用 AuthManager 登出
AuthManager.logout()
## 登出成功回调
func _onLogoutSuccess() -> void:
# 返回登录界面
get_tree().change_scene_to_file("res://scenes/login_scene.tscn")

View File

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