diff options
Diffstat (limited to 'html/plains')
-rw-r--r-- | html/plains/enemies.js | 402 | ||||
-rw-r--r-- | html/plains/game.js | 175 | ||||
-rw-r--r-- | html/plains/index.html | 1 |
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> |