11 Commits

29 changed files with 1096 additions and 30 deletions

View File

@@ -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
},

View File

@@ -376,7 +376,7 @@ func has_custom_skin() -> bool:
return _customSkinActive and _customSkinTexture != null
func get_custom_avatar_texture() -> Texture2D:
if has_custom_avatar():
if _customAvatarActive and _customAvatarTexture != null:
return _customAvatarTexture
return _accountAvatarTexture

View File

@@ -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", "玩家资料保存失败")))

View File

@@ -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,26 @@ 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",
}
# 场景路径映射表
# 将场景名称映射到实际的文件路径
@@ -101,6 +123,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 +149,200 @@ 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)
_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]
# ============ 查询方法 ============
# 获取当前场景名称

View File

@@ -0,0 +1,26 @@
extends Node
# Enforce a Chinese-capable font for every UI control, including controls
# constructed dynamically after their scene has loaded.
const UI_FONT: FontFile = preload("res://assets/fonts/msyh-web.ttf")
func _enter_tree() -> void:
get_tree().node_added.connect(_on_node_added)
_apply_font_recursively(get_tree().root)
func _exit_tree() -> void:
var node_added_callback := Callable(self, "_on_node_added")
if get_tree().node_added.is_connected(node_added_callback):
get_tree().node_added.disconnect(node_added_callback)
func _on_node_added(node: Node) -> void:
_apply_font_recursively(node)
func _apply_font_recursively(node: Node) -> void:
if node is Control:
var control := node as Control
control.add_theme_font_override(&"font", UI_FONT)
if control is RichTextLabel:
control.add_theme_font_override(&"normal_font", UI_FONT)
for child in node.get_children():
_apply_font_recursively(child)

View File

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

View File

@@ -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():

View File

@@ -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

View File

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

BIN
assets/fonts/msyh-web.ttf Normal file

Binary file not shown.

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

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

View File

@@ -1,6 +1,6 @@
[gd_resource type="Theme" format=3 uid="uid://brk6ca2npglqc"]
[ext_resource type="FontFile" uid="uid://ce7ujbeobblyr" path="res://assets/fonts/msyh.ttc" id="1_font"]
[ext_resource type="FontFile" uid="uid://cy7uxq32lpgou" path="res://assets/fonts/msyh-web.ttf" id="1_font"]
[resource]
resource_local_to_scene = true

View File

@@ -6,8 +6,9 @@ runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
export_filter="scenes"
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,assets/fonts/msyh-web.ttf"
exclude_filter=""
export_path="build/web/index.html"
patches=PackedStringArray()
@@ -27,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
@@ -72,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/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/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/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/ui/*.tres,assets/ui/auth/generated/*.png,assets/ui/datawhale_honor/*.png,assets/maps/personal_space/v1/base/personal_room_25d_sidewalls_wider_not_longer_v1.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/characters/player_pixel_spritesheet.png,assets/characters/skins/*.png,assets/ui/auth/v1/bg_auth_scene.png,assets/ui/auth/redesign/whaletown_login_panel_hd.png,assets/ui/auth/redesign/whaletown_register_panel_compact_hd.png,assets/ui/auth/registration_choice/redesign/main_panel.png,assets/ui/auth/registration_choice/redesign/top_whale.png,assets/ui/auth/registration_choice/redesign/card_base.png,assets/ui/auth/registration_choice/redesign/card_selected.png,assets/ui/auth/registration_choice/redesign/header_pill.png,assets/ui/auth/registration_choice/redesign/primary_button.png,assets/ui/auth/registration_choice/redesign/footer_bar.png,assets/ui/auth/registration_choice/redesign/skin_slot_base.png,assets/ui/auth/registration_choice/redesign/skin_slot_selected.png,assets/ui/auth/registration_choice/redesign/upload_file_box.png,assets/ui/auth/registration_choice/redesign/spritesheet_grid_box.png,assets/ui/auth/registration_choice/redesign/reference_upload_box.png,assets/ui/auth/registration_choice/redesign/image_placeholder_box.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

View File

@@ -15,12 +15,13 @@ 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"
[autoload]
UIFontManager="*res://_Core/managers/UIFontManager.gd"
SceneManager="*res://_Core/managers/SceneManager.gd"
EventSystem="*res://_Core/systems/EventSystem.gd"
ApiClient="*res://_Core/managers/ApiClient.gd"
@@ -40,6 +41,10 @@ window/size/viewport_height=1440
window/stretch/mode="canvas_items"
window/stretch/aspect="expand"
[gui]
theme/custom_font="res://assets/fonts/msyh-web.ttf"
[input]
move_left={

View File

@@ -1,7 +1,7 @@
[gd_scene format=3 uid="uid://c2flhkqk5icab"]
[ext_resource type="Script" uid="uid://ccesagjisqodl" path="res://scenes/Maps/CafeInterior.gd" id="1_script"]
[ext_resource type="Texture2D" path="res://assets/maps/cafe/cafe_service_hall_base_v1.png" id="2_map"]
[ext_resource type="Texture2D" path="res://assets/maps/cafe/whale_cafe_large_service_hall_base_v4.png" id="2_map"]
[ext_resource type="PackedScene" path="res://scenes/characters/player.tscn" id="3_player"]
[ext_resource type="PackedScene" path="res://scenes/ui/ChatUI.tscn" id="4_chatui"]
[ext_resource type="PackedScene" path="res://scenes/ui/PlayerHud.tscn" id="5_playerhud"]

View File

@@ -24,14 +24,16 @@ const REGISTRATION_CHOICE_UPLOAD_FILE_BOX_PATH: String = REGISTRATION_CHOICE_ASS
const REGISTRATION_CHOICE_SPRITESHEET_GRID_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/spritesheet_grid_box.png"
const REGISTRATION_CHOICE_REFERENCE_UPLOAD_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/reference_upload_box.png"
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 UI_FONT_PATH: String = "res://assets/fonts/msyh-web.ttf"
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:
@@ -689,9 +696,16 @@ func _ready() -> void:
_connect_signals()
_refresh_appearance_ui()
_show_login()
_notify_web_shell_ready()
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 +739,23 @@ 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 _notify_web_shell_ready() -> void:
if OS.get_name() != "Web" or not Engine.has_singleton("JavaScriptBridge"):
return
var bridge: Object = Engine.get_singleton("JavaScriptBridge")
bridge.eval("window.whaletownGodotReady && window.whaletownGodotReady()", true)
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 +1657,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 +1714,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 +1735,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 +1761,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

View File

@@ -1,7 +1,8 @@
[gd_scene load_steps=15 format=3 uid="uid://bv7k2nan4xj8q"]
[gd_scene load_steps=16 format=3 uid="uid://bv7k2nan4xj8q"]
[ext_resource type="Script" path="res://scenes/ui/ChatUI.gd" id="1"]
[ext_resource type="Script" path="res://scenes/ui/BubbleSendButton.gd" id="2"]
[ext_resource type="Theme" path="res://assets/ui/world_text_theme.tres" id="3"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_panel"]
bg_color = Color(1, 0.996, 0.984, 0.97)
@@ -145,6 +146,7 @@ anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
theme = ExtResource("3")
script = ExtResource("1")
[node name="ChatPanel" type="PanelContainer" parent="."]

View File

@@ -29,7 +29,7 @@ const TEX_TAB_LABEL_PRODUCTIVE = preload("res://assets/ui/datawhale_honor/honor_
const TEX_TAB_LABEL_SOCIAL = preload("res://assets/ui/datawhale_honor/honor_tab_label_social.png")
const TEX_TAB_LABEL_RISING = preload("res://assets/ui/datawhale_honor/honor_tab_label_rising.png")
const TEX_TAB_LABEL_COMPREHENSIVE = preload("res://assets/ui/datawhale_honor/honor_tab_label_comprehensive.png")
const FONT_UI = preload("res://assets/fonts/msyh.ttc")
const FONT_UI = preload("res://assets/fonts/msyh-web.ttf")
const TEX_AVATAR_FALLBACK_1 = preload("res://assets/ui/auth/generated/auth_character_preview_front.png")
const TEX_AVATAR_FALLBACK_2 = preload("res://assets/ui/auth/generated/auth_character_preview_left.png")
const TEX_AVATAR_FALLBACK_3 = preload("res://assets/ui/auth/generated/auth_character_preview_right.png")
@@ -1126,8 +1126,8 @@ func _podiumMetaLine(data: Dictionary) -> String:
if location.is_empty():
return domain
if domain.is_empty():
return "📍 %s" % location
return "📍 %s · %s" % [location, domain]
return location
return "%s · %s" % [location, domain]
func _podiumTags(data: Dictionary) -> Array[String]:
var tags: Array[String] = []
@@ -1165,7 +1165,13 @@ func _avatarPrimaryTextureFromData(data: Dictionary) -> Texture2D:
return null
func _avatarUrlFromData(data: Dictionary) -> String:
return _firstNonEmptyString(data, ["avatarUrl", "avatar_url", "avatar", "avatarURL"])
var avatarUrl := _firstNonEmptyString(data, ["avatarUrl", "avatar_url", "avatar", "avatarURL"])
if not avatarUrl.begins_with("/"):
return avatarUrl
var apiBaseUrl := NetworkConfig.get_api_base_url()
if avatarUrl.begins_with("/api/") and apiBaseUrl.ends_with("/api"):
return apiBaseUrl.trim_suffix("/api") + avatarUrl
return apiBaseUrl + avatarUrl
func _firstNonEmptyString(data: Dictionary, keys: Array[String]) -> String:
for key in keys:

View File

@@ -1,6 +1,7 @@
[gd_scene load_steps=2 format=3]
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://scenes/ui/PlayerHud.gd" id="1"]
[ext_resource type="Theme" path="res://assets/ui/world_text_theme.tres" id="2_theme"]
[node name="PlayerHud" type="Control"]
layout_mode = 3
@@ -10,4 +11,5 @@ anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
theme = ExtResource("2_theme")
script = ExtResource("1")

View File

@@ -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

View File

@@ -0,0 +1,9 @@
extends Control
var _scene_manager: Node
func _ready() -> void:
_scene_manager = get_node_or_null("/root/SceneManager")
if _scene_manager == null:
return
_scene_manager.call_deferred("change_scene", "auth", false)

View File

@@ -0,0 +1 @@
uid://22fxlhvp012i

View File

@@ -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")

View File

@@ -0,0 +1,57 @@
#!/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
find "$WEB_DIR" -maxdepth 1 -type f -name 'index-*.pck' -delete
"$GODOT_BIN" --headless --path "$PROJECT_DIR" --export-release "Web" "$WEB_DIR/index.html"
core_pack_path="$WEB_DIR/index.pck"
core_hash=$(shasum -a 256 "$core_pack_path" | awk '{print substr($1, 1, 12)}')
core_pack_name="index-$core_hash.pck"
core_pack_size=$(stat -f '%z' "$core_pack_path")
mv "$core_pack_path" "$WEB_DIR/$core_pack_name"
perl -0pi -e "s/__WHALETOWN_CORE_PACK__/$core_pack_name/g; s/__WHALETOWN_CORE_PACK_SIZE__/$core_pack_size/g" "$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/$core_pack_name" "$WEB_DIR/index.wasm" "$WEB_DIR/auth-background.jpg" "$PACK_DIR"/*.pck

BIN
web/auth-background.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

View File

@@ -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

View File

@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>$GODOT_PROJECT_NAME</title>
$GODOT_HEAD_INCLUDE
<style>
:root { color-scheme: light; font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; }
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #b9d9df; }
#canvas { position: fixed; inset: 0; display: block; width: 100%; height: 100%; border: 0; background: #0a2437; }
#loading-shell { position: fixed; inset: 0; z-index: 10; display: grid; place-items: end center; padding: 0 24px max(9vh, 34px); background: #b9d9df url("auth-background.jpg") center / cover no-repeat; transition: opacity .18s ease; }
#loading-shell::before { content: ""; position: absolute; inset: 0; background: rgba(222, 242, 244, .22); }
.loading { position: relative; width: min(420px, 88vw); color: #174f72; text-align: center; text-shadow: 0 1px 0 rgba(255, 255, 255, .9); }
.status { min-height: 24px; margin: 0 0 10px; font-size: 15px; font-weight: 700; line-height: 1.5; letter-spacing: 0; }
.track { height: 7px; overflow: hidden; border: 1px solid rgba(23, 79, 114, .28); border-radius: 4px; background: rgba(255, 255, 255, .78); box-shadow: 0 2px 8px rgba(15, 65, 89, .16); }
.bar { width: 0; height: 100%; background: #2b86b6; transition: width .2s ease; }
.failure { display: none; margin-top: 10px; color: #8f2634; font-size: 13px; line-height: 1.5; }
#loading-shell.done { opacity: 0; pointer-events: none; }
</style>
</head>
<body>
<canvas id="canvas">当前浏览器不支持游戏画布。</canvas>
<div id="loading-shell" aria-live="polite">
<div class="loading">
<p class="status" id="status">正在加载小镇 0%</p>
<div class="track" aria-hidden="true"><div class="bar" id="bar"></div></div>
<div class="failure" id="failure" role="alert"></div>
</div>
</div>
<noscript>当前浏览器未启用 JavaScript无法运行鲸鱼小镇。</noscript>
<script src="$GODOT_URL"></script>
<script>
const GODOT_CONFIG = $GODOT_CONFIG;
const GODOT_THREADS_ENABLED = $GODOT_THREADS_ENABLED;
const CORE_PACK_URL = '__WHALETOWN_CORE_PACK__';
const CORE_PACK_SIZE = __WHALETOWN_CORE_PACK_SIZE__;
GODOT_CONFIG.mainPack = CORE_PACK_URL;
GODOT_CONFIG.fileSizes[CORE_PACK_URL] = CORE_PACK_SIZE;
const shell = document.getElementById('loading-shell');
const status = document.getElementById('status');
const bar = document.getElementById('bar');
const failure = document.getElementById('failure');
const engine = new Engine(GODOT_CONFIG);
window.whaletownGodotReady = function () {
bar.style.width = '100%';
shell.classList.add('done');
window.setTimeout(function () { shell.style.display = 'none'; }, 220);
document.getElementById('canvas').focus();
};
window.whaletownPackProgress = function (downloaded, total) {
const downloadedMb = downloaded / 1048576;
if (total > 0) {
const totalMb = total / 1048576;
const percent = Math.min(100, Math.round(downloaded / total * 100));
bar.style.width = percent + '%';
status.textContent = '正在加载完整登录界面 ' + downloadedMb.toFixed(1) + ' / ' + totalMb.toFixed(1) + ' MB';
} else {
status.textContent = '正在加载完整登录界面 ' + downloadedMb.toFixed(1) + ' MB';
}
};
const missing = Engine.getMissingFeatures({ threads: GODOT_THREADS_ENABLED });
if (missing.length) {
failure.style.display = 'block';
failure.textContent = '当前浏览器缺少运行游戏所需能力:' + missing.join('、');
} else {
engine.startGame({
onProgress(current, total) {
if (total <= 0) return;
const percent = Math.min(100, Math.round(current / total * 100));
bar.style.width = percent + '%';
status.textContent = '正在加载小镇 ' + percent + '%';
},
}).then(function () {
status.textContent = '正在加载完整登录界面...';
bar.style.width = '0';
}).catch(function (error) {
failure.style.display = 'block';
failure.textContent = error && error.message ? error.message : '主程序加载失败,请刷新重试';
});
}
</script>
</body>
</html>