From 3d6485da59e89edc672466d168adfa5eb152c159 Mon Sep 17 00:00:00 2001 From: elioat Date: Sat, 14 Dec 2024 17:18:45 -0500 Subject: * --- html/plains/game.js | 1054 ++++++++++++++++++++++++++++++++++++------------ html/plains/index.html | 8 +- 2 files changed, 798 insertions(+), 264 deletions(-) diff --git a/html/plains/game.js b/html/plains/game.js index 9a9fe4c..68a31ed 100644 --- a/html/plains/game.js +++ b/html/plains/game.js @@ -1,9 +1,27 @@ +// ============= Utility Functions ============= +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) +}); + +// ============= Configuration ============= const CONFIG = { display: { fps: 60, grid: { size: 100, - color: '#ddd' + color: 'rgba(221, 221, 221, 0.5)', + worldSize: 100, + voidColor: '#e6f3ff' }, camera: { deadzoneMultiplierX: 0.6, @@ -16,7 +34,7 @@ const CONFIG = { primary: '#4169E1', secondary: '#1E90FF', tertiary: '#0000CD', - glow: 'rgba(30, 144, 255, 0.25)', + glow: 'rgba(0, 128, 255, 0.5)', inner: '#0000CD' } }, @@ -36,9 +54,9 @@ const CONFIG = { exhaustedAt: 0 // Track when dash was exhausted }, idle: { - startDelay: 2000, // Start idle animation after 2 seconds + startDelay: 1500, // Start idle animation after 1.5 seconds lookSpeed: 0.001, // Speed of the looking animation - lookRadius: 0.3 // How far to look around (in radians) + lookRadius: 0.4 // How far to look around (in radians) } }, sword: { @@ -73,27 +91,105 @@ const CONFIG = { lifetime: 1000, spacing: 300, size: 5 + }, + world: { + village: { + size: 25, + groundColor: '#f2f2f2' + }, + 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, // Base 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, // Slight randomness in angle + margin: 4 + }, + spreadFactor: 0.6 // Add this for grass spreading + } + } + } + }, + collision: { + enabled: true, + vegetation: { + tree: { + enabled: true, + sizeMultiplier: 1.0 + } + } } }; -// Set references to shared colors + CONFIG.sword.colors = CONFIG.effects.colors; CONFIG.bubble.colors = CONFIG.effects.colors; + +// ============= Global State ============= 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; + +// ============= State Management ============= const createInitialState = () => ({ player: { - x: window.innerWidth / 2, - y: window.innerHeight / 2, + x: CONFIG.player.size, // A bit offset from the edge + y: CONFIG.player.size, // A bit offset from the edge isDefending: false, direction: { x: 0, y: -1 }, swordAngle: 0, @@ -102,12 +198,12 @@ const createInitialState = () => ({ bubbles: [], bubbleParticles: [], lastBubbleTime: 0, - dashStartTime: 0, // When the current dash started - isDashing: false, // Currently dashing? - dashExhausted: false, // Is dash on cooldown? - lastDashEnd: 0, // When the last dash ended - lastInputTime: 0, // Track when the last input occurred - baseDirection: { x: 0, y: -1 } // Store the actual facing direction + 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 + baseDirection: { x: 0, y: -1 }, + lastDashEnd: 0 }, particles: [], footprints: [], @@ -117,16 +213,71 @@ const createInitialState = () => ({ y: 0, targetX: 0, targetY: 0 - } + }, + collisionMap: new Map() }); let state = createInitialState(); -const canvas = document.getElementById('gameCanvas'); -const ctx = canvas.getContext('2d'); +// ============= Input Handling ============= const keys = new Set(); +const handleKeyDown = (e) => { + 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; @@ -159,120 +310,70 @@ const inputHandlers = { } }; -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; +// ============= Movement System ============= +const calculateMovement = (keys) => { + let dx = 0; + let dy = 0; - if (Math.random() >= CONFIG.bubble.particleEmitRate * (1 - ageRatio)) { - return []; + 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 }; } - 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); + // Update last input time when moving + state.player.lastInputTime = animationTime; - 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); + const length = Math.sqrt(dx * dx + dy * dy); + const normalizedDx = dx / length; + const normalizedDy = dy / length; - 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 isStrafing = keys.has(CONFIG.player.strafeKey); - 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) - }; + const newDirection = isStrafing ? + { ...state.player.direction } : // strafe + { x: normalizedDx, y: normalizedDy }; // normal movement + + // Update base direction when not strafing + if (!isStrafing) { + state.player.baseDirection = { ...newDirection }; + } return { - ...state, - player: { - ...state.player, - bubbles: [...state.player.bubbles, bubble], - lastBubbleTime: animationTime - } + moving: true, + dx: normalizedDx, + dy: normalizedDy, + direction: newDirection }; }; -const weaponSystems = { - updateBubbles: (state, animationTime) => { - const updatedBubbles = state.player.bubbles - .filter(bubble => animationTime - bubble.createdAt < CONFIG.bubble.lifetime) - .map(bubble => 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 - ] - } - }; - }, +const isPositionBlocked = (x, y) => { + const cell = worldToGrid(x, y); + const key = `${cell.x},${cell.y}`; + if (!state.collisionMap.has(key)) return false; - updateSwordSwing: (state, animationTime) => { - 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; - - return { - ...state, - player: { - ...state.player, - swordAngle: newAngle, - isSwinging: !swingComplete - } - }; - } + const obstacle = state.collisionMap.get(key); + const obstacleRadius = CONFIG.player.size / 2; // Use player size for all collision + + // Distance check from center of 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 = { @@ -307,7 +408,7 @@ const movementSystem = { } isDashing = true; - // Check if dash duration exceeded + // Check if dash duration is exhausted if (animationTime - dashStartTime >= CONFIG.player.dash.duration) { isDashing = false; dashExhausted = true; @@ -322,7 +423,6 @@ const movementSystem = { CONFIG.player.speed * CONFIG.player.sprintMultiplier : CONFIG.player.speed; - // Handle footprint creation const timeSinceLastFootprint = animationTime - state.lastFootprintTime; const currentSpacing = isDashing ? CONFIG.footprints.spacing * CONFIG.player.sprintMultiplier : @@ -341,12 +441,39 @@ const movementSystem = { )]; } + const worldBounds = { + min: 0, + max: CONFIG.display.grid.size * CONFIG.display.grid.worldSize + }; + + // After calculating new position, clamp it to world bounds + 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)); + + // Check for collisions at the new position + 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); // Use 80% of player radius for better feel + + // Only update position if there's no collision + const finalX = wouldCollide ? state.player.x : clampedX; + const finalY = wouldCollide ? state.player.y : clampedY; + return { ...state, player: { ...state.player, - x: state.player.x + movement.dx * speed, - y: state.player.y + movement.dy * speed, + x: finalX, + y: finalY, direction: movement.direction, isDashing, dashStartTime, @@ -360,79 +487,126 @@ const movementSystem = { } }; -const handleKeyDown = (e) => { - keys.add(e.key); + +// ============= Weapon Systems ============= +const updateBubble = (bubble, animationTime) => { + const age = animationTime - bubble.createdAt; + const ageRatio = age / CONFIG.bubble.lifetime; + const speedMultiplier = Math.pow(1 - ageRatio, 0.5); - if (e.key === 'z' && !state.player.isDefending) { - Object.assign(state, inputHandlers.handleAttack(state, animationTime)); - } + 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 (e.key === 'e') { - Object.assign(state, inputHandlers.handleEquipmentSwitch(state)); + if (Math.random() >= CONFIG.bubble.particleEmitRate * (1 - ageRatio)) { + return []; } - if (e.key === 'x') { - Object.assign(state, { + 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 weaponSystems = { + updateBubbles: (state, animationTime) => { + const updatedBubbles = state.player.bubbles + .filter(bubble => animationTime - bubble.createdAt < CONFIG.bubble.lifetime) + .map(bubble => updateBubble(bubble, animationTime)); + + const newParticles = updatedBubbles + .flatMap(bubble => generateBubbleParticles(bubble, animationTime)); + + return { ...state, player: { ...state.player, - isDefending: true + bubbles: updatedBubbles, + bubbleParticles: [ + ...updateBubbleParticles(state.player.bubbleParticles, animationTime), + ...newParticles + ] } - }); - } - - // Update last input time for any key press - state.player.lastInputTime = animationTime; -}; + }; + }, -const handleKeyUp = (e) => { - keys.delete(e.key); - if (e.key === 'x') { - Object.assign(state, { + updateSwordSwing: (state, animationTime) => { + 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; + + return { ...state, player: { ...state.player, - isDefending: false + swordAngle: newAngle, + isSwinging: !swingComplete } - }); + }; } }; -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; - }); - - // Update player direction for idle animation - if (!keys.size && !state.player.isSwinging && !state.player.isDefending) { - const idleTime = animationTime - state.player.lastInputTime; - - if (idleTime > CONFIG.player.idle.startDelay) { - // Calculate looking direction using sine waves - 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 { - // Reset direction to base direction when not idle - state.player.direction = { ...state.player.baseDirection }; - } - } else { - // Update last input time when other actions occur - if (state.player.isSwinging || state.player.isDefending) { - state.player.lastInputTime = animationTime; - } - } -}; +// ============= Particle Systems ============= const createParticle = (x, y, angle) => ({ x, y, @@ -452,28 +626,26 @@ const createFootprint = (x, y, direction) => ({ offset: (Math.random() - 0.5) * 5 }); + +// ============= Rendering System ============= const renderPlayer = () => { ctx.save(); - // Render bubble particles state.player.bubbleParticles.forEach(particle => { const age = (animationTime - particle.createdAt) / CONFIG.bubbleParticle.lifetime; const alpha = (1 - age) * 0.8; - // Draw outer glow - ctx.fillStyle = `rgba(65, 105, 225, ${alpha * 0.3})`; // Royal Blue + 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(); - // Draw core - ctx.fillStyle = `rgba(30, 144, 255, ${alpha})`; // Dodger Blue + 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(); }); - // Render bubbles with adjusted fade state.player.bubbles.forEach(bubble => { const age = (animationTime - bubble.createdAt) / CONFIG.bubble.lifetime; const alpha = Math.pow(1 - age, CONFIG.bubble.fadeExponent); @@ -483,7 +655,6 @@ const renderPlayer = () => { ctx.translate(bubble.x, bubble.y); ctx.rotate(bubble.angle); - // Draw outer glow with adjusted fade ctx.beginPath(); ctx.arc(0, 0, expandedSize * 1.5, -CONFIG.bubble.arcWidth * (1 + age * 0.5), @@ -495,14 +666,13 @@ const renderPlayer = () => { ctx.strokeStyle = CONFIG.bubble.colors.glow.replace(')', `, ${alpha * 0.5})`); ctx.stroke(); - // Draw main arc with gradient const gradient = ctx.createLinearGradient( -expandedSize, 0, expandedSize, 0 ); - gradient.addColorStop(0, `rgba(65, 105, 225, ${alpha})`); - gradient.addColorStop(0.6, `rgba(30, 144, 255, ${alpha})`); - gradient.addColorStop(1, `rgba(0, 0, 205, ${alpha})`); + 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, @@ -514,7 +684,6 @@ const renderPlayer = () => { ctx.strokeStyle = gradient; ctx.stroke(); - // Draw inner bright line ctx.beginPath(); ctx.arc(0, 0, expandedSize * 0.9, -CONFIG.bubble.arcWidth * 0.6 * (1 + age * 0.5), @@ -628,7 +797,6 @@ const renderPlayer = () => { 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++) { @@ -666,14 +834,14 @@ const renderPlayer = () => { } } - // Add direction indicator square in the center + // Draw the eyeball...square const dotSize = CONFIG.player.directionIndicator.size; - // Calculate cooldown progress (0 to 1) + // Calculate cooldown progress const timeSinceLastBubble = animationTime - state.player.lastBubbleTime; - const cooldownProgress = Math.min(timeSinceLastBubble / CONFIG.bubble.cooldown, 1); + // const cooldownProgress = Math.min(timeSinceLastBubble / CONFIG.bubble.cooldown, 1); - // Set opacity based on cooldown (0.1 to 1) + // Set opacity based on cooldown's progress const dotOpacity = getDotOpacity(state, animationTime); ctx.fillStyle = CONFIG.player.directionIndicator.color.replace( @@ -703,11 +871,10 @@ const renderPlayer = () => { const dotX = state.player.x + state.player.direction.x * dotDistance; const dotY = state.player.y + state.player.direction.y * dotDistance; - // Calculate cooldown progress (0 to 1) const timeSinceLastBubble = animationTime - state.player.lastBubbleTime; - const cooldownProgress = Math.min(timeSinceLastBubble / CONFIG.bubble.cooldown, 1); + // const cooldownProgress = Math.min(timeSinceLastBubble / CONFIG.bubble.cooldown, 1); - // Set opacity based on cooldown (0.1 to 1) + // Set opacity based on cooldown's progress const dotOpacity = getDotOpacity(state, animationTime); ctx.fillStyle = CONFIG.player.directionIndicator.color.replace( @@ -726,10 +893,6 @@ const renderPlayer = () => { ctx.restore(); }; -const lerp = (start, end, t) => { - return start * (1 - t) + end * t; -}; - const render = () => { ctx.clearRect(0, 0, GAME_WIDTH, GAME_HEIGHT); @@ -755,30 +918,273 @@ const render = () => { ctx.save(); ctx.translate(state.camera.x, state.camera.y); - + const gridSize = CONFIG.display.grid.size; - ctx.strokeStyle = CONFIG.display.grid.color; - ctx.lineWidth = 1; + const worldSize = gridSize * CONFIG.display.grid.worldSize; + const villageSize = CONFIG.world.village.size * gridSize; + // Calculate visible area 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; - for (let x = startX; x < endX; x += gridSize) { + // Draw void background + 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 ground in the top-left + ctx.fillStyle = CONFIG.world.village.groundColor; + ctx.fillRect(0, 0, villageSize, villageSize); + + // After drawing village and wilderness grounds, but before grid: + + // The shore gradient + 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); + + // Draw grid inside of the world + ctx.strokeStyle = CONFIG.display.grid.color; + ctx.lineWidth = 1; + + // Draw vertical lines + for (let x = 0; x < worldSize; x += gridSize) { ctx.beginPath(); - ctx.moveTo(x, startY); - ctx.lineTo(x, endY); + ctx.moveTo(x, 0); + ctx.lineTo(x, worldSize); ctx.stroke(); } - - for (let y = startY; y < endY; y += gridSize) { + + // Draw horizontal lines + for (let y = 0; y < worldSize; y += gridSize) { ctx.beginPath(); - ctx.moveTo(startX, y); - ctx.lineTo(endX, y); + ctx.moveTo(0, y); + ctx.lineTo(worldSize, y); ctx.stroke(); } - + + // Draw vegetation in the wilderness + 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'); + } + + // Generate number of sides for this tree + const sides = Math.floor(10 + seededRandom(cellX * 3, cellY * 3) * 13); // 10 to 22 sides + + // Choose color for this tree + 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; + + // Determine if cell uses single color + const useSingleColor = seededRandom(cellX * 31, cellY * 31) < config.pattern.singleColor; + const cellColorIndex = Math.floor(seededRandom(cellX * 13, cellY * 13) * config.colors.length); + + // Create regular grid of circles with slight 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) { + // Offset every other row for more natural pattern + const offsetX = (Math.floor(j / config.pattern.spacing) % 2) * + (config.pattern.spacing * config.pattern.offset); + const px = x + i + offsetX; + const py = y + j; + + // Add variation to position + 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 + }; + + // Choose color for this dot + const colorIndex = useSingleColor ? cellColorIndex : + Math.floor(seededRandom(cellX * i * j, cellY * i * 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; + + // Determine base color for this cell + const colorIndex = Math.floor(seededRandom(cellX * 13, cellY * 13) * config.colors.length); + ctx.fillStyle = config.colors[colorIndex]; + + // Calculate base rotation for this cell + const baseRotation = config.pattern.rotation + + (seededRandom(cellX * 14, cellY * 14) - 0.5) * config.pattern.variation; + + // Draw tessellating triangle pattern + 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) { + // Offset every other row + const offsetX = (Math.floor(j / config.pattern.spacing) % 2) * (config.pattern.spacing / 2); + const px = x + i + offsetX; + const py = y + j; + + // Add slight position variation + const variation = { + x: (seededRandom(cellX * i, cellY * j) - 0.5) * 4, + y: (seededRandom(cellX * j, cellY * i) - 0.5) * 4 + }; + + // Draw triangle + 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; + + // Draw hatching pattern + ctx.strokeStyle = config.colors[0]; + ctx.lineWidth = 1; + + // Calculate base angle with slight variation + const baseAngle = config.hatch.angle + + (seededRandom(cellX * 20, cellY * 20) - 0.5) * config.hatch.variation; + + // Create hatching pattern + 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; + + // Add slight position variation + 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(); + } + } + } + } + } + + // Draw player + renderPlayer(); + state.footprints.forEach(footprint => { const age = (animationTime - footprint.createdAt) / CONFIG.footprints.lifetime; if (age >= 1) return; @@ -805,11 +1211,45 @@ const render = () => { ctx.restore(); }); - renderPlayer(); - ctx.restore(); }; + +// ============= Game Loop ============= +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; + }); + + // Update player direction for idle animation + 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 { + // Reset direction to base direction when not idle + state.player.direction = { ...state.player.baseDirection }; + } + } else { + // Update last input time when other actions occur + if (state.player.isSwinging || state.player.isDefending) { + state.player.lastInputTime = animationTime; + } + } +}; + const gameLoop = (currentTime) => { if (!lastFrameTime) { lastFrameTime = currentTime; @@ -830,8 +1270,10 @@ const gameLoop = (currentTime) => { requestAnimationFrame(gameLoop); }; -window.addEventListener('keydown', handleKeyDown); -window.addEventListener('keyup', handleKeyUp); + +// ============= Setup & Initialization ============= +const canvas = document.getElementById('gameCanvas'); +const ctx = canvas.getContext('2d'); const resizeCanvas = () => { GAME_WIDTH = window.innerWidth; @@ -844,51 +1286,13 @@ const resizeCanvas = () => { } }; +window.addEventListener('keydown', handleKeyDown); +window.addEventListener('keyup', handleKeyUp); window.addEventListener('resize', resizeCanvas); resizeCanvas(); - requestAnimationFrame(gameLoop); -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 }; - } - - // Update last input time when moving - 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 } : // Keep current direction while strafing - { x: normalizedDx, y: normalizedDy }; // Update direction normally - - // Update base direction when not strafing - if (!isStrafing) { - state.player.baseDirection = { ...newDirection }; - } - - return { - moving: true, - dx: normalizedDx, - dy: normalizedDy, - direction: newDirection - }; -}; - const getDotOpacity = (state, animationTime) => { // Get bubble cooldown opacity const timeSinceLastBubble = animationTime - state.player.lastBubbleTime; @@ -906,18 +1310,144 @@ const getDotOpacity = (state, animationTime) => { dashOpacity = 1 - (dashProgress * 0.7); // Fade to 0.3 during dash } - // Add blinking effect when moving or defending let blinkOpacity = 1; if (state.player.isDefending) { - // Create a slow pulse using sin wave (period of about 1.5 seconds) const blinkPhase = Math.sin(animationTime * 0.002); - // Only blink occasionally (when sin wave is above 0.7) if (blinkPhase > 0.7) { - // Quick fade out and in blinkOpacity = 0.3 + (blinkPhase - 0.7) * 2; } } // Return the lowest opacity of all systems return Math.min(bubbleOpacity, dashOpacity, blinkOpacity); +}; + +const checkCollision = (x, y, size) => { + // Check corners of player square + 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) => { + // Base angle and distance + const angle = seededRandom(cellX * 8 + i, cellY * 8) * Math.PI * 2; + + // Make clusters denser in the middle and sparser on edges + const distanceFromCenter = seededRandom(cellX * 9 + i, cellY * 9); + // Use square root to bias towards center + const distance = Math.sqrt(distanceFromCenter) * config.size.max; + + // Add some variation + 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) * + // Make items on the edge slightly 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) { + // Draw only the top 2/3 of the tree + ctx.beginPath(); + ctx.arc( + x + CONFIG.display.grid.size/2, + y + CONFIG.display.grid.size/2, + size, + -Math.PI, 0 + ); + ctx.fill(); + } else { + // Draw only the bottom 2/3 of the tree + 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 + } + }; }; \ No newline at end of file diff --git a/html/plains/index.html b/html/plains/index.html index 508e7e0..492e93f 100644 --- a/html/plains/index.html +++ b/html/plains/index.html @@ -1,7 +1,10 @@ - Top-down Adventure + Plains + + +