forked from xiangwang25/whale-town-front-v2
feat: expand multiplayer, chat, and release support
- add network NPC synchronization and dialogue interactions - add world bulletin publishing and display - improve authentication, session refresh, and appearance sync - synchronize player direction and movement animations - improve input focus and progressive Web loading - add macOS/Windows builds and deployment configuration - include required fonts, shaders, and runtime assets
This commit is contained in:
@@ -16,11 +16,12 @@ const WORK_ZONE_CAFE_RETURN_POSITION: Vector2 = Vector2(-1093, 560)
|
||||
const CAFE_DOOR_POSITION: Vector2 = Vector2(0, 392)
|
||||
const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres")
|
||||
const COMPANION_NAMEPLATE_RENDER_SCALE: float = 0.5
|
||||
const COMPANION_NAMEPLATE_FONT_SIZE: int = 24
|
||||
const COMPANION_NAMEPLATE_VISUAL_HEIGHT: int = 22
|
||||
const COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH: int = 76
|
||||
const COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH: int = 118
|
||||
const COMPANION_NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12
|
||||
const COMPANION_NAMEPLATE_FONT_SIZE: int = 16
|
||||
const COMPANION_NAMEPLATE_VISUAL_HEIGHT: int = 15
|
||||
const COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH: int = 64
|
||||
const COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH: int = 100
|
||||
const COMPANION_NAMEPLATE_FONT_ZH: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2")
|
||||
const COMPANION_NAMEPLATE_FONT_LATIN: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2")
|
||||
const NPC_NAMEPLATE_OFFSET_Y: float = -136.0
|
||||
const HIRED_PLAYER_NAMEPLATE_OFFSET_Y: float = -96.0
|
||||
|
||||
@@ -36,6 +37,7 @@ const HIRED_PLAYER_NAMEPLATE_OFFSET_Y: float = -96.0
|
||||
|
||||
var _isChangingScene: bool = false
|
||||
var _lastRecruitmentClickMsec: int = 0
|
||||
var _companionNicknameFont: FontFile
|
||||
|
||||
func _ready() -> void:
|
||||
_align_service_occupants()
|
||||
@@ -252,34 +254,20 @@ func _configure_companion_nameplate(label: Label, personaName: String, offsetY:
|
||||
label.clip_text = true
|
||||
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
label.add_theme_color_override("font_color", Color(0.12, 0.20, 0.24, 1.0))
|
||||
label.add_theme_color_override("font_shadow_color", Color(1.0, 1.0, 1.0, 0.85))
|
||||
label.add_theme_constant_override("shadow_offset_x", 0)
|
||||
label.add_theme_constant_override("shadow_offset_y", 1)
|
||||
label.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0))
|
||||
label.add_theme_color_override("font_outline_color", Color(1.0, 0.965, 0.88, 0.98))
|
||||
label.add_theme_constant_override("outline_size", 3)
|
||||
label.add_theme_font_override("font", _get_companion_nickname_font())
|
||||
label.add_theme_font_size_override("font_size", COMPANION_NAMEPLATE_FONT_SIZE)
|
||||
label.add_theme_stylebox_override("normal", _create_companion_nameplate_style())
|
||||
label.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
|
||||
|
||||
func _companion_nameplate_visual_width(displayName: String) -> int:
|
||||
var estimatedWidth := displayName.length() * COMPANION_NAMEPLATE_VISUAL_CHAR_WIDTH + 28
|
||||
return clampi(estimatedWidth, COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH, COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH)
|
||||
var measuredWidth := _get_companion_nickname_font().get_string_size(displayName, HORIZONTAL_ALIGNMENT_LEFT, -1, COMPANION_NAMEPLATE_FONT_SIZE).x
|
||||
var visualWidth := ceili(measuredWidth * COMPANION_NAMEPLATE_RENDER_SCALE + 20.0)
|
||||
return clampi(visualWidth, COMPANION_NAMEPLATE_VISUAL_MIN_WIDTH, COMPANION_NAMEPLATE_VISUAL_MAX_WIDTH)
|
||||
|
||||
func _create_companion_nameplate_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(1.0, 0.988, 0.955, 0.96)
|
||||
style.border_color = Color(0.18, 0.34, 0.38, 0.92)
|
||||
style.border_width_left = 2
|
||||
style.border_width_top = 2
|
||||
style.border_width_right = 2
|
||||
style.border_width_bottom = 2
|
||||
style.corner_radius_top_left = 12
|
||||
style.corner_radius_top_right = 12
|
||||
style.corner_radius_bottom_left = 12
|
||||
style.corner_radius_bottom_right = 12
|
||||
style.content_margin_left = 12
|
||||
style.content_margin_top = 4
|
||||
style.content_margin_right = 12
|
||||
style.content_margin_bottom = 4
|
||||
style.shadow_color = Color(0.05, 0.08, 0.09, 0.18)
|
||||
style.shadow_size = 4
|
||||
style.shadow_offset = Vector2(0, 2)
|
||||
return style
|
||||
func _get_companion_nickname_font() -> FontFile:
|
||||
if _companionNicknameFont == null:
|
||||
_companionNicknameFont = COMPANION_NAMEPLATE_FONT_ZH.duplicate() as FontFile
|
||||
_companionNicknameFont.fallbacks = [COMPANION_NAMEPLATE_FONT_LATIN]
|
||||
return _companionNicknameFont
|
||||
|
||||
@@ -7,6 +7,7 @@ extends Node
|
||||
# ============================================================================
|
||||
|
||||
const REMOTE_PLAYER_SCENE: PackedScene = preload("res://scenes/characters/remote_player.tscn")
|
||||
const NETWORK_NPC_SCENE: PackedScene = preload("res://scenes/characters/network_npc.tscn")
|
||||
const CHAT_BUBBLE_SCENE: PackedScene = preload("res://scenes/ui/ChatBubble.tscn")
|
||||
const DEFAULT_MAP_ID: String = "whale_port"
|
||||
const POSITION_SEND_INTERVAL: float = 0.12
|
||||
@@ -19,19 +20,26 @@ const PRIVATE_CHAT_FORWARD_DOT_THRESHOLD: float = 0.25
|
||||
@export var map_id: String = DEFAULT_MAP_ID
|
||||
@export var local_player_path: NodePath = NodePath("../Characters/Players/Player")
|
||||
@export var remote_players_root_path: NodePath = NodePath("../Characters/Players/RemotePlayers")
|
||||
@export var network_npcs_root_path: NodePath = NodePath("../Characters/Npcs")
|
||||
|
||||
var _local_player: Node2D
|
||||
var _remote_players_root: Node2D
|
||||
var _network_npcs_root: Node2D
|
||||
var _remote_players: Dictionary = {}
|
||||
var _network_npcs: Dictionary = {}
|
||||
var _pending_remote_players: Dictionary = {}
|
||||
var _last_sent_position: Vector2 = Vector2.INF
|
||||
var _last_sent_at_msec: int = 0
|
||||
var _last_sent_direction: String = ""
|
||||
var _last_sent_movement_state: String = ""
|
||||
var _movement_sequence: int = 0
|
||||
var _last_private_chat_interaction_msec: int = 0
|
||||
var _last_friend_request_interaction_msec: int = 0
|
||||
|
||||
func _ready() -> void:
|
||||
_local_player = get_node_or_null(local_player_path) as Node2D
|
||||
_remote_players_root = get_node_or_null(remote_players_root_path) as Node2D
|
||||
_network_npcs_root = get_node_or_null(network_npcs_root_path) as Node2D
|
||||
|
||||
if _local_player == null:
|
||||
push_warning("MapMultiplayerController: local player not found.")
|
||||
@@ -46,6 +54,9 @@ func _ready() -> void:
|
||||
eventSystem.call("connect_event", EventNames.PLAYER_MOVED, _on_local_player_moved, self)
|
||||
eventSystem.call("connect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_chat_login_success, self)
|
||||
eventSystem.call("connect_event", EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, _on_remote_players_snapshot_ready, self)
|
||||
eventSystem.call("connect_event", EventNames.NPC_SNAPSHOT_READY, _on_npc_snapshot_ready, self)
|
||||
eventSystem.call("connect_event", EventNames.NPC_ACTION_STARTED, _on_npc_action_started, self)
|
||||
eventSystem.call("connect_event", EventNames.NPC_ACTION_COMPLETED, _on_npc_action_completed, self)
|
||||
eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_POSITION_UPDATED, _on_remote_player_position_updated, self)
|
||||
eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_JOINED, _on_remote_player_joined, self)
|
||||
eventSystem.call("connect_event", EventNames.REMOTE_PLAYER_LEFT, _on_remote_player_left, self)
|
||||
@@ -78,6 +89,9 @@ func _exit_tree() -> void:
|
||||
eventSystem.call("disconnect_event", EventNames.PLAYER_MOVED, _on_local_player_moved, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_chat_login_success, self)
|
||||
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYERS_SNAPSHOT_READY, _on_remote_players_snapshot_ready, self)
|
||||
eventSystem.call("disconnect_event", EventNames.NPC_SNAPSHOT_READY, _on_npc_snapshot_ready, self)
|
||||
eventSystem.call("disconnect_event", EventNames.NPC_ACTION_STARTED, _on_npc_action_started, self)
|
||||
eventSystem.call("disconnect_event", EventNames.NPC_ACTION_COMPLETED, _on_npc_action_completed, self)
|
||||
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_POSITION_UPDATED, _on_remote_player_position_updated, self)
|
||||
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_JOINED, _on_remote_player_joined, self)
|
||||
eventSystem.call("disconnect_event", EventNames.REMOTE_PLAYER_LEFT, _on_remote_player_left, self)
|
||||
@@ -175,7 +189,9 @@ func _on_chat_login_success(_data: Dictionary) -> void:
|
||||
func _on_local_player_moved(data: Dictionary) -> void:
|
||||
var position_variant: Variant = data.get("position", null)
|
||||
if position_variant is Vector2:
|
||||
_send_position(position_variant as Vector2)
|
||||
var direction := str(data.get("direction", _get_local_direction()))
|
||||
var movementState := str(data.get("movement_state", "walk"))
|
||||
_send_position(position_variant as Vector2, direction, movementState)
|
||||
|
||||
func _send_world_ready() -> void:
|
||||
if _local_player == null:
|
||||
@@ -183,13 +199,16 @@ func _send_world_ready() -> void:
|
||||
var chatManager := _get_chat_manager()
|
||||
if chatManager == null or not chatManager.has_method("mark_world_ready"):
|
||||
return
|
||||
chatManager.call("mark_world_ready", _get_map_id(), _local_player.global_position)
|
||||
chatManager.call("mark_world_ready", _get_map_id(), _local_player.global_position, _get_local_direction(), "idle", _movement_sequence)
|
||||
|
||||
func _send_position(position: Vector2, force: bool = false) -> void:
|
||||
func _send_position(position: Vector2, direction: String, movementState: String, force: bool = false) -> void:
|
||||
var normalizedDirection := _normalize_direction(direction)
|
||||
var normalizedMovementState := _normalize_movement_state(movementState)
|
||||
var stateChanged := normalizedDirection != _last_sent_direction or normalizedMovementState != _last_sent_movement_state
|
||||
var now := Time.get_ticks_msec()
|
||||
if not force and now - _last_sent_at_msec < int(POSITION_SEND_INTERVAL * 1000.0):
|
||||
if not force and not stateChanged and now - _last_sent_at_msec < int(POSITION_SEND_INTERVAL * 1000.0):
|
||||
return
|
||||
if not force and _last_sent_position != Vector2.INF and _last_sent_position.distance_to(position) < MIN_POSITION_DELTA:
|
||||
if not force and not stateChanged and _last_sent_position != Vector2.INF and _last_sent_position.distance_to(position) < MIN_POSITION_DELTA:
|
||||
return
|
||||
|
||||
var chatManager := _get_chat_manager()
|
||||
@@ -200,7 +219,10 @@ func _send_position(position: Vector2, force: bool = false) -> void:
|
||||
|
||||
_last_sent_position = position
|
||||
_last_sent_at_msec = now
|
||||
chatManager.call("update_player_position", position.x, position.y, _get_map_id())
|
||||
_last_sent_direction = normalizedDirection
|
||||
_last_sent_movement_state = normalizedMovementState
|
||||
_movement_sequence += 1
|
||||
chatManager.call("update_player_position", position.x, position.y, _get_map_id(), normalizedDirection, normalizedMovementState, _movement_sequence)
|
||||
|
||||
func _on_remote_player_joined(data: Dictionary) -> void:
|
||||
if not _is_event_for_current_map(data):
|
||||
@@ -210,8 +232,8 @@ func _on_remote_player_joined(data: Dictionary) -> void:
|
||||
return
|
||||
var remote_player := _ensure_remote_player(user_id, data)
|
||||
var position_variant: Variant = data.get("position", null)
|
||||
if remote_player != null and position_variant is Vector2 and remote_player.has_method("update_position"):
|
||||
remote_player.call("update_position", position_variant as Vector2)
|
||||
if remote_player != null and position_variant is Vector2:
|
||||
_update_remote_player_position(remote_player, position_variant as Vector2, data)
|
||||
|
||||
func _on_remote_players_snapshot_ready(data: Dictionary) -> void:
|
||||
if not _is_event_for_current_map(data):
|
||||
@@ -230,8 +252,8 @@ func _on_remote_players_snapshot_ready(data: Dictionary) -> void:
|
||||
seen_user_ids[user_id] = true
|
||||
var remote_player := _ensure_remote_player(user_id, player_data)
|
||||
var position_variant: Variant = player_data.get("position", null)
|
||||
if remote_player != null and position_variant is Vector2 and remote_player.has_method("update_position"):
|
||||
remote_player.call("update_position", position_variant as Vector2)
|
||||
if remote_player != null and position_variant is Vector2:
|
||||
_update_remote_player_position(remote_player, position_variant as Vector2, player_data)
|
||||
|
||||
for user_id_variant in _remote_players.keys().duplicate():
|
||||
var user_id := str(user_id_variant)
|
||||
@@ -246,6 +268,70 @@ func _on_remote_players_snapshot_ready(data: Dictionary) -> void:
|
||||
if not seen_user_ids.has(pendingUserId):
|
||||
_pending_remote_players.erase(pendingUserId)
|
||||
|
||||
func _on_npc_snapshot_ready(data: Dictionary) -> void:
|
||||
if not _is_event_for_current_map(data):
|
||||
return
|
||||
if _network_npcs_root == null:
|
||||
return
|
||||
|
||||
var seenNpcIds: Dictionary = {}
|
||||
var npcsVariant: Variant = data.get("npcs", [])
|
||||
if npcsVariant is Array:
|
||||
for npcVariant in npcsVariant:
|
||||
if not (npcVariant is Dictionary):
|
||||
continue
|
||||
var npcData: Dictionary = npcVariant
|
||||
var npcId := str(npcData.get("npc_id", npcData.get("npcId", ""))).strip_edges()
|
||||
if npcId.is_empty():
|
||||
continue
|
||||
seenNpcIds[npcId] = true
|
||||
var networkNpc := _ensure_network_npc(npcId)
|
||||
if networkNpc != null and networkNpc.has_method("apply_snapshot"):
|
||||
networkNpc.call("apply_snapshot", npcData)
|
||||
|
||||
for npcIdVariant in _network_npcs.keys().duplicate():
|
||||
var npcId := str(npcIdVariant)
|
||||
if seenNpcIds.has(npcId):
|
||||
continue
|
||||
var networkNpc := _network_npcs.get(npcId) as Node
|
||||
_network_npcs.erase(npcId)
|
||||
if is_instance_valid(networkNpc):
|
||||
networkNpc.queue_free()
|
||||
|
||||
func _ensure_network_npc(npcId: String) -> Node2D:
|
||||
if _network_npcs.has(npcId):
|
||||
var existingNpc := _network_npcs.get(npcId) as Node2D
|
||||
if is_instance_valid(existingNpc):
|
||||
return existingNpc
|
||||
_network_npcs.erase(npcId)
|
||||
|
||||
var networkNpc := NETWORK_NPC_SCENE.instantiate() as Node2D
|
||||
if networkNpc == null:
|
||||
return null
|
||||
networkNpc.name = "NetworkNpc_%s" % npcId
|
||||
networkNpc.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
||||
_network_npcs_root.add_child(networkNpc)
|
||||
_network_npcs[npcId] = networkNpc
|
||||
return networkNpc
|
||||
|
||||
func _on_npc_action_started(data: Dictionary) -> void:
|
||||
_apply_npc_action(data, "apply_action_started")
|
||||
|
||||
func _on_npc_action_completed(data: Dictionary) -> void:
|
||||
_apply_npc_action(data, "apply_action_completed")
|
||||
|
||||
func _apply_npc_action(data: Dictionary, methodName: String) -> void:
|
||||
if not _is_event_for_current_map(data):
|
||||
return
|
||||
var npcId := str(data.get("npc_id", data.get("npcId", ""))).strip_edges()
|
||||
if npcId.is_empty():
|
||||
return
|
||||
var networkNpc := _network_npcs.get(npcId) as Node
|
||||
if networkNpc == null:
|
||||
networkNpc = _ensure_network_npc(npcId)
|
||||
if networkNpc != null and networkNpc.has_method(methodName):
|
||||
networkNpc.call(methodName, data)
|
||||
|
||||
func _on_remote_player_position_updated(data: Dictionary) -> void:
|
||||
if not _is_event_for_current_map(data):
|
||||
return
|
||||
@@ -260,8 +346,8 @@ func _on_remote_player_position_updated(data: Dictionary) -> void:
|
||||
return
|
||||
|
||||
var remote_player := _ensure_remote_player(user_id, data)
|
||||
if remote_player != null and remote_player.has_method("update_position"):
|
||||
remote_player.call("update_position", position_variant as Vector2)
|
||||
if remote_player != null:
|
||||
_update_remote_player_position(remote_player, position_variant as Vector2, data)
|
||||
|
||||
func _on_remote_player_left(data: Dictionary) -> void:
|
||||
if not _is_event_for_current_map(data):
|
||||
@@ -357,8 +443,8 @@ func _on_remote_skin_ready(data: Dictionary) -> void:
|
||||
_pending_remote_players.erase(userId)
|
||||
var remotePlayer := _ensure_remote_player(userId, pendingData)
|
||||
var positionVariant: Variant = pendingData.get("position", null)
|
||||
if remotePlayer != null and positionVariant is Vector2 and remotePlayer.has_method("update_position"):
|
||||
remotePlayer.call("update_position", positionVariant as Vector2)
|
||||
if remotePlayer != null and positionVariant is Vector2:
|
||||
_update_remote_player_position(remotePlayer, positionVariant as Vector2, pendingData)
|
||||
|
||||
func _on_remote_skin_failed(data: Dictionary) -> void:
|
||||
var skinId := str(data.get("skin_id", "")).strip_edges()
|
||||
@@ -372,14 +458,14 @@ func _on_remote_skin_failed(data: Dictionary) -> void:
|
||||
continue
|
||||
_pending_remote_players.erase(userId)
|
||||
var fallbackData := pendingData.duplicate(true)
|
||||
fallbackData["skin_id"] = ""
|
||||
fallbackData["skinId"] = ""
|
||||
fallbackData["skin_id"] = "classic_whale"
|
||||
fallbackData["skinId"] = "classic_whale"
|
||||
fallbackData.erase("skin_asset")
|
||||
fallbackData.erase("skinAsset")
|
||||
var remotePlayer := _ensure_remote_player(userId, fallbackData)
|
||||
var positionVariant: Variant = pendingData.get("position", null)
|
||||
if remotePlayer != null and positionVariant is Vector2 and remotePlayer.has_method("update_position"):
|
||||
remotePlayer.call("update_position", positionVariant as Vector2)
|
||||
if remotePlayer != null and positionVariant is Vector2:
|
||||
_update_remote_player_position(remotePlayer, positionVariant as Vector2, pendingData)
|
||||
|
||||
func _find_private_chat_target() -> Node2D:
|
||||
if _local_player == null or _remote_players.is_empty():
|
||||
@@ -436,9 +522,31 @@ func _apply_remote_player_metadata(remote_player: Node2D, data: Dictionary) -> v
|
||||
var username := str(data.get("username", "")).strip_edges()
|
||||
if not username.is_empty():
|
||||
remote_player.set("username", username)
|
||||
if data.has("skin_id") or data.has("skinId") or data.has("skin_asset") or data.has("skinAsset") or data.has("avatar_id") or data.has("avatarId") or data.has("cafe_companion") or data.has("cafeCompanion"):
|
||||
if remote_player.has_method("setup"):
|
||||
remote_player.call("setup", data)
|
||||
if remote_player.has_method("update_metadata"):
|
||||
remote_player.call("update_metadata", data)
|
||||
|
||||
func _update_remote_player_position(remotePlayer: Node2D, position: Vector2, data: Dictionary) -> void:
|
||||
if not remotePlayer.has_method("update_position"):
|
||||
return
|
||||
remotePlayer.call(
|
||||
"update_position",
|
||||
position,
|
||||
str(data.get("direction", "")),
|
||||
str(data.get("movement_state", data.get("movementState", "walk"))),
|
||||
int(data.get("sequence", -1))
|
||||
)
|
||||
|
||||
func _get_local_direction() -> String:
|
||||
if _local_player != null:
|
||||
return _normalize_direction(str(_local_player.get("lastDirection")))
|
||||
return "down"
|
||||
|
||||
func _normalize_direction(value: String) -> String:
|
||||
var normalized := value.strip_edges().to_lower()
|
||||
return normalized if normalized in ["down", "up", "right", "left"] else "down"
|
||||
|
||||
func _normalize_movement_state(value: String) -> String:
|
||||
return "walk" if value.strip_edges().to_lower() == "walk" else "idle"
|
||||
|
||||
func _resolve_chat_bubble_target(data: Dictionary) -> Node2D:
|
||||
var from_user := str(data.get("from_user", data.get("username", ""))).strip_edges()
|
||||
|
||||
@@ -20,6 +20,9 @@ const CAMERA_LIMIT_BOTTOM: int = 960
|
||||
func _ready() -> void:
|
||||
_apply_spawn_point()
|
||||
_configure_camera()
|
||||
var chatManager := get_node_or_null("/root/ChatManager")
|
||||
if chatManager != null and chatManager.has_method("is_guest_mode") and bool(chatManager.call("is_guest_mode")):
|
||||
player.set_spectator_mode(true)
|
||||
|
||||
func _apply_spawn_point() -> void:
|
||||
var spawnName: String = SceneManager.get_next_spawn_name()
|
||||
|
||||
@@ -146,6 +146,9 @@ disabled = true
|
||||
[node name="RemotePlayers" type="Node2D" parent="YSortWorld/Characters/Players" unique_id=2071839484]
|
||||
y_sort_enabled = true
|
||||
|
||||
[node name="Npcs" type="Node2D" parent="YSortWorld/Characters"]
|
||||
y_sort_enabled = true
|
||||
|
||||
[node name="CafeWhaleBaristaNpc" parent="YSortWorld/Characters" unique_id=1188569491 instance=ExtResource("8_barista_npc")]
|
||||
position = Vector2(-472, -291)
|
||||
|
||||
|
||||
@@ -12,9 +12,7 @@
|
||||
[ext_resource type="Texture2D" uid="uid://c8ubnxut51f0r" path="res://assets/maps/square/v1/props/bottom_entrance_left_task_props_v2_hd_clean.png" id="12_lefttask"]
|
||||
[ext_resource type="Texture2D" uid="uid://b4bvp0r5hua1k" path="res://assets/maps/square/v1/props/bottom_entrance_right_service_props_v2_hd_clean.png" id="13_rightsvc"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/characters/player.tscn" id="14_u1t8b"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/characters/npc.tscn" id="17_qog0y"]
|
||||
[ext_resource type="Texture2D" uid="uid://c0kgmsaach8wh" path="res://assets/maps/square/v1/props/foliage_v13_concept_broadleaf_single/broadleaf_tree_v13_clean.png" id="19_edt5w"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/characters/crayfish_npc.tscn" id="20_lemld"]
|
||||
[ext_resource type="Texture2D" uid="uid://q3vldth6fyvw" path="res://assets/maps/square/v1/props/foliage_v14_concept_evergreen_single/evergreen_tree_v14_clean.png" id="20_rixdf"]
|
||||
[ext_resource type="Texture2D" uid="uid://cbqj51mpelsr4" path="res://assets/maps/square/v1/props/top_fence_v1/top_fence_v1_clean.png" id="22_jagxe"]
|
||||
[ext_resource type="Texture2D" uid="uid://csxvgs1v5puvt" path="res://assets/maps/square/v1/props/guild_left_tree_flowers_v1/guild_left_tree_flowers_v1_clean.png" id="24_8hf0h"]
|
||||
@@ -35,6 +33,7 @@
|
||||
[ext_resource type="Script" uid="uid://dxupavbgcw3tw" path="res://scenes/Maps/MapMultiplayerController.gd" id="38_multiplayer"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/ui/PlayerHud.tscn" id="39_playerhud"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/ui/FriendListPanel.tscn" id="40_friendpanel"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/ui/WorldBulletinPanel.tscn" id="45_world_bulletin"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/ui/SettingsPanel.tscn" id="41_settingspanel"]
|
||||
[ext_resource type="Script" uid="uid://dr6uk7m4fsr4l" path="res://scenes/Maps/ScenePortal.gd" id="42_sceneportal"]
|
||||
[ext_resource type="Script" uid="uid://biu0igl6133q5" path="res://scenes/Maps/Square.gd" id="43_square"]
|
||||
@@ -134,6 +133,9 @@ layer = 10
|
||||
|
||||
[node name="FriendListPanel" parent="UILayer" unique_id=914934559 instance=ExtResource("40_friendpanel")]
|
||||
|
||||
[node name="WorldBulletinPanel" parent="UILayer" instance=ExtResource("45_world_bulletin")]
|
||||
z_index = 1000
|
||||
|
||||
[node name="SettingsPanel" parent="UILayer" unique_id=588068795 instance=ExtResource("41_settingspanel")]
|
||||
|
||||
[node name="HDGrassBase" type="Sprite2D" parent="." unique_id=1853750890]
|
||||
@@ -385,20 +387,6 @@ y_sort_enabled = true
|
||||
[node name="Npcs" type="Node2D" parent="YSortWorld/Characters" unique_id=1114010880]
|
||||
y_sort_enabled = true
|
||||
|
||||
[node name="GuildReceptionNpc" parent="YSortWorld/Characters/Npcs" unique_id=442345899 instance=ExtResource("17_qog0y")]
|
||||
texture_filter = 1
|
||||
position = Vector2(-199, -515)
|
||||
npcName = "范鲸晶"
|
||||
dialogue = "欢迎来到 WhaleTown 广场。上方是公会大厅,南边是小镇入口,左边通往码头。"
|
||||
showNameplate = true
|
||||
nameplateOffsetY = -72.0
|
||||
|
||||
[node name="DockGuideNpc" parent="YSortWorld/Characters/Npcs" unique_id=626857126 instance=ExtResource("20_lemld")]
|
||||
texture_filter = 1
|
||||
position = Vector2(-825, 437)
|
||||
showNameplate = true
|
||||
nameplateOffsetY = -66.0
|
||||
|
||||
[node name="MultiplayerController" type="Node" parent="YSortWorld" unique_id=1810372571]
|
||||
script = ExtResource("38_multiplayer")
|
||||
|
||||
|
||||
@@ -241,6 +241,9 @@ position = Vector2(0, 921)
|
||||
y_sort_enabled = true
|
||||
position = Vector2(0, -1)
|
||||
|
||||
[node name="Npcs" type="Node2D" parent="YSortWorld/Characters"]
|
||||
y_sort_enabled = true
|
||||
|
||||
[node name="MultiplayerController" type="Node" parent="YSortWorld" unique_id=1024843959]
|
||||
script = ExtResource("26_multiplayer")
|
||||
map_id = "work_zone"
|
||||
|
||||
@@ -8,27 +8,27 @@ class_name NPCController
|
||||
# 主要功能:
|
||||
# - 播放 NPC 待机动画
|
||||
# - 响应玩家射线交互
|
||||
# - 触发聊天气泡与 NPC 对话事件
|
||||
# - 打开 NPC 对话框并广播 NPC 对话事件
|
||||
#
|
||||
# 依赖: EventSystem, EventNames, ChatBubble
|
||||
# 依赖: EventSystem, EventNames, ChatUI
|
||||
# 作者: Codex
|
||||
# 创建时间: 2026-03-10
|
||||
# ============================================================================
|
||||
|
||||
signal interaction_happened(text: String)
|
||||
|
||||
const CHAT_BUBBLE_SCENE: PackedScene = preload("res://scenes/ui/ChatBubble.tscn")
|
||||
const CHAT_BUBBLE_LAYER_NAME: String = "WorldChatBubbleLayer"
|
||||
const CHAT_BUBBLE_TARGET_OFFSET: Vector2 = Vector2(0, -84)
|
||||
const NPC_TALKED_EVENT: String = "npc_talked"
|
||||
const NPC_COLLISION_LAYER: int = 2
|
||||
const NPC_COLLISION_MASK: int = 1
|
||||
const WORLD_SORT_Z_OFFSET: int = 2048
|
||||
const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres")
|
||||
const NAMEPLATE_RENDER_SCALE: float = 0.5
|
||||
const NAMEPLATE_FONT_SIZE: int = 24
|
||||
const NAMEPLATE_VISUAL_HEIGHT: int = 22
|
||||
const NAMEPLATE_VISUAL_MIN_WIDTH: int = 76
|
||||
const NAMEPLATE_VISUAL_MAX_WIDTH: int = 118
|
||||
const NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12
|
||||
const NAMEPLATE_FONT_SIZE: int = 16
|
||||
const NAMEPLATE_VISUAL_HEIGHT: int = 15
|
||||
const NAMEPLATE_VISUAL_MIN_WIDTH: int = 64
|
||||
const NAMEPLATE_VISUAL_MAX_WIDTH: int = 100
|
||||
const NAMEPLATE_FONT_ZH: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2")
|
||||
const NAMEPLATE_FONT_LATIN: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2")
|
||||
|
||||
@export var npcName: String = "NPC"
|
||||
@export_multiline var dialogue: String = "欢迎来到WhaleTown,我是镇长范鲸晶"
|
||||
@@ -38,6 +38,7 @@ const NAMEPLATE_VISUAL_CHAR_WIDTH: int = 12
|
||||
@onready var animation_player: AnimationPlayer = $AnimationPlayer
|
||||
|
||||
var _nameplate: Label
|
||||
var _nicknameFont: FontFile
|
||||
|
||||
func _ready() -> void:
|
||||
# 播放场景里配置好的待机动画,让不同 NPC 可以复用同一个控制器。
|
||||
@@ -46,16 +47,18 @@ func _ready() -> void:
|
||||
_update_nameplate()
|
||||
_update_world_sort_z()
|
||||
|
||||
# 保持 NPC 可被玩家射线与角色碰撞识别。
|
||||
collision_layer = 3
|
||||
collision_mask = 3
|
||||
# NPC 单独占用交互层;玩家的物理掩码同时包含地图层和 NPC 层。
|
||||
collision_layer = NPC_COLLISION_LAYER
|
||||
collision_mask = NPC_COLLISION_MASK
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
_update_world_sort_z()
|
||||
|
||||
# 处理玩家交互,展示气泡并向全局事件系统广播。
|
||||
# 处理玩家交互,打开统一对话框并向全局事件系统广播。
|
||||
func interact() -> void:
|
||||
show_bubble(dialogue)
|
||||
var chatUi := get_tree().root.find_child("ChatUI", true, false)
|
||||
if chatUi != null and chatUi.has_method("show_npc_dialogue"):
|
||||
chatUi.call("show_npc_dialogue", npcName, dialogue)
|
||||
var eventSystem: Node = get_node_or_null("/root/EventSystem")
|
||||
if eventSystem != null:
|
||||
eventSystem.call("emit_event", NPC_TALKED_EVENT, {
|
||||
@@ -65,31 +68,6 @@ func interact() -> void:
|
||||
})
|
||||
interaction_happened.emit(dialogue)
|
||||
|
||||
# 在 NPC 头顶生成一次性聊天气泡。
|
||||
#
|
||||
# 参数:
|
||||
# text: String - 要展示的对话内容
|
||||
func show_bubble(text: String) -> void:
|
||||
var bubble: Control = CHAT_BUBBLE_SCENE.instantiate() as Control
|
||||
if bubble == null:
|
||||
return
|
||||
var bubbleLayer: CanvasLayer = _get_chat_bubble_layer()
|
||||
bubbleLayer.add_child(bubble)
|
||||
if bubble.has_method("set_text"):
|
||||
bubble.call("set_text", text, self, CHAT_BUBBLE_TARGET_OFFSET)
|
||||
|
||||
func _get_chat_bubble_layer() -> CanvasLayer:
|
||||
var root: Window = get_tree().root
|
||||
var layer: CanvasLayer = root.get_node_or_null(CHAT_BUBBLE_LAYER_NAME) as CanvasLayer
|
||||
if layer != null:
|
||||
return layer
|
||||
|
||||
layer = CanvasLayer.new()
|
||||
layer.name = CHAT_BUBBLE_LAYER_NAME
|
||||
layer.layer = 20
|
||||
root.add_child(layer)
|
||||
return layer
|
||||
|
||||
func _update_world_sort_z() -> void:
|
||||
z_index = WORLD_SORT_Z_OFFSET + int(round(global_position.y))
|
||||
|
||||
@@ -123,34 +101,20 @@ func _update_nameplate() -> void:
|
||||
_nameplate.clip_text = true
|
||||
_nameplate.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
_nameplate.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_nameplate.add_theme_color_override("font_color", Color(0.12, 0.20, 0.24, 1.0))
|
||||
_nameplate.add_theme_color_override("font_shadow_color", Color(1.0, 1.0, 1.0, 0.85))
|
||||
_nameplate.add_theme_constant_override("shadow_offset_x", 0)
|
||||
_nameplate.add_theme_constant_override("shadow_offset_y", 1)
|
||||
_nameplate.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0))
|
||||
_nameplate.add_theme_color_override("font_outline_color", Color(1.0, 0.965, 0.88, 0.98))
|
||||
_nameplate.add_theme_constant_override("outline_size", 3)
|
||||
_nameplate.add_theme_font_override("font", _get_nickname_font())
|
||||
_nameplate.add_theme_font_size_override("font_size", NAMEPLATE_FONT_SIZE)
|
||||
_nameplate.add_theme_stylebox_override("normal", _create_nameplate_style())
|
||||
_nameplate.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
|
||||
|
||||
func _nameplate_visual_width(displayName: String) -> int:
|
||||
var estimatedWidth := displayName.length() * NAMEPLATE_VISUAL_CHAR_WIDTH + 28
|
||||
return clampi(estimatedWidth, NAMEPLATE_VISUAL_MIN_WIDTH, NAMEPLATE_VISUAL_MAX_WIDTH)
|
||||
var measuredWidth := _get_nickname_font().get_string_size(displayName, HORIZONTAL_ALIGNMENT_LEFT, -1, NAMEPLATE_FONT_SIZE).x
|
||||
var visualWidth := ceili(measuredWidth * NAMEPLATE_RENDER_SCALE + 20.0)
|
||||
return clampi(visualWidth, NAMEPLATE_VISUAL_MIN_WIDTH, NAMEPLATE_VISUAL_MAX_WIDTH)
|
||||
|
||||
func _create_nameplate_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(1.0, 0.988, 0.955, 0.96)
|
||||
style.border_color = Color(0.18, 0.34, 0.38, 0.92)
|
||||
style.border_width_left = 2
|
||||
style.border_width_top = 2
|
||||
style.border_width_right = 2
|
||||
style.border_width_bottom = 2
|
||||
style.corner_radius_top_left = 12
|
||||
style.corner_radius_top_right = 12
|
||||
style.corner_radius_bottom_left = 12
|
||||
style.corner_radius_bottom_right = 12
|
||||
style.content_margin_left = 12
|
||||
style.content_margin_top = 4
|
||||
style.content_margin_right = 12
|
||||
style.content_margin_bottom = 4
|
||||
style.shadow_color = Color(0.05, 0.08, 0.09, 0.18)
|
||||
style.shadow_size = 4
|
||||
style.shadow_offset = Vector2(0, 2)
|
||||
return style
|
||||
func _get_nickname_font() -> FontFile:
|
||||
if _nicknameFont == null:
|
||||
_nicknameFont = NAMEPLATE_FONT_ZH.duplicate() as FontFile
|
||||
_nicknameFont.fallbacks = [NAMEPLATE_FONT_LATIN]
|
||||
return _nicknameFont
|
||||
|
||||
510
scenes/characters/NetworkNpc.gd
Normal file
510
scenes/characters/NetworkNpc.gd
Normal file
@@ -0,0 +1,510 @@
|
||||
extends NPCController
|
||||
class_name NetworkNpc
|
||||
|
||||
var npcId: String = ""
|
||||
var stateVersion: int = -1
|
||||
var worldState: String = "idle"
|
||||
var publicIntention: String = ""
|
||||
var dailyGoal: String = ""
|
||||
var planSource: String = "fallback"
|
||||
var currentActivity: Dictionary = {}
|
||||
var movementState: String = "idle"
|
||||
var activeAction: Dictionary = {}
|
||||
var _actionStartedLocalMsec: int = 0
|
||||
var _actionCompletesLocalMsec: int = 0
|
||||
const DIRECTION_ROWS: Dictionary = {"down": 0, "up": 1, "right": 2, "left": 3}
|
||||
const WALK_ANIMATION_LENGTH: float = 0.8
|
||||
const IDLE_ANIMATION_LENGTH: float = 1.2
|
||||
const ACTIVITY_ANIMATION_LENGTH: float = 1.2
|
||||
const COLLISION_MOTION_SAMPLE_DISTANCE: float = 8.0
|
||||
const RESEARCHER_TEXTURE: Texture2D = preload("res://assets/characters/generated/whale_researcher_v2/final_no_feet/processed/whale_researcher_no_feet_spritesheet.png")
|
||||
const MAYOR_TEXTURE: Texture2D = preload("res://assets/characters/npc_286_241.png")
|
||||
const CRAYFISH_TEXTURE: Texture2D = preload("res://assets/characters/crayfish_npc_256_256.png")
|
||||
const NIULAI_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_spritesheet.png")
|
||||
const NIULAI_IDLE_DOWN_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_down.png")
|
||||
const NIULAI_IDLE_UP_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_up.png")
|
||||
const NIULAI_IDLE_RIGHT_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_right.png")
|
||||
const NIULAI_IDLE_LEFT_TEXTURE: Texture2D = preload("res://assets/characters/generated/horned_creature_npc/horned_creature_npc_idle_left.png")
|
||||
var lastDirection: String = "down"
|
||||
var visualScene: String = ""
|
||||
var spriteColumns: int = 8
|
||||
var _lastBlockedActionId: String = ""
|
||||
var _baseSpritePosition: Vector2 = Vector2.ZERO
|
||||
var _niulaiIdleTexture: Texture2D
|
||||
var _baseSpriteScale: Vector2 = Vector2.ONE
|
||||
var _baseSpriteRotation: float = 0.0
|
||||
|
||||
func _ready() -> void:
|
||||
super._ready()
|
||||
var eventSystem := get_node_or_null("/root/EventSystem")
|
||||
if eventSystem != null:
|
||||
eventSystem.call("connect_event", EventNames.NPC_CONVERSATION, _on_npc_conversation, self)
|
||||
if animation_player != null:
|
||||
animation_player.stop()
|
||||
var sharedLibrary := animation_player.get_animation_library("")
|
||||
if sharedLibrary != null:
|
||||
animation_player.remove_animation_library("")
|
||||
animation_player.add_animation_library("", sharedLibrary.duplicate(true))
|
||||
_configure_visual("classic_whale")
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var eventSystem := get_node_or_null("/root/EventSystem")
|
||||
if eventSystem != null:
|
||||
eventSystem.call("disconnect_event", EventNames.NPC_CONVERSATION, _on_npc_conversation, self)
|
||||
|
||||
func interact() -> void:
|
||||
# 第一次交互和后续交流都进入同一个居中对话框。
|
||||
var chatUi := get_tree().root.find_child("ChatUI", true, false)
|
||||
if chatUi != null and chatUi.has_method("start_npc_whisper"):
|
||||
chatUi.call("start_npc_whisper", npcId, npcName, dialogue)
|
||||
|
||||
func _on_npc_conversation(data: Dictionary) -> void:
|
||||
# 环境中的 NPC 对话只更新下次交互时的开场白,不再弹出世界气泡。
|
||||
var linesValue: Variant = data.get("lines", [])
|
||||
if not (linesValue is Array):
|
||||
return
|
||||
for lineValue: Variant in (linesValue as Array):
|
||||
if not (lineValue is Dictionary):
|
||||
continue
|
||||
var line: Dictionary = lineValue
|
||||
if str(line.get("speaker_npc_id", "")) != npcId:
|
||||
continue
|
||||
var text := str(line.get("text", "")).strip_edges()
|
||||
if not text.is_empty():
|
||||
dialogue = text
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if activeAction.is_empty():
|
||||
return
|
||||
var actionKind := str(activeAction.get("kind", "walk"))
|
||||
if actionKind == "transition":
|
||||
return
|
||||
var now := Time.get_ticks_msec()
|
||||
var duration: int = maxi(1, _actionCompletesLocalMsec - _actionStartedLocalMsec)
|
||||
var progress := clampf(float(now - _actionStartedLocalMsec) / float(duration), 0.0, 1.0)
|
||||
var from := Vector2(float(activeAction.get("from_x", global_position.x)), float(activeAction.get("from_y", global_position.y)))
|
||||
var to := Vector2(float(activeAction.get("to_x", global_position.x)), float(activeAction.get("to_y", global_position.y)))
|
||||
var reachedAuthoritativePosition := _move_to_authoritative_position(from.lerp(to, progress))
|
||||
_update_world_sort_z()
|
||||
if progress >= 1.0:
|
||||
if reachedAuthoritativePosition and global_position.distance_to(to) <= 0.5:
|
||||
global_position = to
|
||||
activeAction = {}
|
||||
movementState = "idle"
|
||||
_play_idle_animation()
|
||||
|
||||
func _play_idle_animation() -> void:
|
||||
if animation_player != null:
|
||||
if visualScene == "niulai_ambassador" and has_node("Sprite2D"):
|
||||
_set_niulai_idle_texture("down")
|
||||
animation_player.play("idle_down")
|
||||
else:
|
||||
animation_player.play("idle")
|
||||
|
||||
func _play_activity_animation(activityKind: String = "") -> void:
|
||||
if animation_player == null:
|
||||
return
|
||||
if visualScene == "niulai_ambassador" and has_node("Sprite2D"):
|
||||
_set_niulai_idle_texture("down")
|
||||
animation_player.play("idle_down")
|
||||
return
|
||||
var normalized := activityKind.strip_edges().to_lower()
|
||||
var animationPrefix := "activity_work"
|
||||
if normalized == "socialize" or normalized == "share":
|
||||
animationPrefix = "activity_talk"
|
||||
var animationName := "%s_%s" % [animationPrefix, lastDirection]
|
||||
if animation_player.has_animation(animationName):
|
||||
animation_player.play(animationName)
|
||||
else:
|
||||
_play_idle_animation()
|
||||
|
||||
func _set_niulai_idle_texture(direction: String) -> void:
|
||||
var idleTexture: Texture2D = NIULAI_IDLE_DOWN_TEXTURE
|
||||
match direction:
|
||||
"up": idleTexture = NIULAI_IDLE_UP_TEXTURE
|
||||
"right": idleTexture = NIULAI_IDLE_RIGHT_TEXTURE
|
||||
"left": idleTexture = NIULAI_IDLE_LEFT_TEXTURE
|
||||
$Sprite2D.texture = idleTexture
|
||||
$Sprite2D.hframes = 4
|
||||
$Sprite2D.vframes = 1
|
||||
|
||||
func _configure_visual(sceneKey: String) -> void:
|
||||
if not has_node("Sprite2D"):
|
||||
return
|
||||
var normalized := sceneKey.strip_edges()
|
||||
if normalized.is_empty():
|
||||
normalized = "classic_whale"
|
||||
if visualScene == normalized:
|
||||
return
|
||||
visualScene = normalized
|
||||
var sprite := $Sprite2D as Sprite2D
|
||||
# Network NPC sheets are authored for crisp 2D rendering. Set the filter on
|
||||
# the actual Sprite2D as well as the parent, since imported textures may
|
||||
# otherwise fall back to the renderer's linear sampler on Web.
|
||||
sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
||||
sprite.texture_repeat = CanvasItem.TEXTURE_REPEAT_DISABLED
|
||||
match visualScene:
|
||||
"town_mayor":
|
||||
sprite.texture = MAYOR_TEXTURE
|
||||
spriteColumns = 4
|
||||
sprite.position = Vector2.ZERO
|
||||
sprite.scale = Vector2(0.72, 0.72)
|
||||
# The mayor frames have transparent padding above the propeller, so the
|
||||
# name must follow the visible silhouette instead of the frame boundary.
|
||||
nameplateOffsetY = -52.0
|
||||
_set_collision_size(Vector2(48, 24))
|
||||
"dock_crayfish":
|
||||
sprite.texture = CRAYFISH_TEXTURE
|
||||
spriteColumns = 4
|
||||
sprite.position = Vector2.ZERO
|
||||
sprite.scale = Vector2(0.65, 0.65)
|
||||
nameplateOffsetY = -66.0
|
||||
_set_collision_size(Vector2(44, 22))
|
||||
"niulai_ambassador":
|
||||
sprite.texture = NIULAI_TEXTURE
|
||||
spriteColumns = 4
|
||||
# The generated sheet includes feet. Align the sprite's feet with the
|
||||
# network node origin used for sorting and collision.
|
||||
# Its source frames are 384x256 (larger than the 160x160 sheets used
|
||||
# by the other NPCs), so use a smaller display scale while preserving
|
||||
# the same ground anchor and a slightly broader physical footprint.
|
||||
sprite.position = Vector2(0, -45)
|
||||
sprite.scale = Vector2(0.4, 0.4)
|
||||
nameplateOffsetY = -105.0
|
||||
_set_collision_size(Vector2(52, 24))
|
||||
_:
|
||||
sprite.texture = RESEARCHER_TEXTURE
|
||||
spriteColumns = 8
|
||||
sprite.position = Vector2(0, -29)
|
||||
sprite.scale = Vector2(0.5, 0.5)
|
||||
nameplateOffsetY = -72.0
|
||||
_set_collision_size(Vector2(48, 24))
|
||||
_baseSpritePosition = sprite.position
|
||||
_baseSpriteScale = sprite.scale
|
||||
_baseSpriteRotation = sprite.rotation
|
||||
sprite.hframes = spriteColumns
|
||||
sprite.vframes = 4
|
||||
_configure_directional_animations()
|
||||
_play_idle_animation()
|
||||
_update_nameplate()
|
||||
|
||||
func _set_collision_size(value: Vector2) -> void:
|
||||
var collision := get_node_or_null("CollisionShape2D") as CollisionShape2D
|
||||
if collision != null and collision.shape is RectangleShape2D:
|
||||
collision.shape = collision.shape.duplicate()
|
||||
(collision.shape as RectangleShape2D).size = value
|
||||
|
||||
func _direction_row(direction: String) -> int:
|
||||
if visualScene == "niulai_ambassador":
|
||||
return int({"down": 0, "up": 1, "right": 2, "left": 3}.get(direction, 0))
|
||||
if spriteColumns == 4:
|
||||
return int({"down": 0, "right": 1, "up": 2, "left": 3}.get(direction, 0))
|
||||
return int(DIRECTION_ROWS.get(direction, 0))
|
||||
|
||||
func _configure_directional_animations() -> void:
|
||||
if animation_player == null or not has_node("Sprite2D"):
|
||||
return
|
||||
var sprite := $Sprite2D as Sprite2D
|
||||
var library := animation_player.get_animation_library("")
|
||||
if library == null:
|
||||
library = AnimationLibrary.new()
|
||||
animation_player.add_animation_library("", library)
|
||||
if library.has_animation("idle"):
|
||||
library.remove_animation("idle")
|
||||
var idle := Animation.new()
|
||||
idle.resource_name = "idle"
|
||||
idle.length = IDLE_ANIMATION_LENGTH
|
||||
idle.loop_mode = Animation.LOOP_LINEAR
|
||||
var idleTrack := idle.add_track(Animation.TYPE_VALUE)
|
||||
idle.track_set_path(idleTrack, NodePath("Sprite2D:frame"))
|
||||
idle.value_track_set_update_mode(idleTrack, Animation.UPDATE_DISCRETE)
|
||||
var idleFrames: Array[int] = [0, 1, 0, 2, 0]
|
||||
if visualScene == "niulai_ambassador":
|
||||
sprite.texture = NIULAI_IDLE_DOWN_TEXTURE
|
||||
idleFrames = [0, 1, 2, 3, 2, 1, 0]
|
||||
if spriteColumns >= 8:
|
||||
idleFrames = [0, 2, 0, 4, 0]
|
||||
for frameIndex in range(idleFrames.size()):
|
||||
var frameTime := idle.length * float(frameIndex) / float(idleFrames.size() - 1)
|
||||
idle.track_insert_key(idleTrack, frameTime, idleFrames[frameIndex])
|
||||
var idlePositionTrack := idle.add_track(Animation.TYPE_VALUE)
|
||||
idle.track_set_path(idlePositionTrack, NodePath("Sprite2D:position"))
|
||||
idle.track_insert_key(idlePositionTrack, 0.0, _baseSpritePosition)
|
||||
idle.track_insert_key(idlePositionTrack, idle.length * 0.25, _baseSpritePosition + Vector2(0.0, -0.75))
|
||||
idle.track_insert_key(idlePositionTrack, idle.length * 0.5, _baseSpritePosition)
|
||||
idle.track_insert_key(idlePositionTrack, idle.length * 0.75, _baseSpritePosition + Vector2(0.0, -0.35))
|
||||
idle.track_insert_key(idlePositionTrack, idle.length, _baseSpritePosition)
|
||||
library.add_animation("idle", idle)
|
||||
if visualScene == "niulai_ambassador":
|
||||
sprite.texture = NIULAI_TEXTURE
|
||||
sprite.hframes = 4
|
||||
sprite.vframes = 4
|
||||
for direction in DIRECTION_ROWS.keys():
|
||||
var row := _direction_row(str(direction))
|
||||
if visualScene == "niulai_ambassador":
|
||||
var idleTextureForDirection: Texture2D = NIULAI_IDLE_DOWN_TEXTURE
|
||||
match str(direction):
|
||||
"up": idleTextureForDirection = NIULAI_IDLE_UP_TEXTURE
|
||||
"right": idleTextureForDirection = NIULAI_IDLE_RIGHT_TEXTURE
|
||||
"left": idleTextureForDirection = NIULAI_IDLE_LEFT_TEXTURE
|
||||
sprite.texture = idleTextureForDirection
|
||||
sprite.hframes = 4
|
||||
sprite.vframes = 1
|
||||
var niulaiIdleName := "idle_%s" % direction
|
||||
if library.has_animation(niulaiIdleName): library.remove_animation(niulaiIdleName)
|
||||
var niulaiIdle := Animation.new()
|
||||
niulaiIdle.resource_name = niulaiIdleName
|
||||
niulaiIdle.length = IDLE_ANIMATION_LENGTH
|
||||
niulaiIdle.loop_mode = Animation.LOOP_LINEAR
|
||||
var niulaiIdleTrack := niulaiIdle.add_track(Animation.TYPE_VALUE)
|
||||
niulaiIdle.track_set_path(niulaiIdleTrack, NodePath("Sprite2D:frame"))
|
||||
niulaiIdle.value_track_set_update_mode(niulaiIdleTrack, Animation.UPDATE_DISCRETE)
|
||||
for niulaiFrameIndex in range(idleFrames.size()):
|
||||
var niulaiFrameTime := niulaiIdle.length * float(niulaiFrameIndex) / float(idleFrames.size() - 1)
|
||||
niulaiIdle.track_insert_key(niulaiIdleTrack, niulaiFrameTime, idleFrames[niulaiFrameIndex])
|
||||
library.add_animation(niulaiIdleName, niulaiIdle)
|
||||
var niulaiWalkName := "walk_%s" % direction
|
||||
if library.has_animation(niulaiWalkName): library.remove_animation(niulaiWalkName)
|
||||
var niulaiWalk := Animation.new()
|
||||
niulaiWalk.resource_name = niulaiWalkName
|
||||
niulaiWalk.length = WALK_ANIMATION_LENGTH
|
||||
niulaiWalk.loop_mode = Animation.LOOP_LINEAR
|
||||
var niulaiWalkTrack := niulaiWalk.add_track(Animation.TYPE_VALUE)
|
||||
niulaiWalk.track_set_path(niulaiWalkTrack, NodePath("Sprite2D:frame"))
|
||||
niulaiWalk.value_track_set_update_mode(niulaiWalkTrack, Animation.UPDATE_DISCRETE)
|
||||
for column in range(4):
|
||||
niulaiWalk.track_insert_key(
|
||||
niulaiWalkTrack,
|
||||
float(column) * WALK_ANIMATION_LENGTH / 4.0,
|
||||
row * 4 + column,
|
||||
)
|
||||
library.add_animation(niulaiWalkName, niulaiWalk)
|
||||
for staticPrefix in ["activity_work", "activity_talk"]:
|
||||
var staticName := "%s_%s" % [staticPrefix, direction]
|
||||
if library.has_animation(staticName): library.remove_animation(staticName)
|
||||
var staticAnimation := Animation.new()
|
||||
staticAnimation.resource_name = staticName
|
||||
staticAnimation.length = 1.0
|
||||
staticAnimation.loop_mode = Animation.LOOP_LINEAR
|
||||
var staticTrack := staticAnimation.add_track(Animation.TYPE_VALUE)
|
||||
staticAnimation.track_set_path(staticTrack, NodePath("Sprite2D:frame"))
|
||||
staticAnimation.value_track_set_update_mode(staticTrack, Animation.UPDATE_DISCRETE)
|
||||
staticAnimation.track_insert_key(staticTrack, 0.0, row * spriteColumns)
|
||||
library.add_animation(staticName, staticAnimation)
|
||||
continue
|
||||
var walkName := "walk_%s" % direction
|
||||
if library.has_animation(walkName): library.remove_animation(walkName)
|
||||
var walk := Animation.new()
|
||||
walk.length = WALK_ANIMATION_LENGTH
|
||||
walk.loop_mode = Animation.LOOP_LINEAR
|
||||
var walkTrack := walk.add_track(Animation.TYPE_VALUE)
|
||||
walk.track_set_path(walkTrack, NodePath("Sprite2D:frame"))
|
||||
walk.value_track_set_update_mode(walkTrack, Animation.UPDATE_DISCRETE)
|
||||
for column in range(spriteColumns):
|
||||
walk.track_insert_key(walkTrack, float(column) * WALK_ANIMATION_LENGTH / float(spriteColumns), row * spriteColumns + column)
|
||||
library.add_animation(walkName, walk)
|
||||
|
||||
# Activity animations deliberately reuse the canonical character frame.
|
||||
# Only the timing and a tiny pixel-scale body motion change, so every NPC
|
||||
# keeps the exact same face, outfit, proportions, and palette while working.
|
||||
for activityPrefix in ["activity_work", "activity_talk"]:
|
||||
var activityName := "%s_%s" % [activityPrefix, direction]
|
||||
if library.has_animation(activityName): library.remove_animation(activityName)
|
||||
var activity := Animation.new()
|
||||
activity.length = ACTIVITY_ANIMATION_LENGTH
|
||||
activity.loop_mode = Animation.LOOP_LINEAR
|
||||
var frameTrack := activity.add_track(Animation.TYPE_VALUE)
|
||||
activity.track_set_path(frameTrack, NodePath("Sprite2D:frame"))
|
||||
activity.value_track_set_update_mode(frameTrack, Animation.UPDATE_DISCRETE)
|
||||
var motionFrames: Array[int] = [0, 1, 0, 2, 0]
|
||||
if activityPrefix == "activity_talk":
|
||||
motionFrames = [0, 2, 1, 3, 0]
|
||||
if spriteColumns >= 8:
|
||||
motionFrames = [0, 2, 0, 4, 0]
|
||||
if activityPrefix == "activity_talk":
|
||||
motionFrames = [0, 4, 2, 6, 0]
|
||||
for frameIndex in range(motionFrames.size()):
|
||||
var frameTime := ACTIVITY_ANIMATION_LENGTH * float(frameIndex) / float(motionFrames.size() - 1)
|
||||
activity.track_insert_key(frameTrack, frameTime, row * spriteColumns + motionFrames[frameIndex])
|
||||
var positionTrack := activity.add_track(Animation.TYPE_VALUE)
|
||||
activity.track_set_path(positionTrack, NodePath("Sprite2D:position"))
|
||||
var bobAmount := 1.0 if activityPrefix == "activity_work" else 1.5
|
||||
activity.track_insert_key(positionTrack, 0.0, _baseSpritePosition)
|
||||
activity.track_insert_key(positionTrack, ACTIVITY_ANIMATION_LENGTH * 0.25, _baseSpritePosition + Vector2(0.0, -bobAmount))
|
||||
activity.track_insert_key(positionTrack, ACTIVITY_ANIMATION_LENGTH * 0.5, _baseSpritePosition)
|
||||
activity.track_insert_key(positionTrack, ACTIVITY_ANIMATION_LENGTH * 0.75, _baseSpritePosition + Vector2(0.0, -bobAmount * 0.5))
|
||||
activity.track_insert_key(positionTrack, ACTIVITY_ANIMATION_LENGTH, _baseSpritePosition)
|
||||
var rotationTrack := activity.add_track(Animation.TYPE_VALUE)
|
||||
activity.track_set_path(rotationTrack, NodePath("Sprite2D:rotation"))
|
||||
var tiltAmount := 0.018 if activityPrefix == "activity_work" else 0.028
|
||||
activity.track_insert_key(rotationTrack, 0.0, _baseSpriteRotation)
|
||||
activity.track_insert_key(rotationTrack, ACTIVITY_ANIMATION_LENGTH * 0.25, _baseSpriteRotation - tiltAmount)
|
||||
activity.track_insert_key(rotationTrack, ACTIVITY_ANIMATION_LENGTH * 0.5, _baseSpriteRotation)
|
||||
activity.track_insert_key(rotationTrack, ACTIVITY_ANIMATION_LENGTH * 0.75, _baseSpriteRotation + tiltAmount * 0.6)
|
||||
activity.track_insert_key(rotationTrack, ACTIVITY_ANIMATION_LENGTH, _baseSpriteRotation)
|
||||
var scaleTrack := activity.add_track(Animation.TYPE_VALUE)
|
||||
activity.track_set_path(scaleTrack, NodePath("Sprite2D:scale"))
|
||||
var scaleAmount := 0.012 if activityPrefix == "activity_work" else 0.018
|
||||
activity.track_insert_key(scaleTrack, 0.0, _baseSpriteScale)
|
||||
activity.track_insert_key(scaleTrack, ACTIVITY_ANIMATION_LENGTH * 0.25, _baseSpriteScale * Vector2(1.0, 1.0 + scaleAmount))
|
||||
activity.track_insert_key(scaleTrack, ACTIVITY_ANIMATION_LENGTH * 0.5, _baseSpriteScale)
|
||||
activity.track_insert_key(scaleTrack, ACTIVITY_ANIMATION_LENGTH * 0.75, _baseSpriteScale * Vector2(1.0, 1.0 + scaleAmount * 0.5))
|
||||
activity.track_insert_key(scaleTrack, ACTIVITY_ANIMATION_LENGTH, _baseSpriteScale)
|
||||
library.add_animation(activityName, activity)
|
||||
|
||||
func apply_snapshot(data: Dictionary) -> void:
|
||||
var incomingVersion := int(data.get("version", 0))
|
||||
if stateVersion > incomingVersion:
|
||||
return
|
||||
var isInitialSnapshot := stateVersion < 0
|
||||
|
||||
npcId = str(data.get("npc_id", data.get("npcId", npcId))).strip_edges()
|
||||
_configure_visual(str(data.get("scene", "classic_whale")))
|
||||
stateVersion = incomingVersion
|
||||
npcName = str(data.get("name", npcName)).strip_edges()
|
||||
worldState = str(data.get("state", "idle")).strip_edges()
|
||||
publicIntention = str(data.get("public_intention", data.get("publicIntention", ""))).strip_edges()
|
||||
dailyGoal = str(data.get("daily_goal", data.get("dailyGoal", ""))).strip_edges()
|
||||
planSource = str(data.get("plan_source", data.get("planSource", "fallback"))).strip_edges()
|
||||
var activityValue: Variant = data.get("current_activity", data.get("currentActivity", {}))
|
||||
currentActivity = activityValue if activityValue is Dictionary else {}
|
||||
dialogue = str(data.get("dialogue", _fallback_dialogue())).strip_edges()
|
||||
movementState = str(data.get("movement_state", data.get("movementState", "idle"))).strip_edges()
|
||||
var snapshotDirection := str(data.get("direction", lastDirection)).strip_edges().to_lower()
|
||||
if DIRECTION_ROWS.has(snapshotDirection):
|
||||
lastDirection = snapshotDirection
|
||||
showNameplate = true
|
||||
var snapshotPosition := Vector2(float(data.get("x", global_position.x)), float(data.get("y", global_position.y)))
|
||||
if isInitialSnapshot:
|
||||
_place_at_clear_position(snapshotPosition)
|
||||
else:
|
||||
_move_to_authoritative_position(snapshotPosition)
|
||||
_update_nameplate()
|
||||
_update_world_sort_z()
|
||||
var snapshotAction: Variant = data.get("active_action", data.get("activeAction", {}))
|
||||
if snapshotAction is Dictionary and not (snapshotAction as Dictionary).is_empty():
|
||||
_apply_action(snapshotAction, int(data.get("server_now", 0)))
|
||||
else:
|
||||
activeAction = {}
|
||||
if _is_activity_state():
|
||||
_play_activity_animation(str(currentActivity.get("activityKind", currentActivity.get("activity_kind", ""))))
|
||||
else:
|
||||
_play_idle_animation()
|
||||
|
||||
func apply_action_started(data: Dictionary) -> void:
|
||||
_apply_action(data.get("action", {}), int(data.get("server_now", data.get("serverNow", 0))))
|
||||
|
||||
func _is_activity_state() -> bool:
|
||||
var normalizedState := worldState.strip_edges().to_lower()
|
||||
if normalizedState in ["working", "talking", "performing", "acting", "socializing", "sharing"]:
|
||||
return not currentActivity.is_empty() or normalizedState != "working"
|
||||
return false
|
||||
|
||||
func apply_action_completed(data: Dictionary) -> void:
|
||||
var action: Variant = data.get("action", {})
|
||||
if not (action is Dictionary):
|
||||
return
|
||||
var completed: Dictionary = action
|
||||
var actionKind := str(completed.get("kind", "walk"))
|
||||
var incomingVersion := int(completed.get("version", 0))
|
||||
if incomingVersion < stateVersion:
|
||||
return
|
||||
stateVersion = incomingVersion
|
||||
if actionKind == "transition":
|
||||
visible = false
|
||||
else:
|
||||
var completedPosition := Vector2(float(completed.get("to_x", completed.get("toX", global_position.x))), float(completed.get("to_y", completed.get("toY", global_position.y))))
|
||||
if not _move_to_authoritative_position(completedPosition):
|
||||
movementState = "idle"
|
||||
_play_idle_animation()
|
||||
return
|
||||
_update_world_sort_z()
|
||||
activeAction = {}
|
||||
movementState = "idle"
|
||||
_play_idle_animation()
|
||||
|
||||
func _apply_action(value: Variant, serverNow: int) -> void:
|
||||
if not (value is Dictionary):
|
||||
return
|
||||
var incoming: Dictionary = value
|
||||
var incomingVersion := int(incoming.get("version", 0))
|
||||
if incomingVersion < stateVersion:
|
||||
return
|
||||
stateVersion = incomingVersion
|
||||
activeAction = incoming
|
||||
visible = true
|
||||
var actionKind := str(incoming.get("kind", "walk"))
|
||||
movementState = "walk" if actionKind == "walk" else "idle"
|
||||
worldState = "travelling" if actionKind == "transition" else ("talking" if str(incoming.get("activity_kind", "")) == "socialize" else ("working" if actionKind == "perform" else "walking"))
|
||||
var dx := float(incoming.get("to_x", incoming.get("toX", 0.0))) - float(incoming.get("from_x", incoming.get("fromX", 0.0)))
|
||||
var dy := float(incoming.get("to_y", incoming.get("toY", 0.0))) - float(incoming.get("from_y", incoming.get("fromY", 0.0)))
|
||||
if actionKind == "walk":
|
||||
if absf(dx) > absf(dy):
|
||||
lastDirection = "right" if dx >= 0.0 else "left"
|
||||
else:
|
||||
lastDirection = "down" if dy >= 0.0 else "up"
|
||||
if animation_player != null and actionKind == "walk":
|
||||
if visualScene == "niulai_ambassador" and has_node("Sprite2D"):
|
||||
$Sprite2D.texture = NIULAI_TEXTURE
|
||||
$Sprite2D.hframes = 4
|
||||
$Sprite2D.vframes = 4
|
||||
animation_player.play("walk_%s" % lastDirection)
|
||||
elif animation_player != null and actionKind == "perform":
|
||||
_play_activity_animation(str(incoming.get("activity_kind", incoming.get("activityKind", ""))))
|
||||
elif animation_player != null:
|
||||
_play_idle_animation()
|
||||
var startedAt := int(incoming.get("started_at", incoming.get("startedAt", 0)))
|
||||
var completesAt := int(incoming.get("completes_at", incoming.get("completesAt", startedAt)))
|
||||
var offset := Time.get_ticks_msec() - serverNow if serverNow > 0 else 0
|
||||
_actionStartedLocalMsec = startedAt + offset
|
||||
_actionCompletesLocalMsec = completesAt + offset
|
||||
if actionKind != "transition":
|
||||
var from := Vector2(float(incoming.get("from_x", incoming.get("fromX", global_position.x))), float(incoming.get("from_y", incoming.get("fromY", global_position.y))))
|
||||
var to := Vector2(float(incoming.get("to_x", incoming.get("toX", from.x))), float(incoming.get("to_y", incoming.get("toY", from.y))))
|
||||
var duration: int = maxi(1, _actionCompletesLocalMsec - _actionStartedLocalMsec)
|
||||
var progress := clampf(float(Time.get_ticks_msec() - _actionStartedLocalMsec) / float(duration), 0.0, 1.0)
|
||||
_move_to_authoritative_position(from.lerp(to, progress))
|
||||
_update_world_sort_z()
|
||||
|
||||
func _move_to_authoritative_position(target: Vector2) -> bool:
|
||||
# Network NPC positions are authoritative. Static map collisions are already
|
||||
# resolved by the server's route graph; blocking the replicated position on
|
||||
# the client makes every NPC freeze when a decorative collider is slightly
|
||||
# offset from a route point. Keep the shape for interaction, but never reject
|
||||
# an authoritative movement update locally.
|
||||
global_position = target
|
||||
return true
|
||||
|
||||
func _place_at_clear_position(target: Vector2) -> bool:
|
||||
global_position = target
|
||||
return true
|
||||
|
||||
func _has_static_collision_at(target: Vector2) -> bool:
|
||||
var collision := get_node_or_null("CollisionShape2D") as CollisionShape2D
|
||||
if collision == null or collision.shape == null or not is_inside_tree():
|
||||
return false
|
||||
var query := PhysicsShapeQueryParameters2D.new()
|
||||
query.shape = collision.shape
|
||||
var candidateTransform := collision.global_transform
|
||||
candidateTransform.origin += target - global_position
|
||||
query.transform = candidateTransform
|
||||
query.collision_mask = 1
|
||||
query.collide_with_areas = false
|
||||
query.collide_with_bodies = true
|
||||
query.exclude = [get_rid()]
|
||||
for hit in get_world_2d().direct_space_state.intersect_shape(query, 16):
|
||||
if hit.get("collider") is StaticBody2D:
|
||||
return true
|
||||
return false
|
||||
|
||||
func _report_blocked_route(position: Vector2) -> void:
|
||||
var actionId := str(activeAction.get("action_id", activeAction.get("actionId", "snapshot")))
|
||||
if actionId == _lastBlockedActionId:
|
||||
return
|
||||
_lastBlockedActionId = actionId
|
||||
push_warning("NetworkNpc route blocked by static collision: npc=%s action=%s position=%s" % [npcId, actionId, position])
|
||||
|
||||
func _fallback_dialogue() -> String:
|
||||
if not publicIntention.is_empty():
|
||||
return "你好,我是%s。%s。" % [npcName, publicIntention]
|
||||
return "你好,我是%s。" % npcName
|
||||
1
scenes/characters/NetworkNpc.gd.uid
Normal file
1
scenes/characters/NetworkNpc.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bfkuofgw3ftnp
|
||||
@@ -6,10 +6,23 @@ signal player_moved(position: Vector2)
|
||||
|
||||
# 常量定义
|
||||
const MOVE_SPEED = 200.0
|
||||
const PLAYER_COLLISION_LAYER = 1
|
||||
const PLAYER_COLLISION_MASK = 3
|
||||
const WORLD_SORT_Z_OFFSET = 2048
|
||||
const INTERACTION_COLLISION_MASK = 2
|
||||
const WALK_ANIMATION_LENGTH = 0.8
|
||||
const NAME_LABEL_OFFSET: Vector2 = Vector2(-70, -112)
|
||||
const NAME_LABEL_RENDER_SCALE: float = 0.5
|
||||
const NAME_LABEL_FONT_SIZE: int = 16
|
||||
const NAME_LABEL_VISUAL_HEIGHT: int = 15
|
||||
const NAME_LABEL_VISUAL_MIN_WIDTH: int = 64
|
||||
const NAME_LABEL_VISUAL_MAX_WIDTH: int = 100
|
||||
const NAME_LABEL_VISUAL_CHAR_WIDTH: int = 10
|
||||
const NAME_LABEL_OFFSET_Y: float = -72.0
|
||||
const NAME_LABEL_SIDE_OFFSET_Y: float = -84.0
|
||||
const NAME_LABEL_BACK_OFFSET_Y: float = -78.0
|
||||
const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres")
|
||||
const NAME_LABEL_FONT_ZH: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2")
|
||||
const NAME_LABEL_FONT_LATIN: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2")
|
||||
const DIRECTION_ROWS: Dictionary = {
|
||||
"down": 0,
|
||||
"up": 1,
|
||||
@@ -24,9 +37,32 @@ const DIRECTION_ROWS: Dictionary = {
|
||||
|
||||
var lastDirection: String = "down"
|
||||
var _nameLabel: Label
|
||||
var _nicknameFont: FontFile
|
||||
var _cachedNameLabelText: String = ""
|
||||
var _cachedNameLabelDirection: String = ""
|
||||
var _cachedNameLabelVisible: bool = false
|
||||
var _movementLocked: bool = false
|
||||
var _lastEmittedMovementState: String = "idle"
|
||||
var _spectator_mode: bool = false
|
||||
|
||||
func set_spectator_mode(enabled: bool) -> void:
|
||||
_spectator_mode = enabled
|
||||
if is_instance_valid(sprite):
|
||||
sprite.visible = not enabled
|
||||
if is_instance_valid(_nameLabel):
|
||||
_nameLabel.visible = not enabled
|
||||
if is_instance_valid(ray_cast):
|
||||
ray_cast.enabled = not enabled
|
||||
if enabled:
|
||||
collision_layer = 0
|
||||
collision_mask = 0
|
||||
else:
|
||||
collision_layer = PLAYER_COLLISION_LAYER
|
||||
collision_mask = PLAYER_COLLISION_MASK
|
||||
|
||||
func _ready() -> void:
|
||||
collision_layer = PLAYER_COLLISION_LAYER
|
||||
collision_mask = PLAYER_COLLISION_MASK
|
||||
_reset_movement_input_state()
|
||||
_apply_current_appearance()
|
||||
_subscribe_to_appearance_events()
|
||||
@@ -81,6 +117,8 @@ func _physics_process(delta: float) -> void:
|
||||
_handle_interaction()
|
||||
|
||||
func _handle_interaction() -> void:
|
||||
if _spectator_mode:
|
||||
return
|
||||
if _is_text_input_focused():
|
||||
return
|
||||
if Input.is_action_just_pressed("interact"):
|
||||
@@ -100,6 +138,7 @@ func _handle_movement(_delta: float) -> void:
|
||||
velocity = Vector2.ZERO
|
||||
_play_idle_animation()
|
||||
move_and_slide()
|
||||
_emit_movement_sync("idle")
|
||||
return
|
||||
|
||||
# 输入框获得焦点时禁止移动,避免聊天/表单输入影响角色
|
||||
@@ -108,6 +147,7 @@ func _handle_movement(_delta: float) -> void:
|
||||
velocity = Vector2.ZERO
|
||||
_play_idle_animation()
|
||||
move_and_slide()
|
||||
_emit_movement_sync("idle")
|
||||
return
|
||||
|
||||
# 获取移动向量 (参考 docs/02-开发规范/输入映射配置.md)
|
||||
@@ -126,17 +166,28 @@ func _handle_movement(_delta: float) -> void:
|
||||
|
||||
move_and_slide()
|
||||
|
||||
# 发送移动事件 (如果位置发生明显变化)
|
||||
if velocity.length() > 0:
|
||||
# 移动中持续发送位置;停止时额外发送一次 idle,供远端切回待机动画。
|
||||
var movementState := "walk" if velocity.length() > 0 else "idle"
|
||||
_emit_movement_sync(movementState)
|
||||
|
||||
func _emit_movement_sync(movementState: String) -> void:
|
||||
if _spectator_mode:
|
||||
return
|
||||
if movementState == "walk":
|
||||
player_moved.emit(global_position)
|
||||
if movementState == "walk" or movementState != _lastEmittedMovementState:
|
||||
EventSystem.emit_event(EventNames.PLAYER_MOVED, {
|
||||
"position": global_position
|
||||
"position": global_position,
|
||||
"direction": lastDirection,
|
||||
"movement_state": movementState
|
||||
})
|
||||
_lastEmittedMovementState = movementState
|
||||
|
||||
func _update_animation_state(direction: Vector2) -> void:
|
||||
if not animation_player:
|
||||
return
|
||||
|
||||
var previousDirection := lastDirection
|
||||
# Determine primary direction
|
||||
if abs(direction.x) > abs(direction.y):
|
||||
if direction.x > 0:
|
||||
@@ -153,6 +204,8 @@ func _update_animation_state(direction: Vector2) -> void:
|
||||
lastDirection = "up"
|
||||
ray_cast.target_position = Vector2(0, -60)
|
||||
|
||||
if lastDirection != previousDirection:
|
||||
_update_name_label()
|
||||
animation_player.play("walk_" + lastDirection)
|
||||
|
||||
func _play_idle_animation() -> void:
|
||||
@@ -241,19 +294,61 @@ func _create_name_label() -> void:
|
||||
return
|
||||
_nameLabel = Label.new()
|
||||
_nameLabel.name = "NameLabel"
|
||||
_nameLabel.position = NAME_LABEL_OFFSET
|
||||
_nameLabel.custom_minimum_size = Vector2(140, 28)
|
||||
_nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_nameLabel.add_theme_color_override("font_color", Color(0.188, 0.294, 0.424))
|
||||
_nameLabel.add_theme_font_size_override("font_size", 14)
|
||||
_nameLabel.add_theme_stylebox_override("normal", _create_name_label_style())
|
||||
add_child(_nameLabel)
|
||||
|
||||
func _update_name_label() -> void:
|
||||
if not is_instance_valid(_nameLabel):
|
||||
return
|
||||
_nameLabel.text = _current_username()
|
||||
_nameLabel.visible = _settings_bool("show_name_always", false)
|
||||
var displayName := _current_username()
|
||||
var showName := _settings_bool("show_name_always", false)
|
||||
if displayName == _cachedNameLabelText and lastDirection == _cachedNameLabelDirection and showName == _cachedNameLabelVisible:
|
||||
return
|
||||
var visualWidth := _name_label_visual_width(displayName)
|
||||
var renderWidth := ceili(float(visualWidth) / NAME_LABEL_RENDER_SCALE)
|
||||
var renderHeight := ceili(float(NAME_LABEL_VISUAL_HEIGHT) / NAME_LABEL_RENDER_SCALE)
|
||||
|
||||
_nameLabel.theme = WORLD_TEXT_THEME
|
||||
_nameLabel.text = displayName
|
||||
_nameLabel.visible = showName
|
||||
_nameLabel.z_index = 30
|
||||
_nameLabel.scale = Vector2.ONE * NAME_LABEL_RENDER_SCALE
|
||||
_nameLabel.position = Vector2(float(visualWidth) * -0.5, _name_label_offset_y())
|
||||
_nameLabel.custom_minimum_size = Vector2(renderWidth, renderHeight)
|
||||
_nameLabel.size = _nameLabel.custom_minimum_size
|
||||
_nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_nameLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_nameLabel.clip_text = true
|
||||
_nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
_nameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_nameLabel.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0))
|
||||
_nameLabel.add_theme_color_override("font_outline_color", Color(1.0, 0.965, 0.88, 0.98))
|
||||
_nameLabel.add_theme_constant_override("outline_size", 3)
|
||||
_nameLabel.add_theme_font_override("font", _get_nickname_font())
|
||||
_nameLabel.add_theme_font_size_override("font_size", NAME_LABEL_FONT_SIZE)
|
||||
_nameLabel.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
|
||||
_cachedNameLabelText = displayName
|
||||
_cachedNameLabelDirection = lastDirection
|
||||
_cachedNameLabelVisible = showName
|
||||
|
||||
func _name_label_visual_width(displayName: String) -> int:
|
||||
var measuredWidth := _get_nickname_font().get_string_size(displayName, HORIZONTAL_ALIGNMENT_LEFT, -1, NAME_LABEL_FONT_SIZE).x
|
||||
var visualWidth := ceili(measuredWidth * NAME_LABEL_RENDER_SCALE + 20.0)
|
||||
return clampi(visualWidth, NAME_LABEL_VISUAL_MIN_WIDTH, NAME_LABEL_VISUAL_MAX_WIDTH)
|
||||
|
||||
func _get_nickname_font() -> FontFile:
|
||||
if _nicknameFont == null:
|
||||
_nicknameFont = NAME_LABEL_FONT_ZH.duplicate() as FontFile
|
||||
_nicknameFont.fallbacks = [NAME_LABEL_FONT_LATIN]
|
||||
return _nicknameFont
|
||||
|
||||
func _name_label_offset_y() -> float:
|
||||
match lastDirection:
|
||||
"left", "right":
|
||||
return NAME_LABEL_SIDE_OFFSET_Y
|
||||
"up":
|
||||
return NAME_LABEL_BACK_OFFSET_Y
|
||||
_:
|
||||
return NAME_LABEL_OFFSET_Y
|
||||
|
||||
func _current_username() -> String:
|
||||
var authManager := get_node_or_null("/root/AuthManager")
|
||||
@@ -268,16 +363,3 @@ func _settings_bool(key: String, defaultValue: bool) -> bool:
|
||||
if settingsManager != null and settingsManager.has_method("get_bool"):
|
||||
return bool(settingsManager.call("get_bool", key))
|
||||
return defaultValue
|
||||
|
||||
func _create_name_label_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(1.0, 1.0, 1.0, 0.74)
|
||||
style.corner_radius_top_left = 10
|
||||
style.corner_radius_top_right = 10
|
||||
style.corner_radius_bottom_left = 10
|
||||
style.corner_radius_bottom_right = 10
|
||||
style.content_margin_left = 8
|
||||
style.content_margin_right = 8
|
||||
style.content_margin_top = 4
|
||||
style.content_margin_bottom = 4
|
||||
return style
|
||||
|
||||
@@ -7,17 +7,35 @@ class_name RemotePlayer
|
||||
|
||||
var userId: String = ""
|
||||
var username: String = ""
|
||||
var skinId: String = ""
|
||||
const DEFAULT_REMOTE_SKIN_ID: String = "classic_whale"
|
||||
|
||||
var skinId: String = DEFAULT_REMOTE_SKIN_ID
|
||||
var skinAsset: Dictionary = {}
|
||||
var avatarId: String = ""
|
||||
var targetPosition: Vector2 = Vector2.ZERO
|
||||
var cafeCompanionData: Dictionary = {}
|
||||
var movementState: String = "idle"
|
||||
var lastSequence: int = -1
|
||||
var _hasSyncedDirection: bool = false
|
||||
var _nicknameFont: FontFile
|
||||
var _cachedNameLabelText: String = ""
|
||||
var _cachedNameLabelPersona: String = ""
|
||||
var _cachedNameLabelDirection: String = ""
|
||||
var _cachedNameLabelVisible: bool = false
|
||||
|
||||
# 内部状态
|
||||
var lastDirection: String = "down"
|
||||
const WORLD_SORT_Z_OFFSET = 2048
|
||||
const WALK_ANIMATION_LENGTH = 0.8
|
||||
const NAME_LABEL_OFFSET: Vector2 = Vector2(-70, -112)
|
||||
const NAME_LABEL_RENDER_SCALE: float = 0.5
|
||||
const NAME_LABEL_FONT_SIZE: int = 16
|
||||
const NAME_LABEL_VISUAL_HEIGHT: int = 15
|
||||
const NAME_LABEL_VISUAL_MIN_WIDTH: int = 64
|
||||
const NAME_LABEL_VISUAL_MAX_WIDTH: int = 100
|
||||
const NAME_LABEL_VISUAL_CHAR_WIDTH: int = 10
|
||||
const NAME_LABEL_OFFSET_Y: float = -72.0
|
||||
const NAME_LABEL_SIDE_OFFSET_Y: float = -84.0
|
||||
const NAME_LABEL_BACK_OFFSET_Y: float = -78.0
|
||||
const CAFE_NAME_LABEL_RENDER_SCALE: float = 0.5
|
||||
const CAFE_NAME_LABEL_FONT_SIZE: int = 24
|
||||
const CAFE_NAME_LABEL_VISUAL_HEIGHT: int = 22
|
||||
@@ -26,6 +44,8 @@ const CAFE_NAME_LABEL_VISUAL_MAX_WIDTH: int = 118
|
||||
const CAFE_NAME_LABEL_VISUAL_CHAR_WIDTH: int = 12
|
||||
const CAFE_NAME_LABEL_OFFSET_Y: float = -96.0
|
||||
const WORLD_TEXT_THEME = preload("res://assets/ui/world_text_theme.tres")
|
||||
const NAME_LABEL_FONT_ZH: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-zh_hans.ttf.woff2")
|
||||
const NAME_LABEL_FONT_LATIN: FontFile = preload("res://assets/fonts/fusion-pixel-12px/fusion-pixel-12px-proportional-latin.ttf.woff2")
|
||||
const DIRECTION_ROWS: Dictionary = {
|
||||
"down": 0,
|
||||
"up": 1,
|
||||
@@ -73,37 +93,15 @@ func _process(delta: float) -> void:
|
||||
global_position = newPos
|
||||
_update_world_sort_z()
|
||||
else:
|
||||
# 距离很近时直接吸附并播放待机动画
|
||||
# 距离很近时吸附;动画状态由发送端的 idle/walk 决定。
|
||||
global_position = targetPosition
|
||||
_update_world_sort_z()
|
||||
_play_idle_animation()
|
||||
_play_current_animation()
|
||||
|
||||
# 统一初始化方法
|
||||
# data: 包含 camelCase 字段的字典 (userId, username, position 等)
|
||||
func setup(data: Dictionary) -> void:
|
||||
if data.has("userId"):
|
||||
userId = data.userId
|
||||
if data.has("username"):
|
||||
username = str(data.username)
|
||||
if data.has("skin_id"):
|
||||
skinId = str(data.get("skin_id", ""))
|
||||
elif data.has("skinId"):
|
||||
skinId = str(data.get("skinId", ""))
|
||||
if data.has("skin_asset"):
|
||||
var skinAssetData: Variant = data.get("skin_asset", {})
|
||||
skinAsset = skinAssetData if skinAssetData is Dictionary else {}
|
||||
elif data.has("skinAsset"):
|
||||
var skinAssetPayload: Variant = data.get("skinAsset", {})
|
||||
skinAsset = skinAssetPayload if skinAssetPayload is Dictionary else {}
|
||||
if data.has("avatar_id"):
|
||||
avatarId = str(data.get("avatar_id", ""))
|
||||
elif data.has("avatarId"):
|
||||
avatarId = str(data.get("avatarId", ""))
|
||||
if data.has("cafe_companion") or data.has("cafeCompanion"):
|
||||
cafeCompanionData = _normalize_cafe_companion_data(data.get("cafe_companion", data.get("cafeCompanion", null)))
|
||||
_apply_appearance()
|
||||
_configure_cafe_companion_target()
|
||||
_update_name_label()
|
||||
update_metadata(data)
|
||||
|
||||
if data.has("position"):
|
||||
var positionData: Variant = data.position
|
||||
@@ -111,15 +109,73 @@ func setup(data: Dictionary) -> void:
|
||||
global_position = positionData
|
||||
targetPosition = positionData
|
||||
_update_world_sort_z()
|
||||
elif positionData.has("x") and positionData.has("y"):
|
||||
elif positionData is Dictionary and positionData.has("x") and positionData.has("y"):
|
||||
var newPos := Vector2(positionData.x, positionData.y)
|
||||
global_position = newPos
|
||||
targetPosition = newPos
|
||||
_update_world_sort_z()
|
||||
_apply_movement_state(data)
|
||||
|
||||
func update_metadata(data: Dictionary) -> void:
|
||||
var appearanceChanged := false
|
||||
var companionChanged := false
|
||||
if data.has("userId"):
|
||||
userId = data.userId
|
||||
if data.has("username"):
|
||||
username = str(data.username)
|
||||
if data.has("skin_id"):
|
||||
var nextSkinId := _normalize_remote_skin_id(str(data.get("skin_id", "")))
|
||||
appearanceChanged = appearanceChanged or nextSkinId != skinId
|
||||
skinId = nextSkinId
|
||||
elif data.has("skinId"):
|
||||
var nextSkinId := _normalize_remote_skin_id(str(data.get("skinId", "")))
|
||||
appearanceChanged = appearanceChanged or nextSkinId != skinId
|
||||
skinId = nextSkinId
|
||||
if data.has("skin_asset"):
|
||||
var skinAssetData: Variant = data.get("skin_asset", {})
|
||||
var nextSkinAsset: Dictionary = skinAssetData if skinAssetData is Dictionary else {}
|
||||
appearanceChanged = appearanceChanged or nextSkinAsset != skinAsset
|
||||
skinAsset = nextSkinAsset
|
||||
elif data.has("skinAsset"):
|
||||
var skinAssetPayload: Variant = data.get("skinAsset", {})
|
||||
var nextSkinAsset: Dictionary = skinAssetPayload if skinAssetPayload is Dictionary else {}
|
||||
appearanceChanged = appearanceChanged or nextSkinAsset != skinAsset
|
||||
skinAsset = nextSkinAsset
|
||||
if data.has("avatar_id"):
|
||||
avatarId = str(data.get("avatar_id", ""))
|
||||
elif data.has("avatarId"):
|
||||
avatarId = str(data.get("avatarId", ""))
|
||||
if data.has("cafe_companion") or data.has("cafeCompanion"):
|
||||
var nextCompanionData := _normalize_cafe_companion_data(data.get("cafe_companion", data.get("cafeCompanion", null)))
|
||||
companionChanged = nextCompanionData != cafeCompanionData
|
||||
cafeCompanionData = nextCompanionData
|
||||
if appearanceChanged:
|
||||
_apply_appearance()
|
||||
if companionChanged:
|
||||
_configure_cafe_companion_target()
|
||||
_update_name_label()
|
||||
|
||||
func _normalize_remote_skin_id(value: String) -> String:
|
||||
var normalized := value.strip_edges()
|
||||
if normalized.is_empty() or normalized == "pending_initial_skin":
|
||||
return DEFAULT_REMOTE_SKIN_ID
|
||||
return normalized
|
||||
|
||||
# 更新目标位置
|
||||
func update_position(newPos: Vector2) -> void:
|
||||
func update_position(newPos: Vector2, direction: String = "", nextMovementState: String = "walk", sequence: int = -1) -> void:
|
||||
if sequence >= 0 and lastSequence >= 0 and sequence <= lastSequence:
|
||||
return
|
||||
if sequence >= 0:
|
||||
lastSequence = sequence
|
||||
var normalizedDirection := _normalize_direction(direction)
|
||||
if not normalizedDirection.is_empty():
|
||||
lastDirection = normalizedDirection
|
||||
_hasSyncedDirection = true
|
||||
_update_name_label()
|
||||
movementState = "walk" if nextMovementState.strip_edges().to_lower() == "walk" else "idle"
|
||||
targetPosition = newPos
|
||||
if global_position.distance_to(targetPosition) <= 1.0:
|
||||
_play_current_animation()
|
||||
|
||||
func _update_world_sort_z() -> void:
|
||||
z_index = WORLD_SORT_Z_OFFSET + int(round(global_position.y))
|
||||
@@ -128,20 +184,43 @@ func _update_animation(moveVec: Vector2) -> void:
|
||||
if not animation_player:
|
||||
return
|
||||
|
||||
# 确定主方向
|
||||
if abs(moveVec.x) > abs(moveVec.y):
|
||||
if moveVec.x > 0:
|
||||
lastDirection = "right"
|
||||
# 新协议使用发送端方向;兼容旧位置包时再从位移向量推断。
|
||||
if not _hasSyncedDirection:
|
||||
if abs(moveVec.x) > abs(moveVec.y):
|
||||
if moveVec.x > 0:
|
||||
lastDirection = "right"
|
||||
else:
|
||||
lastDirection = "left"
|
||||
else:
|
||||
lastDirection = "left"
|
||||
else:
|
||||
if moveVec.y > 0:
|
||||
lastDirection = "down"
|
||||
else:
|
||||
lastDirection = "up"
|
||||
if moveVec.y > 0:
|
||||
lastDirection = "down"
|
||||
else:
|
||||
lastDirection = "up"
|
||||
|
||||
animation_player.play("walk_" + lastDirection)
|
||||
|
||||
func _apply_movement_state(data: Dictionary) -> void:
|
||||
var normalizedDirection := _normalize_direction(str(data.get("direction", "")))
|
||||
if not normalizedDirection.is_empty():
|
||||
lastDirection = normalizedDirection
|
||||
_hasSyncedDirection = true
|
||||
_update_name_label()
|
||||
movementState = "walk" if str(data.get("movement_state", data.get("movementState", "idle"))).strip_edges().to_lower() == "walk" else "idle"
|
||||
lastSequence = int(data.get("sequence", lastSequence))
|
||||
_play_current_animation()
|
||||
|
||||
func _normalize_direction(value: String) -> String:
|
||||
var normalized := value.strip_edges().to_lower()
|
||||
return normalized if normalized in ["down", "up", "right", "left"] else ""
|
||||
|
||||
func _play_current_animation() -> void:
|
||||
if animation_player == null:
|
||||
return
|
||||
if movementState == "walk":
|
||||
animation_player.play("walk_" + lastDirection)
|
||||
else:
|
||||
_play_idle_animation()
|
||||
|
||||
func _play_idle_animation() -> void:
|
||||
if animation_player:
|
||||
animation_player.play("idle_" + lastDirection)
|
||||
@@ -206,25 +285,26 @@ func _create_name_label() -> void:
|
||||
return
|
||||
_nameLabel = Label.new()
|
||||
_nameLabel.name = "NameLabel"
|
||||
_nameLabel.position = NAME_LABEL_OFFSET
|
||||
_nameLabel.custom_minimum_size = Vector2(140, 28)
|
||||
_nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_nameLabel.add_theme_color_override("font_color", Color(0.188, 0.294, 0.424))
|
||||
_nameLabel.add_theme_font_size_override("font_size", 14)
|
||||
_nameLabel.add_theme_stylebox_override("normal", _create_name_label_style())
|
||||
add_child(_nameLabel)
|
||||
|
||||
func _update_name_label() -> void:
|
||||
if not is_instance_valid(_nameLabel):
|
||||
return
|
||||
var personaName := str(cafeCompanionData.get("persona_name", "")).strip_edges()
|
||||
var displayName := personaName if not personaName.is_empty() else (username if not username.strip_edges().is_empty() else "玩家")
|
||||
var showName := true if not personaName.is_empty() else (_is_guest_mode() or _settings_bool("show_name_always", false))
|
||||
if displayName == _cachedNameLabelText and personaName == _cachedNameLabelPersona and lastDirection == _cachedNameLabelDirection and showName == _cachedNameLabelVisible:
|
||||
return
|
||||
if not personaName.is_empty():
|
||||
_configure_cafe_name_label(personaName)
|
||||
_nameLabel.visible = true
|
||||
return
|
||||
_configure_default_name_label()
|
||||
_nameLabel.text = username if not username.strip_edges().is_empty() else "玩家"
|
||||
_nameLabel.visible = _settings_bool("show_name_always", false)
|
||||
else:
|
||||
_configure_default_name_label()
|
||||
_nameLabel.text = displayName
|
||||
_nameLabel.visible = showName
|
||||
_cachedNameLabelText = displayName
|
||||
_cachedNameLabelPersona = personaName
|
||||
_cachedNameLabelDirection = lastDirection
|
||||
_cachedNameLabelVisible = showName
|
||||
|
||||
func _configure_cafe_name_label(personaName: String) -> void:
|
||||
var displayName := personaName.strip_edges()
|
||||
@@ -246,30 +326,61 @@ func _configure_cafe_name_label(personaName: String) -> void:
|
||||
_nameLabel.clip_text = true
|
||||
_nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
_nameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_nameLabel.add_theme_color_override("font_color", Color(0.12, 0.20, 0.24, 1.0))
|
||||
_nameLabel.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0))
|
||||
_nameLabel.add_theme_color_override("font_shadow_color", Color(1.0, 1.0, 1.0, 0.85))
|
||||
_nameLabel.add_theme_constant_override("shadow_offset_x", 0)
|
||||
_nameLabel.add_theme_constant_override("shadow_offset_y", 1)
|
||||
_nameLabel.remove_theme_color_override("font_outline_color")
|
||||
_nameLabel.remove_theme_constant_override("outline_size")
|
||||
_nameLabel.add_theme_font_size_override("font_size", CAFE_NAME_LABEL_FONT_SIZE)
|
||||
_nameLabel.add_theme_stylebox_override("normal", _create_cafe_name_label_style())
|
||||
|
||||
func _configure_default_name_label() -> void:
|
||||
_nameLabel.theme = null
|
||||
_nameLabel.position = NAME_LABEL_OFFSET
|
||||
_nameLabel.scale = Vector2.ONE
|
||||
_nameLabel.custom_minimum_size = Vector2(140, 28)
|
||||
var displayName := username if not username.strip_edges().is_empty() else "玩家"
|
||||
var visualWidth := _name_label_visual_width(displayName)
|
||||
var renderWidth := ceili(float(visualWidth) / NAME_LABEL_RENDER_SCALE)
|
||||
var renderHeight := ceili(float(NAME_LABEL_VISUAL_HEIGHT) / NAME_LABEL_RENDER_SCALE)
|
||||
|
||||
_nameLabel.theme = WORLD_TEXT_THEME
|
||||
_nameLabel.z_index = 30
|
||||
_nameLabel.scale = Vector2.ONE * NAME_LABEL_RENDER_SCALE
|
||||
_nameLabel.position = Vector2(float(visualWidth) * -0.5, _name_label_offset_y())
|
||||
_nameLabel.custom_minimum_size = Vector2(renderWidth, renderHeight)
|
||||
_nameLabel.size = _nameLabel.custom_minimum_size
|
||||
_nameLabel.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_nameLabel.vertical_alignment = VERTICAL_ALIGNMENT_TOP
|
||||
_nameLabel.clip_text = false
|
||||
_nameLabel.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING
|
||||
_nameLabel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_nameLabel.add_theme_color_override("font_color", Color(0.188, 0.294, 0.424))
|
||||
_nameLabel.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_nameLabel.clip_text = true
|
||||
_nameLabel.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
_nameLabel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_nameLabel.add_theme_color_override("font_color", Color(0.09, 0.25, 0.31, 1.0))
|
||||
_nameLabel.remove_theme_color_override("font_shadow_color")
|
||||
_nameLabel.remove_theme_constant_override("shadow_offset_x")
|
||||
_nameLabel.remove_theme_constant_override("shadow_offset_y")
|
||||
_nameLabel.add_theme_font_size_override("font_size", 14)
|
||||
_nameLabel.add_theme_stylebox_override("normal", _create_name_label_style())
|
||||
_nameLabel.add_theme_color_override("font_outline_color", Color(1.0, 0.965, 0.88, 0.98))
|
||||
_nameLabel.add_theme_constant_override("outline_size", 3)
|
||||
_nameLabel.add_theme_font_override("font", _get_nickname_font())
|
||||
_nameLabel.add_theme_font_size_override("font_size", NAME_LABEL_FONT_SIZE)
|
||||
_nameLabel.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
|
||||
|
||||
func _name_label_visual_width(displayName: String) -> int:
|
||||
var measuredWidth := _get_nickname_font().get_string_size(displayName, HORIZONTAL_ALIGNMENT_LEFT, -1, NAME_LABEL_FONT_SIZE).x
|
||||
var visualWidth := ceili(measuredWidth * NAME_LABEL_RENDER_SCALE + 20.0)
|
||||
return clampi(visualWidth, NAME_LABEL_VISUAL_MIN_WIDTH, NAME_LABEL_VISUAL_MAX_WIDTH)
|
||||
|
||||
func _get_nickname_font() -> FontFile:
|
||||
if _nicknameFont == null:
|
||||
_nicknameFont = NAME_LABEL_FONT_ZH.duplicate() as FontFile
|
||||
_nicknameFont.fallbacks = [NAME_LABEL_FONT_LATIN]
|
||||
return _nicknameFont
|
||||
|
||||
func _name_label_offset_y() -> float:
|
||||
match lastDirection:
|
||||
"left", "right":
|
||||
return NAME_LABEL_SIDE_OFFSET_Y
|
||||
"up":
|
||||
return NAME_LABEL_BACK_OFFSET_Y
|
||||
_:
|
||||
return NAME_LABEL_OFFSET_Y
|
||||
|
||||
func _cafe_name_label_visual_width(displayName: String) -> int:
|
||||
var estimatedWidth := displayName.length() * CAFE_NAME_LABEL_VISUAL_CHAR_WIDTH + 28
|
||||
@@ -340,18 +451,9 @@ func _settings_bool(key: String, defaultValue: bool) -> bool:
|
||||
return bool(settingsManager.call("get_bool", key))
|
||||
return defaultValue
|
||||
|
||||
func _create_name_label_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(1.0, 1.0, 1.0, 0.74)
|
||||
style.corner_radius_top_left = 10
|
||||
style.corner_radius_top_right = 10
|
||||
style.corner_radius_bottom_left = 10
|
||||
style.corner_radius_bottom_right = 10
|
||||
style.content_margin_left = 8
|
||||
style.content_margin_right = 8
|
||||
style.content_margin_top = 4
|
||||
style.content_margin_bottom = 4
|
||||
return style
|
||||
func _is_guest_mode() -> bool:
|
||||
var chatManager := get_node_or_null("/root/ChatManager")
|
||||
return chatManager != null and chatManager.has_method("is_guest_mode") and bool(chatManager.call("is_guest_mode"))
|
||||
|
||||
func _create_cafe_name_label_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
|
||||
18
scenes/characters/network_npc.tscn
Normal file
18
scenes/characters/network_npc.tscn
Normal file
@@ -0,0 +1,18 @@
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="PackedScene" path="res://scenes/characters/npc.tscn" id="1_base"]
|
||||
[ext_resource type="Script" path="res://scenes/characters/NetworkNpc.gd" id="2_script"]
|
||||
[ext_resource type="Texture2D" path="res://assets/characters/generated/whale_researcher_v2/final_no_feet/processed/whale_researcher_no_feet_spritesheet.png" id="3_researcher"]
|
||||
|
||||
[node name="NetworkNpc" instance=ExtResource("1_base")]
|
||||
script = ExtResource("2_script")
|
||||
showNameplate = true
|
||||
nameplateOffsetY = -72.0
|
||||
|
||||
[node name="Sprite2D" parent="." index="0"]
|
||||
position = Vector2(0, -29)
|
||||
scale = Vector2(0.5, 0.5)
|
||||
texture_filter = 1
|
||||
texture = ExtResource("3_researcher")
|
||||
hframes = 8
|
||||
vframes = 4
|
||||
@@ -157,6 +157,8 @@ _data = {
|
||||
|
||||
[node name="Player" type="CharacterBody2D"]
|
||||
script = ExtResource("1_script")
|
||||
collision_layer = 1
|
||||
collision_mask = 3
|
||||
|
||||
[node name="Sprite2D" type="Sprite2D" parent="."]
|
||||
texture_filter = 2
|
||||
|
||||
@@ -163,6 +163,41 @@ func set_message(from_user: String, content: String, timestamp: float, is_self:
|
||||
# 应用样式
|
||||
_apply_style()
|
||||
|
||||
# NPC 会话由外层复古对话框承载,单条内容不再绘制聊天气泡。
|
||||
func set_dialogue_mode(enabled: bool) -> void:
|
||||
if not enabled:
|
||||
return
|
||||
_cache_node_refs()
|
||||
size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
custom_minimum_size.y = 0.0
|
||||
if message_row:
|
||||
message_row.alignment = BoxContainer.ALIGNMENT_BEGIN
|
||||
message_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
message_row.add_theme_constant_override("separation", 0)
|
||||
if left_avatar_panel:
|
||||
left_avatar_panel.visible = false
|
||||
if right_avatar_panel:
|
||||
right_avatar_panel.visible = false
|
||||
if bubble_panel:
|
||||
bubble_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
bubble_panel.custom_minimum_size.x = 0.0
|
||||
bubble_panel.add_theme_stylebox_override("panel", StyleBoxEmpty.new())
|
||||
if text_container:
|
||||
text_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
text_container.add_theme_constant_override("separation", 4)
|
||||
if user_info_container:
|
||||
user_info_container.alignment = BoxContainer.ALIGNMENT_BEGIN
|
||||
if username_label:
|
||||
username_label.add_theme_color_override("font_color", Color(0.08, 0.24, 0.27, 1.0))
|
||||
username_label.add_theme_font_size_override("font_size", 16)
|
||||
if timestamp_label:
|
||||
timestamp_label.visible = false
|
||||
if content_label:
|
||||
content_label.custom_minimum_size.x = 0.0
|
||||
content_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
content_label.add_theme_color_override("default_color", Color(0.12, 0.18, 0.18, 1.0))
|
||||
content_label.add_theme_font_size_override("normal_font_size", 17)
|
||||
|
||||
# ============================================================================
|
||||
# 内部方法 - 样式处理
|
||||
# ============================================================================
|
||||
|
||||
@@ -25,12 +25,12 @@ const REGISTRATION_CHOICE_SPRITESHEET_GRID_BOX_PATH: String = REGISTRATION_CHOIC
|
||||
const REGISTRATION_CHOICE_REFERENCE_UPLOAD_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/reference_upload_box.png"
|
||||
const REGISTRATION_CHOICE_IMAGE_PLACEHOLDER_BOX_PATH: String = REGISTRATION_CHOICE_ASSET_DIR + "/image_placeholder_box.png"
|
||||
const UI_FONT_PATH: String = "res://assets/fonts/msyh-web.ttf"
|
||||
const AVATAR_MASK_SHADER = preload("res://assets/shaders/avatar_round_mask.gdshader")
|
||||
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
|
||||
const WebFilePicker = preload("res://_Core/utils/WebFilePicker.gd")
|
||||
const SKIN_GENERATION_CREATE_ENDPOINT: String = "/api/skin-generation/jobs"
|
||||
const SKIN_GENERATION_POLL_ENDPOINT_TEMPLATE: String = "/api/skin-generation/jobs/%s"
|
||||
const SKIN_GENERATION_POLL_INTERVAL: float = 2.0
|
||||
const SKIN_GENERATION_REQUEST_TIMEOUT: float = 24.0
|
||||
const AVATAR_NATIVE_FILE_FILTERS: Array[String] = ["*.png,*.jpg,*.jpeg,*.webp"]
|
||||
const SKIN_NATIVE_FILE_FILTERS: Array[String] = ["*.png"]
|
||||
const WEB_FILE_MAX_BYTES: int = 8 * 1024 * 1024
|
||||
@@ -46,7 +46,7 @@ const AUTH_STAGE_VIEWPORT_RATIO: float = 0.92
|
||||
const LOGIN_FRAME_RECT: Rect2 = Rect2(270, 40, 780, 680)
|
||||
const REGISTER_FRAME_RECT: Rect2 = Rect2(150, 15, 1020, 727)
|
||||
const LOGIN_FORM_RECT: Rect2 = Rect2(432, 210, 430, 430)
|
||||
const REGISTER_FORM_RECT: Rect2 = Rect2(292, 155, 446, 500)
|
||||
const REGISTER_FORM_RECT: Rect2 = Rect2(292, 128, 446, 500)
|
||||
|
||||
@onready var whale_frame: TextureRect = %WhaleFrame
|
||||
@onready var login_panel: PanelContainer = %LoginPanel
|
||||
@@ -55,8 +55,10 @@ const REGISTER_FORM_RECT: Rect2 = Rect2(292, 155, 446, 500)
|
||||
@onready var login_identifier_input: LineEdit = %LoginIdentifierInput
|
||||
@onready var login_password_input: LineEdit = %LoginPasswordInput
|
||||
@onready var login_button: Button = %LoginButton
|
||||
@onready var guest_button: Button = %GuestButton
|
||||
@onready var show_register_button: Button = %ShowRegisterButton
|
||||
@onready var register_username_input: LineEdit = %RegisterUsernameInput
|
||||
@onready var register_invitation_code_input: LineEdit = %RegisterInvitationCodeInput
|
||||
@onready var register_email_input: LineEdit = %RegisterEmailInput
|
||||
@onready var send_register_code_button: Button = %SendRegisterCodeButton
|
||||
@onready var register_verification_code_input: LineEdit = %RegisterVerificationCodeInput
|
||||
@@ -96,8 +98,6 @@ var _brand_font: SystemFont
|
||||
var _choice_font: Font
|
||||
var _resuming_cached_session: bool = false
|
||||
var _cached_resume_start_generation: int = 0
|
||||
var _skin_generation_create_request: HTTPRequest
|
||||
var _skin_generation_poll_request: HTTPRequest
|
||||
var _skin_generation_active: bool = false
|
||||
var _skin_generation_job_id: String = ""
|
||||
var _skin_generation_poll_elapsed: float = 0.0
|
||||
@@ -110,6 +110,7 @@ var _registration_official_skins: Array = []
|
||||
var _registration_official_skin_index: int = 0
|
||||
var _registration_avatar_source_path: String = ""
|
||||
var _registration_skin_source_path: String = ""
|
||||
var _registration_avatar_controls_visible: bool = false
|
||||
var _workshop_source_image_path: String = ""
|
||||
var _awaiting_registration_skin_generation: bool = false
|
||||
var _awaiting_registration_skin_choice: bool = false
|
||||
@@ -210,7 +211,7 @@ func _build_login_form(parent: Control) -> void:
|
||||
box.offset_right = -12
|
||||
parent.add_child(box)
|
||||
|
||||
var title := _brand_label("TitleLabel", "WhaleTown V2")
|
||||
var title := _brand_label("TitleLabel", "WhaleTown")
|
||||
title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
box.add_child(title)
|
||||
box.add_child(HSeparator.new())
|
||||
@@ -247,6 +248,9 @@ func _build_login_form(parent: Control) -> void:
|
||||
var loginButton := _primary_button("LoginButton", "进入小镇", 25, Vector2(396, 58))
|
||||
loginButton.unique_name_in_owner = true
|
||||
box.add_child(loginButton)
|
||||
var guestButton := _secondary_button("GuestButton", "游客参观", 17, Vector2(396, 40))
|
||||
guestButton.unique_name_in_owner = true
|
||||
box.add_child(guestButton)
|
||||
|
||||
var links := HBoxContainer.new()
|
||||
links.name = "BottomLinks"
|
||||
@@ -268,7 +272,7 @@ func _build_register_form(parent: Control) -> void:
|
||||
var box := VBoxContainer.new()
|
||||
box.name = "RegisterVBox"
|
||||
box.add_theme_constant_override("separation", 3)
|
||||
_place(box, Rect2(8, 40, 422, 448))
|
||||
_place(box, Rect2(8, 20, 422, 478))
|
||||
content.add_child(box)
|
||||
|
||||
var title := _label("RegisterTitleLabel", "注册新居民", 22, TEXT_COLOR, HORIZONTAL_ALIGNMENT_CENTER)
|
||||
@@ -281,6 +285,12 @@ func _build_register_form(parent: Control) -> void:
|
||||
usernameInput.unique_name_in_owner = true
|
||||
box.add_child(usernameInput)
|
||||
|
||||
box.add_child(_register_form_label("邀请码"))
|
||||
var invitationCodeInput := _register_line_edit("RegisterInvitationCodeInput", "输入邀请码", false)
|
||||
invitationCodeInput.unique_name_in_owner = true
|
||||
invitationCodeInput.max_length = 30
|
||||
box.add_child(invitationCodeInput)
|
||||
|
||||
box.add_child(_register_form_label("邮箱"))
|
||||
var emailRow := HBoxContainer.new()
|
||||
emailRow.name = "RegisterEmailRow"
|
||||
@@ -312,7 +322,7 @@ func _build_register_form(parent: Control) -> void:
|
||||
|
||||
var buttonSpacer := Control.new()
|
||||
buttonSpacer.name = "RegisterButtonSpacer"
|
||||
buttonSpacer.custom_minimum_size = Vector2(0, 12)
|
||||
buttonSpacer.custom_minimum_size = Vector2(0, 4)
|
||||
box.add_child(buttonSpacer)
|
||||
|
||||
var buttons := HBoxContainer.new()
|
||||
@@ -692,11 +702,12 @@ func _ready() -> void:
|
||||
_scene_manager = get_node_or_null("/root/SceneManager")
|
||||
_appearance_manager = get_node_or_null("/root/AppearanceManager")
|
||||
|
||||
_setup_skin_generation_requests()
|
||||
_connect_signals()
|
||||
_refresh_appearance_ui()
|
||||
_show_login()
|
||||
_notify_web_shell_ready()
|
||||
if _scene_manager != null and _scene_manager.has_method("preload_scene_pack"):
|
||||
_scene_manager.call("preload_scene_pack", "square")
|
||||
|
||||
if _auth_manager != null and bool(_auth_manager.call("is_authenticated")) and _should_auto_resume_cached_session():
|
||||
_resume_cached_session()
|
||||
@@ -711,6 +722,7 @@ func _ready() -> void:
|
||||
|
||||
func _connect_signals() -> void:
|
||||
login_button.pressed.connect(_on_login_pressed)
|
||||
guest_button.pressed.connect(_on_guest_pressed)
|
||||
show_register_button.pressed.connect(_show_register)
|
||||
send_register_code_button.pressed.connect(_on_send_register_code_pressed)
|
||||
register_button.pressed.connect(_on_register_pressed)
|
||||
@@ -757,19 +769,6 @@ func _complete_browser_bootstrap(kind: String) -> void:
|
||||
return
|
||||
_on_login_succeeded(_auth_manager.call("get_current_user"))
|
||||
|
||||
func _setup_skin_generation_requests() -> void:
|
||||
_skin_generation_create_request = HTTPRequest.new()
|
||||
_skin_generation_create_request.name = "SkinGenerationCreateRequest"
|
||||
_skin_generation_create_request.timeout = SKIN_GENERATION_REQUEST_TIMEOUT
|
||||
_skin_generation_create_request.request_completed.connect(_on_skin_generation_create_completed)
|
||||
add_child(_skin_generation_create_request)
|
||||
|
||||
_skin_generation_poll_request = HTTPRequest.new()
|
||||
_skin_generation_poll_request.name = "SkinGenerationPollRequest"
|
||||
_skin_generation_poll_request.timeout = SKIN_GENERATION_REQUEST_TIMEOUT
|
||||
_skin_generation_poll_request.request_completed.connect(_on_skin_generation_poll_completed)
|
||||
add_child(_skin_generation_poll_request)
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not _skin_generation_active or _skin_generation_job_id.is_empty():
|
||||
return
|
||||
@@ -779,10 +778,6 @@ func _process(delta: float) -> void:
|
||||
_poll_skin_generation_job()
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if is_instance_valid(_skin_generation_create_request):
|
||||
_skin_generation_create_request.cancel_request()
|
||||
if is_instance_valid(_skin_generation_poll_request):
|
||||
_skin_generation_poll_request.cancel_request()
|
||||
_skin_generation_active = false
|
||||
_skin_generation_job_id = ""
|
||||
|
||||
@@ -936,6 +931,7 @@ func _set_registration_character_controls_visible(visible: bool) -> void:
|
||||
_refresh_appearance_ui()
|
||||
|
||||
func _set_registration_avatar_controls_visible(visible: bool) -> void:
|
||||
_registration_avatar_controls_visible = visible
|
||||
if not is_instance_valid(registration_character_area):
|
||||
return
|
||||
var controlNames := [
|
||||
@@ -968,6 +964,7 @@ func _refresh_character_preview() -> void:
|
||||
character_sprite.modulate = Color.WHITE
|
||||
character_sprite.frame = 0
|
||||
character_sprite.scale = _sprite_scale_for_height(character_sprite.texture, character_sprite.vframes, MAIN_PREVIEW_HEIGHT)
|
||||
character_sprite.position = Vector2(50, 70) + _sprite_visual_center_offset(character_sprite.texture, character_sprite.hframes, character_sprite.vframes, character_sprite.frame, character_sprite.scale)
|
||||
_clear_workshop_preview()
|
||||
return
|
||||
if _appearance_manager.has_method("apply_skin_to_sprite"):
|
||||
@@ -976,6 +973,7 @@ func _refresh_character_preview() -> void:
|
||||
_appearance_manager.call("apply_skin_to_sprite", character_sprite, previewSkinId)
|
||||
character_sprite.frame = 0
|
||||
character_sprite.scale = _sprite_scale_for_height(character_sprite.texture, character_sprite.vframes, MAIN_PREVIEW_HEIGHT)
|
||||
character_sprite.position = Vector2(50, 70) + _sprite_visual_center_offset(character_sprite.texture, character_sprite.hframes, character_sprite.vframes, character_sprite.frame, character_sprite.scale)
|
||||
_clear_workshop_preview()
|
||||
|
||||
func _clear_workshop_preview() -> void:
|
||||
@@ -1006,6 +1004,7 @@ func _show_workshop_generated_preview_from_image(image: Image) -> void:
|
||||
workshop_preview_sprite.vframes = 4
|
||||
workshop_preview_sprite.frame = 0
|
||||
workshop_preview_sprite.scale = _sprite_scale_for_height(texture, 4, 156.0)
|
||||
workshop_preview_sprite.position = Vector2(75, 105) + _sprite_visual_center_offset(texture, 8, 4, workshop_preview_sprite.frame, workshop_preview_sprite.scale)
|
||||
workshop_preview_sprite.visible = true
|
||||
|
||||
func _show_workshop_source_preview(path: String) -> void:
|
||||
@@ -1102,6 +1101,7 @@ func _create_skin_button(skin: Dictionary, selectedSkinId: String) -> Button:
|
||||
preview.frame = 0
|
||||
preview.position = Vector2(90, 99)
|
||||
preview.scale = _sprite_scale_for_frame_bounds(preview.texture, preview.hframes, preview.vframes, Vector2(136, SKIN_THUMB_HEIGHT))
|
||||
preview.position += _sprite_visual_center_offset(preview.texture, preview.hframes, preview.vframes, preview.frame, preview.scale)
|
||||
preview.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
|
||||
button.add_child(preview)
|
||||
|
||||
@@ -1170,6 +1170,9 @@ func _skin_texture(skin: Dictionary) -> Texture2D:
|
||||
|
||||
func _refresh_avatar_preview() -> void:
|
||||
avatar_preview.custom_minimum_size = Vector2(34, 34)
|
||||
if not _registration_avatar_controls_visible:
|
||||
avatar_preview.hide()
|
||||
return
|
||||
if _registration_avatar_texture == null:
|
||||
_clear_registration_avatar_preview()
|
||||
return
|
||||
@@ -1202,7 +1205,7 @@ func _show_registration_avatar_preview(texture: Texture2D) -> void:
|
||||
return
|
||||
avatar_preview.show()
|
||||
avatar_preview.clip_contents = true
|
||||
avatar_preview.add_theme_stylebox_override("panel", _panel_style(Color.WHITE, 18))
|
||||
avatar_preview.add_theme_stylebox_override("panel", _panel_style(Color.WHITE, 22, Color(0.45, 0.66, 0.84, 0.55), 1))
|
||||
if is_instance_valid(avatar_label):
|
||||
avatar_label.hide()
|
||||
var textureRect := _get_or_create_registration_avatar_texture_rect()
|
||||
@@ -1218,11 +1221,19 @@ func _get_or_create_registration_avatar_texture_rect() -> TextureRect:
|
||||
textureRect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
textureRect.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
|
||||
textureRect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
textureRect.stretch_mode = TextureRect.STRETCH_SCALE
|
||||
textureRect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
|
||||
textureRect.material = _create_avatar_mask_material()
|
||||
textureRect.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
avatar_preview.add_child(textureRect)
|
||||
return textureRect
|
||||
|
||||
func _create_avatar_mask_material() -> ShaderMaterial:
|
||||
var material := ShaderMaterial.new()
|
||||
material.shader = AVATAR_MASK_SHADER
|
||||
material.set_shader_parameter("corner_radius", 0.22)
|
||||
material.set_shader_parameter("edge_feather", 0.008)
|
||||
return material
|
||||
|
||||
func _load_registration_avatar_texture(path: String) -> Texture2D:
|
||||
var cropped := _load_registration_avatar_image(path)
|
||||
if cropped == null:
|
||||
@@ -1432,23 +1443,19 @@ func _on_generate_skin_pressed() -> void:
|
||||
"source_image_base64": _image_to_png_base64(sourceImage),
|
||||
"source_mime_type": "image/png",
|
||||
}
|
||||
var err := _skin_generation_create_request.request(
|
||||
"%s%s" % [NetworkConfig.get_api_base_url(), SKIN_GENERATION_CREATE_ENDPOINT],
|
||||
_auth_json_headers(),
|
||||
HTTPClient.METHOD_POST,
|
||||
JSON.stringify(payload)
|
||||
)
|
||||
if err != OK:
|
||||
var apiClient := get_node_or_null("/root/ApiClient")
|
||||
if apiClient == null or not apiClient.has_method("post_json"):
|
||||
_skin_generation_active = false
|
||||
_set_skin_generation_controls_enabled(true)
|
||||
_set_workshop_generation_status("角色生成请求发送失败:%s" % error_string(err))
|
||||
_set_workshop_generation_status("角色生成服务未加载")
|
||||
return
|
||||
apiClient.call("post_json", SKIN_GENERATION_CREATE_ENDPOINT, payload, _on_skin_generation_create_completed, true)
|
||||
|
||||
func _on_skin_generation_create_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
var parsed := _parse_skin_generation_response(result, responseCode, body)
|
||||
if not bool(parsed.get("ok", false)):
|
||||
func _on_skin_generation_create_completed(success: bool, response: Dictionary, errorInfo: Dictionary) -> void:
|
||||
if not success:
|
||||
_skin_generation_active = false
|
||||
_set_skin_generation_controls_enabled(true)
|
||||
var errorMessage := str(parsed.get("message", "角色生成任务创建失败"))
|
||||
var errorMessage := str(errorInfo.get("message", "角色生成任务创建失败"))
|
||||
if errorMessage.contains("已经使用过注册角色生成机会") or errorMessage.contains("没有可用的注册角色生成机会"):
|
||||
_awaiting_registration_skin_generation = false
|
||||
_set_workshop_generation_status("该账号已完成注册角色生成,正在进入小镇...")
|
||||
@@ -1457,7 +1464,13 @@ func _on_skin_generation_create_completed(result: int, responseCode: int, _heade
|
||||
_set_workshop_generation_status(errorMessage)
|
||||
return
|
||||
|
||||
var data: Dictionary = parsed.get("data", {})
|
||||
var dataVariant: Variant = response.get("data", {})
|
||||
if not (dataVariant is Dictionary):
|
||||
_skin_generation_active = false
|
||||
_set_skin_generation_controls_enabled(true)
|
||||
_set_workshop_generation_status("服务器返回的生成任务格式错误")
|
||||
return
|
||||
var data: Dictionary = dataVariant
|
||||
_skin_generation_job_id = str(data.get("job_id", "")).strip_edges()
|
||||
if _skin_generation_job_id.is_empty():
|
||||
_skin_generation_active = false
|
||||
@@ -1473,26 +1486,30 @@ func _poll_skin_generation_job() -> void:
|
||||
|
||||
_skin_generation_poll_in_flight = true
|
||||
var endpoint := SKIN_GENERATION_POLL_ENDPOINT_TEMPLATE % _skin_generation_job_id
|
||||
var err := _skin_generation_poll_request.request(
|
||||
"%s%s" % [NetworkConfig.get_api_base_url(), endpoint],
|
||||
_auth_json_headers(),
|
||||
HTTPClient.METHOD_GET
|
||||
)
|
||||
if err != OK:
|
||||
var apiClient := get_node_or_null("/root/ApiClient")
|
||||
if apiClient == null or not apiClient.has_method("get_json"):
|
||||
_skin_generation_poll_in_flight = false
|
||||
_set_workshop_generation_status("查询生成状态失败:%s" % error_string(err))
|
||||
_set_workshop_generation_status("角色生成服务未加载")
|
||||
return
|
||||
apiClient.call("get_json", endpoint, _on_skin_generation_poll_completed, true)
|
||||
|
||||
func _on_skin_generation_poll_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
func _on_skin_generation_poll_completed(success: bool, response: Dictionary, errorInfo: Dictionary) -> void:
|
||||
_skin_generation_poll_in_flight = false
|
||||
var parsed := _parse_skin_generation_response(result, responseCode, body)
|
||||
if not bool(parsed.get("ok", false)):
|
||||
if not success:
|
||||
var responseCode := int(errorInfo.get("response_code", 0))
|
||||
if responseCode == 401 or responseCode == 403 or responseCode == 404:
|
||||
_skin_generation_active = false
|
||||
_set_skin_generation_controls_enabled(true)
|
||||
_set_workshop_generation_status(str(parsed.get("message", "查询生成状态失败")))
|
||||
_set_workshop_generation_status(str(errorInfo.get("message", "查询生成状态失败")))
|
||||
return
|
||||
|
||||
var data: Dictionary = parsed.get("data", {})
|
||||
var dataVariant: Variant = response.get("data", {})
|
||||
if not (dataVariant is Dictionary):
|
||||
_skin_generation_active = false
|
||||
_set_skin_generation_controls_enabled(true)
|
||||
_set_workshop_generation_status("服务器返回的生成状态格式错误")
|
||||
return
|
||||
var data: Dictionary = dataVariant
|
||||
var status := str(data.get("status", "")).strip_edges()
|
||||
var message := str(data.get("message", "")).strip_edges()
|
||||
if not message.is_empty():
|
||||
@@ -1578,74 +1595,6 @@ func _set_skin_generation_controls_enabled(enabled: bool) -> void:
|
||||
if is_instance_valid(workshop_generate_button):
|
||||
workshop_generate_button.disabled = not enabled
|
||||
|
||||
func _json_headers() -> PackedStringArray:
|
||||
return PackedStringArray([
|
||||
"Content-Type: application/json",
|
||||
"Accept: application/json",
|
||||
])
|
||||
|
||||
func _auth_json_headers() -> PackedStringArray:
|
||||
var headers := _json_headers()
|
||||
if _auth_manager != null and _auth_manager.has_method("get_access_token"):
|
||||
var token := str(_auth_manager.call("get_access_token")).strip_edges()
|
||||
if not token.is_empty():
|
||||
headers.append("Authorization: Bearer %s" % token)
|
||||
return headers
|
||||
|
||||
func _parse_skin_generation_response(result: int, responseCode: int, body: PackedByteArray) -> Dictionary:
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
return {
|
||||
"ok": false,
|
||||
"message": "网络请求失败:%s" % _http_result_to_string(result),
|
||||
}
|
||||
|
||||
var bodyText := body.get_string_from_utf8()
|
||||
var json := JSON.new()
|
||||
var error := json.parse(bodyText)
|
||||
if error != OK or not (json.data is Dictionary):
|
||||
return {
|
||||
"ok": false,
|
||||
"message": "服务器响应解析失败",
|
||||
}
|
||||
|
||||
var response: Dictionary = json.data as Dictionary
|
||||
var success := responseCode >= 200 and responseCode < 300 and bool(response.get("success", true))
|
||||
if not success:
|
||||
return {
|
||||
"ok": false,
|
||||
"message": str(response.get("message", "请求失败")),
|
||||
"response_code": responseCode,
|
||||
}
|
||||
|
||||
var dataVariant: Variant = response.get("data", response)
|
||||
if not (dataVariant is Dictionary):
|
||||
return {
|
||||
"ok": false,
|
||||
"message": "服务器响应格式错误",
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": true,
|
||||
"data": dataVariant as Dictionary,
|
||||
}
|
||||
|
||||
func _http_result_to_string(result: int) -> String:
|
||||
match result:
|
||||
HTTPRequest.RESULT_SUCCESS:
|
||||
return "SUCCESS"
|
||||
HTTPRequest.RESULT_TIMEOUT:
|
||||
return "TIMEOUT"
|
||||
HTTPRequest.RESULT_CANT_CONNECT:
|
||||
return "CANT_CONNECT"
|
||||
HTTPRequest.RESULT_CANT_RESOLVE:
|
||||
return "CANT_RESOLVE"
|
||||
HTTPRequest.RESULT_CONNECTION_ERROR:
|
||||
return "CONNECTION_ERROR"
|
||||
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
|
||||
return "TLS_HANDSHAKE_ERROR"
|
||||
_:
|
||||
return "UNKNOWN_%d" % result
|
||||
|
||||
func _hide_skin_workshop() -> void:
|
||||
if _awaiting_registration_skin_generation:
|
||||
_awaiting_registration_skin_generation = false
|
||||
@@ -1953,7 +1902,7 @@ func _on_send_register_code_pressed() -> void:
|
||||
_is_sending_register_code = true
|
||||
send_register_code_button.disabled = true
|
||||
status_label.text = "正在发送邮箱验证码..."
|
||||
_auth_manager.call("send_email_verification", register_email_input.text)
|
||||
_auth_manager.call("send_email_verification", register_email_input.text, register_invitation_code_input.text)
|
||||
|
||||
func _on_register_pressed() -> void:
|
||||
if _is_submitting:
|
||||
@@ -1976,7 +1925,8 @@ func _on_register_pressed() -> void:
|
||||
"",
|
||||
register_email_input.text,
|
||||
register_verification_code_input.text,
|
||||
_registration_backend_initial_skin_id()
|
||||
_registration_backend_initial_skin_id(),
|
||||
register_invitation_code_input.text
|
||||
)
|
||||
|
||||
func _on_login_succeeded(_user: Dictionary) -> void:
|
||||
@@ -1987,6 +1937,17 @@ func _on_login_succeeded(_user: Dictionary) -> void:
|
||||
_auth_manager.call("fetch_profile")
|
||||
_enter_square()
|
||||
|
||||
func _on_guest_pressed() -> void:
|
||||
if _is_submitting:
|
||||
return
|
||||
var chatManager := get_node_or_null("/root/ChatManager")
|
||||
if chatManager == null or not chatManager.has_method("start_guest_session"):
|
||||
status_label.text = "旁观服务未加载"
|
||||
return
|
||||
_set_submitting(true, "正在进入游客参观模式...")
|
||||
chatManager.call("start_guest_session")
|
||||
SceneManager.change_scene("square")
|
||||
|
||||
func _on_login_failed(message: String) -> void:
|
||||
_set_submitting(false, message)
|
||||
login_password_input.grab_focus()
|
||||
@@ -2192,6 +2153,7 @@ func _set_submitting(is_submitting: bool, message: String) -> void:
|
||||
_is_submitting = is_submitting
|
||||
status_label.text = message
|
||||
login_button.disabled = is_submitting
|
||||
guest_button.disabled = is_submitting
|
||||
register_button.disabled = is_submitting
|
||||
send_register_code_button.disabled = is_submitting or _is_sending_register_code
|
||||
show_register_button.disabled = is_submitting
|
||||
@@ -2453,3 +2415,35 @@ func _sprite_scale_for_frame_bounds(texture: Texture2D, hframes: int, vframes: i
|
||||
return Vector2.ONE
|
||||
var scale: float = minf(bounds.x / frameSize.x, bounds.y / frameSize.y)
|
||||
return Vector2(scale, scale)
|
||||
|
||||
func _sprite_visual_center_offset(texture: Texture2D, hframes: int, vframes: int, frame: int, scale: Vector2) -> Vector2:
|
||||
if texture == null:
|
||||
return Vector2.ZERO
|
||||
var image := texture.get_image()
|
||||
if image == null:
|
||||
return Vector2.ZERO
|
||||
var safeHframes := maxi(1, hframes)
|
||||
var safeVframes := maxi(1, vframes)
|
||||
var frameWidth := image.get_width() / safeHframes
|
||||
var frameHeight := image.get_height() / safeVframes
|
||||
if frameWidth <= 0 or frameHeight <= 0:
|
||||
return Vector2.ZERO
|
||||
var safeFrame := clampi(frame, 0, safeHframes * safeVframes - 1)
|
||||
var frameOrigin := Vector2i((safeFrame % safeHframes) * frameWidth, (safeFrame / safeHframes) * frameHeight)
|
||||
var minX := frameWidth
|
||||
var minY := frameHeight
|
||||
var maxX := -1
|
||||
var maxY := -1
|
||||
for y in range(frameHeight):
|
||||
for x in range(frameWidth):
|
||||
if image.get_pixel(frameOrigin.x + x, frameOrigin.y + y).a <= 0.08:
|
||||
continue
|
||||
minX = mini(minX, x)
|
||||
minY = mini(minY, y)
|
||||
maxX = maxi(maxX, x)
|
||||
maxY = maxi(maxY, y)
|
||||
if maxX < minX or maxY < minY:
|
||||
return Vector2.ZERO
|
||||
var visualCenter := Vector2((float(minX) + float(maxX) + 1.0) * 0.5, (float(minY) + float(maxY) + 1.0) * 0.5)
|
||||
var frameCenter := Vector2(float(frameWidth), float(frameHeight)) * 0.5
|
||||
return (frameCenter - visualCenter) * scale
|
||||
|
||||
@@ -51,13 +51,13 @@ func _draw_polyline(points: Array[Vector2], color: Color, width: float) -> void:
|
||||
var packed: PackedVector2Array = []
|
||||
for point in points:
|
||||
packed.append(_p(point))
|
||||
draw_polyline(packed, color, width, true)
|
||||
draw_polyline(packed, color, width * _iconScale, false)
|
||||
|
||||
func _draw_line(from: Vector2, to: Vector2, color: Color, width: float) -> void:
|
||||
draw_line(_p(from), _p(to), color, width, true)
|
||||
draw_line(_p(from), _p(to), color, width * _iconScale, false)
|
||||
|
||||
func _draw_arc(center: Vector2, radius: float, startAngle: float, endAngle: float, pointCount: int, color: Color, width: float) -> void:
|
||||
draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, color, width, true)
|
||||
draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, color, width * _iconScale, false)
|
||||
|
||||
func _p(point: Vector2) -> Vector2:
|
||||
return _iconOffset + point * _iconScale
|
||||
|
||||
@@ -131,7 +131,9 @@ func _render_service_points() -> void:
|
||||
var index := servicePointOption.get_item_count()
|
||||
servicePointOption.add_item(_format_service_point_option(point))
|
||||
servicePointOption.set_item_metadata(index, pointId)
|
||||
if selectedIndex < 0 and not _point_has_companion(point):
|
||||
var isOccupied := _point_has_companion(point)
|
||||
servicePointOption.set_item_disabled(index, isOccupied)
|
||||
if selectedIndex < 0 and not isOccupied:
|
||||
selectedIndex = index
|
||||
|
||||
if servicePointOption.get_item_count() <= 0:
|
||||
@@ -139,7 +141,14 @@ func _render_service_points() -> void:
|
||||
submitButton.disabled = true
|
||||
return
|
||||
|
||||
servicePointOption.select(selectedIndex if selectedIndex >= 0 else 0)
|
||||
if selectedIndex < 0:
|
||||
servicePointOption.select(-1)
|
||||
submitButton.disabled = true
|
||||
fetchModelsButton.disabled = _isSubmitting
|
||||
_set_status("当前陪伴位均已被占用", true)
|
||||
return
|
||||
|
||||
servicePointOption.select(selectedIndex)
|
||||
submitButton.disabled = _isSubmitting
|
||||
fetchModelsButton.disabled = _isSubmitting
|
||||
_set_status("填写人设和代理配置后可登记", false)
|
||||
@@ -227,6 +236,10 @@ func _build_payload() -> Dictionary:
|
||||
if servicePointId.is_empty():
|
||||
_set_status("请选择陪伴位", true)
|
||||
return {}
|
||||
if not _is_service_point_available(servicePointId):
|
||||
_set_status("该陪伴位已被占用,请选择其他空位", true)
|
||||
_request_service_points()
|
||||
return {}
|
||||
if personaName.is_empty():
|
||||
_set_status("请填写人设名称", true)
|
||||
personaNameInput.grab_focus()
|
||||
@@ -293,6 +306,15 @@ func _selected_service_point_id() -> String:
|
||||
return ""
|
||||
return str(servicePointOption.get_item_metadata(selectedIndex)).strip_edges()
|
||||
|
||||
func _is_service_point_available(servicePointId: String) -> bool:
|
||||
for pointVariant in _servicePoints:
|
||||
if not (pointVariant is Dictionary):
|
||||
continue
|
||||
var point: Dictionary = pointVariant
|
||||
if str(point.get("id", "")).strip_edges() == servicePointId:
|
||||
return not _point_has_companion(point)
|
||||
return false
|
||||
|
||||
func _setup_protocol_options() -> void:
|
||||
protocolOption.clear()
|
||||
protocolOption.add_item("OpenAI", 0)
|
||||
|
||||
@@ -22,7 +22,12 @@ func _process(_delta: float) -> void:
|
||||
|
||||
_update_position()
|
||||
|
||||
func set_text(text: String, targetNode: Node2D = null, targetOffset: Vector2 = TARGET_OFFSET) -> void:
|
||||
func set_text(
|
||||
text: String,
|
||||
targetNode: Node2D = null,
|
||||
targetOffset: Vector2 = TARGET_OFFSET,
|
||||
duration: float = DEFAULT_DURATION,
|
||||
) -> void:
|
||||
_originalText = text
|
||||
_targetNode = targetNode
|
||||
_targetOffset = targetOffset
|
||||
@@ -31,7 +36,7 @@ func set_text(text: String, targetNode: Node2D = null, targetOffset: Vector2 = T
|
||||
if _targetNode != null:
|
||||
_update_position()
|
||||
|
||||
await get_tree().create_timer(DEFAULT_DURATION).timeout
|
||||
await get_tree().create_timer(maxf(0.1, duration)).timeout
|
||||
queue_free()
|
||||
|
||||
func _update_size() -> void:
|
||||
|
||||
@@ -56,6 +56,9 @@ extends Control
|
||||
@onready var friends_list_surface: Control = %FriendsListSurface
|
||||
@onready var add_friend_row: HBoxContainer = %AddFriendRow
|
||||
@onready var add_friend_button: Button = %AddFriendButton
|
||||
@onready var tabs: HBoxContainer = $ChatPanel/PanelMargin/ContentVBox/Tabs
|
||||
@onready var panel_margin: MarginContainer = $ChatPanel/PanelMargin
|
||||
@onready var input_row: HBoxContainer = $ChatPanel/PanelMargin/ContentVBox/InputRow
|
||||
|
||||
# ============================================================================
|
||||
# 预加载资源
|
||||
@@ -80,6 +83,9 @@ const TAB_WHISPER: String = "whisper"
|
||||
const TAB_FRIENDS: String = "friends"
|
||||
const MAX_DISPLAYED_MESSAGES: int = 100
|
||||
const MOVEMENT_ACTIONS: Array[String] = ["move_left", "move_right", "move_up", "move_down"]
|
||||
const SEND_ICON := preload("res://assets/ui/world_bulletin/world_bulletin_send_v2_128.png")
|
||||
const STATIC_NPC_DIALOGUE_PREFIX: String = "static_npc_dialogue:"
|
||||
const DEFAULT_WHISPER_TAB_TEXT: String = "悄悄话"
|
||||
|
||||
# ============================================================================
|
||||
# 成员变量
|
||||
@@ -106,6 +112,9 @@ var _current_tab: String = TAB_WORLD
|
||||
# 悄悄话目标(靠近玩家按 E 后设置)
|
||||
var _whisper_target_user_id: String = ""
|
||||
var _whisper_target_username: String = ""
|
||||
var _npc_target_id: String = ""
|
||||
var _npc_target_name: String = ""
|
||||
var _npc_session_id: String = ""
|
||||
|
||||
# 好友私聊目标(好友列表接入后复用同一私聊协议)
|
||||
var _friend_target_user_id: String = ""
|
||||
@@ -118,6 +127,12 @@ var _friends_status_message: String = ""
|
||||
var _messages: Array[Dictionary] = []
|
||||
|
||||
var _send_failure_handled_by_ui: bool = false
|
||||
var _npc_thinking_row: Control
|
||||
var _npc_thinking_timer: Timer
|
||||
var _npc_dialogue_mode: bool = false
|
||||
var _npc_dialogue_read_only: bool = false
|
||||
var _default_chat_panel_style: StyleBox
|
||||
var _default_input_shell_style: StyleBox
|
||||
|
||||
# ============================================================================
|
||||
# 生命周期方法
|
||||
@@ -126,12 +141,17 @@ var _send_failure_handled_by_ui: bool = false
|
||||
# 准备就绪
|
||||
func _ready() -> void:
|
||||
_configure_mouse_focus()
|
||||
_configure_send_icon()
|
||||
_capture_default_dialogue_styles()
|
||||
if not get_viewport().size_changed.is_connected(_on_viewport_size_changed):
|
||||
get_viewport().size_changed.connect(_on_viewport_size_changed)
|
||||
|
||||
# 初始隐藏聊天框
|
||||
hide_chat(true)
|
||||
|
||||
# 创建隐藏计时器
|
||||
_create_hide_timer()
|
||||
_create_npc_thinking_timer()
|
||||
|
||||
# 订阅事件(Call Down via EventSystem)
|
||||
_subscribe_to_events()
|
||||
@@ -144,6 +164,134 @@ func _ready() -> void:
|
||||
_update_tab_visuals()
|
||||
_update_bubble_send_button_visibility()
|
||||
|
||||
func _configure_send_icon() -> void:
|
||||
if not is_instance_valid(send_button):
|
||||
return
|
||||
send_button.text = ""
|
||||
send_button.icon = SEND_ICON
|
||||
send_button.expand_icon = true
|
||||
send_button.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
|
||||
|
||||
func _capture_default_dialogue_styles() -> void:
|
||||
if is_instance_valid(chat_panel):
|
||||
_default_chat_panel_style = chat_panel.get_theme_stylebox("panel").duplicate() as StyleBox
|
||||
if is_instance_valid(input_shell):
|
||||
_default_input_shell_style = input_shell.get_theme_stylebox("panel").duplicate() as StyleBox
|
||||
|
||||
func _set_npc_dialogue_mode(enabled: bool, readOnly: bool = false) -> void:
|
||||
_npc_dialogue_mode = enabled
|
||||
_npc_dialogue_read_only = enabled and readOnly
|
||||
|
||||
var worldTab := popular_tab_button.get_parent() as Control if is_instance_valid(popular_tab_button) else null
|
||||
var whisperTab := recent_tab_button.get_parent() as Control if is_instance_valid(recent_tab_button) else null
|
||||
var friendsTab := friends_tab_button.get_parent() as Control if is_instance_valid(friends_tab_button) else null
|
||||
if is_instance_valid(worldTab):
|
||||
worldTab.visible = not enabled
|
||||
if is_instance_valid(friendsTab):
|
||||
friendsTab.visible = not enabled
|
||||
if is_instance_valid(whisperTab):
|
||||
whisperTab.visible = true
|
||||
if is_instance_valid(recent_tab_button):
|
||||
recent_tab_button.text = _npc_target_name if enabled else DEFAULT_WHISPER_TAB_TEXT
|
||||
recent_tab_button.alignment = HORIZONTAL_ALIGNMENT_LEFT if enabled else HORIZONTAL_ALIGNMENT_CENTER
|
||||
recent_tab_button.mouse_filter = Control.MOUSE_FILTER_IGNORE if enabled else Control.MOUSE_FILTER_STOP
|
||||
if is_instance_valid(input_row):
|
||||
input_row.visible = not _npc_dialogue_read_only
|
||||
|
||||
if is_instance_valid(chat_panel):
|
||||
var panelStyle := _create_npc_dialogue_style() if enabled else _default_chat_panel_style
|
||||
if panelStyle != null:
|
||||
chat_panel.add_theme_stylebox_override("panel", panelStyle)
|
||||
if is_instance_valid(input_shell):
|
||||
var inputStyle := _create_npc_dialogue_input_style() if enabled else _default_input_shell_style
|
||||
if inputStyle != null:
|
||||
input_shell.add_theme_stylebox_override("panel", inputStyle)
|
||||
if is_instance_valid(panel_margin):
|
||||
var horizontalMargin := 24 if enabled else 20
|
||||
var verticalMargin := 18
|
||||
panel_margin.add_theme_constant_override("margin_left", horizontalMargin)
|
||||
panel_margin.add_theme_constant_override("margin_top", verticalMargin)
|
||||
panel_margin.add_theme_constant_override("margin_right", horizontalMargin)
|
||||
panel_margin.add_theme_constant_override("margin_bottom", verticalMargin)
|
||||
|
||||
_apply_chat_panel_layout()
|
||||
_update_tab_visuals()
|
||||
if is_instance_valid(message_list):
|
||||
_rerender_messages()
|
||||
|
||||
func _clear_npc_dialogue_target() -> void:
|
||||
_npc_target_id = ""
|
||||
_npc_target_name = ""
|
||||
_npc_session_id = ""
|
||||
_set_npc_dialogue_mode(false, false)
|
||||
|
||||
func _apply_chat_panel_layout() -> void:
|
||||
if not is_instance_valid(chat_panel):
|
||||
return
|
||||
if not _npc_dialogue_mode:
|
||||
chat_panel.anchor_left = 0.024
|
||||
chat_panel.anchor_top = 1.0
|
||||
chat_panel.anchor_right = 0.024
|
||||
chat_panel.anchor_bottom = 1.0
|
||||
chat_panel.offset_left = 0.0
|
||||
chat_panel.offset_top = -448.0
|
||||
chat_panel.offset_right = 548.0
|
||||
chat_panel.offset_bottom = -32.0
|
||||
return
|
||||
|
||||
var viewportSize := get_viewport_rect().size
|
||||
var dialogWidth := clampf(viewportSize.x * 0.90, 320.0, 760.0)
|
||||
var minimumHeight := 200.0 if _npc_dialogue_read_only else 250.0
|
||||
var maximumHeight := 250.0 if _npc_dialogue_read_only else 340.0
|
||||
var dialogHeight := clampf(viewportSize.y * 0.42, minimumHeight, maximumHeight)
|
||||
var bottomMargin := clampf(viewportSize.y * 0.04, 18.0, 34.0)
|
||||
chat_panel.anchor_left = 0.5
|
||||
chat_panel.anchor_top = 1.0
|
||||
chat_panel.anchor_right = 0.5
|
||||
chat_panel.anchor_bottom = 1.0
|
||||
chat_panel.offset_left = -dialogWidth * 0.5
|
||||
chat_panel.offset_top = -bottomMargin - dialogHeight
|
||||
chat_panel.offset_right = dialogWidth * 0.5
|
||||
chat_panel.offset_bottom = -bottomMargin
|
||||
|
||||
func _on_viewport_size_changed() -> void:
|
||||
_apply_chat_panel_layout()
|
||||
|
||||
func _create_npc_dialogue_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(1.0, 0.985, 0.91, 0.985)
|
||||
style.border_color = Color(0.055, 0.22, 0.235, 1.0)
|
||||
style.border_width_left = 4
|
||||
style.border_width_top = 4
|
||||
style.border_width_right = 4
|
||||
style.border_width_bottom = 4
|
||||
style.corner_radius_top_left = 5
|
||||
style.corner_radius_top_right = 5
|
||||
style.corner_radius_bottom_left = 5
|
||||
style.corner_radius_bottom_right = 5
|
||||
style.shadow_color = Color(0.02, 0.06, 0.07, 0.3)
|
||||
style.shadow_size = 8
|
||||
style.shadow_offset = Vector2(0, 5)
|
||||
return style
|
||||
|
||||
func _create_npc_dialogue_input_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(1.0, 1.0, 0.98, 1.0)
|
||||
style.border_color = Color(0.22, 0.42, 0.42, 0.82)
|
||||
style.border_width_left = 2
|
||||
style.border_width_top = 2
|
||||
style.border_width_right = 2
|
||||
style.border_width_bottom = 2
|
||||
style.corner_radius_top_left = 4
|
||||
style.corner_radius_top_right = 4
|
||||
style.corner_radius_bottom_left = 4
|
||||
style.corner_radius_bottom_right = 4
|
||||
style.content_margin_left = 14
|
||||
style.content_margin_top = 6
|
||||
style.content_margin_right = 14
|
||||
style.content_margin_bottom = 6
|
||||
return style
|
||||
|
||||
# 清理
|
||||
func _exit_tree() -> void:
|
||||
# 取消事件订阅
|
||||
@@ -155,6 +303,8 @@ func _exit_tree() -> void:
|
||||
eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_SUCCESS, _on_login_success, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CHAT_LOGIN_FAILED, _on_login_failed, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, _on_private_target_selected, self)
|
||||
eventSystem.call("disconnect_event", EventNames.NPC_SPOKE, _on_npc_spoke, self)
|
||||
eventSystem.call("disconnect_event", EventNames.NPC_INTERACTION_ERROR, _on_npc_interaction_error, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CHAT_FRIEND_SELECTED, _on_friend_selected, self)
|
||||
eventSystem.call("disconnect_event", EventNames.CHAT_FRIENDS_UPDATED, _on_friends_updated, self)
|
||||
eventSystem.call("disconnect_event", EventNames.SETTINGS_CHANGED, _on_settings_changed, self)
|
||||
@@ -162,6 +312,11 @@ func _exit_tree() -> void:
|
||||
# 清理计时器
|
||||
if _hide_timer:
|
||||
_hide_timer.queue_free()
|
||||
if _npc_thinking_timer:
|
||||
_npc_thinking_timer.queue_free()
|
||||
_hide_npc_thinking()
|
||||
if get_viewport() != null and get_viewport().size_changed.is_connected(_on_viewport_size_changed):
|
||||
get_viewport().size_changed.disconnect(_on_viewport_size_changed)
|
||||
|
||||
if is_instance_valid(_transition_tween):
|
||||
_transition_tween.kill()
|
||||
@@ -192,6 +347,10 @@ func _input(event: InputEvent) -> void:
|
||||
var key_event := event as InputEventKey
|
||||
if not key_event.pressed or key_event.echo:
|
||||
return
|
||||
if key_event.keycode == KEY_ESCAPE and _is_chat_visible:
|
||||
hide_chat()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
|
||||
# T 键用于唤起聊天(输入框聚焦时不拦截)
|
||||
if key_event.keycode == KEY_T and not chat_input.has_focus():
|
||||
@@ -264,6 +423,9 @@ func _update_input_placeholder() -> void:
|
||||
if _current_tab == TAB_WHISPER and not _whisper_target_username.is_empty():
|
||||
chat_input.placeholder_text = "对 %s 说点什么..." % _whisper_target_username
|
||||
return
|
||||
if _current_tab == TAB_WHISPER and not _npc_target_name.is_empty():
|
||||
chat_input.placeholder_text = "对 %s 说点什么..." % _npc_target_name
|
||||
return
|
||||
|
||||
if _current_tab == TAB_FRIENDS and not _friend_target_username.is_empty():
|
||||
chat_input.placeholder_text = "对 %s 说点什么..." % _friend_target_username
|
||||
@@ -338,6 +500,8 @@ func show_chat(immediate: bool = false) -> void:
|
||||
|
||||
# 隐藏聊天框
|
||||
func hide_chat(immediate: bool = false) -> void:
|
||||
_end_npc_session()
|
||||
_clear_npc_dialogue_target()
|
||||
_is_chat_visible = false
|
||||
_is_typing = false
|
||||
|
||||
@@ -374,6 +538,13 @@ func _create_hide_timer() -> void:
|
||||
_hide_timer.timeout.connect(_on_hide_timeout)
|
||||
add_child(_hide_timer)
|
||||
|
||||
func _create_npc_thinking_timer() -> void:
|
||||
_npc_thinking_timer = Timer.new()
|
||||
_npc_thinking_timer.wait_time = 60.0
|
||||
_npc_thinking_timer.one_shot = true
|
||||
_npc_thinking_timer.timeout.connect(_on_npc_thinking_timeout)
|
||||
add_child(_npc_thinking_timer)
|
||||
|
||||
# 开始隐藏倒计时
|
||||
func _start_hide_timer() -> void:
|
||||
if _is_typing:
|
||||
@@ -508,6 +679,17 @@ func _send_input_message(show_bubble: bool) -> void:
|
||||
|
||||
# 清空输入框
|
||||
chat_input.clear()
|
||||
if not _npc_target_id.is_empty() and _current_tab == TAB_WHISPER:
|
||||
_add_message_data({
|
||||
"from_user": _current_username,
|
||||
"content": content,
|
||||
"timestamp": Time.get_unix_time_from_system(),
|
||||
"is_self": true,
|
||||
"scope": "private",
|
||||
"private_context": TAB_WHISPER,
|
||||
"npc_id": _npc_target_id,
|
||||
})
|
||||
_show_npc_thinking()
|
||||
|
||||
# 发送后延迟重新聚焦,避免被 LineEdit 的提交事件在同一帧内抢走焦点
|
||||
call_deferred("_focus_input_after_send")
|
||||
@@ -565,6 +747,8 @@ func _subscribe_to_events() -> void:
|
||||
|
||||
# 订阅近身悄悄话目标选择事件
|
||||
eventSystem.call("connect_event", EventNames.CHAT_PRIVATE_TARGET_SELECTED, _on_private_target_selected, self)
|
||||
eventSystem.call("connect_event", EventNames.NPC_SPOKE, _on_npc_spoke, self)
|
||||
eventSystem.call("connect_event", EventNames.NPC_INTERACTION_ERROR, _on_npc_interaction_error, self)
|
||||
|
||||
# 订阅右下角好友列表的好友选择事件
|
||||
eventSystem.call("connect_event", EventNames.CHAT_FRIEND_SELECTED, _on_friend_selected, self)
|
||||
@@ -611,9 +795,83 @@ func _on_chat_error(data: Dictionary) -> void:
|
||||
if _current_tab == TAB_FRIENDS:
|
||||
_render_friend_conversation_header()
|
||||
return
|
||||
if not _npc_target_id.is_empty() and _current_tab == TAB_WHISPER:
|
||||
_hide_npc_thinking()
|
||||
if not message.strip_edges().is_empty():
|
||||
_add_system_message(message)
|
||||
|
||||
func _on_npc_spoke(data: Dictionary) -> void:
|
||||
var npc_id := str(data.get("npc_id", data.get("npcId", ""))).strip_edges()
|
||||
if _npc_target_id.is_empty() or npc_id != _npc_target_id:
|
||||
return
|
||||
var response := str(data.get("response", "")).strip_edges()
|
||||
if response.is_empty():
|
||||
return
|
||||
_hide_npc_thinking()
|
||||
var session_id := str(data.get("session_id", data.get("sessionId", ""))).strip_edges()
|
||||
if not session_id.is_empty():
|
||||
_npc_session_id = session_id
|
||||
_add_message_data({
|
||||
"from_user": str(data.get("npc_name", data.get("npcName", _npc_target_name))),
|
||||
"content": response,
|
||||
"timestamp": Time.get_unix_time_from_system(),
|
||||
"is_self": false,
|
||||
"scope": "private",
|
||||
"private_context": TAB_WHISPER,
|
||||
"npc_id": npc_id,
|
||||
})
|
||||
|
||||
func _on_npc_interaction_error(data: Dictionary) -> void:
|
||||
var npc_id := str(data.get("npc_id", data.get("npcId", ""))).strip_edges()
|
||||
if not _npc_target_id.is_empty() and not npc_id.is_empty() and npc_id != _npc_target_id:
|
||||
return
|
||||
_hide_npc_thinking()
|
||||
var message := str(data.get("message", "NPC暂时无法回应")).strip_edges()
|
||||
if not message.is_empty():
|
||||
_add_system_message(message)
|
||||
|
||||
func _show_npc_thinking() -> void:
|
||||
_hide_npc_thinking()
|
||||
if _npc_target_id.is_empty() or _current_tab != TAB_WHISPER:
|
||||
return
|
||||
if not is_instance_valid(message_list):
|
||||
return
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.name = "NpcThinkingRow"
|
||||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
message_list.add_child(row)
|
||||
|
||||
var message_node := chat_message_scene.instantiate() as Control
|
||||
if message_node == null:
|
||||
row.queue_free()
|
||||
return
|
||||
row.add_child(message_node)
|
||||
if message_node.has_method("set_message"):
|
||||
message_node.call("set_message", _npc_target_name, "正在思考...", Time.get_unix_time_from_system(), false)
|
||||
if message_node.has_method("set_dialogue_mode"):
|
||||
message_node.call("set_dialogue_mode", _npc_dialogue_mode)
|
||||
message_node.modulate = Color(1.0, 1.0, 1.0, 0.78)
|
||||
_npc_thinking_row = row
|
||||
if is_instance_valid(_npc_thinking_timer):
|
||||
_npc_thinking_timer.start()
|
||||
call_deferred("_scroll_to_bottom")
|
||||
|
||||
func _hide_npc_thinking() -> void:
|
||||
if is_instance_valid(_npc_thinking_timer):
|
||||
_npc_thinking_timer.stop()
|
||||
if is_instance_valid(_npc_thinking_row):
|
||||
_npc_thinking_row.queue_free()
|
||||
_npc_thinking_row = null
|
||||
|
||||
func _on_npc_thinking_timeout() -> void:
|
||||
if not is_instance_valid(_npc_thinking_row):
|
||||
return
|
||||
_hide_npc_thinking()
|
||||
_add_system_message("NPC暂时没有回应,请稍后再试")
|
||||
|
||||
# 处理连接状态变化
|
||||
func _on_connection_state_changed(_data: Dictionary) -> void:
|
||||
# 连接状态变化处理(当前不更新UI)
|
||||
@@ -699,6 +957,8 @@ func _send_current_tab_message(chatManager: Node, content: String, show_bubble:
|
||||
TAB_WORLD:
|
||||
return bool(chatManager.call("send_chat_message", content, "global", show_bubble))
|
||||
TAB_WHISPER:
|
||||
if not _npc_target_id.is_empty():
|
||||
return bool(chatManager.call("interact_with_world_npc", _npc_target_id, content, _npc_session_id))
|
||||
if _whisper_target_user_id.is_empty():
|
||||
_add_system_message("请靠近玩家按 E 发起悄悄话")
|
||||
_send_failure_handled_by_ui = true
|
||||
@@ -734,6 +994,8 @@ func start_whisper(user_id: String, username: String = "") -> void:
|
||||
if normalized_user_id.is_empty():
|
||||
return
|
||||
|
||||
_end_npc_session()
|
||||
_clear_npc_dialogue_target()
|
||||
_whisper_target_user_id = normalized_user_id
|
||||
_whisper_target_username = username.strip_edges()
|
||||
if _whisper_target_username.is_empty():
|
||||
@@ -744,6 +1006,66 @@ func start_whisper(user_id: String, username: String = "") -> void:
|
||||
show_chat(true)
|
||||
call_deferred("_focus_input_after_send")
|
||||
|
||||
func start_npc_whisper(npc_id: String, npc_name: String = "NPC", greeting: String = "") -> void:
|
||||
var normalized_id := npc_id.strip_edges()
|
||||
if normalized_id.is_empty():
|
||||
return
|
||||
_end_npc_session()
|
||||
_clear_npc_dialogue_target()
|
||||
_hide_npc_thinking()
|
||||
_npc_target_id = normalized_id
|
||||
_npc_target_name = npc_name.strip_edges() if not npc_name.strip_edges().is_empty() else "NPC"
|
||||
_npc_session_id = ""
|
||||
_whisper_target_user_id = ""
|
||||
_whisper_target_username = ""
|
||||
select_tab(TAB_WHISPER)
|
||||
_set_npc_dialogue_mode(true, false)
|
||||
show_chat(true)
|
||||
if not greeting.strip_edges().is_empty():
|
||||
_add_message_data({
|
||||
"from_user": _npc_target_name,
|
||||
"content": greeting.strip_edges(),
|
||||
"timestamp": Time.get_unix_time_from_system(),
|
||||
"is_self": false,
|
||||
"scope": "private",
|
||||
"private_context": TAB_WHISPER,
|
||||
"npc_id": _npc_target_id,
|
||||
})
|
||||
call_deferred("_focus_input_after_send")
|
||||
|
||||
func show_npc_dialogue(npc_name: String, text: String) -> void:
|
||||
var normalizedText := text.strip_edges()
|
||||
if normalizedText.is_empty():
|
||||
return
|
||||
_end_npc_session()
|
||||
_clear_npc_dialogue_target()
|
||||
_hide_npc_thinking()
|
||||
_npc_target_name = npc_name.strip_edges() if not npc_name.strip_edges().is_empty() else "NPC"
|
||||
_npc_target_id = "%s%s:%d" % [STATIC_NPC_DIALOGUE_PREFIX, _npc_target_name, Time.get_ticks_msec()]
|
||||
_npc_session_id = ""
|
||||
_whisper_target_user_id = ""
|
||||
_whisper_target_username = ""
|
||||
select_tab(TAB_WHISPER)
|
||||
_set_npc_dialogue_mode(true, true)
|
||||
show_chat(true)
|
||||
_add_message_data({
|
||||
"from_user": _npc_target_name,
|
||||
"content": normalizedText,
|
||||
"timestamp": Time.get_unix_time_from_system(),
|
||||
"is_self": false,
|
||||
"scope": "private",
|
||||
"private_context": TAB_WHISPER,
|
||||
"npc_id": _npc_target_id,
|
||||
})
|
||||
|
||||
func _end_npc_session() -> void:
|
||||
if _npc_target_id.is_empty() or _npc_session_id.is_empty() or _npc_target_id.begins_with(STATIC_NPC_DIALOGUE_PREFIX):
|
||||
return
|
||||
var chat_manager := _get_chat_manager()
|
||||
if chat_manager != null and chat_manager.has_method("end_world_npc_session"):
|
||||
chat_manager.call("end_world_npc_session", _npc_target_id, _npc_session_id)
|
||||
_npc_session_id = ""
|
||||
|
||||
func add_whisper_target_as_friend() -> bool:
|
||||
if _whisper_target_user_id.is_empty():
|
||||
_add_system_message("请先靠近玩家按 F")
|
||||
@@ -780,6 +1102,8 @@ func select_friend_private_target(user_id: String, username: String = "") -> voi
|
||||
if normalized_user_id.is_empty():
|
||||
return
|
||||
|
||||
_end_npc_session()
|
||||
_clear_npc_dialogue_target()
|
||||
_friend_target_user_id = normalized_user_id
|
||||
_friend_target_username = username.strip_edges()
|
||||
if _friend_target_username.is_empty():
|
||||
@@ -934,15 +1258,23 @@ func _add_message_data(message: Dictionary) -> void:
|
||||
_render_message(message)
|
||||
|
||||
func _render_message(message: Dictionary) -> void:
|
||||
# 如果聊天框隐藏,自动显示
|
||||
# 世界频道消息进入公告 HUD,不自动抢占地图视野;
|
||||
# 私聊、NPC 回复和用户主动发送的消息仍可唤起聊天面板。
|
||||
if not _is_chat_visible:
|
||||
var message_scope := str(message.get("scope", "global")).strip_edges().to_lower()
|
||||
var is_private := bool(message.get("is_private", false)) or message_scope == "private"
|
||||
var is_npc := not str(message.get("npc_id", "")).strip_edges().is_empty()
|
||||
# 系统存在/欢迎消息也属于世界公告,不应自动打开旧聊天窗口。
|
||||
# 只有私聊和 NPC 会话需要在收到回复时主动唤起聊天 UI。
|
||||
if not is_private and not is_npc:
|
||||
return
|
||||
show_chat()
|
||||
|
||||
# 每条消息用一行容器包起来,方便左右对齐且不挤在一起
|
||||
var row := HBoxContainer.new()
|
||||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
row.alignment = BoxContainer.ALIGNMENT_END if bool(message.get("is_self", false)) else BoxContainer.ALIGNMENT_BEGIN
|
||||
row.alignment = BoxContainer.ALIGNMENT_BEGIN if _npc_dialogue_mode else (BoxContainer.ALIGNMENT_END if bool(message.get("is_self", false)) else BoxContainer.ALIGNMENT_BEGIN)
|
||||
|
||||
# 创建消息节点
|
||||
var message_node: Control = chat_message_scene.instantiate() as Control
|
||||
@@ -962,6 +1294,8 @@ func _render_message(message: Dictionary) -> void:
|
||||
float(message.get("timestamp", 0.0)),
|
||||
bool(message.get("is_self", false))
|
||||
)
|
||||
if message_node.has_method("set_dialogue_mode"):
|
||||
message_node.call("set_dialogue_mode", _npc_dialogue_mode)
|
||||
|
||||
# 自动滚动到底部
|
||||
call_deferred("_scroll_to_bottom")
|
||||
@@ -993,6 +1327,9 @@ func _message_matches_current_tab(message: Dictionary) -> bool:
|
||||
return true
|
||||
|
||||
func _private_message_matches_current_tab(message: Dictionary) -> bool:
|
||||
var npc_id := str(message.get("npc_id", message.get("npcId", ""))).strip_edges()
|
||||
if _current_tab == TAB_WHISPER and not _npc_target_id.is_empty():
|
||||
return npc_id == _npc_target_id
|
||||
var scope := str(message.get("scope", "local")).strip_edges().to_lower()
|
||||
var is_private := bool(message.get("is_private", false)) or scope == "private"
|
||||
if not is_private:
|
||||
|
||||
@@ -9,8 +9,8 @@ extends Control
|
||||
const LINE_COLOR: Color = Color(0.592157, 0.72549, 0.835294, 0.82)
|
||||
const DOT_COLOR: Color = Color(0.941176, 0.54902, 0.54902, 0.86)
|
||||
const DOT_BORDER_COLOR: Color = Color(1.0, 1.0, 1.0, 0.95)
|
||||
const LINE_WIDTH: float = 0.85
|
||||
const DETAIL_WIDTH: float = 0.75
|
||||
const LINE_WIDTH: float = 1.15
|
||||
const DETAIL_WIDTH: float = 1.0
|
||||
const BASE_SIZE: float = 27.0
|
||||
|
||||
var iconName: String = "map"
|
||||
@@ -127,17 +127,17 @@ func _draw_polyline(points: Array[Vector2], width: float = LINE_WIDTH) -> void:
|
||||
var packed: PackedVector2Array = []
|
||||
for point in points:
|
||||
packed.append(_p(point))
|
||||
draw_polyline(packed, LINE_COLOR, width, true)
|
||||
draw_polyline(packed, LINE_COLOR, width * _iconScale, false)
|
||||
|
||||
func _draw_red_dot(center: Vector2) -> void:
|
||||
draw_circle(_p(center), 2.3 * _iconScale, DOT_BORDER_COLOR)
|
||||
draw_circle(_p(center), 1.55 * _iconScale, DOT_COLOR)
|
||||
draw_circle(_p(center), 2.3 * _iconScale, DOT_BORDER_COLOR, true, -1.0, false)
|
||||
draw_circle(_p(center), 1.55 * _iconScale, DOT_COLOR, true, -1.0, false)
|
||||
|
||||
func _draw_line(from: Vector2, to: Vector2, width: float) -> void:
|
||||
draw_line(_p(from), _p(to), LINE_COLOR, width, true)
|
||||
draw_line(_p(from), _p(to), LINE_COLOR, width * _iconScale, false)
|
||||
|
||||
func _draw_arc(center: Vector2, radius: float, startAngle: float, endAngle: float, pointCount: int, width: float) -> void:
|
||||
draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, LINE_COLOR, width, true)
|
||||
draw_arc(_p(center), radius * _iconScale, startAngle, endAngle, pointCount, LINE_COLOR, width * _iconScale, false)
|
||||
|
||||
func _p(point: Vector2) -> Vector2:
|
||||
return _iconOffset + point * _iconScale
|
||||
|
||||
532
scenes/ui/WorldBulletinPanel.gd
Normal file
532
scenes/ui/WorldBulletinPanel.gd
Normal file
@@ -0,0 +1,532 @@
|
||||
extends Control
|
||||
|
||||
## Lightweight public announcement HUD.
|
||||
## This panel owns only the public feed; private conversations remain in ChatUI.
|
||||
|
||||
const PANEL_WIDTH := 560.0
|
||||
const COLLAPSED_HEIGHT := 68.0
|
||||
const EXPANDED_HEIGHT := 492.0
|
||||
const EDGE_MARGIN := 26.0
|
||||
const TEXT_COLOR := Color("24476f")
|
||||
const MUTED_COLOR := Color("7894b2")
|
||||
const ACCENT_COLOR := Color("2d94ed")
|
||||
const SURFACE_COLOR := Color(0.985, 0.995, 1.0, 0.97)
|
||||
const LINE_COLOR := Color("d9e8f7")
|
||||
const MAX_ITEMS := 24
|
||||
|
||||
const ICON_EMPTY := preload("res://assets/ui/world_bulletin/community/empty_whale.png")
|
||||
const ICON_BROADCAST := preload("res://assets/ui/world_bulletin/community/world_bulletin_icon_hd_simple_tight.png")
|
||||
const ICON_BANNER := preload("res://assets/ui/world_bulletin/community/town_banner.png")
|
||||
const ICON_PIN := preload("res://assets/ui/world_bulletin/community/pinned_note.png")
|
||||
const ICON_RECRUIT := preload("res://assets/ui/world_bulletin/community/recruit_whales.png")
|
||||
const ICON_SEND_BUTTON := preload("res://assets/ui/world_bulletin/world_bulletin_send_button_final_192.png")
|
||||
|
||||
var _panel: PanelContainer
|
||||
var _surface_root: VBoxContainer
|
||||
var _ticker_row: HBoxContainer
|
||||
var _ticker: Button
|
||||
var _expand_button: Button
|
||||
var _content: VBoxContainer
|
||||
var _header_title: Label
|
||||
var _header_icon: TextureRect
|
||||
var _header_banner: TextureRect
|
||||
var _entries: VBoxContainer
|
||||
var _scroll: ScrollContainer
|
||||
var _composer: LineEdit
|
||||
var _send_button: Button
|
||||
var _expanded := false
|
||||
var _announcements: Array[Dictionary] = []
|
||||
var _unread := 0
|
||||
var _last_preview := "暂无新的世界公告"
|
||||
var _publish_pending := false
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_build_ui()
|
||||
_seed_preview_items()
|
||||
_subscribe_to_events()
|
||||
_set_expanded(true, true)
|
||||
|
||||
func _exit_tree() -> void:
|
||||
var event_system := get_node_or_null("/root/EventSystem")
|
||||
if event_system != null:
|
||||
event_system.call("disconnect_event", EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
|
||||
event_system.call("disconnect_event", EventNames.CHAT_MESSAGE_SENT, _on_chat_message_sent, self)
|
||||
event_system.call("disconnect_event", EventNames.CHAT_ERROR_OCCURRED, _on_chat_error, self)
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventKey:
|
||||
var key_event := event as InputEventKey
|
||||
if key_event.pressed and not key_event.echo and key_event.keycode == KEY_B and not _composer.has_focus():
|
||||
_set_expanded(not _expanded)
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
func _build_ui() -> void:
|
||||
_panel = PanelContainer.new()
|
||||
_panel.name = "WorldBulletinSurface"
|
||||
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_panel.add_theme_stylebox_override("panel", _surface_style())
|
||||
add_child(_panel)
|
||||
_panel.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
|
||||
_panel.offset_left = EDGE_MARGIN
|
||||
_panel.offset_top = -EDGE_MARGIN - COLLAPSED_HEIGHT
|
||||
_panel.offset_right = EDGE_MARGIN + PANEL_WIDTH
|
||||
_panel.offset_bottom = -EDGE_MARGIN
|
||||
_panel.grow_horizontal = Control.GROW_DIRECTION_END
|
||||
_panel.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
||||
_surface_root = VBoxContainer.new()
|
||||
_surface_root.name = "SurfaceContent"
|
||||
_surface_root.add_theme_constant_override("separation", 0)
|
||||
_surface_root.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_surface_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_panel.add_child(_surface_root)
|
||||
|
||||
_ticker_row = HBoxContainer.new()
|
||||
_ticker_row.name = "CollapsedTickerRow"
|
||||
_ticker_row.custom_minimum_size = Vector2(PANEL_WIDTH, COLLAPSED_HEIGHT)
|
||||
_ticker_row.add_theme_constant_override("separation", 4)
|
||||
_ticker_row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_surface_root.add_child(_ticker_row)
|
||||
|
||||
_ticker = Button.new()
|
||||
_ticker.name = "CollapsedTicker"
|
||||
_ticker.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_ticker.custom_minimum_size = Vector2(0, COLLAPSED_HEIGHT)
|
||||
_ticker.focus_mode = Control.FOCUS_NONE
|
||||
_ticker.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
_ticker.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
_ticker.add_theme_font_size_override("font_size", 20)
|
||||
_ticker.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
_ticker.add_theme_color_override("font_hover_color", TEXT_COLOR)
|
||||
_ticker.add_theme_stylebox_override("normal", _empty_style())
|
||||
_ticker.add_theme_stylebox_override("hover", _hover_style())
|
||||
_ticker.add_theme_stylebox_override("pressed", _pressed_style())
|
||||
_ticker.pressed.connect(func() -> void: _set_expanded(not _expanded))
|
||||
_ticker_row.add_child(_ticker)
|
||||
|
||||
_expand_button = Button.new()
|
||||
_expand_button.name = "ExpandButton"
|
||||
_expand_button.text = "展开"
|
||||
_expand_button.custom_minimum_size = Vector2(72, COLLAPSED_HEIGHT)
|
||||
_expand_button.focus_mode = Control.FOCUS_NONE
|
||||
_expand_button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
_expand_button.add_theme_font_size_override("font_size", 18)
|
||||
_expand_button.add_theme_color_override("font_color", ACCENT_COLOR)
|
||||
_expand_button.add_theme_color_override("font_hover_color", TEXT_COLOR)
|
||||
_expand_button.add_theme_stylebox_override("normal", _empty_style())
|
||||
_expand_button.add_theme_stylebox_override("hover", _hover_style())
|
||||
_expand_button.add_theme_stylebox_override("pressed", _pressed_style())
|
||||
_expand_button.pressed.connect(func() -> void: _set_expanded(true))
|
||||
_ticker_row.add_child(_expand_button)
|
||||
|
||||
_content = VBoxContainer.new()
|
||||
_content.name = "ExpandedContent"
|
||||
_content.add_theme_constant_override("separation", 0)
|
||||
_content.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_surface_root.add_child(_content)
|
||||
|
||||
var header := HBoxContainer.new()
|
||||
header.name = "Header"
|
||||
header.custom_minimum_size = Vector2(0, 74)
|
||||
header.add_theme_constant_override("separation", 12)
|
||||
header.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_content.add_child(header)
|
||||
|
||||
_header_icon = TextureRect.new()
|
||||
_header_icon.custom_minimum_size = Vector2(56, 56)
|
||||
_header_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
_header_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
_header_icon.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||||
_header_icon.texture = ICON_BROADCAST
|
||||
_header_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
header.add_child(_header_icon)
|
||||
|
||||
_header_title = Label.new()
|
||||
_header_title.text = "世界公告"
|
||||
_header_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_header_title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_header_title.add_theme_font_size_override("font_size", 26)
|
||||
_header_title.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
_header_title.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
header.add_child(_header_title)
|
||||
|
||||
_header_banner = TextureRect.new()
|
||||
_header_banner.name = "TownBanner"
|
||||
_header_banner.custom_minimum_size = Vector2(88, 58)
|
||||
_header_banner.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
_header_banner.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
_header_banner.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||||
_header_banner.texture = ICON_BANNER
|
||||
_header_banner.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
header.add_child(_header_banner)
|
||||
|
||||
var close_button := Button.new()
|
||||
close_button.text = "×"
|
||||
close_button.tooltip_text = "收起世界公告"
|
||||
close_button.custom_minimum_size = Vector2(54, 54)
|
||||
close_button.focus_mode = Control.FOCUS_NONE
|
||||
close_button.add_theme_font_size_override("font_size", 34)
|
||||
close_button.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
close_button.add_theme_color_override("font_hover_color", ACCENT_COLOR)
|
||||
close_button.add_theme_stylebox_override("normal", _empty_style())
|
||||
close_button.add_theme_stylebox_override("hover", _hover_style())
|
||||
close_button.add_theme_stylebox_override("pressed", _pressed_style())
|
||||
close_button.pressed.connect(func() -> void: _set_expanded(false))
|
||||
header.add_child(close_button)
|
||||
|
||||
var header_rule := ColorRect.new()
|
||||
header_rule.custom_minimum_size = Vector2(0, 1)
|
||||
header_rule.color = LINE_COLOR
|
||||
header_rule.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_content.add_child(header_rule)
|
||||
|
||||
_scroll = ScrollContainer.new()
|
||||
_scroll.name = "AnnouncementScroll"
|
||||
_scroll.custom_minimum_size = Vector2(0, 320)
|
||||
_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
_scroll.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_content.add_child(_scroll)
|
||||
|
||||
_entries = VBoxContainer.new()
|
||||
_entries.name = "AnnouncementEntries"
|
||||
_entries.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_entries.add_theme_constant_override("separation", 0)
|
||||
_scroll.add_child(_entries)
|
||||
|
||||
var composer_row := HBoxContainer.new()
|
||||
composer_row.name = "Composer"
|
||||
composer_row.custom_minimum_size = Vector2(0, 72)
|
||||
composer_row.add_theme_constant_override("separation", 10)
|
||||
_content.add_child(composer_row)
|
||||
|
||||
_composer = LineEdit.new()
|
||||
_composer.name = "AnnouncementInput"
|
||||
_composer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_composer.placeholder_text = "发布世界公告(100鲸币)..."
|
||||
_composer.max_length = 160
|
||||
_composer.add_theme_font_size_override("font_size", 20)
|
||||
_composer.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
_composer.add_theme_color_override("font_placeholder_color", MUTED_COLOR)
|
||||
_composer.add_theme_stylebox_override("normal", _input_style())
|
||||
_composer.add_theme_stylebox_override("focus", _input_focus_style())
|
||||
_composer.text_submitted.connect(func(_value: String) -> void: _publish())
|
||||
composer_row.add_child(_composer)
|
||||
|
||||
_send_button = Button.new()
|
||||
_send_button.name = "PublishButton"
|
||||
_send_button.text = ""
|
||||
_send_button.icon = ICON_SEND_BUTTON
|
||||
_send_button.expand_icon = true
|
||||
_send_button.icon_max_width = 56
|
||||
_send_button.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||||
_send_button.tooltip_text = "发布世界公告"
|
||||
_send_button.custom_minimum_size = Vector2(56, 56)
|
||||
_send_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
_send_button.focus_mode = Control.FOCUS_NONE
|
||||
_send_button.add_theme_color_override("icon_normal_color", Color.WHITE)
|
||||
_send_button.add_theme_color_override("icon_hover_color", Color("eef9ff"))
|
||||
_send_button.add_theme_color_override("icon_pressed_color", Color("bedcf5"))
|
||||
_send_button.add_theme_stylebox_override("normal", _empty_style())
|
||||
_send_button.add_theme_stylebox_override("hover", _empty_style())
|
||||
_send_button.add_theme_stylebox_override("pressed", _empty_style())
|
||||
_send_button.add_theme_stylebox_override("focus", _empty_style())
|
||||
_send_button.pressed.connect(_publish)
|
||||
composer_row.add_child(_send_button)
|
||||
|
||||
func _subscribe_to_events() -> void:
|
||||
var event_system := get_node_or_null("/root/EventSystem")
|
||||
if event_system == null:
|
||||
return
|
||||
event_system.call("connect_event", EventNames.CHAT_MESSAGE_RECEIVED, _on_chat_message_received, self)
|
||||
event_system.call("connect_event", EventNames.CHAT_MESSAGE_SENT, _on_chat_message_sent, self)
|
||||
event_system.call("connect_event", EventNames.CHAT_ERROR_OCCURRED, _on_chat_error, self)
|
||||
|
||||
func _on_chat_message_sent(data: Dictionary) -> void:
|
||||
if not _publish_pending or not bool(data.get("world_bulletin", false)):
|
||||
return
|
||||
_composer.clear()
|
||||
_set_publish_pending(false)
|
||||
|
||||
func _on_chat_error(data: Dictionary) -> void:
|
||||
if not _publish_pending or not bool(data.get("world_bulletin", false)):
|
||||
return
|
||||
_set_publish_pending(false)
|
||||
_composer.grab_focus()
|
||||
|
||||
func _set_publish_pending(pending: bool) -> void:
|
||||
_publish_pending = pending
|
||||
_composer.editable = not pending
|
||||
_send_button.disabled = pending
|
||||
|
||||
func _seed_preview_items() -> void:
|
||||
_announcements = [
|
||||
{"sender": "小海豚", "time": "12:08", "content": "今晚海湾烟花节 20:00 开始", "category": "活动", "pinned": true},
|
||||
{"sender": "珊珊酱", "time": "12:12", "content": "需要 2 名采集伙伴一起出海", "category": "招募"},
|
||||
{"sender": "海盐", "time": "12:20", "content": "新地图补给点已刷新", "category": "通知"},
|
||||
{"sender": "系统", "time": "12:30", "content": "世界频道维护将在 15 分钟后结束", "category": "通知"}
|
||||
]
|
||||
_last_preview = str(_announcements[0].get("content", _last_preview))
|
||||
_render_entries()
|
||||
|
||||
func _on_chat_message_received(data: Dictionary) -> void:
|
||||
# 只展示后端确认并已扣费的世界公告,普通全局聊天不进公告栏。
|
||||
if not bool(data.get("world_bulletin", data.get("worldBulletin", false))):
|
||||
return
|
||||
var scope := str(data.get("scope", data.get("channel", "global"))).to_lower()
|
||||
var tab := str(data.get("tab", "world")).to_lower()
|
||||
if scope in ["private", "whisper"] or tab in ["private", "whisper", "friends"]:
|
||||
return
|
||||
var content := str(data.get("content", data.get("message", ""))).strip_edges()
|
||||
if content.is_empty():
|
||||
return
|
||||
var sender := str(data.get("from_user", data.get("username", "玩家"))).strip_edges()
|
||||
if sender.is_empty():
|
||||
sender = "玩家"
|
||||
var category := "通知"
|
||||
if sender == "系统" or str(data.get("sender_type", "")).to_lower() == "system":
|
||||
category = "通知"
|
||||
_announcements.push_front({
|
||||
"sender": sender,
|
||||
"time": Time.get_time_string_from_system().left(5),
|
||||
"content": content,
|
||||
"category": category
|
||||
})
|
||||
while _announcements.size() > MAX_ITEMS:
|
||||
_announcements.pop_back()
|
||||
_last_preview = content
|
||||
if not _expanded:
|
||||
_unread += 1
|
||||
_render_entries()
|
||||
_update_ticker()
|
||||
|
||||
func _set_expanded(expanded: bool, immediate := false) -> void:
|
||||
_expanded = expanded
|
||||
if _expanded:
|
||||
_unread = 0
|
||||
_panel.custom_minimum_size = Vector2(PANEL_WIDTH, EXPANDED_HEIGHT)
|
||||
_panel.offset_top = -EDGE_MARGIN - EXPANDED_HEIGHT
|
||||
_ticker_row.hide()
|
||||
_content.show()
|
||||
_render_entries()
|
||||
else:
|
||||
_panel.custom_minimum_size = Vector2(PANEL_WIDTH, COLLAPSED_HEIGHT)
|
||||
_panel.offset_top = -EDGE_MARGIN - COLLAPSED_HEIGHT
|
||||
_content.hide()
|
||||
_ticker_row.show()
|
||||
_update_ticker()
|
||||
if immediate:
|
||||
_panel.modulate.a = 1.0
|
||||
|
||||
func _update_ticker() -> void:
|
||||
if not is_instance_valid(_ticker):
|
||||
return
|
||||
var unread_text := " · %d" % _unread if _unread > 0 else ""
|
||||
_ticker.text = " 世界公告 %s%s" % [_last_preview.left(25), unread_text]
|
||||
|
||||
func _render_entries() -> void:
|
||||
if not is_instance_valid(_entries):
|
||||
return
|
||||
for child in _entries.get_children():
|
||||
child.queue_free()
|
||||
if _announcements.is_empty():
|
||||
var empty := VBoxContainer.new()
|
||||
empty.custom_minimum_size = Vector2(0, 210)
|
||||
empty.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
_entries.add_child(empty)
|
||||
var whale := TextureRect.new()
|
||||
whale.texture = ICON_EMPTY
|
||||
whale.custom_minimum_size = Vector2(0, 92)
|
||||
whale.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
whale.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
whale.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||||
whale.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
empty.add_child(whale)
|
||||
var empty_label := Label.new()
|
||||
empty_label.text = "暂无公告"
|
||||
empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
empty_label.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
empty.add_child(empty_label)
|
||||
return
|
||||
for item in _announcements:
|
||||
_entries.add_child(_build_entry(item))
|
||||
|
||||
func _build_entry(item: Dictionary) -> Control:
|
||||
var card := PanelContainer.new()
|
||||
card.custom_minimum_size = Vector2(0, 92)
|
||||
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
card.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var card_style := StyleBoxFlat.new()
|
||||
card_style.bg_color = Color(1, 1, 1, 0.55)
|
||||
card_style.border_color = LINE_COLOR
|
||||
card_style.set_border_width_all(1)
|
||||
card_style.set_corner_radius_all(10)
|
||||
card.add_theme_stylebox_override("panel", card_style)
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 10)
|
||||
margin.add_theme_constant_override("margin_top", 7)
|
||||
margin.add_theme_constant_override("margin_right", 10)
|
||||
margin.add_theme_constant_override("margin_bottom", 7)
|
||||
card.add_child(margin)
|
||||
|
||||
var card_row := HBoxContainer.new()
|
||||
card_row.add_theme_constant_override("separation", 8)
|
||||
card_row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
margin.add_child(card_row)
|
||||
|
||||
var row := VBoxContainer.new()
|
||||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.add_theme_constant_override("separation", 2)
|
||||
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
card_row.add_child(row)
|
||||
|
||||
var meta := HBoxContainer.new()
|
||||
meta.custom_minimum_size = Vector2(0, 30)
|
||||
meta.add_theme_constant_override("separation", 6)
|
||||
meta.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
row.add_child(meta)
|
||||
|
||||
if bool(item.get("pinned", false)):
|
||||
var pin := TextureRect.new()
|
||||
pin.custom_minimum_size = Vector2(28, 28)
|
||||
pin.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
pin.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
pin.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||||
pin.texture = ICON_PIN
|
||||
pin.tooltip_text = "置顶公告"
|
||||
pin.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
meta.add_child(pin)
|
||||
|
||||
var sender := Label.new()
|
||||
sender.text = str(item.get("sender", "玩家"))
|
||||
sender.add_theme_font_size_override("font_size", 20)
|
||||
sender.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
meta.add_child(sender)
|
||||
|
||||
var category := Label.new()
|
||||
category.text = str(item.get("category", "通知"))
|
||||
category.add_theme_font_size_override("font_size", 16)
|
||||
category.add_theme_color_override("font_color", Color.WHITE)
|
||||
category.add_theme_stylebox_override("normal", _category_style(category.text))
|
||||
meta.add_child(category)
|
||||
|
||||
if str(item.get("category", "")) == "招募":
|
||||
var recruit := TextureRect.new()
|
||||
recruit.custom_minimum_size = Vector2(32, 28)
|
||||
recruit.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
recruit.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
recruit.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
|
||||
recruit.texture = ICON_RECRUIT
|
||||
recruit.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
meta.add_child(recruit)
|
||||
|
||||
var time := Label.new()
|
||||
time.text = str(item.get("time", ""))
|
||||
time.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
time.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
time.add_theme_font_size_override("font_size", 16)
|
||||
time.add_theme_color_override("font_color", MUTED_COLOR)
|
||||
meta.add_child(time)
|
||||
|
||||
var body := Label.new()
|
||||
body.text = str(item.get("content", ""))
|
||||
body.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
body.custom_minimum_size = Vector2(0, 30)
|
||||
body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
body.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
body.add_theme_font_size_override("font_size", 20)
|
||||
body.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
body.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
row.add_child(body)
|
||||
|
||||
return card
|
||||
|
||||
func _category_style(category: String) -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.set_corner_radius_all(6)
|
||||
style.content_margin_left = 7
|
||||
style.content_margin_right = 7
|
||||
style.content_margin_top = 3
|
||||
style.content_margin_bottom = 3
|
||||
match category:
|
||||
"活动": style.bg_color = Color("55bd8a")
|
||||
"招募": style.bg_color = Color("f0aa4c")
|
||||
_: style.bg_color = ACCENT_COLOR
|
||||
return style
|
||||
|
||||
func _publish() -> void:
|
||||
if _publish_pending:
|
||||
return
|
||||
var content := _composer.text.strip_edges()
|
||||
if content.is_empty():
|
||||
return
|
||||
var manager := get_node_or_null("/root/ChatManager")
|
||||
if manager == null or not manager.has_method("send_world_bulletin"):
|
||||
return
|
||||
if not bool(manager.call("send_world_bulletin", content)):
|
||||
return
|
||||
_set_publish_pending(true)
|
||||
_set_expanded(true)
|
||||
|
||||
func _surface_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = SURFACE_COLOR
|
||||
style.border_color = Color("b8d9f4")
|
||||
style.set_border_width_all(2)
|
||||
style.set_corner_radius_all(18)
|
||||
style.shadow_color = Color(0.16, 0.40, 0.65, 0.18)
|
||||
style.shadow_size = 10
|
||||
style.shadow_offset = Vector2(0, 4)
|
||||
style.content_margin_left = 14
|
||||
style.content_margin_top = 12
|
||||
style.content_margin_right = 14
|
||||
style.content_margin_bottom = 12
|
||||
return style
|
||||
|
||||
func _empty_style() -> StyleBoxEmpty:
|
||||
return StyleBoxEmpty.new()
|
||||
|
||||
func _hover_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.88, 0.95, 1.0, 0.85)
|
||||
style.set_corner_radius_all(12)
|
||||
return style
|
||||
|
||||
func _pressed_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.78, 0.91, 1.0, 0.9)
|
||||
style.set_corner_radius_all(12)
|
||||
return style
|
||||
|
||||
func _input_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(1, 1, 1, 0.9)
|
||||
style.border_color = Color("c4def4")
|
||||
style.set_border_width_all(1)
|
||||
style.set_corner_radius_all(12)
|
||||
style.content_margin_left = 10
|
||||
style.content_margin_right = 10
|
||||
return style
|
||||
|
||||
func _input_focus_style() -> StyleBoxFlat:
|
||||
var style := _input_style()
|
||||
style.border_color = ACCENT_COLOR
|
||||
style.set_border_width_all(2)
|
||||
return style
|
||||
|
||||
func _accent_style() -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = ACCENT_COLOR
|
||||
style.set_corner_radius_all(14)
|
||||
return style
|
||||
|
||||
func _accent_hover_style() -> StyleBoxFlat:
|
||||
var style := _accent_style()
|
||||
style.bg_color = Color("53a8f4")
|
||||
return style
|
||||
|
||||
func _accent_pressed_style() -> StyleBoxFlat:
|
||||
var style := _accent_style()
|
||||
style.bg_color = Color("247bc7")
|
||||
return style
|
||||
1
scenes/ui/WorldBulletinPanel.gd.uid
Normal file
1
scenes/ui/WorldBulletinPanel.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d1dka1huar71o
|
||||
13
scenes/ui/WorldBulletinPanel.tscn
Normal file
13
scenes/ui/WorldBulletinPanel.tscn
Normal file
@@ -0,0 +1,13 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/ui/WorldBulletinPanel.gd" id="1_world_bulletin"]
|
||||
|
||||
[node name="WorldBulletinPanel" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_world_bulletin")
|
||||
Reference in New Issue
Block a user