fix: restore full Godot auth in progressive web build

This commit is contained in:
2026-08-01 21:50:22 +08:00
parent 7a07f52e2c
commit 52c5a76a01
5 changed files with 102 additions and 220 deletions

View File

@@ -47,6 +47,7 @@ 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"
@@ -160,8 +161,21 @@ func _process(_delta: float) -> void:
_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)):
@@ -181,10 +195,11 @@ func _ensure_scene_pack(scene_name: String) -> bool:
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 FileAccess.file_exists(localPath) and ProjectSettings.load_resource_pack(localPath, true):
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
@@ -194,6 +209,10 @@ func _ensure_scene_pack(scene_name: String) -> bool:
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, "场景资源损坏,请重新进入")
@@ -202,6 +221,26 @@ func _ensure_scene_pack(scene_name: String) -> bool:
_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

View File

@@ -696,6 +696,7 @@ 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()
@@ -741,6 +742,12 @@ func _connect_signals() -> void:
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

View File

@@ -1,40 +1,9 @@
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)
if _scene_manager == null:
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)
_scene_manager.call_deferred("change_scene", "auth", false)

View File

@@ -13,9 +13,17 @@ 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
@@ -46,4 +54,4 @@ jq -n \
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
du -h "$WEB_DIR/$core_pack_name" "$WEB_DIR/index.wasm" "$WEB_DIR/auth-background.jpg" "$PACK_DIR"/*.pck

View File

@@ -9,197 +9,58 @@
: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; }
button, input { font: inherit; letter-spacing: 0; }
#canvas { position: fixed; inset: 0; display: block; width: 100%; height: 100%; border: 0; background: #0a2437; }
#auth-shell { position: fixed; inset: 0; display: grid; place-items: center; padding: 24px; background: #b9d9df url("auth-background.jpg") center / cover no-repeat; }
#auth-shell::before { content: ""; position: absolute; inset: 0; background: rgba(222, 242, 244, .38); }
.auth-panel { position: relative; width: min(440px, 100%); max-height: calc(100vh - 48px); overflow: auto; padding: 30px 32px 24px; border: 2px solid rgba(38, 112, 151, .68); border-radius: 8px; background: rgba(249, 253, 252, .96); box-shadow: 0 14px 38px rgba(15, 65, 89, .22); }
.brand { display: flex; align-items: center; justify-content: center; gap: 12px; margin-bottom: 22px; color: #174f72; }
.brand-mark { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 50%; background: #4c9ac5; color: white; font-size: 27px; font-weight: 800; }
.brand h1 { margin: 0; font-size: 26px; line-height: 1.1; letter-spacing: 0; }
.tabs { display: grid; grid-template-columns: 1fr 1fr; margin-bottom: 22px; border-bottom: 1px solid #c8dbe2; }
.tab { min-height: 42px; border: 0; border-bottom: 3px solid transparent; background: transparent; color: #647b86; cursor: pointer; }
.tab[aria-selected="true"] { border-bottom-color: #257aa8; color: #15577b; font-weight: 700; }
.form { display: grid; gap: 15px; }
.form[hidden] { display: none; }
.field { display: grid; gap: 7px; }
.field label { color: #294b5b; font-size: 14px; font-weight: 650; }
.field input { width: 100%; min-height: 44px; padding: 9px 12px; border: 1px solid #a9c4cf; border-radius: 6px; outline: none; background: #fff; color: #102e3d; }
.field input:focus { border-color: #257aa8; box-shadow: 0 0 0 3px rgba(37, 122, 168, .14); }
.code-row { display: grid; grid-template-columns: 1fr 126px; gap: 8px; }
.primary, .secondary { min-height: 44px; border-radius: 6px; cursor: pointer; font-weight: 700; }
.primary { border: 1px solid #17638e; background: #257aa8; color: #fff; }
.secondary { border: 1px solid #84adbe; background: #e7f3f5; color: #205f7c; }
button:disabled { cursor: wait; opacity: .62; }
.status { min-height: 22px; margin: 13px 0 0; color: #4b6875; font-size: 13px; line-height: 1.5; text-align: center; }
.status.error { color: #9b3340; }
.load-track { height: 5px; margin-top: 12px; overflow: hidden; border-radius: 3px; background: #d5e5e9; }
.load-bar { width: 0; height: 100%; background: #3292b9; transition: width .2s ease; }
#failure { display: none; margin-top: 12px; color: #9b3340; font-size: 13px; text-align: center; }
@media (max-width: 560px) {
#auth-shell { align-items: stretch; padding: 0; }
.auth-panel { width: 100%; max-height: 100%; margin-top: 18vh; padding: 24px 22px; border-width: 1px 0 0; border-radius: 8px 8px 0 0; }
.brand { margin-bottom: 16px; }
.brand h1 { font-size: 23px; }
}
#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>
<main id="auth-shell">
<section class="auth-panel" aria-label="鲸鱼小镇账户入口">
<div class="brand"><span class="brand-mark">W</span><h1>鲸鱼小镇</h1></div>
<div class="tabs" role="tablist">
<button class="tab" id="login-tab" type="button" role="tab" aria-selected="true">登录</button>
<button class="tab" id="register-tab" type="button" role="tab" aria-selected="false">注册</button>
</div>
<form class="form" id="login-form">
<div class="field"><label for="identifier">用户名 / 邮箱 / 手机号</label><input id="identifier" autocomplete="username" maxlength="100" required></div>
<div class="field"><label for="login-password">密码</label><input id="login-password" type="password" autocomplete="current-password" maxlength="128" required></div>
<button class="primary" type="submit">进入小镇</button>
</form>
<form class="form" id="register-form" hidden>
<div class="field"><label for="username">用户名</label><input id="username" autocomplete="username" maxlength="50" pattern="[A-Za-z0-9_]+" required></div>
<div class="field"><label for="email">邮箱</label><div class="code-row"><input id="email" type="email" autocomplete="email" required><button class="secondary" id="send-code" type="button">获取验证码</button></div></div>
<div class="field"><label for="code">邮箱验证码</label><input id="code" inputmode="numeric" autocomplete="one-time-code" minlength="6" maxlength="6" pattern="[0-9]{6}" required></div>
<div class="field"><label for="register-password">密码</label><input id="register-password" type="password" autocomplete="new-password" minlength="8" maxlength="128" required></div>
<div class="field"><label for="confirm-password">确认密码</label><input id="confirm-password" type="password" autocomplete="new-password" minlength="8" maxlength="128" required></div>
<button class="primary" type="submit">注册居民身份</button>
</form>
<p class="status" id="status" aria-live="polite">主程序正在后台加载 0%</p>
<div class="load-track" aria-hidden="true"><div class="load-bar" id="load-bar"></div></div>
<div id="failure" role="alert"></div>
</section>
</main>
<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 AUTH_STORAGE_KEY = 'whaletown.auth.bootstrap';
const engine = new Engine(GODOT_CONFIG);
const shell = document.getElementById('auth-shell');
const canvas = document.getElementById('canvas');
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 loadBar = document.getElementById('load-bar');
const bar = document.getElementById('bar');
const failure = document.getElementById('failure');
let engineReady = false;
let authReady = false;
const engine = new Engine(GODOT_CONFIG);
function selectTab(name) {
const login = name === 'login';
document.getElementById('login-tab').setAttribute('aria-selected', String(login));
document.getElementById('register-tab').setAttribute('aria-selected', String(!login));
document.getElementById('login-form').hidden = !login;
document.getElementById('register-form').hidden = login;
setMessage(engineReady ? '主程序已就绪' : status.textContent, false);
}
function setMessage(message, isError) {
status.textContent = message;
status.classList.toggle('error', Boolean(isError));
}
function setFormsDisabled(disabled) {
document.querySelectorAll('form input, form button').forEach((element) => { element.disabled = disabled; });
}
async function postJson(path, body) {
const response = await fetch('/api' + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
let payload = {};
try { payload = await response.json(); } catch (_) { throw new Error('服务器响应格式错误'); }
if (!response.ok || !payload.success) throw new Error(payload.message || '请求失败');
return payload;
}
function canRecoverRegistration(error) {
const message = String(error && error.message ? error.message : error);
return error instanceof TypeError || message.includes('用户名已存在') || message.includes('用户名已被注册');
}
async function registerWithRecovery(body) {
try {
return await postJson('/auth/register', body);
} catch (registerError) {
if (!canRecoverRegistration(registerError)) throw registerError;
try {
return await postJson('/auth/login', { identifier: body.username, password: body.password });
} catch (_) {
throw registerError;
}
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';
}
}
function handOffAuth(kind, payload) {
sessionStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ kind, payload }));
authReady = true;
setFormsDisabled(true);
setMessage(engineReady ? '正在进入小镇...' : '登录成功,主程序仍在后台加载...', false);
showGameWhenReady();
}
function showGameWhenReady() {
if (!engineReady || !authReady) return;
canvas.focus();
shell.style.display = 'none';
}
document.getElementById('login-tab').addEventListener('click', () => selectTab('login'));
document.getElementById('register-tab').addEventListener('click', () => selectTab('register'));
document.getElementById('login-form').addEventListener('submit', async (event) => {
event.preventDefault();
setFormsDisabled(true);
setMessage('正在登录...', false);
try {
const payload = await postJson('/auth/login', {
identifier: document.getElementById('identifier').value.trim(),
password: document.getElementById('login-password').value,
});
handOffAuth('login', payload);
} catch (error) {
setFormsDisabled(false);
setMessage(error.message || '登录失败', true);
}
});
document.getElementById('send-code').addEventListener('click', async () => {
const email = document.getElementById('email').value.trim();
if (!email) { setMessage('请先输入邮箱', true); return; }
const button = document.getElementById('send-code');
button.disabled = true;
setMessage('正在发送验证码...', false);
try {
await postJson('/auth/send-email-verification', { email });
setMessage('验证码已发送,请查收邮件', false);
} catch (error) {
setMessage(error.message || '验证码发送失败', true);
} finally { button.disabled = false; }
});
document.getElementById('register-form').addEventListener('submit', async (event) => {
event.preventDefault();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('register-password').value;
if (password !== document.getElementById('confirm-password').value) { setMessage('两次输入的密码不一致', true); return; }
if (!/[A-Za-z]/.test(password) || !/[0-9]/.test(password)) { setMessage('密码需要同时包含字母和数字', true); return; }
setFormsDisabled(true);
setMessage('正在注册...', false);
try {
const payload = await registerWithRecovery({
username,
nickname: username,
email: document.getElementById('email').value.trim(),
email_verification_code: document.getElementById('code').value.trim(),
password,
});
handOffAuth('register', payload);
} catch (error) {
setFormsDisabled(false);
setMessage(error.message || '注册失败', true);
}
});
};
const missing = Engine.getMissingFeatures({ threads: GODOT_THREADS_ENABLED });
if (missing.length) {
@@ -210,15 +71,13 @@
onProgress(current, total) {
if (total <= 0) return;
const percent = Math.min(100, Math.round(current / total * 100));
loadBar.style.width = percent + '%';
if (!authReady) setMessage('主程序正在后台加载 ' + percent + '%', false);
bar.style.width = percent + '%';
status.textContent = '正在加载小镇 ' + percent + '%';
},
}).then(() => {
engineReady = true;
loadBar.style.width = '100%';
if (!authReady) setMessage('主程序已就绪', false);
showGameWhenReady();
}).catch((error) => {
}).then(function () {
status.textContent = '正在加载完整登录界面...';
bar.style.width = '0';
}).catch(function (error) {
failure.style.display = 'block';
failure.textContent = error && error.message ? error.message : '主程序加载失败,请刷新重试';
});