Initial WhaleTown V2 frontend
4
.editorconfig
Normal file
@@ -0,0 +1,4 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Normalize EOL for all files that Git considers text files.
|
||||
* text=auto eol=lf
|
||||
120
.gitignore
vendored
Normal 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
@@ -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
|
||||
1
Config/MallCatalog.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dm0ak1jh4piup
|
||||
120
Config/RoomDecorCatalog.gd
Normal 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
|
||||
1
Config/RoomDecorCatalog.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dttn5p0sgtnqv
|
||||
41
Config/game_config.json
Normal 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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
uid://xys0x42g01aw
|
||||
130
_Core/managers/ApiClient.gd
Normal 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
|
||||
1
_Core/managers/ApiClient.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dmpcxgvfo7uyo
|
||||
856
_Core/managers/AppearanceManager.gd
Normal 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)
|
||||
1
_Core/managers/AppearanceManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://rsacygh811e5
|
||||
515
_Core/managers/AuthManager.gd
Normal 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
|
||||
1
_Core/managers/AuthManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bcnqr6uw425hj
|
||||
286
_Core/managers/CafeCompanionManager.gd
Normal 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
|
||||
1
_Core/managers/CafeCompanionManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://nsdw0bspjy35
|
||||
1625
_Core/managers/ChatManager.gd
Normal file
1
_Core/managers/ChatManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://biet5tng84app
|
||||
150
_Core/managers/NotificationSoundManager.gd
Normal 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")
|
||||
1
_Core/managers/NotificationSoundManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b8v2m3ivohfgw
|
||||
227
_Core/managers/PlayerStateManager.gd
Normal 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
|
||||
1
_Core/managers/PlayerStateManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cy1kwce6ngghv
|
||||
183
_Core/managers/RoomDecorSaveManager.gd
Normal 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
|
||||
1
_Core/managers/RoomDecorSaveManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cribngv3l36a
|
||||
244
_Core/managers/SceneManager.gd
Normal 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
|
||||
1
_Core/managers/SceneManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://osl0yxxjjro2
|
||||
136
_Core/managers/SettingsManager.gd
Normal 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()
|
||||
1
_Core/managers/SettingsManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ti504x6wnfr
|
||||
462
_Core/managers/WebSocketManager.gd
Normal 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 "未知状态"
|
||||
1
_Core/managers/WebSocketManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://brsajpuxddi12
|
||||
187
_Core/systems/EventSystem.gd
Normal 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()
|
||||
1
_Core/systems/EventSystem.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b7hhpykc8il8k
|
||||
186
_Core/utils/NetworkConfig.gd
Normal 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
|
||||
1
_Core/utils/NetworkConfig.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bbkfw7my8qrph
|
||||
BIN
assets/audio/ui/notify_friend.wav
Normal file
24
assets/audio/ui/notify_friend.wav.import
Normal 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
|
||||
BIN
assets/audio/ui/notify_private.wav
Normal file
24
assets/audio/ui/notify_private.wav.import
Normal 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
|
||||
BIN
assets/audio/ui/notify_world.wav
Normal file
24
assets/audio/ui/notify_world.wav.import
Normal 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
|
||||
BIN
assets/characters/cafe_whale_barista_npc_640.png
Normal file
|
After Width: | Height: | Size: 398 KiB |
40
assets/characters/cafe_whale_barista_npc_640.png.import
Normal 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
|
||||
BIN
assets/characters/crayfish_npc_256_256.png
Normal file
|
After Width: | Height: | Size: 298 KiB |
40
assets/characters/crayfish_npc_256_256.png.import
Normal 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
|
||||
BIN
assets/characters/npc_286_241.png
Normal file
|
After Width: | Height: | Size: 322 KiB |
40
assets/characters/npc_286_241.png.import
Normal 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
|
||||
BIN
assets/characters/player_pixel_spritesheet.png
Normal file
|
After Width: | Height: | Size: 255 KiB |
40
assets/characters/player_pixel_spritesheet.png.import
Normal 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
|
||||
BIN
assets/characters/player_spritesheet.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
40
assets/characters/player_spritesheet.png.import
Normal 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
|
||||
|
After Width: | Height: | Size: 398 KiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 550 KiB |
@@ -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
|
||||
BIN
assets/characters/skins/ordinary_man_male_8x4_spritesheet.png
Normal file
|
After Width: | Height: | Size: 430 KiB |
@@ -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
|
||||
BIN
assets/characters/skins/panda_hero_8x4_spritesheet.png
Normal file
|
After Width: | Height: | Size: 485 KiB |
@@ -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
36
assets/fonts/msyh.ttc.import
Normal 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={}
|
||||
|
After Width: | Height: | Size: 1.7 MiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 1.8 MiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 1.6 MiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 1.6 MiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 1.6 MiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 2.1 MiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 2.1 MiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 1.7 MiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 139 KiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 1.0 MiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 92 KiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 747 KiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 70 KiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 957 KiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 84 KiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 232 KiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 148 KiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 75 KiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 129 KiB |