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..6fe32df 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 // last tick the cat was moving; the dwell timer counts from here + 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