feat: expand multiplayer, chat, and release support

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

View File

@@ -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