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 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
- Pops a photo of **Nathan Gong** on screen every time Claude Code finishes
4
- responding to a prompt. Download it and it just works — no Rosetta, no
5
- Homebrew, no notification permissions.
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
- That's it. The `postinstall` step wires a Claude Code [`Stop` hook](https://docs.claude.com/en/docs/claude-code/hooks)
14
- into `~/.claude/settings.json`. The next time Claude finishes a turn, Nathan's
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
- Photos are drawn by a tiny bundled **native universal binary** (`vendor/nathan-popup`,
18
- built from `native/nathan-popup.swift`) a borderless AppKit window. It never
19
- steals focus, adds no Dock icon, and runs on both Apple Silicon and Intel. If
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
- Tune how long the card stays up (default 2.5s) with an env var on the hook:
24
- `NATHANGONG_POPUP_SECONDS=4`.
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
- // Pops a small floating card showing an image (a photo of Nathan Gong) in the
4
- // top-right of the main screen, then fades out and exits. Deliberately avoids
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 seconds = args.count >= 4 ? (Double(args[3]) ?? 2.5) : 2.5
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) // no Dock icon, no focus steal
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 origin = NSPoint(x: vf.maxX - winW - 20, y: vf.maxY - winH - 20)
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: NSRect(origin: origin, size: NSSize(width: winW, height: winH)),
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 = true
76
+ panel.ignoresMouseEvents = false
77
+ panel.acceptsMouseMovedEvents = true
49
78
  panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
50
79
 
51
- let container = NSView(frame: NSRect(x: 0, y: 0, width: winW, height: winH))
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.96).cgColor
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
- NSAnimationContext.runAnimationGroup { ctx in
82
- ctx.duration = 0.22
83
- panel.animator().alphaValue = 1
111
+ var dismissed = false
112
+
113
+ func cleanupAndExit() {
114
+ app.terminate(nil)
84
115
  }
85
116
 
86
- DispatchQueue.main.asyncAfter(deadline: .now() + seconds) {
117
+ func dismissWhacked() {
118
+ guard !dismissed else { return }
119
+ dismissed = true
120
+
87
121
  NSAnimationContext.runAnimationGroup({ ctx in
88
- ctx.duration = 0.4
122
+ ctx.duration = 0.25
89
123
  panel.animator().alphaValue = 0
90
124
  }, completionHandler: {
91
- app.terminate(nil)
125
+ cleanupAndExit()
92
126
  })
93
127
  }
94
128
 
95
- // Safety valve: never linger forever.
96
- DispatchQueue.main.asyncAfter(deadline: .now() + seconds + 2) {
97
- app.terminate(nil)
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.2.0",
4
- "description": "Pops a photo of Nathan Gong on screen after every Claude Code turn. Native, no Rosetta.",
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": ">=14"
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
- // Pops a floating card with a random photo of Nathan Gong via our bundled
6
- // native `nathan-popup` binary (no notification permission, no Rosetta, works
7
- // on arm64 + x86_64). Falls back to a plain `osascript` text notification if
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
- // Emoji-only fallback — used only when the native popup can't run.
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 popup = path.join(__dirname, "..", "vendor", "nathan-popup");
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 && canRun(popup)) {
77
- const child = spawn(popup, [image, caption, seconds], {
78
- detached: true,
79
- stdio: "ignore",
80
- });
81
- child.on("error", () => osascriptFallback(caption));
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
+ };
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>
@@ -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