From 4b2f85f8b778fffa797f5e33c9c59173d8bc356a Mon Sep 17 00:00:00 2001 From: Chun Date: Wed, 17 Jun 2026 00:07:43 +0100 Subject: [PATCH] feat(pet): add click-to-pet interaction with bounce, hearts & reactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Click the pet to trigger a squash-stretch bounce animation, floating heart particles, a reaction speech bubble, and a "Pop" sound effect. Three escalating reaction tiers based on consecutive clicks: - 1-2 taps: casual ("Hehe~", "That tickles!") - 3-5 taps: happy ("I love you! 💕", "More pets please!") - 6+ taps: excited ("MAXIMUM LOVE! 💖", "I'm gonna melt~") Fully independent of the existing mood/agent session system — tapping the pet never changes its mood, clears agent bubbles, or interferes with working/waiting state display. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- Sources/App/PetController.swift | 54 ++++++++++++++++++++++++++++++++- Sources/App/PetMoodFX.swift | 38 +++++++++++++++++++++++ Sources/App/PetView.swift | 39 ++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 1 deletion(-) diff --git a/Sources/App/PetController.swift b/Sources/App/PetController.swift index 307bc5c..4ecd206 100644 --- a/Sources/App/PetController.swift +++ b/Sources/App/PetController.swift @@ -1,4 +1,4 @@ -import Foundation +import AppKit import AgentPetCore /// Resolves the aggregate session mood, plays a short `celebrate` burst when @@ -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. diff --git a/Sources/App/PetMoodFX.swift b/Sources/App/PetMoodFX.swift index 8a991b1..bb9c93f 100644 --- a/Sources/App/PetMoodFX.swift +++ b/Sources/App/PetMoodFX.swift @@ -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.. 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)