From 4b2052e2c0a0ee8b1ccf1aa7f4a573e6d53dcded Mon Sep 17 00:00:00 2001 From: Mathieu GROUSSEAU Date: Tue, 24 Jun 2025 09:23:11 +0200 Subject: [PATCH] Scoreboard view --- App/App.xcodeproj/project.pbxproj | 8 ++++ App/App/Utils/Persistance.swift | 7 +++ App/App/Utils/PlayerType.swift | 14 ++++++ App/App/Utils/RulesType.swift | 14 ++++++ App/App/Utils/String.swift | 11 +++++ App/App/View/SavedGamesView.swift | 26 +++++----- App/App/View/ScoreboardView.swift | 9 ++-- App/App/ViewModel/InGameVM.swift | 18 ++++++- App/App/ViewModel/SavedGamesVM.swift | 71 +++++++++++++++++++++------- 9 files changed, 140 insertions(+), 38 deletions(-) create mode 100644 App/App/Utils/Persistance.swift create mode 100644 App/App/Utils/String.swift diff --git a/App/App.xcodeproj/project.pbxproj b/App/App.xcodeproj/project.pbxproj index edf286e..574a70b 100644 --- a/App/App.xcodeproj/project.pbxproj +++ b/App/App.xcodeproj/project.pbxproj @@ -35,6 +35,8 @@ F08534D02E094F830002A3A0 /* C4Rules.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = F0F59E442DD492B400BE32D6 /* C4Rules.xcframework */; }; F08534D12E094F830002A3A0 /* C4Rules.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = F0F59E442DD492B400BE32D6 /* C4Rules.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; F08534D32E0951BD0002A3A0 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F08534D22E0951BD0002A3A0 /* Images.xcassets */; }; + F08534D52E0A857D0002A3A0 /* Persistance.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08534D42E0A857D0002A3A0 /* Persistance.swift */; }; + F08534D72E0A88240002A3A0 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = F08534D62E0A88240002A3A0 /* String.swift */; }; F0F59E4F2DD4996F00BE32D6 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = F0F59E4E2DD4996F00BE32D6 /* Localizable.xcstrings */; }; F0F59E512DD49C2800BE32D6 /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F0F59E502DD49C2800BE32D6 /* Colors.xcassets */; }; F0F59E532DDDC35100BE32D6 /* NewGameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F59E522DDDC35100BE32D6 /* NewGameView.swift */; }; @@ -104,6 +106,8 @@ F08534C32E094F4E0002A3A0 /* xcframeworks */ = {isa = PBXFileReference; lastKnownFileType = folder; name = xcframeworks; path = ../precompiled/xcframeworks; sourceTree = ""; }; F08534C82E094F7A0002A3A0 /* C4Core.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = C4Core.xcframework; path = ../precompiled/xcframeworks/C4Core.xcframework; sourceTree = ""; }; F08534D22E0951BD0002A3A0 /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; + F08534D42E0A857D0002A3A0 /* Persistance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistance.swift; sourceTree = ""; }; + F08534D62E0A88240002A3A0 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; F0F59E412DD492B400BE32D6 /* C4Persistance.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = C4Persistance.xcframework; path = ../precompiled/xcframeworks/C4Persistance.xcframework; sourceTree = ""; }; F0F59E422DD492B400BE32D6 /* C4Players.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = C4Players.xcframework; path = ../precompiled/xcframeworks/C4Players.xcframework; sourceTree = ""; }; F0F59E432DD492B400BE32D6 /* C4.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = C4.xcframework; path = ../precompiled/xcframeworks/C4.xcframework; sourceTree = ""; }; @@ -250,6 +254,8 @@ F0143C332DF987490086CAAA /* PlayerType.swift */, F0143C352DFA9A000086CAAA /* RulesType.swift */, F05DA2162E082F5B0094A4A8 /* Owner.swift */, + F08534D42E0A857D0002A3A0 /* Persistance.swift */, + F08534D62E0A88240002A3A0 /* String.swift */, ); path = Utils; sourceTree = ""; @@ -406,6 +412,7 @@ files = ( F05DA2112E002AA00094A4A8 /* BoardNode.swift in Sources */, F0143C312DF9813C0086CAAA /* PieceNode.swift in Sources */, + F08534D72E0A88240002A3A0 /* String.swift in Sources */, F05DA2152E02D1850094A4A8 /* UIUtilities.swift in Sources */, F0F59E592DE6EB2A00BE32D6 /* PlayerVSPage.swift in Sources */, F001A04E2DD48FAB00809561 /* MainMenuView.swift in Sources */, @@ -420,6 +427,7 @@ F0143C2B2DF018F20086CAAA /* GameScene.swift in Sources */, F0F59E572DE6D6E600BE32D6 /* SavedGamesView.swift in Sources */, F05DA2172E082F5B0094A4A8 /* Owner.swift in Sources */, + F08534D52E0A857D0002A3A0 /* Persistance.swift in Sources */, F0143C342DF987490086CAAA /* PlayerType.swift in Sources */, F0F59E552DDDED1D00BE32D6 /* IngameView.swift in Sources */, ); diff --git a/App/App/Utils/Persistance.swift b/App/App/Utils/Persistance.swift new file mode 100644 index 0000000..ee49621 --- /dev/null +++ b/App/App/Utils/Persistance.swift @@ -0,0 +1,7 @@ +import Connect4Persistance + +extension Persistance { + static let unfinishedGameFileName: String = "lastUnfinished.co4" + static let finishedGameFileName: String = "savedGames.json" + static let saveDirectory: String = "connect4.games" +} diff --git a/App/App/Utils/PlayerType.swift b/App/App/Utils/PlayerType.swift index c1b6c52..981b37c 100644 --- a/App/App/Utils/PlayerType.swift +++ b/App/App/Utils/PlayerType.swift @@ -1,7 +1,21 @@ import Foundation +import Connect4Core +import Connect4Players enum PlayerType: CaseIterable, Identifiable { var id: Self { self } case Human, AIRandom, AIFinnishHim, AISimpleNegaMax } + +extension Player { + var type: PlayerType { + switch (self) { + case is HumanPlayer: .Human + case is RandomPlayer: .AIRandom + case is FinnishHimPlayer: .AIFinnishHim + case is SimpleNegaMaxPlayer: .AISimpleNegaMax + default: fatalError("Unexpected player type") + } + } +} diff --git a/App/App/Utils/RulesType.swift b/App/App/Utils/RulesType.swift index 8bddd78..36e8c7e 100644 --- a/App/App/Utils/RulesType.swift +++ b/App/App/Utils/RulesType.swift @@ -1,5 +1,19 @@ +import Connect4Core +import Connect4Rules + enum RulesType: CaseIterable, Identifiable { var id: Self { self } case Classic, TicTacToe, PopOut } + +extension Rules { + var type: RulesType { + switch (self) { + case is Connect4Rules: .Classic + case is TicTacToeRules: .TicTacToe + case is PopOutRules: .PopOut + default: fatalError("Unexpected rules type") + } + } +} diff --git a/App/App/Utils/String.swift b/App/App/Utils/String.swift new file mode 100644 index 0000000..870bc52 --- /dev/null +++ b/App/App/Utils/String.swift @@ -0,0 +1,11 @@ +import Foundation + +extension String { + var nilIfEmpty: Self? { + if self.isEmpty { + nil + } else { + self + } + } +} diff --git a/App/App/View/SavedGamesView.swift b/App/App/View/SavedGamesView.swift index d2a8dec..2198f5c 100644 --- a/App/App/View/SavedGamesView.swift +++ b/App/App/View/SavedGamesView.swift @@ -3,20 +3,20 @@ import SwiftUI struct SavedGamesView: View { @StateObject private var vm = SavedGamesVM() - + var body: some View { TabView { - /* VStack { + VStack { Section("savedGames.section.unfinished") { - ScoreboardView(results: self.$vm.results, "scoreboard.table.column.players") { + ScoreboardView(results: self.$vm.unfinished, "scoreboard.table.column.players") { result in - let n1 = if result.player1 != nil { - Text(result.player1!) + let n1 = if result.player1Name != nil { + Text(result.player1Name!) } else { Text(LocalizedStringKey(result.player1Type.baseTranslationKey)) } - let n2 = if result.player2 != nil { - Text(result.player2!) + let n2 = if result.player2Name != nil { + Text(result.player2Name!) } else { Text(LocalizedStringKey(result.player2Type.baseTranslationKey)) } @@ -24,19 +24,19 @@ struct SavedGamesView: View { return LocalizedStringKey("\(n1) savedGames.section.unfinished.entry \(n2)") } } - } */ + } VStack { Section("savedGames.section.finished") { - ScoreboardView(results: self.$vm.results, "scoreboard.table.column.players") { + ScoreboardView(results: self.$vm.finished, "scoreboard.table.column.players") { result in - let n1 = if result.player1 != nil { - Text(result.player1!) + let n1 = if result.player1Name != nil { + Text(result.player1Name!) } else { Text(LocalizedStringKey(result.player1Type.baseTranslationKey)) } - let n2 = if result.player2 != nil { - Text(result.player2!) + let n2 = if result.player2Name != nil { + Text(result.player2Name!) } else { Text(LocalizedStringKey(result.player2Type.baseTranslationKey)) } diff --git a/App/App/View/ScoreboardView.swift b/App/App/View/ScoreboardView.swift index e487a2f..ad0d137 100644 --- a/App/App/View/ScoreboardView.swift +++ b/App/App/View/ScoreboardView.swift @@ -12,13 +12,12 @@ struct ScoreboardView: View { private var horizontalSizeClass: UserInterfaceSizeClass? private let playerRelatedColumnKey: LocalizedStringKey - private let localizedKeyProvider: (ResultVM) -> LocalizedStringKey + private let localizedKeyProvider: (GameEntryVM) -> LocalizedStringKey @Binding - private var results: [ResultVM] + private var results: [GameEntryVM] var body: some View { - // TODO: sort by date if horizontalSizeClass == .compact { List(self.results) { result in VStack(alignment: .center) { @@ -33,7 +32,7 @@ struct ScoreboardView: View { } else{ Table(self.results) { TableColumn("scoreboard.table.column.date") { result in - Text(result.date, style: .date) + Text("generic.datetime \(Text(result.date, style: .date)) \(Text(result.date, style: .time))") } TableColumn(playerRelatedColumnKey) { result in Text(localizedKeyProvider(result)) @@ -45,7 +44,7 @@ struct ScoreboardView: View { } } - public init(results: Binding<[ResultVM]>, _ playerRelatedColumnKey: LocalizedStringKey, localizedKeyProvider: @escaping (ResultVM) -> LocalizedStringKey) { + public init(results: Binding<[GameEntryVM]>, _ playerRelatedColumnKey: LocalizedStringKey, localizedKeyProvider: @escaping (GameEntryVM) -> LocalizedStringKey) { self._results = results self.playerRelatedColumnKey = playerRelatedColumnKey self.localizedKeyProvider = localizedKeyProvider diff --git a/App/App/ViewModel/InGameVM.swift b/App/App/ViewModel/InGameVM.swift index e0be21f..2b499dc 100644 --- a/App/App/ViewModel/InGameVM.swift +++ b/App/App/ViewModel/InGameVM.swift @@ -97,11 +97,25 @@ class IngameVM: ObservableObject { } if result == .notFinished { - _ = try await Persistance.saveGame(withName: "lastUnfinished.co4", andGame: self.game, withFolderName: "connect4.games") + _ = try await Persistance.saveGame( + withName: Persistance.unfinishedGameFileName, + andGame: self.game, + withFolderName: Persistance.saveDirectory + ) return } - _ = try await Persistance.saveGameResult(withName: "savedGames.json", andGame: game, andResult: result, withFolderName: "connect4.games") + try await Persistance.deleteGame( + withName: Persistance.unfinishedGameFileName, + withFolderName: Persistance.saveDirectory + ) + + _ = try await Persistance.saveGameResult( + withName: Persistance.finishedGameFileName, + andGame: game, + andResult: result, + withFolderName: Persistance.saveDirectory + ) } game.addGameStartedListener { board in print("game started") diff --git a/App/App/ViewModel/SavedGamesVM.swift b/App/App/ViewModel/SavedGamesVM.swift index b843a3b..88975f9 100644 --- a/App/App/ViewModel/SavedGamesVM.swift +++ b/App/App/ViewModel/SavedGamesVM.swift @@ -1,33 +1,59 @@ import Foundation +import Connect4Core import Connect4Persistance class SavedGamesVM: ObservableObject { @Published - var results: [ResultVM] = [] + var unfinished: [GameEntryVM] = [] + @Published + var finished: [GameEntryVM] = [] init() { Task(priority: .userInitiated) { - let results = (try await Persistance.loadGameResults(withName: "savedGames.json", withFolderName: "connect4.games")) ?? [] - let vms = results.sorted(by: { $0.date > $1.date }).map(ResultVM.init) + let sortedU: [GameEntryVM] + let sortedF: [GameEntryVM] + var temp: [GameEntryVM] + + do { + if let game = try await Persistance.loadGame(withName: Persistance.unfinishedGameFileName, withFolderName: Persistance.saveDirectory) { + temp = [GameEntryVM(fromGame: game)] + } else { + temp = [] + } + sortedU = temp.sorted(by: { $0.date > $1.date }) + } catch { + sortedU = [] + } + + do { + let results = (try await Persistance.loadGameResults(withName: Persistance.finishedGameFileName, withFolderName: Persistance.saveDirectory)) ?? [] + temp = results.map(GameEntryVM.init) + sortedF = temp.sorted(by: { $0.date > $1.date }) + } catch { + sortedF = [] + } DispatchQueue.main.async { - self.results = vms + self.unfinished = sortedU + self.finished = sortedF } } } } -class ResultVM: Identifiable { +class GameEntryVM: Identifiable { var id: Date { self.date } + let game: Game? + let date: Date - let player1: String? + let player1Name: String? let player1Type: PlayerType - let player2: String? + let player2Name: String? let player2Type: PlayerType let rules: RulesType - init(from: GameResult) { + init(fromResult from: GameResult) { self.date = from.date let (p1, p2) = if from.players[0].id == .player1 { (from.players[0], from.players[1]) @@ -36,17 +62,9 @@ class ResultVM: Identifiable { } self.player1Type = Self.i(data: p1) - self.player1 = if p1.name.isEmpty { - nil - } else { - p1.name - } + self.player1Name = p1.name.nilIfEmpty self.player2Type = Self.i(data: p2) - self.player2 = if p2.name.isEmpty { - nil - } else { - p2.name - } + self.player2Name = p2.name.nilIfEmpty self.rules = switch (from.rules.type) { case "Connect4Rules": .Classic @@ -54,6 +72,23 @@ class ResultVM: Identifiable { case "PopOutRules": .PopOut default: fatalError("Unexpected rules type") } + + self.game = nil + } + + init(fromGame from: Game) { + self.game = from; + self.date = Date.now + + let p1 = from.players[.player1]! + let p2 = from.players[.player2]! + + self.player1Name = p1.name.nilIfEmpty + self.player1Type = p1.type + self.player2Name = p2.name.nilIfEmpty + self.player2Type = p2.type + + self.rules = from.rules.type } private static func i(data: PlayerData) -> PlayerType {