@@ -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 : 24 px ; 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 ( 44 0 px , 100 % ) ; max-height : calc ( 100 vh - 4 8px ) ; overflow : auto ; padding : 30 px 32 px 24 px ; border : 2 px solid rgba ( 38 , 112 , 151 , .68 ) ; border-radius : 8 px ; background : rgba ( 249 , 253 , 252 , .96 ) ; box -shadow: 0 14 px 38 px rgba ( 1 5, 6 5, 89 , .22 ) ; }
. brand { display : flex ; align-items : center ; justify-content : center ; gap : 12 px ; margin-bottom : 22 px ; color : #174f72 ; }
. b rand-mar k { display : grid ; place-items : center ; width : 48 px ; height : 48 px ; border-radius : 50 % ; background : #4c9ac 5; color : white ; font-size : 27 px ; font-weight : 800 ; }
. brand h1 { margin : 0 ; font-size : 26 px ; line-height : 1.1 ; letter-spacing : 0 ; }
. tabs { display : grid ; grid-template-columns : 1 fr 1 fr ; margin-bottom : 22 px ; border-bottom : 1 px solid #c8dbe2 ; }
. tab { min-height : 42 px ; border : 0 ; border-bottom : 3 px 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 : 15 px ; }
. form [ hidden ] { display : none ; }
. field { display : grid ; gap : 7 px ; }
. field label { color : #294b5b ; font-size : 14 px ; font-weight : 650 ; }
. field input { width : 100 % ; min-height : 44 px ; padding : 9 px 12 px ; border : 1 px solid #a9c4cf ; border-radius : 6 px ; outline : none ; background : #fff ; color : #102e3d ; }
. field input : focus { border-color : #257aa8 ; box-shadow : 0 0 0 3 px rgba ( 37 , 122 , 168 , .14 ) ; }
. code-row { display : grid ; grid-template-columns : 1 fr 126 px ; gap : 8 px ; }
. primary , . secondary { min-height : 44 px ; border-radius : 6 px ; cursor : pointer ; font-weight : 700 ; }
. primary { border : 1 px solid #17638e ; background : #257aa8 ; color : #fff ; }
. secondary { border : 1 px solid #84adbe ; background : #e7f3f5 ; color : #205f7c ; }
button : disabled { cursor : wait ; opacity : .62 ; }
. status { min-height : 22 px ; margin : 13 px 0 0 ; color : #4b6875 ; font-size : 13 px ; line-height : 1.5 ; text-align : center ; }
. status . error { color : #9b3340 ; }
. load-track { height : 5 px ; margin-top : 12 px ; overflow : hidden ; border-radius : 3 px ; background : #d5e5e9 ; }
. load-bar { width : 0 ; height : 100 % ; background : #3292b9 ; transition : width .2 s ease ; }
# failure { display : none ; margin-top : 12 px ; color : #9b3340 ; font-size : 13 px ; text-align : center ; }
@ media ( max-width : 560px ) {
# auth-shell { align-items : stretch ; padding : 0 ; }
. auth-panel { width : 100 % ; max-height : 100 % ; margin-top : 18 vh ; padding : 24 px 22 px ; border-width : 1 px 0 0 ; border-radius : 8 px 8 px 0 0 ; }
. brand { margin-bottom : 16 px ; }
. brand h1 { font-size : 23 px ; }
}
# loading -shell { position : fixed ; inset : 0 ; z-index : 10 ; display : grid ; place-items : end center; padding : 0 24px max ( 9 vh , 34 px ) ; background : #b9d9df url ( "auth-background.jpg" ) center / cover no-repeat ; transition : opacity .18 s ease ; }
# loading -shell:: before { content : "" ; position : absolute ; inset : 0 ; background : rgba ( 222 , 242 , 244 , .22 ) ; }
. loading { position : relative ; width : min ( 42 0 px , 8 8vw ) ; color : #174f72 ; text-align : center ; text -shadow: 0 1 px 0 rgba ( 25 5, 25 5, 255 , .9 ) ; }
. status { min-height : 24 px ; margin : 0 0 10 px ; font-size : 15 px ; font-weight : 700 ; line-height : 1.5 ; letter-spacing : 0 ; }
. t rac k { height : 7 px ; overflow : hidden ; border : 1 px solid rgba ( 23 , 79 , 114 , .28 ) ; border-radius : 4 px ; background : rgba ( 25 5, 255 , 255 , .78 ) ; box-shadow : 0 2 px 8 px rgba ( 15 , 65 , 89 , .16 ) ; }
. ba r { width : 0 ; height : 100 % ; background : #2b86b6 ; transition : width .2 s ease ; }
. failure { display : none ; margin-top : 10 px ; color : #8f2634 ; font-size : 13 px ; 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 loadB ar = document . getElementById ( 'load- bar' ) ;
const b ar = document . getElementById ( 'bar' ) ;
const failure = document . getElementById ( 'failure' ) ;
le t engineReady = false ;
let authReady = false ;
cons t engine = new Engine ( GODOT _CONFIG ) ;
function selectTab ( name ) {
const login = name === 'login ' ;
document . getElementById ( 'login-tab' ) . setAttribute ( 'aria-selected' , String ( login ) ) ;
document . g etElementById ( '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 . s etTimeout ( 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 ) ) ;
loadB ar. style . width = percent + '%' ;
if ( ! authReady ) setMessage ( '主程序正在后台加载 ' + percent + '%' , false ) ;
b ar. style . width = percent + '%' ;
status . textContent = '正在加载小镇 ' + percent + '%' ;
} ,
} ) . then ( ( ) => {
engineReady = true ;
loadB ar. style . width = '100% ' ;
if ( ! authReady ) setMessage ( '主程序已就绪' , false ) ;
showGameWhenReady ( ) ;
} ) . catch ( ( error ) => {
} ) . then ( function ( ) {
status . textContent = '正在加载完整登录界面...' ;
b ar. style . width = '0 ' ;
} ) . catch ( function ( error ) {
failure . style . display = 'block' ;
failure . textContent = error && error . message ? error . message : '主程序加载失败,请刷新重试' ;
} ) ;