AI human mode

This commit is contained in:
Michal Vrba
2026-09-03 21:11:25 +02:00
parent 035da18584
commit 9005b4b9be
8 changed files with 1220 additions and 247 deletions
+310 -37
View File
@@ -1,4 +1,5 @@
@page "/"
@using Faraon.Core.AI
@inject FxService Fx
@inject IFaraonAI AI
@inject IJSRuntime JS
@@ -24,30 +25,46 @@ else
</span>
}
</div>
<div class="turn-header-info">
Ťah #@Engine.State.TurnCount &bull; Na rade:
<span class="active-turn-header-player">
@Engine.State.CurrentPlayer.Avatar @Engine.State.CurrentPlayer.Name
</span>
</div>
<div class="game-controls">
<!-- Rýchly prepínač rýchlosti hry (5 úrovní) -->
<button class="btn-game-icon btn-speed-control" title="Rýchlosť hry: @GetSpeedDescription(Engine.State.Rules.SpeedLevel) (Klikni pre zmenu)" @onclick="CycleSpeed">
<div style="display: flex; align-items: center; gap: 12px;">
<!-- Rýchlosť hry -->
<button class="btn-speed-control" @onclick="CycleSpeed" title="Zmeniť rýchlosť hry">
@GetSpeedButtonText(Engine.State.Rules.SpeedLevel)
</button>
<!-- Rýchly prepínač balíčka kariet -->
<button class="btn-game-icon" title="Zmeniť balíček kariet (@Engine.State.Rules.SelectedDeck)" @onclick="ToggleDeck">
@GetDeckIcon(Engine.State.Rules.SelectedDeck)
</button>
<button class="btn-game-icon" title="Stlmiť / Zapnúť zvuk" @onclick="ToggleMute">
<!-- Zvuk -->
<button class="btn-header-action" @onclick="ToggleMute" title="@(IsMuted ? "Zapnúť zvuk" : "Stlmiť zvuk")">
@(IsMuted ? "🔇" : "🔊")
</button>
<button class="btn-game-icon" title="Návrat do Lobby" @onclick="ReturnToLobby">
🏠
<!-- Nová hra -->
<button class="btn-header-action" @onclick="ReturnToLobby" title="Späť do menu">
⚙️ Menu
</button>
</div>
</header>
<!-- Špeciálny banner kartového vtipu (AI Human Mode) -->
@if (CurrentJoke != null)
{
<div class="ai-joke-banner @(CurrentJoke.IsFadingOut ? "fading-out" : "")">
<div class="joke-header">
<div class="joke-header-badge">
<span>@CurrentJoke.Joke.Icon</span>
<span>🎭 @CurrentJoke.Joke.Category</span>
</div>
<div style="display: flex; align-items: center; gap: 10px;">
<span class="joke-storyteller">
<span>@CurrentJoke.Storyteller.Avatar</span>
<span>@CurrentJoke.Storyteller.Name</span>
</span>
<button class="joke-close-btn" @onclick="DismissJoke" title="Zavrieť vtip">✕</button>
</div>
</div>
<div class="joke-setup">@CurrentJoke.Joke.Setup</div>
<div class="joke-punchline">@CurrentJoke.Joke.Punchline</div>
</div>
}
<!-- Herný stôl -->
<div class="poker-table-surface">
<!-- Súperi na stole -->
@@ -55,12 +72,15 @@ else
{
var isOppFrozen = Engine.State.PendingSkip && Engine.State.CurrentPlayer == opp.Player;
var canOppBeRevived = Engine.State.Rules.RedSevenRevives && Engine.State.LastFinishedPlayer == opp.Player && Engine.State.ReviveTurnsRemaining > 0;
var thought = GetThoughtState(opp.Player);
<PlayerSlot Player="@opp.Player"
IsCurrentTurn="@(Engine.State.CurrentPlayer == opp.Player)"
IsFrozen="@isOppFrozen"
CanBeRevived="@canOppBeRevived"
ReviveRoundsRemaining="@Engine.State.ReviveRoundsRemaining"
ReviveTurnsRemaining="@Engine.State.ReviveTurnsRemaining"
ThoughtBubble="@thought?.Text"
IsThoughtFadingOut="@(thought?.IsFadingOut ?? false)"
IsOlympicMode="@Engine.State.Rules.OlympicMode"
PositionClass="@opp.PositionClass" />
}
@@ -193,6 +213,17 @@ else
</span>
</div>
}
@{
var bottomThought = GetThoughtState(ViewingPlayer);
}
@if (bottomThought != null && !string.IsNullOrEmpty(bottomThought.Text))
{
<div class="ai-thought-bubble slot-bottom @(bottomThought.IsFadingOut ? "fading-out" : "")">
<span class="thought-bubble-prefix">💭</span>
<span class="thought-bubble-text">@bottomThought.Text</span>
</div>
}
</div>
<!-- Interaktívna ruka hráča -->
@@ -416,6 +447,34 @@ else
break;
}
if (Engine != null && Engine.State.Rules.AIHumanMode)
{
// Kartový vtip pri významných udalostiach (veľa sediem, spálenie, oživenie)
var joke = AIDialogueService.GetContextualJoke(e.Type, e.ExtraData, Engine.State.TurnCount);
if (joke != null)
{
var storyteller = Engine.State.Players.FirstOrDefault(p => p.Type == PlayerType.AI) ?? e.SourcePlayer ?? Engine.State.CurrentPlayer;
TriggerCardJoke(joke, storyteller);
}
if (e.SourcePlayer != null && e.SourcePlayer.Type == PlayerType.AI)
{
var reaction = AIDialogueService.GetEventReaction(e, e.SourcePlayer);
if (!string.IsNullOrEmpty(reaction))
{
SetThoughtBubble(e.SourcePlayer, reaction, 2800);
}
}
if (e.TargetPlayer != null && e.TargetPlayer.Type == PlayerType.AI && e.TargetPlayer != e.SourcePlayer)
{
var reaction = AIDialogueService.GetEventReaction(e, e.TargetPlayer);
if (!string.IsNullOrEmpty(reaction))
{
SetThoughtBubble(e.TargetPlayer, reaction, 2800);
}
}
}
if (Engine != null && Engine.State.CurrentPlayer.Hand.Count == 1 && !Engine.State.CurrentPlayer.HasFinished)
{
await Fx.PlayHeartbeatAsync();
@@ -457,17 +516,25 @@ else
private int MultiCardDelayMs => Engine?.State.Rules.SpeedLevel switch
{
1 => 130,
2 => 105,
4 => 50,
5 => 25,
_ => 80
-5 => 350,
-4 => 280,
-3 => 220,
-2 => 180,
-1 => 140,
0 => 110,
1 => 85,
2 => 65,
3 => 45,
4 => 30,
5 => 15,
_ => 110
};
private async Task CycleSpeed()
{
if (Engine == null) return;
int next = (Engine.State.Rules.SpeedLevel % 5) + 1;
int current = Engine.State.Rules.SpeedLevel;
int next = current >= 5 ? -5 : current + 1;
Engine.State.Rules.SpeedLevel = next;
await Fx.SetSpeedLevelAsync(next);
StateHasChanged();
@@ -475,22 +542,34 @@ else
private string GetSpeedButtonText(int level) => level switch
{
1 => "🐢 0.5x",
2 => "🚶 0.75x",
3 => "⚡ 1x",
4 => "🔥 1.5x",
5 => "🚀 2.5x",
_ => "⚡ 1x"
-5 => "🐢 -5 (0.2x)",
-4 => "🐢 -4 (0.35x)",
-3 => "🚶 -3 (0.5x)",
-2 => "🚶 -2 (0.65x)",
-1 => "🍃 -1 (0.8x)",
0 => "⚡ 0 (1x)",
1 => "🔥 +1 (1.25x)",
2 => "🔥 +2 (1.5x)",
3 => "🚀 +3 (2x)",
4 => "🚀 +4 (2.75x)",
5 => "⚡ +5 (4x)",
_ => "⚡ 0 (1x)"
};
private string GetSpeedDescription(int level) => level switch
{
1 => "0.5x (Pomalá)",
2 => "0.75x (Pokojná)",
3 => "1x (Normálna - stredná)",
4 => "1.5x (Rýchla)",
5 => "2.5x (Turbo / Blesková)",
_ => "1x (Normálna)"
-5 => "-5 (0.2x Zen / Extra pomalá)",
-4 => "-4 (0.35x Veľmi pomalá)",
-3 => "-3 (0.5x Pomalá)",
-2 => "-2 (0.65x Pokojná)",
-1 => "-1 (0.8x Zvoľnená)",
0 => "0 (1x Normálna / Vyvážená)",
1 => "+1 (1.25x Svižná)",
2 => "+2 (1.5x Rýchla)",
3 => "+3 (2x Veľmi rýchla)",
4 => "+4 (2.75x Turbo)",
5 => "+5 (4x Blesková)",
_ => "0 (1x Normálna)"
};
private void ToggleDeck()
@@ -647,6 +726,15 @@ else
if (Engine.State.TurnState == TurnState.Playing && Engine.State.CurrentPlayer.Type == PlayerType.AI)
{
if (Engine.State.Rules.AIHumanMode && Engine.State.TurnCount >= 28 && Engine.State.TurnCount % 16 == 0 && CurrentJoke == null)
{
var longJoke = AIDialogueService.GetContextualJoke(null, 0, Engine.State.TurnCount);
if (longJoke != null)
{
TriggerCardJoke(longJoke, Engine.State.CurrentPlayer);
}
}
_aiCts?.Cancel();
_aiCts = new System.Threading.CancellationTokenSource();
var token = _aiCts.Token;
@@ -655,12 +743,63 @@ else
{
try
{
await Task.Delay(Engine.State.Rules.AIDelayMs, token);
if (token.IsCancellationRequested || Engine == null) return;
var aiPlayer = Engine.State.CurrentPlayer;
if (aiPlayer.Type != PlayerType.AI) return;
if (Engine.State.Rules.AIHumanMode)
{
string thought = AIDialogueService.GetThinkingDialogue(aiPlayer, Engine.State);
int charDelay = Engine.State.Rules.SpeedLevel switch
{
-5 => 55,
-4 => 45,
-3 => 38,
-2 => 30,
-1 => 24,
0 => 18,
1 => 14,
2 => 10,
3 => 7,
4 => 4,
5 => 2,
_ => 18
};
// Postupné písanie myšlienky po písmenkách (Typewriter effect)
for (int i = 1; i <= thought.Length; i++)
{
if (token.IsCancellationRequested || Engine == null) return;
string partial = thought.Substring(0, i) + (i < thought.Length ? " ▌" : "");
await InvokeAsync(() => SetThoughtBubble(aiPlayer, partial, 5000));
await Task.Delay(charDelay, token);
}
// Dokončený text a krátka prirodzená pauza na prečítanie
await InvokeAsync(() => SetThoughtBubble(aiPlayer, thought, 4000));
int readPause = Engine.State.Rules.SpeedLevel switch
{
-5 => 1200,
-4 => 1000,
-3 => 800,
-2 => 650,
-1 => 500,
0 => 400,
1 => 300,
2 => 220,
3 => 150,
4 => 90,
5 => 40,
_ => 400
};
await Task.Delay(readPause, token);
}
else
{
await Task.Delay(Engine.State.Rules.AIDelayMs, token);
}
if (token.IsCancellationRequested || Engine == null) return;
var decision = AI.MakeDecision(Engine.State, aiPlayer);
await InvokeAsync(async () =>
@@ -719,6 +858,12 @@ else
}
else
{
if (Engine.State.Rules.AIHumanMode && Engine.State.PendingDrawCount == 0 && !Engine.State.PendingSkip)
{
var drawThought = AIDialogueService.GetMustDrawDialogue(aiPlayer);
SetThoughtBubble(aiPlayer, drawThought, 1800);
}
if (Engine.State.PendingDrawCount > 0)
{
int drawCount = Engine.State.PendingDrawCount;
@@ -752,6 +897,71 @@ else
}
}
private class PlayerThoughtInfo
{
public string Text { get; set; } = "";
public DateTime FadeOutAt { get; set; }
public DateTime Expiry { get; set; }
public bool IsFadingOut => DateTime.UtcNow >= FadeOutAt && DateTime.UtcNow <= Expiry;
public bool IsExpired => DateTime.UtcNow > Expiry;
}
private readonly Dictionary<Player, PlayerThoughtInfo> PlayerThoughts = new();
private PlayerThoughtInfo? GetThoughtState(Player? player)
{
if (player == null || Engine == null || !Engine.State.Rules.AIHumanMode) return null;
if (PlayerThoughts.TryGetValue(player, out var info))
{
if (!info.IsExpired)
{
return info;
}
}
return null;
}
private void SetThoughtBubble(Player player, string text, int durationMs = 3200, int fadeDurationMs = 600)
{
if (string.IsNullOrEmpty(text)) return;
var now = DateTime.UtcNow;
var fadeAt = now.AddMilliseconds(Math.Max(200, durationMs - fadeDurationMs));
var expireAt = now.AddMilliseconds(durationMs);
var info = new PlayerThoughtInfo
{
Text = text,
FadeOutAt = fadeAt,
Expiry = expireAt
};
PlayerThoughts[player] = info;
StateHasChanged();
_ = Task.Run(async () =>
{
int delayToFade = (int)(fadeAt - DateTime.UtcNow).TotalMilliseconds;
if (delayToFade > 0)
{
await Task.Delay(delayToFade);
if (PlayerThoughts.TryGetValue(player, out var cur) && cur == info)
{
await InvokeAsync(StateHasChanged);
}
}
int delayToExpire = (int)(expireAt - DateTime.UtcNow).TotalMilliseconds;
if (delayToExpire > 0)
{
await Task.Delay(delayToExpire);
if (PlayerThoughts.TryGetValue(player, out var cur) && cur == info)
{
PlayerThoughts.Remove(player);
await InvokeAsync(StateHasChanged);
}
}
});
}
private IEnumerable<(Player Player, string PositionClass)> GetOpponents()
{
if (Engine == null) return Enumerable.Empty<(Player, string)>();
@@ -778,6 +988,65 @@ else
return result;
}
private class ActiveJokeInfo
{
public AIDialogueService.CardJoke Joke { get; set; } = default!;
public Player Storyteller { get; set; } = default!;
public DateTime FadeOutAt { get; set; }
public DateTime Expiry { get; set; }
public bool IsFadingOut => DateTime.UtcNow >= FadeOutAt && DateTime.UtcNow <= Expiry;
public bool IsExpired => DateTime.UtcNow > Expiry;
}
private ActiveJokeInfo? CurrentJoke;
private void TriggerCardJoke(AIDialogueService.CardJoke joke, Player storyteller, int durationMs = 7000, int fadeDurationMs = 700)
{
var now = DateTime.UtcNow;
var fadeAt = now.AddMilliseconds(Math.Max(200, durationMs - fadeDurationMs));
var expireAt = now.AddMilliseconds(durationMs);
var info = new ActiveJokeInfo
{
Joke = joke,
Storyteller = storyteller,
FadeOutAt = fadeAt,
Expiry = expireAt
};
CurrentJoke = info;
StateHasChanged();
_ = Task.Run(async () =>
{
int delayToFade = (int)(fadeAt - DateTime.UtcNow).TotalMilliseconds;
if (delayToFade > 0)
{
await Task.Delay(delayToFade);
if (CurrentJoke == info)
{
await InvokeAsync(StateHasChanged);
}
}
int delayToExpire = (int)(expireAt - DateTime.UtcNow).TotalMilliseconds;
if (delayToExpire > 0)
{
await Task.Delay(delayToExpire);
if (CurrentJoke == info)
{
CurrentJoke = null;
await InvokeAsync(StateHasChanged);
}
}
});
}
private void DismissJoke()
{
CurrentJoke = null;
StateHasChanged();
}
private async Task ToggleMute()
{
IsMuted = await Fx.ToggleMuteAsync();
@@ -785,12 +1054,16 @@ else
private void HandlePlayAgain()
{
PlayerThoughts.Clear();
CurrentJoke = null;
StartNewGame();
}
private void ReturnToLobby()
{
_aiCts?.Cancel();
PlayerThoughts.Clear();
CurrentJoke = null;
IsInLobby = true;
Engine = null;
}