From 7b895f1b7272ba0e2155d1ea738c4630baa6364e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Stompo=CC=81r?= Date: Wed, 21 Jan 2026 22:28:15 +0100 Subject: [PATCH] Support game saving --- Chess.xcodeproj/project.pbxproj | 26 +++++- Chess/Common/Game/CheckState.swift | 5 +- Chess/Common/Game/GameSnapshot.swift | 22 +++++ Chess/Common/Game/GameState.swift | 58 +++++++++---- Chess/Common/Game/HistoryBrowser.swift | 5 +- Chess/Common/Game/PickExpectation.swift | 5 +- Chess/Common/Game/Save/SaveCoordinator.swift | 83 +++++++++++++++++++ Chess/Common/Game/Save/SaveGame.swift | 16 ++++ Chess/Common/Scene/ResourceLoader.swift | 5 +- Chess/Common/Scene/SceneBuilder.swift | 5 +- Chess/macOS/App/AppDelegate.swift | 53 ++++++++++-- .../Storyboards/Base.lproj/Main.storyboard | 51 ++++++++++-- .../ViewControllers/GameViewController.swift | 42 ++++++++-- 13 files changed, 318 insertions(+), 58 deletions(-) create mode 100644 Chess/Common/Game/GameSnapshot.swift create mode 100644 Chess/Common/Game/Save/SaveCoordinator.swift create mode 100644 Chess/Common/Game/Save/SaveGame.swift diff --git a/Chess.xcodeproj/project.pbxproj b/Chess.xcodeproj/project.pbxproj index 94cbc70..d9a8fce 100644 --- a/Chess.xcodeproj/project.pbxproj +++ b/Chess.xcodeproj/project.pbxproj @@ -8,6 +8,9 @@ /* Begin PBXBuildFile section */ 0558E89E2F0BBAE8006EE0FA /* rook.icon in Resources */ = {isa = PBXBuildFile; fileRef = 0558E89D2F0BBAE8006EE0FA /* rook.icon */; }; + 05F753BD2F2159080017ADA8 /* SaveGame.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05F753BC2F2159050017ADA8 /* SaveGame.swift */; }; + 05F753BF2F2159170017ADA8 /* SaveCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05F753BE2F2159160017ADA8 /* SaveCoordinator.swift */; }; + 05F753C92F217B0F0017ADA8 /* GameSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05F753C72F217B0E0017ADA8 /* GameSnapshot.swift */; }; 561B67F328D2604600D6E2F7 /* BoardInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 561B67F228D2604600D6E2F7 /* BoardInteractor.swift */; }; 561B67F828D4C12F00D6E2F7 /* PickExpectation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 561B67F728D4C12F00D6E2F7 /* PickExpectation.swift */; }; 561B67FC28D4C14700D6E2F7 /* Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = 561B67FB28D4C14700D6E2F7 /* Action.swift */; }; @@ -105,6 +108,9 @@ /* Begin PBXFileReference section */ 0558E89D2F0BBAE8006EE0FA /* rook.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = rook.icon; sourceTree = ""; }; + 05F753BC2F2159050017ADA8 /* SaveGame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveGame.swift; sourceTree = ""; }; + 05F753BE2F2159160017ADA8 /* SaveCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveCoordinator.swift; sourceTree = ""; }; + 05F753C72F217B0E0017ADA8 /* GameSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameSnapshot.swift; sourceTree = ""; }; 561B67F228D2604600D6E2F7 /* BoardInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoardInteractor.swift; sourceTree = ""; }; 561B67F728D4C12F00D6E2F7 /* PickExpectation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickExpectation.swift; sourceTree = ""; }; 561B67FB28D4C14700D6E2F7 /* Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; }; @@ -183,6 +189,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 05F753BB2F2158FF0017ADA8 /* Save */ = { + isa = PBXGroup; + children = ( + 05F753BE2F2159160017ADA8 /* SaveCoordinator.swift */, + 05F753BC2F2159050017ADA8 /* SaveGame.swift */, + ); + path = Save; + sourceTree = ""; + }; 56122C1728DB36A800D31958 /* Storyboards */ = { isa = PBXGroup; children = ( @@ -349,6 +364,8 @@ 569699C228CE479700469E6B /* Game */ = { isa = PBXGroup; children = ( + 05F753C72F217B0E0017ADA8 /* GameSnapshot.swift */, + 05F753BB2F2158FF0017ADA8 /* Save */, 562D0E6E28E1E60300944DF2 /* Field */, 561B67FA28D4C13C00D6E2F7 /* Move */, 561B67F528D2604900D6E2F7 /* Board */, @@ -600,6 +617,7 @@ 5648531728C90D7F00BA4CC0 /* Move.swift in Sources */, 561B67FC28D4C14700D6E2F7 /* Action.swift in Sources */, 5648531128C90B5C00BA4CC0 /* PieceColor.swift in Sources */, + 05F753BF2F2159170017ADA8 /* SaveCoordinator.swift in Sources */, 56BA8DEA28DF09F80012A5CE /* QueenMoveGenerator.swift in Sources */, 56BB94AE28A94A750034D4AB /* GameViewController.swift in Sources */, 561B67F328D2604600D6E2F7 /* BoardInteractor.swift in Sources */, @@ -613,6 +631,7 @@ 56BA8DDC28DE59C40012A5CE /* HistoryBrowser.swift in Sources */, 567A568528CB68BF008DDE40 /* CameraController.swift in Sources */, 5648531528C90B9100BA4CC0 /* Piece.swift in Sources */, + 05F753C92F217B0F0017ADA8 /* GameSnapshot.swift in Sources */, 5648530F28C90B2600BA4CC0 /* PieceType.swift in Sources */, 56D8FCED28E4EE0400027132 /* PieceParser.swift in Sources */, 562D0E7128E1E60E00944DF2 /* FieldParser.swift in Sources */, @@ -623,6 +642,7 @@ 56BA8DE428DF08110012A5CE /* RookMoveGenerator.swift in Sources */, 569699CA28CE4DD300469E6B /* Field.swift in Sources */, 56BA8DE828DF09BF0012A5CE /* KingMoveGenerator.swift in Sources */, + 05F753BD2F2159080017ADA8 /* SaveGame.swift in Sources */, 567A568828CBA420008DDE40 /* MouseInteractionHandler.swift in Sources */, 561B67F828D4C12F00D6E2F7 /* PickExpectation.swift in Sources */, 56C3174728CFFDCB00C04F3C /* MovesGenerator.swift in Sources */, @@ -720,7 +740,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 12.4; + MACOSX_DEPLOYMENT_TARGET = 14.6; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -776,7 +796,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 12.4; + MACOSX_DEPLOYMENT_TARGET = 14.6; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; @@ -847,7 +867,6 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 12.5; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.stompor.mateusz.porcelain.chess.tests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -864,7 +883,6 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 12.5; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.stompor.mateusz.porcelain.chess.tests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Chess/Common/Game/CheckState.swift b/Chess/Common/Game/CheckState.swift index 510ecb0..5e448d3 100644 --- a/Chess/Common/Game/CheckState.swift +++ b/Chess/Common/Game/CheckState.swift @@ -1,8 +1,5 @@ // -// CheckState.swift -// Chess -// -// Created by Mateusz Stompór on 26/09/2022. +// Copyright © 2022 Mateusz Stompór. All rights reserved. // enum CheckState: Codable { diff --git a/Chess/Common/Game/GameSnapshot.swift b/Chess/Common/Game/GameSnapshot.swift new file mode 100644 index 0000000..1af2a84 --- /dev/null +++ b/Chess/Common/Game/GameSnapshot.swift @@ -0,0 +1,22 @@ +// +// Copyright © 2026 Mateusz Stompór. All rights reserved. +// + +class GameSnapshot: Codable { + var board: Board + var selectedPiece: Piece? + var turn: PieceColor + var expectation: PickExpectation + var checkState: CheckState + init(board: Board, + selectedPiece: Piece? = nil, + turn: PieceColor, + expectation: PickExpectation, + checkState: CheckState) { + self.board = board + self.selectedPiece = selectedPiece + self.turn = turn + self.expectation = expectation + self.checkState = checkState + } +} diff --git a/Chess/Common/Game/GameState.swift b/Chess/Common/Game/GameState.swift index aafc568..0ddd4d7 100644 --- a/Chess/Common/Game/GameState.swift +++ b/Chess/Common/Game/GameState.swift @@ -1,29 +1,55 @@ // -// GameState.swift -// Chess -// -// Created by Mateusz Stompór on 16/09/2022. +// Copyright © 2022 Mateusz Stompór. All rights reserved. // +import SwiftData + +@Model class GameState { - let previous: GameState? - let board: Board - let selectedPiece: Piece? - let turn: PieceColor - let expectation: PickExpectation - let checkState: CheckState + var all: [GameSnapshot] + var last: GameSnapshot { + guard let lastState = all.last else { + fatalError("Invalid state, game must have at least one board") + } + return lastState + } + var board: Board { + last.board + } + var selectedPiece: Piece? { + last.selectedPiece + } + var turn: PieceColor { + last.turn + } + var expectation: PickExpectation { + last.expectation + } + var checkState: CheckState { + last.checkState + } + var previous: GameState? { + guard all.count > 1 else { + return nil + } + return GameState(all: Array(all.dropLast())) + } + private init(all: [GameSnapshot]) { + self.all = all + } init(previous: GameState?, board: Board, selectedPiece: Piece?, turn: PieceColor, expectation: PickExpectation, checkState: CheckState) { - self.previous = previous - self.board = board - self.selectedPiece = selectedPiece - self.turn = turn - self.expectation = expectation - self.checkState = checkState + let allPrevious = previous?.all ?? [] + let current = GameSnapshot(board: board, + selectedPiece: selectedPiece, + turn: turn, + expectation: expectation, + checkState: checkState) + all = allPrevious + [current] } static var initial: GameState { GameState(previous: nil, diff --git a/Chess/Common/Game/HistoryBrowser.swift b/Chess/Common/Game/HistoryBrowser.swift index 32a6440..8b32f7d 100644 --- a/Chess/Common/Game/HistoryBrowser.swift +++ b/Chess/Common/Game/HistoryBrowser.swift @@ -1,8 +1,5 @@ // -// HistoryBrowser.swift -// Chess -// -// Created by Mateusz Stompór on 23/09/2022. +// Copyright © 2022 Mateusz Stompór. All rights reserved. // class HistoryBrowser { diff --git a/Chess/Common/Game/PickExpectation.swift b/Chess/Common/Game/PickExpectation.swift index c59fcb7..bf6084a 100644 --- a/Chess/Common/Game/PickExpectation.swift +++ b/Chess/Common/Game/PickExpectation.swift @@ -1,8 +1,5 @@ // -// PickExpectation.swift -// Chess -// -// Created by Mateusz Stompór on 16/09/2022. +// Copyright © 2022 Mateusz Stompór. All rights reserved. // enum PickExpectation: Codable { diff --git a/Chess/Common/Game/Save/SaveCoordinator.swift b/Chess/Common/Game/Save/SaveCoordinator.swift new file mode 100644 index 0000000..42ae19e --- /dev/null +++ b/Chess/Common/Game/Save/SaveCoordinator.swift @@ -0,0 +1,83 @@ +// +// Copyright © 2022 Mateusz Stompór. All rights reserved. +// + +import Combine +import SwiftData + +class SaveCoordinator { + let saveDidChange = PassthroughSubject() + private let container: ModelContainer + static let shared: SaveCoordinator = { + SaveCoordinator() + }() + private init() { + do { + container = try ModelContainer(for: SaveGame.self) + } catch { + fatalError("Failed to create ModelContainer: \(error)") + } + } + func save(game: SaveGame) -> Bool { + let context = ModelContext(container) + save(state: game.state, context: context) + context.insert(game) + do { + try context.save() + } catch { + print("Failed to insert: \(error)") + return false + } + saveDidChange.send() + return true + } + private func save(state: GameState, context: ModelContext) { + context.insert(state) + if let previous = state.previous { + save(state: previous, context: context) + } + } + var saveCount: Int { + all.count + } + var all: [SaveGame] { + let context = ModelContext(container) + + let fetchDescriptor = FetchDescriptor() + do { + return try context.fetch(fetchDescriptor) + } catch { + return [] + } + } + func load(index: Int) -> SaveGame? { + let context = ModelContext(container) + + let fetchDescriptor = FetchDescriptor() + do { + return try context.fetch(fetchDescriptor)[index] + } catch { + return nil + } + } + func wipe() -> Bool { + let context = ModelContext(container) + + let fetchDescriptor = FetchDescriptor() + do { + let saves = try context.fetch(fetchDescriptor) + + for save in saves { + context.delete(save) + } + + try context.save() + print("All save records deleted!") + } catch { + print("Failed to delete: \(error)") + return false + } + saveDidChange.send() + return true + } +} diff --git a/Chess/Common/Game/Save/SaveGame.swift b/Chess/Common/Game/Save/SaveGame.swift new file mode 100644 index 0000000..fbccd92 --- /dev/null +++ b/Chess/Common/Game/Save/SaveGame.swift @@ -0,0 +1,16 @@ +// +// Copyright © 2022 Mateusz Stompór. All rights reserved. +// + +import Foundation +import SwiftData + +@Model +class SaveGame { + var creationDate: Date + var state: GameState + init(creationDate: Date, state: GameState) { + self.creationDate = creationDate + self.state = state + } +} diff --git a/Chess/Common/Scene/ResourceLoader.swift b/Chess/Common/Scene/ResourceLoader.swift index c324364..d8589a3 100644 --- a/Chess/Common/Scene/ResourceLoader.swift +++ b/Chess/Common/Scene/ResourceLoader.swift @@ -1,8 +1,5 @@ // -// ResourceLoader.swift -// Chess -// -// Created by Mateusz Stompór on 06/09/2022. +// Copyright © 2022 Mateusz Stompór. All rights reserved. // import MetalKit diff --git a/Chess/Common/Scene/SceneBuilder.swift b/Chess/Common/Scene/SceneBuilder.swift index b306922..56673b8 100644 --- a/Chess/Common/Scene/SceneBuilder.swift +++ b/Chess/Common/Scene/SceneBuilder.swift @@ -1,8 +1,5 @@ // -// SceneBuilder.swift -// Chess -// -// Created by Mateusz Stompór on 06/09/2022. +// Copyright © 2022 Mateusz Stompór. All rights reserved. // import Metal diff --git a/Chess/macOS/App/AppDelegate.swift b/Chess/macOS/App/AppDelegate.swift index 32ce97f..42bc808 100644 --- a/Chess/macOS/App/AppDelegate.swift +++ b/Chess/macOS/App/AppDelegate.swift @@ -1,21 +1,64 @@ // -// AppDelegate.swift -// Chess -// -// Created by Mateusz Stompór on 14/08/2022. +// Copyright © 2022 Mateusz Stompór. All rights reserved. // import Cocoa +import Combine + +extension Notification.Name { + static let persistanceDeleteAll = Notification.Name("persistanceDeleteAll") + static let persistanceSave = Notification.Name("persistanceSave") + static let persistanceLoad = Notification.Name("persistanceLoad") +} @main class AppDelegate: NSObject, NSApplicationDelegate { + private var gameSavesSubscription: AnyCancellable? + @IBOutlet private weak var historyClear: NSMenuItem! + @IBOutlet private weak var separator: NSMenuItem! + + @IBOutlet private weak var savesMenu: NSMenu! + @IBAction private func saveGame(_ sender: NSMenuItem) { + NotificationCenter.default.post( + name: .persistanceSave, + object: nil + ) + } + @IBAction func deleteAll(_ sender: NSMenuItem) { + NotificationCenter.default.post( + name: .persistanceDeleteAll, + object: nil + ) + } @IBAction func createGame(_ sender: Any) { let storyboard = NSStoryboard(name: "Main", bundle: nil) let controller = storyboard.instantiateInitialController() (controller as! NSWindowController).showWindow(self) } + @objc + func loadGame(sender: NSMenuItem) { + NotificationCenter.default.post( + name: .persistanceLoad, + object: ["index": sender.tag] + ) + } + private func updateItems() { + let count = SaveCoordinator.shared.saveCount + let items = (0 ..< count).map { i in + let name = "Save #\(i)" + let item = NSMenuItem(title: name, + action: #selector(loadGame), + keyEquivalent: "") + item.tag = i + return item + } + savesMenu.items = items + [separator, historyClear] + } func applicationDidFinishLaunching(_ aNotification: Notification) { - // Insert code here to initialize your application + updateItems() + gameSavesSubscription = SaveCoordinator.shared.saveDidChange.sink { [weak self] in + self?.updateItems() + } } func applicationWillTerminate(_ aNotification: Notification) { // Insert code here to tear down your application diff --git a/Chess/macOS/Storyboards/Base.lproj/Main.storyboard b/Chess/macOS/Storyboards/Base.lproj/Main.storyboard index ba2c978..ff7ec93 100644 --- a/Chess/macOS/Storyboards/Base.lproj/Main.storyboard +++ b/Chess/macOS/Storyboards/Base.lproj/Main.storyboard @@ -58,17 +58,46 @@ - + - + - + - - + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + @@ -349,11 +378,17 @@ - + + + + + + + - + diff --git a/Chess/macOS/ViewControllers/GameViewController.swift b/Chess/macOS/ViewControllers/GameViewController.swift index 1ea93a9..3ce7187 100644 --- a/Chess/macOS/ViewControllers/GameViewController.swift +++ b/Chess/macOS/ViewControllers/GameViewController.swift @@ -1,28 +1,60 @@ // -// ViewController.swift -// Chess -// -// Created by Mateusz Stompór on 05/11/2020. +// Copyright © 2020 Mateusz Stompór. All rights reserved. // import Cocoa +import Combine import Engine import ModelIO import MetalKit import PNShared class GameViewController: NSViewController, GameDelegate { - @IBOutlet weak var info: NSTextField! + @IBOutlet private weak var info: NSTextField! private var engine: PNEngine! private var engineView: PNView! private var interactionHandler: MouseInteractionHandler! private let game = Game() + private var cancellables = Set() private let nodeInteractor = PNINodeInteractor() private let cameraController = CameraController() private var manipulator: SceneManipulator! private var state = GameState.initial + private func setupNotifications() { + NotificationCenter.default.publisher(for: .persistanceDeleteAll) + .sink { notification in + _ = SaveCoordinator.shared.wipe() + } + .store(in: &cancellables) + NotificationCenter.default.publisher(for: .persistanceSave) + .sink { [weak self] notification in + guard let self else { return } + let saveState = SaveGame(creationDate: .now, state: state) + _ = SaveCoordinator.shared.save(game: saveState) + } + .store(in: &cancellables) + NotificationCenter.default.publisher(for: .persistanceLoad) + .sink { [weak self] notification in + guard let self else { return } + guard let dictionary = notification.object as? [String: Int], + let index = dictionary["index"] else { + fatalError("Incorrect type passed") + } + guard let gameSave = SaveCoordinator.shared.load(index: index) else { + fatalError("Index out of bounds") + } + state = gameSave.state + guard let device = engineView.device else { + fatalError("Device not set") + } + let builder = SceneBuilder(device: device) + engine.scene = builder.build(board: gameSave.state.board) + } + .store(in: &cancellables) + } override func viewDidLoad() { super.viewDidLoad() + setupNotifications() info.alphaValue = 0 engineView = view.subviews[0] as? PNView engine = engineView.engine