// Utility function for distance calculation Math.distance = function(x1, y1, x2, y2) { return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); }; // RagdollCharacter class class RagdollCharacter { constructor(x, y, canvas) { this.canvas = canvas; this.x = x; this.y = y; this.vx = 0; this.vy = 0; this.bodyWidth = 80; this.bodyHeight = 100; // Load images this.headImages = { normal: new Image(), dragging: new Image(), collision: new Image() }; this.bodyImage = new Image(); this.imagesLoaded = 0; this.totalImages = 4; // Load head images this.headImages.normal.onload = () => { this.imagesLoaded++; }; this.headImages.normal.src = 'head01.png'; this.headImages.dragging.onload = () => { this.imagesLoaded++; }; this.headImages.dragging.src = 'head02.png'; this.headImages.collision.onload = () => { this.imagesLoaded++; }; this.headImages.collision.src = 'head03.png'; // Load body image this.bodyImage.onload = () => { this.imagesLoaded++; }; this.bodyImage.src = 'body01.png'; // State tracking this.currentHeadState = 'normal'; this.collisionTimer = 0; this.lastCollisionType = null; // Head properties this.headRadius = 36; this.headOffsetX = 0; this.headOffsetY = -75; this.headAngle = 0; this.headAngularVel = 0; // Ragdoll physics - separate head and body positions this.headX = x; this.headY = y + this.headOffsetY; this.bodyX = x; this.bodyY = y; // Body sway properties this.bodyAngle = 0; this.bodyAngularVel = 0; // Physics this.gravity = 0.5; this.friction = 0.85; this.bounce = 0.3; this.grounded = false; // Interaction this.isDragging = false; this.isDraggingHead = false; // Track if dragging head specifically this.dragOffsetX = 0; this.dragOffsetY = 0; // Walking animation this.walkSpeed = 1; this.walkDirection = 1; this.walkTimer = 0; this.idleTime = 0; this.isWalking = false; this.walkCycle = 0; // Animation this.bobOffset = 0; this.animTime = 0; // Squash and stretch effects this.bodySquash = 1.0; // 1.0 = normal, < 1.0 = squashed, > 1.0 = stretched this.headSquash = 1.0; this.squashRecovery = 0.15; // How fast it returns to normal // Head sway when dragging body this.headDragSway = 0; this.headDragSwayVel = 0; this.lastDragX = 0; this.lastDragY = 0; // Head bounce offset on landing this.headBounceOffset = 0; this.headBounceVel = 0; } update() { this.animTime += 0.1; // Update collision timer if (this.collisionTimer > 0) { this.collisionTimer--; if (this.collisionTimer <= 0) { this.lastCollisionType = null; } } // Update head state based on current situation this.updateHeadState(); if (!this.isDragging) { // Idle walking behavior this.idleTime++; if (this.idleTime > 120 && !this.isWalking) { this.isWalking = true; this.walkTimer = Math.random() * 120 + 60; // Walk for 1-3 seconds this.walkDirection = Math.random() > 0.5 ? 1 : -1; this.idleTime = 0; } if (this.isWalking) { this.vx += this.walkDirection * this.walkSpeed * 0.1; this.walkTimer--; this.walkCycle += 0.2; if (this.walkTimer <= 0) { this.isWalking = false; this.idleTime = 0; } } // Apply gravity this.vy += this.gravity; // Update position normally when not dragging this.x += this.vx; this.y += this.vy; // Keep head and body in sync when not in ragdoll mode this.headX = this.x; this.headY = this.y + this.headOffsetY; this.bodyX = this.x; this.bodyY = this.y; } else if (this.isDraggingHead) { // Ragdoll physics when dragging by head // Head position is controlled by mouse/touch // Body swings from the head with physics const connectionLength = 45; // Distance from head to body connection point const springStrength = 0.015; // Much gentler spring const damping = 0.88; // More damping for smoothness // Calculate where the body should naturally hang from the head const neckX = this.headX; const neckY = this.headY + 25; const targetBodyX = neckX; const targetBodyY = neckY + connectionLength; // Apply gentle spring force to make body follow head const dx = targetBodyX - this.bodyX; const dy = targetBodyY - this.bodyY; this.vx += dx * springStrength; this.vy += dy * springStrength; // Add subtle gravity to body this.vy += this.gravity * 0.3; // Apply strong damping for smoothness this.vx *= damping; this.vy *= damping; // Update body position this.bodyY += this.vy; this.bodyX += this.vx; // Update main position to body position for collision detection this.x = this.bodyX; this.y = this.bodyY; // Calculate swing angle with constraints const bodyDx = this.bodyX - neckX; const bodyDy = this.bodyY - neckY; let swingAngle = Math.atan2(-bodyDx, bodyDy); // Limit swing range to ±30 degrees (about 0.52 radians) const maxSwing = 0.4; swingAngle = Math.max(-maxSwing, Math.min(maxSwing, swingAngle)); // Smooth angle interpolation to reduce jitter const angleSpeed = 0.2; this.bodyAngle += (swingAngle - this.bodyAngle) * angleSpeed; } else { // Normal dragging (by body) this.vx = 0; this.vy = 0; // Keep head and body in sync this.headX = this.x; this.headY = this.y + this.headOffsetY; this.bodyX = this.x; this.bodyY = this.y; } // Ground collision const groundY = this.canvas.height - this.bodyHeight / 2 - 10; if (this.y > groundY) { this.y = groundY; this.bodyY = groundY; if (!this.isDraggingHead) { // Add squash effect on landing if (Math.abs(this.vy) > 3) { // Only squash on significant impact this.bodySquash = Math.max(0.7, 1.0 - Math.abs(this.vy) * 0.05); this.headSquash = Math.max(0.8, 1.0 - Math.abs(this.vy) * 0.03); // Add head bounce offset - head dips down then bounces back this.headBounceVel = Math.abs(this.vy) * 0.15; // Downward velocity } this.vy *= -this.bounce; this.grounded = true; // Reduce horizontal jitter on landing if (Math.abs(this.vy) < 1) { this.vy = 0; this.vx *= 0.7; // Dampen horizontal movement more when settling } } else { // In ragdoll mode, apply bounce to body velocity this.vy *= -this.bounce * 0.02; // Stronger damping on vertical bounce this.vx *= 0.5; // Reduce horizontal movement after bounce this.bodyAngularVel *= 1; // Dampen body rotation after bounce this.grounded = true; } } else { this.grounded = false; } // Wall collisions let wallCollision = false; if (this.x < this.bodyWidth / 2) { this.x = this.bodyWidth / 2; this.bodyX = this.bodyWidth / 2; this.vx *= -this.bounce; this.walkDirection *= -1; wallCollision = true; this.lastCollisionType = 'wall'; // Add squash effect on wall collision this.bodySquash = Math.max(0.85, 1.0 - Math.abs(this.vx) * 0.03); this.headSquash = Math.max(0.9, 1.0 - Math.abs(this.vx) * 0.02); } if (this.x > this.canvas.width - this.bodyWidth / 2) { this.x = this.canvas.width - this.bodyWidth / 2; this.bodyX = this.canvas.width - this.bodyWidth / 2; this.vx *= -this.bounce; this.walkDirection *= -1; wallCollision = true; this.lastCollisionType = 'wall'; // Add squash effect on wall collision this.bodySquash = Math.max(0.85, 1.0 - Math.abs(this.vx) * 0.03); this.headSquash = Math.max(0.9, 1.0 - Math.abs(this.vx) * 0.02); } // Set collision timer when wall collision occurs if (wallCollision) { this.collisionTimer = 60; // Show collision face for 1 second (60 frames) } // Apply friction only when not in ragdoll mode if (!this.isDraggingHead) { this.vx *= this.friction; } // Head physics (enhanced for ragdoll mode) if (!this.isDraggingHead) { const headSpringForce = 0.08; const headDamping = 0.85; const gravityEffect = 0.03; this.headAngularVel += -this.headAngle * headSpringForce; this.headAngularVel += this.vx * 0.03; this.headAngularVel += gravityEffect * Math.sin(this.headAngle); // Add head sway when dragging by body if (this.isDragging && !this.isDraggingHead) { this.headAngularVel += this.headDragSway * 0.05; } this.headAngularVel *= headDamping; this.headAngle += this.headAngularVel; // Clamp head angle this.headAngle = Math.max(-0.8, Math.min(0.8, this.headAngle)); } // Body sway (modified for ragdoll) if (!this.isDraggingHead) { const bodySpringForce = 0.15; const bodyDamping = 0.95; if (this.isDragging) { this.bodyAngularVel += -this.bodyAngle * bodySpringForce; } else { this.bodyAngularVel += -this.bodyAngle * bodySpringForce * 2; } this.bodyAngularVel *= bodyDamping; this.bodyAngle += this.bodyAngularVel; this.bodyAngle = Math.max(-0.2, Math.min(0.02, this.bodyAngle)); } // Walking bob if (this.isWalking && this.grounded && !this.isDragging) { this.bobOffset = Math.sin(this.walkCycle) * 3; } else { this.bobOffset *= 0.9; } // Update squash and stretch recovery this.bodySquash += (1.0 - this.bodySquash) * this.squashRecovery; this.headSquash += (1.0 - this.headSquash) * this.squashRecovery; // Update head drag sway if (this.isDragging && !this.isDraggingHead) { // Calculate drag velocity for head sway const dragVelX = this.x - this.lastDragX; const dragVelY = this.y - this.lastDragY; // Apply sway based on horizontal drag movement this.headDragSwayVel += dragVelX * 0.003; this.headDragSwayVel *= 0.9; // Damping this.headDragSway += this.headDragSwayVel; this.headDragSway *= 0.95; // Return to center // Clamp sway this.headDragSway = Math.max(-0.3, Math.min(0.3, this.headDragSway)); } else { // Return head sway to neutral when not dragging this.headDragSway *= 0.9; this.headDragSwayVel *= 0.9; } // Store current position for next frame this.lastDragX = this.x; this.lastDragY = this.y; // Update head bounce offset if (Math.abs(this.headBounceOffset) > 0.1 || Math.abs(this.headBounceVel) > 0.1) { // Apply spring physics to head bounce const springForce = -this.headBounceOffset * 0.3; // Spring back to neutral const damping = 0.85; // Damping factor this.headBounceVel += springForce; this.headBounceVel *= damping; this.headBounceOffset += this.headBounceVel; } else { // Close to zero, snap to zero to prevent tiny oscillations this.headBounceOffset = 0; this.headBounceVel = 0; } } updateHeadState() { // Priority order: collision > dragging > normal if (this.collisionTimer > 0 && this.lastCollisionType === 'wall') { this.currentHeadState = 'collision'; } else if (this.isDragging) { this.currentHeadState = 'dragging'; } else { this.currentHeadState = 'normal'; } } getCurrentHeadImage() { return this.headImages[this.currentHeadState]; } draw(ctx) { // Only draw if images are loaded if (this.imagesLoaded < this.totalImages) { // Draw placeholder shapes while images load this.drawPlaceholders(ctx); return; } // Draw body - pivot from neck/top of body ctx.save(); if (this.isDraggingHead) { // When in ragdoll mode, pivot from the neck connection point const neckX = this.headX; const neckY = this.headY + 25; // Connection point between head and body ctx.translate(neckX, neckY); ctx.rotate(this.bodyAngle); // Draw body image offset so it hangs from the neck with squash effect const bodyScaleX = 1.0; const bodyScaleY = this.bodySquash; ctx.scale(bodyScaleX, bodyScaleY); ctx.drawImage(this.bodyImage, -this.bodyWidth / 2, 0, this.bodyWidth, this.bodyHeight); } else { // Normal mode - draw from center ctx.translate(this.bodyX, this.bodyY + this.bobOffset); ctx.rotate(this.bodyAngle); // Draw body image centered with squash effect const bodyScaleX = 1.0; const bodyScaleY = this.bodySquash; ctx.scale(bodyScaleX, bodyScaleY); ctx.drawImage(this.bodyImage, -this.bodyWidth / 2, -this.bodyHeight / 2, this.bodyWidth, this.bodyHeight); } ctx.restore(); // Draw connection line when in ragdoll mode (subtle) if (this.isDraggingHead) { ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(this.headX, this.headY + 20); ctx.lineTo(this.headX, this.headY + 25); // Short neck line ctx.stroke(); } // Draw head ctx.save(); const headPivotX = this.headX; const headPivotY = this.headY + 10 + this.headBounceOffset; // Add bounce offset const headDrawX = headPivotX + Math.sin(this.headAngle + this.headDragSway) * 10; const headDrawY = headPivotY + Math.cos(this.headAngle + this.headDragSway) * -10 - 8; ctx.translate(headDrawX, headDrawY); ctx.rotate(this.headAngle + this.headDragSway); // Draw head image centered with squash effect const headScaleX = 1.0; const headScaleY = this.headSquash; ctx.scale(headScaleX, headScaleY); const currentHeadImage = this.getCurrentHeadImage(); ctx.drawImage(currentHeadImage, -this.headRadius, -this.headRadius, this.headRadius * 2, this.headRadius * 2); ctx.restore(); } // Fallback drawing method while images load drawPlaceholders(ctx) { // Draw body - pivot from neck/top of body ctx.save(); if (this.isDraggingHead) { // When in ragdoll mode, pivot from the neck connection point const neckX = this.headX; const neckY = this.headY + 25; // Connection point between head and body ctx.translate(neckX, neckY); ctx.rotate(this.bodyAngle); // Draw body offset so it hangs from the neck ctx.fillStyle = '#FF6B6B'; ctx.strokeStyle = '#E55555'; ctx.lineWidth = 3; ctx.fillRect(-this.bodyWidth / 2, 0, this.bodyWidth, this.bodyHeight); ctx.strokeRect(-this.bodyWidth / 2, 0, this.bodyWidth, this.bodyHeight); } else { // Normal mode - draw from center ctx.translate(this.bodyX, this.bodyY + this.bobOffset); ctx.rotate(this.bodyAngle); ctx.fillStyle = '#FF6B6B'; ctx.strokeStyle = '#E55555'; ctx.lineWidth = 3; ctx.fillRect(-this.bodyWidth / 2, -this.bodyHeight / 2, this.bodyWidth, this.bodyHeight); ctx.strokeRect(-this.bodyWidth / 2, -this.bodyHeight / 2, this.bodyWidth, this.bodyHeight); } ctx.restore(); // Draw connection line when in ragdoll mode (subtle) if (this.isDraggingHead) { ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(this.headX, this.headY + 20); ctx.lineTo(this.headX, this.headY + 25); // Short neck line ctx.stroke(); } // Draw head const headPivotX = this.headX; const headPivotY = this.headY + 10; const headDrawX = headPivotX + Math.sin(this.headAngle) * 10; const headDrawY = headPivotY + Math.cos(this.headAngle) * -10 - 8; ctx.fillStyle = '#FFE66D'; ctx.strokeStyle = '#FFD93D'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(headDrawX, headDrawY, this.headRadius, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); // Eyes ctx.fillStyle = '#333'; ctx.beginPath(); ctx.arc(headDrawX - 9, headDrawY - 5, 4, 0, Math.PI * 2); ctx.arc(headDrawX + 9, headDrawY - 5, 4, 0, Math.PI * 2); ctx.fill(); // Mouth ctx.strokeStyle = '#333'; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(headDrawX, headDrawY + 8, 8, 0, Math.PI); ctx.stroke(); } isPointInHead(px, py) { const headDrawX = this.headX + Math.sin(this.headAngle) * 10; const headDrawY = this.headY + Math.cos(this.headAngle) * -10 + 2; return Math.distance(px, py, headDrawX, headDrawY) <= this.headRadius; } isPointInBody(px, py) { if (this.isDraggingHead) { // When in ragdoll mode, check against the rotated body position const neckX = this.headX; const neckY = this.headY + 25; // Transform point to body's local coordinates const dx = px - neckX; const dy = py - neckY; const rotatedX = dx * Math.cos(-this.bodyAngle) - dy * Math.sin(-this.bodyAngle); const rotatedY = dx * Math.sin(-this.bodyAngle) + dy * Math.cos(-this.bodyAngle); return Math.abs(rotatedX) <= this.bodyWidth / 2 && rotatedY >= 0 && rotatedY <= this.bodyHeight; } else { // Normal mode collision detection const dx = px - this.bodyX; const dy = py - (this.bodyY + this.bobOffset); const rotatedX = dx * Math.cos(-this.bodyAngle) - dy * Math.sin(-this.bodyAngle); const rotatedY = dx * Math.sin(-this.bodyAngle) + dy * Math.cos(-this.bodyAngle); return Math.abs(rotatedX) <= this.bodyWidth / 2 && Math.abs(rotatedY) <= this.bodyHeight / 2; } } isPointInside(px, py) { return this.isPointInHead(px, py) || this.isPointInBody(px, py); } startDrag(px, py) { this.isDragging = true; this.isWalking = false; this.idleTime = 0; // Check if dragging by head or body if (this.isPointInHead(px, py)) { this.isDraggingHead = true; this.dragOffsetX = px - this.headX; this.dragOffsetY = py - this.headY; } else { this.isDraggingHead = false; this.dragOffsetX = px - this.x; this.dragOffsetY = py - this.y; this.vx = 0; this.vy = 0; } } drag(px, py) { if (this.isDragging) { if (this.isDraggingHead) { // Update head position when dragging by head this.headX = px - this.dragOffsetX; this.headY = py - this.dragOffsetY; } else { // Normal dragging by body const newX = px - this.dragOffsetX; const newY = py - this.dragOffsetY; this.x = newX; this.y = newY; this.headX = newX; this.headY = newY + this.headOffsetY; this.bodyX = newX; this.bodyY = newY; this.vx = 0; this.vy = 0; } } } stopDrag() { this.isDragging = false; this.isDraggingHead = false; // Reset velocities and angle smoothly if (!this.isDraggingHead) { // Smoothly return body angle to neutral when not in ragdoll mode this.bodyAngle *= 0.4; } this.vx = 0; this.vy = 0; this.headAngularVel = 0; // Sync positions when stopping ragdoll mode this.x = this.bodyX; this.y = this.bodyY; this.headX = this.x; this.headY = this.y + this.headOffsetY; } }