@shigureni/minion 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,598 @@
1
+ import AppKit
2
+ import CoreGraphics
3
+ import Foundation
4
+
5
+ private let gatewayURL = URL(string: "ws://127.0.0.1:4756/ws")!
6
+
7
+ @main
8
+ @MainActor
9
+ final class AppDelegate: NSObject, NSApplicationDelegate {
10
+ private var pets: [String: Pet] = [:]
11
+ private var systemAsleep = false
12
+ private var timer: Timer?
13
+ private var webSocketTask: URLSessionWebSocketTask?
14
+
15
+ static func main() {
16
+ let app = NSApplication.shared
17
+ let delegate = AppDelegate()
18
+ app.delegate = delegate
19
+ app.setActivationPolicy(.accessory)
20
+ app.run()
21
+ }
22
+
23
+ func applicationDidFinishLaunching(_ notification: Notification) {
24
+ setupPowerObservers()
25
+ startLoop()
26
+ connectGateway()
27
+ }
28
+
29
+ private func setupPowerObservers() {
30
+ let nc = NSWorkspace.shared.notificationCenter
31
+ nc.addObserver(self, selector: #selector(systemWillSleep),
32
+ name: NSWorkspace.willSleepNotification, object: nil)
33
+ nc.addObserver(self, selector: #selector(systemDidWake),
34
+ name: NSWorkspace.didWakeNotification, object: nil)
35
+ }
36
+
37
+ @objc private func systemWillSleep() {
38
+ systemAsleep = true
39
+ }
40
+
41
+ @objc private func systemDidWake() {
42
+ systemAsleep = false
43
+ }
44
+
45
+ private func startLoop() {
46
+ let t = Timer(timeInterval: 1.0 / 30.0, target: self,
47
+ selector: #selector(tick), userInfo: nil, repeats: true)
48
+ RunLoop.main.add(t, forMode: .common)
49
+ timer = t
50
+ }
51
+
52
+ @objc private func tick() {
53
+ let mouseLocation = NSEvent.mouseLocation
54
+ // 実ウィンドウが重なっている場所では最前面に上げない(貫通してポップアップしない)ように、
55
+ // カーソルがどれかのペットの上に乗っている時だけ重いオクルージョン判定を行う。
56
+ let hovering = pets.values.contains { $0.containsPoint(mouseLocation) }
57
+ let interactionAllowed = !hovering || !Self.isPointOccludedByRealWindow(mouseLocation)
58
+
59
+ for pet in pets.values {
60
+ pet.tick(systemAsleep: systemAsleep, mouseLocation: mouseLocation, interactionAllowedAtCursor: interactionAllowed)
61
+ }
62
+ }
63
+
64
+ // 通常のアプリウィンドウ(kCGNormalWindowLevel = 0)がその座標を覆っているかを調べる。
65
+ // 覆われている時にペットを floating まで持ち上げると、他アプリの上に貫通して見えてしまうため。
66
+ // Dock(層20)やControl Center(層25)などのシステムUIは、見た目は小さくても当たり判定が
67
+ // 画面全体に及ぶことがあるため、通常ウィンドウの層(0)だけに絞って除外する。
68
+ private static func isPointOccludedByRealWindow(_ cocoaPoint: NSPoint) -> Bool {
69
+ let referenceHeight = NSScreen.screens.first?.frame.height ?? 0
70
+ let quartzPoint = CGPoint(x: cocoaPoint.x, y: referenceHeight - cocoaPoint.y)
71
+
72
+ guard let list = CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID) as? [[String: Any]] else {
73
+ return false
74
+ }
75
+ let myPid = ProcessInfo.processInfo.processIdentifier
76
+ for entry in list {
77
+ guard let ownerPid = entry[kCGWindowOwnerPID as String] as? Int32, ownerPid != myPid else { continue }
78
+ guard let layer = entry[kCGWindowLayer as String] as? Int, layer == 0 else { continue }
79
+ guard let bounds = entry[kCGWindowBounds as String] as? [String: CGFloat] else { continue }
80
+ let rect = CGRect(x: bounds["X"] ?? 0, y: bounds["Y"] ?? 0,
81
+ width: bounds["Width"] ?? 0, height: bounds["Height"] ?? 0)
82
+ if rect.contains(quartzPoint) { return true }
83
+ }
84
+ return false
85
+ }
86
+
87
+ // MARK: - Gateway (~/.claude/sessions を監視する CLI 側の Bun サーバ) との WebSocket 接続
88
+
89
+ private struct GatewaySession: Decodable {
90
+ let id: String
91
+ let state: String
92
+ let clipIndex: Int
93
+ let name: String
94
+ }
95
+
96
+ private struct GatewayMessage: Decodable {
97
+ let sessions: [GatewaySession]
98
+ }
99
+
100
+ private func connectGateway() {
101
+ let task = URLSession(configuration: .default).webSocketTask(with: gatewayURL)
102
+ webSocketTask = task
103
+ task.resume()
104
+ receiveGatewayMessage()
105
+ }
106
+
107
+ private func receiveGatewayMessage() {
108
+ webSocketTask?.receive { [weak self] result in
109
+ Task { @MainActor in
110
+ guard let self else { return }
111
+ switch result {
112
+ case .success(let message):
113
+ switch message {
114
+ case .string(let text):
115
+ self.handleGatewayMessage(text)
116
+ case .data(let data):
117
+ if let text = String(data: data, encoding: .utf8) {
118
+ self.handleGatewayMessage(text)
119
+ }
120
+ @unknown default:
121
+ break
122
+ }
123
+ self.receiveGatewayMessage()
124
+ case .failure:
125
+ self.scheduleGatewayReconnect()
126
+ }
127
+ }
128
+ }
129
+ }
130
+
131
+ private func scheduleGatewayReconnect() {
132
+ webSocketTask = nil
133
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in
134
+ self?.connectGateway()
135
+ }
136
+ }
137
+
138
+ private func handleGatewayMessage(_ text: String) {
139
+ guard let data = text.data(using: .utf8),
140
+ let message = try? JSONDecoder().decode(GatewayMessage.self, from: data) else { return }
141
+ reconcile(message.sessions)
142
+ }
143
+
144
+ // Gateway から届いたセッション一覧に合わせて Pet を増減させ、running/sleeping を反映する。
145
+ private func reconcile(_ sessions: [GatewaySession]) {
146
+ let incomingIds = Set(sessions.map(\.id))
147
+ for id in Set(pets.keys).subtracting(incomingIds) {
148
+ pets[id]?.remove()
149
+ pets.removeValue(forKey: id)
150
+ }
151
+
152
+ for session in sessions {
153
+ let running = session.state == "running"
154
+ let pet = pets[session.id] ?? {
155
+ let pet = Pet()
156
+ pets[session.id] = pet
157
+ return pet
158
+ }()
159
+ pet.sessionRunning = running
160
+ pet.sessionName = session.name
161
+ pet.applyAction(clipIndex: session.clipIndex)
162
+ }
163
+ }
164
+ }
165
+
166
+ // Custom window so clicking the pet doesn't steal key/main from the active app.
167
+ final class PetWindow: NSWindow {
168
+ override var canBecomeKey: Bool { false }
169
+ override var canBecomeMain: Bool { false }
170
+ }
171
+
172
+ // 1匹のペットが持つ見た目・アニメーション・自律行動・ドラッグ状態をすべて内包する。
173
+ // Gateway のセッション1件につき1つ生成/破棄される。
174
+ @MainActor
175
+ final class Pet {
176
+ let window: PetWindow
177
+ let petView: PetView
178
+
179
+ var sessionRunning = true
180
+
181
+ private var isGrabbed = false
182
+ private var grabOffset: NSPoint = .zero
183
+
184
+ private var facingLeft = false
185
+ private var clipIndex = PetView.idleClipIndex
186
+ private var frameIndex = 0
187
+ private var frameTicksAccumulated = 0
188
+ private var velocityX: CGFloat = 0
189
+ private var velocityY: CGFloat = 0
190
+
191
+ // Gatewayから最後に指示された行動。えさやり演出が終わったらこれに復帰する。
192
+ private var networkClipIndex = PetView.idleClipIndex
193
+ private var feedTicksRemaining = 0
194
+
195
+ var sessionName = ""
196
+ private var nameLabelVisible = false
197
+ private let nameLabel: NSTextField
198
+ private let nameWindow: NSWindow
199
+
200
+ private static let windowSize: CGFloat = 64.0
201
+ // 通常時は壁紙のすぐ上(他ウィンドウの背後)に沈める。カーソルが重なった/掴んでいる間だけ
202
+ // floating まで一時的に持ち上げて最前面に出し、クリックを確実に受け取れるようにする。
203
+ private static let restingLevel = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) + 1)
204
+ private static let activeLevel = NSWindow.Level.floating
205
+
206
+ init() {
207
+ let size = NSSize(width: Self.windowSize, height: Self.windowSize)
208
+ let window = PetWindow(
209
+ contentRect: NSRect(origin: .zero, size: size),
210
+ styleMask: [.borderless],
211
+ backing: .buffered,
212
+ defer: false
213
+ )
214
+ window.isOpaque = false
215
+ window.backgroundColor = .clear
216
+ window.hasShadow = false
217
+ window.level = Self.restingLevel
218
+ window.ignoresMouseEvents = true
219
+ window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
220
+
221
+ let petView = PetView(frame: NSRect(origin: .zero, size: size))
222
+ window.contentView = petView
223
+
224
+ self.window = window
225
+ self.petView = petView
226
+
227
+ let nameLabel = NSTextField(labelWithString: "")
228
+ nameLabel.font = .systemFont(ofSize: 11, weight: .medium)
229
+ nameLabel.textColor = .white
230
+ nameLabel.alignment = .center
231
+ nameLabel.drawsBackground = false
232
+ nameLabel.wantsLayer = true
233
+ nameLabel.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.75).cgColor
234
+ nameLabel.layer?.cornerRadius = 6
235
+ nameLabel.layer?.masksToBounds = true
236
+
237
+ let nameWindow = NSWindow(
238
+ contentRect: .zero,
239
+ styleMask: [.borderless],
240
+ backing: .buffered,
241
+ defer: false
242
+ )
243
+ nameWindow.isOpaque = false
244
+ nameWindow.backgroundColor = .clear
245
+ nameWindow.hasShadow = true
246
+ nameWindow.level = Self.activeLevel
247
+ nameWindow.ignoresMouseEvents = true
248
+ nameWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
249
+ nameWindow.contentView = nameLabel
250
+
251
+ self.nameLabel = nameLabel
252
+ self.nameWindow = nameWindow
253
+
254
+ petView.onGrab = { [weak self] localPoint in
255
+ guard let self else { return }
256
+ self.isGrabbed = true
257
+ self.grabOffset = localPoint
258
+ self.petView.isGrabbed = true
259
+ }
260
+ petView.onDrag = { [weak self] screenPoint in
261
+ guard let self, self.isGrabbed else { return }
262
+ self.window.setFrameOrigin(
263
+ NSPoint(x: screenPoint.x - self.grabOffset.x,
264
+ y: screenPoint.y - self.grabOffset.y)
265
+ )
266
+ }
267
+ petView.onRelease = { [weak self] in
268
+ guard let self else { return }
269
+ self.isGrabbed = false
270
+ self.petView.isGrabbed = false
271
+ }
272
+ petView.onFeed = { [weak self] in
273
+ self?.feed()
274
+ }
275
+
276
+ if let screen = NSScreen.screens.randomElement() ?? NSScreen.main {
277
+ let f = screen.visibleFrame
278
+ let x = CGFloat.random(in: f.minX...max(f.minX, f.maxX - size.width))
279
+ let y = CGFloat.random(in: f.minY...max(f.minY, f.maxY - size.height))
280
+ window.setFrameOrigin(NSPoint(x: x, y: y))
281
+ }
282
+
283
+ window.orderFrontRegardless()
284
+ }
285
+
286
+ // Gatewayから届いた「次の行動」を適用する。えさやり中は割り込まず、終わってから反映する。
287
+ func applyAction(clipIndex newClip: Int) {
288
+ networkClipIndex = newClip
289
+ guard feedTicksRemaining == 0 else { return }
290
+ beginAction(newClip)
291
+ }
292
+
293
+ // ダブルクリック/右クリックメニューからの即時ローカル操作。Gatewayの指示より優先する。
294
+ func feed() {
295
+ feedTicksRemaining = Int(3.0 * 30.0)
296
+ beginAction(PetView.eatClipIndex)
297
+ }
298
+
299
+ func remove() {
300
+ window.orderOut(nil)
301
+ nameWindow.orderOut(nil)
302
+ }
303
+
304
+ private func setNameLabelVisible(_ visible: Bool) {
305
+ guard visible != nameLabelVisible else { return }
306
+ nameLabelVisible = visible
307
+ if visible {
308
+ positionNameWindow()
309
+ nameWindow.orderFrontRegardless()
310
+ } else {
311
+ nameWindow.orderOut(nil)
312
+ }
313
+ }
314
+
315
+ private func positionNameWindow() {
316
+ guard !sessionName.isEmpty else { return }
317
+ nameLabel.stringValue = sessionName
318
+ nameLabel.sizeToFit()
319
+
320
+ let horizontalPadding: CGFloat = 6
321
+ let verticalPadding: CGFloat = 3
322
+ let width = nameLabel.frame.width + horizontalPadding * 2
323
+ let height = nameLabel.frame.height + verticalPadding * 2
324
+ nameLabel.frame = NSRect(x: horizontalPadding, y: verticalPadding,
325
+ width: nameLabel.frame.width, height: nameLabel.frame.height)
326
+
327
+ let petFrame = window.frame
328
+ let x = petFrame.midX - width / 2
329
+ let y = petFrame.minY - height - 4
330
+ nameWindow.setFrame(NSRect(x: x, y: y, width: width, height: height), display: true)
331
+ }
332
+
333
+ func tick(systemAsleep: Bool, mouseLocation: NSPoint, interactionAllowedAtCursor: Bool) {
334
+ if systemAsleep {
335
+ setInteractive(false)
336
+ setNameLabelVisible(false)
337
+ petView.isAsleep = true
338
+ return
339
+ }
340
+
341
+ // mouseUp の取りこぼしで isGrabbed が解除されないまま残るケースへの保険。
342
+ if isGrabbed, NSEvent.pressedMouseButtons & 1 == 0 {
343
+ isGrabbed = false
344
+ petView.isGrabbed = false
345
+ }
346
+
347
+ let hovering: Bool
348
+ if isGrabbed {
349
+ // While being dragged, keep clicks captured. Skip autonomous behavior.
350
+ hovering = true
351
+ setInteractive(true)
352
+ } else {
353
+ hovering = containsPoint(mouseLocation) && interactionAllowedAtCursor
354
+ setInteractive(hovering)
355
+ }
356
+ setNameLabelVisible(hovering)
357
+ if hovering {
358
+ positionNameWindow()
359
+ }
360
+
361
+ // セッションが idle の間は、その場でうずくまって寝る(掴む/なでるはできる)。
362
+ let sleeping = !sessionRunning
363
+ if !sleeping {
364
+ if feedTicksRemaining > 0 {
365
+ feedTicksRemaining -= 1
366
+ if feedTicksRemaining == 0 {
367
+ beginAction(networkClipIndex)
368
+ }
369
+ }
370
+ stepBehavior()
371
+ advanceAnimationFrame()
372
+ }
373
+
374
+ petView.isAsleep = sleeping
375
+ petView.facingLeft = facingLeft
376
+ petView.clipIndex = clipIndex
377
+ petView.frameIndex = frameIndex
378
+ petView.needsDisplay = true
379
+ }
380
+
381
+ // 新しい行動を開始する。表示クリップを切り替え、移動する行動なら向きと速度を選び直す。
382
+ // 「いつ・どの行動にするか」はGateway(またはえさやり)が決め、ここでは物理量だけを初期化する。
383
+ private func beginAction(_ newClip: Int) {
384
+ guard newClip != clipIndex else { return }
385
+ clipIndex = newClip
386
+ frameIndex = 0
387
+ frameTicksAccumulated = 0
388
+
389
+ let clip = PetView.clips[newClip]
390
+ if clip.moveSpeed > 0 {
391
+ let angle = Double.random(in: 0..<(2 * .pi))
392
+ velocityX = clip.moveSpeed * CGFloat(cos(angle))
393
+ velocityY = clip.moveSpeed * CGFloat(sin(angle))
394
+ facingLeft = velocityX < 0
395
+ } else {
396
+ velocityX = 0
397
+ velocityY = 0
398
+ }
399
+ }
400
+
401
+ // 現在の速度を反映して移動し、画面端で跳ね返る。速度がなければ何もしない。
402
+ private func stepBehavior() {
403
+ guard velocityX != 0 || velocityY != 0 else { return }
404
+
405
+ var frame = window.frame
406
+ frame.origin.x += velocityX
407
+ frame.origin.y += velocityY
408
+
409
+ let bounds = currentScreenVisibleFrame()
410
+ if frame.origin.x < bounds.minX {
411
+ frame.origin.x = bounds.minX
412
+ velocityX = abs(velocityX)
413
+ } else if frame.origin.x + frame.width > bounds.maxX {
414
+ frame.origin.x = bounds.maxX - frame.width
415
+ velocityX = -abs(velocityX)
416
+ }
417
+ if frame.origin.y < bounds.minY {
418
+ frame.origin.y = bounds.minY
419
+ velocityY = abs(velocityY)
420
+ } else if frame.origin.y + frame.height > bounds.maxY {
421
+ frame.origin.y = bounds.maxY - frame.height
422
+ velocityY = -abs(velocityY)
423
+ }
424
+ facingLeft = velocityX < 0
425
+ window.setFrameOrigin(frame.origin)
426
+ }
427
+
428
+ private func advanceAnimationFrame() {
429
+ let clip = PetView.clips[clipIndex]
430
+ frameTicksAccumulated += 1
431
+ // 素材本来のfpsの半分の速度でコマ送りする。
432
+ let ticksPerFrame = max(1, Int((60.0 / clip.fps).rounded()))
433
+ if frameTicksAccumulated >= ticksPerFrame {
434
+ frameTicksAccumulated = 0
435
+ frameIndex = (frameIndex + 1) % clip.frameCount
436
+ }
437
+ }
438
+
439
+ private func currentScreenVisibleFrame() -> NSRect {
440
+ let center = NSPoint(x: window.frame.midX, y: window.frame.midY)
441
+ for screen in NSScreen.screens where screen.frame.contains(center) {
442
+ return screen.visibleFrame
443
+ }
444
+ return NSScreen.main?.visibleFrame ?? window.frame
445
+ }
446
+
447
+ // カーソルが重なっている/掴んでいる間だけウィンドウを最前面(floating)へ持ち上げ、クリックを
448
+ // 受け取れるようにする。持ち上げないと、上に重なる Finder のデスクトップ(アイコン)ウィンドウや
449
+ // 通常ウィンドウがクリックを先に奪ってしまい、ペットに mouseDown が届かない。
450
+ // 離れている間は壁紙レベルへ戻し、他ウィンドウの背後に沈めておく。
451
+ private func setInteractive(_ interactive: Bool) {
452
+ window.ignoresMouseEvents = !interactive
453
+ let desired = interactive ? Self.activeLevel : Self.restingLevel
454
+ if window.level != desired {
455
+ window.level = desired
456
+ }
457
+ }
458
+
459
+ func containsPoint(_ screenPoint: NSPoint) -> Bool {
460
+ let origin = window.frame.origin
461
+ let lx = screenPoint.x - origin.x
462
+ let ly = screenPoint.y - origin.y
463
+ return lx >= 0 && lx < Self.windowSize && ly >= 0 && ly < Self.windowSize
464
+ }
465
+ }
466
+
467
+ @MainActor
468
+ final class PetView: NSView {
469
+ struct Clip {
470
+ let row: Int
471
+ let frameCount: Int
472
+ let fps: Double
473
+ let moveSpeed: CGFloat // 0 = その場で再生、>0 = 走るときの移動速度(px/tick)
474
+ let durationRange: ClosedRange<Double> // この動作を続ける秒数
475
+ }
476
+
477
+ // Chicken_Baby_Blue.png (128x304, セル16x16, 8列x19行) のうち基本動作のみ使用。
478
+ // row11(はばたく=ジャンプ大)・row13(ハートのみで単体では絵にならない)・row15-18(未使用)は除外。
479
+ // 実機で見た目を確認した結果、歩く(row1)は静止して見えるため移動なし、
480
+ // ついばむ(row3-6)は実際には小ジャンプ、row12/14は仮に食べる動作として扱う。
481
+ static let clips: [Clip] = [
482
+ Clip(row: 0, frameCount: 4, fps: 4, moveSpeed: 0, durationRange: 3.0...6.0), // 待機
483
+ Clip(row: 1, frameCount: 7, fps: 10, moveSpeed: 0, durationRange: 3.0...6.0), // 歩く(静止して見えるため移動なし)
484
+ Clip(row: 2, frameCount: 8, fps: 12, moveSpeed: 2.0, durationRange: 4.0...7.0), // 走る(唯一の移動動作)
485
+ Clip(row: 3, frameCount: 7, fps: 8, moveSpeed: 0, durationRange: 1.0...2.0), // ジャンプ1
486
+ Clip(row: 4, frameCount: 7, fps: 8, moveSpeed: 0, durationRange: 1.0...2.0), // ジャンプ2
487
+ Clip(row: 5, frameCount: 7, fps: 8, moveSpeed: 0, durationRange: 1.0...2.0), // ジャンプ3
488
+ Clip(row: 6, frameCount: 7, fps: 8, moveSpeed: 0, durationRange: 1.0...2.0), // ジャンプ4
489
+ Clip(row: 7, frameCount: 5, fps: 6, moveSpeed: 0, durationRange: 4.0...7.0), // 座る1
490
+ Clip(row: 8, frameCount: 4, fps: 6, moveSpeed: 0, durationRange: 4.0...7.0), // 座る2
491
+ Clip(row: 9, frameCount: 5, fps: 6, moveSpeed: 0, durationRange: 4.0...7.0), // 座る3
492
+ Clip(row: 10, frameCount: 4, fps: 6, moveSpeed: 0, durationRange: 4.0...7.0), // 座る4
493
+ Clip(row: 12, frameCount: 2, fps: 4, moveSpeed: 0, durationRange: 2.0...3.0), // 食べる1
494
+ Clip(row: 14, frameCount: 7, fps: 8, moveSpeed: 0, durationRange: 2.0...3.0), // 食べる2
495
+ ]
496
+ static let idleClipIndex = 0
497
+ static let walkClipIndex = 1
498
+ static let runClipIndex = 2
499
+ static let eatClipIndex = 11
500
+
501
+ var facingLeft = false
502
+ var clipIndex = PetView.idleClipIndex
503
+ var frameIndex = 0
504
+ var isGrabbed = false
505
+ var isAsleep = false {
506
+ didSet { needsDisplay = true }
507
+ }
508
+
509
+ var onGrab: ((NSPoint) -> Void)?
510
+ var onDrag: ((NSPoint) -> Void)?
511
+ var onRelease: (() -> Void)?
512
+ var onFeed: (() -> Void)?
513
+
514
+ private static let cell: CGFloat = 16.0
515
+ private static let sheetRows = 19
516
+
517
+ // sprite は右向きに描かれているため、左へ移動するとき(facingLeft == true)は描画時に水平反転する。
518
+ private static let sheet: NSBitmapImageRep = {
519
+ guard let url = Bundle.module.url(forResource: "ChickBlue", withExtension: "png"),
520
+ let data = try? Data(contentsOf: url),
521
+ let bitmap = NSBitmapImageRep(data: data) else {
522
+ fatalError("ChickBlue.png resource not found")
523
+ }
524
+ return bitmap
525
+ }()
526
+
527
+ private static let sheetImage: NSImage = {
528
+ let image = NSImage(size: NSSize(width: sheet.pixelsWide, height: sheet.pixelsHigh))
529
+ image.addRepresentation(sheet)
530
+ return image
531
+ }()
532
+
533
+ override var isFlipped: Bool { false }
534
+ override var acceptsFirstResponder: Bool { true }
535
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
536
+
537
+ override func mouseDown(with event: NSEvent) {
538
+ if event.clickCount == 2 {
539
+ onFeed?()
540
+ return
541
+ }
542
+ onGrab?(event.locationInWindow)
543
+ }
544
+
545
+ override func mouseDragged(with event: NSEvent) {
546
+ onDrag?(NSEvent.mouseLocation)
547
+ }
548
+
549
+ override func mouseUp(with event: NSEvent) {
550
+ onRelease?()
551
+ }
552
+
553
+ override func rightMouseDown(with event: NSEvent) {
554
+ let menu = NSMenu()
555
+ let feedItem = NSMenuItem(title: "えさをあげる", action: #selector(handleFeedMenuItem), keyEquivalent: "")
556
+ feedItem.target = self
557
+ menu.addItem(feedItem)
558
+ menu.addItem(.separator())
559
+ let quitItem = NSMenuItem(title: "終了", action: #selector(handleQuitMenuItem), keyEquivalent: "")
560
+ quitItem.target = self
561
+ menu.addItem(quitItem)
562
+ menu.popUp(positioning: nil, at: event.locationInWindow, in: self)
563
+ }
564
+
565
+ @objc private func handleFeedMenuItem() {
566
+ onFeed?()
567
+ }
568
+
569
+ @objc private func handleQuitMenuItem() {
570
+ NSApp.terminate(nil)
571
+ }
572
+
573
+ override func draw(_ dirtyRect: NSRect) {
574
+ guard let context = NSGraphicsContext.current else { return }
575
+ context.imageInterpolation = .none
576
+
577
+ let row = Self.clips[clipIndex].row
578
+ let fromRect = NSRect(
579
+ x: CGFloat(frameIndex) * Self.cell,
580
+ y: CGFloat(Self.sheetRows - row - 1) * Self.cell,
581
+ width: Self.cell,
582
+ height: Self.cell
583
+ )
584
+
585
+ context.cgContext.saveGState()
586
+ if facingLeft {
587
+ context.cgContext.translateBy(x: bounds.width, y: 0)
588
+ context.cgContext.scaleBy(x: -1, y: 1)
589
+ }
590
+ Self.sheetImage.draw(
591
+ in: bounds,
592
+ from: fromRect,
593
+ operation: .sourceOver,
594
+ fraction: isAsleep ? 0.6 : 1.0
595
+ )
596
+ context.cgContext.restoreGState()
597
+ }
598
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "strict": true,
4
+ "target": "ES2022",
5
+ "module": "preserve",
6
+ "moduleResolution": "bundler",
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "types": ["node", "bun"],
10
+ "paths": {
11
+ "@/*": ["./cli/src/*"]
12
+ }
13
+ },
14
+ "include": ["cli/src"]
15
+ }