davidcr01 / WordlePlus

Repository to store all the documentation, files and structure of my Final Degree Project (TFG in Spanish). The main goal is to develop, as full-stack web developer, a remodel of the Wordle game, including more features and functionalities using Ionic, Django REST Api and PostgreSQL.
1 stars 0 forks source link

Classic Wordle (S3) #22

Closed davidcr01 closed 1 year ago

davidcr01 commented 1 year ago

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

davidcr01 commented 1 year ago

Update Report - Frontend

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.

HTML structure

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:

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.

TS logic

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.

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:

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);
  }
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.

// 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.

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(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 });
    });
  }

Reusable component

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.

Results and tests

The result is the following: image

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

davidcr01 commented 1 year ago

Update Report - Backend

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).

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
  }
]
davidcr01 commented 1 year ago

Update Report

Communication between frontend and backend

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:

Testing

If we play a classic wordle, we will obtain the following results: image image image

AuthGuard

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.