diff --git a/Config/game_config.json b/Config/game_config.json index d1b024a..78c9432 100644 --- a/Config/game_config.json +++ b/Config/game_config.json @@ -8,16 +8,16 @@ "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://whaletown.novamailio.com/api", + "chat_ws_url": "wss://whaletown.novamailio.com/game", + "location_ws_url": "wss://whaletown.novamailio.com/game", + "game_ws_url": "wss://whaletown.novamailio.com/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", + "api_base_url": "https://whaletown.novamailio.com/api", + "chat_ws_url": "wss://whaletown.novamailio.com/game", + "location_ws_url": "wss://whaletown.novamailio.com/game", + "game_ws_url": "wss://whaletown.novamailio.com/game", "timeout": 30, "retry_count": 3 }, diff --git a/_Core/managers/AuthManager.gd b/_Core/managers/AuthManager.gd index e51bb08..bfe3f8b 100644 --- a/_Core/managers/AuthManager.gd +++ b/_Core/managers/AuthManager.gd @@ -17,11 +17,14 @@ signal email_verification_failed(message: String) signal profile_update_succeeded(profile: Dictionary) signal profile_update_failed(message: String) signal logout_completed() +signal browser_bootstrap_received(kind: String) const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd") const DEFAULT_AUTH_CONFIG_PATH: String = "user://auth.cfg" const REQUEST_TIMEOUT: float = 12.0 +const BROWSER_BOOTSTRAP_STORAGE_KEY: String = "whaletown.auth.bootstrap" +const BROWSER_BOOTSTRAP_POLL_INTERVAL: float = 0.25 var _access_token: String = "" var _refresh_token: String = "" @@ -32,9 +35,25 @@ 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 +var _pending_registration_username: String = "" +var _pending_registration_password: String = "" +var _registration_recovery_in_flight: bool = false +var _registration_recovery_error: String = "" +var _browser_bootstrap_kind: String = "" +var _browser_bootstrap_poll_elapsed: float = 0.0 func _ready() -> void: _load_cached_session() + _try_import_browser_bootstrap(false) + +func _process(delta: float) -> void: + if OS.get_name() != "Web" or not _browser_bootstrap_kind.is_empty(): + return + _browser_bootstrap_poll_elapsed += delta + if _browser_bootstrap_poll_elapsed < BROWSER_BOOTSTRAP_POLL_INTERVAL: + return + _browser_bootstrap_poll_elapsed = 0.0 + _try_import_browser_bootstrap(true) func _exit_tree() -> void: for request in _active_requests: @@ -70,6 +89,14 @@ func get_account_generation() -> int: func get_auth_config_path() -> String: return _auth_config_path +func consume_browser_bootstrap_kind() -> String: + var kind := _browser_bootstrap_kind + _browser_bootstrap_kind = "" + return kind + +func get_browser_bootstrap_kind() -> String: + return _browser_bootstrap_kind + func login(identifier: String, password: String) -> void: var normalized_identifier := identifier.strip_edges() if normalized_identifier.is_empty(): @@ -144,6 +171,10 @@ func register(username: String, password: String, nickname: String = "", email: if not normalized_skin_id.is_empty(): payload["skin_id"] = normalized_skin_id + _pending_registration_username = normalized_username + _pending_registration_password = password + _registration_recovery_in_flight = false + _registration_recovery_error = "" _advance_account_generation() _request_json("/auth/register", payload, _on_register_response, HTTPClient.METHOD_POST, false, true) @@ -289,14 +320,21 @@ func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary) func _on_register_response(success: bool, data: Dictionary, error_info: Dictionary) -> void: if not success: - register_failed.emit(str(error_info.get("message", "注册失败"))) + var message := str(error_info.get("message", "注册失败")) + if _should_recover_registration(message): + _recover_registration_session(message) + return + _clear_pending_registration() + register_failed.emit(message) return _apply_auth_payload(data) if not is_authenticated(): + _clear_pending_registration() register_failed.emit("注册响应缺少 access_token") return + _clear_pending_registration() _emit_event(EventNames.AUTH_REGISTER_SUCCESS, { "user": get_current_user() }) @@ -304,6 +342,49 @@ func _on_register_response(success: bool, data: Dictionary, error_info: Dictiona auth_state_changed.emit(true, get_current_user()) _refresh_player_snapshot() +func _should_recover_registration(message: String) -> bool: + if _registration_recovery_in_flight or _pending_registration_username.is_empty() or _pending_registration_password.is_empty(): + return false + return message.begins_with("网络请求失败") \ + or message.begins_with("网络请求发送失败") \ + or message.contains("用户名已存在") \ + or message.contains("用户名已被注册") + +func _recover_registration_session(original_error: String) -> void: + _registration_recovery_in_flight = true + _registration_recovery_error = original_error + _request_json("/auth/login", { + "identifier": _pending_registration_username, + "password": _pending_registration_password, + }, _on_registration_recovery_response, HTTPClient.METHOD_POST, false, true) + +func _on_registration_recovery_response(success: bool, data: Dictionary, error_info: Dictionary) -> void: + if not success: + var original_error := _registration_recovery_error + _clear_pending_registration() + register_failed.emit(str(error_info.get("message", original_error if not original_error.is_empty() else "注册状态确认失败"))) + return + + _apply_auth_payload(data) + if not is_authenticated(): + _clear_pending_registration() + register_failed.emit("账号已创建,但登录确认失败") + return + + _clear_pending_registration() + _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 _clear_pending_registration() -> void: + _pending_registration_username = "" + _pending_registration_password = "" + _registration_recovery_in_flight = false + _registration_recovery_error = "" + func _on_refresh_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void: _refresh_in_flight = false if not success: @@ -334,6 +415,38 @@ func _apply_auth_payload(payload: Dictionary) -> void: _session_generation += 1 _save_cached_session() +func _try_import_browser_bootstrap(emit_signal: bool) -> bool: + if OS.get_name() != "Web" or not Engine.has_singleton("JavaScriptBridge"): + return false + var js_bridge: Object = Engine.get_singleton("JavaScriptBridge") + if js_bridge == null: + return false + var raw: String = str(js_bridge.eval( + "window.sessionStorage.getItem('%s') || ''" % BROWSER_BOOTSTRAP_STORAGE_KEY, + true + )).strip_edges() + if raw.is_empty(): + return false + var json := JSON.new() + if json.parse(raw) != OK or not (json.data is Dictionary): + js_bridge.eval("window.sessionStorage.removeItem('%s')" % BROWSER_BOOTSTRAP_STORAGE_KEY) + return false + var bootstrap := json.data as Dictionary + var kind := str(bootstrap.get("kind", "")).strip_edges() + var payload_variant: Variant = bootstrap.get("payload", {}) + if not ["login", "register"].has(kind) or not (payload_variant is Dictionary): + js_bridge.eval("window.sessionStorage.removeItem('%s')" % BROWSER_BOOTSTRAP_STORAGE_KEY) + return false + _apply_auth_payload(payload_variant as Dictionary) + if not is_authenticated(): + return false + _browser_bootstrap_kind = kind + js_bridge.eval("window.sessionStorage.removeItem('%s')" % BROWSER_BOOTSTRAP_STORAGE_KEY) + if emit_signal: + browser_bootstrap_received.emit(kind) + auth_state_changed.emit(true, get_current_user()) + return true + func _on_profile_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void: if not success: profile_update_failed.emit(str(_error_info.get("message", "玩家资料保存失败"))) diff --git a/_Core/managers/SceneManager.gd b/_Core/managers/SceneManager.gd index 3f9ee98..845be54 100644 --- a/_Core/managers/SceneManager.gd +++ b/_Core/managers/SceneManager.gd @@ -30,6 +30,8 @@ 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) # ============ 成员变量 ============ @@ -38,6 +40,25 @@ 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 + +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", +} # 场景路径映射表 # 将场景名称映射到实际的文件路径 @@ -101,6 +122,14 @@ func change_scene(scene_name: String, use_transition: bool = true): 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: @@ -119,6 +148,162 @@ func change_scene(scene_name: String, use_transition: bool = true): 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) + scene_pack_progress.emit(_active_pack_scene_name, downloaded, total) + +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 cacheDirAbsolute := ProjectSettings.globalize_path(PACK_CACHE_DIR) + DirAccess.make_dir_recursive_absolute(cacheDirAbsolute) + var localPath := "%s/%s" % [PACK_CACHE_DIR, fileName] + if FileAccess.file_exists(localPath) 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 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 _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] + # ============ 查询方法 ============ # 获取当前场景名称 diff --git a/_Core/utils/NetworkConfig.gd b/_Core/utils/NetworkConfig.gd index 1aab44a..1051d71 100644 --- a/_Core/utils/NetworkConfig.gd +++ b/_Core/utils/NetworkConfig.gd @@ -17,8 +17,8 @@ const CONFIG_PATHS: Array[String] = [ ] 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 DEFAULT_API_BASE_URL: String = "https://whaletown.novamailio.com/api" +const DEFAULT_WS_URL: String = "wss://whaletown.novamailio.com/game" const API_BASE_URL_ENV_KEY: String = "WHALETOWN_API_BASE_URL" const CHAT_WS_URL_ENV_KEY: String = "WHALETOWN_CHAT_WS_URL" @@ -35,6 +35,10 @@ static func get_api_base_url() -> String: if not env_url.is_empty(): return _trim_trailing_slash(env_url) + var browser_url := _get_browser_same_origin_url("/api") + if not browser_url.is_empty(): + return browser_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(): @@ -51,6 +55,10 @@ static func get_chat_ws_url() -> String: if not env_url.is_empty(): return env_url + var browser_url := _get_browser_same_origin_websocket_url("/game") + if not browser_url.is_empty(): + return browser_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(): @@ -75,6 +83,10 @@ static func get_location_ws_url() -> String: if not game_env_url.is_empty(): return game_env_url + var browser_url := _get_browser_same_origin_websocket_url("/game") + if not browser_url.is_empty(): + return browser_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(): @@ -154,7 +166,7 @@ static func _load_config() -> Dictionary: return {} static func _get_browser_query_value(key: String) -> String: - if OS.get_name() != "Web" or not OS.is_debug_build(): + if OS.get_name() != "Web": return "" if not Engine.has_singleton("JavaScriptBridge"): @@ -168,6 +180,25 @@ static func _get_browser_query_value(key: String) -> String: var value: Variant = js_bridge.eval(script, true) return str(value).strip_edges() +static func _get_browser_same_origin_url(path: String) -> String: + if OS.get_name() != "Web" or not Engine.has_singleton("JavaScriptBridge"): + return "" + var js_bridge: Object = Engine.get_singleton("JavaScriptBridge") + if js_bridge == null: + return "" + var origin: String = str(js_bridge.eval("window.location.origin || ''", true)).strip_edges() + if not origin.begins_with("http://") and not origin.begins_with("https://"): + return "" + return _trim_trailing_slash(origin) + path + +static func _get_browser_same_origin_websocket_url(path: String) -> String: + var http_url := _get_browser_same_origin_url("") + if http_url.begins_with("https://"): + return "wss://" + http_url.substr(8) + path + if http_url.begins_with("http://"): + return "ws://" + http_url.substr(7) + path + return "" + static func _get_browser_url_override(key: String, allowed_schemes: Array[String]) -> String: var value := _get_browser_query_value(key) if value.is_empty(): diff --git a/_Core/utils/WebFilePicker.gd b/_Core/utils/WebFilePicker.gd new file mode 100644 index 0000000..a3213dc --- /dev/null +++ b/_Core/utils/WebFilePicker.gd @@ -0,0 +1,123 @@ +extends RefCounted + +var _javascript_callback: Variant +var _result_callback: Callable +var _callback_name: String = "" + +func open(accept: String, max_bytes: int, result_callback: Callable) -> bool: + if OS.get_name() != "Web" or not Engine.has_singleton("JavaScriptBridge"): + return false + if _result_callback.is_valid(): + return false + + var javascript_bridge: Object = Engine.get_singleton("JavaScriptBridge") + _callback_name = "__whaletownFilePicker%d" % get_instance_id() + _result_callback = result_callback + _javascript_callback = javascript_bridge.create_callback(_on_javascript_result) + var window: Variant = javascript_bridge.get_interface("window") + if window == null: + _reset() + return false + window[_callback_name] = _javascript_callback + + var script := """ +(() => { + const callbackName = %s; + const callback = window[callbackName]; + if (typeof callback !== "function") return false; + + const previous = document.getElementById("whaletown-web-file-picker"); + if (previous) previous.remove(); + + const input = document.createElement("input"); + input.id = "whaletown-web-file-picker"; + input.type = "file"; + input.accept = %s; + input.style.display = "none"; + let finished = false; + + const finish = (status, name, mime, payload) => { + if (finished) return; + finished = true; + callback(status, name || "", mime || "", payload || ""); + input.remove(); + delete window[callbackName]; + }; + + input.addEventListener("cancel", () => finish("cancel", "", "", "")); + input.addEventListener("change", () => { + const file = input.files && input.files[0]; + if (!file) { + finish("cancel", "", "", ""); + return; + } + if (file.size > %d) { + finish("too_large", file.name, file.type, ""); + return; + } + + const reader = new FileReader(); + reader.onerror = () => finish("read_error", file.name, file.type, ""); + reader.onload = () => { + const bytes = new Uint8Array(reader.result); + const chunks = []; + const chunkSize = 0x8000; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + chunkSize))); + } + finish("ok", file.name, file.type, btoa(chunks.join(""))); + }; + reader.readAsArrayBuffer(file); + }); + + document.body.appendChild(input); + input.click(); + return true; +})() +""" % [JSON.stringify(_callback_name), JSON.stringify(accept), max_bytes] + var opened: Variant = javascript_bridge.eval(script, true) + if opened != true: + _reset() + return false + return true + +func _on_javascript_result(arguments: Array) -> void: + var callback := _result_callback + _reset() + if not callback.is_valid(): + return + var status := str(arguments[0]) if arguments.size() > 0 else "read_error" + var file_name := str(arguments[1]) if arguments.size() > 1 else "" + var mime_type := str(arguments[2]) if arguments.size() > 2 else "" + var base64_data := str(arguments[3]) if arguments.size() > 3 else "" + callback.call(status, file_name, mime_type, base64_data) + +func _reset() -> void: + _javascript_callback = null + _result_callback = Callable() + _callback_name = "" + +static func save_base64_file(base64_data: String, file_name: String, prefix: String) -> String: + if base64_data.is_empty(): + return "" + var bytes := Marshalls.base64_to_raw(base64_data) + if bytes.is_empty(): + return "" + + var extension := file_name.get_extension().to_lower() + if not ["png", "jpg", "jpeg", "webp"].has(extension): + extension = "bin" + var root := DirAccess.open("user://") + if root == null: + return "" + if not root.dir_exists("web_uploads") and root.make_dir("web_uploads") != OK: + return "" + + var safe_prefix := prefix.to_lower().replace(" ", "_") + var path := "user://web_uploads/%s_%d.%s" % [safe_prefix, Time.get_ticks_msec(), extension] + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + return "" + file.store_buffer(bytes) + file.close() + return path diff --git a/_Core/utils/WebFilePicker.gd.uid b/_Core/utils/WebFilePicker.gd.uid new file mode 100644 index 0000000..f414d8d --- /dev/null +++ b/_Core/utils/WebFilePicker.gd.uid @@ -0,0 +1 @@ +uid://dqvrn7yv0ap4y diff --git a/export_presets.cfg b/export_presets.cfg index 87cb4e7..7ab6047 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -7,8 +7,8 @@ advanced_options=false dedicated_server=false custom_features="" export_filter="scenes" -export_files=PackedStringArray("res://scenes/ui/AuthScene.tscn", "res://scenes/Maps/square.tscn", "res://scenes/Maps/work_zone.tscn", "res://scenes/Maps/cafe_interior.tscn", "res://scenes/Maps/personal_space.tscn") -include_filter="Config/*.gd,Config/*.json,_Core/*.gd,_Core/managers/*.gd,_Core/systems/*.gd,_Core/utils/*.gd,scenes/Maps/*.gd,scenes/characters/*.gd,scenes/prefabs/items/*.gd,scenes/prefabs/ui/*.gd,scenes/ui/*.gd,scenes/ui/mall/*.gd,assets/audio/ui/*.wav,assets/characters/skins/*.png,assets/maps/personal_space/v1/decor/*.png,assets/ui/auth/generated/*.png,assets/ui/auth/redesign/*.png,assets/ui/auth/registration_choice/redesign/*.png,assets/ui/auth/v1/*.png,assets/ui/mall/branding/*.png,assets/ui/mall/icons/processed/*.png,assets/ui/mall/items/*.png,assets/ui/mall/skins/*_product.png,assets/ui/settings/*.png" +export_files=PackedStringArray("res://scenes/ui/WebBootstrap.tscn") +include_filter="Config/*.gd,Config/*.json,_Core/*.gd,_Core/managers/*.gd,_Core/systems/*.gd,_Core/utils/*.gd,assets/audio/ui/*.wav" exclude_filter="" export_path="build/web/index.html" patches=PackedStringArray() @@ -28,7 +28,7 @@ variant/thread_support=false vram_texture_compression/for_desktop=true vram_texture_compression/for_mobile=false html/export_icon=true -html/custom_html_shell="" +html/custom_html_shell="res://web/progressive_shell.html" html/head_include="" html/canvas_resize_policy=2 html/focus_canvas_on_start=true @@ -73,3 +73,143 @@ texture_format/s3tc_bptc=true texture_format/etc2_astc=false architecture/x86_64=true ssh_remote_deploy/enabled=false + +[preset.2] + +name="Scene Square" +platform="Web" +runnable=false +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="scenes" +export_files=PackedStringArray("res://scenes/Maps/square.tscn") +include_filter="scenes/Maps/*.gd,scenes/characters/*.gd,scenes/characters/*.tscn,scenes/prefabs/items/*.gd,scenes/prefabs/items/*.tscn,scenes/prefabs/ui/*.gd,scenes/prefabs/ui/*.tscn,scenes/ui/*.gd,scenes/ui/*.tscn,scenes/ui/mall/*.gd,scenes/ui/mall/*.tscn,assets/audio/ui/*.wav,assets/characters/*.png,assets/characters/skins/*.png,assets/fonts/*.ttc,assets/ui/*.tres,assets/ui/auth/generated/*.png,assets/ui/datawhale_honor/*.png,assets/ui/mall/branding/*.png,assets/ui/mall/icons/processed/*.png,assets/ui/mall/items/*.png,assets/ui/mall/skins/*_product.png,assets/ui/settings/*.png" +exclude_filter="assets/maps/work_zone/**/*.png,assets/maps/personal_space/**/*.png,assets/maps/cafe/**/*.png" +export_path="build/packs/square.pck" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.2.options] + +variant/extensions_support=false +variant/thread_support=false +vram_texture_compression/for_desktop=true +vram_texture_compression/for_mobile=false + +[preset.3] + +name="Scene Work Zone" +platform="Web" +runnable=false +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="scenes" +export_files=PackedStringArray("res://scenes/Maps/work_zone.tscn") +include_filter="scenes/Maps/*.gd,scenes/characters/*.gd,scenes/characters/*.tscn,scenes/prefabs/items/*.gd,scenes/prefabs/items/*.tscn,scenes/prefabs/ui/*.gd,scenes/prefabs/ui/*.tscn,scenes/ui/*.gd,scenes/ui/*.tscn,scenes/ui/mall/*.gd,scenes/ui/mall/*.tscn,assets/characters/*.png,assets/fonts/*.ttc,assets/ui/*.tres,assets/ui/auth/generated/*.png,assets/ui/datawhale_honor/*.png" +exclude_filter="assets/maps/square/**/*.png,assets/maps/personal_space/**/*.png,assets/maps/cafe/**/*.png" +export_path="build/packs/work_zone.pck" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.3.options] + +variant/extensions_support=false +variant/thread_support=false +vram_texture_compression/for_desktop=true +vram_texture_compression/for_mobile=false + +[preset.4] + +name="Scene Cafe" +platform="Web" +runnable=false +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="scenes" +export_files=PackedStringArray("res://scenes/Maps/cafe_interior.tscn") +include_filter="scenes/Maps/*.gd,scenes/characters/*.gd,scenes/characters/*.tscn,scenes/prefabs/items/*.gd,scenes/prefabs/items/*.tscn,scenes/prefabs/ui/*.gd,scenes/prefabs/ui/*.tscn,scenes/ui/*.gd,scenes/ui/*.tscn,scenes/ui/mall/*.gd,scenes/ui/mall/*.tscn,assets/characters/*.png,assets/fonts/*.ttc,assets/ui/*.tres,assets/ui/auth/generated/*.png,assets/ui/datawhale_honor/*.png" +exclude_filter="assets/maps/square/**/*.png,assets/maps/work_zone/**/*.png,assets/maps/personal_space/**/*.png" +export_path="build/packs/cafe_interior.pck" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.4.options] + +variant/extensions_support=false +variant/thread_support=false +vram_texture_compression/for_desktop=true +vram_texture_compression/for_mobile=false + +[preset.5] + +name="Scene Personal Space" +platform="Web" +runnable=false +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="scenes" +export_files=PackedStringArray("res://scenes/Maps/personal_space.tscn") +include_filter="scenes/Maps/*.gd,scenes/characters/*.gd,scenes/characters/*.tscn,scenes/prefabs/items/*.gd,scenes/prefabs/items/*.tscn,scenes/prefabs/ui/*.gd,scenes/prefabs/ui/*.tscn,scenes/ui/*.gd,scenes/ui/*.tscn,scenes/ui/mall/*.gd,scenes/ui/mall/*.tscn,assets/characters/*.png,assets/fonts/*.ttc,assets/ui/*.tres,assets/ui/auth/generated/*.png,assets/ui/datawhale_honor/*.png,assets/maps/personal_space/v1/decor/*.png" +exclude_filter="assets/maps/square/**/*.png,assets/maps/work_zone/**/*.png,assets/maps/cafe/**/*.png" +export_path="build/packs/personal_space.pck" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.5.options] + +variant/extensions_support=false +variant/thread_support=false +vram_texture_compression/for_desktop=true +vram_texture_compression/for_mobile=false + +[preset.6] + +name="Scene Auth" +platform="Web" +runnable=false +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="scenes" +export_files=PackedStringArray("res://scenes/ui/AuthScene.tscn") +include_filter="scenes/ui/AuthScene.gd,assets/audio/ui/*.wav,assets/characters/player_pixel_spritesheet.png,assets/characters/skins/*.png,assets/fonts/*.ttc,assets/ui/auth/generated/*.png,assets/ui/auth/redesign/*.png,assets/ui/auth/registration_choice/redesign/*.png,assets/ui/auth/v1/*.png" +exclude_filter="assets/maps/**/*.png,assets/ui/mall/**/*.png,assets/ui/settings/*.png" +export_path="build/packs/auth.pck" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.6.options] + +variant/extensions_support=false +variant/thread_support=false +vram_texture_compression/for_desktop=true +vram_texture_compression/for_mobile=false diff --git a/project.godot b/project.godot index de0d3d1..032e133 100644 --- a/project.godot +++ b/project.godot @@ -15,7 +15,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true [application] config/name="WhaleTown V2" -run/main_scene="res://scenes/ui/AuthScene.tscn" +run/main_scene="res://scenes/ui/WebBootstrap.tscn" config/features=PackedStringArray("4.6", "GL Compatibility") config/icon="res://icon.svg" diff --git a/scenes/ui/AuthScene.gd b/scenes/ui/AuthScene.gd index b130238..852aa7f 100644 --- a/scenes/ui/AuthScene.gd +++ b/scenes/ui/AuthScene.gd @@ -26,12 +26,14 @@ const REGISTRATION_CHOICE_REFERENCE_UPLOAD_BOX_PATH: String = REGISTRATION_CHOIC const REGISTRATION_CHOICE_IMAGE_PLACEHOLDER_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/image_placeholder_box.png" const UI_FONT_PATH: String = "res://assets/fonts/msyh.ttc" const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd") +const WebFilePicker = preload("res://_Core/utils/WebFilePicker.gd") const SKIN_GENERATION_CREATE_ENDPOINT: String = "/api/skin-generation/jobs" const SKIN_GENERATION_POLL_ENDPOINT_TEMPLATE: String = "/api/skin-generation/jobs/%s" const SKIN_GENERATION_POLL_INTERVAL: float = 2.0 const SKIN_GENERATION_REQUEST_TIMEOUT: float = 24.0 const AVATAR_NATIVE_FILE_FILTERS: Array[String] = ["*.png,*.jpg,*.jpeg,*.webp"] -const SKIN_NATIVE_FILE_FILTERS: Array[String] = ["*.png,*.jpg,*.jpeg,*.webp"] +const SKIN_NATIVE_FILE_FILTERS: Array[String] = ["*.png"] +const WEB_FILE_MAX_BYTES: int = 8 * 1024 * 1024 const TEXT_COLOR: Color = Color(0.03, 0.12, 0.23, 1.0) const MUTED_COLOR: Color = Color(0.26, 0.34, 0.44, 1.0) const ACCENT_COLOR: Color = Color(0.10, 0.38, 0.68, 1.0) @@ -91,7 +93,7 @@ var _scene_manager: Node var _appearance_manager: Node var _ui_font: FontFile var _brand_font: SystemFont -var _choice_font: SystemFont +var _choice_font: Font var _resuming_cached_session: bool = false var _cached_resume_start_generation: int = 0 var _skin_generation_create_request: HTTPRequest @@ -114,13 +116,18 @@ var _awaiting_registration_skin_choice: bool = false var _pending_profile_sync_after_auth: bool = false var _deferred_enter_square_after_profile_sync: bool = false var _is_sending_register_code: bool = false +var _web_file_picker: RefCounted = WebFilePicker.new() func _enter_tree() -> void: _ui_font = load(UI_FONT_PATH) as FontFile _brand_font = SystemFont.new() _brand_font.font_names = PackedStringArray(["Arial Rounded MT Bold", "Avenir Next", "Trebuchet MS", "Arial"]) - _choice_font = SystemFont.new() - _choice_font.font_names = PackedStringArray(["STHeiti Medium", "Hiragino Sans GB", "PingFang SC", "Microsoft YaHei"]) + if OS.get_name() == "Web": + _choice_font = _ui_font + else: + var systemFont := SystemFont.new() + systemFont.font_names = PackedStringArray(["STHeiti Medium", "Hiragino Sans GB", "PingFang SC", "Microsoft YaHei"]) + _choice_font = systemFont _build_v1_auth_ui() func _build_v1_auth_ui() -> void: @@ -692,6 +699,12 @@ func _ready() -> void: if _auth_manager != null and bool(_auth_manager.call("is_authenticated")) and _should_auto_resume_cached_session(): _resume_cached_session() + elif _auth_manager != null and _auth_manager.has_method("consume_browser_bootstrap_kind"): + var bootstrapKind := str(_auth_manager.call("consume_browser_bootstrap_kind")) + if not bootstrapKind.is_empty(): + call_deferred("_complete_browser_bootstrap", bootstrapKind) + else: + login_identifier_input.grab_focus() else: login_identifier_input.grab_focus() @@ -725,6 +738,17 @@ func _connect_signals() -> void: _auth_manager.connect("profile_update_succeeded", _on_profile_update_succeeded) _auth_manager.connect("profile_update_failed", _on_profile_update_failed) _auth_manager.connect("auth_state_changed", _on_auth_state_changed) + if _auth_manager.has_signal("browser_bootstrap_received"): + _auth_manager.connect("browser_bootstrap_received", _complete_browser_bootstrap) + +func _complete_browser_bootstrap(kind: String) -> void: + if _is_submitting: + return + _set_submitting(true, "正在准备小镇...") + if kind == "register": + _on_register_succeeded(_auth_manager.call("get_current_user")) + return + _on_login_succeeded(_auth_manager.call("get_current_user")) func _setup_skin_generation_requests() -> void: _skin_generation_create_request = HTTPRequest.new() @@ -1626,6 +1650,9 @@ func _hide_skin_workshop() -> void: skin_workshop_overlay.hide() func _on_workshop_source_pressed() -> void: + if _open_web_file_picker("workshop"): + _set_workshop_generation_status("请选择角色参考图片") + return var err := DisplayServer.file_dialog_show( "选择角色参考图片", _default_picker_dir(), @@ -1680,6 +1707,9 @@ func _update_skin_library_buttons() -> void: _apply_library_arrow_style(skin_library_next_button, true) func _on_upload_avatar_pressed() -> void: + if _open_web_file_picker("avatar"): + status_label.text = "请选择头像图片" + return var err := DisplayServer.file_dialog_show( "选择头像图片", _default_picker_dir(), @@ -1698,6 +1728,11 @@ func _on_upload_avatar_pressed() -> void: status_label.text = "系统文件选择器未打开:%s / %s" % [DisplayServer.get_name(), error_string(err)] func _on_upload_skin_pressed() -> void: + if _open_web_file_picker("skin"): + status_label.text = "请选择8x4角色皮肤PNG" + if is_instance_valid(workshop_status_label): + workshop_status_label.text = "等待选择8x4透明PNG" + return var err := DisplayServer.file_dialog_show( "选择8x4角色皮肤PNG", _default_picker_dir(), @@ -1719,6 +1754,46 @@ func _on_upload_skin_pressed() -> void: return status_label.text = "系统文件选择器未打开:%s / %s" % [DisplayServer.get_name(), error_string(err)] +func _open_web_file_picker(kind: String) -> bool: + if OS.get_name() != "Web" or _web_file_picker == null: + return false + var accept := ".png" if kind == "skin" else ".png,.jpg,.jpeg,.webp" + return bool(_web_file_picker.call("open", accept, WEB_FILE_MAX_BYTES, _on_web_file_selected.bind(kind))) + +func _on_web_file_selected(status: String, fileName: String, _mimeType: String, base64Data: String, kind: String) -> void: + if status == "cancel": + if kind == "workshop": + _set_workshop_generation_status("已取消角色参考图选择") + else: + status_label.text = "已取消图片选择" + return + if status == "too_large": + var message := "图片不能超过8MB" + if kind == "workshop": + _set_workshop_generation_status(message) + else: + status_label.text = message + return + if status != "ok": + var message := "浏览器读取图片失败" + if kind == "workshop": + _set_workshop_generation_status(message) + else: + status_label.text = message + return + + var path := WebFilePicker.save_base64_file(base64Data, fileName, kind) + if path.is_empty(): + status_label.text = "图片暂存失败" + return + match kind: + "avatar": + _on_native_avatar_file_selected(true, PackedStringArray([path]), 0) + "skin": + _on_native_skin_file_selected(true, PackedStringArray([path]), 0) + "workshop": + _on_native_workshop_source_file_selected(true, PackedStringArray([path]), 0) + func _open_macos_file_dialog(title: String, callbackMethod: String) -> bool: if OS.get_name() != "macOS": return false diff --git a/scenes/ui/SettingsPanel.gd b/scenes/ui/SettingsPanel.gd index 9078531..d54db66 100644 --- a/scenes/ui/SettingsPanel.gd +++ b/scenes/ui/SettingsPanel.gd @@ -14,6 +14,7 @@ const ICON_SCRIPT: Script = preload("res://scenes/ui/SettingsPanelIcon.gd") const TOGGLE_SCRIPT: Script = preload("res://scenes/ui/SettingsToggle.gd") const SLIDER_SCRIPT: Script = preload("res://scenes/ui/SettingsSlider.gd") const BADGE_TEXTURE_PATH: String = "res://assets/ui/settings/settings_whale_badge_asset_v1.png" +const WebFilePicker = preload("res://_Core/utils/WebFilePicker.gd") const TEXT_COLOR: Color = Color(0.188, 0.294, 0.424) const MUTED_COLOR: Color = Color(0.560, 0.639, 0.733) @@ -22,6 +23,7 @@ const SOFT_BLUE: Color = Color(0.918, 0.961, 0.992, 0.92) const DANGER_COLOR: Color = Color(0.894, 0.392, 0.357) const MOVEMENT_ACTIONS: Array[String] = ["move_left", "move_right", "move_up", "move_down"] const AVATAR_NATIVE_FILE_FILTERS: Array[String] = ["*.png,*.jpg,*.jpeg,*.webp"] +const WEB_FILE_MAX_BYTES: int = 8 * 1024 * 1024 const CATEGORIES: Array[Dictionary] = [ {"id": "basic", "label": "基础", "icon": "basic"}, @@ -71,6 +73,7 @@ var _savedSettings: Dictionary = DEFAULT_SETTINGS.duplicate(true) var _isOpen: bool = false var _transitionTween: Tween var _avatarUploadPending: bool = false +var _webFilePicker: RefCounted = WebFilePicker.new() func _ready() -> void: visible = false @@ -976,6 +979,9 @@ func _on_reconnect_pressed() -> void: _statusLabel.text = "已请求重新连接聊天服务" func _on_upload_avatar_pressed() -> void: + if _try_open_web_avatar_file_picker(): + _set_account_status("请选择头像图片") + return if _open_macos_avatar_file_dialog(): return if _try_open_native_avatar_file_picker(): @@ -983,6 +989,27 @@ func _on_upload_avatar_pressed() -> void: return _set_account_status("系统文件选择器暂时不可用") +func _try_open_web_avatar_file_picker() -> bool: + if OS.get_name() != "Web" or _webFilePicker == null: + return false + return bool(_webFilePicker.call("open", ".png,.jpg,.jpeg,.webp", WEB_FILE_MAX_BYTES, _on_web_avatar_file_selected)) + +func _on_web_avatar_file_selected(status: String, fileName: String, _mimeType: String, base64Data: String) -> void: + if status == "cancel": + _set_account_status("已取消头像选择") + return + if status == "too_large": + _set_account_status("头像图片不能超过8MB") + return + if status != "ok": + _set_account_status("浏览器读取头像失败") + return + var path := WebFilePicker.save_base64_file(base64Data, fileName, "avatar") + if path.is_empty(): + _set_account_status("头像图片暂存失败") + return + _on_avatar_file_selected(path) + func _try_open_native_avatar_file_picker() -> bool: if DisplayServer.get_name() == "headless": return false diff --git a/scenes/ui/WebBootstrap.gd b/scenes/ui/WebBootstrap.gd new file mode 100644 index 0000000..825b934 --- /dev/null +++ b/scenes/ui/WebBootstrap.gd @@ -0,0 +1,40 @@ +extends Control + +var _auth_manager: Node +var _scene_manager: Node +var _handled: bool = false + +func _ready() -> void: + _auth_manager = get_node_or_null("/root/AuthManager") + _scene_manager = get_node_or_null("/root/SceneManager") + if OS.get_name() != "Web": + _scene_manager.call_deferred("change_scene", "auth", false) + return + if _auth_manager == null: + return + if _auth_manager.has_signal("browser_bootstrap_received"): + _auth_manager.connect("browser_bootstrap_received", _on_browser_bootstrap_received) + var kind := str(_auth_manager.call("get_browser_bootstrap_kind")) if _auth_manager.has_method("get_browser_bootstrap_kind") else "" + if not kind.is_empty(): + call_deferred("_on_browser_bootstrap_received", kind) + +func _on_browser_bootstrap_received(kind: String) -> void: + if _handled or _scene_manager == null: + return + _handled = true + if kind == "register": + _scene_manager.call("change_scene", "auth", false) + return + if _auth_manager.has_method("consume_browser_bootstrap_kind"): + _auth_manager.call("consume_browser_bootstrap_kind") + var appearanceManager := get_node_or_null("/root/AppearanceManager") + if appearanceManager != null and appearanceManager.has_method("apply_account_profile"): + appearanceManager.call("apply_account_profile", _auth_manager.call("get_current_profile")) + var chatManager := get_node_or_null("/root/ChatManager") + var token := str(_auth_manager.call("get_access_token")) + if chatManager != null and not token.is_empty(): + chatManager.call("set_game_token", token) + chatManager.call("connect_to_chat_server") + if _auth_manager.has_method("fetch_profile"): + _auth_manager.call("fetch_profile") + _scene_manager.call("change_scene", "square", false) diff --git a/scenes/ui/WebBootstrap.gd.uid b/scenes/ui/WebBootstrap.gd.uid new file mode 100644 index 0000000..f47b5ac --- /dev/null +++ b/scenes/ui/WebBootstrap.gd.uid @@ -0,0 +1 @@ +uid://22fxlhvp012i diff --git a/scenes/ui/WebBootstrap.tscn b/scenes/ui/WebBootstrap.tscn new file mode 100644 index 0000000..02ef130 --- /dev/null +++ b/scenes/ui/WebBootstrap.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scenes/ui/WebBootstrap.gd" id="1_bootstrap"] + +[node name="WebBootstrap" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_bootstrap") diff --git a/scripts/build_progressive_web.sh b/scripts/build_progressive_web.sh new file mode 100755 index 0000000..5f3550e --- /dev/null +++ b/scripts/build_progressive_web.sh @@ -0,0 +1,49 @@ +#!/bin/sh +set -eu + +PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +GODOT_BIN=${GODOT_BIN:-/Applications/Godot.app/Contents/MacOS/Godot} +WEB_DIR="$PROJECT_DIR/build/web" +PACK_DIR="$WEB_DIR/packs" + +if [ ! -x "$GODOT_BIN" ]; then + echo "Godot executable not found: $GODOT_BIN" >&2 + exit 1 +fi + +mkdir -p "$WEB_DIR" "$PACK_DIR" +find "$PACK_DIR" -maxdepth 1 -type f \( -name '*.pck' -o -name 'manifest.json' \) -delete + +"$GODOT_BIN" --headless --path "$PROJECT_DIR" --export-release "Web" "$WEB_DIR/index.html" + +build_pack() { + pack_key=$1 + preset=$2 + temp_path="$PACK_DIR/$pack_key.pck" + "$GODOT_BIN" --headless --path "$PROJECT_DIR" --export-pack "$preset" "$temp_path" >&2 + hash=$(shasum -a 256 "$temp_path" | awk '{print substr($1, 1, 12)}') + file_name="$pack_key-$hash.pck" + mv "$temp_path" "$PACK_DIR/$file_name" + size=$(stat -f '%z' "$PACK_DIR/$file_name") + jq -n --arg file "$file_name" --argjson size "$size" '{file: $file, size: $size}' +} + +square=$(build_pack "square" "Scene Square") +work_zone=$(build_pack "work_zone" "Scene Work Zone") +cafe=$(build_pack "cafe_interior" "Scene Cafe") +personal_space=$(build_pack "personal_space" "Scene Personal Space") +auth=$(build_pack "auth" "Scene Auth") + +jq -n \ + --argjson square "$square" \ + --argjson work_zone "$work_zone" \ + --argjson cafe_interior "$cafe" \ + --argjson personal_space "$personal_space" \ + --argjson auth "$auth" \ + '{version: 1, packs: {auth: $auth, square: $square, work_zone: $work_zone, cafe_interior: $cafe_interior, personal_space: $personal_space}}' \ + > "$PACK_DIR/manifest.json" + +cp "$PROJECT_DIR/web/auth-background.jpg" "$WEB_DIR/auth-background.jpg" + +echo "Progressive web build created in $WEB_DIR" +du -h "$WEB_DIR/index.pck" "$WEB_DIR/index.wasm" "$WEB_DIR/auth-background.jpg" "$PACK_DIR"/*.pck diff --git a/web/auth-background.jpg b/web/auth-background.jpg new file mode 100644 index 0000000..dbeea0d Binary files /dev/null and b/web/auth-background.jpg differ diff --git a/web/auth-background.jpg.import b/web/auth-background.jpg.import new file mode 100644 index 0000000..bd42080 --- /dev/null +++ b/web/auth-background.jpg.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dtn7hj6yayxcy" +path="res://.godot/imported/auth-background.jpg-05cdcc917d4b33aab75636638294862f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://web/auth-background.jpg" +dest_files=["res://.godot/imported/auth-background.jpg-05cdcc917d4b33aab75636638294862f.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 diff --git a/web/progressive_shell.html b/web/progressive_shell.html new file mode 100644 index 0000000..18a8d88 --- /dev/null +++ b/web/progressive_shell.html @@ -0,0 +1,228 @@ + + + + + + $GODOT_PROJECT_NAME + $GODOT_HEAD_INCLUDE + + + + 当前浏览器不支持游戏画布。 +
+
+
W

鲸鱼小镇

+
+ + +
+
+
+
+ +
+ +

主程序正在后台加载 0%

+ + +
+
+ + + + +