extends CharacterBody2D class_name RemotePlayer # 远程玩家脚本 # 负责处理位置同步和动画播放 # 严格遵循 Visual Only 原则:无输入处理,无物理碰撞 var userId: String = "" var username: 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_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 const CAFE_NAME_LABEL_VISUAL_MIN_WIDTH: int = 76 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, "right": 2, "left": 3, } @onready var animation_player: AnimationPlayer = $AnimationPlayer @onready var sprite: Sprite2D = $Sprite2D var _nameLabel: Label var _cafeCompanionTarget: CafeCompanionTarget func _ready() -> void: # 初始化时确保无物理处理 set_physics_process(false) # 初始位置设为当前位置 targetPosition = global_position _apply_appearance() _update_world_sort_z() _create_name_label() _update_name_label() _subscribe_to_settings_events() # 确保禁用物理碰撞 (双重保险) if has_node("CollisionShape2D"): $CollisionShape2D.disabled = true func _exit_tree() -> void: var eventSystem := get_node_or_null("/root/EventSystem") if eventSystem != null: eventSystem.call("disconnect_event", EventNames.SETTINGS_CHANGED, _on_settings_changed, self) func _process(delta: float) -> void: # 1. 平滑移动插值 var currentPos: Vector2 = global_position var distance: float = currentPos.distance_to(targetPosition) if distance > 1.0: # 简单的线性插值,速度系数 10.0 可根据需要调整 var newPos: Vector2 = currentPos.lerp(targetPosition, 10.0 * delta) # 计算移动向量用于动画朝向 var moveVec: Vector2 = newPos - currentPos _update_animation(moveVec) global_position = newPos _update_world_sort_z() else: # 距离很近时吸附;动画状态由发送端的 idle/walk 决定。 global_position = targetPosition _update_world_sort_z() _play_current_animation() # 统一初始化方法 # data: 包含 camelCase 字段的字典 (userId, username, position 等) func setup(data: Dictionary) -> void: update_metadata(data) if data.has("position"): var positionData: Variant = data.position if positionData is Vector2: global_position = positionData targetPosition = positionData _update_world_sort_z() 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, 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)) func _update_animation(moveVec: Vector2) -> void: if not animation_player: return # 新协议使用发送端方向;兼容旧位置包时再从位移向量推断。 if not _hasSyncedDirection: if abs(moveVec.x) > abs(moveVec.y): if moveVec.x > 0: lastDirection = "right" else: lastDirection = "left" else: 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) func _apply_appearance() -> void: var appearanceManager := get_node_or_null("/root/AppearanceManager") if appearanceManager == null: return if not skinAsset.is_empty() and appearanceManager.has_method("apply_skin_asset_to_sprite"): appearanceManager.call("apply_skin_asset_to_sprite", sprite, skinId, skinAsset) elif appearanceManager.has_method("apply_skin_to_sprite"): appearanceManager.call("apply_skin_to_sprite", sprite, skinId) _configure_directional_animations() func _configure_directional_animations() -> void: if animation_player == null or sprite == null: return var library := animation_player.get_animation_library("") if library == null: library = AnimationLibrary.new() animation_player.add_animation_library("", library) var frameCount: int = max(1, sprite.hframes) for direction in DIRECTION_ROWS.keys(): var row := int(DIRECTION_ROWS[direction]) _set_frame_animation(library, "idle_%s" % direction, [row * frameCount], 0.1) var walkFrames: Array[int] = [] for column in range(frameCount): walkFrames.append(row * frameCount + column) _set_frame_animation(library, "walk_%s" % direction, walkFrames, WALK_ANIMATION_LENGTH) func _set_frame_animation(library: AnimationLibrary, animationName: String, frameValues: Array[int], length: float) -> void: if library.has_animation(animationName): library.remove_animation(animationName) var animation := Animation.new() animation.resource_name = animationName animation.length = length animation.loop_mode = Animation.LOOP_LINEAR var trackIndex := animation.add_track(Animation.TYPE_VALUE) animation.track_set_path(trackIndex, NodePath("Sprite2D:frame")) animation.value_track_set_update_mode(trackIndex, Animation.UPDATE_DISCRETE) var frameStep: float = length / float(max(1, frameValues.size())) for index in range(frameValues.size()): animation.track_insert_key(trackIndex, index * frameStep, frameValues[index]) library.add_animation(animationName, animation) func _subscribe_to_settings_events() -> void: var eventSystem := get_node_or_null("/root/EventSystem") if eventSystem != null: eventSystem.call("connect_event", EventNames.SETTINGS_CHANGED, _on_settings_changed, self) func _on_settings_changed(_data: Dictionary) -> void: _update_name_label() func _create_name_label() -> void: if is_instance_valid(_nameLabel): return _nameLabel = Label.new() _nameLabel.name = "NameLabel" 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) 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() if displayName.is_empty(): displayName = "陪伴机器人" var visualWidth := _cafe_name_label_visual_width(displayName) var renderWidth := ceili(float(visualWidth) / CAFE_NAME_LABEL_RENDER_SCALE) var renderHeight := ceili(float(CAFE_NAME_LABEL_VISUAL_HEIGHT) / CAFE_NAME_LABEL_RENDER_SCALE) _nameLabel.theme = WORLD_TEXT_THEME _nameLabel.text = displayName _nameLabel.z_index = 30 _nameLabel.scale = Vector2.ONE * CAFE_NAME_LABEL_RENDER_SCALE _nameLabel.position = Vector2(float(visualWidth) * -0.5, CAFE_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_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: 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_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_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 return clampi(estimatedWidth, CAFE_NAME_LABEL_VISUAL_MIN_WIDTH, CAFE_NAME_LABEL_VISUAL_MAX_WIDTH) func _configure_cafe_companion_target() -> void: if cafeCompanionData.is_empty(): _clear_cafe_companion_target() return if not is_instance_valid(_cafeCompanionTarget): _cafeCompanionTarget = CafeCompanionTarget.new() _cafeCompanionTarget.name = "CafeCompanionTarget" _cafeCompanionTarget.position = Vector2(0, -58) _cafeCompanionTarget.collision_layer = 2 _cafeCompanionTarget.collision_mask = 0 add_child(_cafeCompanionTarget) var shapeNode := CollisionShape2D.new() shapeNode.name = "CollisionShape2D" var shape := RectangleShape2D.new() shape.size = Vector2(112, 148) shapeNode.shape = shape _cafeCompanionTarget.add_child(shapeNode) _cafeCompanionTarget.servicePointId = str(cafeCompanionData.get("service_point_id", "")).strip_edges() _cafeCompanionTarget.companionId = str(cafeCompanionData.get("companion_id", "")).strip_edges() _cafeCompanionTarget.companionType = str(cafeCompanionData.get("companion_type", "hired_player")).strip_edges() _cafeCompanionTarget.personaName = str(cafeCompanionData.get("persona_name", "")).strip_edges() _cafeCompanionTarget.ownerUserId = str(cafeCompanionData.get("owner_user_id", "")).strip_edges() _cafeCompanionTarget.employmentEndsAt = str(cafeCompanionData.get("employment_ends_at", "")).strip_edges() _cafeCompanionTarget.selfTarget = false _cafeCompanionTarget.input_pickable = true var shapeNode := _cafeCompanionTarget.get_node_or_null("CollisionShape2D") as CollisionShape2D if shapeNode != null: shapeNode.disabled = false func _clear_cafe_companion_target() -> void: if is_instance_valid(_cafeCompanionTarget): _cafeCompanionTarget.queue_free() _cafeCompanionTarget = null func _normalize_cafe_companion_data(value: Variant) -> Dictionary: if not (value is Dictionary): return {} var raw: Dictionary = value var servicePointId := str(raw.get("servicePointId", raw.get("service_point_id", ""))).strip_edges() var companionId := str(raw.get("companionId", raw.get("companion_id", ""))).strip_edges() var personaName := str(raw.get("personaName", raw.get("persona_name", ""))).strip_edges() if servicePointId.is_empty() or companionId.is_empty() or personaName.is_empty(): return {} return { "cafe_id": str(raw.get("cafeId", raw.get("cafe_id", "whale_cafe"))).strip_edges(), "service_point_id": servicePointId, "companion_id": companionId, "companion_type": str(raw.get("companionType", raw.get("companion_type", "hired_player"))).strip_edges(), "persona_name": personaName, "employment_ends_at": str(raw.get("employmentEndsAt", raw.get("employment_ends_at", ""))).strip_edges(), "owner_user_id": str(raw.get("ownerUserId", raw.get("owner_user_id", ""))).strip_edges(), } func _settings_bool(key: String, defaultValue: bool) -> bool: var settingsManager := get_node_or_null("/root/SettingsManager") if settingsManager != null and settingsManager.has_method("get_bool"): return bool(settingsManager.call("get_bool", key)) return defaultValue 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() 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