diff --git a/DieHammer.xcodeproj/project.pbxproj b/DieHammer.xcodeproj/project.pbxproj index 7ae6662..c889815 100644 --- a/DieHammer.xcodeproj/project.pbxproj +++ b/DieHammer.xcodeproj/project.pbxproj @@ -18,7 +18,6 @@ 981AD0D927C30E32005A6492 /* Bunch_O__DiceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981AD0D827C30E32005A6492 /* Bunch_O__DiceUITests.swift */; }; 981AD0DB27C30E32005A6492 /* Bunch_O__DiceUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981AD0DA27C30E32005A6492 /* Bunch_O__DiceUITestsLaunchTests.swift */; }; 981AD0E927C31208005A6492 /* Dice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981AD0E827C31208005A6492 /* Dice.swift */; }; - 98225DE12A3527160019DE8E /* dice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98225DE02A3527160019DE8E /* dice.swift */; }; 98325A2E2839BE4B0006DF4B /* RollState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98325A2D2839BE4B0006DF4B /* RollState.swift */; }; 98745CA62870CC670031166A /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98745CA52870CC670031166A /* SettingsView.swift */; }; 988B150F2854E9170082C2C5 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 988B150E2854E9160082C2C5 /* HistoryView.swift */; }; @@ -60,7 +59,6 @@ 981AD0D827C30E32005A6492 /* Bunch_O__DiceUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bunch_O__DiceUITests.swift; sourceTree = ""; }; 981AD0DA27C30E32005A6492 /* Bunch_O__DiceUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bunch_O__DiceUITestsLaunchTests.swift; sourceTree = ""; }; 981AD0E827C31208005A6492 /* Dice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dice.swift; sourceTree = ""; }; - 98225DE02A3527160019DE8E /* dice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = dice.swift; sourceTree = ""; }; 98325A2D2839BE4B0006DF4B /* RollState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RollState.swift; sourceTree = ""; }; 98745CA52870CC670031166A /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 988997FF285814AB00B07E09 /* DieHammer.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = DieHammer.plist; sourceTree = SOURCE_ROOT; }; @@ -132,9 +130,6 @@ 7692E8422BD55CDE005BDD76 /* PrivacyInfo.xcprivacy */, 988997FF285814AB00B07E09 /* DieHammer.plist */, 981AD0BD27C30E31005A6492 /* DieHammerApp.swift */, - 981AD0BF27C30E31005A6492 /* ContentView.swift */, - 988B1518285566AD0082C2C5 /* OnboardingView.swift */, - 980EF453285E2BAD002FE8A0 /* MainView.swift */, 981AD0EC27C3356D005A6492 /* Views */, 981AD0E727C311DA005A6492 /* Model */, 98E534D2285653E700D5ABB4 /* Utilities */, @@ -186,7 +181,9 @@ isa = PBXGroup; children = ( 98D0CD192856E3850047DC1B /* Modals */, - 98225DE02A3527160019DE8E /* dice.swift */, + 988B1518285566AD0082C2C5 /* OnboardingView.swift */, + 981AD0BF27C30E31005A6492 /* ContentView.swift */, + 980EF453285E2BAD002FE8A0 /* MainView.swift */, ); path = Views; sourceTree = ""; @@ -349,7 +346,6 @@ 981AD0E927C31208005A6492 /* Dice.swift in Sources */, 98F001D5284F86F1004F5140 /* AddToPool.swift in Sources */, 988B150F2854E9170082C2C5 /* HistoryView.swift in Sources */, - 98225DE12A3527160019DE8E /* dice.swift in Sources */, 981AD0BE27C30E31005A6492 /* DieHammerApp.swift in Sources */, 980EF454285E2BAD002FE8A0 /* MainView.swift in Sources */, 98325A2E2839BE4B0006DF4B /* RollState.swift in Sources */, diff --git a/DieHammer.xcodeproj/xcshareddata/xcschemes/Bunch O' Dice.xcscheme b/DieHammer.xcodeproj/xcshareddata/xcschemes/Bunch O' Dice.xcscheme index 35cfdee..c4c8ba7 100644 --- a/DieHammer.xcodeproj/xcshareddata/xcschemes/Bunch O' Dice.xcscheme +++ b/DieHammer.xcodeproj/xcshareddata/xcschemes/Bunch O' Dice.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> + shouldUseLaunchSchemeArgsEnv = "YES"> diff --git a/DieHammer/Model/RollState.swift b/DieHammer/Model/RollState.swift index 82528c7..6887cb0 100644 --- a/DieHammer/Model/RollState.swift +++ b/DieHammer/Model/RollState.swift @@ -151,6 +151,11 @@ class Roll: ObservableObject { @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 @@ -209,17 +214,7 @@ class Roll: ObservableObject { case .hit: return hitCutoff case .wound: - if Float(self.strength) / Float(self.toughness) >= 2.0 { - return .two - } else if Float(self.strength) / Float(self.toughness) > 1.0 { - return .three - } else if Float(self.strength) / Float(self.toughness) == 1 { - return .four - } else if Float(self.strength) / Float(self.toughness) <= 0.5 { - return .six - } else { - return .five - } + return Dice.DiceType(rawValue: woundCutoff) ?? .four case .armour: return Dice.DiceType(rawValue: self.armourSave) ?? .one } @@ -376,6 +371,7 @@ class Roll: ObservableObject { self.total = max(0, hit - autoWoundsCarried) + sustained self.state = .wound woundHasRerolled = false + preRerollDice = nil case .wound: let mortals = critWoundCount mortalWoundsTotal = mortals @@ -383,10 +379,12 @@ class Roll: ObservableObject { 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 } @@ -397,6 +395,7 @@ class Roll: ObservableObject { func resetPool() { dice.roll(howMany: total) hitHasRerolled = false + preRerollDice = nil blowupUsed = false } @@ -414,8 +413,6 @@ class Roll: ObservableObject { } func applyReroll() { - guard !currentHasRerolled else { return } - let mode: RerollMode let cutoff: Int @@ -431,9 +428,19 @@ class Roll: ObservableObject { 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: - return + break case .ones: dice.reroll(dice: .one) case .failed: @@ -449,6 +456,18 @@ class Roll: ObservableObject { } } + // 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 diff --git a/DieHammer/Utilities/ViewUtils.swift b/DieHammer/Utilities/ViewUtils.swift index 7b33c5b..09ada7d 100644 --- a/DieHammer/Utilities/ViewUtils.swift +++ b/DieHammer/Utilities/ViewUtils.swift @@ -31,3 +31,9 @@ extension CaseIterable where Self: Equatable { self = allCases[nextIndex == allCases.endIndex ? allCases.startIndex : nextIndex] } } + +extension View { + func disabledWithOpacity(_ condition: Bool) -> some View { + self.disabled(condition).opacity(condition ? 0.4 : 1.0) + } +} diff --git a/DieHammer/ContentView.swift b/DieHammer/Views/ContentView.swift similarity index 100% rename from DieHammer/ContentView.swift rename to DieHammer/Views/ContentView.swift diff --git a/DieHammer/MainView.swift b/DieHammer/Views/MainView.swift similarity index 68% rename from DieHammer/MainView.swift rename to DieHammer/Views/MainView.swift index efb7c2d..0ce625b 100644 --- a/DieHammer/MainView.swift +++ b/DieHammer/Views/MainView.swift @@ -53,6 +53,49 @@ struct MainView: View { } } + @ViewBuilder private func rerollPicker(mode: Binding) -> some View { + let actionBinding = Binding( + get: { mode.wrappedValue }, + set: { newValue in + mode.wrappedValue = newValue + if newValue != .none { + current.applyReroll() + } else { + current.undoReroll() + } + } + ) + HStack { + Text("Reroll") + Picker("", selection: actionBinding) { + ForEach(RerollMode.allCases, id: \.self) { m in + Text(m.label).tag(m) + } + } + .pickerStyle(.segmented) + } + .padding(.horizontal) + } + + @ViewBuilder private func statStepper( + top: String, + value: String, + bottom: String, + increment: @escaping () -> Void, + decrement: @escaping () -> Void + ) -> some View { + VStack { + Text(top) + Group { + Text("▲").gesture(TapGesture().onEnded { increment() }) + Text(value) + Text("▼").gesture(TapGesture().onEnded { decrement() }) + } + .font(.system(size: controlSize)) + Text(bottom) + } + } + @ViewBuilder func buttons(color: CGColor, label: String, value: Int) -> some View { let frameSize = 120.0 let fontSize = 35.0 @@ -79,25 +122,13 @@ struct MainView: View { VStack { HStack(alignment: .top) { Spacer() - VStack { - Text("Armour") - Group { - Text("▲") - .gesture(TapGesture() .onEnded { - current.addPenetration() - }) - Text( - (current.armourPenetration > 0 ? - "+" : "") + - String(current.armourPenetration)) - Text("▼") - .gesture(TapGesture() .onEnded { - current.losePenetration() - }) - } - .font(.system(size: controlSize)) - Text("Penetration") - } + statStepper( + top: "Armour", + value: (current.armourPenetration > 0 ? "+" : "") + String(current.armourPenetration), + bottom: "Penetration", + increment: { current.addPenetration() }, + decrement: { current.losePenetration() } + ) Spacer() VStack { Text("Invulnerable") @@ -106,21 +137,7 @@ struct MainView: View { } Spacer() } - HStack { - Text("Reroll") - Picker("", selection: $current.saveRerollMode) { - ForEach(RerollMode.allCases, id: \.self) { mode in - Text(mode.label).tag(mode) - } - } - .pickerStyle(.segmented) - Button("🎲") { - current.applyReroll() - } - .disabled(current.saveRerollMode == .none || current.saveHasRerolled) - .font(.system(size: controlSize)) - } - .padding(.horizontal) + rerollPicker(mode: $current.saveRerollMode) if current.mortalWoundsTotal > 0 { HStack { Text("Mortal wounds: \(current.mortalWoundsTotal) (bypassed saves)") @@ -149,74 +166,32 @@ struct MainView: View { } HStack(alignment: .top) { Spacer() - VStack { - Text("Attack") - Group { - Text("▲") - .gesture(TapGesture() .onEnded { - current.addStrength() - }) - Text(String(current.strength)) - Text("▼") - .gesture(TapGesture() .onEnded { - current.loseStrength() - }) - }.font(.system(size: controlSize)) - Text("Strength") - } + statStepper( + top: "Attack", + value: String(current.strength), + bottom: "Strength", + increment: { current.addStrength() }, + decrement: { current.loseStrength() } + ) Spacer() - VStack { - Text("Toughness") - Group { - Text("▲") - .gesture(TapGesture() .onEnded { - current.addToughness() - }) - Text(String(current.toughness)) - Text("▼") - .gesture(TapGesture() .onEnded { - current.loseToughness() - }) - }.font(.system(size: controlSize)) - Text(" ") - } + statStepper( + top: "Toughness", + value: String(current.toughness), + bottom: " ", + increment: { current.addToughness() }, + decrement: { current.loseToughness() } + ) Spacer() - VStack { - Text("Wound") - .multilineTextAlignment(.center) - Group { - Text("▲") - .gesture(TapGesture() .onEnded { - current.woundModifier = current.woundModifier.add() - }) - Text( - (current.woundModifier.rawValue > 0 ? - "+" : "") + - String(current.woundModifier.rawValue)) - Text("▼") - .gesture(TapGesture() .onEnded { - current.woundModifier = current.woundModifier.remove() - }) - }.font(.system(size: controlSize)) - Text("Modifier") - } + statStepper( + top: "Wound", + value: (current.woundModifier.rawValue > 0 ? "+" : "") + String(current.woundModifier.rawValue), + bottom: "Modifier", + increment: { current.woundModifier = current.woundModifier.add() }, + decrement: { current.woundModifier = current.woundModifier.remove() } + ) Spacer() } - HStack { - Text("Reroll") - Picker("", selection: $current.woundRerollMode) { - ForEach(RerollMode.allCases, id: \.self) { mode in - Text(mode.label).tag(mode) - } - } - .pickerStyle(.segmented) - Button("🎲") { - current.applyReroll() - } - .disabled(current.woundRerollMode == .none || current.woundHasRerolled) - .font(.system(size: controlSize)) - } - .padding(.horizontal) + rerollPicker(mode: $current.woundRerollMode) VStack(spacing: 8) { HStack { Toggle("Devastating Wounds", isOn: $current.devastatingWounds) @@ -240,26 +215,14 @@ struct MainView: View { VStack { HStack(alignment: .top) { Spacer() - VStack { - Text("Hit") - .multilineTextAlignment(.center) - Group { - Text("▲") - .gesture(TapGesture() .onEnded { - current.modifier = current.modifier.add() - }) - Text((current.modifier.rawValue > 0 ? "+" : "") + - String(current.modifier.rawValue)) - Text("▼") - .gesture(TapGesture() .onEnded { - current.modifier = current.modifier.remove() - }) - } - .font(.system(size: controlSize)) - Text("Modifier") - } - .disabled(current.torrent || current.psychic) - .opacity((current.torrent || current.psychic) ? 0.4 : 1.0) + statStepper( + top: "Hit", + value: (current.modifier.rawValue > 0 ? "+" : "") + String(current.modifier.rawValue), + bottom: "Modifier", + increment: { current.modifier = current.modifier.add() }, + decrement: { current.modifier = current.modifier.remove() } + ) + .disabledWithOpacity(current.torrent || current.psychic) Spacer() VStack { Text("Blow Up") @@ -276,45 +239,38 @@ struct MainView: View { .padding() .font(.system(size: controlSize)) } - .disabled(current.blowupUsed || current.torrent) - .opacity((current.blowupUsed || current.torrent) ? 0.4 : 1.0) + .disabledWithOpacity(current.blowupUsed || current.torrent) Spacer() } + rerollPicker(mode: $current.hitRerollMode) + .disabledWithOpacity(current.torrent) HStack { - Text("Reroll") - Picker("", selection: $current.hitRerollMode) { - ForEach(RerollMode.allCases, id: \.self) { mode in - Text(mode.label).tag(mode) - } - } - .pickerStyle(.segmented) - Button("🎲") { - current.applyReroll() - } - .disabled(current.hitRerollMode == .none || current.hitHasRerolled) - .font(.system(size: controlSize)) - } - .padding(.horizontal) - .disabled(current.torrent) - .opacity(current.torrent ? 0.4 : 1.0) - HStack { - Toggle("Torrent", isOn: $current.torrent) Spacer() - Toggle("Psychic", isOn: $current.psychic) - .disabled(current.torrent) - .opacity(current.torrent ? 0.4 : 1.0) + VStack { + Text("Torrent") + Toggle("", isOn: $current.torrent).labelsHidden() + } + Spacer() + VStack { + Text("Psychic") + Toggle("", isOn: $current.psychic).labelsHidden() + .disabledWithOpacity(current.torrent) + } + Spacer() + VStack { + Text("Lethal Hits") + Toggle("", isOn: $current.lethalHits).labelsHidden() + } + Spacer() } .padding(.horizontal) VStack(spacing: 8) { HStack { - Toggle("Lethal Hits", isOn: $current.lethalHits) - Spacer() Toggle("Sust. Hits", isOn: $current.sustainedHits) - } - if current.sustainedHits { - HStack { + .fixedSize() + Spacer() + if current.sustainedHits { Text("X:") - Spacer() Text("▲") .gesture(TapGesture().onEnded { if current.sustainedHitsX < 6 { current.sustainedHitsX += 1 } @@ -329,8 +285,9 @@ struct MainView: View { } HStack { Toggle("Anti-X", isOn: $current.antiX) + .fixedSize() + Spacer() if current.antiX { - Spacer() Text("▲") .gesture(TapGesture().onEnded { if current.antiXThreshold < 6 { current.antiXThreshold += 1 } diff --git a/DieHammer/OnboardingView.swift b/DieHammer/Views/OnboardingView.swift similarity index 100% rename from DieHammer/OnboardingView.swift rename to DieHammer/Views/OnboardingView.swift diff --git a/DieHammer/Views/dice.swift b/DieHammer/Views/dice.swift deleted file mode 100644 index 4b63d97..0000000 --- a/DieHammer/Views/dice.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// dice.swift -// Bunch O' Dice -// -// Created by Rory Hinnen on 6/10/23. -// - -import SwiftUI - -struct dice: View { - var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) - } -} - -struct dice_Previews: PreviewProvider { - static var previews: some View { - dice() - } -} diff --git a/DieHammerTests/Bunch_O__DiceTests.swift b/DieHammerTests/Bunch_O__DiceTests.swift index 9572efe..1e782c6 100644 --- a/DieHammerTests/Bunch_O__DiceTests.swift +++ b/DieHammerTests/Bunch_O__DiceTests.swift @@ -227,20 +227,30 @@ struct RerollTests { #expect(roll.hitHasRerolled == true) } - @Test("applyReroll: guard blocks second call") - func applyRerollRespectsFlagAlreadySet() { + @Test("applyReroll: re-applying restores snapshot and rerolls with new mode") + func applyRerollChangeModeRestoresSnapshot() { let roll = Roll() roll.state = .hit + roll.hitCutoff = .four + roll.hitRerollMode = .ones + roll.dice.one = 3; roll.dice.six = 2 + roll.total = 5 + + // First reroll: reroll the 3 ones + roll.applyReroll() + #expect(roll.hitHasRerolled == true) + #expect(roll.preRerollDice != nil) + let afterFirst = roll.dice.one + roll.dice.two + roll.dice.three + + roll.dice.four + roll.dice.five + roll.dice.six + #expect(afterFirst == 5) + + // Switch to 'all' mode — should restore snapshot then reroll all roll.hitRerollMode = .all - roll.hitHasRerolled = true // simulate already used - roll.dice.one = 5 // known state - roll.applyReroll() // should be blocked by guard - #expect(roll.dice.one == 5) - #expect(roll.dice.two == 0) - #expect(roll.dice.three == 0) - #expect(roll.dice.four == 0) - #expect(roll.dice.five == 0) - #expect(roll.dice.six == 0) + roll.applyReroll() + let afterSecond = roll.dice.one + roll.dice.two + roll.dice.three + + roll.dice.four + roll.dice.five + roll.dice.six + #expect(afterSecond == 5) + #expect(roll.hitHasRerolled == true) } @Test("applyReroll: only sets flag for the active phase") @@ -269,6 +279,36 @@ struct RerollTests { #expect(roll.dice.six >= 2) // dice above cutoff are untouched; rerolls may also produce sixes } + @Test("undoReroll: restores dice and clears flag") + func undoRerollRestoresDice() { + let roll = Roll() + roll.state = .hit + roll.hitCutoff = .four + roll.hitRerollMode = .ones + roll.dice.one = 3; roll.dice.six = 2 + roll.total = 5 + + roll.applyReroll() + #expect(roll.hitHasRerolled == true) + + roll.undoReroll() + #expect(roll.hitHasRerolled == false) + #expect(roll.preRerollDice == nil) + // Dice should be restored to original state + #expect(roll.dice.one == 3) + #expect(roll.dice.six == 2) + } + + @Test("undoReroll: no-op when no reroll has been applied") + func undoRerollNoop() { + let roll = Roll() + roll.state = .hit + roll.dice.one = 3 + roll.undoReroll() // nothing to undo + #expect(roll.dice.one == 3) + #expect(roll.hitHasRerolled == false) + } + @Test("resetPool: clears hitHasRerolled flag") func resetPoolClearsFlag() { let roll = Roll()