Implement the core game logic for WordScramble
This commit is contained in:
parent
15902e08a7
commit
41bbf426ce
File diff suppressed because it is too large
Load Diff
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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!
|
||||
}
|
||||
```
|
||||
|
||||
|
Loading…
Reference in New Issue