From 9ef00e4859524d80717d8bfb73215ae7d238dc0b Mon Sep 17 00:00:00 2001 From: Mathieu GROUSSEAU Date: Fri, 20 Jun 2025 19:50:57 +0200 Subject: [PATCH] Nearly fully working game --- App/App.xcodeproj/project.pbxproj | 8 ++ App/App/Localizable.xcstrings | 32 ++++++ App/App/SpriteKit/GameScene.swift | 161 +++++++++++++++++++++++++----- App/App/SpriteKit/PieceNode.swift | 88 ++++++++-------- App/App/Utils/RulesType.swift | 6 +- App/App/View/IngameView.swift | 88 +++++++++++----- App/App/View/UIUtilities.swift | 31 ++++++ App/App/ViewModel/InGameVM.swift | 96 +++++++++++++++--- App/App/ViewModel/PlayerVM.swift | 20 ++++ 9 files changed, 420 insertions(+), 110 deletions(-) create mode 100644 App/App/View/UIUtilities.swift create mode 100644 App/App/ViewModel/PlayerVM.swift diff --git a/App/App.xcodeproj/project.pbxproj b/App/App.xcodeproj/project.pbxproj index 0920904..783775a 100644 --- a/App/App.xcodeproj/project.pbxproj +++ b/App/App.xcodeproj/project.pbxproj @@ -21,6 +21,8 @@ F0143C342DF987490086CAAA /* PlayerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0143C332DF987490086CAAA /* PlayerType.swift */; }; F0143C362DFA9A000086CAAA /* RulesType.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0143C352DFA9A000086CAAA /* RulesType.swift */; }; F05DA2112E002AA00094A4A8 /* BoardNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05DA2102E002AA00094A4A8 /* BoardNode.swift */; }; + F05DA2132E01BA270094A4A8 /* PlayerVM.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05DA2122E01BA270094A4A8 /* PlayerVM.swift */; }; + F05DA2152E02D1850094A4A8 /* UIUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05DA2142E02D1850094A4A8 /* UIUtilities.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 */; }; @@ -70,6 +72,8 @@ F0143C332DF987490086CAAA /* PlayerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerType.swift; sourceTree = ""; }; F0143C352DFA9A000086CAAA /* RulesType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulesType.swift; sourceTree = ""; }; F05DA2102E002AA00094A4A8 /* BoardNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoardNode.swift; sourceTree = ""; }; + F05DA2122E01BA270094A4A8 /* PlayerVM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerVM.swift; sourceTree = ""; }; + F05DA2142E02D1850094A4A8 /* UIUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIUtilities.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 = ""; }; @@ -191,6 +195,7 @@ children = ( F0143C2C2DF01E120086CAAA /* NewGameVM.swift */, F0143C2E2DF96B690086CAAA /* InGameVM.swift */, + F05DA2122E01BA270094A4A8 /* PlayerVM.swift */, ); path = ViewModel; sourceTree = ""; @@ -223,6 +228,7 @@ F0F59E562DE6D6E600BE32D6 /* SavedGamesView.swift */, F0F59E582DE6EB2A00BE32D6 /* PlayerVSPage.swift */, F0F59E5A2DE6F68800BE32D6 /* ScoreboardView.swift */, + F05DA2142E02D1850094A4A8 /* UIUtilities.swift */, ); path = View; sourceTree = ""; @@ -362,6 +368,7 @@ files = ( F05DA2112E002AA00094A4A8 /* BoardNode.swift in Sources */, F0143C312DF9813C0086CAAA /* PieceNode.swift in Sources */, + F05DA2152E02D1850094A4A8 /* UIUtilities.swift in Sources */, F0F59E592DE6EB2A00BE32D6 /* PlayerVSPage.swift in Sources */, F001A04E2DD48FAB00809561 /* MainMenuView.swift in Sources */, F001A04C2DD48FAB00809561 /* AppApp.swift in Sources */, @@ -369,6 +376,7 @@ F0143C362DFA9A000086CAAA /* RulesType.swift in Sources */, F0F59E532DDDC35100BE32D6 /* NewGameView.swift in Sources */, F0143C2F2DF96B690086CAAA /* InGameVM.swift in Sources */, + F05DA2132E01BA270094A4A8 /* PlayerVM.swift in Sources */, F0F59E5B2DE6F68800BE32D6 /* ScoreboardView.swift in Sources */, F0143C2B2DF018F20086CAAA /* GameScene.swift in Sources */, F0F59E572DE6D6E600BE32D6 /* SavedGamesView.swift in Sources */, diff --git a/App/App/Localizable.xcstrings b/App/App/Localizable.xcstrings index 0421885..4171e5d 100644 --- a/App/App/Localizable.xcstrings +++ b/App/App/Localizable.xcstrings @@ -237,6 +237,38 @@ } } }, + "generic.rules.popout.name" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pop Out" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pop Out" + } + } + } + }, + "generic.rules.tictactoe.name" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tic Tac Toe" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Morpion" + } + } + } + }, "inGame.currentRules %@" : { "localizations" : { "en" : { diff --git a/App/App/SpriteKit/GameScene.swift b/App/App/SpriteKit/GameScene.swift index a1aacab..4844156 100644 --- a/App/App/SpriteKit/GameScene.swift +++ b/App/App/SpriteKit/GameScene.swift @@ -1,54 +1,163 @@ import SpriteKit +import Combine +import Connect4Core public class GameScene: SKScene { public static let boardTopMargin: CGFloat = PieceNode.pieceSize / 2 + private let vm: IngameVM + private var boardNode: BoardNode private var cells: [PieceNode] private var draggablePiece: PieceNode - public init(s: (w: Int, h: Int)) { - let boardHeight = CGFloat(s.h) * PieceNode.pieceSize - let gWidth = CGFloat(s.w) * PieceNode.pieceSize + private var cancellables: [AnyCancellable] = [] + + init(viewModel vm: IngameVM) { + self.vm = vm + + let boardHeight = CGFloat(vm.height) * PieceNode.pieceSize + let gWidth = CGFloat(vm.width) * PieceNode.pieceSize let gHeight = boardHeight + Self.boardTopMargin + PieceNode.pieceSize - boardNode = BoardNode(cells: s, size: CGSize(width: gWidth, height: boardHeight)) + boardNode = BoardNode(cells: (vm.width, vm.height), size: CGSize(width: gWidth, height: boardHeight)) draggablePiece = PieceNode() draggablePiece.piece = .init(withOwner: .noOne) - draggablePiece.position = CGPoint(x: 0, y: boardHeight + Self.boardTopMargin + PieceNode.pieceSize / 2) - - cells = (0..<(s.w * s.h)).map({ index in - let piece = PieceNode() - piece.boardPosition = (x: index % s.w, y: index / s.w) - piece.piece = switch(index % 3) { - case 1: .init(withOwner: .player1) - case 2: .init(withOwner: .player2) - default: .init(withOwner: .noOne) - } - return piece - }) + + cells = [] super.init(size: CGSize(width: gWidth, height: gHeight)) - draggablePiece.onDragHandler = self.resetDragPiece - + // anchorPoint = CGPoint(x: 0.5, y: 0.5) backgroundColor = .clear - + addChild(boardNode) - addChild(draggablePiece) - for cell in cells { - addChild(cell) - } - self.resetDragPiece(piece: draggablePiece) + draggablePiece.position = draggablePiecePlacement + self.cancellables.append(vm.$currentPlayer.sink { + self.draggablePiece.piece = .init(withOwner: $0) + }) + self.cancellables.append(vm.$currentBoard.sink(receiveValue: self.updateBoard)) + + vm.game.addGameStartedListener { _ in + DispatchQueue.main.async { + self.addChild(self.draggablePiece) + } + } + vm.game.addMoveChosenCallbacksListener { board, move, player in + DispatchQueue.main.async { + self.onMove(board: board, move: move, player: player) + } + } + vm.game.addInvalidMoveCallbacksListener { board, move, player, result in + DispatchQueue.main.async { + self.onInvalidMove(board: board, move: move, player: player, result: result) + } + } + vm.game.addPlayerNotifiedListener { _, player in + DispatchQueue.main.async { + self.onPlayerTurn(player: player) + } + } + vm.game.addGameOverListener { board, result, player in + DispatchQueue.main.async { + self.onGameOver(board: board, result: result, player: player) + } + } } public required init?(coder aDecoder: NSCoder) { fatalError("Unreachable") } - private func resetDragPiece(piece: PieceNode) { - piece.position = CGPoint(x: self.size.width / 2, y: self.size.height - PieceNode.pieceSize / 2) + private var draggablePiecePlacement: CGPoint { CGPoint( + x: self.size.width / 2, + y: self.size.height - PieceNode.pieceSize / 2 + ) } + + private func onPlayerTurn(player: Player) { + print("player \(player)") + self.draggablePiece.onDragHandler = if player is HumanPlayer { + self.onPieceDragEnd + } else { + nil + } + } + + private func onPieceDragEnd(_: PieceNode) { + draggablePiece.onDragHandler = nil + + let at = draggablePiece.clampedPosition() + if (0.. (row: Int, column: Int) { + ( + row: Int((position.y) / Self.pieceSize), + column: Int((position.x) / Self.pieceSize) + ) + } + private func dragTo(position pos: CGPoint) { self.position = pos } @@ -76,45 +83,44 @@ class PieceNode : SKNode { } #if os(macOS) - override public func touchesBegan(with event: NSEvent) { - let holder: NSView? = self.parent?.parent as? NSView - - guard - self.draggable, - let touch = event.touches(matching: .began, in: holder).first - else { return } - - let pos = touch.location(in: holder) - self.position = CGPoint(x: pos.y, y: pos.y) - } - - override public func touchesMoved(with event: NSEvent) { - let holder: NSView? = self.parent?.parent as? NSView - - guard - self.draggable, - let touch = event.touches(matching: .moved, in: holder).first - else { return } - - let pos = touch.location(in: holder) - self.position = CGPoint(x: pos.y, y: pos.y) - } - - override public func touchesEnded(with event: NSEvent) { - let holder: NSView? = self.parent?.parent as? NSView - - guard - self.draggable, - let touch = event.touches(matching: .ended, in: holder).first - else { return } - - let pos = touch.location(in: holder) - self.position = CGPoint(x: pos.y, y: pos.y) - } + // override public func touchesBegan(with event: NSEvent) { + // let holder: NSView? = self.parent?.parent as? NSView + // + // guard + // self.draggable, + // let touch = event.touches(matching: .began, in: holder).first + // else { return } + // + // let pos = touch.location(in: holder) + // self.position = CGPoint(x: pos.y, y: pos.y) + // } + // + // override public func touchesMoved(with event: NSEvent) { + // let holder: NSView? = self.parent?.parent as? NSView + // + // guard + // self.draggable, + // let touch = event.touches(matching: .moved, in: holder).first + // else { return } + // + // let pos = touch.location(in: holder) + // self.position = CGPoint(x: pos.y, y: pos.y) + // } + // + // override public func touchesEnded(with event: NSEvent) { + // let holder: NSView? = self.parent?.parent as? NSView + // + // guard + // self.draggable, + // let touch = event.touches(matching: .ended, in: holder).first + // else { return } + // + // let pos = touch.location(in: holder) + // self.position = CGPoint(x: pos.y, y: pos.y) + // } #elseif os(iOS) override public func touchesBegan(_ touches: Set, with event: UIEvent?) { guard - self.draggable, let touch = touches.first, let holder = self.parent else { return } @@ -125,7 +131,6 @@ class PieceNode : SKNode { override public func touchesMoved(_ touches: Set, with event: UIEvent?) { guard - self.draggable, let touch = touches.first, let holder = self.parent else { return } @@ -136,7 +141,6 @@ class PieceNode : SKNode { override public func touchesEnded(_ touches: Set, with event: UIEvent?) { guard - self.draggable, let touch = touches.first, let holder = self.parent else { return } diff --git a/App/App/Utils/RulesType.swift b/App/App/Utils/RulesType.swift index 8f283e1..8bddd78 100644 --- a/App/App/Utils/RulesType.swift +++ b/App/App/Utils/RulesType.swift @@ -1,3 +1,5 @@ -enum RulesType { - case Classic +enum RulesType: CaseIterable, Identifiable { + var id: Self { self } + + case Classic, TicTacToe, PopOut } diff --git a/App/App/View/IngameView.swift b/App/App/View/IngameView.swift index 7932766..8a3205c 100644 --- a/App/App/View/IngameView.swift +++ b/App/App/View/IngameView.swift @@ -7,16 +7,27 @@ import SwiftUI import SpriteKit +import Connect4Core struct IngameView: View { + @StateObject + private var vm: IngameVM private let scene: GameScene var body: some View { VStack { HStack { - PlayerView(is_local: true) + PlayerView( + who: vm.player1, + dock_left: true, + isTurn: self.$vm.currentPlayer.map { $0 == .player1 } + ) Spacer() - PlayerView(is_local: false) + PlayerView( + who: vm.player2, + dock_left: false, + isTurn: self.$vm.currentPlayer.map { $0 == .player2 } + ) } Spacer() @@ -27,7 +38,7 @@ struct IngameView: View { self.scene.size.width / self.scene.size.height, contentMode: .fit ) - }.safeAreaPadding() + }.safeAreaPadding(.horizontal) Spacer() @@ -35,23 +46,32 @@ struct IngameView: View { // // } - let current = "???" - Text("inGame.currentRules \(current)") + Text("inGame.currentRules \(vm.rulesName)") } } init(settings: NewGameVM, player1: PlayerSettingsVM, player2: PlayerSettingsVM) { - self.scene = GameScene(s: (Int(settings.width), Int(settings.height))) + guard let vm = IngameVM(settings: settings, player1: player1, player2: player2) + else { fatalError("TODO: how to handle game setup failure") } + + self._vm = StateObject(wrappedValue: vm) + self.scene = GameScene(viewModel: vm) + + vm.start() // TODO actual game initialization } } private struct PlayerView: View { - let isLocal: Bool + let dockLeft: Bool + let who: PlayerVM + @Binding + var isTurn: Bool + var body: some View { - let textAlignment: HorizontalAlignment = if self.isLocal { + let textAlignment: HorizontalAlignment = if self.dockLeft { .leading } else { .trailing @@ -59,31 +79,51 @@ private struct PlayerView: View { VStack { HStack { - if self.isLocal { - Circle().frame(width: 50, height: 50) + let img = Circle().frame(width: 50, height: 50) + + if self.dockLeft { + img } + VStack(alignment: textAlignment) { - let name = "Name ???" - Text(name) + Text(who.name) - let type = "Type ???" - Text(type) + Text(LocalizedStringKey(who.type.baseTranslationKey)) } - if !self.isLocal { - Circle().frame(width: 50, height: 50) + + if !self.dockLeft { + img } } - let time = "??:??:??" - Text(time) - - let statusText = "Status Text: Your turn" - Text(statusText) - } + HStack { + let wheel = ProgressView() + .progressViewStyle(.circular) + .tint(.accentColor) + .transition(.scale) + .if(!self.isTurn, { $0.hidden() }) + + if !self.dockLeft { + wheel + } + + // TODO + // let time = "??:??:??" + // Text(time).padding(.horizontal, 10) + + if self.dockLeft { + wheel + } + } + }.padding(.all, 5) + // .background(color, ignoresSafeAreaEdges: Edge.Set()) + // .containerShape(RoundedRectangle(cornerRadius: 5)) } - init(is_local isLocal: Bool) { - self.isLocal = isLocal + init(who owner: PlayerVM, dock_left dockLeft: Bool, isTurn: Binding) { + self.who = owner + self.dockLeft = dockLeft + self._isTurn = isTurn } } diff --git a/App/App/View/UIUtilities.swift b/App/App/View/UIUtilities.swift new file mode 100644 index 0000000..3b3f7aa --- /dev/null +++ b/App/App/View/UIUtilities.swift @@ -0,0 +1,31 @@ +import Foundation +import SwiftUI + +extension View { + @ViewBuilder func `if`(_ condition: Bool, _ modifier: (Self) -> Content) -> some View { + if condition { + modifier(self) + } else { + self + } + } +} + +extension Binding { + func map(_ get: @escaping (Value) -> T) -> Binding { + Binding( + get: { get(self.wrappedValue) }, + set: { _ in fatalError("Not implemented") } + ) + } + + func map( + get: @escaping (Value) -> T, + set: @escaping (T) -> Value + ) -> Binding { + Binding( + get: { get(self.wrappedValue) }, + set: { self.wrappedValue = set($0) } + ) + } +} diff --git a/App/App/ViewModel/InGameVM.swift b/App/App/ViewModel/InGameVM.swift index 5cc5d7c..a88fca3 100644 --- a/App/App/ViewModel/InGameVM.swift +++ b/App/App/ViewModel/InGameVM.swift @@ -1,39 +1,103 @@ import Foundation import Connect4Core import Connect4Players +import Connect4Rules -class InGameVM: ObservableObject { - private var game: Game +class IngameVM: ObservableObject { + let game: Game + // @Published + var rulesName: String { game.rules.name } + + var columns: Int { game.rules.nbColumns } + /// alias for columns + var width: Int { self.columns } + var rows: Int { game.rules.nbRows } + /// alias for rows + var height: Int { self.rows } + + var player1: PlayerVM + var player2: PlayerVM + + @Published + var currentPlayer: Owner = .noOne + + @Published + var currentBoard: Board + + private var running: Bool = false + init?(settings: NewGameVM, player1: PlayerSettingsVM, player2: PlayerSettingsVM) { - guard let rules = switch (settings.rulesType) { - case .Classic: - Connect4Rules( - nbRows: Int(settings.width), nbColumns: Int(settings.height), - nbPiecesToAlign: Int(settings.alignedTokens) - ) - } else { return nil } + let rulesInit: (Int, Int, Int) -> (any Rules)? = switch (settings.rulesType) { + case .Classic: Connect4Rules.init + case .TicTacToe: TicTacToeRules.init + case .PopOut: PopOutRules.init + } + guard let rules = rulesInit( + Int(settings.height), + Int(settings.width), + Int(settings.alignedTokens) + ) else { return nil } guard let player1 = Self.playerOf(settings: player1, id: .player1) else { return nil } guard let player2 = Self.playerOf(settings: player2, id: .player2) else { return nil } self.game = try! Game(withRules: rules, andPlayer1: player1, andPlayer2: player2) - for player in [player1, player2] { - (player as? HumanPlayer)?.changeInput(input: self.onMove) + self.player1 = PlayerVM(inner: player1) + self.player2 = PlayerVM(inner: player2) + + self.currentBoard = self.game.board + + game.addGameOverListener { board, result, player in + print("game over") + DispatchQueue.main.async { + self.currentPlayer = .noOne + } + // TODO + } + game.addGameChangedListener { game, result in + print("game changed") + // TODO + } + game.addGameStartedListener { board in + print("game started") + // TODO + } + game.addBoardChangedListener { board, lastCell in + DispatchQueue.main.async { + self.currentBoard = board + } + } + game.addPlayerNotifiedListener { board, player in + DispatchQueue.main.async { + self.currentPlayer = player.id + } } } - private func onMove(player: HumanPlayer) -> Move? { - fatalError("TODO") + public func start() { + if running { return } + running = true + Task.detached(priority: .userInitiated) { + try await self.game.start() + } + } + + public func pieceDropped(at: (row: Int, column: Int)) { + Task.detached(priority: .userInitiated) { + try await self.game.onPlayed(move: Move( + of: self.currentPlayer, + toRow: at.row, + toColumn: at.column + )) + } } private static func playerOf(settings: PlayerSettingsVM, id: Owner) -> Player? { return switch (settings.type) { case .Human: - HumanPlayer(withName: settings.name, andId: id, andInputMethod: { - _ in fatalError("unreachable: \"uninitialized\" closure should be temporary") - }) + HumanPlayer(withName: settings.name, andId: id) case .AIRandom: RandomPlayer(withName: settings.name, andId: id) case .AIFinnishHim: diff --git a/App/App/ViewModel/PlayerVM.swift b/App/App/ViewModel/PlayerVM.swift new file mode 100644 index 0000000..bdbf70e --- /dev/null +++ b/App/App/ViewModel/PlayerVM.swift @@ -0,0 +1,20 @@ +import Foundation +import Connect4Core +import Connect4Players + +class PlayerVM: ObservableObject { + private let inner: Player + + var name: String { inner.name } + var type: PlayerType + + init(inner: Player) { + self.inner = inner + self.type = switch(inner) { + case is RandomPlayer: .AIRandom + case is FinnishHimPlayer: .AIFinnishHim + case is SimpleNegaMaxPlayer: .AISimpleNegaMax + default: .Human + } + } +}