Initial commit: Tiny Tackle Heroes Unity project
This commit is contained in:
@@ -0,0 +1,627 @@
|
||||
using System.Collections.Generic;
|
||||
using BumpCombat.Audio;
|
||||
using BumpCombat.Combat;
|
||||
using BumpCombat.Core;
|
||||
using BumpCombat.Player;
|
||||
using BumpCombat.Spawning;
|
||||
using BumpCombat.UI;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace BumpCombat.Progression
|
||||
{
|
||||
public sealed class ArtifactRewardController : MonoBehaviour
|
||||
{
|
||||
private enum RewardSource
|
||||
{
|
||||
Elite,
|
||||
EliteRed,
|
||||
EliteBlue,
|
||||
MidBoss,
|
||||
}
|
||||
|
||||
private const int RewardOptionCount = 2;
|
||||
private const int DebugOptionCount = 6;
|
||||
private const int StartupColorCount = 3;
|
||||
private const float OptionCardWidth = 380f;
|
||||
private const float OptionCardHeight = 128f;
|
||||
private const float OptionLabelHeight = 64f;
|
||||
|
||||
private readonly Queue<RewardSource> pendingRewards = new();
|
||||
private readonly HashSet<RewardSource> queuedSources = new();
|
||||
private readonly ActiveArtifactDefinition[] shownArtifacts =
|
||||
new ActiveArtifactDefinition[DebugOptionCount];
|
||||
private ActiveArtifactController artifacts;
|
||||
private SpawnDirector spawnDirector;
|
||||
private GameObject panel;
|
||||
private Text titleText;
|
||||
private Text[] optionTexts;
|
||||
private Image[] optionCardImages = System.Array.Empty<Image>();
|
||||
private Image[] optionIconImages = System.Array.Empty<Image>();
|
||||
private int shownCount;
|
||||
private int selectedIndex;
|
||||
private bool debugStartupSelection;
|
||||
private int startupSelectionCount;
|
||||
private bool debugStartupSelectionPending;
|
||||
private int debugStartupSelectionEarliestFrame;
|
||||
private int debugSelectionsRequired;
|
||||
private int debugSelectionsRemaining;
|
||||
private int ignoreInputThroughFrame;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
public static bool GrantCatalogForTests { get; set; }
|
||||
#endif
|
||||
|
||||
private void Start()
|
||||
{
|
||||
artifacts = FindAnyObjectByType<ActiveArtifactController>();
|
||||
spawnDirector = FindAnyObjectByType<SpawnDirector>();
|
||||
if (artifacts == null || spawnDirector == null)
|
||||
{
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
CreatePanel();
|
||||
spawnDirector.OnEventEnemyDefeated += HandleEventEnemyDefeated;
|
||||
if (RunManager.Instance != null)
|
||||
{
|
||||
RunManager.Instance.OnSelectionChanged += HandleSelectionChanged;
|
||||
RunManager.Instance.OnRunStarted += HandleRunStarted;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
if (GrantCatalogForTests)
|
||||
{
|
||||
artifacts.DebugGrantCatalogArtifacts();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
QueueStartupSelection(
|
||||
RunManager.Instance?.StartupArtifactSelectionCount ?? 3);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (spawnDirector != null)
|
||||
{
|
||||
spawnDirector.OnEventEnemyDefeated -= HandleEventEnemyDefeated;
|
||||
}
|
||||
if (RunManager.Instance != null)
|
||||
{
|
||||
RunManager.Instance.OnSelectionChanged -= HandleSelectionChanged;
|
||||
RunManager.Instance.OnRunStarted -= HandleRunStarted;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (debugStartupSelectionPending
|
||||
&& Time.frameCount >= debugStartupSelectionEarliestFrame
|
||||
&& RunManager.Instance != null
|
||||
&& !RunManager.Instance.IsTitleScreen
|
||||
&& !RunManager.Instance.IsSelectionOpen
|
||||
&& !RunManager.Instance.IsPaused
|
||||
&& !RunManager.Instance.IsGameOver)
|
||||
{
|
||||
if (OpenStartupSelection())
|
||||
{
|
||||
debugStartupSelectionPending = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (panel == null
|
||||
|| !panel.activeSelf
|
||||
|| shownCount == 0
|
||||
|| Time.frameCount <= ignoreInputThroughFrame
|
||||
|| Keyboard.current == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Keyboard.current.leftArrowKey.wasPressedThisFrame
|
||||
|| Keyboard.current.upArrowKey.wasPressedThisFrame)
|
||||
{
|
||||
selectedIndex = (selectedIndex + shownCount - 1) % shownCount;
|
||||
BumpCombatAudioService.Instance?.PlayUi("ui_move");
|
||||
UpdateSelectionVisual();
|
||||
}
|
||||
else if (Keyboard.current.rightArrowKey.wasPressedThisFrame
|
||||
|| Keyboard.current.downArrowKey.wasPressedThisFrame)
|
||||
{
|
||||
selectedIndex = (selectedIndex + 1) % shownCount;
|
||||
BumpCombatAudioService.Instance?.PlayUi("ui_move");
|
||||
UpdateSelectionVisual();
|
||||
}
|
||||
else if (Keyboard.current.spaceKey.wasPressedThisFrame)
|
||||
{
|
||||
ChooseOption(selectedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRunStarted()
|
||||
{
|
||||
QueueStartupSelection(
|
||||
RunManager.Instance?.StartupArtifactSelectionCount ?? 3);
|
||||
}
|
||||
|
||||
private void QueueStartupSelection(int selectionCount)
|
||||
{
|
||||
startupSelectionCount = Mathf.Max(0, selectionCount);
|
||||
debugStartupSelectionPending = true;
|
||||
debugStartupSelectionEarliestFrame = Time.frameCount + 1;
|
||||
}
|
||||
|
||||
private bool ChooseOption(int optionIndex)
|
||||
{
|
||||
if (panel == null
|
||||
|| !panel.activeSelf
|
||||
|| optionIndex < 0
|
||||
|| optionIndex >= shownCount
|
||||
|| !artifacts.TryAddArtifact(shownArtifacts[optionIndex]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
BumpCombatAudioService.Instance?.PlayUi("ui_confirm");
|
||||
if (artifacts.OwnedArtifactCount == 1)
|
||||
{
|
||||
GetComponent<RunTutorialController>()?.NotifyArtifactAcquired();
|
||||
}
|
||||
|
||||
if (debugStartupSelection)
|
||||
{
|
||||
debugSelectionsRemaining--;
|
||||
if (debugSelectionsRemaining > 0)
|
||||
{
|
||||
// Startup selection is deliberately color ordered. Each
|
||||
// step exposes only the two definitions in the next color
|
||||
// group, so a failed duplicate-color click cannot consume
|
||||
// a slot or leave an invalid loadout.
|
||||
if (!PopulateStartupOptions())
|
||||
{
|
||||
debugStartupSelection = false;
|
||||
CloseSelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
selectedIndex = 0;
|
||||
UpdateDebugTitle();
|
||||
ShowOptions();
|
||||
return true;
|
||||
}
|
||||
|
||||
debugStartupSelection = false;
|
||||
}
|
||||
|
||||
CloseSelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool OpenStartupSelection()
|
||||
{
|
||||
if (RunManager.Instance == null
|
||||
|| !RunManager.Instance.TryOpenSelection(this))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
debugSelectionsRequired = Mathf.Min(
|
||||
startupSelectionCount,
|
||||
artifacts.MaxOwnedArtifacts - artifacts.OwnedArtifactCount);
|
||||
if (debugSelectionsRequired <= 0)
|
||||
{
|
||||
RunManager.Instance.CloseSelection(this);
|
||||
return true;
|
||||
}
|
||||
|
||||
debugStartupSelection = true;
|
||||
debugSelectionsRemaining = debugSelectionsRequired;
|
||||
if (!PopulateStartupOptions())
|
||||
{
|
||||
debugStartupSelection = false;
|
||||
RunManager.Instance.CloseSelection(this);
|
||||
return true;
|
||||
}
|
||||
|
||||
selectedIndex = 0;
|
||||
ShowOptions();
|
||||
UpdateDebugTitle();
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool PopulateStartupOptions()
|
||||
{
|
||||
shownCount = 0;
|
||||
int selectedCount = debugSelectionsRequired - debugSelectionsRemaining;
|
||||
if (selectedCount < 0 || selectedCount >= StartupColorCount)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ArtifactColor requiredColor = GetStartupColor(selectedCount);
|
||||
for (int i = 0;
|
||||
i < artifacts.CatalogCount && shownCount < DebugOptionCount;
|
||||
i++)
|
||||
{
|
||||
ActiveArtifactDefinition definition =
|
||||
artifacts.GetCatalogArtifactAt(i);
|
||||
if (definition == null
|
||||
|| definition.ArtifactColor != requiredColor
|
||||
|| artifacts.OwnsArtifact(definition))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
shownArtifacts[shownCount++] = definition;
|
||||
}
|
||||
|
||||
return shownCount > 0;
|
||||
}
|
||||
|
||||
private static ArtifactColor GetStartupColor(int selectionIndex)
|
||||
{
|
||||
return selectionIndex switch
|
||||
{
|
||||
0 => ArtifactColor.Green,
|
||||
1 => ArtifactColor.Red,
|
||||
_ => ArtifactColor.Blue,
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetArtifactColorName(ArtifactColor color)
|
||||
{
|
||||
return color switch
|
||||
{
|
||||
ArtifactColor.Green => "GREEN",
|
||||
ArtifactColor.Red => "RED",
|
||||
_ => "BLUE",
|
||||
};
|
||||
}
|
||||
|
||||
private void HandleEventEnemyDefeated(RunTimedEvent timedEvent)
|
||||
{
|
||||
if (timedEvent == RunTimedEvent.Elite)
|
||||
{
|
||||
if (RunManager.Instance?.IsProductionRun == true)
|
||||
{
|
||||
int spawnedEliteCount =
|
||||
spawnDirector?.TimedEliteSpawnCount ?? 0;
|
||||
if (spawnedEliteCount == 2)
|
||||
{
|
||||
QueueReward(RewardSource.EliteRed);
|
||||
}
|
||||
else if (spawnedEliteCount == 3)
|
||||
{
|
||||
QueueReward(RewardSource.EliteBlue);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
QueueReward(RewardSource.Elite);
|
||||
}
|
||||
}
|
||||
else if (timedEvent == RunTimedEvent.MidBoss
|
||||
&& RunManager.Instance?.IsProductionRun != true)
|
||||
{
|
||||
QueueReward(RewardSource.MidBoss);
|
||||
}
|
||||
}
|
||||
|
||||
private void QueueReward(RewardSource source)
|
||||
{
|
||||
if (artifacts == null
|
||||
|| artifacts.OwnedArtifactCount >= artifacts.MaxOwnedArtifacts)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!queuedSources.Add(source))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRewards.Enqueue(source);
|
||||
TryOpenPendingReward();
|
||||
}
|
||||
|
||||
private void TryOpenPendingReward()
|
||||
{
|
||||
if (pendingRewards.Count == 0 || artifacts == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (artifacts.OwnedArtifactCount >= artifacts.MaxOwnedArtifacts)
|
||||
{
|
||||
pendingRewards.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (RunManager.Instance == null
|
||||
|| !RunManager.Instance.TryOpenSelection(this))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RewardSource source = pendingRewards.Peek();
|
||||
bool hasRequiredColor = TryGetRewardColor(
|
||||
source,
|
||||
out ArtifactColor requiredColor);
|
||||
List<ActiveArtifactDefinition> candidates = new();
|
||||
for (int i = 0; i < artifacts.CatalogCount; i++)
|
||||
{
|
||||
ActiveArtifactDefinition definition =
|
||||
artifacts.GetCatalogArtifactAt(i);
|
||||
if (definition != null
|
||||
&& !artifacts.OwnsArtifact(definition)
|
||||
&& (!hasRequiredColor
|
||||
|| definition.ArtifactColor == requiredColor))
|
||||
{
|
||||
candidates.Add(definition);
|
||||
}
|
||||
}
|
||||
|
||||
shownCount = Mathf.Min(RewardOptionCount, candidates.Count);
|
||||
if (shownCount == 0)
|
||||
{
|
||||
pendingRewards.Clear();
|
||||
RunManager.Instance.CloseSelection(this);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRewards.Dequeue();
|
||||
debugStartupSelection = false;
|
||||
for (int i = 0; i < shownCount; i++)
|
||||
{
|
||||
int choiceIndex = Random.Range(0, candidates.Count);
|
||||
shownArtifacts[i] = candidates[choiceIndex];
|
||||
candidates.RemoveAt(choiceIndex);
|
||||
}
|
||||
|
||||
titleText.text = source switch
|
||||
{
|
||||
RewardSource.Elite => "ELITE REWARD - CHOOSE ARTIFACT",
|
||||
RewardSource.EliteRed => "ELITE REWARD - CHOOSE RED ARTIFACT",
|
||||
RewardSource.EliteBlue => "ELITE REWARD - CHOOSE BLUE ARTIFACT",
|
||||
RewardSource.MidBoss => "MID BOSS REWARD - CHOOSE ARTIFACT",
|
||||
_ => "CHOOSE ARTIFACT",
|
||||
};
|
||||
selectedIndex = 0;
|
||||
ShowOptions();
|
||||
}
|
||||
|
||||
private void HandleSelectionChanged(bool isOpen)
|
||||
{
|
||||
if (!isOpen && (panel == null || !panel.activeSelf))
|
||||
{
|
||||
TryOpenPendingReward();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateSelectionVisual()
|
||||
{
|
||||
for (int i = 0; i < optionTexts.Length; i++)
|
||||
{
|
||||
bool ownedDebugOption = debugStartupSelection
|
||||
&& i < shownCount
|
||||
&& artifacts.OwnsArtifact(shownArtifacts[i]);
|
||||
bool selected = i == selectedIndex && i < shownCount;
|
||||
optionTexts[i].color = ownedDebugOption
|
||||
? new Color(0.35f, 1f, 0.55f)
|
||||
: selected
|
||||
? new Color(1f, 0.85f, 0.2f)
|
||||
: Color.white;
|
||||
if (i < optionCardImages.Length)
|
||||
{
|
||||
PresentationUiStyle.ApplyCard(
|
||||
optionCardImages[i],
|
||||
selected || ownedDebugOption);
|
||||
optionCardImages[i].gameObject.SetActive(
|
||||
optionTexts[i].gameObject.activeSelf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowOptions()
|
||||
{
|
||||
RefreshOptionTexts();
|
||||
LayoutOptions();
|
||||
panel.SetActive(true);
|
||||
ignoreInputThroughFrame = Time.frameCount;
|
||||
UpdateSelectionVisual();
|
||||
}
|
||||
|
||||
private void RefreshOptionTexts()
|
||||
{
|
||||
for (int i = 0; i < optionTexts.Length; i++)
|
||||
{
|
||||
bool visible = i < shownCount;
|
||||
optionTexts[i].gameObject.SetActive(visible);
|
||||
if (!visible)
|
||||
{
|
||||
if (i < optionIconImages.Length)
|
||||
{
|
||||
optionIconImages[i].gameObject.SetActive(false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
ActiveArtifactDefinition definition = shownArtifacts[i];
|
||||
Sprite icon = PresentationUiStyle.GetArtifactIcon(definition);
|
||||
optionIconImages[i].sprite = icon;
|
||||
optionIconImages[i].enabled = icon != null;
|
||||
optionIconImages[i].gameObject.SetActive(icon != null);
|
||||
optionTexts[i].text = icon != null
|
||||
? definition.DisplayName
|
||||
: $"{definition.PlaceholderSymbol}\n{definition.DisplayName}";
|
||||
if (debugStartupSelection && artifacts.OwnsArtifact(definition))
|
||||
{
|
||||
optionTexts[i].text += "\nSELECTED";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LayoutOptions()
|
||||
{
|
||||
bool twoRows = shownCount > RewardOptionCount;
|
||||
for (int i = 0; i < shownCount; i++)
|
||||
{
|
||||
int row = twoRows ? i / RewardOptionCount : 0;
|
||||
int column = twoRows ? i % RewardOptionCount : i;
|
||||
int rowStart = row * RewardOptionCount;
|
||||
int rowCount = Mathf.Min(
|
||||
RewardOptionCount,
|
||||
shownCount - rowStart);
|
||||
Vector2 cardPosition = new Vector2(
|
||||
(column - (rowCount - 1) * 0.5f) * 430f,
|
||||
twoRows ? 90f - row * 190f : 0f);
|
||||
if (i < optionCardImages.Length)
|
||||
{
|
||||
optionCardImages[i].rectTransform.anchoredPosition =
|
||||
cardPosition;
|
||||
}
|
||||
optionTexts[i].rectTransform.anchoredPosition =
|
||||
cardPosition + Vector2.down * 25f;
|
||||
if (i < optionIconImages.Length)
|
||||
{
|
||||
optionIconImages[i].rectTransform.anchoredPosition =
|
||||
cardPosition + Vector2.up * 20f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateDebugTitle()
|
||||
{
|
||||
int selectedCount =
|
||||
debugSelectionsRequired - debugSelectionsRemaining;
|
||||
if (startupSelectionCount <= 1)
|
||||
{
|
||||
titleText.text = "CHOOSE GREEN ARTIFACT "
|
||||
+ $"({selectedCount}/{debugSelectionsRequired})";
|
||||
return;
|
||||
}
|
||||
|
||||
ArtifactColor requiredColor = GetStartupColor(
|
||||
Mathf.Clamp(selectedCount, 0, StartupColorCount - 1));
|
||||
titleText.text =
|
||||
$"CHOOSE {GetArtifactColorName(requiredColor)} ARTIFACT "
|
||||
+ $"({selectedCount}/{debugSelectionsRequired})";
|
||||
}
|
||||
|
||||
private static bool TryGetRewardColor(
|
||||
RewardSource source,
|
||||
out ArtifactColor color)
|
||||
{
|
||||
switch (source)
|
||||
{
|
||||
case RewardSource.EliteRed:
|
||||
color = ArtifactColor.Red;
|
||||
return true;
|
||||
case RewardSource.EliteBlue:
|
||||
color = ArtifactColor.Blue;
|
||||
return true;
|
||||
default:
|
||||
color = ArtifactColor.Green;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseSelection()
|
||||
{
|
||||
panel.SetActive(false);
|
||||
RunManager.Instance?.CloseSelection(this);
|
||||
}
|
||||
|
||||
private void CreatePanel()
|
||||
{
|
||||
Font font = PresentationUiStyle.GetGalmuriFont();
|
||||
panel = PresentationUiStyle.CreatePanel(
|
||||
transform,
|
||||
"Artifact Reward Selection",
|
||||
new Vector2(0.5f, 0.5f),
|
||||
Vector2.zero,
|
||||
new Vector2(1500f, 650f),
|
||||
new Color(0.03f, 0.04f, 0.08f, 0.96f));
|
||||
PresentationUiStyle.ApplyPanel(panel.GetComponent<Image>());
|
||||
|
||||
titleText = PresentationUiStyle.CreateText(
|
||||
panel.transform,
|
||||
"Title",
|
||||
font,
|
||||
38,
|
||||
TextAnchor.MiddleCenter,
|
||||
new Vector2(0f, 255f),
|
||||
new Vector2(1200f, 60f));
|
||||
optionTexts = new Text[DebugOptionCount];
|
||||
optionCardImages = new Image[DebugOptionCount];
|
||||
optionIconImages = new Image[DebugOptionCount];
|
||||
for (int i = 0; i < optionTexts.Length; i++)
|
||||
{
|
||||
optionTexts[i] = PresentationUiStyle.CreateText(
|
||||
panel.transform,
|
||||
$"Option {i + 1}",
|
||||
font,
|
||||
26,
|
||||
TextAnchor.MiddleCenter,
|
||||
Vector2.zero,
|
||||
new Vector2(OptionCardWidth, OptionLabelHeight));
|
||||
optionCardImages[i] = PresentationUiStyle.CreateCardBackdrop(
|
||||
optionTexts[i],
|
||||
$"{optionTexts[i].name} Card",
|
||||
new Vector2(OptionCardWidth, OptionCardHeight));
|
||||
RectTransform optionRect = optionTexts[i].rectTransform;
|
||||
optionIconImages[i] = PresentationUiStyle.CreateImage(
|
||||
optionTexts[i].transform.parent,
|
||||
$"{optionTexts[i].name} Icon",
|
||||
Vector2.zero,
|
||||
new Vector2(48f, 48f));
|
||||
optionIconImages[i].rectTransform.anchorMin = optionRect.anchorMin;
|
||||
optionIconImages[i].rectTransform.anchorMax = optionRect.anchorMax;
|
||||
optionIconImages[i].rectTransform.pivot = optionRect.pivot;
|
||||
optionIconImages[i].rectTransform.anchoredPosition =
|
||||
optionRect.anchoredPosition + Vector2.up * 20f;
|
||||
optionIconImages[i].transform.SetSiblingIndex(
|
||||
optionTexts[i].transform.GetSiblingIndex());
|
||||
optionIconImages[i].enabled = false;
|
||||
}
|
||||
|
||||
Text help = PresentationUiStyle.CreateText(
|
||||
panel.transform,
|
||||
"Help",
|
||||
font,
|
||||
22,
|
||||
TextAnchor.MiddleCenter,
|
||||
new Vector2(0f, -275f),
|
||||
new Vector2(900f, 40f));
|
||||
help.text = "Arrow keys: select Space: confirm";
|
||||
PresentationUiStyle.ApplyText(help, 22);
|
||||
panel.SetActive(false);
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
public int DebugShownCount => shownCount;
|
||||
public bool DebugIsSelectionVisible => panel != null && panel.activeSelf;
|
||||
|
||||
public ActiveArtifactDefinition DebugGetShownArtifact(int index)
|
||||
{
|
||||
return index >= 0 && index < shownCount
|
||||
? shownArtifacts[index]
|
||||
: null;
|
||||
}
|
||||
|
||||
public bool DebugChooseOption(int optionIndex)
|
||||
{
|
||||
return ChooseOption(optionIndex);
|
||||
}
|
||||
|
||||
public void DebugQueueRedArtifactReward()
|
||||
{
|
||||
QueueReward(RewardSource.EliteRed);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user