about summary refs log tree commit diff stats
path: root/html/plains/game.js
diff options
context:
space:
mode:
Diffstat (limited to 'html/plains/game.js')
-rw-r--r--html/plains/game.js1899
1 files changed, 1899 insertions, 0 deletions
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