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.