swiftswiftui

How to ensure spinning wheel animation consistently matches predetermined game outcome?


I am creating a small hobby project in SwiftUI where a revolver cylinder spins and lands on either a loaded chamber (player drinks) or empty chamber (player is safe). I'm having trouble ensuring the visual animation (what chamber lands at the firing pin) consistently matches the game logic outcome.

The Problem. The cylinder has 8 chambers, some with bullets, some empty. When spinning, I want to:

  1. First determine the outcome (hit or miss) based on probability
  2. Then animate the cylinder to land on an appropriate chamber (loaded or empty)
  3. Show the matching result message (BANG! or Click.)

However, my current implementation occasionally shows a mismatch - sometimes landing visually on an empty chamber but triggering the "BANG!" outcome, or vice versa.

Her is also a full code view:

import SwiftUI

// MARK: - Chamber Model
struct RevolverChamber: Identifiable {
    let id = UUID()
    let position: Int       // Position number (0-7)
    let angleDegrees: Double // Position in degrees (0, 45, 90, etc.)
    var hasBullet: Bool     // Whether this chamber has a bullet
}

struct GameMode3View: View {
    let config: GameConfiguration
    @Binding var path: [Route]
    //@Environment(\.colorScheme) var colorScheme
    
    // Game state
    @State private var chambers: [RevolverChamber] = []
    @State private var currentPlayerIndex: Int = 0
    @State private var rotation: Double = 0
    @State private var isSpinning: Bool = false
    @State private var outcome: SpinOutcome? = nil
    @State private var bulletCount: Int = 3 // Start with 3 bullets
    @State private var lastSpinPlayerName: String = ""
    @State private var debugText: String = ""
    
    // Constants
    private let chamberAngle: Double = 45.0 // 360 / 8 chambers
    
    enum SpinOutcome {
        case safe
        case hit
    }
    
    var currentPlayer: Player {
        config.players[currentPlayerIndex]
    }
    
    var body: some View {
        ZStack {
            // Background
            Color.gray
            .edgesIgnoringSafeArea(.all)
            
            VStack(spacing: 20) {
                // Back button at top left
                HStack {
                    Button(action: { path = [.gameModes] }) {
                        Image(systemName: "arrowshape.left.fill")
                            .foregroundColor(.white)
                            .padding(.leading)
                            .font(.system(size: 24))
                    }
                    
                    Spacer()
                }
                
                // Title and player turn indicator
                Text("Russian Roulette")
                    .font(.largeTitle.bold())
                    .foregroundColor(.white)
                
                Text("\(currentPlayer.name)'s turn")
                    .font(.title2.bold())
                    .foregroundColor(.yellow)
                    .padding(.bottom, 10)
                
                // Game status
                Text("Bullets: \(bulletCount)/8")
                    .font(.headline)
                    .foregroundColor(bulletCount > 4 ? .red : .white)
                
                // Cylinder view with fixed firing pin
                ZStack {
                    // Spinning cylinder
                    ZStack {
                        // Cylinder background
                        Circle()
                            .fill(Color(hex: "#444444"))
                            .frame(width: 270, height: 270)
                            .shadow(color: .black, radius: 10)
                        
                        // Chambers using our RevolverChamber model
                        ForEach(chambers) { chamber in
                            ChamberView(hasBullet: chamber.hasBullet)
                                .frame(width: 150, height: 150)
                                .offset(y: -100)
                                .rotationEffect(.degrees(chamber.angleDegrees))
                        }
                        
                        // Chamber dividers
                        ForEach(0..<8, id: \.self) { idx in
                            Rectangle()
                                .fill(Color.black)
                                .frame(width: 4, height: 90)
                                .offset(y: -70)
                                .rotationEffect(.degrees(Double(idx) * chamberAngle + chamberAngle/2))
                        }
                        
                        // Cylinder body
                        Circle()
                            .fill(Color(hex: "#333333"))
                            .frame(width: 120, height: 120)
                    }
                    .rotationEffect(.degrees(rotation))
                    
                    // Fixed firing pin on top (outside rotation) - rotated to point down
                    Triangle()
                        .fill(Color.red)
                        .frame(width: 20, height: 20)
                        .rotationEffect(.degrees(180)) // Rotate arrow 180 degrees
                        .offset(y: -130) // Keep at the top
                }
                
                // Debug text (remove in production)
                Text(debugText)
                    .font(.caption)
                    .foregroundColor(.white)
                    .opacity(0.7)
                
                // Outcome message with player name
                if let outcome = outcome {
                    VStack {
                        if outcome == .hit {
                            Text("BANG!")
                                .font(.title.bold())
                                .foregroundColor(.red)
                            
                            Text("\(lastSpinPlayerName) must DRINK!")
                                .font(.title2.bold())
                                .foregroundColor(.red)
                        } else {
                            Text("Click.")
                                .font(.title.bold())
                                .foregroundColor(.green)
                                
                            Text("\(lastSpinPlayerName) is safe this time...")
                                .font(.title2)
                                .foregroundColor(.green)
                        }
                    }
                    .padding()
                    .background(Color.black.opacity(0.6))
                    .cornerRadius(10)
                    .transition(.scale)
                }
                
                Spacer()
                
                // Control buttons
                HStack(spacing: 30) {
                    // Remove bullet
                    Button(action: removeBullet) {
                        Image(systemName: "minus.circle.fill")
                            .resizable()
                            .frame(width: 44, height: 44)
                            .foregroundColor(.white)
                    }
                    .disabled(bulletCount <= 1 || isSpinning)
                    
                    // Spin button
                    Button(action: spin) {
                        Text("SPIN")
                            .font(.title.bold())
                            .foregroundColor(.white)
                            .frame(width: 120, height: 50)
                            .background(isSpinning ? Color.gray : Color.red)
                            .cornerRadius(10)
                            .shadow(radius: 5)
                    }
                    .disabled(isSpinning)
                    
                    // Add bullet
                    Button(action: addBullet) {
                        Image(systemName: "plus.circle.fill")
                            .resizable()
                            .frame(width: 44, height: 44)
                            .foregroundColor(.white)
                    }
                    .disabled(bulletCount >= 7 || isSpinning)
                }
            }
            .padding()
        }
        .onAppear {
            // Initialize the game
            setupGame(initialSetup: true)
        }
        .navigationBarBackButtonHidden(true) // Hide the default back button
    }
    
    // Game functions
    private func setupGame(initialSetup: Bool = false) {
        // Create chambers with explicit positions and angles
        chambers = (0..<8).map { position in
            let angle = Double(position) * chamberAngle
            return RevolverChamber(
                position: position,
                angleDegrees: angle,
                hasBullet: false
            )
        }
        
        // Clear all bullets first
        for i in 0..<chambers.count {
            chambers[i].hasBullet = false
        }
        
        // Distribute bullets randomly
        var bulletsToAdd = bulletCount
        while bulletsToAdd > 0 {
            let idx = Int.random(in: 0..<8)
            if !chambers[idx].hasBullet {
                chambers[idx].hasBullet = true
                bulletsToAdd -= 1
            }
        }
        
        // Only randomize player on first setup, not when adding/removing bullets
        if initialSetup {
            currentPlayerIndex = Int.random(in: 0..<config.players.count)
        }
        
        // Debug display
        debugText = "Bullets at positions: " + chambers.filter(\.hasBullet).map { String($0.position) }.joined(separator: ", ")
    }
    
    private func spin() {
        isSpinning = true
        outcome = nil
        
        // Store the name of the player who's spinning
        lastSpinPlayerName = currentPlayer.name
        
        // 1. Determine if it's a hit or miss based on probability
        let isHit = Bool.random(probability: Double(bulletCount) / 8.0)
        
        // 2. Find an appropriate chamber based on outcome
        var targetChamber: RevolverChamber?
        
        if isHit {
            // Find a chamber with a bullet
            targetChamber = chambers.filter { $0.hasBullet }.randomElement()
        } else {
            // Find a chamber without a bullet
            targetChamber = chambers.filter { !$0.hasBullet }.randomElement()
        }
        
        // Default to first chamber if something went wrong
        let chamber = targetChamber ?? chambers[0]
        
        // 3. Calculate rotation to make the target chamber align with the firing pin
        //    First do a full circle (or more), then land on the target chamber
        let fullRotations = Double.random(in: 3...5) * 360 // 3-5 full rotations
        let targetAngle = 360 - chamber.angleDegrees // Position to align with the top
        let finalRotation = rotation + fullRotations + targetAngle
        
        // Animate the rotation
        withAnimation(.easeOut(duration: 3.0)) {
            rotation = finalRotation
        }
        
        // Show outcome after spinning stops
        DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
            // Add debug info
            debugText = "Landed on chamber \(chamber.position) - Has bullet: \(chamber.hasBullet)"
            
            // Show outcome based on the predetermined hit/miss
            withAnimation(.spring()) {
                outcome = isHit ? .hit : .safe
            }
            
            isSpinning = false
            
            // Move to next player
            DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
                currentPlayerIndex = (currentPlayerIndex + 1) % config.players.count
            }
        }
    }
    
    private func addBullet() {
        guard bulletCount < 7 else { return }
        
        // Find empty chambers
        let emptyChambers = chambers.filter { !$0.hasBullet }
        if let chamberToFill = emptyChambers.randomElement(),
           let index = chambers.firstIndex(where: { $0.id == chamberToFill.id }) {
            chambers[index].hasBullet = true
            bulletCount += 1
            
            // Update debug text
            debugText = "Bullets at positions: " + chambers.filter(\.hasBullet).map { String($0.position) }.joined(separator: ", ")
        }
    }
    
    private func removeBullet() {
        guard bulletCount > 1 else { return }
        
        // Find filled chambers
        let filledChambers = chambers.filter { $0.hasBullet }
        if let chamberToEmpty = filledChambers.randomElement(),
           let index = chambers.firstIndex(where: { $0.id == chamberToEmpty.id }) {
            chambers[index].hasBullet = false
            bulletCount -= 1
            
            // Update debug text
            debugText = "Bullets at positions: " + chambers.filter(\.hasBullet).map { String($0.position) }.joined(separator: ", ")
        }
    }
}

// MARK: - Supporting Views and Extensions

struct ChamberView: View {
    let hasBullet: Bool
    
    var body: some View {
        ZStack {
            // Chamber background
            Circle()
                .fill(Color.black)
                .frame(width: 40, height: 40)
            
            // Bullet if present
            if hasBullet {
                Circle()
                    .fill(Color(hex: "#FFD700"))
                    .frame(width: 30, height: 30)
            }
        }
    }
}

private struct Triangle: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()
        let tip = CGPoint(x: rect.midX, y: rect.minY)
        let left = CGPoint(x: rect.minX, y: rect.maxY)
        let right = CGPoint(x: rect.maxX, y: rect.maxY)
        path.move(to: tip)
        path.addLine(to: left)
        path.addLine(to: right)
        path.closeSubpath()
        return path
    }
}

// Extension to add probability-based random boolean
extension Bool {
    static func random(probability: Double) -> Bool {
        return Double.random(in: 0...1) < probability
    }
}

// MARK: - Preview
#Preview {
    let mockPlayers = [
        Player(name: "Spiller 1"),
        Player(name: "Spiller 2"),
        Player(name: "Spiller 3"),
        Player(name: "Spiller 4")
    ]
    let config = GameConfiguration(players: mockPlayers, mode: .mode3)
    return GameMode3View(config: config, path: .constant([]))
        .environmentObject(PlayerManager())
}
  1. Pre-determining outcome, then finding a matching chamber:
// Determine hit/miss based on probability
let isHit = Double.random(in: 0...1) < Double(bulletCount) / 8.0

// Find chamber matching outcome
let targetChamber = isHit ? 
    chambers.filter(\.hasBullet).randomElement() :
    chambers.filter { !$0.hasBullet }.randomElement()
  1. Using explicit angles and positions for chambers:
chambers = (0..<8).map { position in
    RevolverChamber(
        position: position,
        angleDegrees: Double(position) * 45.0,
        hasBullet: false
    )
}
  1. Calculating exact rotations to land on the chosen chamber:
let fullRotations = Double.random(in: 3...5) * 360
let targetAngle = 360 - chamber.angleDegrees
let finalRotation = rotation + fullRotations + targetAngle

Solution

  • Not sure about the updated method but in the original approach the problem was that you were not taking current rotation angle into account. A simple fix would be to add a variable to know what's previous was previous rotation and use it while calculating next rotation:

    @State private var previousAngle: Double = 0
    
    private func spin() {
        ...
    
        let fullRotations = Double.random(in: 3...5) * 360 // 3-5 full rotations
        let targetAngle = 360 + previousAngle - chamber.angleDegrees // Position to align with the top
        let finalRotation = rotation + targetAngle + fullRotations
        ...
    }