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) signal scene_pack_progress(scene_name: String, downloaded_bytes: int, total_bytes: int) signal scene_pack_failed(scene_name: String, message: 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 _pack_manifest: Dictionary = {} var _loaded_scene_packs: Dictionary = {} var _active_pack_request: HTTPRequest var _active_pack_scene_name: String = "" var _pack_overlay: CanvasLayer var _pack_status_label: Label var _pack_progress_bar: ProgressBar var _web_pack_progress_last_emit_msec: int = 0 const PACK_MANIFEST_PATH: String = "/packs/manifest.json" const PACK_CACHE_DIR: String = "user://scene-packs" const SCENE_PACK_KEYS: Dictionary = { "main": "auth", "auth": "auth", "square": "square", "work_zone": "work_zone", "cafe_interior": "cafe_interior", "room": "personal_space", "personal_space": "personal_space", } # 场景路径映射表 # 将场景名称映射到实际的文件路径 # 便于统一管理和修改场景路径 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() if OS.get_name() == "Web" and SCENE_PACK_KEYS.has(scene_name): var packLoaded := await _ensure_scene_pack(scene_name) if not packLoaded: is_changing_scene = false if use_transition: await hide_transition() return false # 执行场景切换 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 func _process(_delta: float) -> void: if not is_instance_valid(_active_pack_request): return var downloaded := _active_pack_request.get_downloaded_bytes() var total := _active_pack_request.get_body_size() if is_instance_valid(_pack_progress_bar): _pack_progress_bar.indeterminate = total <= 0 if total > 0: _pack_progress_bar.max_value = total _pack_progress_bar.value = downloaded if is_instance_valid(_pack_status_label): _pack_status_label.text = "正在加载场景 %s" % _format_download_progress(downloaded, total) _notify_web_auth_pack_progress(downloaded, total) scene_pack_progress.emit(_active_pack_scene_name, downloaded, total) func _notify_web_auth_pack_progress(downloaded: int, total: int) -> void: if OS.get_name() != "Web" or _active_pack_scene_name != "auth": return var now := Time.get_ticks_msec() if now - _web_pack_progress_last_emit_msec < 250: return _web_pack_progress_last_emit_msec = now if not Engine.has_singleton("JavaScriptBridge"): return var bridge: Object = Engine.get_singleton("JavaScriptBridge") bridge.eval("window.whaletownPackProgress && window.whaletownPackProgress(%d, %d)" % [downloaded, total], true) func _ensure_scene_pack(scene_name: String) -> bool: var packKey := str(SCENE_PACK_KEYS.get(scene_name, "")) if packKey.is_empty() or bool(_loaded_scene_packs.get(packKey, false)): return true _show_pack_overlay("正在准备场景...") if _pack_manifest.is_empty(): _pack_manifest = await _download_pack_manifest() if _pack_manifest.is_empty(): _show_pack_error(scene_name, "场景清单加载失败,请检查网络后重试") return false var entryVariant: Variant = _pack_manifest.get(packKey, {}) if not (entryVariant is Dictionary): _show_pack_error(scene_name, "服务器缺少场景资源:%s" % packKey) return false var entry := entryVariant as Dictionary var fileName := str(entry.get("file", "")).get_file() if fileName.is_empty() or not fileName.ends_with(".pck"): _show_pack_error(scene_name, "场景资源清单格式错误") return false var expectedSize := int(entry.get("size", 0)) var cacheDirAbsolute := ProjectSettings.globalize_path(PACK_CACHE_DIR) DirAccess.make_dir_recursive_absolute(cacheDirAbsolute) var localPath := "%s/%s" % [PACK_CACHE_DIR, fileName] if _is_scene_pack_file_valid(localPath, fileName, expectedSize) and ProjectSettings.load_resource_pack(localPath, true): _loaded_scene_packs[packKey] = true _hide_pack_overlay() return true if FileAccess.file_exists(localPath): DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath)) var packUrl := _resolve_web_url("/packs/%s" % fileName) if packUrl.is_empty() or not await _download_pack(scene_name, packUrl, localPath): _show_pack_error(scene_name, "场景下载失败,请检查网络后重试") return false if not _is_scene_pack_file_valid(localPath, fileName, expectedSize): DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath)) _show_pack_error(scene_name, "场景资源下载不完整,请重新进入") return false if not ProjectSettings.load_resource_pack(localPath, true): DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath)) _show_pack_error(scene_name, "场景资源损坏,请重新进入") return false _loaded_scene_packs[packKey] = true _hide_pack_overlay() return true func _is_scene_pack_file_valid(local_path: String, file_name: String, expected_size: int) -> bool: if not FileAccess.file_exists(local_path): return false var file := FileAccess.open(local_path, FileAccess.READ) if file == null: return false var actualSize := file.get_length() file.close() if expected_size > 0 and actualSize != expected_size: return false var stem := file_name.trim_suffix(".pck") var separator := stem.rfind("-") if separator < 0: return true var expectedHashPrefix := stem.substr(separator + 1).to_lower() if expectedHashPrefix.length() != 12: return true var actualHash := FileAccess.get_sha256(local_path).to_lower() return not actualHash.is_empty() and actualHash.begins_with(expectedHashPrefix) func _download_pack_manifest() -> Dictionary: var request := HTTPRequest.new() request.timeout = 12.0 add_child(request) var url := _resolve_web_url(PACK_MANIFEST_PATH) if url.is_empty() or request.request(url, ["Cache-Control: no-cache"]) != OK: request.queue_free() return {} var response: Array = await request.request_completed request.queue_free() if response.size() < 4 or int(response[0]) != HTTPRequest.RESULT_SUCCESS: return {} var responseCode := int(response[1]) if responseCode < 200 or responseCode >= 300: return {} var json := JSON.new() if json.parse((response[3] as PackedByteArray).get_string_from_utf8()) != OK: return {} if not (json.data is Dictionary): return {} var root := json.data as Dictionary var packsVariant: Variant = root.get("packs", {}) return packsVariant as Dictionary if packsVariant is Dictionary else {} func _download_pack(scene_name: String, url: String, local_path: String) -> bool: var request := HTTPRequest.new() request.timeout = 180.0 request.download_file = local_path add_child(request) _active_pack_request = request _active_pack_scene_name = scene_name if request.request(url) != OK: _active_pack_request = null _active_pack_scene_name = "" request.queue_free() return false var response: Array = await request.request_completed _active_pack_request = null _active_pack_scene_name = "" request.queue_free() if response.size() < 2 or int(response[0]) != HTTPRequest.RESULT_SUCCESS: return false var responseCode := int(response[1]) return responseCode >= 200 and responseCode < 300 func _resolve_web_url(path: String) -> String: if not Engine.has_singleton("JavaScriptBridge"): return "" var jsBridge: Object = Engine.get_singleton("JavaScriptBridge") if jsBridge == null: return "" return str(jsBridge.eval("new URL('%s', window.location.href).href" % path, true)).strip_edges() func _show_pack_overlay(message: String) -> void: if not is_instance_valid(_pack_overlay): _pack_overlay = CanvasLayer.new() _pack_overlay.layer = 1000 add_child(_pack_overlay) var backdrop := ColorRect.new() backdrop.color = Color(0.025, 0.09, 0.15, 0.92) backdrop.set_anchors_preset(Control.PRESET_FULL_RECT) _pack_overlay.add_child(backdrop) var center := CenterContainer.new() center.set_anchors_preset(Control.PRESET_FULL_RECT) _pack_overlay.add_child(center) var box := VBoxContainer.new() box.custom_minimum_size = Vector2(420, 100) box.add_theme_constant_override("separation", 18) center.add_child(box) _pack_status_label = Label.new() _pack_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _pack_status_label.add_theme_font_size_override("font_size", 22) box.add_child(_pack_status_label) _pack_progress_bar = ProgressBar.new() _pack_progress_bar.custom_minimum_size = Vector2(420, 14) _pack_progress_bar.show_percentage = false _pack_progress_bar.indeterminate = true box.add_child(_pack_progress_bar) _pack_overlay.show() _pack_status_label.text = message _pack_progress_bar.indeterminate = true func _hide_pack_overlay() -> void: if is_instance_valid(_pack_overlay): _pack_overlay.hide() func _show_pack_error(scene_name: String, message: String) -> void: if not is_instance_valid(_pack_overlay): _show_pack_overlay(message) _pack_status_label.text = message _pack_progress_bar.hide() scene_pack_failed.emit(scene_name, message) await get_tree().create_timer(3.0).timeout _pack_progress_bar.show() _hide_pack_overlay() func _format_download_progress(downloaded: int, total: int) -> String: var downloadedMb := float(downloaded) / 1048576.0 if total <= 0: return "%.1f MB" % downloadedMb return "%.1f / %.1f MB" % [downloadedMb, float(total) / 1048576.0] # ============ 查询方法 ============ # 获取当前场景名称 # # 返回值: # 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