Merge pull request 'feature/whaletown-developer-extra-feature' (#19) from feature/whaletown-developer-extra-feature into main

Reviewed-on: #19
This commit was merged in pull request #19.
This commit is contained in:
2026-03-11 18:22:59 +08:00
23 changed files with 508 additions and 93 deletions

View File

@@ -6,6 +6,7 @@
}, },
"network": { "network": {
"api_base_url": "https://whaletownend.xinghangee.icu", "api_base_url": "https://whaletownend.xinghangee.icu",
"location_ws_url": "wss://whaletownend.xinghangee.icu/game",
"timeout": 30, "timeout": 30,
"retry_count": 3 "retry_count": 3
}, },

View File

@@ -126,6 +126,10 @@ var _game_token: String = ""
const SELF_ECHO_DEDUPE_WINDOW: float = 10.0 const SELF_ECHO_DEDUPE_WINDOW: float = 10.0
var _pending_self_messages: Array[Dictionary] = [] var _pending_self_messages: Array[Dictionary] = []
# 空消息类型告警限频(避免日志刷屏)
const EMPTY_MESSAGE_TYPE_WARNING_INTERVAL: float = 10.0
var _last_empty_message_type_warning_at: float = -1000.0
# ============================================================================ # ============================================================================
# 生命周期方法 # 生命周期方法
# ============================================================================ # ============================================================================
@@ -489,12 +493,22 @@ func _on_data_received(message: String) -> void:
push_error("ChatManager: JSON 解析失败") push_error("ChatManager: JSON 解析失败")
return return
var data: Dictionary = json.data var data_variant: Variant = json.data
if not (data_variant is Dictionary):
push_warning("ChatManager: 收到非对象消息,已忽略")
return
# 检查消息类型字段 var data: Dictionary = data_variant
var message_type: String = data.get("t", "")
# 兼容不同后端字段命名t / type
var message_type: String = str(data.get("t", data.get("type", ""))).strip_edges()
if message_type.is_empty():
_warn_empty_message_type_limited(data)
return
match message_type: match message_type:
"connected":
pass
"login_success": "login_success":
_handle_login_success(data) _handle_login_success(data)
"login_error": "login_error":
@@ -509,9 +523,22 @@ func _on_data_received(message: String) -> void:
_handle_chat_render(data) _handle_chat_render(data)
"position_updated": "position_updated":
_handle_position_updated(data) _handle_position_updated(data)
"error":
_handle_error_response(data)
_: _:
push_warning("ChatManager: 未处理的消息类型 %s" % message_type) push_warning("ChatManager: 未处理的消息类型 %s" % message_type)
func _warn_empty_message_type_limited(data: Dictionary) -> void:
var now: float = Time.get_unix_time_from_system()
if now - _last_empty_message_type_warning_at < EMPTY_MESSAGE_TYPE_WARNING_INTERVAL:
return
_last_empty_message_type_warning_at = now
var payload_preview: String = JSON.stringify(data)
if payload_preview.length() > 180:
payload_preview = payload_preview.substr(0, 180) + "..."
push_warning("ChatManager: 收到未带消息类型的消息,已忽略 payload=%s" % payload_preview)
# 处理登录成功 # 处理登录成功
func _handle_login_success(data: Dictionary) -> void: func _handle_login_success(data: Dictionary) -> void:
_is_logged_in = true _is_logged_in = true

View File

@@ -6,7 +6,11 @@ extends Node
# 负责与后端 WebSocket 服务进行位置同步和多人会话管理 # 负责与后端 WebSocket 服务进行位置同步和多人会话管理
# #
# 协议文档: new_docs/game_architecture_design.md # 协议文档: new_docs/game_architecture_design.md
# 后端地址: wss://whaletownend.xinghangee.icu/location-broadcast # 后端地址默认值: wss://whaletownend.xinghangee.icu/game
# 可通过以下方式覆盖:
# 1) 环境变量 WHALETOWN_LOCATION_WS_URL
# 2) Config/game_config.json 或 config/game_config.json 中的
# network.location_ws_url / network.game_ws_url
# ============================================================================ # ============================================================================
signal connected_to_server() signal connected_to_server()
@@ -17,23 +21,32 @@ signal user_joined(data: Dictionary)
signal user_left(data: Dictionary) signal user_left(data: Dictionary)
signal position_updated(data: Dictionary) signal position_updated(data: Dictionary)
const WS_URL = "wss://whaletownend.xinghangee.icu/location-broadcast" const DEFAULT_WS_URL: String = "wss://whaletownend.xinghangee.icu/game"
const WS_URL_ENV_KEY: String = "WHALETOWN_LOCATION_WS_URL"
const PING_INTERVAL = 25.0 # 秒 const PING_INTERVAL = 25.0 # 秒
var _socket: WebSocketPeer var _socket: WebSocketPeer
var _connected: bool = false var _connected: bool = false
var _ping_timer: float = 0.0 var _ping_timer: float = 0.0
var _auth_token: String = "" var _auth_token: String = ""
var _connection_error_reported: bool = false
var _is_connecting: bool = false
var _ws_url: String = DEFAULT_WS_URL
func _ready(): func _ready():
_socket = WebSocketPeer.new() _socket = WebSocketPeer.new()
process_mode = Node.PROCESS_MODE_ALWAYS # 保证暂停时也能处理网络 process_mode = Node.PROCESS_MODE_ALWAYS # 保证暂停时也能处理网络
_ws_url = _resolve_ws_url()
func _process(delta): func _process(delta):
_socket.poll() _socket.poll()
var state = _socket.get_ready_state() var state = _socket.get_ready_state()
if state == WebSocketPeer.STATE_OPEN: if state == WebSocketPeer.STATE_OPEN:
_connection_error_reported = false
_is_connecting = false
if not _connected: if not _connected:
_on_connected() _on_connected()
@@ -51,19 +64,71 @@ func _process(delta):
elif state == WebSocketPeer.STATE_CLOSED: elif state == WebSocketPeer.STATE_CLOSED:
if _connected: if _connected:
_on_disconnected() _on_disconnected()
elif _is_connecting and not _connection_error_reported:
var close_code := _socket.get_close_code()
var close_reason := _socket.get_close_reason()
push_warning(
"LocationManager: WebSocket 握手失败close_code=%d, reason=%s" % [close_code, close_reason]
)
connection_error.emit()
_connection_error_reported = true
_is_connecting = false
func connect_to_server(): func connect_to_server():
if _socket.get_ready_state() == WebSocketPeer.STATE_OPEN: var state: WebSocketPeer.State = _socket.get_ready_state()
if state == WebSocketPeer.STATE_OPEN or state == WebSocketPeer.STATE_CONNECTING:
return return
var err = _socket.connect_to_url(WS_URL) _connection_error_reported = false
_is_connecting = true
var err = _socket.connect_to_url(_ws_url)
if err != OK: if err != OK:
push_error("LocationManager: WebSocket 连接请求失败,错误码: %d" % err) push_error("LocationManager: WebSocket 连接请求失败,url=%s, 错误码: %d" % [_ws_url, err])
connection_error.emit() connection_error.emit()
_connection_error_reported = true
_is_connecting = false
else: else:
# Godot WebSocket connect is non-blocking, wait for state change in _process # Godot WebSocket connect is non-blocking, wait for state change in _process
pass pass
func _resolve_ws_url() -> String:
var env_url: String = OS.get_environment(WS_URL_ENV_KEY).strip_edges()
if not env_url.is_empty():
return env_url
for config_path in ["res://Config/game_config.json", "res://config/game_config.json"]:
var config_url: String = _load_ws_url_from_config(config_path)
if not config_url.is_empty():
return config_url
return DEFAULT_WS_URL
func _load_ws_url_from_config(config_path: String) -> String:
if not FileAccess.file_exists(config_path):
return ""
var content: String = FileAccess.get_file_as_string(config_path)
if content.is_empty():
return ""
var json := JSON.new()
if json.parse(content) != OK:
push_warning("LocationManager: 读取配置失败 %s - %s" % [config_path, json.get_error_message()])
return ""
var data_variant: Variant = json.data
if not (data_variant is Dictionary):
return ""
var root: Dictionary = data_variant
var network_variant: Variant = root.get("network", {})
if not (network_variant is Dictionary):
return ""
var network_config: Dictionary = network_variant
var ws_url: String = str(network_config.get("location_ws_url", network_config.get("game_ws_url", ""))).strip_edges()
return ws_url
func close_connection(): func close_connection():
_socket.close() _socket.close()

View File

@@ -186,9 +186,9 @@ func set_next_spawn_name(spawn_name: String) -> void:
# - 此方法会清除存储的名称,只能获取一次 # - 此方法会清除存储的名称,只能获取一次
# - 如果未设置名称,返回空字符串 # - 如果未设置名称,返回空字符串
func get_next_spawn_name() -> String: func get_next_spawn_name() -> String:
var name = _next_spawn_name var spawn_name: String = _next_spawn_name
_next_spawn_name = "" _next_spawn_name = ""
return name return spawn_name
# ============ 场景注册方法 ============ # ============ 场景注册方法 ============

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View File

@@ -0,0 +1,7 @@
[gd_resource type="Theme" load_steps=2 format=3]
[ext_resource type="FontFile" uid="uid://ce7ujbeobblyr" path="res://assets/fonts/msyh.ttc" id="1_font"]
[resource]
resource_local_to_scene = true
default_font = ExtResource("1_font")

View File

@@ -10,4 +10,4 @@ scale = Vector2(1.2916666, 1.2812501)
texture = ExtResource("1_xrxds") texture = ExtResource("1_xrxds")
[node name="CollisionPolygon2D" type="CollisionPolygon2D" parent="."] [node name="CollisionPolygon2D" type="CollisionPolygon2D" parent="."]
polygon = PackedVector2Array(-216, -96, -216, -96, 200, -96, 200, -32, 192, -32, 192, 0, 72, 0, 72, -16, 48, -16, 48, -32, 48, -40, -40, -40, -48, -24, -64, -16, -72, -16, -80, 0, -200, 0, -200, -32, -216, -32) polygon = PackedVector2Array(-216, -96, -216, -96, 200, -96, 200, -32, 192, -32, 192, -8, 77, -8, 72, -16, 48, -16, 48, -32, 48, -40, -40, -40, -48, -24, -64, -16, -72, -16, -80, -5, -201, -6, -200, -32, -216, -32)

File diff suppressed because one or more lines are too long

View File

@@ -1,29 +1,55 @@
extends CharacterBody2D extends CharacterBody2D
signal interaction_happened(text) # ============================================================================
# 文件名: NPCController.gd
# 作用: 通用 NPC 控制器,负责角色待机表现与交互对话
#
# 主要功能:
# - 播放 NPC 待机动画
# - 响应玩家射线交互
# - 触发聊天气泡与 NPC 对话事件
#
# 依赖: EventSystem, EventNames, ChatBubble
# 作者: Codex
# 创建时间: 2026-03-10
# ============================================================================
signal interaction_happened(text: String)
const CHAT_BUBBLE_SCENE: PackedScene = preload("res://scenes/ui/ChatBubble.tscn")
@export var npc_name: String = "NPC" @export var npc_name: String = "NPC"
@export var dialogue: String = "欢迎来到WhaleTown我是镇长范鲸晶" @export_multiline var dialogue: String = "欢迎来到WhaleTown我是镇长范鲸晶"
func _ready(): @onready var animation_player: AnimationPlayer = $AnimationPlayer
$Sprite2D.texture = preload("res://assets/characters/npc_286_241.png")
$Sprite2D.hframes = 4 func _ready() -> void:
$Sprite2D.vframes = 4 # 播放场景里配置好的待机动画,让不同 NPC 可以复用同一个控制器。
if animation_player.has_animation("idle"):
# Start Idle Animation animation_player.play("idle")
if has_node("AnimationPlayer"):
$AnimationPlayer.play("idle") # 保持 NPC 可被玩家射线与角色碰撞识别。
collision_layer = 3
# Ensure interaction layer
collision_layer = 3 # Layer 1 & 2 (Blocking)
collision_mask = 3 collision_mask = 3
func interact(): # 处理玩家交互,展示气泡并向全局事件系统广播。
func interact() -> void:
show_bubble(dialogue) show_bubble(dialogue)
EventSystem.emit_event(EventNames.NPC_TALKED, {
"npc": self,
"npc_name": npc_name,
"dialogue": dialogue
})
interaction_happened.emit(dialogue) interaction_happened.emit(dialogue)
return null
func show_bubble(text): # 在 NPC 头顶生成一次性聊天气泡。
var bubble = preload("res://scenes/ui/ChatBubble.tscn").instantiate() #
# 参数:
# text: String - 要展示的对话内容
func show_bubble(text: String) -> void:
var bubble: Control = CHAT_BUBBLE_SCENE.instantiate() as Control
if bubble == null:
return
add_child(bubble) add_child(bubble)
bubble.set_text(text) if bubble.has_method("set_text"):
bubble.call("set_text", text)

View File

@@ -81,6 +81,7 @@ func _handle_movement(_delta: float) -> void:
# 发送移动事件 (如果位置发生明显变化) # 发送移动事件 (如果位置发生明显变化)
if velocity.length() > 0: if velocity.length() > 0:
player_moved.emit(global_position)
EventSystem.emit_event(EventNames.PLAYER_MOVED, { EventSystem.emit_event(EventNames.PLAYER_MOVED, {
"position": global_position "position": global_position
}) })

View File

@@ -0,0 +1,65 @@
[gd_scene load_steps=7 format=3]
[ext_resource type="Texture2D" path="res://assets/characters/crayfish_npc_256_256.png" id="1_texture"]
[ext_resource type="Script" path="res://scenes/characters/NPCController.gd" id="2_script"]
[sub_resource type="RectangleShape2D" id="1_shape"]
size = Vector2(44, 22)
[sub_resource type="Animation" id="2_reset"]
length = 0.001
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Sprite2D:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 1,
"values": [0]
}
[sub_resource type="Animation" id="3_idle"]
resource_name = "idle"
length = 1.2
loop_mode = 1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Sprite2D:frame")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0.0333333, 0.26666665, 0.4666667, 0.8, 1),
"transitions": PackedFloat32Array(1, 1, 1, 1, 1),
"update": 1,
"values": [2, 1, 0, 4, 5]
}
[sub_resource type="AnimationLibrary" id="4_library"]
_data = {
&"RESET": SubResource("2_reset"),
&"idle": SubResource("3_idle")
}
[node name="CrayfishNpc" type="CharacterBody2D"]
script = ExtResource("2_script")
npc_name = "虾小满"
dialogue = "欢迎来到 WhaleTown我是虾小满负责看着喷泉边的水路和码头消息。想找热闹的地方顺着水边走就对啦。"
[node name="Sprite2D" type="Sprite2D" parent="."]
texture = ExtResource("1_texture")
hframes = 4
vframes = 4
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
light_mask = 5
visibility_layer = 5
shape = SubResource("1_shape")
[node name="AnimationPlayer" type="AnimationPlayer" parent="."]
libraries = {
&"": SubResource("4_library")
}

View File

@@ -1,20 +1,35 @@
[gd_scene load_steps=4 format=3 uid="uid://rdmrm7j4iokr"] [gd_scene load_steps=6 format=3 uid="uid://rdmrm7j4iokr"]
[ext_resource type="Script" uid="uid://pnlgf420wktn" path="res://scenes/prefabs/items/NoticeBoard.gd" id="1_script"] [ext_resource type="Script" uid="uid://pnlgf420wktn" path="res://scenes/prefabs/items/NoticeBoard.gd" id="1_script"]
[ext_resource type="Texture2D" uid="uid://b4aildrnhbpl4" path="res://assets/materials/NoticeBoard.png" id="2_sprite"] [ext_resource type="Texture2D" uid="uid://b4aildrnhbpl4" path="res://assets/materials/NoticeBoard.png" id="2_sprite"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_nb"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_left_post"]
size = Vector2(160, 40) size = Vector2(14, 16)
[sub_resource type="RectangleShape2D" id="RectangleShape2D_right_post"]
size = Vector2(14, 16)
[sub_resource type="RectangleShape2D" id="RectangleShape2D_center_bar"]
size = Vector2(125, 4.666666)
[node name="NoticeBoard" type="StaticBody2D"] [node name="NoticeBoard" type="StaticBody2D"]
z_index = 1
scale = Vector2(0.6, 0.6) scale = Vector2(0.6, 0.6)
script = ExtResource("1_script") script = ExtResource("1_script")
[node name="Sprite2D" type="Sprite2D" parent="."] [node name="Sprite2D" type="Sprite2D" parent="."]
position = Vector2(0, -16) position = Vector2(0, -45.3)
scale = Vector2(0.5, 0.5) scale = Vector2(0.5, 0.5)
texture = ExtResource("2_sprite") texture = ExtResource("2_sprite")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."] [node name="LeftPostCollision" type="CollisionShape2D" parent="."]
position = Vector2(0, 6.6666665) position = Vector2(-75, 10)
shape = SubResource("RectangleShape2D_nb") shape = SubResource("RectangleShape2D_left_post")
[node name="RightPostCollision" type="CollisionShape2D" parent="."]
position = Vector2(73.33333, 6.666667)
shape = SubResource("RectangleShape2D_right_post")
[node name="CenterBottomCollision" type="CollisionShape2D" parent="."]
position = Vector2(-2.5, -2.6666667)
shape = SubResource("RectangleShape2D_center_bar")

View File

@@ -1,17 +1,33 @@
[gd_scene load_steps=3 format=3 uid="uid://c7k8yay002w4"] [gd_scene load_steps=5 format=3 uid="uid://c7k8yay002w4"]
[ext_resource type="Texture2D" uid="uid://v7loa3smfkrd" path="res://assets/materials/WelcomeBoard.png" id="2_sprite"] [ext_resource type="Texture2D" uid="uid://v7loa3smfkrd" path="res://assets/materials/WelcomeBoard.png" id="2_sprite"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_board"] [sub_resource type="RectangleShape2D" id="RectangleShape2D_left_post"]
size = Vector2(112, 18.5) size = Vector2(16, 20)
[sub_resource type="RectangleShape2D" id="RectangleShape2D_right_post"]
size = Vector2(16, 20)
[sub_resource type="RectangleShape2D" id="RectangleShape2D_center_bar"]
size = Vector2(76, 4)
[node name="WelcomeBoard" type="StaticBody2D"] [node name="WelcomeBoard" type="StaticBody2D"]
z_index = 1
collision_layer = 3 collision_layer = 3
[node name="Sprite2D" type="Sprite2D" parent="."] [node name="Sprite2D" type="Sprite2D" parent="."]
position = Vector2(0, -44.125)
scale = Vector2(0.25, 0.25) scale = Vector2(0.25, 0.25)
texture = ExtResource("2_sprite") texture = ExtResource("2_sprite")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."] [node name="LeftPostCollision" type="CollisionShape2D" parent="."]
position = Vector2(0, 14.75) position = Vector2(-49, -10)
shape = SubResource("RectangleShape2D_board") shape = SubResource("RectangleShape2D_left_post")
[node name="RightPostCollision" type="CollisionShape2D" parent="."]
position = Vector2(47, -10)
shape = SubResource("RectangleShape2D_right_post")
[node name="CenterBottomCollision" type="CollisionShape2D" parent="."]
position = Vector2(-1, -22)
shape = SubResource("RectangleShape2D_center_bar")

View File

@@ -1,6 +1,7 @@
[gd_scene load_steps=3 format=3] [gd_scene load_steps=4 format=3]
[ext_resource type="Script" uid="uid://b4aorojcbwkmb" path="res://scenes/ui/ChatBubble.gd" id="1_script"] [ext_resource type="Script" uid="uid://b4aorojcbwkmb" path="res://scenes/ui/ChatBubble.gd" id="1_script"]
[ext_resource type="Theme" path="res://assets/ui/world_text_theme.tres" id="2_theme"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bubble_modern"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bubble_modern"]
bg_color = Color(1, 1, 1, 0.9) bg_color = Color(1, 1, 1, 0.9)
@@ -8,6 +9,10 @@ corner_radius_top_left = 5
corner_radius_top_right = 5 corner_radius_top_right = 5
corner_radius_bottom_right = 5 corner_radius_bottom_right = 5
corner_radius_bottom_left = 5 corner_radius_bottom_left = 5
content_margin_left = 10.0
content_margin_top = 6.0
content_margin_right = 10.0
content_margin_bottom = 6.0
shadow_color = Color(0, 0, 0, 0.2) shadow_color = Color(0, 0, 0, 0.2)
shadow_size = 2 shadow_size = 2
@@ -15,6 +20,7 @@ shadow_size = 2
layout_mode = 3 layout_mode = 3
anchors_preset = 0 anchors_preset = 0
script = ExtResource("1_script") script = ExtResource("1_script")
theme = ExtResource("2_theme")
[node name="PanelContainer" type="PanelContainer" parent="."] [node name="PanelContainer" type="PanelContainer" parent="."]
layout_mode = 1 layout_mode = 1
@@ -23,10 +29,10 @@ anchor_left = 0.5
anchor_top = 1.0 anchor_top = 1.0
anchor_right = 0.5 anchor_right = 0.5
anchor_bottom = 1.0 anchor_bottom = 1.0
offset_left = -75.0 offset_left = -120.0
offset_top = -60.0 offset_top = -86.0
offset_right = 75.0 offset_right = 120.0
offset_bottom = -20.0 offset_bottom = -26.0
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 0 grow_vertical = 0
theme_override_styles/panel = SubResource("StyleBoxFlat_bubble_modern") theme_override_styles/panel = SubResource("StyleBoxFlat_bubble_modern")
@@ -34,9 +40,9 @@ theme_override_styles/panel = SubResource("StyleBoxFlat_bubble_modern")
[node name="Label" type="Label" parent="PanelContainer"] [node name="Label" type="Label" parent="PanelContainer"]
layout_mode = 2 layout_mode = 2
theme_override_colors/font_color = Color(0.1, 0.1, 0.1, 1) theme_override_colors/font_color = Color(0.1, 0.1, 0.1, 1)
theme_override_font_sizes/font_size = 8 theme_override_font_sizes/font_size = 12
text = "..." text = "..."
horizontal_alignment = 1 horizontal_alignment = 1
vertical_alignment = 1 vertical_alignment = 1
autowrap_mode = 3 autowrap_mode = 3
custom_minimum_size = Vector2(150, 0) custom_minimum_size = Vector2(220, 0)

View File

@@ -1,7 +1,7 @@
extends Control extends Control
# ============================================================================ # ============================================================================
# ChatUI.gd - 聊天界面控制器(Enter 显示/隐藏版本 # ChatUI.gd - 聊天界面控制器(T 唤起 / Enter 发送
# ============================================================================ # ============================================================================
# 聊天系统的用户界面控制器 # 聊天系统的用户界面控制器
# #
@@ -9,8 +9,10 @@ extends Control
# - 显示聊天消息历史 # - 显示聊天消息历史
# - 处理用户输入 # - 处理用户输入
# - 显示连接状态 # - 显示连接状态
# - Enter 显示/隐藏 + 点击外部取消输入状态 + 5秒自动隐藏 # - T 唤起聊天框Enter 发送消息
# - 只有按 Enter 才会取消倒计时 # - 点击聊天框外部隐藏聊天框
# - 失焦后 5 秒自动隐藏
# - 显示/隐藏带 0.5s 过渡动画
# - Call Down: 通过 EventSystem 订阅聊天事件 # - Call Down: 通过 EventSystem 订阅聊天事件
# #
# 使用方式: # 使用方式:
@@ -46,6 +48,8 @@ extends Control
# 聊天消息场景 # 聊天消息场景
@onready var chat_message_scene: PackedScene = preload("res://scenes/prefabs/ui/ChatMessage.tscn") @onready var chat_message_scene: PackedScene = preload("res://scenes/prefabs/ui/ChatMessage.tscn")
const CHAT_TRANSITION_DURATION: float = 0.5
# ============================================================================ # ============================================================================
# 成员变量 # 成员变量
# ============================================================================ # ============================================================================
@@ -59,6 +63,9 @@ var _hide_timer: Timer = null
# 是否在输入中(输入时不隐藏) # 是否在输入中(输入时不隐藏)
var _is_typing: bool = false var _is_typing: bool = false
# 显示/隐藏过渡动画
var _transition_tween: Tween = null
# 当前用户名 # 当前用户名
var _current_username: String = "" var _current_username: String = ""
@@ -69,7 +76,7 @@ var _current_username: String = ""
# 准备就绪 # 准备就绪
func _ready() -> void: func _ready() -> void:
# 初始隐藏聊天框 # 初始隐藏聊天框
hide_chat() hide_chat(true)
# 创建隐藏计时器 # 创建隐藏计时器
_create_hide_timer() _create_hide_timer()
@@ -80,6 +87,9 @@ func _ready() -> void:
# 连接 UI 信号 # 连接 UI 信号
_connect_ui_signals() _connect_ui_signals()
# 尽可能保持回车提交后输入框继续编辑状态Godot 4.6+ 支持该属性)
_enable_keep_editing_on_submit()
# 清理 # 清理
func _exit_tree() -> void: func _exit_tree() -> void:
# 取消事件订阅 # 取消事件订阅
@@ -93,30 +103,48 @@ func _exit_tree() -> void:
if _hide_timer: if _hide_timer:
_hide_timer.queue_free() _hide_timer.queue_free()
if is_instance_valid(_transition_tween):
_transition_tween.kill()
_transition_tween = null
# ============================================================================ # ============================================================================
# 输入处理 # 输入处理
# ============================================================================ # ============================================================================
# 处理全局输入 # 处理全局输入
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
# 检查是否按下 Enter 键 if not (event is InputEventKey):
if event is InputEventKey and event.pressed and not event.echo and (event.keycode == KEY_ENTER or event.keycode == KEY_KP_ENTER): return
var key_event := event as InputEventKey
if not key_event.pressed or key_event.echo:
return
# T 键用于唤起聊天(输入框聚焦时不拦截)
if key_event.keycode == KEY_T and not chat_input.has_focus():
_handle_t_pressed()
return
# Enter 键用于发送聊天
if key_event.keycode == KEY_ENTER or key_event.keycode == KEY_KP_ENTER:
_handle_enter_pressed() _handle_enter_pressed()
# 处理 T 键按下
func _handle_t_pressed() -> void:
if not _is_chat_visible:
show_chat()
# 延迟聚焦,避免同一输入事件周期冲突
call_deferred("_grab_input_focus")
# 处理 Enter 键按下 # 处理 Enter 键按下
func _handle_enter_pressed() -> void: func _handle_enter_pressed() -> void:
# 如果聊天框未显示,显示它 # 聊天框未显示时不处理 Enter
if not _is_chat_visible: if not _is_chat_visible:
show_chat()
# 使用 call_deferred 避免在同一个事件周期内触发 LineEdit 的 text_submitted 信号
call_deferred("_grab_input_focus")
return return
# 如果聊天框已显示且输入框有焦点,检查输入框内容 # 输入框有焦点时,发送逻辑交给 LineEdit 的 text_submitted 统一处理
if chat_input.has_focus(): if chat_input.has_focus():
# 如果输入框有内容,发送消息
if not chat_input.text.is_empty():
_on_send_button_pressed()
return return
# 如果聊天框已显示但输入框无焦点,重新聚焦(取消倒计时) # 如果聊天框已显示但输入框无焦点,重新聚焦(取消倒计时)
@@ -136,11 +164,12 @@ func _gui_input(event: InputEvent) -> void:
# 处理点击聊天框外部区域 # 处理点击聊天框外部区域
func _handle_click_outside() -> void: func _handle_click_outside() -> void:
if not _is_chat_visible:
return
# 检查点击是否在聊天面板外部 # 检查点击是否在聊天面板外部
if not chat_panel.get_global_rect().has_point(get_global_mouse_position()): if not chat_panel.get_global_rect().has_point(get_global_mouse_position()):
# 延迟释放输入框焦点,避免事件冲突 hide_chat()
if chat_input.has_focus():
call_deferred("_release_input_focus")
# 延迟释放输入框焦点(由 call_deferred 调用) # 延迟释放输入框焦点(由 call_deferred 调用)
func _release_input_focus() -> void: func _release_input_focus() -> void:
@@ -152,20 +181,32 @@ func _release_input_focus() -> void:
# ============================================================================ # ============================================================================
# 显示聊天框 # 显示聊天框
func show_chat() -> void: func show_chat(immediate: bool = false) -> void:
_is_chat_visible = true _is_chat_visible = true
if is_instance_valid(chat_panel):
chat_panel.show()
# 停止隐藏计时器 # 停止隐藏计时器
_stop_hide_timer() _stop_hide_timer()
if not is_instance_valid(chat_panel):
return
_stop_transition_tween()
chat_panel.show()
if immediate:
chat_panel.modulate.a = 1.0
return
chat_panel.modulate.a = 0.0
_transition_tween = create_tween()
_transition_tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
_transition_tween.tween_property(chat_panel, "modulate:a", 1.0, CHAT_TRANSITION_DURATION)
_transition_tween.finished.connect(_on_show_transition_finished)
# 隐藏聊天框 # 隐藏聊天框
func hide_chat() -> void: func hide_chat(immediate: bool = false) -> void:
_is_chat_visible = false _is_chat_visible = false
_is_typing = false _is_typing = false
if is_instance_valid(chat_panel):
chat_panel.hide()
if is_instance_valid(chat_input) and chat_input.has_focus(): if is_instance_valid(chat_input) and chat_input.has_focus():
chat_input.release_focus() chat_input.release_focus()
@@ -173,6 +214,25 @@ func hide_chat() -> void:
# 停止隐藏计时器 # 停止隐藏计时器
_stop_hide_timer() _stop_hide_timer()
if not is_instance_valid(chat_panel):
return
_stop_transition_tween()
if immediate:
chat_panel.hide()
chat_panel.modulate.a = 1.0
return
if not chat_panel.visible:
chat_panel.modulate.a = 1.0
return
_transition_tween = create_tween()
_transition_tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
_transition_tween.tween_property(chat_panel, "modulate:a", 0.0, CHAT_TRANSITION_DURATION)
_transition_tween.finished.connect(_on_hide_transition_finished)
# 创建隐藏计时器 # 创建隐藏计时器
func _create_hide_timer() -> void: func _create_hide_timer() -> void:
_hide_timer = Timer.new() _hide_timer = Timer.new()
@@ -202,6 +262,20 @@ func _stop_hide_timer() -> void:
func _on_hide_timeout() -> void: func _on_hide_timeout() -> void:
hide_chat() hide_chat()
func _on_show_transition_finished() -> void:
_transition_tween = null
func _on_hide_transition_finished() -> void:
_transition_tween = null
if not _is_chat_visible and is_instance_valid(chat_panel):
chat_panel.hide()
chat_panel.modulate.a = 1.0
func _stop_transition_tween() -> void:
if is_instance_valid(_transition_tween):
_transition_tween.kill()
_transition_tween = null
# ============================================================================ # ============================================================================
# UI 事件处理 # UI 事件处理
# ============================================================================ # ============================================================================
@@ -243,12 +317,30 @@ func _on_send_button_pressed() -> void:
# 清空输入框 # 清空输入框
chat_input.clear() chat_input.clear()
# 重新聚焦输入框 # 发送后延迟重新聚焦,避免被 LineEdit 的提交事件在同一帧内抢走焦点
call_deferred("_focus_input_after_send")
func _focus_input_after_send() -> void:
if not _is_chat_visible:
return
if not is_instance_valid(chat_input):
return
chat_input.grab_focus() chat_input.grab_focus()
# 聊天输入提交(回车键)处理 # 聊天输入提交(回车键)处理
func _on_chat_input_submitted(text: String) -> void: func _on_chat_input_submitted(_text: String) -> void:
_on_send_button_pressed() _on_send_button_pressed()
# 即便提交的是空串,也保持输入焦点,便于连续输入
call_deferred("_focus_input_after_send")
func _enable_keep_editing_on_submit() -> void:
if not is_instance_valid(chat_input):
return
for property in chat_input.get_property_list():
if property.get("name", "") == "keep_editing_on_text_submit":
chat_input.set("keep_editing_on_text_submit", true)
return
# ============================================================================ # ============================================================================
# 事件订阅Call Down # 事件订阅Call Down
@@ -290,10 +382,11 @@ func _on_chat_error(data: Dictionary) -> void:
var error_code: String = data.get("error_code", "") var error_code: String = data.get("error_code", "")
var message: String = data.get("message", "") var message: String = data.get("message", "")
push_error("ChatUI: [%s] %s" % [error_code, message]) # 聊天发送失败通常是业务校验(频率/内容)导致,不应按引擎级错误处理。
push_warning("ChatUI: [%s] %s" % [error_code, message])
# 处理连接状态变化 # 处理连接状态变化
func _on_connection_state_changed(data: Dictionary) -> void: func _on_connection_state_changed(_data: Dictionary) -> void:
# 连接状态变化处理当前不更新UI # 连接状态变化处理当前不更新UI
pass pass
@@ -319,7 +412,6 @@ func add_message_to_history(from_user: String, content: String, timestamp: float
# 每条消息用一行容器包起来,方便左右对齐且不挤在一起 # 每条消息用一行容器包起来,方便左右对齐且不挤在一起
var row := HBoxContainer.new() var row := HBoxContainer.new()
row.layout_mode = 2 # 让 VBoxContainer 接管布局,否则会重叠在同一位置
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.size_flags_vertical = Control.SIZE_SHRINK_BEGIN row.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
row.alignment = BoxContainer.ALIGNMENT_END if is_self else BoxContainer.ALIGNMENT_BEGIN row.alignment = BoxContainer.ALIGNMENT_END if is_self else BoxContainer.ALIGNMENT_BEGIN

View File

@@ -32,7 +32,7 @@ var current_page = 0
var tween: Tween var tween: Tween
var mock_pages = [] var mock_pages = []
var _chat_ui: Control var _chat_ui: Control
var _chat_ui_prev_mouse_filter: int = Control.MOUSE_FILTER_STOP var _chat_ui_prev_mouse_filter: Control.MouseFilter = Control.MOUSE_FILTER_STOP
var _chat_ui_mouse_disabled: bool = false var _chat_ui_mouse_disabled: bool = false
func _ready(): func _ready():

View File

@@ -1,7 +1,7 @@
extends CanvasLayer extends CanvasLayer
var _chat_ui: Control var _chat_ui: Control
var _chat_ui_prev_mouse_filter: int = Control.MOUSE_FILTER_STOP var _chat_ui_prev_mouse_filter: Control.MouseFilter = Control.MOUSE_FILTER_STOP
var _chat_ui_mouse_disabled: bool = false var _chat_ui_mouse_disabled: bool = false
func _ready(): func _ready():

View File

@@ -1,6 +1,7 @@
[gd_scene load_steps=3 format=3 uid="uid://rdmro1jxs6ga"] [gd_scene load_steps=4 format=3 uid="uid://rdmro1jxs6ga"]
[ext_resource type="Script" uid="uid://c227m0okmjt2t" path="res://scenes/ui/NoticeDialog.gd" id="1_script"] [ext_resource type="Script" uid="uid://c227m0okmjt2t" path="res://scenes/ui/NoticeDialog.gd" id="1_script"]
[ext_resource type="Theme" path="res://assets/ui/world_text_theme.tres" id="2_theme"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_rounded"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_rounded"]
bg_color = Color(0.95, 0.95, 0.95, 1) bg_color = Color(0.95, 0.95, 0.95, 1)
@@ -33,6 +34,7 @@ grow_vertical = 2
[node name="PanelContainer" type="PanelContainer" parent="CenterContainer"] [node name="PanelContainer" type="PanelContainer" parent="CenterContainer"]
custom_minimum_size = Vector2(480, 420) custom_minimum_size = Vector2(480, 420)
layout_mode = 2 layout_mode = 2
theme = ExtResource("2_theme")
theme_override_styles/panel = SubResource("StyleBoxFlat_rounded") theme_override_styles/panel = SubResource("StyleBoxFlat_rounded")
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer/PanelContainer"] [node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer/PanelContainer"]
@@ -96,13 +98,13 @@ vertical_alignment = 1
[node name="TextPanel" type="MarginContainer" parent="CenterContainer/PanelContainer/VBoxContainer/ContentContainer"] [node name="TextPanel" type="MarginContainer" parent="CenterContainer/PanelContainer/VBoxContainer/ContentContainer"]
layout_mode = 2 layout_mode = 2
size_flags_vertical = 3 size_flags_vertical = 3
theme_override_constants/margin_left = 16 theme_override_constants/margin_left = 20
theme_override_constants/margin_right = 16 theme_override_constants/margin_right = 20
[node name="ContentLabel" type="RichTextLabel" parent="CenterContainer/PanelContainer/VBoxContainer/ContentContainer/TextPanel"] [node name="ContentLabel" type="RichTextLabel" parent="CenterContainer/PanelContainer/VBoxContainer/ContentContainer/TextPanel"]
layout_mode = 2 layout_mode = 2
theme_override_colors/default_color = Color(0.3, 0.3, 0.3, 1) theme_override_colors/default_color = Color(0.3, 0.3, 0.3, 1)
theme_override_font_sizes/normal_font_size = 16 theme_override_font_sizes/normal_font_size = 17
bbcode_enabled = true bbcode_enabled = true
text = "Announcement Content..." text = "Announcement Content..."

View File

@@ -1,7 +1,8 @@
[gd_scene load_steps=5 format=3 uid="uid://d8mam0n1a3b5"] [gd_scene load_steps=6 format=3 uid="uid://d8mam0n1a3b5"]
[ext_resource type="Script" uid="uid://cohijfo0yeo34" path="res://scenes/prefabs/ui/WelcomeDialog.gd" id="1_vs5b1"] [ext_resource type="Script" uid="uid://cohijfo0yeo34" path="res://scenes/ui/WelcomeDialog.gd" id="1_vs5b1"]
[ext_resource type="Texture2D" uid="uid://v7loa3smfkrd" path="res://assets/materials/WelcomeBoard.png" id="2_dy5hw"] [ext_resource type="Texture2D" uid="uid://v7loa3smfkrd" path="res://assets/materials/WelcomeBoard.png" id="2_dy5hw"]
[ext_resource type="Theme" path="res://assets/ui/world_text_theme.tres" id="3_theme"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_card"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_card"]
bg_color = Color(1, 1, 1, 1) bg_color = Color(1, 1, 1, 1)
@@ -44,6 +45,7 @@ grow_vertical = 2
[node name="PanelContainer" type="PanelContainer" parent="CenterContainer"] [node name="PanelContainer" type="PanelContainer" parent="CenterContainer"]
custom_minimum_size = Vector2(400, 350) custom_minimum_size = Vector2(400, 350)
layout_mode = 2 layout_mode = 2
theme = ExtResource("3_theme")
theme_override_styles/panel = SubResource("StyleBoxFlat_card") theme_override_styles/panel = SubResource("StyleBoxFlat_card")
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer/PanelContainer"] [node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer/PanelContainer"]
@@ -90,7 +92,7 @@ stretch_mode = 5
[node name="BodyText" type="Label" parent="CenterContainer/PanelContainer/VBoxContainer"] [node name="BodyText" type="Label" parent="CenterContainer/PanelContainer/VBoxContainer"]
layout_mode = 2 layout_mode = 2
theme_override_colors/font_color = Color(0.3, 0.3, 0.3, 1) theme_override_colors/font_color = Color(0.3, 0.3, 0.3, 1)
theme_override_font_sizes/font_size = 14 theme_override_font_sizes/font_size = 16
text = "连接·共生·见证 text = "连接·共生·见证
Datawhale Town —— 学习者的赛博家园与精神坐标。 Datawhale Town —— 学习者的赛博家园与精神坐标。
✨ 实时广场:看大家都在学什么。 ✨ 实时广场:看大家都在学什么。

View File

@@ -0,0 +1,85 @@
extends SceneTree
# ChatUI 输入交互回归测试(无需 GUT
#
# 运行方式Godot 4.6:
# "D:\technology\biancheng\Godot_v4.6-stable_win64.exe\Godot_v4.6-stable_win64_console.exe" --headless --path . --script tests/unit/test_chat_ui_input_regressions.gd
var _failures: Array[String] = []
func _init() -> void:
call_deferred("_run")
func _run() -> void:
await _test_t_opens_enter_sends_outside_click_hides()
if _failures.is_empty():
print("PASS: test_chat_ui_input_regressions")
quit(0)
return
for failure in _failures:
push_error(failure)
print("FAIL: test_chat_ui_input_regressions (%d)" % _failures.size())
quit(1)
func _test_t_opens_enter_sends_outside_click_hides() -> void:
var packed_scene: PackedScene = load("res://scenes/ui/ChatUI.tscn")
_assert(packed_scene != null, "应能加载 ChatUI.tscn")
if packed_scene == null:
return
var chat_ui = packed_scene.instantiate()
root.add_child(chat_ui)
await process_frame
await process_frame
var chat_panel = chat_ui.chat_panel
var chat_input = chat_ui.chat_input
_assert(not chat_panel.visible, "初始化时聊天框应隐藏")
_assert(not chat_ui._is_chat_visible, "初始化时 _is_chat_visible 应为 false")
var enter_event := InputEventKey.new()
enter_event.pressed = true
enter_event.keycode = KEY_ENTER
chat_ui._input(enter_event)
await process_frame
_assert(not chat_panel.visible, "聊天框隐藏时按 Enter 不应唤起聊天框")
var t_event := InputEventKey.new()
t_event.pressed = true
t_event.keycode = KEY_T
chat_ui._input(t_event)
await create_timer(0.7).timeout
_assert(chat_panel.visible, "按 T 后聊天框应显示")
_assert(chat_ui._is_chat_visible, "按 T 后 _is_chat_visible 应为 true")
_assert(chat_input.has_focus(), "按 T 后输入框应获得焦点")
chat_input.text = "hello world"
chat_ui._on_chat_input_submitted(chat_input.text)
await process_frame
_assert(chat_input.text.is_empty(), "输入框有内容时按 Enter 应触发发送并清空输入框")
_assert(chat_input.has_focus(), "按 Enter 发送后输入框应自动保持焦点,便于连续输入")
# 将鼠标移动到左上角,通常在聊天框外
var viewport := root.get_viewport()
if viewport:
viewport.warp_mouse(Vector2.ZERO)
var mouse_event := InputEventMouseButton.new()
mouse_event.button_index = MOUSE_BUTTON_LEFT
mouse_event.pressed = true
chat_ui._gui_input(mouse_event)
await create_timer(0.7).timeout
_assert(not chat_ui._is_chat_visible, "点击聊天框外部后 _is_chat_visible 应为 false")
_assert(not chat_panel.visible, "点击聊天框外部后聊天框应隐藏")
chat_ui.queue_free()
await process_frame
func _assert(condition: bool, message: String) -> void:
if not condition:
_failures.append(message)

View File

@@ -112,7 +112,7 @@ body {
<script src="index.js"></script> <script src="index.js"></script>
<script> <script>
const GODOT_CONFIG = {"args":[],"canvasResizePolicy":2,"emscriptenPoolSize":8,"ensureCrossOriginIsolationHeaders":true,"executable":"index","experimentalVK":false,"fileSizes":{"index.pck":46883000,"index.wasm":37686550},"focusCanvas":true,"gdextensionLibs":[],"godotPoolSize":4,"serviceWorker":"index.service.worker.js"}; const GODOT_CONFIG = {"args":[],"canvasResizePolicy":2,"emscriptenPoolSize":8,"ensureCrossOriginIsolationHeaders":true,"executable":"index","experimentalVK":false,"fileSizes":{"index.pck":47001952,"index.wasm":37686550},"focusCanvas":true,"gdextensionLibs":[],"godotPoolSize":4,"serviceWorker":"index.service.worker.js"};
const GODOT_THREADS_ENABLED = false; const GODOT_THREADS_ENABLED = false;
const engine = new Engine(GODOT_CONFIG); const engine = new Engine(GODOT_CONFIG);

Binary file not shown.

View File

@@ -4,7 +4,7 @@
// Incrementing CACHE_VERSION will kick off the install event and force // Incrementing CACHE_VERSION will kick off the install event and force
// previously cached resources to be updated from the network. // previously cached resources to be updated from the network.
/** @type {string} */ /** @type {string} */
const CACHE_VERSION = '1773130384|168741112'; const CACHE_VERSION = '1773223762|43101372';
/** @type {string} */ /** @type {string} */
const CACHE_PREFIX = 'whaleTown-sw-cache-'; const CACHE_PREFIX = 'whaleTown-sw-cache-';
const CACHE_NAME = CACHE_PREFIX + CACHE_VERSION; const CACHE_NAME = CACHE_PREFIX + CACHE_VERSION;