Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 41 additions & 95 deletions SwiftIntro.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions SwiftIntro/App/RootVC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,7 @@ extension RootVC: GameNavigatorProtocol {
/// Pushes `GameOverVC` after the final flip animation completes.
func navigateToGameOver(outcome: GameOutcome) {
logNav.info("Pushing GameOverVC — clicks: \(outcome.clickCount), level: \(outcome.level)")
let config = GameConfiguration(level: outcome.level)
let gameOverVC = GameOverVC(config: config, outcome: outcome)
let gameOverVC = GameOverVC(outcome: outcome)
gameOverVC.navigator = self
pushViewController(gameOverVC, animated: true)
}
Expand Down
5 changes: 2 additions & 3 deletions SwiftIntro/Extensions/NSObject_Extension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@
import Foundation

extension NSObject {
/// The unqualified class name, derived by stripping the module prefix from `NSStringFromClass`.
/// The unqualified class name, without the module prefix.
///
/// For example, `SwiftIntro.CardCVCell` becomes `"CardCVCell"`.
/// Used as a stable reuse identifier for collection view cells via `CellProtocol`.
static var className: String {
NSStringFromClass(self).components(separatedBy: ".").last!
String(describing: self)
}
}
132 changes: 43 additions & 89 deletions SwiftIntro/Features/Game/GameVC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
// Copyright © 2016-2026 SwiftIntro. All rights reserved.
//

import Factory
import MobiusCore
import UIKit

// MARK: - GameNavigatorProtocol
Expand All @@ -24,38 +22,38 @@ protocol GameNavigatorProtocol: AnyObject {

/// The game screen view controller.
///
/// `GameVC` is a pure view in the Mobius sense — it implements `Connectable` to
/// render `GameModel` and dispatch `GameEvent`s, but owns no loop infrastructure.
/// The `MobiusController` and `GameEffectHandler` live inside `GameLoop`.
/// `GameVC` is a thin MVVM view controller — it installs `GameView`, wires the
/// data source closures to `GameViewModel`, and forwards lifecycle events. All
/// game state and logic live in the view model.
final class GameVC: UIViewController {
// MARK: Properties

/// Injected image cache — used to check whether card images are ready before
/// allowing cell configuration to proceed.
@Injected(\.imageCache) private var imageCache

/// Owns the Mobius loop for this game session. Update and query operations are
/// forwarded through here so `GameVC` stays loop-infrastructure-free.
private let loop: GameLoop
/// Holds all game state and logic for this session.
private let viewModel: GameViewModel

/// The root view; installed via `loadView()`.
private let gameView = GameView()
private lazy var gameView = GameView(
collectionViewDataSource: dataSourceAndDelegate,
collectionViewDelegate: dataSourceAndDelegate
)

/// The UIKit data source and delegate — sized from `loop.level` in `init`.
/// The UIKit data source and delegate — sized from `viewModel.level` in `init`.
private let dataSourceAndDelegate: MemoryDataSourceAndDelegate

/// Wired by the presenting controller (e.g. `GameSetupVC`) before the push.
/// Wired by the presenting controller (e.g. `RootVC`) before the push.
weak var navigator: GameNavigatorProtocol?

// MARK: Inits

init(_ game: PreparedGame) {
let cardModels = game.cards.memoryCards.map(CardModel.init)
let loop = GameLoop(initialModel: GameModel(cards: cardModels, level: game.config.level))
self.loop = loop
let viewModel = GameViewModel(game)
self.viewModel = viewModel
dataSourceAndDelegate = MemoryDataSourceAndDelegate(
rows: loop.level.rowCount,
columns: loop.level.columnCount
rows: viewModel.level.rowCount,
columns: viewModel.level.columnCount,
canSelectCard: { index in viewModel.canSelectCard(at: index) },
configureCell: { cell, index in viewModel.configureCell(cell, at: index) },
onCardTapped: { index in viewModel.cardTapped(at: index) }
)
super.init(nibName: nil, bundle: nil)
}
Expand All @@ -74,86 +72,42 @@ extension GameVC {
view = gameView
}

override func viewDidLoad() {
super.viewDidLoad()
// Logger interpolation is @autoclosure → closure context; compiler needs self.
// swiftformat:disable:next redundantSelf
logGame.notice("Game started — level: \(self.loop.level.debugDescription)")
setupCollectionView()
setupLoop()
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel.start(
onModelChanged: { [weak self] model in
self?.gameView.render(model)
},
onFlipCard: { [weak self] index, isFaceUp in
self?.animateFlip(at: index, isFaceUp: isFaceUp)
},
onNavigateToGameOver: { [weak self] outcome in
self?.navigator?.navigateToGameOver(outcome: outcome)
}
)
}

/// Stops the loop and cancels any pending timers when the screen leaves the hierarchy.
/// Stops the view model — cancels pending timers and clears callbacks.
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
logGame.debug("GameVC disappeared — stopping Mobius loop")
loop.stop()
}
}

// MARK: - Connectable

extension GameVC: Connectable {
typealias Input = GameModel
typealias Output = GameEvent

/// Called by `MobiusController` (via `GameLoop.start`) when the view connects.
///
/// - Parameter consumer: Dispatch closure — call it with a `GameEvent` to inject
/// input into the loop (e.g. when a card is tapped).
/// - Returns: A `Connection<GameModel>` whose `acceptClosure` renders each new model
/// and whose `disposeClosure` cleans up the tap handler on disconnect.
func connect(_ consumer: @escaping (GameEvent) -> Void) -> Connection<GameModel> {
logGame.debug("GameVC connecting to Mobius loop — wiring card-tap dispatch")
dataSourceAndDelegate.onCardTapped = { consumer(.cardTapped(index: $0)) }
return Connection(
acceptClosure: { [weak self] model in
guard let self else { return }
loop.update(with: model)
gameView.render(model)
},
disposeClosure: { [weak self] in
logGame.debug("GameVC disconnecting from Mobius loop — removing card-tap handler")
self?.dataSourceAndDelegate.onCardTapped = nil
}
)
logGame.debug("GameVC disappeared — stopping view model")
viewModel.stop()
}
}

// MARK: - Private

private extension GameVC {
/// Wires the effect handler's UIKit dependencies and starts the Mobius loop.
func setupLoop() {
loop.start(
view: self,
collectionView: gameView.collectionView,
onNavigateToGameOver: { [weak self] outcome in
self?.navigator?.navigateToGameOver(outcome: outcome)
}
/// Looks up the cell at the given flat index and plays the flip animation.
func animateFlip(
at flatIndex: Int,
isFaceUp: Bool
) {
let indexPath = IndexPath(
item: flatIndex % viewModel.level.columnCount,
section: flatIndex / viewModel.level.columnCount
)
}

/// Assigns the data source and delegate, registers the cell class, and wires closures.
func setupCollectionView() {
gameView.collectionView.dataSource = dataSourceAndDelegate
gameView.collectionView.delegate = dataSourceAndDelegate
gameView.collectionView.register(
CardCVCell.self,
forCellWithReuseIdentifier: CardCVCell.cellIdentifier
)
wireDataSourceClosures()
}

/// Connects the data source's query closures to the loop so it stays decoupled from `GameVC`.
func wireDataSourceClosures() {
dataSourceAndDelegate.canSelectCard = { [weak self] index in
guard let self else { return false }
return loop.canSelectCard(at: index)
}
dataSourceAndDelegate.configureCell = { [weak self] cell, index in
guard let self else { return }
loop.configureCell(cell, at: index)
}
gameView.animateFlip(at: indexPath, isFaceUp: isFaceUp)
}
}
Loading
Loading