nathangong 0.1.0 → 0.4.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/INSTALL.md +51 -0
- package/README.md +19 -20
- package/bin/cli.js +0 -0
- package/native/build.sh +32 -0
- package/native/nathan-popup.swift +144 -0
- package/package.json +9 -5
- package/scripts/notify.js +81 -47
- package/scripts/postinstall.js +0 -20
- package/scripts/session-lock.js +74 -0
- package/vendor/nathan-popup +0 -0
- package/whip/main.js +90 -0
- package/whip/overlay.html +534 -0
- package/whip/preload.js +9 -0
- package/whip/sounds/A.mp3 +0 -0
- package/whip/sounds/B.mp3 +0 -0
- package/whip/sounds/C.mp3 +0 -0
- package/whip/sounds/D.mp3 +0 -0
- package/whip/sounds/E.mp3 +0 -0
package/INSTALL.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# nathangong — install from zip
|
|
2
|
+
|
|
3
|
+
macOS only. Requires **Node 18+** and **Claude Code** (for the Stop hook).
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
unzip nathangong-0.4.0.zip
|
|
9
|
+
cd nathangong-0.4.0
|
|
10
|
+
npm install
|
|
11
|
+
npm install -g .
|
|
12
|
+
nathangong test
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
You should get one Nathan + whip overlay. Click Nathan to whip him away.
|
|
16
|
+
|
|
17
|
+
## What gets installed
|
|
18
|
+
|
|
19
|
+
- `postinstall` wires a Claude Code **Stop** hook in `~/.claude/settings.json`
|
|
20
|
+
- After every Claude Code turn, Nathan slides in from the side and your cursor becomes an OpenWhip-style whip
|
|
21
|
+
- He runs away from your mouse — chase him down and click to dismiss
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
nathangong test # try it now
|
|
27
|
+
nathangong install # re-wire the hook
|
|
28
|
+
nathangong uninstall # remove the hook
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Uninstall
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
nathangong uninstall
|
|
35
|
+
npm uninstall -g nathangong
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Notes
|
|
39
|
+
|
|
40
|
+
- Only one Nathan session at a time (duplicate hook fires are ignored)
|
|
41
|
+
- Optional timeout: `NATHANGONG_POPUP_SECONDS=120`
|
|
42
|
+
- Whip overlay uses Electron (~150MB download on first `npm install`)
|
|
43
|
+
- Does **not** run on the Fleetline web app — local Claude Code only
|
|
44
|
+
|
|
45
|
+
## Troubleshooting
|
|
46
|
+
|
|
47
|
+
**Nothing appears:** run `nathangong test` and check Node ≥ 18.
|
|
48
|
+
|
|
49
|
+
**Multiple Nathans:** dismiss the current one first; lock clears on exit.
|
|
50
|
+
|
|
51
|
+
**Native fallback only (no whip):** Electron failed to start — re-run `npm install` in the package folder.
|
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# nathangong
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Whip-a-Nathan for Claude Code: after every turn, your cursor becomes an
|
|
4
|
+
**OpenWhip**-style whip and a photo of **Nathan Gong** slides in slowly from
|
|
5
|
+
the left or right. Click Nathan to whip him away.
|
|
5
6
|
|
|
6
7
|
## Install
|
|
7
8
|
|
|
@@ -9,28 +10,20 @@ finishes responding to a prompt. Download it and it just works.
|
|
|
9
10
|
npm install -g nathangong
|
|
10
11
|
```
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
into `~/.claude/settings.json`.
|
|
13
|
+
The `postinstall` step wires a Claude Code [`Stop` hook](https://docs.claude.com/en/docs/claude-code/hooks)
|
|
14
|
+
into `~/.claude/settings.json`.
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
Uses an Electron overlay with OpenWhip whip physics (adapted from
|
|
17
|
+
[OpenWhip](https://github.com/GitFrog1111/OpenWhip)). Falls back to a native
|
|
18
|
+
macOS popup if Electron can't launch.
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
- **Intel Mac**, or **Apple Silicon with Rosetta** → you get the **photo**. 📸
|
|
21
|
-
- **Apple Silicon without Rosetta** → you get an **emoji notification** (macOS
|
|
22
|
-
can't attach a custom image without a runnable helper binary).
|
|
23
|
-
|
|
24
|
-
To get photos on Apple Silicon, install Rosetta once:
|
|
25
|
-
|
|
26
|
-
```bash
|
|
27
|
-
softwareupdate --install-rosetta --agree-to-license
|
|
28
|
-
```
|
|
20
|
+
Set a max timeout if Nathan survives too long (default 60s):
|
|
21
|
+
`NATHANGONG_POPUP_SECONDS=120`.
|
|
29
22
|
|
|
30
23
|
## Commands
|
|
31
24
|
|
|
32
25
|
```bash
|
|
33
|
-
nathangong test #
|
|
26
|
+
nathangong test # pop Nathan on screen right now to check it works
|
|
34
27
|
nathangong install # (re)wire the Stop hook
|
|
35
28
|
nathangong uninstall # remove the Stop hook
|
|
36
29
|
```
|
|
@@ -47,11 +40,17 @@ The `preuninstall` step removes the hook from your settings. (You can also run
|
|
|
47
40
|
## How it works
|
|
48
41
|
|
|
49
42
|
- The `Stop` hook runs `node .../scripts/notify.js` when Claude ends a turn.
|
|
50
|
-
- `notify.js` picks a random photo from `assets/` and
|
|
51
|
-
`
|
|
43
|
+
- `notify.js` picks a random photo from `assets/` and shows it with the native
|
|
44
|
+
`vendor/nathan-popup` window, falling back to an `osascript` text notification.
|
|
52
45
|
- Editing `~/.claude/settings.json` is idempotent and backs the file up to
|
|
53
46
|
`settings.json.nathangong.bak` before any change.
|
|
54
47
|
|
|
48
|
+
## Rebuilding the native binary
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
bash native/build.sh # needs Xcode; rebuilds vendor/nathan-popup (universal, ad-hoc signed)
|
|
52
|
+
```
|
|
53
|
+
|
|
55
54
|
## Use as a library
|
|
56
55
|
|
|
57
56
|
```js
|
package/bin/cli.js
CHANGED
|
File without changes
|
package/native/build.sh
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Builds the universal (arm64 + x86_64) nathan-popup binary into ../vendor.
|
|
3
|
+
# Requires Xcode / the Swift toolchain. Run on macOS.
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
cd "$(dirname "$0")"
|
|
6
|
+
|
|
7
|
+
SRC="nathan-popup.swift"
|
|
8
|
+
OUT="../vendor/nathan-popup"
|
|
9
|
+
mkdir -p ../vendor
|
|
10
|
+
|
|
11
|
+
build() { # <arch>
|
|
12
|
+
swiftc -O -target "$1-apple-macos11" "$SRC" -o "nathan-popup-$1"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
echo "building arm64…"
|
|
16
|
+
build arm64
|
|
17
|
+
SLICES=(nathan-popup-arm64)
|
|
18
|
+
|
|
19
|
+
if build x86_64 2>/dev/null; then
|
|
20
|
+
echo "building x86_64… ok"
|
|
21
|
+
SLICES+=(nathan-popup-x86_64)
|
|
22
|
+
else
|
|
23
|
+
echo "x86_64 slice failed — shipping arm64-only"
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
lipo -create -output "$OUT" "${SLICES[@]}"
|
|
27
|
+
rm -f nathan-popup-arm64 nathan-popup-x86_64
|
|
28
|
+
chmod +x "$OUT"
|
|
29
|
+
codesign -s - -f "$OUT" # ad-hoc sign so Gatekeeper is happy
|
|
30
|
+
echo "--- built $OUT ---"
|
|
31
|
+
lipo -info "$OUT"
|
|
32
|
+
codesign -dv "$OUT" 2>&1 | head -3
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// nathan-popup <imagePath> [caption] [seconds]
|
|
2
|
+
//
|
|
3
|
+
// Fallback when Electron/OpenWhip overlay isn't available: Nathan slides in
|
|
4
|
+
// slowly from the left or right. Click him to dismiss.
|
|
5
|
+
|
|
6
|
+
import Cocoa
|
|
7
|
+
|
|
8
|
+
let args = CommandLine.arguments
|
|
9
|
+
guard args.count >= 2 else { exit(0) }
|
|
10
|
+
let imagePath = args[1]
|
|
11
|
+
let caption = args.count >= 3 ? args[2] : ""
|
|
12
|
+
let maxSeconds = args.count >= 4 ? (Double(args[3]) ?? 60) : 60
|
|
13
|
+
|
|
14
|
+
guard let image = NSImage(contentsOfFile: imagePath) else { exit(0) }
|
|
15
|
+
|
|
16
|
+
enum EntrySide: CaseIterable {
|
|
17
|
+
case left, right
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
final class WhackTargetView: NSView {
|
|
21
|
+
var onWhack: (() -> Void)?
|
|
22
|
+
|
|
23
|
+
override func mouseDown(with event: NSEvent) {
|
|
24
|
+
onWhack?()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func targetFrame(for side: EntrySide, in visibleFrame: NSRect, size: NSSize) -> NSRect {
|
|
29
|
+
let y = visibleFrame.midY - size.height / 2
|
|
30
|
+
switch side {
|
|
31
|
+
case .left:
|
|
32
|
+
return NSRect(x: visibleFrame.minX + 20, y: y, width: size.width, height: size.height)
|
|
33
|
+
case .right:
|
|
34
|
+
return NSRect(x: visibleFrame.maxX - size.width - 20, y: y, width: size.width, height: size.height)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func offScreenFrame(for side: EntrySide, resting: NSRect, visibleFrame: NSRect) -> NSRect {
|
|
39
|
+
switch side {
|
|
40
|
+
case .left:
|
|
41
|
+
return NSRect(x: visibleFrame.minX - resting.width - 20, y: resting.origin.y, width: resting.width, height: resting.height)
|
|
42
|
+
case .right:
|
|
43
|
+
return NSRect(x: visibleFrame.maxX + 20, y: resting.origin.y, width: resting.width, height: resting.height)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let app = NSApplication.shared
|
|
48
|
+
app.setActivationPolicy(.accessory)
|
|
49
|
+
|
|
50
|
+
let imgW: CGFloat = 240
|
|
51
|
+
let ratio = image.size.width > 0 ? image.size.height / image.size.width : 1
|
|
52
|
+
let imgH = (imgW * ratio).rounded()
|
|
53
|
+
let capH: CGFloat = caption.isEmpty ? 0 : 24
|
|
54
|
+
let pad: CGFloat = 12
|
|
55
|
+
let winW = imgW + pad * 2
|
|
56
|
+
let winH = imgH + capH + pad * 2
|
|
57
|
+
let winSize = NSSize(width: winW, height: winH)
|
|
58
|
+
|
|
59
|
+
guard let screen = NSScreen.main else { exit(0) }
|
|
60
|
+
let vf = screen.visibleFrame
|
|
61
|
+
let side = EntrySide.allCases.randomElement() ?? .right
|
|
62
|
+
let endFrame = targetFrame(for: side, in: vf, size: winSize)
|
|
63
|
+
let startFrame = offScreenFrame(for: side, resting: endFrame, visibleFrame: vf)
|
|
64
|
+
|
|
65
|
+
let panel = NSPanel(
|
|
66
|
+
contentRect: startFrame,
|
|
67
|
+
styleMask: [.borderless, .nonactivatingPanel],
|
|
68
|
+
backing: .buffered,
|
|
69
|
+
defer: false
|
|
70
|
+
)
|
|
71
|
+
panel.isFloatingPanel = true
|
|
72
|
+
panel.level = .floating
|
|
73
|
+
panel.backgroundColor = .clear
|
|
74
|
+
panel.isOpaque = false
|
|
75
|
+
panel.hasShadow = true
|
|
76
|
+
panel.ignoresMouseEvents = false
|
|
77
|
+
panel.acceptsMouseMovedEvents = true
|
|
78
|
+
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
79
|
+
|
|
80
|
+
let container = WhackTargetView(frame: NSRect(x: 0, y: 0, width: winW, height: winH))
|
|
81
|
+
container.wantsLayer = true
|
|
82
|
+
container.layer?.backgroundColor = NSColor(calibratedWhite: 0.10, alpha: 0.94).cgColor
|
|
83
|
+
container.layer?.cornerRadius = 16
|
|
84
|
+
|
|
85
|
+
let iv = NSImageView(frame: NSRect(x: pad, y: pad + capH, width: imgW, height: imgH))
|
|
86
|
+
iv.image = image
|
|
87
|
+
iv.imageScaling = .scaleProportionallyUpOrDown
|
|
88
|
+
iv.wantsLayer = true
|
|
89
|
+
iv.layer?.cornerRadius = 10
|
|
90
|
+
iv.layer?.masksToBounds = true
|
|
91
|
+
container.addSubview(iv)
|
|
92
|
+
|
|
93
|
+
if !caption.isEmpty {
|
|
94
|
+
let label = NSTextField(labelWithString: caption)
|
|
95
|
+
label.frame = NSRect(x: pad, y: pad - 3, width: imgW, height: capH)
|
|
96
|
+
label.alignment = .center
|
|
97
|
+
label.textColor = .white
|
|
98
|
+
label.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
|
|
99
|
+
label.backgroundColor = .clear
|
|
100
|
+
label.isBezeled = false
|
|
101
|
+
label.isEditable = false
|
|
102
|
+
label.lineBreakMode = .byTruncatingTail
|
|
103
|
+
container.addSubview(label)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
panel.contentView = container
|
|
107
|
+
panel.alphaValue = 0
|
|
108
|
+
panel.setFrame(startFrame, display: false)
|
|
109
|
+
panel.orderFrontRegardless()
|
|
110
|
+
|
|
111
|
+
var dismissed = false
|
|
112
|
+
|
|
113
|
+
func cleanupAndExit() {
|
|
114
|
+
app.terminate(nil)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func dismissWhacked() {
|
|
118
|
+
guard !dismissed else { return }
|
|
119
|
+
dismissed = true
|
|
120
|
+
|
|
121
|
+
NSAnimationContext.runAnimationGroup({ ctx in
|
|
122
|
+
ctx.duration = 0.25
|
|
123
|
+
panel.animator().alphaValue = 0
|
|
124
|
+
}, completionHandler: {
|
|
125
|
+
cleanupAndExit()
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
container.onWhack = dismissWhacked
|
|
130
|
+
|
|
131
|
+
NSAnimationContext.runAnimationGroup { ctx in
|
|
132
|
+
ctx.duration = 1.5
|
|
133
|
+
ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
|
134
|
+
panel.animator().alphaValue = 1
|
|
135
|
+
panel.animator().setFrame(endFrame, display: true)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + max(maxSeconds, 10)) {
|
|
139
|
+
guard !dismissed else { return }
|
|
140
|
+
dismissed = true
|
|
141
|
+
cleanupAndExit()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
app.run()
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nathangong",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Whip-a-Nathan: OpenWhip cursor + Nathan slides in from the side after every Claude Code turn.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nathan gong",
|
|
7
7
|
"claude",
|
|
@@ -20,8 +20,12 @@
|
|
|
20
20
|
"bin",
|
|
21
21
|
"scripts",
|
|
22
22
|
"assets",
|
|
23
|
+
"vendor",
|
|
24
|
+
"native",
|
|
25
|
+
"whip",
|
|
23
26
|
"index.js",
|
|
24
|
-
"README.md"
|
|
27
|
+
"README.md",
|
|
28
|
+
"INSTALL.md"
|
|
25
29
|
],
|
|
26
30
|
"scripts": {
|
|
27
31
|
"postinstall": "node scripts/postinstall.js",
|
|
@@ -29,9 +33,9 @@
|
|
|
29
33
|
"test": "node bin/cli.js test"
|
|
30
34
|
},
|
|
31
35
|
"engines": {
|
|
32
|
-
"node": ">=
|
|
36
|
+
"node": ">=18.0.0"
|
|
33
37
|
},
|
|
34
38
|
"dependencies": {
|
|
35
|
-
"
|
|
39
|
+
"electron": "^33.0.0"
|
|
36
40
|
}
|
|
37
41
|
}
|
package/scripts/notify.js
CHANGED
|
@@ -2,29 +2,27 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
// The Claude Code `Stop` hook body: fires each time Claude finishes a turn.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// supports `contentImage` — so the photo shows up with no extra system setup
|
|
9
|
-
// (no Homebrew needed). Falls back to plain `osascript` (emoji only) if
|
|
10
|
-
// node-notifier is unavailable or errors.
|
|
5
|
+
// Opens an OpenWhip-style whip overlay (cursor becomes the whip) and slides
|
|
6
|
+
// Nathan in gently from the left or right. Click Nathan to whip him away.
|
|
7
|
+
// Falls back to the native popup, then osascript, if Electron can't launch.
|
|
11
8
|
//
|
|
12
9
|
// Rules: always exit 0 (a failing hook nags every turn) and never hang.
|
|
13
10
|
|
|
14
11
|
const fs = require("fs");
|
|
15
12
|
const path = require("path");
|
|
16
13
|
const { spawn } = require("child_process");
|
|
14
|
+
const { tryAcquireLock, claimLock, clearLock } = require("./session-lock");
|
|
17
15
|
|
|
18
16
|
process.stdout.on("error", () => {});
|
|
19
17
|
process.stderr.on("error", () => {});
|
|
20
18
|
|
|
21
19
|
const TITLE = "nathan gong 🔔";
|
|
22
|
-
const
|
|
23
|
-
"nathan gong
|
|
24
|
-
"a wild nathan gong
|
|
25
|
-
"nathan
|
|
26
|
-
"thinking
|
|
27
|
-
"nathan gong
|
|
20
|
+
const CAPTIONS = [
|
|
21
|
+
"nathan gong 🧑💻",
|
|
22
|
+
"a wild nathan gong 👋",
|
|
23
|
+
"nathan approves ✅",
|
|
24
|
+
"thinking of nathan 🤔",
|
|
25
|
+
"nathan gong 🏀",
|
|
28
26
|
];
|
|
29
27
|
|
|
30
28
|
function pick(arr) {
|
|
@@ -44,15 +42,69 @@ function pickImage() {
|
|
|
44
42
|
}
|
|
45
43
|
}
|
|
46
44
|
|
|
47
|
-
function
|
|
48
|
-
|
|
45
|
+
function canRun(p) {
|
|
46
|
+
try {
|
|
47
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
48
|
+
return true;
|
|
49
|
+
} catch (_) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function electronBinary() {
|
|
55
|
+
try {
|
|
56
|
+
return require("electron");
|
|
57
|
+
} catch (_) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function launchWhipOverlay(image, caption, seconds) {
|
|
63
|
+
const electronPath = electronBinary();
|
|
64
|
+
if (!electronPath) return false;
|
|
65
|
+
|
|
66
|
+
const main = path.join(__dirname, "..", "whip", "main.js");
|
|
67
|
+
if (!fs.existsSync(main)) return false;
|
|
68
|
+
|
|
69
|
+
if (!tryAcquireLock()) return false;
|
|
70
|
+
|
|
71
|
+
const child = spawn(
|
|
72
|
+
electronPath,
|
|
73
|
+
[main, image, caption, seconds],
|
|
74
|
+
{
|
|
75
|
+
detached: true,
|
|
76
|
+
stdio: "ignore",
|
|
77
|
+
cwd: path.join(__dirname, "..", "whip"),
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
child.on("error", () => clearLock());
|
|
82
|
+
child.on("spawn", () => claimLock(child.pid));
|
|
83
|
+
child.unref();
|
|
84
|
+
return true;
|
|
49
85
|
}
|
|
50
86
|
|
|
51
|
-
|
|
52
|
-
|
|
87
|
+
function launchNativePopup(image, caption, seconds) {
|
|
88
|
+
const popup = path.join(__dirname, "..", "vendor", "nathan-popup");
|
|
89
|
+
if (!canRun(popup)) return false;
|
|
90
|
+
|
|
91
|
+
if (!tryAcquireLock()) return false;
|
|
92
|
+
|
|
93
|
+
const child = spawn(popup, [image, caption, seconds], {
|
|
94
|
+
detached: true,
|
|
95
|
+
stdio: "ignore",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
child.on("error", () => clearLock());
|
|
99
|
+
child.on("spawn", () => claimLock(child.pid));
|
|
100
|
+
child.unref();
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function osascriptFallback(caption) {
|
|
53
105
|
const script =
|
|
54
106
|
"display notification " +
|
|
55
|
-
JSON.stringify(
|
|
107
|
+
JSON.stringify(caption) +
|
|
56
108
|
" with title " +
|
|
57
109
|
JSON.stringify(TITLE);
|
|
58
110
|
const child = spawn("osascript", ["-e", script], {
|
|
@@ -63,42 +115,24 @@ function osascriptFallback(line) {
|
|
|
63
115
|
}
|
|
64
116
|
|
|
65
117
|
function main() {
|
|
66
|
-
if (process.platform !== "darwin") return
|
|
118
|
+
if (process.platform !== "darwin") return process.exit(0);
|
|
67
119
|
|
|
68
|
-
const
|
|
120
|
+
const caption = pick(CAPTIONS);
|
|
69
121
|
const image = pickImage();
|
|
122
|
+
const seconds = String(Number(process.env.NATHANGONG_POPUP_SECONDS) || 60);
|
|
70
123
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
let notifier;
|
|
76
|
-
try {
|
|
77
|
-
notifier = require("node-notifier");
|
|
78
|
-
} catch (_) {
|
|
79
|
-
osascriptFallback(line);
|
|
80
|
-
return done();
|
|
124
|
+
if (image && launchWhipOverlay(image, caption, seconds)) {
|
|
125
|
+
setTimeout(() => process.exit(0), 150);
|
|
126
|
+
return;
|
|
81
127
|
}
|
|
82
128
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
title: TITLE,
|
|
87
|
-
message: line,
|
|
88
|
-
contentImage: image || undefined, // right-side thumbnail (the photo)
|
|
89
|
-
appIcon: image || undefined, // left icon
|
|
90
|
-
sound: false,
|
|
91
|
-
wait: false,
|
|
92
|
-
},
|
|
93
|
-
(err) => {
|
|
94
|
-
if (err) osascriptFallback(line);
|
|
95
|
-
done();
|
|
96
|
-
}
|
|
97
|
-
);
|
|
98
|
-
} catch (_) {
|
|
99
|
-
osascriptFallback(line);
|
|
100
|
-
done();
|
|
129
|
+
if (image && launchNativePopup(image, caption, seconds)) {
|
|
130
|
+
setTimeout(() => process.exit(0), 150);
|
|
131
|
+
return;
|
|
101
132
|
}
|
|
133
|
+
|
|
134
|
+
osascriptFallback(caption);
|
|
135
|
+
process.exit(0);
|
|
102
136
|
}
|
|
103
137
|
|
|
104
138
|
main();
|
package/scripts/postinstall.js
CHANGED
|
@@ -28,23 +28,3 @@ try {
|
|
|
28
28
|
log("nathangong: couldn't wire the hook automatically (" + (err && err.message) + ").");
|
|
29
29
|
log("nathangong: run `nathangong install` to set it up manually.");
|
|
30
30
|
}
|
|
31
|
-
|
|
32
|
-
// Apple Silicon without Rosetta can't run the bundled x86_64 terminal-notifier,
|
|
33
|
-
// so it falls back to an emoji notification. Let the user know how to get photos.
|
|
34
|
-
try {
|
|
35
|
-
const os = require("os");
|
|
36
|
-
if (process.platform === "darwin" && os.arch() === "arm64") {
|
|
37
|
-
const { execFileSync } = require("child_process");
|
|
38
|
-
let rosetta = false;
|
|
39
|
-
try {
|
|
40
|
-
execFileSync("/usr/bin/pgrep", ["-q", "oahd"]);
|
|
41
|
-
rosetta = true;
|
|
42
|
-
} catch (_) {}
|
|
43
|
-
if (!rosetta) {
|
|
44
|
-
log("");
|
|
45
|
-
log("nathangong: you're on Apple Silicon without Rosetta — you'll get emoji");
|
|
46
|
-
log("nathangong: notifications. For actual PHOTOS of Nathan, install Rosetta once:");
|
|
47
|
-
log(" softwareupdate --install-rosetta --agree-to-license");
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
} catch (_) {}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const os = require("os");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
|
|
7
|
+
const LOCK_PATH = path.join(os.tmpdir(), "nathangong-session.lock");
|
|
8
|
+
const STALE_MS = 8000;
|
|
9
|
+
|
|
10
|
+
function readLock() {
|
|
11
|
+
try {
|
|
12
|
+
const raw = fs.readFileSync(LOCK_PATH, "utf8").trim();
|
|
13
|
+
const [pidRaw, startedRaw] = raw.split(":");
|
|
14
|
+
return {
|
|
15
|
+
pid: Number(pidRaw),
|
|
16
|
+
startedAt: Number(startedRaw) || 0,
|
|
17
|
+
};
|
|
18
|
+
} catch (_) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isAlive(pid) {
|
|
24
|
+
if (!pid || !Number.isFinite(pid)) return false;
|
|
25
|
+
try {
|
|
26
|
+
process.kill(pid, 0);
|
|
27
|
+
return true;
|
|
28
|
+
} catch (_) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function clearLock() {
|
|
34
|
+
try {
|
|
35
|
+
fs.unlinkSync(LOCK_PATH);
|
|
36
|
+
} catch (_) {}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Returns false if another live session already owns the lock. */
|
|
40
|
+
function tryAcquireLock() {
|
|
41
|
+
const existing = readLock();
|
|
42
|
+
if (existing) {
|
|
43
|
+
const age = Date.now() - existing.startedAt;
|
|
44
|
+
if (isAlive(existing.pid) && age < STALE_MS * 60) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
clearLock();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
fs.writeFileSync(LOCK_PATH, `0:${Date.now()}`, { flag: "wx" });
|
|
52
|
+
return true;
|
|
53
|
+
} catch (err) {
|
|
54
|
+
if (err && err.code === "EEXIST") return false;
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function claimLock(pid) {
|
|
60
|
+
fs.writeFileSync(LOCK_PATH, `${pid}:${Date.now()}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function releaseLock(pid) {
|
|
64
|
+
const existing = readLock();
|
|
65
|
+
if (existing && existing.pid === pid) clearLock();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
LOCK_PATH,
|
|
70
|
+
tryAcquireLock,
|
|
71
|
+
claimLock,
|
|
72
|
+
releaseLock,
|
|
73
|
+
clearLock,
|
|
74
|
+
};
|
|
Binary file
|
package/whip/main.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { app, BrowserWindow, ipcMain, screen } = require("electron");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { claimLock, releaseLock } = require("../scripts/session-lock");
|
|
6
|
+
|
|
7
|
+
let overlay = null;
|
|
8
|
+
|
|
9
|
+
const gotLock = app.requestSingleInstanceLock();
|
|
10
|
+
if (!gotLock) {
|
|
11
|
+
app.quit();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
app.on("second-instance", () => {
|
|
15
|
+
// Ignore duplicate hook fires while Nathan is already on screen.
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function createOverlay(imagePath, caption, maxSeconds) {
|
|
19
|
+
const display = screen.getPrimaryDisplay();
|
|
20
|
+
const { bounds } = display;
|
|
21
|
+
|
|
22
|
+
overlay = new BrowserWindow({
|
|
23
|
+
x: bounds.x,
|
|
24
|
+
y: bounds.y,
|
|
25
|
+
width: bounds.width,
|
|
26
|
+
height: bounds.height,
|
|
27
|
+
transparent: true,
|
|
28
|
+
frame: false,
|
|
29
|
+
alwaysOnTop: true,
|
|
30
|
+
focusable: false,
|
|
31
|
+
skipTaskbar: true,
|
|
32
|
+
resizable: false,
|
|
33
|
+
hasShadow: false,
|
|
34
|
+
webPreferences: {
|
|
35
|
+
preload: path.join(__dirname, "preload.js"),
|
|
36
|
+
contextIsolation: true,
|
|
37
|
+
nodeIntegration: false,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
overlay.setAlwaysOnTop(true, "screen-saver");
|
|
42
|
+
overlay.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
|
43
|
+
|
|
44
|
+
const query = new URLSearchParams({
|
|
45
|
+
maxSeconds: String(maxSeconds),
|
|
46
|
+
}).toString();
|
|
47
|
+
|
|
48
|
+
overlay.loadFile(path.join(__dirname, "overlay.html"), { query });
|
|
49
|
+
|
|
50
|
+
overlay.webContents.on("did-finish-load", () => {
|
|
51
|
+
overlay.webContents.send("init-nathan", { imagePath, caption });
|
|
52
|
+
overlay.webContents.send("spawn-whip");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const timeoutMs = Math.max(Number(maxSeconds) || 60, 10) * 1000;
|
|
56
|
+
setTimeout(() => {
|
|
57
|
+
if (overlay && !overlay.isDestroyed()) {
|
|
58
|
+
app.quit();
|
|
59
|
+
}
|
|
60
|
+
}, timeoutMs);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
ipcMain.on("whip-crack", () => {
|
|
64
|
+
// Cosmetic only — OpenWhip sends Ctrl-C here; nathangong does not.
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
ipcMain.on("hide-overlay", () => {
|
|
68
|
+
app.quit();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
app.whenReady().then(() => {
|
|
72
|
+
if (!gotLock) return;
|
|
73
|
+
|
|
74
|
+
const imagePath = process.argv[2];
|
|
75
|
+
const caption = process.argv[3] || "";
|
|
76
|
+
const maxSeconds = process.argv[4] || "60";
|
|
77
|
+
if (!imagePath) {
|
|
78
|
+
app.quit();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
claimLock(process.pid);
|
|
83
|
+
createOverlay(imagePath, caption, maxSeconds);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
app.on("will-quit", () => {
|
|
87
|
+
releaseLock(process.pid);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
app.on("window-all-closed", () => app.quit());
|
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>nathangong</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { margin: 0; padding: 0; cursor: none; box-sizing: border-box; }
|
|
9
|
+
html, body { overflow: hidden; background: transparent; width: 100%; height: 100%; }
|
|
10
|
+
canvas { display: block; position: fixed; inset: 0; z-index: 1; }
|
|
11
|
+
#nathan-card {
|
|
12
|
+
position: fixed;
|
|
13
|
+
left: 0;
|
|
14
|
+
top: 0;
|
|
15
|
+
z-index: 2;
|
|
16
|
+
width: 264px;
|
|
17
|
+
padding: 12px;
|
|
18
|
+
border-radius: 16px;
|
|
19
|
+
background: rgba(26, 26, 26, 0.94);
|
|
20
|
+
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
|
21
|
+
opacity: 0;
|
|
22
|
+
pointer-events: auto;
|
|
23
|
+
will-change: left, top, opacity;
|
|
24
|
+
}
|
|
25
|
+
#nathan-card.visible {
|
|
26
|
+
opacity: 1;
|
|
27
|
+
}
|
|
28
|
+
#nathan-img {
|
|
29
|
+
display: block;
|
|
30
|
+
width: 240px;
|
|
31
|
+
height: auto;
|
|
32
|
+
border-radius: 10px;
|
|
33
|
+
}
|
|
34
|
+
#nathan-caption {
|
|
35
|
+
margin-top: 8px;
|
|
36
|
+
text-align: center;
|
|
37
|
+
color: #fff;
|
|
38
|
+
font: 600 13px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
39
|
+
}
|
|
40
|
+
#nathan-caption:empty { display: none; }
|
|
41
|
+
</style>
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
<canvas id="c"></canvas>
|
|
45
|
+
<div id="nathan-card">
|
|
46
|
+
<img id="nathan-img" alt="Nathan Gong" />
|
|
47
|
+
<div id="nathan-caption"></div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<script>
|
|
51
|
+
const params = new URLSearchParams(window.location.search);
|
|
52
|
+
|
|
53
|
+
const nathanCard = document.getElementById("nathan-card");
|
|
54
|
+
const nathanImg = document.getElementById("nathan-img");
|
|
55
|
+
const nathanCaptionEl = document.getElementById("nathan-caption");
|
|
56
|
+
|
|
57
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
58
|
+
const lerp = (a, b, t) => a + (b - a) * t;
|
|
59
|
+
|
|
60
|
+
const N = {
|
|
61
|
+
margin: 24,
|
|
62
|
+
fleeRadius: 200,
|
|
63
|
+
fleeSpeed: 5.5,
|
|
64
|
+
slideDurationMs: 1500,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
let fromLeft = Math.random() < 0.5;
|
|
68
|
+
let nathanX = 0;
|
|
69
|
+
let nathanY = 0;
|
|
70
|
+
let nathanSlidingIn = false;
|
|
71
|
+
let nathanCanFlee = false;
|
|
72
|
+
let slideStartAt = 0;
|
|
73
|
+
let slideFromX = 0;
|
|
74
|
+
let slideFromY = 0;
|
|
75
|
+
let slideToX = 0;
|
|
76
|
+
let slideToY = 0;
|
|
77
|
+
|
|
78
|
+
function nathanSize() {
|
|
79
|
+
const rect = nathanCard.getBoundingClientRect();
|
|
80
|
+
return { w: rect.width || 264, h: rect.height || 320 };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function clampNathanPosition(x, y) {
|
|
84
|
+
const { w, h } = nathanSize();
|
|
85
|
+
return {
|
|
86
|
+
x: clamp(x, N.margin, Math.max(N.margin, W - w - N.margin)),
|
|
87
|
+
y: clamp(y, N.margin, Math.max(N.margin, H - h - N.margin)),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function setNathanPosition(x, y) {
|
|
92
|
+
const clamped = clampNathanPosition(x, y);
|
|
93
|
+
nathanX = clamped.x;
|
|
94
|
+
nathanY = clamped.y;
|
|
95
|
+
nathanCard.style.left = `${nathanX}px`;
|
|
96
|
+
nathanCard.style.top = `${nathanY}px`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function startSlideIn() {
|
|
100
|
+
const { w, h } = nathanSize();
|
|
101
|
+
slideToY = (H - h) / 2;
|
|
102
|
+
slideToX = fromLeft ? N.margin : Math.max(N.margin, W - w - N.margin);
|
|
103
|
+
slideFromY = slideToY;
|
|
104
|
+
slideFromX = fromLeft ? -w - 40 : W + 40;
|
|
105
|
+
|
|
106
|
+
setNathanPosition(slideFromX, slideFromY);
|
|
107
|
+
nathanCard.classList.add("visible");
|
|
108
|
+
nathanSlidingIn = true;
|
|
109
|
+
nathanCanFlee = false;
|
|
110
|
+
slideStartAt = performance.now();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function initNathan({ imagePath, caption }) {
|
|
114
|
+
nathanCaptionEl.textContent = caption || "";
|
|
115
|
+
if (imagePath) {
|
|
116
|
+
nathanImg.onload = () => startSlideIn();
|
|
117
|
+
nathanImg.src = encodeURI(`file://${imagePath}`);
|
|
118
|
+
if (nathanImg.complete) startSlideIn();
|
|
119
|
+
} else {
|
|
120
|
+
startSlideIn();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function updateNathanMotion() {
|
|
125
|
+
if (nathanWhacked) return;
|
|
126
|
+
|
|
127
|
+
if (nathanSlidingIn) {
|
|
128
|
+
const t = clamp((performance.now() - slideStartAt) / N.slideDurationMs, 0, 1);
|
|
129
|
+
const eased = 1 - Math.pow(1 - t, 3);
|
|
130
|
+
setNathanPosition(
|
|
131
|
+
lerp(slideFromX, slideToX, eased),
|
|
132
|
+
lerp(slideFromY, slideToY, eased),
|
|
133
|
+
);
|
|
134
|
+
if (t >= 1) {
|
|
135
|
+
nathanSlidingIn = false;
|
|
136
|
+
nathanCanFlee = true;
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!nathanCanFlee) return;
|
|
142
|
+
|
|
143
|
+
const { w, h } = nathanSize();
|
|
144
|
+
const cx = nathanX + w / 2;
|
|
145
|
+
const cy = nathanY + h / 2;
|
|
146
|
+
const dx = cx - mouseX;
|
|
147
|
+
const dy = cy - mouseY;
|
|
148
|
+
const dist = Math.hypot(dx, dy);
|
|
149
|
+
if (dist > N.fleeRadius || dist < 1) return;
|
|
150
|
+
|
|
151
|
+
const push = ((N.fleeRadius - dist) / N.fleeRadius) * N.fleeSpeed;
|
|
152
|
+
setNathanPosition(nathanX + (dx / dist) * push, nathanY + (dy / dist) * push);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
window.bridge.onInitNathan(initNathan);
|
|
156
|
+
|
|
157
|
+
let nathanWhacked = false;
|
|
158
|
+
function whackNathan() {
|
|
159
|
+
if (nathanWhacked) return;
|
|
160
|
+
nathanWhacked = true;
|
|
161
|
+
nathanCanFlee = false;
|
|
162
|
+
nathanCard.style.opacity = "0";
|
|
163
|
+
nathanCard.style.transform = "scale(0.85)";
|
|
164
|
+
nathanCard.style.transition = "opacity 0.25s ease-in, transform 0.25s ease-in";
|
|
165
|
+
playCrackSound();
|
|
166
|
+
if (whip && !dropping) dropping = true;
|
|
167
|
+
setTimeout(() => window.bridge.hideOverlay(), 450);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
nathanCard.addEventListener("mousedown", (e) => {
|
|
171
|
+
e.stopPropagation();
|
|
172
|
+
whackNathan();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ── OpenWhip physics (adapted) ───────────────────────────────────────────────
|
|
176
|
+
const P = {
|
|
177
|
+
segments: 28,
|
|
178
|
+
segmentLength: 25,
|
|
179
|
+
taper: 0.6,
|
|
180
|
+
gravity: 1.0,
|
|
181
|
+
dropGravity: 0.95,
|
|
182
|
+
damping: 0.96,
|
|
183
|
+
constraintIters: 20,
|
|
184
|
+
maxStretchRatio: 1.2,
|
|
185
|
+
baseTargetAngle: -1.12,
|
|
186
|
+
handleAimByMouseX: 0.4,
|
|
187
|
+
handleAimByMouseY: 0.2,
|
|
188
|
+
handleAimClamp: 2.0,
|
|
189
|
+
handleSpring: 0.7,
|
|
190
|
+
handleAngularDamping: 0.078,
|
|
191
|
+
basePoseSegments: 2,
|
|
192
|
+
basePoseStiffStart: 0.9,
|
|
193
|
+
basePoseStiffEnd: 0.8,
|
|
194
|
+
handleMaxBendDeg: 16,
|
|
195
|
+
tipMaxBendDeg: 130,
|
|
196
|
+
bendRigidityStart: 0.8,
|
|
197
|
+
bendRigidityEnd: 0.12,
|
|
198
|
+
wallBounce: 0.42,
|
|
199
|
+
wallFriction: 0.86,
|
|
200
|
+
crackSpeed: 340,
|
|
201
|
+
crackCooldownMs: 200,
|
|
202
|
+
firstCrackGraceMs: 350,
|
|
203
|
+
lineWidthHandle: 7,
|
|
204
|
+
lineWidthTip: 5,
|
|
205
|
+
outlineWidth: 3,
|
|
206
|
+
handleExtraWidth: 5,
|
|
207
|
+
handleThickSegments: 2,
|
|
208
|
+
bgAlpha: 0.011,
|
|
209
|
+
arcWidth: 260,
|
|
210
|
+
arcHeight: 185,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const canvas = document.getElementById("c");
|
|
214
|
+
const ctx = canvas.getContext("2d");
|
|
215
|
+
let W, H;
|
|
216
|
+
|
|
217
|
+
function resize() {
|
|
218
|
+
W = canvas.width = window.innerWidth;
|
|
219
|
+
H = canvas.height = window.innerHeight;
|
|
220
|
+
}
|
|
221
|
+
resize();
|
|
222
|
+
window.addEventListener("resize", resize);
|
|
223
|
+
|
|
224
|
+
let mouseX = W / 2;
|
|
225
|
+
let mouseY = H / 2;
|
|
226
|
+
let prevMouseX = mouseX;
|
|
227
|
+
let prevMouseY = mouseY;
|
|
228
|
+
let whip = null;
|
|
229
|
+
let dropping = false;
|
|
230
|
+
let lastCrackTime = 0;
|
|
231
|
+
let whipSpawnTime = 0;
|
|
232
|
+
let handleAngle = P.baseTargetAngle;
|
|
233
|
+
let handleAngVel = 0;
|
|
234
|
+
|
|
235
|
+
const WHIP_CRACK_SOUNDS = ["sounds/A.mp3", "sounds/B.mp3", "sounds/C.mp3", "sounds/D.mp3", "sounds/E.mp3"];
|
|
236
|
+
|
|
237
|
+
document.addEventListener("mousemove", (e) => {
|
|
238
|
+
mouseX = e.clientX;
|
|
239
|
+
mouseY = e.clientY;
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
document.addEventListener("mousedown", (e) => {
|
|
243
|
+
if (e.target.closest("#nathan-card")) return;
|
|
244
|
+
if (whip && !dropping) dropping = true;
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
function spawnWhip(mx, my) {
|
|
248
|
+
dropping = false;
|
|
249
|
+
lastCrackTime = 0;
|
|
250
|
+
whipSpawnTime = Date.now();
|
|
251
|
+
const pts = [];
|
|
252
|
+
for (let i = 0; i < P.segments; i++) {
|
|
253
|
+
const t = i / (P.segments - 1);
|
|
254
|
+
const x = mx + t * P.arcWidth;
|
|
255
|
+
const y = my - Math.sin(t * Math.PI * 0.75) * P.arcHeight;
|
|
256
|
+
pts.push({ x, y, px: x, py: y });
|
|
257
|
+
}
|
|
258
|
+
return pts;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function segLen(i) {
|
|
262
|
+
const t = i / (P.segments - 1);
|
|
263
|
+
return P.segmentLength * (1 - t * (1 - P.taper));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function catmullPoint(pts, i) {
|
|
267
|
+
const n = pts.length;
|
|
268
|
+
if (n === 0) return { x: 0, y: 0 };
|
|
269
|
+
if (i < 0) {
|
|
270
|
+
if (n >= 2) return { x: 2 * pts[0].x - pts[1].x, y: 2 * pts[0].y - pts[1].y };
|
|
271
|
+
return { x: pts[0].x, y: pts[0].y };
|
|
272
|
+
}
|
|
273
|
+
if (i >= n) {
|
|
274
|
+
if (n >= 2) {
|
|
275
|
+
const a = pts[n - 2], b = pts[n - 1];
|
|
276
|
+
return { x: 2 * b.x - a.x, y: 2 * b.y - a.y };
|
|
277
|
+
}
|
|
278
|
+
return { x: pts[n - 1].x, y: pts[n - 1].y };
|
|
279
|
+
}
|
|
280
|
+
return pts[i];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function whipSegmentBezier(pts, i) {
|
|
284
|
+
const p0 = catmullPoint(pts, i - 1);
|
|
285
|
+
const p1 = pts[i];
|
|
286
|
+
const p2 = pts[i + 1];
|
|
287
|
+
const p3 = catmullPoint(pts, i + 2);
|
|
288
|
+
return {
|
|
289
|
+
cp1x: p1.x + (p2.x - p0.x) / 6,
|
|
290
|
+
cp1y: p1.y + (p2.y - p0.y) / 6,
|
|
291
|
+
cp2x: p2.x - (p3.x - p1.x) / 6,
|
|
292
|
+
cp2y: p2.y - (p3.y - p1.y) / 6,
|
|
293
|
+
x2: p2.x,
|
|
294
|
+
y2: p2.y,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const wrapPi = (a) => {
|
|
299
|
+
while (a > Math.PI) a -= Math.PI * 2;
|
|
300
|
+
while (a < -Math.PI) a += Math.PI * 2;
|
|
301
|
+
return a;
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
function playCrackSound() {
|
|
305
|
+
if (!WHIP_CRACK_SOUNDS.length) return;
|
|
306
|
+
const src = WHIP_CRACK_SOUNDS[Math.floor(Math.random() * WHIP_CRACK_SOUNDS.length)];
|
|
307
|
+
const a = new Audio(src);
|
|
308
|
+
a.play().catch(() => {});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function updateHandleAim() {
|
|
312
|
+
if (dropping) return;
|
|
313
|
+
const mvx = mouseX - prevMouseX;
|
|
314
|
+
const mvy = mouseY - prevMouseY;
|
|
315
|
+
const delta = clamp(
|
|
316
|
+
mvx * P.handleAimByMouseX + mvy * P.handleAimByMouseY,
|
|
317
|
+
-P.handleAimClamp,
|
|
318
|
+
P.handleAimClamp
|
|
319
|
+
);
|
|
320
|
+
const target = P.baseTargetAngle + delta;
|
|
321
|
+
const err = wrapPi(target - handleAngle);
|
|
322
|
+
handleAngVel += err * P.handleSpring;
|
|
323
|
+
handleAngVel *= P.handleAngularDamping;
|
|
324
|
+
handleAngle = wrapPi(handleAngle + handleAngVel);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function applyBasePose() {
|
|
328
|
+
if (!whip || dropping) return;
|
|
329
|
+
const dx = Math.cos(handleAngle);
|
|
330
|
+
const dy = Math.sin(handleAngle);
|
|
331
|
+
const guided = Math.min(P.basePoseSegments, whip.length - 1);
|
|
332
|
+
for (let i = 1; i <= guided; i++) {
|
|
333
|
+
const t = (i - 1) / Math.max(guided - 1, 1);
|
|
334
|
+
const stiff = lerp(P.basePoseStiffStart, P.basePoseStiffEnd, t);
|
|
335
|
+
const prev = whip[i - 1];
|
|
336
|
+
const p = whip[i];
|
|
337
|
+
const targetLen = segLen(i - 1);
|
|
338
|
+
const tx = prev.x + dx * targetLen;
|
|
339
|
+
const ty = prev.y + dy * targetLen;
|
|
340
|
+
p.x = lerp(p.x, tx, stiff);
|
|
341
|
+
p.y = lerp(p.y, ty, stiff);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function applyBendLimits() {
|
|
346
|
+
if (!whip || whip.length < 3) return;
|
|
347
|
+
for (let i = 1; i < whip.length - 1; i++) {
|
|
348
|
+
const a = whip[i - 1];
|
|
349
|
+
const b = whip[i];
|
|
350
|
+
const c = whip[i + 1];
|
|
351
|
+
const v1x = a.x - b.x;
|
|
352
|
+
const v1y = a.y - b.y;
|
|
353
|
+
const v2x = c.x - b.x;
|
|
354
|
+
const v2y = c.y - b.y;
|
|
355
|
+
const l1 = Math.hypot(v1x, v1y) || 0.0001;
|
|
356
|
+
const l2 = Math.hypot(v2x, v2y) || 0.0001;
|
|
357
|
+
const n1x = v1x / l1, n1y = v1y / l1;
|
|
358
|
+
const n2x = v2x / l2, n2y = v2y / l2;
|
|
359
|
+
const dot = clamp(n1x * n2x + n1y * n2y, -1, 1);
|
|
360
|
+
const angle = Math.acos(dot);
|
|
361
|
+
const t = i / (whip.length - 2);
|
|
362
|
+
const maxBend = lerp(P.handleMaxBendDeg, P.tipMaxBendDeg, t) * Math.PI / 180;
|
|
363
|
+
const bend = Math.PI - angle;
|
|
364
|
+
if (bend <= maxBend) continue;
|
|
365
|
+
const cross = n1x * n2y - n1y * n2x;
|
|
366
|
+
const sign = cross >= 0 ? 1 : -1;
|
|
367
|
+
const targetAngle = Math.PI - maxBend;
|
|
368
|
+
const targetA = Math.atan2(n1y, n1x) + sign * targetAngle;
|
|
369
|
+
const tx = b.x + Math.cos(targetA) * l2;
|
|
370
|
+
const ty = b.y + Math.sin(targetA) * l2;
|
|
371
|
+
const rigidity = lerp(P.bendRigidityStart, P.bendRigidityEnd, t);
|
|
372
|
+
c.x = lerp(c.x, tx, rigidity);
|
|
373
|
+
c.y = lerp(c.y, ty, rigidity);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function capSegmentStretch() {
|
|
378
|
+
if (!whip || whip.length < 2) return;
|
|
379
|
+
for (let i = 0; i < whip.length - 1; i++) {
|
|
380
|
+
const a = whip[i];
|
|
381
|
+
const b = whip[i + 1];
|
|
382
|
+
const dx = b.x - a.x;
|
|
383
|
+
const dy = b.y - a.y;
|
|
384
|
+
const dist = Math.hypot(dx, dy) || 0.0001;
|
|
385
|
+
const maxLen = segLen(i) * P.maxStretchRatio;
|
|
386
|
+
if (dist <= maxLen) continue;
|
|
387
|
+
const k = maxLen / dist;
|
|
388
|
+
b.x = a.x + dx * k;
|
|
389
|
+
b.y = a.y + dy * k;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function applyWallCollisions() {
|
|
394
|
+
if (!whip || dropping) return;
|
|
395
|
+
for (let i = 1; i < whip.length; i++) {
|
|
396
|
+
const p = whip[i];
|
|
397
|
+
let vx = p.x - p.px;
|
|
398
|
+
let vy = p.y - p.py;
|
|
399
|
+
let hit = false;
|
|
400
|
+
if (p.x < 0) { p.x = 0; if (vx < 0) vx = -vx * P.wallBounce; vy *= P.wallFriction; hit = true; }
|
|
401
|
+
else if (p.x > W) { p.x = W; if (vx > 0) vx = -vx * P.wallBounce; vy *= P.wallFriction; hit = true; }
|
|
402
|
+
if (p.y < 0) { p.y = 0; if (vy < 0) vy = -vy * P.wallBounce; vx *= P.wallFriction; hit = true; }
|
|
403
|
+
else if (p.y > H) { p.y = H; if (vy > 0) vy = -vy * P.wallBounce; vx *= P.wallFriction; hit = true; }
|
|
404
|
+
if (hit) { p.px = p.x - vx; p.py = p.y - vy; }
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function update() {
|
|
409
|
+
if (!whip) return;
|
|
410
|
+
const g = dropping ? P.dropGravity : P.gravity;
|
|
411
|
+
updateHandleAim();
|
|
412
|
+
const start = dropping ? 0 : 1;
|
|
413
|
+
for (let i = start; i < whip.length; i++) {
|
|
414
|
+
const p = whip[i];
|
|
415
|
+
const vx = (p.x - p.px) * P.damping;
|
|
416
|
+
const vy = (p.y - p.py) * P.damping;
|
|
417
|
+
p.px = p.x;
|
|
418
|
+
p.py = p.y;
|
|
419
|
+
p.x += vx;
|
|
420
|
+
p.y += vy + g;
|
|
421
|
+
}
|
|
422
|
+
if (!dropping) {
|
|
423
|
+
whip[0].x = mouseX;
|
|
424
|
+
whip[0].y = mouseY;
|
|
425
|
+
whip[0].px = mouseX;
|
|
426
|
+
whip[0].py = mouseY;
|
|
427
|
+
}
|
|
428
|
+
capSegmentStretch();
|
|
429
|
+
applyWallCollisions();
|
|
430
|
+
applyBasePose();
|
|
431
|
+
for (let iter = 0; iter < P.constraintIters; iter++) {
|
|
432
|
+
for (let i = 0; i < whip.length - 1; i++) {
|
|
433
|
+
const a = whip[i], b = whip[i + 1];
|
|
434
|
+
const dx = b.x - a.x, dy = b.y - a.y;
|
|
435
|
+
const dist = Math.sqrt(dx * dx + dy * dy) || 0.0001;
|
|
436
|
+
const target = segLen(i);
|
|
437
|
+
const diff = (dist - target) / dist * 0.5;
|
|
438
|
+
const ox = dx * diff, oy = dy * diff;
|
|
439
|
+
if (i === 0 && !dropping) {
|
|
440
|
+
b.x -= ox * 2;
|
|
441
|
+
b.y -= oy * 2;
|
|
442
|
+
} else {
|
|
443
|
+
a.x += ox; a.y += oy;
|
|
444
|
+
b.x -= ox; b.y -= oy;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
applyBendLimits();
|
|
448
|
+
if (!dropping) applyBasePose();
|
|
449
|
+
capSegmentStretch();
|
|
450
|
+
applyWallCollisions();
|
|
451
|
+
}
|
|
452
|
+
const tip = whip[whip.length - 1];
|
|
453
|
+
const tipVel = Math.hypot(tip.x - tip.px, tip.y - tip.py);
|
|
454
|
+
if (!dropping && tipVel > P.crackSpeed) {
|
|
455
|
+
const now = Date.now();
|
|
456
|
+
if (now - whipSpawnTime >= P.firstCrackGraceMs && now - lastCrackTime > P.crackCooldownMs) {
|
|
457
|
+
lastCrackTime = now;
|
|
458
|
+
playCrackSound();
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (dropping && whip.every((p) => p.y > H + 60)) {
|
|
462
|
+
whip = null;
|
|
463
|
+
dropping = false;
|
|
464
|
+
if (nathanWhacked) window.bridge.hideOverlay();
|
|
465
|
+
}
|
|
466
|
+
prevMouseX = mouseX;
|
|
467
|
+
prevMouseY = mouseY;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function draw() {
|
|
471
|
+
ctx.clearRect(0, 0, W, H);
|
|
472
|
+
ctx.fillStyle = `rgba(0,0,0,${P.bgAlpha})`;
|
|
473
|
+
ctx.fillRect(0, 0, W, H);
|
|
474
|
+
if (!whip) return;
|
|
475
|
+
ctx.lineCap = "round";
|
|
476
|
+
ctx.lineJoin = "round";
|
|
477
|
+
ctx.strokeStyle = "#fff";
|
|
478
|
+
if (whip.length >= 2) {
|
|
479
|
+
ctx.beginPath();
|
|
480
|
+
ctx.moveTo(whip[0].x, whip[0].y);
|
|
481
|
+
for (let i = 0; i < whip.length - 1; i++) {
|
|
482
|
+
const { cp1x, cp1y, cp2x, cp2y, x2, y2 } = whipSegmentBezier(whip, i);
|
|
483
|
+
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x2, y2);
|
|
484
|
+
}
|
|
485
|
+
ctx.lineWidth = P.lineWidthTip + P.outlineWidth * 2;
|
|
486
|
+
ctx.stroke();
|
|
487
|
+
const thickLinks = Math.min(P.handleThickSegments, whip.length - 1);
|
|
488
|
+
if (thickLinks > 0 && P.handleExtraWidth > 0) {
|
|
489
|
+
ctx.beginPath();
|
|
490
|
+
ctx.moveTo(whip[0].x, whip[0].y);
|
|
491
|
+
for (let i = 0; i < thickLinks; i++) {
|
|
492
|
+
const { cp1x, cp1y, cp2x, cp2y, x2, y2 } = whipSegmentBezier(whip, i);
|
|
493
|
+
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x2, y2);
|
|
494
|
+
}
|
|
495
|
+
ctx.lineWidth = P.lineWidthHandle + P.handleExtraWidth + P.outlineWidth * 2;
|
|
496
|
+
ctx.stroke();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
ctx.strokeStyle = "#111";
|
|
500
|
+
for (let i = 0; i < whip.length - 1; i++) {
|
|
501
|
+
const t = i / Math.max(1, whip.length - 2);
|
|
502
|
+
const extra = i < P.handleThickSegments ? P.handleExtraWidth : 0;
|
|
503
|
+
ctx.lineWidth = lerp(P.lineWidthHandle, P.lineWidthTip, t) + extra;
|
|
504
|
+
const { cp1x, cp1y, cp2x, cp2y, x2, y2 } = whipSegmentBezier(whip, i);
|
|
505
|
+
ctx.beginPath();
|
|
506
|
+
ctx.moveTo(whip[i].x, whip[i].y);
|
|
507
|
+
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x2, y2);
|
|
508
|
+
ctx.stroke();
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function loop() {
|
|
513
|
+
updateNathanMotion();
|
|
514
|
+
update();
|
|
515
|
+
draw();
|
|
516
|
+
requestAnimationFrame(loop);
|
|
517
|
+
}
|
|
518
|
+
loop();
|
|
519
|
+
|
|
520
|
+
window.bridge.onSpawnWhip(() => {
|
|
521
|
+
whip = spawnWhip(mouseX || W / 2, mouseY || H / 2);
|
|
522
|
+
dropping = false;
|
|
523
|
+
prevMouseX = mouseX;
|
|
524
|
+
prevMouseY = mouseY;
|
|
525
|
+
handleAngle = P.baseTargetAngle;
|
|
526
|
+
handleAngVel = 0;
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
window.bridge.onDropWhip(() => {
|
|
530
|
+
if (whip && !dropping) dropping = true;
|
|
531
|
+
});
|
|
532
|
+
</script>
|
|
533
|
+
</body>
|
|
534
|
+
</html>
|
package/whip/preload.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const { contextBridge, ipcRenderer } = require("electron");
|
|
2
|
+
|
|
3
|
+
contextBridge.exposeInMainWorld("bridge", {
|
|
4
|
+
whipCrack: () => ipcRenderer.send("whip-crack"),
|
|
5
|
+
hideOverlay: () => ipcRenderer.send("hide-overlay"),
|
|
6
|
+
onSpawnWhip: (fn) => ipcRenderer.on("spawn-whip", () => fn()),
|
|
7
|
+
onDropWhip: (fn) => ipcRenderer.on("drop-whip", () => fn()),
|
|
8
|
+
onInitNathan: (fn) => ipcRenderer.on("init-nathan", (_event, payload) => fn(payload)),
|
|
9
|
+
});
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|