diff --git a/WordleWithFriends.xcodeproj/project.pbxproj b/WordleWithFriends.xcodeproj/project.pbxproj index fd6ce89..7fd098d 100644 --- a/WordleWithFriends.xcodeproj/project.pbxproj +++ b/WordleWithFriends.xcodeproj/project.pbxproj @@ -41,7 +41,7 @@ 6C19F5A927921EFB00062083 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 6C19F5A727921EFB00062083 /* LaunchScreen.storyboard */; }; 6C19F5BF27921EFC00062083 /* WordleWithFriendsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C19F5BE27921EFC00062083 /* WordleWithFriendsUITests.swift */; }; 6C19F5D127922D6700062083 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C19F5D027922D6700062083 /* String+Extension.swift */; }; - 6C19F5D62792356500062083 /* WordGuessViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C19F5D52792356500062083 /* WordGuessViewController.swift */; }; + 6C19F5D62792356500062083 /* ClueGuessViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C19F5D52792356500062083 /* ClueGuessViewController.swift */; }; 6C19F5E527923B1400062083 /* WordGuessRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C19F5E427923B1400062083 /* WordGuessRow.swift */; }; 6C19F5F027923C5900062083 /* NSLayoutConstraint+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C19F5EF27923C5900062083 /* NSLayoutConstraint+Extension.swift */; }; 6C19F5FD27923CBB00062083 /* UIView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C19F5FC27923CBB00062083 /* UIView+Extension.swift */; }; @@ -54,6 +54,12 @@ 6C78E0C12797902700C501CD /* XCUIElement+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C78E0C02797902700C501CD /* XCUIElement+Extension.swift */; }; 6C834C8E279C87160024CB13 /* GameUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C834C8D279C87160024CB13 /* GameUtility.swift */; }; 6C834C90279C897F0024CB13 /* IndexPath+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C834C8F279C897F0024CB13 /* IndexPath+Extension.swift */; }; + 6C94887127D07FD4005252F1 /* WordleKeyboardInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C94887027D07FD4005252F1 /* WordleKeyboardInputView.swift */; }; + 6C94887327D08598005252F1 /* WordleKeyboardKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C94887227D08598005252F1 /* WordleKeyboardKey.swift */; }; + 6C94887627D098C7005252F1 /* WeakRef.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C94887527D098C7005252F1 /* WeakRef.swift */; }; + 6C94887827D0AAD9005252F1 /* LetterState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C94887727D0AAD9005252F1 /* LetterState.swift */; }; + 6C94887C27D0AB15005252F1 /* LetterStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C94887B27D0AB15005252F1 /* LetterStateTests.swift */; }; + 6C94887E27D0B4E4005252F1 /* KeyboardRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C94887D27D0B4E4005252F1 /* KeyboardRow.swift */; }; 6CBBCECF279BDC7D00875C30 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CBBCECE279BDC7D00875C30 /* Double+Extension.swift */; }; 6CBCC3CD2797565D005EB254 /* DismissableAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CBCC3CC2797565D005EB254 /* DismissableAlertController.swift */; }; 6CBCC3D1279764FF005EB254 /* CGPoint+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CBCC3D0279764FF005EB254 /* CGPoint+Extension.swift */; }; @@ -119,7 +125,7 @@ 6C19F5BE27921EFC00062083 /* WordleWithFriendsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordleWithFriendsUITests.swift; sourceTree = ""; }; 6C19F5C027921EFC00062083 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 6C19F5D027922D6700062083 /* String+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = ""; }; - 6C19F5D52792356500062083 /* WordGuessViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordGuessViewController.swift; sourceTree = ""; }; + 6C19F5D52792356500062083 /* ClueGuessViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClueGuessViewController.swift; sourceTree = ""; }; 6C19F5E427923B1400062083 /* WordGuessRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordGuessRow.swift; sourceTree = ""; }; 6C19F5EF27923C5900062083 /* NSLayoutConstraint+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSLayoutConstraint+Extension.swift"; sourceTree = ""; }; 6C19F5FC27923CBB00062083 /* UIView+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Extension.swift"; sourceTree = ""; }; @@ -132,6 +138,12 @@ 6C78E0C02797902700C501CD /* XCUIElement+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCUIElement+Extension.swift"; sourceTree = ""; }; 6C834C8D279C87160024CB13 /* GameUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameUtility.swift; sourceTree = ""; }; 6C834C8F279C897F0024CB13 /* IndexPath+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IndexPath+Extension.swift"; sourceTree = ""; }; + 6C94887027D07FD4005252F1 /* WordleKeyboardInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordleKeyboardInputView.swift; sourceTree = ""; }; + 6C94887227D08598005252F1 /* WordleKeyboardKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordleKeyboardKey.swift; sourceTree = ""; }; + 6C94887527D098C7005252F1 /* WeakRef.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakRef.swift; sourceTree = ""; }; + 6C94887727D0AAD9005252F1 /* LetterState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterState.swift; sourceTree = ""; }; + 6C94887B27D0AB15005252F1 /* LetterStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LetterStateTests.swift; sourceTree = ""; }; + 6C94887D27D0B4E4005252F1 /* KeyboardRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardRow.swift; sourceTree = ""; }; 6CBBCECE279BDC7D00875C30 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = ""; }; 6CBCC3CC2797565D005EB254 /* DismissableAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissableAlertController.swift; sourceTree = ""; }; 6CBCC3D0279764FF005EB254 /* CGPoint+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGPoint+Extension.swift"; sourceTree = ""; }; @@ -177,6 +189,7 @@ children = ( 60D948032793A72800086C51 /* LayoutUtility.swift */, 6C834C8D279C87160024CB13 /* GameUtility.swift */, + 6C94887527D098C7005252F1 /* WeakRef.swift */, ); path = Utils; sourceTree = ""; @@ -272,7 +285,7 @@ 6C19F59E27921EFA00062083 /* SceneDelegate.swift */, 6C19F5A027921EFA00062083 /* GameSetupViewController.swift */, 60D948072793BB0D00086C51 /* Settings */, - 6C19F5D52792356500062083 /* WordGuessViewController.swift */, + 6C19F5D52792356500062083 /* ClueGuessViewController.swift */, 6C19F5A527921EFB00062083 /* Assets.xcassets */, 6C19F5A727921EFB00062083 /* LaunchScreen.storyboard */, 6C19F5AA27921EFC00062083 /* Info.plist */, @@ -319,6 +332,7 @@ 6C19F5E327923B0B00062083 /* Views */ = { isa = PBXGroup; children = ( + 6C94887427D08C64005252F1 /* CustomKeyboard */, 6C19F5E427923B1400062083 /* WordGuessRow.swift */, 6C19F60127923F8400062083 /* LetterTileView.swift */, 6C0009E12792AE8200BCC9B6 /* WordInputTextField.swift */, @@ -333,6 +347,7 @@ 6C39559E279288D100B0EF29 /* WordGuess.swift */, 6C3955A0279288E800B0EF29 /* LetterGuess.swift */, 60D948002793A46700086C51 /* GameEndDelegate.swift */, + 6C94887727D0AAD9005252F1 /* LetterState.swift */, ); path = Models; sourceTree = ""; @@ -343,6 +358,7 @@ 6C3955A22792890F00B0EF29 /* WordGuessTests.swift */, 60D947F927938A6900086C51 /* LetterGuessTests.swift */, 60D947FB27938AD200086C51 /* GameGuessesModelTests.swift */, + 6C94887B27D0AB15005252F1 /* LetterStateTests.swift */, ); path = Models; sourceTree = ""; @@ -355,6 +371,16 @@ path = Extensions; sourceTree = ""; }; + 6C94887427D08C64005252F1 /* CustomKeyboard */ = { + isa = PBXGroup; + children = ( + 6C94887027D07FD4005252F1 /* WordleKeyboardInputView.swift */, + 6C94887227D08598005252F1 /* WordleKeyboardKey.swift */, + 6C94887D27D0B4E4005252F1 /* KeyboardRow.swift */, + ); + path = CustomKeyboard; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -425,6 +451,7 @@ }; 6C19F5AE27921EFC00062083 = { CreatedOnToolsVersion = 12.2; + LastSwiftMigration = 1320; TestTargetID = 6C19F59827921EFA00062083; }; 6C19F5B927921EFC00062083 = { @@ -498,7 +525,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 6C19F5D62792356500062083 /* WordGuessViewController.swift in Sources */, + 6C19F5D62792356500062083 /* ClueGuessViewController.swift in Sources */, 6C19F60B2792439600062083 /* GameGuessesModel.swift in Sources */, 60D948102793E2BC00086C51 /* GameSettingTableViewCell.swift in Sources */, 6C3955A1279288E800B0EF29 /* LetterGuess.swift in Sources */, @@ -514,15 +541,20 @@ 60D948092793BD0300086C51 /* GameSettings.swift in Sources */, 6CBCC3D32797657B005EB254 /* CGSize+Extension.swift in Sources */, 6C19F5E527923B1400062083 /* WordGuessRow.swift in Sources */, + 6C94887127D07FD4005252F1 /* WordleKeyboardInputView.swift in Sources */, 6CBBCECF279BDC7D00875C30 /* Double+Extension.swift in Sources */, 6C19F60227923F8400062083 /* LetterTileView.swift in Sources */, 6CBCC3CD2797565D005EB254 /* DismissableAlertController.swift in Sources */, 60D948062793AE1400086C51 /* GameSettingsViewController.swift in Sources */, + 6C94887827D0AAD9005252F1 /* LetterState.swift in Sources */, 6C834C8E279C87160024CB13 /* GameUtility.swift in Sources */, 60D948142793E50100086C51 /* GameSettingProtocol.swift in Sources */, 6C0009E22792AE8200BCC9B6 /* WordInputTextField.swift in Sources */, 6C19F59F27921EFA00062083 /* SceneDelegate.swift in Sources */, + 6C94887327D08598005252F1 /* WordleKeyboardKey.swift in Sources */, + 6C94887627D098C7005252F1 /* WeakRef.swift in Sources */, 60D948012793A46700086C51 /* GameEndDelegate.swift in Sources */, + 6C94887E27D0B4E4005252F1 /* KeyboardRow.swift in Sources */, 6CBCC3D1279764FF005EB254 /* CGPoint+Extension.swift in Sources */, 6C19F6162792491900062083 /* UIStackView+Extension.swift in Sources */, 60D947FF2793A23700086C51 /* GameMessagingViewController.swift in Sources */, @@ -536,6 +568,7 @@ buildActionMask = 2147483647; files = ( 6C3955A32792890F00B0EF29 /* WordGuessTests.swift in Sources */, + 6C94887C27D0AB15005252F1 /* LetterStateTests.swift in Sources */, 60D947FA27938A6900086C51 /* LetterGuessTests.swift in Sources */, 60D947FC27938AD200086C51 /* GameGuessesModelTests.swift in Sources */, ); @@ -736,6 +769,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 2A23N78DMF; INFOPLIST_FILE = WordleWithFriendsTests/Info.plist; @@ -747,6 +781,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = letsgooo.WordleWithFriendsTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/WordleWithFriends.app/WordleWithFriends"; @@ -758,6 +793,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 2A23N78DMF; INFOPLIST_FILE = WordleWithFriendsTests/Info.plist; diff --git a/WordleWithFriends/WordGuessViewController.swift b/WordleWithFriends/ClueGuessViewController.swift similarity index 84% rename from WordleWithFriends/WordGuessViewController.swift rename to WordleWithFriends/ClueGuessViewController.swift index d11f64b..7ea07da 100644 --- a/WordleWithFriends/WordGuessViewController.swift +++ b/WordleWithFriends/ClueGuessViewController.swift @@ -1,13 +1,14 @@ // -// WordGuessViewController.swift +// ClueGuessViewController.swift // WordleWithFriends // // Created by Geoffrey Liu on 1/14/22. // import UIKit +import AudioToolbox -final class WordGuessViewController: UIViewController { +final class ClueGuessViewController: UIViewController { // Extremely hacky workaround of table jumpiness when reloading... // https://stackoverflow.com/questions/28244475/reloaddata-of-uitableview-with-dynamic-cell-heights-causes-jumpy-scrolling @@ -34,6 +35,12 @@ final class WordGuessViewController: UIViewController { return tableView }() + private lazy var wordleKeyboard: WordleKeyboardInputView = { + let inputView = WordleKeyboardInputView() + inputView.delegate = self + return inputView + }() + private lazy var guessInputTextField: UITextField = { let textField = UITextField() textField.translatesAutoresizingMaskIntoConstraints = false @@ -44,6 +51,7 @@ final class WordGuessViewController: UIViewController { textField.delegate = self textField.layer.borderWidth = 1 textField.layer.borderColor = UIColor.darkText.cgColor + textField.inputView = wordleKeyboard return textField }() @@ -90,7 +98,7 @@ final class WordGuessViewController: UIViewController { loadingView.pin(to: view.safeAreaLayoutGuide) guessInputTextField.becomeFirstResponder() - title = "Guess the word" + title = "Guess the clue" navigationItem.rightBarButtonItem = shareButton } @@ -140,8 +148,25 @@ final class WordGuessViewController: UIViewController { } private func submitGuess() { + // TODO: Move some checks to view model??? + guard let wordGuess = guessInputTextField.text, + wordGuess.count == GameSettings.clueLength.readIntValue(), + GameSettings.allowNonDictionaryGuesses.readBoolValue() || wordGuess.isARealWord() else { + gameGuessesModel.markInvalidGuess() + let currentIndexPath = IndexPath.Row(gameGuessesModel.numberOfGuesses) + guessTable.reloadRows(at: [currentIndexPath], with: .none) + + AudioServicesPlayAlertSound(SystemSoundID(kSystemSoundID_Vibrate)) + + return + } + let gameState = gameGuessesModel.submitGuess() + if let mostRecentGuess = gameGuessesModel.mostRecentGuess { + wordleKeyboard.updateState(with: mostRecentGuess) + } + guessTable.reloadData() guessInputTextField.text = "" @@ -189,7 +214,7 @@ final class WordGuessViewController: UIViewController { } } -extension WordGuessViewController: UITableViewDelegate, UITableViewDataSource { +extension ClueGuessViewController: UITableViewDelegate, UITableViewDataSource { func numberOfSections(in tableView: UITableView) -> Int { 1 } @@ -253,18 +278,8 @@ extension WordGuessViewController: UITableViewDelegate, UITableViewDataSource { } } -extension WordGuessViewController: UITextFieldDelegate { +extension ClueGuessViewController: UITextFieldDelegate { func textFieldShouldReturn(_ textField: UITextField) -> Bool { - // this is how we submit a guess - guard let wordGuess = textField.text, - wordGuess.count == GameSettings.clueLength.readIntValue(), - GameSettings.allowNonDictionaryGuesses.readBoolValue() || wordGuess.isARealWord() else { - gameGuessesModel.markInvalidGuess() - let currentIndexPath = IndexPath.Row(gameGuessesModel.numberOfGuesses) - guessTable.reloadRows(at: [currentIndexPath], with: .none) - return false - } - submitGuess() return false } @@ -276,18 +291,14 @@ extension WordGuessViewController: UITextFieldDelegate { return false } + // Note: We still need this function as users can use bluetooth keyboard etc. to bypass the onscreen input func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { - guard !gameGuessesModel.isGameOver else { - return false - } - - guard string.isLettersOnly() else { - return false - } - - guard (textField.text?.count ?? 0) + string.count <= GameSettings.clueLength.readIntValue() else { - return false - } + guard !gameGuessesModel.isGameOver, + string.isLettersOnly(), + (textField.text?.count ?? 0) + string.count <= GameSettings.clueLength.readIntValue() else { + AudioServicesPlayAlertSound(SystemSoundID(kSystemSoundID_Vibrate)) + return false + } gameGuessesModel.clearInvalidGuess() @@ -295,7 +306,7 @@ extension WordGuessViewController: UITextFieldDelegate { } } -extension WordGuessViewController: GameEndDelegate { +extension ClueGuessViewController: GameEndDelegate { func shareResult() { shareAction(nil) } @@ -305,9 +316,11 @@ extension WordGuessViewController: GameEndDelegate { } func restartWithNewClue() { - let newClue = GameUtility.pickWord(length: GameSettings.clueLength.readIntValue()) + let newClue = GameUtility.pickWord() gameGuessesModel = GameGuessesModel(clue: newClue) + wordleKeyboard.resetKeyboard() + DispatchQueue.main.async { [weak self] in self?.guessTable.reloadData() // TODO: In the future might have to reset `cellHeightCache` @@ -315,3 +328,24 @@ extension WordGuessViewController: GameEndDelegate { } } } + +extension ClueGuessViewController: KeyTapDelegate { + func didTapKey(_ char: Character) { + guard !gameGuessesModel.isGameOver, + guessInputTextField.text?.isLettersOnly() ?? false, + (guessInputTextField.text?.count ?? 0) < GameSettings.clueLength.readIntValue() else { + AudioServicesPlayAlertSound(SystemSoundID(kSystemSoundID_Vibrate)) + return + } + + guessInputTextField.insertText("\(char)") + } + + func didTapSubmit() { + submitGuess() + } + + func didTapDelete() { + guessInputTextField.deleteBackward() + } +} diff --git a/WordleWithFriends/GameSetupViewController.swift b/WordleWithFriends/GameSetupViewController.swift index 77eef64..d3291f3 100644 --- a/WordleWithFriends/GameSetupViewController.swift +++ b/WordleWithFriends/GameSetupViewController.swift @@ -167,20 +167,25 @@ final class GameSetupViewController: UIViewController { } @objc private func initiateGameWithRandomWord() { - clueTextField.text = GameUtility.pickWord(length: GameSettings.clueLength.readIntValue()) + var clue = "" + repeat { + clue = GameUtility.pickWord() + } while !clue.isARealWord() + + clueTextField.text = clue initiateGame(.computer) } private func initiateGame(_ clueSource: ClueSource) { // start game - let wordGuessVC = WordGuessViewController(clue: clueTextField.text?.uppercased() ?? "", clueSource: clueSource) + let clueGuessVC = ClueGuessViewController(clue: clueTextField.text?.uppercased() ?? "", clueSource: clueSource) clueTextField.text = "" startGameButton.isEnabled = false clueTextField.resignFirstResponder() - navigationController?.pushViewController(wordGuessVC, animated: true) + navigationController?.pushViewController(clueGuessVC, animated: true) } } diff --git a/WordleWithFriends/Models/GameGuessesModel.swift b/WordleWithFriends/Models/GameGuessesModel.swift index 455b9ec..f500433 100644 --- a/WordleWithFriends/Models/GameGuessesModel.swift +++ b/WordleWithFriends/Models/GameGuessesModel.swift @@ -25,12 +25,18 @@ struct GameGuessesModel { return letterGuesses[index] } + var mostRecentGuess: WordGuess? { + guard numberOfGuesses >= 1 else { return nil } + return letterGuesses[numberOfGuesses - 1] + } + mutating func updateGuess(_ newGuess: String) { letterGuesses[letterGuesses.count - 1].updateGuess(newGuess) } /// Submit a guess /// - Returns: if the user guessed the word correctly + @discardableResult mutating func submitGuess() -> GameState { let didGuessCorrectly = letterGuesses[letterGuesses.count - 1].checkGuess(against: clue) @@ -63,14 +69,6 @@ struct GameGuessesModel { } } -enum LetterState: Character { - case unchecked = "⬛️" - case correct = "🟩" - case misplaced = "🟨" - case incorrect = "⬜️" - case invalid = "🟥" -} - enum GameState { case win case lose diff --git a/WordleWithFriends/Models/LetterGuess.swift b/WordleWithFriends/Models/LetterGuess.swift index 5005557..c809986 100644 --- a/WordleWithFriends/Models/LetterGuess.swift +++ b/WordleWithFriends/Models/LetterGuess.swift @@ -18,3 +18,5 @@ struct LetterGuess { self.state = state } } + +extension LetterGuess: Equatable { } diff --git a/WordleWithFriends/Models/LetterState.swift b/WordleWithFriends/Models/LetterState.swift new file mode 100644 index 0000000..6a90474 --- /dev/null +++ b/WordleWithFriends/Models/LetterState.swift @@ -0,0 +1,52 @@ +// +// LetterState.swift +// WordleWithFriends +// +// Created by Geoffrey Liu on 3/2/22. +// + +import UIKit + +enum LetterState: Character { + case unchecked = "⬛️" + case correct = "🟩" + case misplaced = "🟨" + case incorrect = "⬜️" + case invalid = "🟥" + + var associatedColor: UIColor { + switch self { + case .unchecked: + return .systemBackground + case .correct: + return .systemGreen + case .misplaced: + return .systemYellow + case .incorrect: + return .systemGray + case .invalid: + return .clear + } + } + + var priority: Int { + switch self { + case .unchecked: + return -1 + case .correct: + return 10 + case .misplaced: + return 5 + case .incorrect: + return 0 + case .invalid: + return -100 + } + } +} + +extension LetterState: Comparable { + static func < (lhs: LetterState, rhs: LetterState) -> Bool { + lhs.priority < rhs.priority + } +} diff --git a/WordleWithFriends/Models/WordGuess.swift b/WordleWithFriends/Models/WordGuess.swift index 14fd2eb..401343e 100644 --- a/WordleWithFriends/Models/WordGuess.swift +++ b/WordleWithFriends/Models/WordGuess.swift @@ -10,8 +10,15 @@ import Foundation struct WordGuess { private var guess: [LetterGuess] - init() { - guess = [] + init(guess: String = "") { + self.guess = [] + updateGuess(guess) + } + + var word: String { + guess.reduce("") { + "\($0)\($1.letter)" + } } mutating func forceState(_ state: LetterState) { @@ -71,3 +78,11 @@ struct WordGuess { guess.reduce("") { $0 + String($1.state.rawValue) } } } + +extension WordGuess: Equatable { } + +extension WordGuess: Sequence { + func makeIterator() -> IndexingIterator> { + guess.makeIterator() + } +} diff --git a/WordleWithFriends/Utils/GameUtility.swift b/WordleWithFriends/Utils/GameUtility.swift index 3012998..4cf9776 100644 --- a/WordleWithFriends/Utils/GameUtility.swift +++ b/WordleWithFriends/Utils/GameUtility.swift @@ -8,7 +8,7 @@ import Foundation struct GameUtility { - static func pickWord(length: Int) -> String { + static func pickWord(length: Int = GameSettings.clueLength.readIntValue()) -> String { guard let path = Bundle.main.path(forResource: "words_\(GameSettings.clueLength.readIntValue())letters", ofType: "txt"), let data = try? String(contentsOfFile: path) else { return "" } diff --git a/WordleWithFriends/Utils/WeakRef.swift b/WordleWithFriends/Utils/WeakRef.swift new file mode 100644 index 0000000..923691e --- /dev/null +++ b/WordleWithFriends/Utils/WeakRef.swift @@ -0,0 +1,15 @@ +// +// WeakRef.swift +// WordleWithFriends +// +// Created by Geoffrey Liu on 3/2/22. +// + +class WeakRef where T: AnyObject { + + private(set) weak var value: T? + + init(value: T?) { + self.value = value + } +} diff --git a/WordleWithFriends/Views/CustomKeyboard/KeyboardRow.swift b/WordleWithFriends/Views/CustomKeyboard/KeyboardRow.swift new file mode 100644 index 0000000..3645934 --- /dev/null +++ b/WordleWithFriends/Views/CustomKeyboard/KeyboardRow.swift @@ -0,0 +1,74 @@ +// +// KeyboardRow.swift +// WordleWithFriends +// +// Created by Geoffrey Liu on 3/3/22. +// + +import UIKit + +final class KeyboardRow: UIStackView { + struct Layout { + static let interKeySpacing = 4.0 + static let specialKeySpacing = 8.0 + static let specialKeyWidthMultiplier = 1.5 + static let heightToWidthRatio = 1.4 + } + + var delegate: KeyTapDelegate? + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init(coder: NSCoder) { + super.init(coder: coder) + setupView() + } + + private func setupView() { + translatesAutoresizingMaskIntoConstraints = false + spacing = Layout.interKeySpacing + axis = .horizontal + alignment = .fill + } + + @discardableResult + func configure(keys: [Character], keyWidth: CGFloat, isLastRow: Bool = false) -> [WeakRef]{ + var keyReferences: [WeakRef] = [] + + if isLastRow { + // last row must add Enter key (Submit guess) + let enterKey = WordleKeyboardKey(keyType: .submit) + enterKey.delegate = delegate + enterKey.widthAnchor.constraint(equalToConstant: keyWidth * Layout.specialKeyWidthMultiplier).isActive = true + addArrangedSubview(enterKey) + setCustomSpacing(Layout.specialKeySpacing, after: enterKey) + } + + keys.enumerated().forEach { index, char in + let keyView = WordleKeyboardKey(keyType: .char(char)) + keyView.delegate = delegate + keyView.widthAnchor.constraint(equalToConstant: keyWidth).isActive = true + addArrangedSubview(keyView) + + keyReferences.append(WeakRef(value: keyView)) + } + + if isLastRow { + if let lastKey = arrangedSubviews.last { + setCustomSpacing(Layout.specialKeySpacing, after: lastKey) + } + // last row must add Backspace key + let backspaceKey = WordleKeyboardKey(keyType: .del) + backspaceKey.delegate = delegate + backspaceKey.widthAnchor.constraint(equalToConstant: keyWidth * Layout.specialKeyWidthMultiplier).isActive = true + addArrangedSubview(backspaceKey) + } + + heightAnchor.constraint(equalToConstant: keyWidth * Layout.heightToWidthRatio).isActive = true + + return keyReferences + } +} diff --git a/WordleWithFriends/Views/CustomKeyboard/WordleKeyboardInputView.swift b/WordleWithFriends/Views/CustomKeyboard/WordleKeyboardInputView.swift new file mode 100644 index 0000000..82c246a --- /dev/null +++ b/WordleWithFriends/Views/CustomKeyboard/WordleKeyboardInputView.swift @@ -0,0 +1,125 @@ +// +// WordleKeyboardInputView.swift +// WordleWithFriends +// +// Created by Geoffrey Liu on 3/2/22. +// + +import Foundation +import UIKit + +protocol KeyTapDelegate { + func didTapKey(_ char: Character) + func didTapSubmit() + func didTapDelete() +} + +final class WordleKeyboardInputView: UIInputView { + private struct Layout { + static let rowSpacing = 8.0 + static let topPadding = 8.0 + } + private var keyReferences: [WeakRef] = [] + + // TODO make customizable? + private static let keyboardLayout: [[Character]] = [[ + "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", + ], [ + "A", "S", "D", "F", "G", "H", "J", "K", "L", + ], [ + "Z", "X", "C", "V", "B", "N", "M", + ]] + + var delegate: KeyTapDelegate? { + didSet { + setupKeyboard() + } + } + + private lazy var mainStackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.alignment = .center + stackView.axis = .vertical + stackView.distribution = .equalSpacing + stackView.spacing = Layout.rowSpacing + + return stackView + }() + + static func getPortraitModeKeyWidth() -> CGFloat { + let keyboardWidth = UIScreen.main.bounds.width + let keyboardRowKeyWidths = keyboardLayout.enumerated().map { index, row -> CGFloat in + let isLastRow = index == keyboardLayout.count - 1 + + let totalSpace: Double; let keysInRow: Double + if isLastRow { + totalSpace = KeyboardRow.Layout.interKeySpacing * Double(row.count + 1) + 2 * KeyboardRow.Layout.specialKeySpacing + keysInRow = Double(row.count) + 2.0 + (2.0 * (KeyboardRow.Layout.specialKeyWidthMultiplier - 1.0)) + } else { + totalSpace = KeyboardRow.Layout.interKeySpacing * Double(row.count + 1) + keysInRow = Double(row.count) + } + + return CGFloat((keyboardWidth - totalSpace) / keysInRow) + } + + return keyboardRowKeyWidths.min() ?? .zero + } + + func resetKeyboard() { + setupKeyboard() + } + + private func setupKeyboard(keyWidth: CGFloat = getPortraitModeKeyWidth()) { + backgroundColor = .tertiarySystemFill + translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + widthAnchor.constraint(equalToConstant: UIScreen.main.bounds.size.width), + ]) + + mainStackView.removeAllArrangedSubviews() + keyReferences = [] + + type(of: self).keyboardLayout.enumerated().forEach { index, row in + let keyboardRow = KeyboardRow() + keyboardRow.delegate = delegate + + let isLastRow = index == type(of: self).keyboardLayout.count - 1 + + let keyRowRefs = keyboardRow.configure(keys: row, keyWidth: keyWidth, isLastRow: isLastRow) + + keyReferences.append(contentsOf: keyRowRefs) + + mainStackView.addArrangedSubview(keyboardRow) + } + + // Sort key references A->Z for better lookup later + keyReferences.sort { keyRef1, keyRef2 in + guard let key1 = keyRef1.value, let key2 = keyRef2.value, + case KeyType.char(let char1) = key1.keyType, + case KeyType.char(let char2) = key2.keyType else { return false } + + return char1 < char2 + } + + addSubview(mainStackView) + NSLayoutConstraint.activate([ + mainStackView.topAnchor.constraint(equalTo: topAnchor, constant: Layout.topPadding), + mainStackView.centerXAnchor.constraint(equalTo: centerXAnchor), + ]) + } + + func updateState(with wordGuess: WordGuess) { + wordGuess.forEach { letterGuess in + guard let guessAsciiValue = letterGuess.letter.asciiValue else { return } + let indexInRefArray = Int(guessAsciiValue - (Character("A").asciiValue ?? 65)) + keyReferences[indexInRefArray].value?.updateGuessState(letterGuess.state) + } + } +} + +extension WordleKeyboardInputView: UIInputViewAudioFeedback { + var enableInputClicksWhenVisible: Bool { true } +} diff --git a/WordleWithFriends/Views/CustomKeyboard/WordleKeyboardKey.swift b/WordleWithFriends/Views/CustomKeyboard/WordleKeyboardKey.swift new file mode 100644 index 0000000..7c7cbca --- /dev/null +++ b/WordleWithFriends/Views/CustomKeyboard/WordleKeyboardKey.swift @@ -0,0 +1,83 @@ +// +// WordleKeyboardKey.swift +// WordleWithFriends +// +// Created by Geoffrey Liu on 3/2/22. +// + +import Foundation +import UIKit +import AudioToolbox + +enum KeyType { + case char(Character) + case submit + case del +} + +final class WordleKeyboardKey: UIButton { + var keyType: KeyType { + didSet { + switch keyType { + case .char(let character): + setTitle("\(character)", for: .normal) + case .submit: + setTitle("⏎", for: .normal) + case .del: + setTitle("⌫", for: .normal) + } + } + } + + private var guessState: LetterState = .unchecked { + didSet { + backgroundColor = guessState.associatedColor // TODO verify + } + } + + var delegate: KeyTapDelegate? + + init(keyType: KeyType) { + self.keyType = .del + defer { + self.keyType = keyType + } + super.init(frame: .zero) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + translatesAutoresizingMaskIntoConstraints = false + + layer.cornerRadius = 3.0 + layer.masksToBounds = false + backgroundColor = guessState.associatedColor + + titleLabel?.font = titleLabel?.font.withSize(24.0) + titleLabel?.numberOfLines = 1 + addTarget(self, action: #selector(didTapKey), for: .touchUpInside) + } + + func updateGuessState(_ state: LetterState) { + guard state.priority > guessState.priority else { return } + guessState = state + } + + @objc private func didTapKey() { + switch keyType { + case .char(let character): + delegate?.didTapKey(character) + UIDevice.current.playInputClick() + case .submit: + delegate?.didTapSubmit() + AudioServicesPlaySystemSound(1156) + case .del: + delegate?.didTapDelete() + AudioServicesPlaySystemSound(1155) + } + } +} diff --git a/WordleWithFriends/Views/LetterTileView.swift b/WordleWithFriends/Views/LetterTileView.swift index 2732f75..0348345 100644 --- a/WordleWithFriends/Views/LetterTileView.swift +++ b/WordleWithFriends/Views/LetterTileView.swift @@ -50,20 +50,16 @@ final class LetterTileView: UIView { layer.borderWidth = 1.0 switch letterGuess.state { - case .correct: - backgroundColor = .systemGreen - case .misplaced: - backgroundColor = .systemYellow - case .incorrect: - backgroundColor = .systemGray case .unchecked: if letterGuess.letter != .space { layer.borderWidth = 3.0 } - backgroundColor = .systemBackground case .invalid: layer.borderColor = UIColor.systemRed.cgColor layer.borderWidth = 3.0 + default: break } + + backgroundColor = letterGuess.state.associatedColor } } diff --git a/WordleWithFriendsTests/Models/GameGuessesModelTests.swift b/WordleWithFriendsTests/Models/GameGuessesModelTests.swift index fe189ed..7f25647 100644 --- a/WordleWithFriendsTests/Models/GameGuessesModelTests.swift +++ b/WordleWithFriendsTests/Models/GameGuessesModelTests.swift @@ -10,9 +10,9 @@ import XCTest final class GameGuessesModelTests: XCTestCase { func testInitialConditions() { - let model = GameGuessesModel() + let model = GameGuessesModel(clue: "GOOSE") let maxGuesses = GameSettings.maxGuesses.readIntValue() - XCTAssertEqual(model.clue, "") + XCTAssertEqual(model.clue, "GOOSE") XCTAssertEqual(model.isGameOver, false) XCTAssertEqual(model.numberOfGuesses, 0) @@ -22,4 +22,24 @@ final class GameGuessesModelTests: XCTestCase { model.copyResult() XCTAssertEqual(UIPasteboard.general.string, "Wordle With Friends - 0/\(maxGuesses)\n\n") } + + // MARK: - mostRecentGuess + + func testMostRecentGuessWhenNoneExist() { + XCTAssertNil(GameGuessesModel(clue: "COOKS").mostRecentGuess) + } + + func testMostRecentGuessWhenIncompleteGuessExists() { + var model = GameGuessesModel(clue: "COOKS") + model.updateGuess("CORKS") + XCTAssertNil(model.mostRecentGuess) + } + + func testMostRecentGuessWhenOneCompleteGuessExists() { + var model = GameGuessesModel(clue: "COOKS") + model.updateGuess("CORKS") + model.submitGuess() + + XCTAssertEqual(model.mostRecentGuess?.word, "CORKS") + } } diff --git a/WordleWithFriendsTests/Models/LetterStateTests.swift b/WordleWithFriendsTests/Models/LetterStateTests.swift new file mode 100644 index 0000000..52bcac1 --- /dev/null +++ b/WordleWithFriendsTests/Models/LetterStateTests.swift @@ -0,0 +1,19 @@ +// +// LetterStateTests.swift +// WordleWithFriendsTests +// +// Created by Geoffrey Liu on 3/2/22. +// + +import XCTest +@testable import WordleWithFriends + +final class LetterStateTests: XCTestCase { + // MARK: - priority + func testPriority() { + XCTAssertGreaterThan(LetterState.correct, LetterState.misplaced) + XCTAssertGreaterThan(LetterState.misplaced, LetterState.incorrect) + XCTAssertGreaterThan(LetterState.incorrect, LetterState.unchecked) + XCTAssertGreaterThan(LetterState.unchecked, LetterState.invalid) + } +} diff --git a/WordleWithFriendsTests/Models/WordGuessTests.swift b/WordleWithFriendsTests/Models/WordGuessTests.swift index 97d5bac..90318fc 100644 --- a/WordleWithFriendsTests/Models/WordGuessTests.swift +++ b/WordleWithFriendsTests/Models/WordGuessTests.swift @@ -9,6 +9,16 @@ import XCTest @testable import WordleWithFriends class WordGuessTests: XCTestCase { + // MARK: - word + func testWordFromEmptyGuess() { + XCTAssertEqual(WordGuess().word, "") + } + + func testWordFromNonEmptyGuess() { + XCTAssertEqual(WordGuess(guess: "FIRST").word, "FIRST") + } + + // MARK: - checkGuess func testSmokeTests() { validateGuess("CATER", against: "RATED", pattern: [.incorrect, .correct, .correct, .correct, .misplaced]) validateGuess("SMOKE", against: "WATER", pattern: [.incorrect, .incorrect, .incorrect, .incorrect, .misplaced]) @@ -44,8 +54,7 @@ class WordGuessTests: XCTestCase { func testForceStateMisplaced() { let guess = "CRACK" - var model = WordGuess() - model.updateGuess(guess) + var model = WordGuess(guess: guess) model.forceState(.misplaced) (0..