Skip to content
Closed
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
54 changes: 54 additions & 0 deletions Sources/PokeTokenBar/Core/CompanionModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,60 @@ enum PokemonBalance {
let denom = Double(kk * (kk + 1)) / 2.0
return Int((total * Double(i) / denom).rounded())
}

// MARK: 난이도 배율 (설정 슬라이더)

/// 사용자 조절 배율의 허용 범위. 1 미만이면 기본보다 빠르고·싸며, 1 초과면 느리고·비싸다.
static let difficultyRange: ClosedRange<Double> = 0.0001...20.0
/// 기본값 — 이 값에서 모든 밸런스가 위 상수표 그대로다(기존 동작과 동일).
static let defaultDifficulty: Double = 1.0

/// 저장값을 허용 범위로 조인다. UserDefaults 는 `defaults write` 로 외부에서 쓸 수 있어
/// 0·음수·NaN 이 실제로 들어올 수 있고, 배율 0 은 임계 0(진행률 0 나눗셈·퇴화한 진화 루프)이 된다.
static func clampDifficulty(_ value: Double) -> Double {
guard value.isFinite else { return defaultDifficulty }
return min(max(value, difficultyRange.lowerBound), difficultyRange.upperBound)
}

/// 기본값 × 난이도 — 임계·가격 공통. **상수표 자체는 스케일하지 않는다**: 등급 알 가격이
/// `graduationTotal` 의 *비율*로 파생되므로(FreshEgg.price), 표를 건드리면 성장 배율이 상점
/// 가격까지 끌고 간다. 소비 지점에서만 곱해 두 배율을 서로 독립으로 유지한다.
static func scaled(_ base: Int, by difficulty: Double) -> Int {
Int((Double(base) * clampDifficulty(difficulty)).rounded())
}

// MARK: 슬라이더 위치 ↔ 배율 (로그 매핑)
//
// 범위가 다섯 자릿수를 넘어 선형 슬라이더로는 못 쓴다 — 1.0(기본)이 트랙의 5% 지점에 몰려
// 실사용 구간을 손으로 집을 수 없고, 0.1 미만은 전부 한 틱에 뭉갠다. 위치를 로그로 매핑해
// "배율 10배당 이동거리"를 일정하게 만든다.

/// 기본값(100%)이 트랙에서 차지하는 폭. 로그축이라 1.0 근처 1px 이 배율 7% 에 해당해서,
/// 값 기준으로 스냅하면 창이 1px 보다 좁아져 드래그가 100% 를 그냥 지나친다 —
/// 되돌릴 수단이 없어지므로 창을 **위치 기준**으로 잡는다(트랙의 ±1%).
private static let defaultSnapWidth = 0.01

/// 슬라이더 위치(0…1) → 배율.
static func difficulty(atPosition position: Double) -> Double {
let lo = difficultyRange.lowerBound, hi = difficultyRange.upperBound
let p = min(max(position, 0), 1)
if abs(p - difficultyPosition(defaultDifficulty)) < defaultSnapWidth { return defaultDifficulty }
return snapDifficulty(lo * pow(hi / lo, p))
}

/// 배율 → 슬라이더 위치(0…1).
static func difficultyPosition(_ value: Double) -> Double {
let lo = difficultyRange.lowerBound, hi = difficultyRange.upperBound
return log(clampDifficulty(value) / lo) / log(hi / lo)
}

/// 로그 슬라이더에서 나온 값을 유효숫자 2자리로 정리한다 — 없으면 1.0473 같은 값이 그대로 표시된다.
/// 기본값 되돌리기는 여기가 아니라 `difficulty(atPosition:)` 의 위치 스냅이 담당한다.
static func snapDifficulty(_ value: Double) -> Double {
guard value > 0 else { return difficultyRange.lowerBound }
let magnitude = pow(10, (log10(value)).rounded(.down) - 1)
return ((value / magnitude).rounded() * magnitude)
}
}

/// 인벤토리 아이템 종류 — 확장 대비 enum(현재 이상한 사탕 1종). rawValue 로 CompanionState.inventory 에 저장.
Expand Down
148 changes: 129 additions & 19 deletions Sources/PokeTokenBar/Core/CompanionStore.swift

Large diffs are not rendered by default.

51 changes: 51 additions & 0 deletions Sources/PokeTokenBar/Core/Localization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,44 @@ struct L {
t("대표로 설정", "Set as representative", "代表ポケモンに設定", "Establecer como representante")
}
var representativeBadge: String { t("대표", "Representative", "代表", "Representante") }
// MARK: 난이도
var difficultySectionTitle: String { t("난이도", "Difficulty", "難易度", "Dificultad") }
var difficultyGrowthLabel: String { t("성장", "Growth", "成長", "Crecimiento") }
var difficultyShopLabel: String { t("상점 가격", "Shop prices", "ショップ価格", "Precios de la tienda") }
var difficultyHint: String {
t("기본값 100% 기준이에요 — 낮추면 빨리 자라고 싸지고, 높이면 그 반대예요",
"Percentages of the default balance — lower grows faster and costs less, higher does the opposite",
"標準バランスに対する割合です — 下げると早く育ち安くなり、上げるとその逆になります",
"Porcentajes del balance predeterminado: si los bajas, crece más rápido y cuesta menos; si los subes, al revés")
}
/// 슬라이더 옆 현재 배율 — 퍼센트 표기(1.0 = 100%). 범위가 0.01%~2000% 라 작은 쪽은 자릿수를
/// 더 보여준다(고정 소수점이면 0.01% 와 0.05% 가 똑같이 "0%" 로 뭉갠다).
func difficultyValue(_ value: Double) -> String {
let percent = value * 100
if percent >= 10 { return String(format: "%.0f%%", percent) }
if percent >= 1 { return String(format: "%.1f%%", percent) }
return String(format: "%.2f%%", percent)
}
// MARK: 알 스폰 (설정)
var spawnEggSectionTitle: String { t("알 스폰", "Spawn Egg", "タマゴを出す", "Generar huevo") }
var spawnEggSpeciesLabel: String { t("종", "Species", "ポケモン", "Especie") }
var spawnEggChoosePlaceholder: String { t("선택…", "Choose…", "選択…", "Elegir…") }
var spawnEggButton: String { t("스폰", "Spawn", "出す", "Generar") }
var spawnEggHint: String {
t("지금 포켓몬을 놓아주고 고른 종으로 부화할 알을 놓아요 — 도감엔 남지 않아요",
"Releases your current Pokémon and leaves an egg that hatches into the chosen species — no Pokédex credit",
"今のポケモンを手放し、選んだ種でかえるタマゴを置きます — 図鑑には残りません",
"Libera a tu Pokémon actual y deja un huevo que eclosionará en la especie elegida (no cuenta para la Pokédex)")
}
var spawnEggUnavailable: String {
t("종 목록을 불러오지 못했어요 — 연결을 확인해 주세요",
"Couldn't load the species list — check your connection",
"種の一覧を読み込めませんでした — 接続を確認してください",
"No se pudo cargar la lista de especies: revisa tu conexión")
}
func generationLabel(_ number: Int) -> String {
t("\(number)세대", "Gen \(number)", "第\(number)世代", "Gen \(number)")
}
// MARK: 플로팅 펫
var floatingPetSectionTitle: String { t("플로팅 펫", "Floating Pet", "フローティングペット", "Mascota flotante") }
var floatingPetEnableLabel: String { t("플로팅 펫 표시", "Show floating pet", "フローティングペットを表示", "Mostrar mascota flotante") }
Expand All @@ -140,6 +178,19 @@ struct L {
"Tu Pokémon flota sobre la pantalla — arrástralo para moverlo")
}
var floatingPetSizeLabel: String { t("크기", "Size", "サイズ", "Tamaño") }
/// 푸터 눈 아이콘의 툴팁(켜져 있을 때) — 켜는 쪽 문구는 floatingPetEnableLabel 을 그대로 쓴다.
var floatingPetHideLabel: String {
t("플로팅 펫 숨기기", "Hide floating pet", "フローティングペットを隠す", "Ocultar mascota flotante")
}
var floatingPetSmoothLabel: String {
t("부드러운 애니메이션", "Smooth animation", "なめらかなアニメーション", "Animación fluida")
}
var floatingPetSmoothHint: String {
t("팝오버와 같은 fps 로 움직여요 — 끄면 배터리를 아끼는 대신 뚝뚝 끊겨 보여요",
"Runs at the same fps as the popover — off saves battery but looks choppy",
"ポップオーバーと同じfpsで動きます — オフにすると省電力ですがカクつきます",
"Se mueve a los mismos fps que el popover; al desactivarlo ahorras batería pero se ve entrecortado")
}
/// 지금은 한도 알림만 말풍선으로 뜨지만, 알림 종류가 늘어도 이 라벨은 그대로 쓴다.
var floatingPetBubbleAlertsLabel: String {
t("말풍선으로 알림 받기", "Show notifications as bubbles", "通知を吹き出しで表示", "Mostrar notificaciones como globos")
Expand Down
38 changes: 34 additions & 4 deletions Sources/PokeTokenBar/Core/PokeAPIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import Foundation
struct BaseSpecies: Sendable, Codable {
let id: Int
let captureRate: Int // 3(뮤츠급)~255(캐터피급), 공식 희귀도 신호
/// 언어코드→이름(설정의 알 스폰 목록 표시용). base 인덱스 1쿼리에 실려 오므로 추가 요청이 없다.
/// **비어 있을 수 있다** — REST 폴백 경로와 이 필드 이전에 저장된 디스크 캐시. 표시는 #id 로 폴백한다.
var names: [String: String]? = nil
}

/// 포켓몬 라인 데이터 제공(주입 가능 — 테스트는 스텁 사용).
Expand Down Expand Up @@ -62,9 +65,28 @@ actor PokeAPIClient: PokeProviding {
return dir.appendingPathComponent("base-index.json")
}()
private struct BaseIndexSnapshot: Codable { let fetchedAt: Date; let entries: [BaseSpecies] }

/// 이름 필드가 생기기 전에 저장된 캐시인가. 관대 디코딩(`names` 옵셔널) 덕에 옛 스냅샷도 그대로
/// 읽히지만, 그러면 30일 TTL 이 끝날 때까지 스폰 목록이 전부 "#id" 로 남는다 — **스키마가 바뀐
/// 캐시는 만료로 취급**해 다시 받아야 한다. 디코딩 호환성은 캐시 무효화를 대신해 주지 않는다.
///
/// 재요청이 실패하면 호출부의 catch 가 이 스냅샷으로 폴백한다 — 이름이 없어도 부화 후보로는
/// 멀쩡하므로 오프라인 동작은 그대로다(이름은 있으면 좋은 것이지 부화의 전제가 아니다).
static func indexPredatesNames(_ entries: [BaseSpecies]) -> Bool {
!entries.contains { !($0.names?.isEmpty ?? true) }
}
private struct GraphQLBaseResponse: Decodable {
struct DataBox: Decodable { let pokemonspecies: [Row] }
struct Row: Decodable { let id: Int; let capture_rate: Int }
struct NameRow: Decodable {
struct Language: Decodable { let name: String }
let name: String
let language: Language
}
struct Row: Decodable {
let id: Int
let capture_rate: Int
let pokemonspeciesnames: [NameRow]
}
let data: DataBox
}

Expand All @@ -75,7 +97,8 @@ actor PokeAPIClient: PokeProviding {
if let c = baseIndexCache { return c }
let disk = (try? Data(contentsOf: Self.baseIndexFile))
.flatMap { try? JSONDecoder().decode(BaseIndexSnapshot.self, from: $0) }
if let disk, Date().timeIntervalSince(disk.fetchedAt) < 30 * 86400, !disk.entries.isEmpty {
if let disk, !Self.indexPredatesNames(disk.entries),
Date().timeIntervalSince(disk.fetchedAt) < 30 * 86400, !disk.entries.isEmpty {
baseIndexCache = disk.entries
return disk.entries
}
Expand Down Expand Up @@ -148,12 +171,19 @@ actor PokeAPIClient: PokeProviding {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
// 메타몽(#132)은 위장 리빌 전용 → 일반 부화 풀에서 제외(_neq).
let maxID = PokemonAssets.animatedSpeciesIDs.upperBound
let query = "{ pokemonspecies(where: {evolves_from_species_id: {_is_null: true}, id: {_lte: \(maxID), _neq: \(PokemonOdds.dittoSpeciesID)}}, order_by: {id: asc}) { id capture_rate } }"
// 이름을 같은 쿼리에 실어 온다(중첩 조인) — 설정의 스폰 목록이 329종 이름을 위해
// 별도 요청을 돌리지 않게. 언어는 line() 과 같은 langCodes 단일 소스를 쓴다.
let langFilter = langCodes.map { "\"\($0)\"" }.joined(separator: ", ")
let query = "{ pokemonspecies(where: {evolves_from_species_id: {_is_null: true}, id: {_lte: \(maxID), _neq: \(PokemonOdds.dittoSpeciesID)}}, order_by: {id: asc}) { id capture_rate pokemonspeciesnames(where: {language: {name: {_in: [\(langFilter)]}}}) { name language { name } } } }"
req.httpBody = try JSONSerialization.data(withJSONObject: ["query": query])
let (data, resp) = try await URLSession.shared.data(for: req)
guard (resp as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.badServerResponse) }
let decoded = try JSONDecoder().decode(GraphQLBaseResponse.self, from: data)
let entries = decoded.data.pokemonspecies.map { BaseSpecies(id: $0.id, captureRate: $0.capture_rate) }
let entries = decoded.data.pokemonspecies.map { row in
BaseSpecies(id: row.id, captureRate: row.capture_rate,
names: Dictionary(row.pokemonspeciesnames.map { ($0.language.name, $0.name) },
uniquingKeysWith: { first, _ in first }))
}
guard !entries.isEmpty else { throw URLError(.cannotParseResponse) }
return entries
}
Expand Down
8 changes: 8 additions & 0 deletions Sources/PokeTokenBar/Core/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ final class UsageStore {
var floatingPetBubbleAlerts: Bool {
didSet { defaults.set(floatingPetBubbleAlerts, forKey: "floatingPetBubbleAlerts") }
}
/// 펫을 팝오버와 같은 네이티브 fps(10~16)로 돌릴지. 끄면 `FloatingPetView.frameFloor`(0.4s≈2.5fps)
/// 배터리 캡이 걸린다 — 상시 표시 표면의 idle wakeup 을 억제하는 원래 규율(defect-log §에너지).
/// 켜면 팝오버와 같은 부드러움을 얻는 대신 wakeup 이 늘어난다. 저전력 모드의 정적화는 이 설정과
/// 무관하게 항상 우선한다(`FloatingPetController.shouldAnimate`).
var floatingPetSmoothAnimation: Bool {
didSet { defaults.set(floatingPetSmoothAnimation, forKey: "floatingPetSmoothAnimation") }
}
var disableKeychainAccess: Bool {
didSet {
defaults.set(disableKeychainAccess, forKey: "disableKeychainAccess") // 저장 누락이던 기존 버그 — 재시작 후 풀렸음
Expand Down Expand Up @@ -424,6 +431,7 @@ final class UsageStore {
floatingPetEnabled = d.object(forKey: "floatingPetEnabled") as? Bool ?? false
floatingPetSize = d.object(forKey: "floatingPetSize") as? Double ?? 96
floatingPetBubbleAlerts = d.object(forKey: "floatingPetBubbleAlerts") as? Bool ?? true
floatingPetSmoothAnimation = d.object(forKey: "floatingPetSmoothAnimation") as? Bool ?? true
disableKeychainAccess = d.object(forKey: "disableKeychainAccess") as? Bool ?? false

reschedule()
Expand Down
4 changes: 3 additions & 1 deletion Sources/PokeTokenBar/UI/CompanionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,9 @@ struct SpriteView: View {
}
// GIF 재생 중엔 bob 정지(프레임 자체가 움직임) — 폴백/정적일 때만 상하 움직임
.offset(y: bob && frames.isEmpty && up ? -3 : 0)
.task(id: "\(speciesID.map(String.init) ?? "nil")-\(shiny)") {
// minFrameDelay 를 id 에 포함한다 — 프레임 루프는 시작 시점의 하한을 들고 돌기 때문에,
// 빠지면 설정을 바꿔도 다음 스프라이트 교체 전까지 옛 fps 로 계속 돈다(설정이 안 먹는 것처럼 보인다).
.task(id: "\(speciesID.map(String.init) ?? "nil")-\(shiny)-\(minFrameDelay)") {
// animated 프레임은 id/shiny 변경 시 항상 초기화(이전 개체 프레임 잔상 방지)
frames = []
frameIndex = 0
Expand Down
10 changes: 9 additions & 1 deletion Sources/PokeTokenBar/UI/FloatingPetPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,13 @@ final class PetHostingView: NSHostingView<AnyView> {

struct FloatingPetView: View {
static let frameFloor: TimeInterval = 0.4

/// 푸터 토글의 SF Symbol — 지금 상태를 그린다(보이는 중 = 뜬 눈). 순수 판정이라 뒤집힘을 테스트로 잠근다.
static func visibilitySymbol(visible: Bool) -> String { visible ? "eye" : "eye.slash" }

/// 실제로 적용할 프레임 하한 — 부드러움 설정이 켜지면 네이티브(0, 팝오버와 동일),
/// 꺼지면 배터리 캡(`frameFloor`). 순수 판정이라 두 분기를 테스트로 잠근다.
static func effectiveFrameFloor(smooth: Bool) -> TimeInterval { smooth ? 0 : frameFloor }
var animated: Bool = true
@Environment(UsageStore.self) private var store
@Environment(CompanionStore.self) private var companion
Expand All @@ -427,7 +434,8 @@ struct FloatingPetView: View {
}

SpriteView(speciesID: subject.speciesID, size: size, animated: animated,
shiny: subject.isShiny, minFrameDelay: Self.frameFloor)
shiny: subject.isShiny,
minFrameDelay: Self.effectiveFrameFloor(smooth: store.floatingPetSmoothAnimation))
.frame(width: size, height: size)
.zIndex(0)
}
Expand Down
9 changes: 9 additions & 0 deletions Sources/PokeTokenBar/UI/PopoverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,15 @@ struct PopoverView: View {
}
}
Spacer()
// 데스크톱 펫 표시 토글 — 설정창의 스위치와 **같은 값**(store.floatingPetEnabled)을 읽고 쓴다.
// @Observable 이라 어느 쪽에서 바꾸든 다른 쪽이 즉시 따라온다(별도 동기화 없음).
Button {
store.floatingPetEnabled.toggle()
} label: {
Image(systemName: FloatingPetView.visibilitySymbol(visible: store.floatingPetEnabled))
}
.buttonStyle(.borderless)
.help(store.floatingPetEnabled ? l.floatingPetHideLabel : l.floatingPetEnableLabel)
Button {
nav.showSettings = true
} label: {
Expand Down
Loading