···
1
+
import type { KAPLAYCtx, Comp, GameObj } from "kaplay";
2
+
import { Vec2 } from "kaplay";
4
+
// Define player component type
5
+
interface PlayerComp extends Comp {
9
+
damage(amount: number): void;
14
+
function player(k: KAPLAYCtx): PlayerComp {
15
+
// Use closed local variable for internal data
17
+
let jumpForce = 600;
18
+
let maxHealth = 100;
19
+
let health = maxHealth;
20
+
let isAttacking = 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
30
+
// Helper function to convert radians to degrees
31
+
const radToDeg = (rad: number) => (rad * 180) / Math.PI;
33
+
// Helper function to create a bezier curve point
34
+
const bezierPoint = (
44
+
3 * mt * mt * t * p1 +
45
+
3 * mt * t * t * p2 +
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 },
56
+
const dx = point.x - center.x;
57
+
const dy = point.y - center.y;
58
+
const distance = Math.sqrt(dx * dx + dy * dy);
60
+
if (distance <= radius) {
61
+
return k.vec2(point.x, point.y); // Point is already inside circle
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);
71
+
require: ["body", "area", "pos"],
73
+
// Exposed properties
79
+
damage(this: GameObj, amount: number) {
80
+
if (isHit) return; // Prevent taking damage too quickly
84
+
// Flash red when hit
86
+
this.color = k.rgb(255, 0, 0);
88
+
// Reset color after a short time
90
+
this.color = k.rgb();
94
+
// Update health bar
96
+
const healthPercent = Math.max(0, health / maxHealth);
97
+
healthBar.width = 60 * healthPercent;
100
+
// Check if player is dead
102
+
// Game over logic here
103
+
k.addKaboom(this.pos);
108
+
// Runs when the obj is added to scene
109
+
add(this: GameObj) {
110
+
// Create health bar background (gray)
111
+
healthBarBg = k.add([
113
+
k.pos(this.pos.x - 30, this.pos.y - 40),
114
+
k.color(100, 100, 100),
118
+
// Create health bar (red)
119
+
healthBar = k.add([
121
+
k.pos(this.pos.x - 30, this.pos.y - 40),
122
+
k.color(255, 0, 0),
126
+
// Create sword attached to player
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"),
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
137
+
isAttacking: false, // Custom property to track attack state
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
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
160
+
arrowPoints.push(segment);
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
171
+
arrowPoints.push(arrowHead);
173
+
// Jump with space or up arrow
174
+
this.onKeyPress(["space", "up", "w"], () => {
175
+
if (this.isGrounded()) {
176
+
this.jump(jumpForce);
180
+
// Attack with X key
181
+
this.onKeyPress("x", () => {
185
+
// Attack, kaboom and shake on click
187
+
// Attack with sword
190
+
// Get mouse position and clamp it to the attack range
191
+
const mousePos = k.mousePos();
192
+
const clampedPos = clampToCircle(
195
+
MAX_ATTACK_DISTANCE,
198
+
console.log("Creating explosion at", clampedPos.x, clampedPos.y);
200
+
// Create visual explosion effect
201
+
k.addKaboom(clampedPos);
203
+
// Create explosion area for damage
204
+
const explosionRadius = 120;
205
+
const explosion = k.add([
206
+
k.circle(explosionRadius),
208
+
k.color(255, 0, 0), // Semi-transparent red
210
+
k.anchor("center"),
211
+
k.opacity(0.3), // Add opacity component
215
+
// Destroy explosion after a short time
216
+
k.wait(0.1, () => {
217
+
explosion.destroy();
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
232
+
`Direct damage to enemy: ${damage}, distance: ${dist}, percent: ${damagePercent}`,
234
+
// Add type assertion to tell TypeScript that enemy has a damage method
235
+
(enemy as any).damage(damage);
239
+
// Shake the screen
245
+
attack(this: GameObj) {
246
+
if (isAttacking) return;
248
+
isAttacking = true;
251
+
// Set sword to attacking state for collision detection
252
+
sword.isAttacking = true;
254
+
// Store original angle
255
+
const originalAngle = this.flipX ? -30 : 30;
257
+
// Animate sword swing
258
+
const direction = this.flipX ? -1 : 1;
259
+
const endAngle = direction > 0 ? 90 : -90;
261
+
// Tween the sword rotation
266
+
(val) => (sword!.angle = val),
267
+
k.easings.easeInOutQuad,
270
+
// Return sword to original position
271
+
k.wait(0.15, () => {
277
+
(val) => (sword!.angle = val),
278
+
k.easings.easeOutQuad,
283
+
// End attack state
284
+
k.wait(0.3, () => {
285
+
isAttacking = false;
287
+
sword.isAttacking = false;
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);
301
+
// Right movement (right arrow or D key)
302
+
if (k.isKeyDown(["right", "d"])) {
303
+
this.move(speed, 0);
304
+
this.flipX = false;
307
+
// Update sword position to follow player
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;
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
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;
326
+
healthBar.pos.x = this.pos.x + 5;
327
+
healthBar.pos.y = this.pos.y - 40;
330
+
// Update attack range circle to follow player
331
+
if (attackRangeCircle) {
332
+
attackRangeCircle.pos = this.pos;
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
340
+
// Clamp mouse position to maximum attack range
341
+
const clampedMousePos = clampToCircle(
344
+
MAX_ATTACK_DISTANCE,
347
+
// Calculate horizontal distance from player to mouse
348
+
const horizontalDist = clampedMousePos.x - startPos.x;
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),
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;
361
+
if (Math.abs(horizontalDist) < centerThreshold) {
362
+
// Smooth transition near center
363
+
arcDirection = -(horizontalDist / centerThreshold); // Will be between -1 and 1
365
+
// Full curve away from center
366
+
arcDirection = horizontalDist > 0 ? -1 : 1;
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;
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;
382
+
// Control points for the bezier curve
384
+
x: startPos.x + dirX * 0.25 + perpX * arcHeight,
385
+
y: startPos.y + dirY * 0.25 + perpY * arcHeight,
389
+
x: startPos.x + dirX * 0.75 + perpX * arcHeight,
390
+
y: startPos.y + dirY * 0.75 + perpY * arcHeight,
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(
403
+
const y = bezierPoint(
411
+
// Calculate segment position along the curve
412
+
arrowPoints[i].pos.x = x;
413
+
arrowPoints[i].pos.y = y;
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
420
+
const size = minSize + segmentDist * (maxSize - minSize);
423
+
if (arrowPoints[i].scale) {
424
+
arrowPoints[i].scale.x = size / 3; // Divide by default size (3)
425
+
arrowPoints[i].scale.y = size / 3;
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;
434
+
// Make arrow head larger
435
+
if (arrowHead.scale) {
436
+
arrowHead.scale.x = 3;
437
+
arrowHead.scale.y = 3;
442
+
// Cleanup when destroyed
448
+
if (attackRangeCircle) {
449
+
attackRangeCircle.destroy();
453
+
healthBar.destroy();
457
+
healthBarBg.destroy();
460
+
// Destroy all arrow segments
461
+
arrowPoints.forEach((segment) => {
469
+
export default player;