diff --git a/Packages/TTTKit/.gitignore b/Packages/TTTKit/.gitignore new file mode 100644 index 00000000..3b298120 --- /dev/null +++ b/Packages/TTTKit/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Packages/TTTKit/Package.swift b/Packages/TTTKit/Package.swift new file mode 100644 index 00000000..6d046421 --- /dev/null +++ b/Packages/TTTKit/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "TTTKit", + platforms: [ + .iOS(.v15), + ], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "TTTKit", + targets: ["TTTKit"]), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "TTTKit", + dependencies: []), + .testTarget( + name: "TTTKitTests", + dependencies: ["TTTKit"]), + ] +) diff --git a/Packages/TTTKit/README.md b/Packages/TTTKit/README.md new file mode 100644 index 00000000..4b05a0e3 --- /dev/null +++ b/Packages/TTTKit/README.md @@ -0,0 +1,3 @@ +# TTTKit + +A description of this package. diff --git a/Packages/TTTKit/Sources/TTTKit/AI/GameModel.swift b/Packages/TTTKit/Sources/TTTKit/AI/GameModel.swift new file mode 100644 index 00000000..ea71cef6 --- /dev/null +++ b/Packages/TTTKit/Sources/TTTKit/AI/GameModel.swift @@ -0,0 +1,152 @@ +// +// GameModel.swift +// TTTKit +// +// Created by Shadowfacts on 12/21/22. +// + +import Foundation +import GameKit + +public class GameModel: NSObject, NSCopying, GKGameModel { + private var controller: GameController + + public init(controller: GameController) { + self.controller = controller + } + + // MARK: GKGameModel + + public var players: [GKGameModelPlayer]? { + [Player.x, Player.o] + } + + public var activePlayer: GKGameModelPlayer? { + switch controller.state { + case .playAnywhere(let mark), .playSpecific(let mark, column: _, row: _): + switch mark { + case .x: + return Player.x + case .o: + return Player.o + } + case .end(_): + return nil + } + } + + public func setGameModel(_ gameModel: GKGameModel) { + let other = (gameModel as! GameModel).controller + self.controller = GameController(state: other.state, board: other.board) + } + + public func gameModelUpdates(for player: GKGameModelPlayer) -> [GKGameModelUpdate]? { + guard let player = player as? Player else { + return nil + } + let mark = player.mark + + switch controller.state { + case .playAnywhere: + return updatesForPlayAnywhere(mark: mark) + case .playSpecific(_, column: let col, row: let row): + return updatesForPlaySpecific(mark: mark, board: (col, row)) + case .end: + return nil + } + } + + public func apply(_ gameModelUpdate: GKGameModelUpdate) { + let update = gameModelUpdate as! Update + switch controller.state { + case .playAnywhere(update.mark), .playSpecific(update.mark, column: update.subBoard.column, row: update.subBoard.row): + break + default: + fatalError() + } + controller.play(on: update.subBoard, column: update.column, row: update.row) + } + + public func score(for player: GKGameModelPlayer) -> Int { + guard let player = player as? Player else { + return .min + } + + var score = 0 + for column in 0..<3 { + for row in 0..<3 { + let subBoard = controller.board.getSubBoard(column: column, row: row) + if let win = subBoard.win { + if win.mark == player.mark { + score += 10 + } else { + score -= 5 + } + } else { + score += 4 * subBoard.potentialWinCount(for: player.mark) + score -= 2 * subBoard.potentialWinCount(for: player.mark.next) + } + } + } + if case .playAnywhere(let mark) = controller.state { + if mark == player.mark { + score += 5 + } + } + score += 8 * controller.board.potentialWinCount(for: player.mark) + score -= 4 * controller.board.potentialWinCount(for: player.mark.next) + return score + } + + public func isWin(for player: GKGameModelPlayer) -> Bool { + let mark = (player as! Player).mark + return controller.board.win?.mark == mark + } + + private func updatesForPlayAnywhere(mark: Mark) -> [Update] { + var updates = [Update]() + for boardColumn in 0..<3 { + for boardRow in 0..<3 { + let subBoard = controller.board.getSubBoard(column: boardColumn, row: boardRow) + guard !subBoard.ended else { continue } + + for column in 0..<3 { + for row in 0..<3 { + guard subBoard[column, row] == nil else { continue } + updates.append(Update(mark: mark, subBoard: (boardColumn, boardRow), column: column, row: row)) + } + } + } + } + return updates + } + + private func updatesForPlaySpecific(mark: Mark, board: (column: Int, row: Int)) -> [Update] { + let subBoard = controller.board.getSubBoard(column: board.column, row: board.row) + var updates = [Update]() + for column in 0..<3 { + for row in 0..<3 { + guard subBoard[column, row] == nil else { continue } + updates.append(Update(mark: mark, subBoard: board, column: column, row: row)) + } + } + return updates + } + + // MARK: NSCopying + + public func copy(with zone: NSZone? = nil) -> Any { + return GameModel(controller: GameController(state: controller.state, board: controller.board)) + } + +} + +extension Board { + func potentialWinCount(for mark: Mark) -> Int { + return Win.allPoints.filter { points in + let empty = points.filter { self[$0] == nil }.count + let matching = points.filter { self[$0] == mark }.count + return matching == 2 && empty == 1 + }.count + } +} diff --git a/Packages/TTTKit/Sources/TTTKit/AI/Player.swift b/Packages/TTTKit/Sources/TTTKit/AI/Player.swift new file mode 100644 index 00000000..6e253f24 --- /dev/null +++ b/Packages/TTTKit/Sources/TTTKit/AI/Player.swift @@ -0,0 +1,24 @@ +// +// Player.swift +// TTTKit +// +// Created by Shadowfacts on 12/21/22. +// + +import Foundation +import GameKit + +class Player: NSObject, GKGameModelPlayer { + static let x = Player(playerId: 0) + static let o = Player(playerId: 1) + + let playerId: Int + + var mark: Mark { + playerId == 0 ? .x : .o + } + + private init(playerId: Int) { + self.playerId = playerId + } +} diff --git a/Packages/TTTKit/Sources/TTTKit/AI/Update.swift b/Packages/TTTKit/Sources/TTTKit/AI/Update.swift new file mode 100644 index 00000000..3bb100d4 --- /dev/null +++ b/Packages/TTTKit/Sources/TTTKit/AI/Update.swift @@ -0,0 +1,25 @@ +// +// Update.swift +// TTTKit +// +// Created by Shadowfacts on 12/21/22. +// + +import Foundation +import GameKit + +class Update: NSObject, GKGameModelUpdate { + let mark: Mark + let subBoard: (column: Int, row: Int) + let column: Int + let row: Int + + init(mark: Mark, subBoard: (Int, Int), column: Int, row: Int) { + self.mark = mark + self.subBoard = subBoard + self.column = column + self.row = row + } + + var value: Int = 0 +} diff --git a/Packages/TTTKit/Sources/TTTKit/Logic/GameController.swift b/Packages/TTTKit/Sources/TTTKit/Logic/GameController.swift new file mode 100644 index 00000000..25dd0f10 --- /dev/null +++ b/Packages/TTTKit/Sources/TTTKit/Logic/GameController.swift @@ -0,0 +1,120 @@ +// +// GameController.swift +// TTTKit +// +// Created by Shadowfacts on 12/21/22. +// + +import Foundation + +public class GameController: ObservableObject { + + @Published public private(set) var state: State + @Published public private(set) var board: SuperTicTacToeBoard + + public init() { + self.state = .playAnywhere(.x) + self.board = SuperTicTacToeBoard() + } + + init(state: State, board: SuperTicTacToeBoard) { + self.state = state + self.board = board + } + + public func play(on subBoard: (column: Int, row: Int), column: Int, row: Int) { + guard board.getSubBoard(column: subBoard.column, row: subBoard.row)[column, row] == nil else { + return + } + let activePlayer: Mark + switch state { + case .playAnywhere(let mark): + activePlayer = mark + case .playSpecific(let mark, column: subBoard.column, row: subBoard.row): + activePlayer = mark + default: + return + } + + board.play(mark: activePlayer, subBoard: subBoard, column: column, row: row) + + if let win = board.win { + state = .end(.won(win)) + } else if board.tied { + state = .end(.tie) + } else { + let nextSubBoard = board.getSubBoard(column: column, row: row) + if nextSubBoard.ended { + state = .playAnywhere(activePlayer.next) + } else { + state = .playSpecific(activePlayer.next, column: column, row: row) + } + } + } + + private func canPlay(on subBoard: (column: Int, row: Int)) -> Bool { + switch state { + case .playAnywhere(_): + return true + case .playSpecific(_, column: subBoard.column, row: subBoard.row): + return true + default: + return false + } + } + +} + +public extension GameController { + enum State { + case playAnywhere(Mark) + case playSpecific(Mark, column: Int, row: Int) + case end(Result) + + public var displayName: String { + switch self { + case .playAnywhere(_): + return "Play anywhere" + case .playSpecific(_, column: let col, row: let row): + switch (col, row) { + case (0, 0): + return "Play in the top left" + case (1, 0): + return "Play in the top middle" + case (2, 0): + return "Play in the top right" + case (0, 1): + return "Play in the middle left" + case (1, 1): + return "Play in the center" + case (2, 1): + return "Play in the middle right" + case (0, 2): + return "Play in the bottom left" + case (1, 2): + return "Play in the bottom middle" + case (2, 2): + return "Play in the bottom right" + default: + fatalError() + } + case .end(.tie): + return "It's a tie!" + case .end(.won(let win)): + switch win.mark { + case .x: + return "X wins!" + case .o: + return "O wins!" + } + } + } + } +} + +public extension GameController { + enum Result { + case won(Win) + case tie + } +} diff --git a/Packages/TTTKit/Sources/TTTKit/Model/Board.swift b/Packages/TTTKit/Sources/TTTKit/Model/Board.swift new file mode 100644 index 00000000..0d115f55 --- /dev/null +++ b/Packages/TTTKit/Sources/TTTKit/Model/Board.swift @@ -0,0 +1,53 @@ +// +// Board.swift +// TTTKit +// +// Created by Shadowfacts on 12/21/22. +// + +import Foundation + +public protocol Board { + subscript(_ column: Int, _ row: Int) -> Mark? { get } +} + +extension Board { + subscript(_ point: (column: Int, row: Int)) -> Mark? { + get { + self[point.column, point.row] + } + } + + public var full: Bool { + for column in 0..<3 { + for row in 0..<3 { + if self[column, row] == nil { + return false + } + } + } + return true + } + + public var win: Win? { + for points in Win.allPoints { + if let mark = self[points[0]], + self[points[1]] == mark && self[points[2]] == mark { + return Win(mark: mark, points: points) + } + } + return nil + } + + public var won: Bool { + win != nil + } + + public var tied: Bool { + full && !won + } + + public var ended: Bool { + won || tied + } +} diff --git a/Packages/TTTKit/Sources/TTTKit/Model/Mark.swift b/Packages/TTTKit/Sources/TTTKit/Model/Mark.swift new file mode 100644 index 00000000..fb7cdbc2 --- /dev/null +++ b/Packages/TTTKit/Sources/TTTKit/Model/Mark.swift @@ -0,0 +1,21 @@ +// +// Mark.swift +// TTTKit +// +// Created by Shadowfacts on 12/21/22. +// + +import Foundation + +public enum Mark { + case x, o + + public var next: Mark { + switch self { + case .x: + return .o + case .o: + return .x + } + } +} diff --git a/Packages/TTTKit/Sources/TTTKit/Model/SuperTicTacToeBoard.swift b/Packages/TTTKit/Sources/TTTKit/Model/SuperTicTacToeBoard.swift new file mode 100644 index 00000000..8b78aae8 --- /dev/null +++ b/Packages/TTTKit/Sources/TTTKit/Model/SuperTicTacToeBoard.swift @@ -0,0 +1,32 @@ +// +// SuperTicTacToeBoard.swift +// TTTKit +// +// Created by Shadowfacts on 12/21/22. +// + +import Foundation + +public struct SuperTicTacToeBoard: Board { + + private var boards: [[TicTacToeBoard]] = [ + [TicTacToeBoard(), TicTacToeBoard(), TicTacToeBoard()], + [TicTacToeBoard(), TicTacToeBoard(), TicTacToeBoard()], + [TicTacToeBoard(), TicTacToeBoard(), TicTacToeBoard()], + ] + + public subscript(_ column: Int, _ row: Int) -> Mark? { + get { + getSubBoard(column: column, row: row).win?.mark + } + } + + public func getSubBoard(column: Int, row: Int) -> TicTacToeBoard { + return boards[row][column] + } + + public mutating func play(mark: Mark, subBoard: (column: Int, row: Int), column: Int, row: Int) { + boards[subBoard.row][subBoard.column].play(mark: mark, column: column, row: row) + } + +} diff --git a/Packages/TTTKit/Sources/TTTKit/Model/TicTacToeBoard.swift b/Packages/TTTKit/Sources/TTTKit/Model/TicTacToeBoard.swift new file mode 100644 index 00000000..77944b72 --- /dev/null +++ b/Packages/TTTKit/Sources/TTTKit/Model/TicTacToeBoard.swift @@ -0,0 +1,39 @@ +// +// TicTacToeBoard.swift +// TTTKit +// +// Created by Shadowfacts on 12/21/22. +// + +import Foundation + +public struct TicTacToeBoard: Board { + private var marks: [[Mark?]] = [ + [nil, nil, nil], + [nil, nil, nil], + [nil, nil, nil], + ] + + init() { + } + + init(marks: [[Mark?]]) { + precondition(marks.count == 3) + precondition(marks.allSatisfy { $0.count == 3 }) + self.marks = marks + } + + public subscript(_ column: Int, _ row: Int) -> Mark? { + get { + marks[row][column] + } + } + + public func canPlay(column: Int, row: Int) -> Bool { + return self[column, row] == nil + } + + public mutating func play(mark: Mark, column: Int, row: Int) { + marks[row][column] = mark + } +} diff --git a/Packages/TTTKit/Sources/TTTKit/Model/Win.swift b/Packages/TTTKit/Sources/TTTKit/Model/Win.swift new file mode 100644 index 00000000..f9f872c2 --- /dev/null +++ b/Packages/TTTKit/Sources/TTTKit/Model/Win.swift @@ -0,0 +1,24 @@ +// +// Win.swift +// TTTKit +// +// Created by Shadowfacts on 12/21/22. +// + +import Foundation + +public struct Win { + public let mark: Mark + public let points: [(column: Int, row: Int)] + + static let allPoints: [[(Int, Int)]] = [ + [(0, 0), (1, 1), (2, 2)], // top left diag + [(2, 0), (1, 1), (0, 2)], // top right diag + [(0, 0), (1, 0), (2, 0)], // top row + [(0, 1), (1, 1), (2, 1)], // middle row + [(0, 2), (1, 2), (2, 2)], // bottom row + [(0, 0), (0, 1), (0, 2)], // left col + [(1, 0), (1, 1), (1, 2)], // middle col + [(2, 0), (2, 1), (2, 2)], // right col + ] +} diff --git a/Packages/TTTKit/Sources/TTTKit/UI/BoardView.swift b/Packages/TTTKit/Sources/TTTKit/UI/BoardView.swift new file mode 100644 index 00000000..1bb43c55 --- /dev/null +++ b/Packages/TTTKit/Sources/TTTKit/UI/BoardView.swift @@ -0,0 +1,88 @@ +// +// BoardView.swift +// TTTKit +// +// Created by Shadowfacts on 12/21/22. +// + +import SwiftUI + +@available(iOS 16.0, *) +struct BoardView: View { + let board: any Board + @Binding var cellSize: CGFloat + let spacing: CGFloat + let cellProvider: (_ column: Int, _ row: Int) -> Cell + + init(board: any Board, cellSize: Binding, spacing: CGFloat, @ViewBuilder cellProvider: @escaping (Int, Int) -> Cell) { + self.board = board + self._cellSize = cellSize + self.spacing = spacing + self.cellProvider = cellProvider + } + + var body: some View { + ZStack { + if let win = board.win { + winOverlay(win) + } + + Grid(horizontalSpacing: 10, verticalSpacing: 10) { + GridRow { + cellProvider(0, 0) + .background(GeometryReader { proxy in + Color.clear + .preference(key: MarkSizePrefKey.self, value: proxy.size.width) + .onPreferenceChange(MarkSizePrefKey.self) { newValue in + cellSize = newValue + } + }) + cellProvider(1, 0) + cellProvider(2, 0) + } + GridRow { + cellProvider(0, 1) + cellProvider(1, 1) + cellProvider(2, 1) + } + GridRow { + cellProvider(0, 2) + cellProvider(1, 2) + cellProvider(2, 2) + } + } + + let sepOffset = (cellSize + spacing) / 2 + Separator(axis: .vertical) + .offset(x: -sepOffset) + Separator(axis: .vertical) + .offset(x: sepOffset) + Separator(axis: .horizontal) + .offset(y: -sepOffset) + Separator(axis: .horizontal) + .offset(y: sepOffset) + } + .padding(.all, spacing / 2) + .aspectRatio(1, contentMode: .fit) + } + + private func winOverlay(_ win: Win) -> some View { + let pointsWithIndices = win.points.map { ($0, $0.row * 3 + $0.column) } + let cellSize = cellSize + spacing + return ForEach(pointsWithIndices, id: \.1) { (point, _) in + Rectangle() + .foregroundColor(Color(UIColor.green)) + .opacity(0.5) + .frame(width: cellSize, height: cellSize) + .offset(x: CGFloat(point.column - 1) * cellSize, y: CGFloat(point.row - 1) * cellSize) + } + } +} + +private struct MarkSizePrefKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} diff --git a/Packages/TTTKit/Sources/TTTKit/UI/GameView.swift b/Packages/TTTKit/Sources/TTTKit/UI/GameView.swift new file mode 100644 index 00000000..bf4117fe --- /dev/null +++ b/Packages/TTTKit/Sources/TTTKit/UI/GameView.swift @@ -0,0 +1,81 @@ +// +// GameView.swift +// TTTKit +// +// Created by Shadowfacts on 12/21/22. +// + +import SwiftUI + +@available(iOS 16.0, *) +public struct GameView: View { + @ObservedObject private var controller: GameController + @State private var cellSize: CGFloat = 0 + @State private var scaleAnchor: UnitPoint = .center + @State private var focusedSubBoard: (column: Int, row: Int)? = nil + + public init(controller: GameController) { + self.controller = controller + } + + public var body: some View { + let scale: CGFloat = focusedSubBoard == nil ? 1 : 2.5 + return boardView + .scaleEffect(x: scale, y: scale, anchor: scaleAnchor) + .clipped() + } + + private var boardView: some View { + BoardView(board: controller.board, cellSize: $cellSize, spacing: 10) { column, row in + ZStack { + if case .playSpecific(_, column: column, row: row) = controller.state { + Color.teal + .padding(.all, -5) + .opacity(0.4) + } + + let board = controller.board.getSubBoard(column: column, row: row) + SubBoardView(board: board, cellTapped: cellTapHandler(column, row)) + .environment(\.separatorColor, Color.gray.opacity(0.6)) + .contentShape(Rectangle()) + .onTapGesture { + switch controller.state { + case .playAnywhere(_), .playSpecific(_, column: column, row: row): + scaleAnchor = UnitPoint(x: CGFloat(column) * 0.5, y: CGFloat(row) * 0.5) + withAnimation(.easeInOut(duration: 0.3)) { + focusedSubBoard = (column, row) + } + default: + break + } + } + .opacity(board.won ? 0.8 : 1) + + if let mark = board.win?.mark { + MarkView(mark: mark) + } + } + } + } + + private func cellTapHandler(_ column: Int, _ row: Int) -> ((Int, Int) -> Void)? { + guard focusedSubBoard?.column == column && focusedSubBoard?.row == row else { + return nil + } + let subBoard = (column, row) + return { column, row in + controller.play(on: subBoard, column: column, row: row) + withAnimation(.easeInOut(duration: 0.3)) { + focusedSubBoard = nil + } + } + } +} + +@available(iOS 16.0, *) +struct GameView_Previews: PreviewProvider { + static var previews: some View { + GameView(controller: GameController()) + .padding() + } +} diff --git a/Packages/TTTKit/Sources/TTTKit/UI/MarkView.swift b/Packages/TTTKit/Sources/TTTKit/UI/MarkView.swift new file mode 100644 index 00000000..525aaaa3 --- /dev/null +++ b/Packages/TTTKit/Sources/TTTKit/UI/MarkView.swift @@ -0,0 +1,39 @@ +// +// MarkView.swift +// TTTKit +// +// Created by Shadowfacts on 12/21/22. +// + +import SwiftUI + +@available(iOS 16.0, *) +struct MarkView: View { + let mark: Mark? + + var body: some View { + maybeImage.aspectRatio(1, contentMode: .fit) + } + + @ViewBuilder + private var maybeImage: some View { + if let mark { + Image(systemName: mark == .x ? "xmark" : "circle") + .resizable() + .fontWeight(mark == .x ? .regular : .semibold) + } else { + Color.clear + } + } +} + +@available(iOS 16.0, *) +struct MarkView_Previews: PreviewProvider { + static var previews: some View { + HStack { + MarkView(mark: .x) + MarkView(mark: .o) + MarkView(mark: nil) + } + } +} diff --git a/Packages/TTTKit/Sources/TTTKit/UI/Separator.swift b/Packages/TTTKit/Sources/TTTKit/UI/Separator.swift new file mode 100644 index 00000000..6a7ab8fc --- /dev/null +++ b/Packages/TTTKit/Sources/TTTKit/UI/Separator.swift @@ -0,0 +1,39 @@ +// +// SwiftUIView.swift +// +// +// Created by Shadowfacts on 12/21/22. +// + +import SwiftUI + +@available(iOS 16.0, *) +struct Separator: View { + let axis: Axis + @Environment(\.separatorColor) private var separatorColor + + var body: some View { + rect + .foregroundColor(separatorColor) + .gridCellUnsizedAxes(axis == .vertical ? .vertical : .horizontal) + } + + private var rect: some View { + if axis == .vertical { + return Rectangle().frame(width: 1) + } else { + return Rectangle().frame(height: 1) + } + } +} + +private struct SeparatorColorKey: EnvironmentKey { + static var defaultValue = Color.black +} + +extension EnvironmentValues { + var separatorColor: Color { + get { self[SeparatorColorKey.self] } + set { self[SeparatorColorKey.self] = newValue } + } +} diff --git a/Packages/TTTKit/Sources/TTTKit/UI/SubBoardView.swift b/Packages/TTTKit/Sources/TTTKit/UI/SubBoardView.swift new file mode 100644 index 00000000..e03992db --- /dev/null +++ b/Packages/TTTKit/Sources/TTTKit/UI/SubBoardView.swift @@ -0,0 +1,49 @@ +// +// SubBoardView.swift +// +// +// Created by Shadowfacts on 12/21/22. +// + +import SwiftUI + +@available(iOS 16.0, *) +struct SubBoardView: View { + let board: TicTacToeBoard + let cellTapped: ((_ column: Int, _ row: Int) -> Void)? + @State private var cellSize: CGFloat = 0 + + init(board: TicTacToeBoard, cellTapped: ((Int, Int) -> Void)? = nil) { + self.board = board + self.cellTapped = cellTapped + } + + var body: some View { + BoardView(board: board, cellSize: $cellSize, spacing: 10) { column, row in + applyTapHandler(column, row, MarkView(mark: board[column, row]) + .contentShape(Rectangle())) + } + } + + @ViewBuilder + private func applyTapHandler(_ column: Int, _ row: Int, _ view: some View) -> some View { + if let cellTapped { + view.onTapGesture { + cellTapped(column, row) + } + } else { + view + } + } +} + +@available(iOS 16.0, *) +struct SubBoardView_Previews: PreviewProvider { + static var previews: some View { + SubBoardView(board: TicTacToeBoard(marks: [ + [ .x, .o, .x], + [nil, .x, .o], + [ .x, nil, nil], + ])) + } +} diff --git a/Packages/TTTKit/Tests/TTTKitTests/TTTKitTests.swift b/Packages/TTTKit/Tests/TTTKitTests/TTTKitTests.swift new file mode 100644 index 00000000..cf11601a --- /dev/null +++ b/Packages/TTTKit/Tests/TTTKitTests/TTTKitTests.swift @@ -0,0 +1,7 @@ +import XCTest +@testable import TTTKit + +final class TTTKitTests: XCTestCase { + func testExample() throws { + } +} diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 11cb0780..2f74a483 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -144,6 +144,8 @@ D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; }; D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */; }; D6552367289870790048A653 /* ScreenCorners in Frameworks */ = {isa = PBXBuildFile; productRef = D6552366289870790048A653 /* ScreenCorners */; }; + D659F35E2953A212002D944A /* TTTKit in Frameworks */ = {isa = PBXBuildFile; productRef = D659F35D2953A212002D944A /* TTTKit */; }; + D659F36229541065002D944A /* TTTView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D659F36129541065002D944A /* TTTView.swift */; }; D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */; }; D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; }; D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.swift */; }; @@ -190,7 +192,9 @@ D68A76E329524D2A001DA1B3 /* ListMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A76E229524D2A001DA1B3 /* ListMO.swift */; }; D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A76E729527884001DA1B3 /* PinnedTimelinesView.swift */; }; D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A76E9295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift */; }; + D68A76EC295369A8001DA1B3 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A76EB295369A8001DA1B3 /* AboutView.swift */; }; D68A76EE295369C7001DA1B3 /* AcknowledgementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A76ED295369C7001DA1B3 /* AcknowledgementsView.swift */; }; + D68A76F129539116001DA1B3 /* FlipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A76F029539116001DA1B3 /* FlipView.swift */; }; D68ACE5D279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */; }; D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */; }; D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */; }; @@ -520,6 +524,7 @@ D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentViewController.swift; sourceTree = ""; }; D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = ""; }; D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffableTimelineLikeTableViewController.swift; sourceTree = ""; }; + D659F36129541065002D944A /* TTTView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTTView.swift; sourceTree = ""; }; D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundableViewController.swift; sourceTree = ""; }; D65F612D23AE990C00F3CFD3 /* Embassy.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Embassy.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D65F613023AE99E000F3CFD3 /* Ambassador.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Ambassador.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -569,7 +574,10 @@ D68A76E229524D2A001DA1B3 /* ListMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListMO.swift; sourceTree = ""; }; D68A76E729527884001DA1B3 /* PinnedTimelinesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedTimelinesView.swift; sourceTree = ""; }; D68A76E9295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddHashtagPinnedTimelineView.swift; sourceTree = ""; }; + D68A76EB295369A8001DA1B3 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; D68A76ED295369C7001DA1B3 /* AcknowledgementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcknowledgementsView.swift; sourceTree = ""; }; + D68A76F029539116001DA1B3 /* FlipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlipView.swift; sourceTree = ""; }; + D68A76F22953915C001DA1B3 /* TTTKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TTTKit; path = Packages/TTTKit; sourceTree = ""; }; D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerControlCollectionViewCell.swift; sourceTree = ""; }; D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuxiliarySceneDelegate.swift; sourceTree = ""; }; D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = ""; }; @@ -732,6 +740,7 @@ buildActionMask = 2147483647; files = ( D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */, + D659F35E2953A212002D944A /* TTTKit in Frameworks */, D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */, D6552367289870790048A653 /* ScreenCorners in Frameworks */, D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */, @@ -1075,6 +1084,7 @@ 0427033922B31269000D31B6 /* AdvancedPrefsView.swift */, D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */, D68A76ED295369C7001DA1B3 /* AcknowledgementsView.swift */, + D68A76EF2953910A001DA1B3 /* About */, ); path = Preferences; sourceTree = ""; @@ -1213,6 +1223,16 @@ path = "Account Detail"; sourceTree = ""; }; + D68A76EF2953910A001DA1B3 /* About */ = { + isa = PBXGroup; + children = ( + D68A76EB295369A8001DA1B3 /* AboutView.swift */, + D68A76F029539116001DA1B3 /* FlipView.swift */, + D659F36129541065002D944A /* TTTView.swift */, + ); + path = About; + sourceTree = ""; + }; D6A3BC822321F69400FD64D5 /* Account List */ = { isa = PBXGroup; children = ( @@ -1389,6 +1409,7 @@ D6D706A829498C82000827ED /* Tusker.xcconfig */, D674A50727F910F300BA03AC /* Pachyderm */, D6BEA243291A0C83002F4D01 /* Duckable */, + D68A76F22953915C001DA1B3 /* TTTKit */, D6D4DDCE212518A000E1C4BB /* Tusker */, D6D4DDE3212518A200E1C4BB /* TuskerTests */, D6D4DDEE212518A200E1C4BB /* TuskerUITests */, @@ -1585,6 +1606,7 @@ D6552366289870790048A653 /* ScreenCorners */, D63CC701290EC0B8000E19DE /* Sentry */, D6BEA244291A0EDE002F4D01 /* Duckable */, + D659F35D2953A212002D944A /* TTTKit */, ); productName = Tusker; productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */; @@ -1911,6 +1933,7 @@ D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */, D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */, D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */, + D68A76EC295369A8001DA1B3 /* AboutView.swift in Sources */, D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */, 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */, D6BEA249291C6118002F4D01 /* DraftsView.swift in Sources */, @@ -1967,6 +1990,7 @@ D61F75BD293D099600C0B37F /* Lazy.swift in Sources */, D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */, D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */, + D659F36229541065002D944A /* TTTView.swift in Sources */, D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */, D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */, D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */, @@ -2035,6 +2059,7 @@ D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */, D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */, D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */, + D68A76F129539116001DA1B3 /* FlipView.swift in Sources */, D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */, D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */, D61F75B5293BD97400C0B37F /* DeleteFilterService.swift in Sources */, @@ -2759,6 +2784,10 @@ package = D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */; productName = ScreenCorners; }; + D659F35D2953A212002D944A /* TTTKit */ = { + isa = XCSwiftPackageProductDependency; + productName = TTTKit; + }; D6676CA427A8D0020052936B /* WebURLFoundationExtras */ = { isa = XCSwiftPackageProductDependency; package = D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */; diff --git a/Tusker/Assets.xcassets/AboutIcon.imageset/256x256@2x.png b/Tusker/Assets.xcassets/AboutIcon.imageset/256x256@2x.png new file mode 100644 index 00000000..9bcd69fb Binary files /dev/null and b/Tusker/Assets.xcassets/AboutIcon.imageset/256x256@2x.png differ diff --git a/Tusker/Assets.xcassets/AboutIcon.imageset/256x256@3x.png b/Tusker/Assets.xcassets/AboutIcon.imageset/256x256@3x.png new file mode 100644 index 00000000..81b5ea37 Binary files /dev/null and b/Tusker/Assets.xcassets/AboutIcon.imageset/256x256@3x.png differ diff --git a/Tusker/Assets.xcassets/AboutIcon.imageset/Contents.json b/Tusker/Assets.xcassets/AboutIcon.imageset/Contents.json new file mode 100644 index 00000000..f8d04708 --- /dev/null +++ b/Tusker/Assets.xcassets/AboutIcon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "256x256@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "256x256@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tusker/Assets.xcassets/AppIcon.appiconset/1024x1024@1x.png b/Tusker/Assets.xcassets/AppIcon.appiconset/1024x1024@1x.png index b37826d8..adf6a10e 100644 Binary files a/Tusker/Assets.xcassets/AppIcon.appiconset/1024x1024@1x.png and b/Tusker/Assets.xcassets/AppIcon.appiconset/1024x1024@1x.png differ diff --git a/Tusker/Screens/Preferences/About/AboutView.swift b/Tusker/Screens/Preferences/About/AboutView.swift new file mode 100644 index 00000000..11c549f9 --- /dev/null +++ b/Tusker/Screens/Preferences/About/AboutView.swift @@ -0,0 +1,92 @@ +// +// AboutView.swift +// Tusker +// +// Created by Shadowfacts on 12/21/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import SwiftUI + +struct AboutView: View { + private var version: String { + let marketing = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String + return "\(marketing ?? "") (\(build ?? ""))" + } + + var body: some View { + List { + iconOrGame + .frame(maxWidth: .infinity, alignment: .center) + .listRowInsets(EdgeInsets(top: 9, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(EmptyView()) + + Section { + HStack { + Text("Version") + Spacer() + Text(version) + .foregroundColor(.secondary) + } + .contextMenu { + Button { + UIPasteboard.general.string = version + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + } + } + + Section { + Link("Website", destination: URL(string: "https://vaccor.space/tusker")!) + Link("Source Code", destination: URL(string: "https://git.shadowfacts.net/shadowfacts/Tusker")!) + Link("Issue Tracker", destination: URL(string: "https://git.shadowfacts.net/shadowfacts/Tusker/issues")!) + } + } + } + + @ViewBuilder + private var iconOrGame: some View { + if #available(iOS 16.0, *) { + FlipView { + appIcon + } back: { + TTTView() + } + } else { + appIcon + } + } + + private var appIcon: some View { + VStack { + AppIconView() + .shadow(radius: 6, y: 3) + .frame(width: 256, height: 256) + + Text("Tusker") + .font(.title2.bold()) + } + } +} + +struct AppIconView: UIViewRepresentable { + func makeUIView(context: Context) -> UIImageView { + let view = UIImageView(image: UIImage(named: "AboutIcon")) + view.contentMode = .scaleAspectFit + view.layer.cornerRadius = 256 / 6.4 + view.layer.cornerCurve = .continuous + view.layer.masksToBounds = true + return view + } + + func updateUIView(_ uiView: UIImageView, context: Context) { + } +} + +struct AboutView_Previews: PreviewProvider { + static var previews: some View { + AboutView() + } +} diff --git a/Tusker/Screens/Preferences/About/FlipView.swift b/Tusker/Screens/Preferences/About/FlipView.swift new file mode 100644 index 00000000..d288ea2e --- /dev/null +++ b/Tusker/Screens/Preferences/About/FlipView.swift @@ -0,0 +1,116 @@ +// +// FlipView.swift +// Tusker +// +// Created by Shadowfacts on 12/21/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import SwiftUI + +// Based on https://stackoverflow.com/a/60807269 +struct FlipView : View { + private let front: Front + private let back: Back + + @State private var flipped = false + @State private var angle: Angle = .zero + @State private var width: CGFloat = 0 + @State private var initialFlipped: Bool! + + init(@ViewBuilder front: () -> Front, @ViewBuilder back: () -> Back) { + self.front = front() + self.back = back() + } + + var body: some View { + ZStack { + front + .opacity(flipped ? 0.0 : 1.0) + back + .rotation3DEffect(.degrees(180), axis: (0, 1, 0)) + .opacity(flipped ? 1.0 : 0.0) + } + .modifier(FlipEffect(flipped: $flipped, angle: angle.degrees, axis: (x: 0, y: 1))) + .background(GeometryReader { proxy in + Color.clear + .preference(key: WidthPrefKey.self, value: proxy.size.width) + .onPreferenceChange(WidthPrefKey.self) { newValue in + width = newValue + } + }) + .gesture( + DragGesture() + .onChanged({ value in + if initialFlipped == nil { + initialFlipped = flipped + } + let adj = (width / 2) - value.location.x + let hyp = abs((width / 2) - value.startLocation.x) + let clamped: Double = min(max(adj / hyp, -1), 1) + let startedOnRight = value.startLocation.x > width / 2 + angle = .radians(acos(clamped) + (startedOnRight != initialFlipped ? .pi : 0)) + }) + .onEnded({ value in + initialFlipped = nil + let deg = angle.degrees.truncatingRemainder(dividingBy: 360) + if deg == 0 { + angle = .zero + } else if deg == 180 { + angle = .degrees(180) + } else { + withAnimation(.easeInOut(duration: 0.25)) { + angle = deg > 90 && deg < 270 ? .degrees(180) : .zero + } + } + }) + ) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.5)) { + angle = angle == .zero ? .degrees(180) : .zero + } + } + } +} + +// Based on https://swiftui-lab.com/swiftui-animations-part2/ +struct FlipEffect: GeometryEffect { + var animatableData: Double { + get { angle } + set { angle = newValue } + } + + @Binding var flipped: Bool + var angle: Double + let axis: (x: CGFloat, y: CGFloat) + + func effectValue(size: CGSize) -> ProjectionTransform { + // We schedule the change to be done after the view has finished drawing, + // otherwise, we would receive a runtime error, indicating we are changing + // the state while the view is being drawn. + DispatchQueue.main.async { + self.flipped = self.angle >= 90 && self.angle < 270 + } + + let a = CGFloat(Angle.degrees(angle).radians) + + var transform3d = CATransform3DIdentity + transform3d.m34 = -1/max(size.width, size.height) + + transform3d = CATransform3DRotate(transform3d, a, axis.x, axis.y, 0) + transform3d = CATransform3DTranslate(transform3d, -size.width/2.0, -size.height/2.0, 0) + + let affineTransform = ProjectionTransform(CGAffineTransform(translationX: size.width/2.0, y: size.height / 2.0)) + + return ProjectionTransform(transform3d).concatenating(affineTransform) + } +} + +private struct WidthPrefKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + diff --git a/Tusker/Screens/Preferences/About/TTTView.swift b/Tusker/Screens/Preferences/About/TTTView.swift new file mode 100644 index 00000000..c98f50fa --- /dev/null +++ b/Tusker/Screens/Preferences/About/TTTView.swift @@ -0,0 +1,63 @@ +// +// TTTView.swift +// Tusker +// +// Created by Shadowfacts on 12/21/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import SwiftUI +import TTTKit +import GameKit +import OSLog + +@available(iOS 16.0, *) +struct TTTView: View { + static let aiQueue = DispatchQueue(label: "TTT AI Strategist", qos: .userInitiated) + + @StateObject private var controller = GameController() + @State private var isAIThinking = false + @State private var strategist = { + let strategist = GKMinmaxStrategist() + strategist.maxLookAheadDepth = 5 + return strategist + }() + + var body: some View { + VStack(alignment: .center) { + GameView(controller: controller) + .frame(width: 256, height: 256) + + if isAIThinking { + ProgressView() + .progressViewStyle(.circular) + } else { + Text(controller.state.displayName) + } + } + .onReceive(controller.$state) { newState in + switch newState { + case .playAnywhere(.o), .playSpecific(.o, column: _, row: _): + isAIThinking = true + TTTView.aiQueue.async { + let gameModel = GameModel(controller: controller) + strategist.gameModel = gameModel + let move = strategist.bestMoveForActivePlayer()! + DispatchQueue.main.async { + gameModel.apply(move) + isAIThinking = false + } + } + default: + break + } + } + } +} + +@available(iOS 16.0, *) +struct TTTView_Previews: PreviewProvider { + static var previews: some View { + TTTView() + } +} diff --git a/Tusker/Screens/Preferences/PreferencesView.swift b/Tusker/Screens/Preferences/PreferencesView.swift index c65e61b4..c4b92c53 100644 --- a/Tusker/Screens/Preferences/PreferencesView.swift +++ b/Tusker/Screens/Preferences/PreferencesView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import TTTKit struct PreferencesView: View { let mastodonController: MastodonController @@ -98,6 +99,9 @@ struct PreferencesView: View { } Section { + NavigationLink("About") { + AboutView() + } NavigationLink("Acknowledgements") { AcknowledgementsView() }