// // State.swift // Bunch O' Dice // // Created by Rory Hinnen on 22/5/21. // import Foundation import SwiftUI // MARK: Extensions extension Int { func digits() -> [Int] { var digits: [Int] = [] var num = self digits.append(num % 10) repeat { num = num / 10 digits.append(num % 10) } while num >= 10 while digits.count < 3 { digits.append(0) } return digits } } // MARK: Enums enum RollState: Int { case hit case wound case armour func print() -> String { switch self { case .hit: return "⚔️" case .wound: return "🩸" case .armour: return "🛡" } } func text() -> String { switch self { case .hit: return "Hit" case .wound: return "Wound" case .armour: return "Damage" } } } enum Appearance: Int, CaseIterable, Identifiable { case light, dark, automatic var id: Int { self.rawValue } var name: String { switch self { case .light: return "Light" case .dark: return "Dark" case .automatic: return "Automatic" } } var type: ColorScheme? { switch self { case .light: return ColorScheme.light case .dark: return ColorScheme.dark case .automatic: return nil } } } enum RerollMode: Int, CaseIterable { case none, ones, failed, all var label: String { switch self { case .none: return "None" case .ones: return "1s" case .failed: return "Failed" case .all: return "All" } } } // MARK: Structs struct Event: Identifiable { let id: UUID var total: Int var pool: Dice var state: RollState var poolTotal: Int { get { return pool.one + pool.two + pool.three + pool.four + pool.five + pool.six } } init() { id = UUID() total = 0 pool = Dice() state = .hit } init(total: Int, pool: Dice, state: RollState) { self.id = UUID() self.total = total self.pool = pool self.state = state } } // MARK: Classes class Roll: ObservableObject { //Published @Published var dice = Dice() @Published var total = 0 @Published var state = RollState.hit @Published var history = [Event()] @Published var appearance = Appearance.automatic @Published var firstView = true @Published var hitCutoff = Dice.DiceType.four @Published var modifier = Dice.RollModifiers.none @Published var strength = 7 @Published var toughness = 5 @Published var woundModifier = Dice.RollModifiers.none @Published var mortal = false @Published var armourPenetration = 0 @Published var armourSave = 3 @Published var invulnerable = false @Published var currentPage: String @Published var hitRerollMode = RerollMode.none @Published var woundRerollMode = RerollMode.none @Published var saveRerollMode = RerollMode.none @Published var hitHasRerolled = false @Published var woundHasRerolled = false @Published var saveHasRerolled = false // Snapshot of dice before the current phase's reroll was applied. // Restored when the user switches reroll modes, so the new mode // always applies to the original un-rerolled results. @Published var preRerollDice: Dice? = nil @Published var blowupUsed = false @Published var torrent = false @Published var psychic = false @Published var sustainedHits = false @Published var sustainedHitsX = 1 @Published var lethalHits = false @Published var devastatingWounds = false @Published var antiX = false @Published var antiXThreshold = 6 @Published var autoWoundsCarried = 0 @Published var mortalWoundsTotal = 0 // calculated variables var totalDigits: [Int] { get { return total.digits() } } var result: Int { get { switch self.state { case .hit: return hit case .wound: return wound case .armour: return save } } } var woundCutoff: Int { get { if Float(self.strength) / Float(self.toughness) >= 2.0 { return 2 } else if Float(self.strength) / Float(self.toughness) > 1.0 { return 3 } else if Float(self.strength) / Float(self.toughness) == 1 { return 4 } else if Float(self.strength) / Float(self.toughness) <= 0.5 { return 6 } else { return 5 } } } var cutoff: Dice.DiceType { get { switch self.state { case .hit: return hitCutoff case .wound: return Dice.DiceType(rawValue: woundCutoff) ?? .four case .armour: return Dice.DiceType(rawValue: self.armourSave) ?? .one } } } var hit: Int { get { if torrent { return total } let mod = psychic ? Dice.RollModifiers.none.rawValue : modifier.rawValue return self.dice.hit(cutoff: self.hitCutoff.rawValue, modifier: mod) } } var wound: Int { get { let cutoff = self.mortal ? 2 : self.woundCutoff return self.dice.wound(cutoff: cutoff, modifier: woundModifier.rawValue) } } var save: Int { get { return self.dice.armour(save: self.armourSave, armourPenetration: self.armourPenetration, invulnerable: self.invulnerable) } } var critHitCount: Int { dice.six } var antiXCount: Int { guard antiX else { return 0 } var count = 0 for face in antiXThreshold...6 { if let dt = Dice.DiceType(rawValue: face) { count += dice.value(which: dt) } } return count } var critWoundCount: Int { devastatingWounds ? dice.six : 0 } // Deduplicated count of hit-phase crits that auto-wound (Lethal Hits and/or Anti-X). // Anti-X is always the superset when its threshold <= 6, so it takes precedence. // Gated off during Torrent since there are no hit dice to count natural rolls on. var autoWoundCrits: Int { guard !torrent else { return 0 } if antiX { return antiXCount } if lethalHits { return dice.six } return 0 } var currentRerollMode: RerollMode { switch state { case .hit: return hitRerollMode case .wound: return woundRerollMode case .armour: return saveRerollMode } } var currentHasRerolled: Bool { switch state { case .hit: return hitHasRerolled case .wound: return woundHasRerolled case .armour: return saveHasRerolled } } // init init() { if !UserDefaults.standard.bool(forKey: "didLaunchBefore") { UserDefaults.standard.set(true, forKey: "didLaunchBefore") currentPage = "OnboardingView" } else { currentPage = "Bunch" } } // modifier controllers // MARK: wound controllers func addStrength() { self.strength += 1 } func loseStrength() { if self.strength > 1 { self.strength -= 1 } } func addToughness() { self.toughness += 1 } func loseToughness() { if self.toughness > 1 { self.toughness -= 1 } } // MARK: armour controllers func addArmour() { if self.armourSave < 6 { self.armourSave += 1 } } func loseArmour() { if self.armourSave > 1 { self.armourSave -= 1 } } func addPenetration() { self.armourPenetration += 1 } func losePenetration() { self.armourPenetration -= 1 } // MARK: total pool func incrementDigits(place: Int) { if totalDigits[place] < 9 { self.total += Int(pow(10.0, Float(place))) } } func decrementDigits(place: Int) { if totalDigits[place] > 0 { self.total -= Int(pow(10.0, Float(place))) } } // MARK: utility function func incrementState() { var x = 0 switch self.state { case .hit: x = self.hit case .wound: x = self.wound case .armour: x = self.armourSave } if self.history[0].total == 0 { self.history[0] = Event(total: x, pool: self.dice, state: self.state) } else { self.history.insert(Event(total: x, pool: self.dice, state: self.state), at: 0) } switch self.state { case .hit: let crits = autoWoundCrits let sustained = (sustainedHits && !torrent) ? dice.six * sustainedHitsX : 0 autoWoundsCarried = min(crits, hit) self.total = max(0, hit - autoWoundsCarried) + sustained self.state = .wound woundHasRerolled = false preRerollDice = nil case .wound: let mortals = critWoundCount mortalWoundsTotal = mortals self.total = max(0, wound + autoWoundsCarried - mortals) autoWoundsCarried = 0 self.state = .armour saveHasRerolled = false preRerollDice = nil case .armour: self.total = save self.state = .hit hitHasRerolled = false preRerollDice = nil blowupUsed = false mortalWoundsTotal = 0 } self.dice.roll(howMany: self.total) } // MARK: extra func resetPool() { dice.roll(howMany: total) hitHasRerolled = false preRerollDice = nil blowupUsed = false } func applyBlowup(face: Dice.DiceType) { guard !blowupUsed else { return } switch face { case .six: dice.blowup(dice: .six) case .five: dice.blowup(dice: .five) default: break } blowupUsed = true } func applyReroll() { let mode: RerollMode let cutoff: Int switch state { case .hit: mode = hitRerollMode cutoff = hitCutoff.rawValue case .wound: mode = woundRerollMode cutoff = woundCutoff case .armour: mode = saveRerollMode cutoff = invulnerable ? armourSave : (armourSave + armourPenetration) } guard mode != .none else { return } // If a reroll was already applied this phase, restore the original dice first // so the new mode always operates on the un-rerolled results. if let snapshot = preRerollDice { dice = snapshot } else { preRerollDice = dice } switch mode { case .none: break case .ones: dice.reroll(dice: .one) case .failed: dice.rerollBelow(cutoff: cutoff) case .all: dice.roll(howMany: total) } switch state { case .hit: hitHasRerolled = true case .wound: woundHasRerolled = true case .armour: saveHasRerolled = true } } // Restores dice to pre-reroll state when the user selects None. func undoReroll() { guard let snapshot = preRerollDice else { return } dice = snapshot preRerollDice = nil switch state { case .hit: hitHasRerolled = false case .wound: woundHasRerolled = false case .armour: saveHasRerolled = false } } func add(howMuch: Int) { var newTotal: Int = 0 newTotal = self.total + howMuch if newTotal < 0 { newTotal = 0 } self.total = newTotal } }