forked from shadowfacts/Tusker
Add About screen
This commit is contained in:
parent
231b0ea830
commit
9f86158bb7
|
@ -0,0 +1,9 @@
|
||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
/*.xcodeproj
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/config/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
|
@ -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"]),
|
||||||
|
]
|
||||||
|
)
|
|
@ -0,0 +1,3 @@
|
||||||
|
# TTTKit
|
||||||
|
|
||||||
|
A description of this package.
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,88 @@
|
||||||
|
//
|
||||||
|
// BoardView.swift
|
||||||
|
// TTTKit
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/21/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct BoardView<Cell: View>: 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<CGFloat>, 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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
|
@ -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],
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
import XCTest
|
||||||
|
@testable import TTTKit
|
||||||
|
|
||||||
|
final class TTTKitTests: XCTestCase {
|
||||||
|
func testExample() throws {
|
||||||
|
}
|
||||||
|
}
|
|
@ -144,6 +144,8 @@
|
||||||
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; };
|
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; };
|
||||||
D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */; };
|
D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */; };
|
||||||
D6552367289870790048A653 /* ScreenCorners in Frameworks */ = {isa = PBXBuildFile; productRef = D6552366289870790048A653 /* ScreenCorners */; };
|
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 */; };
|
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */; };
|
||||||
D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; };
|
D65F613423AEAB6600F3CFD3 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65F613323AEAB6600F3CFD3 /* OnboardingTests.swift */; };
|
||||||
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6620ACD2511A0ED00312CA0 /* StatusStateResolver.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 */; };
|
D68A76E329524D2A001DA1B3 /* ListMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A76E229524D2A001DA1B3 /* ListMO.swift */; };
|
||||||
D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A76E729527884001DA1B3 /* PinnedTimelinesView.swift */; };
|
D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A76E729527884001DA1B3 /* PinnedTimelinesView.swift */; };
|
||||||
D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A76E9295285D0001DA1B3 /* AddHashtagPinnedTimelineView.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 */; };
|
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 */; };
|
D68ACE5D279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */; };
|
||||||
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */; };
|
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */; };
|
||||||
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68E525A24A3D77E0054355A /* TuskerRootViewController.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 = "<group>"; };
|
D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentViewController.swift; sourceTree = "<group>"; };
|
||||||
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; };
|
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; };
|
||||||
D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffableTimelineLikeTableViewController.swift; sourceTree = "<group>"; };
|
D653F410267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffableTimelineLikeTableViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D659F36129541065002D944A /* TTTView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTTView.swift; sourceTree = "<group>"; };
|
||||||
D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundableViewController.swift; sourceTree = "<group>"; };
|
D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundableViewController.swift; sourceTree = "<group>"; };
|
||||||
D65F612D23AE990C00F3CFD3 /* Embassy.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Embassy.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
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; };
|
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 = "<group>"; };
|
D68A76E229524D2A001DA1B3 /* ListMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListMO.swift; sourceTree = "<group>"; };
|
||||||
D68A76E729527884001DA1B3 /* PinnedTimelinesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedTimelinesView.swift; sourceTree = "<group>"; };
|
D68A76E729527884001DA1B3 /* PinnedTimelinesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedTimelinesView.swift; sourceTree = "<group>"; };
|
||||||
D68A76E9295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddHashtagPinnedTimelineView.swift; sourceTree = "<group>"; };
|
D68A76E9295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddHashtagPinnedTimelineView.swift; sourceTree = "<group>"; };
|
||||||
|
D68A76EB295369A8001DA1B3 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
|
||||||
D68A76ED295369C7001DA1B3 /* AcknowledgementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcknowledgementsView.swift; sourceTree = "<group>"; };
|
D68A76ED295369C7001DA1B3 /* AcknowledgementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcknowledgementsView.swift; sourceTree = "<group>"; };
|
||||||
|
D68A76F029539116001DA1B3 /* FlipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlipView.swift; sourceTree = "<group>"; };
|
||||||
|
D68A76F22953915C001DA1B3 /* TTTKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TTTKit; path = Packages/TTTKit; sourceTree = "<group>"; };
|
||||||
D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerControlCollectionViewCell.swift; sourceTree = "<group>"; };
|
D68ACE5C279B1ABA001CE8EB /* AssetPickerControlCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerControlCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuxiliarySceneDelegate.swift; sourceTree = "<group>"; };
|
D68C2AE225869BAB00548EFF /* AuxiliarySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuxiliarySceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = "<group>"; };
|
D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerRootViewController.swift; sourceTree = "<group>"; };
|
||||||
|
@ -732,6 +740,7 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */,
|
D674A50927F9128D00BA03AC /* Pachyderm in Frameworks */,
|
||||||
|
D659F35E2953A212002D944A /* TTTKit in Frameworks */,
|
||||||
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */,
|
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */,
|
||||||
D6552367289870790048A653 /* ScreenCorners in Frameworks */,
|
D6552367289870790048A653 /* ScreenCorners in Frameworks */,
|
||||||
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
|
D6676CA527A8D0020052936B /* WebURLFoundationExtras in Frameworks */,
|
||||||
|
@ -1075,6 +1084,7 @@
|
||||||
0427033922B31269000D31B6 /* AdvancedPrefsView.swift */,
|
0427033922B31269000D31B6 /* AdvancedPrefsView.swift */,
|
||||||
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */,
|
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */,
|
||||||
D68A76ED295369C7001DA1B3 /* AcknowledgementsView.swift */,
|
D68A76ED295369C7001DA1B3 /* AcknowledgementsView.swift */,
|
||||||
|
D68A76EF2953910A001DA1B3 /* About */,
|
||||||
);
|
);
|
||||||
path = Preferences;
|
path = Preferences;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1213,6 +1223,16 @@
|
||||||
path = "Account Detail";
|
path = "Account Detail";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
D68A76EF2953910A001DA1B3 /* About */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D68A76EB295369A8001DA1B3 /* AboutView.swift */,
|
||||||
|
D68A76F029539116001DA1B3 /* FlipView.swift */,
|
||||||
|
D659F36129541065002D944A /* TTTView.swift */,
|
||||||
|
);
|
||||||
|
path = About;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
D6A3BC822321F69400FD64D5 /* Account List */ = {
|
D6A3BC822321F69400FD64D5 /* Account List */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -1389,6 +1409,7 @@
|
||||||
D6D706A829498C82000827ED /* Tusker.xcconfig */,
|
D6D706A829498C82000827ED /* Tusker.xcconfig */,
|
||||||
D674A50727F910F300BA03AC /* Pachyderm */,
|
D674A50727F910F300BA03AC /* Pachyderm */,
|
||||||
D6BEA243291A0C83002F4D01 /* Duckable */,
|
D6BEA243291A0C83002F4D01 /* Duckable */,
|
||||||
|
D68A76F22953915C001DA1B3 /* TTTKit */,
|
||||||
D6D4DDCE212518A000E1C4BB /* Tusker */,
|
D6D4DDCE212518A000E1C4BB /* Tusker */,
|
||||||
D6D4DDE3212518A200E1C4BB /* TuskerTests */,
|
D6D4DDE3212518A200E1C4BB /* TuskerTests */,
|
||||||
D6D4DDEE212518A200E1C4BB /* TuskerUITests */,
|
D6D4DDEE212518A200E1C4BB /* TuskerUITests */,
|
||||||
|
@ -1585,6 +1606,7 @@
|
||||||
D6552366289870790048A653 /* ScreenCorners */,
|
D6552366289870790048A653 /* ScreenCorners */,
|
||||||
D63CC701290EC0B8000E19DE /* Sentry */,
|
D63CC701290EC0B8000E19DE /* Sentry */,
|
||||||
D6BEA244291A0EDE002F4D01 /* Duckable */,
|
D6BEA244291A0EDE002F4D01 /* Duckable */,
|
||||||
|
D659F35D2953A212002D944A /* TTTKit */,
|
||||||
);
|
);
|
||||||
productName = Tusker;
|
productName = Tusker;
|
||||||
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
|
productReference = D6D4DDCC212518A000E1C4BB /* Tusker.app */;
|
||||||
|
@ -1911,6 +1933,7 @@
|
||||||
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
|
D6B053A623BD2D0C00A066FA /* AssetCollectionViewController.swift in Sources */,
|
||||||
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */,
|
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */,
|
||||||
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
|
D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */,
|
||||||
|
D68A76EC295369A8001DA1B3 /* AboutView.swift in Sources */,
|
||||||
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */,
|
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */,
|
||||||
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
|
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
|
||||||
D6BEA249291C6118002F4D01 /* DraftsView.swift in Sources */,
|
D6BEA249291C6118002F4D01 /* DraftsView.swift in Sources */,
|
||||||
|
@ -1967,6 +1990,7 @@
|
||||||
D61F75BD293D099600C0B37F /* Lazy.swift in Sources */,
|
D61F75BD293D099600C0B37F /* Lazy.swift in Sources */,
|
||||||
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */,
|
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */,
|
||||||
D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */,
|
D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */,
|
||||||
|
D659F36229541065002D944A /* TTTView.swift in Sources */,
|
||||||
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */,
|
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */,
|
||||||
D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */,
|
D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */,
|
||||||
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */,
|
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */,
|
||||||
|
@ -2035,6 +2059,7 @@
|
||||||
D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */,
|
D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */,
|
||||||
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */,
|
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */,
|
||||||
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */,
|
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */,
|
||||||
|
D68A76F129539116001DA1B3 /* FlipView.swift in Sources */,
|
||||||
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */,
|
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */,
|
||||||
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */,
|
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */,
|
||||||
D61F75B5293BD97400C0B37F /* DeleteFilterService.swift in Sources */,
|
D61F75B5293BD97400C0B37F /* DeleteFilterService.swift in Sources */,
|
||||||
|
@ -2759,6 +2784,10 @@
|
||||||
package = D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */;
|
package = D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */;
|
||||||
productName = ScreenCorners;
|
productName = ScreenCorners;
|
||||||
};
|
};
|
||||||
|
D659F35D2953A212002D944A /* TTTKit */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = TTTKit;
|
||||||
|
};
|
||||||
D6676CA427A8D0020052936B /* WebURLFoundationExtras */ = {
|
D6676CA427A8D0020052936B /* WebURLFoundationExtras */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */;
|
package = D6676CA127A8D0020052936B /* XCRemoteSwiftPackageReference "swift-url" */;
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
After Width: | Height: | Size: 58 KiB |
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 123 KiB |
|
@ -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 ?? "<unknown>") (\(build ?? "<unknown>"))"
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<Front: View, Back: View> : 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,6 +6,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import TTTKit
|
||||||
|
|
||||||
struct PreferencesView: View {
|
struct PreferencesView: View {
|
||||||
let mastodonController: MastodonController
|
let mastodonController: MastodonController
|
||||||
|
@ -98,6 +99,9 @@ struct PreferencesView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
|
NavigationLink("About") {
|
||||||
|
AboutView()
|
||||||
|
}
|
||||||
NavigationLink("Acknowledgements") {
|
NavigationLink("Acknowledgements") {
|
||||||
AcknowledgementsView()
|
AcknowledgementsView()
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue