Nearly fully working game

main
Mathieu GROUSSEAU 1 week ago
parent e5caaed004
commit 9ef00e4859

@ -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 = "<group>"; };
F0143C352DFA9A000086CAAA /* RulesType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulesType.swift; sourceTree = "<group>"; };
F05DA2102E002AA00094A4A8 /* BoardNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoardNode.swift; sourceTree = "<group>"; };
F05DA2122E01BA270094A4A8 /* PlayerVM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerVM.swift; sourceTree = "<group>"; };
F05DA2142E02D1850094A4A8 /* UIUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIUtilities.swift; sourceTree = "<group>"; };
F0F59E412DD492B400BE32D6 /* C4Persistance.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = C4Persistance.xcframework; path = ../precompiled/xcframeworks/C4Persistance.xcframework; sourceTree = "<group>"; };
F0F59E422DD492B400BE32D6 /* C4Players.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = C4Players.xcframework; path = ../precompiled/xcframeworks/C4Players.xcframework; sourceTree = "<group>"; };
F0F59E432DD492B400BE32D6 /* C4.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = C4.xcframework; path = ../precompiled/xcframeworks/C4.xcframework; sourceTree = "<group>"; };
@ -191,6 +195,7 @@
children = (
F0143C2C2DF01E120086CAAA /* NewGameVM.swift */,
F0143C2E2DF96B690086CAAA /* InGameVM.swift */,
F05DA2122E01BA270094A4A8 /* PlayerVM.swift */,
);
path = ViewModel;
sourceTree = "<group>";
@ -223,6 +228,7 @@
F0F59E562DE6D6E600BE32D6 /* SavedGamesView.swift */,
F0F59E582DE6EB2A00BE32D6 /* PlayerVSPage.swift */,
F0F59E5A2DE6F68800BE32D6 /* ScoreboardView.swift */,
F05DA2142E02D1850094A4A8 /* UIUtilities.swift */,
);
path = View;
sourceTree = "<group>";
@ -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 */,

@ -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" : {

@ -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..<vm.currentBoard.nbRows).contains(at.row) &&
(0..<vm.currentBoard.nbColumns).contains(at.column) {
vm.pieceDropped(at: at)
} else {
self.cancelMove(mayRetry: true)
}
}
private func updateBoard(board: Board) {
assert(vm.columns == board.nbColumns && vm.rows == board.nbRows, "Board size mismatch")
for cell in cells {
cell.removeFromParent()
}
cells.removeAll()
for row in 0..<board.nbRows {
for column in 0..<board.nbColumns {
guard let piece = board[row, column] else { continue }
let node = PieceNode()
node.boardPosition = (column, row)
node.piece = piece
cells.append(node)
addChild(node)
}
}
}
private func onMove(board: Board, move: Move, player: Player) {
draggablePiece.position = draggablePiecePlacement
}
private func onInvalidMove(board: Board, move: Move, player: Player, result: Bool) {
if result { return }
self.cancelMove(mayRetry: player is HumanPlayer)
}
private func cancelMove(mayRetry: Bool) {
draggablePiece.run(SKAction.move(to: draggablePiecePlacement, duration: 0.25)) {
if mayRetry {
self.draggablePiece.onDragHandler = self.onPieceDragEnd
}
}
}
private func onGameOver(board: Board, result: Connect4Core.Result, player: Player?) {
self.draggablePiece.removeFromParent()
switch (result) {
case .winner(_, let alignment):
for cell in cells {
if alignment.contains(where: {
$0.row == cell.boardPosition!.y && $0.col == cell.boardPosition!.x
}) {
continue
}
cell.alpha = 0.25
}
default:
break
}
}
deinit {
for c in cancellables { c.cancel() }
}
}

@ -16,12 +16,13 @@ class PieceNode : SKNode {
switch (piece.owner) {
case .player1:
circle.fillColor = .yellow
circle.strokeColor = .orange
circle.strokeColor = .black
case .player2:
circle.fillColor = .red
circle.strokeColor = .orange
circle.strokeColor = .black
default:
// NOTE: should be unreachable
circle.fillColor = .lightGray
circle.strokeColor = .clear
}
@ -40,10 +41,9 @@ class PieceNode : SKNode {
}
}
}
var draggable: Bool { self.onDragHandler != nil }
override var isUserInteractionEnabled: Bool {
get { true }
get { self.onDragHandler != nil }
set { /* fatalError("Unsupported operation: set") */ }
}
@ -66,6 +66,13 @@ class PieceNode : SKNode {
fatalError("init(coder:) has not been implemented")
}
public func clampedPosition() -> (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<UITouch>, 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<UITouch>, 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<UITouch>, with event: UIEvent?) {
guard
self.draggable,
let touch = touches.first,
let holder = self.parent
else { return }

@ -1,3 +1,5 @@
enum RulesType {
case Classic
enum RulesType: CaseIterable, Identifiable {
var id: Self { self }
case Classic, TicTacToe, PopOut
}

@ -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<Bool>) {
self.who = owner
self.dockLeft = dockLeft
self._isTurn = isTurn
}
}

@ -0,0 +1,31 @@
import Foundation
import SwiftUI
extension View {
@ViewBuilder func `if`<Content: View>(_ condition: Bool, _ modifier: (Self) -> Content) -> some View {
if condition {
modifier(self)
} else {
self
}
}
}
extension Binding {
func map<T>(_ get: @escaping (Value) -> T) -> Binding<T> {
Binding<T>(
get: { get(self.wrappedValue) },
set: { _ in fatalError("Not implemented") }
)
}
func map<T>(
get: @escaping (Value) -> T,
set: @escaping (T) -> Value
) -> Binding<T> {
Binding<T>(
get: { get(self.wrappedValue) },
set: { self.wrappedValue = set($0) }
)
}
}

@ -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:

@ -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
}
}
}
Loading…
Cancel
Save