Skip to content
Merged
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: 53 additions & 1 deletion Sources/App/PetController.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Foundation
import AppKit
import AgentPetCore

/// Resolves the aggregate session mood, plays a short `celebrate` burst when
Expand Down Expand Up @@ -219,6 +219,58 @@ final class PetController: ObservableObject {
StatusBarController.shared.refreshTitle()
}

// MARK: - Pet tap interaction

@Published private(set) var isPetted = false
@Published private(set) var petReactionLine: String = ""
@Published private(set) var petTapCount: Int = 0

private var petBounceTimer: Timer?
private var petLineTimer: Timer?
private var petCooldown = false
private var consecutivePets = 0
private var lastPetTime: Date?

private static let petReactions: [[String]] = [
["Hehe~", "That tickles!", "Hi there! πŸ‘‹", "Oh! Hello~", "*purrs*", "Nyaa~"],
["I love you! πŸ’•", "More pets please!", "Best human ever!", "So happy~ ✨"],
["MAXIMUM LOVE! πŸ’–", "Can't stop smiling! πŸ₯°", "I'm gonna melt~"],
]

func petTap() {
guard !petCooldown else { return }
petCooldown = true

let now = Date()
if let last = lastPetTime, now.timeIntervalSince(last) < 3.0 {
consecutivePets += 1
} else {
consecutivePets = 1
}
lastPetTime = now

let tier = consecutivePets >= 6 ? 2 : consecutivePets >= 3 ? 1 : 0
petReactionLine = Self.petReactions[tier].randomElement() ?? "Hehe~"
petTapCount += 1

isPetted = true
petBounceTimer?.invalidate()
petBounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
Task { @MainActor [weak self] in self?.isPetted = false }
}

petLineTimer?.invalidate()
petLineTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { _ in
Task { @MainActor [weak self] in self?.petReactionLine = "" }
}

NSSound(named: "Pop")?.play()

DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
self?.petCooldown = false
}
}

// MARK: - Agent list

/// Builds the structured session list and a plain-text fallback chatLine.
Expand Down
38 changes: 38 additions & 0 deletions Sources/App/PetMoodFX.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,44 @@ struct PetMotion {
}
}

/// Hearts that float upward and fade out when the user pets (clicks) the pet.
struct PetHearts: View {
let size: CGFloat

@State private var animate = false

private static let hearts: [(x: CGFloat, y: CGFloat, scale: CGFloat)] = [
(-0.22, -0.50, 0.8),
( 0.18, -0.58, 1.0),
(-0.05, -0.70, 0.7),
( 0.28, -0.42, 0.9),
(-0.30, -0.62, 0.6),
]

var body: some View {
ZStack {
ForEach(0..<Self.hearts.count, id: \.self) { i in
let h = Self.hearts[i]
Image(systemName: "heart.fill")
.font(.system(size: 10 * h.scale))
.foregroundStyle(.pink)
.offset(
x: h.x * size,
y: animate ? h.y * size : 0
)
.opacity(animate ? 0 : 0.85)
.scaleEffect(animate ? 0.3 : 1.0)
}
}
.allowsHitTesting(false)
.onAppear {
withAnimation(.easeOut(duration: 0.8)) {
animate = true
}
}
}
}

/// Mood overlays shared by all pet renderers: sparkles while celebrating and a
/// "?" bubble while waiting.
struct MoodAccessories: View {
Expand Down
39 changes: 39 additions & 0 deletions Sources/App/PetView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,47 @@ struct FloatingPetView: View {
}
}
PetView(size: pet.petPoint)
.overlay {
if pet.petTapCount > 0 {
PetHearts(size: pet.petPoint)
.id(pet.petTapCount)
}
}
.overlay(alignment: .top) {
if !pet.petReactionLine.isEmpty {
Text(pet.petReactionLine)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.primary.opacity(0.85))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
Capsule()
.fill(.regularMaterial)
)
.overlay(
Capsule()
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
)
.shadow(color: .black.opacity(0.15), radius: 4, y: 2)
.offset(y: -16)
.transition(.asymmetric(
insertion: .scale(scale: 0.5, anchor: .bottom).combined(with: .opacity),
removal: .opacity
))
}
}
.scaleEffect(
x: pet.isPetted ? 1.12 : 1.0,
y: pet.isPetted ? 0.82 : 1.0,
anchor: .bottom
)
.animation(.interpolatingSpring(stiffness: 300, damping: 8), value: pet.isPetted)
.onTapGesture {
PetController.shared.petTap()
}
}
.fixedSize(horizontal: true, vertical: true)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: pet.petReactionLine)
.background(
GeometryReader { proxy in
Color.clear.preference(key: PetContentSizeKey.self, value: proxy.size)
Expand Down
Loading