diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f6737bfc4f..c96bcebfd3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,14 +36,14 @@ repos: exclude: '(\.vscode/settings\.json|\.jtd\.json$|.*\.xcassets/.*|.*\.colorset/.*|\.animation\.lottie\.json$)' - repo: https://github.com/realm/SwiftLint - rev: 0.57.0 + rev: 0.57.1 hooks: - id: swiftlint entry: swiftlint args: ["--use-alternative-excluding"] - repo: https://github.com/nicklockwood/SwiftFormat - rev: 0.54.5 + rev: 0.55.3 hooks: - id: swiftformat diff --git a/Modules/GameEngineKit/Examples/GameEngineKitExample/Sources/App/ContentView.swift b/Modules/GameEngineKit/Examples/GameEngineKitExample/Sources/App/ContentView.swift index a79eb34c55..85e53e5fc5 100644 --- a/Modules/GameEngineKit/Examples/GameEngineKitExample/Sources/App/ContentView.swift +++ b/Modules/GameEngineKit/Examples/GameEngineKitExample/Sources/App/ContentView.swift @@ -180,6 +180,19 @@ struct ContentView: View { .buttonStyle(.borderedProminent) .frame(maxWidth: .infinity) + NavigationLink("Drag & Drop One To One In Right Order", destination: { + let gameplay = NewGameplayFindTheRightOrder(choices: NewGameplayFindTheRightOrder.kDefaultImageChoicesWithZones) + let coordinator = DnDOneToOneCoordinatorFindTheRightOrder(gameplay: gameplay) + let viewModel = DnDOneToOneViewModel(coordinator: coordinator) + + return DnDOneToOneView(viewModel: viewModel) + .navigationTitle("Drag & Drop One To One In Right Order") + .navigationBarTitleDisplayMode(.large) + }) + .tint(.red) + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + Spacer() } } diff --git a/Modules/GameEngineKit/Sources/OldSystem/Utils/SpriteKitExtensions/SKSpriteNodeExtension.swift b/Modules/GameEngineKit/Sources/OldSystem/Utils/SpriteKitExtensions/SKSpriteNodeExtension.swift index accfd8b08c..ca98eef7de 100644 --- a/Modules/GameEngineKit/Sources/OldSystem/Utils/SpriteKitExtensions/SKSpriteNodeExtension.swift +++ b/Modules/GameEngineKit/Sources/OldSystem/Utils/SpriteKitExtensions/SKSpriteNodeExtension.swift @@ -25,7 +25,20 @@ extension SKSpriteNode { let newY = max(dropZoneFrame.minY + size.height / 2, min(position.y, dropZoneFrame.maxY - size.height / 2)) - position = CGPoint(x: newX, y: newY) + let targetPosition = CGPoint(x: newX, y: newY) + + let moveToCenter = SKAction.move(to: targetPosition, duration: 0.2) + moveToCenter.timingMode = .easeInEaseOut + + run( + moveToCenter, + completion: { + self.position = targetPosition + self.zRotation = 0 + self.zPosition = 10 + self.removeAllActions() + } + ) } func fullyContains(bounds: CGRect) -> Bool { diff --git a/Modules/GameEngineKit/Sources/_NewSystem/Gameplays/NewGameplayFindTheRightOrder.swift b/Modules/GameEngineKit/Sources/_NewSystem/Gameplays/NewGameplayFindTheRightOrder.swift index b0fb0bd53d..3f34c851e6 100644 --- a/Modules/GameEngineKit/Sources/_NewSystem/Gameplays/NewGameplayFindTheRightOrder.swift +++ b/Modules/GameEngineKit/Sources/_NewSystem/Gameplays/NewGameplayFindTheRightOrder.swift @@ -10,10 +10,11 @@ import Foundation public struct NewGameplayFindTheRightOrderChoice: Identifiable, Equatable { // MARK: Lifecycle - public init(id: String = UUID().uuidString, value: String, type: ChoiceType = .text) { + public init(id: String = UUID().uuidString, value: String, type: ChoiceType = .text, alreadyOrdered: Bool = false) { self.id = id self.value = value self.type = type + self.alreadyOrdered = alreadyOrdered } // MARK: Public @@ -26,8 +27,11 @@ public struct NewGameplayFindTheRightOrderChoice: Identifiable, Equatable { // MARK: Internal + static let zero = NewGameplayFindTheRightOrderChoice(value: "") + let value: String let type: ChoiceType + let alreadyOrdered: Bool } // MARK: - NewGameplayFindTheRightOrder @@ -56,7 +60,7 @@ public class NewGameplayFindTheRightOrder: GameplayProtocol { } return self.orderedChoices.enumerated().map { index, choice in - if self.orderedChoices[index] == choice { + if choices[index] == choice { (choice: choice, correctPosition: true) } else { (choice: choice, correctPosition: false) @@ -74,4 +78,12 @@ public extension NewGameplayFindTheRightOrder { NewGameplayFindTheRightOrderChoice(value: "5th choice"), NewGameplayFindTheRightOrderChoice(value: "6th choice"), ] + + static let kDefaultImageChoicesWithZones: [NewGameplayFindTheRightOrderChoice] = [ + NewGameplayFindTheRightOrderChoice(value: "sequencing_dressing_up_1", type: .image, alreadyOrdered: true), + NewGameplayFindTheRightOrderChoice(value: "sequencing_dressing_up_2", type: .image, alreadyOrdered: false), + NewGameplayFindTheRightOrderChoice(value: "sequencing_dressing_up_3", type: .image, alreadyOrdered: false), + NewGameplayFindTheRightOrderChoice(value: "sequencing_dressing_up_4", type: .image, alreadyOrdered: true), + NewGameplayFindTheRightOrderChoice(value: "sequencing_dressing_up_5", type: .image, alreadyOrdered: false), + ] } diff --git a/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/Grid/Coordinators/DnDGridCoordinator+AssociateCategories.swift b/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/Grid/Coordinators/DnDGridCoordinator+AssociateCategories.swift index c899e9a644..fdbbe9cd3f 100644 --- a/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/Grid/Coordinators/DnDGridCoordinator+AssociateCategories.swift +++ b/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/Grid/Coordinators/DnDGridCoordinator+AssociateCategories.swift @@ -16,7 +16,7 @@ public class DnDGridCoordinatorAssociateCategories: DnDGridGameplayCoordinatorPr self.gameplay = gameplay self.uiChoices.value.choices = gameplay.choices.map { choice in - DnDAnswerNode(id: choice.id, value: choice.value, type: choice.type, size: self.uiChoices.value.choiceSize) + DnDAnswerNode(id: choice.id, value: choice.value, type: choice.type, size: self.uiChoices.value.choiceSize(for: gameplay.choices.count)) } } diff --git a/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/GridWithZones/Coordinators/DnDGridWithZonesCoordinator+AssociateCategories.swift b/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/GridWithZones/Coordinators/DnDGridWithZonesCoordinator+AssociateCategories.swift index 70de2fa2e4..c516ce13b1 100644 --- a/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/GridWithZones/Coordinators/DnDGridWithZonesCoordinator+AssociateCategories.swift +++ b/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/GridWithZones/Coordinators/DnDGridWithZonesCoordinator+AssociateCategories.swift @@ -23,7 +23,7 @@ public class DnDGridWithZonesCoordinatorAssociateCategories: DnDGridWithZonesGam } self.uiChoices.value.choices = gameplay.choices[2...].map { choice in - DnDAnswerNode(id: choice.id, value: choice.value, type: choice.type, size: self.uiChoices.value.choiceSize) + DnDAnswerNode(id: choice.id, value: choice.value, type: choice.type, size: self.uiChoices.value.choiceSize(for: gameplay.choices.count)) } } diff --git a/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/OneToOne/BaseScene/DnDOneToOneBaseScene.swift b/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/OneToOne/BaseScene/DnDOneToOneBaseScene.swift new file mode 100644 index 0000000000..29b866735d --- /dev/null +++ b/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/OneToOne/BaseScene/DnDOneToOneBaseScene.swift @@ -0,0 +1,120 @@ +// Leka - iOS Monorepo +// Copyright APF France handicap +// SPDX-License-Identifier: Apache-2.0 + +import ContentKit +import SpriteKit +import SwiftUI + +class DnDOneToOneBaseScene: SKScene { + // MARK: Lifecycle + + init(viewModel: DnDOneToOneViewModel) { + self.viewModel = viewModel + super.init(size: CGSize.zero) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Internal + + override func didMove(to _: SKView) { + self.reset() + } + + // MARK: - Touch Interaction + + override func touchesBegan(_ touches: Set, with _: UIEvent?) { + guard let touch = touches.first else { + return + } + let location = touch.location(in: self) + if let node = atPoint(location) as? DnDAnswerNode { + for choice in self.viewModel.choices where node.id == choice.id && node.isDraggable { + selectedNodes[touch] = node + self.viewModel.onTouch(.began, choice: node) + } + } + } + + override func touchesMoved(_ touches: Set, with _: UIEvent?) { + guard let touch = touches.first else { + return + } + let location = touch.location(in: self) + if let node = selectedNodes[touch] { + node.position = location + } + } + + override func touchesEnded(_ touches: Set, with _: UIEvent?) { + guard let touch = touches.first, + let playedNode = self.selectedNodes[touch] + else { + return + } + + self.viewModel.onTouch(.ended, choice: playedNode, destination: self.dropZonesNodes.first(where: { + $0.frame.contains(touch.location(in: self)) + })) + } + + func reset() { + backgroundColor = .clear + removeAllChildren() + removeAllActions() + + self.spacer = size.width / CGFloat(self.viewModel.choices.count + 1) + + self.layoutChoices() + self.layoutDropzones() + self.viewModel.setAlreadyOrderedNodes() + } + + func exerciseCompletedBehavior() { + for node in self.answerNodes { + node.isDraggable = false + } + } + + func layoutChoices() { + for (index, choice) in self.viewModel.choices.enumerated() { + choice.initialPosition = self.setInitialPosition(index) + choice.position = choice.initialPosition! + self.answerNodes.append(choice) + + let shadowChoice = DnDShadowNode(node: choice) + addChild(shadowChoice) + addChild(choice) + } + } + + func layoutDropzones() { + for (index, dropzone) in self.viewModel.dropzones.enumerated() { + dropzone.position = self.setInitialDropZonePosition(index) + self.dropZonesNodes.append(dropzone) + addChild(dropzone) + } + } + + func setInitialPosition(_ index: Int) -> CGPoint { + CGPoint(x: self.spacer * CGFloat(index + 1), y: size.height - self.viewModel.choices[0].size.height * 0.8) + } + + func setInitialDropZonePosition(_ index: Int) -> CGPoint { + CGPoint(x: self.spacer * CGFloat(index + 1), y: self.viewModel.choices[0].size.height * 0.8) + } + + // MARK: Private + + private var spacer: CGFloat = .zero + private var viewModel: DnDOneToOneViewModel + private var expectedItemsNodes: [String: [SKSpriteNode]] = [:] + private var selectedNodes: [UITouch: DnDAnswerNode] = [:] + private var dropZonesNodes: [DnDDropZoneNode] = [] + private var answerNodes: [DnDAnswerNode] = [] + private var playedNode: DnDAnswerNode? +} diff --git a/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/OneToOne/Coordinators/DnDOneToOneCoordinator+FindTheRightOrder.swift b/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/OneToOne/Coordinators/DnDOneToOneCoordinator+FindTheRightOrder.swift new file mode 100644 index 0000000000..812cd0624a --- /dev/null +++ b/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/OneToOne/Coordinators/DnDOneToOneCoordinator+FindTheRightOrder.swift @@ -0,0 +1,189 @@ +// Leka - iOS Monorepo +// Copyright APF France handicap +// SPDX-License-Identifier: Apache-2.0 + +import Combine +import SpriteKit +import SwiftUI +import UtilsKit + +// MARK: - DnDOneToOneCoordinatorFindTheRightOrder + +public class DnDOneToOneCoordinatorFindTheRightOrder: DnDOneToOneGameplayCoordinatorProtocol { + // MARK: Lifecycle + + public init(gameplay: NewGameplayFindTheRightOrder) { + self.gameplay = gameplay + + self.uiChoices.value.choices = gameplay.orderedChoices.map { choice in + DnDAnswerNode(id: choice.id, value: choice.value, type: choice.type, size: self.uiChoices.value.choiceSize(for: gameplay.orderedChoices.count)) + } + + self.uiDropZones = self.uiChoices.value.choices.map { node in + DnDDropZoneNode(node: node) + } + + self.uiChoices.value.choices.shuffle() + + self.currentOrderedChoices = Array(repeating: .zero, count: gameplay.orderedChoices.count) + self.alreadyValidatedChoices = Array(repeating: .zero, count: gameplay.orderedChoices.count) + } + + // MARK: Public + + public private(set) var uiDropZones: [DnDDropZoneNode] = [] + public private(set) var uiChoices = CurrentValueSubject(.zero) + + public func setAlreadyOrderedNodes() { + self.gameplay.orderedChoices.forEach { choice in + if choice.alreadyOrdered { + guard let index = self.uiDropZones.firstIndex(where: { $0.id == choice.id }) else { return } + self.updateChoiceState(for: choice, to: .correct(order: index)) + self.currentOrderedChoices[index] = choice + self.alreadyValidatedChoices[index] = choice + } + } + } + + public func onTouch(_ event: DnDTouchEvent, choice: DnDAnswerNode, destination: DnDDropZoneNode? = nil) { + switch event { + case .began: + self.updateChoiceState(for: self.gameplay.orderedChoices.first(where: { $0.id == choice.id })!, to: .selected) + case .ended: + guard let destination else { + self.updateChoiceState(for: self.gameplay.orderedChoices.first(where: { $0.id == choice.id })!, to: .idle) + return + } + + self.processUserDropOnDestination(choice: choice, destination: destination) + } + } + + // MARK: Private + + private let gameplay: NewGameplayFindTheRightOrder + + private var currentOrderedChoices: [NewGameplayFindTheRightOrderChoice] = [] + private var alreadyValidatedChoices: [NewGameplayFindTheRightOrderChoice] = [] + + private func processUserDropOnDestination(choice: DnDAnswerNode, destination: DnDDropZoneNode) { + guard let sourceChoice = self.gameplay.orderedChoices.first(where: { $0.id == choice.id }), + let destinationIndex = self.uiDropZones.firstIndex(where: { $0.id == destination.id }), + !self.choiceAlreadySelected(choice: sourceChoice) else { return } + + self.order(choice: sourceChoice, dropZoneIndex: destinationIndex) + if self.currentOrderedChoices.doesNotContain(.zero) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + let results = self.gameplay.process(choices: self.currentOrderedChoices) + + results.enumerated().forEach { index, result in + if result.correctPosition { + self.updateChoiceState(for: result.choice, to: .correct(order: index)) + self.alreadyValidatedChoices[index] = result.choice + } else { + self.updateChoiceState(for: result.choice, to: .idle) + self.currentOrderedChoices[index] = .zero + } + } + } + } + } + + private func updateChoiceState(for choice: NewGameplayFindTheRightOrderChoice, to state: State) { + guard let index = self.uiChoices.value.choices.firstIndex(where: { $0.id == choice.id }) else { return } + + self.updateUINodeState(node: self.uiChoices.value.choices[index], state: state) + } + + private func choiceAlreadySelected(choice: NewGameplayFindTheRightOrderChoice) -> Bool { + self.alreadyValidatedChoices.contains(where: { $0.id == choice.id }) + } + + private func order(choice: NewGameplayFindTheRightOrderChoice, dropZoneIndex: Int) { + let previousChoice = self.currentOrderedChoices[dropZoneIndex] + if let index = self.currentOrderedChoices.firstIndex(where: { $0 == choice }) { + if previousChoice == .zero { + self.currentOrderedChoices[index] = .zero + self.currentOrderedChoices[dropZoneIndex] = choice + self.updateChoiceState(for: choice, to: .ordered(order: dropZoneIndex)) + } else if self.isMovable(choice: previousChoice) { + self.currentOrderedChoices[index] = previousChoice + self.currentOrderedChoices[dropZoneIndex] = choice + self.updateChoiceState(for: previousChoice, to: .ordered(order: index)) + self.updateChoiceState(for: choice, to: .ordered(order: dropZoneIndex)) + } else { + self.currentOrderedChoices[index] = .zero + self.updateChoiceState(for: choice, to: .idle) + } + } else { + if previousChoice == .zero { + self.currentOrderedChoices[dropZoneIndex] = choice + self.updateChoiceState(for: choice, to: .ordered(order: dropZoneIndex)) + } else if self.isMovable(choice: previousChoice) { + self.currentOrderedChoices[dropZoneIndex] = choice + self.updateChoiceState(for: previousChoice, to: .idle) + self.updateChoiceState(for: choice, to: .ordered(order: dropZoneIndex)) + } else { + self.updateChoiceState(for: choice, to: .idle) + } + } + } + + private func isMovable(choice: NewGameplayFindTheRightOrderChoice) -> Bool { + self.uiChoices.value.choices.first(where: { choice.id == $0.id })!.isDraggable + } +} + +extension DnDOneToOneCoordinatorFindTheRightOrder { + enum State: Equatable { + case idle + case selected + case ordered(order: Int) + case correct(order: Int) + } + + private func updateUINodeState(node: DnDAnswerNode, state: State) { + switch state { + case .idle: + self.moveNodeBackToInitialPosition(node) + case .selected: + self.onDragAnimation(node) + case let .ordered(order): + node.snapToCenter(dropZone: self.uiDropZones[order]) + case let .correct(order): + node.snapToCenter(dropZone: self.uiDropZones[order]) + node.isDraggable = false + } + } + + // MARK: - Animations + + private func onDragAnimation(_ node: SKSpriteNode) { + let wiggleAnimation = SKAction.sequence([ + SKAction.rotate(byAngle: CGFloat(degreesToRadian(degrees: -4)), duration: 0.1), + SKAction.rotate(byAngle: 0.0, duration: 0.1), + SKAction.rotate(byAngle: CGFloat(degreesToRadian(degrees: 4)), duration: 0.1), + ]) + node.zPosition += 100 + node.run(SKAction.repeatForever(wiggleAnimation)) + } + + // MARK: Private + + private func onDropAction(_ node: SKSpriteNode) { + node.zRotation = 0 + node.removeAllActions() + } + + private func moveNodeBackToInitialPosition(_ node: DnDAnswerNode) { + let moveAnimation = SKAction.move(to: node.initialPosition ?? .zero, duration: 0.25) + node.run( + moveAnimation, + completion: { + node.position = node.initialPosition ?? .zero + node.zPosition = 10 + self.onDropAction(node) + } + ) + } +} diff --git a/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/OneToOne/Coordinators/DnDOneToOneGameplayCoordinatorProtocol.swift b/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/OneToOne/Coordinators/DnDOneToOneGameplayCoordinatorProtocol.swift new file mode 100644 index 0000000000..d0129eae1c --- /dev/null +++ b/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/OneToOne/Coordinators/DnDOneToOneGameplayCoordinatorProtocol.swift @@ -0,0 +1,14 @@ +// Leka - iOS Monorepo +// Copyright APF France handicap +// SPDX-License-Identifier: Apache-2.0 + +import Combine +import Foundation + +// swiftlint:disable:next type_name +public protocol DnDOneToOneGameplayCoordinatorProtocol { + var uiChoices: CurrentValueSubject { get } + var uiDropZones: [DnDDropZoneNode] { get } + func setAlreadyOrderedNodes() + func onTouch(_ event: DnDTouchEvent, choice: DnDAnswerNode, destination: DnDDropZoneNode?) +} diff --git a/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/OneToOne/DnDOneToOneView.swift b/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/OneToOne/DnDOneToOneView.swift new file mode 100644 index 0000000000..2cd45cdcd1 --- /dev/null +++ b/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/OneToOne/DnDOneToOneView.swift @@ -0,0 +1,41 @@ +// Leka - iOS Monorepo +// Copyright APF France handicap +// SPDX-License-Identifier: Apache-2.0 + +import SpriteKit +import SwiftUI + +// MARK: - DnDOneToOneView + +public struct DnDOneToOneView: View { + // MARK: Lifecycle + + public init(viewModel: DnDOneToOneViewModel) { + self._viewModel = StateObject(wrappedValue: viewModel) + } + + // MARK: Public + + public var body: some View { + GeometryReader { proxy in + SpriteView(scene: self.makeScene(size: proxy.size), options: [.allowsTransparency]) + .frame(width: proxy.size.width, height: proxy.size.height) + .onAppear { + self.scene = DnDOneToOneBaseScene(viewModel: self.viewModel) + } + } + } + + // MARK: Private + + @StateObject private var viewModel: DnDOneToOneViewModel + @State private var scene: SKScene = .init() + + private func makeScene(size: CGSize) -> SKScene { + guard let finalScene = scene as? DnDOneToOneBaseScene else { + return SKScene() + } + finalScene.size = size + return finalScene + } +} diff --git a/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/OneToOne/DnDOneToOneViewModel.swift b/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/OneToOne/DnDOneToOneViewModel.swift new file mode 100644 index 0000000000..52324dffa7 --- /dev/null +++ b/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/OneToOne/DnDOneToOneViewModel.swift @@ -0,0 +1,42 @@ +// Leka - iOS Monorepo +// Copyright APF France handicap +// SPDX-License-Identifier: Apache-2.0 + +import Combine +import SwiftUI + +// MARK: - DnDOneToOneViewModel + +public class DnDOneToOneViewModel: ObservableObject { + // MARK: Lifecycle + + public init(coordinator: DnDOneToOneGameplayCoordinatorProtocol) { + self.choices = coordinator.uiChoices.value.choices + self.dropzones = coordinator.uiDropZones + self.coordinator = coordinator + self.coordinator.uiChoices + .receive(on: DispatchQueue.main) + .sink { [weak self] choices in + self?.choices = choices.choices + } + .store(in: &self.cancellables) + } + + // MARK: Internal + + @Published var choices: [DnDAnswerNode] = [] + @Published var dropzones: [DnDDropZoneNode] = [] + + func setAlreadyOrderedNodes() { + self.coordinator.setAlreadyOrderedNodes() + } + + func onTouch(_ event: DnDTouchEvent, choice: DnDAnswerNode, destination: DnDDropZoneNode? = nil) { + self.coordinator.onTouch(event, choice: choice, destination: destination) + } + + // MARK: Private + + private let coordinator: DnDOneToOneGameplayCoordinatorProtocol + private var cancellables: Set = [] +} diff --git a/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/SKComponents/DnDAnswerNode.swift b/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/SKComponents/DnDAnswerNode.swift index 824a34f227..6f961c292a 100644 --- a/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/SKComponents/DnDAnswerNode.swift +++ b/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/SKComponents/DnDAnswerNode.swift @@ -13,45 +13,16 @@ public class DnDAnswerNode: SKSpriteNode { init(id: String, value: String, type: ChoiceType, size: CGSize) { self.id = id self.type = type - switch type { + let texture: SKTexture = switch type { + case .text: + Self.createTextTexture(value: value, size: size) case .image: - guard let path = Bundle.path(forImage: value), let image = UIImage(named: path) else { - fatalError("Image not found") - } - - super.init(texture: SKTexture(image: image), color: .clear, size: size) - + Self.createImageTexture(value: value, size: size) case .sfsymbol: - guard let image = UIImage(systemName: value, withConfiguration: UIImage.SymbolConfiguration(pointSize: size.height)) else { - fatalError("SFSymbol not found") - } - - super.init(texture: SKTexture(image: image), color: .clear, size: size) - - case .text: - super.init(texture: nil, color: .clear, size: size) - - let circle = SKShapeNode(circleOfRadius: size.width / 2) - - circle.fillColor = .white - circle.strokeColor = .black - circle.lineWidth = 0.5 - circle.zPosition = -1 - circle.position = CGPoint(x: 0, y: 0) - - self.addChild(circle) - - let label = SKLabelNode(text: value) - - label.fontSize = 20 - label.fontName = "AvenirNext-Bold" - label.fontColor = .black - label.position = CGPoint(x: 0, y: -10) - label.zPosition = 0 - - self.addChild(label) + Self.createSFSymbolTexture(value: value, size: size) } + super.init(texture: texture, color: .clear, size: texture.size()) self.name = value self.zPosition = 10 } @@ -67,6 +38,75 @@ public class DnDAnswerNode: SKSpriteNode { let type: ChoiceType var initialPosition: CGPoint? var isDraggable = true + + // MARK: Private + + private static let cornerRadiusFactor: CGFloat = 10 / 57 + private static let sizeFactorSFSymbol: CGFloat = 0.6 +} + +extension DnDAnswerNode { + private static func createImageTexture(value: String, size: CGSize) -> SKTexture { + guard let path = Bundle.path(forImage: value), + let image = UIImage(named: path) + else { + fatalError("Image not found") + } + + let renderer = UIGraphicsImageRenderer(size: size) + let finalImage = renderer.image { _ in + let rect = CGRect(origin: .zero, size: size) + let path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadiusFactor * size.width) + path.addClip() + image.draw(in: rect) + } + + return SKTexture(image: finalImage) + } + + private static func createSFSymbolTexture(value: String, size: CGSize) -> SKTexture { + guard let image = UIImage(systemName: value, + withConfiguration: UIImage.SymbolConfiguration(pointSize: size.height * sizeFactorSFSymbol)) + else { + fatalError("SFSymbol not found") + } + + return SKTexture(image: image) + } + + private static func createTextTexture(value: String, size: CGSize) -> SKTexture { + let rectSize = CGSize(width: size.width, height: size.height) + let renderer = UIGraphicsImageRenderer(size: rectSize) + let finalImage = renderer.image { _ in + let rect = CGRect(origin: .zero, size: rectSize) + + let path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadiusFactor * size.width) + path.addClip() + + UIColor.white.setFill() + path.fill() + + UIColor.gray.setStroke() + let strokeWidth: CGFloat = 2 + let borderRect = rect.insetBy(dx: strokeWidth / 2, dy: strokeWidth / 2) + let borderPath = UIBezierPath(roundedRect: borderRect, cornerRadius: cornerRadiusFactor * size.width) + borderPath.stroke() + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .center + + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont(name: "AvenirNext-Bold", size: 20) ?? UIFont.systemFont(ofSize: 20), + .foregroundColor: UIColor.black, + .paragraphStyle: paragraphStyle, + ] + + let textRect = rect.insetBy(dx: 10, dy: (rect.height - 20) / 2) + (value as NSString).draw(in: textRect, withAttributes: attributes) + } + + return SKTexture(image: finalImage) + } } // MARK: - DnDUIChoices @@ -78,8 +118,8 @@ public struct DnDUIChoices { var choices: [DnDAnswerNode] - var choiceSize: CGSize { - DnDGridSize(self.choices.count).choiceSize + func choiceSize(for choiceNumber: Int) -> CGSize { + DnDGridSize(choiceNumber).choiceSize } // MARK: Private @@ -122,14 +162,15 @@ public struct DnDUIChoices { switch self { case .one, .two: + CGSize(width: 220, height: 220) + case .three, + .four: CGSize(width: 200, height: 200) - case .three: - CGSize(width: 180, height: 180) - case .four, - .five, - .six, + case .five: + CGSize(width: 160, height: 160) + case .six, .none: - CGSize(width: 140, height: 140) + CGSize(width: 150, height: 150) } } } diff --git a/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/SKComponents/DnDDropZoneNode.swift b/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/SKComponents/DnDDropZoneNode.swift index e995937b16..b9a75cc5bd 100644 --- a/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/SKComponents/DnDDropZoneNode.swift +++ b/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/SKComponents/DnDDropZoneNode.swift @@ -13,53 +13,29 @@ public class DnDDropZoneNode: SKSpriteNode { init(id: String, value: String, type: ChoiceType, position: CGPoint) { self.id = id let size = CGSize(width: 420, height: 315) - switch type { + let texture: SKTexture = switch type { case .image: - guard let path = Bundle.path(forImage: value), let image = UIImage(named: path) else { - fatalError("Image not found") - } - - super.init(texture: SKTexture(image: image), color: .clear, size: size) - + Self.createImageTexture(value: value, size: size) case .sfsymbol: - guard let image = UIImage(systemName: value, withConfiguration: UIImage.SymbolConfiguration(pointSize: size.height)) else { - fatalError("SFSymbol not found") - } - - super.init(texture: SKTexture(image: image), color: .clear, size: size) - + Self.createSFSymbolTexture(value: value, size: size) case .text: - super.init(texture: nil, color: .clear, size: size) - - let rectangle = SKShapeNode( - rect: CGRect(x: -size.width / 2, y: -size.height / 2, width: size.width, height: size.height), - cornerRadius: 10 - ) - - rectangle.fillColor = .white - rectangle.strokeColor = .black - rectangle.lineWidth = 0.5 - rectangle.zPosition = -1 - rectangle.position = CGPoint(x: 0, y: 0) - - self.addChild(rectangle) - - let label = SKLabelNode(text: value) - - label.fontSize = 20 - label.fontName = "AvenirNext-Bold" - label.fontColor = .black - label.position = CGPoint(x: 0, y: -10) - label.zPosition = 0 - - self.addChild(label) + Self.createTextTexture(value: value, size: size) } + super.init(texture: texture, color: .clear, size: size) self.name = value self.position = position self.zPosition = 10 } + init(node: DnDAnswerNode, position: CGPoint = .zero) { + self.id = node.id + let image = DnDDropZoneNode.createRoundedRectImage(size: node.size) + let texture = SKTexture(image: image) + super.init(texture: texture, color: .clear, size: node.size) + self.position = position + } + @available(*, unavailable) required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") @@ -70,4 +46,102 @@ public class DnDDropZoneNode: SKSpriteNode { let id: String var initialPosition: CGPoint? var isDraggable = true + + // MARK: Private + + private static let cornerRadiusFactor: CGFloat = 10 / 57 + private static let defaultSize = CGSize(width: 420, height: 315) + private static let borderWidth: CGFloat = 2.0 + private static let labelFontSize: CGFloat = 20 + private static let labelFontName = "AvenirNext-Bold" + private static let horizontalPadding: CGFloat = 10 +} + +extension DnDDropZoneNode { + private static func createImageTexture(value: String, size: CGSize) -> SKTexture { + guard let path = Bundle.path(forImage: value), let image = UIImage(named: path) else { + fatalError("Image not found") + } + + let renderer = UIGraphicsImageRenderer(size: size) + let finalImage = renderer.image { _ in + let rect = CGRect(origin: .zero, size: size) + let path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadiusFactor * size.width) + path.addClip() + image.draw(in: rect) + } + + return SKTexture(image: finalImage) + } + + private static func createSFSymbolTexture(value: String, size: CGSize) -> SKTexture { + guard let image = UIImage(systemName: value, withConfiguration: UIImage.SymbolConfiguration(pointSize: size.height * 0.6)) else { + fatalError("SFSymbol not found") + } + + return SKTexture(image: image) + } + + private static func createTextTexture(value: String, size: CGSize) -> SKTexture { + let renderer = UIGraphicsImageRenderer(size: size) + + let finalImage = renderer.image { _ in + let rect = CGRect(origin: .zero, size: size) + + let roundedPath = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadiusFactor * size.width) + roundedPath.addClip() + UIColor.white.setFill() + roundedPath.fill() + + UIColor.gray.setStroke() + let borderRect = rect.insetBy(dx: self.borderWidth / 2, dy: self.borderWidth / 2) + let borderPath = UIBezierPath(roundedRect: borderRect, cornerRadius: cornerRadiusFactor * size.width - self.borderWidth / 2) + borderPath.lineWidth = self.borderWidth + borderPath.stroke() + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .center + + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont(name: self.labelFontName, size: self.labelFontSize) ?? UIFont.systemFont(ofSize: self.labelFontSize), + .foregroundColor: UIColor.black, + .paragraphStyle: paragraphStyle, + ] + + let textHeight = (attributes[.font] as? UIFont)?.lineHeight ?? self.labelFontSize + let textRect = rect.insetBy(dx: self.horizontalPadding, dy: (rect.height - textHeight) / 2) + + (value as NSString).draw(in: textRect, withAttributes: attributes) + } + + return SKTexture(image: finalImage) + } + + private static let dropzoneBackgroundColor: UIColor = .init( + light: UIColor.white, + dark: UIColor(displayP3Red: 242 / 255, green: 242 / 255, blue: 247 / 255, alpha: 1.0) + ) + + private static func createRoundedRectImage(size: CGSize) -> UIImage { + let rect = CGRect(origin: .zero, size: size) + let cornerRadius: CGFloat = 10 / 57 * size.width + let strokeWidth: CGFloat = 2.0 + + let renderer = UIGraphicsImageRenderer(size: size) + let image = renderer.image { context in + let context = context.cgContext + + context.setFillColor(self.dropzoneBackgroundColor.cgColor) + let path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius) + path.fill() + + context.setStrokeColor(UIColor.gray.cgColor) + context.setLineWidth(strokeWidth) + let borderRect = rect.insetBy(dx: strokeWidth / 2, dy: strokeWidth / 2) + let borderPath = UIBezierPath(roundedRect: borderRect, cornerRadius: cornerRadius - strokeWidth / 2) + borderPath.stroke() + } + + return image + } } diff --git a/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/SKComponents/DnDShadowNode.swift b/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/SKComponents/DnDShadowNode.swift index dce65fcb44..6aca49638a 100644 --- a/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/SKComponents/DnDShadowNode.swift +++ b/Modules/GameEngineKit/Sources/_NewSystem/Views/DnD/SKComponents/DnDShadowNode.swift @@ -6,37 +6,20 @@ import SpriteKit class DnDShadowNode: SKSpriteNode { init(node: DnDAnswerNode) { - switch node.type { - case .text: - super.init(texture: nil, color: .clear, size: node.size) + super.init(texture: nil, color: .clear, size: node.size) - let shadowNode = SKShapeNode(circleOfRadius: node.size.width / 2) + let shadowTexture = node.texture?.copy() as? SKTexture + let shadowNode = SKSpriteNode(texture: shadowTexture) - shadowNode.fillColor = .black - shadowNode.strokeColor = .clear - shadowNode.alpha = 0.15 - shadowNode.position = node.position - shadowNode.zPosition = -1 + shadowNode.color = .black + shadowNode.colorBlendFactor = 1.0 + shadowNode.alpha = 0.15 + shadowNode.blendMode = .alpha + shadowNode.size = node.size + shadowNode.position = node.position + shadowNode.zPosition = -1 - self.addChild(shadowNode) - - case .sfsymbol, - .image: - super.init(texture: nil, color: .clear, size: node.size) - - let shadowTexture = node.texture?.copy() as? SKTexture - let shadowNode = SKSpriteNode(texture: shadowTexture) - - shadowNode.color = .black - shadowNode.colorBlendFactor = 1.0 - shadowNode.alpha = 0.15 - shadowNode.blendMode = .alpha - shadowNode.size = node.size - shadowNode.position = node.position - shadowNode.zPosition = -1 - - self.addChild(shadowNode) - } + self.addChild(shadowNode) } required init?(coder aDecoder: NSCoder) {