feat: add core features

dunkirk.sh 20559584 ddeef53c

verified
+5
bun.lock
···
"": {
"name": "myGame",
"dependencies": {
+
"@kaplayjs/crew": "^2.0.1",
"kaplay": "^3001.0.19",
},
"devDependencies": {
···
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.10", "", { "os": "win32", "cpu": "ia32" }, "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="],
+
+
"@kaplayjs/crew": ["@kaplayjs/crew@2.0.1", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-PqYtP+MhjELQ/bAzlCSurhjmgmKp7CxustZlIhXGhEEDTBbzn5177FT6P4anO9rxxYvKHOcga3lBfR8pnVZIIQ=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.2", "", { "os": "android", "cpu": "arm" }, "sha512-o3pcKzJgSGt4d74lSZ+OCnHwkKBeAbFDmbEm5gg70eA8VkyCuC/zV9TwBnmw6VjDlRdF4Pshfb+WE9E6XY1PoQ=="],
···
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+
+
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
"undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="],
+10 -8
index.html
···
-
<!DOCTYPE html>
+
<!doctype html>
<html>
-
<head>
-
<title>myGame</title>
-
</head>
-
<body style="overflow:hidden">
-
<script src="src/main.js" type="module"></script>
-
</body>
-
</html>
+
<head>
+
<title>myGame</title>
+
</head>
+
+
<body style="overflow: hidden">
+
<script src="src/main.ts" type="module"></script>
+
</body>
+
</html>
+
+18 -16
package.json
···
{
-
"name": "myGame",
-
"type": "module",
-
"scripts": {
-
"build": "vite build",
-
"dev": "vite",
-
"preview": "vite preview",
-
"zip": "bun run build && mkdir -p dist && zip -r dist/game.zip dist -x \"**/.DS_Store\""
-
},
-
"dependencies": {
-
"kaplay": "^3001.0.19"
-
},
-
"devDependencies": {
-
"@types/node": "^24.5.2",
-
"vite": "^7.1.7"
-
}
-
}
+
"name": "smackdown",
+
"type": "module",
+
"scripts": {
+
"build": "vite build",
+
"dev": "vite",
+
"preview": "vite preview",
+
"zip": "bun run build && mkdir -p dist && zip -r dist/game.zip dist -x \"**/.DS_Store\""
+
},
+
"dependencies": {
+
"@kaplayjs/crew": "^2.0.1",
+
"kaplay": "^3001.0.19"
+
},
+
"devDependencies": {
+
"@types/node": "^24.5.2",
+
"vite": "^7.1.7"
+
}
+
}
+
+111
src/confetti.ts
···
+
import type { KAPLAYCtx, GameObj } from "kaplay";
+
+
// Extend the KAPLAYCtx type to include our addConfetti method
+
declare module "kaplay" {
+
interface KAPLAYCtx {
+
addConfetti?: (pos: { x: number, y: number }) => GameObj[];
+
}
+
}
+
+
// Function to create a confetti effect at a position
+
export function addConfetti(k: KAPLAYCtx, pos: { x: number, y: number }) {
+
// Number of confetti particles
+
const PARTICLE_COUNT = 50;
+
+
// Confetti colors
+
const COLORS = [
+
[255, 0, 0], // Red
+
[0, 255, 0], // Green
+
[0, 0, 255], // Blue
+
[255, 255, 0], // Yellow
+
[255, 0, 255], // Magenta
+
[0, 255, 255], // Cyan
+
[255, 165, 0], // Orange
+
[128, 0, 128], // Purple
+
];
+
+
// Create particles
+
const particles: GameObj[] = [];
+
+
for (let i = 0; i < PARTICLE_COUNT; i++) {
+
// Random color
+
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
+
+
// Random size
+
const size = Math.random() * 8 + 2; // 2-10 pixels
+
+
// Random shape (circle or rect)
+
const isCircle = Math.random() > 0.5;
+
+
// Random velocity
+
const angle = Math.random() * Math.PI * 2;
+
const speed = Math.random() * 400 + 100; // 100-500 pixels per second
+
const vx = Math.cos(angle) * speed;
+
const vy = Math.sin(angle) * speed - 200; // Initial upward boost
+
+
// Random rotation speed
+
const rotSpeed = Math.random() * 10 - 5; // -5 to 5 radians per second
+
+
// Create particle
+
const particle = k.add([
+
isCircle ? k.circle(size / 2) : k.rect(size, size / 2),
+
k.pos(pos.x, pos.y),
+
k.color(...color),
+
k.anchor("center"),
+
k.rotate(Math.random() * 360), // Random initial rotation
+
k.opacity(1),
+
k.z(100), // Above most game elements
+
{
+
// Custom properties for movement
+
vx,
+
vy,
+
rotSpeed,
+
gravity: 980, // Gravity effect
+
lifespan: Math.random() * 1 + 1, // 1-2 seconds
+
fadeStart: 0.7, // When to start fading (0.7 = 70% of lifespan)
+
},
+
]);
+
+
// Update function for the particle
+
particle.onUpdate(() => {
+
// Update position based on velocity
+
particle.pos.x += particle.vx * k.dt();
+
particle.pos.y += particle.vy * k.dt();
+
+
// Apply gravity
+
particle.vy += particle.gravity * k.dt();
+
+
// Apply rotation
+
particle.angle += particle.rotSpeed * k.dt() * 60;
+
+
// Update lifespan
+
particle.lifespan -= k.dt();
+
+
// Fade out
+
if (particle.lifespan < particle.fadeStart) {
+
particle.opacity = Math.max(0, particle.lifespan / particle.fadeStart);
+
}
+
+
// Destroy when lifespan is over
+
if (particle.lifespan <= 0) {
+
particle.destroy();
+
}
+
});
+
+
particles.push(particle);
+
}
+
+
return particles;
+
}
+
+
// Component to add confetti method to the game context
+
export function confettiPlugin(k: KAPLAYCtx) {
+
return {
+
// Add the confetti function to the game context
+
addConfetti(pos: { x: number, y: number }) {
+
return addConfetti(k, pos);
+
}
+
};
+
}
+
+
export default confettiPlugin;
+213
src/enemy.ts
···
+
import type { KAPLAYCtx, Comp, GameObj } from "kaplay";
+
+
// Define enemy component type
+
export interface EnemyComp extends Comp {
+
health: number;
+
maxHealth: number;
+
speed: number;
+
damage(amount: number): void;
+
update(): void;
+
}
+
+
export function enemy(k: KAPLAYCtx, target: GameObj) {
+
// Use closed local variables for internal data
+
let maxHealth = 100;
+
let health = maxHealth;
+
let speed = 100;
+
let isHit = false;
+
let isDying = false;
+
let healthBar: GameObj | null = null;
+
let healthBarBg: GameObj | null = null;
+
let lastDamageTime = 0;
+
const DAMAGE_COOLDOWN = 0.5; // seconds
+
+
return {
+
id: "enemy",
+
require: ["pos", "sprite", "area", "body"],
+
+
// Exposed properties
+
health,
+
maxHealth,
+
speed,
+
+
// Damage the enemy
+
damage(this: GameObj, amount: number) {
+
if (isDying) return;
+
+
health -= amount;
+
console.log(`Enemy damaged: ${amount}, health: ${health}`);
+
+
// Flash red when hit
+
isHit = true;
+
this.color = k.rgb(255, 0, 0);
+
+
// Reset color after a short time
+
k.wait(0.1, () => {
+
this.color = k.rgb();
+
isHit = false;
+
});
+
+
// Update health bar
+
if (healthBar) {
+
const healthPercent = Math.max(0, health / maxHealth);
+
healthBar.width = 40 * healthPercent;
+
}
+
+
// Check if enemy is dead
+
if (health <= 0 && !isDying) {
+
isDying = true;
+
this.die();
+
}
+
},
+
+
// Enemy death
+
die(this: GameObj) {
+
// Add confetti effect only (no kaboom)
+
if (k.addConfetti) {
+
k.addConfetti(this.pos);
+
}
+
+
// Remove health bar
+
if (healthBarBg) healthBarBg.destroy();
+
if (healthBar) healthBar.destroy();
+
+
// Scale down and fade out
+
k.tween(
+
this.scale.x,
+
0,
+
0.5,
+
(v) => {
+
this.scale.x = v;
+
this.scale.y = v;
+
},
+
k.easings.easeInQuad,
+
);
+
+
k.tween(
+
1,
+
0,
+
0.5,
+
(v) => {
+
this.opacity = v;
+
},
+
k.easings.easeInQuad,
+
);
+
+
// Destroy after animation completes
+
k.wait(0.5, () => {
+
this.destroy();
+
});
+
},
+
+
// Add method runs when the component is added to a game object
+
add(this: GameObj) {
+
// Create health bar background (gray)
+
healthBarBg = k.add([
+
k.rect(40, 5),
+
k.pos(this.pos.x - 20, this.pos.y - 30),
+
k.color(100, 100, 100),
+
k.z(0.9),
+
]);
+
+
// Create health bar (red)
+
healthBar = k.add([
+
k.rect(40, 5),
+
k.pos(this.pos.x - 20, this.pos.y - 30),
+
k.color(255, 0, 0),
+
k.z(1),
+
]);
+
+
// Handle collisions with sword
+
this.onCollide("sword", (sword) => {
+
if (sword.isAttacking && !isHit) {
+
// Sword does 35 damage (35% of enemy health)
+
this.damage(35);
+
}
+
});
+
},
+
+
// Runs every frame
+
update(this: GameObj) {
+
if (isDying) return;
+
+
// Move toward target
+
const dir = k.vec2(target.pos.x - this.pos.x, target.pos.y - this.pos.y);
+
+
// Normalize direction vector
+
const dist = Math.sqrt(dir.x * dir.x + dir.y * dir.y);
+
+
// Only move if not too close to target
+
if (dist > 50) {
+
const normalizedDir = {
+
x: dir.x / dist,
+
y: dir.y / dist,
+
};
+
+
// Move toward target
+
this.move(normalizedDir.x * speed, normalizedDir.y * speed);
+
+
// Flip sprite based on movement direction
+
if (normalizedDir.x !== 0) {
+
this.flipX = normalizedDir.x < 0;
+
}
+
}
+
+
// Check for collision with player and apply damage if in contact
+
// Only apply damage if cooldown has passed
+
if (k.time() - lastDamageTime > DAMAGE_COOLDOWN) {
+
const playerObj = k.get("player")[0];
+
if (playerObj && this.isColliding(playerObj)) {
+
lastDamageTime = k.time();
+
+
// Damage player
+
if (playerObj.damage) {
+
playerObj.damage(5);
+
}
+
+
// Knockback effect
+
const knockback = 200;
+
const knockbackDir = k.vec2(
+
playerObj.pos.x - this.pos.x,
+
playerObj.pos.y - this.pos.y
+
).unit();
+
+
playerObj.move(knockbackDir.x * knockback, knockbackDir.y * knockback);
+
}
+
}
+
+
// Update health bar position to follow enemy
+
if (healthBar && healthBarBg) {
+
healthBarBg.pos.x = this.pos.x - 20;
+
healthBarBg.pos.y = this.pos.y - 30;
+
+
healthBar.pos.x = this.pos.x - 20;
+
healthBar.pos.y = this.pos.y - 30;
+
}
+
},
+
+
// Cleanup when destroyed
+
destroy() {
+
if (healthBar) healthBar.destroy();
+
if (healthBarBg) healthBarBg.destroy();
+
},
+
};
+
}
+
+
// Function to create an enemy
+
export function makeEnemy(k: KAPLAYCtx, target: GameObj, x: number, y: number) {
+
// Create enemy
+
const newEnemy = k.add([
+
k.sprite("bean"),
+
k.pos(x, y),
+
k.scale(1),
+
k.anchor("center"),
+
k.area(),
+
k.body(),
+
enemy(k, target),
+
"enemy", // Add tag for collision detection
+
]);
+
+
return newEnemy;
+
}
+
+
export default enemy;
-11
src/main.js
···
-
import kaplay from "kaplay";
-
// import "kaplay/global"; // uncomment if you want to use without the k. prefix
-
-
const k = kaplay();
-
-
k.loadRoot("./"); // A good idea for Itch.io publishing later
-
k.loadSprite("bean", "sprites/bean.png");
-
-
k.add([k.pos(120, 80), k.sprite("bean")]);
-
-
k.onClick(() => k.addKaboom(k.mousePos()));
+156
src/main.ts
···
+
import { crew } from "@kaplayjs/crew";
+
import kaplay from "kaplay";
+
+
import player from "./player";
+
import { makeEnemy, type EnemyComp } from "./enemy";
+
import confettiPlugin, { addConfetti } from "./confetti";
+
+
const k = kaplay({ plugins: [crew] });
+
k.loadRoot("./"); // A good idea for Itch.io publishing later
+
k.loadCrew("sprite", "glady-o");
+
k.loadCrew("sprite", "sword-o");
+
k.loadCrew("sprite", "bean"); // Using bean sprite for enemies
+
+
// Add confetti plugin to the game context
+
const confetti = confettiPlugin(k);
+
k.addConfetti = confetti.addConfetti;
+
+
k.setGravity(1600);
+
+
// Create ground
+
const ground = k.add([
+
k.rect(k.width(), 48),
+
k.pos(0, k.height() - 48),
+
k.outline(4),
+
k.area(),
+
k.body({ isStatic: true }),
+
k.color(127, 200, 255),
+
]);
+
+
// Create player object with components
+
const playerObj = k.add([
+
k.pos(120, 500),
+
k.sprite("glady-o"),
+
k.body(),
+
k.area(),
+
player(k),
+
"player", // Add tag for collision detection
+
]);
+
+
// Enemy spawning variables
+
let enemies: any[] = [];
+
let initialMaxEnemies = 5;
+
let maxEnemies = initialMaxEnemies;
+
let initialSpawnInterval = 3; // seconds
+
let spawnInterval = initialSpawnInterval;
+
let gameTime = 0; // Track game time in seconds
+
let difficultyLevel = 1;
+
let spawnLoopId: any = null;
+
+
// Difficulty scaling
+
function updateDifficulty() {
+
gameTime += 1; // Increment game time by 1 second
+
+
// Every 30 seconds, increase difficulty
+
if (gameTime % 30 === 0) {
+
difficultyLevel += 1;
+
+
// Increase max enemies (cap at 15)
+
maxEnemies = Math.min(initialMaxEnemies + difficultyLevel, 15);
+
+
// Decrease spawn interval (minimum 0.5 seconds)
+
spawnInterval = Math.max(initialSpawnInterval - (difficultyLevel * 0.3), 0.5);
+
+
console.log(`Difficulty increased to level ${difficultyLevel}. Max enemies: ${maxEnemies}, Spawn interval: ${spawnInterval}s`);
+
+
// Cancel previous spawn loop and start a new one with updated interval
+
if (spawnLoopId !== null) {
+
k.cancel(spawnLoopId);
+
}
+
spawnLoopId = k.loop(spawnInterval, spawnEnemy);
+
+
// Visual feedback for difficulty increase
+
const screenCenter = k.vec2(k.width() / 2, k.height() / 2);
+
if (k.addConfetti) {
+
k.addConfetti(screenCenter);
+
}
+
+
// Add difficulty level text
+
const levelText = k.add([
+
k.text(`Difficulty Level ${difficultyLevel}!`, { size: 32 }),
+
k.pos(screenCenter),
+
k.anchor("center"),
+
k.color(255, 255, 255),
+
k.outline(2, k.rgb(0, 0, 0)),
+
k.z(100),
+
k.opacity(1),
+
]);
+
+
// Fade out and destroy the text
+
k.tween(
+
1,
+
0,
+
2,
+
(v) => {
+
levelText.opacity = v;
+
},
+
k.easings.easeInQuad,
+
);
+
+
k.wait(2, () => {
+
levelText.destroy();
+
});
+
}
+
}
+
+
// Start difficulty scaling
+
k.loop(1, updateDifficulty);
+
+
// Spawn an enemy at a random position
+
function spawnEnemy() {
+
// Don't spawn if we already have max enemies
+
if (enemies.length >= maxEnemies) return;
+
+
// Random position at the edges of the screen
+
const side = Math.floor(Math.random() * 4); // 0: top, 1: right, 2: bottom, 3: left
+
let x, y;
+
+
switch (side) {
+
case 0: // top
+
x = Math.random() * k.width();
+
y = -50;
+
break;
+
case 1: // right
+
x = k.width() + 50;
+
y = Math.random() * k.height();
+
break;
+
case 2: // bottom
+
x = Math.random() * k.width();
+
y = k.height() + 50;
+
break;
+
case 3: // left
+
x = -50;
+
y = Math.random() * k.height();
+
break;
+
}
+
+
// Create enemy using the makeEnemy function
+
const newEnemy = makeEnemy(k, playerObj, x, y);
+
enemies.push(newEnemy);
+
+
// Remove from array when destroyed
+
newEnemy.on("destroy", () => {
+
enemies = enemies.filter((e) => e !== newEnemy);
+
});
+
}
+
+
// Start spawning enemies
+
spawnLoopId = k.loop(spawnInterval, spawnEnemy);
+
+
// Game loop
+
k.onUpdate(() => {
+
// Update enemy list (remove destroyed enemies)
+
enemies = enemies.filter((enemy) => enemy.exists());
+
});
+
+
console.log(typeof k);
+469
src/player.ts
···
+
import type { KAPLAYCtx, Comp, GameObj } from "kaplay";
+
import { Vec2 } from "kaplay";
+
+
// Define player component type
+
interface PlayerComp extends Comp {
+
speed: number;
+
health: number;
+
maxHealth: number;
+
damage(amount: number): void;
+
attack(): void;
+
update(): void;
+
}
+
+
function player(k: KAPLAYCtx): PlayerComp {
+
// Use closed local variable for internal data
+
let speed = 500;
+
let jumpForce = 600;
+
let maxHealth = 100;
+
let health = maxHealth;
+
let isAttacking = false;
+
let isHit = false;
+
let sword: GameObj | null = null;
+
let arrowPoints: GameObj[] = [];
+
let healthBar: GameObj | null = null;
+
let healthBarBg: GameObj | null = null;
+
const ARROW_SEGMENTS = 8; // Number of segments in the arrow
+
const MAX_ATTACK_DISTANCE = 500; // Maximum distance for attacks and kaboom
+
let attackRangeCircle: GameObj | null = null; // Visual indicator of attack range
+
+
// Helper function to convert radians to degrees
+
const radToDeg = (rad: number) => (rad * 180) / Math.PI;
+
+
// Helper function to create a bezier curve point
+
const bezierPoint = (
+
t: number,
+
p0: number,
+
p1: number,
+
p2: number,
+
p3: number,
+
) => {
+
const mt = 1 - t;
+
return (
+
mt * mt * mt * p0 +
+
3 * mt * mt * t * p1 +
+
3 * mt * t * t * p2 +
+
t * t * t * p3
+
);
+
};
+
+
// Helper function to clamp a point to a circle
+
const clampToCircle = (
+
center: { x: number; y: number },
+
point: { x: number; y: number },
+
radius: number,
+
): Vec2 => {
+
const dx = point.x - center.x;
+
const dy = point.y - center.y;
+
const distance = Math.sqrt(dx * dx + dy * dy);
+
+
if (distance <= radius) {
+
return k.vec2(point.x, point.y); // Point is already inside circle
+
}
+
+
// Calculate the point on the circle's edge
+
const ratio = radius / distance;
+
return k.vec2(center.x + dx * ratio, center.y + dy * ratio);
+
};
+
+
return {
+
id: "player",
+
require: ["body", "area", "pos"],
+
+
// Exposed properties
+
speed,
+
health,
+
maxHealth,
+
+
// Take damage
+
damage(this: GameObj, amount: number) {
+
if (isHit) return; // Prevent taking damage too quickly
+
+
health -= amount;
+
+
// Flash red when hit
+
isHit = true;
+
this.color = k.rgb(255, 0, 0);
+
+
// Reset color after a short time
+
k.wait(0.1, () => {
+
this.color = k.rgb();
+
isHit = false;
+
});
+
+
// Update health bar
+
if (healthBar) {
+
const healthPercent = Math.max(0, health / maxHealth);
+
healthBar.width = 60 * healthPercent;
+
}
+
+
// Check if player is dead
+
if (health <= 0) {
+
// Game over logic here
+
k.addKaboom(this.pos);
+
k.shake(20);
+
}
+
},
+
+
// Runs when the obj is added to scene
+
add(this: GameObj) {
+
// Create health bar background (gray)
+
healthBarBg = k.add([
+
k.rect(60, 8),
+
k.pos(this.pos.x - 30, this.pos.y - 40),
+
k.color(100, 100, 100),
+
k.z(0.9),
+
]);
+
+
// Create health bar (red)
+
healthBar = k.add([
+
k.rect(60, 8),
+
k.pos(this.pos.x - 30, this.pos.y - 40),
+
k.color(255, 0, 0),
+
k.z(1),
+
]);
+
+
// Create sword attached to player
+
sword = k.add([
+
k.sprite("sword-o"),
+
k.pos(this.pos.x + 30, this.pos.y - 10),
+
k.rotate(45), // Hold at 45 degrees
+
k.anchor("center"),
+
k.scale(0.7),
+
k.area(), // Add area for collision detection
+
k.z(1), // Make sure sword is in front of player
+
"sword", // Add tag for collision detection
+
{
+
isAttacking: false, // Custom property to track attack state
+
},
+
]);
+
+
// Create attack range indicator (semi-transparent circle)
+
attackRangeCircle = k.add([
+
k.circle(MAX_ATTACK_DISTANCE),
+
k.pos(this.pos.x, this.pos.y),
+
k.color(255, 255, 255),
+
k.opacity(0.1), // Very subtle
+
k.z(0.1), // Behind everything
+
]);
+
+
// Create arrow segments
+
for (let i = 0; i < ARROW_SEGMENTS; i++) {
+
// Create segment with white outline
+
const segment = k.add([
+
k.circle(3), // Initial size, will be scaled based on distance
+
k.pos(this.pos.x, this.pos.y - 30), // Start from player's head
+
k.color(255, 0, 0), // Red fill
+
k.outline(2, k.rgb(255, 255, 255)), // White outline
+
k.z(0.5),
+
]);
+
arrowPoints.push(segment);
+
}
+
+
// Create arrow head (using a circle for now)
+
const arrowHead = k.add([
+
k.circle(6), // Larger circle for the arrow head
+
k.pos(this.pos.x, this.pos.y - 30),
+
k.color(255, 0, 0), // Red fill
+
k.outline(2, k.rgb(255, 255, 255)), // White outline
+
k.z(0.5),
+
]);
+
arrowPoints.push(arrowHead);
+
+
// Jump with space or up arrow
+
this.onKeyPress(["space", "up", "w"], () => {
+
if (this.isGrounded()) {
+
this.jump(jumpForce);
+
}
+
});
+
+
// Attack with X key
+
this.onKeyPress("x", () => {
+
this.attack();
+
});
+
+
// Attack, kaboom and shake on click
+
k.onClick(() => {
+
// Attack with sword
+
this.attack();
+
+
// Get mouse position and clamp it to the attack range
+
const mousePos = k.mousePos();
+
const clampedPos = clampToCircle(
+
this.pos,
+
mousePos,
+
MAX_ATTACK_DISTANCE,
+
);
+
+
console.log("Creating explosion at", clampedPos.x, clampedPos.y);
+
+
// Create visual explosion effect
+
k.addKaboom(clampedPos);
+
+
// Create explosion area for damage
+
const explosionRadius = 120;
+
const explosion = k.add([
+
k.circle(explosionRadius),
+
k.pos(clampedPos),
+
k.color(255, 0, 0), // Semi-transparent red
+
k.area(),
+
k.anchor("center"),
+
k.opacity(0.3), // Add opacity component
+
"explosion",
+
]);
+
+
// Destroy explosion after a short time
+
k.wait(0.1, () => {
+
explosion.destroy();
+
});
+
+
// Manually check for enemies in range
+
const enemies = k.get("enemy");
+
enemies.forEach((enemy) => {
+
const dist = k.vec2(enemy.pos).dist(clampedPos);
+
if (dist < explosionRadius) {
+
// Calculate damage based on distance from center
+
// At center (dist = 0): 70 damage (70% of enemy health)
+
// At edge (dist = explosionRadius): 20 damage (20% of enemy health)
+
const damagePercent = 0.7 - (0.5 * dist) / explosionRadius;
+
const damage = Math.floor(100 * damagePercent); // 100 is enemy max health
+
+
console.log(
+
`Direct damage to enemy: ${damage}, distance: ${dist}, percent: ${damagePercent}`,
+
);
+
// Add type assertion to tell TypeScript that enemy has a damage method
+
(enemy as any).damage(damage);
+
}
+
});
+
+
// Shake the screen
+
k.shake(10);
+
});
+
},
+
+
// Attack method
+
attack(this: GameObj) {
+
if (isAttacking) return;
+
+
isAttacking = true;
+
+
if (sword) {
+
// Set sword to attacking state for collision detection
+
sword.isAttacking = true;
+
+
// Store original angle
+
const originalAngle = this.flipX ? -30 : 30;
+
+
// Animate sword swing
+
const direction = this.flipX ? -1 : 1;
+
const endAngle = direction > 0 ? 90 : -90;
+
+
// Tween the sword rotation
+
k.tween(
+
sword.angle,
+
endAngle,
+
0.15,
+
(val) => (sword!.angle = val),
+
k.easings.easeInOutQuad,
+
);
+
+
// Return sword to original position
+
k.wait(0.15, () => {
+
if (sword) {
+
k.tween(
+
sword.angle,
+
originalAngle,
+
0.15,
+
(val) => (sword!.angle = val),
+
k.easings.easeOutQuad,
+
);
+
}
+
});
+
+
// End attack state
+
k.wait(0.3, () => {
+
isAttacking = false;
+
if (sword) {
+
sword.isAttacking = false;
+
}
+
});
+
}
+
},
+
+
// Runs every frame
+
update(this: GameObj) {
+
// Left movement (left arrow or A key)
+
if (k.isKeyDown(["left", "a"])) {
+
this.move(-speed, 0);
+
this.flipX = true;
+
}
+
+
// Right movement (right arrow or D key)
+
if (k.isKeyDown(["right", "d"])) {
+
this.move(speed, 0);
+
this.flipX = false;
+
}
+
+
// Update sword position to follow player
+
if (sword) {
+
const xOffset = this.flipX ? 10 : 60;
+
const yOffset = 60; // Slightly above center
+
sword.pos.x = this.pos.x + xOffset;
+
sword.pos.y = this.pos.y + yOffset;
+
+
// Update sword angle and flip based on player direction (when not attacking)
+
if (!isAttacking) {
+
sword.flipX = this.flipX;
+
sword.angle = this.flipX ? -30 : 30; // Mirror angle when facing left
+
}
+
}
+
+
// Update health bar position to follow player
+
if (healthBar && healthBarBg) {
+
healthBarBg.pos.x = this.pos.x + 5;
+
healthBarBg.pos.y = this.pos.y - 40;
+
+
healthBar.pos.x = this.pos.x + 5;
+
healthBar.pos.y = this.pos.y - 40;
+
}
+
+
// Update attack range circle to follow player
+
if (attackRangeCircle) {
+
attackRangeCircle.pos = this.pos;
+
}
+
+
// Update arrow to create an arc from player to mouse
+
if (arrowPoints.length > 0) {
+
const mousePos = k.mousePos();
+
const startPos = { x: this.pos.x + 40, y: this.pos.y }; // Player's head
+
+
// Clamp mouse position to maximum attack range
+
const clampedMousePos = clampToCircle(
+
this.pos,
+
mousePos,
+
MAX_ATTACK_DISTANCE,
+
);
+
+
// Calculate horizontal distance from player to mouse
+
const horizontalDist = clampedMousePos.x - startPos.x;
+
+
// Calculate total distance from player to mouse
+
const dist = Math.sqrt(
+
Math.pow(clampedMousePos.x - startPos.x, 2) +
+
Math.pow(clampedMousePos.y - startPos.y, 2),
+
);
+
+
// Determine arc direction based on horizontal position
+
// Use a smooth transition near the center
+
const centerThreshold = 50; // Distance from center where arc is minimal
+
let arcDirection = 0;
+
+
if (Math.abs(horizontalDist) < centerThreshold) {
+
// Smooth transition near center
+
arcDirection = -(horizontalDist / centerThreshold); // Will be between -1 and 1
+
} else {
+
// Full curve away from center
+
arcDirection = horizontalDist > 0 ? -1 : 1;
+
}
+
+
// Calculate arc height based on distance and direction
+
// Reduce arc height when close to center
+
const maxArcHeight = 100;
+
const arcHeightFactor = Math.min(Math.abs(arcDirection), 1); // Between 0 and 1
+
const arcHeight = Math.min(dist * 0.5, maxArcHeight) * arcHeightFactor;
+
+
// Calculate perpendicular direction for control points
+
const dirX = clampedMousePos.x - startPos.x;
+
const dirY = clampedMousePos.y - startPos.y;
+
const len = Math.sqrt(dirX * dirX + dirY * dirY);
+
const perpX = (-dirY / len) * arcDirection;
+
const perpY = (dirX / len) * arcDirection;
+
+
// Control points for the bezier curve
+
const ctrl1 = {
+
x: startPos.x + dirX * 0.25 + perpX * arcHeight,
+
y: startPos.y + dirY * 0.25 + perpY * arcHeight,
+
};
+
+
const ctrl2 = {
+
x: startPos.x + dirX * 0.75 + perpX * arcHeight,
+
y: startPos.y + dirY * 0.75 + perpY * arcHeight,
+
};
+
+
// Position each segment along the bezier curve
+
for (let i = 0; i < ARROW_SEGMENTS; i++) {
+
const t = i / (ARROW_SEGMENTS - 1);
+
const x = bezierPoint(
+
t,
+
startPos.x,
+
ctrl1.x,
+
ctrl2.x,
+
clampedMousePos.x,
+
);
+
const y = bezierPoint(
+
t,
+
startPos.y,
+
ctrl1.y,
+
ctrl2.y,
+
clampedMousePos.y,
+
);
+
+
// Calculate segment position along the curve
+
arrowPoints[i].pos.x = x;
+
arrowPoints[i].pos.y = y;
+
+
// Scale circle size based on distance from start
+
// Segments get progressively larger toward the end
+
const segmentDist = i / (ARROW_SEGMENTS - 1); // 0 to 1
+
const minSize = 2;
+
const maxSize = 5;
+
const size = minSize + segmentDist * (maxSize - minSize);
+
+
// Apply scale
+
if (arrowPoints[i].scale) {
+
arrowPoints[i].scale.x = size / 3; // Divide by default size (3)
+
arrowPoints[i].scale.y = size / 3;
+
}
+
}
+
+
// Position arrow head at the end of the curve and make it larger
+
const arrowHead = arrowPoints[arrowPoints.length - 1];
+
arrowHead.pos.x = clampedMousePos.x;
+
arrowHead.pos.y = clampedMousePos.y;
+
+
// Make arrow head larger
+
if (arrowHead.scale) {
+
arrowHead.scale.x = 3;
+
arrowHead.scale.y = 3;
+
}
+
}
+
},
+
+
// Cleanup when destroyed
+
destroy() {
+
if (sword) {
+
sword.destroy();
+
}
+
+
if (attackRangeCircle) {
+
attackRangeCircle.destroy();
+
}
+
+
if (healthBar) {
+
healthBar.destroy();
+
}
+
+
if (healthBarBg) {
+
healthBarBg.destroy();
+
}
+
+
// Destroy all arrow segments
+
arrowPoints.forEach((segment) => {
+
segment.destroy();
+
});
+
arrowPoints = [];
+
},
+
};
+
}
+
+
export default player;