From d4e0071f956bf39a45e0cd32dcec3f4626f57d78 Mon Sep 17 00:00:00 2001 From: Michal Vrba Date: Fri, 4 Sep 2026 11:31:52 +0200 Subject: [PATCH] improvfmeents --- Faraon.Client/Components/CardView.razor | 2 + Faraon.Client/Components/GameOverModal.razor | 155 ++++++---- Faraon.Client/Components/Lobby.razor | 31 ++ Faraon.Client/Components/PlayerHandView.razor | 1 + Faraon.Client/Pages/Home.razor | 83 ++++- Faraon.Client/Services/FxService.cs | 10 + Faraon.Client/wwwroot/css/app.css | 291 ++++++++++++++++++ Faraon.Client/wwwroot/js/gameFx.js | 189 ++++++++++++ Faraon.Core/Engine/GameEngine.cs | 115 +++++++ Faraon.Core/Models/GameState.cs | 3 + Faraon.Core/Models/GameStats.cs | 70 +++++ Faraon.Core/Models/Player.cs | 5 + Faraon.Core/Models/RuleOptions.cs | 12 + Faraon.Tests/GameEngineTests.cs | 35 +++ 14 files changed, 941 insertions(+), 61 deletions(-) create mode 100644 Faraon.Core/Models/GameStats.cs diff --git a/Faraon.Client/Components/CardView.razor b/Faraon.Client/Components/CardView.razor index 7379c94..0b21c1e 100644 --- a/Faraon.Client/Components/CardView.razor +++ b/Faraon.Client/Components/CardView.razor @@ -2,6 +2,7 @@
@if (IsBack || Card == null) @@ -20,6 +21,7 @@ [Parameter] public bool IsBack { get; set; } = false; [Parameter] public bool IsPlayable { get; set; } = false; [Parameter] public bool IsInteractive { get; set; } = false; + [Parameter] public int? CardIndex { get; set; } [Parameter] public string CustomClass { get; set; } = ""; [Parameter] public string CustomStyle { get; set; } = ""; [Parameter] public EventCallback OnCardClicked { get; set; } diff --git a/Faraon.Client/Components/GameOverModal.razor b/Faraon.Client/Components/GameOverModal.razor index 8a527a4..3f5d2ea 100644 --- a/Faraon.Client/Components/GameOverModal.razor +++ b/Faraon.Client/Components/GameOverModal.razor @@ -1,79 +1,115 @@ @namespace Faraon.Client.Components -
-
-
+
+
+
@if (IsOlympicMode) { 🏆 OLYMPIJSKÝ ŠAMPIÓN 👑 } else { - Výsledky Hry 👑 + 🏆 VÝSLEDKY ZÁPASU 👑 }
- @if (IsOlympicMode && MatchWinner != null) - { -
-
🥇👑
-
- @MatchWinner.Avatar @MatchWinner.Name + + @{ + var sortedPlayers = Players.OrderBy(p => p.IsEliminated ? 1 : 0).ThenBy(p => p.FinishedPosition ?? 99).ToList(); + var firstPlace = MatchWinner ?? sortedPlayers.FirstOrDefault(p => p.FinishedPosition == 1) ?? sortedPlayers.FirstOrDefault(); + var secondPlace = sortedPlayers.Count > 1 ? (sortedPlayers.FirstOrDefault(p => p != firstPlace && p.FinishedPosition == 2) ?? sortedPlayers.Skip(1).FirstOrDefault()) : null; + var thirdPlace = sortedPlayers.Count > 2 ? (sortedPlayers.FirstOrDefault(p => p != firstPlace && p != secondPlace && p.FinishedPosition == 3) ?? sortedPlayers.Skip(2).FirstOrDefault()) : null; + } + +
+ + @if (secondPlace != null) + { +
+
+
@secondPlace.GetLiveAvatar()
+
+
@secondPlace.Name
+
2
-
- Prežil všetky kolá ako jediný hráč a zvíťazil v Olympijskom turnaji! + } + + + @if (firstPlace != null) + { +
+
+
👑
+
@firstPlace.GetLiveAvatar()
+
+
@firstPlace.Name
+
1
+
+ } + + + @if (thirdPlace != null) + { +
+
+
@thirdPlace.GetLiveAvatar()
+
+
@thirdPlace.Name
+
3
+
+ } +
+ + + @if (Stats != null) + { +
+
+ 👑 +
+
MVP Zápasu
+
@Stats.GetMvpPlayerName()
+
+
+ +
+ 🎒 +
+
Najväčší trest
+
+ @(Stats.MaxPenaltyDrawnInOneTurn > 0 ? $"+{Stats.MaxPenaltyDrawnInOneTurn} ({Stats.MaxPenaltyPlayerName})" : "Žiadny") +
+
+
+ +
+ ⏱️ +
+
Čas & Ťahy
+
+ @($"{(int)Stats.Duration.TotalMinutes}m {Stats.Duration.Seconds:D2}s • {Stats.TotalTurns} ťahov") +
+
+
+ +
+ 🔥 +
+
Spálenia & Esá
+
+ @($"{Stats.TotalCardsBurned / 4}x spálenie • {Stats.TotalAcesPlayed} es") +
+
} -
- @foreach (var player in Players.OrderBy(p => p.IsEliminated ? 1 : 0).ThenBy(p => p.FinishedPosition ?? 99)) - { -
-
- @if ((MatchWinner != null && player == MatchWinner) || (!IsOlympicMode && player.FinishedPosition == 1)) { 🥇 } - else if (player.FinishedPosition == 2) { 🥈 } - else if (player.FinishedPosition == 3) { 🥉 } - else { #@(player.FinishedPosition ?? 4) } -
-
- @player.Avatar - @player.Name - @if (player.Type == PlayerType.AI) - { - (AI) - } -
-
- @if (IsOlympicMode) - { - if (player == MatchWinner) - { - Šampión! 🏆 - } - else if (player.IsEliminated) - { - Vyradený ☠️ - } - else - { - @player.OlympicCardsCount kariet - } - } - else - { - @(player.FinishedPosition == 1 ? "Víťaz! 🎉" : $"{player.FinishedPosition}. miesto") - } -
-
- } -
- -
- -
@@ -84,6 +120,7 @@ [Parameter] public List Players { get; set; } = new(); [Parameter] public bool IsOlympicMode { get; set; } = false; [Parameter] public Player? MatchWinner { get; set; } + [Parameter] public GameMatchStats? Stats { get; set; } [Parameter] public EventCallback OnPlayAgain { get; set; } [Parameter] public EventCallback OnBackToLobby { get; set; } } diff --git a/Faraon.Client/Components/Lobby.razor b/Faraon.Client/Components/Lobby.razor index add81c0..81f6112 100644 --- a/Faraon.Client/Components/Lobby.razor +++ b/Faraon.Client/Components/Lobby.razor @@ -217,6 +217,28 @@ }
+ +
+
+ 🌲 + ATMOSFÉRA & PLÁTNO HERNÉHO STOLA +
+
+ @foreach (var t in TableThemes) + { +
+
@t.Icon
+
+
@t.Name
+
@t.Desc
+
+
+ } +
+
+
+
+ +
+ + @if (!IsLogRadarMinimized && Engine.State.MoveLog.Count > 0) + { +
+ @foreach (var log in Engine.State.MoveLog.TakeLast(5).Reverse()) + { +
+ @log.Icon +
+
@log.Text
+ @if (!string.IsNullOrEmpty(log.SubText)) + { +
@log.SubText
+ } +
+
+ } +
+ } +
+ @if (CurrentJoke != null) { @@ -310,6 +342,7 @@ else } @@ -319,6 +352,7 @@ else @code { private bool IsInLobby = true; private bool IsMuted = false; + private bool IsLogRadarMinimized = true; private GameEngine? Engine; private string LastEventMessage = ""; private System.Threading.CancellationTokenSource? _aiCts; @@ -376,7 +410,7 @@ else private void StartNewGame() { // Klony hráčov pre čistú hru - var gamePlayers = ConfiguredPlayers.Select(p => new Player(p.Name, p.Type, p.Avatar, p.Difficulty)).ToList(); + var gamePlayers = ConfiguredPlayers.Select(p => new Player(p.Name, p.Type, p.Avatar, p.Difficulty, p.PersonalityId)).ToList(); Engine = new GameEngine(); Engine.OnGameEvent += HandleGameEvent; @@ -1024,6 +1058,11 @@ else player.CurrentEmotion = emotion; StateHasChanged(); + if (player.Type == PlayerType.AI && !string.IsNullOrEmpty(player.PersonalityId)) + { + _ = Fx.PlayBotSfxAsync(player.PersonalityId, emotion.ToString().ToLowerInvariant()); + } + if (emotion != PlayerEmotion.Neutral && emotion != PlayerEmotion.Celebrating) { _ = Task.Run(async () => @@ -1038,6 +1077,38 @@ else } } + private void CycleTableTheme() + { + if (Engine == null) return; + Engine.State.Rules.TableTheme = Engine.State.Rules.TableTheme switch + { + TableTheme.ClassicGreen => TableTheme.RoyalBurgundy, + TableTheme.RoyalBurgundy => TableTheme.WoodCabin, + TableTheme.WoodCabin => TableTheme.CyberNeon, + TableTheme.CyberNeon => TableTheme.ClassicGreen, + _ => TableTheme.ClassicGreen + }; + StateHasChanged(); + } + + private string GetThemeName(TableTheme theme) => theme switch + { + TableTheme.ClassicGreen => "Zelené plátno", + TableTheme.RoyalBurgundy => "Kráľovský bord", + TableTheme.WoodCabin => "Drevený zrub", + TableTheme.CyberNeon => "Cyberpunk neón", + _ => "Zelené plátno" + }; + + private string GetThemeIcon(TableTheme theme) => theme switch + { + TableTheme.ClassicGreen => "🍀", + TableTheme.RoyalBurgundy => "👑", + TableTheme.WoodCabin => "🪵", + TableTheme.CyberNeon => "⚡", + _ => "🍀" + }; + private IEnumerable<(Player Player, string PositionClass)> GetOpponents() { if (Engine == null) return Enumerable.Empty<(Player, string)>(); @@ -1132,6 +1203,7 @@ else { PlayerThoughts.Clear(); CurrentJoke = null; + IsLogRadarMinimized = true; StartNewGame(); } @@ -1140,7 +1212,14 @@ else _aiCts?.Cancel(); PlayerThoughts.Clear(); CurrentJoke = null; + IsLogRadarMinimized = true; IsInLobby = true; Engine = null; } + + public void Dispose() + { + _aiCts?.Cancel(); + _aiCts?.Dispose(); + } } diff --git a/Faraon.Client/Services/FxService.cs b/Faraon.Client/Services/FxService.cs index 7d23a9d..5e9f764 100644 --- a/Faraon.Client/Services/FxService.cs +++ b/Faraon.Client/Services/FxService.cs @@ -198,6 +198,16 @@ public class FxService try { await _js.InvokeVoidAsync("FaraonFx.triggerDopamineBanner", title, subtitle, type); } catch { } } + public async ValueTask PlayCardWhooshAsync() + { + try { await _js.InvokeVoidAsync("FaraonFx.playCardWhoosh"); } catch { } + } + + public async ValueTask PlayBotSfxAsync(string? personalityId, string? emotion = null) + { + try { await _js.InvokeVoidAsync("FaraonFx.playBotPersonalitySfx", personalityId, emotion); } catch { } + } + public async ValueTask ToggleMuteAsync() { try { return await _js.InvokeAsync("FaraonFx.toggleMute"); } catch { return false; } diff --git a/Faraon.Client/wwwroot/css/app.css b/Faraon.Client/wwwroot/css/app.css index d31b4fd..4c97bfe 100644 --- a/Faraon.Client/wwwroot/css/app.css +++ b/Faraon.Client/wwwroot/css/app.css @@ -2501,4 +2501,295 @@ body, html { @keyframes fadeIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } +} + +/* --- 1. TABLE THEMES --- */ +#game-table-container.theme-classicgreen, +#game-table-container.theme-classic-green { + background: radial-gradient(ellipse at center, #144933 0%, #0b2b1d 60%, #04120c 100%) !important; +} +#game-table-container.theme-classicgreen .poker-table-surface, +#game-table-container.theme-classic-green .poker-table-surface { + border-color: #5a3818 !important; + background: radial-gradient(ellipse at center, #1e5c42 0%, #13422e 65%, #0d2e20 100%) !important; + box-shadow: inset 0 0 60px rgba(0, 0, 0, 0.8), 0 0 30px rgba(0, 0, 0, 0.9) !important; +} + +#game-table-container.theme-royalburgundy, +#game-table-container.theme-royal-burgundy { + background: radial-gradient(ellipse at center, #420d18 0%, #24050b 60%, #0c0103 100%) !important; +} +#game-table-container.theme-royalburgundy .poker-table-surface, +#game-table-container.theme-royal-burgundy .poker-table-surface { + border-color: #8c6227 !important; + background: radial-gradient(ellipse at center, #5c1422 0%, #3d0a14 65%, #200409 100%) !important; + box-shadow: inset 0 0 65px rgba(0, 0, 0, 0.85), 0 0 35px rgba(236, 72, 153, 0.25) !important; +} + +#game-table-container.theme-woodcabin, +#game-table-container.theme-wood-cabin { + background: radial-gradient(ellipse at center, #2e1d10 0%, #1a0f08 60%, #080402 100%) !important; +} +#game-table-container.theme-woodcabin .poker-table-surface, +#game-table-container.theme-wood-cabin .poker-table-surface { + border-color: #3e2723 !important; + background: radial-gradient(ellipse at center, #3d2817 0%, #291a0e 65%, #180f07 100%) !important; + box-shadow: inset 0 0 70px rgba(0, 0, 0, 0.9), 0 0 30px rgba(245, 158, 11, 0.2) !important; +} + +#game-table-container.theme-cyberneon, +#game-table-container.theme-cyber-neon { + background: radial-gradient(ellipse at center, #0a1f30 0%, #050d14 60%, #010408 100%) !important; +} +#game-table-container.theme-cyberneon .poker-table-surface, +#game-table-container.theme-cyber-neon .poker-table-surface { + border-color: #00e5ff !important; + background: radial-gradient(ellipse at center, #0e2a40 0%, #081724 65%, #03080d 100%) !important; + box-shadow: inset 0 0 60px rgba(0, 229, 255, 0.25), 0 0 40px rgba(0, 229, 255, 0.35) !important; +} + +/* --- MOVE HISTORY RADAR (ŽIVÝ ZÁZNAMNÍK ŤAHOV) --- */ +.game-log-radar { + position: absolute; + top: 55px; + left: 14px; + z-index: 80; + max-width: 280px; + width: 260px; + pointer-events: auto; + transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +.game-log-radar.minimized { + width: auto; + max-width: 48px; +} + +.log-radar-toggle-btn { + display: inline-flex; + align-items: center; + gap: 6px; + background: rgba(10, 25, 20, 0.85); + border: 1px solid rgba(245, 176, 65, 0.45); + color: var(--gold); + font-size: 0.76rem; + font-weight: 800; + padding: 4px 10px; + border-radius: 14px; + cursor: pointer; + backdrop-filter: blur(8px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6); + margin-bottom: 6px; + transition: all 0.2s ease; + user-select: none; +} + +.log-radar-toggle-btn:hover { + background: rgba(245, 176, 65, 0.25); + border-color: var(--gold); + transform: scale(1.05); +} + +.log-radar-items-container { + display: flex; + flex-direction: column; + gap: 5px; + background: rgba(8, 16, 12, 0.92); + border: 1px solid rgba(245, 176, 65, 0.3); + border-radius: 14px; + padding: 8px 10px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.85), inset 0 0 15px rgba(245, 176, 65, 0.08); + backdrop-filter: blur(10px); + max-height: 220px; + overflow-y: auto; +} + +.log-radar-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.78rem; + color: #f1f5f9; + padding: 5px 8px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + border-left: 3px solid var(--gold); + animation: radar-item-in 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +@keyframes radar-item-in { + 0% { transform: translateX(-15px); opacity: 0; } + 100% { transform: translateX(0); opacity: 1; } +} + +.log-radar-icon { + font-size: 1rem; + flex-shrink: 0; +} + +.log-radar-text { + flex: 1; + line-height: 1.25; + font-weight: 600; +} + +.log-radar-sub { + font-size: 0.68rem; + opacity: 0.85; + color: var(--gold); +} + +/* --- 4. 3D VICTORY PODIUM & MATCH STATS MODAL --- */ +.match-summary-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.88); + backdrop-filter: blur(14px); + z-index: 500; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + animation: fadeIn 0.35s ease; +} + +.match-summary-card { + background: linear-gradient(135deg, rgba(20, 15, 35, 0.98) 0%, rgba(10, 25, 20, 0.98) 50%, rgba(30, 15, 10, 0.98) 100%); + border: 2px solid #ffd700; + border-radius: 28px; + box-shadow: 0 0 60px rgba(255, 215, 0, 0.45), 0 20px 50px rgba(0, 0, 0, 0.95); + max-width: 650px; + width: 100%; + padding: 24px 28px; + color: #ffffff; + text-align: center; + position: relative; + max-height: 90vh; + overflow-y: auto; +} + +.podium-container { + display: flex; + align-items: flex-end; + justify-content: center; + gap: 14px; + margin: 18px 0 22px; + min-height: 180px; +} + +.podium-column { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + max-width: 130px; +} + +.podium-avatar-box { + margin-bottom: 8px; + position: relative; +} + +.podium-avatar { + font-size: 2.8rem; + filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.8)); + animation: podium-bounce 1.5s infinite alternate ease-in-out; +} + +.podium-crown { + position: absolute; + top: -16px; + left: 50%; + transform: translateX(-50%); + font-size: 1.5rem; + animation: float 2s infinite ease-in-out alternate; +} + +.podium-name { + font-size: 0.88rem; + font-weight: 800; + color: #ffffff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; + margin-bottom: 6px; +} + +.podium-step { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + font-family: 'Cinzel', serif; + font-size: 1.8rem; + font-weight: 900; + border-radius: 12px 12px 0 0; + box-shadow: inset 0 2px 6px rgba(255, 255, 255, 0.3), 0 10px 20px rgba(0, 0, 0, 0.6); +} + +.step-gold { + height: 110px; + background: linear-gradient(180deg, #ffd700 0%, #b8860b 100%); + color: #1a1a1a; + border: 2px solid #fff; + box-shadow: 0 0 25px rgba(255, 215, 0, 0.6); +} + +.step-silver { + height: 80px; + background: linear-gradient(180deg, #e2e8f0 0%, #94a3b8 100%); + color: #0f172a; + border: 2px solid #f8fafc; +} + +.step-bronze { + height: 60px; + background: linear-gradient(180deg, #cd7f32 0%, #8b4513 100%); + color: #ffffff; + border: 2px solid #d97706; +} + +@keyframes podium-bounce { + 0% { transform: translateY(0); } + 100% { transform: translateY(-8px); } +} + +.match-stats-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + margin-bottom: 22px; +} + +.stat-badge-box { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(245, 176, 65, 0.25); + border-radius: 14px; + padding: 10px 14px; + display: flex; + align-items: center; + gap: 10px; + text-align: left; +} + +.stat-badge-icon { + font-size: 1.6rem; +} + +.stat-badge-title { + font-size: 0.72rem; + color: #94a3b8; + text-transform: uppercase; + font-weight: 700; + letter-spacing: 0.5px; +} + +.stat-badge-value { + font-size: 0.98rem; + font-weight: 800; + color: var(--gold); } \ No newline at end of file diff --git a/Faraon.Client/wwwroot/js/gameFx.js b/Faraon.Client/wwwroot/js/gameFx.js index 26a6c12..c6fd020 100644 --- a/Faraon.Client/wwwroot/js/gameFx.js +++ b/Faraon.Client/wwwroot/js/gameFx.js @@ -39,6 +39,17 @@ window.FaraonFx = (function () { return audioCtx; } + // Automatické prebudenie AudioContextu pri prvom kliknutí používateľa kdekoľvek + if (typeof window !== 'undefined') { + const unlockAudio = () => { + getAudioContext(); + window.removeEventListener('pointerdown', unlockAudio); + window.removeEventListener('keydown', unlockAudio); + }; + window.addEventListener('pointerdown', unlockAudio, { passive: true }); + window.addEventListener('keydown', unlockAudio, { passive: true }); + } + // --- PROCEDURAL SOUND GENERATORS --- function playCardSnap() { if (isMuted) return; @@ -386,6 +397,182 @@ window.FaraonFx = (function () { }); } + function playCardWhoosh() { + if (isMuted) return; + const ctx = getAudioContext(); + if (!ctx) return; + const now = ctx.currentTime; + const bufferSize = Math.floor(ctx.sampleRate * 0.22); + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) { + data[i] = (Math.random() * 2 - 1) * Math.sin((i / bufferSize) * Math.PI); + } + const noise = ctx.createBufferSource(); + noise.buffer = buffer; + const filter = ctx.createBiquadFilter(); + filter.type = 'bandpass'; + filter.frequency.setValueAtTime(450, now); + filter.frequency.exponentialRampToValueAtTime(1200, now + 0.1); + filter.frequency.exponentialRampToValueAtTime(350, now + 0.22); + filter.Q.value = 2.5; + + const gain = ctx.createGain(); + gain.gain.setValueAtTime(0.3, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.22); + + noise.connect(filter); + filter.connect(gain); + gain.connect(ctx.destination); + noise.start(now); + } + + function playBotPersonalitySfx(personalityId, emotion) { + if (isMuted) return; + const ctx = getAudioContext(); + if (!ctx) return; + const now = ctx.currentTime; + const pId = (personalityId || 'fox').toLowerCase(); + const emo = (emotion || 'thinking').toLowerCase(); + + // Mierny posun tóniny podľa emócie + let pitchMul = 1.0; + if (emo === 'shocked' || emo === 'desperate') pitchMul = 0.82; + else if (emo === 'confident' || emo === 'celebrating') pitchMul = 1.22; + else if (emo === 'freezing') pitchMul = 1.45; + else if (emo === 'angry') pitchMul = 0.92; + + switch (pId) { + case 'fox': { // Lišiak Filip - sly playful rising arpeggio + const baseFreqs = [440, 554.37, 659.25, 880]; + baseFreqs.forEach((freq, i) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + const t = now + i * 0.045; + osc.type = 'triangle'; + osc.frequency.setValueAtTime(freq * pitchMul, t); + gain.gain.setValueAtTime(0.28, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.18); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(t); + osc.stop(t + 0.19); + }); + break; + } + case 'queen': { // Kráľovná Alžbeta - regal royal chime / bell chords + const baseFreqs = [523.25, 659.25, 783.99, 1046.50]; + baseFreqs.forEach((freq, i) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + const t = now + i * 0.055; + osc.type = 'sine'; + osc.frequency.setValueAtTime(freq * pitchMul, t); + gain.gain.setValueAtTime(0.32, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.42); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(t); + osc.stop(t + 0.43); + }); + break; + } + case 'robot': { // Kyber-Robo - 8-bit digital synth crunch + const tones = [880, 1320, 660, 1760]; + tones.forEach((freq, i) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + const t = now + i * 0.04; + osc.type = 'square'; + osc.frequency.setValueAtTime(freq * pitchMul, t); + gain.gain.setValueAtTime(0.22, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.06); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(t); + osc.stop(t + 0.065); + }); + break; + } + case 'grandpa': { // Dedo Jozef - warm vintage harmonic bell + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(987.77 * pitchMul, now); + gain.gain.setValueAtTime(0.35, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.55); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(now); + osc.stop(now + 0.56); + break; + } + case 'cyberkid': { // Hacker Miško - futuristic laser zap + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(1500 * pitchMul, now); + osc.frequency.exponentialRampToValueAtTime(180 * pitchMul, now + 0.18); + gain.gain.setValueAtTime(0.3, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.18); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(now); + osc.stop(now + 0.19); + break; + } + case 'cat': { // Mačka Minda - playful synth meow + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(580 * pitchMul, now); + osc.frequency.exponentialRampToValueAtTime(980 * pitchMul, now + 0.11); + osc.frequency.exponentialRampToValueAtTime(680 * pitchMul, now + 0.28); + gain.gain.setValueAtTime(0.32, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.30); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(now); + osc.stop(now + 0.31); + break; + } + case 'wizard': { // Čarodejník Baltazár - mystical shimmering chord + [392.00, 587.33, 880.00, 1174.66].forEach((freq, i) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + const t = now + i * 0.035; + osc.type = 'sine'; + osc.frequency.setValueAtTime(freq * pitchMul, t); + gain.gain.setValueAtTime(0.24, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.45); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(t); + osc.stop(t + 0.46); + }); + break; + } + case 'bear': { // Medveď Mišo - deep bass growl thud + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(95 * pitchMul, now); + osc.frequency.exponentialRampToValueAtTime(38 * pitchMul, now + 0.35); + gain.gain.setValueAtTime(0.42, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.35); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(now); + osc.stop(now + 0.36); + break; + } + default: { + playCardSnap(); + break; + } + } + } + // --- SCREEN SHAKE --- function triggerScreenShake(intensity = 5) { const board = document.getElementById('game-table-container'); @@ -1197,6 +1384,8 @@ window.FaraonFx = (function () { animateCardPlay, animateCardDraw, animateTurnTransition, + playCardWhoosh, + playBotPersonalitySfx, setSpeedLevel, toggleMute }; diff --git a/Faraon.Core/Engine/GameEngine.cs b/Faraon.Core/Engine/GameEngine.cs index c3941ad..1e3bdf3 100644 --- a/Faraon.Core/Engine/GameEngine.cs +++ b/Faraon.Core/Engine/GameEngine.cs @@ -770,6 +770,121 @@ public class GameEngine private void EmitEvent(GameEvent e) { + RecordStatsAndLog(e); OnGameEvent?.Invoke(e); } + + private void RecordStatsAndLog(GameEvent e) + { + State.Stats.TotalTurns = State.TurnCount; + switch (e.Type) + { + case GameEventType.GameStarted: + AddLogItem(new GameLogItem("🎮", "Hra sa začala!", null, DateTime.UtcNow)); + break; + case GameEventType.CardPlayed: + if (e.SourcePlayer != null) + { + State.Stats.RecordCardPlayed(e.SourcePlayer, e.Cards?.Count ?? 1); + if (e.Card?.Rank == Rank.Ace || (e.Cards != null && e.Cards.Any(c => c.Rank == Rank.Ace))) + { + State.Stats.TotalAcesPlayed++; + } + if (e.Card?.Rank == Rank.Seven || (e.Cards != null && e.Cards.Any(c => c.Rank == Rank.Seven))) + { + State.Stats.TotalSevensPlayed++; + } + + string cardDesc = e.Cards != null && e.Cards.Count > 1 + ? $"{e.Cards.Count}x {e.Cards[0].RankName}" + : (e.Card != null ? $"{e.Card.RankName} {e.Card.SuitSymbol}" : "karta"); + + AddLogItem(new GameLogItem("🎴", $"{e.SourcePlayer.Name} zahral {cardDesc}", null, DateTime.UtcNow, e.SourcePlayer.Name, e.SourcePlayer.Avatar)); + } + break; + case GameEventType.CardDrawn: + if (e.SourcePlayer != null) + { + int count = e.ExtraData > 0 ? e.ExtraData : 1; + State.Stats.RecordCardDrawn(e.SourcePlayer, count); + string countText = count > 1 ? $"{count} kariet" : "z balíčka"; + string? sub = count > 1 ? "Trest za sedmy" : null; + string? accent = count > 1 ? "#f59e0b" : null; + AddLogItem(new GameLogItem("📥", $"{e.SourcePlayer.Name} potiahol {countText}", sub, DateTime.UtcNow, e.SourcePlayer.Name, e.SourcePlayer.Avatar, accent)); + } + break; + case GameEventType.SevenStacked: + if (e.TargetPlayer != null) + { + State.Stats.RecordCardDrawn(e.TargetPlayer, e.ExtraData); + AddLogItem(new GameLogItem("🎒", $"{e.TargetPlayer.Name} musel potiahnuť +{e.ExtraData} kariet!", "Trest zo sediem", DateTime.UtcNow, e.TargetPlayer.Name, e.TargetPlayer.Avatar, "#f59e0b")); + } + break; + case GameEventType.SevenCanceled: + if (e.SourcePlayer != null) + { + AddLogItem(new GameLogItem("🛡️", $"{e.SourcePlayer.Name} zrušil sedmy Zeleným dolníkom!", "Štít úspešný", DateTime.UtcNow, e.SourcePlayer.Name, e.SourcePlayer.Avatar, "#10b981")); + } + break; + case GameEventType.AceSkipped: + if (e.SourcePlayer != null) + { + AddLogItem(new GameLogItem("❄️", $"{e.SourcePlayer.Name} stojí toto kolo", "Zmrazený Esom", DateTime.UtcNow, e.SourcePlayer.Name, e.SourcePlayer.Avatar, "#06b6d4")); + } + break; + case GameEventType.AceCountered: + if (e.SourcePlayer != null) + { + AddLogItem(new GameLogItem("⚡", $"{e.SourcePlayer.Name} prebil Eso vlastným Esom!", "Protiútok", DateTime.UtcNow, e.SourcePlayer.Name, e.SourcePlayer.Avatar, "#3b82f6")); + } + break; + case GameEventType.CardsBurned: + if (e.SourcePlayer != null) + { + State.Stats.RecordBurn(e.SourcePlayer); + AddLogItem(new GameLogItem("🔥", $"{e.SourcePlayer.Name} SPÁLIL 4 KARTY!", "Hrá znova", DateTime.UtcNow, e.SourcePlayer.Name, e.SourcePlayer.Avatar, "#ef4444")); + } + break; + case GameEventType.SuitChanged: + if (e.SourcePlayer != null && e.ChosenSuit.HasValue) + { + AddLogItem(new GameLogItem("👑", $"{e.SourcePlayer.Name} zmenil farbu na {GetSuitName(e.ChosenSuit.Value)}", null, DateTime.UtcNow, e.SourcePlayer.Name, e.SourcePlayer.Avatar, "#eab308")); + } + break; + case GameEventType.PlayerRevived: + if (e.TargetPlayer != null) + { + AddLogItem(new GameLogItem("❤️", $"{e.TargetPlayer.Name} VSTAL Z MŔTVYCH!", "Červená sedma", DateTime.UtcNow, e.TargetPlayer.Name, e.TargetPlayer.Avatar, "#ec4899")); + } + break; + case GameEventType.PlayerFinished: + if (e.SourcePlayer != null) + { + AddLogItem(new GameLogItem("🏆", $"{e.SourcePlayer.Name} skončil na {e.ExtraData}. mieste!", "Vyprázdnil ruku", DateTime.UtcNow, e.SourcePlayer.Name, e.SourcePlayer.Avatar, "#ffd700")); + } + break; + case GameEventType.GameOver: + case GameEventType.MatchWon: + State.Stats.EndTime = DateTime.UtcNow; + break; + } + } + + private void AddLogItem(GameLogItem item) + { + State.MoveLog.Add(item); + if (State.MoveLog.Count > 30) + { + State.MoveLog.RemoveAt(0); + } + } + + private static string GetSuitName(Suit suit) => suit switch + { + Suit.Hearts => "Červeň ❤️", + Suit.Leaves => "Zeleň 🍃", + Suit.Acorns => "Žaluď 🌰", + Suit.Bells => "Guľu 🔔", + _ => suit.ToString() + }; } diff --git a/Faraon.Core/Models/GameState.cs b/Faraon.Core/Models/GameState.cs index b755422..aee1a19 100644 --- a/Faraon.Core/Models/GameState.cs +++ b/Faraon.Core/Models/GameState.cs @@ -40,4 +40,7 @@ public class GameState public int ActivePlayersCount => Players.Where(p => !p.IsEliminated).Count(p => !p.HasFinished); public List ActiveTournamentPlayers => Players.Where(p => !p.IsEliminated).ToList(); + + public GameMatchStats Stats { get; set; } = new(); + public List MoveLog { get; set; } = new(); } diff --git a/Faraon.Core/Models/GameStats.cs b/Faraon.Core/Models/GameStats.cs new file mode 100644 index 0000000..8dab6b7 --- /dev/null +++ b/Faraon.Core/Models/GameStats.cs @@ -0,0 +1,70 @@ +namespace Faraon.Core.Models; + +using System; +using System.Collections.Generic; + +public record GameLogItem( + string Icon, + string Text, + string? SubText, + DateTime Time, + string? PlayerName = null, + string? PlayerAvatar = null, + string? AccentColor = null +); + +public class GameMatchStats +{ + public DateTime StartTime { get; set; } = DateTime.UtcNow; + public DateTime? EndTime { get; set; } + public TimeSpan Duration => (EndTime ?? DateTime.UtcNow) - StartTime; + public int TotalTurns { get; set; } + public int TotalAcesPlayed { get; set; } + public int TotalSevensPlayed { get; set; } + public int TotalCardsBurned { get; set; } + public int MaxPenaltyDrawnInOneTurn { get; set; } + public string? MaxPenaltyPlayerName { get; set; } + + public Dictionary CardsPlayedPerPlayer { get; } = new(); + public Dictionary CardsDrawnPerPlayer { get; } = new(); + public Dictionary BurnsPerPlayer { get; } = new(); + + public void RecordCardPlayed(Player player, int count = 1) + { + CardsPlayedPerPlayer[player.Name] = CardsPlayedPerPlayer.GetValueOrDefault(player.Name) + count; + } + + public void RecordCardDrawn(Player player, int count = 1) + { + CardsDrawnPerPlayer[player.Name] = CardsDrawnPerPlayer.GetValueOrDefault(player.Name) + count; + if (count > MaxPenaltyDrawnInOneTurn) + { + MaxPenaltyDrawnInOneTurn = count; + MaxPenaltyPlayerName = player.Name; + } + } + + public void RecordBurn(Player player) + { + TotalCardsBurned += 4; + BurnsPerPlayer[player.Name] = BurnsPerPlayer.GetValueOrDefault(player.Name) + 1; + } + + public string GetMvpPlayerName() + { + string best = "Nikto"; + int bestScore = -999; + foreach (var (name, played) in CardsPlayedPerPlayer) + { + int burns = BurnsPerPlayer.GetValueOrDefault(name); + int drawn = CardsDrawnPerPlayer.GetValueOrDefault(name); + int score = (played * 2) + (burns * 10) - drawn; + if (score > bestScore) + { + bestScore = score; + best = name; + } + } + return best; + } +} \ No newline at end of file diff --git a/Faraon.Core/Models/Player.cs b/Faraon.Core/Models/Player.cs index 354d90a..68f803b 100644 --- a/Faraon.Core/Models/Player.cs +++ b/Faraon.Core/Models/Player.cs @@ -50,6 +50,11 @@ public class Player Avatar = avatar; Difficulty = difficulty; PersonalityId = personalityId; + if (type == PlayerType.AI && string.IsNullOrEmpty(PersonalityId)) + { + var pers = AIPersonality.All.FirstOrDefault(a => a.BaseAvatar == avatar); + PersonalityId = pers?.Id ?? "fox"; + } } public string GetLiveAvatar() diff --git a/Faraon.Core/Models/RuleOptions.cs b/Faraon.Core/Models/RuleOptions.cs index b144f1e..d2c6430 100644 --- a/Faraon.Core/Models/RuleOptions.cs +++ b/Faraon.Core/Models/RuleOptions.cs @@ -14,8 +14,20 @@ public enum GreenUnderKnaveMode UniversalWildcard // Ruší sedmy, ide na čokoľvek a súper naňho môže dať čokoľvek } +public enum TableTheme +{ + ClassicGreen = 0, + RoyalBurgundy = 1, + WoodCabin = 2, + CyberNeon = 3 +} + public class RuleOptions { + /// + /// Vizuálna téma a atmosféra herného stola. + /// + public TableTheme TableTheme { get; set; } = TableTheme.ClassicGreen; /// /// Počet kariet rozdaných každému hráčovi na začiatku (zvyčajne 4 alebo 5). /// diff --git a/Faraon.Tests/GameEngineTests.cs b/Faraon.Tests/GameEngineTests.cs index 31f69ef..8fa2001 100644 --- a/Faraon.Tests/GameEngineTests.cs +++ b/Faraon.Tests/GameEngineTests.cs @@ -504,4 +504,39 @@ public class GameEngineTests Assert.Equal("😾", player.GetLiveAvatar()); Assert.Equal("🔥", player.GetEmotionBadge()); } + + [Fact] + public void GameStatsAndMoveLog_RecordEventsProperly() + { + var engine = new GameEngine(seed: 42); + var p1 = new Player("Hráč 1", PlayerType.Human); + var p2 = new Player("AI 1", PlayerType.AI); + var rules = new RuleOptions { StackSevens = true }; + engine.StartGame(new List { p1, p2 }, rules); + + Assert.NotNull(engine.State.Stats); + Assert.NotNull(engine.State.MoveLog); + Assert.NotEmpty(engine.State.MoveLog); + Assert.Contains(engine.State.MoveLog, l => l.Text.Contains("Hra sa začala")); + + // Play Seven + p1.Hand.Clear(); + var seven = new Card(Suit.Hearts, Rank.Seven); + p1.Hand.Add(seven); + p1.Hand.Add(new Card(Suit.Hearts, Rank.Eight)); + engine.State.DiscardPile.Clear(); + engine.State.DiscardPile.Add(new Card(Suit.Hearts, Rank.Ten)); + + engine.PlayCard(p1, seven); + + Assert.Equal(1, engine.State.Stats.TotalSevensPlayed); + Assert.Contains(engine.State.MoveLog, l => l.Text.Contains("zahral") && l.Text.Contains("Sedma")); + + // Bot draws 3 cards + engine.DrawOrAcknowledge(p2); + + Assert.True(engine.State.Stats.MaxPenaltyDrawnInOneTurn >= 3); + Assert.Contains(engine.State.MoveLog, l => l.Text.Contains("potiahol 3")); + } } +