Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ Pi は複数のモデルを一つのセッションログに流すことがあ

- **低電力モード** — メニューバーのアニメーションはバッテリー優先の品質で継続します。
- **再接続時の更新** — ネットワークが復帰すると使用量と上限を更新します。
- **トレーナーカード** — コレクションタブの両面カード。表はトレーナー名・番号、累計トークン、図鑑数、色違い、卒業、重複、初捕獲、裏は6匹のチームです。自分で選ぶまでチームは希少な個体と色違いで自動的に埋まり、カードからでも捕獲ログの右クリックメニューからでも選べます。画像としてコピー、PNG として保存でき、色違いの相棒ならホロ枠が付きます。ネットワークに出るものはなく、トークン数はトグルで隠せます。
- **代表ポケモン** — 図鑑で所有している種を選び、育成中のポケモンとは別にメニューバーと任意のフローティングペットへ固定。固定中はメニューバーがタマゴ・孵化・進化を追わなくなりますが、育成の進行は Home で引き続き確認できます。
- **アニメーション品質** — メニューバーのスプライトとフローティングペットの滑らかさを選べます(バッテリー優先/標準/滑らか)。常に表示される2つの表面が同じ設定を共有します。既定の「バッテリー優先」はこの設定が入る前と同じフレームレートで、「標準」「滑らか」はバッテリーを多く使います(実測アイドル CPU 約1.8%/約5.1%)。
- **インタラクティブなフローティングペット** — ホバーで今日の使用量、クリックでメイン画面、右クリックでメニュー、上限アラートは吹き出しで表示。
Expand Down
1 change: 1 addition & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ Pi는 모델 여러 개를 세션 로그 하나로 흘려보낼 수 있습니다

- **저전력 모드** — 메뉴바 애니메이션을 배터리 절약 품질로 계속 표시합니다.
- **재연결 시 새로고침** — 네트워크가 복구되면 사용량과 한도를 새로고침합니다.
- **트레이너 카드** — 컬렉션 탭의 양면 카드. 앞면은 트레이너 이름·번호, 누적 토큰, 도감 수, 이로치, 졸업, 중복, 첫 포획이고 뒷면은 6마리 팀입니다. 직접 고르기 전까지 팀은 가장 희귀하고 이로치인 개체로 저절로 채워지며, 카드나 포획 로그의 우클릭 메뉴에서 고를 수 있습니다. 이미지로 복사하거나 PNG로 저장할 수 있고, 이로치 파트너면 홀로 테두리가 붙습니다. 네트워크로 나가는 것은 없고, 토큰 수는 토글로 가릴 수 있습니다.
- **대표 포켓몬** — 도감에서 보유한 종을 골라 현재 키우는 포켓몬과 별개로 메뉴바와 선택적 플로팅 펫에 고정. 고정 중에는 메뉴바가 알·부화·진화를 따라가지 않지만, 육성 과정은 Home에서 계속 확인할 수 있습니다.
- **애니메이션 품질** — 메뉴바 스프라이트와 플로팅 펫이 얼마나 부드럽게 움직일지 고릅니다(배터리 절약 / 기본 / 부드럽게). 상시 표시되는 두 표면이 같은 설정을 씁니다. 기본값 "배터리 절약"은 이 설정이 생기기 전과 같은 프레임 레이트이고, "기본"·"부드럽게"는 배터리를 더 씁니다(실측 유휴 CPU 약 1.8% / 약 5.1%).
- **인터랙티브 플로팅 펫** — 호버로 오늘 사용량, 클릭으로 메인 창, 우클릭 메뉴, 한도 알림은 말풍선으로 표시.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ Reset countdowns include the clock time, and the colored percentage stays aligne

- **Low Power Mode** — menu-bar animation continues at Power saver quality.
- **Reconnect refresh** — usage and limits refresh when the network comes back.
- **Trainer card** — a two-sided card in the Collection tab: your trainer name and number, lifetime tokens, Pokédex count, shinies, graduations, duplicates and first catch on the front, a team of six on the back. The team fills itself with your rarest and shiniest until you pick one, from the card or the catch log's right-click menu. Copy it as an image or save it as a PNG; a shiny companion gets a holo frame. Nothing leaves your Mac, and a toggle hides the token count.
- **Representative Pokémon** — pin any owned Pokédex species to the menu bar and optional floating pet, independently of the companion you're raising. While pinned, the menu bar stops following egg, hatch, and evolution changes; raising progress remains visible on Home.
- **Animation quality** — pick how smoothly the menu bar sprite and the floating pet animate (Power saver / Balanced / Smooth). Both always-visible surfaces share the setting. Power saver is the default and keeps the frame rate the app used before this setting existed; Balanced and Smooth trade battery for smoothness (measured idle CPU ~1.8% and ~5.1%).
- **Interactive floating pet** — hover for today's usage, click to open the main window, right-click for a menu, and show limit alerts as speech bubbles.
Expand Down
35 changes: 35 additions & 0 deletions Sources/PokeTokenBar/Core/CompanionModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ enum Rarity: String, Codable, Sendable {
/// 유일한 소비자는 프리미엄 알의 보증 관문(`hatch` 의 `line.rarity.sortRank < tier.sortRank`) —
/// 뽑힌 등급이 산 보증보다 낮은지 판정한다. 순서가 뒤집히면 고급/희귀 알이 조용히 낮은 등급을
/// 통과시키므로 `testSortRankOrdersRarityAscendingByValue` 가 순서를 고정한다.
/// The trainer card's automatic team also ranks by it, rarest graduates first.
var sortRank: Int {
switch self {
case .common: return 0
Expand Down Expand Up @@ -655,6 +656,18 @@ struct CompanionState: Codable, Sendable {
var candyGrantTier: [String: Int] = [:]
// 사탕 지급 첫 실행 시드 완료 — 업데이트 직후 이미 100%였던 창의 소급 지급 차단.
var candyFeatureSeeded = false
/// Trainer card team in slot order, as individual ids (`DexEntry.id`, or the raised Pokémon's
/// `profile.instanceID`, which its graduated entry keeps). nil = never picked one.
///
/// Kept while `teamIsAutomatic` is on: going back to the automatic team must not throw away a
/// hand-picked line-up, or one stray click would wipe six deliberate choices with no undo.
var teamEntryIDs: [String]? = nil
/// true = the card picks the team itself and `teamEntryIDs` is only the stash to come back to.
var teamIsAutomatic = true
/// Random number printed on the trainer card, assigned the first time the card is opened.
var trainerID: Int? = nil
/// Optional name printed on the trainer card. Empty = no name row.
var trainerName = ""

init() {}

Expand Down Expand Up @@ -694,6 +707,11 @@ struct CompanionState: Codable, Sendable {
inventory = c.lenient([String: Int].self, forKey: .inventory, default: [:])
candyGrantTier = c.lenient([String: Int].self, forKey: .candyGrantTier, default: [:])
candyFeatureSeeded = c.lenient(Bool.self, forKey: .candyFeatureSeeded, default: false)
teamEntryIDs = c.lenientOptional([String].self, forKey: .teamEntryIDs)
// Saves written before the stash existed only had the list: having one meant it was shown.
teamIsAutomatic = c.lenientOptional(Bool.self, forKey: .teamIsAutomatic) ?? (teamEntryIDs == nil)
trainerID = c.lenientOptional(Int.self, forKey: .trainerID)
trainerName = c.lenient(String.self, forKey: .trainerName, default: "")
}

/// 졸업 기록 또는 현재 개체가 실제로 도달한 단계에 이 종이 포함되는가.
Expand Down Expand Up @@ -754,6 +772,23 @@ struct CompanionState: Codable, Sendable {
representativeUnownForm = nil
}
}

/// The team only holds individuals the save still has: a released Pokémon leaves it, and so do
/// duplicates or ids a hand-edited save made up.
mutating func reconcileTeamSelection() {
guard let picked = teamEntryIDs else { return }
var owned = Set(dex.lazy.filter { !$0.isReleased }.map(\.id))
if let raised = active?.profile?.instanceID { owned.insert(raised) }
var seen = Set<String>()
teamEntryIDs = Array(picked.filter { owned.contains($0) && seen.insert($0).inserted }
.prefix(TrainerCard.teamSize))
}
}

enum TrainerCard {
static let teamSize = 6
static let nameLimit = 16
static let idRange = 10_000...99_999
}

// NOTE: 부화 후보는 더 이상 하드코딩하지 않는다 — CompanionStore.chooseBase() 가
Expand Down
192 changes: 192 additions & 0 deletions Sources/PokeTokenBar/Core/CompanionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ final class CompanionStore {
defaults.set(shopDifficulty, forKey: "shopDifficulty")
migratePokemonProfilesIfNeeded()
refreshRepresentativeSubject()
refreshTeamKeySet()
if state.active != nil { displayState = .idle }
}

Expand Down Expand Up @@ -804,6 +805,7 @@ final class CompanionStore {
eventUntil = clock().addingTimeInterval(6)
state.active = nil
state.reconcileRepresentativeSelection() // 졸업 체인이 dex 로 옮겨져 선택은 정상적으로 유지된다
state.reconcileTeamSelection()
activeGeneration += 1
currentLine = nil
state.eggUsage = 0 // 새 알은 처음부터 인큐베이션
Expand Down Expand Up @@ -1040,6 +1042,7 @@ final class CompanionStore {
// 놓아준 종도 이제 dex 에 있으므로 대표 선택은 유지된다. 손상 상태 파일 등으로 정말 보유가
// 끊긴 경우만 자동 추적으로 복귀한다.
state.reconcileRepresentativeSelection()
state.reconcileTeamSelection() // a released Pokémon leaves the team
activeGeneration += 1
currentLine = nil
state.eggUsage = 0 // 새 알은 처음부터 인큐베이션(재부화에 5M 필요)
Expand Down Expand Up @@ -1701,6 +1704,194 @@ final class CompanionStore {
completedGrowth + min(SaveTransfer.maxTokenValue, max(0, currentStageUsage)))
}

// MARK: Trainer card

struct TrainerCardStats: Equatable, Sendable {
let lifetimeTokens: Int
let speciesCount: Int
let speciesTotal: Int
let shinyCount: Int
let graduatedCount: Int
/// Individuals from a line already in the log — what a second Pikachu adds to the collection.
let duplicateCount: Int
let firstCatch: Date?
let rarityCounts: [Rarity: Int]
}

/// Catch log counts, the raised Pokémon included, next to the Pokédex species count.
///
/// Individuals you let go are out of every individual count: they are gone, so crediting the
/// card with a released shiny — or calling it a duplicate — would count what you no longer have.
/// The Pokédex line is the exception, and not the card's call: `dexSpecies` folds **owned
/// species** whatever became of the individual, so a released Pokémon keeps its cells there.
var trainerCardStats: TrainerCardStats {
let kept = dexEntries.filter { !$0.isReleased }
// Hatching only draws bases in the animated range, but a chain can evolve past it (Sylveon
// from Eevee). Those extras are kept out of the count so it never goes over the total.
let counted = PokemonAssets.animatedSpeciesIDs
return TrainerCardStats(
lifetimeTokens: state.usedSinceInstall,
speciesCount: dexSpecies.lazy.filter { counted.contains($0.id) }.count,
speciesTotal: counted.count,
shinyCount: kept.lazy.filter(\.isShiny).count,
graduatedCount: state.dex.lazy.filter { !$0.isReleased }.count,
duplicateCount: kept.count - Set(kept.map(\.baseID)).count,
firstCatch: kept.compactMap(\.caughtAt).min(),
rarityCounts: Dictionary(grouping: kept, by: \.rarity).mapValues(\.count))
}

/// Who the card puts in the frame: the pinned species, or the raised Pokémon when none is
/// pinned. Pinning is a species choice, so the stage and nature of an individual are dropped
/// for the rarity — the card would otherwise print the raised Pokémon's details under another
/// species' sprite.
struct TrainerCardSubject: Equatable, Sendable {
let speciesID: Int?
let isShiny: Bool
let name: String
let detail: String
var unownForm: UnownForm? = nil
}

var cardSubject: TrainerCardSubject {
let subject = representativeSubject
guard let pinned = state.representativeSpeciesID else {
let detail = ([stageText] + [currentNature?.name(state.language)].compactMap { $0 })
.filter { !$0.isEmpty }.joined(separator: " · ")
return TrainerCardSubject(speciesID: subject.speciesID, isShiny: subject.isShiny,
name: displayName, detail: detail,
unownForm: subject.unownForm)
}
// The main Pokédex aggregates the Unown forms, so the pinned letter only reaches the card
// through the representative subject: without it the frame prints another letter's sprite.
let species = dexSpecies.first { $0.id == pinned }
return TrainerCardSubject(speciesID: pinned, isShiny: subject.isShiny,
name: UnownForm.displayName(species?.name ?? "#\(pinned)",
speciesID: pinned,
form: subject.unownForm),
detail: species.map { l.rarityLabel($0.rarity) } ?? "",
unownForm: subject.unownForm)
}

var trainerID: Int? { state.trainerID }
var trainerName: String { state.trainerName }

/// Assigned on first use, so a save that never opens the card is left as it was.
func ensureTrainerID() {
guard state.trainerID == nil else { return }
state.trainerID = Int.random(in: TrainerCard.idRange)
save()
}

func setTrainerName(_ name: String) {
let trimmed = String(name.trimmingCharacters(in: .whitespacesAndNewlines).prefix(TrainerCard.nameLimit))
guard trimmed != state.trainerName else { return }
state.trainerName = trimmed
save()
}

/// A catch log row's team id. The raised Pokémon uses its profile id, which its graduated entry
/// keeps, so it stays in the team after graduating. Released individuals cannot join.
func teamKey(for entry: DexEntry) -> String? {
guard !entry.isReleased else { return nil }
return isActiveDexEntry(entry) ? state.active?.profile?.instanceID : entry.id
}

/// Everyone who may join, in catch log order.
var teamCandidates: [DexEntry] { dexEntriesSorted.filter { teamKey(for: $0) != nil } }

var isTeamPicked: Bool { !state.teamIsAutomatic }

/// Team membership for the catch log badges, folded once at the save boundary like
/// `representativeSubject`. Every row body asks whether it is in the team, and the automatic
/// team sorts the whole log to answer, so asking per row would sort it once per visible row.
private(set) var teamKeySet: Set<String> = []

private func refreshTeamKeySet() {
let next = Set(trainerTeamKeys)
if teamKeySet != next { teamKeySet = next }
}

/// The card's team in slot order. Until the user edits it, it is the rarest graduates with
/// shinies first, so the card needs no setup.
var trainerTeam: [DexEntry] {
let candidates = teamCandidates
guard !state.teamIsAutomatic else {
return Array(candidates.filter { !isActiveDexEntry($0) }
.sorted(by: Self.automaticTeamOrder).prefix(TrainerCard.teamSize))
}
let picked = state.teamEntryIDs ?? []
var byKey: [String: DexEntry] = [:]
for entry in candidates { if let key = teamKey(for: entry) { byKey[key] = entry } }
return picked.compactMap { byKey[$0] }
}

private static func automaticTeamOrder(_ a: DexEntry, _ b: DexEntry) -> Bool {
if a.isShiny != b.isShiny { return a.isShiny }
if a.rarity != b.rarity { return a.rarity.sortRank > b.rarity.sortRank }
return (a.caughtAt ?? .distantPast) > (b.caughtAt ?? .distantPast)
}

var trainerTeamKeys: [String] { trainerTeam.compactMap(teamKey(for:)) }

func isInTeam(_ entry: DexEntry) -> Bool {
guard let key = teamKey(for: entry) else { return false }
return teamKeySet.contains(key)
}

var isTeamFull: Bool { teamKeySet.count >= TrainerCard.teamSize }

/// Adds an individual, or puts it in `slot` in place of the member there. Every edit starts from
/// the team as shown, so the first one keeps the automatic picks where they were.
@discardableResult
func addToTeam(_ entry: DexEntry, slot: Int? = nil) -> Bool {
guard let key = teamKey(for: entry) else { return false }
var keys = trainerTeamKeys
guard !keys.contains(key) else { return false }
if let slot, keys.indices.contains(slot) {
keys[slot] = key
} else {
guard keys.count < TrainerCard.teamSize else { return false }
keys.append(key)
}
storeTeam(keys)
return true
}

func removeFromTeam(_ entry: DexEntry) {
guard let key = teamKey(for: entry) else { return }
var keys = trainerTeamKeys
guard let index = keys.firstIndex(of: key) else { return }
keys.remove(at: index)
storeTeam(keys)
}

func moveInTeam(_ entry: DexEntry, by offset: Int) {
var keys = trainerTeamKeys
guard let key = teamKey(for: entry), let from = keys.firstIndex(of: key),
keys.indices.contains(from + offset) else { return }
keys.swapAt(from, from + offset)
storeTeam(keys)
}

/// Switches the team between automatic and picked. Turning it off the first time keeps the
/// automatic line-up as a starting point; afterwards both directions come back to what was
/// last shown, so the switch is never destructive.
func setAutomaticTeam(_ automatic: Bool) {
guard automatic != state.teamIsAutomatic else { return }
let shown = trainerTeamKeys
state.teamIsAutomatic = automatic
if !automatic, state.teamEntryIDs == nil { state.teamEntryIDs = shown }
state.reconcileTeamSelection()
save()
}

private func storeTeam(_ keys: [String]) {
state.teamEntryIDs = keys
state.teamIsAutomatic = false // any edit is a pick
state.reconcileTeamSelection()
save()
}

// MARK: 영속
private func load() {
guard let data = try? Data(contentsOf: fileURL) else { return } // 파일 없음 = 신규 설치
Expand All @@ -1720,6 +1911,7 @@ final class CompanionStore {
}
private func save() {
refreshRepresentativeSubject()
refreshTeamKeySet()
guard let data = try? JSONEncoder().encode(state) else { return }
try? data.write(to: fileURL, options: .atomic) // 부분 쓰기 손상 방지(펫 상태)
}
Expand Down
Loading
Loading