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