Een Elden Ring Quest Tracker bouwen
Ik hield van Skyrim. Ik heb met plezier honderden uren besteed aan het spelen en opnieuw afspelen ervan. Dus toen ik onlangs hoorde van een nieuwe game, de Skyrim van de jaren 2020 , ik moest het kopen. Zo begint mijn saga met Elden Ring, de enorme open-wereld RPG met verhaalbegeleiding van George R.R. Martin.
Binnen het eerste uur van de game leerde ik hoe brutaal Souls-games kunnen zijn. Ik kroop in interessante grotten op de kliffen om zo ver binnenin te sterven dat ik mijn lijk niet meer kon terughalen.
Ik ben al mijn runen kwijt.
Ik staarde vol ontzag en verwondering toen ik met de lift naar de Siofra-rivier reed, om te ontdekken dat die griezelige dood me wachtte, ver van de dichtstbijzijnde plaats van genade. Ik rende dapper weg voordat ik weer kon sterven.
Ik ontmoette spookachtige figuren en fascinerende NPC's die me verleidden met een paar regels dialoog... die ik meteen vergat zodra het nodig was.
10/10, ten zeerste aanbevolen.
Eén ding in het bijzonder aan Elden Ring irriteerde me - er was geen quest-tracker. Ooit de goede sport, opende ik een Notes-document op mijn iPhone. Dat was natuurlijk lang niet genoeg.
Ik had een app nodig om me te helpen bij het volgen van RPG-afspeeldetails. Niets in de App Store kwam echt overeen met wat ik zocht, dus blijkbaar zou ik het moeten schrijven. Het heet Shattered Ring en is nu beschikbaar in de App Store.
Technische keuzes
Overdag schrijf ik documentatie voor de Realm Swift SDK. Ik had onlangs een SwiftUI-sjabloon-app voor Realm geschreven om ontwikkelaars een SwiftUI-startsjabloon te bieden om op voort te bouwen, compleet met inlogstromen. Het Realm Swift SDK-team heeft gestaag SwiftUI-functies geleverd, waardoor het - naar mijn waarschijnlijk bevooroordeelde mening - een doodeenvoudig startpunt is voor app-ontwikkeling.
Ik wilde iets dat ik supersnel kon bouwen - gedeeltelijk zodat ik Elden Ring weer kon spelen in plaats van een app te schrijven, en gedeeltelijk om andere apps op de markt te brengen terwijl iedereen het nog steeds over Elden Ring heeft. Ik zou geen maanden nodig hebben om deze app te bouwen. Ik wilde het gisteren. Realm + SwiftUI zou dat mogelijk maken.
Gegevensmodellering
Ik wist dat ik speurtochten in het spel wilde volgen. Het zoekmodel was eenvoudig:
class Quest: Object, ObjectKeyIdentifiable {
@Persisted(primaryKey: true) var _id: ObjectId
@Persisted var name = ""
@Persisted var isComplete = false
@Persisted var notes = ""
}
Alles wat ik echt nodig had, was een naam, een bool om te schakelen wanneer de zoektocht was voltooid, een notitieveld en een unieke identificatie.
Terwijl ik nadacht over mijn gameplay, realiseerde ik me echter dat ik niet alleen speurtochten nodig had, maar ook locaties wilde bijhouden. Ik strompelde naar - en kwam er snel weer uit toen ik begon te sterven - zoveel coole plaatsen die waarschijnlijk interessante niet-spelerpersonages (NPC's) en geweldige buit hadden. Ik wilde kunnen bijhouden of ik een locatie had vrijgemaakt, of er gewoon van was weggelopen, zodat ik eraan kon denken om later terug te gaan en het te bekijken zodra ik betere uitrusting en meer vaardigheden had. Dus ik heb een locatie-object toegevoegd:
class Location: Object, ObjectKeyIdentifiable {
@Persisted(primaryKey: true) var _id: ObjectId
@Persisted var name = ""
@Persisted var isCleared = false
@Persisted var notes = ""
}
Hm. Dat leek veel op het quest-model. Had ik echt een apart object nodig? Toen dacht ik aan een van de vroege locaties die ik bezocht - de kerk van Elleh - die een smidsaambeeld had. Ik had eigenlijk nog niets gedaan om mijn uitrusting te verbeteren, maar het zou leuk zijn om te weten op welke locaties in de toekomst het smidsaambeeld zou zijn als ik ergens heen wilde om een upgrade uit te voeren. Dus ik heb nog een bool toegevoegd:
@Persisted var hasSmithAnvil = false
Toen bedacht ik dat diezelfde locatie ook een koopman had. Misschien wil ik in de toekomst weten of een locatie een handelaar had. Dus ik heb nog een bool toegevoegd:
@Persisted var hasMerchant = false
Super goed! Locatie-object gesorteerd.
Maar... er was nog iets anders. Ik kreeg steeds al deze interessante verhalen van NPC's. En wat gebeurde er toen ik een quest voltooide - zou ik terug moeten naar een NPC om een beloning te verzamelen? Daarvoor zou ik moeten weten wie mij de zoektocht had gegeven en waar ze zich bevonden. Tijd om een derde model toe te voegen, de NPC, die alles met elkaar zou verbinden:
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 = ""
}
Super goed! Nu kon ik NPC's volgen. Ik kon aantekeningen toevoegen om me te helpen die interessante weetjes van het verhaal bij te houden terwijl ik wachtte om te zien wat er zou gebeuren. Ik kon missies en locaties associëren met NPC's. Na het toevoegen van dit object, werd het duidelijk dat dit het object was dat de anderen verbond. NPC's zijn op locaties. Maar ik wist door wat online lezen dat NPC's soms in het spel bewegen, dus locaties zouden meerdere ingangen moeten ondersteunen - vandaar de lijst. NPC's geven speurtochten. Maar dat zou ook een lijst moeten zijn, want de eerste NPC die ik ontmoette gaf me meer dan één zoektocht. Varre, net buiten de Shattered Graveyard toen je voor het eerst in het spel kwam, zei me "Volg de draden van genade" en "ga naar het kasteel". Goed, gesorteerd!
Nu kon ik mijn objecten met SwiftUI-eigenschapwrappers gebruiken om de gebruikersinterface te maken.
SwiftUI-weergaven + Magical Property Wrappers van Realm
Aangezien alles aan de NPC hangt, zou ik beginnen met de NPC-weergaven. De @ObservedResults
property wrapper biedt u een eenvoudige manier om dit te doen.
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)
}
}
}
Nu kon ik een lijst van alle NPC's doorlopen, had een automatische onDelete
actie om NPC's te verwijderen, en zou Realm's implementatie van .searchable
kunnen toevoegen toen ik klaar was om zoeken en filteren toe te voegen. En het was eigenlijk één regel om het aan te sluiten op mijn datamodel. Had ik al gezegd dat Realm + SwiftUI geweldig is? Het was eenvoudig genoeg om hetzelfde te doen met Locaties en Quests, en het voor app-gebruikers mogelijk te maken om via elk pad in hun gegevens te duiken.
Dan zou mijn NPC-detailweergave kunnen werken met de @ObservedRealmObject
eigenschap wrapper om de NPC-details weer te geven en het gemakkelijk te maken om de NPC te bewerken:
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()
}
}
}
Een ander voordeel van het @ObservedRealmObject
was dat ik de $
. kon gebruiken notatie om een snelle schrijfactie te starten, zodat het notitieveld gewoon bewerkbaar zou zijn. Gebruikers konden erop tikken en gewoon meer notities toevoegen, en Realm zou de wijzigingen gewoon opslaan. Geen aparte bewerkingsweergave nodig of een expliciete schrijftransactie openen om de notities bij te werken.
Op dat moment had ik een werkende app en ik had hem gemakkelijk kunnen verzenden.
Maar... ik had een gedachte.
Een van de dingen die ik leuk vond aan RPG-games in de open wereld, was ze opnieuw te spelen als verschillende personages en met verschillende keuzes. Dus misschien wil ik Elden Ring opnieuw spelen als een andere klasse. Of - misschien was dit niet specifiek een Elden Ring-tracker, maar misschien zou ik het kunnen gebruiken om elke RPG-game te volgen. Hoe zit het met mijn D&D-spellen?
Als ik meerdere games wilde volgen, moest ik iets aan mijn model toevoegen. Ik had een concept nodig van zoiets als een game of een playthrough.
Itereren op het datamodel
Ik had een object nodig om de NPC's, locaties en missies te omvatten die deel uitmaakten van dit playthrough, zodat ik ze gescheiden kon houden van andere playthroughs. Dus wat als dat een spel was?
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>()
}
Akkoord! Super goed. Nu kan ik de NPC's, locaties en missies in deze game volgen en ze onderscheiden van andere games.
Het Game-object was gemakkelijk te bedenken, maar toen ik begon na te denken over de @ObservedResults
naar mijn mening realiseerde ik me dat dat niet meer zou werken. @ObservedResults
retourneer alle resultaten voor een specifiek objecttype. Dus als ik alleen de NPC's voor deze game wilde weergeven, zou ik mijn mening moeten veranderen.*
- Swift SDK versie 10.24.0 heeft de mogelijkheid toegevoegd om Swift Query-syntaxis te gebruiken in
@ObservedResults
, waarmee u resultaten kunt filteren met dewhere
parameter. Ik ben zeker aan het refactoren om dit in een toekomstige versie te gebruiken! Het Swift SDK-team brengt gestaag nieuwe SwiftUI-goodies uit.
Oh. Ik zou ook een manier nodig hebben om de NPC's in deze game te onderscheiden van die in andere games. Hm. Nu is het misschien tijd om te kijken naar backlinking. Na speleologie in de Realm Swift SDK-documenten, heb ik dit toegevoegd aan het NPC-model:
@Persisted(originProperty: "npcs") var npcInGame: LinkingObjects<Game>
Nu kon ik de NPC's backlinken naar het Game-object. Maar helaas, nu worden mijn opvattingen ingewikkelder.
SwiftUI-weergaven bijwerken voor de modelwijzigingen
Omdat ik nu alleen een subset van mijn objecten wil (en dit was vóór de @ObservedResults
update), heb ik mijn lijstweergaven gewijzigd van @ObservedResults
naar @ObservedRealmObject
, het spel observeren:
@ObservedRealmObject var game: Game
Nu krijg ik nog steeds de voordelen van snel schrijven om NPC's, locaties en missies in de game toe te voegen en te bewerken, maar mijn lijstcode moest een beetje worden bijgewerkt:
ForEach(game.npcs) { npc in
NavigationLink {
NPCDetailView(npc: npc)
} label: {
NPCRow(npc: npc)
}
}
.onDelete(perform: $game.npcs.remove
Nog steeds niet slecht, maar een ander niveau van relaties om te overwegen. En aangezien dit geen gebruik maakt van @ObservedResults
, ik kon de Realm-implementatie van .searchable
niet gebruiken , maar zou het zelf moeten implementeren. Geen big deal, maar meer werk.
Bevroren objecten en toevoegen aan lijsten
Tot nu toe heb ik een werkende app. Ik zou dit kunnen verzenden zoals het is. Alles is nog steeds eenvoudig met de Realm Swift SDK-eigenschapwrappers die al het werk doen.
Maar ik wilde dat mijn app meer zou doen.
Ik wilde locaties en missies vanuit de NPC-weergave kunnen toevoegen en ze automatisch aan de NPC kunnen toevoegen. En ik wilde een opdrachtgever kunnen bekijken en toevoegen vanuit de opdrachtweergave. En ik wilde NPC's kunnen bekijken en toevoegen aan locaties vanuit de locatieweergave.
Dit alles vereiste veel toevoegen aan lijsten, en toen ik begon te proberen dit te doen met snelle schrijfacties nadat ik het object had gemaakt, realiseerde ik me dat dat niet zou werken. Ik zou objecten handmatig moeten doorgeven en toevoegen.
Wat ik wilde was om zoiets als dit te doen:
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)
}
}
Dit is waar iets dat voor mij als nieuwe ontwikkelaar niet helemaal duidelijk was, me in de weg begon te lopen. Ik had nog nooit echt iets met threading en bevroren objecten hoeven doen, maar ik kreeg crashes waarvan de foutmeldingen me deden denken dat dit daarmee te maken had. Gelukkig herinnerde ik me dat ik een codevoorbeeld had geschreven over het ontdooien van bevroren objecten, zodat je ermee aan andere threads kunt werken, dus het was terug naar de documenten - dit keer naar de Threading-pagina die Frozen Objects behandelt. (Meer verbeteringen die het Realm Swift SDK-team heeft toegevoegd sinds ik me bij MongoDB heb aangesloten - yay!)
Na het bezoeken van de documenten, had ik zoiets als dit:
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)
}
}
Dat zag er goed uit, maar crashte nog steeds. Maar waarom? (Dit is het moment waarop ik mezelf vervloekte omdat ik geen grondiger codevoorbeeld in de documenten had gegeven. Het werken aan deze app heeft zeker een aantal tickets opgeleverd om onze documentatie op een paar gebieden te verbeteren!)
Na speleologie op de forums en het raadplegen van het grote orakel Google, kwam ik een draad tegen waarin iemand over dit probleem sprak. Het blijkt dat je niet alleen het object waaraan je probeert toe te voegen moet ontdooien, maar ook datgene dat je probeert toe te voegen. Dit is misschien voor de hand liggend voor een meer ervaren ontwikkelaar, maar het heeft me een tijdje laten struikelen. Dus wat ik echt nodig had, was zoiets als dit:
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)
}
}
Super goed! Probleem opgelost. Nu kon ik alle functies maken die ik nodig had om het toevoegen (en verwijderen, zo blijkt) van objecten handmatig af te handelen.
Al het andere is gewoon SwiftUI
Hierna was al het andere dat ik moest leren om de app te maken gewoon SwiftUI, zoals hoe te filteren, hoe de filters door de gebruiker te selecteren en hoe mijn eigen versie van .searchable
te implementeren. .
Er zijn zeker enkele dingen die ik doe met navigatie die niet optimaal zijn. Er zijn enkele UX-verbeteringen die ik nog wil maken. En het wisselen van mijn @ObservedRealmObject var game: Game
terug naar @ObservedResults
met het nieuwe filtermateriaal zal helpen bij sommige van die verbeteringen. Maar over het algemeen maakten de Realm Swift SDK-eigendomswrappers het implementeren van deze app zo eenvoudig dat zelfs ik het zou kunnen.
In totaal heb ik de app in twee weekenden en een handvol doordeweekse avonden gebouwd. Waarschijnlijk kwam ik in een weekend van die tijd vast te zitten met het probleem met het toevoegen aan lijsten, en maakte ik ook een website voor de app, kreeg ik alle schermafbeeldingen om naar de App Store te sturen en alle "zakelijke" dingen die erbij horen als een indie-app-ontwikkelaar.
Maar ik ben hier om je te vertellen dat als ik, een minder ervaren ontwikkelaar met precies één eerdere app op mijn naam - en dat met veel feedback van mijn lead - een app als Shattered Ring kan maken, jij dat ook kunt. En het is een stuk eenvoudiger met SwiftUI + de SwiftUI-functies van de Realm Swift SDK. Bekijk de SwiftUI Quick Start voor een goed voorbeeld om te zien hoe eenvoudig het is.