the game

feat: add core features

dunkirk.sh 20559584 ddeef53c

verified
+982 -35
+5
bun.lock
··· 4 4 "": { 5 5 "name": "myGame", 6 6 "dependencies": { 7 + "@kaplayjs/crew": "^2.0.1", 7 8 "kaplay": "^3001.0.19", 8 9 }, 9 10 "devDependencies": { ··· 64 65 "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.10", "", { "os": "win32", "cpu": "ia32" }, "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw=="], 65 66 66 67 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="], 68 + 69 + "@kaplayjs/crew": ["@kaplayjs/crew@2.0.1", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-PqYtP+MhjELQ/bAzlCSurhjmgmKp7CxustZlIhXGhEEDTBbzn5177FT6P4anO9rxxYvKHOcga3lBfR8pnVZIIQ=="], 67 70 68 71 "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.2", "", { "os": "android", "cpu": "arm" }, "sha512-o3pcKzJgSGt4d74lSZ+OCnHwkKBeAbFDmbEm5gg70eA8VkyCuC/zV9TwBnmw6VjDlRdF4Pshfb+WE9E6XY1PoQ=="], 69 72 ··· 134 137 "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], 135 138 136 139 "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], 140 + 141 + "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], 137 142 138 143 "undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="], 139 144
+10 -8
index.html
··· 1 - <!DOCTYPE html> 1 + <!doctype html> 2 2 <html> 3 - <head> 4 - <title>myGame</title> 5 - </head> 6 - <body style="overflow:hidden"> 7 - <script src="src/main.js" type="module"></script> 8 - </body> 9 - </html> 3 + <head> 4 + <title>myGame</title> 5 + </head> 6 + 7 + <body style="overflow: hidden"> 8 + <script src="src/main.ts" type="module"></script> 9 + </body> 10 + </html> 11 +
+18 -16
package.json
··· 1 1 { 2 - "name": "myGame", 3 - "type": "module", 4 - "scripts": { 5 - "build": "vite build", 6 - "dev": "vite", 7 - "preview": "vite preview", 8 - "zip": "bun run build && mkdir -p dist && zip -r dist/game.zip dist -x \"**/.DS_Store\"" 9 - }, 10 - "dependencies": { 11 - "kaplay": "^3001.0.19" 12 - }, 13 - "devDependencies": { 14 - "@types/node": "^24.5.2", 15 - "vite": "^7.1.7" 16 - } 17 - } 2 + "name": "smackdown", 3 + "type": "module", 4 + "scripts": { 5 + "build": "vite build", 6 + "dev": "vite", 7 + "preview": "vite preview", 8 + "zip": "bun run build && mkdir -p dist && zip -r dist/game.zip dist -x \"**/.DS_Store\"" 9 + }, 10 + "dependencies": { 11 + "@kaplayjs/crew": "^2.0.1", 12 + "kaplay": "^3001.0.19" 13 + }, 14 + "devDependencies": { 15 + "@types/node": "^24.5.2", 16 + "vite": "^7.1.7" 17 + } 18 + } 19 +
+111
src/confetti.ts
··· 1 + import type { KAPLAYCtx, GameObj } from "kaplay"; 2 + 3 + // Extend the KAPLAYCtx type to include our addConfetti method 4 + declare module "kaplay" { 5 + interface KAPLAYCtx { 6 + addConfetti?: (pos: { x: number, y: number }) => GameObj[]; 7 + } 8 + } 9 + 10 + // Function to create a confetti effect at a position 11 + export function addConfetti(k: KAPLAYCtx, pos: { x: number, y: number }) { 12 + // Number of confetti particles 13 + const PARTICLE_COUNT = 50; 14 + 15 + // Confetti colors 16 + const COLORS = [ 17 + [255, 0, 0], // Red 18 + [0, 255, 0], // Green 19 + [0, 0, 255], // Blue 20 + [255, 255, 0], // Yellow 21 + [255, 0, 255], // Magenta 22 + [0, 255, 255], // Cyan 23 + [255, 165, 0], // Orange 24 + [128, 0, 128], // Purple 25 + ]; 26 + 27 + // Create particles 28 + const particles: GameObj[] = []; 29 + 30 + for (let i = 0; i < PARTICLE_COUNT; i++) { 31 + // Random color 32 + const color = COLORS[Math.floor(Math.random() * COLORS.length)]; 33 + 34 + // Random size 35 + const size = Math.random() * 8 + 2; // 2-10 pixels 36 + 37 + // Random shape (circle or rect) 38 + const isCircle = Math.random() > 0.5; 39 + 40 + // Random velocity 41 + const angle = Math.random() * Math.PI * 2; 42 + const speed = Math.random() * 400 + 100; // 100-500 pixels per second 43 + const vx = Math.cos(angle) * speed; 44 + const vy = Math.sin(angle) * speed - 200; // Initial upward boost 45 + 46 + // Random rotation speed 47 + const rotSpeed = Math.random() * 10 - 5; // -5 to 5 radians per second 48 + 49 + // Create particle 50 + const particle = k.add([ 51 + isCircle ? k.circle(size / 2) : k.rect(size, size / 2), 52 + k.pos(pos.x, pos.y), 53 + k.color(...color), 54 + k.anchor("center"), 55 + k.rotate(Math.random() * 360), // Random initial rotation 56 + k.opacity(1), 57 + k.z(100), // Above most game elements 58 + { 59 + // Custom properties for movement 60 + vx, 61 + vy, 62 + rotSpeed, 63 + gravity: 980, // Gravity effect 64 + lifespan: Math.random() * 1 + 1, // 1-2 seconds 65 + fadeStart: 0.7, // When to start fading (0.7 = 70% of lifespan) 66 + }, 67 + ]); 68 + 69 + // Update function for the particle 70 + particle.onUpdate(() => { 71 + // Update position based on velocity 72 + particle.pos.x += particle.vx * k.dt(); 73 + particle.pos.y += particle.vy * k.dt(); 74 + 75 + // Apply gravity 76 + particle.vy += particle.gravity * k.dt(); 77 + 78 + // Apply rotation 79 + particle.angle += particle.rotSpeed * k.dt() * 60; 80 + 81 + // Update lifespan 82 + particle.lifespan -= k.dt(); 83 + 84 + // Fade out 85 + if (particle.lifespan < particle.fadeStart) { 86 + particle.opacity = Math.max(0, particle.lifespan / particle.fadeStart); 87 + } 88 + 89 + // Destroy when lifespan is over 90 + if (particle.lifespan <= 0) { 91 + particle.destroy(); 92 + } 93 + }); 94 + 95 + particles.push(particle); 96 + } 97 + 98 + return particles; 99 + } 100 + 101 + // Component to add confetti method to the game context 102 + export function confettiPlugin(k: KAPLAYCtx) { 103 + return { 104 + // Add the confetti function to the game context 105 + addConfetti(pos: { x: number, y: number }) { 106 + return addConfetti(k, pos); 107 + } 108 + }; 109 + } 110 + 111 + export default confettiPlugin;
+213
src/enemy.ts
··· 1 + import type { KAPLAYCtx, Comp, GameObj } from "kaplay"; 2 + 3 + // Define enemy component type 4 + export interface EnemyComp extends Comp { 5 + health: number; 6 + maxHealth: number; 7 + speed: number; 8 + damage(amount: number): void; 9 + update(): void; 10 + } 11 + 12 + export function enemy(k: KAPLAYCtx, target: GameObj) { 13 + // Use closed local variables for internal data 14 + let maxHealth = 100; 15 + let health = maxHealth; 16 + let speed = 100; 17 + let isHit = false; 18 + let isDying = false; 19 + let healthBar: GameObj | null = null; 20 + let healthBarBg: GameObj | null = null; 21 + let lastDamageTime = 0; 22 + const DAMAGE_COOLDOWN = 0.5; // seconds 23 + 24 + return { 25 + id: "enemy", 26 + require: ["pos", "sprite", "area", "body"], 27 + 28 + // Exposed properties 29 + health, 30 + maxHealth, 31 + speed, 32 + 33 + // Damage the enemy 34 + damage(this: GameObj, amount: number) { 35 + if (isDying) return; 36 + 37 + health -= amount; 38 + console.log(`Enemy damaged: ${amount}, health: ${health}`); 39 + 40 + // Flash red when hit 41 + isHit = true; 42 + this.color = k.rgb(255, 0, 0); 43 + 44 + // Reset color after a short time 45 + k.wait(0.1, () => { 46 + this.color = k.rgb(); 47 + isHit = false; 48 + }); 49 + 50 + // Update health bar 51 + if (healthBar) { 52 + const healthPercent = Math.max(0, health / maxHealth); 53 + healthBar.width = 40 * healthPercent; 54 + } 55 + 56 + // Check if enemy is dead 57 + if (health <= 0 && !isDying) { 58 + isDying = true; 59 + this.die(); 60 + } 61 + }, 62 + 63 + // Enemy death 64 + die(this: GameObj) { 65 + // Add confetti effect only (no kaboom) 66 + if (k.addConfetti) { 67 + k.addConfetti(this.pos); 68 + } 69 + 70 + // Remove health bar 71 + if (healthBarBg) healthBarBg.destroy(); 72 + if (healthBar) healthBar.destroy(); 73 + 74 + // Scale down and fade out 75 + k.tween( 76 + this.scale.x, 77 + 0, 78 + 0.5, 79 + (v) => { 80 + this.scale.x = v; 81 + this.scale.y = v; 82 + }, 83 + k.easings.easeInQuad, 84 + ); 85 + 86 + k.tween( 87 + 1, 88 + 0, 89 + 0.5, 90 + (v) => { 91 + this.opacity = v; 92 + }, 93 + k.easings.easeInQuad, 94 + ); 95 + 96 + // Destroy after animation completes 97 + k.wait(0.5, () => { 98 + this.destroy(); 99 + }); 100 + }, 101 + 102 + // Add method runs when the component is added to a game object 103 + add(this: GameObj) { 104 + // Create health bar background (gray) 105 + healthBarBg = k.add([ 106 + k.rect(40, 5), 107 + k.pos(this.pos.x - 20, this.pos.y - 30), 108 + k.color(100, 100, 100), 109 + k.z(0.9), 110 + ]); 111 + 112 + // Create health bar (red) 113 + healthBar = k.add([ 114 + k.rect(40, 5), 115 + k.pos(this.pos.x - 20, this.pos.y - 30), 116 + k.color(255, 0, 0), 117 + k.z(1), 118 + ]); 119 + 120 + // Handle collisions with sword 121 + this.onCollide("sword", (sword) => { 122 + if (sword.isAttacking && !isHit) { 123 + // Sword does 35 damage (35% of enemy health) 124 + this.damage(35); 125 + } 126 + }); 127 + }, 128 + 129 + // Runs every frame 130 + update(this: GameObj) { 131 + if (isDying) return; 132 + 133 + // Move toward target 134 + const dir = k.vec2(target.pos.x - this.pos.x, target.pos.y - this.pos.y); 135 + 136 + // Normalize direction vector 137 + const dist = Math.sqrt(dir.x * dir.x + dir.y * dir.y); 138 + 139 + // Only move if not too close to target 140 + if (dist > 50) { 141 + const normalizedDir = { 142 + x: dir.x / dist, 143 + y: dir.y / dist, 144 + }; 145 + 146 + // Move toward target 147 + this.move(normalizedDir.x * speed, normalizedDir.y * speed); 148 + 149 + // Flip sprite based on movement direction 150 + if (normalizedDir.x !== 0) { 151 + this.flipX = normalizedDir.x < 0; 152 + } 153 + } 154 + 155 + // Check for collision with player and apply damage if in contact 156 + // Only apply damage if cooldown has passed 157 + if (k.time() - lastDamageTime > DAMAGE_COOLDOWN) { 158 + const playerObj = k.get("player")[0]; 159 + if (playerObj && this.isColliding(playerObj)) { 160 + lastDamageTime = k.time(); 161 + 162 + // Damage player 163 + if (playerObj.damage) { 164 + playerObj.damage(5); 165 + } 166 + 167 + // Knockback effect 168 + const knockback = 200; 169 + const knockbackDir = k.vec2( 170 + playerObj.pos.x - this.pos.x, 171 + playerObj.pos.y - this.pos.y 172 + ).unit(); 173 + 174 + playerObj.move(knockbackDir.x * knockback, knockbackDir.y * knockback); 175 + } 176 + } 177 + 178 + // Update health bar position to follow enemy 179 + if (healthBar && healthBarBg) { 180 + healthBarBg.pos.x = this.pos.x - 20; 181 + healthBarBg.pos.y = this.pos.y - 30; 182 + 183 + healthBar.pos.x = this.pos.x - 20; 184 + healthBar.pos.y = this.pos.y - 30; 185 + } 186 + }, 187 + 188 + // Cleanup when destroyed 189 + destroy() { 190 + if (healthBar) healthBar.destroy(); 191 + if (healthBarBg) healthBarBg.destroy(); 192 + }, 193 + }; 194 + } 195 + 196 + // Function to create an enemy 197 + export function makeEnemy(k: KAPLAYCtx, target: GameObj, x: number, y: number) { 198 + // Create enemy 199 + const newEnemy = k.add([ 200 + k.sprite("bean"), 201 + k.pos(x, y), 202 + k.scale(1), 203 + k.anchor("center"), 204 + k.area(), 205 + k.body(), 206 + enemy(k, target), 207 + "enemy", // Add tag for collision detection 208 + ]); 209 + 210 + return newEnemy; 211 + } 212 + 213 + export default enemy;
-11
src/main.js
··· 1 - import kaplay from "kaplay"; 2 - // import "kaplay/global"; // uncomment if you want to use without the k. prefix 3 - 4 - const k = kaplay(); 5 - 6 - k.loadRoot("./"); // A good idea for Itch.io publishing later 7 - k.loadSprite("bean", "sprites/bean.png"); 8 - 9 - k.add([k.pos(120, 80), k.sprite("bean")]); 10 - 11 - k.onClick(() => k.addKaboom(k.mousePos()));
+156
src/main.ts
··· 1 + import { crew } from "@kaplayjs/crew"; 2 + import kaplay from "kaplay"; 3 + 4 + import player from "./player"; 5 + import { makeEnemy, type EnemyComp } from "./enemy"; 6 + import confettiPlugin, { addConfetti } from "./confetti"; 7 + 8 + const k = kaplay({ plugins: [crew] }); 9 + k.loadRoot("./"); // A good idea for Itch.io publishing later 10 + k.loadCrew("sprite", "glady-o"); 11 + k.loadCrew("sprite", "sword-o"); 12 + k.loadCrew("sprite", "bean"); // Using bean sprite for enemies 13 + 14 + // Add confetti plugin to the game context 15 + const confetti = confettiPlugin(k); 16 + k.addConfetti = confetti.addConfetti; 17 + 18 + k.setGravity(1600); 19 + 20 + // Create ground 21 + const ground = k.add([ 22 + k.rect(k.width(), 48), 23 + k.pos(0, k.height() - 48), 24 + k.outline(4), 25 + k.area(), 26 + k.body({ isStatic: true }), 27 + k.color(127, 200, 255), 28 + ]); 29 + 30 + // Create player object with components 31 + const playerObj = k.add([ 32 + k.pos(120, 500), 33 + k.sprite("glady-o"), 34 + k.body(), 35 + k.area(), 36 + player(k), 37 + "player", // Add tag for collision detection 38 + ]); 39 + 40 + // Enemy spawning variables 41 + let enemies: any[] = []; 42 + let initialMaxEnemies = 5; 43 + let maxEnemies = initialMaxEnemies; 44 + let initialSpawnInterval = 3; // seconds 45 + let spawnInterval = initialSpawnInterval; 46 + let gameTime = 0; // Track game time in seconds 47 + let difficultyLevel = 1; 48 + let spawnLoopId: any = null; 49 + 50 + // Difficulty scaling 51 + function updateDifficulty() { 52 + gameTime += 1; // Increment game time by 1 second 53 + 54 + // Every 30 seconds, increase difficulty 55 + if (gameTime % 30 === 0) { 56 + difficultyLevel += 1; 57 + 58 + // Increase max enemies (cap at 15) 59 + maxEnemies = Math.min(initialMaxEnemies + difficultyLevel, 15); 60 + 61 + // Decrease spawn interval (minimum 0.5 seconds) 62 + spawnInterval = Math.max(initialSpawnInterval - (difficultyLevel * 0.3), 0.5); 63 + 64 + console.log(`Difficulty increased to level ${difficultyLevel}. Max enemies: ${maxEnemies}, Spawn interval: ${spawnInterval}s`); 65 + 66 + // Cancel previous spawn loop and start a new one with updated interval 67 + if (spawnLoopId !== null) { 68 + k.cancel(spawnLoopId); 69 + } 70 + spawnLoopId = k.loop(spawnInterval, spawnEnemy); 71 + 72 + // Visual feedback for difficulty increase 73 + const screenCenter = k.vec2(k.width() / 2, k.height() / 2); 74 + if (k.addConfetti) { 75 + k.addConfetti(screenCenter); 76 + } 77 + 78 + // Add difficulty level text 79 + const levelText = k.add([ 80 + k.text(`Difficulty Level ${difficultyLevel}!`, { size: 32 }), 81 + k.pos(screenCenter), 82 + k.anchor("center"), 83 + k.color(255, 255, 255), 84 + k.outline(2, k.rgb(0, 0, 0)), 85 + k.z(100), 86 + k.opacity(1), 87 + ]); 88 + 89 + // Fade out and destroy the text 90 + k.tween( 91 + 1, 92 + 0, 93 + 2, 94 + (v) => { 95 + levelText.opacity = v; 96 + }, 97 + k.easings.easeInQuad, 98 + ); 99 + 100 + k.wait(2, () => { 101 + levelText.destroy(); 102 + }); 103 + } 104 + } 105 + 106 + // Start difficulty scaling 107 + k.loop(1, updateDifficulty); 108 + 109 + // Spawn an enemy at a random position 110 + function spawnEnemy() { 111 + // Don't spawn if we already have max enemies 112 + if (enemies.length >= maxEnemies) return; 113 + 114 + // Random position at the edges of the screen 115 + const side = Math.floor(Math.random() * 4); // 0: top, 1: right, 2: bottom, 3: left 116 + let x, y; 117 + 118 + switch (side) { 119 + case 0: // top 120 + x = Math.random() * k.width(); 121 + y = -50; 122 + break; 123 + case 1: // right 124 + x = k.width() + 50; 125 + y = Math.random() * k.height(); 126 + break; 127 + case 2: // bottom 128 + x = Math.random() * k.width(); 129 + y = k.height() + 50; 130 + break; 131 + case 3: // left 132 + x = -50; 133 + y = Math.random() * k.height(); 134 + break; 135 + } 136 + 137 + // Create enemy using the makeEnemy function 138 + const newEnemy = makeEnemy(k, playerObj, x, y); 139 + enemies.push(newEnemy); 140 + 141 + // Remove from array when destroyed 142 + newEnemy.on("destroy", () => { 143 + enemies = enemies.filter((e) => e !== newEnemy); 144 + }); 145 + } 146 + 147 + // Start spawning enemies 148 + spawnLoopId = k.loop(spawnInterval, spawnEnemy); 149 + 150 + // Game loop 151 + k.onUpdate(() => { 152 + // Update enemy list (remove destroyed enemies) 153 + enemies = enemies.filter((enemy) => enemy.exists()); 154 + }); 155 + 156 + console.log(typeof k);
+469
src/player.ts
··· 1 + import type { KAPLAYCtx, Comp, GameObj } from "kaplay"; 2 + import { Vec2 } from "kaplay"; 3 + 4 + // Define player component type 5 + interface PlayerComp extends Comp { 6 + speed: number; 7 + health: number; 8 + maxHealth: number; 9 + damage(amount: number): void; 10 + attack(): void; 11 + update(): void; 12 + } 13 + 14 + function player(k: KAPLAYCtx): PlayerComp { 15 + // Use closed local variable for internal data 16 + let speed = 500; 17 + let jumpForce = 600; 18 + let maxHealth = 100; 19 + let health = maxHealth; 20 + let isAttacking = false; 21 + let isHit = false; 22 + let sword: GameObj | null = null; 23 + let arrowPoints: GameObj[] = []; 24 + let healthBar: GameObj | null = null; 25 + let healthBarBg: GameObj | null = null; 26 + const ARROW_SEGMENTS = 8; // Number of segments in the arrow 27 + const MAX_ATTACK_DISTANCE = 500; // Maximum distance for attacks and kaboom 28 + let attackRangeCircle: GameObj | null = null; // Visual indicator of attack range 29 + 30 + // Helper function to convert radians to degrees 31 + const radToDeg = (rad: number) => (rad * 180) / Math.PI; 32 + 33 + // Helper function to create a bezier curve point 34 + const bezierPoint = ( 35 + t: number, 36 + p0: number, 37 + p1: number, 38 + p2: number, 39 + p3: number, 40 + ) => { 41 + const mt = 1 - t; 42 + return ( 43 + mt * mt * mt * p0 + 44 + 3 * mt * mt * t * p1 + 45 + 3 * mt * t * t * p2 + 46 + t * t * t * p3 47 + ); 48 + }; 49 + 50 + // Helper function to clamp a point to a circle 51 + const clampToCircle = ( 52 + center: { x: number; y: number }, 53 + point: { x: number; y: number }, 54 + radius: number, 55 + ): Vec2 => { 56 + const dx = point.x - center.x; 57 + const dy = point.y - center.y; 58 + const distance = Math.sqrt(dx * dx + dy * dy); 59 + 60 + if (distance <= radius) { 61 + return k.vec2(point.x, point.y); // Point is already inside circle 62 + } 63 + 64 + // Calculate the point on the circle's edge 65 + const ratio = radius / distance; 66 + return k.vec2(center.x + dx * ratio, center.y + dy * ratio); 67 + }; 68 + 69 + return { 70 + id: "player", 71 + require: ["body", "area", "pos"], 72 + 73 + // Exposed properties 74 + speed, 75 + health, 76 + maxHealth, 77 + 78 + // Take damage 79 + damage(this: GameObj, amount: number) { 80 + if (isHit) return; // Prevent taking damage too quickly 81 + 82 + health -= amount; 83 + 84 + // Flash red when hit 85 + isHit = true; 86 + this.color = k.rgb(255, 0, 0); 87 + 88 + // Reset color after a short time 89 + k.wait(0.1, () => { 90 + this.color = k.rgb(); 91 + isHit = false; 92 + }); 93 + 94 + // Update health bar 95 + if (healthBar) { 96 + const healthPercent = Math.max(0, health / maxHealth); 97 + healthBar.width = 60 * healthPercent; 98 + } 99 + 100 + // Check if player is dead 101 + if (health <= 0) { 102 + // Game over logic here 103 + k.addKaboom(this.pos); 104 + k.shake(20); 105 + } 106 + }, 107 + 108 + // Runs when the obj is added to scene 109 + add(this: GameObj) { 110 + // Create health bar background (gray) 111 + healthBarBg = k.add([ 112 + k.rect(60, 8), 113 + k.pos(this.pos.x - 30, this.pos.y - 40), 114 + k.color(100, 100, 100), 115 + k.z(0.9), 116 + ]); 117 + 118 + // Create health bar (red) 119 + healthBar = k.add([ 120 + k.rect(60, 8), 121 + k.pos(this.pos.x - 30, this.pos.y - 40), 122 + k.color(255, 0, 0), 123 + k.z(1), 124 + ]); 125 + 126 + // Create sword attached to player 127 + sword = k.add([ 128 + k.sprite("sword-o"), 129 + k.pos(this.pos.x + 30, this.pos.y - 10), 130 + k.rotate(45), // Hold at 45 degrees 131 + k.anchor("center"), 132 + k.scale(0.7), 133 + k.area(), // Add area for collision detection 134 + k.z(1), // Make sure sword is in front of player 135 + "sword", // Add tag for collision detection 136 + { 137 + isAttacking: false, // Custom property to track attack state 138 + }, 139 + ]); 140 + 141 + // Create attack range indicator (semi-transparent circle) 142 + attackRangeCircle = k.add([ 143 + k.circle(MAX_ATTACK_DISTANCE), 144 + k.pos(this.pos.x, this.pos.y), 145 + k.color(255, 255, 255), 146 + k.opacity(0.1), // Very subtle 147 + k.z(0.1), // Behind everything 148 + ]); 149 + 150 + // Create arrow segments 151 + for (let i = 0; i < ARROW_SEGMENTS; i++) { 152 + // Create segment with white outline 153 + const segment = k.add([ 154 + k.circle(3), // Initial size, will be scaled based on distance 155 + k.pos(this.pos.x, this.pos.y - 30), // Start from player's head 156 + k.color(255, 0, 0), // Red fill 157 + k.outline(2, k.rgb(255, 255, 255)), // White outline 158 + k.z(0.5), 159 + ]); 160 + arrowPoints.push(segment); 161 + } 162 + 163 + // Create arrow head (using a circle for now) 164 + const arrowHead = k.add([ 165 + k.circle(6), // Larger circle for the arrow head 166 + k.pos(this.pos.x, this.pos.y - 30), 167 + k.color(255, 0, 0), // Red fill 168 + k.outline(2, k.rgb(255, 255, 255)), // White outline 169 + k.z(0.5), 170 + ]); 171 + arrowPoints.push(arrowHead); 172 + 173 + // Jump with space or up arrow 174 + this.onKeyPress(["space", "up", "w"], () => { 175 + if (this.isGrounded()) { 176 + this.jump(jumpForce); 177 + } 178 + }); 179 + 180 + // Attack with X key 181 + this.onKeyPress("x", () => { 182 + this.attack(); 183 + }); 184 + 185 + // Attack, kaboom and shake on click 186 + k.onClick(() => { 187 + // Attack with sword 188 + this.attack(); 189 + 190 + // Get mouse position and clamp it to the attack range 191 + const mousePos = k.mousePos(); 192 + const clampedPos = clampToCircle( 193 + this.pos, 194 + mousePos, 195 + MAX_ATTACK_DISTANCE, 196 + ); 197 + 198 + console.log("Creating explosion at", clampedPos.x, clampedPos.y); 199 + 200 + // Create visual explosion effect 201 + k.addKaboom(clampedPos); 202 + 203 + // Create explosion area for damage 204 + const explosionRadius = 120; 205 + const explosion = k.add([ 206 + k.circle(explosionRadius), 207 + k.pos(clampedPos), 208 + k.color(255, 0, 0), // Semi-transparent red 209 + k.area(), 210 + k.anchor("center"), 211 + k.opacity(0.3), // Add opacity component 212 + "explosion", 213 + ]); 214 + 215 + // Destroy explosion after a short time 216 + k.wait(0.1, () => { 217 + explosion.destroy(); 218 + }); 219 + 220 + // Manually check for enemies in range 221 + const enemies = k.get("enemy"); 222 + enemies.forEach((enemy) => { 223 + const dist = k.vec2(enemy.pos).dist(clampedPos); 224 + if (dist < explosionRadius) { 225 + // Calculate damage based on distance from center 226 + // At center (dist = 0): 70 damage (70% of enemy health) 227 + // At edge (dist = explosionRadius): 20 damage (20% of enemy health) 228 + const damagePercent = 0.7 - (0.5 * dist) / explosionRadius; 229 + const damage = Math.floor(100 * damagePercent); // 100 is enemy max health 230 + 231 + console.log( 232 + `Direct damage to enemy: ${damage}, distance: ${dist}, percent: ${damagePercent}`, 233 + ); 234 + // Add type assertion to tell TypeScript that enemy has a damage method 235 + (enemy as any).damage(damage); 236 + } 237 + }); 238 + 239 + // Shake the screen 240 + k.shake(10); 241 + }); 242 + }, 243 + 244 + // Attack method 245 + attack(this: GameObj) { 246 + if (isAttacking) return; 247 + 248 + isAttacking = true; 249 + 250 + if (sword) { 251 + // Set sword to attacking state for collision detection 252 + sword.isAttacking = true; 253 + 254 + // Store original angle 255 + const originalAngle = this.flipX ? -30 : 30; 256 + 257 + // Animate sword swing 258 + const direction = this.flipX ? -1 : 1; 259 + const endAngle = direction > 0 ? 90 : -90; 260 + 261 + // Tween the sword rotation 262 + k.tween( 263 + sword.angle, 264 + endAngle, 265 + 0.15, 266 + (val) => (sword!.angle = val), 267 + k.easings.easeInOutQuad, 268 + ); 269 + 270 + // Return sword to original position 271 + k.wait(0.15, () => { 272 + if (sword) { 273 + k.tween( 274 + sword.angle, 275 + originalAngle, 276 + 0.15, 277 + (val) => (sword!.angle = val), 278 + k.easings.easeOutQuad, 279 + ); 280 + } 281 + }); 282 + 283 + // End attack state 284 + k.wait(0.3, () => { 285 + isAttacking = false; 286 + if (sword) { 287 + sword.isAttacking = false; 288 + } 289 + }); 290 + } 291 + }, 292 + 293 + // Runs every frame 294 + update(this: GameObj) { 295 + // Left movement (left arrow or A key) 296 + if (k.isKeyDown(["left", "a"])) { 297 + this.move(-speed, 0); 298 + this.flipX = true; 299 + } 300 + 301 + // Right movement (right arrow or D key) 302 + if (k.isKeyDown(["right", "d"])) { 303 + this.move(speed, 0); 304 + this.flipX = false; 305 + } 306 + 307 + // Update sword position to follow player 308 + if (sword) { 309 + const xOffset = this.flipX ? 10 : 60; 310 + const yOffset = 60; // Slightly above center 311 + sword.pos.x = this.pos.x + xOffset; 312 + sword.pos.y = this.pos.y + yOffset; 313 + 314 + // Update sword angle and flip based on player direction (when not attacking) 315 + if (!isAttacking) { 316 + sword.flipX = this.flipX; 317 + sword.angle = this.flipX ? -30 : 30; // Mirror angle when facing left 318 + } 319 + } 320 + 321 + // Update health bar position to follow player 322 + if (healthBar && healthBarBg) { 323 + healthBarBg.pos.x = this.pos.x + 5; 324 + healthBarBg.pos.y = this.pos.y - 40; 325 + 326 + healthBar.pos.x = this.pos.x + 5; 327 + healthBar.pos.y = this.pos.y - 40; 328 + } 329 + 330 + // Update attack range circle to follow player 331 + if (attackRangeCircle) { 332 + attackRangeCircle.pos = this.pos; 333 + } 334 + 335 + // Update arrow to create an arc from player to mouse 336 + if (arrowPoints.length > 0) { 337 + const mousePos = k.mousePos(); 338 + const startPos = { x: this.pos.x + 40, y: this.pos.y }; // Player's head 339 + 340 + // Clamp mouse position to maximum attack range 341 + const clampedMousePos = clampToCircle( 342 + this.pos, 343 + mousePos, 344 + MAX_ATTACK_DISTANCE, 345 + ); 346 + 347 + // Calculate horizontal distance from player to mouse 348 + const horizontalDist = clampedMousePos.x - startPos.x; 349 + 350 + // Calculate total distance from player to mouse 351 + const dist = Math.sqrt( 352 + Math.pow(clampedMousePos.x - startPos.x, 2) + 353 + Math.pow(clampedMousePos.y - startPos.y, 2), 354 + ); 355 + 356 + // Determine arc direction based on horizontal position 357 + // Use a smooth transition near the center 358 + const centerThreshold = 50; // Distance from center where arc is minimal 359 + let arcDirection = 0; 360 + 361 + if (Math.abs(horizontalDist) < centerThreshold) { 362 + // Smooth transition near center 363 + arcDirection = -(horizontalDist / centerThreshold); // Will be between -1 and 1 364 + } else { 365 + // Full curve away from center 366 + arcDirection = horizontalDist > 0 ? -1 : 1; 367 + } 368 + 369 + // Calculate arc height based on distance and direction 370 + // Reduce arc height when close to center 371 + const maxArcHeight = 100; 372 + const arcHeightFactor = Math.min(Math.abs(arcDirection), 1); // Between 0 and 1 373 + const arcHeight = Math.min(dist * 0.5, maxArcHeight) * arcHeightFactor; 374 + 375 + // Calculate perpendicular direction for control points 376 + const dirX = clampedMousePos.x - startPos.x; 377 + const dirY = clampedMousePos.y - startPos.y; 378 + const len = Math.sqrt(dirX * dirX + dirY * dirY); 379 + const perpX = (-dirY / len) * arcDirection; 380 + const perpY = (dirX / len) * arcDirection; 381 + 382 + // Control points for the bezier curve 383 + const ctrl1 = { 384 + x: startPos.x + dirX * 0.25 + perpX * arcHeight, 385 + y: startPos.y + dirY * 0.25 + perpY * arcHeight, 386 + }; 387 + 388 + const ctrl2 = { 389 + x: startPos.x + dirX * 0.75 + perpX * arcHeight, 390 + y: startPos.y + dirY * 0.75 + perpY * arcHeight, 391 + }; 392 + 393 + // Position each segment along the bezier curve 394 + for (let i = 0; i < ARROW_SEGMENTS; i++) { 395 + const t = i / (ARROW_SEGMENTS - 1); 396 + const x = bezierPoint( 397 + t, 398 + startPos.x, 399 + ctrl1.x, 400 + ctrl2.x, 401 + clampedMousePos.x, 402 + ); 403 + const y = bezierPoint( 404 + t, 405 + startPos.y, 406 + ctrl1.y, 407 + ctrl2.y, 408 + clampedMousePos.y, 409 + ); 410 + 411 + // Calculate segment position along the curve 412 + arrowPoints[i].pos.x = x; 413 + arrowPoints[i].pos.y = y; 414 + 415 + // Scale circle size based on distance from start 416 + // Segments get progressively larger toward the end 417 + const segmentDist = i / (ARROW_SEGMENTS - 1); // 0 to 1 418 + const minSize = 2; 419 + const maxSize = 5; 420 + const size = minSize + segmentDist * (maxSize - minSize); 421 + 422 + // Apply scale 423 + if (arrowPoints[i].scale) { 424 + arrowPoints[i].scale.x = size / 3; // Divide by default size (3) 425 + arrowPoints[i].scale.y = size / 3; 426 + } 427 + } 428 + 429 + // Position arrow head at the end of the curve and make it larger 430 + const arrowHead = arrowPoints[arrowPoints.length - 1]; 431 + arrowHead.pos.x = clampedMousePos.x; 432 + arrowHead.pos.y = clampedMousePos.y; 433 + 434 + // Make arrow head larger 435 + if (arrowHead.scale) { 436 + arrowHead.scale.x = 3; 437 + arrowHead.scale.y = 3; 438 + } 439 + } 440 + }, 441 + 442 + // Cleanup when destroyed 443 + destroy() { 444 + if (sword) { 445 + sword.destroy(); 446 + } 447 + 448 + if (attackRangeCircle) { 449 + attackRangeCircle.destroy(); 450 + } 451 + 452 + if (healthBar) { 453 + healthBar.destroy(); 454 + } 455 + 456 + if (healthBarBg) { 457 + healthBarBg.destroy(); 458 + } 459 + 460 + // Destroy all arrow segments 461 + arrowPoints.forEach((segment) => { 462 + segment.destroy(); 463 + }); 464 + arrowPoints = []; 465 + }, 466 + }; 467 + } 468 + 469 + export default player;