Construire un tracker de quête Elden Ring
J'ai adoré Skyrim. J'ai passé avec bonheur plusieurs centaines d'heures à jouer et à rejouer. Alors quand j'ai récemment entendu parler d'un nouveau jeu, le Skyrim des années 2020 , j'ai dû l'acheter. Ainsi commence ma saga avec Elden Ring, le RPG à monde ouvert massif avec des conseils d'histoire de George R.R. Martin.
Au cours de la première heure de jeu, j'ai appris à quel point les jeux Souls peuvent être brutaux. Je me suis glissé dans des grottes intéressantes à flanc de falaise pour mourir si loin à l'intérieur que je n'ai pas pu récupérer mon cadavre.
J'ai perdu toutes mes runes.
Je restai bouche bée d'émerveillement alors que je montais dans l'ascenseur jusqu'à la rivière Siofra, seulement pour découvrir que la mort macabre m'attendait, loin du site de grâce le plus proche. Je me suis bravement enfui avant de pouvoir mourir à nouveau.
J'ai rencontré des personnages fantomatiques et des PNJ fascinants qui m'ont tenté avec quelques lignes de dialogue… que j'ai immédiatement oubliées dès que j'en ai eu besoin.
10/10, hautement recommandé.
Une chose en particulier à propos d'Elden Ring m'a agacé - il n'y avait pas de suivi de quête. Toujours le bon sport, j'ai ouvert un document Notes sur mon iPhone. Bien sûr, ce n'était pas suffisant.
J'avais besoin d'une application pour m'aider à suivre les détails de la lecture du RPG. Rien sur l'App Store ne correspondait vraiment à ce que je cherchais, donc apparemment j'aurais besoin de l'écrire. Il s'appelle Shattered Ring, et il est maintenant disponible sur l'App Store.
Choix technologiques
Le jour, j'écris de la documentation pour le SDK Realm Swift. J'avais récemment écrit un modèle d'application SwiftUI pour Realm afin de fournir aux développeurs un modèle de démarrage SwiftUI sur lequel s'appuyer, avec des flux de connexion. L'équipe Realm Swift SDK a régulièrement livré des fonctionnalités SwiftUI, ce qui en a fait - à mon avis probablement biaisé - un point de départ simple et mort pour le développement d'applications.
Je voulais quelque chose que je pourrais construire très rapidement - en partie pour pouvoir recommencer à jouer à Elden Ring au lieu d'écrire une application, et en partie pour battre d'autres applications sur le marché pendant que tout le monde parle encore d'Elden Ring. Je ne pouvais pas prendre des mois pour créer cette application. Je l'ai voulu hier. Realm + SwiftUI allait rendre cela possible.
Modélisation des données
Je savais que je voulais suivre les quêtes dans le jeu. Le modèle de quête était simple :
class Quest: Object, ObjectKeyIdentifiable {
@Persisted(primaryKey: true) var _id: ObjectId
@Persisted var name = ""
@Persisted var isComplete = false
@Persisted var notes = ""
}
Tout ce dont j'avais vraiment besoin était un nom, un bool pour basculer lorsque la quête était terminée, un champ de notes et un identifiant unique.
Cependant, en réfléchissant à mon gameplay, j'ai réalisé que je n'avais pas seulement besoin de quêtes - je voulais aussi garder une trace des lieux. Je suis tombé par hasard sur - et rapidement sorti du moment où j'ai commencé à mourir - dans tant d'endroits sympas qui avaient probablement des personnages non-joueurs (PNJ) intéressants et un butin génial. Je voulais être en mesure de savoir si j'avais dégagé un emplacement ou si je m'en étais enfui, afin de me souvenir de revenir plus tard et de le vérifier une fois que j'aurais un meilleur équipement et plus de capacités. J'ai donc ajouté un objet de localisation :
class Location: Object, ObjectKeyIdentifiable {
@Persisted(primaryKey: true) var _id: ObjectId
@Persisted var name = ""
@Persisted var isCleared = false
@Persisted var notes = ""
}
Hmm. Cela ressemblait beaucoup au modèle de quête. Ai-je vraiment besoin d'un objet séparé ? Puis j'ai pensé à l'un des premiers endroits que j'ai visités - l'église d'Elleh - qui avait une enclume de forgeron. Je n'avais encore rien fait pour améliorer mon équipement, mais il serait peut-être bon de savoir quels endroits avaient l'enclume de forgeron à l'avenir lorsque je voulais aller quelque part pour faire une mise à niveau. J'ai donc ajouté un autre booléen :
@Persisted var hasSmithAnvil = false
Puis j'ai pensé à la façon dont ce même endroit avait aussi un marchand. Je pourrais vouloir savoir à l'avenir si un emplacement avait un marchand. J'ai donc ajouté un autre booléen :
@Persisted var hasMerchant = false
Génial! Objet de localisation trié.
Mais… il y avait autre chose. J'ai continué à recevoir toutes ces histoires intéressantes de la part des PNJ. Et que s'est-il passé lorsque j'ai terminé une quête - aurais-je besoin de retourner voir un PNJ pour récupérer une récompense ? Cela exigerait que je sache qui m'avait confié la quête et où elle se trouvait. Il est temps d'ajouter un troisième modèle, le PNJ, qui relierait tout :
class NPC: Object, ObjectKeyIdentifiable {
@Persisted(primaryKey: true) var _id: ObjectId
@Persisted var name = ""
@Persisted var isMerchant = false
@Persisted var locations = List<Location>()
@Persisted var quests = List<Quest>()
@Persisted var notes = ""
}
Génial! Maintenant, je pouvais suivre les PNJ. Je pourrais ajouter des notes pour m'aider à garder une trace de ces bribes d'histoire intéressantes pendant que j'attendais de voir ce qui allait se dérouler. Je pouvais associer des quêtes et des lieux à des PNJ. Après avoir ajouté cet objet, il est devenu évident que c'était l'objet qui reliait les autres. Les PNJ sont à des endroits. Mais je savais d'après certaines lectures en ligne que parfois les PNJ se déplaçaient dans le jeu, donc les emplacements devraient prendre en charge plusieurs entrées - d'où la liste. Les PNJ donnent des quêtes. Mais cela devrait aussi être une liste, car le premier PNJ que j'ai rencontré m'a donné plus d'une quête. Varre, juste à l'extérieur du cimetière brisé lorsque vous entrez pour la première fois dans le jeu, m'a dit de "suivre les fils de la grâce" et "d'aller au château". Bon, trié !
Maintenant, je peux utiliser mes objets avec les wrappers de propriété SwiftUI pour commencer à créer l'interface utilisateur.
Vues SwiftUI + Wrappers de propriétés magiques de Realm
Puisque tout dépend du PNJ, je commencerais par les vues du PNJ. Le @ObservedResults
propriété wrapper vous donne un moyen facile de le faire.
struct NPCListView: View {
@ObservedResults(NPC.self) var npcs
var body: some View {
VStack {
List {
ForEach(npcs) { npc in
NavigationLink {
NPCDetailView(npc: npc)
} label: {
NPCRow(npc: npc)
}
}
.onDelete(perform: $npcs.remove)
.navigationTitle("NPCs")
}
.listStyle(.inset)
}
}
}
Maintenant, je pouvais parcourir une liste de tous les PNJ, j'avais un onDelete
automatique action pour supprimer les PNJ, et pourrait ajouter l'implémentation de Realm de .searchable
quand j'étais prêt à ajouter la recherche et le filtrage. Et c'était essentiellement une ligne pour le connecter à mon modèle de données. Ai-je mentionné que Realm + SwiftUI est incroyable ? Il était assez facile de faire la même chose avec les emplacements et les quêtes, et de permettre aux utilisateurs de l'application de plonger dans leurs données par n'importe quel chemin.
Ensuite, ma vue détaillée du PNJ pourrait fonctionner avec le @ObservedRealmObject
propriété wrapper pour afficher les détails du PNJ et faciliter la modification du PNJ :
struct NPCDetailView: View {
@ObservedRealmObject var npc: NPC
var body: some View {
VStack {
HStack {
Text("Notes")
.font(.title2)
Spacer()
if npc.isMerchant {
Image(systemName: "dollarsign.square.fill")
}
Spacer()
Text($npc.notes)
Spacer()
}
}
}
Un autre avantage de @ObservedRealmObject
était que je pouvais utiliser le $
notation pour lancer une écriture rapide, de sorte que le champ des notes serait simplement modifiable. Les utilisateurs pouvaient taper et simplement ajouter plus de notes, et Realm enregistrerait simplement les modifications. Pas besoin d'une vue d'édition séparée ou d'ouvrir une transaction d'écriture explicite pour mettre à jour les notes.
À ce stade, j'avais une application qui fonctionnait et j'aurais pu facilement l'envoyer.
Mais… j'ai eu une pensée.
L'une des choses que j'aimais dans les jeux RPG en monde ouvert était de les rejouer en tant que personnages différents et avec des choix différents. Alors peut-être que je voudrais rejouer Elden Ring en tant que classe différente. Ou - peut-être que ce n'était pas spécifiquement un tracker Elden Ring, mais peut-être que je pourrais l'utiliser pour suivre n'importe quel jeu RPG. Qu'en est-il de mes jeux D&D ?
Si je voulais suivre plusieurs jeux, je devais ajouter quelque chose à mon modèle. J'avais besoin d'un concept de quelque chose comme un jeu ou une partie.
Itérer sur le modèle de données
J'avais besoin d'un objet pour englober les PNJ, les lieux et les quêtes qui faisaient partie de ceci playthrough, afin que je puisse les séparer des autres playthroughs. Et si c'était un jeu ?
class Game: Object, ObjectKeyIdentifiable {
@Persisted(primaryKey: true) var _id: ObjectId
@Persisted var name = ""
@Persisted var npcs = List<NPC>()
@Persisted var locations = List<Location>()
@Persisted var quests = List<Quest>()
}
Très bien! Génial. Maintenant, je peux suivre les PNJ, les lieux et les quêtes qui se trouvent dans ce jeu et les distinguer des autres jeux.
L'objet Game était facile à concevoir, mais quand j'ai commencé à penser au @ObservedResults
dans mes vues, j'ai réalisé que cela ne fonctionnerait plus. @ObservedResults
renvoie tous les résultats pour un type d'objet spécifique. Donc, si je voulais n'afficher que les PNJ pour ce jeu, je devrais changer mes vues.*
- Swift SDK version 10.24.0 a ajouté la possibilité d'utiliser la syntaxe Swift Query dans
@ObservedResults
, qui vous permet de filtrer les résultats à l'aide dewhere
paramètre. Je suis définitivement en train de refactoriser pour l'utiliser dans une future version! L'équipe Swift SDK publie régulièrement de nouveaux goodies SwiftUI.
Oh. De plus, j'aurais besoin d'un moyen de distinguer les PNJ de ce jeu de ceux des autres jeux. Hum. Il est peut-être temps de se pencher sur le backlinking. Après avoir fait de la spéléologie dans les documents du SDK Realm Swift, j'ai ajouté ceci au modèle NPC :
@Persisted(originProperty: "npcs") var npcInGame: LinkingObjects<Game>
Maintenant, je pouvais relier les PNJ à l'objet Game. Mais, hélas, maintenant mes vues se compliquent.
Mise à jour des vues SwiftUI pour les modifications du modèle
Puisque je veux seulement un sous-ensemble de mes objets maintenant (et c'était avant le @ObservedResults
mise à jour), j'ai changé mes vues de liste de @ObservedResults
à @ObservedRealmObject
, observant le jeu :
@ObservedRealmObject var game: Game
Maintenant, je profite toujours des avantages de l'écriture rapide pour ajouter et modifier des PNJ, des lieux et des quêtes dans le jeu, mais mon code de liste a dû être légèrement mis à jour :
ForEach(game.npcs) { npc in
NavigationLink {
NPCDetailView(npc: npc)
} label: {
NPCRow(npc: npc)
}
}
.onDelete(perform: $game.npcs.remove
Toujours pas mal, mais un autre niveau de relations à considérer. Et puisque cela n'utilise pas @ObservedResults
, je n'ai pas pu utiliser l'implémentation Realm de .searchable
, mais je devrais l'implémenter moi-même. Pas un gros problème, mais plus de travail.
Objets gelés et ajout aux listes
Maintenant, jusqu'à présent, j'ai une application qui fonctionne. Je pourrais l'expédier tel quel. Tout reste simple avec les wrappers de propriété Realm Swift SDK qui font tout le travail.
Mais je voulais que mon application en fasse plus.
Je voulais pouvoir ajouter des emplacements et des quêtes à partir de la vue PNJ et les ajouter automatiquement au PNJ. Et je voulais pouvoir voir et ajouter un donneur de quête depuis la vue des quêtes. Et je voulais pouvoir voir et ajouter des PNJ aux emplacements à partir de la vue de l'emplacement.
Tout cela nécessitait beaucoup d'ajouts aux listes, et lorsque j'ai commencé à essayer de le faire avec des écritures rapides après avoir créé l'objet, j'ai réalisé que cela n'allait pas fonctionner. Je devrais passer manuellement des objets et les ajouter.
Ce que je voulais, c'était faire quelque chose comme ça :
func addLocationToNpc(npc: NPC, game: Game, locationName: String) {
let realm = try! Realm()
let thisLocation = game.locations.where { $0.name == locationName }.first!
try! realm.write {
npc!.locations.append(thisLocation)
}
}
C'est là que quelque chose qui n'était pas tout à fait évident pour moi en tant que nouveau développeur a commencé à me gêner. Je n'avais jamais vraiment eu à faire quoi que ce soit avec le threading et les objets gelés auparavant, mais j'avais des plantages dont les messages d'erreur me faisaient penser que c'était lié à ça. Heureusement, je me suis souvenu d'avoir écrit un exemple de code sur la décongélation d'objets gelés afin que vous puissiez travailler avec eux sur d'autres threads, donc c'était de retour à la documentation - cette fois à la page Threading qui couvre les objets gelés. (Plus d'améliorations que l'équipe Realm Swift SDK a ajoutées depuis que j'ai rejoint MongoDB - yay !)
Après avoir visité les docs, j'ai eu quelque chose comme ça:
func addLocationToNpc(npc: NPC, game: Game, locationName: String) {
let realm = try! Realm()
Let thawedNPC = npc.thaw()
let thisLocation = game.locations.where { $0.name == locationName }.first!
try! realm.write {
thawedNPC!.locations.append(thisLocation)
}
}
Cela avait l'air correct, mais plantait toujours. Mais pourquoi? (C'est à ce moment-là que je me suis maudit de ne pas avoir fourni d'exemple de code plus complet dans la documentation. Travailler sur cette application a certainement produit des tickets pour améliorer notre documentation dans quelques domaines !)
Après avoir spéléo dans les forums et consulté le grand oracle Google, je suis tombé sur un fil où quelqu'un parlait de ce problème. Il s'avère que vous devez décongeler non seulement l'objet auquel vous essayez d'ajouter, mais également la chose que vous essayez d'ajouter. Cela peut être évident pour un développeur plus expérimenté, mais cela m'a fait trébucher pendant un moment. Donc, ce dont j'avais vraiment besoin, c'était quelque chose comme ça:
func addLocationToNpc(npc: NPC, game: Game, locationName: String) {
let realm = try! Realm()
let thawedNpc = npc.thaw()
let thisLocation = game.locations.where { $0.name == locationName }.first!
let thawedLocation = thisLocation.thaw()!
try! realm.write {
thawedNpc!.locations.append(thawedLocation)
}
}
Génial! Problème résolu. Maintenant, je pouvais créer toutes les fonctions dont j'avais besoin pour gérer manuellement l'ajout (et la suppression, en fin de compte) d'objets.
Tout le reste n'est que SwiftUI
Après cela, tout ce que j'ai dû apprendre pour produire l'application n'était que SwiftUI, comme comment filtrer, comment rendre les filtres sélectionnables par l'utilisateur et comment implémenter ma propre version de .searchable
.
Il y a certainement certaines choses que je fais avec la navigation qui ne sont pas optimales. Il y a quelques améliorations UX que je veux encore apporter. Et changer mon @ObservedRealmObject var game: Game
retour à @ObservedResults
avec les nouveaux éléments de filtrage aideront à certaines de ces améliorations. Mais dans l'ensemble, les wrappers de propriété Realm Swift SDK ont rendu la mise en œuvre de cette application suffisamment simple pour que même moi, je puisse le faire.
Au total, j'ai construit l'application en deux week-ends et une poignée de soirs de semaine. Probablement un week-end de cette époque, je me suis retrouvé coincé avec le problème de l'ajout aux listes, et j'ai également créé un site Web pour l'application, obtenu toutes les captures d'écran à soumettre à l'App Store, et tous les trucs «professionnels» qui vont de pair avec être un développeur d'applications indépendantes.
Mais je suis ici pour vous dire que si moi, un développeur moins expérimenté avec exactement une application précédente à mon nom - et cela avec beaucoup de commentaires de mon chef - peut créer une application comme Shattered Ring, vous le pouvez aussi. Et c'est beaucoup plus facile avec SwiftUI + les fonctionnalités SwiftUI du Realm Swift SDK. Consultez le démarrage rapide SwiftUI pour un bon exemple pour voir à quel point c'est facile.