Closed davidcr01 closed 1 year ago
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})
Notice how the player2 information is passed to the backend and it ignores it. Besides, the notification is created:
When the second player plays the game, the information is passed to the backend and the winner is calculated and returned:
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.
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.
Player challenges testing to a 5 letter game:
Testing see that some pending games are listed in the History page:
Testing plays the game. Notice that the word is the same: apple
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:
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: