From 53f9506de8f6eea851b3e676c50fb14291594480 Mon Sep 17 00:00:00 2001 From: Mathieu GROUSSEAU Date: Mon, 16 Jun 2025 12:30:58 +0200 Subject: [PATCH] Draggable piece --- App/App.xcodeproj/project.pbxproj | 6 ++ App/App/SpriteKit/GameScene.swift | 16 ++++- App/App/SpriteKit/PieceNode.swift | 110 ++++++++++++++++++++++++++++-- App/App/Utils/RulesType.swift | 3 + App/App/View/IngameView.swift | 16 ++--- App/App/ViewModel/InGameVM.swift | 40 +++++++++++ App/App/ViewModel/NewGameVM.swift | 6 +- 7 files changed, 177 insertions(+), 20 deletions(-) create mode 100644 App/App/Utils/RulesType.swift diff --git a/App/App.xcodeproj/project.pbxproj b/App/App.xcodeproj/project.pbxproj index 88cdf7a..5975b6d 100644 --- a/App/App.xcodeproj/project.pbxproj +++ b/App/App.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ F0143C2F2DF96B690086CAAA /* InGameVM.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0143C2E2DF96B690086CAAA /* InGameVM.swift */; }; F0143C312DF9813C0086CAAA /* PieceNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0143C302DF9813C0086CAAA /* PieceNode.swift */; }; F0143C342DF987490086CAAA /* PlayerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0143C332DF987490086CAAA /* PlayerType.swift */; }; + F0143C362DFA9A000086CAAA /* RulesType.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0143C352DFA9A000086CAAA /* RulesType.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 */; }; @@ -66,6 +67,7 @@ F0143C2E2DF96B690086CAAA /* InGameVM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InGameVM.swift; sourceTree = ""; }; F0143C302DF9813C0086CAAA /* PieceNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PieceNode.swift; sourceTree = ""; }; F0143C332DF987490086CAAA /* PlayerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerType.swift; sourceTree = ""; }; + F0143C352DFA9A000086CAAA /* RulesType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulesType.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 = ""; }; @@ -204,6 +206,7 @@ isa = PBXGroup; children = ( F0143C332DF987490086CAAA /* PlayerType.swift */, + F0143C352DFA9A000086CAAA /* RulesType.swift */, ); path = Utils; sourceTree = ""; @@ -359,6 +362,7 @@ F001A04E2DD48FAB00809561 /* MainMenuView.swift in Sources */, F001A04C2DD48FAB00809561 /* AppApp.swift in Sources */, F0143C2D2DF01E120086CAAA /* NewGameVM.swift in Sources */, + F0143C362DFA9A000086CAAA /* RulesType.swift in Sources */, F0F59E532DDDC35100BE32D6 /* NewGameView.swift in Sources */, F0143C2F2DF96B690086CAAA /* InGameVM.swift in Sources */, F0F59E5B2DE6F68800BE32D6 /* ScoreboardView.swift in Sources */, @@ -527,6 +531,7 @@ DEVELOPMENT_ASSET_PATHS = "\"App/Preview Content\""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.board-games"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -563,6 +568,7 @@ DEVELOPMENT_ASSET_PATHS = "\"App/Preview Content\""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.board-games"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; diff --git a/App/App/SpriteKit/GameScene.swift b/App/App/SpriteKit/GameScene.swift index a4ea54c..eba16e4 100644 --- a/App/App/SpriteKit/GameScene.swift +++ b/App/App/SpriteKit/GameScene.swift @@ -1,21 +1,29 @@ import SpriteKit public class GameScene: SKScene { + public static let boardTopMargin: CGFloat = PieceNode.pieceSize / 2 + private var boardNode: SKShapeNode 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 - let gHeight = boardHeight + PieceNode.pieceSize + let gHeight = boardHeight + Self.boardTopMargin + PieceNode.pieceSize boardNode = SKShapeNode(rect: CGRect(x: 0, y: 0, width: gWidth, height: boardHeight)) boardNode.fillColor = .blue boardNode.strokeColor = .black + draggablePiece = PieceNode() + draggablePiece.piece = .init(withOwner: .noOne) + draggablePiece.draggable = true + 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.intPosition = (x: index % s.w, y: index / s.w) + 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) @@ -27,11 +35,15 @@ public class GameScene: SKScene { super.init(size: CGSize(width: gWidth, height: gHeight)) // anchorPoint = CGPoint(x: 0.5, y: 0.5) + backgroundColor = .clear addChild(boardNode) + addChild(draggablePiece) for cell in cells { addChild(cell) } + + draggablePiece.resetDragPosition() } public required init?(coder aDecoder: NSCoder) { diff --git a/App/App/SpriteKit/PieceNode.swift b/App/App/SpriteKit/PieceNode.swift index 9f0afde..20b80df 100644 --- a/App/App/SpriteKit/PieceNode.swift +++ b/App/App/SpriteKit/PieceNode.swift @@ -7,6 +7,11 @@ class PieceNode : SKNode { private var circle: SKShapeNode + override var isUserInteractionEnabled: Bool { + get { true } + set { /* fatalError("Unsupported operation: set") */ } + } + var piece: Piece = .init(withOwner: .noOne) { didSet { switch (piece.owner) { @@ -23,24 +28,119 @@ class PieceNode : SKNode { } } } - var intPosition: (x: Int, y: Int) = (0, 0) { + var boardPosition: (x: Int, y: Int)? = nil { didSet { - position = CGPoint( - x: CGFloat(intPosition.x) * Self.pieceSize + Self.pieceSize / 2, - y: CGFloat(intPosition.y) * Self.pieceSize + Self.pieceSize / 2 - ) + if let pos = boardPosition { + position = CGPoint( + x: CGFloat(pos.x) * Self.pieceSize + Self.pieceSize / 2, + y: CGFloat(pos.y) * Self.pieceSize + Self.pieceSize / 2 + ) + zPosition = 10 + } else { + zPosition = 100 + } } } + var draggable: Bool = false + + private var gameScene: GameScene { self.scene as! GameScene } + override init() { circle = SKShapeNode(circleOfRadius: Self.pieceSize / 2 * 0.90) super.init() addChild(circle) + zPosition = 100 } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + public func resetDragPosition() { + let sc = self.scene!.size + self.position = CGPoint(x: sc.width / 2, y: sc.height - Self.pieceSize / 2) + } + + private func dragTo(position pos: CGPoint) { + self.position = pos + } + + private func releaseAt(position pos: CGPoint) { + resetDragPosition() + // TODO + } + +#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) + } +#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 } + + let pos = touch.location(in: holder) + self.dragTo(position: CGPoint(x: pos.x, y: pos.y)) + } + + override public func touchesMoved(_ touches: Set, with event: UIEvent?) { + guard + self.draggable, + let touch = touches.first, + let holder = self.parent + else { return } + + let pos = touch.location(in: holder) + self.dragTo(position: CGPoint(x: pos.x, y: pos.y)) + } + + override public func touchesEnded(_ touches: Set, with event: UIEvent?) { + guard + self.draggable, + let touch = touches.first, + let holder = self.parent + else { return } + + let pos = touch.location(in: holder) + self.releaseAt(position: CGPoint(x: pos.x, y: pos.y)) + } +#endif } diff --git a/App/App/Utils/RulesType.swift b/App/App/Utils/RulesType.swift new file mode 100644 index 0000000..8f283e1 --- /dev/null +++ b/App/App/Utils/RulesType.swift @@ -0,0 +1,3 @@ +enum RulesType { + case Classic +} diff --git a/App/App/View/IngameView.swift b/App/App/View/IngameView.swift index 164e22c..7932766 100644 --- a/App/App/View/IngameView.swift +++ b/App/App/View/IngameView.swift @@ -22,12 +22,12 @@ struct IngameView: View { Spacer() VStack(alignment: .center) { - SpriteView(scene: self.scene) + SpriteView(scene: self.scene, options: .allowsTransparency) .aspectRatio( self.scene.size.width / self.scene.size.height, contentMode: .fit ) - } + }.safeAreaPadding() Spacer() @@ -51,14 +51,14 @@ private struct PlayerView: View { let isLocal: Bool var body: some View { + let textAlignment: HorizontalAlignment = if self.isLocal { + .leading + } else { + .trailing + } + VStack { HStack { - let textAlignment: HorizontalAlignment = if self.isLocal { - .leading - } else { - .trailing - } - if self.isLocal { Circle().frame(width: 50, height: 50) } diff --git a/App/App/ViewModel/InGameVM.swift b/App/App/ViewModel/InGameVM.swift index fb38a2a..5cc5d7c 100644 --- a/App/App/ViewModel/InGameVM.swift +++ b/App/App/ViewModel/InGameVM.swift @@ -1,5 +1,45 @@ import Foundation +import Connect4Core +import Connect4Players class InGameVM: ObservableObject { + private var game: Game + 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 } + + 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) + } + } + + private func onMove(player: HumanPlayer) -> Move? { + fatalError("TODO") + } + + 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") + }) + case .AIRandom: + RandomPlayer(withName: settings.name, andId: id) + case .AIFinnishHim: + FinnishHimPlayer(withName: settings.name, andId: id) + case .AISimpleNegaMax: + SimpleNegaMaxPlayer(withName: settings.name, andId: id) + } + } } diff --git a/App/App/ViewModel/NewGameVM.swift b/App/App/ViewModel/NewGameVM.swift index 6c70c5c..790b4c8 100644 --- a/App/App/ViewModel/NewGameVM.swift +++ b/App/App/ViewModel/NewGameVM.swift @@ -3,7 +3,7 @@ import Foundation class NewGameVM : ObservableObject { @Published var rulesType: RulesType = .Classic @Published var width: UInt = 7 { didSet { if width < 1 { width = 1 } } } - @Published var height: UInt = 7 { didSet { if height < 1 { height = 1 } } } + @Published var height: UInt = 6 { didSet { if height < 1 { height = 1 } } } @Published var alignedTokens: UInt = 4 { didSet { if alignedTokens < 2 { alignedTokens = 2 } } } } @@ -16,7 +16,3 @@ class PlayerSettingsVM : ObservableObject, Identifiable { self.type = type } } - -enum RulesType { - case Classic -}