From 595996f63983dd0dd21afd031437aa3b7223f8cb Mon Sep 17 00:00:00 2001 From: Mathieu GROUSSEAU Date: Sun, 22 Jun 2025 17:25:59 +0200 Subject: [PATCH] WIP saved games --- App/App.xcodeproj/project.pbxproj | 4 ++ App/App/Localizable.xcstrings | 4 ++ App/App/SpriteKit/PieceNode.swift | 6 ++- App/App/View/PlayerVSPage.swift | 6 +-- App/App/View/SavedGamesView.swift | 44 ++++++++--------- App/App/View/ScoreboardView.swift | 35 +++++++------ App/App/ViewModel/InGameVM.swift | 17 ++++++- App/App/ViewModel/SavedGamesVM.swift | 73 ++++++++++++++++++++++++++++ 8 files changed, 140 insertions(+), 49 deletions(-) create mode 100644 App/App/ViewModel/SavedGamesVM.swift diff --git a/App/App.xcodeproj/project.pbxproj b/App/App.xcodeproj/project.pbxproj index 93c107f..7feffd3 100644 --- a/App/App.xcodeproj/project.pbxproj +++ b/App/App.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ F05DA2132E01BA270094A4A8 /* PlayerVM.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05DA2122E01BA270094A4A8 /* PlayerVM.swift */; }; F05DA2152E02D1850094A4A8 /* UIUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05DA2142E02D1850094A4A8 /* UIUtilities.swift */; }; F05DA2172E082F5B0094A4A8 /* Owner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05DA2162E082F5B0094A4A8 /* Owner.swift */; }; + F05DA2192E0843170094A4A8 /* SavedGamesVM.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05DA2182E0843170094A4A8 /* SavedGamesVM.swift */; }; F0F59E492DD4958800BE32D6 /* C4.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = F0F59E432DD492B400BE32D6 /* C4.xcframework */; }; F0F59E4A2DD4958800BE32D6 /* C4Persistance.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = F0F59E412DD492B400BE32D6 /* C4Persistance.xcframework */; }; F0F59E4B2DD4958800BE32D6 /* C4Players.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = F0F59E422DD492B400BE32D6 /* C4Players.xcframework */; }; @@ -76,6 +77,7 @@ F05DA2122E01BA270094A4A8 /* PlayerVM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerVM.swift; sourceTree = ""; }; F05DA2142E02D1850094A4A8 /* UIUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIUtilities.swift; sourceTree = ""; }; F05DA2162E082F5B0094A4A8 /* Owner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Owner.swift; sourceTree = ""; }; + F05DA2182E0843170094A4A8 /* SavedGamesVM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedGamesVM.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 = ""; }; @@ -198,6 +200,7 @@ F0143C2C2DF01E120086CAAA /* NewGameVM.swift */, F0143C2E2DF96B690086CAAA /* InGameVM.swift */, F05DA2122E01BA270094A4A8 /* PlayerVM.swift */, + F05DA2182E0843170094A4A8 /* SavedGamesVM.swift */, ); path = ViewModel; sourceTree = ""; @@ -379,6 +382,7 @@ F0143C362DFA9A000086CAAA /* RulesType.swift in Sources */, F0F59E532DDDC35100BE32D6 /* NewGameView.swift in Sources */, F0143C2F2DF96B690086CAAA /* InGameVM.swift in Sources */, + F05DA2192E0843170094A4A8 /* SavedGamesVM.swift in Sources */, F05DA2132E01BA270094A4A8 /* PlayerVM.swift in Sources */, F0F59E5B2DE6F68800BE32D6 /* ScoreboardView.swift in Sources */, F0143C2B2DF018F20086CAAA /* GameScene.swift in Sources */, diff --git a/App/App/Localizable.xcstrings b/App/App/Localizable.xcstrings index b05091a..8982e7e 100644 --- a/App/App/Localizable.xcstrings +++ b/App/App/Localizable.xcstrings @@ -18,6 +18,7 @@ } }, "%@ savedGames.section.unfinished.entry %@" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -286,6 +287,7 @@ } }, "inGame.ppButton.pause" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -302,6 +304,7 @@ } }, "inGame.ppButton.resume" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -584,6 +587,7 @@ } }, "savedGames.section.unfinished" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { diff --git a/App/App/SpriteKit/PieceNode.swift b/App/App/SpriteKit/PieceNode.swift index 644f90a..621c468 100644 --- a/App/App/SpriteKit/PieceNode.swift +++ b/App/App/SpriteKit/PieceNode.swift @@ -13,8 +13,12 @@ class PieceNode : SKNode { var piece: Piece = .init(withOwner: .noOne) { didSet { + #if os(iOS) circle.fillColor = UIColor(piece.owner.pieceColor) - circle.strokeColor = UIColor(.pieceBorder) + #else + circle.fillColor = NSColor(piece.owner.pieceColor) + #endif + circle.strokeColor = .pieceBorder } } var boardPosition: (x: Int, y: Int)? = nil { diff --git a/App/App/View/PlayerVSPage.swift b/App/App/View/PlayerVSPage.swift index 9680f05..40665e5 100644 --- a/App/App/View/PlayerVSPage.swift +++ b/App/App/View/PlayerVSPage.swift @@ -37,9 +37,9 @@ struct PlayerVSPage: View { PlayerPicker() } - ScoreboardView("scoreboard.table.column.players") { result in - "\(result.player1) savedGames.section.finished.entry \(result.player2)" - } + // ScoreboardView("scoreboard.table.column.players") { result in + // "\(result.player1) savedGames.section.finished.entry \(result.player2)" + // } } } } diff --git a/App/App/View/SavedGamesView.swift b/App/App/View/SavedGamesView.swift index f6cae40..405c295 100644 --- a/App/App/View/SavedGamesView.swift +++ b/App/App/View/SavedGamesView.swift @@ -1,36 +1,30 @@ -// -// SavedGamesView.swift -// App -// -// Created by etudiant2 on 28/05/2025. -// - import SwiftUI struct SavedGamesView: View { + @StateObject + private var vm = SavedGamesVM() + var body: some View { - // TODO: use the same collection view with headers instead? Or use collapsible sections? - Section("savedGames.section.unfinished") { - ScoreboardView("scoreboard.table.column.players") { result in - "\(result.player1) savedGames.section.unfinished.entry \(result.player2)" - } - } - Section("savedGames.section.finished") { - ScoreboardView("scoreboard.table.column.players") { result in - "\(result.player1) savedGames.section.finished.entry \(result.player2)" + VStack { + // TODO: use the same collection view with headers instead? Or use collapsible sections? + //Section("savedGames.section.unfinished") { + // ScoreboardView("scoreboard.table.column.players") { result in + // "\(result.player1) savedGames.section.unfinished.entry \(result.player2)" + // } + //} + Section("savedGames.section.finished") { + ScoreboardView(results: self.$vm.results, "scoreboard.table.column.players") { result in + "\(result.player1) savedGames.section.finished.entry \(result.player2)" + } } + }.onAppear { + vm.loadData() } } -} - - -struct Result: Identifiable { - let id = UUID() - let date: Date - let player1: String - let player2: String - let rules: String + init() { + + } } #Preview { diff --git a/App/App/View/ScoreboardView.swift b/App/App/View/ScoreboardView.swift index a4c9bbd..fdd6cfe 100644 --- a/App/App/View/ScoreboardView.swift +++ b/App/App/View/ScoreboardView.swift @@ -12,50 +12,49 @@ struct ScoreboardView: View { private var horizontalSizeClass: UserInterfaceSizeClass? private let playerRelatedColumnKey: LocalizedStringKey - private let localizedKeyProvider: (Result) -> LocalizedStringKey + private let localizedKeyProvider: (ResultVM) -> LocalizedStringKey - @State private var unsinished = [ - Result(date: Date.now, player1: "P1", player2: "P2", rules: "Rule1"), - Result(date: Date.now, player1: "P2", player2: "P3", rules: "Rule2"), - Result(date: Date.now, player1: "P3", player2: "P4", rules: "Rule3"), - Result(date: Date.now, player1: "P4", player2: "P5", rules: "Rule4"), - Result(date: Date.now, player1: "P5", player2: "P1", rules: "Rule5") - ]; + @Binding + private var results: [ResultVM] var body: some View { // TODO: sort by date if horizontalSizeClass == .compact { - List(self.unsinished) { result in + List(self.results) { result in VStack(alignment: .center) { Text(localizedKeyProvider(result)) HStack { Text(result.date, style: .date) Spacer() - Text(result.rules) + Text(result.rules.baseTranslationKey) }.foregroundStyle(.secondary) } } } else{ - Table(self.unsinished) { + Table(self.results) { TableColumn("scoreboard.table.column.date") { result in Text(result.date, style: .date) } TableColumn(playerRelatedColumnKey) { result in Text(localizedKeyProvider(result)) } - TableColumn("scoreboard.table.column.rules", value: \.rules) + TableColumn("scoreboard.table.column.rules", content: { result in + Text(result.rules.baseTranslationKey) + }) } } } - public init(_ playerRelatedColumnKey: LocalizedStringKey, localizedKeyProvider: @escaping (Result) -> LocalizedStringKey) { + public init(results: Binding<[ResultVM]>, _ playerRelatedColumnKey: LocalizedStringKey, localizedKeyProvider: @escaping (ResultVM) -> LocalizedStringKey) { + self._results = results self.playerRelatedColumnKey = playerRelatedColumnKey self.localizedKeyProvider = localizedKeyProvider } } -#Preview { - ScoreboardView("scoreboard.table.column.players") { result in - "\(result.player1) savedGames.section.unfinished.entry \(result.player2)" - } -} +//TODO +// #Preview { +// ScoreboardView("scoreboard.table.column.players") { result in +// "\(result.player1) savedGames.section.unfinished.entry \(result.player2)" +// } +// } diff --git a/App/App/ViewModel/InGameVM.swift b/App/App/ViewModel/InGameVM.swift index e6eeddd..5e1de17 100644 --- a/App/App/ViewModel/InGameVM.swift +++ b/App/App/ViewModel/InGameVM.swift @@ -77,8 +77,21 @@ class IngameVM: ObservableObject { // TODO } game.addGameChangedListener { game, result in - print("game changed") - // TODO + if game.players.contains(where: { $0.value is ReplayPlayer }) { + // Do not save progress of an already finished game + return + } + + defer { + print("game saved") + } + + if result == .notFinished { + _ = try await Persistance.saveGame(withName: "\(self.gameName).co4", andGame: self.game, withFolderName: "connect4.games") + return + } + + _ = try await Persistance.saveGameResult(withName: "savedGames.json", andGame: game, andResult: result, withFolderName: "connect4.games") } game.addGameStartedListener { board in print("game started") diff --git a/App/App/ViewModel/SavedGamesVM.swift b/App/App/ViewModel/SavedGamesVM.swift new file mode 100644 index 0000000..087d782 --- /dev/null +++ b/App/App/ViewModel/SavedGamesVM.swift @@ -0,0 +1,73 @@ +import Foundation +import Connect4Persistance + +class SavedGamesVM: ObservableObject { + private var loaded: Bool = false + + @Published + var results: [ResultVM] = [] + + init() { + + } + + func loadData() { + if (!loaded) { + loaded = true + + Task(priority: .userInitiated) { + let results = (try await Persistance.loadGameResults(withName: "savedGames.json", withFolderName: "connect4.games")) ?? [] + let vms = results.map(ResultVM.init) + + DispatchQueue.main.async { + self.results = vms + } + } + } + } +} + +class ResultVM: Identifiable { + var id: Date { self.date } + + let date: Date + let player1: String + let player1Type: PlayerType + let player2: String + let player2Type: PlayerType + let rules: RulesType + + init(from: GameResult) { + self.date = from.date + let p = from.players[0] + + if p.id == .player1 { + self.player1 = p.name + self.player1Type = Self.i(data: p) + self.player2 = p.name + self.player2Type = Self.i(data: from.players[1]) + } else { + self.player2 = p.name + self.player2Type = Self.i(data: p) + self.player1 = p.name + self.player1Type = Self.i(data: from.players[1]) + } + + self.rules = switch (from.rules.type) { + case "Connect4Rules": .Classic + case "TicTacToeRules": .TicTacToe + case "PopOutRules": .PopOut + default: fatalError("Unexpected rules type") + } + } + + private static func i(data: PlayerData) -> PlayerType { + return switch (data.type) { + case "HumanPlayer": .Human + case "RandomPlayer": .AIRandom + case "FinnishHimPlayer": .AIFinnishHim + case "SimpleNegaMaxPlayer": .AISimpleNegaMax + default: fatalError("Unexpected player type") + } + } +}