diff options
Diffstat (limited to 'html/plains')
-rw-r--r-- | html/plains/about.html | 37 | ||||
-rw-r--r-- | html/plains/enemies.js | 438 | ||||
-rw-r--r-- | html/plains/game.js | 1899 | ||||
-rw-r--r-- | html/plains/index.html | 32 |
4 files changed, 2406 insertions, 0 deletions
diff --git a/html/plains/about.html b/html/plains/about.html new file mode 100644 index 0000000..3790fef --- /dev/null +++ b/html/plains/about.html @@ -0,0 +1,37 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Plains</title> + <meta name="description" content="Help little black square rescue the villagers, and battle the monsters."> + <style> + body { + background-color: #f0f0f0; + font-size: x-large; + padding: 1em 2em; + } + </style> +</head> +<body> + <h1>How to play</h1> + <p>Help little black square rescue the villagers, and battle the monsters.</p> + <ul> + <li>Rescue the other villagers from the monsters.</li> + <li>You start in the village.</li> + <li>Monsters will mostly stay away from the village.</li> + <li>Use the arrow keys to move.</li> + <li>Use the z key to attack.</li> + <li>Use the x key to hide in your protective shell. Whil hiding you won't be able to move.</li> + <li>Use the e key to cycle between your push attack and your sword.</li> + <li>Earn the sword by rescuing some villagers!</li> + <li>The push attack will knock enemies back, but won't harm them.</li> + <li>The sword slices. This is dangerous, and will harm enemies.</li> + <li>Be careful of the enemies, they will chase you around!</li> + <li>If you defeate an enemy they may drop some yellow gems. Collect them to restore your health.</li> + <li>Hold down the shift key to run faster, you only have so much stamina, though, so after a while you'll have to slow down again.</li> + <li>Hold down the space bar to strafe.</li> + </ul> + <p><a href="index.html">Play the game!</a></p> +</body> +</html> \ No newline at end of file diff --git a/html/plains/enemies.js b/html/plains/enemies.js new file mode 100644 index 0000000..3d40c3e --- /dev/null +++ b/html/plains/enemies.js @@ -0,0 +1,438 @@ +const blueShades = [ + 'rgb(0, 0, 255)', + 'rgb(0, 0, 200)', + 'rgb(0, 0, 150)', + 'rgb(0, 0, 100)', + 'rgb(0, 0, 50)' +]; + +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; + + // 2 - 5 enemies per villager + const numEnemies = 2 + Math.floor(Math.random() * 4); + + for (let i = 0; i < numEnemies; i++) { + // make sure to place an enemy within 2 - 3 cells of a 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}`; + + if (occupiedCells.has(cellKey)) { + continue; + } + + // 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() * 4), + 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 createDeathParticles = (enemy) => { + const numParticles = 15 + Math.floor(Math.random() * 10); + for (let i = 0; i < numParticles; i++) { + const particleAngle = (i / numParticles) * Math.PI * 2; + const speed = 2 + Math.random() * 3; + state.particles.push({ + x: enemy.x, + y: enemy.y, + dx: Math.cos(particleAngle) * speed, + dy: Math.sin(particleAngle) * speed, + size: enemy.size * (0.1 + Math.random() * 0.2), + color: enemy.color, + lifetime: 1000, + createdAt: animationTime + }); + } +}; + +const createDeathDiamonds = (enemy) => { + const diamondCount = Math.floor(Math.random() * 5); + for (let i = 0; i < diamondCount; i++) { + state.diamonds.push({ + x: enemy.x + (Math.random() - 0.5) * 20, + y: enemy.y + (Math.random() - 0.5) * 20, + size: 6, + collected: false + }); + } +}; + +const handleEnemyDamage = (enemy, damage, knockbackForce = 0, angle = 0) => { + const gridSize = CONFIG.display.grid.size; + enemy.hp -= damage; + + // stun the enemy when you hit them + enemy.stunned = true; + enemy.stunEndTime = animationTime + 500; + + // knock the enemy back when you hit them + if (knockbackForce > 0) { + const knockbackDistance = gridSize * 0.5; + enemy.knockback = { + active: true, + startX: enemy.x, + startY: enemy.y, + targetX: enemy.x + Math.cos(angle) * knockbackDistance, + targetY: enemy.y + Math.sin(angle) * knockbackDistance, + startTime: animationTime, + duration: 300 + }; + } + + if (damage > 0 && enemy.hp <= 0) { + createDeathParticles(enemy); + createDeathDiamonds(enemy); + } + + return damage > 0 && enemy.hp <= 0; +}; + +// find the nearest lost villager, zoot to 'em +const findNearestLostVillager = (enemyX, enemyY, villagers) => { + let nearestVillager = null; + let shortestDistance = Infinity; + + villagers.forEach(villager => { + if (villager.status === 'lost') { + const dx = villager.x - enemyX; + const dy = villager.y - enemyY; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < shortestDistance) { + shortestDistance = distance; + nearestVillager = villager; + } + } + }); + + return nearestVillager; +}; + +const updateEnemies = (enemies, deltaTime) => { + const gridSize = CONFIG.display.grid.size; + const aggroRange = gridSize * CONFIG.enemies.chase.range; + + return enemies.filter(enemy => enemy.hp > 0).map(enemy => { + const baseSpeed = CONFIG.enemies.patrol.speed.base * deltaTime / 1000; + + 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; + + const dvx = enemy.x - enemy.targetVillager.x; + const dvy = enemy.y - enemy.targetVillager.y; + const distanceToVillager = Math.sqrt(dvx * dvx + dvy * dvy); + + if (distanceToVillager > gridSize * 3) { + return { + ...enemy, + x: enemy.x, + y: enemy.y, + isChasing: false, + isReturning: true + }; + } + } else { + // 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, go back to normal behavior + if (distanceToVillager <= gridSize * 2) { + enemy.isReturning = false; + } else { + // Move to the 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 + }; + } + } + + // Still stunned? + if (enemy.stunned && animationTime >= enemy.stunEndTime) { + enemy.stunned = false; + } + + // Was your villager rescued?? + if (enemy.targetVillager.status === 'rescued') { + // find a new villager + const newTarget = findNearestLostVillager(enemy.x, enemy.y, state.villagers); + + if (newTarget) { + // zoot towards that villager + enemy.targetVillager = newTarget; + enemy.isReturning = true; + enemy.isChasing = false; + } else { + // no more villagers, get real sad + return { + ...enemy, + color: CONFIG.enemies.colors.defeated + }; + } + } + + // 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) { + + const attackRange = gridSize * 0.5; + + if (enemy.attackCooldown) { + if (animationTime >= enemy.attackCooldownUntil) { + enemy.attackCooldown = false; + } else if (distanceToPlayer < gridSize) { + const retreatAngle = Math.atan2(dy, dx) + Math.PI; // retreat in the 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 (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; + enemy.color = blueShades[Math.floor(Math.random() * blueShades.length)]; + } + + // attack animation and damage + if (enemy.attacking) { + const attackDuration = 200; + const progress = (animationTime - enemy.attackStartTime) / attackDuration; + + if (progress >= 1) { + enemy.attacking = false; + enemy.attackCooldown = true; + enemy.attackCooldownUntil = animationTime + 500; + + // did the 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 + }; + } + + // lunge towards the player + 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 + }; + } + + 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 { + + const dvx = enemy.x - enemy.targetVillager.x; + const dvy = enemy.y - enemy.targetVillager.y; + const distanceToVillager = Math.sqrt(dvx * dvx + dvy * dvy); + + if (distanceToVillager > gridSize * 3) { + return { + ...enemy, + isReturning: true, + isChasing: false + }; + } + + if (!enemy.patrolAngle) { + enemy.patrolAngle = Math.random() * Math.PI * 2; + } + + enemy.patrolAngle += baseSpeed * 0.02; + + 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) => { + // I tried to generate this per enemy, but it was wildly inefficient + const healthGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, 2); + healthGradient.addColorStop(0, 'rgba(255, 0, 0, 0.8)'); + healthGradient.addColorStop(0.7, 'rgba(200, 0, 0, 0.6)'); + healthGradient.addColorStop(1, 'rgba(150, 0, 0, 0.4)'); + + 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(); + + // she be radiant + 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 glowGradient = ctx.createRadialGradient( + enemy.x, enemy.y, enemy.size * 0.5, + enemy.x, enemy.y, enemy.size * glowSize + ); + glowGradient.addColorStop(0, `rgba(255, 0, 0, ${glowIntensity})`); + glowGradient.addColorStop(1, 'rgba(255, 0, 0, 0)'); + + ctx.beginPath(); + ctx.arc(enemy.x, enemy.y, enemy.size * glowSize, 0, Math.PI * 2); + ctx.fillStyle = glowGradient; + ctx.fill(); + + if (enemy.hp > 0) { + const circleRadius = 3; + const circleSpacing = 8; + const totalCircles = enemy.hp; + const startX = enemy.x - ((totalCircles - 1) * circleSpacing) / 2; + const circleY = enemy.y - enemy.size - 15; + + ctx.beginPath(); + for (let i = 0; i < totalCircles; i++) { + const circleX = startX + (i * circleSpacing); + ctx.moveTo(circleX + circleRadius, circleY); + ctx.arc(circleX, circleY, circleRadius, 0, Math.PI * 2); + } + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.lineWidth = 1; + ctx.stroke(); + + ctx.beginPath(); + for (let i = 0; i < totalCircles; i++) { + const circleX = startX + (i * circleSpacing); + ctx.moveTo(circleX + circleRadius - 1, circleY); + ctx.arc(circleX, circleY, circleRadius - 1, 0, Math.PI * 2); + } + ctx.fillStyle = 'rgba(255, 0, 0, 0.6)'; + ctx.fill(); + } + }); +}; + +const generateDiamonds = () => { + const diamondCount = Math.floor(Math.random() * 3); + const diamonds = []; + + for (let i = 0; i < diamondCount; i++) { + diamonds.push({ + x: enemy.x + (Math.random() - 0.5) * 20, + y: enemy.y + (Math.random() - 0.5) * 20, + size: 10, + collected: false + }); + } + + return diamonds; +}; + +window.enemySystem = { + generateEnemies, + updateEnemies, + renderEnemies, + findNearestLostVillager, + handleEnemyDamage +}; \ No newline at end of file diff --git a/html/plains/game.js b/html/plains/game.js new file mode 100644 index 0000000..7c73a19 --- /dev/null +++ b/html/plains/game.js @@ -0,0 +1,1899 @@ +const lerp = (start, end, t) => { + return start * (1 - t) + end * t; +}; + +const seededRandom = (x, y) => { + const a = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453123; + return a - Math.floor(a); +}; + +const worldToGrid = (x, y) => ({ + x: Math.floor(x / CONFIG.display.grid.size), + y: Math.floor(y / CONFIG.display.grid.size) +}); + +// Helper function to create a villager object +const createVillager = (cellX, cellY) => ({ + x: (cellX * CONFIG.display.grid.size) + (CONFIG.display.grid.size / 2), + y: (cellY * CONFIG.display.grid.size) + (CONFIG.display.grid.size / 2), + color: CONFIG.world.villagers.colors[Math.floor(Math.random() * CONFIG.world.villagers.colors.length)], + shape: CONFIG.world.villagers.shapes[Math.floor(Math.random() * CONFIG.world.villagers.shapes.length)], + status: 'lost', + cellX, + cellY, + bobSpeed: 0.005 + Math.random() * 0.005, + bobAmplitude: 2 + Math.random() * 2, + lostBobSpeed: 0.005 + Math.random() * 0.005, + lostBobAmplitude: 2 + Math.random() * 2 +}); + +const generateVillagers = () => { + const villagers = []; + const occupiedCells = new Set(); + const gridSize = CONFIG.display.grid.size; + const villageSize = CONFIG.world.village.size; + const worldSize = CONFIG.display.grid.worldSize; + + // Place one villager near the village + const nearVillageX = villageSize + Math.floor(Math.random() * 2); + const nearVillageY = villageSize + Math.floor(Math.random() * 2); + villagers.push(createVillager(nearVillageX, nearVillageY)); + occupiedCells.add(`${nearVillageX},${nearVillageY}`); + + while (villagers.length < CONFIG.world.villagers.total) { + const cellX = villageSize + Math.floor(Math.random() * (worldSize - villageSize)); + const cellY = villageSize + Math.floor(Math.random() * (worldSize - villageSize)); + const cellKey = `${cellX},${cellY}`; + + if (occupiedCells.has(cellKey) || state.collisionMap.has(cellKey)) { + continue; + } + + villagers.push(createVillager(cellX, cellY)); + occupiedCells.add(cellKey); + } + + return villagers; +}; + +// Refactored drawVillagerShape function +const drawVillagerShape = (ctx, x, y, shape, size) => { + ctx.beginPath(); + const shapes = { + square: () => ctx.rect(x - size / 2, y - size / 2, size, size), + triangle: () => { + ctx.moveTo(x, y - size / 2); + ctx.lineTo(x + size / 2, y + size / 2); + ctx.lineTo(x - size / 2, y + size / 2); + }, + pentagon: () => { + for (let i = 0; i < 5; i++) { + const angle = (i * 2 * Math.PI / 5) - Math.PI / 2; + ctx.lineTo(x + Math.cos(angle) * size / 2, y + Math.sin(angle) * size / 2); + } + }, + hexagon: () => { + for (let i = 0; i < 6; i++) { + const angle = (i * 2 * Math.PI / 6); + ctx.lineTo(x + Math.cos(angle) * size / 2, y + Math.sin(angle) * size / 2); + } + } + }; + + if (shapes[shape]) shapes[shape](); + ctx.closePath(); +}; + + +const CONFIG = { + display: { + fps: 60, + grid: { + size: 100, + color: 'rgba(221, 221, 221, 0.5)', + worldSize: 20, + voidColor: '#e6f3ff' + }, + camera: { + deadzoneMultiplierX: 0.6, + deadzoneMultiplierY: 0.6, + ease: 0.08 + } + }, + effects: { + colors: { + primary: '#4169E1', + secondary: '#1E90FF', + tertiary: '#0000CD', + glow: 'rgba(0, 128, 255, 0.5)', + inner: '#0000CD' + } + }, + player: { + size: 30, + speed: 5, + sprintMultiplier: 2, + color: '#111', + strafeKey: ' ', + directionIndicator: { + size: 10, + color: 'rgba(32, 178, 170, 1)' + }, + dash: { + duration: 3000, // 3 seconds of use + cooldown: 3000, // 3 second cooldown + exhaustedAt: 0 + }, + idle: { + startDelay: 1500, // Get board after 1.5 seconds + lookSpeed: 0.001, + lookRadius: 0.4 + }, + equipment: { + swordUnlockCount: 8, // The number of villagers you need to rescue to unlock the sword + unlockAnimation: { + duration: 1500, + glowColor: 'rgba(255, 215, 0, 0.6)', + messageText: 'Sword Unlocked!', + messageColor: '#FFD700' + } + } + }, + sword: { + length: 60, + swingSpeed: 0.6, + colors: null + }, + bubble: { + size: 20, + speed: 8, + lifetime: 800, + cooldown: 1000, + arcWidth: Math.PI / 3, + colors: null, + particleEmitRate: 0.3, + fadeExponent: 2.5 + }, + bubbleParticle: { + lifetime: 700, + speedMultiplier: 0.3, + size: 3 + }, + defense: { + numLayers: 6, + maxRadiusMultiplier: 2, + baseAlpha: 0.15, + particleCount: 12, + orbitRadiusMultiplier: 0.8, + rotationSpeed: 1.5 + }, + footprints: { + lifetime: 1000, + spacing: 300, + size: 5 + }, + world: { + village: { + size: 5, + groundColor: '#f2f2f2' + }, + villagers: { + total: 25, + colors: [ + '#4B0082', // Indigo + '#483D8B', // DarkSlateBlue + '#6A5ACD', // SlateBlue + '#2F4F4F', // DarkSlateGray + '#363636', // DarkGray + '#4682B4' // SteelBlue + ], + shapes: ['square', 'triangle', 'pentagon', 'hexagon'], + size: 30, + rescueMessage: 'Congratulations! You rescued all the villagers!', + messageDisplayTime: 5000 + }, + wilderness: { + groundColor: '#e6ffe6', + vegetation: { + tree: { + frequency: 0.1, // Chance per grid cell + colors: [ + 'rgba(100, 144, 79, 1)', + 'rgba(85, 128, 64, 1)', + 'rgba(128, 164, 98, 1)', + 'rgba(110, 139, 61, 1)', + 'rgba(95, 133, 73, 1)', + 'rgba(248, 239, 58, 1)' + ], + size: { min: 20, max: 30 } + }, + mushroom: { + frequency: 0.03, + colors: [ + 'rgba(242, 63, 63, 0.25)', + 'rgba(245, 131, 148, 0.25)', + 'rgba(255, 119, 65, 0.25)', + 'rgba(193, 97, 1, 0.5)' + ], + pattern: { + size: 3, + spacing: 10, + margin: 10, + variation: 0.5, + offset: 0.5, + singleColor: 0.7 // % chance that all dots in a cell will be the same color + } + }, + flower: { + frequency: 0.05, + colors: [ + 'rgba(255, 105, 180, 0.3)', + 'rgba(221, 160, 221, 0.3)', + 'rgba(147, 112, 219, 0.3)' + ], + pattern: { + size: 12, + spacing: 16, + rotation: Math.PI / 6, // rotation of pattern + margin: 10, + variation: 0.2 + } + }, + grass: { + frequency: 0.12, + colors: ['rgba(28, 48, 32, 0.25)'], + hatch: { + spacing: 8, + length: 6, + angle: Math.PI / 4, + variation: 0.4, // we can have some randomness, as a treat + margin: 4 + }, + spreadFactor: 0.6 + } + } + } + }, + collision: { + enabled: true, + vegetation: { + tree: { + enabled: true, + 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 // PPS, pixels per second + } + }, + chase: { + range: 2, + speedMultiplier: 1.5 + }, + return: { + speedMultiplier: 1.25 + } + } +}; + + +CONFIG.sword.colors = CONFIG.effects.colors; +CONFIG.bubble.colors = CONFIG.effects.colors; + + +let GAME_WIDTH = window.innerWidth; +let GAME_HEIGHT = window.innerHeight; +let lastFrameTime = 0; +let animationTime = 0; +const FRAME_TIME = 1000 / CONFIG.display.fps; +const CAMERA_DEADZONE_X = GAME_WIDTH * CONFIG.display.camera.deadzoneMultiplierX; +const CAMERA_DEADZONE_Y = GAME_HEIGHT * CONFIG.display.camera.deadzoneMultiplierY; + + +const createInitialState = () => ({ + player: { + x: CONFIG.player.size, + y: CONFIG.player.size, + isDefending: false, + direction: { x: 0, y: -1 }, + swordAngle: 0, + isSwinging: false, + equipment: 'unarmed', + bubbles: [], + bubbleParticles: [], + lastBubbleTime: 0, + dashStartTime: 0, + isDashing: false, + dashExhausted: false, + lastInputTime: 0, + baseDirection: { x: 0, y: -1 }, + lastDashEnd: 0, + swordUnlocked: false, + rescuedCount: 0, + hp: 15, + maxHp: 15, + isInvulnerable: false, + invulnerableUntil: 0, + isDead: false, + diamonds: 0, + lastRenderedCircles: 4 + }, + particles: [], + footprints: [], + lastFootprintTime: 0, + camera: { + x: 0, + y: 0, + targetX: 0, + targetY: 0 + }, + collisionMap: new Map(), + villagers: [], + gameComplete: false, + gameCompleteMessageShown: false, + enemies: [], + diamonds: [] +}); + +let state = createInitialState(); + + +const keys = new Set(); + +const handleKeyDown = (e) => { + if (state.player.isDead) { + if (e.code === 'Space') { + window.location.reload(); + } + return; + } + + keys.add(e.key); + + if (e.key === 'z' && !state.player.isDefending) { + Object.assign(state, inputHandlers.handleAttack(state, animationTime)); + } + + if (e.key === 'e') { + Object.assign(state, inputHandlers.handleEquipmentSwitch(state)); + } + + if (e.key === 'x') { + Object.assign(state, { + ...state, + player: { + ...state.player, + isDefending: true + } + }); + } + + if (e.key === 'c') { + const cellInfo = getCellInfo(state.player.x, state.player.y); + console.group('Current Cell Information:'); + console.log(`Position: (${cellInfo.position.cellX}, ${cellInfo.position.cellY})`); + console.log(`Biome: ${cellInfo.biome}`); + console.log('Vegetation:'); + const presentVegetation = Object.entries(cellInfo.vegetation) + .filter(([type, present]) => present) + .map(([type]) => type); + + if (presentVegetation.length === 0) { + console.log('none'); + } else { + presentVegetation.forEach(type => console.log(type)); + } + console.groupEnd(); + } + + state.player.lastInputTime = animationTime; +}; + +const handleKeyUp = (e) => { + keys.delete(e.key); + if (e.key === 'x') { + Object.assign(state, { + ...state, + player: { + ...state.player, + isDefending: false + } + }); + } +}; + +const inputHandlers = { + handleAttack: (state, animationTime) => { + if (state.player.isDefending) return state; + + if (state.player.equipment === 'sword') { + if (!state.player.swordUnlocked) return state; // can't swing a sword you haven't earned yet! + if (!state.player.isSwinging) { + return { + ...state, + player: { + ...state.player, + isSwinging: true, + swordAngle: Math.atan2(state.player.direction.y, state.player.direction.x) - Math.PI / 2 + } + }; + } + } else if (state.player.equipment === 'unarmed') { + return createBubbleAttack(state, animationTime); + } + return state; + }, + + handleEquipmentSwitch: (state) => { + if (!state.player.swordUnlocked) return state; // can't switch to a sword you haven't earned yet! + + const equipment = ['sword', 'unarmed']; + const currentIndex = equipment.indexOf(state.player.equipment); + return { + ...state, + player: { + ...state.player, + equipment: equipment[(currentIndex + 1) % equipment.length] + } + }; + } +}; + + +const calculateMovement = (keys) => { + let dx = 0; + let dy = 0; + + if (keys.has('ArrowLeft')) dx -= 1; + if (keys.has('ArrowRight')) dx += 1; + if (keys.has('ArrowUp')) dy -= 1; + if (keys.has('ArrowDown')) dy += 1; + + if (dx === 0 && dy === 0) { + return { moving: false }; + } + + state.player.lastInputTime = animationTime; + + const length = Math.sqrt(dx * dx + dy * dy); + const normalizedDx = dx / length; + const normalizedDy = dy / length; + + const isStrafing = keys.has(CONFIG.player.strafeKey); + + const newDirection = isStrafing ? + { ...state.player.direction } : // strafe + { x: normalizedDx, y: normalizedDy }; // normal + + if (!isStrafing) { + state.player.baseDirection = { ...newDirection }; + } + + return { + moving: true, + dx: normalizedDx, + dy: normalizedDy, + direction: newDirection + }; +}; + +const isPositionBlocked = (x, y) => { + const cell = worldToGrid(x, y); + const key = `${cell.x},${cell.y}`; + if (!state.collisionMap.has(key)) return false; + + const obstacle = state.collisionMap.get(key); + const obstacleRadius = CONFIG.player.size / 2; + + // check distance from the center of a grid cell + const dx = x - obstacle.x; + const dy = y - obstacle.y; + const distanceSquared = dx * dx + dy * dy; + + return distanceSquared < obstacleRadius * obstacleRadius; +}; + +const addToCollisionMap = (cellX, cellY, type) => { + const key = `${cellX},${cellY}`; + state.collisionMap.set(key, { + type, + x: (cellX * CONFIG.display.grid.size) + (CONFIG.display.grid.size / 2), + y: (cellY * CONFIG.display.grid.size) + (CONFIG.display.grid.size / 2) + }); +}; + +const movementSystem = { + updatePosition: (state, keys) => { + if (state.player.isDefending) return state; + + const movement = calculateMovement(keys); + if (!movement.moving) { + return { + ...state, + player: { + ...state.player, + isDashing: false, + dashStartTime: 0 + } + }; + } + + const wantsToDash = keys.has('Shift'); + const canDash = !state.player.dashExhausted && + (animationTime - state.player.lastDashEnd) >= CONFIG.player.dash.cooldown; + + let isDashing = false; + let dashExhausted = state.player.dashExhausted; + let dashStartTime = state.player.dashStartTime; + let lastDashEnd = state.player.lastDashEnd; + + if (wantsToDash && canDash) { + if (!state.player.isDashing) { + dashStartTime = animationTime; + } + isDashing = true; + + // Are you tired of dashing? + if (animationTime - dashStartTime >= CONFIG.player.dash.duration) { + isDashing = false; + dashExhausted = true; + lastDashEnd = animationTime; + } + } else if (state.player.dashExhausted && + (animationTime - state.player.lastDashEnd >= CONFIG.player.dash.cooldown)) { + dashExhausted = false; + } + + const speed = isDashing ? + CONFIG.player.speed * CONFIG.player.sprintMultiplier : + CONFIG.player.speed; + + const timeSinceLastFootprint = animationTime - state.lastFootprintTime; + const currentSpacing = isDashing ? + CONFIG.footprints.spacing * CONFIG.player.sprintMultiplier : + CONFIG.footprints.spacing; + + let newFootprints = state.footprints; + if (timeSinceLastFootprint > currentSpacing / speed) { + const offset = (Math.random() - 0.5) * 6; + const perpX = -movement.direction.y * offset; + const perpY = movement.direction.x * offset; + + newFootprints = [...state.footprints, createFootprint( + state.player.x + perpX, + state.player.y + perpY, + Math.atan2(movement.dy, movement.dx) + )]; + } + + const worldBounds = { + min: 0, + max: CONFIG.display.grid.size * CONFIG.display.grid.worldSize + }; + + const newX = state.player.x + movement.dx * speed; + const newY = state.player.y + movement.dy * speed; + + const clampedX = Math.max(worldBounds.min, Math.min(worldBounds.max, newX)); + const clampedY = Math.max(worldBounds.min, Math.min(worldBounds.max, newY)); + + const playerRadius = CONFIG.player.size / 2; + // const checkPoints = [ + // { x: newX - playerRadius, y: newY - playerRadius }, // Top-left + // { x: newX + playerRadius, y: newY - playerRadius }, // Top-right + // { x: newX - playerRadius, y: newY + playerRadius }, // Bottom-left + // { x: newX + playerRadius, y: newY + playerRadius } // Bottom-right + // ]; + + const wouldCollide = checkCollision(newX, newY, playerRadius * 0.8); + + const finalX = wouldCollide ? state.player.x : clampedX; + const finalY = wouldCollide ? state.player.y : clampedY; + + return { + ...state, + player: { + ...state.player, + x: finalX, + y: finalY, + direction: movement.direction, + isDashing, + dashStartTime, + dashExhausted, + lastDashEnd + }, + footprints: newFootprints, + lastFootprintTime: timeSinceLastFootprint > currentSpacing / speed ? + animationTime : state.lastFootprintTime + }; + } +}; + + +const updateBubble = (bubble, animationTime) => { + const age = animationTime - bubble.createdAt; + const ageRatio = age / CONFIG.bubble.lifetime; + const speedMultiplier = Math.pow(1 - ageRatio, 0.5); + + return { + ...bubble, + x: bubble.x + bubble.dx * speedMultiplier, + y: bubble.y + bubble.dy * speedMultiplier + }; +}; + +const generateBubbleParticles = (bubble, animationTime) => { + const age = animationTime - bubble.createdAt; + const ageRatio = age / CONFIG.bubble.lifetime; + + if (Math.random() >= CONFIG.bubble.particleEmitRate * (1 - ageRatio)) { + return []; + } + + const trailDistance = Math.random() * 20; + const particleX = bubble.x - bubble.dx * (trailDistance / CONFIG.bubble.speed); + const particleY = bubble.y - bubble.dy * (trailDistance / CONFIG.bubble.speed); + + const particleAngle = bubble.angle + (Math.random() - 0.5) * CONFIG.bubble.arcWidth * 2; + const spreadSpeed = CONFIG.bubble.speed * 0.2 * (1 - ageRatio); + const spreadAngle = Math.random() * Math.PI * 2; + const speedMultiplier = Math.pow(1 - ageRatio, 0.5); + + return [{ + x: particleX, + y: particleY, + dx: (Math.cos(particleAngle) * CONFIG.bubble.speed * CONFIG.bubbleParticle.speedMultiplier * speedMultiplier) + + (Math.cos(spreadAngle) * spreadSpeed), + dy: (Math.sin(particleAngle) * CONFIG.bubble.speed * CONFIG.bubbleParticle.speedMultiplier * speedMultiplier) + + (Math.sin(spreadAngle) * spreadSpeed), + size: CONFIG.bubbleParticle.size * (0.5 + Math.random() * 0.5), + createdAt: animationTime + }]; +}; + +const updateBubbleParticles = (particles, animationTime) => { + return particles.filter(particle => { + const age = animationTime - particle.createdAt; + return age < CONFIG.bubbleParticle.lifetime; + }).map(particle => ({ + ...particle, + x: particle.x + particle.dx, + y: particle.y + particle.dy + })); +}; + +const createBubbleAttack = (state, animationTime) => { + const timeSinceLastBubble = animationTime - state.player.lastBubbleTime; + if (timeSinceLastBubble < CONFIG.bubble.cooldown) return state; + + const angle = Math.atan2(state.player.direction.y, state.player.direction.x); + const bubble = { + x: state.player.x, + y: state.player.y, + dx: state.player.direction.x * CONFIG.bubble.speed, + dy: state.player.direction.y * CONFIG.bubble.speed, + angle: angle, + createdAt: animationTime, + size: CONFIG.bubble.size * (0.8 + Math.random() * 0.4) + }; + + return { + ...state, + player: { + ...state.player, + bubbles: [...state.player.bubbles, bubble], + lastBubbleTime: animationTime + } + }; +}; + +const checkEnemyCollision = (enemy, sourceX, sourceY, range, damage = 0) => { + if (enemy.stunned) return false; + + const dx = enemy.x - sourceX; + const dy = enemy.y - sourceY; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < enemy.size + range) { + const knockbackAngle = Math.atan2(dy, dx); + enemySystem.handleEnemyDamage(enemy, damage, 1, knockbackAngle); + return true; + } + return false; +}; + +const weaponSystems = { + updateBubbles: (state, animationTime) => { + const updatedBubbles = state.player.bubbles + .filter(bubble => animationTime - bubble.createdAt < CONFIG.bubble.lifetime) + .map(bubble => { + state.enemies.forEach(enemy => { + checkEnemyCollision(enemy, bubble.x, bubble.y, CONFIG.bubble.size, 0); + }); + return updateBubble(bubble, animationTime); + }); + + const newParticles = updatedBubbles + .flatMap(bubble => generateBubbleParticles(bubble, animationTime)); + + return { + ...state, + player: { + ...state.player, + bubbles: updatedBubbles, + bubbleParticles: [ + ...updateBubbleParticles(state.player.bubbleParticles, animationTime), + ...newParticles + ] + } + }; + }, + + updateSwordSwing: (state) => { + if (!state.player.isSwinging) return state; + + const newAngle = state.player.swordAngle + CONFIG.sword.swingSpeed; + const swingComplete = newAngle > Math.atan2(state.player.direction.y, state.player.direction.x) + Math.PI / 2; + + const updatedEnemies = state.enemies.map(enemy => { + checkEnemyCollision(enemy, state.player.x, state.player.y, CONFIG.sword.length, 1); + return enemy; + }); + + return { + ...state, + player: { + ...state.player, + swordAngle: newAngle, + isSwinging: !swingComplete + }, + enemies: updatedEnemies + }; + } +}; + + +const createParticle = (x, y, angle) => ({ + x, + y, + angle, + createdAt: animationTime, + lifetime: CONFIG.swordParticles.lifetime, + speed: CONFIG.swordParticles.speed * (0.5 + Math.random() * 0.5), + size: CONFIG.swordParticles.size.min + Math.random() * (CONFIG.swordParticles.size.max - CONFIG.swordParticles.size.min) +}); + +const createFootprint = (x, y, direction) => ({ + x, + y, + direction, + createdAt: animationTime, + size: CONFIG.footprints.size * (0.8 + Math.random() * 0.4), + offset: (Math.random() - 0.5) * 5 +}); + + +const renderPlayer = () => { + ctx.save(); + + state.player.bubbleParticles.forEach(particle => { + const age = (animationTime - particle.createdAt) / CONFIG.bubbleParticle.lifetime; + const alpha = (1 - age) * 0.8; + + ctx.fillStyle = CONFIG.effects.colors.glow.replace('0.25)', `${alpha * 0.3})`); // Outer glow + ctx.beginPath(); + ctx.arc(particle.x, particle.y, particle.size * 2.5, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = CONFIG.effects.colors.secondary.replace('rgb', 'rgba').replace(')', `, ${alpha})`); // Core + ctx.beginPath(); + ctx.arc(particle.x, particle.y, particle.size * 1.5, 0, Math.PI * 2); + ctx.fill(); + }); + + state.player.bubbles.forEach(bubble => { + const age = (animationTime - bubble.createdAt) / CONFIG.bubble.lifetime; + const alpha = Math.pow(1 - age, CONFIG.bubble.fadeExponent); + const expandedSize = bubble.size * (1 + age * 2); + + ctx.save(); + ctx.translate(bubble.x, bubble.y); + ctx.rotate(bubble.angle); + + ctx.beginPath(); + ctx.arc(0, 0, expandedSize * 1.5, + -CONFIG.bubble.arcWidth * (1 + age * 0.5), + CONFIG.bubble.arcWidth * (1 + age * 0.5), + false + ); + ctx.lineCap = 'round'; + ctx.lineWidth = expandedSize * 0.5 * (1 - age * 0.3); + ctx.strokeStyle = CONFIG.bubble.colors.glow.replace(')', `, ${alpha * 0.5})`); + ctx.stroke(); + + const gradient = ctx.createLinearGradient( + -expandedSize, 0, + expandedSize, 0 + ); + gradient.addColorStop(0, CONFIG.bubble.colors.primary.replace('rgb', 'rgba').replace(')', `, ${alpha})`)); + gradient.addColorStop(0.6, CONFIG.bubble.colors.secondary.replace('rgb', 'rgba').replace(')', `, ${alpha})`)); + gradient.addColorStop(1, CONFIG.bubble.colors.tertiary.replace('rgb', 'rgba').replace(')', `, ${alpha})`)); + + ctx.beginPath(); + ctx.arc(0, 0, expandedSize, + -CONFIG.bubble.arcWidth * 0.8 * (1 + age * 0.5), + CONFIG.bubble.arcWidth * 0.8 * (1 + age * 0.5), + false + ); + ctx.lineWidth = expandedSize * 0.3 * (1 - age * 0.3); + ctx.strokeStyle = gradient; + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(0, 0, expandedSize * 0.9, + -CONFIG.bubble.arcWidth * 0.6 * (1 + age * 0.5), + CONFIG.bubble.arcWidth * 0.6 * (1 + age * 0.5), + false + ); + ctx.lineWidth = expandedSize * 0.1 * (1 - age * 0.3); + ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.8})`; + ctx.stroke(); + + ctx.restore(); + }); + + if (state.player.isSwinging && state.player.equipment === 'sword') { + const blurSteps = 12; + const blurSpread = 0.2; + + for (let i = 0; i < blurSteps; i++) { + const alpha = 0.35 - (i * 0.02); + const angleOffset = -blurSpread * i; + + ctx.strokeStyle = `rgba(30, 144, 255, ${alpha})`; + ctx.lineWidth = 4 + (blurSteps - i); + ctx.beginPath(); + ctx.moveTo(state.player.x, state.player.y); + ctx.lineTo( + state.player.x + Math.cos(state.player.swordAngle + angleOffset) * CONFIG.sword.length, + state.player.y + Math.sin(state.player.swordAngle + angleOffset) * CONFIG.sword.length + ); + ctx.stroke(); + } + + const gradient = ctx.createLinearGradient( + state.player.x, + state.player.y, + state.player.x + Math.cos(state.player.swordAngle) * CONFIG.sword.length, + state.player.y + Math.sin(state.player.swordAngle) * CONFIG.sword.length + ); + gradient.addColorStop(0, CONFIG.sword.colors.primary); + gradient.addColorStop(0.6, CONFIG.sword.colors.secondary); + gradient.addColorStop(1, CONFIG.sword.colors.tertiary); + + ctx.strokeStyle = CONFIG.sword.colors.glow; + ctx.lineWidth = 10; + ctx.beginPath(); + ctx.moveTo(state.player.x, state.player.y); + ctx.lineTo( + state.player.x + Math.cos(state.player.swordAngle) * CONFIG.sword.length, + state.player.y + Math.sin(state.player.swordAngle) * CONFIG.sword.length + ); + ctx.stroke(); + + ctx.strokeStyle = gradient; + ctx.lineWidth = 6; + ctx.stroke(); + } + + if (state.player.isDefending) { + const numLayers = CONFIG.defense.numLayers; + const maxRadius = CONFIG.player.size * CONFIG.defense.maxRadiusMultiplier; + const baseAlpha = CONFIG.defense.baseAlpha; + + for (let i = numLayers - 1; i >= 0; i--) { + const radius = CONFIG.player.size / 2 + (maxRadius - CONFIG.player.size / 2) * (i / numLayers); + const alpha = baseAlpha * (1 - i / numLayers); + + const pulseOffset = Math.sin(Date.now() / 500) * 3; + + const glowGradient = ctx.createRadialGradient( + state.player.x, state.player.y, radius - 5, + state.player.x, state.player.y, radius + pulseOffset + ); + glowGradient.addColorStop(0, `rgba(30, 144, 255, ${alpha})`); + glowGradient.addColorStop(1, 'rgba(30, 144, 255, 0)'); + + ctx.beginPath(); + ctx.arc(state.player.x, state.player.y, radius + pulseOffset, 0, Math.PI * 2); + ctx.fillStyle = glowGradient; + ctx.fill(); + } + + const mainAuraGradient = ctx.createRadialGradient( + state.player.x, state.player.y, CONFIG.player.size / 2 - 2, + state.player.x, state.player.y, CONFIG.player.size / 2 + 8 + ); + mainAuraGradient.addColorStop(0, 'rgba(30, 144, 255, 0.3)'); + mainAuraGradient.addColorStop(0.5, 'rgba(30, 144, 255, 0.2)'); + mainAuraGradient.addColorStop(1, 'rgba(30, 144, 255, 0)'); + + ctx.beginPath(); + ctx.arc(state.player.x, state.player.y, CONFIG.player.size / 2 + 8, 0, Math.PI * 2); + ctx.fillStyle = mainAuraGradient; + ctx.fill(); + + ctx.beginPath(); + ctx.arc(state.player.x, state.player.y, CONFIG.player.size / 2, 0, Math.PI * 2); + ctx.fillStyle = '#111'; + ctx.fill(); + + ctx.beginPath(); + ctx.arc(state.player.x, state.player.y, CONFIG.player.size / 2, 0, Math.PI * 2); + ctx.strokeStyle = CONFIG.sword.colors.secondary; + ctx.lineWidth = 3; + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(state.player.x, state.player.y, CONFIG.player.size / 2 - 3, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(30, 144, 255, 0.3)'; + ctx.lineWidth = 2; + ctx.stroke(); + + const numParticles = CONFIG.defense.particleCount; + const baseOrbitRadius = CONFIG.player.size * CONFIG.defense.orbitRadiusMultiplier; + const rotationSpeed = CONFIG.defense.rotationSpeed; + + for (let i = 0; i < numParticles; i++) { + const radialOffset = Math.sin(animationTime * 0.002 + i * 0.5) * 4; + const orbitRadius = baseOrbitRadius + radialOffset; + + const angle = (i / numParticles) * Math.PI * 2 + animationTime * rotationSpeed * 0.001; + const x = state.player.x + Math.cos(angle) * orbitRadius; + const y = state.player.y + Math.sin(angle) * orbitRadius; + + const size = 2 + Math.sin(animationTime * 0.003 + i * 0.8) * 1.5; + const baseAlpha = 0.6 + Math.sin(animationTime * 0.002 + i) * 0.2; + + ctx.beginPath(); + ctx.arc(x, y, size * 2, 0, Math.PI * 2); + ctx.fillStyle = `rgba(30, 144, 255, ${baseAlpha * 0.3})`; + ctx.fill(); + + ctx.beginPath(); + ctx.arc(x, y, size, 0, Math.PI * 2); + ctx.fillStyle = `rgba(135, 206, 250, ${baseAlpha})`; + ctx.fill(); + + if (i > 0) { + const prevAngle = ((i - 1) / numParticles) * Math.PI * 2 + animationTime * rotationSpeed * 0.001; + const prevX = state.player.x + Math.cos(prevAngle) * orbitRadius; + const prevY = state.player.y + Math.sin(prevAngle) * orbitRadius; + + ctx.beginPath(); + ctx.moveTo(prevX, prevY); + ctx.lineTo(x, y); + ctx.strokeStyle = `rgba(30, 144, 255, ${baseAlpha * 0.2})`; + ctx.lineWidth = 1; + ctx.stroke(); + } + } + + // Draw the eyeball...square + const dotSize = CONFIG.player.directionIndicator.size; + + // const timeSinceLastBubble = animationTime - state.player.lastBubbleTime; + // const cooldownProgress = Math.min(timeSinceLastBubble / CONFIG.bubble.cooldown, 1); + + const dotOpacity = getDotOpacity(state, animationTime); + + ctx.fillStyle = CONFIG.player.directionIndicator.color.replace( + '1)', + `${dotOpacity})` + ); + + ctx.fillRect( + state.player.x - dotSize/2, + state.player.y - dotSize/2, + dotSize, + dotSize + ); + } else { + ctx.fillStyle = CONFIG.player.color; + ctx.fillRect( + state.player.x - CONFIG.player.size / 2, + state.player.y - CONFIG.player.size / 2, + CONFIG.player.size, + CONFIG.player.size + ); + + // direction indicator square with cooldown opacity + const dotSize = CONFIG.player.directionIndicator.size; + const dotDistance = CONFIG.player.size / 3; + const dotX = state.player.x + state.player.direction.x * dotDistance; + const dotY = state.player.y + state.player.direction.y * dotDistance; + + const timeSinceLastBubble = animationTime - state.player.lastBubbleTime; + // const cooldownProgress = Math.min(timeSinceLastBubble / CONFIG.bubble.cooldown, 1); + + const dotOpacity = getDotOpacity(state, animationTime); + + ctx.fillStyle = CONFIG.player.directionIndicator.color.replace('1)', `${dotOpacity})`); + + ctx.fillRect( + dotX - dotSize/2, + dotY - dotSize/2, + dotSize, + dotSize + ); + } + + ctx.restore(); +}; + +const renderPlayerHUD = (ctx) => { + ctx.save(); + + ctx.setTransform(1, 0, 0, 1, 0, 0); + + const circleRadius = 15; + const circleSpacing = 40; + const startX = 30; + const startY = 30; + const totalCircles = 5; + const hpPerCircle = state.player.maxHp / totalCircles; + + const currentFilledCircles = Math.ceil(state.player.hp / hpPerCircle); + if (currentFilledCircles < state.player.lastRenderedCircles) { + const circleIndex = currentFilledCircles; + const particleX = startX + circleIndex * circleSpacing; + createHealthCircleExplosion(particleX, startY); + } + state.player.lastRenderedCircles = currentFilledCircles; + + for (let i = 0; i < totalCircles; i++) { + const circleX = startX + i * circleSpacing; + const circleY = startY; + const circleStartHp = i * hpPerCircle; + const circleEndHp = (i + 1) * hpPerCircle; + let fillAmount = 1; + + if (state.player.hp <= circleStartHp) { + fillAmount = 0; + } else if (state.player.hp < circleEndHp) { + fillAmount = (state.player.hp - circleStartHp) / hpPerCircle; + } + + ctx.beginPath(); + ctx.arc(circleX, circleY, circleRadius, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.lineWidth = 2; + ctx.stroke(); + + if (fillAmount > 0) { + const gradient = ctx.createRadialGradient( + circleX, circleY, 0, + circleX, circleY, circleRadius + ); + gradient.addColorStop(0, 'rgba(255, 0, 0, 0.8)'); + gradient.addColorStop(0.7, 'rgba(200, 0, 0, 0.6)'); + gradient.addColorStop(1, 'rgba(150, 0, 0, 0.4)'); + + ctx.beginPath(); + ctx.arc(circleX, circleY, circleRadius * 0.9, 0, Math.PI * 2 * fillAmount); + ctx.lineTo(circleX, circleY); + ctx.fillStyle = gradient; + ctx.fill(); + } + } + + ctx.restore(); +}; + +const createHealthCircleExplosion = (screenX, screenY) => { + const numParticles = 20; + const colors = [ + 'rgba(255, 0, 0, 0.8)', + 'rgba(200, 0, 0, 0.6)', + 'rgba(150, 0, 0, 0.4)' + ]; + + const worldX = screenX - state.camera.x; + const worldY = screenY - state.camera.y; + + for (let i = 0; i < numParticles; i++) { + const angle = (i / numParticles) * Math.PI * 2; + const speed = 2 + Math.random() * 3; + const size = 2 + Math.random() * 3; + const color = colors[Math.floor(Math.random() * colors.length)]; + + state.particles.push({ + x: worldX, + y: worldY, + dx: Math.cos(angle) * speed, + dy: Math.sin(angle) * speed, + size: size, + color: color, + lifetime: 1000, + createdAt: animationTime + }); + } +}; + +const render = () => { + ctx.clearRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + + const screenCenterX = -state.camera.x + GAME_WIDTH / 2; + const screenCenterY = -state.camera.y + GAME_HEIGHT / 2; + + const distX = state.player.x - screenCenterX; + const distY = state.player.y - screenCenterY; + + if (Math.abs(distX) > CAMERA_DEADZONE_X / 2) { + const bufferX = (GAME_WIDTH - CAMERA_DEADZONE_X) / 2; + const targetOffsetX = distX > 0 ? GAME_WIDTH - bufferX : bufferX; + state.camera.targetX = -(state.player.x - targetOffsetX); + } + if (Math.abs(distY) > CAMERA_DEADZONE_Y / 2) { + const bufferY = (GAME_HEIGHT - CAMERA_DEADZONE_Y) / 2; + const targetOffsetY = distY > 0 ? GAME_HEIGHT - bufferY : bufferY; + state.camera.targetY = -(state.player.y - targetOffsetY); + } + + state.camera.x = lerp(state.camera.x, state.camera.targetX, CONFIG.display.camera.ease); + state.camera.y = lerp(state.camera.y, state.camera.targetY, CONFIG.display.camera.ease); + + ctx.save(); + ctx.translate(state.camera.x, state.camera.y); + + const gridSize = CONFIG.display.grid.size; + const worldSize = gridSize * CONFIG.display.grid.worldSize; + const villageSize = CONFIG.world.village.size * gridSize; + + const startX = Math.floor((-state.camera.x) / gridSize) * gridSize; + const startY = Math.floor((-state.camera.y) / gridSize) * gridSize; + const endX = startX + GAME_WIDTH + gridSize; + const endY = startY + GAME_HEIGHT + gridSize; + + ctx.fillStyle = CONFIG.display.grid.voidColor; + ctx.fillRect( + startX, startY, + endX - startX, endY - startY + ); + + // First draw the wilderness ground for the whole world + ctx.fillStyle = CONFIG.world.wilderness.groundColor; + ctx.fillRect(0, 0, worldSize, worldSize); + + // Then draw the village + ctx.fillStyle = CONFIG.world.village.groundColor; + ctx.fillRect(0, 0, villageSize, villageSize); + + const shoreWidth = 60; + const shoreColor = 'rgba(179, 220, 255, 0.3)'; + + + // FIXME: There is likely a way to do this all at once, but this was easy + // Top shore + const topShore = ctx.createLinearGradient(0, 0, 0, shoreWidth); + topShore.addColorStop(0, shoreColor); + topShore.addColorStop(1, 'rgba(255, 255, 255, 0)'); + ctx.fillStyle = topShore; + ctx.fillRect(0, 0, worldSize, shoreWidth); + + // Bottom shore + const bottomShore = ctx.createLinearGradient(0, worldSize - shoreWidth, 0, worldSize); + bottomShore.addColorStop(0, 'rgba(255, 255, 255, 0)'); + bottomShore.addColorStop(1, shoreColor); + ctx.fillStyle = bottomShore; + ctx.fillRect(0, worldSize - shoreWidth, worldSize, shoreWidth); + + // Left shore + const leftShore = ctx.createLinearGradient(0, 0, shoreWidth, 0); + leftShore.addColorStop(0, shoreColor); + leftShore.addColorStop(1, 'rgba(255, 255, 255, 0)'); + ctx.fillStyle = leftShore; + ctx.fillRect(0, 0, shoreWidth, worldSize); + + // Right shore + const rightShore = ctx.createLinearGradient(worldSize - shoreWidth, 0, worldSize, 0); + rightShore.addColorStop(0, 'rgba(255, 255, 255, 0)'); + rightShore.addColorStop(1, shoreColor); + ctx.fillStyle = rightShore; + ctx.fillRect(worldSize - shoreWidth, 0, shoreWidth, worldSize); + + + ctx.strokeStyle = CONFIG.display.grid.color; + ctx.lineWidth = 1; + for (let x = 0; x < worldSize; x += gridSize) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, worldSize); + ctx.stroke(); + } + for (let y = 0; y < worldSize; y += gridSize) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(worldSize, y); + ctx.stroke(); + } + + // Now add vegetation + for (let x = startX; x < endX; x += gridSize) { + for (let y = startY; y < endY; y += gridSize) { + if (x >= worldSize || y >= worldSize) continue; + if (x < villageSize && y < villageSize) continue; + + const cellX = Math.floor(x / gridSize); + const cellY = Math.floor(y / gridSize); + + if (cellX < 0 || cellY < 0 || cellX >= CONFIG.display.grid.worldSize || cellY >= CONFIG.display.grid.worldSize) continue; + + const random = seededRandom(cellX, cellY); + + // Trees + if (random < CONFIG.world.wilderness.vegetation.tree.frequency) { + const size = CONFIG.world.wilderness.vegetation.tree.size; + const treeSize = size.min + seededRandom(cellX * 2, cellY * 2) * (size.max - size.min); + + // Add tree to collision map + if (CONFIG.collision.enabled && CONFIG.collision.vegetation.tree.enabled) { + addToCollisionMap(cellX, cellY, 'tree'); + } + + const sides = Math.floor(10 + seededRandom(cellX * 3, cellY * 3) * 13); // 10 to 22 sides + const colorIndex = Math.floor(seededRandom(cellX * 4, cellY * 4) * CONFIG.world.wilderness.vegetation.tree.colors.length); + ctx.fillStyle = CONFIG.world.wilderness.vegetation.tree.colors[colorIndex]; + ctx.beginPath(); + + for (let i = 0; i < sides; i++) { + const angle = (i / sides) * Math.PI * 2; + + const radiusVariation = 0.8 + seededRandom(cellX * i, cellY * i) * 0.4; + const pointRadius = treeSize * radiusVariation; + + const px = x + gridSize/2 + Math.cos(angle) * pointRadius; + const py = y + gridSize/2 + Math.sin(angle) * pointRadius; + + if (i === 0) { + ctx.moveTo(px, py); + } else { + ctx.lineTo(px, py); + } + } + + ctx.closePath(); + ctx.fill(); + } + + // Mushrooms + else if (random < CONFIG.world.wilderness.vegetation.tree.frequency + + CONFIG.world.wilderness.vegetation.mushroom.frequency) { + const config = CONFIG.world.wilderness.vegetation.mushroom; + + const useSingleColor = seededRandom(cellX * 31, cellY * 31) < config.pattern.singleColor; + const cellColorIndex = Math.floor(seededRandom(cellX * 13, cellY * 13) * config.colors.length); + + for (let i = config.pattern.margin; i < gridSize - config.pattern.margin; i += config.pattern.spacing) { + for (let j = config.pattern.margin; j < gridSize - config.pattern.margin; j += config.pattern.spacing) { + const offsetX = (Math.floor(j / config.pattern.spacing) % 2) * + (config.pattern.spacing * config.pattern.offset); + const px = x + i + offsetX; + const py = y + j; + const variation = { + x: (seededRandom(cellX * i, cellY * j) - 0.5) * config.pattern.variation * config.pattern.spacing, + y: (seededRandom(cellX * j, cellY * i) - 0.5) * config.pattern.variation * config.pattern.spacing + }; + + const colorIndex = useSingleColor ? cellColorIndex : + Math.floor(seededRandom(cellX * i, cellY * j) * config.colors.length); + ctx.fillStyle = config.colors[colorIndex]; + + ctx.beginPath(); + ctx.arc( + px + variation.x, + py + variation.y, + config.pattern.size, + 0, Math.PI * 2 + ); + ctx.fill(); + } + } + } + // Flowers + else if (random < CONFIG.world.wilderness.vegetation.tree.frequency + + CONFIG.world.wilderness.vegetation.mushroom.frequency + + CONFIG.world.wilderness.vegetation.flower.frequency) { + const config = CONFIG.world.wilderness.vegetation.flower; + + const colorIndex = Math.floor(seededRandom(cellX * 13, cellY * 13) * config.colors.length); + ctx.fillStyle = config.colors[colorIndex]; + + const baseRotation = config.pattern.rotation + + (seededRandom(cellX * 14, cellY * 14) - 0.5) * config.pattern.variation; + + for (let i = config.pattern.margin; i < gridSize - config.pattern.margin; i += config.pattern.spacing) { + for (let j = config.pattern.margin; j < gridSize - config.pattern.margin; j += config.pattern.spacing) { + const offsetX = (Math.floor(j / config.pattern.spacing) % 2) * (config.pattern.spacing / 2); + const px = x + i + offsetX; + const py = y + j; + + const variation = { + x: (seededRandom(cellX * i, cellY * j) - 0.5) * 4, + y: (seededRandom(cellX * j, cellY * i) - 0.5) * 4 + }; + + ctx.beginPath(); + ctx.save(); + ctx.translate(px + variation.x, py + variation.y); + ctx.rotate(baseRotation + (seededRandom(cellX * i, cellY * j) - 0.5) * 0.5); + + const size = config.pattern.size * (0.8 + seededRandom(cellX * i, cellY * j) * 0.4); + ctx.moveTo(-size/2, size/2); + ctx.lineTo(size/2, size/2); + ctx.lineTo(0, -size/2); + ctx.closePath(); + + ctx.fill(); + ctx.restore(); + } + } + } + + // Grass + else if (random < CONFIG.world.wilderness.vegetation.tree.frequency + + CONFIG.world.wilderness.vegetation.mushroom.frequency + + CONFIG.world.wilderness.vegetation.flower.frequency + + CONFIG.world.wilderness.vegetation.grass.frequency || + (seededRandom(cellX * 50, cellY * 50) < + CONFIG.world.wilderness.vegetation.grass.spreadFactor && + hasAdjacentGrass(cellX, cellY))) { + + const config = CONFIG.world.wilderness.vegetation.grass; + + ctx.strokeStyle = config.colors[0]; + ctx.lineWidth = 1; + + const baseAngle = config.hatch.angle + + (seededRandom(cellX * 20, cellY * 20) - 0.5) * config.hatch.variation; + + for (let i = config.hatch.margin; i < gridSize - config.hatch.margin; i += config.hatch.spacing) { + for (let j = config.hatch.margin; j < gridSize - config.hatch.margin; j += config.hatch.spacing) { + const hatchX = x + i; + const hatchY = y + j; + + const offsetX = (seededRandom(cellX * i, cellY * j) - 0.5) * 2; + const offsetY = (seededRandom(cellX * j, cellY * i) - 0.5) * 2; + + ctx.beginPath(); + ctx.moveTo( + hatchX + offsetX, + hatchY + offsetY + ); + ctx.lineTo( + hatchX + Math.cos(baseAngle) * config.hatch.length + offsetX, + hatchY + Math.sin(baseAngle) * config.hatch.length + offsetY + ); + ctx.stroke(); + } + } + } + } + } + + // After drawing vegetation but before drawing the player + state.villagers.forEach(villager => { + ctx.save(); + ctx.fillStyle = villager.color; + + // Bobbing + if (villager.status === 'rescued') { + const bobOffset = Math.sin(animationTime * villager.bobSpeed) * villager.bobAmplitude; + drawVillagerShape(ctx, villager.x, villager.y + bobOffset, villager.shape, CONFIG.world.villagers.size); + } else if (villager.status === 'lost') { + const lostBobOffset = Math.sin(animationTime * villager.lostBobSpeed) * villager.lostBobAmplitude; + drawVillagerShape(ctx, villager.x + lostBobOffset, villager.y, villager.shape, CONFIG.world.villagers.size); + } else { + drawVillagerShape(ctx, villager.x, villager.y, villager.shape, CONFIG.world.villagers.size); + } + + ctx.fill(); + ctx.restore(); + }); + + enemySystem.renderEnemies(ctx, state.enemies); + renderPlayer(); + + state.footprints.forEach(footprint => { + const age = (animationTime - footprint.createdAt) / CONFIG.footprints.lifetime; + if (age >= 1) return; + + const alpha = Math.max(0, 1 - age * age); + + ctx.save(); + ctx.translate(footprint.x + footprint.offset, footprint.y + footprint.offset); + + const radius = Math.max(0.1, footprint.size * (1 - age * 0.5)); + + if (radius > 0) { + ctx.beginPath(); + ctx.arc(0, 0, radius * 2, 0, Math.PI * 2); + ctx.fillStyle = `rgba(17, 17, 17, ${alpha * 0.1})`; + ctx.fill(); + + ctx.beginPath(); + ctx.arc(0, 0, radius, 0, Math.PI * 2); + ctx.fillStyle = `rgba(17, 17, 17, ${alpha * 0.3})`; + ctx.fill(); + } + + ctx.restore(); + }); + + 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; + }); + + renderPlayerHUD(ctx); + + if (state.player.isInvulnerable && animationTime >= state.player.invulnerableUntil) { + state.player.isInvulnerable = false; + } + + renderDiamonds(); + + ctx.restore(); +}; + + +const updatePlayer = () => { + Object.assign(state, weaponSystems.updateBubbles(state, animationTime)); + Object.assign(state, weaponSystems.updateSwordSwing(state, animationTime)); + Object.assign(state, movementSystem.updatePosition(state, keys)); + + state.footprints = state.footprints.filter(footprint => { + return (animationTime - footprint.createdAt) < CONFIG.footprints.lifetime; + }); + + if (!keys.size && !state.player.isSwinging && !state.player.isDefending) { + const idleTime = animationTime - state.player.lastInputTime; + + if (idleTime > CONFIG.player.idle.startDelay) { + const lookAngle = Math.sin(animationTime * CONFIG.player.idle.lookSpeed) * CONFIG.player.idle.lookRadius; + const baseAngle = Math.atan2(state.player.baseDirection.y, state.player.baseDirection.x); + const newAngle = baseAngle + lookAngle; + + state.player.direction = { + x: Math.cos(newAngle), + y: Math.sin(newAngle) + }; + } else { + state.player.direction = { ...state.player.baseDirection }; + } + } else { + if (state.player.isSwinging || state.player.isDefending) { + state.player.lastInputTime = animationTime; + } + } + + state.villagers.forEach(villager => { + if (villager.status === 'rescued') return; + + const dx = state.player.x - villager.x; + const dy = state.player.y - villager.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < (CONFIG.player.size + CONFIG.world.villagers.size) / 2) { + villager.status = 'rescued'; + state.player.rescuedCount++; + + if (state.player.rescuedCount >= CONFIG.player.equipment.swordUnlockCount && !state.player.swordUnlocked) { + state.player.swordUnlocked = true; + state.player.equipment = 'sword'; // Auto-equip sword + showSwordUnlockAnimation(); + } + + const villageSize = CONFIG.world.village.size * CONFIG.display.grid.size; + const margin = CONFIG.world.villagers.size; + + villager.x = margin + Math.random() * (villageSize - margin * 2); + villager.y = margin + Math.random() * (villageSize - margin * 2); + + const allRescued = state.villagers.every(v => v.status === 'rescued'); + if (allRescued && !state.gameComplete) { + state.gameComplete = true; + const message = document.createElement('div'); + message.textContent = CONFIG.world.villagers.rescueMessage; + message.style.position = 'fixed'; + message.style.top = '50%'; + message.style.left = '50%'; + message.style.transform = 'translate(-50%, -50%)'; + message.style.padding = '20px'; + message.style.backgroundColor = 'rgba(0, 0, 0, 0.8)'; + message.style.color = 'white'; + message.style.borderRadius = '10px'; + message.style.fontSize = '24px'; + document.body.appendChild(message); + + setTimeout(() => { + document.body.removeChild(message); + }, CONFIG.world.villagers.messageDisplayTime); + } + } + }); +}; + +const gameLoop = (currentTime) => { + if (!lastFrameTime) { + lastFrameTime = currentTime; + animationTime = 0; + } + + const deltaTime = currentTime - lastFrameTime; + + if (deltaTime >= FRAME_TIME) { + animationTime += FRAME_TIME; + + if (state.player.hp <= 0 && !state.player.isDead) { + state.player.isDead = true; + showGameOver(); + } + + updatePlayer(); + state.enemies = enemySystem.updateEnemies(state.enemies, deltaTime); + collectDiamonds(); + render(); + + lastFrameTime = currentTime; + } + + requestAnimationFrame(gameLoop); +}; + + + + + +const canvas = document.getElementById('gameCanvas'); +const ctx = canvas.getContext('2d'); + +const resizeCanvas = () => { + GAME_WIDTH = window.innerWidth; + GAME_HEIGHT = window.innerHeight; + canvas.width = GAME_WIDTH; + canvas.height = GAME_HEIGHT; + if (!state.player.x) { + state.player.x = GAME_WIDTH / 2; + state.player.y = GAME_HEIGHT / 2; + } +}; + +window.addEventListener('keydown', handleKeyDown); +window.addEventListener('keyup', handleKeyUp); +window.addEventListener('resize', resizeCanvas); + +resizeCanvas(); + +// Initialize villagers after collision map is populated +state.villagers = generateVillagers(); +state.enemies = enemySystem.generateEnemies(state.villagers, state.collisionMap); + +requestAnimationFrame(gameLoop); + +const getDotOpacity = (state, animationTime) => { + const timeSinceLastBubble = animationTime - state.player.lastBubbleTime; + const bubbleCooldownProgress = Math.min(timeSinceLastBubble / CONFIG.bubble.cooldown, 1); + const bubbleOpacity = 0.1 + (bubbleCooldownProgress * 0.9); + + let dashOpacity = 1; + if (state.player.dashExhausted) { + const timeSinceExhaustion = animationTime - state.player.lastDashEnd; + const dashCooldownProgress = timeSinceExhaustion / CONFIG.player.dash.cooldown; + dashOpacity = 0.1 + (Math.min(dashCooldownProgress, 1) * 0.9); + } else if (state.player.isDashing) { + const dashProgress = (animationTime - state.player.dashStartTime) / CONFIG.player.dash.duration; + dashOpacity = 1 - (dashProgress * 0.7); + } + + let blinkOpacity = 1; + if (state.player.isDefending) { + const blinkPhase = Math.sin(animationTime * 0.002); + if (blinkPhase > 0.7) { + blinkOpacity = 0.3 + (blinkPhase - 0.7) * 2; + } + } + + return Math.min(bubbleOpacity, dashOpacity, blinkOpacity); +}; + +const checkCollision = (x, y, size) => { + const halfSize = size / 2; + const corners = [ + { x: x - halfSize, y: y - halfSize }, // Top-left + { x: x + halfSize, y: y - halfSize }, // Top-right + { x: x - halfSize, y: y + halfSize }, // Bottom-left + { x: x + halfSize, y: y + halfSize } // Bottom-right + ]; + + return corners.some(corner => isPositionBlocked(corner.x, corner.y)); +}; + +const createNaturalCluster = (centerX, centerY, config, cellX, cellY, i) => { + const angle = seededRandom(cellX * 8 + i, cellY * 8) * Math.PI * 2; + const distanceFromCenter = seededRandom(cellX * 9 + i, cellY * 9); + const distance = Math.sqrt(distanceFromCenter) * config.size.max; + + const variation = { + x: (seededRandom(cellX * 10 + i, cellY * 10) - 0.5) * config.size.max, + y: (seededRandom(cellX * 11 + i, cellY * 11) - 0.5) * config.size.max + }; + + return { + x: centerX + Math.cos(angle) * distance + variation.x, + y: centerY + Math.sin(angle) * distance + variation.y, + size: config.size.min + + seededRandom(cellX * 3 + i, cellY * 3) * + (config.size.max - config.size.min) * + // This makes stuff on the edge a little smaller + (1 - (distance / config.cluster.spread) * 0.3) + }; +}; + +const renderTree = (ctx, x, y, size, isTop = false) => { + ctx.fillStyle = CONFIG.world.wilderness.vegetation.tree.color; + if (isTop) { + ctx.beginPath(); + ctx.arc( + x + CONFIG.display.grid.size/2, + y + CONFIG.display.grid.size/2, + size, + -Math.PI, 0 + ); + ctx.fill(); + } else { + ctx.beginPath(); + ctx.arc( + x + CONFIG.display.grid.size/2, + y + CONFIG.display.grid.size/2, + size, + 0, Math.PI + ); + ctx.fill(); + } +}; + +const hasAdjacentGrass = (cellX, cellY) => { + const adjacentCells = [ + [-1, -1], [0, -1], [1, -1], + [-1, 0], [1, 0], + [-1, 1], [0, 1], [1, 1] + ]; + + return adjacentCells.some(([dx, dy]) => { + const adjX = cellX + dx; + const adjY = cellY + dy; + const adjRandom = seededRandom(adjX, adjY); + + return adjRandom < CONFIG.world.wilderness.vegetation.grass.frequency; + }); +}; + +const getCellInfo = (x, y) => { + const cellX = Math.floor(x / CONFIG.display.grid.size); + const cellY = Math.floor(y / CONFIG.display.grid.size); + const random = seededRandom(cellX, cellY); + + // Determine biome + const isInVillage = x < (CONFIG.world.village.size * CONFIG.display.grid.size) && + y < (CONFIG.world.village.size * CONFIG.display.grid.size); + + // If in village, return early with no vegetation + if (isInVillage) { + return { + position: { cellX, cellY }, + biome: 'Village', + vegetation: { + tree: false, + mushrooms: false, + flowers: false, + grass: false + } + }; + } + + // Check for vegetation only if in wilderness + const hasTree = random < CONFIG.world.wilderness.vegetation.tree.frequency; + const hasMushrooms = random < (CONFIG.world.wilderness.vegetation.tree.frequency + + CONFIG.world.wilderness.vegetation.mushroom.frequency); + const hasFlowers = random < (CONFIG.world.wilderness.vegetation.tree.frequency + + CONFIG.world.wilderness.vegetation.mushroom.frequency + + CONFIG.world.wilderness.vegetation.flower.frequency); + const hasGrass = random < (CONFIG.world.wilderness.vegetation.tree.frequency + + CONFIG.world.wilderness.vegetation.mushroom.frequency + + CONFIG.world.wilderness.vegetation.flower.frequency + + CONFIG.world.wilderness.vegetation.grass.frequency) || + (seededRandom(cellX * 50, cellY * 50) < + CONFIG.world.wilderness.vegetation.grass.spreadFactor && + hasAdjacentGrass(cellX, cellY)); + + return { + position: { cellX, cellY }, + biome: 'Wilderness', + vegetation: { + tree: hasTree, + mushrooms: !hasTree && hasMushrooms, + flowers: !hasTree && !hasMushrooms && hasFlowers, + grass: !hasTree && !hasMushrooms && !hasFlowers && hasGrass + } + }; +}; + +const createFloatingMessage = (messageConfig) => { + const { + text, + duration = null, // null for permanent + backgroundColor = 'rgba(0, 0, 0, 0.8)', + glowColor = null, + glowDuration = 2000 + } = messageConfig; + + const container = document.createElement('div'); + container.style.position = 'fixed'; + container.style.top = '50%'; + container.style.left = '50%'; + container.style.transform = 'translate(-50%, -50%)'; + container.style.padding = '20px'; + container.style.backgroundColor = backgroundColor; + container.style.borderRadius = '10px'; + container.style.zIndex = '1000'; + + const message = document.createElement('div'); + message.textContent = text; + message.style.color = 'white'; + message.style.fontSize = '24px'; + message.style.textAlign = 'center'; + container.appendChild(message); + + if (glowColor) { + container.style.animation = `glow ${glowDuration}ms ease-in-out`; + + const style = document.createElement('style'); + style.textContent = ` + @keyframes glow { + 0% { box-shadow: 0 0 0 0 ${glowColor}; opacity: 0; } + 50% { box-shadow: 0 0 30px 10px ${glowColor}; opacity: 1; } + 100% { box-shadow: 0 0 0 0 ${glowColor}; opacity: ${duration ? 0 : 1}; } + } + `; + document.head.appendChild(style); + + setTimeout(() => { + document.head.removeChild(style); + if (!duration) { + container.style.boxShadow = `0 0 30px 10px ${glowColor}`; + } + }, glowDuration); + } + + document.body.appendChild(container); + if (duration) { + setTimeout(() => { + document.body.removeChild(container); + }, duration); + } + + return container; +}; + +const showGameOver = () => { + createFloatingMessage({ + text: 'Game Over\nPress Space to restart', + duration: null, + glowColor: 'rgba(255, 0, 0, 0.6)' + }); +}; + +const showSwordUnlockAnimation = () => { + createFloatingMessage({ + text: CONFIG.player.equipment.unlockAnimation.messageText, + duration: CONFIG.player.equipment.unlockAnimation.duration, + glowColor: CONFIG.player.equipment.unlockAnimation.glowColor, + glowDuration: 2000 + }); +}; + +const collectDiamonds = () => { + state.diamonds.forEach(diamond => { + const dx = state.player.x - diamond.x; + const dy = state.player.y - diamond.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < (CONFIG.player.size / 2 + diamond.size / 2) && !diamond.collected) { + diamond.collected = true; + state.player.hp = Math.min(state.player.hp + 1, state.player.maxHp); // Restore 1 HP, max out at maxHp + } + }); + + state.diamonds = state.diamonds.filter(diamond => !diamond.collected); +}; + +const renderDiamonds = () => { + state.diamonds.forEach(diamond => { + ctx.save(); + + const pulseIntensity = 0.5 + Math.sin(animationTime * 0.005) * 0.3; // Pulsing between 0.2 and 0.8 + + // Outer glow + const gradient = ctx.createRadialGradient( + diamond.x, diamond.y, diamond.size * 0.2, + diamond.x, diamond.y, diamond.size * 1.5 + ); + gradient.addColorStop(0, `rgba(255, 215, 0, ${pulseIntensity})`); // Gold center + gradient.addColorStop(0.6, 'rgba(255, 215, 0, 0.2)'); // Fading gold + gradient.addColorStop(1, 'rgba(255, 215, 0, 0)'); // Transparent edge + + ctx.beginPath(); + ctx.arc(diamond.x, diamond.y, diamond.size * 1.5, 0, Math.PI * 2); + ctx.fillStyle = gradient; + ctx.fill(); + + // Inner diamond + ctx.beginPath(); + ctx.arc(diamond.x, diamond.y, diamond.size * 0.4, 0, Math.PI * 2); // Make core smaller + ctx.fillStyle = `rgba(255, 223, 0, ${0.8 + pulseIntensity * 0.2})`; // Brighter gold + ctx.fill(); + + // Shiny + ctx.beginPath(); + ctx.arc( + diamond.x - diamond.size * 0.2, + diamond.y - diamond.size * 0.2, + diamond.size * 0.15, + 0, Math.PI * 2 + ); + ctx.fillStyle = `rgba(255, 255, 255, ${0.6 + pulseIntensity * 0.4})`; + ctx.fill(); + + ctx.restore(); + }); +}; \ No newline at end of file diff --git a/html/plains/index.html b/html/plains/index.html new file mode 100644 index 0000000..af634ad --- /dev/null +++ b/html/plains/index.html @@ -0,0 +1,32 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <title>Plains</title> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta name="description" content="Help little black square rescue the villagers, and battle the monsters."> + <style> + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + body { + background: #f0f0f0; + overflow: hidden; + } + canvas { + display: block; + width: 100vw; + height: 100vh; + } + </style> +</head> +<body> + <canvas id="gameCanvas"></canvas> + <a href="about.html" style="position: absolute; top: 12px; right: 12px; color: black; text-decoration: underline; font-size: 16px;">About</a> + <script src="enemies.js"></script> + <script src="game.js"></script> +</body> +</html> + |