PageSourceSearch

https://pupwife.com/scripts/followers/orcaFollower.js

js pupwife.com collected 2026-09-30 15:05:22 UTC 10,448 bytes, 370 lines download raw bytes

1/**
2 * Orca Cursor Follower
3 * Inspired by whalefollowmouse.com
4 * Refactored for integration with follower switcher
5 */
6
7(function() {
8    'use strict';
9
10    // Physics constants
11    const HORIZONTAL_DIVISOR = 40;
12    const VERTICAL_DIVISOR = 40;
13    const SPEED = 60;              // Slower speed to keep frames tightly together
14    const MOBILE_SPEED = 40;       // Slower speed on mobile
15    const MIN_DISTANCE = 10;       // Tight frame spacing (1/5th of original 50)
16    const THRESHOLD_DISTANCE = 1;  // Tight frame spacing (1/5th of original 15)
17    const THRESHOLD_ANGLE = 1;
18    const FPS = 60;
19    const FRAME_TIME = 1000 / FPS;
20    const MOBILE_BREAKPOINT = 425;
21    const TABLET_BREAKPOINT = 640;
22    const SCALE_DESKTOP = 0.2;     // 1/5th size for desktop
23    const SCALE_TABLET = 0.12;     // 1/5th size for tablet
24    const SCALE_MOBILE = 0.17;     // 1/5th size for mobile
25    const IMAGE_OFFSET_X = 2;
26    const IMAGE_OFFSET_Y = 1.7;
27    const FRAME_COUNT = 44;
28
29    // Image paths for all 44 frames
30    const FRAME_PATHS = [...Array(FRAME_COUNT).keys()].map(i => `/images/orca/${i + 1}.webp`);
31
32    // Module state
33    let canvas = null;
34    let ctx = null;
35    let images = [];
36    let mouseX = 0;
37    let mouseY = 0;
38    let speed = SPEED;
39    let lastFrameTime = Date.now();
40    let animationId = null;
41    let isInitialized = false;
42
43    // Orca state
44    const orcaState = {
45        layerPositions: [],
46        orca: { x: 0, y: 0 },
47        mouseOffset: { x: 0, y: 0 }
48    };
49
50    // Event handlers
51    let mouseMoveHandler = null;
52    let touchMoveHandler = null;
53    let resizeHandler = null;
54
55    /**
56     * Load all orca frame images
57     */
58    function loadImages(scale) {
59        const imagePromises = FRAME_PATHS.map((path, index) => {
60            return new Promise((resolve, reject) => {
61                const img = new Image();
62                img.onload = () => {
63                    img.width = img.naturalWidth * scale;
64                    img.height = img.naturalHeight * scale;
65                    resolve({ img, id: index });
66                };
67                img.onerror = () => reject({ img, id: index });
68                img.src = path;
69            });
70        });
71
72        return Promise.all(imagePromises);
73    }
74
75    /**
76     * Sort images by ID in descending order
77     */
78    function sortImages(loadedImages) {
79        loadedImages.sort((a, b) => a.id > b.id ? -1 : 1);
80    }
81
82    /**
83     * Calculate movement vector towards target
84     */
85    function calculateMovement(target, current, currentSpeed) {
86        const dx = target.x - current.x;
87        const dy = target.y - current.y;
88        const angle = Math.atan2(dy, dx);
89        const distance = Math.sqrt(dx * dx + dy * dy);
90
91        if (distance > THRESHOLD_DISTANCE) {
92            const movement = polarToCartesian(currentSpeed, angle);
93            return {
94                x: movement.x / HORIZONTAL_DIVISOR,
95                y: movement.y / VERTICAL_DIVISOR
96            };
97        } else if (angle > THRESHOLD_ANGLE) {
98            const movement = polarToCartesian(MIN_DISTANCE, angle);
99            return {
100                x: movement.x / HORIZONTAL_DIVISOR,
101                y: movement.y / VERTICAL_DIVISOR
102            };
103        }
104
105        return { x: dx, y: dy };
106    }
107
108    /**
109     * Convert polar coordinates to cartesian
110     */
111    function polarToCartesian(radius, angle) {
112        return {
113            x: radius * Math.cos(angle),
114            y: radius * Math.sin(angle)
115        };
116    }
117
118    /**
119     * Create array filled with a value
120     */
121    function fillArray(value) {
122        return [...Array(FRAME_COUNT).keys()].map(() => value);
123    }
124
125    /**
126     * Get appropriate scale based on window size
127     */
128    function getScale() {
129        if (window.matchMedia(`screen and (max-width: ${MOBILE_BREAKPOINT}px)`).matches) {
130            return SCALE_TABLET;
131        }
132        if (window.matchMedia(`screen and (max-width: ${TABLET_BREAKPOINT}px)`).matches) {
133            return SCALE_MOBILE;
134        }
135        return SCALE_DESKTOP;
136    }
137
138    /**
139     * Initialize orca positions
140     */
141    function initializePositions() {
142        const centerX = window.innerWidth / 2;
143        const centerY = window.innerHeight / 2;
144
145        orcaState.layerPositions = fillArray({ x: centerX, y: centerY });
146        orcaState.orca.x = centerX;
147        orcaState.orca.y = centerY;
148        mouseX = centerX;
149        mouseY = centerY;
150    }
151
152    /**
153     * Handle window resize
154     */
155    function handleResize() {
156        if (!canvas) return;
157        canvas.width = window.innerWidth;
158        canvas.height = window.innerHeight;
159    }
160
161    /**
162     * Handle mouse movement
163     */
164    function handleMouseMove(e) {
165        mouseX = e.clientX;
166        mouseY = e.clientY;
167    }
168
169    /**
170     * Handle touch movement
171     */
172    function handleTouchMove(e) {
173        if (e.targetTouches.length > 0) {
174            mouseX = e.targetTouches[0].clientX;
175            mouseY = e.targetTouches[0].clientY;
176        }
177    }
178
179    /**
180     * Update orca positions
181     */
182    function updatePositions() {
183        const target = {
184            x: mouseX - orcaState.mouseOffset.x,
185            y: mouseY - orcaState.mouseOffset.y
186        };
187
188        const movement = calculateMovement(target, orcaState.orca, speed);
189
190        orcaState.orca.x += movement.x;
191        orcaState.orca.y += movement.y;
192
193        orcaState.layerPositions.push({
194            x: orcaState.orca.x,
195            y: orcaState.orca.y
196        });
197        orcaState.layerPositions.shift();
198    }
199
200    /**
201     * Render orca frames
202     */
203    function render() {
204        if (!ctx || !canvas) return;
205
206        // Clear canvas
207        ctx.clearRect(0, 0, canvas.width, canvas.height);
208
209        // Draw all layers
210        orcaState.layerPositions.forEach((pos, index) => {
211            const img = images[index].img;
212            const width = img.width;
213            const height = img.height;
214
215            ctx.drawImage(
216                img,
217                pos.x - width / IMAGE_OFFSET_X,
218                pos.y - height / IMAGE_OFFSET_Y,
219                width,
220                height
221            );
222        });
223    }
224
225    /**
226     * Animation loop
227     */
228    function animate() {
229        if (!isInitialized) return;
230
231        const now = Date.now();
232        const elapsed = now - lastFrameTime;
233
234        // Update positions at target FPS
235        if (elapsed > FRAME_TIME) {
236            lastFrameTime = now - (elapsed % FRAME_TIME);
237            updatePositions();
238        }
239
240        render();
241        animationId = requestAnimationFrame(animate);
242    }
243
244    /**
245     * Set mobile speed if needed
246     */
247    function setMobileSpeed() {
248        if (window.matchMedia(`screen and (max-width: ${MOBILE_BREAKPOINT}px)`).matches) {
249            speed = MOBILE_SPEED;
250        }
251    }
252
253    /**
254     * Initialize the orca follower
255     */
256    function init() {
257        if (isInitialized) {
258            console.warn('🐋 Orca follower already initialized');
259            return;
260        }
261
262        console.log('🐋 Initializing orca follower...');
263
264        // Create canvas
265        canvas = document.createElement('canvas');
266        canvas.id = 'orca-canvas';
267        canvas.style.cssText = `
268            position: fixed;
269            top: 0;
270            left: 0;
271            width: 100%;
272            height: 100%;
273            pointer-events: none;
274            z-index: 9998;
275        `;
276        document.body.appendChild(canvas);
277
278        ctx = canvas.getContext('2d');
279        if (!ctx) {
280            console.error('🐋 Failed to get canvas context');
281            cleanup();
282            return;
283        }
284
285        canvas.width = window.innerWidth;
286        canvas.height = window.innerHeight;
287
288        // Set speed based on device
289        setMobileSpeed();
290
291        // Initialize positions
292        initializePositions();
293
294        // Set up event listeners
295        mouseMoveHandler = handleMouseMove;
296        touchMoveHandler = handleTouchMove;
297        resizeHandler = handleResize;
298
299        window.addEventListener('mousemove', mouseMoveHandler, false);
300        window.addEventListener('touchmove', touchMoveHandler, false);
301        window.addEventListener('resize', resizeHandler, false);
302
303        // Load images and start animation
304        const scale = getScale();
305        loadImages(scale)
306            .then(loadedImages => {
307                images = loadedImages;
308                sortImages(images);
309                isInitialized = true;
310                animate();
311                console.log('🐋 Orca follower initialized successfully!');
312            })
313            .catch(err => {
314                console.error('🐋 Failed to load orca images:', err);
315                cleanup();
316            });
317    }
318
319    /**
320     * Clean up the orca follower
321     */
322    function cleanup() {
323        console.log('🐋 Cleaning up orca follower...');
324
325        isInitialized = false;
326
327        // Cancel animation
328        if (animationId) {
329            cancelAnimationFrame(animationId);
330            animationId = null;
331        }
332
333        // Remove event listeners
334        if (mouseMoveHandler) {
335            window.removeEventListener('mousemove', mouseMoveHandler, false);
336            mouseMoveHandler = null;
337        }
338        if (touchMoveHandler) {
339            window.removeEventListener('touchmove', touchMoveHandler, false);
340            touchMoveHandler = null;
341        }
342        if (resizeHandler) {
343            window.removeEventListener('resize', resizeHandler, false);
344            resizeHandler = null;
345        }
346
347        // Remove canvas
348        if (canvas && canvas.parentNode) {
349            canvas.parentNode.removeChild(canvas);
350        }
351        canvas = null;
352        ctx = null;
353
354        // Clear state
355        images = [];
356        orcaState.layerPositions = [];
357        orcaState.orca = { x: 0, y: 0 };
358        orcaState.mouseOffset = { x: 0, y: 0 };
359
360        console.log('🐋 Orca follower cleaned up');
361    }
362
363    // Expose public API
364    window.OrcaFollower = {
365        init,
366        cleanup
367    };
368
369})();
370

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.