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 */; };
|
||||
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 = "<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>"; };
|
||||
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>"; };
|
||||
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 = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -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 = "<group>";
|
||||
|
@ -1213,6 +1223,16 @@
|
|||
path = "Account Detail";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D68A76EF2953910A001DA1B3 /* About */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D68A76EB295369A8001DA1B3 /* AboutView.swift */,
|
||||
D68A76F029539116001DA1B3 /* FlipView.swift */,
|
||||
D659F36129541065002D944A /* TTTView.swift */,
|
||||
);
|
||||
path = About;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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" */;
|
||||
|
|
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 TTTKit
|
||||
|
||||
struct PreferencesView: View {
|
||||
let mastodonController: MastodonController
|
||||
|
@ -98,6 +99,9 @@ struct PreferencesView: View {
|
|||
}
|
||||
|
||||
Section {
|
||||
NavigationLink("About") {
|
||||
AboutView()
|
||||
}
|
||||
NavigationLink("Acknowledgements") {
|
||||
AcknowledgementsView()
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue