nathangong 0.2.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 +10 -13
- package/bin/cli.js +0 -0
- package/native/nathan-popup.swift +68 -24
- package/package.json +9 -4
- package/scripts/notify.js +61 -15
- 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,8 +1,8 @@
|
|
|
1
1
|
# nathangong
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -10,18 +10,15 @@ Homebrew, no notification permissions.
|
|
|
10
10
|
npm install -g nathangong
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
into `~/.claude/settings.json`.
|
|
15
|
-
face fades in as a small card in the top-right of your screen, then fades out.
|
|
13
|
+
The `postinstall` step wires a Claude Code [`Stop` hook](https://docs.claude.com/en/docs/claude-code/hooks)
|
|
14
|
+
into `~/.claude/settings.json`.
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
the binary can't launch for some reason, it falls back to a plain text
|
|
21
|
-
notification via `osascript`.
|
|
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.
|
|
22
19
|
|
|
23
|
-
|
|
24
|
-
`NATHANGONG_POPUP_SECONDS=
|
|
20
|
+
Set a max timeout if Nathan survives too long (default 60s):
|
|
21
|
+
`NATHANGONG_POPUP_SECONDS=120`.
|
|
25
22
|
|
|
26
23
|
## Commands
|
|
27
24
|
|
package/bin/cli.js
CHANGED
|
File without changes
|
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
// nathan-popup <imagePath> [caption] [seconds]
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// Notification Center: no notification permission, no deprecated
|
|
6
|
-
// NSUserNotification, no Rosetta — just an AppKit window. Works on any modern
|
|
7
|
-
// macOS on both arm64 and x86_64.
|
|
8
|
-
//
|
|
9
|
-
// Non-activating + accessory policy => never steals focus, no Dock icon.
|
|
3
|
+
// Fallback when Electron/OpenWhip overlay isn't available: Nathan slides in
|
|
4
|
+
// slowly from the left or right. Click him to dismiss.
|
|
10
5
|
|
|
11
6
|
import Cocoa
|
|
12
7
|
|
|
@@ -14,14 +9,44 @@ let args = CommandLine.arguments
|
|
|
14
9
|
guard args.count >= 2 else { exit(0) }
|
|
15
10
|
let imagePath = args[1]
|
|
16
11
|
let caption = args.count >= 3 ? args[2] : ""
|
|
17
|
-
let
|
|
12
|
+
let maxSeconds = args.count >= 4 ? (Double(args[3]) ?? 60) : 60
|
|
18
13
|
|
|
19
14
|
guard let image = NSImage(contentsOfFile: imagePath) else { exit(0) }
|
|
20
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
|
+
|
|
21
47
|
let app = NSApplication.shared
|
|
22
|
-
app.setActivationPolicy(.accessory)
|
|
48
|
+
app.setActivationPolicy(.accessory)
|
|
23
49
|
|
|
24
|
-
// Layout
|
|
25
50
|
let imgW: CGFloat = 240
|
|
26
51
|
let ratio = image.size.width > 0 ? image.size.height / image.size.width : 1
|
|
27
52
|
let imgH = (imgW * ratio).rounded()
|
|
@@ -29,13 +54,16 @@ let capH: CGFloat = caption.isEmpty ? 0 : 24
|
|
|
29
54
|
let pad: CGFloat = 12
|
|
30
55
|
let winW = imgW + pad * 2
|
|
31
56
|
let winH = imgH + capH + pad * 2
|
|
57
|
+
let winSize = NSSize(width: winW, height: winH)
|
|
32
58
|
|
|
33
59
|
guard let screen = NSScreen.main else { exit(0) }
|
|
34
60
|
let vf = screen.visibleFrame
|
|
35
|
-
let
|
|
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)
|
|
36
64
|
|
|
37
65
|
let panel = NSPanel(
|
|
38
|
-
contentRect:
|
|
66
|
+
contentRect: startFrame,
|
|
39
67
|
styleMask: [.borderless, .nonactivatingPanel],
|
|
40
68
|
backing: .buffered,
|
|
41
69
|
defer: false
|
|
@@ -45,12 +73,13 @@ panel.level = .floating
|
|
|
45
73
|
panel.backgroundColor = .clear
|
|
46
74
|
panel.isOpaque = false
|
|
47
75
|
panel.hasShadow = true
|
|
48
|
-
panel.ignoresMouseEvents =
|
|
76
|
+
panel.ignoresMouseEvents = false
|
|
77
|
+
panel.acceptsMouseMovedEvents = true
|
|
49
78
|
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
50
79
|
|
|
51
|
-
let container =
|
|
80
|
+
let container = WhackTargetView(frame: NSRect(x: 0, y: 0, width: winW, height: winH))
|
|
52
81
|
container.wantsLayer = true
|
|
53
|
-
container.layer?.backgroundColor = NSColor(calibratedWhite: 0.10, alpha: 0.
|
|
82
|
+
container.layer?.backgroundColor = NSColor(calibratedWhite: 0.10, alpha: 0.94).cgColor
|
|
54
83
|
container.layer?.cornerRadius = 16
|
|
55
84
|
|
|
56
85
|
let iv = NSImageView(frame: NSRect(x: pad, y: pad + capH, width: imgW, height: imgH))
|
|
@@ -76,25 +105,40 @@ if !caption.isEmpty {
|
|
|
76
105
|
|
|
77
106
|
panel.contentView = container
|
|
78
107
|
panel.alphaValue = 0
|
|
108
|
+
panel.setFrame(startFrame, display: false)
|
|
79
109
|
panel.orderFrontRegardless()
|
|
80
110
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
111
|
+
var dismissed = false
|
|
112
|
+
|
|
113
|
+
func cleanupAndExit() {
|
|
114
|
+
app.terminate(nil)
|
|
84
115
|
}
|
|
85
116
|
|
|
86
|
-
|
|
117
|
+
func dismissWhacked() {
|
|
118
|
+
guard !dismissed else { return }
|
|
119
|
+
dismissed = true
|
|
120
|
+
|
|
87
121
|
NSAnimationContext.runAnimationGroup({ ctx in
|
|
88
|
-
ctx.duration = 0.
|
|
122
|
+
ctx.duration = 0.25
|
|
89
123
|
panel.animator().alphaValue = 0
|
|
90
124
|
}, completionHandler: {
|
|
91
|
-
|
|
125
|
+
cleanupAndExit()
|
|
92
126
|
})
|
|
93
127
|
}
|
|
94
128
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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()
|
|
98
142
|
}
|
|
99
143
|
|
|
100
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",
|
|
@@ -22,8 +22,10 @@
|
|
|
22
22
|
"assets",
|
|
23
23
|
"vendor",
|
|
24
24
|
"native",
|
|
25
|
+
"whip",
|
|
25
26
|
"index.js",
|
|
26
|
-
"README.md"
|
|
27
|
+
"README.md",
|
|
28
|
+
"INSTALL.md"
|
|
27
29
|
],
|
|
28
30
|
"scripts": {
|
|
29
31
|
"postinstall": "node scripts/postinstall.js",
|
|
@@ -31,6 +33,9 @@
|
|
|
31
33
|
"test": "node bin/cli.js test"
|
|
32
34
|
},
|
|
33
35
|
"engines": {
|
|
34
|
-
"node": ">=
|
|
36
|
+
"node": ">=18.0.0"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"electron": "^33.0.0"
|
|
35
40
|
}
|
|
36
41
|
}
|
package/scripts/notify.js
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
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
|
-
// the binary is missing or can't launch.
|
|
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.
|
|
9
8
|
//
|
|
10
9
|
// Rules: always exit 0 (a failing hook nags every turn) and never hang.
|
|
11
10
|
|
|
12
11
|
const fs = require("fs");
|
|
13
12
|
const path = require("path");
|
|
14
13
|
const { spawn } = require("child_process");
|
|
14
|
+
const { tryAcquireLock, claimLock, clearLock } = require("./session-lock");
|
|
15
15
|
|
|
16
16
|
process.stdout.on("error", () => {});
|
|
17
17
|
process.stderr.on("error", () => {});
|
|
@@ -51,7 +51,56 @@ function canRun(p) {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
|
|
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;
|
|
85
|
+
}
|
|
86
|
+
|
|
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
|
+
|
|
55
104
|
function osascriptFallback(caption) {
|
|
56
105
|
const script =
|
|
57
106
|
"display notification " +
|
|
@@ -70,17 +119,14 @@ function main() {
|
|
|
70
119
|
|
|
71
120
|
const caption = pick(CAPTIONS);
|
|
72
121
|
const image = pickImage();
|
|
73
|
-
const
|
|
74
|
-
const seconds = String(Number(process.env.NATHANGONG_POPUP_SECONDS) || 2.5);
|
|
122
|
+
const seconds = String(Number(process.env.NATHANGONG_POPUP_SECONDS) || 60);
|
|
75
123
|
|
|
76
|
-
if (image &&
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
child.unref();
|
|
83
|
-
// Linger briefly to catch a launch error, then always exit 0.
|
|
124
|
+
if (image && launchWhipOverlay(image, caption, seconds)) {
|
|
125
|
+
setTimeout(() => process.exit(0), 150);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (image && launchNativePopup(image, caption, seconds)) {
|
|
84
130
|
setTimeout(() => process.exit(0), 150);
|
|
85
131
|
return;
|
|
86
132
|
}
|
|
@@ -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
|
+
};
|
package/vendor/nathan-popup
CHANGED
|
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
|