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

Multiplayer (S8) #50

Closed davidcr01 closed 1 year ago

davidcr01 commented 1 year ago

Description

As a player, it is necessary to implement new functionality to play the wordle game with another player.

Tasks

To accept the games, is necessary to implement a list of pending games:

davidcr01 commented 1 year ago

Update Report - DRF

A new model has been added to store the multiplayer games called Games:

class Game(models.Model):
    player1 = models.ForeignKey(Player, on_delete=models.CASCADE, related_name='player1_wordle')
    player2 = models.ForeignKey(Player, on_delete=models.CASCADE, related_name='player2_wordle')
    word = models.CharField(max_length=255)
    player1_time = models.PositiveIntegerField(default=0)
    player1_attempts = models.PositiveIntegerField(default=0)
    player1_xp = models.PositiveIntegerField(default=0)
    player2_time = models.PositiveIntegerField(default=0)
    player2_attempts = models.PositiveIntegerField(default=0)
    player2_xp = models.PositiveIntegerField(default=0)
    winner = models.ForeignKey(Player, on_delete=models.SET_NULL, null=True, blank=True, related_name='winner')
    timestamp = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return f"{self.player1.user.username} - {self.player2.user.username}"

The serializer looks like this:

class GameDetailSerializer(serializers.ModelSerializer):
    player1 = serializers.SerializerMethodField()
    player2 = serializers.SerializerMethodField()

    class Meta:
        model = Game
        fields = ['id', 'player1', 'player2', 'player1_time', 'player2_time', 'player1_xp',
                  'player2_xp', 'timestamp', 'word', 'player1_attempts', 'player2_attempts',
                  'winner']

    def get_player1(self, obj):
        return obj.player1.user.username

    def get_player2(self, obj):
        return obj.player2.user.username

class GameCreateSerializer(serializers.ModelSerializer):
    player2 = serializers.SerializerMethodField()
    class Meta:
        model = Game
        fields = ['id', 'player2', 'player1_time', 'player2_time', 'player1_xp',
                  'player2_xp', 'timestamp', 'word', 'player1_attempts', 'player2_attempts',
                  'winner']

    def get_player2(self, obj):
        return obj.player2.user.username

Notice that in the GameCreateSerializer player1 is not managed as is the player that creates the game, and it is fetched by the identified player.

The serializer will be selected in the view depending on the executed method:

def get_serializer_class(self):
        if self.action in ['list', 'retrieve', 'completed_games', 'pending_games']:
            return GameDetailSerializer
        elif self.action in ['create', 'partial_update']:
            return GameCreateSerializer
        return super().get_serializer_class()

About the view, the create method (executed by the player that creates the game) only allows the player1 statistics. To correctly create the game and link it to the player2, the ID of the second player must be included in the body.

def create(self, request, *args, **kwargs):
        player1 = getattr(request.user, 'player', None)
        if not player1:
            return Response({'error': 'Player1 not found'}, status=404)
        player2_id = request.data.get('player2')

        if not player2_id:
            return Response({'error': 'player2 parameter is required'}, status=400)

        try:
            player2 = Player.objects.get(id=player2_id)
        except Player.DoesNotExist:
            return Response({'error': 'Player2 not found'}, status=status.HTTP_404_NOT_FOUND)

        allowed_fields = ['player2', 'player1_xp', 'player1_time', 'player1_attempts', 'word']
        data = {key: request.data.get(key) for key in allowed_fields}

        if 'winner' in data:
            return Response({'error': 'The "winner" field cannot be modified.'}, status=400)

        serializer = self.get_serializer(data=data)
        serializer.is_valid(raise_exception=True)

        # Increment player XP
        player1.xp += serializer.validated_data['player1_xp']
        player1.save()
        serializer.save(player1=player1, player2=player2)

       # Create notification to the guest player
        Notification.objects.create(
            player=player2,
            text=f"You have been challenged by {player1.user.username}. Let's play!",
            link=''
        )

        return Response(serializer.data, status=201)

About the PATCH method, executed by the second player (when it accepts the challenge), it adds the information that is left and defines the winner.

def partial_update(self, request, *args, **kwargs):
        instance = self.get_object()
        player = request.user.player

        if instance.player2 != player:
            return Response({'error': 'You do not have permission to update this game.'}, status=403)
        if instance.winner is not None:
            return Response({'error': 'This game is already completed and cannot be modified.'}, status=400)

        allowed_fields = ['player2_xp', 'player2_time', 'player2_attempts']
        data = {key: request.data.get(key) for key in allowed_fields}

        if 'winner' in data:
            return Response({'error': 'The "winner" field cannot be modified.'}, status=400)

        player2_xp = request.data.get('player2_xp')
        player1_xp = instance.player1_xp
        player2_time = request.data.get('player2_time')
        player1_time = instance.player1_time

        if player2_xp is not None:
            player1_xp = instance.player1_xp
            if player2_xp > player1_xp:
                instance.winner = instance.player2
                instance.winner.wins_pvp += 1
                instance.winner.save()
            elif player2_xp < player1_xp:
                instance.winner = instance.player1
                instance.winner.wins_pvp += 1
                instance.winner.save()
            else:
                if player2_time <= player1_time:
                    instance.winner = instance.player2
                    instance.winner.wins_pvp += 1
                    instance.winner.save()
                else:
                    instance.winner = instance.player1
                    instance.winner.wins_pvp += 1
                    instance.winner.save()

        serializer = self.get_serializer(instance, data=data, partial=True)
        serializer.is_valid(raise_exception=True)
        serializer.save()

        return Response({'winner': instance.winner.user.username})

image Notice how the player2 information is passed to the backend and it ignores it. Besides, the notification is created:

image

When the second player plays the game, the information is passed to the backend and the winner is calculated and returned: image

Besides, new GET methods have been added to the views:

@action(detail=False, methods=['get'])
    def completed_games(self, request):
        player = getattr(request.user, 'player', None)
        if not player:
            return Response({'error': 'Player not found'}, status=404)

        queryset = Game.objects.filter(Q(player1=player) | Q(player2=player), ~Q(winner=None)).order_by('timestamp')
        serializer = self.get_serializer(queryset, many=True)
        return Response(serializer.data)

    # Pending games are those in which the winner is null and the player is the receiver (player2)
    @action(detail=False, methods=['get'])
    def pending_games(self, request):
        player = getattr(request.user, 'player', None)
        if not player:
            return Response({'error': 'Player not found'}, status=404)
        queryset = Game.objects.filter(player2=player, winner=None).order_by('timestamp')
        serializer = self.get_serializer(queryset, many=True)
        return Response(serializer.data)

    def retrieve(self, request, *args, **kwargs):
        instance = self.get_object()
        player = getattr(request.user, 'player', None)
        if not player:
            return Response({'error': 'Player not found'}, status=404)

        if player not in [instance.player1, instance.player2]:
            return Response({'error': 'You can not access to this game.'}, status=403)

Notice how the decorators are used to parameterize the GET method.

image

image

image

davidcr01 commented 1 year ago

Update Report - Frontend

Three new pages have been generated for the multiplayer:

<ion-content>
    <div class="game-info">
        <div class="username">{{ selfUsername }}</div>
        <img src="../../../assets/icon/vs.png" alt="VS icon">
        <div class="username">{{ opponentUsername }}</div>
      </div>

   <ng-container *ngIf="wordLength">
    <app-wordle-dashboard [WORDS_LENGTH]="wordLength" [isMultiplayer]="true" (gameFinished)="finishGame($event.time, $event.xp, $event.attempts, $event.selectedWord)"></app-wordle-dashboard>
   </ng-container>
</ion-content>

async ngOnInit() {
    this.selfUsername = await this.storageService.getUsername();
    this.route.queryParams.subscribe((params) => {
      this.opponentId = params['opponentId'];
      this.opponentUsername = params['opponentUsername'];
      this.wordLength = parseInt(params['length'],10);
    });
  }

  async finishGame(time: number, xp: number, attempts: number, selectedWord: string) {
    const gameData = {
      player2: this.opponentId,
      player1_time: time,
      player1_xp: xp,
      player1_attempts: attempts,
      word: selectedWord,
    };

    (await this.apiService.createGame(gameData)).subscribe(
      (response) => {
        console.log('Game registered successfully', response);
        this.toastService.showToast('Game registered successfully! Who will win?', 2000, 'top', 'success');
        setTimeout(() =>this.router.navigate(['/tabs/main'], { queryParams: { refresh: 'true' } }), 2500);
      },
      (error) => {
        console.error('Error creating game:', error);
      }
    );

About the HTML code, it displays the information of the multiplayer game and loads the wordle-dashboard component implemented in #22. About the TS logic, the finishGame method is executed when the dashboard component emit an event when the first user finishes the game, and the create-game page publish the results in the backend.

@Output() gameFinished: EventEmitter<any> = new EventEmitter();
...
// Case of multiplayer game
      if (this.isMultiplayer) {
        const gameFinishedEvent = {
          time: timeConsumed,
          xp: xP,
          attempts: attempsConsumed,
          selectedWord: this.rightGuessString,
        };
        this.gameFinished.emit(gameFinishedEvent);
<div *ngIf="pendingPvpGames && pendingPvpGames.length > 0">
      <ion-card *ngFor="let game of pendingPvpGames">
        <ion-card-header>
          <ion-icon name="flash"></ion-icon>
          {{ game.player1 }}
          <ion-icon name="flash"></ion-icon>
        </ion-card-header>
        <ion-card-content>
          <ion-list>
            <ion-item>
              <ion-icon name="hourglass-outline" slot="start"></ion-icon>
              <ion-label>Time</ion-label>
              {{ game.player1_time }}"
            </ion-item>
            <ion-item>
              <ion-icon name="text-outline" slot="start"></ion-icon>
              <ion-label>Word length</ion-label>
              {{ game.word.length }} letters
            </ion-item>
          </ion-list>
          <ion-button class="play-button" expand="block" size="default" shape="default" (click)="respondGame(game.id)">
            Play
          </ion-button>
        </ion-card-content>
      </ion-card>
    </div>

async loadPendingPvpGames() {
    try {
      this.pendingPvpGames = await this.apiService.getPendingPVPGames();
    } catch (error) {
      this.toastService.showToast("Error loading pending games", 2000, 'top', 'danger');
    }
  }

  respondGame(idGame: string){
    // Refresh pending games and redirect
    this.loadPendingPvpGames();
    this.router.navigate(['/respond-game'], { queryParams: { idGame: idGame } });
  }

The pending games are fetched by a new apiService method called getPendingPVPGames, which fetches the games that the identified player has not played yet, using the pending_games route.

When clicking in the "play" button, the respond-game page is loaded. This page receives the id of the game as parameter. With the ID, the rest of the information of the game can be fetched and not passed by parameters in the URL:

async ngOnInit() {
    this.selfUsername = await this.storageService.getUsername();
    this.route.queryParams.subscribe((params) => {
      this.idGame = params['idGame'];
    });

    (await this.apiService.getGame(this.idGame)).subscribe(
      (response) => {
        console.log(response);
        this.selectedWord = response.word;
        this.opponentUsername = response.player1;
        this.wordLength = this.selectedWord.length;
      },
      (error) => {
        this.showAlert("Ups!", "You can't play this game!");
      }
    );
  }

This page also uses the finishGame method, but in this case it fetches the player2 statistics and push them to the backend:

async finishGame(time: number, xp: number, attempts: number, selectedWord: string) {
    const gameData = {
      player2_time: time,
      player2_xp: xp,
      player2_attempts: attempts,
    };

    (await this.apiService.resolveGame(this.idGame, gameData)).subscribe(
      (response) => {
        console.log('Game resolved successfully', response);
        console.log(response.winner, this.selfUsername);
        if (response.winner === this.selfUsername) {
          setTimeout( () => this.showAlert('Congratulations!', 'You won! Amazing!'), 2500);
        } else {
          setTimeout( () => this.showAlert('Bad news!', 'You lost. Try next time!'), 2500);
        }
        // Handle success and navigate to appropriate page
      },
      (error) => {
        console.log('Game could not be resolved', error);
        // Handle error
      }
    );

With this, both pages uses the wordle-dashboard component, which is also used in the classic wordles. This is a very good example of building and reusing a component.

Results

  1. Player challenges testing to a 5 letter game: image

  2. Testing see that some pending games are listed in the History page: image

  3. Testing plays the game. Notice that the word is the same: apple image

Notice that the user can not respond to any game. For example, if he access to a game guessing the ID, and the game is completed or he is not involved, an error will be displayed: image