feat: wordle animation on guess

This commit is contained in:
sBubshait 2025-08-03 13:15:07 +03:00
parent 2b64d4ad50
commit 251bb79f0a

View File

@ -13,18 +13,26 @@ class _WordlePageState extends State<WordlePage> with TickerProviderStateMixin {
static const int MAX_ATTEMPTS = 6; static const int MAX_ATTEMPTS = 6;
static const int WORD_LENGTH = 5; static const int WORD_LENGTH = 5;
List<List<String>> grid = List.generate(MAX_ATTEMPTS, (_) => List.filled(WORD_LENGTH, '')); List<List<String>> grid = List.generate(
List<List<LetterState>> gridStates = List.generate(MAX_ATTEMPTS, (_) => List.filled(WORD_LENGTH, LetterState.empty)); MAX_ATTEMPTS,
(_) => List.filled(WORD_LENGTH, ''),
);
List<List<LetterState>> gridStates = List.generate(
MAX_ATTEMPTS,
(_) => List.filled(WORD_LENGTH, LetterState.empty),
);
Map<String, LetterState> keyboardStates = {}; Map<String, LetterState> keyboardStates = {};
int currentRow = 0; int currentRow = 0;
int currentCol = 0; int currentCol = 0;
int animatingRow = -1;
bool gameWon = false; bool gameWon = false;
bool gameLost = false; bool gameLost = false;
late List<AnimationController> _flipControllers; late AnimationController _revealController;
late List<Animation<double>> _flipAnimations; late List<Animation<double>> _tileAnimations;
late FocusNode _focusNode; late FocusNode _focusNode;
Set<LogicalKeyboardKey> _pressedKeys = <LogicalKeyboardKey>{};
@override @override
void initState() { void initState() {
@ -32,7 +40,7 @@ class _WordlePageState extends State<WordlePage> with TickerProviderStateMixin {
_initializeAnimations(); _initializeAnimations();
_initializeKeyboardStates(); _initializeKeyboardStates();
_focusNode = FocusNode(); _focusNode = FocusNode();
// Request focus for keyboard input on web/desktop // Request focus for keyboard input on web/desktop
if (kIsWeb || (!kIsWeb && !Platform.isIOS && !Platform.isAndroid)) { if (kIsWeb || (!kIsWeb && !Platform.isIOS && !Platform.isAndroid)) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@ -42,18 +50,24 @@ class _WordlePageState extends State<WordlePage> with TickerProviderStateMixin {
} }
void _initializeAnimations() { void _initializeAnimations() {
_flipControllers = List.generate( _revealController = AnimationController(
duration: Duration(milliseconds: 1500),
vsync: this,
);
_tileAnimations = List.generate(
WORD_LENGTH, WORD_LENGTH,
(index) => AnimationController( (index) => Tween<double>(begin: 0.0, end: 1.0).animate(
duration: Duration(milliseconds: 600), CurvedAnimation(
vsync: this, parent: _revealController,
curve: Interval(
index * 0.1,
(index * 0.1) + 0.5,
curve: Curves.elasticOut,
),
),
), ),
); );
_flipAnimations = _flipControllers.map((controller) =>
Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: controller, curve: Curves.easeInOut)
)
).toList();
} }
void _initializeKeyboardStates() { void _initializeKeyboardStates() {
@ -64,15 +78,16 @@ class _WordlePageState extends State<WordlePage> with TickerProviderStateMixin {
@override @override
void dispose() { void dispose() {
for (var controller in _flipControllers) { _revealController.dispose();
controller.dispose();
}
_focusNode.dispose(); _focusNode.dispose();
super.dispose(); super.dispose();
} }
void _addLetter(String letter) { void _addLetter(String letter) {
if (currentCol < WORD_LENGTH && currentRow < MAX_ATTEMPTS && !gameWon && !gameLost) { if (currentCol < WORD_LENGTH &&
currentRow < MAX_ATTEMPTS &&
!gameWon &&
!gameLost) {
setState(() { setState(() {
grid[currentRow][currentCol] = letter; grid[currentRow][currentCol] = letter;
currentCol++; currentCol++;
@ -94,26 +109,23 @@ class _WordlePageState extends State<WordlePage> with TickerProviderStateMixin {
Future<void> _submitWord() async { Future<void> _submitWord() async {
if (currentCol == WORD_LENGTH && !gameWon && !gameLost) { if (currentCol == WORD_LENGTH && !gameWon && !gameLost) {
String guess = grid[currentRow].join(''); String guess = grid[currentRow].join('');
// Animate the flip
for (int i = 0; i < WORD_LENGTH; i++) {
await Future.delayed(Duration(milliseconds: 100));
_flipControllers[i].forward();
}
await Future.delayed(Duration(milliseconds: 300));
List<LetterState> newStates = _evaluateGuess(guess); List<LetterState> newStates = _evaluateGuess(guess);
// Set the states and start animating the current row
setState(() { setState(() {
gridStates[currentRow] = newStates; gridStates[currentRow] = newStates;
_updateKeyboardStates(guess, newStates); _updateKeyboardStates(guess, newStates);
animatingRow = currentRow;
}); });
// Reset animations // Start the reveal animation
for (var controller in _flipControllers) { _revealController.reset();
controller.reset(); await _revealController.forward();
}
// Clear the animating row and move to next row
setState(() {
animatingRow = -1;
});
if (guess == TARGET_WORD) { if (guess == TARGET_WORD) {
setState(() { setState(() {
@ -172,7 +184,8 @@ class _WordlePageState extends State<WordlePage> with TickerProviderStateMixin {
LetterState newState = states[i]; LetterState newState = states[i];
if (currentState == LetterState.correct) continue; if (currentState == LetterState.correct) continue;
if (currentState == LetterState.present && newState == LetterState.absent) continue; if (currentState == LetterState.present && newState == LetterState.absent)
continue;
keyboardStates[letter] = newState; keyboardStates[letter] = newState;
} }
@ -185,14 +198,19 @@ class _WordlePageState extends State<WordlePage> with TickerProviderStateMixin {
builder: (BuildContext context) { builder: (BuildContext context) {
return AlertDialog( return AlertDialog(
title: Text(won ? 'Congratulations!' : 'Game Over'), title: Text(won ? 'Congratulations!' : 'Game Over'),
content: Text(won ? 'You solved today\'s puzzle!' : 'The word was: $TARGET_WORD'), content: Text(
won ? 'You solved today\'s puzzle!' : 'The word was: $TARGET_WORD',
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
_resetGame(); _resetGame();
}, },
child: Text('Play Again', style: TextStyle(color: Color(0xFF6A4C93))), child: Text(
'Play Again',
style: TextStyle(color: Color(0xFF6A4C93)),
),
), ),
], ],
); );
@ -203,9 +221,13 @@ class _WordlePageState extends State<WordlePage> with TickerProviderStateMixin {
void _resetGame() { void _resetGame() {
setState(() { setState(() {
grid = List.generate(MAX_ATTEMPTS, (_) => List.filled(WORD_LENGTH, '')); grid = List.generate(MAX_ATTEMPTS, (_) => List.filled(WORD_LENGTH, ''));
gridStates = List.generate(MAX_ATTEMPTS, (_) => List.filled(WORD_LENGTH, LetterState.empty)); gridStates = List.generate(
MAX_ATTEMPTS,
(_) => List.filled(WORD_LENGTH, LetterState.empty),
);
currentRow = 0; currentRow = 0;
currentCol = 0; currentCol = 0;
animatingRow = -1;
gameWon = false; gameWon = false;
gameLost = false; gameLost = false;
_initializeKeyboardStates(); _initializeKeyboardStates();
@ -225,166 +247,176 @@ class _WordlePageState extends State<WordlePage> with TickerProviderStateMixin {
_focusNode.requestFocus(); _focusNode.requestFocus();
} }
}, },
child: KeyboardListener( child: RawKeyboardListener(
focusNode: _focusNode, focusNode: _focusNode,
onKeyEvent: _handleKeyEvent, onKey: _handleRawKeyEvent,
child: Scaffold( child: Scaffold(
backgroundColor: Colors.white, backgroundColor: Colors.white,
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.white, backgroundColor: Colors.white,
elevation: 0, elevation: 0,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
centerTitle: true, centerTitle: true,
title: Row( title: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
'وصال', 'وصال',
style: TextStyle( style: TextStyle(
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.w200, fontWeight: FontWeight.w200,
fontFamily: 'Blaka', fontFamily: 'Blaka',
color: Color(0xFF6A4C93), color: Color(0xFF6A4C93),
), ),
),
SizedBox(width: 8),
Text(
'Daily Challenge',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.grey[600],
),
),
],
), ),
SizedBox(width: 8), bottom: PreferredSize(
Text( preferredSize: Size.fromHeight(1),
'Daily Challenge', child: Container(height: 1, color: Colors.grey[200]),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.grey[600],
),
), ),
], ),
), body: Column(
bottom: PreferredSize( children: [
preferredSize: Size.fromHeight(1), Expanded(
child: Container(height: 1, color: Colors.grey[200]), child: Center(
), child: Container(
), constraints: BoxConstraints(maxWidth: 350),
body: Column( child: Column(
children: [ mainAxisAlignment: MainAxisAlignment.center,
Expanded( children: [
child: Center( // Game Grid
child: Container( Container(
constraints: BoxConstraints(maxWidth: 350), padding: EdgeInsets.all(16),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, children: List.generate(MAX_ATTEMPTS, (row) {
children: [ return Padding(
// Game Grid padding: EdgeInsets.symmetric(vertical: 2),
Container( child: Row(
padding: EdgeInsets.all(16), mainAxisAlignment: MainAxisAlignment.center,
child: Column( children: List.generate(WORD_LENGTH, (col) {
children: List.generate(MAX_ATTEMPTS, (row) { bool shouldAnimate =
return Padding( row == animatingRow &&
padding: EdgeInsets.symmetric(vertical: 2), gridStates[row][col] !=
child: Row( LetterState.empty;
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(WORD_LENGTH, (col) { return Container(
return Container( margin: EdgeInsets.all(2),
margin: EdgeInsets.all(2), width: 62,
width: 62, height: 62,
height: 62, child: shouldAnimate
child: AnimatedBuilder( ? AnimatedBuilder(
animation: _flipAnimations[col], animation: _tileAnimations[col],
builder: (context, child) { builder: (context, child) {
// Check if this tile should be animating double scale =
bool isCurrentRowAndAnimating = row == currentRow && _flipAnimations[col].value > 0; 0.8 +
(0.2 *
// If not animating, show the final state _tileAnimations[col]
if (!isCurrentRowAndAnimating) { .value);
return Container( return Transform.scale(
width: 62, scale: scale,
height: 62, child: Container(
decoration: BoxDecoration( width: 62,
border: Border.all( height: 62,
color: _getBorderColor(row, col), decoration: BoxDecoration(
width: 2, border: Border.all(
), color:
color: _getTileColor(row, col), _getBorderColor(
), row,
child: Center( col,
child: Text( ),
grid[row][col], width: 2,
style: TextStyle( ),
fontSize: 32, color:
fontWeight: FontWeight.bold, _getAnimatedTileColor(
color: _getTextColor(row, col), row,
col,
_tileAnimations[col]
.value,
),
borderRadius:
BorderRadius.circular(
4,
),
),
child: Center(
child: Text(
grid[row][col],
style: TextStyle(
fontSize: 32,
fontWeight:
FontWeight.bold,
color: _getAnimatedTextColor(
row,
col,
_tileAnimations[col]
.value,
),
),
),
),
),
);
},
)
: Container(
width: 62,
height: 62,
decoration: BoxDecoration(
border: Border.all(
color: _getBorderColor(
row,
col,
),
width: 2,
),
color: _getTileColor(
row,
col,
),
borderRadius:
BorderRadius.circular(4),
), ),
), child: Center(
), child: Text(
); grid[row][col],
} style: TextStyle(
fontSize: 32,
// Animation is running - create flip effect fontWeight:
double progress = _flipAnimations[col].value; FontWeight.bold,
double rotationX = progress * 3.14159; color: _getTextColor(
row,
// Determine colors to show during animation col,
bool showFinalColors = progress > 0.5; ),
Color bgColor = showFinalColors ? _getTileColor(row, col) : Colors.white; ),
Color textColor = showFinalColors ? _getTextColor(row, col) : Colors.black;
return Container(
width: 62,
height: 62,
decoration: BoxDecoration(
border: Border.all(
color: _getBorderColor(row, col),
width: 2,
),
color: Colors.white,
),
child: Stack(
children: [
// Animated background that flips
Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateX(rotationX),
child: Container(
width: 62,
height: 62,
color: bgColor,
),
),
// Text that always stays upright and visible
Center(
child: Text(
grid[row][col],
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: textColor,
), ),
), ),
), ),
], );
), }),
); ),
}, );
), }),
); ),
}), ),
), ],
);
}),
),
), ),
], ),
), ),
), ),
), // Keyboard
Container(padding: EdgeInsets.all(8), child: _buildKeyboard()),
],
), ),
// Keyboard
Container(
padding: EdgeInsets.all(8),
child: _buildKeyboard(),
),
],
),
), ),
), ),
), ),
@ -407,7 +439,7 @@ class _WordlePageState extends State<WordlePage> with TickerProviderStateMixin {
children: row.map((key) { children: row.map((key) {
bool isSpecial = key == 'ENTER' || key == ''; bool isSpecial = key == 'ENTER' || key == '';
double width = isSpecial ? 65 : 35; double width = isSpecial ? 65 : 35;
return Container( return Container(
margin: EdgeInsets.symmetric(horizontal: 1.5), margin: EdgeInsets.symmetric(horizontal: 1.5),
width: width, width: width,
@ -449,23 +481,19 @@ class _WordlePageState extends State<WordlePage> with TickerProviderStateMixin {
} }
} }
bool _handleKeyEvent(KeyEvent event) { void _handleRawKeyEvent(RawKeyEvent event) {
if (event is KeyDownEvent) { if (event is RawKeyDownEvent) {
if (event.logicalKey == LogicalKeyboardKey.enter) { if (event.logicalKey == LogicalKeyboardKey.enter) {
_submitWord(); _submitWord();
return true;
} else if (event.logicalKey == LogicalKeyboardKey.backspace) { } else if (event.logicalKey == LogicalKeyboardKey.backspace) {
_deleteLetter(); _deleteLetter();
return true; } else if (event.character != null && event.character!.isNotEmpty) {
} else if (event.logicalKey.keyLabel.length == 1) { String key = event.character!.toUpperCase();
String key = event.logicalKey.keyLabel.toUpperCase();
if (key.codeUnitAt(0) >= 65 && key.codeUnitAt(0) <= 90) { if (key.codeUnitAt(0) >= 65 && key.codeUnitAt(0) <= 90) {
_addLetter(key); _addLetter(key);
return true;
} }
} }
} }
return false;
} }
Color _getBorderColor(int row, int col) { Color _getBorderColor(int row, int col) {
@ -482,7 +510,7 @@ class _WordlePageState extends State<WordlePage> with TickerProviderStateMixin {
Color _getTileColor(int row, int col) { Color _getTileColor(int row, int col) {
if (row >= MAX_ATTEMPTS || col >= WORD_LENGTH) return Colors.white; if (row >= MAX_ATTEMPTS || col >= WORD_LENGTH) return Colors.white;
if (row >= currentRow && !gameWon) return Colors.white; if (row >= currentRow && !gameWon) return Colors.white;
switch (gridStates[row][col]) { switch (gridStates[row][col]) {
case LetterState.correct: case LetterState.correct:
return Color(0xFF6AAE7C); return Color(0xFF6AAE7C);
@ -498,14 +526,53 @@ class _WordlePageState extends State<WordlePage> with TickerProviderStateMixin {
Color _getTextColor(int row, int col) { Color _getTextColor(int row, int col) {
if (row >= MAX_ATTEMPTS || col >= WORD_LENGTH) return Colors.black; if (row >= MAX_ATTEMPTS || col >= WORD_LENGTH) return Colors.black;
if (row >= currentRow && !gameWon) return Colors.black; if (row >= currentRow && !gameWon) return Colors.black;
return gridStates[row][col] == LetterState.empty ? Colors.black : Colors.white; return gridStates[row][col] == LetterState.empty
? Colors.black
: Colors.white;
}
Color _getAnimatedTileColor(int row, int col, double animationValue) {
if (row >= MAX_ATTEMPTS || col >= WORD_LENGTH) return Colors.white;
if (row != animatingRow || gridStates[row][col] == LetterState.empty) {
return _getTileColor(row, col);
}
// Show color based on animation progress
Color targetColor;
switch (gridStates[row][col]) {
case LetterState.correct:
targetColor = Color(0xFF6AAE7C);
break;
case LetterState.present:
targetColor = Color(0xFFC9B037);
break;
case LetterState.absent:
targetColor = Color(0xFF787C7E);
break;
default:
targetColor = Colors.white;
}
return Color.lerp(Colors.white, targetColor, animationValue) ??
Colors.white;
}
Color _getAnimatedTextColor(int row, int col, double animationValue) {
if (row >= MAX_ATTEMPTS || col >= WORD_LENGTH) return Colors.black;
if (row != animatingRow || gridStates[row][col] == LetterState.empty) {
return _getTextColor(row, col);
}
// Text color changes with animation
return Color.lerp(Colors.black, Colors.white, animationValue) ??
Colors.black;
} }
Color _getKeyColor(String key) { Color _getKeyColor(String key) {
if (key == 'ENTER' || key == '') { if (key == 'ENTER' || key == '') {
return Colors.grey[300]!; return Colors.grey[300]!;
} }
LetterState state = keyboardStates[key] ?? LetterState.empty; LetterState state = keyboardStates[key] ?? LetterState.empty;
switch (state) { switch (state) {
case LetterState.correct: case LetterState.correct:
@ -523,15 +590,10 @@ class _WordlePageState extends State<WordlePage> with TickerProviderStateMixin {
if (key == 'ENTER' || key == '') { if (key == 'ENTER' || key == '') {
return Colors.black; return Colors.black;
} }
LetterState state = keyboardStates[key] ?? LetterState.empty; LetterState state = keyboardStates[key] ?? LetterState.empty;
return state == LetterState.empty ? Colors.black : Colors.white; return state == LetterState.empty ? Colors.black : Colors.white;
} }
} }
enum LetterState { enum LetterState { empty, absent, present, correct }
empty,
absent,
present,
correct,
}