- {/* API Key Input is now handled in start screen, but we keep the button for updates */}
- {/*
{/* Header with Back Button */}
{t.game.backToMenu}
@@ -500,13 +323,23 @@ export default function ChessGame() {
-
{/* Match board height roughly */}
+
-
+
+ {/* Opponent's Captured Pieces (Top) */}
+
+ 0 ? blackAdvantage : null) : (whiteAdvantage > 0 ? whiteAdvantage : null)}
+ />
+
+
+
+ {/* Player's Captured Pieces (Bottom) */}
+
+ 0 ? whiteAdvantage : null) : (blackAdvantage > 0 ? blackAdvantage : null)}
+ />
+
@@ -528,16 +370,15 @@ export default function ChessGame() {
{
const game = gameRef.current;
- // Undo twice: once for computer, once for user
game.undo();
game.undo();
setFen(game.fen());
- // Reset moves to prevent re-analysis of old moves
setUserMove(null);
setComputerMove(null);
setEvalP0(null);
setEvalP2(null);
setOpeningData(null);
+ updateCapturedPieces();
}}
className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900 dark:text-red-200 transition-colors"
>
@@ -554,8 +395,6 @@ export default function ChessGame() {
/>
- {/* PGN Display */}
- {/* Game History (Scrollable List) */}
Game History
@@ -601,7 +440,6 @@ export default function ChessGame() {
})()}
- {/* Auto-scroll anchor */}
el?.scrollIntoView({ behavior: "smooth" })} />
@@ -618,13 +456,12 @@ export default function ChessGame() {
openingData={openingData}
onAnalysisComplete={() => { }}
apiKey={apiKey}
- personality={selectedPersonality!}
+ personality={selectedPersonality}
language={language}
playerColor={playerColor}
/>
- {/* Analysis Modal */}
{showAnalysisModal && (
)}
- {/* Game Over Modal */}
{gameOverState && (
setGameOverState(null)}
- onNewGame={() => handleNewGame(selectedPersonality!)}
+ onNewGame={handleNewGame}
/>
)}
diff --git a/src/components/EvaluationBar.tsx b/src/components/EvaluationBar.tsx
index 7997c2a..8b2caf6 100644
--- a/src/components/EvaluationBar.tsx
+++ b/src/components/EvaluationBar.tsx
@@ -5,9 +5,10 @@ import clsx from "clsx";
interface EvaluationBarProps {
score?: number | null; // centipawns
mate?: number | null; // moves to mate
+ isPlayerWhite: boolean;
}
-export function EvaluationBar({ score, mate }: EvaluationBarProps) {
+export function EvaluationBar({ score, mate, isPlayerWhite }: EvaluationBarProps) {
// Calculate white's percentage height
// Using sigmoid-like function for score: P = 1 / (1 + 10^(-score/400))
// This is a standard way to visualize CP advantage.
@@ -18,7 +19,7 @@ export function EvaluationBar({ score, mate }: EvaluationBarProps) {
// Mate detected
if (mate > 0) {
whiteHeightPercent = 100;
- label = `M${mate}`;
+ label = `M${Math.abs(mate)}`;
} else {
whiteHeightPercent = 0;
label = `M${Math.abs(mate)}`;
@@ -30,37 +31,34 @@ export function EvaluationBar({ score, mate }: EvaluationBarProps) {
whiteHeightPercent = winChance * 100;
// Format label: +1.5 or -0.3
- const pawnScore = score / 100;
- label = pawnScore > 0 ? `+${pawnScore.toFixed(1)}` : pawnScore.toFixed(1);
+ // If player is NOT white, we invert the score for display (so + means Player advantage)
+ let displayScore = score / 100;
+ if (!isPlayerWhite) {
+ displayScore = -displayScore;
+ }
+
+ label = displayScore > 0 ? `+${displayScore.toFixed(1)}` : displayScore.toFixed(1);
if (score === 0) label = "0.0";
}
- // Invert label color based on background
- // If whiteHeightPercent is high, top is white, text should be black if it's at the top?
- // Actually, usually the text is placed based on who is winning or fixed.
- // Let's place text at top for White advantage and bottom for Black?
- // Or just center it? Standard is usually top/bottom or floating.
- // Let's keep it simple: Text always visible, color contrasting with the bar it's on.
-
- // We'll put the text in a small badge that floats?
- // Or just inside the bar.
-
return (
-
+
{/* Black background is the container (h-full) */}
- {/* White bar grows from bottom (flex-col-reverse) */}
+ {/* White bar grows from bottom if player is white, from top if player is black */}
{/* Score Label */}
-
50 ? "top-0 text-gray-800" : "bottom-0 text-white"
- )}>
- {label}
+
+
+ {label}
+
);
diff --git a/src/components/StartScreen.tsx b/src/components/StartScreen.tsx
new file mode 100644
index 0000000..aef87fd
--- /dev/null
+++ b/src/components/StartScreen.tsx
@@ -0,0 +1,199 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { Settings, ChevronDown, ChevronUp } from "lucide-react";
+import { Personality, PERSONALITIES } from "@/lib/personalities";
+import { useTranslation } from "@/lib/i18n/useTranslation";
+import { SupportedLanguage } from "@/lib/i18n/translations";
+import Header from "./Header";
+
+interface StartScreenProps {
+ onStartGame: (options: {
+ personality: Personality;
+ color: 'white' | 'black' | 'random';
+ fen?: string;
+ }) => void;
+ onResumeGame: () => void;
+ hasSavedGame: boolean;
+}
+
+export default function StartScreen({ onStartGame, onResumeGame, hasSavedGame }: StartScreenProps) {
+ const router = useRouter();
+ const [language, setLanguage] = useState
('en');
+ const [showNewGameOptions, setShowNewGameOptions] = useState(false);
+ const [customFen, setCustomFen] = useState("");
+ const [colorSelection, setColorSelection] = useState<'white' | 'black' | 'random'>('white');
+ const [showAdvanced, setShowAdvanced] = useState(false);
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ const storedLang = localStorage.getItem("chess_tutor_language");
+ if (storedLang) setLanguage(storedLang as SupportedLanguage);
+ setMounted(true);
+ }, []);
+
+ const t = useTranslation(language);
+
+ const handleNewGame = (personality: Personality) => {
+ onStartGame({
+ personality,
+ color: colorSelection,
+ fen: customFen.trim() || undefined
+ });
+ };
+
+ if (!mounted) return null;
+
+ return (
+ <>
+
+
+
+ router.push("/settings")}
+ className="p-3 bg-white dark:bg-gray-800 rounded-full shadow-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-all text-gray-700 dark:text-gray-200"
+ title={t.start.settings}
+ >
+
+
+
+
+
+ {t.start.title}
+
+
+
+
+
+ {t.start.startGame}
+
+
+
+ {/* Resume Option */}
+ {hasSavedGame && !showNewGameOptions && (
+
+
+ ▶ {t.start.resumeGame}
+
+
+
setShowNewGameOptions(true)}
+ className="w-full py-3 bg-white dark:bg-gray-700 border-2 border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-200 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-600 font-semibold transition-colors"
+ >
+ {t.start.startNewGame}
+
+
+ )}
+
+ {/* New Game Options */}
+ {(!hasSavedGame || showNewGameOptions) && (
+
+ {/* Color Selection */}
+
+
+ {t.start.colorSelection}
+
+
+ setColorSelection('white')}
+ className={`py-4 px-4 rounded-xl border-2 text-sm font-bold transition-all flex flex-col items-center gap-2 ${colorSelection === 'white'
+ ? 'bg-blue-50 border-blue-600 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400'
+ : 'bg-white dark:bg-gray-700 border-gray-200 dark:border-gray-600 hover:border-blue-300 dark:hover:border-blue-500'
+ }`}
+ >
+ ♔ {t.start.playAsWhite}
+
+ setColorSelection('black')}
+ className={`py-4 px-4 rounded-xl border-2 text-sm font-bold transition-all flex flex-col items-center gap-2 ${colorSelection === 'black'
+ ? 'bg-blue-50 border-blue-600 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400'
+ : 'bg-white dark:bg-gray-700 border-gray-200 dark:border-gray-600 hover:border-blue-300 dark:hover:border-blue-500'
+ }`}
+ >
+ ♚ {t.start.playAsBlack}
+
+ setColorSelection('random')}
+ className={`py-4 px-4 rounded-xl border-2 text-sm font-bold transition-all flex flex-col items-center gap-2 ${colorSelection === 'random'
+ ? 'bg-blue-50 border-blue-600 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400'
+ : 'bg-white dark:bg-gray-700 border-gray-200 dark:border-gray-600 hover:border-blue-300 dark:hover:border-blue-500'
+ }`}
+ >
+ 🎲 {t.start.randomColor}
+
+
+
+
+ {/* Personality Grid */}
+
+
+ {t.start.chooseCoach}
+
+
+ {PERSONALITIES.map(p => (
+
handleNewGame(p)}
+ className="group relative bg-gray-50 dark:bg-gray-700 p-5 rounded-xl hover:bg-white dark:hover:bg-gray-600 transition-all border-2 border-transparent hover:border-blue-500 dark:hover:border-blue-400 shadow-sm hover:shadow-md text-left flex items-start gap-4"
+ >
+ {p.image}
+
+
{p.name}
+
{p.description}
+
+
+ ))}
+
+
+
+ {/* Advanced Options (Accordion) */}
+
+
setShowAdvanced(!showAdvanced)}
+ className="flex items-center gap-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors"
+ >
+ {showAdvanced ? : }
+ Advanced Options
+
+
+ {showAdvanced && (
+
+
+ {t.start.importPosition}
+
+ setCustomFen(e.target.value)}
+ className="w-full p-3 border rounded-lg dark:bg-gray-700 dark:border-gray-600 font-mono text-sm focus:ring-2 focus:ring-blue-500 outline-none"
+ />
+
+ )}
+
+
+ {hasSavedGame && (
+
setShowNewGameOptions(false)}
+ className="w-full py-3 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
+ >
+ {t.common.cancel}
+
+ )}
+
+ )}
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/Tutor.tsx b/src/components/Tutor.tsx
index ff34e0f..a125837 100644
--- a/src/components/Tutor.tsx
+++ b/src/components/Tutor.tsx
@@ -170,13 +170,14 @@ You can use this metadata to explain the position:
const prompt = `
[SYSTEM TRIGGER: move_exchange]
-User (White) Move: ${userMove.san}
-My (Black) Reply: ${computerMove.san}
+User (${playerColorName}) Move: ${userMove.san}
+My (${tutorColorName}) Reply: ${computerMove.san}
My Internal Thoughts (Data):
- Pre-Eval (Before User Move): ${preScore} cp
- Post-Eval (After My Reply): ${postScore} cp
- Delta: ${delta} cp
+(Note: Scores are from White's perspective. Positive = White advantage, Negative = Black advantage.)
INSTRUCTIONS:
1. ${evalInstruction}
diff --git a/src/lib/i18n/translations.ts b/src/lib/i18n/translations.ts
index 8b94201..4884a76 100644
--- a/src/lib/i18n/translations.ts
+++ b/src/lib/i18n/translations.ts
@@ -8,6 +8,7 @@ export interface Translations {
confirm: string;
loading: string;
error: string;
+ save: string;
};
// Header
@@ -104,6 +105,7 @@ const en: Translations = {
confirm: 'Confirm',
loading: 'Loading...',
error: 'Error',
+ save: 'Save',
},
header: {
tagline: 'with Gemini & Stockfish',
@@ -186,6 +188,7 @@ const de: Translations = {
confirm: 'Bestätigen',
loading: 'Lädt...',
error: 'Fehler',
+ save: 'Speichern',
},
header: {
tagline: 'mit Gemini & Stockfish',
@@ -268,6 +271,7 @@ const fr: Translations = {
confirm: 'Confirmer',
loading: 'Chargement...',
error: 'Erreur',
+ save: 'Enregistrer',
},
header: {
tagline: 'avec Gemini & Stockfish',
@@ -350,6 +354,7 @@ const it: Translations = {
confirm: 'Conferma',
loading: 'Caricamento...',
error: 'Errore',
+ save: 'Salva',
},
header: {
tagline: 'con Gemini & Stockfish',
diff --git a/src/lib/stockfish.ts b/src/lib/stockfish.ts
index f31b037..afaf4f0 100644
--- a/src/lib/stockfish.ts
+++ b/src/lib/stockfish.ts
@@ -27,7 +27,7 @@ export class Stockfish {
}
async evaluate(fen: string, depth: number = 15, multiPV: number = 1): Promise {
- return new Promise((resolve, reject) => {
+ return new Promise((resolve, reject) => {
if (!this.worker) {
reject("Stockfish worker not initialized");
return;
@@ -81,6 +81,15 @@ export class Stockfish {
this.worker.addEventListener("message", handler);
this.worker.postMessage(`position fen ${fen}`);
this.worker.postMessage(`go depth ${depth}`);
+ }).then((evalResult: StockfishEvaluation) => {
+ // Normalize score to be from White's perspective
+ // Stockfish returns score relative to side to move
+ const sideToMove = fen.split(" ")[1]; // 'w' or 'b'
+ if (sideToMove === 'b') {
+ if (evalResult.score !== 0) evalResult.score = -evalResult.score;
+ if (evalResult.mate !== null && evalResult.mate !== 0) evalResult.mate = -evalResult.mate;
+ }
+ return evalResult;
});
}