From f588dc045909290d5541a8c715f5bc2acf886b63 Mon Sep 17 00:00:00 2001 From: winebarrel Date: Sun, 12 Jul 2026 17:25:31 +0900 Subject: [PATCH 1/2] Add wander mode and About menu item Add a "Wander" toggle that makes the cat chase random on-screen points instead of the cursor, reusing the existing chase/idle-chain logic. When it reaches a target it lingers for a random dwell, then a fresh target pulls it away and wakes it, just like cursor movement. State persists via UserDefaults. Also add an "About Neco" item that shows the standard About panel. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Myx5oSKZwGs2zNQNyAX68J --- Neco/NecoApp.swift | 22 +++++++++++++++++++ Neco/Neko.swift | 55 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/Neco/NecoApp.swift b/Neco/NecoApp.swift index 2e58304..732b2bf 100644 --- a/Neco/NecoApp.swift +++ b/Neco/NecoApp.swift @@ -25,6 +25,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private let litter = LitterField() private var pawsItem: NSMenuItem! private var scratchItem: NSMenuItem! + private var wanderItem: NSMenuItem! private var tick = 0 private var side: CGFloat { @@ -34,6 +35,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_: Notification) { let m = NSEvent.mouseLocation neko = Neko(pos: NSPoint(x: m.x - 120, y: m.y - 120)) + neko.wandering = UserDefaults.standard.bool(forKey: "wandering") view = NekoView(frame: NSRect(x: 0, y: 0, width: side, height: side)) view.neko = neko @@ -129,10 +131,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { menu.addItem(clear) menu.addItem(.separator()) + wanderItem = NSMenuItem(title: "Wander", action: #selector(toggleWander), keyEquivalent: "") + wanderItem.target = self + wanderItem.state = neko.wandering ? .on : .off + menu.addItem(wanderItem) + let pause = NSMenuItem(title: "Pause / Resume", action: #selector(togglePause), keyEquivalent: "p") pause.target = self menu.addItem(pause) menu.addItem(.separator()) + + let about = NSMenuItem(title: "About Neco", action: #selector(showAbout), keyEquivalent: "") + about.target = self + menu.addItem(about) // Target NSApp, not self: AppDelegate does not implement terminate:, so a // self target would leave the item auto-disabled (greyed out). let quit = NSMenuItem(title: "Quit Neco", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") @@ -169,6 +180,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { litterView.needsDisplay = true } + @objc private func toggleWander() { + neko.wandering.toggle() + wanderItem.state = neko.wandering ? .on : .off + UserDefaults.standard.set(neko.wandering, forKey: "wandering") + } + + @objc private func showAbout() { + NSApp.activate(ignoringOtherApps: true) + NSApp.orderFrontStandardAboutPanel(nil) + } + private func startTimer() { // Target/selector Timer: it fires on the run loop it is scheduled on (main), // so tick stays on the MainActor without an unchecked assumeIsolated. diff --git a/Neco/Neko.swift b/Neco/Neko.swift index 060c9fe..48ed6a3 100644 --- a/Neco/Neko.swift +++ b/Neco/Neko.swift @@ -23,6 +23,10 @@ enum Tuning { static let animTicks = 7 // run/idle frame swap cadence (~8fps, like the original) static let sleepAnimTicks = 30 // slower sleeping breath + + /// Wander mode: instead of the cursor, chase a random on-screen point; once caught, + /// linger this long (seconds) before picking a new one and running off again. + static let wanderDwellRange = 0.4 ... 2.0 } @MainActor @@ -40,6 +44,19 @@ final class Neko { private var tick = 0 private var stateStart = 0 + /// Wander mode: chase random on-screen points instead of the cursor. Turning it on + /// immediately picks a first destination so the cat sets off right away. + var wandering = false { + didSet { + guard wandering, wandering != oldValue else { return } + pickWanderTarget() + } + } + + private var wanderTarget = NSPoint.zero + private var wanderDwell = 0.0 // seconds to linger at the current target before moving on + private var idleSince = 0 // tick the cat last stopped moving (for the dwell timer) + init(pos: NSPoint) { self.pos = pos } @@ -71,8 +88,18 @@ final class Neko { stateStart = tick } - func update(mouse: NSPoint) { + /// The cat is heading somewhere: running toward a target, or waking to do so. + private var isMoving: Bool { + switch motion { + case .running, .awake: true + default: false + } + } + + func update(mouse rawMouse: NSPoint) { tick += 1 + let mouse = wandering ? advanceWander() : rawMouse + let dx = mouse.x - pos.x let dy = mouse.y - pos.y let dist = hypot(dx, dy) @@ -165,6 +192,32 @@ final class Neko { } } + /// Wander mode's stand-in for the cursor. The cat keeps moving until it settles, + /// lingers for wanderDwell, then a fresh destination pulls the target away and wakes + /// it — the same way real cursor movement does. Returns the current target point. + private func advanceWander() -> NSPoint { + if isMoving { + idleSince = tick + } else if Double(tick - idleSince) / 60.0 >= wanderDwell { + pickWanderTarget() + } + return wanderTarget + } + + /// Pick a fresh random destination for wander mode: a random point (kept off the + /// very edges) on a randomly chosen screen. Reset the dwell clock so the cat runs + /// off before it is eligible to settle again. + private func pickWanderTarget() { + let frame = (NSScreen.screens.randomElement() ?? NSScreen.main)?.frame ?? .zero + let m = Tuning.edgeMargin + wanderTarget = NSPoint( + x: .random(in: (frame.minX + m) ... max(frame.minX + m, frame.maxX - m)), + y: .random(in: (frame.minY + m) ... max(frame.minY + m, frame.maxY - m)) + ) + wanderDwell = .random(in: Tuning.wanderDwellRange) + idleSince = tick + } + /// Which sprite to show right now. var frame: String { let phase = (tick / Tuning.animTicks) % 2 From b104ed9161f29fd32c196dcd669e941c5e6dd31d Mon Sep 17 00:00:00 2001 From: winebarrel Date: Sun, 12 Jul 2026 17:30:38 +0900 Subject: [PATCH 2/2] Fix idleSince comment to match its actual value It is refreshed every tick while moving, so it marks the last moving tick, not the tick the cat stopped. The dwell timer counts from here. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Myx5oSKZwGs2zNQNyAX68J --- Neco/Neko.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Neco/Neko.swift b/Neco/Neko.swift index 48ed6a3..6fe32df 100644 --- a/Neco/Neko.swift +++ b/Neco/Neko.swift @@ -55,7 +55,7 @@ final class Neko { private var wanderTarget = NSPoint.zero private var wanderDwell = 0.0 // seconds to linger at the current target before moving on - private var idleSince = 0 // tick the cat last stopped moving (for the dwell timer) + private var idleSince = 0 // last tick the cat was moving; the dwell timer counts from here init(pos: NSPoint) { self.pos = pos