// ============= 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: 'rgba(221, 221, 221, 0.5)', worldSize: 100, 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: 1000, // 1 second cooldown exhaustedAt: 0 // Track when dash was exhausted }, idle: { startDelay: 1500, // Start idle animation after 1.5 seconds lookSpeed: 0.001, // Speed of the looking animation lookRadius: 0.4 // How far to look around (in radians) } }, 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: 2, 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 } } } }; 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: 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, isSwinging: false, equipment: 'sword', bubbles: [], bubbleParticles: [], lastBubbleTime: 0, dashStartTime: 0, // When the current dash started isDashing: false, // Currently dashing? dashExhausted: false, // Is dash on cooldown? lastInputTime: 0, // Track when the last input occurred baseDirection: { x: 0, y: -1 }, lastDashEnd: 0 }, particles: [], footprints: [], lastFootprintTime: 0, camera: { x: 0, y: 0, targetX: 0, targetY: 0 }, collisionMap: new Map() }); let state = createInitialState(); // ============= 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; if (state.player.equipment === 'sword' && !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) => { const equipment = ['sword', 'unarmed']; const currentIndex = equipment.indexOf(state.player.equipment); return { ...state, player: { ...state.player, equipment: equipment[(currentIndex + 1) % equipment.length] } }; } }; // ============= Movement System ============= 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 } : // strafe { x: normalizedDx, y: normalizedDy }; // normal movement // Update base direction when not strafing 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; // 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 = { updatePosition: (state, keys) => { if (state.player.isDefending) return state; const movement = calculateMovement(keys); if (!movement.moving) { // Reset dash when not 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; // Check if dash duration is exhausted 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 }; // 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: finalX, y: finalY, direction: movement.direction, isDashing, dashStartTime, dashExhausted, lastDashEnd }, footprints: newFootprints, lastFootprintTime: timeSinceLastFootprint > currentSpacing / speed ? animationTime : state.lastFootprintTime }; } }; // ============= 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); 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 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 ] } }; }, 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 } }; } }; // ============= Particle Systems ============= 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 }); // ============= Rendering System ============= 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; // Calculate cooldown progress const timeSinceLastBubble = animationTime - state.player.lastBubbleTime; // const cooldownProgress = Math.min(timeSinceLastBubble / CONFIG.bubble.cooldown, 1); // Set opacity based on cooldown's progress 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 { // Draw player square 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 ); // Draw 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); // Set opacity based on cooldown's progress const dotOpacity = getDotOpacity(state, animationTime); ctx.fillStyle = CONFIG.player.directionIndicator.color.replace( '1)', // Replace the full opacity with our calculated opacity `${dotOpacity})` ); ctx.fillRect( dotX - dotSize/2, dotY - dotSize/2, dotSize, dotSize ); } ctx.restore(); }; 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; // 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; // 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, 0); ctx.lineTo(x, worldSize); ctx.stroke(); } // Draw horizontal lines for (let y = 0; y < worldSize; y += gridSize) { ctx.beginPath(); 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; 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(); }); 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; animationTime = 0; } const deltaTime = currentTime - lastFrameTime; if (deltaTime >= FRAME_TIME) { animationTime += FRAME_TIME; updatePlayer(); render(); lastFrameTime = currentTime; } requestAnimationFrame(gameLoop); }; // ============= Setup & Initialization ============= 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(); requestAnimationFrame(gameLoop); const getDotOpacity = (state, animationTime) => { // Get bubble cooldown opacity const timeSinceLastBubble = animationTime - state.player.lastBubbleTime; const bubbleCooldownProgress = Math.min(timeSinceLastBubble / CONFIG.bubble.cooldown, 1); const bubbleOpacity = 0.1 + (bubbleCooldownProgress * 0.9); // Get dash cooldown opacity 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); // Fade to 0.3 during dash } 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 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 } }; };