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
171 changes: 171 additions & 0 deletions Neco/Litter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import AppKit

// The mess the cat leaves behind. Neco drops paw prints while it runs and scratch
// marks while it works its claws (grooming or clawing a wall). Marks live in global
// screen coordinates, fade out over their lifetime, and are drawn by LitterView on a
// full-screen overlay panel that sits just below the cat.

/// One mark on the screen. Kept file-private so LitterField owns the whole lifecycle.
private struct Mark {
enum Kind { case paw, scratch }
let kind: Kind
let pos: NSPoint // global screen coordinates
let angle: CGFloat // radians: paw points along travel, scratch is tilted
let birth: Int // tick when created
}

/// Accumulates and ages the cat's marks. `update(neko:)` is called once per 60fps
/// tick; it emits new marks from the cat's motion, culls faded ones, and reports
/// whether the set changed so the view only redraws when it needs to.
@MainActor
final class LitterField {
var pawsEnabled = false
var scratchEnabled = false

fileprivate private(set) var marks: [Mark] = []
private var tick = 0
private var prevPos: NSPoint?
private var lastPawPos: NSPoint? // nil until the first running frame seeds it
private var pawSide: CGFloat = 1 // flip each print for a left/right foot
private var lastScratch = -9999 // safely in the past; never Int.min (subtraction would overflow)

// Tunables. Times are in ticks (60 per second).
private let pawSpacing: CGFloat = 26 // travel distance between paw prints
private let scratchInterval = 9 // ~7 scratch marks per second while clawing
private let pawLife = 60 * 12 // 12s
private let scratchLife = 60 * 20 // 20s
private let maxMarks = 400 // hard cap: drop the oldest beyond this

private func life(of kind: Mark.Kind) -> Int {
kind == .paw ? pawLife : scratchLife
}

/// Advance one tick. Returns true if a mark was added or culled (a new/removed
/// mark needs an immediate redraw; ongoing fade is redrawn on a throttle).
@discardableResult
func update(neko: Neko) -> Bool {
tick += 1
let pos = neko.pos
defer { prevPos = pos }

let before = marks.count
marks.removeAll { tick - $0.birth > life(of: $0.kind) }
var changed = marks.count != before

if pawsEnabled, neko.isRunning {
if let last = lastPawPos, hypot(pos.x - last.x, pos.y - last.y) >= pawSpacing {
let heading = prevPos.map { atan2(pos.y - $0.y, pos.x - $0.x) } ?? 0
// Step sideways from the path so prints alternate like real feet.
let off = 6 * pawSide
let p = NSPoint(x: pos.x + cos(heading + .pi / 2) * off,
y: pos.y + sin(heading + .pi / 2) * off)
add(Mark(kind: .paw, pos: p, angle: heading, birth: tick))
lastPawPos = pos
pawSide *= -1
changed = true
} else if lastPawPos == nil {
lastPawPos = pos // seed on the first running frame; the first step drops no print
}
} else {
lastPawPos = pos // measure spacing fresh from where the next run starts
}

if scratchEnabled, neko.isClawing, tick - lastScratch >= scratchInterval {
// Deterministic scatter (no RNG): derive jitter and tilt from the tick.
let jitter = CGFloat((tick * 37) % 18 - 9)
let tilt = CGFloat((tick * 53) % 50 - 25) * .pi / 180
let p = NSPoint(x: pos.x + jitter, y: pos.y + jitter * 0.5)
add(Mark(kind: .scratch, pos: p, angle: tilt, birth: tick))
lastScratch = tick
changed = true
}

return changed
}

private func add(_ mark: Mark) {
marks.append(mark)
if marks.count > maxMarks {
marks.removeFirst(marks.count - maxMarks)
}
}

var isEmpty: Bool {
marks.isEmpty
}

func clear() {
marks.removeAll()
}

/// Opacity for a mark: full for most of its life, then fading over the last quarter.
fileprivate func alpha(of mark: Mark) -> CGFloat {
let age = CGFloat(tick - mark.birth)
let life = CGFloat(life(of: mark.kind))
let fadeStart = life * 0.75
guard age > fadeStart else { return 1 }
return max(0, 1 - (age - fadeStart) / (life - fadeStart))
}
}

/// Draws every current mark onto the full-screen overlay panel. `originOffset` is the
/// panel's origin in global coordinates, so a global mark maps to a local point by
/// subtracting it.
@MainActor
final class LitterView: NSView {
var field: LitterField?
var originOffset = NSPoint.zero

override var isFlipped: Bool {
false
}

override func draw(_: NSRect) {
NSColor.clear.setFill()
bounds.fill(using: .copy)
guard let field, let ctx = NSGraphicsContext.current?.cgContext else { return }
for mark in field.marks {
let a = field.alpha(of: mark)
let p = NSPoint(x: mark.pos.x - originOffset.x, y: mark.pos.y - originOffset.y)
switch mark.kind {
case .paw: Self.drawPaw(ctx, at: p, angle: mark.angle, alpha: a)
case .scratch: Self.drawScratch(ctx, at: p, angle: mark.angle, alpha: a)
}
}
}

/// A paw print: one big pad plus four toe beans, drawn pointing +y then rotated
/// so it points along the direction of travel.
private static func drawPaw(_ ctx: CGContext, at p: NSPoint, angle: CGFloat, alpha: CGFloat) {
ctx.saveGState()
defer { ctx.restoreGState() }
ctx.translateBy(x: p.x, y: p.y)
ctx.rotate(by: angle - .pi / 2) // shape points +y; align +y with travel
ctx.setFillColor(NSColor(calibratedWhite: 0.12, alpha: 0.5 * alpha).cgColor)
let s: CGFloat = 1.2
ctx.fillEllipse(in: CGRect(x: -5 * s, y: -6 * s, width: 10 * s, height: 8 * s))
let toes = [CGPoint(x: -5, y: 3), CGPoint(x: -2, y: 6.5), CGPoint(x: 2, y: 6.5), CGPoint(x: 5, y: 3)]
for t in toes {
ctx.fillEllipse(in: CGRect(x: (t.x - 1.6) * s, y: (t.y - 1.6) * s,
width: 3.2 * s, height: 3.2 * s))
}
}

/// A scratch mark: three parallel claw streaks with a slight slant.
private static func drawScratch(_ ctx: CGContext, at p: NSPoint, angle: CGFloat, alpha: CGFloat) {
ctx.saveGState()
defer { ctx.restoreGState() }
ctx.translateBy(x: p.x, y: p.y)
ctx.rotate(by: angle)
ctx.setStrokeColor(NSColor(calibratedWhite: 0.1, alpha: 0.45 * alpha).cgColor)
ctx.setLineWidth(1.6)
ctx.setLineCap(.round)
let len: CGFloat = 16
for i in 0 ..< 3 {
let x = CGFloat(i - 1) * 4
ctx.move(to: CGPoint(x: x, y: -len / 2))
ctx.addLine(to: CGPoint(x: x + 1.5, y: len / 2))
}
ctx.strokePath()
}
}
100 changes: 100 additions & 0 deletions Neco/NecoApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var paused = false
private var lastStatusFrame = ""

// The mess overlay: a full-screen click-through panel below the cat that draws
// the paw prints and scratch marks the cat leaves behind.
private var litterPanel: NSPanel!
private var litterView: LitterView!
private let litter = LitterField()
private var pawsItem: NSMenuItem!
private var scratchItem: NSMenuItem!
private var tick = 0

private var side: CGFloat {
CGFloat(Sprites.width) * Tuning.scale
}
Expand Down Expand Up @@ -45,15 +54,81 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
reposition()
panel.orderFrontRegardless()

setupLitterPanel()
setupStatusItem()
startTimer()
}

/// A full-screen, click-through overlay just below the cat, for its paw prints
/// and scratch marks. It spans every screen so the mess follows the cat around.
private func setupLitterPanel() {
let d = UserDefaults.standard
litter.pawsEnabled = d.object(forKey: "pawsEnabled") as? Bool ?? false
litter.scratchEnabled = d.object(forKey: "scratchEnabled") as? Bool ?? false

litterView = LitterView()
litterView.field = litter

litterPanel = NSPanel(
contentRect: screensFrame(),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
litterPanel.isOpaque = false
litterPanel.backgroundColor = .clear
litterPanel.hasShadow = false
// One level below the cat so the cat always walks on top of its own mess.
litterPanel.level = NSWindow.Level(rawValue: NSWindow.Level.statusBar.rawValue - 1)
litterPanel.ignoresMouseEvents = true
litterPanel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
litterPanel.contentView = litterView
layoutLitterPanel()
litterPanel.orderFrontRegardless()

NotificationCenter.default.addObserver(
self, selector: #selector(screensChanged),
name: NSApplication.didChangeScreenParametersNotification, object: nil
)
}

private func screensFrame() -> NSRect {
NSScreen.screens.reduce(NSRect.zero) { $0.union($1.frame) }
}

private func layoutLitterPanel() {
let frame = screensFrame()
litterPanel.setFrame(frame, display: false)
litterView.frame = NSRect(origin: .zero, size: frame.size)
litterView.originOffset = frame.origin
}

@objc private func screensChanged() {
layoutLitterPanel()
litterView.needsDisplay = true
}

private func setupStatusItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
updateStatusImage()

let menu = NSMenu()

pawsItem = NSMenuItem(title: "Paw Prints", action: #selector(togglePaws), keyEquivalent: "")
pawsItem.target = self
pawsItem.state = litter.pawsEnabled ? .on : .off
menu.addItem(pawsItem)

scratchItem = NSMenuItem(title: "Scratch Marks", action: #selector(toggleScratch), keyEquivalent: "")
scratchItem.target = self
scratchItem.state = litter.scratchEnabled ? .on : .off
menu.addItem(scratchItem)

let clear = NSMenuItem(title: "Clear Mess", action: #selector(clearMess), keyEquivalent: "")
clear.target = self
menu.addItem(clear)
menu.addItem(.separator())

let pause = NSMenuItem(title: "Pause / Resume", action: #selector(togglePause), keyEquivalent: "p")
pause.target = self
menu.addItem(pause)
Expand All @@ -77,6 +152,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
paused.toggle()
}

@objc private func togglePaws() {
litter.pawsEnabled.toggle()
pawsItem.state = litter.pawsEnabled ? .on : .off
UserDefaults.standard.set(litter.pawsEnabled, forKey: "pawsEnabled")
}

@objc private func toggleScratch() {
litter.scratchEnabled.toggle()
scratchItem.state = litter.scratchEnabled ? .on : .off
UserDefaults.standard.set(litter.scratchEnabled, forKey: "scratchEnabled")
}

@objc private func clearMess() {
litter.clear()
litterView.needsDisplay = true
}

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 All @@ -88,10 +180,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate {

@objc private func onTick() {
guard !paused else { return }
tick += 1
neko.update(mouse: NSEvent.mouseLocation)
reposition()
view.needsDisplay = true
updateStatusImage()

// Redraw the mess when a mark appears or vanishes; otherwise refresh at ~10fps
// so fading marks dim smoothly without a full-screen repaint every frame.
let changed = litter.update(neko: neko)
if changed || (!litter.isEmpty && tick % 6 == 0) {
litterView.needsDisplay = true
}
}

private func reposition() {
Expand Down
29 changes: 27 additions & 2 deletions Neco/Neko.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ enum Tuning {
static let stopDistance: CGFloat = 24 // "caught the cursor" threshold
static let wakeDistance: CGFloat = 40 // cursor must move this far to wake a sleeping cat
static let edgeMargin: CGFloat = 24 // how close to a screen edge counts as "at the wall"
static let togiChance = 0.5 // when not at a wall, chance of scratching claws (togi) instead of pawing (jare) on stop

// Idle-chain durations, in seconds. Mirror oneko.h (there 1 tick ~= 0.125s):
// STOP 4, JARE 10, KAKI 4, AKUBI 6, AWAKE 3, TOGI 10.
Expand Down Expand Up @@ -47,6 +48,24 @@ final class Neko {
Double(tick - stateStart) / 60.0
}

/// The cat is on the move; drops paw prints behind it (see LitterField).
var isRunning: Bool {
if case .running = motion {
true
} else {
false
}
}

/// The cat is sharpening its claws (togi): at a screen edge, or — per
/// Tuning.togiChance — at a random stop anywhere. Scratch marks drop in this state.
var isClawing: Bool {
switch motion {
case .togi: true
default: false
}
}

private func setState(_ m: Motion) {
motion = m
stateStart = tick
Expand Down Expand Up @@ -119,7 +138,8 @@ final class Neko {
}
}

/// Leaving STOP: groom if the cat has run up against a screen edge, else paw.
/// Leaving STOP: groom if the cat has run up against a screen edge. Otherwise, at
/// random, sharpen claws (togi) facing a random way, else paw (jare).
private func chooseAfterStop() {
let screen = NSScreen.screens.first { $0.frame.contains(pos) } ?? NSScreen.main
if let f = screen?.frame {
Expand All @@ -137,7 +157,12 @@ final class Neko {
togiDir = "d"; return setState(.togi)
}
}
setState(.jare)
if Double.random(in: 0 ..< 1) < Tuning.togiChance {
togiDir = ["u", "d", "l", "r"].randomElement() ?? "d"
setState(.togi)
} else {
setState(.jare)
}
}

/// Which sprite to show right now.
Expand Down
Loading