PageSourceSearch

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

js pupwife.com collected 2026-09-30 15:05:21 UTC 42,829 bytes, 922 lines download raw bytes

1// Skeletile - Procedural skeleton creature follower
2// Refactored for puppywifey website integration
3
4(function() {
5    'use strict';
6    
7    // Module state
8    let canvas = null;
9    let ctx = null;
10    let animationFrame = null;
11    let critter = null;
12    let isActive = false;
13    let legCount = 1; // Track leg count for glow scaling
14    
15    // Input tracking
16    const Input = {
17        mouse: {
18            x: window.innerWidth / 2,
19            y: window.innerHeight / 2
20        }
21    };
22    
23    // ========== HUNTER/STALKER PERSONALITY ==========
24    // Track cursor movement for hunting behavior
25    let cursorHistory = [];
26    const HISTORY_SIZE = 10;
27    let lastMouseX = Input.mouse.x;
28    let lastMouseY = Input.mouse.y;
29    let cursorIdleTime = 0;
30    let huntAngle = Math.random() * Math.PI * 2; // Start with random hunt angle
31    let huntTimer = 0; // Timer for switching hunt strategies
32    let lastFrameTime = performance.now(); // For delta time tracking
33    let stalkPosition = {x: Input.mouse.x, y: Input.mouse.y}; // Position Skeletile actually follows (lags behind)
34    let pounceTimer = 0; // Timer for pounce behavior
35    let hasPounced = false; // Whether pounce has occurred
36    let lastCoilTime = 0; // When Skeletile last successfully coiled around cursor
37    let aggressionLevel = 0; // 0-1, increases the longer without catching cursor
38    let coilAngle = 0; // Current angle around cursor for coiling
39    let creatureSize = 1.0; // Will be set based on actual size
40    let isBrute = false; // Brute force (few legs) vs sneaky (many legs)
41    let pounceState = 'none'; // 'none', 'approaching', 'attacking', 'wrapping' - tracks pounce phase
42    let wrapRadius = 25; // Current wrap radius for ouroboros behavior (will be initialized properly)
43    
44    const IDLE_THRESHOLD = 1200; // ms before cursor is considered idle
45    const IDLE_RADIUS = 25; // Pixels - cursor must stay within this radius to be "idle"
46    const COIL_RADIUS = IDLE_RADIUS; // Match the idle radius for coiling
47    const STALK_DISTANCE = 250; // How far behind to lag (stalking distance)
48    const POUNCE_TRIGGER = 2500; // ms of being idle before pouncing
49    const LAG_SPEED = 0.1; // How fast stalk position catches up (0-1, lower = more lag)
50    const AGGRESSION_INCREASE_RATE = 0.0005; // How fast aggression builds (per ms)
51    
52    // Event handlers (stored for cleanup)
53    let mouseMoveHandler = null;
54    let resizeHandler = null;
55    
56    // Segment counter
57    let segmentCount = 0;
58    
59    // ========== CLASSES ==========
60    
61    class Segment {
62        constructor(parent, size, angle, range, stiffness) {
63            segmentCount++;
64            this.isSegment = true;
65            this.parent = parent;
66            if (typeof parent.children == "object") {
67                parent.children.push(this);
68            }
69            this.children = [];
70            this.size = size;
71            this.relAngle = angle;
72            this.defAngle = angle;
73            this.absAngle = parent.absAngle + angle;
74            this.range = range;
75            this.stiffness = stiffness;
76            this.updateRelative(false, true);
77        }
78        
79        updateRelative(iter, flex) {
80            this.relAngle = this.relAngle - 2 * Math.PI * Math.floor((this.relAngle - this.defAngle) / 2 / Math.PI + 1 / 2);
81            if (flex) {
82                this.relAngle = Math.min(
83                    this.defAngle + this.range / 2,
84                    Math.max(
85                        this.defAngle - this.range / 2,
86                        (this.relAngle - this.defAngle) / this.stiffness + this.defAngle
87                    )
88                );
89            }
90            this.absAngle = this.parent.absAngle + this.relAngle;
91            this.x = this.parent.x + Math.cos(this.absAngle) * this.size;
92            this.y = this.parent.y + Math.sin(this.absAngle) * this.size;
93            if (iter) {
94                for (var i = 0; i < this.children.length; i++) {
95                    this.children[i].updateRelative(iter, flex);
96                }
97            }
98        }
99        
100        draw(iter) {
101            ctx.beginPath();
102            ctx.moveTo(this.parent.x, this.parent.y);
103            ctx.lineTo(this.x, this.y);
104            ctx.stroke();
105            if (iter) {
106                for (var i = 0; i < this.children.length; i++) {
107                    this.children[i].draw(true);
108                }
109            }
110        }
111        
112        follow(iter) {
113            var x = this.parent.x;
114            var y = this.parent.y;
115            var dist = ((this.x - x) ** 2 + (this.y - y) ** 2) ** 0.5;
116            this.x = x + this.size * (this.x - x) / dist;
117            this.y = y + this.size * (this.y - y) / dist;
118            this.absAngle = Math.atan2(this.y - y, this.x - x);
119            this.relAngle = this.absAngle - this.parent.absAngle;
120            this.updateRelative(false, true);
121            if (iter) {
122                for (var i = 0; i < this.children.length; i++) {
123                    this.children[i].follow(true);
124                }
125            }
126        }
127    }
128    
129    class LimbSystem {
130        constructor(end, length, speed, creature) {
131            this.end = end;
132            this.length = Math.max(1, length);
133            this.creature = creature;
134            this.speed = speed;
135            creature.systems.push(this);
136            this.nodes = [];
137            var node = end;
138            for (var i = 0; i < length; i++) {
139                this.nodes.unshift(node);
140                node = node.parent;
141                if (!node.isSegment) {
142                    this.length = i + 1;
143                    break;
144                }
145            }
146            this.hip = this.nodes[0].parent;
147        }
148        
149        moveTo(x, y) {
150            this.nodes[0].updateRelative(true, true);
151            var dist = ((x - this.end.x) ** 2 + (y - this.end.y) ** 2) ** 0.5;
152            var len = Math.max(0, dist - this.speed);
153            for (var i = this.nodes.length - 1; i >= 0; i--) {
154                var node = this.nodes[i];
155                var ang = Math.atan2(node.y - y, node.x - x);
156                node.x = x + len * Math.cos(ang);
157                node.y = y + len * Math.sin(ang);
158                x = node.x;
159                y = node.y;
160                len = node.size;
161            }
162            for (var i = 0; i < this.nodes.length; i++) {
163                var node = this.nodes[i];
164                node.absAngle = Math.atan2(node.y - node.parent.y, node.x - node.parent.x);
165                node.relAngle = node.absAngle - node.parent.absAngle;
166                for (var ii = 0; ii < node.children.length; ii++) {
167                    var childNode = node.children[ii];
168                    if (!this.nodes.includes(childNode)) {
169                        childNode.updateRelative(true, false);
170                    }
171                }
172            }
173        }
174        
175        update() {
176            this.moveTo(Input.mouse.x, Input.mouse.y);
177        }
178    }
179    
180    class LegSystem extends LimbSystem {
181        constructor(end, length, speed, creature) {
182            super(end, length, speed, creature);
183            this.goalX = end.x;
184            this.goalY = end.y;
185            this.step = 0;
186            this.forwardness = 0;
187            
188            this.reach = 0.9 * ((this.end.x - this.hip.x) ** 2 + (this.end.y - this.hip.y) ** 2) ** 0.5;
189            var relAngle = this.creature.absAngle - Math.atan2(this.end.y - this.hip.y, this.end.x - this.hip.x);
190            relAngle -= 2 * Math.PI * Math.floor(relAngle / 2 / Math.PI + 1 / 2);
191            this.swing = -relAngle + (2 * (relAngle < 0) - 1) * Math.PI / 2;
192            this.swingOffset = this.creature.absAngle - this.hip.absAngle;
193        }
194        
195        update(x, y) {
196            this.moveTo(this.goalX, this.goalY);
197            if (this.step == 0) {
198                var dist = ((this.end.x - this.goalX) ** 2 + (this.end.y - this.goalY) ** 2) ** 0.5;
199                if (dist > 1) {
200                    this.step = 1;
201                    this.goalX = this.hip.x + this.reach * Math.cos(this.swing + this.hip.absAngle + this.swingOffset) + (2 * Math.random() - 1) * this.reach / 2;
202                    this.goalY = this.hip.y + this.reach * Math.sin(this.swing + this.hip.absAngle + this.swingOffset) + (2 * Math.random() - 1) * this.reach / 2;
203                }
204            } else if (this.step == 1) {
205                var theta = Math.atan2(this.end.y - this.hip.y, this.end.x - this.hip.x) - this.hip.absAngle;
206                var dist = ((this.end.x - this.hip.x) ** 2 + (this.end.y - this.hip.y) ** 2) ** 0.5;
207                var forwardness2 = dist * Math.cos(theta);
208                var dF = this.forwardness - forwardness2;
209                this.forwardness = forwardness2;
210                if (dF * dF < 1) {
211                    this.step = 0;
212                    this.goalX = this.hip.x + (this.end.x - this.hip.x);
213                    this.goalY = this.hip.y + (this.end.y - this.hip.y);
214                }
215            }
216        }
217    }
218    
219    class Creature {
220        constructor(x, y, angle, fAccel, fFric, fRes, fThresh, rAccel, rFric, rRes, rThresh) {
221            this.x = x;
222            this.y = y;
223            this.absAngle = angle;
224            this.fSpeed = 0;
225            this.fAccel = fAccel;
226            this.fFric = fFric;
227            this.fRes = fRes;
228            this.fThresh = fThresh;
229            this.rSpeed = 0;
230            this.rAccel = rAccel;
231            this.rFric = rFric;
232            this.rRes = rRes;
233            this.rThresh = rThresh;
234            this.children = [];
235            this.systems = [];
236        }
237        
238        async follow(x, y, deltaTime) {
239            // Calculate distance to actual cursor
240            var distToCursor = ((this.x - x) ** 2 + (this.y - y) ** 2) ** 0.5;
241            
242            // ========== AGGRESSION SYSTEM ==========
243            // Increase aggression the longer it's been since last successful coil
244            const timeSinceLastCoil = Date.now() - lastCoilTime;
245            if (distToCursor > COIL_RADIUS * 2) {
246                // Not coiled - increase aggression
247                aggressionLevel = Math.min(1.0, aggressionLevel + (deltaTime * AGGRESSION_INCREASE_RATE));
248            } else if (distToCursor < COIL_RADIUS && cursorIdleTime > IDLE_THRESHOLD) {
249                // Successfully coiled! Reset aggression
250                if (lastCoilTime === 0 || timeSinceLastCoil > 1000) {
251                    lastCoilTime = Date.now();
252                    aggressionLevel = 0;
253                    // Removed console.log to reduce spam
254                }
255            }
256            
257            // Update hunt timer for sneaky behavior patterns
258            huntTimer += deltaTime;
259            
260            // Determine personality based on leg count
261            // Fewer legs = brute (0-4 legs), more legs = sneaky (8-12 legs)
262            const legCountForPersonality = legCount || 6;
263            isBrute = legCountForPersonality <= 4; // Fewer legs = brute force
264            const sneakyLevel = legCountForPersonality >= 8 ? 1.0 : Math.max(0, (legCountForPersonality - 4) / 4);
265            
266            // Update cursor tracking with delta time
267            const mouseMoved = Math.abs(x - lastMouseX) > 1 || Math.abs(y - lastMouseY) > 1;
268            const cursorMovementDist = Math.hypot(x - lastMouseX, y - lastMouseY);
269            
270            if (mouseMoved && cursorMovementDist > IDLE_RADIUS) {
271                // Cursor moved significantly - reset idle tracking
272                cursorIdleTime = 0;
273                pounceTimer = 0;
274                hasPounced = false;
275                cursorHistory.push({x: x, y: y, time: Date.now()});
276                if (cursorHistory.length > HISTORY_SIZE) {
277                    cursorHistory.shift();
278                }
279            } else {
280                // Check if cursor is within idle radius
281                if (cursorHistory.length > 0) {
282                    const lastPos = cursorHistory[cursorHistory.length - 1];
283                    const distFromLastPos = Math.hypot(x - lastPos.x, y - lastPos.y);
284                    if (distFromLastPos < IDLE_RADIUS) {
285                        cursorIdleTime += deltaTime;
286                        pounceTimer += deltaTime;
287                    } else {
288                        cursorIdleTime = 0;
289                        pounceTimer = 0;
290                        hasPounced = false;
291                    }
292                } else {
293                    cursorIdleTime += deltaTime;
294                    pounceTimer += deltaTime;
295                }
296            }
297            
298            lastMouseX = x;
299            lastMouseY = y;
300            
301            // ========== PERSONALITY-BASED STALKING BEHAVIOR ==========
302            // Brute: Less hiding, more direct stalking (but still lags)
303            // Sneaky: More hiding, more indirect approaches, uses aggression for restlessness
304            let baseStalkDistance = STALK_DISTANCE;
305            
306            if (isBrute) {
307                // Brute: Less lag, more direct, aggression makes it charge more
308                baseStalkDistance = STALK_DISTANCE * (1 - aggressionLevel * 0.5); // Gets closer when agitated
309            } else {
310                // Sneaky: More lag, more indirect, aggression makes it more restless (circles around)
311                baseStalkDistance = STALK_DISTANCE * (1 + sneakyLevel * 0.3); // Stays further when sneaky
312                // High aggression + sneaky = restless circling behavior
313                if (aggressionLevel > 0.5) {
314                    const restlessOffset = Math.sin(aggressionLevel * Math.PI * 4) * 40 * aggressionLevel;
315                    baseStalkDistance += restlessOffset;
316                }
317            }
318            
319            // Calculate ideal stalking position (behind cursor)
320            const angleToCursor = Math.atan2(y - stalkPosition.y, x - stalkPosition.x);
321            
322            // Brute: Direct approach, Sneaky: Offset angle for indirect approach
323            let stalkOffsetAngle = 0;
324            if (!isBrute && sneakyLevel > 0.3) {
325                // Sneaky: Approach from slightly offset angle (more cunning)
326                stalkOffsetAngle = Math.sin(aggressionLevel * Math.PI * 2 + huntTimer / 200) * 0.4 * sneakyLevel;
327            }
328            
329            const idealStalkX = x - Math.cos(angleToCursor + stalkOffsetAngle) * baseStalkDistance;
330            const idealStalkY = y - Math.sin(angleToCursor + stalkOffsetAngle) * baseStalkDistance;
331            
332            // Smoothly move stalk position towards ideal position (with lag)
333            // Brute moves faster, sneaky moves slower (more patient)
334            const currentLagSpeed = isBrute ? LAG_SPEED * 1.5 : LAG_SPEED * (0.7 + sneakyLevel * 0.3);
335            const stalkDist = Math.hypot(idealStalkX - stalkPosition.x, idealStalkY - stalkPosition.y);
336            if (stalkDist > 5) {
337                // Interpolate towards ideal position (lag effect)
338                stalkPosition.x += (idealStalkX - stalkPosition.x) * currentLagSpeed;
339                stalkPosition.y += (idealStalkY - stalkPosition.y) * currentLagSpeed;
340            }
341            
342            // ========== HIDING BEHAVIOR: Try to hide behind page elements ==========
343            let targetX = stalkPosition.x;
344            let targetY = stalkPosition.y;
345            
346            // Sneaky Skeletile tries harder to hide, brute doesn't care
347            const hideChance = isBrute ? 0.2 : (0.6 + sneakyLevel * 0.3); // Sneaky more likely to hide
348            
349            if (Math.random() < hideChance) {
350                // Check for elements between stalk position and cursor to "hide" behind
351                const elements = document.elementsFromPoint(x, y);
352                if (elements.length > 1) { // More than just body/html
353                    // Find elements that could serve as "cover"
354                    const coverElements = elements.filter(el => {
355                        const rect = el.getBoundingClientRect();
356                        const elCenterX = rect.left + rect.width / 2;
357                        const elCenterY = rect.top + rect.height / 2;
358                        const distToEl = Math.hypot(elCenterX - x, elCenterY - y);
359                        // Must be between stalk position and cursor, and be substantial
360                        return distToEl < STALK_DISTANCE * 1.5 && 
361                               rect.width > 20 && rect.height > 20 &&
362                               el.tagName !== 'CANVAS' && el.id !== 'skeletile-canvas';
363                    });
364                    
365                    if (coverElements.length > 0) {
366                        // Try to position behind the closest cover element
367                        const cover = coverElements[0];
368                        const rect = cover.getBoundingClientRect();
369                        const elCenterX = rect.left + rect.width / 2;
370                        const elCenterY = rect.top + rect.height / 2;
371                        
372                        // Position slightly behind the element (away from cursor)
373                        const angleFromCursor = Math.atan2(elCenterY - y, elCenterX - x);
374                        const hideOffset = 30 * (1 + sneakyLevel); // Sneaky hides further
375                        targetX = elCenterX - Math.cos(angleFromCursor) * hideOffset;
376                        targetY = elCenterY - Math.sin(angleFromCursor) * hideOffset;
377                        
378                        // Blend with stalk position for smooth hiding
379                        const blendFactor = isBrute ? 0.8 : 0.5; // Sneaky hides more
380                        targetX = targetX * blendFactor + stalkPosition.x * (1 - blendFactor);
381                        targetY = targetY * blendFactor + stalkPosition.y * (1 - blendFactor);
382                    }
383                }
384            }
385            
386            // ========== POUNCE BEHAVIOR: When cursor idle long enough ==========
387            // Adjust pounce trigger based on aggression and personality
388            const adjustedPounceTrigger = isBrute 
389                ? POUNCE_TRIGGER * (1 - aggressionLevel * 0.5) // Brute gets more impatient when agitated
390                : POUNCE_TRIGGER * (1 + sneakyLevel * 0.3); // Sneaky is more patient
391            
392            let isPouncing = false;
393            let isCoiling = false;
394            const isAnaconda = legCountForPersonality >= 8; // 8+ legs = anaconda wrapping behavior
395            
396            // Check if close enough to coil (even without pounce) - but only when idle
397            const shouldCoil = distToCursor < COIL_RADIUS && cursorIdleTime > IDLE_THRESHOLD && pounceState === 'none';
398            
399            // Update pounce state machine
400            if (pounceTimer > adjustedPounceTrigger && !hasPounced && !shouldCoil) {
401                // Start pounce - begin approaching
402                if (pounceState === 'none') {
403                    pounceState = 'approaching';
404                    hasPounced = true;
405                    isPouncing = true;
406                }
407            }
408            
409            // Transition from approaching to attacking/ouroboros when close
410            if (pounceState === 'approaching' && distToCursor < STALK_DISTANCE * 0.5) {
411                if (isAnaconda) {
412                    pounceState = 'wrapping'; // Ouroboros mode: pace in tight circle
413                    wrapRadius = COIL_RADIUS * 1.2; // Start at slightly larger radius
414                } else {
415                    pounceState = 'attacking'; // Brute mode: direct attack
416                }
417            }
418            
419            // Transition from wrapping/attacking to coiling when very close and stable
420            // Ouroboros: stays in circle indefinitely (perpetual ouroboros)
421            // Brute: when right on top of cursor
422            const isCloseEnough = isAnaconda 
423                ? (distToCursor < COIL_RADIUS * 2 && cursorIdleTime > 500) // Ouroboros just needs to be close and stable
424                : (distToCursor < COIL_RADIUS * 0.5); // Brute needs to be very close
425            
426            if ((pounceState === 'wrapping' || pounceState === 'attacking') && 
427                isCloseEnough && cursorIdleTime > 300) {
428                // Successfully caught! Switch to coiling mode (or stay in ouroboros)
429                if (isAnaconda && distToCursor < COIL_RADIUS * 1.5) {
430                    // Keep ouroboros going - it's perpetual!
431                    // Just ensure we stay in the circle
432                } else if (!isAnaconda) {
433                    // Brute mode: switch to normal coiling
434                    pounceState = 'none';
435                    hasPounced = false;
436                    isCoiling = true;
437                    wrapRadius = COIL_RADIUS;
438                }
439            }
440            
441            // If cursor moves away during pounce, abort and reset
442            if (pounceState !== 'none' && distToCursor > STALK_DISTANCE * 1.5) {
443                pounceState = 'none';
444                hasPounced = false;
445                isPouncing = false;
446                wrapRadius = COIL_RADIUS;
447            }
448            
449            // Normal idle coiling (when not pouncing)
450            if (shouldCoil && pounceState === 'none') {
451                isCoiling = true;
452            }
453            
454            // Calculate distance and angle to target (which may be stalk position or cursor)
455            var dist = ((this.x - targetX) ** 2 + (this.y - targetY) ** 2) ** 0.5;
456            let targetAngle = Math.atan2(targetY - this.y, targetX - this.x);
457            
458            // ========== POUNCE BEHAVIORS ==========
459            if (pounceState === 'wrapping') {
460                // OUROBOROS MODE (8+ legs): Pace around cursor in tight perpetual circle
461                isPouncing = true;
462                
463                // Maintain a tight circle radius (ouroboros - eating its own tail)
464                // Slightly tighter when more aggressive, but stays circular
465                const baseOuroborosRadius = COIL_RADIUS * 1.1;
466                const radiusVariation = Math.sin(aggressionLevel * Math.PI * 2 + huntTimer / 500) * (COIL_RADIUS * 0.2);
467                wrapRadius = baseOuroborosRadius + radiusVariation; // Oscillates slightly but stays circular
468                
469                // Update coil angle for smooth circular motion (perpetual ouroboros)
470                const rotationSpeed = 0.004 + aggressionLevel * 0.002; // Faster when more aggressive
471                coilAngle += deltaTime * rotationSpeed;
472                if (coilAngle > Math.PI * 2) coilAngle -= Math.PI * 2;
473                
474                // Calculate position on circle around cursor (tight ouroboros circle)
475                const circleX = x + Math.cos(coilAngle) * wrapRadius;
476                const circleY = y + Math.sin(coilAngle) * wrapRadius;
477                
478                // Target the point on the circle (perpetual motion)
479                targetX = circleX;
480                targetY = circleY;
481                targetAngle = Math.atan2(circleY - this.y, circleX - this.x);
482                
483                // Smooth movement around the circle
484                dist = ((this.x - circleX) ** 2 + (this.y - circleY) ** 2) ** 0.5;
485                
486            } else if (pounceState === 'attacking') {
487                // BRUTE MODE (≤4 legs): Direct attack - get right on top of cursor to bite/scratch!
488                isPouncing = true;
489                
490                // Target slightly ahead of cursor position to account for movement
491                // Try to get the "head" of Skeletile right on the cursor
492                const attackOffset = Math.min(5, distToCursor * 0.1); // Small offset to ensure overlap
493                const angleToCursor = Math.atan2(y - this.y, x - this.x);
494                targetX = x - Math.cos(angleToCursor) * attackOffset;
495                targetY = y - Math.sin(angleToCursor) * attackOffset;
496                targetAngle = Math.atan2(targetY - this.y, targetX - this.x);
497                
498                // Boost acceleration massively to get as close as possible
499                dist = distToCursor * 4.0; // Very aggressive targeting - try to overlap cursor
500                
501            } else if (pounceState === 'approaching') {
502                // Still approaching during pounce - move towards cursor
503                isPouncing = true;
504                
505                // Brute: Direct charge
506                // Sneaky (5-7 legs): Slight curve for "evil plan" execution
507                if (isBrute) {
508                    targetX = x;
509                    targetY = y;
510                } else {
511                    // Sneaky: Add a slight curve (pacing back and forth is okay here)
512                    const curveAmount = (1 - aggressionLevel) * 30;
513                    const angleToCursor = Math.atan2(y - this.y, x - this.x);
514                    const curveAngle = angleToCursor + Math.sin(huntTimer / 100) * (curveAmount / STALK_DISTANCE);
515                    targetX = x - Math.cos(curveAngle) * curveAmount;
516                    targetY = y - Math.sin(curveAngle) * curveAmount;
517                }
518                targetAngle = Math.atan2(targetY - this.y, targetX - this.x);
519                dist = distToCursor * (1.5 + aggressionLevel * 0.5);
520                
521            } else if (isCoiling) {
522                // Normal idle coiling: curl around cursor in a proper circle
523                // Update coil angle for smooth circular motion
524                coilAngle += deltaTime * 0.003; // Slow rotation around cursor
525                if (coilAngle > Math.PI * 2) coilAngle -= Math.PI * 2;
526                
527                // Calculate position on circle around cursor
528                const coilRadius = COIL_RADIUS;
529                const circleX = x + Math.cos(coilAngle) * coilRadius;
530                const circleY = y + Math.sin(coilAngle) * coilRadius;
531                
532                // Target the point on the circle
533                targetX = circleX;
534                targetY = circleY;
535                targetAngle = Math.atan2(circleY - this.y, circleX - this.x);
536                
537                // Move slower when coiling (tight controlled circle)
538                dist = ((this.x - circleX) ** 2 + (this.y - circleY) ** 2) ** 0.5;
539                dist = dist * 0.3; // Very controlled movement
540            }
541            
542            // Update forward movement
543            var accel = this.fAccel;
544            if (this.systems.length > 0) {
545                var sum = 0;
546                for (var i = 0; i < this.systems.length; i++) {
547                    sum += this.systems[i].step == 0;
548                }
549                accel *= sum / this.systems.length;
550            }
551            
552            // Adjust acceleration based on behavior and personality
553            if (isCoiling) {
554                accel *= 0.3; // Slow down when coiling (tight controlled movement)
555            } else if (pounceState === 'wrapping') {
556                // Ouroboros pacing - smooth, controlled circular motion
557                accel *= 1.8 + aggressionLevel * 0.3; // Smooth but responsive
558            } else if (pounceState === 'attacking') {
559                // Brute attack - maximum aggression!
560                accel *= 4.0 + aggressionLevel * 1.0; // Very fast to get on top of cursor
561            } else if (pounceState === 'approaching') {
562                // Approaching during pounce - fast but varies by personality
563                const basePounceSpeed = isBrute ? 2.5 : 2.0;
564                const aggressionBoost = aggressionLevel * (isBrute ? 1.0 : 0.5);
565                accel *= basePounceSpeed + aggressionBoost;
566            } else if (isPouncing) {
567                // Fallback for any other pouncing state
568                accel *= 2.0;
569            } else {
570                // Normal stalking - varies by personality and aggression
571                if (isBrute) {
572                    // Brute: More direct, aggression makes it less patient (faster)
573                    accel *= 0.8 + aggressionLevel * 0.3;
574                } else {
575                    // Sneaky: More patient, but restlessness (aggression) makes it move more
576                    accel *= 0.7 + aggressionLevel * 0.2 * sneakyLevel;
577                }
578            }
579            
580            this.fSpeed += accel * (dist > this.fThresh);
581            this.fSpeed *= 1 - this.fRes;
582            this.speed = Math.max(0, this.fSpeed - this.fFric);
583            
584            // Update rotation - turn towards target angle
585            var dif = this.absAngle - targetAngle;
586            dif -= 2 * Math.PI * Math.floor(dif / (2 * Math.PI) + 1 / 2);
587            
588            // Rotation varies by behavior, personality, and aggression
589            let rotationAccel = this.rAccel;
590            
591            if (pounceState === 'wrapping') {
592                // Ouroboros: Smooth rotation for tight circular pacing
593                rotationAccel = this.rAccel * 1.5 * (1 + aggressionLevel * 0.3);
594            } else if (pounceState === 'attacking') {
595                // Brute attack: Very fast, aggressive rotation to stay on cursor
596                rotationAccel = this.rAccel * 3.0 * (1 + aggressionLevel * 0.5);
597            } else if (pounceState === 'approaching') {
598                // Approaching: Fast rotation during pounce approach
599                rotationAccel = this.rAccel * (isBrute ? 2.5 : 2.0) * (1 + aggressionLevel * 0.3);
600            } else if (isPouncing) {
601                // Fallback for pouncing
602                rotationAccel = this.rAccel * 2.0 * (1 + aggressionLevel * 0.3);
603            } else if (isCoiling) {
604                rotationAccel = this.rAccel * 0.6;
605            } else {
606                // Stalking rotation
607                if (isBrute) {
608                    // Brute: More direct rotation, gets more aggressive when agitated
609                    rotationAccel = this.rAccel * (1 + aggressionLevel * 0.4);
610                } else {
611                    // Sneaky: More nuanced, restless circling when agitated
612                    rotationAccel = this.rAccel * (0.9 + aggressionLevel * 0.3 * sneakyLevel);
613                }
614            }
615            
616            if (Math.abs(dif) > this.rThresh && dist > this.fThresh) {
617                this.rSpeed -= rotationAccel * (2 * (dif > 0) - 1);
618            }
619            this.rSpeed *= 1 - this.rRes;
620            if (Math.abs(this.rSpeed) > this.rFric) {
621                this.rSpeed -= this.rFric * (2 * (this.rSpeed > 0) - 1);
622            } else {
623                this.rSpeed = 0;
624            }
625            
626            // Update position
627            this.absAngle += this.rSpeed;
628            this.absAngle -= 2 * Math.PI * Math.floor(this.absAngle / (2 * Math.PI) + 1 / 2);
629            this.x += this.speed * Math.cos(this.absAngle);
630            this.y += this.speed * Math.sin(this.absAngle);
631            this.absAngle += Math.PI;
632            for (var i = 0; i < this.children.length; i++) {
633                this.children[i].follow(true, true);
634            }
635            for (var i = 0; i < this.systems.length; i++) {
636                this.systems[i].update(x, y);
637            }
638            this.absAngle -= Math.PI;
639            this.draw(true);
640        }
641        
642        draw(iter) {
643            var r = 4;
644            ctx.beginPath();
645            ctx.arc(this.x, this.y, r, Math.PI / 4 + this.absAngle, 7 * Math.PI / 4 + this.absAngle);
646            ctx.moveTo(
647                this.x + r * Math.cos(7 * Math.PI / 4 + this.absAngle),
648                this.y + r * Math.sin(7 * Math.PI / 4 + this.absAngle)
649            );
650            ctx.lineTo(
651                this.x + r * Math.cos(this.absAngle) * 2 ** 0.5,
652                this.y + r * Math.sin(this.absAngle) * 2 ** 0.5
653            );
654            ctx.lineTo(
655                this.x + r * Math.cos(Math.PI / 4 + this.absAngle),
656                this.y + r * Math.sin(Math.PI / 4 + this.absAngle)
657            );
658            ctx.stroke();
659            if (iter) {
660                for (var i = 0; i < this.children.length; i++) {
661                    this.children[i].draw(true);
662                }
663            }
664        }
665    }
666    
667    // ========== SETUP FUNCTION ==========
668    
669    function setupLizard(size, legs, tail, speedMultiplier) {
670        var s = size;
671        speedMultiplier = speedMultiplier || 1.0; // Default to 1.0 if not provided
672        
673        // Inverse friction multiplier - slower creatures have slightly MORE friction
674        const frictionMultiplier = 1.5 - (speedMultiplier * 0.25); // Gentle friction scaling
675        
676        critter = new Creature(
677            window.innerWidth / 2,
678            window.innerHeight / 2,
679            0,
680            s * 10 * speedMultiplier,  // Forward acceleration scales with leg count
681            s * 2 * frictionMultiplier, // Friction gently inversely scales
682            0.5,                        // Keep resistance constant
683            16,
684            0.5 * speedMultiplier,      // Rotation speed also scales
685            0.085,
686            0.5,                        // Keep rotation resistance constant
687            0.3
688        );
689        var spinal = critter;
690        
691        // Neck
692        for (var i = 0; i < 6; i++) {
693            spinal = new Segment(spinal, s * 4, 0, 3.1415 * 2 / 3, 1.1);
694            for (var ii = -1; ii <= 1; ii += 2) {
695                var node = new Segment(spinal, s * 3, ii, 0.1, 2);
696                for (var iii = 0; iii < 3; iii++) {
697                    node = new Segment(node, s * 0.1, -ii * 0.1, 0.1, 2);
698                }
699            }
700        }
701        
702        // Torso and legs
703        for (var i = 0; i < legs; i++) {
704            if (i > 0) {
705                // Vertebrae and ribs
706                for (var ii = 0; ii < 6; ii++) {
707                    spinal = new Segment(spinal, s * 4, 0, 1.571, 1.5);
708                    for (var iii = -1; iii <= 1; iii += 2) {
709                        var node = new Segment(spinal, s * 3, iii * 1.571, 0.1, 1.5);
710                        for (var iv = 0; iv < 3; iv++) {
711                            node = new Segment(node, s * 3, -iii * 0.3, 0.1, 2);
712                        }
713                    }
714                }
715            }
716            // Legs and shoulders
717            for (var ii = -1; ii <= 1; ii += 2) {
718                var node = new Segment(spinal, s * 12, ii * 0.785, 0, 8);
719                node = new Segment(node, s * 16, -ii * 0.785, 6.28, 1);
720                node = new Segment(node, s * 16, ii * 1.571, 3.1415, 2);
721                for (var iii = 0; iii < 4; iii++) {
722                    new Segment(node, s * 4, (iii / 3 - 0.5) * 1.571, 0.1, 4);
723                }
724                new LegSystem(node, 3, s * 12 * speedMultiplier, critter, 4); // Leg speed also scales
725            }
726        }
727        
728        // Tail
729        for (var i = 0; i < tail; i++) {
730            spinal = new Segment(spinal, s * 4, 0, 3.1415 * 2 / 3, 1.1);
731            for (var ii = -1; ii <= 1; ii += 2) {
732                var node = new Segment(spinal, s * 3, ii, 0.1, 2);
733                for (var iii = 0; iii < 3; iii++) {
734                    node = new Segment(node, s * 3 * (tail - i) / tail, -ii * 0.1, 0.1, 2);
735                }
736            }
737        }
738    }
739    
740    // ========== THEME SUPPORT ==========
741    
742    function getSkeletileColors() {
743        const isDarkMode = document.body.classList.contains('dark-mode');
744        
745        return {
746            // Main skeleton color (no expensive shadow/glow for better performance!)
747            // Dark mode: off-white/bone grey for a more realistic skeleton look
748            strokeColor: isDarkMode ? '#e8e8d8' : '#000000'
749        };
750    }
751    
752    // ========== ANIMATION LOOP ==========
753    
754    function animate() {
755        if (!isActive || !canvas || !ctx || !critter) return;
756        
757        // Calculate delta time for frame-rate independent behavior
758        const currentTime = performance.now();
759        const deltaTime = Math.min(currentTime - lastFrameTime, 50); // Cap at 50ms for stability
760        lastFrameTime = currentTime;
761        
762        // Clear canvas
763        ctx.clearRect(0, 0, canvas.width, canvas.height);
764        
765        // Get theme colors (updates with dark mode toggle)
766        const colors = getSkeletileColors();
767        
768        // Apply stroke color (no shadow for optimal performance)
769        ctx.strokeStyle = colors.strokeColor;
770        
771        // Disable shadow properties for better performance
772        ctx.shadowColor = 'transparent';
773        ctx.shadowBlur = 0;
774        
775        // Update and draw with delta time for hunting behavior (async handled internally)
776        critter.follow(Input.mouse.x, Input.mouse.y, deltaTime).catch(err => {
777            console.error('🦴 Error in follow():', err);
778        });
779        
780        animationFrame = requestAnimationFrame(animate);
781    }
782    
783    // ========== RESIZE HANDLER ==========
784    
785    function handleResize() {
786        if (!canvas) return;
787        canvas.width = window.innerWidth;
788        canvas.height = window.innerHeight;
789    }
790    
791    // ========== PUBLIC API ==========
792    
793    window.Skeletile = {
794        get isActive() {
795            return isActive;
796        },
797        
798        init: function() {
799            if (isActive) return;
800            console.log('Skeletile: Initializing');
801            
802            isActive = true;
803            segmentCount = 0;
804            
805            // Create canvas
806            canvas = document.createElement('canvas');
807            canvas.width = window.innerWidth;
808            canvas.height = window.innerHeight;
809            canvas.style.position = 'fixed';
810            canvas.style.top = '0';
811            canvas.style.left = '0';
812            canvas.style.pointerEvents = 'none';
813            canvas.style.zIndex = '9999';
814            canvas.id = 'skeletile-canvas';
815            document.body.appendChild(canvas);
816            
817            ctx = canvas.getContext('2d');
818            // Theme-aware colors - will update in animate loop
819            ctx.lineWidth = 2;
820            
821            // Set up mouse tracking
822            mouseMoveHandler = function(event) {
823                Input.mouse.x = event.clientX;
824                Input.mouse.y = event.clientY;
825            };
826            document.addEventListener('mousemove', mouseMoveHandler);
827            
828            // Set up resize handler
829            resizeHandler = handleResize;
830            window.addEventListener('resize', resizeHandler);
831            
832            // Create creature (1/5 the original size with variation)
833            legCount = Math.floor(1 + Math.random() * 12); // Store for glow scaling
834            const baseSize = 8 / Math.sqrt(legCount);
835            
836            // Add random size variation (0.8x to 1.2x)
837            const sizeVariation = 0.8 + Math.random() * 0.4;
838            const scaledSize = baseSize * 0.2 * sizeVariation; // 1/5 size with variation
839            
840            // Calculate speed multiplier based on leg count
841            // More legs = MUCH faster, fewer legs = slower (but still functional!)
842            // Linear interpolation: 1 leg = 0.5x speed, 12 legs = 3.0x speed
843            const speedMultiplier = 0.5 + (legCount - 1) * (2.5 / 11);
844            
845            setupLizard(
846                scaledSize,
847                legCount,
848                Math.floor(4 + Math.random() * legCount * 8),
849                speedMultiplier
850            );
851            
852            // Initialize frame time and stalk position for delta tracking
853            lastFrameTime = performance.now();
854            stalkPosition.x = window.innerWidth / 2;
855            stalkPosition.y = window.innerHeight / 2;
856            cursorIdleTime = 0;
857            pounceTimer = 0;
858            hasPounced = false;
859            cursorHistory = [];
860            lastCoilTime = 0;
861            aggressionLevel = 0;
862            coilAngle = 0;
863            huntTimer = 0;
864            creatureSize = sizeVariation;
865            pounceState = 'none';
866            wrapRadius = COIL_RADIUS;
867            
868            // Start animation
869            animate();
870            
871            const personality = legCount <= 4 ? 'Brute' : (legCount >= 8 ? 'Sneaky' : 'Balanced');
872            console.log('Skeletile: Initialized with', legCount, 'legs, size:', sizeVariation.toFixed(2) + 'x, speed:', speedMultiplier.toFixed(2) + 'x - ' + personality + ' Hunter/Stalker mode activated! 🦴');
873        },
874        
875        cleanup: function() {
876            if (!isActive) return;
877            console.log('Skeletile: Cleaning up');
878            
879            isActive = false;
880            
881            // Cancel animation
882            if (animationFrame) {
883                cancelAnimationFrame(animationFrame);
884                animationFrame = null;
885            }
886            
887            // Remove event listeners
888            if (mouseMoveHandler) {
889                document.removeEventListener('mousemove', mouseMoveHandler);
890                mouseMoveHandler = null;
891            }
892            if (resizeHandler) {
893                window.removeEventListener('resize', resizeHandler);
894                resizeHandler = null;
895            }
896            
897            // Remove canvas
898            if (canvas && canvas.parentNode) {
899                canvas.parentNode.removeChild(canvas);
900            }
901            canvas = null;
902            ctx = null;
903            
904            // Clear creature
905            critter = null;
906            segmentCount = 0;
907        },
908        
909        regenerate: function() {
910            console.log('Skeletile: Regenerating...');
911            this.cleanup();
912            // Small delay to ensure cleanup is complete
913            setTimeout(() => {
914                this.init();
915            }, 100);
916        }
917    };
918    
919    // Also expose as lowercase for compatibility
920    window.skeletile = window.Skeletile;
921})();
922

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.