@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.
- package/README.md +65 -0
- package/cli/src/app.ts +27 -0
- package/cli/src/factory.ts +3 -0
- package/cli/src/gateway.ts +204 -0
- package/cli/src/index.ts +63 -0
- package/cli/src/lib/help-guard.ts +15 -0
- package/cli/src/lib/post-json.ts +8 -0
- package/cli/src/lib/process.ts +204 -0
- package/cli/src/on-error.ts +10 -0
- package/cli/src/router.ts +45 -0
- package/cli/src/routes/config/get/[key]/route.ts +17 -0
- package/cli/src/routes/config/list/route.ts +17 -0
- package/cli/src/routes/config/set/[key]/[value]/route.ts +20 -0
- package/cli/src/routes/dev/route.ts +17 -0
- package/cli/src/routes/kill/route.ts +15 -0
- package/cli/src/routes/not-found.ts +8 -0
- package/cli/src/routes/reboot/route.ts +17 -0
- package/cli/src/routes/start/route.ts +16 -0
- package/cli/src/routes/status/route.ts +15 -0
- package/package.json +48 -0
- package/swift/Package.swift +19 -0
- package/swift/Sources/open-minion/Resources/ChickBlue.png +0 -0
- package/swift/Sources/open-minion/open_minion.swift +598 -0
- package/tsconfig.json +15 -0
|
@@ -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
|
+
}
|