Finalize scoring system, release year integration, and fix song deletion
This commit is contained in:
@@ -16,6 +16,7 @@ interface GameProps {
|
||||
title: string;
|
||||
artist: string;
|
||||
coverImage: string | null;
|
||||
releaseYear?: number | null;
|
||||
startTime?: number;
|
||||
} | null;
|
||||
genre?: string | null;
|
||||
@@ -27,7 +28,7 @@ interface GameProps {
|
||||
const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
|
||||
|
||||
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
|
||||
const { gameState, statistics, addGuess } = useGameState(genre, maxAttempts);
|
||||
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus } = useGameState(genre, maxAttempts);
|
||||
const [hasWon, setHasWon] = useState(false);
|
||||
const [hasLost, setHasLost] = useState(false);
|
||||
const [shareText, setShareText] = useState('🔗 Share');
|
||||
@@ -35,6 +36,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const [isProcessingGuess, setIsProcessingGuess] = useState(false);
|
||||
const [timeUntilNext, setTimeUntilNext] = useState('');
|
||||
const [hasRated, setHasRated] = useState(false);
|
||||
const [showYearModal, setShowYearModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const updateCountdown = () => {
|
||||
@@ -50,7 +52,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
};
|
||||
|
||||
updateCountdown();
|
||||
const interval = setInterval(updateCountdown, 1000); // Update every second to be accurate
|
||||
const interval = setInterval(updateCountdown, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
@@ -58,6 +60,11 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
if (gameState && dailyPuzzle) {
|
||||
setHasWon(gameState.isSolved);
|
||||
setHasLost(gameState.isFailed);
|
||||
|
||||
// Show year modal if won but year not guessed yet and release year is available
|
||||
if (gameState.isSolved && !gameState.yearGuessed && dailyPuzzle.releaseYear) {
|
||||
setShowYearModal(true);
|
||||
}
|
||||
}
|
||||
}, [gameState, dailyPuzzle]);
|
||||
|
||||
@@ -87,37 +94,61 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
if (!gameState) return <div>Loading state...</div>;
|
||||
|
||||
const handleGuess = (song: any) => {
|
||||
if (isProcessingGuess) return; // Prevent multiple guesses
|
||||
if (isProcessingGuess) return;
|
||||
|
||||
setIsProcessingGuess(true);
|
||||
setLastAction('GUESS');
|
||||
if (song.id === dailyPuzzle.songId) {
|
||||
addGuess(song.title, true);
|
||||
setHasWon(true);
|
||||
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre);
|
||||
// Notification sent after year guess or skip
|
||||
if (!dailyPuzzle.releaseYear) {
|
||||
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||
}
|
||||
} else {
|
||||
addGuess(song.title, false);
|
||||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||
setHasLost(true);
|
||||
setHasWon(false); // Ensure won is false
|
||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre);
|
||||
setHasWon(false);
|
||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
||||
}
|
||||
}
|
||||
// Reset after a short delay to allow UI update
|
||||
setTimeout(() => setIsProcessingGuess(false), 500);
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
setLastAction('SKIP');
|
||||
addGuess("SKIPPED", false);
|
||||
|
||||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||
setHasLost(true);
|
||||
setHasWon(false);
|
||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
||||
}
|
||||
};
|
||||
|
||||
const handleGiveUp = () => {
|
||||
setLastAction('SKIP');
|
||||
addGuess("SKIPPED", false);
|
||||
giveUp(); // Ensure game is marked as failed and score reset to 0
|
||||
setHasLost(true);
|
||||
setHasWon(false);
|
||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre);
|
||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0);
|
||||
};
|
||||
|
||||
const handleYearGuess = (year: number) => {
|
||||
const correct = year === dailyPuzzle.releaseYear;
|
||||
addYearBonus(correct);
|
||||
setShowYearModal(false);
|
||||
|
||||
// Send notification now that game is fully complete
|
||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
|
||||
};
|
||||
|
||||
const handleYearSkip = () => {
|
||||
setShowYearModal(false);
|
||||
// Send notification now that game is fully complete
|
||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||
};
|
||||
|
||||
const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
|
||||
@@ -126,21 +157,16 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
let emojiGrid = '';
|
||||
const totalGuesses = maxAttempts;
|
||||
|
||||
// Build the grid
|
||||
for (let i = 0; i < totalGuesses; i++) {
|
||||
if (i < gameState.guesses.length) {
|
||||
// If this was the winning guess (last one and won)
|
||||
if (hasWon && i === gameState.guesses.length - 1) {
|
||||
emojiGrid += '🟩';
|
||||
} else if (gameState.guesses[i] === 'SKIPPED') {
|
||||
// Skipped
|
||||
emojiGrid += '⬛';
|
||||
} else {
|
||||
// Wrong guess
|
||||
emojiGrid += '🟥';
|
||||
}
|
||||
} else {
|
||||
// Unused attempts
|
||||
emojiGrid += '⬜';
|
||||
}
|
||||
}
|
||||
@@ -148,7 +174,6 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const speaker = hasWon ? '🔉' : '🔇';
|
||||
const genreText = genre ? `Genre: ${genre}\n` : '';
|
||||
|
||||
// Generate URL with genre/special path
|
||||
let shareUrl = 'https://hoerdle.elpatron.me';
|
||||
if (genre) {
|
||||
if (isSpecial) {
|
||||
@@ -158,9 +183,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
}
|
||||
}
|
||||
|
||||
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}\n\n#Hördle #Music\n\n${shareUrl}`;
|
||||
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`;
|
||||
|
||||
// Try native Web Share API only on mobile devices
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||
|
||||
if (isMobile && navigator.share) {
|
||||
@@ -173,14 +197,12 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
setTimeout(() => setShareText('🔗 Share'), 2000);
|
||||
return;
|
||||
} catch (err) {
|
||||
// User cancelled or error - fall through to clipboard
|
||||
if ((err as Error).name !== 'AbortError') {
|
||||
console.error('Share failed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Copy to clipboard
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setShareText('✓ Copied!');
|
||||
@@ -192,8 +214,6 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleRatingSubmit = async (rating: number) => {
|
||||
if (!dailyPuzzle) return;
|
||||
|
||||
@@ -201,7 +221,6 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
await submitRating(dailyPuzzle.songId, rating, genre, isSpecial, dailyPuzzle.puzzleNumber);
|
||||
setHasRated(true);
|
||||
|
||||
// Persist to localStorage
|
||||
const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]');
|
||||
if (!ratedPuzzles.includes(dailyPuzzle.id)) {
|
||||
ratedPuzzles.push(dailyPuzzle.id);
|
||||
@@ -222,17 +241,20 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
</header>
|
||||
|
||||
<main className="game-board">
|
||||
|
||||
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
||||
<div className="status-bar">
|
||||
<span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
|
||||
<span>{unlockedSeconds}s unlocked</span>
|
||||
</div>
|
||||
|
||||
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
|
||||
|
||||
<AudioPlayer
|
||||
src={dailyPuzzle.audioUrl}
|
||||
unlockedSeconds={unlockedSeconds}
|
||||
startTime={dailyPuzzle.startTime}
|
||||
autoPlay={lastAction === 'SKIP'}
|
||||
onReplay={addReplay}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -253,7 +275,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
{!hasWon && !hasLost && (
|
||||
<>
|
||||
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
|
||||
{gameState.guesses.length < 6 ? (
|
||||
{gameState.guesses.length < maxAttempts - 1 ? (
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="skip-button"
|
||||
@@ -275,12 +297,32 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasWon && (
|
||||
<div className="message-box success">
|
||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>You won!</h2>
|
||||
<p>Come back tomorrow for a new song.</p>
|
||||
{(hasWon || hasLost) && (
|
||||
<div className={`message-box ${hasWon ? 'success' : 'failure'}`}>
|
||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
||||
{hasWon ? 'You won!' : 'Game Over'}
|
||||
</h2>
|
||||
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? '#059669' : '#dc2626' }}>
|
||||
Score: {gameState.score}
|
||||
</div>
|
||||
|
||||
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: '#666' }}>
|
||||
<summary>Score Breakdown</summary>
|
||||
<ul style={{ listStyle: 'none', padding: '0.5rem', textAlign: 'left', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
|
||||
{gameState.scoreBreakdown.map((item, i) => (
|
||||
<li key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.25rem 0' }}>
|
||||
<span>{item.reason}</span>
|
||||
<span style={{ fontWeight: 'bold', color: item.value >= 0 ? 'green' : 'red' }}>
|
||||
{item.value > 0 ? '+' : ''}{item.value}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<p>{hasWon ? 'Come back tomorrow for a new song.' : 'The song was:'}</p>
|
||||
|
||||
{/* Song Details */}
|
||||
<div style={{ margin: '1.5rem 0', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<img
|
||||
src={dailyPuzzle.coverImage || '/favicon.ico'}
|
||||
@@ -288,14 +330,16 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
|
||||
/>
|
||||
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>{dailyPuzzle.artist}</p>
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
|
||||
{dailyPuzzle.releaseYear && (
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p>
|
||||
)}
|
||||
<audio controls style={{ width: '100%' }}>
|
||||
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
{/* Rating Component */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
|
||||
</div>
|
||||
@@ -306,40 +350,150 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasLost && (
|
||||
<div className="message-box failure">
|
||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>Game Over</h2>
|
||||
<p>The song was:</p>
|
||||
|
||||
{/* Song Details */}
|
||||
<div style={{ margin: '1.5rem 0', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<img
|
||||
src={dailyPuzzle.coverImage || '/favicon.ico'}
|
||||
alt="Album Cover"
|
||||
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
|
||||
/>
|
||||
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>{dailyPuzzle.artist}</p>
|
||||
<audio controls style={{ width: '100%' }}>
|
||||
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
{/* Rating Component */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
|
||||
</div>
|
||||
|
||||
{statistics && <Statistics statistics={statistics} />}
|
||||
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
|
||||
{shareText}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</main>
|
||||
|
||||
{showYearModal && dailyPuzzle.releaseYear && (
|
||||
<YearGuessModal
|
||||
correctYear={dailyPuzzle.releaseYear}
|
||||
onGuess={handleYearGuess}
|
||||
onSkip={handleYearSkip}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{ value: number, reason: string }> }) {
|
||||
const tooltipText = breakdown.map(item => `${item.reason}: ${item.value > 0 ? '+' : ''}${item.value}`).join('\n');
|
||||
|
||||
// Create expression: "90 - 2 - 5 + 10"
|
||||
// Limit to last 5 items to avoid overflow if too long
|
||||
const displayItems = breakdown.length > 5 ?
|
||||
[{ value: breakdown[0].value, reason: 'Start' }, ...breakdown.slice(-4)] :
|
||||
breakdown;
|
||||
|
||||
const expression = displayItems.map((item, index) => {
|
||||
if (index === 0 && breakdown.length <= 5) return item.value.toString();
|
||||
if (index === 0 && breakdown.length > 5) return `${item.value} ...`;
|
||||
return item.value >= 0 ? `+ ${item.value}` : `- ${Math.abs(item.value)}`;
|
||||
}).join(' ');
|
||||
|
||||
return (
|
||||
<div className="score-display" title={tooltipText} style={{
|
||||
textAlign: 'center',
|
||||
margin: '0.5rem 0',
|
||||
padding: '0.5rem',
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.9rem',
|
||||
fontFamily: 'monospace',
|
||||
cursor: 'help'
|
||||
}}>
|
||||
<span style={{ color: '#666' }}>{expression} = </span>
|
||||
<span style={{ fontWeight: 'bold', color: 'var(--primary)', fontSize: '1.1rem' }}>{score}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
|
||||
const [options, setOptions] = useState<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const minYear = 1950;
|
||||
|
||||
const closeOptions = new Set<number>();
|
||||
closeOptions.add(correctYear);
|
||||
|
||||
// Add 2 close years (+/- 2)
|
||||
while (closeOptions.size < 3) {
|
||||
const offset = Math.floor(Math.random() * 5) - 2;
|
||||
const year = correctYear + offset;
|
||||
if (year <= currentYear && year >= minYear && year !== correctYear) {
|
||||
closeOptions.add(year);
|
||||
}
|
||||
}
|
||||
|
||||
const allOptions = new Set(closeOptions);
|
||||
|
||||
// Fill up to 10 with random years
|
||||
while (allOptions.size < 10) {
|
||||
const year = Math.floor(Math.random() * (currentYear - minYear + 1)) + minYear;
|
||||
allOptions.add(year);
|
||||
}
|
||||
|
||||
setOptions(Array.from(allOptions).sort((a, b) => a - b));
|
||||
}, [correctYear]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
padding: '1rem'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
padding: '2rem',
|
||||
borderRadius: '1rem',
|
||||
maxWidth: '500px',
|
||||
width: '100%',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: '#1f2937' }}>Bonus Round!</h3>
|
||||
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>Guess the release year for <strong style={{ color: '#10b981' }}>+10 points</strong>!</p>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
|
||||
gap: '0.75rem',
|
||||
marginBottom: '1.5rem'
|
||||
}}>
|
||||
{options.map(year => (
|
||||
<button
|
||||
key={year}
|
||||
onClick={() => onGuess(year)}
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
background: '#f3f4f6',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 'bold',
|
||||
color: '#374151',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseOver={e => e.currentTarget.style.borderColor = '#10b981'}
|
||||
onMouseOut={e => e.currentTarget.style.borderColor = '#e5e7eb'}
|
||||
>
|
||||
{year}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onSkip}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#6b7280',
|
||||
textDecoration: 'underline',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
>
|
||||
Skip Bonus
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user