Implement the core game logic for WordScramble

This commit is contained in:
CypherPoet 2019-10-23 14:17:07 -05:00
parent 15902e08a7
commit 41bbf426ce
13 changed files with 12811 additions and 45 deletions

File diff suppressed because it is too large Load Diff

View File

@ -12,8 +12,12 @@
F3660DA1235EC5B100FAF849 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F3660DA0235EC5B100FAF849 /* Assets.xcassets */; };
F3660DA4235EC5B100FAF849 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F3660DA3235EC5B100FAF849 /* Preview Assets.xcassets */; };
F3660DA7235EC5B100FAF849 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F3660DA5235EC5B100FAF849 /* LaunchScreen.storyboard */; };
F3660DB2235EC62500FAF849 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3660DB1235EC62500FAF849 /* MainView.swift */; };
F3660DB5235EC9D000FAF849 /* Bundle+DecodeFromFileName.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3660DB4235EC9D000FAF849 /* Bundle+DecodeFromFileName.swift */; };
F367BF242360873600FDEB0C /* GameViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F367BF232360873600FDEB0C /* GameViewModel.swift */; };
F367BF2623608A4500FDEB0C /* GameContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F367BF2523608A4500FDEB0C /* GameContainerView.swift */; };
F367BF2823608B1600FDEB0C /* GameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F367BF2723608B1600FDEB0C /* GameView.swift */; };
F367BF2A23609FC400FDEB0C /* Appearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = F367BF2923609FC400FDEB0C /* Appearance.swift */; };
F367BF2F2360CEF800FDEB0C /* game-words.txt in Resources */ = {isa = PBXBuildFile; fileRef = F367BF2E2360CEF800FDEB0C /* game-words.txt */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@ -24,8 +28,12 @@
F3660DA3235EC5B100FAF849 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
F3660DA6235EC5B100FAF849 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
F3660DA8235EC5B100FAF849 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
F3660DB1235EC62500FAF849 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
F3660DB4235EC9D000FAF849 /* Bundle+DecodeFromFileName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+DecodeFromFileName.swift"; sourceTree = "<group>"; };
F367BF232360873600FDEB0C /* GameViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameViewModel.swift; sourceTree = "<group>"; };
F367BF2523608A4500FDEB0C /* GameContainerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GameContainerView.swift; sourceTree = "<group>"; };
F367BF2723608B1600FDEB0C /* GameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameView.swift; sourceTree = "<group>"; };
F367BF2923609FC400FDEB0C /* Appearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Appearance.swift; sourceTree = "<group>"; };
F367BF2E2360CEF800FDEB0C /* game-words.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = "game-words.txt"; path = "Resources/game-words.txt"; sourceTree = SOURCE_ROOT; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -60,11 +68,12 @@
children = (
F3660DA8235EC5B100FAF849 /* Info.plist */,
F3660D9A235EC5B000FAF849 /* AppDelegate.swift */,
F367BF2923609FC400FDEB0C /* Appearance.swift */,
F3660D9C235EC5B000FAF849 /* SceneDelegate.swift */,
F3660DA0235EC5B100FAF849 /* Assets.xcassets */,
F3660DAF235EC5FF00FAF849 /* Data */,
F3660DA5235EC5B100FAF849 /* LaunchScreen.storyboard */,
F3660DA2235EC5B100FAF849 /* Preview Content */,
F367BF2D2360CEAA00FDEB0C /* Resources */,
F3660DB0235EC60600FAF849 /* Reusables */,
F3660DAE235EC5FA00FAF849 /* Scenes */,
);
@ -82,7 +91,7 @@
F3660DAE235EC5FA00FAF849 /* Scenes */ = {
isa = PBXGroup;
children = (
F3660DB1235EC62500FAF849 /* MainView.swift */,
F367BF222360872900FDEB0C /* Game */,
);
path = Scenes;
sourceTree = "<group>";
@ -110,6 +119,25 @@
path = Extensions;
sourceTree = "<group>";
};
F367BF222360872900FDEB0C /* Game */ = {
isa = PBXGroup;
children = (
F367BF232360873600FDEB0C /* GameViewModel.swift */,
F367BF2523608A4500FDEB0C /* GameContainerView.swift */,
F367BF2723608B1600FDEB0C /* GameView.swift */,
);
path = Game;
sourceTree = "<group>";
};
F367BF2D2360CEAA00FDEB0C /* Resources */ = {
isa = PBXGroup;
children = (
F3660DA0235EC5B100FAF849 /* Assets.xcassets */,
F367BF2E2360CEF800FDEB0C /* game-words.txt */,
);
path = Resources;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -170,6 +198,7 @@
files = (
F3660DA7235EC5B100FAF849 /* LaunchScreen.storyboard in Resources */,
F3660DA4235EC5B100FAF849 /* Preview Assets.xcassets in Resources */,
F367BF2F2360CEF800FDEB0C /* game-words.txt in Resources */,
F3660DA1235EC5B100FAF849 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -183,8 +212,11 @@
files = (
F3660DB5235EC9D000FAF849 /* Bundle+DecodeFromFileName.swift in Sources */,
F3660D9B235EC5B000FAF849 /* AppDelegate.swift in Sources */,
F3660DB2235EC62500FAF849 /* MainView.swift in Sources */,
F367BF242360873600FDEB0C /* GameViewModel.swift in Sources */,
F3660D9D235EC5B000FAF849 /* SceneDelegate.swift in Sources */,
F367BF2A23609FC400FDEB0C /* Appearance.swift in Sources */,
F367BF2823608B1600FDEB0C /* GameView.swift in Sources */,
F367BF2623608A4500FDEB0C /* GameContainerView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -0,0 +1,37 @@
//
// Appearance.swift
// WordScramble
//
// Created by CypherPoet on 10/23/19.
//
//
import UIKit
enum Appearance {
enum Navbar {
static let `default`: UINavigationBarAppearance = {
let appearance = UINavigationBarAppearance()
appearance.titleTextAttributes = [
.foregroundColor: UIColor.systemPink,
]
appearance.largeTitleTextAttributes = [
.foregroundColor: UIColor.systemPink,
]
appearance.configureWithTransparentBackground()
return appearance
}()
}
static func set(navBarAppearance: UINavigationBarAppearance) {
UINavigationBar.appearance().standardAppearance = navBarAppearance
UINavigationBar.appearance().scrollEdgeAppearance = navBarAppearance
}
}

View File

@ -13,19 +13,18 @@ extension Bundle {
public func createString(
fromFileNamed fileName: String,
withExtension extensionName: String? = nil
) throws -> Result<String, Error> {
withExtension extensionName: String? = nil,
then completionHandler: ((Result<String, Error>) -> Void)
) {
guard let url = url(forResource: fileName, withExtension: extensionName) else {
return .success("")
let fileDebugName = extensionName == nil ? fileName : "\(fileName).\(extensionName)"
fatalError("No url found for file named \(fileDebugName)")
}
do {
return .success(try String(contentsOf: url))
completionHandler(.success(try String(contentsOf: url)))
} catch {
return .failure(error)
completionHandler(.failure(error))
}
}
}

View File

@ -20,12 +20,15 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
// Create the SwiftUI view that provides the window contents.
let contentView = MainView()
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
let contentView = GameContainerView()
window.rootViewController = UIHostingController(rootView: contentView)
Appearance.set(navBarAppearance: Appearance.Navbar.default)
self.window = window
window.makeKeyAndVisible()
}

View File

@ -0,0 +1,52 @@
//
// GameContainerView.swift
// WordScramble
//
// Created by CypherPoet on 10/22/19.
//
//
import SwiftUI
struct GameContainerView: View {
@ObservedObject var gameViewModel = GameViewModel()
}
// MARK: - Body
extension GameContainerView {
var body: some View {
GameView(viewModel: gameViewModel)
.onAppear {
self.loadWords()
}
}
}
// MARK: - Private Helpers
extension GameContainerView {
private func loadWords() {
Bundle.main.createString(fromFileNamed: "game-words", withExtension: "txt") { result in
switch result {
case .success(let string):
self.gameViewModel.allRootWords = string.components(separatedBy: "\n")
self.gameViewModel.startNewRound()
case .failure:
fatalError()
}
}
}
}
// MARK: - Preview
struct MainView_Previews: PreviewProvider {
static var previews: some View {
return GameContainerView(gameViewModel: GameViewModel(rootWords: sampleWords))
}
}

View File

@ -0,0 +1,69 @@
//
// GameView.swift
// WordScramble
//
// Created by CypherPoet on 10/23/19.
//
//
import SwiftUI
struct GameView: View {
@ObservedObject var viewModel: GameViewModel
}
// MARK: - Body
extension GameView {
var body: some View {
NavigationView {
VStack {
VStack {
Text("Choose an anagram for")
.font(.headline)
.fontWeight(.bold)
Text("\(viewModel.currentRootWord)")
.font(.largeTitle)
.fontWeight(.light)
.foregroundColor(.pink)
}
.padding()
TextField(
"Enter your word",
text: $viewModel.currentGuess,
onCommit: viewModel.checkNewWord
)
.textFieldStyle(RoundedBorderTextFieldStyle())
.autocapitalization(.none)
.padding()
List(viewModel.usedWords, id: \.self) { word in
Text(word)
Spacer()
Image(systemName: "\(word.count).circle")
.imageScale(.large)
}
}
.navigationBarTitle("Anagrams")
.alert(isPresented: $viewModel.shouldShowErrorAlert) {
Alert(
title: Text(self.viewModel.errorTitle),
message: Text(self.viewModel.errorMessage),
dismissButton: .default(Text("OK"))
)
}
}
}
}
// MARK: - Preview
struct GameView_Previews: PreviewProvider {
static var previews: some View {
GameView(viewModel: GameViewModel(rootWords: sampleWords))
}
}

View File

@ -0,0 +1,150 @@
//
// GameViewModel.swift
// WordScramble
//
// Created by CypherPoet on 10/23/19.
//
//
import Foundation
import Combine
import UIKit
final class GameViewModel: ObservableObject {
@Published var allRootWords: [String] = []
@Published var currentRootWord: String = ""
@Published var currentGuess: String = ""
@Published var usedWords: [String] = []
@Published var shouldShowErrorAlert: Bool = false
@Published var errorTitle = ""
@Published var errorMessage = ""
private static let textChecker = UITextChecker()
}
// MARK: - Init
extension GameViewModel {
convenience init(rootWords: [String]) {
self.init()
self.allRootWords = rootWords
startNewRound()
}
}
// MARK: - Computeds
extension GameViewModel {
var currentGuessIsOriginal: Bool {
!usedWords.contains(currentGuess)
}
var currentGuessIsAnagram: Bool {
let guessSet = NSCountedSet(array: Array(currentGuess))
let rootWordSet = NSCountedSet(array: Array(currentRootWord))
return guessSet.allSatisfy { character in
guessSet.count(for: character) <= rootWordSet.count(for: character)
}
}
var currentGuessIsRealWord: Bool {
let range = NSRange(location: 0, length: currentRootWord.utf16.count)
let misspelledRange = Self.textChecker.rangeOfMisspelledWord(
in: currentRootWord,
range: range,
startingAt: 0,
wrap: false,
language: "en"
)
return misspelledRange.location == NSNotFound
}
}
// MARK: - Public Methods
extension GameViewModel {
func startNewRound() {
usedWords.removeAll(keepingCapacity: true)
currentGuess = ""
currentRootWord = allRootWords.randomElement()!
}
func checkNewWord() {
let word = currentGuess.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
guard !word.isEmpty else { return } // TODO: Better error handling here
guard word != currentRootWord else {
setWordError(
title: "Mix it up!",
message: "Your answer shouldn't match the original word."
)
return
}
guard currentGuessIsOriginal else {
setWordError(
title: "Be original!",
message: "You've already used \"\(word)\" as an anagram for \"\(currentRootWord)\"."
)
return
}
guard currentGuessIsAnagram else {
setWordError(
title: "Try Again",
message: "\"\(word)\" is not an anagram for \"\(currentRootWord)\"."
)
return
}
guard currentGuessIsRealWord else {
setWordError(
title: "Is that a word?",
message: "Unfortunatley, we don't recognize \"\(word)\" as a valid English word 🤷‍♂️."
)
return
}
usedWords.insert(word, at: 0)
currentGuess = ""
}
}
extension GameViewModel {
func setWordError(title: String, message: String) {
errorTitle = title
errorMessage = message
shouldShowErrorAlert = true
}
}
#if DEBUG
let sampleWords = [
"coffee",
"cambridge",
"digital",
"agency",
"wordsmith",
"fahrenheit",
"network",
]
#endif

View File

@ -1,31 +0,0 @@
//
// MainView.swift
// WordScramble
//
// Created by CypherPoet on 10/22/19.
//
//
import SwiftUI
struct MainView: View {
}
// MARK: - Body
extension MainView {
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello World!"/*@END_MENU_TOKEN@*/)
}
}
// MARK: - Preview
struct MainView_Previews: PreviewProvider {
static var previews: some View {
MainView()
}
}

View File

@ -23,6 +23,7 @@ From the description:
>
> For example, if the starter word is “alarming” they might spell “alarm”, “ring”, “main”, and so on.
These "words from another word" are also known as [anagrams](https://en.wikipedia.org/wiki/Anagram).
## Introducing List, your best friend

71
day-030/README.md Normal file
View File

@ -0,0 +1,71 @@
# Day 30: _Project 5: WordScramble_ (Part One)
_Follow along at https://www.hackingwithswift.com/100/swiftui/30_.
# 📒 Field Notes
This day covers Part One of _`Project 5: WordScramble`_ in the [100 Days of SwiftUI Challenge](https://www.hackingwithswift.com/100/swiftui/30).
It focuses on several specific topics:
- Word Scramble: Introduction
- Introducing List, your best friend
- Loading resources from your app bundle
- Working with strings
## Word Scramble: Introduction
From the description:
> The game will show players a random eight-letter word, and ask them to make words out of it.
>
> For example, if the starter word is “alarming” they might spell “alarm”, “ring”, “main”, and so on.
## Introducing List, your best friend
`List`s are essentially SwiftUI's version of UIKit's TableView. But one neat difference is their ability to seamlessly integrate static and dynamic content within the same `List` element:
```swift
List {
Section(header: Text("Section 1")) {
Text("Static row 1")
Text("Static row 2")
}
Section(header: Text("Section 2")) {
ForEach(0..<5) {
Text("Dynamic row \($0)")
}
}
Section(header: Text("Section 3")) {
Text("Static row 3")
Text("Static row 4")
}
}
```
Oh... and, that tight integration with the `Section` element is pretty sweet, too 🙂.
## Loading resources from your app bundle
Whenever we have something in our app's `Bundle` that we want to deal with in code, we first need to locate it with a URL (which is why it's called a "Uniform Resource Locator").
In many cases, we'd use this URL to create an instance of `Data`, and then decode that data into some kind of structured model based upon the structure of the file.
In this app, though, we'll be grabbing the contents of a plain-text file that lacks the structure of something like JSON.
Fortunately, because Swift `String`s are weapons-grade, we can also create them directly from the content's of a file:
```swift
if let fileContents = try? String(contentsOf: fileURL) {
// we loaded the file into a string!
}
```