about summary refs log tree commit diff stats
path: root/html/plains
diff options
context:
space:
mode:
Diffstat (limited to 'html/plains')
-rw-r--r--html/plains/enemies.js402
-rw-r--r--html/plains/game.js175
-rw-r--r--html/plains/index.html1
3 files changed, 568 insertions, 10 deletions
diff --git a/html/plains/enemies.js b/html/plains/enemies.js
new file mode 100644
index 0000000..c0e3daa
--- /dev/null
+++ b/html/plains/enemies.js
@@ -0,0 +1,402 @@
+// ============= Enemy System =============
+const blueShades = [
+    'rgb(0, 0, 255)',    // Bright blue
+    'rgb(0, 0, 200)',    // Medium blue
+    'rgb(0, 0, 150)',    // Darker blue
+    'rgb(0, 0, 100)',    // Even darker blue
+    'rgb(0, 0, 50)'      // Very dark blue
+];
+
+const generateEnemies = (villagers, collisionMap) => {
+    const enemies = [];
+    const gridSize = CONFIG.display.grid.size;
+    const occupiedCells = new Set([...collisionMap.keys()]);
+    
+    villagers.forEach(villager => {
+        if (villager.status === 'rescued') return;
+        
+        // Generate 2-5 enemies per villager
+        const numEnemies = 2 + Math.floor(Math.random() * 4);
+        
+        for (let i = 0; i < numEnemies; i++) {
+            // Place within 2-3 cells of villager
+            const radius = 2 + Math.floor(Math.random());
+            const angle = Math.random() * Math.PI * 2;
+            
+            const cellX = villager.cellX + Math.floor(Math.cos(angle) * radius);
+            const cellY = villager.cellY + Math.floor(Math.sin(angle) * radius);
+            const cellKey = `${cellX},${cellY}`;
+            
+            // Skip if cell is occupied
+            if (occupiedCells.has(cellKey)) {
+                continue;
+            }
+            
+            // Generate random color between orange and dark red
+            const red = 150 + Math.floor(Math.random() * 105);  // 150-255
+            const green = Math.floor(Math.random() * 100);      // 0-100
+            const blue = 0;
+            
+            enemies.push({
+                x: (cellX * gridSize) + (gridSize / 2),
+                y: (cellY * gridSize) + (gridSize / 2),
+                color: `rgb(${red}, ${green}, ${blue})`,
+                size: CONFIG.enemies.size.min + Math.random() * (CONFIG.enemies.size.max - CONFIG.enemies.size.min),
+                targetVillager: villager,
+                patrolAngle: Math.random() * Math.PI * 2,
+                isChasing: false,
+                hp: 2 + Math.floor(Math.random() * 9),
+                stunned: false,
+                stunEndTime: 0,
+                attacking: false,
+                attackCooldown: false,
+                attackCooldownUntil: 0,
+                knockback: {
+                    active: false,
+                    startX: 0,
+                    startY: 0,
+                    targetX: 0,
+                    targetY: 0,
+                    startTime: 0,
+                    duration: 300
+                }
+            });
+            
+            occupiedCells.add(cellKey);
+        }
+    });
+    
+    return enemies;
+};
+
+const handleEnemyDamage = (enemy, damage, knockbackForce = 0, angle = 0) => {
+    const gridSize = CONFIG.display.grid.size;
+    enemy.hp -= damage;
+    
+    if (damage > 0 && enemy.hp <= 0) {
+        // Create death particles
+        const numParticles = 15 + Math.floor(Math.random() * 10);
+        for (let i = 0; i < numParticles; i++) {
+            const angle = (i / numParticles) * Math.PI * 2;
+            const speed = 2 + Math.random() * 3;
+            state.particles.push({
+                x: enemy.x,
+                y: enemy.y,
+                dx: Math.cos(angle) * speed,
+                dy: Math.sin(angle) * speed,
+                size: enemy.size * (0.1 + Math.random() * 0.2),
+                color: enemy.color,
+                lifetime: 1000,
+                createdAt: animationTime
+            });
+        }
+    }
+    
+    return damage > 0 && enemy.hp <= 0;
+};
+
+const updateEnemies = (enemies, deltaTime) => {
+    const gridSize = CONFIG.display.grid.size;
+    const aggroRange = gridSize * CONFIG.enemies.chase.range;
+
+    // Check for weapon collisions
+    enemies.forEach(enemy => {
+        // Check bubble collisions
+        state.player.bubbles.forEach((bubble, bubbleIndex) => {
+            const dx = enemy.x - bubble.x;
+            const dy = enemy.y - bubble.y;
+            const distance = Math.sqrt(dx * dx + dy * dy);
+            
+            if (distance < enemy.size + CONFIG.bubble.size) {
+                // Remove the bubble
+                state.player.bubbles.splice(bubbleIndex, 1);
+                
+                // Normalize the direction vector
+                const length = Math.sqrt(dx * dx + dy * dy);
+                const dirX = dx / length;
+                const dirY = dy / length;
+                
+                // Set up knockback animation
+                enemy.knockback = {
+                    active: true,
+                    startX: enemy.x,
+                    startY: enemy.y,
+                    targetX: enemy.x + dirX * CONFIG.display.grid.size,
+                    targetY: enemy.y + dirY * CONFIG.display.grid.size,
+                    startTime: animationTime,
+                    duration: 300
+                };
+                
+                // Stun the enemy
+                enemy.stunned = true;
+                enemy.stunEndTime = animationTime + 500; // Stun for 500ms
+            }
+        });
+        
+        // Check sword collision
+        if (state.player.isSwinging && state.player.equipment === 'sword') {
+            const swordTip = {
+                x: state.player.x + Math.cos(state.player.swordAngle) * CONFIG.sword.length,
+                y: state.player.y + Math.sin(state.player.swordAngle) * CONFIG.sword.length
+            };
+            
+            const dx = enemy.x - swordTip.x;
+            const dy = enemy.y - swordTip.y;
+            const distance = Math.sqrt(dx * dx + dy * dy);
+            
+            if (distance < enemy.size + 10) { // 10px sword hit tolerance
+                handleEnemyDamage(enemy, 1);
+                enemy.stunned = true;
+                enemy.stunEndTime = animationTime + 300; // Stun for 300ms
+            }
+        }
+    });
+
+    return enemies.filter(enemy => enemy.hp > 0).map(enemy => {
+        const baseSpeed = CONFIG.enemies.patrol.speed.base * deltaTime / 1000;  // Convert to pixels per frame
+
+        // Handle knockback animation
+        if (enemy.knockback.active) {
+            const progress = (animationTime - enemy.knockback.startTime) / enemy.knockback.duration;
+            
+            if (progress >= 1) {
+                enemy.knockback.active = false;
+                enemy.x = enemy.knockback.targetX;
+                enemy.y = enemy.knockback.targetY;
+                
+                // Calculate distance from villager after knockback
+                const dvx = enemy.x - enemy.targetVillager.x;
+                const dvy = enemy.y - enemy.targetVillager.y;
+                const distanceToVillager = Math.sqrt(dvx * dvx + dvy * dvy);
+                
+                // If too far from villager, set returning state
+                if (distanceToVillager > gridSize * 3) {
+                    return {
+                        ...enemy,
+                        x: enemy.x,
+                        y: enemy.y,
+                        isChasing: false,
+                        isReturning: true
+                    };
+                }
+            } else {
+                // Smooth easing function (ease-out)
+                const t = 1 - Math.pow(1 - progress, 3);
+                enemy.x = enemy.knockback.startX + (enemy.knockback.targetX - enemy.knockback.startX) * t;
+                enemy.y = enemy.knockback.startY + (enemy.knockback.targetY - enemy.knockback.startY) * t;
+            }
+            
+            return {
+                ...enemy,
+                x: enemy.x,
+                y: enemy.y,
+                isChasing: false
+            };
+        }
+        
+        // If enemy is returning to villager
+        if (enemy.isReturning) {
+            const dvx = enemy.targetVillager.x - enemy.x;
+            const dvy = enemy.targetVillager.y - enemy.y;
+            const distanceToVillager = Math.sqrt(dvx * dvx + dvy * dvy);
+            
+            // If back within range, resume normal behavior
+            if (distanceToVillager <= gridSize * 2) {
+                enemy.isReturning = false;
+            } else {
+                // Move towards villager
+                const angle = Math.atan2(dvy, dvx);
+                const returnSpeed = baseSpeed * CONFIG.enemies.return.speedMultiplier;
+                
+                return {
+                    ...enemy,
+                    x: enemy.x + Math.cos(angle) * returnSpeed,
+                    y: enemy.y + Math.sin(angle) * returnSpeed,
+                    isChasing: false,
+                    isReturning: true
+                };
+            }
+        }
+        
+        // Check if stun has worn off
+        if (enemy.stunned && animationTime >= enemy.stunEndTime) {
+            enemy.stunned = false;
+        }
+        
+        if (enemy.targetVillager.status === 'rescued') {
+            return {
+                ...enemy,
+                color: CONFIG.enemies.colors.defeated
+            };
+        }
+        
+        // Calculate distance to player
+        const dx = state.player.x - enemy.x;
+        const dy = state.player.y - enemy.y;
+        const distanceToPlayer = Math.sqrt(dx * dx + dy * dy);
+        
+        if (distanceToPlayer <= aggroRange) {
+            // Check if in attack range (half grid cell)
+            const attackRange = gridSize * 0.5;
+            
+            // If on attack cooldown, keep distance
+            if (enemy.attackCooldown) {
+                if (animationTime >= enemy.attackCooldownUntil) {
+                    enemy.attackCooldown = false;
+                } else if (distanceToPlayer < gridSize) {
+                    // Retreat if too close during cooldown
+                    const retreatAngle = Math.atan2(dy, dx) + Math.PI; // Opposite direction
+                    const retreatSpeed = baseSpeed * CONFIG.enemies.chase.speedMultiplier;
+                    return {
+                        ...enemy,
+                        x: enemy.x + Math.cos(retreatAngle) * retreatSpeed,
+                        y: enemy.y + Math.sin(retreatAngle) * retreatSpeed,
+                        isChasing: true
+                    };
+                }
+            }
+            
+            // If in attack range and not on cooldown, initiate attack
+            if (distanceToPlayer <= attackRange && !enemy.attacking && !enemy.attackCooldown) {
+                enemy.attacking = true;
+                enemy.attackStartPosition = { x: enemy.x, y: enemy.y };
+                enemy.attackTargetPosition = {
+                    x: state.player.x,
+                    y: state.player.y
+                };
+                enemy.attackStartTime = animationTime;
+
+                // Change enemy color to a random blue shade
+                enemy.color = blueShades[Math.floor(Math.random() * blueShades.length)];
+            }
+            
+            // Handle attack animation and damage
+            if (enemy.attacking) {
+                const attackDuration = 200; // 200ms attack
+                const progress = (animationTime - enemy.attackStartTime) / attackDuration;
+                
+                if (progress >= 1) {
+                    enemy.attacking = false;
+                    enemy.attackCooldown = true;
+                    enemy.attackCooldownUntil = animationTime + 1000; // 1 second cooldown
+                    
+                    // Check if attack hit the player
+                    const finalDx = state.player.x - enemy.x;
+                    const finalDy = state.player.y - enemy.y;
+                    const finalDistance = Math.sqrt(finalDx * finalDx + finalDy * finalDy);
+                    
+                    if (finalDistance < enemy.size + CONFIG.player.size && 
+                        !state.player.isInvulnerable && 
+                        !state.player.isDefending && 
+                        !state.player.isSwinging && 
+                        state.player.bubbles.length === 0) {
+                        state.player.hp -= 1;
+                        state.player.isInvulnerable = true;
+                        state.player.invulnerableUntil = animationTime + 1000;
+                    }
+                    
+                    return {
+                        ...enemy,
+                        isChasing: true
+                    };
+                }
+                
+                // Fast lunge animation with ease-in
+                const t = progress * progress; // Quadratic ease-in
+                return {
+                    ...enemy,
+                    x: enemy.attackStartPosition.x + (enemy.attackTargetPosition.x - enemy.attackStartPosition.x) * t,
+                    y: enemy.attackStartPosition.y + (enemy.attackTargetPosition.y - enemy.attackStartPosition.y) * t,
+                    isChasing: true,
+                    attacking: true
+                };
+            }
+            
+            // Normal chase behavior if not attacking
+            const angle = Math.atan2(dy, dx);
+            const chaseSpeed = baseSpeed * CONFIG.enemies.chase.speedMultiplier;
+            
+            return {
+                ...enemy,
+                x: enemy.x + Math.cos(angle) * chaseSpeed,
+                y: enemy.y + Math.sin(angle) * chaseSpeed,
+                isChasing: true
+            };
+        } else {
+            // Get current distance from villager
+            const dvx = enemy.x - enemy.targetVillager.x;
+            const dvy = enemy.y - enemy.targetVillager.y;
+            const distanceToVillager = Math.sqrt(dvx * dvx + dvy * dvy);
+            
+            // If too far from villager, start returning
+            if (distanceToVillager > gridSize * 3) {
+                return {
+                    ...enemy,
+                    isReturning: true,
+                    isChasing: false
+                };
+            }
+            
+            // Patrol behavior - move in small circles around current position
+            if (!enemy.patrolAngle) {
+                enemy.patrolAngle = Math.random() * Math.PI * 2;
+            }
+            
+            enemy.patrolAngle += baseSpeed * 0.02; // Small angle change for circular movement
+            
+            return {
+                ...enemy,
+                x: enemy.x + Math.cos(enemy.patrolAngle) * baseSpeed,
+                y: enemy.y + Math.sin(enemy.patrolAngle) * baseSpeed,
+                patrolAngle: enemy.patrolAngle,
+                isChasing: false
+            };
+        }
+    });
+};
+
+const renderEnemies = (ctx, enemies) => {
+    enemies.forEach(enemy => {
+        ctx.beginPath();
+        ctx.arc(enemy.x, enemy.y, enemy.size, 0, Math.PI * 2);
+        ctx.fillStyle = enemy.stunned ? 'rgb(150, 150, 150)' : enemy.color;
+        ctx.fill();
+        
+        // Add a glowing effect
+        const glowSize = enemy.stunned ? 1.1 : (enemy.attacking ? 1.6 : enemy.isChasing ? 1.4 : 1.2);
+        const glowIntensity = enemy.stunned ? 0.1 : (enemy.attacking ? 0.5 : enemy.isChasing ? 0.3 : 0.2);
+        const gradient = ctx.createRadialGradient(
+            enemy.x, enemy.y, enemy.size * 0.5,
+            enemy.x, enemy.y, enemy.size * glowSize
+        );
+        gradient.addColorStop(0, `rgba(255, 0, 0, ${glowIntensity})`);
+        gradient.addColorStop(1, 'rgba(255, 0, 0, 0)');
+        
+        ctx.beginPath();
+        ctx.arc(enemy.x, enemy.y, enemy.size * glowSize, 0, Math.PI * 2);
+        ctx.fillStyle = gradient;
+        ctx.fill();
+        
+        // Draw HP bar
+        const maxHP = 10;
+        const barWidth = enemy.size * 2;
+        const barHeight = 4;
+        const barX = enemy.x - barWidth / 2;
+        const barY = enemy.y - enemy.size - barHeight - 5;
+        
+        // Background
+        ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
+        ctx.fillRect(barX, barY, barWidth, barHeight);
+        
+        // HP
+        ctx.fillStyle = `rgb(${Math.floor(255 * (1 - enemy.hp/maxHP))}, ${Math.floor(255 * enemy.hp/maxHP)}, 0)`;
+        ctx.fillRect(barX, barY, barWidth * (enemy.hp/maxHP), barHeight);
+    });
+};
+
+// Export the functions
+window.enemySystem = {
+    generateEnemies,
+    updateEnemies,
+    renderEnemies
+}; 
\ No newline at end of file
diff --git a/html/plains/game.js b/html/plains/game.js
index 9e91353..5923b16 100644
--- a/html/plains/game.js
+++ b/html/plains/game.js
@@ -286,6 +286,43 @@ const CONFIG = {
                 sizeMultiplier: 1.0
             }
         }
+    },
+    enemies: {
+        size: {
+            min: 15,
+            max: 20
+        },
+        colors: {
+            active: {
+                min: {
+                    red: 150,
+                    green: 0,
+                    blue: 0
+                },
+                max: {
+                    red: 255,
+                    green: 100,
+                    blue: 0
+                }
+            },
+            defeated: 'rgb(100, 100, 100)'
+        },
+        patrol: {
+            radius: {
+                min: 100,
+                max: 200
+            },
+            speed: {
+                base: 100  // pixels per second
+            }
+        },
+        chase: {
+            range: 2,
+            speedMultiplier: 1.5  // 50% faster than base speed
+        },
+        return: {
+            speedMultiplier: 1.25  // 25% faster than base speed
+        }
     }
 };
 
@@ -307,24 +344,29 @@ const CAMERA_DEADZONE_Y = GAME_HEIGHT * CONFIG.display.camera.deadzoneMultiplier
 // ============= State Management =============
 const createInitialState = () => ({
     player: {
-        x: CONFIG.player.size,  // A bit offset from the edge
-        y: CONFIG.player.size,  // A bit offset from the edge
+        x: CONFIG.player.size,
+        y: CONFIG.player.size,
         isDefending: false,
         direction: { x: 0, y: -1 },
         swordAngle: 0,
         isSwinging: false,
-        equipment: 'unarmed',  // Start without sword
+        equipment: 'unarmed',
         bubbles: [],
         bubbleParticles: [],
         lastBubbleTime: 0,
-        dashStartTime: 0, // When the current dash started
-        isDashing: false, // Currently dashing?
-        dashExhausted: false, // Is dash on cooldown?
-        lastInputTime: 0, // Track when the last input occurred
+        dashStartTime: 0,
+        isDashing: false,
+        dashExhausted: false,
+        lastInputTime: 0,
         baseDirection: { x: 0, y: -1 },
         lastDashEnd: 0,
         swordUnlocked: false,
-        rescuedCount: 0
+        rescuedCount: 0,
+        hp: 15,
+        maxHp: 15,
+        isInvulnerable: false,
+        invulnerableUntil: 0,
+        isDead: false
     },
     particles: [],
     footprints: [],
@@ -338,7 +380,8 @@ const createInitialState = () => ({
     collisionMap: new Map(),
     villagers: [],
     gameComplete: false,
-    gameCompleteMessageShown: false
+    gameCompleteMessageShown: false,
+    enemies: []
 });
 
 let state = createInitialState();
@@ -348,6 +391,14 @@ let state = createInitialState();
 const keys = new Set();
 
 const handleKeyDown = (e) => {
+    // If player is dead, only handle restart
+    if (state.player.isDead) {
+        if (e.code === 'Space') {
+            window.location.reload();
+        }
+        return;
+    }
+    
     keys.add(e.key);
     
     if (e.key === 'z' && !state.player.isDefending) {
@@ -1022,6 +1073,53 @@ const renderPlayer = () => {
     ctx.restore();
 };
 
+const renderPlayerHUD = (ctx) => {
+    // Save the current context state
+    ctx.save();
+    
+    // Switch to screen coordinates
+    ctx.setTransform(1, 0, 0, 1, 0, 0);
+    
+    // HP Bar container
+    const barWidth = 200;
+    const barHeight = 20;
+    const barX = 20;
+    const barY = 20;
+    const borderWidth = 2;
+    
+    // Draw border
+    ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
+    ctx.fillRect(
+        barX - borderWidth, 
+        barY - borderWidth, 
+        barWidth + borderWidth * 2, 
+        barHeight + borderWidth * 2
+    );
+    
+    // Background
+    ctx.fillStyle = 'rgba(50, 50, 50, 0.8)';
+    ctx.fillRect(barX, barY, barWidth, barHeight);
+    
+    // HP
+    const hpRatio = state.player.hp / state.player.maxHp;
+    ctx.fillStyle = `rgb(${Math.floor(255 * (1 - hpRatio))}, ${Math.floor(255 * hpRatio)}, 0)`;
+    ctx.fillRect(barX, barY, barWidth * hpRatio, barHeight);
+    
+    // HP Text
+    ctx.fillStyle = 'white';
+    ctx.font = '14px Arial';
+    ctx.textAlign = 'center';
+    ctx.textBaseline = 'middle';
+    ctx.fillText(
+        `HP: ${state.player.hp}/${state.player.maxHp}`,
+        barX + barWidth/2,
+        barY + barHeight/2
+    );
+    
+    // Restore the context state
+    ctx.restore();
+};
+
 const render = () => {
     ctx.clearRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
     
@@ -1207,7 +1305,7 @@ const render = () => {
                         
                         // Choose color for this dot
                         const colorIndex = useSingleColor ? cellColorIndex :
-                            Math.floor(seededRandom(cellX * i * j, cellY * i * j) * config.colors.length);
+                            Math.floor(seededRandom(cellX * i, cellY * j) * config.colors.length);
                         ctx.fillStyle = config.colors[colorIndex];
                         
                         ctx.beginPath();
@@ -1332,6 +1430,9 @@ const render = () => {
         ctx.restore();
     });
 
+    // Render enemies
+    enemySystem.renderEnemies(ctx, state.enemies);
+
     // Draw player
     renderPlayer();
 
@@ -1361,6 +1462,33 @@ const render = () => {
         ctx.restore();
     });
     
+    // Render particles
+    state.particles = state.particles.filter(particle => {
+        const age = animationTime - particle.createdAt;
+        if (age >= particle.lifetime) return false;
+        
+        const alpha = 1 - (age / particle.lifetime);
+        ctx.beginPath();
+        ctx.arc(
+            particle.x + particle.dx * age * 0.1,
+            particle.y + particle.dy * age * 0.1,
+            particle.size,
+            0, Math.PI * 2
+        );
+        ctx.fillStyle = particle.color.replace('rgb', 'rgba').replace(')', `, ${alpha})`);
+        ctx.fill();
+        
+        return true;
+    });
+    
+    // Render HUD elements
+    renderPlayerHUD(ctx);
+    
+    // Update player invulnerability
+    if (state.player.isInvulnerable && animationTime >= state.player.invulnerableUntil) {
+        state.player.isInvulnerable = false;
+    }
+    
     ctx.restore();
 };
 
@@ -1462,7 +1590,14 @@ const gameLoop = (currentTime) => {
     if (deltaTime >= FRAME_TIME) {
         animationTime += FRAME_TIME;
         
+        // Check for player death
+        if (state.player.hp <= 0 && !state.player.isDead) {
+            state.player.isDead = true;
+            showGameOver();
+        }
+        
         updatePlayer();
+        state.enemies = enemySystem.updateEnemies(state.enemies, deltaTime);
         render();
         
         lastFrameTime = currentTime;
@@ -1495,6 +1630,7 @@ resizeCanvas();
 
 // Initialize villagers after collision map is populated
 state.villagers = generateVillagers();
+state.enemies = enemySystem.generateEnemies(state.villagers, state.collisionMap);
 
 // Start the game loop
 requestAnimationFrame(gameLoop);
@@ -1696,4 +1832,23 @@ const showSwordUnlockAnimation = () => {
         document.body.removeChild(container);
         document.head.removeChild(style);
     }, CONFIG.player.equipment.unlockAnimation.duration);
+};
+
+const showGameOver = () => {
+    const gameOverDiv = document.createElement('div');
+    gameOverDiv.style.position = 'fixed';
+    gameOverDiv.style.top = '50%';
+    gameOverDiv.style.left = '50%';
+    gameOverDiv.style.transform = 'translate(-50%, -50%)';
+    gameOverDiv.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
+    gameOverDiv.style.padding = '20px';
+    gameOverDiv.style.borderRadius = '10px';
+    gameOverDiv.style.color = 'white';
+    gameOverDiv.style.textAlign = 'center';
+    gameOverDiv.style.fontSize = '24px';
+    gameOverDiv.innerHTML = `
+        <h2>Game Over</h2>
+        <p>Press Space to restart</p>
+    `;
+    document.body.appendChild(gameOverDiv);
 };
\ No newline at end of file
diff --git a/html/plains/index.html b/html/plains/index.html
index 492e93f..0ca2807 100644
--- a/html/plains/index.html
+++ b/html/plains/index.html
@@ -24,6 +24,7 @@
 </head>
 <body>
     <canvas id="gameCanvas"></canvas>
+    <script src="enemies.js"></script>
     <script src="game.js"></script>
 </body>
 </html>