Add About screen

This commit is contained in:
Shadowfacts 2022-12-22 11:54:23 -05:00
parent 231b0ea830
commit 9f86158bb7
27 changed files with 1162 additions and 0 deletions

9
Packages/TTTKit/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@ -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"]),
]
)

View File

@ -0,0 +1,3 @@
# TTTKit
A description of this package.

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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
]
}

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -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)
}
}
}

View File

@ -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 }
}
}

View File

@ -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],
]))
}
}

View File

@ -0,0 +1,7 @@
import XCTest
@testable import TTTKit
final class TTTKitTests: XCTestCase {
func testExample() throws {
}
}

View File

@ -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

View File

@ -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

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -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()
}