diff --git a/MeoAsstMac.xcodeproj/project.pbxproj b/MeoAsstMac.xcodeproj/project.pbxproj index 9de7740d0..7466fd431 100644 --- a/MeoAsstMac.xcodeproj/project.pbxproj +++ b/MeoAsstMac.xcodeproj/project.pbxproj @@ -95,6 +95,11 @@ 92EB82872A0CD315006DD9D2 /* SystemSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92EB82862A0CD315006DD9D2 /* SystemSettingsView.swift */; }; 92F78D4D2A0A7B2E00999BD0 /* TaskTimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92F78D4C2A0A7B2E00999BD0 /* TaskTimerView.swift */; }; 92F78D4F2A0B361C00999BD0 /* TaskTimerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92F78D4E2A0B361C00999BD0 /* TaskTimerManager.swift */; }; + 9BEBD1002DD3CA3C006D212B /* CopilotListConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BEBD0FF2DD3CA3C006D212B /* CopilotListConfiguration.swift */; }; + 9BEBD1012DD3CA3C006D212B /* CopilotItemConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BEBD0FE2DD3CA3C006D212B /* CopilotItemConfiguration.swift */; }; + 9BEBD1052DD3CBB2006D212B /* MapHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BEBD1042DD3CBB2006D212B /* MapHelper.swift */; }; + 9BEBD1082DD3CD52006D212B /* RegularCopilotList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BEBD1072DD3CD52006D212B /* RegularCopilotList.swift */; }; + 9BEBD1092DD3CD52006D212B /* CopilotList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BEBD1062DD3CD52006D212B /* CopilotList.swift */; }; 9BC2EEB32E3F188D0039C4DB /* MiniGameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BC2EEB22E3F188D0039C4DB /* MiniGameView.swift */; }; /* End PBXBuildFile section */ @@ -215,6 +220,11 @@ 92EB82862A0CD315006DD9D2 /* SystemSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemSettingsView.swift; sourceTree = ""; }; 92F78D4C2A0A7B2E00999BD0 /* TaskTimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskTimerView.swift; sourceTree = ""; }; 92F78D4E2A0B361C00999BD0 /* TaskTimerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskTimerManager.swift; sourceTree = ""; }; + 9BEBD0FE2DD3CA3C006D212B /* CopilotItemConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopilotItemConfiguration.swift; sourceTree = ""; }; + 9BEBD0FF2DD3CA3C006D212B /* CopilotListConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopilotListConfiguration.swift; sourceTree = ""; }; + 9BEBD1042DD3CBB2006D212B /* MapHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapHelper.swift; sourceTree = ""; }; + 9BEBD1062DD3CD52006D212B /* CopilotList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopilotList.swift; sourceTree = ""; }; + 9BEBD1072DD3CD52006D212B /* RegularCopilotList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegularCopilotList.swift; sourceTree = ""; }; 9BC2EEB22E3F188D0039C4DB /* MiniGameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniGameView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -261,6 +271,8 @@ 83264A3429EBEE8D0074E33A /* AwardConfiguration.swift */, 8341194F29EC249E00044F45 /* RoguelikeConfiguration.swift */, 8341195429EC55C600044F45 /* CopilotConfiguration.swift */, + 9BEBD0FE2DD3CA3C006D212B /* CopilotItemConfiguration.swift */, + 9BEBD0FF2DD3CA3C006D212B /* CopilotListConfiguration.swift */, 832985AF29EEF483009F7ED5 /* ReclamationConfiguration.swift */, 83782A2A2CFFAF2900A32BDD /* LegacyConfigurations.swift */, ); @@ -329,10 +341,12 @@ 832985B829EF9D50009F7ED5 /* MAAContent.swift */, 832985BC29EFA43B009F7ED5 /* MAADetail.swift */, 8355E7B329E918A2005D5A1C /* TasksContent.swift */, - 8341195C29EC719B00044F45 /* CopilotContent.swift */, - 832985AD29EEE672009F7ED5 /* UtilityContent.swift */, 832985BA29EFA26D009F7ED5 /* TaskDetail.swift */, + 9BEBD1072DD3CD52006D212B /* RegularCopilotList.swift */, + 9BEBD1062DD3CD52006D212B /* CopilotList.swift */, + 8341195C29EC719B00044F45 /* CopilotContent.swift */, 8329858929ED121A009F7ED5 /* CopilotDetail.swift */, + 832985AD29EEE672009F7ED5 /* UtilityContent.swift */, 832985C029EFB665009F7ED5 /* UtilityDetail.swift */, ); path = Navigation; @@ -456,6 +470,7 @@ 92CDF6202A065E20005669EC /* Utils */ = { isa = PBXGroup; children = ( + 9BEBD1042DD3CBB2006D212B /* MapHelper.swift */, 92CDF6212A065E55005669EC /* Codeable+JSON.swift */, 92F78D4E2A0B361C00999BD0 /* TaskTimerManager.swift */, 8347B5AE2CDF925400AEB627 /* URLSession+downloadProgress.swift */, @@ -566,6 +581,8 @@ 9BC2EEB32E3F188D0039C4DB /* MiniGameView.swift in Sources */, 832985BB29EFA26D009F7ED5 /* TaskDetail.swift in Sources */, 834DEC122C4D94BD0073B5D4 /* ClosedownConfiguration.swift in Sources */, + 9BEBD1082DD3CD52006D212B /* RegularCopilotList.swift in Sources */, + 9BEBD1092DD3CD52006D212B /* CopilotList.swift in Sources */, 83477CB429FEA7A500A5FF99 /* TaskButtons.swift in Sources */, 8355E7B829E9E2B2005D5A1C /* Sidebar.swift in Sources */, 83661E2C29F77CFF007793CA /* VideoRecogView.swift in Sources */, @@ -606,6 +623,7 @@ 832985B629EF1890009F7ED5 /* MAADepot.swift in Sources */, 83D3283928F3048E00018D10 /* RoguelikeSettingsView.swift in Sources */, 92EB82872A0CD315006DD9D2 /* SystemSettingsView.swift in Sources */, + 9BEBD1052DD3CBB2006D212B /* MapHelper.swift in Sources */, 8315B0BD290E48F300AE7FDC /* StartupSettingsView.swift in Sources */, 83264A3529EBEE8D0074E33A /* AwardConfiguration.swift in Sources */, 832985BD29EFA43B009F7ED5 /* MAADetail.swift in Sources */, @@ -626,6 +644,8 @@ 838FF82729FDD0F9000566A0 /* GachaView.swift in Sources */, 83782A2B2CFFAF2900A32BDD /* LegacyConfigurations.swift in Sources */, 83264A2F29EBEE140074E33A /* RecruitConfiguration.swift in Sources */, + 9BEBD1002DD3CA3C006D212B /* CopilotListConfiguration.swift in Sources */, + 9BEBD1012DD3CA3C006D212B /* CopilotItemConfiguration.swift in Sources */, 8341195029EC249E00044F45 /* RoguelikeConfiguration.swift in Sources */, 83264A2B29EBED780074E33A /* StartupConfiguration.swift in Sources */, ); diff --git a/MeoAsstMac/Core/Maa.swift b/MeoAsstMac/Core/Maa.swift index b261aebc6..b42624fee 100644 --- a/MeoAsstMac/Core/Maa.swift +++ b/MeoAsstMac/Core/Maa.swift @@ -34,7 +34,7 @@ actor MAAHandle { private let callback: AsstApiCallback = { msg, details_json, custom_arg in guard let details_json, let custom_arg, - let detailString = String(cString: details_json, encoding: .utf8) + let detailString = String(cString: details_json, encoding: .utf8) else { return } @@ -43,9 +43,10 @@ actor MAAHandle { let message = MaaMessage(code: Int(msg), details: details) let uuid = custom_arg.assumingMemoryBound(to: UUID.self).pointee - NotificationCenter.default.post(name: .MAAReceivedCallbackMessage, - object: message, - userInfo: ["uuid": uuid]) + NotificationCenter.default.post( + name: .MAAReceivedCallbackMessage, + object: message, + userInfo: ["uuid": uuid]) } init(options: MAAInstanceOptions = [:]) throws { @@ -103,8 +104,9 @@ actor MAAHandle { let data = Data(bytesNoCopy: buffer, count: size, deallocator: .free) guard let provider = CGDataProvider(data: data as CFData), - let image = CGImage(pngDataProviderSource: provider, decode: nil, - shouldInterpolate: false, intent: .defaultIntent) + let image = CGImage( + pngDataProviderSource: provider, decode: nil, + shouldInterpolate: false, intent: .defaultIntent) else { throw MaaCoreError.getImageFailed } @@ -175,8 +177,8 @@ extension JSON { } } -private extension AsstBool { - var isTrue: Bool { self != 0 } +extension AsstBool { + fileprivate var isTrue: Bool { self != 0 } } struct MAAResourceVersion: Codable { diff --git a/MeoAsstMac/Core/MaaMessage.swift b/MeoAsstMac/Core/MaaMessage.swift index 17d4592d4..9b7c84a1d 100644 --- a/MeoAsstMac/Core/MaaMessage.swift +++ b/MeoAsstMac/Core/MaaMessage.swift @@ -139,12 +139,24 @@ extension MAAViewModel { logError("TaskError \(taskChain)") if isCopilot { logError("CombatError") + if isCopilotListRunning { + Task { try await stop() } + self.copilotListConfig = copilotListConfig + if let stage = getCurrentCopilotStageName() { + logError("战斗出错:\(stage)") + } + isCopilotListRunning = false + logError("CopilotListError") + } } case .TaskChainStart: if let id = taskID(taskDetails: message.details) { taskStatus[id] = .running } + if isCopilot, let stage = getCurrentCopilotStageName() { + logInfo("开始关卡:\(stage)") + } logTrace("StartTask \(taskChain)") case .TaskChainCompleted: @@ -173,6 +185,15 @@ extension MAAViewModel { if isCopilot { logInfo("CompleteCombat") + if isCopilotListRunning { + if let stage = getCurrentCopilotStageName() { + logInfo("完成关卡:\(stage)") + } + if let idx = copilotListConfig.items.firstIndex(where: { $0.enabled }) { + copilotListConfig.items[idx].enabled = false + self.copilotListConfig = copilotListConfig + } + } } case .TaskChainExtraInfo: @@ -180,6 +201,10 @@ extension MAAViewModel { case .AllTasksCompleted: logTrace("AllTasksComplete") + if isCopilotListRunning { + logInfo("CopilotListCompleted") + isCopilotListRunning = false + } resetStatus() default: @@ -557,7 +582,7 @@ extension MAAViewModel { } } - // MARK: Recruit Recoginition + // MARK: - Recruit Recoginition private func processRecruitMessage(details: JSON) { guard let what = details["what"].string else { @@ -579,7 +604,7 @@ extension MAAViewModel { } } - // MARK: Video Recognition + // MARK: - Video Recognition private func processVideoMessage(details: JSON) { guard let what = details["what"].string else { diff --git a/MeoAsstMac/Core/MaaToolClient.swift b/MeoAsstMac/Core/MaaToolClient.swift index e01f0a124..a32b7558e 100644 --- a/MeoAsstMac/Core/MaaToolClient.swift +++ b/MeoAsstMac/Core/MaaToolClient.swift @@ -14,8 +14,8 @@ actor MaaToolClient { init?(address: String) async { let parts = address.split(separator: ":") guard parts.count >= 2, - let portNumber = UInt16(parts[1]), - let port = NWEndpoint.Port(rawValue: portNumber) + let portNumber = UInt16(parts[1]), + let port = NWEndpoint.Port(rawValue: portNumber) else { return nil } @@ -62,13 +62,15 @@ actor MaaToolClient { let data = Data([0x4d, 0x41, 0x41, 0x00, 0x00, 0x04, 0x54, 0x45, 0x52, 0x4d]) return try await withCheckedThrowingContinuation { continuation in - connection.send(content: data, completion: .contentProcessed { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - }) + connection.send( + content: data, + completion: .contentProcessed { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + }) } } } diff --git a/MeoAsstMac/MAADev.entitlements b/MeoAsstMac/MAADev.entitlements index 9c8769bfc..46996a506 100644 --- a/MeoAsstMac/MAADev.entitlements +++ b/MeoAsstMac/MAADev.entitlements @@ -8,6 +8,8 @@ com.apple.security.device.usb + com.apple.security.files.downloads.read-only + com.apple.security.files.user-selected.read-only com.apple.security.network.client diff --git a/MeoAsstMac/Main Menu/CheckForUpdatesView.swift b/MeoAsstMac/Main Menu/CheckForUpdatesView.swift index 4f911c45f..a172c43cb 100644 --- a/MeoAsstMac/Main Menu/CheckForUpdatesView.swift +++ b/MeoAsstMac/Main Menu/CheckForUpdatesView.swift @@ -33,7 +33,8 @@ struct CheckForUpdatesView: View { } struct CheckForUpdatesView_Previews: PreviewProvider { - private static let updateController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) + private static let updateController = SPUStandardUpdaterController( + startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) static var previews: some View { CheckForUpdatesView(updater: updateController.updater) diff --git a/MeoAsstMac/MeoAsstMac.entitlements b/MeoAsstMac/MeoAsstMac.entitlements index 50231f75b..eb3c727d3 100644 --- a/MeoAsstMac/MeoAsstMac.entitlements +++ b/MeoAsstMac/MeoAsstMac.entitlements @@ -6,6 +6,8 @@ com.apple.security.device.usb + com.apple.security.files.downloads.read-only + com.apple.security.files.user-selected.read-only com.apple.security.network.client diff --git a/MeoAsstMac/Model/MAACopilot.swift b/MeoAsstMac/Model/MAACopilot.swift index 8899e8693..300d53587 100644 --- a/MeoAsstMac/Model/MAACopilot.swift +++ b/MeoAsstMac/Model/MAACopilot.swift @@ -7,14 +7,22 @@ import Foundation +enum DifficultyFlags: Int, Codable { + case none = 0 + case normal = 1 + case raid = 2 + case normal_raid = 3 +} + struct MAACopilot: Codable, Equatable { let stage_name: String let opers: [Operator] let groups: [Group]? let minimum_required: String let doc: Documentation? + var difficulty: DifficultyFlags? = nil - // MARK: SSS + // MARK: - SSS let type: String? let equipment: [String]? diff --git a/MeoAsstMac/Model/MAADepot.swift b/MeoAsstMac/Model/MAADepot.swift index 604d781b6..278956a12 100644 --- a/MeoAsstMac/Model/MAADepot.swift +++ b/MeoAsstMac/Model/MAADepot.swift @@ -11,22 +11,22 @@ struct MAADepot: Codable { let done: Bool let arkplanner: Arkplanner let lolicon: Lolicon - + struct Arkplanner: Codable { let object: ArkplannerObject let data: String } - + struct ArkplannerObject: Codable { let items: [ArkplannerItem] } - + struct ArkplannerItem: Codable { let id: String let have: Int let name: String } - + struct Lolicon: Codable { let object: [String: Int] let data: String @@ -39,7 +39,7 @@ extension MAADepot: CustomStringConvertible { .sorted { $0.id < $1.id } .map { "\($0.name): \($0.have)" } } - + var description: String { contents.joined(separator: "\n") } diff --git a/MeoAsstMac/Model/MAAOperBox.swift b/MeoAsstMac/Model/MAAOperBox.swift index 1c85cbebe..94234dd55 100644 --- a/MeoAsstMac/Model/MAAOperBox.swift +++ b/MeoAsstMac/Model/MAAOperBox.swift @@ -50,7 +50,7 @@ extension MAAOperBox.OwnedOper: Comparable { { $0.elite > $1.elite }, { $0.level > $1.level }, { $0.rarity > $1.rarity }, - { $0.id < $1.id } + { $0.id < $1.id }, ] } diff --git a/MeoAsstMac/Model/MAATask.swift b/MeoAsstMac/Model/MAATask.swift index 817ef2c57..6dd1d7469 100644 --- a/MeoAsstMac/Model/MAATask.swift +++ b/MeoAsstMac/Model/MAATask.swift @@ -68,7 +68,7 @@ extension MAATaskType: Codable, CustomStringConvertible { } } -// MARK: Task Persistance +// MARK: - Task Persistance struct DailyTask: Codable, Equatable, Identifiable { let id: UUID diff --git a/MeoAsstMac/Model/MAAViewModel.swift b/MeoAsstMac/Model/MAAViewModel.swift index 3517d6ce3..f5261cc98 100644 --- a/MeoAsstMac/Model/MAAViewModel.swift +++ b/MeoAsstMac/Model/MAAViewModel.swift @@ -92,10 +92,20 @@ import SwiftUI } @Published var copilot: CopilotConfiguration? - @Published var downloadCopilot: String? @Published var showImportCopilot = false @Published var copilotDetailMode: CopilotDetailMode = .log + @Published var copilots = Set() + @Published var downloading = false + @Published var selectedCopilotURL: URL? + + @Published var useCopilotList = false + @Published var isCopilotListRunning = false + + @AppStorage("MAACopilotListConfig") var serializedCopilotListConfig: String? + + @Published var copilotListConfig = CopilotListConfiguration() + // MARK: - Recognition @Published var recruitConfig = RecruitConfiguration.recognition @@ -163,6 +173,10 @@ import SwiftUI logError("日志文件出错: \(error.localizedDescription)") } + loadUserCopilots() + + initCopilotListConfig() + do { let data = try Data(contentsOf: tasksURL) tasks = try PropertyListDecoder().decode([DailyTask].self, from: data) @@ -320,6 +334,9 @@ extension MAAViewModel { /// /// Should be the outermost call to load resources. private func loadResource(channel: MAAClientChannel) async throws { + // Initialize map data + MapHelper.loadMapData() + let (preferUser, currentResourceVersion) = try resourceChannel.version() try await loadResource(url: Bundle.main.resourceURL!, channel: channel) @@ -408,7 +425,7 @@ extension MAAViewModel { } } -// MARK: Daily Tasks +// MARK: - Daily Tasks extension MAAViewModel { func tryStartTasks() async { @@ -498,35 +515,283 @@ extension MAAViewModel { } } -// MARK: Copilot +// MARK: - Copilot Management + +extension MAAViewModel { + func loadUserCopilots() { + copilots.formUnion(externalDirectory.copilots) + copilots.formUnion(recordingDirectory.copilots) + } + + func addCopilots(_ providers: [NSItemProvider]) -> Bool { + Task { + for provider in providers { + if let url = try? await provider.loadURL() { + let value = try? url.resourceValues(forKeys: [.contentTypeKey]) + if value?.contentType == .json { + copilots.insert(url) + } else if value?.contentType?.conforms(to: .movie) == true { + try? await recognizeVideo(video: url) + } + } + } + self.useCopilotList = false + self.selectedCopilotURL = self.copilots.urls.last + } + return true + } + + func addCopilots(_ results: Result<[URL], Error>) { + if case let .success(urls) = results { + copilots.formUnion(urls) + } + } + + func downloadCopilot(id: String?) { + guard let id else { return } + + let file = + externalDirectory + .appendingPathComponent(id) + .appendingPathExtension("json") + + let url = URL(string: "https://prts.maa.plus/copilot/get/\(id)")! + Task { + self.downloading = true + do { + let data = try await URLSession.shared.data(from: url).0 + let response = try JSONDecoder().decode(CopilotResponse.self, from: data) + try response.data.content.write(toFile: file.path, atomically: true, encoding: .utf8) + copilots.insert(file) + logTrace("下载成功:\(file.lastPathComponent)") + self.selectedCopilotURL = file + self.useCopilotList = false + } catch { + print(error) + logTrace("下载失败:\(error.localizedDescription)") + } + self.downloading = false + } + } + + func deleteCopilot(url: URL) { + copilots.remove(url) + + copilotListConfig.items.removeAll(where: { $0.filename == url.path }) + + guard canDeleteFile(url) else { return } + try? FileManager.default.removeItem(at: url) + } + + func canDeleteFile(_ url: URL?) -> Bool { + [externalDirectory, recordingDirectory] + .compactMap { url?.path.starts(with: $0.path) } + .first(where: { $0 }) + ?? false + } + + var bundledDirectory: URL { + Bundle.main.resourceURL! + .appendingPathComponent("resource") + .appendingPathComponent("copilot") + } + + var externalDirectory: URL { + let directory = FileManager.default + .urls(for: .documentDirectory, in: .userDomainMask) + .first! + .appendingPathComponent("copilot") + + if !FileManager.default.fileExists(atPath: directory.path) { + try? FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true) + } + + return directory + } + + var recordingDirectory: URL { + FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first! + .appendingPathComponent("cache") + .appendingPathComponent("CombatRecord") + } +} + +// MARK: - Copilot List extension MAAViewModel { func startCopilot() async throws { status = .pending defer { handleEarlyReturn(backTo: .idle) } - guard let copilot, - let params = copilot.params - else { + if useCopilotList { + try await ensureHandle() + + logTrace("开始添加任务到战斗列表") + + guard copilotListConfig.items.firstIndex(where: { $0.enabled }) != nil else { + logTrace("没有启用的战斗列表任务") + return + } + + for (_, item) in copilotListConfig.items.enumerated() where item.enabled { + + let config = RegularCopilotConfiguration( + filename: item.filename, + formation: copilotListConfig.formation, + add_trust: copilotListConfig.add_trust, + is_raid: item.is_raid, + use_sanity_potion: copilotListConfig.use_sanity_potion, + need_navigate: item.need_navigate, + navigate_name: item.navigate_name + ) + + let copilot = CopilotConfiguration.regular(config) + + guard let params = copilot.params else { + continue + } + + logTrace("添加关卡:\(item.navigate_name) \(item.is_raid ? "突袭" : "普通")") + + do { + _ = try await handle?.appendTask(type: .Copilot, params: params) + } catch { + logError("添加关卡失败:\(error.localizedDescription)") + return + } + } + + isCopilotListRunning = true + try await handle?.start() + + } else { + guard let copilot, + let params = copilot.params + else { + return + } + + try await ensureHandle() + + switch copilot { + case .regular: + _ = try await handle?.appendTask(type: .Copilot, params: params) + case .sss: + _ = try await handle?.appendTask(type: .SSSCopilot, params: params) + } + + try await handle?.start() + } + + status = .busy + } + + func addToCopilotList(copilot: MAACopilot, url: URL) { + let name = copilot.doc?.title ?? url.lastPathComponent + + let stage_name = copilot.stage_name + guard !stage_name.isEmpty else { + logError("关卡名不能为空") return } - try await ensureHandle() + guard let navigate_name = MapHelper.findMap(stage_name)?.code else { + logError("找不到关卡 '\(stage_name)' 的地图数据,请更新资源") - switch copilot { - case .regular: - _ = try await handle?.appendTask(type: .Copilot, params: params) - case .sss: - _ = try await handle?.appendTask(type: .SSSCopilot, params: params) + let alert = NSAlert() + alert.messageText = "地图数据不存在" + alert.informativeText = "找不到关卡 '\(stage_name)' 的地图数据,是否需要更新资源?" + alert.alertStyle = .warning + alert.addButton(withTitle: "更新") + alert.addButton(withTitle: "取消") + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + showResourceUpdate = true + } + return } - try await handle?.start() + // is_raid: DifficultyFlags == .raid or .normal_raid + // If DifficultyFlags is .normal_raid, add normal item first + let is_raid = + copilot.difficulty?.rawValue == DifficultyFlags.raid.rawValue + || copilot.difficulty?.rawValue == DifficultyFlags.normal_raid.rawValue + if copilot.difficulty?.rawValue == DifficultyFlags.normal_raid.rawValue { + let item = CopilotItemConfiguration( + enabled: true, + filename: url.path, + name: name, + is_raid: false, + need_navigate: true, + navigate_name: navigate_name + ) + copilotListConfig.items.append(item) + } - status = .busy + let item = CopilotItemConfiguration( + enabled: true, + filename: url.path, + name: name, + is_raid: is_raid, + need_navigate: true, + navigate_name: navigate_name + ) + copilotListConfig.items.append(item) + } + + func removeFromCopilotList(at index: Int) { + copilotListConfig.items.remove(at: index) + } + + func moveCopilotItem(from source: Int, to destination: Int) { + copilotListConfig.items.move(fromOffsets: IndexSet(integer: source), toOffset: destination) + } + + func getCurrentCopilotStageName() -> String? { + guard isCopilotListRunning else { return nil } + return copilotListConfig.items.first(where: { $0.enabled }).map { item in + "\(item.navigate_name) \(item.is_raid ? "突袭" : "普通")" + } + } + + private func initCopilotListConfig() { + if let serializedString = serializedCopilotListConfig, + let data = serializedString.data(using: .utf8), + let config = try? JSONDecoder().decode(CopilotListConfiguration.self, from: data) + { + self.copilotListConfig = config + } else { + if serializedCopilotListConfig != nil { + logError("Failed to decode CopilotListConfiguration from saved data.") + } + } + + $copilotListConfig + .sink { [weak self] newConfig in + guard let self else { return } + + do { + let data = try JSONEncoder().encode(newConfig) + if let jsonString = String(data: data, encoding: .utf8) { + if jsonString != self.serializedCopilotListConfig { + self.serializedCopilotListConfig = jsonString + } + } else { + logError("Failed to serialize CopilotListConfiguration to string.") + } + } catch { + logError("Failed to encode CopilotListConfiguration: \(error.localizedDescription)") + } + } + .store(in: &cancellables) } } -// MARK: Utility +// MARK: - Utility extension MAAViewModel { func recognizeRecruit() async throws { @@ -698,3 +963,88 @@ extension MAAViewModel { try await client.terminate() } } + +// MARK: - Value Extensions + +extension URL { + var copilots: [URL] { + guard + let urls = try? FileManager.default.contentsOfDirectory( + at: self, + includingPropertiesForKeys: [.contentTypeKey], + options: .skipsHiddenFiles) + else { return [] } + + return urls.filter { url in + let value = try? url.resourceValues(forKeys: [.contentTypeKey]) + return value?.contentType == .json + } + .sorted { lhs, rhs in + lhs.lastPathComponent < rhs.lastPathComponent + } + } +} + +extension Set where Element == URL { + var urls: [URL] { sorted { $0.lastPathComponent < $1.lastPathComponent } } +} + +// MARK: - Download Model + +private struct CopilotResponse: Codable { + let data: CopilotData + + struct CopilotData: Codable { + let content: String + } +} + +// MARK: - NSItemProvider Extension + +extension NSItemProvider { + @MainActor fileprivate func loadURL() async throws -> URL { + let handle = ProgressActor() + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let progress = loadObject(ofClass: URL.self) { object, error in + if let error { + continuation.resume(throwing: error) + return + } + + guard let object else { + continuation.resume(throwing: MAAError.emptyItemObject) + return + } + + continuation.resume(returning: object) + } + + Task { + await handle.bind(progress: progress) + } + } + } onCancel: { + Task { + await handle.cancel() + } + } + } +} + +private actor ProgressActor { + private var progress: Progress? + private var cancelled = false + + func bind(progress: Progress) { + guard !cancelled else { return } + self.progress = progress + progress.resume() + } + + func cancel() { + cancelled = true + progress?.cancel() + } +} diff --git a/MeoAsstMac/Navigation/CopilotContent.swift b/MeoAsstMac/Navigation/CopilotContent.swift index f65545737..54c59a76f 100644 --- a/MeoAsstMac/Navigation/CopilotContent.swift +++ b/MeoAsstMac/Navigation/CopilotContent.swift @@ -11,69 +11,55 @@ struct CopilotContent: View { @EnvironmentObject private var viewModel: MAAViewModel @Binding var selection: URL? - @State private var copilots = Set() - @State private var downloading = false - @State private var expanded = false + private func toggleCopilotList() { + viewModel.useCopilotList.toggle() + if viewModel.useCopilotList && viewModel.status == .idle { + // 战斗列表模式,未运行默认展示全局设置 + viewModel.copilotDetailMode = .copilotConfig + } + } var body: some View { - List(selection: $selection) { - DisclosureGroup(isExpanded: $expanded) { - ForEach(bundledCopilots, id: \.self) { url in - Text(url.lastPathComponent) - } - } label: { - Text("内置作业") - .font(.subheadline) - .fontWeight(.semibold) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .onTapGesture { - withAnimation { - expanded.toggle() - } - } - } - - Section { - ForEach(copilots.urls, id: \.self) { url in - Text(url.lastPathComponent) - } - } header: { - HStack { - Text("外部作业(可拖入文件)") - if downloading { - Spacer() - ProgressView().controlSize(.small) - } - } + Group { + if viewModel.useCopilotList { + CopilotList() + } else { + RegularCopilotList(selection: $selection) } } .toolbar(content: listToolbar) - .animation(.default, value: copilots) - .animation(.default, value: downloading) - .onAppear(perform: loadUserCopilots) - .onDrop(of: [.fileURL], isTargeted: .none, perform: addCopilots) - .onReceive(viewModel.$copilotDetailMode, perform: deselectCopilot) - .onReceive(viewModel.$downloadCopilot, perform: downloadCopilot) - .onReceive(viewModel.$videoRecoginition, perform: selectNewCopilot) - .fileImporter( - isPresented: $viewModel.showImportCopilot, - allowedContentTypes: [.json], - allowsMultipleSelection: true, - onCompletion: addCopilots) + // 这里我不太会处理双向数据绑定,先用这种方式 + .onChange(of: selection) { newValue in + viewModel.selectedCopilotURL = newValue + viewModel.copilotDetailMode = newValue == nil ? .log : .copilotConfig + } + .onChange(of: viewModel.selectedCopilotURL) { newValue in + selection = newValue + } } - // MARK: - Toolbar - @ToolbarContentBuilder private func listToolbar() -> some ToolbarContent { ToolbarItemGroup { - Button(action: deleteSelectedCopilot) { - Label("移除", systemImage: "trash") + if !viewModel.useCopilotList { + Button(action: deleteSelectedCopilot) { + Label("移除", systemImage: "trash") + } + .help("移除作业") + .disabled(shouldDisableDeletion) + .keyboardShortcut(.delete, modifiers: [.command]) } - .help("移除作业") - .disabled(shouldDisableDeletion) - .keyboardShortcut(.delete, modifiers: [.command]) + } + + ToolbarItemGroup { + Button(action: toggleCopilotList) { + Label( + "战斗列表", + systemImage: "list.bullet" + ) + } + .background(viewModel.useCopilotList ? .gray.opacity(0.2) : .clear) + .cornerRadius(4) + .help(viewModel.useCopilotList ? "切换回单个作业模式" : "切换到战斗列表模式") } ToolbarItemGroup { @@ -112,72 +98,12 @@ struct CopilotContent: View { } } - private func loadUserCopilots() { - copilots.formUnion(externalDirectory.copilots) - copilots.formUnion(recordingDirectory.copilots) - } - - private func addCopilots(_ providers: [NSItemProvider]) -> Bool { - Task { - for provider in providers { - if let url = try? await provider.loadURL() { - let value = try? url.resourceValues(forKeys: [.contentTypeKey]) - if value?.contentType == .json { - copilots.insert(url) - } else if value?.contentType?.conforms(to: .movie) == true { - try? await viewModel.recognizeVideo(video: url) - } - } - } - self.selection = self.copilots.urls.last - } - - return true - } - - private func addCopilots(_ results: Result<[URL], Error>) { - if case let .success(urls) = results { - copilots.formUnion(urls) - selection = copilots.urls.last - } - } - - private func downloadCopilot(id: String?) { - guard let id else { return } - - let file = - externalDirectory - .appendingPathComponent(id) - .appendingPathExtension("json") - - let url = URL(string: "https://prts.maa.plus/copilot/get/\(id)")! - Task { - self.downloading = true - do { - let data = try await URLSession.shared.data(from: url).0 - let response = try JSONDecoder().decode(CopilotResponse.self, from: data) - try response.data.content.write(toFile: file.path, atomically: true, encoding: .utf8) - copilots.insert(file) - self.selection = file - } catch { - print(error) - } - self.downloading = false - } - } - - private func deleteCopilot(url: URL) { - copilots.remove(url) - guard canDelete(url) else { return } - try? FileManager.default.removeItem(at: url) - } - private func deleteSelectedCopilot() { - guard let selection, let index = copilots.urls.firstIndex(of: selection) else { return } + guard let selection, let index = viewModel.copilots.urls.firstIndex(of: selection) else { return } - deleteCopilot(url: selection) + viewModel.deleteCopilot(url: selection) - let urls = copilots.urls + let urls = viewModel.copilots.urls if index < urls.count { self.selection = urls[index] } else { @@ -185,19 +111,6 @@ struct CopilotContent: View { } } - private func deselectCopilot(_ viewMode: MAAViewModel.CopilotDetailMode) { - if viewMode != .copilotConfig { - selection = nil - } - } - - private func selectNewCopilot(url: URL?) { - if let url { - copilots.insert(url) - selection = copilots.urls.last - } - } - // MARK: - State Wrappers private var shouldDisableDeletion: Bool { @@ -205,137 +118,11 @@ struct CopilotContent: View { } private func isBundled(_ url: URL?) -> Bool { - return url?.path.starts(with: bundledDirectory.path) ?? false + return url?.path.starts(with: viewModel.bundledDirectory.path) ?? false } - - private func canDelete(_ url: URL?) -> Bool { - [externalDirectory, recordingDirectory] - .compactMap { url?.path.starts(with: $0.path) } - .first(where: { $0 }) - ?? false - } - - // MARK: - File Paths - - private var bundledCopilots: [URL] { bundledDirectory.copilots } - - private var bundledDirectory: URL { - Bundle.main.resourceURL! - .appendingPathComponent("resource") - .appendingPathComponent("copilot") - } - - private var externalDirectory: URL { - let directory = FileManager.default - .urls(for: .documentDirectory, in: .userDomainMask) - .first! - .appendingPathComponent("copilot") - - if !FileManager.default.fileExists(atPath: directory.path) { - try? FileManager.default.createDirectory( - at: directory, withIntermediateDirectories: true) - } - - return directory - } - - private var recordingDirectory: URL { - FileManager.default - .urls(for: .applicationSupportDirectory, in: .userDomainMask) - .first! - .appendingPathComponent("cache") - .appendingPathComponent("CombatRecord") - } -} - -struct CopilotContent_Previews: PreviewProvider { - static var previews: some View { - CopilotContent(selection: .constant(nil)) - .environmentObject(MAAViewModel()) - } -} - -// MARK: - Value Extensions - -extension URL { - fileprivate var copilots: [URL] { - guard - let urls = try? FileManager.default.contentsOfDirectory( - at: self, - includingPropertiesForKeys: [.contentTypeKey], - options: .skipsHiddenFiles) - else { return [] } - - return urls.filter { url in - let value = try? url.resourceValues(forKeys: [.contentTypeKey]) - return value?.contentType == .json - } - .sorted { lhs, rhs in - lhs.lastPathComponent < rhs.lastPathComponent - } - } -} - -extension Set where Element == URL { - fileprivate var urls: [URL] { sorted { $0.lastPathComponent < $1.lastPathComponent } } } -// MARK: - Download Model - -private struct CopilotResponse: Codable { - let data: CopilotData - - struct CopilotData: Codable { - let content: String - } -} - -// MARK: - Convenience Methods - -extension NSItemProvider { - @MainActor fileprivate func loadURL() async throws -> URL { - let handle = ProgressActor() - - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - let progress = loadObject(ofClass: URL.self) { object, error in - if let error { - continuation.resume(throwing: error) - return - } - - guard let object else { - continuation.resume(throwing: MAAError.emptyItemObject) - return - } - - continuation.resume(returning: object) - } - - Task { - await handle.bind(progress: progress) - } - } - } onCancel: { - Task { - await handle.cancel() - } - } - } -} - -private actor ProgressActor { - private var progress: Progress? - private var cancelled = false - - func bind(progress: Progress) { - guard !cancelled else { return } - self.progress = progress - progress.resume() - } - - func cancel() { - cancelled = true - progress?.cancel() - } +#Preview { + CopilotContent(selection: .constant(nil)) + .environmentObject(MAAViewModel()) } diff --git a/MeoAsstMac/Navigation/CopilotDetail.swift b/MeoAsstMac/Navigation/CopilotDetail.swift index ad868e73a..cce684a51 100644 --- a/MeoAsstMac/Navigation/CopilotDetail.swift +++ b/MeoAsstMac/Navigation/CopilotDetail.swift @@ -15,7 +15,14 @@ struct CopilotDetail: View { var body: some View { VStack { - if let url { + if viewModel.useCopilotList { + switch viewModel.copilotDetailMode { + case .copilotConfig: + CopilotListSettings() + case .log: + LogView() + } + } else if let url { CopilotView(url: url) } else { LogView() @@ -25,6 +32,20 @@ struct CopilotDetail: View { .toolbar(content: detailToolbar) } + private struct CopilotListSettings: View { + @EnvironmentObject private var viewModel: MAAViewModel + + var body: some View { + Form { + Section("全局设置") { + Toggle("自动编队", isOn: $viewModel.copilotListConfig.formation) + Toggle("信赖干员", isOn: $viewModel.copilotListConfig.add_trust) + Toggle("允许使用理智药", isOn: $viewModel.copilotListConfig.use_sanity_potion) + } + } + } + } + // MARK: - Toolbar @ToolbarContentBuilder private func detailToolbar() -> some ToolbarContent { @@ -39,13 +60,15 @@ struct CopilotDetail: View { } ToolbarItemGroup { - Button { - viewModel.copilotDetailMode = .log - } label: { - Label("日志", systemImage: "note.text") - .foregroundColor(url == nil ? Color.accentColor : nil) + HStack { + Divider() + + if viewModel.useCopilotList { + CopilotDetailTabButton( + mode: .copilotConfig, icon: "gearshape", selection: $viewModel.copilotDetailMode) + } + CopilotDetailTabButton(mode: .log, icon: "note.text", selection: $viewModel.copilotDetailMode) } - .help("运行日志") } } @@ -59,12 +82,18 @@ struct CopilotDetail: View { .foregroundColor(.secondary) TextField("maa://", text: $prtsCode) .textFieldStyle(.roundedBorder) + .onSubmit { + if let parsedID = prtsCode.parsedID { + viewModel.downloadCopilot(id: parsedID) + showAdd = false + } + } } Divider() Button { - viewModel.downloadCopilot = prtsCode.parsedID + viewModel.downloadCopilot(id: prtsCode.parsedID) showAdd = false } label: { Label("下载作业", systemImage: "arrow.down.doc") @@ -78,7 +107,7 @@ struct CopilotDetail: View { let parsedID = clipboardString.parsedID { prtsCode = clipboardString - viewModel.downloadCopilot = parsedID + viewModel.downloadCopilot(id: parsedID) showAdd = false } } label: { @@ -94,12 +123,32 @@ struct CopilotDetail: View { .frame(maxWidth: .infinity) } .buttonStyle(.bordered) + .fileImporter( + isPresented: $viewModel.showImportCopilot, + allowedContentTypes: [.json], + allowsMultipleSelection: true, + onCompletion: viewModel.addCopilots) } .frame(width: 200) .padding() } } +struct CopilotDetailTabButton: View { + let mode: MAAViewModel.CopilotDetailMode + let icon: String + @Binding var selection: MAAViewModel.CopilotDetailMode + + var body: some View { + Button { + selection = mode + } label: { + Image(systemName: icon) + .foregroundColor(mode == selection ? Color.accentColor : nil) + } + } +} + struct CopilotDetail_Previews: PreviewProvider { static let url = Bundle.main.resourceURL! .appendingPathComponent("resource") diff --git a/MeoAsstMac/Navigation/CopilotList.swift b/MeoAsstMac/Navigation/CopilotList.swift new file mode 100644 index 000000000..76009b337 --- /dev/null +++ b/MeoAsstMac/Navigation/CopilotList.swift @@ -0,0 +1,72 @@ +// +// CopilotList.swift +// MAA +// +// Created by ninekirin on 2025/5/6. +// + +import SwiftUI + +struct CopilotList: View { + @EnvironmentObject private var viewModel: MAAViewModel + + var body: some View { + List { + Section("战斗列表(自动模式)") { + ForEach(Array(viewModel.copilotListConfig.items.enumerated()), id: \.0) { index, item in + HStack { + Toggle( + "", + isOn: .init( + get: { item.enabled }, + set: { viewModel.copilotListConfig.items[index].enabled = $0 } + ) + ) + .labelsHidden() + + Text(item.name) + .frame(maxWidth: .infinity, alignment: .leading) + + if item.is_raid { + Text("突袭") + .foregroundStyle(.secondary) + } + + Menu { + Button(action: { + var newItem = item + newItem.is_raid.toggle() + viewModel.copilotListConfig.items[index] = newItem + }) { + Label( + item.is_raid ? "普通模式" : "突袭模式", + systemImage: item.is_raid ? "shield.slash" : "shield.fill") + } + + Button( + role: .destructive, + action: { + viewModel.removeFromCopilotList(at: index) + } + ) { + Label("删除", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis.circle") + } + .buttonStyle(.borderless) + } + } + .onMove { from, to in + viewModel.moveCopilotItem(from: from.first ?? 0, to: to) + } + } + } + .animation(.default, value: viewModel.copilotListConfig.items.count) + } +} + +#Preview { + CopilotList() + .environmentObject(MAAViewModel()) +} diff --git a/MeoAsstMac/Navigation/MAAContent.swift b/MeoAsstMac/Navigation/MAAContent.swift index 51d1712dc..d537c6da7 100644 --- a/MeoAsstMac/Navigation/MAAContent.swift +++ b/MeoAsstMac/Navigation/MAAContent.swift @@ -14,17 +14,17 @@ struct MAAContent: View { var body: some View { Group { switch sidebar { - case .daily: - TasksContent(selection: $selection.task) - case .copilot: - CopilotContent(selection: $selection.copilot) - case .utility: - UtilityContent(selection: $selection.utility) - case .none: - Text("请从边栏选择功能") + case .daily: + TasksContent(selection: $selection.task) + case .copilot: + CopilotContent(selection: $selection.copilot) + case .utility: + UtilityContent(selection: $selection.utility) + case .none: + Text("请从边栏选择功能") } } - .frame(minWidth: 260) + .frame(minWidth: 290) } } diff --git a/MeoAsstMac/Navigation/MAADetail.swift b/MeoAsstMac/Navigation/MAADetail.swift index 5e80a13e9..c4fde2b35 100644 --- a/MeoAsstMac/Navigation/MAADetail.swift +++ b/MeoAsstMac/Navigation/MAADetail.swift @@ -14,14 +14,14 @@ struct MAADetail: View { var body: some View { Group { switch sidebar { - case .daily: - TaskDetail(id: selection.task) - case .copilot: - CopilotDetail(url: selection.copilot) - case .utility: - UtilityDetail(entry: selection.utility) - case .none: - Text("请选择内容项目") + case .daily: + TaskDetail(id: selection.task) + case .copilot: + CopilotDetail(url: selection.copilot) + case .utility: + UtilityDetail(entry: selection.utility) + case .none: + Text("请选择内容项目") } } .frame(minWidth: 450, minHeight: 360) diff --git a/MeoAsstMac/Navigation/RegularCopilotList.swift b/MeoAsstMac/Navigation/RegularCopilotList.swift new file mode 100644 index 000000000..4c4a61265 --- /dev/null +++ b/MeoAsstMac/Navigation/RegularCopilotList.swift @@ -0,0 +1,76 @@ +// +// RegularCopilotList.swift +// MAA +// +// Created by ninekirin on 2025/5/6. +// + +import SwiftUI + +struct RegularCopilotList: View { + @EnvironmentObject private var viewModel: MAAViewModel + @Binding var selection: URL? + + @State private var expanded = false + + private var bundledCopilots: [URL] { viewModel.bundledDirectory.copilots } + + var body: some View { + List(selection: $selection) { + DisclosureGroup(isExpanded: $expanded) { + ForEach(bundledCopilots, id: \.self) { url in + Text(url.lastPathComponent) + } + } label: { + Text("内置作业") + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { + withAnimation { + expanded.toggle() + } + } + } + + Section { + ForEach(viewModel.copilots.urls, id: \.self) { url in + Text(url.lastPathComponent) + } + } header: { + HStack { + Text("外部作业(可拖入文件)") + if viewModel.downloading { + Spacer() + ProgressView().controlSize(.small) + } + } + } + } + .animation(.default, value: viewModel.copilots) + .animation(.default, value: viewModel.downloading) + .onDrop(of: [.fileURL], isTargeted: .none, perform: viewModel.addCopilots) + .onReceive(viewModel.$copilotDetailMode, perform: deselectCopilot) + .onReceive(viewModel.$videoRecoginition, perform: selectNewCopilot) + } + + private func deselectCopilot(_ viewMode: MAAViewModel.CopilotDetailMode) { + if viewMode != .copilotConfig { + selection = nil + } + } + + private func selectNewCopilot(url: URL?) { + if let url { + viewModel.copilots.insert(url) + selection = viewModel.copilots.urls.last + } + } +} + +#Preview { + RegularCopilotList(selection: .constant(nil)) + .environmentObject(MAAViewModel()) +} diff --git a/MeoAsstMac/Navigation/TaskDetail.swift b/MeoAsstMac/Navigation/TaskDetail.swift index 6d55c2f32..d5ccca428 100644 --- a/MeoAsstMac/Navigation/TaskDetail.swift +++ b/MeoAsstMac/Navigation/TaskDetail.swift @@ -77,9 +77,9 @@ struct TaskDetail: View { HStack { Divider() - ViewDetaiTabButton(mode: .taskConfig, icon: "gearshape", selection: $viewModel.dailyTasksDetailMode) - ViewDetaiTabButton(mode: .log, icon: "note.text", selection: $viewModel.dailyTasksDetailMode) - ViewDetaiTabButton( + ViewDetailTabButton(mode: .taskConfig, icon: "gearshape", selection: $viewModel.dailyTasksDetailMode) + ViewDetailTabButton(mode: .log, icon: "note.text", selection: $viewModel.dailyTasksDetailMode) + ViewDetailTabButton( mode: .timerConfig, icon: "clock.arrow.2.circlepath", selection: $viewModel.dailyTasksDetailMode) } } @@ -93,7 +93,7 @@ struct TaskDetail: View { } } -struct ViewDetaiTabButton: View { +struct ViewDetailTabButton: View { let mode: MAAViewModel.DailyTasksDetailMode let icon: String @Binding var selection: MAAViewModel.DailyTasksDetailMode diff --git a/MeoAsstMac/Navigation/UtilityContent.swift b/MeoAsstMac/Navigation/UtilityContent.swift index 02129dee3..d0f2baea5 100644 --- a/MeoAsstMac/Navigation/UtilityContent.swift +++ b/MeoAsstMac/Navigation/UtilityContent.swift @@ -74,6 +74,7 @@ struct UtilityDetailContent_Previews: PreviewProvider { static var previews: some View { NavigationView { UtilityContent(selection: .constant(.recruit)) + .environmentObject(MAAViewModel()) } } } diff --git a/MeoAsstMac/Task Configurations/CopilotConfiguration.swift b/MeoAsstMac/Task Configurations/CopilotConfiguration.swift index 6221ceb88..2cf3853cc 100644 --- a/MeoAsstMac/Task Configurations/CopilotConfiguration.swift +++ b/MeoAsstMac/Task Configurations/CopilotConfiguration.swift @@ -7,11 +7,23 @@ import Foundation +struct UserAdditional: Codable, Hashable, Identifiable { + var id = UUID() + var name: String + var skill: Int +} + struct RegularCopilotConfiguration: Codable { var enable = true var filename: String var formation = false var add_trust = false + var is_raid = false + var loop_times = 1 + var use_sanity_potion = false + var need_navigate = false + var navigate_name: String? + var user_additional: [UserAdditional]? = nil } struct SSSCopilotConfiguration: Codable { diff --git a/MeoAsstMac/Task Configurations/CopilotItemConfiguration.swift b/MeoAsstMac/Task Configurations/CopilotItemConfiguration.swift new file mode 100644 index 000000000..d1280deec --- /dev/null +++ b/MeoAsstMac/Task Configurations/CopilotItemConfiguration.swift @@ -0,0 +1,19 @@ +import Foundation + +struct CopilotItemConfiguration: Codable { + var enabled: Bool = true + var filename: String + var name: String + var is_raid: Bool + var need_navigate: Bool + var navigate_name: String + + enum CodingKeys: String, CodingKey { + case enabled + case filename + case name + case is_raid + case need_navigate + case navigate_name + } +} diff --git a/MeoAsstMac/Task Configurations/CopilotListConfiguration.swift b/MeoAsstMac/Task Configurations/CopilotListConfiguration.swift new file mode 100644 index 000000000..f12edcb5c --- /dev/null +++ b/MeoAsstMac/Task Configurations/CopilotListConfiguration.swift @@ -0,0 +1,30 @@ +// +// CopilotListConfiguration.swift +// MAA +// +// Created by ninekirin on 2025/5/6. +// + +import Foundation + +struct CopilotListConfiguration: Codable { + var items: [CopilotItemConfiguration] = [] + var formation: Bool = true + var add_trust: Bool = false + var use_sanity_potion: Bool = false + + enum CodingKeys: String, CodingKey { + case items + case formation + case add_trust + case use_sanity_potion + } +} + +extension CopilotListConfiguration { + func jsonString() throws -> String { + let encoder = JSONEncoder() + let data = try encoder.encode(self) + return String(data: data, encoding: .utf8) ?? "" + } +} diff --git a/MeoAsstMac/Task Configurations/MAATaskConfiguration.swift b/MeoAsstMac/Task Configurations/MAATaskConfiguration.swift index bc96103d7..6d538f6c6 100644 --- a/MeoAsstMac/Task Configurations/MAATaskConfiguration.swift +++ b/MeoAsstMac/Task Configurations/MAATaskConfiguration.swift @@ -28,7 +28,7 @@ extension MAATaskConfiguration { } } -// MARK: JSON TaskParams +// MARK: - JSON TaskParams extension MAAHandle { func appendTask(_ task: MAATask) throws -> Int32 { diff --git a/MeoAsstMac/Utils/MapHelper.swift b/MeoAsstMac/Utils/MapHelper.swift new file mode 100644 index 000000000..bad8c52e6 --- /dev/null +++ b/MeoAsstMac/Utils/MapHelper.swift @@ -0,0 +1,53 @@ +// +// MapHelper.swift +// MAA +// +// Created by ninekirin on 2025/5/8. +// + +import Foundation + +struct MapInfo: Codable { + var code: String? + var filename: String? + var levelId: String? + var name: String? + var stageId: String? + var height: Int + var width: Int + + private enum CodingKeys: String, CodingKey { + case code = "code" + case filename = "filename" + case levelId = "levelId" + case name = "name" + case stageId = "stageId" + case height = "height" + case width = "width" + } +} + +class MapHelper { + static private(set) var mapData: [MapInfo] = [] + + static func loadMapData() { + let path = Bundle.main.resourceURL! + .appendingPathComponent("resource") + .appendingPathComponent("Arknights-Tile-Pos") + .appendingPathComponent("overview.json") + + guard let data = try? Data(contentsOf: path), + let jsonObj = try? JSONDecoder().decode([String: MapInfo].self, from: data) + else { + return + } + + mapData = Array(jsonObj.values) + } + + static func findMap(_ mapId: String) -> MapInfo? { + return mapData.first { map in + [map.code, map.name, map.stageId, map.levelId].contains { $0 == mapId } + } + } +} diff --git a/MeoAsstMac/Views/CopilotView.swift b/MeoAsstMac/Views/CopilotView.swift index b00662e38..9edf05d27 100644 --- a/MeoAsstMac/Views/CopilotView.swift +++ b/MeoAsstMac/Views/CopilotView.swift @@ -11,6 +11,11 @@ struct CopilotView: View { @EnvironmentObject private var viewModel: MAAViewModel let url: URL + @State private var userAdditionals: [UserAdditional] = [] + @State private var additionalOperName: String = "" + @State private var additionalOperSkill: String = "1" + @State private var showUserAdditional: Bool = false + var body: some View { if let copilot = MAACopilot(url: url) { VStack(spacing: 20) { @@ -37,8 +42,9 @@ struct CopilotView: View { } } - private var copilot: MAACopilot? { - MAACopilot(url: url) + private func addToCopilotList() { + guard let pilot = MAACopilot(url: url) else { return } + viewModel.addToCopilotList(copilot: pilot, url: url) } // MARK: - Copilot Config @@ -47,13 +53,81 @@ struct CopilotView: View { switch viewModel.copilot { case .regular(let innerConfig): let binding = Binding { - innerConfig + var config = innerConfig + config.user_additional = userAdditionals.isEmpty ? nil : userAdditionals + return config } set: { newValue in + userAdditionals = newValue.user_additional ?? [] viewModel.copilot = .regular(newValue) } - HStack { - Toggle("自动编队", isOn: binding.formation) - Toggle("信赖干员", isOn: binding.add_trust) + VStack { + // 战斗列表相关 + Button(action: addToCopilotList) { + Label("添加到战斗列表", systemImage: "plus.rectangle.on.rectangle") + } + .buttonStyle(.borderedProminent) + + if viewModel.copilotListConfig.items.contains(where: { $0.filename == innerConfig.filename }) { + Text("已添加到战斗列表") + .foregroundColor(.green) + } else { + Text("未添加到战斗列表") + .foregroundColor(.red) + } + + // 自动编队相关 + HStack { + VStack(alignment: .leading) { + Toggle("自动编队", isOn: binding.formation) + if binding.formation.wrappedValue { + Toggle("追加低信赖干员", isOn: binding.add_trust) + Toggle("追加自定干员", isOn: $showUserAdditional) + } + } + } + // 仅自动编队选中时展示 + if binding.formation.wrappedValue { + if showUserAdditional { + VStack(alignment: .leading, spacing: 4) { + Text("追加自定干员") + .font(.subheadline) + ForEach($userAdditionals) { $additional in + HStack { + TextField("干员名", text: $additional.name) + .frame(width: 100) + TextField("技能", text: Binding( + get: { String($additional.skill.wrappedValue) }, + set: { $additional.skill.wrappedValue = Int($0) ?? 1 } + )) + .frame(width: 40) + Button(action: { + userAdditionals.removeAll { $0.id == additional.id } + updateCopilotUserAdditionals() + }) { + Image(systemName: "minus.circle.fill").foregroundColor(.red) + } + } + } + HStack { + TextField("干员名", text: $additionalOperName) + .frame(width: 100) + TextField("技能", text: $additionalOperSkill) + .frame(width: 40) + Button(action: { + guard !additionalOperName.isEmpty, let skill = Int(additionalOperSkill) else { return } + let newAdd = UserAdditional(name: additionalOperName, skill: skill) + userAdditionals.append(newAdd) + additionalOperName = "" + additionalOperSkill = "1" + updateCopilotUserAdditionals() + }) { + Image(systemName: "plus.circle.fill").foregroundColor(.green) + } + } + } + .padding(.vertical, 4) + } + } } case .sss(let innerConfig): @@ -112,13 +186,30 @@ struct CopilotView: View { Text(toolmen.sorted { $0.key < $1.key }.map { "\($1)\($0)" }.joined(separator: ", ")) } } + + private func updateCopilotUserAdditionals() { + if case .regular(var config) = viewModel.copilot { + if bindingFormation() && showUserAdditional { + config.user_additional = userAdditionals.isEmpty ? nil : userAdditionals + } else { + config.user_additional = nil + } + viewModel.copilot = .regular(config) + } + } + private func bindingFormation() -> Bool { + if case .regular(let config) = viewModel.copilot { + return config.formation + } + return false + } } struct CopilotView_Previews: PreviewProvider { static let url = Bundle.main.resourceURL! .appendingPathComponent("resource") .appendingPathComponent("copilot") - .appendingPathComponent("SSS_约翰老妈新建地块") + .appendingPathComponent("OF-1_credit_fight") .appendingPathExtension("json") static var previews: some View { diff --git a/MeoAsstMac/Views/TaskTimerView.swift b/MeoAsstMac/Views/TaskTimerView.swift index 7c2a119c4..a55e9a1c0 100644 --- a/MeoAsstMac/Views/TaskTimerView.swift +++ b/MeoAsstMac/Views/TaskTimerView.swift @@ -25,16 +25,18 @@ struct TaskTimerView: View { ) } } - .alert("允许阻止系统睡眠", - isPresented: $showingAlertForPreventingSleep, - actions: { - Button("允许") { - viewModel.preventSystemSleeping = true - } - Button("取消", role: .cancel) {} - }, message: { - Text("日常任务定时执行会在系统休眠之后失效, 打开此功能可以阻止系统自动睡眠") - }) + .alert( + "允许阻止系统睡眠", + isPresented: $showingAlertForPreventingSleep, + actions: { + Button("允许") { + viewModel.preventSystemSleeping = true + } + Button("取消", role: .cancel) {} + }, + message: { + Text("日常任务定时执行会在系统休眠之后失效, 打开此功能可以阻止系统自动睡眠") + }) } private func showAlertIfNeeded() { @@ -53,12 +55,15 @@ struct TaskTimerItem: View { var body: some View { HStack { - Button(action: { - onDelete() - }, label: { - Image(systemName: "minus.circle") - .foregroundColor(.red) - }) + Button( + action: { + onDelete() + }, + label: { + Image(systemName: "minus.circle") + .foregroundColor(.red) + } + ) .buttonStyle(PlainButtonStyle()) Spacer() @@ -79,9 +84,12 @@ struct TaskTimerItem: View { Spacer() - Toggle(isOn: $taskTimer.isEnabled, label: { - Text("开启") - }) + Toggle( + isOn: $taskTimer.isEnabled, + label: { + Text("开启") + } + ) .toggleStyle(.switch) .onChange(of: taskTimer.isEnabled) { if $0 { diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index b069039e6..c22845ef8 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -376,6 +376,26 @@ } } }, + "CopilotListCompleted" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动战斗列表已全部完成!" + } + } + } + }, + "CopilotListError" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动战斗列表出错,已停止执行后续任务" + } + } + } + }, "CurrentStage %@" : { "localizations" : { "ko" : { @@ -497,6 +517,15 @@ } } } + }, + "Failed to decode CopilotListConfiguration from saved data." : { + + }, + "Failed to encode CopilotListConfiguration: %@" : { + + }, + "Failed to serialize CopilotListConfiguration to string." : { + }, "FailedToOpenClient" : { "localizations" : { @@ -1584,6 +1613,12 @@ }, "下载作业" : { + }, + "下载失败:%@" : { + + }, + "下载成功:%@" : { + }, "不使用" : { @@ -1789,6 +1824,9 @@ }, "允许使用 Gzip" : { + }, + "允许使用理智药" : { + }, "允许阻止系统睡眠" : { "localizations" : { @@ -1802,6 +1840,9 @@ }, "免费单抽" : { + }, + "全局设置" : { + }, "全部取消" : { "localizations" : { @@ -1835,6 +1876,9 @@ } } } + }, + "关卡名不能为空" : { + }, "关卡数据获取失败: %@" : { @@ -1898,6 +1942,12 @@ }, "分队开局密文板" : { + }, + "切换到战斗列表模式" : { + + }, + "切换回单个作业模式" : { + }, "初始化失败: %@" : { @@ -2088,6 +2138,9 @@ }, "如果长时间连接不上或出错,请尝试下载使用“文件” > “PlayCover链接…”中的最新版本" : { + }, + "完成关卡:%@" : { + }, "官服" : { @@ -2162,6 +2215,9 @@ }, "已拥有干员:%lld" : { + }, + "已添加到战斗列表" : { + }, "希望" : { @@ -2171,6 +2227,9 @@ }, "干员列表" : { + }, + "干员名" : { + }, "干员寻访" : { @@ -2210,9 +2269,15 @@ } } } + }, + "开始关卡:%@" : { + }, "开始唤醒" : { + }, + "开始添加任务到战斗列表" : { + }, "开始游戏" : { @@ -2261,6 +2326,15 @@ } } } + }, + "战斗出错:%@" : { + + }, + "战斗列表" : { + + }, + "战斗列表(自动模式)" : { + }, "手动确认1星" : { @@ -2276,6 +2350,12 @@ }, "打开自定义排班文件夹…" : { + }, + "找不到关卡 '%@' 的地图数据,请更新资源" : { + + }, + "技能" : { + }, "投资后进二层" : { @@ -2386,9 +2466,6 @@ }, "日常任务定时执行会在系统休眠之后失效, 打开此功能可以阻止系统自动睡眠" : { - }, - "日志" : { - }, "日志文件出错: %@" : { @@ -2435,6 +2512,9 @@ } } } + }, + "普通模式" : { + }, "更新失败:%@" : { @@ -2476,6 +2556,9 @@ }, "未拥有干员:%lld" : { + }, + "未添加到战斗列表" : { + }, "未知排班" : { @@ -2525,6 +2608,9 @@ }, "沙洲遗闻" : { + }, + "没有启用的战斗列表任务" : { + }, "活动商店" : { @@ -2560,6 +2646,22 @@ }, "添加作业" : { + }, + "添加关卡:%@ %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "new", + "value" : "添加关卡:%1$@ %2$@" + } + } + } + }, + "添加关卡失败:%@" : { + + }, + "添加到战斗列表" : { + }, "游戏设置" : { "localizations" : { @@ -2654,6 +2756,12 @@ } } } + }, + "突袭" : { + + }, + "突袭模式" : { + }, "策略" : { @@ -2985,6 +3093,9 @@ }, "资源更新来源" : { + }, + "跟着鸭总喝口汤" : { + }, "运行日志" : { @@ -3014,6 +3125,12 @@ } } } + }, + "追加低信赖干员" : { + + }, + "追加自定干员" : { + }, "选择小游戏" : {