refactor into smaller units

This commit is contained in:
Rory Hinnen
2026-09-12 11:25:34 -05:00
parent 4eb7494d08
commit 1507f8b8c6
14 changed files with 637 additions and 415 deletions
+50 -2
View File
@@ -7,6 +7,16 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
7619A1543055ABA300E6F277 /* RerollPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7619A1533055ABA300E6F277 /* RerollPickerView.swift */; };
7619A1563055ABAF00E6F277 /* HitCalculatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7619A1553055ABAF00E6F277 /* HitCalculatorView.swift */; };
7619A1583055ABB400E6F277 /* WoundCalculatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7619A1573055ABB400E6F277 /* WoundCalculatorView.swift */; };
7619A15A3055ABB600E6F277 /* ArmourCalculatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7619A1593055ABB600E6F277 /* ArmourCalculatorView.swift */; };
7619A15C3055AF2400E6F277 /* StatePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7619A15B3055AF2400E6F277 /* StatePickerView.swift */; };
7619A15E3055AF2A00E6F277 /* RollSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7619A15D3055AF2A00E6F277 /* RollSummaryView.swift */; };
7619A1603055AF3100E6F277 /* DieView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7619A15F3055AF3100E6F277 /* DieView.swift */; };
7619A1623055AF3300E6F277 /* DiePoolView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7619A1613055AF3300E6F277 /* DiePoolView.swift */; };
7619A1643055AF3900E6F277 /* InfoAreaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7619A1633055AF3900E6F277 /* InfoAreaView.swift */; };
7619A1663055AF3D00E6F277 /* ButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7619A1653055AF3D00E6F277 /* ButtonView.swift */; };
7692E8432BD55CDF005BDD76 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7692E8422BD55CDE005BDD76 /* PrivacyInfo.xcprivacy */; }; 7692E8432BD55CDF005BDD76 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7692E8422BD55CDE005BDD76 /* PrivacyInfo.xcprivacy */; };
76AB19823050A4B500BF7B9D /* DieHammerEngine in Frameworks */ = {isa = PBXBuildFile; productRef = 76AB19813050A4B500BF7B9D /* DieHammerEngine */; }; 76AB19823050A4B500BF7B9D /* DieHammerEngine in Frameworks */ = {isa = PBXBuildFile; productRef = 76AB19813050A4B500BF7B9D /* DieHammerEngine */; };
980EF454285E2BAD002FE8A0 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980EF453285E2BAD002FE8A0 /* MainView.swift */; }; 980EF454285E2BAD002FE8A0 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980EF453285E2BAD002FE8A0 /* MainView.swift */; };
@@ -45,6 +55,16 @@
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
7619A1533055ABA300E6F277 /* RerollPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RerollPickerView.swift; sourceTree = "<group>"; };
7619A1553055ABAF00E6F277 /* HitCalculatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HitCalculatorView.swift; sourceTree = "<group>"; };
7619A1573055ABB400E6F277 /* WoundCalculatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WoundCalculatorView.swift; sourceTree = "<group>"; };
7619A1593055ABB600E6F277 /* ArmourCalculatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArmourCalculatorView.swift; sourceTree = "<group>"; };
7619A15B3055AF2400E6F277 /* StatePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatePickerView.swift; sourceTree = "<group>"; };
7619A15D3055AF2A00E6F277 /* RollSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RollSummaryView.swift; sourceTree = "<group>"; };
7619A15F3055AF3100E6F277 /* DieView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DieView.swift; sourceTree = "<group>"; };
7619A1613055AF3300E6F277 /* DiePoolView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiePoolView.swift; sourceTree = "<group>"; };
7619A1633055AF3900E6F277 /* InfoAreaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoAreaView.swift; sourceTree = "<group>"; };
7619A1653055AF3D00E6F277 /* ButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonView.swift; sourceTree = "<group>"; };
768C2F3A304F04E6002FEA9A /* Goals.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = Goals.md; sourceTree = "<group>"; }; 768C2F3A304F04E6002FEA9A /* Goals.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = Goals.md; sourceTree = "<group>"; };
7692E8422BD55CDE005BDD76 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; 7692E8422BD55CDE005BDD76 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
980EF453285E2BAD002FE8A0 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; }; 980EF453285E2BAD002FE8A0 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
@@ -95,6 +115,23 @@
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
7619A1523055AA4500E6F277 /* Elements */ = {
isa = PBXGroup;
children = (
7619A1533055ABA300E6F277 /* RerollPickerView.swift */,
7619A1553055ABAF00E6F277 /* HitCalculatorView.swift */,
7619A1573055ABB400E6F277 /* WoundCalculatorView.swift */,
7619A1593055ABB600E6F277 /* ArmourCalculatorView.swift */,
7619A15B3055AF2400E6F277 /* StatePickerView.swift */,
7619A15D3055AF2A00E6F277 /* RollSummaryView.swift */,
7619A15F3055AF3100E6F277 /* DieView.swift */,
7619A1613055AF3300E6F277 /* DiePoolView.swift */,
7619A1633055AF3900E6F277 /* InfoAreaView.swift */,
7619A1653055AF3D00E6F277 /* ButtonView.swift */,
);
path = Elements;
sourceTree = "<group>";
};
76AB19803050A4B500BF7B9D /* Frameworks */ = { 76AB19803050A4B500BF7B9D /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -180,6 +217,7 @@
981AD0EC27C3356D005A6492 /* Views */ = { 981AD0EC27C3356D005A6492 /* Views */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
7619A1523055AA4500E6F277 /* Elements */,
98D0CD192856E3850047DC1B /* Modals */, 98D0CD192856E3850047DC1B /* Modals */,
988B1518285566AD0082C2C5 /* OnboardingView.swift */, 988B1518285566AD0082C2C5 /* OnboardingView.swift */,
981AD0BF27C30E31005A6492 /* ContentView.swift */, 981AD0BF27C30E31005A6492 /* ContentView.swift */,
@@ -341,16 +379,26 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
98E534D42856540A00D5ABB4 /* ViewUtils.swift in Sources */, 98E534D42856540A00D5ABB4 /* ViewUtils.swift in Sources */,
7619A1643055AF3900E6F277 /* InfoAreaView.swift in Sources */,
7619A15A3055ABB600E6F277 /* ArmourCalculatorView.swift in Sources */,
981AD0C027C30E31005A6492 /* ContentView.swift in Sources */, 981AD0C027C30E31005A6492 /* ContentView.swift in Sources */,
988B1519285566AD0082C2C5 /* OnboardingView.swift in Sources */, 988B1519285566AD0082C2C5 /* OnboardingView.swift in Sources */,
981AD0E927C31208005A6492 /* Dice.swift in Sources */, 981AD0E927C31208005A6492 /* Dice.swift in Sources */,
7619A1603055AF3100E6F277 /* DieView.swift in Sources */,
7619A1583055ABB400E6F277 /* WoundCalculatorView.swift in Sources */,
98F001D5284F86F1004F5140 /* AddToPool.swift in Sources */, 98F001D5284F86F1004F5140 /* AddToPool.swift in Sources */,
988B150F2854E9170082C2C5 /* HistoryView.swift in Sources */, 988B150F2854E9170082C2C5 /* HistoryView.swift in Sources */,
7619A1543055ABA300E6F277 /* RerollPickerView.swift in Sources */,
981AD0BE27C30E31005A6492 /* DieHammerApp.swift in Sources */, 981AD0BE27C30E31005A6492 /* DieHammerApp.swift in Sources */,
7619A15E3055AF2A00E6F277 /* RollSummaryView.swift in Sources */,
7619A1623055AF3300E6F277 /* DiePoolView.swift in Sources */,
7619A15C3055AF2400E6F277 /* StatePickerView.swift in Sources */,
980EF454285E2BAD002FE8A0 /* MainView.swift in Sources */, 980EF454285E2BAD002FE8A0 /* MainView.swift in Sources */,
7619A1563055ABAF00E6F277 /* HitCalculatorView.swift in Sources */,
98325A2E2839BE4B0006DF4B /* RollState.swift in Sources */, 98325A2E2839BE4B0006DF4B /* RollState.swift in Sources */,
988B1517285565610082C2C5 /* Onboarding.swift in Sources */, 988B1517285565610082C2C5 /* Onboarding.swift in Sources */,
98745CA62870CC670031166A /* SettingsView.swift in Sources */, 98745CA62870CC670031166A /* SettingsView.swift in Sources */,
7619A1663055AF3D00E6F277 /* ButtonView.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -528,7 +576,7 @@
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;
IPHONEOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_IPHONEOS_DEPLOYMENT_TARGET)"; IPHONEOS_DEPLOYMENT_TARGET = 26.6;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -560,7 +608,7 @@
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;
IPHONEOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_IPHONEOS_DEPLOYMENT_TARGET)"; IPHONEOS_DEPLOYMENT_TARGET = 26.6;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
+2 -2
View File
@@ -186,7 +186,7 @@ class Roll: ObservableObject {
case .wound: case .wound:
return wound return wound
case .armour: case .armour:
return save return save + mortalWoundsTotal
} }
} }
} }
@@ -381,7 +381,7 @@ class Roll: ObservableObject {
saveHasRerolled = false saveHasRerolled = false
preRerollDice = nil preRerollDice = nil
case .armour: case .armour:
self.total = save self.total = save + mortalWoundsTotal
self.state = .hit self.state = .hit
hitHasRerolled = false hitHasRerolled = false
preRerollDice = nil preRerollDice = nil
+39
View File
@@ -37,3 +37,42 @@ extension View {
self.disabled(condition).opacity(condition ? 0.4 : 1.0) self.disabled(condition).opacity(condition ? 0.4 : 1.0)
} }
} }
struct ToggleChip: View {
let label: String
@Binding var isOn: Bool
var body: some View {
Button(action: { isOn.toggle() }) {
Text(label)
.fontWeight(isOn ? .semibold : .regular)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.foregroundColor(isOn ? Color(UIColor.systemBackground) : .primary)
.background(isOn ? Color.primary : Color.clear)
.clipShape(Capsule())
.overlay(Capsule().stroke(Color.primary, lineWidth: 1))
}
}
}
struct StatStepper: View {
let top: String
let value: String
let bottom: String
let increment: () -> Void
let decrement: () -> Void
var body: some View {
VStack {
Text(top)
Group {
Text("▲").gesture(TapGesture().onEnded { increment() })
Text(value)
Text("▼").gesture(TapGesture().onEnded { decrement() })
}
.font(.system(size: 30))
Text(bottom)
}
}
}
@@ -0,0 +1,39 @@
//
// ArmourCalculatorView.swift
// Bunch O' Dice
//
import SwiftUI
struct ArmourCalculatorView: View {
@EnvironmentObject var current: Roll
var body: some View {
VStack {
HStack(alignment: .top) {
Spacer()
StatStepper(
top: "Armour",
value: (current.armourPenetration > 0 ? "+" : "") + String(current.armourPenetration),
bottom: "Penetration",
increment: { current.addPenetration() },
decrement: { current.losePenetration() }
)
Spacer()
ToggleChip(label: "Invulnerable", isOn: $current.invulnerable)
Spacer()
}
RerollPickerView(mode: $current.saveRerollMode)
}
.padding()
}
}
#Preview {
ArmourCalculatorView()
.environmentObject({
let r = Roll()
r.state = .armour
return r
}())
}
+41
View File
@@ -0,0 +1,41 @@
//
// ButtonView.swift
// Bunch O' Dice
//
import SwiftUI
struct ButtonView: View {
let color: CGColor
let label: String
let value: Int
var body: some View {
let frameSize = 120.0
let fontSize = 35.0
let blurSize = 5.0
ZStack {
Circle()
.fill(Color(color))
.frame(width: frameSize, height: frameSize)
.blur(radius: blurSize)
VStack {
Text(String(value))
.fontWeight(.black)
Text(label)
.font(.system(size: 20))
}
}
.foregroundColor(.primary)
.font(.system(size: fontSize))
}
}
#Preview {
ButtonView(
color: CGColor(red: 1.0, green: 0.3, blue: 0.3, alpha: 1.0),
label: "Hits",
value: 12
)
}
@@ -0,0 +1,21 @@
//
// DiePoolView.swift
// Bunch O' Dice
//
import SwiftUI
struct DiePoolView: View {
var body: some View {
HStack(spacing: 0) {
ForEach(Dice.DiceType.allCases, id: \.rawValue) { i in
DieView(whichDie: i)
}
}
}
}
#Preview {
DiePoolView()
.environmentObject(Roll())
}
+43
View File
@@ -0,0 +1,43 @@
//
// DieView.swift
// Bunch O' Dice
//
import SwiftUI
struct DieView: View {
let whichDie: Dice.DiceType
@EnvironmentObject var current: Roll
var body: some View {
let size = 22.0
let displayColorHit = (whichDie.rawValue >= current.cutoff.rawValue) ? Color.red : Color.primary
let displayColorArmor = (whichDie.rawValue <= current.cutoff.rawValue) ? Color.red : Color.primary
let displayColorArmorText = (whichDie.rawValue < current.cutoff.rawValue) ? Color.red : Color.primary
VStack(spacing: 2) {
Text(String(current.dice.value(which: whichDie)))
.foregroundColor(current.state == .armour ? displayColorArmorText : displayColorHit)
.frame(minWidth: 30, alignment: .center)
Image(systemName: "die.face." + String(whichDie.rawValue) + ".fill")
.foregroundColor(current.state == .armour ? displayColorArmor : displayColorHit)
}
.font(.system(size: size))
.padding(4)
.frame(maxWidth: .infinity)
.gesture(TapGesture().onEnded {
if current.state == .hit {
if whichDie.rawValue != 1 {
current.hitCutoff = whichDie
}
} else if current.state == .armour {
current.armourSave = Int(whichDie.rawValue)
}
})
}
}
#Preview {
DieView(whichDie: .four)
.environmentObject(Roll())
}
@@ -0,0 +1,97 @@
//
// HitCalculatorView.swift
// Bunch O' Dice
//
import SwiftUI
struct HitCalculatorView: View {
@EnvironmentObject var current: Roll
private let controlSize = 30.0
var body: some View {
VStack {
HStack(alignment: .top) {
Spacer()
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")
VStack {
Button(action: { current.applyBlowup(face: .six) }) {
Image(systemName: "die.face.6")
.foregroundColor(.primary)
}
Button(action: { current.applyBlowup(face: .five) }) {
Image(systemName: "die.face.5")
.foregroundColor(.primary)
}
}
.padding()
.font(.system(size: controlSize))
}
.disabledWithOpacity(current.blowupUsed || current.torrent)
Spacer()
}
RerollPickerView(mode: $current.hitRerollMode)
.disabledWithOpacity(current.torrent)
HStack(spacing: 6) {
ToggleChip(label: "Torrent", isOn: $current.torrent)
ToggleChip(label: "Psychic", isOn: $current.psychic)
.disabledWithOpacity(current.torrent)
ToggleChip(label: "Lethal Hits", isOn: $current.lethalHits)
}
.padding(.horizontal)
VStack(spacing: 8) {
HStack {
ToggleChip(label: "Sust. Hits", isOn: $current.sustainedHits)
Spacer()
if current.sustainedHits {
Text("X:")
Text("▲")
.gesture(TapGesture().onEnded {
if current.sustainedHitsX < 6 { current.sustainedHitsX += 1 }
})
Text(String(current.sustainedHitsX))
.frame(minWidth: 30, alignment: .center)
Text("▼")
.gesture(TapGesture().onEnded {
if current.sustainedHitsX > 1 { current.sustainedHitsX -= 1 }
})
}
}
HStack {
ToggleChip(label: "Anti-X", isOn: $current.antiX)
Spacer()
if current.antiX {
Text("▲")
.gesture(TapGesture().onEnded {
if current.antiXThreshold < 6 { current.antiXThreshold += 1 }
})
Text("\(current.antiXThreshold)+")
.frame(minWidth: 34, alignment: .center)
Text("▼")
.gesture(TapGesture().onEnded {
if current.antiXThreshold > 2 { current.antiXThreshold -= 1 }
})
}
}
}
.font(.system(size: 18))
.padding(.horizontal)
}
.padding()
}
}
#Preview {
HitCalculatorView()
.environmentObject(Roll())
}
@@ -0,0 +1,49 @@
//
// InfoAreaView.swift
// Bunch O' Dice
//
import SwiftUI
struct InfoAreaView: View {
@EnvironmentObject var current: Roll
var body: some View {
VStack(alignment: .leading, spacing: 6) {
switch current.state {
case .hit:
if current.lethalHits || current.sustainedHits {
Text("Crits (6s): \(current.critHitCount)")
.fontWeight(.semibold)
.foregroundColor(.orange)
}
if current.antiX {
Text("Anti-X (\(current.antiXThreshold)+): \(current.antiXCount)")
.fontWeight(.semibold)
.foregroundColor(.orange)
}
case .wound:
if current.autoWoundsCarried > 0 {
Text("Auto-wounds carried: \(current.autoWoundsCarried) (skip wound roll)")
.fontWeight(.semibold)
.foregroundColor(.orange)
}
if current.devastatingWounds {
Text("Mortals (6s): \(current.critWoundCount)")
.fontWeight(.semibold)
.foregroundColor(.orange)
}
case .armour:
EmptyView()
}
}
.font(.system(size: 18))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
}
}
#Preview {
InfoAreaView()
.environmentObject(Roll())
}
@@ -0,0 +1,53 @@
//
// RerollPickerView.swift
// Bunch O' Dice
//
import SwiftUI
@available(iOS 26.0, *)
struct RerollPickerView: View {
@EnvironmentObject var current: Roll
@Binding var mode: RerollMode
var body: some View {
let actionBinding = Binding<RerollMode>(
get: { mode },
set: { newValue in
mode = newValue
if newValue != .none {
current.applyReroll()
} else {
current.undoReroll()
}
}
)
HStack(spacing: 8) {
Text("Reroll")
HStack(spacing: 6) {
ForEach(RerollMode.allCases, id: \.self) { m in
let isSelected = mode == m
Button(action: { actionBinding.wrappedValue = m }) {
Text(m.label)
.fontWeight(isSelected ? .semibold : .regular)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.foregroundColor(isSelected ? Color(UIColor.systemBackground) : .primary)
.background(isSelected ? Color.green : Color.gray)
.clipShape(Capsule())
.overlay(Capsule().stroke(Color.primary, lineWidth: 1))
.glassEffect()
}
}
}
}
.padding(.horizontal)
}
}
#Preview {
if #available(iOS 26.0, *) {
RerollPickerView(mode: .constant(.none))
.environmentObject(Roll())
}
}
@@ -0,0 +1,62 @@
//
// RollSummaryView.swift
// Bunch O' Dice
//
import SwiftUI
struct RollSummaryView: View {
@EnvironmentObject var current: Roll
@State private var showFillView = false
var body: some View {
HStack(spacing: 12) {
Button(action: { showFillView.toggle() }) {
VStack(spacing: 4) {
Text("Dice Pool")
.font(.caption)
Text(String(current.total))
.font(.system(size: 28, weight: .black))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
.foregroundColor(.black)
}
.sheet(isPresented: $showFillView,
onDismiss: { current.resetPool() }) {
VStack {
AddToPool()
Button("Accept", action: { showFillView.toggle() })
.padding(20)
}
}
Button(action: current.incrementState) {
VStack(spacing: 4) {
Text(current.state.text())
.font(.caption)
Text(String(current.result))
.font(.system(size: 28, weight: .black))
if current.state == .armour && current.mortalWoundsTotal > 0 {
Text("mortal: \(current.mortalWoundsTotal)")
.font(.caption)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
.foregroundColor(.black)
}
}
.padding()
.background(Color.black)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}
#Preview {
RollSummaryView()
.environmentObject(Roll())
}
@@ -0,0 +1,50 @@
//
// StatePickerView.swift
// Bunch O' Dice
//
import SwiftUI
struct StatePickerView: View {
@EnvironmentObject var current: Roll
private let controlSize = 30.0
var body: some View {
HStack {
Button(action: { current.state = .hit }) {
ZStack {
if current.state == .hit { circle() }
Text(RollState.hit.print())
}
}
.glassEffect()
Button(action: { current.state = .wound }) {
ZStack {
if current.state == .wound { circle() }
Text(RollState.wound.print())
}
}
.glassEffect()
Button(action: { current.state = .armour }) {
ZStack {
if current.state == .armour { circle() }
Text(RollState.armour.print())
}
}
.glassEffect()
}
.font(.system(size: 60))
}
@ViewBuilder private func circle() -> some View {
Circle()
.fill(Color.gray)
.blur(radius: 2.0)
.frame(width: controlSize * 2.5, height: controlSize * 2.5, alignment: .center)
}
}
#Preview {
StatePickerView()
.environmentObject(Roll())
}
@@ -0,0 +1,58 @@
//
// WoundCalculatorView.swift
// Bunch O' Dice
//
import SwiftUI
struct WoundCalculatorView: View {
@EnvironmentObject var current: Roll
var body: some View {
VStack {
HStack(alignment: .top) {
Spacer()
StatStepper(
top: "Attack",
value: String(current.strength),
bottom: "Strength",
increment: { current.addStrength() },
decrement: { current.loseStrength() }
)
Spacer()
StatStepper(
top: "Toughness",
value: String(current.toughness),
bottom: " ",
increment: { current.addToughness() },
decrement: { current.loseToughness() }
)
Spacer()
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()
}
RerollPickerView(mode: $current.woundRerollMode)
HStack {
ToggleChip(label: "Devastating Wounds", isOn: $current.devastatingWounds)
}
.font(.system(size: 18))
.padding(.horizontal)
}
.padding()
}
}
#Preview {
WoundCalculatorView()
.environmentObject({
let r = Roll()
r.state = .wound
return r
}())
}
+33 -411
View File
@@ -11,439 +11,61 @@ struct MainView: View {
enum MenuView: Int { enum MenuView: Int {
case history, add, settings case history, add, settings
func icon() -> Image { func icon() -> Image {
switch self { switch self {
case .history: case .history: return Image(systemName: "folder")
return(Image(systemName: "folder")) case .settings: return Image(systemName: "square.and.pencil")
case .settings: case .add: return Image(systemName: "folder.badge.plus")
return(Image(systemName: "square.and.pencil"))
case .add:
return(Image(systemName: "folder.badge.plus"))
} }
} }
} }
@ViewBuilder private func myCircle() -> some View {
Circle()
.fill(Color.gray)
.blur(radius: 2.0)
.frame(width: controlSize*2.5, height: controlSize*2.5, alignment: .center)
}
@ViewBuilder private func stateView() -> some View {
switch current.state {
case .wound:
woundCalculator(controlSize: controlSize)
case .armour:
armourCalculator(controlSize: controlSize)
default:
hitCalculator(controlSize: controlSize)
}
}
@ViewBuilder private func sheetContent() -> some View {
switch sheet.state {
case .settings:
SettingsView(isShowing: $sheet.isShowing)
case .history:
HistoryView(isShowing: $sheet.isShowing)
default:
EmptyView()
}
}
@ViewBuilder private func rerollPicker(mode: Binding<RerollMode>) -> some View {
let actionBinding = Binding<RerollMode>(
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
let blurSize = 5.0
ZStack {
Circle()
.fill(Color(color))
.frame(width: frameSize, height: frameSize)
.blur(radius: blurSize)
VStack{
Text(String(value))
.fontWeight(.black)
Text(label)
.font(.system(size: 20))
}
}
.foregroundColor(.primary)
.font(.system(size: fontSize))
}
//MARK: State Views
@ViewBuilder func armourCalculator(controlSize: Double) -> some View {
VStack {
HStack(alignment: .top) {
Spacer()
statStepper(
top: "Armour",
value: (current.armourPenetration > 0 ? "+" : "") + String(current.armourPenetration),
bottom: "Penetration",
increment: { current.addPenetration() },
decrement: { current.losePenetration() }
)
Spacer()
VStack {
Text("Invulnerable")
Toggle("", isOn: $current.invulnerable).labelsHidden()
.padding()
}
Spacer()
}
rerollPicker(mode: $current.saveRerollMode)
if current.mortalWoundsTotal > 0 {
HStack {
Text("Mortal wounds: \(current.mortalWoundsTotal) (bypassed saves)")
.fontWeight(.semibold)
.foregroundColor(.orange)
Spacer()
}
.font(.system(size: 18))
.padding(.horizontal)
}
}
.padding()
}
@ViewBuilder func woundCalculator(controlSize: Double) -> some View {
VStack {
if current.autoWoundsCarried > 0 {
HStack {
Text("Auto-wounds carried: \(current.autoWoundsCarried) (skip wound roll)")
.fontWeight(.semibold)
.foregroundColor(.orange)
Spacer()
}
.font(.system(size: 18))
.padding(.horizontal)
}
HStack(alignment: .top) {
Spacer()
statStepper(
top: "Attack",
value: String(current.strength),
bottom: "Strength",
increment: { current.addStrength() },
decrement: { current.loseStrength() }
)
Spacer()
statStepper(
top: "Toughness",
value: String(current.toughness),
bottom: " ",
increment: { current.addToughness() },
decrement: { current.loseToughness() }
)
Spacer()
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()
}
rerollPicker(mode: $current.woundRerollMode)
VStack(spacing: 8) {
HStack {
Toggle("Devastating Wounds", isOn: $current.devastatingWounds)
}
if current.devastatingWounds {
HStack {
Text("Mortals (6s): \(current.critWoundCount)")
.fontWeight(.semibold)
.foregroundColor(.orange)
Spacer()
}
}
}
.font(.system(size: 18))
.padding(.horizontal)
}
.padding()
}
@ViewBuilder func hitCalculator(controlSize: Double) -> some View {
VStack {
HStack(alignment: .top) {
Spacer()
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")
VStack {
Button(action: { current.applyBlowup(face: .six) }) {
Image(systemName: "die.face.6")
.foregroundColor(.primary)
}
Button(action: { current.applyBlowup(face: .five) }) {
Image(systemName: "die.face.5")
.foregroundColor(.primary)
}
}
.padding()
.font(.system(size: controlSize))
}
.disabledWithOpacity(current.blowupUsed || current.torrent)
Spacer()
}
rerollPicker(mode: $current.hitRerollMode)
.disabledWithOpacity(current.torrent)
HStack {
Spacer()
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("Sust. Hits", isOn: $current.sustainedHits)
.fixedSize()
Spacer()
if current.sustainedHits {
Text("X:")
Text("▲")
.gesture(TapGesture().onEnded {
if current.sustainedHitsX < 6 { current.sustainedHitsX += 1 }
})
Text(String(current.sustainedHitsX))
.frame(minWidth: 30, alignment: .center)
Text("▼")
.gesture(TapGesture().onEnded {
if current.sustainedHitsX > 1 { current.sustainedHitsX -= 1 }
})
}
}
HStack {
Toggle("Anti-X", isOn: $current.antiX)
.fixedSize()
Spacer()
if current.antiX {
Text("▲")
.gesture(TapGesture().onEnded {
if current.antiXThreshold < 6 { current.antiXThreshold += 1 }
})
Text("\(current.antiXThreshold)+")
.frame(minWidth: 34, alignment: .center)
Text("▼")
.gesture(TapGesture().onEnded {
if current.antiXThreshold > 2 { current.antiXThreshold -= 1 }
})
}
}
if current.lethalHits || current.sustainedHits || current.antiX {
HStack {
if current.lethalHits || current.sustainedHits {
Text("Crits (6s): \(current.critHitCount)")
.fontWeight(.semibold)
}
Spacer()
if current.antiX {
Text("Anti-X (\(current.antiXThreshold)+): \(current.antiXCount)")
.fontWeight(.semibold)
}
}
.foregroundColor(.orange)
}
}
.font(.system(size: 18))
.padding(.horizontal)
}
.padding()
}
//MARK: die views
@ViewBuilder func die(whichDie: Dice.DiceType) -> some View {
let size = 22.0
let displayColorHit = (whichDie.rawValue >= current.cutoff.rawValue) ? Color.red : Color.primary
let displayColorArmor = (whichDie.rawValue <= current.cutoff.rawValue) ? Color.red : Color.primary
let displayColorArmorText = (whichDie.rawValue < current.cutoff.rawValue) ? Color.red : Color.primary
VStack(spacing: 2) {
Text(String(current.dice.value(which: whichDie)))
.foregroundColor(current.state == .armour ?
displayColorArmorText : displayColorHit)
.frame(minWidth: 30, alignment: .center)
Image(systemName: "die.face." + String(whichDie.rawValue) + ".fill")
.foregroundColor(current.state == .armour ?
displayColorArmor : displayColorHit)
}
.font(.system(size: size))
.padding(4)
.frame(maxWidth: .infinity)
.gesture(TapGesture().onEnded {
if current.state == .hit {
if whichDie.rawValue != 1 {
current.hitCutoff = whichDie }
} else if current.state == .armour {
current.armourSave = Int(whichDie.rawValue)
}
})
}
@ViewBuilder func diePool() -> some View {
HStack(spacing: 0) {
ForEach(Dice.DiceType.allCases, id: \.rawValue) { i in
die(whichDie: i)
}
}
}
//MARK: Var declarations
@EnvironmentObject var current: Roll @EnvironmentObject var current: Roll
@ObservedObject var sheet = SettingsSheet() @ObservedObject var sheet = SettingsSheet()
@State var showFillView = false
private let controlSize = 30.0
let padSize = 60.0
let controlSize = 30.0
let red = CGColor(red: 1.0, green: 0.3, blue: 0.3, alpha: 1.0)
let blue = CGColor(red: 0.3, green: 0.3, blue: 1.0, alpha: 1.0)
var body: some View { var body: some View {
VStack{ VStack {
HStack{ StatePickerView()
Button(action: {current.state = .hit}) {
ZStack{
if current.state == .hit {myCircle()}
Text(RollState.hit.print())}}
Button(action: {current.state = .wound}) {
ZStack{
if current.state == .wound {myCircle()}
Text(RollState.wound.print())}}
Button(action: {current.state = .armour}) {
ZStack{
if current.state == .armour {myCircle()}
Text(RollState.armour.print())}}
}.font(.system(size: 60))
VStack(spacing: 12) { VStack(spacing: 12) {
HStack(spacing: 12) { RollSummaryView()
Button(action: { showFillView.toggle() }) { DiePoolView()
VStack(spacing: 4) { switch current.state {
Text("Dice Pool") case .wound: WoundCalculatorView()
.font(.caption) case .armour: ArmourCalculatorView()
Text(String(current.total)) default: HitCalculatorView()
.font(.system(size: 28, weight: .black))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
.foregroundColor(.black)
}
.sheet(isPresented: $showFillView,
onDismiss: { current.resetPool() }) {
VStack {
AddToPool()
Button("Accept", action: {showFillView.toggle()})
.padding(20)
}
}
Button(action: current.incrementState) {
VStack(spacing: 4) {
Text(current.state.text())
.font(.caption)
Text(String(current.result))
.font(.system(size: 28, weight: .black))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
.foregroundColor(.black)
}
} }
.padding() InfoAreaView()
.background(Color.black)
.clipShape(RoundedRectangle(cornerRadius: 14))
diePool()
stateView()
Spacer() Spacer()
}.padding() }.padding()
} }
.toolbar { .toolbar {
ToolbarItemGroup(placement: .bottomBar) { ToolbarItemGroup(placement: .bottomBar) {
HStack { HStack {
Button(action: {self.sheet.state = .history}) { Button(action: { sheet.state = .history }) {
MenuView.history.icon()} MenuView.history.icon()
Button(action: {current.history.insert(Event(total: current.result, pool: current.dice, state: current.state), at: 0)}) { }
MenuView.add.icon()} Button(action: { current.history.insert(Event(total: current.result, pool: current.dice, state: current.state), at: 0) }) {
Button(action: {self.sheet.state = .settings}) { MenuView.add.icon()
MenuView.settings.icon()} }
Button(action: { sheet.state = .settings }) {
MenuView.settings.icon()
}
} }
.font(.system(size: controlSize)) .font(.system(size: controlSize))
.foregroundColor(.primary) .foregroundColor(.primary)
} }
} }
.sheet(isPresented: $sheet.isShowing, content: sheetContent)} .sheet(isPresented: $sheet.isShowing) {
switch sheet.state {
case .settings: SettingsView(isShowing: $sheet.isShowing)
case .history: HistoryView(isShowing: $sheet.isShowing)
default: EmptyView()
}
}
}
} }
#Preview("Hit") { #Preview("Hit") {