Closed davidcr01 closed 1 year ago
To generate this component, use the command ionic g component components/wordle-dashboard
. This will be a component because it will be reused in several parts of the platform. Besides, it will change dynamically due to the change of the word length.
The main HTML structure of the component is simple: the guess tries part, where the words are written, and the virtual keyboard part.
The words part:
<div class="container" tabindex="0" (keyup)="handleKeyboardKeyUp($event)">
<ion-grid>
<ion-row class="flex-center">
<ion-col size="9" class="game-board">
<ion-row *ngFor="let row of letterRows">
<ion-col *ngFor="let box of row" class="letter-box" [class.filled-box]="box.filled">
{{ box.content }}
</ion-col>
</ion-row>
</ion-col>
</ion-row>
</ion-grid>
Notice that:
keyup
event. This allows using a fiscal keyboard in the game.The keyboard part:
<div id="keyboard">
<ion-row>
<div class="keyboard-button-row">
<button class="keyboard-button" *ngFor="let letter of firstRow" (click)="handleKeyboardButtonClick(letter)">{{ letter }}</button>
</div>
</ion-row>
<ion-row>
<div class="keyboard-button-row">
<button class="keyboard-button" *ngFor="let letter of secondRow" (click)="handleKeyboardButtonClick(letter)">{{ letter }}</button>
</div>
</ion-row>
<ion-row>
<div class="keyboard-button-row">
<button class="keyboard-button" *ngFor="let letter of thirdRow" (click)="handleKeyboardButtonClick(letter)">{{ letter }}</button>
</div>
</ion-row>
<ion-row>
<div class="keyboard-button-row">
<button class="keyboard-button" (click)="handleKeyboardButtonClick('delete')">Del</button>
<button class="keyboard-button" (click)="handleKeyboardButtonClick('enter')">Enter</button>
</div>
</ion-row>
</div>
It is similar to the previous part, it defines rows for every row of keys on the keyboard, and a row specifically for the special buttons (delete and intro keys).
⚠️ The iterable variables must be declared in the TS file.
The components uses various variables to implement the game:
export class WordleDashboardComponent implements OnInit {
public letterRows: LetterBox[][];
public keyboardLetters: string[];
public firstRow: string[] = ['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'];
public secondRow: string[] = ['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'];
public thirdRow: string[] = ['z', 'x', 'c', 'v', 'b', 'n', 'm'];
private readonly MAX_GUESSES = 6;
@Input() WORDS_LENGTH: number;
private wordsOfDesiredLength: string[];
private rightGuessString: string;
private guessesRemaining: number;
private currentGuess: string[];
private nextLetter: number;
private successColor: string;
It defines the rows of the keyboard used in the HTML file, the max number of guesses, the length of the word (which is an input of the component), among others...
The ngOnInit
function contains:
ngOnInit(): void {
this.generateWord();
this.initGame();
this.getSuccesseColor();
}
// Reads the JSON file of words and select a random one with a specified length
private generateWord(): void {
this.http.get<any>('assets/words.json').subscribe(
(wordsData) => {
this.wordsOfDesiredLength = wordsData[this.WORDS_LENGTH];
if (!this.wordsOfDesiredLength || this.wordsOfDesiredLength.length === 0) {
throw new Error(`No words found for length ${this.WORDS_LENGTH}`);
}
this.rightGuessString = this.selectRandomWordByLength(this.WORDS_LENGTH);
},
(error) => {
throw new Error(`Failed to load words data: ${error.message}`);
}
);
}
// Select a word from the list of words of desired length
private selectRandomWordByLength(length: number): string {
const wordsOfDesiredLength = this.wordsOfDesiredLength;
if (wordsOfDesiredLength && wordsOfDesiredLength.length > 0) {
const randomIndex = Math.floor(Math.random() * wordsOfDesiredLength.length);
const selectedWord = wordsOfDesiredLength[randomIndex];
console.log(selectedWord);
return selectedWord;
} else {
throw new Error(`No words found for length ${length}`);
}
}
The chosen word ill be stored in the this.rightGuessString
variable.
private initGame(): void {
this.guessesRemaining = this.MAX_GUESSES;
this.currentGuess = [];
this.nextLetter = 0;
this.letterRows = Array.from({ length: this.MAX_GUESSES }, () => Array.from({ length: this.WORDS_LENGTH }, () => ({
content: '',
filled: false
})));
this.keyboardLetters = ['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'z', 'x', 'c', 'v', 'b', 'n', 'm'];
}
Notice that the array has a pair values, the content and if it's filled or not.
--ion-color-purple
variable defined in variable.scss
, we need to access to the document.private getSuccesseColor(): void {
const root = document.documentElement;
this.successColor = getComputedStyle(root).getPropertyValue('--ion-color-purple').trim();
}
To finish the implementation of the game, some important functions are added:
deleteLetter
: it deletes the letter which is recently introduced. This implies to mark the box as not filled, and remove the letter of the string that is building the user:private deleteLetter(): void {
const currentRow = this.MAX_GUESSES - this.guessesRemaining;
const boxIndex = Math.max(0, this.nextLetter - 1);
// Access to legal position of the matrix
if (currentRow >= 0 && currentRow < this.letterRows.length && boxIndex >= 0 && boxIndex < this.letterRows[currentRow].length) {
const box = this.letterRows[currentRow][boxIndex];
box.content = '';
box.filled = false;
}
// Delete the letter from the word
this.currentGuess.pop();
this.nextLetter = Math.max(0, this.nextLetter - 1);
}
handleKeyboardButtonClick(letter: string)
: it handles the letter introduced by the user, either by clicking on the virtual keyboard or the fiscal (this last will need the handleKeyboardKeyUp
function too).
public handleKeyboardButtonClick(letter: string): void {
if (letter === 'delete' || letter === 'backspace') {
this.deleteLetter();
} else if (letter === 'enter') {
this.checkGuess();
} else { // Controlling max number of letters in input
if (this.currentGuess.length >= this.WORDS_LENGTH) {
this.showToast('Max letters reached!');
return;
}
const currentRow = this.MAX_GUESSES - this.guessesRemaining;
const boxIndex = this.nextLetter;
// Access to legal position of the matrix
if (currentRow >= 0 && currentRow < this.letterRows.length && boxIndex >= 0 && boxIndex < this.letterRows[currentRow].length) {
const box = this.letterRows[currentRow][boxIndex];
box.content = letter;
box.filled = true;
}
this.currentGuess.push(letter);
this.nextLetter++;
}
}
⚠️ Notice that in every moment is necessary to control in which row and box we are written. This is fundamental to the correct behavior of the game.
handleKeyboardKeyUp
, is the main function that allows the physical keyboard works. It listens to a keyup event, fetches the pressed key, and passes that key to the handleKeyboardButtonClick
function. By this, we have a centralized function that manages either the virtual or physical keyboard.// Allows using the keyboard
@HostListener('window:keyup', ['$event'])
handleKeyboardKeyUp(event: KeyboardEvent): void {
const pressedKey = event.key.toUpperCase();
event.stopPropagation();
if (/^[A-Z]$/.test(pressedKey) || pressedKey === 'DELETE' || pressedKey === 'BACKSPACE' || pressedKey === 'ENTER') {
event.preventDefault();
// Calls the existing function of the virtual keyboard
this.handleKeyboardButtonClick(pressedKey.toLowerCase());
}
}
Notice that is necessary to add the event.stopPropagation();
. This line avoids fetching the key twice, as the event is propagated through the DOM elements and stays in the current element.
checkGuess
function is executed when the user presses intro. It checks various conditionals:
Besides, it colors the boxes depending on the introduced word. If the letter is in the correct place, is colored with the this.successColor
color, if is not in the correct place, in orange, and in another case, in gray. It reduces the remaining guesses, it animates the dashboard using the animateCSS
function and it checks if the player has won or lost the game.
animateCSS
function animates an HTML object with a specified animation. It adds to the HTML object the animation and handles its animation end.animateCSS(element, animation, prefix = 'animate__') {
return new Promise((resolve, reject) => {
const animationName = `${prefix}${animation}`;
const node = element;
node.style.setProperty('--animate-duration', '0.3s');
node.classList.add(`${prefix}animated`, animationName);
function handleAnimationEnd(event) {
event.stopPropagation();
node.classList.remove(`${prefix}animated`, animationName);
resolve('Animation ended');
}
node.addEventListener('animationend', handleAnimationEnd, { once: true });
});
}
This code is encapsulated in a component. It can be used as:
<ion-content>
<app-wordle-dashboard [WORDS_LENGTH]="wordLength"></app-wordle-dashboard>
</ion-content>
Notice that a parameter is added, WORDS_LENGTH
. It specifies the length of the word of the game. In this way, we are preparing the code for https://github.com/davidcr01/WordlePlus/milestone/4.
This allows the component to change its appearance (the boxes part) and functionality.
The result is the following:
It is very similar to the original Wordle, but applies the application theme.
✔️ Example of a normal game, it shows the use of the physical and virtual keyboards, intro and delete buttons, animation and appearance functionality.
https://github.com/davidcr01/WordlePlus/assets/72193239/21802550-2805-4cab-9bb6-ad5c62134e73
✔️ Examples of possible errors: introducing too many letters, not enough letters, and non-sense words. Notice how the toast appears at the top of the screen.
https://github.com/davidcr01/WordlePlus/assets/72193239/c6f78750-f503-431d-a02a-cb42f1131e33
To store the information of the games that the player plays as a solo (classic Wordle) is necessary to add a new model and its related serializer and views.
This model will be also useful when implementing the history of games of the player.
In the models.py
file:
class ClassicWordle(models.Model):
player = models.ForeignKey(Player, on_delete=models.CASCADE, related_name='classic_wordle_games')
word = models.CharField(max_length=255)
time_consumed = models.PositiveIntegerField()
attempts = models.PositiveIntegerField()
xp_gained = models.PositiveIntegerField()
date_played = models.DateTimeField(default=timezone.now)
win = models.BooleanField(default=False)
We need to store every information of the game: the word the player is guessing, the number of attempts, the time the player has consumed, the xp gained (calculated in the frontend), the date the player played...
⚠️ After this, don't forget to make and apply the migrations by using the docker-compose exec dj python manage.py makemigrations djapi
and docker-compose exec dj python manage.py migrate djapi
commands.
In the serializers.py
file:
class ClassicWordleSerializer(serializers.ModelSerializer):
class Meta:
model = ClassicWordle
fields = ['player', 'word', 'time_consumed', 'attempts', 'xp_gained', 'date_played', 'win']
def create(self, validated_data):
player = validated_data['player']
is_winner = validated_data['win']
xp = validated_data['xp_gained']
# Increment the number of victories and add the xp_gained
if is_winner:
player.wins += 1
player.xp += xp
player.save()
return ClassicWordle.objects.create(**validated_data)
Notice that the wins and xp is incremented in the player information.
In the views.py
file:
class ClassicWordleViewSet(viewsets.GenericViewSet):
"""
API endpoint that allows list, retrieve, and create operations for classic wordle games of players.
"""
permission_classes = [IsOwnerOrAdminPermission]
queryset = ClassicWordle.objects.all()
serializer_class = ClassicWordleSerializer
def list(self, request):
player_id = request.query_params.get('player_id')
if not player_id:
return Response({'error': 'player_id parameter is required'}, status=400)
player = get_object_or_404(Player, id=player_id)
queryset = ClassicWordle.objects.filter(player=player).order_by('-date_played')
serializer = ClassicWordleSerializer(queryset, many=True)
return Response(serializer.data)
def create(self, request):
serializer = ClassicWordleSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save(player=request.user.player)
return Response(serializer.data)
And in the urls.py
file we add router.register(r'api/classicwordles', views.ClassicWordleViewSet)
.
GenericViewSet
. In this way, we are not implementing all the HTTP requests automatically, only the defined ones. In this case, we are only interested in the GET and POST requests (list and create methods).player_id
, to retrieve the information.By doing this, we have the following URL available:
POST http://localhost:8080/api/classicwordles/
BODY:
{
"player": 1,
"word": "apple",
"win": true,
"time_consumed": 120,
"attempts": 4,
"xp_gained": 50
}
RESPONSE:
{
"player": 1,
"word": "apple",
"time_consumed": 120,
"attempts": 4,
"xp_gained": 50,
"date_played": "2023-06-26T20:19:13.732827+02:00",
"win": true
}
GET http://localhost:8080/api/classicwordles/?player_id=1
RESPONSE:
[
{
"player": 1,
"word": "apple",
"time_consumed": 120,
"attempts": 4,
"xp_gained": 50,
"date_played": "2023-06-25T17:07:38.356661+02:00",
"win": false
},
{
"player": 1,
"word": "rocket",
"time_consumed": 72,
"attempts": 5,
"xp_gained": 150,
"date_played": "2023-06-25T16:20:41+02:00",
"win": false
}
]
When the game is finished, is necessary to register the game in the backend. To do this, the checkGuess
function of the wordle-component
component has been modified:
this.nextLetter = 0;
this.guessesRemaining--;
if (this.rightGuessString === guessString) {
this.handleEndgame(true);
} else if (this.guessesRemaining === 0) {
this.handleEndgame(false);
}
this.currentGuess = [];
And a new function has been added:
private async handleEndgame(won: boolean) {
const playerId = await this.storageService.getPlayerID();
const timeConsumed = this.calculateTimeConsumed();
const attempsConsumed = this.MAX_GUESSES - this.guessesRemaining;
const xP = this.calculateExperience(timeConsumed, attempsConsumed, this.WORDS_LENGTH, won);
if (playerId !== null) {
const body = {
word: this.rightGuessString,
attempts: attempsConsumed,
player: playerId,
time_consumed: timeConsumed,
win: won,
xp_gained: xP,
};
(await
// API call
this.apiService.addClassicGame(body)).subscribe(
(response) => {
console.log('Game added successfully', response);
},
(error) => {
console.log('Game could not be added', error);
});
if (won) {
setTimeout(() => {
this.toastService.showToast('You won!', 2000, 'top');
this.initGame();
}, 250 * this.WORDS_LENGTH + 3000);
} else {
setTimeout(() => {
this.toastService.showToast('You lost!', 2000, 'top');
this.initGame();
}, 250 * this.WORDS_LENGTH + 3000);
}
}
}
This function recollects all the game information and gives it to the backend, using the URL defined previously.
Notice that:
calculateTimeConsumed
has been added. This functions defines the time between the start of the game and the end of the game.
private calculateTimeConsumed(): number {
const endTime = Date.now();
const timeInSeconds = Math.floor((endTime - this.startTime) / 1000);
return timeInSeconds;
}
A new function calculateExperience
has been added. The function calculates the gained experience depending on the statistics of the game:
private calculateExperience(timeDiffInSeconds: number, numGuesses: number, wordLength: number, hasWon: boolean): number {
const baseExperience = 100;
const timeMultiplier = 10;
const guessesMultiplier = 5;
const lengthMultiplier = 10;
const lossExperienceMultiplier = 0.5;
const timeExperience = baseExperience - timeDiffInSeconds * timeMultiplier;
const guessesExperience = baseExperience - numGuesses * guessesMultiplier;
const lengthExperience = baseExperience + wordLength * lengthMultiplier;
let totalExperience = timeExperience + guessesExperience + lengthExperience;
if (!hasWon) {
totalExperience *= lossExperienceMultiplier;
}
return totalExperience > 0 ? totalExperience : 0;
}
A new API method has been added to the ApiService
, addClassicGame
, which calls the URL to add a classic game, attaching the access token:
async addClassicGame(gameData: any): Promise<Observable<any>> {
let url = `${this.baseURL}/api/classicwordles/`;
const accessToken = this.storageService.getAccessToken();
if (!accessToken) {
return throwError('Access token not found');
}
const decryptedToken = this.encryptionService.decryptData(await accessToken);
const headers = new HttpHeaders({'Authorization': `Token ${decryptedToken}`});
return this.http.post(url, gameData, { headers });
}
If we play a classic wordle, we will obtain the following results:
To protect the platform URLs that need authentication, a new authguard has been added:
export class AuthGuard implements CanActivate {
constructor(
private router: Router,
private storageService: StorageService,
private apiService: ApiService
) {}
async canActivate(): Promise<boolean> {
const accessToken = await this.storageService.getAccessToken();
if (accessToken) {
return (await this.apiService.checkTokenExpiration()).pipe(
catchError((error: HttpErrorResponse) => {
console.log('in error block');
if (error.status === 401 && error.error && error.error.detail === 'Invalid token.') {
this.storageService.destroyAll();
this.router.navigate(['/login']);
} else {
console.error('Error al comprobar el token de acceso:', error);
}
return of(false);
})
).toPromise();
} else {
this.router.navigate(['/login']);
return false;
}
}
}
This guard assets that the access token exists and is not expired. Besides, a new URL in the backend has added to check the expiration:
class CheckTokenExpirationView(APIView):
def get(self, request):
token = request.user.auth_token
if token.created < timezone.now() - timedelta(seconds=settings.TOKEN_EXPIRED_AFTER_SECONDS):
# El token ha expirado
token.delete()
return Response({'message': 'Token has expired.'}, status=status.HTTP_401_UNAUTHORIZED)
return Response({'message': 'Token is valid.'}, status=status.HTTP_200_OK)
path('check-token-expiration/', CheckTokenExpirationView.as_view(), name='check-token-expiration'),
By this, every time the user gets into a protected view of the frontend, its token will be checked. Notice that this develop is different from the middleware definition done in #15. The middleware checks the token when using the API, but this new develop checks the token when accessing different URLs of the frontend application.
Description
It is necessary to implement the Wordle game. This page will be similar to de UI design of the original game but adapted to the styles of the project.
Is necessary to implement the game with a flexible number of words, to prepare the implementation for the Advanced Wordle.
Tasks