forked from xiangwang25/whale-town-front-v2
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:
@@ -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
|
||||
|
||||
@@ -51,13 +51,13 @@ func _draw_polyline(points: Array[Vector2], color: Color, width: float) -> void:
|
||||
var packed: PackedVector2Array = []
|
||||
for point in points:
|
||||
packed.append(_p(point))
|
||||
draw_polyline(packed, color, width, true)
|
||||
draw_polyline(packed, color, width * _iconScale, false)
|
||||
|
||||
func _draw_line(from: Vector2, to: Vector2, color: Color, width: float) -> void:
|
||||
draw_line(_p(from), _p(to), color, width, true)
|
||||
draw_line(_p(from), _p(to), color, width * _iconScale, false)
|
||||
|
||||
func _draw_arc(center: Vector2, radius: float, startAngle: float, endAngle: float, pointCount: int, color: Color, width: float) -> void:
|
||||
draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, color, width, true)
|
||||
draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, color, width * _iconScale, false)
|
||||
|
||||
func _p(point: Vector2) -> Vector2:
|
||||
return _iconOffset + point * _iconScale
|
||||
|
||||
@@ -131,7 +131,9 @@ func _render_service_points() -> void:
|
||||
var index := servicePointOption.get_item_count()
|
||||
servicePointOption.add_item(_format_service_point_option(point))
|
||||
servicePointOption.set_item_metadata(index, pointId)
|
||||
if selectedIndex < 0 and not _point_has_companion(point):
|
||||
var isOccupied := _point_has_companion(point)
|
||||
servicePointOption.set_item_disabled(index, isOccupied)
|
||||
if selectedIndex < 0 and not isOccupied:
|
||||
selectedIndex = index
|
||||
|
||||
if servicePointOption.get_item_count() <= 0:
|
||||
@@ -139,7 +141,14 @@ func _render_service_points() -> void:
|
||||
submitButton.disabled = true
|
||||
return
|
||||
|
||||
servicePointOption.select(selectedIndex if selectedIndex >= 0 else 0)
|
||||
if selectedIndex < 0:
|
||||
servicePointOption.select(-1)
|
||||
submitButton.disabled = true
|
||||
fetchModelsButton.disabled = _isSubmitting
|
||||
_set_status("当前陪伴位均已被占用", true)
|
||||
return
|
||||
|
||||
servicePointOption.select(selectedIndex)
|
||||
submitButton.disabled = _isSubmitting
|
||||
fetchModelsButton.disabled = _isSubmitting
|
||||
_set_status("填写人设和代理配置后可登记", false)
|
||||
@@ -227,6 +236,10 @@ func _build_payload() -> Dictionary:
|
||||
if servicePointId.is_empty():
|
||||
_set_status("请选择陪伴位", true)
|
||||
return {}
|
||||
if not _is_service_point_available(servicePointId):
|
||||
_set_status("该陪伴位已被占用,请选择其他空位", true)
|
||||
_request_service_points()
|
||||
return {}
|
||||
if personaName.is_empty():
|
||||
_set_status("请填写人设名称", true)
|
||||
personaNameInput.grab_focus()
|
||||
@@ -293,6 +306,15 @@ func _selected_service_point_id() -> String:
|
||||
return ""
|
||||
return str(servicePointOption.get_item_metadata(selectedIndex)).strip_edges()
|
||||
|
||||
func _is_service_point_available(servicePointId: String) -> bool:
|
||||
for pointVariant in _servicePoints:
|
||||
if not (pointVariant is Dictionary):
|
||||
continue
|
||||
var point: Dictionary = pointVariant
|
||||
if str(point.get("id", "")).strip_edges() == servicePointId:
|
||||
return not _point_has_companion(point)
|
||||
return false
|
||||
|
||||
func _setup_protocol_options() -> void:
|
||||
protocolOption.clear()
|
||||
protocolOption.add_item("OpenAI", 0)
|
||||
|
||||
@@ -22,7 +22,12 @@ func _process(_delta: float) -> void:
|
||||
|
||||
_update_position()
|
||||
|
||||
func set_text(text: String, targetNode: Node2D = null, targetOffset: Vector2 = TARGET_OFFSET) -> void:
|
||||
func set_text(
|
||||
text: String,
|
||||
targetNode: Node2D = null,
|
||||
targetOffset: Vector2 = TARGET_OFFSET,
|
||||
duration: float = DEFAULT_DURATION,
|
||||
) -> void:
|
||||
_originalText = text
|
||||
_targetNode = targetNode
|
||||
_targetOffset = targetOffset
|
||||
@@ -31,7 +36,7 @@ func set_text(text: String, targetNode: Node2D = null, targetOffset: Vector2 = T
|
||||
if _targetNode != null:
|
||||
_update_position()
|
||||
|
||||
await get_tree().create_timer(DEFAULT_DURATION).timeout
|
||||
await get_tree().create_timer(maxf(0.1, duration)).timeout
|
||||
queue_free()
|
||||
|
||||
func _update_size() -> void:
|
||||
|
||||
@@ -56,6 +56,9 @@ extends Control
|
||||
@onready var friends_list_surface: Control = %FriendsListSurface
|
||||
@onready var add_friend_row: HBoxContainer = %AddFriendRow
|
||||
@onready var add_friend_button: Button = %AddFriendButton
|
||||
@onready var tabs: HBoxContainer = $ChatPanel/PanelMargin/ContentVBox/Tabs
|
||||
@onready var panel_margin: MarginContainer = $ChatPanel/PanelMargin
|
||||
@onready var input_row: HBoxContainer = $ChatPanel/PanelMargin/ContentVBox/InputRow
|
||||
|
||||
# ============================================================================
|
||||
# 预加载资源
|
||||
@@ -80,6 +83,9 @@ const TAB_WHISPER: String = "whisper"
|
||||
const TAB_FRIENDS: String = "friends"
|
||||
const MAX_DISPLAYED_MESSAGES: int = 100
|
||||
const MOVEMENT_ACTIONS: Array[String] = ["move_left", "move_right", "move_up", "move_down"]
|
||||
const SEND_ICON := preload("res://assets/ui/world_bulletin/world_bulletin_send_v2_128.png")
|
||||
const STATIC_NPC_DIALOGUE_PREFIX: String = "static_npc_dialogue:"
|
||||
const DEFAULT_WHISPER_TAB_TEXT: String = "悄悄话"
|
||||
|
||||
# ============================================================================
|
||||
# 成员变量
|
||||
@@ -106,6 +112,9 @@ var _current_tab: String = TAB_WORLD
|
||||
# 悄悄话目标(靠近玩家按 E 后设置)
|
||||
var _whisper_target_user_id: String = ""
|
||||
var _whisper_target_username: String = ""
|
||||
var _npc_target_id: String = ""
|
||||
var _npc_target_name: String = ""
|
||||
var _npc_session_id: String = ""
|
||||
|
||||
# 好友私聊目标(好友列表接入后复用同一私聊协议)
|
||||
var _friend_target_user_id: String = ""
|
||||
@@ -118,6 +127,12 @@ var _friends_status_message: String = ""
|
||||
var _messages: Array[Dictionary] = []
|
||||
|
||||
var _send_failure_handled_by_ui: bool = false
|
||||
var _npc_thinking_row: Control
|
||||
var _npc_thinking_timer: Timer
|
||||
var _npc_dialogue_mode: bool = false
|
||||
var _npc_dialogue_read_only: bool = false
|
||||
var _default_chat_panel_style: StyleBox
|
||||
var _default_input_shell_style: StyleBox
|
||||
|
||||
# ============================================================================
|
||||
# 生命周期方法
|
||||
@@ -126,12 +141,17 @@ var _send_failure_handled_by_ui: bool = false
|
||||
# 准备就绪
|
||||
func _ready() -> void:
|
||||
_configure_mouse_focus()
|
||||
_configure_send_icon()
|
||||
_capture_default_dialogue_styles()
|
||||
if not get_viewport().size_changed.is_connected(_on_viewport_size_changed):
|
||||
get_viewport().size_changed.connect(_on_viewport_size_changed)
|
||||
|
||||
# 初始隐藏聊天框
|
||||
hide_chat(true)
|
||||
|
||||
# 创建隐藏计时器
|
||||
_create_hide_timer()
|
||||
_create_npc_thinking_timer()
|
||||
|
||||
# 订阅事件(Call Down via EventSystem)
|
||||
_subscribe_to_events()
|
||||
@@ -144,6 +164,134 @@ func _ready() -> void:
|
||||
_update_tab_visuals()
|
||||
_update_bubble_send_button_visibility()
|
||||
|
||||
func _configure_send_icon() -> void:
|
||||
if not is_instance_valid(send_button):
|
||||
return
|
||||
send_button.text = ""
|
||||
send_button.icon = SEND_ICON
|
||||
send_button.expand_icon = true
|
||||
send_button.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
|
||||
|
||||
func _capture_default_dialogue_styles() -> void:
|
||||
if is_instance_valid(chat_panel):
|
||||
_default_chat_panel_style = chat_panel.get_theme_stylebox("panel").duplicate() as StyleBox
|
||||
if is_instance_valid(input_shell):
|
||||
_default_input_shell_style = input_shell.get_theme_stylebox("panel").duplicate() as StyleBox
|
||||
|
||||
func _set_npc_dialogue_mode(enabled: bool, readOnly: bool = false) -> void:
|
||||
_npc_dialogue_mode = enabled
|
||||
_npc_dialogue_read_only = enabled and readOnly
|
||||
|
||||
var worldTab := popular_tab_button.get_parent() as Control if is_instance_valid(popular_tab_button) else null
|
||||
var whisperTab := recent_tab_button.get_parent() as Control if is_instance_valid(recent_tab_button) else null
|
||||
var friendsTab := friends_tab_button.get_parent() as Control if is_instance_valid(friends_tab_button) else null
|
||||
if is_instance_valid(worldTab):
|
||||
worldTab.visible = not enabled
|
||||
if is_instance_valid(friendsTab):
|
||||
friendsTab.visible = not enabled
|
||||
if is_instance_valid(whisperTab):
|
||||
whisperTab.visible = true
|
||||
if is_instance_valid(recent_tab_button):
|
||||
recent_tab_button.text = _npc_target_name if enabled else DEFAULT_WHISPER_TAB_TEXT
|
||||
recent_tab_button.alignment = HORIZONTAL_ALIGNMENT_LEFT if enabled else HORIZONTAL_ALIGNMENT_CENTER
|
||||
recent_tab_button.mouse_filter = Control.MOUSE_FILTER_IGNORE if enabled else Control.MOUSE_FILTER_STOP
|
||||
if is_instance_valid(input_row):
|
||||
input_row.visible = not _npc_dialogue_read_only
|
||||
|
||||
if is_instance_valid(chat_panel):
|
||||
var panelStyle := _create_npc_dialogue_style() if enabled else _default_chat_panel_style
|
||||
if panelStyle != null:
|
||||
chat_panel.add_theme_stylebox_override("panel", panelStyle)
|
||||
if is_instance_valid(input_shell):
|
||||
var inputStyle := _create_npc_dialogue_input_style() if enabled else _default_input_shell_style
|
||||
if inputStyle != null:
|
||||
input_shell.add_theme_stylebox_override("panel", inputStyle)
|
||||
if is_instance_valid(panel_margin):
|
||||
var horizontalMargin := 24 if enabled else 20
|
||||
var verticalMargin := 18
|
||||
panel_margin.add_theme_constant_override("margin_left", horizontalMargin)
|
||||
panel_margin.add_theme_constant_override("margin_top", verticalMargin)
|
||||
panel_margin.add_theme_constant_override("margin_right", horizontalMargin)
|
||||
panel_margin.add_theme_constant_override("margin_bottom", verticalMargin)
|
||||
|
||||
_apply_chat_panel_layout()
|
||||
_update_tab_visuals()
|
||||
if is_instance_valid(message_list):
|
||||
_rerender_messages()
|
||||
|
||||
func _clear_npc_dialogue_target() -> void:
|
||||
_npc_target_id = ""
|
||||
_npc_target_name = ""
|
||||
_npc_session_id = ""
|
||||
_set_npc_dialogue_mode(false, false)
|
||||
|
||||
func _apply_chat_panel_layout() -> void:
|
||||
if not is_instance_valid(chat_panel):
|
||||
return
|
||||
if not _npc_dialogue_mode:
|
||||
chat_panel.anchor_left = 0.024
|
||||
chat_panel.anchor_top = 1.0
|
||||
chat_panel.anchor_right = 0.024
|
||||
chat_panel.anchor_bottom = 1.0
|
||||
chat_panel.offset_left = 0.0
|
||||
chat_panel.offset_top = -448.0
|
||||
chat_panel.offset_right = 548.0
|
||||
chat_panel.offset_bottom = -32.0
|
||||
return
|
||||
|
||||
var viewportSize := get_viewport_rect().size
|
||||
var dialogWidth := clampf(viewportSize.x * 0.90, 320.0, 760.0)
|
||||
var minimumHeight := 200.0 if _npc_dialogue_read_only else 250.0
|
||||
var maximumHeight := 250.0 if _npc_dialogue_read_only else 340.0
|
||||
var dialogHeight := clampf(viewportSize.y * 0.42, minimumHeight, maximumHeight)
|
||||
var bottomMargin := clampf(viewportSize.y * 0.04, 18.0, 34.0)
|
||||
chat_panel.anchor_left = 0.5
|
||||
chat_panel.anchor_top = 1.0
|
||||
chat_panel.anchor_right = 0.5
|
||||
chat_panel.anchor_bottom = 1.0
|
||||
chat_panel.offset_left = -dialogWidth * 0.5
|
||||
chat_panel.offset_top = -bottomMargin - dialogHeight
|
||||
chat_panel.offset_right = dialogWidth * 0.5
|
||||
chat_panel.offset_bottom = -bottomMargin
|
||||
|
||||
func _on_viewport_size_changed() -> void:
|
||||
_apply_chat_panel_layout()
|
||||
|
||||
func _create_npc_dialogue_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(1.0, 0.985, 0.91, 0.985)
|
||||
style.border_color = Color(0.055, 0.22, 0.235, 1.0)
|
||||
style.border_width_left = 4
|
||||
style.border_width_top = 4
|
||||
style.border_width_right = 4
|
||||
style.border_width_bottom = 4
|
||||
style.corner_radius_top_left = 5
|
||||
style.corner_radius_top_right = 5
|
||||
style.corner_radius_bottom_left = 5
|
||||
style.corner_radius_bottom_right = 5
|
||||
style.shadow_color = Color(0.02, 0.06, 0.07, 0.3)
|
||||
style.shadow_size = 8
|
||||
style.shadow_offset = Vector2(0, 5)
|
||||
return style
|
||||
|
||||
func _create_npc_dialogue_input_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(1.0, 1.0, 0.98, 1.0)
|
||||
style.border_color = Color(0.22, 0.42, 0.42, 0.82)
|
||||
style.border_width_left = 2
|
||||
style.border_width_top = 2
|
||||
style.border_width_right = 2
|
||||
style.border_width_bottom = 2
|
||||
style.corner_radius_top_left = 4
|
||||
style.corner_radius_top_right = 4
|
||||
style.corner_radius_bottom_left = 4
|
||||
style.corner_radius_bottom_right = 4
|
||||
style.content_margin_left = 14
|
||||
style.content_margin_top = 6
|
||||
style.content_margin_right = 14
|
||||
style.content_margin_bottom = 6
|
||||
return style
|
||||
|
||||
# 清理
|
||||
func _exit_tree() -> void:
|
||||
# 取消事件订阅
|
||||
@@ -155,6 +303,8 @@ func _exit_tree() -> void:
|
||||
eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_login_success, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_FAILED, _on_login_failed, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, _on_private_target_selected, self)
|
||||
eventSystem.call("disconnect_event", EventNames.NPC_SPOKE, _on_npc_spoke, self)
|
||||
eventSystem.call("disconnect_event", EventNames.NPC_INTERACTION_ERROR, _on_npc_interaction_error, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CHAT_FRIEND_SELECTED, _on_friend_selected, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CHAT_FRIENDS_UPDATED, _on_friends_updated, self)
|
||||
eventSystem.call("disconnect_event", EventNames.SETTINGS_CHANGED, _on_settings_changed, self)
|
||||
@@ -162,6 +312,11 @@ func _exit_tree() -> void:
|
||||
# 清理计时器
|
||||
if _hide_timer:
|
||||
_hide_timer.queue_free()
|
||||
if _npc_thinking_timer:
|
||||
_npc_thinking_timer.queue_free()
|
||||
_hide_npc_thinking()
|
||||
if get_viewport() != null and get_viewport().size_changed.is_connected(_on_viewport_size_changed):
|
||||
get_viewport().size_changed.disconnect(_on_viewport_size_changed)
|
||||
|
||||
if is_instance_valid(_transition_tween):
|
||||
_transition_tween.kill()
|
||||
@@ -192,6 +347,10 @@ func _input(event: InputEvent) -> void:
|
||||
var key_event := event as InputEventKey
|
||||
if not key_event.pressed or key_event.echo:
|
||||
return
|
||||
if key_event.keycode == KEY_ESCAPE and _is_chat_visible:
|
||||
hide_chat()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
|
||||
# T 键用于唤起聊天(输入框聚焦时不拦截)
|
||||
if key_event.keycode == KEY_T and not chat_input.has_focus():
|
||||
@@ -264,6 +423,9 @@ func _update_input_placeholder() -> void:
|
||||
if _current_tab == TAB_WHISPER and not _whisper_target_username.is_empty():
|
||||
chat_input.placeholder_text = "对 %s 说点什么..." % _whisper_target_username
|
||||
return
|
||||
if _current_tab == TAB_WHISPER and not _npc_target_name.is_empty():
|
||||
chat_input.placeholder_text = "对 %s 说点什么..." % _npc_target_name
|
||||
return
|
||||
|
||||
if _current_tab == TAB_FRIENDS and not _friend_target_username.is_empty():
|
||||
chat_input.placeholder_text = "对 %s 说点什么..." % _friend_target_username
|
||||
@@ -338,6 +500,8 @@ func show_chat(immediate: bool = false) -> void:
|
||||
|
||||
# 隐藏聊天框
|
||||
func hide_chat(immediate: bool = false) -> void:
|
||||
_end_npc_session()
|
||||
_clear_npc_dialogue_target()
|
||||
_is_chat_visible = false
|
||||
_is_typing = false
|
||||
|
||||
@@ -374,6 +538,13 @@ func _create_hide_timer() -> void:
|
||||
_hide_timer.timeout.connect(_on_hide_timeout)
|
||||
add_child(_hide_timer)
|
||||
|
||||
func _create_npc_thinking_timer() -> void:
|
||||
_npc_thinking_timer = Timer.new()
|
||||
_npc_thinking_timer.wait_time = 60.0
|
||||
_npc_thinking_timer.one_shot = true
|
||||
_npc_thinking_timer.timeout.connect(_on_npc_thinking_timeout)
|
||||
add_child(_npc_thinking_timer)
|
||||
|
||||
# 开始隐藏倒计时
|
||||
func _start_hide_timer() -> void:
|
||||
if _is_typing:
|
||||
@@ -508,6 +679,17 @@ func _send_input_message(show_bubble: bool) -> void:
|
||||
|
||||
# 清空输入框
|
||||
chat_input.clear()
|
||||
if not _npc_target_id.is_empty() and _current_tab == TAB_WHISPER:
|
||||
_add_message_data({
|
||||
"from_user": _current_username,
|
||||
"content": content,
|
||||
"timestamp": Time.get_unix_time_from_system(),
|
||||
"is_self": true,
|
||||
"scope": "private",
|
||||
"private_context": TAB_WHISPER,
|
||||
"npc_id": _npc_target_id,
|
||||
})
|
||||
_show_npc_thinking()
|
||||
|
||||
# 发送后延迟重新聚焦,避免被 LineEdit 的提交事件在同一帧内抢走焦点
|
||||
call_deferred("_focus_input_after_send")
|
||||
@@ -565,6 +747,8 @@ func _subscribe_to_events() -> void:
|
||||
|
||||
# 订阅近身悄悄话目标选择事件
|
||||
eventSystem.call("connect_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, _on_private_target_selected, self)
|
||||
eventSystem.call("connect_event", EventNames.NPC_SPOKE, _on_npc_spoke, self)
|
||||
eventSystem.call("connect_event", EventNames.NPC_INTERACTION_ERROR, _on_npc_interaction_error, self)
|
||||
|
||||
# 订阅右下角好友列表的好友选择事件
|
||||
eventSystem.call("connect_event", EventNames.CHAT_FRIEND_SELECTED, _on_friend_selected, self)
|
||||
@@ -611,9 +795,83 @@ func _on_chat_error(data: Dictionary) -> void:
|
||||
if _current_tab == TAB_FRIENDS:
|
||||
_render_friend_conversation_header()
|
||||
return
|
||||
if not _npc_target_id.is_empty() and _current_tab == TAB_WHISPER:
|
||||
_hide_npc_thinking()
|
||||
if not message.strip_edges().is_empty():
|
||||
_add_system_message(message)
|
||||
|
||||
func _on_npc_spoke(data: Dictionary) -> void:
|
||||
var npc_id := str(data.get("npc_id", data.get("npcId", ""))).strip_edges()
|
||||
if _npc_target_id.is_empty() or npc_id != _npc_target_id:
|
||||
return
|
||||
var response := str(data.get("response", "")).strip_edges()
|
||||
if response.is_empty():
|
||||
return
|
||||
_hide_npc_thinking()
|
||||
var session_id := str(data.get("session_id", data.get("sessionId", ""))).strip_edges()
|
||||
if not session_id.is_empty():
|
||||
_npc_session_id = session_id
|
||||
_add_message_data({
|
||||
"from_user": str(data.get("npc_name", data.get("npcName", _npc_target_name))),
|
||||
"content": response,
|
||||
"timestamp": Time.get_unix_time_from_system(),
|
||||
"is_self": false,
|
||||
"scope": "private",
|
||||
"private_context": TAB_WHISPER,
|
||||
"npc_id": npc_id,
|
||||
})
|
||||
|
||||
func _on_npc_interaction_error(data: Dictionary) -> void:
|
||||
var npc_id := str(data.get("npc_id", data.get("npcId", ""))).strip_edges()
|
||||
if not _npc_target_id.is_empty() and not npc_id.is_empty() and npc_id != _npc_target_id:
|
||||
return
|
||||
_hide_npc_thinking()
|
||||
var message := str(data.get("message", "NPC暂时无法回应")).strip_edges()
|
||||
if not message.is_empty():
|
||||
_add_system_message(message)
|
||||
|
||||
func _show_npc_thinking() -> void:
|
||||
_hide_npc_thinking()
|
||||
if _npc_target_id.is_empty() or _current_tab != TAB_WHISPER:
|
||||
return
|
||||
if not is_instance_valid(message_list):
|
||||
return
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.name = "NpcThinkingRow"
|
||||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
message_list.add_child(row)
|
||||
|
||||
var message_node := chat_message_scene.instantiate() as Control
|
||||
if message_node == null:
|
||||
row.queue_free()
|
||||
return
|
||||
row.add_child(message_node)
|
||||
if message_node.has_method("set_message"):
|
||||
message_node.call("set_message", _npc_target_name, "正在思考...", Time.get_unix_time_from_system(), false)
|
||||
if message_node.has_method("set_dialogue_mode"):
|
||||
message_node.call("set_dialogue_mode", _npc_dialogue_mode)
|
||||
message_node.modulate = Color(1.0, 1.0, 1.0, 0.78)
|
||||
_npc_thinking_row = row
|
||||
if is_instance_valid(_npc_thinking_timer):
|
||||
_npc_thinking_timer.start()
|
||||
call_deferred("_scroll_to_bottom")
|
||||
|
||||
func _hide_npc_thinking() -> void:
|
||||
if is_instance_valid(_npc_thinking_timer):
|
||||
_npc_thinking_timer.stop()
|
||||
if is_instance_valid(_npc_thinking_row):
|
||||
_npc_thinking_row.queue_free()
|
||||
_npc_thinking_row = null
|
||||
|
||||
func _on_npc_thinking_timeout() -> void:
|
||||
if not is_instance_valid(_npc_thinking_row):
|
||||
return
|
||||
_hide_npc_thinking()
|
||||
_add_system_message("NPC暂时没有回应,请稍后再试")
|
||||
|
||||
# 处理连接状态变化
|
||||
func _on_connection_state_changed(_data: Dictionary) -> void:
|
||||
# 连接状态变化处理(当前不更新UI)
|
||||
@@ -699,6 +957,8 @@ func _send_current_tab_message(chatManager: Node, content: String, show_bubble:
|
||||
TAB_WORLD:
|
||||
return bool(chatManager.call("send_chat_message", content, "global", show_bubble))
|
||||
TAB_WHISPER:
|
||||
if not _npc_target_id.is_empty():
|
||||
return bool(chatManager.call("interact_with_world_npc", _npc_target_id, content, _npc_session_id))
|
||||
if _whisper_target_user_id.is_empty():
|
||||
_add_system_message("请靠近玩家按 E 发起悄悄话")
|
||||
_send_failure_handled_by_ui = true
|
||||
@@ -734,6 +994,8 @@ func start_whisper(user_id: String, username: String = "") -> void:
|
||||
if normalized_user_id.is_empty():
|
||||
return
|
||||
|
||||
_end_npc_session()
|
||||
_clear_npc_dialogue_target()
|
||||
_whisper_target_user_id = normalized_user_id
|
||||
_whisper_target_username = username.strip_edges()
|
||||
if _whisper_target_username.is_empty():
|
||||
@@ -744,6 +1006,66 @@ func start_whisper(user_id: String, username: String = "") -> void:
|
||||
show_chat(true)
|
||||
call_deferred("_focus_input_after_send")
|
||||
|
||||
func start_npc_whisper(npc_id: String, npc_name: String = "NPC", greeting: String = "") -> void:
|
||||
var normalized_id := npc_id.strip_edges()
|
||||
if normalized_id.is_empty():
|
||||
return
|
||||
_end_npc_session()
|
||||
_clear_npc_dialogue_target()
|
||||
_hide_npc_thinking()
|
||||
_npc_target_id = normalized_id
|
||||
_npc_target_name = npc_name.strip_edges() if not npc_name.strip_edges().is_empty() else "NPC"
|
||||
_npc_session_id = ""
|
||||
_whisper_target_user_id = ""
|
||||
_whisper_target_username = ""
|
||||
select_tab(TAB_WHISPER)
|
||||
_set_npc_dialogue_mode(true, false)
|
||||
show_chat(true)
|
||||
if not greeting.strip_edges().is_empty():
|
||||
_add_message_data({
|
||||
"from_user": _npc_target_name,
|
||||
"content": greeting.strip_edges(),
|
||||
"timestamp": Time.get_unix_time_from_system(),
|
||||
"is_self": false,
|
||||
"scope": "private",
|
||||
"private_context": TAB_WHISPER,
|
||||
"npc_id": _npc_target_id,
|
||||
})
|
||||
call_deferred("_focus_input_after_send")
|
||||
|
||||
func show_npc_dialogue(npc_name: String, text: String) -> void:
|
||||
var normalizedText := text.strip_edges()
|
||||
if normalizedText.is_empty():
|
||||
return
|
||||
_end_npc_session()
|
||||
_clear_npc_dialogue_target()
|
||||
_hide_npc_thinking()
|
||||
_npc_target_name = npc_name.strip_edges() if not npc_name.strip_edges().is_empty() else "NPC"
|
||||
_npc_target_id = "%s%s:%d" % [STATIC_NPC_DIALOGUE_PREFIX, _npc_target_name, Time.get_ticks_msec()]
|
||||
_npc_session_id = ""
|
||||
_whisper_target_user_id = ""
|
||||
_whisper_target_username = ""
|
||||
select_tab(TAB_WHISPER)
|
||||
_set_npc_dialogue_mode(true, true)
|
||||
show_chat(true)
|
||||
_add_message_data({
|
||||
"from_user": _npc_target_name,
|
||||
"content": normalizedText,
|
||||
"timestamp": Time.get_unix_time_from_system(),
|
||||
"is_self": false,
|
||||
"scope": "private",
|
||||
"private_context": TAB_WHISPER,
|
||||
"npc_id": _npc_target_id,
|
||||
})
|
||||
|
||||
func _end_npc_session() -> void:
|
||||
if _npc_target_id.is_empty() or _npc_session_id.is_empty() or _npc_target_id.begins_with(STATIC_NPC_DIALOGUE_PREFIX):
|
||||
return
|
||||
var chat_manager := _get_chat_manager()
|
||||
if chat_manager != null and chat_manager.has_method("end_world_npc_session"):
|
||||
chat_manager.call("end_world_npc_session", _npc_target_id, _npc_session_id)
|
||||
_npc_session_id = ""
|
||||
|
||||
func add_whisper_target_as_friend() -> bool:
|
||||
if _whisper_target_user_id.is_empty():
|
||||
_add_system_message("请先靠近玩家按 F")
|
||||
@@ -780,6 +1102,8 @@ func select_friend_private_target(user_id: String, username: String = "") -> voi
|
||||
if normalized_user_id.is_empty():
|
||||
return
|
||||
|
||||
_end_npc_session()
|
||||
_clear_npc_dialogue_target()
|
||||
_friend_target_user_id = normalized_user_id
|
||||
_friend_target_username = username.strip_edges()
|
||||
if _friend_target_username.is_empty():
|
||||
@@ -934,15 +1258,23 @@ func _add_message_data(message: Dictionary) -> void:
|
||||
_render_message(message)
|
||||
|
||||
func _render_message(message: Dictionary) -> void:
|
||||
# 如果聊天框隐藏,自动显示
|
||||
# 世界频道消息进入公告 HUD,不自动抢占地图视野;
|
||||
# 私聊、NPC 回复和用户主动发送的消息仍可唤起聊天面板。
|
||||
if not _is_chat_visible:
|
||||
var message_scope := str(message.get("scope", "global")).strip_edges().to_lower()
|
||||
var is_private := bool(message.get("is_private", false)) or message_scope == "private"
|
||||
var is_npc := not str(message.get("npc_id", "")).strip_edges().is_empty()
|
||||
# 系统存在/欢迎消息也属于世界公告,不应自动打开旧聊天窗口。
|
||||
# 只有私聊和 NPC 会话需要在收到回复时主动唤起聊天 UI。
|
||||
if not is_private and not is_npc:
|
||||
return
|
||||
show_chat()
|
||||
|
||||
# 每条消息用一行容器包起来,方便左右对齐且不挤在一起
|
||||
var row := HBoxContainer.new()
|
||||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
row.alignment = BoxContainer.ALIGNMENT_END if bool(message.get("is_self", false)) else BoxContainer.ALIGNMENT_BEGIN
|
||||
row.alignment = BoxContainer.ALIGNMENT_BEGIN if _npc_dialogue_mode else (BoxContainer.ALIGNMENT_END if bool(message.get("is_self", false)) else BoxContainer.ALIGNMENT_BEGIN)
|
||||
|
||||
# 创建消息节点
|
||||
var message_node: Control = chat_message_scene.instantiate() as Control
|
||||
@@ -962,6 +1294,8 @@ func _render_message(message: Dictionary) -> void:
|
||||
float(message.get("timestamp", 0.0)),
|
||||
bool(message.get("is_self", false))
|
||||
)
|
||||
if message_node.has_method("set_dialogue_mode"):
|
||||
message_node.call("set_dialogue_mode", _npc_dialogue_mode)
|
||||
|
||||
# 自动滚动到底部
|
||||
call_deferred("_scroll_to_bottom")
|
||||
@@ -993,6 +1327,9 @@ func _message_matches_current_tab(message: Dictionary) -> bool:
|
||||
return true
|
||||
|
||||
func _private_message_matches_current_tab(message: Dictionary) -> bool:
|
||||
var npc_id := str(message.get("npc_id", message.get("npcId", ""))).strip_edges()
|
||||
if _current_tab == TAB_WHISPER and not _npc_target_id.is_empty():
|
||||
return npc_id == _npc_target_id
|
||||
var scope := str(message.get("scope", "local")).strip_edges().to_lower()
|
||||
var is_private := bool(message.get("is_private", false)) or scope == "private"
|
||||
if not is_private:
|
||||
|
||||
@@ -9,8 +9,8 @@ extends Control
|
||||
const LINE_COLOR: Color = Color(0.592157, 0.72549, 0.835294, 0.82)
|
||||
const DOT_COLOR: Color = Color(0.941176, 0.54902, 0.54902, 0.86)
|
||||
const DOT_BORDER_COLOR: Color = Color(1.0, 1.0, 1.0, 0.95)
|
||||
const LINE_WIDTH: float = 0.85
|
||||
const DETAIL_WIDTH: float = 0.75
|
||||
const LINE_WIDTH: float = 1.15
|
||||
const DETAIL_WIDTH: float = 1.0
|
||||
const BASE_SIZE: float = 27.0
|
||||
|
||||
var iconName: String = "map"
|
||||
@@ -127,17 +127,17 @@ func _draw_polyline(points: Array[Vector2], width: float = LINE_WIDTH) -> void:
|
||||
var packed: PackedVector2Array = []
|
||||
for point in points:
|
||||
packed.append(_p(point))
|
||||
draw_polyline(packed, LINE_COLOR, width, true)
|
||||
draw_polyline(packed, LINE_COLOR, width * _iconScale, false)
|
||||
|
||||
func _draw_red_dot(center: Vector2) -> void:
|
||||
draw_circle(_p(center), 2.3 * _iconScale, DOT_BORDER_COLOR)
|
||||
draw_circle(_p(center), 1.55 * _iconScale, DOT_COLOR)
|
||||
draw_circle(_p(center), 2.3 * _iconScale, DOT_BORDER_COLOR, true, -1.0, false)
|
||||
draw_circle(_p(center), 1.55 * _iconScale, DOT_COLOR, true, -1.0, false)
|
||||
|
||||
func _draw_line(from: Vector2, to: Vector2, width: float) -> void:
|
||||
draw_line(_p(from), _p(to), LINE_COLOR, width, true)
|
||||
draw_line(_p(from), _p(to), LINE_COLOR, width * _iconScale, false)
|
||||
|
||||
func _draw_arc(center: Vector2, radius: float, startAngle: float, endAngle: float, pointCount: int, width: float) -> void:
|
||||
draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, LINE_COLOR, width, true)
|
||||
draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, LINE_COLOR, width * _iconScale, false)
|
||||
|
||||
func _p(point: Vector2) -> Vector2:
|
||||
return _iconOffset + point * _iconScale
|
||||
|
||||
532
scenes/ui/WorldBulletinPanel.gd
Normal file
532
scenes/ui/WorldBulletinPanel.gd
Normal file
@@ -0,0 +1,532 @@
|
||||
extends Control
|
||||
|
||||
## Lightweight public announcement HUD.
|
||||
## This panel owns only the public feed; private conversations remain in ChatUI.
|
||||
|
||||
const PANEL_WIDTH := 560.0
|
||||
const COLLAPSED_HEIGHT := 68.0
|
||||
const EXPANDED_HEIGHT := 492.0
|
||||
const EDGE_MARGIN := 26.0
|
||||
const TEXT_COLOR := Color("24476f")
|
||||
const MUTED_COLOR := Color("7894b2")
|
||||
const ACCENT_COLOR := Color("2d94ed")
|
||||
const SURFACE_COLOR := Color(0.985, 0.995, 1.0, 0.97)
|
||||
const LINE_COLOR := Color("d9e8f7")
|
||||
const MAX_ITEMS := 24
|
||||
|
||||
const ICON_EMPTY := preload("res://assets/ui/world_bulletin/community/empty_whale.png")
|
||||
const ICON_BROADCAST := preload("res://assets/ui/world_bulletin/community/world_bulletin_icon_hd_simple_tight.png")
|
||||
const ICON_BANNER := preload("res://assets/ui/world_bulletin/community/town_banner.png")
|
||||
const ICON_PIN := preload("res://assets/ui/world_bulletin/community/pinned_note.png")
|
||||
const ICON_RECRUIT := preload("res://assets/ui/world_bulletin/community/recruit_whales.png")
|
||||
const ICON_SEND_BUTTON := preload("res://assets/ui/world_bulletin/world_bulletin_send_button_final_192.png")
|
||||
|
||||
var _panel: PanelContainer
|
||||
var _surface_root: VBoxContainer
|
||||
var _ticker_row: HBoxContainer
|
||||
var _ticker: Button
|
||||
var _expand_button: Button
|
||||
var _content: VBoxContainer
|
||||
var _header_title: Label
|
||||
var _header_icon: TextureRect
|
||||
var _header_banner: TextureRect
|
||||
var _entries: VBoxContainer
|
||||
var _scroll: ScrollContainer
|
||||
var _composer: LineEdit
|
||||
var _send_button: Button
|
||||
var _expanded := false
|
||||
var _announcements: Array[Dictionary] = []
|
||||
var _unread := 0
|
||||
var _last_preview := "暂无新的世界公告"
|
||||
var _publish_pending := false
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_build_ui()
|
||||
_seed_preview_items()
|
||||
_subscribe_to_events()
|
||||
_set_expanded(true, true)
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var event_system := get_node_or_null("/root/EventSystem")
|
||||
if event_system != null:
|
||||
event_system.call("disconnect_event", EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
|
||||
event_system.call("disconnect_event", EventNames.CHAT_MESSAGE_SENT, _on_chat_message_sent, self)
|
||||
event_system.call("disconnect_event", EventNames.CHAT_ERROR_OCCURRED, _on_chat_error, self)
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey:
|
||||
var key_event := event as InputEventKey
|
||||
if key_event.pressed and not key_event.echo and key_event.keycode == KEY_B and not _composer.has_focus():
|
||||
_set_expanded(not _expanded)
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
func _build_ui() -> void:
|
||||
_panel = PanelContainer.new()
|
||||
_panel.name = "WorldBulletinSurface"
|
||||
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_panel.add_theme_stylebox_override("panel", _surface_style())
|
||||
add_child(_panel)
|
||||
_panel.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
|
||||
_panel.offset_left = EDGE_MARGIN
|
||||
_panel.offset_top = -EDGE_MARGIN - COLLAPSED_HEIGHT
|
||||
_panel.offset_right = EDGE_MARGIN + PANEL_WIDTH
|
||||
_panel.offset_bottom = -EDGE_MARGIN
|
||||
_panel.grow_horizontal = Control.GROW_DIRECTION_END
|
||||
_panel.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
||||
_surface_root = VBoxContainer.new()
|
||||
_surface_root.name = "SurfaceContent"
|
||||
_surface_root.add_theme_constant_override("separation", 0)
|
||||
_surface_root.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_surface_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_panel.add_child(_surface_root)
|
||||
|
||||
_ticker_row = HBoxContainer.new()
|
||||
_ticker_row.name = "CollapsedTickerRow"
|
||||
_ticker_row.custom_minimum_size = Vector2(PANEL_WIDTH, COLLAPSED_HEIGHT)
|
||||
_ticker_row.add_theme_constant_override("separation", 4)
|
||||
_ticker_row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_surface_root.add_child(_ticker_row)
|
||||
|
||||
_ticker = Button.new()
|
||||
_ticker.name = "CollapsedTicker"
|
||||
_ticker.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_ticker.custom_minimum_size = Vector2(0, COLLAPSED_HEIGHT)
|
||||
_ticker.focus_mode = Control.FOCUS_NONE
|
||||
_ticker.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
_ticker.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
_ticker.add_theme_font_size_override("font_size", 20)
|
||||
_ticker.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
_ticker.add_theme_color_override("font_hover_color", TEXT_COLOR)
|
||||
_ticker.add_theme_stylebox_override("normal", _empty_style())
|
||||
_ticker.add_theme_stylebox_override("hover", _hover_style())
|
||||
_ticker.add_theme_stylebox_override("pressed", _pressed_style())
|
||||
_ticker.pressed.connect(func() -> void: _set_expanded(not _expanded))
|
||||
_ticker_row.add_child(_ticker)
|
||||
|
||||
_expand_button = Button.new()
|
||||
_expand_button.name = "ExpandButton"
|
||||
_expand_button.text = "展开"
|
||||
_expand_button.custom_minimum_size = Vector2(72, COLLAPSED_HEIGHT)
|
||||
_expand_button.focus_mode = Control.FOCUS_NONE
|
||||
_expand_button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
_expand_button.add_theme_font_size_override("font_size", 18)
|
||||
_expand_button.add_theme_color_override("font_color", ACCENT_COLOR)
|
||||
_expand_button.add_theme_color_override("font_hover_color", TEXT_COLOR)
|
||||
_expand_button.add_theme_stylebox_override("normal", _empty_style())
|
||||
_expand_button.add_theme_stylebox_override("hover", _hover_style())
|
||||
_expand_button.add_theme_stylebox_override("pressed", _pressed_style())
|
||||
_expand_button.pressed.connect(func() -> void: _set_expanded(true))
|
||||
_ticker_row.add_child(_expand_button)
|
||||
|
||||
_content = VBoxContainer.new()
|
||||
_content.name = "ExpandedContent"
|
||||
_content.add_theme_constant_override("separation", 0)
|
||||
_content.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_surface_root.add_child(_content)
|
||||
|
||||
var header := HBoxContainer.new()
|
||||
header.name = "Header"
|
||||
header.custom_minimum_size = Vector2(0, 74)
|
||||
header.add_theme_constant_override("separation", 12)
|
||||
header.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_content.add_child(header)
|
||||
|
||||
_header_icon = TextureRect.new()
|
||||
_header_icon.custom_minimum_size = Vector2(56, 56)
|
||||
_header_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
_header_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
_header_icon.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||||
_header_icon.texture = ICON_BROADCAST
|
||||
_header_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
header.add_child(_header_icon)
|
||||
|
||||
_header_title = Label.new()
|
||||
_header_title.text = "世界公告"
|
||||
_header_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_header_title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_header_title.add_theme_font_size_override("font_size", 26)
|
||||
_header_title.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
_header_title.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
header.add_child(_header_title)
|
||||
|
||||
_header_banner = TextureRect.new()
|
||||
_header_banner.name = "TownBanner"
|
||||
_header_banner.custom_minimum_size = Vector2(88, 58)
|
||||
_header_banner.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
_header_banner.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
_header_banner.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||||
_header_banner.texture = ICON_BANNER
|
||||
_header_banner.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
header.add_child(_header_banner)
|
||||
|
||||
var close_button := Button.new()
|
||||
close_button.text = "×"
|
||||
close_button.tooltip_text = "收起世界公告"
|
||||
close_button.custom_minimum_size = Vector2(54, 54)
|
||||
close_button.focus_mode = Control.FOCUS_NONE
|
||||
close_button.add_theme_font_size_override("font_size", 34)
|
||||
close_button.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
close_button.add_theme_color_override("font_hover_color", ACCENT_COLOR)
|
||||
close_button.add_theme_stylebox_override("normal", _empty_style())
|
||||
close_button.add_theme_stylebox_override("hover", _hover_style())
|
||||
close_button.add_theme_stylebox_override("pressed", _pressed_style())
|
||||
close_button.pressed.connect(func() -> void: _set_expanded(false))
|
||||
header.add_child(close_button)
|
||||
|
||||
var header_rule := ColorRect.new()
|
||||
header_rule.custom_minimum_size = Vector2(0, 1)
|
||||
header_rule.color = LINE_COLOR
|
||||
header_rule.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_content.add_child(header_rule)
|
||||
|
||||
_scroll = ScrollContainer.new()
|
||||
_scroll.name = "AnnouncementScroll"
|
||||
_scroll.custom_minimum_size = Vector2(0, 320)
|
||||
_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
_scroll.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_content.add_child(_scroll)
|
||||
|
||||
_entries = VBoxContainer.new()
|
||||
_entries.name = "AnnouncementEntries"
|
||||
_entries.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_entries.add_theme_constant_override("separation", 0)
|
||||
_scroll.add_child(_entries)
|
||||
|
||||
var composer_row := HBoxContainer.new()
|
||||
composer_row.name = "Composer"
|
||||
composer_row.custom_minimum_size = Vector2(0, 72)
|
||||
composer_row.add_theme_constant_override("separation", 10)
|
||||
_content.add_child(composer_row)
|
||||
|
||||
_composer = LineEdit.new()
|
||||
_composer.name = "AnnouncementInput"
|
||||
_composer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_composer.placeholder_text = "发布世界公告(100鲸币)..."
|
||||
_composer.max_length = 160
|
||||
_composer.add_theme_font_size_override("font_size", 20)
|
||||
_composer.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
_composer.add_theme_color_override("font_placeholder_color", MUTED_COLOR)
|
||||
_composer.add_theme_stylebox_override("normal", _input_style())
|
||||
_composer.add_theme_stylebox_override("focus", _input_focus_style())
|
||||
_composer.text_submitted.connect(func(_value: String) -> void: _publish())
|
||||
composer_row.add_child(_composer)
|
||||
|
||||
_send_button = Button.new()
|
||||
_send_button.name = "PublishButton"
|
||||
_send_button.text = ""
|
||||
_send_button.icon = ICON_SEND_BUTTON
|
||||
_send_button.expand_icon = true
|
||||
_send_button.icon_max_width = 56
|
||||
_send_button.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||||
_send_button.tooltip_text = "发布世界公告"
|
||||
_send_button.custom_minimum_size = Vector2(56, 56)
|
||||
_send_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
_send_button.focus_mode = Control.FOCUS_NONE
|
||||
_send_button.add_theme_color_override("icon_normal_color", Color.WHITE)
|
||||
_send_button.add_theme_color_override("icon_hover_color", Color("eef9ff"))
|
||||
_send_button.add_theme_color_override("icon_pressed_color", Color("bedcf5"))
|
||||
_send_button.add_theme_stylebox_override("normal", _empty_style())
|
||||
_send_button.add_theme_stylebox_override("hover", _empty_style())
|
||||
_send_button.add_theme_stylebox_override("pressed", _empty_style())
|
||||
_send_button.add_theme_stylebox_override("focus", _empty_style())
|
||||
_send_button.pressed.connect(_publish)
|
||||
composer_row.add_child(_send_button)
|
||||
|
||||
func _subscribe_to_events() -> void:
|
||||
var event_system := get_node_or_null("/root/EventSystem")
|
||||
if event_system == null:
|
||||
return
|
||||
event_system.call("connect_event", EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
|
||||
event_system.call("connect_event", EventNames.CHAT_MESSAGE_SENT, _on_chat_message_sent, self)
|
||||
event_system.call("connect_event", EventNames.CHAT_ERROR_OCCURRED, _on_chat_error, self)
|
||||
|
||||
func _on_chat_message_sent(data: Dictionary) -> void:
|
||||
if not _publish_pending or not bool(data.get("world_bulletin", false)):
|
||||
return
|
||||
_composer.clear()
|
||||
_set_publish_pending(false)
|
||||
|
||||
func _on_chat_error(data: Dictionary) -> void:
|
||||
if not _publish_pending or not bool(data.get("world_bulletin", false)):
|
||||
return
|
||||
_set_publish_pending(false)
|
||||
_composer.grab_focus()
|
||||
|
||||
func _set_publish_pending(pending: bool) -> void:
|
||||
_publish_pending = pending
|
||||
_composer.editable = not pending
|
||||
_send_button.disabled = pending
|
||||
|
||||
func _seed_preview_items() -> void:
|
||||
_announcements = [
|
||||
{"sender": "小海豚", "time": "12:08", "content": "今晚海湾烟花节 20:00 开始", "category": "活动", "pinned": true},
|
||||
{"sender": "珊珊酱", "time": "12:12", "content": "需要 2 名采集伙伴一起出海", "category": "招募"},
|
||||
{"sender": "海盐", "time": "12:20", "content": "新地图补给点已刷新", "category": "通知"},
|
||||
{"sender": "系统", "time": "12:30", "content": "世界频道维护将在 15 分钟后结束", "category": "通知"}
|
||||
]
|
||||
_last_preview = str(_announcements[0].get("content", _last_preview))
|
||||
_render_entries()
|
||||
|
||||
func _on_chat_message_received(data: Dictionary) -> void:
|
||||
# 只展示后端确认并已扣费的世界公告,普通全局聊天不进公告栏。
|
||||
if not bool(data.get("world_bulletin", data.get("worldBulletin", false))):
|
||||
return
|
||||
var scope := str(data.get("scope", data.get("channel", "global"))).to_lower()
|
||||
var tab := str(data.get("tab", "world")).to_lower()
|
||||
if scope in ["private", "whisper"] or tab in ["private", "whisper", "friends"]:
|
||||
return
|
||||
var content := str(data.get("content", data.get("message", ""))).strip_edges()
|
||||
if content.is_empty():
|
||||
return
|
||||
var sender := str(data.get("from_user", data.get("username", "玩家"))).strip_edges()
|
||||
if sender.is_empty():
|
||||
sender = "玩家"
|
||||
var category := "通知"
|
||||
if sender == "系统" or str(data.get("sender_type", "")).to_lower() == "system":
|
||||
category = "通知"
|
||||
_announcements.push_front({
|
||||
"sender": sender,
|
||||
"time": Time.get_time_string_from_system().left(5),
|
||||
"content": content,
|
||||
"category": category
|
||||
})
|
||||
while _announcements.size() > MAX_ITEMS:
|
||||
_announcements.pop_back()
|
||||
_last_preview = content
|
||||
if not _expanded:
|
||||
_unread += 1
|
||||
_render_entries()
|
||||
_update_ticker()
|
||||
|
||||
func _set_expanded(expanded: bool, immediate := false) -> void:
|
||||
_expanded = expanded
|
||||
if _expanded:
|
||||
_unread = 0
|
||||
_panel.custom_minimum_size = Vector2(PANEL_WIDTH, EXPANDED_HEIGHT)
|
||||
_panel.offset_top = -EDGE_MARGIN - EXPANDED_HEIGHT
|
||||
_ticker_row.hide()
|
||||
_content.show()
|
||||
_render_entries()
|
||||
else:
|
||||
_panel.custom_minimum_size = Vector2(PANEL_WIDTH, COLLAPSED_HEIGHT)
|
||||
_panel.offset_top = -EDGE_MARGIN - COLLAPSED_HEIGHT
|
||||
_content.hide()
|
||||
_ticker_row.show()
|
||||
_update_ticker()
|
||||
if immediate:
|
||||
_panel.modulate.a = 1.0
|
||||
|
||||
func _update_ticker() -> void:
|
||||
if not is_instance_valid(_ticker):
|
||||
return
|
||||
var unread_text := " · %d" % _unread if _unread > 0 else ""
|
||||
_ticker.text = " 世界公告 %s%s" % [_last_preview.left(25), unread_text]
|
||||
|
||||
func _render_entries() -> void:
|
||||
if not is_instance_valid(_entries):
|
||||
return
|
||||
for child in _entries.get_children():
|
||||
child.queue_free()
|
||||
if _announcements.is_empty():
|
||||
var empty := VBoxContainer.new()
|
||||
empty.custom_minimum_size = Vector2(0, 210)
|
||||
empty.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
_entries.add_child(empty)
|
||||
var whale := TextureRect.new()
|
||||
whale.texture = ICON_EMPTY
|
||||
whale.custom_minimum_size = Vector2(0, 92)
|
||||
whale.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
whale.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
whale.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||||
whale.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
empty.add_child(whale)
|
||||
var empty_label := Label.new()
|
||||
empty_label.text = "暂无公告"
|
||||
empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
empty_label.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
empty.add_child(empty_label)
|
||||
return
|
||||
for item in _announcements:
|
||||
_entries.add_child(_build_entry(item))
|
||||
|
||||
func _build_entry(item: Dictionary) -> Control:
|
||||
var card := PanelContainer.new()
|
||||
card.custom_minimum_size = Vector2(0, 92)
|
||||
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
card.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var card_style := StyleBoxFlat.new()
|
||||
card_style.bg_color = Color(1, 1, 1, 0.55)
|
||||
card_style.border_color = LINE_COLOR
|
||||
card_style.set_border_width_all(1)
|
||||
card_style.set_corner_radius_all(10)
|
||||
card.add_theme_stylebox_override("panel", card_style)
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 10)
|
||||
margin.add_theme_constant_override("margin_top", 7)
|
||||
margin.add_theme_constant_override("margin_right", 10)
|
||||
margin.add_theme_constant_override("margin_bottom", 7)
|
||||
card.add_child(margin)
|
||||
|
||||
var card_row := HBoxContainer.new()
|
||||
card_row.add_theme_constant_override("separation", 8)
|
||||
card_row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
margin.add_child(card_row)
|
||||
|
||||
var row := VBoxContainer.new()
|
||||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.add_theme_constant_override("separation", 2)
|
||||
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
card_row.add_child(row)
|
||||
|
||||
var meta := HBoxContainer.new()
|
||||
meta.custom_minimum_size = Vector2(0, 30)
|
||||
meta.add_theme_constant_override("separation", 6)
|
||||
meta.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
row.add_child(meta)
|
||||
|
||||
if bool(item.get("pinned", false)):
|
||||
var pin := TextureRect.new()
|
||||
pin.custom_minimum_size = Vector2(28, 28)
|
||||
pin.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
pin.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
pin.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||||
pin.texture = ICON_PIN
|
||||
pin.tooltip_text = "置顶公告"
|
||||
pin.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
meta.add_child(pin)
|
||||
|
||||
var sender := Label.new()
|
||||
sender.text = str(item.get("sender", "玩家"))
|
||||
sender.add_theme_font_size_override("font_size", 20)
|
||||
sender.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
meta.add_child(sender)
|
||||
|
||||
var category := Label.new()
|
||||
category.text = str(item.get("category", "通知"))
|
||||
category.add_theme_font_size_override("font_size", 16)
|
||||
category.add_theme_color_override("font_color", Color.WHITE)
|
||||
category.add_theme_stylebox_override("normal", _category_style(category.text))
|
||||
meta.add_child(category)
|
||||
|
||||
if str(item.get("category", "")) == "招募":
|
||||
var recruit := TextureRect.new()
|
||||
recruit.custom_minimum_size = Vector2(32, 28)
|
||||
recruit.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
recruit.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
recruit.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||||
recruit.texture = ICON_RECRUIT
|
||||
recruit.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
meta.add_child(recruit)
|
||||
|
||||
var time := Label.new()
|
||||
time.text = str(item.get("time", ""))
|
||||
time.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
time.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
time.add_theme_font_size_override("font_size", 16)
|
||||
time.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
meta.add_child(time)
|
||||
|
||||
var body := Label.new()
|
||||
body.text = str(item.get("content", ""))
|
||||
body.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
body.custom_minimum_size = Vector2(0, 30)
|
||||
body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
body.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
body.add_theme_font_size_override("font_size", 20)
|
||||
body.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
body.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
row.add_child(body)
|
||||
|
||||
return card
|
||||
|
||||
func _category_style(category: String) -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.set_corner_radius_all(6)
|
||||
style.content_margin_left = 7
|
||||
style.content_margin_right = 7
|
||||
style.content_margin_top = 3
|
||||
style.content_margin_bottom = 3
|
||||
match category:
|
||||
"活动": style.bg_color = Color("55bd8a")
|
||||
"招募": style.bg_color = Color("f0aa4c")
|
||||
_: style.bg_color = ACCENT_COLOR
|
||||
return style
|
||||
|
||||
func _publish() -> void:
|
||||
if _publish_pending:
|
||||
return
|
||||
var content := _composer.text.strip_edges()
|
||||
if content.is_empty():
|
||||
return
|
||||
var manager := get_node_or_null("/root/ChatManager")
|
||||
if manager == null or not manager.has_method("send_world_bulletin"):
|
||||
return
|
||||
if not bool(manager.call("send_world_bulletin", content)):
|
||||
return
|
||||
_set_publish_pending(true)
|
||||
_set_expanded(true)
|
||||
|
||||
func _surface_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = SURFACE_COLOR
|
||||
style.border_color = Color("b8d9f4")
|
||||
style.set_border_width_all(2)
|
||||
style.set_corner_radius_all(18)
|
||||
style.shadow_color = Color(0.16, 0.40, 0.65, 0.18)
|
||||
style.shadow_size = 10
|
||||
style.shadow_offset = Vector2(0, 4)
|
||||
style.content_margin_left = 14
|
||||
style.content_margin_top = 12
|
||||
style.content_margin_right = 14
|
||||
style.content_margin_bottom = 12
|
||||
return style
|
||||
|
||||
func _empty_style() -> StyleBoxEmpty:
|
||||
return StyleBoxEmpty.new()
|
||||
|
||||
func _hover_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.88, 0.95, 1.0, 0.85)
|
||||
style.set_corner_radius_all(12)
|
||||
return style
|
||||
|
||||
func _pressed_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.78, 0.91, 1.0, 0.9)
|
||||
style.set_corner_radius_all(12)
|
||||
return style
|
||||
|
||||
func _input_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(1, 1, 1, 0.9)
|
||||
style.border_color = Color("c4def4")
|
||||
style.set_border_width_all(1)
|
||||
style.set_corner_radius_all(12)
|
||||
style.content_margin_left = 10
|
||||
style.content_margin_right = 10
|
||||
return style
|
||||
|
||||
func _input_focus_style() -> StyleBoxFlat:
|
||||
var style := _input_style()
|
||||
style.border_color = ACCENT_COLOR
|
||||
style.set_border_width_all(2)
|
||||
return style
|
||||
|
||||
func _accent_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = ACCENT_COLOR
|
||||
style.set_corner_radius_all(14)
|
||||
return style
|
||||
|
||||
func _accent_hover_style() -> StyleBoxFlat:
|
||||
var style := _accent_style()
|
||||
style.bg_color = Color("53a8f4")
|
||||
return style
|
||||
|
||||
func _accent_pressed_style() -> StyleBoxFlat:
|
||||
var style := _accent_style()
|
||||
style.bg_color = Color("247bc7")
|
||||
return style
|
||||
1
scenes/ui/WorldBulletinPanel.gd.uid
Normal file
1
scenes/ui/WorldBulletinPanel.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d1dka1huar71o
|
||||
13
scenes/ui/WorldBulletinPanel.tscn
Normal file
13
scenes/ui/WorldBulletinPanel.tscn
Normal file
@@ -0,0 +1,13 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/ui/WorldBulletinPanel.gd" id="1_world_bulletin"]
|
||||
|
||||
[node name="WorldBulletinPanel" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_world_bulletin")
|
||||
Reference in New Issue
Block a user