Bygga en Elden Ring Quest Tracker
Jag älskade Skyrim. Jag tillbringade gärna flera hundra timmar med att spela och spela om det. Så när jag nyligen hörde talas om ett nytt spel, 2020-talets Skyrim , jag var tvungen att köpa den. Så börjar min saga med Elden Ring, det enorma RPG-spelet i öppen värld med berättelsevägledning från George R.R. Martin.
Inom den första timmen av spelet lärde jag mig hur brutala Souls-spel kan vara. Jag kröp in i intressanta grottor vid klipporna bara för att dö så långt inne att jag inte kunde hämta mitt lik.
Jag tappade alla mina runor.
Jag gapade av häpnadsväckande förundran när jag åkte hissen ner till floden Siofra, bara för att upptäcka att den grymma döden väntade mig, långt från den närmaste nådens plats. Jag sprang modigt iväg innan jag kunde dö igen.
Jag träffade spöklika figurer och fascinerande NPC:er som frestade mig med några rader av dialog... som jag omedelbart glömde så fort det behövdes.
10/10, rekommenderas starkt.
En sak i synnerhet med Elden Ring irriterade mig - det fanns ingen quest tracker. Oavsett den bra sporten öppnade jag ett Notes-dokument på min iPhone. Naturligtvis räckte det inte alls.
Jag behövde en app för att hjälpa mig spåra RPG-uppspelningsdetaljer. Ingenting på App Store matchade verkligen det jag letade efter, så tydligen skulle jag behöva skriva det. Den heter Shattered Ring och är tillgänglig i App Store nu.
Tekniska val
På dagen skriver jag dokumentation för Realm Swift SDK. Jag hade nyligen skrivit en SwiftUI-mallapp för Realm för att ge utvecklare en SwiftUI-startmall att bygga på, komplett med inloggningsflöden. Realm Swift SDK-teamet har stadigt levererat SwiftUI-funktioner, vilket har gjort det - enligt min förmodligen partiska åsikt - till en dödlig utgångspunkt för apputveckling.
Jag ville ha något jag kunde bygga supersnabbt – dels så att jag kunde återgå till att spela Elden Ring istället för att skriva en app, och dels för att slå andra appar på marknaden medan alla fortfarande pratar om Elden Ring. Jag kunde inte ta månader att bygga den här appen. Jag ville ha det igår. Realm + SwiftUI skulle göra det möjligt.
Datamodellering
Jag visste att jag ville spåra uppdrag i spelet. Questmodellen var enkel:
class Quest: Object, ObjectKeyIdentifiable {
@Persisted(primaryKey: true) var _id: ObjectId
@Persisted var name = ""
@Persisted var isComplete = false
@Persisted var notes = ""
}
Allt jag egentligen behövde var ett namn, en bool att växla när uppdraget var klart, ett anteckningsfält och en unik identifierare.
Men när jag tänkte på mitt spelande insåg jag att jag inte bara behövde uppdrag - jag ville också hålla reda på platser. Jag snubblade in i - och snabbt ut av när jag började dö - så många coola platser som förmodligen hade intressanta icke-spelare karaktärer (NPC) och fantastiskt byte. Jag ville kunna hålla reda på om jag hade rensat en plats, eller bara sprungit ifrån den, så jag kunde komma ihåg att gå tillbaka senare och kolla upp det när jag hade bättre utrustning och fler förmågor. Så jag lade till ett platsobjekt:
class Location: Object, ObjectKeyIdentifiable {
@Persisted(primaryKey: true) var _id: ObjectId
@Persisted var name = ""
@Persisted var isCleared = false
@Persisted var notes = ""
}
Hmm. Det såg mycket ut som quest-modellen. Behövde jag verkligen ett separat objekt? Sedan tänkte jag på en av de tidiga platserna jag besökte - Elleh-kyrkan - som hade ett städ av smed. Jag hade faktiskt inte gjort något för att förbättra min utrustning än, men det kan vara trevligt att veta vilka platser som hade smith-städet i framtiden när jag ville åka någonstans för att göra en uppgradering. Så jag lade till en annan bool:
@Persisted var hasSmithAnvil = false
Sedan tänkte jag på hur samma plats också hade en köpman. Jag kanske vill veta i framtiden om en plats hade en handlare. Så jag lade till en annan bool:
@Persisted var hasMerchant = false
Bra! Platsobjekt sorterat.
Men... det var något annat. Jag fick hela tiden alla dessa intressanta berättelser från NPC:er. Och vad hände när jag slutförde ett uppdrag - skulle jag behöva gå tillbaka till en NPC för att få en belöning? Det skulle kräva att jag visste vem som hade gett mig uppdraget och var de befann sig. Dags att lägga till en tredje modell, NPC, som skulle knyta ihop allt:
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 = ""
}
Bra! Nu kunde jag spåra NPC:er. Jag kunde lägga till anteckningar för att hjälpa mig att hålla reda på dessa intressanta berättelser medan jag väntade på att se vad som skulle utvecklas. Jag kunde associera uppdrag och platser med NPC:er. Efter att ha lagt till detta objekt blev det uppenbart att detta var objektet som kopplade ihop de andra. NPC:er finns på platser. Men jag visste från lite läsning på nätet att ibland rör sig NPC:er runt i spelet, så platser måste stödja flera poster - därav listan. NPC:er ger uppdrag. Men det borde också vara en lista, för den första NPC jag träffade gav mig mer än ett uppdrag. Varre, precis utanför Shattered Graveyard när du först går in i spelet, sa åt mig att "Följ nådens trådar" och "gå till slottet." Precis, sorterat!
Nu kunde jag använda mina objekt med SwiftUI-egenskapsomslag för att börja skapa användargränssnittet.
SwiftUI Views + Realm's Magical Property Wrappers
Eftersom allt hänger utanför NPC, skulle jag börja med NPC-vyerna. @ObservedResults
Property wrapper ger dig ett enkelt sätt att göra detta.
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 kunde jag iterera genom en lista över alla NPC:er, hade en automatisk onDelete
åtgärd för att ta bort NPC:er och kan lägga till Realms implementering av .searchable
när jag var redo att lägga till sökning och filtrering. Och det var i princip en rad för att koppla upp den till min datamodell. Nämnde jag att Realm + SwiftUI är fantastiskt? Det var lätt nog att göra samma sak med platser och uppdrag, och göra det möjligt för appanvändare att dyka in i sin data genom vilken väg som helst.
Då kan min NPC-detaljvy fungera med @ObservedRealmObject
egenskapsomslag för att visa NPC-detaljerna och göra det enkelt att redigera NPC:n:
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()
}
}
}
En annan fördel med @ObservedRealmObject
var att jag kunde använda $
notation för att initiera en snabb skrivning, så att anteckningsfältet bara skulle kunna redigeras. Användare kunde klicka in och bara lägga till fler anteckningar, och Realm skulle bara spara ändringarna. Inget behov av en separat redigeringsvy eller att öppna en explicit skrivtransaktion för att uppdatera anteckningarna.
Vid det här laget hade jag en fungerande app och jag kunde lätt ha skickat den.
Men... jag hade en tanke.
En av de saker som jag älskade med RPG-spel i öppen värld var att spela om dem som olika karaktärer och med olika val. Så jag kanske skulle vilja spela om Elden Ring som en annan klass. Eller - det här var kanske inte en Elden Ring-spårare specifikt, men jag kanske kunde använda den för att spåra vilket RPG-spel som helst. Hur är det med mina D&D-spel?
Om jag ville spåra flera spel behövde jag lägga till något till min modell. Jag behövde ett koncept av något som ett spel eller en genomspelning.
Iteration på datamodellen
Jag behövde något objekt för att omfatta NPC:er, platser och uppdrag som var en del av detta genomspelning, så att jag kunde hålla dem åtskilda från andra genomspelningar. Så tänk om det var ett spel?
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>()
}
OK! Bra. Nu kan jag spåra NPC:er, platser och uppdrag som finns i det här spelet och hålla dem åtskilda från andra spel.
Spelobjektet var lätt att föreställa sig, men när jag började tänka på @ObservedResults
enligt mina åsikter insåg jag att det inte skulle fungera längre. @ObservedResults
returnera alla resultat för en specifik objekttyp. Så om jag bara vill visa NPC:erna för det här spelet måste jag ändra mina åsikter.*
- Swift SDK version 10.24.0 lade till möjligheten att använda Swift Query-syntax i
@ObservedResults
, som låter dig filtrera resultat medwhere
parameter. Jag överväger definitivt att använda detta i en framtida version! Swift SDK-teamet har ständigt släppt nya SwiftUI-godsaker.
Åh. Dessutom skulle jag behöva ett sätt att skilja NPC:erna i det här spelet från de i andra spel. Hrm. Nu kan det vara dags att titta på bakåtlänkning. Efter att ha spelat i Realm Swift SDK Docs lade jag till detta till NPC-modellen:
@Persisted(originProperty: "npcs") var npcInGame: LinkingObjects<Game>
Nu kunde jag länka NPC:erna till spelobjektet. Men tyvärr, nu blir mina åsikter mer komplicerade.
Uppdatering av SwiftUI-vyer för modelländringarna
Eftersom jag bara vill ha en delmängd av mina objekt nu (och detta var före @ObservedResults
uppdatering), bytte jag mina listvyer från @ObservedResults
till @ObservedRealmObject
, observerar spelet:
@ObservedRealmObject var game: Game
Nu får jag fortfarande fördelarna med att snabbt skriva för att lägga till och redigera NPC:er, platser och uppdrag i spelet, men min listkod var tvungen att uppdatera lite:
ForEach(game.npcs) { npc in
NavigationLink {
NPCDetailView(npc: npc)
} label: {
NPCRow(npc: npc)
}
}
.onDelete(perform: $game.npcs.remove
Fortfarande inte dåligt, men en annan nivå av relationer att överväga. Och eftersom detta inte använder @ObservedResults
, jag kunde inte använda Realm-implementeringen av .searchable
, men skulle behöva implementera det själv. Inte en stor sak, men mer arbete.
Frysta objekt och tillägg till listor
Nu, fram till denna punkt, har jag en fungerande app. Jag skulle kunna skicka detta som det är. Allt är fortfarande enkelt med Realm Swift SDK-egenskapsomslag som gör allt arbete.
Men jag ville att min app skulle göra mer.
Jag ville kunna lägga till platser och uppdrag från NPC-vyn och få dem automatiskt bifogade till NPC. Och jag ville kunna se och lägga till en uppdragsgivare från uppdragsvyn. Och jag ville kunna se och lägga till NPC:er till platser från platsvyn.
Allt detta krävde en hel del tillägg till listor, och när jag började försöka göra det här med snabbskrivningar efter att ha skapat objektet, insåg jag att det inte skulle fungera. Jag måste manuellt skicka runt objekt och lägga till dem.
Det jag ville var att göra något sånt här:
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)
}
}
Det var här något som inte var helt uppenbart för mig som ny utvecklare började komma i vägen för mig. Jag hade egentligen aldrig behövt göra något med trådning och frusna föremål tidigare, men jag fick krascher vars felmeddelanden fick mig att tro att detta var relaterat till det. Lyckligtvis kom jag ihåg att jag skrev ett kodexempel om att tina frusna föremål så att du kan arbeta med dem i andra trådar, så det var tillbaka till dokumenten - den här gången till trådningssidan som täcker Frosna föremål. (Fler förbättringar som Realm Swift SDK-teamet har lagt till sedan jag gick med i MongoDB - yay!)
Efter att ha besökt docs hade jag något sånt här:
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)
}
}
Det såg rätt ut, men kraschade fortfarande. Men varför? (Det var då jag förbannade mig själv för att jag inte gav ett mer ingående kodexempel i dokumenten. Arbetet med den här appen har definitivt gett några biljetter för att förbättra vår dokumentation på några områden!)
Efter att ha tjatat i forumen och konsulterat det stora oraklet Google, stötte jag på en tråd där någon pratade om det här problemet. Det visar sig att du måste tina inte bara föremålet du försöker lägga till utan också det du försöker lägga till. Detta kan vara uppenbart för en mer erfaren utvecklare, men det gjorde mig sämre ett tag. Så vad jag verkligen behövde var något sånt här:
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)
}
}
Bra! Problemet löst. Nu kunde jag skapa alla funktioner jag behövde för att manuellt hantera tillägg (och borttagning, som det visar sig) av objekt.
Allt annat är bara SwiftUI
Efter detta var allt annat jag behövde lära mig för att producera appen bara SwiftUI, som hur man filtrerar, hur man gör filtren valbara av användaren och hur man implementerar min egen version av .searchable
.
Det finns definitivt några saker jag gör med navigering som är mindre än optimala. Det finns några UX-förbättringar jag fortfarande vill göra. Och byter mitt @ObservedRealmObject var game: Game
tillbaka till @ObservedResults
med de nya filtreringsgrejerna kommer det att hjälpa till med några av dessa förbättringar. Men på det hela taget gjorde Realm Swift SDK-egenskapsomslagen att implementera den här appen så enkelt att även jag kunde göra det.
Totalt byggde jag appen på två helger och en handfull veckonätter. Förmodligen en helg på den tiden fastnade jag med problemet med bilagor till listor, och jag gjorde även en webbplats för appen, fick alla skärmdumpar att skicka till App Store och alla "affärssaker" som hör ihop med att vara en indie app-utvecklare.
Men jag är här för att berätta att om jag, en mindre erfaren utvecklare med exakt en tidigare app till mitt namn - och det med mycket feedback från min chef - kan göra en app som Shattered Ring, så kan du också. Och det är mycket enklare med SwiftUI + Realm Swift SDK:s SwiftUI-funktioner. Kolla in SwiftUI Quick Start för ett bra exempel för att se hur enkelt det är.