feat: expand multiplayer, chat, and release support

- add network NPC synchronization and dialogue interactions
- add world bulletin publishing and display
- improve authentication, session refresh, and appearance sync
- synchronize player direction and movement animations
- improve input focus and progressive Web loading
- add macOS/Windows builds and deployment configuration
- include required fonts, shaders, and runtime assets
This commit is contained in:
2026-09-08 21:37:43 +08:00
parent 175621f66c
commit fc6c3c1bd3
87 changed files with 4546 additions and 506 deletions

View File

@@ -25,12 +25,12 @@ const REGISTRATION_CHOICE_SPRITESHEET_GRID_BOX_PATH: String = REGISTRATION_CHOIC
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-web.ttf"
const AVATAR_MASK_SHADER = preload("res://assets/shaders/avatar_round_mask.gdshader")
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"]
const WEB_FILE_MAX_BYTES: int = 8 * 1024 * 1024
@@ -46,7 +46,7 @@ const AUTH_STAGE_VIEWPORT_RATIO: float = 0.92
const LOGIN_FRAME_RECT: Rect2 = Rect2(270, 40, 780, 680)
const REGISTER_FRAME_RECT: Rect2 = Rect2(150, 15, 1020, 727)
const LOGIN_FORM_RECT: Rect2 = Rect2(432, 210, 430, 430)
const REGISTER_FORM_RECT: Rect2 = Rect2(292, 155, 446, 500)
const REGISTER_FORM_RECT: Rect2 = Rect2(292, 128, 446, 500)
@onready var whale_frame: TextureRect = %WhaleFrame
@onready var login_panel: PanelContainer = %LoginPanel
@@ -55,8 +55,10 @@ const REGISTER_FORM_RECT: Rect2 = Rect2(292, 155, 446, 500)
@onready var login_identifier_input: LineEdit = %LoginIdentifierInput
@onready var login_password_input: LineEdit = %LoginPasswordInput
@onready var login_button: Button = %LoginButton
@onready var guest_button: Button = %GuestButton
@onready var show_register_button: Button = %ShowRegisterButton
@onready var register_username_input: LineEdit = %RegisterUsernameInput
@onready var register_invitation_code_input: LineEdit = %RegisterInvitationCodeInput
@onready var register_email_input: LineEdit = %RegisterEmailInput
@onready var send_register_code_button: Button = %SendRegisterCodeButton
@onready var register_verification_code_input: LineEdit = %RegisterVerificationCodeInput
@@ -96,8 +98,6 @@ var _brand_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
var _skin_generation_poll_request: HTTPRequest
var _skin_generation_active: bool = false
var _skin_generation_job_id: String = ""
var _skin_generation_poll_elapsed: float = 0.0
@@ -110,6 +110,7 @@ var _registration_official_skins: Array = []
var _registration_official_skin_index: int = 0
var _registration_avatar_source_path: String = ""
var _registration_skin_source_path: String = ""
var _registration_avatar_controls_visible: bool = false
var _workshop_source_image_path: String = ""
var _awaiting_registration_skin_generation: bool = false
var _awaiting_registration_skin_choice: bool = false
@@ -210,7 +211,7 @@ func _build_login_form(parent: Control) -> void:
box.offset_right = -12
parent.add_child(box)
var title := _brand_label("TitleLabel", "WhaleTown V2")
var title := _brand_label("TitleLabel", "WhaleTown")
title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
box.add_child(title)
box.add_child(HSeparator.new())
@@ -247,6 +248,9 @@ func _build_login_form(parent: Control) -> void:
var loginButton := _primary_button("LoginButton", "进入小镇", 25, Vector2(396, 58))
loginButton.unique_name_in_owner = true
box.add_child(loginButton)
var guestButton := _secondary_button("GuestButton", "游客参观", 17, Vector2(396, 40))
guestButton.unique_name_in_owner = true
box.add_child(guestButton)
var links := HBoxContainer.new()
links.name = "BottomLinks"
@@ -268,7 +272,7 @@ func _build_register_form(parent: Control) -> void:
var box := VBoxContainer.new()
box.name = "RegisterVBox"
box.add_theme_constant_override("separation", 3)
_place(box, Rect2(8, 40, 422, 448))
_place(box, Rect2(8, 20, 422, 478))
content.add_child(box)
var title := _label("RegisterTitleLabel", "注册新居民", 22, TEXT_COLOR, HORIZONTAL_ALIGNMENT_CENTER)
@@ -281,6 +285,12 @@ func _build_register_form(parent: Control) -> void:
usernameInput.unique_name_in_owner = true
box.add_child(usernameInput)
box.add_child(_register_form_label("邀请码"))
var invitationCodeInput := _register_line_edit("RegisterInvitationCodeInput", "输入邀请码", false)
invitationCodeInput.unique_name_in_owner = true
invitationCodeInput.max_length = 30
box.add_child(invitationCodeInput)
box.add_child(_register_form_label("邮箱"))
var emailRow := HBoxContainer.new()
emailRow.name = "RegisterEmailRow"
@@ -312,7 +322,7 @@ func _build_register_form(parent: Control) -> void:
var buttonSpacer := Control.new()
buttonSpacer.name = "RegisterButtonSpacer"
buttonSpacer.custom_minimum_size = Vector2(0, 12)
buttonSpacer.custom_minimum_size = Vector2(0, 4)
box.add_child(buttonSpacer)
var buttons := HBoxContainer.new()
@@ -692,11 +702,12 @@ func _ready() -> void:
_scene_manager = get_node_or_null("/root/SceneManager")
_appearance_manager = get_node_or_null("/root/AppearanceManager")
_setup_skin_generation_requests()
_connect_signals()
_refresh_appearance_ui()
_show_login()
_notify_web_shell_ready()
if _scene_manager != null and _scene_manager.has_method("preload_scene_pack"):
_scene_manager.call("preload_scene_pack", "square")
if _auth_manager != null and bool(_auth_manager.call("is_authenticated")) and _should_auto_resume_cached_session():
_resume_cached_session()
@@ -711,6 +722,7 @@ func _ready() -> void:
func _connect_signals() -> void:
login_button.pressed.connect(_on_login_pressed)
guest_button.pressed.connect(_on_guest_pressed)
show_register_button.pressed.connect(_show_register)
send_register_code_button.pressed.connect(_on_send_register_code_pressed)
register_button.pressed.connect(_on_register_pressed)
@@ -757,19 +769,6 @@ func _complete_browser_bootstrap(kind: String) -> void:
return
_on_login_succeeded(_auth_manager.call("get_current_user"))
func _setup_skin_generation_requests() -> void:
_skin_generation_create_request = HTTPRequest.new()
_skin_generation_create_request.name = "SkinGenerationCreateRequest"
_skin_generation_create_request.timeout = SKIN_GENERATION_REQUEST_TIMEOUT
_skin_generation_create_request.request_completed.connect(_on_skin_generation_create_completed)
add_child(_skin_generation_create_request)
_skin_generation_poll_request = HTTPRequest.new()
_skin_generation_poll_request.name = "SkinGenerationPollRequest"
_skin_generation_poll_request.timeout = SKIN_GENERATION_REQUEST_TIMEOUT
_skin_generation_poll_request.request_completed.connect(_on_skin_generation_poll_completed)
add_child(_skin_generation_poll_request)
func _process(delta: float) -> void:
if not _skin_generation_active or _skin_generation_job_id.is_empty():
return
@@ -779,10 +778,6 @@ func _process(delta: float) -> void:
_poll_skin_generation_job()
func _exit_tree() -> void:
if is_instance_valid(_skin_generation_create_request):
_skin_generation_create_request.cancel_request()
if is_instance_valid(_skin_generation_poll_request):
_skin_generation_poll_request.cancel_request()
_skin_generation_active = false
_skin_generation_job_id = ""
@@ -936,6 +931,7 @@ func _set_registration_character_controls_visible(visible: bool) -> void:
_refresh_appearance_ui()
func _set_registration_avatar_controls_visible(visible: bool) -> void:
_registration_avatar_controls_visible = visible
if not is_instance_valid(registration_character_area):
return
var controlNames := [
@@ -968,6 +964,7 @@ func _refresh_character_preview() -> void:
character_sprite.modulate = Color.WHITE
character_sprite.frame = 0
character_sprite.scale = _sprite_scale_for_height(character_sprite.texture, character_sprite.vframes, MAIN_PREVIEW_HEIGHT)
character_sprite.position = Vector2(50, 70) + _sprite_visual_center_offset(character_sprite.texture, character_sprite.hframes, character_sprite.vframes, character_sprite.frame, character_sprite.scale)
_clear_workshop_preview()
return
if _appearance_manager.has_method("apply_skin_to_sprite"):
@@ -976,6 +973,7 @@ func _refresh_character_preview() -> void:
_appearance_manager.call("apply_skin_to_sprite", character_sprite, previewSkinId)
character_sprite.frame = 0
character_sprite.scale = _sprite_scale_for_height(character_sprite.texture, character_sprite.vframes, MAIN_PREVIEW_HEIGHT)
character_sprite.position = Vector2(50, 70) + _sprite_visual_center_offset(character_sprite.texture, character_sprite.hframes, character_sprite.vframes, character_sprite.frame, character_sprite.scale)
_clear_workshop_preview()
func _clear_workshop_preview() -> void:
@@ -1006,6 +1004,7 @@ func _show_workshop_generated_preview_from_image(image: Image) -> void:
workshop_preview_sprite.vframes = 4
workshop_preview_sprite.frame = 0
workshop_preview_sprite.scale = _sprite_scale_for_height(texture, 4, 156.0)
workshop_preview_sprite.position = Vector2(75, 105) + _sprite_visual_center_offset(texture, 8, 4, workshop_preview_sprite.frame, workshop_preview_sprite.scale)
workshop_preview_sprite.visible = true
func _show_workshop_source_preview(path: String) -> void:
@@ -1102,6 +1101,7 @@ func _create_skin_button(skin: Dictionary, selectedSkinId: String) -> Button:
preview.frame = 0
preview.position = Vector2(90, 99)
preview.scale = _sprite_scale_for_frame_bounds(preview.texture, preview.hframes, preview.vframes, Vector2(136, SKIN_THUMB_HEIGHT))
preview.position += _sprite_visual_center_offset(preview.texture, preview.hframes, preview.vframes, preview.frame, preview.scale)
preview.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
button.add_child(preview)
@@ -1170,6 +1170,9 @@ func _skin_texture(skin: Dictionary) -> Texture2D:
func _refresh_avatar_preview() -> void:
avatar_preview.custom_minimum_size = Vector2(34, 34)
if not _registration_avatar_controls_visible:
avatar_preview.hide()
return
if _registration_avatar_texture == null:
_clear_registration_avatar_preview()
return
@@ -1202,7 +1205,7 @@ func _show_registration_avatar_preview(texture: Texture2D) -> void:
return
avatar_preview.show()
avatar_preview.clip_contents = true
avatar_preview.add_theme_stylebox_override("panel", _panel_style(Color.WHITE, 18))
avatar_preview.add_theme_stylebox_override("panel", _panel_style(Color.WHITE, 22, Color(0.45, 0.66, 0.84, 0.55), 1))
if is_instance_valid(avatar_label):
avatar_label.hide()
var textureRect := _get_or_create_registration_avatar_texture_rect()
@@ -1218,11 +1221,19 @@ func _get_or_create_registration_avatar_texture_rect() -> TextureRect:
textureRect.mouse_filter = Control.MOUSE_FILTER_IGNORE
textureRect.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
textureRect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
textureRect.stretch_mode = TextureRect.STRETCH_SCALE
textureRect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
textureRect.material = _create_avatar_mask_material()
textureRect.set_anchors_preset(Control.PRESET_FULL_RECT)
avatar_preview.add_child(textureRect)
return textureRect
func _create_avatar_mask_material() -> ShaderMaterial:
var material := ShaderMaterial.new()
material.shader = AVATAR_MASK_SHADER
material.set_shader_parameter("corner_radius", 0.22)
material.set_shader_parameter("edge_feather", 0.008)
return material
func _load_registration_avatar_texture(path: String) -> Texture2D:
var cropped := _load_registration_avatar_image(path)
if cropped == null:
@@ -1432,23 +1443,19 @@ func _on_generate_skin_pressed() -> void:
"source_image_base64": _image_to_png_base64(sourceImage),
"source_mime_type": "image/png",
}
var err := _skin_generation_create_request.request(
"%s%s" % [NetworkConfig.get_api_base_url(), SKIN_GENERATION_CREATE_ENDPOINT],
_auth_json_headers(),
HTTPClient.METHOD_POST,
JSON.stringify(payload)
)
if err != OK:
var apiClient := get_node_or_null("/root/ApiClient")
if apiClient == null or not apiClient.has_method("post_json"):
_skin_generation_active = false
_set_skin_generation_controls_enabled(true)
_set_workshop_generation_status("角色生成请求发送失败:%s" % error_string(err))
_set_workshop_generation_status("角色生成服务未加载")
return
apiClient.call("post_json", SKIN_GENERATION_CREATE_ENDPOINT, payload, _on_skin_generation_create_completed, true)
func _on_skin_generation_create_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var parsed := _parse_skin_generation_response(result, responseCode, body)
if not bool(parsed.get("ok", false)):
func _on_skin_generation_create_completed(success: bool, response: Dictionary, errorInfo: Dictionary) -> void:
if not success:
_skin_generation_active = false
_set_skin_generation_controls_enabled(true)
var errorMessage := str(parsed.get("message", "角色生成任务创建失败"))
var errorMessage := str(errorInfo.get("message", "角色生成任务创建失败"))
if errorMessage.contains("已经使用过注册角色生成机会") or errorMessage.contains("没有可用的注册角色生成机会"):
_awaiting_registration_skin_generation = false
_set_workshop_generation_status("该账号已完成注册角色生成,正在进入小镇...")
@@ -1457,7 +1464,13 @@ func _on_skin_generation_create_completed(result: int, responseCode: int, _heade
_set_workshop_generation_status(errorMessage)
return
var data: Dictionary = parsed.get("data", {})
var dataVariant: Variant = response.get("data", {})
if not (dataVariant is Dictionary):
_skin_generation_active = false
_set_skin_generation_controls_enabled(true)
_set_workshop_generation_status("服务器返回的生成任务格式错误")
return
var data: Dictionary = dataVariant
_skin_generation_job_id = str(data.get("job_id", "")).strip_edges()
if _skin_generation_job_id.is_empty():
_skin_generation_active = false
@@ -1473,26 +1486,30 @@ func _poll_skin_generation_job() -> void:
_skin_generation_poll_in_flight = true
var endpoint := SKIN_GENERATION_POLL_ENDPOINT_TEMPLATE % _skin_generation_job_id
var err := _skin_generation_poll_request.request(
"%s%s" % [NetworkConfig.get_api_base_url(), endpoint],
_auth_json_headers(),
HTTPClient.METHOD_GET
)
if err != OK:
var apiClient := get_node_or_null("/root/ApiClient")
if apiClient == null or not apiClient.has_method("get_json"):
_skin_generation_poll_in_flight = false
_set_workshop_generation_status("查询生成状态失败:%s" % error_string(err))
_set_workshop_generation_status("角色生成服务未加载")
return
apiClient.call("get_json", endpoint, _on_skin_generation_poll_completed, true)
func _on_skin_generation_poll_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
func _on_skin_generation_poll_completed(success: bool, response: Dictionary, errorInfo: Dictionary) -> void:
_skin_generation_poll_in_flight = false
var parsed := _parse_skin_generation_response(result, responseCode, body)
if not bool(parsed.get("ok", false)):
if not success:
var responseCode := int(errorInfo.get("response_code", 0))
if responseCode == 401 or responseCode == 403 or responseCode == 404:
_skin_generation_active = false
_set_skin_generation_controls_enabled(true)
_set_workshop_generation_status(str(parsed.get("message", "查询生成状态失败")))
_set_workshop_generation_status(str(errorInfo.get("message", "查询生成状态失败")))
return
var data: Dictionary = parsed.get("data", {})
var dataVariant: Variant = response.get("data", {})
if not (dataVariant is Dictionary):
_skin_generation_active = false
_set_skin_generation_controls_enabled(true)
_set_workshop_generation_status("服务器返回的生成状态格式错误")
return
var data: Dictionary = dataVariant
var status := str(data.get("status", "")).strip_edges()
var message := str(data.get("message", "")).strip_edges()
if not message.is_empty():
@@ -1578,74 +1595,6 @@ func _set_skin_generation_controls_enabled(enabled: bool) -> void:
if is_instance_valid(workshop_generate_button):
workshop_generate_button.disabled = not enabled
func _json_headers() -> PackedStringArray:
return PackedStringArray([
"Content-Type: application/json",
"Accept: application/json",
])
func _auth_json_headers() -> PackedStringArray:
var headers := _json_headers()
if _auth_manager != null and _auth_manager.has_method("get_access_token"):
var token := str(_auth_manager.call("get_access_token")).strip_edges()
if not token.is_empty():
headers.append("Authorization: Bearer %s" % token)
return headers
func _parse_skin_generation_response(result: int, responseCode: int, body: PackedByteArray) -> Dictionary:
if result != HTTPRequest.RESULT_SUCCESS:
return {
"ok": false,
"message": "网络请求失败:%s" % _http_result_to_string(result),
}
var bodyText := body.get_string_from_utf8()
var json := JSON.new()
var error := json.parse(bodyText)
if error != OK or not (json.data is Dictionary):
return {
"ok": false,
"message": "服务器响应解析失败",
}
var response: Dictionary = json.data as Dictionary
var success := responseCode >= 200 and responseCode < 300 and bool(response.get("success", true))
if not success:
return {
"ok": false,
"message": str(response.get("message", "请求失败")),
"response_code": responseCode,
}
var dataVariant: Variant = response.get("data", response)
if not (dataVariant is Dictionary):
return {
"ok": false,
"message": "服务器响应格式错误",
}
return {
"ok": true,
"data": dataVariant as Dictionary,
}
func _http_result_to_string(result: int) -> String:
match result:
HTTPRequest.RESULT_SUCCESS:
return "SUCCESS"
HTTPRequest.RESULT_TIMEOUT:
return "TIMEOUT"
HTTPRequest.RESULT_CANT_CONNECT:
return "CANT_CONNECT"
HTTPRequest.RESULT_CANT_RESOLVE:
return "CANT_RESOLVE"
HTTPRequest.RESULT_CONNECTION_ERROR:
return "CONNECTION_ERROR"
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
return "TLS_HANDSHAKE_ERROR"
_:
return "UNKNOWN_%d" % result
func _hide_skin_workshop() -> void:
if _awaiting_registration_skin_generation:
_awaiting_registration_skin_generation = false
@@ -1953,7 +1902,7 @@ func _on_send_register_code_pressed() -> void:
_is_sending_register_code = true
send_register_code_button.disabled = true
status_label.text = "正在发送邮箱验证码..."
_auth_manager.call("send_email_verification", register_email_input.text)
_auth_manager.call("send_email_verification", register_email_input.text, register_invitation_code_input.text)
func _on_register_pressed() -> void:
if _is_submitting:
@@ -1976,7 +1925,8 @@ func _on_register_pressed() -> void:
"",
register_email_input.text,
register_verification_code_input.text,
_registration_backend_initial_skin_id()
_registration_backend_initial_skin_id(),
register_invitation_code_input.text
)
func _on_login_succeeded(_user: Dictionary) -> void:
@@ -1987,6 +1937,17 @@ func _on_login_succeeded(_user: Dictionary) -> void:
_auth_manager.call("fetch_profile")
_enter_square()
func _on_guest_pressed() -> void:
if _is_submitting:
return
var chatManager := get_node_or_null("/root/ChatManager")
if chatManager == null or not chatManager.has_method("start_guest_session"):
status_label.text = "旁观服务未加载"
return
_set_submitting(true, "正在进入游客参观模式...")
chatManager.call("start_guest_session")
SceneManager.change_scene("square")
func _on_login_failed(message: String) -> void:
_set_submitting(false, message)
login_password_input.grab_focus()
@@ -2192,6 +2153,7 @@ func _set_submitting(is_submitting: bool, message: String) -> void:
_is_submitting = is_submitting
status_label.text = message
login_button.disabled = is_submitting
guest_button.disabled = is_submitting
register_button.disabled = is_submitting
send_register_code_button.disabled = is_submitting or _is_sending_register_code
show_register_button.disabled = is_submitting
@@ -2453,3 +2415,35 @@ func _sprite_scale_for_frame_bounds(texture: Texture2D, hframes: int, vframes: i
return Vector2.ONE
var scale: float = minf(bounds.x / frameSize.x, bounds.y / frameSize.y)
return Vector2(scale, scale)
func _sprite_visual_center_offset(texture: Texture2D, hframes: int, vframes: int, frame: int, scale: Vector2) -> Vector2:
if texture == null:
return Vector2.ZERO
var image := texture.get_image()
if image == null:
return Vector2.ZERO
var safeHframes := maxi(1, hframes)
var safeVframes := maxi(1, vframes)
var frameWidth := image.get_width() / safeHframes
var frameHeight := image.get_height() / safeVframes
if frameWidth <= 0 or frameHeight <= 0:
return Vector2.ZERO
var safeFrame := clampi(frame, 0, safeHframes * safeVframes - 1)
var frameOrigin := Vector2i((safeFrame % safeHframes) * frameWidth, (safeFrame / safeHframes) * frameHeight)
var minX := frameWidth
var minY := frameHeight
var maxX := -1
var maxY := -1
for y in range(frameHeight):
for x in range(frameWidth):
if image.get_pixel(frameOrigin.x + x, frameOrigin.y + y).a <= 0.08:
continue
minX = mini(minX, x)
minY = mini(minY, y)
maxX = maxi(maxX, x)
maxY = maxi(maxY, y)
if maxX < minX or maxY < minY:
return Vector2.ZERO
var visualCenter := Vector2((float(minX) + float(maxX) + 1.0) * 0.5, (float(minY) + float(maxY) + 1.0) * 0.5)
var frameCenter := Vector2(float(frameWidth), float(frameHeight)) * 0.5
return (frameCenter - visualCenter) * scale