Files
whale-town-front-v2/scenes/ui/WorldBulletinPanel.gd
xiangwang fc6c3c1bd3 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
2026-09-08 21:37:43 +08:00

533 lines
20 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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