diff --git a/ios/MagicCounter.xcodeproj/project.pbxproj b/ios/MagicCounter.xcodeproj/project.pbxproj index 56f8d87..e53cce8 100644 --- a/ios/MagicCounter.xcodeproj/project.pbxproj +++ b/ios/MagicCounter.xcodeproj/project.pbxproj @@ -411,11 +411,12 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = MagicCounter; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -446,11 +447,12 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = MagicCounter; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/ios/MagicCounter.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/MagicCounter.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index 542a702..96a97bf 100644 Binary files a/ios/MagicCounter.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/MagicCounter.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/MagicCounter/GameView.swift b/ios/MagicCounter/GameView.swift index fb7a185..026a3c7 100644 --- a/ios/MagicCounter/GameView.swift +++ b/ios/MagicCounter/GameView.swift @@ -7,6 +7,7 @@ import SwiftUI import UniformTypeIdentifiers +import Combine /** * Main game screen displaying the grid of players. @@ -19,75 +20,65 @@ struct GameView: View { @State private var showingStopConfirmation = false @State private var selectedPlayerForCommander: PlayerState? @State private var draggedPlayer: PlayerState? + @State private var elapsedTime: TimeInterval = 0 + let match: MatchRecord init(match: MatchRecord) { + self.match = match self._gameState = State(initialValue: match.state) } var body: some View { - ZStack { - // Background - LinearGradient(colors: [Color.black, Color.indigo.opacity(0.5)], startPoint: .top, endPoint: .bottom) - .ignoresSafeArea() - - VStack(spacing: 0) { - // Custom Toolbar - HStack { - Button(action: { gameManager.activeMatch = nil }) { - Image(systemName: "xmark") - .font(.system(size: 16, weight: .bold)) - .foregroundColor(.white) - .padding(10) - .background(.ultraThinMaterial) - .clipShape(Circle()) - } - - Spacer() - - if gameState.stopped { - Text("Game Stopped") - .font(.headline) - .foregroundStyle(.red) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(.ultraThinMaterial) - .cornerRadius(8) - } else if let winner = gameState.winner { - Text("Winner: \(winner.name)") - .font(.headline) - .foregroundStyle(.green) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(.ultraThinMaterial) - .cornerRadius(8) - } else { - Text("Magic Counter") - .font(.headline) - .foregroundStyle(.white) - } - - Spacer() - - if !gameState.stopped && gameState.winner == nil { - Button(action: { showingStopConfirmation = true }) { - Image(systemName: "stop.fill") - .font(.system(size: 16, weight: .bold)) - .foregroundColor(.white) - .padding(10) - .background(.ultraThinMaterial) - .clipShape(Circle()) + NavigationStack { + GeometryReader { geometry in + let columns = [GridItem(.adaptive(minimum: 320), spacing: 24)] + ScrollView { + VStack(alignment: .leading, spacing: 24) { + // Custom Header + HStack(alignment: .center, spacing: 12) { + Text(match.name) + .font(.largeTitle.bold()) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(.white) + + if !gameState.stopped && gameState.winner == nil { + Text(timeString(from: elapsedTime)) + .font(.subheadline.bold()) + .monospacedDigit() + .foregroundStyle(.green) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.green.opacity(0.2)) + .clipShape(Capsule()) + } else { + Text(timeString(from: match.lastUpdated.timeIntervalSince(match.startedAt))) + .font(.subheadline.bold()) + .monospacedDigit() + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.secondary.opacity(0.2)) + .clipShape(Capsule()) + } + + Spacer() } - } else { - Color.clear.frame(width: 40, height: 40) - } - } - .padding() - .background(.ultraThinMaterial) - - // Players Grid - GeometryReader { geometry in - let columns = [GridItem(.adaptive(minimum: 320), spacing: 24)] - ScrollView { + .padding(.horizontal, 24) + .padding(.top, 16) + + if gameState.stopped { + Text("Game Stopped") + .font(.headline) + .foregroundStyle(.red) + .padding(.horizontal, 24) + } else if let winner = gameState.winner { + Text("Winner: \(winner.name)") + .font(.headline) + .foregroundStyle(.green) + .padding(.horizontal, 24) + } + LazyVGrid(columns: columns, spacing: 24) { ForEach(gameState.players) { player in PlayerCell( @@ -106,10 +97,33 @@ struct GameView: View { .onDrop(of: [.text], delegate: PlayerDropDelegate(item: player, items: $gameState.players, draggedItem: $draggedPlayer)) } } - .padding(24) + .padding(.horizontal, 24) + .padding(.bottom, 24) } } } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(action: { gameManager.activeMatch = nil }) { + Image(systemName: "xmark") + } + } + + ToolbarItem(placement: .topBarTrailing) { + if !gameState.stopped && gameState.winner == nil { + Button(action: { showingStopConfirmation = true }) { + Image(systemName: "stop.fill") + } + } + } + } + } + .onAppear { + updateElapsedTime() + } + .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in + updateElapsedTime() } .alert("Stop Game?", isPresented: $showingStopConfirmation) { Button("Cancel", role: .cancel) { } @@ -138,6 +152,25 @@ struct GameView: View { } } + private func updateElapsedTime() { + if !gameState.stopped && gameState.winner == nil { + elapsedTime = Date().timeIntervalSince(match.startedAt) + } else { + elapsedTime = match.lastUpdated.timeIntervalSince(match.startedAt) + } + } + + private func timeString(from timeInterval: TimeInterval) -> String { + let hours = Int(timeInterval) / 3600 + let minutes = Int(timeInterval) / 60 % 60 + let seconds = Int(timeInterval) % 60 + if hours > 0 { + return String(format: "%02i:%02i:%02i", hours, minutes, seconds) + } else { + return String(format: "%02i:%02i", minutes, seconds) + } + } + private func updatePlayer(player: PlayerState) { if gameState.stopped || gameState.winner != nil { return } @@ -306,12 +339,12 @@ struct PlayerCell: View { */ struct CommanderDamageView: View { @Environment(\.dismiss) var dismiss - @State private var targetPlayer: PlayerState + let targetPlayer: PlayerState let gameState: GameState let onUpdate: (PlayerState) -> Void init(targetPlayer: PlayerState, gameState: GameState, onUpdate: @escaping (PlayerState) -> Void) { - self._targetPlayer = State(initialValue: targetPlayer) + self.targetPlayer = targetPlayer self.gameState = gameState self.onUpdate = onUpdate } @@ -330,6 +363,7 @@ struct CommanderDamageView: View { color: .secondary, action: { adjustCommanderDamage(attackerId: attacker.id, by: -1) } ) + .buttonStyle(.borderless) Text("\(targetPlayer.commanderDamages[attacker.id] ?? 0)") .font(.title.bold()) @@ -341,6 +375,7 @@ struct CommanderDamageView: View { color: .primary, action: { adjustCommanderDamage(attackerId: attacker.id, by: 1) } ) + .buttonStyle(.borderless) } } .padding(.vertical, 8) @@ -359,20 +394,27 @@ struct CommanderDamageView: View { private func adjustCommanderDamage(attackerId: Int, by amount: Int) { Haptics.play(.light) let wasEliminated = targetPlayer.isEliminated - var newPlayer = targetPlayer - var damages = newPlayer.commanderDamages - let current = damages[attackerId] ?? 0 - let newDamage = max(0, current + amount) - let damageChange = newDamage - current - damages[attackerId] = newDamage - newPlayer.commanderDamages = damages - // Commander damage also reduces life total - newPlayer.life -= damageChange - // Update local state so the view refreshes - targetPlayer = newPlayer - onUpdate(newPlayer) - if newPlayer.isEliminated && !wasEliminated { + // Get current damage value + let current = targetPlayer.commanderDamages[attackerId] ?? 0 + let newDamage = max(0, current + amount) + + // Only proceed if there's an actual change + guard newDamage != current else { return } + + // Update commander damages + var updatedPlayer = targetPlayer + var damages = updatedPlayer.commanderDamages + damages[attackerId] = newDamage + updatedPlayer.commanderDamages = damages + + // Adjust life total by the damage change + updatedPlayer.life -= amount + + // Notify parent to update + onUpdate(updatedPlayer) + + if updatedPlayer.isEliminated && !wasEliminated { Haptics.notification(.error) } } @@ -403,4 +445,4 @@ struct PlayerDropDelegate: DropDelegate { func dropUpdated(info: DropInfo) -> DropProposal? { return DropProposal(operation: .move) } -} +} \ No newline at end of file