at main 22 kB view raw
1import type { KAPLAYCtx, Comp, GameObj } from "kaplay"; 2import { Vec2 } from "kaplay"; 3 4// Make sound system available to the player component 5declare global { 6 interface Window { 7 gameSound: any; 8 } 9} 10 11// Define player component type 12interface PlayerComp extends Comp { 13 speed: number; 14 health: number; 15 maxHealth: number; 16 damage(amount: number): void; 17 heal(amount: number): void; 18 attack(): void; 19 update(): void; 20} 21 22function player(k: KAPLAYCtx): PlayerComp { 23 // Use closed local variable for internal data 24 let speed = 500; 25 let jumpForce = 600; 26 let maxHealth = 100; 27 let health = maxHealth; 28 let isAttacking = false; 29 let isHit = false; 30 let sword: GameObj | null = null; 31 let arrowPoints: GameObj[] = []; 32 let healthBar: GameObj | null = null; 33 let healthBarBg: GameObj | null = null; 34 const ARROW_SEGMENTS = 8; // Number of segments in the arrow 35 const MAX_ATTACK_DISTANCE = 500; // Maximum distance for attacks and kaboom 36 let attackRangeCircle: GameObj | null = null; // Visual indicator of attack range 37 38 // Helper function to convert radians to degrees 39 const radToDeg = (rad: number) => (rad * 180) / Math.PI; 40 41 // Helper function to create a bezier curve point 42 const bezierPoint = ( 43 t: number, 44 p0: number, 45 p1: number, 46 p2: number, 47 p3: number, 48 ) => { 49 const mt = 1 - t; 50 return ( 51 mt * mt * mt * p0 + 52 3 * mt * mt * t * p1 + 53 3 * mt * t * t * p2 + 54 t * t * t * p3 55 ); 56 }; 57 58 // Helper function to clamp a point to a circle 59 const clampToCircle = ( 60 center: { x: number; y: number }, 61 point: { x: number; y: number }, 62 radius: number, 63 ): Vec2 => { 64 const dx = point.x - center.x; 65 const dy = point.y - center.y; 66 const distance = Math.sqrt(dx * dx + dy * dy); 67 68 if (distance <= radius) { 69 return k.vec2(point.x, point.y); // Point is already inside circle 70 } 71 72 // Calculate the point on the circle's edge 73 const ratio = radius / distance; 74 return k.vec2(center.x + dx * ratio, center.y + dy * ratio); 75 }; 76 77 // Helper function to play sound if available 78 const playSound = (type: string, options = {}) => { 79 if (window.gameSound) { 80 window.gameSound.playSfx(type, options); 81 } 82 }; 83 84 return { 85 id: "player", 86 require: ["body", "area", "pos"], 87 88 // Exposed properties 89 speed, 90 health, 91 maxHealth, 92 93 // Take damage 94 damage(this: GameObj, amount: number) { 95 if (isHit) return; // Prevent taking damage too quickly 96 97 health -= amount; 98 99 // Play hit sound 100 playSound("hit", { volume: 0.4, detune: 300 }); 101 102 // Flash red when hit 103 isHit = true; 104 this.color = k.rgb(255, 0, 0); 105 106 // Reset color after a short time 107 k.wait(0.1, () => { 108 this.color = k.rgb(); 109 isHit = false; 110 }); 111 112 // Update health bar 113 if (healthBar) { 114 const healthPercent = Math.max(0, health / maxHealth); 115 healthBar.width = 60 * healthPercent; 116 } 117 118 // Check if player is dead 119 if (health <= 0) { 120 // Game over logic 121 health = 0; // Ensure health doesn't go negative 122 123 // Create dramatic death effect 124 k.addKaboom(this.pos, { scale: 2 }); 125 k.shake(20); 126 127 // Play death sound 128 playSound("explosion", { volume: 1, detune: -300 }); 129 130 // Emit death event for game over handling 131 this.trigger("death"); 132 } 133 }, 134 135 // Heal player 136 heal(this: GameObj, amount: number) { 137 // Add health but don't exceed max health 138 health = Math.min(health + amount, maxHealth); 139 140 // Play heal sound 141 playSound("coin", { volume: 0.2, detune: 200 }); 142 143 // Flash green when healed 144 this.color = k.rgb(0, 255, 0); 145 146 // Reset color after a short time 147 k.wait(0.1, () => { 148 this.color = k.rgb(); 149 }); 150 151 // Update health bar 152 if (healthBar) { 153 const healthPercent = Math.max(0, health / maxHealth); 154 healthBar.width = 60 * healthPercent; 155 } 156 }, 157 158 // Runs when the obj is added to scene 159 add(this: GameObj) { 160 // Create health bar background (gray) 161 healthBarBg = k.add([ 162 k.rect(60, 8), 163 k.pos(this.pos.x - 30, this.pos.y - 40), 164 k.color(100, 100, 100), 165 k.z(0.9), 166 ]); 167 168 // Create health bar (red) 169 healthBar = k.add([ 170 k.rect(60, 8), 171 k.pos(this.pos.x - 30, this.pos.y - 40), 172 k.color(255, 0, 0), 173 k.z(1), 174 ]); 175 176 // Create sword attached to player 177 sword = k.add([ 178 k.sprite("sword-o"), 179 k.pos(this.pos.x + 30, this.pos.y - 10), 180 k.rotate(45), // Hold at 45 degrees 181 k.anchor("center"), 182 k.scale(0.7), 183 k.area(), // Add area for collision detection 184 k.z(1), // Make sure sword is in front of player 185 "sword", // Add tag for collision detection 186 { 187 isAttacking: false, // Custom property to track attack state 188 }, 189 ]); 190 191 // Create attack range indicator (semi-transparent circle) 192 attackRangeCircle = k.add([ 193 k.circle(MAX_ATTACK_DISTANCE), 194 k.pos(this.pos.x, this.pos.y), 195 k.color(255, 255, 255), 196 k.opacity(0.1), // Very subtle 197 k.z(0.1), // Behind everything 198 ]); 199 200 // Create arrow segments 201 for (let i = 0; i < ARROW_SEGMENTS; i++) { 202 // Create segment with white outline 203 const segment = k.add([ 204 k.circle(3), // Initial size, will be scaled based on distance 205 k.pos(this.pos.x, this.pos.y - 30), // Start from player's head 206 k.color(255, 0, 0), // Red fill 207 k.outline(2, k.rgb(255, 255, 255)), // White outline 208 k.z(0.5), 209 ]); 210 arrowPoints.push(segment); 211 } 212 213 // Create arrow head (using a circle for now) 214 const arrowHead = k.add([ 215 k.circle(6), // Larger circle for the arrow head 216 k.pos(this.pos.x, this.pos.y - 30), 217 k.color(255, 0, 0), // Red fill 218 k.outline(2, k.rgb(255, 255, 255)), // White outline 219 k.z(0.5), 220 ]); 221 arrowPoints.push(arrowHead); 222 223 // Jump with space or up arrow 224 this.onKeyPress(["space", "up", "w"], () => { 225 if (this.isGrounded()) { 226 this.jump(jumpForce); 227 playSound("coin", { volume: 0.3, detune: 400 }); 228 } 229 }); 230 231 // Attack with X key - now ultimate move 232 this.onKeyPress("x", () => { 233 // Play charging sound 234 playSound("windup", { volume: 0.5, detune: 500 }); 235 236 // Create visual effects for charging up 237 const chargeEffect = k.add([ 238 k.circle(50), 239 k.pos(this.pos), 240 k.color(255, 0, 0), 241 k.opacity(0.5), 242 k.anchor("center"), 243 k.z(0.8), 244 ]); 245 246 // Grow the charge effect 247 k.tween( 248 50, 249 200, 250 1.4, 251 (v) => { 252 if (chargeEffect.exists()) { 253 chargeEffect.radius = v; 254 chargeEffect.opacity = 0.5 + Math.sin(k.time() * 10) * 0.2; 255 } 256 }, 257 k.easings.easeInQuad, 258 ); 259 260 // Add warning text 261 const warningText = k.add([ 262 k.text("ULTIMATE CHARGING...", { size: 24 }), 263 k.pos(k.width() / 2, 100), 264 k.color(255, 50, 50), 265 k.anchor("center"), 266 k.z(100), 267 ]); 268 269 // Flash the warning text 270 k.loop(0.2, () => { 271 if (warningText.exists()) { 272 warningText.color = 273 warningText.color === k.rgb(255, 50, 50) 274 ? k.rgb(255, 100, 100) 275 : k.rgb(255, 50, 50); 276 } 277 }); 278 279 // After delay, trigger the ultimate explosion 280 k.wait(1.5, () => { 281 if (chargeEffect.exists()) chargeEffect.destroy(); 282 if (warningText.exists()) warningText.destroy(); 283 284 // Create massive explosion 285 const explosionRadius = 500; // Much larger than normal explosions 286 287 // Animate sword for dramatic effect 288 if (sword) { 289 // Make sword glow red 290 sword.color = k.rgb(255, 0, 0); 291 292 // Dramatic sword spin 293 k.tween( 294 0, 295 720, 296 1, 297 (v) => { 298 if (sword) { 299 sword.angle = v; 300 sword.scaleTo(1 + Math.sin(k.time() * 10) * 0.3); 301 } 302 }, 303 k.easings.easeInOutQuad, 304 ); 305 } 306 307 // Play massive explosion sound 308 playSound("explosion", { volume: 1.0, detune: -600 }); 309 310 // Visual effects 311 k.addKaboom(this.pos, { 312 scale: 5, 313 }); 314 315 // Add multiple explosion effects for dramatic impact 316 for (let i = 0; i < 8; i++) { 317 const angle = Math.PI * 2 * (i / 8); 318 const offset = k.vec2(Math.cos(angle) * 100, Math.sin(angle) * 100); 319 320 k.wait(i * 0.1, () => { 321 k.addKaboom(k.vec2(this.pos).add(offset), { 322 scale: 2 + Math.random() * 2, 323 }); 324 325 // Play additional explosion sounds with slight delay 326 playSound("explosion", { 327 volume: 0.7, 328 detune: -300 + Math.random() * 200, 329 }); 330 }); 331 } 332 333 // Heavy screen shake 334 k.shake(40); 335 336 // Create explosion area for damage 337 const explosion = k.add([ 338 k.circle(explosionRadius), 339 k.pos(this.pos), 340 k.color(255, 50, 50), 341 k.area(), 342 k.anchor("center"), 343 k.opacity(0.6), 344 "ultimate-explosion", 345 ]); 346 347 // Fade out explosion 348 k.tween( 349 0.6, 350 0, 351 1.5, 352 (v) => { 353 if (explosion.exists()) { 354 explosion.opacity = v; 355 } 356 }, 357 k.easings.easeOutQuad, 358 ); 359 360 // Destroy explosion after animation 361 k.wait(1.5, () => { 362 if (explosion.exists()) explosion.destroy(); 363 }); 364 365 // Damage all enemies with high damage 366 const enemies = k.get("enemy"); 367 let enemiesKilled = 0; 368 369 enemies.forEach((enemy) => { 370 const dist = k.vec2(enemy.pos).dist(this.pos); 371 if (dist < explosionRadius) { 372 // Count enemies killed 373 enemiesKilled++; 374 375 // Instant kill any enemy within the explosion radius 376 (enemy as any).damage(1000); // Extremely high damage to ensure death 377 378 // Add additional explosion effect at enemy position 379 k.wait(Math.random() * 0.3, () => { 380 k.addKaboom(enemy.pos, { 381 scale: 1 + Math.random(), 382 }); 383 384 // Play enemy death sound 385 playSound("explosion", { 386 volume: 0.5, 387 detune: Math.random() * 400 - 200, 388 }); 389 }); 390 } 391 }); 392 393 // Calculate bonus score based on health and enemies killed 394 // Higher health = higher score multiplier 395 const healthPercent = health / maxHealth; 396 const scoreBonus = Math.round( 397 500 * healthPercent * (1 + enemiesKilled * 0.5), 398 ); 399 400 // Add score bonus 401 if (scoreBonus > 0) { 402 // Get score object 403 const scoreObj = k.get("score")[0]; 404 if (scoreObj) { 405 // Extract current score 406 const currentScore = parseInt(scoreObj.text.split(": ")[1]); 407 // Add bonus 408 const newScore = currentScore + scoreBonus; 409 // Update score display 410 scoreObj.text = `Score: ${newScore}`; 411 412 // Update the actual score variable in the game scene 413 // This is needed for the game over screen to show the correct score 414 const gameScores = k.get("game-score-tracker"); 415 if (gameScores.length > 0) { 416 gameScores[0].updateScore(newScore); 417 } 418 419 // Play bonus sound 420 playSound("coin", { volume: 0.8 }); 421 422 // Show bonus text 423 const bonusText = k.add([ 424 k.text(`+${scoreBonus} ULTIMATE BONUS!`, { size: 32 }), 425 k.pos(k.width() / 2, k.height() / 2 - 100), 426 k.anchor("center"), 427 k.color(255, 255, 0), 428 k.outline(2, k.rgb(0, 0, 0)), 429 k.z(100), 430 k.opacity(1), 431 ]); 432 433 // Fade out and destroy the text 434 k.tween( 435 1, 436 0, 437 1.5, 438 (v) => { 439 if (bonusText.exists()) { 440 bonusText.opacity = v; 441 bonusText.pos.y -= 0.5; // Float upward 442 } 443 }, 444 k.easings.easeInQuad, 445 ); 446 447 k.wait(1.5, () => { 448 if (bonusText.exists()) bonusText.destroy(); 449 }); 450 } 451 } 452 453 // Kill the player (sacrifice) 454 this.damage(health); // Use current health to ensure death 455 }); 456 }); 457 458 // Attack, kaboom and shake on click 459 k.onClick(() => { 460 // Attack with sword 461 this.attack(); 462 463 // Get mouse position and clamp it to the attack range 464 const mousePos = k.mousePos(); 465 const clampedPos = clampToCircle( 466 this.pos, 467 mousePos, 468 MAX_ATTACK_DISTANCE, 469 ); 470 471 console.log("Creating explosion at", clampedPos.x, clampedPos.y); 472 473 // Play explosion sound 474 playSound("explosion", { volume: 0.6 }); 475 476 // Create visual explosion effect 477 k.addKaboom(clampedPos); 478 479 // Create explosion area for damage 480 const explosionRadius = 120; 481 const explosion = k.add([ 482 k.circle(explosionRadius), 483 k.pos(clampedPos), 484 k.color(255, 0, 0), // Semi-transparent red 485 k.area(), 486 k.anchor("center"), 487 k.opacity(0.3), // Add opacity component 488 "explosion", 489 ]); 490 491 // Destroy explosion after a short time 492 k.wait(0.1, () => { 493 explosion.destroy(); 494 }); 495 496 // Manually check for enemies in range 497 const enemies = k.get("enemy"); 498 enemies.forEach((enemy) => { 499 const dist = k.vec2(enemy.pos).dist(clampedPos); 500 if (dist < explosionRadius) { 501 // Normalize distance to 0-1 range 502 const normalizedDist = dist / explosionRadius; 503 504 const maxDamage = 80; 505 const minDamage = 10; 506 const logFalloff = Math.log(10 * normalizedDist) / Math.log(20); 507 const damagePercent = 1 - logFalloff; 508 const damage = Math.max( 509 Math.floor(maxDamage * damagePercent), 510 minDamage, 511 ); 512 513 console.log( 514 `Explosion damage to enemy: ${damage}, distance: ${dist}, normalized: ${normalizedDist}, falloff: ${logFalloff}`, 515 ); 516 // Add type assertion to tell TypeScript that enemy has a damage method 517 (enemy as any).damage(damage); 518 } 519 }); 520 521 // Shake the screen 522 k.shake(10); 523 }); 524 }, 525 526 // Attack method 527 attack(this: GameObj) { 528 if (isAttacking) return; 529 530 isAttacking = true; 531 532 // Play sword swing sound 533 playSound("explosion", { volume: 1, detune: 800 }); 534 535 if (sword) { 536 // Set sword to attacking state for collision detection 537 sword.isAttacking = true; 538 539 // Store original angle 540 const originalAngle = this.flipX ? -30 : 30; 541 542 // Animate sword swing 543 const direction = this.flipX ? -1 : 1; 544 const endAngle = direction > 0 ? 90 : -90; 545 546 // Tween the sword rotation 547 k.tween( 548 sword.angle, 549 endAngle, 550 0.15, 551 (val) => (sword!.angle = val), 552 k.easings.easeInOutQuad, 553 ); 554 555 // Return sword to original position 556 k.wait(0.15, () => { 557 if (sword) { 558 k.tween( 559 sword.angle, 560 originalAngle, 561 0.15, 562 (val) => (sword!.angle = val), 563 k.easings.easeOutQuad, 564 ); 565 } 566 }); 567 568 // End attack state 569 k.wait(0.3, () => { 570 isAttacking = false; 571 if (sword) { 572 sword.isAttacking = false; 573 } 574 }); 575 } 576 }, 577 578 // Runs every frame 579 update(this: GameObj) { 580 // Left movement (left arrow or A key) 581 if (k.isKeyDown(["left", "a"])) { 582 this.move(-speed, 0); 583 this.flipX = true; 584 } 585 586 // Right movement (right arrow or D key) 587 if (k.isKeyDown(["right", "d"])) { 588 this.move(speed, 0); 589 this.flipX = false; 590 } 591 592 // Update sword position to follow player 593 if (sword) { 594 const xOffset = this.flipX ? 10 : 60; 595 const yOffset = 60; // Slightly above center 596 sword.pos.x = this.pos.x + xOffset; 597 sword.pos.y = this.pos.y + yOffset; 598 599 // Update sword angle and flip based on player direction (when not attacking) 600 if (!isAttacking) { 601 sword.flipX = this.flipX; 602 sword.angle = this.flipX ? -30 : 30; // Mirror angle when facing left 603 } 604 } 605 606 // Update health bar position to follow player 607 if (healthBar && healthBarBg) { 608 healthBarBg.pos.x = this.pos.x + 5; 609 healthBarBg.pos.y = this.pos.y - 40; 610 611 healthBar.pos.x = this.pos.x + 5; 612 healthBar.pos.y = this.pos.y - 40; 613 } 614 615 // Update attack range circle to follow player 616 if (attackRangeCircle) { 617 attackRangeCircle.pos = this.pos; 618 } 619 620 // Update arrow to create an arc from player to mouse 621 if (arrowPoints.length > 0) { 622 const mousePos = k.mousePos(); 623 const startPos = { x: this.pos.x + 40, y: this.pos.y }; // Player's head 624 625 // Clamp mouse position to maximum attack range 626 const clampedMousePos = clampToCircle( 627 this.pos, 628 mousePos, 629 MAX_ATTACK_DISTANCE, 630 ); 631 632 // Calculate horizontal distance from player to mouse 633 const horizontalDist = clampedMousePos.x - startPos.x; 634 635 // Calculate total distance from player to mouse 636 const dist = Math.sqrt( 637 Math.pow(clampedMousePos.x - startPos.x, 2) + 638 Math.pow(clampedMousePos.y - startPos.y, 2), 639 ); 640 641 // Determine arc direction based on horizontal position 642 // Use a smooth transition near the center 643 const centerThreshold = 50; // Distance from center where arc is minimal 644 let arcDirection = 0; 645 646 if (Math.abs(horizontalDist) < centerThreshold) { 647 // Smooth transition near center 648 arcDirection = -(horizontalDist / centerThreshold); // Will be between -1 and 1 649 } else { 650 // Full curve away from center 651 arcDirection = horizontalDist > 0 ? -1 : 1; 652 } 653 654 // Calculate arc height based on distance and direction 655 // Reduce arc height when close to center 656 const maxArcHeight = 100; 657 const arcHeightFactor = Math.min(Math.abs(arcDirection), 1); // Between 0 and 1 658 const arcHeight = Math.min(dist * 0.5, maxArcHeight) * arcHeightFactor; 659 660 // Calculate perpendicular direction for control points 661 const dirX = clampedMousePos.x - startPos.x; 662 const dirY = clampedMousePos.y - startPos.y; 663 const len = Math.sqrt(dirX * dirX + dirY * dirY); 664 const perpX = (-dirY / len) * arcDirection; 665 const perpY = (dirX / len) * arcDirection; 666 667 // Control points for the bezier curve 668 const ctrl1 = { 669 x: startPos.x + dirX * 0.25 + perpX * arcHeight, 670 y: startPos.y + dirY * 0.25 + perpY * arcHeight, 671 }; 672 673 const ctrl2 = { 674 x: startPos.x + dirX * 0.75 + perpX * arcHeight, 675 y: startPos.y + dirY * 0.75 + perpY * arcHeight, 676 }; 677 678 // Position each segment along the bezier curve 679 for (let i = 0; i < ARROW_SEGMENTS; i++) { 680 const t = i / (ARROW_SEGMENTS - 1); 681 const x = bezierPoint( 682 t, 683 startPos.x, 684 ctrl1.x, 685 ctrl2.x, 686 clampedMousePos.x, 687 ); 688 const y = bezierPoint( 689 t, 690 startPos.y, 691 ctrl1.y, 692 ctrl2.y, 693 clampedMousePos.y, 694 ); 695 696 // Calculate segment position along the curve 697 arrowPoints[i].pos.x = x; 698 arrowPoints[i].pos.y = y; 699 700 // Scale circle size based on distance from start 701 // Segments get progressively larger toward the end 702 const segmentDist = i / (ARROW_SEGMENTS - 1); // 0 to 1 703 const minSize = 2; 704 const maxSize = 5; 705 const size = minSize + segmentDist * (maxSize - minSize); 706 707 // Apply scale 708 if (arrowPoints[i].scale) { 709 arrowPoints[i].scaleTo(size / 3); // Divide by default size (3) 710 } 711 } 712 713 // Position arrow head at the end of the curve and make it larger 714 const arrowHead = arrowPoints[arrowPoints.length - 1]; 715 arrowHead.pos.x = clampedMousePos.x; 716 arrowHead.pos.y = clampedMousePos.y; 717 718 // Make arrow head larger 719 if (arrowHead.scale) { 720 arrowHead.scaleTo(3); 721 } 722 } 723 }, 724 725 // Cleanup when destroyed 726 destroy() { 727 if (sword) { 728 sword.destroy(); 729 } 730 731 if (attackRangeCircle) { 732 attackRangeCircle.destroy(); 733 } 734 735 if (healthBar) { 736 healthBar.destroy(); 737 } 738 739 if (healthBarBg) { 740 healthBarBg.destroy(); 741 } 742 743 // Destroy all arrow segments 744 arrowPoints.forEach((segment) => { 745 segment.destroy(); 746 }); 747 arrowPoints = []; 748 }, 749 }; 750} 751 752export default player;