Initial WhaleTown V2 frontend

This commit is contained in:
2026-07-19 22:42:20 +08:00
commit 435c578dde
1421 changed files with 54486 additions and 0 deletions

4
.editorconfig Normal file
View File

@@ -0,0 +1,4 @@
root = true
[*]
charset = utf-8

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Normalize EOL for all files that Git considers text files.
* text=auto eol=lf

120
.gitignore vendored Normal file
View File

@@ -0,0 +1,120 @@
# Godot 4+ specific ignores
.godot/
/android/
# Python cache files
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.so
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Godot executable files (should not be in version control)
Godot/
*.exe
*.app
*.dmg
# Logs and temporary files
*.log
*.tmp
*.temp
# Build outputs
build/
dist/
/output/
*.zip
*.tar.gz
# Environment files
.env
.env.local
.env.*.local
# Credentials and local secrets
secrets/
*.pem
*.key
*.p12
*.pfx
id_rsa*
id_ed25519*
auth.cfg
# Test coverage reports
coverage/
*.coverage
.nyc_output/
# Dependency directories
node_modules/
vendor/
# Internal development material is kept locally but not published.
/tools/
/docs/
# Superseded concept and generation assets.
/assets/characters/reference/
/assets/characters/skin_generation_references/
/assets/ui/chat/
/assets/ui/auth/registration_choice/generated/
/assets/ui/auth/registration_choice/redesign/*_magenta.png*
/assets/ui/auth/generated/*
!/assets/ui/auth/generated/auth_character_preview_front.png
!/assets/ui/auth/generated/auth_character_preview_front.png.import
!/assets/ui/auth/generated/auth_character_preview_left.png
!/assets/ui/auth/generated/auth_character_preview_left.png.import
!/assets/ui/auth/generated/auth_character_preview_right.png
!/assets/ui/auth/generated/auth_character_preview_right.png.import
/assets/ui/auth/v1/*
!/assets/ui/auth/v1/bg_auth_scene.png
!/assets/ui/auth/v1/bg_auth_scene.png.import
/assets/ui/auth/redesign/*
!/assets/ui/auth/redesign/whaletown_login_panel_hd.png
!/assets/ui/auth/redesign/whaletown_login_panel_hd.png.import
!/assets/ui/auth/redesign/whaletown_register_panel_compact_hd.png
!/assets/ui/auth/redesign/whaletown_register_panel_compact_hd.png.import
/assets/maps/square/v1/square_base_terrain_v*.png*
# The active work-zone scene uses v13 tiles, v14 buildings and v15 props.
/assets/maps/work_zone/v1/
/assets/maps/work_zone/v2/
/assets/maps/work_zone/v3/
/assets/maps/work_zone/v4/
/assets/maps/work_zone/v5/
/assets/maps/work_zone/v6/
/assets/maps/work_zone/v7/
/assets/maps/work_zone/v8/
/assets/maps/work_zone/v9/
/assets/maps/work_zone/v10/
/assets/maps/work_zone/v11/
/assets/maps/work_zone/v12/
/assets/maps/work_zone/v16/
/assets/maps/work_zone/v17/
/assets/maps/work_zone/v18/
/assets/maps/work_zone/v19/
/assets/maps/work_zone/v20/
/assets/maps/work_zone/v21/
/assets/maps/work_zone/v22/
/assets/maps/work_zone/v23/
/assets/maps/work_zone/v24/
/assets/maps/work_zone/v25/

270
Config/MallCatalog.gd Normal file
View File

@@ -0,0 +1,270 @@
class_name MallCatalog
extends RefCounted
# ============================================================================
# MallCatalog.gd - 鲸鱼商城本地配置
# ============================================================================
# 提供商城分类、商品目录和状态文案。
# ============================================================================
const STATUS_AVAILABLE: String = "available"
const STATUS_OWNED: String = "owned"
const STATUS_COMING_SOON: String = "coming_soon"
const STATUS_LOCKED: String = "locked"
const CATEGORY_ICON_PATHS: Dictionary = {
"recommended": "res://assets/ui/mall/icons/processed/category_recommended_128.png",
"outfit": "res://assets/ui/mall/icons/processed/category_outfit_128.png",
"items": "res://assets/ui/mall/icons/processed/category_items_128.png",
"companion": "res://assets/ui/mall/icons/processed/category_companion_128.png",
"space": "res://assets/ui/mall/icons/processed/category_space_128.png",
"limited": "res://assets/ui/mall/icons/processed/category_limited_128.png",
}
const CATEGORIES: Array[Dictionary] = [
{"id": "recommended", "label": "推荐", "icon": "recommended"},
{"id": "outfit", "label": "装扮", "icon": "outfit"},
{"id": "items", "label": "道具", "icon": "items"},
{"id": "companion", "label": "伙伴", "icon": "companion"},
{"id": "space", "label": "空间", "icon": "space"},
{"id": "limited", "label": "限时", "icon": "limited"},
]
const ITEM_TYPE_SKIN: String = "skin"
const ITEM_TYPE_ROOM_DECOR: String = "room_decor"
const MALL_ITEMS: Array[Dictionary] = [
{
"id": "skin_classic_whale",
"itemType": ITEM_TYPE_SKIN,
"skinId": "classic_whale",
"name": "经典鲸鱼",
"category": "outfit",
"description": "圆润、轻快的鲸鱼居民皮肤,适合喜欢海洋感角色的玩家。",
"price": 680,
"status": STATUS_AVAILABLE,
"tags": ["可预览", "永久", "皮肤"],
"icon": "res://assets/ui/mall/skins/classic_whale_product.png",
"sortOrder": 10,
},
{
"id": "skin_human_whale_directional_v2_8x4",
"itemType": ITEM_TYPE_SKIN,
"skinId": "human_whale_directional_v2_8x4",
"name": "海风行者",
"category": "outfit",
"description": "蓝白海风主题的人类角色皮肤,带有鲸鱼小镇风格的服装细节。",
"price": 680,
"status": STATUS_AVAILABLE,
"tags": ["可预览", "永久", "皮肤"],
"icon": "res://assets/ui/mall/skins/human_whale_directional_v2_8x4_product.png",
"sortOrder": 20,
},
{
"id": "skin_girl_sailor_turnaround_v2_8x4",
"itemType": ITEM_TYPE_SKIN,
"skinId": "girl_sailor_turnaround_v2_8x4",
"name": "海风少女",
"category": "outfit",
"description": "水手风格的人类角色皮肤,适合轻松、清爽的 WhaleTown 日常。",
"price": 880,
"status": STATUS_AVAILABLE,
"tags": ["可预览", "永久", "皮肤"],
"icon": "res://assets/ui/mall/skins/girl_sailor_turnaround_v2_8x4_product.png",
"sortOrder": 30,
},
{
"id": "skin_panda_hero_8x4",
"itemType": ITEM_TYPE_SKIN,
"skinId": "panda_hero_8x4",
"name": "熊猫侠",
"category": "outfit",
"description": "黑白连帽外观的人类角色皮肤四方向8帧动作适合想要更鲜明角色辨识度的玩家。",
"price": 980,
"status": STATUS_AVAILABLE,
"tags": ["可预览", "永久", "皮肤"],
"icon": "res://assets/ui/mall/skins/panda_hero_8x4_product.png",
"sortOrder": 40,
},
{
"id": "skin_ordinary_man_male_8x4",
"itemType": ITEM_TYPE_SKIN,
"skinId": "ordinary_man_male_8x4",
"name": "普通人(男)",
"category": "outfit",
"description": "男性日常角色皮肤四方向8帧动作适合普通玩家形象。",
"price": 980,
"status": STATUS_AVAILABLE,
"tags": ["可预览", "永久", "皮肤"],
"icon": "res://assets/ui/mall/skins/ordinary_man_male_8x4_product.png",
"sortOrder": 50,
},
{
"id": "decor_whale_floor_rug",
"itemType": ITEM_TYPE_ROOM_DECOR,
"decorId": "whale_floor_rug",
"name": "鲸浪地毯",
"category": "space",
"description": "蓝白鲸鱼主题地毯,购买后会进入房间家具背包,可在个人房间自由拖拽摆放。",
"price": 260,
"status": STATUS_AVAILABLE,
"tags": ["家具", "可拖拽", "地面"],
"icon": "res://assets/ui/mall/items/room_decor_whale_floor_rug.png",
"sortOrder": 110,
},
{
"id": "decor_whale_memory_board",
"itemType": ITEM_TYPE_ROOM_DECOR,
"decorId": "whale_memory_board",
"name": "鲸语记忆板",
"category": "space",
"description": "鲸鱼木质装饰板,购买后会进入房间家具背包,可在个人房间自由拖拽摆放。",
"price": 220,
"status": STATUS_AVAILABLE,
"tags": ["家具", "可拖拽", "挂件"],
"icon": "res://assets/ui/mall/items/room_decor_whale_memory_board.png",
"sortOrder": 120,
},
{
"id": "decor_whale_tail_lamp",
"itemType": ITEM_TYPE_ROOM_DECOR,
"decorId": "whale_tail_lamp",
"name": "鲸尾暖灯",
"category": "space",
"description": "鲸尾造型的温暖装饰灯,购买后会进入房间家具背包,可在个人房间自由拖拽摆放。",
"price": 360,
"status": STATUS_AVAILABLE,
"tags": ["家具", "可拖拽", "灯具"],
"icon": "res://assets/ui/mall/items/room_decor_whale_tail_lamp.png",
"sortOrder": 130,
},
{
"id": "decor_boat_cabin_bed",
"itemType": ITEM_TYPE_ROOM_DECOR,
"decorId": "boat_cabin_bed",
"name": "船舱小床",
"category": "space",
"description": "白木船舱造型的小床,带海风细节,购买后会进入家具背包,可在个人房间自由拖拽摆放。",
"price": 520,
"status": STATUS_AVAILABLE,
"tags": ["家具", "可拖拽", ""],
"icon": "res://assets/ui/mall/items/room_decor_boat_cabin_bed.png",
"sortOrder": 140,
},
{
"id": "decor_low_wave_bed",
"itemType": ITEM_TYPE_ROOM_DECOR,
"decorId": "low_wave_bed",
"name": "海浪低床",
"category": "space",
"description": "蓝白海浪被面的低矮小床,购买后会进入家具背包,可在个人房间自由拖拽摆放。",
"price": 500,
"status": STATUS_AVAILABLE,
"tags": ["家具", "可拖拽", ""],
"icon": "res://assets/ui/mall/items/room_decor_low_wave_bed.png",
"sortOrder": 150,
},
{
"id": "decor_whale_tail_headboard_bed",
"itemType": ITEM_TYPE_ROOM_DECOR,
"decorId": "whale_tail_headboard_bed",
"name": "鲸尾床头床",
"category": "space",
"description": "鲸尾床头和深蓝被面的主题小床,购买后会进入家具背包,可在个人房间自由拖拽摆放。",
"price": 580,
"status": STATUS_AVAILABLE,
"tags": ["家具", "可拖拽", ""],
"icon": "res://assets/ui/mall/items/room_decor_whale_tail_headboard_bed.png",
"sortOrder": 180,
},
{
"id": "decor_dev_whale_bookshelf",
"itemType": ITEM_TYPE_ROOM_DECOR,
"decorId": "dev_whale_bookshelf",
"name": "程序员鲸书架",
"category": "space",
"description": "带 GitHub、Datawhale 和代码小物件的蓝白书架,购买后会进入家具背包,可在个人房间自由拖拽摆放。",
"price": 620,
"status": STATUS_AVAILABLE,
"tags": ["家具", "可拖拽", "书架"],
"icon": "res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png",
"sortOrder": 190,
},
{
"id": "decor_datawhale_bug_feature_badge",
"itemType": ITEM_TYPE_ROOM_DECOR,
"decorId": "datawhale_bug_feature_badge",
"name": "BUG特性徽章",
"category": "space",
"description": "写着“这不是BUG 这是feature”的佛系学习小徽章适合贴在个人房间墙面。",
"price": 120,
"status": STATUS_AVAILABLE,
"tags": ["家具", "可拖拽", "徽章"],
"icon": "res://assets/ui/mall/items/room_decor_datawhale_bug_feature_badge.png",
"sortOrder": 200,
},
{
"id": "decor_datawhale_buddhist_learning_badge",
"itemType": ITEM_TYPE_ROOM_DECOR,
"decorId": "datawhale_buddhist_learning_badge",
"name": "佛系学习徽章",
"category": "space",
"description": "Datawhale 佛系学习主题徽章,适合贴在个人房间墙面。",
"price": 140,
"status": STATUS_AVAILABLE,
"tags": ["家具", "可拖拽", "徽章"],
"icon": "res://assets/ui/mall/items/room_decor_datawhale_buddhist_learning_badge.png",
"sortOrder": 210,
},
{
"id": "decor_datawhale_ok_working_badge",
"itemType": ITEM_TYPE_ROOM_DECOR,
"decorId": "datawhale_ok_working_badge",
"name": "已经在做徽章",
"category": "space",
"description": "写着“OKKKK 已经在做了”的工作状态徽章,适合贴在个人房间墙面。",
"price": 120,
"status": STATUS_AVAILABLE,
"tags": ["家具", "可拖拽", "徽章"],
"icon": "res://assets/ui/mall/items/room_decor_datawhale_ok_working_badge.png",
"sortOrder": 220,
},
]
static func get_categories() -> Array[Dictionary]:
return CATEGORIES.duplicate(true)
static func get_items() -> Array[Dictionary]:
return MALL_ITEMS.duplicate(true)
static func get_items_for_category(categoryId: String) -> Array[Dictionary]:
var result: Array[Dictionary] = []
for item in MALL_ITEMS:
var itemCategory := str(item.get("category", ""))
if categoryId == "recommended" or itemCategory == categoryId:
result.append(item.duplicate(true))
result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return int(a.get("sortOrder", 0)) < int(b.get("sortOrder", 0))
)
return result
static func get_category_icon_path(categoryId: String) -> String:
return str(CATEGORY_ICON_PATHS.get(categoryId, CATEGORY_ICON_PATHS["recommended"]))
static func get_status_label(status: String) -> String:
match status:
STATUS_AVAILABLE:
return "可购买"
STATUS_OWNED:
return "已购买"
STATUS_COMING_SOON:
return "未开放"
STATUS_LOCKED:
return "未解锁"
_:
return "未知"
static func can_purchase(item: Dictionary, balance: int) -> bool:
var status := str(item.get("status", STATUS_COMING_SOON))
var price := int(item.get("price", 0))
return status == STATUS_AVAILABLE and balance >= price

View File

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

120
Config/RoomDecorCatalog.gd Normal file
View File

@@ -0,0 +1,120 @@
class_name RoomDecorCatalog
extends RefCounted
const ROOM_SCALE: float = 0.7
const BED_DEFAULT_SCALE: float = ROOM_SCALE
const BOOKSHELF_DEFAULT_SCALE: float = 0.12
const FLOOR_RUG_DEFAULT_SCALE: float = 1.0
const WALL_DECOR_DEFAULT_SCALE: float = 0.04
const DEFINITIONS: Dictionary = {
"whale_floor_rug": {
"decor_id": "whale_floor_rug",
"name": "鲸浪地毯",
"icon": "res://assets/ui/mall/items/room_decor_whale_floor_rug.png",
"texture": "res://assets/maps/personal_space/v1/decor/room_decor_whale_floor_rug_roomfit.png",
"default_position": Vector2(0, 161),
"default_scale": FLOOR_RUG_DEFAULT_SCALE,
"default_z_index": -8,
},
"whale_memory_board": {
"decor_id": "whale_memory_board",
"name": "鲸语记忆板",
"icon": "res://assets/ui/mall/items/room_decor_whale_memory_board.png",
"default_position": Vector2(182, -207),
"default_scale": 0.11,
"default_z_index": -14,
},
"whale_tail_lamp": {
"decor_id": "whale_tail_lamp",
"name": "鲸尾暖灯",
"icon": "res://assets/ui/mall/items/room_decor_whale_tail_lamp.png",
"texture": "res://assets/maps/personal_space/v1/decor/room_decor_whale_tail_lamp_roomfit.png",
"default_position": Vector2(231, -175),
"default_scale": 1.0,
"default_z_index": -10,
"collision_size": Vector2(50, 32),
"collision_offset": Vector2(0, 56),
},
"boat_cabin_bed": {
"decor_id": "boat_cabin_bed",
"name": "船舱小床",
"icon": "res://assets/ui/mall/items/room_decor_boat_cabin_bed.png",
"texture": "res://assets/maps/personal_space/v1/decor/room_decor_boat_cabin_bed_roomfit.png",
"default_position": Vector2(-161, 25),
"default_scale": BED_DEFAULT_SCALE,
"default_z_index": -9,
"collision_size": Vector2(220, 112),
"collision_offset": Vector2(0, 52),
},
"low_wave_bed": {
"decor_id": "low_wave_bed",
"name": "海浪低床",
"icon": "res://assets/ui/mall/items/room_decor_low_wave_bed.png",
"texture": "res://assets/maps/personal_space/v1/decor/room_decor_low_wave_bed_roomfit.png",
"default_position": Vector2(-98, 39),
"default_scale": BED_DEFAULT_SCALE,
"default_z_index": -9,
"collision_size": Vector2(220, 112),
"collision_offset": Vector2(0, 56),
},
"whale_tail_headboard_bed": {
"decor_id": "whale_tail_headboard_bed",
"name": "鲸尾床头床",
"icon": "res://assets/ui/mall/items/room_decor_whale_tail_headboard_bed.png",
"texture": "res://assets/maps/personal_space/v1/decor/room_decor_whale_tail_headboard_bed_roomfit.png",
"default_position": Vector2(0, 32),
"default_scale": BED_DEFAULT_SCALE,
"default_z_index": -9,
"collision_size": Vector2(214, 112),
"collision_offset": Vector2(0, 62),
},
"dev_whale_bookshelf": {
"decor_id": "dev_whale_bookshelf",
"name": "程序员鲸书架",
"icon": "res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png",
"texture": "res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png",
"default_position": Vector2(-210, -39),
"default_scale": BOOKSHELF_DEFAULT_SCALE,
"default_z_index": -10,
"collision_size": Vector2(626.667, 226.667),
"collision_offset": Vector2(0, 580),
},
"datawhale_bug_feature_badge": {
"decor_id": "datawhale_bug_feature_badge",
"name": "BUG特性徽章",
"icon": "res://assets/ui/mall/items/room_decor_datawhale_bug_feature_badge.png",
"texture": "res://assets/maps/personal_space/v1/decor/room_decor_datawhale_bug_feature_badge_hires_clean.png",
"default_position": Vector2(-210, -203),
"default_scale": WALL_DECOR_DEFAULT_SCALE,
"default_z_index": -14,
},
"datawhale_buddhist_learning_badge": {
"decor_id": "datawhale_buddhist_learning_badge",
"name": "佛系学习徽章",
"icon": "res://assets/ui/mall/items/room_decor_datawhale_buddhist_learning_badge.png",
"texture": "res://assets/maps/personal_space/v1/decor/room_decor_datawhale_buddhist_learning_badge_hires_clean.png",
"default_position": Vector2(0, -203),
"default_scale": WALL_DECOR_DEFAULT_SCALE,
"default_z_index": -14,
},
"datawhale_ok_working_badge": {
"decor_id": "datawhale_ok_working_badge",
"name": "已经在做徽章",
"icon": "res://assets/ui/mall/items/room_decor_datawhale_ok_working_badge.png",
"texture": "res://assets/maps/personal_space/v1/decor/room_decor_datawhale_ok_working_badge_hires_clean.png",
"default_position": Vector2(210, -203),
"default_scale": WALL_DECOR_DEFAULT_SCALE,
"default_z_index": -14,
},
}
static func get_definition(decorId: String) -> Dictionary:
var definition: Variant = DEFINITIONS.get(decorId, {})
return definition.duplicate(true) if definition is Dictionary else {}
static func get_all_definitions() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for decorId in DEFINITIONS.keys():
result.append(get_definition(str(decorId)))
return result

View File

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

41
Config/game_config.json Normal file
View File

@@ -0,0 +1,41 @@
{
"game": {
"name": "whaleTown",
"version": "1.0.0",
"debug_mode": false
},
"network": {
"active_profile": "production",
"profiles": {
"production": {
"api_base_url": "https://whaletownend.xinghangee.icu",
"chat_ws_url": "wss://whaletownend.xinghangee.icu/game",
"location_ws_url": "wss://whaletownend.xinghangee.icu/game",
"game_ws_url": "wss://whaletownend.xinghangee.icu/game"
}
},
"api_base_url": "https://whaletownend.xinghangee.icu",
"chat_ws_url": "wss://whaletownend.xinghangee.icu/game",
"location_ws_url": "wss://whaletownend.xinghangee.icu/game",
"game_ws_url": "wss://whaletownend.xinghangee.icu/game",
"timeout": 30,
"retry_count": 3
},
"ui": {
"default_font_size": 14,
"toast_duration": 2.0,
"transition_duration": 0.3
},
"gameplay": {
"auto_save_interval": 300,
"max_inventory_slots": 50,
"default_player_stats": {
"level": 1,
"coins": 100,
"exp": 0,
"max_exp": 100,
"energy": 100,
"max_energy": 100
}
}
}

29
README.md Normal file
View File

@@ -0,0 +1,29 @@
# WhaleTown Front V2
WhaleTown V2 前端是基于 Godot 4.6 的 2D 多人小镇客户端。
当前包含:
- 账号登录、注册、外观与设置同步。
- 广场、打工区、咖啡馆和个人空间场景。
- WebSocket 实时聊天、玩家位置和外观同步。
- 商城、钱包、家具背包与房间摆放。
- 咖啡店陪伴机器人与相关雇佣流程。
## 运行
1. 安装 Godot `4.6.x`
2. 用 Godot 打开 `project.godot`
3. 运行默认主场景。
默认连接 WhaleTown 生产 API 和 WebSocket 服务。本地联调时可使用以下环境变量覆盖:
- `WHALETOWN_API_BASE_URL`
- `WHALETOWN_CHAT_WS_URL`
- `WHALETOWN_LOCATION_WS_URL`
- `WHALETOWN_GAME_WS_URL`
- `WHALETOWN_NETWORK_PROFILE`
## 安全
仓库不包含访问 Token、SSH 私钥、`.env` 或本地登录状态。运行时认证信息仅保存在 Godot `user://` 目录。

135
_Core/EventNames.gd Normal file
View File

@@ -0,0 +1,135 @@
# ============================================================================
# 事件名称定义 - EventNames.gd
#
# 定义项目中所有事件的名称常量,确保事件名称的一致性和可维护性
#
# 使用方式:
# EventSystem.emit_event(EventNames.PLAYER_MOVED, data)
# EventSystem.connect_event(EventNames.INTERACT_PRESSED, callback)
# ============================================================================
class_name EventNames
# ============================================================================
# 玩家相关事件
# ============================================================================
const PLAYER_MOVED = "player_moved"
const PLAYER_SPAWNED = "player_spawned"
const REMOTE_PLAYER_JOINED = "remote_player_joined"
const REMOTE_PLAYER_LEFT = "remote_player_left"
const REMOTE_PLAYER_POSITION_UPDATED = "remote_player_position_updated"
const REMOTE_PLAYERS_SNAPSHOT_READY = "remote_players_snapshot_ready"
const REMOTE_SKIN_READY = "remote_skin_ready"
const REMOTE_SKIN_FAILED = "remote_skin_failed"
const PLAYER_HEALTH_CHANGED = "player_health_changed"
const PLAYER_DIED = "player_died"
const PLAYER_RESPAWNED = "player_respawned"
const PLAYER_ATTACK = "player_attack"
# ============================================================================
# 交互事件
# ============================================================================
const INTERACT_PRESSED = "interact_pressed"
const NPC_TALKED = "npc_talked"
const ITEM_COLLECTED = "item_collected"
const OBJECT_INTERACTED = "object_interacted"
# ============================================================================
# UI事件
# ============================================================================
const UI_BUTTON_CLICKED = "ui_button_clicked"
const DIALOG_OPENED = "dialog_opened"
const DIALOG_CLOSED = "dialog_closed"
const MENU_OPENED = "menu_opened"
const MENU_CLOSED = "menu_closed"
# ============================================================================
# 游戏状态事件
# ============================================================================
const GAME_PAUSED = "game_paused"
const GAME_RESUMED = "game_resumed"
const SCENE_CHANGED = "scene_changed"
const SCENE_DATA_TRANSFER = "scene_data_transfer"
# ============================================================================
# 系统事件
# ============================================================================
const TILEMAP_READY = "tilemap_ready"
const COMPONENT_MESSAGE = "component_message"
const POSITION_UPDATE = "position_update"
const GRID_POSITION_CHANGED = "grid_position_changed"
const GRID_SNAP_REQUESTED = "grid_snap_requested"
const SETTINGS_CHANGED = "settings_changed"
const PLAYER_STATE_CHANGED = "player_state_changed"
const PLAYER_WALLET_CHANGED = "player_wallet_changed"
const PLAYER_INVENTORY_CHANGED = "player_inventory_changed"
# ============================================================================
# 测试事件
# ============================================================================
const TEST_EVENT = "test_event"
# ============================================================================
# 聊天事件
# ============================================================================
const CHAT_MESSAGE_SENT = "chat_message_sent"
const CHAT_MESSAGE_RECEIVED = "chat_message_received"
const CHAT_ERROR_OCCURRED = "chat_error_occurred"
const CHAT_CONNECTION_STATE_CHANGED = "chat_connection_state_changed"
const CHAT_POSITION_UPDATED = "chat_position_updated"
const CHAT_LOGIN_SUCCESS = "chat_login_success"
const CHAT_LOGIN_FAILED = "chat_login_failed"
const CHAT_PRIVATE_TARGET_SELECTED = "chat_private_target_selected"
const CHAT_FRIEND_SELECTED = "chat_friend_selected"
const CHAT_FRIENDS_UPDATED = "chat_friends_updated"
# ============================================================================
# 咖啡店陪伴机器人事件
# ============================================================================
const CAFE_COMPANION_SELECTED = "cafe_companion_selected"
const CAFE_COMPANION_SELF_SELECTED = "cafe_companion_self_selected"
const CAFE_COMPANION_PRODUCTS_READY = "cafe_companion_products_ready"
const CAFE_COMPANION_CHAT_TIME_PURCHASED = "cafe_companion_chat_time_purchased"
const CAFE_COMPANION_MESSAGE_RECEIVED = "cafe_companion_message_received"
const CAFE_COMPANION_ERROR_OCCURRED = "cafe_companion_error_occurred"
const CAFE_COMPANION_AGENT_REGISTERED = "cafe_companion_agent_registered"
const CAFE_COMPANION_EMPLOYMENT_RESIGNED = "cafe_companion_employment_resigned"
const CAFE_COMPANION_RECRUITMENT_SELECTED = "cafe_companion_recruitment_selected"
const CAFE_COMPANION_SERVICE_POINTS_READY = "cafe_companion_service_points_ready"
const CAFE_COMPANION_MODELS_READY = "cafe_companion_models_ready"
# ============================================================================
# HUD事件
# ============================================================================
const HUD_FRIEND_LIST_TOGGLE = "hud_friend_list_toggle"
const HUD_SETTINGS_REQUESTED = "hud_settings_requested"
const HUD_BACKPACK_TOGGLE = "hud_backpack_toggle"
const HUD_MAP_TOGGLE = "hud_map_toggle"
# ============================================================================
# 商城事件
# ============================================================================
const MALL_OPENED = "mall_opened"
const MALL_CLOSED = "mall_closed"
const MALL_CATEGORY_CHANGED = "mall_category_changed"
const MALL_ITEM_SELECTED = "mall_item_selected"
const MALL_PREVIEW_REQUESTED = "mall_preview_requested"
const MALL_PURCHASE_REQUESTED = "mall_purchase_requested"
const MALL_PURCHASE_SUCCEEDED = "mall_purchase_succeeded"
const MALL_PURCHASE_FAILED = "mall_purchase_failed"
# ============================================================================
# 外观事件
# ============================================================================
const APPEARANCE_SKIN_CHANGED = "appearance_skin_changed"
const APPEARANCE_AVATAR_CHANGED = "appearance_avatar_changed"
const APPEARANCE_PROFILE_CHANGED = "appearance_profile_changed"
# ============================================================================
# 认证事件
# ============================================================================
const AUTH_LOGIN_SUCCESS = "auth_login_success"
const AUTH_LOGIN_FAILED = "auth_login_failed"
const AUTH_REGISTER_SUCCESS = "auth_register_success"
const AUTH_REGISTER_FAILED = "auth_register_failed"
const AUTH_LOGOUT = "auth_logout"

1
_Core/EventNames.gd.uid Normal file
View File

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

130
_Core/managers/ApiClient.gd Normal file
View File

@@ -0,0 +1,130 @@
extends Node
# ============================================================================
# ApiClient.gd - 后端 JSON API 客户端
# ============================================================================
# 统一处理 WhaleTown 后端 HTTP JSON 请求、认证头和响应信封解析。
# ============================================================================
signal request_failed(endpoint: String, message: String)
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const REQUEST_TIMEOUT: float = 12.0
var _activeRequests: Array[HTTPRequest] = []
func _exit_tree() -> void:
for request in _activeRequests:
if is_instance_valid(request):
request.cancel_request()
request.queue_free()
_activeRequests.clear()
func get_json(endpoint: String, callback: Callable, authenticated: bool = true) -> void:
request_json(endpoint, {}, callback, HTTPClient.METHOD_GET, authenticated)
func post_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_POST, authenticated)
func patch_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_PATCH, authenticated)
func put_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_PUT, authenticated)
func request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_GET, authenticated: bool = true) -> void:
var request := HTTPRequest.new()
request.timeout = REQUEST_TIMEOUT
add_child(request)
_activeRequests.append(request)
request.request_completed.connect(func(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
_activeRequests.erase(request)
request.queue_free()
_handle_response(endpoint, result, responseCode, body, callback)
)
var url := "%s%s" % [NetworkConfig.get_api_base_url(), endpoint]
var body := "" if method == HTTPClient.METHOD_GET else JSON.stringify(payload)
var err := request.request(url, _headers(authenticated), method, body)
if err != OK:
_activeRequests.erase(request)
request.queue_free()
var message := "网络请求发送失败: %s" % error_string(err)
request_failed.emit(endpoint, message)
callback.call(false, {}, {"message": message})
func _headers(authenticated: bool) -> PackedStringArray:
var headers := PackedStringArray(["Content-Type: application/json"])
if authenticated:
var authManager := get_node_or_null("/root/AuthManager")
var accessToken := str(authManager.call("get_access_token")).strip_edges() if authManager != null and authManager.has_method("get_access_token") else ""
if not accessToken.is_empty():
headers.append("Authorization: Bearer %s" % accessToken)
return headers
func _handle_response(endpoint: String, result: int, responseCode: int, body: PackedByteArray, callback: Callable) -> void:
if result != HTTPRequest.RESULT_SUCCESS:
var message := "网络请求失败: %s" % _http_result_to_string(result)
request_failed.emit(endpoint, message)
callback.call(false, {}, {"message": message})
return
var bodyText := body.get_string_from_utf8()
var json := JSON.new()
if json.parse(bodyText) != OK:
var parseMessage := "服务器响应解析失败"
request_failed.emit(endpoint, parseMessage)
callback.call(false, {}, {"message": parseMessage, "response_code": responseCode})
return
var payloadVariant: Variant = json.data
if not (payloadVariant is Dictionary):
var formatMessage := "服务器响应格式错误"
request_failed.emit(endpoint, formatMessage)
callback.call(false, {}, {"message": formatMessage, "response_code": responseCode})
return
var response: Dictionary = payloadVariant
var ok := responseCode >= 200 and responseCode < 300 and bool(response.get("success", true))
if ok:
callback.call(true, response, {})
return
var errorInfo := {
"message": str(response.get("message", "请求失败")),
"response_code": responseCode,
"error_code": str(response.get("error_code", ""))
}
request_failed.emit(endpoint, str(errorInfo.get("message", "请求失败")))
callback.call(false, response, errorInfo)
func _http_result_to_string(result: int) -> String:
match result:
HTTPRequest.RESULT_CHUNKED_BODY_SIZE_MISMATCH:
return "响应分块大小不匹配"
HTTPRequest.RESULT_CANT_CONNECT:
return "无法连接服务器"
HTTPRequest.RESULT_CANT_RESOLVE:
return "无法解析服务器地址"
HTTPRequest.RESULT_CONNECTION_ERROR:
return "连接中断"
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
return "TLS握手失败"
HTTPRequest.RESULT_NO_RESPONSE:
return "服务器无响应"
HTTPRequest.RESULT_BODY_SIZE_LIMIT_EXCEEDED:
return "响应内容过大"
HTTPRequest.RESULT_REQUEST_FAILED:
return "请求失败"
HTTPRequest.RESULT_DOWNLOAD_FILE_CANT_OPEN:
return "下载文件无法打开"
HTTPRequest.RESULT_DOWNLOAD_FILE_WRITE_ERROR:
return "下载文件写入失败"
HTTPRequest.RESULT_REDIRECT_LIMIT_REACHED:
return "重定向次数过多"
HTTPRequest.RESULT_TIMEOUT:
return "请求超时"
_:
return "未知错误 %s" % result

View File

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

View File

@@ -0,0 +1,856 @@
extends Node
# ============================================================================
# AppearanceManager.gd - 玩家外观偏好管理器
# ============================================================================
# 统一管理玩家皮肤和头像偏好。账号资产以后端 profile 为准,本地只保留
# 当前会话内的注册/上传预览纹理,避免不同账号之间串用 user:// 资产。
# ============================================================================
const CONFIG_PATH: String = "user://appearance.cfg"
const FALLBACK_SKIN_ID: String = "classic_whale"
const DEFAULT_AVATAR_ID: String = "default"
const CUSTOM_AVATAR_ID: String = "custom"
const CUSTOM_SKIN_ID: String = "custom_skin"
const CUSTOM_SKIN_HFRAMES: int = 8
const CUSTOM_SKIN_VFRAMES: int = 4
const CUSTOM_AVATAR_SIZE: int = 256
const REMOTE_SKIN_MAX_BYTES: int = 2 * 1024 * 1024
const REMOTE_SKIN_RETRY_DELAY: float = 3.0
const REMOTE_SKIN_MAX_RETRIES: int = 3
const REMOTE_SKIN_CACHE_DIR: String = "user://skin_cache"
const AVATAR_CORNER_RADIUS_RATIO: float = 0.22
const DEFAULT_CHARACTER_TEXTURE_PATH: String = "res://assets/characters/player_pixel_spritesheet.png"
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const SKINS: Array[Dictionary] = [
{
"id": "classic_whale",
"name": "经典鲸鱼",
"desc": "圆润轻快的鲸鱼居民角色",
"texture": DEFAULT_CHARACTER_TEXTURE_PATH,
"hframes": 4,
"vframes": 4,
"modulate": Color(1.0, 1.0, 1.0, 1.0),
"accent": Color(0.392157, 0.717647, 0.94902, 1.0),
},
{
"id": "human_whale_directional_v2_8x4",
"name": "海风行者8帧",
"desc": "四方向分别生成的8帧高清人类角色",
"texture": "res://assets/characters/skins/human_whale_directional_v2_8x4_spritesheet.png",
"hframes": 8,
"vframes": 4,
"modulate": Color(1.0, 1.0, 1.0, 1.0),
"accent": Color(0.109804, 0.309804, 0.568627, 1.0),
},
{
"id": "girl_sailor_turnaround_v2_8x4",
"name": "海风少女8帧",
"desc": "四方向分别生成的8帧高清女生角色",
"texture": "res://assets/characters/skins/girl_sailor_turnaround_v2_8x4_spritesheet.png",
"hframes": 8,
"vframes": 4,
"modulate": Color(1.0, 1.0, 1.0, 1.0),
"accent": Color(0.109804, 0.309804, 0.568627, 1.0),
},
{
"id": "panda_hero_8x4",
"name": "熊猫侠",
"desc": "玩家上传参考图生成的四方向8帧角色",
"texture": "res://assets/characters/skins/panda_hero_8x4_spritesheet.png",
"hframes": 8,
"vframes": 4,
"modulate": Color(1.0, 1.0, 1.0, 1.0),
"accent": Color(0.12, 0.12, 0.12, 1.0),
},
{
"id": "ordinary_man_male_8x4",
"name": "普通人(男)",
"desc": "四方向8帧的男性日常角色",
"texture": "res://assets/characters/skins/ordinary_man_male_8x4_spritesheet.png",
"hframes": 8,
"vframes": 4,
"modulate": Color(1.0, 1.0, 1.0, 1.0),
"accent": Color(0.109804, 0.309804, 0.568627, 1.0),
},
]
const DEFAULT_AVATAR: Dictionary = {
"id": DEFAULT_AVATAR_ID,
"name": "默认头像",
"label": "",
"color": Color(0.392157, 0.717647, 0.94902, 1.0),
"accent": Color(0.172549, 0.482353, 0.776471, 1.0),
}
var selectedSkinId: String = FALLBACK_SKIN_ID
var selectedAvatarId: String = DEFAULT_AVATAR_ID
var ownedSkinIds: Dictionary = {}
var _customAvatarTexture: Texture2D
var _customAvatarActive: bool = false
var _customSkinTexture: Texture2D
var _customSkinActive: bool = false
var _accountSkinAssets: Dictionary = {}
var _accountSkinTextures: Dictionary = {}
var _remoteSkinTextures: Dictionary = {}
var _remoteSkinRequests: Dictionary = {}
var _remoteSkinRetryCounts: Dictionary = {}
var _accountAvatarUrl: String = ""
var _accountAvatarTexture: Texture2D
var _profileSyncSuppressed: bool = false
func _ready() -> void:
_load()
_connect_auth_events()
_emit_profile_changed()
func get_skin_catalog(includeLocked: bool = true) -> Array[Dictionary]:
var result: Array[Dictionary] = []
for skin in SKINS:
var nextSkin := skin.duplicate(true)
var skinId := str(nextSkin.get("id", "")).strip_edges()
var owned := is_skin_owned(skinId)
nextSkin["owned"] = owned
nextSkin["locked"] = not owned
if includeLocked or owned:
result.append(nextSkin)
for skinId in _accountSkinAssets.keys():
var accountSkin: Dictionary = (_accountSkinAssets[skinId] as Dictionary).duplicate(true)
accountSkin["owned"] = true
accountSkin["locked"] = false
result.append(accountSkin)
return result
func get_owned_skin_catalog() -> Array[Dictionary]:
return get_skin_catalog(false)
func get_registration_skin_catalog() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for skin in SKINS:
var nextSkin := skin.duplicate(true)
nextSkin["owned"] = true
nextSkin["locked"] = false
result.append(nextSkin)
for skinId in _accountSkinAssets.keys():
var accountSkin: Dictionary = (_accountSkinAssets[skinId] as Dictionary).duplicate(true)
accountSkin["owned"] = true
accountSkin["locked"] = false
result.append(accountSkin)
return result
func get_avatar_catalog() -> Array[Dictionary]:
return []
func get_selected_skin_id() -> String:
return selectedSkinId
func get_selected_avatar_id() -> String:
return selectedAvatarId
func get_selected_skin() -> Dictionary:
return get_skin(selectedSkinId)
func get_selected_avatar() -> Dictionary:
return get_avatar(selectedAvatarId)
func get_skin(skinId: String) -> Dictionary:
var normalizedId := skinId.strip_edges()
if normalizedId == CUSTOM_SKIN_ID and has_custom_skin():
return _custom_skin_entry()
if _accountSkinAssets.has(normalizedId):
return (_accountSkinAssets[normalizedId] as Dictionary).duplicate(true)
return _find_entry(SKINS, skinId, FALLBACK_SKIN_ID)
func get_avatar(avatarId: String) -> Dictionary:
var normalizedId := avatarId.strip_edges()
if normalizedId == CUSTOM_AVATAR_ID and has_custom_avatar():
var customAvatar := DEFAULT_AVATAR.duplicate(true)
customAvatar["id"] = CUSTOM_AVATAR_ID
customAvatar["name"] = "账号头像"
customAvatar["label"] = ""
customAvatar["color"] = Color(1, 1, 1, 1)
return customAvatar
if normalizedId == CUSTOM_AVATAR_ID and (_accountAvatarTexture != null or not _accountAvatarUrl.is_empty()):
var accountAvatar := DEFAULT_AVATAR.duplicate(true)
accountAvatar["id"] = CUSTOM_AVATAR_ID
accountAvatar["name"] = "账号头像"
accountAvatar["label"] = ""
accountAvatar["image_path"] = _accountAvatarUrl
accountAvatar["color"] = Color(1, 1, 1, 1)
return accountAvatar
return DEFAULT_AVATAR.duplicate(true)
func set_owned_skin_ids(skinIds: Array) -> void:
ownedSkinIds.clear()
for skinIdVariant in skinIds:
var normalizedId := str(skinIdVariant).strip_edges()
if not normalizedId.is_empty():
ownedSkinIds[normalizedId] = true
if not is_skin_owned(selectedSkinId):
selectedSkinId = _first_owned_skin_id()
_save()
_emit_skin_changed()
_emit_profile_changed()
func _first_owned_skin_id() -> String:
for skinId in ownedSkinIds.keys():
var normalizedId := str(skinId).strip_edges()
if not normalizedId.is_empty():
return normalizedId
return FALLBACK_SKIN_ID
func grant_owned_skin(skinId: String) -> void:
var normalizedId := skinId.strip_edges()
if normalizedId.is_empty():
return
ownedSkinIds[normalizedId] = true
_emit_profile_changed()
func is_skin_owned(skinId: String) -> bool:
var normalizedId := skinId.strip_edges()
if _accountSkinAssets.has(normalizedId):
return true
if normalizedId == CUSTOM_SKIN_ID:
return has_custom_skin()
return ownedSkinIds.has(normalizedId)
func set_selected_skin(skinId: String, allowLocked: bool = false) -> bool:
var normalizedId := skinId.strip_edges()
if normalizedId == CUSTOM_SKIN_ID and not has_custom_skin():
return false
if normalizedId.is_empty() or (normalizedId != CUSTOM_SKIN_ID and not _has_entry(SKINS, normalizedId) and not _accountSkinAssets.has(normalizedId)):
return false
if not allowLocked and not is_skin_owned(normalizedId):
return false
if normalizedId == selectedSkinId:
return true
selectedSkinId = normalizedId
_save()
_emit_skin_changed()
_emit_profile_changed()
if not _profileSyncSuppressed:
_sync_account_skin_profile()
return true
func apply_account_profile(profile: Dictionary) -> void:
_profileSyncSuppressed = true
_clear_session_custom_avatar()
_clear_session_custom_skin()
var skinId := str(profile.get("skin_id", "")).strip_edges()
_accountAvatarUrl = _absolute_backend_url(str(profile.get("avatar_url", "")).strip_edges())
_accountAvatarTexture = _texture_from_base64(str(profile.get("avatar_base64", "")).strip_edges())
var ownedSkinsVariant: Variant = profile.get("owned_skins", [])
if ownedSkinsVariant is Array:
apply_account_skin_assets(ownedSkinsVariant as Array)
var ownedVariant: Variant = profile.get("owned_skin_ids", [])
if ownedVariant is Array:
set_owned_skin_ids(ownedVariant)
if not skinId.is_empty():
grant_owned_skin(skinId)
set_selected_skin(skinId, true)
else:
var fallbackSkinId := _first_owned_skin_id()
if selectedSkinId != fallbackSkinId:
selectedSkinId = fallbackSkinId
_save()
_emit_skin_changed()
_emit_profile_changed()
_profileSyncSuppressed = false
if _accountAvatarTexture != null or not _accountAvatarUrl.is_empty():
selectedAvatarId = CUSTOM_AVATAR_ID
_save()
_emit_avatar_changed()
_emit_profile_changed()
elif selectedAvatarId != DEFAULT_AVATAR_ID:
selectedAvatarId = DEFAULT_AVATAR_ID
_save()
_emit_avatar_changed()
_emit_profile_changed()
func clear_account_profile() -> void:
_accountSkinAssets.clear()
_accountSkinTextures.clear()
_accountAvatarUrl = ""
_accountAvatarTexture = null
_clear_session_custom_avatar()
_clear_session_custom_skin()
selectedSkinId = FALLBACK_SKIN_ID
selectedAvatarId = DEFAULT_AVATAR_ID
ownedSkinIds.clear()
_save()
_emit_skin_changed()
_emit_avatar_changed()
_emit_profile_changed()
func apply_account_skin_assets(skins: Array) -> void:
_accountSkinAssets.clear()
_accountSkinTextures.clear()
for skinVariant in skins:
if not (skinVariant is Dictionary):
continue
var skin := _normalize_account_skin_asset(skinVariant as Dictionary)
var skinId := str(skin.get("id", "")).strip_edges()
if skinId.is_empty():
continue
_accountSkinAssets[skinId] = skin
ownedSkinIds[skinId] = true
if skin.has("texture_base64"):
var texture := _texture_from_base64(str(skin.get("texture_base64", "")))
if texture != null:
_accountSkinTextures[skinId] = texture
_emit_profile_changed()
func apply_account_avatar_base64(avatarUrl: String, avatarBase64: String) -> void:
_accountAvatarUrl = _absolute_backend_url(avatarUrl.strip_edges())
_customAvatarTexture = null
_customAvatarActive = false
_accountAvatarTexture = _texture_from_base64(avatarBase64)
if _accountAvatarTexture != null or not _accountAvatarUrl.is_empty():
selectedAvatarId = CUSTOM_AVATAR_ID
else:
selectedAvatarId = DEFAULT_AVATAR_ID
_save()
_emit_avatar_changed()
_emit_profile_changed()
func set_selected_avatar(avatarId: String) -> bool:
var normalizedId := avatarId.strip_edges()
if normalizedId == CUSTOM_AVATAR_ID and not has_custom_avatar():
return false
if normalizedId != DEFAULT_AVATAR_ID and normalizedId != CUSTOM_AVATAR_ID:
return false
if normalizedId == selectedAvatarId:
return true
selectedAvatarId = normalizedId
_save()
_emit_avatar_changed()
_emit_profile_changed()
return true
func set_custom_avatar_from_file(sourcePath: String) -> bool:
var image := Image.load_from_file(sourcePath)
if image == null:
push_warning("AppearanceManager: 头像图片读取失败: %s" % sourcePath)
return false
var cropped := _create_square_avatar_image(image)
_apply_rounded_rect_alpha(cropped)
_customAvatarTexture = ImageTexture.create_from_image(cropped)
_customAvatarActive = true
selectedAvatarId = CUSTOM_AVATAR_ID
_save()
_emit_avatar_changed()
_emit_profile_changed()
return true
func set_custom_skin_from_file(sourcePath: String) -> bool:
var image := Image.load_from_file(sourcePath)
if image == null:
push_warning("AppearanceManager: 角色皮肤图片读取失败: %s" % sourcePath)
return false
if image.get_width() <= 0 or image.get_height() <= 0:
push_warning("AppearanceManager: 角色皮肤图片尺寸无效: %s" % sourcePath)
return false
if image.get_width() % CUSTOM_SKIN_HFRAMES != 0 or image.get_height() % CUSTOM_SKIN_VFRAMES != 0:
push_warning("AppearanceManager: 自定义角色皮肤需要 %dx%d 网格: %s" % [CUSTOM_SKIN_HFRAMES, CUSTOM_SKIN_VFRAMES, sourcePath])
return false
if image.get_format() != Image.FORMAT_RGBA8:
image.convert(Image.FORMAT_RGBA8)
_customSkinTexture = ImageTexture.create_from_image(image)
_customSkinActive = true
grant_owned_skin(CUSTOM_SKIN_ID)
selectedSkinId = CUSTOM_SKIN_ID
_save()
_emit_skin_changed()
_emit_profile_changed()
return true
func has_custom_avatar() -> bool:
return (_customAvatarActive and _customAvatarTexture != null) or _accountAvatarTexture != null or not _accountAvatarUrl.is_empty()
func has_custom_skin() -> bool:
return _customSkinActive and _customSkinTexture != null
func get_custom_avatar_texture() -> Texture2D:
if has_custom_avatar():
return _customAvatarTexture
return _accountAvatarTexture
func get_custom_skin_texture() -> Texture2D:
return _customSkinTexture
func get_account_skin_texture(skinId: String) -> Texture2D:
var normalizedId := skinId.strip_edges()
if _accountSkinTextures.has(normalizedId):
return _accountSkinTextures[normalizedId] as Texture2D
var skin: Dictionary = _accountSkinAssets.get(normalizedId, {})
var textureBase64 := str(skin.get("texture_base64", "")).strip_edges()
if not textureBase64.is_empty():
var texture := _texture_from_base64(textureBase64)
if texture != null:
_accountSkinTextures[normalizedId] = texture
return texture
return null
func is_remote_skin_ready(skinId: String, skinAsset: Dictionary) -> bool:
var normalizedId := skinId.strip_edges()
if normalizedId.is_empty():
return true
if _has_entry(SKINS, normalizedId):
return true
if _accountSkinTextures.has(normalizedId) or _remoteSkinTextures.has(normalizedId):
return true
return false
func request_remote_skin(skinId: String, skinAsset: Dictionary) -> void:
var normalizedId := skinId.strip_edges()
if normalizedId.is_empty() or is_remote_skin_ready(normalizedId, skinAsset):
_emit_remote_skin_event(EventNames.REMOTE_SKIN_READY, normalizedId, skinAsset)
return
if _remoteSkinRequests.has(normalizedId):
return
var normalizedAsset := _normalize_account_skin_asset(skinAsset)
var textureUrl := str(normalizedAsset.get("texture_url", "")).strip_edges()
if textureUrl.is_empty():
_emit_remote_skin_event(EventNames.REMOTE_SKIN_FAILED, normalizedId, normalizedAsset)
return
var cachedTexture := _load_remote_skin_cache(normalizedId, normalizedAsset)
if cachedTexture != null:
_remoteSkinTextures[normalizedId] = cachedTexture
_emit_remote_skin_event(EventNames.REMOTE_SKIN_READY, normalizedId, normalizedAsset)
return
var request := HTTPRequest.new()
request.timeout = 12.0
request.body_size_limit = REMOTE_SKIN_MAX_BYTES
add_child(request)
_remoteSkinRequests[normalizedId] = request
request.request_completed.connect(_on_remote_skin_request_completed.bind(normalizedId, normalizedAsset, request), CONNECT_ONE_SHOT)
var err := request.request(textureUrl)
if err != OK:
_remoteSkinRequests.erase(normalizedId)
request.queue_free()
_handle_remote_skin_failure(normalizedId, normalizedAsset)
func get_remote_skin_texture(skinId: String) -> Texture2D:
var normalizedId := skinId.strip_edges()
if _accountSkinTextures.has(normalizedId):
return _accountSkinTextures[normalizedId] as Texture2D
if _remoteSkinTextures.has(normalizedId):
return _remoteSkinTextures[normalizedId] as Texture2D
return null
func apply_skin_to_sprite(sprite: Sprite2D, skinId: String = "") -> void:
if sprite == null:
return
var skin := get_skin(selectedSkinId if skinId.strip_edges().is_empty() else skinId)
var texture: Texture2D
var appliedSkinId := str(skin.get("id", ""))
if _accountSkinAssets.has(appliedSkinId):
texture = get_account_skin_texture(appliedSkinId)
elif appliedSkinId == CUSTOM_SKIN_ID:
texture = get_custom_skin_texture()
else:
var texturePath := str(skin.get("texture", DEFAULT_CHARACTER_TEXTURE_PATH))
texture = load(texturePath) as Texture2D
if texture != null:
sprite.texture = texture
sprite.modulate = Color.WHITE
sprite.hframes = int(skin.get("hframes", 4))
sprite.vframes = int(skin.get("vframes", 4))
func apply_skin_asset_to_sprite(sprite: Sprite2D, skinId: String, skinAsset: Dictionary) -> void:
if sprite == null:
return
var normalizedAsset := _normalize_account_skin_asset(skinAsset)
var assetSkinId := str(normalizedAsset.get("id", "")).strip_edges()
if assetSkinId.is_empty() or (not skinId.strip_edges().is_empty() and assetSkinId != skinId.strip_edges()):
apply_skin_to_sprite(sprite, skinId)
return
var texture := get_remote_skin_texture(assetSkinId)
if texture == null:
texture = _texture_from_base64(str(normalizedAsset.get("texture_base64", "")))
if texture == null:
apply_skin_to_sprite(sprite, skinId)
return
sprite.texture = texture
sprite.modulate = Color.WHITE
sprite.hframes = int(normalizedAsset.get("hframes", CUSTOM_SKIN_HFRAMES))
sprite.vframes = int(normalizedAsset.get("vframes", CUSTOM_SKIN_VFRAMES))
func _on_remote_skin_request_completed(
result: int,
responseCode: int,
_headers: PackedStringArray,
body: PackedByteArray,
skinId: String,
skinAsset: Dictionary,
request: HTTPRequest
) -> void:
_remoteSkinRequests.erase(skinId)
if is_instance_valid(request):
request.queue_free()
if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300:
_handle_remote_skin_failure(skinId, skinAsset)
return
var texture := _texture_from_png_bytes(body, skinAsset)
if texture == null:
_handle_remote_skin_failure(skinId, skinAsset)
return
_remoteSkinTextures[skinId] = texture
_remoteSkinRetryCounts.erase(skinId)
_save_remote_skin_cache(skinId, body)
_emit_remote_skin_event(EventNames.REMOTE_SKIN_READY, skinId, skinAsset)
func _handle_remote_skin_failure(skinId: String, skinAsset: Dictionary) -> void:
var retryCount := int(_remoteSkinRetryCounts.get(skinId, 0)) + 1
_remoteSkinRetryCounts[skinId] = retryCount
if retryCount < REMOTE_SKIN_MAX_RETRIES:
_retry_remote_skin(skinId, skinAsset)
return
_emit_remote_skin_event(EventNames.REMOTE_SKIN_FAILED, skinId, skinAsset)
func _retry_remote_skin(skinId: String, skinAsset: Dictionary) -> void:
await get_tree().create_timer(REMOTE_SKIN_RETRY_DELAY).timeout
if not is_remote_skin_ready(skinId, skinAsset):
request_remote_skin(skinId, skinAsset)
func _texture_from_png_bytes(bytes: PackedByteArray, skinAsset: Dictionary) -> Texture2D:
if bytes.is_empty() or bytes.size() > REMOTE_SKIN_MAX_BYTES:
return null
var image := Image.new()
if image.load_png_from_buffer(bytes) != OK:
return null
var hframes := maxi(1, int(skinAsset.get("hframes", CUSTOM_SKIN_HFRAMES)))
var vframes := maxi(1, int(skinAsset.get("vframes", CUSTOM_SKIN_VFRAMES)))
if image.get_width() % hframes != 0 or image.get_height() % vframes != 0:
return null
return ImageTexture.create_from_image(image)
func _remote_skin_cache_path(skinId: String) -> String:
return "%s/%s.png" % [REMOTE_SKIN_CACHE_DIR, skinId.sha256_text()]
func _load_remote_skin_cache(skinId: String, skinAsset: Dictionary) -> Texture2D:
var path := _remote_skin_cache_path(skinId)
if not FileAccess.file_exists(path):
return null
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return null
var texture := _texture_from_png_bytes(file.get_buffer(file.get_length()), skinAsset)
if texture == null:
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
return texture
func _save_remote_skin_cache(skinId: String, bytes: PackedByteArray) -> void:
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(REMOTE_SKIN_CACHE_DIR))
var file := FileAccess.open(_remote_skin_cache_path(skinId), FileAccess.WRITE)
if file != null:
file.store_buffer(bytes)
func _emit_remote_skin_event(eventName: String, skinId: String, skinAsset: Dictionary) -> void:
var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem != null:
eventSystem.call_deferred("emit_event", eventName, {
"skin_id": skinId,
"skin_asset": skinAsset,
})
func apply_avatar_to_panel(panel: PanelContainer, label: Label, avatarId: String = "", fallbackText: String = "") -> void:
if panel == null:
return
var avatar := get_avatar(selectedAvatarId if avatarId.strip_edges().is_empty() else avatarId)
panel.add_theme_stylebox_override("panel", create_avatar_style(avatar, _avatar_radius_for(panel)))
panel.clip_contents = true
var textureRect := _get_or_create_avatar_texture_rect(panel)
var avatarTexture := get_custom_avatar_texture() if str(avatar.get("id", "")) == CUSTOM_AVATAR_ID else null
if textureRect != null:
textureRect.texture = avatarTexture
textureRect.visible = avatarTexture != null
textureRect.custom_minimum_size = panel.custom_minimum_size
if label != null:
label.visible = avatarTexture == null
var avatarLabel := str(avatar.get("label", "")).strip_edges()
if avatarLabel.is_empty():
avatarLabel = fallbackText.strip_edges().substr(0, 1)
if avatarLabel.is_empty():
avatarLabel = ""
label.text = avatarLabel
label.add_theme_color_override("font_color", Color.WHITE)
func create_avatar_style(avatar: Dictionary, radius: int = 12, disabled: bool = false) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
var baseColor := avatar.get("color", Color(0.392157, 0.717647, 0.94902, 1.0)) as Color
style.bg_color = baseColor if not disabled else Color(0.678, 0.725, 0.773, 1.0)
style.corner_radius_top_left = radius
style.corner_radius_top_right = radius
style.corner_radius_bottom_left = radius
style.corner_radius_bottom_right = radius
style.border_width_left = 1
style.border_width_top = 1
style.border_width_right = 1
style.border_width_bottom = 1
style.border_color = Color(1, 1, 1, 0.34)
return style
func create_fallback_avatar_style(username: String, radius: int = 12, disabled: bool = false) -> StyleBoxFlat:
var colors: Array[Color] = [
Color(0.392157, 0.717647, 0.94902, 1.0),
Color(0.843137, 0.65098, 0.47451, 1.0),
Color(0.364706, 0.760784, 0.670588, 1.0),
Color(0.584314, 0.592157, 0.894118, 1.0),
Color(0.929412, 0.564706, 0.462745, 1.0),
]
var index: int = abs(hash(username.strip_edges())) % colors.size()
var avatar: Dictionary = {
"color": colors[index],
}
return create_avatar_style(avatar, radius, disabled)
func get_avatar_label(avatarId: String = "") -> String:
var avatar := get_avatar(selectedAvatarId if avatarId.strip_edges().is_empty() else avatarId)
return str(avatar.get("label", ""))
func reset_to_default_avatar() -> bool:
if selectedAvatarId == DEFAULT_AVATAR_ID:
return true
selectedAvatarId = DEFAULT_AVATAR_ID
_customAvatarTexture = null
_customAvatarActive = false
_accountAvatarTexture = null
_accountAvatarUrl = ""
_save()
_emit_avatar_changed()
_emit_profile_changed()
return true
func _find_entry(entries: Array[Dictionary], entryId: String, fallbackId: String) -> Dictionary:
for entry in entries:
if str(entry.get("id", "")) == entryId:
return entry.duplicate(true)
for entry in entries:
if str(entry.get("id", "")) == fallbackId:
return entry.duplicate(true)
return entries[0].duplicate(true) if not entries.is_empty() else {}
func _has_entry(entries: Array[Dictionary], entryId: String) -> bool:
for entry in entries:
if str(entry.get("id", "")) == entryId:
return true
return false
func _avatar_radius_for(panel: PanelContainer) -> int:
var minSize: Vector2 = panel.custom_minimum_size
var smallestSide: float = minf(minSize.x, minSize.y)
if smallestSide <= 0.0:
return 12
return max(8, int(round(smallestSide * AVATAR_CORNER_RADIUS_RATIO)))
func _load() -> void:
var config := ConfigFile.new()
if config.load(CONFIG_PATH) != OK:
selectedSkinId = FALLBACK_SKIN_ID
selectedAvatarId = DEFAULT_AVATAR_ID
return
var skinId := str(config.get_value("appearance", "selected_skin_id", FALLBACK_SKIN_ID)).strip_edges()
var avatarId := str(config.get_value("appearance", "selected_avatar_id", DEFAULT_AVATAR_ID)).strip_edges()
selectedSkinId = skinId if _has_entry(SKINS, skinId) else FALLBACK_SKIN_ID
selectedAvatarId = DEFAULT_AVATAR_ID if avatarId == CUSTOM_AVATAR_ID else avatarId
if selectedAvatarId != DEFAULT_AVATAR_ID:
selectedAvatarId = DEFAULT_AVATAR_ID
func _save() -> void:
var config := ConfigFile.new()
config.set_value("appearance", "selected_skin_id", selectedSkinId)
config.set_value("appearance", "selected_avatar_id", selectedAvatarId)
var err := config.save(CONFIG_PATH)
if err != OK:
push_warning("AppearanceManager: 保存外观配置失败: %s" % error_string(err))
func _emit_skin_changed() -> void:
var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem != null:
eventSystem.call("emit_event", EventNames.APPEARANCE_SKIN_CHANGED, {
"skin_id": selectedSkinId,
"skin": get_selected_skin(),
})
func _emit_avatar_changed() -> void:
var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem != null:
eventSystem.call("emit_event", EventNames.APPEARANCE_AVATAR_CHANGED, {
"avatar_id": selectedAvatarId,
"avatar": get_selected_avatar(),
})
func _emit_profile_changed() -> void:
var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem != null:
eventSystem.call("emit_event", EventNames.APPEARANCE_PROFILE_CHANGED, get_profile_payload())
func _sync_account_skin_profile() -> void:
var authManager := get_node_or_null("/root/AuthManager")
if authManager == null or not authManager.has_method("is_authenticated"):
return
if not bool(authManager.call("is_authenticated")):
return
if authManager.has_method("update_profile"):
authManager.call("update_profile", {
"skin_id": selectedSkinId,
})
func _connect_auth_events() -> void:
var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem == null:
return
eventSystem.call("connect_event", EventNames.AUTH_LOGOUT, _on_auth_logout, self)
func _on_auth_logout(_data: Variant = null) -> void:
clear_account_profile()
func get_profile_payload() -> Dictionary:
return {
"skin_id": selectedSkinId,
"avatar_id": selectedAvatarId,
"avatar_url": _accountAvatarUrl,
"skin": get_selected_skin(),
"avatar": get_selected_avatar(),
}
func _create_square_avatar_image(image: Image) -> Image:
var side: int = mini(image.get_width(), image.get_height())
var cropX: int = int((image.get_width() - side) * 0.5)
var cropY: int = int((image.get_height() - side) * 0.5)
var cropped := image.get_region(Rect2i(cropX, cropY, side, side))
cropped.resize(CUSTOM_AVATAR_SIZE, CUSTOM_AVATAR_SIZE, Image.INTERPOLATE_LANCZOS)
return cropped
func _custom_skin_entry() -> Dictionary:
return {
"id": CUSTOM_SKIN_ID,
"name": "待保存角色",
"desc": "注册页本次上传的8x4角色皮肤预览",
"texture": "",
"hframes": CUSTOM_SKIN_HFRAMES,
"vframes": CUSTOM_SKIN_VFRAMES,
"modulate": Color.WHITE,
"accent": Color(0.109804, 0.309804, 0.568627, 1.0),
"session_preview": true,
}
func _get_or_create_avatar_texture_rect(panel: PanelContainer) -> TextureRect:
var existing := panel.get_node_or_null("AvatarTexture") as TextureRect
if existing != null:
return existing
var textureRect := TextureRect.new()
textureRect.name = "AvatarTexture"
textureRect.mouse_filter = Control.MOUSE_FILTER_IGNORE
textureRect.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
textureRect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
textureRect.stretch_mode = TextureRect.STRETCH_SCALE
textureRect.custom_minimum_size = panel.custom_minimum_size
textureRect.set_anchors_preset(Control.PRESET_FULL_RECT)
panel.add_child(textureRect)
return textureRect
func _normalize_account_skin_asset(skin: Dictionary) -> Dictionary:
var normalized := skin.duplicate(true)
var skinId := str(normalized.get("id", normalized.get("skin_id", ""))).strip_edges()
if skinId.is_empty():
return {}
normalized["id"] = skinId
normalized["name"] = str(normalized.get("name", "账号角色"))
normalized["desc"] = str(normalized.get("desc", "账号自定义角色皮肤"))
normalized["texture"] = _absolute_backend_url(str(normalized.get("texture_url", "")))
normalized["texture_url"] = normalized["texture"]
normalized["hframes"] = int(normalized.get("hframes", CUSTOM_SKIN_HFRAMES))
normalized["vframes"] = int(normalized.get("vframes", CUSTOM_SKIN_VFRAMES))
normalized["modulate"] = Color.WHITE
normalized["accent"] = Color(0.109804, 0.309804, 0.568627, 1.0)
normalized["account_asset"] = true
return normalized
func _texture_from_base64(base64Text: String) -> Texture2D:
var bytes := Marshalls.base64_to_raw(base64Text.strip_edges())
if bytes.is_empty():
return null
var image := Image.new()
var err := image.load_png_from_buffer(bytes)
if err != OK:
return null
return ImageTexture.create_from_image(image)
func _absolute_backend_url(path: String) -> String:
var normalized := path.strip_edges()
if normalized.is_empty() or normalized.begins_with("http://") or normalized.begins_with("https://"):
return normalized
if normalized.begins_with("/"):
return "%s%s" % [NetworkConfig.get_api_base_url(), normalized]
return normalized
func _clear_session_custom_skin() -> void:
if selectedSkinId == CUSTOM_SKIN_ID:
selectedSkinId = _first_owned_skin_id()
_customSkinTexture = null
_customSkinActive = false
ownedSkinIds.erase(CUSTOM_SKIN_ID)
func _clear_session_custom_avatar() -> void:
_customAvatarTexture = null
_customAvatarActive = false
func _apply_rounded_rect_alpha(image: Image) -> void:
var width := image.get_width()
var height := image.get_height()
if width <= 0 or height <= 0:
return
if image.get_format() != Image.FORMAT_RGBA8:
image.convert(Image.FORMAT_RGBA8)
var radius: float = maxf(1.0, minf(float(width), float(height)) * AVATAR_CORNER_RADIUS_RATIO)
var feather: float = 1.5
var maxX: float = float(width - 1)
var maxY: float = float(height - 1)
for x in range(width):
for y in range(height):
var px: float = float(x)
var py: float = float(y)
var cornerCenter: Vector2 = Vector2(px, py)
var inCorner: bool = false
if px < radius and py < radius:
cornerCenter = Vector2(radius, radius)
inCorner = true
elif px > maxX - radius and py < radius:
cornerCenter = Vector2(maxX - radius, radius)
inCorner = true
elif px < radius and py > maxY - radius:
cornerCenter = Vector2(radius, maxY - radius)
inCorner = true
elif px > maxX - radius and py > maxY - radius:
cornerCenter = Vector2(maxX - radius, maxY - radius)
inCorner = true
if inCorner:
var color: Color = image.get_pixel(x, y)
var distance: float = Vector2(px, py).distance_to(cornerCenter)
if distance > radius + feather:
color.a = 0.0
image.set_pixel(x, y, color)
elif distance > radius:
var alphaFactor: float = clampf(1.0 - ((distance - radius) / feather), 0.0, 1.0)
color.a *= alphaFactor
image.set_pixel(x, y, color)

View File

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

View File

@@ -0,0 +1,515 @@
extends Node
# ============================================================================
# AuthManager.gd - V2 用户认证管理器
# ============================================================================
# 负责 V2 的正式多用户登录、注册、token 缓存和登出。
# 聊天系统通过这里获取当前用户 access_token不再在正常游戏流程中共用默认账号。
# ============================================================================
signal auth_state_changed(is_authenticated: bool, user: Dictionary)
signal login_succeeded(user: Dictionary)
signal login_failed(message: String)
signal register_succeeded(user: Dictionary)
signal register_failed(message: String)
signal email_verification_sent(email: String)
signal email_verification_failed(message: String)
signal profile_update_succeeded(profile: Dictionary)
signal profile_update_failed(message: String)
signal logout_completed()
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const DEFAULT_AUTH_CONFIG_PATH: String = "user://auth.cfg"
const REQUEST_TIMEOUT: float = 12.0
var _access_token: String = ""
var _refresh_token: String = ""
var _current_user: Dictionary = {}
var _current_profile: Dictionary = {}
var _active_requests: Array[HTTPRequest] = []
var _session_generation: int = 0
var _account_generation: int = 0
var _refresh_in_flight: bool = false
var _auth_config_path: String = DEFAULT_AUTH_CONFIG_PATH
func _ready() -> void:
_load_cached_session()
func _exit_tree() -> void:
for request in _active_requests:
if is_instance_valid(request):
request.cancel_request()
request.queue_free()
_active_requests.clear()
func is_authenticated() -> bool:
return not _access_token.strip_edges().is_empty()
func get_access_token() -> String:
return _access_token
func get_refresh_token() -> String:
return _refresh_token
func get_current_user() -> Dictionary:
return _current_user.duplicate(true)
func get_current_profile() -> Dictionary:
return _current_profile.duplicate(true)
func get_current_username() -> String:
return str(_current_user.get("username", ""))
func get_session_generation() -> int:
return _session_generation
func get_account_generation() -> int:
return _account_generation
func get_auth_config_path() -> String:
return _auth_config_path
func login(identifier: String, password: String) -> void:
var normalized_identifier := identifier.strip_edges()
if normalized_identifier.is_empty():
login_failed.emit("请输入用户名、邮箱或手机号")
return
if password.is_empty():
login_failed.emit("请输入密码")
return
_advance_account_generation()
_request_json("/auth/login", {
"identifier": normalized_identifier,
"password": password
}, _on_login_response, HTTPClient.METHOD_POST, false, true)
func send_email_verification(email: String) -> void:
var normalized_email := email.strip_edges()
if normalized_email.is_empty():
email_verification_failed.emit("请输入邮箱")
return
if not _is_valid_email(normalized_email):
email_verification_failed.emit("邮箱格式不正确")
return
_request_json("/auth/send-email-verification", {
"email": normalized_email
}, func(success: bool, _data: Dictionary, error_info: Dictionary) -> void:
if success:
email_verification_sent.emit(normalized_email)
else:
email_verification_failed.emit(str(error_info.get("message", "验证码发送失败")))
)
func register(username: String, password: String, nickname: String = "", email: String = "", email_verification_code: String = "", skin_id: String = "") -> void:
var normalized_username := username.strip_edges()
var normalized_nickname := nickname.strip_edges()
var normalized_email := email.strip_edges()
var normalized_code := email_verification_code.strip_edges()
if normalized_username.is_empty():
register_failed.emit("请输入用户名")
return
if not _is_valid_username(normalized_username):
register_failed.emit("用户名只能包含字母、数字和下划线,长度 1-50")
return
if password.length() < 8:
register_failed.emit("密码至少 8 位,并需要包含字母和数字")
return
if not _password_has_letter_and_number(password):
register_failed.emit("密码需要同时包含字母和数字")
return
if normalized_nickname.is_empty():
normalized_nickname = normalized_username
if normalized_email.is_empty():
register_failed.emit("请输入邮箱并先获取验证码")
return
if not _is_valid_email(normalized_email):
register_failed.emit("邮箱格式不正确")
return
if not _is_valid_email_code(normalized_code):
register_failed.emit("请输入邮件中的 6 位验证码")
return
var payload := {
"username": normalized_username,
"password": password,
"nickname": normalized_nickname,
"email": normalized_email,
"email_verification_code": normalized_code
}
var normalized_skin_id := skin_id.strip_edges()
if not normalized_skin_id.is_empty():
payload["skin_id"] = normalized_skin_id
_advance_account_generation()
_request_json("/auth/register", payload, _on_register_response, HTTPClient.METHOD_POST, false, true)
func fetch_profile() -> void:
if not is_authenticated():
return
var playerStateManager := get_node_or_null("/root/PlayerStateManager")
if playerStateManager != null and playerStateManager.has_method("refresh_snapshot"):
playerStateManager.call("refresh_snapshot")
func update_profile(profile_data: Dictionary) -> void:
if not is_authenticated():
profile_update_failed.emit("请先登录")
return
var playerStateManager := get_node_or_null("/root/PlayerStateManager")
if playerStateManager == null:
profile_update_failed.emit("玩家状态管理器未加载")
return
var assetPayload := profile_data.duplicate(true)
assetPayload.erase("skin_id")
assetPayload.erase("settings")
if profile_data.has("skin_id") and playerStateManager.has_method("update_appearance"):
playerStateManager.call(
"update_appearance",
str(profile_data.get("skin_id", "")),
Callable(self, "_on_profile_response")
)
if profile_data.has("settings") and playerStateManager.has_method("update_settings"):
var settingsVariant: Variant = profile_data.get("settings", {})
if settingsVariant is Dictionary:
playerStateManager.call(
"update_settings",
settingsVariant as Dictionary,
Callable(self, "_on_profile_response")
)
if not assetPayload.is_empty():
var api := get_node_or_null("/root/ApiClient")
if api != null and api.has_method("patch_json"):
var requestGeneration := _account_generation
api.call(
"patch_json",
"/player/profile-assets",
assetPayload,
Callable(self, "_on_profile_assets_response").bind(requestGeneration),
true
)
else:
profile_update_failed.emit("ApiClient 不可用")
return
if not profile_data.has("skin_id") and not profile_data.has("settings"):
profile_update_failed.emit("没有可保存的玩家资料")
func refresh_session() -> void:
if _refresh_token.strip_edges().is_empty() or _refresh_in_flight:
return
_refresh_in_flight = true
_request_json("/auth/refresh-token", {
"refresh_token": _refresh_token
}, _on_refresh_response, HTTPClient.METHOD_POST, false, true)
func logout() -> void:
_clear_session_memory()
if FileAccess.file_exists(_auth_config_path):
DirAccess.remove_absolute(_auth_config_path)
auth_state_changed.emit(false, {})
_emit_event(EventNames.AUTH_LOGOUT, {})
logout_completed.emit()
call_deferred("_return_to_auth_scene")
func _request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_POST, authenticated: bool = false, accountBound: bool = false) -> void:
var request := HTTPRequest.new()
request.timeout = REQUEST_TIMEOUT
request.set_meta("account_bound", accountBound)
add_child(request)
_active_requests.append(request)
var requestGeneration := _account_generation
request.request_completed.connect(func(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
_active_requests.erase(request)
request.queue_free()
if accountBound and requestGeneration != _account_generation:
return
_handle_json_response(result, response_code, body, callback)
)
var url := "%s%s" % [NetworkConfig.get_api_base_url(), endpoint]
var headers := PackedStringArray(["Content-Type: application/json"])
if authenticated and not _access_token.strip_edges().is_empty():
headers.append("Authorization: Bearer %s" % _access_token)
var body := "" if method == HTTPClient.METHOD_GET else JSON.stringify(payload)
var err := request.request(url, headers, method, body)
if err != OK:
_active_requests.erase(request)
request.queue_free()
if not accountBound or requestGeneration == _account_generation:
callback.call(false, {}, {"message": "网络请求发送失败: %s" % error_string(err)})
func _handle_json_response(result: int, response_code: int, body: PackedByteArray, callback: Callable) -> void:
var body_text := body.get_string_from_utf8()
if result != HTTPRequest.RESULT_SUCCESS:
callback.call(false, {}, {"message": "网络请求失败: %s" % _http_result_to_string(result)})
return
var json := JSON.new()
if json.parse(body_text) != OK:
callback.call(false, {}, {"message": "服务器响应解析失败"})
return
var payload_variant: Variant = json.data
if not (payload_variant is Dictionary):
callback.call(false, {}, {"message": "服务器响应格式错误"})
return
var response: Dictionary = payload_variant
var success := response_code >= 200 and response_code < 300 and bool(response.get("success", true))
if success:
callback.call(true, response, {})
return
callback.call(false, response, {
"message": str(response.get("message", "请求失败")),
"response_code": response_code,
"error_code": str(response.get("error_code", ""))
})
func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary) -> void:
if not success:
login_failed.emit(str(error_info.get("message", "登录失败")))
return
_apply_auth_payload(data)
if not is_authenticated():
login_failed.emit("登录响应缺少 access_token")
return
_emit_event(EventNames.AUTH_LOGIN_SUCCESS, {
"user": get_current_user()
})
login_succeeded.emit(get_current_user())
auth_state_changed.emit(true, get_current_user())
_refresh_player_snapshot()
func _on_register_response(success: bool, data: Dictionary, error_info: Dictionary) -> void:
if not success:
register_failed.emit(str(error_info.get("message", "注册失败")))
return
_apply_auth_payload(data)
if not is_authenticated():
register_failed.emit("注册响应缺少 access_token")
return
_emit_event(EventNames.AUTH_REGISTER_SUCCESS, {
"user": get_current_user()
})
register_succeeded.emit(get_current_user())
auth_state_changed.emit(true, get_current_user())
_refresh_player_snapshot()
func _on_refresh_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void:
_refresh_in_flight = false
if not success:
logout()
return
_apply_auth_payload(data)
auth_state_changed.emit(true, get_current_user())
_refresh_player_snapshot()
func _apply_auth_payload(payload: Dictionary) -> void:
var data_variant: Variant = payload.get("data", {})
if not (data_variant is Dictionary):
return
var auth_data: Dictionary = data_variant
_access_token = str(auth_data.get("access_token", "")).strip_edges()
_refresh_token = str(auth_data.get("refresh_token", _refresh_token)).strip_edges()
var user_variant: Variant = auth_data.get("user", {})
if user_variant is Dictionary:
_current_user = user_variant
var profile_variant: Variant = auth_data.get("profile", {})
if profile_variant is Dictionary:
_current_profile = profile_variant
_apply_profile_to_appearance_manager()
_session_generation += 1
_save_cached_session()
func _on_profile_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void:
if not success:
profile_update_failed.emit(str(_error_info.get("message", "玩家资料保存失败")))
auth_state_changed.emit(is_authenticated(), get_current_user())
return
var data_variant: Variant = data.get("data", {})
if not (data_variant is Dictionary):
profile_update_failed.emit("玩家状态响应格式错误")
auth_state_changed.emit(is_authenticated(), get_current_user())
return
var snapshot := data_variant as Dictionary
var playerStateManager := get_node_or_null("/root/PlayerStateManager")
if playerStateManager != null and playerStateManager.has_method("apply_snapshot"):
playerStateManager.call("apply_snapshot", snapshot)
_apply_snapshot_profile_payload(snapshot)
profile_update_succeeded.emit(get_current_profile())
auth_state_changed.emit(is_authenticated(), get_current_user())
func _on_profile_assets_response(success: bool, data: Dictionary, _error_info: Dictionary, requestGeneration: int) -> void:
if requestGeneration != _account_generation:
return
if not success:
profile_update_failed.emit(str(_error_info.get("message", "玩家资源更新失败")))
auth_state_changed.emit(is_authenticated(), get_current_user())
return
var dataVariant: Variant = data.get("data", {})
var playerStateManager := get_node_or_null("/root/PlayerStateManager")
if dataVariant is Dictionary and playerStateManager != null and playerStateManager.has_method("apply_snapshot"):
var snapshot := dataVariant as Dictionary
playerStateManager.call("apply_snapshot", snapshot)
_apply_snapshot_profile_payload(snapshot)
profile_update_succeeded.emit(get_current_profile())
auth_state_changed.emit(is_authenticated(), get_current_user())
func _apply_profile_to_appearance_manager() -> void:
if _current_profile.is_empty():
return
var appearanceManager := get_node_or_null("/root/AppearanceManager")
if appearanceManager != null and appearanceManager.has_method("apply_account_profile"):
appearanceManager.call("apply_account_profile", _current_profile)
var settingsVariant: Variant = _current_profile.get("settings", {})
var settingsManager := get_node_or_null("/root/SettingsManager")
if settingsVariant is Dictionary and settingsManager != null and settingsManager.has_method("apply_account_settings"):
settingsManager.call("apply_account_settings", settingsVariant as Dictionary)
func _apply_snapshot_profile_payload(snapshot: Dictionary) -> void:
var userVariant: Variant = snapshot.get("user", {})
if userVariant is Dictionary:
_current_user = (userVariant as Dictionary).duplicate(true)
var profileVariant: Variant = snapshot.get("profile", {})
if not (profileVariant is Dictionary):
return
var profile := (profileVariant as Dictionary).duplicate(true)
var appearance := {}
var appearanceVariant: Variant = snapshot.get("appearance", {})
if appearanceVariant is Dictionary:
appearance = (appearanceVariant as Dictionary).duplicate(true)
var selectedSkinId := str(appearance.get("selected_skin_id", profile.get("selected_skin_id", profile.get("skin_id", "")))).strip_edges()
profile["skin_id"] = selectedSkinId
profile["owned_skin_ids"] = appearance.get("owned_skin_ids", [])
profile["owned_skins"] = appearance.get("owned_skins", [])
_current_profile = profile
_apply_profile_to_appearance_manager()
_save_cached_session()
func _refresh_player_snapshot() -> void:
var playerStateManager := get_node_or_null("/root/PlayerStateManager")
if playerStateManager != null and playerStateManager.has_method("refresh_snapshot"):
playerStateManager.call_deferred("refresh_snapshot")
func _save_cached_session() -> void:
var config := ConfigFile.new()
config.set_value("auth", "refresh_token", _refresh_token)
config.set_value("auth", "access_token", _access_token)
config.set_value("auth", "saved_at", Time.get_unix_time_from_system())
var err := config.save(_auth_config_path)
if err != OK:
push_warning("AuthManager: 保存本地登录状态失败: %s" % error_string(err))
func _load_cached_session(emit_cached_state: bool = true) -> void:
if not FileAccess.file_exists(_auth_config_path):
return
var config := ConfigFile.new()
if config.load(_auth_config_path) != OK:
return
_refresh_token = str(config.get_value("auth", "refresh_token", "")).strip_edges()
_access_token = str(config.get_value("auth", "access_token", "")).strip_edges()
_current_user.clear()
_current_profile.clear()
if is_authenticated():
_session_generation += 1
_account_generation += 1
if emit_cached_state and is_authenticated():
call_deferred("_emit_cached_auth_state")
func _emit_cached_auth_state() -> void:
auth_state_changed.emit(true, get_current_user())
func _clear_session_memory() -> void:
_access_token = ""
_refresh_token = ""
_current_user.clear()
_current_profile.clear()
_advance_account_generation()
func _advance_account_generation() -> void:
_session_generation += 1
_account_generation += 1
_refresh_in_flight = false
for request in _active_requests.duplicate():
if is_instance_valid(request) and bool(request.get_meta("account_bound", false)):
request.cancel_request()
request.queue_free()
_active_requests.erase(request)
func _return_to_auth_scene() -> void:
var tree := get_tree()
if tree == null or tree.current_scene == null:
return
if tree.current_scene.scene_file_path == "res://scenes/ui/AuthScene.tscn":
return
var sceneManager := get_node_or_null("/root/SceneManager")
if sceneManager != null and sceneManager.has_method("change_scene"):
sceneManager.call("change_scene", "auth", false)
func _emit_event(event_name: String, data: Variant = null) -> void:
var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem == null:
return
eventSystem.call("emit_event", event_name, data)
func _is_valid_username(username: String) -> bool:
var regex := RegEx.new()
regex.compile("^[A-Za-z0-9_]{1,50}$")
return regex.search(username) != null
func _is_valid_email(email: String) -> bool:
var regex := RegEx.new()
regex.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")
return regex.search(email) != null
func _is_valid_email_code(code: String) -> bool:
var regex := RegEx.new()
regex.compile("^\\d{6}$")
return regex.search(code) != null
func _password_has_letter_and_number(password: String) -> bool:
var has_letter := false
var has_number := false
for i in range(password.length()):
var code := password.unicode_at(i)
if (code >= 65 and code <= 90) or (code >= 97 and code <= 122):
has_letter = true
if code >= 48 and code <= 57:
has_number = true
return has_letter and has_number
func _http_result_to_string(result: int) -> String:
match result:
HTTPRequest.RESULT_SUCCESS:
return "SUCCESS"
HTTPRequest.RESULT_TIMEOUT:
return "TIMEOUT"
HTTPRequest.RESULT_CANT_CONNECT:
return "CANT_CONNECT"
HTTPRequest.RESULT_CANT_RESOLVE:
return "CANT_RESOLVE"
HTTPRequest.RESULT_CONNECTION_ERROR:
return "CONNECTION_ERROR"
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
return "TLS_HANDSHAKE_ERROR"
_:
return "UNKNOWN_%d" % result

View File

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

View File

@@ -0,0 +1,286 @@
extends Node
# ============================================================================
# CafeCompanionManager.gd - 咖啡店陪伴机器人网络管理器
# ============================================================================
# 负责咖啡店陪伴机器人服务点、付费聊天时长和 Agent
# 聊天代理的前端协议。客户端不保存模型 URL/token。
# ============================================================================
signal cafe_companion_products_ready(data: Dictionary)
signal cafe_companion_service_points_ready(data: Dictionary)
signal cafe_companion_chat_time_purchased(data: Dictionary)
signal cafe_companion_message_received(message: Dictionary)
signal cafe_companion_error_occurred(message: String)
signal cafe_companion_agent_registered(data: Dictionary)
signal cafe_companion_employment_resigned(data: Dictionary)
signal cafe_companion_models_ready(data: Dictionary)
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const REQUEST_TIMEOUT: float = 24.0
var _activeRequests: Array[HTTPRequest] = []
var _cachedProducts: Array = []
var _cachedServicePoints: Array = []
var _accountGeneration: int = -1
func _ready() -> void:
_accountGeneration = _current_account_generation()
var authManager := get_node_or_null("/root/AuthManager")
if authManager != null and authManager.has_signal("auth_state_changed"):
authManager.auth_state_changed.connect(_on_auth_state_changed)
func _exit_tree() -> void:
_cancel_active_requests()
func get_chat_time_products(forceRefresh: bool = false) -> void:
if not forceRefresh and not _cachedProducts.is_empty():
var cachedData := {"products": _cachedProducts}
cafe_companion_products_ready.emit(cachedData)
_emit_event(EventNames.CAFE_COMPANION_PRODUCTS_READY, cachedData)
return
if not _has_auth_token():
_emit_chat_error("请先登录后再查看咖啡店陪聊时长")
return
_request_json("/cafe-companion/chat-time-products", {}, _on_chat_time_products_response, HTTPClient.METHOD_GET)
func get_service_points(forceRefresh: bool = false) -> void:
if not forceRefresh and not _cachedServicePoints.is_empty():
var cachedData := {"service_points": _cachedServicePoints}
cafe_companion_service_points_ready.emit(cachedData)
_emit_event(EventNames.CAFE_COMPANION_SERVICE_POINTS_READY, cachedData)
return
if not _has_auth_token():
_emit_chat_error("请先登录后再查看咖啡店陪伴位")
return
_request_json("/cafe-companion/service-points", {}, _on_service_points_response, HTTPClient.METHOD_GET)
func purchase_chat_time(targetData: Dictionary, minutes: int) -> void:
if not _has_auth_token():
_emit_chat_error("请先登录后再购买咖啡店陪聊时长")
return
var payload := {
"service_point_id": str(targetData.get("service_point_id", "")),
"minutes": minutes,
}
var companionId := str(targetData.get("companion_id", "")).strip_edges()
if not companionId.is_empty():
payload["companion_id"] = companionId
_request_json("/cafe-companion/chat-time/purchase", payload, _on_purchase_chat_time_response)
func send_chat_message(sessionId: String, content: String) -> void:
var normalizedContent := content.strip_edges()
if sessionId.strip_edges().is_empty():
_emit_chat_error("咖啡店陪聊会话还没有准备好")
return
if normalizedContent.is_empty():
return
if not _has_auth_token():
_emit_chat_error("请先登录后再发送咖啡店陪聊消息")
return
_request_json("/cafe-companion/chat/messages", {
"session_id": sessionId,
"content": normalizedContent,
}, _on_send_chat_message_response)
func register_employment_agent(payload: Dictionary) -> void:
if not _has_auth_token():
_emit_chat_error("请先登录后再登记咖啡店陪伴机器人")
return
_request_json("/cafe-companion/employment/agents", payload, _on_register_employment_agent_response)
func resign_employment(servicePointId: String) -> void:
if not _has_auth_token():
_emit_chat_error("请先登录后再结束咖啡店雇佣")
return
_request_json("/cafe-companion/employment/resign", {
"service_point_id": servicePointId,
}, _on_resign_employment_response)
func get_employment_models(protocol: String, baseUrl: String, token: String) -> void:
if not _has_auth_token():
_emit_chat_error("请先登录后再获取咖啡店雇佣模型")
return
_request_json("/cafe-companion/employment/models", {
"protocol": protocol,
"base_url": baseUrl,
"token": token,
}, _on_employment_models_response)
func _request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_POST) -> void:
var request := HTTPRequest.new()
request.timeout = REQUEST_TIMEOUT
add_child(request)
_activeRequests.append(request)
var requestGeneration := _current_account_generation()
request.request_completed.connect(func(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
_activeRequests.erase(request)
request.queue_free()
if requestGeneration != _current_account_generation():
return
_handle_json_response(result, responseCode, body, callback)
)
var headers := PackedStringArray(["Content-Type: application/json"])
var token := _get_auth_token()
if not token.is_empty():
headers.append("Authorization: Bearer %s" % token)
var body := "" if method == HTTPClient.METHOD_GET else JSON.stringify(payload)
var err := request.request("%s%s" % [NetworkConfig.get_api_base_url(), endpoint], headers, method, body)
if err != OK:
_activeRequests.erase(request)
request.queue_free()
if requestGeneration == _current_account_generation():
_emit_chat_error("咖啡店陪聊请求发送失败: %s" % error_string(err))
func _handle_json_response(result: int, responseCode: int, body: PackedByteArray, callback: Callable) -> void:
if result != HTTPRequest.RESULT_SUCCESS:
_emit_chat_error("咖啡店陪聊网络请求失败: %s" % _http_result_to_string(result))
return
var bodyText := body.get_string_from_utf8()
var json := JSON.new()
if json.parse(bodyText) != OK:
_emit_chat_error("咖啡店陪聊响应解析失败")
return
var payloadVariant: Variant = json.data
if not (payloadVariant is Dictionary):
_emit_chat_error("咖啡店陪聊响应格式错误")
return
var response: Dictionary = payloadVariant
var success := responseCode >= 200 and responseCode < 300 and bool(response.get("success", true))
if not success:
_emit_chat_error(str(response.get("message", "咖啡店陪聊请求失败")))
return
callback.call(response)
func _on_chat_time_products_response(response: Dictionary) -> void:
var data := _response_data(response)
var productsVariant: Variant = data.get("products", [])
_cachedProducts = productsVariant if productsVariant is Array else []
cafe_companion_products_ready.emit(data)
_emit_event(EventNames.CAFE_COMPANION_PRODUCTS_READY, data)
func _on_service_points_response(response: Dictionary) -> void:
var data := _response_data(response)
var servicePointsVariant: Variant = data.get("service_points", [])
_cachedServicePoints = servicePointsVariant if servicePointsVariant is Array else []
cafe_companion_service_points_ready.emit(data)
_emit_event(EventNames.CAFE_COMPANION_SERVICE_POINTS_READY, data)
func _on_purchase_chat_time_response(response: Dictionary) -> void:
var data := _response_data(response)
_apply_wallet_from_payload(data)
cafe_companion_chat_time_purchased.emit(data)
_emit_event(EventNames.CAFE_COMPANION_CHAT_TIME_PURCHASED, data)
func _on_send_chat_message_response(response: Dictionary) -> void:
var data := _response_data(response)
cafe_companion_message_received.emit(data)
_emit_event(EventNames.CAFE_COMPANION_MESSAGE_RECEIVED, data)
func _on_register_employment_agent_response(response: Dictionary) -> void:
var data := _response_data(response)
cafe_companion_agent_registered.emit(data)
_cachedServicePoints.clear()
_emit_event(EventNames.CAFE_COMPANION_AGENT_REGISTERED, data)
func _on_resign_employment_response(response: Dictionary) -> void:
var data := _response_data(response)
var penaltyVariant: Variant = data.get("penalty", {})
if penaltyVariant is Dictionary:
_apply_wallet_from_payload(penaltyVariant as Dictionary)
cafe_companion_employment_resigned.emit(data)
_cachedServicePoints.clear()
_emit_event(EventNames.CAFE_COMPANION_EMPLOYMENT_RESIGNED, data)
func _on_employment_models_response(response: Dictionary) -> void:
var data := _response_data(response)
cafe_companion_models_ready.emit(data)
_emit_event(EventNames.CAFE_COMPANION_MODELS_READY, data)
func _response_data(response: Dictionary) -> Dictionary:
var dataVariant: Variant = response.get("data", {})
if dataVariant is Dictionary:
return dataVariant
return {}
func _apply_wallet_from_payload(data: Dictionary) -> void:
if not data.has("balance"):
return
var playerStateManager := get_node_or_null("/root/PlayerStateManager")
if playerStateManager != null and playerStateManager.has_method("apply_wallet"):
playerStateManager.call("apply_wallet", {
"balance": int(data.get("balance", 0)),
"currency": str(data.get("currency", "whale_coin")),
})
func _emit_chat_error(message: String) -> void:
cafe_companion_error_occurred.emit(message)
_emit_event(EventNames.CAFE_COMPANION_ERROR_OCCURRED, {
"message": message
})
func _emit_event(eventName: String, data: Dictionary) -> void:
var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem != null:
eventSystem.call("emit_event", eventName, data)
func _has_auth_token() -> bool:
return not _get_auth_token().is_empty()
func _get_auth_token() -> String:
var authManager := get_node_or_null("/root/AuthManager")
if authManager == null or not authManager.has_method("get_access_token"):
return ""
return str(authManager.call("get_access_token")).strip_edges()
func _current_account_generation() -> int:
var authManager := get_node_or_null("/root/AuthManager")
if authManager == null or not authManager.has_method("get_account_generation"):
return -1
return int(authManager.call("get_account_generation"))
func _on_auth_state_changed(_isAuthenticated: bool, _user: Dictionary) -> void:
var currentGeneration := _current_account_generation()
if currentGeneration == _accountGeneration:
return
_accountGeneration = currentGeneration
_cancel_active_requests()
_cachedProducts.clear()
_cachedServicePoints.clear()
func _cancel_active_requests() -> void:
for request in _activeRequests.duplicate():
if is_instance_valid(request):
request.cancel_request()
request.queue_free()
_activeRequests.clear()
func _http_result_to_string(result: int) -> String:
match result:
HTTPRequest.RESULT_SUCCESS:
return "SUCCESS"
HTTPRequest.RESULT_TIMEOUT:
return "TIMEOUT"
HTTPRequest.RESULT_CANT_CONNECT:
return "CANT_CONNECT"
HTTPRequest.RESULT_CANT_RESOLVE:
return "CANT_RESOLVE"
HTTPRequest.RESULT_CONNECTION_ERROR:
return "CONNECTION_ERROR"
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
return "TLS_HANDSHAKE_ERROR"
_:
return "HTTP_RESULT_%d" % result

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,150 @@
extends Node
# ============================================================================
# NotificationSoundManager.gd - 提醒音效管理器
# ============================================================================
# 监听聊天和好友事件,播放项目内正式 UI 提醒音效资源。
# ============================================================================
const MIN_PLAY_INTERVAL_SEC: float = 0.18
const WORLD_NOTIFICATION_PATH: String = "res://assets/audio/ui/notify_world.wav"
const PRIVATE_NOTIFICATION_PATH: String = "res://assets/audio/ui/notify_private.wav"
const FRIEND_NOTIFICATION_PATH: String = "res://assets/audio/ui/notify_friend.wav"
var _player: AudioStreamPlayer
var _worldStream: AudioStreamWAV
var _privateStream: AudioStreamWAV
var _friendStream: AudioStreamWAV
var _lastPlayedAt: float = 0.0
var _seenFriendRequestKeys: Dictionary = {}
func _ready() -> void:
_build_player()
_load_streams()
_subscribe_to_events()
func _exit_tree() -> void:
var eventSystem := _get_event_system()
if eventSystem != null:
eventSystem.call("disconnect_event", EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
eventSystem.call("disconnect_event", EventNames.CHAT_FRIENDS_UPDATED, _on_friends_updated, self)
eventSystem.call("disconnect_event", EventNames.CHAT_ERROR_OCCURRED, _on_chat_error, self)
func play_notification(kind: String = "world") -> void:
if _settings_bool("mute_ui_sfx", false):
return
var now := Time.get_unix_time_from_system()
if now - _lastPlayedAt < MIN_PLAY_INTERVAL_SEC:
return
_lastPlayedAt = now
if not is_instance_valid(_player):
return
var stream := _stream_for_kind(kind)
if stream == null:
return
_player.stream = stream
_player.play()
func should_play_message_notification(data: Dictionary) -> bool:
if bool(data.get("is_self", false)):
return false
var scope := str(data.get("scope", "")).strip_edges().to_lower()
var isPrivate := bool(data.get("is_private", false)) or scope == "private"
if isPrivate:
return _settings_bool("private_notifications", true)
return _settings_bool("world_notifications", true)
func get_notification_asset_path(kind: String = "world") -> String:
match kind:
"private":
return PRIVATE_NOTIFICATION_PATH
"friend":
return FRIEND_NOTIFICATION_PATH
_:
return WORLD_NOTIFICATION_PATH
func _build_player() -> void:
_player = AudioStreamPlayer.new()
_player.name = "NotificationAudioPlayer"
_player.bus = "SFX"
add_child(_player)
func _load_streams() -> void:
_worldStream = _load_wav_stream(WORLD_NOTIFICATION_PATH)
_privateStream = _load_wav_stream(PRIVATE_NOTIFICATION_PATH)
_friendStream = _load_wav_stream(FRIEND_NOTIFICATION_PATH)
func _load_wav_stream(path: String) -> AudioStreamWAV:
var stream := AudioStreamWAV.load_from_file(ProjectSettings.globalize_path(path))
if stream == null:
push_warning("NotificationSoundManager: 提醒音效加载失败: %s" % path)
return stream
func _stream_for_kind(kind: String) -> AudioStream:
match kind:
"private":
return _privateStream
"friend":
return _friendStream
_:
return _worldStream
func _subscribe_to_events() -> void:
var eventSystem := _get_event_system()
if eventSystem == null:
push_warning("NotificationSoundManager: EventSystem autoload is not available.")
return
eventSystem.call("connect_event", EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
eventSystem.call("connect_event", EventNames.CHAT_FRIENDS_UPDATED, _on_friends_updated, self)
eventSystem.call("connect_event", EventNames.CHAT_ERROR_OCCURRED, _on_chat_error, self)
func _on_chat_message_received(data: Dictionary) -> void:
if not should_play_message_notification(data):
return
var scope := str(data.get("scope", "")).strip_edges().to_lower()
var isPrivate := bool(data.get("is_private", false)) or scope == "private"
play_notification("private" if isPrivate else "world")
func _on_friends_updated(data: Dictionary) -> void:
if not _settings_bool("friend_request_notifications", true):
return
var requestsVariant: Variant = data.get("requests", [])
if not (requestsVariant is Array):
return
var shouldPlay := false
for requestVariant in requestsVariant:
if not (requestVariant is Dictionary):
continue
var key := _friend_request_key(requestVariant as Dictionary)
if key.is_empty() or _seenFriendRequestKeys.has(key):
continue
_seenFriendRequestKeys[key] = true
shouldPlay = true
if shouldPlay:
play_notification("friend")
func _on_chat_error(data: Dictionary) -> void:
var errorCode := str(data.get("error_code", ""))
if errorCode == "FRIEND_REQUEST_SENT" and _settings_bool("friend_request_notifications", true):
play_notification("friend")
func _friend_request_key(request: Dictionary) -> String:
var userId := str(request.get("user_id", request.get("userId", ""))).strip_edges()
if not userId.is_empty():
return userId
return str(request.get("username", "")).strip_edges()
func _settings_bool(key: String, defaultValue: bool) -> bool:
var settingsManager := get_node_or_null("/root/SettingsManager")
if settingsManager != null and settingsManager.has_method("get_bool"):
return bool(settingsManager.call("get_bool", key))
return defaultValue
func _get_event_system() -> Node:
return get_node_or_null("/root/EventSystem")

View File

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

View File

@@ -0,0 +1,227 @@
extends Node
# ============================================================================
# PlayerStateManager.gd - 当前玩家状态聚合管理器
# ============================================================================
# 缓存后端 /player/snapshot并向 UI 广播钱包、背包、外观和设置变化。
# ============================================================================
signal snapshot_changed(snapshot: Dictionary)
signal wallet_changed(wallet: Dictionary)
signal inventory_changed(inventory: Dictionary)
signal appearance_changed(appearance: Dictionary)
signal settings_changed(settings: Dictionary)
signal snapshot_failed(message: String)
var _snapshot: Dictionary = {}
var _wallet: Dictionary = {}
var _inventory: Dictionary = {}
var _appearance: Dictionary = {}
var _settings: Dictionary = {}
var _loadingSnapshot: bool = false
var _snapshotRequestGeneration: int = -1
func _ready() -> void:
var authManager := get_node_or_null("/root/AuthManager")
if authManager != null and authManager.has_signal("auth_state_changed"):
authManager.auth_state_changed.connect(_on_auth_state_changed)
func get_snapshot() -> Dictionary:
return _snapshot.duplicate(true)
func get_wallet() -> Dictionary:
return _wallet.duplicate(true)
func get_inventory() -> Dictionary:
return _inventory.duplicate(true)
func get_appearance() -> Dictionary:
return _appearance.duplicate(true)
func get_settings() -> Dictionary:
return _settings.duplicate(true)
func get_balance() -> int:
return int(_wallet.get("balance", 0))
func is_loaded() -> bool:
return not _snapshot.is_empty()
func refresh_snapshot() -> void:
if not _is_authenticated():
clear_state()
return
if _loadingSnapshot:
return
_loadingSnapshot = true
_snapshotRequestGeneration = _current_account_generation()
var api := _api_client()
if api == null:
_loadingSnapshot = false
_snapshotRequestGeneration = -1
snapshot_failed.emit("ApiClient 不可用")
return
api.call(
"get_json",
"/player/snapshot",
Callable(self, "_on_snapshot_response").bind(_snapshotRequestGeneration),
true
)
func apply_snapshot(snapshot: Dictionary) -> void:
_snapshot = snapshot.duplicate(true)
_wallet = _dictionary(snapshot.get("wallet", {}))
_inventory = _dictionary(snapshot.get("inventory", {}))
_appearance = _dictionary(snapshot.get("appearance", {}))
_settings = _dictionary(snapshot.get("settings", {}))
_apply_to_local_managers()
snapshot_changed.emit(get_snapshot())
wallet_changed.emit(get_wallet())
inventory_changed.emit(get_inventory())
appearance_changed.emit(get_appearance())
settings_changed.emit(get_settings())
func apply_wallet(wallet: Dictionary) -> void:
_wallet = wallet.duplicate(true)
if not _snapshot.is_empty():
_snapshot["wallet"] = _wallet.duplicate(true)
wallet_changed.emit(get_wallet())
func apply_inventory(inventory: Dictionary) -> void:
_inventory = inventory.duplicate(true)
if not _snapshot.is_empty():
_snapshot["inventory"] = _inventory.duplicate(true)
inventory_changed.emit(get_inventory())
_apply_inventory_to_appearance()
func apply_purchase_payload(payload: Dictionary) -> void:
var snapshotVariant: Variant = payload.get("snapshot", {})
if snapshotVariant is Dictionary:
apply_snapshot(snapshotVariant as Dictionary)
return
var walletVariant: Variant = payload.get("wallet", {})
if walletVariant is Dictionary:
apply_wallet(walletVariant as Dictionary)
var inventoryVariant: Variant = payload.get("inventory", {})
if inventoryVariant is Dictionary:
apply_inventory(inventoryVariant as Dictionary)
func update_appearance(skinId: String, completion: Callable = Callable()) -> void:
var api := _api_client()
if api == null:
var errorInfo := {"message": "ApiClient 不可用"}
snapshot_failed.emit(str(errorInfo.get("message")))
_call_update_completion(completion, false, {}, errorInfo)
return
var requestGeneration := _current_account_generation()
api.call(
"patch_json",
"/player/appearance",
{"skin_id": skinId},
Callable(self, "_on_update_response").bind(completion, requestGeneration),
true
)
func update_settings(settings: Dictionary, completion: Callable = Callable()) -> void:
var api := _api_client()
if api == null:
var errorInfo := {"message": "ApiClient 不可用"}
snapshot_failed.emit(str(errorInfo.get("message")))
_call_update_completion(completion, false, {}, errorInfo)
return
var requestGeneration := _current_account_generation()
api.call(
"patch_json",
"/player/settings",
{"settings": settings},
Callable(self, "_on_update_response").bind(completion, requestGeneration),
true
)
func clear_state() -> void:
_snapshot.clear()
_wallet.clear()
_inventory.clear()
_appearance.clear()
_settings.clear()
_loadingSnapshot = false
_snapshotRequestGeneration = -1
snapshot_changed.emit({})
wallet_changed.emit({})
inventory_changed.emit({})
appearance_changed.emit({})
settings_changed.emit({})
func _on_auth_state_changed(isAuthenticated: bool, _user: Dictionary) -> void:
if isAuthenticated:
if _snapshotRequestGeneration != _current_account_generation():
_loadingSnapshot = false
_snapshotRequestGeneration = -1
refresh_snapshot()
else:
clear_state()
func _on_snapshot_response(success: bool, response: Dictionary, errorInfo: Dictionary, requestGeneration: int) -> void:
if requestGeneration != _current_account_generation():
if requestGeneration == _snapshotRequestGeneration:
_loadingSnapshot = false
_snapshotRequestGeneration = -1
return
if requestGeneration == _snapshotRequestGeneration:
_loadingSnapshot = false
_snapshotRequestGeneration = -1
_apply_snapshot_response(success, response, errorInfo)
func _apply_snapshot_response(success: bool, response: Dictionary, errorInfo: Dictionary) -> void:
if not success:
snapshot_failed.emit(str(errorInfo.get("message", "玩家状态读取失败")))
return
var dataVariant: Variant = response.get("data", {})
if dataVariant is Dictionary:
apply_snapshot(dataVariant as Dictionary)
else:
snapshot_failed.emit("玩家状态格式错误")
func _on_update_response(success: bool, response: Dictionary, errorInfo: Dictionary, completion: Callable, requestGeneration: int) -> void:
if requestGeneration != _current_account_generation():
return
_apply_snapshot_response(success, response, errorInfo)
_call_update_completion(completion, success, response, errorInfo)
func _call_update_completion(completion: Callable, success: bool, response: Dictionary, errorInfo: Dictionary) -> void:
if completion.is_valid():
completion.call(success, response, errorInfo)
func _apply_to_local_managers() -> void:
_apply_inventory_to_appearance()
var appearanceManager := get_node_or_null("/root/AppearanceManager")
if appearanceManager != null and appearanceManager.has_method("apply_account_profile"):
var profile := _dictionary(_snapshot.get("profile", {}))
var appearance := _dictionary(_snapshot.get("appearance", {}))
profile["skin_id"] = str(appearance.get("selected_skin_id", profile.get("selected_skin_id", "")))
profile["owned_skin_ids"] = appearance.get("owned_skin_ids", [])
profile["owned_skins"] = appearance.get("owned_skins", [])
appearanceManager.call("apply_account_profile", profile)
var settingsManager := get_node_or_null("/root/SettingsManager")
if settingsManager != null and settingsManager.has_method("apply_account_settings"):
settingsManager.call("apply_account_settings", get_settings())
func _apply_inventory_to_appearance() -> void:
var skinIds: Variant = _inventory.get("skin_ids", [])
var appearanceManager := get_node_or_null("/root/AppearanceManager")
if skinIds is Array and appearanceManager != null and appearanceManager.has_method("set_owned_skin_ids"):
appearanceManager.call("set_owned_skin_ids", skinIds)
func _dictionary(value: Variant) -> Dictionary:
return (value as Dictionary).duplicate(true) if value is Dictionary else {}
func _api_client() -> Node:
return get_node_or_null("/root/ApiClient")
func _is_authenticated() -> bool:
var authManager := get_node_or_null("/root/AuthManager")
return bool(authManager.call("is_authenticated")) if authManager != null and authManager.has_method("is_authenticated") else false
func _current_account_generation() -> int:
var authManager := get_node_or_null("/root/AuthManager")
return int(authManager.call("get_account_generation")) if authManager != null and authManager.has_method("get_account_generation") else 0

View File

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

View File

@@ -0,0 +1,183 @@
extends Node
signal decor_save_succeeded(item: Dictionary)
signal decor_save_failed(item: Dictionary, message: String)
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const REQUEST_TIMEOUT: float = 12.0
const MAX_SAVE_RETRIES: int = 3
const RETRY_BASE_DELAY: float = 0.75
var _request: HTTPRequest
var _retryTimer: Timer
var _queue: Array[Dictionary] = []
var _inFlight: Dictionary = {}
var _requestGeneration: int = -1
var _accountGeneration: int = -1
func _ready() -> void:
_request = HTTPRequest.new()
_request.name = "roomDecorSaveRequest"
_request.timeout = REQUEST_TIMEOUT
_request.request_completed.connect(_on_request_completed)
add_child(_request)
_retryTimer = Timer.new()
_retryTimer.name = "roomDecorSaveRetryTimer"
_retryTimer.one_shot = true
_retryTimer.timeout.connect(_process_next_save)
add_child(_retryTimer)
var authManager := get_node_or_null("/root/AuthManager")
if authManager != null and authManager.has_signal("auth_state_changed"):
_accountGeneration = _current_account_generation()
authManager.auth_state_changed.connect(_on_auth_state_changed)
func enqueue_save(item: Dictionary) -> bool:
if not _is_authenticated():
decor_save_failed.emit(item.duplicate(true), "请先登录后保存摆放")
return false
var decorId := str(item.get("decor_id", "")).strip_edges()
if decorId.is_empty():
decor_save_failed.emit(item.duplicate(true), "家具数据缺少 decor_id")
return false
# Only the newest queued placement for a decor matters.
for index in range(_queue.size() - 1, -1, -1):
var queuedItem: Dictionary = _queue[index].get("item", {})
if str(queuedItem.get("decor_id", "")) == decorId:
_queue.remove_at(index)
_queue.append({"item": item.duplicate(true), "retry_count": 0})
_process_next_save()
return true
func has_pending_saves() -> bool:
return not _inFlight.is_empty() or not _queue.is_empty() or (_retryTimer != null and not _retryTimer.is_stopped())
func _process_next_save() -> void:
if not _inFlight.is_empty() or _queue.is_empty() or not _is_authenticated():
return
if _retryTimer != null and not _retryTimer.is_stopped():
return
_inFlight = _queue.pop_front()
_requestGeneration = _current_account_generation()
var item: Dictionary = _inFlight.get("item", {})
var payload := {
"decor_id": str(item.get("decor_id", "")),
"placed": bool(item.get("placed", false)),
"position_x": float(item.get("position_x", 0.0)),
"position_y": float(item.get("position_y", 0.0)),
"scale": float(item.get("scale", item.get("default_scale", 1.0))),
"z_index": int(item.get("z_index", item.get("default_z_index", 0))),
}
var decorId := str(item.get("decor_id", "")).uri_encode()
var err := _request.request(
"%s/rooms/me/decor-placements/%s" % [NetworkConfig.get_api_base_url(), decorId],
_auth_headers(),
HTTPClient.METHOD_PUT,
JSON.stringify(payload)
)
if err != OK:
_retry_or_fail("家具位置保存请求发送失败")
func _on_request_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
if _inFlight.is_empty():
return
if _requestGeneration != _current_account_generation():
_inFlight.clear()
_requestGeneration = -1
_process_next_save()
return
if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300:
_retry_or_fail(_read_error_message(body, "家具位置保存失败"))
return
var savedItem: Dictionary = (_inFlight.get("item", {}) as Dictionary).duplicate(true)
var json := JSON.new()
if json.parse(body.get_string_from_utf8()) == OK and json.data is Dictionary:
var response: Dictionary = json.data
if not bool(response.get("success", true)):
_retry_or_fail(str(response.get("message", "家具位置保存失败")))
return
var dataVariant: Variant = response.get("data", {})
if dataVariant is Dictionary:
savedItem = (dataVariant as Dictionary).duplicate(true)
_inFlight.clear()
_requestGeneration = -1
decor_save_succeeded.emit(savedItem)
_process_next_save()
func _retry_or_fail(message: String) -> void:
if _inFlight.is_empty():
return
var failedEntry := _inFlight.duplicate(true)
var failedItem: Dictionary = failedEntry.get("item", {})
var decorId := str(failedItem.get("decor_id", ""))
_inFlight.clear()
_requestGeneration = -1
if _queue_has_decor(decorId):
_process_next_save()
return
var retryCount := int(failedEntry.get("retry_count", 0)) + 1
if retryCount <= MAX_SAVE_RETRIES and _is_authenticated():
failedEntry["retry_count"] = retryCount
_queue.push_front(failedEntry)
_retryTimer.start(RETRY_BASE_DELAY * pow(2.0, retryCount - 1))
return
decor_save_failed.emit(failedItem.duplicate(true), message)
_process_next_save()
func _queue_has_decor(decorId: String) -> bool:
for entryVariant in _queue:
if entryVariant is Dictionary:
var item: Dictionary = (entryVariant as Dictionary).get("item", {})
if str(item.get("decor_id", "")) == decorId:
return true
return false
func _on_auth_state_changed(_isAuthenticated: bool, _user: Dictionary) -> void:
var currentGeneration := _current_account_generation()
if currentGeneration == _accountGeneration:
return
_accountGeneration = currentGeneration
if is_instance_valid(_request):
_request.cancel_request()
if is_instance_valid(_retryTimer):
_retryTimer.stop()
_queue.clear()
_inFlight.clear()
_requestGeneration = -1
func _auth_headers() -> PackedStringArray:
var authManager := get_node_or_null("/root/AuthManager")
var accessToken := str(authManager.call("get_access_token")).strip_edges() if authManager != null and authManager.has_method("get_access_token") else ""
return PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % accessToken,
])
func _is_authenticated() -> bool:
var authManager := get_node_or_null("/root/AuthManager")
return authManager != null and authManager.has_method("is_authenticated") and bool(authManager.call("is_authenticated"))
func _current_account_generation() -> int:
var authManager := get_node_or_null("/root/AuthManager")
return int(authManager.call("get_account_generation")) if authManager != null and authManager.has_method("get_account_generation") else -1
func _read_error_message(body: PackedByteArray, fallback: String) -> String:
var json := JSON.new()
if json.parse(body.get_string_from_utf8()) != OK or not (json.data is Dictionary):
return fallback
var response: Dictionary = json.data
var messageVariant: Variant = response.get("message", fallback)
if messageVariant is Array:
var parts: Array[String] = []
for part in messageVariant:
parts.append(str(part))
return "; ".join(parts) if not parts.is_empty() else fallback
var message := str(messageVariant).strip_edges()
return message if not message.is_empty() else fallback

View File

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

View File

@@ -0,0 +1,244 @@
extends Node
# ============================================================================
# SceneManager.gd - 场景管理器
# ============================================================================
# 全局单例管理器,负责场景切换和管理
#
# 核心职责:
# - 场景切换的统一接口
# - 场景路径映射管理
# - 场景切换过渡效果
# - 场景状态跟踪
#
# 使用方式:
# SceneManager.change_scene("main")
# SceneManager.register_scene("custom", "res://scenes/custom.tscn")
#
# 注意事项:
# - 作为自动加载单例,全局可访问
# - 场景切换是异步操作,支持过渡效果
# - 场景名称必须在 scene_paths 中注册
# ============================================================================
# ============ 信号定义 ============
# 场景切换完成信号
# 参数: scene_name - 切换到的场景名称
signal scene_changed(scene_name: String)
# 场景切换开始信号
# 参数: scene_name - 即将切换到的场景名称
signal scene_change_started(scene_name: String)
# ============ 成员变量 ============
# 场景状态
var current_scene_name: String = "" # 当前场景名称
var is_changing_scene: bool = false # 是否正在切换场景
var _next_scene_position: Variant = null # 下一个场景的初始位置 (Vector2 or null)
var _next_spawn_name: String = "" # 下一个场景的出生点名称 (String)
# 场景路径映射表
# 将场景名称映射到实际的文件路径
# 便于统一管理和修改场景路径
var scene_paths: Dictionary = {
"main": "res://scenes/ui/AuthScene.tscn", # 认证入口
"auth": "res://scenes/ui/AuthScene.tscn", # 登录/注册
"square": "res://scenes/Maps/square.tscn", # 广场地图
"work_zone": "res://scenes/Maps/work_zone.tscn", # 打工区地图
"cafe_interior": "res://scenes/Maps/cafe_interior.tscn", # 咖啡馆室内
"room": "res://scenes/Maps/personal_space.tscn", # 房间地图(兼容旧入口)
"personal_space": "res://scenes/Maps/personal_space.tscn", # 个人空间
"fountain": "res://scenes/Maps/fountain.tscn", # 喷泉地图
"datawhale_home": "res://scenes/Maps/datawhale_home.tscn", # 数据鲸鱼之家
"community": "res://scenes/Maps/community.tscn" # 社区地图
}
# ============ 场景切换方法 ============
# 切换到指定场景
#
# 参数:
# scene_name: String - 要切换到的场景名称必须在scene_paths中注册
# use_transition: bool - 是否使用过渡效果默认为true
#
# 返回值:
# bool - 切换是否成功
#
# 功能:
# - 检查场景切换状态和场景是否存在
# - 显示过渡效果(可选)
# - 执行场景切换
# - 更新当前场景状态
# - 发送相关信号
#
# 使用示例:
# var success = SceneManager.change_scene("main", true)
#
# 注意事项:
# - 场景切换是异步操作
# - 切换过程中会阻止新的切换请求
# - 场景名称必须预先注册
func change_scene(scene_name: String, use_transition: bool = true):
# 防止重复切换
if is_changing_scene:
push_warning("SceneManager: 场景切换中,忽略新的切换请求")
return false
# 检查场景是否存在
if not scene_paths.has(scene_name):
push_error("SceneManager: 未找到场景 %s" % scene_name)
return false
var scene_path = scene_paths[scene_name]
# 设置切换状态
is_changing_scene = true
scene_change_started.emit(scene_name)
# 显示过渡效果
if use_transition:
await show_transition()
# 执行场景切换
var error = get_tree().change_scene_to_file(scene_path)
if error != OK:
push_error("SceneManager: 场景切换失败 %s -> %s, 错误码: %d" % [current_scene_name, scene_name, error])
is_changing_scene = false
return false
# 更新状态
current_scene_name = scene_name
is_changing_scene = false
scene_changed.emit(scene_name)
# 隐藏过渡效果
if use_transition:
await hide_transition()
return true
# ============ 查询方法 ============
# 获取当前场景名称
#
# 返回值:
# String - 当前场景的名称
func get_current_scene_name() -> String:
return current_scene_name
# ============ 场景位置和出生点管理 ============
# 设置下一个场景的初始位置
#
# 参数:
# pos: Vector2 - 玩家在下一个场景的初始位置
#
# 功能:
# - 用于场景切换时传递玩家位置信息
# - 配合 DoorTeleport 等传送机制使用
#
# 使用示例:
# SceneManager.set_next_scene_position(Vector2(100, 200))
# SceneManager.change_scene("room")
func set_next_scene_position(pos: Vector2) -> void:
_next_scene_position = pos
# 获取并清除下一个场景的初始位置
#
# 返回值:
# Variant - Vector2 位置或 null如果未设置
#
# 功能:
# - 获取预设的场景初始位置
# - 获取后自动清除,避免影响后续场景切换
#
# 注意事项:
# - 此方法会清除存储的位置,只能获取一次
# - 如果未设置位置,返回 null
func get_next_scene_position() -> Variant:
var pos = _next_scene_position
_next_scene_position = null
return pos
# 设置下一个场景的出生点名称
#
# 参数:
# spawn_name: String - 出生点节点的名称
#
# 功能:
# - 指定玩家在下一个场景应该出现在哪个出生点
# - 配合场景中的 Marker2D 出生点使用
#
# 使用示例:
# SceneManager.set_next_spawn_name("DoorExit")
# SceneManager.change_scene("square")
func set_next_spawn_name(spawn_name: String) -> void:
_next_spawn_name = spawn_name
# 获取并清除下一个场景的出生点名称
#
# 返回值:
# String - 出生点名称(如果未设置则返回空字符串)
#
# 功能:
# - 获取预设的出生点名称
# - 获取后自动清除,避免影响后续场景切换
#
# 注意事项:
# - 此方法会清除存储的名称,只能获取一次
# - 如果未设置名称,返回空字符串
func get_next_spawn_name() -> String:
var spawn_name: String = _next_spawn_name
_next_spawn_name = ""
return spawn_name
# ============ 场景注册方法 ============
# 注册新场景
#
# 参数:
# scene_name: String - 场景名称(用于切换时引用)
# scene_path: String - 场景文件路径
#
# 功能:
# - 将场景名称和路径添加到映射表
# - 支持运行时动态注册场景
#
# 使用示例:
# SceneManager.register_scene("boss_battle", "res://scenes/boss/boss_battle.tscn")
func register_scene(scene_name: String, scene_path: String):
scene_paths[scene_name] = scene_path
# ============ 过渡效果方法 ============
# 显示场景切换过渡效果
#
# 功能:
# - 显示场景切换时的过渡动画
# - 为用户提供视觉反馈
#
# 注意事项:
# - 这是异步方法需要await等待完成
# - 当前实现为简单的延时,可扩展为复杂动画
#
# TODO: 实现淡入淡出、滑动等过渡效果
func show_transition():
# TODO: 实现场景切换过渡效果(当前仅占位延时)
await get_tree().create_timer(0.2).timeout
# 隐藏场景切换过渡效果
#
# 功能:
# - 隐藏场景切换完成后的过渡动画
# - 恢复正常的游戏显示
#
# 注意事项:
# - 这是异步方法需要await等待完成
# - 与show_transition()配对使用
#
# TODO: 实现与show_transition()对应的隐藏效果
func hide_transition():
# TODO: 隐藏场景切换过渡效果(当前仅占位延时)
await get_tree().create_timer(0.2).timeout

View File

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

View File

@@ -0,0 +1,136 @@
extends Node
# ============================================================================
# SettingsManager.gd - 游戏设置偏好管理器
# ============================================================================
# 统一应用账号级设置,并向运行中的 UI、聊天和角色系统广播变更。
# 设置持久化以后端账号 profile 为准,本管理器不再读写 user:// 配置文件。
# ============================================================================
const BASE_VIEWPORT_SIZE: Vector2i = Vector2i(1920, 1440)
const DEFAULT_SETTINGS: Dictionary = {
"master_volume": 0.80,
"music_volume": 0.60,
"effects_volume": 0.90,
"ui_scale": 1.00,
"fullscreen": false,
"show_interaction_hints": false,
"show_name_always": false,
"show_chat_bubbles": true,
"world_notifications": true,
"private_notifications": true,
"friend_request_notifications": true,
"allow_nearby_private": true,
"allow_nearby_friend_requests": true,
"mute_ui_sfx": false,
}
var _settings: Dictionary = DEFAULT_SETTINGS.duplicate(true)
func _ready() -> void:
_connect_auth_events()
reset_to_defaults(false)
apply_all_settings()
func load_settings() -> void:
_settings = DEFAULT_SETTINGS.duplicate(true)
apply_all_settings()
_emit_settings_changed()
func save_settings() -> bool:
_emit_settings_changed()
return true
func get_default_settings() -> Dictionary:
return DEFAULT_SETTINGS.duplicate(true)
func get_settings() -> Dictionary:
return _settings.duplicate(true)
func set_settings(settings: Dictionary, saveImmediately: bool = true) -> void:
for key in DEFAULT_SETTINGS.keys():
if settings.has(key):
_settings[key] = settings[key]
apply_all_settings()
if saveImmediately:
save_settings()
else:
_emit_settings_changed()
func apply_account_settings(settings: Dictionary) -> void:
set_settings(settings, false)
func reset_to_defaults(emitChanged: bool = true) -> void:
_settings = DEFAULT_SETTINGS.duplicate(true)
apply_all_settings()
if emitChanged:
_emit_settings_changed()
func set_setting(key: String, value: Variant, saveImmediately: bool = true) -> void:
if not DEFAULT_SETTINGS.has(key):
return
_settings[key] = value
apply_all_settings()
if saveImmediately:
save_settings()
else:
_emit_settings_changed()
func get_setting(key: String, fallback: Variant = null) -> Variant:
return _settings.get(key, fallback if fallback != null else DEFAULT_SETTINGS.get(key))
func get_bool(key: String) -> bool:
return bool(_settings.get(key, DEFAULT_SETTINGS.get(key, false)))
func get_float(key: String) -> float:
return float(_settings.get(key, DEFAULT_SETTINGS.get(key, 0.0)))
func apply_all_settings() -> void:
_apply_fullscreen_setting()
_apply_audio_settings()
_apply_ui_scale_setting()
func _apply_audio_settings() -> void:
_apply_bus_volume("Master", get_float("master_volume"))
_apply_bus_volume("Music", get_float("music_volume"))
_apply_bus_volume("SFX", get_float("effects_volume"))
func _apply_bus_volume(busName: String, value: float) -> void:
var busIndex := AudioServer.get_bus_index(busName)
if busIndex < 0:
return
var clamped := clampf(value, 0.0, 1.0)
AudioServer.set_bus_mute(busIndex, clamped <= 0.001)
if clamped > 0.001:
AudioServer.set_bus_volume_db(busIndex, linear_to_db(clamped))
func _apply_fullscreen_setting() -> void:
if DisplayServer.get_name() == "headless":
return
var fullscreen := get_bool("fullscreen")
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN if fullscreen else DisplayServer.WINDOW_MODE_WINDOWED)
func _apply_ui_scale_setting() -> void:
var viewport := get_tree().root
if viewport == null:
return
var scale := clampf(get_float("ui_scale"), 0.8, 1.2)
viewport.content_scale_size = Vector2i(
int(round(float(BASE_VIEWPORT_SIZE.x) / scale)),
int(round(float(BASE_VIEWPORT_SIZE.y) / scale))
)
func _emit_settings_changed() -> void:
var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem != null:
eventSystem.call("emit_event", EventNames.SETTINGS_CHANGED, get_settings())
func _connect_auth_events() -> void:
var eventSystem := get_node_or_null("/root/EventSystem")
if eventSystem == null:
return
eventSystem.call("connect_event", EventNames.AUTH_LOGOUT, _on_auth_logout, self)
func _on_auth_logout(_data: Variant = null) -> void:
reset_to_defaults()

View File

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

View File

@@ -0,0 +1,462 @@
extends Node
class_name ChatWebSocketManager
# ============================================================================
# WebSocketManager.gd - WebSocket 连接生命周期管理(原生 WebSocket 版本)
# ============================================================================
# 管理 WebSocket 连接状态、自动重连和错误恢复
#
# 核心职责:
# - 连接状态管理(断开、连接中、已连接、重连中)
# - 自动重连机制(指数退避)
# - 连接错误恢复
# - WebSocket 消息发送/接收
# ============================================================================
# 使用方式:
# WebSocketManager.connect_to_game_server()
# WebSocketManager.connection_state_changed.connect(_on_state_changed)
#
# 注意事项:
# - 作为自动加载单例,全局可访问
# - 自动处理连接断开和重连
# - 通过信号通知连接状态变化
# ============================================================================
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
# ============================================================================
# 信号定义
# ============================================================================
# 连接状态变化信号
# 参数:
# new_state: ConnectionState - 新的连接状态
signal connection_state_changed(new_state: ConnectionState)
# 连接丢失信号
signal connection_lost()
# 重连成功信号
signal reconnection_succeeded()
# 重连失败信号
# 参数:
# attempt: int - 当前重连尝试次数
# max_attempts: int - 最大重连次数
signal reconnection_failed(attempt: int, max_attempts: int)
# WebSocket 消息接收信号
# 参数:
# message: String - 接收到的消息内容JSON 字符串)
signal data_received(message: String)
# ============================================================================
# 枚举定义
# ============================================================================
# 连接状态枚举
enum ConnectionState {
DISCONNECTED, # 未连接
CONNECTING, # 连接中
CONNECTED, # 已连接
RECONNECTING, # 重连中
ERROR # 错误状态
}
# ============================================================================
# 常量定义
# ============================================================================
# WebSocket 服务器 URL原生 WebSocket
const WEBSOCKET_URL: String = "wss://whaletownend.xinghangee.icu/game"
# 默认最大重连次数
const DEFAULT_MAX_RECONNECT_ATTEMPTS: int = 5
# 默认重连基础延迟(秒)
const DEFAULT_RECONNECT_BASE_DELAY: float = 3.0
# 最大重连延迟(秒)
const MAX_RECONNECT_DELAY: float = 30.0
# ============================================================================
# 成员变量
# ============================================================================
# WebSocket peer
var _websocket_peer: WebSocketPeer = WebSocketPeer.new()
# 当前 WebSocket 地址
var _websocket_url: String = WEBSOCKET_URL
# 当前连接状态
var _connection_state: ConnectionState = ConnectionState.DISCONNECTED
# 自动重连启用标志
var _auto_reconnect_enabled: bool = true
# 最大重连次数
var _max_reconnect_attempts: int = DEFAULT_MAX_RECONNECT_ATTEMPTS
# 重连基础延迟
var _reconnect_base_delay: float = DEFAULT_RECONNECT_BASE_DELAY
# 当前重连尝试次数
var _reconnect_attempt: int = 0
# 重连定时器
var _reconnect_timer: Timer
# 只有客户端主动断开时才抑制自动重连。
var _manual_disconnect_requested: bool = false
# 当前 CLOSED 状态是否已经处理过(防止每帧重复处理 close 事件)
var _closed_state_handled: bool = false
# 心跳定时器
var _heartbeat_timer: Timer
# 心跳间隔(秒)
const HEARTBEAT_INTERVAL: float = 30.0
# ============================================================================
# 生命周期方法
# ============================================================================
# 初始化
func _ready() -> void:
_websocket_url = NetworkConfig.get_chat_ws_url()
# 设置重连定时器
_setup_reconnect_timer()
# 设置心跳定时器
_setup_heartbeat_timer()
# 启动处理循环
set_process(true)
# 处理每帧
func _process(_delta: float) -> void:
# 检查 WebSocket 状态变化
_check_websocket_state()
var state: WebSocketPeer.State = _websocket_peer.get_ready_state()
if state == WebSocketPeer.STATE_OPEN:
# 处理收到的数据
while _websocket_peer.get_available_packet_count() > 0:
var packet: PackedByteArray = _websocket_peer.get_packet()
var message: String = packet.get_string_from_utf8()
# 发射消息接收信号
data_received.emit(message)
# 清理
func _exit_tree() -> void:
_disconnect()
if is_instance_valid(_reconnect_timer):
_reconnect_timer.stop()
if is_instance_valid(_heartbeat_timer):
_heartbeat_timer.stop()
# ============================================================================
# 公共 API - 连接管理
# ============================================================================
# 连接到游戏服务器
func connect_to_game_server(is_reconnect_attempt: bool = false) -> void:
if _connection_state == ConnectionState.CONNECTED or _connection_state == ConnectionState.CONNECTING:
push_warning("已经在连接或已连接状态")
return
if is_reconnect_attempt:
_set_connection_state(ConnectionState.RECONNECTING)
else:
_set_connection_state(ConnectionState.CONNECTING)
_manual_disconnect_requested = false
_closed_state_handled = false
# 仅在首次/手动连接时重置重连计数,重连流程保持累计尝试次数
if not is_reconnect_attempt:
_reconnect_attempt = 0
_websocket_peer = WebSocketPeer.new()
var err: Error = _websocket_peer.connect_to_url(_websocket_url)
if err != OK:
push_error("WebSocketManager: 连接失败url=%s - %s" % [_websocket_url, error_string(err)])
_closed_state_handled = true
if _auto_reconnect_enabled:
_attempt_reconnect()
else:
_set_connection_state(ConnectionState.ERROR)
return
# 启动心跳
_start_heartbeat()
# 断开 WebSocket 连接
func disconnect_websocket() -> void:
_disconnect()
# 断开连接(内部方法)
func _disconnect() -> void:
_manual_disconnect_requested = true
# 停止重连定时器
if is_instance_valid(_reconnect_timer):
_reconnect_timer.stop()
# 停止心跳
if is_instance_valid(_heartbeat_timer):
_heartbeat_timer.stop()
# 关闭 WebSocket
if _websocket_peer.get_ready_state() == WebSocketPeer.STATE_OPEN:
_websocket_peer.close(1000, "Client disconnect")
# CONNECTING 状态无法可靠地原地取消;替换 peer 可隔离旧连接的后续状态。
_websocket_peer = WebSocketPeer.new()
_closed_state_handled = true
_set_connection_state(ConnectionState.DISCONNECTED)
# 检查 WebSocket 是否已连接
#
# 返回值:
# bool - WebSocket 是否已连接
func is_websocket_connected() -> bool:
return _connection_state == ConnectionState.CONNECTED
# 获取当前连接状态
#
# 返回值:
# ConnectionState - 当前连接状态
func get_connection_state() -> ConnectionState:
return _connection_state
# ============================================================================
# 公共 API - 消息发送
# ============================================================================
# 发送 WebSocket 消息
#
# 参数:
# message: String - 要发送的消息内容JSON 字符串)
#
# 返回值:
# Error - 错误码OK 表示成功
func send_message(message: String) -> Error:
if _websocket_peer.get_ready_state() != WebSocketPeer.STATE_OPEN:
push_warning("WebSocketManager: 未连接,无法发送消息")
return ERR_UNCONFIGURED
var err: Error = _websocket_peer.send_text(message)
if err != OK:
push_error("WebSocketManager: 发送消息失败 - %s" % error_string(err))
return err
return OK
# ============================================================================
# 公共 API - 自动重连
# ============================================================================
# 启用/禁用自动重连
#
# 参数:
# enabled: bool - 是否启用自动重连
# max_attempts: int - 最大重连次数(默认 5
# base_delay: float - 基础重连延迟,秒(默认 3.0
#
# 使用示例:
# WebSocketManager.enable_auto_reconnect(true, 5, 3.0)
func enable_auto_reconnect(enabled: bool, max_attempts: int = DEFAULT_MAX_RECONNECT_ATTEMPTS, base_delay: float = DEFAULT_RECONNECT_BASE_DELAY) -> void:
_auto_reconnect_enabled = enabled
_max_reconnect_attempts = max_attempts
_reconnect_base_delay = base_delay
# 获取重连信息
#
# 返回值:
# Dictionary - 重连信息 {enabled, attempt, max_attempts, delay}
func get_reconnect_info() -> Dictionary:
return {
"enabled": _auto_reconnect_enabled,
"attempt": _reconnect_attempt,
"max_attempts": _max_reconnect_attempts,
"next_delay": _calculate_reconnect_delay() if _connection_state == ConnectionState.RECONNECTING else 0.0
}
# ============================================================================
# 内部方法 - 连接状态管理
# ============================================================================
# 设置连接状态
func _set_connection_state(new_state: ConnectionState) -> void:
if _connection_state == new_state:
return
_connection_state = new_state
# 发射信号
connection_state_changed.emit(new_state)
# ============================================================================
# 内部方法 - WebSocket 状态监控
# ============================================================================
# 检查 WebSocket 状态变化
func _check_websocket_state() -> void:
# 必须先 poll 才能获取最新状态
_websocket_peer.poll()
var state: WebSocketPeer.State = _websocket_peer.get_ready_state()
match state:
WebSocketPeer.STATE_CONNECTING:
_closed_state_handled = false
# 正在连接
if _connection_state != ConnectionState.CONNECTING and _connection_state != ConnectionState.RECONNECTING:
_set_connection_state(ConnectionState.CONNECTING)
WebSocketPeer.STATE_OPEN:
_closed_state_handled = false
# 连接成功
if _connection_state != ConnectionState.CONNECTED:
_on_websocket_connected()
WebSocketPeer.STATE_CLOSING:
_closed_state_handled = false
# 正在关闭
pass
WebSocketPeer.STATE_CLOSED:
if _closed_state_handled:
return
_closed_state_handled = true
# 仅在连接生命周期中发生的关闭才触发关闭处理
var should_handle_close: bool = (
_connection_state == ConnectionState.CONNECTED
or _connection_state == ConnectionState.CONNECTING
or _connection_state == ConnectionState.RECONNECTING
)
if should_handle_close:
_on_websocket_closed()
# WebSocket 连接成功处理
func _on_websocket_connected() -> void:
# 如果是重连,发射重连成功信号
if _connection_state == ConnectionState.RECONNECTING:
_reconnect_attempt = 0
reconnection_succeeded.emit()
_set_connection_state(ConnectionState.CONNECTED)
# WebSocket 连接关闭处理
func _on_websocket_closed() -> void:
if not _manual_disconnect_requested and _auto_reconnect_enabled:
connection_lost.emit()
_attempt_reconnect()
else:
_set_connection_state(ConnectionState.DISCONNECTED)
# ============================================================================
# 内部方法 - 重连机制
# ============================================================================
# 设置重连定时器
func _setup_reconnect_timer() -> void:
_reconnect_timer = Timer.new()
_reconnect_timer.one_shot = true
_reconnect_timer.autostart = false
add_child(_reconnect_timer)
_reconnect_timer.timeout.connect(_on_reconnect_timeout)
# 尝试重连
func _attempt_reconnect() -> void:
# 检查是否超过最大重连次数
if _reconnect_attempt >= _max_reconnect_attempts:
push_error("WebSocketManager: 达到最大重连次数 (%d),停止重连" % _max_reconnect_attempts)
reconnection_failed.emit(_reconnect_attempt, _max_reconnect_attempts)
_set_connection_state(ConnectionState.ERROR)
return
_reconnect_attempt += 1
_set_connection_state(ConnectionState.RECONNECTING)
# 计算重连延迟(指数退避)
var delay: float = _calculate_reconnect_delay()
# 启动重连定时器
_reconnect_timer.start(delay)
# 计算重连延迟(指数退避)
func _calculate_reconnect_delay() -> float:
# 指数退避: base_delay * 2^(attempt-1)
var delay: float = _reconnect_base_delay * pow(2.0, _reconnect_attempt - 1)
# 限制最大延迟
return min(delay, MAX_RECONNECT_DELAY)
# 重连定时器超时处理
func _on_reconnect_timeout() -> void:
connect_to_game_server(true)
# ============================================================================
# 内部方法 - 心跳机制
# ============================================================================
# 设置心跳定时器
func _setup_heartbeat_timer() -> void:
_heartbeat_timer = Timer.new()
_heartbeat_timer.wait_time = HEARTBEAT_INTERVAL
_heartbeat_timer.one_shot = false
_heartbeat_timer.autostart = false
add_child(_heartbeat_timer)
_heartbeat_timer.timeout.connect(_on_heartbeat)
# 启动心跳
func _start_heartbeat() -> void:
_heartbeat_timer.start()
# 停止心跳
func _stop_heartbeat() -> void:
_heartbeat_timer.stop()
# 心跳超时处理
func _on_heartbeat() -> void:
# 不发送心跳,避免服务器返回 "消息格式错误"
# 如果需要心跳,服务器应该支持特定格式
pass
# ============================================================================
# 工具方法
# ============================================================================
# 获取连接状态描述
#
# 返回值:
# String - 连接状态描述
func get_state_description() -> String:
match _connection_state:
ConnectionState.DISCONNECTED:
return "未连接"
ConnectionState.CONNECTING:
return "连接中"
ConnectionState.CONNECTED:
return "已连接"
ConnectionState.RECONNECTING:
return "重连中 (%d/%d)" % [_reconnect_attempt, _max_reconnect_attempts]
ConnectionState.ERROR:
return "错误"
_:
return "未知状态"

View File

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

View File

@@ -0,0 +1,187 @@
extends Node
# ============================================================================
# EventSystem.gd - 全局事件系统
# ============================================================================
# 全局单例管理器,提供解耦的事件通信机制
#
# 核心职责:
# - 事件监听器注册和管理
# - 事件发送和分发
# - 自动清理无效监听器
# - 支持带参数的事件通信
#
# 使用方式:
# EventSystem.connect_event("player_moved", _on_player_moved)
# EventSystem.emit_event("player_moved", {"position": Vector2(100, 200)})
#
# 注意事项:
# - 作为自动加载单例,全局可访问
# - 监听器会自动检查目标节点的有效性
# - 建议使用EventNames类中定义的事件名称常量
# ============================================================================
# ============ 成员变量 ============
# 事件监听器存储
# 结构: {event_name: [{"callback": Callable, "target": Node}, ...]}
var event_listeners: Dictionary = {}
# ============ 事件监听器管理 ============
# 注册事件监听器
#
# 参数:
# event_name: String - 事件名称建议使用EventNames中的常量
# callback: Callable - 回调函数
# target: Node - 目标节点(可选,用于自动清理)
#
# 功能:
# - 将回调函数注册到指定事件
# - 支持同一事件多个监听器
# - 自动管理监听器生命周期
#
# 使用示例:
# EventSystem.connect_event(EventNames.PLAYER_MOVED, _on_player_moved, self)
#
# 注意事项:
# - 如果提供target参数当target节点被销毁时会自动清理监听器
# - 同一个callback可以监听多个事件
func connect_event(event_name: String, callback: Callable, target: Node = null):
# 初始化事件监听器数组
if not event_listeners.has(event_name):
event_listeners[event_name] = []
# 避免重复注册同一个监听器
for listener in event_listeners[event_name]:
if listener.callback == callback and listener.target == target:
return
# 创建监听器信息
var listener_info = {
"callback": callback,
"target": target
}
# 添加到监听器列表
event_listeners[event_name].append(listener_info)
# 移除事件监听器
#
# 参数:
# event_name: String - 事件名称
# callback: Callable - 要移除的回调函数
# target: Node - 目标节点(可选,用于精确匹配)
#
# 功能:
# - 从指定事件中移除特定的监听器
# - 支持精确匹配callback + target
#
# 使用示例:
# EventSystem.disconnect_event(EventNames.PLAYER_MOVED, _on_player_moved, self)
func disconnect_event(event_name: String, callback: Callable, target: Node = null):
if not event_listeners.has(event_name):
return
var listeners = event_listeners[event_name]
# 从后往前遍历,避免删除元素时索引问题
for i in range(listeners.size() - 1, -1, -1):
var listener = listeners[i]
# 匹配callback和target
if listener.callback == callback and listener.target == target:
listeners.remove_at(i)
break
# ============ 事件发送 ============
# 发送事件
#
# 参数:
# event_name: String - 事件名称
# data: Variant - 事件数据(可选)
#
# 功能:
# - 向所有注册的监听器发送事件
# - 自动跳过无效的监听器
# - 支持任意类型的事件数据
#
# 使用示例:
# EventSystem.emit_event(EventNames.PLAYER_MOVED, {"position": Vector2(100, 200)})
# EventSystem.emit_event(EventNames.GAME_PAUSED) # 无数据事件
#
# 注意事项:
# - 事件发送是同步的,所有监听器会立即执行
# - 如果监听器执行出错,不会影响其他监听器
func emit_event(event_name: String, data: Variant = null):
# 检查是否有监听器
if not event_listeners.has(event_name):
return
var listeners = event_listeners[event_name].duplicate()
for listener_info in listeners:
var target = listener_info.target
var callback = listener_info.callback
# 检查目标节点是否仍然有效
if target != null and not is_instance_valid(target):
continue
# 调用回调函数
if data != null:
callback.call(data)
else:
callback.call()
# ============ 维护方法 ============
# 清理无效的监听器
#
# 功能:
# - 遍历所有监听器,移除已销毁节点的监听器
# - 防止内存泄漏
# - 建议定期调用或在场景切换时调用
#
# 使用场景:
# - 场景切换时清理
# - 定期维护(如每分钟一次)
# - 内存优化时调用
func cleanup_invalid_listeners():
for event_name in event_listeners.keys():
var listeners = event_listeners[event_name]
# 从后往前遍历,避免删除元素时索引问题
for i in range(listeners.size() - 1, -1, -1):
var listener = listeners[i]
var target = listener.target
# 如果目标节点无效,移除监听器
if target != null and not is_instance_valid(target):
listeners.remove_at(i)
# ============ 查询方法 ============
# 获取事件监听器数量
#
# 参数:
# event_name: String - 事件名称
#
# 返回值:
# int - 监听器数量
#
# 使用场景:
# - 调试时检查监听器数量
# - 性能分析
func get_listener_count(event_name: String) -> int:
if not event_listeners.has(event_name):
return 0
return event_listeners[event_name].size()
# 清空所有事件监听器
#
# 功能:
# - 移除所有已注册的事件监听器
# - 通常在游戏重置或退出时使用
#
# 警告:
# - 这是一个危险操作,会影响所有模块
# - 使用前请确保所有模块都能正确处理监听器丢失
func clear_all_listeners():
event_listeners.clear()

View File

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

View File

@@ -0,0 +1,186 @@
class_name NetworkConfig
# ============================================================================
# NetworkConfig.gd - 前端网络环境配置
# ============================================================================
# 统一解析本地调试/线上环境的 HTTP 和 WebSocket 地址。
# 优先级:
# 1) 环境变量
# 2) Config/game_config.json 中 active_profile 对应的 profiles 配置
# 3) network 下的兼容字段
# 4) 线上默认值
# ============================================================================
const CONFIG_PATHS: Array[String] = [
"res://Config/game_config.json",
"res://config/game_config.json"
]
const DEFAULT_PROFILE: String = "production"
const DEFAULT_API_BASE_URL: String = "https://whaletownend.xinghangee.icu"
const DEFAULT_WS_URL: String = "wss://whaletownend.xinghangee.icu/game"
const API_BASE_URL_ENV_KEY: String = "WHALETOWN_API_BASE_URL"
const CHAT_WS_URL_ENV_KEY: String = "WHALETOWN_CHAT_WS_URL"
const LOCATION_WS_URL_ENV_KEY: String = "WHALETOWN_LOCATION_WS_URL"
const GAME_WS_URL_ENV_KEY: String = "WHALETOWN_GAME_WS_URL"
const PROFILE_ENV_KEY: String = "WHALETOWN_NETWORK_PROFILE"
static func get_api_base_url() -> String:
var query_url: String = _get_browser_url_override("api_base_url", ["http://", "https://"])
if not query_url.is_empty():
return _trim_trailing_slash(query_url)
var env_url: String = OS.get_environment(API_BASE_URL_ENV_KEY).strip_edges()
if not env_url.is_empty():
return _trim_trailing_slash(env_url)
var network_config := _get_active_network_config()
var url: String = str(network_config.get("api_base_url", "")).strip_edges()
if not url.is_empty():
return _trim_trailing_slash(url)
return DEFAULT_API_BASE_URL
static func get_chat_ws_url() -> String:
var query_url: String = _get_browser_url_override("chat_ws_url", ["ws://", "wss://"])
if not query_url.is_empty():
return query_url
var env_url: String = OS.get_environment(CHAT_WS_URL_ENV_KEY).strip_edges()
if not env_url.is_empty():
return env_url
var network_config := _get_active_network_config()
var url: String = str(network_config.get("chat_ws_url", network_config.get("game_ws_url", ""))).strip_edges()
if not url.is_empty():
return url
return DEFAULT_WS_URL
static func get_location_ws_url() -> String:
var query_url: String = _get_browser_url_override("location_ws_url", ["ws://", "wss://"])
if not query_url.is_empty():
return query_url
var game_query_url: String = _get_browser_url_override("game_ws_url", ["ws://", "wss://"])
if not game_query_url.is_empty():
return game_query_url
var env_url: String = OS.get_environment(LOCATION_WS_URL_ENV_KEY).strip_edges()
if not env_url.is_empty():
return env_url
var game_env_url: String = OS.get_environment(GAME_WS_URL_ENV_KEY).strip_edges()
if not game_env_url.is_empty():
return game_env_url
var network_config := _get_active_network_config()
var url: String = str(network_config.get("location_ws_url", network_config.get("game_ws_url", ""))).strip_edges()
if not url.is_empty():
return url
return DEFAULT_WS_URL
static func get_active_profile() -> String:
var query_profile: String = _get_browser_query_value("network_profile")
if not query_profile.is_empty():
return query_profile
var short_query_profile: String = _get_browser_query_value("profile")
if not short_query_profile.is_empty():
return short_query_profile
var env_profile: String = OS.get_environment(PROFILE_ENV_KEY).strip_edges()
if not env_profile.is_empty():
return env_profile
var root := _load_config()
var network_variant: Variant = root.get("network", {})
if network_variant is Dictionary:
var network_config: Dictionary = network_variant
var active_profile: String = str(network_config.get("active_profile", "")).strip_edges()
if not active_profile.is_empty():
return active_profile
return DEFAULT_PROFILE
static func is_local_debug() -> bool:
return get_active_profile() == "local"
static func get_summary() -> Dictionary:
return {
"profile": get_active_profile(),
"api_base_url": get_api_base_url(),
"chat_ws_url": get_chat_ws_url(),
"location_ws_url": get_location_ws_url()
}
static func _get_active_network_config() -> Dictionary:
var root := _load_config()
var network_variant: Variant = root.get("network", {})
if not (network_variant is Dictionary):
return {}
var network_config: Dictionary = network_variant
var active_profile := get_active_profile()
var profiles_variant: Variant = network_config.get("profiles", {})
if profiles_variant is Dictionary:
var profiles: Dictionary = profiles_variant
var profile_variant: Variant = profiles.get(active_profile, {})
if profile_variant is Dictionary:
return profile_variant
return network_config
static func _load_config() -> Dictionary:
for config_path in CONFIG_PATHS:
if not FileAccess.file_exists(config_path):
continue
var content: String = FileAccess.get_file_as_string(config_path)
if content.is_empty():
continue
var json := JSON.new()
if json.parse(content) != OK:
push_warning("NetworkConfig: 读取配置失败 %s - %s" % [config_path, json.get_error_message()])
continue
var data_variant: Variant = json.data
if data_variant is Dictionary:
return data_variant
return {}
static func _get_browser_query_value(key: String) -> String:
if OS.get_name() != "Web" or not OS.is_debug_build():
return ""
if not Engine.has_singleton("JavaScriptBridge"):
return ""
var js_bridge: Object = Engine.get_singleton("JavaScriptBridge")
if js_bridge == null:
return ""
var script := "new URLSearchParams(window.location.search).get('%s') || ''" % key
var value: Variant = js_bridge.eval(script, true)
return str(value).strip_edges()
static func _get_browser_url_override(key: String, allowed_schemes: Array[String]) -> String:
var value := _get_browser_query_value(key)
if value.is_empty():
return ""
var normalized := value.to_lower()
for scheme in allowed_schemes:
if normalized.begins_with(scheme):
return value
push_warning("NetworkConfig: 忽略无效的浏览器网络地址覆盖 %s" % key)
return ""
static func _trim_trailing_slash(url: String) -> String:
var trimmed := url.strip_edges()
while trimmed.ends_with("/") and trimmed.length() > 1:
trimmed = trimmed.substr(0, trimmed.length() - 1)
return trimmed

View File

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

Binary file not shown.

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://c5yyrqm25jmdc"
path="res://.godot/imported/notify_friend.wav-d44d53e81630576955c5447c5426e7fc.sample"
[deps]
source_file="res://assets/audio/ui/notify_friend.wav"
dest_files=["res://.godot/imported/notify_friend.wav-d44d53e81630576955c5447c5426e7fc.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://c63txcx3nyh24"
path="res://.godot/imported/notify_private.wav-e1ffa7f6c16df0a60bb3af5aad82ebca.sample"
[deps]
source_file="res://assets/audio/ui/notify_private.wav"
dest_files=["res://.godot/imported/notify_private.wav-e1ffa7f6c16df0a60bb3af5aad82ebca.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://dny32qa113wtu"
path="res://.godot/imported/notify_world.wav-e8bfd8d2e8704e49e9f132861869c541.sample"
[deps]
source_file="res://assets/audio/ui/notify_world.wav"
dest_files=["res://.godot/imported/notify_world.wav-e8bfd8d2e8704e49e9f132861869c541.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://u6cxaapuonws"
path="res://.godot/imported/cafe_whale_barista_npc_640.png-4196ac54acfd0552e9d6c52c70c00f6f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/characters/cafe_whale_barista_npc_640.png"
dest_files=["res://.godot/imported/cafe_whale_barista_npc_640.png-4196ac54acfd0552e9d6c52c70c00f6f.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b6xq4l3cv6fue"
path="res://.godot/imported/crayfish_npc_256_256.png-32dc06031ff358d2a346c64348a439f8.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/characters/crayfish_npc_256_256.png"
dest_files=["res://.godot/imported/crayfish_npc_256_256.png-32dc06031ff358d2a346c64348a439f8.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://brko2ik6t6ib5"
path="res://.godot/imported/npc_286_241.png-dfe6daef11d0f27f7902e69d6057828f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/characters/npc_286_241.png"
dest_files=["res://.godot/imported/npc_286_241.png-dfe6daef11d0f27f7902e69d6057828f.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://u55bg162ny16"
path="res://.godot/imported/player_pixel_spritesheet.png-f787db879b4dc38c74038c2589200748.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/characters/player_pixel_spritesheet.png"
dest_files=["res://.godot/imported/player_pixel_spritesheet.png-f787db879b4dc38c74038c2589200748.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cghab1hkx5lg5"
path="res://.godot/imported/player_spritesheet.png-7e76f97a17946ec512358d45f2527da8.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/characters/player_spritesheet.png"
dest_files=["res://.godot/imported/player_spritesheet.png-7e76f97a17946ec512358d45f2527da8.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bk5njnttxokm8"
path="res://.godot/imported/girl_sailor_turnaround_v2_8x4_spritesheet.png-f995c128643883e0707ec2b35fdaeec4.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/characters/skins/girl_sailor_turnaround_v2_8x4_spritesheet.png"
dest_files=["res://.godot/imported/girl_sailor_turnaround_v2_8x4_spritesheet.png-f995c128643883e0707ec2b35fdaeec4.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cglvm4rvfl082"
path="res://.godot/imported/human_whale_directional_v2_8x4_spritesheet.png-6f8432a58c34f1132ab74261222ead34.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/characters/skins/human_whale_directional_v2_8x4_spritesheet.png"
dest_files=["res://.godot/imported/human_whale_directional_v2_8x4_spritesheet.png-6f8432a58c34f1132ab74261222ead34.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dwwnmg378vuin"
path="res://.godot/imported/ordinary_man_male_8x4_spritesheet.png-fc922ea86dfe5dad54b5bb5b35b76594.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/characters/skins/ordinary_man_male_8x4_spritesheet.png"
dest_files=["res://.godot/imported/ordinary_man_male_8x4_spritesheet.png-fc922ea86dfe5dad54b5bb5b35b76594.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://beyxgqvdussjo"
path="res://.godot/imported/panda_hero_8x4_spritesheet.png-a4d4e17349c486beb02afa65b8acdf67.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/characters/skins/panda_hero_8x4_spritesheet.png"
dest_files=["res://.godot/imported/panda_hero_8x4_spritesheet.png-a4d4e17349c486beb02afa65b8acdf67.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/fonts/msyh.ttc Normal file

Binary file not shown.

View File

@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://ce7ujbeobblyr"
path="res://.godot/imported/msyh.ttc-1f7944f6d1cff8092894a3525ec5156c.fontdata"
[deps]
source_file="res://assets/fonts/msyh.ttc"
dest_files=["res://.godot/imported/msyh.ttc-1f7944f6d1cff8092894a3525ec5156c.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=1
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bj5jpjtv0ylfu"
path="res://.godot/imported/personal_room_25d_empty_v1.png-da4d81c17ea4e7e3362007ea9da6e9b0.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/maps/personal_space/v1/base/personal_room_25d_empty_v1.png"
dest_files=["res://.godot/imported/personal_room_25d_empty_v1.png-da4d81c17ea4e7e3362007ea9da6e9b0.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ca1u6wquf1p7t"
path="res://.godot/imported/personal_room_25d_empty_v1_white_bg_backup.png-944515a574318d69eed7e27545a7d92e.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/maps/personal_space/v1/base/personal_room_25d_empty_v1_white_bg_backup.png"
dest_files=["res://.godot/imported/personal_room_25d_empty_v1_white_bg_backup.png-944515a574318d69eed7e27545a7d92e.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cg7hdyqol6wvi"
path="res://.godot/imported/personal_room_25d_front_ledges_v1.png-88d05ac0639ef8d828da8c1b001b3b44.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/maps/personal_space/v1/base/personal_room_25d_front_ledges_v1.png"
dest_files=["res://.godot/imported/personal_room_25d_front_ledges_v1.png-88d05ac0639ef8d828da8c1b001b3b44.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dnbilxhci8f2q"
path="res://.godot/imported/personal_room_25d_large_empty_v1.png-f2bd45f96d298f523909d327d0353d50.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/maps/personal_space/v1/base/personal_room_25d_large_empty_v1.png"
dest_files=["res://.godot/imported/personal_room_25d_large_empty_v1.png-f2bd45f96d298f523909d327d0353d50.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cc0b4iwnsyeve"
path="res://.godot/imported/personal_room_25d_no_front_ledges_v1.png-237d7989173954f4e65bf9b4dcf8bbb8.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/maps/personal_space/v1/base/personal_room_25d_no_front_ledges_v1.png"
dest_files=["res://.godot/imported/personal_room_25d_no_front_ledges_v1.png-237d7989173954f4e65bf9b4dcf8bbb8.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ch8r7edxfbolr"
path="res://.godot/imported/personal_room_25d_sidewalls_wider_not_longer_v1.png-44fa073b68dbbbeb8c7d2d443d7f3c25.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/maps/personal_space/v1/base/personal_room_25d_sidewalls_wider_not_longer_v1.png"
dest_files=["res://.godot/imported/personal_room_25d_sidewalls_wider_not_longer_v1.png-44fa073b68dbbbeb8c7d2d443d7f3c25.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cfup26cbsp5uj"
path="res://.godot/imported/personal_room_25d_straight_walls_wide_sidewalls_balanced_v1.png-02b454b51e1686a1766107abdb20627a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/maps/personal_space/v1/base/personal_room_25d_straight_walls_wide_sidewalls_balanced_v1.png"
dest_files=["res://.godot/imported/personal_room_25d_straight_walls_wide_sidewalls_balanced_v1.png-02b454b51e1686a1766107abdb20627a.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dgxq1r47vrjgq"
path="res://.godot/imported/personal_room_25d_xlarge_sidewalls_v1.png-752f7732c404e418f53c1c6332e3d43f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/maps/personal_space/v1/base/personal_room_25d_xlarge_sidewalls_v1.png"
dest_files=["res://.godot/imported/personal_room_25d_xlarge_sidewalls_v1.png-752f7732c404e418f53c1c6332e3d43f.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cy2gxurx5w273"
path="res://.godot/imported/room_decor_boat_cabin_bed_roomfit.png-9db8e1de9f35c57cbce6e7ebd5f8c3d6.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/maps/personal_space/v1/decor/room_decor_boat_cabin_bed_roomfit.png"
dest_files=["res://.godot/imported/room_decor_boat_cabin_bed_roomfit.png-9db8e1de9f35c57cbce6e7ebd5f8c3d6.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://d1o7ruvvbmxr"
path="res://.godot/imported/room_decor_datawhale_buddhist_learning_badge_hires_clean.png-d4ac686a4f0e89762dab779cf04ce97c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/maps/personal_space/v1/decor/room_decor_datawhale_buddhist_learning_badge_hires_clean.png"
dest_files=["res://.godot/imported/room_decor_datawhale_buddhist_learning_badge_hires_clean.png-d4ac686a4f0e89762dab779cf04ce97c.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bk5qnwlckdmpy"
path="res://.godot/imported/room_decor_datawhale_buddhist_learning_badge_roomfit.png-8cbb8d7740f2642b0ea2afa8cc704ab6.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/maps/personal_space/v1/decor/room_decor_datawhale_buddhist_learning_badge_roomfit.png"
dest_files=["res://.godot/imported/room_decor_datawhale_buddhist_learning_badge_roomfit.png-8cbb8d7740f2642b0ea2afa8cc704ab6.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ctd1navuvfll7"
path="res://.godot/imported/room_decor_datawhale_bug_feature_badge_hires_clean.png-c79ab46269bdbff26b0226ef8351029c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/maps/personal_space/v1/decor/room_decor_datawhale_bug_feature_badge_hires_clean.png"
dest_files=["res://.godot/imported/room_decor_datawhale_bug_feature_badge_hires_clean.png-c79ab46269bdbff26b0226ef8351029c.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bn4gpjwqpx26t"
path="res://.godot/imported/room_decor_datawhale_bug_feature_badge_roomfit.png-d70b88d7e0b15badce4210c458f8f9b3.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/maps/personal_space/v1/decor/room_decor_datawhale_bug_feature_badge_roomfit.png"
dest_files=["res://.godot/imported/room_decor_datawhale_bug_feature_badge_roomfit.png-d70b88d7e0b15badce4210c458f8f9b3.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://juc5aierj14a"
path="res://.godot/imported/room_decor_datawhale_ok_working_badge_hires_clean.png-834b8b11dd1f8c147754dad40829ef91.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/maps/personal_space/v1/decor/room_decor_datawhale_ok_working_badge_hires_clean.png"
dest_files=["res://.godot/imported/room_decor_datawhale_ok_working_badge_hires_clean.png-834b8b11dd1f8c147754dad40829ef91.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bp7f3v2wan04k"
path="res://.godot/imported/room_decor_datawhale_ok_working_badge_roomfit.png-26da1d2924594c2b293abaaad39c9a20.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/maps/personal_space/v1/decor/room_decor_datawhale_ok_working_badge_roomfit.png"
dest_files=["res://.godot/imported/room_decor_datawhale_ok_working_badge_roomfit.png-26da1d2924594c2b293abaaad39c9a20.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bpaooono6llrm"
path="res://.godot/imported/room_decor_dev_whale_bookshelf_roomfit.png-158b2df95009f4f6069d1c5b200db1bd.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/maps/personal_space/v1/decor/room_decor_dev_whale_bookshelf_roomfit.png"
dest_files=["res://.godot/imported/room_decor_dev_whale_bookshelf_roomfit.png-158b2df95009f4f6069d1c5b200db1bd.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ch0wiqlv3oq8l"
path="res://.godot/imported/room_decor_low_wave_bed_roomfit.png-f005a11bd238de0a9247f2bf32e97a6f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/maps/personal_space/v1/decor/room_decor_low_wave_bed_roomfit.png"
dest_files=["res://.godot/imported/room_decor_low_wave_bed_roomfit.png-f005a11bd238de0a9247f2bf32e97a6f.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://rb18oqsu74ed"
path="res://.godot/imported/room_decor_whale_floor_rug_roomfit.png-28b922a03583e1d104116a661a0c1112.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/maps/personal_space/v1/decor/room_decor_whale_floor_rug_roomfit.png"
dest_files=["res://.godot/imported/room_decor_whale_floor_rug_roomfit.png-28b922a03583e1d104116a661a0c1112.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Some files were not shown because too many files have changed in this diff Show More