the game
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;