Screen3Results.tsx 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. import type { GameStats, SolverReplay } from '@wordle/shared';
  2. import { getMessages } from '../messages/index.js';
  3. import { LangSwitch } from '../components/LangSwitch.js';
  4. interface Screen3ResultsProps {
  5. won: boolean;
  6. attemptCount: number;
  7. revealedWord: string | null;
  8. stats: GameStats;
  9. replays?: SolverReplay[];
  10. onPlayAgain?: () => void;
  11. }
  12. const MINI_COLORS: Record<string, string> = { g: '#6aaa64', y: '#c9b458', x: '#787c7e' };
  13. /** Compute Wordle feedback colors for a guess against the target word (same algorithm as server). */
  14. function computeFeedback(target: string, guess: string): string[] {
  15. const targetUpper = target.toLowerCase();
  16. const guessUpper = guess.toLowerCase();
  17. const targetCounts: Record<string, number> = {};
  18. for (const ch of targetUpper) {
  19. targetCounts[ch] = (targetCounts[ch] ?? 0) + 1;
  20. }
  21. const result: string[] = Array(5).fill('x');
  22. // Pass 1: greens
  23. for (let i = 0; i < 5; i++) {
  24. if (guessUpper[i] === targetUpper[i]) {
  25. result[i] = 'g';
  26. targetCounts[guessUpper[i]]--;
  27. }
  28. }
  29. // Pass 2: yellows
  30. for (let i = 0; i < 5; i++) {
  31. if (result[i] === 'g') continue;
  32. const ch = guessUpper[i];
  33. if ((targetCounts[ch] ?? 0) > 0) {
  34. result[i] = 'y';
  35. targetCounts[ch]--;
  36. }
  37. }
  38. return result;
  39. }
  40. function MiniBoard({ replay, targetWord }: { replay: SolverReplay; targetWord: string }) {
  41. return (
  42. <div style={{ marginBottom: '12px' }}>
  43. <div style={{ fontSize: '0.7rem', fontWeight: 'bold', color: '#555', marginBottom: '4px', textAlign: 'center' }}>
  44. {replay.solver}
  45. </div>
  46. {replay.steps.map((guess, rowIdx) => {
  47. const colors = computeFeedback(targetWord, guess);
  48. return (
  49. <div key={rowIdx} style={{ display: 'flex', gap: '2px', justifyContent: 'center', marginBottom: '2px' }}>
  50. {guess.split('').map((letter, colIdx) => {
  51. const c = colors[colIdx];
  52. return (
  53. <div key={colIdx} style={{
  54. width: '20px', height: '20px',
  55. backgroundColor: MINI_COLORS[c] ?? '#d3d6da',
  56. color: '#fff',
  57. display: 'flex', alignItems: 'center', justifyContent: 'center',
  58. fontSize: '0.55rem', fontWeight: 'bold',
  59. textTransform: 'uppercase', fontFamily: 'monospace',
  60. borderRadius: '2px',
  61. }}>
  62. {letter}
  63. </div>
  64. );
  65. })}
  66. </div>
  67. );
  68. })}
  69. </div>
  70. );
  71. }
  72. function Histogram({ stats }: { stats: GameStats }) {
  73. if (stats.gamesPlayed === 0) return <p style={{ color: '#787c7e' }}>{getMessages().results.noGames}</p>;
  74. const maxCount = Math.max(...stats.histogram, 1);
  75. return (
  76. <div style={{ maxWidth: '300px', margin: '0 auto' }}>
  77. {stats.histogram.map((count, idx) => {
  78. const label = idx < 6 ? `${idx + 1}` : 'X';
  79. const pct = (count / maxCount) * 100;
  80. return (
  81. <div key={idx} style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
  82. <span style={{ width: '20px', textAlign: 'right', fontWeight: 'bold', color: '#555' }}>{label}</span>
  83. <div style={{ flex: 1, height: '20px', backgroundColor: count > 0 ? '#6aaa64' : '#d3d6da', width: `${Math.max(pct, count > 0 ? 5 : 0)}%`, minWidth: count > 0 ? '12px' : '4px', borderRadius: '2px' }} />
  84. <span style={{ width: '24px', fontSize: '0.8rem', color: '#555' }}>{count}</span>
  85. </div>
  86. );
  87. })}
  88. </div>
  89. );
  90. }
  91. export function Screen3Results({ won, attemptCount, revealedWord, stats, replays, onPlayAgain }: Screen3ResultsProps) {
  92. const r = getMessages().results;
  93. return (
  94. <div style={{ maxWidth: '400px', margin: '24px auto', padding: '16px', fontFamily: 'sans-serif', textAlign: 'center', position: 'relative' }}>
  95. <LangSwitch />
  96. <h1 style={{ fontSize: '1.6rem', marginBottom: '12px' }}>
  97. {won ? r.congratulations : r.betterLuck}
  98. </h1>
  99. {won ? (
  100. <p style={{ fontSize: '1.1rem', marginBottom: '8px' }}>{r.solvedIn(attemptCount)}</p>
  101. ) : (
  102. revealedWord && <p style={{ fontSize: '1.1rem', marginBottom: '8px' }}>{r.theWordWas(revealedWord)}</p>
  103. )}
  104. <div style={{ margin: '24px 0' }}>
  105. <h2 style={{ fontSize: '1.1rem', marginBottom: '12px' }}>{r.statistics}</h2>
  106. <div style={{ display: 'flex', justifyContent: 'center', gap: '24px', marginBottom: '16px' }}>
  107. <div><div style={{ fontSize: '1.4rem', fontWeight: 'bold' }}>{stats.gamesPlayed}</div><div style={{ fontSize: '0.75rem', color: '#787c7e' }}>{r.played}</div></div>
  108. <div><div style={{ fontSize: '1.4rem', fontWeight: 'bold' }}>{stats.gamesPlayed > 0 ? Math.round((stats.wins / stats.gamesPlayed) * 100) : 0}%</div><div style={{ fontSize: '0.75rem', color: '#787c7e' }}>{r.winRate}</div></div>
  109. <div><div style={{ fontSize: '1.4rem', fontWeight: 'bold' }}>{stats.currentStreak}</div><div style={{ fontSize: '0.75rem', color: '#787c7e' }}>{r.streak}</div></div>
  110. <div><div style={{ fontSize: '1.4rem', fontWeight: 'bold' }}>{stats.maxStreak}</div><div style={{ fontSize: '0.75rem', color: '#787c7e' }}>{r.maxStreak}</div></div>
  111. </div>
  112. <Histogram stats={stats} />
  113. </div>
  114. {replays && replays.length > 0 && revealedWord && (
  115. <div style={{ margin: '24px 0' }}>
  116. <p style={{ fontSize: '0.85rem', color: '#787c7e', marginBottom: '12px' }}>
  117. {r.solverCaption}
  118. </p>
  119. <div style={{ display: 'flex', flexWrap: 'wrap', gap: '16px', justifyContent: 'center', maxWidth: '320px', margin: '0 auto' }}>
  120. {replays.map((rp) => (
  121. <div key={rp.solver} style={{ flex: '0 0 calc(50% - 8px)' }}>
  122. <MiniBoard replay={rp} targetWord={revealedWord} />
  123. </div>
  124. ))}
  125. </div>
  126. </div>
  127. )}
  128. {onPlayAgain && (
  129. <button
  130. onClick={onPlayAgain}
  131. style={{
  132. padding: '12px 48px',
  133. fontSize: '1.1rem',
  134. fontWeight: 'bold',
  135. backgroundColor: '#6aaa64',
  136. color: '#fff',
  137. border: 'none',
  138. borderRadius: '8px',
  139. cursor: 'pointer',
  140. }}
  141. >
  142. {r.playAgain}
  143. </button>
  144. )}
  145. </div>
  146. );
  147. }