Improved detective board system

This commit is contained in:
2026-02-10 03:00:42 +01:00
parent b172762d4f
commit 8758d6eaeb
28 changed files with 3211 additions and 305 deletions
@@ -1,93 +0,0 @@
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using Unheimlich.Data.DetectiveBoard;
namespace Unheimlich.UI.DetectiveBoard
{
/// <summary>
/// Visual representation of a clue node on the detective board.
/// Displays as a post-it/card with title, description, and icon.
/// Supports visual states: unlocked, eliminated, highlighted.
/// </summary>
public class ClueNodeUI : MonoBehaviour
{
[Header("References")]
[SerializeField] private Image _nodeImage;
[SerializeField] private TextMeshProUGUI _titleText;
[SerializeField] private Image _iconImage;
[SerializeField] private GameObject _eliminatedOverlay;
private ClueData _clueData;
private bool _isEliminated;
private Color _originalColor;
public ClueData ClueData => _clueData;
public bool IsEliminated => _isEliminated;
/// <summary>
/// Initializes the node with clue data.
/// </summary>
public void Initialize(ClueData clueData)
{
_clueData = clueData;
if (_titleText != null)
_titleText.text = clueData.Title;
if (_iconImage != null && clueData.Icon != null)
{
_iconImage.sprite = clueData.Icon;
_iconImage.enabled = true;
}
else if (_iconImage != null)
{
_iconImage.enabled = false;
}
if (_nodeImage != null)
{
_originalColor = clueData.NodeColor;
_nodeImage.color = _originalColor;
}
if (_eliminatedOverlay != null)
_eliminatedOverlay.SetActive(false);
}
/// <summary>
/// Marks this node as eliminated (incorrect hypothesis).
/// </summary>
public void SetEliminated(bool eliminated)
{
_isEliminated = eliminated;
if (_eliminatedOverlay != null)
_eliminatedOverlay.SetActive(eliminated);
if (_nodeImage != null)
{
_nodeImage.color = eliminated ? Color.gray : _originalColor;
}
}
/// <summary>
/// Highlights the node when hovered.
/// </summary>
public void SetHighlighted(bool highlighted, Color highlightColor)
{
if (_nodeImage != null && !_isEliminated)
{
_nodeImage.color = highlighted ? highlightColor : _originalColor;
}
}
/// <summary>
/// Updates the position of this node on the board (world space or canvas).
/// </summary>
public void SetPosition(Vector3 position)
{
transform.position = position;
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: a7086ec4620627b458b08500e412ee90
@@ -0,0 +1,79 @@
using UnityEngine;
using UnityEngine.UI;
namespace Unheimlich.UI.DetectiveBoard
{
[RequireComponent(typeof(RectTransform))]
public class ConnectionLine : MonoBehaviour
{
[Header("References")]
[SerializeField] private Image _lineImage;
[SerializeField] private RectTransform _rectTransform;
[Header("Settings")]
[SerializeField] private float _lineThickness = 3f;
[SerializeField] private Color _lineColor = Color.white;
private PostItNode _startNode;
private PostItNode _endNode;
public PostItNode StartNode => _startNode;
public PostItNode EndNode => _endNode;
private void Awake()
{
if (_rectTransform == null)
_rectTransform = GetComponent<RectTransform>();
if (_lineImage == null)
_lineImage = GetComponent<Image>();
}
public void Initialize(PostItNode startNode, PostItNode endNode, Color? color = null)
{
_startNode = startNode;
_endNode = endNode;
if (color.HasValue)
_lineColor = color.Value;
if (_lineImage != null)
_lineImage.color = _lineColor;
UpdateLine();
}
public void UpdateLine()
{
if (_startNode == null || _endNode == null || _rectTransform == null)
return;
Vector2 startPos = _startNode.ConnectionPoint.anchoredPosition;
Vector2 endPos = _endNode.ConnectionPoint.anchoredPosition;
Vector2 direction = endPos - startPos;
float distance = direction.magnitude;
_rectTransform.anchoredPosition = startPos;
_rectTransform.sizeDelta = new Vector2(distance, _lineThickness);
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
_rectTransform.rotation = Quaternion.Euler(0, 0, angle);
_rectTransform.pivot = new Vector2(0, 0.5f);
}
public void SetColor(Color color)
{
_lineColor = color;
if (_lineImage != null)
_lineImage.color = color;
}
public bool ConnectsNodes(PostItNode nodeA, PostItNode nodeB)
{
return (_startNode == nodeA && _endNode == nodeB) ||
(_startNode == nodeB && _endNode == nodeA);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d38fc1a64b1949443b49ec6506174085
@@ -0,0 +1,380 @@
using UnityEngine;
using UnityEngine.InputSystem;
using System.Collections.Generic;
using System.Linq;
using Unheimlich.Data.DetectiveBoard;
using Unheimlich.Managers;
namespace Unheimlich.UI.DetectiveBoard
{
public class DetectiveBoardCanvas : MonoBehaviour
{
[Header("References")]
[SerializeField] private Canvas _canvas;
[SerializeField] private RectTransform _boardArea;
[SerializeField] private GameObject _postItNodePrefab;
[SerializeField] private GameObject _connectionLinePrefab;
[Header("World Space Setup")]
[SerializeField] private Camera _targetCamera;
[Header("Connection Settings")]
[SerializeField] private Color _connectionColor = Color.white;
[SerializeField] private bool _allowFreeConnections = false;
[Header("Input")]
[SerializeField] private Key _connectModeKey = Key.LeftCtrl;
[SerializeField] private Key _eliminateKey = Key.X;
private Dictionary<string, PostItNode> _activeNodes = new Dictionary<string, PostItNode>();
private List<ConnectionLine> _activeConnections = new List<ConnectionLine>();
private PostItNode _selectedNode;
private bool _isConnectMode;
private DetectiveBoardSaveData _saveData;
public bool IsConnectMode => _isConnectMode;
/// <summary>
/// Called by DetectiveBoardInteraction when a node is left-clicked.
/// Handles connection creation in connect mode, or selection in normal mode.
/// </summary>
public void OnNodeClicked(PostItNode node)
{
if (_isConnectMode)
{
HandleConnectionModeClick(node);
}
else
{
_selectedNode = node;
UpdateAllNodeSelections();
}
}
/// <summary>
/// Called by DetectiveBoardInteraction when a node is right-clicked.
/// Toggles the eliminated state of the node.
/// </summary>
public void OnNodeRightClicked(PostItNode node)
{
ToggleNodeEliminated(node);
}
private void Awake()
{
if (_canvas == null)
_canvas = GetComponent<Canvas>();
if (_canvas != null && _canvas.renderMode == RenderMode.WorldSpace)
{
if (_targetCamera == null)
_targetCamera = Camera.main;
_canvas.worldCamera = _targetCamera;
}
_saveData = new DetectiveBoardSaveData();
}
private void OnEnable()
{
TrySubscribeToManager();
}
private void Start()
{
TrySubscribeToManager();
}
private void TrySubscribeToManager()
{
if (DetectiveBoardManager.Instance != null)
{
DetectiveBoardManager.Instance.OnClueUnlocked -= OnClueUnlocked;
DetectiveBoardManager.Instance.OnClueUnlocked += OnClueUnlocked;
foreach (var clue in DetectiveBoardManager.Instance.GetUnlockedClues())
{
if (!_activeNodes.ContainsKey(clue.ClueID))
{
AddClueNode(clue);
}
}
}
}
private void OnDisable()
{
if (DetectiveBoardManager.Instance != null)
{
DetectiveBoardManager.Instance.OnClueUnlocked -= OnClueUnlocked;
}
}
private void OnClueUnlocked(ClueData clueData)
{
Debug.Log($"[DetectiveBoardCanvas] OnClueUnlocked received for: {clueData.ClueID}");
AddClueNode(clueData);
}
private void Update()
{
HandleConnectModeInput();
HandleEliminateInput();
}
private void HandleConnectModeInput()
{
if (Keyboard.current == null)
return;
bool wasConnectMode = _isConnectMode;
_isConnectMode = Keyboard.current[_connectModeKey].isPressed;
if (wasConnectMode && !_isConnectMode)
{
_selectedNode = null;
UpdateAllNodeSelections();
}
}
private void HandleEliminateInput()
{
if (Keyboard.current == null)
return;
if (Keyboard.current[_eliminateKey].wasPressedThisFrame && _selectedNode != null)
{
ToggleNodeEliminated(_selectedNode);
}
}
public void AddClueNode(ClueData clueData)
{
if (_activeNodes.ContainsKey(clueData.ClueID))
return;
if (_postItNodePrefab == null)
{
Debug.LogError("[DetectiveBoardCanvas] _postItNodePrefab is null! Assign the prefab in the inspector.");
return;
}
if (_boardArea == null)
{
Debug.LogError("[DetectiveBoardCanvas] _boardArea is null! Assign NodesContainer in the inspector.");
return;
}
PostItNodeData nodeData = _saveData.GetNodeData(clueData.ClueID);
if (nodeData == null)
{
Vector2 canvasSize = _boardArea.rect.size;
Vector2 randomPos = new Vector2(
Random.Range(-canvasSize.x * 0.4f, canvasSize.x * 0.4f),
Random.Range(-canvasSize.y * 0.4f, canvasSize.y * 0.4f)
);
nodeData = new PostItNodeData(clueData.ClueID, randomPos);
_saveData.AddOrUpdateNode(nodeData);
}
GameObject nodeObj = Instantiate(_postItNodePrefab, _boardArea);
PostItNode node = nodeObj.GetComponent<PostItNode>();
if (node == null)
{
Debug.LogError("[DetectiveBoardCanvas] PostItNode component not found on prefab");
Destroy(nodeObj);
return;
}
node.Initialize(clueData, nodeData);
_activeNodes.Add(clueData.ClueID, node);
RestoreConnectionsForNode(node);
}
public void RemoveClueNode(string clueID)
{
if (!_activeNodes.TryGetValue(clueID, out PostItNode node))
return;
RemoveConnectionsForNode(node);
_activeNodes.Remove(clueID);
Destroy(node.gameObject);
}
private void HandleConnectionModeClick(PostItNode clickedNode)
{
if (_selectedNode == null)
{
_selectedNode = clickedNode;
UpdateAllNodeSelections();
}
else if (_selectedNode == clickedNode)
{
_selectedNode = null;
UpdateAllNodeSelections();
}
else
{
ToggleConnection(_selectedNode, clickedNode);
_selectedNode = null;
UpdateAllNodeSelections();
}
}
private void ToggleConnection(PostItNode nodeA, PostItNode nodeB)
{
if (!CanConnect(nodeA, nodeB))
return;
ConnectionLine existingConnection = _activeConnections.Find(c => c.ConnectsNodes(nodeA, nodeB));
if (existingConnection != null)
{
RemoveConnection(existingConnection);
}
else
{
CreateConnection(nodeA, nodeB);
}
}
private bool CanConnect(PostItNode nodeA, PostItNode nodeB)
{
if (nodeA == null || nodeB == null)
return false;
if (_allowFreeConnections)
return true;
if (nodeA.ClueData.PossibleConnections == null || nodeA.ClueData.PossibleConnections.Length == 0)
return true;
return nodeA.ClueData.PossibleConnections.Contains(nodeB.NodeID);
}
private void CreateConnection(PostItNode startNode, PostItNode endNode)
{
GameObject lineObj = Instantiate(_connectionLinePrefab, _boardArea);
lineObj.transform.SetAsFirstSibling();
ConnectionLine line = lineObj.GetComponent<ConnectionLine>();
if (line == null)
{
Debug.LogError("[DetectiveBoardCanvas] ConnectionLine component not found on prefab");
Destroy(lineObj);
return;
}
line.Initialize(startNode, endNode, _connectionColor);
_activeConnections.Add(line);
if (!startNode.NodeData.ConnectedNodeIDs.Contains(endNode.NodeID))
startNode.NodeData.ConnectedNodeIDs.Add(endNode.NodeID);
if (!endNode.NodeData.ConnectedNodeIDs.Contains(startNode.NodeID))
endNode.NodeData.ConnectedNodeIDs.Add(startNode.NodeID);
}
private void RemoveConnection(ConnectionLine connection)
{
if (connection.StartNode != null && connection.EndNode != null)
{
connection.StartNode.NodeData.ConnectedNodeIDs.Remove(connection.EndNode.NodeID);
connection.EndNode.NodeData.ConnectedNodeIDs.Remove(connection.StartNode.NodeID);
}
_activeConnections.Remove(connection);
Destroy(connection.gameObject);
}
private void RemoveConnectionsForNode(PostItNode node)
{
var connectionsToRemove = _activeConnections
.Where(c => c.StartNode == node || c.EndNode == node)
.ToList();
foreach (var connection in connectionsToRemove)
{
RemoveConnection(connection);
}
}
/// <summary>
/// Updates all connection lines attached to the specified node.
/// Called during node dragging to keep connections visually updated.
/// </summary>
public void UpdateConnectionsForNode(PostItNode node)
{
foreach (var connection in _activeConnections)
{
if (connection.StartNode == node || connection.EndNode == node)
{
connection.UpdateLine();
}
}
}
private void RestoreConnectionsForNode(PostItNode node)
{
if (node.NodeData.ConnectedNodeIDs == null || node.NodeData.ConnectedNodeIDs.Count == 0)
return;
foreach (string connectedID in node.NodeData.ConnectedNodeIDs)
{
if (_activeNodes.TryGetValue(connectedID, out PostItNode otherNode))
{
bool connectionExists = _activeConnections.Any(c => c.ConnectsNodes(node, otherNode));
if (!connectionExists)
{
CreateConnection(node, otherNode);
}
}
}
}
private void ToggleNodeEliminated(PostItNode node)
{
node.SetEliminated(!node.IsEliminated);
}
private void UpdateAllNodeSelections()
{
foreach (var node in _activeNodes.Values)
{
node.SetSelected(node == _selectedNode);
}
}
public DetectiveBoardSaveData GetSaveData()
{
return _saveData;
}
public void LoadSaveData(DetectiveBoardSaveData saveData)
{
_saveData = saveData;
foreach (var node in _activeNodes.Values)
{
PostItNodeData nodeData = _saveData.GetNodeData(node.NodeID);
if (nodeData != null)
{
node.SetPosition(nodeData.Position);
node.SetEliminated(nodeData.IsEliminated);
}
}
_activeConnections.Clear();
foreach (var node in _activeNodes.Values)
{
RestoreConnectionsForNode(node);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: edfcd01879d2cb34495302ef9882965b
@@ -0,0 +1,289 @@
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;
using Unheimlich.Gameplay.DetectiveBoard;
namespace Unheimlich.UI.DetectiveBoard
{
/// <summary>
/// Handles mouse interaction for the detective board World Space canvas.
/// Manages hover, selection, dragging, and click forwarding for Post-It nodes.
/// Interactions are only active when the board is in inspection mode.
/// </summary>
public class DetectiveBoardInteraction : MonoBehaviour
{
[Header("References")]
[SerializeField] private Camera _camera;
[SerializeField] private Canvas _boardCanvas;
[SerializeField] private RectTransform _boardArea;
[SerializeField] private LayerMask _boardLayer;
[SerializeField] private DetectiveBoardController _boardController;
[Header("Settings")]
[SerializeField] private float _maxInteractionDistance = 5f;
private DetectiveBoardCanvas _canvasManager;
private PostItNode _hoveredNode;
private PostItNode _selectedNode;
private bool _isDragging;
private Plane _canvasPlane;
private Vector3 _dragOffset;
private void Start()
{
if (_camera == null)
_camera = Camera.main;
if (_boardController == null)
_boardController = FindFirstObjectByType<DetectiveBoardController>();
if (_canvasManager == null)
_canvasManager = GetComponent<DetectiveBoardCanvas>();
UpdateCanvasPlane();
}
private void Update()
{
if (!IsInBoardInteractionMode())
{
ClearInteractionState();
return;
}
UpdateCanvasPlane();
if (_isDragging)
{
HandleDrag();
}
else
{
HandleHover();
}
HandleClick();
}
private bool IsInBoardInteractionMode()
{
return _boardController != null && _boardController.IsBoardActive;
}
private void ClearInteractionState()
{
if (_hoveredNode != null)
{
_hoveredNode.SetHovered(false);
_hoveredNode = null;
}
if (_selectedNode != null && _isDragging)
{
_selectedNode.SetSelected(false);
_selectedNode = null;
_isDragging = false;
}
}
private void UpdateCanvasPlane()
{
if (_boardCanvas != null)
{
Vector3 planeNormal = -_boardCanvas.transform.forward;
Vector3 planePosition = _boardCanvas.transform.position;
_canvasPlane = new Plane(planeNormal, planePosition);
}
}
private void HandleHover()
{
PostItNode hitNode = RaycastForNode();
if (hitNode != _hoveredNode)
{
if (_hoveredNode != null)
{
_hoveredNode.SetHovered(false);
}
_hoveredNode = hitNode;
if (_hoveredNode != null)
{
_hoveredNode.SetHovered(true);
}
}
}
private void HandleClick()
{
if (Mouse.current == null)
return;
PostItNode hitNode = RaycastForNode();
if (Mouse.current.leftButton.wasPressedThisFrame)
{
HandleLeftClick(hitNode);
}
else if (Mouse.current.rightButton.wasPressedThisFrame && hitNode != null && _canvasManager != null)
{
_canvasManager.OnNodeRightClicked(hitNode);
}
}
private void HandleLeftClick(PostItNode hitNode)
{
if (_canvasManager != null && _canvasManager.IsConnectMode)
{
if (hitNode != null)
{
_canvasManager.OnNodeClicked(hitNode);
}
return;
}
if (_isDragging)
{
_isDragging = false;
if (_selectedNode != null && hitNode == _selectedNode)
{
return;
}
if (_selectedNode != null)
{
_selectedNode.SetSelected(false);
}
if (hitNode != null)
{
_selectedNode = hitNode;
_selectedNode.SetSelected(true);
_isDragging = true;
CalculateDragOffset();
}
else
{
_selectedNode = null;
}
}
else
{
if (hitNode != null)
{
if (_selectedNode != null && _selectedNode != hitNode)
{
_selectedNode.SetSelected(false);
}
_selectedNode = hitNode;
_selectedNode.SetSelected(true);
_isDragging = true;
CalculateDragOffset();
}
else
{
if (_selectedNode != null)
{
_selectedNode.SetSelected(false);
_selectedNode = null;
}
}
}
}
private void HandleDrag()
{
if (_selectedNode == null || _camera == null)
return;
Vector2 mousePos = Mouse.current.position.ReadValue();
Ray ray = _camera.ScreenPointToRay(new Vector3(mousePos.x, mousePos.y, 0));
if (_canvasPlane.Raycast(ray, out float enter))
{
Vector3 worldHitPoint = ray.GetPoint(enter);
Vector3 newWorldPos = worldHitPoint + _dragOffset;
Vector3 localPos = _boardCanvas.transform.InverseTransformPoint(newWorldPos);
Vector2 clampedPos = ClampPositionToBoard(new Vector2(localPos.x, localPos.y));
_selectedNode.SetPosition(clampedPos);
if (_canvasManager != null)
{
_canvasManager.UpdateConnectionsForNode(_selectedNode);
}
}
}
private Vector2 ClampPositionToBoard(Vector2 position)
{
if (_boardArea == null)
return position;
Rect boardRect = _boardArea.rect;
float halfWidth = boardRect.width * 0.5f;
float halfHeight = boardRect.height * 0.5f;
float clampedX = Mathf.Clamp(position.x, -halfWidth, halfWidth);
float clampedY = Mathf.Clamp(position.y, -halfHeight, halfHeight);
return new Vector2(clampedX, clampedY);
}
private void CalculateDragOffset()
{
if (_selectedNode == null || _camera == null)
return;
Vector2 mousePos = Mouse.current.position.ReadValue();
Ray ray = _camera.ScreenPointToRay(new Vector3(mousePos.x, mousePos.y, 0));
if (_canvasPlane.Raycast(ray, out float enter))
{
Vector3 worldHitPoint = ray.GetPoint(enter);
Vector3 currentWorldPos = _selectedNode.transform.position;
_dragOffset = currentWorldPos - worldHitPoint;
}
}
private PostItNode RaycastForNode()
{
if (_camera == null)
return null;
Vector2 mousePos = Mouse.current.position.ReadValue();
Ray ray = _camera.ScreenPointToRay(new Vector3(mousePos.x, mousePos.y, 0));
if (Physics.Raycast(ray, out RaycastHit hit, _maxInteractionDistance, _boardLayer))
{
return hit.collider.GetComponentInParent<PostItNode>();
}
GraphicRaycaster raycaster = _boardCanvas.GetComponent<GraphicRaycaster>();
if (raycaster != null)
{
var pointerEventData = new UnityEngine.EventSystems.PointerEventData(UnityEngine.EventSystems.EventSystem.current)
{
position = mousePos
};
var results = new System.Collections.Generic.List<UnityEngine.EventSystems.RaycastResult>();
raycaster.Raycast(pointerEventData, results);
foreach (var result in results)
{
PostItNode node = result.gameObject.GetComponentInParent<PostItNode>();
if (node != null)
return node;
}
}
return null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 17bf00a333a0bc9409c21009e3ac6aea
@@ -0,0 +1,128 @@
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using Unheimlich.Data.DetectiveBoard;
namespace Unheimlich.UI.DetectiveBoard
{
public class PostItNode : MonoBehaviour
{
[Header("UI References")]
[SerializeField] private RectTransform _rectTransform;
[SerializeField] private Image _background;
[SerializeField] private TextMeshProUGUI _titleText;
[SerializeField] private TextMeshProUGUI _descriptionText;
[SerializeField] private Image _iconImage;
[SerializeField] private GameObject _strikethroughLine;
[SerializeField] private Image _selectionOutline;
[Header("Connection Points")]
[SerializeField] private RectTransform _connectionPoint;
private ClueData _clueData;
private PostItNodeData _nodeData;
private bool _isEliminated;
private bool _isSelected;
private Color _originalColor;
public string NodeID => _clueData?.ClueID;
public RectTransform RectTransform => _rectTransform;
public RectTransform ConnectionPoint => _connectionPoint;
public ClueData ClueData => _clueData;
public PostItNodeData NodeData => _nodeData;
public bool IsEliminated => _isEliminated;
public bool IsSelected => _isSelected;
private void Awake()
{
if (_rectTransform == null)
_rectTransform = GetComponent<RectTransform>();
if (_strikethroughLine != null)
_strikethroughLine.SetActive(false);
if (_selectionOutline != null)
_selectionOutline.enabled = false;
}
public void Initialize(ClueData clueData, PostItNodeData nodeData)
{
_clueData = clueData;
_nodeData = nodeData;
if (_titleText != null)
_titleText.text = clueData.Title;
if (_descriptionText != null)
_descriptionText.text = clueData.Description;
if (_iconImage != null && clueData.Icon != null)
{
_iconImage.sprite = clueData.Icon;
_iconImage.enabled = true;
}
else if (_iconImage != null)
{
_iconImage.enabled = false;
}
if (_background != null)
{
_originalColor = clueData.NodeColor;
_background.color = _originalColor;
}
if (_connectionPoint == null)
{
_connectionPoint = _rectTransform;
}
SetPosition(nodeData.Position);
SetEliminated(nodeData.IsEliminated);
}
public void SetPosition(Vector2 position)
{
if (_rectTransform != null)
{
_rectTransform.anchoredPosition = position;
if (_nodeData != null)
_nodeData.Position = position;
}
}
public void SetEliminated(bool eliminated)
{
_isEliminated = eliminated;
if (_strikethroughLine != null)
_strikethroughLine.SetActive(eliminated);
if (_background != null)
{
_background.color = eliminated ? new Color(0.5f, 0.5f, 0.5f, 0.8f) : _originalColor;
}
if (_nodeData != null)
_nodeData.IsEliminated = eliminated;
}
public void SetSelected(bool selected)
{
_isSelected = selected;
if (_selectionOutline != null)
{
_selectionOutline.enabled = selected;
}
}
public void SetHovered(bool hovered)
{
if (_selectionOutline != null)
{
_selectionOutline.enabled = hovered || _isSelected;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a398093f79b17c04e81fe88b509b1319