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
22 changes: 22 additions & 0 deletions Neco/NecoApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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.
Expand Down
55 changes: 54 additions & 1 deletion Neco/Neko.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading