class_name CafeInterior extends Node2D # ============================================================================ # CafeInterior.gd - 鲸鱼咖啡馆室内场景控制器 # ============================================================================ # 承载咖啡馆挂机服务大厅,负责玩家出生点、相机边界和返回工作区。 # ============================================================================ const CAMERA_ZOOM: Vector2 = Vector2(1.65, 1.65) const CAMERA_LIMIT_LEFT: int = -768 const CAMERA_LIMIT_TOP: int = -512 const CAMERA_LIMIT_RIGHT: int = 768 const CAMERA_LIMIT_BOTTOM: int = 512 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 NPC_NAMEPLATE_OFFSET_Y: float = -136.0 const HIRED_PLAYER_NAMEPLATE_OFFSET_Y: float = -96.0 const InteractionAnchorUtil = preload("res://_Core/utils/InteractionAnchor.gd") @onready var player: PlayerController = $YSortWorld/Characters/Players/Player @onready var playerCamera: Camera2D = $YSortWorld/Characters/Players/Player/Camera2D @onready var hiredPlayerCompanionTarget: CafeCompanionTarget = $YSortWorld/Characters/Players/Player/CafeHiredCompanionTarget @onready var hiredPlayerNameplate: Label = $YSortWorld/Characters/Players/Player/CafeHiredCompanionNameplate @onready var exitToWorkZoneArea: Area2D = $InteractionAreas/ExitToWorkZoneArea @onready var cafeRecruitmentLogoArea: Area2D = $InteractionAreas/CafeRecruitmentLogoArea @onready var serviceIdlePoint01: Marker2D = $Markers/ServiceIdlePoints/ServiceIdlePoint01 @onready var cafeWhaleBaristaNpc: NPCController = $YSortWorld/Characters/CafeWhaleBaristaNpc @onready var cafeCompanionNameplate: Label = $YSortWorld/Characters/CafeWhaleBaristaNpc/CafeCompanionNameplate var _isChangingScene: bool = false var _lastRecruitmentClickMsec: int = 0 func _ready() -> void: add_to_group("whaletown_interactable") _align_service_occupants() _configure_static_companion_nameplates() _apply_spawn_point() _configure_camera() _connect_exit_area() _connect_recruitment_area() _connect_cafe_companion_events() _discover_destination() func _discover_destination() -> void: var destination_id := SceneManager.get_next_destination_id() if destination_id.is_empty(): destination_id = "cafe_entrance" var socialManager := get_node_or_null("/root/SocialManager") if socialManager != null and socialManager.has_method("discover_destination"): socialManager.call("discover_destination", destination_id) func _exit_tree() -> void: var eventSystem := get_node_or_null("/root/EventSystem") if eventSystem != null: eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_AGENT_REGISTERED, _on_cafe_companion_agent_registered, self) eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_EMPLOYMENT_RESIGNED, _on_cafe_companion_employment_resigned, self) func _align_service_occupants() -> void: cafeWhaleBaristaNpc.global_position = serviceIdlePoint01.global_position func _apply_spawn_point() -> void: if player.has_scene_position_override: return var spawnName: String = SceneManager.get_next_spawn_name() var markerName: String = spawnName if not spawnName.is_empty() else "DefaultSpawn" var marker := $Markers.get_node_or_null(markerName) as Marker2D if marker == null: marker = $Markers/DefaultSpawn player.global_position = marker.global_position func _configure_camera() -> void: playerCamera.enabled = true playerCamera.make_current() playerCamera.zoom = CAMERA_ZOOM playerCamera.position_smoothing_enabled = true playerCamera.limit_left = CAMERA_LIMIT_LEFT playerCamera.limit_top = CAMERA_LIMIT_TOP playerCamera.limit_right = CAMERA_LIMIT_RIGHT playerCamera.limit_bottom = CAMERA_LIMIT_BOTTOM playerCamera.limit_smoothed = true func _connect_exit_area() -> void: exitToWorkZoneArea.collision_mask = 0 func _connect_recruitment_area() -> void: cafeRecruitmentLogoArea.input_pickable = true if not cafeRecruitmentLogoArea.input_event.is_connected(_on_recruitment_area_input_event): cafeRecruitmentLogoArea.input_event.connect(_on_recruitment_area_input_event) set_process_unhandled_input(true) func _connect_cafe_companion_events() -> void: var eventSystem := get_node_or_null("/root/EventSystem") if eventSystem == null: return eventSystem.call("connect_event", EventNames.CAFE_COMPANION_AGENT_REGISTERED, _on_cafe_companion_agent_registered, self) eventSystem.call("connect_event", EventNames.CAFE_COMPANION_EMPLOYMENT_RESIGNED, _on_cafe_companion_employment_resigned, self) func _on_exit_area_body_entered(body: Node2D) -> void: return func get_interaction_actions(local_player: Node2D) -> Array[Dictionary]: if _isChangingScene or exitToWorkZoneArea == null: return [] var anchor_position: Vector2 = InteractionAnchorUtil.get_position(exitToWorkZoneArea) var distance: float = anchor_position.distance_to(local_player.global_position) if distance > 160.0: return [] return [{ "id": "cafe_exit", "title": "离开咖啡馆", "distance": distance, "priority": 20, "callback": Callable(self, "_leave_to_work_zone"), }] func get_interaction_marker_positions() -> Array[Vector2]: if exitToWorkZoneArea == null: return [] return [InteractionAnchorUtil.get_position(exitToWorkZoneArea)] func _leave_to_work_zone() -> void: if _isChangingScene: return _isChangingScene = true SceneManager.set_next_scene_position(WORK_ZONE_CAFE_RETURN_POSITION) SceneManager.change_scene("work_zone") func _on_recruitment_area_input_event(_viewport: Viewport, event: InputEvent, _shapeIdx: int) -> void: if not _is_primary_mouse_click(event): return get_viewport().set_input_as_handled() _try_emit_recruitment_selected() func _unhandled_input(event: InputEvent) -> void: if not _is_primary_mouse_click(event): return if not _area_contains_global_point(cafeRecruitmentLogoArea, get_global_mouse_position()): return get_viewport().set_input_as_handled() _try_emit_recruitment_selected() func _try_emit_recruitment_selected() -> void: var now := Time.get_ticks_msec() if _lastRecruitmentClickMsec > 0 and now - _lastRecruitmentClickMsec < 120: return _lastRecruitmentClickMsec = now _emit_recruitment_selected() func _emit_recruitment_selected() -> void: var eventSystem := get_node_or_null("/root/EventSystem") if eventSystem == null: return eventSystem.call("emit_event", EventNames.CAFE_COMPANION_RECRUITMENT_SELECTED, { "cafe_id": "whale_cafe", "terminal_id": "front_reception_whale_logo", "title": "咖啡店陪伴招聘", "anchor_position": cafeRecruitmentLogoArea.global_position, }) func _is_primary_mouse_click(event: InputEvent) -> bool: if not (event is InputEventMouseButton): return false var mouseEvent := event as InputEventMouseButton return mouseEvent.button_index == MOUSE_BUTTON_LEFT and mouseEvent.pressed func _area_contains_global_point(area: Area2D, globalPoint: Vector2) -> bool: for child in area.get_children(): var shapeNode := child as CollisionShape2D if shapeNode == null or shapeNode.disabled or shapeNode.shape == null: continue if _shape_contains_point(shapeNode, globalPoint): return true return false func _shape_contains_point(shapeNode: CollisionShape2D, globalPoint: Vector2) -> bool: var localPoint := shapeNode.to_local(globalPoint) var shape := shapeNode.shape if shape is RectangleShape2D: var rectangle := shape as RectangleShape2D return Rect2(rectangle.size * -0.5, rectangle.size).has_point(localPoint) if shape is CircleShape2D: var circle := shape as CircleShape2D return localPoint.length() <= circle.radius return false func _on_cafe_companion_agent_registered(data: Dictionary) -> void: var companionData := _extract_companion_data(data) if companionData.is_empty(): return var servicePointId := str(companionData.get("service_point_id", "")).strip_edges() var servicePointMarker := _service_point_marker(servicePointId) if servicePointMarker == null: push_warning("CafeInterior: 服务点不存在,无法摆放被雇佣玩家: %s" % servicePointId) return var personaName := str(companionData.get("persona_name", "陪伴机器人")).strip_edges() player.global_position = servicePointMarker.global_position player.velocity = Vector2.ZERO player.lastDirection = "down" player.set_movement_locked(true) player.call("_play_idle_animation") player.call("_update_world_sort_z") _configure_hired_player_companion_target(companionData, servicePointId, personaName) _configure_hired_player_nameplate(personaName) func _on_cafe_companion_employment_resigned(_data: Dictionary) -> void: player.set_movement_locked(false) _disable_hired_player_companion_target() hiredPlayerNameplate.visible = false player.global_position = CAFE_DOOR_POSITION player.velocity = Vector2.ZERO player.lastDirection = "down" player.call("_play_idle_animation") player.call("_update_world_sort_z") func _extract_companion_data(data: Dictionary) -> Dictionary: var companionVariant: Variant = data.get("companion", {}) if companionVariant is Dictionary: return companionVariant if data.has("service_point_id"): return data return {} func _service_point_marker(servicePointId: String) -> Marker2D: if servicePointId.is_empty(): return null return $Markers/ServiceIdlePoints.get_node_or_null(servicePointId) as Marker2D func _configure_hired_player_companion_target(companionData: Dictionary, servicePointId: String, personaName: String) -> void: hiredPlayerCompanionTarget.servicePointId = servicePointId hiredPlayerCompanionTarget.companionId = str(companionData.get("id", "")).strip_edges() if hiredPlayerCompanionTarget.companionId.is_empty(): hiredPlayerCompanionTarget.companionId = str(companionData.get("companion_id", "")).strip_edges() hiredPlayerCompanionTarget.companionType = str(companionData.get("companion_type", "hired_player")).strip_edges() hiredPlayerCompanionTarget.personaName = personaName hiredPlayerCompanionTarget.ownerUserId = str(companionData.get("owner_user_id", "")).strip_edges() hiredPlayerCompanionTarget.employmentEndsAt = str(companionData.get("employment_ends_at", "")).strip_edges() hiredPlayerCompanionTarget.selfTarget = true hiredPlayerCompanionTarget.input_pickable = true var shapeNode := hiredPlayerCompanionTarget.get_node_or_null("CollisionShape2D") as CollisionShape2D if shapeNode != null: shapeNode.disabled = false func _disable_hired_player_companion_target() -> void: hiredPlayerCompanionTarget.input_pickable = false hiredPlayerCompanionTarget.selfTarget = false var shapeNode := hiredPlayerCompanionTarget.get_node_or_null("CollisionShape2D") as CollisionShape2D if shapeNode != null: shapeNode.disabled = true func _configure_hired_player_nameplate(personaName: String) -> void: _configure_companion_nameplate(hiredPlayerNameplate, personaName, HIRED_PLAYER_NAMEPLATE_OFFSET_Y) hiredPlayerNameplate.visible = true func _configure_static_companion_nameplates() -> void: _configure_companion_nameplate(cafeCompanionNameplate, cafeWhaleBaristaNpc.npcName, NPC_NAMEPLATE_OFFSET_Y) cafeCompanionNameplate.visible = true func _configure_companion_nameplate(label: Label, personaName: String, offsetY: float) -> void: var displayName := personaName.strip_edges() if displayName.is_empty(): displayName = "陪伴机器人" var visualWidth := _companion_nameplate_visual_width(displayName) var renderWidth := ceili(float(visualWidth) / COMPANION_NAMEPLATE_RENDER_SCALE) var renderHeight := ceili(float(COMPANION_NAMEPLATE_VISUAL_HEIGHT) / COMPANION_NAMEPLATE_RENDER_SCALE) label.theme = WORLD_TEXT_THEME label.text = displayName label.visible = true label.z_index = 30 label.scale = Vector2.ONE * COMPANION_NAMEPLATE_RENDER_SCALE label.position = Vector2(float(visualWidth) * -0.5, offsetY) label.custom_minimum_size = Vector2(renderWidth, renderHeight) label.size = label.custom_minimum_size label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER 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_font_size_override("font_size", COMPANION_NAMEPLATE_FONT_SIZE) label.add_theme_stylebox_override("normal", _create_companion_nameplate_style()) 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) 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